普通用户建应用集成收尾:

- 问数据和个人应用的图放行绑定只读查询等合规工具的调用工具节点,只读和遮罩在这条路径上生效;被治理成写入的工具在只读运行里直接拒;知识库范围在工具层固定(图级默认值、参数名纠错、Agent 都改不了)
- 个人应用的刷新和个人蓝图的试运行只有拥有者能读:运行、事件、证据、工件、文件、运行与审批列表、对比、可验证报告、质询、假设分析、提取工作流一并收紧;个人蓝图的试运行不论谁发起都按个人应用约束
- 共享蓝图只给拥有者、对应应用的管理者、管理员,看不到的一律 404;审核副本归审核人;管理员看别人的个人应用只有元数据、可以归档
- 记录表跨版本汇总(只读挂载旧版本、按当前列 UNION ALL、各版本各自固定高水位,缺的版本照实写进印章);停用前发起的运行可以继续写旧版本;更正沿用原提交人、更正人记审计
- 约定收口:待办种类 review、事件 file.extracted、表单错误 file、模板定义带示例与预置;前端类型一次补齐、去掉临时的局部类型;审核待办卡不再给打不开的链接;读附件事件有中文说法;检查脚本补断言
This commit is contained in:
ThearchyHelios
2026-10-15 13:55:19 +08:00
parent a702a5c14b
commit 7739340607
95 changed files with 3107 additions and 611 deletions
+73 -3
View File
@@ -13,7 +13,10 @@
// - 搭建者:导航九项都在,账户菜单写搭建者;
// - 关登录:/login 直接回首页;导航底部是署名入口,没有账户菜单;应用首页挂「未开启登录」的说明;
// - 管理员允许使用者问数据时(D-c):问数据页只取数据源的名字(/api/datasources/names),不取完整列表和表结构;
// - 使用者的设置页只剩偏好设置(主题)和通知,换主题只存本机、不写服务端(D-s)。
// - 使用者的设置页只剩偏好设置(主题)和通知,换主题只存本机、不写服务端(D-s);
// - 管理员的「使用者可以建个人应用」开关(认人接口的 user_personal_apps,docs/design/normal-user-apps.md N7):关着时使用者
// 没有「新建应用」、新建页写明管理员还没开放、进不了蓝图页;开着时有「新建应用」(建的是个人应用),自己的个人应用单列
// 一组「我的应用」,能进自己的蓝图页。
//
// 身份接口在浏览器层答掉(/api/auth/**),按 state 切登录方式和角色;其余 GET 放行(外壳要它),写请求拦下记在探针里。
// 编排者的完整关口另起一套开着 local 登录的服务跑真接口,见 docs/design §6.3。
@@ -60,6 +63,28 @@ const TASK = {
can_respond: true, outcome: null, resolved_by: null, resolved_at: null, created_at: new Date().toISOString(),
}
// 使用者自己建的个人应用和它的蓝图(形状同服务端 api/apps._summary、blueprints/store.blueprint_out)
const ago = (ms) => new Date(Date.now() - ms).toISOString()
const MY_APP = {
id: 'app_mine', name: '我的退款周报', description: '每周看自己门店的退款', icon: 'app', status: 'active', published_version: null,
level: null, needs_upgrade: false, pending_tasks: 0, my_role: 'manage', created_at: ago(86_400_000), updated_at: ago(600_000),
scope: 'personal', owner: 'zhangsan', blueprint_id: 'bp_mine',
}
const SHARED_APP = {
id: 'app_weekly', name: '门店周报', description: '每周一看各门店的销售概况', icon: 'app', status: 'active', published_version: 3,
level: 'published', needs_upgrade: false, pending_tasks: 0, my_role: 'use', created_at: ago(7 * 86_400_000),
updated_at: ago(3600_000), scope: 'shared', owner: null, blueprint_id: null,
}
const item = (value, status = 'confirmed') => ({ value, source: 'user', status, why: null })
const MY_BLUEPRINT = {
id: 'bp_mine', name: '我的退款周报', owner: 'zhangsan', scope: 'personal', status: 'personal',
spec: { format: 'agentlab.blueprint/1', purpose: item('每周看自己门店的退款'), template: null, data: [],
cadence: item(null, 'missing'), people: [], fields: [], writeback: null, access: { use: [], observe: [], manage: [] }, asks: [] },
template_id: null, template_version: null, source: { kind: 'conversation' }, conversation_id: null, workflow_id: null,
app_id: 'app_mine', last_trial_run_id: null, graph_hash: null, joints: {}, copied_from: null, issues: [],
created_at: ago(86_400_000), updated_at: ago(600_000),
}
const browser = await chromium.launch({ executablePath: CHROME })
/**
@@ -103,13 +128,21 @@ async function open(state) {
return route.fulfill({ status: 401, json: { detail: '请先登录', code: 'auth_required', mode: state.mode,
can_bootstrap: !!state.bootstrap } })
}
return route.fulfill({ json: { mode: state.mode, ...PEOPLE[state.who], verified: true, user_can_chat: !!state.chat } })
return route.fulfill({ json: { mode: state.mode, ...PEOPLE[state.who], verified: true, user_can_chat: !!state.chat,
user_personal_apps: !!state.personal } })
}
state.reads?.push(p)
if (state.mode !== 'off' && (!state.who || state.expire)) {
return route.fulfill({ status: 401, json: { detail: '请先登录', code: 'auth_required', mode: state.mode } })
}
if (p === '/api/apps') return route.fulfill({ json: { items: [] } })
if (p === '/api/apps') {
return route.fulfill({ json: { items: state.apps ?? [], can_create: { shared: state.who !== 'user', personal: !!state.personal } } })
}
// 蓝图:使用者自己的个人蓝图(开关开着时能进);没有准备的一律当没有
if (p === '/api/blueprints') return route.fulfill({ json: { items: state.personal ? [MY_BLUEPRINT] : [] } })
if (p === '/api/blueprints/bp_mine') return route.fulfill({ json: MY_BLUEPRINT })
if (p.startsWith('/api/blueprints')) return route.fulfill({ status: 404, json: { detail: '蓝图不存在或已被删除' } })
if (p === '/api/templates') return route.fulfill({ json: { items: [] } })
if (p === '/api/tasks/count') return route.fulfill({ json: { mine: 1, apps: 1, other: 0 } })
if (p === '/api/tasks') return route.fulfill({ json: { items: [TASK] } })
if (p === '/api/tasks/task_x') return route.fulfill({ json: TASK })
@@ -296,6 +329,43 @@ await section('使用者的设置页:只剩偏好设置和通知,换主题
check('启动时也不去读服务端的设置', !state.reads.includes('/api/settings'))
})
await section('使用者:个人应用开关关着时没有新建入口,开着时有「新建应用」和「我的应用」', async () => {
// 关着:首页没有「新建应用」,也没有「我的应用」一组;直接打开新建页写明管理员还没开放;进不了蓝图页
const off = { mode: 'local', who: 'user', personal: false, apps: [SHARED_APP], writes: [] }
const page = await open(off)
await page.goto(`${WEB}/apps?tab=apps`)
await page.locator('[data-app="app_weekly"]').waitFor({ timeout: 15000 })
check('关着:页头没有「新建应用」', await page.locator('[data-app-create]').count() === 0)
check('……没有「我的应用」一组', await page.locator('[data-app-group="personal"]').count() === 0)
await page.goto(`${WEB}/apps/create`)
await page.locator('[data-new-app]').waitFor({ timeout: 8000 })
check('……打开新建页:写明管理员还没有开放个人应用,不给起点', (await page.locator('[data-new-app]').getAttribute('data-new-app')) === 'none'
&& (await page.locator('main').innerText()).includes('管理员还没有开放个人应用'))
await page.goto(`${WEB}/blueprints/bp_mine`)
check('……进不了蓝图页(送回应用首页)', await page.waitForURL(/\/apps(\?|$)/, { timeout: 8000 }).then(() => true, () => false), page.url())
check('没有未捕获的运行时错误', page.errors.length === 0, page.errors[0] ?? '')
// 开着:页头有「新建应用」(建的是个人应用),自己的个人应用单列「我的应用」;能进自己的蓝图页
const on = { mode: 'local', who: 'user', personal: true, apps: [MY_APP, SHARED_APP], writes: [] }
const p2 = await open(on)
await p2.goto(`${WEB}/apps?tab=apps`)
const mine = p2.locator('[data-app-group="personal"]')
check('开着:自己的个人应用单列「我的应用」', await mine.waitFor({ timeout: 15000 }).then(() => true, () => false)
&& await mine.locator('[data-app="app_mine"]').count() === 1)
check('……共享的应用另列一组', await p2.locator('[data-app-group="shared"] [data-app="app_weekly"]').count() === 1)
const create = p2.locator('[data-app-create]')
check('……页头有「新建应用」,建的是个人应用', await create.count() === 1 && (await create.getAttribute('data-app-create')) === 'personal'
&& (await create.getAttribute('href')) === '/apps/create', String(await create.getAttribute('data-app-create').catch(() => '')))
await p2.goto(`${WEB}/apps/create`)
await p2.locator('[data-new-app]').waitFor({ timeout: 8000 })
check('……新建页按个人应用给起点', (await p2.locator('[data-new-app]').getAttribute('data-new-app')) === 'personal'
&& await p2.locator('[data-new-app-personal]').count() === 1)
await p2.goto(`${WEB}/blueprints/bp_mine`)
await p2.waitForTimeout(800)
check('……能进自己的蓝图页', /\/blueprints\/bp_mine/.test(p2.url()), p2.url())
check('没有未捕获的运行时错误', p2.errors.length === 0, p2.errors[0] ?? '')
})
await browser.close()
console.log(failed ? `\n✗ ${failed} 项未通过` : '\n✓ 全部通过')
process.exit(failed ? 1 : 0)
+63 -14
View File
@@ -68,7 +68,7 @@ const USER_OFF = { ...USER_ON, user_personal_apps: false }
const TEMPLATES = [
{ id: 'periodic_board', version: 1, label: '周期看板', description: '每天、每周或每月看一组固定的数', applies_when: '', tags: [],
personal: '全部可用', produces: '一页看板:印章、指标、趋势、明细',
personal: '全部可用',
cards: [{ card: 'purpose', note: '必填' }, { card: 'data', note: '必填:要看的数' }, { card: 'cadence', note: '必填:多久出一次数' },
{ card: 'access', note: '谁能用' }] },
{ id: 'approval', version: 1, label: '审批流程', description: '有人发起、有人审批,可以按金额分级', applies_when: '', tags: [],
@@ -120,8 +120,11 @@ const READY = (extra = {}) => ({ ...clone(BP), issues: [], joints: { ...BP.joint
const ASK = { id: 'q1', question: '销售额按下单日还是支付日算?', options: ['按下单日', '按支付日'], path: '/data/0/date', answer: null }
const STAMP = { as_of: ago(3600_000), as_of_text: '截至 2026-10-13 09:00;统计期 上一 ISO 周 2026-W41', pin: '门店周报(试运行)',
graph_hash: 'ab12cd34', calibers: [], tier: 'formal', tier_text: '完整出具', from: '试运行', sealed: true,
notes: ['数据是真的;这不是正式结果,发布后才会按定时任务出数'] }
graph_hash: 'ab12cd34', calibers: [], tier: 'formal', tier_text: '完整出具', from: '试运行', sealed: true, trial: true,
notes: ['数据是真的,但这不是正式结果:发布之后才会按设定出数'] }
// 形状同服务端试运行预览(blueprints/trial.preview_page):印章 trial=true;趋势只有这一次试运行一个点,version 为 null,
// note 照实说没有趋势(apps/resolve._trial_series);视图模型带 trial(对着哪次试运行)
const TRIAL_SERIES_NOTE = '试运行只有一次,没有趋势:发布之后每出一期数,这里多一个点'
const PREVIEW = {
format: 'agentlab.view/1',
app: { id: '', name: '门店周报', version: 0, level: null, needs_upgrade: false },
@@ -130,14 +133,29 @@ const PREVIEW = {
tree: [
{ id: 'stamp', type: 'stamp', props: {}, data: { view: 'wk', state: 'ok', stamp: STAMP } },
{ id: 'gmv', type: 'metric', props: { label: '销售额' }, data: { state: 'ok', text: '45.68 万元', name: '销售额' } },
{ id: 'gmv_trend', type: 'series', props: { label: '销售额的趋势' }, data: { state: 'ok', mode: 'single', unit: '万元',
trial: true, breaks: [], note: TRIAL_SERIES_NOTE,
series: [{ version: null, label: '试运行', points: [{ run_id: 'run_trial', as_of: '2026-10-12', value: 45.68, text: '45.68 万元',
evidence: null }] }] } },
],
forms: {}, problems: [],
trial: { run_id: 'run_trial', status: 'succeeded', status_text: '已完成' },
}
const PREVIEW_FAILED = {
...PREVIEW, sources: { wk: { kind: 'trial', state: 'error', run_id: 'run_trial', stamp: null,
message: '「取数」这一步查询失败:表 refunds 里没有列 refund_amt' } },
tree: [{ id: 'gmv', type: 'metric', props: { label: '销售额' }, data: { view: 'wk', state: 'error' } }],
}
// 看板类的应用骨架(同服务端 periodic_board.app_skeleton):没有入口;「按条件查看」那一页的条件表单是图的输入(统计期),
// 按引用写在 forms 里({"$ref": "#/forms/start"})——「换一期再试」填的就是它
const BOARD_SPEC = {
format: 'agentlab.app/1', name: '门店周报', pins: [{ alias: 'main', workflow_id: null, version: null, graph_hash: 'ab12cd34', use: 'view' }],
slots: {}, entries: [], task_ui: {}, hooks: [], notify: [], pages: [],
forms: { start: { format: 'agentlab.form/1', type: 'object', required: [], 'x-order': ['period'],
properties: { period: { type: 'string', title: '统计期' } } } },
views: { wk: { source: { kind: 'latest_sealed', pin: 'main' } },
custom: { source: { kind: 'on_demand', pin: 'main', params: { $ref: '#/forms/start' }, ttl_s: 3600 } } },
}
const TRIAL_TASK = {
id: 'task_trial', approval_id: null, run_id: 'run_trial', node_id: 'review', app_id: null, app_name: null, workflow_name: null,
kind: 'approve', title: '财务审批', form: null, policy: { kind: 'any' }, status: 'pending', due_at: null, escalation_level: 0,
@@ -157,21 +175,26 @@ const SUMMARY = [
{ step: 'publish_workflow', text: '发布「门店周报」:此后每周一 09:00 按这一版自动出数' },
{ step: 'grant', text: '门店组的人可以使用' },
]
// 形状同服务端 blueprints/publish_plan.plan_out:ok(没有 error 级问题、也没停下)由服务端算,界面照它决定能不能确认;
// 每条问题顶层带 who(归谁处理)和 source(出自哪一项预检)
const PLAN_BLOCKED = {
id: 'plan_1', blueprint_id: 'bp_week', level: 'published', graph_hash: 'ab12cd34', status: 'planned', steps: steps(0),
summary: SUMMARY, options: { first_run: true },
summary: SUMMARY, options: { first_run: true, dispositions: {}, spec_hash: 'f00d' }, ok: false, actor: 'lisi',
created_at: '2026-10-14T09:00:00+00:00', updated_at: '2026-10-14T09:00:00+00:00',
problems: [
{ code: 'bp.unconfirmed', level: 'error', path: '/data/0/table', message: '「看哪些数·第 1 项·对到的表」还没确认',
{ code: 'bp.unconfirmed', level: 'error', path: '/data/0/table', message: '「看哪些数·第 1 项·对到的表」还没确认', who: 'you',
source: 'blueprint',
plain: { title: '「销售额」对到的表还没确认', why: '这是助手按数据目录对的', who: 'you', action: '在卡片上点「确认」' } },
{ code: 'app.upgrade_policy', level: 'error', path: 'pins[0]', alias: 'weekly', message: '口径升版必须处置',
plain: { title: '口径变了,以前的数怎么处理还没选', why: '', who: 'you', action: '选一种处置' } },
{ code: 'app.view_unbound', level: 'warning', path: 'views.wk', message: '视图没有绑定定时任务',
plain: { title: '有一块看板没有对应的数', why: '', who: 'assistant', action: '交给助手按卡片改' } },
{ code: 'governed.metric_untraced', level: 'warning', path: null, message: '指标没有静态追到原始数据',
{ code: 'app.upgrade_policy', level: 'error', path: 'pins[0]', alias: 'weekly', message: '口径升版必须处置', who: 'you',
source: 'app', plain: { title: '口径变了,以前的数怎么处理还没选', why: '', who: 'you', action: '选一种处置' } },
{ code: 'app.view_unbound', level: 'warning', path: 'views.wk', message: '视图没有绑定定时任务', who: 'assistant',
source: 'app', plain: { title: '有一块看板没有对应的数', why: '', who: 'assistant', action: '交给助手按卡片改' } },
{ code: 'governed.metric_untraced', level: 'warning', path: null, message: '指标没有静态追到原始数据', who: 'admin',
source: 'workflow', fix_kind: null,
plain: { title: '有的指标追不到原始数据', why: '受管级要求', who: 'admin', action: '请管理员处理,或改选已发布级' } },
],
}
const PLAN_OK = { ...PLAN_BLOCKED, id: 'plan_2', problems: [] }
const PLAN_OK = { ...PLAN_BLOCKED, id: 'plan_2', problems: [], ok: true }
// ---------------------------------------------------------------- 浏览器
@@ -277,8 +300,8 @@ async function open({ width = 1280, height = 900, state }) {
}
if (what === 'preview') return json(state.preview ?? PREVIEW)
if (what === 'instantiate') {
return json({ blueprint: state.bp ?? BP, app_spec: { format: 'agentlab.app/1', name: '门店周报', pins: [], slots: {}, entries: [],
forms: {}, task_ui: {}, views: {}, pages: [], hooks: [], notify: [] }, problems: [] })
return json({ blueprint: state.bp ?? BP, app_spec: state.appSpec ?? { format: 'agentlab.app/1', name: '门店周报', pins: [], slots: {},
entries: [], forms: {}, task_ui: {}, views: {}, pages: [], hooks: [], notify: [] }, problems: [] })
}
if (what === 'publish-plan') {
state.planBodies = [...(state.planBodies ?? []), body()]
@@ -545,6 +568,9 @@ await section('试运行:横幅、输入、换一期再试、待办、出错',
check('横幅写这次的输入', (await banner.locator('[data-trial-inputs]').innerText()).includes('2026-W41'))
check('应用的样子用 ViewRenderer 渲染(预览)', await page.locator('[data-view-renderer="preview"]').count() === 1
&& (await page.locator('[data-stamp]').first().innerText()).includes('试运行'))
const trend = page.locator('[data-component="series"]')
check('趋势只有试运行一个点:照写服务端的说明(没有趋势),不当成出错', (await trend.locator('[data-series-note]').allInnerTexts())
.includes(TRIAL_SERIES_NOTE), (await trend.innerText().catch(() => '')).replace(/\s+/g, ' '))
const tasks = page.locator('[data-trial-tasks]')
check('有待办:右侧列试运行里的待办', await tasks.waitFor({ timeout: 5000 }).then(() => true, () => false))
check('按钮写「以试运行身份…」', (await tasks.locator('[data-task-approve]').innerText()).includes('以试运行身份同意')
@@ -553,9 +579,32 @@ await section('试运行:横幅、输入、换一期再试、待办、出错',
await tasks.locator('[data-task-approve]').click()
check('以试运行身份同意:回复待办', await until(() => (state.responds ?? []).length === 1) && state.responds[0].decision === 'approve')
await banner.locator('[data-trial-retry]').click()
check('预览没给表单:不自己从骨架里挑,只能原样再试', await banner.locator('[data-trial-retry-form] [data-field]').count() === 0)
await banner.locator('[data-trial-retry-go]').click()
check('换一期再试:再发起一次试运行', await until(() => (state.trials ?? []).length === 2))
// 看板类:「换一期再试」的表单由服务端随预览给(ViewModel.trial.form,blueprints/trial.retry_form:看板类是按条件查看的
// 条件表单),界面照它画、不自己从骨架里挑;填的期带进试运行
const board = { bp: READY(), appSpec: BOARD_SPEC, preview: { ...PREVIEW, trial: { ...PREVIEW.trial, form: BOARD_SPEC.forms.start } } }
const b = await open({ state: board })
await b.page.goto(`${WEB}/blueprints/bp_week`)
await b.page.locator('[data-bp-instantiate]').click()
await b.page.waitForTimeout(300)
await b.page.locator('[data-blueprint-trial]').click()
await b.page.waitForURL(/\/trial\?run=run_trial/, { timeout: 5000 })
const bBanner = b.page.locator('[data-trial-banner]')
await bBanner.waitFor({ timeout: 5000 })
check('横幅写服务端交回的这次输入(字段按表单写中文名)', (await bBanner.locator('[data-trial-inputs]').innerText()).includes('统计期:2026-W41'),
await bBanner.locator('[data-trial-inputs]').innerText())
await bBanner.locator('[data-trial-retry]').click()
const retryForm = bBanner.locator('[data-trial-retry-form] [data-field="period"] input')
check('看板类:「换一期再试」照服务端随预览给的条件表单画,带上这次的期', await retryForm.count() === 1
&& await retryForm.inputValue() === '2026-W41', String(await retryForm.inputValue().catch(() => '(没有表单)')))
await retryForm.fill('2026-W40')
await bBanner.locator('[data-trial-retry-go]').click()
check('……换的期带进试运行', await until(() => (board.trials ?? []).length === 2) && board.trials[1]?.inputs?.period === '2026-W40',
JSON.stringify(board.trials))
// 出错:白话说为什么,给「交给助手修」「回到卡片改」
const bad = { bp: READY({ status: 'trialed', last_trial_run_id: 'run_trial' }), preview: PREVIEW_FAILED,
streams: [{ when: '试运行出错了', ops: [{ op: 'done', explanation: '改了' }] }] }
@@ -604,7 +653,7 @@ await section('发布摘要:三栏、处置、交给助手修', async () => {
await section('发布摘要:确认、逐步打勾、停下、接着做', async () => {
const running = { ...clone(PLAN_OK), status: 'running', steps: steps(2) }
const stopped = { ...clone(PLAN_OK), status: 'stopped', steps: steps(7, 'grant', '名录里没有组「门店」:先在设置里建这个组,或者在卡片上改授权') }
const stopped = { ...clone(PLAN_OK), status: 'stopped', ok: false, steps: steps(7, 'grant', '名录里没有组「门店」:先在设置里建这个组,或者在卡片上改授权') }
const done = { ...clone(PLAN_OK), status: 'done', steps: steps(9) }
const state = {
bp: READY({ status: 'trialed', last_trial_run_id: 'run_trial' }), plans: [clone(PLAN_OK)],
+42
View File
@@ -2682,5 +2682,47 @@ await section('单次运行时限将至(cause: run_deadline):核查没做
check('……不出 undefined、NaN、[object Object]', !odd.some((s) => /undefined|NaN|object Object/.test(`${s.title}${s.sub ?? ''}${s.next ?? ''}`)))
})
await section('读入附件 file.extracted:读入多少字、全文多少字、截断和抽不出都看得见,工件可点开', async () => {
// 服务端 files/text.py:模板函数 file_text 每读一个附件记一条,node_id 是读它的节点。以前这类事件落进「未识别的事件」
const E = (seq, type, node_id, data = {}) => ({ seq, type, node_id, ts: 1790000000 + seq, data })
const ART = 'f1e0'.repeat(16)
const base = { file_id: 'f1', sha256: 'ab'.repeat(32), artifact: ART }
const steps = mod.decodeRun([
E(1, 'run.started', null, { nodes: 2 }),
E(2, 'node.started', 'llm', { node_type: 'llm', label: '读说明' }),
E(3, 'file.extracted', 'llm', { ...base, name: '报损说明.txt', chars: 1200, total_chars: 1200, truncated: false, ok: true }),
E(4, 'file.extracted', 'llm', { ...base, name: '合同.pdf', chars: 20000, total_chars: 53120, truncated: true, ok: true }),
E(5, 'file.extracted', 'llm', { ...base, name: '扫描件.png', chars: 0, total_chars: 0, truncated: false, ok: false,
reason: '图片里的文字目前读不出来' }),
E(6, 'node.finished', 'llm', {}),
])
const all = flatten(steps)
const rows = all.filter((s) => s.code === 'file_extracted')
check('每读一个附件一行,挂在读它的节点下,不再是「未识别的事件」', rows.length === 3 && !all.some((s) => s.title === '未识别的事件')
&& all.find((s) => s.kind === 'node' && s.nodeId === 'llm')?.children?.filter((c) => c.code === 'file_extracted').length === 3,
all.map((s) => s.title).join(' | '))
const [whole, cut, failedRead] = rows
check('读全了:标题写文件名和读入字数,副标题写全文已全部读入,不用提醒色', whole?.title === '读入附件「报损说明.txt」1,200 字'
&& whole.sub === '全文 1,200 字,已全部读入' && whole.level === undefined, `${whole?.title} / ${whole?.sub} / ${whole?.level}`)
check('截断了:副标题写全文多少字、只读入了前多少字,提醒色', cut?.title === '读入附件「合同.pdf」20,000 字'
&& cut.sub === '全文 53,120 字,只读入了前 20,000 字,其余没有读入' && cut.level === 'warn', `${cut?.title} / ${cut?.sub}`)
check('抽不出:标题说抽不出文字,副标题写原因,提醒色', failedRead?.title === '附件「扫描件.png」抽不出文字'
&& failedRead.sub === '图片里的文字目前读不出来' && failedRead.level === 'warn', `${failedRead?.title} / ${failedRead?.sub}`)
check('读入的那一段可以点开(工件)', rows.every((s) => s.artifact === ART))
check('标题、副标题里没有内部代码', !rows.some((s) => /file\.extracted|file_extracted|total_chars|truncated/.test(`${s.title}${s.sub ?? ''}`)))
// 缺字段、类型不对:不崩,不出 undefined、NaN、[object Object]
let broken = null
let odd = []
try {
odd = flatten(mod.decodeRun([{}, { name: 7, chars: 'x', ok: 'yes' }, { ok: false }].map((d, i) => E(i + 1, 'file.extracted', null, d))))
.filter((s) => s.code === 'file_extracted')
} catch (e) {
broken = String(e?.message ?? e)
}
check('缺字段的事件:不抛异常,每条照样一行', broken === null && odd.length === 3, broken ?? `${odd.length} 行`)
check('……不出 undefined、NaN、[object Object],没有文件名就不写名字,不知道字数就不写数', !odd.some((s) => /undefined|NaN|object Object|「」|\d/.test(`${s.title}${s.sub ?? ''}`))
&& odd[0]?.title === '读入附件' && odd[2]?.title === '附件抽不出文字', odd.map((s) => `${s.title}|${s.sub ?? ''}`).join(' ; '))
})
console.log(failed ? `\n✗ ${failed} 项未通过` : '\n✓ 解码器全部通过')
process.exit(failed ? 1 : 0)
+80 -1
View File
@@ -12,7 +12,10 @@
// - 升级之后:升级加派的那一份不算进会签人数,另写升级后的规则(升级给谁、其中一人说了算),名单里标「升级」;
// 待办箱一行写所属实例,同标题的分得开;
// - 补充输入带表单的:本地校验,服务端 422 的错误挂回字段;
// - 关登录时签批人一行写本机署名;会签只记下一票(resolved 为假)时提示「等待其他人处理」。
// - 关登录时签批人一行写本机署名;会签只记下一票(resolved 为假)时提示「等待其他人处理」;
// - 审核个人应用(kind=review,payload 同服务端 blueprints/personal.review_payload):写提交人(显示名)、目的、说明;没有
// 「同意」——「取副本,去发布」调 POST /api/blueprints/{id}/copy 后进副本蓝图;取过的给「打开我的副本」(只认自己取的那
// 一份,别人取的不算);「退回」可以写原因,走待办接口 decision=reject。
//
// 接口全部在浏览器层答掉(/api/tasks/**),数据是这里虚构的;写请求记在探针里。
//
@@ -110,6 +113,26 @@ const TASKS = {
escalation: { slot: 'director', assignees: [{ kind: 'user', name: 'zhaoliu' }], decided: null, by: null } },
payload: { kind: 'human_node', message: '钱七报销 300 元' },
},
// 审核个人应用:不挂在中断上(node_id 为空),派给处理审核的人;payload 同服务端 blueprints/personal.review_payload
review: {
...base, id: 'review', kind: 'review', title: '审核个人应用「我的退款周报」', node_id: null, app_id: 'app_mine',
app_name: '我的退款周报', app_version: null, workflow_name: null, run_id: 'run_p1',
assignments: [assignment('r1', '审批人甲', 'pending', null, { source: 'review' })],
payload: { kind: 'review', blueprint_id: 'bp_mine', app_id: 'app_mine', name: '我的退款周报', purpose: '每周看自己门店的退款',
template_id: 'periodic_board', submitted_by: 'zhangsan', note: '想给门店组的人都用', copies: [] },
people: { zhangsan: '张三', 审批人甲: '审批人甲' },
},
// 已经取过副本:自己取的那一份给「打开我的副本」;别的处理人取的不算
reviewed: {
...base, id: 'reviewed', kind: 'review', title: '审核个人应用「门店日报」', node_id: null, app_id: 'app_daily',
app_name: '门店日报', app_version: null, workflow_name: null, run_id: 'run_p2',
assignments: [assignment('r2', '审批人甲', 'pending', null, { source: 'review' })],
payload: { kind: 'review', blueprint_id: 'bp_daily', app_id: 'app_daily', name: '门店日报', purpose: '每天看门店的销售',
template_id: 'periodic_board', submitted_by: 'zhangsan', note: '', copies: [
{ id: 'bp_daily_other', owner: 'lisi', status: 'drafting', app_id: null },
{ id: 'bp_daily_mine', owner: '审批人甲', status: 'trialed', app_id: null }] },
people: { zhangsan: '张三' },
},
input: {
...base, id: 'input', approval_id: 'ap5', kind: 'input', title: '补填发票信息',
form: { format: 'agentlab.form/1', type: 'object', required: ['invoice'], properties: {
@@ -144,9 +167,13 @@ async function open(state) {
const resolved = m[1] !== 'signoff'
return route.fulfill({ json: { task: { ...t, status: resolved ? 'resolved' : 'pending' }, resolved } })
}
// 审核待办「取副本,去发布」:服务端建一份归处理人的共享副本,交回副本蓝图
if (p === '/api/blueprints/bp_mine/copy') return route.fulfill({ json: { id: 'bp_mine_copy', scope: 'shared', owner: '审批人甲' } })
return route.fulfill({ status: 503, json: { detail: '检查脚本拦下了写请求' } })
}
if (p === '/api/auth/me') return route.fulfill({ json: { mode: 'off', username: null, display_name: null, role: 'admin', groups: [], verified: false } })
// 进了副本蓝图之后的页面不在这里查:蓝图一律答「不存在」,不打到真实服务端
if (p.startsWith('/api/blueprints/')) return route.fulfill({ status: 404, json: { detail: '蓝图不存在或已被删除' } })
if (p === '/api/tasks/count') return route.fulfill({ json: { mine: Object.keys(TASKS).length, apps: Object.keys(TASKS).length, other: 0 } })
if (p === '/api/tasks') return route.fulfill({ json: { items: Object.values(TASKS) } })
const m = p.match(/^\/api\/tasks\/([^/]+)$/)
@@ -279,11 +306,63 @@ await section('补充输入:表单校验、422 挂回字段', async () => {
check('提交的是表单的值,decision 是 submit', sent?.body?.decision === 'submit' && sent?.body?.values?.invoice === 'FP-001', JSON.stringify(sent?.body))
})
await section('审核个人应用:取副本去发布、打开我的副本、退回', async () => {
const state = { writes: [] }
const page = await open(state)
await page.goto(`${WEB}/apps/inbox/review`)
const box = page.locator('[data-task-review]')
await box.waitFor({ timeout: 15000 })
const text = await box.innerText()
check('写提交人(显示名)、目的、提交说明', text.includes('提交人:张三') && text.includes('每周看自己门店的退款')
&& text.includes('想给门店组的人都用'), text.replace(/\s+/g, ' '))
check('同意不在这里点:没有「同意」「驳回」按钮,也不写「没有派给你」',
await page.locator('[data-task-approve], [data-task-reject]').count() === 0 && !(await card(page).innerText()).includes('没有派给你'))
check('没取过副本:给「取副本,去发布」,没有「打开我的副本」', await box.locator('[data-task-review-copy]').count() === 1
&& await box.locator('[data-task-review-open]').count() === 0)
// 待办挂在提交人那次个人应用的刷新上,只有拥有者读得到:不给「打开实例」「打开运行记录」(链出去只会 404 / 403)
const links = await card(page).locator('a[href]').evaluateAll((as) => as.map((a) => a.getAttribute('href')))
check('不给打不开的链接:没有指向提交人个人应用的实例页、也没有运行记录页', !links.some((h) => /\/apps\/app_mine\/i\/|^\/runs\//.test(h ?? ''))
&& !/查看实例|查看运行/.test(await card(page).innerText()), links.join(' , '))
check('没有未捕获的运行时错误', page.errors.length === 0, page.errors[0] ?? '')
await box.locator('[data-task-review-copy]').click()
check('取副本:POST /api/blueprints/{原蓝图}/copy', await page.waitForURL(/\/blueprints\/bp_mine_copy$/, { timeout: 5000 }).then(() => true, () => false)
&& state.writes.some((w) => w.method === 'POST' && w.path === '/api/blueprints/bp_mine/copy'), page.url())
check('……然后进副本蓝图(在副本上试运行、走发布单)', /\/blueprints\/bp_mine_copy$/.test(page.url()), page.url())
check('取副本不回复待办(待办等发布单做完自动记为已发布)', !state.writes.some((w) => w.path === '/api/tasks/review/respond'))
// 已经取过副本:只认自己取的那一份
const p2 = await open({ writes: [] })
await p2.goto(`${WEB}/apps/inbox/reviewed`)
const box2 = p2.locator('[data-task-review]')
await box2.waitFor({ timeout: 15000 })
const openCopy = box2.locator('[data-task-review-open]')
check('取过副本:给「打开我的副本」,链到自己取的那一份', await openCopy.count() === 1
&& (await openCopy.getAttribute('href')) === '/blueprints/bp_daily_mine', String(await openCopy.getAttribute('href').catch(() => '')))
check('……别的处理人取的副本不算,也不再给「取副本」', await box2.locator('[data-task-review-copy]').count() === 0
&& !(await box2.innerHTML()).includes('bp_daily_other'))
// 退回:可以写原因,走待办接口
const s3 = { writes: [] }
const p3 = await open(s3)
await p3.goto(`${WEB}/apps/inbox/review`)
const box3 = p3.locator('[data-task-review]')
await box3.waitFor({ timeout: 15000 })
await box3.getByRole('textbox', { name: '退回的原因(选填)' }).fill('口径和财务的对不上')
await box3.locator('[data-task-review-reject]').click()
await p3.waitForTimeout(300)
const sent = s3.writes.find((w) => w.path === '/api/tasks/review/respond')
check('退回:decision 是 reject,带上原因', sent?.body?.decision === 'reject' && sent?.body?.note === '口径和财务的对不上', JSON.stringify(sent?.body))
check('退回后提示「已退回」', await p3.getByText('已退回').first().waitFor({ timeout: 3000 }).then(() => true, () => false))
check('退回不取副本', !s3.writes.some((w) => w.path.endsWith('/copy')))
})
await section('待办箱:列表和徽标', async () => {
const page = await open({ writes: [] })
await page.goto(`${WEB}/apps?tab=inbox`)
await page.locator('[data-inbox-task]').first().waitFor({ timeout: 15000 })
check('每条待办一行,链到详情', (await page.locator('[data-inbox-task]').count()) === Object.keys(TASKS).length)
check('审核个人应用的一行写种类「审核个人应用」', (await page.locator('[data-inbox-task="review"]').innerText()).includes('审核个人应用')
&& (await page.locator('[data-inbox-task="review"]').getAttribute('data-task-kind')) === 'review')
check('「应用」导航上的徽标数应用里的待办', (await page.locator('[data-apps-badge]').innerText()).trim() === String(Object.keys(TASKS).length))
check('页签上带数', /\d/.test(await page.getByRole('tab', { name: /待办/ }).innerText()))
})
+52
View File
@@ -679,6 +679,58 @@ await section('设置 · 起草模型', async () => {
await old.close()
})
await section('设置 · 应用:使用者可以建个人应用(只有管理员能改)', async () => {
// 管理员开关 apps.user_personal_apps(docs/design/normal-user-apps.md N7):在「应用」一组,和按需视图每日上限一起整组写;
// 只有管理员能改,搭建者看得到、改不了。使用者那边的入口随认人接口的 user_personal_apps 变(check-auth 查)
let stored = await get('/settings')
stored = { ...stored, apps: { ...(stored.apps ?? {}), on_demand_daily_limit: 200, user_personal_apps: false } }
const { page, sent, errors, close } = await open('/settings/prefs', {
handlers: [
[/^GET \/settings$/, (route) => json(stored)(route)],
[/^PUT \/settings$/, (route, { body }) => { stored = { ...stored, ...body.values }; return json(stored)(route) }],
],
})
const box = page.locator('[data-apps-settings]')
await box.waitFor()
const toggle = box.locator('[data-apps-personal]')
check('「应用」一组里有「使用者可以建个人应用」,存着的是关', await toggle.count() === 1 && !(await toggle.isChecked())
&& (await box.innerText()).includes('使用者可以建个人应用'))
const hint = await page.locator('#pref-apps-personal-hint').innerText().catch(() => '')
check('……写明开了之后使用者能做什么:只有自己看得见、只读遮罩、给别人用要审核', hint.includes('只有自己看得见')
&& hint.includes('遮罩') && hint.includes('审核'), hint)
check('管理员(关登录)可以改', await toggle.isEnabled())
await toggle.check()
const bar = (await page.locator('[data-prefs-bar]').innerText().catch(() => '')).replace(/\s+/g, ' ')
check('……保存条写「使用者可以建个人应用」', bar.includes('有 1 项未保存') && bar.includes('使用者可以建个人应用'), bar)
await page.getByRole('button', { name: /保存设置/ }).click()
await page.waitForTimeout(500)
const put = sent.filter((s) => s.key === 'PUT /settings').at(-1)?.body?.values
check('保存只 PUT apps 一组:开关写成 true,每日上限原样带回', Object.keys(put ?? {}).join() === 'apps'
&& put.apps.user_personal_apps === true && put.apps.on_demand_daily_limit === 200, JSON.stringify(put))
check('……读回来还是开着', await toggle.isChecked())
check('没有运行时报错', errors.length === 0, errors[0] ?? '')
await close()
// 搭建者(开了登录):开关照服务端显示,改不了,写明只有管理员可以修改
const builder = await open('/settings/prefs', {
handlers: [
[/^GET \/auth\/me$/, json({ mode: 'local', username: 'lisi', display_name: '李四', role: 'builder', groups: [], verified: true,
user_personal_apps: true })],
[/^GET \/settings$/, (route) => json({ ...stored, apps: { ...stored.apps, user_personal_apps: true } })(route)],
],
})
const t2 = builder.page.locator('[data-apps-personal]')
await t2.waitFor()
check('搭建者:开关照服务端显示(开着),但改不了', await t2.isChecked() && await t2.isDisabled())
check('……写明只有管理员可以修改', (await builder.page.locator('[data-prefs-readonly]').innerText().catch(() => '')).includes('只有管理员'))
await t2.click({ force: true, timeout: 2000 }).catch(() => {})
await builder.page.waitForTimeout(300)
check('……点了也不变、不出保存条、不发 PUT', await t2.isChecked() && await builder.page.locator('[data-prefs-bar]').count() === 0
&& !builder.sent.some((s) => s.key === 'PUT /settings'))
check('没有运行时报错', builder.errors.length === 0, builder.errors[0] ?? '')
await builder.close()
})
await section('设置 · 点名的接入已停用或不存在', async () => {
// 点名停用的接入以前照常调用,现在运行时照实报错(K1):存着的值后来被停用了,那一格下面要说;别的停用接入列着但选不了;
// 服务端按项拒收(422 带 field)时整句落到那一格下面、焦点移过去,保存条上原因不截断。接入表、设置读写都是假的
+132 -16
View File
@@ -10,7 +10,12 @@
// 「审核中」和交给了谁,不再给提交按钮;
// - 审核状态以服务端为准(详情的 personal,形状同服务端 blueprints/personal.personal_state):审核待办还在等写「审核中」;
// 结论是已发布写「已有正式版本:去看看」并链到那个应用;
// 详情没给时看蓝图的状态(in_review 写「审核中」)。
// 详情没给时看蓝图的状态(in_review 写「审核中」);
// - 管理员关了「使用者可以建个人应用」:已有的个人应用还看得到,写明暂时不能刷新、提交审核,两个按钮置灰;
// - 开了登录的管理员看别人的个人应用(服务端 api/apps._personal_stub,my_role 为空):应用首页另列一组「使用者的个人应用」,
// 只写名称、拥有者、创建和更新时间,不链到页面、不混进「我的应用」;可以归档(确认框写后果);直接打开它的地址只给
// 元数据和「归档」,不去取页面、蓝图;
// - 别人的共享蓝图(不是拥有者、不管它发布出来的应用):服务端当没有(404),蓝图页写「不存在或已被删除」。
// 使用者在蓝图里「存成我的应用」那一步在 check-blueprint 里查。
//
// 页面是真页面(走真的应用外壳),/api/apps/**、/api/blueprints/** 在浏览器层答掉,数据是这里虚构的(「我的退款周报」);
@@ -45,20 +50,39 @@ async function section(name, fn) {
}
const ago = (ms) => new Date(Date.now() - ms).toISOString()
const USER = { mode: 'local', username: 'zhangsan', display_name: '张三', role: 'user', groups: ['门店'], verified: true }
// 认人接口:管理员开了「使用者可以建个人应用」(user_personal_apps,服务端 api/auth.principal_full)
const USER = { mode: 'local', username: 'zhangsan', display_name: '张三', role: 'user', groups: ['门店'], verified: true,
user_personal_apps: true }
const ADMIN = { mode: 'local', username: 'root', display_name: '管理员甲', role: 'admin', groups: [], verified: true,
user_personal_apps: true }
const BUILDER = { mode: 'local', username: 'lisi', display_name: '李四', role: 'builder', groups: [], verified: true,
user_personal_apps: true }
// ---------------------------------------------------------------- 假数据
// 拥有者对自己的个人应用是管理权(服务端 apps/access.personal_role),详情里带草稿和 personal
const MINE = {
id: 'app_mine', name: '我的退款周报', description: '每周看自己门店的退款', icon: 'app', status: 'active',
published_version: null, level: null, needs_upgrade: false, pending_tasks: 0, my_role: 'use', updated_at: ago(600_000),
scope: 'personal', owner: 'zhangsan', blueprint_id: 'bp_mine',
published_version: null, level: null, needs_upgrade: false, pending_tasks: 0, my_role: 'manage', created_at: ago(86_400_000),
updated_at: ago(600_000), scope: 'personal', owner: 'zhangsan', blueprint_id: 'bp_mine',
}
const SHARED = {
id: 'app_weekly', name: '门店周报', description: '每周一看各门店的销售概况', icon: 'app', status: 'active',
published_version: 3, level: 'published', needs_upgrade: false, pending_tasks: 0, my_role: 'use', updated_at: ago(3600_000),
scope: 'shared', owner: null, blueprint_id: null,
published_version: 3, level: 'published', needs_upgrade: false, pending_tasks: 0, my_role: 'use', created_at: ago(7 * 86_400_000),
updated_at: ago(3600_000), scope: 'shared', owner: null, blueprint_id: null,
}
// 管理员看到的别人的个人应用:只有元数据(服务端 api/apps._personal_stub,列表、详情、归档的回包同一份)
const STUB = {
id: 'app_mine', name: '我的退款周报', description: '', icon: 'app', status: 'active', published_version: null, level: null,
needs_upgrade: false, pending_tasks: 0, my_role: null, created_at: '2026-10-01T08:00:00+00:00',
updated_at: '2026-10-14T09:30:00+00:00', scope: 'personal', owner: 'zhangsan', blueprint_id: null,
}
/** 详情里的 personal:形状同服务端 blueprints/personal.personal_state */
const personalOf = (over = {}) => ({ allowed: true, remaining_today: 2, daily_limit: 5, blueprint_status: 'personal',
graph_changed: false, review: null, ...over })
/** personal_state.review:最近一条审核待办 */
const reviewOf = (over = {}) => ({ task_id: 't1', status: 'pending', decision: null, note: '', app_id: null, to: [],
resolved_at: null, ...over })
const STAMP = {
as_of: ago(3600_000), as_of_text: '截至 2026-10-14 09:05;统计期 上一 ISO 周', pin: 'mine', graph_hash: '9c1e22aa',
calibers: [], tier: 'formal', tier_text: '完整出具', from: '探索 · 未经发布审核', sealed: true, notes: [],
@@ -76,7 +100,10 @@ const HOME = {
forms: {},
problems: [],
}
const detailOf = (personal) => ({ ...MINE, published: null, ...(personal === undefined ? {} : { personal }) })
const DRAFT = { format: 'agentlab.app/1', name: '我的退款周报', pins: [], slots: {}, entries: [], forms: {}, task_ui: {},
views: {}, pages: [{ id: 'home', title: '本周退款', body: [] }], hooks: [], notify: [] }
const detailOf = (personal) => ({ ...MINE, published: null, draft: DRAFT, draft_bindings: { slots: {} }, created_by: 'zhangsan',
...(personal === undefined ? {} : { personal }) })
// ---------------------------------------------------------------- 浏览器
@@ -100,13 +127,23 @@ async function open({ state, width = 1280, height = 900 }) {
return next.status ? route.fulfill({ status: next.status, json: next.json }) : route.fulfill({ json: next })
}
if (p === '/api/apps/app_mine/submit-review') return route.fulfill({ json: { task_id: 'task_review_1' } })
if (p === '/api/apps/app_mine/archive') {
state.archived = true
return route.fulfill({ json: { ...STUB, status: 'archived', published: null } })
}
return route.fulfill({ status: 503, json: { detail: '检查脚本拦下了写请求' } })
}
if (p === '/api/auth/me') return route.fulfill({ json: state.me ?? USER })
if (p === '/api/tasks/count') return route.fulfill({ json: { mine: 0, apps: 0, other: 0 } })
if (p === '/api/tasks') return route.fulfill({ json: { items: [] } })
if (p === '/api/apps') return route.fulfill({ json: { items: [MINE, SHARED] } })
if (p === '/api/apps') {
// 归档了的不再列(服务端默认不列归档的)
const items = (state.apps ?? [MINE, SHARED]).filter((a) => !(state.archived && a.id === 'app_mine'))
return route.fulfill({ json: { items, can_create: { shared: false, personal: true } } })
}
if (p === '/api/apps/app_mine') return route.fulfill({ json: state.detail })
// 别人的共享蓝图:服务端当没有(blueprints/store.can_access)
if (p === '/api/blueprints/bp_other') return route.fulfill({ status: 404, json: { detail: '蓝图不存在或已被删除' } })
if (p === '/api/apps/app_mine/pages/home') {
state.pageHits = (state.pageHits ?? 0) + 1
return route.fulfill({ json: HOME })
@@ -126,7 +163,7 @@ const bar = (page) => page.locator('[data-personal-bar]')
// ---------------------------------------------------------------- 首页
await section('首页:「我的应用」单独一组,角标「个人」', async () => {
const state = { writes: [], refreshes: [], detail: detailOf({ daily_limit: 5, remaining_today: 2, review: null }) }
const state = { writes: [], refreshes: [], detail: detailOf(personalOf()) }
const { page } = await open({ state })
await page.goto(`${WEB}/apps?tab=apps`)
const group = page.locator('[data-app-group="personal"]')
@@ -145,7 +182,7 @@ await section('首页:「我的应用」单独一组,角标「个人」', as
await section('应用页:写明是个人应用、印章照服务端写、刷新写剩余次数', async () => {
const state = {
writes: [], detail: detailOf({ daily_limit: 5, remaining_today: 2, review: null }),
writes: [], detail: detailOf(personalOf()),
refreshes: [{ run_id: 'run_p2', remaining: 1 }, { run_id: 'run_p3', remaining: 0 }],
}
const { page } = await open({ state })
@@ -155,7 +192,8 @@ await section('应用页:写明是个人应用、印章照服务端写、刷
&& /未经发布审核/.test(await page.locator('[data-personal-banner]').innerText()))
check('页头角标写「个人」', (await page.locator('header [data-app-personal]').innerText()) === '个人')
check('印章的来源照服务端写「探索 · 未经发布审核」', (await page.locator('[data-stamp-row="from"]').innerText()).includes('探索 · 未经发布审核'))
check('个人应用没有「编辑」(使用者没有管理权)', await page.locator('[data-page-edit-start]').count() === 0)
check('拥有者对自己的个人应用有管理权:可以在页面上直接改', await page.locator('[data-page-edit-start]').count() === 1)
check('……开关开着:没有「暂时不能刷新」的提示', await page.locator('[data-personal-off]').count() === 0)
const left = page.locator('[data-personal-remaining]')
check('「刷新」旁写明今天还剩几次(服务端给的)', (await left.innerText()) === '今天还剩 2 次', await left.innerText())
@@ -193,7 +231,7 @@ await section('刷新:服务端回 429 时照实说原话、按钮置灰;不
// ---------------------------------------------------------------- 提交审核
await section('提交审核:选人才能交、带说明;提交后写「审核中」', async () => {
const state = { writes: [], refreshes: [], detail: detailOf({ daily_limit: 5, remaining_today: 5, review: null }) }
const state = { writes: [], refreshes: [], detail: detailOf(personalOf({ remaining_today: 5 })) }
const { page } = await open({ state })
await page.goto(`${WEB}/apps/app_mine`)
await bar(page).waitFor({ timeout: 15000 })
@@ -219,15 +257,16 @@ await section('提交审核:选人才能交、带说明;提交后写「审
})
await section('审核状态:以服务端为准(审核中、已有正式版本、详情没给时看蓝图)', async () => {
const a = { writes: [], refreshes: [], detail: detailOf({ remaining_today: 3, review: { task_id: 't1', status: 'pending', to: [{ kind: 'group', name: '搭建组' }] } }) }
const a = { writes: [], refreshes: [], detail: detailOf(personalOf({ remaining_today: 3, blueprint_status: 'in_review',
review: reviewOf({ to: [{ kind: 'group', name: '搭建组' }] }) })) }
const one = await open({ state: a })
await one.page.goto(`${WEB}/apps/app_mine`)
await bar(one.page).waitFor({ timeout: 15000 })
check('详情说审核中:写「审核中」和交给的组', /已提交给 搭建组 审核/.test(await one.page.locator('[data-personal-review="in_review"]').innerText()))
check('……没有「提交审核」', await one.page.locator('[data-personal-submit-review]').count() === 0)
const b = { writes: [], refreshes: [], detail: detailOf({ remaining_today: 3,
review: { task_id: 't1', status: 'resolved', decision: 'published', app_id: 'app_refund' } }) }
const b = { writes: [], refreshes: [], detail: detailOf(personalOf({ remaining_today: 3,
review: reviewOf({ status: 'resolved', decision: 'published', app_id: 'app_refund', resolved_at: ago(600_000) }) })) }
const two = await open({ state: b })
await two.page.goto(`${WEB}/apps/app_mine`)
await bar(two.page).waitFor({ timeout: 15000 })
@@ -246,8 +285,85 @@ await section('审核状态:以服务端为准(审核中、已有正式版
[one, two, three].map((x) => x.page.errors[0]).filter(Boolean).join(' | '))
})
await section('审核退回:写退回和原因,可以再提交', async () => {
const state = { writes: [], refreshes: [], detail: detailOf(personalOf({
review: reviewOf({ status: 'resolved', decision: 'reject', note: '口径和财务的对不上', resolved_at: ago(600_000) }) })) }
const { page } = await open({ state })
await page.goto(`${WEB}/apps/app_mine`)
await bar(page).waitFor({ timeout: 15000 })
const rejected = page.locator('[data-personal-review="rejected"]')
check('退回了:写退回和原因', (await rejected.innerText().catch(() => '')).includes('口径和财务的对不上'), await rejected.innerText().catch(() => ''))
check('……可以再提交', await page.locator('[data-personal-submit-review]').isEnabled())
check('没有未捕获的运行时错误', page.errors.length === 0, page.errors[0] ?? '')
})
await section('开关关了:已有的个人应用还看得到,暂时不能刷新、提交审核', async () => {
// 能不能刷新、提交审核由服务端在详情里给(personal_state.allowed,同 blueprints/personal.allowed),界面不按开关自己推
const state = { me: { ...USER, user_personal_apps: false }, writes: [], refreshes: [],
detail: detailOf(personalOf({ allowed: false })) }
const { page } = await open({ state })
await page.goto(`${WEB}/apps/app_mine`)
await bar(page).waitFor({ timeout: 15000 })
check('写明管理员关了开关、暂时不能刷新和提交审核', (await page.locator('[data-personal-off]').innerText().catch(() => ''))
.includes('暂时不能刷新、提交审核'))
check('……「刷新」「提交审核」置灰', await page.locator('[data-personal-refresh]').isDisabled()
&& await page.locator('[data-personal-submit-review]').isDisabled())
check('……页面照常显示', await page.locator('[data-component="metric"], [data-node="refund"]').count() >= 1)
check('没有未捕获的运行时错误', page.errors.length === 0, page.errors[0] ?? '')
})
await section('管理员看别人的个人应用:只有名称、拥有者、时间,可以归档', async () => {
const state = { me: ADMIN, apps: [STUB, { ...SHARED, my_role: 'manage' }], writes: [], refreshes: [],
detail: { ...STUB, published: null } }
const { page } = await open({ state })
await page.goto(`${WEB}/apps?tab=apps`)
const group = page.locator('[data-app-group="others"]')
check('另列一组「使用者的个人应用」', await group.waitFor({ timeout: 15000 }).then(() => true, () => false)
&& (await group.locator('h2').innerText()) === '使用者的个人应用')
check('……写明只看得到名称、拥有者和时间,看不到内容', (await group.innerText()).includes('看不到页面、运行和蓝图的内容'))
const card = group.locator('[data-app-stub="app_mine"]')
const text = (await card.innerText()).replace(/\s+/g, ' ')
check('卡片写名称、拥有者、创建和更新时间', text.includes('我的退款周报') && text.includes('拥有者:zhangsan')
&& await card.locator('[data-app-stub-created]').count() === 1 && await card.locator('[data-app-stub-updated]').count() === 1, text)
check('……不链到应用页(看不到内容)', await card.locator('a[href="/apps/app_mine"]').count() === 0)
check('……不混进「我的应用」', await page.locator('[data-app-group="personal"]').count() === 0)
await card.locator('[data-app-stub-archive]').click()
const dlg = page.getByRole('dialog')
await dlg.waitFor({ timeout: 5000 })
const said = await dlg.innerText()
check('归档先确认,写明后果(拥有者不能再刷新、没有恢复)', said.includes('不能再刷新或提交审核') && said.includes('没有恢复'), said.replace(/\s+/g, ' '))
await dlg.getByRole('button', { name: '归档个人应用' }).click()
await page.waitForTimeout(400)
check('确认后 POST /archive', state.writes.some((w) => w.path === '/api/apps/app_mine/archive'))
check('……归档之后列表里不再有它', await page.locator('[data-app-stub="app_mine"]').waitFor({ state: 'detached', timeout: 5000 })
.then(() => true, () => false))
check('没有未捕获的运行时错误', page.errors.length === 0, page.errors[0] ?? '')
// 直接打开它的地址:只给元数据和「归档」,不去取页面、蓝图
const s2 = { ...state, archived: false, writes: [], pageHits: 0, blueprintHits: 0 }
const two = await open({ state: s2 })
await two.page.goto(`${WEB}/apps/app_mine`)
const stubPage = two.page.locator('[data-app-stub-page="app_mine"]')
check('打开地址:写明这是谁的个人应用、看不到内容', await stubPage.waitFor({ timeout: 15000 }).then(() => true, () => false)
&& (await stubPage.locator('[data-app-stub-notice]').innerText()).includes('这是 zhangsan 的个人应用'))
check('……给「归档」', await stubPage.locator('[data-app-stub-archive]').count() === 1)
await two.page.waitForTimeout(500)
check('……不去取页面、蓝图', s2.pageHits === 0 && s2.blueprintHits === 0, `页面 ${s2.pageHits} 次、蓝图 ${s2.blueprintHits} 次`)
check('……没有刷新、提交审核', await two.page.locator('[data-personal-refresh], [data-personal-submit-review]').count() === 0)
check('没有未捕获的运行时错误', two.page.errors.length === 0, two.page.errors[0] ?? '')
})
await section('别人的共享蓝图:服务端当没有,蓝图页写「不存在或已被删除」', async () => {
const state = { me: BUILDER, writes: [], refreshes: [], detail: detailOf(personalOf()) }
const { page } = await open({ state })
await page.goto(`${WEB}/blueprints/bp_other`)
check('写「蓝图不存在或已被删除」,不说它在不在', await page.getByText('蓝图不存在或已被删除').first().waitFor({ timeout: 15000 })
.then(() => true, () => false))
check('没有未捕获的运行时错误', page.errors.length === 0, page.errors[0] ?? '')
})
await section('窄屏:390px 不出横向滚动', async () => {
const state = { writes: [], refreshes: [], detail: detailOf({ remaining_today: 2, review: null }) }
const state = { writes: [], refreshes: [], detail: detailOf(personalOf()) }
const { page } = await open({ state, width: 390, height: 844 })
for (const path of ['/apps?tab=apps', '/apps/app_mine']) {
await page.goto(`${WEB}${path}`)
+12 -3
View File
@@ -65,9 +65,12 @@ const HOME = {
{ id: 'my_records', type: 'records_list', props: { table: 'loss', title: '报损登记' } },
{ id: 'no_table', type: 'records_list', props: {} },
{ id: 'server_page', type: 'records_list', props: { who: 'mine' },
data: { table: 'loss', version: 1, columns: COLUMNS_FIRST, mine_only: true, items: [] } },
data: { table: 'loss', version: 1, columns: COLUMNS_FIRST, mine_only: true, items: [], next_cursor: null, state: 'ok' } },
{ id: 'empty_page', type: 'records_list', props: {},
data: { table: null, version: 0, columns: [], items: [], mine_only: true, state: 'empty', next_cursor: null } },
// 按草稿预览(服务端 apps/resolve._records_list):不列真实的记录
{ id: 'preview_page', type: 'records_list', props: {},
data: { table: null, version: 0, columns: [], items: [], mine_only: false, state: 'preview', next_cursor: null } },
],
forms: {},
problems: [],
@@ -79,8 +82,10 @@ const COLUMNS = [
{ name: 'photo', label: '照片', type: 'file' },
]
const PHOTO = { file_id: 'f_photo', name: '破损.png', size: 20480, sha256: 'ab'.repeat(32), type: 'image/png' }
// 一行的形状同服务端 records/listing.py:系统列(records.SYSTEM_COLUMNS)加上这一行在第几版表里(_version)
const row = (id, seq, by, values, extra = {}) => ({
_id: id, _seq: seq, _submitted_by: by, _submitted_at: at(seq), _supersedes: null, values, ...extra,
_id: id, _seq: seq, _submitted_by: by, _submitted_at: at(seq), _run_id: `run_${id}`, _supersedes: null, _schema_version: 1,
_version: 1, superseded: false, values, ...extra,
})
/** zhangsan 的三条:r3 是对 r1 的更正(r1 被更正过,只在历史里) */
const MINE_ROWS = [
@@ -120,7 +125,7 @@ async function open({ state, width = 1280, height = 900 }) {
const items = mine ? MINE_ROWS : ALL_ROWS
const history = url.searchParams.get('history') === 'true' || url.searchParams.get('history') === '1'
return route.fulfill({ json: { table: 'loss', version: 1, columns: COLUMNS, mine_only: mine,
items: history ? [R1, ...items] : items } })
items: history ? [R1, ...items] : items, next_cursor: null, state: 'ok' } })
}
return route.continue()
})
@@ -167,6 +172,10 @@ await section('我的记录:使用者只看到自己的、标题写「我的
check('服务端给了第一页的照它画(这里是空的,写「你还没有提交过记录」)',
(await page.locator('[data-node="server_page"]').innerText()).includes('你还没有提交过记录'))
check('服务端说还没有记录表的照实说', (await page.locator('[data-node="empty_page"]').innerText()).includes('还没有记录表'))
const preview = page.locator('[data-node="preview_page"]')
check('按草稿预览(state=preview):照实说不列真实的记录,不说成「还没有记录表」,也不给重新读取',
await preview.locator('[data-records-preview]').count() === 1 && !(await preview.innerText()).includes('还没有记录表')
&& await preview.getByRole('button', { name: '重新读取' }).count() === 0, (await preview.innerText()).replace(/\s+/g, ' '))
check('只读:没有发出写请求', state.writes.length === 0, state.writes.map((w) => w.path).join(','))
check('没有未捕获的运行时错误', page.errors.length === 0, page.errors[0] ?? '')
})