定时任务自动对比复核整改(前端):页头的定时任务链接在窄窗口里不再被挤没、带日历图标、任务删了写纯文本;触发历史的对比入口改叫「与上一次对比」并写出有几个指标有变化

- 运行详情页头:元信息这一行改成可换行,定时任务链接至少留 7 个字宽。以前这一行不换行、只有链接能缩,820 宽的探索
  运行只剩「由…」,768 宽为 0,Tab 停在看不见的链接上,「2 条事件」被压成竖排。链接前加和「定时」标记一样的日历
  图标,平时看得出能点。取回的定时任务列表里没有这个任务(已删除)时写成纯文本「由定时任务「X」发起(已删除)」,
  不再链到「不存在」页;列表取不到时不算删除
- 触发历史:「对比上一次」改成和运行详情「更多」菜单同一个叫法「与上一次对比」(复用 RUN_COMPARE_TEXT.menu),旁边
  写「N 个指标有变化」,触屏和键盘也看得到变化的规模,悬停照旧写各类个数。基准不是紧挨着的上一次(服务端给
  previous: false)时悬停写明是哪一次,也不再带「默认基准:同一定时任务的上一次运行」的来历
- 「通知中的运行链接」改成「通知中的链接」,说明里写清对比页链接也靠这个地址
- check-schedules:对比入口的叫法、计数、previous 为 false 时的悬停和来历;页头链接在 768、820 宽(含「提取为草稿」
  的探索运行、署名的立即运行)写得下「由定时任务」且「条事件」不竖排、Tab 到时看得见;图标;定时任务删除后的纯文本
  和列表取不到时照旧是链接;390 宽深浅色下计数在屏幕里、对比度够
