diff --git a/admin-web/package-lock.json b/admin-web/package-lock.json index eaee5bf..1439853 100644 --- a/admin-web/package-lock.json +++ b/admin-web/package-lock.json @@ -9,6 +9,7 @@ "version": "0.1.0", "dependencies": { "@ant-design/icons": "^5.4.0", + "@tabler/icons-react": "^3.46.0", "antd": "^5.22.0", "axios": "^1.7.0", "dayjs": "^1.11.0", @@ -1445,6 +1446,32 @@ "win32" ] }, + "node_modules/@tabler/icons": { + "version": "3.46.0", + "resolved": "https://registry.npmjs.org/@tabler/icons/-/icons-3.46.0.tgz", + "integrity": "sha512-f2RYFl3fzPwj5WO82x6en0dmkjefxEfOm16D1ByM6cj/McNiwOkL4VaPUoP9VVIrXAD9WnTSVFr70px703b//A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/codecalm" + } + }, + "node_modules/@tabler/icons-react": { + "version": "3.46.0", + "resolved": "https://registry.npmjs.org/@tabler/icons-react/-/icons-react-3.46.0.tgz", + "integrity": "sha512-CCm7xJWhDT2PH4ZIFkP6AgYKtVhq0gpYkjUN+GVh1AzmIQaa77OW0bQPBPQiTE0PsXMR9oSxFqA3qBglzPyrVQ==", + "license": "MIT", + "dependencies": { + "@tabler/icons": "3.46.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/codecalm" + }, + "peerDependencies": { + "react": ">= 16" + } + }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", diff --git a/admin-web/package.json b/admin-web/package.json index 83e996c..b693812 100644 --- a/admin-web/package.json +++ b/admin-web/package.json @@ -10,6 +10,7 @@ }, "dependencies": { "@ant-design/icons": "^5.4.0", + "@tabler/icons-react": "^3.46.0", "antd": "^5.22.0", "axios": "^1.7.0", "dayjs": "^1.11.0", diff --git a/admin-web/public/category-icons/README.md b/admin-web/public/category-icons/README.md new file mode 100644 index 0000000..7d76358 --- /dev/null +++ b/admin-web/public/category-icons/README.md @@ -0,0 +1,14 @@ +# 快捷分类自定义 logo + +把 4 张分类图标放到本目录(`admin-web/public/category-icons/`),文件名固定如下: + +| 分类 | 文件名 | 图片内容 | +|---|---|---| +| 美食 | `food.png` | 披萨 | +| 酒店 | `hotel.png` | 酒店楼(HOTEL) | +| 交通设施 | `transit.png` | 黄色出租车 | +| 景点 | `spot.png` | 天坛/古建 | + +建议:正方形、透明底 PNG,边长 ≥ 96px(显示时缩到 38px)。 + +放好后代码里 `POI_CATEGORY_ICON` 会引用 `/admin/category-icons/xxx.png`(`public/` 下的文件构建后映射到 `/admin/` 路径),重新 `npm run build` 即生效;未放图时自动回退到 emoji。 diff --git a/admin-web/public/category-icons/food.png b/admin-web/public/category-icons/food.png new file mode 100644 index 0000000..e360c89 Binary files /dev/null and b/admin-web/public/category-icons/food.png differ diff --git a/admin-web/public/category-icons/hotel.png b/admin-web/public/category-icons/hotel.png new file mode 100644 index 0000000..cf60ba0 Binary files /dev/null and b/admin-web/public/category-icons/hotel.png differ diff --git a/admin-web/public/category-icons/spot.png b/admin-web/public/category-icons/spot.png new file mode 100644 index 0000000..a3ea970 Binary files /dev/null and b/admin-web/public/category-icons/spot.png differ diff --git a/admin-web/public/category-icons/transit.png b/admin-web/public/category-icons/transit.png new file mode 100644 index 0000000..2c75a3b Binary files /dev/null and b/admin-web/public/category-icons/transit.png differ diff --git a/admin-web/src/App.tsx b/admin-web/src/App.tsx index 9f0301b..ef67e09 100644 --- a/admin-web/src/App.tsx +++ b/admin-web/src/App.tsx @@ -70,7 +70,7 @@ const RUNTIME_CONTEXT = { }; const MENU_ITEMS = [ - { key: "/projects", icon: , label: "项目工作区" }, + { key: "/projects", icon: , label: "图谱中心" }, { key: "/data", icon: , label: "数据中心" }, { key: "/plaza", icon: , label: "知识广场", children: [ { key: "/plaza/overview", label: "馆藏概览" }, @@ -169,6 +169,7 @@ export default function App() { const selectedKey = location.pathname.replace("/admin", "") || "/projects"; const openKeys = [selectedKey.split("/").slice(0, 2).join("/")]; const showProjectContextBar = location.pathname === "/admin/projects"; + const showDataCenterWorkbench = location.pathname === "/admin/data"; return ( @@ -225,7 +226,7 @@ export default function App() { - + {showProjectContextBar && } } /> diff --git a/admin-web/src/ProjectContextBar.tsx b/admin-web/src/ProjectContextBar.tsx index 0a28a38..5b6a43b 100644 --- a/admin-web/src/ProjectContextBar.tsx +++ b/admin-web/src/ProjectContextBar.tsx @@ -1,17 +1,21 @@ -import { useEffect, useMemo, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import { Select, Space, Tag, Typography, message } from "antd"; import { DatabaseOutlined, ProjectOutlined } from "@ant-design/icons"; import { DEFAULT_PROJECT_CONTEXT, - displayGraphName, + displayProjectName, displayStatusName, - displayTenantName, getProjectContext, listGraphReleases, listProjects, setProjectContext, type KgProjectContext, } from "./api"; +import { + mergeProjectCatalogItem, + PROJECT_CATALOG_CHANGED_EVENT, + type ProjectCatalogItem, +} from "./projectCatalog"; const { Text } = Typography; @@ -25,16 +29,31 @@ type ProjectRow = { type ReleaseRow = { graph_release_id: string | number; project_id: string; + tenant_id?: string; graph_name: string; + alias?: string; status?: string; }; +function preferredRelease(releases: ReleaseRow[]): ReleaseRow | undefined { + return releases.find((release) => release.status === "active") + || releases.find((release) => release.alias === "active") + || releases[0]; +} + +function releaseVersionLabel(release: ReleaseRow): string { + const rawVersion = String(release.alias || release.graph_release_id || "").trim(); + const version = rawVersion === "active" ? "当前版本" : rawVersion || "图谱版本"; + return `${version}${release.status ? ` · ${displayStatusName(release.status)}` : ""}`; +} + export default function ProjectContextBar() { const [context, setContextState] = useState(getProjectContext()); const [projects, setProjects] = useState([]); const [releases, setReleases] = useState([]); const [loadingProjects, setLoadingProjects] = useState(false); const [loadingReleases, setLoadingReleases] = useState(false); + const loadedReleaseProject = useRef(""); const currentProject = useMemo( () => projects.find((item) => item.project_id === context.projectId), @@ -59,27 +78,72 @@ export default function ProjectContextBar() { }; const refreshReleases = async (projectId: string) => { - if (!projectId) return; + if (!projectId) { + loadedReleaseProject.current = ""; + setReleases([]); + return; + } setLoadingReleases(true); try { const { data } = await listGraphReleases(projectId); setReleases(Array.isArray(data) ? data : []); + loadedReleaseProject.current = projectId; } catch { setReleases([]); + loadedReleaseProject.current = ""; + } finally { + setLoadingReleases(false); + } + }; + + const switchProject = async (projectId: string) => { + const project = projects.find((item) => item.project_id === projectId); + setLoadingReleases(true); + try { + const { data } = await listGraphReleases(projectId); + const nextReleases = Array.isArray(data) ? data : []; + const release = preferredRelease(nextReleases); + if (!release?.graph_name) { + message.warning(`项目“${project?.display_name || displayProjectName(projectId)}”暂无可用图谱版本`); + return; + } + setReleases(nextReleases); + loadedReleaseProject.current = projectId; + persist({ + tenantId: release.tenant_id || project?.tenant_id || projectId, + projectId, + graphName: release.graph_name, + }); + } catch { + message.error("图谱版本读取失败,未切换当前项目"); } finally { setLoadingReleases(false); } }; useEffect(() => { - refreshProjects(); - const onChange = () => setContextState(getProjectContext()); - window.addEventListener("znkg-project-context", onChange); - return () => window.removeEventListener("znkg-project-context", onChange); + void refreshProjects(); + const onContextChange = () => setContextState(getProjectContext()); + const onProjectCatalogChange = (event: Event) => { + const updatedProject = (event as CustomEvent).detail; + if (!updatedProject?.project_id) { + void refreshProjects(); + return; + } + setProjects((current) => mergeProjectCatalogItem(current, updatedProject)); + }; + window.addEventListener("znkg-project-context", onContextChange); + window.addEventListener(PROJECT_CATALOG_CHANGED_EVENT, onProjectCatalogChange); + return () => { + window.removeEventListener("znkg-project-context", onContextChange); + window.removeEventListener(PROJECT_CATALOG_CHANGED_EVENT, onProjectCatalogChange); + }; }, []); useEffect(() => { - refreshReleases(context.projectId); + if (loadedReleaseProject.current !== context.projectId) { + void refreshReleases(context.projectId); + } }, [context.projectId]); return ( @@ -90,47 +154,48 @@ export default function ProjectContextBar() { ({ value: release.graph_name, - label: `${displayGraphName(release.graph_name)}${release.status ? ` · ${displayStatusName(release.status)}` : ""}`, + label: releaseVersionLabel(release), })), - ...(releases.some((release) => release.graph_name === context.graphName) + ...(!context.graphName || releases.some((release) => release.graph_name === context.graphName) ? [] - : [{ value: context.graphName, label: displayGraphName(context.graphName) }]), + : [{ value: context.graphName, label: "当前图谱版本(兼容)" }]), ]} - onChange={(graphName) => persist({ ...context, graphName: graphName || context.projectId })} + onChange={(graphName) => { + const release = releases.find((item) => item.graph_name === graphName); + persist({ + ...context, + tenantId: release?.tenant_id || context.tenantId, + graphName, + }); + }} /> - {displayTenantName(context.tenantId)} {displayStatusName(currentProject?.status || "active")} diff --git a/admin-web/src/ProjectLanding.tsx b/admin-web/src/ProjectLanding.tsx index e99888a..f81a398 100644 --- a/admin-web/src/ProjectLanding.tsx +++ b/admin-web/src/ProjectLanding.tsx @@ -1,113 +1,1230 @@ -import { useEffect, useState } from "react"; -import { Alert, Button, Card, Form, Input, List, Modal, Space, Tag, Typography, message } from "antd"; -import { FolderOutlined, PlusOutlined } from "@ant-design/icons"; +import { useEffect, useRef, useState } from "react"; +import { + Alert, + Button, + Card, + Descriptions, + Dropdown, + Empty, + Form, + Input, + List, + Modal, + Space, + Spin, + Steps, + Tag, + Typography, + Upload, + message, +} from "antd"; +import type { MenuProps } from "antd"; +import { + CheckCircleOutlined, + DeleteOutlined, + EditOutlined, + FolderOutlined, + InboxOutlined, + MoreOutlined, + PlusOutlined, +} from "@ant-design/icons"; import { useNavigate } from "react-router-dom"; import { - createGraphRelease, - createProject, + DEFAULT_PROJECT_CONTEXT, + clearProjectContext, + deleteGraphProject, displayProjectName, displayStatusName, - displayTenantName, + getGraphProjectDeletionImpact, + getProjectContext, listGraphReleases, listProjects, + provisionGraphProject, setProjectContext, + updateGraphProject, + type ProjectDeletionImpact, + type ProjectSummary, + type GraphReleaseSummary, } from "./api"; +import { + mergeProjectCatalogItem, + notifyProjectCatalogChanged, +} from "./projectCatalog"; const { Paragraph, Text, Title } = Typography; +const { TextArea } = Input; -type ProjectRow = { +type BasicProjectValues = { project_id: string; - tenant_id?: string; - display_name?: string; - status?: string; - created_at?: string; + display_name: string; }; +type GraphImportData = { + nodes: Array>; + relations: Array>; +}; + +type ValidationResult = { + value?: T; + errors: string[]; +}; + +const SAFE_RESOURCE_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$/; +const SAFE_SCHEMA_IDENTIFIER_PATTERN = /^[A-Za-z_][A-Za-z0-9_]{0,127}$/; +const SAFE_SCHEMA_VERSION_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/; + +const FIELD_TYPE_ALIASES: Record = { + str: "string", + text: "string", + varchar: "string", + int: "integer", + long: "integer", + float: "number", + double: "number", + decimal: "number", + bool: "boolean", + json: "object", + dict: "object", + list: "array", + timestamp: "datetime", +}; + +const SUPPORTED_FIELD_TYPES = new Set([ + "string", + "integer", + "number", + "boolean", + "object", + "array", + "date", + "datetime", + "any", +]); + +const COUNT_LABELS: Record = { + projects: "项目元数据", + project_metadata: "项目元数据", + graph_releases: "图谱版本", + ontology_schemas: "本体 Schema", + ontology_entity_types: "实体类型定义", + ontology_fields: "本体字段定义", + ontology_relation_types: "关系类型定义", + import_templates: "导入模板", + import_batches: "导入批次", + raw_records: "原始记录", + candidate_entities: "候选实体", + candidate_relations: "候选关系", + validation_issues: "校验问题", + review_actions: "审核记录", + publish_jobs: "发布任务", + acquisition_tasks: "采集任务", + inventory_issues: "治理问题", + vocabulary_terms: "权威词条", + question_traces: "问答轨迹", + simulation_questions: "模拟问题", + agent_call_logs: "Agent 调用日志", + tasks: "任务记录", +}; + +function isRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +} + +function asRecord(value: unknown): Record { + return isRecord(value) ? value : {}; +} + +function backendBoolean(value: unknown): boolean { + if (value === null || value === undefined || value === false || value === 0 || value === "") { + return false; + } + if (Array.isArray(value)) return value.length > 0; + if (isRecord(value)) return Object.keys(value).length > 0; + return true; +} + +function projectName(project: ProjectSummary): string { + return project.display_name || project.name || displayProjectName(project.project_id); +} + +function apiErrorText(error: unknown, fallback: string): string { + const detail = (error as { response?: { data?: { detail?: unknown } } })?.response?.data?.detail; + if (typeof detail === "string" && detail.trim()) return detail; + if (Array.isArray(detail)) { + const messages = detail + .map((item) => { + if (typeof item === "string") return item; + if (!isRecord(item)) return undefined; + if (typeof item.msg === "string") return item.msg; + if (typeof item.message === "string") return item.message; + return undefined; + }) + .filter((item): item is string => typeof item === "string" && Boolean(item.trim())); + if (messages.length) return messages.join(";"); + } + if (isRecord(detail)) { + const messageText = typeof detail.message === "string" ? detail.message.trim() : ""; + const detailErrors = Array.isArray(detail.errors) + ? detail.errors + .map((item) => { + if (typeof item === "string") return item.trim(); + if (!isRecord(item)) return ""; + if (typeof item.msg === "string") return item.msg.trim(); + if (typeof item.message === "string") return item.message.trim(); + return ""; + }) + .filter(Boolean) + : []; + const messages = [messageText, ...detailErrors] + .filter((item, index, values) => Boolean(item) && values.indexOf(item) === index); + if (messages.length) return messages.join(";"); + } + return fallback; +} + +function parseJsonObject(text: string, label: string): ValidationResult> { + if (!text.trim()) return { errors: [`请上传或输入${label} JSON`] }; + try { + const parsed: unknown = JSON.parse(text); + if (!isRecord(parsed)) return { errors: [`${label} JSON 顶层必须是对象`] }; + return { value: parsed, errors: [] }; + } catch (error) { + return { + errors: [`${label} JSON 格式错误:${error instanceof Error ? error.message : "无法解析"}`], + }; + } +} + +function normalizeNamedDefinitions( + value: unknown, + kind: "实体类型" | "关系类型", + nameKeys: string[], + errors: string[], +): Record> { + if (isRecord(value)) { + const result: Record> = {}; + Object.entries(value).forEach(([rawName, rawValue]) => { + const name = rawName.trim(); + let definition: unknown = rawValue; + if (kind === "关系类型" && Array.isArray(definition)) { + if (definition.length < 2) { + errors.push(`关系类型 ${name || "<未命名>"} 至少需要 from 和 to`); + return; + } + definition = { + from: definition[0], + to: definition[1], + description: definition.length > 2 ? definition[2] : null, + properties: {}, + }; + } + if (!isRecord(definition)) { + errors.push(`${kind} ${name || "<未命名>"} 的定义必须是 JSON 对象`); + return; + } + result[name] = { ...definition }; + }); + return result; + } + + if (Array.isArray(value)) { + const result: Record> = {}; + value.forEach((rawDefinition, index) => { + if (!isRecord(rawDefinition)) { + errors.push(`${kind}列表第 ${index + 1} 项必须是 JSON 对象`); + return; + } + const name = nameKeys + .map((key) => String(rawDefinition[key] || "").trim()) + .find(Boolean) || ""; + if (!name) { + errors.push(`${kind}列表第 ${index + 1} 项缺少名称`); + return; + } + result[name] = { ...rawDefinition }; + }); + return result; + } + + errors.push(`${kind}必须是 JSON 对象或数组`); + return {}; +} + +function normalizeFieldDefinitions( + value: unknown, + path: string, + errors: string[], +): Record> { + if (value === null || value === undefined) return {}; + + let items: Array<[string, unknown]> = []; + if (isRecord(value)) { + items = Object.entries(value); + } else if (Array.isArray(value)) { + value.forEach((item, index) => { + if (typeof item === "string") { + items.push([item, { type: "string" }]); + return; + } + if (!isRecord(item)) { + errors.push(`${path} 第 ${index + 1} 个字段定义无效`); + return; + } + const name = String(item.name || item.field_name || "").trim(); + if (!name) { + errors.push(`${path} 第 ${index + 1} 个字段缺少名称`); + return; + } + items.push([name, item]); + }); + } else { + errors.push(`${path} 必须是 JSON 对象或数组`); + return {}; + } + + const fields: Record> = {}; + items.forEach(([rawName, rawDefinition]) => { + const fieldName = rawName.trim(); + if (!fieldName) { + errors.push(`${path} 包含空字段名`); + return; + } + + let definition: Record; + if (typeof rawDefinition === "string") { + definition = { type: rawDefinition }; + } else if (isRecord(rawDefinition)) { + definition = { ...rawDefinition }; + } else { + errors.push(`${path}.${fieldName} 的字段定义必须是对象或类型字符串`); + return; + } + + const rawType = String(definition.type || definition.value_type || "string").toLowerCase().trim(); + const valueType = FIELD_TYPE_ALIASES[rawType] || rawType; + if (!SUPPORTED_FIELD_TYPES.has(valueType)) { + errors.push(`${path}.${fieldName} 使用了不支持的类型 “${rawType}”`); + return; + } + fields[fieldName] = { + ...definition, + type: valueType, + required: backendBoolean(definition.required), + }; + }); + return fields; +} + +function endpointTypes(value: unknown): string[] { + return String(value || "") + .split("|") + .map((item) => item.trim()) + .filter(Boolean); +} + +function validateSchemaText( + text: string, + fallbackNamespace = "", +): ValidationResult> { + const parsed = parseJsonObject(text, "本体 Schema"); + if (!parsed.value) return parsed; + + const schema = parsed.value; + const errors: string[] = []; + const namespace = String(schema.namespace || fallbackNamespace || "").trim(); + const version = String(schema.version || "").trim(); + if (!namespace) errors.push("namespace 不能为空"); + else if (!SAFE_RESOURCE_ID_PATTERN.test(namespace)) errors.push("namespace 只能包含安全字符"); + if (!version) errors.push("version 不能为空"); + else if (!SAFE_SCHEMA_VERSION_PATTERN.test(version)) { + errors.push("version 只能包含字母、数字、点、下划线和连字符,最长 64 个字符"); + } + + const entityTypes = normalizeNamedDefinitions( + schema.entity_types, + "实体类型", + ["name", "entity_type"], + errors, + ); + if (!Object.keys(entityTypes).length) errors.push("entity_types 至少需要一个实体类型"); + + const normalizedEntityTypes: Record> = {}; + Object.entries(entityTypes).forEach(([entityName, rawDefinition]) => { + if (!SAFE_SCHEMA_IDENTIFIER_PATTERN.test(entityName)) { + errors.push(`实体类型 “${entityName}” 不是安全的 Schema/Cypher 标识符`); + return; + } + normalizedEntityTypes[entityName] = { + ...rawDefinition, + fields: normalizeFieldDefinitions( + rawDefinition.fields, + `entity_types.${entityName}.fields`, + errors, + ), + }; + }); + + const relationTypes = normalizeNamedDefinitions( + schema.relation_types === undefined ? {} : schema.relation_types, + "关系类型", + ["name", "relation_type"], + errors, + ); + const normalizedRelationTypes: Record> = {}; + Object.entries(relationTypes).forEach(([relationName, rawDefinition]) => { + if (!SAFE_SCHEMA_IDENTIFIER_PATTERN.test(relationName)) { + errors.push(`关系类型 “${relationName}” 不是安全的 Schema/Cypher 标识符`); + return; + } + const sourceType = String(rawDefinition.from || rawDefinition.source || "").trim(); + const targetType = String(rawDefinition.to || rawDefinition.target || "").trim(); + const sourceTypes = endpointTypes(sourceType); + const targetTypes = endpointTypes(targetType); + if ( + !sourceTypes.length + || sourceTypes.some((item) => !Object.prototype.hasOwnProperty.call(normalizedEntityTypes, item)) + ) { + errors.push(`关系 ${relationName} 的 from/source 必须引用已定义实体类型`); + } + if ( + !targetTypes.length + || targetTypes.some((item) => !Object.prototype.hasOwnProperty.call(normalizedEntityTypes, item)) + ) { + errors.push(`关系 ${relationName} 的 to/target 必须引用已定义实体类型`); + } + normalizedRelationTypes[relationName] = { + ...rawDefinition, + from: sourceType, + to: targetType, + properties: normalizeFieldDefinitions( + rawDefinition.properties, + `relation_types.${relationName}.properties`, + errors, + ), + }; + }); + + return errors.length + ? { errors } + : { + value: { + ...schema, + namespace, + version, + entity_types: normalizedEntityTypes, + relation_types: normalizedRelationTypes, + }, + errors: [], + }; +} + +function valueMatchesType(value: unknown, rawType: unknown): boolean { + if (value === null || value === undefined) return true; + const rawValueType = String(rawType || "string").trim().toLowerCase(); + const valueType = FIELD_TYPE_ALIASES[rawValueType] || rawValueType; + if (["string", "date", "datetime"].includes(valueType)) { + return typeof value === "string"; + } + if (valueType === "number") { + return typeof value === "number" && Number.isFinite(value); + } + if (valueType === "integer") return Number.isInteger(value); + if (valueType === "boolean") return typeof value === "boolean"; + if (valueType === "array") return Array.isArray(value); + if (valueType === "object") return isRecord(value); + if (valueType === "any") return true; + return false; +} + +function validateProperties( + properties: unknown, + definitions: Record>, + path: string, + errors: string[], +) { + if (!isRecord(properties)) { + errors.push(`${path} 必须是对象`); + return; + } + Object.entries(definitions).forEach(([fieldName, definition]) => { + const valuePresent = Object.prototype.hasOwnProperty.call(properties, fieldName) + && properties[fieldName] !== null + && properties[fieldName] !== undefined; + if (definition.required && !valuePresent) { + errors.push(`${path}.${fieldName} 是必填字段`); + return; + } + if (valuePresent && !valueMatchesType(properties[fieldName], definition.type)) { + errors.push(`${path}.${fieldName} 不符合类型 ${String(definition.type)}`); + } + }); +} + +function validateGraphText( + text: string, + schema: Record, +): ValidationResult { + const parsed = parseJsonObject(text, "图谱数据"); + if (!parsed.value) return parsed as ValidationResult; + + const graphData = parsed.value; + const errors: string[] = []; + const rawNodes = graphData.nodes === undefined ? [] : graphData.nodes; + const rawRelations = graphData.relations === undefined ? [] : graphData.relations; + if (!Array.isArray(rawNodes)) errors.push("nodes 必须是数组"); + if (!Array.isArray(rawRelations)) errors.push("relations 必须是数组"); + if (errors.length) return { errors }; + + const nodes = rawNodes as unknown[]; + const relations = rawRelations as unknown[]; + if (!nodes.length) errors.push("nodes 至少需要一个节点,FalkorDB 不支持持久化空图"); + const entityTypes = asRecord(schema.entity_types); + const relationTypes = asRecord(schema.relation_types); + const nodeTypes = new Map(); + const normalizedNodes: Array> = []; + + nodes.forEach((rawNode, index) => { + const path = `nodes[${index}]`; + if (!isRecord(rawNode)) { + errors.push(`${path} 必须是对象`); + return; + } + const id = String(rawNode.id || "").trim(); + const entityType = String(rawNode.type || rawNode.entity_type || "").trim(); + const properties = rawNode.properties === undefined ? {} : rawNode.properties; + const normalizedProperties = isRecord(properties) ? properties : {}; + if (!id) errors.push(`${path}.id 必须是非空字符串`); + else if (nodeTypes.has(id)) errors.push(`节点 ID ${id} 重复`); + else nodeTypes.set(id, entityType); + if (!entityType) errors.push(`${path}.type 必须是非空字符串`); + const entityDefinition = entityTypes[entityType]; + if (entityType && !isRecord(entityDefinition)) { + errors.push(`${path}.type ${entityType} 未在 Schema 中定义`); + } + if (!isRecord(properties)) errors.push(`${path}.properties 必须是对象`); + if (isRecord(entityDefinition)) { + const fields = normalizeFieldDefinitions( + entityDefinition.fields, + `entity_types.${entityType}.fields`, + errors, + ); + validateProperties(normalizedProperties, fields, `${path}.properties`, errors); + } + normalizedNodes.push({ id, type: entityType, properties: normalizedProperties }); + }); + + const normalizedRelations: Array> = []; + relations.forEach((rawRelation, index) => { + const path = `relations[${index}]`; + if (!isRecord(rawRelation)) { + errors.push(`${path} 必须是对象`); + return; + } + const relationType = String(rawRelation.type || rawRelation.relation_type || "").trim(); + const source = String(rawRelation.source || "").trim(); + const target = String(rawRelation.target || "").trim(); + const properties = rawRelation.properties === undefined ? {} : rawRelation.properties; + const normalizedProperties = isRecord(properties) ? properties : {}; + if (!relationType) errors.push(`${path}.type 必须是非空字符串`); + const relationDefinition = relationTypes[relationType]; + if (relationType && !isRecord(relationDefinition)) { + errors.push(`${path}.type ${relationType} 未在 Schema 中定义`); + } + if (!source) errors.push(`${path}.source 必须是非空字符串`); + else if (!nodeTypes.has(source)) errors.push(`${path}.source 引用的节点 ${source} 不存在`); + if (!target) errors.push(`${path}.target 必须是非空字符串`); + else if (!nodeTypes.has(target)) errors.push(`${path}.target 引用的节点 ${target} 不存在`); + if (!isRecord(properties)) errors.push(`${path}.properties 必须是对象`); + + if (isRecord(relationDefinition)) { + const expectedSource = String(relationDefinition.from || "").trim(); + const expectedTarget = String(relationDefinition.to || "").trim(); + if ( + source + && nodeTypes.has(source) + && expectedSource + && !endpointTypes(expectedSource).includes(nodeTypes.get(source) || "") + ) { + errors.push(`${path}.source 节点类型应为 ${expectedSource}`); + } + if ( + target + && nodeTypes.has(target) + && expectedTarget + && !endpointTypes(expectedTarget).includes(nodeTypes.get(target) || "") + ) { + errors.push(`${path}.target 节点类型应为 ${expectedTarget}`); + } + const definitions = normalizeFieldDefinitions( + relationDefinition.properties, + `relation_types.${relationType}.properties`, + errors, + ); + validateProperties(normalizedProperties, definitions, `${path}.properties`, errors); + } + normalizedRelations.push({ + type: relationType, + source, + target, + properties: normalizedProperties, + }); + }); + + if (errors.length) return { errors }; + return { + value: { + nodes: normalizedNodes, + relations: normalizedRelations, + }, + errors: [], + }; +} + +function validationAlert(title: string, errors: string[]) { + if (!errors.length) return null; + const shown = errors.slice(0, 12); + return ( + + {shown.map((error, index) =>
  • {error}
  • )} + {errors.length > shown.length &&
  • 其余 {errors.length - shown.length} 项请修正后重新校验
  • } + + )} + /> + ); +} + export default function ProjectLanding() { - const [projects, setProjects] = useState([]); + const [projects, setProjects] = useState([]); const [loading, setLoading] = useState(false); - const [open, setOpen] = useState(false); - const [form] = Form.useForm(); + const [createOpen, setCreateOpen] = useState(false); + const [wizardStep, setWizardStep] = useState(0); + const [basicValues, setBasicValues] = useState(null); + const [checkingBasics, setCheckingBasics] = useState(false); + const [schemaText, setSchemaText] = useState(""); + const [schemaFileName, setSchemaFileName] = useState(""); + const [schemaObject, setSchemaObject] = useState | null>(null); + const [schemaErrors, setSchemaErrors] = useState([]); + const [graphText, setGraphText] = useState(""); + const [graphFileName, setGraphFileName] = useState(""); + const [graphObject, setGraphObject] = useState(null); + const [graphErrors, setGraphErrors] = useState([]); + const [provisioning, setProvisioning] = useState(false); + const [provisionError, setProvisionError] = useState(""); + + const [renameTarget, setRenameTarget] = useState(null); + const [renaming, setRenaming] = useState(false); + const [deleteTarget, setDeleteTarget] = useState(null); + const [deletionImpact, setDeletionImpact] = useState(null); + const [impactLoading, setImpactLoading] = useState(false); + const [impactError, setImpactError] = useState(""); + const [deleteConfirmation, setDeleteConfirmation] = useState(""); + const [deleting, setDeleting] = useState(false); + + const [basicForm] = Form.useForm(); + const [renameForm] = Form.useForm<{ display_name: string }>(); + const impactRequest = useRef(0); const navigate = useNavigate(); - const refresh = async () => { + const refresh = async (): Promise => { setLoading(true); try { const { data } = await listProjects(); - setProjects(Array.isArray(data) ? data : []); - } catch { - message.error("项目列表加载失败"); + const next = Array.isArray(data) ? data : []; + setProjects(next); + return next; + } catch (error) { + message.error(apiErrorText(error, "项目列表加载失败")); + return null; } finally { setLoading(false); } }; useEffect(() => { - refresh(); + void refresh(); }, []); - const enterProject = async (project: ProjectRow) => { - let graphName = project.project_id; - try { - const { data } = await listGraphReleases(project.project_id); - const releases = Array.isArray(data) ? data : []; - graphName = releases.find((row: any) => row.status === "active")?.graph_name - || releases[0]?.graph_name - || project.project_id; - } catch { - graphName = project.project_id; - } - setProjectContext({ - tenantId: project.tenant_id || project.project_id, - projectId: project.project_id, - graphName, - }); - navigate("/admin/plaza/overview"); + const resolveGraphRelease = async (project: ProjectSummary): Promise => { + const { data } = await listGraphReleases(project.project_id); + const releases = Array.isArray(data) ? data : []; + return releases.find((row) => row.status === "active") + || releases.find((row) => row.alias === "active") + || releases[0] + || null; }; - const submit = async (values: any) => { - const projectId = String(values.project_id || "").trim(); - const graphName = String(values.graph_name || projectId).trim(); - if (!projectId) return; + const enterProject = async (project: ProjectSummary) => { try { - await createProject({ - project_id: projectId, - tenant_id: values.tenant_id || projectId, - display_name: values.display_name || projectId, - status: "active", - }); - await createGraphRelease(projectId, { - graph_name: graphName, - status: "active", - }); + const release = await resolveGraphRelease(project); + if (!release?.graph_name) { + message.warning(`项目“${projectName(project)}”暂无可用图谱版本`); + return; + } setProjectContext({ - tenantId: values.tenant_id || projectId, + tenantId: release.tenant_id || project.tenant_id || project.project_id, + projectId: project.project_id, + graphName: release.graph_name, + }); + navigate("/admin/plaza/overview"); + } catch (error) { + message.error(apiErrorText(error, `无法读取项目 ${project.project_id} 的图谱版本,请重试`)); + } + }; + + const resetWizard = () => { + setWizardStep(0); + setBasicValues(null); + setCheckingBasics(false); + setSchemaText(""); + setSchemaFileName(""); + setSchemaObject(null); + setSchemaErrors([]); + setGraphText(""); + setGraphFileName(""); + setGraphObject(null); + setGraphErrors([]); + setProvisionError(""); + basicForm.resetFields(); + }; + + const openCreateWizard = () => { + resetWizard(); + setCreateOpen(true); + }; + + const closeCreateWizard = () => { + if (checkingBasics || provisioning) return; + setCreateOpen(false); + resetWizard(); + }; + + const validateBasicStep = async () => { + let values: BasicProjectValues; + try { + values = await basicForm.validateFields(); + } catch { + return false; + } + const normalized: BasicProjectValues = { + project_id: values.project_id.trim(), + display_name: values.display_name.trim(), + }; + setCheckingBasics(true); + try { + const { data: latestProjectData } = await listProjects(); + const latestProjects = Array.isArray(latestProjectData) ? latestProjectData : []; + setProjects(latestProjects); + if (latestProjects.some((project) => project.project_id === normalized.project_id)) { + basicForm.setFields([{ name: "project_id", errors: ["项目英文标识已存在,不能覆盖已有项目"] }]); + return false; + } + } catch (error) { + message.error(apiErrorText(error, "项目英文标识占用检查失败,请重试")); + return false; + } finally { + setCheckingBasics(false); + } + + setBasicValues(normalized); + return true; + }; + + const loadJsonFile = async (file: File, kind: "schema" | "graph") => { + try { + const text = await file.text(); + const parsed: unknown = JSON.parse(text); + if (!isRecord(parsed)) throw new Error("JSON 顶层必须是对象"); + const formatted = JSON.stringify(parsed, null, 2); + if (kind === "schema") { + setSchemaText(formatted); + setSchemaFileName(file.name); + setSchemaObject(null); + setSchemaErrors([]); + } else { + setGraphText(formatted); + setGraphFileName(file.name); + setGraphObject(null); + setGraphErrors([]); + } + message.success(`${file.name} 已读取,可继续编辑`); + } catch (error) { + const errorText = error instanceof Error ? error.message : "无法读取文件"; + if (kind === "schema") setSchemaErrors([`本体 Schema JSON 格式错误:${errorText}`]); + else setGraphErrors([`图谱数据 JSON 格式错误:${errorText}`]); + message.error(`${file.name} 不是有效的 JSON 文件`); + } + }; + + const formatJson = (kind: "schema" | "graph") => { + const text = kind === "schema" ? schemaText : graphText; + const parsed = parseJsonObject(text, kind === "schema" ? "本体 Schema" : "图谱数据"); + if (!parsed.value) { + if (kind === "schema") setSchemaErrors(parsed.errors); + else setGraphErrors(parsed.errors); + return; + } + const formatted = JSON.stringify(parsed.value, null, 2); + if (kind === "schema") { + setSchemaText(formatted); + setSchemaErrors([]); + } else { + setGraphText(formatted); + setGraphErrors([]); + } + }; + + const validateSchemaStep = () => { + const result = validateSchemaText(schemaText, basicValues?.project_id); + setSchemaErrors(result.errors); + setSchemaObject(result.value || null); + return Boolean(result.value); + }; + + const validateGraphStep = () => { + if (!schemaObject) { + setSchemaErrors(["请先完成本体 Schema 校验"]); + setWizardStep(1); + return false; + } + const result = validateGraphText(graphText, schemaObject); + setGraphErrors(result.errors); + setGraphObject(result.value || null); + return Boolean(result.value); + }; + + const submitProvision = async () => { + if (!basicValues) { + setWizardStep(0); + return; + } + const schemaResult = validateSchemaText(schemaText, basicValues.project_id); + if (!schemaResult.value) { + setSchemaErrors(schemaResult.errors); + setWizardStep(1); + return; + } + const graphResult = validateGraphText(graphText, schemaResult.value); + if (!graphResult.value) { + setGraphErrors(graphResult.errors); + setWizardStep(2); + return; + } + + setProvisioning(true); + setProvisionError(""); + try { + const { data } = await provisionGraphProject({ + project_id: basicValues.project_id, + display_name: basicValues.display_name, + schema: schemaResult.value, + graph_data: graphResult.value, + }); + const createdProject = data.project; + const projectId = String(createdProject?.project_id || basicValues.project_id).trim(); + const tenantId = String( + createdProject?.tenant_id || data.graph_release?.tenant_id || projectId, + ).trim(); + const graphName = String( + data.graph_release?.graph_name || data.graph?.graph_name || "", + ).trim(); + setProjectContext({ + tenantId, projectId, graphName, }); - message.success("项目已创建并切换"); - setOpen(false); - form.resetFields(); + message.success("项目、Schema 与图谱数据已创建"); + setCreateOpen(false); + resetWizard(); await refresh(); - navigate("/admin/plaza/overview"); - } catch (error: any) { - message.error(error?.response?.data?.detail || "项目创建失败"); + if (graphName) { + navigate("/admin/plaza/overview"); + } else { + message.warning("项目已创建,但接口未返回可用图谱版本;请从项目列表重新进入"); + } + } catch (error) { + const detail = apiErrorText(error, "项目创建失败,请检查输入后重试"); + setProvisionError(detail); + message.error(detail); + } finally { + setProvisioning(false); } }; + const nextWizardStep = async () => { + if (wizardStep === 0) { + if (await validateBasicStep()) setWizardStep(1); + return; + } + if (wizardStep === 1) { + if (validateSchemaStep()) setWizardStep(2); + return; + } + if (wizardStep === 2) { + if (validateGraphStep()) setWizardStep(3); + return; + } + await submitProvision(); + }; + + const openRename = (project: ProjectSummary) => { + setRenameTarget(project); + renameForm.setFieldsValue({ display_name: projectName(project) }); + }; + + const saveRename = async () => { + if (!renameTarget) return; + let values: { display_name: string }; + try { + values = await renameForm.validateFields(); + } catch { + return; + } + setRenaming(true); + try { + const displayName = values.display_name.trim(); + const { data } = await updateGraphProject(renameTarget.project_id, { + display_name: displayName, + }); + const updatedProject: ProjectSummary = { + ...renameTarget, + ...data, + project_id: renameTarget.project_id, + display_name: data?.display_name || displayName, + }; + setProjects((current) => mergeProjectCatalogItem(current, updatedProject)); + notifyProjectCatalogChanged(updatedProject); + setRenameTarget(null); + renameForm.resetFields(); + await refresh(); + message.success("项目名称已修改,项目英文标识与图谱数据保持不变"); + } catch (error) { + message.error(apiErrorText(error, "项目名称修改失败")); + } finally { + setRenaming(false); + } + }; + + const loadDeletionImpact = async (project: ProjectSummary) => { + const requestId = ++impactRequest.current; + setImpactLoading(true); + setImpactError(""); + setDeletionImpact(null); + try { + const { data } = await getGraphProjectDeletionImpact(project.project_id); + if (impactRequest.current === requestId) setDeletionImpact(data); + } catch (error) { + if (impactRequest.current === requestId) { + setImpactError(apiErrorText(error, "删除影响分析加载失败")); + } + } finally { + if (impactRequest.current === requestId) setImpactLoading(false); + } + }; + + const openDelete = (project: ProjectSummary) => { + setDeleteTarget(project); + setDeleteConfirmation(""); + void loadDeletionImpact(project); + }; + + const closeDelete = () => { + if (deleting) return; + impactRequest.current += 1; + setDeleteTarget(null); + setDeletionImpact(null); + setImpactError(""); + setDeleteConfirmation(""); + setImpactLoading(false); + }; + + const projectActionMenu = (project: ProjectSummary): MenuProps => ({ + items: [ + { key: "rename", icon: , label: "修改项目名称" }, + { type: "divider" }, + { key: "delete", icon: , label: "删除项目", danger: true }, + ], + onClick: ({ key, domEvent }) => { + domEvent.preventDefault(); + domEvent.stopPropagation(); + if (key === "rename") openRename(project); + if (key === "delete") openDelete(project); + }, + }); + + const confirmDelete = async () => { + if ( + !deleteTarget + || !deletionImpact + || deleteConfirmation !== deleteTarget.project_id + ) return; + + const deletedProjectId = deleteTarget.project_id; + const deletingCurrentProject = getProjectContext().projectId === deletedProjectId; + const knownRemainingProjects = projects.filter( + (project) => project.project_id !== deletedProjectId, + ); + setDeleting(true); + try { + const { data: deletionResult } = await deleteGraphProject( + deletedProjectId, + deleteConfirmation, + ); + if (deletingCurrentProject) { + clearProjectContext(); + } + setProjects(knownRemainingProjects); + impactRequest.current += 1; + setDeleteTarget(null); + setDeletionImpact(null); + setDeleteConfirmation(""); + const refreshedProjects = await refresh(); + const remainingProjects = refreshedProjects ?? knownRemainingProjects; + if (deletingCurrentProject && remainingProjects.length) { + const fallback = remainingProjects.find( + (project) => project.project_id === DEFAULT_PROJECT_CONTEXT.projectId, + ) || remainingProjects[0]; + try { + const release = await resolveGraphRelease(fallback); + if (!release?.graph_name) { + message.warning(`备用项目“${projectName(fallback)}”暂无可用图谱版本;当前上下文保持为空`); + } else { + setProjectContext({ + tenantId: release.tenant_id || fallback.tenant_id || fallback.project_id, + projectId: fallback.project_id, + graphName: release.graph_name, + }); + } + } catch (error) { + message.error(apiErrorText( + error, + `项目已删除,但无法读取备用项目 ${fallback.project_id} 的图谱版本;当前上下文保持为空`, + )); + } + } else if (deletingCurrentProject) { + message.info("已无可用知识图谱项目,当前项目上下文已清空"); + } + const cleanupPending = Array.isArray(deletionResult.cleanup_pending) + ? deletionResult.cleanup_pending + : []; + if (cleanupPending.length) { + message.warning( + `项目元数据已删除;${cleanupPending.length} 个 FalkorDB 图等待补偿清理,PostgreSQL 数据中心业务数据未受影响`, + ); + Modal.warning({ + title: "FalkorDB 补偿清理任务待重试", + width: 640, + content: ( +
    + + PostgreSQL 项目元数据已安全删除。以下独占图未能立即清理,后端已保留可重试的补偿任务: + + {cleanupPending.map((item) => ( +
    + + {item.graph_name} + {item.task_id !== undefined && 任务 #{item.task_id}} + + {item.error || "未返回具体错误"} +
    + ))} +
    + ), + }); + } else { + message.success("知识图谱项目已删除,PostgreSQL 数据中心业务数据未受影响"); + } + } catch (error) { + message.error(apiErrorText(error, "项目删除失败")); + } finally { + setDeleting(false); + } + }; + + const createStepContent = (() => { + if (wizardStep === 0) { + return ( +
    +
    + + + + + + +
    + + + ); + } + + if (wizardStep === 1) { + return ( +
    +
    + { + void loadJsonFile(file, "schema"); + return Upload.LIST_IGNORE; + }} + className="project-json-uploader" + > +

    +

    上传本体 Schema JSON

    +

    {schemaFileName || "点击或拖拽文件;读取后仍可编辑"}

    +
    +
    +
    + Schema JSON + +
    +