feat: redesign documents/new page with tree sidebar + list layout
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user