From b70ada06a811fd3fa7539b6669b388cddd5664a5 Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 10 Aug 2026 10:06:12 +0800 Subject: [PATCH] =?UTF-8?q?feat(M08-C):=20=E5=BB=BA=E7=AB=8B=E7=AE=A1?= =?UTF-8?q?=E7=90=86=E5=91=98=E6=88=BF=E6=80=81=E8=BF=90=E8=90=A5=E5=85=A5?= =?UTF-8?q?=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/src/auth/auth-repository.ts | 2 + backend/src/auth/rbac-repository.ts | 2 + backend/src/db/migration-runner.ts | 8 +- backend/src/orders/pricing-repository.ts | 2 +- backend/src/routes/store-room-management.ts | 41 +++- backend/src/stores/store-room-repository.ts | 48 ++++- backend/tests/migration-contract.test.mjs | 15 +- backend/tests/migration-runner.test.mjs | 7 +- backend/tests/pricing.test.mjs | 9 + backend/tests/rbac.test.mjs | 2 + backend/tests/store-room.test.mjs | 40 +++- ...2026062524_m08a_recharge_wechat.verify.sql | 4 +- ...1001_m08c_staff_management_access.down.sql | 9 + ...081001_m08c_staff_management_access.up.sql | 11 + ...01_m08c_staff_management_access.verify.sql | 19 ++ miniapp/app.json | 1 + miniapp/pages/index/index.js | 31 +++ miniapp/pages/index/index.wxml | 2 + miniapp/pages/manager/dashboard.js | 191 ++++++++++++++++++ miniapp/pages/manager/dashboard.json | 4 + miniapp/pages/manager/dashboard.wxml | 64 ++++++ miniapp/pages/manager/dashboard.wxss | 134 ++++++++++++ scripts/check-miniapp-m08-c.mjs | 67 ++++++ scripts/dev/windows/test-all.ps1 | 1 + 24 files changed, 699 insertions(+), 15 deletions(-) create mode 100644 database/migrations/2026081001_m08c_staff_management_access.down.sql create mode 100644 database/migrations/2026081001_m08c_staff_management_access.up.sql create mode 100644 database/migrations/2026081001_m08c_staff_management_access.verify.sql create mode 100644 miniapp/pages/manager/dashboard.js create mode 100644 miniapp/pages/manager/dashboard.json create mode 100644 miniapp/pages/manager/dashboard.wxml create mode 100644 miniapp/pages/manager/dashboard.wxss create mode 100644 scripts/check-miniapp-m08-c.mjs diff --git a/backend/src/auth/auth-repository.ts b/backend/src/auth/auth-repository.ts index 84c7c89..f061a24 100644 --- a/backend/src/auth/auth-repository.ts +++ b/backend/src/auth/auth-repository.ts @@ -140,6 +140,8 @@ export class AuthRepository { SELECT ?, r.id, p.id FROM qipai_roles r INNER JOIN qipai_permissions p ON (r.code = 'CUSTOMER' AND p.code IN ('profile.read', 'order.self.read')) + OR (r.code = 'STAFF' + AND p.code IN ('profile.read', 'store.operation.read')) OR (r.code = 'STORE_ADMIN' AND p.code IN ('user.read', 'staff.manage', 'session.reset', 'store.operation.read', 'store.operation.write', diff --git a/backend/src/auth/rbac-repository.ts b/backend/src/auth/rbac-repository.ts index 1665aba..9dd22c5 100644 --- a/backend/src/auth/rbac-repository.ts +++ b/backend/src/auth/rbac-repository.ts @@ -36,6 +36,8 @@ export class RbacRepository { SELECT ?, r.id, p.id FROM qipai_roles r INNER JOIN qipai_permissions p ON (r.code = 'CUSTOMER' AND p.code IN ('profile.read', 'order.self.read')) + OR (r.code = 'STAFF' + AND p.code IN ('profile.read', 'store.operation.read')) OR (r.code = 'CLEANER' AND p.code IN ('profile.read', 'cleaning.task.read', 'cleaning.task.write', 'cleaning.statistics.read')) diff --git a/backend/src/db/migration-runner.ts b/backend/src/db/migration-runner.ts index 3e8d97f..d3937ad 100644 --- a/backend/src/db/migration-runner.ts +++ b/backend/src/db/migration-runner.ts @@ -49,7 +49,8 @@ const migrationFiles: Record = { 'database/migrations/2026062626_m08b_cleaning_settlements.up.sql', 'database/migrations/2026062627_m08b_cleaning_collaboration.up.sql', 'database/migrations/2026062728_m08b_cleaning_payouts.up.sql', - 'database/migrations/2026062729_m08b_cleaning_transfer_state.up.sql' + 'database/migrations/2026062729_m08b_cleaning_transfer_state.up.sql', + 'database/migrations/2026081001_m08c_staff_management_access.up.sql' ], verify: [ 'database/migrations/2026061601_m01b_core_schema.verify.sql', @@ -80,9 +81,11 @@ const migrationFiles: Record = { 'database/migrations/2026062626_m08b_cleaning_settlements.verify.sql', 'database/migrations/2026062627_m08b_cleaning_collaboration.verify.sql', 'database/migrations/2026062728_m08b_cleaning_payouts.verify.sql', - 'database/migrations/2026062729_m08b_cleaning_transfer_state.verify.sql' + 'database/migrations/2026062729_m08b_cleaning_transfer_state.verify.sql', + 'database/migrations/2026081001_m08c_staff_management_access.verify.sql' ], down: [ + 'database/migrations/2026081001_m08c_staff_management_access.down.sql', 'database/migrations/2026062729_m08b_cleaning_transfer_state.down.sql', 'database/migrations/2026062728_m08b_cleaning_payouts.down.sql', 'database/migrations/2026062627_m08b_cleaning_collaboration.down.sql', @@ -251,6 +254,7 @@ export async function executeMigrationPlan( 1, 1, 1, 4, 7, 1, 1, 1, 7, 7, 1, 5, 10, 7, 3, 1, + 5, 2, 2, 8, 4, 3, 1, 2, 9, 5, 2, 1, 1, 7, 5, 1, diff --git a/backend/src/orders/pricing-repository.ts b/backend/src/orders/pricing-repository.ts index f2b437c..3a2b6f8 100644 --- a/backend/src/orders/pricing-repository.ts +++ b/backend/src/orders/pricing-repository.ts @@ -304,7 +304,7 @@ export class PricingRepository { r.deposit_cents AS depositCents, r.minimum_minutes AS minimumMinutes, r.max_advance_days AS maxAdvanceDays, - r.room_category_id AS roomCategoryId + r.category_id AS roomCategoryId FROM qipai_rooms r INNER JOIN qipai_stores s ON s.id = r.store_id AND s.tenant_id = r.tenant_id AND s.deleted_at IS NULL diff --git a/backend/src/routes/store-room-management.ts b/backend/src/routes/store-room-management.ts index 33a040a..e13f027 100644 --- a/backend/src/routes/store-room-management.ts +++ b/backend/src/routes/store-room-management.ts @@ -64,11 +64,21 @@ const disabledPeriodSchema = z.object({ endsAt: z.coerce.date(), reason: z.string().trim().max(255).default('') }).refine((value) => value.endsAt > value.startsAt); +const roomStatusSchema = z.object({ + storeId: z.string().regex(/^[1-9]\d{0,19}$/), + configurationStatus: z.enum(['ENABLED', 'DISABLED']).optional(), + operationalStatus: z.enum([ + 'AVAILABLE', 'MAINTENANCE', 'RESERVED', 'IN_USE', 'CLEANING_REQUIRED' + ]).optional(), + reason: z.string().trim().min(1).max(255) +}).strict().refine((value) => value.configurationStatus || value.operationalStatus, { + message: 'at least one room status is required' +}); export interface StoreRoomRouteOptions { repository: Pick; + | 'createRoom' | 'updateRoom' | 'updateRoomStatus' | 'archiveRoom' | 'addDisabledPeriod'>; authRepository: Pick; accessControl: { getAccessProfile(tenantId: string, userId: string): Promise }; jwtSecret: string; @@ -80,6 +90,11 @@ export async function registerStoreRoomRoutes(app: FastifyInstance, options: Sto if (!actor) return; return { code: 0, data: await options.repository.listStores(actor), traceId: request.traceId }; }); + app.get('/app-api/management/stores', async (request, reply) => { + const actor = await requireOperator(request, reply, options); + if (!actor) return; + return { code: 0, data: await options.repository.listStores(actor), traceId: request.traceId }; + }); app.post('/admin-api/stores', async (request, reply) => { const actor = await requireOperator(request, reply, options); const body = storeSchema.safeParse(request.body); @@ -119,6 +134,15 @@ export async function registerStoreRoomRoutes(app: FastifyInstance, options: Sto traceId: request.traceId })); }); + app.get('/app-api/management/stores/:storeId/rooms', async (request, reply) => { + const actor = await requireOperator(request, reply, options); + const params = storeIdSchema.safeParse(request.params); + if (!actor || !params.success) return actor ? invalid(reply, request.traceId) : undefined; + return mutate(reply, request.traceId, async () => ({ + code: 0, data: await options.repository.listRooms(actor, params.data.storeId), + traceId: request.traceId + })); + }); app.post('/admin-api/rooms', async (request, reply) => { const actor = await requireOperator(request, reply, options); const body = roomSchema.safeParse(request.body); @@ -140,6 +164,21 @@ export async function registerStoreRoomRoutes(app: FastifyInstance, options: Sto traceId: request.traceId })); }); + for (const path of ['/admin-api/rooms/:id/status', '/app-api/management/rooms/:id/status']) { + app.patch(path, async (request, reply) => { + const actor = await requireOperator(request, reply, options); + const params = idSchema.safeParse(request.params); + const body = roomStatusSchema.safeParse(request.body); + if (!actor || !params.success || !body.success) { + return actor ? invalid(reply, request.traceId) : undefined; + } + return mutate(reply, request.traceId, async () => ({ + code: 0, + data: await options.repository.updateRoomStatus(actor, params.data.id, body.data), + traceId: request.traceId + })); + }); + } app.delete('/admin-api/rooms/:id', async (request, reply) => { const actor = await requireOperator(request, reply, options); const params = idSchema.safeParse(request.params); diff --git a/backend/src/stores/store-room-repository.ts b/backend/src/stores/store-room-repository.ts index 8888ea3..c669ba5 100644 --- a/backend/src/stores/store-room-repository.ts +++ b/backend/src/stores/store-room-repository.ts @@ -47,6 +47,13 @@ export interface RoomInput { sortOrder: number; } +export interface RoomStatusInput { + storeId: string; + configurationStatus?: 'ENABLED' | 'DISABLED'; + operationalStatus?: 'AVAILABLE' | 'MAINTENANCE' | 'RESERVED' | 'IN_USE' | 'CLEANING_REQUIRED'; + reason: string; +} + interface IdRow extends RowDataPacket { id: string } interface CountRow extends RowDataPacket { total: number } interface StoreRow extends RowDataPacket { @@ -152,7 +159,7 @@ export class StoreRoomRepository { } async listRooms(actor: ManagementActor, storeId: string) { - this.assertStoreScope(actor, storeId); + this.assertStoreScope(actor, storeId, false); const [rows] = await this.pool.execute( `SELECT r.id, r.store_id AS storeId, COALESCE(c.name, '') AS categoryName, r.name, r.room_no AS roomNo, r.capacity, @@ -226,6 +233,33 @@ export class StoreRoomRepository { }); } + async updateRoomStatus(actor: ManagementActor, roomId: string, input: RoomStatusInput) { + this.assertStoreScope(actor, input.storeId, true); + return this.transaction(async (connection) => { + await this.lockRoom(connection, actor, roomId, input.storeId); + await connection.execute( + `UPDATE qipai_rooms + SET configuration_status = COALESCE(?, configuration_status), + operational_status = COALESCE(?, operational_status), + status = CASE + WHEN COALESCE(?, configuration_status) = 'DISABLED' THEN 'DISABLED' + ELSE COALESCE(?, operational_status) + END + WHERE tenant_id = ? AND store_id = ? AND id = ?`, + [input.configurationStatus ?? null, input.operationalStatus ?? null, + input.configurationStatus ?? null, input.operationalStatus ?? null, + actor.tenantId, input.storeId, roomId] + ); + await this.audit(connection, actor, 'ROOM_STATUS_UPDATED', 'ROOM', roomId, { + storeId: input.storeId, + configurationStatus: input.configurationStatus ?? null, + operationalStatus: input.operationalStatus ?? null, + reason: input.reason + }); + return { roomId, ...input }; + }); + } + async archiveRoom(actor: ManagementActor, roomId: string, storeId: string) { this.assertStoreScope(actor, storeId); return this.transaction(async (connection) => { @@ -265,10 +299,11 @@ export class StoreRoomRepository { } } - private assertStoreScope(actor: ManagementActor, storeId: string) { + private assertStoreScope(actor: ManagementActor, storeId: string, write = true) { if (actor.access.capabilities.includes('tenant.manage') || actor.access.roles.includes('PLATFORM_ADMIN')) return; - if (!actor.access.capabilities.includes('store.operation.write') + const capability = write ? 'store.operation.write' : 'store.operation.read'; + if (!actor.access.capabilities.includes(capability) || !actor.access.storeIds.includes(storeId)) { throw new StoreRoomError('STORE_SCOPE_FORBIDDEN'); } @@ -343,15 +378,16 @@ export class StoreRoomRepository { private async audit( connection: PoolConnection, actor: ManagementActor, - action: string, resourceType: string, resourceId: string + action: string, resourceType: string, resourceId: string, + metadata: Record = {} ) { await connection.execute( `INSERT INTO qipai_audit_logs (tenant_id, actor_type, actor_id, action, resource_type, resource_id, trace_id, ip, user_agent, metadata) - VALUES (?, 'USER', ?, ?, ?, ?, ?, ?, ?, JSON_OBJECT())`, + VALUES (?, 'USER', ?, ?, ?, ?, ?, ?, ?, ?)`, [actor.tenantId, actor.userId, action, resourceType, resourceId, - actor.traceId, actor.ip, actor.userAgent.slice(0, 255)] + actor.traceId, actor.ip, actor.userAgent.slice(0, 255), JSON.stringify(metadata)] ); } diff --git a/backend/tests/migration-contract.test.mjs b/backend/tests/migration-contract.test.mjs index 0a687d1..07b4d29 100644 --- a/backend/tests/migration-contract.test.mjs +++ b/backend/tests/migration-contract.test.mjs @@ -96,6 +96,9 @@ const cleaningPayoutVerifySql = read('database/migrations/2026062728_m08b_cleani const cleaningTransferStateUpSql = read('database/migrations/2026062729_m08b_cleaning_transfer_state.up.sql'); const cleaningTransferStateDownSql = read('database/migrations/2026062729_m08b_cleaning_transfer_state.down.sql'); const cleaningTransferStateVerifySql = read('database/migrations/2026062729_m08b_cleaning_transfer_state.verify.sql'); +const staffManagementUpSql = read('database/migrations/2026081001_m08c_staff_management_access.up.sql'); +const staffManagementDownSql = read('database/migrations/2026081001_m08c_staff_management_access.down.sql'); +const staffManagementVerifySql = read('database/migrations/2026081001_m08c_staff_management_access.verify.sql'); const coreTables = [ 'qipai_schema_migrations', @@ -392,6 +395,9 @@ assert.match(rechargeWechatUpSql, /UNIQUE KEY uq_qipai_recharge_provider_callbac assert.match(rechargeWechatDownSql, /DROP COLUMN provider_payment_id/); assert.match(rechargeWechatVerifySql, /'provider_payment_id'/); assert.match(rechargeWechatVerifySql, /'uq_qipai_recharge_provider_callback'/); +assert.match(rechargeWechatVerifySql, /SELECT column_name/); +assert.match(rechargeWechatVerifySql, /SELECT index_name/); +assert.doesNotMatch(rechargeWechatVerifySql, /COUNT\(\*\) AS expected_/); for (const table of ['qipai_cleaning_tasks', 'qipai_cleaning_task_events']) { assert.match(cleaningUpSql, new RegExp(`CREATE TABLE IF NOT EXISTS ${table}`)); @@ -450,4 +456,11 @@ assert.match(cleaningTransferStateDownSql, /DROP COLUMN payout_package_info/); assert.match(cleaningTransferStateVerifySql, /'payout_state'/); assert.match(cleaningTransferStateVerifySql, /'2026062729'/); -console.log('PASS: M01-B through M08-B migration contracts are present.'); +assert.match(staffManagementUpSql, /r\.code = 'STAFF'/); +assert.match(staffManagementUpSql, /'profile\.read', 'store\.operation\.read'/); +assert.match(staffManagementUpSql, /'2026081001'/); +assert.match(staffManagementDownSql, /DELETE rp FROM qipai_role_permissions/); +assert.match(staffManagementVerifySql, /fully_granted_staff_roles/); +assert.match(staffManagementVerifySql, /HAVING COUNT\(DISTINCT p\.code\) = 2/); + +console.log('PASS: M01-B through M08-C migration contracts are present.'); diff --git a/backend/tests/migration-runner.test.mjs b/backend/tests/migration-runner.test.mjs index 18c8c20..fd8afe0 100644 --- a/backend/tests/migration-runner.test.mjs +++ b/backend/tests/migration-runner.test.mjs @@ -39,10 +39,15 @@ assert.match(plan.file, /2026062525_m08b_cleaner_tasks\.up\.sql/); assert.match(plan.file, /2026062626_m08b_cleaning_settlements\.up\.sql/); assert.match(plan.file, /2026062627_m08b_cleaning_collaboration\.up\.sql/); assert.match(plan.file, /2026062728_m08b_cleaning_payouts\.up\.sql/); -assert.match(plan.file, /2026062729_m08b_cleaning_transfer_state\.up\.sql$/); +assert.match(plan.file, /2026062729_m08b_cleaning_transfer_state\.up\.sql/); +assert.match(plan.file, /2026081001_m08c_staff_management_access\.up\.sql$/); assert.match(plan.checksum, /^[a-f0-9]{64}$/); assert.ok(plan.statements.length >= 11); +const verifyPlan = await loadMigrationPlan('verify'); +assert.match(verifyPlan.statements[90], /^SELECT column_name/); +assert.match(verifyPlan.statements[91], /^SELECT index_name/); + const calls = []; const fakePool = { async query(sql) { diff --git a/backend/tests/pricing.test.mjs b/backend/tests/pricing.test.mjs index 2f7dafd..0bc0b12 100644 --- a/backend/tests/pricing.test.mjs +++ b/backend/tests/pricing.test.mjs @@ -1,8 +1,17 @@ import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; import { buildApp } from '../dist/app.js'; import { signAccessToken } from '../dist/auth/jwt.js'; const secret = 'test-only-pricing-jwt-secret-with-32-characters'; +const pricingSource = readFileSync( + join(dirname(dirname(fileURLToPath(import.meta.url))), 'src/orders/pricing-repository.ts'), + 'utf8' +); +assert.match(pricingSource, /r\.category_id AS roomCategoryId/); +assert.doesNotMatch(pricingSource, /r\.room_category_id AS roomCategoryId/); const token = signAccessToken({ sub: '21', sid: '5c4d3af8-c63c-4edb-bf95-b84127bb3f6e', diff --git a/backend/tests/rbac.test.mjs b/backend/tests/rbac.test.mjs index 414cd36..ed9ad1c 100644 --- a/backend/tests/rbac.test.mjs +++ b/backend/tests/rbac.test.mjs @@ -27,5 +27,7 @@ assert.equal(await repository.grantStore({ }), true); assert.match(calls.at(-1)[0], /s\.tenant_id = \?/); assert.match(calls.at(-1)[0], /u\.tenant_id = \?/); +await repository.ensureCustomerRole('7', '21'); +assert.ok(calls.some(([sql]) => sql.includes("r.code = 'STAFF'") && sql.includes('store.operation.read'))); console.log('PASS: M02-C roles, capabilities and tenant-scoped store grants are present.'); diff --git a/backend/tests/store-room.test.mjs b/backend/tests/store-room.test.mjs index 99a4293..c8055f7 100644 --- a/backend/tests/store-room.test.mjs +++ b/backend/tests/store-room.test.mjs @@ -23,6 +23,10 @@ const repository = new StoreRoomRepository({ } }); assert.equal((await repository.listStores(storeActor))[0].id, '11'); +assert.deepEqual(await repository.listRooms({ + ...storeActor, + access: { roles: ['STAFF'], capabilities: ['store.operation.read'], storeIds: ['11'] } +}, '11'), []); await assert.rejects( () => repository.listRooms({ ...storeActor, access: { ...storeActor.access, storeIds: ['12'] } }, '11'), (error) => error instanceof StoreRoomError && error.code === 'STORE_SCOPE_FORBIDDEN' @@ -34,6 +38,8 @@ const token = signAccessToken({ tid: '7', aid: '9', rv: 1 }, secret, 900); let roomInput; +let roomStatusInput; +let currentAccess = tenantActor.access; const app = await buildApp({ storeRoom: { jwtSecret: secret, @@ -49,7 +55,7 @@ const app = await buildApp({ }; } }, - accessControl: { async getAccessProfile() { return tenantActor.access; } }, + accessControl: { async getAccessProfile() { return currentAccess; } }, repository: { async listStores() { return []; }, async createStore() { return { storeId: '11' }; }, @@ -58,6 +64,14 @@ const app = await buildApp({ async listRooms() { return []; }, async createRoom(_actor, input) { roomInput = input; return { roomId: '31' }; }, async updateRoom() { return { roomId: '31' }; }, + async updateRoomStatus(actor, _roomId, input) { + if (!actor.access.capabilities.includes('store.operation.write') + && !actor.access.capabilities.includes('tenant.manage')) { + throw new StoreRoomError('STORE_SCOPE_FORBIDDEN'); + } + roomStatusInput = input; + return { roomId: '31', ...input }; + }, async archiveRoom() { return { roomId: '31', archived: true }; }, async addDisabledPeriod() { return { disabledPeriodId: '41' }; } } @@ -78,6 +92,30 @@ const created = await app.inject({ assert.equal(created.statusCode, 201); assert.equal(created.json().data.roomId, '31'); assert.equal(roomInput.weekdayPriceCents, 2800); +const managementStores = await app.inject({ + method: 'GET', url: '/app-api/management/stores', + headers: { authorization: `Bearer ${token}` } +}); +assert.equal(managementStores.statusCode, 200); +const statusUpdated = await app.inject({ + method: 'PATCH', url: '/app-api/management/rooms/31/status', + headers: { authorization: `Bearer ${token}` }, + payload: { storeId: '11', operationalStatus: 'MAINTENANCE', reason: '现场维护' } +}); +assert.equal(statusUpdated.statusCode, 200); +assert.equal(roomStatusInput.operationalStatus, 'MAINTENANCE'); +currentAccess = { roles: ['STAFF'], capabilities: ['store.operation.read'], storeIds: ['11'] }; +const staffRooms = await app.inject({ + method: 'GET', url: '/app-api/management/stores/11/rooms', + headers: { authorization: `Bearer ${token}` } +}); +assert.equal(staffRooms.statusCode, 200); +const staffWrite = await app.inject({ + method: 'PATCH', url: '/app-api/management/rooms/31/status', + headers: { authorization: `Bearer ${token}` }, + payload: { storeId: '11', operationalStatus: 'AVAILABLE', reason: '越权尝试' } +}); +assert.equal(staffWrite.statusCode, 403); const invalid = await app.inject({ method: 'POST', url: '/admin-api/rooms/31/disabled-periods', headers: { authorization: `Bearer ${token}` }, diff --git a/database/migrations/2026062524_m08a_recharge_wechat.verify.sql b/database/migrations/2026062524_m08a_recharge_wechat.verify.sql index 6d1ef37..d14027a 100644 --- a/database/migrations/2026062524_m08a_recharge_wechat.verify.sql +++ b/database/migrations/2026062524_m08a_recharge_wechat.verify.sql @@ -1,4 +1,4 @@ -SELECT COUNT(*) AS expected_columns +SELECT column_name FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'qipai_recharge_orders' @@ -10,7 +10,7 @@ WHERE table_schema = DATABASE() 'raw_notify' ); -SELECT COUNT(*) AS expected_indexes +SELECT index_name FROM information_schema.statistics WHERE table_schema = DATABASE() AND table_name = 'qipai_recharge_orders' diff --git a/database/migrations/2026081001_m08c_staff_management_access.down.sql b/database/migrations/2026081001_m08c_staff_management_access.down.sql new file mode 100644 index 0000000..a35128f --- /dev/null +++ b/database/migrations/2026081001_m08c_staff_management_access.down.sql @@ -0,0 +1,9 @@ +DELETE rp FROM qipai_role_permissions rp +INNER JOIN qipai_roles r + ON r.tenant_id = rp.tenant_id AND r.id = rp.role_id +INNER JOIN qipai_permissions p ON p.id = rp.permission_id +WHERE r.code = 'STAFF' + AND p.code IN ('profile.read', 'store.operation.read'); + +DELETE FROM qipai_schema_migrations +WHERE version = '2026081001'; diff --git a/database/migrations/2026081001_m08c_staff_management_access.up.sql b/database/migrations/2026081001_m08c_staff_management_access.up.sql new file mode 100644 index 0000000..714f47e --- /dev/null +++ b/database/migrations/2026081001_m08c_staff_management_access.up.sql @@ -0,0 +1,11 @@ +INSERT IGNORE INTO qipai_role_permissions (tenant_id, role_id, permission_id) +SELECT r.tenant_id, r.id, p.id +FROM qipai_roles r +INNER JOIN qipai_permissions p + ON p.code IN ('profile.read', 'store.operation.read') +WHERE r.code = 'STAFF' + AND r.status = 'ACTIVE' + AND r.deleted_at IS NULL; + +INSERT IGNORE INTO qipai_schema_migrations (version, name) +VALUES ('2026081001', 'm08c_staff_management_access'); diff --git a/database/migrations/2026081001_m08c_staff_management_access.verify.sql b/database/migrations/2026081001_m08c_staff_management_access.verify.sql new file mode 100644 index 0000000..f316a83 --- /dev/null +++ b/database/migrations/2026081001_m08c_staff_management_access.verify.sql @@ -0,0 +1,19 @@ +SELECT COUNT(*) AS staff_role_count +FROM qipai_roles r +WHERE r.code = 'STAFF' AND r.status = 'ACTIVE' AND r.deleted_at IS NULL +HAVING COUNT(*) = ( + SELECT COUNT(*) + FROM ( + SELECT granted_role.tenant_id, granted_role.id + FROM qipai_roles granted_role + INNER JOIN qipai_role_permissions rp + ON rp.tenant_id = granted_role.tenant_id AND rp.role_id = granted_role.id + INNER JOIN qipai_permissions p ON p.id = rp.permission_id + WHERE granted_role.code = 'STAFF' + AND granted_role.status = 'ACTIVE' + AND granted_role.deleted_at IS NULL + AND p.code IN ('profile.read', 'store.operation.read') + GROUP BY granted_role.tenant_id, granted_role.id + HAVING COUNT(DISTINCT p.code) = 2 + ) fully_granted_staff_roles +); diff --git a/miniapp/app.json b/miniapp/app.json index 8da7314..a226670 100644 --- a/miniapp/app.json +++ b/miniapp/app.json @@ -9,6 +9,7 @@ "pages/benefits/index", "pages/recharge/index", "pages/cleaner/tasks", + "pages/manager/dashboard", "pages/logs/logs" ], "window": { diff --git a/miniapp/pages/index/index.js b/miniapp/pages/index/index.js index 815730b..870d306 100644 --- a/miniapp/pages/index/index.js +++ b/miniapp/pages/index/index.js @@ -7,6 +7,8 @@ Page({ loading: false, errorMessage: '', stores: [], + showManagerEntry: false, + showCleanerEntry: false, }, onLoad(options) { @@ -15,6 +17,27 @@ Page({ } }, + onShow() { + this.loadRoleEntrances() + }, + + async loadRoleEntrances() { + if (!wx.getStorageSync('qipai_access_token')) { + this.setData({ showManagerEntry: false, showCleanerEntry: false }) + return + } + try { + const response = await request('/auth/me') + const roles = response.data?.access?.roles || [] + this.setData({ + showManagerEntry: roles.some((role) => ['STAFF', 'STORE_ADMIN', 'TENANT_ADMIN', 'PLATFORM_ADMIN'].includes(role)), + showCleanerEntry: roles.includes('CLEANER'), + }) + } catch (_) { + this.setData({ showManagerEntry: false, showCleanerEntry: false }) + } + }, + onCityInput(event) { this.setData({ city: event.detail.value }) }, @@ -73,6 +96,14 @@ Page({ wx.navigateTo({ url: '/pages/profile/index' }) }, + openManager() { + wx.navigateTo({ url: '/pages/manager/dashboard' }) + }, + + openCleaner() { + wx.navigateTo({ url: '/pages/cleaner/tasks' }) + }, + async resolveScene(code, sourceType) { this.setData({ loading: true, errorMessage: '' }) try { diff --git a/miniapp/pages/index/index.wxml b/miniapp/pages/index/index.wxml index 56c16e6..7bbabff 100644 --- a/miniapp/pages/index/index.wxml +++ b/miniapp/pages/index/index.wxml @@ -4,6 +4,8 @@ + + diff --git a/miniapp/pages/manager/dashboard.js b/miniapp/pages/manager/dashboard.js new file mode 100644 index 0000000..5d27527 --- /dev/null +++ b/miniapp/pages/manager/dashboard.js @@ -0,0 +1,191 @@ +const { request, ensureLogin, cents } = require('../../utils/api.js') + +const managerRoles = ['STAFF', 'STORE_ADMIN', 'TENANT_ADMIN', 'PLATFORM_ADMIN'] +const roomStatusLabels = { + AVAILABLE: '空闲', + MAINTENANCE: '维护中', + RESERVED: '已预订', + IN_USE: '使用中', + CLEANING_REQUIRED: '待清洁', +} +const orderStatusLabels = { + DRAFT: '草稿', + PENDING_PAYMENT: '待支付', + PAID: '已支付', + RESERVED: '待开始', + IN_PROGRESS: '进行中', + FINISHED: '已结束', + CANCELLED: '已取消', + REFUNDING: '退款中', + REFUNDED: '已退款', + CLOSED: '已关闭', +} + +Page({ + data: { + loading: false, + errorMessage: '', + canWrite: false, + stores: [], + selectedStoreId: '', + selectedStoreName: '', + rooms: [], + orders: [], + visibleOrders: [], + busyRoomId: '', + summary: { + roomTotal: 0, + available: 0, + inUse: 0, + attention: 0, + activeOrders: 0, + }, + }, + + async onLoad() { + await this.loadDashboard() + }, + + async onPullDownRefresh() { + await this.loadDashboard(this.data.selectedStoreId) + wx.stopPullDownRefresh() + }, + + async loadDashboard(preferredStoreId = '') { + this.setData({ loading: true, errorMessage: '' }) + try { + await ensureLogin() + const me = await request('/auth/me') + const access = me.data?.access || { roles: [], capabilities: [], storeIds: [] } + if (!access.roles.some((role) => managerRoles.includes(role))) { + throw new Error('当前账号没有门店运营权限') + } + const [storesResponse, ordersResponse] = await Promise.all([ + request('/management/stores'), + request('/orders?page=1&pageSize=50'), + ]) + const stores = storesResponse.data || [] + const selectedStoreId = stores.some((store) => store.id === preferredStoreId) + ? preferredStoreId + : (stores[0]?.id || '') + this.setData({ + canWrite: access.capabilities.includes('store.operation.write') + || access.capabilities.includes('tenant.manage') + || access.roles.includes('PLATFORM_ADMIN'), + stores, + orders: (ordersResponse.data?.items || []).map((item) => this.presentOrder(item)), + selectedStoreId, + selectedStoreName: stores.find((store) => store.id === selectedStoreId)?.name || '', + }) + await this.loadRooms(selectedStoreId) + } catch (error) { + this.setData({ errorMessage: error.message || '门店运营数据加载失败' }) + } finally { + this.setData({ loading: false }) + } + }, + + async selectStore(event) { + const storeId = event.currentTarget.dataset.storeId + if (!storeId || storeId === this.data.selectedStoreId) return + const store = this.data.stores.find((item) => item.id === storeId) + this.setData({ selectedStoreId: storeId, selectedStoreName: store?.name || '' }) + await this.loadRooms(storeId) + }, + + async loadRooms(storeId) { + if (!storeId) { + this.setData({ rooms: [], visibleOrders: [] }) + this.refreshSummary() + return + } + try { + const response = await request(`/management/stores/${encodeURIComponent(storeId)}/rooms`) + const rooms = (response.data || []).map((item) => ({ + ...item, + statusText: roomStatusLabels[item.operationalStatus] || item.operationalStatus, + priceText: cents(item.basePriceCents), + })) + this.setData({ + rooms, + visibleOrders: this.data.orders.filter((item) => item.storeId === storeId), + }) + this.refreshSummary() + } catch (error) { + this.setData({ errorMessage: error.message || '房态加载失败' }) + } + }, + + refreshSummary() { + const rooms = this.data.rooms + const activeStatuses = ['PAID', 'RESERVED', 'IN_PROGRESS'] + this.setData({ + summary: { + roomTotal: rooms.length, + available: rooms.filter((item) => item.configurationStatus === 'ENABLED' && item.operationalStatus === 'AVAILABLE').length, + inUse: rooms.filter((item) => ['RESERVED', 'IN_USE'].includes(item.operationalStatus)).length, + attention: rooms.filter((item) => item.configurationStatus === 'DISABLED' || ['MAINTENANCE', 'CLEANING_REQUIRED'].includes(item.operationalStatus)).length, + activeOrders: this.data.visibleOrders.filter((item) => activeStatuses.includes(item.status)).length, + }, + }) + }, + + changeRoomStatus(event) { + if (!this.data.canWrite) return + const roomId = event.currentTarget.dataset.roomId + wx.showActionSheet({ + itemList: ['设为空闲', '设为维护中', '设为待清洁'], + success: ({ tapIndex }) => { + const statuses = ['AVAILABLE', 'MAINTENANCE', 'CLEANING_REQUIRED'] + this.updateRoomStatus(roomId, { operationalStatus: statuses[tapIndex] }, '管理员小程序调整房态') + }, + }) + }, + + toggleRoomConfiguration(event) { + if (!this.data.canWrite) return + const roomId = event.currentTarget.dataset.roomId + const current = event.currentTarget.dataset.status + const configurationStatus = current === 'ENABLED' ? 'DISABLED' : 'ENABLED' + this.updateRoomStatus(roomId, { configurationStatus }, configurationStatus === 'ENABLED' ? '管理员小程序启用房间' : '管理员小程序停用房间') + }, + + async updateRoomStatus(roomId, status, reason) { + if (!roomId || this.data.busyRoomId) return + this.setData({ busyRoomId: roomId, errorMessage: '' }) + try { + await request(`/management/rooms/${encodeURIComponent(roomId)}/status`, { + method: 'PATCH', + data: { storeId: this.data.selectedStoreId, ...status, reason }, + }) + await this.loadRooms(this.data.selectedStoreId) + wx.showToast({ title: '房态已更新', icon: 'success' }) + } catch (error) { + this.setData({ errorMessage: error.message || '房态更新失败' }) + } finally { + this.setData({ busyRoomId: '' }) + } + }, + + openOrder(event) { + const orderId = event.currentTarget.dataset.orderId + if (!orderId) return + wx.navigateTo({ url: `/pages/orders/detail?orderId=${encodeURIComponent(orderId)}` }) + }, + + presentOrder(item) { + return { + ...item, + statusText: orderStatusLabels[item.status] || item.status, + amountText: cents(item.paidAmountCents || item.totalAmountCents), + timeText: `${this.formatTime(item.startAt)} - ${this.formatTime(item.endAt)}`, + } + }, + + formatTime(value) { + const date = new Date(value) + if (Number.isNaN(date.getTime())) return String(value || '') + const pad = (part) => String(part).padStart(2, '0') + return `${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}` + }, +}) diff --git a/miniapp/pages/manager/dashboard.json b/miniapp/pages/manager/dashboard.json new file mode 100644 index 0000000..c929ee8 --- /dev/null +++ b/miniapp/pages/manager/dashboard.json @@ -0,0 +1,4 @@ +{ + "navigationBarTitleText": "门店运营", + "enablePullDownRefresh": true +} diff --git a/miniapp/pages/manager/dashboard.wxml b/miniapp/pages/manager/dashboard.wxml new file mode 100644 index 0000000..9afc64d --- /dev/null +++ b/miniapp/pages/manager/dashboard.wxml @@ -0,0 +1,64 @@ + + + + + 门店运营 + {{selectedStoreName || '请选择授权门店'}} + + {{canWrite ? '可操作' : '只读'}} + + + {{errorMessage}} + 正在加载运营数据... + + + + + + + + + {{summary.roomTotal}}房间 + {{summary.available}}空闲 + {{summary.inUse}}占用 + {{summary.attention}}待处理 + {{summary.activeOrders}}活跃订单 + + + 实时房态 + 当前门店暂无房间 + + + + {{item.name}} · {{item.roomNo}} + {{item.categoryName}} / {{item.capacity}} 人 / {{item.priceText}} 起 + + {{item.configurationStatus === 'DISABLED' ? '已停用' : item.statusText}} + + + + + + + + 近期订单 + 当前门店暂无订单 + + + + {{item.roomName}} · {{item.roomNo}} + {{item.orderNo}} + + {{item.statusText}} + + {{item.timeText}}{{item.amountText}} + + + diff --git a/miniapp/pages/manager/dashboard.wxss b/miniapp/pages/manager/dashboard.wxss new file mode 100644 index 0000000..0707f24 --- /dev/null +++ b/miniapp/pages/manager/dashboard.wxss @@ -0,0 +1,134 @@ +.manager-dashboard { + padding-bottom: 48rpx; +} + +.manager-header, +.card-main, +.order-line { + align-items: center; + display: flex; + justify-content: space-between; +} + +.subtitle, +.card-meta { + color: #6b7280; + font-size: 24rpx; + margin-top: 8rpx; +} + +.permission-tag, +.status-pill { + background: #eef2ff; + border-radius: 999rpx; + color: #3730a3; + font-size: 22rpx; + padding: 8rpx 16rpx; +} + +.store-tabs { + margin: 24rpx 0; + white-space: nowrap; + width: 100%; +} + +.store-tabs-inner { + display: inline-flex; + gap: 12rpx; +} + +.store-tab { + margin: 0; +} + +.store-tab.active { + background: #2563eb; + color: #fff; +} + +.summary-grid { + display: grid; + gap: 14rpx; + grid-template-columns: repeat(3, 1fr); + margin-bottom: 28rpx; +} + +.summary-card { + background: #f8fafc; + border: 1rpx solid #e2e8f0; + border-radius: 16rpx; + display: flex; + flex-direction: column; + padding: 18rpx; +} + +.summary-card strong { + font-size: 36rpx; +} + +.summary-card text { + color: #64748b; + font-size: 22rpx; +} + +.summary-card.success strong { color: #15803d; } +.summary-card.primary strong { color: #2563eb; } +.summary-card.warning strong { color: #c2410c; } + +.section-title { + font-size: 30rpx; + font-weight: 600; + margin: 28rpx 0 16rpx; +} + +.room-card, +.order-card { + background: #fff; + border: 1rpx solid #e2e8f0; + border-radius: 18rpx; + margin-bottom: 16rpx; + padding: 22rpx; +} + +.room-card.disabled { + opacity: .64; +} + +.card-title { + font-size: 28rpx; + font-weight: 600; +} + +.card-actions { + display: flex; + gap: 12rpx; + justify-content: flex-end; + margin-top: 18rpx; +} + +.card-actions button { + margin: 0; +} + +.status-AVAILABLE { background: #dcfce7; color: #166534; } +.status-MAINTENANCE { background: #ffedd5; color: #9a3412; } +.status-RESERVED, +.status-IN_USE { background: #dbeafe; color: #1d4ed8; } +.status-CLEANING_REQUIRED { background: #fef3c7; color: #92400e; } + +.order-line { + color: #64748b; + font-size: 23rpx; + margin-top: 18rpx; +} + +.order-line strong { + color: #0f172a; +} + +.loading, +.empty { + color: #64748b; + padding: 30rpx 0; + text-align: center; +} diff --git a/scripts/check-miniapp-m08-c.mjs b/scripts/check-miniapp-m08-c.mjs new file mode 100644 index 0000000..6a4d9fe --- /dev/null +++ b/scripts/check-miniapp-m08-c.mjs @@ -0,0 +1,67 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; + +const root = new URL('..', import.meta.url).pathname.replace(/^\/([A-Za-z]:)/, '$1'); +const read = (path) => readFileSync(join(root, path), 'utf8'); + +const appJson = JSON.parse(read('miniapp/app.json')); +assert.ok(appJson.pages.includes('pages/manager/dashboard')); + +const index = read('miniapp/pages/index/index.js') + read('miniapp/pages/index/index.wxml'); +for (const pattern of [ + '/auth/me', + 'showManagerEntry', + 'STORE_ADMIN', + 'TENANT_ADMIN', + 'PLATFORM_ADMIN', + 'openManager', + 'pages/manager/dashboard', + '门店管理' +]) { + assert.match(index, new RegExp(pattern.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))); +} + +const dashboard = read('miniapp/pages/manager/dashboard.js') + + read('miniapp/pages/manager/dashboard.wxml') + + read('miniapp/pages/manager/dashboard.wxss'); +for (const pattern of [ + '/management/stores', + '/management/stores/${encodeURIComponent(storeId)}/rooms', + '/management/rooms/${encodeURIComponent(roomId)}/status', + '/orders?page=1&pageSize=50', + "method: 'PATCH'", + 'store.operation.write', + 'tenant.manage', + 'changeRoomStatus', + 'toggleRoomConfiguration', + 'activeOrders', + '实时房态', + '近期订单', + '只读' +]) { + assert.match(dashboard, new RegExp(pattern.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))); +} + +const routes = read('backend/src/routes/store-room-management.ts'); +for (const pattern of [ + '/app-api/management/stores', + '/app-api/management/stores/:storeId/rooms', + '/app-api/management/rooms/:id/status', + 'roomStatusSchema', + 'updateRoomStatus' +]) { + assert.match(routes, new RegExp(pattern.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))); +} + +const repository = read('backend/src/stores/store-room-repository.ts'); +assert.match(repository, /ROOM_STATUS_UPDATED/); +assert.match(repository, /store\.operation\.read/); +assert.match(repository, /store\.operation\.write/); +assert.match(repository, /JSON\.stringify\(metadata\)/); + +const migration = read('database/migrations/2026081001_m08c_staff_management_access.up.sql'); +assert.match(migration, /r\.code = 'STAFF'/); +assert.match(migration, /store\.operation\.read/); + +console.log('PASS: M08-C manager/staff miniapp exposes scoped stores, room status and order overview.'); diff --git a/scripts/dev/windows/test-all.ps1 b/scripts/dev/windows/test-all.ps1 index 710b7ab..071ac80 100644 --- a/scripts/dev/windows/test-all.ps1 +++ b/scripts/dev/windows/test-all.ps1 @@ -10,6 +10,7 @@ $ErrorActionPreference = "Stop" & powershell -ExecutionPolicy Bypass -File scripts/dev/windows/check-readme-config.ps1 & powershell -ExecutionPolicy Bypass -File scripts/dev/windows/check-backend.ps1 node scripts/check-admin-m08-b.mjs +node scripts/check-miniapp-m08-c.mjs if (Test-Path "admin/package.json") { Push-Location admin