feat: redesign documents/new page with tree sidebar + list layout

This commit is contained in:
2026-06-01 12:21:16 +08:00
parent f7c6d10928
commit 0ca699d901
2 changed files with 281 additions and 182 deletions
+158 -182
View File
@@ -1,13 +1,24 @@
"use client";
import {
AppstoreOutlined,
BankOutlined,
CaretDownOutlined,
CaretRightOutlined,
FileAddOutlined,
FilterOutlined,
SearchOutlined,
} from "@ant-design/icons";
import { Alert, Button, Empty, Input, Select, Space, Spin, Tag, Typography, message } from "antd";
import {
Button,
Empty,
Input,
List,
Space,
Spin,
Tag,
Tree,
Typography,
message,
} from "antd";
import type { DataNode } from "antd/es/tree";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useRouter } from "next/navigation";
@@ -17,7 +28,7 @@ import {
type LegalDocumentTypeItem,
} from "@/services/legal-document";
const { Title, Text, Paragraph } = Typography;
const { Title, Text } = Typography;
const documentNameMap: Record<string, string> = {
procurement_goods: "货物买卖合同",
@@ -45,13 +56,6 @@ const categoryLabelMap: Record<string, string> = {
"notices-letters": "通知函件",
};
const industryLabelMap: Record<string, string> = {
"general-enterprise": "通用企业",
technology: "科技互联网",
"professional-services": "专业服务",
"real-estate": "房地产",
};
const scenarioLabelMap: Record<string, string> = {
"procurement-supply": "采购供应",
"technical-service": "技术服务",
@@ -88,9 +92,7 @@ const riskLabelMap: Record<string, string> = {
};
function formatLabel(map: Record<string, string>, value?: string | null) {
if (!value) {
return "-";
}
if (!value) return "-";
return map[value] ?? value;
}
@@ -98,50 +100,7 @@ function formatDocumentName(item: LegalDocumentTypeItem) {
return documentNameMap[item.code] ?? item.name;
}
function buildOptions(values: string[], map: Record<string, string>) {
return values.map((value) => ({
value,
label: formatLabel(map, value),
}));
}
function DocumentTypeCard({
item,
onOpen,
}: {
item: LegalDocumentTypeItem;
onOpen: (code: string) => void;
}) {
return (
<article className="document-type-card">
<div className="document-type-card-main">
<div className="document-type-card-icon">
<FileAddOutlined />
</div>
<div>
<Space wrap size={[6, 6]}>
<Tag color="blue">{formatLabel(categoryLabelMap, item.category)}</Tag>
<Tag>{formatLabel(industryLabelMap, item.industry)}</Tag>
<Tag color={riskColorMap[item.risk_level] ?? "default"}>{riskLabelMap[item.risk_level] ?? item.risk_level}</Tag>
</Space>
<Title level={4}>{formatDocumentName(item)}</Title>
<Paragraph type="secondary" ellipsis={{ rows: 2 }}>
{item.description || "按结构化要素生成法律文书,生成后可继续进入审查、问答和修改流程。"}
</Paragraph>
</div>
</div>
<div className="document-type-card-footer">
<div>
<Text type="secondary">场景</Text>
<strong>{formatLabel(scenarioLabelMap, item.scenario)}</strong>
</div>
<Button type="primary" icon={<FileAddOutlined />} onClick={() => onOpen(item.code)}>
开始起草
</Button>
</div>
</article>
);
}
const ALL_KEY = "__all__";
export default function NewDocumentPage() {
const [messageApi, contextHolder] = message.useMessage();
@@ -153,20 +112,14 @@ export default function NewDocumentPage() {
industries: [],
scenarios: [],
});
const [query, setQuery] = useState("");
const [category, setCategory] = useState<string | undefined>();
const [industry, setIndustry] = useState<string | undefined>();
const [scenario, setScenario] = useState<string | undefined>();
const [searchQuery, setSearchQuery] = useState("");
const [selectedCategory, setSelectedCategory] = useState<string>(ALL_KEY);
const [expandedKeys, setExpandedKeys] = useState<React.Key[]>([ALL_KEY]);
const loadDocumentTypes = useCallback(async () => {
setLoading(true);
try {
const result = await listLegalDocumentTypes({
query,
category,
industry,
scenario,
});
const result = await listLegalDocumentTypes({ active_only: true });
if (result.code !== 0) {
messageApi.error(result.message || "文书类型加载失败");
return;
@@ -178,141 +131,164 @@ export default function NewDocumentPage() {
} finally {
setLoading(false);
}
}, [category, industry, messageApi, query, scenario]);
}, [messageApi]);
useEffect(() => {
const timer = window.setTimeout(() => {
void loadDocumentTypes();
}, 0);
const timer = window.setTimeout(() => void loadDocumentTypes(), 0);
return () => window.clearTimeout(timer);
}, [loadDocumentTypes]);
const categoryOptions = useMemo(() => buildOptions(facets.categories, categoryLabelMap), [facets.categories]);
const industryOptions = useMemo(() => buildOptions(facets.industries, industryLabelMap), [facets.industries]);
const scenarioOptions = useMemo(() => buildOptions(facets.scenarios, scenarioLabelMap), [facets.scenarios]);
const filteredItems = useMemo(() => {
let result = items;
if (selectedCategory !== ALL_KEY) {
result = result.filter((item) => item.category === selectedCategory);
}
if (searchQuery.trim()) {
const q = searchQuery.toLowerCase();
result = result.filter(
(item) =>
formatDocumentName(item).toLowerCase().includes(q) ||
item.code.toLowerCase().includes(q) ||
(item.description ?? "").toLowerCase().includes(q),
);
}
return result;
}, [items, selectedCategory, searchQuery]);
const handleReset = () => {
setQuery("");
setCategory(undefined);
setIndustry(undefined);
setScenario(undefined);
const treeData = useMemo((): DataNode[] => {
const categoryCounts: Record<string, number> = {};
for (const item of items) {
categoryCounts[item.category] = (categoryCounts[item.category] || 0) + 1;
}
const children: DataNode[] = Object.entries(categoryLabelMap)
.map(([key, label]) => ({
key,
title: (
<Space>
<span>{label}</span>
<Tag color="default" style={{ margin: 0 }}>{categoryCounts[key] ?? 0}</Tag>
</Space>
),
}))
.sort((a, b) => (categoryCounts[a.key as string] ?? 0) - (categoryCounts[b.key as string] ?? 0));
return [
{
key: ALL_KEY,
title: (
<Space>
<span>全部文书</span>
<Tag color="blue" style={{ margin: 0 }}>{items.length}</Tag>
</Space>
),
children,
},
];
}, [items]);
const handleTreeSelect = (_selectedKeys: React.Key[], info: { node: DataNode }) => {
const key = info.node.key as string;
setSelectedCategory(key);
setExpandedKeys((prev) => (prev.includes(key) ? prev : [...prev, key]));
};
return (
<main className="page-shell documents-page">
{contextHolder}
<Space direction="vertical" size="large" style={{ width: "100%" }}>
<section className="review-page-hero">
<div className="review-page-hero-copy">
<span className="landing-badge">法律文书</span>
<Title level={2}>按业务场景起草法律文书</Title>
<Text type="secondary">
从文书类型、行业和使用场景进入,再填写动态要素。系统会保存草稿,并把生成来源、待补字段、AI 提醒和相关法条带到结果页。
</Text>
</div>
<div className="documents-hero-panel">
<span className="landing-panel-eyebrow">文书入口</span>
<Title level={4}>文书类型入口</Title>
<div className="documents-hero-metrics">
<div>
<strong>{items.length}</strong>
<span>当前可用类型</span>
</div>
<div>
<strong>{facets.categories.length}</strong>
<span>分类入口</span>
</div>
<div>
<strong>{facets.scenarios.length}</strong>
<span>业务场景</span>
</div>
</div>
</div>
</section>
<section className="document-filter-band">
<div className="doc-layout-shell">
{/* 左侧树形菜单 */}
<aside className="doc-sidebar">
<Input
allowClear
prefix={<SearchOutlined />}
placeholder="搜索文书名称、类型代码或说明"
value={query}
onChange={(event) => setQuery(event.target.value)}
onPressEnter={() => void loadDocumentTypes()}
placeholder="搜索文书名称"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="doc-sidebar-search"
/>
<Select
allowClear
placeholder="分类"
value={category}
options={categoryOptions}
onChange={setCategory}
/>
<Select
allowClear
placeholder="行业"
value={industry}
options={industryOptions}
onChange={setIndustry}
/>
<Select
allowClear
placeholder="场景"
value={scenario}
options={scenarioOptions}
onChange={setScenario}
/>
<Button icon={<FilterOutlined />} onClick={() => void loadDocumentTypes()}>
筛选
</Button>
<Button onClick={handleReset}>重置</Button>
</section>
<Alert
showIcon
type="info"
message="这里不是模板列表,而是按法律文书场景进入生成流程。"
description="选定文书类型后,下一页会读取该类型的动态要素 schema,支持草稿保存、恢复和提交生成。"
/>
{loading ? (
<div className="document-loading-panel">
<Spin />
<Text type="secondary">正在加载文书类型...</Text>
<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
/>
</div>
) : items.length ? (
<section className="document-type-grid">
{items.map((item) => (
<DocumentTypeCard
item={item}
key={item.code}
onOpen={(code) => router.push(`/documents/new/${code}`)}
/>
))}
</section>
) : (
<div className="document-empty-panel">
</aside>
{/* 右侧文书列表 */}
<section className="doc-main-content">
<div className="doc-content-header">
<div>
<Title level={2} style={{ margin: 0 }}>
{selectedCategory === ALL_KEY ? "全部文书" : formatLabel(categoryLabelMap, selectedCategory)}
</Title>
<Text type="secondary">{filteredItems.length} 个可用类型</Text>
</div>
</div>
{loading ? (
<div className="doc-loading-panel">
<Spin />
<Text type="secondary">正在加载文书类型...</Text>
</div>
) : filteredItems.length > 0 ? (
<List
dataSource={filteredItems}
renderItem={(item) => (
<List.Item
className="doc-list-item"
actions={[
<Button
key="draft"
type="primary"
icon={<FileAddOutlined />}
onClick={() => router.push(`/documents/new/${item.code}`)}
>
开始起草
</Button>,
]}
>
<List.Item.Meta
title={
<Space wrap size={[6, 4]}>
<span className="doc-item-name">{formatDocumentName(item)}</span>
<Tag color={riskColorMap[item.risk_level] ?? "default"}>
{riskLabelMap[item.risk_level] ?? item.risk_level}
</Tag>
</Space>
}
description={
<Space wrap size={[6, 4]}>
<Tag color="default">{formatLabel(scenarioLabelMap, item.scenario)}</Tag>
<Text type="secondary" ellipsis={{ tooltip: item.description }}>
{item.description || "按结构化要素生成法律文书,生成后可继续进入审查、问答和修改流程。"}
</Text>
</Space>
}
/>
</List.Item>
)}
/>
) : (
<Empty
image={Empty.PRESENTED_IMAGE_SIMPLE}
description="当前没有匹配的文书类型"
>
<Space>
<Button onClick={handleReset}>清空筛选</Button>
<Button type="primary" icon={<AppstoreOutlined />} onClick={() => router.push("/templates")}>
查看模板管理
</Button>
</Space>
<Button onClick={() => { setSearchQuery(""); setSelectedCategory(ALL_KEY); }}>
清空筛选
</Button>
</Empty>
</div>
)}
<section className="document-next-band">
<div>
<BankOutlined />
<strong>继续处理</strong>
<span>生成文书后,可以进入审查记录继续查看风险、下载报告或追问条款。</span>
</div>
<Button onClick={() => router.push("/reviews")}>查看审查记录</Button>
)}
</section>
</Space>
</div>
</main>
);
}
+123
View File
@@ -6901,3 +6901,126 @@ textarea.ant-input:focus,
grid-template-columns: 1fr !important;
}
}
/* Documents page — tree sidebar + list layout */
.doc-layout-shell {
display: grid;
grid-template-columns: 280px minmax(0, 1fr);
gap: 20px;
align-items: start;
margin-top: 16px;
}
.doc-sidebar {
position: sticky;
top: 84px;
display: flex;
flex-direction: column;
gap: 12px;
padding: 16px;
border-radius: var(--radius-card);
background: var(--surface-elevated);
border: 1px solid var(--border);
box-shadow: var(--card-shadow);
}
.doc-sidebar-search {
min-height: 40px !important;
border-radius: var(--radius-control) !important;
}
.doc-sidebar-search input {
border-radius: var(--radius-control) !important;
}
.doc-sidebar-tree {
overflow: auto;
max-height: calc(100vh - 200px);
}
.doc-sidebar-tree .ant-tree {
font-size: 14px;
}
.doc-sidebar-tree .ant-tree-node-content-wrapper {
padding: 6px 8px !important;
border-radius: 6px !important;
}
.doc-sidebar-tree .ant-tree-treenode-selected .ant-tree-node-content-wrapper {
background: var(--surface-tinted) !important;
}
.doc-sidebar-tree .ant-tree-switcher {
color: var(--muted);
}
.doc-main-content {
min-width: 0;
}
.doc-content-header {
margin-bottom: 12px;
}
.doc-content-header h2 {
color: var(--ink);
font-size: 22px;
font-weight: 900;
margin-bottom: 4px;
}
.doc-loading-panel {
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
padding: 48px;
}
.doc-list-item {
padding: 14px 16px !important;
border-radius: var(--radius-card) !important;
border: 1px solid var(--border) !important;
background: var(--surface-elevated) !important;
margin-bottom: 8px !important;
transition: border-color 0.18s ease, box-shadow 0.18s ease, transform 0.18s ease;
}
.doc-list-item:hover {
border-color: var(--primary) !important;
box-shadow: var(--card-shadow-hover) !important;
}
.doc-list-item .ant-list-item-meta-title {
margin-bottom: 4px !important;
font-size: 15px !important;
font-weight: 700 !important;
}
.doc-item-name {
color: var(--ink);
}
.doc-list-item .ant-list-item-meta-description {
margin-bottom: 0 !important;
}
.doc-list-item .ant-list-item-action {
flex-shrink: 0;
}
@media (max-width: 960px) {
.doc-layout-shell {
grid-template-columns: 1fr !important;
}
.doc-sidebar {
position: static;
max-height: 320px;
}
.doc-sidebar-tree {
max-height: 240px;
}
}