diff --git a/frontend/src/pages/catalog/CatalogPage.tsx b/frontend/src/pages/catalog/CatalogPage.tsx index 30a8035a..5b2ee0f6 100644 --- a/frontend/src/pages/catalog/CatalogPage.tsx +++ b/frontend/src/pages/catalog/CatalogPage.tsx @@ -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('all') - const [sort, setSort] = useState('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(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>(() => 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 ? ( navigate(base)} onDetail={onDetail} /> + onOpen={openTable} onBack={backToList} onDetail={onDetail} /> ) : } diff --git a/frontend/src/pages/catalog/model.ts b/frontend/src/pages/catalog/model.ts index 07d17a3c..ee1bf6ad 100644 --- a/frontend/src/pages/catalog/model.ts +++ b/frontend/src/pages/catalog/model.ts @@ -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 +} + // --------------------------------------------------------------------------- // 批量确认:把选中的推断项改成已确认,整份提交(服务端把「值没动、状态改成确认」当作单项审阅,来源不变) // --------------------------------------------------------------------------- diff --git a/scripts/check-catalog.mjs b/scripts/check-catalog.mjs index d6127f13..855062d9 100644 --- a/scripts/check-catalog.mjs +++ b/scripts/check-catalog.mjs @@ -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')