feat(web): 角色与音频工作台 - 完善素材选择、生成参数与同步交互

This commit is contained in:
ddcat666
2026-10-01 09:43:02 +08:00
parent 3aa874a70c
commit 12125e18f6
77 changed files with 3641 additions and 721 deletions

View File

@@ -30,3 +30,275 @@
[data-admin-root] .admin-log-error {
color: var(--admin-status-error);
}
/* Request-log drawer: dense, scannable sections instead of nested bordered panels. */
.api-log-detail-drawer .ant-drawer-content-wrapper {
width: min(1120px, 66vw) !important;
}
.api-log-detail-drawer-panel .ant-drawer-header {
min-height: 68px;
padding: 16px 24px;
}
.api-log-detail-drawer-panel .ant-drawer-body {
padding: 20px 24px 28px;
}
.api-log-detail-drawer-panel .ant-drawer-close {
margin-inline-end: 4px;
}
.api-log-detail-body {
display: flex;
flex-direction: column;
gap: 16px;
}
.api-log-detail-section {
overflow: hidden;
border: 1px solid var(--admin-border);
border-radius: 12px;
background: color-mix(in srgb, var(--admin-surface) 94%, var(--admin-text) 6%);
}
.api-log-detail-section-heading {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
min-height: 56px;
padding: 12px 16px;
border-bottom: 1px solid var(--admin-border);
}
.api-log-detail-section-heading h2 {
margin: 2px 0 0;
color: var(--admin-text);
font-size: 15px;
font-weight: 650;
letter-spacing: 0;
}
.api-log-detail-eyebrow {
color: var(--admin-text-muted);
font-size: 10px;
font-weight: 700;
letter-spacing: 0.08em;
line-height: 1.2;
}
.api-log-detail-summary-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0;
padding: 4px 16px 8px;
}
.api-log-detail-info-item {
min-width: 0;
padding: 11px 16px 10px 0;
border-bottom: 1px solid color-mix(in srgb, var(--admin-border) 70%, transparent);
}
.api-log-detail-info-label {
color: var(--admin-text-muted);
font-size: 11px;
line-height: 1.3;
}
.api-log-detail-info-value {
min-width: 0;
margin-top: 4px;
overflow: hidden;
color: var(--admin-text);
font-size: 13px;
line-height: 1.4;
text-overflow: ellipsis;
white-space: nowrap;
}
.api-log-detail-info-detail {
margin-top: 2px;
overflow: hidden;
color: var(--admin-text-muted);
font-size: 11px;
text-overflow: ellipsis;
white-space: nowrap;
}
.api-log-copy-value {
display: inline-flex;
min-width: 0;
max-width: 100%;
align-items: center;
color: var(--admin-text);
}
.api-log-copy-value code {
overflow: hidden;
font-size: 11px;
text-overflow: ellipsis;
white-space: nowrap;
}
.api-log-copy-value .ant-typography-copy {
margin-inline-start: 6px;
color: var(--admin-text-muted);
}
.api-log-detail-error {
margin: 4px 16px 16px;
padding: 10px 12px;
border: 1px solid color-mix(in srgb, var(--admin-status-error) 28%, var(--admin-border));
border-radius: 8px;
background: color-mix(in srgb, var(--admin-status-error) 7%, transparent);
}
.api-log-recovery-body {
padding: 12px 16px 16px;
}
.api-log-recovery-row {
display: grid;
grid-template-columns: 170px minmax(0, 1fr) auto auto;
align-items: center;
gap: 10px;
min-height: 48px;
}
.api-log-recovery-row + .api-log-recovery-row {
margin-top: 10px;
padding-top: 10px;
border-top: 1px solid color-mix(in srgb, var(--admin-border) 70%, transparent);
}
.api-log-recovery-label {
display: flex;
flex-direction: column;
gap: 3px;
min-width: 0;
color: var(--admin-text);
font-size: 13px;
font-weight: 600;
}
.api-log-recovery-label small {
overflow: hidden;
color: var(--admin-text-muted);
font-size: 11px;
font-weight: 400;
text-overflow: ellipsis;
white-space: nowrap;
}
.api-log-recovery-input {
min-width: 0;
}
.api-log-recovery-result {
margin-top: 12px;
}
.api-log-payload-section .ant-tabs-nav {
margin: 0;
padding: 0 16px;
}
.api-log-payload-section .ant-tabs-content-holder {
padding: 0 16px 16px;
}
.api-log-payload-wrap {
position: relative;
}
.api-log-payload-actions {
display: flex;
justify-content: flex-end;
min-height: 28px;
align-items: center;
color: var(--admin-text-muted);
font-size: 11px;
}
.api-log-payload {
max-height: min(34vh, 360px);
margin: 0;
overflow: auto;
padding: 12px;
border: 1px solid var(--admin-border);
border-radius: 8px;
background: color-mix(in srgb, var(--admin-canvas) 84%, var(--admin-text) 4%);
color: var(--admin-text-secondary);
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
font-size: 11px;
line-height: 1.65;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
@media (max-width: 1000px) {
.api-log-detail-drawer .ant-drawer-content-wrapper {
width: min(860px, 88vw) !important;
}
.api-log-detail-summary-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.api-log-recovery-row {
grid-template-columns: 150px minmax(0, 1fr) auto;
}
.api-log-recovery-row .api-log-recovery-label {
grid-column: 1 / -1;
}
}
@media (max-width: 680px) {
.api-log-detail-drawer .ant-drawer-content-wrapper {
width: 100vw !important;
}
.api-log-detail-drawer-panel .ant-drawer-header {
padding: 14px 16px;
}
.api-log-detail-drawer-panel .ant-drawer-body {
padding: 16px 12px 24px;
}
.api-log-detail-summary-grid {
grid-template-columns: 1fr;
padding-inline: 12px;
}
.api-log-detail-info-item {
padding-right: 0;
}
.api-log-recovery-body {
padding-inline: 12px;
}
.api-log-recovery-row,
.api-log-recovery-row + .api-log-recovery-row {
display: flex;
flex-wrap: wrap;
align-items: stretch;
gap: 8px;
}
.api-log-recovery-label {
width: 100%;
}
.api-log-recovery-input {
flex: 1 1 100%;
}
.api-log-recovery-row .ant-btn {
flex: 1 1 auto;
}
}

View File

@@ -1,7 +1,7 @@
import { Alert, App, Button, Input, Modal, Segmented } from "antd";
import { IconButton } from "@/pages/admin/ui/controls";
import type { ColumnsType } from "antd/es/table";
import { Download, Eye, Play, Search } from "lucide-react";
import { Download, Eye, Play, Search, RefreshCw } from "lucide-react";
import { saveAs } from "file-saver";
import { useEffect, useRef, useState } from "react";
import { useSearchParams } from "react-router";
@@ -11,7 +11,7 @@ import { MediaPreview } from "@/components/media-preview";
import { formatCredits } from "@/constant/credits";
import { useDebouncedValue } from "@/hooks/use-debounced-value";
import { mediaDeliverySummary } from "@/lib/generation-task-display";
import { exportAdminApiLogs, listAdminApiLogs, type ApiCallLog } from "@/services/api/auth";
import { batchRecoverAdminApiLogs, exportAdminApiLogs, listAdminApiLogs, type ApiCallLog } from "@/services/api/auth";
import { ApiLogDetailDrawer } from "../components/api-log-detail-drawer";
import { AdminPageFrame } from "../components/admin-shell";
import { AdminBatchBar, AdminDataTable, AdminExportButton, AdminFilterChip, AdminStatusBadge, AdminTableEmpty } from "../components/admin-ui";
@@ -26,6 +26,7 @@ export default function LogsPage() {
const view = normalizeLogView(searchParams.get("view"));
const status = normalizeStatus(searchParams.get("status"));
const recordType = searchParams.get("recordType") === "download" ? "download" : searchParams.get("recordType") === "all" ? "all" : "request";
const capability = normalizeCapability(searchParams.get("capability"));
const page = positiveInt(searchParams.get("page"), 1);
const pageSize = normalizePageSize(searchParams.get("pageSize"));
const debouncedKeyword = useDebouncedValue(keyword);
@@ -36,14 +37,15 @@ export default function LogsPage() {
const [retry, setRetry] = useState(0);
const [selectedIds, setSelectedIds] = useState<string[]>([]);
const [detailLogId, setDetailLogId] = useState<string | null>(null);
const [batchRecovering, setBatchRecovering] = useState(false);
const [mediaPreview, setMediaPreview] = useState<{ url: string; kind: "image" | "video"; title: string } | null>(null);
const requestSequence = useRef(0);
const hasFilters = Boolean(keyword || status !== "all" || recordType !== "request");
const hasFilters = Boolean(keyword || status !== "all" || recordType !== "request" || capability !== "all");
const updateUrl = (patch: Record<string, string | number>, replace = false) => {
const next = new URLSearchParams(searchParams);
Object.entries(patch).forEach(([key, value]) => {
const isDefault = (key === "filter" && value === "") || (key === "status" && value === "all") || (key === "page" && value === 1) || (key === "pageSize" && value === 20);
const isDefault = (key === "filter" && value === "") || (key === "status" && value === "all") || (key === "recordType" && value === "request") || (key === "capability" && value === "all") || (key === "page" && value === 1) || (key === "pageSize" && value === 20);
if (isDefault) next.delete(key);
else next.set(key, String(value));
});
@@ -57,7 +59,7 @@ export default function LogsPage() {
setLogs([]);
setTotal(0);
setSelectedIds([]);
void listAdminApiLogs({ recordType, keyword: debouncedKeyword || undefined, status: status === "all" ? undefined : status, page, pageSize })
void listAdminApiLogs({ recordType, capability: capability === "all" ? undefined : capability, keyword: debouncedKeyword || undefined, status: status === "all" ? undefined : status, page, pageSize })
.then((result) => {
if (sequence !== requestSequence.current) return;
setLogs(result.logs);
@@ -73,7 +75,7 @@ export default function LogsPage() {
})
.finally(() => sequence === requestSequence.current && setLoading(false));
return () => { requestSequence.current += 1; };
}, [debouncedKeyword, status, recordType, page, pageSize, retry]);
}, [debouncedKeyword, status, recordType, capability, page, pageSize, retry]);
const fullColumns: ColumnsType<ApiCallLog> = [
{ title: "时间", width: 168, render: (_, log) => <button type="button" className="admin-log-detail-link" aria-label={`查看请求 ${log.id} 详情`} onClick={() => setDetailLogId(log.id)}>{formatTime(log.startedAt || log.createdAt)}</button> },
@@ -160,7 +162,24 @@ export default function LogsPage() {
{ title: "耗时", dataIndex: "durationMs", align: "right", width: 90, render: formatDuration },
{ title: "错误摘要", key: "error", ellipsis: true, render: (_, log) => <span className={logStatus(log).tone === "error" ? "admin-log-error" : undefined} title={[log.errorCode, log.error].filter(Boolean).join(" · ")}>{[log.errorCode, log.error].filter(Boolean).join(" · ") || (logStatus(log).tone === "error" ? `HTTP ${log.statusCode || "失败"} · 未返回详情` : "—")}</span> },
];
const columns = view === "all" ? fullColumns : compactColumns;
const baseColumns = view === "all" ? fullColumns : compactColumns;
const columns: ColumnsType<ApiCallLog> = [...baseColumns, { title: "操作", key: "actions", width: 126, fixed: "right", render: (_, log) => log.capability === "video" ? <Button type="link" size="small" icon={<RefreshCw className="size-3.5" />} onClick={() => setDetailLogId(log.id)}>恢复任务</Button> : <span className="text-foreground/30">--</span> }];
const runBatchRecovery = async () => {
const selectedLogs = logs.filter((log) => selectedIds.includes(log.id));
const videoIds = selectedLogs.filter((log) => log.capability === "video").map((log) => log.id);
const skipped = selectedIds.length - videoIds.length;
if (videoIds.length === 0) { message.info("所选记录中没有视频任务可恢复"); return; }
setBatchRecovering(true);
try {
const result = await batchRecoverAdminApiLogs(videoIds);
const totalSkipped = skipped + result.skipped;
Modal.info({ title: "批量恢复处理结果", width: 560, content: <div className="max-h-[50vh] space-y-2 overflow-auto"><div className="mb-3">成功 {result.success} 条,失败 {result.failed} 条,跳过 {totalSkipped} 条</div>{result.items.map((item) => <div key={item.logId} className="rounded border border-border/70 px-3 py-2 text-xs"><div className="font-mono">{item.logId}</div><div>{item.recovered ? "成功" : item.error?.startsWith("跳过") ? item.error : item.providerStatus ? `仍在生成中(${item.providerStatus})` : `失败:${item.error || "未恢复"}`}</div></div>)}</div> });
if (result.success > 0) window.dispatchEvent(new CustomEvent("wallet:updated"));
setRetry((value) => value + 1);
} catch (error) { message.error(error instanceof Error ? error.message : "批量恢复失败"); }
finally { setBatchRecovering(false); }
};
return (
<AdminPageFrame
@@ -168,7 +187,7 @@ export default function LogsPage() {
description="模型生成与结果下载记录;仅计费调用扣除积分"
actions={
<AdminExportButton
exportFile={() => exportAdminApiLogs({ recordType, keyword: debouncedKeyword || undefined, status: status === "all" ? undefined : status })}
exportFile={() => exportAdminApiLogs({ recordType, capability: capability === "all" ? undefined : capability, keyword: debouncedKeyword || undefined, status: status === "all" ? undefined : status })}
fileName={() => `请求明细-${new Date().toISOString().slice(0, 10)}.csv`}
label="导出当前筛选"
successMessage="已按当前筛选导出请求明细"
@@ -194,11 +213,13 @@ export default function LogsPage() {
<>
{keyword ? <AdminFilterChip label={`搜索:${keyword}`} onRemove={() => updateUrl({ filter: "", page: 1 })} /> : null}
{status !== "all" ? <AdminFilterChip label={`结果:${status === "succeeded" ? "成功" : "失败"}`} onRemove={() => updateUrl({ status: "all", page: 1 })} /> : null}
{capability !== "all" ? <AdminFilterChip label={`能力:${capabilityText(capability)}`} onRemove={() => updateUrl({ capability: "all", page: 1 })} /> : null}
</>
}
toolbarFilters={
<>
<Select aria-label="明细类型" className="w-32" value={recordType} onChange={(value) => updateUrl({ recordType: value, page: 1 })} options={[{ label: "仅请求", value: "request" }, { label: "仅下载", value: "download" }, { label: "全部明细", value: "all" }]} />
<Select aria-label="能力类型" className="w-28" value={capability} onChange={(value) => updateUrl({ capability: value, page: 1 })} options={[{ label: "全部能力", value: "all" }, { label: "文本", value: "text" }, { label: "图片", value: "image" }, { label: "视频", value: "video" }, { label: "音频", value: "audio" }]} />
<Select
aria-label="请求结果"
className="w-32"
@@ -213,9 +234,10 @@ export default function LogsPage() {
</>
}
toolbarActive={hasFilters}
onReset={() => updateUrl({ filter: "", status: "all", recordType: "request", page: 1 })}
onReset={() => updateUrl({ filter: "", status: "all", recordType: "request", capability: "all", page: 1 })}
batchActions={
<AdminBatchBar count={selectedIds.length} onClear={() => setSelectedIds([])}>
<Button size="small" icon={<RefreshCw className="size-3.5" />} loading={batchRecovering} onClick={() => void runBatchRecovery()}>批量恢复</Button>
<AdminExportButton
type="primary"
size="small"
@@ -245,7 +267,7 @@ export default function LogsPage() {
tableLayout: "fixed",
dataSource: logs,
pagination: false,
scroll: { x: view === "all" ? 1775 : view === "billing" ? 1120 : 1060 },
scroll: { x: view === "all" ? 1901 : view === "billing" ? 1246 : 1186 },
}}
empty={loadError ? <span role="status">数据暂不可用,请重试</span> : <AdminTableEmpty filtered={hasFilters} />}
footer={<PaginationBar alwaysShow current={page} pageSize={pageSize} total={total} onChange={(nextPage, nextSize) => updateUrl({ page: nextSize !== pageSize ? 1 : nextPage, pageSize: nextSize })} />}
@@ -288,6 +310,7 @@ function normalizePageSize(value: string | null) {
const parsed = positiveInt(value, 20);
return [20, 50, 100].includes(parsed) ? parsed : 20;
}
function normalizeCapability(value: string | null): "all" | "text" | "image" | "video" | "audio" { return value === "text" || value === "image" || value === "video" || value === "audio" ? value : "all"; }
function normalizeStatus(value: string | null): "all" | "succeeded" | "failed" {
return value === "succeeded" || value === "failed" ? value : "all";
}