feat: redesign templates page with left-tree right-detail layout
- Left sidebar: tree menu showing all templates grouped by contract type, template category, and industry (each template is a leaf node) - Right panel: selected template's full detail including content preview, variable definitions, version history, and action buttons - Edit/Create moved to Drawer - Modern styling with card-based sections, monospace content display, and visual version history timeline Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
+562
-248
@@ -3,8 +3,13 @@
|
||||
import {
|
||||
CaretDownOutlined,
|
||||
CaretRightOutlined,
|
||||
CopyOutlined,
|
||||
EditOutlined,
|
||||
FileTextOutlined,
|
||||
HistoryOutlined,
|
||||
MinusCircleOutlined,
|
||||
PlusOutlined,
|
||||
RollbackOutlined,
|
||||
SearchOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import {
|
||||
@@ -14,15 +19,16 @@ import {
|
||||
Form,
|
||||
Input,
|
||||
List,
|
||||
message,
|
||||
Popconfirm,
|
||||
Select,
|
||||
Space,
|
||||
Spin,
|
||||
Switch,
|
||||
Tag,
|
||||
Tooltip,
|
||||
Tree,
|
||||
Typography,
|
||||
message,
|
||||
} from "antd";
|
||||
import type { DataNode } from "antd/es/tree";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
@@ -124,21 +130,18 @@ export default function TemplatesPage() {
|
||||
const [rollbackingId, setRollbackingId] = useState<string | null>(null);
|
||||
|
||||
const [items, setItems] = useState<TemplateItem[]>([]);
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [selectedCategory, setSelectedCategory] = useState<string>(ALL_KEY);
|
||||
const [expandedKeys, setExpandedKeys] = useState<React.Key[]>([ALL_KEY]);
|
||||
|
||||
const [versionDrawerOpen, setVersionDrawerOpen] = useState(false);
|
||||
const [versionLoading, setVersionLoading] = useState(false);
|
||||
const [versionHistory, setVersionHistory] = useState<TemplateVersionItem[]>([]);
|
||||
const [versionTemplate, setVersionTemplate] = useState<TemplateItem | null>(null);
|
||||
const [rollbackingVersion, setRollbackingVersion] = useState<string | null>(null);
|
||||
|
||||
const editingTemplate = useMemo(() => items.find((item) => item.id === editingId) ?? null, [editingId, items]);
|
||||
const watchedVariables = Form.useWatch("variables", form);
|
||||
const selectedTemplate = useMemo(() => items.find((item) => item.id === selectedId) ?? null, [items, selectedId]);
|
||||
const editingTemplate = useMemo(() => items.find((item) => item.id === editingId) ?? null, [items, editingId]);
|
||||
|
||||
const loadTemplates = useCallback(async () => {
|
||||
if (currentUser?.role !== "admin") {
|
||||
@@ -154,18 +157,45 @@ export default function TemplatesPage() {
|
||||
return;
|
||||
}
|
||||
setItems(result.data.items);
|
||||
if (!selectedId && result.data.items.length > 0) {
|
||||
setSelectedId(result.data.items[0].id);
|
||||
}
|
||||
} catch {
|
||||
messageApi.error("模板列表加载失败,请稍后重试");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [currentUser?.role, messageApi]);
|
||||
}, [currentUser?.role, messageApi, selectedId]);
|
||||
|
||||
const loadVersions = useCallback(async (templateId: string) => {
|
||||
setVersionLoading(true);
|
||||
try {
|
||||
const result = await getTemplateVersions(templateId);
|
||||
if (result.code !== 0) {
|
||||
messageApi.error(result.message || "获取版本历史失败");
|
||||
return;
|
||||
}
|
||||
setVersionHistory(result.data.items);
|
||||
} catch {
|
||||
messageApi.error("版本历史加载失败");
|
||||
} finally {
|
||||
setVersionLoading(false);
|
||||
}
|
||||
}, [messageApi]);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = window.setTimeout(() => void loadTemplates(), 0);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [loadTemplates]);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedId) {
|
||||
void loadVersions(selectedId);
|
||||
} else {
|
||||
setVersionHistory([]);
|
||||
}
|
||||
}, [selectedId, loadVersions]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!editingTemplate) {
|
||||
form.resetFields();
|
||||
@@ -186,30 +216,15 @@ 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]);
|
||||
if (!searchQuery.trim()) return items;
|
||||
const q = searchQuery.toLowerCase();
|
||||
return items.filter(
|
||||
(item) =>
|
||||
item.name.toLowerCase().includes(q) ||
|
||||
(item.template_code ?? "").toLowerCase().includes(q) ||
|
||||
(item.description ?? "").toLowerCase().includes(q),
|
||||
);
|
||||
}, [items, searchQuery]);
|
||||
|
||||
const treeData = useMemo((): DataNode[] => {
|
||||
const contractCounts: Record<string, number> = {};
|
||||
@@ -230,39 +245,81 @@ export default function TemplatesPage() {
|
||||
|
||||
const contractChildren: DataNode[] = Object.entries(contractTypeLabelMap)
|
||||
.filter(([key]) => contractCounts[key])
|
||||
.map(([key, label]) => ({
|
||||
key: `contract:${key}`,
|
||||
title: (
|
||||
<Space>
|
||||
<span>{label}</span>
|
||||
<Tag color="default" style={{ margin: 0 }}>{contractCounts[key] ?? 0}</Tag>
|
||||
</Space>
|
||||
),
|
||||
}));
|
||||
.map(([key, label]) => {
|
||||
const templatesInCategory = items.filter((item) => item.contract_type === key);
|
||||
return {
|
||||
key: `group-contract:${key}`,
|
||||
title: (
|
||||
<Space>
|
||||
<span>{label}</span>
|
||||
<Tag color="default" style={{ margin: 0, fontSize: 11 }}>{contractCounts[key] ?? 0}</Tag>
|
||||
</Space>
|
||||
),
|
||||
selectable: false,
|
||||
children: templatesInCategory.map((t) => ({
|
||||
key: t.id,
|
||||
title: (
|
||||
<div className="template-tree-item">
|
||||
<span className="template-tree-name">{t.name}</span>
|
||||
{t.enabled ? null : <Tag color="default" style={{ fontSize: 10, marginLeft: 4 }}>停</Tag>}
|
||||
</div>
|
||||
),
|
||||
isLeaf: true,
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
const templateChildren: DataNode[] = templateCategoryOptions
|
||||
.filter((opt) => templateCounts[opt.value])
|
||||
.map((opt) => ({
|
||||
key: `template:${opt.value}`,
|
||||
title: (
|
||||
<Space>
|
||||
<span>{opt.label}</span>
|
||||
<Tag color="default" style={{ margin: 0 }}>{templateCounts[opt.value] ?? 0}</Tag>
|
||||
</Space>
|
||||
),
|
||||
}));
|
||||
.map((opt) => {
|
||||
const templatesInCategory = items.filter((item) => item.template_category === opt.value);
|
||||
return {
|
||||
key: `group-template:${opt.value}`,
|
||||
title: (
|
||||
<Space>
|
||||
<span>{opt.label}</span>
|
||||
<Tag color="default" style={{ margin: 0, fontSize: 11 }}>{templateCounts[opt.value] ?? 0}</Tag>
|
||||
</Space>
|
||||
),
|
||||
selectable: false,
|
||||
children: templatesInCategory.map((t) => ({
|
||||
key: t.id,
|
||||
title: (
|
||||
<div className="template-tree-item">
|
||||
<span className="template-tree-name">{t.name}</span>
|
||||
{t.enabled ? null : <Tag color="default" style={{ fontSize: 10, marginLeft: 4 }}>停</Tag>}
|
||||
</div>
|
||||
),
|
||||
isLeaf: true,
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
const industryChildren: DataNode[] = industryCategoryOptions
|
||||
.filter((opt) => industryCounts[opt.value])
|
||||
.map((opt) => ({
|
||||
key: `industry:${opt.value}`,
|
||||
title: (
|
||||
<Space>
|
||||
<span>{opt.label}</span>
|
||||
<Tag color="default" style={{ margin: 0 }}>{industryCounts[opt.value] ?? 0}</Tag>
|
||||
</Space>
|
||||
),
|
||||
}));
|
||||
.map((opt) => {
|
||||
const templatesInCategory = items.filter((item) => item.industry_category === opt.value);
|
||||
return {
|
||||
key: `group-industry:${opt.value}`,
|
||||
title: (
|
||||
<Space>
|
||||
<span>{opt.label}</span>
|
||||
<Tag color="default" style={{ margin: 0, fontSize: 11 }}>{industryCounts[opt.value] ?? 0}</Tag>
|
||||
</Space>
|
||||
),
|
||||
selectable: false,
|
||||
children: templatesInCategory.map((t) => ({
|
||||
key: t.id,
|
||||
title: (
|
||||
<div className="template-tree-item">
|
||||
<span className="template-tree-name">{t.name}</span>
|
||||
{t.enabled ? null : <Tag color="default" style={{ fontSize: 10, marginLeft: 4 }}>停</Tag>}
|
||||
</div>
|
||||
),
|
||||
isLeaf: true,
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
return [
|
||||
{
|
||||
@@ -270,13 +327,14 @@ export default function TemplatesPage() {
|
||||
title: (
|
||||
<Space>
|
||||
<span>全部模板</span>
|
||||
<Tag color="blue" style={{ margin: 0 }}>{items.length}</Tag>
|
||||
<Tag color="blue" style={{ margin: 0, fontSize: 11 }}>{items.length}</Tag>
|
||||
</Space>
|
||||
),
|
||||
selectable: false,
|
||||
children: [
|
||||
{ key: "group-contract", title: <strong style={{ color: "#1677ff" }}>按合同类型</strong>, children: contractChildren, selectable: false },
|
||||
{ key: "group-template", title: <strong style={{ color: "#1677ff" }}>按模板分类</strong>, children: templateChildren, selectable: false },
|
||||
{ key: "group-industry", title: <strong style={{ color: "#1677ff" }}>按行业入口</strong>, children: industryChildren, selectable: false },
|
||||
{ key: "group-contract", title: <span className="tree-group-title">按合同类型</span>, selectable: false, children: contractChildren },
|
||||
{ key: "group-template", title: <span className="tree-group-title">按模板分类</span>, selectable: false, children: templateChildren },
|
||||
{ key: "group-industry", title: <span className="tree-group-title">按行业入口</span>, selectable: false, children: industryChildren },
|
||||
],
|
||||
},
|
||||
];
|
||||
@@ -284,10 +342,8 @@ export default function TemplatesPage() {
|
||||
|
||||
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]));
|
||||
}
|
||||
if (key.startsWith("group-") || key === ALL_KEY) return;
|
||||
setSelectedId(key);
|
||||
};
|
||||
|
||||
const handleOpenDrawer = (template?: TemplateItem) => {
|
||||
@@ -330,6 +386,11 @@ export default function TemplatesPage() {
|
||||
messageApi.success(editingTemplate ? "模板已更新并生成新版本" : "模板已创建");
|
||||
handleCloseDrawer();
|
||||
await loadTemplates();
|
||||
if (editingTemplate) {
|
||||
setSelectedId(editingTemplate.id);
|
||||
} else if (result.data?.id) {
|
||||
setSelectedId(result.data.id);
|
||||
}
|
||||
} catch {
|
||||
messageApi.error("保存模板失败,请稍后重试");
|
||||
} finally {
|
||||
@@ -342,6 +403,9 @@ export default function TemplatesPage() {
|
||||
try {
|
||||
await deleteTemplate(templateId);
|
||||
messageApi.success("模板已删除");
|
||||
if (selectedId === templateId) {
|
||||
setSelectedId(items.find((item) => item.id !== templateId)?.id ?? null);
|
||||
}
|
||||
await loadTemplates();
|
||||
} catch {
|
||||
messageApi.error("删除模板失败,请稍后重试");
|
||||
@@ -418,30 +482,11 @@ export default function TemplatesPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleViewVersions = async (item: TemplateItem) => {
|
||||
setVersionTemplate(item);
|
||||
setVersionDrawerOpen(true);
|
||||
setVersionLoading(true);
|
||||
try {
|
||||
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);
|
||||
if (!selectedTemplate) return;
|
||||
setRollbackingId(versionLabel);
|
||||
try {
|
||||
const result = await rollbackTemplate(versionTemplate.id, {
|
||||
const result = await rollbackTemplate(selectedTemplate.id, {
|
||||
target_version_label: versionLabel,
|
||||
change_summary: `Rollback to ${versionLabel}`,
|
||||
});
|
||||
@@ -451,28 +496,14 @@ export default function TemplatesPage() {
|
||||
}
|
||||
messageApi.success(`模板已回滚为新版本,来源 ${versionLabel}`);
|
||||
await loadTemplates();
|
||||
await handleViewVersions(versionTemplate);
|
||||
await loadVersions(selectedTemplate.id);
|
||||
} catch (error) {
|
||||
messageApi.error(error instanceof Error ? error.message : "模板回滚失败,请稍后重试");
|
||||
} finally {
|
||||
setRollbackingVersion(null);
|
||||
setRollbackingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
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 "模板列表";
|
||||
};
|
||||
|
||||
if (currentUser?.role !== "admin") {
|
||||
return (
|
||||
<main className="page-shell templates-page">
|
||||
@@ -487,117 +518,202 @@ export default function TemplatesPage() {
|
||||
return (
|
||||
<main className="page-shell templates-page">
|
||||
{contextHolder}
|
||||
<div className="doc-layout-shell">
|
||||
<div className="template-layout">
|
||||
{/* 左侧树形菜单 */}
|
||||
<aside className="doc-sidebar">
|
||||
<Input
|
||||
allowClear
|
||||
prefix={<SearchOutlined />}
|
||||
placeholder="搜索模板名称"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="doc-sidebar-search"
|
||||
/>
|
||||
<div className="doc-sidebar-tree">
|
||||
<Tree
|
||||
showIcon={false}
|
||||
switcherIcon={({ expanded }) => (expanded ? <CaretDownOutlined /> : <CaretRightOutlined />)}
|
||||
treeData={treeData}
|
||||
expandedKeys={expandedKeys}
|
||||
selectedKeys={[selectedCategory]}
|
||||
onExpand={setExpandedKeys}
|
||||
onSelect={(_keys, info) => handleTreeSelect(_keys, info)}
|
||||
blockNode
|
||||
<aside className="template-sidebar">
|
||||
<div className="template-sidebar-header">
|
||||
<Input
|
||||
allowClear
|
||||
prefix={<SearchOutlined style={{ color: "#8c8c8c" }} />}
|
||||
placeholder="搜索模板..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="template-search"
|
||||
/>
|
||||
</div>
|
||||
<div style={{ marginTop: 16 }}>
|
||||
<Button block onClick={() => void handleSeedDefaults()} loading={seeding}>
|
||||
一键初始化默认模板
|
||||
<div className="template-sidebar-tree">
|
||||
{loading ? (
|
||||
<div className="template-loading"><Spin /></div>
|
||||
) : (
|
||||
<Tree
|
||||
showIcon={false}
|
||||
switcherIcon={({ expanded }) => expanded ? <CaretDownOutlined style={{ fontSize: 12 }} /> : <CaretRightOutlined style={{ fontSize: 12 }} />}
|
||||
treeData={treeData}
|
||||
expandedKeys={expandedKeys}
|
||||
selectedKeys={selectedId ? [selectedId] : []}
|
||||
onExpand={setExpandedKeys}
|
||||
onSelect={handleTreeSelect}
|
||||
blockNode
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="template-sidebar-actions">
|
||||
<Button type="primary" icon={<PlusOutlined />} block onClick={() => handleOpenDrawer()}>
|
||||
新建模板
|
||||
</Button>
|
||||
<Button icon={<FileTextOutlined />} block loading={seeding} onClick={() => void handleSeedDefaults()} style={{ marginTop: 8 }}>
|
||||
初始化默认模板
|
||||
</Button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* 右侧模板列表 */}
|
||||
<section className="doc-main-content">
|
||||
<div className="doc-content-header" style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
|
||||
<div>
|
||||
<Title level={2} style={{ margin: 0 }}>{getCategoryLabel()}</Title>
|
||||
<Text type="secondary">{filteredItems.length} 个模板</Text>
|
||||
</div>
|
||||
<Button type="primary" onClick={() => handleOpenDrawer()}>
|
||||
新建模板
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 右侧模板详情 */}
|
||||
<section className="template-detail">
|
||||
{loading ? (
|
||||
<div className="doc-loading-panel">
|
||||
<Spin />
|
||||
<Text type="secondary">正在加载模板列表...</Text>
|
||||
<div className="template-loading-panel">
|
||||
<Spin size="large" />
|
||||
<Text type="secondary">加载中...</Text>
|
||||
</div>
|
||||
) : filteredItems.length > 0 ? (
|
||||
<List
|
||||
dataSource={filteredItems}
|
||||
renderItem={(item) => (
|
||||
<List.Item className="doc-list-item" style={{ flexDirection: "column", alignItems: "stretch" }}>
|
||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start" }}>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<Space wrap size={[6, 4]}>
|
||||
<span className="doc-item-name">{item.name}</span>
|
||||
<Tag color={item.enabled ? "green" : "default"}>{item.enabled ? "启用" : "停用"}</Tag>
|
||||
<Tag color={item.published_at ? "blue" : "default"}>{item.published_at ? "已发布" : "未发布"}</Tag>
|
||||
{item.template_code && <Text code>{item.template_code}</Text>}
|
||||
</Space>
|
||||
<div style={{ marginTop: 4 }}>
|
||||
<Space wrap size={[4, 4]}>
|
||||
<Tag>{contractTypeLabelMap[item.contract_type ?? ""] ?? item.contract_type ?? "未分类"}</Tag>
|
||||
<Tag color="geekblue">{formatTemplateCategory(item.template_category)}</Tag>
|
||||
<Tag color="purple">{formatIndustryCategory(item.industry_category)}</Tag>
|
||||
<Tag>v{item.current_version_label}</Tag>
|
||||
{item.version_count > 1 && <Text type="secondary" style={{ fontSize: 12 }}>({item.version_count} 个版本)</Text>}
|
||||
</Space>
|
||||
</div>
|
||||
{item.description && (
|
||||
<Paragraph type="secondary" ellipsis={{ rows: 1 }} style={{ margin: "4px 0 0" }}>
|
||||
{item.description}
|
||||
</Paragraph>
|
||||
)}
|
||||
</div>
|
||||
<Space style={{ marginLeft: 16, flexShrink: 0 }}>
|
||||
<Switch
|
||||
) : selectedTemplate ? (
|
||||
<div className="template-detail-content">
|
||||
{/* 头部信息 */}
|
||||
<header className="template-detail-header">
|
||||
<div className="template-detail-title-row">
|
||||
<Title level={3} style={{ margin: 0 }}>{selectedTemplate.name}</Title>
|
||||
<Space size={8}>
|
||||
<Switch
|
||||
checked={selectedTemplate.enabled}
|
||||
loading={statusChangingId === selectedTemplate.id}
|
||||
onChange={(checked) => void handleToggleStatus(selectedTemplate, checked)}
|
||||
checkedChildren="启用"
|
||||
unCheckedChildren="停用"
|
||||
/>
|
||||
<Button icon={<EditOutlined />} onClick={() => handleOpenDrawer(selectedTemplate)}>
|
||||
编辑
|
||||
</Button>
|
||||
{selectedTemplate.published_at ? (
|
||||
<Button loading={publishingId === selectedTemplate.id} onClick={() => void handleUnpublish(selectedTemplate)}>
|
||||
下线
|
||||
</Button>
|
||||
) : (
|
||||
<Button type="primary" loading={publishingId === selectedTemplate.id} onClick={() => void handlePublish(selectedTemplate)}>
|
||||
发布
|
||||
</Button>
|
||||
)}
|
||||
<Popconfirm title="确认删除此模板?" okText="删除" cancelText="取消" onConfirm={() => void handleDelete(selectedTemplate.id)}>
|
||||
<Button danger loading={deletingId === selectedTemplate.id}>
|
||||
删除
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
</div>
|
||||
<div className="template-detail-meta">
|
||||
<Space size={[8, 8]} wrap>
|
||||
<Tag color={selectedTemplate.enabled ? "green" : "default"}>{selectedTemplate.enabled ? "启用中" : "已停用"}</Tag>
|
||||
<Tag color={selectedTemplate.published_at ? "blue" : "default"}>{selectedTemplate.published_at ? "已发布" : "未发布"}</Tag>
|
||||
{selectedTemplate.template_code && <Tag><Text code style={{ fontSize: 12 }}>{selectedTemplate.template_code}</Text></Tag>}
|
||||
<Tag>v{selectedTemplate.current_version_label}</Tag>
|
||||
</Space>
|
||||
<Space size={[8, 8]} wrap>
|
||||
<Tag>{contractTypeLabelMap[selectedTemplate.contract_type ?? ""] ?? "未分类"}</Tag>
|
||||
<Tag color="geekblue">{formatTemplateCategory(selectedTemplate.template_category)}</Tag>
|
||||
<Tag color="purple">{formatIndustryCategory(selectedTemplate.industry_category)}</Tag>
|
||||
</Space>
|
||||
</div>
|
||||
{selectedTemplate.description && (
|
||||
<Paragraph type="secondary" style={{ margin: "12px 0 0" }}>
|
||||
{selectedTemplate.description}
|
||||
</Paragraph>
|
||||
)}
|
||||
</header>
|
||||
|
||||
{/* 正文和变量并排 */}
|
||||
<div className="template-detail-body">
|
||||
{/* 模板正文 */}
|
||||
<div className="template-detail-section template-content-section">
|
||||
<div className="template-section-header">
|
||||
<Text strong>模板正文</Text>
|
||||
<Tooltip title="复制全文">
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
checked={item.enabled}
|
||||
loading={statusChangingId === item.id}
|
||||
onChange={(checked) => void handleToggleStatus(item, checked)}
|
||||
icon={<CopyOutlined />}
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(selectedTemplate.content);
|
||||
messageApi.success("已复制到剪贴板");
|
||||
}}
|
||||
/>
|
||||
<Button size="small" onClick={() => void handleViewVersions(item)}>
|
||||
版本
|
||||
</Button>
|
||||
<Button size="small" onClick={() => handleOpenDrawer(item)}>
|
||||
编辑
|
||||
</Button>
|
||||
{item.published_at ? (
|
||||
<Button size="small" loading={publishingId === item.id} onClick={() => void handleUnpublish(item)}>
|
||||
下线
|
||||
</Button>
|
||||
) : (
|
||||
<Button size="small" type="primary" loading={publishingId === item.id} onClick={() => void handlePublish(item)}>
|
||||
发布
|
||||
</Button>
|
||||
)}
|
||||
<Popconfirm title="确认删除这个模板?" okText="删除" cancelText="取消" onConfirm={() => void handleDelete(item.id)}>
|
||||
<Button danger size="small" loading={deletingId === item.id}>
|
||||
删除
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</List.Item>
|
||||
)}
|
||||
/>
|
||||
<div className="template-content-box">
|
||||
<pre className="template-content-text">{selectedTemplate.content}</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 变量定义 */}
|
||||
<div className="template-detail-section template-vars-section">
|
||||
<div className="template-section-header">
|
||||
<Text strong>变量定义</Text>
|
||||
<Tag>{selectedTemplate.variables.length} 个</Tag>
|
||||
</div>
|
||||
{selectedTemplate.variables.length > 0 ? (
|
||||
<div className="template-vars-list">
|
||||
{selectedTemplate.variables.map((v, i) => (
|
||||
<div key={i} className="template-var-item">
|
||||
<code className="template-var-name">{"{{"}{v.name}{"}}"}</code>
|
||||
<Text type="secondary">{v.label}</Text>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<Text type="secondary">暂无变量定义</Text>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 版本历史 */}
|
||||
<div className="template-detail-section">
|
||||
<div className="template-section-header">
|
||||
<Space>
|
||||
<HistoryOutlined />
|
||||
<Text strong>版本历史</Text>
|
||||
</Space>
|
||||
<Tag>{versionHistory.length} 个版本</Tag>
|
||||
</div>
|
||||
{versionLoading ? (
|
||||
<div style={{ textAlign: "center", padding: 24 }}><Spin /></div>
|
||||
) : versionHistory.length > 0 ? (
|
||||
<div className="template-version-list">
|
||||
{versionHistory.map((version, index) => (
|
||||
<div key={version.id} className={`template-version-item ${version.is_current ? "current" : ""}`}>
|
||||
<div className="template-version-main">
|
||||
<Space>
|
||||
<Tag color={version.is_current ? "green" : "default"}>{version.version_label}</Tag>
|
||||
{version.is_current && <Tag color="blue">当前</Tag>}
|
||||
</Space>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
{formatDateTime(version.created_at)} · {version.created_by_name || version.created_by || "未知"}
|
||||
</Text>
|
||||
</div>
|
||||
<div className="template-version-desc">
|
||||
<Text>{version.change_summary || "无变更说明"}</Text>
|
||||
{index > 0 && (
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
icon={<RollbackOutlined />}
|
||||
loading={rollbackingId === version.version_label}
|
||||
onClick={() => void handleRollback(version.version_label)}
|
||||
>
|
||||
回滚
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<Text type="secondary">暂无版本历史</Text>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="没有匹配的模板">
|
||||
<Button onClick={() => { setSearchQuery(""); setSelectedCategory(ALL_KEY); }}>清空筛选</Button>
|
||||
</Empty>
|
||||
<div className="template-empty">
|
||||
<Empty
|
||||
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||
description="请从左侧选择模板查看详情"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
@@ -627,7 +743,7 @@ export default function TemplatesPage() {
|
||||
<Input.TextArea rows={2} placeholder="说明适用场景、边界或使用注意事项" />
|
||||
</Form.Item>
|
||||
<Form.Item label="模板正文" name="content" rules={[{ required: true, message: "请输入模板正文" }]}>
|
||||
<Input.TextArea rows={6} placeholder="请输入合同模板正文" />
|
||||
<Input.TextArea rows={8} placeholder="请输入合同模板正文,使用 {{变量名}} 标记可替换变量" style={{ fontFamily: "monospace" }} />
|
||||
</Form.Item>
|
||||
<Form.Item label={editingTemplate ? "本次变更说明" : "初始版本说明"} name="change_summary">
|
||||
<Input placeholder={editingTemplate ? "例如:补充采购行业分类" : "例如:首次建立采购模板"} />
|
||||
@@ -663,7 +779,7 @@ export default function TemplatesPage() {
|
||||
<Switch checkedChildren="启用" unCheckedChildren="停用" />
|
||||
</Form.Item>
|
||||
|
||||
<Space>
|
||||
<Space style={{ marginTop: 16 }}>
|
||||
<Button type="primary" htmlType="submit" loading={submitting}>
|
||||
{editingTemplate ? "保存并生成新版本" : "创建模板"}
|
||||
</Button>
|
||||
@@ -672,49 +788,247 @@ export default function TemplatesPage() {
|
||||
</Form>
|
||||
</Drawer>
|
||||
|
||||
{/* 版本历史抽屉 */}
|
||||
<Drawer
|
||||
title={versionTemplate ? `版本历史 - ${versionTemplate.name}` : "版本历史"}
|
||||
open={versionDrawerOpen}
|
||||
onClose={() => { setVersionDrawerOpen(false); setVersionHistory([]); setVersionTemplate(null); }}
|
||||
width={720}
|
||||
>
|
||||
{versionLoading ? (
|
||||
<div style={{ textAlign: "center", padding: 48 }}><Spin /></div>
|
||||
) : versionHistory.length > 0 ? (
|
||||
<List
|
||||
dataSource={versionHistory}
|
||||
renderItem={(version, index) => (
|
||||
<List.Item
|
||||
actions={
|
||||
index > 0
|
||||
? [<Button key="rollback" size="small" loading={rollbackingVersion === `${versionTemplate?.id}:${version.version_label}`} onClick={() => void handleRollback(version.version_label)}>回滚</Button>]
|
||||
: []
|
||||
}
|
||||
>
|
||||
<List.Item.Meta
|
||||
title={
|
||||
<Space>
|
||||
<Tag color={version.is_current ? "green" : "default"}>{version.version_label}</Tag>
|
||||
{version.is_current && <Tag color="blue">当前</Tag>}
|
||||
</Space>
|
||||
}
|
||||
description={
|
||||
<Space direction="vertical" size={2}>
|
||||
<Text>{version.change_summary || "无变更说明"}</Text>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
{formatDateTime(version.created_at)} · {version.created_by_name || version.created_by || "未知"}
|
||||
</Text>
|
||||
</Space>
|
||||
}
|
||||
/>
|
||||
</List.Item>
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<Empty description="暂无版本历史" />
|
||||
)}
|
||||
</Drawer>
|
||||
<style jsx global>{`
|
||||
.template-layout {
|
||||
display: flex;
|
||||
height: calc(100vh - 64px);
|
||||
background: #f5f7fa;
|
||||
}
|
||||
|
||||
.template-sidebar {
|
||||
width: 280px;
|
||||
background: #fff;
|
||||
border-right: 1px solid #e8e8e8;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.template-sidebar-header {
|
||||
padding: 16px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.template-search {
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.template-search .ant-input {
|
||||
background: #f5f7fa;
|
||||
}
|
||||
|
||||
.template-sidebar-tree {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.template-sidebar-tree .ant-tree {
|
||||
background: transparent;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.template-sidebar-tree .ant-tree-node-content-wrapper {
|
||||
padding: 4px 8px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.template-sidebar-tree .ant-tree-node-selected {
|
||||
background: #e6f4ff !important;
|
||||
}
|
||||
|
||||
.template-tree-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.template-tree-name {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tree-group-title {
|
||||
font-weight: 600;
|
||||
color: #1677ff;
|
||||
font-size: 12px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.template-sidebar-actions {
|
||||
padding: 16px;
|
||||
border-top: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.template-loading {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 200px;
|
||||
}
|
||||
|
||||
.template-detail {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.template-loading-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.template-empty {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.template-detail-content {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.template-detail-header {
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
padding: 24px;
|
||||
margin-bottom: 16px;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.template-detail-title-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.template-detail-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.template-detail-body {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 320px;
|
||||
gap: 16px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
.template-detail-body {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.template-detail-section {
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
padding: 20px;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.template-section-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
padding-bottom: 12px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.template-content-section {
|
||||
min-height: 300px;
|
||||
}
|
||||
|
||||
.template-content-box {
|
||||
background: #fafafa;
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
max-height: 400px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.template-content-text {
|
||||
margin: 0;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
font-family: "SF Mono", "Monaco", "Consolas", monospace;
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
color: #262626;
|
||||
}
|
||||
|
||||
.template-vars-section {
|
||||
height: fit-content;
|
||||
}
|
||||
|
||||
.template-vars-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.template-var-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 10px 12px;
|
||||
background: #f5f7fa;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.template-var-name {
|
||||
font-family: "SF Mono", "Monaco", "Consolas", monospace;
|
||||
font-size: 13px;
|
||||
background: #fff;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
color: #1677ff;
|
||||
border: 1px solid #d9d9d9;
|
||||
}
|
||||
|
||||
.template-version-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.template-version-item {
|
||||
padding: 12px 16px;
|
||||
background: #fafafa;
|
||||
border-radius: 8px;
|
||||
border-left: 3px solid #d9d9d9;
|
||||
}
|
||||
|
||||
.template-version-item.current {
|
||||
background: #f6ffed;
|
||||
border-left-color: #52c41a;
|
||||
}
|
||||
|
||||
.template-version-main {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.template-version-desc {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
`}</style>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user