diff --git a/frontend/src/app/admin/[slug]/page.tsx b/frontend/src/app/admin/[slug]/page.tsx index 2f905661..83e17802 100644 --- a/frontend/src/app/admin/[slug]/page.tsx +++ b/frontend/src/app/admin/[slug]/page.tsx @@ -5,6 +5,9 @@ const adminRouteMap: Record = { 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", diff --git a/frontend/src/app/admin/page.tsx b/frontend/src/app/admin/page.tsx index 684ec830..2b4282f7 100644 --- a/frontend/src/app/admin/page.tsx +++ b/frontend/src/app/admin/page.tsx @@ -19,6 +19,12 @@ const adminSections = [ label: "知识库管理", description: "维护知识文档、索引状态和问答依据。", }, + { + href: "/legal-articles", + eyebrow: "Legal Fulltext", + label: "法条全文库", + description: "查看已吸收入库的法律法规全文、索引状态和法源追溯。", + }, { href: "/rules", eyebrow: "Rules", diff --git a/frontend/src/app/legal-articles/page.tsx b/frontend/src/app/legal-articles/page.tsx new file mode 100644 index 00000000..5274eb0e --- /dev/null +++ b/frontend/src/app/legal-articles/page.tsx @@ -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 = { + indexed: { color: "green", label: "已索引" }, + pending: { color: "gold", label: "待索引" }, + processing: { color: "blue", label: "索引中" }, + failed: { color: "red", label: "索引失败" }, +}; + +const publishedStatusMeta: Record = { + 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) { + const meta = metaMap[value] ?? { color: "default", label: value || "未知" }; + return {meta.label}; +} + +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(); + const [loading, setLoading] = useState(true); + const [detailLoading, setDetailLoading] = useState(false); + const [items, setItems] = useState([]); + const [filters, setFilters] = useState(defaultFilters); + const [page, setPage] = useState(1); + const [pageSize, setPageSize] = useState(10); + const [total, setTotal] = useState(0); + const [selectedArticle, setSelectedArticle] = useState(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 = [ + { + title: "法源", + dataIndex: "source_title", + key: "source_title", + width: 260, + render: (_, item) => ( + + {item.source_title} + {item.source_id} + {item.authority} + + ), + }, + { + title: "条文编号", + dataIndex: "article_no", + key: "article_no", + width: 120, + render: (value: string) => {value}, + }, + { + title: "章节", + key: "chapter", + width: 220, + render: (_, item) => ( + + {item.chapter_title || "-"} + {item.section_title || "-"} + + ), + }, + { + title: "法条摘要", + dataIndex: "article_text", + key: "article_text", + width: 360, + render: (value: string) => ( + + {value} + + ), + }, + { + title: "标签", + dataIndex: "tags", + key: "tags", + width: 180, + render: (value: string[]) => ( + + {value?.length ? value.slice(0, 3).map((tag) => {tag}) : -} + {value?.length > 3 ? +{value.length - 3} : null} + + ), + }, + { + title: "知识库吸收", + key: "knowledge", + width: 220, + render: (_, item) => ( + + {renderStatusTag(item.index_status, indexStatusMeta)} + 知识库:{item.knowledge_base_id || "-"} + 文档:{item.knowledge_document_id || "-"} + + ), + }, + { + title: "发布状态", + key: "published_status", + width: 160, + render: (_, item) => ( + + {renderStatusTag(item.published_status, publishedStatusMeta)} + {formatDateTime(item.published_at)} + + ), + }, + { + title: "生效范围", + key: "effective", + width: 160, + render: (_, item) => ( + + {item.region} + {formatDate(item.effective_date)} + {item.validity_status || "-"} + + ), + }, + { + title: "操作", + key: "actions", + fixed: "right", + width: 120, + render: (_, item) => ( + + ), + }, + ]; + + if (currentUser?.role !== "admin") { + return ( +
+ {contextHolder} + + + +
+ ); + } + + return ( +
+ {contextHolder} + +
+
+ Legal Fulltext Library + 法条全文库 + 查看 Phase14 已吸收入库的法律法规全文条款,核对法源、索引状态、发布状态和知识库追溯信息。 +
+ + Phase14 + + 吸收闭环 + +
    +
  • 全文条款由治理资产资料包同步到业务运行层。
  • +
  • 本页只查看和核验,不替代正式法源导入脚本。
  • +
  • 每条法条保留知识库、文档、校验值和发布时间。
  • +
+ + + +
+
+ + + + + + + + + + + + + + + + + +
+
+ + } /> + + + + + + + + + + +
+ + + + +
+
+ + + }} + onChange={handleTableChange} + scroll={{ x: 1720 }} + /> + + + + setDrawerOpen(false)} + > + + {selectedArticle ? ( + + + {renderStatusTag(selectedArticle.index_status, indexStatusMeta)} + {renderStatusTag(selectedArticle.published_status, publishedStatusMeta)} + {selectedArticle.source_type} + {selectedArticle.region} + +
+ 法源 + + {selectedArticle.source_title} + + {selectedArticle.authority} +
+
+ 章节 + + {[selectedArticle.chapter_title, selectedArticle.section_title].filter(Boolean).join(" / ") || "-"} + +
+
+ 全文 + + {selectedArticle.article_text} + +
+ + {selectedArticle.tags.length ? selectedArticle.tags.map((tag) => {tag}) : 暂无标签} + + + + 法源编号:{selectedArticle.source_id} + 生效日期:{formatDate(selectedArticle.effective_date)} + 有效性:{selectedArticle.validity_status || "-"} + 知识库:{selectedArticle.knowledge_base_id || "-"} + 知识文档:{selectedArticle.knowledge_document_id || "-"} + 全文校验值:{formatHash(selectedArticle.text_checksum)} + 法源校验值:{formatHash(selectedArticle.source_checksum)} + 更新时间:{formatDateTime(selectedArticle.updated_at)} + {selectedArticle.official_url ? ( + + 打开官方来源 + + ) : null} + + +
+ ) : ( + + )} +
+
+ + ); +} diff --git a/frontend/src/components/layout/app-topbar.tsx b/frontend/src/components/layout/app-topbar.tsx index b75c27cf..651b7d8d 100644 --- a/frontend/src/components/layout/app-topbar.tsx +++ b/frontend/src/components/layout/app-topbar.tsx @@ -8,6 +8,7 @@ import { DeploymentUnitOutlined, DownOutlined, FileDoneOutlined, + FileSearchOutlined, FileProtectOutlined, FolderOpenOutlined, HistoryOutlined, @@ -76,6 +77,7 @@ const adminNavItems = [ { href: "/rules", label: "规则管理", icon: }, { href: "/templates", label: "模板管理", icon: }, { href: "/knowledge-bases", label: "知识库管理", icon: }, + { href: "/legal-articles", label: "法条全文库", icon: }, { href: "/files", label: "文件管理", icon: }, { href: "/tenant-settings", label: "租户设置", icon: }, { href: "/users", label: "用户管理", icon: }, diff --git a/frontend/src/services/legal-article.ts b/frontend/src/services/legal-article.ts new file mode 100644 index 00000000..45b00a7d --- /dev/null +++ b/frontend/src/services/legal-article.ts @@ -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>("/legal-articles", { + params: cleanParams(params), + }); + return response.data; +} + +export async function getLegalArticle(articleId: string) { + const response = await http.get>(`/legal-articles/${articleId}`); + return response.data; +} diff --git a/frontend/src/types/api.ts b/frontend/src/types/api.ts index 31e0ff19..c70fd8e5 100644 --- a/frontend/src/types/api.ts +++ b/frontend/src/types/api.ts @@ -299,6 +299,47 @@ export type KnowledgeBaseRollbackPayload = { export type KnowledgeBaseListResponse = PageResponse; export type KnowledgeBaseDocumentListResponse = PageResponse; +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; + export type ReviewRuleItem = { id: string; name: string; diff --git a/project-docs/AI合同起草与审查助手-当前工作进度保存-2026-05-11.md b/project-docs/AI合同起草与审查助手-当前工作进度保存-2026-05-11.md index 4a9c9356..5e40994f 100644 --- a/project-docs/AI合同起草与审查助手-当前工作进度保存-2026-05-11.md +++ b/project-docs/AI合同起草与审查助手-当前工作进度保存-2026-05-11.md @@ -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 未配置。