feat: 实现阶段二实体体系——AI预处理真实化+实体CRUD+审核归一化

- 新增 entities 数据表(含迁移)、Entity 类型、DAO 层完整 CRUD
- AI 预处理管道接入真实模型(generateText),输出结构化 JSON(摘要+规范化内容+候选实体)
- 模板接口重构为 {systemPrompt, buildUserPrompt, parseOutput},general/meeting 模板真实化
- 新增 5 个实体路由端点 + 实体管理前端页面(列表/详情/编辑弹窗)
- 审核面板增强:展示 AI 预处理结构化结果+候选实体归一化面板(合并/新建/选择/放弃)
- 素材通过时根据用户确认的候选实体写入 entities 表
- 工作台菜单新增"实体"入口
- 新增 entities DAO 测试(16)、processor 测试(11)、路由测试(8),服务端 367 测试全部通过
- TypeScript 0 错误
This commit is contained in:
2026-06-08 18:49:30 +08:00
parent 034496e946
commit 12edf0b545
36 changed files with 2109 additions and 62 deletions

View File

@@ -0,0 +1,24 @@
import { Tag, Typography } from "antd";
import type { Entity } from "../types";
import { ENTITY_TYPE_COLORS, ENTITY_TYPE_LABELS } from "./constants";
interface EntityCardProps {
entity: Entity;
onSelect: () => void;
selected: boolean;
}
export function EntityCard({ entity, onSelect, selected }: EntityCardProps) {
const className = selected ? "app-sidebar-list-item app-sidebar-list-item--selected" : "app-sidebar-list-item";
return (
<div className={className} onClick={onSelect} style={{ padding: "8px 12px" }}>
<Typography.Text ellipsis strong style={{ display: "block", marginBottom: 4 }}>
{entity.name}
</Typography.Text>
<Tag color={ENTITY_TYPE_COLORS[entity.type] ?? "default"}>{ENTITY_TYPE_LABELS[entity.type] ?? entity.type}</Tag>
</div>
);
}

View File

@@ -0,0 +1,114 @@
import { DeleteOutlined, EditOutlined } from "@ant-design/icons";
import { Card, Descriptions, Empty, Popconfirm, Result, Space, Spin, Tag } from "antd";
import "overlayscrollbars/styles/overlayscrollbars.css";
import { OverlayScrollbarsComponent } from "overlayscrollbars-react";
import type { Entity } from "../types";
import { useDeleteEntity, useEntity } from "../../../shared/hooks/use-entities";
import { formatRelativeTime } from "../../../shared/utils/time";
import { ENTITY_TYPE_COLORS, ENTITY_TYPE_LABELS } from "./constants";
interface EntityDetailPanelProps {
entityId: null | string;
onDelete: (id: string) => void;
onEdit: (entity: Entity) => void;
projectId: string;
}
export function EntityDetailPanel({ entityId, onDelete, onEdit, projectId }: EntityDetailPanelProps) {
const { data, error, isLoading } = useEntity({ entityId, projectId });
const deleteMutation = useDeleteEntity(projectId);
if (!entityId) {
return (
<div className="app-inbox-panel">
<OverlayScrollbarsComponent
className="app-inbox-content"
options={{
overflow: { x: "hidden", y: "scroll" },
scrollbars: { autoHide: "move", theme: "os-theme-custom" },
}}
>
<Empty description="请在左侧选择实体" />
</OverlayScrollbarsComponent>
</div>
);
}
if (isLoading) {
return (
<div className="app-inbox-panel">
<OverlayScrollbarsComponent
className="app-inbox-content"
options={{
overflow: { x: "hidden", y: "scroll" },
scrollbars: { autoHide: "move", theme: "os-theme-custom" },
}}
>
<Spin />
</OverlayScrollbarsComponent>
</div>
);
}
if (error || !data) {
return (
<div className="app-inbox-panel">
<OverlayScrollbarsComponent
className="app-inbox-content"
options={{
overflow: { x: "hidden", y: "scroll" },
scrollbars: { autoHide: "move", theme: "os-theme-custom" },
}}
>
<Result subTitle="加载实体失败" />
</OverlayScrollbarsComponent>
</div>
);
}
const handleDelete = () => {
void deleteMutation.mutate({ entityId: data.id, projectId }, { onSuccess: () => onDelete(data.id) });
};
return (
<div className="app-inbox-panel">
<OverlayScrollbarsComponent
className="app-inbox-content"
options={{ overflow: { x: "hidden", y: "scroll" }, scrollbars: { autoHide: "move", theme: "os-theme-custom" } }}
>
<Card
extra={
<Space>
<EditOutlined onClick={() => onEdit(data)} style={{ cursor: "pointer" }} />
<Popconfirm
description="删除后相关内容退化为普通文本"
okButtonProps={{ danger: true }}
okText="删除"
onConfirm={handleDelete}
title="确认删除该实体?"
>
<DeleteOutlined style={{ color: "var(--ant-color-error)", cursor: "pointer" }} />
</Popconfirm>
</Space>
}
size="small"
title={data.name}
>
<Descriptions column={1} size="small">
<Descriptions.Item label="类型">
<Tag color={ENTITY_TYPE_COLORS[data.type] ?? "default"}>{ENTITY_TYPE_LABELS[data.type] ?? data.type}</Tag>
</Descriptions.Item>
<Descriptions.Item label="描述">{data.description || "暂无描述"}</Descriptions.Item>
<Descriptions.Item label="别名">
{data.aliases.length > 0 ? data.aliases.join("、") : "无"}
</Descriptions.Item>
<Descriptions.Item label="创建时间">{formatRelativeTime(data.createdAt)}</Descriptions.Item>
<Descriptions.Item label="更新时间">{formatRelativeTime(data.updatedAt)}</Descriptions.Item>
</Descriptions>
</Card>
</OverlayScrollbarsComponent>
</div>
);
}

