feat: implement project relational data platform
This commit is contained in:
@@ -115,6 +115,48 @@ export const listGraphReleases = (projectId: string) =>
|
||||
api.get(`/projects/${encodeURIComponent(projectId)}/graph-releases`);
|
||||
export const createGraphRelease = (projectId: string, data: unknown) =>
|
||||
api.post(`/projects/${encodeURIComponent(projectId)}/graph-releases`, data);
|
||||
|
||||
// ── Project relational data platform ──
|
||||
export const listProjectDatabases = () =>
|
||||
api.get("/data-platform/databases");
|
||||
export const listProjectDataTables = (projectId: string) =>
|
||||
api.get(`/data-platform/databases/${encodeURIComponent(projectId)}/tables`);
|
||||
export const listProjectTableRecords = (
|
||||
projectId: string,
|
||||
tableCode: string,
|
||||
params?: Record<string, unknown>,
|
||||
) =>
|
||||
api.get(
|
||||
`/data-platform/databases/${encodeURIComponent(projectId)}/tables/${encodeURIComponent(tableCode)}/records`,
|
||||
{ params },
|
||||
);
|
||||
export const createProjectTableRecord = (
|
||||
projectId: string,
|
||||
tableCode: string,
|
||||
data: unknown,
|
||||
) =>
|
||||
api.post(
|
||||
`/data-platform/databases/${encodeURIComponent(projectId)}/tables/${encodeURIComponent(tableCode)}/records`,
|
||||
data,
|
||||
);
|
||||
export const updateProjectTableRecord = (
|
||||
projectId: string,
|
||||
tableCode: string,
|
||||
recordId: string,
|
||||
data: unknown,
|
||||
) =>
|
||||
api.patch(
|
||||
`/data-platform/databases/${encodeURIComponent(projectId)}/tables/${encodeURIComponent(tableCode)}/records/${encodeURIComponent(recordId)}`,
|
||||
data,
|
||||
);
|
||||
export const deleteProjectTableRecord = (
|
||||
projectId: string,
|
||||
tableCode: string,
|
||||
recordId: string,
|
||||
) =>
|
||||
api.delete(
|
||||
`/data-platform/databases/${encodeURIComponent(projectId)}/tables/${encodeURIComponent(tableCode)}/records/${encodeURIComponent(recordId)}`,
|
||||
);
|
||||
export const listOntologySchemas = () => api.get("/ontology-schemas");
|
||||
export const getCurrentOntologySchema = () => api.get("/ontology-schemas/current");
|
||||
export const getOntologySchema = (schemaId: string | number) =>
|
||||
|
||||
@@ -1,73 +1,373 @@
|
||||
import {
|
||||
ApartmentOutlined,
|
||||
CheckCircleOutlined,
|
||||
ClockCircleOutlined,
|
||||
DatabaseOutlined,
|
||||
ExportOutlined,
|
||||
ImportOutlined,
|
||||
SyncOutlined,
|
||||
DeleteOutlined,
|
||||
EditOutlined,
|
||||
FolderOpenOutlined,
|
||||
PlusOutlined,
|
||||
ReloadOutlined,
|
||||
SearchOutlined,
|
||||
TableOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import { Card, Col, Row, Space, Tag, Typography } from "antd";
|
||||
import { displayProjectName, getProjectContext } from "../../api";
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
Col,
|
||||
Empty,
|
||||
Form,
|
||||
Input,
|
||||
InputNumber,
|
||||
Modal,
|
||||
Popconfirm,
|
||||
Row,
|
||||
Select,
|
||||
Space,
|
||||
Spin,
|
||||
Statistic,
|
||||
Switch,
|
||||
Table,
|
||||
Tag,
|
||||
Typography,
|
||||
message,
|
||||
} from "antd";
|
||||
import type { ColumnsType } from "antd/es/table";
|
||||
import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react";
|
||||
import {
|
||||
createProjectTableRecord,
|
||||
deleteProjectTableRecord,
|
||||
getProjectContext,
|
||||
listProjectDatabases,
|
||||
listProjectDataTables,
|
||||
listProjectTableRecords,
|
||||
updateProjectTableRecord,
|
||||
} from "../../api";
|
||||
|
||||
const { Paragraph, Text, Title } = Typography;
|
||||
const { Search } = Input;
|
||||
|
||||
const capabilityCards = [
|
||||
{
|
||||
icon: <TableOutlined />,
|
||||
title: "正式业务数据",
|
||||
description: "统一管理酒店、美食、景区、交通及其明细表。",
|
||||
status: "第一阶段",
|
||||
},
|
||||
{
|
||||
icon: <ImportOutlined />,
|
||||
title: "批量导入",
|
||||
description: "接收处理完成的 CSV、XLSX 或多表 ZIP 数据包。",
|
||||
status: "第二阶段",
|
||||
},
|
||||
{
|
||||
icon: <ExportOutlined />,
|
||||
title: "批量导出",
|
||||
description: "按数据类型导出正式数据、明细表和数据字典。",
|
||||
status: "第二阶段",
|
||||
},
|
||||
{
|
||||
icon: <SyncOutlined />,
|
||||
title: "图谱同步",
|
||||
description: "把 PostgreSQL 正式数据同步为 FalkorDB 图谱投影。",
|
||||
status: "第三阶段",
|
||||
},
|
||||
];
|
||||
type ProjectDatabase = {
|
||||
project_id: string;
|
||||
tenant_id: string;
|
||||
display_name: string;
|
||||
database_name: string;
|
||||
schema_name: string;
|
||||
status: string;
|
||||
project_status: string;
|
||||
table_count: number;
|
||||
record_count: number;
|
||||
updated_at?: string | null;
|
||||
};
|
||||
|
||||
const dataGroups = [
|
||||
{
|
||||
icon: <DatabaseOutlined />,
|
||||
title: "正式实体",
|
||||
tables: "poi_entities",
|
||||
description: "保存酒店、美食、景区、交通等统一基础信息。",
|
||||
},
|
||||
{
|
||||
icon: <ApartmentOutlined />,
|
||||
title: "业务明细",
|
||||
tables: "房型、设施、套餐、评论、公交线路与站序",
|
||||
description: "一对多数据使用独立关系表,不再拼接成长文本。",
|
||||
},
|
||||
{
|
||||
icon: <CheckCircleOutlined />,
|
||||
title: "必要运行记录",
|
||||
tables: "导入、导出、修改日志",
|
||||
description: "只记录正式数据操作结果,不保存原始采集和候选数据。",
|
||||
},
|
||||
{
|
||||
icon: <SyncOutlined />,
|
||||
title: "图谱投影",
|
||||
tables: "graph_sync_queue",
|
||||
description: "PostgreSQL 是权威数据源,FalkorDB 可按项目重新生成。",
|
||||
},
|
||||
];
|
||||
type DataField = {
|
||||
code: string;
|
||||
label: string;
|
||||
data_type: string;
|
||||
required: boolean;
|
||||
searchable: boolean;
|
||||
sortable: boolean;
|
||||
editable: boolean;
|
||||
visible_in_list: boolean;
|
||||
options: string[];
|
||||
};
|
||||
|
||||
type DataTableMeta = {
|
||||
code: string;
|
||||
label: string;
|
||||
group: string;
|
||||
description: string;
|
||||
primary_key: string;
|
||||
title_field: string;
|
||||
allow_create: boolean;
|
||||
allow_update: boolean;
|
||||
allow_delete: boolean;
|
||||
fields: DataField[];
|
||||
record_count: number;
|
||||
updated_at?: string | null;
|
||||
};
|
||||
|
||||
type RecordRow = Record<string, unknown> & { id: string };
|
||||
|
||||
const groupIcons: Record<string, ReactNode> = {
|
||||
正式实体: <DatabaseOutlined />,
|
||||
公共明细: <ApartmentOutlined />,
|
||||
酒店: <FolderOpenOutlined />,
|
||||
美食: <FolderOpenOutlined />,
|
||||
景区: <FolderOpenOutlined />,
|
||||
交通: <FolderOpenOutlined />,
|
||||
};
|
||||
|
||||
function errorText(error: unknown, fallback: string) {
|
||||
const detail = (error as { response?: { data?: { detail?: string } } })?.response?.data?.detail;
|
||||
return detail || fallback;
|
||||
}
|
||||
|
||||
function formatDate(value?: string | null) {
|
||||
if (!value) return "尚无数据";
|
||||
const date = new Date(value);
|
||||
return Number.isNaN(date.getTime()) ? String(value) : date.toLocaleString();
|
||||
}
|
||||
|
||||
function displayValue(value: unknown, field?: DataField) {
|
||||
if (value === null || value === undefined || value === "") return "—";
|
||||
if (field?.data_type === "boolean") return value ? "是" : "否";
|
||||
if (field?.data_type === "datetime") return formatDate(String(value));
|
||||
if (typeof value === "object") return JSON.stringify(value);
|
||||
return String(value);
|
||||
}
|
||||
|
||||
function fieldControl(field: DataField) {
|
||||
if (field.options?.length) {
|
||||
return (
|
||||
<Select
|
||||
allowClear
|
||||
showSearch
|
||||
options={field.options.map((value) => ({ value, label: value }))}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (field.data_type === "number") {
|
||||
return <InputNumber style={{ width: "100%" }} />;
|
||||
}
|
||||
if (field.data_type === "boolean") {
|
||||
return <Switch />;
|
||||
}
|
||||
if (field.data_type === "long_text") {
|
||||
return <Input.TextArea rows={4} showCount maxLength={5000} />;
|
||||
}
|
||||
if (field.data_type === "json") {
|
||||
return <Input.TextArea rows={5} placeholder='请输入合法 JSON,例如 {"key":"value"}' />;
|
||||
}
|
||||
if (field.data_type === "datetime") {
|
||||
return <Input type="datetime-local" />;
|
||||
}
|
||||
return <Input placeholder={`请输入${field.label}`} />;
|
||||
}
|
||||
|
||||
export default function DataCenterPanel() {
|
||||
const context = getProjectContext();
|
||||
const [form] = Form.useForm();
|
||||
const currentProject = getProjectContext().projectId;
|
||||
const [databases, setDatabases] = useState<ProjectDatabase[]>([]);
|
||||
const [selectedProjectId, setSelectedProjectId] = useState("");
|
||||
const [tables, setTables] = useState<DataTableMeta[]>([]);
|
||||
const [selectedTableCode, setSelectedTableCode] = useState("");
|
||||
const [records, setRecords] = useState<RecordRow[]>([]);
|
||||
const [databaseLoading, setDatabaseLoading] = useState(true);
|
||||
const [tableLoading, setTableLoading] = useState(false);
|
||||
const [recordLoading, setRecordLoading] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [search, setSearch] = useState("");
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(50);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [editingRecord, setEditingRecord] = useState<RecordRow | null>(null);
|
||||
|
||||
const selectedDatabase = useMemo(
|
||||
() => databases.find((item) => item.project_id === selectedProjectId),
|
||||
[databases, selectedProjectId],
|
||||
);
|
||||
const selectedTable = useMemo(
|
||||
() => tables.find((item) => item.code === selectedTableCode),
|
||||
[tables, selectedTableCode],
|
||||
);
|
||||
|
||||
const loadDatabases = useCallback(async () => {
|
||||
setDatabaseLoading(true);
|
||||
try {
|
||||
const { data } = await listProjectDatabases();
|
||||
const items = data as ProjectDatabase[];
|
||||
setDatabases(items);
|
||||
setSelectedProjectId((previous) => {
|
||||
if (previous && items.some((item) => item.project_id === previous)) return previous;
|
||||
return items.find((item) => item.project_id === currentProject)?.project_id
|
||||
|| items.find((item) => item.project_id === "yunyou_libo")?.project_id
|
||||
|| items[0]?.project_id
|
||||
|| "";
|
||||
});
|
||||
} catch (error) {
|
||||
message.error(errorText(error, "项目数据库加载失败"));
|
||||
} finally {
|
||||
setDatabaseLoading(false);
|
||||
}
|
||||
}, [currentProject]);
|
||||
|
||||
const loadTables = useCallback(async (projectId: string) => {
|
||||
if (!projectId) return;
|
||||
setTableLoading(true);
|
||||
try {
|
||||
const { data } = await listProjectDataTables(projectId);
|
||||
const items = data.tables as DataTableMeta[];
|
||||
setTables(items);
|
||||
setSelectedTableCode((previous) => (
|
||||
previous && items.some((item) => item.code === previous)
|
||||
? previous
|
||||
: items[0]?.code || ""
|
||||
));
|
||||
} catch (error) {
|
||||
message.error(errorText(error, "项目数据表加载失败"));
|
||||
setTables([]);
|
||||
setSelectedTableCode("");
|
||||
} finally {
|
||||
setTableLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const loadRecords = useCallback(async () => {
|
||||
if (!selectedProjectId || !selectedTableCode) return;
|
||||
setRecordLoading(true);
|
||||
try {
|
||||
const { data } = await listProjectTableRecords(
|
||||
selectedProjectId,
|
||||
selectedTableCode,
|
||||
{ page, page_size: pageSize, search: search || undefined },
|
||||
);
|
||||
setRecords(data.items as RecordRow[]);
|
||||
setTotal(Number(data.total || 0));
|
||||
} catch (error) {
|
||||
message.error(errorText(error, "表数据加载失败"));
|
||||
setRecords([]);
|
||||
setTotal(0);
|
||||
} finally {
|
||||
setRecordLoading(false);
|
||||
}
|
||||
}, [page, pageSize, search, selectedProjectId, selectedTableCode]);
|
||||
|
||||
useEffect(() => {
|
||||
loadDatabases();
|
||||
}, [loadDatabases]);
|
||||
|
||||
useEffect(() => {
|
||||
setSearch("");
|
||||
setPage(1);
|
||||
setTables([]);
|
||||
setSelectedTableCode("");
|
||||
if (selectedProjectId) loadTables(selectedProjectId);
|
||||
}, [loadTables, selectedProjectId]);
|
||||
|
||||
useEffect(() => {
|
||||
loadRecords();
|
||||
}, [loadRecords]);
|
||||
|
||||
const groupedTables = useMemo(() => {
|
||||
const groups = new Map<string, DataTableMeta[]>();
|
||||
for (const table of tables) {
|
||||
groups.set(table.group, [...(groups.get(table.group) || []), table]);
|
||||
}
|
||||
return Array.from(groups.entries());
|
||||
}, [tables]);
|
||||
|
||||
const openCreate = () => {
|
||||
setEditingRecord(null);
|
||||
form.resetFields();
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
const openEdit = (record: RecordRow) => {
|
||||
setEditingRecord(record);
|
||||
const values: Record<string, unknown> = {};
|
||||
selectedTable?.fields.filter((field) => field.editable).forEach((field) => {
|
||||
const value = record[field.code];
|
||||
values[field.code] = field.data_type === "json" && value && typeof value === "object"
|
||||
? JSON.stringify(value, null, 2)
|
||||
: value;
|
||||
});
|
||||
form.setFieldsValue(values);
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
const saveRecord = async () => {
|
||||
if (!selectedTable || !selectedProjectId) return;
|
||||
const values = await form.validateFields();
|
||||
setSaving(true);
|
||||
try {
|
||||
if (editingRecord) {
|
||||
await updateProjectTableRecord(
|
||||
selectedProjectId,
|
||||
selectedTable.code,
|
||||
editingRecord.id,
|
||||
values,
|
||||
);
|
||||
message.success("记录已更新");
|
||||
} else {
|
||||
await createProjectTableRecord(selectedProjectId, selectedTable.code, values);
|
||||
message.success("记录已新增");
|
||||
}
|
||||
setModalOpen(false);
|
||||
form.resetFields();
|
||||
await Promise.all([loadRecords(), loadTables(selectedProjectId), loadDatabases()]);
|
||||
} catch (error) {
|
||||
message.error(errorText(error, "保存失败"));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const removeRecord = async (recordId: string) => {
|
||||
if (!selectedTable || !selectedProjectId) return;
|
||||
try {
|
||||
await deleteProjectTableRecord(selectedProjectId, selectedTable.code, recordId);
|
||||
message.success("记录已删除,可从修改日志恢复");
|
||||
await Promise.all([loadRecords(), loadTables(selectedProjectId), loadDatabases()]);
|
||||
} catch (error) {
|
||||
message.error(errorText(error, "删除失败"));
|
||||
}
|
||||
};
|
||||
|
||||
const visibleFields = useMemo(
|
||||
() => (selectedTable?.fields || [])
|
||||
.filter((field) => field.visible_in_list)
|
||||
.slice(0, 7),
|
||||
[selectedTable],
|
||||
);
|
||||
|
||||
const columns = useMemo<ColumnsType<RecordRow>>(() => {
|
||||
const dataColumns: ColumnsType<RecordRow> = visibleFields.map((field) => ({
|
||||
title: field.label,
|
||||
dataIndex: field.code,
|
||||
key: field.code,
|
||||
width: field.code === selectedTable?.title_field ? 210 : 150,
|
||||
ellipsis: true,
|
||||
render: (value: unknown) => (
|
||||
<span title={displayValue(value, field)}>{displayValue(value, field)}</span>
|
||||
),
|
||||
}));
|
||||
dataColumns.push({
|
||||
title: "操作",
|
||||
key: "actions",
|
||||
width: 116,
|
||||
fixed: "right",
|
||||
render: (_value, record) => (
|
||||
<Space size={2}>
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
icon={<EditOutlined />}
|
||||
disabled={!selectedTable?.allow_update}
|
||||
onClick={() => openEdit(record)}
|
||||
/>
|
||||
<Popconfirm
|
||||
title="确认删除这条记录?"
|
||||
description="系统将执行软删除并保留修改日志。"
|
||||
okText="删除"
|
||||
cancelText="取消"
|
||||
okButtonProps={{ danger: true }}
|
||||
onConfirm={() => removeRecord(record.id)}
|
||||
>
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
danger
|
||||
icon={<DeleteOutlined />}
|
||||
disabled={!selectedTable?.allow_delete}
|
||||
/>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
),
|
||||
});
|
||||
return dataColumns;
|
||||
}, [selectedTable, visibleFields]);
|
||||
|
||||
return (
|
||||
<div className="data-center-page">
|
||||
@@ -77,73 +377,225 @@ export default function DataCenterPanel() {
|
||||
<span className="data-center-title-icon"><DatabaseOutlined /></span>
|
||||
<Title level={2}>关系数据中心</Title>
|
||||
</Space>
|
||||
<Paragraph type="secondary">
|
||||
当前项目的 PostgreSQL 正式业务数据入口
|
||||
</Paragraph>
|
||||
<Paragraph type="secondary">项目数据库、业务数据表与正式记录管理</Paragraph>
|
||||
</div>
|
||||
<Tag color="blue">项目 · {displayProjectName(context.projectId)}</Tag>
|
||||
<Button icon={<ReloadOutlined />} onClick={loadDatabases}>刷新数据库</Button>
|
||||
</div>
|
||||
|
||||
<Row gutter={[16, 16]} className="data-center-capabilities">
|
||||
{capabilityCards.map((item) => (
|
||||
<Col xs={24} sm={12} xl={6} key={item.title}>
|
||||
<Card className="data-center-capability-card">
|
||||
<div className="data-center-capability-icon">{item.icon}</div>
|
||||
<div className="data-center-capability-heading">
|
||||
<Text strong>{item.title}</Text>
|
||||
<Tag>{item.status}</Tag>
|
||||
<Spin spinning={databaseLoading}>
|
||||
<section className="data-center-section">
|
||||
<div className="data-center-section-heading">
|
||||
<div>
|
||||
<Title level={4}>项目数据库</Title>
|
||||
<Text type="secondary">一个项目对应一个独立的 PostgreSQL 数据空间</Text>
|
||||
</div>
|
||||
<Tag color="blue">{databases.length} 个数据库</Tag>
|
||||
</div>
|
||||
{databases.length ? (
|
||||
<div className="data-center-database-grid">
|
||||
{databases.map((database) => {
|
||||
const active = database.project_id === selectedProjectId;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={`data-center-database-card${active ? " active" : ""}`}
|
||||
key={database.project_id}
|
||||
onClick={() => setSelectedProjectId(database.project_id)}
|
||||
>
|
||||
<div className="data-center-database-card-title">
|
||||
<span className="data-center-database-icon"><DatabaseOutlined /></span>
|
||||
<span>
|
||||
<strong>{database.display_name}</strong>
|
||||
<small>{database.database_name}</small>
|
||||
</span>
|
||||
{active && <CheckCircleOutlined className="data-center-database-selected" />}
|
||||
</div>
|
||||
<div className="data-center-database-stats">
|
||||
<span><b>{database.table_count}</b> 张表</span>
|
||||
<span><b>{database.record_count.toLocaleString()}</b> 条记录</span>
|
||||
</div>
|
||||
<div className="data-center-database-meta">
|
||||
<span>{database.schema_name}</span>
|
||||
<Tag color="green">可用</Tag>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<Empty description="暂无项目数据库" />
|
||||
)}
|
||||
</section>
|
||||
</Spin>
|
||||
|
||||
{selectedDatabase && (
|
||||
<Row gutter={18} className="data-center-workspace">
|
||||
<Col xs={24} lg={6}>
|
||||
<Card className="data-center-table-catalog" styles={{ body: { padding: 0 } }}>
|
||||
<div className="data-center-catalog-header">
|
||||
<Space>
|
||||
<TableOutlined />
|
||||
<Text strong>数据表</Text>
|
||||
</Space>
|
||||
<Tag>{tables.length}</Tag>
|
||||
</div>
|
||||
<Paragraph type="secondary">{item.description}</Paragraph>
|
||||
<Spin spinning={tableLoading}>
|
||||
<div className="data-center-table-groups">
|
||||
{groupedTables.map(([group, items]) => (
|
||||
<div className="data-center-table-group" key={group}>
|
||||
<div className="data-center-table-group-label">
|
||||
{groupIcons[group]}
|
||||
<span>{group}</span>
|
||||
</div>
|
||||
{items.map((table) => (
|
||||
<button
|
||||
type="button"
|
||||
key={table.code}
|
||||
className={`data-center-table-item${table.code === selectedTableCode ? " active" : ""}`}
|
||||
onClick={() => {
|
||||
setSelectedTableCode(table.code);
|
||||
setPage(1);
|
||||
setSearch("");
|
||||
}}
|
||||
>
|
||||
<span>
|
||||
<strong>{table.label}</strong>
|
||||
<small>{table.code}</small>
|
||||
</span>
|
||||
<b>{table.record_count.toLocaleString()}</b>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Spin>
|
||||
</Card>
|
||||
</Col>
|
||||
))}
|
||||
</Row>
|
||||
|
||||
<Card
|
||||
className="data-center-structure-card"
|
||||
title="正式数据结构"
|
||||
extra={<Tag color="green">PostgreSQL 权威数据</Tag>}
|
||||
>
|
||||
<div className="data-center-groups">
|
||||
{dataGroups.map((group) => (
|
||||
<div className="data-center-group" key={group.title}>
|
||||
<div className="data-center-group-icon">{group.icon}</div>
|
||||
<div className="data-center-group-content">
|
||||
<div className="data-center-group-title">
|
||||
<Text strong>{group.title}</Text>
|
||||
<Text code>{group.tables}</Text>
|
||||
</div>
|
||||
<Text type="secondary">{group.description}</Text>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="data-center-boundary-card" title="数据边界">
|
||||
<Row gutter={[24, 16]}>
|
||||
<Col xs={24} lg={12}>
|
||||
<div className="data-center-boundary data-center-boundary-include">
|
||||
<Text strong>进入 PostgreSQL</Text>
|
||||
<ul>
|
||||
<li>处理完成的酒店、美食、景区、交通实体</li>
|
||||
<li>房型、设施、套餐、评论和公交站序</li>
|
||||
<li>已经确认的外部平台 ID、URL 和实体关系</li>
|
||||
</ul>
|
||||
</div>
|
||||
</Col>
|
||||
<Col xs={24} lg={12}>
|
||||
<div className="data-center-boundary data-center-boundary-exclude">
|
||||
<Text strong>不进入正式业务库</Text>
|
||||
<ul>
|
||||
<li>未清洗的接口原始响应和爬虫缓存</li>
|
||||
<li>候选匹配中间结果和重复计算过程</li>
|
||||
<li>“仅剩几间”等无长期价值的实时文案</li>
|
||||
</ul>
|
||||
</div>
|
||||
<Col xs={24} lg={18}>
|
||||
<Card className="data-center-record-card" styles={{ body: { padding: 0 } }}>
|
||||
{selectedTable ? (
|
||||
<>
|
||||
<div className="data-center-record-header">
|
||||
<div>
|
||||
<Space size={8}>
|
||||
<Title level={4}>{selectedTable.label}</Title>
|
||||
<Tag color="blue">{selectedTable.record_count.toLocaleString()} 条</Tag>
|
||||
</Space>
|
||||
<Paragraph type="secondary">{selectedTable.description}</Paragraph>
|
||||
</div>
|
||||
<Space wrap>
|
||||
<Search
|
||||
allowClear
|
||||
prefix={<SearchOutlined />}
|
||||
placeholder="搜索本表"
|
||||
defaultValue={search}
|
||||
onSearch={(value) => {
|
||||
setSearch(value.trim());
|
||||
setPage(1);
|
||||
}}
|
||||
style={{ width: 230 }}
|
||||
/>
|
||||
<Button icon={<ReloadOutlined />} onClick={loadRecords}>刷新</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<PlusOutlined />}
|
||||
disabled={!selectedTable.allow_create}
|
||||
onClick={openCreate}
|
||||
>
|
||||
新增记录
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
<div className="data-center-record-context">
|
||||
<Space split={<span className="data-center-context-divider" />}>
|
||||
<span><DatabaseOutlined /> {selectedDatabase.database_name}</span>
|
||||
<span><TableOutlined /> {selectedTable.code}</span>
|
||||
<span><ClockCircleOutlined /> {formatDate(selectedTable.updated_at)}</span>
|
||||
</Space>
|
||||
</div>
|
||||
<Table<RecordRow>
|
||||
rowKey="id"
|
||||
loading={recordLoading}
|
||||
columns={columns}
|
||||
dataSource={records}
|
||||
scroll={{ x: 900 }}
|
||||
locale={{
|
||||
emptyText: (
|
||||
<Empty
|
||||
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||
description="该表暂无正式数据,可新增记录或后续批量导入"
|
||||
/>
|
||||
),
|
||||
}}
|
||||
pagination={{
|
||||
current: page,
|
||||
pageSize,
|
||||
total,
|
||||
showSizeChanger: true,
|
||||
showTotal: (value) => `共 ${value} 条`,
|
||||
onChange: (nextPage, nextPageSize) => {
|
||||
setPage(nextPageSize === pageSize ? nextPage : 1);
|
||||
setPageSize(nextPageSize);
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<Empty description="请选择数据表" />
|
||||
)}
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Row gutter={16} className="data-center-summary">
|
||||
<Col xs={24} sm={8}>
|
||||
<Card><Statistic title="项目数据库" value={databases.length} prefix={<DatabaseOutlined />} /></Card>
|
||||
</Col>
|
||||
<Col xs={24} sm={8}>
|
||||
<Card><Statistic title="当前数据库数据表" value={tables.length} prefix={<TableOutlined />} /></Card>
|
||||
</Col>
|
||||
<Col xs={24} sm={8}>
|
||||
<Card><Statistic title="当前数据库记录" value={selectedDatabase?.record_count || 0} prefix={<ApartmentOutlined />} /></Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Modal
|
||||
title={`${editingRecord ? "编辑" : "新增"} · ${selectedTable?.label || ""}`}
|
||||
open={modalOpen}
|
||||
width={720}
|
||||
okText="保存"
|
||||
cancelText="取消"
|
||||
confirmLoading={saving}
|
||||
onOk={saveRecord}
|
||||
onCancel={() => {
|
||||
setModalOpen(false);
|
||||
form.resetFields();
|
||||
}}
|
||||
destroyOnClose
|
||||
>
|
||||
<Form form={form} layout="vertical" className="data-center-record-form">
|
||||
<Row gutter={16}>
|
||||
{selectedTable?.fields.filter((field) => field.editable).map((field) => (
|
||||
<Col
|
||||
xs={24}
|
||||
sm={field.data_type === "long_text" || field.data_type === "json" ? 24 : 12}
|
||||
key={field.code}
|
||||
>
|
||||
<Form.Item
|
||||
name={field.code}
|
||||
label={field.label}
|
||||
valuePropName={field.data_type === "boolean" ? "checked" : "value"}
|
||||
rules={[{ required: field.required, message: `请填写${field.label}` }]}
|
||||
>
|
||||
{fieldControl(field)}
|
||||
</Form.Item>
|
||||
</Col>
|
||||
))}
|
||||
</Row>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -81,7 +81,7 @@ body { margin: 0; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Ro
|
||||
|
||||
/* ── Relational data center ── */
|
||||
.data-center-page {
|
||||
max-width: 1440px;
|
||||
max-width: 1680px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
@@ -117,121 +117,314 @@ body { margin: 0; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Ro
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.data-center-capabilities {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.data-center-capability-card {
|
||||
height: 100%;
|
||||
.data-center-section,
|
||||
.data-center-table-catalog,
|
||||
.data-center-record-card,
|
||||
.data-center-summary .ant-card {
|
||||
border-color: #e6edf7;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.data-center-capability-card .ant-card-body {
|
||||
min-height: 170px;
|
||||
.data-center-section {
|
||||
padding: 20px;
|
||||
background: #fbfcff;
|
||||
border: 1px solid #e6edf7;
|
||||
}
|
||||
|
||||
.data-center-capability-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
margin-bottom: 18px;
|
||||
border-radius: 10px;
|
||||
color: #1677ff;
|
||||
background: #f0f6ff;
|
||||
font-size: 19px;
|
||||
}
|
||||
|
||||
.data-center-capability-heading {
|
||||
.data-center-section-heading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 8px;
|
||||
font-size: 16px;
|
||||
gap: 16px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.data-center-capability-card p.ant-typography {
|
||||
margin-bottom: 0;
|
||||
line-height: 1.7;
|
||||
.data-center-section-heading .ant-typography {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.data-center-structure-card,
|
||||
.data-center-boundary-card {
|
||||
margin-top: 20px;
|
||||
border-color: #e6edf7;
|
||||
border-radius: 12px;
|
||||
.data-center-section-heading h4.ant-typography {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.data-center-groups {
|
||||
.data-center-database-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 14px;
|
||||
grid-template-columns: repeat(4, minmax(220px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.data-center-group {
|
||||
display: flex;
|
||||
gap: 14px;
|
||||
.data-center-database-card {
|
||||
min-width: 0;
|
||||
padding: 16px;
|
||||
border: 1px solid #edf1f7;
|
||||
padding: 15px;
|
||||
color: #1f2937;
|
||||
text-align: left;
|
||||
background: #fff;
|
||||
border: 1px solid #e4eaf3;
|
||||
border-radius: 10px;
|
||||
background: #fbfcff;
|
||||
cursor: pointer;
|
||||
transition: border-color .18s ease, box-shadow .18s ease, transform .18s ease;
|
||||
}
|
||||
|
||||
.data-center-group-icon {
|
||||
.data-center-database-card:hover {
|
||||
border-color: #8bbcff;
|
||||
box-shadow: 0 6px 18px rgba(22, 119, 255, .08);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.data-center-database-card.active {
|
||||
border-color: #1677ff;
|
||||
box-shadow: 0 0 0 2px rgba(22, 119, 255, .1);
|
||||
}
|
||||
|
||||
.data-center-database-card-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.data-center-database-card-title > span:nth-child(2) {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.data-center-database-card-title strong,
|
||||
.data-center-database-card-title small {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.data-center-database-card-title strong {
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.data-center-database-card-title small {
|
||||
margin-top: 3px;
|
||||
color: #8b96a8;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.data-center-database-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: 0 0 34px;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
flex: 0 0 36px;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
color: #1677ff;
|
||||
background: #edf5ff;
|
||||
border-radius: 9px;
|
||||
color: #5b6b82;
|
||||
background: #fff;
|
||||
box-shadow: 0 1px 4px rgba(31, 55, 88, 0.08);
|
||||
font-size: 17px;
|
||||
}
|
||||
|
||||
.data-center-group-content {
|
||||
.data-center-database-selected {
|
||||
color: #1677ff;
|
||||
}
|
||||
|
||||
.data-center-database-stats {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
margin: 15px 0 12px 46px;
|
||||
color: #7a8699;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.data-center-database-stats b {
|
||||
margin-right: 3px;
|
||||
color: #263247;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.data-center-database-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
padding-top: 10px;
|
||||
color: #909aab;
|
||||
border-top: 1px dashed #edf0f5;
|
||||
font: 11px ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
}
|
||||
|
||||
.data-center-database-meta > span {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.data-center-workspace {
|
||||
margin-top: 18px;
|
||||
}
|
||||
|
||||
.data-center-table-catalog,
|
||||
.data-center-record-card {
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.data-center-catalog-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
min-height: 60px;
|
||||
padding: 0 16px;
|
||||
border-bottom: 1px solid #edf0f5;
|
||||
}
|
||||
|
||||
.data-center-table-groups {
|
||||
max-height: 720px;
|
||||
padding: 10px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.data-center-table-group + .data-center-table-group {
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.data-center-table-group-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
padding: 5px 9px 7px;
|
||||
color: #7a8699;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.data-center-table-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
gap: 10px;
|
||||
padding: 9px 10px;
|
||||
color: #334057;
|
||||
text-align: left;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.data-center-table-item:hover {
|
||||
background: #f4f8fe;
|
||||
}
|
||||
|
||||
.data-center-table-item.active {
|
||||
color: #0958d9;
|
||||
background: #eaf3ff;
|
||||
}
|
||||
|
||||
.data-center-table-item > span {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.data-center-group-title {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 6px;
|
||||
.data-center-table-item strong,
|
||||
.data-center-table-item small {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.data-center-boundary {
|
||||
height: 100%;
|
||||
padding: 16px 18px;
|
||||
.data-center-table-item strong {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.data-center-table-item small {
|
||||
margin-top: 2px;
|
||||
color: #9aa3b3;
|
||||
font: 10px ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
}
|
||||
|
||||
.data-center-table-item b {
|
||||
min-width: 24px;
|
||||
padding: 1px 6px;
|
||||
color: #7c8799;
|
||||
text-align: center;
|
||||
background: #f0f2f5;
|
||||
border-radius: 10px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.data-center-boundary ul {
|
||||
margin: 10px 0 0;
|
||||
padding-left: 20px;
|
||||
color: #5b6678;
|
||||
line-height: 1.9;
|
||||
.data-center-record-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 20px;
|
||||
padding: 18px 20px 14px;
|
||||
}
|
||||
|
||||
.data-center-boundary-include {
|
||||
border: 1px solid #d9f0df;
|
||||
background: #f7fcf8;
|
||||
.data-center-record-header .ant-typography {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.data-center-boundary-exclude {
|
||||
border: 1px solid #f0e6d8;
|
||||
background: #fffaf4;
|
||||
.data-center-record-header h4.ant-typography {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.data-center-record-context {
|
||||
padding: 9px 20px;
|
||||
color: #738097;
|
||||
background: #fafbfd;
|
||||
border-top: 1px solid #f0f2f5;
|
||||
border-bottom: 1px solid #edf0f5;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.data-center-context-divider {
|
||||
display: inline-block;
|
||||
width: 1px;
|
||||
height: 12px;
|
||||
background: #d9dfe8;
|
||||
}
|
||||
|
||||
.data-center-record-card .ant-table-wrapper {
|
||||
padding: 0 1px;
|
||||
}
|
||||
|
||||
.data-center-record-card .ant-table-thead > tr > th {
|
||||
color: #596579;
|
||||
background: #fafbfd;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.data-center-record-card .ant-empty {
|
||||
margin: 48px 0;
|
||||
}
|
||||
|
||||
.data-center-summary {
|
||||
margin-top: 18px;
|
||||
}
|
||||
|
||||
.data-center-summary .ant-card {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.data-center-record-form {
|
||||
max-height: 62vh;
|
||||
padding-right: 4px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
@media (max-width: 1280px) {
|
||||
.data-center-database-grid {
|
||||
grid-template-columns: repeat(3, minmax(220px, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.data-center-groups {
|
||||
grid-template-columns: 1fr;
|
||||
.data-center-database-grid {
|
||||
grid-template-columns: repeat(2, minmax(200px, 1fr));
|
||||
}
|
||||
|
||||
.data-center-table-catalog {
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -239,6 +432,14 @@ body { margin: 0; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Ro
|
||||
.data-center-header {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.data-center-database-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.data-center-record-header {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Plaza Overview ── */
|
||||
|
||||
Reference in New Issue
Block a user