From 1895e3e452786bb7f1f2007114f46bca8d68ace7 Mon Sep 17 00:00:00 2001 From: Eclipseic1848 <237380389+Eclipseic1848@users.noreply.github.com> Date: Thu, 3 Sep 2026 08:27:51 -0700 Subject: [PATCH 1/3] fix: close audit and frontend UX gaps Align receivable review constraints and governance pagination. Improve form safety, refresh states, drilldowns, navigation, and accessibility. Refs #140, #141, #142, #143, #144, #145, #146, #147, #148, #149, #150, #151, #152. --- DESIGN.md | 2 +- README.md | 2 +- UX-CONTRACT.md | 7 +- ...28_allow_receivable_historical_reviews.sql | 13 + apps/api/src/audit-query.integration.test.ts | 5 +- .../api/src/authorization.integration.test.ts | 4 +- .../src/backup-restore.integration.test.ts | 2 +- .../src/ledger-governance.integration.test.ts | 111 ++++ apps/api/src/modules/accounting-periods.ts | 100 ++-- apps/api/src/modules/admin.ts | 2 +- apps/api/src/modules/audits.ts | 2 +- apps/api/src/modules/performance.ts | 4 +- .../performance-analysis.integration.test.ts | 4 +- .../api/src/test-database.integration.test.ts | 1 + apps/web/e2e/accessibility.spec.ts | 33 +- apps/web/e2e/accounts.spec.ts | 3 +- apps/web/e2e/analysis.spec.ts | 70 ++- apps/web/e2e/audits.spec.ts | 35 +- apps/web/e2e/desktop-acceptance.spec.ts | 28 +- apps/web/e2e/goal-roles.spec.ts | 2 +- apps/web/e2e/login.spec.ts | 135 +++-- apps/web/e2e/money-inputs.spec.ts | 2 +- apps/web/e2e/onboarding.spec.ts | 32 +- apps/web/e2e/routes.spec.ts | 20 +- apps/web/src/App.tsx | 5 +- apps/web/src/app-api.ts | 10 +- apps/web/src/app-types.ts | 2 +- apps/web/src/dashboard.tsx | 3 +- apps/web/src/onboarding.tsx | 43 +- apps/web/src/pages/accounts-page.tsx | 15 +- apps/web/src/pages/analysis-page.tsx | 33 +- apps/web/src/pages/audit-page.tsx | 9 +- apps/web/src/pages/goal-workspace.tsx | 24 +- apps/web/src/pages/orders-page.tsx | 67 ++- apps/web/src/pages/organization-page.tsx | 16 +- apps/web/src/pages/overview-page.tsx | 28 +- apps/web/src/shared-ui.tsx | 6 +- apps/web/src/styles.css | 127 ++-- docs/specs/p1-product-closure.md | 4 +- docs/specs/p1-ticket-breakdown.md | 6 +- docs/v1-scope.md | 2 +- handoff.md | 555 +++++++++++++----- 42 files changed, 1149 insertions(+), 425 deletions(-) create mode 100644 apps/api/migrations/028_allow_receivable_historical_reviews.sql diff --git a/DESIGN.md b/DESIGN.md index 041c668..5d27fab 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -87,7 +87,7 @@ components: ### Navigation and data display -侧栏保持现有深蓝语汇。台账使用原生表格、明确列名和横向滚动;订单编号是识别列。事件详情按服务端稳定序号展示发生日、操作时刻、差额、投影状态、原因、操作者和组织快照;历史原始行明确标注状态未推断。记账治理工作台以核对、关账、更正和历史审查三类并列区域呈现,不把职责分离隐藏在通用操作中。 +侧栏保持现有深蓝语汇。订单台账优先展示订单、客户、区域、负责人、金额与状态,次要订单资料进入“查看 / 调整”详情;订单编号保持识别列,窄屏仍允许横向滚动。事件详情按服务端稳定序号展示发生日、操作时刻、差额、投影状态、原因、操作者和组织快照;历史原始行明确标注状态未推断。记账治理工作台以独立摘要区隔核对、关账、更正和历史审查,并按任务展开表单,不改变职责分离。 ### Forms and overlays diff --git a/README.md b/README.md index dd3f5e1..b97211a 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ SampleFlow 是面向销售到样业务的业绩与目标管理 Web 系统。它 - 系统账号、首次改密、会话安全和角色权限矩阵。 - 部门、小组、人员身份和带有效期的组织任职。 - 以“分子”Sheet3 字段为准的订单台账与只追加、不覆盖的业绩事件链;关键识别/操作列固定,中间字段可横向滚动;负数系统营业额明确标记为“应收未收”。 -- 所有数据表格和可增长业务清单统一分页:默认 20 条,可选 10/20/50/100 条,并可直接点击页码;订单、审计和分析穿透使用 URL 快照保持跨页结果稳定。 +- 所有数据表格和可增长业务清单统一分页:默认 10 条,可选 10/20/50/100 条,并可直接点击页码;订单、审计和分析穿透使用 URL 快照保持跨页结果稳定。 - 按事件发生日期固化人员及组织快照,保留调组前后的历史归属。 - 省份与客户单位分析支持按月、按年汇总和穿透,未取得来源证据的维度明确显示为待补齐。 - 分层目标下达、责任人实名确认、总经理/人事审批和修改申请。 diff --git a/UX-CONTRACT.md b/UX-CONTRACT.md index 355ac53..cf3fa9d 100644 --- a/UX-CONTRACT.md +++ b/UX-CONTRACT.md @@ -58,7 +58,7 @@ - Exploratory lists: 省份、总览指标等有限集合也复用统一分页控件,不另建局部分页交互。 - URL state: 页面、已提交筛选、页码、每页条数和服务端不透明快照令牌写入查询参数;History API 必须支持刷新及前进/后退恢复。 - Stable snapshot: 订单、审计和分析穿透的首次页码查询返回绑定用户、筛选和 cutoff 的快照;后续翻页复用,筛选、每页条数或主动刷新时清空并重新创建。旧游标 API 仅保留兼容。 -- Page size: 默认 20 条,可选 10/20/50/100 条;显示精确总数、总页数、当前页、上一页/下一页和可点击页码。 +- Page size: 默认 10 条,可选 10/20/50/100 条;显示精确总数、总页数、当前页、上一页/下一页和可点击页码。 - Empty/no-results/error/loading treatment: 分别说明“暂无数据”“没有匹配结果”“加载失败可刷新”;后台刷新保留既有结果。 - Back/scroll restoration: 筛选、分页和快照由 URL 恢复;页面保留自然文档滚动。 - Selection scope: 当前不提供批量选择。 @@ -84,9 +84,10 @@ - Route document title policy: 八个主要页面使用稳定 URL,并采用“页面 — SampleFlow”。 - Route error / 403 page behavior: API 403 显示明确权限边界,不把无权限伪装为空数据。 - Breadcrumb/tab/route-state policy: 当前侧栏驱动稳定 URL;筛选、分页和穿透状态按页面合同写入查询参数。 +- Analysis drilldown overlay: 省份列表和地图以同一共享 `Modal` 打开省份客户明细;“查看月份”和订单事件在该弹窗内继续钻取,不嵌套第二个弹窗;关闭恢复原省份触发点,刷新及前进/后退仍由 URL 恢复。 - Sidebar/drawer/bottom-sheet transformation: 当前产品只验收桌面 Web;不把移动端抽屉或底部操作区作为发布门禁。 -- Responsive table strategy: 保留原生表格和横向滚动,不静默丢列。 -- Truncation/full-value access: 关键订单号、原因和事件值允许换行或进入详情查看。 +- Responsive table strategy: 保留原生表格和横向滚动;订单台账只在主表展示高频字段,完整订单资料由同一行的“查看 / 调整”详情持续可达。 +- Truncation/full-value access: 关键订单号、原因和事件值允许换行或进入详情查看;从订单台账收纳的服务类型、备注、协作分配和责任快照必须在详情中完整呈现。 - Focus restoration and sticky-obstruction policy: 模态关闭回到触发点;焦点不得被侧栏或模态标题遮挡。 ## Overlays and feedback diff --git a/apps/api/migrations/028_allow_receivable_historical_reviews.sql b/apps/api/migrations/028_allow_receivable_historical_reviews.sql new file mode 100644 index 0000000..ac37035 --- /dev/null +++ b/apps/api/migrations/028_allow_receivable_historical_reviews.sql @@ -0,0 +1,13 @@ +alter table historical_order_reviews + drop constraint historical_order_reviews_proposed_lifecycle_state_check, + drop constraint historical_order_reviews_proposed_current_revenue_check, + drop constraint historical_review_state_revenue; + +alter table historical_order_reviews + add constraint historical_review_lifecycle_state_check + check (proposed_lifecycle_state in ('active','paused','zero','receivable_pending')), + add constraint historical_review_state_revenue check ( + (proposed_lifecycle_state='zero' and proposed_current_revenue=0) + or (proposed_lifecycle_state in ('active','paused') and proposed_current_revenue>0) + or (proposed_lifecycle_state='receivable_pending' and proposed_current_revenue<0) + ); diff --git a/apps/api/src/audit-query.integration.test.ts b/apps/api/src/audit-query.integration.test.ts index cd52eca..f42cb4e 100644 --- a/apps/api/src/audit-query.integration.test.ts +++ b/apps/api/src/audit-query.integration.test.ts @@ -223,7 +223,7 @@ test("审计查询支持人员、动作、实体、时间和稳定游标过滤", const inFlightId = inFlight.rows[0]!.id; const firstPageResult = await Promise.race([ app.inject({ method: "GET", url: "/api/audits?action=performance.cursor_test", headers: { cookie } }), - new Promise((resolve) => setTimeout(() => resolve(null), 1_000)), + new Promise((resolve) => setTimeout(() => resolve(null), 10_000)), ]); await inFlightClient.query("commit"); await inFlightClient.end(); @@ -246,8 +246,9 @@ test("审计查询支持人员、动作、实体、时间和稳定游标过滤", } finally { await concurrentClient.end(); } - const numberedFirst = await app.inject({ method: "GET", url: "/api/audits?action=performance.cursor_test&page=1&pageSize=10", headers: { cookie } }); + const numberedFirst = await app.inject({ method: "GET", url: "/api/audits?action=performance.cursor_test&page=1", headers: { cookie } }); assert.equal(numberedFirst.statusCode, 200, numberedFirst.body); + assert.equal(numberedFirst.json().pageSize,10); const numberedSnapshot=numberedFirst.json<{snapshot:string}>().snapshot; assert.ok(numberedSnapshot); const postSnapshotClient=new Client({connectionString:database.url}); diff --git a/apps/api/src/authorization.integration.test.ts b/apps/api/src/authorization.integration.test.ts index 10cc117..103ec13 100644 --- a/apps/api/src/authorization.integration.test.ts +++ b/apps/api/src/authorization.integration.test.ts @@ -1259,7 +1259,7 @@ test("账号管理使用稳定搜索分页并审计固定角色组合变更", as ); await setup.query("insert into user_roles(user_id,role_code,assigned_by) values($1,'salesperson',$2)", [late.rows[0]!.id, scenario.users.admin]); - const numbered = await app.inject({ method: "GET", url: `/api/admin/users?search=${search}&page=2&pageSize=10`, headers: adminHeaders }); + const numbered = await app.inject({ method: "GET", url: `/api/admin/users?search=${search}&page=2`, headers: adminHeaders }); assert.equal(numbered.statusCode, 200, numbered.body); assert.equal(numbered.json().page, 2); assert.equal(numbered.json().pageSize, 10); @@ -1451,7 +1451,7 @@ test("订单台账用固定快照稳定遍历并保持有界查询次数", async assert.equal(first.body.previousCursor, null); assert.ok(first.body.nextCursor); - const numbered = await app.inject({ method: "GET", url: "/api/performance/orders?search=CURSOR-FIX-&page=2&pageSize=10", headers: { cookie: leaderCookie } }); + const numbered = await app.inject({ method: "GET", url: "/api/performance/orders?search=CURSOR-FIX-&page=2", headers: { cookie: leaderCookie } }); assert.equal(numbered.statusCode, 200, numbered.body); assert.equal(numbered.json().page, 2); assert.equal(numbered.json().pageSize, 10); diff --git a/apps/api/src/backup-restore.integration.test.ts b/apps/api/src/backup-restore.integration.test.ts index ac5cf46..f9c21ed 100644 --- a/apps/api/src/backup-restore.integration.test.ts +++ b/apps/api/src/backup-restore.integration.test.ts @@ -179,7 +179,7 @@ test("自定义格式备份只恢复到显式新库且保持来源与恢复摘 SOURCE_DB_NAME: targetName, }, backupDirectory); assert.equal(targetSummary.stdout, sourceSummary.stdout); - assert.match(sourceSummary.stdout, /^schema_migrations\|27\|[a-f0-9]{32}$/m); + assert.match(sourceSummary.stdout, /^schema_migrations\|28\|[a-f0-9]{32}$/m); assert.match(sourceSummary.stdout, /^users\|1\|[a-f0-9]{32}$/m); const appDatabaseUrl = roleUrl(targetUrl.toString(), roles.app, roles.appPassword); diff --git a/apps/api/src/ledger-governance.integration.test.ts b/apps/api/src/ledger-governance.integration.test.ts index 2b34b04..9cc2f2b 100644 --- a/apps/api/src/ledger-governance.integration.test.ts +++ b/apps/api/src/ledger-governance.integration.test.ts @@ -367,6 +367,55 @@ test("更正申请被拒绝、撤销或批准超过二十四小时后均不能 }); }); +test("记账治理列表默认每页十条且后续页可访问",async()=>{ + await withMigratedTestDatabase(async(database)=>{ + const scenario=await seedLedgerScenario(database.url); + const client=new Client({connectionString:database.url});await client.connect(); + try{ + const requester=await client.query<{user_id:string}>("select user_id::text from people where id=$1",[scenario.assistantLeaderPersonId]); + const order=await client.query<{id:string}>( + `insert into performance_orders + (qingflow_order_no,customer_name,customer_unit,salesperson_person_id,salesperson_name,source_received_on, + original_amount,current_revenue,counted_amount,lifecycle_state,posted_at) + values('GOVERNANCE-PAGE','分页客户','分页单位',$1,'账本业务员','2025-01-01',1,1,1,'active',now()) returning id::text`, + [scenario.memberPersonId], + ); + await client.query("insert into accounting_periods(period_month) select month::date from generate_series('2025-01-01'::date,'2025-12-01'::date,'1 month') month"); + await client.query( + `insert into accounting_correction_requests + (period_month,order_id,event_type,occurred_on,reason,business_region_code,business_region_source_text, + customer_unit,analysis_dimension_evidence,requested_by_user_id,requested_by_person_id,requested_at,status) + select '2025-01-01',$1,'revenue_change','2025-01-15','分页更正 '||value,'CN-AH','安徽分页凭证', + '分页单位','分页证据',$2,$3,now()-value*interval '1 minute','rejected' + from generate_series(1,12) as series(value)`, + [order.rows[0]!.id,requester.rows[0]!.user_id,scenario.assistantLeaderPersonId], + ); + await client.query( + `insert into historical_order_reviews + (order_id,proposed_lifecycle_state,proposed_current_revenue,conclusion,evidence,reason, + requested_by_user_id,requested_by_person_id,requested_at,status) + select $1,'active',1,'分页核对 '||value,'分页证据','分页原因',$2,$3,now()-value*interval '1 minute','rejected' + from generate_series(1,12) as series(value)`, + [order.rows[0]!.id,requester.rows[0]!.user_id,scenario.assistantLeaderPersonId], + ); + }finally{await client.end();} + + await withTestApi(database.url,async(app)=>{ + const headers=await writeHeaders(app,"ledger_assistant_leader"); + for(const [path,key] of [["/api/accounting-periods","periods"],["/api/accounting-corrections","corrections"],["/api/historical-order-reviews","reviews"]] as const){ + const first=await app.inject({method:"GET",url:path,headers:{cookie:headers.cookie}}); + assert.equal(first.statusCode,200,first.body); + assert.equal(first.json().page,1);assert.equal(first.json().pageSize,10);assert.equal(first.json().totalCount,12);assert.equal(first.json()[key].length,10); + const second=await app.inject({method:"GET",url:`${path}?page=2&pageSize=10`,headers:{cookie:headers.cookie}}); + assert.equal(second.statusCode,200,second.body); + assert.equal(second.json().page,2);assert.equal(second.json()[key].length,2); + const invalid=await app.inject({method:"GET",url:`${path}?pageSize=15`,headers:{cookie:headers.cookie}}); + assert.equal(invalid.statusCode,400,invalid.body); + } + }); + }); +}); + test("历史待核订单保留原始事件语义并经组长核对和人事批准后解锁",async()=>{ await withMigratedTestDatabase(async(database)=>{ const scenario=await seedLedgerScenario(database.url); @@ -452,3 +501,65 @@ test("历史待核订单保留原始事件语义并经组长核对和人事批 },{clock:()=>now}); }); }); + +test("协作历史订单可核对为负数应收未收且保留双方归属",async()=>{ + await withMigratedTestDatabase(async(database)=>{ + const scenario=await seedLedgerScenario(database.url); + const client=new Client({connectionString:database.url});await client.connect(); + let orderId=""; + try{ + const order=await client.query<{id:string}>( + `insert into performance_orders + (qingflow_order_no,customer_name,customer_unit,business_region_source_text,business_region_code, + salesperson_person_id,salesperson_name,collaborator_person_id,collaborator_name,collaboration_ratio, + source_received_on,original_amount,current_revenue,counted_amount,lifecycle_state,posted_at) + values('LEGACY-RECEIVABLE-COLLAB','历史协作客户','协作单位','安徽原文','CN-AH', + $1,'账本业务员',$2,'账本协作人',0.25,'2026-08-01',-50,-50,-50,'historical_review_required',now()) + returning id::text`,[scenario.memberPersonId,scenario.collaboratorPersonId], + ); + orderId=order.rows[0]!.id; + await client.query( + `insert into performance_events + (order_id,event_type,delta_amount,resulting_current_revenue,resulting_counted_amount,accounting_month,occurred_on, + reason,salesperson_name,department_name,group_name,leader_name,supervisor_name,source_row_number, + salesperson_person_id,department_unit_id,group_unit_id,leader_person_id,supervisor_person_id,order_sequence) + values($1,'legacy_adjustment',-50,-50,-50,'2026-08-01','2026-08-01','原始负向行', + '账本业务员','账本部门','账本小组','账本组长','账本主管',91001,$2,$3,$4,$5,$6,1)`, + [orderId,scenario.memberPersonId,scenario.departmentId,scenario.groupId,scenario.leaderPersonId,scenario.supervisorPersonId], + ); + }finally{await client.end();} + + await withTestApi(database.url,async(app)=>{ + const leader=await writeHeaders(app,"ledger_assistant_leader"); + const hr=await writeHeaders(app,"ledger_hr"); + const requested=await app.inject({ + method:"POST",url:"/api/historical-order-reviews",headers:leader, + payload:{orderId,lifecycleState:"receivable_pending",currentRevenue:-80,conclusion:"核对为应收未收",evidence:"原始订单及应收凭证",reason:"确认仍有八十元未收"}, + }); + assert.equal(requested.statusCode,201,requested.body); + const reviewId=requested.json().id as string; + const approved=await app.inject({method:"POST",url:`/api/historical-order-reviews/${reviewId}/approve`,headers:hr,payload:{note:"证据完整,同意解析"}}); + assert.equal(approved.statusCode,200,approved.body); + + const after=await app.inject({method:"GET",url:`/api/performance/orders/${orderId}/events`,headers:{cookie:leader.cookie}}); + assert.equal(after.statusCode,200,after.body); + assert.equal(after.json().lifecycleState,"receivable_pending"); + assert.deepEqual(after.json().allowedActions,["revenue_change"]); + const resolution=after.json().events.at(-1); + assert.equal(resolution.deltaAmount,"-30.00"); + assert.equal(resolution.collaboratorName,"账本协作人"); + assert.equal(resolution.collaborationRatio,"0.250000"); + + const verify=new Client({connectionString:database.url});await verify.connect(); + try{ + const order=await verify.query<{current_revenue:string;counted_amount:string;lifecycle_state:string}>("select current_revenue::text,counted_amount::text,lifecycle_state from performance_orders where id=$1",[orderId]); + assert.deepEqual(order.rows[0],{current_revenue:"-80.00",counted_amount:"-80.00",lifecycle_state:"receivable_pending"}); + const attributions=await verify.query<{attribution_role:string;amount:string}>( + `select attribution_role,attributed_amount::text as amount from performance_event_attributions attribution + join historical_order_reviews review on review.resolution_event_id=attribution.event_id where review.id=$1 order by attribution_role`,[reviewId], + ); + assert.deepEqual(attributions.rows,[{attribution_role:"collaborator",amount:"-7.50"},{attribution_role:"primary",amount:"-22.50"}]); + }finally{await verify.end();} + },{clock:()=>new Date("2026-09-12T02:00:00.000Z")}); + }); +}); diff --git a/apps/api/src/modules/accounting-periods.ts b/apps/api/src/modules/accounting-periods.ts index b96e6d6..a984a7d 100644 --- a/apps/api/src/modules/accounting-periods.ts +++ b/apps/api/src/modules/accounting-periods.ts @@ -4,7 +4,7 @@ import { z } from "zod"; import type { Database } from "../db.js"; import { standardBusinessRegionName } from "../domain/business-regions.js"; import { businessDate } from "../domain/business-time.js"; -import { nonnegativeMoneySchema, postgresBigintIdSchema } from "../validation.js"; +import { pageNumberSchema, pageSizeSchema, postgresBigintIdSchema, signedMoneySchema } from "../validation.js"; import { hasAnyRole, type CurrentUser } from "./auth.js"; import { recordEventAnalysisDimensions } from "./event-analysis-dimensions.js"; import { OrganizationResolutionError, resolveOrganization } from "./organization.js"; @@ -23,14 +23,17 @@ const correctionSchema = z.strictObject({ analysisDimensionEvidence: z.string().trim().min(1).max(1000), }); const idSchema = z.object({ id: postgresBigintIdSchema }); -const correctionListSchema = z.object({ +const listPaginationSchema = z.object({ + page: pageNumberSchema.default(1), + pageSize: pageSizeSchema.default(10), +}); +const correctionListSchema = listPaginationSchema.extend({ status: z.enum(["pending", "approved", "rejected", "consumed", "revoked"]).optional(), - limit: z.coerce.number().int().min(1).max(100).default(30), }); const reviewSchema = z.strictObject({ orderId: postgresBigintIdSchema, - lifecycleState: z.enum(["active", "paused", "zero"]), - currentRevenue: nonnegativeMoneySchema, + lifecycleState: z.enum(["active", "paused", "zero", "receivable_pending"]), + currentRevenue: signedMoneySchema, conclusion: z.string().trim().min(1).max(500), evidence: z.string().trim().min(1).max(1000), reason: z.string().trim().min(1).max(500), @@ -38,13 +41,15 @@ const reviewSchema = z.strictObject({ if (value.lifecycleState === "zero" && value.currentRevenue !== 0) { context.addIssue({ code: "custom", message: "零金额状态的当前营业额必须为零", path: ["currentRevenue"] }); } - if (value.lifecycleState !== "zero" && value.currentRevenue <= 0) { + if (["active", "paused"].includes(value.lifecycleState) && value.currentRevenue <= 0) { context.addIssue({ code: "custom", message: "活动或暂停状态的当前营业额必须大于零", path: ["currentRevenue"] }); } + if (value.lifecycleState === "receivable_pending" && value.currentRevenue >= 0) { + context.addIssue({ code: "custom", message: "应收未收状态的当前营业额必须小于零", path: ["currentRevenue"] }); + } }); -const reviewListSchema = z.object({ +const reviewListSchema = listPaginationSchema.extend({ status: z.enum(["pending", "approved", "rejected"]).optional(), - limit: z.coerce.number().int().min(1).max(100).default(30), }); export class AccountingPeriodError extends Error {} @@ -153,19 +158,20 @@ export async function registerAccountingPeriods(app: FastifyInstance, db: Databa app.get("/api/accounting-periods", async (request, reply) => { const denied = requireRole(request.currentUser, ["sales_assistant_leader", "hr"], "仅销售助理组长或人事可查看记账期间"); if (denied) return reply.code(denied.statusCode).send({ message: denied.message }); - const parsed = z.object({ limit: z.coerce.number().int().min(1).max(36).default(18) }).safeParse(request.query); + const parsed = listPaginationSchema.safeParse(request.query); if (!parsed.success) return reply.code(400).send({ message: "查询条件无效" }); - const result = await db.query( + const { page, pageSize } = parsed.data; + const [result, count] = await Promise.all([db.query( `select period.period_month::text as "periodMonth",period.status,period.version, period.needs_reclose as "needsReclose",period.verification_confirmed_at as "verifiedAt", verifier.display_name as "verifiedBy",period.closed_at as "closedAt",closer.display_name as "closedBy" from accounting_periods period left join people verifier on verifier.id=period.verification_confirmed_by_person_id left join people closer on closer.id=period.closed_by_person_id - order by period.period_month desc limit $1`, - [parsed.data.limit], - ); - return { periods: result.rows }; + order by period.period_month desc limit $1 offset $2`, + [pageSize, (page - 1) * pageSize], + ), db.query<{ total_count:string }>("select count(*)::text as total_count from accounting_periods")]); + return { periods: result.rows, page, pageSize, totalCount: Number(count.rows[0]!.total_count) }; }); app.get("/api/accounting-corrections", async (request, reply) => { @@ -173,7 +179,8 @@ export async function registerAccountingPeriods(app: FastifyInstance, db: Databa if (denied) return reply.code(denied.statusCode).send({ message: denied.message }); const parsed = correctionListSchema.safeParse(request.query); if (!parsed.success) return reply.code(400).send({ message: "查询条件无效" }); - const result = await db.query( + const { page, pageSize } = parsed.data; + const [result, count] = await Promise.all([db.query( `select request_row.id::text,request_row.period_month::text as "periodMonth",request_row.order_id::text as "orderId", orders.qingflow_order_no as "orderNo",request_row.event_type as "eventType", request_row.occurred_on::text as "occurredOn",request_row.reason,request_row.status, @@ -188,10 +195,15 @@ export async function registerAccountingPeriods(app: FastifyInstance, db: Databa join people requester on requester.id=request_row.requested_by_person_id left join people reviewer on reviewer.id=request_row.reviewed_by_person_id where ($1::text is null or request_row.status=$1) - order by request_row.requested_at desc limit $2`, - [parsed.data.status ?? null, parsed.data.limit], - ); - return { corrections: result.rows }; + order by request_row.requested_at desc limit $2 offset $3`, + [parsed.data.status ?? null, pageSize, (page - 1) * pageSize], + ), db.query<{ total_count:string; pending_count:string }>( + `select count(*)::text as total_count, + count(*) filter(where status in ('pending','approved'))::text as pending_count + from accounting_correction_requests where ($1::text is null or status=$1)`, + [parsed.data.status ?? null], + )]); + return { corrections: result.rows, page, pageSize, totalCount: Number(count.rows[0]!.total_count), pendingCount: Number(count.rows[0]!.pending_count) }; }); app.post("/api/accounting-periods/:month/confirm-close", async (request, reply) => { @@ -404,7 +416,8 @@ export async function registerAccountingPeriods(app: FastifyInstance, db: Databa if (denied) return reply.code(denied.statusCode).send({ message: denied.message }); const parsed = reviewListSchema.safeParse(request.query); if (!parsed.success) return reply.code(400).send({ message: "查询条件无效" }); - const result = await db.query( + const { page, pageSize } = parsed.data; + const [result, count] = await Promise.all([db.query( `select review.id::text,review.order_id::text as "orderId",orders.qingflow_order_no as "orderNo", review.proposed_lifecycle_state as "lifecycleState",review.proposed_current_revenue::text as "currentRevenue", review.conclusion,review.evidence,review.reason,review.status,review.requested_at as "requestedAt", @@ -414,10 +427,14 @@ export async function registerAccountingPeriods(app: FastifyInstance, db: Databa join people requester on requester.id=review.requested_by_person_id left join people reviewer on reviewer.id=review.reviewed_by_person_id where ($1::text is null or review.status=$1) - order by review.requested_at desc limit $2`, - [parsed.data.status ?? null, parsed.data.limit], - ); - return { reviews: result.rows }; + order by review.requested_at desc limit $2 offset $3`, + [parsed.data.status ?? null, pageSize, (page - 1) * pageSize], + ), db.query<{ total_count:string; pending_count:string }>( + `select count(*)::text as total_count,count(*) filter(where status='pending')::text as pending_count + from historical_order_reviews where ($1::text is null or status=$1)`, + [parsed.data.status ?? null], + )]); + return { reviews: result.rows, page, pageSize, totalCount: Number(count.rows[0]!.total_count), pendingCount: Number(count.rows[0]!.pending_count) }; }); app.post("/api/historical-order-reviews", async (request, reply) => { @@ -466,7 +483,7 @@ export async function registerAccountingPeriods(app: FastifyInstance, db: Databa try { await client.query("begin"); const review = await client.query<{ - id: string; order_id: string; proposed_lifecycle_state: "active" | "paused" | "zero"; + id: string; order_id: string; proposed_lifecycle_state: "active" | "paused" | "zero" | "receivable_pending"; proposed_current_revenue: string; reason: string; requested_by_user_id: string; requested_by_person_id: string; status: string; }>( @@ -481,9 +498,9 @@ export async function registerAccountingPeriods(app: FastifyInstance, db: Databa await client.query("rollback"); return reply.code(409).send({ message: "核对人与审批人必须是不同人员" }); } - const order = await client.query<{ salesperson_person_id: string; counted_amount: string; lifecycle_state: string; customer_unit:string; business_region_source_text:string|null; business_region_code:string|null }>( - `select salesperson_person_id::text,counted_amount::text,lifecycle_state,customer_unit, - business_region_source_text,business_region_code + const order = await client.query<{ salesperson_person_id: string; collaborator_person_id:string|null; collaboration_ratio:string|null; counted_amount: string; lifecycle_state: string; customer_unit:string; business_region_source_text:string|null; business_region_code:string|null }>( + `select salesperson_person_id::text,collaborator_person_id::text,collaboration_ratio::text,counted_amount::text,lifecycle_state,customer_unit, + business_region_source_text,business_region_code from performance_orders where id=$1 for update`, [row.order_id], ); if (order.rows[0]?.lifecycle_state !== "historical_review_required") { @@ -496,22 +513,35 @@ export async function registerAccountingPeriods(app: FastifyInstance, db: Databa } await assertAccountingPeriodOpen(client, accountingMonth(occurredOn)); const organization = await resolveOrganization(client, order.rows[0]!.salesperson_person_id, occurredOn); + const collaboratorOrganization = order.rows[0]!.collaborator_person_id + ? await resolveOrganization(client, order.rows[0]!.collaborator_person_id, occurredOn) + : null; const currentRevenue = Number(row.proposed_current_revenue); - const countedAmount = row.proposed_lifecycle_state === "active" ? currentRevenue : 0; + const countedAmount = ["active", "receivable_pending"].includes(row.proposed_lifecycle_state) ? currentRevenue : 0; const deltaAmount = countedAmount - Number(order.rows[0]!.counted_amount); const inserted = await client.query<{ id: string }>( `insert into performance_events (order_id,event_type,delta_amount,resulting_current_revenue,resulting_counted_amount, accounting_month,occurred_on,reason,salesperson_name,department_name,group_name, - leader_name,supervisor_name,created_by,salesperson_person_id,department_unit_id, - group_unit_id,leader_person_id,supervisor_person_id,occurred_at) - values($1,'historical_review_resolution',$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19) + leader_name,supervisor_name,created_by,salesperson_person_id,department_unit_id, + group_unit_id,leader_person_id,supervisor_person_id, + collaborator_person_id,collaborator_name,collaboration_ratio,collaborator_department_unit_id,collaborator_department_name, + collaborator_group_unit_id,collaborator_group_name,collaborator_leader_person_id,collaborator_leader_name, + collaborator_supervisor_person_id,collaborator_supervisor_name,occurred_at) + values($1,'historical_review_resolution',$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18, + $19,$20,$21,$22,$23,$24,$25,$26,$27,$28,$29,$30) returning id::text`, [row.order_id, deltaAmount, currentRevenue, countedAmount, accountingMonth(occurredOn), occurredOn, `历史核对结论:${row.reason}`.slice(0, 500), organization.salespersonName, organization.departmentName, - organization.groupName, organization.leaderName, organization.supervisorName, row.requested_by_user_id, - organization.personId, organization.departmentId, organization.groupId, organization.leaderPersonId, - organization.supervisorPersonId, now], + organization.groupName, organization.leaderName, organization.supervisorName, row.requested_by_user_id, + organization.personId, organization.departmentId, organization.groupId, organization.leaderPersonId, + organization.supervisorPersonId, + collaboratorOrganization?.personId ?? null, collaboratorOrganization?.salespersonName ?? null, + order.rows[0]!.collaboration_ratio ? Number(order.rows[0]!.collaboration_ratio) : null, + collaboratorOrganization?.departmentId ?? null, collaboratorOrganization?.departmentName ?? null, + collaboratorOrganization?.groupId ?? null, collaboratorOrganization?.groupName ?? null, + collaboratorOrganization?.leaderPersonId ?? null, collaboratorOrganization?.leaderName ?? null, + collaboratorOrganization?.supervisorPersonId ?? null, collaboratorOrganization?.supervisorName ?? null, now], ); await recordEventAnalysisDimensions(client,inserted.rows[0]!.id,{ businessRegionCode:order.rows[0]!.business_region_code, diff --git a/apps/api/src/modules/admin.ts b/apps/api/src/modules/admin.ts index 4c219ef..2783679 100644 --- a/apps/api/src/modules/admin.ts +++ b/apps/api/src/modules/admin.ts @@ -33,7 +33,7 @@ export async function registerAdmin(app:FastifyInstance,db:Database){ const parsed=accountListQuerySchema.safeParse(request.query);if(!parsed.success)return reply.code(400).send({message:"账号查询条件无效"}); const numbered=parsed.data.page!==undefined||parsed.data.pageSize!==undefined; if(numbered&&parsed.data.cursor)return reply.code(400).send({message:"页码与游标不能同时使用"}); - const page=parsed.data.page??1;const pageSize=parsed.data.pageSize??20; + const page=parsed.data.page??1;const pageSize=parsed.data.pageSize??10; const cursor=parsed.data.cursor?decodeAccountCursor(parsed.data.cursor):null; if(parsed.data.cursor&&(!cursor||cursor.userId!==request.currentUser!.id||cursor.search!==parsed.data.search))return reply.code(400).send({message:"账号分页游标无效或已不适用于当前查询"}); const client=await db.connect(); diff --git a/apps/api/src/modules/audits.ts b/apps/api/src/modules/audits.ts index 4bbefb7..8935eec 100644 --- a/apps/api/src/modules/audits.ts +++ b/apps/api/src/modules/audits.ts @@ -72,7 +72,7 @@ export async function registerAudits(app: FastifyInstance, db: Database) { const { cursor: encodedCursor, snapshot:encodedSnapshot, page:requestedPage, pageSize:requestedPageSize, ...filters } = parsed.data; const numbered=requestedPage!==undefined||requestedPageSize!==undefined; if((numbered&&encodedCursor)||(!numbered&&encodedSnapshot))return reply.code(400).send({message:"页码快照只能与页码一起使用,且不能与游标混用"}); - const page=requestedPage??1;const pageSize=requestedPageSize??20; + const page=requestedPage??1;const pageSize=requestedPageSize??10; const cursor = encodedCursor ? decodeAuditCursor(encodedCursor) : null; if (encodedCursor && (!cursor || cursor.userId !== request.currentUser.id || JSON.stringify(cursor.filters) !== JSON.stringify(filters))) { return reply.code(400).send({ message: "审计分页游标无效或已不适用于当前查询" }); diff --git a/apps/api/src/modules/performance.ts b/apps/api/src/modules/performance.ts index 8545929..124e764 100644 --- a/apps/api/src/modules/performance.ts +++ b/apps/api/src/modules/performance.ts @@ -1132,7 +1132,7 @@ export async function registerPerformance(app: FastifyInstance, db: Database, cl const numbered=parsed.data.level!=="months"&&(parsed.data.page!==undefined||parsed.data.pageSize!==undefined); if(parsed.data.level!=="months"&&((numbered&&parsed.data.cursor)||(!numbered&&parsed.data.snapshot)))return reply.code(400).send({code:"ANALYSIS_PAGINATION_INVALID",message:"页码快照只能与页码一起使用,且不能与游标混用"}); const page=parsed.data.level==="months"?1:parsed.data.page??1; - const pageSize=parsed.data.level==="months"?20:parsed.data.pageSize??20; + const pageSize=parsed.data.level==="months"?20:parsed.data.pageSize??10; const queryDigest = analysisQueryDigest({ ...parsed.data, cursor: undefined, snapshot:undefined, page:undefined, pageSize:undefined }); const customerCursor = parsed.data.level === "customers" && parsed.data.cursor ? decodeAnalysisCursor(parsed.data.cursor, analysisCustomerCursorSchema) @@ -1506,7 +1506,7 @@ export async function registerPerformance(app: FastifyInstance, db: Database, cl if (!query.success) return reply.code(400).send({ message: "查询条件无效" }); const numbered=query.data.page!==undefined||query.data.pageSize!==undefined; if((numbered&&query.data.cursor)||(!numbered&&query.data.snapshot))return reply.code(400).send({code:"ORDER_PAGINATION_INVALID",message:"页码快照只能与页码一起使用,且不能与游标混用"}); - const page=query.data.page??1;const pageSize=query.data.pageSize??20; + const page=query.data.page??1;const pageSize=query.data.pageSize??10; const filters = normalizeOrderFilters(query.data); const cursor = query.data.cursor ? decodeOrderCursor(query.data.cursor) : null; if (query.data.cursor && (!cursor || cursor.filterDigest !== orderFilterDigest(filters) || cursor.userId !== request.currentUser.id)) { diff --git a/apps/api/src/performance-analysis.integration.test.ts b/apps/api/src/performance-analysis.integration.test.ts index cd6bc9e..969dc6d 100644 --- a/apps/api/src/performance-analysis.integration.test.ts +++ b/apps/api/src/performance-analysis.integration.test.ts @@ -220,7 +220,7 @@ test("地区与客户分析按事件快照对账且查询次数不随规模增 const smallCustomersQuery = requireCapturedQuery(analysisQuery); const numberedCustomers = await app.inject({ method: "GET", - url: "/api/performance/analysis/drilldown?level=customers®ionCode=CN-JS&month=2026-08&page=1&pageSize=10", + url: "/api/performance/analysis/drilldown?level=customers®ionCode=CN-JS&month=2026-08&page=1", headers: { cookie: leaderCookie }, }); assert.equal(numberedCustomers.statusCode, 200, numberedCustomers.body); @@ -295,7 +295,7 @@ test("地区与客户分析按事件快照对账且查询次数不随规模增 const smallEventsQuery = requireCapturedQuery(analysisQuery); const numberedEvents = await app.inject({ method: "GET", - url: "/api/performance/analysis/drilldown?level=events®ionCode=CN-JS&customerUnit=%E5%AE%A2%E6%88%B7%E5%8D%95%E4%BD%8D%E7%94%B2&month=2026-08&page=1&pageSize=10", + url: "/api/performance/analysis/drilldown?level=events®ionCode=CN-JS&customerUnit=%E5%AE%A2%E6%88%B7%E5%8D%95%E4%BD%8D%E7%94%B2&month=2026-08&page=1", headers: { cookie: leaderCookie }, }); assert.equal(numberedEvents.statusCode, 200, numberedEvents.body); diff --git a/apps/api/src/test-database.integration.test.ts b/apps/api/src/test-database.integration.test.ts index 7fa85b1..119a76c 100644 --- a/apps/api/src/test-database.integration.test.ts +++ b/apps/api/src/test-database.integration.test.ts @@ -87,6 +87,7 @@ test("干净隔离数据库可应用全部现有迁移", async () => { "025_enforce_performance_order_state_amounts.sql", "026_sheet3_order_input.sql", "027_repair_legacy_import_dates.sql", + "028_allow_receivable_historical_reviews.sql", ]); assert.ok(result.rows.every((row) => /^[a-f0-9]{64}$/.test(row.sha256))); await assert.rejects( diff --git a/apps/web/e2e/accessibility.spec.ts b/apps/web/e2e/accessibility.spec.ts index 5a50328..76380e3 100644 --- a/apps/web/e2e/accessibility.spec.ts +++ b/apps/web/e2e/accessibility.spec.ts @@ -18,7 +18,7 @@ async function expectVisibleControlsNamed(page: import("@playwright/test").Page) } } -test("账号弹窗约束键盘焦点,并在写入失败后保留输入安全重试", async ({ database, page }) => { +test("账号弹窗约束键盘焦点,并在写入失败后保留输入安全重试", async ({ context, database, page }) => { await seedTestUser(database.url, { username: "e2e_access_admin", displayName: "E2E 无障碍管理员", @@ -31,6 +31,7 @@ test("账号弹窗约束键盘焦点,并在写入失败后保留输入安全 await setup.query("insert into people(display_name,identity_source,source_key) values('E2E 精确标识人员','e2e','precise-id-person')"); await setup.end(); await page.goto("/?page=accounts"); + await context.grantPermissions(["clipboard-read","clipboard-write"]); await login(page, "e2e_access_admin"); const trigger = page.getByRole("button", { name: "创建账号" }); @@ -41,6 +42,12 @@ test("账号弹窗约束键盘焦点,并在写入失败后保留输入安全 const close = dialog.getByRole("button", { name: "关闭" }); const submit = dialog.getByRole("button", { name: "创建账号" }); await expect(dialog).toBeFocused(); + let invalidCreateRequests=0; + page.on("request",(request)=>{if(request.method()==="POST"&&request.url().endsWith("/api/admin/users"))invalidCreateRequests+=1;}); + await submit.click(); + await expect(dialog.getByText("请输入登录账号。",{exact:true})).toBeVisible(); + await expect(dialog.getByLabel("登录账号")).toBeFocused(); + expect(invalidCreateRequests).toBe(0); await submit.focus(); await page.keyboard.press("Tab"); await expect(close).toBeFocused(); @@ -50,6 +57,19 @@ test("账号弹窗约束键盘焦点,并在写入失败后保留输入安全 await expect(dialog).toBeHidden(); await expect(trigger).toBeFocused(); + await trigger.click(); + const dirtyDialog=page.getByRole("dialog",{name:"创建系统账号"}); + await dirtyDialog.getByLabel("登录账号").fill("e2e_unsaved_draft"); + let discardMessage=""; + page.once("dialog",async(prompt)=>{discardMessage=prompt.message();await prompt.dismiss();}); + await page.keyboard.press("Escape"); + expect(discardMessage).toBe("当前内容尚未保存,确定放弃吗?"); + await expect(dirtyDialog).toBeVisible(); + await expect(dirtyDialog.getByLabel("登录账号")).toHaveValue("e2e_unsaved_draft"); + page.once("dialog",async(prompt)=>{await prompt.accept();}); + await dirtyDialog.getByRole("button",{name:"取消"}).click(); + await expect(dirtyDialog).toBeHidden(); + let createMode: "known-failure" | "pass" | "uncertain" = "known-failure"; let createBody:Record|null=null; let releaseKnownFailure!: () => void; @@ -94,6 +114,10 @@ test("账号弹窗约束键盘焦点,并在写入失败后保留输入安全 await expect(retryDialog.getByLabel("账号显示姓名")).toHaveValue("E2E 重试保留输入"); await retryDialog.getByRole("button", { name: "创建账号" }).click(); await expect(retryDialog.getByText("请立即安全保存临时密码,关闭后无法再次查看。", { exact: true })).toBeVisible(); + const createdPassword=await retryDialog.locator(".temporary-password-result strong").textContent(); + await retryDialog.getByRole("button",{name:"复制临时密码"}).click(); + await expect(retryDialog.getByRole("status")).toHaveText("临时密码已复制,请保存到安全位置。"); + expect(await page.evaluate(()=>navigator.clipboard.readText())).toBe(createdPassword); await retryDialog.getByRole("button", { name: "我已安全保存" }).click(); createMode = "uncertain"; @@ -108,7 +132,10 @@ test("账号弹窗约束键盘焦点,并在写入失败后保留输入安全 await expect(uncertainRow).toBeVisible(); await uncertainRow.getByRole("button", { name: "重置密码" }).click(); await page.getByRole("dialog", { name: "确认重置密码" }).getByRole("button", { name: "确认重置密码" }).click(); - await expect(page.getByRole("dialog", { name: "临时密码已生成" })).toBeVisible(); + const resetDialog=page.getByRole("dialog", { name: "临时密码已生成" }); + await expect(resetDialog).toBeVisible(); + await resetDialog.getByRole("button",{name:"复制临时密码"}).click(); + await expect(resetDialog.getByRole("status")).toHaveText("临时密码已复制,请保存到安全位置。"); }); test("图表和关键状态具有不依赖颜色的文本等价信息", async ({ database, page }) => { @@ -202,7 +229,7 @@ test("八个主要页面的可见控件和图表均有可访问名称", async ({ await page.goto("/?page=accounts"); await login(page, "e2e_access_all"); for (const name of ["账号管理", "业绩总览", "目标管理", "订单业绩", "业绩分析", "组织架构", "审批中心", "审计查询"]) { - await page.getByRole("button", { name, exact: true }).click(); + await page.getByRole("link", { name, exact: true }).click(); await expectVisibleControlsNamed(page); } }); diff --git a/apps/web/e2e/accounts.spec.ts b/apps/web/e2e/accounts.spec.ts index f2169a7..aec564b 100644 --- a/apps/web/e2e/accounts.spec.ts +++ b/apps/web/e2e/accounts.spec.ts @@ -39,8 +39,9 @@ test("系统管理员搜索分页账号并审计固定角色组合变更", async await page.getByLabel("密码", { exact: true }).fill("Accounts@123"); await page.getByRole("button", { name: "进入 SampleFlow" }).click(); await expect(page.getByRole("heading", { name: "账号管理" })).toBeVisible(); - await expect(page.getByText("本页 20 个账号", { exact: true })).toBeVisible(); + await expect(page.getByText("本页 10 个账号", { exact: true })).toBeVisible(); const pageSize=page.getByLabel("账号每页条数"); + await expect(pageSize).toHaveValue("10"); expect(await pageSize.locator("option").allTextContents()).toEqual(["10 条/页","20 条/页","50 条/页","100 条/页"]); await pageSize.selectOption("50"); await expect(page.getByText("本页 50 个账号", { exact: true })).toBeVisible(); diff --git a/apps/web/e2e/analysis.spec.ts b/apps/web/e2e/analysis.spec.ts index bb450ce..16b1eb5 100644 --- a/apps/web/e2e/analysis.spec.ts +++ b/apps/web/e2e/analysis.spec.ts @@ -86,7 +86,7 @@ test("业绩分析页显示事件快照地区、外贸、客户单位和待补 await page.getByLabel("账号").fill("e2e_analysis_assistant"); await page.getByLabel("密码", { exact: true }).fill("Analysis@123"); await page.getByRole("button", { name: "进入 SampleFlow" }).click(); - await page.getByRole("button", { name: "业绩分析" }).click(); + await page.getByRole("link", { name: "业绩分析" }).click(); const analysis = page.getByRole("region", { name: "地区与客户单位分析" }); await analysis.getByLabel("分析月份").fill("2026-08"); @@ -96,6 +96,11 @@ test("业绩分析页显示事件快照地区、外贸、客户单位和待补 await expect(analysis.getByText("¥30.00", { exact: true })).toBeVisible(); const provinces = analysis.getByRole("table", { name: "省份汇总" }); + const provincePageSize = analysis.getByLabel("省份汇总每页条数"); + await expect(provincePageSize).toHaveValue("10"); + expect(await provincePageSize.locator("option").allTextContents()).toEqual(["10 条/页", "20 条/页", "50 条/页", "100 条/页"]); + await provincePageSize.selectOption("20"); + await expect(provincePageSize).toHaveValue("20"); await expect(provinces.getByRole("row", { name: /江苏省.*102.*¥101,000,000,000,098\.99/ })).toBeVisible(); await expect(provinces.getByRole("row", { name: /浙江省.*1.*¥50\.00/ })).toBeVisible(); await expect(provinces.getByRole("row", { name: /台湾省.*1.*¥10\.00/ })).toBeVisible(); @@ -104,9 +109,30 @@ test("业绩分析页显示事件快照地区、外贸、客户单位和待补 await expect(analysis.getByText("1 条事件 · -¥25.00", { exact: true })).toBeVisible(); const customers = analysis.getByRole("table", { name: "客户单位汇总" }); + await expect(analysis.getByLabel("客户单位汇总每页条数")).toHaveValue("10"); await expect(customers.getByRole("row", { name: /江苏省.*客户单位甲.*1.*¥100\.00/ })).toBeVisible(); await expect(customers.getByRole("row", { name: /江苏省.*大额客户.*101.*¥100,999,999,999,998\.99/ })).toBeVisible(); await expect(customers.getByRole("row", { name: /外贸.*客户单位乙.*1.*-¥25\.00/ })).toBeVisible(); + const customerFilter = analysis.getByLabel("筛选客户单位"); + await expect(customerFilter).toHaveAttribute("placeholder", "区域或客户单位"); + await customerFilter.fill("大额客户"); + await expect.poll(()=>new URL(page.url()).searchParams.get("analysisCustomerFilter")).toBe("大额客户"); + await expect(customers.getByRole("row", { name: /江苏省.*大额客户.*101.*¥100,999,999,999,998\.99/ })).toBeVisible(); + await expect(customers.getByText("客户单位甲", { exact: true })).toHaveCount(0); + await analysis.getByLabel("分析范围").selectOption("year"); + await customerFilter.fill(""); + await page.goBack(); + await expect(analysis.getByLabel("分析范围")).toHaveValue("month"); + await expect(customerFilter).toHaveValue("大额客户"); + await page.reload(); + await expect(customerFilter).toHaveValue("大额客户"); + await expect(customers.getByRole("row", { name: /江苏省.*大额客户.*101.*¥100,999,999,999,998\.99/ })).toBeVisible(); + await customerFilter.fill("浙江省"); + await expect(customers.getByRole("row", { name: /浙江省.*客户单位甲.*1.*¥50\.00/ })).toBeVisible(); + await expect(customers.getByText("大额客户", { exact: true })).toHaveCount(0); + await customerFilter.fill("不存在"); + await expect(customers.getByText("没有符合“不存在”的客户单位。", { exact: true })).toBeVisible(); + await customerFilter.fill(""); await analysis.getByLabel("分析范围").selectOption("year"); await expect(analysis.getByLabel("分析年份")).toHaveValue("2026"); @@ -115,11 +141,23 @@ test("业绩分析页显示事件快照地区、外贸、客户单位和待补 await expect(provinces.getByRole("row", { name: /江苏省.*103.*¥101,000,000,000,105\.99/ })).toBeVisible(); expect(Object.fromEntries(new URL(page.url()).searchParams)).toMatchObject({ analysisRange: "year", analysisYear: "2026" }); expect(new URL(page.url()).searchParams.has("analysisMonth")).toBe(false); - await provinces.getByRole("button", { name: "江苏省" }).click(); - const yearlyDrilldown=analysis.getByRole("region", { name: "分析穿透" }); + const yearlyProvinceButton=provinces.getByRole("button", { name: "江苏省" }); + const analysisPanel=page.locator(".analysis-panel"); + const analysisHeight=await analysisPanel.evaluate((element)=>element.scrollHeight); + await yearlyProvinceButton.click(); + const yearlyDialog=page.getByRole("dialog", { name: "江苏省业绩明细" }); + const yearlyDrilldown=yearlyDialog.getByRole("region", { name: "分析穿透" }); + await expect(yearlyDialog).toBeVisible(); + expect(await analysisPanel.evaluate((element)=>element.scrollHeight)).toBe(analysisHeight); await expect(yearlyDrilldown.getByRole("row", { name: /客户单位甲.*2.*¥107\.00/ })).toBeVisible(); await yearlyDrilldown.getByRole("button", { name: "查看客户单位甲月份趋势" }).click(); + const yearlyMonthHeading=yearlyDrilldown.getByRole("heading", { name: "客户单位甲月度趋势" }); + await expect(yearlyMonthHeading).toBeFocused(); await expect(yearlyDrilldown.getByText("2026年金额与上级客户行完全对平。", { exact: true })).toBeVisible(); + await expect(page.getByRole("dialog")).toHaveCount(1); + await yearlyDialog.getByRole("button", { name: "关闭" }).click(); + await expect(yearlyDialog).toBeHidden(); + await expect(yearlyProvinceButton).toBeFocused(); await analysis.getByLabel("分析范围").selectOption("month"); await analysis.getByLabel("分析月份").fill("2026-08"); await expect(provinces.getByRole("row", { name: /江苏省.*102.*¥101,000,000,000,098\.99/ })).toBeVisible(); @@ -148,28 +186,32 @@ test("业绩分析页显示事件快照地区、外贸、客户单位和待补 await route.fulfill({ response, json: { ...body, eventCount: body.eventCount + 1 } }); }); await jiangsu.click(); - const drilldown = analysis.getByRole("region", { name: "分析穿透" }); + const drilldownDialog=page.getByRole("dialog", { name: "江苏省业绩明细" }); + const drilldown = drilldownDialog.getByRole("region", { name: "分析穿透" }); await expect(drilldown.getByRole("heading", { name: "江苏省客户单位" })).toBeVisible(); await expect(drilldown.getByText("客户合计与省份汇总不一致,请停止使用当前穿透结果。", { exact: true })).toBeVisible(); await page.unroute("**/api/performance/analysis/drilldown?*"); + await drilldownDialog.getByRole("button", { name: "关闭" }).click(); await zhejiang.click(); - await expect(analysis.getByRole("region", { name: "分析穿透" }).getByRole("heading", { name: "浙江省客户单位" })).toBeVisible(); + const zhejiangDialog=page.getByRole("dialog", { name: "浙江省业绩明细" }); + await expect(zhejiangDialog.getByRole("region", { name: "分析穿透" }).getByRole("heading", { name: "浙江省客户单位" })).toBeVisible(); + await zhejiangDialog.getByRole("button", { name: "关闭" }).click(); await jiangsu.click(); await expect(drilldown.getByRole("heading", { name: "江苏省客户单位" })).toBeVisible(); await expect(drilldown.getByText("¥101,000,000,000,098.99", { exact: true })).toBeVisible(); await drilldown.getByRole("button", { name: "查看客户单位甲月份趋势" }).click(); - await expect(drilldown.getByRole("heading", { name: "客户单位甲月度趋势" })).toBeVisible(); + await expect(drilldown.getByRole("heading", { name: "客户单位甲月度趋势" })).toBeFocused(); await expect(drilldown.getByRole("button", { name: "查看2026年8月订单事件,1 条事件,金额 ¥100.00" })).toBeVisible(); await expect(drilldown.getByRole("button", { name: "查看2026年9月订单事件,1 条事件,金额 ¥7.00" })).toBeVisible(); await drilldown.getByRole("button", { name: "查看2026年8月订单事件,1 条事件,金额 ¥100.00" }).click(); - await expect(drilldown.getByRole("heading", { name: "2026年8月订单与事件" })).toBeVisible(); + await expect(drilldown.getByRole("heading", { name: "2026年8月订单与事件" })).toBeFocused(); await expect(drilldown.getByRole("row", { name: /E2E-ANALYSIS.*E2E 分析客户.*1.*¥100\.00/ })).toBeVisible(); await expect(drilldown.getByRole("row", { name: /第 1 条.*首次录入.*¥100\.00.*江苏省.*客户单位甲/ })).toBeVisible(); await expect.poll(()=>new URL(page.url()).searchParams.get("analysisCustomerSnapshot")).toBeTruthy(); await expect.poll(()=>new URL(page.url()).searchParams.get("analysisEventSnapshot")).toBeTruthy(); expect(Object.fromEntries(new URL(page.url()).searchParams)).toMatchObject({analysisMonth:"2026-08",analysisRegion:"CN-JS",analysisCustomer:"客户单位甲",analysisEventMonth:"2026-08"}); await page.reload(); - await expect(analysis.getByRole("region", { name: "分析穿透" }).getByRole("heading", { name: "2026年8月订单与事件" })).toBeVisible(); + await expect(drilldown.getByRole("heading", { name: "2026年8月订单与事件" })).toBeVisible(); let failSeptemberEvents = true; await page.route("**/api/performance/analysis/drilldown?*", async (route) => { @@ -191,6 +233,7 @@ test("业绩分析页显示事件快照地区、外贸、客户单位和待补 await drilldown.getByRole("button", { name: "查看2026年8月订单事件,101 条事件,金额 ¥100,999,999,999,998.99" }).click(); const eventPageSize=drilldown.getByLabel("订单事件每页条数"); await expect(eventPageSize).toBeVisible(); + await expect(eventPageSize).toHaveValue("10"); expect(await eventPageSize.locator("option").allTextContents()).toEqual(["10 条/页","20 条/页","50 条/页","100 条/页"]); await eventPageSize.selectOption("100"); await expect(drilldown.getByText("本页 100 / 共 101 条事件", { exact: true })).toBeVisible(); @@ -199,9 +242,13 @@ test("业绩分析页显示事件快照地区、外贸、客户单位和待补 await drilldown.getByRole("button", { name: "查看2026年1月订单事件,0 条事件,金额 ¥0.00" }).click(); await expect(drilldown.getByText("该月份没有订单事件。", { exact: true })).toBeVisible(); + await drilldownDialog.getByRole("button", { name: "关闭" }).click(); await zhejiang.focus(); await page.keyboard.press("Enter"); - await expect(analysis.getByRole("region", { name: "分析穿透" }).getByRole("heading", { name: "浙江省客户单位" })).toBeVisible(); + const keyboardDialog=page.getByRole("dialog", { name: "浙江省业绩明细" }); + await expect(keyboardDialog.getByRole("region", { name: "分析穿透" }).getByRole("heading", { name: "浙江省客户单位" })).toBeVisible(); + await keyboardDialog.getByRole("button", { name: "关闭" }).click(); + await expect(zhejiang).toBeFocused(); await page.setViewportSize({ width: 1024, height: 800 }); await expect(map).toBeVisible(); @@ -252,7 +299,7 @@ test("第二批客户穿透可通过刷新和浏览器历史恢复", async ({ da if (level === "customers") { customerRequests += 1; const pageNumber = Number(url.searchParams.get("page")??"1"); - const pageSize = Number(url.searchParams.get("pageSize")??"20"); + const pageSize = Number(url.searchParams.get("pageSize")??"10"); const secondPage = pageNumber === 2; const customers = secondPage ? [{ customerUnit: "客户51", eventCount: 0, totalAmount: "0.00" }] @@ -264,13 +311,14 @@ test("第二批客户穿透可通过刷新和浏览器历史恢复", async ({ da await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ level, regionCode: "CN-JS", regionName: "江苏省", customerUnit: url.searchParams.get("customerUnit"), year: "2026", eventCount: 0, totalAmount: "0.00", months: [{ month: "2026-08", eventCount: 0, totalAmount: "0.00" }] }) }); return; } - await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ level: "events", regionCode: "CN-JS", regionName: "江苏省", customerUnit: url.searchParams.get("customerUnit"), month: "2026-08", eventCount: 0, totalAmount: "0.00", nextCursor: null, snapshot:"mock-event-snapshot", page: 1, pageSize: 20, totalCount: 0, orders: [] }) }); + await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ level: "events", regionCode: "CN-JS", regionName: "江苏省", customerUnit: url.searchParams.get("customerUnit"), month: "2026-08", eventCount: 0, totalAmount: "0.00", nextCursor: null, snapshot:"mock-event-snapshot", page: 1, pageSize: 10, totalCount: 0, orders: [] }) }); }); await page.goto(`/?${new URLSearchParams({ page: "analysis", analysisMonth: "2026-08", analysisRegion: "CN-JS", analysisCustomer: "客户51", analysisEventMonth: "2026-08", analysisCustomerPage: "2", analysisCustomerPageSize: "50" })}`); await page.getByLabel("账号").fill("e2e_analysis_restore"); await page.getByLabel("密码", { exact: true }).fill("Analysis@123"); await page.getByRole("button", { name: "进入 SampleFlow" }).click(); + await expect(page.getByRole("dialog", { name: "江苏省业绩明细" })).toBeVisible(); await expect(page.getByText("台湾省资料暂缺", { exact: true })).toHaveCount(0); await expect(page.getByRole("heading", { name: "客户51月度趋势" })).toBeVisible(); await expect(page.getByRole("heading", { name: "2026年8月订单与事件" })).toBeVisible(); diff --git a/apps/web/e2e/audits.spec.ts b/apps/web/e2e/audits.spec.ts index e10a03f..4627767 100644 --- a/apps/web/e2e/audits.spec.ts +++ b/apps/web/e2e/audits.spec.ts @@ -42,43 +42,44 @@ test("审计页面只读展示所属域并支持组合过滤", async ({ context, ($1::bigint,'auth.account_created','user',$1::text,null,'2026-09-01T12:29:00Z'), ($1::bigint,'organization.unit_created','org_unit','9001',null,'2026-09-01T12:30:00Z'), ($2::bigint,'performance.order_posted','performance_order',$3::text,$4::jsonb,'2026-09-01T12:31:00Z')`, - [adminId, actorId, order.rows[0]!.id, JSON.stringify({ customer: "E2E 审计客户", temporaryPassword: "E2E-TEMP-CANARY", token: "E2E-TOKEN-CANARY" })], + [adminId, actorId, order.rows[0]!.id, JSON.stringify({ customer: "E2E 审计客户", sourceSystem: "E2E 来源", temporaryPassword: "E2E-TEMP-CANARY", token: "E2E-TOKEN-CANARY" })], ); await login(page, "e2e_audit_admin"); - await page.getByRole("button", { name: "审计查询" }).click(); + await page.getByRole("link", { name: "审计查询" }).click(); await expect(page.getByRole("heading", { name: "审计查询" })).toBeVisible(); - await expect(page.getByRole("cell", { name: "创建账号" })).toBeVisible(); + await expect(page.getByRole("cell", { name: "创建账号", exact: true })).toBeVisible(); await expect.poll(()=>new URL(page.url()).searchParams.get("auditSnapshot")).toBeTruthy(); await expect(page.getByText("performance.order_posted", { exact: true })).not.toBeVisible(); const pageSize=page.getByLabel("审计记录每页条数"); + await expect(pageSize).toHaveValue("10"); expect(await pageSize.locator("option").allTextContents()).toEqual(["10 条/页","20 条/页","50 条/页","100 条/页"]); await page.getByRole("button", { name: "第 2 页" }).click(); expect(new URL(page.url()).searchParams.get("auditPage")).toBe("2"); expect(new URL(page.url()).searchParams.get("auditSnapshot")).toBeTruthy(); await expect(page.getByText("创建账号", { exact: true })).toHaveCount(0); - await expect(page.getByRole("cell", { name: "组织分页记录" }).first()).toBeVisible(); + await expect(page.getByRole("cell", { name: "组织分页记录", exact: true }).first()).toBeVisible(); const secondPageUrl = page.url(); const secondPageRows = await page.locator(".audit-table tbody tr").allTextContents(); const coldPage = await context.newPage(); await coldPage.goto(secondPageUrl); await expect(coldPage.getByRole("heading",{name:"审计查询"})).toBeVisible(); - await expect(coldPage.getByRole("cell", { name: "组织分页记录" }).first()).toBeVisible(); + await expect(coldPage.getByRole("cell", { name: "组织分页记录", exact: true }).first()).toBeVisible(); await expect(coldPage.getByRole("button", { name: "上一页" })).toBeEnabled(); await coldPage.close(); await page.reload(); await expect(page.getByRole("heading", { name: "审计查询" })).toBeVisible(); - await expect(page.getByRole("cell", { name: "组织分页记录" }).first()).toBeVisible(); + await expect(page.getByRole("cell", { name: "组织分页记录", exact: true }).first()).toBeVisible(); expect(await page.locator(".audit-table tbody tr").allTextContents()).toEqual(secondPageRows); await page.getByRole("button", { name: "上一页" }).click(); - await expect(page.getByRole("cell", { name: "创建账号" })).toBeVisible(); + await expect(page.getByRole("cell", { name: "创建账号", exact: true })).toBeVisible(); await page.goBack(); - await expect(page.getByRole("cell", { name: "组织分页记录" }).first()).toBeVisible(); + await expect(page.getByRole("cell", { name: "组织分页记录", exact: true }).first()).toBeVisible(); await page.goForward(); - await expect(page.getByRole("cell", { name: "创建账号" })).toBeVisible(); + await expect(page.getByRole("cell", { name: "创建账号", exact: true })).toBeVisible(); await page.getByLabel("动作").fill("organization"); await page.getByRole("button", { name: "查询审计" }).click(); - await expect(page.getByRole("cell", { name: "新增组织单元" })).toBeVisible(); + await expect(page.getByRole("cell", { name: "新增组织单元", exact: true })).toBeVisible(); await expect(page.getByText("创建账号", { exact: true })).not.toBeVisible(); const organizationRow = page.getByRole("row").filter({ hasText: "新增组织单元" }); await expect(organizationRow).toContainText(/2026.*9.*1.*20:30/); @@ -96,11 +97,17 @@ test("审计页面只读展示所属域并支持组合过滤", async ({ context, await page.getByRole("button", { name: "退出登录" }).click(); await login(page, "e2e_audit_hr"); - await page.getByRole("button", { name: "审计查询" }).click(); - await expect(page.getByRole("cell", { name: "订单入账" })).toBeVisible(); + await page.getByRole("link", { name: "审计查询" }).click(); + await expect(page.getByRole("cell", { name: "订单入账", exact: true })).toBeVisible(); await expect(page.getByText("创建账号", { exact: true })).not.toBeVisible(); - await expect(page.getByText("客户:E2E 审计客户", { exact: true })).toBeVisible(); - await expect(page.locator(".audit-data").filter({ hasText: /[{}]/ })).toHaveCount(0); + const performanceRow=page.getByRole("row").filter({hasText:"订单入账"}); + await expect(performanceRow).toContainText("客户:E2E 审计客户"); + await performanceRow.getByRole("button",{name:"查看订单入账变更"}).click(); + const changeDialog=page.getByRole("dialog",{name:"订单入账 · 业绩订单"}); + await expect(changeDialog.getByRole("row").filter({hasText:"客户"})).toContainText("E2E 审计客户"); + await expect(changeDialog.getByRole("row").filter({hasText:"sourceSystem"})).toContainText("E2E 来源"); + await expect(changeDialog.getByText("补充信息",{exact:true})).toHaveCount(0); + await expect(page.locator(".audit-change-summary").filter({ hasText: /[{}]/ })).toHaveCount(0); await expect(page.getByText(/auth\.account_created|organization\.unit_created|performance\.order_posted/)).toHaveCount(0); await expect(page.getByText(/E2E-TEMP-CANARY|E2E-TOKEN-CANARY/)).toHaveCount(0); } finally { diff --git a/apps/web/e2e/desktop-acceptance.spec.ts b/apps/web/e2e/desktop-acceptance.spec.ts index 07ad2ff..9988bdc 100644 --- a/apps/web/e2e/desktop-acceptance.spec.ts +++ b/apps/web/e2e/desktop-acceptance.spec.ts @@ -33,6 +33,11 @@ async function login(page: import("@playwright/test").Page, username: string) { await page.getByLabel("账号").fill(username); await page.getByLabel("密码", { exact: true }).fill("Desktop@123"); await page.getByRole("button", { name: "进入 SampleFlow" }).click(); + await expect.poll(async()=>{ + await page.locator("body").press("Shift"); + return page.evaluate(()=>Object.keys(window.localStorage).some((key)=>key.startsWith("sampleflow:onboarding:v2:")&&key.endsWith(":all")&&window.localStorage.getItem(key)==="completed")); + }).toBe(true); + await expect(page.locator(".onboarding-bubble")).toHaveCount(0); await expect(page.getByRole("button", { name: "退出登录" })).toBeVisible(); } @@ -99,23 +104,29 @@ test("桌面关键页面无横向溢出且操作可达", async ({ database, page expect(await page.evaluate(() => navigator.language)).toBe("zh-CN"); expect(await page.evaluate(() => Intl.DateTimeFormat().resolvedOptions().timeZone)).toBe("Asia/Shanghai"); + let failOrganizationDetails=true; + await page.route("**/api/performance/sales-achievement/events?*",async(route)=>{if(failOrganizationDetails){failOrganizationDetails=false;await route.fulfill({status:503,contentType:"application/json",body:'{"message":"业绩构成暂时不可用"}'});return;}await route.continue();}); const performanceDetails = page.getByRole("button", { name: "查看销售组织业绩构成" }); await expectHorizontallyReachable(page, performanceDetails); await performanceDetails.click(); - await expect(page.getByRole("dialog", { name: "销售组织 · 业绩构成" })).toBeVisible(); + const organizationDetailsDialog=page.getByRole("dialog", { name: "销售组织 · 业绩构成" }); + await expect(organizationDetailsDialog.getByRole("alert")).toHaveText("业绩构成暂时不可用"); + await organizationDetailsDialog.getByRole("button",{name:"重试组织业绩构成"}).click(); + await expect(organizationDetailsDialog.getByText(/条事件 · 净额/)).toBeVisible(); await page.getByRole("button", { name: "关闭" }).click(); - await page.getByRole("button", { name: "订单业绩", exact: true }).click(); + await page.getByRole("link", { name: "订单业绩", exact: true }).click(); await page.getByLabel("定位订单").fill("DESKTOP-NO-MATCH"); await page.getByRole("button", { name: "搜索", exact: true }).click(); await expect(page).toHaveURL(/orderSearch=DESKTOP-NO-MATCH/); + await page.locator(".order-filter-disclosure > summary").click(); await expectHorizontallyReachable(page, page.getByRole("button", { name: "应用筛选" })); - await page.getByRole("button", { name: "业绩分析", exact: true }).click(); + await page.getByRole("link", { name: "业绩分析", exact: true }).click(); await expect(page.getByRole("heading", { name: "地区与客户单位分析" })).toBeVisible(); await expectHorizontallyReachable(page, page.getByLabel("分析月份")); - await page.getByRole("button", { name: "账号管理", exact: true }).click(); + await page.getByRole("link", { name: "账号管理", exact: true }).click(); await page.getByLabel("搜索账号").fill("e2e_desktop_layout"); await page.getByRole("button", { name: "搜索账号" }).click(); await expect(page).toHaveURL(/accountSearch=e2e_desktop_layout/); @@ -125,6 +136,8 @@ test("桌面关键页面无横向溢出且操作可达", async ({ database, page await page.goto("/?page=organization"); await waitForPageReady(page, "组织架构"); await expectSearchDecorationsClear(page); + expect(await page.locator(".org-grid").evaluate((element)=>getComputedStyle(element).gridTemplateColumns.split(" ").length)).toBe(1); + for(const pagination of await page.locator(".org-grid .table-pagination").all()){const rows=await pagination.locator(":scope > .page-size-select, :scope > button, :scope > .page-number-list").evaluateAll((elements)=>elements.map((element)=>Math.round(element.getBoundingClientRect().top)));expect(Math.max(...rows)-Math.min(...rows)).toBeLessThanOrEqual(1);} const pages = [ ["overview", "业绩账本总览"], @@ -140,6 +153,7 @@ test("桌面关键页面无横向溢出且操作可达", async ({ database, page await page.goto(`/?page=${pageId}`); await waitForPageReady(page, heading); await expectHorizontallyReachable(page, page.getByRole("button", { name: "退出登录" })); + for(const region of await page.locator(".orders-table-wrap").all()){await expect(region).toHaveAttribute("tabindex","0");await expect(region).toHaveAttribute("role","region");await expect(region).toHaveAttribute("aria-label",/.+/);} await expectNoPageOverflow(page); } @@ -182,10 +196,10 @@ test("200% 桌面缩放仍保留导航、键盘焦点和无遮挡操作", async await page.goto("/?page=overview"); await login(page, "e2e_desktop_zoom"); await expect(page.getByRole("button", { name: "退出登录" })).toBeVisible(); - const navigation = page.locator(".sidebar nav button"); + const navigation = page.locator(".sidebar nav a"); await expect(navigation).toHaveCount(8); for (const item of await navigation.all()) await expect(item.locator("span")).toBeVisible(); - await expect(page.getByRole("button", { name: "业绩总览", exact: true })).toHaveAttribute("aria-current", "page"); + await expect(page.getByRole("link", { name: "业绩总览", exact: true })).toHaveAttribute("aria-current", "page"); await expectNoPageOverflow(page); const skipLink = page.getByRole("link", { name: "跳到主要内容" }); @@ -195,7 +209,7 @@ test("200% 桌面缩放仍保留导航、键盘焦点和无遮挡操作", async await page.keyboard.press("Enter"); await expect(page.locator("#main-content")).toBeFocused(); - await page.getByRole("button", { name: "账号管理", exact: true }).click(); + await page.getByRole("link", { name: "账号管理", exact: true }).click(); const heading = page.getByRole("heading", { name: "账号管理", level: 1 }); await expect(heading).toBeFocused(); await waitForPageReady(page, "账号管理"); diff --git a/apps/web/e2e/goal-roles.spec.ts b/apps/web/e2e/goal-roles.spec.ts index 98abe2e..accb1ee 100644 --- a/apps/web/e2e/goal-roles.spec.ts +++ b/apps/web/e2e/goal-roles.spec.ts @@ -36,7 +36,7 @@ test("多角色账号可选择全部合法目标下达层级", async ({ database await page.getByLabel("账号").fill("e2e_multi_role_goals"); await page.getByLabel("密码", { exact: true }).fill("Goals@123"); await page.getByRole("button", { name: "进入 SampleFlow" }).click(); - await page.getByRole("button", { name: "目标管理", exact: true }).click(); + await page.getByRole("link", { name: "目标管理", exact: true }).click(); await page.getByRole("button", { name: "下达目标" }).click(); await expect(page.getByLabel("目标层级").locator("option")).toHaveText([ diff --git a/apps/web/e2e/login.spec.ts b/apps/web/e2e/login.spec.ts index f830d67..4c6bc5e 100644 --- a/apps/web/e2e/login.spec.ts +++ b/apps/web/e2e/login.spec.ts @@ -83,10 +83,18 @@ test("销售助理组长可用标准模板预检并确认整批入账", async ({ }finally{await client.end();} await page.setViewportSize({width:390,height:844}); await page.goto("/");await page.getByLabel("账号").fill("e2e_import_leader");await page.getByLabel("密码",{exact:true}).fill("E2ePass@123");await page.getByRole("button",{name:"进入 SampleFlow"}).click(); - await page.getByRole("button",{name:"订单业绩",exact:true}).click();await page.getByRole("button",{name:"Excel 导入"}).click(); + await page.getByRole("link",{name:"订单业绩",exact:true}).click();await page.getByRole("button",{name:"Excel 导入"}).click(); await expect(page.getByRole("link",{name:"下载标准业绩模板"})).toHaveAttribute("href","/SampleFlow标准业绩导入模板.xlsx"); + let releasePreflight!:()=>void;const heldPreflight=new Promise((resolve)=>{releasePreflight=resolve;});let preflightRequests=0; + await page.route("**/api/imports/preflight",async(route)=>{preflightRequests+=1;await heldPreflight;await route.continue();}); await page.locator('input[type="file"]').setInputFiles(importTemplate);await page.getByRole("button",{name:"运行只读预检"}).click(); + const importDialog=page.getByRole("dialog",{name:"Excel 批量导入"}); + await expect(importDialog.getByRole("button",{name:"正在预检…"})).toBeDisabled(); + await expect(importDialog.getByRole("button",{name:"关闭"})).toBeDisabled(); + await page.keyboard.press("Escape");await page.locator(".modal-backdrop").dispatchEvent("mousedown"); + await expect(importDialog).toBeVisible();expect(preflightRequests).toBe(1);releasePreflight(); await expect(page.getByRole("heading",{name:"预检通过,等待确认"})).toBeVisible(); + await page.unroute("**/api/imports/preflight"); await expect(page.getByRole("heading",{name:"逐月对账"})).toBeVisible(); await expect(page.getByRole("row",{name:/2026-03.*1.*100\.00/})).toBeVisible(); await page.getByRole("button",{name:"确认整批入账"}).click(); @@ -131,7 +139,7 @@ test("销售助理组长可在桌面端预检并确认合成历史分析维度 [standard.rows[0]!.sheet_name,JSON.stringify(standard.rows[0]!.expected_headers),JSON.stringify(columnMapping),JSON.stringify({[source.businessRegionSourceText]:"EXT-TRADE"})], ); await page.setViewportSize({width:1280,height:900});await page.goto("/");await page.getByLabel("账号").fill("e2e_backfill_leader");await page.getByLabel("密码",{exact:true}).fill("E2ePass@123");await page.getByRole("button",{name:"进入 SampleFlow"}).click(); - await page.getByRole("button",{name:"订单业绩",exact:true}).click();await page.getByRole("button",{name:"Excel 导入"}).click(); + await page.getByRole("link",{name:"订单业绩",exact:true}).click();await page.getByRole("button",{name:"Excel 导入"}).click(); await page.getByLabel("历史分析维度补齐").check();await expect(page.getByText("上传原始受控工作簿",{exact:false})).toBeVisible(); await page.locator('input[type="file"]').setInputFiles(importTemplate);await page.getByRole("button",{name:"运行只读预检"}).click(); await expect(page.getByRole("heading",{name:"预检通过,等待确认"})).toBeVisible();await expect(page.getByRole("heading",{name:"来源对账"})).toBeVisible(); @@ -178,23 +186,34 @@ test("订单台账支持每页条数与可点击页码", async ({ database, page await page.getByLabel("账号").fill("e2e_cursor_assistant"); await page.getByLabel("密码", { exact: true }).fill("Cursor@123"); await page.getByRole("button", { name: "进入 SampleFlow" }).click(); - await page.getByRole("button", { name: "订单业绩", exact: true }).click(); + await page.getByRole("link", { name: "订单业绩", exact: true }).click(); const ledger = page.locator("section.orders-card").filter({ has: page.getByRole("heading", { name: "订单台账" }) }); - await expect(ledger.getByText("本页 20 笔订单", { exact: true })).toBeVisible(); + await expect(ledger.getByText("本页 10 笔订单", { exact: true })).toBeVisible(); await expect(ledger.getByText("E2E-CURSOR-0101", { exact: true })).toBeVisible(); await expect(ledger.getByText("待补齐", { exact: true }).first()).toBeVisible(); + const amountStatus=ledger.locator(".order-amount-status").first(); + const systemAmountBox=await amountStatus.locator("small").boundingBox(); + const statusBox=await amountStatus.locator(".status").boundingBox(); + expect(systemAmountBox).not.toBeNull(); + expect(statusBox).not.toBeNull(); + expect(statusBox!.y-(systemAmountBox!.y+systemAmountBox!.height)).toBeGreaterThanOrEqual(6); const scroller=ledger.locator(".order-ledger-scroll"); const headers=ledger.getByRole("table", { name: "订单台账" }).getByRole("columnheader"); + await expect(headers).toHaveCount(5); const beforeScroll=await headers.evaluateAll((cells)=>cells.map((cell)=>cell.getBoundingClientRect().x)); expect(await headers.nth(0).evaluate((cell)=>getComputedStyle(cell).position)).toBe("sticky"); - expect(await headers.nth(13).evaluate((cell)=>getComputedStyle(cell).position)).toBe("sticky"); - await scroller.evaluate((element)=>{element.scrollLeft=element.scrollWidth-element.clientWidth;}); - await expect.poll(()=>scroller.evaluate((element)=>element.scrollLeft)).toBeGreaterThan(0); - const afterScroll=await headers.evaluateAll((cells)=>cells.map((cell)=>cell.getBoundingClientRect().x)); - for(const index of [0,1,2,13,14,15])expect(Math.abs(afterScroll[index]!-beforeScroll[index]!)).toBeLessThanOrEqual(1); - expect(afterScroll[3]!).toBeLessThan(beforeScroll[3]!-100); - expect(afterScroll[2]!+(await headers.nth(2).evaluate((cell)=>cell.getBoundingClientRect().width))).toBeLessThanOrEqual(afterScroll[13]!+1); + expect(await headers.nth(4).evaluate((cell)=>getComputedStyle(cell).position)).toBe("sticky"); + const hasHorizontalOverflow=await scroller.evaluate((element)=>element.scrollWidth>element.clientWidth+1); + if(hasHorizontalOverflow){ + await scroller.evaluate((element)=>{element.scrollLeft=element.scrollWidth-element.clientWidth;}); + await expect.poll(()=>scroller.evaluate((element)=>element.scrollLeft)).toBeGreaterThan(0); + const afterScroll=await headers.evaluateAll((cells)=>cells.map((cell)=>cell.getBoundingClientRect().x)); + for(const index of [0,4])expect(Math.abs(afterScroll[index]!-beforeScroll[index]!)).toBeLessThanOrEqual(1); + expect(afterScroll[1]!).toBeLessThan(beforeScroll[1]!-10); + expect(afterScroll[0]!+(await headers.nth(0).evaluate((cell)=>cell.getBoundingClientRect().width))).toBeLessThanOrEqual(afterScroll[4]!+1); + } const pageSize=ledger.getByLabel("订单每页条数"); + await expect(pageSize).toHaveValue("10"); expect(await pageSize.locator("option").allTextContents()).toEqual(["10 条/页","20 条/页","50 条/页","100 条/页"]); await pageSize.selectOption("50"); await expect(ledger.getByText("本页 50 笔订单", { exact: true })).toBeVisible(); @@ -227,6 +246,14 @@ test("订单台账支持每页条数与可点击页码", async ({ database, page await page.getByLabel("定位订单").fill("E2E游标单位0042"); await expect(ledger.getByText("E2E-CURSOR-0042", { exact: true })).toBeVisible(); await expect(ledger.getByText("本页 1 笔订单", { exact: true })).toBeVisible(); + await ledger.getByRole("button", { name: "查看 / 调整" }).click(); + const orderDialog=page.getByRole("dialog", { name: "E2E-CURSOR-0042" }); + await expect(orderDialog.getByRole("heading", { name: "订单资料" })).toBeVisible(); + await expect(orderDialog).toContainText("E2E游标单位0042"); + await expect(orderDialog).toContainText("服务类型"); + await expect(orderDialog).toContainText("协作分配"); + await expect(orderDialog).toContainText("备注"); + await orderDialog.getByRole("button", { name: "关闭" }).click(); } finally { await setup.end(); } @@ -296,9 +323,10 @@ test("订单组合筛选由 URL 恢复并区分空集、失败和无权限", asy await page.getByLabel("账号").fill("e2e_filter_assistant"); await page.getByLabel("密码", { exact: true }).fill("Filter@123"); await page.getByRole("button", { name: "进入 SampleFlow" }).click(); - await page.getByRole("button", { name: "订单业绩", exact: true }).click(); + await page.getByRole("link", { name: "订单业绩", exact: true }).click(); const ledger = page.locator("section.orders-card").filter({ has: page.getByRole("heading", { name: "订单台账" }) }); await ledger.getByLabel("订单每页条数").selectOption("50"); + await ledger.locator("summary").filter({ hasText: "筛选条件" }).click(); await page.getByLabel("订单月份").fill(matching.month); await page.getByRole("button", { name: "应用筛选" }).click(); @@ -307,6 +335,7 @@ test("订单组合筛选由 URL 恢复并区分空集、失败和无权限", asy await expect(page).toHaveURL(/orderSearch=%E7%94%B2%E4%B9%99/); await page.goBack(); await expect(orderSearch).toHaveValue(""); + await ledger.locator("summary").filter({ hasText: "筛选条件" }).click(); await page.getByLabel("订单月份").fill(matching.month); await page.getByLabel("部门筛选").fill(matching.department); await orderSearch.fill("E2E-FILTER-"); @@ -552,10 +581,10 @@ test("系统管理员在账号管理页查看只读角色权限说明", async ({ await page.getByRole("button", { name: "进入 SampleFlow" }).click(); expect((await accountsResponse).status()).toBe(200); - await expect(page.getByRole("button", { name: "账号管理" })).toHaveAttribute("aria-current", "page"); - await expect(page.getByRole("button", { name: "业绩总览" })).toHaveCount(0); - await expect(page.getByRole("button", { name: "订单业绩" })).toHaveCount(0); - await expect(page.getByRole("button", { name: "目标管理" })).toHaveCount(0); + await expect(page.getByRole("link", { name: "账号管理" })).toHaveAttribute("aria-current", "page"); + await expect(page.getByRole("link", { name: "业绩总览" })).toHaveCount(0); + await expect(page.getByRole("link", { name: "订单业绩" })).toHaveCount(0); + await expect(page.getByRole("link", { name: "目标管理" })).toHaveCount(0); await expect(page.getByRole("heading", { name: "角色权限说明" })).toBeVisible(); await expect(page.getByText("多角色账号取各角色权限并集;系统管理员角色本身不增加业务权限")).toBeVisible(); @@ -605,7 +634,7 @@ test("目标未生效时页面不提供正式报表,生效后才可查看", as await page.getByLabel("账号").fill("e2e_salesperson_report"); await page.getByLabel("密码", { exact: true }).fill("Report@123"); await page.getByRole("button", { name: "进入 SampleFlow" }).click(); - await page.getByRole("button", { name: "目标管理" }).click(); + await page.getByRole("link", { name: "目标管理" }).click(); const pendingRow = page.getByRole("row").filter({ hasText: "2026-09" }); await expect(pendingRow).toContainText("待人事审批"); @@ -648,10 +677,10 @@ test("销售经理可从选择器创建顶层目标并完成总经理到人事 const logout=async()=>{await page.getByRole("button",{name:"退出登录"}).click();await expect(page.getByRole("heading",{name:"登录系统"})).toBeVisible();}; let delayedGoalOptions=false; await page.route("**/api/goals/options?*",async(route)=>{if(!delayedGoalOptions&&route.request().url().includes("periodMonth=2026-11")&&route.request().url().includes("level=sales_manager")){delayedGoalOptions=true;await new Promise((resolve)=>setTimeout(resolve,6000));}await route.continue();}); - await page.goto("/");await login("e2e_goal_manager");await expect(page.getByRole("button",{name:"目标管理"})).toBeVisible(); + await page.goto("/");await login("e2e_goal_manager");await expect(page.getByRole("link",{name:"目标管理"})).toBeVisible(); const illegalRoot=await page.evaluate(async({ownerPersonId})=>{const csrf=document.cookie.split("; ").find((item)=>item.startsWith("sampleflow_csrf="))?.split("=")[1]??"";const response=await fetch("/api/goals",{method:"POST",headers:{"content-type":"application/json","x-csrf-token":decodeURIComponent(csrf)},body:JSON.stringify({periodMonth:"2026-11",level:"sales_manager",ownerPersonId:Number(ownerPersonId),orgUnitId:null,parentGoalId:999999,amount:1000,changeReason:"非法顶层父目标"})});return{status:response.status,body:await response.text()};},{ownerPersonId:managerPersonId}); expect(illegalRoot.status).toBe(400);expect(illegalRoot.body).not.toMatch(/constraint|foreign key/i); - await page.getByRole("button",{name:"目标管理"}).click(); + await page.getByRole("link",{name:"目标管理"}).click(); await page.getByRole("button",{name:"下达目标"}).click(); await expect(page.getByLabel("目标层级")).toHaveValue("sales_manager"); const goalOptionsResponse=page.waitForResponse((response)=>response.url().includes("/api/goals/options?")&&response.url().includes("periodMonth=2026-11")&&response.url().includes("level=sales_manager")&&response.status()===200); @@ -676,7 +705,7 @@ test("销售经理可从选择器创建顶层目标并完成总经理到人事 await expect(topRow).toContainText("待总经理审批"); let delayedHistory=false;await page.route("**/api/goals/*/history",async(route)=>{if(!delayedHistory){delayedHistory=true;await new Promise((resolve)=>setTimeout(resolve,500));}await route.continue();}); - await logout();await login("e2e_goal_gm");await page.getByRole("button",{name:"审批中心"}).click(); + await logout();await login("e2e_goal_gm");await page.getByRole("link",{name:"审批中心"}).click(); const gmRow=page.getByRole("row").filter({hasText:"2026-11"});await gmRow.getByRole("button",{name:"批准"}).click(); const gmDialog=page.getByRole("dialog"); await expect(gmDialog.getByRole("button",{name:"确认批准"})).toBeDisabled(); @@ -692,14 +721,14 @@ test("销售经理可从选择器创建顶层目标并完成总经理到人事 await expect(page.getByText("状态已变化,已重新读取权威状态。")).toBeVisible(); await expect(gmRow).toHaveCount(0); - await logout();await login("e2e_goal_hr");await page.getByRole("button",{name:"审批中心"}).click(); + await logout();await login("e2e_goal_hr");await page.getByRole("link",{name:"审批中心"}).click(); const hrRow=page.getByRole("row").filter({hasText:"2026-11"});await hrRow.getByRole("button",{name:"批准"}).click(); await expect(page.getByRole("dialog").getByRole("heading",{name:"审批依据"})).toBeVisible(); await expect(page.getByRole("dialog")).toContainText("并发总经理批准"); await page.getByLabel("审批意见").fill("人事终审同意");await page.getByRole("button",{name:"确认批准"}).click(); await expect(hrRow).toHaveCount(0); - await logout();await login("e2e_goal_manager");await page.getByRole("button",{name:"目标管理"}).click(); + await logout();await login("e2e_goal_manager");await page.getByRole("link",{name:"目标管理"}).click(); const activeTopGoal=page.getByRole("row").filter({hasText:"2026-11"}).filter({hasText:"已生效"}); await expect(activeTopGoal).toBeVisible(); await activeTopGoal.getByRole("button",{name:"版本与记录"}).click(); @@ -744,12 +773,14 @@ test("目标修改申请在审批中心完成填金额、重新确认、终审 const login=async(username:string)=>{await page.getByLabel("账号").fill(username);await page.getByLabel("密码",{exact:true}).fill("Goal@123");await page.getByRole("button",{name:"进入 SampleFlow"}).click();}; const logout=async()=>{await page.getByRole("button",{name:"退出登录"}).click();await expect(page.getByRole("heading",{name:"登录系统"})).toBeVisible();}; await page.goto("/"); - await login("e2e_change_salesperson");await page.getByRole("button",{name:"目标管理"}).click(); + await login("e2e_change_salesperson");await page.getByRole("link",{name:"目标管理"}).click(); const personalRow=page.getByRole("row").filter({hasText:"E2E 变更业务员"});await personalRow.getByRole("button",{name:"申请修改"}).click(); await page.getByLabel("修改原因").fill("客户结构发生变化");await page.getByLabel("建议金额(可选)").fill("450");await page.getByRole("button",{name:"提交修改申请"}).click(); await expect(page.getByText("修改申请已提交。")).toBeVisible(); - await logout();await login("e2e_change_leader");await page.getByRole("button",{name:"审批中心"}).click(); + await logout();await login("e2e_change_leader");await page.getByRole("link",{name:"审批中心"}).click(); + const changeTab=page.getByRole("button",{name:/^目标修改申请/});await expect(changeTab).toHaveAttribute("aria-current","page");await expect(page).toHaveURL(/approvalTab=changes/); + await page.reload();await expect(page.getByRole("button",{name:/^目标修改申请/})).toHaveAttribute("aria-current","page"); const requestRow=page.getByRole("row").filter({hasText:"E2E 变更业务员"}).filter({hasText:"客户结构发生变化"});await requestRow.getByRole("button",{name:"接受并填金额"}).click(); const concurrentRequestDecision=await page.evaluate(async()=>{const workflows=await fetch("/api/goal-workflows").then((response)=>response.json()) as {changeRequests:Array<{id:string;reason:string}>;linkageDecisions:unknown[]};const request=workflows.changeRequests.find((item)=>item.reason==="客户结构发生变化")!;const csrf=document.cookie.split("; ").find((item)=>item.startsWith("sampleflow_csrf="))?.split("=")[1]??"";const response=await fetch(`/api/goal-change-requests/${request.id}/accept`,{method:"POST",headers:{"content-type":"application/json","x-csrf-token":decodeURIComponent(csrf)},body:JSON.stringify({newAmount:450,comment:"并发接受修改"})});return{status:response.status,staleWorkflows:workflows};}); expect(concurrentRequestDecision.status).toBe(200); @@ -757,23 +788,23 @@ test("目标修改申请在审批中心完成填金额、重新确认、终审 await page.getByLabel("新目标金额").fill("450");await page.getByLabel("处理意见").fill("同意按客户结构调整");await page.getByRole("button",{name:"接受并创建新版本"}).click(); await expect(page.getByText(/状态已变化.*列表刷新失败/)).toBeVisible(); await expect(requestRow).toHaveCount(0); - await page.getByRole("button",{name:"目标管理"}).click();await page.getByRole("button",{name:"审批中心"}).click(); + await page.getByRole("link",{name:"目标管理"}).click();await page.getByRole("link",{name:"审批中心"}).click(); await expect(page.getByRole("row").filter({hasText:"客户结构发生变化"})).toHaveCount(0); let releaseStaleLoad!:()=>void;let markStaleLoadStarted!:()=>void;let workflowLoadCount=0; const staleLoadGate=new Promise((resolve)=>{releaseStaleLoad=resolve;});const staleLoadStarted=new Promise((resolve)=>{markStaleLoadStarted=resolve;}); await page.route("**/api/goal-workflows",async(route)=>{workflowLoadCount+=1;if(workflowLoadCount===1){markStaleLoadStarted();await staleLoadGate;await route.fulfill({status:200,contentType:"application/json",body:JSON.stringify(concurrentRequestDecision.staleWorkflows)});return;}await route.continue();}); - await page.getByRole("button",{name:"目标管理"}).click();await page.getByRole("button",{name:"审批中心"}).click();await staleLoadStarted; - await page.getByRole("button",{name:"目标管理"}).click();const currentWorkflowResponse=page.waitForResponse((response)=>response.url().endsWith("/api/goal-workflows")&&response.status()===200);await page.getByRole("button",{name:"审批中心"}).click();await currentWorkflowResponse; + await page.getByRole("link",{name:"目标管理"}).click();await page.getByRole("link",{name:"审批中心"}).click();await staleLoadStarted; + await page.getByRole("link",{name:"目标管理"}).click();const currentWorkflowResponse=page.waitForResponse((response)=>response.url().endsWith("/api/goal-workflows")&&response.status()===200);await page.getByRole("link",{name:"审批中心"}).click();await currentWorkflowResponse; const staleWorkflowResponse=page.waitForResponse((response)=>response.url().endsWith("/api/goal-workflows")&&response.status()===200);releaseStaleLoad();await (await staleWorkflowResponse).finished(); await page.evaluate(()=>new Promise((resolve)=>requestAnimationFrame(()=>requestAnimationFrame(()=>resolve())))); await expect(page.getByRole("row").filter({hasText:"客户结构发生变化"})).toHaveCount(0); - await logout();await login("e2e_change_salesperson");await page.getByRole("button",{name:"目标管理"}).click(); + await logout();await login("e2e_change_salesperson");await page.getByRole("link",{name:"目标管理"}).click(); const pendingRow=page.getByRole("row").filter({hasText:"E2E 变更业务员"});await expect(pendingRow).toContainText("¥450.00");await pendingRow.getByRole("button",{name:"确认目标"}).click(); await page.getByRole("button",{name:"确认目标"}).click(); - await logout();await login("e2e_change_hr");await page.getByRole("button",{name:"审批中心"}).click(); + await logout();await login("e2e_change_hr");await page.getByRole("link",{name:"审批中心"}).click();await page.getByRole("button",{name:/^待确认与待审批目标/}).click(); const approvalRow=page.getByRole("row").filter({hasText:"E2E 变更业务员"}).filter({hasText:"待人事审批"});await approvalRow.getByRole("button",{name:"批准"}).click(); const approvalDialog=page.getByRole("dialog"); await expect(approvalDialog.getByRole("heading",{name:"审批依据"})).toBeVisible(); @@ -786,31 +817,32 @@ test("目标修改申请在审批中心完成填金额、重新确认、终审 await expect(approvalDialog).toContainText("并发接受修改"); await page.getByLabel("审批意见").fill("人事确认变更链完整");await page.getByRole("button",{name:"确认批准"}).click(); - await logout();await login("e2e_change_leader");await page.getByRole("button",{name:"审批中心"}).click(); + await logout();await login("e2e_change_leader");await page.getByRole("link",{name:"审批中心"}).click();await page.getByRole("button",{name:/^目标联动选择/}).click(); const linkageRow=page.getByRole("row").filter({hasText:"E2E 变更业务员"}).filter({hasText:"¥450.00"});await linkageRow.getByRole("button",{name:"选择是否调整"}).click(); await page.getByLabel("处理方式").selectOption("keep_parent");await page.getByLabel("联动原因").fill("小组目标维持六百元");await page.getByRole("button",{name:"确认联动选择"}).click(); const linkageCard=page.locator("section.workflow-card").filter({has:page.getByRole("heading",{name:"目标联动选择"})}); await expect(linkageCard.getByRole("row").filter({hasText:"E2E 变更业务员"})).toHaveCount(0); - await logout();await login("e2e_change_salesperson");await page.getByRole("button",{name:"目标管理"}).click(); + await logout();await login("e2e_change_salesperson");await page.getByRole("link",{name:"目标管理"}).click(); const activePersonal=page.getByRole("row").filter({hasText:"E2E 变更业务员"});await activePersonal.getByRole("button",{name:"申请修改"}).click(); await page.getByLabel("修改原因").fill("再次调整并联动上级");await page.getByLabel("建议金额(可选)").fill("475");await page.getByRole("button",{name:"提交修改申请"}).click(); - await logout();await login("e2e_change_leader");await page.getByRole("button",{name:"审批中心"}).click(); + await logout();await login("e2e_change_leader");await page.getByRole("link",{name:"审批中心"}).click();await page.getByRole("button",{name:/^目标修改申请/}).click(); const secondRequest=page.getByRole("row").filter({hasText:"再次调整并联动上级"});await secondRequest.getByRole("button",{name:"接受并填金额"}).click(); await page.getByLabel("新目标金额").fill("475");await page.getByLabel("处理意见").fill("接受第二次调整");await page.getByRole("button",{name:"接受并创建新版本"}).click(); - await logout();await login("e2e_change_salesperson");await page.getByRole("button",{name:"目标管理"}).click(); + await logout();await login("e2e_change_salesperson");await page.getByRole("link",{name:"目标管理"}).click(); const secondPending=page.getByRole("row").filter({hasText:"E2E 变更业务员"});await secondPending.getByRole("button",{name:"确认目标"}).click(); await page.getByRole("button",{name:"确认目标"}).click(); - await logout();await login("e2e_change_hr");await page.getByRole("button",{name:"审批中心"}).click(); + await logout();await login("e2e_change_hr");await page.getByRole("link",{name:"审批中心"}).click();await page.getByRole("button",{name:/^待确认与待审批目标/}).click(); const secondApproval=page.getByRole("row").filter({hasText:"E2E 变更业务员"}).filter({hasText:"待人事审批"});await secondApproval.getByRole("button",{name:"批准"}).click(); await page.getByLabel("审批意见").fill("人事批准第二次调整");await page.getByRole("button",{name:"确认批准"}).click(); - await logout();await login("e2e_change_leader");await page.getByRole("button",{name:"审批中心"}).click(); + await logout();await login("e2e_change_leader");await page.getByRole("link",{name:"审批中心"}).click();await page.getByRole("button",{name:/^目标联动选择/}).click(); const secondLinkage=page.getByRole("row").filter({hasText:"E2E 变更业务员"}).filter({hasText:"¥475.00"});await secondLinkage.getByRole("button",{name:"选择是否调整"}).click(); await page.getByLabel("处理方式").selectOption("adjust_parent");await page.getByLabel("联动原因").fill("需要同步调整小组目标");await page.getByRole("button",{name:"确认联动选择"}).click(); + await page.getByRole("button",{name:/^目标修改申请/}).click(); const changeCard=page.locator("section.workflow-card").filter({has:page.getByRole("heading",{name:"目标修改申请"})}); await expect(changeCard.getByRole("row").filter({hasText:"E2E 变更组长"}).filter({hasText:"需要同步调整小组目标"})).toContainText("待处理"); }); @@ -828,7 +860,7 @@ test("系统管理员通过页面办理组织异动并保留前后有效期", as await page.getByLabel("账号").fill("e2e_org_admin"); await page.getByLabel("密码",{exact:true}).fill("OrgAdmin@123"); await page.getByRole("button",{name:"进入 SampleFlow"}).click(); - await page.getByRole("button",{name:"组织架构"}).click(); + await page.getByRole("link",{name:"组织架构"}).click(); for(const departmentName of ["E2E 原部门","E2E 新部门"]){ await page.getByRole("button",{name:"新增组织"}).click(); @@ -932,7 +964,7 @@ test("系统管理员通过页面办理组织异动并保留前后有效期", as await page.getByLabel("账号",{exact:true}).fill("e2e_org_assistant"); await page.getByLabel("密码",{exact:true}).fill("OrgAssistant@123"); await page.getByRole("button",{name:"进入 SampleFlow"}).click(); - await page.getByRole("button",{name:"订单业绩",exact:true}).click(); + await page.getByRole("link",{name:"订单业绩",exact:true}).click(); await page.getByRole("button",{name:"录入新订单"}).click(); const createOrderDialog=page.getByRole("dialog",{name:"录入订单业绩"}); await page.getByLabel("订单编号").fill("ORG-TRANSFER-E2E-100"); @@ -1017,7 +1049,7 @@ test("订单搜索与不可变事件链在浏览器和数据库中保持一致", await page.getByLabel("账号").fill("e2e_ledger_assistant"); await page.getByLabel("密码", { exact: true }).fill("Ledger@123"); await page.getByRole("button", { name: "进入 SampleFlow" }).click(); - await page.getByRole("button", { name: "订单业绩", exact:true }).click(); + await page.getByRole("link", { name: "订单业绩", exact:true }).click(); await page.getByRole("button", { name: "录入新订单" }).click(); const createOrderDialog=page.getByRole("dialog",{name:"录入订单业绩"}); @@ -1105,8 +1137,14 @@ test("订单搜索与不可变事件链在浏览器和数据库中保持一致", await page.getByLabel("账号").fill("e2e_accounting_leader"); await page.getByLabel("密码", { exact: true }).fill("Ledger@123"); await page.getByRole("button",{name:"进入 SampleFlow"}).click(); - await page.getByRole("button",{name:"订单业绩",exact:true}).click(); + await page.getByRole("link",{name:"订单业绩",exact:true}).click(); await expect(page.getByRole("heading",{name:"记账治理工作台"})).toBeVisible(); + await page.getByRole("heading",{name:"记账治理工作台"}).click(); + await expect(page.getByLabel("记账期间每页条数")).toHaveValue("10"); + await expect(page.getByLabel("更正申请每页条数")).toHaveValue("10"); + await expect(page.getByLabel("历史核对每页条数")).toHaveValue("10"); + expect(await page.getByLabel("记账期间每页条数").locator("option").allTextContents()).toEqual(["10 条/页","20 条/页","50 条/页","100 条/页"]); + await page.getByText("组长核对确认",{exact:true}).click(); await page.getByLabel("定位订单").fill("当前列表无结果"); await page.getByRole("button",{name:"搜索",exact:true}).click(); await expect(page.getByText("没有符合当前组合条件的订单。",{exact:true})).toBeVisible(); @@ -1119,7 +1157,9 @@ test("订单搜索与不可变事件链在浏览器和数据库中保持一致", await page.getByLabel("账号").fill("e2e_accounting_hr"); await page.getByLabel("密码", { exact: true }).fill("Ledger@123"); await page.getByRole("button",{name:"进入 SampleFlow"}).click(); - await page.getByRole("button",{name:"订单业绩",exact:true}).click(); + await page.getByRole("link",{name:"订单业绩",exact:true}).click(); + await page.getByRole("heading",{name:"记账治理工作台"}).click(); + await page.getByText("人事关账",{exact:true}).click(); await page.getByLabel("记账月份").fill("2026-07"); await page.getByLabel("关账说明").fill("七月浏览器关账"); let closeRequests=0; @@ -1147,9 +1187,12 @@ test("订单搜索与不可变事件链在浏览器和数据库中保持一致", await page.getByLabel("账号").fill("e2e_accounting_leader"); await page.getByLabel("密码", { exact: true }).fill("Ledger@123"); await page.getByRole("button",{name:"进入 SampleFlow"}).click(); - await page.getByRole("button",{name:"订单业绩",exact:true}).click(); - await page.getByLabel("记账月份").fill("2026-07"); - const correctionForm=page.getByRole("heading",{name:"申请关闭月更正"}).locator(".."); + await page.getByRole("link",{name:"订单业绩",exact:true}).click(); + await page.getByRole("heading",{name:"记账治理工作台"}).click(); + const correctionTask=page.locator("details.governance-task").filter({hasText:"申请关闭月更正"}); + await correctionTask.getByText("申请关闭月更正",{exact:true}).click(); + const correctionForm=correctionTask.locator("form"); + await correctionForm.getByLabel("更正月份").fill("2026-07"); await correctionForm.getByLabel("精确订单编号").fill("不存在的订单"); await correctionForm.getByRole("button",{name:"查询订单"}).click(); await expect(correctionForm.getByText("未找到可访问的精确订单。",{exact:true})).toBeVisible(); @@ -1175,7 +1218,8 @@ test("订单搜索与不可变事件链在浏览器和数据库中保持一致", await page.getByLabel("账号").fill("e2e_accounting_hr"); await page.getByLabel("密码", { exact: true }).fill("Ledger@123"); await page.getByRole("button",{name:"进入 SampleFlow"}).click(); - await page.getByRole("button",{name:"订单业绩",exact:true}).click(); + await page.getByRole("link",{name:"订单业绩",exact:true}).click(); + await page.getByRole("heading",{name:"记账治理工作台"}).click(); await page.getByLabel("审批意见").fill("同意浏览器更正"); await page.getByRole("button",{name:"批准",exact:true}).click(); confirmation=page.getByRole("dialog",{name:"确认批准更正"}); @@ -1188,7 +1232,8 @@ test("订单搜索与不可变事件链在浏览器和数据库中保持一致", await page.getByLabel("账号").fill("e2e_accounting_leader"); await page.getByLabel("密码", { exact: true }).fill("Ledger@123"); await page.getByRole("button",{name:"进入 SampleFlow"}).click(); - await page.getByRole("button",{name:"订单业绩",exact:true}).click(); + await page.getByRole("link",{name:"订单业绩",exact:true}).click(); + await page.getByRole("heading",{name:"记账治理工作台"}).click(); await page.getByRole("button",{name:"执行更正"}).click(); const executionDialog=page.getByRole("dialog",{name:/执行更正 · CHAIN-E2E-110/}); await expect(executionDialog).toContainText("将追加不可变更正事件"); diff --git a/apps/web/e2e/money-inputs.spec.ts b/apps/web/e2e/money-inputs.spec.ts index 1c364e2..9e62483 100644 --- a/apps/web/e2e/money-inputs.spec.ts +++ b/apps/web/e2e/money-inputs.spec.ts @@ -40,7 +40,7 @@ test("空金额不会变成零元,并保留各业务动作的零值边界", as nextCursor: null, }), })); - await page.getByRole("button", { name: "订单业绩", exact: true }).click(); + await page.getByRole("link", { name: "订单业绩", exact: true }).click(); await page.route("**/api/performance/people*", (route) => route.fulfill({ status: 200, diff --git a/apps/web/e2e/onboarding.spec.ts b/apps/web/e2e/onboarding.spec.ts index 21b2ee5..a1b8f31 100644 --- a/apps/web/e2e/onboarding.spec.ts +++ b/apps/web/e2e/onboarding.spec.ts @@ -58,7 +58,7 @@ test("首次自动播放,完成后不再打扰且可以手动重播", async ({ roleCode: "sales_assistant", roleName: "销售助理", }); - await seedTestUser(database.url, { + const secondUserId = await seedTestUser(database.url, { username: "e2e_tour_second_user", displayName: "E2E 引导第二用户", password: "Tour@123", @@ -66,18 +66,24 @@ test("首次自动播放,完成后不再打扰且可以手动重播", async ({ roleName: "销售助理", }); + const legacyKeys = [ + `sampleflow:onboarding:v1:${userId}:sales_assistant:orders`, + `sampleflow:onboarding:v1:${secondUserId}:sales_assistant:all`, + ]; + await page.addInitScript((keys) => keys.forEach((key) => localStorage.setItem(key, "completed")), legacyKeys); await page.goto("/?page=orders"); await login(page, "e2e_tour_persistence"); const dialog = page.locator(".onboarding-bubble"); await expect(dialog.getByRole("heading", { name: "订单业绩" })).toBeVisible(); + expect(await page.evaluate((keys) => keys.map((key) => localStorage.getItem(key)), legacyKeys)).toEqual([null, null]); await page.keyboard.press("ArrowRight"); await expect(dialog.getByRole("heading", { name: "录入与导入" })).toBeVisible(); await page.keyboard.press("ArrowLeft"); await expect(dialog.getByRole("heading", { name: "订单业绩" })).toBeVisible(); await completeTour(page); - const pageKey = `sampleflow:onboarding:v1:${userId}:sales_assistant:orders`; + const pageKey = `sampleflow:onboarding:v2:${userId}:sales_assistant:orders`; expect(await page.evaluate((key) => localStorage.getItem(key), pageKey)).toBe("completed"); await page.reload(); await expect(dialog).toBeHidden(); @@ -88,12 +94,12 @@ test("首次自动播放,完成后不再打扰且可以手动重播", async ({ await expect(dialog.getByRole("heading", { name: "订单业绩" })).toBeVisible(); await dialog.getByRole("button", { name: "跳过本页" }).click(); - await page.getByRole("button", { name: "业绩分析", exact: true }).click(); + await page.getByRole("link", { name: "业绩分析", exact: true }).click(); await expect(dialog.getByRole("heading", { name: "地区与客户分析" })).toBeVisible(); await page.goBack(); await expect(page.getByRole("heading", { name: "订单业绩", exact: true })).toBeVisible(); await expect(dialog).toBeHidden(); - const analysisKey = `sampleflow:onboarding:v1:${userId}:sales_assistant:analysis`; + const analysisKey = `sampleflow:onboarding:v2:${userId}:sales_assistant:analysis`; expect(await page.evaluate((key) => localStorage.getItem(key), analysisKey)).toBeNull(); await page.getByRole("button", { name: "退出登录" }).click(); @@ -131,7 +137,7 @@ test("权限决定页面范围,角色变化使用独立状态且八个页面 await expect(page.locator(".onboarding-bubble")).toBeHidden(); await expect(page.getByRole("button", { name: "重播当前页面新手引导" })).toHaveCount(0); - await page.getByRole("button", { name: "账号管理", exact: true }).click(); + await page.getByRole("link", { name: "账号管理", exact: true }).click(); const dialog = page.locator(".onboarding-bubble"); await expect(dialog.getByRole("heading", { name: "从这里进入工作页面" })).toBeVisible(); await dialog.getByRole("button", { name: "跳过本页" }).click(); @@ -159,7 +165,7 @@ test("权限决定页面范围,角色变化使用独立状态且八个页面 ["审计查询", "审计查询"], ] as const; for (const [navigation, firstStep] of routes) { - await page.getByRole("button", { name: navigation, exact: true }).click(); + await page.getByRole("link", { name: navigation, exact: true }).click(); await expect(dialog.getByRole("heading", { name: firstStep })).toBeVisible(); if (navigation === "订单业绩") { for (let index = 0; index < 4; index += 1) await dialog.getByRole("button", { name: "下一步" }).click(); @@ -199,7 +205,7 @@ test("支持焦点约束、键盘退出、跳过全部和无障碍", async ({ da const spotlightDuration = await page.locator(".onboarding-spotlight").evaluate((element) => Number.parseFloat(getComputedStyle(element).transitionDuration)); expect(spotlightDuration).toBeLessThanOrEqual(0.001); - const backgroundNavigation = page.locator('nav button[aria-label="业绩分析"]'); + const backgroundNavigation = page.locator('nav a[aria-label="业绩分析"]'); const box = await backgroundNavigation.boundingBox(); expect(box).not.toBeNull(); await page.mouse.click(box!.x + box!.width / 2, box!.y + box!.height / 2); @@ -215,14 +221,18 @@ test("支持焦点约束、键盘退出、跳过全部和无障碍", async ({ da await expect(dialog).toBeHidden(); const replayOrders = page.getByRole("button", { name: "重播当前页面新手引导" }); await expect(replayOrders).toBeFocused(); - const pageKey = `sampleflow:onboarding:v1:${userId}:sales_assistant:orders`; + const pageKey = `sampleflow:onboarding:v2:${userId}:sales_assistant:orders`; expect(await page.evaluate((key) => localStorage.getItem(key), pageKey)).toBeNull(); + expect(await page.evaluate((key) => sessionStorage.getItem(key), pageKey)).toBe("dismissed"); - await replayOrders.click(); + await page.reload(); + await expect(dialog).toBeHidden(); + await page.getByRole("button", { name: "重播当前页面新手引导" }).click(); + expect(await page.evaluate((key) => sessionStorage.getItem(key), pageKey)).toBeNull(); await dialog.getByRole("button", { name: "跳过全部引导" }).click(); - const allKey = `sampleflow:onboarding:v1:${userId}:sales_assistant:all`; + const allKey = `sampleflow:onboarding:v2:${userId}:sales_assistant:all`; expect(await page.evaluate((key) => localStorage.getItem(key), allKey)).toBe("completed"); - await page.getByRole("button", { name: "业绩分析", exact: true }).click(); + await page.getByRole("link", { name: "业绩分析", exact: true }).click(); await expect(dialog).toBeHidden(); const replayAnalysis = page.getByRole("button", { name: "重播当前页面新手引导" }); await expect(replayAnalysis).toBeVisible(); diff --git a/apps/web/e2e/routes.spec.ts b/apps/web/e2e/routes.spec.ts index c7277d5..353dfad 100644 --- a/apps/web/e2e/routes.spec.ts +++ b/apps/web/e2e/routes.spec.ts @@ -48,7 +48,9 @@ test("八个主要页面使用稳定 URL、标题并支持刷新和浏览器历 ] as const; for (const route of routes) { - if (route.id !== "overview") await page.getByRole("button", { name: route.nav }).click(); + const navigation=page.getByRole("link", { name: route.nav }); + await expect(navigation).toHaveAttribute("href",new RegExp(`[?&]page=${route.id}(?:&|#|$)`)); + if (route.id !== "overview") await navigation.click(); await expect(page).toHaveURL(new RegExp(`[?&]page=${route.id}(?:&|$)`)); await expect(page.getByRole("heading", { name: route.heading, exact: true })).toBeVisible(); await expect(page).toHaveTitle(route.title); @@ -61,13 +63,13 @@ test("八个主要页面使用稳定 URL、标题并支持刷新和浏览器历 await expect(page.getByRole("heading", { name: "审计查询", exact: true })).toBeVisible(); await page.reload(); await expect(page.getByRole("heading", { name: "审计查询", exact: true })).toBeVisible(); - await page.getByRole("button", { name: "账号管理" }).click(); + await page.getByRole("link", { name: "账号管理" }).click(); await page.getByLabel("搜索账号").fill("e2e_routes"); await page.getByRole("button", { name: "搜索账号" }).click(); await expect(page).toHaveURL(/accountSearch=e2e_routes/); - await page.getByRole("button", { name: "账号管理" }).click(); + await page.getByRole("link", { name: "账号管理" }).click(); await expect(page).toHaveURL(/accountSearch=e2e_routes/); - await page.getByRole("button", { name: "审计查询" }).click(); + await page.getByRole("link", { name: "审计查询" }).click(); await page.goBack(); await expect(page.getByLabel("搜索账号")).toHaveValue("e2e_routes"); @@ -141,7 +143,7 @@ test("退出失败保留会话,受保护请求只在 401 时回到登录", asy }); await page.getByRole("button", { name: "导出当前授权范围匹配订单" }).click(); await expect(page.getByRole("heading", { name: "登录系统", exact: true })).toBeVisible(); - await expect(page).toHaveURL(/\?page=orders&orderSearch=keep&orderPage=1&orderPageSize=20$/); + await expect(page).toHaveURL(/\?page=orders&orderSearch=keep&orderPage=1&orderPageSize=10$/); }); test("目标创建原样提交超出 JavaScript 安全整数范围的标识", async ({ database, page }) => { @@ -226,14 +228,14 @@ test("目标、组织、账号与审计查询区分失败和真实空集", async await page.route("**/api/audits*",async(route)=>route.fulfill({status:failures.audits?503:200,contentType:"application/json",body:failures.audits?'{"message":"审计服务不可用"}':'{"audits":[],"nextCursor":null}'})); await page.goto("/");await login(page,"e2e_truthful_states"); - await page.getByRole("button",{name:"目标管理"}).click(); + await page.getByRole("link",{name:"目标管理"}).click(); await expect(page.getByRole("alert")).toHaveText("目标服务不可用"); await expect(page.getByText("0 条记录",{exact:true})).toHaveCount(0); await expect(page.getByText(/暂无目标/)).toHaveCount(0); failures.goals=false;await page.getByRole("button",{name:"重试目标"}).click(); await expect(page.getByText(/暂无目标/)).toBeVisible(); - await page.getByRole("button",{name:"组织架构"}).click(); + await page.getByRole("link",{name:"组织架构"}).click(); await expect(page.getByRole("alert")).toHaveText("组织服务不可用"); await expect(page.getByText("0 个组织单元",{exact:true})).toHaveCount(0); await expect(page.getByText("暂无当前有效任职",{exact:true})).toHaveCount(0); @@ -241,7 +243,7 @@ test("目标、组织、账号与审计查询区分失败和真实空集", async await expect(page.getByText("暂无组织单元",{exact:true})).toBeVisible(); await expect(page.getByText("暂无当前有效任职",{exact:true})).toBeVisible(); - await page.getByRole("button",{name:"账号管理"}).click(); + await page.getByRole("link",{name:"账号管理"}).click(); await expect(page.getByRole("alert")).toHaveText("账号服务不可用"); await expect(page.getByText(/本页 0 个账号/)).toHaveCount(0); await expect(page.getByText("暂无角色权限定义",{exact:true})).toHaveCount(0); @@ -249,7 +251,7 @@ test("目标、组织、账号与审计查询区分失败和真实空集", async await expect(page.getByText("暂无账号数据。",{exact:true})).toBeVisible(); await expect(page.getByText("暂无角色权限定义",{exact:true})).toBeVisible(); - await page.getByRole("button",{name:"审计查询"}).click(); + await page.getByRole("link",{name:"审计查询"}).click(); await expect(page.getByRole("alert")).toHaveText("审计服务不可用"); await expect(page.getByText("本页 0 条记录",{exact:true})).toHaveCount(0); failures.audits=false;await page.getByRole("button",{name:"重试查询"}).click(); diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 8fd7e90..69df15d 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -74,6 +74,7 @@ function Login({ onLogin }: { onLogin: (user: User) => void }) { useEffect(()=>{document.title="登录 — SampleFlow";},[]); const [submitting, setSubmitting] = useState(false); const [readiness, setReadiness] = useState<"checking" | "ready" | "unavailable">("checking"); + const readinessText=import.meta.env.DEV?{checking:"正在检查 API 与数据库",ready:"前端、API 与数据库已连接",unavailable:"API 或数据库暂不可用"}:{checking:"正在连接系统",ready:"系统服务正常",unavailable:"系统暂不可用,请稍后重试"}; useEffect(() => { let active = true; let controller: AbortController | null = null; @@ -93,7 +94,7 @@ function Login({ onLogin }: { onLogin: (user: User) => void }) { return () => { active = false; controller?.abort(); window.clearInterval(timer); }; }, []); async function submit(event: FormEvent) { - event.preventDefault(); setSubmitting(true); setMessage(""); + event.preventDefault(); if (submitting || !event.currentTarget.reportValidity()) return; setSubmitting(true); setMessage(""); try { const formData = new FormData(event.currentTarget); const submittedUsername = String(formData.get("username") ?? ""); @@ -109,6 +110,6 @@ function Login({ onLogin }: { onLogin: (user: User) => void }) { } return
瑞源生物 Pronetbio