View File

@@ -0,0 +1,95 @@
import { App as AntApp, Form, Input, Modal, Select } from "antd";
import { useEffect, useState } from "react";
import type { Entity, EntityType } from "../types";
import { ENTITY_TYPES } from "../types";
import { ENTITY_TYPE_LABELS } from "./constants";
interface EntityFormModalProps {
editingEntity: Entity | null;
onCancel: () => void;
onOpenChange: (open: boolean) => void;
onSubmit: (entity: Entity | null, values: FormValues) => Promise<void>;
open: boolean;
}
export interface FormValues {
aliases: string[];
description: string;
name: string;
type: EntityType;
}
const TYPE_OPTIONS = ENTITY_TYPES.map((t) => ({
label: ENTITY_TYPE_LABELS[t],
value: t,
}));
export function EntityFormModal({ editingEntity, onCancel, onOpenChange, onSubmit, open }: EntityFormModalProps) {
const { message } = AntApp.useApp();
const [form] = Form.useForm<FormValues>();
const [submitting, setSubmitting] = useState(false);
useEffect(() => {
if (!open) return;
if (editingEntity) {
form.setFieldsValue({
aliases: editingEntity.aliases,
description: editingEntity.description,
name: editingEntity.name,
type: editingEntity.type,
});
} else {
form.resetFields();
}
}, [form, open, editingEntity]);
const handleFinish = async (values: FormValues) => {
setSubmitting(true);
try {
await onSubmit(editingEntity, values);
message.success(editingEntity ? "实体已更新" : "实体已创建");
onOpenChange(false);
} catch (e: unknown) {
message.error(`操作失败:${e instanceof Error ? e.message : "未知错误"}`);
} finally {
setSubmitting(false);
}
};
return (
<Modal
confirmLoading={submitting}
destroyOnHidden
okText="确定"
onCancel={() => {
onCancel();
onOpenChange(false);
}}
onOk={() => void form.submit()}
open={open}
title={editingEntity ? "编辑实体" : "新增实体"}
>
<Form
form={form}
initialValues={{ aliases: [], description: "", type: "other" as EntityType }}
layout="vertical"
onFinish={(values) => void handleFinish(values)}
>
<Form.Item label="名称" name="name" rules={[{ message: "请输入实体名称", required: true, whitespace: true }]}>
<Input maxLength={100} placeholder="实体名称" />
</Form.Item>
<Form.Item label="类型" name="type" rules={[{ message: "请选择类型", required: true }]}>
<Select options={TYPE_OPTIONS} />
</Form.Item>
<Form.Item label="描述" name="description">
<Input.TextArea maxLength={500} placeholder="实体描述" />
</Form.Item>
<Form.Item label="别名" name="aliases">
<Select mode="tags" placeholder="输入别名后按回车" />
</Form.Item>
</Form>
</Modal>
);
}

