数据目录 F2b / B13:清单的搜索词、筛选、排序和「只看运行中查询过的表」写进地址的查询参数(q / filter / sort / used),刷新、前进后退都不丢,打开表和回到清单时带着走;点了立刻生效,搜索词停手 200ms 再写地址;补检查脚本的断言

This commit is contained in:
ThearchyHelios
2026-10-04 01:59:25 +08:00
parent 48ac7dbc92
commit fe4f551d71
3 changed files with 103 additions and 15 deletions
+48 -15
View File
@@ -1,6 +1,6 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import type { ReactNode } from 'react'
import { useNavigate, useParams } from 'react-router-dom'
import { useNavigate, useParams, useSearchParams } from 'react-router-dom'
import { ArrowLeft, BookMarked, Database, ListFilter as ListFilterIcon, ScanSearch, Sparkles } from 'lucide-react'
import clsx from 'clsx'
import { ApiError, api } from '../../api/client'
@@ -16,8 +16,8 @@ import { ProfileSettingsDialog } from './ProfileSettings'
import { profileBlockOf, profileSettingsOf, secondsText } from './profile'
import { TableDetail } from './TableDetail'
import { TableIndex } from './TableIndex'
import { filterCounts, progressOf, rowFromDetail, usageSummary, visibleRows } from './model'
import type { ListFilter, ListSort } from './model'
import { filterCounts, listParamsOf, progressOf, rowFromDetail, usageSummary, visibleRows, writeListParams } from './model'
import type { ListFilter, ListParams, ListSort } from './model'
// ===========================================================================
// 数据目录页(/data/catalog/:sourceId,选中一张表时 /data/catalog/:sourceId/:table)。
@@ -27,6 +27,9 @@ import type { ListFilter, ListSort } from './model'
// 位置;编辑到一半换表、离开页面,走全站统一的离开前确认。
//
// 宽屏左右两栏;窄屏(< lg)一次只显示一栏:没选表时是清单,选了表是详情(详情里有「返回表清单」)。
//
// 清单的搜索词、筛选、排序和「只看运行中查询过的表」记在地址的查询参数里(q / filter / sort / used),刷新、前进后退
// 都不丢;在清单和表详情之间跳转时带着它们走。
// ===========================================================================
export function CatalogPage() {
@@ -62,11 +65,39 @@ export function CatalogPage() {
}, [load])
useOnReconnect(() => { void load() })
const [query, setQuery] = useState('')
const [filter, setFilter] = useState<ListFilter>('all')
const [sort, setSort] = useState<ListSort>('usage')
// 清单的状态以页面上的为准(点了立刻生效),再写进地址:搜索词停手 200ms 再写,其余马上写,都替换当前这条历史
// (筛选不该占满后退键)。地址被别处改了(前进后退、点了带参数的链接)时按地址重来
const [params, setParams] = useSearchParams()
const fromUrl = useMemo(() => listParamsOf(params), [params])
const urlSig = useMemo(() => writeListParams(new URLSearchParams(), fromUrl).toString(), [fromUrl])
const [list, setList] = useState<ListParams>(fromUrl)
const wrote = useRef(urlSig)
useEffect(() => {
if (urlSig === wrote.current) return
wrote.current = urlSig
setList(fromUrl)
}, [urlSig, fromUrl])
const listSig = useMemo(() => writeListParams(new URLSearchParams(), list).toString(), [list])
const typedAt = useRef(0)
useEffect(() => {
if (listSig === wrote.current) return
const t = setTimeout(() => {
wrote.current = listSig
setParams((prev) => writeListParams(prev, list), { replace: true })
}, Date.now() - typedAt.current < 200 ? 200 : 0)
return () => clearTimeout(t)
}, [listSig, list, setParams])
const { query, filter, sort } = list
/** 只看运行中查询过的表:从顶部摘要点进来时打开,筛选结果和摘要的数字一致 */
const [usedOnly, setUsedOnly] = useState(false)
const usedOnly = list.used
const setQuery = useCallback((q: string) => { typedAt.current = Date.now(); setList((c) => ({ ...c, query: q })) }, [])
const setFilter = useCallback((f: ListFilter) => setList((c) => ({ ...c, filter: f })), [])
const setSort = useCallback((v: ListSort) => setList((c) => ({ ...c, sort: v })), [])
const setUsedOnly = useCallback((v: boolean) => setList((c) => ({ ...c, used: v })), [])
/** 跳到别的表、回到清单时带上清单的查询参数(按页面上的算:搜索词可能还没写进地址) */
const searchRef = useRef('')
const search = writeListParams(params, list).toString()
searchRef.current = search ? `?${search}` : ''
const [selected, setSelected] = useState<Set<string>>(() => new Set())
const [drafting, setDrafting] = useState(false)
/** 起草写进了这些表:详情据此重新载入(编辑中的不动,保存时服务端会用版本号拦下) */
@@ -94,7 +125,8 @@ export function CatalogPage() {
const visible = useMemo(() => visibleRows(rows, query, filter, sort, usedOnly), [rows, query, filter, sort, usedOnly])
const counts = useMemo(() => filterCounts(rows, query, usedOnly), [rows, query, usedOnly])
const base = `/data/catalog/${encodeURIComponent(sourceId)}`
const openTable = useCallback((t: string) => navigate(`${base}/${encodeURIComponent(t)}`), [navigate, base])
const openTable = useCallback((t: string) => navigate(`${base}/${encodeURIComponent(t)}${searchRef.current}`), [navigate, base])
const backToList = useCallback(() => navigate(`${base}${searchRef.current}`), [navigate, base])
const back = source?.origin === 'upload' ? '/data/tables' : '/data/databases'
/** 写入、审阅之后按返回的单表目录更新清单里那一行 */
@@ -198,12 +230,13 @@ export function CatalogPage() {
* 正在看表详情时回到清单才看得到筛选结果;宽屏两栏都在,正在看的表不动
*/
const focusUsed = useCallback((f: ListFilter) => {
setQuery('')
setFilter(f)
setUsedOnly(true)
setSort('usage')
if (table && typeof matchMedia === 'function' && matchMedia('(max-width: 1023px)').matches) navigate(base)
}, [table, navigate, base])
const next: ListParams = { query: '', filter: f, used: true, sort: 'usage' }
setList(next)
if (table && typeof matchMedia === 'function' && matchMedia('(max-width: 1023px)').matches) {
const q = writeListParams(params, next).toString()
navigate(`${base}${q ? `?${q}` : ''}`)
}
}, [table, navigate, base, params])
const filtered = !!query.trim() || filter !== 'all' || usedOnly
const empty = rows.length > 0 && rows.every((r) => progressOf(r.counts) === 'none')
@@ -290,7 +323,7 @@ export function CatalogPage() {
{table
? (
<TableDetail sourceId={sourceId} table={table} rows={rows} drafted={drafted} fillCodes={fillCodes} prev={prev} next={next}
onOpen={openTable} onBack={() => navigate(base)} onDetail={onDetail} />
onOpen={openTable} onBack={backToList} onDetail={onDetail} />
)
: <Overview rows={rows} empty={empty} systemNotes={data.system_notes} draft={draftButton(true)} onOpen={openTable} />}
</section>
+28
View File
@@ -155,6 +155,34 @@ export function filterCounts(rows: CatalogTableRow[], q: string, used = false):
return out
}
/**
* 清单的筛选、搜索、排序记在地址栏里(刷新、前进后退、把链接发给同事都不丢):q 搜索词,filter 筛选,sort 排序,
* used=1 只看运行中查询过的表。默认值不写进地址;认不出的值按默认
*/
export interface ListParams { query: string; filter: ListFilter; sort: ListSort; used: boolean }
export function listParamsOf(p: URLSearchParams): ListParams {
const filter = p.get('filter') as ListFilter | null
const sort = p.get('sort') as ListSort | null
return {
query: p.get('q') ?? '',
filter: filter && LIST_FILTERS.includes(filter) ? filter : 'all',
sort: sort && LIST_SORTS.includes(sort) ? sort : 'usage',
used: p.get('used') === '1',
}
}
/** 把清单的状态写回地址的查询参数:只动这几个键,别的参数原样留着 */
export function writeListParams(p: URLSearchParams, s: ListParams): URLSearchParams {
const next = new URLSearchParams(p)
const put = (k: string, v: string | null) => (v ? next.set(k, v) : next.delete(k))
put('q', s.query.trim() ? s.query : null)
put('filter', s.filter === 'all' ? null : s.filter)
put('sort', s.sort === 'usage' ? null : s.sort)
put('used', s.used ? '1' : null)
return next
}
// ---------------------------------------------------------------------------
// 批量确认:把选中的推断项改成已确认,整份提交(服务端把「值没动、状态改成确认」当作单项审阅,来源不变)
// ---------------------------------------------------------------------------
+27
View File
@@ -580,6 +580,33 @@ await section('数据目录 · 表清单的排序与筛选', async () => {
&& names.at(-1) === 'legacy_coupons', names.slice(0, 3).join(','))
await page.locator('[data-catalog-sort]').selectOption('usage')
// 筛选、搜索、排序写进地址栏:刷新不丢,打开一张表、再回到清单都带着
await page.locator('[data-filter="pending"]').click()
await page.locator('[data-catalog-sort]').selectOption('name')
await search.fill('order')
await until(async () => new URL(page.url()).searchParams.get('q') === 'order', 3000)
const listUrl = new URL(page.url())
check('筛选、搜索、排序写进地址栏的查询参数', listUrl.searchParams.get('filter') === 'pending' && listUrl.searchParams.get('sort') === 'name'
&& listUrl.searchParams.get('q') === 'order', listUrl.search)
await page.reload({ waitUntil: 'networkidle' })
await page.locator('[data-catalog-row]').first().waitFor()
names = await rowNames(page)
check('刷新之后筛选、搜索、排序都还在', (await page.locator('[data-catalog-filter]').getAttribute('data-catalog-filter')) === 'pending'
&& await page.locator('[data-catalog-sort]').inputValue() === 'name' && await search.inputValue() === 'order'
&& names.join(',') === 'order_items', `${names.join(',')} ${page.url()}`)
await page.locator('[data-catalog-row="order_items"] [data-catalog-open-row]').click()
await page.waitForURL(`**/data/catalog/${S1}/order_items?**`, { timeout: 5000 }).catch(() => {})
const tableUrl = new URL(page.url())
check('打开一张表:地址带着清单的查询参数,清单照旧筛着', tableUrl.pathname === `/data/catalog/${S1}/order_items`
&& tableUrl.search === listUrl.search && (await rowNames(page)).join(',') === 'order_items', page.url())
await page.goBack({ waitUntil: 'networkidle' })
check('后退回到清单:筛选照旧', new URL(page.url()).search === listUrl.search
&& (await page.locator('[data-catalog-filter]').getAttribute('data-catalog-filter')) === 'pending', page.url())
await page.locator('[data-filter="all"]').click()
await page.locator('[data-catalog-sort]').selectOption('usage')
await search.fill('')
check('回到默认(全部、按使用次数、不搜索):地址里不留这几个参数', await until(async () => new URL(page.url()).search === '', 3000), page.url())
// 键盘:↑↓ 在行之间移动,Enter 打开
await page.locator('[data-catalog-row="visits"] [data-catalog-open-row]').focus()
await page.keyboard.press('ArrowDown')