1
This commit is contained in:
parent
55318c54e8
commit
153dcc2f70
219
frontend/src/app/(auth)/admin/invites/page.tsx
Normal file
219
frontend/src/app/(auth)/admin/invites/page.tsx
Normal file
@ -0,0 +1,219 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { createInviteCodeAPI, listInviteCodesAPI, toggleInviteCodeAPI, type InviteCodeItem } from "@/lib/api";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
|
||||
export default function AdminInvitesPage() {
|
||||
const { user } = useAuth();
|
||||
const [invites, setInvites] = useState<InviteCodeItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [message, setMessage] = useState("");
|
||||
const [inviteCode, setInviteCode] = useState("");
|
||||
const [inviteDesc, setInviteDesc] = useState("");
|
||||
const [maxUses, setMaxUses] = useState(10);
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
if (user?.role !== "admin") return;
|
||||
setLoading(true);
|
||||
setMessage("");
|
||||
try {
|
||||
setInvites(await listInviteCodesAPI());
|
||||
} catch (error) {
|
||||
setMessage(error instanceof Error ? error.message : "邀请码数据加载失败");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [user?.role]);
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, [loadData]);
|
||||
|
||||
const stats = useMemo(() => {
|
||||
const active = invites.filter((item) => item.is_active).length;
|
||||
const used = invites.reduce((sum, item) => sum + (item.used_count || 0), 0);
|
||||
const capacity = invites.reduce((sum, item) => sum + (item.max_uses || 0), 0);
|
||||
return { active, disabled: invites.length - active, used, remaining: Math.max(0, capacity - used) };
|
||||
}, [invites]);
|
||||
|
||||
const handleCreateInvite = async (event: React.FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
const code = inviteCode.trim().toUpperCase();
|
||||
if (!code) {
|
||||
setMessage("请填写邀请码");
|
||||
return;
|
||||
}
|
||||
setMessage("");
|
||||
await createInviteCodeAPI(code, inviteDesc.trim(), maxUses);
|
||||
setInviteCode("");
|
||||
setInviteDesc("");
|
||||
setMaxUses(10);
|
||||
setMessage("邀请码已创建");
|
||||
await loadData();
|
||||
};
|
||||
|
||||
const handleToggleInvite = async (item: InviteCodeItem) => {
|
||||
setMessage("");
|
||||
await toggleInviteCodeAPI(item.id);
|
||||
setMessage(item.is_active ? "邀请码已停用" : "邀请码已启用");
|
||||
await loadData();
|
||||
};
|
||||
|
||||
if (user?.role !== "admin") {
|
||||
return <NoAccess />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-7xl mx-auto px-4 md:px-8 pt-6 pb-20 md:pb-10 space-y-5">
|
||||
<Link href="/admin" className="inline-flex items-center gap-1.5 text-xs text-text-muted hover:text-text-primary transition-colors">
|
||||
← 返回系统管理
|
||||
</Link>
|
||||
|
||||
<header className="flex flex-col gap-3 md:flex-row md:items-end md:justify-between">
|
||||
<div>
|
||||
<div className="text-[10px] font-semibold uppercase tracking-[0.22em] text-amber-400">Invitations</div>
|
||||
<h1 className="mt-2 text-2xl font-bold tracking-tight text-text-primary">邀请码管理</h1>
|
||||
<p className="mt-1 text-sm text-text-muted">控制注册入口、发放额度和邀请码启停。</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={loadData}
|
||||
className="rounded-xl border border-border-subtle bg-surface-2 px-4 py-2 text-xs text-text-secondary hover:text-text-primary"
|
||||
>
|
||||
刷新邀请码
|
||||
</button>
|
||||
</header>
|
||||
|
||||
{message ? <div className="glass-card-static border-amber-500/15 px-4 py-2.5 text-xs text-amber-400">{message}</div> : null}
|
||||
|
||||
<section className="grid grid-cols-2 gap-3 lg:grid-cols-4">
|
||||
<StatCard label="总邀请码" value={loading ? "-" : invites.length} />
|
||||
<StatCard label="启用" value={loading ? "-" : stats.active} />
|
||||
<StatCard label="已注册" value={loading ? "-" : stats.used} />
|
||||
<StatCard label="剩余名额" value={loading ? "-" : stats.remaining} />
|
||||
</section>
|
||||
|
||||
<section className="grid grid-cols-1 gap-5 xl:grid-cols-[420px_minmax(0,1fr)]">
|
||||
<form onSubmit={handleCreateInvite} className="glass-card-static p-4">
|
||||
<h2 className="text-sm font-semibold text-text-primary">新建邀请码</h2>
|
||||
<div className="mt-4 space-y-3">
|
||||
<label className="block">
|
||||
<span className="text-[11px] text-text-muted">邀请码</span>
|
||||
<input
|
||||
value={inviteCode}
|
||||
onChange={(event) => setInviteCode(event.target.value)}
|
||||
placeholder="ASTOCK-ACCESS"
|
||||
className="mt-1 w-full rounded-xl border border-border-subtle bg-surface-2 px-3 py-2 text-sm text-text-primary outline-none focus:border-amber-500/30"
|
||||
/>
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="text-[11px] text-text-muted">说明</span>
|
||||
<input
|
||||
value={inviteDesc}
|
||||
onChange={(event) => setInviteDesc(event.target.value)}
|
||||
placeholder="用途或发放对象"
|
||||
className="mt-1 w-full rounded-xl border border-border-subtle bg-surface-2 px-3 py-2 text-sm text-text-primary outline-none focus:border-amber-500/30"
|
||||
/>
|
||||
</label>
|
||||
<div className="grid grid-cols-[1fr_120px] gap-2">
|
||||
<label className="block">
|
||||
<span className="text-[11px] text-text-muted">可用次数</span>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={999}
|
||||
value={maxUses}
|
||||
onChange={(event) => setMaxUses(Number(event.target.value) || 1)}
|
||||
className="mt-1 w-full rounded-xl border border-border-subtle bg-surface-2 px-3 py-2 text-sm text-text-primary outline-none focus:border-amber-500/30"
|
||||
/>
|
||||
</label>
|
||||
<button className="mt-5 rounded-xl bg-amber-500 px-4 py-2 text-sm font-semibold text-black hover:bg-amber-400">
|
||||
创建
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div className="glass-card-static overflow-hidden">
|
||||
<div className="flex items-center justify-between border-b border-border-subtle px-4 py-3">
|
||||
<h2 className="text-sm font-semibold text-text-primary">邀请码列表</h2>
|
||||
<span className="font-mono text-xs text-text-muted">{invites.length}</span>
|
||||
</div>
|
||||
{loading ? (
|
||||
<div className="space-y-3 p-4">
|
||||
{[1, 2, 3, 4].map((item) => <div key={item} className="h-20 animate-shimmer rounded-xl bg-surface-2" />)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-border-subtle">
|
||||
{invites.map((item) => (
|
||||
<InviteRow key={item.id} item={item} onToggle={() => handleToggleInvite(item)} />
|
||||
))}
|
||||
{!invites.length ? <div className="p-6 text-center text-sm text-text-muted">暂无邀请码</div> : null}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function NoAccess() {
|
||||
return (
|
||||
<div className="max-w-5xl mx-auto px-4 md:px-8 pt-8 pb-20">
|
||||
<div className="glass-card-static p-6 text-sm text-text-secondary">当前账号没有邀请码管理权限。</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatCard({ label, value }: { label: string; value: string | number }) {
|
||||
return (
|
||||
<div className="glass-card-static p-4">
|
||||
<div className="text-[10px] text-text-muted">{label}</div>
|
||||
<div className="mt-2 font-mono text-2xl font-bold tabular-nums text-text-primary">{value}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function InviteRow({ item, onToggle }: { item: InviteCodeItem; onToggle: () => void }) {
|
||||
const remaining = Math.max(0, (item.max_uses || 0) - (item.used_count || 0));
|
||||
return (
|
||||
<article className="grid gap-3 px-4 py-3 md:grid-cols-[minmax(0,1fr)_260px_80px] md:items-center">
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="font-mono text-sm font-semibold text-text-primary">{item.code}</span>
|
||||
<StatusBadge active={item.is_active} />
|
||||
</div>
|
||||
<div className="mt-1 line-clamp-1 text-[11px] text-text-muted">{item.description || "无说明"}</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-2 text-center text-[11px]">
|
||||
<MiniMetric label="已用" value={item.used_count || 0} />
|
||||
<MiniMetric label="上限" value={item.max_uses || 0} />
|
||||
<MiniMetric label="剩余" value={remaining} />
|
||||
</div>
|
||||
<button
|
||||
onClick={onToggle}
|
||||
className="rounded-lg border border-border-subtle bg-surface-2 px-3 py-1.5 text-xs text-text-secondary hover:text-text-primary"
|
||||
>
|
||||
{item.is_active ? "停用" : "启用"}
|
||||
</button>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusBadge({ active }: { active: boolean }) {
|
||||
return (
|
||||
<span className={`rounded-md border px-1.5 py-0.5 text-[10px] ${active ? "border-emerald-500/15 bg-emerald-500/10 text-emerald-300" : "border-border-subtle bg-surface-2 text-text-muted"}`}>
|
||||
{active ? "启用" : "停用"}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function MiniMetric({ label, value }: { label: string; value: number }) {
|
||||
return (
|
||||
<div className="rounded-lg bg-surface-2 px-2 py-1.5">
|
||||
<div className="text-[10px] text-text-muted">{label}</div>
|
||||
<div className="mt-0.5 font-mono text-xs text-text-secondary">{value}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
146
frontend/src/app/(auth)/admin/page.tsx
Normal file
146
frontend/src/app/(auth)/admin/page.tsx
Normal file
@ -0,0 +1,146 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { listInviteCodesAPI, listUsersAPI, type InviteCodeItem, type UserItem } from "@/lib/api";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
|
||||
export default function AdminPage() {
|
||||
const { user } = useAuth();
|
||||
const [users, setUsers] = useState<UserItem[]>([]);
|
||||
const [invites, setInvites] = useState<InviteCodeItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
if (user?.role !== "admin") return;
|
||||
setLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
const [userRows, inviteRows] = await Promise.all([listUsersAPI(), listInviteCodesAPI()]);
|
||||
setUsers(userRows);
|
||||
setInvites(inviteRows);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "管理数据加载失败");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [user?.role]);
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, [loadData]);
|
||||
|
||||
const stats = useMemo(() => {
|
||||
const activeUsers = users.filter((item) => item.is_active).length;
|
||||
const disabledUsers = users.length - activeUsers;
|
||||
const activeInvites = invites.filter((item) => item.is_active).length;
|
||||
const remainingInvites = invites.reduce((sum, item) => sum + Math.max(0, (item.max_uses || 0) - (item.used_count || 0)), 0);
|
||||
return { activeUsers, disabledUsers, activeInvites, remainingInvites };
|
||||
}, [invites, users]);
|
||||
|
||||
if (user?.role !== "admin") {
|
||||
return <NoAccess />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-7xl mx-auto px-4 md:px-8 pt-6 pb-20 md:pb-10 space-y-5">
|
||||
<header className="flex flex-col gap-3 md:flex-row md:items-end md:justify-between">
|
||||
<div>
|
||||
<div className="text-[10px] font-semibold uppercase tracking-[0.22em] text-amber-400">Admin Console</div>
|
||||
<h1 className="mt-2 text-2xl font-bold tracking-tight text-text-primary">系统管理</h1>
|
||||
<p className="mt-1 text-sm text-text-muted">账号访问和注册入口分开管理,减少误操作。</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={loadData}
|
||||
className="rounded-xl border border-border-subtle bg-surface-2 px-4 py-2 text-xs text-text-secondary hover:text-text-primary"
|
||||
>
|
||||
刷新概览
|
||||
</button>
|
||||
</header>
|
||||
|
||||
{error ? <div className="glass-card-static border-red-500/15 px-4 py-2.5 text-xs text-red-300">{error}</div> : null}
|
||||
|
||||
<section className="grid grid-cols-2 gap-3 lg:grid-cols-4">
|
||||
<StatCard label="启用用户" value={loading ? "-" : stats.activeUsers} />
|
||||
<StatCard label="停用用户" value={loading ? "-" : stats.disabledUsers} />
|
||||
<StatCard label="启用邀请码" value={loading ? "-" : stats.activeInvites} />
|
||||
<StatCard label="剩余注册名额" value={loading ? "-" : stats.remainingInvites} />
|
||||
</section>
|
||||
|
||||
<section className="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
||||
<AdminEntryCard
|
||||
href="/admin/users"
|
||||
title="用户管理"
|
||||
description="查看账号、禁用账号、重置密码。适合处理访问权限和账号生命周期。"
|
||||
primary={`${stats.activeUsers} 个启用账号`}
|
||||
secondary={`${stats.disabledUsers} 个停用账号`}
|
||||
loading={loading}
|
||||
/>
|
||||
<AdminEntryCard
|
||||
href="/admin/invites"
|
||||
title="邀请码管理"
|
||||
description="创建邀请码、控制启停、查看使用量。适合管理新用户注册入口。"
|
||||
primary={`${stats.activeInvites} 个启用邀请码`}
|
||||
secondary={`${stats.remainingInvites} 个剩余名额`}
|
||||
loading={loading}
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function NoAccess() {
|
||||
return (
|
||||
<div className="max-w-5xl mx-auto px-4 md:px-8 pt-8 pb-20">
|
||||
<div className="glass-card-static p-6 text-sm text-text-secondary">当前账号没有系统管理权限。</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatCard({ label, value }: { label: string; value: string | number }) {
|
||||
return (
|
||||
<div className="glass-card-static p-4">
|
||||
<div className="text-[10px] text-text-muted">{label}</div>
|
||||
<div className="mt-2 font-mono text-2xl font-bold tabular-nums text-text-primary">{value}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AdminEntryCard({
|
||||
href,
|
||||
title,
|
||||
description,
|
||||
primary,
|
||||
secondary,
|
||||
loading,
|
||||
}: {
|
||||
href: string;
|
||||
title: string;
|
||||
description: string;
|
||||
primary: string;
|
||||
secondary: string;
|
||||
loading: boolean;
|
||||
}) {
|
||||
return (
|
||||
<Link href={href} className="glass-card-static block p-5 transition-colors hover:border-amber-500/20">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h2 className="text-lg font-bold tracking-tight text-text-primary">{title}</h2>
|
||||
<p className="mt-2 max-w-xl text-sm leading-6 text-text-muted">{description}</p>
|
||||
</div>
|
||||
<span className="rounded-xl border border-border-subtle bg-surface-2 px-3 py-1.5 text-xs text-text-secondary">进入</span>
|
||||
</div>
|
||||
<div className="mt-5 grid grid-cols-2 gap-3">
|
||||
<div className="rounded-xl bg-surface-2 px-3 py-3">
|
||||
<div className="text-[10px] text-text-muted">当前状态</div>
|
||||
<div className="mt-1 text-sm font-semibold text-text-primary">{loading ? "加载中" : primary}</div>
|
||||
</div>
|
||||
<div className="rounded-xl bg-surface-2 px-3 py-3">
|
||||
<div className="text-[10px] text-text-muted">补充信息</div>
|
||||
<div className="mt-1 text-sm font-semibold text-text-primary">{loading ? "加载中" : secondary}</div>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
190
frontend/src/app/(auth)/admin/users/page.tsx
Normal file
190
frontend/src/app/(auth)/admin/users/page.tsx
Normal file
@ -0,0 +1,190 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { disableUserAPI, listUsersAPI, resetPasswordAPI, type ResetPasswordResult, type UserItem } from "@/lib/api";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
|
||||
export default function AdminUsersPage() {
|
||||
const { user } = useAuth();
|
||||
const [users, setUsers] = useState<UserItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [message, setMessage] = useState("");
|
||||
const [resetResult, setResetResult] = useState<ResetPasswordResult | null>(null);
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
if (user?.role !== "admin") return;
|
||||
setLoading(true);
|
||||
setMessage("");
|
||||
try {
|
||||
setUsers(await listUsersAPI());
|
||||
} catch (error) {
|
||||
setMessage(error instanceof Error ? error.message : "用户数据加载失败");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [user?.role]);
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, [loadData]);
|
||||
|
||||
const stats = useMemo(() => {
|
||||
const active = users.filter((item) => item.is_active).length;
|
||||
const admin = users.filter((item) => item.role === "admin").length;
|
||||
return { active, disabled: users.length - active, admin };
|
||||
}, [users]);
|
||||
|
||||
const handleDisableUser = async (target: UserItem) => {
|
||||
if (!window.confirm(`确认禁用 ${target.email || target.username}?`)) return;
|
||||
setMessage("");
|
||||
await disableUserAPI(target.id);
|
||||
setMessage("用户已禁用");
|
||||
await loadData();
|
||||
};
|
||||
|
||||
const handleResetPassword = async (target: UserItem) => {
|
||||
if (!window.confirm(`确认重置 ${target.email || target.username} 的密码?`)) return;
|
||||
setMessage("");
|
||||
const result = await resetPasswordAPI(target.id);
|
||||
setResetResult(result);
|
||||
await loadData();
|
||||
};
|
||||
|
||||
if (user?.role !== "admin") {
|
||||
return <NoAccess />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-7xl mx-auto px-4 md:px-8 pt-6 pb-20 md:pb-10 space-y-5">
|
||||
<Link href="/admin" className="inline-flex items-center gap-1.5 text-xs text-text-muted hover:text-text-primary transition-colors">
|
||||
← 返回系统管理
|
||||
</Link>
|
||||
|
||||
<header className="flex flex-col gap-3 md:flex-row md:items-end md:justify-between">
|
||||
<div>
|
||||
<div className="text-[10px] font-semibold uppercase tracking-[0.22em] text-amber-400">Accounts</div>
|
||||
<h1 className="mt-2 text-2xl font-bold tracking-tight text-text-primary">用户管理</h1>
|
||||
<p className="mt-1 text-sm text-text-muted">处理账号启停和密码重置。</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={loadData}
|
||||
className="rounded-xl border border-border-subtle bg-surface-2 px-4 py-2 text-xs text-text-secondary hover:text-text-primary"
|
||||
>
|
||||
刷新用户
|
||||
</button>
|
||||
</header>
|
||||
|
||||
{message ? <div className="glass-card-static border-amber-500/15 px-4 py-2.5 text-xs text-amber-400">{message}</div> : null}
|
||||
|
||||
{resetResult ? (
|
||||
<div className="glass-card-static border-red-500/15 bg-red-500/[0.04] p-4">
|
||||
<div className="text-sm font-semibold text-red-300">新密码仅显示一次</div>
|
||||
<div className="mt-2 grid gap-2 text-sm text-text-secondary md:grid-cols-[1fr_220px]">
|
||||
<div>{resetResult.email}</div>
|
||||
<div className="font-mono text-red-300">{resetResult.password}</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<section className="grid grid-cols-2 gap-3 lg:grid-cols-4">
|
||||
<StatCard label="总用户" value={loading ? "-" : users.length} />
|
||||
<StatCard label="启用" value={loading ? "-" : stats.active} />
|
||||
<StatCard label="停用" value={loading ? "-" : stats.disabled} />
|
||||
<StatCard label="管理员" value={loading ? "-" : stats.admin} />
|
||||
</section>
|
||||
|
||||
<section className="glass-card-static overflow-hidden">
|
||||
<div className="flex items-center justify-between border-b border-border-subtle px-4 py-3">
|
||||
<h2 className="text-sm font-semibold text-text-primary">账号列表</h2>
|
||||
<span className="font-mono text-xs text-text-muted">{users.length}</span>
|
||||
</div>
|
||||
{loading ? (
|
||||
<div className="space-y-3 p-4">
|
||||
{[1, 2, 3, 4].map((item) => <div key={item} className="h-16 animate-shimmer rounded-xl bg-surface-2" />)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-border-subtle">
|
||||
{users.map((item) => (
|
||||
<UserRow
|
||||
key={item.id}
|
||||
item={item}
|
||||
isSelf={item.id === user.id}
|
||||
onDisable={() => handleDisableUser(item)}
|
||||
onReset={() => handleResetPassword(item)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function NoAccess() {
|
||||
return (
|
||||
<div className="max-w-5xl mx-auto px-4 md:px-8 pt-8 pb-20">
|
||||
<div className="glass-card-static p-6 text-sm text-text-secondary">当前账号没有用户管理权限。</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatCard({ label, value }: { label: string; value: string | number }) {
|
||||
return (
|
||||
<div className="glass-card-static p-4">
|
||||
<div className="text-[10px] text-text-muted">{label}</div>
|
||||
<div className="mt-2 font-mono text-2xl font-bold tabular-nums text-text-primary">{value}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function UserRow({
|
||||
item,
|
||||
isSelf,
|
||||
onDisable,
|
||||
onReset,
|
||||
}: {
|
||||
item: UserItem;
|
||||
isSelf: boolean;
|
||||
onDisable: () => void;
|
||||
onReset: () => void;
|
||||
}) {
|
||||
return (
|
||||
<article className="grid gap-3 px-4 py-3 md:grid-cols-[minmax(0,1fr)_110px_190px] md:items-center">
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="truncate text-sm font-semibold text-text-primary">{item.email || item.username}</span>
|
||||
<StatusBadge active={item.is_active} />
|
||||
<span className="rounded-md border border-border-subtle bg-surface-2 px-1.5 py-0.5 text-[10px] text-text-muted">{item.role}</span>
|
||||
</div>
|
||||
<div className="mt-1 text-[11px] text-text-muted">
|
||||
邀请码 {item.invite_code_used || "-"} · {item.created_at ? item.created_at.slice(0, 10) : "-"}
|
||||
</div>
|
||||
</div>
|
||||
<div className="font-mono text-xs text-text-muted">ID {item.id}</div>
|
||||
<div className="flex flex-wrap gap-2 md:justify-end">
|
||||
<button
|
||||
onClick={onReset}
|
||||
className="rounded-lg border border-border-subtle bg-surface-2 px-3 py-1.5 text-xs text-text-secondary hover:text-text-primary"
|
||||
>
|
||||
重置密码
|
||||
</button>
|
||||
<button
|
||||
onClick={onDisable}
|
||||
disabled={isSelf || !item.is_active}
|
||||
className="rounded-lg border border-red-500/15 bg-red-500/[0.06] px-3 py-1.5 text-xs text-red-300 hover:bg-red-500/[0.1] disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
禁用
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusBadge({ active }: { active: boolean }) {
|
||||
return (
|
||||
<span className={`rounded-md border px-1.5 py-0.5 text-[10px] ${active ? "border-emerald-500/15 bg-emerald-500/10 text-emerald-300" : "border-border-subtle bg-surface-2 text-text-muted"}`}>
|
||||
{active ? "启用" : "停用"}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@ -2,6 +2,7 @@
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
|
||||
function DashboardIcon() {
|
||||
return (
|
||||
@ -60,6 +61,15 @@ function WatchlistIcon() {
|
||||
);
|
||||
}
|
||||
|
||||
function AdminIcon() {
|
||||
return (
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M12 3l7 3v5c0 4.5-2.8 8.4-7 10-4.2-1.6-7-5.5-7-10V6l7-3z" />
|
||||
<path d="M9.5 12l1.7 1.7 3.6-4" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
function SideNavItem({ href, icon, label, onNavigate }: { href: string; icon: React.ReactNode; label: string; onNavigate?: () => void }) {
|
||||
const pathname = usePathname();
|
||||
@ -82,6 +92,8 @@ function SideNavItem({ href, icon, label, onNavigate }: { href: string; icon: Re
|
||||
}
|
||||
|
||||
export function SidebarNav({ onNavigate }: { onNavigate?: () => void }) {
|
||||
const { user } = useAuth();
|
||||
|
||||
return (
|
||||
<nav className="flex-1 overflow-y-auto px-2 sm:px-3 py-4 sm:py-5 space-y-1">
|
||||
<SideNavItem href="/dashboard" icon={<DashboardIcon />} label="今日作战" onNavigate={onNavigate} />
|
||||
@ -90,6 +102,9 @@ export function SidebarNav({ onNavigate }: { onNavigate?: () => void }) {
|
||||
<SideNavItem href="/sentiment" icon={<RadarIcon />} label="舆情雷达" onNavigate={onNavigate} />
|
||||
<SideNavItem href="/watchlists" icon={<WatchlistIcon />} label="自选股" onNavigate={onNavigate} />
|
||||
<SideNavItem href="/chat" icon={<ChatIcon />} label="研究助手" onNavigate={onNavigate} />
|
||||
{user?.role === "admin" ? (
|
||||
<SideNavItem href="/admin" icon={<AdminIcon />} label="系统管理" onNavigate={onNavigate} />
|
||||
) : null}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user