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:
2026-06-01 19:21:15 +08:00
parent f8dd1e4524
commit c9bff4c15f
+562 -248
View File
@@ -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>
);
}