diff --git a/apps/admin-portal/src/app/admin/ai-settings/page.tsx b/apps/admin-portal/src/app/admin/ai-settings/page.tsx new file mode 100644 index 0000000..12cd1a0 --- /dev/null +++ b/apps/admin-portal/src/app/admin/ai-settings/page.tsx @@ -0,0 +1,271 @@ +"use client"; + +import { type ReactNode } from "react"; +import { + useAiProviders, + useUpdateAiProvider, + useAiUsage, +} from "@/hooks/use-ai-settings"; +import { useToast } from "@/providers/toast-provider"; +import { + PageHeader, + PaperCard, + Button, + LoadingState, + ErrorState, + EmptyState, + Table, + TableRow, + TableCell, + Badge, +} from "@/components/ui"; +import { t } from "@/lib/i18n"; + +interface StatCardProps { + label: string; + value: number | string; + suffix?: string; +} + +function StatCard({ label, value, suffix }: StatCardProps): ReactNode { + return ( + +

+ {label} +

+

+ {value} + {suffix && ( + + {suffix} + + )} +

+
+ ); +} + +export default function AiSettingsPage(): ReactNode { + const { data: providers, loading, error, refetch } = useAiProviders(); + const { data: usage } = useAiUsage(); + const [updateProvider, mutationState] = useUpdateAiProvider(); + const { show } = useToast(); + + const handleToggle = async (id: string, isActive: boolean) => { + const res = await updateProvider(id, { isActive: !isActive }); + if (res) { + show("success", `Provider 已${!isActive ? "启用" : "禁用"}`); + refetch(); + } else if (mutationState.error) { + show("error", "操作失败", mutationState.error.message); + } + }; + + return ( +
+ + + {/* AI Provider 列表 */} + +

+ {t("admin.aiSettings.providers")} +

+ {loading && } + {error && } + {!loading && !error && providers && ( + <> + {providers.length === 0 ? ( + + ) : ( + + {providers.map((p) => ( + + {p.name} + + + {p.type} + + + + + {p.model} + + + + + + + + + + + {p.apiBase} + + + + {new Date(p.createdAt).toLocaleDateString("zh-CN")} + + + ))} +
+ )} + + )} +
+ + {/* AI 用量统计 */} + {usage && ( + <> +
+ + + +
+ + {/* Provider 用量明细 */} + +

+ {t("admin.aiSettings.usage")} - {t("admin.aiSettings.provider")} +

+ + {usage.byProvider.map((row) => ( + + {row.providerName} + + {row.requests} + + + {row.tokens} + + + ${row.cost.toFixed(2)} + + + ))} +
+
+ + {/* 7 天趋势 */} + +

+ 7 {t("admin.auditLogs.trend")} +

+ + {usage.byDay.map((row) => ( + + + {row.date} + + + {row.requests} + + + {row.tokens} + + + ))} +
+
+ + )} +
+ ); +} diff --git a/apps/admin-portal/src/app/admin/announcements/page.tsx b/apps/admin-portal/src/app/admin/announcements/page.tsx new file mode 100644 index 0000000..3598b13 --- /dev/null +++ b/apps/admin-portal/src/app/admin/announcements/page.tsx @@ -0,0 +1,406 @@ +"use client"; + +import { type ReactNode, useState, useEffect } from "react"; +import { + useAnnouncements, + useAnnouncementFilter, + useCreateAnnouncement, + usePublishAnnouncement, + useArchiveAnnouncement, + useToggleAnnouncementPin, +} from "@/hooks/use-announcements"; +import { useToast } from "@/providers/toast-provider"; +import { + PageHeader, + PaperCard, + Input, + Select, + Button, + Badge, + LoadingState, + ErrorState, + EmptyState, + Table, + TableRow, + TableCell, + Pagination, +} from "@/components/ui"; +import { t } from "@/lib/i18n"; +import type { AnnouncementViewModel } from "@/types/view-models"; + +const AUDIENCE_OPTIONS = [ + { value: "all", label: "全部" }, + { value: "teachers", label: "教师" }, + { value: "students", label: "学生" }, + { value: "parents", label: "家长" }, +] as const; + +const audienceLabel: Record = { + all: "全部", + teachers: "教师", + students: "学生", + parents: "家长", +}; + +export default function AnnouncementsPage(): ReactNode { + const { filter, setPage, setSearch, setStatus } = useAnnouncementFilter(); + const { data, loading, error } = useAnnouncements(filter); + const { show } = useToast(); + const [createAnnouncement] = useCreateAnnouncement(); + const [publishAnnouncement] = usePublishAnnouncement(); + const [archiveAnnouncement] = useArchiveAnnouncement(); + const [togglePin] = useToggleAnnouncementPin(); + const [searchInput, setSearchInput] = useState(""); + const [modalOpen, setModalOpen] = useState(false); + const [formTitle, setFormTitle] = useState(""); + const [formContent, setFormContent] = useState(""); + const [formAudience, setFormAudience] = useState(["all"]); + const [submitting, setSubmitting] = useState(false); + + useEffect(() => { + const timer = setTimeout(() => setSearch(searchInput), 300); + return () => clearTimeout(timer); + }, [searchInput, setSearch]); + + const openCreateModal = () => { + setFormTitle(""); + setFormContent(""); + setFormAudience(["all"]); + setModalOpen(true); + }; + + const toggleAudience = (value: string) => { + setFormAudience((prev) => { + if (value === "all") { + return ["all"]; + } + const next = prev.filter((a) => a !== "all"); + if (next.includes(value)) { + const filtered = next.filter((a) => a !== value); + return filtered.length === 0 ? ["all"] : filtered; + } + return [...next, value]; + }); + }; + + const handleCreate = async () => { + if (!formTitle.trim() || !formContent.trim()) { + show("warning", "请填写标题和内容"); + return; + } + setSubmitting(true); + try { + await createAnnouncement({ + title: formTitle, + content: formContent, + audience: formAudience, + }); + show("success", "公告已创建"); + setModalOpen(false); + window.location.reload(); + } catch (err) { + show( + "error", + "创建失败", + err instanceof Error ? err.message : String(err), + ); + } finally { + setSubmitting(false); + } + }; + + const handlePublish = async (ann: AnnouncementViewModel) => { + try { + await publishAnnouncement(ann.id); + show("success", "公告已发布"); + window.location.reload(); + } catch (err) { + show( + "error", + "操作失败", + err instanceof Error ? err.message : String(err), + ); + } + }; + + const handleArchive = async (ann: AnnouncementViewModel) => { + try { + await archiveAnnouncement(ann.id); + show("success", "公告已归档"); + window.location.reload(); + } catch (err) { + show( + "error", + "操作失败", + err instanceof Error ? err.message : String(err), + ); + } + }; + + const handleTogglePin = async (ann: AnnouncementViewModel) => { + try { + await togglePin(ann.id); + show("success", ann.isPinned ? "已取消置顶" : "已置顶"); + window.location.reload(); + } catch (err) { + show( + "error", + "操作失败", + err instanceof Error ? err.message : String(err), + ); + } + }; + + return ( +
+ + {t("admin.announcements.new")} + + } + /> + + +
+ setSearchInput(e.target.value)} + style={{ width: 240 }} + /> + +
+
+ + + {loading && } + {error && } + {!loading && !error && data && ( + <> + {data.items.length === 0 ? ( + + ) : ( + <> + + {data.items.map((ann) => ( + + + {ann.title} + + + + + + {ann.audience + .map((a) => audienceLabel[a] ?? a) + .join(", ")} + + {ann.authorName} + + {ann.isPinned ? : "—"} + + + {ann.readCount}/{ann.totalCount} + + + {ann.publishedAt + ? new Date(ann.publishedAt).toLocaleDateString( + "zh-CN", + ) + : "—"} + + + {new Date(ann.createdAt).toLocaleDateString("zh-CN")} + + +
+ {ann.status === "draft" && ( + + )} + {ann.status === "published" && ( + + )} + +
+
+
+ ))} +
+ + + )} + + )} +
+ + {modalOpen && ( +
+
+

+ {t("admin.announcements.new")} +

+
+
+ + 标题 + + setFormTitle(e.target.value)} + placeholder="请输入公告标题" + /> +
+