feat: 素材处理管线——自动处理、审核流程、6状态机

This commit is contained in:
2026-06-07 22:50:05 +08:00
parent a389888eb4
commit 90fdb44b20
30 changed files with 1452 additions and 55 deletions

View File

@@ -4,11 +4,13 @@ import type { RuntimeMode } from "../shared/api";
import type { ResolvedConfig, ResolvedLoggingConfig } from "./config/types";
import type { MigrationRecord } from "./db/load-migrations";
import type { Logger } from "./logger";
import type { MaterialProcessor } from "./processing";
import type { StartServerOptions } from "./server";
import { loadServerConfig } from "./config";
import { createDatabase, loadMigrationsFromDir, runMigrations } from "./db";
import { createConsoleFallback, createRuntimeLogger } from "./logger";
import { startMaterialProcessor } from "./processing";
import { startServer } from "./server";
export interface BootstrapDependencies {
@@ -66,8 +68,11 @@ export async function bootstrap(options: BootstrapOptions, dependencies: Bootstr
const db = createDatabase(config.dataDir, logger!);
runMigrations(db, migrations, config.dataDir, logger!);
const processor: MaterialProcessor = startMaterialProcessor(db, logger!.child({ component: "processor" }));
const shutdown = () => {
logger?.info("收到退出信号,开始优雅关闭");
processor.stop();
db.close();
logger?.flush();
exit(0);

View File

@@ -2,12 +2,37 @@ import type Database from "bun:sqlite";
import { and, desc, eq } from "drizzle-orm";
import type { CreateMaterialRequest, Material, MaterialStatus } from "../../shared/api";
import type { CreateMaterialRequest, Material, MaterialStatus, MaterialType } from "../../shared/api";
import type { Logger } from "../logger";
import { notDeleted, paginateQuery, softDeleteRecord, timestamp, wrap } from "./connection";
import { materials, projects } from "./schema";
const ALLOWED_MATERIAL_TYPES: MaterialType[] = ["general", "meeting"];
export function approveMaterial(
raw: Database,
projectId: string,
materialId: string,
_logger: Logger,
): { error: string; status: number } | { material: Material } {
const db = wrap(raw);
const row = db
.select()
.from(materials)
.where(and(eq(materials.id, materialId), notDeleted(materials)))
.get();
if (!row) return { error: "素材不存在", status: 404 };
if (row.projectId !== projectId) return { error: "素材不属于该项目", status: 403 };
if (row.status !== "review") return { error: "仅待审核素材可通过", status: 409 };
const now = timestamp();
db.update(materials).set({ status: "approved", updatedAt: now }).where(eq(materials.id, materialId)).run();
const updated = db.select().from(materials).where(eq(materials.id, materialId)).get();
return { material: toMaterial(updated!) };
}
export function createMaterial(
raw: Database,
projectId: string,
@@ -31,6 +56,11 @@ export function createMaterial(
return { error: "associatedDate 格式错误,要求 YYYY-MM-DD", status: 400 };
}
const materialType: MaterialType = request.materialType ?? "general";
if (!ALLOWED_MATERIAL_TYPES.includes(materialType)) {
return { error: "materialType 无效,仅支持 general/meeting", status: 400 };
}
const id = crypto.randomUUID();
const now = timestamp();
@@ -40,6 +70,7 @@ export function createMaterial(
createdAt: now,
description,
id,
materialType,
projectId,
status: "pending",
updatedAt: now,
@@ -64,11 +95,37 @@ export function deleteMaterial(
.get();
if (!row) return { error: "素材不存在", status: 404 };
if (row.projectId !== projectId) return { error: "素材不属于该项目", status: 403 };
if (row.status === "processing") {
return { error: "处理中的素材不可删除", status: 409 };
}
softDeleteRecord(db, materials, materialId);
return { success: true };
}
export function discardMaterial(
raw: Database,
projectId: string,
materialId: string,
_logger: Logger,
): { error: string; status: number } | { material: Material } {
const db = wrap(raw);
const row = db
.select()
.from(materials)
.where(and(eq(materials.id, materialId), notDeleted(materials)))
.get();
if (!row) return { error: "素材不存在", status: 404 };
if (row.projectId !== projectId) return { error: "素材不属于该项目", status: 403 };
if (row.status !== "review") return { error: "仅待审核素材可放弃", status: 409 };
const now = timestamp();
db.update(materials).set({ status: "discarded", updatedAt: now }).where(eq(materials.id, materialId)).run();
const updated = db.select().from(materials).where(eq(materials.id, materialId)).get();
return { material: toMaterial(updated!) };
}
export function getMaterial(
raw: Database,
projectId: string,
@@ -107,12 +164,40 @@ export function listMaterials(
});
}
export function retryMaterial(
raw: Database,
projectId: string,
materialId: string,
_logger: Logger,
): { error: string; status: number } | { material: Material } {
const db = wrap(raw);
const row = db
.select()
.from(materials)
.where(and(eq(materials.id, materialId), notDeleted(materials)))
.get();
if (!row) return { error: "素材不存在", status: 404 };
if (row.projectId !== projectId) return { error: "素材不属于该项目", status: 403 };
if (row.status !== "failed") return { error: "仅失败素材可重试", status: 409 };
const now = timestamp();
db.update(materials)
.set({ processedContent: null, status: "pending", updatedAt: now })
.where(eq(materials.id, materialId))
.run();
const updated = db.select().from(materials).where(eq(materials.id, materialId)).get();
return { material: toMaterial(updated!) };
}
function toMaterial(row: typeof materials.$inferSelect): Material {
return {
associatedDate: row.associatedDate,
createdAt: row.createdAt,
description: row.description,
id: row.id,
materialType: row.materialType,
processedContent: row.processedContent,
projectId: row.projectId,
status: row.status,
updatedAt: row.updatedAt,

View File

@@ -57,10 +57,16 @@ export const materials = sqliteTable(
...baseColumns,
associatedDate: text("associated_date").notNull(),
description: text("description").notNull(),
materialType: text("material_type", { enum: ["general", "meeting"] })
.notNull()
.default("general"),
processedContent: text("processed_content"),
projectId: text("project_id")
.notNull()
.references(() => projects.id),
status: text("status", { enum: ["pending", "approved", "discarded"] })
status: text("status", {
enum: ["pending", "processing", "review", "approved", "discarded", "failed"],
})
.notNull()
.default("pending"),
},

View File

@@ -0,0 +1,14 @@
import type Database from "bun:sqlite";
import type { Logger } from "../logger";
import { MaterialProcessor } from "./processor";
export { MaterialProcessor, type ProcessableMaterial } from "./processor";
export { getTemplate, type ProcessingTemplate } from "./templates";
export function startMaterialProcessor(db: Database, logger: Logger): MaterialProcessor {
const processor = new MaterialProcessor(db, logger);
processor.start();
return processor;
}

View File

@@ -0,0 +1,151 @@
import type Database from "bun:sqlite";
import type { MaterialType } from "../../shared/api";
import type { Logger } from "../logger";
import { and, asc, eq } from "drizzle-orm";
import { notDeleted, timestamp, wrap } from "../db/connection";
import { materials } from "../db/schema";
import { getTemplate } from "./templates";
const MAX_RETRIES = 3;
const DEFAULT_INTERVAL_MS = 5000;
export interface ProcessableMaterial {
description: string;
id: string;
materialType: MaterialType;
}
export class MaterialProcessor {
private readonly db: Database;
private readonly logger: Logger;
private timer: ReturnType<typeof setInterval> | null = null;
private running = false;
constructor(db: Database, logger: Logger) {
this.db = db;
this.logger = logger.child({ component: "material-processor" });
}
recoverStuckMaterials(): number {
const db = wrap(this.db);
const now = timestamp();
const restored = db
.update(materials)
.set({ status: "pending", updatedAt: now })
.where(and(eq(materials.status, "processing"), notDeleted(materials)))
.returning({ id: materials.id })
.all();
const count = restored.length;
if (count > 0) {
this.logger.info({ count }, "恢复卡住的素材到 pending 状态");
}
return count;
}
start(intervalMs: number = DEFAULT_INTERVAL_MS): void {
const recovered = this.recoverStuckMaterials();
this.logger.info({ intervalMs, recovered }, "素材处理器启动");
this.timer = setInterval(() => {
void this.tick();
}, intervalMs);
}
stop(): void {
if (this.timer !== null) {
clearInterval(this.timer);
this.timer = null;
}
this.running = false;
this.logger.info("素材处理器停止");
}
private async tick(): Promise<void> {
if (this.running) {
this.logger.debug("上一轮处理尚未完成,跳过本次扫描");
return;
}
this.running = true;
try {
await this.processNext();
} catch (error: unknown) {
this.logger.error({ error: error instanceof Error ? error.message : String(error) }, "处理过程中发生未捕获错误");
} finally {
this.running = false;
}
}
async processNext(): Promise<void> {
const db = wrap(this.db);
const row = db
.select()
.from(materials)
.where(and(eq(materials.status, "pending"), notDeleted(materials)))
.orderBy(asc(materials.createdAt))
.limit(1)
.get();
if (!row) {
this.logger.debug("无待处理素材");
return;
}
const processingAt = timestamp();
db.update(materials).set({ status: "processing", updatedAt: processingAt }).where(eq(materials.id, row.id)).run();
const material: ProcessableMaterial = {
description: row.description,
id: row.id,
materialType: row.materialType as MaterialType,
};
let lastError: unknown;
let success = false;
for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
try {
const result = await this.processOne(material);
const finishedAt = timestamp();
db.update(materials)
.set({ processedContent: result, status: "review", updatedAt: finishedAt })
.where(eq(materials.id, row.id))
.run();
this.logger.info({ attempt, materialId: row.id }, "素材处理成功");
success = true;
break;
} catch (error: unknown) {
lastError = error;
this.logger.warn(
{
attempt,
error: error instanceof Error ? error.message : String(error),
materialId: row.id,
},
`素材处理第 ${attempt} 次失败`,
);
}
}
if (!success) {
const failedAt = timestamp();
db.update(materials).set({ status: "failed", updatedAt: failedAt }).where(eq(materials.id, row.id)).run();
this.logger.warn(
{
error: lastError instanceof Error ? lastError.message : String(lastError),
materialId: row.id,
},
`素材处理 ${MAX_RETRIES} 次均失败,标记为 failed`,
);
}
}
protected processOne(material: ProcessableMaterial): Promise<string> {
const template = getTemplate(material.materialType);
// TODO: 替换为真实 AI Agent 调用
return Promise.resolve(template.outputTemplate.replace("{description}", material.description));
}
}

View File

@@ -0,0 +1,4 @@
export const GENERAL_TEMPLATE = {
outputTemplate: "{description}",
systemPrompt: "通用素材处理",
} as const;

View File

@@ -0,0 +1,18 @@
import type { MaterialType } from "../../../shared/api";
import { GENERAL_TEMPLATE } from "./general";
import { MEETING_TEMPLATE } from "./meeting";
export interface ProcessingTemplate {
outputTemplate: string;
systemPrompt: string;
}
const TEMPLATES: Record<MaterialType, ProcessingTemplate> = {
general: GENERAL_TEMPLATE,
meeting: MEETING_TEMPLATE,
};
export function getTemplate(type: MaterialType): ProcessingTemplate {
return TEMPLATES[type];
}

View File

@@ -0,0 +1,4 @@
export const MEETING_TEMPLATE = {
outputTemplate: "{description}",
systemPrompt: "会议素材处理",
} as const;

View File

@@ -0,0 +1,29 @@
import type Database from "bun:sqlite";
import type { RuntimeMode } from "../../../shared/api";
import type { Logger } from "../../logger";
import { approveMaterial } from "../../db/materials";
import { createApiError, jsonResponse } from "../../helpers";
import { validateIdParam } from "../../middleware";
export function handleApproveMaterial(req: Request, db: Database, mode: RuntimeMode, logger: Logger): Response {
const url = new URL(req.url);
const parts = url.pathname.split("/");
const projectIdStr = parts[3];
const materialIdStr = parts[5];
const validatedProject = validateIdParam(projectIdStr ?? "", mode);
if (validatedProject instanceof Response) return validatedProject;
const validatedMaterial = validateIdParam(materialIdStr ?? "", mode);
if (validatedMaterial instanceof Response) return validatedMaterial;
const result = approveMaterial(db, validatedProject.id, validatedMaterial.id, logger);
if ("error" in result) {
return jsonResponse(createApiError(result.error, result.status), { mode, status: result.status });
}
logger.info({ materialId: validatedMaterial.id, projectId: validatedProject.id }, "素材审核通过");
return jsonResponse(result, { mode, status: 201 });
}

View File

@@ -0,0 +1,29 @@
import type Database from "bun:sqlite";
import type { RuntimeMode } from "../../../shared/api";
import type { Logger } from "../../logger";
import { discardMaterial } from "../../db/materials";
import { createApiError, jsonResponse } from "../../helpers";
import { validateIdParam } from "../../middleware";
export function handleDiscardMaterial(req: Request, db: Database, mode: RuntimeMode, logger: Logger): Response {
const url = new URL(req.url);
const parts = url.pathname.split("/");
const projectIdStr = parts[3];
const materialIdStr = parts[5];
const validatedProject = validateIdParam(projectIdStr ?? "", mode);
if (validatedProject instanceof Response) return validatedProject;
const validatedMaterial = validateIdParam(materialIdStr ?? "", mode);
if (validatedMaterial instanceof Response) return validatedMaterial;
const result = discardMaterial(db, validatedProject.id, validatedMaterial.id, logger);
if ("error" in result) {
return jsonResponse(createApiError(result.error, result.status), { mode, status: result.status });
}
logger.info({ materialId: validatedMaterial.id, projectId: validatedProject.id }, "素材已放弃");
return jsonResponse(result, { mode, status: 201 });
}

View File

@@ -21,14 +21,15 @@ export function handleListMaterials(req: Request, db: Database, mode: RuntimeMod
const pagination = validatePagination(pageParam, pageSizeParam, mode);
if (pagination instanceof Response) return pagination;
if (statusParam && statusParam !== "pending" && statusParam !== "approved" && statusParam !== "discarded") {
const ALLOWED_STATUSES = ["pending", "processing", "review", "approved", "discarded", "failed"] as const;
if (statusParam && !(ALLOWED_STATUSES as readonly string[]).includes(statusParam)) {
return jsonResponse(createApiError("Invalid status parameter", 400), { mode, status: 400 });
}
const result = listMaterials(db, validated.id, {
page: pagination.page,
pageSize: pagination.pageSize,
status: (statusParam as "approved" | "discarded" | "pending") ?? undefined,
status: (statusParam as (typeof ALLOWED_STATUSES)[number]) ?? undefined,
});
return jsonResponse(result, { mode });

View File

@@ -0,0 +1,29 @@
import type Database from "bun:sqlite";
import type { RuntimeMode } from "../../../shared/api";
import type { Logger } from "../../logger";
import { retryMaterial } from "../../db/materials";
import { createApiError, jsonResponse } from "../../helpers";
import { validateIdParam } from "../../middleware";
export function handleRetryMaterial(req: Request, db: Database, mode: RuntimeMode, logger: Logger): Response {
const url = new URL(req.url);
const parts = url.pathname.split("/");
const projectIdStr = parts[3];
const materialIdStr = parts[5];
const validatedProject = validateIdParam(projectIdStr ?? "", mode);
if (validatedProject instanceof Response) return validatedProject;
const validatedMaterial = validateIdParam(materialIdStr ?? "", mode);
if (validatedMaterial instanceof Response) return validatedMaterial;
const result = retryMaterial(db, validatedProject.id, validatedMaterial.id, logger);
if ("error" in result) {
return jsonResponse(createApiError(result.error, result.status), { mode, status: result.status });
}
logger.info({ materialId: validatedMaterial.id, projectId: validatedProject.id }, "素材重试已触发");
return jsonResponse(result, { mode, status: 201 });
}

View File

@@ -256,6 +256,36 @@ export function startServer(options: StartServerOptions) {
logger,
),
},
"/api/projects/:id/materials/:mid/approve": {
POST: withErrorHandler(
async (req) => {
const { handleApproveMaterial } = await import("./routes/materials/approve");
return handleApproveMaterial(req, db, mode, logger);
},
mode,
logger,
),
},
"/api/projects/:id/materials/:mid/discard": {
POST: withErrorHandler(
async (req) => {
const { handleDiscardMaterial } = await import("./routes/materials/discard");
return handleDiscardMaterial(req, db, mode, logger);
},
mode,
logger,
),
},
"/api/projects/:id/materials/:mid/retry": {
POST: withErrorHandler(
async (req) => {
const { handleRetryMaterial } = await import("./routes/materials/retry");
return handleRetryMaterial(req, db, mode, logger);
},
mode,
logger,
),
},
"/api/projects/:id/restore": {
POST: withErrorHandler(
async (req) => {

View File

@@ -31,6 +31,7 @@ export interface CreateConversationRequest {
export interface CreateMaterialRequest {
associatedDate: string;
description: string;
materialType?: MaterialType;
}
export interface CreateModelRequest {
@@ -69,6 +70,8 @@ export interface Material {
createdAt: string;
description: string;
id: string;
materialType: MaterialType;
processedContent: null | string;
projectId: string;
status: MaterialStatus;
updatedAt: string;
@@ -85,7 +88,9 @@ export interface MaterialResponse {
material: Material;
}
export type MaterialStatus = "approved" | "discarded" | "pending";
export type MaterialStatus = "approved" | "discarded" | "failed" | "pending" | "processing" | "review";
export type MaterialType = "general" | "meeting";
export interface Message {
content: string;

View File

@@ -1,8 +1,8 @@
import { App as AntApp, DatePicker, Form, Input, Modal } from "antd";
import { App as AntApp, DatePicker, Form, Input, Modal, Select } from "antd";
import dayjs from "dayjs";
import { useEffect, useState } from "react";
import type { CreateMaterialRequest, Material } from "../types";
import type { CreateMaterialRequest, Material, MaterialType } from "../types";
interface AddMaterialModalProps {
onAdd: (body: CreateMaterialRequest) => Promise<Material>;
@@ -13,8 +13,14 @@ interface AddMaterialModalProps {
interface FormValues {
associatedDate: dayjs.Dayjs;
description: string;
materialType: MaterialType;
}
const MATERIAL_TYPE_OPTIONS = [
{ label: "通用", value: "general" },
{ label: "会议", value: "meeting" },
];
export function AddMaterialModal({ onAdd, onOpenChange, open }: AddMaterialModalProps) {
const { message } = AntApp.useApp();
const [form] = Form.useForm<FormValues>();
@@ -29,6 +35,7 @@ export function AddMaterialModal({ onAdd, onOpenChange, open }: AddMaterialModal
const body: CreateMaterialRequest = {
associatedDate: values.associatedDate.format("YYYY-MM-DD"),
description: values.description,
materialType: values.materialType,
};
setSubmitting(true);
@@ -55,7 +62,7 @@ export function AddMaterialModal({ onAdd, onOpenChange, open }: AddMaterialModal
>
<Form
form={form}
initialValues={{ associatedDate: dayjs() }}
initialValues={{ associatedDate: dayjs(), materialType: "general" as MaterialType }}
layout="vertical"
onFinish={(values) => void handleFinish(values)}
>
@@ -69,6 +76,9 @@ export function AddMaterialModal({ onAdd, onOpenChange, open }: AddMaterialModal
<Form.Item label="关联时间" name="associatedDate" rules={[{ message: "请选择关联时间", required: true }]}>
<DatePicker className="app-inbox-datepicker" />
</Form.Item>
<Form.Item label="素材类型" name="materialType" rules={[{ message: "请选择素材类型", required: true }]}>
<Select options={MATERIAL_TYPE_OPTIONS} />
</Form.Item>
</Form>
</Modal>
);

View File

@@ -13,12 +13,16 @@ interface MaterialCardProps {
const STATUS_MAP: Record<MaterialStatus, { color: string; label: string }> = {
approved: { color: "green", label: "已通过" },
discarded: { color: "red", label: "已放弃" },
pending: { color: "gold", label: "待审核" },
failed: { color: "magenta", label: "失败" },
pending: { color: "gold", label: "待处理" },
processing: { color: "blue", label: "处理中" },
review: { color: "orange", label: "待审核" },
};
export function MaterialCard({ material, onDelete, onSelect, selected }: MaterialCardProps) {
const statusInfo = STATUS_MAP[material.status];
const className = selected ? "app-sidebar-list-item app-sidebar-list-item--selected" : "app-sidebar-list-item";
const canDelete = material.status !== "processing";
return (
<Flex align="center" className={className} gap="small" justify="space-between" onClick={onSelect}>
@@ -34,26 +38,28 @@ export function MaterialCard({ material, onDelete, onSelect, selected }: Materia
{statusInfo && <Tag color={statusInfo.color}>{statusInfo.label}</Tag>}
</span>
<span className="material-item-actions">
<Popconfirm
description="删除后不可恢复"
okButtonProps={{ danger: true }}
okText="删除"
onCancel={(e) => e?.stopPropagation()}
onConfirm={(e) => {
e?.stopPropagation();
onDelete();
}}
title="确认删除该素材?"
>
<Button
aria-label="删除"
danger
icon={<DeleteOutlined />}
onClick={(e) => e.stopPropagation()}
size="small"
type="text"
/>
</Popconfirm>
{canDelete && (
<Popconfirm
description="删除后不可恢复"
okButtonProps={{ danger: true }}
okText="删除"
onCancel={(e) => e?.stopPropagation()}
onConfirm={(e) => {
e?.stopPropagation();
onDelete();
}}
title="确认删除该素材?"
>
<Button
aria-label="删除"
danger
icon={<DeleteOutlined />}
onClick={(e) => e.stopPropagation()}
size="small"
type="text"
/>
</Popconfirm>
)}
</span>
</div>
</Flex>

View File

@@ -1,6 +1,6 @@
import { Card, Descriptions, Tag, Typography } from "antd";
import type { Material } from "../types";
import type { Material, MaterialStatus, MaterialType } from "../types";
import { formatRelativeTime } from "../../../shared/utils/time";
@@ -8,24 +8,46 @@ interface MaterialContentProps {
material: Material;
}
const STATUS_MAP: Record<string, { color: string; label: string }> = {
const STATUS_MAP: Record<MaterialStatus, { color: string; label: string }> = {
approved: { color: "green", label: "已通过" },
discarded: { color: "red", label: "已放弃" },
pending: { color: "gold", label: "待审核" },
failed: { color: "magenta", label: "失败" },
pending: { color: "gold", label: "待处理" },
processing: { color: "blue", label: "处理中" },
review: { color: "orange", label: "待审核" },
};
const MATERIAL_TYPE_LABELS: Record<MaterialType, string> = {
general: "通用",
meeting: "会议",
};
export function MaterialContent({ material }: MaterialContentProps) {
const statusInfo = STATUS_MAP[material.status] ?? { color: "default", label: material.status };
const typeLabel = MATERIAL_TYPE_LABELS[material.materialType] ?? material.materialType;
return (
<div className="app-inbox-content">
<Typography.Title level={4}></Typography.Title>
<Card>
<Typography.Paragraph>{material.description}</Typography.Paragraph>
{material.processedContent && (
<Typography.Paragraph
style={{
background: "var(--ant-color-fill-quaternary)",
padding: 12,
borderRadius: 6,
whiteSpace: "pre-wrap",
}}
>
{material.processedContent}
</Typography.Paragraph>
)}
<Descriptions column={1} size="small">
<Descriptions.Item label="状态">
<Tag color={statusInfo.color}>{statusInfo.label}</Tag>
</Descriptions.Item>
<Descriptions.Item label="素材类型">{typeLabel}</Descriptions.Item>
<Descriptions.Item label="关联时间">{material.associatedDate}</Descriptions.Item>
<Descriptions.Item label="创建时间">{formatRelativeTime(material.createdAt)}</Descriptions.Item>
</Descriptions>

View File

@@ -1,14 +1,26 @@
import { Empty, Result, Spin } from "antd";
import { CheckOutlined, CloseOutlined, RedoOutlined } from "@ant-design/icons";
import { App as AntApp, Button, Empty, Result, Space, Spin } from "antd";
import type { Material } from "../types";
import { useMaterial } from "../../../shared/hooks/use-materials";
import { MaterialContent } from "./MaterialContent";
interface MaterialDetailPanelProps {
materialId: null | string;
onApprove: (materialId: string) => Promise<void>;
onDiscard: (materialId: string) => Promise<void>;
onRetry: (materialId: string) => Promise<void>;
projectId: string;
}
export function MaterialDetailPanel({ materialId, projectId }: MaterialDetailPanelProps) {
export function MaterialDetailPanel({
materialId,
onApprove,
onDiscard,
onRetry,
projectId,
}: MaterialDetailPanelProps) {
if (!materialId) {
return (
<div className="app-inbox-content">
@@ -17,11 +29,20 @@ export function MaterialDetailPanel({ materialId, projectId }: MaterialDetailPan
);
}
return <MaterialDetailPanelInner materialId={materialId} projectId={projectId} />;
return (
<MaterialDetailPanelInner
materialId={materialId}
onApprove={onApprove}
onDiscard={onDiscard}
onRetry={onRetry}
projectId={projectId}
/>
);
}
function MaterialDetailPanelInner({ materialId, projectId }: MaterialDetailPanelProps) {
function MaterialDetailPanelInner({ materialId, onApprove, onDiscard, onRetry, projectId }: MaterialDetailPanelProps) {
const { data, error, isLoading } = useMaterial({ materialId, projectId });
const { message } = AntApp.useApp();
if (isLoading) {
return (
@@ -39,7 +60,7 @@ function MaterialDetailPanelInner({ materialId, projectId }: MaterialDetailPanel
);
}
if (!data) {
if (!data || !materialId) {
return (
<div className="app-inbox-content">
<Empty description="请在左侧选择素材" />
@@ -47,5 +68,73 @@ function MaterialDetailPanelInner({ materialId, projectId }: MaterialDetailPanel
);
}
return <MaterialContent material={data} />;
const id = materialId;
const handleApprove = async () => {
try {
await onApprove(id);
message.success("已通过");
} catch (e: unknown) {
message.error(`操作失败:${e instanceof Error ? e.message : "未知错误"}`);
}
};
const handleDiscard = async () => {
try {
await onDiscard(id);
message.success("已放弃");
} catch (e: unknown) {
message.error(`操作失败:${e instanceof Error ? e.message : "未知错误"}`);
}
};
const handleRetry = async () => {
try {
await onRetry(id);
message.success("已重新提交处理");
} catch (e: unknown) {
message.error(`操作失败:${e instanceof Error ? e.message : "未知错误"}`);
}
};
return (
<div className="app-inbox-content">
<MaterialContent material={data} />
<ActionButtons material={data} onApprove={handleApprove} onDiscard={handleDiscard} onRetry={handleRetry} />
</div>
);
}
interface ActionButtonsProps {
material: Material;
onApprove: () => Promise<void>;
onDiscard: () => Promise<void>;
onRetry: () => Promise<void>;
}
function ActionButtons({ material, onApprove, onDiscard, onRetry }: ActionButtonsProps) {
if (material.status === "review") {
return (
<Space style={{ marginTop: 16 }}>
<Button icon={<CheckOutlined />} onClick={() => void onApprove()} type="primary">
</Button>
<Button danger icon={<CloseOutlined />} onClick={() => void onDiscard()}>
</Button>
</Space>
);
}
if (material.status === "failed") {
return (
<Space style={{ marginTop: 16 }}>
<Button icon={<RedoOutlined />} onClick={() => void onRetry()}>
</Button>
</Space>
);
}
return null;
}

View File

@@ -1,9 +1,9 @@
import {
AppstoreOutlined,
CheckCircleOutlined,
ClockCircleOutlined,
CloseCircleOutlined,
ExclamationCircleOutlined,
PlusOutlined,
SyncOutlined,
} from "@ant-design/icons";
import { Button, Empty, Segmented, Skeleton } from "antd";
import "overlayscrollbars/styles/overlayscrollbars.css";
@@ -27,9 +27,9 @@ interface MaterialListProps {
const STATUS_FILTER_OPTIONS = [
{ icon: <AppstoreOutlined />, label: "全部", value: "all" },
{ color: "#faad14", icon: <ClockCircleOutlined />, label: "待审核", value: "pending" },
{ color: "#52c41a", icon: <CheckCircleOutlined />, label: "已通过", value: "approved" },
{ color: "#ff4d4f", icon: <CloseCircleOutlined />, label: "已放弃", value: "discarded" },
{ color: "#1677ff", icon: <SyncOutlined />, label: "运行中", value: "processing" },
{ color: "#fa8c16", icon: <ClockCircleOutlined />, label: "待审核", value: "review" },
{ color: "#ff4d4f", icon: <ExclamationCircleOutlined />, label: "失败", value: "failed" },
] as const;
type FilterValue = (typeof STATUS_FILTER_OPTIONS)[number]["value"];

View File

@@ -3,7 +3,12 @@ import { useState } from "react";
import type { CreateMaterialRequest, Material } from "./types";
import { useCurrentProject } from "../../shared/hooks/use-current-project";
import { useCreateMaterial } from "../../shared/hooks/use-materials";
import {
useApproveMaterial,
useCreateMaterial,
useDiscardMaterial,
useRetryMaterial,
} from "../../shared/hooks/use-materials";
import { AddMaterialModal } from "./components/AddMaterialModal";
import { MaterialDetailPanel } from "./components/MaterialDetailPanel";
import { MaterialSidebar } from "./components/MaterialSidebar";
@@ -14,6 +19,9 @@ export function InboxPage() {
const [selectedId, setSelectedId] = useState<null | string>(null);
const createMutation = useCreateMaterial(project.id);
const approveMutation = useApproveMaterial(project.id);
const discardMutation = useDiscardMaterial(project.id);
const retryMutation = useRetryMaterial(project.id);
const handleCreate = async (body: CreateMaterialRequest): Promise<Material> => {
const material = await createMutation.mutateAsync({ body, projectId: project.id });
@@ -27,6 +35,18 @@ export function InboxPage() {
}
};
const handleApprove = async (materialId: string) => {
await approveMutation.mutateAsync({ materialId, projectId: project.id });
};
const handleDiscard = async (materialId: string) => {
await discardMutation.mutateAsync({ materialId, projectId: project.id });
};
const handleRetry = async (materialId: string) => {
await retryMutation.mutateAsync({ materialId, projectId: project.id });
};
return (
<div className="app-inbox-page">
<MaterialSidebar
@@ -36,7 +56,13 @@ export function InboxPage() {
projectId={project.id}
selectedId={selectedId}
/>
<MaterialDetailPanel materialId={selectedId} projectId={project.id} />
<MaterialDetailPanel
materialId={selectedId}
onApprove={handleApprove}
onDiscard={handleDiscard}
onRetry={handleRetry}
projectId={project.id}
/>
<AddMaterialModal onAdd={handleCreate} onOpenChange={setModalOpen} open={modalOpen} />
</div>
);

View File

@@ -1 +1 @@
export type { CreateMaterialRequest, Material, MaterialStatus } from "../../../shared/api";
export type { CreateMaterialRequest, Material, MaterialStatus, MaterialType } from "../../../shared/api";

View File

@@ -23,11 +23,26 @@ export function createMaterial(args: { body: CreateMaterialRequest; projectId: s
return response.then((r) => handleResponse(r, (data) => (data as MaterialResponse).material));
}
export function approveMaterial(args: { materialId: string; projectId: string }): Promise<Material> {
const response = fetch(`/api/projects/${args.projectId}/materials/${args.materialId}/approve`, { method: "POST" });
return response.then((r) => handleResponse(r, (data) => (data as MaterialResponse).material));
}
export function deleteMaterial(args: { materialId: string; projectId: string }): Promise<void> {
const response = fetch(`/api/projects/${args.projectId}/materials/${args.materialId}`, { method: "DELETE" });
return response.then(handleVoidResponse);
}
export function discardMaterial(args: { materialId: string; projectId: string }): Promise<Material> {
const response = fetch(`/api/projects/${args.projectId}/materials/${args.materialId}/discard`, { method: "POST" });
return response.then((r) => handleResponse(r, (data) => (data as MaterialResponse).material));
}
export function retryMaterial(args: { materialId: string; projectId: string }): Promise<Material> {
const response = fetch(`/api/projects/${args.projectId}/materials/${args.materialId}/retry`, { method: "POST" });
return response.then((r) => handleResponse(r, (data) => (data as MaterialResponse).material));
}
export async function fetchMaterial(args: { materialId: string; projectId: string }): Promise<Material> {
const response = await fetch(`/api/projects/${args.projectId}/materials/${args.materialId}`);
return handleResponse(response, (data) => (data as MaterialResponse).material);
@@ -65,6 +80,18 @@ export function useCreateMaterial(projectId: string) {
});
}
export function useApproveMaterial(projectId: string) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: approveMaterial,
onSuccess: (data) => {
logger.info("素材通过成功", { materialId: data.id, projectId });
void queryClient.invalidateQueries({ queryKey: [...MATERIALS_KEY, "list", projectId] });
void queryClient.invalidateQueries({ queryKey: [...MATERIALS_KEY, "detail", projectId, data.id] });
},
});
}
export function useDeleteMaterial(projectId: string) {
const queryClient = useQueryClient();
return useMutation({
@@ -76,6 +103,30 @@ export function useDeleteMaterial(projectId: string) {
});
}
export function useDiscardMaterial(projectId: string) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: discardMaterial,
onSuccess: (data) => {
logger.info("素材放弃成功", { materialId: data.id, projectId });
void queryClient.invalidateQueries({ queryKey: [...MATERIALS_KEY, "list", projectId] });
void queryClient.invalidateQueries({ queryKey: [...MATERIALS_KEY, "detail", projectId, data.id] });
},
});
}
export function useRetryMaterial(projectId: string) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: retryMaterial,
onSuccess: (data) => {
logger.info("素材重试成功", { materialId: data.id, projectId });
void queryClient.invalidateQueries({ queryKey: [...MATERIALS_KEY, "list", projectId] });
void queryClient.invalidateQueries({ queryKey: [...MATERIALS_KEY, "detail", projectId, data.id] });
},
});
}
export function useMaterial(args: { materialId: null | string; projectId: string }) {
return useQuery({
enabled: !!args.materialId,