This commit is contained in:
aaron 2026-06-02 13:31:24 +08:00
parent 55318c54e8
commit 153dcc2f70
4 changed files with 570 additions and 0 deletions

View 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>
);
}

View 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>
);
}

View 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>
);
}

View File

@ -2,6 +2,7 @@
import Link from "next/link"; import Link from "next/link";
import { usePathname } from "next/navigation"; import { usePathname } from "next/navigation";
import { useAuth } from "@/hooks/use-auth";
function DashboardIcon() { function DashboardIcon() {
return ( 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 }) { function SideNavItem({ href, icon, label, onNavigate }: { href: string; icon: React.ReactNode; label: string; onNavigate?: () => void }) {
const pathname = usePathname(); const pathname = usePathname();
@ -82,6 +92,8 @@ function SideNavItem({ href, icon, label, onNavigate }: { href: string; icon: Re
} }
export function SidebarNav({ onNavigate }: { onNavigate?: () => void }) { export function SidebarNav({ onNavigate }: { onNavigate?: () => void }) {
const { user } = useAuth();
return ( return (
<nav className="flex-1 overflow-y-auto px-2 sm:px-3 py-4 sm:py-5 space-y-1"> <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} /> <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="/sentiment" icon={<RadarIcon />} label="舆情雷达" onNavigate={onNavigate} />
<SideNavItem href="/watchlists" icon={<WatchlistIcon />} label="自选股" onNavigate={onNavigate} /> <SideNavItem href="/watchlists" icon={<WatchlistIcon />} label="自选股" onNavigate={onNavigate} />
<SideNavItem href="/chat" icon={<ChatIcon />} label="研究助手" onNavigate={onNavigate} /> <SideNavItem href="/chat" icon={<ChatIcon />} label="研究助手" onNavigate={onNavigate} />
{user?.role === "admin" ? (
<SideNavItem href="/admin" icon={<AdminIcon />} label="系统管理" onNavigate={onNavigate} />
) : null}
</nav> </nav>
); );
} }