From a5f592938be5421165cdbc810ae390e91b63744d Mon Sep 17 00:00:00 2001 From: zhaozhaozz <6450679+zhaozhaozz@users.noreply.github.com> Date: Thu, 15 Oct 2026 11:35:32 +0000 Subject: [PATCH] Simplify the Issue page around the newest-updated open list --- docs/dashboard-pages.md | 4 +-- static/app.js | 13 ---------- static/index.html | 8 +++--- test/journey-board.spec.cjs | 49 +++++++++++++++++++++++++++++++++++-- 4 files changed, 53 insertions(+), 21 deletions(-) diff --git a/docs/dashboard-pages.md b/docs/dashboard-pages.md index 905ca905a..b803d510b 100644 --- a/docs/dashboard-pages.md +++ b/docs/dashboard-pages.md @@ -40,7 +40,7 @@ CI、PR、Issue 数据不可读取时显示“状态未知”,不会显示为 - **口径**:按 UTC 日期统计。存量按每个 Issue/PR 当前的创建、关闭时间回推,关闭后又重开的从创建起都算开放。历史尚未回填完时,说明里会提示较早日期可能偏少。 - 范围和开关只保存在当前页面,重新打开回到最近 30 天;定时刷新数据时保留。 -**开启时长**饼图与开放列表标题、切换及筛选条同一行(窄屏上下堆叠),四桶使用分类色 `--s2`、`--s4`、`--s7`、`--s3`:按创建时间把当前开放项分进 `<7d`、`7-30d`、`30-90d`、`>90d` 四桶(边界与采集器的年龄统计一致),图例同时给出数量和占比;`age_days` 缺失的项不计入,数量为 0 的桶只在图例里列出、不可点。点击扇区或图例只筛选本页下方的开放列表(表格和看板都吃同一个筛选),再点一次选中的扇区取消;筛选与搜索、标签、认领人等条件同时生效(取交集),Issue 与 PR 的选择互不影响。筛选生效时筛选条出现当前时长桶,可点 × 清除;结果为 0 时列表显示空状态,饼图保持可见。“最近更新”“最久未动”“最近合并”等旁路表不受这个筛选影响。Issue 页用这张图代替原来的“年龄分布”条形图。 +**开启时长**饼图与开放列表标题、切换及筛选条同一行(窄屏上下堆叠),四桶使用分类色 `--s2`、`--s4`、`--s7`、`--s3`:按创建时间把当前开放项分进 `<7d`、`7-30d`、`30-90d`、`>90d` 四桶(边界与采集器的年龄统计一致),图例同时给出数量和占比;`age_days` 缺失的项不计入,数量为 0 的桶只在图例里列出、不可点。点击扇区或图例只筛选本页下方的开放列表(表格和看板都吃同一个筛选),再点一次选中的扇区取消;筛选与搜索、标签、认领人等条件同时生效(取交集),Issue 与 PR 的选择互不影响。筛选生效时筛选条出现当前时长桶,可点 × 清除;结果为 0 时列表显示空状态,饼图保持可见。“最近更新”“最久未动”两张卡已从 Issue 页去掉;开放 Issue 表默认按更新时间 `updated_at` 从新到旧,缺失时间排最后,用户可点表头改排序,刷新页面恢复默认。PR 的“最近合并”仍不受开放时长筛选。Issue 页用这张图代替原来的“年龄分布”条形图。 数据由看板仓的采集在 Actions 中算好写进快照,不读 Bot 的数据库,计算方式见[增量同步与历史](incremental-history.md)。 @@ -91,7 +91,7 @@ node .e2e/node_modules/playwright/cli.js test --config test/playwright.config.cj - 总览的问题排序与数据缺失时显示“状态未知”; - Issue / PR 的表格 / 看板切换、按类型隔离、本地字段与导入导出; -- Issue / PR 开启时长饼图:桶边界与数量、扇区/图例点击与再点取消、与文本搜索取交、两页选择互不影响、看板视图同一筛选、空结果与旁路表不受影响; +- Issue / PR 开启时长饼图:桶边界与数量、扇区/图例点击与再点取消、与文本搜索取交、两页选择互不影响、看板视图同一筛选、空结果、Issue 更新时间默认降序与 PR 最近合并不受影响; - Issue / PR 每日趋势:默认 30 天、预设与自定义范围、刷新后保留范围、数据开关、悬停与方向键查看、整数刻度、按日表格,以及 390px 下不溢出; - 看板列独立滚动与刷新后保留位置; - 覆盖率目录树的展开、收起、过滤、按覆盖率排序,以及无逐文件数据时按分组展示; diff --git a/static/app.js b/static/app.js index b04e970ff..89a6759aa 100644 --- a/static/app.js +++ b/static/app.js @@ -637,24 +637,11 @@ { label: '中位年龄', value: days(d.median_age_days), sub: `最老 ${days(d.oldest_age_days)}` }, ]); html += trendSection('issues'); - const issueRow = (i) => `${link(i.url, `#${i.number} ${esc(i.title)}`)}${esc(i.author)} · ${labelChips(i.labels)}`; html += `
${card('标签分布', bars(d.labels.map((l) => ({ label: l.name, labelHtml: `${esc(l.name)}`, value: l.count })), { emptyText: '开放 Issue 都没有标签' }) + (d.unused_labels.length ? `
仓库另有 ${d.unused_labels.length} 个标签未用于任何开放 Issue:${esc(d.unused_labels.join('、'))}
` : ''), { sub: '按开放 Issue 计数' })} ${card('认领负载', bars(d.assignee_load.map((a) => ({ label: a.login, value: a.count })), { emptyText: '所有开放 Issue 均无人认领' }), { sub: '按 assignee' })} ${card('里程碑', bars(d.milestones.map((m) => ({ label: m.name, value: m.count }))))}
`; - html += `
- ${card('最近更新', table('iss-recent', [ - { key: 'number', label: 'Issue', render: issueRow, sortable: false }, - { key: 'updated_at', label: '更新', render: (i) => ago(i.updated_at) }, - { key: 'comments', label: '评论', num: true }, - ], d.recent, { limit: 10 }))} - ${card('最久未动', table('iss-stale', [ - { key: 'number', label: 'Issue', render: issueRow, sortable: false }, - { key: 'idle_days', label: '闲置', num: true, render: (i) => days(i.idle_days) }, - { key: 'age_days', label: '年龄', num: true, render: (i) => days(i.age_days) }, - ], (d.stale.length ? d.stale : d.oldest), { limit: 10, emptyText: '没有陈旧 Issue' }), { sub: d.stale.length ? `≥${d.stale_days_threshold} 天无更新` : '暂无陈旧 Issue,显示最老的开放 Issue' })} -
`; // Drill-down list with filters. const f = STATE.filters; const labels = [...new Set(d.items.flatMap((i) => i.labels.map((l) => l.name)))].sort(); diff --git a/static/index.html b/static/index.html index 09fb19206..81d5646a6 100644 --- a/static/index.html +++ b/static/index.html @@ -5,7 +5,7 @@ GitHub 状态看板 - +
@@ -48,8 +48,8 @@ - - - + + + diff --git a/test/journey-board.spec.cjs b/test/journey-board.spec.cjs index 0ded172fd..bfc5d1e00 100644 --- a/test/journey-board.spec.cjs +++ b/test/journey-board.spec.cjs @@ -58,6 +58,49 @@ test('issue distribution and filters; PR reviews and current commit checks', asy await expect(page.locator('#tab-prs .work-table details')).toContainText('E2E'); }); +test('open Issue table defaults to newest updates and resets sorting on reload', async ({page}) => { + await page.route('**/github-status-board/data/snapshot.json*', async route => { + const response = await route.fetch(), doc = await response.json(); + const issues = doc.sections.issues.data, original = issues.items[0]; + const item = (number, title, updated_at) => ({...original, number, title, updated_at, + url: `${doc.repo_url}/issues/${number}`}); + const missing = item(31, '缺失更新时间', undefined); + // Keep the shared Issue untouched and deliberately return an unsorted list. + issues.items = [item(30, '空更新时间', null), item(10, '较早更新的 Issue', '2026-09-21T10:00:00Z'), + original, item(20, '较新更新的 Issue', '2026-09-22T10:00:00Z'), missing]; + issues.open_count = issues.items.length; + await route.fulfill({response, json: doc}); + }); + await page.goto('/github-status-board/#issues'); + const tab = page.locator('#tab-issues'); + const table = tab.locator('.work-table'); + const numbers = table.locator('tbody tr td:first-child'); + const updateHead = table.locator('th[data-key="updated_at"]'); + const defaultOrder = ['#20', '#10', '#1', '#30', '#31']; + await expect(numbers).toHaveText(defaultOrder); + await expect(table.locator('tbody tr').nth(0)).toContainText('较新更新的 Issue'); + await expect(table.locator('tbody tr').nth(1)).toContainText('较早更新的 Issue'); + await expect(updateHead).toHaveText('更新 ▼'); + await expect(tab.getByRole('heading', {name: /^最近更新/})).toHaveCount(0); + await expect(tab.getByRole('heading', {name: /^最久未动/})).toHaveCount(0); + await expect(tab.locator('.tiles')).toContainText('陈旧(≥30 天无更新)'); + await expect(tab.getByRole('heading', {name: /^标签分布/})).toBeVisible(); + await expect(tab.getByRole('heading', {name: /^认领负载/})).toBeVisible(); + await expect(tab.getByRole('heading', {name: '里程碑', exact: true})).toBeVisible(); + await table.locator('th[data-key="number"]').click(); + await expect(numbers).toHaveText(['#31', '#30', '#20', '#10', '#1']); + await expect(updateHead).toHaveText('更新'); + await page.reload(); + await expect(numbers).toHaveText(defaultOrder); + await expect(updateHead).toHaveText('更新 ▼'); + await page.screenshot({path: shot('issue-list-updated-desktop'), fullPage: true}); + await page.setViewportSize({width: 390, height: 1000}); + expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true); + await page.screenshot({path: shot('issue-list-updated-narrow'), fullPage: true}); + await page.locator('[data-tab="prs"]').click(); + await expect(page.locator('#tab-prs').getByRole('heading', {name: '最近合并', exact: true})).toBeVisible(); +}); + // Issue and PR lists switch between the table and the local board. const showBoard = async (page, tab) => { await page.locator(`[data-work-view="${tab}"] [data-val="board"]`).click(); await expect(page.locator(`#tab-${tab} .work-board .board`)).toBeVisible(); }; test('board grouping, drawer edits and reload retain browser fields', async ({page})=>{ @@ -188,7 +231,7 @@ test('open-age pie filters only the open list on its own page', async ({page}) = await expect(key('7-30d')).toContainText('22.2%'); await expect(key('30-90d')).toContainText('22.2%'); await expect(key('>90d')).toContainText('22.2%'); - // Legend click filters the open Issue table; side cards keep their own rows. + // Legend click filters the open Issue table; summary counts remain unchanged. await stableClick('issues', key('7-30d')); await expect(rows).toHaveCount(2); await expect(page.locator('#tab-issues .list-head')).toContainText('2 / 10'); @@ -199,7 +242,9 @@ test('open-age pie filters only the open list on its own page', async ({page}) = await expect(key('7-30d')).toHaveAttribute('aria-pressed', 'true'); await expect(pie.locator('.age-sector[data-age-filter="1"]')).toHaveAttribute('aria-pressed', 'true'); await expect(page.locator('#tab-issues .age-filter-slot .chip')).toContainText('开启时长:7-30d'); - await expect(page.locator('#tab-issues .grid.wide')).toContainText('处理超时问题'); + await expect(page.locator('#tab-issues').getByRole('heading', {name: /^最近更新/})).toHaveCount(0); + await expect(page.locator('#tab-issues').getByRole('heading', {name: /^最久未动/})).toHaveCount(0); + await expect(page.locator('#tab-issues .tiles .tile').filter({hasText: /^开放/})).toContainText('10'); await page.screenshot({path:shot('issue-age-pie'),fullPage:true}); // The age bucket intersects with the text search; an empty result keeps the pie. await page.locator('[data-filter="issueQ"]').fill('29.9');