数据目录 F2b / D4:390 宽时目录页不再挤着横向滚动——列表格和关联关系在框宽不到 40rem 时排成卡片(容器查询):表头收起,每项一行「字段名 值 状态」,没填的项不占行,关系的状态放在第一行,状态标识始终在框里;页头在窄屏只留标题,不再被挤出说明的残字;宽屏照旧是表格;补检查脚本的断言

This commit is contained in:
ThearchyHelios
2026-10-04 02:42:46 +08:00
parent ac4ea030a8
commit 5590fa9cd7
4 changed files with 110 additions and 24 deletions
+17 -1
View File
@@ -243,6 +243,7 @@ export function CatalogPage() {
}, [table, visible, rows, sort])
const usage = useMemo(() => usageSummary(rows), [rows])
const wideHeader = useMedia('(min-width: 640px)')
/**
* 顶部摘要点进来:清掉搜索词,切到对应的筛选、只看运行中查询过的表(和摘要同一个口径:摘要说「1 张还没有目录」,
* 点进去就是那 1 张,不是全部没有目录的表),按使用次数排——用到最多的排在最前。窄屏一次只显示一栏,
@@ -355,7 +356,8 @@ export function CatalogPage() {
<PageHeader
icon={<BookMarked size={13} />}
title={CT.title(source?.name ?? sourceId)}
subtitle={CT.subtitle}
// 窄屏放不下说明:硬塞进去会被挤成一个字的宽度,标题后面露出说明的头一个字(「先」)。窄屏只留标题
subtitle={wideHeader ? CT.subtitle : undefined}
actions={(
<div className="flex shrink-0 items-center gap-1.5">
{hasTables && headerProfile}
@@ -410,6 +412,20 @@ export function CatalogPage() {
)
}
/** 媒体查询的当前结果,跟着窗口大小变 */
function useMedia(query: string): boolean {
const [on, setOn] = useState(() => typeof matchMedia !== 'function' || matchMedia(query).matches)
useEffect(() => {
if (typeof matchMedia !== 'function') return
const mq = matchMedia(query)
const sync = () => setOn(mq.matches)
sync()
mq.addEventListener('change', sync)
return () => mq.removeEventListener('change', sync)
}, [query])
return on
}
/**
* 表结构只探查了一部分(每个数据源最多取 200 张表):没探查到的表不在清单里,助手也看不到。顶部常驻一条说明,
* 写清一共几张、探查了几张、为什么只取了这些,以及下一步——回到数据源卡片换 schema 或缩小范围后重新探查
+27 -9
View File
@@ -18,8 +18,23 @@ import type { ColumnDraft, ColumnField } from './model'
// 单表详情的列表格:列名 / 类型、中文名、含义、单位、度量类型、码值。每格的值旁边是只有图标的状态标识,点开看来源、
// 做确认 / 驳回 / 恢复;有推断项的行末尾有「确认本列推断」。编辑时每格换成输入框。
// 一张表 50 列时每次按键都会重画整张表:行是 memo 的,只有改动的那一行重画。
//
// 窄(表格所在的框不到 40rem,比如 390 宽的手机)时不再是 860px 宽、要横向滚动的表格:每一列排成一张卡片,列名在上,
// 下面每项一行「字段名 值 状态」,没填的项不占行;状态标识跟着值走,始终在框里看得到。按框的宽度(容器查询)切换,
// 不按视口:宽屏左右两栏时右栏也可能很窄。
// ===========================================================================
/** 窄框下的卡片式排法(容器查询,框宽 < 40rem 时生效)。表格元素改成块,表头藏起来,每格前面补上字段名 */
export const NARROW = {
table: '@max-[40rem]:block @min-[40rem]:min-w-[860px] @min-[40rem]:table-fixed',
head: '@max-[40rem]:hidden',
body: '@max-[40rem]:block',
row: '@max-[40rem]:relative @max-[40rem]:flex @max-[40rem]:flex-col @max-[40rem]:py-1.5',
rowHead: '@max-[40rem]:block @max-[40rem]:pr-12',
cell: '@max-[40rem]:flex @max-[40rem]:items-start @max-[40rem]:gap-2 @max-[40rem]:px-3 @max-[40rem]:py-0.5',
label: 'hidden w-16 shrink-0 pt-0.5 text-2xs text-faint @max-[40rem]:block',
}
const MEASURES = Object.keys(CATALOG_MEASURE_LABEL) as CatalogMeasure[]
const CODES_SHOWN = 4
@@ -51,9 +66,9 @@ export function ColumnTable({ columns, form, initial, problems, busy, systemNote
onCodesComplete: (col: string, value: boolean) => void
}) {
return (
<div className="relative overflow-x-auto rounded-lg border" data-catalog-columns="">
<table className="w-full min-w-[860px] table-fixed border-collapse text-xs">
<thead>
<div className="@container relative overflow-x-auto rounded-lg border" data-catalog-columns="">
<table className={clsx('w-full border-collapse text-xs', NARROW.table)}>
<thead className={NARROW.head}>
<tr className="border-b bg-elev text-left text-2xs text-faint">
<th scope="col" className="w-[21%] px-3 py-2 font-medium">{CT.columnHead.name} / {CT.columnHead.type}</th>
<th scope="col" className="w-[14%] px-2 py-2 font-medium">{CT.columnHead.label}</th>
@@ -67,7 +82,7 @@ export function ColumnTable({ columns, form, initial, problems, busy, systemNote
<th scope="col" className="w-9 px-1 py-2"><span className="sr-only">{CT.confirmColumn}</span></th>
</tr>
</thead>
<tbody>
<tbody className={NARROW.body}>
{columns.map((c) => (
<ColumnRow key={c.name} col={c} editing={!!form} draft={form?.[c.name]} initial={initial?.[c.name]} problems={problems} busy={busy}
systemNotes={systemNotes} onReview={onReview} onConfirmColumn={onConfirmColumn} onFillCodes={onFillCodes} onChange={onChange}
@@ -104,8 +119,10 @@ const ColumnRow = memo(function ColumnRow({ col, editing, draft, initial, proble
const problem = problems[`c:${name}:${f}`]
const changed = editing && ((draft?.[f] ?? '') !== (initial?.[f] ?? '') || (f === 'codes' && completeOf(draft) !== completeOf(initial)))
return (
<td className="px-2 py-1.5 align-top" data-cell={f} data-status={it?.status}>
<div className="flex items-start gap-1.5">
// 窄框下没填的项不占行(编辑时照常列出来好填)
<td className={clsx('px-2 py-1.5 align-top', NARROW.cell, !it && !editing && '@max-[40rem]:hidden')} data-cell={f} data-status={it?.status}>
<span className={NARROW.label} aria-hidden>{CT.columnHead[f]}</span>
<div className="flex min-w-0 flex-1 items-start gap-1.5">
<div className="min-w-0 flex-1">{editing ? input : view}</div>
{it && !changed && (
<span className="pt-0.5">
@@ -140,8 +157,8 @@ const ColumnRow = memo(function ColumnRow({ col, editing, draft, initial, proble
const measure = items?.measure
const codes = items?.codes
return (
<tr className="border-b border-hairline last:border-b-0 hover:bg-hover/40" data-column={name} data-pending={pending}>
<th scope="row" className="px-3 py-1.5 text-left align-top font-normal">
<tr className={clsx('border-b border-hairline last:border-b-0 hover:bg-hover/40', NARROW.row)} data-column={name} data-pending={pending}>
<th scope="row" className={clsx('px-3 py-1.5 text-left align-top font-normal', NARROW.rowHead)}>
<div className="flex flex-wrap items-center gap-1">
<span className="mono text-xs font-medium text-fg [overflow-wrap:anywhere]">{name}</span>
{structure?.pk && <span className="chip !px-1.5 !py-0 !text-2xs">{CT.pk}</span>}
@@ -193,7 +210,8 @@ const ColumnRow = memo(function ColumnRow({ col, editing, draft, initial, proble
</label>
</>,
)}
<td className="px-1 py-1.5 align-top">
<td className={clsx('px-1 py-1.5 align-top', '@max-[40rem]:absolute @max-[40rem]:right-1.5 @max-[40rem]:top-1.5 @max-[40rem]:p-0',
(editing || !pending) && '@max-[40rem]:hidden')}>
{!editing && pending > 0 && (
<IconButton label={CT.confirmColumnLabel(name, pending)} icon={<CheckCheck size={13} />} disabled={busy}
onClick={() => onConfirmColumn(name)} data-confirm-column={name} />
+29 -13
View File
@@ -17,7 +17,7 @@ import {
import { CatalogImpactList } from '../../components/CatalogImpact'
import { CountBadges, ItemMark, StatusChip, StatusLegend, SystemNotesNotice } from './parts'
import type { ReviewTarget } from './parts'
import { ColumnTable, Value, pendingCodes } from './ColumnTable'
import { ColumnTable, NARROW, Value, pendingCodes } from './ColumnTable'
import type { ColumnModel } from './ColumnTable'
import { CodesDialog } from './CodesDialog'
import { coverageText } from './profile'
@@ -710,10 +710,13 @@ function RelationList({ relations, busy, targetOf, onOpen, onReview }: {
onReview: (t: ReviewTarget, a: CatalogReviewAction) => void
}) {
if (!relations.length) return <p className="rounded-lg border px-3 py-4 text-xs text-faint" data-relations-empty="">{CT.noRelations}</p>
// 窄框(< 40rem)下和列表格一样排成卡片:状态在第一行,下面每项一行「字段名 值」,不用横向滚动
const cell = clsx('px-2 py-1.5 align-top', NARROW.cell)
const label = (text: string) => <span className={NARROW.label} aria-hidden>{text}</span>
return (
<div className="relative overflow-x-auto rounded-lg border" data-catalog-relations="">
<table className="w-full min-w-[640px] border-collapse text-xs">
<thead>
<div className="@container relative overflow-x-auto rounded-lg border" data-catalog-relations="">
<table className="w-full border-collapse text-xs @max-[40rem]:block @min-[40rem]:min-w-[640px]">
<thead className={NARROW.head}>
<tr className="border-b bg-elev text-left text-2xs text-faint">
<th scope="col" className="px-3 py-2 font-medium">{CT.relationHead.from}</th>
<th scope="col" className="px-2 py-2 font-medium">{CT.relationHead.to}</th>
@@ -723,15 +726,20 @@ function RelationList({ relations, busy, targetOf, onOpen, onReview }: {
<th scope="col" className="px-2 py-2 font-medium">{CT.relationHead.source} / {CT.relationHead.status}</th>
</tr>
</thead>
<tbody>
<tbody className={NARROW.body}>
{relations.map((r) => {
const target = targetOf(r.to_table)
const where = CT.whereRelation(r.to_table)
const rejected = r.status === 'rejected'
return (
<tr key={r.id} className="border-b border-hairline last:border-b-0" data-relation={r.id} data-status={r.status}>
<td className={clsx('mono px-3 py-1.5 align-top', rejected && 'text-faint line-through')}>{r.columns.join(', ')}</td>
<td className="px-2 py-1.5 align-top">
<tr key={r.id} className={clsx('border-b border-hairline last:border-b-0', NARROW.row)} data-relation={r.id} data-status={r.status}>
<td className={clsx(cell, 'px-3')}>
{label(CT.relationHead.from)}
<span className={clsx('mono min-w-0 break-all', rejected && 'text-faint line-through')}>{r.columns.join(', ')}</span>
</td>
<td className={cell}>
{label(CT.relationHead.to)}
<span className="min-w-0">
{target
? (
<button type="button" className={clsx('mono text-left underline decoration-dotted underline-offset-2 hover:text-[var(--accent)]', rejected && 'text-faint line-through')}
@@ -740,16 +748,24 @@ function RelationList({ relations, busy, targetOf, onOpen, onReview }: {
</button>
)
: <span className={clsx('mono', rejected && 'text-faint line-through')}>{r.to_table}</span>}
{target?.label && <div className="text-2xs text-faint">{target.label}</div>}
{target?.label && <span className="block text-2xs text-faint">{target.label}</span>}
</span>
</td>
<td className={clsx('mono px-2 py-1.5 align-top', rejected && 'text-faint line-through')}>{r.to_columns.join(', ')}</td>
<td className="px-2 py-1.5 align-top">{r.cardinality ? CATALOG_CARDINALITY_LABEL[r.cardinality] : <span className="text-faint">{CT.cardinalityNone}</span>}</td>
<td className="tnum px-2 py-1.5 align-top">
<td className={cell}>
{label(CT.relationHead.toColumns)}
<span className={clsx('mono min-w-0 break-all', rejected && 'text-faint line-through')}>{r.to_columns.join(', ')}</span>
</td>
<td className={cell}>
{label(CT.relationHead.cardinality)}
{r.cardinality ? CATALOG_CARDINALITY_LABEL[r.cardinality] : <span className="text-faint">{CT.cardinalityNone}</span>}
</td>
<td className={clsx('tnum', cell, typeof r.coverage !== 'number' && '@max-[40rem]:hidden')}>
{label(CT.relationHead.coverage)}
{typeof r.coverage === 'number'
? <span data-relation-coverage={r.coverage}>{coverageText(r.coverage)}</span>
: <span className="text-faint">—</span>}
</td>
<td className="px-2 py-1.5 align-top">
<td className={clsx(cell, '@max-[40rem]:order-first @max-[40rem]:pb-1')} data-relation-status="">
<ItemMark target={{ path: relationPath(r.id), where, source: r.source, status: r.status, note: r.note, updated_at: r.updated_at }}
disabled={busy} onReview={(a) => onReview({ path: relationPath(r.id), where, source: r.source, status: r.status }, a)} />
</td>
+37 -1
View File
@@ -1777,11 +1777,40 @@ await section('数据目录 · 窄屏', async () => {
await page.locator('[data-catalog-row]').first().waitFor()
check('390 宽:只显示表清单', await page.locator('[data-catalog-list]').isVisible() && !(await page.locator('[data-catalog-overview]').isVisible()))
check('……清单没有横向滚动', await overflow() <= 1, String(await overflow()))
// 页头放不下说明:只留标题,不能被挤成一个字的宽度、在标题后面露出说明的头一个字
const headSub = await page.locator('[data-catalog-page] > header p').count()
check('……页头只有标题,不露说明的残字', headSub === 0 && !(await page.locator('[data-catalog-page] > header').innerText()).includes('先起草'),
await page.locator('[data-catalog-page] > header').innerText())
await page.locator('[data-catalog-row="visits"] [data-catalog-open-row]').click()
await until(async () => (await page.locator('[data-catalog-detail="visits"][data-version]').count()) === 1)
check('……打开一张表:只显示详情,有「返回表清单」', !(await page.locator('[data-catalog-list]').isVisible())
&& await page.locator('[data-catalog-detail-back]').isVisible())
check('……详情没有横向滚动(列表格在自己的框里滚)', await overflow() <= 1, String(await overflow()))
check('……详情没有横向滚动', await overflow() <= 1, String(await overflow()))
// 列表格、关联关系不再是 860px / 640px 宽、要横着滚的表格:排成卡片,状态标识始终在框里看得到
const fits = (sel) => page.evaluate((q) => {
const box = document.querySelector(q)
if (!box) return null
const r = box.getBoundingClientRect()
const marks = [...box.querySelectorAll('[data-item-mark]')].map((m) => m.getBoundingClientRect())
return { scroll: box.scrollWidth - box.clientWidth, width: Math.round(r.width), marks: marks.length,
inside: marks.every((m) => m.width > 0 && m.left >= r.left - 1 && m.right <= r.right + 1),
head: getComputedStyle(box.querySelector('thead')).display }
}, sel)
const cols = await fits('[data-catalog-columns]')
check('……列表格排成卡片:不横向滚动,表头收起,每个状态标识都在框里', !!cols && cols.scroll <= 1 && cols.head === 'none' && cols.marks > 0 && cols.inside,
JSON.stringify(cols))
const codesBox = await page.locator('[data-column="status"] [data-cell="codes"]').boundingBox()
check('……码值那一项在屏幕里,前面写着字段名', !!codesBox && codesBox.x >= 0 && codesBox.x + codesBox.width <= 390
&& (await page.locator('[data-column="status"] [data-cell="codes"]').innerText()).startsWith('码值'), JSON.stringify(codesBox))
await page.locator('[data-column="visitor_count"]').scrollIntoViewIfNeeded()
if (SHOTS) await sleep(200)
await shot(page, 'catalog-narrow-columns')
const rels = await fits('[data-catalog-relations]')
check('……关联关系也排成卡片:不横向滚动,三条关系的状态都看得到', !!rels && rels.scroll <= 1 && rels.head === 'none' && rels.marks === 3 && rels.inside,
JSON.stringify(rels))
await page.locator('[data-catalog-section="relations"]').scrollIntoViewIfNeeded()
if (SHOTS) await sleep(200)
await shot(page, 'catalog-narrow-relations')
await openMark(page, 'grain')
const box = await panel(page).boundingBox()
check('……状态弹层在视口里', !!box && box.x >= 0 && box.x + box.width <= 390, JSON.stringify(box))
@@ -1799,6 +1828,13 @@ await section('数据目录 · 窄屏', async () => {
const inner = await profileBox(page).evaluate((el) => el.scrollWidth - el.clientWidth)
check('……数据剖析弹窗在视口里,正文不横向滚动', !!dlg && dlg.x >= 0 && dlg.x + dlg.width <= 390 && inner <= 1, `${JSON.stringify(dlg)} ${inner}`)
await page.keyboard.press('Escape')
// 宽屏照旧是表格(表头在,列表格 860px 起,框里横向滚动)
await page.setViewportSize({ width: 1440, height: 900 })
await openDetail(page, S1, 'visits')
const wide = await page.locator('[data-catalog-columns]').evaluate((el) => ({ head: getComputedStyle(el.querySelector('thead')).display,
table: getComputedStyle(el.querySelector('table')).display, width: el.querySelector('table').getBoundingClientRect().width }))
check('宽屏照旧是表格', wide.head !== 'none' && wide.table === 'table' && wide.width >= 860, JSON.stringify(wide))
check('……页头写着说明', (await page.locator('[data-catalog-page] > header').innerText()).includes('先起草'))
check('没有运行时报错', errors.length === 0, errors[0] ?? '')
await close()
})