删除股票相关功能,仅保留用户体系
This commit is contained in:
@@ -1,7 +1,6 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { NavLink, Outlet, useNavigate } from 'react-router-dom'
|
||||
import {
|
||||
LayoutDashboard,
|
||||
Users,
|
||||
User,
|
||||
LogOut,
|
||||
@@ -46,7 +45,6 @@ export function Layout() {
|
||||
}
|
||||
|
||||
const navItems = [
|
||||
{ to: '/', label: '看板', icon: LayoutDashboard },
|
||||
...(user && canManageUsers(user.role) ? [{ to: '/users', label: '用户管理', icon: Users }] : []),
|
||||
{ to: '/profile', label: '个人中心', icon: User },
|
||||
]
|
||||
@@ -70,7 +68,7 @@ export function Layout() {
|
||||
A
|
||||
</div>
|
||||
<div className="font-mono font-bold text-[13px] tracking-[0.06em] text-foreground leading-tight">
|
||||
<div>A股</div>
|
||||
<div>用户体系</div>
|
||||
<div>工作台</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -133,7 +131,7 @@ export function Layout() {
|
||||
<div className="lg:hidden flex items-center justify-between px-4 py-3 border-b border-border bg-surface shrink-0">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="h-6 w-6 rounded-md flex items-center justify-center text-white text-xs font-bold" style={{ background: BRAND }}>A</div>
|
||||
<span className="text-sm font-semibold">A股工作台</span>
|
||||
<span className="text-sm font-semibold">用户体系工作台</span>
|
||||
</div>
|
||||
<button onClick={() => setMobileOpen(!mobileOpen)} className="p-2 rounded-btn hover:bg-elevated">
|
||||
<Menu className="h-5 w-5" />
|
||||
|
||||
@@ -1,283 +0,0 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import {
|
||||
BarChart3,
|
||||
TrendingUp,
|
||||
TrendingDown,
|
||||
Minus,
|
||||
RefreshCw,
|
||||
Loader2,
|
||||
AlertCircle,
|
||||
Calendar,
|
||||
Activity,
|
||||
} from 'lucide-react'
|
||||
import { api, MarketOverview, SyncJob, StockRow } from '@/lib/api'
|
||||
import { canManageUsers } from '@/lib/auth'
|
||||
import { cn } from '@/lib/cn'
|
||||
|
||||
interface StockOverviewProps {
|
||||
role: 'system_admin' | 'admin' | 'user'
|
||||
}
|
||||
|
||||
export function StockOverview({ role }: StockOverviewProps) {
|
||||
const [overview, setOverview] = useState<MarketOverview | null>(null)
|
||||
const [job, setJob] = useState<SyncJob | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [syncing, setSyncing] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const pollRef = useRef<number | null>(null)
|
||||
|
||||
const canSync = canManageUsers(role)
|
||||
|
||||
const fetchAll = async () => {
|
||||
setLoading(true)
|
||||
setError('')
|
||||
try {
|
||||
const [o, j] = await Promise.all([api.stockOverview(), api.stockSyncStatus()])
|
||||
setOverview(o)
|
||||
setJob(j)
|
||||
} catch (err: any) {
|
||||
setError(err?.message || '加载市场数据失败')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
fetchAll()
|
||||
return () => {
|
||||
if (pollRef.current) {
|
||||
window.clearInterval(pollRef.current)
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
const startPolling = () => {
|
||||
if (pollRef.current) {
|
||||
window.clearInterval(pollRef.current)
|
||||
}
|
||||
pollRef.current = window.setInterval(async () => {
|
||||
try {
|
||||
const j = await api.stockSyncStatus()
|
||||
setJob(j)
|
||||
if (!j || j.status !== 'running') {
|
||||
if (pollRef.current) {
|
||||
window.clearInterval(pollRef.current)
|
||||
pollRef.current = null
|
||||
}
|
||||
setSyncing(false)
|
||||
await fetchAll()
|
||||
if (j?.status === 'failed') {
|
||||
setError(j.error_message || '同步失败')
|
||||
}
|
||||
}
|
||||
} catch (err: any) {
|
||||
if (pollRef.current) {
|
||||
window.clearInterval(pollRef.current)
|
||||
pollRef.current = null
|
||||
}
|
||||
setSyncing(false)
|
||||
setError(err?.message || '轮询同步状态失败')
|
||||
}
|
||||
}, 2000)
|
||||
}
|
||||
|
||||
const handleSync = async () => {
|
||||
setSyncing(true)
|
||||
setError('')
|
||||
try {
|
||||
await api.triggerStockSync()
|
||||
startPolling()
|
||||
} catch (err: any) {
|
||||
setSyncing(false)
|
||||
setError(err?.message || '同步失败')
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="rounded-card border border-border bg-surface p-6 flex items-center justify-center gap-2 text-sm text-muted">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
加载市场数据…
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!overview?.latest_trade_date) {
|
||||
return (
|
||||
<div className="rounded-card border border-border bg-surface p-6 text-center">
|
||||
<div className="text-sm text-secondary">暂无市场数据</div>
|
||||
{canSync && (
|
||||
<button
|
||||
onClick={handleSync}
|
||||
disabled={syncing}
|
||||
className="mt-3 inline-flex items-center gap-1.5 px-3 py-1.5 rounded-btn bg-accent text-white text-xs font-medium hover:bg-accent/90 disabled:opacity-60"
|
||||
>
|
||||
{syncing ? <Loader2 className="h-3 w-3 animate-spin" /> : <RefreshCw className="h-3 w-3" />}
|
||||
立即同步
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const { counts, avg_change_pct, indices, top_gainers, top_losers, turnover_leaders } = overview
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 rounded-card border border-border bg-surface/80 px-4 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<BarChart3 className="h-4 w-4 text-accent" />
|
||||
<h2 className="text-base font-semibold text-foreground">市场概览</h2>
|
||||
<span className="text-xs text-muted flex items-center gap-1">
|
||||
<Calendar className="h-3 w-3" />
|
||||
{overview.latest_trade_date}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
{job && (
|
||||
<span className="text-xs text-muted">
|
||||
上次同步:{formatSyncStatus(job)}
|
||||
</span>
|
||||
)}
|
||||
{canSync && (
|
||||
<button
|
||||
onClick={handleSync}
|
||||
disabled={syncing}
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-btn bg-accent text-white text-xs font-medium hover:bg-accent/90 disabled:opacity-60 transition-colors"
|
||||
>
|
||||
{syncing ? <Loader2 className="h-3 w-3 animate-spin" /> : <RefreshCw className="h-3 w-3" />}
|
||||
立即同步
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="flex items-start gap-2 rounded-btn border border-danger/30 bg-danger/10 px-3 py-2.5 text-xs text-danger">
|
||||
<AlertCircle className="h-3.5 w-3.5 mt-px shrink-0" />
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
<KpiCard icon={<TrendingUp className="h-4 w-4 text-bull" />} label="上涨" value={counts.up} />
|
||||
<KpiCard icon={<TrendingDown className="h-4 w-4 text-bear" />} label="下跌" value={counts.down} />
|
||||
<KpiCard icon={<Minus className="h-4 w-4 text-muted" />} label="平盘" value={counts.flat} />
|
||||
<KpiCard
|
||||
icon={<Activity className="h-4 w-4 text-accent" />}
|
||||
label="平均涨跌"
|
||||
value={`${avg_change_pct >= 0 ? '+' : ''}${(avg_change_pct * 100).toFixed(2)}%`}
|
||||
valueClass={avg_change_pct >= 0 ? 'text-bull' : 'text-bear'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{indices.length > 0 && (
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
{indices.map((item) => (
|
||||
<IndexCard key={item.symbol} item={item} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<StockList title="涨幅榜" rows={top_gainers} valueKey="change_pct" />
|
||||
<StockList title="跌幅榜" rows={top_losers} valueKey="change_pct" />
|
||||
<StockList title="成交额榜" rows={turnover_leaders} valueKey="amount" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function KpiCard({
|
||||
icon,
|
||||
label,
|
||||
value,
|
||||
valueClass,
|
||||
}: {
|
||||
icon: React.ReactNode
|
||||
label: string
|
||||
value: React.ReactNode
|
||||
valueClass?: string
|
||||
}) {
|
||||
return (
|
||||
<div className="rounded-card border border-border bg-surface p-3 flex items-center gap-3">
|
||||
<div className="p-1.5 rounded-btn bg-elevated">{icon}</div>
|
||||
<div>
|
||||
<div className={cn('text-lg font-bold text-foreground', valueClass)}>{value}</div>
|
||||
<div className="text-xs text-secondary">{label}</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function IndexCard({ item }: { item: StockRow }) {
|
||||
const positive = item.change_pct >= 0
|
||||
return (
|
||||
<div className="rounded-card border border-border bg-surface p-3">
|
||||
<div className="text-xs text-secondary truncate">{item.name || item.symbol}</div>
|
||||
<div className={cn('text-base font-semibold mt-1', positive ? 'text-bull' : 'text-bear')}>
|
||||
{item.close.toFixed(2)}
|
||||
</div>
|
||||
<div className={cn('text-xs font-medium', positive ? 'text-bull' : 'text-bear')}>
|
||||
{positive ? '+' : ''}{(item.change_pct * 100).toFixed(2)}%
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function StockList({
|
||||
title,
|
||||
rows,
|
||||
valueKey,
|
||||
}: {
|
||||
title: string
|
||||
rows: StockRow[]
|
||||
valueKey: 'change_pct' | 'amount'
|
||||
}) {
|
||||
return (
|
||||
<div className="rounded-card border border-border bg-surface p-3">
|
||||
<h3 className="text-xs font-semibold text-secondary uppercase tracking-wider mb-2">{title}</h3>
|
||||
<div className="space-y-1">
|
||||
{rows.length === 0 ? (
|
||||
<div className="text-xs text-muted py-2">暂无数据</div>
|
||||
) : (
|
||||
rows.map((item, idx) => {
|
||||
const val = valueKey === 'amount' ? (item.amount ?? 0) / 1e8 : item.change_pct * 100
|
||||
const isPositive = valueKey === 'amount' ? true : item.change_pct >= 0
|
||||
return (
|
||||
<div key={item.symbol} className="flex items-center justify-between text-sm py-1">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<span className="text-[10px] text-muted w-4">{idx + 1}</span>
|
||||
<span className="text-foreground font-medium truncate">{item.name || item.symbol}</span>
|
||||
</div>
|
||||
<div className={cn('text-xs font-mono shrink-0', valueKey === 'change_pct' ? (isPositive ? 'text-bull' : 'text-bear') : 'text-foreground')}>
|
||||
{valueKey === 'amount'
|
||||
? `${val.toFixed(2)}亿`
|
||||
: `${isPositive ? '+' : ''}${val.toFixed(2)}%`}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function formatSyncStatus(job: SyncJob): string {
|
||||
if (!job || !job.id) return '状态未知'
|
||||
if (job.status === 'running') return '同步中…'
|
||||
const ts = job.finished_at || job.started_at
|
||||
let timeStr = '时间未知'
|
||||
if (ts && ts !== '0001-01-01T00:00:00Z') {
|
||||
const d = new Date(ts)
|
||||
if (!isNaN(d.getTime())) {
|
||||
timeStr = d.toLocaleString()
|
||||
}
|
||||
}
|
||||
const statusText = job.status === 'success' ? '成功' : '失败'
|
||||
if (job.status === 'failed' && job.error_message) {
|
||||
return `${statusText} · ${timeStr} · ${job.error_message} · ${job.id.slice(0, 8)}`
|
||||
}
|
||||
return `${statusText} · ${timeStr} · ${job.id.slice(0, 8)}`
|
||||
}
|
||||
@@ -28,40 +28,6 @@ export interface AuthResponse {
|
||||
user: UserInfo
|
||||
}
|
||||
|
||||
export interface StockRow {
|
||||
symbol: string
|
||||
name: string
|
||||
close: number
|
||||
change_pct: number
|
||||
amount?: number
|
||||
}
|
||||
|
||||
export interface MarketOverview {
|
||||
latest_trade_date: string
|
||||
counts: {
|
||||
up: number
|
||||
down: number
|
||||
flat: number
|
||||
total: number
|
||||
}
|
||||
avg_change_pct: number
|
||||
indices: StockRow[]
|
||||
top_gainers: StockRow[]
|
||||
top_losers: StockRow[]
|
||||
turnover_leaders: StockRow[]
|
||||
}
|
||||
|
||||
export interface SyncJob {
|
||||
id: string
|
||||
job_date: string
|
||||
status: 'running' | 'success' | 'failed'
|
||||
records_count: number
|
||||
started_at: string
|
||||
finished_at?: string
|
||||
error_message?: string
|
||||
trigger_by: string
|
||||
}
|
||||
|
||||
export function getToken(): string | null {
|
||||
try {
|
||||
return localStorage.getItem('access_token')
|
||||
@@ -141,11 +107,4 @@ export const api = {
|
||||
request<{ message: string }>(`/api/admin/users/${id}`, { method: 'DELETE' }),
|
||||
|
||||
listRoles: () => request<Role[]>('/api/admin/roles'),
|
||||
|
||||
stockOverview: () => request<MarketOverview>('/api/stocks/overview'),
|
||||
|
||||
stockSyncStatus: () => request<SyncJob | null>('/api/stocks/sync/status'),
|
||||
|
||||
triggerStockSync: () =>
|
||||
request<{ job: SyncJob }>('/api/admin/stocks/sync', { method: 'POST' }),
|
||||
}
|
||||
|
||||
@@ -1,64 +0,0 @@
|
||||
import { LayoutDashboard, ShieldCheck } from 'lucide-react'
|
||||
import { getStoredUser, roleLabel } from '@/lib/auth'
|
||||
import { StockOverview } from '@/components/StockOverview'
|
||||
|
||||
export function Dashboard() {
|
||||
const user = getStoredUser()
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 rounded-btn bg-accent/10 text-accent">
|
||||
<LayoutDashboard className="h-5 w-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-foreground">看板</h1>
|
||||
<p className="text-sm text-secondary">欢迎回来,{user?.username || '—'}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<StockOverview role={user?.role || 'user'} />
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
<Card
|
||||
icon={<ShieldCheck className="h-5 w-5 text-accent" />}
|
||||
title="当前身份"
|
||||
value={user ? roleLabel(user.role) : '—'}
|
||||
desc={user ? `用户:${user.username}` : '加载中…'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="rounded-card border border-border bg-surface p-5">
|
||||
<h2 className="text-sm font-semibold text-foreground mb-3">权限说明</h2>
|
||||
<ul className="space-y-2 text-sm text-secondary">
|
||||
<li><span className="text-accent font-medium">系统管理员</span>:可管理管理员、用户、系统配置</li>
|
||||
<li><span className="text-accent font-medium">管理员</span>:可管理普通用户</li>
|
||||
<li><span className="text-accent font-medium">用户</span>:可访问业务功能、修改个人资料</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Card({
|
||||
icon,
|
||||
title,
|
||||
value,
|
||||
desc,
|
||||
}: {
|
||||
icon: React.ReactNode
|
||||
title: string
|
||||
value: string
|
||||
desc: string
|
||||
}) {
|
||||
return (
|
||||
<div className="rounded-card border border-border bg-surface p-4 transition hover:border-accent/30">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
{icon}
|
||||
<span className="text-xs font-medium text-secondary uppercase tracking-wider">{title}</span>
|
||||
</div>
|
||||
<div className="text-lg font-bold text-foreground">{value}</div>
|
||||
<div className="mt-1 text-xs text-muted">{desc}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -2,7 +2,6 @@ import { createBrowserRouter, Navigate } from 'react-router-dom'
|
||||
import { Layout } from './components/Layout'
|
||||
import { AuthGuard } from './components/AuthGuard'
|
||||
import { Login } from './pages/Login'
|
||||
import { Dashboard } from './pages/Dashboard'
|
||||
import { Users } from './pages/Users'
|
||||
import { Profile } from './pages/Profile'
|
||||
|
||||
@@ -16,7 +15,7 @@ export const router = createBrowserRouter([
|
||||
</AuthGuard>
|
||||
),
|
||||
children: [
|
||||
{ index: true, element: <Dashboard /> },
|
||||
{ index: true, element: <Navigate to="/profile" replace /> },
|
||||
{
|
||||
path: 'users',
|
||||
element: (
|
||||
|
||||
Reference in New Issue
Block a user