View File

@@ -0,0 +1,85 @@
import { PlusOutlined } from "@ant-design/icons";
import { Button, Empty, Segmented, Skeleton } from "antd";
import "overlayscrollbars/styles/overlayscrollbars.css";
import { OverlayScrollbarsComponent } from "overlayscrollbars-react";
import { useMemo, useState } from "react";
import type { Entity } from "../types";
import { ENTITY_TYPES } from "../types";
import { SidebarGroup } from "../../../shared/components/SidebarGroup";
import { GROUP_LABELS, groupByDate } from "../../../shared/utils/date-group";
import { ENTITY_TYPE_LABELS } from "./constants";
import { EntityCard } from "./EntityCard";
interface EntityListProps {
entities: readonly Entity[];
loading: boolean;
onAddClick: () => void;
onSelect: (id: string) => void;
selectedId: null | string;
}
export function EntityList({ entities, loading, onAddClick, onSelect, selectedId }: EntityListProps) {
const [filterType, setFilterType] = useState<string>("all");
const filteredEntities = useMemo(() => {
if (filterType === "all") return entities;
return entities.filter((e) => e.type === filterType);
}, [entities, filterType]);
const groupedEntities = useMemo(() => groupByDate(filteredEntities, "createdAt"), [filteredEntities]);
const segmentedOptions = useMemo(
() => [
{ label: "全部", value: "all" },
...ENTITY_TYPES.map((t) => ({
label: ENTITY_TYPE_LABELS[t],
value: t,
})),
],
[],
);
return (
<div className="app-sidebar-list" style={{ width: 260 }}>
<div className="app-sidebar-list-header">
<Button block icon={<PlusOutlined />} onClick={onAddClick} type="primary">
</Button>
<Segmented block onChange={(value) => setFilterType(value)} options={segmentedOptions} value={filterType} />
</div>
<OverlayScrollbarsComponent
className="app-sidebar-list-body"
options={{
overflow: { x: "hidden", y: "scroll" },
scrollbars: { autoHide: "move", theme: "os-theme-custom" },
}}
>
{loading ? (
<Skeleton active paragraph={{ rows: 6 }} title={false} />
) : entities.length === 0 ? (
<Empty description="暂无实体" image={Empty.PRESENTED_IMAGE_SIMPLE} />
) : filteredEntities.length === 0 ? (
<Empty description="当前筛选条件下无实体" image={Empty.PRESENTED_IMAGE_SIMPLE} />
) : (
groupedEntities.map((group) => {
if (group.items.length === 0) return null;
return (
<SidebarGroup count={group.items.length} key={group.key} label={GROUP_LABELS[group.key]}>
{group.items.map((entity) => (
<EntityCard
entity={entity}
key={entity.id}
onSelect={() => onSelect(entity.id)}
selected={entity.id === selectedId}
/>
))}
</SidebarGroup>
);
})
)}
</OverlayScrollbarsComponent>
</div>
);
}

View File

@@ -0,0 +1,23 @@
import type { EntityType } from "../types";
export const ENTITY_TYPE_LABELS: Record<EntityType, string> = {
feature: "功能/模块",
issue: "问题/风险",
organization: "组织",
other: "其他",
person: "人",
requirement: "需求",
system: "系统/软件",
term: "术语/概念",
};
export const ENTITY_TYPE_COLORS: Record<EntityType, string> = {
feature: "blue",
issue: "red",
organization: "purple",
other: "default",
person: "green",
requirement: "orange",
system: "cyan",
term: "geekblue",
};

View File

