diff --git a/frontend/src/app/templates/page.tsx b/frontend/src/app/templates/page.tsx index b530145a..d924175c 100644 --- a/frontend/src/app/templates/page.tsx +++ b/frontend/src/app/templates/page.tsx @@ -1,8 +1,30 @@ -"use client"; +"use client"; -import { MinusCircleOutlined, PlusOutlined } from "@ant-design/icons"; -import { Alert, Button, Card, Empty, Form, Input, Popconfirm, Select, Space, Switch, Table, Tag, Typography, message } from "antd"; -import type { ColumnsType, TablePaginationConfig } from "antd/es/table"; +import { + CaretDownOutlined, + CaretRightOutlined, + MinusCircleOutlined, + PlusOutlined, + SearchOutlined, +} from "@ant-design/icons"; +import { + Button, + Drawer, + Empty, + Form, + Input, + List, + Popconfirm, + Select, + Space, + Spin, + Switch, + Tag, + Tree, + Typography, + message, +} from "antd"; +import type { DataNode } from "antd/es/tree"; import { useCallback, useEffect, useMemo, useState } from "react"; import { @@ -18,7 +40,12 @@ import { updateTemplateStatus, } from "@/services/template"; import { useAuthStore } from "@/stores/auth.store"; -import { formatIndustryCategory, formatTemplateCategory, industryCategoryOptions, templateCategoryOptions } from "@/lib/template-taxonomy"; +import { + formatIndustryCategory, + formatTemplateCategory, + industryCategoryOptions, + templateCategoryOptions, +} from "@/lib/template-taxonomy"; import type { TemplateItem, TemplateVersionItem } from "@/types/api"; const { Title, Text, Paragraph } = Typography; @@ -48,10 +75,16 @@ const contractTypeOptions = [ { value: "nda", label: "保密协议" }, ]; +const contractTypeLabelMap: Record = { + labor: "劳动合同", + procurement: "采购合同", + sales: "销售合同", + service: "服务合同", + nda: "保密协议", +}; + function formatDateTime(value?: string | null) { - if (!value) { - return "-"; - } + if (!value) return "-"; return new Date(value).toLocaleString("zh-CN", { hour12: false }); } @@ -59,54 +92,29 @@ function buildVariablesJson(values: TemplateFormValues) { const variables = (values.variables ?? []) .map((item) => { const name = item.name?.trim(); - if (!name) { - return null; - } - return { - name, - label: item.label?.trim() || name, - }; + if (!name) return null; + return { name, label: item.label?.trim() || name }; }) .filter((item): item is { name: string; label: string } => item !== null); - if (!variables.length) { - return undefined; - } - + if (!variables.length) return undefined; return JSON.stringify(variables); } function getInitialVariables(template?: TemplateItem | null): TemplateVariableFormValue[] { if (template?.variables.length) { - return template.variables.map((item) => ({ - name: item.name, - label: item.label, - })); + return template.variables.map((item) => ({ name: item.name, label: item.label })); } return [{}]; } -function formatVariablesPreview(values?: TemplateVariableFormValue[]) { - const variables = (values ?? []) - .map((item) => { - const name = item.name?.trim(); - if (!name) { - return null; - } - return { - name, - label: item.label?.trim() || name, - }; - }) - .filter((item): item is { name: string; label: string } => item !== null); - - return JSON.stringify(variables, null, 2); -} +const ALL_KEY = "__all__"; export default function TemplatesPage() { const [messageApi, contextHolder] = message.useMessage(); const currentUser = useAuthStore((state) => state.currentUser); const [form] = Form.useForm(); + const [loading, setLoading] = useState(true); const [submitting, setSubmitting] = useState(false); const [seeding, setSeeding] = useState(false); @@ -114,86 +122,47 @@ export default function TemplatesPage() { const [statusChangingId, setStatusChangingId] = useState(null); const [publishingId, setPublishingId] = useState(null); const [rollbackingId, setRollbackingId] = useState(null); + const [items, setItems] = useState([]); const [editingId, setEditingId] = useState(null); - const [contractTypeFilter, setContractTypeFilter] = useState(undefined); - const [templateCategoryFilter, setTemplateCategoryFilter] = useState(undefined); - const [industryCategoryFilter, setIndustryCategoryFilter] = useState(undefined); - const [page, setPage] = useState(1); - const [pageSize, setPageSize] = useState(10); - const [total, setTotal] = useState(0); - const [expandedRowKeys, setExpandedRowKeys] = useState([]); - const [versionLoadingId, setVersionLoadingId] = useState(null); - const [versionHistoryMap, setVersionHistoryMap] = useState>({}); + const [drawerOpen, setDrawerOpen] = useState(false); + + const [searchQuery, setSearchQuery] = useState(""); + const [selectedCategory, setSelectedCategory] = useState(ALL_KEY); + const [expandedKeys, setExpandedKeys] = useState([ALL_KEY]); + + const [versionDrawerOpen, setVersionDrawerOpen] = useState(false); + const [versionLoading, setVersionLoading] = useState(false); + const [versionHistory, setVersionHistory] = useState([]); + const [versionTemplate, setVersionTemplate] = useState(null); + const [rollbackingVersion, setRollbackingVersion] = useState(null); const editingTemplate = useMemo(() => items.find((item) => item.id === editingId) ?? null, [editingId, items]); const watchedVariables = Form.useWatch("variables", form); - const variablePreview = useMemo(() => formatVariablesPreview(watchedVariables), [watchedVariables]); - const loadTemplates = useCallback( - async ( - nextPage = page, - nextPageSize = pageSize, - nextContractType = contractTypeFilter, - nextTemplateCategory = templateCategoryFilter, - nextIndustryCategory = industryCategoryFilter, - ) => { - if (currentUser?.role !== "admin") { - setLoading(false); + const loadTemplates = useCallback(async () => { + if (currentUser?.role !== "admin") { + setLoading(false); + return; + } + + setLoading(true); + try { + const result = await listTemplatesWithFilters({ page: 1, pageSize: 1000 }); + if (result.code !== 0) { + messageApi.error(result.message || "获取模板列表失败"); return; } - - try { - const result = await listTemplatesWithFilters({ - page: nextPage, - pageSize: nextPageSize, - contractType: nextContractType, - templateCategory: nextTemplateCategory, - industryCategory: nextIndustryCategory, - }); - if (result.code !== 0) { - messageApi.error(result.message || "获取模板列表失败"); - return; - } - setItems(result.data.items); - setPage(result.data.page); - setPageSize(result.data.page_size); - setTotal(result.data.total); - } catch { - messageApi.error("模板列表加载失败,请稍后重试"); - } finally { - setLoading(false); - } - }, - [contractTypeFilter, currentUser?.role, industryCategoryFilter, messageApi, page, pageSize, templateCategoryFilter], - ); - - const loadTemplateVersions = useCallback( - async (templateId: string) => { - if (versionHistoryMap[templateId]) { - return; - } - setVersionLoadingId(templateId); - try { - const result = await getTemplateVersions(templateId); - if (result.code !== 0) { - messageApi.error(result.message || "获取模板版本历史失败"); - return; - } - setVersionHistoryMap((current) => ({ ...current, [templateId]: result.data.items })); - } catch { - messageApi.error("模板版本历史加载失败,请稍后重试"); - } finally { - setVersionLoadingId(null); - } - }, - [messageApi, versionHistoryMap], - ); + setItems(result.data.items); + } catch { + messageApi.error("模板列表加载失败,请稍后重试"); + } finally { + setLoading(false); + } + }, [currentUser?.role, messageApi]); useEffect(() => { - const timer = window.setTimeout(() => { - void loadTemplates(); - }, 0); + const timer = window.setTimeout(() => void loadTemplates(), 0); return () => window.clearTimeout(timer); }, [loadTemplates]); @@ -203,7 +172,6 @@ export default function TemplatesPage() { form.setFieldsValue({ enabled: true, variables: [{}], change_summary: undefined }); return; } - form.setFieldsValue({ name: editingTemplate.name, contract_type: editingTemplate.contract_type ?? undefined, @@ -217,6 +185,129 @@ export default function TemplatesPage() { }); }, [editingTemplate, form]); + const filteredItems = useMemo(() => { + let result = items; + if (selectedCategory !== ALL_KEY) { + if (selectedCategory.startsWith("contract:")) { + const contractType = selectedCategory.replace("contract:", ""); + result = result.filter((item) => item.contract_type === contractType); + } else if (selectedCategory.startsWith("template:")) { + const templateCategory = selectedCategory.replace("template:", ""); + result = result.filter((item) => item.template_category === templateCategory); + } else if (selectedCategory.startsWith("industry:")) { + const industryCategory = selectedCategory.replace("industry:", ""); + result = result.filter((item) => item.industry_category === industryCategory); + } + } + if (searchQuery.trim()) { + const q = searchQuery.toLowerCase(); + result = result.filter( + (item) => + item.name.toLowerCase().includes(q) || + (item.template_code ?? "").toLowerCase().includes(q) || + (item.description ?? "").toLowerCase().includes(q), + ); + } + return result; + }, [items, selectedCategory, searchQuery]); + + const treeData = useMemo((): DataNode[] => { + const contractCounts: Record = {}; + const templateCounts: Record = {}; + const industryCounts: Record = {}; + + for (const item of items) { + if (item.contract_type) { + contractCounts[item.contract_type] = (contractCounts[item.contract_type] || 0) + 1; + } + if (item.template_category) { + templateCounts[item.template_category] = (templateCounts[item.template_category] || 0) + 1; + } + if (item.industry_category) { + industryCounts[item.industry_category] = (industryCounts[item.industry_category] || 0) + 1; + } + } + + const contractChildren: DataNode[] = Object.entries(contractTypeLabelMap) + .filter(([key]) => contractCounts[key]) + .map(([key, label]) => ({ + key: `contract:${key}`, + title: ( + + {label} + {contractCounts[key] ?? 0} + + ), + })); + + const templateChildren: DataNode[] = templateCategoryOptions + .filter((opt) => templateCounts[opt.value]) + .map((opt) => ({ + key: `template:${opt.value}`, + title: ( + + {opt.label} + {templateCounts[opt.value] ?? 0} + + ), + })); + + const industryChildren: DataNode[] = industryCategoryOptions + .filter((opt) => industryCounts[opt.value]) + .map((opt) => ({ + key: `industry:${opt.value}`, + title: ( + + {opt.label} + {industryCounts[opt.value] ?? 0} + + ), + })); + + return [ + { + key: ALL_KEY, + title: ( + + 全部模板 + {items.length} + + ), + children: [ + { key: "group-contract", title: 按合同类型, children: contractChildren, selectable: false }, + { key: "group-template", title: 按模板分类, children: templateChildren, selectable: false }, + { key: "group-industry", title: 按行业入口, children: industryChildren, selectable: false }, + ], + }, + ]; + }, [items]); + + const handleTreeSelect = (_selectedKeys: React.Key[], info: { node: DataNode }) => { + const key = info.node.key as string; + if (!key.startsWith("group-")) { + setSelectedCategory(key); + setExpandedKeys((prev) => (prev.includes(key) ? prev : [...prev, key])); + } + }; + + const handleOpenDrawer = (template?: TemplateItem) => { + if (template) { + setEditingId(template.id); + } else { + setEditingId(null); + form.resetFields(); + form.setFieldsValue({ enabled: true, variables: [{}], change_summary: undefined }); + } + setDrawerOpen(true); + }; + + const handleCloseDrawer = () => { + setDrawerOpen(false); + setEditingId(null); + form.resetFields(); + form.setFieldsValue({ enabled: true, variables: [{}], change_summary: undefined }); + }; + const handleSubmit = async (values: TemplateFormValues) => { setSubmitting(true); try { @@ -237,18 +328,8 @@ export default function TemplatesPage() { return; } messageApi.success(editingTemplate ? "模板已更新并生成新版本" : "模板已创建"); - setEditingId(null); - form.resetFields(); - form.setFieldsValue({ enabled: true, variables: [{}], change_summary: undefined }); - setVersionHistoryMap((current) => { - if (!editingTemplate) { - return current; - } - const next = { ...current }; - delete next[editingTemplate.id]; - return next; - }); - await loadTemplates(page, pageSize, contractTypeFilter, templateCategoryFilter, industryCategoryFilter); + handleCloseDrawer(); + await loadTemplates(); } catch { messageApi.error("保存模板失败,请稍后重试"); } finally { @@ -261,17 +342,7 @@ export default function TemplatesPage() { try { await deleteTemplate(templateId); messageApi.success("模板已删除"); - if (editingId === templateId) { - setEditingId(null); - form.resetFields(); - form.setFieldsValue({ enabled: true, variables: [{}], change_summary: undefined }); - } - setVersionHistoryMap((current) => { - const next = { ...current }; - delete next[templateId]; - return next; - }); - await loadTemplates(page, pageSize, contractTypeFilter, templateCategoryFilter, industryCategoryFilter); + await loadTemplates(); } catch { messageApi.error("删除模板失败,请稍后重试"); } finally { @@ -288,12 +359,7 @@ export default function TemplatesPage() { return; } messageApi.success(enabled ? "模板已启用" : "模板已停用"); - setVersionHistoryMap((current) => { - const next = { ...current }; - delete next[item.id]; - return next; - }); - await loadTemplates(page, pageSize, contractTypeFilter, templateCategoryFilter, industryCategoryFilter); + await loadTemplates(); } catch { messageApi.error("更新模板状态失败,请稍后重试"); } finally { @@ -310,9 +376,7 @@ export default function TemplatesPage() { return; } messageApi.success(`默认模板初始化完成:新增 ${result.data.created_count} 个,已存在 ${result.data.existing_count} 个`); - setVersionHistoryMap({}); - setLoading(true); - await loadTemplates(1, pageSize, contractTypeFilter, templateCategoryFilter, industryCategoryFilter); + await loadTemplates(); } catch { messageApi.error("初始化默认模板失败,请稍后重试"); } finally { @@ -329,7 +393,7 @@ export default function TemplatesPage() { return; } messageApi.success("模板已发布到生产可用集合"); - await loadTemplates(page, pageSize, contractTypeFilter, templateCategoryFilter, industryCategoryFilter); + await loadTemplates(); } catch (error) { messageApi.error(error instanceof Error ? error.message : "模板发布失败,请稍后重试"); } finally { @@ -346,7 +410,7 @@ export default function TemplatesPage() { return; } messageApi.success("模板已从生产可用集合移除"); - await loadTemplates(page, pageSize, contractTypeFilter, templateCategoryFilter, industryCategoryFilter); + await loadTemplates(); } catch (error) { messageApi.error(error instanceof Error ? error.message : "模板下线失败,请稍后重试"); } finally { @@ -354,10 +418,30 @@ export default function TemplatesPage() { } }; - const handleRollback = async (item: TemplateItem, versionLabel: string) => { - setRollbackingId(`${item.id}:${versionLabel}`); + const handleViewVersions = async (item: TemplateItem) => { + setVersionTemplate(item); + setVersionDrawerOpen(true); + setVersionLoading(true); try { - const result = await rollbackTemplate(item.id, { + const result = await getTemplateVersions(item.id); + if (result.code !== 0) { + messageApi.error(result.message || "获取模板版本历史失败"); + return; + } + setVersionHistory(result.data.items); + } catch { + messageApi.error("模板版本历史加载失败,请稍后重试"); + } finally { + setVersionLoading(false); + } + }; + + const handleRollback = async (versionLabel: string) => { + if (!versionTemplate) return; + const rollbackKey = `${versionTemplate.id}:${versionLabel}`; + setRollbackingVersion(rollbackKey); + try { + const result = await rollbackTemplate(versionTemplate.id, { target_version_label: versionLabel, change_summary: `Rollback to ${versionLabel}`, }); @@ -366,457 +450,271 @@ export default function TemplatesPage() { return; } messageApi.success(`模板已回滚为新版本,来源 ${versionLabel}`); - setVersionHistoryMap((current) => { - const next = { ...current }; - delete next[item.id]; - return next; - }); - await loadTemplates(page, pageSize, contractTypeFilter, templateCategoryFilter, industryCategoryFilter); - await loadTemplateVersions(item.id); + await loadTemplates(); + await handleViewVersions(versionTemplate); } catch (error) { messageApi.error(error instanceof Error ? error.message : "模板回滚失败,请稍后重试"); } finally { - setRollbackingId(null); + setRollbackingVersion(null); } }; - const handleTableChange = (pagination: TablePaginationConfig) => { - const nextPage = pagination.current ?? 1; - const nextPageSize = pagination.pageSize ?? pageSize; - setLoading(true); - void loadTemplates(nextPage, nextPageSize, contractTypeFilter, templateCategoryFilter, industryCategoryFilter); - }; - - const handleExpand = (expanded: boolean, record: TemplateItem) => { - const nextKeys = expanded ? Array.from(new Set([...expandedRowKeys, record.id])) : expandedRowKeys.filter((key) => key !== record.id); - setExpandedRowKeys(nextKeys); - if (expanded) { - void loadTemplateVersions(record.id); + const getCategoryLabel = () => { + if (selectedCategory === ALL_KEY) return "全部模板"; + if (selectedCategory.startsWith("contract:")) { + return contractTypeLabelMap[selectedCategory.replace("contract:", "")] ?? "合同类型"; } + if (selectedCategory.startsWith("template:")) { + return formatTemplateCategory(selectedCategory.replace("template:", "")); + } + if (selectedCategory.startsWith("industry:")) { + return formatIndustryCategory(selectedCategory.replace("industry:", "")); + } + return "模板列表"; }; - const buildVersionColumns = (templateId: string): ColumnsType => [ - { - title: "版本", - dataIndex: "version_label", - key: "version_label", - width: 120, - render: (value, item) => ( - - {value} - {item.is_current ? 当前 : null} - - ), - }, - { - title: "变更说明", - dataIndex: "change_summary", - key: "change_summary", - render: (value: string | null) => value || "-", - }, - { - title: "模板分类", - dataIndex: "template_category", - key: "template_category", - width: 160, - render: (value: string | null) => formatTemplateCategory(value), - }, - { - title: "行业入口", - dataIndex: "industry_category", - key: "industry_category", - width: 160, - render: (value: string | null) => formatIndustryCategory(value), - }, - { - title: "操作人", - dataIndex: "created_by_name", - key: "created_by_name", - width: 140, - render: (value: string | null, item) => value || item.created_by || "-", - }, - { - title: "时间", - dataIndex: "created_at", - key: "created_at", - width: 180, - render: (value: string) => formatDateTime(value), - }, - { - title: "操作", - key: "actions", - width: 120, - render: (_, version, index) => - index > 0 ? ( - - ) : null, - }, - ]; - - const columns: ColumnsType = [ - { title: "模板名称", dataIndex: "name", key: "name", width: 200 }, - { - title: "合同类型", - dataIndex: "contract_type", - key: "contract_type", - width: 140, - render: (value: string | null) => contractTypeOptions.find((item) => item.value === value)?.label ?? value ?? "未分类", - }, - { - title: "模板分类", - dataIndex: "template_category", - key: "template_category", - width: 160, - render: (value: string | null) => formatTemplateCategory(value), - }, - { - title: "行业入口", - dataIndex: "industry_category", - key: "industry_category", - width: 160, - render: (value: string | null) => formatIndustryCategory(value), - }, - { - title: "模板编号", - dataIndex: "template_code", - key: "template_code", - width: 170, - render: (value: string | null) => value ? {value} : "-", - }, - { - title: "当前版本", - dataIndex: "current_version_label", - key: "current_version_label", - width: 160, - render: (value: string, item) => ( - - {value} - - 共 {item.version_count} 个版本 - - - ), - }, - { - title: "最新变更", - dataIndex: "latest_change_summary", - key: "latest_change_summary", - render: (value: string | null) => value || "-", - }, - { - title: "发布状态", - key: "publish_status", - width: 180, - render: (_, item) => ( - - {item.published_at ? "已发布" : "未发布"} - - {item.published_at ? formatDateTime(item.published_at) : "需治理批准后发布"} - - - ), - }, - { - title: "状态", - dataIndex: "enabled", - key: "enabled", - width: 180, - render: (_value: boolean, item) => ( - - {item.enabled ? "启用中" : "已停用"} - void handleToggleStatus(item, checked)} - /> - - ), - }, - { - title: "更新时间", - dataIndex: "updated_at", - key: "updated_at", - width: 180, - render: (value: string) => formatDateTime(value), - }, - { - title: "操作", - key: "actions", - width: 180, - render: (_, item) => ( - - - {item.published_at ? ( - - ) : ( - - )} - void handleDelete(item.id)}> - - - - ), - }, - ]; - - const templateStats = useMemo( - () => ({ - enabledCount: items.filter((item) => item.enabled).length, - disabledCount: items.filter((item) => !item.enabled).length, - versionedCount: items.filter((item) => item.version_count > 1).length, - }), - [items], - ); - if (currentUser?.role !== "admin") { return ( -
+
{contextHolder} - - - +
+ 仅管理员可管理合同模板。 +
); } return ( -
+
{contextHolder} - -
-
- Template Management - 把模板库做成可维护、可筛选、可追踪的产品资产 - - 这轮把模板管理从“覆盖式编辑”推进到“分类清晰、行业可进、版本可追”。管理员既能维护模板,也能为业务入口准备更清楚的分类结构。 - - - - - 当前模板 {total} 个,已启用 {templateStats.enabledCount} 个,已形成多版本 {templateStats.versionedCount} 个。 - - -
- - Phase 1 - - 分类与行业入口 - -
    -
  • 模板创建时就补齐合同类型、模板分类和行业入口。
  • -
  • 业务入口可以直接按行业和分类挑模板。
  • -
  • 版本历史继续保留这次分类调整的变更说明。
  • -
- -
-
- -
- -
- - - - - - - - - - -
-
- 变量配置 -
- - {(fields, { add, remove }) => ( - - {fields.map((field) => ( - - - - - - - - - - )} - -
- -
-
- 变量 JSON 预览 -
-
{variablePreview}
-
- - - - - - - - {editingTemplate ? ( - - ) : null} - -
-
- - - { - setLoading(true); - setTemplateCategoryFilter(value); - setPage(1); - void loadTemplates(1, pageSize, contractTypeFilter, value, industryCategoryFilter); - }} - /> - } + placeholder="搜索模板名称" + value={searchQuery} + onChange={(e) => setSearchQuery(e.target.value)} + className="doc-sidebar-search" + /> +
+ (expanded ? : )} + treeData={treeData} + expandedKeys={expandedKeys} + selectedKeys={[selectedCategory]} + onExpand={setExpandedKeys} + onSelect={(_keys, info) => handleTreeSelect(_keys, info)} + blockNode /> - -
- +
+
+ +
+ + + {/* 右侧模板列表 */} +
+
+
+ {getCategoryLabel()} + {filteredItems.length} 个模板 +
+ +
+ + {loading ? ( +
+ + 正在加载模板列表... +
+ ) : filteredItems.length > 0 ? ( + ( + +
+
+ + {item.name} + {item.enabled ? "启用" : "停用"} + {item.published_at ? "已发布" : "未发布"} + {item.template_code && {item.template_code}} + +
+ + {contractTypeLabelMap[item.contract_type ?? ""] ?? item.contract_type ?? "未分类"} + {formatTemplateCategory(item.template_category)} + {formatIndustryCategory(item.industry_category)} + v{item.current_version_label} + {item.version_count > 1 && ({item.version_count} 个版本)} + +
+ {item.description && ( + + {item.description} + + )} +
+ + void handleToggleStatus(item, checked)} + /> + + + {item.published_at ? ( + + ) : ( + + )} + void handleDelete(item.id)}> + + + +
+
+ )} + /> + ) : ( + + + + )} +
+ + + {/* 新建/编辑模板抽屉 */} + +
+ + + + + + + + + + +
+
+ 变量配置 +
+ + {(fields, { add, remove }) => ( + + {fields.map((field) => ( + + + + + + + + + + )} + +
+ + + + + + + + + +
+
+ + {/* 版本历史抽屉 */} + { setVersionDrawerOpen(false); setVersionHistory([]); setVersionTemplate(null); }} + width={720} + > + {versionLoading ? ( +
+ ) : versionHistory.length > 0 ? ( + ( + 0 + ? [] + : [] + } + > + + {version.version_label} + {version.is_current && 当前} +
+ } + description={ + + {version.change_summary || "无变更说明"} + + {formatDateTime(version.created_at)} · {version.created_by_name || version.created_by || "未知"} + + + } + /> + + )} + /> + ) : ( + + )} +
); }