mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 04:11:58 +00:00
W4R-04 数据源页:指名一张卡片的地址全站只认 #source-<id>,带 &maintenance 打开它的定时维护
复核 W4R-04:定时任务页上维护定时的「去数据源页设置」链的是 /data/databases?source=<id>,数据源页只认 #source-<id>(目录页 任务条上探查的「查看」用的写法),没人读 ?source,点了落在列表顶上,既不滚到那张卡片也不打开定时维护。 改法按复核意见统一到 #source-<id>,数据源页这边: - 新的 lib/sourceAnchor.ts 管这个地址的拼和拆(sourceAnchor / parseSourceAnchor / sourceCardId),两边各写一遍的话改一边 另一边就落空。目录页任务条上探查的「查看」改用它,拼出来的地址和原来一样。 - DataSourcesTab 按 useLocation 的 # 处理:列表载入后滚到那张卡片;带 &maintenance 的再打开那张卡片的「定时维护」(只有 手工登记的库才开,上传表格、记录表照旧没有这个入口),标记用过就从地址里去掉,刷新不再弹(同目录页的 pjob / djob)。 - 指名的数据源已经不在了(维护定时比数据源活得久,删数据源不删它):照实说「没找到这个数据源,可能已经删除」,不然只是 落在列表顶上、什么也没发生。 定时任务页那边的链接(ScheduleDetail.tsx,W4-b 的文件)这里不动,要改成 /data/databases#source-<id>&maintenance (或 sourceAnchor(id, 'maintenance')),check-schedules 的断言跟着改。 check-catalog-changes 加一节「链到一张卡片」:前面垫十张库让那张落在第一屏下(先有对照:不带地址时看不见);带 &maintenance 打开那张卡片的定时维护、滚到眼前、地址里只剩 #source-<id>;刷新只滚不弹;源不在了照实说;导入表格的源不弹;只是打开没有 保存,没有写请求。先写这一节跑出 6 项失败;修完 115 项全过;把打开、去标记、照实说三处去掉回跑,4 项报错,已还原。
This commit is contained in:
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* 指名数据源页上的一张卡片的地址:<数据源页>#source-<id>,带 &maintenance 时顺带打开那张卡片的「定时维护」。
|
||||
*
|
||||
* 全站只认这一种写法,拼、拆都在这里:目录页后台任务条上探查的「查看」、定时任务页上维护定时的「去数据源页设置」都链到这里。
|
||||
* 两边各写一遍的话改了一边另一边就落空——复核 W4R-04 就是这样:定时任务页链的是 ?source=<id>,数据源页只认 #source-,
|
||||
* 点了落在列表顶上,既不滚到那张卡片也不打开定时维护。
|
||||
*
|
||||
* 用 #(不用查询串):页内的元素 id 就是 source-<id>,浏览器自己也认这个锚点;数据源页的两个标签(数据库 / 表格)共用一个组件,
|
||||
* 查询串留给页面别的状态。
|
||||
*/
|
||||
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 }
|
||||
}
|
||||
@@ -166,6 +166,11 @@ export const SOURCE_BADGE_TEXT = {
|
||||
hint: '探查结构、取值观察测到的变化里要处理的条数,点开到数据目录的「变化」处理',
|
||||
}
|
||||
|
||||
/** 从别处链到数据源页上的一张卡片(lib/sourceAnchor 的 #source-<id>),那个数据源已经不在了 */
|
||||
export const SOURCE_ANCHOR_TEXT = {
|
||||
missing: '没找到这个数据源,可能已经删除',
|
||||
}
|
||||
|
||||
/** 表详情的「结构与取值变化」一节 */
|
||||
export const TABLE_CHANGES_TEXT = {
|
||||
title: '结构与取值变化',
|
||||
|
||||
@@ -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<any[] | null>(null)
|
||||
const [kinds, setKinds] = useState<any[]>([])
|
||||
const [loadError, setLoadError] = useState<unknown>(null)
|
||||
@@ -91,6 +94,8 @@ export function DataSourcesTab({ view = 'databases' }: { view?: DataView }) {
|
||||
/** 按配方导入的向导(首次、上传新一期、修改配方、继续未完成的导入) */
|
||||
const [wizard, setWizard] = useState<WizardEntry | null>(null)
|
||||
const [kick, setKick] = useState<{ id: string; seq: number } | null>(null)
|
||||
/** 从别处链过来要打开定时维护的那张卡片(#source-<id>&maintenance,见 lib/sourceAnchor);seq 让同一张卡片能再开一次 */
|
||||
const [arrival, setArrival] = useState<{ id: string; seq: number } | null>(null)
|
||||
/** 各源要处理的变化条数(角标)和定时维护(next-round 03 B3、B4):一次取齐;取不到只是少了角标,不挡列表 */
|
||||
const [upkeep, setUpkeep] = useState<Map<string, SourceUpkeep>>(() => 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-<id>,目录页后台任务条上探查的「查看」):列表载入后滚过去
|
||||
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-<id>&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 (
|
||||
<article className="rounded-lg border bg-panel" data-source={row.name} id={`source-${row.id}`}>
|
||||
<article className="rounded-lg border bg-panel" data-source={row.name} id={sourceCardId(row.id)}>
|
||||
<div className="flex flex-wrap items-center gap-x-2 gap-y-1.5 px-3 pt-2.5">
|
||||
{uploaded
|
||||
? <FileSpreadsheet size={14} className={row.enabled ? 'text-dim' : 'text-faint'} aria-hidden />
|
||||
|
||||
@@ -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])
|
||||
|
||||
|
||||
@@ -685,6 +685,49 @@ await section('定时维护:缺省关、建一条维护定时、改频率和
|
||||
[...blocked(sent).map((s) => s.key), ...natives, ...errors].join(' | '))
|
||||
})
|
||||
|
||||
// 指名一张卡片的地址全站只有一种写法:/data/databases#source-<id>,带 &maintenance 时打开它的「定时维护」(复核 W4R-04:定时
|
||||
// 任务页上维护定时的「去数据源页设置」以前链的是 ?source=<id>,数据源页不认,落在列表顶上)
|
||||
await section('链到一张卡片:#source-<id> 滚过去,带 &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-<id>', 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)
|
||||
|
||||
Reference in New Issue
Block a user