feat: 完成短剧项目空间与结构化资产创作闭环 (#38)

* feat(canvas): 短剧项目空间 - 建立项目章节与画布关联 MVP

* feat(canvas): 短剧项目空间 - 完善项目资产、镜头与流程闭环

Refs #30

* fix(deploy): 本地部署 - 修复前端构建并忽略本地 PostgreSQL 配置

* feat(*): 短剧项目空间 - 完成项目、章节、资产、角色与画布创作闭环

Refs #30

---------

Co-authored-by: ddCat <ddcat666@88.com>
This commit is contained in:
ddcat666
2026-07-27 10:45:11 +08:00
committed by GitHub
parent 243c43f6c2
commit ea15000541
208 changed files with 22655 additions and 4234 deletions

View File

@@ -1,6 +1,6 @@
import { App, Button, Drawer, Form, Input, InputNumber, Select, Switch, Table, Tag } from "antd";
import type { ColumnsType } from "antd/es/table";
import { Pencil, Plus, Power, Search } from "lucide-react";
import { Pencil, Plus, Power, Search, Trash2 } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { useSearchParams } from "react-router";
@@ -123,8 +123,7 @@ export default function ChannelsPage() {
const toggleChannel = async (channel: ModelChannel) => {
try {
if (channel.enabled === false) await updateAdminChannel(channel.id, { enabled: true });
else await deleteAdminChannel(channel.id);
await updateAdminChannel(channel.id, { enabled: channel.enabled === false });
await syncChannels();
await reload();
message.success(channel.enabled === false ? "系统渠道已启用" : "系统渠道已停用");
@@ -133,14 +132,25 @@ export default function ChannelsPage() {
}
};
const removeChannel = async (channel: ModelChannel) => {
try {
await deleteAdminChannel(channel.id);
await syncChannels();
await reload();
message.success("系统渠道已删除");
} catch (error) {
message.error(error instanceof Error ? error.message : "删除系统渠道失败");
}
};
const columns: ColumnsType<ModelChannel> = [
{ title: "渠道", dataIndex: "name", render: (_, channel) => <div><div className="font-medium">{channel.name}</div><div className="max-w-lg truncate text-xs text-foreground/45">{channel.baseUrl}</div></div> },
{ title: "接口类型", dataIndex: "interfaceType", width: 160, render: (value: ChannelInterfaceType) => <Tag bordered={false} color={value === "newapi-channel-1" ? "green" : value === "newapi" ? "orange" : value === "newapi-channel-2" ? "purple" : value === "xai-video" ? "cyan" : "blue"}>{interfaceTypeLabel(value)}</Tag> },
{ title: "接口类型", dataIndex: "interfaceType", width: 160, render: (value: ChannelInterfaceType) => <Tag variant="filled" color={value === "newapi-channel-1" ? "green" : value === "newapi" ? "orange" : value === "newapi-channel-2" ? "purple" : value === "xai-video" ? "cyan" : "blue"}>{interfaceTypeLabel(value)}</Tag> },
{ title: "模型", dataIndex: "models", width: 100, render: (models: string[]) => `${models?.length || 0} 个` },
{ title: "最大并发", dataIndex: "concurrencyLimit", width: 120, render: (value: number) => value > 0 ? value : <span className="text-foreground/45">跟随系统</span> },
{ title: "密钥", dataIndex: "hasApiKey", width: 100, render: (configured) => <Tag bordered={false} color={configured ? "success" : "default"}>{configured ? "已配置" : "未配置"}</Tag> },
{ title: "状态", dataIndex: "enabled", width: 100, render: (enabled) => <Tag bordered={false} color={enabled !== false ? "success" : "default"}>{enabled !== false ? "已启用" : "已停用"}</Tag> },
{ title: "操作", width: 160, fixed: "right", align: "right", render: (_, channel) => <AdminRowActions primary={{ label: "模型管理", onClick: () => setManagingChannel(channel) }} actions={[{ key: "edit", label: "编辑渠道", icon: <Pencil className="size-3.5" />, onClick: () => openDrawer(channel) }, { key: "toggle", label: channel.enabled !== false ? "停用渠道" : "启用渠道", icon: <Power className="size-3.5" />, danger: channel.enabled !== false, confirm: { title: channel.enabled !== false ? "停用这个系统渠道?" : "启用这个系统渠道?", description: channel.enabled !== false ? "停用后新任务不会再使用该渠道,历史账单和调用记录继续保留。" : "启用后,配置完整的模型会重新进入系统可用模型集合。", okText: channel.enabled !== false ? "确认停用" : "确认启用" }, onClick: () => toggleChannel(channel) }]} /> },
{ title: "密钥", dataIndex: "hasApiKey", width: 100, render: (configured) => <Tag variant="filled" color={configured ? "success" : "default"}>{configured ? "已配置" : "未配置"}</Tag> },
{ title: "状态", dataIndex: "enabled", width: 100, render: (enabled) => <Tag variant="filled" color={enabled !== false ? "success" : "default"}>{enabled !== false ? "已启用" : "已停用"}</Tag> },
{ title: "操作", width: 160, fixed: "right", align: "right", render: (_, channel) => <AdminRowActions primary={{ label: "模型管理", onClick: () => setManagingChannel(channel) }} actions={[{ key: "edit", label: "编辑渠道", icon: <Pencil className="size-3.5" />, onClick: () => openDrawer(channel) }, { key: "toggle", label: channel.enabled !== false ? "停用渠道" : "启用渠道", icon: <Power className="size-3.5" />, danger: channel.enabled !== false, confirm: { title: channel.enabled !== false ? "停用这个系统渠道?" : "启用这个系统渠道?", description: channel.enabled !== false ? "停用后新任务不会再使用该渠道,但仍会保留在列表中,可随时重新启用。" : "启用后,配置完整的模型会重新进入系统可用模型集合。", okText: channel.enabled !== false ? "确认停用" : "确认启用" }, onClick: () => toggleChannel(channel) }, { key: "delete", label: "删除渠道", icon: <Trash2 className="size-3.5" />, danger: true, confirm: { title: "删除这个系统渠道?", description: "删除后渠道及所属模型将不再显示,API Key 会被清除,历史账单和调用记录继续保留。该操作不能在页面恢复。", okText: "确认删除" }, onClick: () => removeChannel(channel) }]} /> },
];
if (managingChannel) {
@@ -150,19 +160,19 @@ export default function ChannelsPage() {
return (
<AdminPageFrame title="系统渠道" description="渠道、模型与售价" actions={<Button type="primary" icon={<Plus className="size-4" />} onClick={() => openDrawer()}>新增系统渠道</Button>}>
<ListToolbar active={hasFilters} onReset={() => updateUrl({ filter: "", interfaceType: "all", status: "all", page: 1 })}>
<Input allowClear className="w-full sm:w-72" prefix={<Search className="size-4 text-foreground/40" />} value={keyword} placeholder="搜索渠道名称或地址" onChange={(event) => updateUrl({ filter: event.target.value, page: 1 }, true)} />
<Input id="admin-channel-search" aria-label="搜索系统渠道" autoComplete="off" allowClear className="app-list-search" prefix={<Search className="size-4 text-foreground/40" />} value={keyword} placeholder="搜索渠道名称或地址" onChange={(event) => updateUrl({ filter: event.target.value, page: 1 }, true)} />
<Select className="w-40" value={interfaceType} onChange={(value) => updateUrl({ interfaceType: value, page: 1 })} options={[{ label: "全部接口", value: "all" }, ...interfaceTypeOptions.flatMap((group) => group.options)]} />
<Select className="w-32" value={status} onChange={(value) => updateUrl({ status: value, page: 1 })} options={[{ label: "全部状态", value: "all" }, { label: "已启用", value: "enabled" }, { label: "已停用", value: "disabled" }]} />
</ListToolbar>
<TableSurface>
{loading && channels.length === 0 ? <AdminTableSkeleton rows={8} columns={7} /> : <Table className="app-data-table" size="middle" rowKey="id" loading={loading} columns={columns} dataSource={channels} locale={{ emptyText: <AdminTableEmpty filtered={hasFilters} title={hasFilters ? undefined : "还没有系统渠道"} description={hasFilters ? undefined : "创建渠道并配置模型后,普通用户即可使用系统模型。"} action={hasFilters ? undefined : <Button type="primary" icon={<Plus className="size-4" />} onClick={() => openDrawer()}>新增系统渠道</Button>} /> }} pagination={{ current: page, pageSize, total, showSizeChanger: true, pageSizeOptions: [20, 50, 100], showTotal: (value, range) => `${range[0]}-${range[1]} / 共 ${value} 条`, onChange: (nextPage, nextSize) => updateUrl({ page: nextSize !== pageSize ? 1 : nextPage, pageSize: nextSize }) }} scroll={{ x: 970 }} />}
</TableSurface>
<Drawer title={editingChannel ? "编辑系统渠道" : "新增系统渠道"} open={drawerOpen} width="min(560px, 100vw)" onClose={closeDrawer} maskClosable={!saving} destroyOnHidden extra={<Button type="primary" loading={saving} onClick={() => void save()}>保存</Button>}>
<Drawer title={editingChannel ? "编辑系统渠道" : "新增系统渠道"} open={drawerOpen} size="min(560px, 100vw)" onClose={closeDrawer} maskClosable={!saving} destroyOnHidden extra={<Button type="primary" loading={saving} onClick={() => void save()}>保存</Button>}>
<Form form={form} layout="vertical" requiredMark={false}>
<Form.Item name="name" label="渠道名称" rules={[{ required: true, message: "请填写渠道名称" }]}><Input placeholder="例如:OpenAI 官方渠道" /></Form.Item>
<Form.Item name="interfaceType" label="接口类型" rules={[{ required: true, message: "请选择接口类型" }]} extra="按生成能力选择实际上游协议;系统渠道统一使用 Bearer 鉴权。"><Select options={interfaceTypeOptions} onChange={(value: ChannelInterfaceType) => { const current = String(form.getFieldValue("baseUrl") || "").trim(); if (!current || current === defaultBaseUrlForChannelInterface()) form.setFieldValue("baseUrl", defaultBaseUrlForChannelInterface(value)); }} /></Form.Item>
<Form.Item name="baseUrl" label="Base URL" rules={[{ required: true, message: "请填写 Base URL" }]}><Input placeholder="填写渠道 Base URL" /></Form.Item>
<Form.Item name="apiKey" label={editingChannel ? `API Key(${configuredSecretText})` : "API Key"} rules={editingChannel ? [] : [{ required: true, message: "请填写 API Key" }]}><Input.Password placeholder={editingChannel ? "留空保留原密钥" : "系统渠道密钥"} /></Form.Item>
<Form.Item name="apiKey" label={editingChannel ? `API Key(${configuredSecretText})` : "API Key"} rules={editingChannel ? [] : [{ required: true, message: "请填写 API Key" }]}><Input.Password autoComplete="new-password" placeholder={editingChannel ? "留空保留原密钥" : "系统渠道密钥"} /></Form.Item>
<Form.Item name="useGlobalConcurrency" label="跟随系统并发配置" valuePropName="checked"><Switch /></Form.Item>
<Form.Item name="concurrencyLimit" label="渠道最大并发数" extra="后台任务和系统代理请求共享该渠道上限;槽位暂满时请求会等待。" rules={useGlobalConcurrency ? [] : [{ required: true, message: "请填写渠道最大并发数" }, { type: "number", min: 1, max: 999, message: "请输入 1-999 的整数" }]}><InputNumber className="w-full" min={1} max={999} precision={0} disabled={useGlobalConcurrency} placeholder={useGlobalConcurrency ? "使用系统默认值" : "1-999"} /></Form.Item>
<Form.Item name="enabled" label="启用" valuePropName="checked"><Switch /></Form.Item>

View File

@@ -167,7 +167,7 @@ export default function AdminAnnouncementsPanel() {
</div>
<ListToolbar active={Boolean(keyword || status !== "all")} onReset={() => { setKeyword(""); setStatus("all"); setPage(1); }}>
<Input allowClear className="w-full sm:w-80" prefix={<Search className="size-4 text-foreground/40" />} value={keyword} placeholder="搜索公告标题或正文" onChange={(event) => { setKeyword(event.target.value); setPage(1); }} />
<Input allowClear className="app-list-search" prefix={<Search className="size-4 text-foreground/40" />} value={keyword} placeholder="搜索公告标题或正文" onChange={(event) => { setKeyword(event.target.value); setPage(1); }} />
<Select className="w-32" value={status} onChange={(value) => { setStatus(value); setPage(1); }} options={[{ label: "全部状态", value: "all" }, { label: "发布中", value: "active" }, { label: "已关闭", value: "closed" }]} />
</ListToolbar>
<TableSurface>

View File

@@ -1,18 +1,18 @@
import { Tooltip } from "antd";
import { BarChart3, BellRing, Coins, FileClock, HardDrive, Home, Mail, MessageSquareText, PanelLeftClose, PanelLeftOpen, RadioTower, Settings2, ShieldCheck, TicketCheck, UsersRound } from "lucide-react";
import { BarChart3, BellRing, Coins, FileClock, HardDrive, Home, Infinity as InfinityIcon, Mail, MessageSquareText, PanelLeftClose, PanelLeftOpen, RadioTower, Settings2, ShieldCheck, TicketCheck, UsersRound } from "lucide-react";
import { useState, type ReactNode } from "react";
import { NavLink, Outlet } from "react-router";
import { Link, NavLink, Outlet } from "react-router";
import { AppChangelogButton } from "@/components/layout/app-changelog-modal";
import { PageHeader, WorkspacePage } from "@/components/layout/workspace-page";
import { WORKSPACE_SIDEBAR_STORAGE_KEY } from "@/components/layout/workspace-sidebar-state";
import { cn } from "@/lib/utils";
import { AdminPageHeader } from "./admin-ui";
type AdminNavigationItem = {
path: string;
label: string;
description: string;
icon: ReactNode;
secondary?: boolean;
};
const adminNavigation: Array<{ label: string; items: AdminNavigationItem[] }> = [
@@ -40,44 +40,48 @@ const adminNavigation: Array<{ label: string; items: AdminNavigationItem[] }> =
{
label: "系统配置",
items: [
{ path: "/admin/settings/runtime-policy", label: "资源与策略", description: "配额、并发、频控与超时", icon: <Settings2 className="size-4" />, secondary: true },
{ path: "/admin/settings/access", label: "登录与注册", description: "注册策略与 Linux.do", icon: <ShieldCheck className="size-4" />, secondary: true },
{ path: "/admin/settings/email", label: "邮件服务", description: "注册验证码 SMTP", icon: <Mail className="size-4" />, secondary: true },
{ path: "/admin/settings/storage", label: "存储服务", description: "OSS 与资源存储", icon: <HardDrive className="size-4" />, secondary: true },
{ path: "/admin/settings/runtime-policy", label: "资源与策略", description: "配额、并发、频控与超时", icon: <Settings2 className="size-4" /> },
{ path: "/admin/settings/access", label: "登录与注册", description: "注册策略与 Linux.do", icon: <ShieldCheck className="size-4" /> },
{ path: "/admin/settings/email", label: "邮件服务", description: "注册验证码 SMTP", icon: <Mail className="size-4" /> },
{ path: "/admin/settings/storage", label: "存储服务", description: "OSS 与资源存储", icon: <HardDrive className="size-4" /> },
],
},
];
export function AdminShell() {
const [collapsed, setCollapsed] = useState(() => window.localStorage.getItem("admin-sidebar-collapsed") === "true");
const [collapsed, setCollapsed] = useState(() => window.localStorage.getItem(WORKSPACE_SIDEBAR_STORAGE_KEY) === "1");
const toggleCollapsed = () => {
setCollapsed((current) => {
const next = !current;
window.localStorage.setItem("admin-sidebar-collapsed", String(next));
window.localStorage.setItem(WORKSPACE_SIDEBAR_STORAGE_KEY, next ? "1" : "0");
return next;
});
};
return (
<main className="flex h-full min-h-0 overflow-hidden bg-muted/15 text-foreground">
<aside className={cn("hidden shrink-0 flex-col border-r border-sidebar-border bg-sidebar text-sidebar-foreground transition-[width] duration-200 motion-reduce:transition-none lg:flex", collapsed ? "w-16" : "w-64")}>
<div className={cn("flex h-16 items-center border-b border-border", collapsed ? "justify-center px-2" : "gap-2.5 px-4")}>
<span className="size-6 shrink-0 bg-current" style={{ mask: "url(/logo.svg) center / contain no-repeat", WebkitMask: "url(/logo.svg) center / contain no-repeat" }} />
{!collapsed ? <div className="min-w-0 flex-1"><div className="truncate text-sm font-semibold">无限画布</div><div className="mt-0.5 text-[11px] text-sidebar-foreground/50">运维管理后台</div></div> : null}
<main className="app-user-workspace flex h-full min-h-0 overflow-hidden text-foreground">
<aside className={cn("app-workspace-sidebar hidden shrink-0 flex-col overflow-hidden lg:flex", collapsed ? "w-[60px]" : "w-[212px]")}>
<div className={cn("flex h-13 shrink-0 items-center", collapsed ? "justify-center" : "gap-2 px-3")}>
{!collapsed ? (
<Link to="/" className="flex min-w-0 flex-1 items-center gap-2" title="影策">
<span className="grid size-7 shrink-0 place-items-center rounded-md bg-foreground text-background"><InfinityIcon className="size-4" /></span>
<span className="min-w-0"><span className="block truncate text-[13px] font-semibold">影策</span><span className="block truncate text-[9px] text-foreground/42">管理后台</span></span>
</Link>
) : null}
<Tooltip title={collapsed ? "展开侧栏" : "折叠侧栏"} placement="right">
<button type="button" className="grid size-8 shrink-0 place-items-center rounded-md text-sidebar-foreground/55 hover:bg-sidebar-accent hover:text-sidebar-foreground" onClick={toggleCollapsed} aria-label={collapsed ? "展开侧栏" : "折叠侧栏"}>
<button type="button" className="app-workspace-icon-button shrink-0" onClick={toggleCollapsed} aria-label={collapsed ? "展开侧栏" : "折叠侧栏"}>
{collapsed ? <PanelLeftOpen className="size-4" /> : <PanelLeftClose className="size-4" />}
</button>
</Tooltip>
</div>
<AdminNavigation collapsed={collapsed} />
<div className="border-t border-border p-2">
<div className="shrink-0 border-t border-border/70 p-2">
<Tooltip title={collapsed ? "更新日志" : undefined} placement="right">
<AppChangelogButton className={cn("flex h-9 w-full items-center rounded-md text-sm text-sidebar-foreground/60 transition-colors hover:bg-sidebar-accent hover:text-sidebar-foreground", collapsed ? "justify-center px-0" : "gap-2 px-3")} showVersion={!collapsed} />
<AppChangelogButton className={cn("flex h-8 w-full items-center rounded text-[11px] text-foreground/52 transition-colors hover:bg-foreground/[.055] hover:text-foreground", collapsed ? "justify-center px-0" : "gap-2 px-2")} showVersion={!collapsed} />
</Tooltip>
<Tooltip title={collapsed ? "返回创作台" : undefined} placement="right">
<NavLink to="/canvas" className={cn("flex h-9 items-center rounded-md text-sm text-sidebar-foreground/60 transition-colors hover:bg-sidebar-accent hover:text-sidebar-foreground", collapsed ? "justify-center px-0" : "gap-2 px-3")}>
<Home className="size-4" />
<NavLink to="/canvas" className={cn("flex h-8 items-center rounded text-[11px] text-foreground/52 transition-colors hover:bg-foreground/[.055] hover:text-foreground", collapsed ? "justify-center px-0" : "gap-2 px-2")}>
<Home className="size-3.5" />
{!collapsed ? <span>返回创作台</span> : null}
</NavLink>
</Tooltip>
@@ -93,33 +97,34 @@ export function AdminShell() {
export function AdminPageFrame({ title, description, actions, children }: { title: string; description: string; actions?: ReactNode; children: ReactNode }) {
return (
<div className="flex min-h-0 flex-1 flex-col">
<AdminPageHeader title={title} description={description} actions={<>{actions}<AppChangelogButton className="grid size-8 place-items-center rounded-md text-foreground/55 transition-colors hover:bg-muted hover:text-foreground lg:hidden [&_svg]:size-4" /></>} />
<div className="thin-scrollbar min-h-0 flex-1 overflow-y-auto p-4 sm:p-6 lg:p-8">
<div className="mx-auto max-w-[1440px]">{children}</div>
<WorkspacePage>
<div className="mx-auto w-full max-w-[1440px] pb-5">
<PageHeader title={title} description={description} actions={actions} />
{children}
</div>
</div>
</WorkspacePage>
);
}
function MobileAdminNavigation() {
return (
<nav className="hide-scrollbar flex shrink-0 gap-1 overflow-x-auto border-b border-border bg-background px-3 py-2 lg:hidden" aria-label="管理后台分区">
<nav className="hide-scrollbar flex shrink-0 gap-1 overflow-x-auto border-b border-border/70 bg-background px-3 py-2 lg:hidden" aria-label="管理后台分区">
{adminNavigation.flatMap((group) => group.items).map((item) => (
<NavLink key={item.path} to={item.path} end={item.path === "/admin"} className={({ isActive }) => cn("flex h-8 shrink-0 items-center gap-1.5 rounded-full px-3 text-xs transition", isActive ? "bg-foreground text-background" : "bg-muted/50 text-foreground/60 hover:text-foreground")}>
<NavLink key={item.path} to={item.path} end={item.path === "/admin"} className={({ isActive }) => cn("app-workspace-nav-link flex h-8 shrink-0 items-center gap-1.5 rounded-md px-2.5 text-xs transition-colors", isActive ? "is-active font-medium" : "text-foreground/60 hover:bg-foreground/[.05] hover:text-foreground")}>
{item.icon}<span>{item.label}</span>
</NavLink>
))}
<AppChangelogButton className="grid size-8 shrink-0 place-items-center rounded-md text-foreground/55 transition-colors hover:bg-foreground/[.05] hover:text-foreground [&_svg]:size-4" />
</nav>
);
}
function AdminNavigation({ collapsed }: { collapsed: boolean }) {
return (
<nav className="thin-scrollbar flex-1 overflow-y-auto px-2 py-3" aria-label="管理后台菜单">
<nav className="thin-scrollbar flex-1 overflow-y-auto px-2 py-2" aria-label="管理后台菜单">
{adminNavigation.map((group) => (
<div key={group.label} className="mb-4">
{!collapsed ? <div className="mb-1.5 px-2.5 text-[11px] font-medium text-sidebar-foreground/45">{group.label}</div> : <div className="mx-auto mb-2 h-px w-7 bg-sidebar-border" />}
<div key={group.label} className="mb-3">
{!collapsed ? <div className="mb-1 px-2.5 text-[10px] font-medium text-foreground/38">{group.label}</div> : <div className="mx-auto mb-1.5 h-px w-7 bg-border/80" />}
<div className="space-y-0.5">
{group.items.map((item) => (
<Tooltip key={item.path} title={collapsed ? item.label : undefined} placement="right">
@@ -127,12 +132,12 @@ function AdminNavigation({ collapsed }: { collapsed: boolean }) {
to={item.path}
end={item.path === "/admin"}
className={({ isActive }) => cn(
"relative flex h-9 items-center rounded-md text-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-sidebar-ring",
collapsed ? "justify-center px-0" : item.secondary ? "gap-2 pl-6 pr-3" : "gap-2 px-3",
isActive ? "bg-sidebar-accent font-medium text-sidebar-accent-foreground" : "text-sidebar-foreground/65 hover:bg-sidebar-accent/60 hover:text-sidebar-foreground",
"app-workspace-nav-link flex h-9 items-center rounded-md text-[13px] transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring",
collapsed ? "justify-center px-0" : "gap-2.5 px-2.5",
isActive ? "is-active font-medium" : "text-foreground/62 hover:bg-foreground/[.05] hover:text-foreground",
)}
>
{({ isActive }) => <>{isActive ? <span className="absolute inset-y-2 left-0 w-0.5 rounded-full bg-sidebar-primary" aria-hidden="true" /> : null}{item.icon}{!collapsed ? <span className="truncate">{item.label}</span> : null}</>}
{item.icon}{!collapsed ? <span className="truncate">{item.label}</span> : null}
</NavLink>
</Tooltip>
))}

View File

@@ -1,6 +1,8 @@
import { App, Button, Dropdown, Tag } from "antd";
import type { MenuProps } from "antd";
import { CheckSquare2, MoreHorizontal, SearchX, X } from "lucide-react";
import type { ButtonProps, MenuProps } from "antd";
import { saveAs } from "file-saver";
import { CheckSquare2, Download, MoreHorizontal, SearchX, X } from "lucide-react";
import { useState } from "react";
import type { ReactNode } from "react";
import { cn } from "@/lib/utils";
@@ -12,24 +14,38 @@ function isStatusConfig(value: ReactNode | { label: string; color?: string }): v
return typeof (value as { label?: unknown }).label === "string";
}
export function AdminPageHeader({
title,
description,
actions,
}: {
title: string;
description?: string;
actions?: ReactNode;
export function AdminExportButton({
exportFile,
fileName,
label = "导出",
successMessage,
errorMessage = "导出失败",
size,
...buttonProps
}: Omit<ButtonProps, "children" | "icon" | "loading" | "onClick"> & {
exportFile: () => Blob | Promise<Blob>;
fileName: string | (() => string);
label?: string;
successMessage?: string;
errorMessage?: string;
}) {
return (
<header className="flex min-h-16 shrink-0 items-center justify-between gap-4 border-b border-border bg-background px-4 sm:px-6">
<div className="min-w-0">
<h1 className="truncate text-lg font-semibold">{title}</h1>
{description ? <p className="mt-0.5 truncate text-xs text-foreground/55">{description}</p> : null}
</div>
{actions ? <div className="flex shrink-0 flex-wrap items-center justify-end gap-2">{actions}</div> : null}
</header>
);
const { message } = App.useApp();
const [exporting, setExporting] = useState(false);
const runExport = async () => {
setExporting(true);
try {
const blob = await exportFile();
saveAs(blob, typeof fileName === "function" ? fileName() : fileName);
if (successMessage) message.success(successMessage);
} catch (error) {
message.error(error instanceof Error ? error.message : errorMessage);
} finally {
setExporting(false);
}
};
return <Button {...buttonProps} size={size} icon={<Download className={size === "small" ? "size-3.5" : "size-4"} />} loading={exporting} onClick={() => void runExport()}>{label}</Button>;
}
export function AdminTableEmpty({
@@ -181,7 +197,7 @@ export function SettingsSectionCard({
<p className="mt-1 text-xs leading-5 text-foreground/55">{description}</p>
</div>
</div>
{isStatusConfig(status) ? <Tag bordered={false} color={status.color}>{status.label}</Tag> : status}
{isStatusConfig(status) ? <Tag variant="filled" color={status.color}>{status.label}</Tag> : status}
</div>
{children}
{footer ? <div className="flex flex-wrap items-center justify-between gap-3 border-t border-border px-5 py-4">{footer}</div> : null}

View File

@@ -85,7 +85,7 @@ export function AdminUserDetailDrawer({ userId, onClose }: { userId: string | nu
}, [auditPage, message, userId]);
return (
<Drawer title={detail ? `${detail.user.displayName || detail.user.username} · 用户详情` : "用户详情"} open={Boolean(userId)} onClose={onClose} width="min(920px, 100vw)" destroyOnHidden>
<Drawer title={detail ? `${detail.user.displayName || detail.user.username} · 用户详情` : "用户详情"} open={Boolean(userId)} onClose={onClose} size="min(920px, 100vw)" destroyOnHidden>
{loading && !detail ? (
<Skeleton active paragraph={{ rows: 10 }} />
) : detail ? (

View File

@@ -2,11 +2,11 @@ import { useCallback, useEffect, useMemo, useState } from "react";
import { App, Button, DatePicker, Form, Input, InputNumber, Modal, Popconfirm, Select, Space, Table, Tabs, Tag, Tooltip } from "antd";
import type { ColumnsType } from "antd/es/table";
import dayjs, { type Dayjs } from "dayjs";
import { saveAs } from "file-saver";
import { Download, Pencil, Plus, RefreshCw, Trash2 } from "lucide-react";
import { Pencil, Plus, RefreshCw, Trash2 } from "lucide-react";
import { Area, CartesianGrid, ComposedChart, Legend, Line, ResponsiveContainer, Tooltip as ChartTooltip, XAxis, YAxis } from "recharts";
import { useSearchParams } from "react-router";
import { ListToolbar } from "@/components/layout/workspace-page";
import {
createAdminModelPricing,
deleteAdminModelPricing,
@@ -20,6 +20,7 @@ import {
type AnalyticsFilters,
type ModelPricing,
} from "@/services/api/auth";
import { AdminExportButton } from "./admin-ui";
type Props = {
users: AdminReferenceData["users"];
@@ -180,15 +181,6 @@ export default function AnalyticsPanel({ users, channels }: Props) {
}
};
const exportCSV = async () => {
try {
const blob = await exportAdminAnalytics(filters);
saveAs(blob, `usage-${filters.from}-${filters.to}.csv`);
} catch (error) {
message.error(error instanceof Error ? error.message : "导出失败");
}
};
const modelColumns: ColumnsType<AdminAnalytics["models"][number]> = [
{
title: "模型",
@@ -199,7 +191,7 @@ export default function AnalyticsPanel({ users, channels }: Props) {
<div>
<div className="font-medium">{value}</div>
<div className="mt-1">
<Tag bordered={false}>{capabilityLabel(row.capability)}</Tag>
<Tag variant="filled">{capabilityLabel(row.capability)}</Tag>
</div>
</div>
),
@@ -282,7 +274,7 @@ export default function AnalyticsPanel({ users, channels }: Props) {
return (
<div className="space-y-5">
<div className="flex flex-wrap items-end gap-3">
<ListToolbar trailing={<><Button icon={<RefreshCw className="size-4" />} loading={loading} onClick={() => void reload()}>刷新</Button><AdminExportButton exportFile={() => exportAdminAnalytics(filters)} fileName={() => `usage-${filters.from}-${filters.to}.csv`} label="导出 CSV" /></>}>
<div>
<div className="mb-1 text-xs text-foreground/55">时间范围</div>
<DatePicker.RangePicker allowClear={false} value={range} onChange={(value) => value?.[0] && value?.[1] && setRange([value[0], value[1]])} />
@@ -291,15 +283,7 @@ export default function AnalyticsPanel({ users, channels }: Props) {
<FilterSelect label="模型" value={model} onChange={setModel} options={modelOptions} width={210} />
<FilterSelect label="渠道" value={channelId} onChange={setChannelId} options={channels.map((channel) => ({ label: channel.name, value: channel.id }))} />
<FilterSelect label="能力" value={capability} onChange={setCapability} options={capabilityOptions} />
<div className="ml-auto flex gap-2">
<Button icon={<RefreshCw className="size-4" />} loading={loading} onClick={() => void reload()}>
刷新
</Button>
<Button icon={<Download className="size-4" />} onClick={() => void exportCSV()}>
导出 CSV
</Button>
</div>
</div>
</ListToolbar>
<div className="grid overflow-hidden rounded-md border border-border sm:grid-cols-2 xl:grid-cols-6">
<Metric label="活跃用户" value={data?.kpi.activeUsers ?? "--"} detail={data ? `DAU ${data.kpi.dau} · WAU ${data.kpi.wau} · MAU ${data.kpi.mau}` : undefined} />
@@ -419,7 +403,7 @@ function PriceInput({ name, label }: { name: keyof PricingFormValues; label: str
}
function capabilityLabel(value: string) {
return capabilityOptions.find((item) => item.value === value)?.label || value || "未分类";
return capabilityOptions.find((item) => item.value === value)?.label || "未分类";
}
function percent(value: number) {

View File

@@ -41,7 +41,7 @@ export function ApiLogDetailDrawer({ logId, onClose }: { logId: string | null; o
].map(([label, children], index) => ({ key: String(index), label, children }))
: [];
return (
<Drawer title="请求详情" open={Boolean(logId)} onClose={onClose} width="min(760px, 100vw)" destroyOnHidden>
<Drawer title="请求详情" open={Boolean(logId)} onClose={onClose} size="min(760px, 100vw)" destroyOnHidden>
{loading ? <Skeleton active paragraph={{ rows: 10 }} /> : log ? <Descriptions bordered size="small" column={1} items={items} /> : <Empty description="没有请求详情" />}
</Drawer>
);

View File

@@ -1,16 +1,17 @@
import { useEffect, useState } from "react";
import { App, Button, Drawer, Form, Input, InputNumber, Popconfirm, Select, Space, Switch, Table, Tag } from "antd";
import { App, Button, Drawer, Form, Input, InputNumber, Popconfirm, Segmented, Select, Space, Switch, Table, Tag } from "antd";
import type { ColumnsType } from "antd/es/table";
import { ArrowLeft, Plus, RefreshCw, Search, Trash2 } from "lucide-react";
import { ListToolbar, TableSurface } from "@/components/layout/workspace-page";
import { createAdminChannelModel, disableAdminChannelModel, fetchAdminChannelModels, listAdminChannelModels, updateAdminChannelModel, type ChannelModel } from "@/services/api/wallet";
import { createAdminChannelModel, deleteAdminChannelModel, fetchAdminChannelModels, listAdminChannelModels, updateAdminChannelModel, type ChannelModel } from "@/services/api/wallet";
import type { ModelChannel } from "@/stores/use-config-store";
type FormValues = {
modelKey: string;
displayName?: string;
capability: ChannelModel["capability"];
billingMode: ChannelModel["billingMode"];
unitPrice: number;
enabled: boolean;
};
@@ -29,6 +30,8 @@ export function ChannelModelManager({ channel, onClose, onChanged }: { channel:
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(20);
const [form] = Form.useForm<FormValues>();
const billingMode = Form.useWatch("billingMode", form) || "fixed_request";
const modelCapability = Form.useWatch("capability", form);
const reload = async () => {
if (!channel) return;
@@ -71,13 +74,13 @@ export function ChannelModelManager({ channel, onClose, onChanged }: { channel:
const startCreate = () => {
setEditing(null);
form.setFieldsValue({ modelKey: "", displayName: "", capability: capabilityFromInterface(channel?.interfaceType), unitPrice: 0, enabled: true });
form.setFieldsValue({ modelKey: "", displayName: "", capability: capabilityFromInterface(channel?.interfaceType), billingMode: "fixed_request", unitPrice: 0, enabled: true });
setEditorOpen(true);
};
const startEdit = (item: ChannelModel) => {
setEditing(item);
form.setFieldsValue({ modelKey: item.modelKey, displayName: item.displayName, capability: item.capability, unitPrice: item.unitPriceMicrocredits / 1_000_000, enabled: item.enabled });
form.setFieldsValue({ modelKey: item.modelKey, displayName: item.displayName, capability: item.capability, billingMode: item.billingMode, unitPrice: item.unitPriceMicrocredits / 1_000_000, enabled: item.enabled });
setEditorOpen(true);
};
@@ -89,7 +92,7 @@ export function ChannelModelManager({ channel, onClose, onChanged }: { channel:
modelKey: values.modelKey.trim(),
displayName: values.displayName?.trim() || values.modelKey.trim(),
capability: values.capability,
billingMode: "fixed_request" as const,
billingMode: values.billingMode,
unitPriceMicrocredits: Math.round(values.unitPrice * 1_000_000),
priceConfigured: true,
enabled: values.enabled !== false,
@@ -108,14 +111,14 @@ export function ChannelModelManager({ channel, onClose, onChanged }: { channel:
}
};
const disable = async (item: ChannelModel) => {
const remove = async (item: ChannelModel) => {
try {
await disableAdminChannelModel(channel.id, item.id);
await deleteAdminChannelModel(channel.id, item.id);
await reload();
await onChanged();
message.success("模型已停用");
message.success("模型已删除");
} catch (error) {
message.error(error instanceof Error ? error.message : "停用模型失败");
message.error(error instanceof Error ? error.message : "删除模型失败");
}
};
@@ -130,7 +133,7 @@ export function ChannelModelManager({ channel, onClose, onChanged }: { channel:
),
},
{ title: "能力", dataIndex: "capability", width: 90, render: capabilityLabel },
{ title: "计费", width: 150, render: (_, item) => (item.priceConfigured ? `${formatCredits(item.unitPriceMicrocredits)} 积分 / 次` : <Tag color="orange">未配置价格</Tag>) },
{ title: "计费", width: 165, render: (_, item) => (item.priceConfigured ? `${formatCredits(item.unitPriceMicrocredits)} 积分 / ${item.billingMode === "per_second" ? "秒" : "次"}` : <Tag color="orange">未配置价格</Tag>) },
{ title: "版本", dataIndex: "priceVersion", width: 75, render: (value) => `v${value}` },
{ title: "状态", dataIndex: "enabled", width: 85, render: (enabled) => (enabled ? <Tag color="green">启用</Tag> : <Tag>停用</Tag>) },
{
@@ -139,8 +142,8 @@ export function ChannelModelManager({ channel, onClose, onChanged }: { channel:
render: (_, item) => (
<Space>
<Button size="small" onClick={() => startEdit(item)}>编辑</Button>
<Popconfirm title="停用模型" description="新任务将不能再使用该模型,历史账单不受影响。" okText="停用" cancelText="取消" onConfirm={() => void disable(item)}>
<Button size="small" danger disabled={!item.enabled} icon={<Trash2 className="size-3.5" />} />
<Popconfirm title="删除模型" description="删除后模型不再显示,历史账单仍会保留。该操作不能在页面恢复。" okText="删除" cancelText="取消" onConfirm={() => void remove(item)}>
<Button size="small" danger title="删除模型" aria-label="删除模型" icon={<Trash2 className="size-3.5" />} />
</Popconfirm>
</Space>
),
@@ -163,7 +166,7 @@ export function ChannelModelManager({ channel, onClose, onChanged }: { channel:
<Button aria-label="返回系统渠道" icon={<ArrowLeft className="size-4" />} onClick={onClose} />
<div className="min-w-0">
<h2 className="truncate text-lg font-semibold">{channel.name} / 模型管理</h2>
<p className="mt-1 text-xs text-foreground/50">维护此渠道的模型能力、启用状态和每次积分价格。</p>
<p className="mt-1 text-xs text-foreground/50">维护模型能力、计费单位、积分单价与启用状态。</p>
</div>
</div>
<Space wrap>
@@ -176,7 +179,7 @@ export function ChannelModelManager({ channel, onClose, onChanged }: { channel:
</Space>
</div>
<ListToolbar active={Boolean(keyword || capability !== "all" || status !== "all")} onReset={() => { setKeyword(""); setCapability("all"); setStatus("all"); setPage(1); }}>
<Input allowClear className="w-full sm:w-72" prefix={<Search className="size-4 text-foreground/40" />} value={keyword} placeholder="搜索模型标识或显示名称" onChange={(event) => { setKeyword(event.target.value); setPage(1); }} />
<Input allowClear className="app-list-search" prefix={<Search className="size-4 text-foreground/40" />} value={keyword} placeholder="搜索模型标识或显示名称" onChange={(event) => { setKeyword(event.target.value); setPage(1); }} />
<Select className="w-32" value={capability} onChange={(value) => { setCapability(value); setPage(1); }} options={[{ label: "全部能力", value: "all" }, { label: "文本", value: "text" }, { label: "图片", value: "image" }, { label: "视频", value: "video" }, { label: "音频", value: "audio" }]} />
<Select className="w-32" value={status} onChange={(value) => { setStatus(value); setPage(1); }} options={[{ label: "全部状态", value: "all" }, { label: "已启用", value: "enabled" }, { label: "已停用", value: "disabled" }]} />
</ListToolbar>
@@ -192,7 +195,7 @@ export function ChannelModelManager({ channel, onClose, onChanged }: { channel:
scroll={{ x: 760 }}
/>
</TableSurface>
<Drawer title={editing ? "编辑模型" : "新增模型"} open={editorOpen} width="min(520px, 100vw)" onClose={() => setEditorOpen(false)} styles={{ body: { paddingBottom: 88 } }} extra={editing ? <Button size="small" icon={<Plus className="size-3.5" />} onClick={startCreate}>新增</Button> : null}>
<Drawer title={editing ? "编辑模型" : "新增模型"} open={editorOpen} size="min(520px, 100vw)" onClose={() => setEditorOpen(false)} styles={{ body: { paddingBottom: 88 } }} extra={editing ? <Button size="small" icon={<Plus className="size-3.5" />} onClick={startCreate}>新增</Button> : null}>
<Form form={form} layout="vertical" requiredMark={false}>
<Form.Item name="modelKey" label="模型标识" rules={[{ required: true, message: "请输入模型标识" }]}>
<Input placeholder="gpt-image-2" />
@@ -201,9 +204,12 @@ export function ChannelModelManager({ channel, onClose, onChanged }: { channel:
<Input placeholder="不填则使用模型标识" />
</Form.Item>
<Form.Item name="capability" label="能力" rules={[{ required: true }]}>
<Select options={[{ label: "文本", value: "text" }, { label: "图片", value: "image" }, { label: "视频", value: "video" }, { label: "音频", value: "audio" }]} />
<Select onChange={(value) => { if (value !== "video") form.setFieldValue("billingMode", "fixed_request"); }} options={[{ label: "文本", value: "text" }, { label: "图片", value: "image" }, { label: "视频", value: "video" }, { label: "音频", value: "audio" }]} />
</Form.Item>
<Form.Item name="unitPrice" label="每次消耗积分" rules={[{ required: true, message: "请输入积分价格" }]}>
<Form.Item name="billingMode" label="计费方式" rules={[{ required: true }]}>
<Segmented block options={[{ label: "按次计费", value: "fixed_request" }, { label: "按秒计费", value: "per_second", disabled: modelCapability !== "video" }]} />
</Form.Item>
<Form.Item name="unitPrice" label={billingMode === "per_second" ? "每秒消耗积分" : "每次消耗积分"} rules={[{ required: true, message: "请输入积分价格" }]}>
<InputNumber style={{ width: "100%" }} min={0} max={1_000_000} precision={6} step={0.1} />
</Form.Item>
<Form.Item name="enabled" label="启用" valuePropName="checked">

View File

@@ -162,8 +162,8 @@ export default function CreditOperationsPanel({ users }: { users: AdminReference
dataIndex: "status",
width: 105,
render: (value) => (
<Tag bordered={false} color={value === "settled" ? "success" : value === "refunded" ? "default" : "warning"}>
{({ uncertain: "待核对", running: "运行中", reserved: "已冻结", settled: "已结算", refunded: "已退款" } as Record<string, string>)[value] || value}
<Tag variant="filled" color={value === "settled" ? "success" : value === "refunded" ? "default" : "warning"}>
{({ uncertain: "待核对", running: "运行中", reserved: "已冻结", settled: "已结算", refunded: "已退款" } as Record<string, string>)[value] || "未知状态"}
</Tag>
),
},
@@ -300,7 +300,7 @@ export default function CreditOperationsPanel({ users }: { users: AdminReference
<div>
<div className="flex items-center gap-2">
<h2 className="text-base font-semibold">计费订单</h2>
<Tag bordered={false} color={orderStatus === "review" && total ? "warning" : "default"}>
<Tag variant="filled" color={orderStatus === "review" && total ? "warning" : "default"}>
{total} 条
</Tag>
</div>
@@ -320,7 +320,7 @@ export default function CreditOperationsPanel({ users }: { users: AdminReference
>
<Input
allowClear
className="w-full sm:w-80"
className="app-list-search"
prefix={<Search className="size-4 text-foreground/40" />}
value={keyword}
placeholder="搜索用户、模型、场景或请求号"

View File

@@ -48,7 +48,7 @@ export default function EmailSettingsPanel() {
icon={<MailCheck className="size-4" />}
title="注册验证邮件"
description="通过 SMTP 发送普通用户注册验证码。"
status={<Space size={6}><Tag bordered={false} color={setting?.enabled ? "success" : "default"}>{setting?.enabled ? "已启用" : "未启用"}</Tag>{setting?.hasPassword ? <Tag bordered={false} color="blue">{configuredSecretText}</Tag> : null}</Space>}
status={<Space size={6}><Tag variant="filled" color={setting?.enabled ? "success" : "default"}>{setting?.enabled ? "已启用" : "未启用"}</Tag>{setting?.hasPassword ? <Tag variant="filled" color="blue">{configuredSecretText}</Tag> : null}</Space>}
footer={<><span className="text-xs text-foreground/45">SMTP 密码使用服务端密钥加密,接口不回显明文。</span><Button type="primary" loading={saving} onClick={() => void save()}>保存邮件配置</Button></>}
>
<Form form={form} layout="vertical" requiredMark={false} disabled={loading}>
@@ -60,7 +60,7 @@ export default function EmailSettingsPanel() {
<Form.Item name="username" label="SMTP 用户名"><Input autoComplete="off" placeholder="通常为完整邮箱地址" /></Form.Item>
<Form.Item name="password" label={setting?.hasPassword ? `SMTP 密码(${configuredSecretText})` : "SMTP 密码"}><Input.Password autoComplete="new-password" placeholder={setting?.hasPassword ? "留空保留原密码" : "SMTP 密码或授权码"} /></Form.Item>
<Form.Item name="fromEmail" label="发件邮箱" rules={[{ type: "email", message: "请输入有效的发件邮箱" }]}><Input placeholder="noreply@example.com" /></Form.Item>
<Form.Item name="fromName" label="发件人名称"><Input placeholder="无限画布" /></Form.Item>
<Form.Item name="fromName" label="发件人名称"><Input placeholder="影策" /></Form.Item>
</div>
</Form>
</SettingsSectionCard>

View File

@@ -1,12 +1,13 @@
import { useEffect, useState } from "react";
import { App, Button, Form, Input, InputNumber, Modal, Popconfirm, Select, Space, Table, Tag } from "antd";
import type { ColumnsType } from "antd/es/table";
import { Ban, Copy, Download, Eye, KeyRound, RefreshCw, Search, TicketCheck } from "lucide-react";
import { Ban, Copy, Eye, KeyRound, RefreshCw, Search, TicketCheck } from "lucide-react";
import { ListToolbar, TableSurface } from "@/components/layout/workspace-page";
import { formatCredits } from "@/constant/credits";
import { useDebouncedValue } from "@/hooks/use-debounced-value";
import { createAdminRedeemBatch, disableAdminRedeemBatch, disableAdminRedeemCode, listAdminRedeemBatchCodes, listAdminRedeemBatches, type AdminRedeemCode, type RedeemBatch } from "@/services/api/wallet";
import { AdminExportButton } from "./admin-ui";
type RedeemFormValues = { amount: number; count: number; note?: string; expiresAt?: string };
@@ -84,14 +85,14 @@ export default function RedemptionCodesPanel() {
</span>
<span className="text-xs text-foreground/45">已核销</span>
{(batch.expiredCount ?? 0) > 0 ? (
<Tag bordered={false} color="default">
<Tag variant="filled" color="default">
{batch.expiredCount} 已过期
</Tag>
) : null}
</div>
),
},
{ title: "有效期", dataIndex: "expiresAt", width: 180, render: (value) => (value ? formatTime(value) : <Tag bordered={false}>永久有效</Tag>) },
{ title: "有效期", dataIndex: "expiresAt", width: 180, render: (value) => (value ? formatTime(value) : <Tag variant="filled">永久有效</Tag>) },
{ title: "批次备注", dataIndex: "note", render: (value) => value || <span className="text-foreground/35">未填写</span> },
{
title: "操作",
@@ -181,7 +182,7 @@ export default function RedemptionCodesPanel() {
>
<Input
allowClear
className="w-full sm:w-72"
className="app-list-search"
prefix={<Search className="size-4 text-foreground/40" />}
value={keyword}
placeholder="搜索批次备注、积分或数量"
@@ -242,14 +243,6 @@ function GeneratedCodesModal({ codes, onClose }: { codes: string[]; onClose: ()
await navigator.clipboard.writeText(content);
message.success("兑换码已复制");
};
const download = () => {
const url = URL.createObjectURL(new Blob([content + "\n"], { type: "text/plain;charset=utf-8" }));
const link = document.createElement("a");
link.href = url;
link.download = `兑换码-${new Date().toISOString().slice(0, 10)}.txt`;
link.click();
URL.revokeObjectURL(url);
};
return (
<Modal
title={`已生成 ${codes.length} 个兑换码`}
@@ -260,9 +253,7 @@ function GeneratedCodesModal({ codes, onClose }: { codes: string[]; onClose: ()
<Button icon={<Copy className="size-4" />} onClick={() => void copy()}>
复制全部
</Button>
<Button type="primary" icon={<Download className="size-4" />} onClick={download}>
下载 TXT
</Button>
<AdminExportButton type="primary" exportFile={() => new Blob([content + "\n"], { type: "text/plain;charset=utf-8" })} fileName={() => `兑换码-${new Date().toISOString().slice(0, 10)}.txt`} label="下载 TXT" />
</Space>
}
width={680}
@@ -390,14 +381,14 @@ function RedeemBatchCodesModal({ batch, onClose }: { batch: RedeemBatch | null;
{!plaintextAvailable ? <div className="mb-4 rounded-md bg-amber-500/10 px-3 py-2 text-sm text-amber-800 dark:text-amber-200">该批次创建于加密回看功能上线前,系统当时只保存了哈希,无法恢复完整明文;核销状态和审计信息仍可查看。</div> : null}
<div className="mb-3 flex flex-wrap items-center justify-between gap-3">
<div className="flex flex-wrap gap-2">
<Tag bordered={false} color="green">
<Tag variant="filled" color="green">
可用 {batchSummary?.availableCount ?? 0}
</Tag>
<Tag bordered={false} color="blue">
<Tag variant="filled" color="blue">
已核销 {batchSummary?.redeemedCount ?? 0}
</Tag>
<Tag bordered={false}>已过期 {batchSummary?.expiredCount ?? 0}</Tag>
<Tag bordered={false}>已禁用 {batchSummary?.disabledCount ?? 0}</Tag>
<Tag variant="filled">已过期 {batchSummary?.expiredCount ?? 0}</Tag>
<Tag variant="filled">已禁用 {batchSummary?.disabledCount ?? 0}</Tag>
</div>
<Select
className="w-32"
@@ -448,7 +439,7 @@ function renderCodeStatus(status: AdminRedeemCode["status"]) {
expired: { label: "已过期", color: "default" },
}[status];
return (
<Tag bordered={false} color={config.color}>
<Tag variant="filled" color={config.color}>
{config.label}
</Tag>
);

View File

@@ -1,6 +1,6 @@
import { App, Button, Input, Select, Table, Tag } from "antd";
import type { ColumnsType } from "antd/es/table";
import { Download, Eye, Search } from "lucide-react";
import { Eye, Search } from "lucide-react";
import { useEffect, useMemo, useRef, useState } from "react";
import { useSearchParams } from "react-router";
@@ -10,7 +10,7 @@ import { exportAdminApiLogs, listAdminApiLogs, type ApiCallLog } from "@/service
import { useAdminContext } from "../admin-context";
import { ApiLogDetailDrawer } from "../components/api-log-detail-drawer";
import { AdminPageFrame } from "../components/admin-shell";
import { AdminBatchBar, AdminTableEmpty, AdminTableSkeleton } from "../components/admin-ui";
import { AdminBatchBar, AdminExportButton, AdminTableEmpty, AdminTableSkeleton } from "../components/admin-ui";
export default function LogsPage() {
const { message } = App.useApp();
@@ -24,7 +24,6 @@ export default function LogsPage() {
const [logs, setLogs] = useState<ApiCallLog[]>([]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(true);
const [exporting, setExporting] = useState(false);
const [selectedIds, setSelectedIds] = useState<string[]>([]);
const [detailLogId, setDetailLogId] = useState<string | null>(null);
const requestSequence = useRef(0);
@@ -56,31 +55,13 @@ export default function LogsPage() {
.finally(() => sequence === requestSequence.current && setLoading(false));
}, [debouncedKeyword, status, page, pageSize]);
const exportLogs = async (ids?: string[]) => {
setExporting(true);
try {
const blob = await exportAdminApiLogs({ keyword: ids?.length ? undefined : debouncedKeyword || undefined, status: ids?.length ? undefined : status === "all" ? undefined : status, ids });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = ids?.length ? `请求明细-已选${ids.length}条.csv` : `请求明细-${new Date().toISOString().slice(0, 10)}.csv`;
link.click();
URL.revokeObjectURL(url);
message.success(ids?.length ? `已导出选中的 ${ids.length} 条请求明细` : "已按当前筛选导出请求明细");
} catch (error) {
message.error(error instanceof Error ? error.message : "导出请求明细失败");
} finally {
setExporting(false);
}
};
const columns: ColumnsType<ApiCallLog> = [
{ title: "时间", dataIndex: "createdAt", width: 170, render: formatTime },
{ title: "用户", dataIndex: "userId", width: 160, render: (id) => userNameById.get(id) || id },
{ title: "渠道", dataIndex: "channelName", width: 170, render: (name, log) => name || log.channelId || <span className="text-foreground/40">未记录</span> },
{ title: "模型", dataIndex: "model", width: 180, render: (model) => model || <span className="text-foreground/40">未识别</span> },
{ title: "能力 / 阶段", width: 125, render: (_, log) => `${capabilityText(log.capability)} / ${requestKindText(log.requestKind)}` },
{ title: "状态", dataIndex: "status", width: 110, render: (value, log) => <Tag bordered={false} color={value === "succeeded" ? "success" : "error"}>{value === "succeeded" ? "成功" : `失败 ${log.statusCode || ""}`}</Tag> },
{ title: "状态", dataIndex: "status", width: 110, render: (value, log) => <Tag variant="filled" color={value === "succeeded" ? "success" : "error"}>{value === "succeeded" ? "成功" : `失败 ${log.statusCode || ""}`}</Tag> },
{ title: "错误码", dataIndex: "errorCode", width: 160, ellipsis: true, render: (value) => value || "--" },
{ title: "耗时", dataIndex: "durationMs", width: 100, render: (value) => `${value}ms` },
{ title: "Token", width: 145, render: (_, log) => log.usageAvailable ? `${log.inputTokens} / ${log.outputTokens}` : "--" },
@@ -89,12 +70,12 @@ export default function LogsPage() {
];
return (
<AdminPageFrame title="请求明细" description="上游调用与费用" actions={<Button icon={<Download className="size-4" />} loading={exporting} onClick={() => void exportLogs()}>导出当前筛选</Button>}>
<AdminPageFrame title="请求明细" description="上游调用与费用" actions={<AdminExportButton exportFile={() => exportAdminApiLogs({ keyword: debouncedKeyword || undefined, status: status === "all" ? undefined : status })} fileName={() => `请求明细-${new Date().toISOString().slice(0, 10)}.csv`} label="导出当前筛选" successMessage="已按当前筛选导出请求明细" errorMessage="导出请求明细失败" />}>
<ListToolbar active={hasFilters} onReset={() => updateUrl({ filter: "", status: "all", page: 1 })}>
<Input allowClear className="w-full sm:w-80" prefix={<Search className="size-4 text-foreground/40" />} value={keyword} placeholder="搜索用户、渠道、模型、路径或请求号" onChange={(event) => updateUrl({ filter: event.target.value, page: 1 }, true)} />
<Input allowClear className="app-list-search" prefix={<Search className="size-4 text-foreground/40" />} value={keyword} placeholder="搜索用户、渠道、模型、路径或请求号" onChange={(event) => updateUrl({ filter: event.target.value, page: 1 }, true)} />
<Select className="w-32" value={status} onChange={(value) => updateUrl({ status: value, page: 1 })} options={[{ label: "全部结果", value: "all" }, { label: "成功", value: "succeeded" }, { label: "失败", value: "failed" }]} />
</ListToolbar>
<AdminBatchBar count={selectedIds.length} onClear={() => setSelectedIds([])}><Button type="primary" size="small" icon={<Download className="size-3.5" />} loading={exporting} onClick={() => void exportLogs(selectedIds)}>导出已选</Button></AdminBatchBar>
<AdminBatchBar count={selectedIds.length} onClear={() => setSelectedIds([])}><AdminExportButton type="primary" size="small" exportFile={() => exportAdminApiLogs({ ids: selectedIds })} fileName={() => `请求明细-已选${selectedIds.length}条.csv`} label="导出已选" successMessage={`已导出选中的 ${selectedIds.length} 条请求明细`} errorMessage="导出请求明细失败" /></AdminBatchBar>
<TableSurface>
{loading && logs.length === 0 ? <AdminTableSkeleton rows={8} columns={11} /> : <Table className="app-data-table" size="middle" rowKey="id" loading={loading} rowSelection={{ selectedRowKeys: selectedIds, preserveSelectedRowKeys: false, onChange: (keys) => setSelectedIds(keys.map(String)) }} columns={columns} dataSource={logs} locale={{ emptyText: <AdminTableEmpty filtered={hasFilters} /> }} pagination={{ current: page, pageSize, total, showSizeChanger: true, pageSizeOptions: [20, 50, 100], showTotal: (value, range) => `${range[0]}-${range[1]} / 共 ${value} 条`, onChange: (nextPage, nextSize) => updateUrl({ page: nextSize !== pageSize ? 1 : nextPage, pageSize: nextSize }) }} scroll={{ x: 1280 }} />}
</TableSurface>

View File

@@ -167,7 +167,7 @@ export default function RuntimePolicySettingsPage() {
<Form form={form} layout="vertical" requiredMark={false} disabled={loading} onValuesChange={() => setDirty(true)}>
<div className="mx-auto max-w-6xl space-y-5">
<PolicySection icon={<Database className="size-4" />} title="资源与账号配额" description="上传、文件容量、结构化数据和历史记录上限。" fields={resourceFields} />
<PolicySection icon={<Gauge className="size-4" />} title="任务与并发" description="后台任务消费、渠道调度和单账号活动任务上限。" fields={concurrencyFields} status={<Tag bordered={false} color="blue">热更新</Tag>} />
<PolicySection icon={<Gauge className="size-4" />} title="任务与并发" description="后台任务消费、渠道调度和单账号活动任务上限。" fields={concurrencyFields} status={<Tag variant="filled" color="blue">热更新</Tag>} />
<PolicySection icon={<TimerReset className="size-4" />} title="任务超时" description="不同生成类型的最长执行时间。" fields={timeoutFields} />
<PolicySection icon={<ShieldCheck className="size-4" />} title="业务频控" description="账号与 IP 维度的固定窗口请求限制。" fields={rateFields} />
<PolicySection icon={<Network className="size-4" />} title="渠道中转与熔断" description="请求体、响应体、并发、超时和上游故障保护。" fields={relayFields} />

View File

@@ -54,19 +54,19 @@ export default function StorageSettingsPage() {
icon={<Cloud className="size-4" />}
title="平台 OSS"
description="配置平台媒体资源的默认存储位置。"
status={<Space size={6}><Tag bordered={false} color={setting?.enabled ? "success" : "default"}>{setting?.enabled ? "已启用" : "未启用"}</Tag><Tag bordered={false} color={setting?.hasAccessKeySecret ? "blue" : "warning"}>{setting?.hasAccessKeySecret ? configuredSecretText : "未保存密钥"}</Tag></Space>}
status={<Space size={6}><Tag variant="filled" color={setting?.enabled ? "success" : "default"}>{setting?.enabled ? "已启用" : "未启用"}</Tag><Tag variant="filled" color={setting?.hasAccessKeySecret ? "blue" : "warning"}>{setting?.hasAccessKeySecret ? configuredSecretText : "未保存密钥"}</Tag></Space>}
footer={<><div className="text-xs text-foreground/45">{setting?.updatedAt ? `上次更新:${formatTime(setting.updatedAt)}${setting.updatedBy ? ` · ${userNameById.get(setting.updatedBy) || setting.updatedBy}` : ""}` : "尚未保存 OSS 配置"}</div><Button type="primary" loading={saving} onClick={() => void save()}>保存 OSS 配置</Button></>}
>
<Form form={form} layout="vertical" requiredMark={false} disabled={loading}>
<div className="grid grid-cols-1 gap-x-5 px-5 pt-5 md:grid-cols-2">
<Form.Item name="enabled" label="启用 OSS" valuePropName="checked"><Switch /></Form.Item>
<Form.Item name="provider" label="存储渠道" rules={[{ required: true, message: "请选择存储渠道" }]}><Select options={[{ label: "阿里云 OSS", value: "aliyun" }]} /></Form.Item>
<Form.Item name="region" label="Region"><Input placeholder="例如:oss-cn-hangzhou" /></Form.Item>
<Form.Item name="endpoint" label="Endpoint"><Input placeholder="https://oss-cn-hangzhou.aliyuncs.com" /></Form.Item>
<Form.Item name="bucket" label="Bucket"><Input placeholder="例如:my-canvas-assets" /></Form.Item>
<Form.Item name="pathPrefix" label="路径前缀"><Input placeholder="例如:uploads/infinite-canvas" /></Form.Item>
<Form.Item name="accessKeyId" label="AccessKey ID"><Input placeholder="阿里云 AccessKey ID" /></Form.Item>
<Form.Item name="accessKeySecret" label={setting?.hasAccessKeySecret ? `AccessKey Secret(${configuredSecretText})` : "AccessKey Secret"}><Input.Password placeholder={setting?.hasAccessKeySecret ? "留空保留原密钥" : "阿里云 AccessKey Secret"} /></Form.Item>
<Form.Item name="region" label="Region"><Input autoComplete="off" placeholder="例如:oss-cn-hangzhou" /></Form.Item>
<Form.Item name="endpoint" label="Endpoint"><Input autoComplete="off" placeholder="https://oss-cn-hangzhou.aliyuncs.com" /></Form.Item>
<Form.Item name="bucket" label="Bucket"><Input autoComplete="off" placeholder="例如:my-canvas-assets" /></Form.Item>
<Form.Item name="pathPrefix" label="路径前缀"><Input autoComplete="off" placeholder="例如:uploads/infinite-canvas" /></Form.Item>
<Form.Item name="accessKeyId" label="AccessKey ID"><Input autoComplete="off" placeholder="阿里云 AccessKey ID" /></Form.Item>
<Form.Item name="accessKeySecret" label={setting?.hasAccessKeySecret ? `AccessKey Secret(${configuredSecretText})` : "AccessKey Secret"}><Input.Password autoComplete="new-password" placeholder={setting?.hasAccessKeySecret ? "留空保留原密钥" : "阿里云 AccessKey Secret"} /></Form.Item>
</div>
</Form>
</SettingsSectionCard>

View File

@@ -108,7 +108,7 @@ export default function StoryboardPromptsPage() {
const columns: ColumnsType<StoryboardPromptTemplate> = [
{ title: "模板", dataIndex: "name", render: (_, template) => <div><div className="font-medium">{template.name}</div><div className="text-xs text-foreground/45">{template.content.length} 字符</div></div> },
{ title: "内容摘要", dataIndex: "content", width: 420, render: (content: string) => <div className="line-clamp-2 whitespace-pre-wrap text-xs leading-5 text-foreground/65">{content}</div> },
{ title: "状态", dataIndex: "enabled", width: 110, render: (enabled) => <Tag bordered={false} color={enabled ? "success" : "default"}>{enabled ? "启用中" : "未启用"}</Tag> },
{ title: "状态", dataIndex: "enabled", width: 110, render: (enabled) => <Tag variant="filled" color={enabled ? "success" : "default"}>{enabled ? "启用中" : "未启用"}</Tag> },
{ title: "更新时间", dataIndex: "updatedAt", width: 180, render: formatTime },
{ title: "操作", width: 140, fixed: "right", align: "right", render: (_, template) => <AdminRowActions primary={{ label: "编辑", icon: <Pencil className="size-3.5" />, onClick: () => openDrawer(template) }} actions={[{ key: "activate", label: "启用版本", icon: <Power className="size-3.5" />, disabled: template.enabled, confirm: { title: "启用这个提示词版本?", description: "启用后会立即替换当前 Agent 分镜生成所使用的版本。", okText: "确认启用" }, onClick: () => activate(template) }, { key: "delete", label: "删除版本", icon: <Trash2 className="size-3.5" />, danger: true, disabled: template.enabled, confirm: { title: "删除这个提示词版本?", description: "删除后不可恢复;启用中的版本不能删除。", okText: "确认删除" }, onClick: () => remove(template) }]} /> },
];
@@ -122,11 +122,11 @@ export default function StoryboardPromptsPage() {
<AdminPageFrame title="分镜提示词" description="Agent 提示词版本" actions={<Button type="primary" icon={<Plus className="size-4" />} onClick={() => openDrawer()}>新增版本</Button>}>
<div className="border-b border-border pb-4 text-xs leading-5 text-foreground/60"><div className="flex items-center gap-1.5 font-medium text-foreground/80"><Info className="size-3.5" />真实用于 Agent 分镜生成</div><p>当前启用版本会与镜头时长、数量和电影化质量约束一起发送给文本模型。展开行可查看完整提示词。</p></div>
<ListToolbar active={hasFilters} onReset={() => updateUrl({ filter: "", status: "all" })}>
<Input allowClear className="w-full sm:w-72" prefix={<Search className="size-4 text-foreground/40" />} value={keyword} placeholder="搜索版本名称或提示词" onChange={(event) => updateUrl({ filter: event.target.value })} />
<Input allowClear className="app-list-search" prefix={<Search className="size-4 text-foreground/40" />} value={keyword} placeholder="搜索版本名称或提示词" onChange={(event) => updateUrl({ filter: event.target.value })} />
<Select className="w-32" value={status} onChange={(value) => updateUrl({ status: value })} options={[{ label: "全部状态", value: "all" }, { label: "启用中", value: "enabled" }, { label: "未启用", value: "disabled" }]} />
</ListToolbar>
<TableSurface>{loading && templates.length === 0 ? <AdminTableSkeleton rows={8} columns={5} /> : <Table className="app-data-table" size="middle" rowKey="id" loading={loading} columns={columns} dataSource={filtered} locale={{ emptyText: <AdminTableEmpty filtered={hasFilters} /> }} pagination={{ pageSize: 20, showSizeChanger: true, pageSizeOptions: [20, 50, 100] }} scroll={{ x: 1080 }} expandable={{ expandedRowRender: (template) => <pre className="max-h-80 overflow-auto whitespace-pre-wrap rounded-md bg-muted/50 p-3 text-xs leading-5 text-foreground/75">{template.content}</pre> }} />}</TableSurface>
<Drawer title={editing ? "编辑分镜提示词" : "新增分镜提示词"} open={drawerOpen} width="min(760px, 100vw)" onClose={closeDrawer} maskClosable={!saving} destroyOnHidden extra={<Button type="primary" loading={saving} onClick={() => void save()}>保存</Button>}>
<Drawer title={editing ? "编辑分镜提示词" : "新增分镜提示词"} open={drawerOpen} size="min(760px, 100vw)" onClose={closeDrawer} maskClosable={!saving} destroyOnHidden extra={<Button type="primary" loading={saving} onClick={() => void save()}>保存</Button>}>
<Form form={form} layout="vertical" requiredMark={false}>
<Form.Item name="name" label="版本名称" rules={[{ required: true, message: "请填写版本名称" }]}><Input placeholder="例如:写实电影分镜 v2" /></Form.Item>
<Form.Item label="变量"><div className="flex flex-wrap gap-2">{variables.map((variable) => <Button key={variable.placeholder} size="small" icon={<Copy className="size-3.5" />} onClick={() => insertVariable(variable.placeholder)}>{variable.label}</Button>)}</div></Form.Item>

View File

@@ -3,6 +3,7 @@ import type { ColumnsType } from "antd/es/table";
import { Eye, Pencil, Power } from "lucide-react";
import { formatCredits } from "@/constant/credits";
import { IdentityProviderBadge } from "@/components/layout/identity-provider-badge";
import { AdminRowActions } from "../components/admin-ui";
import type { AdminUser } from "@/services/api/auth";
@@ -38,7 +39,7 @@ export function createUserColumns({
dataIndex: "username",
render: (_, user) => (
<div>
<div className="font-medium">{user.displayName || user.username}</div>
<div className="flex items-center gap-1.5"><span className="font-medium">{user.displayName || user.username}</span><IdentityProviderBadge user={user} /></div>
<div className="text-xs text-foreground/45">@{user.username}</div>
</div>
),
@@ -52,8 +53,8 @@ export function createUserColumns({
align: "right",
render: (value, user) => <span className="tabular-nums" title={`冻结积分:${formatCredits(user.reservedMicrocredits)}`}>{formatCredits(value)}</span>,
},
{ key: "role", title: "角色", dataIndex: "role", width: 110, render: (role) => <Tag bordered={false} color={role === "admin" ? "blue" : "default"}>{role === "admin" ? "管理员" : "普通用户"}</Tag> },
{ key: "status", title: "状态", dataIndex: "status", width: 110, render: (status) => <Tag bordered={false} color={status === "active" ? "success" : "default"}>{status === "active" ? "已启用" : "已停用"}</Tag> },
{ key: "role", title: "角色", dataIndex: "role", width: 110, render: (role) => <Tag variant="filled" color={role === "admin" ? "blue" : "default"}>{role === "admin" ? "管理员" : "普通用户"}</Tag> },
{ key: "status", title: "状态", dataIndex: "status", width: 110, render: (status) => <Tag variant="filled" color={status === "active" ? "success" : "default"}>{status === "active" ? "已启用" : "已停用"}</Tag> },
{ key: "createdAt", title: "注册时间", dataIndex: "createdAt", width: 180, render: formatTime },
{
key: "actions",

View File

@@ -160,7 +160,7 @@ export default function UsersPanel({ onUserChanged }: { onUserChanged?: (user: L
>
<Input
allowClear
className="w-full sm:w-72"
className="app-list-search"
prefix={<Search className="size-4 text-foreground/40" />}
value={state.filter}
placeholder="搜索用户名、名称或邮箱"