Files
Genarrative/apps/admin-web/src/pages/AdminDatabaseTablesPage.tsx
T
suzmii cbc73f111c
Project CI / Repository checks (pull_request) Has been cancelled
Project CI / Frontend tests (pull_request) Has been cancelled
Project CI / Backend tests (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled
恢复外部 API Key 表并统一 Router 账号存储
恢复 external_api_key 表及客户端绑定为 master 原始结构

将 LLM Router 凭据、状态、撤销和缓存统一收敛到 llm_router_account

移除后台与契约中的 purpose 字段及 Router 专用撤销 procedure

同步更新架构文档并补齐相关测试契约
2026-09-03 19:43:10 +08:00

2778 lines
90 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import {
ArrowDown,
ArrowUp,
ArrowUpDown,
ChevronLeft,
ChevronRight,
Eye,
Filter,
Plus,
RefreshCcw,
Search,
X,
} from 'lucide-react';
import {
ClipboardEvent,
FormEvent,
KeyboardEvent,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
} from 'react';
import {
getAdminDatabaseTableRows,
getAdminDatabaseTables,
getAdminExternalApiKeys,
} from '../api/adminApiClient';
import type {
AdminDatabaseTableRowPayload,
AdminDatabaseTableRowsResponse,
AdminExternalApiKeyListResponse,
} from '../api/adminApiTypes';
import { AdminUserReferenceButton } from '../components/AdminUserReferenceButton';
import { handlePageError } from './pageUtils';
interface AdminDatabaseTablesPageProps {
token: string;
onUnauthorized: (message?: string) => void;
}
type SortDirection = 'asc' | 'desc';
type ExternalApiKeySortColumn =
| ''
| 'keyId'
| 'ownerUserId'
| 'name'
| 'keyPrefix'
| 'createdAt'
| 'lastUsedAt'
| 'updatedAt';
type AdminCopyToast = {
message: string;
tone: 'success' | 'error';
};
export function AdminDatabaseTablesPage({
token,
onUnauthorized,
}: AdminDatabaseTablesPageProps) {
const pageRef = useRef<HTMLElement>(null);
const appliedQueryRef = useRef({
search: '',
filters: '',
limit: '100',
sortColumn: '',
sortDirection: 'asc' as SortDirection,
});
const rowsRequestIdRef = useRef(0);
const [tables, setTables] = useState<string[]>([]);
const [tableName, setTableName] = useState(() => readHashTableName());
const [search, setSearch] = useState('');
const [conditions, setConditions] = useState<DatabaseFilterCondition[]>([]);
const [limit, setLimit] = useState('100');
const [result, setResult] = useState<AdminDatabaseTableRowsResponse | null>(
null,
);
const [detailRow, setDetailRow] =
useState<AdminDatabaseTableRowPayload | null>(null);
const [errorMessage, setErrorMessage] = useState('');
const [copyToast, setCopyToast] = useState<AdminCopyToast | null>(null);
const [sortColumn, setSortColumn] = useState('');
const [sortDirection, setSortDirection] = useState<SortDirection>('asc');
const [isLoadingTables, setIsLoadingTables] = useState(false);
const [isLoadingRows, setIsLoadingRows] = useState(false);
const isExternalApiKeyTable = tableName === 'external_api_key';
useEffect(() => {
void loadTables();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [token]);
useEffect(() => {
const nextTableName = readHashTableName();
if (nextTableName) {
setTableName(nextTableName);
}
const handleHashChange = () => {
const tableFromHash = readHashTableName();
if (tableFromHash) {
setTableName(tableFromHash);
}
};
window.addEventListener('hashchange', handleHashChange);
return () => window.removeEventListener('hashchange', handleHashChange);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
if (tables.length && !tableName) {
setTableName(tables[0] ?? '');
}
}, [tableName, tables]);
useEffect(() => {
if (tableName && tableName !== 'external_api_key') {
void refreshRows(tableName);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [tableName]);
useEffect(() => {
if (!copyToast) {
return;
}
const timer = window.setTimeout(() => setCopyToast(null), 2200);
return () => window.clearTimeout(timer);
}, [copyToast]);
useLayoutEffect(() => {
const pageElement = pageRef.current;
if (!pageElement) {
return;
}
const bottomNav = document.querySelector<HTMLElement>('.admin-bottom-nav');
const pagination = pageElement.querySelector<HTMLElement>(
'.admin-database-pagination',
);
const updateFixedBarHeights = () => {
pageElement.style.setProperty(
'--admin-bottom-nav-height',
`${bottomNav?.getBoundingClientRect().height ?? 0}px`,
);
pageElement.style.setProperty(
'--admin-database-pagination-height',
`${pagination?.getBoundingClientRect().height ?? 0}px`,
);
};
updateFixedBarHeights();
window.addEventListener('resize', updateFixedBarHeights);
const resizeObserver =
typeof ResizeObserver !== 'undefined'
? new ResizeObserver(updateFixedBarHeights)
: null;
if (resizeObserver) {
if (bottomNav) {
resizeObserver.observe(bottomNav);
}
if (pagination) {
resizeObserver.observe(pagination);
}
}
return () => {
resizeObserver?.disconnect();
window.removeEventListener('resize', updateFixedBarHeights);
};
}, [result]);
const visibleColumns = useMemo(() => {
const columns = result?.columns ?? [];
if (columns.length) {
return columns;
}
const firstRow = result?.rows[0];
return firstRow ? Object.keys(firstRow.cells) : [];
}, [result]);
const detailFields = useMemo(() => {
if (!detailRow) {
return [];
}
const cells = detailRow.cells;
if (typeof cells !== 'object' || cells === null || Array.isArray(cells)) {
return [];
}
return Object.entries(cells).map(([column, value]) => {
const columnHeader = getDatabaseTableColumnHeader(tableName, column);
return {
cellValue: formatCellValue(value, column),
column,
description: columnHeader.description,
jsonPreview: getJsonPreviewText(value),
label: columnHeader.label,
};
});
}, [detailRow, tableName]);
const tableOptions = useMemo(() => {
const optionNames =
tableName && !tables.includes(tableName)
? [tableName, ...tables]
: tables;
return optionNames.map(getDatabaseTableHeader);
}, [tableName, tables]);
const selectedTableHeader = useMemo(
() => getDatabaseTableHeader(tableName),
[tableName],
);
const resultTableHeader = useMemo(
() => getDatabaseTableHeader(result?.tableName || tableName),
[result?.tableName, tableName],
);
const columnHeaders = useMemo(
() =>
visibleColumns.map((column) =>
getDatabaseTableColumnHeader(tableName, column),
),
[tableName, visibleColumns],
);
const currentPage = result?.page ?? 1;
const totalMatched = result?.totalMatched ?? result?.totalReturned ?? 0;
const totalPages = result
? Math.max(1, Math.ceil(totalMatched / Math.max(1, result.limit)))
: 1;
async function loadTables() {
setIsLoadingTables(true);
setErrorMessage('');
try {
const response = await getAdminDatabaseTables(token);
setTables(response.tables);
if (response.fetchErrors.length) {
setErrorMessage(response.fetchErrors.join(''));
}
} catch (error: unknown) {
handlePageError(error, onUnauthorized, setErrorMessage);
} finally {
setIsLoadingTables(false);
}
}
async function refreshRows(
nextTableName = tableName,
options: {
search?: string;
filters?: string;
limit?: string;
page?: number;
sortColumn?: string;
sortDirection?: SortDirection;
} = {},
) {
const normalizedTableName = nextTableName.trim();
if (!normalizedTableName) {
return;
}
const querySearch = options.search ?? search;
const queryFilters =
options.filters ?? buildDatabaseFiltersJson(conditions);
const queryLimit = options.limit ?? limit;
const querySortColumn = options.sortColumn ?? sortColumn;
const querySortDirection = options.sortDirection ?? sortDirection;
const requestId = ++rowsRequestIdRef.current;
setIsLoadingRows(true);
setErrorMessage('');
try {
const response = await getAdminDatabaseTableRows(
token,
normalizedTableName,
{
search: querySearch,
filters: queryFilters,
limit: parseLimit(queryLimit),
page: options.page ?? 1,
sortColumn: querySortColumn || undefined,
sortDirection: querySortColumn ? querySortDirection : undefined,
},
);
if (requestId !== rowsRequestIdRef.current) {
return;
}
appliedQueryRef.current = {
search: querySearch,
filters: queryFilters,
limit: queryLimit,
sortColumn: querySortColumn,
sortDirection: querySortDirection,
};
setSortColumn(querySortColumn);
setSortDirection(querySortDirection);
setResult(response);
setCopyToast(null);
} catch (error: unknown) {
if (requestId !== rowsRequestIdRef.current) {
return;
}
handlePageError(error, onUnauthorized, setErrorMessage);
} finally {
if (requestId === rowsRequestIdRef.current) {
setIsLoadingRows(false);
}
}
}
function handleSearch(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const validationMessage = getDatabaseFilterValidationMessage(conditions);
if (validationMessage) {
setErrorMessage(validationMessage);
return;
}
void refreshRows(tableName, { page: 1 });
}
function handleQueryRows() {
const validationMessage = getDatabaseFilterValidationMessage(conditions);
if (validationMessage) {
setErrorMessage(validationMessage);
return;
}
void refreshRows(tableName, { page: 1 });
}
function handleTableChange(nextTableName: string) {
setSortColumn('');
setSortDirection('asc');
setConditions([]);
setTableName(nextTableName);
const nextHash = `#tables?table=${encodeURIComponent(nextTableName)}`;
if (window.location.hash !== nextHash) {
window.location.hash = nextHash;
}
}
function handleResetQuery() {
setSearch('');
setConditions([]);
setLimit('100');
setSortColumn('');
setSortDirection('asc');
void refreshRows(tableName, {
search: '',
filters: '',
limit: '100',
page: 1,
sortColumn: '',
sortDirection: 'asc',
});
}
function handleAddCondition(column = '') {
const nextConditions = [
...conditions,
{
column,
op: 'eq' as DatabaseFilterOperator,
value: '',
values: [],
enabled: true,
},
];
setConditions(nextConditions);
focusConditionControl(
nextConditions.length - 1,
column ? 'value' : 'column',
);
}
function handleUpdateCondition(
index: number,
patch: Partial<DatabaseFilterCondition>,
) {
setErrorMessage('');
setConditions((current) =>
current.map((condition, conditionIndex) =>
conditionIndex === index ? { ...condition, ...patch } : condition,
),
);
}
function handleConditionOperatorChange(
index: number,
op: DatabaseFilterOperator,
) {
setErrorMessage('');
setConditions((current) =>
current.map((condition, conditionIndex) => {
if (conditionIndex !== index) {
return condition;
}
const nextOption = getDatabaseFilterOperatorOption(op);
const previousOption = getDatabaseFilterOperatorOption(condition.op);
const nextValues = nextOption?.listValue
? condition.values.length
? condition.values
: condition.value.trim()
? [condition.value.trim()]
: []
: condition.values;
return {
...condition,
op,
value:
nextOption?.listValue && !previousOption?.listValue
? ''
: condition.value,
values: nextValues,
};
}),
);
}
function handleAddConditionListValue(index: number) {
setConditions((current) =>
current.map((condition, conditionIndex) => {
if (conditionIndex !== index) {
return condition;
}
const value = condition.value.trim();
return value
? { ...condition, value: '', values: [...condition.values, value] }
: condition;
}),
);
setErrorMessage('');
}
function handleRemoveConditionListValue(index: number, valueIndex: number) {
setConditions((current) =>
current.map((condition, conditionIndex) =>
conditionIndex === index
? {
...condition,
values: condition.values.filter(
(_, currentValueIndex) => currentValueIndex !== valueIndex,
),
}
: condition,
),
);
setErrorMessage('');
}
function handlePasteConditionListValues(
index: number,
event: ClipboardEvent<HTMLInputElement>,
) {
const pastedText = event.clipboardData.getData('text');
if (!pastedText.includes('\n')) {
return;
}
event.preventDefault();
const pastedValues = pastedText
.split(/\r?\n/)
.map((value) => value.trim())
.filter(Boolean);
if (!pastedValues.length) {
return;
}
setConditions((current) =>
current.map((condition, conditionIndex) =>
conditionIndex === index
? {
...condition,
values: [...condition.values, ...pastedValues],
}
: condition,
),
);
setErrorMessage('');
}
function handleRemoveCondition(index: number) {
setConditions((current) =>
current.filter((_, conditionIndex) => conditionIndex !== index),
);
}
function handlePageChange(page: number) {
void refreshRows(tableName, { ...appliedQueryRef.current, page });
}
function handleRowKeyDown(
event: KeyboardEvent<HTMLTableRowElement>,
row: AdminDatabaseTableRowPayload,
) {
if (event.key !== 'Enter' && event.key !== ' ') {
return;
}
if (isInteractiveFormControl(event.target)) {
return;
}
event.preventDefault();
setDetailRow(row);
}
function handleSortColumn(column: string) {
let nextSortColumn = column;
let nextDirection: SortDirection = 'asc';
if (sortColumn === column) {
if (sortDirection === 'asc') {
nextDirection = 'desc';
} else {
nextSortColumn = '';
}
}
void refreshRows(tableName, {
...appliedQueryRef.current,
page: 1,
sortColumn: nextSortColumn,
sortDirection: nextDirection,
});
}
async function handleCopyDetailField(column: string, value: unknown) {
const copiedText = stringifyDetailValue(value);
if (
value === null ||
typeof value === 'undefined' ||
copiedText.trim() === ''
) {
setCopyToast({
message: '该字段为空,无法复制',
tone: 'error',
});
return;
}
try {
await navigator.clipboard.writeText(copiedText);
setCopyToast({
message: `已复制 ${getDatabaseTableColumnLabel(column)}`,
tone: 'success',
});
} catch {
setCopyToast({
message: '复制失败,请手动选中后复制',
tone: 'error',
});
}
}
if (isExternalApiKeyTable) {
return (
<section
ref={pageRef}
className="admin-page admin-page-wide admin-database-tables-page"
>
<div className="admin-page-heading">
<div>
<h2>表查询</h2>
<p>external_api_key 使用专用安全查询</p>
</div>
<div className="admin-action-row">
<label className="admin-field admin-field-compact">
<span></span>
<select
value={tableName}
onChange={(event) => handleTableChange(event.target.value)}
>
{tableOptions.map(({ name, optionLabel, description }) => (
<option key={name} title={description} value={name}>
{optionLabel}
</option>
))}
</select>
</label>
<button
className="admin-secondary-button"
disabled={isLoadingTables}
type="button"
onClick={() => void loadTables()}
>
<RefreshCcw size={17} aria-hidden="true" />
<span>{isLoadingTables ? '刷新中' : '刷新表'}</span>
</button>
</div>
</div>
<AdminExternalApiKeysPanel
token={token}
onUnauthorized={onUnauthorized}
/>
</section>
);
}
return (
<section
ref={pageRef}
className="admin-page admin-page-wide admin-database-tables-page"
>
<div className="admin-page-heading">
<div>
<h2>表查询</h2>
<p>SpacetimeDB 行数据</p>
</div>
<div className="admin-action-row">
<button
className="admin-secondary-button"
disabled={isLoadingTables}
type="button"
onClick={() => void loadTables()}
>
<RefreshCcw size={17} aria-hidden="true" />
<span>{isLoadingTables ? '刷新中' : '刷新表'}</span>
</button>
<button
className="admin-primary-button"
disabled={!tableName || isLoadingRows}
type="button"
onClick={handleQueryRows}
>
<Search size={17} aria-hidden="true" />
<span>{isLoadingRows ? '查询中' : '查询'}</span>
</button>
</div>
</div>
<form className="admin-panel admin-form" onSubmit={handleSearch}>
<div className="admin-table-query-grid">
<label className="admin-field">
<span></span>
<select
value={tableName}
onChange={(event) => handleTableChange(event.target.value)}
>
{tableOptions.map(({ name, optionLabel, description }) => (
<option key={name} title={description} value={name}>
{optionLabel}
</option>
))}
</select>
</label>
<label className="admin-field">
<span>关键词</span>
<input
placeholder="全部"
value={search}
onChange={(event) => setSearch(event.target.value)}
/>
</label>
<label className="admin-field admin-field-compact">
<span>每页条数</span>
<input
inputMode="numeric"
value={limit}
onChange={(event) => setLimit(event.target.value)}
/>
</label>
<button
className="admin-secondary-button"
disabled={isLoadingRows}
type="submit"
>
<Search size={17} aria-hidden="true" />
<span>{isLoadingRows ? '查询中' : '查询'}</span>
</button>
</div>
{conditions.length ? (
<div
className="admin-database-filter-grid"
role="table"
aria-label="字段筛选条件"
>
<div className="admin-database-filter-header" role="row">
<span role="columnheader">启用</span>
<span role="columnheader">字段</span>
<span role="columnheader">条件</span>
<span role="columnheader"></span>
<span role="columnheader">操作</span>
</div>
<div className="admin-database-filter-list" role="rowgroup">
{conditions.map((condition, index) => {
const operatorOption = getDatabaseFilterOperatorOption(
condition.op,
);
const validationError =
getDatabaseFilterValidationError(condition);
return (
<div
className="admin-database-filter-item"
key={index}
role="row"
>
<div
className="admin-database-filter-row"
data-enabled={condition.enabled ? 'true' : 'false'}
>
<label className="admin-database-filter-toggle">
<input
id={`admin-filter-condition-enabled-${index}`}
aria-label={`启用条件 ${index + 1}`}
type="checkbox"
checked={condition.enabled}
onChange={(event) =>
handleUpdateCondition(index, {
enabled: event.target.checked,
})
}
/>
</label>
<label className="admin-field">
<select
id={`admin-filter-condition-column-${index}`}
aria-label={`条件 ${index + 1} 字段`}
aria-invalid={validationError === '请选择字段'}
value={condition.column}
onChange={(event) =>
handleUpdateCondition(index, {
column: event.target.value,
})
}
>
<option value="">选择字段</option>
{condition.column &&
!visibleColumns.includes(condition.column) ? (
<option value={condition.column}>
{condition.column}
</option>
) : null}
{visibleColumns.map((column) => {
const columnHeader = getDatabaseTableColumnHeader(
tableName,
column,
);
return (
<option
key={column}
title={columnHeader.description}
value={column}
>
{`${columnHeader.label}${column}`}
</option>
);
})}
</select>
</label>
<label className="admin-field">
<select
id={`admin-filter-condition-op-${index}`}
aria-label={`条件 ${index + 1} 运算符`}
value={condition.op}
onChange={(event) =>
handleConditionOperatorChange(
index,
event.target.value as DatabaseFilterOperator,
)
}
>
{databaseFilterOperatorOptions.map(
({ op, label }) => (
<option key={op} value={op}>
{label}
</option>
),
)}
</select>
</label>
{operatorOption?.listValue ? (
<div
className="admin-database-filter-list-editor"
aria-label={`条件 ${index + 1} 值列表`}
>
<div className="admin-database-filter-list-input-row">
<input
id={`admin-filter-condition-value-${index}`}
aria-label={`条件 ${index + 1} 值输入`}
aria-invalid={Boolean(validationError)}
placeholder="输入值后按 Enter 添加;可粘贴多行"
value={condition.value}
onChange={(event) =>
handleUpdateCondition(index, {
value: event.target.value,
})
}
onKeyDown={(event) => {
if (event.key === 'Enter') {
event.preventDefault();
handleAddConditionListValue(index);
}
}}
onPaste={(event) =>
handlePasteConditionListValues(index, event)
}
/>
<button
className="admin-secondary-button admin-database-filter-add-value"
aria-label={`添加条件 ${index + 1} 的值`}
type="button"
onClick={() => handleAddConditionListValue(index)}
>
<Plus size={14} aria-hidden="true" />
<span>添加</span>
</button>
</div>
</div>
) : operatorOption?.needsValue ? (
<label className="admin-field">
<input
id={`admin-filter-condition-value-${index}`}
aria-label={`条件 ${index + 1} 值`}
aria-invalid={Boolean(validationError)}
placeholder="输入值"
value={condition.value}
onChange={(event) =>
handleUpdateCondition(index, {
value: event.target.value,
})
}
/>
</label>
) : (
<div
className="admin-database-filter-value-slot"
aria-label={`条件 ${index + 1} 值`}
>
<span className="admin-database-filter-no-value">
无需填写
</span>
</div>
)}
<button
className="admin-ghost-button admin-database-filter-remove"
aria-label={`删除条件 ${index + 1}`}
title="删除条件"
type="button"
onClick={() => handleRemoveCondition(index)}
>
<X size={16} aria-hidden="true" />
</button>
</div>
{operatorOption?.listValue && condition.values.length ? (
<div className="admin-database-filter-values-row">
<span className="admin-database-filter-values-label">
已添加
</span>
<div className="admin-database-filter-value-chips">
{condition.values.map((value, valueIndex) => (
<span
className="admin-database-filter-value-chip"
key={`${value}-${valueIndex}`}
>
<span title={value}>{value}</span>
<button
aria-label={`删除条件 ${index + 1} 的值 ${valueIndex + 1}`}
type="button"
onClick={() =>
handleRemoveConditionListValue(
index,
valueIndex,
)
}
>
<X size={12} aria-hidden="true" />
</button>
</span>
))}
</div>
</div>
) : null}
{condition.enabled && validationError ? (
<div
className="admin-database-filter-error"
role="status"
>
{validationError}
</div>
) : null}
</div>
);
})}
</div>
</div>
) : null}
{conditions.length ? (
<div className="admin-database-filter-summary">
<span className="admin-database-filter-summary-label">
当前生效
</span>
{conditions.filter((condition) => condition.enabled).length ? (
conditions
.filter((condition) => condition.enabled)
.map((condition, index) => (
<span
className="admin-database-filter-summary-chip"
key={`${condition.column}-${condition.op}-${index}`}
>
{formatDatabaseFilterSummary(condition)}
</span>
))
) : (
<span className="admin-database-filter-summary-empty">
未启用筛选,查询全部数据
</span>
)}
</div>
) : null}
<div className="admin-action-row admin-database-filter-actions">
<button
className="admin-secondary-button"
disabled={isLoadingRows}
type="button"
onClick={() => handleAddCondition()}
>
<Plus size={16} aria-hidden="true" />
<span>添加条件</span>
</button>
</div>
<div className="admin-action-row admin-query-action-row">
<button
className="admin-ghost-button admin-query-reset-button"
disabled={isLoadingRows}
type="button"
onClick={handleResetQuery}
>
重置条件
</button>
<div className="admin-query-summary">
<span title={selectedTableHeader.description}>
已选表:{selectedTableHeader.optionLabel}
</span>
<span>显示列:{visibleColumns.length}</span>
{conditions.length ? (
<span>
筛选:
{
conditions.filter((condition) => condition.enabled).length
} / {conditions.length} 个条件已启用
</span>
) : null}
</div>
</div>
</form>
{errorMessage ? (
<div className="admin-alert" role="status">
{errorMessage}
</div>
) : null}
{result?.scanLimitReached ? (
<section className="admin-panel admin-panel-warning" role="status">
当前表超过 {result.scanLimit} 条扫描与浏览上限,本次已扫描{' '}
{result.scannedCount} 条;当前分页结果和匹配总数可能不完整。
</section>
) : null}
<section className="admin-panel admin-database-result-panel">
<div className="admin-panel-heading">
<h3 title={resultTableHeader.description}>
{resultTableHeader.label}
</h3>
<span>
匹配 {totalMatched} 条,本页 {result?.totalReturned ?? 0}
</span>
</div>
<div className="admin-table-wrap">
<table className="admin-table admin-table-wide admin-database-table">
<thead>
<tr>
{columnHeaders.map(({ column, label, description }) => {
const isSorted = sortColumn === column;
return (
<th
key={column}
aria-sort={
isSorted
? sortDirection === 'asc'
? 'ascending'
: 'descending'
: 'none'
}
>
<div className="admin-database-column-header">
<button
className="admin-table-sort-button"
data-active={isSorted ? 'true' : 'false'}
title={`${description} 当前状态:${isSorted ? (sortDirection === 'asc' ? '正序' : '倒序') : '不排序'}。`}
type="button"
disabled={isLoadingRows}
onClick={() => handleSortColumn(column)}
>
<span>{label}</span>
{isSorted ? (
sortDirection === 'asc' ? (
<ArrowUp size={14} aria-hidden="true" />
) : (
<ArrowDown size={14} aria-hidden="true" />
)
) : (
<ArrowUpDown size={14} aria-hidden="true" />
)}
</button>
<button
className="admin-table-filter-button"
aria-label={`按${label}添加条件`}
title={`按 ${label} 添加筛选条件`}
type="button"
disabled={isLoadingRows}
onClick={() => handleAddCondition(column)}
>
<Filter size={13} aria-hidden="true" />
</button>
</div>
</th>
);
})}
</tr>
</thead>
<tbody>
{result?.rows.length ? (
result.rows.map((row, rowIndex) => (
<tr
key={buildRowKey(row, rowIndex)}
data-clickable="true"
tabIndex={0}
onClick={() => setDetailRow(row)}
onKeyDown={(event) => handleRowKeyDown(event, row)}
>
{visibleColumns.map((column) => {
const rawValue = row.cells[column];
const cellValue = formatCellValue(rawValue, column);
const userReference = resolveAdminDatabaseUserReference(
result.tableName,
column,
row.cells[column],
);
return (
<td key={column}>
<div className="admin-database-user-cell">
<span
className="admin-table-cell-ellipsis"
title={cellValue.fullText}
>
{getJsonTablePreviewText(rawValue) ? (
<span className="admin-table-json-preview">
{renderJsonSyntax(
getJsonTablePreviewText(rawValue) ?? '',
)}
</span>
) : (
cellValue.content
)}
</span>
{userReference ? (
<AdminUserReferenceButton
token={token}
{...userReference}
onUnauthorized={onUnauthorized}
/>
) : null}
</div>
</td>
);
})}
</tr>
))
) : (
<tr>
<td colSpan={Math.max(visibleColumns.length, 1)}>暂无数据</td>
</tr>
)}
</tbody>
</table>
</div>
</section>
{result ? (
<nav
className="admin-action-row admin-database-pagination"
aria-label="表查询分页"
>
<span className="admin-database-pagination-info">
{currentPage} / {totalPages} 页,共 {totalMatched}
</span>
<div className="admin-action-row">
<button
className="admin-secondary-button"
aria-label="上一页"
disabled={isLoadingRows || currentPage <= 1}
title="上一页"
type="button"
onClick={() => handlePageChange(currentPage - 1)}
>
<ChevronLeft size={17} aria-hidden="true" />
<span>上一页</span>
</button>
<button
className="admin-secondary-button"
aria-label="下一页"
disabled={isLoadingRows || currentPage >= totalPages}
title="下一页"
type="button"
onClick={() => handlePageChange(currentPage + 1)}
>
<span>下一页</span>
<ChevronRight size={17} aria-hidden="true" />
</button>
</div>
</nav>
) : null}
{copyToast ? (
<div
className="admin-toast"
data-tone={copyToast.tone}
role={copyToast.tone === 'error' ? 'alert' : 'status'}
aria-live={copyToast.tone === 'error' ? 'assertive' : 'polite'}
>
{copyToast.message}
</div>
) : null}
{detailRow ? (
<div className="admin-confirm-backdrop" role="presentation">
<section
className="admin-detail-panel"
role="dialog"
aria-modal="true"
>
<div className="admin-panel-heading">
<h3>行详情</h3>
<div className="admin-detail-actions">
<button
className="admin-ghost-button"
title="关闭"
type="button"
onClick={() => setDetailRow(null)}
>
<X size={17} aria-hidden="true" />
</button>
</div>
</div>
<div className="admin-database-detail-table" role="table">
<div className="admin-database-detail-table-header" role="row">
<span role="columnheader">字段</span>
<span role="columnheader"></span>
<span role="columnheader">操作</span>
</div>
<div className="admin-database-detail-field-list" role="rowgroup">
{detailFields.map(
({ column, label, description, cellValue, jsonPreview }) => (
<div
className="admin-database-detail-field"
key={column}
role="row"
>
<div
className="admin-database-detail-field-name"
role="rowheader"
title={description}
>
{label}
</div>
<div
className="admin-database-detail-field-value"
role="cell"
title={cellValue.fullText}
>
{jsonPreview ? (
<pre
className="admin-json-preview"
aria-label={`${label} JSON`}
>
{renderJsonSyntax(jsonPreview)}
</pre>
) : (
cellValue.content
)}
</div>
<div role="cell">
<div className="admin-database-detail-row-actions">
<button
className="admin-secondary-button"
aria-label={`复制${label}`}
title={`复制 ${label}`}
type="button"
onClick={() =>
void handleCopyDetailField(
column,
detailRow?.cells[column],
)
}
>
<span>复制</span>
</button>
</div>
</div>
</div>
),
)}
</div>
</div>
</section>
</div>
) : null}
</section>
);
}
function AdminExternalApiKeysPanel({
token,
onUnauthorized,
}: {
token: string;
onUnauthorized: (message?: string) => void;
}) {
const [ownerUserId, setOwnerUserId] = useState('');
const [publicUserCode, setPublicUserCode] = useState('');
const [keyId, setKeyId] = useState('');
const [name, setName] = useState('');
const [keyPrefix, setKeyPrefix] = useState('');
const [createdAfter, setCreatedAfter] = useState('');
const [createdBefore, setCreatedBefore] = useState('');
const [status, setStatus] = useState<'' | 'active' | 'revoked'>('');
const [limit, setLimit] = useState('100');
const [sortColumn, setSortColumn] = useState<ExternalApiKeySortColumn>('');
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('desc');
const [result, setResult] = useState<AdminExternalApiKeyListResponse | null>(
null,
);
const [selectedKey, setSelectedKey] = useState<
AdminExternalApiKeyListResponse['keys'][number] | null
>(null);
const [errorMessage, setErrorMessage] = useState('');
const [isLoading, setIsLoading] = useState(false);
async function submit(event?: FormEvent<HTMLFormElement>, nextOffset = 0) {
event?.preventDefault();
const normalizedOwner = ownerUserId.trim();
const normalizedPublicCode = publicUserCode.trim();
const normalizedKeyId = keyId.trim();
const normalizedPrefix = keyPrefix.trim();
if (
!normalizedOwner &&
!normalizedPublicCode &&
!normalizedKeyId &&
!normalizedPrefix
) {
setErrorMessage(
'请提供 owner、公开用户编号、keyId 或精确 Key 前缀后再查询。',
);
setResult(null);
return;
}
if (normalizedOwner && normalizedPublicCode) {
setErrorMessage('owner 与公开用户编号不能同时提供。');
setResult(null);
return;
}
setIsLoading(true);
setErrorMessage('');
try {
const response = await getAdminExternalApiKeys(token, {
ownerUserId: normalizedOwner || undefined,
publicUserCode: normalizedPublicCode || undefined,
keyId: normalizedKeyId || undefined,
name: name.trim() || undefined,
keyPrefix: normalizedPrefix || undefined,
createdAfter: createdAfter.trim() || undefined,
createdBefore: createdBefore.trim() || undefined,
status: status || undefined,
limit: parseLimit(limit),
offset: nextOffset,
sortColumn: sortColumn || undefined,
sortDirection: sortColumn ? sortDirection : undefined,
});
setResult(response);
setSelectedKey(null);
} catch (error: unknown) {
handlePageError(error, onUnauthorized, setErrorMessage);
setResult(null);
} finally {
setIsLoading(false);
}
}
function reset() {
setOwnerUserId('');
setPublicUserCode('');
setKeyId('');
setName('');
setKeyPrefix('');
setCreatedAfter('');
setCreatedBefore('');
setStatus('');
setLimit('100');
setSortColumn('');
setSortDirection('desc');
setResult(null);
setSelectedKey(null);
setErrorMessage('');
}
const resultLimit = result?.limit ?? parseLimit(limit);
const resultOffset = result?.offset ?? 0;
const keyTotalPages = result
? Math.max(1, Math.ceil(result.total / Math.max(1, resultLimit)))
: 1;
const keyCurrentPage =
Math.floor(resultOffset / Math.max(1, resultLimit)) + 1;
const canGoToPreviousKeyPage = Boolean(result && resultOffset > 0);
const canGoToNextKeyPage = Boolean(
result && resultOffset + result.keys.length < result.total,
);
return (
<>
<form className="admin-panel admin-form" onSubmit={submit}>
<div className="admin-table-query-grid">
<label className="admin-field">
<span>Owner ID</span>
<input
placeholder="精确 ownerUserId"
value={ownerUserId}
onChange={(event) => setOwnerUserId(event.target.value)}
/>
</label>
<label className="admin-field">
<span>公开用户编号</span>
<input
placeholder="精确 publicUserCode"
value={publicUserCode}
onChange={(event) => setPublicUserCode(event.target.value)}
/>
</label>
<label className="admin-field">
<span>Key ID</span>
<input
placeholder="精确 keyId"
value={keyId}
onChange={(event) => setKeyId(event.target.value)}
/>
</label>
<label className="admin-field">
<span>Key 前缀</span>
<input
placeholder="精确前缀"
value={keyPrefix}
onChange={(event) => setKeyPrefix(event.target.value)}
/>
</label>
<label className="admin-field">
<span>名称包含</span>
<input
placeholder="可选"
value={name}
onChange={(event) => setName(event.target.value)}
/>
</label>
<label className="admin-field">
<span>状态</span>
<select
value={status}
onChange={(event) =>
setStatus(event.target.value as typeof status)
}
>
<option value="">全部</option>
<option value="active">active</option>
<option value="revoked">revoked</option>
</select>
</label>
<label className="admin-field">
<span>创建时间起</span>
<input
placeholder="ISO 时间"
value={createdAfter}
onChange={(event) => setCreatedAfter(event.target.value)}
/>
</label>
<label className="admin-field">
<span>创建时间止</span>
<input
placeholder="ISO 时间"
value={createdBefore}
onChange={(event) => setCreatedBefore(event.target.value)}
/>
</label>
<label className="admin-field admin-field-compact">
<span>每页条数</span>
<input
inputMode="numeric"
value={limit}
onChange={(event) => setLimit(event.target.value)}
/>
</label>
<label className="admin-field">
<span>安全排序字段</span>
<select
value={sortColumn}
onChange={(event) =>
setSortColumn(event.target.value as ExternalApiKeySortColumn)
}
>
<option value="">创建时间(默认倒序)</option>
<option value="keyId">Key ID</option>
<option value="ownerUserId">Owner</option>
<option value="name">名称</option>
<option value="keyPrefix">Key 前缀</option>
<option value="createdAt">创建时间</option>
<option value="lastUsedAt">最近使用</option>
<option value="updatedAt">更新时间</option>
</select>
</label>
<label className="admin-field admin-field-compact">
<span>排序方向</span>
<select
value={sortDirection}
onChange={(event) =>
setSortDirection(event.target.value as SortDirection)
}
>
<option value="desc">倒序</option>
<option value="asc">正序</option>
</select>
</label>
</div>
<div className="admin-action-row admin-query-action-row">
<button
className="admin-primary-button"
disabled={isLoading}
type="submit"
>
<Search size={17} aria-hidden="true" />
<span>{isLoading ? '查询中' : '安全查询'}</span>
</button>
<button
className="admin-ghost-button admin-query-reset-button"
disabled={isLoading}
type="button"
onClick={reset}
>
重置条件
</button>
<span className="admin-query-summary">
必须提供 owner、公开用户编号、keyId 或精确前缀;只返回安全元数据。
</span>
</div>
</form>
{errorMessage ? (
<div className="admin-alert" role="status">
{errorMessage}
</div>
) : null}
<section className="admin-panel">
<div className="admin-panel-heading">
<h3>External API Key 安全视图</h3>
<span>{result ? `匹配 ${result.total} 条` : '尚未查询'}</span>
</div>
<div className="admin-table-wrap">
<table className="admin-table admin-table-wide admin-database-table">
<thead>
<tr>
<th>Key ID</th>
<th>Owner</th>
<th>名称</th>
<th>前缀</th>
<th>Scope</th>
<th>创建时间</th>
<th>状态</th>
<th>详情</th>
</tr>
</thead>
<tbody>
{result?.keys.length ? (
result.keys.map((key) => (
<tr key={key.keyId}>
<td>{key.keyId}</td>
<td>{key.ownerUserId}</td>
<td>{key.name}</td>
<td>{key.keyPrefix}</td>
<td>{key.scopes.join(', ')}</td>
<td>{formatSafeDate(key.createdAt)}</td>
<td>{key.status}</td>
<td>
<button
className="admin-secondary-button"
type="button"
onClick={() => setSelectedKey(key)}
>
<Eye size={16} aria-hidden="true" />
<span>详情</span>
</button>
</td>
</tr>
))
) : (
<tr>
<td colSpan={8}>
{result ? '暂无数据' : '请先提供精确范围并查询'}
</td>
</tr>
)}
</tbody>
</table>
</div>
{result?.scanLimitReached ? (
<div className="admin-alert" role="status">
当前查询达到 {result.scanLimit} 条安全扫描上限,已扫描{' '}
{result.scannedCount} 条; 总数和分页可能不完整,请缩小 ownerKey
ID 或精确前缀范围。
</div>
) : null}
{result ? (
<nav
className="admin-action-row admin-key-pagination"
aria-label="API Key 查询分页"
>
<span>
{keyCurrentPage} / {keyTotalPages} 页,共至少 {result.total}
</span>
<div className="admin-action-row">
<button
className="admin-ghost-button"
type="button"
disabled={isLoading || !canGoToPreviousKeyPage}
onClick={() => {
void submit(
undefined,
Math.max(0, resultOffset - resultLimit),
);
}}
>
<ChevronLeft size={16} aria-hidden="true" />
<span>上一页</span>
</button>
<button
className="admin-ghost-button"
type="button"
disabled={isLoading || !canGoToNextKeyPage}
onClick={() => {
void submit(undefined, resultOffset + resultLimit);
}}
>
<span>下一页</span>
<ChevronRight size={16} aria-hidden="true" />
</button>
</div>
</nav>
) : null}
</section>
{selectedKey ? (
<div className="admin-confirm-backdrop" role="presentation">
<section
className="admin-detail-panel"
role="dialog"
aria-modal="true"
>
<div className="admin-panel-heading">
<h3>API Key 安全详情</h3>
<button
className="admin-ghost-button"
title="关闭"
type="button"
onClick={() => setSelectedKey(null)}
>
<X size={17} aria-hidden="true" />
</button>
</div>
<dl className="admin-key-detail-list">
<dt>Key ID</dt>
<dd>{selectedKey.keyId}</dd>
<dt>Owner</dt>
<dd>{selectedKey.ownerUserId}</dd>
<dt>名称</dt>
<dd>{selectedKey.name}</dd>
<dt>前缀</dt>
<dd>{selectedKey.keyPrefix}</dd>
<dt>Scope</dt>
<dd>{selectedKey.scopes.join(', ') || '-'}</dd>
<dt>创建时间</dt>
<dd>{formatSafeDate(selectedKey.createdAt)}</dd>
<dt>最近使用</dt>
<dd>{formatSafeDate(selectedKey.lastUsedAt)}</dd>
<dt>撤销时间</dt>
<dd>{formatSafeDate(selectedKey.revokedAt)}</dd>
<dt>更新时间</dt>
<dd>{formatSafeDate(selectedKey.updatedAt)}</dd>
<dt>状态</dt>
<dd>{selectedKey.status}</dd>
</dl>
</section>
</div>
) : null}
</>
);
}
function formatSafeDate(value: string | null) {
return value ? value : '-';
}
function readHashTableName() {
const hash = window.location.hash;
const queryIndex = hash.indexOf('?');
if (queryIndex < 0) {
return '';
}
return (
new URLSearchParams(hash.slice(queryIndex + 1)).get('table')?.trim() ?? ''
);
}
function parseLimit(value: string) {
const parsed = Number.parseInt(value.trim(), 10);
return Number.isFinite(parsed) ? parsed : 100;
}
type DatabaseFilterOperator =
| 'eq'
| 'ne'
| 'gt'
| 'gte'
| 'lt'
| 'lte'
| 'contains'
| 'notContains'
| 'startsWith'
| 'endsWith'
| 'in'
| 'notIn'
| 'isEmpty'
| 'isNotEmpty';
interface DatabaseFilterCondition {
column: string;
op: DatabaseFilterOperator;
value: string;
values: string[];
enabled: boolean;
}
interface DatabaseFilterOperatorOption {
op: DatabaseFilterOperator;
label: string;
needsValue: boolean;
listValue: boolean;
}
const databaseFilterOperatorOptions: DatabaseFilterOperatorOption[] = [
{ op: 'eq', label: '等于', needsValue: true, listValue: false },
{ op: 'ne', label: '不等于', needsValue: true, listValue: false },
{ op: 'contains', label: '包含', needsValue: true, listValue: false },
{ op: 'notContains', label: '不包含', needsValue: true, listValue: false },
{ op: 'startsWith', label: '开头是', needsValue: true, listValue: false },
{ op: 'endsWith', label: '结尾是', needsValue: true, listValue: false },
{ op: 'gt', label: '大于', needsValue: true, listValue: false },
{ op: 'gte', label: '大于等于', needsValue: true, listValue: false },
{ op: 'lt', label: '小于', needsValue: true, listValue: false },
{ op: 'lte', label: '小于等于', needsValue: true, listValue: false },
{ op: 'in', label: '在列表中', needsValue: true, listValue: true },
{ op: 'notIn', label: '不在列表中', needsValue: true, listValue: true },
{ op: 'isEmpty', label: '为空', needsValue: false, listValue: false },
{ op: 'isNotEmpty', label: '不为空', needsValue: false, listValue: false },
];
function getDatabaseFilterOperatorOption(op: DatabaseFilterOperator) {
return databaseFilterOperatorOptions.find((option) => option.op === op);
}
function buildDatabaseFiltersJson(conditions: DatabaseFilterCondition[]) {
const entries = conditions.flatMap((condition) => {
if (!condition.enabled) {
return [];
}
const entry = buildDatabaseFilterEntry(condition);
return entry ? [entry] : [];
});
return entries.length ? JSON.stringify(entries) : '';
}
function buildDatabaseFilterEntry(condition: DatabaseFilterCondition) {
const column = condition.column.trim();
if (!column) {
return null;
}
const operatorOption = getDatabaseFilterOperatorOption(condition.op);
if (!operatorOption) {
return null;
}
if (!operatorOption.needsValue) {
return { column, op: condition.op };
}
if (operatorOption.listValue) {
return condition.values.length
? { column, op: condition.op, value: condition.values }
: null;
}
const value = condition.value.trim();
if (!value) {
return null;
}
return { column, op: condition.op, value };
}
function getDatabaseFilterValidationError(condition: DatabaseFilterCondition) {
if (!condition.enabled) {
return null;
}
if (!condition.column.trim()) {
return '请选择字段';
}
const operatorOption = getDatabaseFilterOperatorOption(condition.op);
if (!operatorOption) {
return '请选择有效的条件';
}
if (operatorOption.listValue && !condition.values.length) {
return '请至少添加一个值';
}
if (operatorOption.needsValue && !operatorOption.listValue) {
return condition.value.trim() ? null : '请输入筛选值';
}
return null;
}
function getDatabaseFilterValidationMessage(
conditions: DatabaseFilterCondition[],
) {
const invalidIndex = conditions.findIndex(
(condition) => getDatabaseFilterValidationError(condition) !== null,
);
return invalidIndex >= 0
? `条件 ${invalidIndex + 1} 未完成,请补充后再查询`
: null;
}
function formatDatabaseFilterSummary(condition: DatabaseFilterCondition) {
const column = condition.column.trim() || '未选择字段';
const operatorOption = getDatabaseFilterOperatorOption(condition.op);
const operator = operatorOption?.label ?? condition.op;
if (operatorOption?.listValue) {
return `${column} ${operator} ${condition.values.length ? `[${condition.values.join('、')}]` : '[]'}`;
}
if (!operatorOption?.needsValue) {
return `${column} ${operator}`;
}
return `${column} ${operator} ${condition.value.trim() || '(未填写)'}`;
}
function focusConditionControl(index: number, target: 'column' | 'value') {
window.setTimeout(() => {
document
.getElementById(`admin-filter-condition-${target}-${index}`)
?.focus();
}, 0);
}
function isInteractiveFormControl(target: EventTarget | null) {
return (
target instanceof HTMLElement &&
target.closest('button, a, input, select, textarea') !== null
);
}
function getDatabaseTableHeader(tableName: string): DatabaseTableHeader {
const normalizedName = tableName.trim();
if (!normalizedName) {
return {
description: '尚未选择数据库表。',
label: '数据行',
name: '',
optionLabel: '-',
};
}
const label = getDatabaseTableLabel(normalizedName);
const description = getDatabaseTableDescription(normalizedName, label);
return {
description: `原始表名:${normalizedName}${description}。`,
label,
name: normalizedName,
optionLabel: `${label}${normalizedName}`,
};
}
function getDatabaseTableLabel(tableName: string) {
const exactLabel = databaseTableLabelMap[tableName];
if (exactLabel) {
return exactLabel;
}
const segments = tableName.split('_').filter(Boolean);
if (segments.length) {
return segments.map(formatDatabaseTableColumnSegment).join('');
}
return tableName;
}
function getDatabaseTableDescription(tableName: string, label: string) {
return (
databaseTableDescriptionMap[tableName] ??
`当前 SpacetimeDB 中的 ${label} 表`
);
}
function getDatabaseTableColumnHeader(tableName: string, column: string) {
const normalizedColumn = column.trim();
const label = getDatabaseTableColumnLabel(normalizedColumn);
const description = getDatabaseTableColumnDescription(
tableName,
normalizedColumn,
label,
);
return { column: normalizedColumn, label, description };
}
function getDatabaseTableColumnLabel(column: string) {
const normalizedColumn = column.trim();
const exactLabel = databaseTableColumnLabelMap[normalizedColumn];
if (exactLabel) {
return exactLabel;
}
const segments = normalizedColumn.split('_').filter(Boolean);
if (segments.length) {
return segments.map(formatDatabaseTableColumnSegment).join('');
}
return '字段';
}
function formatDatabaseTableColumnSegment(segment: string) {
return databaseTableColumnSegmentLabelMap[segment] ?? segment;
}
function getDatabaseTableColumnDescription(
tableName: string,
column: string,
label: string,
) {
const exactDescription = databaseTableColumnDescriptionMap[column];
const description =
exactDescription ?? `当前表 ${tableName || '未知'} 中的 ${label} 字段`;
return `原始字段名:${column}${description}。点击列名可在正序、倒序和不排序之间循环切换。`;
}
function buildRowKey(row: AdminDatabaseTableRowPayload, rowIndex: number) {
const firstValue = Object.values(row.cells)[0];
return `${rowIndex}-${String(firstValue ?? '')}`;
}
export function resolveAdminDatabaseUserReference(
tableName: string,
column: string,
value: unknown,
) {
if (typeof value !== 'string' && typeof value !== 'number') {
return null;
}
const normalizedValue = String(value).trim();
const normalizedColumn = column.trim().toLowerCase();
const normalizedTable = tableName.trim().toLowerCase();
if (!normalizedValue || normalizedValue.toLowerCase().startsWith('admin:')) {
return null;
}
if (
normalizedColumn === 'operator_user_id' ||
normalizedColumn.includes('admin_user_id') ||
(normalizedTable === 'profile_invite_code' &&
normalizedColumn === 'user_id')
) {
return null;
}
if (
normalizedColumn === 'public_user_code' ||
normalizedColumn.endsWith('_public_user_code')
) {
return { publicUserCode: normalizedValue };
}
if (normalizedColumn === 'user_id' || normalizedColumn.endsWith('_user_id')) {
return { userId: normalizedValue };
}
return null;
}
function formatCellValue(value: unknown, column = ''): FormattedTableCellValue {
if (value === null || typeof value === 'undefined' || value === '') {
return { content: '-', fullText: '-' };
}
if (
typeof value === 'string' ||
typeof value === 'number' ||
typeof value === 'boolean'
) {
const text = String(value);
const readableTimestamp = formatReadableTimestampValue(value, column);
if (readableTimestamp) {
return {
content: readableTimestamp,
fullText: `${readableTimestamp}(原始值:${text}`,
};
}
return { content: text, fullText: text };
}
return {
content: stringifyUnknownValue(value),
fullText: stringifyPrettyUnknownValue(value),
};
}
function getJsonPreviewText(value: unknown) {
if (value === null || typeof value !== 'object') {
if (typeof value !== 'string') {
return null;
}
try {
const parsed = JSON.parse(value) as unknown;
return parsed !== null && typeof parsed === 'object'
? stringifyPrettyUnknownValue(parsed)
: null;
} catch {
return null;
}
}
return stringifyPrettyUnknownValue(value);
}
function getJsonTablePreviewText(value: unknown) {
if (value === null || typeof value !== 'object') {
if (typeof value !== 'string') {
return null;
}
try {
const parsed = JSON.parse(value) as unknown;
return parsed !== null && typeof parsed === 'object'
? stringifyUnknownValue(parsed)
: null;
} catch {
return null;
}
}
return stringifyUnknownValue(value);
}
function renderJsonSyntax(text: string) {
const tokenPattern =
/("(?:\\.|[^"\\])*")(?=\s*:)|("(?:\\.|[^"\\])*")|(-?\b\d+(?:\.\d+)?(?:e[+-]?\d+)?\b)|(\btrue\b|\bfalse\b)|(\bnull\b)/gi;
const parts = [];
let lastIndex = 0;
let match: RegExpExecArray | null;
while ((match = tokenPattern.exec(text))) {
if (match.index > lastIndex) {
parts.push(text.slice(lastIndex, match.index));
}
const token = match[0];
const className = match[1]
? 'admin-json-token-key'
: match[2]
? 'admin-json-token-string'
: match[3]
? 'admin-json-token-number'
: match[4]
? 'admin-json-token-boolean'
: 'admin-json-token-null';
parts.push(
<span className={className} key={`${match.index}-${token}`}>
{token}
</span>,
);
lastIndex = tokenPattern.lastIndex;
}
if (lastIndex < text.length) {
parts.push(text.slice(lastIndex));
}
return parts;
}
function formatReadableTimestampValue(
value: string | number | boolean,
column: string,
) {
if (typeof value === 'boolean' || !isTimestampColumn(column)) {
return '';
}
const timestampMs = parseTimestampMillis(value, column);
if (timestampMs === null) {
return '';
}
const date = new Date(timestampMs);
if (Number.isNaN(date.getTime())) {
return '';
}
return formatBeijingDateTime(date);
}
function isTimestampColumn(column: string) {
const normalizedColumn = column.trim().toLowerCase();
return (
normalizedColumn.endsWith('_at') ||
normalizedColumn.endsWith('_at_ms') ||
normalizedColumn.endsWith('_at_micros') ||
normalizedColumn.endsWith('_timestamp') ||
normalizedColumn.endsWith('_timestamp_ms') ||
normalizedColumn.endsWith('_timestamp_micros')
);
}
function parseTimestampMillis(value: string | number, column: string) {
if (typeof value === 'number') {
return parseNumericTimestampMillis(value, column);
}
const trimmed = value.trim();
if (!trimmed) {
return null;
}
const numericValue = Number(trimmed);
if (Number.isFinite(numericValue) && /^-?\d+(\.\d+)?$/.test(trimmed)) {
return parseNumericTimestampMillis(numericValue, column);
}
const parsed = Date.parse(trimmed);
return Number.isNaN(parsed) ? null : parsed;
}
function parseNumericTimestampMillis(value: number, column: string) {
if (!Number.isFinite(value) || value <= 0) {
return null;
}
const normalizedColumn = column.trim().toLowerCase();
if (
normalizedColumn.endsWith('_at_ms') ||
normalizedColumn.endsWith('_timestamp_ms')
) {
return value;
}
if (
normalizedColumn.endsWith('_at_micros') ||
normalizedColumn.endsWith('_timestamp_micros')
) {
return Math.floor(value / 1_000);
}
if (value >= 1_000_000_000_000_000) {
return Math.floor(value / 1_000);
}
if (value >= 1_000_000_000_000) {
return value;
}
if (value >= 1_000_000_000) {
return value * 1_000;
}
return null;
}
function formatBeijingDateTime(date: Date) {
const parts = new Intl.DateTimeFormat('zh-CN', {
day: '2-digit',
hour: '2-digit',
hourCycle: 'h23',
minute: '2-digit',
month: '2-digit',
second: '2-digit',
timeZone: 'Asia/Shanghai',
year: 'numeric',
}).formatToParts(date);
const partMap = Object.fromEntries(
parts.map((part) => [part.type, part.value]),
);
return `${partMap.year}-${partMap.month}-${partMap.day} ${partMap.hour}:${partMap.minute}:${partMap.second}`;
}
function stringifyPrettyUnknownValue(value: unknown) {
try {
const serialized = JSON.stringify(value, null, 2);
return serialized ?? String(value);
} catch {
return String(value);
}
}
function stringifyUnknownValue(value: unknown) {
try {
const serialized = JSON.stringify(value);
return serialized ?? String(value);
} catch {
return String(value);
}
}
function stringifyDetailValue(value: unknown) {
if (value === null || typeof value === 'undefined') {
return '';
}
if (typeof value === 'string') {
return value;
}
if (typeof value === 'number' || typeof value === 'boolean') {
return String(value);
}
return stringifyPrettyUnknownValue(value);
}
interface DatabaseTableHeader {
name: string;
label: string;
optionLabel: string;
description: string;
}
interface FormattedTableCellValue {
content: string;
fullText: string;
}
const databaseTableColumnLabelMap: Record<string, string> = {
id: 'ID',
table_name: '表名',
row_count: '行数',
error_message: '错误信息',
fetch_errors: '读取异常',
total_returned: '返回总数',
columns: '列名列表',
rows: '行列表',
raw: '原始值',
user_id: '用户ID',
referee_user_id: '被邀请人ID',
invitee_user_id: '被邀请人ID',
owner_user_id: '归属用户ID',
profile_id: '档案ID',
module_key: '模块键',
event_id: '事件ID',
event_key: '事件键',
event_title: '事件名称',
operation_id: '操作ID',
code_kind: '码类型',
scope_kind: '范围类型',
scope_id: '范围ID',
day_key: '日期键',
occurred_at: '发生时间',
created_at: '创建时间',
updated_at: '更新时间',
starts_at: '开始时间',
expires_at: '到期时间',
last_played_at: '最近游玩时间',
published_at: '发布时间',
sort_order: '排序值',
reward_points: '奖励积分',
threshold: '阈值',
enabled: '启用状态',
status: '状态',
description: '说明',
title: '标题',
name: '名称',
points: '积分',
total: '总数',
count: '数量',
metadata_json: '元数据JSON',
data_json: '数据JSON',
config_json: '配置JSON',
draft_json: '草稿JSON',
snapshot_json: '快照JSON',
payload_json: '载荷JSON',
request_payload_json: '请求载荷JSON',
latest_structured_payload_json: '最新结构化载荷JSON',
result_payload_json: '结果载荷JSON',
evidence_json: '凭证JSON',
source_asset_ids_json: '源资产ID列表',
available_choices_json: '可选项JSON',
visible_character_ids_json: '可见角色ID列表',
played_profile_ids_json: '已玩档案列表',
tags_json: '标签JSON',
theme_tags_json: '主题标签JSON',
cover_image_src: '封面图',
cover_asset_id: '封面资产ID',
public_user_code: '陶泥号',
public_work_code: '公开作品号',
author_public_user_code: '作者陶泥号',
author_display_name: '作者昵称',
display_name: '显示名',
avatar_url: '头像地址',
phone_number_masked: '手机号掩码',
phone_number_e164: '手机号E164',
login_method: '登录方式',
binding_status: '绑定状态',
token_version: '令牌版本',
wallet_balance: '钱包余额',
amount_delta: '变动金额',
balance_after: '变动后余额',
source_type: '来源类型',
source_module: '来源模块',
source_entity_id: '来源实体ID',
request_label: '请求标签',
task_kind: '任务类型',
failure_message: '失败信息',
latest_text_output: '最新文本输出',
world_key: '世界键',
world_type: '世界类型',
world_name: '世界名称',
world_title: '世界标题',
world_subtitle: '世界副标题',
subtitle: '副标题',
summary_text: '摘要文本',
bottom_tab: '底部Tab',
saved_at: '存档时间',
visited_at: '访问时间',
claimed_at: '领取时间',
paid_at: '支付时间',
started_at: '开始时间',
completed_at: '完成时间',
finished_at_ms: '完成时间毫秒',
started_at_ms: '开始时间毫秒',
elapsed_ms: '耗时毫秒',
duration_limit_ms: '时限毫秒',
play_count: '游玩次数',
clear_count: '通关次数',
like_id: '点赞ID',
liked_at: '点赞时间',
invite_code: '邀请码',
inviter_user_id: '邀请人ID',
inviter_reward_granted: '邀请人奖励发放状态',
invitee_reward_granted: '被邀请人奖励发放状态',
bound_at: '绑定时间',
search: '关键词',
filters: '筛选条件',
limit: '条数上限',
value: '值',
type: '类型',
kind: '类型',
key: '键',
url: '链接地址',
path: '路径',
message: '消息',
content: '内容',
text: '文本',
source: '来源',
target: '目标',
asset_id: '资产ID',
file_id: '文件ID',
task_id: '任务ID',
session_id: '会话ID',
record_id: '记录ID',
created_by: '创建人',
updated_by: '更新人',
updated_by_admin_user_id: '更新管理员',
operator_user_id: '操作人ID',
writeridentity: '写入身份',
total_count: '总数',
max_uses: '最大使用次数',
global_used_count: '全局使用次数',
allowed_user_ids: '允许用户列表',
metadata: '元数据',
};
const databaseTableColumnDescriptionMap: Record<string, string> = {
id: '当前记录的唯一标识',
table_name: '当前查询的表名',
row_count: '当前统计到的行数',
error_message: '读取表统计或行数据时返回的错误信息',
fetch_errors: '读取表列表时积累的异常信息',
total_returned: '本次接口实际返回的行数',
columns: '当前结果集返回的字段名列表',
rows: '当前结果集返回的行列表',
raw: '当前行的原始值',
user_id: '当前记录所属用户的标识',
referee_user_id: '被邀请人的用户标识',
invitee_user_id: '被邀请人的用户标识',
owner_user_id: '当前记录归属用户的标识',
profile_id: '当前用户档案的标识',
module_key: '当前记录所属模块的业务键',
event_id: '当前埋点事件的唯一标识',
event_key: '埋点事件键',
event_title: '埋点事件展示名称',
operation_id: '后台操作记录的唯一标识',
code_kind: '码类型,redeem 表示兑换码,invite 表示邀请码',
scope_kind: '埋点统计范围类型',
scope_id: '埋点统计范围标识',
day_key: '按天聚合时使用的日期键',
occurred_at: '事件实际发生时间',
created_at: '记录创建时间',
updated_at: '记录更新时间',
starts_at: '生效开始时间',
expires_at: '失效或到期时间',
last_played_at: '最近一次游玩时间',
published_at: '公开发布时间',
sort_order: '列表或配置项使用的排序值',
reward_points: '完成任务后发放的奖励积分',
threshold: '触发完成条件的阈值',
enabled: '是否启用',
status: '当前状态',
description: '字段说明',
title: '展示标题',
name: '名称',
points: '积分或点数',
metadata_json: '存放结构化附加信息的 JSON 文本',
data_json: '存放业务数据的 JSON 文本',
config_json: '当前记录的配置 JSON',
draft_json: '当前记录的草稿 JSON',
snapshot_json: '当前运行态快照 JSON',
payload_json: '事件或任务携带的载荷 JSON',
request_payload_json: 'AI 任务请求载荷 JSON',
latest_structured_payload_json: 'AI 任务最新结构化输出 JSON',
result_payload_json: '生成或编排完成后的结果载荷 JSON',
evidence_json: '用户反馈提交时附带的凭证元数据 JSON',
source_asset_ids_json: '当前记录引用的源资产 ID 列表',
available_choices_json: '当前运行态可选项 JSON',
visible_character_ids_json: '当前运行态可见角色 ID 列表',
played_profile_ids_json: '当前运行态已串联游玩的档案 ID 列表',
tags_json: '标签列表 JSON',
theme_tags_json: '主题标签列表 JSON',
cover_image_src: '封面图片地址或平台资产引用',
cover_asset_id: '封面对应的平台资产 ID',
public_user_code: '用户对外展示的陶泥号',
public_work_code: '作品公开后对外展示的作品号',
author_public_user_code: '作者对外展示的陶泥号',
author_display_name: '作者展示昵称',
display_name: '用户展示名称',
avatar_url: '用户头像地址',
phone_number_masked: '脱敏后的手机号',
phone_number_e164: 'E.164 格式手机号',
login_method: '账号最近或主要登录方式',
binding_status: '账号绑定状态',
token_version: '用于令牌吊销和刷新控制的版本号',
wallet_balance: '当前钱包余额',
amount_delta: '本次钱包流水的增减值',
balance_after: '本次变更后的钱包余额',
source_type: '当前记录的来源类型',
source_module: '触发 AI 任务的来源模块',
source_entity_id: '触发 AI 任务的来源实体 ID',
request_label: 'AI 任务请求展示标签',
task_kind: 'AI 任务类型',
failure_message: '失败时记录的错误信息',
latest_text_output: 'AI 任务最近一次文本输出',
world_key: '世界或作品在运行态中的稳定键',
world_type: '世界或作品类型',
world_name: '世界名称',
world_title: '世界标题',
world_subtitle: '世界副标题',
subtitle: '副标题',
summary_text: '摘要文本',
bottom_tab: '保存快照时所在的底部 Tab',
saved_at: '用户保存存档的时间',
visited_at: '用户访问该作品或世界的时间',
claimed_at: '奖励领取时间',
paid_at: '订单支付时间',
started_at: '任务或流程开始时间',
completed_at: '任务或流程完成时间',
finished_at_ms: '运行态完成时的毫秒时间戳',
started_at_ms: '运行态开始时的毫秒时间戳',
elapsed_ms: '运行态已消耗毫秒数',
duration_limit_ms: '运行态时间限制毫秒数',
play_count: '作品累计游玩次数',
clear_count: '累计通关次数',
like_id: '点赞记录唯一标识',
liked_at: '点赞发生时间',
invite_code: '完成绑定时使用的邀请码',
inviter_user_id: '邀请人的用户标识',
inviter_reward_granted: '邀请人奖励是否已经发放',
invitee_reward_granted: '被邀请人奖励是否已经发放',
bound_at: '邀请关系绑定时间',
search: '用于本地过滤的关键词',
filters: '用于等值筛选的 JSON 条件',
limit: '本次查询请求的条数上限',
value: '当前字段值',
type: '当前字段类型',
kind: '当前字段种类',
key: '用于稳定识别记录或业务项的键',
url: '链接地址或资源地址',
path: '资源路径或业务路径',
message: '消息内容或错误信息',
content: '主要内容',
text: '文本内容',
source: '来源字段',
target: '目标字段',
asset_id: '平台资产对象标识',
file_id: '文件标识',
task_id: '任务标识',
session_id: '会话标识',
record_id: '记录标识',
created_by: '创建该记录的主体',
updated_by: '最后更新该记录的主体',
operator_user_id: '执行后台操作的用户标识',
total_count: '累计总数',
max_uses: '允许的最大使用次数',
global_used_count: '当前已使用次数',
allowed_user_ids: '被允许使用的用户 ID 列表',
metadata: '结构化附加信息',
};
const databaseTableColumnSegmentLabelMap: Record<string, string> = {
account: '账户',
action: '动作',
active: '启用',
actor: '参与者',
added: '新增',
address: '地址',
after: '后',
agent: 'Agent',
ai: 'AI',
amount: '金额',
anchor: '锚点',
archive: '存档',
assistant: '助手',
at: '时间',
asset: '资产',
attach: '附加',
attachment: '附件',
author: '作者',
available: '可用',
balance: '余额',
before: '前',
best: '最佳',
binding: '绑定',
blockers: '阻断项',
body: '正文',
bottom: '底部',
built: '构建',
bucket: 'Bucket',
cache: '缓存',
card: '卡片',
cents: '分',
channel: '渠道',
chapter: '章节',
character: '角色',
chat: '聊天',
choice: '选项',
claim: '领取',
claimed: '领取',
claimant: '领取人',
cleared: '已清除',
client: '客户端',
code: '编码',
completed: '完成',
content: '内容',
count: '数量',
create: '创建',
created: '创建',
current: '当前',
custom: '自定义',
data: '数据',
date: '日期',
day: '日期',
default: '默认',
delta: '变动',
delete: '删除',
deleted: '删除',
description: '说明',
detail: '详情',
device: '设备',
difficulty: '难度',
display: '展示',
draft: '草稿',
elapsed: '耗时',
enabled: '启用',
entity: '实体',
entry: '记录',
error: '错误',
evidence: '凭证',
event: '事件',
expires: '到期',
expire: '到期',
expired: '到期',
failure: '失败',
file: '文件',
filter: '筛选',
first: '首个',
flags: '标记',
flow: '流程',
form: '表单',
game: '游戏',
granted: '发放',
global: '全局',
grid: '网格',
group: '分组',
hash: '哈希',
history: '历史',
host: '主机',
id: 'ID',
image: '图片',
info: '信息',
initial: '初始',
input: '输入',
invite: '邀请',
invitee: '被邀请人',
inviter: '邀请人',
issued: '签发',
item: '物品',
job: '任务',
json: 'JSON',
key: '键',
kind: '类型',
last: '最近',
ledger: '流水',
level: '关卡',
liked: '点赞',
limit: '条数上限',
line: '行',
link: '链接',
list: '列表',
login: '登录',
main: '主',
max: '最大',
membership: '会员',
message: '消息',
metadata: '元数据',
metrics: '指标',
module: '模块',
mode: '模式',
name: '名称',
next: '下一个',
npc: 'NPC',
note: '备注',
number: '编号',
object: '对象',
observed: '观察',
order: '订单',
owner: '归属',
pack: '包',
paid: '支付',
password: '密码',
payment: '支付',
percent: '百分比',
phone: '手机',
phase: '阶段',
path: '路径',
pending: '待处理',
picture: '图片',
platform: '平台',
points: '积分',
policy: '策略',
product: '商品',
profile: '档案',
progress: '进度',
prompt: '提示词',
provider: '提供方',
publication: '发布',
publish: '发布',
payload: '载荷',
public: '公开',
query: '查询',
ready: '就绪',
raw: '原始',
record: '记录',
reference: '引用',
referral: '邀请关系',
refresh: '刷新',
register: '注册',
relation: '关系',
reply: '回复',
request: '请求',
response: '响应',
reward: '奖励',
row: '行',
rows: '行',
run: '运行',
runtime: '运行态',
save: '存档',
saved: '存档',
search: '搜索',
seed: '种子',
seen: '可见',
sequence: '序号',
session: '会话',
shape: '形状',
share: '分享',
slot: '槽位',
snapshot: '快照',
sort: '排序',
status: '状态',
source: '来源',
starts: '开始',
started: '开始',
state: '状态',
step: '步骤',
structured: '结构化',
summary: '摘要',
task: '任务',
target: '目标',
text: '文本',
time: '时间',
title: '标题',
total: '总数',
type: '类型',
unique: '唯一',
update: '更新',
updated: '更新',
url: '链接',
user: '用户',
value: '值',
version: '版本',
visible: '可见',
wallet: '钱包',
wechat: '微信',
work: '作品',
world: '世界',
xp: '经验',
occurred: '发生',
played: '游玩',
published: '发布时间',
returned: '返回',
rewardpoints: '奖励积分',
result: '结果',
read: '读取',
write: '写入',
totalreturned: '返回总数',
};
const databaseTableLabelMap: Record<string, string> = {
database_migration_operator: '数据库迁移操作员',
database_migration_import_chunk: '数据库迁移分片',
auth_store_snapshot: '认证仓储快照',
user_account: '用户账号',
auth_identity: '身份绑定',
refresh_session: '刷新会话',
runtime_setting: '运行时设置',
runtime_snapshot: '运行时快照',
user_browse_history: '浏览历史',
profile_dashboard_state: '个人主页状态',
profile_wallet_ledger: '钱包流水',
analytics_date_dimension: '日期维表',
tracking_event: '埋点事件',
tracking_daily_stat: '埋点日统计',
profile_task_config: '个人任务配置',
profile_task_progress: '个人任务进度',
profile_task_reward_claim: '个人任务领奖',
profile_redeem_code: '兑换码',
profile_redeem_code_usage: '兑换码使用记录',
profile_code_operation: '码操作记录',
profile_invite_code: '邀请码',
profile_referral_relation: '邀请关系',
profile_played_world: '已玩世界',
public_work_play_daily_stat: '公开作品日游玩统计',
public_work_like: '公开作品点赞',
profile_membership: '会员状态',
profile_recharge_order: '充值订单',
profile_feedback_submission: '反馈提交',
profile_save_archive: '存档记录',
story_session: '剧情会话',
story_event: '剧情事件',
npc_state: 'NPC 状态',
inventory_slot: '背包槽位',
battle_state: '战斗状态',
treasure_record: '宝藏记录',
quest_record: '任务记录',
quest_log: '任务日志',
player_progression: '玩家进度',
chapter_progression: '章节进度',
custom_world_profile: '自定义世界档案',
custom_world_session: '自定义世界会话',
custom_world_agent_session: '自定义世界 Agent 会话',
custom_world_agent_message: '自定义世界 Agent 消息',
custom_world_agent_operation: '自定义世界 Agent 操作',
custom_world_draft_card: '自定义世界草稿卡片',
custom_world_gallery_entry: '自定义世界画廊条目',
puzzle_agent_session: '拼图 Agent 会话',
puzzle_agent_message: '拼图 Agent 消息',
puzzle_work_profile: '拼图作品档案',
puzzle_event: '拼图事件',
puzzle_runtime_run: '拼图运行记录',
puzzle_leaderboard_entry: '拼图排行榜条目',
match3d_agent_session: '抓大鹅 Agent 会话',
match3d_agent_message: '抓大鹅 Agent 消息',
match3d_work_profile: '抓大鹅作品档案',
match3d_runtime_run: '抓大鹅运行记录',
square_hole_agent_session: '方洞挑战 Agent 会话',
square_hole_agent_message: '方洞挑战 Agent 消息',
square_hole_work_profile: '方洞挑战作品档案',
square_hole_runtime_run: '方洞挑战运行记录',
visual_novel_agent_session: '视觉小说 Agent 会话',
visual_novel_agent_message: '视觉小说 Agent 消息',
visual_novel_work_profile: '视觉小说作品档案',
visual_novel_runtime_run: '视觉小说运行记录',
visual_novel_runtime_history_entry: '视觉小说历史条目',
visual_novel_runtime_event: '视觉小说运行事件',
big_fish_creation_session: '大鱼吃小鱼创建会话',
big_fish_agent_message: '大鱼吃小鱼 Agent 消息',
big_fish_asset_slot: '大鱼吃小鱼资产槽位',
big_fish_event: '大鱼吃小鱼事件',
big_fish_runtime_run: '大鱼吃小鱼运行记录',
asset_object: '资产对象',
asset_entity_binding: '资产实体绑定',
asset_event: '资产事件',
ai_task: 'AI 任务',
ai_task_stage: 'AI 任务阶段',
ai_text_chunk: 'AI 文本分片',
ai_result_reference: 'AI 结果引用',
ai_task_event: 'AI 任务事件',
};
const databaseTableDescriptionMap: Record<string, string> = {
database_migration_operator:
'管理数据库迁移导出、导入和增量导入权限的操作员表',
database_migration_import_chunk: '大迁移 JSON 分片导入的临时表',
auth_store_snapshot: '旧认证仓储的整份 JSON 快照表',
user_account: '用户账号主表',
auth_identity: '第三方或手机号身份绑定表',
refresh_session: '刷新令牌会话表',
runtime_setting: '用户运行时设置表',
runtime_snapshot: '用户当前运行时快照表',
user_browse_history: '用户浏览历史表',
profile_dashboard_state: '个人主页聚合状态表',
profile_wallet_ledger: '钱包流水账表',
analytics_date_dimension: '分析日期维表',
tracking_event: '埋点原始事件表',
tracking_daily_stat: '埋点按自然日聚合表',
profile_task_config: '个人任务配置表',
profile_task_progress: '个人任务进度表',
profile_task_reward_claim: '个人任务领奖记录表',
profile_redeem_code: '运营兑换码表',
profile_redeem_code_usage: '兑换码使用记录表',
profile_code_operation: '兑换码/邀请码后台操作记录表',
profile_invite_code: '用户邀请中心邀请码表',
profile_referral_relation: '邀请关系记录表',
profile_played_world: '用户已玩世界记录表',
public_work_play_daily_stat: '公开作品日游玩统计表',
public_work_like: '公开作品点赞表',
profile_membership: '会员状态表',
profile_recharge_order: '充值订单表',
profile_feedback_submission: '反馈提交记录表',
profile_save_archive: '用户存档记录表',
story_session: '剧情会话表',
story_event: '剧情事件表',
npc_state: 'NPC 状态表',
inventory_slot: '背包槽位表',
battle_state: '战斗状态表',
treasure_record: '宝藏记录表',
quest_record: '任务记录表',
quest_log: '任务日志表',
player_progression: '玩家进度表',
chapter_progression: '章节进度表',
custom_world_profile: '自定义世界档案表',
custom_world_session: '自定义世界会话表',
custom_world_agent_session: '自定义世界 Agent 会话表',
custom_world_agent_message: '自定义世界 Agent 消息表',
custom_world_agent_operation: '自定义世界 Agent 操作表',
custom_world_draft_card: '自定义世界草稿卡片表',
custom_world_gallery_entry: '自定义世界画廊条目表',
puzzle_agent_session: '拼图 Agent 会话表',
puzzle_agent_message: '拼图 Agent 消息表',
puzzle_work_profile: '拼图作品档案表',
puzzle_event: '拼图事件表',
puzzle_runtime_run: '拼图运行记录表',
puzzle_leaderboard_entry: '拼图排行榜条目表',
match3d_agent_session: '抓大鹅 Agent 会话表',
match3d_agent_message: '抓大鹅 Agent 消息表',
match3d_work_profile: '抓大鹅作品档案表',
match3d_runtime_run: '抓大鹅运行记录表',
square_hole_agent_session: '方洞挑战 Agent 会话表',
square_hole_agent_message: '方洞挑战 Agent 消息表',
square_hole_work_profile: '方洞挑战作品档案表',
square_hole_runtime_run: '方洞挑战运行记录表',
visual_novel_agent_session: '视觉小说 Agent 会话表',
visual_novel_agent_message: '视觉小说 Agent 消息表',
visual_novel_work_profile: '视觉小说作品档案表',
visual_novel_runtime_run: '视觉小说运行记录表',
visual_novel_runtime_history_entry: '视觉小说历史条目表',
visual_novel_runtime_event: '视觉小说运行事件表',
big_fish_creation_session: '大鱼吃小鱼创建会话表',
big_fish_agent_message: '大鱼吃小鱼 Agent 消息表',
big_fish_asset_slot: '大鱼吃小鱼资产槽位表',
big_fish_event: '大鱼吃小鱼事件表',
big_fish_runtime_run: '大鱼吃小鱼运行记录表',
asset_object: '资产对象表',
asset_entity_binding: '资产实体绑定表',
asset_event: '资产事件表',
ai_task: 'AI 任务表',
ai_task_stage: 'AI 任务阶段表',
ai_text_chunk: 'AI 文本分片表',
ai_result_reference: 'AI 结果引用表',
ai_task_event: 'AI 任务事件表',
};