From f3edcf2caef31fd494cec0a31f27e1eb078ff465 Mon Sep 17 00:00:00 2001 From: Eclipseic1848 <237380389+Eclipseic1848@users.noreply.github.com> Date: Wed, 2 Sep 2026 00:21:29 -0700 Subject: [PATCH 1/2] feat(web): add numbered pagination --- README.md | 7 ++- apps/api/src/audit-query.integration.test.ts | 10 ++++ .../api/src/authorization.integration.test.ts | 22 ++++++++ apps/api/src/modules/admin.ts | 26 ++++++++- apps/api/src/modules/audits.ts | 24 +++++--- apps/api/src/modules/performance.ts | 56 ++++++++++++------- .../performance-analysis.integration.test.ts | 40 +++++++++++-- apps/api/src/validation.ts | 3 + apps/web/e2e/accounts.spec.ts | 28 ++++++---- apps/web/e2e/analysis.spec.ts | 29 ++++++---- apps/web/e2e/audits.spec.ts | 8 ++- apps/web/e2e/login.spec.ts | 31 +++++----- apps/web/src/app-types.ts | 4 +- apps/web/src/pages/accounts-page.tsx | 25 ++++----- apps/web/src/pages/analysis-page.tsx | 51 +++++++++-------- apps/web/src/pages/audit-page.tsx | 23 ++++---- apps/web/src/pages/goal-workspace.tsx | 18 ++++-- apps/web/src/pages/orders-page.tsx | 46 +++++++-------- apps/web/src/pages/organization-page.tsx | 7 ++- apps/web/src/pages/overview-page.tsx | 31 +++++----- apps/web/src/shared-ui.tsx | 28 ++++++++++ apps/web/src/styles.css | 10 +++- docs/specs/p1-product-closure.md | 10 ++-- docs/specs/p1-ticket-breakdown.md | 15 ++--- docs/v1-scope.md | 15 ++--- 25 files changed, 374 insertions(+), 193 deletions(-) diff --git a/README.md b/README.md index bf96e00..84a9e0a 100644 --- a/README.md +++ b/README.md @@ -5,15 +5,16 @@ SampleFlow 是面向销售到样业务的业绩与目标管理 Web 系统。它将角色权限、带生效日期的组织任职、不可变业绩事件、目标实名确认与审批和受控 Excel 导入放在同一套可审计流程中。 -> 当前成熟度:P0 已完成,P1 桌面 Web 与仓库生产准备正在做审查加固;尚未完成真实组织数据落库、业务 UAT 或公司服务器生产部署,不能视为生产系统。 +> 当前成熟度:P0 与可自动验收的 P1 桌面 Web/仓库工程能力已完成;真实组织数据落库、业务 UAT 和公司服务器生产验收仍是人工 Gate,完成前不能视为生产系统。 ## 核心能力 - 系统账号、首次改密、会话安全和角色权限矩阵。 - 部门、小组、人员身份和带有效期的组织任职。 - 订单台账与只追加、不覆盖的业绩事件链。 +- 所有数据表格和可增长业务清单统一分页:默认 20 条,可选 10/20/50/100 条,并可直接点击页码。 - 按事件发生日期固化人员及组织快照,保留调组前后的历史归属。 -- 分层目标下达、责任人签名、总经理/人事审批和修改申请。 +- 分层目标下达、责任人实名确认、总经理/人事审批和修改申请。 - 人工录入与受控 `.xlsx` 导入;预检、逐月核对、确认、回滚和幂等证据分离。 - PostgreSQL、React、Fastify 和 Docker Compose 组成的模块化单体。 @@ -22,7 +23,7 @@ SampleFlow 是面向销售到样业务的业绩与目标管理 Web 系统。它 - GitHub Issue #1—#9 的 P0 工作已关闭;P1 当前状态以 `handoff.md` 与 Roadmap #18 为准。 - `main` 受保护,Pull Request 必须通过 `Typecheck, test, build and audit`。 - 真实历史工作簿已在隔离临时数据库完成功能核对;原文件和行级业务数据不在仓库中。 -- P1 产品化、审查加固与部署工作由 [Roadmap #18](https://github.com/Eclipseic1848/SampleFlow/issues/18) 跟踪;当前产品仅验收 1024px/1280px 桌面 Web,移动端 #57 已取消。 +- P1 产品化与人工验收由 [Roadmap #18](https://github.com/Eclipseic1848/SampleFlow/issues/18) 跟踪;当前产品仅验收 1024px/1280px 桌面 Web,移动端 #57 已取消;未完成项只剩真实数据 UAT 与公司服务器验收。 - 当前事实、数据边界和接手步骤以 [`handoff.md`](handoff.md) 为准。 ## 技术栈 diff --git a/apps/api/src/audit-query.integration.test.ts b/apps/api/src/audit-query.integration.test.ts index cc4eb3f..ed768a4 100644 --- a/apps/api/src/audit-query.integration.test.ts +++ b/apps/api/src/audit-query.integration.test.ts @@ -246,6 +246,16 @@ test("审计查询支持人员、动作、实体、时间和稳定游标过滤", } finally { await concurrentClient.end(); } + const numbered = await app.inject({ method: "GET", url: "/api/audits?action=performance.cursor_test&page=6&pageSize=10", headers: { cookie } }); + assert.equal(numbered.statusCode, 200, numbered.body); + assert.equal(numbered.json().page, 6); + assert.equal(numbered.json().pageSize, 10); + assert.equal(numbered.json().totalCount, 53); + assert.equal(numbered.json().audits.length, 3); + const mixedPagination = await app.inject({ method: "GET", url: `/api/audits?action=performance.cursor_test&page=1&cursor=${firstData.nextCursor}`, headers: { cookie } }); + assert.equal(mixedPagination.statusCode, 400, mixedPagination.body); + const invalidPageSize = await app.inject({ method: "GET", url: "/api/audits?pageSize=15", headers: { cookie } }); + assert.equal(invalidPageSize.statusCode, 400, invalidPageSize.body); const cursorPage = await app.inject({ method: "GET", url: `/api/audits?action=performance.cursor_test&cursor=${firstData.nextCursor}`, headers: { cookie } }); assert.equal(cursorPage.statusCode, 200, cursorPage.body); const secondRows = cursorPage.json<{ audits: AuditRow[] }>().audits; diff --git a/apps/api/src/authorization.integration.test.ts b/apps/api/src/authorization.integration.test.ts index d53c38c..cf0b30f 100644 --- a/apps/api/src/authorization.integration.test.ts +++ b/apps/api/src/authorization.integration.test.ts @@ -1252,6 +1252,17 @@ 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 }); + assert.equal(numbered.statusCode, 200, numbered.body); + assert.equal(numbered.json().page, 2); + assert.equal(numbered.json().pageSize, 10); + assert.equal(numbered.json().totalCount, 62); + assert.equal(numbered.json().users.length, 10); + const mixedPagination = await app.inject({ method: "GET", url: `/api/admin/users?search=${search}&page=1&cursor=${encodeURIComponent(first.json().nextCursor)}`, headers: adminHeaders }); + assert.equal(mixedPagination.statusCode, 400, mixedPagination.body); + const invalidPageSize = await app.inject({ method: "GET", url: "/api/admin/users?pageSize=15", headers: adminHeaders }); + assert.equal(invalidPageSize.statusCode, 400, invalidPageSize.body); + const second = await app.inject({ method: "GET", url: `/api/admin/users?search=${search}&cursor=${encodeURIComponent(first.json().nextCursor)}`, @@ -1433,6 +1444,17 @@ 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 } }); + assert.equal(numbered.statusCode, 200, numbered.body); + assert.equal(numbered.json().page, 2); + assert.equal(numbered.json().pageSize, 10); + assert.equal(numbered.json().totalCount, 101); + assert.equal(numbered.json().orders.length, 10); + const mixedPagination = await app.inject({ method: "GET", url: `/api/performance/orders?search=CURSOR-FIX-&page=1&cursor=${encodeURIComponent(first.body.nextCursor!)}`, headers: { cookie: leaderCookie } }); + assert.equal(mixedPagination.statusCode, 400, mixedPagination.body); + const invalidPageSize = await app.inject({ method: "GET", url: "/api/performance/orders?pageSize=15", headers: { cookie: leaderCookie } }); + assert.equal(invalidPageSize.statusCode, 400, invalidPageSize.body); + const [newOrderId] = await insertRows("CURSOR-FIX-NEW-", 1); const pages = [first.body]; let nextCursor: string | null = first.body.nextCursor; diff --git a/apps/api/src/modules/admin.ts b/apps/api/src/modules/admin.ts index 212677e..479cf9a 100644 --- a/apps/api/src/modules/admin.ts +++ b/apps/api/src/modules/admin.ts @@ -2,12 +2,12 @@ import type { FastifyInstance } from "fastify"; import { z } from "zod"; import type { Database } from "../db.js"; import { generateTemporaryPassword, hashPassword, TEMPORARY_PASSWORD_TTL_MS } from "../security/password.js"; -import { postgresBigintIdSchema } from "../validation.js"; +import { pageNumberSchema, pageSizeSchema, postgresBigintIdSchema } from "../validation.js"; import { hasAnyRole } from "./auth.js"; import { BUSINESS_DATE_SQL, canReadPerformance, resolveGoalAccess, resolvePerformanceAccess, ROLE_PERMISSION_MATRIX } from "./authorization.js"; const createUserSchema = z.strictObject({ username:z.string().trim().min(2).max(100), displayName:z.string().trim().min(1).max(100), roles:z.array(z.string().trim().min(1)).min(1), personId:postgresBigintIdSchema.nullable().optional() }); -const accountListQuerySchema = z.strictObject({ search:z.string().trim().max(100).optional().default(""), cursor:z.string().max(2048).optional() }); +const accountListQuerySchema = z.strictObject({ search:z.string().trim().max(100).optional().default(""), cursor:z.string().max(2048).optional(), page:pageNumberSchema.optional(), pageSize:pageSizeSchema.optional() }); const accountCursorSchema = z.strictObject({ version:z.literal(1), userId:postgresBigintIdSchema, search:z.string().max(100), id:postgresBigintIdSchema, cutoffId:postgresBigintIdSchema }); const roleUpdateSchema = z.strictObject({ roles:z.array(z.string().trim().min(1)).min(1) }); const statusSchema = z.object({ isActive:z.boolean() }); @@ -31,11 +31,33 @@ export async function registerAdmin(app:FastifyInstance,db:Database){ app.get("/api/admin/users",async(request,reply)=>{ const denied=requireAdmin(request,reply);if(denied)return denied; 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 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(); try{ await client.query("begin transaction isolation level repeatable read read only"); + if(numbered){ + const result=await client.query<{totalCount:string;users:Array<{id:string;username:string;displayName:string;isActive:boolean;mustChangePassword:boolean;roles:string[]}>}>( + `with filtered as materialized ( + select u.id as "__id",u.id::text,u.username,u.display_name as "displayName",u.is_active as "isActive",u.must_change_password as "mustChangePassword", + coalesce(array_agg(ur.role_code order by ur.role_code) filter(where ur.role_code is not null),'{}') as roles + from users u left join user_roles ur on ur.user_id=u.id + where position(lower($1) in lower(u.username))>0 or position(lower($1) in lower(u.display_name))>0 + group by u.id + ), page_rows as ( + select * from filtered order by "__id" limit $2 offset $3 + ) + select (select count(*)::text from filtered) as "totalCount", + coalesce(jsonb_agg(to_jsonb(page_rows)-'__id' order by page_rows."__id") filter(where page_rows."__id" is not null),'[]'::jsonb) as users + from page_rows`, + [parsed.data.search,pageSize,(page-1)*pageSize], + ); + await client.query("commit"); + return{users:result.rows[0]!.users,roles:fixedRoles,permissionMatrix:ROLE_PERMISSION_MATRIX,page,pageSize,totalCount:Number(result.rows[0]!.totalCount)}; + } if(!cursor)await client.query("lock table users in share mode"); const result=await client.query<{cutoffId:string|null;users:Array<{id:string;username:string;displayName:string;isActive:boolean;mustChangePassword:boolean;roles:string[]}>}>( `with cutoff as (select coalesce($3::bigint,max(id)) as id from users), page as ( diff --git a/apps/api/src/modules/audits.ts b/apps/api/src/modules/audits.ts index 461b6b0..b69e814 100644 --- a/apps/api/src/modules/audits.ts +++ b/apps/api/src/modules/audits.ts @@ -1,7 +1,7 @@ import type { FastifyInstance } from "fastify"; import { z } from "zod"; import type { Database } from "../db.js"; -import { postgresBigintIdSchema } from "../validation.js"; +import { pageNumberSchema, pageSizeSchema, postgresBigintIdSchema } from "../validation.js"; import { canReadGoals, canReadPerformance, performanceScopeSql, performanceScopeValues, resolveGoalAccess, resolvePerformanceAccess } from "./authorization.js"; const auditFiltersSchema = z.strictObject({ @@ -12,7 +12,7 @@ const auditFiltersSchema = z.strictObject({ from: z.iso.datetime({ offset: true }).optional(), to: z.iso.datetime({ offset: true }).optional(), }); -const querySchema = auditFiltersSchema.extend({ cursor: z.string().max(2048).optional() }); +const querySchema = auditFiltersSchema.extend({ cursor: z.string().max(2048).optional(), page:pageNumberSchema.optional(), pageSize:pageSizeSchema.optional() }); const auditCursorSchema = z.strictObject({ version: z.literal(1), userId: postgresBigintIdSchema, @@ -50,7 +50,10 @@ export async function registerAudits(app: FastifyInstance, db: Database) { return reply.code(400).send({ message: "审计查询条件无效" }); } - const { cursor: encodedCursor, ...filters } = parsed.data; + const { cursor: encodedCursor, page:requestedPage, pageSize:requestedPageSize, ...filters } = parsed.data; + const numbered=requestedPage!==undefined||requestedPageSize!==undefined; + if(numbered&&encodedCursor)return reply.code(400).send({message:"页码与游标不能同时使用"}); + const page=requestedPage??1;const pageSize=requestedPageSize??20; 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: "审计分页游标无效或已不适用于当前查询" }); @@ -81,13 +84,14 @@ export async function registerAudits(app: FastifyInstance, db: Database) { entityType: string; id: string; cutoffId: string; + __totalCount: string; }>( `with cutoff as (select coalesce($20::bigint,max(id)) as id from audit_logs) select audit.id::text, actor_person.id::text as "actorPersonId",actor_user.username as "actorUsername",actor_user.display_name as "actorDisplayName", audit.action,audit.entity_type as "entityType",audit.entity_id as "entityId", audit.before_data as "beforeData",audit.after_data as "afterData",audit.created_at as "createdAt", - cutoff.id::text as "cutoffId" + cutoff.id::text as "cutoffId",count(*) over()::text as "__totalCount" from audit_logs audit cross join cutoff left join users actor_user on actor_user.id=audit.actor_user_id @@ -142,7 +146,7 @@ export async function registerAudits(app: FastifyInstance, db: Database) { and ($15::timestamptz is null or audit.created_at<=$15::timestamptz) and ($16::bigint is null or audit.id<$16::bigint) and audit.id<=cutoff.id - order by audit.id desc limit $19`, + order by audit.id desc limit $19 offset $21`, [ systemAdmin, goalAccess.all, @@ -159,17 +163,21 @@ export async function registerAudits(app: FastifyInstance, db: Database) { cursor?.id ?? null, request.currentUser.id, performanceReader, - PAGE_SIZE + 1, + numbered?pageSize:PAGE_SIZE + 1, cursor?.cutoffId ?? null, + numbered?(page-1)*pageSize:0, ], ); await client.query("commit"); - const hasNext = result.rows.length > PAGE_SIZE; - const audits = result.rows.slice(0, PAGE_SIZE).map(({ cutoffId: _cutoffId, ...row }) => ({ + const totalCount=Number(result.rows[0]?.__totalCount??0); + const hasNext = numbered?page*pageSize PAGE_SIZE; + const rows=numbered?result.rows:result.rows.slice(0,PAGE_SIZE); + const audits = rows.map(({ cutoffId: _cutoffId, __totalCount:_totalCount, ...row }) => ({ ...row, beforeData: redact(row.beforeData), afterData: redact(row.afterData), })); + if(numbered)return{audits,page,pageSize,totalCount}; const last = audits.at(-1); const cutoffId = result.rows[0]?.cutoffId; return { diff --git a/apps/api/src/modules/performance.ts b/apps/api/src/modules/performance.ts index d9014ed..8aef57f 100644 --- a/apps/api/src/modules/performance.ts +++ b/apps/api/src/modules/performance.ts @@ -2,7 +2,7 @@ import { createHash } from "node:crypto"; import type { FastifyInstance } from "fastify"; import { z } from "zod"; import type { Database } from "../db.js"; -import { postgresBigintIdSchema } from "../validation.js"; +import { pageNumberSchema, pageSizeSchema, postgresBigintIdSchema } from "../validation.js"; import { businessDate } from "../domain/business-time.js"; import { decidePerformanceEvent, @@ -35,8 +35,9 @@ const dashboardQuerySchema = z.object({ }); const analysisProvinceSchema = z.string().refine((value) => value.startsWith("CN-") && standardBusinessRegionName(value) !== undefined); const analysisMonthSchema = z.string().regex(/^[1-9]\d{3}-(0[1-9]|1[0-2])$/); +const paginationQueryFields={page:pageNumberSchema.optional(),pageSize:pageSizeSchema.optional()}; const analysisDrilldownQuerySchema = z.discriminatedUnion("level", [ - z.strictObject({ level: z.literal("customers"), regionCode: analysisProvinceSchema, month: analysisMonthSchema, cursor: z.string().min(1).max(2048).optional() }), + z.strictObject({ level: z.literal("customers"), regionCode: analysisProvinceSchema, month: analysisMonthSchema, cursor: z.string().min(1).max(2048).optional(), ...paginationQueryFields }), z.strictObject({ level: z.literal("months"), regionCode: analysisProvinceSchema, @@ -49,6 +50,7 @@ const analysisDrilldownQuerySchema = z.discriminatedUnion("level", [ customerUnit: z.string().trim().min(1).max(300), month: analysisMonthSchema, cursor: z.string().min(1).max(2048).optional(), + ...paginationQueryFields, }), ]); const groupAchievementQuerySchema = dashboardQuerySchema.extend({ @@ -62,6 +64,7 @@ const ANALYSIS_CUSTOMER_PAGE_SIZE = 50; const ANALYSIS_EVENT_PAGE_SIZE = 100; const orderListQuerySchema = orderFilterQuerySchema.extend({ cursor: z.string().min(1).max(2048).optional(), + ...paginationQueryFields, }); const orderCursorSchema = z.strictObject({ version: z.literal(2), @@ -1039,7 +1042,11 @@ export async function registerPerformance(app: FastifyInstance, db: Database, cl if (!request.currentUser) return reply.code(401).send({ message: "尚未登录" }); const parsed = analysisDrilldownQuerySchema.safeParse(request.query); if (!parsed.success) return reply.code(400).send({ code: "ANALYSIS_DRILLDOWN_INVALID", message: "分析穿透条件无效" }); - const queryDigest = analysisQueryDigest({ ...parsed.data, cursor: undefined }); + const numbered=parsed.data.level!=="months"&&(parsed.data.page!==undefined||parsed.data.pageSize!==undefined); + if(parsed.data.level!=="months"&&numbered&&parsed.data.cursor)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 queryDigest = analysisQueryDigest({ ...parsed.data, cursor: undefined, page:undefined, pageSize:undefined }); const customerCursor = parsed.data.level === "customers" && parsed.data.cursor ? decodeAnalysisCursor(parsed.data.cursor, analysisCustomerCursorSchema) : null; @@ -1086,7 +1093,7 @@ export async function registerPerformance(app: FastifyInstance, db: Database, cl select customer_unit,event_count,total_amount from customers where ($7::numeric is null or total_amount<$7::numeric or (total_amount=$7::numeric and customer_unit>$8)) order by total_amount desc,customer_unit - limit $11 + limit $11 offset $12 ) select summary.event_count::text,summary.total_amount::text, (select count(*)::text from customers) as customer_count, @@ -1100,12 +1107,12 @@ export async function registerPerformance(app: FastifyInstance, db: Database, cl filter(where page.customer_unit is not null),'[]'::jsonb) as customers from summary cross join cutoff cross join dimension_cutoff left join page on true group by summary.event_count,summary.total_amount,cutoff.event_id,dimension_cutoff.sequence`, - [`${parsed.data.month}-01`, parsed.data.regionCode, ...performanceScopeValues(access), customerCursor?.totalAmount ?? null, customerCursor?.customerUnit ?? null, customerCursor?.cutoffEventId ?? null, customerCursor?.cutoffDimensionSequence ?? null, ANALYSIS_CUSTOMER_PAGE_SIZE + 1], + [`${parsed.data.month}-01`, parsed.data.regionCode, ...performanceScopeValues(access), customerCursor?.totalAmount ?? null, customerCursor?.customerUnit ?? null, customerCursor?.cutoffEventId ?? null, customerCursor?.cutoffDimensionSequence ?? null, numbered?pageSize:ANALYSIS_CUSTOMER_PAGE_SIZE + 1, numbered?(page-1)*pageSize:0], ); await client.query("commit"); const row = result.rows[0]!; - const hasNextPage = row.customers.length > ANALYSIS_CUSTOMER_PAGE_SIZE; - const customers = row.customers.slice(0, ANALYSIS_CUSTOMER_PAGE_SIZE); + const hasNextPage = !numbered&&row.customers.length > ANALYSIS_CUSTOMER_PAGE_SIZE; + const customers = numbered?row.customers:row.customers.slice(0, ANALYSIS_CUSTOMER_PAGE_SIZE); const last = customers.at(-1); return { level: "customers", @@ -1116,7 +1123,8 @@ export async function registerPerformance(app: FastifyInstance, db: Database, cl totalAmount: row.total_amount, customerCount: Number(row.customer_count), nextCursor: hasNextPage && last && row.cutoff_event_id && row.cutoff_dimension_sequence ? encodeAnalysisCursor({ version: 1, queryDigest, userId: request.currentUser.id, cutoffEventId: row.cutoff_event_id, cutoffDimensionSequence: row.cutoff_dimension_sequence, totalAmount: last.totalAmount, customerUnit: last.customerUnit }) : null, - pageSize: ANALYSIS_CUSTOMER_PAGE_SIZE, + ...(numbered?{page,totalCount:Number(row.customer_count)}:{}), + pageSize: numbered?pageSize:ANALYSIS_CUSTOMER_PAGE_SIZE, customers, }; } @@ -1187,7 +1195,7 @@ export async function registerPerformance(app: FastifyInstance, db: Database, cl from raw_scoped_analysis raw cross join cutoff where raw."__eventId"<=cutoff.event_id ), page as ( select * from scoped_analysis where ($8::bigint is null or "__eventId">$8::bigint) - order by "__eventId" limit $11 + order by "__eventId" limit $11 offset $12 ), summary as ( select count(*)::bigint as event_count,coalesce(sum("deltaAmount"::numeric),0.00) as total_amount from scoped_analysis ) @@ -1198,12 +1206,12 @@ export async function registerPerformance(app: FastifyInstance, db: Database, cl filter(where page."__eventId" is not null),'[]'::jsonb) as events from summary cross join cutoff cross join dimension_cutoff left join page on true group by summary.event_count,summary.total_amount,cutoff.event_id,dimension_cutoff.sequence`, - [`${parsed.data.month}-01`, parsed.data.regionCode, parsed.data.customerUnit, ...performanceScopeValues(access), eventCursor?.eventId ?? null, eventCursor?.cutoffEventId ?? null, eventCursor?.cutoffDimensionSequence ?? null, ANALYSIS_EVENT_PAGE_SIZE + 1], + [`${parsed.data.month}-01`, parsed.data.regionCode, parsed.data.customerUnit, ...performanceScopeValues(access), eventCursor?.eventId ?? null, eventCursor?.cutoffEventId ?? null, eventCursor?.cutoffDimensionSequence ?? null, numbered?pageSize:ANALYSIS_EVENT_PAGE_SIZE + 1, numbered?(page-1)*pageSize:0], ); await client.query("commit"); const row = result.rows[0]!; - const hasNextPage = row.events.length > ANALYSIS_EVENT_PAGE_SIZE; - const pageEvents = row.events.slice(0, ANALYSIS_EVENT_PAGE_SIZE); + const hasNextPage = !numbered&&row.events.length > ANALYSIS_EVENT_PAGE_SIZE; + const pageEvents = numbered?row.events:row.events.slice(0, ANALYSIS_EVENT_PAGE_SIZE); const orders = new Map>; @@ -1227,7 +1235,8 @@ export async function registerPerformance(app: FastifyInstance, db: Database, cl eventCount: Number(row.eventCount), totalAmount: row.totalAmount, nextCursor: hasNextPage && last && row.cutoffEventId && row.cutoffDimensionSequence ? encodeAnalysisCursor({ version: 1, queryDigest, userId: request.currentUser.id, cutoffEventId: row.cutoffEventId, cutoffDimensionSequence: row.cutoffDimensionSequence, eventId: last.id }) : null, - pageSize: ANALYSIS_EVENT_PAGE_SIZE, + ...(numbered?{page,totalCount:Number(row.eventCount)}:{}), + pageSize: numbered?pageSize:ANALYSIS_EVENT_PAGE_SIZE, orders: [...orders.values()], }; } catch (error) { @@ -1391,15 +1400,18 @@ export async function registerPerformance(app: FastifyInstance, db: Database, cl if (!canReadPerformance(access)) return reply.code(403).send({ message: "当前角色没有业务查看权限" }); const query = orderListQuerySchema.safeParse(request.query); if (!query.success) return reply.code(400).send({ message: "查询条件无效" }); + const numbered=query.data.page!==undefined||query.data.pageSize!==undefined; + if(numbered&&query.data.cursor)return reply.code(400).send({code:"ORDER_PAGINATION_INVALID",message:"页码与游标不能同时使用"}); + const page=query.data.page??1;const pageSize=query.data.pageSize??20; 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)) { return reply.code(400).send({ code: "ORDER_CURSOR_INVALID", message: "分页游标无效或已不适用于当前查询" }); } const direction = cursor?.direction ?? "next"; - type OrderListRow = Record & { id: string; __cursorCreatedAt: Date }; + type OrderListRow = Record & { id: string; __cursorCreatedAt: Date; __totalCount:string }; const result = await db.query( - `select id::text, created_at as "__cursorCreatedAt", qingflow_order_no as "orderNo", customer_name as "customerName", + `select id::text, created_at as "__cursorCreatedAt",count(*) over()::text as "__totalCount",qingflow_order_no as "orderNo", customer_name as "customerName", customer_unit as "customerUnit", performance_orders.salesperson_name as "salespersonName", service_type as "serviceType", source_received_on as "sourceReceivedOn", original_amount::text as "originalAmount", current_revenue::text as "currentRevenue", counted_amount::text as "countedAmount", @@ -1413,17 +1425,19 @@ export async function registerPerformance(app: FastifyInstance, db: Database, cl ${cursor ? `and (performance_orders.created_at,performance_orders.id)<=($15::timestamptz,$16::bigint) and (performance_orders.created_at,performance_orders.id)${direction === "next" ? "<" : ">"}($17::timestamptz,$18::bigint)` : ""} order by performance_orders.created_at ${direction === "previous" ? "asc" : "desc"},performance_orders.id ${direction === "previous" ? "asc" : "desc"} - limit $1`, + limit $1 ${numbered?"offset $15":""}`, [ - ORDER_PAGE_SIZE + 1, + numbered?pageSize:ORDER_PAGE_SIZE + 1, ...performanceScopeValues(access), ...orderFilterValues(filters), - ...(cursor ? [cursor.cutoffCreatedAt, cursor.cutoffId, cursor.anchorCreatedAt, cursor.anchorId] : []), + ...(cursor ? [cursor.cutoffCreatedAt, cursor.cutoffId, cursor.anchorCreatedAt, cursor.anchorId] : numbered?[(page-1)*pageSize]:[]), ], ); - const hasExtra = result.rows.length > ORDER_PAGE_SIZE; - const pageRows = result.rows.slice(0, ORDER_PAGE_SIZE); + const totalCount=Number(result.rows[0]?.__totalCount??0); + const hasExtra = !numbered&&result.rows.length > ORDER_PAGE_SIZE; + const pageRows = numbered?result.rows:result.rows.slice(0, ORDER_PAGE_SIZE); if (direction === "previous") pageRows.reverse(); + if(numbered)return{orders:pageRows.map(({__cursorCreatedAt:_createdAt,__totalCount:_total,...order})=>order),page,pageSize,totalCount}; const cutoff = cursor ?? (pageRows[0] ? { cutoffCreatedAt: pageRows[0].__cursorCreatedAt.toISOString(), cutoffId: pageRows[0].id, @@ -1443,7 +1457,7 @@ export async function registerPerformance(app: FastifyInstance, db: Database, cl const previousCursor = first && cursor && (cursor.direction === "next" || hasExtra) ? makeCursor("previous", first) : null; const nextCursor = last && (cursor?.direction === "previous" || hasExtra) ? makeCursor("next", last) : null; return { - orders: pageRows.map(({ __cursorCreatedAt: _createdAt, ...order }) => order), + orders: pageRows.map(({ __cursorCreatedAt: _createdAt, __totalCount:_totalCount, ...order }) => order), previousCursor, nextCursor, pageSize: ORDER_PAGE_SIZE, diff --git a/apps/api/src/performance-analysis.integration.test.ts b/apps/api/src/performance-analysis.integration.test.ts index 255aa74..30a103d 100644 --- a/apps/api/src/performance-analysis.integration.test.ts +++ b/apps/api/src/performance-analysis.integration.test.ts @@ -201,6 +201,16 @@ test("地区与客户分析按事件快照对账且查询次数不随规模增 assert.ok(analysisReadCount <= 3, `省份客户穿透读取应不超过 3 次,实际 ${analysisReadCount} 次`); const smallCustomersReadCount = analysisReadCount; 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", + headers: { cookie: leaderCookie }, + }); + assert.equal(numberedCustomers.statusCode, 200, numberedCustomers.body); + assert.equal(numberedCustomers.json().page, 1); + assert.equal(numberedCustomers.json().pageSize, 10); + assert.equal(numberedCustomers.json().totalCount, 1); + assert.equal(numberedCustomers.json().customers.length, 1); analysisReadCount = 0; const months = await app.inject({ @@ -263,6 +273,32 @@ test("地区与客户分析按事件快照对账且查询次数不随规模增 totalAmount: "100.00", events: undefined, }]); + assert.ok(analysisReadCount <= 3, `订单事件穿透读取应不超过 3 次,实际 ${analysisReadCount} 次`); + const smallEventsReadCount = analysisReadCount; + 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", + headers: { cookie: leaderCookie }, + }); + assert.equal(numberedEvents.statusCode, 200, numberedEvents.body); + assert.equal(numberedEvents.json().page, 1); + assert.equal(numberedEvents.json().pageSize, 10); + assert.equal(numberedEvents.json().totalCount, 2); + assert.equal(numberedEvents.json().orders[0].events.length, 2); + const mixedPagination = await app.inject({ + method: "GET", + url: "/api/performance/analysis/drilldown?level=customers®ionCode=CN-JS&month=2026-08&page=1&cursor=x", + headers: { cookie: leaderCookie }, + }); + assert.equal(mixedPagination.statusCode, 400, mixedPagination.body); + assert.equal(mixedPagination.json().code, "ANALYSIS_PAGINATION_INVALID"); + const invalidPageSize = await app.inject({ + method: "GET", + url: "/api/performance/analysis/drilldown?level=customers®ionCode=CN-JS&month=2026-08&pageSize=15", + headers: { cookie: leaderCookie }, + }); + assert.equal(invalidPageSize.statusCode, 400, invalidPageSize.body); assert.deepEqual(eventsBody.orders[0].events.map((item: Record) => ({ id: item.id, sequence: item.sequence, @@ -281,10 +317,6 @@ test("地区与客户分析按事件快照对账且查询次数不随规模增 { id: eventIds[0], sequence: 1, eventType: "legacy_adjustment", deltaAmount: "100.00", accountingMonth: "2026-08-01", occurredOn: "2026-08-01", reason: "分析回归", salespersonName: "分析业务员", departmentName: "分析甲部", groupName: "分析甲组", businessRegionCode: "CN-JS", businessRegionSourceText: "江苏来源", customerUnit: "客户单位甲" }, { id: eventIds[3], sequence: 4, eventType: "legacy_adjustment", deltaAmount: "0.00", accountingMonth: "2026-08-01", occurredOn: "2026-08-01", reason: "分析回归", salespersonName: "分析业务员", departmentName: "分析甲部", groupName: "分析甲组", businessRegionCode: "CN-JS", businessRegionSourceText: "江苏来源", customerUnit: "客户单位甲" }, ]); - assert.ok(analysisReadCount <= 3, `订单事件穿透读取应不超过 3 次,实际 ${analysisReadCount} 次`); - const smallEventsReadCount = analysisReadCount; - const smallEventsQuery = requireCapturedQuery(analysisQuery); - for (const [name, captured] of [ ["省份客户", smallCustomersQuery], ["客户月份", smallMonthsQuery], diff --git a/apps/api/src/validation.ts b/apps/api/src/validation.ts index 46c1f03..1a250a7 100644 --- a/apps/api/src/validation.ts +++ b/apps/api/src/validation.ts @@ -3,3 +3,6 @@ import { z } from "zod"; export const postgresBigintIdSchema = z.string().refine( (value) => /^[1-9]\d*$/.test(value) && BigInt(value) <= 9_223_372_036_854_775_807n, ); + +export const pageNumberSchema=z.coerce.number().int().min(1).max(1_000_000); +export const pageSizeSchema=z.coerce.number().int().refine((value)=>[10,20,50,100].includes(value)); diff --git a/apps/web/e2e/accounts.spec.ts b/apps/web/e2e/accounts.spec.ts index 6fc3e9a..f2169a7 100644 --- a/apps/web/e2e/accounts.spec.ts +++ b/apps/web/e2e/accounts.spec.ts @@ -39,24 +39,28 @@ 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("第 1 页 · 本页 50 个账号", { exact: true })).toBeVisible(); + await expect(page.getByText("本页 20 个账号", { exact: true })).toBeVisible(); + const pageSize=page.getByLabel("账号每页条数"); + expect(await pageSize.locator("option").allTextContents()).toEqual(["10 条/页","20 条/页","50 条/页","100 条/页"]); + await pageSize.selectOption("50"); + await expect(page.getByText("本页 50 个账号", { exact: true })).toBeVisible(); - const nextPage = page.waitForResponse((response) => new URL(response.url()).searchParams.has("cursor")); - await page.getByRole("button", { name: "下一页" }).click(); + const nextPage = page.waitForResponse((response) => new URL(response.url()).searchParams.get("page") === "2"); + await page.getByRole("button", { name: "第 2 页" }).click(); expect((await nextPage).status()).toBe(200); - await expect(page.getByText("第 2 页 · 本页 7 个账号", { exact: true })).toBeVisible(); - await expect.poll(() => new URL(page.url()).searchParams.get("accountPage")).toBe("1"); - expect(new URL(page.url()).searchParams.get("accountCursor")).toBeTruthy(); + await expect(page.getByText("本页 7 个账号", { exact: true })).toBeVisible(); + await expect.poll(() => new URL(page.url()).searchParams.get("accountPage")).toBe("2"); + expect(new URL(page.url()).searchParams.get("accountPageSize")).toBe("50"); const coldPage = await context.newPage(); await coldPage.goto(page.url()); await expect(coldPage.getByRole("heading", { name: "账号管理" })).toBeVisible(); - await expect(coldPage.getByText("第 2 页 · 本页 7 个账号", { exact: true })).toBeVisible(); - await expect(coldPage.getByRole("button", { name: "上一页" })).toBeDisabled(); + await expect(coldPage.getByText("本页 7 个账号", { exact: true })).toBeVisible(); + await expect(coldPage.getByRole("button", { name: "上一页" }).first()).toBeEnabled(); await coldPage.close(); await page.reload(); - await expect(page.getByText("第 2 页 · 本页 7 个账号", { exact: true })).toBeVisible(); - await page.getByRole("button", { name: "上一页" }).click(); - await expect(page.getByText("第 1 页 · 本页 50 个账号", { exact: true })).toBeVisible(); + await expect(page.getByText("本页 7 个账号", { exact: true })).toBeVisible(); + await page.getByRole("button", { name: "上一页" }).first().click(); + await expect(page.getByText("本页 50 个账号", { exact: true })).toBeVisible(); await page.getByLabel("搜索账号").fill("e2e_role_target"); await page.getByRole("button", { name: "搜索账号" }).click(); @@ -155,7 +159,7 @@ test("系统管理员搜索分页账号并审计固定角色组合变更", async await expect(page.getByLabel("搜索账号")).toBeFocused(); await expect(page.getByLabel("搜索账号")).toHaveValue(""); await expect.poll(() => new URL(page.url()).searchParams.get("accountSearch")).toBe(null); - await expect(page.getByText("第 1 页 · 本页 50 个账号", { exact: true })).toBeVisible(); + await expect(page.getByText("本页 50 个账号", { exact: true })).toBeVisible(); await page.getByLabel("搜索账号").pressSequentially("甲乙", { delay: 350 }); await expect(page).toHaveURL(/accountSearch=%E7%94%B2%E4%B9%99/); await page.goBack(); diff --git a/apps/web/e2e/analysis.spec.ts b/apps/web/e2e/analysis.spec.ts index 8074d28..2842465 100644 --- a/apps/web/e2e/analysis.spec.ts +++ b/apps/web/e2e/analysis.spec.ts @@ -171,10 +171,12 @@ test("业绩分析页显示事件快照地区、外贸、客户单位和待补 await drilldown.getByRole("button", { name: "查看大额客户月份趋势" }).click(); await drilldown.getByRole("button", { name: "查看2026年8月订单事件,101 条事件,金额 ¥100,999,999,999,998.99" }).click(); - await expect(drilldown.getByText("已加载 100 / 101 条事件", { exact: true })).toBeVisible(); - await drilldown.getByRole("button", { name: "加载更多事件" }).click(); - await expect(drilldown.getByText("已加载 101 / 101 条事件", { exact: true })).toBeVisible(); - await expect(drilldown.getByRole("button", { name: "加载更多事件" })).toHaveCount(0); + const eventPageSize=drilldown.getByLabel("订单事件每页条数"); + expect(await eventPageSize.locator("option").allTextContents()).toEqual(["10 条/页","20 条/页","50 条/页","100 条/页"]); + await eventPageSize.selectOption("100"); + await expect(drilldown.getByText("本页 100 / 共 101 条事件", { exact: true })).toBeVisible(); + await drilldown.getByRole("navigation", { name: "订单事件分页" }).getByRole("button", { name: "第 2 页" }).click(); + await expect(drilldown.getByText("本页 1 / 共 101 条事件", { exact: true })).toBeVisible(); await drilldown.getByRole("button", { name: "查看2026年1月订单事件,0 条事件,金额 ¥0.00" }).click(); await expect(drilldown.getByText("该月份没有订单事件。", { exact: true })).toBeVisible(); @@ -230,37 +232,42 @@ test("第二批客户穿透可通过刷新和浏览器历史恢复", async ({ da const level = url.searchParams.get("level"); if (level === "customers") { customerRequests += 1; - const secondPage = url.searchParams.get("cursor") === "page-2"; + const pageNumber = Number(url.searchParams.get("page")??"1"); + const pageSize = Number(url.searchParams.get("pageSize")??"20"); + const secondPage = pageNumber === 2; const customers = secondPage ? [{ customerUnit: "客户51", eventCount: 0, totalAmount: "0.00" }] - : Array.from({ length: 50 }, (_, index) => ({ customerUnit: `客户${String(index + 1).padStart(2, "0")}`, eventCount: 0, totalAmount: "0.00" })); - await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ level, regionCode: "CN-JS", regionName: "江苏省", month: "2026-08", eventCount: 0, totalAmount: "0.00", customerCount: 51, nextCursor: secondPage ? null : "page-2", pageSize: 50, customers }) }); + : Array.from({ length: pageSize }, (_, index) => ({ customerUnit: `客户${String(index + 1).padStart(2, "0")}`, eventCount: 0, totalAmount: "0.00" })); + await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ level, regionCode: "CN-JS", regionName: "江苏省", month: "2026-08", eventCount: 0, totalAmount: "0.00", customerCount: 51, nextCursor: null, page: pageNumber, pageSize, totalCount: 51, customers }) }); return; } if (level === "months") { 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, pageSize: 100, 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, page: 1, pageSize: 20, totalCount: 0, orders: [] }) }); }); - await page.goto(`/?${new URLSearchParams({ page: "analysis", analysisMonth: "2026-08", analysisRegion: "CN-JS", analysisCustomer: "客户51", analysisEventMonth: "2026-08" })}`); + 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.getByText("台湾省资料暂缺", { exact: true })).toHaveCount(0); await expect(page.getByRole("heading", { name: "客户51月度趋势" })).toBeVisible(); await expect(page.getByRole("heading", { name: "2026年8月订单与事件" })).toBeVisible(); - expect(customerRequests).toBeGreaterThanOrEqual(2); + expect(customerRequests).toBeGreaterThanOrEqual(1); customerRequests = 0; await page.reload(); await expect(page.getByRole("heading", { name: "2026年8月订单与事件" })).toBeVisible(); - expect(customerRequests).toBeGreaterThanOrEqual(2); + expect(customerRequests).toBeGreaterThanOrEqual(1); + await page.getByRole("navigation", { name: "客户单位分页" }).getByRole("button", { name: "第 1 页" }).click(); await page.getByRole("button", { name: "查看客户01月份趋势" }).click(); await expect(page.getByRole("heading", { name: "客户01月度趋势" })).toBeVisible(); await page.goBack(); + await page.goBack(); await expect(page.getByRole("heading", { name: "2026年8月订单与事件" })).toBeVisible(); await page.goForward(); + await page.goForward(); await expect(page.getByRole("heading", { name: "客户01月度趋势" })).toBeVisible(); }); diff --git a/apps/web/e2e/audits.spec.ts b/apps/web/e2e/audits.spec.ts index 291deac..b183820 100644 --- a/apps/web/e2e/audits.spec.ts +++ b/apps/web/e2e/audits.spec.ts @@ -50,8 +50,10 @@ test("审计页面只读展示所属域并支持组合过滤", async ({ context, await expect(page.getByRole("heading", { name: "审计查询" })).toBeVisible(); await expect(page.getByRole("cell", { name: "创建账号" })).toBeVisible(); await expect(page.getByText("performance.order_posted", { exact: true })).not.toBeVisible(); - await page.getByRole("button", { name: "下一页" }).click(); - expect(new URL(page.url()).searchParams.get("auditCursor")).toBeTruthy(); + const pageSize=page.getByLabel("审计记录每页条数"); + 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"); await expect(page.getByText("创建账号", { exact: true })).toHaveCount(0); await expect(page.getByRole("cell", { name: "组织分页记录" }).first()).toBeVisible(); const secondPageUrl = page.url(); @@ -60,7 +62,7 @@ test("审计页面只读展示所属域并支持组合过滤", async ({ context, await coldPage.goto(secondPageUrl); await expect(coldPage.getByRole("heading",{name:"审计查询"})).toBeVisible(); await expect(coldPage.getByRole("cell", { name: "组织分页记录" }).first()).toBeVisible(); - await expect(coldPage.getByRole("button", { name: "上一页" })).toBeDisabled(); + await expect(coldPage.getByRole("button", { name: "上一页" })).toBeEnabled(); await coldPage.close(); await page.reload(); await expect(page.getByRole("heading", { name: "审计查询" })).toBeVisible(); diff --git a/apps/web/e2e/login.spec.ts b/apps/web/e2e/login.spec.ts index fcc0a12..32455f2 100644 --- a/apps/web/e2e/login.spec.ts +++ b/apps/web/e2e/login.spec.ts @@ -142,7 +142,7 @@ test("销售助理组长可在桌面端预检并确认合成历史分析维度 }finally{await client.query("rollback").catch(()=>{});await client.end();} }); -test("订单台账以前后游标稳定浏览并在刷新后开启新快照", async ({ database, page }) => { +test("订单台账支持每页条数与可点击页码", async ({ database, page }) => { const userId = await seedTestUser(database.url, { username: "e2e_cursor_assistant", displayName: "E2E 游标销售助理", @@ -180,26 +180,29 @@ test("订单台账以前后游标稳定浏览并在刷新后开启新快照", as await page.getByRole("button", { name: "进入 SampleFlow" }).click(); await page.getByRole("button", { name: "订单业绩", exact: true }).click(); const ledger = page.locator("section.orders-card").filter({ has: page.getByRole("heading", { name: "订单台账" }) }); - await expect(ledger.getByText("本页 50 笔订单", { exact: true })).toBeVisible(); + await expect(ledger.getByText("本页 20 笔订单", { exact: true })).toBeVisible(); await expect(ledger.getByText("E2E-CURSOR-0101", { exact: true })).toBeVisible(); + const pageSize=ledger.getByLabel("订单每页条数"); + expect(await pageSize.locator("option").allTextContents()).toEqual(["10 条/页","20 条/页","50 条/页","100 条/页"]); + await pageSize.selectOption("50"); + await expect(ledger.getByText("本页 50 笔订单", { exact: true })).toBeVisible(); await expect(ledger.getByRole("button", { name: "上一页" })).toBeDisabled(); await expect(ledger.getByRole("button", { name: "下一页" })).toBeEnabled(); - await insertRows("E2E-CURSOR-NEW-", 1); let failNextPage = true; - await page.route("**/api/performance/orders?cursor=*", async (route) => { - if (failNextPage) { + await page.route("**/api/performance/orders?*", async (route) => { + if (failNextPage&&new URL(route.request().url()).searchParams.get("page")==="2") { failNextPage = false; await route.fulfill({ status: 503, contentType: "application/json", body: '{"message":"分页暂时失败"}' }); return; } await route.continue(); }); - await ledger.getByRole("button", { name: "下一页" }).click(); + await ledger.getByRole("button", { name: "第 2 页" }).click(); await expect(page.getByText("分页暂时失败", { exact: true })).toBeVisible(); await page.getByRole("button", { name: "重试查询" }).click(); await expect(ledger.getByText("E2E-CURSOR-0051", { exact: true })).toBeVisible(); - await ledger.getByRole("button", { name: "下一页" }).click(); + await ledger.getByRole("button", { name: "第 3 页" }).click(); await expect(ledger.getByText("E2E-CURSOR-0001", { exact: true })).toBeVisible(); await expect(ledger.getByText("本页 1 笔订单", { exact: true })).toBeVisible(); await expect(ledger.getByRole("button", { name: "下一页" })).toBeDisabled(); @@ -207,10 +210,8 @@ test("订单台账以前后游标稳定浏览并在刷新后开启新快照", as await expect(ledger.getByText("E2E-CURSOR-0051", { exact: true })).toBeVisible(); await ledger.getByRole("button", { name: "上一页" }).click(); await expect(ledger.getByText("E2E-CURSOR-0101", { exact: true })).toBeVisible(); - await expect(ledger.getByText("E2E-CURSOR-NEW-0001", { exact: true })).toHaveCount(0); - - await ledger.getByRole("button", { name: "刷新订单" }).click(); - await expect(ledger.getByText("E2E-CURSOR-NEW-0001", { exact: true })).toBeVisible(); + expect(new URL(page.url()).searchParams.get("orderPage")).toBe("1"); + expect(new URL(page.url()).searchParams.get("orderPageSize")).toBe("50"); await page.getByLabel("定位订单").fill("E2E游标单位0042"); await expect(ledger.getByText("E2E-CURSOR-0042", { exact: true })).toBeVisible(); await expect(ledger.getByText("本页 1 笔订单", { exact: true })).toBeVisible(); @@ -285,6 +286,7 @@ test("订单组合筛选由 URL 恢复并区分空集、失败和无权限", asy await page.getByRole("button", { name: "进入 SampleFlow" }).click(); await page.getByRole("button", { name: "订单业绩", exact: true }).click(); const ledger = page.locator("section.orders-card").filter({ has: page.getByRole("heading", { name: "订单台账" }) }); + await ledger.getByLabel("订单每页条数").selectOption("50"); await page.getByLabel("订单月份").fill(matching.month); await page.getByRole("button", { name: "应用筛选" }).click(); @@ -314,7 +316,8 @@ test("订单组合筛选由 URL 恢复并区分空集、失败和无权限", asy await expect(ledger.getByText("E2E-FILTER-0001", { exact: true })).toBeVisible(); await expect(ledger.getByText("本页 1 笔订单", { exact: true })).toBeVisible(); const secondPageUrl = page.url(); - expect(secondPageUrl).toContain("orderCursor="); + expect(secondPageUrl).toContain("orderPage=2"); + expect(secondPageUrl).toContain("orderPageSize=50"); await page.reload(); await expect(ledger.getByText("E2E-FILTER-0001", { exact: true })).toBeVisible(); await ledger.getByRole("button", { name: "查看 / 调整" }).click(); @@ -353,7 +356,7 @@ test("订单组合筛选由 URL 恢复并区分空集、失败和无权限", asy if (responseMode === "live") return route.continue(); if (responseMode === "failure") return route.fulfill({ status: 503, contentType: "application/json", body: '{"message":"筛选加载失败"}' }); if (responseMode === "forbidden") return route.fulfill({ status: 403, contentType: "application/json", body: '{"message":"当前角色没有业务查看权限"}' }); - return route.fulfill({ status: 200, contentType: "application/json", body: '{"orders":[],"previousCursor":null,"nextCursor":null,"pageSize":50}' }); + return route.fulfill({ status: 200, contentType: "application/json", body: '{"orders":[],"page":1,"pageSize":50,"totalCount":0}' }); }); responseMode = "failure"; await page.getByLabel("客户单位筛选").fill("不存在的单位"); @@ -531,7 +534,7 @@ test("系统管理员在账号管理页查看只读角色权限说明", async ({ await page.goto("/"); await page.getByLabel("账号").fill("e2e_system_admin"); await page.getByLabel("密码", { exact: true }).fill("Admin@123"); - const accountsResponse = page.waitForResponse((response) => response.url().endsWith("/api/admin/users")); + const accountsResponse = page.waitForResponse((response) => new URL(response.url()).pathname.endsWith("/api/admin/users")); await page.getByRole("button", { name: "进入 SampleFlow" }).click(); expect((await accountsResponse).status()).toBe(200); diff --git a/apps/web/src/app-types.ts b/apps/web/src/app-types.ts index 76cdfac..bb0a0a3 100644 --- a/apps/web/src/app-types.ts +++ b/apps/web/src/app-types.ts @@ -25,11 +25,11 @@ export type AnalysisAmount = { eventCount:number;totalAmount:string }; export type AnalysisProvince = {regionCode:string;regionName:string;eventCount:number;totalAmount:string}; export type AnalysisCustomer = {customerUnit:string;eventCount:number;totalAmount:string}; export type PerformanceAnalysis = { month:string;ledger:AnalysisAmount;mapped:AnalysisAmount;pending:AnalysisAmount;reconciled:boolean;provinces:AnalysisProvince[];foreignTrade:{regionCode:"EXT-TRADE";regionName:string;eventCount:number;totalAmount:string};customers:Array }; -export type AnalysisCustomersDrilldown = {level:"customers";regionCode:string;regionName:string;month:string;eventCount:number;totalAmount:string;customerCount:number;nextCursor:string|null;pageSize:number;customers:AnalysisCustomer[]}; +export type AnalysisCustomersDrilldown = {level:"customers";regionCode:string;regionName:string;month:string;eventCount:number;totalAmount:string;customerCount:number;nextCursor:string|null;page:number;pageSize:number;totalCount:number;customers:AnalysisCustomer[]}; export type AnalysisMonthsDrilldown = {level:"months";regionCode:string;regionName:string;customerUnit:string;year:string;eventCount:number;totalAmount:string;months:Array<{month:string;eventCount:number;totalAmount:string}>}; export type AnalysisDrilldownEvent = {id:string;eventType:string;deltaAmount:string;resultingCurrentRevenue:string;resultingCountedAmount:string;resultingLifecycleState:"active"|"paused"|"zero"|null;accountingMonth:string;occurredOn:string;reason:string|null;salespersonName:string;departmentName:string|null;groupName:string|null;sequence:number;businessRegionCode:string;businessRegionSourceText:string;customerUnit:string}; export type AnalysisDrilldownOrder = {orderId:string;orderNo:string;customerName:string;eventCount:number;totalAmount:string;events:AnalysisDrilldownEvent[]}; -export type AnalysisEventsDrilldown = {level:"events";regionCode:string;regionName:string;customerUnit:string;month:string;eventCount:number;totalAmount:string;nextCursor:string|null;pageSize:number;orders:AnalysisDrilldownOrder[]}; +export type AnalysisEventsDrilldown = {level:"events";regionCode:string;regionName:string;customerUnit:string;month:string;eventCount:number;totalAmount:string;nextCursor:string|null;page:number;pageSize:number;totalCount:number;orders:AnalysisDrilldownOrder[]}; export type GoalLevel="sales_manager"|"department"|"group"|"personal"; export type Goal = { id:string;periodMonth:string;level:GoalLevel;ownerUsername:string|null;ownerName:string;ownerPersonId:string;orgUnitId:string|null;orgUnitName:string|null;parentGoalId:string|null;versionId:string;versionNo:string;amount:string;effectiveAmount:string|null;status:string;signatureText:string|null;signedAt:string|null;changeReason:string;allocatedAmount:string;allocationDifference:string;allocationType:"unallocated"|"overallocated"|"balanced";allocationRatio:string|null }; export type GoalOption={personId:string;name:string;orgUnitId:string|null;orgUnitName:string|null}; diff --git a/apps/web/src/pages/accounts-page.tsx b/apps/web/src/pages/accounts-page.tsx index 6d7b641..6d59626 100644 --- a/apps/web/src/pages/accounts-page.tsx +++ b/apps/web/src/pages/accounts-page.tsx @@ -2,32 +2,31 @@ 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 } from "../shared-ui"; +import { Field, Modal, Pagination, parsePageNumber, parsePageSize, type PageSize, usePagination } from "../shared-ui"; -type AccountCursor=string|null|undefined; -function readAccountUrlState(){const params=new URLSearchParams(window.location.search);const cursor=params.get("accountCursor");const parsedPage=Number(params.get("accountPage"));const page=cursor&&Number.isSafeInteger(parsedPage)&&parsedPage>=0&&parsedPage<=10_000?parsedPage:0;const currentCursor=page?cursor:null;const saved=(window.history.state as {sampleflowAccountCursors?:unknown}|null)?.sampleflowAccountCursors;const cursors=Array.isArray(saved)&&saved.every((item)=>item===null||item===undefined||typeof item==="string")&&saved[page]===currentCursor?saved as AccountCursor[]:Array(page+1).fill(undefined);cursors[page]=currentCursor;return{search:params.get("accountSearch")??"",cursor:currentCursor,page,cursors};} -function writeAccountUrlState(search:string,cursor:string|null,page:number,cursors:AccountCursor[],mode:"push"|"replace"="push"){const params=new URLSearchParams(window.location.search);if(search)params.set("accountSearch",search);else params.delete("accountSearch");if(cursor){params.set("accountCursor",cursor);params.set("accountPage",String(page));}else{params.delete("accountCursor");params.delete("accountPage");}const state=window.history.state&&typeof window.history.state==="object"?window.history.state:{};window.history[mode==="push"?"pushState":"replaceState"]({...state,sampleflowAccountCursors:cursors},"",`${window.location.pathname}?${params.toString()}${window.location.hash}`);} +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}`);} export function AccountsPage({user}:{user:User}){ const initial=useRef(readAccountUrlState()).current; - const [users,setUsers]=useState([]);const [roles,setRoles]=useState>([]);const [permissionMatrix,setPermissionMatrix]=useState([]);const [message,setMessage]=useState("");const [loadError,setLoadError]=useState("");const [loading,setLoading]=useState(false);const [searchInput,setSearchInput]=useState(initial.search);const [search,setSearch]=useState(initial.search);const [isComposing,setIsComposing]=useState(false);const [cursors,setCursors]=useState(initial.cursors);const [pageIndex,setPageIndex]=useState(initial.page);const [nextCursor,setNextCursor]=useState(null);const [revision,setRevision]=useState(0);const [showCreate,setShowCreate]=useState(false);const [roleTarget,setRoleTarget]=useState(null);const [accountAction,setAccountAction]=useState<{kind:"reset"|"disable";item:AdminUser}|null>(null);const [pendingAccountIds,setPendingAccountIds]=useState([]);const [temporaryCredential,setTemporaryCredential]=useState<{username:string;password:string;expiresAt:string}|null>(null);const searchRef=useRef(null);const isAdmin=user.capabilities.manageAccounts;const cursor=cursors[pageIndex]??null;const previousCursor=pageIndex?cursors[pageIndex-1]:undefined; - const commitSearch=useCallback((value:string,historyMode:"push"|"replace"="push",updateInput=true)=>{const normalized=value.trim();writeAccountUrlState(normalized,null,0,[null],historyMode);if(updateInput)setSearchInput(normalized);setSearch(normalized);setCursors([null]);setPageIndex(0);setRevision((current)=>current+1);},[]); - useEffect(()=>{const restore=()=>{const restored=readAccountUrlState();setSearchInput(restored.search);setSearch(restored.search);setCursors(restored.cursors);setPageIndex(restored.page);setRevision((current)=>current+1);};window.addEventListener("popstate",restore);return()=>window.removeEventListener("popstate",restore);},[]); + const [users,setUsers]=useState([]);const [roles,setRoles]=useState>([]);const [permissionMatrix,setPermissionMatrix]=useState([]);const [message,setMessage]=useState("");const [loadError,setLoadError]=useState("");const [loading,setLoading]=useState(false);const [searchInput,setSearchInput]=useState(initial.search);const [search,setSearch]=useState(initial.search);const [isComposing,setIsComposing]=useState(false);const [page,setPage]=useState(initial.page);const [pageSize,setPageSize]=useState(initial.pageSize);const [totalCount,setTotalCount]=useState(0);const [revision,setRevision]=useState(0);const [showCreate,setShowCreate]=useState(false);const [roleTarget,setRoleTarget]=useState(null);const [accountAction,setAccountAction]=useState<{kind:"reset"|"disable";item:AdminUser}|null>(null);const [pendingAccountIds,setPendingAccountIds]=useState([]);const [temporaryCredential,setTemporaryCredential]=useState<{username:string;password:string;expiresAt:string}|null>(null);const searchRef=useRef(null);const isAdmin=user.capabilities.manageAccounts;const permissionPagination=usePagination(permissionMatrix); + const commitSearch=useCallback((value:string,historyMode:"push"|"replace"="push",updateInput=true)=>{const normalized=value.trim();writeAccountUrlState(normalized,1,pageSize,historyMode);if(updateInput)setSearchInput(normalized);setSearch(normalized);setPage(1);setRevision((current)=>current+1);},[pageSize]); + useEffect(()=>{const restore=()=>{const restored=readAccountUrlState();setSearchInput(restored.search);setSearch(restored.search);setPage(restored.page);setPageSize(restored.pageSize);setRevision((current)=>current+1);};window.addEventListener("popstate",restore);return()=>window.removeEventListener("popstate",restore);},[]); useEffect(()=>{if(isComposing||searchInput.trim()===search)return;const timer=window.setTimeout(()=>commitSearch(searchInput,"replace",false),300);return()=>window.clearTimeout(timer);},[searchInput,isComposing,search,commitSearch]); - const requestKey=JSON.stringify([search,cursor]); - useEffect(()=>{if(!isAdmin)return;const controller=new AbortController();const params=new URLSearchParams();if(search)params.set("search",search);if(cursor)params.set("cursor",cursor);setLoading(true);setLoadError("");apiFetch(`/api/admin/users${params.size?`?${params.toString()}`:""}`,{signal:controller.signal}).then(async(response)=>{const data=await readResponseJson<{users?:AdminUser[];roles?:Array<{code:string;name:string}>;permissionMatrix?:RolePermission[];nextCursor?:string|null;message?:string}>(response,"账号响应无效,请重试。");if(!response.ok)throw new Error(data.message??"账号加载失败");setUsers(data.users??[]);setRoles(data.roles??[]);setPermissionMatrix(data.permissionMatrix??[]);setNextCursor(data.nextCursor??null);}).catch((error)=>{if(error instanceof DOMException&&error.name==="AbortError")return;setLoadError(error instanceof Error?error.message:"账号加载失败");}).finally(()=>{if(!controller.signal.aborted)setLoading(false);});return()=>controller.abort();},[isAdmin,search,cursor,requestKey,revision]); + const requestKey=JSON.stringify([search,page,pageSize]); + useEffect(()=>{if(!isAdmin)return;const controller=new AbortController();const params=new URLSearchParams({page:String(page),pageSize:String(pageSize)});if(search)params.set("search",search);setLoading(true);setLoadError("");apiFetch(`/api/admin/users?${params}`,{signal:controller.signal}).then(async(response)=>{const data=await readResponseJson<{users?:AdminUser[];roles?:Array<{code:string;name:string}>;permissionMatrix?:RolePermission[];totalCount?:number;message?:string}>(response,"账号响应无效,请重试。");if(!response.ok)throw new Error(data.message??"账号加载失败");const total=data.totalCount??0;const lastPage=Math.max(1,Math.ceil(total/pageSize));if(page>lastPage){writeAccountUrlState(search,lastPage,pageSize,"replace");setPage(lastPage);return;}setUsers(data.users??[]);setRoles(data.roles??[]);setPermissionMatrix(data.permissionMatrix??[]);setTotalCount(total);}).catch((error)=>{if(error instanceof DOMException&&error.name==="AbortError")return;setLoadError(error instanceof Error?error.message:"账号加载失败");}).finally(()=>{if(!controller.signal.aborted)setLoading(false);});return()=>controller.abort();},[isAdmin,search,page,pageSize,requestKey,revision]); function refresh(){setRevision((value)=>value+1);} - function restart(){writeAccountUrlState(search,null,0,[null],"replace");setCursors([null]);setPageIndex(0);setRevision((value)=>value+1);} + function restart(){writeAccountUrlState(search,1,pageSize,"replace");setPage(1);setRevision((value)=>value+1);} function submitSearch(event:FormEvent){event.preventDefault();if(isComposing)return;setMessage("");commitSearch(searchInput);} function clearSearch(){setSearchInput("");commitSearch("","push",false);window.requestAnimationFrame(()=>searchRef.current?.focus());} - function nextPage(){if(!nextCursor)return;const nextCursors=[...cursors.slice(0,pageIndex+1),nextCursor];writeAccountUrlState(search,nextCursor,pageIndex+1,nextCursors);setCursors(nextCursors);setPageIndex((value)=>value+1);} - function previousPage(){if(pageIndex===0||previousCursor===undefined)return;const previousCursors=cursors.slice(0,pageIndex);writeAccountUrlState(search,previousCursor,pageIndex-1,previousCursors);setCursors(previousCursors);setPageIndex((value)=>value-1);} + function changePage(value:number){writeAccountUrlState(search,value,pageSize);setPage(value);} + function changePageSize(value:PageSize){writeAccountUrlState(search,1,value);setPageSize(value);setPage(1);} function setAccountPending(id:string,pending:boolean){setPendingAccountIds((current)=>pending?[...current,id]:current.filter((value)=>value!==id));} async function toggle(item:AdminUser){if(pendingAccountIds.includes(item.id))return;setAccountPending(item.id,true);setMessage("");try{const response=await apiFetch(`/api/admin/users/${item.id}/status`,{method:"PATCH",headers:{"content-type":"application/json"},body:JSON.stringify({isActive:!item.isActive})});const data=await readResponseJson<{message?:string}>(response,"状态修改响应无效,请重试。");if(!response.ok){setMessage(data.message??"状态修改失败");return;}refresh();}catch(failure){setMessage(failure instanceof Error?failure.message:"网络异常,状态修改失败,请重试。");}finally{setAccountPending(item.id,false);}} 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?"查询失败":`第 ${pageIndex+1} 页 · 本页 ${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?:permissionMatrix.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?setTemporaryCredential(null)}>

