diff --git a/frontend/src/pages/catalog/CatalogPage.tsx b/frontend/src/pages/catalog/CatalogPage.tsx index 1df74651..ca5393d2 100644 --- a/frontend/src/pages/catalog/CatalogPage.tsx +++ b/frontend/src/pages/catalog/CatalogPage.tsx @@ -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() { } title={CT.title(source?.name ?? sourceId)} - subtitle={CT.subtitle} + // 窄屏放不下说明:硬塞进去会被挤成一个字的宽度,标题后面露出说明的头一个字(「先」)。窄屏只留标题 + subtitle={wideHeader ? CT.subtitle : undefined} actions={(
{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 或缩小范围后重新探查 diff --git a/frontend/src/pages/catalog/ColumnTable.tsx b/frontend/src/pages/catalog/ColumnTable.tsx index d07ef1f9..26f49b9c 100644 --- a/frontend/src/pages/catalog/ColumnTable.tsx +++ b/frontend/src/pages/catalog/ColumnTable.tsx @@ -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 ( -
- - +
+
+ @@ -67,7 +82,7 @@ export function ColumnTable({ columns, form, initial, problems, busy, systemNote - + {columns.map((c) => ( -
+ // 窄框下没填的项不占行(编辑时照常列出来好填) +
- +
{CT.columnHead.name} / {CT.columnHead.type} {CT.columnHead.label}{CT.confirmColumn}
+ {CT.columnHead[f]} +
{editing ? input : view}
{it && !changed && ( @@ -140,8 +157,8 @@ const ColumnRow = memo(function ColumnRow({ col, editing, draft, initial, proble const measure = items?.measure const codes = items?.codes return ( -
+
{name} {structure?.pk && {CT.pk}} @@ -193,7 +210,8 @@ const ColumnRow = memo(function ColumnRow({ col, editing, draft, initial, proble , )} -
+ {!editing && pending > 0 && ( } disabled={busy} onClick={() => onConfirmColumn(name)} data-confirm-column={name} /> diff --git a/frontend/src/pages/catalog/TableDetail.tsx b/frontend/src/pages/catalog/TableDetail.tsx index e4ac8c95..3b67a5be 100644 --- a/frontend/src/pages/catalog/TableDetail.tsx +++ b/frontend/src/pages/catalog/TableDetail.tsx @@ -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

{CT.noRelations}

+ // 窄框(< 40rem)下和列表格一样排成卡片:状态在第一行,下面每项一行「字段名 值」,不用横向滚动 + const cell = clsx('px-2 py-1.5 align-top', NARROW.cell) + const label = (text: string) => {text} return ( -
- - +
+
+ @@ -723,15 +726,20 @@ function RelationList({ relations, busy, targetOf, onOpen, onReview }: { - + {relations.map((r) => { const target = targetOf(r.to_table) const where = CT.whereRelation(r.to_table) const rejected = r.status === 'rejected' return ( - - - + + - - - + + - diff --git a/scripts/check-catalog.mjs b/scripts/check-catalog.mjs index d86101b3..1522b799 100644 --- a/scripts/check-catalog.mjs +++ b/scripts/check-catalog.mjs @@ -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() })
{CT.relationHead.from} {CT.relationHead.to}{CT.relationHead.source} / {CT.relationHead.status}
{r.columns.join(', ')} +
+ {label(CT.relationHead.from)} + {r.columns.join(', ')} + + {label(CT.relationHead.to)} + {target ? ( {r.to_columns.join(', ')}{r.cardinality ? CATALOG_CARDINALITY_LABEL[r.cardinality] : {CT.cardinalityNone}} + + {label(CT.relationHead.toColumns)} + {r.to_columns.join(', ')} + + {label(CT.relationHead.cardinality)} + {r.cardinality ? CATALOG_CARDINALITY_LABEL[r.cardinality] : {CT.cardinalityNone}} + + {label(CT.relationHead.coverage)} {typeof r.coverage === 'number' ? {coverageText(r.coverage)} : —} + onReview({ path: relationPath(r.id), where, source: r.source, status: r.status }, a)} />