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:
24
src/web/features/entities/components/EntityCard.tsx
Normal file
24
src/web/features/entities/components/EntityCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
114
src/web/features/entities/components/EntityDetailPanel.tsx
Normal file
114
src/web/features/entities/components/EntityDetailPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
95
src/web/features/entities/components/EntityFormModal.tsx
Normal file
95
src/web/features/entities/components/EntityFormModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
85
src/web/features/entities/components/EntityList.tsx
Normal file
85
src/web/features/entities/components/EntityList.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
23
src/web/features/entities/components/constants.ts
Normal file
23
src/web/features/entities/components/constants.ts
Normal 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",
|
||||
};
|
||||
82
src/web/features/entities/index.tsx
Normal file
82
src/web/features/entities/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
2
src/web/features/entities/types.ts
Normal file
2
src/web/features/entities/types.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export type { Entity, EntityType } from "../../../shared/api";
|
||||
export { ENTITY_TYPES } from "../../../shared/api";
|
||||
138
src/web/features/inbox/components/EntityCandidatePanel.tsx
Normal file
138
src/web/features/inbox/components/EntityCandidatePanel.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -1 +1,7 @@
|
||||
export type { CreateMaterialRequest, Material, MaterialStatus, MaterialType } from "../../../shared/api";
|
||||
export type {
|
||||
CreateMaterialRequest,
|
||||
EntityConfirmation,
|
||||
Material,
|
||||
MaterialStatus,
|
||||
MaterialType,
|
||||
} from "../../../shared/api";
|
||||
|
||||
Reference in New Issue
Block a user