账号:{temporaryCredential.username}

{temporaryCredential.password}失效时间:{new Date(temporaryCredential.expiresAt).toLocaleString("zh-CN")}
:null}
; } function scopeName(scope:RolePermission["businessScope"]):string{return{none:"无业务范围",self:"仅本人",group:"本人及所负责小组",department:"本人及所负责部门",all:"全公司 / 销售组织"}[scope];} diff --git a/apps/web/src/pages/analysis-page.tsx b/apps/web/src/pages/analysis-page.tsx index 25b16d0..4558a83 100644 --- a/apps/web/src/pages/analysis-page.tsx +++ b/apps/web/src/pages/analysis-page.tsx @@ -2,7 +2,7 @@ import { useEffect, useRef, useState } from "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, Status } from "../shared-ui"; +import { Metric, 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", @@ -13,8 +13,8 @@ const chinaMapRegionCodes:Record={ "540000":"CN-XZ","610000":"CN-SN","620000":"CN-GS","630000":"CN-QH","640000":"CN-NX", "650000":"CN-XJ","710000":"CN-TW","810000":"CN-HK","820000":"CN-MO", }; -function readAnalysisUrlState(){const params=new URLSearchParams(window.location.search);const month=params.get("analysisMonth");return{month:month&&/^\d{4}-(0[1-9]|1[0-2])$/.test(month)?month:null,region:params.get("analysisRegion"),customer:params.get("analysisCustomer"),eventMonth:params.get("analysisEventMonth")};} -function writeAnalysisUrlState(values:{month?:string;region?:string|null;customer?:string|null;eventMonth?:string|null},mode:"push"|"replace"="push"){const params=new URLSearchParams(window.location.search);for(const [key,value] of Object.entries({analysisMonth:values.month,analysisRegion:values.region,analysisCustomer:values.customer,analysisEventMonth:values.eventMonth}))if(value===null)params.delete(key);else if(value!==undefined)params.set(key,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");return{month:month&&/^\d{4}-(0[1-9]|1[0-2])$/.test(month)?month:null,region:params.get("analysisRegion"),customer:params.get("analysisCustomer"),eventMonth:params.get("analysisEventMonth"),customerPage:parsePageNumber(params.get("analysisCustomerPage")),customerPageSize:parsePageSize(params.get("analysisCustomerPageSize")),eventPage:parsePageNumber(params.get("analysisEventPage")),eventPageSize:parsePageSize(params.get("analysisEventPageSize"))};} +function writeAnalysisUrlState(values:{month?:string;region?:string|null;customer?:string|null;eventMonth?:string|null;customerPage?:number|null;customerPageSize?:PageSize|null;eventPage?:number|null;eventPageSize?:PageSize|null},mode:"push"|"replace"="push"){const params=new URLSearchParams(window.location.search);for(const [key,value] of Object.entries({analysisMonth:values.month,analysisRegion:values.region,analysisCustomer:values.customer,analysisEventMonth:values.eventMonth,analysisCustomerPage:values.customerPage,analysisCustomerPageSize:values.customerPageSize,analysisEventPage:values.eventPage,analysisEventPageSize:values.eventPageSize}))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; @@ -25,11 +25,11 @@ export function AnalysisPage(){ const[selectedProvince,setSelectedProvince]=useState(null); useEffect(()=>{const restore=()=>{const state=readAnalysisUrlState();setMonth(state.month??businessDateToday().slice(0,7));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?month=${month}`,{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();},[month,revision]); - function chooseProvince(province:AnalysisProvince){writeAnalysisUrlState({region:province.regionCode,customer:null,eventMonth:null});setSelectedProvince(province);} - return

地区与客户单位分析

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

+ function chooseProvince(province:AnalysisProvince){writeAnalysisUrlState({region:province.regionCode,customer:null,eventMonth:null,customerPage:null,customerPageSize:null,eventPage:null,eventPageSize:null});setSelectedProvince(province);} + return

地区与客户单位分析

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

{error?

{error}

:null} {!data&&!error?

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

:null} - {data?<>

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

省份汇总

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

客户单位汇总

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

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

省份汇总

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

客户单位汇总

{(pageItems)=>
{pageItems.length?pageItems.map((item)=>):}
区域客户单位事件金额
{item.regionName}{item.customerUnit}{item.eventCount}{formatMoney(item.totalAmount)}
本月没有已映射客户单位事件。
}
{selectedProvince?:null}:null}
; } @@ -51,11 +51,12 @@ function ChinaProvinceMap({provinces,selectedRegionCode,onSelect}:{provinces:Ana } function AnalysisDrilldown({province,month}:{province:AnalysisProvince;month:string}){ + const initial=useRef(readAnalysisUrlState()).current; const[customers,setCustomers]=useState(null); const[customersError,setCustomersError]=useState(""); const[customersRevision,setCustomersRevision]=useState(0); - const[customerCursor,setCustomerCursor]=useState(null); - const[customersLoadingMore,setCustomersLoadingMore]=useState(false); + const[customerPage,setCustomerPage]=useState(initial.customerPage); + const[customerPageSize,setCustomerPageSize]=useState(initial.customerPageSize); const[selectedCustomer,setSelectedCustomer]=useState(null); const[months,setMonths]=useState(null); const[monthsError,setMonthsError]=useState(""); @@ -64,29 +65,29 @@ function AnalysisDrilldown({province,month}:{province:AnalysisProvince;month:str const[events,setEvents]=useState(null); const[eventsError,setEventsError]=useState(""); const[eventsRevision,setEventsRevision]=useState(0); - const[eventCursor,setEventCursor]=useState(null); - const[eventsLoadingMore,setEventsLoadingMore]=useState(false); + const[eventPage,setEventPage]=useState(initial.eventPage); + const[eventPageSize,setEventPageSize]=useState(initial.eventPageSize); useEffect(()=>{ - const append=customerCursor!==null;const controller=new AbortController();if(!append){setCustomers(null);setSelectedCustomer(null);setMonths(null);setSelectedMonth(null);setEvents(null);}else setCustomersLoadingMore(true);setCustomersError(""); - const params=new URLSearchParams({level:"customers",regionCode:province.regionCode,month});if(customerCursor)params.set("cursor",customerCursor); - apiFetch(`/api/performance/analysis/drilldown?${params}`,{signal:controller.signal}).then(async(response)=>{const result=await readResponseJson(response,"省份客户响应无效");if(!response.ok)throw new Error(result.message??"省份客户加载失败");setCustomers((current)=>append&¤t?{...result,customers:[...current.customers,...result.customers]}:result);const customer=readAnalysisUrlState().customer;if(customer){const match=result.customers.find((item)=>item.customerUnit===customer);if(match)setSelectedCustomer(match);else if(result.nextCursor&&result.nextCursor!==customerCursor)setCustomerCursor(result.nextCursor);}}).catch((failure)=>{if(failure instanceof DOMException&&failure.name==="AbortError")return;setCustomersError(failure instanceof Error?failure.message:"省份客户加载失败");}).finally(()=>{if(!controller.signal.aborted)setCustomersLoadingMore(false);}); + const controller=new AbortController();setCustomers(null);setSelectedCustomer(null);setMonths(null);setSelectedMonth(null);setEvents(null);setCustomersError(""); + const params=new URLSearchParams({level:"customers",regionCode:province.regionCode,month,page:String(customerPage),pageSize:String(customerPageSize)}); + apiFetch(`/api/performance/analysis/drilldown?${params}`,{signal:controller.signal}).then(async(response)=>{const result=await readResponseJson(response,"省份客户响应无效");if(!response.ok)throw new Error(result.message??"省份客户加载失败");const lastPage=Math.max(1,Math.ceil(result.totalCount/customerPageSize));if(customerPage>lastPage){writeAnalysisUrlState({customerPage:lastPage},"replace");setCustomerPage(lastPage);return;}setCustomers(result);const customer=readAnalysisUrlState().customer;if(customer)setSelectedCustomer(result.customers.find((item)=>item.customerUnit===customer)??null);}).catch((failure)=>{if(failure instanceof DOMException&&failure.name==="AbortError")return;setCustomersError(failure instanceof Error?failure.message:"省份客户加载失败");}); return()=>controller.abort(); - },[province.regionCode,month,customerCursor,customersRevision]); + },[province.regionCode,month,customerPage,customerPageSize,customersRevision]); useEffect(()=>{ - setMonths(null);setMonthsError("");setSelectedMonth(null);setEvents(null);setEventCursor(null);if(!selectedCustomer)return; + setMonths(null);setMonthsError("");setSelectedMonth(null);setEvents(null);if(!selectedCustomer)return; const controller=new AbortController();const params=new URLSearchParams({level:"months",regionCode:province.regionCode,customerUnit:selectedCustomer.customerUnit,year:month.slice(0,4)}); apiFetch(`/api/performance/analysis/drilldown?${params}`,{signal:controller.signal}).then(async(response)=>{const result=await readResponseJson(response,"客户月份响应无效");if(!response.ok)throw new Error(result.message??"客户月份加载失败");setMonths(result);const eventMonth=readAnalysisUrlState().eventMonth;if(eventMonth)setSelectedMonth(result.months.find((item)=>item.month===eventMonth)??null);}).catch((failure)=>{if(failure instanceof DOMException&&failure.name==="AbortError")return;setMonthsError(failure instanceof Error?failure.message:"客户月份加载失败");}); return()=>controller.abort(); },[province.regionCode,selectedCustomer,month,monthsRevision]); useEffect(()=>{ - const append=eventCursor!==null;if(!append)setEvents(null);setEventsError("");if(!selectedCustomer||!selectedMonth)return; - const controller=new AbortController();if(append)setEventsLoadingMore(true);const params=new URLSearchParams({level:"events",regionCode:province.regionCode,customerUnit:selectedCustomer.customerUnit,month:selectedMonth.month});if(eventCursor)params.set("cursor",eventCursor); - apiFetch(`/api/performance/analysis/drilldown?${params}`,{signal:controller.signal}).then(async(response)=>{const result=await readResponseJson(response,"订单事件响应无效");if(!response.ok)throw new Error(result.message??"订单事件加载失败");setEvents((current)=>{if(!append||!current)return result;const orders=new Map(current.orders.map((order)=>[order.orderId,{...order,events:[...order.events]}]));for(const order of result.orders){const existing=orders.get(order.orderId);if(existing)existing.events.push(...order.events);else orders.set(order.orderId,order);}return{...result,orders:[...orders.values()]};});}).catch((failure)=>{if(failure instanceof DOMException&&failure.name==="AbortError")return;setEventsError(failure instanceof Error?failure.message:"订单事件加载失败");}).finally(()=>{if(!controller.signal.aborted)setEventsLoadingMore(false);}); + setEvents(null);setEventsError("");if(!selectedCustomer||!selectedMonth)return; + const controller=new AbortController();const params=new URLSearchParams({level:"events",regionCode:province.regionCode,customerUnit:selectedCustomer.customerUnit,month:selectedMonth.month,page:String(eventPage),pageSize:String(eventPageSize)}); + apiFetch(`/api/performance/analysis/drilldown?${params}`,{signal:controller.signal}).then(async(response)=>{const result=await readResponseJson(response,"订单事件响应无效");if(!response.ok)throw new Error(result.message??"订单事件加载失败");const lastPage=Math.max(1,Math.ceil(result.totalCount/eventPageSize));if(eventPage>lastPage){writeAnalysisUrlState({eventPage:lastPage},"replace");setEventPage(lastPage);return;}setEvents(result);}).catch((failure)=>{if(failure instanceof DOMException&&failure.name==="AbortError")return;setEventsError(failure instanceof Error?failure.message:"订单事件加载失败");}); return()=>controller.abort(); - },[province.regionCode,selectedCustomer,selectedMonth,eventCursor,eventsRevision]); + },[province.regionCode,selectedCustomer,selectedMonth,eventPage,eventPageSize,eventsRevision]); const customerMatched=customers&&formatMoney(customers.totalAmount)===formatMoney(province.totalAmount)&&customers.eventCount===province.eventCount; const monthRows=new Map(months?.months.map((item)=>[item.month,item])??[]); @@ -95,11 +96,15 @@ function AnalysisDrilldown({province,month}:{province:AnalysisProvince;month:str const monthMatched=parentMonth&&selectedCustomer&&formatMoney(parentMonth.totalAmount)===formatMoney(selectedCustomer.totalAmount)&&parentMonth.eventCount===selectedCustomer.eventCount; const eventsMatched=events&&selectedMonth&&formatMoney(events.totalAmount)===formatMoney(selectedMonth.totalAmount)&&events.eventCount===selectedMonth.eventCount; const allEvents=events?.orders.flatMap((order)=>order.events)??[]; + function changeCustomerPage(page:number){writeAnalysisUrlState({customer:null,eventMonth:null,customerPage:page,eventPage:null,eventPageSize:null});setCustomerPage(page);} + function changeCustomerPageSize(pageSize:PageSize){writeAnalysisUrlState({customer:null,eventMonth:null,customerPage:1,customerPageSize:pageSize,eventPage:null,eventPageSize:null});setCustomerPageSize(pageSize);setCustomerPage(1);} + function changeEventPage(page:number){writeAnalysisUrlState({eventPage:page,eventPageSize});setEventPage(page);} + function changeEventPageSize(pageSize:PageSize){writeAnalysisUrlState({eventPage:1,eventPageSize:pageSize});setEventPageSize(pageSize);setEventPage(1);} return

{province.regionName}客户单位

{province.eventCount} 条事件 · 省份汇总 {formatMoney(province.totalAmount)}

- {customersError?

{customersError}

:null} + {customersError?

{customersError}

:null} {!customers&&!customersError?

正在读取{province.regionName}客户单位…

:null} - {customers?<>

{customerMatched?`服务端客户事件数与金额 ${formatMoney(customers.totalAmount)} 均与省份汇总完全对平。`:"客户合计与省份汇总不一致,请停止使用当前穿透结果。"}

{customers.customers.length?customers.customers.map((customer)=>):}
客户单位事件金额穿透
{customer.customerUnit}{customer.eventCount}{formatMoney(customer.totalAmount)}
该省份本月没有客户单位事件。
:null} - {selectedCustomer?

{selectedCustomer.customerUnit}月度趋势

{month.slice(0,4)} 年 · 客户年度净额 {months?formatMoney(months.totalAmount):"读取中"}

{monthsError?

{monthsError}

:null}{!months&&!monthsError?

正在读取{selectedCustomer.customerUnit}月度趋势…

:null}{months?<>

{monthMatched?`${month.replace("-","年")}月金额与上级客户行完全对平。`:`${month.replace("-","年")}月金额与上级客户行不一致,请停止使用当前穿透结果。`}

{filledMonths.map((item)=>
)}
:null}
:null} - {selectedMonth&&selectedCustomer?

{Number(selectedMonth.month.slice(0,4))}年{Number(selectedMonth.month.slice(5))}月订单与事件

{selectedCustomer.customerUnit} · 上级月份净额 {formatMoney(selectedMonth.totalAmount)}

{eventsError?

{eventsError}

:null}{!events&&!eventsError?

正在读取订单与不可变事件…

:null}{events?<>

{eventsMatched?`服务端全部订单事件合计 ${formatMoney(events.totalAmount)} 与上级月份完全对平。`:"订单事件合计与上级月份不一致,请停止使用当前穿透结果。"}

已加载 {allEvents.length} / {events.eventCount} 条事件

{events.orders.length?events.orders.map((order)=>):}
订单客户事件净额
{order.orderNo}{order.customerName}{order.eventCount}{formatMoney(order.totalAmount)}
该月份没有订单事件。
{allEvents.length?
{allEvents.map((event)=>)}
序号事件金额业务日 / 记账月发生时分析维度责任归属状态 / 原因
第 {event.sequence} 条{eventTypeName(event.eventType)}{formatMoney(event.deltaAmount)}{event.occurredOn} / {event.accountingMonth}{province.regionName} / {event.customerUnit}{event.businessRegionSourceText}{event.salespersonName}{[event.departmentName,event.groupName].filter(Boolean).join(" / ")||"—"}{event.resultingLifecycleState?:"原始状态未推断"}{event.reason??"—"}
:null}{events.nextCursor?:null}:null}
:null} + {customers?<>

{customerMatched?`服务端客户事件数与金额 ${formatMoney(customers.totalAmount)} 均与省份汇总完全对平。`:"客户合计与省份汇总不一致,请停止使用当前穿透结果。"}

{customers.customers.length?customers.customers.map((customer)=>):}
客户单位事件金额穿透
{customer.customerUnit}{customer.eventCount}{formatMoney(customer.totalAmount)}
该省份本月没有客户单位事件。
:null} + {selectedCustomer?

{selectedCustomer.customerUnit}月度趋势

{month.slice(0,4)} 年 · 客户年度净额 {months?formatMoney(months.totalAmount):"读取中"}

{monthsError?

{monthsError}

:null}{!months&&!monthsError?

正在读取{selectedCustomer.customerUnit}月度趋势…

:null}{months?<>

{monthMatched?`${month.replace("-","年")}月金额与上级客户行完全对平。`:`${month.replace("-","年")}月金额与上级客户行不一致,请停止使用当前穿透结果。`}

{filledMonths.map((item)=>
)}
:null}
:null} + {selectedMonth&&selectedCustomer?

{Number(selectedMonth.month.slice(0,4))}年{Number(selectedMonth.month.slice(5))}月订单与事件

{selectedCustomer.customerUnit} · 上级月份净额 {formatMoney(selectedMonth.totalAmount)}

{eventsError?

{eventsError}

:null}{!events&&!eventsError?

正在读取订单与不可变事件…

:null}{events?<>

{eventsMatched?`服务端全部订单事件合计 ${formatMoney(events.totalAmount)} 与上级月份完全对平。`:"订单事件合计与上级月份不一致,请停止使用当前穿透结果。"}

本页 {allEvents.length} / 共 {events.eventCount} 条事件

{events.orders.length?events.orders.map((order)=>):}
订单客户事件净额
{order.orderNo}{order.customerName}{order.eventCount}{formatMoney(order.totalAmount)}
该月份没有订单事件。
{allEvents.length?
{allEvents.map((event)=>)}
序号事件金额业务日 / 记账月发生时分析维度责任归属状态 / 原因
第 {event.sequence} 条{eventTypeName(event.eventType)}{formatMoney(event.deltaAmount)}{event.occurredOn} / {event.accountingMonth}{province.regionName} / {event.customerUnit}{event.businessRegionSourceText}{event.salespersonName}{[event.departmentName,event.groupName].filter(Boolean).join(" / ")||"—"}{event.resultingLifecycleState?:"原始状态未推断"}{event.reason??"—"}
:null}:null}
:null}
; } diff --git a/apps/web/src/pages/audit-page.tsx b/apps/web/src/pages/audit-page.tsx index 42996b1..4aa50a4 100644 --- a/apps/web/src/pages/audit-page.tsx +++ b/apps/web/src/pages/audit-page.tsx @@ -2,25 +2,26 @@ import { type FormEvent, useEffect, useRef, useState } from "react"; import { ShieldCheck } from "lucide-react"; import { apiFetch, auditActionName, auditDataText, auditEntityName, readResponseJson } from "../app-api"; import type { AuditFilters, AuditRow } from "../app-types"; +import { Pagination, parsePageNumber, parsePageSize, type PageSize } from "../shared-ui"; const emptyAuditFilters:AuditFilters={person:"",action:"",entityType:"",entityId:"",from:"",to:""}; const auditUrlKeys:Record={person:"auditPerson",action:"auditAction",entityType:"auditEntityType",entityId:"auditEntityId",from:"auditFrom",to:"auditTo"}; -function readAuditUrlState(){const params=new URLSearchParams(window.location.search);const filters={...emptyAuditFilters};for(const key of Object.keys(auditUrlKeys) as Array)filters[key]=params.get(auditUrlKeys[key])??"";const cursor=params.get("auditCursor");const saved=(window.history.state as {sampleflowAuditPagination?:unknown}|null)?.sampleflowAuditPagination;const pagination=saved&&typeof saved==="object"&&!Array.isArray(saved)?saved as {cursor?:unknown;previousCursors?:unknown}:null;const previousCursors=pagination?.cursor===cursor&&Array.isArray(pagination.previousCursors)&&pagination.previousCursors.every((item)=>item===null||typeof item==="string")?pagination.previousCursors as Array:[];return{filters,cursor,previousCursors};} -function writeAuditUrlState(filters:AuditFilters,cursor:string|null,previousCursors:Array,mode:"push"|"replace"="push"){const params=new URLSearchParams(window.location.search);for(const key of Object.keys(auditUrlKeys) as Array){if(filters[key])params.set(auditUrlKeys[key],filters[key]);else params.delete(auditUrlKeys[key]);}if(cursor)params.set("auditCursor",cursor);else params.delete("auditCursor");const state=window.history.state&&typeof window.history.state==="object"?window.history.state:{};window.history[mode==="push"?"pushState":"replaceState"]({...state,sampleflowAuditPagination:{cursor,previousCursors}},"",`${window.location.pathname}?${params.toString()}${window.location.hash}`);} +function readAuditUrlState(){const params=new URLSearchParams(window.location.search);const filters={...emptyAuditFilters};for(const key of Object.keys(auditUrlKeys) as Array)filters[key]=params.get(auditUrlKeys[key])??"";return{filters,page:parsePageNumber(params.get("auditPage")),pageSize:parsePageSize(params.get("auditPageSize"))};} +function writeAuditUrlState(filters:AuditFilters,page:number,pageSize:PageSize,mode:"push"|"replace"="push"){const params=new URLSearchParams(window.location.search);for(const key of Object.keys(auditUrlKeys) as Array){if(filters[key])params.set(auditUrlKeys[key],filters[key]);else params.delete(auditUrlKeys[key]);}params.delete("auditCursor");params.set("auditPage",String(page));params.set("auditPageSize",String(pageSize));window.history[mode==="push"?"pushState":"replaceState"]({},"",`${window.location.pathname}?${params.toString()}${window.location.hash}`);} function auditDateTime(value:string):string{return new Intl.DateTimeFormat("zh-CN",{dateStyle:"medium",timeStyle:"medium",timeZone:"Asia/Shanghai"}).format(new Date(value));} function auditTimeParameter(value:string):string{return value?`${value}${value.length===16?":00":""}+08:00`:"";} export function AuditPage(){ const initial=useRef(readAuditUrlState()).current; - const[draft,setDraft]=useState(initial.filters);const[filters,setFilters]=useState(initial.filters);const[audits,setAudits]=useState([]);const[cursor,setCursor]=useState(initial.cursor);const[previousCursors,setPreviousCursors]=useState>(initial.previousCursors);const[nextCursor,setNextCursor]=useState(null);const[loading,setLoading]=useState(false);const[error,setError]=useState("");const[revision,setRevision]=useState(0); - useEffect(()=>{const restore=()=>{const state=readAuditUrlState();setDraft(state.filters);setFilters(state.filters);setCursor(state.cursor);setPreviousCursors(state.previousCursors);setRevision((value)=>value+1);};window.addEventListener("popstate",restore);return()=>window.removeEventListener("popstate",restore);},[]); - const requestKey=JSON.stringify([filters,cursor,revision]); - useEffect(()=>{const controller=new AbortController();const params=new URLSearchParams();for(const key of ["person","action","entityType","entityId"] as const)if(filters[key])params.set(key,filters[key]);if(filters.from)params.set("from",auditTimeParameter(filters.from));if(filters.to)params.set("to",auditTimeParameter(filters.to));if(cursor)params.set("cursor",cursor);setLoading(true);setError("");apiFetch(`/api/audits${params.size?`?${params.toString()}`:""}`,{signal:controller.signal}).then(async(response)=>{const data=await readResponseJson<{audits?:AuditRow[];nextCursor?:string|null;message?:string}>(response,"审计响应无效,请重试。");if(!response.ok)throw new Error(data.message??"审计查询失败");setAudits(data.audits??[]);setNextCursor(data.nextCursor??null);}).catch((reason)=>{if(reason instanceof DOMException&&reason.name==="AbortError")return;setError(reason instanceof Error?reason.message:"审计查询失败");}).finally(()=>{if(!controller.signal.aborted)setLoading(false);});return()=>controller.abort();},[requestKey]); + const[draft,setDraft]=useState(initial.filters);const[filters,setFilters]=useState(initial.filters);const[audits,setAudits]=useState([]);const[page,setPage]=useState(initial.page);const[pageSize,setPageSize]=useState(initial.pageSize);const[totalCount,setTotalCount]=useState(0);const[loading,setLoading]=useState(false);const[error,setError]=useState("");const[revision,setRevision]=useState(0); + useEffect(()=>{const restore=()=>{const state=readAuditUrlState();setDraft(state.filters);setFilters(state.filters);setPage(state.page);setPageSize(state.pageSize);setRevision((value)=>value+1);};window.addEventListener("popstate",restore);return()=>window.removeEventListener("popstate",restore);},[]); + const requestKey=JSON.stringify([filters,page,pageSize,revision]); + useEffect(()=>{const controller=new AbortController();const params=new URLSearchParams({page:String(page),pageSize:String(pageSize)});for(const key of ["person","action","entityType","entityId"] as const)if(filters[key])params.set(key,filters[key]);if(filters.from)params.set("from",auditTimeParameter(filters.from));if(filters.to)params.set("to",auditTimeParameter(filters.to));setLoading(true);setError("");apiFetch(`/api/audits?${params}`,{signal:controller.signal}).then(async(response)=>{const data=await readResponseJson<{audits?:AuditRow[];page?:number;pageSize?:PageSize;totalCount?:number;message?:string}>(response,"审计响应无效,请重试。");if(!response.ok)throw new Error(data.message??"审计查询失败");const total=data.totalCount??0;const lastPage=Math.max(1,Math.ceil(total/pageSize));if(page>lastPage){writeAuditUrlState(filters,lastPage,pageSize,"replace");setPage(lastPage);return;}setAudits(data.audits??[]);setTotalCount(total);}).catch((reason)=>{if(reason instanceof DOMException&&reason.name==="AbortError")return;setError(reason instanceof Error?reason.message:"审计查询失败");}).finally(()=>{if(!controller.signal.aborted)setLoading(false);});return()=>controller.abort();},[requestKey]); function update(key:keyof AuditFilters,value:string){setDraft((current)=>({...current,[key]:value}));} - function query(event:FormEvent){event.preventDefault();const next=Object.fromEntries(Object.entries(draft).map(([key,value])=>[key,value.trim()])) as AuditFilters;writeAuditUrlState(next,null,[]);setFilters(next);setCursor(null);setPreviousCursors([]);setRevision((value)=>value+1);} - function clear(){writeAuditUrlState(emptyAuditFilters,null,[]);setDraft(emptyAuditFilters);setFilters(emptyAuditFilters);setCursor(null);setPreviousCursors([]);setRevision((value)=>value+1);} - function nextPage(){if(!nextCursor)return;const history=[...previousCursors,cursor];writeAuditUrlState(filters,nextCursor,history);setPreviousCursors(history);setCursor(nextCursor);} - function previousPage(){if(previousCursors.length===0)return;const previous=previousCursors.at(-1)!;const history=previousCursors.slice(0,-1);writeAuditUrlState(filters,previous,history);setPreviousCursors(history);setCursor(previous);} - return

审计查询

按账号、动作、实体和时间追溯不可变记录;数据范围沿用当前角色权限

此页面只提供查询,不提供修改或删除入口;敏感凭据字段不会返回。

审计记录

{loading?"正在查询…":error?"查询失败":`本页 ${audits.length} 条记录`}
{error?

{error}

:null}
{!loading&&!error&&audits.length===0?:audits.map((row)=>)}
时间人员动作实体变更前变更后
没有符合当前权限和条件的审计记录。
{row.actorDisplayName??"系统"}{row.actorUsername??row.actorPersonId??"—"}{auditActionName(row.action)}{auditEntityName(row.entityType)}{row.entityId??"—"}{auditDataText(row.beforeData)}{auditDataText(row.afterData)}
; + function query(event:FormEvent){event.preventDefault();const next=Object.fromEntries(Object.entries(draft).map(([key,value])=>[key,value.trim()])) as AuditFilters;writeAuditUrlState(next,1,pageSize);setFilters(next);setPage(1);setRevision((value)=>value+1);} + function clear(){writeAuditUrlState(emptyAuditFilters,1,pageSize);setDraft(emptyAuditFilters);setFilters(emptyAuditFilters);setPage(1);setRevision((value)=>value+1);} + function changePage(nextPage:number){writeAuditUrlState(filters,nextPage,pageSize);setPage(nextPage);} + function changePageSize(nextPageSize:PageSize){writeAuditUrlState(filters,1,nextPageSize);setPageSize(nextPageSize);setPage(1);} + return

审计查询

按账号、动作、实体和时间追溯不可变记录;数据范围沿用当前角色权限

此页面只提供查询,不提供修改或删除入口;敏感凭据字段不会返回。

审计记录

{loading?"正在查询…":error?"查询失败":`本页 ${audits.length} 条记录`}
{error?

{error}

:null}
{!loading&&!error&&audits.length===0?:audits.map((row)=>)}
时间人员动作实体变更前变更后
没有符合当前权限和条件的审计记录。
{row.actorDisplayName??"系统"}{row.actorUsername??row.actorPersonId??"—"}{auditActionName(row.action)}{auditEntityName(row.entityType)}{row.entityId??"—"}{auditDataText(row.beforeData)}{auditDataText(row.afterData)}
; } diff --git a/apps/web/src/pages/goal-workspace.tsx b/apps/web/src/pages/goal-workspace.tsx index 4238b60..22699ff 100644 --- a/apps/web/src/pages/goal-workspace.tsx +++ b/apps/web/src/pages/goal-workspace.tsx @@ -2,7 +2,7 @@ import { type FormEvent, type ReactNode, useCallback, useEffect, useRef, useStat import { Plus } from "lucide-react"; import { GOAL_CONFIRMATION_STATEMENT, apiFetch, auditDataText, businessDateToday, downloadApiFile, formatMoney, formatOperationTime, goalAuditName, goalLevelName, goalStatusName, readResponseJson, workflowStatusName } from "../app-api"; import type { FormalReport, Goal, GoalChangeRequest, GoalHistory, GoalLevel, GoalLinkageDecision, GoalOption, GoalWorkflows, ParentGoalOption, User } from "../app-types"; -import { Field, Modal } from "../shared-ui"; +import { Field, Modal, PaginatedCollection, Pagination, usePagination } from "../shared-ui"; type GoalDialog={kind:"sign"|"approve"|"reject"|"request";goal:Goal}; type WorkflowDialog={kind:"accept"|"reject";request:GoalChangeRequest}|{kind:"linkage";linkage:GoalLinkageDecision}; @@ -20,12 +20,13 @@ export function GoalWorkspace({ user, pendingOnly }: { user: User; pendingOnly: const canCreate=user.roles.some((role)=>["sales_manager","sales_supervisor","sales_leader"].includes(role)); const load=useCallback(async()=>{const revision=++loadRevision.current;setLoading(true);setLoadError("");setGoals([]);setWorkflows({changeRequests:[],linkageDecisions:[]});try{const [goalResponse,workflowResponse]=await Promise.all([apiFetch(pendingOnly?"/api/goals?pendingOnly=true":"/api/goals"),pendingOnly?apiFetch("/api/goal-workflows"):Promise.resolve(null)]);const goalData=await readResponseJson<{goals?:Goal[];message?:string}>(goalResponse,"目标响应无效,请重试。");if(!goalResponse.ok)throw new Error(goalData.message??"目标加载失败");let workflowData:GoalWorkflows={changeRequests:[],linkageDecisions:[]};if(workflowResponse){const parsed=await readResponseJson(workflowResponse,"目标待办响应无效,请重试。");if(!workflowResponse.ok)throw new Error(parsed.message??"目标待办加载失败");workflowData={changeRequests:parsed.changeRequests??[],linkageDecisions:parsed.linkageDecisions??[]};}if(revision!==loadRevision.current)return;setGoals(goalData.goals??[]);setWorkflows(workflowData);}catch(error){if(revision!==loadRevision.current)return;setGoals([]);setWorkflows({changeRequests:[],linkageDecisions:[]});setLoadError(error instanceof Error?error.message:"目标加载失败");throw error;}finally{if(revision===loadRevision.current)setLoading(false);}},[pendingOnly]); useEffect(()=>{load().catch(()=>undefined);},[load]); + const goalPagination=usePagination(goals,pendingOnly); const refreshed=async(note:string)=>{setGoalDialog(null);setWorkflowDialog(null);try{await load();setMessage(note);}catch{setMessage(`${note} 列表刷新失败,请手动刷新页面。`);}}; async function withdraw(item:GoalChangeRequest){if(busyWorkflowId)return;setBusyWorkflowId(item.id);setMessage("");try{const response=await apiFetch(`/api/goal-change-requests/${item.id}/withdraw`,{method:"POST",headers:{"content-type":"application/json"},body:"{}"});const data=await readResponseJson<{message?:string}>(response,"撤回响应无效,请重试。");if(!response.ok){if(response.status===403||response.status===409){await refreshed("状态已变化,已重新读取权威状态。");return;}setMessage(data.message??"撤回失败");return;}await refreshed("修改申请已撤回,可以基于当前生效目标重新申请。");}catch{await refreshed("操作结果不确定,已重新读取权威状态。");}finally{setBusyWorkflowId(null);}} - return

{pendingOnly?"审批中心":"目标管理"}

{pendingOnly?"处理目标审批、修改申请和逐级联动选择":"按组织责任逐级下达,实名确认和审批均绑定具体版本"}

{user.capabilities.exportGoals?:null}{canCreate&&!pendingOnly?:null}
{message?

{message}

:null}{loadError?

{loadError}

:null}

{pendingOnly?"待确认与待审批目标":"目标责任台账"}

{loading?"正在读取…":loadError?"读取失败":`${goals.length} 条记录`}
{!loading&&!loadError&&goals.length===0?:goals.map((goal)=>)}
月份层级 / 范围责任人目标直接下级分配差额状态操作
{pendingOnly?"当前没有待确认或待审批目标。":"暂无目标;有下达权限的负责人可创建第一条目标。"}
{goal.periodMonth}{goalLevelName(goal.level)}{goal.orgUnitName?{goal.orgUnitName}:null}{goal.ownerName}{formatMoney(goal.amount)}{goal.effectiveAmount!==null&&goal.effectiveAmount!==goal.amount?当前生效 {formatMoney(goal.effectiveAmount)}:null}{formatMoney(goal.allocatedAmount)}{allocationText(goal)}{goalStatusName(goal.status)}
{goal.status==="active"?:null}{goal.ownerPersonId===user.personId&&goal.status==="pending_signature"?:null}{goal.ownerPersonId===user.personId&&goal.level!=="sales_manager"&&goal.status==="active"?:null}{goal.status==="pending_gm"&&user.roles.includes("general_manager")?<>:null}{goal.status==="pending_hr"&&user.roles.includes("hr")?<>:null}
{pendingOnly&&!loadError?<>{goalLevelName(item.level)} · {item.ownerName}{item.requestedByName}{item.currentAmount?formatMoney(item.currentAmount):"—"}{item.requestedAmount?formatMoney(item.requestedAmount):"由直属上级填写"}{item.newAmount?formatMoney(item.newAmount):"尚未生成"}{differenceText(item.amountDifference)}{item.reason}{item.outcomeComment?处理意见:{item.outcomeComment}:null}{workflowStatusName(item.status)}
{item.canHandle?<>:null}{item.canWithdraw?:null}
)} headers={["目标","申请人","当前生效","建议金额","候选版本","版本差异","原因 / 意见","状态","操作"]}/>{goalLevelName(item.parentLevel)}{item.childOwnerName}{formatMoney(item.childAmount)}{formatMoney(item.parentAmount)}{workflowStatusName(item.status)}
{item.canDecide?:null}
)} headers={["本级目标","变化的下级责任人","下级新目标","本级生效目标","状态","操作"]}/>:null}{showCreate?setShowCreate(false)} onSaved={async()=>{setShowCreate(false);try{await load();setMessage("目标已下达,等待责任人确认。");}catch{setMessage("目标已下达,但列表刷新失败,请手动刷新页面。");}}}/>:null}{goalDialog?setGoalDialog(null)} onSaved={()=>refreshed(goalDialog.kind==="request"?"修改申请已提交。":"目标状态已更新。")} onStale={()=>refreshed("状态已变化,已重新读取权威状态。")} onUncertain={()=>refreshed("操作结果不确定,已重新读取权威状态。")}/>:null}{workflowDialog?setWorkflowDialog(null)} onSaved={()=>refreshed("目标待办已处理。")} onStale={()=>refreshed("状态已变化,已重新读取权威状态。")} onUncertain={()=>refreshed("操作结果不确定,已重新读取权威状态。")}/>:null}{historyGoal?setHistoryGoal(null)}/>:null}{formalGoal?setFormalGoal(null)}/>:null}
; + return

