diff --git a/apps/admin-web/src/pages/AdminDatabaseTablesPage.test.tsx b/apps/admin-web/src/pages/AdminDatabaseTablesPage.test.tsx new file mode 100644 index 00000000..645127aa --- /dev/null +++ b/apps/admin-web/src/pages/AdminDatabaseTablesPage.test.tsx @@ -0,0 +1,107 @@ +/* @vitest-environment jsdom */ + +import {render, screen, waitFor} from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import {beforeEach, expect, test, vi} from 'vitest'; + +import { + getAdminDatabaseTableRows, + getAdminDatabaseTables, +} from '../api/adminApiClient'; +import {AdminDatabaseTablesPage} from './AdminDatabaseTablesPage'; + +vi.mock('../api/adminApiClient', () => ({ + formatAdminApiError: vi.fn((error: unknown) => + error instanceof Error ? error.message : '请求失败', + ), + getAdminDatabaseTableRows: vi.fn(), + getAdminDatabaseTables: vi.fn(), + isAdminApiError: vi.fn(() => false), +})); + +beforeEach(() => { + window.location.hash = '#tables?table=profile_referral_relation'; + vi.mocked(getAdminDatabaseTables).mockResolvedValue({ + fetchErrors: [], + tables: ['profile_referral_relation'], + }); + vi.mocked(getAdminDatabaseTableRows).mockResolvedValue({ + columns: ['invitee_user_id', 'inviter_user_id', 'invite_code', 'bound_at'], + limit: 100, + rows: [ + { + cells: { + bound_at: '2026-05-02T00:00:00Z', + invitee_user_id: 'u-b', + invite_code: 'INV-1001', + inviter_user_id: 'u-a', + }, + raw: [ + 'u-b', + 'u-a', + 'INV-1001', + '2026-05-02T00:00:00Z', + ], + }, + { + cells: { + bound_at: '2026-05-01T00:00:00Z', + invitee_user_id: 'u-a', + invite_code: 'INV-1002', + inviter_user_id: 'u-c', + }, + raw: ['u-a', 'u-c', 'INV-1002', '2026-05-01T00:00:00Z'], + }, + { + cells: { + bound_at: '2026-05-03T00:00:00Z', + invitee_user_id: 'u-c', + invite_code: 'INV-1003', + inviter_user_id: 'u-a', + }, + raw: ['u-c', 'u-a', 'INV-1003', '2026-05-03T00:00:00Z'], + }, + ], + tableName: 'profile_referral_relation', + totalReturned: 3, + }); +}); + +test('后台表查询页支持宽表滚动容器和表头排序', async () => { + const user = userEvent.setup(); + const {container} = render( + , + ); + + await screen.findByText('u-b'); + + const tableWrap = container.querySelector('.admin-table-wrap'); + expect(tableWrap?.querySelector('.admin-database-table')).not.toBeNull(); + expect(screen.getByRole('option', {name: '邀请关系(profile_referral_relation)'}).getAttribute('title')).toBe( + '原始表名:profile_referral_relation。邀请关系记录表。', + ); + expect(screen.getByText('已选表:邀请关系(profile_referral_relation)')).toBeTruthy(); + expect(screen.getByRole('heading', {name: '邀请关系'}).getAttribute('title')).toBe( + '原始表名:profile_referral_relation。邀请关系记录表。', + ); + expect(screen.getByRole('button', {name: '被邀请人ID'}).getAttribute('title')).toBe( + '原始字段名:invitee_user_id。被邀请人的用户标识。点击可按此列排序。', + ); + expect(readFirstColumnValues(container)).toEqual(['u-b', 'u-a', 'u-c']); + + await user.click(screen.getByRole('button', {name: '邀请人ID'})); + await waitFor(() => { + expect(readFirstColumnValues(container)).toEqual(['u-b', 'u-c', 'u-a']); + }); + + await user.click(screen.getByRole('button', {name: '邀请人ID'})); + await waitFor(() => { + expect(readFirstColumnValues(container)).toEqual(['u-a', 'u-b', 'u-c']); + }); +}); + +function readFirstColumnValues(container: HTMLElement) { + return Array.from(container.querySelectorAll('tbody tr')).map( + (row) => row.querySelector('td')?.textContent?.trim() ?? '', + ); +} diff --git a/apps/admin-web/src/pages/AdminDatabaseTablesPage.tsx b/apps/admin-web/src/pages/AdminDatabaseTablesPage.tsx index 62e5eacc..781c2d23 100644 --- a/apps/admin-web/src/pages/AdminDatabaseTablesPage.tsx +++ b/apps/admin-web/src/pages/AdminDatabaseTablesPage.tsx @@ -1,4 +1,12 @@ -import {Eye, RefreshCcw, Search, X} from 'lucide-react'; +import { + ArrowDown, + ArrowUp, + ArrowUpDown, + Eye, + RefreshCcw, + Search, + X, +} from 'lucide-react'; import {FormEvent, useEffect, useMemo, useState} from 'react'; import { @@ -16,6 +24,8 @@ interface AdminDatabaseTablesPageProps { onUnauthorized: (message?: string) => void; } +type SortDirection = 'asc' | 'desc'; + export function AdminDatabaseTablesPage({ token, onUnauthorized, @@ -29,6 +39,8 @@ export function AdminDatabaseTablesPage({ const [detailRow, setDetailRow] = useState(null); const [errorMessage, setErrorMessage] = useState(''); const [copyMessage, setCopyMessage] = useState(''); + const [sortColumn, setSortColumn] = useState(''); + const [sortDirection, setSortDirection] = useState('asc'); const [isLoadingTables, setIsLoadingTables] = useState(false); const [isLoadingRows, setIsLoadingRows] = useState(false); @@ -76,6 +88,52 @@ export function AdminDatabaseTablesPage({ 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(''); @@ -144,6 +202,18 @@ export function AdminDatabaseTablesPage({ 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; @@ -195,12 +265,9 @@ export function AdminDatabaseTablesPage({ value={tableName} onChange={(event) => handleTableChange(event.target.value)} > - {tableName && !tables.includes(tableName) ? ( - {tableName} - ) : null} - {tables.map((name) => ( - - {name} + {tableOptions.map(({name, optionLabel, description}) => ( + + {optionLabel} ))} @@ -244,7 +311,9 @@ export function AdminDatabaseTablesPage({ 重置条件 - 已选表:{tableName || '-'} + + 已选表:{selectedTableHeader.optionLabel} + 显示列:{visibleColumns.length} @@ -258,30 +327,71 @@ export function AdminDatabaseTablesPage({ - {result?.tableName || tableName || '数据行'} + {resultTableHeader.label} {result?.totalReturned ?? 0} 条 - + - {visibleColumns.map((column) => ( - {column} - ))} + {columnHeaders.map(({column, label, description}) => { + const isSorted = sortColumn === column; + return ( + + handleSortColumn(column)} + > + {label} + {isSorted ? ( + sortDirection === 'asc' ? ( + + ) : ( + + ) + ) : ( + + )} + + + ); + })} 详情 - {result?.rows.length ? ( - result.rows.map((row, rowIndex) => ( + {sortedRows.length ? ( + sortedRows.map((row, rowIndex) => ( setDetailRow(row)} > - {visibleColumns.map((column) => ( - {formatCellValue(row.cells[column])} - ))} + {visibleColumns.map((column) => { + const cellValue = formatCellValue(row.cells[column]); + return ( + + + {cellValue.content} + + + ); + })} {JSON.stringify(value, null, 2)}; +} + +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 = { + 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 = { + 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 = { + 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 = { + 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 = { + 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 { + return value.kind === 'number'; +} + +function isSortableBooleanValue( + value: SortableTableCellValue, +): value is Extract { + return value.kind === 'boolean'; +} + +function isSortableTextValue( + value: SortableTableCellValue, +): value is Extract { + return value.kind === 'text'; } diff --git a/apps/admin-web/src/styles/admin.css b/apps/admin-web/src/styles/admin.css index 6152ff73..24096c02 100644 --- a/apps/admin-web/src/styles/admin.css +++ b/apps/admin-web/src/styles/admin.css @@ -350,11 +350,6 @@ button:disabled { font-weight: 650; } -.admin-query-reset-button { - width: auto; - padding: 0 12px; -} - .admin-field { display: grid; min-width: 0; @@ -603,6 +598,13 @@ button:disabled { background: #eef3f6; } +.admin-ghost-button.admin-query-reset-button { + width: auto; + min-width: 76px; + height: 40px; + padding: 0 12px; +} + .admin-text-button { display: inline; border: 0; @@ -650,7 +652,10 @@ button:disabled { } .admin-table-wrap { + max-width: 100%; overflow: auto; + scrollbar-gutter: stable; + -webkit-overflow-scrolling: touch; } .admin-table { @@ -687,6 +692,65 @@ button:disabled { min-width: 1180px; } +.admin-database-table { + width: max-content; + min-width: 100%; + table-layout: fixed; +} + +.admin-database-table th, +.admin-database-table td { + width: 220px; + min-width: 220px; + max-width: 220px; +} + +.admin-database-table th:last-child, +.admin-database-table td:last-child { + width: 112px; + min-width: 112px; + max-width: 112px; +} + +.admin-table-sort-button { + display: inline-flex; + align-items: center; + gap: 6px; + max-width: 100%; + border: 0; + color: #667682; + background: transparent; + padding: 0; + text-align: left; + font-size: 12px; + font-weight: 800; +} + +.admin-table-sort-button span { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; +} + +.admin-table-sort-button svg { + flex: 0 0 auto; +} + +.admin-table-cell-ellipsis { + display: block; + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.admin-table-sort-button:hover, +.admin-table-sort-button:focus-visible, +.admin-table-sort-button[data-active="true"] { + color: #0f5666; + outline: none; +} + .admin-json-preview { max-width: 360px; max-height: 160px; diff --git a/docs/technical/ADMIN_DATABASE_TABLE_QUERY_2026-05-08.md b/docs/technical/ADMIN_DATABASE_TABLE_QUERY_2026-05-08.md index 3526ad9d..8ed5c4af 100644 --- a/docs/technical/ADMIN_DATABASE_TABLE_QUERY_2026-05-08.md +++ b/docs/technical/ADMIN_DATABASE_TABLE_QUERY_2026-05-08.md @@ -78,9 +78,13 @@ Query: 页面能力: -- 表选择下拉,支持 URL hash `#tables?table=xxx` 直达指定表。 +- 表选择下拉展示中文表名并保留原始表名,支持 URL hash `#tables?table=xxx` 直达指定表。 - 查询表单:表名、关键词、JSON 条件、条数。 - 查询结果表格横向滚动,移动端不撑坏布局。 +- 查询结果标题和已选表摘要展示中文表名,鼠标悬浮显示原始表名和表说明,方便运营识别真实数据域。 +- 表头支持点击排序,排序只作用于当前已拉取的行数据,不改变后端 SQL。 +- 表头展示中文字段名,鼠标悬浮显示原始字段名、字段说明和排序提示,方便运营阅读且保留排障所需的真实列名。 +- 单元格内容过长时在表格内单行省略,完整内容可通过悬浮标题或行详情弹层查看。 - 每行提供“详情”按钮,以独立弹层展示完整 JSON。 - 总览表统计行点击后跳转到 `#tables?table={tableName}`。 diff --git a/vitest.config.ts b/vitest.config.ts index e334d9c9..9a44f493 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -7,6 +7,8 @@ export default defineConfig({ include: [ 'src/**/*.test.ts', 'src/**/*.test.tsx', + 'apps/admin-web/src/**/*.test.ts', + 'apps/admin-web/src/**/*.test.tsx', 'scripts/**/*.test.ts', 'packages/shared/src/**/*.test.ts', ],