Files
Genarrative/apps/admin-web/src/pages/AdminDatabaseTablesPage.tsx
T
2026-05-14 14:21:17 +08:00

1362 lines
42 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,
Eye,
RefreshCcw,
Search,
X,
} from 'lucide-react';
import {FormEvent, useEffect, useMemo, useState} from 'react';
import {
getAdminDatabaseTableRows,
getAdminDatabaseTables,
} from '../api/adminApiClient';
import type {
AdminDatabaseTableRowPayload,
AdminDatabaseTableRowsResponse,
} from '../api/adminApiTypes';
import {handlePageError} from './pageUtils';
interface AdminDatabaseTablesPageProps {
token: string;
onUnauthorized: (message?: string) => void;
}
type SortDirection = 'asc' | 'desc';
export function AdminDatabaseTablesPage({
token,
onUnauthorized,
}: AdminDatabaseTablesPageProps) {
const [tables, setTables] = useState<string[]>([]);
const [tableName, setTableName] = useState(() => readHashTableName());
const [search, setSearch] = useState('');
const [filters, setFilters] = useState('');
const [limit, setLimit] = useState('100');
const [result, setResult] = useState<AdminDatabaseTableRowsResponse | null>(null);
const [detailRow, setDetailRow] = useState<AdminDatabaseTableRowPayload | null>(null);
const [errorMessage, setErrorMessage] = useState('');
const [copyMessage, setCopyMessage] = useState('');
const [sortColumn, setSortColumn] = useState('');
const [sortDirection, setSortDirection] = useState<SortDirection>('asc');
const [isLoadingTables, setIsLoadingTables] = useState(false);
const [isLoadingRows, setIsLoadingRows] = useState(false);
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);
void refreshRows(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) {
void refreshRows(tableName);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [tableName]);
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 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 sortedRows = useMemo(() => {
const rows = result?.rows ?? [];
if (!sortColumn || !visibleColumns.includes(sortColumn)) {
return rows;
}
return [...rows]
.map((row, index) => ({index, row}))
.sort((left, right) => {
const comparison = compareTableCellValues(
left.row.cells[sortColumn],
right.row.cells[sortColumn],
sortDirection,
);
if (comparison !== 0) {
return comparison;
}
return left.index - right.index;
})
.map(({row}) => row);
}, [result, sortColumn, sortDirection, visibleColumns]);
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;
} = {},
) {
const normalizedTableName = nextTableName.trim();
if (!normalizedTableName) {
return;
}
const querySearch = options.search ?? search;
const queryFilters = options.filters ?? filters;
const queryLimit = options.limit ?? limit;
setIsLoadingRows(true);
setErrorMessage('');
try {
const response = await getAdminDatabaseTableRows(token, normalizedTableName, {
search: querySearch,
filters: queryFilters,
limit: parseLimit(queryLimit),
});
setResult(response);
setCopyMessage('');
} catch (error: unknown) {
handlePageError(error, onUnauthorized, setErrorMessage);
} finally {
setIsLoadingRows(false);
}
}
function handleSearch(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
void refreshRows();
}
function handleTableChange(nextTableName: string) {
setTableName(nextTableName);
const nextHash = `#tables?table=${encodeURIComponent(nextTableName)}`;
if (window.location.hash !== nextHash) {
window.location.hash = nextHash;
}
}
function handleResetQuery() {
setSearch('');
setFilters('');
setLimit('100');
void refreshRows(tableName, {search: '', filters: '', limit: '100'});
}
function handleSortColumn(column: string) {
if (sortColumn === column) {
setSortDirection((currentDirection) =>
currentDirection === 'asc' ? 'desc' : 'asc',
);
return;
}
setSortColumn(column);
setSortDirection('asc');
}
async function handleCopyDetailJson() {
if (!detailRow) {
return;
}
const copiedText = JSON.stringify(detailRow.raw ?? detailRow.cells, null, 2);
try {
await navigator.clipboard.writeText(copiedText);
setCopyMessage('已复制 JSON');
} catch {
setCopyMessage('复制失败,请手动选中后复制');
}
}
return (
<section className="admin-page admin-page-wide">
<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={() => void refreshRows()}
>
<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">
<span>筛选 JSON</span>
<input
placeholder='{"user_id":"u1"}'
value={filters}
onChange={(event) => setFilters(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>
<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>
</div>
</div>
</form>
{errorMessage ? (
<div className="admin-alert" role="status">
{errorMessage}
</div>
) : null}
<section className="admin-panel">
<div className="admin-panel-heading">
<h3 title={resultTableHeader.description}>{resultTableHeader.label}</h3>
<span>{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'
: undefined
}
>
<button
className="admin-table-sort-button"
data-active={isSorted ? 'true' : 'false'}
title={description}
type="button"
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>
</th>
);
})}
<th>详情</th>
</tr>
</thead>
<tbody>
{sortedRows.length ? (
sortedRows.map((row, rowIndex) => (
<tr
key={buildRowKey(row, rowIndex)}
data-clickable="true"
onClick={() => setDetailRow(row)}
>
{visibleColumns.map((column) => {
const cellValue = formatCellValue(row.cells[column]);
return (
<td key={column}>
<span
className="admin-table-cell-ellipsis"
title={cellValue.fullText}
>
{cellValue.content}
</span>
</td>
);
})}
<td>
<button
className="admin-secondary-button"
type="button"
onClick={(event) => {
event.stopPropagation();
setDetailRow(row);
}}
>
<Eye size={16} aria-hidden="true" />
<span>详情</span>
</button>
</td>
</tr>
))
) : (
<tr>
<td colSpan={Math.max(visibleColumns.length + 1, 1)}>暂无数据</td>
</tr>
)}
</tbody>
</table>
</div>
</section>
{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-secondary-button"
type="button"
onClick={() => void handleCopyDetailJson()}
>
<span>复制 JSON</span>
</button>
<button
className="admin-ghost-button"
title="关闭"
type="button"
onClick={() => setDetailRow(null)}
>
<X size={17} aria-hidden="true" />
</button>
</div>
</div>
{copyMessage ? <div className="admin-status admin-status-ok">{copyMessage}</div> : null}
<pre className="admin-code-block">
{JSON.stringify(detailRow.raw ?? detailRow.cells, null, 2)}
</pre>
</section>
</div>
) : null}
</section>
);
}
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;
}
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 compareTableCellValues(
leftValue: unknown,
rightValue: unknown,
sortDirection: SortDirection,
) {
const direction = sortDirection === 'asc' ? 1 : -1;
const left = normalizeTableCellSortValue(leftValue);
const right = normalizeTableCellSortValue(rightValue);
if (left.kind === 'empty' && right.kind === 'empty') {
return 0;
}
if (left.kind === 'empty') {
return 1;
}
if (right.kind === 'empty') {
return -1;
}
if (left.kind !== right.kind) {
return direction * (getSortKindOrder(left.kind) - getSortKindOrder(right.kind));
}
let comparison = 0;
switch (left.kind) {
case 'number':
comparison = left.value - getSortableNumberValue(right);
break;
case 'boolean':
comparison = Number(left.value) - Number(getSortableBooleanValue(right));
break;
case 'text':
comparison = tableSortCollator.compare(left.value, getSortableTextValue(right));
break;
}
return direction * comparison;
}
function normalizeTableCellSortValue(value: unknown): SortableTableCellValue {
if (value === null || typeof value === 'undefined' || value === '') {
return {kind: 'empty'};
}
if (typeof value === 'number' && Number.isFinite(value)) {
return {kind: 'number', value};
}
if (typeof value === 'boolean') {
return {kind: 'boolean', value};
}
if (typeof value === 'string') {
const trimmed = value.trim();
if (!trimmed) {
return {kind: 'empty'};
}
return {kind: 'text', value: trimmed};
}
return {kind: 'text', value: stringifyUnknownValue(value)};
}
function buildRowKey(row: AdminDatabaseTableRowPayload, rowIndex: number) {
const firstValue = Object.values(row.cells)[0];
return `${rowIndex}-${String(firstValue ?? '')}`;
}
function formatCellValue(value: unknown): 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);
return {content: text, fullText: text};
}
return {
content: stringifyUnknownValue(value),
fullText: stringifyPrettyUnknownValue(value),
};
}
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 getSortKindOrder(kind: SortableTableCellValue['kind']): number {
switch (kind) {
case 'number':
return 0;
case 'boolean':
return 1;
case 'text':
return 2;
case 'empty':
return 3;
default:
return 3;
}
}
type SortableTableCellValue =
| {kind: 'empty'}
| {kind: 'number'; value: number}
| {kind: 'boolean'; value: boolean}
| {kind: 'text'; value: string};
interface DatabaseTableHeader {
name: string;
label: string;
optionLabel: string;
description: string;
}
interface FormattedTableCellValue {
content: string;
fullText: string;
}
const tableSortCollator = new Intl.Collator('zh-CN', {
numeric: true,
sensitivity: 'base',
});
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: '事件名称',
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: '更新人',
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: '埋点事件展示名称',
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: '最后更新该记录的主体',
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_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_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 任务事件表',
};
function getSortableNumberValue(value: SortableTableCellValue) {
return isSortableNumberValue(value) ? value.value : 0;
}
function getSortableBooleanValue(value: SortableTableCellValue) {
return isSortableBooleanValue(value) ? value.value : false;
}
function getSortableTextValue(value: SortableTableCellValue) {
return isSortableTextValue(value) ? value.value : stringifyUnknownValue(value);
}
function isSortableNumberValue(
value: SortableTableCellValue,
): value is Extract<SortableTableCellValue, {kind: 'number'}> {
return value.kind === 'number';
}
function isSortableBooleanValue(
value: SortableTableCellValue,
): value is Extract<SortableTableCellValue, {kind: 'boolean'}> {
return value.kind === 'boolean';
}
function isSortableTextValue(
value: SortableTableCellValue,
): value is Extract<SortableTableCellValue, {kind: 'text'}> {
return value.kind === 'text';
}