feat: implement project relational data platform

This commit is contained in:
2026-07-30 11:51:46 +08:00
parent 506621966e
commit ada281862e
12 changed files with 2534 additions and 189 deletions

View File

@@ -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) =>

View File

@@ -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> IDURL </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>
);
}

View File

@@ -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 ── */