首次提交

This commit is contained in:
zeronline
2026-06-25 08:39:47 +08:00
parent 20a93c703a
commit c5cccd346b
169 changed files with 24720 additions and 0 deletions
+44
View File
@@ -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; }
+61
View File
@@ -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>
)
}
+13
View File
@@ -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>,
)
+117
View File
@@ -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>
)
}
+113
View File
@@ -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>
)
}
+46
View File
@@ -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>
)
}
+65
View File
@@ -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>
)
}
+71
View File
@@ -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>
)
}
+51
View File
@@ -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' }),
}
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />