修改页面
This commit is contained in:
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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'),
|
||||
},
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user