@@ -0,0 +1,82 @@
import { useState } from "react";
import type { Entity } from "./types";
import { useCurrentProject } from "../../shared/hooks/use-current-project";
import { useCreateEntity, useDeleteEntity, useEntityList, useUpdateEntity } from "../../shared/hooks/use-entities";
import { EntityDetailPanel } from "./components/EntityDetailPanel";
import { type FormValues, EntityFormModal } from "./components/EntityFormModal";
import { EntityList } from "./components/EntityList";
export function EntitiesPage() {
const project = useCurrentProject();
const [modalOpen, setModalOpen] = useState(false);
const [selectedId, setSelectedId] = useState<null | string>(null);
const [editingEntity, setEditingEntity] = useState<Entity | null>(null);
const { data, isLoading } = useEntityList(project.id, { pageSize: 200 });
const createMutation = useCreateEntity(project.id);
const updateMutation = useUpdateEntity(project.id);
const deleteMutation = useDeleteEntity(project.id);
const handleEdit = (entity: Entity) => {
setEditingEntity(entity);
setModalOpen(true);
};
const handleSubmit = async (existing: Entity | null, values: FormValues) => {
if (existing) {
await updateMutation.mutateAsync({
body: {
aliases: values.aliases,
description: values.description,
name: values.name,
type: values.type,
},
entityId: existing.id,
projectId: project.id,
});
} else {
const entity = await createMutation.mutateAsync({
body: {
aliases: values.aliases,
description: values.description,
name: values.name,
type: values.type,
},
projectId: project.id,
});
setSelectedId(entity.id);
}
};
const handleDelete = (id: string) => {
void deleteMutation.mutate({ entityId: id, projectId: project.id });
if (selectedId === id) {
setSelectedId(null);
}
};
return (
<div className="app-inbox-page">
<EntityList
entities={data?.items ?? []}
loading={isLoading}
onAddClick={() => {
setEditingEntity(null);
setModalOpen(true);
}}
onSelect={setSelectedId}
selectedId={selectedId}
/>
<EntityDetailPanel entityId={selectedId} onDelete={handleDelete} onEdit={handleEdit} projectId={project.id} />
<EntityFormModal
editingEntity={editingEntity}
onCancel={() => setEditingEntity(null)}
onOpenChange={setModalOpen}
onSubmit={handleSubmit}
open={modalOpen}
/>
</div>
);
}

View File

@@ -0,0 +1,2 @@
export type { Entity, EntityType } from "../../../shared/api";
export { ENTITY_TYPES } from "../../../shared/api";

View File

@@ -0,0 +1,138 @@
import type { CandidateEntity, EntityConfirmation } from "../../../../shared/api";
import { useState } from "react";
import { Badge, Button, Card, Flex, Modal, Select, Space, Tag, Typography } from "antd";
import { useEntityList } from "../../../shared/hooks/use-entities";
interface EntityCandidatePanelProps {
candidates: CandidateEntity[];
projectId: string;
onConfirmationsChange: (confirmations: EntityConfirmation[]) => void;
}
export function EntityCandidatePanel({ candidates, projectId, onConfirmationsChange }: EntityCandidatePanelProps) {
const [confirmations, setConfirmations] = useState<Map<number, EntityConfirmation>>(new Map());
const [selectingIndex, setSelectingIndex] = useState<number | null>(null);
const [selectValue, setSelectValue] = useState<string | null>(null);
const { data: entityList } = useEntityList(projectId, { pageSize: 200 });
const handleAction = (index: number, action: EntityConfirmation["action"], targetEntityId?: string) => {
const next = new Map(confirmations);
if (action === "discard") {
next.set(index, { action: "discard", candidateIndex: index });
} else if (action === "merge" && targetEntityId) {
next.set(index, { action: "merge", candidateIndex: index, targetEntityId });
} else if (action === "create") {
next.set(index, { action: "create", candidateIndex: index });
} else if (action === "select" && targetEntityId) {
next.set(index, { action: "select", candidateIndex: index, targetEntityId });
}
setConfirmations(next);
onConfirmationsChange(Array.from(next.values()));
};
const handleSelectConfirm = () => {
if (selectingIndex !== null && selectValue) {
handleAction(selectingIndex, "select", selectValue);
}
setSelectingIndex(null);
setSelectValue(null);
};
if (!candidates || candidates.length === 0) return null;
const entityOptions = (entityList?.items ?? []).map((e) => ({
label: `${e.name}${e.aliases.length > 0 ? ` (${e.aliases.join("、")})` : ""}`,
value: e.id,
}));
return (
<>
<Card size="small" title="候选实体">
<Flex gap={8} vertical>
{candidates.map((candidate, index) => {
const conf = confirmations.get(index);
return (
<Card key={index} size="small" type="inner">
<Flex align="center" gap={8} justify="space-between" wrap="wrap">
<Flex align="center" gap={8}>
<Typography.Text strong>{candidate.name}</Typography.Text>
<Tag>{candidate.type}</Tag>
{candidate.matchedEntityId && <Badge color="blue" text="有匹配" />}
</Flex>
<Space size="small">
{candidate.matchedEntityId && (
<Button
onClick={() => handleAction(index, "merge", candidate.matchedEntityId ?? undefined)}
size="small"
type={conf?.action === "merge" ? "primary" : "default"}
>
</Button>
)}
<Button
onClick={() => handleAction(index, "create")}
size="small"
type={conf?.action === "create" ? "primary" : "default"}
>
</Button>
<Button
onClick={() => {
setSelectingIndex(index);
setSelectValue(candidate.matchedEntityId);
}}
size="small"
type={conf?.action === "select" ? "primary" : "default"}
>
</Button>
<Button
danger={conf?.action === "discard"}
onClick={() => handleAction(index, "discard")}
size="small"
type={conf?.action === "discard" ? "primary" : "default"}
>
</Button>
</Space>
</Flex>
{candidate.context && (
<Typography.Paragraph
ellipsis={{ rows: 2 }}
style={{ color: "var(--ant-color-text-secondary)", fontSize: 12, margin: "4px 0 0 0" }}
>
{candidate.context}
</Typography.Paragraph>
)}
</Card>
);
})}
</Flex>
</Card>
<Modal
onCancel={() => {
setSelectingIndex(null);
setSelectValue(null);
}}
onOk={handleSelectConfirm}
open={selectingIndex !== null}
title="选择已有实体"
>
<Select
allowClear
filterOption={(input, option) => String(option?.label ?? "").includes(input)}
onChange={setSelectValue}
options={entityOptions}
placeholder="搜索实体名称"
showSearch
style={{ width: "100%" }}
value={selectValue}
/>
</Modal>
</>
);
}

