feat(M08-C): 建立管理员房态运营入口

This commit is contained in:
Codex
2026-08-10 10:06:12 +08:00
parent 1160f16e8d
commit b70ada06a8
24 changed files with 699 additions and 15 deletions
+2
View File
@@ -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',
+2
View File
@@ -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'))
+6 -2
View File
@@ -49,7 +49,8 @@ const migrationFiles: Record<MigrationDirection, readonly string[]> = {
'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<MigrationDirection, readonly string[]> = {
'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,
+1 -1
View File
@@ -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
+40 -1
View File
@@ -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<StoreRoomRepository,
'listStores' | 'createStore' | 'updateStore' | 'archiveStore' | 'listRooms'
| 'createRoom' | 'updateRoom' | 'archiveRoom' | 'addDisabledPeriod'>;
| 'createRoom' | 'updateRoom' | 'updateRoomStatus' | 'archiveRoom' | 'addDisabledPeriod'>;
authRepository: Pick<AuthRepository, 'validateSession'>;
accessControl: { getAccessProfile(tenantId: string, userId: string): Promise<AccessProfile> };
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);
+42 -6
View File
@@ -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<RoomRow[]>(
`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<string, unknown> = {}
) {
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)]
);
}
+14 -1
View File
@@ -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.');
+6 -1
View File
@@ -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) {
+9
View File
@@ -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',
+2
View File
@@ -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.');
+39 -1
View File
@@ -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}` },
@@ -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'
@@ -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';
@@ -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');
@@ -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
);
+1
View File
@@ -9,6 +9,7 @@
"pages/benefits/index",
"pages/recharge/index",
"pages/cleaner/tasks",
"pages/manager/dashboard",
"pages/logs/logs"
],
"window": {
+31
View File
@@ -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 {
+2
View File
@@ -4,6 +4,8 @@
<view class="quick-actions">
<button bindtap="openOrders">我的订单</button>
<button bindtap="openProfile">个人中心</button>
<button wx:if="{{showManagerEntry}}" type="primary" bindtap="openManager">门店管理</button>
<button wx:if="{{showCleanerEntry}}" bindtap="openCleaner">保洁任务</button>
</view>
<button loading="{{locating}}" bindtap="locateNearby">定位附近门店</button>
<view class="city-search">
+191
View File
@@ -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())}`
},
})
+4
View File
@@ -0,0 +1,4 @@
{
"navigationBarTitleText": "门店运营",
"enablePullDownRefresh": true
}
+64
View File
@@ -0,0 +1,64 @@
<scroll-view class="scrollarea" scroll-y type="list">
<view class="container manager-dashboard">
<view class="manager-header">
<view>
<view class="title">门店运营</view>
<view class="subtitle">{{selectedStoreName || '请选择授权门店'}}</view>
</view>
<view class="permission-tag">{{canWrite ? '可操作' : '只读'}}</view>
</view>
<view wx:if="{{errorMessage}}" class="error">{{errorMessage}}</view>
<view wx:if="{{loading}}" class="loading">正在加载运营数据...</view>
<scroll-view wx:if="{{stores.length}}" class="store-tabs" scroll-x enhanced show-scrollbar="false">
<view class="store-tabs-inner">
<button
wx:for="{{stores}}"
wx:key="id"
size="mini"
class="{{item.id === selectedStoreId ? 'store-tab active' : 'store-tab'}}"
data-store-id="{{item.id}}"
bindtap="selectStore"
>{{item.name}}</button>
</view>
</scroll-view>
<view class="summary-grid">
<view class="summary-card"><strong>{{summary.roomTotal}}</strong><text>房间</text></view>
<view class="summary-card success"><strong>{{summary.available}}</strong><text>空闲</text></view>
<view class="summary-card primary"><strong>{{summary.inUse}}</strong><text>占用</text></view>
<view class="summary-card warning"><strong>{{summary.attention}}</strong><text>待处理</text></view>
<view class="summary-card"><strong>{{summary.activeOrders}}</strong><text>活跃订单</text></view>
</view>
<view class="section-title">实时房态</view>
<view wx:if="{{!loading && rooms.length === 0}}" class="empty">当前门店暂无房间</view>
<view wx:for="{{rooms}}" wx:key="id" class="room-card {{item.configurationStatus === 'DISABLED' ? 'disabled' : ''}}">
<view class="card-main">
<view>
<view class="card-title">{{item.name}} · {{item.roomNo}}</view>
<view class="card-meta">{{item.categoryName}} / {{item.capacity}} 人 / {{item.priceText}} 起</view>
</view>
<view class="status-pill status-{{item.operationalStatus}}">{{item.configurationStatus === 'DISABLED' ? '已停用' : item.statusText}}</view>
</view>
<view wx:if="{{canWrite}}" class="card-actions">
<button size="mini" loading="{{busyRoomId === item.id}}" data-room-id="{{item.id}}" bindtap="changeRoomStatus">调整房态</button>
<button size="mini" data-room-id="{{item.id}}" data-status="{{item.configurationStatus}}" bindtap="toggleRoomConfiguration">{{item.configurationStatus === 'ENABLED' ? '停用' : '启用'}}</button>
</view>
</view>
<view class="section-title">近期订单</view>
<view wx:if="{{!loading && visibleOrders.length === 0}}" class="empty">当前门店暂无订单</view>
<view wx:for="{{visibleOrders}}" wx:key="id" class="order-card" data-order-id="{{item.id}}" bindtap="openOrder">
<view class="card-main">
<view>
<view class="card-title">{{item.roomName}} · {{item.roomNo}}</view>
<view class="card-meta">{{item.orderNo}}</view>
</view>
<view class="status-pill">{{item.statusText}}</view>
</view>
<view class="order-line"><text>{{item.timeText}}</text><strong>{{item.amountText}}</strong></view>
</view>
</view>
</scroll-view>
+134
View File
@@ -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;
}
+67
View File
@@ -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.');
+1
View File
@@ -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