{pendingOnly?"审批中心":"目标管理"}

{pendingOnly?"处理目标审批、修改申请和逐级联动选择":"按组织责任逐级下达,实名确认和审批均绑定具体版本"}

{user.capabilities.exportGoals?:null}{canCreate&&!pendingOnly?:null}
{message?

{message}

:null}{loadError?

{loadError}

:null}

{pendingOnly?"待确认与待审批目标":"目标责任台账"}

{loading?"正在读取…":loadError?"读取失败":`${goals.length} 条记录`}
{!loading&&!loadError&&goals.length===0?:goalPagination.items.map((goal)=>)}
月份层级 / 范围责任人目标直接下级分配差额状态操作
{pendingOnly?"当前没有待确认或待审批目标。":"暂无目标;有下达权限的负责人可创建第一条目标。"}
{goal.periodMonth}{goalLevelName(goal.level)}{goal.orgUnitName?{goal.orgUnitName}:null}{goal.ownerName}{formatMoney(goal.amount)}{goal.effectiveAmount!==null&&goal.effectiveAmount!==goal.amount?当前生效 {formatMoney(goal.effectiveAmount)}:null}{formatMoney(goal.allocatedAmount)}{allocationText(goal)}{goalStatusName(goal.status)}
{goal.status==="active"?:null}{goal.ownerPersonId===user.personId&&goal.status==="pending_signature"?:null}{goal.ownerPersonId===user.personId&&goal.level!=="sales_manager"&&goal.status==="active"?:null}{goal.status==="pending_gm"&&user.roles.includes("general_manager")?<>:null}{goal.status==="pending_hr"&&user.roles.includes("hr")?<>:null}
{pendingOnly&&!loadError?<>{goalLevelName(item.level)} · {item.ownerName}{item.requestedByName}{item.currentAmount?formatMoney(item.currentAmount):"—"}{item.requestedAmount?formatMoney(item.requestedAmount):"由直属上级填写"}{item.newAmount?formatMoney(item.newAmount):"尚未生成"}{differenceText(item.amountDifference)}{item.reason}{item.outcomeComment?处理意见:{item.outcomeComment}:null}{workflowStatusName(item.status)}
{item.canHandle?<>:null}{item.canWithdraw?:null}
)} headers={["目标","申请人","当前生效","建议金额","候选版本","版本差异","原因 / 意见","状态","操作"]}/>{goalLevelName(item.parentLevel)}{item.childOwnerName}{formatMoney(item.childAmount)}{formatMoney(item.parentAmount)}{workflowStatusName(item.status)}
{item.canDecide?:null}
)} headers={["本级目标","变化的下级责任人","下级新目标","本级生效目标","状态","操作"]}/>:null}{showCreate?setShowCreate(false)} onSaved={async()=>{setShowCreate(false);try{await load();setMessage("目标已下达,等待责任人确认。");}catch{setMessage("目标已下达,但列表刷新失败,请手动刷新页面。");}}}/>:null}{goalDialog?setGoalDialog(null)} onSaved={()=>refreshed(goalDialog.kind==="request"?"修改申请已提交。":"目标状态已更新。")} onStale={()=>refreshed("状态已变化,已重新读取权威状态。")} onUncertain={()=>refreshed("操作结果不确定,已重新读取权威状态。")}/>:null}{workflowDialog?setWorkflowDialog(null)} onSaved={()=>refreshed("目标待办已处理。")} onStale={()=>refreshed("状态已变化,已重新读取权威状态。")} onUncertain={()=>refreshed("操作结果不确定,已重新读取权威状态。")}/>:null}{historyGoal?setHistoryGoal(null)}/>:null}{formalGoal?setFormalGoal(null)}/>:null}
; } -function WorkflowTable({title,empty,headers,rows}:{title:string;empty:string;headers:string[];rows:ReactNode[]}){return

{title}

{rows.length} 条记录
{headers.map((header)=>)}{rows.length?rows:}
{header}
{empty}
} +function WorkflowTable({title,empty,headers,rows}:{title:string;empty:string;headers:string[];rows:ReactNode[]}){return

{title}

{rows.length} 条记录
{(pageRows)=>
{headers.map((header)=>)}{pageRows.length?pageRows:}
{header}
{empty}
}
} function GoalActionDialog({dialog,onClose,onSaved,onStale,onUncertain}:{dialog:GoalDialog;onClose:()=>void;onSaved:()=>Promise;onStale:()=>Promise;onUncertain:()=>Promise}){ const[reason,setReason]=useState("");const[requestedAmount,setRequestedAmount]=useState("");const[comment,setComment]=useState("");const[error,setError]=useState("");const[saving,setSaving]=useState(false); @@ -42,7 +43,16 @@ function GoalActionDialog({dialog,onClose,onSaved,onStale,onUncertain}:{dialog:G } function WorkflowActionDialog({dialog,onClose,onSaved,onStale,onUncertain}:{dialog:WorkflowDialog;onClose:()=>void;onSaved:()=>Promise;onStale:()=>Promise;onUncertain:()=>Promise}){const isLinkage=dialog.kind==="linkage";const[decision,setDecision]=useState<"keep_parent"|"adjust_parent">("keep_parent");const[newAmount,setNewAmount]=useState(!isLinkage&&dialog.kind==="accept"?(dialog.request.requestedAmount??""):"");const[reason,setReason]=useState("");const[error,setError]=useState("");const[saving,setSaving]=useState(false);async function submit(event:FormEvent){event.preventDefault();if(saving)return;setSaving(true);setError("");let url:string;let body:Record;if(isLinkage){url=`/api/goal-linkage-decisions/${dialog.linkage.id}/decide`;body={decision,reason,...(decision==="adjust_parent"&&dialog.linkage.parentLevel==="sales_manager"?{newAmount:Number(newAmount)}:{})};}else{url=`/api/goal-change-requests/${dialog.request.id}/${dialog.kind}`;body=dialog.kind==="accept"?{newAmount:Number(newAmount),comment:reason}:{comment:reason};}try{const response=await apiFetch(url,{method:"POST",headers:{"content-type":"application/json"},body:JSON.stringify(body)});const data=await readResponseJson<{message?:string}>(response,"服务响应无效,请重试。");if(!response.ok){if(response.status===403||response.status===409){await onStale();return;}setError(data.message??"待办处理失败");return;}await onSaved();}catch{await onUncertain();}finally{setSaving(false);}}const title=isLinkage?"处理目标联动":dialog.kind==="accept"?"接受修改申请":"拒绝修改申请";return
{isLinkage?<>{decision==="adjust_parent"&&dialog.linkage.parentLevel==="sales_manager"?:null}:dialog.kind==="accept"?:null}