View File

@@ -1,12 +1,16 @@
import { Card, Descriptions, Flex, Tag, Typography } from "antd";
import type { EntityConfirmation, ProcessingResult } from "../../../../shared/api";
import type { Material, MaterialType } from "../types";
import { formatRelativeTime } from "../../../shared/utils/time";
import { STATUS_MAP } from "./constants";
import { EntityCandidatePanel } from "./EntityCandidatePanel";
interface MaterialContentProps {
material: Material;
projectId?: string;
onConfirmationsChange?: (confirmations: EntityConfirmation[]) => void;
}
const MATERIAL_TYPE_LABELS: Record<MaterialType, string> = {
@@ -14,36 +18,97 @@ const MATERIAL_TYPE_LABELS: Record<MaterialType, string> = {
meeting: "会议",
};
export function MaterialContent({ material }: MaterialContentProps) {
function parseProcessingResult(raw: null | string): ProcessingResult | null {
if (!raw) return null;
try {
const parsed = JSON.parse(raw) as Partial<ProcessingResult>;
if (parsed && typeof parsed === "object") {
return {
candidateEntities: Array.isArray(parsed.candidateEntities) ? parsed.candidateEntities : [],
normalizedContent: typeof parsed.normalizedContent === "string" ? parsed.normalizedContent : "",
summary: typeof parsed.summary === "string" ? parsed.summary : "",
};
}
return null;
} catch {
return null;
}
}
export function MaterialContent({ material, projectId, onConfirmationsChange }: MaterialContentProps) {
const statusInfo = STATUS_MAP[material.status] ?? { color: "default", label: material.status };
const typeLabel = MATERIAL_TYPE_LABELS[material.materialType] ?? material.materialType;
const processingResult = parseProcessingResult(material.processedContent);
return (
<Flex gap={12} vertical>
<Card size="small" title="基本信息">
<Flex gap={12} vertical>
<Typography.Paragraph>{material.description}</Typography.Paragraph>
{material.processedContent && (
<Card size="small" title="原始文本">
<Typography.Paragraph>{material.description}</Typography.Paragraph>
</Card>
{processingResult && (
<>
<Card size="small" title="AI 摘要">
<Typography.Paragraph>{processingResult.summary}</Typography.Paragraph>
</Card>
<Card size="small" title="规范化内容">
<Typography.Paragraph
style={{
background: "var(--ant-color-fill-quaternary)",
padding: 12,
borderRadius: 6,
padding: 12,
whiteSpace: "pre-wrap",
}}
>
{material.processedContent}
{processingResult.normalizedContent}
</Typography.Paragraph>
</Card>
{material.status === "review" && projectId && onConfirmationsChange && (
<EntityCandidatePanel
candidates={processingResult.candidateEntities}
projectId={projectId}
onConfirmationsChange={onConfirmationsChange}
/>
)}
<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>
</Flex>
{material.status !== "review" && processingResult.candidateEntities.length > 0 && (
<Card size="small" title="候选实体(已确认)">
<Flex gap={4} wrap="wrap">
{processingResult.candidateEntities.map((ce: { name: string }, i: number) => (
<Tag key={i}>{ce.name}</Tag>
))}
</Flex>
</Card>
)}
</>
)}
{!processingResult && material.processedContent && (
<Card size="small" title="处理结果">
<Typography.Paragraph
style={{
background: "var(--ant-color-fill-quaternary)",
borderRadius: 6,
padding: 12,
whiteSpace: "pre-wrap",
}}
>
{material.processedContent}
</Typography.Paragraph>
</Card>
)}
<Card size="small" title="基本信息">
<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>
</Card>
</Flex>
);

View File

@@ -2,6 +2,9 @@ import { CheckOutlined, CloseOutlined, RedoOutlined } from "@ant-design/icons";
import "overlayscrollbars/styles/overlayscrollbars.css";
import { App as AntApp, Button, Empty, Result, Space, Spin, Typography } from "antd";
import { OverlayScrollbarsComponent } from "overlayscrollbars-react";
import { useState } from "react";
import type { EntityConfirmation } from "../../../../shared/api";
import { useMaterial } from "../../../shared/hooks/use-materials";
import { MaterialContent } from "./MaterialContent";
@@ -13,7 +16,7 @@ const OS_OPTIONS = {
interface MaterialDetailPanelProps {
materialId: null | string;
onApprove: (materialId: string) => Promise<void>;
onApprove: (materialId: string, entityConfirmations: EntityConfirmation[]) => Promise<void>;
onDiscard: (materialId: string) => Promise<void>;
onRetry: (materialId: string) => Promise<void>;
projectId: string;
@@ -55,6 +58,7 @@ export function MaterialDetailPanel({
function MaterialDetailPanelInner({ materialId, onApprove, onDiscard, onRetry, projectId }: MaterialDetailPanelProps) {
const { data, error, isLoading } = useMaterial({ materialId, projectId });
const { message } = AntApp.useApp();
const [entityConfirmations, setEntityConfirmations] = useState<EntityConfirmation[]>([]);
if (isLoading) {
return (
@@ -90,8 +94,9 @@ function MaterialDetailPanelInner({ materialId, onApprove, onDiscard, onRetry, p
const handleApprove = async () => {
try {
await onApprove(id);
await onApprove(id, entityConfirmations);
message.success("已通过");
setEntityConfirmations([]);
} catch (e: unknown) {
message.error(`操作失败:${e instanceof Error ? e.message : "未知错误"}`);
}
@@ -101,6 +106,7 @@ function MaterialDetailPanelInner({ materialId, onApprove, onDiscard, onRetry, p
try {
await onDiscard(id);
message.success("已放弃");
setEntityConfirmations([]);
} catch (e: unknown) {
message.error(`操作失败:${e instanceof Error ? e.message : "未知错误"}`);
}
@@ -118,7 +124,7 @@ function MaterialDetailPanelInner({ materialId, onApprove, onDiscard, onRetry, p
return (
<div className="app-inbox-panel">
<OverlayScrollbarsComponent className="app-inbox-content" options={OS_OPTIONS}>
<MaterialContent material={data} />
<MaterialContent material={data} projectId={projectId} onConfirmationsChange={setEntityConfirmations} />
</OverlayScrollbarsComponent>
<div className="app-inbox-action-bar">
<Space>

View File

@@ -1,6 +1,6 @@
import { useState } from "react";
import type { CreateMaterialRequest, Material } from "./types";
import type { CreateMaterialRequest, EntityConfirmation, Material } from "./types";
import { useCurrentProject } from "../../shared/hooks/use-current-project";
import {
@@ -35,8 +35,8 @@ export function InboxPage() {
}
};
const handleApprove = async (materialId: string) => {
await approveMutation.mutateAsync({ materialId, projectId: project.id });
const handleApprove = async (materialId: string, entityConfirmations: EntityConfirmation[]) => {
await approveMutation.mutateAsync({ entityConfirmations, materialId, projectId: project.id });
};
const handleDiscard = async (materialId: string) => {

View File

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