feat: add legal articles admin page

This commit is contained in:
2026-05-16 18:17:53 +08:00
parent 3c7e193b57
commit 2e8a8b7b0d
7 changed files with 572 additions and 1 deletions
+3
View File
@@ -5,6 +5,9 @@ const adminRouteMap: Record<string, string> = {
files: "/files",
knowledge: "/knowledge-bases",
"knowledge-bases": "/knowledge-bases",
"legal-articles": "/legal-articles",
"legal-fulltext": "/legal-articles",
laws: "/legal-articles",
governance: "/governance",
rules: "/rules",
templates: "/templates",
+6
View File
@@ -19,6 +19,12 @@ const adminSections = [
label: "知识库管理",
description: "维护知识文档、索引状态和问答依据。",
},
{
href: "/legal-articles",
eyebrow: "Legal Fulltext",
label: "法条全文库",
description: "查看已吸收入库的法律法规全文、索引状态和法源追溯。",
},
{
href: "/rules",
eyebrow: "Rules",
+456
View File
@@ -0,0 +1,456 @@
"use client";
import { 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 Link from "next/link";
import { useCallback, useEffect, useMemo, useState } from "react";
import { getLegalArticle, listLegalArticles } from "@/services/legal-article";
import { useAuthStore } from "@/stores/auth.store";
import type { LegalArticleItem } from "@/types/api";
const { Paragraph, Text, Title } = Typography;
type LegalArticleFilterValues = {
keyword?: string;
source_id?: string;
article_no?: string;
index_status?: string;
published_status?: string;
};
const defaultFilters: LegalArticleFilterValues = {
keyword: "",
source_id: "",
article_no: "",
index_status: "all",
published_status: "all",
};
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 indexStatusMeta: Record<string, { color: string; label: string }> = {
indexed: { color: "green", label: "已索引" },
pending: { color: "gold", label: "待索引" },
processing: { color: "blue", label: "索引中" },
failed: { color: "red", label: "索引失败" },
};
const publishedStatusMeta: Record<string, { color: string; label: string }> = {
published: { color: "green", label: "已发布" },
draft: { color: "default", label: "草稿" },
archived: { color: "purple", label: "已归档" },
};
function formatDateTime(value: string | null | undefined) {
if (!value) {
return "-";
}
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return value;
}
return date.toLocaleString("zh-CN", { hour12: false });
}
function formatDate(value: string | null | undefined) {
if (!value) {
return "-";
}
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return value;
}
return date.toLocaleDateString("zh-CN");
}
function formatHash(value: string | null | undefined) {
return value ? `${value.slice(0, 12)}...` : "-";
}
function renderStatusTag(value: string, metaMap: Record<string, { color: string; label: string }>) {
const meta = metaMap[value] ?? { color: "default", label: value || "未知" };
return <Tag color={meta.color}>{meta.label}</Tag>;
}
function normalizeFilters(values: LegalArticleFilterValues): LegalArticleFilterValues {
return {
keyword: values.keyword?.trim() ?? "",
source_id: values.source_id?.trim() ?? "",
article_no: values.article_no?.trim() ?? "",
index_status: values.index_status ?? "all",
published_status: values.published_status ?? "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 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 loadArticles = useCallback(
async (nextPage = page, nextPageSize = pageSize, nextFilters = filters) => {
if (currentUser?.role !== "admin") {
setLoading(false);
return;
}
try {
const result = await listLegalArticles({
page: nextPage,
page_size: nextPageSize,
...nextFilters,
});
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 (error) {
messageApi.error(error instanceof Error ? error.message : "法条全文库加载失败,请稍后重试");
} finally {
setLoading(false);
}
},
[currentUser?.role, filters, messageApi, page, pageSize],
);
useEffect(() => {
const timer = window.setTimeout(() => {
void loadArticles();
}, 0);
return () => window.clearTimeout(timer);
}, [loadArticles]);
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);
};
const handleTableChange = (pagination: TablePaginationConfig) => {
const nextPage = pagination.current ?? 1;
const nextPageSize = pagination.pageSize ?? pageSize;
setLoading(true);
void loadArticles(nextPage, nextPageSize, filters);
};
const handleOpenDetail = async (item: LegalArticleItem) => {
setSelectedArticle(item);
setDrawerOpen(true);
setDetailLoading(true);
try {
const result = await getLegalArticle(item.id);
if (result.code !== 0) {
messageApi.error(result.message || "读取法条详情失败");
return;
}
setSelectedArticle(result.data);
} catch (error) {
messageApi.error(error instanceof Error ? error.message : "读取法条详情失败,请稍后重试");
} finally {
setDetailLoading(false);
}
};
const columns: ColumnsType<LegalArticleItem> = [
{
title: "法源",
dataIndex: "source_title",
key: "source_title",
width: 260,
render: (_, item) => (
<Space direction="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 direction="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 direction="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 direction="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 direction="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 handleOpenDetail(item)}>
查看全文
</Button>
),
},
];
if (currentUser?.role !== "admin") {
return (
<main className="page-shell review-history-page">
{contextHolder}
<Card className="review-list-card">
<Alert message="仅管理员可查看法条全文库。" type="warning" showIcon />
</Card>
</main>
);
}
return (
<main className="page-shell review-page-grid">
{contextHolder}
<Space direction="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>
<Card className="review-side-card" bordered={false}>
<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>
<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>
</Space>
<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="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>
<Space>
<Button type="primary" htmlType="submit" icon={<SearchOutlined />}>
查询
</Button>
<Button onClick={handleReset} icon={<ReloadOutlined />}>
重置
</Button>
</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 }}
/>
</Card>
</Space>
<Drawer
title={selectedArticle ? `${selectedArticle.source_title} ${selectedArticle.article_no}` : "法条全文"}
open={drawerOpen}
width={760}
onClose={() => setDrawerOpen(false)}
>
<Card loading={detailLoading} bordered={false}>
{selectedArticle ? (
<Space direction="vertical" size="middle" style={{ width: "100%" }}>
<Space wrap>
{renderStatusTag(selectedArticle.index_status, indexStatusMeta)}
{renderStatusTag(selectedArticle.published_status, publishedStatusMeta)}
<Tag>{selectedArticle.source_type}</Tag>
<Tag>{selectedArticle.region}</Tag>
</Space>
<div>
<Text type="secondary">法源</Text>
<Title level={4} style={{ marginTop: 4 }}>
{selectedArticle.source_title}
</Title>
<Text>{selectedArticle.authority}</Text>
</div>
<div>
<Text type="secondary">章节</Text>
<Paragraph style={{ marginTop: 4, marginBottom: 0 }}>
{[selectedArticle.chapter_title, selectedArticle.section_title].filter(Boolean).join(" / ") || "-"}
</Paragraph>
</div>
<div>
<Text type="secondary">全文</Text>
<Paragraph copyable={{ text: selectedArticle.article_text }} style={{ marginTop: 8, whiteSpace: "pre-wrap" }}>
{selectedArticle.article_text}
</Paragraph>
</div>
<Space wrap>
{selectedArticle.tags.length ? selectedArticle.tags.map((tag) => <Tag key={tag}>{tag}</Tag>) : <Tag>暂无标签</Tag>}
</Space>
<Card size="small" title="追溯信息">
<Space direction="vertical" size={6} style={{ width: "100%" }}>
<Text>法源编号:{selectedArticle.source_id}</Text>
<Text>生效日期:{formatDate(selectedArticle.effective_date)}</Text>
<Text>有效性:{selectedArticle.validity_status || "-"}</Text>
<Text>知识库:{selectedArticle.knowledge_base_id || "-"}</Text>
<Text>知识文档:{selectedArticle.knowledge_document_id || "-"}</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">
<LinkOutlined /> 打开官方来源
</a>
) : null}
</Space>
</Card>
</Space>
) : (
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="请选择一条法条。" />
)}
</Card>
</Drawer>
</main>
);
}
@@ -8,6 +8,7 @@ import {
DeploymentUnitOutlined,
DownOutlined,
FileDoneOutlined,
FileSearchOutlined,
FileProtectOutlined,
FolderOpenOutlined,
HistoryOutlined,
@@ -76,6 +77,7 @@ const adminNavItems = [
{ href: "/rules", label: "规则管理", icon: <SolutionOutlined /> },
{ href: "/templates", label: "模板管理", icon: <BookOutlined /> },
{ href: "/knowledge-bases", label: "知识库管理", icon: <FolderOpenOutlined /> },
{ href: "/legal-articles", label: "法条全文库", icon: <FileSearchOutlined /> },
{ href: "/files", label: "文件管理", icon: <FileDoneOutlined /> },
{ href: "/tenant-settings", label: "租户设置", icon: <SettingOutlined /> },
{ href: "/users", label: "用户管理", icon: <TeamOutlined /> },
+20
View File
@@ -0,0 +1,20 @@
import { http } from "@/services/http";
import type { ApiResponse, LegalArticleItem, LegalArticleListParams, LegalArticleListResponse } from "@/types/api";
function cleanParams(params: LegalArticleListParams) {
return Object.fromEntries(
Object.entries(params).filter(([, value]) => value !== undefined && value !== null && String(value).trim() !== "" && value !== "all"),
);
}
export async function listLegalArticles(params: LegalArticleListParams = {}) {
const response = await http.get<ApiResponse<LegalArticleListResponse>>("/legal-articles", {
params: cleanParams(params),
});
return response.data;
}
export async function getLegalArticle(articleId: string) {
const response = await http.get<ApiResponse<LegalArticleItem>>(`/legal-articles/${articleId}`);
return response.data;
}
+41
View File
@@ -299,6 +299,47 @@ export type KnowledgeBaseRollbackPayload = {
export type KnowledgeBaseListResponse = PageResponse<KnowledgeBaseItem>;
export type KnowledgeBaseDocumentListResponse = PageResponse<KnowledgeBaseDocumentItem>;
export type LegalArticleItem = {
id: string;
tenant_id: string;
source_id: string;
source_title: string;
source_type: string;
authority: string;
effective_date: string | null;
validity_status: string | null;
region: string;
official_url: string | null;
article_no: string;
chapter_title: string | null;
section_title: string | null;
article_text: string;
tags: string[];
tags_json: string[] | null;
source_checksum: string | null;
text_checksum: string | null;
knowledge_base_id: string | null;
knowledge_document_id: string | null;
index_status: string;
published_status: string;
published_at: string | null;
published_by: string | null;
created_at: string | null;
updated_at: string | null;
};
export type LegalArticleListParams = {
page?: number;
page_size?: number;
keyword?: string;
source_id?: string;
article_no?: string;
index_status?: string;
published_status?: string;
};
export type LegalArticleListResponse = PageResponse<LegalArticleItem>;
export type ReviewRuleItem = {
id: string;
name: string;
@@ -2093,10 +2093,53 @@
- Phase14 目前完成的是首批 6 个法源、33 条高频合同相关法条,不是全国全部法律法规全文。
- 后续增强项:
- 扩展更多民法典合同编和相关司法解释条文
- 增加前端“法条全文库”管理页面
- 在审查报告中把风险依据进一步链接到 `legal_articles` 的具体条文全文
- 支持用户本地下载的法律法规资料包按同一清单格式导入
### 阻塞项
- 本轮无 required 阻塞项。
- 仍保留旧的非 required 项:外部告警 Webhook 未配置。
## 2026-05-16 Phase14 法条全文库前端入口闭环
### 本轮完成项
- 已新增管理员前端页面:
- `frontend/src/app/legal-articles/page.tsx`
- 路由:`/legal-articles`
- 能查看法条总数、当前页法源数、索引状态、发布状态、知识库编号、知识文档编号、法源元数据和全文抽屉。
- 已新增前端服务和类型:
- `frontend/src/services/legal-article.ts`
- `frontend/src/types/api.ts`
- 已接入后台入口:
- 管理顶部菜单新增“法条全文库”
- 后台首页新增“法条全文库”卡片
- `/admin/legal-articles`、`/admin/legal-fulltext`、`/admin/laws` 会跳转到 `/legal-articles`
### 本轮验证
- `npm --prefix frontend run lint`
- 结果:通过
- `npm --prefix frontend run build`
- 结果:通过
- 构建路由包含:`/legal-articles`
- `git diff --check`
- 结果:通过,仅有 Git CRLF 工作区提示
- Playwright 前端冒烟:
- 地址:`http://127.0.0.1:3000/legal-articles`
- 方式:模拟管理员会话并拦截 `/api/v1/legal-articles` 接口
- 结果:`PASS`
- 已验证:页面标题、总数、法条列表和“查看全文”抽屉。
### 当前状态
- Phase14 现在不只是后端和导入脚本可用,管理员也能在 Web 后台直接查看“法条全文库”。
- 当前前端定位是查询、核验和追溯,不做人工录入法条,避免正式法源维护退回人工填表。
- 本地前端 dev server 已启动:`http://127.0.0.1:3000/legal-articles`
### 未完成项
- Phase14 首批仍是 6 个法源、33 条高频合同相关条文,不是全国全部法律法规全文。
- 后续增强项:
- 扩展更多民法典合同编和相关司法解释条文
- 在审查报告中把风险依据链接到 `legal_articles` 具体条文全文
- 支持用户本地下载的法律法规资料包按同一清单格式导入
### 阻塞项
- 本轮无 required 阻塞项。
- 仍保留旧的非 required 项:外部告警 Webhook 未配置。