修改页面
CI / Server (Go) (push) Has been cancelled
CI / Web (Next.js) (push) Has been cancelled
CI / Mobile (React Native) (push) Has been cancelled
CI / Admin (React) (push) Has been cancelled
CI / Docker Build (push) Has been cancelled

This commit is contained in:
zeronline
2026-06-25 16:33:32 +08:00
parent c5cccd346b
commit 545329ac40
63 changed files with 2560 additions and 161 deletions
+10
View File
@@ -0,0 +1,10 @@
import { describe, it, expect } from 'vitest'
describe('Admin API placeholder', () => {
it('should have api module', async () => {
const mod = await import('../services/api')
expect(mod.api).toBeDefined()
expect(typeof mod.api.get).toBe('function')
expect(typeof mod.api.post).toBe('function')
})
})
+69
View File
@@ -0,0 +1,69 @@
import React from 'react'
interface Column<T> {
key: string
label: string
render?: (row: T) => React.ReactNode
width?: string | number
}
interface DataTableProps<T> {
columns: Column<T>[]
data: T[]
loading?: boolean
emptyText?: string
onRowClick?: (row: T) => void
}
export default function DataTable<T extends Record<string, any>>({
columns,
data,
loading = false,
emptyText = '暂无数据',
onRowClick,
}: DataTableProps<T>) {
return (
<div className="card" style={{ padding: 0, overflow: 'hidden' }}>
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead>
<tr>
{columns.map((col) => (
<th key={col.key} style={{ width: col.width }}>
{col.label}
</th>
))}
</tr>
</thead>
<tbody>
{loading ? (
<tr>
<td colSpan={columns.length} style={{ textAlign: 'center', padding: 24, color: '#86868B' }}>
...
</td>
</tr>
) : data.length === 0 ? (
<tr>
<td colSpan={columns.length} style={{ textAlign: 'center', padding: 24, color: '#86868B' }}>
{emptyText}
</td>
</tr>
) : (
data.map((row, i) => (
<tr
key={row.id || i}
onClick={() => onRowClick?.(row)}
style={{ cursor: onRowClick ? 'pointer' : 'default' }}
>
{columns.map((col) => (
<td key={col.key}>
{col.render ? col.render(row) : row[col.key] ?? '--'}
</td>
))}
</tr>
))
)}
</tbody>
</table>
</div>
)
}
+49
View File
@@ -0,0 +1,49 @@
import React from 'react'
interface PaginationProps {
page: number
totalPages: number
onChange: (page: number) => void
total?: number
}
export default function Pagination({ page, totalPages, onChange, total }: PaginationProps) {
const visiblePages = Array.from({ length: Math.min(totalPages, 7) }, (_, i) => {
const start = Math.max(1, Math.min(page - 3, totalPages - 6))
return i + Math.max(1, Math.min(page - 3, totalPages - 6))
}).filter((p) => p <= totalPages && p >= 1)
return (
<div style={{ display: 'flex', justifyContent: 'center', gap: 8, marginTop: 16, alignItems: 'center' }}>
<button
className="btn btn-secondary"
disabled={page <= 1}
onClick={() => onChange(page - 1)}
>
</button>
{visiblePages.map((p) => (
<button
key={p}
className={'btn ' + (p === page ? 'btn-primary' : 'btn-secondary')}
style={{ padding: '6px 12px', minWidth: 36 }}
onClick={() => onChange(p)}
>
{p}
</button>
))}
<button
className="btn btn-secondary"
disabled={page >= totalPages}
onClick={() => onChange(page + 1)}
>
</button>
{total !== undefined && (
<span style={{ fontSize: 13, color: '#86868B' }}>
{total} · {page}/{totalPages}
</span>
)}
</div>
)
}
+36
View File
@@ -0,0 +1,36 @@
import React from 'react'
interface StatCardProps {
title: string
value: number | string
icon?: string
color?: string
onClick?: () => void
subtitle?: string
}
export default function StatCard({ title, value, icon, color = '#FF6B35', onClick, subtitle }: StatCardProps) {
return (
<div
className="stat-card"
onClick={onClick}
style={{
background: 'white',
borderRadius: 12,
padding: 20,
cursor: onClick ? 'pointer' : 'default',
boxShadow: '0 1px 3px rgba(0,0,0,0.04)',
display: 'flex',
flexDirection: 'column',
gap: 4,
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8 }}>
{icon && <span style={{ fontSize: 24 }}>{icon}</span>}
<span style={{ fontSize: 13, color: '#86868B' }}>{title}</span>
</div>
<span style={{ fontSize: 32, fontWeight: 700, color }}>{value}</span>
{subtitle && <span style={{ fontSize: 12, color: '#86868B' }}>{subtitle}</span>}
</div>
)
}
+7 -21
View File
@@ -1,6 +1,7 @@
import { useEffect, useState } from 'react'
import { api } from '../services/api'
import { useNavigate } from 'react-router-dom'
import StatCard from '../components/StatCard'
export default function Dashboard() {
const [stats, setStats] = useState<any>(null)
@@ -37,27 +38,12 @@ export default function Dashboard() {
<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 className="stat-grid">
<StatCard title="总用户数" value={stats.totalUsers} icon="👥" onClick={() => navigate('/users')} />
<StatCard title="今日新增" value={stats.newUsersToday} icon="📈" color="#34C759" onClick={() => navigate('/users')} />
<StatCard title="总打卡次数" value={stats.totalCheckins} icon="✅" color="#7209B7" />
<StatCard title="总打卡项" value={stats.totalItems} icon="📋" />
<StatCard title="动态总数" value={stats.totalPosts} icon="🌊" color="#4CC9F0" onClick={() => navigate('/posts')} />
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 16 }}>
+45 -38
View File
@@ -1,5 +1,7 @@
import { useEffect, useState } from 'react'
import { useEffect, useState, useCallback } from 'react'
import { api } from '../services/api'
import DataTable from '../components/DataTable'
import Pagination from '../components/Pagination'
const PAGE_SIZE = 15
@@ -10,7 +12,7 @@ export default function Posts() {
const [loading, setLoading] = useState(true)
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE))
useEffect(() => {
const loadPosts = useCallback(() => {
setLoading(true)
api.get<any>('/api/admin/posts?page=' + page + '&pageSize=' + PAGE_SIZE)
.then(data => { setPosts(data.list || []); setTotal(data.total || 0) })
@@ -18,48 +20,53 @@ export default function Posts() {
.finally(() => setLoading(false))
}, [page])
useEffect(() => { loadPosts() }, [loadPosts])
const handleHide = async (id: string) => {
if (!confirm('确定隐藏该动态?隐藏后用户将不可见。')) return
try {
await api.delete('/api/social/posts/' + id)
loadPosts()
} catch {
alert('操作失败')
}
}
const columns = [
{
key: 'content',
label: '内容',
render: (row: any) => (
<span style={{ maxWidth: 300, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', display: 'inline-block' }}>
{row.content || '(无文字内容)'}
</span>
),
},
{ key: 'user', label: '用户', render: (row: any) => row.user?.nickname || '--' },
{ key: 'likes', label: '点赞' },
{ key: 'comments', label: '评论' },
{ key: 'createdAt', label: '时间', render: (row: any) => row.createdAt?.slice(0, 10) || '--' },
{
key: 'actions',
label: '操作',
render: (row: any) => (
<button className="btn btn-danger" style={{ padding: '4px 12px', fontSize: 12 }}
onClick={(e) => { e.stopPropagation(); handleHide(row.id) }}>
</button>
),
},
]
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>
<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>
<DataTable columns={columns} data={posts} loading={loading} emptyText="暂无动态" />
<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>
<Pagination page={page} totalPages={totalPages} onChange={setPage} total={total} />
</div>
)
}
+16
View File
@@ -0,0 +1,16 @@
import { defineConfig } from 'vitest/config'
import path from 'path'
export default defineConfig({
test: {
environment: 'node',
globals: true,
include: ['src/**/*.test.ts', 'src/**/*.test.tsx'],
setupFiles: [],
},
resolve: {
alias: {
'@': path.resolve(__dirname, 'src'),
},
},
})