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:
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user