diff --git a/backend/src/auth/user-management-repository.ts b/backend/src/auth/user-management-repository.ts index 9fb1bee..617c459 100644 --- a/backend/src/auth/user-management-repository.ts +++ b/backend/src/auth/user-management-repository.ts @@ -72,6 +72,7 @@ export class UserManagementRepository { status?: 'ACTIVE' | 'DISABLED'; role?: AssignableRole; search?: string; + userType?: 'STAFF'; }): Promise<{ items: ManagedUser[]; total: number }> { const filters = ['u.tenant_id = ?', 'u.deleted_at IS NULL']; const params: Array = [input.actor.tenantId]; @@ -79,6 +80,10 @@ export class UserManagementRepository { filters.push('u.status = ?'); params.push(input.status); } + if (input.userType) { + filters.push('u.user_type = ?'); + params.push(input.userType); + } if (input.role) { filters.push(`EXISTS ( SELECT 1 FROM qipai_user_roles fur @@ -102,6 +107,8 @@ export class UserManagementRepository { WHERE ${filters.join(' AND ')}`, params ); + const pageSize = Math.max(1, Math.min(100, Math.trunc(input.pageSize))); + const offset = Math.max(0, (Math.trunc(input.page) - 1) * pageSize); const [rows] = await this.pool.execute( `SELECT u.id, u.user_type AS userType, u.status, u.nickname, u.avatar_url AS avatarUrl, u.phone, @@ -119,8 +126,8 @@ export class UserManagementRepository { LEFT JOIN qipai_user_admin_profiles p ON p.tenant_id = u.tenant_id AND p.user_id = u.id WHERE ${filters.join(' AND ')} - ORDER BY u.id DESC LIMIT ? OFFSET ?`, - [...params, input.pageSize, (input.page - 1) * input.pageSize] + ORDER BY u.id DESC LIMIT ${pageSize} OFFSET ${offset}`, + params ); const items = await Promise.all(rows.map(async (row) => ({ id: String(row.id), diff --git a/backend/src/cleaning/cleaning-task-repository.ts b/backend/src/cleaning/cleaning-task-repository.ts index 7d15b6c..fed21e2 100644 --- a/backend/src/cleaning/cleaning-task-repository.ts +++ b/backend/src/cleaning/cleaning-task-repository.ts @@ -517,7 +517,8 @@ export class CleaningTaskRepository { params.push(input.cleanerUserId); } const whereSql = where.join(' AND '); - const offset = (input.page - 1) * input.pageSize; + const pageSize = Math.max(1, Math.min(100, Math.trunc(input.pageSize))); + const offset = Math.max(0, (Math.trunc(input.page) - 1) * pageSize); const [counts] = await this.pool.execute( `SELECT COUNT(*) AS total FROM qipai_cleaning_settlements s WHERE ${whereSql}`, params @@ -538,14 +539,14 @@ export class CleaningTaskRepository { LEFT JOIN qipai_stores st ON st.tenant_id = s.tenant_id AND st.id = s.store_id WHERE ${whereSql} ORDER BY s.created_at DESC, s.id DESC - LIMIT ? OFFSET ?`, - [...params, input.pageSize, offset] + LIMIT ${pageSize} OFFSET ${offset}`, + params ); return { items: rows.map(publicSettlement), total: Number(counts[0]?.total ?? 0), page: input.page, - pageSize: input.pageSize + pageSize }; } @@ -1046,7 +1047,8 @@ export class CleaningTaskRepository { orderBy: string ) { const whereSql = where.join(' AND '); - const offset = (page - 1) * pageSize; + const safePageSize = Math.max(1, Math.min(100, Math.trunc(pageSize))); + const offset = Math.max(0, (Math.trunc(page) - 1) * safePageSize); const [counts] = await this.pool.execute( `SELECT COUNT(*) AS total FROM qipai_cleaning_tasks t WHERE ${whereSql}`, params @@ -1069,10 +1071,13 @@ export class CleaningTaskRepository { LEFT JOIN qipai_orders o ON o.tenant_id = t.tenant_id AND o.id = t.order_id WHERE ${whereSql} ORDER BY ${orderBy} - LIMIT ? OFFSET ?`, - [...params, pageSize, offset] + LIMIT ${safePageSize} OFFSET ${offset}`, + params ); - return { items: rows.map(publicTask), total: Number(counts[0]?.total ?? 0), page, pageSize }; + return { + items: rows.map(publicTask), total: Number(counts[0]?.total ?? 0), + page, pageSize: safePageSize + }; } private async getMineTask(input: CleaningActor, taskId: string) { diff --git a/backend/src/orders/order-query-repository.ts b/backend/src/orders/order-query-repository.ts index d1a7202..1d55b27 100644 --- a/backend/src/orders/order-query-repository.ts +++ b/backend/src/orders/order-query-repository.ts @@ -58,7 +58,8 @@ export class OrderQueryRepository { params.push(input.storeId); } const whereSql = where.join(' AND '); - const offset = (input.page - 1) * input.pageSize; + const pageSize = Math.max(1, Math.min(50, Math.trunc(input.pageSize))); + const offset = Math.max(0, (Math.trunc(input.page) - 1) * pageSize); const [counts] = await this.pool.execute( `SELECT COUNT(DISTINCT o.id) AS total FROM qipai_orders o @@ -89,14 +90,14 @@ export class OrderQueryRepository { ) WHERE ${whereSql} ORDER BY o.created_at DESC, o.id DESC - LIMIT ? OFFSET ?`, - [...params, input.pageSize, offset] + LIMIT ${pageSize} OFFSET ${offset}`, + params ); return { items: rows.map(publicOrder), total: Number(counts[0]?.total ?? 0), page: input.page, - pageSize: input.pageSize + pageSize }; } diff --git a/backend/src/routes/members.ts b/backend/src/routes/members.ts index 48d7108..2942ce3 100644 --- a/backend/src/routes/members.ts +++ b/backend/src/routes/members.ts @@ -54,29 +54,33 @@ export async function registerMemberRoutes( })); }); - app.get('/admin-api/members', async (request, reply) => { - const actor = await requireReader(request, reply, options); - if (!actor) return; - const query = listSchema.safeParse(request.query); - if (!query.success) return invalid(reply, request.traceId); - return handle(reply, request.traceId, async () => ({ - code: 0, - data: await options.service.listMembers({ actor, ...query.data }), - traceId: request.traceId - })); - }); + for (const path of ['/admin-api/members', '/app-api/management/members']) { + app.get(path, async (request, reply) => { + const actor = await requireReader(request, reply, options); + if (!actor) return; + const query = listSchema.safeParse(request.query); + if (!query.success) return invalid(reply, request.traceId); + return handle(reply, request.traceId, async () => ({ + code: 0, + data: await options.service.listMembers({ actor, ...query.data }), + traceId: request.traceId + })); + }); + } - app.get('/admin-api/members/:id', async (request, reply) => { - const actor = await requireReader(request, reply, options); - if (!actor) return; - const params = idSchema.safeParse(request.params); - if (!params.success) return invalid(reply, request.traceId); - return handle(reply, request.traceId, async () => ({ - code: 0, - data: await options.service.getMember({ actor, memberId: params.data.id }), - traceId: request.traceId - })); - }); + for (const path of ['/admin-api/members/:id', '/app-api/management/members/:id']) { + app.get(path, async (request, reply) => { + const actor = await requireReader(request, reply, options); + if (!actor) return; + const params = idSchema.safeParse(request.params); + if (!params.success) return invalid(reply, request.traceId); + return handle(reply, request.traceId, async () => ({ + code: 0, + data: await options.service.getMember({ actor, memberId: params.data.id }), + traceId: request.traceId + })); + }); + } } async function requireProfileReader( diff --git a/backend/src/routes/user-management.ts b/backend/src/routes/user-management.ts index 30bbf59..633258c 100644 --- a/backend/src/routes/user-management.ts +++ b/backend/src/routes/user-management.ts @@ -47,14 +47,23 @@ export async function registerUserManagementRoutes( app: FastifyInstance, options: UserManagementRouteOptions ): Promise { - app.get('/admin-api/users', async (request, reply) => { - const actor = await requireManager(request, reply, options); - if (!actor) return; - const query = listSchema.safeParse(request.query); - if (!query.success) return invalid(reply, request.traceId); - const data = await options.repository.listUsers({ actor, ...query.data }); - return { code: 0, data, traceId: request.traceId }; - }); + for (const [path, staffOnly] of [ + ['/admin-api/users', false], + ['/app-api/management/staff', true] + ] as const) { + app.get(path, async (request, reply) => { + const actor = await requireManager(request, reply, options); + if (!actor) return; + const query = listSchema.safeParse(request.query); + if (!query.success) return invalid(reply, request.traceId); + const data = await options.repository.listUsers({ + actor, + ...query.data, + ...(staffOnly ? { userType: 'STAFF' as const } : {}) + }); + return { code: 0, data, traceId: request.traceId }; + }); + } app.post('/admin-api/staff', async (request, reply) => { const actor = await requireManager(request, reply, options); diff --git a/backend/src/wallets/member-profile-service.ts b/backend/src/wallets/member-profile-service.ts index e321c1f..e948529 100644 --- a/backend/src/wallets/member-profile-service.ts +++ b/backend/src/wallets/member-profile-service.ts @@ -138,13 +138,15 @@ export class MemberProfileService { WHERE ${where}`, params ); + const pageSize = Math.max(1, Math.min(100, Math.trunc(input.pageSize))); + const offset = Math.max(0, (Math.trunc(input.page) - 1) * pageSize); const [rows] = await this.pool.execute( `SELECT u.id, u.status, u.nickname, u.phone, u.created_at AS createdAt, u.last_login_at AS lastLoginAt FROM qipai_users u WHERE ${where} - ORDER BY u.id DESC LIMIT ? OFFSET ?`, - [...params, input.pageSize, (input.page - 1) * input.pageSize] + ORDER BY u.id DESC LIMIT ${pageSize} OFFSET ${offset}`, + params ); const items = await Promise.all(rows.map((row) => this.memberCard(input.actor, row))); return { items, total: Number(counts[0]?.total ?? 0) }; @@ -341,9 +343,9 @@ export class MemberProfileService { ); const [orderRows] = await this.pool.execute( `SELECT COUNT(*) AS orderCount, - COALESCE(SUM(CASE WHEN o.status IN ('PAID', 'IN_USE', 'COMPLETED') + COALESCE(SUM(CASE WHEN o.status IN ('PAID', 'RESERVED', 'IN_PROGRESS', 'FINISHED') THEN 1 ELSE 0 END), 0) AS paidOrderCount, - COALESCE(SUM(CASE WHEN o.status IN ('PAID', 'IN_USE', 'COMPLETED') + COALESCE(SUM(CASE WHEN o.status IN ('PAID', 'RESERVED', 'IN_PROGRESS', 'FINISHED') THEN o.paid_amount_cents ELSE 0 END), 0) AS paidAmountCents, MAX(o.created_at) AS lastOrderAt FROM qipai_orders o @@ -433,12 +435,19 @@ function memberScopeClause(actor: MemberActor, userExpression: string) { } const placeholders = actor.access.storeIds.map(() => '?').join(','); return { - sql: `EXISTS ( + sql: `(EXISTS ( SELECT 1 FROM qipai_wallet_accounts wa WHERE wa.tenant_id = u.tenant_id AND wa.user_id = ${userExpression} AND wa.store_id IN (${placeholders}) - )`, - params: actor.access.storeIds + ) OR EXISTS ( + SELECT 1 FROM qipai_order_user_access oa + INNER JOIN qipai_orders mo + ON mo.tenant_id = oa.tenant_id AND mo.id = oa.order_id + AND mo.deleted_at IS NULL + WHERE oa.tenant_id = u.tenant_id AND oa.user_id = ${userExpression} + AND oa.access_type = 'OWNER' AND mo.store_id IN (${placeholders}) + ))`, + params: [...actor.access.storeIds, ...actor.access.storeIds] }; } diff --git a/backend/tests/member-profile-route.test.mjs b/backend/tests/member-profile-route.test.mjs index f7314ed..fc218ac 100644 --- a/backend/tests/member-profile-route.test.mjs +++ b/backend/tests/member-profile-route.test.mjs @@ -11,9 +11,15 @@ const forbiddenToken = signAccessToken({ sub: '22', sid: '6c4d3af8-c63c-4edb-bf95-b84127bb3f6e', tid: '7', aid: '9', rv: 1 }, secret, 900); +const managerToken = signAccessToken({ + sub: '23', sid: '7c4d3af8-c63c-4edb-bf95-b84127bb3f6e', + tid: '7', aid: '9', rv: 1 +}, secret, 900); let profileInput; let benefitsInput; +let memberListInput; +let memberDetailInput; const app = await buildApp({ members: { jwtSecret: secret, @@ -25,7 +31,8 @@ const app = await buildApp({ platformAppId: '9', expiresAt: new Date(Date.now() + 60000), user: { - id: sessionId === '6c4d3af8-c63c-4edb-bf95-b84127bb3f6e' ? '22' : '21', + id: sessionId === '6c4d3af8-c63c-4edb-bf95-b84127bb3f6e' ? '22' + : sessionId === '7c4d3af8-c63c-4edb-bf95-b84127bb3f6e' ? '23' : '21', tenantId: '7', userType: 'CUSTOMER', status: 'ACTIVE', @@ -39,14 +46,28 @@ const app = await buildApp({ }, accessControl: { async getAccessProfile(tenantId, userId) { - return userId === '21' + return userId === '23' + ? { roles: ['STORE_ADMIN'], capabilities: ['user.read'], storeIds: ['11'] } + : userId === '21' ? { roles: ['CUSTOMER'], capabilities: ['profile.read', 'order.self.read'], storeIds: [] } : { roles: ['CUSTOMER'], capabilities: ['order.self.read'], storeIds: [] }; } }, service: { - async listMembers() { throw new Error('not called'); }, - async getMember() { throw new Error('not called'); }, + async listMembers(input) { + memberListInput = input; + return { + items: [{ + memberId: '31', nickname: '门店会员', maskedPhone: '138****8000', + wallet: { totalBalanceCents: 1200 }, orders: { orderCount: 2 } + }], + total: 1 + }; + }, + async getMember(input) { + memberDetailInput = input; + return { memberId: input.memberId, nickname: '门店会员', recentLedger: [] }; + }, async getMyBenefits(input) { benefitsInput = input; return { @@ -100,6 +121,33 @@ assert.equal(benefitsInput.userId, '21'); assert.equal(benefits.json().data.coupons[0].couponGrantId, '401'); assert.equal(benefits.json().data.packages[0].packageHoldingId, '501'); +const members = await app.inject({ + method: 'GET', + url: '/app-api/management/members?page=1&pageSize=20&status=ACTIVE&search=138', + headers: { authorization: `Bearer ${managerToken}` } +}); +assert.equal(members.statusCode, 200); +assert.equal(memberListInput.actor.userId, '23'); +assert.deepEqual(memberListInput.actor.access.storeIds, ['11']); +assert.equal(memberListInput.status, 'ACTIVE'); +assert.equal(memberListInput.search, '138'); +assert.equal(members.json().data.items[0].memberId, '31'); + +const memberDetail = await app.inject({ + method: 'GET', + url: '/app-api/management/members/31', + headers: { authorization: `Bearer ${managerToken}` } +}); +assert.equal(memberDetail.statusCode, 200); +assert.equal(memberDetailInput.memberId, '31'); + +const memberForbidden = await app.inject({ + method: 'GET', + url: '/app-api/management/members', + headers: { authorization: `Bearer ${token}` } +}); +assert.equal(memberForbidden.statusCode, 403); + const unauthorized = await app.inject({ method: 'GET', url: '/app-api/profile' diff --git a/backend/tests/member-profile-service.test.mjs b/backend/tests/member-profile-service.test.mjs index b2adbf0..7364212 100644 --- a/backend/tests/member-profile-service.test.mjs +++ b/backend/tests/member-profile-service.test.mjs @@ -210,7 +210,8 @@ const orders = [ id: '301', tenantId: '7', userId: '21', - status: 'COMPLETED', + storeId: '11', + status: 'FINISHED', paidAmountCents: 12_000, createdAt: new Date('2026-06-23T00:00:00.000Z') }, @@ -218,9 +219,19 @@ const orders = [ id: '302', tenantId: '7', userId: '21', + storeId: '11', status: 'CANCELLED', paidAmountCents: 0, createdAt: new Date('2026-06-24T00:00:00.000Z') + }, + { + id: '303', + tenantId: '7', + userId: '22', + storeId: '11', + status: 'CANCELLED', + paidAmountCents: 0, + createdAt: new Date('2026-06-25T00:00:00.000Z') } ]; @@ -335,7 +346,7 @@ function createService() { const userId = String(params[0]); const tenantId = String(params[1]); const rows = orders.filter((item) => item.tenantId === tenantId && item.userId === userId); - const paid = rows.filter((item) => ['PAID', 'IN_USE', 'COMPLETED'].includes(item.status)); + const paid = rows.filter((item) => ['PAID', 'RESERVED', 'IN_PROGRESS', 'FINISHED'].includes(item.status)); return [[{ orderCount: rows.length, paidOrderCount: paid.length, @@ -379,11 +390,17 @@ function queryMembers(sql, params) { if (user.tenantId !== tenantId || user.userType !== 'CUSTOMER' || user.deleted) return false; if (statusFilter && user.status !== statusFilter) return false; if (hasStoreScope) { - return wallets.some((wallet) => + const walletMatch = wallets.some((wallet) => wallet.tenantId === tenantId && wallet.userId === user.id && storeParams.includes(wallet.storeId) ); + const orderMatch = orders.some((order) => + order.tenantId === tenantId + && order.userId === user.id + && storeParams.includes(order.storeId) + ); + return walletMatch || orderMatch; } return true; }).map((user) => ({ @@ -458,12 +475,9 @@ const forbiddenReader = { page: 1, pageSize: 20 }); - assert.equal(scoped.total, 1); - assert.equal(scoped.items[0].memberId, '21'); - await assert.rejects( - () => service.getMember({ actor: storeReader, memberId: '22' }), - (error) => error instanceof MemberProfileError && error.code === 'MEMBER_NOT_FOUND' - ); + assert.equal(scoped.total, 2); + assert.equal(scoped.items[0].memberId, '22'); + assert.equal((await service.getMember({ actor: storeReader, memberId: '22' })).memberId, '22'); } { diff --git a/backend/tests/mysql-migration-roundtrip.test.mjs b/backend/tests/mysql-migration-roundtrip.test.mjs index 87bf6c5..c0726b6 100644 --- a/backend/tests/mysql-migration-roundtrip.test.mjs +++ b/backend/tests/mysql-migration-roundtrip.test.mjs @@ -37,6 +37,7 @@ import { ThirdPartyClient } from '../dist/third-party/third-party-client.js'; import { ThirdPartyService } from '../dist/third-party/third-party-service.js'; import { DeviceRepository } from '../dist/devices/device-repository.js'; import { IotMessageService } from '../dist/devices/iot-message-service.js'; +import { MemberProfileService } from '../dist/wallets/member-profile-service.js'; import { executeMigrationPlan, loadMigrationPlan, @@ -708,6 +709,25 @@ async function assertPricingAndReservations(pool, context) { assert.equal(managedAudit[0].action, 'ORDER_CREATED_ON_BEHALF'); assert.equal(String(JSON.parse(managedAudit[0].metadata).targetUserId), customerId); + const scopedMembers = await new MemberProfileService(pool).listMembers({ + actor: { + tenantId: context.tenantId, + userId: adminId, + access: { + roles: ['STORE_ADMIN'], + capabilities: ['user.read'], + storeIds: [String(targetRows[0].storeId)] + } + }, + page: 1, + pageSize: 20, + status: 'ACTIVE', + search: customerId + }); + assert.equal(scopedMembers.total, 1); + assert.equal(scopedMembers.items[0].memberId, customerId); + assert.ok(scopedMembers.items[0].orders.orderCount >= 1); + const [foreignUser] = await pool.query( `INSERT INTO qipai_users (tenant_id, user_type, status) VALUES (?, 'CUSTOMER', 'ACTIVE')`, @@ -2000,6 +2020,7 @@ try { 'store-scoped on-behalf booking rejection', 'manager on-behalf booking history and audit attribution', 'cross-tenant on-behalf target user rejection', + 'store-scoped member discovery through historical owner orders', 'share token stored as SHA-256 only', 'default view and door permissions', 'renew permission denied by default', diff --git a/backend/tests/user-management.test.mjs b/backend/tests/user-management.test.mjs index 10a62ce..62715eb 100644 --- a/backend/tests/user-management.test.mjs +++ b/backend/tests/user-management.test.mjs @@ -115,6 +115,18 @@ assert.equal(listedInput.role, 'CLEANER'); assert.equal(listedInput.status, 'ACTIVE'); assert.equal(listedInput.search, '保洁'); +const miniappStaff = await app.inject({ + method: 'GET', + url: '/app-api/management/staff?page=1&pageSize=20&status=ACTIVE&search=%E5%A4%9C%E7%8F%AD', + headers: { authorization: `Bearer ${token}` } +}); +assert.equal(miniappStaff.statusCode, 200); +assert.equal(listedInput.page, 1); +assert.equal(listedInput.pageSize, 20); +assert.equal(listedInput.status, 'ACTIVE'); +assert.equal(listedInput.search, '夜班'); +assert.equal(listedInput.userType, 'STAFF'); + const updated = await app.inject({ method: 'PATCH', url: '/admin-api/users/22', diff --git a/miniapp/app.json b/miniapp/app.json index c40d992..d12724d 100644 --- a/miniapp/app.json +++ b/miniapp/app.json @@ -10,6 +10,7 @@ "pages/recharge/index", "pages/cleaner/tasks", "pages/manager/dashboard", + "pages/manager/people", "pages/manager/order-create", "pages/logs/logs" ], diff --git a/miniapp/pages/manager/dashboard.js b/miniapp/pages/manager/dashboard.js index 67fc804..82fa95c 100644 --- a/miniapp/pages/manager/dashboard.js +++ b/miniapp/pages/manager/dashboard.js @@ -37,6 +37,7 @@ Page({ loading: false, errorMessage: '', canWrite: false, + canReadUsers: false, stores: [], selectedStoreId: '', selectedStoreName: '', @@ -94,6 +95,9 @@ Page({ canWrite: access.capabilities.includes('store.operation.write') || access.capabilities.includes('tenant.manage') || access.roles.includes('PLATFORM_ADMIN'), + canReadUsers: access.capabilities.includes('user.read') + || access.capabilities.includes('tenant.manage') + || access.roles.includes('PLATFORM_ADMIN'), stores, selectedStoreId, selectedStoreName: stores.find((store) => store.id === selectedStoreId)?.name || '', @@ -216,6 +220,11 @@ Page({ }) }, + openPeople() { + if (!this.data.canReadUsers) return + wx.navigateTo({ url: '/pages/manager/people' }) + }, + manageOrder(event) { if (!this.data.canWrite) return const orderId = event.currentTarget.dataset.orderId diff --git a/miniapp/pages/manager/dashboard.wxml b/miniapp/pages/manager/dashboard.wxml index 3c093a1..8acf499 100644 --- a/miniapp/pages/manager/dashboard.wxml +++ b/miniapp/pages/manager/dashboard.wxml @@ -24,6 +24,14 @@ + + + 会员与员工 + 检索会员资产概况与授权范围内员工 + + + + {{summary.roomTotal}}房间 {{summary.available}}空闲 diff --git a/miniapp/pages/manager/dashboard.wxss b/miniapp/pages/manager/dashboard.wxss index 00e5778..bef6e4f 100644 --- a/miniapp/pages/manager/dashboard.wxss +++ b/miniapp/pages/manager/dashboard.wxss @@ -53,6 +53,21 @@ margin-bottom: 28rpx; } +.management-tools { + align-items: center; + background: #f8fafc; + border: 1rpx solid #e2e8f0; + border-radius: 16rpx; + display: flex; + justify-content: space-between; + margin-bottom: 24rpx; + padding: 20rpx; +} + +.management-tools button { + margin: 0; +} + .summary-card { background: #f8fafc; border: 1rpx solid #e2e8f0; diff --git a/miniapp/pages/manager/order-create.js b/miniapp/pages/manager/order-create.js index 39f9adf..6322b6c 100644 --- a/miniapp/pages/manager/order-create.js +++ b/miniapp/pages/manager/order-create.js @@ -9,7 +9,11 @@ Page({ storeName: '', rooms: [], roomIndex: 0, - userId: '', + memberLoading: false, + memberSearch: '', + members: [], + selectedMemberId: '', + selectedMemberName: '', startDate: '', startTime: '', endDate: '', @@ -34,7 +38,7 @@ Page({ endDate: this.datePart(end), endTime: this.timePart(end), }) - await this.loadRooms() + await Promise.all([this.loadRooms(), this.loadMembers()]) }, async loadRooms() { @@ -60,7 +64,14 @@ Page({ } }, - inputUserId(event) { this.setData({ userId: String(event.detail.value || '').trim() }) }, + inputMemberSearch(event) { this.setData({ memberSearch: String(event.detail.value || '').trim() }) }, + searchMembers() { this.loadMembers() }, + selectMember(event) { + const memberId = event.currentTarget.dataset.memberId + const member = this.data.members.find((item) => item.memberId === memberId) + if (!member) return + this.setData({ selectedMemberId: member.memberId, selectedMemberName: member.displayName }) + }, selectRoom(event) { this.setData({ roomIndex: Number(event.detail.value) }) }, selectPricingMode(event) { this.setData({ pricingModeIndex: Number(event.detail.value) }) }, changeStartDate(event) { this.setData({ startDate: event.detail.value }) }, @@ -68,13 +79,39 @@ Page({ changeEndDate(event) { this.setData({ endDate: event.detail.value }) }, changeEndTime(event) { this.setData({ endTime: event.detail.value }) }, + async loadMembers() { + this.setData({ memberLoading: true, errorMessage: '' }) + try { + await ensureLogin() + const search = this.data.memberSearch + ? `&search=${encodeURIComponent(this.data.memberSearch)}` + : '' + const response = await request(`/management/members?page=1&pageSize=20&status=ACTIVE${search}`) + const members = (response.data?.items || []).map((member) => ({ + ...member, + displayName: `${member.nickname || '未命名会员'} · ${member.maskedPhone || `ID ${member.memberId}`}`, + balanceText: cents(member.wallet?.totalBalanceCents), + })) + const selectedStillVisible = members.some((member) => member.memberId === this.data.selectedMemberId) + this.setData({ + members, + selectedMemberId: selectedStillVisible ? this.data.selectedMemberId : '', + selectedMemberName: selectedStillVisible ? this.data.selectedMemberName : '', + }) + } catch (error) { + this.setData({ errorMessage: error.message || '会员加载失败' }) + } finally { + this.setData({ memberLoading: false }) + } + }, + async submit() { if (this.data.submitting) return const room = this.data.rooms[this.data.roomIndex] const pricingMode = this.data.pricingModes[this.data.pricingModeIndex]?.value - const userId = this.data.userId - if (!/^[1-9]\d{0,19}$/.test(userId)) { - this.setData({ errorMessage: '请输入有效的会员用户 ID' }) + const userId = this.data.selectedMemberId + if (!userId) { + this.setData({ errorMessage: '请从授权范围内选择会员' }) return } if (!room) { diff --git a/miniapp/pages/manager/order-create.wxml b/miniapp/pages/manager/order-create.wxml index 9b16581..ef60283 100644 --- a/miniapp/pages/manager/order-create.wxml +++ b/miniapp/pages/manager/order-create.wxml @@ -7,8 +7,25 @@ 正在加载房间... - 会员用户 ID - + 会员 + + + + + 已选择:{{selectedMemberName}}(ID {{selectedMemberId}}) + + + {{item.displayName}}ID {{item.memberId}} + {{item.balanceText}} + + + 授权范围内未找到有效会员 房间 @@ -36,6 +53,6 @@ {{endTime}} - + diff --git a/miniapp/pages/manager/order-create.wxss b/miniapp/pages/manager/order-create.wxss index 8013c6a..3fd6cdf 100644 --- a/miniapp/pages/manager/order-create.wxss +++ b/miniapp/pages/manager/order-create.wxss @@ -38,6 +38,62 @@ padding: 18rpx; } +.member-search-row { + align-items: center; + display: grid; + gap: 12rpx; + grid-template-columns: 1fr auto; +} + +.member-search-row button { + margin: 0; +} + +.selected-member { + background: #ecfdf5; + border-radius: 12rpx; + color: #166534; + font-size: 23rpx; + margin-top: 12rpx; + padding: 14rpx; +} + +.member-results { + border: 1rpx solid #e2e8f0; + border-radius: 14rpx; + margin-top: 12rpx; + max-height: 360rpx; +} + +.member-option { + align-items: center; + border-bottom: 1rpx solid #f1f5f9; + display: flex; + justify-content: space-between; + padding: 16rpx; +} + +.member-option.active { + background: #eff6ff; +} + +.member-option strong, +.member-option text { + display: block; + font-size: 23rpx; +} + +.member-option > text, +.member-option view text, +.empty-members { + color: #64748b; +} + +.empty-members { + font-size: 23rpx; + padding: 18rpx 0; +} + .datetime-row { display: grid; gap: 14rpx; diff --git a/miniapp/pages/manager/people.js b/miniapp/pages/manager/people.js new file mode 100644 index 0000000..a50f45f --- /dev/null +++ b/miniapp/pages/manager/people.js @@ -0,0 +1,123 @@ +const { request, ensureLogin, cents } = require('../../utils/api.js') + +const roleLabels = { + CLEANER: '保洁员', + STAFF: '门店员工', + STORE_ADMIN: '门店管理员', + TENANT_ADMIN: '租户管理员', + PLATFORM_ADMIN: '平台管理员', +} + +Page({ + data: { + loading: false, + detailLoading: false, + errorMessage: '', + tab: 'members', + query: '', + members: [], + staff: [], + selectedMember: null, + }, + + async onLoad() { + await this.loadData() + }, + + selectTab(event) { + const tab = event.currentTarget.dataset.tab + if (!tab || tab === this.data.tab) return + this.setData({ tab, selectedMember: null, errorMessage: '' }) + this.loadData() + }, + + inputQuery(event) { + this.setData({ query: String(event.detail.value || '').trim() }) + }, + + search() { + this.loadData() + }, + + async loadData() { + this.setData({ loading: true, errorMessage: '' }) + try { + await ensureLogin() + const search = this.data.query ? `&search=${encodeURIComponent(this.data.query)}` : '' + if (this.data.tab === 'members') { + const response = await request(`/management/members?page=1&pageSize=50${search}`) + this.setData({ + members: (response.data?.items || []).map((item) => this.presentMember(item)), + }) + } else { + const response = await request(`/management/staff?page=1&pageSize=50${search}`) + this.setData({ + staff: (response.data?.items || []).map((item) => this.presentStaff(item)), + }) + } + } catch (error) { + this.setData({ errorMessage: error.message || '人员数据加载失败' }) + } finally { + this.setData({ loading: false }) + } + }, + + async openMember(event) { + const memberId = event.currentTarget.dataset.memberId + if (!memberId || this.data.detailLoading) return + this.setData({ detailLoading: true, errorMessage: '' }) + try { + const response = await request(`/management/members/${encodeURIComponent(memberId)}`) + const member = this.presentMember(response.data || {}) + member.recentLedger = (response.data?.recentLedger || []).map((item) => ({ + ...item, + deltaText: this.deltaText(item), + createdText: this.formatTime(item.createdAt), + })) + this.setData({ selectedMember: member }) + } catch (error) { + this.setData({ errorMessage: error.message || '会员详情加载失败' }) + } finally { + this.setData({ detailLoading: false }) + } + }, + + closeMember() { + this.setData({ selectedMember: null }) + }, + + presentMember(item) { + return { + ...item, + displayName: item.nickname || '未命名会员', + balanceText: cents(item.wallet?.totalBalanceCents), + paidText: cents(item.orders?.paidAmountCents), + registeredText: this.formatTime(item.registeredAt), + } + }, + + presentStaff(item) { + return { + ...item, + displayName: item.nickname || '未命名员工', + rolesText: (item.roles || []).map((role) => roleLabels[role] || role).join(' / ') || '未分配角色', + storesText: (item.storeIds || []).length ? `门店 ${item.storeIds.join('、')}` : '全部门店/未绑定门店', + statusText: item.status === 'ACTIVE' ? '在职' : '已停用', + lastLoginText: item.lastLoginAt ? this.formatTime(item.lastLoginAt) : '从未登录', + } + }, + + deltaText(item) { + const cash = Number(item.cashDeltaCents || 0) + const gift = Number(item.giftDeltaCents || 0) + const total = cash + gift + return `${total >= 0 ? '+' : '-'}${cents(Math.abs(total))}` + }, + + formatTime(value) { + const date = new Date(value) + if (Number.isNaN(date.getTime())) return String(value || '') + const pad = (part) => String(part).padStart(2, '0') + return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}` + }, +}) diff --git a/miniapp/pages/manager/people.json b/miniapp/pages/manager/people.json new file mode 100644 index 0000000..9e23184 --- /dev/null +++ b/miniapp/pages/manager/people.json @@ -0,0 +1,4 @@ +{ + "navigationBarTitleText": "会员与员工", + "enablePullDownRefresh": false +} diff --git a/miniapp/pages/manager/people.wxml b/miniapp/pages/manager/people.wxml new file mode 100644 index 0000000..f5be446 --- /dev/null +++ b/miniapp/pages/manager/people.wxml @@ -0,0 +1,72 @@ + + + 会员与员工 + 仅展示当前账号数据范围内的人员,手机号与最近 IP 保持脱敏。 + + + + + + + + + + {{errorMessage}} + 正在加载... + + + + + {{selectedMember.displayName}} + {{selectedMember.maskedPhone}} · ID {{selectedMember.memberId}} + + + + + {{selectedMember.balanceText}}总余额 + {{selectedMember.orders.orderCount}}订单 + {{selectedMember.benefits.availableCoupons}}可用券 + {{selectedMember.benefits.activePackages}}有效套餐 + + 累计支付:{{selectedMember.paidText}} + 注册时间:{{selectedMember.registeredText}} + 最近余额流水 + 暂无余额流水 + + {{item.businessType}}{{item.createdText}} + {{item.deltaText}} + + + + + 授权范围内暂无会员 + + + + {{item.displayName}} + {{item.maskedPhone}} · ID {{item.memberId}} + + {{item.status === 'ACTIVE' ? '有效' : '停用'}} + + 余额 {{item.balanceText}}订单 {{item.orders.orderCount}}消费 {{item.paidText}} + + + + + 授权范围内暂无员工 + + + + {{item.displayName}} + {{item.maskedPhone}} · ID {{item.id}} + + {{item.statusText}} + + {{item.rolesText}} + {{item.storesText}} + 最近登录:{{item.lastLoginText}} · {{item.maskedLastIp || '无 IP'}} + 备注:{{item.note}} + + + + diff --git a/miniapp/pages/manager/people.wxss b/miniapp/pages/manager/people.wxss new file mode 100644 index 0000000..de597ab --- /dev/null +++ b/miniapp/pages/manager/people.wxss @@ -0,0 +1,171 @@ +.people-page { + padding-bottom: 48rpx; +} + +.subtitle, +.card-meta, +.detail-line, +.ledger-line text { + color: #64748b; + font-size: 23rpx; +} + +.subtitle { + line-height: 1.6; + margin: 8rpx 0 22rpx; +} + +.people-tabs, +.search-row, +.card-heading, +.person-summary, +.ledger-line { + align-items: center; + display: flex; +} + +.people-tabs { + gap: 12rpx; + margin-bottom: 16rpx; +} + +.people-tabs button { + flex: 1; + margin: 0; +} + +.people-tabs .active { + background: #0f172a; + color: #fff; +} + +.search-row { + display: grid; + gap: 12rpx; + grid-template-columns: 1fr auto; + margin-bottom: 20rpx; +} + +.search-row input { + background: #fff; + border: 1rpx solid #cbd5e1; + border-radius: 14rpx; + padding: 18rpx; +} + +.search-row button, +.card-heading button { + margin: 0; +} + +.person-card, +.detail-card { + background: #fff; + border: 1rpx solid #e2e8f0; + border-radius: 18rpx; + margin-bottom: 16rpx; + padding: 22rpx; +} + +.detail-card { + border-color: #93c5fd; +} + +.card-heading, +.ledger-line { + justify-content: space-between; +} + +.card-title { + font-size: 28rpx; + font-weight: 600; +} + +.card-meta { + margin-top: 6rpx; +} + +.status-tag { + background: #dcfce7; + border-radius: 999rpx; + color: #166534; + font-size: 22rpx; + padding: 8rpx 14rpx; +} + +.status-tag.disabled { + background: #f1f5f9; + color: #64748b; +} + +.person-summary { + gap: 20rpx; + justify-content: space-between; + margin-top: 18rpx; +} + +.person-summary text { + color: #334155; + font-size: 23rpx; +} + +.metric-grid { + display: grid; + gap: 12rpx; + grid-template-columns: repeat(2, 1fr); + margin: 20rpx 0; +} + +.metric-grid view { + background: #f8fafc; + border-radius: 12rpx; + display: flex; + flex-direction: column; + padding: 16rpx; +} + +.metric-grid strong { + color: #0f172a; + font-size: 28rpx; +} + +.metric-grid text { + color: #64748b; + font-size: 21rpx; +} + +.detail-line { + line-height: 1.7; +} + +.subheading { + font-size: 25rpx; + font-weight: 600; + margin: 20rpx 0 10rpx; +} + +.ledger-line { + border-top: 1rpx solid #f1f5f9; + padding: 14rpx 0; +} + +.ledger-line strong, +.ledger-line text { + display: block; +} + +.staff-note { + background: #f8fafc; + border-radius: 10rpx; + color: #475569; + font-size: 23rpx; + margin-top: 12rpx; + padding: 12rpx; +} + +.loading, +.empty { + color: #64748b; + padding: 28rpx 0; + text-align: center; +} diff --git a/scripts/check-miniapp-m08-c.mjs b/scripts/check-miniapp-m08-c.mjs index 9a2dc80..f66dc15 100644 --- a/scripts/check-miniapp-m08-c.mjs +++ b/scripts/check-miniapp-m08-c.mjs @@ -7,6 +7,7 @@ const read = (path) => readFileSync(join(root, path), 'utf8'); const appJson = JSON.parse(read('miniapp/app.json')); assert.ok(appJson.pages.includes('pages/manager/dashboard')); +assert.ok(appJson.pages.includes('pages/manager/people')); assert.ok(appJson.pages.includes('pages/manager/order-create')); const index = read('miniapp/pages/index/index.js') + read('miniapp/pages/index/index.wxml'); @@ -36,6 +37,8 @@ for (const pattern of [ "method: 'PATCH'", 'store.operation.write', 'tenant.manage', + 'canReadUsers', + 'openPeople', 'changeRoomStatus', 'toggleRoomConfiguration', 'activeOrders', @@ -55,11 +58,13 @@ const orderCreate = read('miniapp/pages/manager/order-create.js') + read('miniapp/pages/manager/order-create.wxml'); for (const pattern of [ '/management/stores/${encodeURIComponent(this.data.storeId)}/rooms', + '/management/members?page=1&pageSize=20&status=ACTIVE', '/management/orders/reserve-on-behalf', 'startAt.toISOString()', 'endAt.toISOString()', 'pricingMode', - '会员用户 ID', + 'selectMember', + '授权范围内选择会员', '价格由服务端' ]) { assert.match(orderCreate, new RegExp(pattern.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))); @@ -78,6 +83,42 @@ for (const pattern of [ assert.match(orderRoutes, new RegExp(pattern.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))); } +const people = read('miniapp/pages/manager/people.js') + + read('miniapp/pages/manager/people.wxml'); +for (const pattern of [ + '/management/members?page=1&pageSize=50', + '/management/members/${encodeURIComponent(memberId)}', + '/management/staff?page=1&pageSize=50', + 'selectedMember', + 'maskedLastIp', + '会员与员工', + '最近余额流水' +]) { + assert.match(people, new RegExp(pattern.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))); +} + +const peopleRoutes = read('backend/src/routes/members.ts') + + read('backend/src/routes/user-management.ts') + + read('backend/src/auth/user-management-repository.ts') + + read('backend/src/wallets/member-profile-service.ts'); +for (const pattern of [ + '/app-api/management/members', + '/app-api/management/members/:id', + '/app-api/management/staff', + "userType: 'STAFF'", + 'qipai_order_user_access oa', + "oa.access_type = 'OWNER'", + 'mo.store_id IN' +]) { + assert.match(peopleRoutes, new RegExp(pattern.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))); +} + +const mysqlPaginationRepositories = peopleRoutes + + read('backend/src/orders/order-query-repository.ts') + + read('backend/src/cleaning/cleaning-task-repository.ts'); +assert.doesNotMatch(mysqlPaginationRepositories, /LIMIT \? OFFSET \?/); +assert.match(mysqlPaginationRepositories, /Math\.trunc/); + const routes = read('backend/src/routes/store-room-management.ts'); for (const pattern of [ '/app-api/management/stores',