Fix recorder finalize path and add invite-gated login
这个提交包含在:
@@ -20,6 +20,7 @@ import Recorder from "./pages/Recorder";
|
||||
import Tutorials from "./pages/Tutorials";
|
||||
import Reminders from "./pages/Reminders";
|
||||
import VisionLab from "./pages/VisionLab";
|
||||
import Logs from "./pages/Logs";
|
||||
|
||||
function DashboardRoute({ component: Component }: { component: React.ComponentType }) {
|
||||
return (
|
||||
@@ -70,6 +71,9 @@ function Router() {
|
||||
<Route path="/reminders">
|
||||
<DashboardRoute component={Reminders} />
|
||||
</Route>
|
||||
<Route path="/logs">
|
||||
<DashboardRoute component={Logs} />
|
||||
</Route>
|
||||
<Route path="/vision-lab">
|
||||
<DashboardRoute component={VisionLab} />
|
||||
</Route>
|
||||
|
||||
@@ -23,7 +23,7 @@ import { useIsMobile } from "@/hooks/useMobile";
|
||||
import {
|
||||
LayoutDashboard, LogOut, PanelLeft, Target, Video,
|
||||
Award, Activity, FileVideo, Trophy, Flame, Camera, CircleDot,
|
||||
BookOpen, Bell, Microscope
|
||||
BookOpen, Bell, Microscope, ScrollText
|
||||
} from "lucide-react";
|
||||
import { CSSProperties, useEffect, useRef, useState } from "react";
|
||||
import { useLocation, Redirect } from "wouter";
|
||||
@@ -51,6 +51,7 @@ const menuItems: MenuItem[] = [
|
||||
{ icon: Trophy, label: "排行榜", path: "/leaderboard", group: "stats" },
|
||||
{ icon: BookOpen, label: "教程库", path: "/tutorials", group: "learn" },
|
||||
{ icon: Bell, label: "训练提醒", path: "/reminders", group: "learn" },
|
||||
{ icon: ScrollText, label: "系统日志", path: "/logs", group: "learn" },
|
||||
{ icon: Microscope, label: "视觉测试", path: "/vision-lab", group: "learn", adminOnly: true },
|
||||
];
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import { Target, Loader2 } from "lucide-react";
|
||||
|
||||
export default function Login() {
|
||||
const [username, setUsername] = useState("");
|
||||
const [inviteCode, setInviteCode] = useState("");
|
||||
const [, setLocation] = useLocation();
|
||||
const utils = trpc.useUtils();
|
||||
const loginMutation = trpc.auth.loginWithUsername.useMutation();
|
||||
@@ -37,7 +38,10 @@ export default function Login() {
|
||||
}
|
||||
|
||||
try {
|
||||
const data = await loginMutation.mutateAsync({ username: username.trim() });
|
||||
const data = await loginMutation.mutateAsync({
|
||||
username: username.trim(),
|
||||
inviteCode: inviteCode.trim() || undefined,
|
||||
});
|
||||
const user = await syncAuthenticatedUser(data.user);
|
||||
toast.success(data.isNew ? `已创建用户:${user.name}` : `已登录:${user.name}`);
|
||||
setLocation("/dashboard");
|
||||
@@ -77,6 +81,20 @@ export default function Login() {
|
||||
maxLength={64}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Input
|
||||
data-testid="login-invite-code-input"
|
||||
type="text"
|
||||
placeholder="邀请码,仅新用户首次登录需要"
|
||||
value={inviteCode}
|
||||
onChange={(e) => setInviteCode(e.target.value)}
|
||||
className="h-12 text-base"
|
||||
maxLength={64}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
已存在账号只需输入用户名。新用户首次登录需要邀请码。
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
data-testid="login-submit-button"
|
||||
type="submit"
|
||||
@@ -114,7 +132,7 @@ export default function Login() {
|
||||
</Card>
|
||||
|
||||
<p className="text-center text-xs text-muted-foreground mt-6">
|
||||
输入用户名后进入系统
|
||||
直接输入用户名登录;新用户首次登录需填写邀请码
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
228
client/src/pages/Logs.tsx
普通文件
228
client/src/pages/Logs.tsx
普通文件
@@ -0,0 +1,228 @@
|
||||
import { useMemo } from "react";
|
||||
import { trpc } from "@/lib/trpc";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { toast } from "sonner";
|
||||
import { AlertTriangle, BellRing, CheckCircle2, ClipboardList, Loader2, RefreshCcw } from "lucide-react";
|
||||
|
||||
function formatTaskStatus(status: string) {
|
||||
switch (status) {
|
||||
case "running":
|
||||
return "执行中";
|
||||
case "succeeded":
|
||||
return "已完成";
|
||||
case "failed":
|
||||
return "失败";
|
||||
default:
|
||||
return "排队中";
|
||||
}
|
||||
}
|
||||
|
||||
function formatNotificationState(isRead: number | boolean | null | undefined) {
|
||||
return isRead ? "已读" : "未读";
|
||||
}
|
||||
|
||||
function formatStructuredValue(value: unknown) {
|
||||
if (!value) return "";
|
||||
if (typeof value === "string") return value;
|
||||
try {
|
||||
return JSON.stringify(value, null, 2);
|
||||
} catch {
|
||||
return String(value);
|
||||
}
|
||||
}
|
||||
|
||||
export default function Logs() {
|
||||
const utils = trpc.useUtils();
|
||||
const taskListQuery = trpc.task.list.useQuery(
|
||||
{ limit: 50 },
|
||||
{
|
||||
refetchInterval: (query) => {
|
||||
const hasActiveTask = (query.state.data ?? []).some((task) => task.status === "queued" || task.status === "running");
|
||||
return hasActiveTask ? 3_000 : 10_000;
|
||||
},
|
||||
},
|
||||
);
|
||||
const notificationQuery = trpc.notification.list.useQuery({ limit: 50 });
|
||||
const retryMutation = trpc.task.retry.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.task.list.invalidate();
|
||||
toast.success("任务已重新排队");
|
||||
},
|
||||
onError: (error) => toast.error(`任务重试失败: ${error.message}`),
|
||||
});
|
||||
|
||||
const activeTaskCount = useMemo(
|
||||
() => (taskListQuery.data ?? []).filter((task) => task.status === "queued" || task.status === "running").length,
|
||||
[taskListQuery.data],
|
||||
);
|
||||
const failedTaskCount = useMemo(
|
||||
() => (taskListQuery.data ?? []).filter((task) => task.status === "failed").length,
|
||||
[taskListQuery.data],
|
||||
);
|
||||
const unreadNotificationCount = useMemo(
|
||||
() => (notificationQuery.data ?? []).filter((item) => !item.isRead).length,
|
||||
[notificationQuery.data],
|
||||
);
|
||||
|
||||
if (taskListQuery.isLoading && notificationQuery.isLoading) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Skeleton className="h-28 w-full" />
|
||||
<Skeleton className="h-96 w-full" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">系统日志</h1>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
查看后台任务、归档失败原因和通知记录。录制结束失败、训练计划生成失败等信息会保留在这里。
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Badge variant="secondary">活动任务 {activeTaskCount}</Badge>
|
||||
<Badge variant={failedTaskCount > 0 ? "destructive" : "secondary"}>失败任务 {failedTaskCount}</Badge>
|
||||
<Badge variant="outline">未读通知 {unreadNotificationCount}</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Alert>
|
||||
<ClipboardList className="h-4 w-4" />
|
||||
<AlertTitle>排障入口</AlertTitle>
|
||||
<AlertDescription>
|
||||
如果录制归档、视频分析或训练计划生成失败,先看“后台任务”里的错误信息,再根据任务标题定位具体模块。
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<Tabs defaultValue="tasks" className="space-y-4">
|
||||
<TabsList>
|
||||
<TabsTrigger value="tasks">后台任务</TabsTrigger>
|
||||
<TabsTrigger value="notifications">通知记录</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="tasks">
|
||||
<ScrollArea className="max-h-[70vh] pr-3">
|
||||
<div className="space-y-4">
|
||||
{(taskListQuery.data ?? []).length === 0 ? (
|
||||
<Card className="border-dashed">
|
||||
<CardContent className="pt-6 text-sm text-muted-foreground">
|
||||
还没有后台任务记录。
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
(taskListQuery.data ?? []).map((task) => (
|
||||
<Card key={task.id} className="border-0 shadow-sm">
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div className="space-y-1">
|
||||
<CardTitle className="text-base">{task.title}</CardTitle>
|
||||
<CardDescription>
|
||||
{new Date(task.createdAt).toLocaleString("zh-CN")} · {task.type}
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Badge variant={task.status === "failed" ? "destructive" : "secondary"}>
|
||||
{formatTaskStatus(task.status)}
|
||||
</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<p className="text-sm text-muted-foreground">{task.message || formatTaskStatus(task.status)}</p>
|
||||
|
||||
{task.error ? (
|
||||
<div className="rounded-xl bg-red-50 px-3 py-2 text-sm text-red-700">
|
||||
<div className="flex items-start gap-2">
|
||||
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" />
|
||||
<span className="whitespace-pre-wrap break-words">{task.error}</span>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{task.result ? (
|
||||
<pre className="overflow-x-auto rounded-xl bg-muted/60 p-3 text-xs leading-5 whitespace-pre-wrap break-words">
|
||||
{formatStructuredValue(task.result)}
|
||||
</pre>
|
||||
) : null}
|
||||
|
||||
<div className="flex items-center justify-between text-xs text-muted-foreground">
|
||||
<span>进度 {task.progress}% · 尝试 {task.attempts}/{task.maxAttempts}</span>
|
||||
{task.status === "failed" ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="gap-2"
|
||||
onClick={() => retryMutation.mutate({ taskId: task.id })}
|
||||
disabled={retryMutation.isPending}
|
||||
>
|
||||
{retryMutation.isPending ? <Loader2 className="h-4 w-4 animate-spin" /> : <RefreshCcw className="h-4 w-4" />}
|
||||
重试
|
||||
</Button>
|
||||
) : task.status === "succeeded" ? (
|
||||
<span className="inline-flex items-center gap-1 text-emerald-600">
|
||||
<CheckCircle2 className="h-3.5 w-3.5" />
|
||||
已完成
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-1 text-primary">
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
处理中
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="notifications">
|
||||
<ScrollArea className="max-h-[70vh] pr-3">
|
||||
<div className="space-y-4">
|
||||
{(notificationQuery.data ?? []).length === 0 ? (
|
||||
<Card className="border-dashed">
|
||||
<CardContent className="pt-6 text-sm text-muted-foreground">
|
||||
还没有通知记录。
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
(notificationQuery.data ?? []).map((item) => (
|
||||
<Card key={item.id} className="border-0 shadow-sm">
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div className="space-y-1">
|
||||
<CardTitle className="text-base">{item.title}</CardTitle>
|
||||
<CardDescription>
|
||||
{new Date(item.createdAt).toLocaleString("zh-CN")} · {item.notificationType}
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Badge variant={item.isRead ? "secondary" : "outline"}>
|
||||
{formatNotificationState(item.isRead)}
|
||||
</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-start gap-2 text-sm">
|
||||
<BellRing className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<p className="whitespace-pre-wrap break-words">{item.message || "无附加内容"}</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
在新工单中引用
屏蔽一个用户