diff --git a/frontend/src/lib/sourceAnchor.ts b/frontend/src/lib/sourceAnchor.ts new file mode 100644 index 00000000..483d0c3e --- /dev/null +++ b/frontend/src/lib/sourceAnchor.ts @@ -0,0 +1,34 @@ +/** + * 指名数据源页上的一张卡片的地址:<数据源页>#source-,带 &maintenance 时顺带打开那张卡片的「定时维护」。 + * + * 全站只认这一种写法,拼、拆都在这里:目录页后台任务条上探查的「查看」、定时任务页上维护定时的「去数据源页设置」都链到这里。 + * 两边各写一遍的话改了一边另一边就落空——复核 W4R-04 就是这样:定时任务页链的是 ?source=,数据源页只认 #source-, + * 点了落在列表顶上,既不滚到那张卡片也不打开定时维护。 + * + * 用 #(不用查询串):页内的元素 id 就是 source-,浏览器自己也认这个锚点;数据源页的两个标签(数据库 / 表格)共用一个组件, + * 查询串留给页面别的状态。 + */ +export type SourceAnchorOpen = 'maintenance' + +const PREFIX = 'source-' +const OPENS: readonly SourceAnchorOpen[] = ['maintenance'] + +/** 页内卡片的元素 id */ +export const sourceCardId = (id: string) => `${PREFIX}${id}` + +/** 地址里的 # 部分(带 #):sourceAnchor(id) 只滚过去,sourceAnchor(id, 'maintenance') 还打开定时维护 */ +export const sourceAnchor = (id: string, open?: SourceAnchorOpen) => + `#${PREFIX}${encodeURIComponent(id)}${open ? `&${open}` : ''}` + +/** 拆开地址里的 # 部分;不是指名卡片的(没有 #、别的锚点、编码坏了)给 null。认不得的标记不管,只当滚过去 */ +export function parseSourceAnchor(hash: string): { id: string; open: SourceAnchorOpen | null } | null { + const [head, ...flags] = hash.replace(/^#/, '').split('&') + if (!head.startsWith(PREFIX) || head.length === PREFIX.length) return null + let id: string + try { + id = decodeURIComponent(head.slice(PREFIX.length)) + } catch { + return null + } + return { id, open: OPENS.find((o) => flags.includes(o)) ?? null } +} diff --git a/frontend/src/lib/termsSource.ts b/frontend/src/lib/termsSource.ts index 3e851b7f..cd04b605 100644 --- a/frontend/src/lib/termsSource.ts +++ b/frontend/src/lib/termsSource.ts @@ -166,6 +166,11 @@ export const SOURCE_BADGE_TEXT = { hint: '探查结构、取值观察测到的变化里要处理的条数,点开到数据目录的「变化」处理', } +/** 从别处链到数据源页上的一张卡片(lib/sourceAnchor 的 #source-),那个数据源已经不在了 */ +export const SOURCE_ANCHOR_TEXT = { + missing: '没找到这个数据源,可能已经删除', +} + /** 表详情的「结构与取值变化」一节 */ export const TABLE_CHANGES_TEXT = { title: '结构与取值变化', diff --git a/frontend/src/pages/DataSourcesTab.tsx b/frontend/src/pages/DataSourcesTab.tsx index 175d9fea..fdcd0bd5 100644 --- a/frontend/src/pages/DataSourcesTab.tsx +++ b/frontend/src/pages/DataSourcesTab.tsx @@ -1,5 +1,5 @@ import { useEffect, useId, useMemo, useRef, useState } from 'react' -import { useNavigate } from 'react-router-dom' +import { useLocation, useNavigate } from 'react-router-dom' import { AlertOctagon, AlertTriangle, BookMarked, CalendarClock, ChevronRight, Database, EyeOff, FileSpreadsheet, History, Info, KeyRound, Layers, Lock, Plug, Plus, RefreshCw, ScanSearch, Search, Table2, Upload, X, @@ -24,8 +24,10 @@ import type { HealthRecord } from '../lib/health' import { workflowList, workflowsMentioning } from '../lib/mentions' import { CATALOG_TEXT, INTROSPECT_SCOPE_TEXT, INTROSPECT_TEXT, MAINTENANCE_TEXT, MASK_BOUNDARY_TEXT, PROFILE_TEXT, RAW_STATE_LABEL, - RECIPE_ORIGIN_LABEL, RECIPE_TEXT, SOURCE_BADGE_TEXT, UPLOAD_CONVERSION_LABEL, UPLOAD_SHAPE_LABEL, UPLOAD_TEXT, VERSIONS_TEXT, + RECIPE_ORIGIN_LABEL, RECIPE_TEXT, SOURCE_ANCHOR_TEXT, SOURCE_BADGE_TEXT, UPLOAD_CONVERSION_LABEL, UPLOAD_SHAPE_LABEL, UPLOAD_TEXT, + VERSIONS_TEXT, } from '../lib/terms' +import { parseSourceAnchor, sourceAnchor, sourceCardId } from '../lib/sourceAnchor' import { useLoginOn } from '../store/auth' import { useRunClock } from '../run/useRunClock' import { focusProfileField, ProfileSettingsDialog, ProfileSettingsSection } from './catalog/ProfileSettings' @@ -83,6 +85,7 @@ const syncCatalog = () => void useCatalog.getState().reload('datasources') export function DataSourcesTab({ view = 'databases' }: { view?: DataView }) { const navigate = useNavigate() + const location = useLocation() const [rows, setRows] = useState(null) const [kinds, setKinds] = useState([]) const [loadError, setLoadError] = useState(null) @@ -91,6 +94,8 @@ export function DataSourcesTab({ view = 'databases' }: { view?: DataView }) { /** 按配方导入的向导(首次、上传新一期、修改配方、继续未完成的导入) */ const [wizard, setWizard] = useState(null) const [kick, setKick] = useState<{ id: string; seq: number } | null>(null) + /** 从别处链过来要打开定时维护的那张卡片(#source-&maintenance,见 lib/sourceAnchor);seq 让同一张卡片能再开一次 */ + const [arrival, setArrival] = useState<{ id: string; seq: number } | null>(null) /** 各源要处理的变化条数(角标)和定时维护(next-round 03 B3、B4):一次取齐;取不到只是少了角标,不挡列表 */ const [upkeep, setUpkeep] = useState>(() => new Map()) const loadSeq = useRef(0) @@ -118,16 +123,26 @@ export function DataSourcesTab({ view = 'databases' }: { view?: DataView }) { } useEffect(() => { void load(); loadUpkeep() }, []) useOnReconnect(() => { void load(); loadUpkeep() }) - // 从别处链过来指名一张卡片(#source-,目录页后台任务条上探查的「查看」):列表载入后滚过去 - const scrolledTo = useRef('') + // 从别处链过来指名一张卡片(lib/sourceAnchor:目录页后台任务条上探查的「查看」、定时任务页上维护定时的「去数据源页设置」): + // 列表载入后滚过去;带 &maintenance 的再打开它的「定时维护」,标记用过就从地址里去掉(刷新不再弹)。指名的源已经不在了 + // (维护定时比数据源活得久)照实说一句,不然只是落在列表顶上、什么也没发生。每个地址只处理一次:列表之后每次增删改都会变 + const arrived = useRef('') useEffect(() => { - const hash = window.location.hash - if (!rows || !hash.startsWith('#source-') || scrolledTo.current === hash) return - const el = document.getElementById(decodeURIComponent(hash.slice(1))) - if (!el) return - scrolledTo.current = hash - el.scrollIntoView({ block: 'center' }) - }, [rows]) + const anchor = parseSourceAnchor(location.hash) + if (!rows || !anchor || arrived.current === location.hash) return + arrived.current = location.hash + const row = rows.find((r) => r.id === anchor.id) + if (!row) { + toast.info(SOURCE_ANCHOR_TEXT.missing) + return + } + document.getElementById(sourceCardId(row.id))?.scrollIntoView({ block: 'center' }) + if (!anchor.open) return + if (anchor.open === 'maintenance') setArrival({ id: row.id, seq: Date.now() }) + const plain = sourceAnchor(row.id) + arrived.current = plain + navigate({ pathname: location.pathname, search: location.search, hash: plain }, { replace: true }) + }, [rows, location.hash]) const upsert = (row: any) => setRows((rs) => { if (!rs) return [row] @@ -215,6 +230,7 @@ export function DataSourcesTab({ view = 'databases' }: { view?: DataView }) { // 版本页里清除原件之后服务端不返回数据源,卡片上的原件状态靠重新取列表 onReload={() => void load()} kick={kick && kick.id === row.id ? kick.seq : 0} + openMaintenance={arrival && arrival.id === row.id ? arrival.seq : 0} upkeep={upkeep.get(row.id) ?? null} onUpkeep={loadUpkeep} /> @@ -291,7 +307,7 @@ export function DataSourcesTab({ view = 'databases' }: { view?: DataView }) { const STALE_SCHEMA_MS = 7 * 24 * 3600_000 function SourceCard({ row, meta, onChange, onRemoved, onEdit, onReupload, onRecipeReupload, onRedraft, onResume, onReload, kick, - upkeep = null, onUpkeep }: { + openMaintenance = 0, upkeep = null, onUpkeep }: { row: any; meta?: any /** 要处理的变化条数、定时维护(GET /api/datasources/upkeep);取不到为 null */ upkeep?: SourceUpkeep | null @@ -309,6 +325,8 @@ function SourceCard({ row, meta, onChange, onRemoved, onEdit, onReupload, onReci onResume: () => void /** 非 0 时自动点一次「探查结构」(新建后 toast 上的按钮) */ kick: number + /** 非 0 时打开一次「定时维护」(从别处链过来,#source-&maintenance);这张卡片不做定时维护时不理 */ + openMaintenance?: number }) { const healthKey = `datasource:${row.id}` // 后端记着上次测的结果:换了浏览器、清了缓存也还在。本机刚测过的更新就用本机的 @@ -353,6 +371,9 @@ function SourceCard({ row, meta, onChange, onRemoved, onEdit, onReupload, onReci const actionChanges = upkeep?.changes.by_severity.action ?? 0 /** 定时维护只给手工登记的库:上传表格、记录表的结构随版本或表单走 */ const maintainable = (row.origin ?? 'manual') === 'manual' + useEffect(() => { + if (openMaintenance && maintainable && !uploaded) setMaintaining(true) + }, [openMaintenance]) const jobActive = !!job && (job.status === 'queued' || job.status === 'running') useEffect(() => { const next = row.introspect_job ?? null @@ -559,7 +580,7 @@ function SourceCard({ row, meta, onChange, onRemoved, onEdit, onReupload, onReci const failed = record && !record.ok && !checkingSince return ( -
+
{uploaded ? diff --git a/frontend/src/pages/catalog/CatalogPage.tsx b/frontend/src/pages/catalog/CatalogPage.tsx index 59148def..cdfc38f0 100644 --- a/frontend/src/pages/catalog/CatalogPage.tsx +++ b/frontend/src/pages/catalog/CatalogPage.tsx @@ -13,6 +13,7 @@ import { EmptyState, ErrorState, PageHeader, Skeleton, Spinner, rovingTarget, to import { useCatalog, useDatasources, useOnReconnect } from '../../store/catalog' import { errorMessage } from '../../lib/errors' import { formatNumber } from '../../lib/format' +import { sourceAnchor } from '../../lib/sourceAnchor' import { CATALOG_DRAFT_TEXT as DT, CATALOG_REVIEW_TEXT as RT, CATALOG_TEXT as CT, CATALOG_TRANSFER_TEXT as XT, JOB_STRIP_TEXT as JT, PROFILE_TEXT as PT, SCHEMA_PARTIAL_TEXT as SPT, SOURCE_CHANGE_TEXT as GT, @@ -382,7 +383,7 @@ export function CatalogPage() { total: profileStrip.total, waiting_for: profileStrip.waiting_for ?? null, position: null, notice: profileNotice } : null, }), [sourceJobs.items, stripJob, stripNotice, profileStrip, profileNotice]) const openStripItem = useCallback((item: StripItem) => { - if (item.kind === 'introspect') navigate(`${back}#source-${encodeURIComponent(sourceId)}`) + if (item.kind === 'introspect') navigate(`${back}${sourceAnchor(sourceId)}`) else openJob(item.kind, item.id) }, [navigate, back, sourceId, openJob]) diff --git a/scripts/check-catalog-changes.mjs b/scripts/check-catalog-changes.mjs index 0228d2cd..751e7bd7 100644 --- a/scripts/check-catalog-changes.mjs +++ b/scripts/check-catalog-changes.mjs @@ -685,6 +685,49 @@ await section('定时维护:缺省关、建一条维护定时、改频率和 [...blocked(sent).map((s) => s.key), ...natives, ...errors].join(' | ')) }) +// 指名一张卡片的地址全站只有一种写法:/data/databases#source-,带 &maintenance 时打开它的「定时维护」(复核 W4R-04:定时 +// 任务页上维护定时的「去数据源页设置」以前链的是 ?source=,数据源页不认,落在列表顶上) +await section('链到一张卡片:#source- 滚过去,带 &maintenance 打开定时维护', async () => { + const state = freshState() + // 前面垫几张别的库,指名的那张落在第一屏下面:不滚过去就看不见 + const fillers = 'abcdefghij'.split('').map((x) => ({ ...state.sources[0], id: `check-chg-fill${x}`, name: `zzchgfill${x}`, + database: `/tmp/check/zzchgfill${x}.db`, tools: [`db_query__zzchgfill${x}`] })) + state.sources = [...fillers, ...state.sources] + const inView = (page, id) => page.locator(`[id="source-${id}"]`).evaluate((el) => { + const r = el.getBoundingClientRect() + return r.top >= 0 && r.bottom <= window.innerHeight + }).catch(() => false) + // 从别的页链过来:先离开本页再进,免得只换 # 的同页跳转 + const land = async (page, path) => { await page.goto('about:blank'); await goto(page, path) } + const { page, sent, errors, natives } = await open('/data/databases', { handlers: handlers(state), viewport: { width: 1280, height: 720 } }) + await page.locator(`[data-source="${NAME1}"]`).waitFor() + check('(对照)不带地址落在列表顶上:那张卡片在第一屏下面', !(await inView(page, S1))) + const dlg = page.locator('[data-maintenance-dialog]') + await land(page, `/data/databases#source-${S1}&maintenance`) + check('带 &maintenance:打开那张卡片的「定时维护」', await until(async () => (await dlg.count()) === 1) + && (await textOf(page.getByRole('dialog'))).includes(`「${NAME1}」的定时维护`), await textOf(page.getByRole('dialog'))) + check('……卡片滚到眼前', await until(() => inView(page, S1))) + check('……用过的 &maintenance 从地址里去掉,只留 #source-', await until(async () => new URL(page.url()).hash === `#source-${S1}`), page.url()) + await page.keyboard.press('Escape') + await until(async () => (await dlg.count()) === 0) + await page.reload({ waitUntil: 'networkidle' }) + await page.locator(`[data-source="${NAME1}"]`).waitFor() + await page.waitForTimeout(500) + check('刷新:只滚过去,不再打开定时维护', await until(() => inView(page, S1)) && await dlg.count() === 0) + await land(page, '/data/databases#source-check-chg-gone&maintenance') + check('指名的数据源不在了:照实说,不打开对话框', await until(async () => (await page.locator('[data-toast]') + .filter({ hasText: '没找到这个数据源,可能已经删除' }).count()) === 1) && await dlg.count() === 0) + await land(page, `/data/tables#source-${S2}&maintenance`) + await page.locator(`[data-source="${NAME2}"]`).waitFor() + await page.waitForTimeout(500) + check('导入表格的源不做定时维护:只滚过去,不打开对话框', await dlg.count() === 0 + && await until(async () => new URL(page.url()).hash === `#source-${S2}`), page.url()) + // 对话框打开时会算一次频率的说法(POST /schedules/preview,纯计算),不算写 + const saved = writes(sent, /./).filter((s) => s.key !== 'POST /schedules/preview') + check('只是打开、没有保存:没有写请求、没有原生对话框、没有运行时报错', saved.length === 0 && blocked(sent).length === 0 && natives.length === 0 + && errors.length === 0, [...saved.map((s) => s.key), ...blocked(sent).map((s) => s.key), ...natives, ...errors].join(' | ')) +}) + await section('后台任务条:按服务端的先后,页头只做入口', async () => { const state = freshState() state.jobs = clone(JOBS_A)