Loading files...
;
+ return Loading files...
;
}
if (!user) {
return (
{(listError || errorMessage) && (
-
+
{listError || errorMessage}
)}
{feedbackMessage && (
-
+
{feedbackMessage}
)}
-
+
挂载点
- 一期按挂载点浏览目录树,支持 VFS/S3。
+ 一期按挂载点浏览目录树,支持 VFS/S3。
{mounts.map((mount) => {
const selected = mount.code === (listData?.current_mount.code ?? mountCode);
return (
-
+
);
})}
{mounts.length === 0 && (
-
暂无可用挂载点。
+
暂无可用挂载点。
)}
-
+
文件列表
-
+
存储后端:{listData?.current_mount.backend.name ?? "-"}({listData?.current_mount.backend.driver_type ?? "-"})
-
+
{canManage && (
<>
-
+ />
>
)}
-
+
{(listData?.breadcrumbs ?? [{ name: "根目录", path: "/" }]).map((crumb, index, all) => (
-
- {index < all.length - 1 && /}
+
+ {index < all.length - 1 && /}
))}
{canManage && (
- ) => setNewDirectoryName(event.currentTarget.value)}
placeholder="新建目录名"
- className="w-full max-w-xs control"
+ className="w-full max-w-xs"
/>
-
+
)}
-
-
-
- | 名称 |
- 类型 |
- 大小 |
- 修改时间 |
- 索引同步时间 |
- 操作 |
-
-
-
+
+
+
+ 名称
+ 类型
+ 大小
+ 修改时间
+ 索引同步时间
+ 操作
+
+
+
{items.map((item) => {
const isActive = activeItemPath === item.path;
return (
-
- |
-
{isActive && canManage && (
- |
- {item.is_dir ? "目录" : item.mime_type ?? "文件"} |
- {item.is_dir ? "-" : formatFileSize(item.size)} |
- {formatDate(item.modified_at)} |
- {formatDate(item.synced_at)} |
-
+
+ {item.is_dir ? "目录" : item.mime_type ?? "文件"}
+ {item.is_dir ? "-" : formatFileSize(item.size)}
+ {formatDate(item.modified_at)}
+ {formatDate(item.synced_at)}
+
{item.is_dir && (
- handleOpenDirectory(item)}
>
进入
-
+
)}
{!item.is_dir && (
- void handleDownload(item)}
>
下载
-
+
)}
{canManage && (
<>
- startRename(item)}
disabled={operationBusy}
>
重命名
-
-
+ startMove(item)}
disabled={operationBusy}
>
移动
-
-
+ handleDelete(item)}
disabled={deleteMutation.isPending}
>
删除
-
+
>
)}
- |
-
+
+
);
})}
{items.length === 0 && (
-
- |
+
+
当前目录为空
- |
-
+
+
)}
-
-
+
+
diff --git a/web/src/app/admin/git-desktop/page.tsx b/web/src/app/admin/git-desktop/page.tsx
new file mode 100644
index 0000000..6186292
--- /dev/null
+++ b/web/src/app/admin/git-desktop/page.tsx
@@ -0,0 +1,3 @@
+"use client";
+
+export { default } from "@/app/admin/requirements/page";
diff --git a/web/src/app/admin/group/page.tsx b/web/src/app/admin/group/page.tsx
new file mode 100644
index 0000000..fc45ba7
--- /dev/null
+++ b/web/src/app/admin/group/page.tsx
@@ -0,0 +1 @@
+export { default } from "../tag/page";
diff --git a/web/src/app/admin/history/page.tsx b/web/src/app/admin/history/page.tsx
new file mode 100644
index 0000000..7bea963
--- /dev/null
+++ b/web/src/app/admin/history/page.tsx
@@ -0,0 +1 @@
+export { default } from "../question-bank/page";
diff --git a/web/src/app/admin/homework/page.tsx b/web/src/app/admin/homework/page.tsx
new file mode 100644
index 0000000..7bea963
--- /dev/null
+++ b/web/src/app/admin/homework/page.tsx
@@ -0,0 +1 @@
+export { default } from "../question-bank/page";
diff --git a/web/src/app/admin/hot-search/page.tsx b/web/src/app/admin/hot-search/page.tsx
new file mode 100644
index 0000000..05732e2
--- /dev/null
+++ b/web/src/app/admin/hot-search/page.tsx
@@ -0,0 +1,685 @@
+"use client";
+
+import Link from "next/link";
+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
+import { ChangeEvent, KeyboardEvent, useCallback, useEffect, useMemo, useState } from "react";
+import { Button, Dialog, Select, Table, TextArea, TextField } from "@radix-ui/themes";
+
+import { useAuth } from "@/components/auth-provider";
+import { useTopicSubscription } from "@/hooks/use-topic-subscription";
+import { readApiError } from "@/lib/api";
+import type {
+ HotSearchFollowTopicListResponse,
+ HotSearchFollowTopicSummary,
+ HotSearchListResponse,
+ HotSearchRecordSummary,
+} from "@/types/auth";
+
+const SOURCE_OPTIONS = [
+ { label: "头条", value: "TOUTIAO" },
+];
+
+type TopicFormState = {
+ topic_name: string;
+ keywords: string;
+ enabled: "true" | "false";
+ seq: string;
+};
+
+const EMPTY_TOPIC_FORM: TopicFormState = {
+ topic_name: "",
+ keywords: "",
+ enabled: "true",
+ seq: "0",
+};
+
+function formatDateTime(value: string | null | undefined): string {
+ if (!value) {
+ return "-";
+ }
+ const date = new Date(value);
+ if (Number.isNaN(date.getTime())) {
+ return value;
+ }
+ return date.toLocaleString("zh-CN", { hour12: false });
+}
+
+function renderTopicTags(topics: string[]) {
+ if (topics.length === 0) {
+ return 未命中;
+ }
+ return (
+
+ {topics.map((topic) => (
+
+ {topic}
+
+ ))}
+
+ );
+}
+
+export default function AdminHotSearchPage() {
+ const { user, initializing, fetchWithAuth, hasPermission } = useAuth();
+ const queryClient = useQueryClient();
+
+ const [source, setSource] = useState("TOUTIAO");
+ const [keywordInput, setKeywordInput] = useState("");
+ const [keyword, setKeyword] = useState("");
+ const [followedOnly, setFollowedOnly] = useState(false);
+ const [selectedId, setSelectedId] = useState(null);
+
+ const [error, setError] = useState("");
+ const [success, setSuccess] = useState("");
+
+ const [topicDialogOpen, setTopicDialogOpen] = useState(false);
+ const [editingTopicId, setEditingTopicId] = useState(null);
+ const [topicForm, setTopicForm] = useState(EMPTY_TOPIC_FORM);
+
+ const canRead = hasPermission("question_bank.read") || hasPermission("question_bank.manage");
+ const canManage = hasPermission("question_bank.manage");
+
+ const listQuery = useQuery({
+ queryKey: ["/api/v1/admin/hot-search/search", source, keyword, followedOnly],
+ enabled: !!user && canRead,
+ queryFn: async () => {
+ const response = await fetchWithAuth("/api/v1/admin/hot-search/search", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ source,
+ title_keyword: keyword.trim() || null,
+ followed_only: followedOnly,
+ }),
+ });
+ if (!response.ok) {
+ throw new Error(await readApiError(response));
+ }
+ return (await response.json()) as HotSearchListResponse;
+ },
+ });
+
+ const followTopicsQuery = useQuery({
+ queryKey: ["/api/v1/admin/hot-search/follow-topics"],
+ enabled: !!user && canRead,
+ queryFn: async () => {
+ const response = await fetchWithAuth("/api/v1/admin/hot-search/follow-topics");
+ if (!response.ok) {
+ throw new Error(await readApiError(response));
+ }
+ return (await response.json()) as HotSearchFollowTopicListResponse;
+ },
+ });
+
+ const refreshRecords = useCallback(async () => {
+ await queryClient.invalidateQueries({
+ predicate: (query) => {
+ const key = query.queryKey[0];
+ return typeof key === "string" && key.startsWith("/api/v1/admin/hot-search/search");
+ },
+ });
+ }, [queryClient]);
+
+ const refreshTopics = useCallback(async () => {
+ await queryClient.invalidateQueries({ queryKey: ["/api/v1/admin/hot-search/follow-topics"] });
+ }, [queryClient]);
+
+ useTopicSubscription("admin.hot_search", useCallback(() => {
+ void refreshRecords();
+ }, [refreshRecords]));
+
+ useTopicSubscription("admin.hot_search.follow_topics", useCallback(() => {
+ void refreshTopics();
+ void refreshRecords();
+ }, [refreshRecords, refreshTopics]));
+
+ const records = listQuery.data?.items ?? [];
+ const topics = followTopicsQuery.data?.items ?? [];
+
+ useEffect(() => {
+ if (records.length === 0) {
+ setSelectedId(null);
+ return;
+ }
+ const exists = selectedId !== null && records.some((item) => item.id === selectedId);
+ if (!exists) {
+ setSelectedId(records[0].id);
+ }
+ }, [records, selectedId]);
+
+ const selectedRecord = useMemo(() => {
+ if (records.length === 0) {
+ return null;
+ }
+ if (selectedId === null) {
+ return records[0];
+ }
+ return records.find((item) => item.id === selectedId) ?? records[0];
+ }, [records, selectedId]);
+
+ const topicStats = useMemo(() => {
+ const enabled = topics.filter((item) => item.enabled).length;
+ return {
+ total: topics.length,
+ enabled,
+ disabled: Math.max(0, topics.length - enabled),
+ };
+ }, [topics]);
+
+ const saveTopicMutation = useMutation({
+ mutationFn: async () => {
+ if (!canManage) {
+ throw new Error("缺少 question_bank.manage 权限");
+ }
+
+ const topic_name = topicForm.topic_name.trim();
+ if (!topic_name) {
+ throw new Error("主题名称不能为空");
+ }
+
+ const seqNumber = Number(topicForm.seq || "0");
+ if (Number.isNaN(seqNumber) || seqNumber < 0) {
+ throw new Error("排序值必须是大于等于 0 的数字");
+ }
+
+ const payload = {
+ topic_name,
+ keywords: topicForm.keywords.trim() || null,
+ enabled: topicForm.enabled === "true",
+ seq: seqNumber,
+ };
+
+ if (editingTopicId === null) {
+ const response = await fetchWithAuth("/api/v1/admin/hot-search/follow-topics", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(payload),
+ });
+ if (!response.ok) {
+ throw new Error(await readApiError(response));
+ }
+ return "created";
+ }
+
+ const response = await fetchWithAuth(`/api/v1/admin/hot-search/follow-topics/${editingTopicId}`, {
+ method: "PATCH",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(payload),
+ });
+ if (!response.ok) {
+ throw new Error(await readApiError(response));
+ }
+ return "updated";
+ },
+ onSuccess: async (mode) => {
+ setError("");
+ setSuccess(mode === "created" ? "关注主题已创建" : "关注主题已更新");
+ setTopicDialogOpen(false);
+ setEditingTopicId(null);
+ setTopicForm(EMPTY_TOPIC_FORM);
+ await refreshTopics();
+ await refreshRecords();
+ },
+ onError: (candidate) => {
+ setSuccess("");
+ setError(candidate instanceof Error ? candidate.message : "保存关注主题失败");
+ },
+ });
+
+ const deleteTopicMutation = useMutation({
+ mutationFn: async (topicId: number) => {
+ if (!canManage) {
+ throw new Error("缺少 question_bank.manage 权限");
+ }
+ const response = await fetchWithAuth(`/api/v1/admin/hot-search/follow-topics/${topicId}`, {
+ method: "DELETE",
+ });
+ if (!response.ok) {
+ throw new Error(await readApiError(response));
+ }
+ },
+ onSuccess: async () => {
+ setError("");
+ setSuccess("关注主题已删除");
+ await refreshTopics();
+ await refreshRecords();
+ },
+ onError: (candidate) => {
+ setSuccess("");
+ setError(candidate instanceof Error ? candidate.message : "删除关注主题失败");
+ },
+ });
+
+ const openCreateTopic = () => {
+ setError("");
+ setSuccess("");
+ setEditingTopicId(null);
+ setTopicForm({ ...EMPTY_TOPIC_FORM, seq: String(topics.length) });
+ setTopicDialogOpen(true);
+ };
+
+ const openEditTopic = (item: HotSearchFollowTopicSummary) => {
+ setError("");
+ setSuccess("");
+ setEditingTopicId(item.id);
+ setTopicForm({
+ topic_name: item.topic_name,
+ keywords: item.keywords ?? "",
+ enabled: item.enabled ? "true" : "false",
+ seq: String(item.seq),
+ });
+ setTopicDialogOpen(true);
+ };
+
+ const submitSearch = () => {
+ setKeyword(keywordInput.trim());
+ };
+
+ const onKeywordKeyDown = (event: KeyboardEvent) => {
+ if (event.key === "Enter") {
+ event.preventDefault();
+ submitSearch();
+ }
+ };
+
+ const listError = listQuery.error instanceof Error ? listQuery.error.message : "";
+ const topicError = followTopicsQuery.error instanceof Error ? followTopicsQuery.error.message : "";
+ const totalError = error || listError || topicError;
+
+ if (initializing) {
+ return Loading hot search workspace...
;
+ }
+
+ if (!user) {
+ return (
+
+ 请先登录后再访问热搜页面。
+ 返回首页
+
+ );
+ }
+
+ if (!canRead) {
+ return (
+
+ 你没有访问该页面的权限(需要 `question_bank.read`)。
+ 返回首页
+
+ );
+ }
+
+ return (
+
+ {totalError && (
+
{totalError}
+ )}
+ {success && (
+
{success}
+ )}
+
+
+
+
+
热搜列表
+
支持来源筛选、关键词检索与关注主题命中识别。
+
+
+ 共 {records.length} 条
+ 启用主题 {topicStats.enabled}
+ 全部主题 {topicStats.total}
+
+
+
+
+
+
+
+
+
+
+
+
+ 查询
+ {
+ setKeywordInput("");
+ setKeyword("");
+ setFollowedOnly(false);
+ setSource("TOUTIAO");
+ }}
+ >
+ 重置
+
+
+
+
+
+
+
+
+ 序号
+ 标题
+ 来源
+ 热度
+ 命中主题
+ 抓取时间
+
+
+
+ {listQuery.isLoading && (
+
+
+ 正在加载热搜数据...
+
+
+ )}
+
+ {!listQuery.isLoading && records.map((item) => {
+ const active = selectedRecord?.id === item.id;
+ return (
+
+ setSelectedId(item.id)}
+ >
+ {item.rank_index ?? "-"}
+
+ setSelectedId(item.id)}
+ >
+ {item.title}
+
+ {item.source || "-"}
+ {item.hot_value || "-"}
+ {renderTopicTags(item.matched_topics)}
+ {formatDateTime(item.crawl_time)}
+
+ );
+ })}
+
+ {!listQuery.isLoading && records.length === 0 && (
+
+
+ 暂无热搜数据。
+
+
+ )}
+
+
+
+
+
+ 详情
+ {!selectedRecord && (
+ 请选择一条热搜查看详情。
+ )}
+ {selectedRecord && (
+
+
+
标题
+
{selectedRecord.title}
+
+
+
+
+
来源
+
{selectedRecord.source || "-"}
+
+
+
热度
+
{selectedRecord.hot_value || "-"}
+
+
+
序号
+
{selectedRecord.rank_index ?? "-"}
+
+
+
抓取时间
+
{formatDateTime(selectedRecord.crawl_time)}
+
+
+
+
+
命中关注主题
+
{renderTopicTags(selectedRecord.matched_topics)}
+
+
+
+
+
+
详情内容
+
+ {selectedRecord.detail_markdown || "暂无详情"}
+
+
+
+ )}
+
+
+
+
+
+
+
+
关注主题
+
用于识别热搜命中主题,支持启停、排序与关键词配置。
+
+
+ 启用 {topicStats.enabled}
+ 停用 {topicStats.disabled}
+ {canManage && (
+ 新建主题
+ )}
+
+
+
+
+
+
+
+ 主题名称
+ 关键词
+ 状态
+ 排序
+ 更新时间
+ {canManage && 操作}
+
+
+
+ {followTopicsQuery.isLoading && (
+
+
+ 正在加载关注主题...
+
+
+ )}
+
+ {!followTopicsQuery.isLoading && topics.map((item) => (
+
+ {item.topic_name}
+
+ {item.keywords || "-"}
+
+
+
+ {item.enabled ? "启用" : "停用"}
+
+
+ {item.seq}
+ {formatDateTime(item.updated_at)}
+ {canManage && (
+
+
+ openEditTopic(item)}>编辑
+ {
+ if (!window.confirm(`确认删除主题「${item.topic_name}」吗?`)) {
+ return;
+ }
+ deleteTopicMutation.mutate(item.id);
+ }}
+ disabled={deleteTopicMutation.isPending}
+ >
+ 删除
+
+
+
+ )}
+
+ ))}
+
+ {!followTopicsQuery.isLoading && topics.length === 0 && (
+
+
+ 暂无关注主题。
+
+
+ )}
+
+
+
+
+
+
+
+ {editingTopicId === null ? "新建关注主题" : `编辑关注主题 #${editingTopicId}`}
+
+ 主题关键词支持逗号或换行分隔;系统将基于关键词自动匹配热搜内容。
+
+
+
+
+
+
+
+
+
+
+
+
+
+ saveTopicMutation.mutate()}
+ disabled={saveTopicMutation.isPending}
+ >
+ {saveTopicMutation.isPending ? "提交中..." : editingTopicId === null ? "创建" : "保存"}
+
+ {
+ setTopicDialogOpen(false);
+ setEditingTopicId(null);
+ setTopicForm(EMPTY_TOPIC_FORM);
+ }}
+ >
+ 取消
+
+
+
+
+
+ );
+}
diff --git a/web/src/app/admin/job/page.tsx b/web/src/app/admin/job/page.tsx
new file mode 100644
index 0000000..7bea963
--- /dev/null
+++ b/web/src/app/admin/job/page.tsx
@@ -0,0 +1 @@
+export { default } from "../question-bank/page";
diff --git a/web/src/app/admin/jobqueue/page.tsx b/web/src/app/admin/jobqueue/page.tsx
new file mode 100644
index 0000000..648db25
--- /dev/null
+++ b/web/src/app/admin/jobqueue/page.tsx
@@ -0,0 +1 @@
+export { default } from "@/app/admin/todos/page";
diff --git a/web/src/app/admin/jwt-generator/page.tsx b/web/src/app/admin/jwt-generator/page.tsx
new file mode 100644
index 0000000..ba385c9
--- /dev/null
+++ b/web/src/app/admin/jwt-generator/page.tsx
@@ -0,0 +1,155 @@
+"use client";
+
+import Link from "next/link";
+import { ChangeEvent, useCallback, useState } from "react";
+
+import { useAuth } from "@/components/auth-provider";
+import { readApiError } from "@/lib/api";
+import { Button, TextField } from "@radix-ui/themes";
+
+type JwtGenerateResponse = {
+ token_type: string;
+ access_token: string;
+ expires_in: number;
+ user_id: string;
+};
+
+export default function AdminJwtGeneratorPage() {
+ const { user, initializing, fetchWithAuth, hasPermission } = useAuth();
+
+ const canRead = hasPermission("jwt_generator.read") || hasPermission("jwt_generator.manage");
+
+ const [userId, setUserId] = useState("");
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState("");
+ const [result, setResult] = useState(null);
+
+ const handleGenerate = useCallback(async () => {
+ const normalized = userId.trim();
+ if (!normalized) {
+ setError("请输入 user_id");
+ return;
+ }
+
+ setLoading(true);
+ setError("");
+ setResult(null);
+
+ const response = await fetchWithAuth("/api/v1/admin/jwt-generator/generate", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ user_id: normalized }),
+ });
+
+ if (!response.ok) {
+ setError(await readApiError(response));
+ setLoading(false);
+ return;
+ }
+
+ const payload = (await response.json()) as JwtGenerateResponse;
+ setResult(payload);
+ setLoading(false);
+ }, [fetchWithAuth, userId]);
+
+ const handleCopy = useCallback(async () => {
+ if (!result?.access_token) {
+ return;
+ }
+ try {
+ await navigator.clipboard.writeText(result.access_token);
+ } catch {
+ setError("复制失败,请手动复制");
+ }
+ }, [result?.access_token]);
+
+ if (initializing) {
+ return Loading jwt generator...
;
+ }
+
+ if (!user) {
+ return (
+
+ 请先登录后再访问 Jwt 生成器页面。
+ 返回首页
+
+ );
+ }
+
+ if (!canRead) {
+ return (
+
+ 你没有访问该页面的权限(需要 `jwt_generator.read`)。
+ 返回首页
+
+ );
+ }
+
+ return (
+
+ {error && (
+
{error}
+ )}
+
+
+
+
Jwt生成器
+
输入用户 ID,生成该用户的 Bearer Token(含角色与权限声明)。
+
+
+
+
+ void handleGenerate()} disabled={loading}>
+ {loading ? "生成中..." : "生成 Token"}
+
+
+
+
+
+
+
生成结果
+ void handleCopy()} disabled={!result?.access_token}>
+ 复制 Token
+
+
+
+ {!result ? (
+
+ 暂无生成结果。
+
+ ) : (
+
+
+
+
- user_id
+ - {result.user_id}
+
+
+
- token_type
+ - {result.token_type}
+
+
+
- expires_in
+ - {result.expires_in}s
+
+
+
+
+
access_token
+
+ {result.access_token}
+
+
+
+ )}
+
+
+ );
+}
diff --git a/web/src/app/admin/knowledge-mastery/page.tsx b/web/src/app/admin/knowledge-mastery/page.tsx
new file mode 100644
index 0000000..5ecfaa7
--- /dev/null
+++ b/web/src/app/admin/knowledge-mastery/page.tsx
@@ -0,0 +1 @@
+export { default } from "../vocabulary-proficiency/page";
diff --git a/web/src/app/admin/knowledge-set/page.tsx b/web/src/app/admin/knowledge-set/page.tsx
new file mode 100644
index 0000000..583a7ee
--- /dev/null
+++ b/web/src/app/admin/knowledge-set/page.tsx
@@ -0,0 +1 @@
+export { default } from "../files/page";
diff --git a/web/src/app/admin/knowledge/page.tsx b/web/src/app/admin/knowledge/page.tsx
new file mode 100644
index 0000000..fc45ba7
--- /dev/null
+++ b/web/src/app/admin/knowledge/page.tsx
@@ -0,0 +1 @@
+export { default } from "../tag/page";
diff --git a/web/src/app/admin/layout.tsx b/web/src/app/admin/layout.tsx
index 9dea15d..436d28d 100644
--- a/web/src/app/admin/layout.tsx
+++ b/web/src/app/admin/layout.tsx
@@ -8,6 +8,7 @@ import { useAuth } from "@/components/auth-provider";
import { useTopicSubscription } from "@/hooks/use-topic-subscription";
import { readApiError } from "@/lib/api";
import type { MenuTreeItem } from "@/types/auth";
+import { Button, Callout, Card, Flex, Heading, Text } from "@radix-ui/themes";
function flattenMenuTree(tree: MenuTreeItem[]): MenuTreeItem[] {
const result: MenuTreeItem[] = [];
@@ -37,18 +38,15 @@ function renderMenuNodes(items: MenuTreeItem[], pathname: string): React.ReactNo
return (
{item.path ? (
-
- {item.name}
-
+
+ {item.name}
+
) : (
-
{item.name}
+
{item.name}
)}
{item.children.length > 0 && (
-
+
{renderMenuNodes(item.children, pathname)}
)}
@@ -109,7 +107,7 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
if (initializing || loadingMenus) {
return (
- Loading admin workspace...
+ Loading admin workspace...
);
}
@@ -117,68 +115,67 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
if (!user) {
return (
- 请先登录后再访问后台。
- 返回首页
+ 请先登录后再访问后台。
+
+ 返回首页
+
);
}
return (
-
-
-
-
-
-
-
-
+
+
+ {children}
+
);
}
diff --git a/web/src/app/admin/life-countdown/page.tsx b/web/src/app/admin/life-countdown/page.tsx
new file mode 100644
index 0000000..0accc5f
--- /dev/null
+++ b/web/src/app/admin/life-countdown/page.tsx
@@ -0,0 +1,381 @@
+"use client";
+
+import { ChangeEvent, useCallback, useEffect, useMemo, useState } from "react";
+
+import { useAuth } from "@/components/auth-provider";
+import { readApiError } from "@/lib/api";
+import type { LifeCountdownProfile, LifeCountdownWarning } from "@/types/auth";
+import { Button, TextField } from "@radix-ui/themes";
+
+type CountdownParts = {
+ expired: boolean;
+ totalDays: number;
+ years: number;
+ days: number;
+ hours: number;
+ minutes: number;
+ seconds: number;
+};
+
+function padTime(value: number): string {
+ return String(value).padStart(2, "0");
+}
+
+function formatCalendarDate(value?: string): string {
+ if (!value) {
+ return "--";
+ }
+ const parsed = new Date(value);
+ if (Number.isNaN(parsed.getTime())) {
+ return "--";
+ }
+ const year = parsed.getFullYear();
+ const month = String(parsed.getMonth() + 1).padStart(2, "0");
+ const day = String(parsed.getDate()).padStart(2, "0");
+ return `${year}-${month}-${day}`;
+}
+
+function calculateCountdown(deathDate?: string, nowMs?: number): CountdownParts | null {
+ if (!deathDate || !nowMs) {
+ return null;
+ }
+
+ const target = new Date(`${deathDate}T23:59:59`);
+ if (Number.isNaN(target.getTime())) {
+ return null;
+ }
+
+ const diffMs = target.getTime() - nowMs;
+ if (diffMs <= 0) {
+ return {
+ expired: true,
+ totalDays: 0,
+ years: 0,
+ days: 0,
+ hours: 0,
+ minutes: 0,
+ seconds: 0,
+ };
+ }
+
+ const totalSeconds = Math.floor(diffMs / 1000);
+ const totalDays = Math.floor(totalSeconds / 86400);
+
+ return {
+ expired: false,
+ totalDays,
+ years: Math.floor(totalDays / 365),
+ days: totalDays % 365,
+ hours: Math.floor((totalSeconds % 86400) / 3600),
+ minutes: Math.floor((totalSeconds % 3600) / 60),
+ seconds: totalSeconds % 60,
+ };
+}
+
+function toDateInputValue(value?: string): string {
+ if (!value) {
+ return "";
+ }
+ const normalized = formatCalendarDate(value);
+ return normalized === "--" ? "" : normalized;
+}
+
+export default function LifeCountdownPage() {
+ const { user, initializing, fetchWithAuth, hasPermission } = useAuth();
+
+ const canRead = hasPermission("life_countdown.read") || hasPermission("life_countdown.manage");
+ const canManage = hasPermission("life_countdown.manage");
+
+ const [loading, setLoading] = useState(true);
+ const [saving, setSaving] = useState(false);
+ const [generating, setGenerating] = useState(false);
+ const [error, setError] = useState("");
+ const [success, setSuccess] = useState("");
+ const [profile, setProfile] = useState
(null);
+ const [deathDateInput, setDeathDateInput] = useState("");
+ const [nowMs, setNowMs] = useState(() => Date.now());
+
+ useEffect(() => {
+ const timerId = window.setInterval(() => setNowMs(Date.now()), 1000);
+ return () => window.clearInterval(timerId);
+ }, []);
+
+ const loadProfile = useCallback(async () => {
+ if (!user || !canRead) {
+ setLoading(false);
+ return;
+ }
+
+ setLoading(true);
+ setError("");
+ const response = await fetchWithAuth("/api/v1/admin/life-countdown/current");
+ if (!response.ok) {
+ setError(await readApiError(response));
+ setLoading(false);
+ return;
+ }
+
+ const payload = (await response.json()) as LifeCountdownProfile;
+ setProfile(payload);
+ setDeathDateInput(toDateInputValue(payload.deathDate));
+ setLoading(false);
+ }, [canRead, fetchWithAuth, user]);
+
+ useEffect(() => {
+ void loadProfile();
+ }, [loadProfile]);
+
+ const countdown = useMemo(
+ () => calculateCountdown(profile?.deathDate, nowMs),
+ [profile?.deathDate, nowMs],
+ );
+
+ const handleSave = async () => {
+ if (!canManage) {
+ return;
+ }
+ if (!deathDateInput) {
+ setError("请选择死亡日期");
+ return;
+ }
+
+ setSaving(true);
+ setError("");
+ setSuccess("");
+
+ const response = await fetchWithAuth("/api/v1/admin/life-countdown/save", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ deathDate: deathDateInput }),
+ });
+
+ if (!response.ok) {
+ setError(await readApiError(response));
+ setSaving(false);
+ return;
+ }
+
+ const payload = (await response.json()) as LifeCountdownProfile;
+ setProfile(payload);
+ setDeathDateInput(toDateInputValue(payload.deathDate));
+ setSuccess("死亡日期已保存");
+ setSaving(false);
+ };
+
+ const handleGenerateWarning = async (forceRefresh: boolean) => {
+ if (!canManage) {
+ return;
+ }
+ if (!profile?.deathDate) {
+ setError("请先设置死亡日期");
+ return;
+ }
+
+ setGenerating(true);
+ setError("");
+ setSuccess("");
+
+ const response = await fetchWithAuth("/api/v1/admin/life-countdown/generate-warning", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ forceRefresh }),
+ });
+
+ if (!response.ok) {
+ setError(await readApiError(response));
+ setGenerating(false);
+ return;
+ }
+
+ const warning = (await response.json()) as LifeCountdownWarning;
+ setProfile((prev) => ({
+ ...(prev ?? {}),
+ deathDate: prev?.deathDate,
+ todayWarningText: warning.warningText,
+ todayWarningDate: warning.warningDate,
+ todayWarningGeneratedAt: warning.generatedAt,
+ todayWarningModel: warning.modelName,
+ }));
+
+ setSuccess(warning.cached ? "已返回今日缓存警示语" : forceRefresh ? "已重新生成今日警示语" : "已生成今日警示语");
+ setGenerating(false);
+ };
+
+ if (initializing || loading) {
+ return Loading life countdown...
;
+ }
+
+ if (!user) {
+ return 请先登录后再访问该页面。
;
+ }
+
+ if (!canRead) {
+ return 你没有访问该页面的权限(需要 `life_countdown.read`)。
;
+ }
+
+ return (
+
+ {error &&
{error}}
+ {success &&
{success}}
+
+
+
+
+
生命倒计时
+
设定你的死亡日期,看清剩余时间,并用一句话把自己拉回今天。
+
+
+ {profile?.deathDate ? "已设定日期" : "未设定日期"}
+
+
+
+
+
+
+
+
+
死亡日期
+
倒计时按所选日期当天 23:59:59 结束。
+
+
void handleSave()} disabled={!canManage || saving}>
+ {saving ? "保存中..." : "保存日期"}
+
+
+
+
+
+
+
+
- 当前设定
+ - {formatCalendarDate(profile?.deathDate)}
+
+
+
- 最后更新
+ - {profile?.updateDate ? new Date(profile.updateDate).toLocaleString() : "--"}
+
+
+
- 今日文案缓存
+ - {formatCalendarDate(profile?.todayWarningDate)}
+
+
+
+
+
+
+
剩余时间
+
不是抽象的人生,而是精确减少的今天。
+
+
+ {!profile?.deathDate ? (
+
+ 先设定死亡日期,再开始倒数。
+
+ ) : countdown?.expired ? (
+
+
设定日期已到
+
这一天已经过去。要么重新设定日期,要么立刻处理今天最重要的事。
+
+ ) : countdown ? (
+ <>
+
+
+
目标日期
+
{formatCalendarDate(profile.deathDate)}
+
+
+
剩余总天数
+
{countdown.totalDays}
+
+
+
今天
+
{formatCalendarDate(new Date().toISOString())}
+
+
+
+
+
+
{countdown.years}
+
年
+
+
+
+
{padTime(countdown.hours)}
+
小时
+
+
+
{padTime(countdown.minutes)}
+
分钟
+
+
+
{padTime(countdown.seconds)}
+
秒
+
+
+ >
+ ) : (
+ 无法解析当前倒计时日期。
+ )}
+
+
+
+
+
+
+
今日警示语
+
按天缓存。重新生成会覆盖今天的文案。
+
+
+ void handleGenerateWarning(false)}
+ >
+ {generating ? "生成中..." : "生成今日警示语"}
+
+ void handleGenerateWarning(true)}
+ >
+ 重新生成
+
+
+
+
+ {profile?.todayWarningText ? (
+
+
{profile.todayWarningText}
+
+ 日期:{formatCalendarDate(profile.todayWarningDate)}
+ 生成时间:{profile.todayWarningGeneratedAt ? new Date(profile.todayWarningGeneratedAt).toLocaleString() : "--"}
+ 模型:{profile.todayWarningModel || "--"}
+
+
+ ) : (
+
+ 暂无今日警示语,点击上方按钮生成。
+
+ )}
+
+
+ );
+}
diff --git a/web/src/app/admin/mcp-server/page.tsx b/web/src/app/admin/mcp-server/page.tsx
new file mode 100644
index 0000000..82d9ac1
--- /dev/null
+++ b/web/src/app/admin/mcp-server/page.tsx
@@ -0,0 +1 @@
+export { default } from "@/app/admin/models/page";
diff --git a/web/src/app/admin/mdresolve/page.tsx b/web/src/app/admin/mdresolve/page.tsx
new file mode 100644
index 0000000..bfe0433
--- /dev/null
+++ b/web/src/app/admin/mdresolve/page.tsx
@@ -0,0 +1,202 @@
+"use client";
+
+import Link from "next/link";
+import { ChangeEvent, useMemo, useState } from "react";
+import { useMutation } from "@tanstack/react-query";
+import { Button, Table, TextArea } from "@radix-ui/themes";
+
+import { useAuth } from "@/components/auth-provider";
+import { readApiError } from "@/lib/api";
+import type { MdResolveImportResponse, MdResolveParseResponse } from "@/types/auth";
+
+function normalizeError(error: unknown): string {
+ return error instanceof Error ? error.message : "操作失败";
+}
+
+export default function AdminMdResolvePage() {
+ const { user, initializing, fetchWithAuth, hasPermission } = useAuth();
+
+ const [markdown, setMarkdown] = useState("");
+ const [parseResult, setParseResult] = useState(null);
+ const [importResult, setImportResult] = useState(null);
+ const [error, setError] = useState("");
+
+ const canRead = hasPermission("question_bank.read") || hasPermission("question_bank.manage");
+ const canManage = hasPermission("question_bank.manage");
+
+ const parseMutation = useMutation({
+ mutationFn: async () => {
+ const response = await fetchWithAuth("/api/v1/admin/mdresolve/parse", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ markdown }),
+ });
+ if (!response.ok) {
+ throw new Error(await readApiError(response));
+ }
+ return (await response.json()) as MdResolveParseResponse;
+ },
+ onSuccess: (data) => {
+ setError("");
+ setImportResult(null);
+ setParseResult(data);
+ },
+ onError: (candidate) => {
+ setError(normalizeError(candidate));
+ },
+ });
+
+ const importMutation = useMutation({
+ mutationFn: async () => {
+ if (!parseResult || parseResult.items.length === 0) {
+ throw new Error("没有可导入的解析结果");
+ }
+ const response = await fetchWithAuth("/api/v1/admin/mdresolve/import", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ items: parseResult.items }),
+ });
+ if (!response.ok) {
+ throw new Error(await readApiError(response));
+ }
+ return (await response.json()) as MdResolveImportResponse;
+ },
+ onSuccess: (data) => {
+ setError("");
+ setImportResult(data);
+ },
+ onError: (candidate) => {
+ setError(normalizeError(candidate));
+ },
+ });
+
+ const previewRows = useMemo(() => parseResult?.items ?? [], [parseResult]);
+
+ if (initializing) {
+ return Loading mdresolve...
;
+ }
+
+ if (!user) {
+ return (
+
+ 请先登录后再访问 MD 解析页面。
+ 返回首页
+
+ );
+ }
+
+ if (!canRead) {
+ return (
+
+ 你没有访问该页面的权限(需要 `question_bank.read`)。
+ 返回后台首页
+
+ );
+ }
+
+ return (
+
+ {error && (
+
{error}
+ )}
+
+
+
+
MD 解析
+
粘贴 Markdown 题目内容,解析后可批量导入题库。
+
+
+
+
+
+ {parseResult && (
+
+
+
解析结果({parseResult.total})
+ {parseResult.warnings.length > 0 && (
+ 警告 {parseResult.warnings.length} 条
+ )}
+
+
+ {parseResult.warnings.length > 0 && (
+
+ {parseResult.warnings.map((warning, index) => (
+ - {warning}
+ ))}
+
+ )}
+
+
+
+
+
+ 题型
+ 题干
+ 答案
+ 难度
+ 状态
+
+
+
+ {previewRows.map((item, index) => (
+
+ {item.question_type}
+
+ {item.stem}
+
+ {item.answer}
+ {item.difficulty}
+ {item.status}
+
+ ))}
+
+
+
+
+ )}
+
+ {importResult && (
+
+ 导入成功:{importResult.created_count} 条。
+ {importResult.warnings.length > 0 && (
+
+ {importResult.warnings.map((warning, index) => (
+ - {warning}
+ ))}
+
+ )}
+
+ )}
+
+ );
+}
diff --git a/web/src/app/admin/menus/page.tsx b/web/src/app/admin/menus/page.tsx
index 36eb8bf..e99ff63 100644
--- a/web/src/app/admin/menus/page.tsx
+++ b/web/src/app/admin/menus/page.tsx
@@ -4,7 +4,7 @@ import { ChangeEvent, useEffect, useMemo, useState, useCallback } from "react";
import Link from "next/link";
import { useAuth } from "@/components/auth-provider";
-import { Select, TextField } from "@radix-ui/themes";
+import { Checkbox, Dialog, Select, TextField, Button, Table } from "@radix-ui/themes";
import { useTopicSubscription } from "@/hooks/use-topic-subscription";
import { readApiError } from "@/lib/api";
import type { MenuItem, MenuListResponse } from "@/types/auth";
@@ -46,6 +46,7 @@ export default function AdminMenusPage() {
const [error, setError] = useState("");
const [success, setSuccess] = useState("");
const [editingMenuId, setEditingMenuId] = useState(null);
+ const [dialogOpen, setDialogOpen] = useState(false);
const [form, setForm] = useState(EMPTY_FORM);
const [keyword, setKeyword] = useState("");
const [statusFilter, setStatusFilter] = useState<"all" | "enabled" | "disabled">("all");
@@ -53,7 +54,7 @@ export default function AdminMenusPage() {
const canRead = hasPermission("menu.read") || hasPermission("menu.manage");
const canManage = hasPermission("menu.manage");
- const protectedMenuCodes = new Set(["dashboard", "admin.users", "admin.roles", "admin.menus", "admin.files", "admin.requirements", "admin.models"]);
+ const protectedMenuCodes = new Set(["dashboard", "admin.users", "admin.roles", "admin.menus", "admin.system_params", "admin.wxapp", "admin.system_message", "admin.code_review", "admin.git_desktop", "admin.agent", "admin.mcp_server", "admin.files", "admin.filedetector", "admin.baidu_pan", "admin.requirements", "admin.data_query", "admin.hot_search", "admin.schedule", "admin.cron_task_mgr", "admin.queue_mgr", "admin.todos", "admin.mindmap", "admin.knowledge_mastery", "admin.mdresolve", "admin.mermaid_mgr", "admin.tag", "admin.knowledge_point_mgr", "admin.question_bank", "admin.homework", "admin.job_mgr", "admin.history", "admin.vocabulary", "admin.diary", "admin.syslog", "admin.chat", "admin.models", "admin.password", "admin.token_usage", "admin.jwt_generator", "admin.life_countdown", "admin.api_tester", "admin.orchestration"]);
const parentOptions = useMemo(() => menus.map((menu) => ({ id: menu.id, label: `${menu.name} (${menu.code})` })), [menus]);
const menuNameById = useMemo(() => {
@@ -137,6 +138,13 @@ export default function AdminMenusPage() {
const resetForm = () => {
setEditingMenuId(null);
setForm(EMPTY_FORM);
+ setDialogOpen(false);
+ };
+
+ const startCreate = () => {
+ setEditingMenuId(null);
+ setForm(EMPTY_FORM);
+ setDialogOpen(true);
};
const startEdit = (menu: MenuItem) => {
@@ -155,6 +163,7 @@ export default function AdminMenusPage() {
component: menu.component ?? "",
permission_code: menu.permission_code ?? "",
});
+ setDialogOpen(true);
};
const submit = async () => {
@@ -222,14 +231,14 @@ export default function AdminMenusPage() {
};
if (initializing || loading) {
- return Loading menus...
;
+ return Loading menus...
;
}
if (!user) {
return (
- 请先登录后再访问菜单管理页面。
- 返回首页
+ 请先登录后再访问菜单管理页面。
+ 返回首页
);
}
@@ -237,8 +246,8 @@ export default function AdminMenusPage() {
if (!canRead) {
return (
- 你没有访问该页面的权限(需要 `menu.read`)。
- 返回首页
+ 你没有访问该页面的权限(需要 `menu.read`)。
+ 返回首页
);
}
@@ -246,38 +255,45 @@ export default function AdminMenusPage() {
return (
{error && (
-
{error}
+
{error}
)}
{success && (
-
{success}
+
{success}
)}
-
- 菜单列表
- 维护后台导航菜单与访问权限。
+
+
+
+
菜单列表
+
维护后台导航菜单与访问权限。
+
+ {canManage && (
+
新建菜单
+ )}
+
-
总菜单数
+
总菜单数
{stats.total}
-
启用
-
{stats.enabled}
+
启用
+
{stats.enabled}
-
禁用
-
{stats.disabled}
+
禁用
+
{stats.disabled}
-
顶级菜单
+
顶级菜单
{stats.topLevel}
-
-
-
- | ID |
- Code |
- Name |
- Path |
- Permission |
- Parent |
- Sort |
- {canManage && 操作 | }
-
-
-
+
+
+
+ ID
+ 编码
+ 名称
+ 路径
+ 权限码
+ 父菜单
+ 排序
+ {canManage && 操作}
+
+
+
{filteredMenus.map((menu) => (
-
- | {menu.id} |
- {menu.code} |
- {menu.name} |
- {menu.path ?? "-"} |
- {menu.permission_code ?? "-"} |
- {menu.parent_id ? (menuNameById.get(menu.parent_id) ?? menu.parent_id) : "-"} |
- {menu.sort_order} |
+
+ {menu.id}
+ {menu.code}
+ {menu.name}
+ {menu.path ?? "-"}
+ {menu.permission_code ?? "-"}
+ {menu.parent_id ? (menuNameById.get(menu.parent_id) ?? menu.parent_id) : "-"}
+ {menu.sort_order}
{canManage && (
-
+
- startEdit(menu)}
type="button"
>
编辑
-
+
{!protectedMenuCodes.has(menu.code) && (
- void removeMenu(menu)}
type="button"
>
删除
-
+
)}
- |
+
)}
-
+
))}
{filteredMenus.length === 0 && (
-
- |
+
+
未找到符合筛选条件的菜单项。
- |
-
+
+
)}
-
-
+
+
{canManage && (
-
-
-
-
{editingMenuId ? "编辑菜单" : "新建菜单"}
-
支持层级菜单、权限码和排序。
+
{
+ if (!open) {
+ resetForm();
+ }
+ }}
+ >
+
+
+
+
{editingMenuId ? "编辑菜单" : "新建菜单"}
+
支持层级菜单、权限码和排序。
+
+
取消
- {editingMenuId && (
- 取消编辑
- )}
-
-
-
-
-
-
-
+
+
)}
);
diff --git a/web/src/app/admin/mermaid-mgr/page.tsx b/web/src/app/admin/mermaid-mgr/page.tsx
new file mode 100644
index 0000000..e19546a
--- /dev/null
+++ b/web/src/app/admin/mermaid-mgr/page.tsx
@@ -0,0 +1 @@
+export { default } from "../mdresolve/page";
diff --git a/web/src/app/admin/mindmap/page.tsx b/web/src/app/admin/mindmap/page.tsx
new file mode 100644
index 0000000..e8f9588
--- /dev/null
+++ b/web/src/app/admin/mindmap/page.tsx
@@ -0,0 +1,584 @@
+"use client";
+
+import Link from "next/link";
+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
+import { ChangeEvent, useCallback, useMemo, useState } from "react";
+import { Button, Dialog, Select, Table, TextArea, TextField } from "@radix-ui/themes";
+
+import { useAuth } from "@/components/auth-provider";
+import { useTopicSubscription } from "@/hooks/use-topic-subscription";
+import { readApiError } from "@/lib/api";
+import type {
+ QuestionBankListResponse,
+ QuestionBankSummary,
+ QuestionDifficulty,
+ QuestionStatus,
+ QuestionType,
+} from "@/types/auth";
+
+type FormState = {
+ question_type: QuestionType;
+ stem: string;
+ options_text: string;
+ answer: string;
+ analysis: string;
+ difficulty: QuestionDifficulty;
+ status: QuestionStatus;
+ tags_text: string;
+};
+
+type Filters = {
+ keyword: string;
+ status: "all" | QuestionStatus;
+ difficulty: "all" | QuestionDifficulty;
+ question_type: "all" | QuestionType;
+ tag: string;
+};
+
+const DEFAULT_FILTERS: Filters = {
+ keyword: "",
+ status: "all",
+ difficulty: "all",
+ question_type: "all",
+ tag: "",
+};
+
+const EMPTY_FORM: FormState = {
+ question_type: "single_choice",
+ stem: "",
+ options_text: "",
+ answer: "",
+ analysis: "",
+ difficulty: "medium",
+ status: "draft",
+ tags_text: "",
+};
+
+const QUESTION_TYPE_LABEL: Record = {
+ single_choice: "单选题",
+ multiple_choice: "多选题",
+ true_false: "判断题",
+ short_answer: "简答题",
+};
+
+const DIFFICULTY_LABEL: Record = {
+ easy: "简单",
+ medium: "中等",
+ hard: "困难",
+};
+
+const STATUS_LABEL: Record = {
+ draft: "草稿",
+ published: "已发布",
+ archived: "已归档",
+};
+
+function parseOptions(text: string): Array> | null {
+ const lines = text
+ .split("\n")
+ .map((line) => line.trim())
+ .filter(Boolean);
+ if (lines.length === 0) {
+ return null;
+ }
+
+ return lines.map((line, index) => {
+ const [maybeKey, ...rest] = line.split(":");
+ if (rest.length === 0) {
+ const key = String.fromCharCode(65 + index);
+ return { key, content: maybeKey.trim() };
+ }
+ return { key: maybeKey.trim(), content: rest.join(":").trim() };
+ });
+}
+
+function serializeOptions(options: Array> | null): string {
+ if (!options || options.length === 0) {
+ return "";
+ }
+ return options
+ .map((item, index) => {
+ const key = typeof item.key === "string" ? item.key : String.fromCharCode(65 + index);
+ const content = typeof item.content === "string" ? item.content : "";
+ return `${key}: ${content}`;
+ })
+ .join("\n");
+}
+
+function normalizeTags(tagsText: string): string[] {
+ return Array.from(
+ new Set(
+ tagsText
+ .split(",")
+ .map((item) => item.trim())
+ .filter(Boolean),
+ ),
+ );
+}
+
+export default function AdminMindmapPage() {
+ const { user, initializing, fetchWithAuth, hasPermission } = useAuth();
+ const queryClient = useQueryClient();
+
+ const [filters, setFilters] = useState(DEFAULT_FILTERS);
+ const [dialogOpen, setDialogOpen] = useState(false);
+ const [editingId, setEditingId] = useState(null);
+ const [form, setForm] = useState(EMPTY_FORM);
+ const [error, setError] = useState("");
+ const [success, setSuccess] = useState("");
+
+ const canRead = hasPermission("question_bank.read") || hasPermission("question_bank.manage");
+ const canManage = hasPermission("question_bank.manage");
+
+ const listPath = useMemo(() => {
+ const params = new URLSearchParams();
+ if (filters.keyword.trim()) params.set("keyword", filters.keyword.trim());
+ if (filters.status !== "all") params.set("status", filters.status);
+ if (filters.difficulty !== "all") params.set("difficulty", filters.difficulty);
+ if (filters.question_type !== "all") params.set("question_type", filters.question_type);
+ if (filters.tag.trim()) params.set("tag", filters.tag.trim());
+
+ const query = params.toString();
+ return `/api/v1/admin/question-bank${query ? `?${query}` : ""}`;
+ }, [filters]);
+
+ const listQuery = useQuery({
+ queryKey: [listPath],
+ enabled: !!user && canRead,
+ queryFn: async () => {
+ const response = await fetchWithAuth(listPath);
+ if (!response.ok) {
+ throw new Error(await readApiError(response));
+ }
+ return (await response.json()) as QuestionBankListResponse;
+ },
+ });
+
+ const refreshList = useCallback(async () => {
+ await queryClient.invalidateQueries({
+ predicate: (query) =>
+ Array.isArray(query.queryKey)
+ && typeof query.queryKey[0] === "string"
+ && query.queryKey[0].startsWith("/api/v1/admin/question-bank"),
+ });
+ }, [queryClient]);
+
+ useTopicSubscription(
+ "admin.question_bank",
+ useCallback(() => {
+ void refreshList();
+ }, [refreshList]),
+ );
+
+ const resetForm = useCallback(() => {
+ setEditingId(null);
+ setForm(EMPTY_FORM);
+ setDialogOpen(false);
+ }, []);
+
+ const startCreate = () => {
+ setError("");
+ setSuccess("");
+ setEditingId(null);
+ setForm(EMPTY_FORM);
+ setDialogOpen(true);
+ };
+
+ const startEdit = (item: QuestionBankSummary) => {
+ setError("");
+ setSuccess("");
+ setEditingId(item.id);
+ setForm({
+ question_type: item.question_type,
+ stem: item.stem,
+ options_text: serializeOptions(item.options_json),
+ answer: item.answer,
+ analysis: item.analysis ?? "",
+ difficulty: item.difficulty,
+ status: item.status,
+ tags_text: (item.tags_json ?? []).join(", "),
+ });
+ setDialogOpen(true);
+ };
+
+ const saveMutation = useMutation({
+ mutationFn: async () => {
+ if (!canManage) {
+ throw new Error("缺少 question_bank.manage 权限");
+ }
+
+ if (!form.stem.trim() || !form.answer.trim()) {
+ throw new Error("题干和答案不能为空");
+ }
+
+ const payload = {
+ question_type: form.question_type,
+ stem: form.stem.trim(),
+ options_json: parseOptions(form.options_text),
+ answer: form.answer.trim(),
+ analysis: form.analysis.trim() || null,
+ difficulty: form.difficulty,
+ status: form.status,
+ tags_json: normalizeTags(form.tags_text),
+ };
+
+ const url = editingId === null
+ ? "/api/v1/admin/question-bank"
+ : `/api/v1/admin/question-bank/${editingId}`;
+
+ const method = editingId === null ? "POST" : "PATCH";
+
+ const response = await fetchWithAuth(url, {
+ method,
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(payload),
+ });
+
+ if (!response.ok) {
+ throw new Error(await readApiError(response));
+ }
+
+ return editingId === null ? "created" : "updated";
+ },
+ onSuccess: async (mode) => {
+ setError("");
+ setSuccess(mode === "created" ? "题目已创建" : "题目已更新");
+ resetForm();
+ await refreshList();
+ },
+ onError: (candidate) => {
+ setSuccess("");
+ setError(candidate instanceof Error ? candidate.message : "保存失败");
+ },
+ });
+
+ const deleteMutation = useMutation({
+ mutationFn: async (item: QuestionBankSummary) => {
+ const response = await fetchWithAuth(`/api/v1/admin/question-bank/${item.id}`, {
+ method: "DELETE",
+ });
+ if (!response.ok) {
+ throw new Error(await readApiError(response));
+ }
+ return item.id;
+ },
+ onSuccess: async (deletedId) => {
+ if (editingId === deletedId) {
+ resetForm();
+ }
+ setError("");
+ setSuccess("题目已删除");
+ await refreshList();
+ },
+ onError: (candidate) => {
+ setSuccess("");
+ setError(candidate instanceof Error ? candidate.message : "删除失败");
+ },
+ });
+
+ const items = listQuery.data?.items ?? [];
+ const listError = listQuery.error instanceof Error ? listQuery.error.message : "";
+
+ if (initializing || listQuery.isLoading) {
+ return Loading question bank...
;
+ }
+
+ if (!user) {
+ return (
+
+ 请先登录后再访问试题管理页面。
+ 返回首页
+
+ );
+ }
+
+ if (!canRead) {
+ return (
+
+ 你没有访问该页面的权限(需要 `question_bank.read`)。
+ 返回首页
+
+ );
+ }
+
+ return (
+
+ {(error || listError) && (
+
{error || listError}
+ )}
+ {success && (
+
{success}
+ )}
+
+
+
+
+
试题管理
+
迁移 quiz exam_mgr 菜单能力:题目列表、筛选、编辑与状态管理。
+
+ {canManage && (
+
+ 新建题目
+
+ )}
+
+
+
+ ) =>
+ setFilters((prev) => ({ ...prev, keyword: event.currentTarget.value }))
+ }
+ placeholder="按题干/答案筛选"
+ className="w-full md:col-span-2"
+ />
+
+
+ setFilters((prev) => ({ ...prev, status: value as Filters["status"] }))
+ }
+ >
+
+
+ 全部状态
+ 草稿
+ 已发布
+ 已归档
+
+
+
+
+ setFilters((prev) => ({ ...prev, difficulty: value as Filters["difficulty"] }))
+ }
+ >
+
+
+ 全部难度
+ 简单
+ 中等
+ 困难
+
+
+
+ ) =>
+ setFilters((prev) => ({ ...prev, tag: event.currentTarget.value }))
+ }
+ placeholder="标签筛选"
+ className="w-full"
+ />
+
+
+
+
+
+
+ ID
+ 题型
+ 题干
+ 难度
+ 状态
+ 标签
+ 更新时间
+ {canManage && 操作}
+
+
+
+ {items.map((item) => (
+
+ {item.id}
+ {QUESTION_TYPE_LABEL[item.question_type]}
+
+
+
{item.stem}
+
答案:{item.answer}
+
+
+ {DIFFICULTY_LABEL[item.difficulty]}
+ {STATUS_LABEL[item.status]}
+ {(item.tags_json ?? []).join(", ") || "-"}
+ {new Date(item.updated_at).toLocaleString()}
+ {canManage && (
+
+
+ startEdit(item)}
+ >
+ 编辑
+
+ {
+ if (!window.confirm(`确认删除题目 #${item.id} 吗?`)) {
+ return;
+ }
+ deleteMutation.mutate(item);
+ }}
+ >
+ 删除
+
+
+
+ )}
+
+ ))}
+ {items.length === 0 && (
+
+
+ 暂无题目数据。
+
+
+ )}
+
+
+
+
+
+
+
+ {editingId === null ? "新建题目" : `编辑题目 #${editingId}`}
+
+ 支持题型、选项、答案、解析、难度、状态与标签管理。
+
+
+
+
+ 题型
+
+ setForm((prev) => ({ ...prev, question_type: value as QuestionType }))
+ }
+ >
+
+
+ 单选题
+ 多选题
+ 判断题
+ 简答题
+
+
+
+
+
+ 难度
+
+ setForm((prev) => ({ ...prev, difficulty: value as QuestionDifficulty }))
+ }
+ >
+
+
+ 简单
+ 中等
+ 困难
+
+
+
+
+
+ 题干
+
+
+
+ 选项(每行一项,格式:A: 选项内容;简答题可留空)
+
+
+
+ 答案
+
+
+
+ 解析
+
+
+
+ 状态
+
+ setForm((prev) => ({ ...prev, status: value as QuestionStatus }))
+ }
+ >
+
+
+ 草稿
+ 已发布
+ 已归档
+
+
+
+
+
+ 标签(逗号分隔)
+ ) =>
+ setForm((prev) => ({ ...prev, tags_text: event.currentTarget.value }))
+ }
+ placeholder="例如:数学, 函数"
+ className="w-full"
+ />
+
+
+
+
+ saveMutation.mutate()}
+ disabled={saveMutation.isPending}
+ >
+ {saveMutation.isPending ? "提交中..." : editingId === null ? "创建" : "保存"}
+
+
+ 取消
+
+
+
+
+
+ );
+}
diff --git a/web/src/app/admin/models/page.tsx b/web/src/app/admin/models/page.tsx
index 5df05aa..01cef9c 100644
--- a/web/src/app/admin/models/page.tsx
+++ b/web/src/app/admin/models/page.tsx
@@ -5,7 +5,7 @@ import Link from "next/link";
import { ChangeEvent, useCallback, useMemo, useState } from "react";
import { useAuth } from "@/components/auth-provider";
-import { Dialog, Select, TextArea, TextField } from "@radix-ui/themes";
+import { Checkbox, Dialog, Select, TextArea, TextField, Button, Table } from "@radix-ui/themes";
import { useTopicSubscription } from "@/hooks/use-topic-subscription";
import { readApiError } from "@/lib/api";
import type {
@@ -16,6 +16,10 @@ import type {
ModelRouteType,
ModelStatus,
ModelSummaryResponse,
+ ModelTestChatResponse,
+ ModelTestRunItem,
+ ModelTestRunListResponse,
+ ModelTestStatus,
} from "@/types/auth";
const MODEL_STATUS_OPTIONS: ModelStatus[] = ["DRAFT", "ENABLED", "DISABLED", "DEPRECATED"];
@@ -36,6 +40,10 @@ const HEALTH_STATUS_LABELS: Record = {
DEGRADED: "退化",
UNHEALTHY: "不健康",
};
+const TEST_STATUS_LABELS: Record = {
+ PASSED: "通过",
+ FAILED: "失败",
+};
const ROUTE_TYPE_OPTIONS: ModelRouteType[] = ["GLOBAL", "CAPABILITY", "BUSINESS", "AGENT"];
const GLOBAL_ROUTE_KEY = "__global__";
const MODEL_STATUS_ALL_FILTER = "__all_model_status__";
@@ -71,6 +79,17 @@ const EMPTY_ROUTE_FORM = {
note: "",
};
+const EMPTY_TEST_FORM = {
+ kind: "SMOKE",
+ input_tokens: "16",
+ output_tokens: "32",
+};
+
+const EMPTY_CHAT_TEST_FORM = {
+ message: "",
+ system_prompt: "",
+};
+
function parseCapabilities(value: string): string[] {
return value
.split(",")
@@ -94,6 +113,10 @@ function formatHealthStatus(status: ModelHealthStatus | null): string {
return `${HEALTH_STATUS_LABELS[status]}(${status})`;
}
+function formatTestStatus(status: ModelTestStatus): string {
+ return `${TEST_STATUS_LABELS[status]}(${status})`;
+}
+
async function invalidateModelQueries(
queryClient: QueryClient,
modelsPath: string,
@@ -125,6 +148,16 @@ export default function AdminModelsPage() {
const [showRouteModal, setShowRouteModal] = useState(false);
const [routeForm, setRouteForm] = useState(EMPTY_ROUTE_FORM);
+ const [showTestModal, setShowTestModal] = useState(false);
+ const [testingModel, setTestingModel] = useState(null);
+ const [testForm, setTestForm] = useState(EMPTY_TEST_FORM);
+ const [testRunHistory, setTestRunHistory] = useState([]);
+
+ const [showChatTestModal, setShowChatTestModal] = useState(false);
+ const [chatTestingModel, setChatTestingModel] = useState(null);
+ const [chatTestForm, setChatTestForm] = useState(EMPTY_CHAT_TEST_FORM);
+ const [chatTestResult, setChatTestResult] = useState(null);
+
const modelsPath = useMemo(() => {
const params = new URLSearchParams();
if (statusFilter) params.set("status", statusFilter);
@@ -135,6 +168,32 @@ export default function AdminModelsPage() {
const summaryPath = "/api/v1/admin/models/summary";
const routesPath = "/api/v1/admin/model-routes";
+ const resetModelModal = useCallback(() => {
+ setEditingModelId(null);
+ setShowModelModal(false);
+ setModelForm(EMPTY_MODEL_FORM);
+ }, []);
+
+ const resetRouteModal = useCallback(() => {
+ setEditingRouteId(null);
+ setShowRouteModal(false);
+ setRouteForm(EMPTY_ROUTE_FORM);
+ }, []);
+
+ const resetTestModal = useCallback(() => {
+ setShowTestModal(false);
+ setTestingModel(null);
+ setTestForm(EMPTY_TEST_FORM);
+ setTestRunHistory([]);
+ }, []);
+
+ const resetChatTestModal = useCallback(() => {
+ setShowChatTestModal(false);
+ setChatTestingModel(null);
+ setChatTestForm(EMPTY_CHAT_TEST_FORM);
+ setChatTestResult(null);
+ }, []);
+
const loadModels = useCallback(async () => {
const response = await fetchWithAuth(modelsPath);
if (!response.ok) {
@@ -233,9 +292,7 @@ export default function AdminModelsPage() {
onSuccess: async () => {
setSuccess(editingModelId ? "模型已更新" : "模型已创建");
setError("");
- setEditingModelId(null);
- setShowModelModal(false);
- setModelForm(EMPTY_MODEL_FORM);
+ resetModelModal();
await invalidateModelQueries(queryClient, modelsPath, summaryPath, routesPath);
},
onError: (candidate) => {
@@ -281,9 +338,7 @@ export default function AdminModelsPage() {
setError("");
setSuccess("模型已删除");
if (editingModelId) {
- setEditingModelId(null);
- setShowModelModal(false);
- setModelForm(EMPTY_MODEL_FORM);
+ resetModelModal();
}
await invalidateModelQueries(queryClient, modelsPath, summaryPath, routesPath);
},
@@ -338,28 +393,111 @@ export default function AdminModelsPage() {
});
const testMutation = useMutation({
- mutationFn: async (modelId: number) => {
- const response = await fetchWithAuth(`/api/v1/admin/models/${modelId}/tests`, {
+ mutationFn: async () => {
+ if (!testingModel) {
+ throw new Error("未选择待测试模型");
+ }
+ const payload = {
+ kind: testForm.kind.trim().toUpperCase() || "SMOKE",
+ input_tokens: Number(testForm.input_tokens || 0),
+ output_tokens: Number(testForm.output_tokens || 0),
+ };
+ const response = await fetchWithAuth(`/api/v1/admin/models/${testingModel.id}/tests`, {
method: "POST",
headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ kind: "SMOKE", input_tokens: 16, output_tokens: 32 }),
+ body: JSON.stringify(payload),
});
if (!response.ok) {
throw new Error(await readApiError(response));
}
- return response.json();
+ return (await response.json()) as ModelTestRunItem;
},
- onSuccess: async () => {
+ onSuccess: async (created) => {
setError("");
- setSuccess("冒烟测试已执行并计入统计");
+ setSuccess(`冒烟测试已执行:${formatTestStatus(created.status)}`);
+ setTestRunHistory((prev) => [created, ...prev].slice(0, 20));
await invalidateModelQueries(queryClient, modelsPath, summaryPath, routesPath);
},
onError: (candidate) => {
setSuccess("");
- setError(candidate instanceof Error ? candidate.message : "冒烟测试失败");
+ setError(candidate instanceof Error ? candidate.message : "模型测试失败");
},
});
+ const chatTestMutation = useMutation({
+ mutationFn: async () => {
+ if (!chatTestingModel) {
+ throw new Error("未选择待测试模型");
+ }
+ const payload = {
+ message: chatTestForm.message.trim(),
+ system_prompt: chatTestForm.system_prompt.trim() || null,
+ };
+ if (!payload.message) {
+ throw new Error("请输入测试内容");
+ }
+ const response = await fetchWithAuth(`/api/v1/admin/models/${chatTestingModel.id}/test-chat`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(payload),
+ });
+ if (!response.ok) {
+ throw new Error(await readApiError(response));
+ }
+ return (await response.json()) as ModelTestChatResponse;
+ },
+ onSuccess: async (result) => {
+ setError("");
+ setSuccess(`对话测试已执行:${formatTestStatus(result.test_status)}`);
+ setChatTestResult(result);
+ await invalidateModelQueries(queryClient, modelsPath, summaryPath, routesPath);
+ },
+ onError: (candidate) => {
+ setSuccess("");
+ setChatTestResult(null);
+ setError(candidate instanceof Error ? candidate.message : "对话测试失败");
+ },
+ });
+
+ const loadModelTests = useCallback(
+ async (modelId: number) => {
+ const response = await fetchWithAuth(`/api/v1/admin/models/${modelId}/tests?limit=20`);
+ if (!response.ok) {
+ throw new Error(await readApiError(response));
+ }
+ const data = (await response.json()) as ModelTestRunListResponse;
+ return data.items;
+ },
+ [fetchWithAuth],
+ );
+
+ const openTestModal = useCallback(
+ async (model: ModelRegistryItem) => {
+ setError("");
+ setSuccess("");
+ setTestingModel(model);
+ setShowTestModal(true);
+ setTestForm(EMPTY_TEST_FORM);
+ try {
+ const history = await loadModelTests(model.id);
+ setTestRunHistory(history);
+ } catch (candidate) {
+ setTestRunHistory([]);
+ setError(candidate instanceof Error ? candidate.message : "获取模型测试记录失败");
+ }
+ },
+ [loadModelTests],
+ );
+
+ const openChatTestModal = useCallback((model: ModelRegistryItem) => {
+ setError("");
+ setSuccess("");
+ setChatTestingModel(model);
+ setShowChatTestModal(true);
+ setChatTestForm(EMPTY_CHAT_TEST_FORM);
+ setChatTestResult(null);
+ }, []);
+
const saveRouteMutation = useMutation({
mutationFn: async () => {
const payload = {
@@ -396,9 +534,7 @@ export default function AdminModelsPage() {
onSuccess: async () => {
setError("");
setSuccess(editingRouteId ? "路由规则已更新" : "路由规则已创建");
- setEditingRouteId(null);
- setShowRouteModal(false);
- setRouteForm(EMPTY_ROUTE_FORM);
+ resetRouteModal();
await invalidateModelQueries(queryClient, modelsPath, summaryPath, routesPath);
},
onError: (candidate) => {
@@ -421,9 +557,7 @@ export default function AdminModelsPage() {
setError("");
setSuccess("路由规则已删除");
if (editingRouteId) {
- setEditingRouteId(null);
- setShowRouteModal(false);
- setRouteForm(EMPTY_ROUTE_FORM);
+ resetRouteModal();
}
await invalidateModelQueries(queryClient, modelsPath, summaryPath, routesPath);
},
@@ -491,14 +625,14 @@ export default function AdminModelsPage() {
}, [modelsQuery.error, routesQuery.error, summaryQuery.error]);
if (initializing || modelsQuery.isLoading || summaryQuery.isLoading || routesQuery.isLoading) {
- return Loading model management...
;
+ return Loading model management...
;
}
if (!user) {
return (
- 请先登录后再访问模型管理页面。
- 返回首页
+ 请先登录后再访问模型管理页面。
+ 返回首页
);
}
@@ -506,8 +640,8 @@ export default function AdminModelsPage() {
if (!canRead) {
return (
- 你没有访问该页面的权限(需要 `model.read`)。
- 返回首页
+ 你没有访问该页面的权限(需要 `model.read`)。
+ 返回首页
);
}
@@ -515,40 +649,40 @@ export default function AdminModelsPage() {
return (
{(error || queryError) && (
-
{error || queryError}
+
{error || queryError}
)}
{success && (
-
{success}
+
{success}
)}
-
-
模型总数
+
+
模型总数
{summary?.total_models ?? 0}
-
已启用: {summary?.status_counts.ENABLED ?? 0}
+
已启用: {summary?.status_counts.ENABLED ?? 0}
-
-
路由规则
+
+
路由规则
{summary?.total_route_rules ?? 0}
-
GLOBAL: {summary?.route_type_counts.GLOBAL ?? 0}
+
GLOBAL: {summary?.route_type_counts.GLOBAL ?? 0}
-
-
近 7 天用量
+
+
近 7 天用量
{summary?.usage_7d.request_count ?? 0}
-
成功率: {formatPercent(summary?.usage_7d.success_rate ?? null)}
+
成功率: {formatPercent(summary?.usage_7d.success_rate ?? null)}
-
-
健康风险
+
+
健康风险
{summary?.enabled_without_healthy_check ?? 0}
-
ENABLED 且未健康
+
ENABLED 且未健康
-
+
模型列表
-
稳定 `code` 作为引用键,`name` 仅用于展示。
+
稳定 `code` 作为引用键,`name` 仅用于展示。
-
-
-
- | Code |
- Provider/Model |
- 状态 |
- 密钥 |
- 健康 |
- 7日用量 |
- 7日测试 |
- 路由绑定 |
- {canManage && 操作 | }
-
-
-
+
+
+
+ Code
+ Provider/Model
+ 状态
+ 密钥
+ 健康
+ 7日用量
+ 7日测试
+ 路由绑定
+ {canManage && 操作}
+
+
+
{models.map((model) => (
-
- |
+
+
{model.code}
- {model.name}
- |
-
+ {model.name}
+
+
{model.provider}
- {model.provider_model}
- |
-
+ {model.provider_model}
+
+
{formatModelStatus(model.status)}
- {model.capabilities.join(", ") || "-"}
- |
-
+ {model.capabilities.join(", ") || "-"}
+
+
{model.active_key_masked ?? "-"}
- v{model.active_key_version ?? "-"}
- |
-
+ v{model.active_key_version ?? "-"}
+
+
{formatHealthStatus(model.latest_health_status)}
- {model.latest_health_at ? new Date(model.latest_health_at).toLocaleString() : "-"}
- |
-
+ {model.latest_health_at ? new Date(model.latest_health_at).toLocaleString() : "-"}
+
+
请求: {model.usage_7d.request_count}
- 成功率: {formatPercent(model.usage_7d.success_rate)}
- |
-
+ 成功率: {formatPercent(model.usage_7d.success_rate)}
+
+
Runs: {model.tests_7d.total_runs}
- 通过率: {formatPercent(model.tests_7d.pass_rate)}
- |
- {model.route_bindings_count} |
+ 通过率: {formatPercent(model.tests_7d.pass_rate)}
+
+ {model.route_bindings_count}
{canManage && (
-
+
- startEditModel(model)}
>
编辑
-
-
+ {
const key = window.prompt(`为 ${model.code} 输入新 API Key`);
if (key && key.trim()) {
@@ -643,39 +777,47 @@ export default function AdminModelsPage() {
disabled={rotateKeyMutation.isPending}
>
轮换密钥
-
-
+ healthCheckMutation.mutate(model.id)}
disabled={healthCheckMutation.isPending}
>
健康检查
-
-
+ testMutation.mutate(model.id)}
+ color="gray" size="1" variant="soft"
+ onClick={() => openTestModal(model)}
disabled={testMutation.isPending}
>
冒烟测试
-
+
+ openChatTestModal(model)}
+ disabled={chatTestMutation.isPending}
+ >
+ 对话测试
+
{MODEL_STATUS_TRANSITIONS[model.status].map((nextStatus) => (
- transitionMutation.mutate({ modelId: model.id, status: nextStatus })}
disabled={transitionMutation.isPending}
>
{"-> "}
{formatModelStatus(nextStatus)}
-
+
))}
{model.status !== "ENABLED" && (
- {
if (window.confirm(`确认删除模型 ${model.code} 吗?`)) {
deleteModelMutation.mutate(model);
@@ -684,95 +826,95 @@ export default function AdminModelsPage() {
disabled={deleteModelMutation.isPending}
>
删除
-
+
)}
- |
+
)}
-
+
))}
-
-
+
+
{canManage && (
-
+
模型维护
-
新建/编辑模型已迁移为弹窗操作。
+
新建/编辑模型已迁移为弹窗操作。
-
{
- setEditingModelId(null);
setModelForm(EMPTY_MODEL_FORM);
+ setEditingModelId(null);
setShowModelModal(true);
}}
>
新建模型
-
+
)}
-
+
路由规则
-
支持 GLOBAL / CAPABILITY / BUSINESS / AGENT 四类规则。
+
支持 GLOBAL / CAPABILITY / BUSINESS / AGENT 四类规则。
{canManage && (
- {
- setEditingRouteId(null);
setRouteForm(EMPTY_ROUTE_FORM);
+ setEditingRouteId(null);
setShowRouteModal(true);
}}
>
新建路由规则
-
+
)}
-
-
-
- | 类型 |
- Key |
- 目标模型 Code |
- 优先级 |
- 状态 |
- {canManage && 操作 | }
-
-
-
+
+
+
+ 类型
+ Key
+ 目标模型 Code
+ 优先级
+ 状态
+ {canManage && 操作}
+
+
+
{routes.map((route) => (
-
- | {route.route_type} |
- {route.route_key} |
- {route.target_model_code} |
- {route.priority} |
- {route.enabled ? "启用" : "停用"} |
+
+ {route.route_type}
+ {route.route_key}
+ {route.target_model_code}
+ {route.priority}
+ {route.enabled ? "启用" : "停用"}
{canManage && (
-
+
- startEditRoute(route)}
>
编辑
-
-
+ {
if (window.confirm(`确认删除路由规则 ${route.route_type}:${route.route_key} 吗?`)) {
deleteRouteMutation.mutate(route.id);
@@ -781,14 +923,14 @@ export default function AdminModelsPage() {
disabled={deleteRouteMutation.isPending}
>
删除
-
+
- |
+
)}
-
+
))}
-
-
+
+
@@ -797,9 +939,7 @@ export default function AdminModelsPage() {
open={showModelModal}
onOpenChange={(open: boolean) => {
if (!open) {
- setEditingModelId(null);
- setShowModelModal(false);
- setModelForm(EMPTY_MODEL_FORM);
+ resetModelModal();
}
}}
>
@@ -807,19 +947,15 @@ export default function AdminModelsPage() {
{editingModelId ? "编辑模型" : "新建模型"}
-
创建时可设置初始密钥;编辑阶段仅维护模型元数据。
+
创建时可设置初始密钥;编辑阶段仅维护模型元数据。
-
{
- setEditingModelId(null);
- setShowModelModal(false);
- setModelForm(EMPTY_MODEL_FORM);
- }}
+ className="w-fit" color="gray" variant="soft"
+ onClick={resetModelModal}
>
关闭
-
+
@@ -928,14 +1064,14 @@ export default function AdminModelsPage() {
- saveModelMutation.mutate()}
>
{saveModelMutation.isPending ? "提交中..." : editingModelId ? "保存模型" : "创建模型"}
-
+
@@ -946,9 +1082,7 @@ export default function AdminModelsPage() {
open={showRouteModal}
onOpenChange={(open: boolean) => {
if (!open) {
- setEditingRouteId(null);
- setShowRouteModal(false);
- setRouteForm(EMPTY_ROUTE_FORM);
+ resetRouteModal();
}
}}
>
@@ -956,19 +1090,15 @@ export default function AdminModelsPage() {
{editingRouteId ? "编辑路由规则" : "新建路由规则"}
-
GLOBAL 规则的 key 固定为 {GLOBAL_ROUTE_KEY}。
+
GLOBAL 规则的 key 固定为 {GLOBAL_ROUTE_KEY}。
-
{
- setEditingRouteId(null);
- setShowRouteModal(false);
- setRouteForm(EMPTY_ROUTE_FORM);
- }}
+ className="w-fit" color="gray" variant="soft"
+ onClick={resetRouteModal}
>
关闭
-
+
@@ -1033,24 +1163,223 @@ export default function AdminModelsPage() {
/>
- ) => setRouteForm((prev) => ({ ...prev, enabled: event.currentTarget.checked }))}
+ onCheckedChange={(checked: boolean | "indeterminate") =>
+ setRouteForm((prev) => ({ ...prev, enabled: checked === true }))
+ }
/>
启用规则
- saveRouteMutation.mutate()}
>
{saveRouteMutation.isPending ? "提交中..." : editingRouteId ? "保存规则" : "创建规则"}
-
+
+
+
+
+ )}
+ {canManage && (
+ {
+ if (!open) {
+ resetTestModal();
+ }
+ }}
+ >
+
+
+
+
+ 冒烟测试:{testingModel ? `${testingModel.code} / ${testingModel.name}` : "-"}
+
+
支持自定义测试类型与输入/输出 token,提交后可查看最近 20 条测试记录。
+
+
+ 关闭
+
+
+
+
+
+ 测试类型
+ ) =>
+ setTestForm((prev) => ({ ...prev, kind: event.currentTarget.value }))
+ }
+ placeholder="SMOKE"
+ className="w-full"
+ />
+
+
+ 输入 Token
+ ) =>
+ setTestForm((prev) => ({ ...prev, input_tokens: event.currentTarget.value }))
+ }
+ className="w-full"
+ />
+
+
+ 输出 Token
+ ) =>
+ setTestForm((prev) => ({ ...prev, output_tokens: event.currentTarget.value }))
+ }
+ className="w-full"
+ />
+
+
+
+
+ testMutation.mutate()}
+ >
+ {testMutation.isPending ? "测试中..." : "执行测试"}
+
+
+
+
+
+
+
+ 时间
+ 类型
+ 状态
+ Token
+ 耗时
+ 错误
+
+
+
+ {testRunHistory.length === 0 ? (
+
+
+ 暂无测试记录
+
+
+ ) : (
+ testRunHistory.map((item) => (
+
+ {new Date(item.created_at).toLocaleString()}
+ {item.kind}
+ {formatTestStatus(item.status)}
+ {item.input_tokens} / {item.output_tokens}
+ {item.latency_ms ?? "-"} ms
+ {item.error_message ?? "-"}
+
+ ))
+ )}
+
+
+
+
+
+ )}
+ {canManage && (
+ {
+ if (!open) {
+ resetChatTestModal();
+ }
+ }}
+ >
+
+
+
+
+ 对话测试:{chatTestingModel ? `${chatTestingModel.code} / ${chatTestingModel.name}` : "-"}
+
+
向目标模型发送真实对话请求并展示回复、耗时与 token 统计。
+
+
+ 关闭
+
+
+
+
+
+ 用户输入
+
+
+ 系统提示词(可选)
+
+
+
+
+ chatTestMutation.mutate()}
+ >
+ {chatTestMutation.isPending ? "测试中..." : "执行对话测试"}
+
+
+
+
+ {chatTestResult ? (
+
+
+ 状态:{formatTestStatus(chatTestResult.test_status)}
+ 耗时:{chatTestResult.latency_ms ?? "-"} ms
+
+ Token:{chatTestResult.prompt_tokens ?? "-"} / {chatTestResult.completion_tokens ?? "-"} / {chatTestResult.total_tokens ?? "-"}
+
+
+
+ 模型:{chatTestResult.provider} / {chatTestResult.provider_model}
+
+ {chatTestResult.error_message ? (
+
{chatTestResult.error_message}
+ ) : (
+
{chatTestResult.reply ?? "(空回复)"}
+ )}
+
+ ) : (
+
尚未执行对话测试。
+ )}
diff --git a/web/src/app/admin/orchestration/page.tsx b/web/src/app/admin/orchestration/page.tsx
new file mode 100644
index 0000000..82d9ac1
--- /dev/null
+++ b/web/src/app/admin/orchestration/page.tsx
@@ -0,0 +1 @@
+export { default } from "@/app/admin/models/page";
diff --git a/web/src/app/admin/page.tsx b/web/src/app/admin/page.tsx
index 862a7cd..9662ccb 100644
--- a/web/src/app/admin/page.tsx
+++ b/web/src/app/admin/page.tsx
@@ -1,6 +1,7 @@
"use client";
import Link from "next/link";
+import { Card, Flex, Heading, Text } from "@radix-ui/themes";
import { useAuth } from "@/components/auth-provider";
@@ -24,10 +25,22 @@ const CARDS = [
visible: (hasPermission: (code: string) => boolean) => hasPermission("menu.read") || hasPermission("menu.manage"),
},
{
- href: "/admin/files",
- title: "文件管理",
- description: "管理挂载点文件列表、目录浏览、目录创建和删除。",
- visible: (hasPermission: (code: string) => boolean) => hasPermission("file.read") || hasPermission("file.manage"),
+ href: "/admin/system-params",
+ title: "系统参数",
+ description: "维护系统级参数键值、启停状态与变更说明。",
+ visible: (hasPermission: (code: string) => boolean) => hasPermission("system_param.read") || hasPermission("system_param.manage"),
+ },
+ {
+ href: "/admin/wxapp",
+ title: "微信小程序",
+ description: "复用系统参数能力维护微信小程序配置项、启停状态与说明信息。",
+ visible: (hasPermission: (code: string) => boolean) => hasPermission("system_param.read") || hasPermission("system_param.manage"),
+ },
+ {
+ href: "/admin/prompt",
+ title: "提示词管理",
+ description: "复用系统消息能力维护提示词内容、等级、有效期与发布状态。",
+ visible: (hasPermission: (code: string) => boolean) => hasPermission("system_message.read") || hasPermission("system_message.manage"),
},
{
href: "/admin/chat",
@@ -35,18 +48,198 @@ const CARDS = [
description: "基于模型路由规则发起多轮对话并保留会话记录。",
visible: (hasPermission: (code: string) => boolean) => hasPermission("chat.use"),
},
+ {
+ href: "/admin/jwt-generator",
+ title: "Jwt生成器",
+ description: "为指定用户生成 Bearer Token,便于联调鉴权与权限定位。",
+ visible: (hasPermission: (code: string) => boolean) => hasPermission("jwt_generator.read") || hasPermission("jwt_generator.manage"),
+ },
+ {
+ href: "/admin/life-countdown",
+ title: "生命倒计时",
+ description: "设定死亡日期、查看生命倒计时,并生成今日警示语。",
+ visible: (hasPermission: (code: string) => boolean) => hasPermission("life_countdown.read") || hasPermission("life_countdown.manage"),
+ },
+ {
+ href: "/admin/code-review",
+ title: "代码评审",
+ description: "查看代码评审任务、状态流转、评论协作与处理留痕。",
+ visible: (hasPermission: (code: string) => boolean) => hasPermission("requirement.read"),
+ },
+ {
+ href: "/admin/git-desktop",
+ title: "Git管理",
+ description: "复用需求管理能力维护 Git 相关需求,统一跟踪分支、状态与协作流程。",
+ visible: (hasPermission: (code: string) => boolean) => hasPermission("requirement.read"),
+ },
+ {
+ href: "/admin/orchestration",
+ title: "编排管理",
+ description: "维护编排路由(AGENT)规则,绑定目标模型并控制优先级与启停状态。",
+ visible: (hasPermission: (code: string) => boolean) => hasPermission("model.read") || hasPermission("model.manage"),
+ },
+ {
+ href: "/admin/mcp-server",
+ title: "MCP管理",
+ description: "复用模型与路由规则能力,统一管理 MCP Server 相关编排与启停配置。",
+ visible: (hasPermission: (code: string) => boolean) => hasPermission("model.read") || hasPermission("model.manage"),
+ },
+ {
+ href: "/admin/mdresolve",
+ title: "MD解析",
+ description: "将 Markdown 题库文本解析为结构化题目,并一键导入题库。",
+ visible: (hasPermission: (code: string) => boolean) => hasPermission("question_bank.read") || hasPermission("question_bank.manage"),
+ },
+ {
+ href: "/admin/mermaid-mgr",
+ title: "流程图",
+ description: "复用 MD 解析能力,将流程图文档快速解析并导入结构化题目。",
+ visible: (hasPermission: (code: string) => boolean) => hasPermission("question_bank.read") || hasPermission("question_bank.manage"),
+ },
{
href: "/admin/requirements",
title: "需求管理",
description: "查看需求列表、流转处理、评论协作和操作留痕。",
visible: (hasPermission: (code: string) => boolean) => hasPermission("requirement.read"),
},
+ {
+ href: "/admin/mindmap",
+ title: "题库统计",
+ description: "按题目状态、难度与标签维度统计题库分布,支撑题库运营决策。",
+ visible: (hasPermission: (code: string) => boolean) => hasPermission("question_bank.read") || hasPermission("question_bank.manage"),
+ },
+ {
+ href: "/admin/vocabulary-proficiency",
+ title: "单词统计",
+ description: "统计词条总量、启用占比、缺失字段与最近更新趋势。",
+ visible: (hasPermission: (code: string) => boolean) => hasPermission("vocabulary.read") || hasPermission("vocabulary.manage"),
+ },
+ {
+ href: "/admin/schedule",
+ title: "日程管理",
+ description: "按日程维度管理待办事项,支持筛选、状态流转与负责人协作。",
+ visible: (hasPermission: (code: string) => boolean) => hasPermission("todo.read"),
+ },
+ {
+ href: "/admin/cron",
+ title: "脚本管理",
+ description: "复用待办能力维护脚本任务清单,支持筛选、状态流转与负责人协作。",
+ visible: (hasPermission: (code: string) => boolean) => hasPermission("todo.read"),
+ },
+ {
+ href: "/admin/jobqueue",
+ title: "队列管理",
+ description: "复用待办能力维护队列任务清单,支持筛选、状态流转与负责人协作。",
+ visible: (hasPermission: (code: string) => boolean) => hasPermission("todo.read"),
+ },
{
href: "/admin/todos",
title: "待办管理",
description: "基于 HeroUI 快速维护待办事项、状态流转与执行节奏。",
visible: (hasPermission: (code: string) => boolean) => hasPermission("todo.read"),
},
+ {
+ href: "/admin/group",
+ title: "分组管理",
+ description: "维护题库分组:检索、重命名、批量解除绑定。",
+ visible: (hasPermission: (code: string) => boolean) => hasPermission("question_bank.read") || hasPermission("question_bank.manage"),
+ },
+ {
+ href: "/admin/knowledge",
+ title: "知识点管理",
+ description: "复用分组能力维护知识点:检索、重命名与解除题目关联。",
+ visible: (hasPermission: (code: string) => boolean) => hasPermission("question_bank.read") || hasPermission("question_bank.manage"),
+ },
+ {
+ href: "/admin/question-bank",
+ title: "试题管理",
+ description: "维护试题:题目新增、筛选、状态流转与标签管理。",
+ visible: (hasPermission: (code: string) => boolean) => hasPermission("question_bank.read") || hasPermission("question_bank.manage"),
+ },
+ {
+ href: "/admin/homework",
+ title: "家庭作业",
+ description: "复用题库能力管理家庭作业题目:新增、筛选、流转与标签。",
+ visible: (hasPermission: (code: string) => boolean) => hasPermission("question_bank.read") || hasPermission("question_bank.manage"),
+ },
+ {
+ href: "/admin/job",
+ title: "作业监控",
+ description: "复用题库能力监控作业题目执行情况:筛选、流转与标签维度查看。",
+ visible: (hasPermission: (code: string) => boolean) => hasPermission("question_bank.read") || hasPermission("question_bank.manage"),
+ },
+ {
+ href: "/admin/history",
+ title: "历史答卷",
+ description: "复用题库能力承接历史答卷查询与管理入口,支持筛选、流转与标签维度查看。",
+ visible: (hasPermission: (code: string) => boolean) => hasPermission("question_bank.read") || hasPermission("question_bank.manage"),
+ },
+ {
+ href: "/admin/poetry",
+ title: "诗词本",
+ description: "维护诗词词条、拼音、释义与示例,支持启停与关键词检索。",
+ visible: (hasPermission: (code: string) => boolean) => hasPermission("vocabulary.read") || hasPermission("vocabulary.manage"),
+ },
+ {
+ href: "/admin/data-query",
+ title: "数据查询",
+ description: "复用系统日志能力执行数据检索,支持按动作与用户维度过滤查询。",
+ visible: (hasPermission: (code: string) => boolean) => hasPermission("menu.read") || hasPermission("menu.manage"),
+ },
+ {
+ href: "/admin/hot-search",
+ title: "热搜",
+ description: "查看热搜数据、关键词检索,并维护关注主题命中规则。",
+ visible: (hasPermission: (code: string) => boolean) => hasPermission("question_bank.read") || hasPermission("question_bank.manage"),
+ },
+ {
+ href: "/admin/knowledge-set",
+ title: "知识集管理",
+ description: "复用文件管理能力维护知识集目录与文件,支持挂载切换、上传、重命名与移动。",
+ visible: (hasPermission: (code: string) => boolean) => hasPermission("file.read") || hasPermission("file.manage"),
+ },
+ {
+ href: "/admin/filedetector",
+ title: "文件识别",
+ description: "复用文件管理能力执行文件识别场景的目录浏览与文件维护,支持挂载切换、上传、重命名、移动与下载。",
+ visible: (hasPermission: (code: string) => boolean) => hasPermission("file.read") || hasPermission("file.manage"),
+ },
+ {
+ href: "/admin/baidu-pan",
+ title: "百度网盘",
+ description: "复用文件管理能力承接百度网盘目录与文件维护,支持挂载切换、上传、重命名、移动与下载。",
+ visible: (hasPermission: (code: string) => boolean) => hasPermission("file.read") || hasPermission("file.manage"),
+ },
+ {
+ href: "/admin/diary",
+ title: "上帝视角",
+ description: "复用系统日志能力,按动作与用户维度查看全局审计轨迹。",
+ visible: (hasPermission: (code: string) => boolean) => hasPermission("menu.read") || hasPermission("menu.manage"),
+ },
+ {
+ href: "/admin/syslog",
+ title: "系统日志",
+ description: "查看鉴权与会话类审计日志,支持动作与用户筛选。",
+ visible: (hasPermission: (code: string) => boolean) => hasPermission("menu.read") || hasPermission("menu.manage"),
+ },
+ {
+ href: "/admin/password",
+ title: "密钥管理",
+ description: "聚焦模型密钥的查看、轮换与密钥版本留痕。",
+ visible: (hasPermission: (code: string) => boolean) => hasPermission("model.read") || hasPermission("model.manage"),
+ },
+ {
+ href: "/admin/price-monitor",
+ title: "价格监控",
+ description: "聚合模型请求量、成功率、Token 消耗与费用趋势。",
+ visible: (hasPermission: (code: string) => boolean) => hasPermission("model.read") || hasPermission("model.manage"),
+ },
+ {
+ href: "/admin/api-tester",
+ title: "API测试",
+ description: "复用模型测试能力执行冒烟测试与对话测试,支持查看最近测试记录。",
+ visible: (hasPermission: (code: string) => boolean) => hasPermission("model.read") || hasPermission("model.manage"),
+ },
{
href: "/admin/models",
title: "模型管理",
@@ -61,27 +254,24 @@ export default function AdminHomePage() {
if (visibleCards.length === 0) {
return (
-
- 当前账号暂无可访问的后台模块。
-
+
+ 当前账号暂无可访问的后台模块。
+
);
}
return (
{visibleCards.map((item) => (
-
-
-
{item.title}
-
{item.description}
-
- 查看模块
-
-
+
+
+
+ {item.title}
+ {item.description}
+ 查看模块
+
+
+
))}
);
diff --git a/web/src/app/admin/password/page.tsx b/web/src/app/admin/password/page.tsx
new file mode 100644
index 0000000..94b35d0
--- /dev/null
+++ b/web/src/app/admin/password/page.tsx
@@ -0,0 +1,409 @@
+"use client";
+
+import Link from "next/link";
+import { Button, Select, Table, TextField } from "@radix-ui/themes";
+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
+import { ChangeEvent, useCallback, useMemo, useState } from "react";
+
+import { useAuth } from "@/components/auth-provider";
+import { useTopicSubscription } from "@/hooks/use-topic-subscription";
+import { readApiError } from "@/lib/api";
+import type {
+ ModelApiKeyListResponse,
+ ModelApiKeyItem,
+ ModelRegistryItem,
+ PasswordModelListResponse,
+} from "@/types/auth";
+
+type StatusFilter = "all" | "DRAFT" | "ENABLED" | "DISABLED" | "DEPRECATED";
+
+type RotateKeyForm = {
+ api_key: string;
+ note: string;
+};
+
+const STATUS_OPTIONS: Array<{ value: StatusFilter; label: string }> = [
+ { value: "all", label: "全部状态" },
+ { value: "DRAFT", label: "草稿" },
+ { value: "ENABLED", label: "已启用" },
+ { value: "DISABLED", label: "已停用" },
+ { value: "DEPRECATED", label: "已废弃" },
+];
+
+const MODEL_STATUS_LABELS: Record, string> = {
+ DRAFT: "草稿",
+ ENABLED: "已启用",
+ DISABLED: "已停用",
+ DEPRECATED: "已废弃",
+};
+
+const EMPTY_ROTATE_FORM: RotateKeyForm = {
+ api_key: "",
+ note: "",
+};
+
+function formatModelStatus(status: ModelRegistryItem["status"]): string {
+ return `${MODEL_STATUS_LABELS[status]}(${status})`;
+}
+
+function formatDateTime(value: string | null | undefined): string {
+ if (!value) {
+ return "-";
+ }
+ const date = new Date(value);
+ if (Number.isNaN(date.getTime())) {
+ return value;
+ }
+ return date.toLocaleString("zh-CN", { hour12: false });
+}
+
+export default function AdminPasswordPage() {
+ const { user, initializing, fetchWithAuth, hasPermission } = useAuth();
+ const queryClient = useQueryClient();
+
+ const [keyword, setKeyword] = useState("");
+ const [statusFilter, setStatusFilter] = useState("all");
+ const [selectedModelId, setSelectedModelId] = useState(null);
+ const [rotateForm, setRotateForm] = useState(EMPTY_ROTATE_FORM);
+ const [error, setError] = useState("");
+ const [success, setSuccess] = useState("");
+
+ const canRead = hasPermission("model.read") || hasPermission("model.manage");
+ const canManage = hasPermission("model.manage");
+
+ const modelsPath = useMemo(() => {
+ const params = new URLSearchParams();
+ if (statusFilter !== "all") {
+ params.set("status", statusFilter);
+ }
+ if (keyword.trim()) {
+ params.set("keyword", keyword.trim());
+ }
+ const query = params.toString();
+ return query ? `/api/v1/admin/password/models?${query}` : "/api/v1/admin/password/models";
+ }, [keyword, statusFilter]);
+
+ const modelsQuery = useQuery({
+ queryKey: [modelsPath],
+ enabled: !!user && canRead,
+ queryFn: async () => {
+ const response = await fetchWithAuth(modelsPath);
+ if (!response.ok) {
+ throw new Error(await readApiError(response));
+ }
+ return (await response.json()) as PasswordModelListResponse;
+ },
+ });
+
+ const models = modelsQuery.data?.items ?? [];
+
+ const selectedModel = useMemo(
+ () => models.find((item) => item.id === selectedModelId) ?? null,
+ [models, selectedModelId],
+ );
+
+ const selectedModelKeysPath = useMemo(() => {
+ if (selectedModelId === null) {
+ return null;
+ }
+ return `/api/v1/admin/password/models/${selectedModelId}/keys`;
+ }, [selectedModelId]);
+
+ const keysQuery = useQuery({
+ queryKey: [selectedModelKeysPath],
+ enabled: !!selectedModelKeysPath && !!user && canRead,
+ queryFn: async () => {
+ if (!selectedModelKeysPath) {
+ throw new Error("模型未选择");
+ }
+ const response = await fetchWithAuth(selectedModelKeysPath);
+ if (!response.ok) {
+ throw new Error(await readApiError(response));
+ }
+ return (await response.json()) as ModelApiKeyListResponse;
+ },
+ });
+
+ const keys = keysQuery.data?.items ?? [];
+
+ const refreshAll = useCallback(async () => {
+ await queryClient.invalidateQueries({
+ predicate: (query) =>
+ Array.isArray(query.queryKey)
+ && typeof query.queryKey[0] === "string"
+ && (
+ query.queryKey[0].startsWith("/api/v1/admin/password/models")
+ || query.queryKey[0].startsWith("/api/v1/admin/models")
+ || query.queryKey[0].startsWith("/api/v1/admin/models/summary")
+ ),
+ });
+ }, [queryClient]);
+
+ useTopicSubscription("admin.models", useCallback(() => {
+ void refreshAll();
+ }, [refreshAll]));
+
+ const rotateKeyMutation = useMutation({
+ mutationFn: async () => {
+ if (!selectedModelId) {
+ throw new Error("请先选择模型");
+ }
+ const apiKey = rotateForm.api_key.trim();
+ if (!apiKey) {
+ throw new Error("密钥不能为空");
+ }
+ const response = await fetchWithAuth(`/api/v1/admin/password/models/${selectedModelId}/rotate-key`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ api_key: apiKey,
+ note: rotateForm.note.trim() || null,
+ }),
+ });
+ if (!response.ok) {
+ throw new Error(await readApiError(response));
+ }
+ return (await response.json()) as ModelApiKeyItem;
+ },
+ onSuccess: async () => {
+ setError("");
+ setSuccess("密钥轮换成功");
+ setRotateForm(EMPTY_ROTATE_FORM);
+ await refreshAll();
+ if (selectedModelKeysPath) {
+ await queryClient.invalidateQueries({ queryKey: [selectedModelKeysPath] });
+ }
+ },
+ onError: (candidate) => {
+ setSuccess("");
+ setError(candidate instanceof Error ? candidate.message : "密钥轮换失败");
+ },
+ });
+
+ const listError = modelsQuery.error instanceof Error ? modelsQuery.error.message : "";
+ const keysError = keysQuery.error instanceof Error ? keysQuery.error.message : "";
+
+ if (initializing || modelsQuery.isLoading) {
+ return Loading password management...
;
+ }
+
+ if (!user) {
+ return (
+
+ 请先登录后再访问密钥管理页面。
+ 返回首页
+
+ );
+ }
+
+ if (!canRead) {
+ return (
+
+ 你没有访问该页面的权限(需要 `model.read`)。
+ 返回首页
+
+ );
+ }
+
+ return (
+
+ {(error || listError || keysError) &&
{error || listError || keysError}}
+ {success &&
{success}}
+
+
+
+
+
模型密钥总览
+
查看各模型当前启用密钥脱敏信息,并进入版本明细与轮换。
+
+
+
+
+
+ 关键词
+ ) => setKeyword(event.currentTarget.value)}
+ placeholder="按模型编码 / 名称 / 厂商筛选"
+ className="w-full"
+ />
+
+
+ 状态
+ setStatusFilter(value as StatusFilter)}
+ >
+
+
+ {STATUS_OPTIONS.map((option) => (
+
+ {option.label}
+
+ ))}
+
+
+
+
+
+
+
+
+
+ 模型
+ 状态
+ 当前密钥
+ 最近轮换
+ 操作
+
+
+
+ {models.length === 0 && (
+
+
+ 当前筛选条件下暂无模型。
+
+
+ )}
+ {models.map((item) => {
+ const active = selectedModelId === item.id;
+ return (
+
+
+ {item.name}
+ {item.code}
+ {item.provider} / {item.provider_model}
+
+
+
+ {formatModelStatus(item.status)}
+
+
+
+ {item.active_key_masked ?? "-"}
+ v{item.active_key_version ?? "-"} / {item.active_key_fingerprint ?? "-"}
+
+ {formatDateTime(item.active_key_rotated_at)}
+
+ {
+ setSelectedModelId(item.id);
+ setError("");
+ setSuccess("");
+ }}
+ >
+ {active ? "当前模型" : "查看密钥"}
+
+
+
+ );
+ })}
+
+
+
+
+
+
+
+
+
密钥版本与轮换
+
+ {selectedModel ? `当前模型:${selectedModel.name}(${selectedModel.code})` : "请先在上方选择一个模型"}
+
+
+
+
+ {selectedModelId === null ? (
+ 未选择模型,无法查看密钥版本与执行轮换。
+ ) : (
+ <>
+
+
+
+
+ 版本
+ 脱敏密钥
+ 指纹
+ 状态
+ 备注
+ 创建时间
+
+
+
+ {keysQuery.isLoading && (
+
+ 加载密钥版本中...
+
+ )}
+ {!keysQuery.isLoading && keys.length === 0 && (
+
+ 该模型暂无密钥记录。
+
+ )}
+ {keys.map((key) => (
+
+ v{key.version}
+ {key.secret_masked}
+ {key.secret_fingerprint}
+
+
+ {key.is_active ? "生效中" : "已失效"}
+
+
+ {key.rotation_note ?? "-"}
+ {formatDateTime(key.created_at)}
+
+ ))}
+
+
+
+
+ {canManage ? (
+
+
+ 新密钥
+ ) => setRotateForm((prev) => ({ ...prev, api_key: event.currentTarget.value }))}
+ placeholder="输入新密钥(至少 8 位)"
+ />
+
+
+ 轮换备注(可选)
+ ) => setRotateForm((prev) => ({ ...prev, note: event.currentTarget.value }))}
+ placeholder="例如:季度轮换 / 紧急替换"
+ />
+
+
+ {
+ setError("");
+ setSuccess("");
+ rotateKeyMutation.mutate();
+ }}
+ >
+ {rotateKeyMutation.isPending ? "轮换中..." : "提交密钥轮换"}
+
+
+
+ ) : (
+ 当前账号无 `model.manage` 权限,仅可查看密钥信息。
+ )}
+ >
+ )}
+
+
+ );
+}
diff --git a/web/src/app/admin/poetry/page.tsx b/web/src/app/admin/poetry/page.tsx
new file mode 100644
index 0000000..b895721
--- /dev/null
+++ b/web/src/app/admin/poetry/page.tsx
@@ -0,0 +1 @@
+export { default } from "../vocabulary/page";
diff --git a/web/src/app/admin/price-monitor/page.tsx b/web/src/app/admin/price-monitor/page.tsx
new file mode 100644
index 0000000..99c7d9a
--- /dev/null
+++ b/web/src/app/admin/price-monitor/page.tsx
@@ -0,0 +1 @@
+export { default } from "../token-usage/page";
diff --git a/web/src/app/admin/prompt/page.tsx b/web/src/app/admin/prompt/page.tsx
new file mode 100644
index 0000000..6a66753
--- /dev/null
+++ b/web/src/app/admin/prompt/page.tsx
@@ -0,0 +1 @@
+export { default } from "../system-message/page";
diff --git a/web/src/app/admin/question-bank/page.tsx b/web/src/app/admin/question-bank/page.tsx
new file mode 100644
index 0000000..a0fe6a7
--- /dev/null
+++ b/web/src/app/admin/question-bank/page.tsx
@@ -0,0 +1 @@
+export { default } from "../mindmap/page";
diff --git a/web/src/app/admin/requirements/[id]/page.tsx b/web/src/app/admin/requirements/[id]/page.tsx
index ee749d2..33bd784 100644
--- a/web/src/app/admin/requirements/[id]/page.tsx
+++ b/web/src/app/admin/requirements/[id]/page.tsx
@@ -22,6 +22,22 @@ import type {
const COMMENT_KIND_OPTIONS: RequirementCommentKind[] = ["comment", "analysis", "revision", "system"];
const PRIORITY_OPTIONS: RequirementPriority[] = ["low", "medium", "high", "urgent"];
const UNASSIGNED_ASSIGNEE = "__unassigned_assignee__";
+
+const STATUS_LABEL: Record = {
+ PENDING_ANALYSIS: "待分析",
+ PENDING_REVISION: "待修订",
+ OPEN: "待处理",
+ IN_PROGRESS: "处理中",
+ COMPLETED: "已完成",
+ CANCELLED: "已取消",
+};
+
+const PRIORITY_LABEL: Record = {
+ low: "低",
+ medium: "中",
+ high: "高",
+ urgent: "紧急",
+};
const ALLOWED_TRANSITIONS: Record = {
PENDING_ANALYSIS: ["OPEN", "PENDING_REVISION", "CANCELLED"],
PENDING_REVISION: ["OPEN", "CANCELLED"],
@@ -33,6 +49,16 @@ const ALLOWED_TRANSITIONS: Record = {
type FetchWithAuth = ReturnType["fetchWithAuth"];
+function formatRequirementStatus(value: string | null | undefined): string {
+ if (!value) return "-";
+ return STATUS_LABEL[value as RequirementStatus] ?? value;
+}
+
+function formatRequirementPriority(value: string | null | undefined): string {
+ if (!value) return "-";
+ return PRIORITY_LABEL[value as RequirementPriority] ?? value;
+}
+
function toDatetimeLocalInput(value: string | null): string {
if (!value) return "";
const date = new Date(value);
@@ -104,14 +130,14 @@ function RequirementEditSection({
const error = updateMutation.error instanceof Error ? updateMutation.error.message : "";
return (
-
+
编辑基础信息
-
支持更新标题、描述、优先级、项目、模块、来源和截止时间。
+
支持更新标题、描述、优先级、项目、模块、来源和截止时间。
{error && (
- {error}
+ {error}
)}
@@ -141,7 +167,7 @@ function RequirementEditSection({
{PRIORITY_OPTIONS.map((item) => (
- {item}
+ {formatRequirementPriority(item)}
))}
@@ -276,10 +302,10 @@ function RequirementActionsSection({
return (
-
+
处理动作
{error instanceof Error && (
-
{error.message}
+
{error.message}
)}
{canAssign && (
@@ -326,7 +352,7 @@ function RequirementActionsSection({
{availableTransitions.map((item) => (
- {item}
+ {formatRequirementStatus(item)}
))}
@@ -343,16 +369,16 @@ function RequirementActionsSection({
>
) : (
-
当前状态没有可继续流转的目标状态。
+
当前状态没有可继续流转的目标状态。
)}
-
+
当前处理说明
-
-
当前状态:{detail.status}
+
+
当前状态:{formatRequirementStatus(detail.status)}
当前指派人:{detail.assignee?.username ?? "-"}
当前评审人:{detail.reviewer?.username ?? "-"}
@@ -399,10 +425,10 @@ function RequirementCommentSection({
const error = commentMutation.error instanceof Error ? commentMutation.error.message : "";
return (
-
+
新增评论
{error && (
-
{error}
+
{error}
)}
setCommentKind(value as RequirementCommentKind)}>
@@ -522,18 +548,18 @@ export default function RequirementDetailPage() {
}, [commentsQuery.error, detailQuery.error, eventsQuery.error, usersQuery.error]);
if (initializing || detailQuery.isLoading) {
- return Loading requirement...
;
+ return Loading requirement...
;
}
if (!requirementId) {
- return 需求 ID 无效。
;
+ return 需求 ID 无效。
;
}
if (!user) {
return (
- 请先登录后再访问需求详情。
- 返回首页
+ 请先登录后再访问需求详情。
+ 返回首页
);
}
@@ -541,15 +567,15 @@ export default function RequirementDetailPage() {
if (!canRead) {
return (
- 你没有访问该页面的权限(需要 `requirement.read`)。
- 返回需求列表
+ 你没有访问该页面的权限(需要 `requirement.read`)。
+ 返回需求列表
);
}
const detail = detailQuery.data;
if (!detail) {
- return 需求不存在。
;
+ return 需求不存在。
;
}
const comments = commentsQuery.data ?? [];
@@ -559,48 +585,48 @@ export default function RequirementDetailPage() {
return (
{anyError && (
-
{anyError}
+
{anyError}
)}
-
+
-
{detail.code}
+
{detail.code}
{detail.title}
-
{detail.description || "暂无描述"}
+
{detail.description || "暂无描述"}
-
+
返回需求列表
- 状态:{detail.status}
- 优先级:{detail.priority}
+ 状态:{formatRequirementStatus(detail.status)}
+ 优先级:{formatRequirementPriority(detail.priority)}
创建人:{detail.creator?.username ?? "-"}
指派人:{detail.assignee?.username ?? "-"}
-
-
项目
+
+
项目
{detail.project_name ?? "-"}
-
-
模块
+
+
模块
{detail.module_name ?? "-"}
-
-
来源
+
+
来源
{detail.source ?? "-"}
-
-
截止时间
+
+
截止时间
{detail.due_at ? new Date(detail.due_at).toLocaleString() : "-"}
-
-
完成时间
+
+
完成时间
{detail.closed_at ? new Date(detail.closed_at).toLocaleString() : "-"}
-
-
更新时间
+
+
更新时间
{new Date(detail.updated_at).toLocaleString()}
@@ -643,12 +669,12 @@ export default function RequirementDetailPage() {
)}
-
+
评论区
- {comments.length === 0 ?
暂无评论
: comments.map((item) => (
-
-
+ {comments.length === 0 ?
暂无评论
: comments.map((item) => (
+
+
{item.author?.username ?? "系统"} · {item.kind}
{new Date(item.created_at).toLocaleString()}
@@ -658,18 +684,18 @@ export default function RequirementDetailPage() {
-
+
操作日志
- {events.length === 0 ?
暂无日志
: events.map((item) => (
-
-
+ {events.length === 0 ?
暂无日志
: events.map((item) => (
+
+
{item.actor?.username ?? "系统"} · {item.event_type}
{new Date(item.created_at).toLocaleString()}
-
{item.from_status ?? "-"} → {item.to_status ?? "-"}
+
{formatRequirementStatus(item.from_status)} → {formatRequirementStatus(item.to_status)}
{item.payload_json && (
-
{JSON.stringify(item.payload_json, null, 2)}
+
{JSON.stringify(item.payload_json, null, 2)}
)}
))}
diff --git a/web/src/app/admin/requirements/new/page.tsx b/web/src/app/admin/requirements/new/page.tsx
index ae324d6..5309094 100644
--- a/web/src/app/admin/requirements/new/page.tsx
+++ b/web/src/app/admin/requirements/new/page.tsx
@@ -19,6 +19,22 @@ const STATUS_OPTIONS: RequirementStatus[] = [
"CANCELLED",
];
const PRIORITY_OPTIONS: RequirementPriority[] = ["low", "medium", "high", "urgent"];
+
+const STATUS_LABEL: Record
= {
+ PENDING_ANALYSIS: "待分析",
+ PENDING_REVISION: "待修订",
+ OPEN: "待处理",
+ IN_PROGRESS: "处理中",
+ COMPLETED: "已完成",
+ CANCELLED: "已取消",
+};
+
+const PRIORITY_LABEL: Record = {
+ low: "低",
+ medium: "中",
+ high: "高",
+ urgent: "紧急",
+};
const UNASSIGNED_OPTION = "__unassigned__";
export default function RequirementCreatePage() {
@@ -79,14 +95,14 @@ export default function RequirementCreatePage() {
});
if (initializing) {
- return Loading...
;
+ return Loading...
;
}
if (!user) {
return (
- 请先登录后再创建需求。
- 返回首页
+ 请先登录后再创建需求。
+ 返回首页
);
}
@@ -94,8 +110,8 @@ export default function RequirementCreatePage() {
if (!canCreate) {
return (
- 你没有创建需求的权限(需要 `requirement.create`)。
- 返回需求列表
+ 你没有创建需求的权限(需要 `requirement.create`)。
+ 返回需求列表
);
}
@@ -106,16 +122,16 @@ export default function RequirementCreatePage() {
return (
{error && (
-
{error}
+
{error}
)}
-
+
新建需求
-
填写需求基本信息并指定初始处理人。
+
填写需求基本信息并指定初始处理人。
-
返回列表
+
返回列表
@@ -145,7 +161,7 @@ export default function RequirementCreatePage() {
{STATUS_OPTIONS.map((item) => (
- {item}
+ {STATUS_LABEL[item]}
))}
@@ -159,7 +175,7 @@ export default function RequirementCreatePage() {
{PRIORITY_OPTIONS.map((item) => (
- {item}
+ {PRIORITY_LABEL[item]}
))}
diff --git a/web/src/app/admin/requirements/page.tsx b/web/src/app/admin/requirements/page.tsx
index a93597e..31639a6 100644
--- a/web/src/app/admin/requirements/page.tsx
+++ b/web/src/app/admin/requirements/page.tsx
@@ -5,7 +5,7 @@ import Link from "next/link";
import { ChangeEvent, useCallback, useMemo, useState } from "react";
import { useAuth } from "@/components/auth-provider";
-import { Select, TextField } from "@radix-ui/themes";
+import { Select, TextField, Button, Table } from "@radix-ui/themes";
import { useTopicSubscription } from "@/hooks/use-topic-subscription";
import { readApiError } from "@/lib/api";
import type { RequirementListResponse, RequirementPriority, RequirementStatus, UserListResponse, UserPublic } from "@/types/auth";
@@ -20,6 +20,22 @@ const STATUS_OPTIONS: RequirementStatus[] = [
];
const PRIORITY_OPTIONS: RequirementPriority[] = ["low", "medium", "high", "urgent"];
+const STATUS_LABEL: Record
= {
+ PENDING_ANALYSIS: "待分析",
+ PENDING_REVISION: "待修订",
+ OPEN: "待处理",
+ IN_PROGRESS: "处理中",
+ COMPLETED: "已完成",
+ CANCELLED: "已取消",
+};
+
+const PRIORITY_LABEL: Record = {
+ low: "低",
+ medium: "中",
+ high: "高",
+ urgent: "紧急",
+};
+
type Filters = {
keyword: string;
status: string;
@@ -42,6 +58,8 @@ export default function RequirementsPage() {
const queryClient = useQueryClient();
const { user, initializing, fetchWithAuth, hasPermission } = useAuth();
const [filters, setFilters] = useState(DEFAULT_FILTERS);
+ const [actionError, setActionError] = useState("");
+ const [deletingRequirementId, setDeletingRequirementId] = useState(null);
const canRead = hasPermission("requirement.read");
const canCreate = hasPermission("requirement.create") || hasPermission("requirement.manage");
@@ -105,8 +123,12 @@ export default function RequirementsPage() {
return response.json();
},
onSuccess: async () => {
+ setActionError("");
await queryClient.invalidateQueries({ queryKey: [requirementsPath] });
},
+ onError: (error) => {
+ setActionError(error instanceof Error ? error.message : "领取需求失败");
+ },
});
const transitionMutation = useMutation({
@@ -122,19 +144,44 @@ export default function RequirementsPage() {
return response.json();
},
onSuccess: async () => {
+ setActionError("");
await queryClient.invalidateQueries({ queryKey: [requirementsPath] });
},
+ onError: (error) => {
+ setActionError(error instanceof Error ? error.message : "更新需求状态失败");
+ },
+ });
+
+ const deleteMutation = useMutation({
+ mutationFn: async (requirementId: string) => {
+ const response = await fetchWithAuth(`/api/v1/requirements/${requirementId}`, {
+ method: "DELETE",
+ });
+ if (!response.ok) {
+ throw new Error(await readApiError(response));
+ }
+ return response.json();
+ },
+ onSuccess: async () => {
+ setActionError("");
+ setDeletingRequirementId(null);
+ await queryClient.invalidateQueries({ queryKey: [requirementsPath] });
+ },
+ onError: (error) => {
+ setActionError(error instanceof Error ? error.message : "删除需求失败");
+ setDeletingRequirementId(null);
+ },
});
if (initializing || requirementsQuery.isLoading) {
- return Loading requirements...
;
+ return Loading requirements...
;
}
if (!user) {
return (
- 请先登录后再访问需求管理页面。
- 返回首页
+ 请先登录后再访问需求管理页面。
+ 返回首页
);
}
@@ -142,35 +189,33 @@ export default function RequirementsPage() {
if (!canRead) {
return (
- 你没有访问该页面的权限(需要 `requirement.read`)。
- 返回首页
+ 你没有访问该页面的权限(需要 `requirement.read`)。
+ 返回首页
);
}
const users: UserPublic[] = usersQuery.data?.items ?? [];
const items = requirementsQuery.data?.items ?? [];
- const error = requirementsQuery.error instanceof Error ? requirementsQuery.error.message : "";
+ const queryError = requirementsQuery.error instanceof Error ? requirementsQuery.error.message : "";
+ const error = queryError || actionError;
return (
{error && (
-
{error}
+
{error}
)}
-
+
需求列表
-
按关键词、状态、优先级、指派人筛选当前需求。
+
按关键词、状态、优先级、指派人筛选当前需求。
{canCreate && (
-
- 新建需求
-
+
+ 新建需求
+
)}
@@ -194,7 +239,7 @@ export default function RequirementsPage() {
全部状态
{STATUS_OPTIONS.map((item) => (
- {item}
+ {STATUS_LABEL[item]}
))}
@@ -210,7 +255,7 @@ export default function RequirementsPage() {
全部优先级
{PRIORITY_OPTIONS.map((item) => (
- {item}
+ {PRIORITY_LABEL[item]}
))}
@@ -235,79 +280,96 @@ export default function RequirementsPage() {
-
+
-
共 {requirementsQuery.data?.total ?? 0} 条
- {requirementsQuery.isFetching &&
刷新中...
}
+
共 {requirementsQuery.data?.total ?? 0} 条
+ {requirementsQuery.isFetching &&
刷新中...
}
-
-
-
- | 编号 |
- 标题 |
- 状态 |
- 优先级 |
- 项目 |
- 指派人 |
- 更新时间 |
- 操作 |
-
-
-
+
+
+
+ 编号
+ 标题
+ 状态
+ 优先级
+ 项目
+ 指派人
+ 更新时间
+ 操作
+
+
+
{items.map((item) => (
-
- | {item.code} |
-
+
+ {item.code}
+
{item.title}
- {item.description || "-"}
- |
- {item.status} |
- {item.priority} |
- {item.project_name ?? "-"} |
- {item.assignee?.username ?? "-"} |
- {new Date(item.updated_at).toLocaleString()} |
-
+ {item.description || "-"}
+
+ {STATUS_LABEL[item.status]}
+ {PRIORITY_LABEL[item.priority]}
+ {item.project_name ?? "-"}
+ {item.assignee?.username ?? "-"}
+ {new Date(item.updated_at).toLocaleString()}
+
{canProcess && (
- claimMutation.mutate(item.id)}
- disabled={claimMutation.isPending}
+ disabled={claimMutation.isPending || deletingRequirementId === item.id}
>
领取
-
+
)}
{canProcess && item.status === "OPEN" && (
- transitionMutation.mutate({ requirementId: item.id, status: "IN_PROGRESS" })}
- disabled={transitionMutation.isPending}
+ disabled={transitionMutation.isPending || deletingRequirementId === item.id}
>
开始处理
-
+
)}
{canProcess && item.status === "IN_PROGRESS" && (
- transitionMutation.mutate({ requirementId: item.id, status: "COMPLETED" })}
- disabled={transitionMutation.isPending}
+ disabled={transitionMutation.isPending || deletingRequirementId === item.id}
>
标记完成
-
+
+ )}
+ {canProcess && (
+ {
+ const confirmed = window.confirm(`确认删除需求 ${item.code}(${item.title})?`);
+ if (!confirmed) {
+ return;
+ }
+ setDeletingRequirementId(item.id);
+ deleteMutation.mutate(item.id);
+ }}
+ disabled={deleteMutation.isPending || transitionMutation.isPending || claimMutation.isPending}
+ >
+ {deletingRequirementId === item.id ? "删除中..." : "删除"}
+
)}
- |
-
+
+
))}
-
-
+
+
diff --git a/web/src/app/admin/roles/page.tsx b/web/src/app/admin/roles/page.tsx
index 41c7099..e75784d 100644
--- a/web/src/app/admin/roles/page.tsx
+++ b/web/src/app/admin/roles/page.tsx
@@ -4,7 +4,7 @@ import { ChangeEvent, useEffect, useMemo, useState, useCallback } from "react";
import Link from "next/link";
import { useAuth } from "@/components/auth-provider";
-import { Dialog, TextField } from "@radix-ui/themes";
+import { Checkbox, Dialog, TextField, Button, Table } from "@radix-ui/themes";
import { useTopicSubscription } from "@/hooks/use-topic-subscription";
import { readApiError } from "@/lib/api";
import type { MenuItem, PermissionItem, RoleItem, RoleListResponse } from "@/types/auth";
@@ -40,6 +40,10 @@ export default function AdminRolesPage() {
[menus],
);
+ const menuNameById = useMemo(() => {
+ return new Map(menus.map((menu) => [menu.id, `${menu.name} (${menu.code})`]));
+ }, [menus]);
+
const loadData = useCallback(async () => {
if (!canRead) {
setLoading(false);
@@ -189,14 +193,14 @@ export default function AdminRolesPage() {
};
if (initializing || loading) {
- return Loading roles...
;
+ return Loading roles...
;
}
if (!user) {
return (
- 请先登录后再访问角色管理页面。
- 返回首页
+ 请先登录后再访问角色管理页面。
+ 返回首页
);
}
@@ -204,8 +208,8 @@ export default function AdminRolesPage() {
if (!canRead) {
return (
- 你没有访问该页面的权限(需要 `role.read`)。
- 返回首页
+ 你没有访问该页面的权限(需要 `role.read`)。
+ 返回首页
);
}
@@ -213,67 +217,71 @@ export default function AdminRolesPage() {
return (
{error && (
-
{error}
+
{error}
)}
{success && (
-
{success}
+
{success}
)}
-
+
角色列表
-
当前已配置 {roles.length} 个角色。
+
当前已配置 {roles.length} 个角色。
{canManage && (
-
新建角色
+
新建角色
)}
-
-
-
- | 角色编码 |
- 角色名称 |
- 权限 |
- 菜单 |
- {canManage && 操作 | }
-
-
-
+
+
+
+ 角色编码
+ 角色名称
+ 权限
+ 菜单
+ {canManage && 操作}
+
+
+
{roles.map((role) => (
-
- | {role.code} |
- {role.name} |
- {role.permission_codes.join(", ") || "-"} |
- {role.menu_ids.join(", ") || "-"} |
+
+ {role.code}
+ {role.name}
+ {role.permission_codes.join(", ") || "-"}
+
+ {role.menu_ids.length
+ ? role.menu_ids.map((menuId) => menuNameById.get(menuId) ?? String(menuId)).join(", ")
+ : "-"}
+
{canManage && (
-
+
- startEdit(role)}
type="button"
>
编辑
-
+
{!['admin', 'user'].includes(role.code) && (
- void removeRole(role)}
type="button"
>
删除
-
+
)}
- |
+
)}
-
+
))}
-
-
+
+
@@ -290,9 +298,9 @@ export default function AdminRolesPage() {
{editingRoleId ? "编辑角色" : "新建角色"}
-
角色绑定权限点和可见菜单。
+
角色绑定权限点和可见菜单。
-
取消
+
取消
@@ -330,18 +338,17 @@ export default function AdminRolesPage() {
可见菜单
-
+
{menuOptions.map((item) => {
const checked = form.menu_ids.includes(item.value);
return (
- ) => {
+ onCheckedChange={(state: boolean | "indeterminate") => {
setForm((prev) => ({
...prev,
- menu_ids: event.currentTarget.checked
+ menu_ids: state === true
? [...prev.menu_ids, item.value]
: prev.menu_ids.filter((menuId) => menuId !== item.value),
}));
@@ -356,14 +363,14 @@ export default function AdminRolesPage() {
- void submit()}
type="button"
>
{saving ? "提交中..." : editingRoleId ? "保存修改" : "创建角色"}
-
+
diff --git a/web/src/app/admin/schedule/page.tsx b/web/src/app/admin/schedule/page.tsx
new file mode 100644
index 0000000..648db25
--- /dev/null
+++ b/web/src/app/admin/schedule/page.tsx
@@ -0,0 +1 @@
+export { default } from "@/app/admin/todos/page";
diff --git a/web/src/app/admin/syslog/page.tsx b/web/src/app/admin/syslog/page.tsx
new file mode 100644
index 0000000..0b9f43a
--- /dev/null
+++ b/web/src/app/admin/syslog/page.tsx
@@ -0,0 +1,227 @@
+"use client";
+
+import { useQuery, useQueryClient } from "@tanstack/react-query";
+import Link from "next/link";
+import { ChangeEvent, useCallback, useMemo, useState } from "react";
+
+import { useAuth } from "@/components/auth-provider";
+import { useTopicSubscription } from "@/hooks/use-topic-subscription";
+import { readApiError } from "@/lib/api";
+import { Button, TextField, Table } from "@radix-ui/themes";
+import type { AuditLogListResponse } from "@/types/auth";
+
+const PAGE_SIZE = 50;
+
+type Filters = {
+ action: string;
+ user_id: string;
+};
+
+const EMPTY_FILTERS: Filters = {
+ action: "",
+ user_id: "",
+};
+
+function formatDate(value: string): string {
+ const date = new Date(value);
+ if (Number.isNaN(date.getTime())) {
+ return "-";
+ }
+ return date.toLocaleString();
+}
+
+export default function AdminSyslogPage() {
+ const queryClient = useQueryClient();
+ const { user, initializing, fetchWithAuth, hasPermission } = useAuth();
+
+ const [offset, setOffset] = useState(0);
+ const [draftFilters, setDraftFilters] = useState
(EMPTY_FILTERS);
+ const [filters, setFilters] = useState(EMPTY_FILTERS);
+
+ const canRead = hasPermission("menu.read") || hasPermission("menu.manage");
+
+ const logsPath = useMemo(() => {
+ const params = new URLSearchParams();
+ params.set("limit", String(PAGE_SIZE));
+ params.set("offset", String(offset));
+ if (filters.action.trim()) {
+ params.set("action", filters.action.trim());
+ }
+ if (filters.user_id.trim()) {
+ params.set("user_id", filters.user_id.trim());
+ }
+ return `/api/v1/admin/audit-logs?${params.toString()}`;
+ }, [filters.action, filters.user_id, offset]);
+
+ const loadLogs = useCallback(async () => {
+ const response = await fetchWithAuth(logsPath);
+ if (!response.ok) {
+ throw new Error(await readApiError(response));
+ }
+ return (await response.json()) as AuditLogListResponse;
+ }, [fetchWithAuth, logsPath]);
+
+ const logsQuery = useQuery({
+ queryKey: [logsPath],
+ queryFn: loadLogs,
+ enabled: !!user && canRead,
+ });
+
+ useTopicSubscription(
+ "admin.audit_logs",
+ useCallback(() => {
+ void queryClient.invalidateQueries({ queryKey: [logsPath] });
+ }, [logsPath, queryClient]),
+ );
+
+ if (initializing || logsQuery.isLoading) {
+ return Loading audit logs...
;
+ }
+
+ if (!user) {
+ return (
+
+ 请先登录后再访问系统日志页面。
+
+ 返回首页
+
+
+ );
+ }
+
+ if (!canRead) {
+ return (
+
+ 你没有访问该页面的权限(需要 `menu.read`)。
+
+ 返回首页
+
+
+ );
+ }
+
+ const logs = logsQuery.data?.items ?? [];
+ const total = logsQuery.data?.total ?? 0;
+ const error = logsQuery.error instanceof Error ? logsQuery.error.message : "";
+ const hasPrev = offset > 0;
+ const hasNext = offset + PAGE_SIZE < total;
+
+ return (
+
+ {error &&
{error}}
+
+
+
+
+
系统日志
+
查看鉴权与会话类审计日志,支持按动作和用户筛选。
+
+
常见动作:auth.login / auth.logout / auth.refresh
+
+
+
+ ) =>
+ setDraftFilters((prev) => ({ ...prev, action: event.currentTarget.value }))
+ }
+ />
+ ) =>
+ setDraftFilters((prev) => ({ ...prev, user_id: event.currentTarget.value }))
+ }
+ />
+ {
+ setOffset(0);
+ setFilters({
+ action: draftFilters.action.trim(),
+ user_id: draftFilters.user_id.trim(),
+ });
+ }}
+ >
+ 查询
+
+ {
+ setOffset(0);
+ setDraftFilters(EMPTY_FILTERS);
+ setFilters(EMPTY_FILTERS);
+ }}
+ >
+ 重置
+
+
+
+
+
+
+
+ 共 {total} 条,当前第 {Math.floor(offset / PAGE_SIZE) + 1} 页
+
+
+ {logsQuery.isFetching && 刷新中...}
+ setOffset((prev) => Math.max(0, prev - PAGE_SIZE))}
+ >
+ 上一页
+
+ setOffset((prev) => prev + PAGE_SIZE)}
+ >
+ 下一页
+
+
+
+
+
+
+
+
+ 时间
+ 用户
+ 动作
+ 详情
+
+
+
+ {logs.length === 0 ? (
+
+
+ 暂无日志数据
+
+
+ ) : (
+ logs.map((item) => (
+
+ {formatDate(item.created_at)}
+
+ {item.username ?? "-"}
+ {item.user_id ?? "-"}
+
+ {item.action}
+ {item.detail || "-"}
+
+ ))
+ )}
+
+
+
+
+
+ );
+}
diff --git a/web/src/app/admin/system-message/page.tsx b/web/src/app/admin/system-message/page.tsx
new file mode 100644
index 0000000..65a79d0
--- /dev/null
+++ b/web/src/app/admin/system-message/page.tsx
@@ -0,0 +1,448 @@
+"use client";
+
+import Link from "next/link";
+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
+import { ChangeEvent, useCallback, useMemo, useState } from "react";
+
+import { useAuth } from "@/components/auth-provider";
+import { useTopicSubscription } from "@/hooks/use-topic-subscription";
+import { readApiError } from "@/lib/api";
+import type { SystemMessageListResponse, SystemMessageSummary } from "@/types/auth";
+import { Button, Select, Table, TextArea, TextField } from "@radix-ui/themes";
+
+type StatusFilter = "all" | "draft" | "published" | "archived";
+type LevelFilter = "all" | "info" | "success" | "warning" | "error";
+
+type FormState = {
+ title: string;
+ content: string;
+ level: "info" | "success" | "warning" | "error";
+ status: "draft" | "published" | "archived";
+ start_at: string;
+ end_at: string;
+};
+
+const EMPTY_FORM: FormState = {
+ title: "",
+ content: "",
+ level: "info",
+ status: "draft",
+ start_at: "",
+ end_at: "",
+};
+
+function toDatetimeLocal(value: string | null): string {
+ if (!value) {
+ return "";
+ }
+ const date = new Date(value);
+ if (Number.isNaN(date.getTime())) {
+ return "";
+ }
+ const tzOffset = date.getTimezoneOffset() * 60000;
+ const local = new Date(date.getTime() - tzOffset);
+ return local.toISOString().slice(0, 16);
+}
+
+function toUtcIso(value: string): string | null {
+ if (!value.trim()) {
+ return null;
+ }
+ const date = new Date(value);
+ if (Number.isNaN(date.getTime())) {
+ return null;
+ }
+ return date.toISOString();
+}
+
+export default function AdminSystemMessagePage() {
+ const { user, initializing, fetchWithAuth, hasPermission } = useAuth();
+ const queryClient = useQueryClient();
+
+ const [keyword, setKeyword] = useState("");
+ const [statusFilter, setStatusFilter] = useState("all");
+ const [levelFilter, setLevelFilter] = useState("all");
+ const [editingId, setEditingId] = useState(null);
+ const [form, setForm] = useState(EMPTY_FORM);
+ const [error, setError] = useState("");
+ const [success, setSuccess] = useState("");
+
+ const canRead = hasPermission("system_message.read") || hasPermission("system_message.manage");
+ const canManage = hasPermission("system_message.manage");
+
+ const listPath = useMemo(() => {
+ const params = new URLSearchParams();
+ if (keyword.trim()) {
+ params.set("keyword", keyword.trim());
+ }
+ if (statusFilter !== "all") {
+ params.set("status", statusFilter);
+ }
+ if (levelFilter !== "all") {
+ params.set("level", levelFilter);
+ }
+ const qs = params.toString();
+ return `/api/v1/admin/system-messages${qs ? `?${qs}` : ""}`;
+ }, [keyword, statusFilter, levelFilter]);
+
+ const listQuery = useQuery({
+ queryKey: [listPath],
+ enabled: !!user && canRead,
+ queryFn: async () => {
+ const response = await fetchWithAuth(listPath);
+ if (!response.ok) {
+ throw new Error(await readApiError(response));
+ }
+ return (await response.json()) as SystemMessageListResponse;
+ },
+ });
+
+ const refreshList = useCallback(async () => {
+ await queryClient.invalidateQueries({
+ predicate: (query) =>
+ Array.isArray(query.queryKey)
+ && typeof query.queryKey[0] === "string"
+ && query.queryKey[0].startsWith("/api/v1/admin/system-messages"),
+ });
+ }, [queryClient]);
+
+ useTopicSubscription("admin.system-messages", useCallback(() => {
+ void refreshList();
+ }, [refreshList]));
+
+ const resetForm = () => {
+ setEditingId(null);
+ setForm(EMPTY_FORM);
+ };
+
+ const startCreate = () => {
+ setError("");
+ setSuccess("");
+ resetForm();
+ };
+
+ const startEdit = (item: SystemMessageSummary) => {
+ setError("");
+ setSuccess("");
+ setEditingId(item.id);
+ setForm({
+ title: item.title,
+ content: item.content,
+ level: item.level,
+ status: item.status,
+ start_at: toDatetimeLocal(item.start_at),
+ end_at: toDatetimeLocal(item.end_at),
+ });
+ };
+
+ const saveMutation = useMutation({
+ mutationFn: async () => {
+ if (!canManage) {
+ throw new Error("缺少 system_message.manage 权限");
+ }
+ if (!form.title.trim() || !form.content.trim()) {
+ throw new Error("标题和内容不能为空");
+ }
+
+ const payload = {
+ title: form.title.trim(),
+ content: form.content.trim(),
+ level: form.level,
+ status: form.status,
+ start_at: toUtcIso(form.start_at),
+ end_at: toUtcIso(form.end_at),
+ };
+
+ if (editingId === null) {
+ const response = await fetchWithAuth("/api/v1/admin/system-messages", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(payload),
+ });
+ if (!response.ok) {
+ throw new Error(await readApiError(response));
+ }
+ return "created";
+ }
+
+ const response = await fetchWithAuth(`/api/v1/admin/system-messages/${editingId}`, {
+ method: "PATCH",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(payload),
+ });
+ if (!response.ok) {
+ throw new Error(await readApiError(response));
+ }
+ return "updated";
+ },
+ onSuccess: async (mode) => {
+ setError("");
+ setSuccess(mode === "created" ? "系统消息已创建" : "系统消息已更新");
+ resetForm();
+ await refreshList();
+ },
+ onError: (candidate) => {
+ setSuccess("");
+ setError(candidate instanceof Error ? candidate.message : "保存失败");
+ },
+ });
+
+ const deleteMutation = useMutation({
+ mutationFn: async (item: SystemMessageSummary) => {
+ const response = await fetchWithAuth(`/api/v1/admin/system-messages/${item.id}`, {
+ method: "DELETE",
+ });
+ if (!response.ok) {
+ throw new Error(await readApiError(response));
+ }
+ return item.id;
+ },
+ onSuccess: async (deletedId) => {
+ if (editingId === deletedId) {
+ resetForm();
+ }
+ setError("");
+ setSuccess("系统消息已删除");
+ await refreshList();
+ },
+ onError: (candidate) => {
+ setSuccess("");
+ setError(candidate instanceof Error ? candidate.message : "删除失败");
+ },
+ });
+
+ const items = listQuery.data?.items ?? [];
+ const listError = listQuery.error instanceof Error ? listQuery.error.message : "";
+
+ if (initializing || listQuery.isLoading) {
+ return Loading system messages...
;
+ }
+
+ if (!user) {
+ return (
+
+ 请先登录后再访问系统消息页面。
+ 返回首页
+
+ );
+ }
+
+ if (!canRead) {
+ return (
+
+ 你没有访问该页面的权限(需要 `system_message.read`)。
+ 返回首页
+
+ );
+ }
+
+ return (
+
+ {(error || listError) &&
{error || listError}}
+ {success &&
{success}}
+
+
+
+
+
系统消息列表
+
维护系统公告消息,支持等级、有效期与发布状态。
+
+ {canManage && (
+
新建消息
+ )}
+
+
+
+
+ 关键词
+ ) => setKeyword(event.currentTarget.value)}
+ placeholder="按标题/内容筛选"
+ className="w-full"
+ />
+
+
+ 状态
+ setStatusFilter(value as StatusFilter)}
+ >
+
+
+ 全部
+ 草稿
+ 已发布
+ 已归档
+
+
+
+
+ 等级
+ setLevelFilter(value as LevelFilter)}
+ >
+
+
+ 全部
+ 信息
+ 成功
+ 警告
+ 错误
+
+
+
+
+
+
+
+
+
+ ID
+ 标题
+ 等级
+ 状态
+ 有效期
+ 更新时间
+ {canManage && 操作}
+
+
+
+ {items.map((item) => (
+
+ {item.id}
+
+ {item.title}
+
+ {item.content}
+
+
+ {item.level}
+ {item.status}
+
+ {item.start_at ? new Date(item.start_at).toLocaleString() : "-"}
+ {" ~ "}
+ {item.end_at ? new Date(item.end_at).toLocaleString() : "-"}
+
+ {new Date(item.updated_at).toLocaleString()}
+ {canManage && (
+
+
+ startEdit(item)}>编辑
+ {
+ if (!window.confirm(`确认删除系统消息「${item.title}」吗?`)) {
+ return;
+ }
+ deleteMutation.mutate(item);
+ }}
+ >
+ 删除
+
+
+
+ )}
+
+ ))}
+ {items.length === 0 && (
+
+
+ 未找到系统消息。
+
+
+ )}
+
+
+
+
+
+ {canManage && (
+
+ {editingId === null ? "新建系统消息" : "编辑系统消息"}
+
+
+ 标题
+ ) => setForm((prev) => ({ ...prev, title: event.currentTarget.value }))}
+ placeholder="请输入消息标题"
+ className="w-full"
+ />
+
+
+ 内容
+
+
+ 等级
+ setForm((prev) => ({ ...prev, level: value as FormState["level"] }))}
+ >
+
+
+ 信息
+ 成功
+ 警告
+ 错误
+
+
+
+
+ 状态
+ setForm((prev) => ({ ...prev, status: value as FormState["status"] }))}
+ >
+
+
+ 草稿
+ 已发布
+ 已归档
+
+
+
+
+ 生效时间
+ ) => setForm((prev) => ({ ...prev, start_at: event.currentTarget.value }))}
+ className="w-full"
+ />
+
+
+ 失效时间
+ ) => setForm((prev) => ({ ...prev, end_at: event.currentTarget.value }))}
+ className="w-full"
+ />
+
+
+
+
+ saveMutation.mutate()}
+ disabled={saveMutation.isPending}
+ >
+ {saveMutation.isPending ? "提交中..." : editingId === null ? "创建" : "保存"}
+
+ 重置
+
+
+ )}
+
+ );
+}
diff --git a/web/src/app/admin/system-params/page.tsx b/web/src/app/admin/system-params/page.tsx
new file mode 100644
index 0000000..f227ea3
--- /dev/null
+++ b/web/src/app/admin/system-params/page.tsx
@@ -0,0 +1,378 @@
+"use client";
+
+import Link from "next/link";
+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
+import { ChangeEvent, useCallback, useMemo, useState } from "react";
+
+import { useAuth } from "@/components/auth-provider";
+import { useTopicSubscription } from "@/hooks/use-topic-subscription";
+import { readApiError } from "@/lib/api";
+import type { SystemParamListResponse, SystemParamSummary } from "@/types/auth";
+import { Button, Select, Table, TextArea, TextField } from "@radix-ui/themes";
+
+type StatusFilter = "all" | "enabled" | "disabled";
+
+type FormState = {
+ param_key: string;
+ param_name: string;
+ param_value: string;
+ description: string;
+ status: "enabled" | "disabled";
+};
+
+const EMPTY_FORM: FormState = {
+ param_key: "",
+ param_name: "",
+ param_value: "",
+ description: "",
+ status: "enabled",
+};
+
+export default function AdminSystemParamsPage() {
+ const { user, initializing, fetchWithAuth, hasPermission } = useAuth();
+ const queryClient = useQueryClient();
+
+ const [keyword, setKeyword] = useState("");
+ const [statusFilter, setStatusFilter] = useState("all");
+ const [editingId, setEditingId] = useState(null);
+ const [form, setForm] = useState(EMPTY_FORM);
+ const [error, setError] = useState("");
+ const [success, setSuccess] = useState("");
+
+ const canRead = hasPermission("system_param.read") || hasPermission("system_param.manage");
+ const canManage = hasPermission("system_param.manage");
+
+ const listPath = useMemo(() => {
+ const params = new URLSearchParams();
+ if (keyword.trim()) {
+ params.set("keyword", keyword.trim());
+ }
+ if (statusFilter !== "all") {
+ params.set("status", statusFilter);
+ }
+ const qs = params.toString();
+ return `/api/v1/admin/system-params${qs ? `?${qs}` : ""}`;
+ }, [keyword, statusFilter]);
+
+ const listQuery = useQuery({
+ queryKey: [listPath],
+ enabled: !!user && canRead,
+ queryFn: async () => {
+ const response = await fetchWithAuth(listPath);
+ if (!response.ok) {
+ throw new Error(await readApiError(response));
+ }
+ return (await response.json()) as SystemParamListResponse;
+ },
+ });
+
+ const refreshList = useCallback(async () => {
+ await queryClient.invalidateQueries({
+ predicate: (query) =>
+ Array.isArray(query.queryKey)
+ && typeof query.queryKey[0] === "string"
+ && query.queryKey[0].startsWith("/api/v1/admin/system-params"),
+ });
+ }, [queryClient]);
+
+ useTopicSubscription("admin.system-params", useCallback(() => {
+ void refreshList();
+ }, [refreshList]));
+
+ const resetForm = () => {
+ setEditingId(null);
+ setForm(EMPTY_FORM);
+ };
+
+ const startCreate = () => {
+ setError("");
+ setSuccess("");
+ setEditingId(null);
+ setForm(EMPTY_FORM);
+ };
+
+ const startEdit = (item: SystemParamSummary) => {
+ setError("");
+ setSuccess("");
+ setEditingId(item.id);
+ setForm({
+ param_key: item.param_key,
+ param_name: item.param_name,
+ param_value: item.param_value,
+ description: item.description ?? "",
+ status: item.status,
+ });
+ };
+
+ const saveMutation = useMutation({
+ mutationFn: async () => {
+ if (!canManage) {
+ throw new Error("缺少 system_param.manage 权限");
+ }
+ if (!form.param_name.trim() || !form.param_key.trim()) {
+ throw new Error("参数键和参数名称不能为空");
+ }
+
+ if (editingId === null) {
+ const response = await fetchWithAuth("/api/v1/admin/system-params", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ param_key: form.param_key.trim(),
+ param_name: form.param_name.trim(),
+ param_value: form.param_value,
+ description: form.description,
+ status: form.status,
+ }),
+ });
+ if (!response.ok) {
+ throw new Error(await readApiError(response));
+ }
+ return "created";
+ }
+
+ const response = await fetchWithAuth(`/api/v1/admin/system-params/${editingId}`, {
+ method: "PATCH",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ param_name: form.param_name.trim(),
+ param_value: form.param_value,
+ description: form.description,
+ status: form.status,
+ }),
+ });
+ if (!response.ok) {
+ throw new Error(await readApiError(response));
+ }
+ return "updated";
+ },
+ onSuccess: async (mode) => {
+ setError("");
+ setSuccess(mode === "created" ? "系统参数已创建" : "系统参数已更新");
+ resetForm();
+ await refreshList();
+ },
+ onError: (candidate) => {
+ setSuccess("");
+ setError(candidate instanceof Error ? candidate.message : "保存失败");
+ },
+ });
+
+ const deleteMutation = useMutation({
+ mutationFn: async (item: SystemParamSummary) => {
+ const response = await fetchWithAuth(`/api/v1/admin/system-params/${item.id}`, {
+ method: "DELETE",
+ });
+ if (!response.ok) {
+ throw new Error(await readApiError(response));
+ }
+ return item.id;
+ },
+ onSuccess: async (deletedId) => {
+ if (editingId === deletedId) {
+ resetForm();
+ }
+ setError("");
+ setSuccess("系统参数已删除");
+ await refreshList();
+ },
+ onError: (candidate) => {
+ setSuccess("");
+ setError(candidate instanceof Error ? candidate.message : "删除失败");
+ },
+ });
+
+ const items = listQuery.data?.items ?? [];
+ const listError = listQuery.error instanceof Error ? listQuery.error.message : "";
+
+ if (initializing || listQuery.isLoading) {
+ return Loading system params...
;
+ }
+
+ if (!user) {
+ return (
+
+ 请先登录后再访问系统参数页面。
+ 返回首页
+
+ );
+ }
+
+ if (!canRead) {
+ return (
+
+ 你没有访问该页面的权限(需要 `system_param.read`)。
+ 返回首页
+
+ );
+ }
+
+ return (
+
+ {(error || listError) &&
{error || listError}}
+ {success &&
{success}}
+
+
+
+
+
系统参数列表
+
维护系统级参数键值、状态与说明。
+
+ {canManage && (
+
新建参数
+ )}
+
+
+
+
+ 关键词
+ ) => setKeyword(event.currentTarget.value)}
+ placeholder="按参数键 / 名称 / 值筛选"
+ className="w-full"
+ />
+
+
+ 状态
+ setStatusFilter(value as StatusFilter)}
+ >
+
+
+ 全部
+ 已启用
+ 已禁用
+
+
+
+
+
+
+
+
+
+ ID
+ 参数键
+ 参数名称
+ 参数值
+ 状态
+ 更新时间
+ {canManage && 操作}
+
+
+
+ {items.map((item) => (
+
+ {item.id}
+ {item.param_key}
+ {item.param_name}
+ {item.param_value || "-"}
+ {item.status === "enabled" ? "已启用" : "已禁用"}
+ {new Date(item.updated_at).toLocaleString()}
+ {canManage && (
+
+
+ startEdit(item)}>编辑
+ {
+ if (!window.confirm(`确认删除系统参数 ${item.param_key} 吗?`)) {
+ return;
+ }
+ deleteMutation.mutate(item);
+ }}
+ >
+ 删除
+
+
+
+ )}
+
+ ))}
+ {items.length === 0 && (
+
+
+ 未找到系统参数。
+
+
+ )}
+
+
+
+
+
+ {canManage && (
+
+ {editingId === null ? "新建系统参数" : "编辑系统参数"}
+
+
+ 参数键
+ ) => setForm((prev) => ({ ...prev, param_key: event.currentTarget.value }))}
+ disabled={editingId !== null}
+ placeholder="如 site.title"
+ className="w-full"
+ />
+
+
+ 参数名称
+ ) => setForm((prev) => ({ ...prev, param_name: event.currentTarget.value }))}
+ placeholder="如 站点标题"
+ className="w-full"
+ />
+
+
+ 参数值
+
+
+ 说明
+
+
+ 状态
+ setForm((prev) => ({ ...prev, status: value as "enabled" | "disabled" }))}
+ >
+
+
+ 已启用
+ 已禁用
+
+
+
+
+
+
+ saveMutation.mutate()}
+ disabled={saveMutation.isPending}
+ >
+ {saveMutation.isPending ? "提交中..." : editingId === null ? "创建" : "保存"}
+
+ 重置
+
+
+ )}
+
+ );
+}
diff --git a/web/src/app/admin/tag/page.tsx b/web/src/app/admin/tag/page.tsx
new file mode 100644
index 0000000..980fc9c
--- /dev/null
+++ b/web/src/app/admin/tag/page.tsx
@@ -0,0 +1,293 @@
+"use client";
+
+import Link from "next/link";
+import { ChangeEvent, useCallback, useMemo, useState } from "react";
+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
+import { Button, Dialog, Table, TextField } from "@radix-ui/themes";
+
+import { useAuth } from "@/components/auth-provider";
+import { useTopicSubscription } from "@/hooks/use-topic-subscription";
+import { readApiError } from "@/lib/api";
+import type {
+ QuestionTagListResponse,
+ QuestionTagMutationResponse,
+ QuestionTagSummary,
+} from "@/types/auth";
+
+export default function AdminTagPage() {
+ const { user, initializing, hasPermission, fetchWithAuth } = useAuth();
+ const queryClient = useQueryClient();
+
+ const [keyword, setKeyword] = useState("");
+ const [renameDialogOpen, setRenameDialogOpen] = useState(false);
+ const [sourceTag, setSourceTag] = useState("");
+ const [targetTag, setTargetTag] = useState("");
+ const [error, setError] = useState("");
+ const [success, setSuccess] = useState("");
+
+ const canRead = hasPermission("question_bank.read") || hasPermission("question_bank.manage");
+ const canManage = hasPermission("question_bank.manage");
+
+ const tagsPath = useMemo(() => {
+ const params = new URLSearchParams();
+ const normalized = keyword.trim();
+ if (normalized) {
+ params.set("keyword", normalized);
+ }
+ const query = params.toString();
+ return `/api/v1/admin/question-bank/tags${query ? `?${query}` : ""}`;
+ }, [keyword]);
+
+ const loadTags = useQuery({
+ queryKey: [tagsPath],
+ enabled: !!user && canRead,
+ queryFn: async () => {
+ const response = await fetchWithAuth(tagsPath);
+ if (!response.ok) {
+ throw new Error(await readApiError(response));
+ }
+ return (await response.json()) as QuestionTagListResponse;
+ },
+ });
+
+ const refreshTags = useCallback(async () => {
+ await queryClient.invalidateQueries({
+ predicate: (query) =>
+ Array.isArray(query.queryKey)
+ && typeof query.queryKey[0] === "string"
+ && query.queryKey[0].startsWith("/api/v1/admin/question-bank/tags"),
+ });
+ }, [queryClient]);
+
+ useTopicSubscription(
+ "admin.question_bank",
+ useCallback(() => {
+ void refreshTags();
+ }, [refreshTags]),
+ );
+
+ const renameMutation = useMutation({
+ mutationFn: async (payload: { old_tag: string; new_tag: string }) => {
+ const response = await fetchWithAuth("/api/v1/admin/question-bank/tags/rename", {
+ method: "PATCH",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(payload),
+ });
+ if (!response.ok) {
+ throw new Error(await readApiError(response));
+ }
+ return (await response.json()) as QuestionTagMutationResponse;
+ },
+ onSuccess: async (payload) => {
+ setError("");
+ setSuccess(`分组已重命名,影响题目数:${payload.affected_questions}`);
+ setRenameDialogOpen(false);
+ setTargetTag("");
+ await refreshTags();
+ await queryClient.invalidateQueries({
+ predicate: (query) =>
+ Array.isArray(query.queryKey)
+ && typeof query.queryKey[0] === "string"
+ && query.queryKey[0].startsWith("/api/v1/admin/question-bank"),
+ });
+ },
+ onError: (candidate) => {
+ setSuccess("");
+ setError(candidate instanceof Error ? candidate.message : "分组重命名失败");
+ },
+ });
+
+ const deleteMutation = useMutation({
+ mutationFn: async (tag: QuestionTagSummary) => {
+ const response = await fetchWithAuth("/api/v1/admin/question-bank/tags", {
+ method: "DELETE",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ tag: tag.name }),
+ });
+ if (!response.ok) {
+ throw new Error(await readApiError(response));
+ }
+ return { tag: tag.name, ...(await response.json() as QuestionTagMutationResponse) };
+ },
+ onSuccess: async (payload) => {
+ setError("");
+ setSuccess(`分组 ${payload.tag} 已删除,影响题目数:${payload.affected_questions}`);
+ await refreshTags();
+ await queryClient.invalidateQueries({
+ predicate: (query) =>
+ Array.isArray(query.queryKey)
+ && typeof query.queryKey[0] === "string"
+ && query.queryKey[0].startsWith("/api/v1/admin/question-bank"),
+ });
+ },
+ onError: (candidate) => {
+ setSuccess("");
+ setError(candidate instanceof Error ? candidate.message : "分组删除失败");
+ },
+ });
+
+ const openRenameDialog = (tag: QuestionTagSummary) => {
+ setError("");
+ setSuccess("");
+ setSourceTag(tag.name);
+ setTargetTag(tag.name);
+ setRenameDialogOpen(true);
+ };
+
+ const submitRename = () => {
+ const oldTag = sourceTag.trim();
+ const newTag = targetTag.trim();
+ if (!oldTag || !newTag) {
+ setError("分组名不能为空");
+ return;
+ }
+ renameMutation.mutate({ old_tag: oldTag, new_tag: newTag });
+ };
+
+ const removeTag = (tag: QuestionTagSummary) => {
+ if (!window.confirm(`确认删除分组「${tag.name}」吗?将从 ${tag.count} 道题中移除。`)) {
+ return;
+ }
+ deleteMutation.mutate(tag);
+ };
+
+ const items = loadTags.data?.items ?? [];
+ const queryError = loadTags.error instanceof Error ? loadTags.error.message : "";
+
+ if (initializing || loadTags.isLoading) {
+ return Loading group manager...
;
+ }
+
+ if (!user) {
+ return (
+
+ 请先登录后再访问分组管理页面。
+ 返回首页
+
+ );
+ }
+
+ if (!canRead) {
+ return (
+
+ 你没有访问该页面的权限(需要 `question_bank.read`)。
+ 返回首页
+
+ );
+ }
+
+ return (
+
+ {(error || queryError) && (
+
{error || queryError}
+ )}
+ {success && (
+
{success}
+ )}
+
+
+
+
+
分组管理
+
迁移 quiz group 菜单能力:分组检索、重命名、解除关联。
+
+
+
+
+ ) => setKeyword(event.currentTarget.value)}
+ placeholder="按分组关键词筛选"
+ className="w-full md:col-span-2"
+ />
+
+
+
+
+
+
+ 分组
+ 关联题目数
+ {canManage && 操作}
+
+
+
+ {items.map((tag) => (
+
+ {tag.name}
+ {tag.count}
+ {canManage && (
+
+
+ openRenameDialog(tag)}
+ disabled={renameMutation.isPending || deleteMutation.isPending}
+ >
+ 重命名
+
+ removeTag(tag)}
+ disabled={renameMutation.isPending || deleteMutation.isPending}
+ >
+ 删除
+
+
+
+ )}
+
+ ))}
+ {items.length === 0 && (
+
+
+ 暂无分组数据。
+
+
+ )}
+
+
+
+
+
+
+
+ 重命名分组
+
+ 原分组会批量替换为新分组,并自动去重。
+
+
+
+
+ 原分组
+
+
+
+
+ 新分组
+ ) => setTargetTag(event.currentTarget.value)}
+ className="w-full"
+ placeholder="请输入新分组名"
+ />
+
+
+
+
+
+ {renameMutation.isPending ? "提交中..." : "确认重命名"}
+
+ setRenameDialogOpen(false)}>
+ 取消
+
+
+
+
+
+ );
+}
diff --git a/web/src/app/admin/todos/page.tsx b/web/src/app/admin/todos/page.tsx
index 54c1920..496429e 100644
--- a/web/src/app/admin/todos/page.tsx
+++ b/web/src/app/admin/todos/page.tsx
@@ -216,14 +216,14 @@ export default function TodoPage() {
const todoError = todosQuery.error instanceof Error ? todosQuery.error.message : "";
if (initializing || todosQuery.isLoading) {
- return Loading todos...
;
+ return Loading todos...
;
}
if (!user) {
return (
- 请先登录后再访问待办管理页面。
-
+ 请先登录后再访问待办管理页面。
+
返回首页
@@ -233,8 +233,8 @@ export default function TodoPage() {
if (!canRead) {
return (
- 你没有访问该页面的权限(需要 `todo.read`)。
-
+ 你没有访问该页面的权限(需要 `todo.read`)。
+
返回首页
@@ -246,13 +246,13 @@ export default function TodoPage() {
return (
- {(todoError || panelError) &&
{todoError || panelError}}
+ {(todoError || panelError) &&
{todoError || panelError}}
-
+
待办列表
-
支持筛选、状态流转、删除与快捷创建。
+
支持筛选、状态流转、删除与快捷创建。
{canCreate && (
setCreateOpen(true)} type="button">
@@ -333,10 +333,10 @@ export default function TodoPage() {
-
+
-
共 {todosQuery.data?.total ?? 0} 条
- {todosQuery.isFetching &&
刷新中...
}
+
共 {todosQuery.data?.total ?? 0} 条
+ {todosQuery.isFetching &&
刷新中...
}
@@ -356,7 +356,7 @@ export default function TodoPage() {
{item.title}
- {item.description || "-"}
+ {item.description || "-"}
{STATUS_LABEL[item.status]}
{PRIORITY_LABEL[item.priority]}
diff --git a/web/src/app/admin/token-usage/page.tsx b/web/src/app/admin/token-usage/page.tsx
new file mode 100644
index 0000000..3d0c2f5
--- /dev/null
+++ b/web/src/app/admin/token-usage/page.tsx
@@ -0,0 +1,274 @@
+"use client";
+
+import Link from "next/link";
+import { useQuery, useQueryClient } from "@tanstack/react-query";
+import { ChangeEvent, useCallback, useMemo, useState } from "react";
+
+import { useAuth } from "@/components/auth-provider";
+import { useTopicSubscription } from "@/hooks/use-topic-subscription";
+import { readApiError } from "@/lib/api";
+import { Button, Select, TextField, Table } from "@radix-ui/themes";
+import type { ModelListResponse, TokenUsageOverviewResponse } from "@/types/auth";
+
+const DAY_OPTIONS = [7, 14, 30, 60, 90] as const;
+
+function formatNumber(value: number | null | undefined): string {
+ return new Intl.NumberFormat("zh-CN").format(Number(value || 0));
+}
+
+function formatCost(value: number | null | undefined): string {
+ return `USD ${Number(value || 0).toFixed(4)}`;
+}
+
+function formatPercent(value: number | null | undefined): string {
+ if (value === null || value === undefined) {
+ return "-";
+ }
+ return `${(value * 100).toFixed(2)}%`;
+}
+
+export default function AdminTokenUsagePage() {
+ const queryClient = useQueryClient();
+ const { user, initializing, fetchWithAuth, hasPermission } = useAuth();
+
+ const [days, setDays] = useState(7);
+ const [modelCodeDraft, setModelCodeDraft] = useState("");
+ const [modelCode, setModelCode] = useState("");
+
+ const canRead = hasPermission("model.read") || hasPermission("model.manage");
+
+ const overviewPath = useMemo(() => {
+ const params = new URLSearchParams();
+ params.set("days", String(days));
+ if (modelCode.trim()) {
+ params.set("model_code", modelCode.trim());
+ }
+ return `/api/v1/admin/token-usage/overview?${params.toString()}`;
+ }, [days, modelCode]);
+
+ const modelsPath = "/api/v1/admin/models";
+
+ const overviewQuery = useQuery({
+ queryKey: [overviewPath],
+ enabled: !!user && canRead,
+ queryFn: async () => {
+ const response = await fetchWithAuth(overviewPath);
+ if (!response.ok) {
+ throw new Error(await readApiError(response));
+ }
+ return (await response.json()) as TokenUsageOverviewResponse;
+ },
+ });
+
+ const modelsQuery = useQuery({
+ queryKey: [modelsPath],
+ enabled: !!user && canRead,
+ queryFn: async () => {
+ const response = await fetchWithAuth(modelsPath);
+ if (!response.ok) {
+ throw new Error(await readApiError(response));
+ }
+ return (await response.json()) as ModelListResponse;
+ },
+ });
+
+ useTopicSubscription("admin.models", useCallback(() => {
+ void queryClient.invalidateQueries({ queryKey: [overviewPath] });
+ void queryClient.invalidateQueries({ queryKey: [modelsPath] });
+ }, [modelsPath, overviewPath, queryClient]));
+
+ if (initializing || overviewQuery.isLoading) {
+ return Loading token usage...
;
+ }
+
+ if (!user) {
+ return (
+
+ 请先登录后再访问 Token 统计页面。
+ 返回首页
+
+ );
+ }
+
+ if (!canRead) {
+ return (
+
+ 你没有访问该页面的权限(需要 `model.read`)。
+ 返回首页
+
+ );
+ }
+
+ const overview = overviewQuery.data;
+ const trend = overview?.trend ?? [];
+ const topModels = overview?.top_models ?? [];
+ const modelOptions = modelsQuery.data?.items ?? [];
+
+ const error = overviewQuery.error instanceof Error
+ ? overviewQuery.error.message
+ : modelsQuery.error instanceof Error
+ ? modelsQuery.error.message
+ : "";
+
+ return (
+
+ {error &&
{error}}
+
+
+
+
+
Token 统计
+
按时间范围聚合模型请求、成功率、Token 用量与费用,支持按模型过滤。
+
+
+ 统计区间:{overview?.start_date ?? "-"} ~ {overview?.end_date ?? "-"}
+
+
+
+
+
+ 统计天数
+ setDays(Number(value))}>
+
+
+ {DAY_OPTIONS.map((option) => (
+ {option} 天
+ ))}
+
+
+
+
+
+ 模型编码(可选)
+ ) => setModelCodeDraft(event.currentTarget.value)}
+ />
+
+
+
+ setModelCode(modelCodeDraft.trim())}
+ >
+ 查询
+
+
+ {
+ setModelCodeDraft("");
+ setModelCode("");
+ }}
+ >
+ 清空模型
+
+
+
+
+
+
+ 请求总数
+ {formatNumber(overview?.summary.request_count)}
+
+
+ 成功请求
+ {formatNumber(overview?.summary.success_count)}
+
+
+ 成功率
+ {formatPercent(overview?.summary.success_rate)}
+
+
+ Token 总量
+ {formatNumber(overview?.summary.total_tokens)}
+
+
+ 费用总计
+ {formatCost(overview?.summary.total_cost_usd)}
+
+
+
+
+ 每日趋势
+ 展示统计区间内每天的请求、成功与 Token 消耗。
+
+
+
+
+
+ 日期
+ 请求
+ 成功
+ 成功率
+ Token
+ 费用
+
+
+
+ {trend.length === 0 ? (
+
+ 暂无统计数据
+
+ ) : (
+ trend.map((item) => (
+
+ {item.date}
+ {formatNumber(item.request_count)}
+ {formatNumber(item.success_count)}
+ {formatPercent(item.success_rate)}
+ {formatNumber(item.total_tokens)}
+ {formatCost(item.total_cost_usd)}
+
+ ))
+ )}
+
+
+
+
+
+
+ Top 模型(按 Token)
+ 展示当前统计范围内 Token 消耗最高的前 10 个模型。
+
+
+
+
+
+ 模型编码
+ 请求
+ 成功率
+ Token
+ 费用
+
+
+
+ {topModels.length === 0 ? (
+
+ 暂无模型聚合数据
+
+ ) : (
+ topModels.map((item) => (
+
+ {item.model_code}
+ {formatNumber(item.request_count)}
+ {formatPercent(item.success_rate)}
+ {formatNumber(item.total_tokens)}
+ {formatCost(item.total_cost_usd)}
+
+ ))
+ )}
+
+
+
+
+
+ );
+}
diff --git a/web/src/app/admin/users/page.tsx b/web/src/app/admin/users/page.tsx
index 7d38b26..26e8678 100644
--- a/web/src/app/admin/users/page.tsx
+++ b/web/src/app/admin/users/page.tsx
@@ -5,7 +5,7 @@ import Link from "next/link";
import { ChangeEvent, FormEvent, useCallback, useMemo, useState } from "react";
import { useAuth } from "@/components/auth-provider";
-import { Button, TextField } from "@radix-ui/themes";
+import { Button, Checkbox, TextField, Table } from "@radix-ui/themes";
import { useTopicSubscription } from "@/hooks/use-topic-subscription";
import { readApiError } from "@/lib/api";
import type { RoleItem, RoleListResponse, UserListResponse, UserPublic } from "@/types/auth";
@@ -20,6 +20,7 @@ export default function AdminUsersPage() {
const [savingUserId, setSavingUserId] = useState(null);
const [deletingUserId, setDeletingUserId] = useState(null);
const [resettingUserId, setResettingUserId] = useState(null);
+ const [updatingStatusUserId, setUpdatingStatusUserId] = useState(null);
const [newUserId, setNewUserId] = useState("");
const [newEmail, setNewEmail] = useState("");
const [newUsername, setNewUsername] = useState("");
@@ -82,6 +83,19 @@ export default function AdminUsersPage() {
const roleOptions = useMemo(() => roles.map((item) => item.code), [roles]);
+ const existingUserIds = useMemo(
+ () => new Set(users.map((item) => item.id.trim().toLowerCase())),
+ [users],
+ );
+ const existingEmails = useMemo(
+ () => new Set(users.map((item) => item.email.trim().toLowerCase())),
+ [users],
+ );
+ const existingUsernames = useMemo(
+ () => new Set(users.map((item) => item.username.trim().toLowerCase())),
+ [users],
+ );
+
const refreshData = async () => {
await queryClient.invalidateQueries({ queryKey: [usersPath] });
if (canReadRoles) {
@@ -167,6 +181,32 @@ export default function AdminUsersPage() {
onSettled: () => setResettingUserId(null),
});
+ const updateUserProfileMutation = useMutation({
+ mutationFn: async ({ userId, status }: { userId: string; status: "active" | "disabled" }) => {
+ const response = await fetchWithAuth(`/api/v1/users/${userId}`, {
+ method: "PATCH",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ status }),
+ });
+ if (!response.ok) throw new Error(await readApiError(response));
+ return response.json() as Promise;
+ },
+ onMutate: ({ userId }) => {
+ setUpdatingStatusUserId(userId);
+ setError("");
+ setSuccess("");
+ },
+ onSuccess: async (_, variables) => {
+ setSuccess(variables.status === "active" ? "用户已启用" : "用户已禁用");
+ await refreshData();
+ },
+ onError: (candidate) => {
+ setSuccess("");
+ setError(candidate instanceof Error ? candidate.message : "更新用户状态失败");
+ },
+ onSettled: () => setUpdatingStatusUserId(null),
+ });
+
const deleteUserMutation = useMutation({
mutationFn: async (userId: string) => {
const response = await fetchWithAuth(`/api/v1/users/${userId}`, { method: "DELETE" });
@@ -193,6 +233,24 @@ export default function AdminUsersPage() {
event.preventDefault();
setError("");
setSuccess("");
+
+ const candidateUserId = newUserId.trim().toLowerCase();
+ const candidateEmail = newEmail.trim().toLowerCase();
+ const candidateUsername = newUsername.trim().toLowerCase();
+
+ if (existingUserIds.has(candidateUserId)) {
+ setError("用户 ID 已存在,请更换后重试");
+ return;
+ }
+ if (existingEmails.has(candidateEmail)) {
+ setError("邮箱已存在,请更换后重试");
+ return;
+ }
+ if (existingUsernames.has(candidateUsername)) {
+ setError("用户名已存在,请更换后重试");
+ return;
+ }
+
createUserMutation.mutate();
};
@@ -202,14 +260,14 @@ export default function AdminUsersPage() {
|| (rolesQuery.error instanceof Error ? rolesQuery.error.message : "");
if (initializing || usersQuery.isLoading || rolesQuery.isLoading) {
- return Loading users...
;
+ return Loading users...
;
}
if (!user) {
return (
- 请先登录后再访问用户管理页面。
- 返回首页
+ 请先登录后再访问用户管理页面。
+ 返回首页
);
}
@@ -217,20 +275,20 @@ export default function AdminUsersPage() {
if (!canManage) {
return (
- 你没有访问该页面的权限(需要 `user.manage`)。
- 返回首页
+ 你没有访问该页面的权限(需要 `user.manage`)。
+ 返回首页
);
}
return (
- {anyError &&
{anyError}}
- {success &&
{success}}
+ {anyError &&
{anyError}}
+ {success &&
{success}}
-
+
新增用户
- 用户 ID 由管理员手动填写,系统会校验重复。
+ 用户 ID 由管理员手动填写,系统会校验重复。
-
+
用户列表
-
表头已中文化,支持改角色、重置密码、删除。
+
表头已中文化,支持改角色、重置密码、删除。
-
-
-
- | 用户ID |
- 邮箱 |
- 用户名 |
- 状态 |
- 角色 |
- 权限 |
- 操作 |
-
-
-
+
+
+
+ 用户ID
+ 邮箱
+ 用户名
+ 状态
+ 角色
+ 权限
+ 操作
+
+
+
{users.map((item) => (
-
- | {item.id} |
- {item.email} |
- {item.username} |
- {item.status} |
-
+
+ {item.id}
+ {item.email}
+ {item.username}
+ {item.status === "active" ? "启用" : item.status === "disabled" ? "禁用" : item.status}
+
{roleOptions.map((roleCode) => {
const checked = item.role_codes.includes(roleCode);
return (
- ) => {
- const nextRoles = event.currentTarget.checked
+ onCheckedChange={(state: boolean | "indeterminate") => {
+ const nextRoles = state === true
? [...item.role_codes, roleCode]
: item.role_codes.filter((code) => code !== roleCode);
updateRolesMutation.mutate({ userId: item.id, roleCodes: nextRoles });
@@ -323,13 +379,32 @@ export default function AdminUsersPage() {
);
})}
- |
- {item.permission_codes.join(", ") || "-"} |
-
+
+ {item.permission_codes.join(", ") || "-"}
+
- {
+ if (item.id === user.id) {
+ setError("不能修改当前登录账号的状态");
+ return;
+ }
+ const nextStatus: "active" | "disabled" = item.status === "active" ? "disabled" : "active";
+ updateUserProfileMutation.mutate({ userId: item.id, status: nextStatus });
+ }}
+ >
+ {updatingStatusUserId === item.id
+ ? "更新中..."
+ : item.status === "active"
+ ? "禁用"
+ : "启用"}
+
+ {
const pwd = window.prompt(`请输入用户 ${item.username} 的新密码(至少8位)`);
@@ -342,10 +417,10 @@ export default function AdminUsersPage() {
}}
>
{resettingUserId === item.id ? "重置中..." : "改密码"}
-
-
+ {
const confirmed = window.confirm(`确认删除用户 ${item.username}(${item.id})?`);
@@ -354,13 +429,13 @@ export default function AdminUsersPage() {
}}
>
{deletingUserId === item.id ? "删除中..." : "删除"}
-
+
- |
-
+
+
))}
-
-
+
+
diff --git a/web/src/app/admin/vocabulary-proficiency/page.tsx b/web/src/app/admin/vocabulary-proficiency/page.tsx
new file mode 100644
index 0000000..f04c177
--- /dev/null
+++ b/web/src/app/admin/vocabulary-proficiency/page.tsx
@@ -0,0 +1,206 @@
+"use client";
+
+import Link from "next/link";
+import { useQuery, useQueryClient } from "@tanstack/react-query";
+import { useCallback } from "react";
+import { Button, Table } from "@radix-ui/themes";
+
+import { useAuth } from "@/components/auth-provider";
+import { useTopicSubscription } from "@/hooks/use-topic-subscription";
+import { readApiError } from "@/lib/api";
+import type { VocabularyWordStatsResponse } from "@/types/auth";
+
+function formatPercent(value: number | null | undefined): string {
+ if (value === null || value === undefined) {
+ return "-";
+ }
+ return `${(value * 100).toFixed(2)}%`;
+}
+
+export default function AdminVocabularyProficiencyPage() {
+ const { user, initializing, fetchWithAuth, hasPermission } = useAuth();
+ const queryClient = useQueryClient();
+
+ const canRead = hasPermission("vocabulary.read") || hasPermission("vocabulary.manage");
+ const statsPath = "/api/v1/admin/vocabulary/stats";
+
+ const statsQuery = useQuery({
+ queryKey: [statsPath],
+ enabled: !!user && canRead,
+ queryFn: async () => {
+ const response = await fetchWithAuth(statsPath);
+ if (!response.ok) {
+ throw new Error(await readApiError(response));
+ }
+ return (await response.json()) as VocabularyWordStatsResponse;
+ },
+ });
+
+ const refreshStats = useCallback(async () => {
+ await queryClient.invalidateQueries({ queryKey: [statsPath] });
+ }, [queryClient]);
+
+ useTopicSubscription("admin.vocabulary", useCallback(() => {
+ void refreshStats();
+ }, [refreshStats]));
+
+ if (initializing || statsQuery.isLoading) {
+ return Loading vocabulary proficiency...
;
+ }
+
+ if (!user) {
+ return (
+
+ 请先登录后再访问单词统计页面。
+ 返回首页
+
+ );
+ }
+
+ if (!canRead) {
+ return (
+
+ 你没有访问该页面的权限(需要 `vocabulary.read`)。
+ 返回首页
+
+ );
+ }
+
+ const data = statsQuery.data;
+ const error = statsQuery.error instanceof Error ? statsQuery.error.message : "";
+
+ return (
+
+ {error && (
+
{error}
+ )}
+
+
+
+
+
单词统计
+
统计词条规模、状态分布、首字母分布与最近更新情况。
+
+
+ 进入诗词本
+
+
+
+
+
+ 词条总数
+ {data?.summary.total_words ?? 0}
+
+
+ 启用词条
+ {data?.summary.enabled_words ?? 0}
+
+
+ 禁用词条
+ {data?.summary.disabled_words ?? 0}
+
+
+ 启用占比
+ {formatPercent(data?.summary.enabled_rate)}
+
+
+ 缺少音标
+ {data?.summary.missing_phonetic_words ?? 0}
+
+
+ 缺少例句
+ {data?.summary.missing_example_words ?? 0}
+
+
+
+
+
+
+ 状态分布
+
+
+
+
+ 状态
+ 数量
+
+
+
+ {(data?.status_buckets ?? []).map((item) => (
+
+ {item.status}
+ {item.count}
+
+ ))}
+ {(data?.status_buckets ?? []).length === 0 && (
+
+ 暂无状态分布数据
+
+ )}
+
+
+
+
+
+
+ 高频首字母(Top 12)
+
+
+
+
+ 首字母
+ 数量
+
+
+
+ {(data?.initial_buckets ?? []).map((item) => (
+
+ {item.initial}
+ {item.count}
+
+ ))}
+ {(data?.initial_buckets ?? []).length === 0 && (
+
+ 暂无首字母分布数据
+
+ )}
+
+
+
+
+
+
+
+ 最近更新词条
+ 按更新时间倒序展示最近 10 条,便于快速巡检。
+
+
+
+
+ ID
+ 词条
+ 状态
+ 更新时间
+
+
+
+ {(data?.recently_updated ?? []).map((item) => (
+
+ {item.id}
+ {item.word}
+ {item.status === "enabled" ? "已启用" : "已禁用"}
+ {new Date(item.updated_at).toLocaleString()}
+
+ ))}
+ {(data?.recently_updated ?? []).length === 0 && (
+
+ 暂无最近更新数据
+
+ )}
+
+
+
+
+
+ );
+}
diff --git a/web/src/app/admin/vocabulary/page.tsx b/web/src/app/admin/vocabulary/page.tsx
new file mode 100644
index 0000000..8d0c635
--- /dev/null
+++ b/web/src/app/admin/vocabulary/page.tsx
@@ -0,0 +1,386 @@
+"use client";
+
+import Link from "next/link";
+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
+import { ChangeEvent, useCallback, useMemo, useState } from "react";
+import { Button, Select, Table, TextArea, TextField } from "@radix-ui/themes";
+
+import { useAuth } from "@/components/auth-provider";
+import { useTopicSubscription } from "@/hooks/use-topic-subscription";
+import { readApiError } from "@/lib/api";
+import type {
+ VocabularyWordListResponse,
+ VocabularyWordSummary,
+} from "@/types/auth";
+
+type StatusFilter = "all" | "enabled" | "disabled";
+
+type FormState = {
+ word: string;
+ phonetic: string;
+ meaning: string;
+ example: string;
+ status: "enabled" | "disabled";
+};
+
+const EMPTY_FORM: FormState = {
+ word: "",
+ phonetic: "",
+ meaning: "",
+ example: "",
+ status: "enabled",
+};
+
+export default function AdminVocabularyPage() {
+ const { user, initializing, fetchWithAuth, hasPermission } = useAuth();
+ const queryClient = useQueryClient();
+
+ const [keyword, setKeyword] = useState("");
+ const [statusFilter, setStatusFilter] = useState("all");
+ const [editingId, setEditingId] = useState(null);
+ const [form, setForm] = useState(EMPTY_FORM);
+ const [error, setError] = useState("");
+ const [success, setSuccess] = useState("");
+
+ const canRead = hasPermission("vocabulary.read") || hasPermission("vocabulary.manage");
+ const canManage = hasPermission("vocabulary.manage");
+
+ const listPath = useMemo(() => {
+ const params = new URLSearchParams();
+ if (keyword.trim()) {
+ params.set("keyword", keyword.trim());
+ }
+ if (statusFilter !== "all") {
+ params.set("status", statusFilter);
+ }
+ const qs = params.toString();
+ return `/api/v1/admin/vocabulary${qs ? `?${qs}` : ""}`;
+ }, [keyword, statusFilter]);
+
+ const listQuery = useQuery({
+ queryKey: [listPath],
+ enabled: !!user && canRead,
+ queryFn: async () => {
+ const response = await fetchWithAuth(listPath);
+ if (!response.ok) {
+ throw new Error(await readApiError(response));
+ }
+ return (await response.json()) as VocabularyWordListResponse;
+ },
+ });
+
+ const refreshList = useCallback(async () => {
+ await queryClient.invalidateQueries({
+ predicate: (query) =>
+ Array.isArray(query.queryKey)
+ && typeof query.queryKey[0] === "string"
+ && query.queryKey[0].startsWith("/api/v1/admin/vocabulary"),
+ });
+ }, [queryClient]);
+
+ useTopicSubscription("admin.vocabulary", useCallback(() => {
+ void refreshList();
+ }, [refreshList]));
+
+ const resetForm = () => {
+ setEditingId(null);
+ setForm(EMPTY_FORM);
+ };
+
+ const startCreate = () => {
+ setError("");
+ setSuccess("");
+ setEditingId(null);
+ setForm(EMPTY_FORM);
+ };
+
+ const startEdit = (item: VocabularyWordSummary) => {
+ setError("");
+ setSuccess("");
+ setEditingId(item.id);
+ setForm({
+ word: item.word,
+ phonetic: item.phonetic ?? "",
+ meaning: item.meaning,
+ example: item.example ?? "",
+ status: item.status,
+ });
+ };
+
+ const saveMutation = useMutation({
+ mutationFn: async () => {
+ if (!canManage) {
+ throw new Error("缺少 vocabulary.manage 权限");
+ }
+ if (!form.word.trim()) {
+ throw new Error("词条不能为空");
+ }
+
+ if (editingId === null) {
+ const response = await fetchWithAuth("/api/v1/admin/vocabulary", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ word: form.word.trim(),
+ phonetic: form.phonetic.trim() || null,
+ meaning: form.meaning,
+ example: form.example.trim() || null,
+ status: form.status,
+ }),
+ });
+ if (!response.ok) {
+ throw new Error(await readApiError(response));
+ }
+ return "created";
+ }
+
+ const response = await fetchWithAuth(`/api/v1/admin/vocabulary/${editingId}`, {
+ method: "PATCH",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ word: form.word.trim(),
+ phonetic: form.phonetic.trim() || null,
+ meaning: form.meaning,
+ example: form.example.trim() || null,
+ status: form.status,
+ }),
+ });
+ if (!response.ok) {
+ throw new Error(await readApiError(response));
+ }
+ return "updated";
+ },
+ onSuccess: async (mode) => {
+ setError("");
+ setSuccess(mode === "created" ? "词条已创建" : "词条已更新");
+ resetForm();
+ await refreshList();
+ },
+ onError: (candidate) => {
+ setSuccess("");
+ setError(candidate instanceof Error ? candidate.message : "保存失败");
+ },
+ });
+
+ const deleteMutation = useMutation({
+ mutationFn: async (item: VocabularyWordSummary) => {
+ const response = await fetchWithAuth(`/api/v1/admin/vocabulary/${item.id}`, {
+ method: "DELETE",
+ });
+ if (!response.ok) {
+ throw new Error(await readApiError(response));
+ }
+ return item.id;
+ },
+ onSuccess: async (deletedId) => {
+ if (editingId === deletedId) {
+ resetForm();
+ }
+ setError("");
+ setSuccess("词条已删除");
+ await refreshList();
+ },
+ onError: (candidate) => {
+ setSuccess("");
+ setError(candidate instanceof Error ? candidate.message : "删除失败");
+ },
+ });
+
+ const items = listQuery.data?.items ?? [];
+ const listError = listQuery.error instanceof Error ? listQuery.error.message : "";
+
+ if (initializing || listQuery.isLoading) {
+ return Loading poetry notebook...
;
+ }
+
+ if (!user) {
+ return (
+
+ 请先登录后再访问诗词本页面。
+ 返回首页
+
+ );
+ }
+
+ if (!canRead) {
+ return (
+
+ 你没有访问该页面的权限(需要 `vocabulary.read`)。
+ 返回首页
+
+ );
+ }
+
+ return (
+
+ {(error || listError) && (
+
{error || listError}
+ )}
+ {success && (
+
{success}
+ )}
+
+
+
+
+
诗词本列表
+
维护诗词词条、拼音、释义与示例。
+
+ {canManage && (
+
新建词条
+ )}
+
+
+
+
+ 关键词
+ ) => setKeyword(event.currentTarget.value)}
+ placeholder="按词条 / 音标 / 释义筛选"
+ className="w-full"
+ />
+
+
+ 状态
+ setStatusFilter(value as StatusFilter)}
+ >
+
+
+ 全部
+ 已启用
+ 已禁用
+
+
+
+
+
+
+
+
+
+ ID
+ 词条
+ 音标
+ 释义
+ 状态
+ 更新时间
+ {canManage && 操作}
+
+
+
+ {items.map((item) => (
+
+ {item.id}
+ {item.word}
+ {item.phonetic || "-"}
+ {item.meaning || "-"}
+ {item.status === "enabled" ? "已启用" : "已禁用"}
+ {new Date(item.updated_at).toLocaleString()}
+ {canManage && (
+
+
+ startEdit(item)}>编辑
+ {
+ if (!window.confirm(`确认删除词条 ${item.word} 吗?`)) {
+ return;
+ }
+ deleteMutation.mutate(item);
+ }}
+ >
+ 删除
+
+
+
+ )}
+
+ ))}
+ {items.length === 0 && (
+
+
+ 未找到词条数据。
+
+
+ )}
+
+
+
+
+
+ {canManage && (
+
+ {editingId === null ? "新建词条" : "编辑词条"}
+
+
+ 词条
+ ) => setForm((prev) => ({ ...prev, word: event.currentTarget.value }))}
+ placeholder="例如:abandon"
+ className="w-full"
+ />
+
+
+ 音标
+ ) => setForm((prev) => ({ ...prev, phonetic: event.currentTarget.value }))}
+ placeholder="例如:/əˈbændən/"
+ className="w-full"
+ />
+
+
+ 释义
+
+
+ 例句
+
+
+ 状态
+ setForm((prev) => ({ ...prev, status: value as "enabled" | "disabled" }))}
+ >
+
+
+ 已启用
+ 已禁用
+
+
+
+
+
+
+ saveMutation.mutate()}
+ disabled={saveMutation.isPending}
+ >
+ {saveMutation.isPending ? "提交中..." : editingId === null ? "创建" : "保存"}
+
+ 重置
+
+
+ )}
+
+ );
+}
diff --git a/web/src/app/admin/wxapp/page.tsx b/web/src/app/admin/wxapp/page.tsx
new file mode 100644
index 0000000..fe922d5
--- /dev/null
+++ b/web/src/app/admin/wxapp/page.tsx
@@ -0,0 +1 @@
+export { default } from "../system-params/page";
diff --git a/web/src/app/globals.css b/web/src/app/globals.css
index 8f09afb..9ca18ef 100644
--- a/web/src/app/globals.css
+++ b/web/src/app/globals.css
@@ -1,215 +1,5 @@
@import "tailwindcss";
-:root {
- --font-heading: "Space Grotesk", "Avenir Next", "Segoe UI", "Helvetica Neue", Arial, sans-serif;
- --font-body: "Manrope", "Inter", "Segoe UI", "Helvetica Neue", Arial, sans-serif;
- --font-mono: "JetBrains Mono", "SFMono-Regular", Menlo, Monaco, Consolas, "Liberation Mono", monospace;
-}
-
-@theme inline {
- --font-sans: var(--font-body);
- --font-mono: var(--font-mono);
-}
-
body {
min-height: 100vh;
- font-family: var(--font-body), sans-serif;
- color: inherit;
-}
-
-h1,
-h2,
-h3,
-h4 {
- font-family: var(--font-heading), var(--font-body), sans-serif;
- letter-spacing: -0.02em;
-}
-
-a {
- color: inherit;
-}
-
-.radix-themes {
- --background: var(--gray-2);
- --foreground: var(--gray-12);
- --card: var(--color-panel-solid, var(--gray-1));
- --card-solid: var(--gray-1);
- --muted: var(--gray-11);
- --border: var(--gray-6);
- --accent: var(--accent-9);
- --accent-strong: var(--accent-10);
- --danger: var(--red-9);
- --success: var(--green-9);
-}
-
-.app-theme-root {
- background:
- radial-gradient(1100px 420px at -10% -18%, var(--accent-a3), transparent 58%),
- radial-gradient(860px 340px at 110% 8%, var(--accent-a2), transparent 56%),
- linear-gradient(180deg, var(--gray-1) 0%, var(--gray-2) 46%, var(--gray-3) 100%);
- color: var(--foreground);
-}
-
-.surface-card {
- @apply rounded-2xl border p-5 shadow-sm;
- border-color: var(--border);
- background: var(--card);
- box-shadow:
- 0 14px 30px var(--gray-a4),
- 0 1px 0 var(--gray-a3) inset;
-}
-
-.surface-card-muted {
- @apply rounded-2xl border;
- border-color: var(--border);
- background: var(--gray-a2);
-}
-
-.notice {
- @apply overflow-auto rounded-xl border p-4 text-sm;
- border-color: var(--gray-6);
- background: var(--gray-a2);
-}
-
-.notice-error {
- border-color: var(--red-6);
- background: var(--red-a2);
- color: var(--red-11);
-}
-
-.notice-success {
- border-color: var(--green-6);
- background: var(--green-a2);
- color: var(--green-11);
-}
-
-.btn-primary {
- @apply inline-flex items-center justify-center rounded-md px-4 py-2 text-sm font-semibold transition disabled:cursor-not-allowed disabled:opacity-60;
- border: 1px solid var(--accent-8);
- background: var(--accent);
- color: var(--accent-contrast, #fff);
- box-shadow: 0 10px 20px var(--accent-a5);
-}
-
-.btn-primary:hover {
- background: var(--accent-strong);
- box-shadow: 0 12px 24px var(--accent-a6);
-}
-
-.btn-secondary {
- @apply inline-flex items-center justify-center rounded-md px-4 py-2 text-sm font-medium transition disabled:cursor-not-allowed disabled:opacity-60;
- border: 1px solid var(--border);
- background: var(--gray-a2);
- color: var(--gray-12);
-}
-
-.btn-secondary:hover {
- background: var(--gray-a3);
- border-color: var(--gray-7);
-}
-
-.btn-danger {
- @apply inline-flex items-center justify-center rounded-md px-4 py-2 text-sm font-medium transition disabled:cursor-not-allowed disabled:opacity-60;
- border: 1px solid var(--red-7);
- background: var(--red-a2);
- color: var(--red-11);
-}
-
-.btn-danger:hover {
- background: var(--red-a3);
-}
-
-.btn-ghost {
- @apply inline-flex items-center justify-center rounded-md text-sm transition disabled:cursor-not-allowed disabled:opacity-60;
- color: var(--gray-12);
-}
-
-.btn-ghost:hover {
- background: var(--gray-a3);
-}
-
-.btn-small {
- @apply px-3 py-1 text-xs;
-}
-
-.control {
- @apply rounded-md border px-3 py-2 text-sm outline-none transition disabled:cursor-not-allowed disabled:opacity-60;
- border-color: var(--gray-7);
- background: var(--gray-1);
- color: var(--gray-12);
-}
-
-.control::placeholder {
- color: var(--gray-10);
-}
-
-.control:focus {
- border-color: var(--accent-8);
- box-shadow: 0 0 0 3px var(--accent-a4);
-}
-
-.checkbox-control {
- @apply h-4 w-4 rounded border;
- border-color: var(--gray-7);
- accent-color: var(--accent-9);
-}
-
-.checkbox-control:focus-visible {
- outline: none;
- box-shadow: 0 0 0 3px var(--accent-a4);
-}
-
-.dialog-overlay {
- background: var(--gray-a8);
-}
-
-.dialog-content {
- border-color: var(--gray-6);
- background: var(--card);
- box-shadow: 0 26px 64px var(--gray-a7);
-}
-
-.select-content {
- border-color: var(--gray-6);
- background: var(--card);
- color: var(--gray-12);
- box-shadow: 0 16px 36px var(--gray-a6);
-}
-
-.select-item[data-highlighted] {
- background: var(--accent-a3);
- color: var(--accent-11);
-}
-
-.select-item[data-disabled] {
- pointer-events: none;
- opacity: 0.5;
-}
-
-.table-modern {
- border-color: var(--border);
-}
-
-.table-head {
- background: var(--gray-a3);
-}
-
-.table-head th {
- color: var(--gray-12);
-}
-
-.table-body {
- border-color: var(--gray-4);
-}
-
-.table-body tr {
- transition: background-color 0.18s ease;
-}
-
-.table-body tr:hover {
- background: var(--gray-a2);
-}
-
-.text-muted {
- color: var(--muted);
}
diff --git a/web/src/app/layout.tsx b/web/src/app/layout.tsx
index 11283e2..b1113d7 100644
--- a/web/src/app/layout.tsx
+++ b/web/src/app/layout.tsx
@@ -9,8 +9,8 @@ import "@radix-ui/themes/styles.css";
import "./globals.css";
export const metadata: Metadata = {
- title: "fquiz",
- description: "fquiz admin workspace",
+ title: "Quiz",
+ description: "Quiz admin workspace",
};
export default function RootLayout({
@@ -22,7 +22,7 @@ export default function RootLayout({
-
+
{children}
diff --git a/web/src/app/page.tsx b/web/src/app/page.tsx
index 8a082cf..6e26199 100644
--- a/web/src/app/page.tsx
+++ b/web/src/app/page.tsx
@@ -1,10 +1,11 @@
"use client";
import Link from "next/link";
-import { FormEvent, ChangeEvent, useEffect, useState } from "react";
+import { ChangeEvent, FormEvent, useEffect, useState } from "react";
import { useAuth } from "@/components/auth-provider";
-import { API_BASE_URL, getApiBaseUrl, readApiError } from "@/lib/api";
+import { getApiBaseUrl, readApiError } from "@/lib/api";
+import { Button, Callout, Card, Checkbox, Flex, Heading, Text, TextField } from "@radix-ui/themes";
type Mode = "login" | "register";
type PingResponse = { message: string };
@@ -27,7 +28,6 @@ export default function Home() {
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const [pingResult, setPingResult] = useState(null);
- const [resolvedApiBaseUrl, setResolvedApiBaseUrl] = useState(API_BASE_URL);
useEffect(() => {
try {
@@ -48,10 +48,6 @@ export default function Home() {
}
}, []);
- useEffect(() => {
- setResolvedApiBaseUrl(getApiBaseUrl());
- }, []);
-
const handleSubmit = async (event: FormEvent) => {
event.preventDefault();
setError("");
@@ -61,10 +57,7 @@ export default function Home() {
await login(email, password);
if (rememberPassword) {
const credentials: RememberedCredentials = { email, password };
- window.localStorage.setItem(
- REMEMBER_CREDENTIALS_KEY,
- JSON.stringify(credentials),
- );
+ window.localStorage.setItem(REMEMBER_CREDENTIALS_KEY, JSON.stringify(credentials));
} else {
window.localStorage.removeItem(REMEMBER_CREDENTIALS_KEY);
}
@@ -73,8 +66,7 @@ export default function Home() {
}
setPassword("");
} catch (submitError) {
- const message =
- submitError instanceof Error ? submitError.message : "Unknown error";
+ const message = submitError instanceof Error ? submitError.message : "Unknown error";
setError(message);
} finally {
setBusy(false);
@@ -97,106 +89,98 @@ export default function Home() {
if (initializing) {
return (
- Initializing session...
+ Initializing session...
+
+ );
+ }
+
+ if (user) {
+ return (
+
+ Quiz
+
+ 用户管理、角色管理、菜单管理、需求管理已接入统一后台(JWT + Refresh Session + RBAC + Menu + WS)。
+
+
+
+
+ 欢迎,{user.username}
+ {user.email}
+ Roles: {user.role_codes.join(", ") || "-"}
+ Permissions: {user.permission_codes.join(", ") || "-"}
+
+
+
+ Ping Backend
+ 进入后台
+ {hasPermission("user.manage") && (
+ 管理用户
+ )}
+ {hasPermission("requirement.read") && (
+ 查看需求
+ )}
+ void logout()} type="button">退出登录
+
+
+
+ {pingResult && (
+
+
+ {JSON.stringify(pingResult, null, 2)}
+
+
+ )}
+
+ {error && (
+
+ {error}
+
+ )}
);
}
return (
-
- fquiz
-
- 用户管理、角色管理、菜单管理、需求管理已接入统一后台(JWT + Refresh Session + RBAC + Menu + WS)。
-
+
+
+
+
+
+ WELCOME
+ Quiz
+
+ 统一后台入口,支持账号登录、权限访问控制、需求协作与密钥管理。保持现有鉴权链路不变,体验更清晰。
+
+
+
+ 已接入能力
+ JWT + Refresh Session / RBAC / Menu / Requirement / WebSocket
+
+
+
-
- API Base URL
- {resolvedApiBaseUrl}
-
-
- {user ? (
-
- 欢迎,{user.username}
- {user.email}
-
- Roles: {user.role_codes.join(", ") || "-"}
-
-
- Permissions: {user.permission_codes.join(", ") || "-"}
-
-
-
-
+
+
- Ping Backend
-
-
- 进入后台
-
- {hasPermission("user.manage") && (
-
- 管理用户
-
- )}
- {hasPermission("requirement.read") && (
-
- 查看需求
-
- )}
- void logout()}
- type="button"
- >
- 退出登录
-
-
-
- ) : (
-
-
- setMode("login")}
- type="button"
>
登录
-
- setMode("register")}
+
+ setMode("register")}
>
注册
-
-
+
+
-
- )}
- {pingResult && (
-
- {JSON.stringify(pingResult, null, 2)}
-
- )}
-
- {error && (
-
- {error}
-
- )}
+ {error && (
+
+ {error}
+
+ )}
+
+
);
}
diff --git a/web/src/types/auth.ts b/web/src/types/auth.ts
index 1d001ca..3de714e 100644
--- a/web/src/types/auth.ts
+++ b/web/src/types/auth.ts
@@ -65,6 +65,239 @@ export type MenuListResponse = {
total: number;
};
+export type AuditLogItem = {
+ id: number;
+ user_id: string | null;
+ username: string | null;
+ action: string;
+ detail: string | null;
+ created_at: string;
+};
+
+export type AuditLogListResponse = {
+ items: AuditLogItem[];
+ total: number;
+ limit: number;
+ offset: number;
+};
+
+export type SystemParamStatus = "enabled" | "disabled";
+
+export type SystemParamSummary = {
+ id: number;
+ param_key: string;
+ param_name: string;
+ param_value: string;
+ description: string | null;
+ status: SystemParamStatus;
+ created_by_user_id: string | null;
+ updated_by_user_id: string | null;
+ created_at: string;
+ updated_at: string;
+ created_by: UserPublic | null;
+ updated_by: UserPublic | null;
+};
+
+export type SystemParamListResponse = {
+ items: SystemParamSummary[];
+ total: number;
+};
+
+export type SystemMessageLevel = "info" | "success" | "warning" | "error";
+export type SystemMessageStatus = "draft" | "published" | "archived";
+
+export type SystemMessageSummary = {
+ id: number;
+ title: string;
+ content: string;
+ level: SystemMessageLevel;
+ status: SystemMessageStatus;
+ start_at: string | null;
+ end_at: string | null;
+ created_by_user_id: string | null;
+ updated_by_user_id: string | null;
+ created_at: string;
+ updated_at: string;
+ created_by: UserPublic | null;
+ updated_by: UserPublic | null;
+};
+
+export type SystemMessageListResponse = {
+ items: SystemMessageSummary[];
+ total: number;
+};
+
+export type QuestionType =
+ | "single_choice"
+ | "multiple_choice"
+ | "true_false"
+ | "short_answer";
+
+export type QuestionStatus = "draft" | "published" | "archived";
+export type QuestionDifficulty = "easy" | "medium" | "hard";
+
+export type QuestionBankType = QuestionType;
+export type QuestionBankStatus = QuestionStatus;
+export type QuestionBankDifficulty = QuestionDifficulty;
+
+export type QuestionBankSummary = {
+ id: number;
+ question_type: QuestionType;
+ stem: string;
+ options_json: Array> | null;
+ answer: string;
+ analysis: string | null;
+ difficulty: QuestionDifficulty;
+ status: QuestionStatus;
+ tags_json: string[] | null;
+ creator_user_id: string | null;
+ updater_user_id: string | null;
+ created_at: string;
+ updated_at: string;
+ creator: UserPublic | null;
+ updater: UserPublic | null;
+};
+
+export type QuestionBankListResponse = {
+ items: QuestionBankSummary[];
+ total: number;
+};
+
+export type QuestionTagSummary = {
+ name: string;
+ count: number;
+};
+
+export type QuestionTagListResponse = {
+ items: QuestionTagSummary[];
+ total: number;
+};
+
+export type QuestionTagMutationResponse = {
+ affected_questions: number;
+};
+
+export type VocabularyWordStatus = "enabled" | "disabled";
+
+export type VocabularyWordSummary = {
+ id: number;
+ word: string;
+ phonetic: string | null;
+ meaning: string;
+ example: string | null;
+ status: VocabularyWordStatus;
+ created_by_user_id: string | null;
+ updated_by_user_id: string | null;
+ created_at: string;
+ updated_at: string;
+ created_by: UserPublic | null;
+ updated_by: UserPublic | null;
+};
+
+export type VocabularyWordListResponse = {
+ items: VocabularyWordSummary[];
+ total: number;
+};
+
+export type VocabularyStatsSummary = {
+ total_words: number;
+ enabled_words: number;
+ disabled_words: number;
+ enabled_rate: number | null;
+ missing_phonetic_words: number;
+ missing_example_words: number;
+};
+
+export type VocabularyStatusBucketItem = {
+ status: string;
+ count: number;
+};
+
+export type VocabularyInitialBucketItem = {
+ initial: string;
+ count: number;
+};
+
+export type VocabularyWordTrendItem = {
+ id: number;
+ word: string;
+ status: VocabularyWordStatus;
+ updated_at: string;
+};
+
+export type VocabularyWordStatsResponse = {
+ summary: VocabularyStatsSummary;
+ status_buckets: VocabularyStatusBucketItem[];
+ initial_buckets: VocabularyInitialBucketItem[];
+ recently_updated: VocabularyWordTrendItem[];
+};
+
+export type HotSearchRecordSummary = {
+ id: number;
+ source: string;
+ external_id: string | null;
+ title: string;
+ url: string | null;
+ hot_value: string | null;
+ rank_index: number | null;
+ crawl_time: string;
+ batch_no: string | null;
+ detail_markdown: string | null;
+ extra_json: Record | null;
+ matched_topics: string[];
+ creator_user_id: string | null;
+ updater_user_id: string | null;
+ created_at: string;
+ updated_at: string;
+ creator: UserPublic | null;
+ updater: UserPublic | null;
+};
+
+export type HotSearchListResponse = {
+ items: HotSearchRecordSummary[];
+ total: number;
+};
+
+export type HotSearchFollowTopicSummary = {
+ id: number;
+ topic_name: string;
+ keywords: string | null;
+ enabled: boolean;
+ seq: number;
+ created_at: string;
+ updated_at: string;
+ creator: UserPublic | null;
+ updater: UserPublic | null;
+};
+
+export type HotSearchFollowTopicListResponse = {
+ items: HotSearchFollowTopicSummary[];
+ total: number;
+};
+
+export type MdResolveQuestionDraft = {
+ question_type: QuestionType;
+ stem: string;
+ options_json: Array<{ key: string; content: string }> | null;
+ answer: string;
+ analysis: string | null;
+ difficulty: QuestionDifficulty;
+ status: QuestionStatus;
+ tags_json: string[];
+};
+
+export type MdResolveParseResponse = {
+ items: MdResolveQuestionDraft[];
+ total: number;
+ warnings: string[];
+};
+
+export type MdResolveImportResponse = {
+ created_count: number;
+ items: QuestionBankSummary[];
+ warnings: string[];
+};
+
export type FileStorageDriverType = "VFS" | "S3";
export type FileStorageBackendSummary = {
@@ -173,6 +406,8 @@ export type ModelListResponse = {
total: number;
};
+export type PasswordModelListResponse = ModelListResponse;
+
export type ModelRouteRuleItem = {
id: number;
route_type: ModelRouteType;
@@ -241,6 +476,20 @@ export type ModelTestRunListResponse = {
total: number;
};
+export type ModelTestChatResponse = {
+ model_id: number;
+ model_code: string;
+ provider: string;
+ provider_model: string;
+ reply: string | null;
+ latency_ms: number | null;
+ prompt_tokens: number | null;
+ completion_tokens: number | null;
+ total_tokens: number | null;
+ test_status: ModelTestStatus;
+ error_message: string | null;
+};
+
export type ModelSummaryResponse = {
total_models: number;
status_counts: Record;
@@ -251,6 +500,32 @@ export type ModelSummaryResponse = {
tests_7d: ModelTestSummary;
};
+export type TokenUsageSummary = {
+ request_count: number;
+ success_count: number;
+ total_tokens: number;
+ total_cost_usd: number;
+ success_rate: number | null;
+};
+
+export type TokenUsageDailyItem = TokenUsageSummary & {
+ date: string;
+};
+
+export type TokenUsageModelItem = TokenUsageSummary & {
+ model_code: string;
+};
+
+export type TokenUsageOverviewResponse = {
+ days: number;
+ model_code: string | null;
+ start_date: string;
+ end_date: string;
+ summary: TokenUsageSummary;
+ trend: TokenUsageDailyItem[];
+ top_models: TokenUsageModelItem[];
+};
+
export type ChatRole = "system" | "user" | "assistant";
export type ChatSession = {
@@ -298,6 +573,25 @@ export type ChatSendResponse = {
assistant_message: ChatMessage;
};
+export type LifeCountdownProfile = {
+ id?: string;
+ deathDate?: string;
+ todayWarningDate?: string;
+ todayWarningText?: string;
+ todayWarningGeneratedAt?: string;
+ todayWarningModel?: string;
+ createDate?: string;
+ updateDate?: string;
+};
+
+export type LifeCountdownWarning = {
+ warningText?: string;
+ warningDate?: string;
+ generatedAt?: string;
+ modelName?: string;
+ cached?: boolean;
+};
+
export type RequirementStatus =
| "PENDING_ANALYSIS"
| "PENDING_REVISION"