cbc73f111c
恢复 external_api_key 表及客户端绑定为 master 原始结构 将 LLM Router 凭据、状态、撤销和缓存统一收敛到 llm_router_account 移除后台与契约中的 purpose 字段及 Router 专用撤销 procedure 同步更新架构文档并补齐相关测试契约
2778 lines
90 KiB
TypeScript
2778 lines
90 KiB
TypeScript
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} 条; 总数和分页可能不完整,请缩小 owner、Key
|
||
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 任务事件表',
|
||
};
|