feat: redesign rules, legal-articles, knowledge-bases pages with tree sidebar

- Rules page: tree menu by contract type + risk level → list layout
- Legal articles page: tree menu by source type + KPI cards → list layout
- Knowledge bases page: tree menu by status + retrieval mode → list layout

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-06-01 17:23:25 +08:00
parent 7f1276b755
commit fdbce39019
3 changed files with 1207 additions and 1568 deletions
File diff suppressed because it is too large Load Diff
+267 -362
View File
@@ -1,8 +1,30 @@
"use client";
"use client";
import { ExperimentOutlined, EyeOutlined, FileSearchOutlined, LinkOutlined, ReloadOutlined, SearchOutlined } from "@ant-design/icons";
import { Alert, Button, Card, Drawer, Empty, Form, Input, Select, Space, Statistic, Table, Tag, Typography, message } from "antd";
import type { ColumnsType, TablePaginationConfig } from "antd/es/table";
import {
CaretDownOutlined,
CaretRightOutlined,
ExperimentOutlined,
EyeOutlined,
FileSearchOutlined,
LinkOutlined,
ReloadOutlined,
SearchOutlined,
} from "@ant-design/icons";
import {
Button,
Drawer,
Empty,
Input,
List,
Select,
Space,
Spin,
Tag,
Tree,
Typography,
message,
} from "antd";
import type { DataNode } from "antd/es/tree";
import Link from "next/link";
import { useCallback, useEffect, useMemo, useState } from "react";
@@ -30,30 +52,14 @@ const defaultFilters: LegalArticleFilterValues = {
published_status: "all",
};
const sourceTypeOptions = [
{ value: "all", label: "全部法源类型" },
{ value: "法律", label: "法律" },
{ value: "行政法规", label: "行政法规" },
{ value: "司法解释", label: "司法解释" },
{ value: "地方法规", label: "地方法规" },
{ value: "部门规章", label: "部门规章" },
{ value: "规范性文件", label: "规范性文件" },
];
const indexStatusOptions = [
{ value: "all", label: "全部索引状态" },
{ value: "indexed", label: "已索引" },
{ value: "pending", label: "待索引" },
{ value: "processing", label: "索引中" },
{ value: "failed", label: "索引失败" },
];
const publishedStatusOptions = [
{ value: "all", label: "全部发布状态" },
{ value: "published", label: "已发布" },
{ value: "draft", label: "草稿" },
{ value: "archived", label: "已归档" },
];
const sourceTypeLabelMap: Record<string, string> = {
法律: "法律",
行政法规: "行政法规",
司法解释: "司法解释",
地方法规: "地方法规",
部门规章: "部门规章",
规范性文件: "规范性文件",
};
const indexStatusMeta: Record<string, { color: string; label: string }> = {
indexed: { color: "green", label: "已索引" },
@@ -69,24 +75,16 @@ const publishedStatusMeta: Record<string, { color: string; label: string }> = {
};
function formatDateTime(value: string | null | undefined) {
if (!value) {
return "-";
}
if (!value) return "-";
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return value;
}
if (Number.isNaN(date.getTime())) return value;
return date.toLocaleString("zh-CN", { hour12: false });
}
function formatDate(value: string | null | undefined) {
if (!value) {
return "-";
}
if (!value) return "-";
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return value;
}
if (Number.isNaN(date.getTime())) return value;
return date.toLocaleDateString("zh-CN");
}
@@ -99,39 +97,25 @@ function renderStatusTag(value: string, metaMap: Record<string, { color: string;
return <Tag color={meta.color}>{meta.label}</Tag>;
}
function normalizeFilters(values: LegalArticleFilterValues): LegalArticleFilterValues {
return {
keyword: values.keyword?.trim() ?? "",
source_id: values.source_id?.trim() ?? "",
source_type: values.source_type ?? "all",
article_no: values.article_no?.trim() ?? "",
index_status: values.index_status ?? "all",
published_status: values.published_status ?? "all",
};
}
function getSemanticSearchReason(item: ChatSourceItem) {
const reason = item.metadata?.match_reason;
return typeof reason === "string" && reason.trim() ? reason : "混合语义命中";
}
const ALL_KEY = "__all__";
export default function LegalArticlesPage() {
const [messageApi, contextHolder] = message.useMessage();
const currentUser = useAuthStore((state) => state.currentUser);
const [form] = Form.useForm<LegalArticleFilterValues>();
const [loading, setLoading] = useState(true);
const [detailLoading, setDetailLoading] = useState(false);
const [items, setItems] = useState<LegalArticleItem[]>([]);
const [filters, setFilters] = useState<LegalArticleFilterValues>(defaultFilters);
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(10);
const [total, setTotal] = useState(0);
const [selectedArticle, setSelectedArticle] = useState<LegalArticleItem | null>(null);
const [drawerOpen, setDrawerOpen] = useState(false);
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(50);
const [filters, setFilters] = useState<LegalArticleFilterValues>(defaultFilters);
const [vectorizing, setVectorizing] = useState(false);
const [semanticQuery, setSemanticQuery] = useState("");
const [semanticSearching, setSemanticSearching] = useState(false);
const [semanticResults, setSemanticResults] = useState<ChatSourceItem[]>([]);
const [vectorizationSummary, setVectorizationSummary] = useState({
total_articles: 0,
vectorized_articles: 0,
@@ -142,9 +126,16 @@ export default function LegalArticlesPage() {
latest_article_title: null as string | null,
});
const currentPageSourceCount = useMemo(() => new Set(items.map((item) => item.source_id)).size, [items]);
const currentPageIndexedCount = useMemo(() => items.filter((item) => item.index_status === "indexed").length, [items]);
const currentPagePublishedCount = useMemo(() => items.filter((item) => item.published_status === "published").length, [items]);
const [selectedArticle, setSelectedArticle] = useState<LegalArticleItem | 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 [semanticQuery, setSemanticQuery] = useState("");
const [semanticSearching, setSemanticSearching] = useState(false);
const [semanticResults, setSemanticResults] = useState<ChatSourceItem[]>([]);
const loadArticles = useCallback(
async (nextPage = page, nextPageSize = pageSize, nextFilters = filters) => {
@@ -179,9 +170,7 @@ export default function LegalArticlesPage() {
const openArticleDetail = useCallback(
async (articleId: string, fallback?: LegalArticleItem) => {
if (fallback) {
setSelectedArticle(fallback);
}
if (fallback) setSelectedArticle(fallback);
setDrawerOpen(true);
setDetailLoading(true);
try {
@@ -201,32 +190,17 @@ export default function LegalArticlesPage() {
);
useEffect(() => {
const timer = window.setTimeout(() => {
void loadArticles();
}, 0);
const timer = window.setTimeout(() => void loadArticles(), 0);
return () => window.clearTimeout(timer);
}, [loadArticles]);
useEffect(() => {
if (currentUser?.role !== "admin" || typeof window === "undefined") {
return;
}
if (currentUser?.role !== "admin" || typeof window === "undefined") return;
const articleId = new URLSearchParams(window.location.search).get("article_id");
if (articleId) {
void openArticleDetail(articleId);
}
if (articleId) void openArticleDetail(articleId);
}, [currentUser?.role, openArticleDetail]);
const handleSearch = (values: LegalArticleFilterValues) => {
const nextFilters = normalizeFilters(values);
setFilters(nextFilters);
setLoading(true);
void loadArticles(1, pageSize, nextFilters);
};
const handleReset = () => {
form.setFieldsValue(defaultFilters);
setFilters(defaultFilters);
setLoading(true);
void loadArticles(1, pageSize, defaultFilters);
@@ -235,11 +209,7 @@ export default function LegalArticlesPage() {
const handleVectorize = async () => {
setVectorizing(true);
try {
const result = await vectorizeLegalArticlesByFilters({
batch_size: 200,
force: false,
...filters,
});
const result = await vectorizeLegalArticlesByFilters({ batch_size: 200, force: false, ...filters });
if (result.code !== 0) {
messageApi.error(result.message || "法条向量化失败");
return;
@@ -276,305 +246,243 @@ export default function LegalArticlesPage() {
}
};
const handleTableChange = (pagination: TablePaginationConfig) => {
const nextPage = pagination.current ?? 1;
const nextPageSize = pagination.pageSize ?? pageSize;
setLoading(true);
void loadArticles(nextPage, nextPageSize, filters);
const filteredItems = useMemo(() => {
let result = items;
if (selectedCategory !== ALL_KEY) {
result = result.filter((item) => item.source_type === selectedCategory);
}
if (searchQuery.trim()) {
const q = searchQuery.toLowerCase();
result = result.filter(
(item) =>
item.source_title.toLowerCase().includes(q) ||
item.article_no.toLowerCase().includes(q) ||
(item.source_id ?? "").toLowerCase().includes(q) ||
item.article_text.toLowerCase().includes(q),
);
}
return result;
}, [items, selectedCategory, searchQuery]);
const treeData = useMemo((): DataNode[] => {
const typeCounts: Record<string, number> = {};
for (const item of items) {
typeCounts[item.source_type] = (typeCounts[item.source_type] || 0) + 1;
}
const children: DataNode[] = Object.entries(sourceTypeLabelMap)
.filter(([key]) => typeCounts[key])
.map(([key, label]) => ({
key,
title: (
<Space>
<span>{label}</span>
<Tag color="default" style={{ margin: 0 }}>{typeCounts[key] ?? 0}</Tag>
</Space>
),
}));
return [
{
key: ALL_KEY,
title: (
<Space>
<span>全部法条</span>
<Tag color="blue" style={{ margin: 0 }}>{total}</Tag>
</Space>
),
children,
},
];
}, [items, total]);
const handleTreeSelect = (_selectedKeys: React.Key[], info: { node: DataNode }) => {
const key = info.node.key as string;
setSelectedCategory(key);
};
const semanticColumns: ColumnsType<ChatSourceItem> = [
{
title: "命中法条",
key: "title",
width: 260,
render: (_, item) => (
<Space direction="vertical" size={2}>
<Text strong>{item.title}</Text>
<Text type="secondary">{item.authority || "-"}</Text>
</Space>
),
},
{
title: "相关度",
dataIndex: "score",
key: "score",
width: 100,
render: (value?: number) => (typeof value === "number" ? value.toFixed(3) : "-"),
},
{
title: "命中原因",
key: "reason",
width: 220,
render: (_, item) => <Text type="secondary">{getSemanticSearchReason(item)}</Text>,
},
{
title: "摘要",
dataIndex: "content",
key: "content",
render: (value: string) => (
<Paragraph ellipsis={{ rows: 2 }} style={{ marginBottom: 0 }}>
{value}
</Paragraph>
),
},
{
title: "操作",
key: "actions",
width: 120,
render: (_, item) => (
<Button disabled={!item.source_id} icon={<EyeOutlined />} size="small" onClick={() => (item.source_id ? void openArticleDetail(item.source_id) : undefined)}>
查看法条
</Button>
),
},
];
const getCategoryLabel = () => {
if (selectedCategory === ALL_KEY) return "全部法条";
return sourceTypeLabelMap[selectedCategory] ?? selectedCategory;
};
const columns: ColumnsType<LegalArticleItem> = [
{
title: "法源",
dataIndex: "source_title",
key: "source_title",
width: 260,
render: (_, item) => (
<Space orientation="vertical" size={2}>
<Text strong>{item.source_title}</Text>
<Text code>{item.source_id}</Text>
<Text type="secondary">{item.authority}</Text>
</Space>
),
},
{
title: "条文编号",
dataIndex: "article_no",
key: "article_no",
width: 120,
render: (value: string) => <Text strong>{value}</Text>,
},
{
title: "章节",
key: "chapter",
width: 220,
render: (_, item) => (
<Space orientation="vertical" size={2}>
<Text>{item.chapter_title || "-"}</Text>
<Text type="secondary">{item.section_title || "-"}</Text>
</Space>
),
},
{
title: "法条摘要",
dataIndex: "article_text",
key: "article_text",
width: 360,
render: (value: string) => (
<Paragraph ellipsis={{ rows: 2 }} style={{ marginBottom: 0 }}>
{value}
</Paragraph>
),
},
{
title: "标签",
dataIndex: "tags",
key: "tags",
width: 180,
render: (value: string[]) => (
<Space wrap size={4}>
{value?.length ? value.slice(0, 3).map((tag) => <Tag key={tag}>{tag}</Tag>) : <Text type="secondary">-</Text>}
{value?.length > 3 ? <Tag>+{value.length - 3}</Tag> : null}
</Space>
),
},
{
title: "知识库吸收",
key: "knowledge",
width: 220,
render: (_, item) => (
<Space orientation="vertical" size={2}>
{renderStatusTag(item.index_status, indexStatusMeta)}
<Text type="secondary">知识库:{item.knowledge_base_id || "-"}</Text>
<Text type="secondary">文档:{item.knowledge_document_id || "-"}</Text>
</Space>
),
},
{
title: "发布状态",
key: "published_status",
width: 160,
render: (_, item) => (
<Space orientation="vertical" size={2}>
{renderStatusTag(item.published_status, publishedStatusMeta)}
<Text type="secondary">{formatDateTime(item.published_at)}</Text>
</Space>
),
},
{
title: "生效范围",
key: "effective",
width: 160,
render: (_, item) => (
<Space orientation="vertical" size={2}>
<Text>{item.region}</Text>
<Text type="secondary">{formatDate(item.effective_date)}</Text>
<Text type="secondary">{item.validity_status || "-"}</Text>
</Space>
),
},
{
title: "操作",
key: "actions",
fixed: "right",
width: 120,
render: (_, item) => (
<Button icon={<EyeOutlined />} size="small" onClick={() => void openArticleDetail(item.id, item)}>
查看全文
</Button>
),
},
];
const indexedCount = useMemo(() => items.filter((item) => item.index_status === "indexed").length, [items]);
const publishedCount = useMemo(() => items.filter((item) => item.published_status === "published").length, [items]);
if (currentUser?.role !== "admin") {
return (
<main className="page-shell review-history-page">
<main className="page-shell legal-articles-page">
{contextHolder}
<Card className="review-list-card">
<Alert title="仅管理员可查看法条全文库。" type="warning" showIcon />
</Card>
<div style={{ padding: 48, textAlign: "center" }}>
<Text type="secondary">仅管理员可查看法条全文库。</Text>
</div>
</main>
);
}
return (
<main className="page-shell review-page-grid">
<main className="page-shell legal-articles-page">
{contextHolder}
<Space orientation="vertical" size="large" style={{ width: "100%" }}>
<section className="review-page-hero">
<div className="review-page-hero-copy">
<span className="landing-badge">Legal Fulltext Library</span>
<Title level={2}>法条全文库</Title>
<Text type="secondary">查看 Phase14 已吸收入库的法律法规全文条款,核对法源、索引状态、发布状态和知识库追溯信息。</Text>
<div className="doc-layout-shell">
{/* 左侧树形菜单 */}
<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
/>
</div>
<Card className="review-side-card" variant="borderless">
<span className="landing-panel-eyebrow">Phase14</span>
<Title level={4} style={{ margin: 0, color: "inherit" }}>
吸收闭环
</Title>
<ul className="review-page-tip-list">
<li>全文条款由治理资产资料包同步到业务运行层。</li>
<li>本页只查看和核验,不替代正式法源导入脚本。</li>
<li>每条法条保留知识库、文档、校验值和发布时间。</li>
</ul>
<Link href="/knowledge-bases">
<Button icon={<FileSearchOutlined />}>查看知识库</Button>
</Link>
</Card>
</section>
<div style={{ marginTop: 16, display: "flex", flexDirection: "column", gap: 8 }}>
<Button block icon={<ExperimentOutlined />} loading={vectorizing} onClick={() => void handleVectorize()}>
补齐向量化
</Button>
<Button block icon={<ReloadOutlined />} onClick={handleReset}>
重置筛选
</Button>
</div>
</aside>
<Space size="middle" wrap>
<Card className="review-surface-card" style={{ minWidth: 180 }}>
<Statistic title="法条总数" value={total} />
</Card>
<Card className="review-surface-card" style={{ minWidth: 180 }}>
<Statistic title="当前页法源" value={currentPageSourceCount} />
</Card>
<Card className="review-surface-card" style={{ minWidth: 180 }}>
<Statistic title="当前页已索引" value={currentPageIndexedCount} suffix={`/ ${items.length}`} />
</Card>
<Card className="review-surface-card" style={{ minWidth: 180 }}>
<Statistic title="当前页已发布" value={currentPagePublishedCount} suffix={`/ ${items.length}`} />
</Card>
<Card className="review-surface-card" style={{ minWidth: 220 }}>
<Statistic title="当前筛选向量化覆盖率" value={vectorizationSummary.coverage_percent} suffix="%" precision={2} prefix={<ExperimentOutlined />} />
</Card>
</Space>
{/* 右侧内容 */}
<section className="doc-main-content">
{/* KPI 统计 */}
<div className="prompt-kpi-grid" style={{ marginBottom: 16 }}>
<div className="prompt-kpi-card">
<Text>法条总数</Text>
<strong>{total}</strong>
</div>
<div className="prompt-kpi-card prompt-kpi-card-success">
<Text>已索引</Text>
<strong>{indexedCount}</strong>
</div>
<div className="prompt-kpi-card prompt-kpi-card-warning">
<Text>已发布</Text>
<strong>{publishedCount}</strong>
</div>
<div className="prompt-kpi-card prompt-kpi-card-info">
<Text>向量化覆盖</Text>
<strong>{vectorizationSummary.coverage_percent.toFixed(1)}%</strong>
</div>
</div>
<Card className="review-surface-card" title="语义检索">
<Space direction="vertical" style={{ width: "100%" }}>
{/* 语义检索 */}
<div style={{ marginBottom: 16 }}>
<Input.Search
allowClear
enterButton="检索"
enterButton="语义检索"
loading={semanticSearching}
onChange={(event) => setSemanticQuery(event.target.value)}
onChange={(e) => setSemanticQuery(e.target.value)}
onSearch={(value) => void handleSemanticSearch(value)}
placeholder="输入法条语义问题,例如:违约责任、付款条件、电子签章证据"
value={semanticQuery}
style={{ marginBottom: 8 }}
/>
<Alert
message="语义检索会优先结合已向量化的法条库返回结果,再叠加关键词与条号命中。"
type="info"
showIcon
/>
<Table
rowKey={(item) => `${item.source_id}-${item.detail_path}`}
columns={semanticColumns}
{semanticResults.length > 0 && (
<List
dataSource={semanticResults}
loading={semanticSearching}
locale={{ emptyText: <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="输入一个问题后即可看到语义命中的法条。" /> }}
pagination={{ pageSize: 5, showSizeChanger: false }}
scroll={{ x: 1200 }}
/>
</Space>
</Card>
<Card className="review-surface-card" title="检索条件">
<Form form={form} layout="vertical" initialValues={defaultFilters} onFinish={handleSearch}>
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(180px, 1fr))", gap: 16 }}>
<Form.Item label="关键词" name="keyword">
<Input allowClear placeholder="搜索法源、条号或全文" prefix={<SearchOutlined />} />
</Form.Item>
<Form.Item label="法源编号" name="source_id">
<Input allowClear placeholder="例如 LAW-CIVIL-CODE-2020" />
</Form.Item>
<Form.Item label="法源类型" name="source_type">
<Select options={sourceTypeOptions} />
</Form.Item>
<Form.Item label="条文编号" name="article_no">
<Input allowClear placeholder="例如 第四百六十四条" />
</Form.Item>
<Form.Item label="索引状态" name="index_status">
<Select options={indexStatusOptions} />
</Form.Item>
<Form.Item label="发布状态" name="published_status">
<Select options={publishedStatusOptions} />
</Form.Item>
</div>
size="small"
renderItem={(item) => (
<List.Item>
<List.Item.Meta
title={
<Space>
<Button type="primary" htmlType="submit" icon={<SearchOutlined />}>
查询
</Button>
<Button onClick={handleReset} icon={<ReloadOutlined />}>
重置
</Button>
<Button icon={<ExperimentOutlined />} loading={vectorizing} onClick={() => void handleVectorize()}>
按当前筛选补齐向量化
</Button>
<Text strong>{item.title}</Text>
{item.score != null && <Tag color="purple">{item.score.toFixed(3)}</Tag>}
<Text type="secondary" style={{ fontSize: 12 }}>{getSemanticSearchReason(item)}</Text>
</Space>
</Form>
</Card>
<Card className="review-surface-card" title="法条列表">
<Table
rowKey="id"
dataSource={items}
loading={loading}
columns={columns}
pagination={{ current: page, pageSize, total, showSizeChanger: true }}
locale={{ emptyText: <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="还没有法条全文数据。" /> }}
onChange={handleTableChange}
scroll={{ x: 1720 }}
}
description={item.content ? <Paragraph ellipsis={{ rows: 2 }}>{item.content}</Paragraph> : null}
/>
</Card>
</Space>
{item.source_id && (
<Button disabled={!item.source_id} icon={<EyeOutlined />} size="small" onClick={() => void openArticleDetail(item.source_id)}>
查看
</Button>
)}
</List.Item>
)}
/>
)}
</div>
{/* 法条列表 */}
<div className="doc-content-header" style={{ marginBottom: 12 }}>
<Title level={2} style={{ margin: 0 }}>{getCategoryLabel()}</Title>
<Text type="secondary">{filteredItems.length} 条(共 {total} 条)</Text>
</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" 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.source_title}</span>
<Tag color="blue">{item.article_no}</Tag>
{renderStatusTag(item.index_status, indexStatusMeta)}
{renderStatusTag(item.published_status, publishedStatusMeta)}
<Tag>{item.source_type}</Tag>
<Text code style={{ fontSize: 12 }}>{item.source_id}</Text>
</Space>
<div style={{ marginTop: 4 }}>
<Paragraph type="secondary" ellipsis={{ rows: 2 }} style={{ marginBottom: 0 }}>
{item.article_text}
</Paragraph>
</div>
{item.tags?.length > 0 && (
<Space wrap size={[4, 4]} style={{ marginTop: 4 }}>
{item.tags.slice(0, 5).map((tag) => <Tag key={tag} style={{ fontSize: 11 }}>{tag}</Tag>)}
{item.tags.length > 5 && <Tag style={{ fontSize: 11 }}>+{item.tags.length - 5}</Tag>}
</Space>
)}
</div>
<Space style={{ marginLeft: 16, flexShrink: 0 }}>
<Button icon={<EyeOutlined />} size="small" onClick={() => void openArticleDetail(item.id, item)}>
查看全文
</Button>
</Space>
</div>
</List.Item>
)}
/>
) : (
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="没有匹配的法条">
<Button onClick={() => { setSearchQuery(""); setSelectedCategory(ALL_KEY); }}>清空筛选</Button>
</Empty>
)}
</section>
</div>
{/* 法条详情抽屉 */}
<Drawer
title={selectedArticle ? `${selectedArticle.source_title} ${selectedArticle.article_no}` : "法条全文"}
open={drawerOpen}
size={760}
onClose={() => setDrawerOpen(false)}
>
<Card loading={detailLoading} variant="borderless">
{selectedArticle ? (
<Space orientation="vertical" size="middle" style={{ width: "100%" }}>
{detailLoading ? (
<div style={{ textAlign: "center", padding: 48 }}><Spin /></div>
) : selectedArticle ? (
<Space direction="vertical" size="middle" style={{ width: "100%" }}>
<Space wrap>
{renderStatusTag(selectedArticle.index_status, indexStatusMeta)}
{renderStatusTag(selectedArticle.published_status, publishedStatusMeta)}
@@ -583,9 +491,7 @@ export default function LegalArticlesPage() {
</Space>
<div>
<Text type="secondary">法源</Text>
<Title level={4} style={{ marginTop: 4 }}>
{selectedArticle.source_title}
</Title>
<Title level={4} style={{ marginTop: 4 }}>{selectedArticle.source_title}</Title>
<Text>{selectedArticle.authority}</Text>
</div>
<div>
@@ -603,8 +509,9 @@ export default function LegalArticlesPage() {
<Space wrap>
{selectedArticle.tags.length ? selectedArticle.tags.map((tag) => <Tag key={tag}>{tag}</Tag>) : <Tag>暂无标签</Tag>}
</Space>
<Card size="small" title="追溯信息">
<Space orientation="vertical" size={6} style={{ width: "100%" }}>
<div style={{ padding: 16, background: "#f7f9fc", borderRadius: 8 }}>
<Text type="secondary" style={{ marginBottom: 8, display: "block" }}>追溯信息</Text>
<Space direction="vertical" size={6} style={{ width: "100%" }}>
<Text>法源编号:{selectedArticle.source_id}</Text>
<Text>生效日期:{formatDate(selectedArticle.effective_date)}</Text>
<Text>有效性:{selectedArticle.validity_status || "-"}</Text>
@@ -614,7 +521,6 @@ export default function LegalArticlesPage() {
<Text>向量维度:{selectedArticle.semantic_vector_terms ?? 0}</Text>
<Text>向量更新时间:{formatDateTime(selectedArticle.semantic_vector_updated_at)}</Text>
<Text>全文校验值:{formatHash(selectedArticle.text_checksum)}</Text>
<Text>法源校验值:{formatHash(selectedArticle.source_checksum)}</Text>
<Text>更新时间:{formatDateTime(selectedArticle.updated_at)}</Text>
{selectedArticle.official_url ? (
<a href={selectedArticle.official_url} target="_blank" rel="noreferrer">
@@ -622,12 +528,11 @@ export default function LegalArticlesPage() {
</a>
) : null}
</Space>
</Card>
</div>
</Space>
) : (
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="请选择一条法条。" />
)}
</Card>
</Drawer>
</main>
);
+288 -217
View File
@@ -1,7 +1,29 @@
"use client";
"use client";
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,
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 {
@@ -17,7 +39,7 @@ import {
import { useAuthStore } from "@/stores/auth.store";
import type { ReviewRuleItem } from "@/types/api";
const { Title, Text } = Typography;
const { Title, Text, Paragraph } = Typography;
type ReviewRuleFormValues = {
name: string;
@@ -39,11 +61,20 @@ const contractTypeOptions = [
{ value: "nda", label: "保密协议" },
];
const riskLevelOptions = [
{ value: "high", label: "高风险" },
{ value: "medium", label: "中风险" },
{ value: "low", label: "低风险" },
];
const contractTypeLabelMap: Record<string, string> = {
all: "全部合同",
procurement: "采购合同",
sales: "销售合同",
labor: "劳动合同",
service: "服务合同",
nda: "保密协议",
};
const riskLevelLabelMap: Record<string, string> = {
high: "高风险",
medium: "中风险",
low: "低风险",
};
function formatDateTime(value: string) {
return new Date(value).toLocaleString("zh-CN", { hour12: false });
@@ -55,65 +86,61 @@ function getRiskLevelTag(value: string) {
return <Tag color="blue">低风险</Tag>;
}
const ALL_KEY = "__all__";
export default function RulesPage() {
const [messageApi, contextHolder] = message.useMessage();
const currentUser = useAuthStore((state) => state.currentUser);
const [form] = Form.useForm<ReviewRuleFormValues>();
const [loading, setLoading] = useState(true);
const [submitting, setSubmitting] = useState(false);
const [statusChangingId, setStatusChangingId] = useState<string | null>(null);
const [items, setItems] = useState<ReviewRuleItem[]>([]);
const [editingId, setEditingId] = useState<string | null>(null);
const [deletingId, setDeletingId] = useState<string | null>(null);
const [publishingId, setPublishingId] = useState<string | null>(null);
const [rollbackingId, setRollbackingId] = useState<string | null>(null);
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(10);
const [total, setTotal] = useState(0);
const [items, setItems] = useState<ReviewRuleItem[]>([]);
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 editingRule = useMemo(() => items.find((item) => item.id === editingId) ?? null, [editingId, items]);
const loadRules = useCallback(async (nextPage = page, nextPageSize = pageSize) => {
const loadRules = useCallback(async () => {
if (currentUser?.role !== "admin") {
setLoading(false);
return;
}
setLoading(true);
try {
const result = await listReviewRules(nextPage, nextPageSize);
const result = await listReviewRules(1, 1000);
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);
}
}, [currentUser?.role, messageApi, page, pageSize]);
}, [currentUser?.role, messageApi]);
useEffect(() => {
const timer = window.setTimeout(() => {
void loadRules();
}, 0);
const timer = window.setTimeout(() => void loadRules(), 0);
return () => window.clearTimeout(timer);
}, [loadRules]);
useEffect(() => {
if (!editingRule) {
form.setFieldsValue({
contract_type: "all",
risk_level: "medium",
is_active: true,
});
form.setFieldsValue({ contract_type: "all", risk_level: "medium", is_active: true });
return;
}
form.setFieldsValue({
name: editingRule.name,
keyword: editingRule.keyword,
@@ -126,6 +153,105 @@ export default function RulesPage() {
});
}, [editingRule, 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("risk:")) {
const riskLevel = selectedCategory.replace("risk:", "");
result = result.filter((item) => item.risk_level === riskLevel);
}
}
if (searchQuery.trim()) {
const q = searchQuery.toLowerCase();
result = result.filter(
(item) =>
item.name.toLowerCase().includes(q) ||
item.keyword.toLowerCase().includes(q) ||
(item.rule_code ?? "").toLowerCase().includes(q) ||
item.description.toLowerCase().includes(q),
);
}
return result;
}, [items, selectedCategory, searchQuery]);
const treeData = useMemo((): DataNode[] => {
const contractCounts: Record<string, number> = {};
const riskCounts: Record<string, number> = {};
for (const item of items) {
contractCounts[item.contract_type] = (contractCounts[item.contract_type] || 0) + 1;
riskCounts[item.risk_level] = (riskCounts[item.risk_level] || 0) + 1;
}
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>
),
}));
const riskChildren: DataNode[] = Object.entries(riskLevelLabelMap)
.filter(([key]) => riskCounts[key])
.map(([key, label]) => ({
key: `risk:${key}`,
title: (
<Space>
<span>{label}</span>
<Tag color={key === "high" ? "red" : key === "medium" ? "gold" : "blue"} style={{ margin: 0 }}>{riskCounts[key] ?? 0}</Tag>
</Space>
),
}));
return [
{
key: ALL_KEY,
title: (
<Space>
<span>全部规则</span>
<Tag color="blue" style={{ margin: 0 }}>{items.length}</Tag>
</Space>
),
children: [
{ key: "group-contract", title: <strong style={{ color: "#1677ff" }}>按合同类型</strong>, children: contractChildren, selectable: false },
{ key: "group-risk", title: <strong style={{ color: "#1677ff" }}>按风险等级</strong>, children: riskChildren, selectable: false },
],
},
];
}, [items]);
const handleTreeSelect = (_selectedKeys: React.Key[], info: { node: DataNode }) => {
const key = info.node.key as string;
if (!key.startsWith("group-")) {
setSelectedCategory(key);
}
};
const handleOpenDrawer = (rule?: ReviewRuleItem) => {
if (rule) {
setEditingId(rule.id);
} else {
setEditingId(null);
form.resetFields();
form.setFieldsValue({ contract_type: "all", risk_level: "medium", is_active: true });
}
setDrawerOpen(true);
};
const handleCloseDrawer = () => {
setDrawerOpen(false);
setEditingId(null);
form.resetFields();
form.setFieldsValue({ contract_type: "all", risk_level: "medium", is_active: true });
};
const handleSubmit = async (values: ReviewRuleFormValues) => {
setSubmitting(true);
try {
@@ -145,10 +271,8 @@ export default function RulesPage() {
return;
}
messageApi.success(editingRule ? "规则已更新" : "规则已创建");
setEditingId(null);
form.resetFields();
form.setFieldsValue({ contract_type: "all", risk_level: "medium", is_active: true });
await loadRules(page, pageSize);
handleCloseDrawer();
await loadRules();
} catch {
messageApi.error("保存规则失败,请稍后重试");
} finally {
@@ -165,10 +289,7 @@ export default function RulesPage() {
return;
}
messageApi.success(checked ? "规则已启用" : "规则已停用");
await loadRules(page, pageSize);
if (editingId === item.id) {
setEditingId(item.id);
}
await loadRules();
} catch {
messageApi.error("更新规则状态失败,请稍后重试");
} finally {
@@ -185,12 +306,7 @@ export default function RulesPage() {
return;
}
messageApi.success("规则已删除");
await loadRules(page, pageSize);
if (editingId === ruleId) {
setEditingId(null);
form.resetFields();
form.setFieldsValue({ contract_type: "all", risk_level: "medium", is_active: true });
}
await loadRules();
} catch {
messageApi.error("删除规则失败,请稍后重试");
} finally {
@@ -207,7 +323,7 @@ export default function RulesPage() {
return;
}
messageApi.success("规则已发布到生产可用集合");
await loadRules(page, pageSize);
await loadRules();
} catch (error) {
messageApi.error(error instanceof Error ? error.message : "规则发布失败,请稍后重试");
} finally {
@@ -224,7 +340,7 @@ export default function RulesPage() {
return;
}
messageApi.success("规则已从生产可用集合移除");
await loadRules(page, pageSize);
await loadRules();
} catch (error) {
messageApi.error(error instanceof Error ? error.message : "规则下线失败,请稍后重试");
} finally {
@@ -247,7 +363,7 @@ export default function RulesPage() {
return;
}
messageApi.success("规则已回滚为新版本");
await loadRules(page, pageSize);
await loadRules();
} catch (error) {
messageApi.error(error instanceof Error ? error.message : "规则回滚失败,请稍后重试");
} finally {
@@ -255,81 +371,108 @@ export default function RulesPage() {
}
};
const handleTableChange = (pagination: TablePaginationConfig) => {
const nextPage = pagination.current ?? 1;
const nextPageSize = pagination.pageSize ?? pageSize;
setLoading(true);
void loadRules(nextPage, nextPageSize);
const getCategoryLabel = () => {
if (selectedCategory === ALL_KEY) return "全部规则";
if (selectedCategory.startsWith("contract:")) {
return contractTypeLabelMap[selectedCategory.replace("contract:", "")] ?? "合同类型";
}
if (selectedCategory.startsWith("risk:")) {
return riskLevelLabelMap[selectedCategory.replace("risk:", "")] ?? "风险等级";
}
return "规则列表";
};
const columns: ColumnsType<ReviewRuleItem> = [
{
title: "规则名称",
dataIndex: "name",
key: "name",
width: 180,
},
{
title: "关键词",
dataIndex: "keyword",
key: "keyword",
width: 140,
},
{
title: "规则编号",
dataIndex: "rule_code",
key: "rule_code",
width: 180,
render: (value: string | null) => value ? <Text code>{value}</Text> : "-",
},
{
title: "合同类型",
dataIndex: "contract_type",
key: "contract_type",
width: 140,
render: (value: string) => contractTypeOptions.find((item) => item.value === value)?.label ?? value,
},
{
title: "风险等级",
dataIndex: "risk_level",
key: "risk_level",
width: 120,
render: (value: string) => getRiskLevelTag(value),
},
{
title: "发布状态",
key: "publish_status",
width: 180,
render: (_, item) => (
<Space orientation="vertical" size={2}>
<Tag color={item.published_at ? "green" : "default"}>{item.published_at ? "已发布" : "未发布"}</Tag>
<Text type="secondary" style={{ fontSize: 12 }}>
{item.published_at ? formatDateTime(item.published_at) : "需治理批准后发布"}
</Text>
if (currentUser?.role !== "admin") {
return (
<main className="page-shell rules-page">
{contextHolder}
<div style={{ padding: 48, textAlign: "center" }}>
<Text type="secondary">仅管理员可管理审查规则。</Text>
</div>
</main>
);
}
return (
<main className="page-shell rules-page">
{contextHolder}
<div className="doc-layout-shell">
{/* 左侧树形菜单 */}
<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
/>
</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" icon={<PlusOutlined />} onClick={() => handleOpenDrawer()}>
新建规则
</Button>
</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" 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.is_active ? "green" : "default"}>{item.is_active ? "启用" : "停用"}</Tag>
<Tag color={item.published_at ? "blue" : "default"}>{item.published_at ? "已发布" : "未发布"}</Tag>
{item.rule_code && <Text code>{item.rule_code}</Text>}
</Space>
),
},
{
title: "状态",
dataIndex: "is_active",
key: "is_active",
width: 120,
render: (value: boolean) => <Tag color={value ? "green" : "default"}>{value ? "启用中" : "已停用"}</Tag>,
},
{
title: "更新时间",
dataIndex: "updated_at",
key: "updated_at",
width: 180,
render: (value: string) => formatDateTime(value),
},
{
title: "操作",
key: "actions",
width: 220,
render: (_, item) => (
<Space>
<Button size="small" onClick={() => setEditingId(item.id)}>
<div style={{ marginTop: 4 }}>
<Space wrap size={[4, 4]}>
<Tag color="orange">关键词:{item.keyword}</Tag>
<Tag>{contractTypeLabelMap[item.contract_type] ?? item.contract_type}</Tag>
{getRiskLevelTag(item.risk_level)}
<Tag>v{item.current_version_label}</Tag>
</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
size="small"
checked={item.is_active}
loading={statusChangingId === item.id}
onChange={(checked) => void handleToggleStatus(item, checked)}
/>
<Button size="small" onClick={() => handleOpenDrawer(item)}>
编辑
</Button>
{item.published_at ? (
@@ -341,66 +484,37 @@ export default function RulesPage() {
发布
</Button>
)}
{item.version_count > 1 ? (
{item.version_count > 1 && (
<Button size="small" loading={rollbackingId === item.id} onClick={() => void handleRollback(item)}>
回滚
</Button>
) : null}
<Switch
checked={item.is_active}
loading={statusChangingId === item.id}
checkedChildren="启用"
unCheckedChildren="停用"
onChange={(checked) => void handleToggleStatus(item, checked)}
/>
)}
<Popconfirm title="确认删除这个规则?" okText="删除" cancelText="取消" onConfirm={() => void handleDelete(item.id)}>
<Button danger size="small" loading={deletingId === item.id}>
删除
</Button>
</Popconfirm>
</Space>
),
},
];
if (currentUser?.role !== "admin") {
return (
<main className="page-shell review-history-page">
{contextHolder}
<Card className="review-list-card">
<Alert title="仅管理员可管理审查规则。" type="warning" showIcon />
</Card>
</main>
);
}
return (
<main className="page-shell review-page-grid">
{contextHolder}
<Space orientation="vertical" size="large" style={{ width: "100%" }}>
<section className="review-page-hero">
<div className="review-page-hero-copy">
<span className="landing-badge">Rule Management</span>
<Title level={2}>维护审查规则,直接影响风险识别。</Title>
<Text type="secondary">左侧编辑规则,右侧查看启停状态和命中依据,规则变更更容易复核。</Text>
</div>
<Card className="review-side-card" variant="borderless">
<span className="landing-panel-eyebrow">Admin Workspace</span>
<Title level={4} style={{ margin: 0, color: "inherit" }}>
规则节奏
</Title>
<ul className="review-page-tip-list">
<li>先配置关键词规则,再发起合同审查。</li>
<li>支持按合同类型区分规则。</li>
<li>停用规则后不再参与后续审查匹配。</li>
</ul>
<Alert showIcon type="info" title="权限边界:member 消费审查结果,admin 负责规则配置与维护。" />
</Card>
</List.Item>
)}
/>
) : (
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="没有匹配的规则">
<Button onClick={() => { setSearchQuery(""); setSelectedCategory(ALL_KEY); }}>清空筛选</Button>
</Empty>
)}
</section>
</div>
<div className="chat-layout-grid chat-layout-grid-history">
<Card className="review-surface-card" title={editingRule ? "编辑规则" : "新建规则"}>
{/* 新建/编辑规则抽屉 */}
<Drawer
title={editingRule ? "编辑规则" : "新建规则"}
open={drawerOpen}
onClose={handleCloseDrawer}
width={560}
destroyOnClose
>
<Form form={form} layout="vertical" onFinish={handleSubmit} initialValues={{ contract_type: "all", risk_level: "medium", is_active: true }}>
<Form.Item label="规则名称" name="name" rules={[{ required: true, message: "请输入规则名称" }]}>
<Input placeholder="例如:自动续约预警" />
@@ -412,71 +526,28 @@ export default function RulesPage() {
<Select options={contractTypeOptions} />
</Form.Item>
<Form.Item label="风险等级" name="risk_level" rules={[{ required: true, message: "请选择风险等级" }]}>
<Select options={riskLevelOptions} />
<Select options={[{ value: "high", label: "高风险" }, { value: "medium", label: "中风险" }, { value: "low", label: "低风险" }]} />
</Form.Item>
<Form.Item label="风险描述" name="description" rules={[{ required: true, message: "请输入风险描述" }]}>
<Input.TextArea rows={4} placeholder="说明为什么这是风险点" />
<Input.TextArea rows={3} placeholder="说明为什么这是风险点" />
</Form.Item>
<Form.Item label="审查建议" name="suggestion">
<Input.TextArea rows={3} placeholder="例如:补充通知期限和终止条件" />
<Input.TextArea rows={2} placeholder="例如:补充通知期限和终止条件" />
</Form.Item>
<Form.Item label="法律依据" name="legal_basis">
<Input.TextArea rows={3} placeholder="例如:《民法典》相关规则" />
<Input.TextArea rows={2} placeholder="例如:《民法典》相关规则" />
</Form.Item>
<Form.Item label="启用状态" name="is_active" valuePropName="checked">
<Switch checkedChildren="启用" unCheckedChildren="停用" />
</Form.Item>
<Space style={{ width: "100%" }}>
<Space>
<Button type="primary" htmlType="submit" loading={submitting}>
{editingRule ? "保存规则" : "创建规则"}
</Button>
{editingRule ? (
<Button
onClick={() => {
setEditingId(null);
form.resetFields();
form.setFieldsValue({ contract_type: "all", risk_level: "medium", is_active: true });
}}
>
取消编辑
</Button>
) : null}
<Button onClick={handleCloseDrawer}>取消</Button>
</Space>
</Form>
</Card>
<Card className="review-surface-card" title="规则列表">
<Table
rowKey="id"
dataSource={items}
loading={loading}
pagination={{ current: page, pageSize, total, showSizeChanger: true }}
locale={{ emptyText: <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="还没有审查规则。" /> }}
columns={columns}
onChange={handleTableChange}
expandable={{
expandedRowRender: (item) => (
<Space orientation="vertical" size="small" style={{ width: "100%" }}>
<div>
<strong>风险描述:</strong>
<div>{item.description}</div>
</div>
<div>
<strong>审查建议:</strong>
<div>{item.suggestion || "-"}</div>
</div>
<div>
<strong>法律依据:</strong>
<div>{item.legal_basis || "-"}</div>
</div>
</Space>
),
}}
scroll={{ x: 1440 }}
/>
</Card>
</div>
</Space>
</Drawer>
</main>
);
}