首次提交
This commit is contained in:
@@ -0,0 +1,44 @@
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, 'SF Pro', 'PingFang SC', sans-serif; }
|
||||
|
||||
.card {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
padding: 20px;
|
||||
margin-bottom: 16px;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.06);
|
||||
}
|
||||
|
||||
.page-title { font-size: 22px; font-weight: 700; margin-bottom: 20px; }
|
||||
|
||||
input, select {
|
||||
width: 100%;
|
||||
padding: 10px 14px;
|
||||
border: 1px solid #E5E5EA;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
outline: none;
|
||||
font-family: inherit;
|
||||
}
|
||||
input:focus { border-color: #FF6B35; }
|
||||
|
||||
.btn { padding: 10px 20px; border: none; border-radius: 8px; font-size: 14px; cursor: pointer; font-weight: 600; }
|
||||
.btn-primary { background: #FF6B35; color: white; }
|
||||
.btn-primary:hover { opacity: 0.9; }
|
||||
.btn-danger { background: #FF3B30; color: white; }
|
||||
.btn-secondary { background: #f0f0f0; color: #333; }
|
||||
.btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
|
||||
table { width: 100%; border-collapse: collapse; font-size: 14px; }
|
||||
th { text-align: left; padding: 12px 8px; color: #86868B; font-weight: 600; font-size: 12px; text-transform: uppercase; border-bottom: 1px solid #E5E5EA; }
|
||||
td { padding: 12px 8px; border-bottom: 1px solid #F0F0F0; }
|
||||
tr:hover td { background: #FAFAFA; }
|
||||
|
||||
.stat-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 16px; margin-bottom: 24px; }
|
||||
.stat-card { background: white; border-radius: 12px; padding: 20px; box-shadow: 0 1px 3px rgba(0,0,0,0.06); }
|
||||
.stat-value { font-size: 28px; font-weight: 700; color: #FF6B35; margin-bottom: 4px; }
|
||||
.stat-label { font-size: 13px; color: #86868B; }
|
||||
.badge { display: inline-block; padding: 2px 8px; border-radius: 4px; font-size: 11px; font-weight: 600; }
|
||||
.badge-green { background: #E8F5E9; color: #2E7D32; }
|
||||
.badge-red { background: #FFEBEE; color: #C62828; }
|
||||
.badge-gray { background: #F5F5F5; color: #616161; }
|
||||
@@ -0,0 +1,61 @@
|
||||
import { Routes, Route, Navigate, Link, useLocation, useNavigate } from 'react-router-dom'
|
||||
import { isLoggedIn, clearToken } from './services/api'
|
||||
import Login from './pages/Login'
|
||||
import Dashboard from './pages/Dashboard'
|
||||
import Users from './pages/Users'
|
||||
import Posts from './pages/Posts'
|
||||
import Admins from './pages/Admins'
|
||||
|
||||
function Layout({ children }: { children: React.ReactNode }) {
|
||||
const location = useLocation()
|
||||
const navigate = useNavigate()
|
||||
const nav = [
|
||||
{ path: '/dashboard', label: '数据概览' },
|
||||
{ path: '/users', label: '用户管理' },
|
||||
{ path: '/posts', label: '动态审核' },
|
||||
{ path: '/admins', label: '管理员' },
|
||||
]
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', minHeight: '100vh' }}>
|
||||
<aside style={{ width: 220, background: '#1D1D1F', color: 'white', padding: 20, display: 'flex', flexDirection: 'column' }}>
|
||||
<h2 style={{ fontSize: 18, marginBottom: 24, cursor: 'pointer' }} onClick={() => navigate('/dashboard')}>寸进管理</h2>
|
||||
<nav style={{ flex: 1, display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||
{nav.map(n => (
|
||||
<Link key={n.path} to={n.path}
|
||||
style={{
|
||||
display: 'block', padding: '10px 12px', borderRadius: 8, textDecoration: 'none',
|
||||
color: location.pathname === n.path ? '#FF6B35' : '#ccc',
|
||||
background: location.pathname === n.path ? '#ffffff15' : 'transparent',
|
||||
fontSize: 14, fontWeight: location.pathname === n.path ? 600 : 400,
|
||||
}}>
|
||||
{n.label}
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
<button onClick={() => { clearToken(); navigate('/') }}
|
||||
style={{ padding: '10px 12px', borderRadius: 8, border: 'none', background: '#ffffff15', color: '#ccc', cursor: 'pointer', fontSize: 14 }}>
|
||||
退出登录
|
||||
</button>
|
||||
</aside>
|
||||
<main style={{ flex: 1, background: '#F5F5F7', padding: 24, overflow: 'auto' }}>{children}</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
||||
if (!isLoggedIn()) return <Navigate to="/" replace />
|
||||
return <>{children}</>
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="/" element={isLoggedIn() ? <Navigate to="/dashboard" replace /> : <Login />} />
|
||||
<Route path="/dashboard" element={<ProtectedRoute><Layout><Dashboard /></Layout></ProtectedRoute>} />
|
||||
<Route path="/users" element={<ProtectedRoute><Layout><Users /></Layout></ProtectedRoute>} />
|
||||
<Route path="/posts" element={<ProtectedRoute><Layout><Posts /></Layout></ProtectedRoute>} />
|
||||
<Route path="/admins" element={<ProtectedRoute><Layout><Admins /></Layout></ProtectedRoute>} />
|
||||
</Routes>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import { BrowserRouter } from 'react-router-dom'
|
||||
import App from './App'
|
||||
import './App.css'
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
</React.StrictMode>,
|
||||
)
|
||||
@@ -0,0 +1,117 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { api } from '../services/api'
|
||||
|
||||
export default function Admins() {
|
||||
const [admins, setAdmins] = useState<any[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [showCreate, setShowCreate] = useState(false)
|
||||
const [username, setUsername] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [nickname, setNickname] = useState('')
|
||||
const [role, setRole] = useState('admin')
|
||||
const [creating, setCreating] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
const loadAdmins = () => {
|
||||
setLoading(true)
|
||||
api.get<any[]>('/api/admin/admins')
|
||||
.then(setAdmins)
|
||||
.catch(() => setAdmins([]))
|
||||
.finally(() => setLoading(false))
|
||||
}
|
||||
|
||||
useEffect(() => { loadAdmins() }, [])
|
||||
|
||||
async function handleCreate() {
|
||||
if (!username.trim() || !password.trim()) { setError('用户名和密码不能为空'); return }
|
||||
setCreating(true); setError('')
|
||||
try {
|
||||
await api.post('/api/admin/admins', { username: username.trim(), password, nickname: nickname.trim() || undefined, role })
|
||||
setShowCreate(false); setUsername(''); setPassword(''); setNickname(''); setRole('admin')
|
||||
loadAdmins()
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '创建失败')
|
||||
} finally { setCreating(false) }
|
||||
}
|
||||
|
||||
async function handleDisable(id: string) {
|
||||
if (!confirm('确定禁用该管理员?')) return
|
||||
try {
|
||||
await api.delete('/api/admin/admins/' + id)
|
||||
loadAdmins()
|
||||
} catch { alert('操作失败') }
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 20 }}>
|
||||
<h1 className='page-title' style={{ margin: 0 }}>管理员管理</h1>
|
||||
<button className='btn btn-primary' onClick={() => setShowCreate(!showCreate)}>
|
||||
{showCreate ? '取消' : '+ 新增管理员'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 创建表单 */}
|
||||
{showCreate && (
|
||||
<div className='card' style={{ marginBottom: 16 }}>
|
||||
<h3 style={{ marginBottom: 12 }}>新增管理员</h3>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12, marginBottom: 12 }}>
|
||||
<div>
|
||||
<label style={{ fontSize: 13, color: '#86868B', marginBottom: 4, display: 'block' }}>用户名 *</label>
|
||||
<input value={username} onChange={e => setUsername(e.target.value)} placeholder='登录用户名' />
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ fontSize: 13, color: '#86868B', marginBottom: 4, display: 'block' }}>密码 *</label>
|
||||
<input type='password' value={password} onChange={e => setPassword(e.target.value)} placeholder='至少 8 位' />
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ fontSize: 13, color: '#86868B', marginBottom: 4, display: 'block' }}>昵称</label>
|
||||
<input value={nickname} onChange={e => setNickname(e.target.value)} placeholder='显示名称' />
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ fontSize: 13, color: '#86868B', marginBottom: 4, display: 'block' }}>角色</label>
|
||||
<select value={role} onChange={e => setRole(e.target.value)}>
|
||||
<option value='admin'>普通管理员</option>
|
||||
<option value='super_admin'>超级管理员</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
{error && <p style={{ fontSize: 13, color: '#FF3B30', marginBottom: 12 }}>{error}</p>}
|
||||
<button className='btn btn-primary' onClick={handleCreate} disabled={creating}>
|
||||
{creating ? '创建中...' : '确认创建'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 管理员列表 */}
|
||||
<div className='card' style={{ padding: 0, overflow: 'hidden' }}>
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>用户名</th><th>昵称</th><th>角色</th><th>状态</th><th>创建时间</th><th>操作</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{loading ? (
|
||||
<tr><td colSpan={6} style={{ textAlign: 'center', padding: 24, color: '#86868B' }}>加载中...</td></tr>
|
||||
) : admins.length === 0 ? (
|
||||
<tr><td colSpan={6} style={{ textAlign: 'center', padding: 24, color: '#86868B' }}>暂无管理员</td></tr>
|
||||
) : admins.map((a: any) => (
|
||||
<tr key={a.id}>
|
||||
<td style={{ fontFamily: 'monospace' }}>{a.username}</td>
|
||||
<td>{a.nickname || '--'}</td>
|
||||
<td><span className={'badge ' + (a.role === 'super_admin' ? 'badge-green' : 'badge-gray')}>{a.role === 'super_admin' ? '超级管理员' : '管理员'}</span></td>
|
||||
<td><span className={'badge ' + (a.status === 'active' ? 'badge-green' : 'badge-red')}>{a.status === 'active' ? '正常' : '已禁用'}</span></td>
|
||||
<td style={{ fontSize: 12 }}>{a.createdAt?.slice(0, 10) || '--'}</td>
|
||||
<td>
|
||||
{a.status === 'active' && a.role !== 'super_admin' && (
|
||||
<button className='btn btn-danger' style={{ padding: '4px 12px', fontSize: 12 }} onClick={() => handleDisable(a.id)}>禁用</button>
|
||||
)}
|
||||
{(a.status !== 'active' || a.role === 'super_admin') && <span style={{ fontSize: 12, color: '#86868B' }}>--</span>}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { api } from '../services/api'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
|
||||
export default function Dashboard() {
|
||||
const [stats, setStats] = useState<any>(null)
|
||||
const [recentUsers, setRecentUsers] = useState<any[]>([])
|
||||
const [recentPosts, setRecentPosts] = useState<any[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const navigate = useNavigate()
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([
|
||||
api.get<any>('/api/admin/dashboard'),
|
||||
api.get<any>('/api/admin/users?page=1&pageSize=5'),
|
||||
api.get<any>('/api/admin/posts?page=1&pageSize=5'),
|
||||
]).then(([dashData, usersData, postsData]) => {
|
||||
setStats({
|
||||
totalUsers: dashData.totalUsers || 0,
|
||||
totalPosts: dashData.totalPosts || 0,
|
||||
totalCheckins: dashData.totalRecords || 0,
|
||||
totalItems: dashData.totalItems || 0,
|
||||
newUsersToday: dashData.newUsersToday || 0,
|
||||
})
|
||||
setRecentUsers(usersData.list || [])
|
||||
setRecentPosts(postsData.list || [])
|
||||
}).catch(() => {
|
||||
setStats({ totalUsers: 0, totalPosts: 0, totalCheckins: 0, totalItems: 0, newUsersToday: 0 })
|
||||
}).finally(() => setLoading(false))
|
||||
}, [])
|
||||
|
||||
if (loading) return <div style={{ textAlign: 'center', padding: 40, color: '#86868B' }}>加载中...</div>
|
||||
if (!stats) return <div style={{ textAlign: 'center', padding: 40, color: '#86868B' }}>暂无数据</div>
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className='page-title'>数据概览</h1>
|
||||
|
||||
{/* 统计卡片 */}
|
||||
<div className='stat-grid'>
|
||||
<div className='stat-card' onClick={() => navigate('/users')} style={{ cursor: 'pointer' }}>
|
||||
<div className='stat-value'>{stats.totalUsers}</div>
|
||||
<div className='stat-label'>总用户数</div>
|
||||
</div>
|
||||
<div className='stat-card' onClick={() => navigate('/users')} style={{ cursor: 'pointer' }}>
|
||||
<div className='stat-value'>{stats.newUsersToday}</div>
|
||||
<div className='stat-label'>今日新增</div>
|
||||
</div>
|
||||
<div className='stat-card'>
|
||||
<div className='stat-value'>{stats.totalCheckins}</div>
|
||||
<div className='stat-label'>总打卡次数</div>
|
||||
</div>
|
||||
<div className='stat-card'>
|
||||
<div className='stat-value'>{stats.totalItems}</div>
|
||||
<div className='stat-label'>总打卡项</div>
|
||||
</div>
|
||||
<div className='stat-card' onClick={() => navigate('/posts')} style={{ cursor: 'pointer' }}>
|
||||
<div className='stat-value'>{stats.totalPosts}</div>
|
||||
<div className='stat-label'>动态总数</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 16 }}>
|
||||
{/* 最近用户 */}
|
||||
<div className='card'>
|
||||
<h3 style={{ marginBottom: 12, display: 'flex', justifyContent: 'space-between' }}>
|
||||
<span>最近注册</span>
|
||||
<a href='/users' style={{ fontSize: 13, color: '#FF6B35', textDecoration: 'none' }}>查看全部 →</a>
|
||||
</h3>
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>昵称</th><th>手机号</th><th>积分</th><th>时间</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{recentUsers.map((u: any) => (
|
||||
<tr key={u.id}>
|
||||
<td>{u.nickname || '--'}</td>
|
||||
<td>{u.phone || '--'}</td>
|
||||
<td>{u.points}</td>
|
||||
<td style={{ fontSize: 12 }}>{u.createdAt?.slice(0, 10) || '--'}</td>
|
||||
</tr>
|
||||
))}
|
||||
{recentUsers.length === 0 && <tr><td colSpan={4} style={{ textAlign: 'center', color: '#86868B' }}>暂无用户</td></tr>}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* 最近动态 */}
|
||||
<div className='card'>
|
||||
<h3 style={{ marginBottom: 12, display: 'flex', justifyContent: 'space-between' }}>
|
||||
<span>最新动态</span>
|
||||
<a href='/posts' style={{ fontSize: 13, color: '#FF6B35', textDecoration: 'none' }}>查看全部 →</a>
|
||||
</h3>
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>内容</th><th>用户</th><th>时间</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{recentPosts.map((p: any) => (
|
||||
<tr key={p.id}>
|
||||
<td style={{ maxWidth: 200, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{p.content || '--'}</td>
|
||||
<td>{p.user?.nickname || '--'}</td>
|
||||
<td style={{ fontSize: 12 }}>{p.createdAt?.slice(0, 10) || '--'}</td>
|
||||
</tr>
|
||||
))}
|
||||
{recentPosts.length === 0 && <tr><td colSpan={3} style={{ textAlign: 'center', color: '#86868B' }}>暂无动态</td></tr>}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { useState, type FormEvent } from 'react'
|
||||
import { api, setToken } from '../services/api'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
|
||||
export default function Login() {
|
||||
const [phone, setPhone] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const navigate = useNavigate()
|
||||
|
||||
async function handleSubmit(e: FormEvent) {
|
||||
e.preventDefault()
|
||||
if (phone.length !== 11) { setError('请输入正确的手机号'); return }
|
||||
setLoading(true)
|
||||
setError('')
|
||||
try {
|
||||
const result = await api.post<{ user: any; token: string }>('/api/admin/auth/login', { phone })
|
||||
setToken(result.token)
|
||||
localStorage.setItem('admin_user', JSON.stringify(result.user))
|
||||
navigate('/dashboard')
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '登录失败')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ minHeight: '100vh', display: 'flex', alignItems: 'center', justifyContent: 'center', background: '#F5F5F7' }}>
|
||||
<div className="card" style={{ width: 360, padding: 32 }}>
|
||||
<h1 style={{ fontSize: 24, fontWeight: 700, marginBottom: 4, textAlign: 'center' }}>寸进管理后台</h1>
|
||||
<p style={{ fontSize: 14, color: '#86868B', marginBottom: 24, textAlign: 'center' }}>运营管理平台</p>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<label style={{ fontSize: 13, color: '#86868B', marginBottom: 6, display: 'block' }}>管理员手机号</label>
|
||||
<input type="tel" value={phone} onChange={e => setPhone(e.target.value.replace(/\D/g, ''))}
|
||||
placeholder="输入手机号" maxLength={11} style={{ marginBottom: 16 }} />
|
||||
{error && <p style={{ fontSize: 13, color: '#FF3B30', marginBottom: 12 }}>{error}</p>}
|
||||
<button className="btn btn-primary" type="submit" disabled={loading} style={{ width: '100%', padding: 12, fontSize: 15 }}>
|
||||
{loading ? '登录中...' : '登录'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { api } from '../services/api'
|
||||
|
||||
const PAGE_SIZE = 15
|
||||
|
||||
export default function Posts() {
|
||||
const [posts, setPosts] = useState<any[]>([])
|
||||
const [total, setTotal] = useState(0)
|
||||
const [page, setPage] = useState(1)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE))
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true)
|
||||
api.get<any>('/api/admin/posts?page=' + page + '&pageSize=' + PAGE_SIZE)
|
||||
.then(data => { setPosts(data.list || []); setTotal(data.total || 0) })
|
||||
.catch(() => { setPosts([]); setTotal(0) })
|
||||
.finally(() => setLoading(false))
|
||||
}, [page])
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 20 }}>
|
||||
<h1 className='page-title' style={{ margin: 0 }}>动态审核 <span style={{ fontSize: 14, fontWeight: 400, color: '#86868B' }}>共 {total} 条</span></h1>
|
||||
</div>
|
||||
|
||||
<div className='card' style={{ padding: 0, overflow: 'hidden' }}>
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>内容</th><th>用户</th><th>点赞</th><th>评论</th><th>时间</th><th>操作</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{loading ? (
|
||||
<tr><td colSpan={6} style={{ textAlign: 'center', padding: 24, color: '#86868B' }}>加载中...</td></tr>
|
||||
) : posts.length === 0 ? (
|
||||
<tr><td colSpan={6} style={{ textAlign: 'center', padding: 24, color: '#86868B' }}>暂无动态</td></tr>
|
||||
) : posts.map((p: any) => (
|
||||
<tr key={p.id}>
|
||||
<td style={{ maxWidth: 300, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{p.content || '(无文字内容)'}
|
||||
</td>
|
||||
<td>{p.user?.nickname || '--'}</td>
|
||||
<td>{p.likes}</td>
|
||||
<td>{p.comments}</td>
|
||||
<td style={{ fontSize: 12 }}>{p.createdAt?.slice(0, 10) || '--'}</td>
|
||||
<td>
|
||||
<button className='btn btn-danger' style={{ padding: '4px 12px', fontSize: 12 }}
|
||||
onClick={() => { if (confirm('确定隐藏该动态?')) { /* TODO */ alert('已隐藏') } }}>
|
||||
隐藏
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', justifyContent: 'center', gap: 8, marginTop: 16, alignItems: 'center' }}>
|
||||
<button className='btn btn-secondary' disabled={page <= 1} onClick={() => setPage(p => p - 1)}>上一页</button>
|
||||
<span style={{ fontSize: 14 }}>第 {page}/{totalPages} 页</span>
|
||||
<button className='btn btn-secondary' disabled={page >= totalPages} onClick={() => setPage(p => p + 1)}>下一页</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { api } from '../services/api'
|
||||
|
||||
const PAGE_SIZE = 15
|
||||
|
||||
export default function Users() {
|
||||
const [users, setUsers] = useState<any[]>([])
|
||||
const [total, setTotal] = useState(0)
|
||||
const [page, setPage] = useState(1)
|
||||
const [search, setSearch] = useState('')
|
||||
const [loading, setLoading] = useState(true)
|
||||
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE))
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true)
|
||||
const query = search ? "&search=" + search : ""
|
||||
api.get<any>('/api/admin/users?page=' + page + '&pageSize=' + PAGE_SIZE + query)
|
||||
.then(data => { setUsers(data.list || []); setTotal(data.total || 0) })
|
||||
.catch(() => { setUsers([]); setTotal(0) })
|
||||
.finally(() => setLoading(false))
|
||||
}, [page, search])
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 20 }}>
|
||||
<h1 className='page-title' style={{ margin: 0 }}>用户管理 <span style={{ fontSize: 14, fontWeight: 400, color: '#86868B' }}>共 {total} 人</span></h1>
|
||||
<input value={search} onChange={e => { setSearch(e.target.value); setPage(1) }}
|
||||
placeholder='搜索昵称/手机号...' style={{ width: 240 }} />
|
||||
</div>
|
||||
|
||||
<div className='card' style={{ padding: 0, overflow: 'hidden' }}>
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>ID</th><th>昵称</th><th>手机号</th><th>积分</th><th>注册时间</th><th>操作</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{loading ? (
|
||||
<tr><td colSpan={6} style={{ textAlign: 'center', padding: 24, color: '#86868B' }}>加载中...</td></tr>
|
||||
) : users.length === 0 ? (
|
||||
<tr><td colSpan={6} style={{ textAlign: 'center', padding: 24, color: '#86868B' }}>暂无数据</td></tr>
|
||||
) : users.map((u: any) => (
|
||||
<tr key={u.id}>
|
||||
<td style={{ fontFamily: 'monospace', fontSize: 12 }}>{u.id.slice(0, 8)}...</td>
|
||||
<td>{u.nickname || '--'}</td>
|
||||
<td>{u.phone || '--'}</td>
|
||||
<td>{u.points}</td>
|
||||
<td style={{ fontSize: 12 }}>{u.createdAt?.slice(0, 10) || '--'}</td>
|
||||
<td><span className='badge badge-green'>正常</span></td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* 分页 */}
|
||||
<div style={{ display: 'flex', justifyContent: 'center', gap: 8, marginTop: 16, alignItems: 'center' }}>
|
||||
<button className='btn btn-secondary' disabled={page <= 1} onClick={() => setPage(p => p - 1)}>上一页</button>
|
||||
{Array.from({ length: Math.min(totalPages, 7) }, (_, i) => {
|
||||
const p = i + 1
|
||||
return <button key={p} className={'btn ' + (p === page ? 'btn-primary' : 'btn-secondary')}
|
||||
style={{ padding: '6px 12px', minWidth: 36 }}
|
||||
onClick={() => setPage(p)}>{p}</button>
|
||||
})}
|
||||
<button className='btn btn-secondary' disabled={page >= totalPages} onClick={() => setPage(p => p + 1)}>下一页</button>
|
||||
<span style={{ fontSize: 13, color: '#86868B' }}>第 {page}/{totalPages} 页</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:8080'
|
||||
|
||||
interface ApiResponse<T = unknown> {
|
||||
code: number
|
||||
message: string
|
||||
data?: T
|
||||
}
|
||||
|
||||
function getToken(): string | null {
|
||||
return localStorage.getItem('admin_token')
|
||||
}
|
||||
|
||||
export function setToken(token: string) {
|
||||
localStorage.setItem('admin_token', token)
|
||||
localStorage.setItem('admin_token_time', String(Date.now()))
|
||||
}
|
||||
|
||||
export function clearToken() {
|
||||
localStorage.removeItem('admin_token')
|
||||
localStorage.removeItem('admin_token_time')
|
||||
localStorage.removeItem('admin_user')
|
||||
}
|
||||
|
||||
export function isLoggedIn(): boolean {
|
||||
return !!getToken()
|
||||
}
|
||||
|
||||
export async function request<T>(path: string, options: RequestInit = {}): Promise<T> {
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
...(options.headers as Record<string, string>),
|
||||
}
|
||||
const token = getToken()
|
||||
if (token) headers['Authorization'] = `Bearer ${token}`
|
||||
|
||||
const res = await fetch(`${API_URL}${path}`, { ...options, headers })
|
||||
const body: ApiResponse<T> = await res.json()
|
||||
|
||||
if (body.code !== 0) {
|
||||
if (body.code === 401) { clearToken(); window.location.href = '/' }
|
||||
throw new Error(body.message)
|
||||
}
|
||||
return body.data as T
|
||||
}
|
||||
|
||||
export const api = {
|
||||
get: <T>(path: string) => request<T>(path),
|
||||
post: <T>(path: string, data?: unknown) => request<T>(path, { method: 'POST', body: JSON.stringify(data) }),
|
||||
put: <T>(path: string, data?: unknown) => request<T>(path, { method: 'PUT', body: JSON.stringify(data) }),
|
||||
delete: <T>(path: string) => request<T>(path, { method: 'DELETE' }),
|
||||
}
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
Reference in New Issue
Block a user