SampleFlow

每一笔业绩,都有清晰的来路与责任。

面向销售到样业务的目标、订单、组织归属与审批系统。历史不重写,调整有事件,结果可追溯。

实时业绩事件
角色权限分离
集中数据与审计
-

销售到样业绩管理

登录系统

{import.meta.env.DEV ? "开发环境已预填销售助理演示账号" : "请使用管理员分配的账号登录"}
setUsername(e.target.value)} autoComplete="username" />{message ?

{message}

: null}
{{checking:"正在检查 API 与数据库",ready:"前端、API 与数据库已连接",unavailable:"API 或数据库暂不可用"}[readiness]}
+

销售到样业绩管理

登录系统

{import.meta.env.DEV ? "开发环境已预填销售助理演示账号" : "请使用管理员分配的账号登录"}
setUsername(e.target.value)} autoComplete="username" />{message ?

{message}

: null}
{readinessText[readiness]}
; } diff --git a/apps/web/src/app-api.ts b/apps/web/src/app-api.ts index 4849e06..b362ffe 100644 --- a/apps/web/src/app-api.ts +++ b/apps/web/src/app-api.ts @@ -70,7 +70,7 @@ const auditActionNames:Record={ const auditDomainNames:Record={auth:"账号",organization:"组织",performance:"业绩",import:"导入",goal:"目标",audit:"审计",development:"开发维护"}; const auditEntityNames:Record={user:"账号",org_unit:"组织单元",org_membership:"人员任职",org_responsibility:"负责人职责",performance_order:"业绩订单",performance_event:"业绩事件",goal:"目标",goal_version:"目标版本",goal_change_request:"目标修改申请",goal_linkage_decision:"目标联动待办",import_config:"导入配置",import_batch:"导入批次"}; const auditFieldNames:Record={roles:"角色",personId:"人员",isActive:"是否启用",name:"名称",unitType:"组织类型",parentId:"上级组织",effectiveFrom:"生效日期",effectiveTo:"结束日期",effectiveOn:"办理日期",result:"处理结果",ownerPersonId:"责任人",orgUnitId:"责任组织",parentGoalId:"上级目标",periodMonth:"目标月份",level:"目标层级",amount:"目标金额",previousAmount:"原目标金额",newAmount:"新目标金额",changeReason:"变更原因",status:"状态",statement:"确认声明",confirmedAt:"确认时间",decision:"处理决定",comment:"处理意见",requestId:"请求标识",rowCount:"记录数",fileSha256:"文件校验值",failureCode:"失败原因",filterSummary:"筛选范围",customer:"客户",currentRevenue:"当前营业额",countedAmount:"计入业绩",reason:"原因",sourceSha256:"来源文件校验值",sourceFilename:"来源文件",predecessor:"原负责人",successor:"继任负责人",username:"账号",displayName:"姓名"}; -const auditValueNames:Record={succeeded:"成功",completed:"已完成",blocked:"已阻止",approved:"已批准",rejected:"已拒绝",pending:"待处理",department:"部门",group:"小组",personal:"个人",sales_manager:"销售经理总目标",leader:"小组负责人",supervisor:"部门主管"}; +const auditValueNames:Record={succeeded:"成功",completed:"已完成",blocked:"已阻止",approved:"已批准",rejected:"已拒绝",pending:"待处理",pending_signature:"待责任人确认",pending_gm:"待总经理审批",pending_hr:"待人事审批",active:"已生效",superseded:"已替代",accepted:"已接受",withdrawn:"已撤回",invalidated:"已失效",department:"部门",group:"小组",personal:"个人",sales_manager:"销售经理总目标",leader:"小组负责人",supervisor:"部门主管"}; export function auditActionName(action:string){return auditActionNames[action]??`${auditDomainNames[action.split(".")[0]??""]??"其他"}业务操作`;} export function auditEntityName(entityType:string){return auditEntityNames[entityType]??"业务记录";} @@ -80,8 +80,14 @@ function auditValueText(value:unknown):string{ if(typeof value==="string")return roleNames[value]??auditValueNames[value]??value; if(typeof value==="number"||typeof value==="bigint")return String(value); if(Array.isArray(value))return value.length?value.map(auditValueText).join("、"):"无"; - if(typeof value==="object")return Object.entries(value as Record).map(([key,item])=>`${auditFieldNames[key]??"补充信息"}:${auditValueText(item)}`).join(";")||"无"; + if(typeof value==="object")return Object.entries(value as Record).map(([key,item])=>`${auditFieldNames[key]??key}:${auditValueText(item)}`).join(";")||"无"; return String(value); } export function auditDataText(value:unknown){return value===null||value===undefined?"—":auditValueText(value);} +export function auditChangeRows(beforeData:unknown,afterData:unknown){ + const record=(value:unknown):Record=>value&&typeof value==="object"&&!Array.isArray(value)?value as Record:value===null||value===undefined?{}:{value}; + const before=record(beforeData);const after=record(afterData);const keys=[...new Set([...Object.keys(before),...Object.keys(after)])]; + return keys.map((key)=>({key,label:auditFieldNames[key]??(key==="value"?"值":key),before:auditValueText(before[key]),after:auditValueText(after[key])})).filter((item)=>item.before!==item.after); +} +export function auditChangeSummary(beforeData:unknown,afterData:unknown){const rows=auditChangeRows(beforeData,afterData);return rows.length?`${rows.slice(0,2).map((item)=>`${item.label}:${item.after}`).join(";")}${rows.length>2?` 等 ${rows.length} 项`:""}`:"无字段变化";} export function goalAuditName(action:string){return auditActionName(action);} diff --git a/apps/web/src/app-types.ts b/apps/web/src/app-types.ts index 6c434a1..2821789 100644 --- a/apps/web/src/app-types.ts +++ b/apps/web/src/app-types.ts @@ -7,7 +7,7 @@ export type OrderFilters = { search:string;month:string;status:string;salesperso export type PerformanceEvent = { id:string; sequence:number; eventType:string; deltaAmount:string; resultingCurrentRevenue:string; resultingCountedAmount:string; resultingLifecycleState:OrderLifecycle|null; accountingMonth:string; occurredOn:string; occurredAt:string; reason:string|null; actorName:string|null; salespersonName:string; departmentName:string|null; groupName:string|null; leaderName:string|null; supervisorName:string|null; serviceType:string|null; collaboratorName:string|null; collaborationRatio:string|null; collaboratorDepartmentName:string|null; collaboratorGroupName:string|null; businessRegionCode:string|null; businessRegionSourceText:string|null; customerUnit:string|null }; export type AccountingPeriod={periodMonth:string;status:"open"|"closed";version:number;needsReclose:boolean;verifiedAt:string|null;verifiedBy:string|null;closedAt:string|null;closedBy:string|null}; export type AccountingCorrection={id:string;periodMonth:string;orderId:string;orderNo:string;eventType:string;occurredOn:string;reason:string;businessRegionCode:string|null;businessRegionSourceText:string|null;customerUnit:string|null;analysisDimensionEvidence:string|null;status:"pending"|"approved"|"rejected"|"consumed"|"revoked";requestedBy:string;reviewedBy:string|null;reviewNote:string|null;expiresAt:string|null}; -export type HistoricalReview={id:string;orderId:string;orderNo:string;lifecycleState:"active"|"paused"|"zero";currentRevenue:string;conclusion:string;evidence:string;reason:string;status:"pending"|"approved"|"rejected";requestedBy:string;reviewedBy:string|null;reviewNote:string|null}; +export type HistoricalReview={id:string;orderId:string;orderNo:string;lifecycleState:"active"|"paused"|"zero"|"receivable_pending";currentRevenue:string;conclusion:string;evidence:string;reason:string;status:"pending"|"approved"|"rejected";requestedBy:string;reviewedBy:string|null;reviewNote:string|null}; export type PersonalAchievementEvent = {id:string;orderId:string;orderNo:string;customerName:string;eventType:string;deltaAmount:string;accountingMonth:string;occurredOn:string;sequence:number;reason:string|null;resultingCountedAmount:string;resultingLifecycleState:OrderLifecycle|null;departmentName:string|null;groupName:string|null}; export type PersonalAchievement = { goalId:string|null;periodMonth:string;targetAmount:string|null;actualAmount:string;gapAmount:string|null;achievementRate:string|null;timeProgressRate:string|null;progressVariance:string|null;calculationReason:"PERIOD_IN_FUTURE"|"TARGET_NOT_ACTIVE"|"TARGET_AMOUNT_NOT_POSITIVE"|"TARGET_SCOPE_AMBIGUOUS"|null;eventCount:number }; export type PersonalAchievementDrilldown = PersonalAchievement&{events:PersonalAchievementEvent[]}; diff --git a/apps/web/src/dashboard.tsx b/apps/web/src/dashboard.tsx index 1305e1d..b1372cb 100644 --- a/apps/web/src/dashboard.tsx +++ b/apps/web/src/dashboard.tsx @@ -25,7 +25,6 @@ export function Dashboard({ user, onLogout, onChangePassword }: { user: User; on const navigate=useCallback((page:PageId,mode:"push"|"replace"="push")=>{if(mode==="push"&&readPageId(window.location.search)===page)return;const params=new URLSearchParams(window.location.search);params.set("page",page);window.history[`${mode}State`]({},"",`${window.location.pathname}?${params.toString()}${window.location.hash}`);focusPageHeading.current=mode==="push";setActive(page);},[]); useEffect(()=>{if(!readPageId(window.location.search))navigate(defaultPage,"replace");const restore=()=>{const page=readPageId(window.location.search);if(page){focusPageHeading.current=true;setActive(page);}else navigate(defaultPage,"replace");};window.addEventListener("popstate",restore);return()=>window.removeEventListener("popstate",restore);},[defaultPage,navigate]); useEffect(()=>{document.title=`${PAGE_ROUTES[active].label} — SampleFlow`;if(!focusPageHeading.current)return;focusPageHeading.current=false;const frame=window.requestAnimationFrame(()=>{const heading=document.querySelector("#main-content h1");heading?.setAttribute("tabindex","-1");heading?.focus();});return()=>window.cancelAnimationFrame(frame);},[active]); - useEffect(()=>{const root=document.getElementById("main-content");if(!root)return;const enhance=()=>root.querySelectorAll(".orders-table-wrap").forEach((region)=>{region.tabIndex=0;region.setAttribute("role","region");if(region.hasAttribute("aria-label")||region.hasAttribute("aria-labelledby"))return;const tableName=region.querySelector("table")?.getAttribute("aria-label");const owner=region.closest("[aria-labelledby]");const ownerName=owner?.getAttribute("aria-labelledby")?.split(/\s+/).map((id)=>document.getElementById(id)?.textContent?.trim()).filter(Boolean).join(" ");const heading=region.closest("section,article,.modal")?.querySelector("h2,h3,h4")?.textContent?.trim();const firstColumn=region.querySelector("th")?.textContent?.trim();region.setAttribute("aria-label",`${tableName||ownerName||heading||firstColumn||"数据表格"},可横向滚动`);});enhance();const observer=new MutationObserver(enhance);observer.observe(root,{childList:true,subtree:true});return()=>observer.disconnect();},[active]); async function logout() { if(loggingOut)return; setLoggingOut(true);setLogoutError(""); @@ -51,5 +50,5 @@ export function Dashboard({ user, onLogout, onChangePassword }: { user: User; on : active === "accounts" ? : navigate("orders")} />; - return
跳到主要内容
{content}
; + return
跳到主要内容
{content}
; } diff --git a/apps/web/src/onboarding.tsx b/apps/web/src/onboarding.tsx index 30b52be..0a1b22d 100644 --- a/apps/web/src/onboarding.tsx +++ b/apps/web/src/onboarding.tsx @@ -13,18 +13,36 @@ type TourStep = Readonly<{ type Spotlight = Readonly<{ top: number; left: number; width: number; height: number }>; -const STORAGE_VERSION = "v1"; +const STORAGE_VERSION = "v2"; +const STORAGE_PREFIX = "sampleflow:onboarding:"; +const CURRENT_STORAGE_PREFIX = `${STORAGE_PREFIX}${STORAGE_VERSION}:`; const memoryFlags = new Set(); +const sessionDismissedFlags = new Set(); +let legacyFlagsCleared = false; + +function clearLegacyFlags(): void { + if (legacyFlagsCleared) return; + legacyFlagsCleared = true; + try { + for (let index = window.localStorage.length - 1; index >= 0; index -= 1) { + const key = window.localStorage.key(index); + if (key?.startsWith(STORAGE_PREFIX) && !key.startsWith(CURRENT_STORAGE_PREFIX)) window.localStorage.removeItem(key); + } + } catch { + // 浏览器禁用存储时按未完成引导处理。 + } +} function roleKey(user: User): string { return [...user.roles].sort().join("."); } function storageKey(user: User, page: PageId | "all"): string { - return `sampleflow:onboarding:${STORAGE_VERSION}:${encodeURIComponent(user.id)}:${roleKey(user)}:${page}`; + return `${CURRENT_STORAGE_PREFIX}${encodeURIComponent(user.id)}:${roleKey(user)}:${page}`; } function hasFlag(key: string): boolean { + clearLegacyFlags(); if (memoryFlags.has(key)) return true; try { return window.localStorage.getItem(key) === "completed"; @@ -34,6 +52,7 @@ function hasFlag(key: string): boolean { } function saveFlag(key: string): void { + clearLegacyFlags(); memoryFlags.add(key); try { window.localStorage.setItem(key, "completed"); @@ -42,12 +61,16 @@ function saveFlag(key: string): void { } } +function isDismissedForSession(key:string):boolean{if(sessionDismissedFlags.has(key))return true;try{return window.sessionStorage.getItem(key)==="dismissed";}catch{return false;}} +function dismissForSession(key:string):void{sessionDismissedFlags.add(key);try{window.sessionStorage.setItem(key,"dismissed");}catch{/* 当前运行期内仍避免重复打扰。 */}} +function clearSessionDismissal(key:string):void{sessionDismissedFlags.delete(key);try{window.sessionStorage.removeItem(key);}catch{/* 手动重播仍可继续。 */}} + function target(page: PageId, anchor: string): string { if (page === "goals" || page === "approvals") return { "page-header": ".goals-page > header", "page-actions": ".goals-page > header .header-actions", "primary-content": ".goals-page > .orders-card", - "role-workspace": ".goals-page > .workflow-card", + "role-workspace": ".goals-page > .approval-tabs", }[anchor] ?? ".goals-page"; if (page === "analysis") return anchor === "page-header" ? ".analysis-page .analysis-header" : ".analysis-page > .analysis-panel"; if (page === "organization") return { @@ -152,7 +175,7 @@ function pageSteps(page: PageId, user: User, includeNavigation: boolean): TourSt if (page === "approvals") steps.push( { target: target(page, "page-header"), title: "审批中心", description: approvalDescription(user) }, { target: target(page, "primary-content"), title: "待确认与待审批", description: "操作按钮只会出现在当前人员可处理的目标版本上;冲突后页面会重新读取权威状态。" }, - { target: target(page, "role-workspace"), title: "修改与联动", description: "修改申请不会直接覆盖生效目标;下级目标变化后,本级负责人必须明确选择是否联动调整。" }, + { target: target(page, "role-workspace"), title: "修改与联动", description: "通过页签切换目标审批、修改申请与联动选择;修改不会直接覆盖生效目标。" }, ); if (page === "analysis") steps.push( @@ -235,11 +258,12 @@ export function Onboarding({ user, page, canOpen, includeNavigation }: { user: U setSteps(available); setStepIndex(0); setOpen(true); + clearSessionDismissal(pageFlag); return true; - }, [canOpen, configuredSteps]); + }, [canOpen, configuredSteps, pageFlag]); useEffect(() => { - if (!canOpen || hasFlag(pageFlag) || hasFlag(allFlag) || autoAttempted.current.has(pageFlag)) return; + if (!canOpen || hasFlag(pageFlag) || hasFlag(allFlag) || isDismissedForSession(pageFlag) || autoAttempted.current.has(pageFlag)) return; const tryStart = () => { if (page === "overview" && !document.querySelector('[data-onboarding-page="overview"][data-onboarding-ready="true"]')) return false; if (!start()) return false; @@ -311,11 +335,12 @@ export function Onboarding({ user, page, canOpen, includeNavigation }: { user: U const next = useCallback(() => setStepIndex((index) => Math.min(steps.length - 1, index + 1)), [steps.length]); const completePage = useCallback(() => { saveFlag(pageFlag); close(); }, [close, pageFlag]); const skipAll = useCallback(() => { saveFlag(allFlag); close(); }, [allFlag, close]); + const dismiss = useCallback(() => { dismissForSession(pageFlag); close(); }, [close, pageFlag]); useEffect(() => { if (!open) return; function keydown(event: KeyboardEvent) { - if (event.key === "Escape") { event.preventDefault(); close(); return; } + if (event.key === "Escape") { event.preventDefault(); dismiss(); return; } if (event.key === "ArrowLeft") { event.preventDefault(); previous(); return; } if (event.key === "ArrowRight") { event.preventDefault(); stepIndex === steps.length - 1 ? completePage() : next(); return; } if (event.key !== "Tab") return; @@ -331,7 +356,7 @@ export function Onboarding({ user, page, canOpen, includeNavigation }: { user: U } document.addEventListener("keydown", keydown); return () => document.removeEventListener("keydown", keydown); - }, [close, completePage, next, open, previous, stepIndex, steps.length]); + }, [completePage, dismiss, next, open, previous, stepIndex, steps.length]); const position = spotlight ? bubblePosition(spotlight) : null; return <> @@ -351,7 +376,7 @@ export function Onboarding({ user, page, canOpen, includeNavigation }: { user: U - ← → 切换步骤,Esc 关闭 + ← → 切换步骤,Esc 稍后再看(本次会话不再自动提示) , document.body, diff --git a/apps/web/src/pages/accounts-page.tsx b/apps/web/src/pages/accounts-page.tsx index 6d59626..b270202 100644 --- a/apps/web/src/pages/accounts-page.tsx +++ b/apps/web/src/pages/accounts-page.tsx @@ -2,7 +2,7 @@ import { type FormEvent, useCallback, useEffect, useRef, useState } from "react" import { Plus, Search, ShieldCheck, X } from "lucide-react"; import { apiFetch, readResponseJson, roleNames } from "../app-api"; import type { AdminUser, PersonOption, RolePermission, User } from "../app-types"; -import { Field, Modal, Pagination, parsePageNumber, parsePageSize, type PageSize, usePagination } from "../shared-ui"; +import { Field, Modal, Pagination, confirmDiscard, parsePageNumber, parsePageSize, type PageSize, usePagination } from "../shared-ui"; function readAccountUrlState(){const params=new URLSearchParams(window.location.search);return{search:params.get("accountSearch")??"",page:parsePageNumber(params.get("accountPage")),pageSize:parsePageSize(params.get("accountPageSize"))};} function writeAccountUrlState(search:string,page:number,pageSize:PageSize,mode:"push"|"replace"="push"){const params=new URLSearchParams(window.location.search);if(search)params.set("accountSearch",search);else params.delete("accountSearch");params.set("accountPage",String(page));params.set("accountPageSize",String(pageSize));window.history[mode==="push"?"pushState":"replaceState"]({},"",`${window.location.pathname}?${params.toString()}${window.location.hash}`);} @@ -26,20 +26,27 @@ export function AccountsPage({user}:{user:User}){ async function resetPassword(item:AdminUser){if(pendingAccountIds.includes(item.id))return;setAccountPending(item.id,true);setMessage("");try{const response=await apiFetch(`/api/admin/users/${item.id}/reset-password`,{method:"POST",headers:{"content-type":"application/json"},body:"{}"});const data=await readResponseJson<{message?:string;temporaryPassword?:string;temporaryPasswordExpiresAt?:string}>(response,"密码重置响应无效,请重试。");if(!response.ok){setMessage(data.message??"密码重置失败");return;}if(!data.temporaryPassword||!data.temporaryPasswordExpiresAt){setMessage("密码重置结果不确定,请勿重复操作;请核对账号状态后再决定是否重新重置。");return;}setTemporaryCredential({username:item.username,password:data.temporaryPassword,expiresAt:data.temporaryPasswordExpiresAt});refresh();}catch{setMessage("密码重置结果不确定,请勿重复操作;请核对账号状态后再决定是否重新重置。");}finally{setAccountPending(item.id,false);}} async function confirmAccountAction(){if(!accountAction)return;const action=accountAction;await(action.kind==="reset"?resetPassword(action.item):toggle(action.item));setAccountAction(null);} const accountActionPending=accountAction?pendingAccountIds.includes(accountAction.item.id):false; - return

账号管理

系统管理权限与业务权限分离;业务角色必须显式分配

{isAdmin?:null}
{!isAdmin?
仅独立系统管理员可以维护账号和角色。
:null}{message?

{message}

:null}{isAdmin?<>

系统账号

{loading?"正在查询…":loadError?"查询失败":`本页 ${users.length} 个账号`}
setIsComposing(true)} onCompositionEnd={()=>setIsComposing(false)} onChange={(event)=>setSearchInput(event.target.value)} placeholder="账号或姓名"/>{searchInput?:null}
{loadError?

{loadError}

:null}
{!loading&&!loadError&&users.length===0?:users.map((item)=>)}
账号姓名角色状态操作
{search?"没有符合条件的账号。":"暂无账号数据。"}
{item.username}{item.displayName}{item.roles.map((role)=>roleNames[role]??role).join("、")}{item.isActive?"启用":"停用"}

角色权限说明

多角色账号取各角色权限并集;系统管理员角色本身不增加业务权限
{!loading&&!loadError&&permissionMatrix.length===0?:permissionPagination.items.map((item)=>)}
角色数据范围业务操作目标职责导出权限明确禁止
暂无角色权限定义
{item.name}{scopeName(item.businessScope)}{item.businessOperations.join(";")}{item.targetResponsibilities}{item.exportPermission}{item.forbidden.join(";")}
:null}{showCreate?setShowCreate(false)} onUncertain={(username)=>{setShowCreate(false);setMessage("创建结果不确定,已重新查询该账号;如账号已存在,请重置密码生成新的临时密码。");commitSearch(username);}} onSaved={async()=>{setShowCreate(false);restart();}}/>:null}{roleTarget?setRoleTarget(null)} onSaved={async()=>{const changedSelf=roleTarget.id===user.id;setRoleTarget(null);setMessage(`${roleTarget.username} 的角色已更新。`);if(changedSelf)window.location.reload();else refresh();}}/>:null}{accountAction?setAccountAction(null)} preventClose={accountActionPending}>

操作账号:{accountAction.item.username}({accountAction.item.displayName})

:null}{temporaryCredential?setTemporaryCredential(null)}>

账号:{temporaryCredential.username}

{temporaryCredential.password}失效时间:{new Date(temporaryCredential.expiresAt).toLocaleString("zh-CN")}
:null}
; + return

账号管理

系统管理权限与业务权限分离;业务角色必须显式分配

{isAdmin?:null}
{!isAdmin?
仅独立系统管理员可以维护账号和角色。
:null}{message?

{message}

:null}{isAdmin?<>

系统账号

{loading?"正在查询…":loadError?"查询失败":`本页 ${users.length} 个账号`}
setIsComposing(true)} onCompositionEnd={()=>setIsComposing(false)} onChange={(event)=>setSearchInput(event.target.value)} placeholder="账号或姓名"/>{searchInput?:null}
{loadError?

{loadError}

:null}
{!loading&&!loadError&&users.length===0?:users.map((item)=>)}
账号姓名角色状态操作
{search?"没有符合条件的账号。":"暂无账号数据。"}
{item.username}{item.displayName}{item.roles.map((role)=>roleNames[role]??role).join("、")}{item.isActive?"启用":"停用"}

角色权限说明

多角色账号取各角色权限并集;系统管理员角色本身不增加业务权限
{!loading&&!loadError&&permissionMatrix.length===0?:permissionPagination.items.map((item)=>)}
角色数据范围业务操作目标职责导出权限明确禁止
暂无角色权限定义
{item.name}{scopeName(item.businessScope)}{item.businessOperations.join(";")}{item.targetResponsibilities}{item.exportPermission}{item.forbidden.join(";")}
:null}{showCreate?setShowCreate(false)} onUncertain={(username)=>{setShowCreate(false);setMessage("创建结果不确定,已重新查询该账号;如账号已存在,请重置密码生成新的临时密码。");commitSearch(username);}} onSaved={async()=>{setShowCreate(false);restart();}}/>:null}{roleTarget?setRoleTarget(null)} onSaved={async()=>{const changedSelf=roleTarget.id===user.id;setRoleTarget(null);setMessage(`${roleTarget.username} 的角色已更新。`);if(changedSelf)window.location.reload();else refresh();}}/>:null}{accountAction?setAccountAction(null)} preventClose={accountActionPending}>