This commit is contained in:
ThearchyHelios
2026-10-06 08:49:10 +08:00
parent 45fe01effe
commit 5e8d2c9c36
7 changed files with 268 additions and 69 deletions
+36 -10
View File
@@ -2968,14 +2968,14 @@ export const SCHEDULE_TEXT = {
testResult: (head: string, status: number | null, detail: string) =>
`${head}${status != null ? `(HTTP ${status})` : ''}${detail ? `:${detail}` : ''}`,
// ---- 通知里的运行链接(设置 notify 组,所有定时任务共用)
linkBase: '通知中的运行链接',
// ---- 通知里的链接:运行、对比页都按这个地址拼(设置 notify 组,所有定时任务共用)
linkBase: '通知中的链接',
linkBaseValue: (url: string) => `按 ${url} 拼接`,
linkBaseUnset: '未设置服务访问地址,通知中不附带运行链接',
linkBaseUnset: '未设置服务访问地址,通知中不附带运行和对比页链接',
linkBaseEdit: '修改',
linkBaseSet: '设置',
linkBaseLabel: '服务访问地址',
linkBaseHint: '收件人打开运行链接用的地址,例如 https://agentlab.example.com。所有定时任务共用',
linkBaseHint: '收件人打开运行、对比页链接用的地址,例如 https://agentlab.example.com。所有定时任务共用',
linkBasePlaceholder: 'https://agentlab.example.com',
linkBaseInvalid: '请输入以 http:// 或 https:// 开头的地址',
linkBaseSaved: '已保存服务访问地址',
@@ -3050,21 +3050,40 @@ export const SCHEDULE_TEXT = {
allLoaded: '已全部加载',
shown: (n: number) => `已显示 ${n} 条`,
// ---- 触发历史里的对比:运行结束后服务端自动和同一定时任务的上一次比一次
compareLink: '对比上一次',
// ---- 触发历史里的对比:运行成功结束后,服务端自动和同一定时任务上一次成功的运行比一次
/**
* 和运行详情「更多」菜单里的入口是同一件事,用同一个叫法。RUN_COMPARE_TEXT 定义在本对象之后,模块初始化时还取不到,
* 写成 getter 用到时再取
*/
get compareLink() { return RUN_COMPARE_TEXT.menu },
/**
* 悬停写各类指标的个数,和 Webhook 通知里那一行同一个写法(服务端 scheduler/auto_compare.py 的 summary_text):
* 变化三分法的三类总写上,0 个也写;其余各类有才写,次序同对比页;两边都没有指标时不写一串 0 个。只有个数,
* 不写数值。各类的叫法用对比页那一套(RUN_COMPARE_TEXT.classLabel)
* 不写数值。各类的叫法用对比页那一套(RUN_COMPARE_TEXT.classLabel)。base 是基准的短编号,基准不是紧挨着的
* 上一次(上一次失败了、取消了)时给,写明比的是哪一次,同通知
*/
compareTitle: (summary: Partial<Record<string, number>>) => {
compareTitle: (summary: Partial<Record<string, number>>, base?: string | null) => {
const n = (key: string) => (typeof summary[key] === 'number' ? summary[key] as number : 0)
const core = ['data_only', 'caliber_and_data', 'sql_changed']
const rest = ['params_only', 'added', 'removed', 'incomparable', 'unchanged']
if (![...core, ...rest].some((key) => n(key) > 0)) return '与上一次相比:两次运行都没有指标'
const head = base ? `与上一次成功的运行(${base})相比` : '与上一次相比'
if (![...core, ...rest].some((key) => n(key) > 0)) return `${head}:两次运行都没有指标`
const parts = [...core, ...rest.filter((key) => n(key) > 0)]
.map((key) => `${RUN_COMPARE_TEXT.classLabel[key] ?? key} ${n(key)} 个`)
return `与上一次相比:${parts.join('、')}`
return `${head}:${parts.join('、')}`
},
/**
* 链接旁看得见的简短计数:触屏没有悬停,键盘聚焦时也不显示 title,只靠悬停看不出变化的规模。有变化的几类(只数据变、
* 口径与数据都变、SQL 结构变、只参数变、新增、移除)合起来写一个数;都没变化时说有几个无法比较,或者都未变;两边都
* 没有指标时不写(悬停里有)
*/
compareCount: (summary: Partial<Record<string, number>>) => {
const n = (key: string) => (typeof summary[key] === 'number' ? summary[key] as number : 0)
const changed = ['data_only', 'caliber_and_data', 'sql_changed', 'params_only', 'added', 'removed']
.reduce((sum, key) => sum + n(key), 0)
if (changed) return `${changed} 个指标有变化`
if (n('incomparable')) return `${n('incomparable')} 个指标无法比较`
return n('unchanged') ? '指标都未变' : null
},
// ---- 运行详情页头的来源
@@ -3080,6 +3099,13 @@ export const SCHEDULE_TEXT = {
/** 悬停写全名(页头窄时链接会截断)和点开去哪 */
runSourceTitle: (name?: string | null) =>
(name ? `定时任务「${name}」:查看设置和触发历史` : '查看这个定时任务的设置和触发历史'),
/**
* 定时任务已经删除(取回的定时任务列表里没有它):不再链过去——只会落到「不存在」页,页上的返回又回到定时任务列表。
* 和同一行的「工作流已删除」一样写成纯文本
*/
runSourceGone: (name?: string | null) => (name ? `由定时任务「${name}」发起(已删除)` : '由已删除的定时任务发起'),
runSourceManualGone: (name?: string | null) => (name ? `来自定时任务「${name}」(已删除)` : '来自已删除的定时任务'),
runSourceGoneTitle: '这个定时任务已删除,它发起的运行记录仍然保留',
// ---- 记录页的定时标记
runTag: '定时',
+2 -1
View File
@@ -555,7 +555,8 @@ export function RunDetailView({ runId, onChange, onDeleted }: {
<ClassChip runClass={run.run_class} version={run.version} always />
<TierChip tier={(run.output as any)?._issuance?.tier} />
</div>
<div className="mt-0.5 flex min-w-0 items-center gap-1 text-2xs text-faint">
{/* 窄时换行:不换行时这一行只有定时任务链接能缩,会被挤成 0 宽,其余各项被压成竖排 */}
<div className="mt-0.5 flex min-w-0 flex-wrap items-center gap-x-1 gap-y-0.5 text-2xs text-faint">
<span className="mono">{shortId(run.id)}</span>
<span>·</span>
<time className="tnum" title={`发起于 ${formatDateTime(run.created_at ?? null)}`}>
+18 -8
View File
@@ -3,7 +3,7 @@ import { Link } from 'react-router-dom'
import { ChevronRight, GitCompareArrows, History, RotateCw } from 'lucide-react'
import { api } from '../../api/client'
import { EmptyState, ErrorState, Skeleton, Spinner, StatusPill, toast, useTicker } from '../../components/ui'
import { formatDateTime, parseServerTime } from '../../lib/format'
import { formatDateTime, parseServerTime, shortId } from '../../lib/format'
import { SCHEDULE_TEXT as T } from '../../lib/terms'
import type { Schedule, ScheduleFire, ScheduleFireCompare } from '../../types'
import { comparePath, type CompareVia } from '../runs/compare/entry'
@@ -13,8 +13,8 @@ const PAGE = 20
/**
* 触发历史:每次触发一行,新的在前,20 条一页、「加载更多」往后接。计划时间按定时任务的时区写;跳过、发起失败
* 写原因;发起了的链到那次运行、写运行现在的状态;运行结束后服务端和同一任务的上一次比过的,另给「对比上一次」;
* 发出去的通知逐条写结果。refreshKey 变了(立即运行之后)从头重取
* 写原因;发起了的链到那次运行、写运行现在的状态;运行成功结束后服务端和同一任务上一次成功的运行比过的,另给
* 「与上一次对比」;发出去的通知逐条写结果。refreshKey 变了(立即运行之后)从头重取
*/
export function FireHistory({ schedule, refreshKey }: { schedule: Schedule; refreshKey: number }) {
const [items, setItems] = useState<ScheduleFire[] | null>(null)
@@ -142,21 +142,31 @@ function FireRow({ fire: f, tz, now }: { fire: ScheduleFire; tz: string; now: nu
}
/**
* 「对比上一次」:去对比页,本次是这次触发的运行、基准是服务端自动对比时取的同一任务的上一次。悬停写各类指标的个数
* (和通知里那一行同一个写法),读屏把它当说明念。带上来历,对比页据此写「默认基准:同一定时任务的上一次运行」
* 「与上一次对比」:去对比页,本次是这次触发的运行、基准是服务端自动对比时取的同一任务上一次成功的运行。旁边写有几个
* 指标有变化(触屏没有悬停,键盘聚焦也不显示悬停),悬停写各类指标的个数(和通知里那一行同一个写法),读屏把它当
* 说明念。基准就是紧挨着的上一次时带上来历,对比页据此写「默认基准:同一定时任务的上一次运行」;不是(上一次失败了、
* 取消了)时不带,免得说错,悬停写明比的是哪一次
*/
function CompareLink({ runId, compare }: { runId: string; compare: ScheduleFireCompare }) {
const via: CompareVia = { base: compare.base_run_id, reason: 'same_schedule' }
const previous = compare.previous !== false
const via: CompareVia | undefined = previous ? { base: compare.base_run_id, reason: 'same_schedule' } : undefined
const count = T.compareCount(compare.summary)
return (
<Link
to={comparePath(runId, compare.base_run_id)}
state={via}
title={T.compareTitle(compare.summary)}
title={T.compareTitle(compare.summary, previous ? null : shortId(compare.base_run_id))}
data-fire-compare={compare.base_run_id}
className="inline-flex shrink-0 items-center gap-1 rounded-sm text-2xs text-dim outline-none hover:text-fg hover:underline focus-visible:ring-2 focus-visible:ring-[var(--accent)]"
>
<GitCompareArrows size={11} aria-hidden />
{T.compareLink}
<span data-fire-compare-label="">{T.compareLink}</span>
{count && (
<>
<span aria-hidden>·</span>
<span className="tnum" data-fire-compare-count="">{count}</span>
</>
)}
</Link>
)
}
@@ -9,7 +9,7 @@ import type { ScheduleEvent, ScheduleWebhook, ScheduleWebhookTest } from '../../
import { SCHEDULE_EVENTS, WEBHOOK_FORMATS, isHttpUrl, webhookTestText, type FormField, type WebhookForm } from './model'
/**
* Webhook 通知:格式、地址、签名密钥、订阅事件、发送测试消息,外加通知里运行链接用的服务访问地址。
* Webhook 通知:格式、地址、签名密钥、订阅事件、发送测试消息,外加通知里运行、对比页链接用的服务访问地址。
*
* 地址和密钥保存之后服务端不再返回明文:地址只显示主机和末 4 位(url_hint),可以替换或删除;密钥只说「已设置」,
* 可以替换或清除。没点替换时请求里不带它们,改别的字段不会把地址清掉。测试消息用的是已保存的设置,
@@ -195,7 +195,7 @@ function SecretField({ value: w, saved, onChange, error }: {
}
/**
* 通知里运行链接用的服务访问地址:设置里 notify 一组的 public_base_url,所有定时任务共用。这里给出当前值和修改入口,
* 通知里运行、对比页链接用的服务访问地址:设置里 notify 一组的 public_base_url,所有定时任务共用。这里给出当前值和修改入口,
* 写的时候先重读一遍 notify 组、只换这一项,不把同组别的设置冲掉
*/
function LinkBase() {
+48 -11
View File
@@ -223,6 +223,11 @@ export const isUnsupported = (e: unknown) => e instanceof ApiError && e.status =
* 不认识的 id 再取一次列表(一分钟内最多一次)。取不到(老服务端、暂时连不上)不要紧,标记照样显示,只是不写名字
*/
const names = new Map<string, string>()
/**
* 已删除的定时任务:取回的列表里没有的(列表是全部定时任务,服务端不分页),或者刚在本页删掉的。运行详情页头据此不再
* 链过去。列表没取到时谁都不算删除:只是不知道
*/
const missing = new Set<string>()
let fetchedAt = 0
let inflight: Promise<void> | null = null
let namesVersion = 0
@@ -236,16 +241,30 @@ const emitNames = () => {
/** 记下读到的定时任务名字(gone 里的是刚删掉的) */
export function rememberScheduleNames(items: Pick<Schedule, 'id' | 'name'>[], gone: string[] = []) {
for (const s of items) names.set(s.id, s.name)
for (const id of gone) names.delete(id)
for (const s of items) {
names.set(s.id, s.name)
missing.delete(s.id)
}
for (const id of gone) {
names.delete(id)
missing.add(id)
}
emitNames()
}
function ensureNames(ids: string[]) {
if (inflight || !ids.some((id) => !names.has(id)) || Date.now() - fetchedAt < NAMES_RETRY_MS) return
if (inflight || !ids.some((id) => !names.has(id) && !missing.has(id)) || Date.now() - fetchedAt < NAMES_RETRY_MS) return
inflight = api.schedules.list({ timeoutMs: 10_000 })
.then((r) => { for (const s of r.items ?? []) names.set(s.id, s.name) })
.catch(() => { /* 取不到就不写名字 */ })
.then((r) => {
const items = r.items ?? []
for (const s of items) {
names.set(s.id, s.name)
missing.delete(s.id)
}
const listed = new Set(items.map((s) => s.id))
for (const id of ids) if (!listed.has(id)) missing.add(id)
})
.catch(() => { /* 取不到就不写名字,也不算删除 */ })
.finally(() => {
inflight = null
fetchedAt = Date.now()
@@ -253,6 +272,9 @@ function ensureNames(ids: string[]) {
})
}
/** 这个定时任务已删除(取回的列表里没有它、或者刚在本页删掉)。和 useScheduleNames 一起用:取回来后会重渲染 */
export const scheduleGone = (id: string) => missing.has(id)
/** 这些定时任务的名字(id → 名字)。没记过的去取一次,取回来后重渲染 */
export function useScheduleNames(ids: (string | null | undefined)[]): ReadonlyMap<string, string> {
useSyncExternalStore(
@@ -302,29 +324,44 @@ export function ScheduleTag({ scheduleId, name }: { scheduleId: string; name?: s
*
* 到点发起的运行(以及没有署名的立即运行)记在「定时任务:X」名下:原来那句「定时任务:X 发起」整句换成这条链接,
* 不写两遍。有人署名点「立即运行」的,署名照写,后面接「来自定时任务「X」」。名字取定时任务现在的名字(改过名的写
* 新名字),取不到时用署名里记的那个,再没有就不写名字。页头窄时链接截断,全名在悬停里
* 新名字),取不到时用署名里记的那个,再没有就不写名字。
*
* 前面带和「定时」标记一样的日历图标:页头这一行都是灰字,不带记号看不出这一段能点。页头窄时这一行换行,链接至少留
* 几个字宽(以前这一行不换行,只有链接能缩,窄窗口里被挤成 0 宽,Tab 停在一个看不见的链接上),再长截断,全名在悬停里。
* 定时任务已经删除的写成纯文本(链过去只有「不存在」页),同一行的「工作流已删除」也是这样
*/
export function RunScheduleSource({ scheduleId, startedBy }: { scheduleId: string; startedBy?: string | null }) {
const names = useScheduleNames([scheduleId])
const recorded = startedBy?.startsWith(T.actorPrefix) ? startedBy.slice(T.actorPrefix.length).trim() : null
const auto = !startedBy || recorded != null
const name = names.get(scheduleId) || recorded || null
const link = (
const body = (text: string) => (
<>
<CalendarClock size={10} aria-hidden className="shrink-0" />
<span className="min-w-0 truncate">{text}</span>
</>
)
const source = scheduleGone(scheduleId) ? (
<span title={T.runSourceGoneTitle} data-run-schedule-gone={scheduleId}
className="inline-flex min-w-[7em] max-w-full items-center gap-1">
{body(auto ? T.runSourceGone(name) : T.runSourceManualGone(name))}
</span>
) : (
<Link
to={`/schedules/${encodeURIComponent(scheduleId)}`}
title={T.runSourceTitle(name)}
data-run-schedule-link={scheduleId}
className="min-w-0 truncate rounded-sm text-dim outline-none hover:text-fg hover:underline focus-visible:ring-2 focus-visible:ring-[var(--accent)]"
className="inline-flex min-w-[7em] max-w-full items-center gap-1 rounded-sm text-dim outline-none hover:text-fg hover:underline focus-visible:ring-2 focus-visible:ring-[var(--accent)]"
>
{auto ? T.runSource(name) : T.runSourceManual(name)}
{body(auto ? T.runSource(name) : T.runSourceManual(name))}
</Link>
)
if (auto || !startedBy) return link
if (auto || !startedBy) return source
return (
<>
<span className="shrink-0">{T.runStarter(startedBy)}</span>
<span>·</span>
{link}
{source}
</>
)
}
+9 -3
View File
@@ -3208,12 +3208,17 @@ export interface ScheduleFireNotice {
}
/**
* 运行结束后和同一定时任务上一次运行的自动对比:基准是哪一次、各类指标的个数(同对比接口的 summary)。只有个数,
* 具体的数在对比页上
* 运行成功结束后和同一定时任务上一次成功的运行的自动对比:基准是哪一次、各类指标的个数(同对比接口的 summary)。
* 只有个数,具体的数在对比页上
*/
export interface ScheduleFireCompare {
base_run_id: string
summary: RunCompareSummary
/**
* 基准是不是紧挨着的上一次。上一次失败了、取消了时,基准是再往前成功的那一次,为 false:界面写明比的是哪一次。
* 老服务端不给,按 true 看
*/
previous?: boolean
}
/** 一次触发(触发历史的一行) */
@@ -3230,7 +3235,8 @@ export interface ScheduleFire {
reason: string | null
notified: ScheduleFireNotice[]
/**
* 和同一定时任务上一次的对比。没有上一次、还没算、比不了、运行又在执行(不在终态)时为 null;老服务端不给
* 和同一定时任务上一次成功的运行的对比。运行没有成功结束、没有可比的、还没算、比不了、任何一边又在执行(不在终态)
* 或重新封存过时为 null;老服务端不给
*/
compare?: ScheduleFireCompare | null
created_at: string
+153 -34
View File
@@ -3,9 +3,11 @@
// 守的是这些「看着没坏、其实不能用」的地方:预设生成的 cron 不对(选了每周一 09:00,交上去的是每天)、时区按本机写
// 而不是按定时任务的时区、预览不防抖、非法 cron 只弹一句不知道指哪一项、启用开关不带版本号(别人改过也照样覆盖)、
// 立即运行之后没有下文、触发历史只有一页、保存过的 Webhook 地址又被原样显示出来、改了别的字段却把地址清掉、
// 记录页看不出哪些运行来自定时任务、390px 窄屏被表单撑出横向滚动。运行结束后的自动对比:触发历史里「对比上一次」
// 链到对比页、悬停只写各类指标的个数;运行详情页头的「由定时任务「X」发起」是键盘到得了的真链接(记录页的「定时」
// 标记在整行按钮里,只能鼠标点)。
// 记录页看不出哪些运行来自定时任务、390px 窄屏被表单撑出横向滚动。运行成功结束后的自动对比:触发历史里「与上一次
// 对比」(和运行详情「更多」菜单同一个叫法)链到对比页,旁边写有几个指标有变化,悬停只写各类指标的个数,基准不是
// 紧挨着的上一次时写明是哪一次、不带「默认基准」的来历;运行详情页头的「由定时任务「X」发起」是键盘到得了的真链接
// (记录页的「定时」标记在整行按钮里,只能鼠标点),带日历图标,窄窗口里(768、820、900 宽,探索运行多一个「提取为
// 草稿」)不被挤没,定时任务删掉之后写成纯文本。
// 还有几处时序和口径:晚到的刷新把刚切换的开关盖回去、先开触发历史再开设置时带着旧版本撞假的 409、保存途中又改了
// 别的字段时替换过的地址明文留在框里、服务端会拒的组合(未发布 + 正式运行、必填没填、已发布版本里没有的字段)
// 照样放行、测试消息的结果把服务端的整句又套一层、错过已跳过的那条悬停另说一个原因(和本条记的原因对不上)。
@@ -265,6 +267,24 @@ await section('纯函数:服务端会拒的组合、保存回来怎么接、
check(`测试结果:服务端给整句、给补充说明都只说一遍(${tests.length} 种)`, !wrong.length, wrong.map(([r]) => t(r)).join(';'))
})
await section('纯函数:触发历史对比的文案', async () => {
const { SCHEDULE_TEXT: T, RUN_COMPARE_TEXT: C } = await import(await load('/src/lib/terms.ts'))
check('「与上一次对比」和运行详情「更多」菜单同一个叫法', T.compareLink === C.menu && T.compareLink === '与上一次对比', T.compareLink)
const zero = { unchanged: 0, data_only: 0, caliber_and_data: 0, sql_changed: 0, params_only: 0, added: 0, removed: 0, incomparable: 0 }
const cases = [
['有变化的几类合起来', { ...zero, data_only: 3, caliber_and_data: 1, params_only: 2, unchanged: 4 }, '6 个指标有变化'],
['新增、移除也算变化', { ...zero, added: 1, removed: 2 }, '3 个指标有变化'],
['没有变化、有无法比较的', { ...zero, incomparable: 2, unchanged: 3 }, '2 个指标无法比较'],
['都未变', { ...zero, unchanged: 5 }, '指标都未变'],
['两边都没有指标:不写', zero, null],
]
const bad = cases.filter(([, sum, want]) => T.compareCount(sum) !== want)
check(`看得见的计数:${cases.length} 种情况`, !bad.length, bad.map(([n, sum]) => `${n}:${T.compareCount(sum)}`).join(';'))
check('悬停:基准不是紧挨着的上一次时写明是哪一次', T.compareTitle({ ...zero, data_only: 1 }, '#run-f1')
=== '与上一次成功的运行(#run-f1)相比:只数据变 1 个、口径与数据都变 0 个、SQL 结构变 0 个'
&& T.compareTitle(zero, '#run-f1') === '与上一次成功的运行(#run-f1)相比:两次运行都没有指标')
})
// ---------------------------------------------------------------- 夹具:虚构的门店周报、库存日报
const NOW = Date.now()
@@ -378,11 +398,14 @@ const S2 = {
* f02),f05 是超出补发宽限的。f06、f07 没有记原因(约定里 reason 可以是 null),看兜底的说明
*/
/** 服务端自动对比给的计数(只有个数):f00 和同一任务的上一次(run-f02)比过 */
const COMPARE_F00 = { base_run_id: 'run-f02', summary: { unchanged: 4, data_only: 3, caliber_and_data: 1, sql_changed: 0,
params_only: 2, added: 0, removed: 0, incomparable: 0 } }
const COMPARE_F00 = { base_run_id: 'run-f02', previous: true, summary: { unchanged: 4, data_only: 3, caliber_and_data: 1,
sql_changed: 0, params_only: 2, added: 0, removed: 0, incomparable: 0 } }
const COMPARE_F00_TITLE = '与上一次相比:只数据变 3 个、口径与数据都变 1 个、SQL 结构变 0 个、只参数变 2 个、未变 4 个'
const NO_METRICS = { base_run_id: 'run-f09', summary: { unchanged: 0, data_only: 0, caliber_and_data: 0, sql_changed: 0,
params_only: 0, added: 0, removed: 0, incomparable: 0 } }
const NO_METRICS = { base_run_id: 'run-f09', previous: true, summary: { unchanged: 0, data_only: 0, caliber_and_data: 0,
sql_changed: 0, params_only: 0, added: 0, removed: 0, incomparable: 0 } }
/** f10 的上一次(f11)失败了:基准是再往前成功的 f12,不是紧挨着的上一次 */
const COMPARE_F10 = { base_run_id: 'run-f12', previous: false, summary: { unchanged: 1, data_only: 1, caliber_and_data: 0,
sql_changed: 0, params_only: 0, added: 0, removed: 0, incomparable: 0 } }
function makeFires() {
const out = []
@@ -403,6 +426,8 @@ function makeFires() {
if (i === 6) f = { ...f, ...missed, reason: null }
if (i === 7) f = { ...f, status: 'failed_to_start', run_id: null, run_status: null, reason: null }
if (i === 8) f = { ...f, compare: NO_METRICS }
if (i === 10) f = { ...f, compare: COMPARE_F10 }
if (i === 11) f = { ...f, run_status: 'failed', compare: null }
out.push(f)
}
return out
@@ -421,6 +446,9 @@ const RUNS = [
/** 有人署名点了「立即运行」:署名照写,页头另接到定时任务的链接 */
const RUN_MANUAL_FIRE = { ...RUNS[0], id: 'run-sched-2', started_by: '检查员甲', created_at: iso(NOW - H), started_at: iso(NOW - H),
finished_at: iso(NOW - H + 4200) }
/** 探索运行成功了,页头多一个「提取为草稿」,这一行更挤:到点发起的、署名点了立即运行的各一次 */
const RUN_EXPLORE = { ...RUNS[0], id: 'run-sched-3', run_class: 'exploratory', version: null, started_by: '定时任务:门店周报 · 每周' }
const RUN_EXPLORE_MANUAL = { ...RUN_EXPLORE, id: 'run-sched-4', started_by: '检查员甲' }
/** 运行详情要的图和事件:一个节点、开始到结束两条,够页头和时间线画出来 */
const RUN_GRAPH = { nodes: [{ id: 'out', type: 'output', position: { x: 0, y: 0 }, data: { label: '成果', config: {} } }], edges: [] }
function runEvents(run) {
@@ -1166,8 +1194,10 @@ await section('详情:Webhook 与运行链接地址', async () => {
&& !('secret' in (p2?.webhook ?? {})), JSON.stringify(p2))
check('……保存后又只显示 url_hint', await until(async () => (await text(page, '[data-webhook-url-hint]').catch(() => '')).includes('…cd34'))
&& await page.locator('[data-webhook-url]').count() === 0)
// 运行链接地址:读写设置的 notify 组
check('写出运行链接地址还没设置', (await text(page, '[data-link-base]')).includes('未设置'))
// 运行链接地址:读写设置的 notify 组。对比页链接也按它拼,说明里一并说到
const unset = await text(page, '[data-link-base]')
check('写出运行链接地址还没设置,没有它时运行和对比页链接都不附', unset.includes('未设置') && unset.includes('运行和对比页链接'),
oneLine([unset]))
await page.locator('[data-link-base-edit]').click()
await page.locator('[data-link-base-input]').fill('ftp://bad')
check('地址不对时不让存', await page.locator('[data-link-base-save]').isDisabled())
@@ -1412,24 +1442,31 @@ await section('详情:触发历史分页', async () => {
&& await gone.page.locator('[data-schedule-missing] a[href="/schedules"]').count() === 1)
})
await section('详情:触发历史里的「对比上一次」', async () => {
await section('详情:触发历史里的「与上一次对比」', async () => {
const { page, sent, errors } = await open('/schedules/s-weekly?tab=history')
await page.locator('[data-fire-id]').first().waitFor()
const row = (id) => page.locator(`[data-fire-id="${id}"]`)
const link = row('f00').locator('[data-fire-compare]')
check('有对比的那次:给「对比上一次」', (await link.count()) === 1 && (await link.innerText()).trim() === '对比上一次',
await link.count() ? await link.innerText() : '没有链接')
const label = await link.locator('[data-fire-compare-label]').innerText().catch(() => '没有链接')
check('有对比的那次:给「与上一次对比」(和运行详情「更多」菜单同一个叫法)', (await link.count()) === 1
&& label.trim() === '与上一次对比', label)
check('……链到对比页,本次是这次的运行、基准是服务端比过的那一次',
await link.getAttribute('href') === '/runs/compare?target=run-f00&base=run-f02', (await link.getAttribute('href')) ?? '')
const count = (await link.locator('[data-fire-compare-count]').innerText().catch(() => '')).trim()
check('……旁边看得见有几个指标有变化(触屏、键盘看不到悬停)', count === '6 个指标有变化', count)
const title = (await link.getAttribute('title')) ?? ''
check('……悬停写各类指标的个数:三类总写,其余有才写,没有数值', title === COMPARE_F00_TITLE, title)
check('两边都没有指标:悬停不写一串 0 个', ((await row('f08').locator('[data-fire-compare]').getAttribute('title')) ?? '')
=== '与上一次相比:两次运行都没有指标')
const f08 = row('f08').locator('[data-fire-compare]')
check('两边都没有指标:悬停不写一串 0 个,旁边也不写计数', ((await f08.getAttribute('title')) ?? '')
=== '与上一次相比:两次运行都没有指标' && await f08.locator('[data-fire-compare-count]').count() === 0)
const f10 = row('f10').locator('[data-fire-compare]')
const t10 = (await f10.getAttribute('title').catch(() => '')) ?? ''
check('上一次失败了、基准是再往前成功的那次:悬停写明是哪一次', t10.startsWith('与上一次成功的运行(#run-f1)相比:只数据变 1 个'), t10)
const linked = await page.locator('[data-fire-compare]').evaluateAll((els) => els.map((e) => e.closest('[data-fire-id]')?.getAttribute('data-fire-id')))
check('没有对比的(compare 为 null、老服务端不给、没有运行)不给链接', JSON.stringify(linked) === '["f00","f08"]', JSON.stringify(linked))
check('没有对比的(compare 为 null、老服务端不给、没有运行)不给链接', JSON.stringify(linked) === '["f00","f08","f10"]', JSON.stringify(linked))
check('运行链接照旧在', await row('f00').locator('a[href="/runs/run-f00"]').count() === 1)
const presses = await tabTo(page, '[data-fire-id="f00"] [data-fire-compare]')
check('键盘:Tab 到得了「对比上一次」', presses > 0, `按了 ${presses} 次`)
check('键盘:Tab 到得了「与上一次对比」', presses > 0, `按了 ${presses} 次`)
await shot(page, 'history-compare-focus-dark')
await page.keyboard.press('Enter')
const url = await until(() => {
@@ -1438,14 +1475,44 @@ await section('详情:触发历史里的「对比上一次」', async () => {
})
check('……回车打开对比页,带着本次和基准', !!url && url.searchParams.get('target') === 'run-f00'
&& url.searchParams.get('base') === 'run-f02', page.url())
const real = writes(sent, /./).filter((x) => x.key !== 'POST /schedules/preview')
// 来历跟着这一次跳转走(路由的 state),对比页据此写「默认基准:同一定时任务的上一次运行」
const via = await page.evaluate(() => history.state?.usr ?? null)
check('……紧挨着的上一次:带上「同一定时任务」的来历', via?.reason === 'same_schedule' && via?.base === 'run-f02', JSON.stringify(via))
const second = await open('/schedules/s-weekly?tab=history')
await second.page.locator('[data-fire-id="f10"] [data-fire-compare]').click()
await until(() => new URL(second.page.url()).pathname === '/runs/compare')
const via10 = await second.page.evaluate(() => history.state?.usr ?? null)
check('……基准不是紧挨着的上一次:不带来历,对比页不说「上一次」', new URL(second.page.url()).searchParams.get('base') === 'run-f12'
&& via10 == null, JSON.stringify(via10))
const real = [...writes(sent, /./), ...writes(second.sent, /./)].filter((x) => x.key !== 'POST /schedules/preview')
check('只读:没有写请求', real.length === 0, real.map((x) => x.key).join('、'))
check('没有页面报错', !errors.length, errors.join(' | '))
check('没有页面报错', ![...errors, ...second.errors].length, [...errors, ...second.errors].join(' | '))
})
// ---------------------------------------------------------------- 运行详情
/**
* 运行详情页头元信息那一行:定时任务的链接(或删除后的纯文本)可见的宽度够不够写下图标加「由定时任务」五个字(fits),
* 「N 条事件」是不是还在一行里(eventsFlat,以前被挤成一字一行),页头有没有横向溢出,链接在不在视口里,署名在不在
*/
const headRow = (page) => page.evaluate(() => {
const head = document.querySelector('[data-run-detail] header')
const link = head?.querySelector('[data-run-schedule-link], [data-run-schedule-gone]')
if (!head || !link) return null
const font = parseFloat(getComputedStyle(link).fontSize)
const icon = link.querySelector('svg')?.getBoundingClientRect().width ?? 0
const l = link.getBoundingClientRect()
const events = [...head.querySelectorAll('span')].find((el) => /^[\d,]+ 条事件$/.test(el.textContent.trim()))
const eh = events?.getBoundingClientRect().height ?? 0
return { width: Math.round(l.width), need: Math.round(icon + 4 + font * 5), fits: l.width >= icon + 4 + font * 5 - 0.5,
eventsFlat: !!events && eh > 0 && eh < font * 2.2, scroll: head.scrollWidth - head.clientWidth,
inView: l.left >= 0 && l.right <= innerWidth + 0.5 && l.width > 0, starter: head.innerText.includes('检查员甲 发起') }
})
await section('运行详情:页头的定时任务链接', async () => {
const writesSeenExtra = []
const errorsExtra = []
const state = freshState({ runs: [...RUNS, RUN_MANUAL_FIRE] })
const { page, sent, errors } = await open('/runs/run-sched-1', { state })
await page.locator('[data-run-detail="run-sched-1"]').waitFor({ timeout: 10000 })
@@ -1462,6 +1529,7 @@ await section('运行详情:页头的定时任务链接', async () => {
const headText = await head.innerText()
check('……不再另写「定时任务:X 发起」', !headText.includes('定时任务:') && !headText.includes('旧名'), oneLine([headText]).slice(0, 160))
check('……悬停写全名和点开去哪', ((await link.getAttribute('title')) ?? '').includes('门店周报 · 每周'))
check('……前面带和「定时」标记一样的日历图标,平时看得出能点', await link.locator('svg').count() === 1)
const presses = await tabTo(page, '[data-run-detail] header [data-run-schedule-link]')
check('键盘:Tab 到得了页头的定时任务链接', presses > 0, `按了 ${presses} 次`)
const ring = await page.evaluate(() => getComputedStyle(document.activeElement).boxShadow)
@@ -1505,11 +1573,12 @@ await section('运行详情:页头的定时任务链接', async () => {
const fit = await narrow.page.evaluate(() => {
const head = document.querySelector('[data-run-detail] header')
const link = head?.querySelector('[data-run-schedule-link]')
if (!head || !link) return null
const label = link?.querySelector('span')
if (!head || !link || !label) return null
const h = head.getBoundingClientRect()
const l = link.getBoundingClientRect()
return { headRight: Math.round(h.right), linkRight: Math.round(l.right), scroll: head.scrollWidth - head.clientWidth,
clipped: link.scrollWidth > link.clientWidth }
clipped: label.scrollWidth > label.clientWidth }
})
check('长名字:链接截断,不超出页头、页头不横向溢出', !!fit && fit.linkRight <= fit.headRight && fit.scroll <= 1 && fit.clipped,
JSON.stringify(fit))
@@ -1520,23 +1589,70 @@ await section('运行详情:页头的定时任务链接', async () => {
await crowded.page.locator('[data-run-detail="run-sched-2"]').waitFor({ timeout: 10000 })
const cLink = crowded.page.locator('[data-run-detail] header [data-run-schedule-link]')
await until(async () => (await cLink.innerText().catch(() => '')).includes('复盘'))
const crowd = await crowded.page.evaluate(() => {
const head = document.querySelector('[data-run-detail] header')
const link = head?.querySelector('[data-run-schedule-link]')
if (!head || !link) return null
const l = link.getBoundingClientRect()
return { headRight: Math.round(head.getBoundingClientRect().right), linkRight: Math.round(l.right),
linkWidth: Math.round(l.width), scroll: head.scrollWidth - head.clientWidth,
starter: head.innerText.includes('检查员甲 发起') }
})
const crowd = await headRow(crowded.page)
check('……署名的立即运行配长名字:署名照写,链接截断、看得到,页头不横向溢出', !!crowd && crowd.starter
&& crowd.linkRight <= crowd.headRight && crowd.linkWidth >= 24 && crowd.scroll <= 1, JSON.stringify(crowd))
&& crowd.fits && crowd.scroll <= 1 && crowd.inView, JSON.stringify(crowd))
// 分屏常见的窄窗口:探索运行的页头多一个「提取为草稿」,这一行最挤。以前这一行不换行、只有链接能缩,820 宽时链接
// 只剩「由…」,768 宽时 0 宽,Tab 停在一个看不见的链接上,「2 条事件」被压成竖排
const tight = []
for (const [width, run, extract] of [[820, RUN_EXPLORE, true], [820, RUN_EXPLORE_MANUAL, true],
[768, RUN_EXPLORE, true], [768, RUN_EXPLORE_MANUAL, true], [768, RUN_MANUAL_FIRE, false]]) {
const one = await open(`/runs/${run.id}`, { state: freshState({ runs: [...RUNS, RUN_MANUAL_FIRE, RUN_EXPLORE, RUN_EXPLORE_MANUAL] }), width })
await one.page.locator(`[data-run-detail="${run.id}"] header [data-run-schedule-link]`).waitFor({ timeout: 10000 })
await until(async () => (await one.page.locator('[data-run-detail] header [data-run-schedule-link]').innerText()).includes('门店周报 · 每周'))
const got = await headRow(one.page)
const hasExtract = await one.page.locator('[data-run-detail] header [data-action="extract"]').count() === 1
const manual = run.started_by === '检查员甲'
const ok = !!got && got.fits && got.eventsFlat && got.scroll <= 1 && got.inView && hasExtract === extract
&& (!manual || got.starter)
tight.push(ok)
check(`${width} 宽、${run.run_class === 'exploratory' ? '探索' : '正式'}运行(${manual ? '署名点了立即运行' : '到点发起'}):链接至少写得下「由定时任务」,「条事件」不被压成竖排`,
ok, JSON.stringify({ ...got, hasExtract }))
const presses = await tabTo(one.page, '[data-run-detail] header [data-run-schedule-link]')
if (width === 768 && manual && extract) {
check('……最挤的那种 Tab 到链接时看得见(有宽度、在视口里)', presses > 0 && !!got && got.width > 0 && got.inView, `按了 ${presses} 次`)
await shot(one.page, 'run-detail-schedule-link-768')
}
writesSeenExtra.push(...writes(one.sent, /./))
errorsExtra.push(...one.errors)
}
// 定时任务已经删了:取回的列表里没有它,不再链过去(只会落到「不存在」页),写成纯文本,和「工作流已删除」一样
const goneState = freshState({ runs: [...RUNS, RUN_MANUAL_FIRE] })
goneState.schedules = goneState.schedules.filter((x) => x.id !== 's-weekly')
const gone = await open('/runs/run-sched-1', { state: goneState })
await gone.page.locator('[data-run-detail="run-sched-1"]').waitFor({ timeout: 10000 })
const goneEl = gone.page.locator('[data-run-detail] header [data-run-schedule-gone]')
await goneEl.waitFor({ timeout: 6000 }).catch(() => {})
check('定时任务已删除:写「由定时任务「X」发起(已删除)」,不是链接', (await goneEl.count()) === 1
&& (await goneEl.innerText()).trim() === '由定时任务「门店周报(旧名)」发起(已删除)'
&& await gone.page.locator('[data-run-detail] header [data-run-schedule-link]').count() === 0
&& (await goneEl.evaluate((el) => el.tagName)) !== 'A', (await goneEl.innerText().catch(() => '没有')).trim())
check('……悬停说已删除、运行记录仍然保留', ((await goneEl.getAttribute('title')) ?? '').includes('已删除'))
const goneManual = await open('/runs/run-sched-2', { state: goneState })
await goneManual.page.locator('[data-run-detail="run-sched-2"]').waitFor({ timeout: 10000 })
const gmEl = goneManual.page.locator('[data-run-detail] header [data-run-schedule-gone]')
await gmEl.waitFor({ timeout: 6000 }).catch(() => {})
check('……署名点了立即运行的:署名照写,后面是「来自已删除的定时任务」', oneLine([await goneManual.page.locator('[data-run-detail] header').first().innerText()]).includes('检查员甲 发起')
&& (await gmEl.innerText().catch(() => '')).trim() === '来自已删除的定时任务', (await gmEl.innerText().catch(() => '没有')).trim())
// 列表取不到(老服务端、暂时连不上)不算删除:照样是链接,用署名里记的名字
const downState = freshState({ runs: [...RUNS, RUN_MANUAL_FIRE] })
downState.override = (method, path) => (method === 'GET' && path === '/schedules' ? { status: 503, json: { detail: '暂时不可用' } } : null)
const down = await open('/runs/run-sched-1', { state: downState })
const downLink = down.page.locator('[data-run-detail="run-sched-1"] header [data-run-schedule-link]')
await downLink.waitFor({ timeout: 10000 })
await sleep(300)
check('……定时任务列表取不到时不算删除,照样是链接', await down.page.locator('[data-run-detail] header [data-run-schedule-gone]').count() === 0
&& (await downLink.innerText()).trim() === '由定时任务「门店周报(旧名)」发起')
writesSeenExtra.push(...writes(gone.sent, /./), ...writes(goneManual.sent, /./), ...writes(down.sent, /./))
errorsExtra.push(...gone.errors, ...goneManual.errors, ...down.errors)
// 回车打开的定时任务页会请求预览(POST,但只读),不算写
const writesSeen = [...writes(sent, /./), ...writes(manual.sent, /./), ...writes(light.sent, /./), ...writes(plain.sent, /./),
...writes(narrow.sent, /./), ...writes(crowded.sent, /./)]
...writes(narrow.sent, /./), ...writes(crowded.sent, /./), ...writesSeenExtra]
.filter((x) => x.key !== 'POST /schedules/preview')
check('只读:没有写请求', writesSeen.length === 0, writesSeen.map((x) => x.key).join('、'))
const all = [...errors, ...manual.errors, ...light.errors, ...plain.errors, ...narrow.errors, ...crowded.errors]
const all = [...errors, ...manual.errors, ...light.errors, ...plain.errors, ...narrow.errors, ...crowded.errors, ...errorsExtra]
check('没有页面报错', !all.length, all.join(' | '))
})
@@ -1621,9 +1737,12 @@ await section('窄屏 390px 与浅色', async () => {
const hist = await open('/schedules/s-weekly?tab=history', { ...narrow, theme })
await hist.page.locator('[data-fire-id]').first().waitFor()
check(`${theme}:触发历史不横向溢出`, await overflow(hist.page) <= 1, `${await overflow(hist.page)}px`)
check(`${theme}:「对比上一次」在屏幕里`, await inViewX(hist.page, '[data-fire-id="f00"] [data-fire-compare]'))
check(`${theme}:「与上一次对比」和旁边的计数在屏幕里`, await inViewX(hist.page, '[data-fire-id="f00"] [data-fire-compare]')
&& await inViewX(hist.page, '[data-fire-id="f00"] [data-fire-compare-count]'))
const cmp = await readable(hist.page, '[data-fire-id="f00"] [data-fire-compare]')
check(`${theme}:「对比上一次」对比度 ≥ 4.5`, !!cmp && cmp.ratio >= 4.5, JSON.stringify(cmp))
check(`${theme}:「与上一次对比」对比度 ≥ 4.5`, !!cmp && cmp.ratio >= 4.5, JSON.stringify(cmp))
const cnt = await readable(hist.page, '[data-fire-id="f00"] [data-fire-compare-count]')
check(`${theme}:旁边的计数对比度 ≥ 4.5`, !!cnt && cnt.ratio >= 4.5, JSON.stringify(cnt))
check(`${theme}:页头的开关、立即运行、删除在屏幕里`, await inViewX(hist.page, 'header [data-schedule-run]')
&& await inViewX(hist.page, 'header [data-schedule-toggle]') && await inViewX(hist.page, 'header [data-schedule-delete]'))
await shot(hist.page, `history-390-${theme}`)