操作账号:{accountAction.item.username}({accountAction.item.displayName})

:null}{temporaryCredential?{if(confirmDiscard(true))setTemporaryCredential(null);}}>setTemporaryCredential(null)}/>:null}
; } function scopeName(scope:RolePermission["businessScope"]):string{return{none:"无业务范围",self:"仅本人",group:"本人及所负责小组",department:"本人及所负责部门",all:"全公司 / 销售组织"}[scope];} +function TemporaryPasswordResult({username,password,expiresAt,onSaved}:{username:string;password:string;expiresAt:string;onSaved:()=>void|Promise}){ + const[copyStatus,setCopyStatus]=useState(""); + async function copy(){try{await navigator.clipboard.writeText(password);setCopyStatus("临时密码已复制,请保存到安全位置。");}catch{setCopyStatus("复制失败,请手动选择上方临时密码。");}} + return

请立即安全保存临时密码,关闭后无法再次查看。

账号:{username}{password}失效时间:{new Date(expiresAt).toLocaleString("zh-CN")}{copyStatus?

{copyStatus}

:null}
; +} + function RoleOptions({roles,selected,onToggle}:{roles:Array<{code:string;name:string}>;selected:string[];onToggle:(role:string)=>void}){return
固定角色(多选){roles.map((role)=>)}
} function EditAccountRoles({item,roles,onClose,onSaved}:{item:AdminUser;roles:Array<{code:string;name:string}>;onClose:()=>void;onSaved:()=>Promise}){const[selected,setSelected]=useState(item.roles);const[error,setError]=useState("");const[saving,setSaving]=useState(false);function toggle(role:string){setSelected((current)=>current.includes(role)?current.filter((item)=>item!==role):[...current,role]);}async function submit(event:FormEvent){event.preventDefault();if(saving)return;if(selected.length===0){setError("至少保留一个固定角色。");return;}setSaving(true);setError("");try{const response=await apiFetch(`/api/admin/users/${item.id}/roles`,{method:"PATCH",headers:{"content-type":"application/json"},body:JSON.stringify({roles:selected})});const data=await readResponseJson<{message?:string}>(response,"角色修改响应无效,请重试。");if(!response.ok){setError(data.message??"角色修改失败");return;}await onSaved();}catch(failure){setError(failure instanceof Error?failure.message:"网络异常,角色修改失败,请重试。");}finally{setSaving(false);}}return
{error?

{error}

:null}
} function CreateAccount({roles,onClose,onUncertain,onSaved}:{roles:Array<{code:string;name:string}>;onClose:()=>void;onUncertain:(username:string)=>void;onSaved:()=>Promise}){ const[username,setUsername]=useState("");const[displayName,setDisplayName]=useState("");const[selectedRoles,setSelectedRoles]=useState(()=>roles[0]?[roles[0].code]:[]);const[personMode,setPersonMode]=useState<"new"|"existing">("new");const[personId,setPersonId]=useState("");const[people,setPeople]=useState([]);const[peopleLoading,setPeopleLoading]=useState(true);const[peopleError,setPeopleError]=useState("");const[error,setError]=useState("");const[created,setCreated]=useState<{temporaryPassword:string;temporaryPasswordExpiresAt:string}|null>(null);const[saving,setSaving]=useState(false); + const initialRole=roles[0]?.code;const dirty=!!created||username!==""||displayName!==""||personMode!=="new"||personId!==""||selectedRoles.length!==(initialRole?1:0)||selectedRoles[0]!==initialRole;const requestClose=()=>{if(confirmDiscard(dirty))onClose();}; function loadPeople(){setPeopleLoading(true);setPeopleError("");apiFetch("/api/admin/people").then(async(response)=>{const data=await readResponseJson<{people?:PersonOption[];message?:string}>(response,"人员列表响应无效,请重试。");if(!response.ok)throw new Error(data.message??"人员列表加载失败");setPeople((data.people??[]).filter((person)=>!person.username));}).catch((reason)=>setPeopleError(reason instanceof Error?reason.message:"人员列表加载失败")).finally(()=>setPeopleLoading(false));} useEffect(loadPeople,[]); function toggleRole(role:string){setSelectedRoles((current)=>current.includes(role)?current.filter((item)=>item!==role):[...current,role]);} - async function submit(event:FormEvent){event.preventDefault();if(saving)return;if(selectedRoles.length===0){setError("至少选择一个固定角色。");return;}if(personMode==="existing"&&!personId){setError("请选择要绑定的已有人员。");return;}setSaving(true);setError("");try{const response=await apiFetch("/api/admin/users",{method:"POST",headers:{"content-type":"application/json"},body:JSON.stringify({username,displayName,roles:selectedRoles,personId:personMode==="existing"?personId:null})});if(!response.ok){const data=await response.json().catch(()=>({})) as {message?:string};setError(data.message??"创建账号失败,请重试。");return;}const data=await readResponseJson<{temporaryPassword?:string;temporaryPasswordExpiresAt?:string}>(response,"创建账号响应无效,请重试。");if(!data.temporaryPassword||!data.temporaryPasswordExpiresAt){onUncertain(username);return;}setCreated({temporaryPassword:data.temporaryPassword,temporaryPasswordExpiresAt:data.temporaryPasswordExpiresAt});}catch{onUncertain(username);}finally{setSaving(false);}} - return {created?

请立即安全保存临时密码,关闭后无法再次查看。

{created.temporaryPassword}失效时间:{new Date(created.temporaryPasswordExpiresAt).toLocaleString("zh-CN")}
:
人员身份
{peopleLoading?

正在读取可绑定人员…

:peopleError?

{peopleError}

:people.length===0?

当前没有未绑定账号的人员,只能创建新人员身份。

:personMode==="existing"?:

将为此账号创建新的人员身份;如人员已存在,请改选“绑定已有人员”。

}{error?

{error}

:null}
}
; + async function submit(event:FormEvent){event.preventDefault();if(saving||!event.currentTarget.reportValidity())return;if(selectedRoles.length===0){setError("至少选择一个固定角色。");return;}if(personMode==="existing"&&!personId){setError("请选择要绑定的已有人员。");return;}setSaving(true);setError("");try{const response=await apiFetch("/api/admin/users",{method:"POST",headers:{"content-type":"application/json"},body:JSON.stringify({username,displayName,roles:selectedRoles,personId:personMode==="existing"?personId:null})});if(!response.ok){const data=await response.json().catch(()=>({})) as {message?:string};setError(data.message??"创建账号失败,请重试。");return;}const data=await readResponseJson<{temporaryPassword?:string;temporaryPasswordExpiresAt?:string}>(response,"创建账号响应无效,请重试。");if(!data.temporaryPassword||!data.temporaryPasswordExpiresAt){onUncertain(username);return;}setCreated({temporaryPassword:data.temporaryPassword,temporaryPasswordExpiresAt:data.temporaryPasswordExpiresAt});}catch{onUncertain(username);}finally{setSaving(false);}} + return {created?:
人员身份
{peopleLoading?

正在读取可绑定人员…

:peopleError?

{peopleError}

:people.length===0?

当前没有未绑定账号的人员,只能创建新人员身份。

:personMode==="existing"?:

将为此账号创建新的人员身份;如人员已存在,请改选“绑定已有人员”。

}{error?

{error}

:null}
}
; } diff --git a/apps/web/src/pages/analysis-page.tsx b/apps/web/src/pages/analysis-page.tsx index 27d57b8..2c1e1a7 100644 --- a/apps/web/src/pages/analysis-page.tsx +++ b/apps/web/src/pages/analysis-page.tsx @@ -1,8 +1,9 @@ import { useEffect, useRef, useState } from "react"; +import { Search, X } from "lucide-react"; import { apiFetch, businessDateToday, eventTypeName, formatMoney, readResponseJson } from "../app-api"; import type { AnalysisCustomer, AnalysisCustomersDrilldown, AnalysisEventsDrilldown, AnalysisMonthsDrilldown, AnalysisProvince, PerformanceAnalysis } from "../app-types"; import type { ChinaMap } from "../china-map"; -import { Metric, PaginatedCollection, Pagination, Status, parsePageNumber, parsePageSize, type PageSize } from "../shared-ui"; +import { Metric, Modal, PaginatedCollection, Pagination, Status, parsePageNumber, parsePageSize, type PageSize } from "../shared-ui"; const chinaMapRegionCodes:Record={ "110000":"CN-BJ","120000":"CN-TJ","130000":"CN-HE","140000":"CN-SX","150000":"CN-NM", @@ -14,8 +15,8 @@ const chinaMapRegionCodes:Record={ "650000":"CN-XJ","710000":"CN-TW","810000":"CN-HK","820000":"CN-MO", }; type AnalysisRange="month"|"year"; -function readAnalysisUrlState(){const params=new URLSearchParams(window.location.search);const month=params.get("analysisMonth");const year=params.get("analysisYear");return{range:params.get("analysisRange")==="year"?"year" as const:"month" as const,month:month&&/^\d{4}-(0[1-9]|1[0-2])$/.test(month)?month:null,year:year&&/^[1-9]\d{3}$/.test(year)?year:null,region:params.get("analysisRegion"),customer:params.get("analysisCustomer"),eventMonth:params.get("analysisEventMonth"),customerPage:parsePageNumber(params.get("analysisCustomerPage")),customerPageSize:parsePageSize(params.get("analysisCustomerPageSize")),customerSnapshot:params.get("analysisCustomerSnapshot"),eventPage:parsePageNumber(params.get("analysisEventPage")),eventPageSize:parsePageSize(params.get("analysisEventPageSize")),eventSnapshot:params.get("analysisEventSnapshot")};} -function writeAnalysisUrlState(values:{range?:AnalysisRange;month?:string|null;year?:string|null;region?:string|null;customer?:string|null;eventMonth?:string|null;customerPage?:number|null;customerPageSize?:PageSize|null;customerSnapshot?:string|null;eventPage?:number|null;eventPageSize?:PageSize|null;eventSnapshot?:string|null},mode:"push"|"replace"="push"){const params=new URLSearchParams(window.location.search);for(const [key,value] of Object.entries({analysisRange:values.range,analysisMonth:values.month,analysisYear:values.year,analysisRegion:values.region,analysisCustomer:values.customer,analysisEventMonth:values.eventMonth,analysisCustomerPage:values.customerPage,analysisCustomerPageSize:values.customerPageSize,analysisCustomerSnapshot:values.customerSnapshot,analysisEventPage:values.eventPage,analysisEventPageSize:values.eventPageSize,analysisEventSnapshot:values.eventSnapshot}))if(value===null)params.delete(key);else if(value!==undefined)params.set(key,String(value));window.history[mode==="push"?"pushState":"replaceState"]({},"",`${window.location.pathname}?${params.toString()}${window.location.hash}`);} +function readAnalysisUrlState(){const params=new URLSearchParams(window.location.search);const month=params.get("analysisMonth");const year=params.get("analysisYear");return{range:params.get("analysisRange")==="year"?"year" as const:"month" as const,month:month&&/^\d{4}-(0[1-9]|1[0-2])$/.test(month)?month:null,year:year&&/^[1-9]\d{3}$/.test(year)?year:null,customerFilter:(params.get("analysisCustomerFilter")??"").slice(0,100),region:params.get("analysisRegion"),customer:params.get("analysisCustomer"),eventMonth:params.get("analysisEventMonth"),customerPage:parsePageNumber(params.get("analysisCustomerPage")),customerPageSize:parsePageSize(params.get("analysisCustomerPageSize")),customerSnapshot:params.get("analysisCustomerSnapshot"),eventPage:parsePageNumber(params.get("analysisEventPage")),eventPageSize:parsePageSize(params.get("analysisEventPageSize")),eventSnapshot:params.get("analysisEventSnapshot")};} +function writeAnalysisUrlState(values:{range?:AnalysisRange;month?:string|null;year?:string|null;customerFilter?:string|null;region?:string|null;customer?:string|null;eventMonth?:string|null;customerPage?:number|null;customerPageSize?:PageSize|null;customerSnapshot?:string|null;eventPage?:number|null;eventPageSize?:PageSize|null;eventSnapshot?:string|null},mode:"push"|"replace"="push"){const params=new URLSearchParams(window.location.search);for(const [key,value] of Object.entries({analysisRange:values.range,analysisMonth:values.month,analysisYear:values.year,analysisCustomerFilter:values.customerFilter,analysisRegion:values.region,analysisCustomer:values.customer,analysisEventMonth:values.eventMonth,analysisCustomerPage:values.customerPage,analysisCustomerPageSize:values.customerPageSize,analysisCustomerSnapshot:values.customerSnapshot,analysisEventPage:values.eventPage,analysisEventPageSize:values.eventPageSize,analysisEventSnapshot:values.eventSnapshot}))if(value===null)params.delete(key);else if(value!==undefined)params.set(key,String(value));window.history[mode==="push"?"pushState":"replaceState"]({},"",`${window.location.pathname}?${params.toString()}${window.location.hash}`);} export function AnalysisPage(){ const initial=useRef(readAnalysisUrlState()).current; @@ -27,20 +28,25 @@ export function AnalysisPage(){ const[data,setData]=useState(null); const[error,setError]=useState(""); const[revision,setRevision]=useState(0); + const provinceTriggerRef=useRef<{focus:()=>void}|null>(null); const[selectedProvince,setSelectedProvince]=useState(null); + const[customerFilter,setCustomerFilter]=useState(initial.customerFilter); const period=range==="month"?month:year; const periodText=range==="month"?`${month.replace("-","年")}月`:`${year}年`; - useEffect(()=>{const restore=()=>{const state=readAnalysisUrlState();const restoredMonth=state.month??businessDateToday().slice(0,7);const restoredYear=state.year??restoredMonth.slice(0,4);setRange(state.range);setMonth(restoredMonth);setYear(restoredYear);setYearDraft(restoredYear);setSelectedProvince(null);setRevision((value)=>value+1);};window.addEventListener("popstate",restore);return()=>window.removeEventListener("popstate",restore);},[]); + const customerQuery=customerFilter.trim().toLowerCase(); + const filteredCustomers=data?.customers.filter((item)=>!customerQuery||item.regionName.toLowerCase().includes(customerQuery)||item.customerUnit.toLowerCase().includes(customerQuery))??[]; + useEffect(()=>{const restore=()=>{const state=readAnalysisUrlState();const restoredMonth=state.month??businessDateToday().slice(0,7);const restoredYear=state.year??restoredMonth.slice(0,4);setRange(state.range);setMonth(restoredMonth);setYear(restoredYear);setYearDraft(restoredYear);setCustomerFilter(state.customerFilter);setSelectedProvince(null);setRevision((value)=>value+1);};window.addEventListener("popstate",restore);return()=>window.removeEventListener("popstate",restore);},[]); useEffect(()=>{const controller=new AbortController();setData(null);setError("");apiFetch(`/api/performance/analysis?${range}=${period}`,{signal:controller.signal}).then(async(response)=>{const result=await readResponseJson(response,"分析服务响应无效");if(!response.ok)throw new Error(result.message??"分析加载失败");setData(result);const region=readAnalysisUrlState().region;setSelectedProvince(result.provinces.find((province)=>province.regionCode===region)??null);}).catch((failure)=>{if(failure instanceof DOMException&&failure.name==="AbortError")return;setError(failure instanceof Error?failure.message:"分析加载失败");});return()=>controller.abort();},[range,period,revision]); - function chooseProvince(province:AnalysisProvince){writeAnalysisUrlState({region:province.regionCode,customer:null,eventMonth:null,customerPage:null,customerPageSize:null,customerSnapshot:null,eventPage:null,eventPageSize:null,eventSnapshot:null});setSelectedProvince(province);} + function chooseProvince(province:AnalysisProvince,trigger:{focus:()=>void}){provinceTriggerRef.current=trigger;writeAnalysisUrlState({region:province.regionCode,customer:null,eventMonth:null,customerPage:null,customerPageSize:null,customerSnapshot:null,eventPage:null,eventPageSize:null,eventSnapshot:null});setSelectedProvince(province);} + function closeProvince(){const trigger=provinceTriggerRef.current;writeAnalysisUrlState({region:null,customer:null,eventMonth:null,customerPage:null,customerPageSize:null,customerSnapshot:null,eventPage:null,eventPageSize:null,eventSnapshot:null});setSelectedProvince(null);window.requestAnimationFrame(()=>trigger?.focus());} return

地区与客户单位分析

只按事件发生时的不可变分析维度快照汇总,不使用订单当前资料

{event.preventDefault();if(!/^[1-9]\d{3}$/.test(yearDraft))return;writeAnalysisUrlState({range:"year",month:null,year:yearDraft,region:null,customer:null,eventMonth:null,customerPage:null,customerPageSize:null,customerSnapshot:null,eventPage:null,eventPageSize:null,eventSnapshot:null});setSelectedProvince(null);setYear(yearDraft);}}>{range==="month"?:<>}
{error?

{error}

:null} {!data&&!error?

正在读取地区与客户单位分析…

:null} - {data?<>

{data.reconciled?"已映射金额 + 待补齐金额与授权范围总账完全对平。":"分析维度对账失败,请停止使用当前汇总。"}

省份汇总

{(pageItems)=>
{pageItems.length?pageItems.map((item)=>):}
省份事件金额
{item.eventCount}{formatMoney(item.totalAmount)}
{periodText}没有已映射省份事件。
}
外贸(EXT-TRADE)独立区域,不进入省份统计
{data.foreignTrade.eventCount} 条事件 · {formatMoney(data.foreignTrade.totalAmount)}

客户单位汇总

{(pageItems)=>
{pageItems.length?pageItems.map((item)=>):}
区域客户单位事件金额
{item.regionName}{item.customerUnit}{item.eventCount}{formatMoney(item.totalAmount)}
{periodText}没有已映射客户单位事件。
}
{selectedProvince?:null}:null} + {data?<>

{data.reconciled?"已映射金额 + 待补齐金额与授权范围总账完全对平。":"分析维度对账失败,请停止使用当前汇总。"}

省份汇总

{(pageItems)=>
{pageItems.length?pageItems.map((item)=>):}
省份事件金额
{item.eventCount}{formatMoney(item.totalAmount)}
{periodText}没有已映射省份事件。
}
外贸(EXT-TRADE)独立区域,不进入省份统计
{data.foreignTrade.eventCount} 条事件 · {formatMoney(data.foreignTrade.totalAmount)}

客户单位汇总

event.preventDefault()}>
{(pageItems)=>
{pageItems.length?pageItems.map((item)=>):}
区域客户单位事件金额
{item.regionName}{item.customerUnit}{item.eventCount}{formatMoney(item.totalAmount)}
{customerQuery?`没有符合“${customerFilter.trim()}”的客户单位。`:`${periodText}没有已映射客户单位事件。`}
}
{selectedProvince?:null}:null}
; } -function ChinaProvinceMap({provinces,selectedRegionCode,onSelect}:{provinces:AnalysisProvince[];selectedRegionCode:string|null;onSelect:(province:AnalysisProvince)=>void}){ +function ChinaProvinceMap({provinces,selectedRegionCode,onSelect}:{provinces:AnalysisProvince[];selectedRegionCode:string|null;onSelect:(province:AnalysisProvince,trigger:{focus:()=>void})=>void}){ const[chinaMap,setChinaMap]=useState(null); const[mapError,setMapError]=useState(""); useEffect(()=>{const controller=new AbortController();import("../china-map").then(({loadChinaMap})=>loadChinaMap(controller.signal)).then(setChinaMap).catch((failure)=>{if(failure instanceof DOMException&&failure.name==="AbortError")return;setMapError(failure instanceof Error?failure.message:"中国省级地图加载失败");});return()=>controller.abort();},[]); @@ -53,7 +59,7 @@ function ChinaProvinceMap({provinces,selectedRegionCode,onSelect}:{provinces:Ana if(!province)return