feat(M08-B): 接入保洁任务端基础流程

This commit is contained in:
Codex
2026-06-25 23:19:04 +08:00
parent 9f7c2e4be7
commit 2a4dcd0fd8
19 changed files with 1081 additions and 10 deletions
+1 -1
View File
@@ -17,7 +17,7 @@
"db:migrate:verify": "npm run build && node dist/db/migrate-cli.js verify", "db:migrate:verify": "npm run build && node dist/db/migrate-cli.js verify",
"db:migrate:down": "npm run build && node dist/db/migrate-cli.js down", "db:migrate:down": "npm run build && node dist/db/migrate-cli.js down",
"test:mysql:migration": "npm run build && node tests/mysql-migration-roundtrip.test.mjs", "test:mysql:migration": "npm run build && node tests/mysql-migration-roundtrip.test.mjs",
"test": "npm run build && node tests/backend-contract.test.mjs && node tests/mqtt-service.test.mjs && node tests/migration-contract.test.mjs && node tests/mysql-pool-contract.test.mjs && node tests/migration-runner.test.mjs && node tests/legacy-money.test.mjs && node tests/legacy-read-repository.test.mjs && node tests/task-repository.test.mjs && node tests/platform-config-repository.test.mjs && node tests/auth.test.mjs && node tests/rbac.test.mjs && node tests/user-management.test.mjs && node tests/store-room.test.mjs && node tests/content-management.test.mjs && node tests/store-discovery.test.mjs && node tests/store-access.test.mjs && node tests/pricing.test.mjs && node tests/order-state.test.mjs && node tests/order-query.test.mjs && node tests/order-management.test.mjs && node tests/order-share.test.mjs && node tests/payment.test.mjs && node tests/wechat-pay.test.mjs && node tests/third-party.test.mjs && node tests/profit-sharing.test.mjs && node tests/device.test.mjs && node tests/iot-protocol.test.mjs && node tests/device-control.test.mjs && node tests/order-device-automation.test.mjs && node tests/hardware-smoke-runner.test.mjs && node tests/wallet-ledger.test.mjs && node tests/recharge-service.test.mjs && node tests/recharge-route.test.mjs && node tests/marketing-benefit-service.test.mjs && node tests/member-profile-service.test.mjs && node tests/member-profile-route.test.mjs" "test": "npm run build && node tests/backend-contract.test.mjs && node tests/mqtt-service.test.mjs && node tests/migration-contract.test.mjs && node tests/mysql-pool-contract.test.mjs && node tests/migration-runner.test.mjs && node tests/legacy-money.test.mjs && node tests/legacy-read-repository.test.mjs && node tests/task-repository.test.mjs && node tests/platform-config-repository.test.mjs && node tests/auth.test.mjs && node tests/rbac.test.mjs && node tests/user-management.test.mjs && node tests/store-room.test.mjs && node tests/content-management.test.mjs && node tests/store-discovery.test.mjs && node tests/store-access.test.mjs && node tests/pricing.test.mjs && node tests/order-state.test.mjs && node tests/order-query.test.mjs && node tests/order-management.test.mjs && node tests/order-share.test.mjs && node tests/payment.test.mjs && node tests/wechat-pay.test.mjs && node tests/third-party.test.mjs && node tests/profit-sharing.test.mjs && node tests/device.test.mjs && node tests/iot-protocol.test.mjs && node tests/device-control.test.mjs && node tests/order-device-automation.test.mjs && node tests/hardware-smoke-runner.test.mjs && node tests/wallet-ledger.test.mjs && node tests/recharge-service.test.mjs && node tests/recharge-route.test.mjs && node tests/marketing-benefit-service.test.mjs && node tests/member-profile-service.test.mjs && node tests/member-profile-route.test.mjs && node tests/cleaning-route.test.mjs"
}, },
"dependencies": { "dependencies": {
"@fastify/cors": "^11.2.0", "@fastify/cors": "^11.2.0",
+5
View File
@@ -50,6 +50,7 @@ import {
} from './routes/device-control.js'; } from './routes/device-control.js';
import { registerMemberRoutes, type MemberRouteOptions } from './routes/members.js'; import { registerMemberRoutes, type MemberRouteOptions } from './routes/members.js';
import { registerRechargeRoutes, type RechargeRouteOptions } from './routes/recharge.js'; import { registerRechargeRoutes, type RechargeRouteOptions } from './routes/recharge.js';
import { registerCleaningRoutes, type CleaningRouteOptions } from './routes/cleaning.js';
export interface BuildAppOptions { export interface BuildAppOptions {
config?: AppConfig; config?: AppConfig;
@@ -72,6 +73,7 @@ export interface BuildAppOptions {
deviceControl?: DeviceControlRouteOptions; deviceControl?: DeviceControlRouteOptions;
members?: MemberRouteOptions; members?: MemberRouteOptions;
recharge?: RechargeRouteOptions; recharge?: RechargeRouteOptions;
cleaning?: CleaningRouteOptions;
} }
declare module 'fastify' { declare module 'fastify' {
@@ -170,6 +172,9 @@ export async function buildApp(options: BuildAppOptions = {}): Promise<FastifyIn
if (options.recharge) { if (options.recharge) {
await registerRechargeRoutes(app, options.recharge); await registerRechargeRoutes(app, options.recharge);
} }
if (options.cleaning) {
await registerCleaningRoutes(app, options.cleaning);
}
return app; return app;
} }
+9 -2
View File
@@ -36,13 +36,20 @@ export class RbacRepository {
SELECT ?, r.id, p.id FROM qipai_roles r SELECT ?, r.id, p.id FROM qipai_roles r
INNER JOIN qipai_permissions p ON INNER JOIN qipai_permissions p ON
(r.code = 'CUSTOMER' AND p.code IN ('profile.read', 'order.self.read')) (r.code = 'CUSTOMER' AND p.code IN ('profile.read', 'order.self.read'))
OR (r.code = 'CLEANER'
AND p.code IN ('profile.read', 'cleaning.task.read',
'cleaning.task.write', 'cleaning.statistics.read'))
OR (r.code = 'STORE_ADMIN' OR (r.code = 'STORE_ADMIN'
AND p.code IN ('user.read', 'staff.manage', 'session.reset', AND p.code IN ('user.read', 'staff.manage', 'session.reset',
'store.operation.read', 'store.operation.write', 'store.operation.read', 'store.operation.write',
'device.read', 'device.write')) 'device.read', 'device.write',
'cleaning.task.read', 'cleaning.task.write',
'cleaning.statistics.read'))
OR (r.code IN ('TENANT_ADMIN', 'PLATFORM_ADMIN') OR (r.code IN ('TENANT_ADMIN', 'PLATFORM_ADMIN')
AND p.code IN ('user.read', 'staff.manage', 'session.reset', 'tenant.manage', AND p.code IN ('user.read', 'staff.manage', 'session.reset', 'tenant.manage',
'device.read', 'device.write')) 'device.read', 'device.write',
'cleaning.task.read', 'cleaning.task.write',
'cleaning.statistics.read'))
WHERE r.tenant_id = ?`, WHERE r.tenant_id = ?`,
[tenantId, tenantId] [tenantId, tenantId]
); );
@@ -0,0 +1,286 @@
import type { ResultSetHeader, RowDataPacket } from 'mysql2/promise';
import type { AccessProfile } from '../auth/rbac-repository.js';
import type { MySqlPool } from '../db/mysql.js';
export const cleaningTaskStatuses = [
'WAITING', 'CLAIMED', 'STARTED', 'SUBMITTED', 'COMPLETED',
'REJECTED', 'EXEMPT', 'SETTLED', 'CANCELLED'
] as const;
export type CleaningTaskStatus = typeof cleaningTaskStatuses[number];
export class CleaningTaskError extends Error {
constructor(public readonly code: string) { super(code); }
}
export interface CleaningActor {
tenantId: string;
userId: string;
access: AccessProfile;
traceId: string;
}
interface CleaningTaskRow extends RowDataPacket {
id: string;
taskNo: string;
storeId: string;
storeName: string;
roomId: string;
roomName: string;
roomNo: string;
orderId: string | null;
orderNo: string | null;
status: CleaningTaskStatus;
cleanerUserId: string | null;
priority: number;
rewardCents: number;
requirement: string;
photoUrlsJson: string | string[] | null;
rejectReason: string;
claimedAt: Date | null;
startedAt: Date | null;
submittedAt: Date | null;
completedAt: Date | null;
createdAt: Date;
updatedAt: Date;
}
interface CountRow extends RowDataPacket { total: number }
interface StatusCountRow extends RowDataPacket { status: CleaningTaskStatus; total: number }
interface AmountRow extends RowDataPacket { amount: number | null }
interface CurrentStatusRow extends RowDataPacket { status: CleaningTaskStatus; cleanerUserId: string | null }
export class CleaningTaskRepository {
constructor(private readonly pool: MySqlPool) {}
async listHall(input: CleaningActor & { page: number; pageSize: number; status?: CleaningTaskStatus }) {
this.assertCleaner(input.access, 'read');
const status = input.status ?? 'WAITING';
const where = [
't.tenant_id = ?',
't.deleted_at IS NULL',
't.status = ?',
storeScopeSql(input.access, 't.store_id')
];
const params: Array<string | number> = [input.tenantId, status];
return this.listByWhere(where, params, input.page, input.pageSize, 't.priority ASC, t.created_at ASC');
}
async listMine(input: CleaningActor & { page: number; pageSize: number; status?: CleaningTaskStatus }) {
this.assertCleaner(input.access, 'read');
const where = [
't.tenant_id = ?',
't.deleted_at IS NULL',
't.cleaner_user_id = ?'
];
const params: Array<string | number> = [input.tenantId, input.userId];
if (input.status) {
where.push('t.status = ?');
params.push(input.status);
}
return this.listByWhere(where, params, input.page, input.pageSize, 't.updated_at DESC, t.id DESC');
}
async claim(input: CleaningActor & { taskId: string }) {
this.assertCleaner(input.access, 'write');
await this.assertStoreVisible(input, input.taskId);
const [result] = await this.pool.execute<ResultSetHeader>(
`UPDATE qipai_cleaning_tasks
SET status = 'CLAIMED', cleaner_user_id = ?, claimed_at = UTC_TIMESTAMP(3)
WHERE tenant_id = ? AND id = ? AND status = 'WAITING' AND cleaner_user_id IS NULL
AND deleted_at IS NULL`,
[input.userId, input.tenantId, input.taskId]
);
if (result.affectedRows !== 1) throw new CleaningTaskError('CLEANING_TASK_NOT_CLAIMABLE');
await this.recordEvent(input, input.taskId, 'WAITING', 'CLAIMED', 'CLAIM', '');
return this.getMineTask(input, input.taskId);
}
async start(input: CleaningActor & { taskId: string }) {
return this.moveMine(input, 'CLAIMED', 'STARTED', 'START', 'started_at', '');
}
async submit(input: CleaningActor & { taskId: string; photoUrls: string[]; note?: string }) {
const task = await this.moveMine(
input,
'STARTED',
'SUBMITTED',
'SUBMIT',
'submitted_at',
input.note ?? '',
{ photo_urls_json: JSON.stringify(input.photoUrls.slice(0, 9)) }
);
return task;
}
async stats(input: CleaningActor) {
this.assertCleaner(input.access, 'read');
const [counts] = await this.pool.execute<StatusCountRow[]>(
`SELECT status, COUNT(*) AS total
FROM qipai_cleaning_tasks
WHERE tenant_id = ? AND cleaner_user_id = ? AND deleted_at IS NULL
GROUP BY status`,
[input.tenantId, input.userId]
);
const [amountRows] = await this.pool.execute<AmountRow[]>(
`SELECT COALESCE(SUM(reward_cents), 0) AS amount
FROM qipai_cleaning_tasks
WHERE tenant_id = ? AND cleaner_user_id = ? AND status IN ('SUBMITTED', 'COMPLETED')
AND settled_at IS NULL AND deleted_at IS NULL`,
[input.tenantId, input.userId]
);
return {
byStatus: Object.fromEntries(counts.map((row) => [row.status, Number(row.total)])),
pendingSettlementCents: Number(amountRows[0]?.amount ?? 0)
};
}
private async listByWhere(
where: string[],
params: Array<string | number>,
page: number,
pageSize: number,
orderBy: string
) {
const whereSql = where.join(' AND ');
const offset = (page - 1) * pageSize;
const [counts] = await this.pool.execute<CountRow[]>(
`SELECT COUNT(*) AS total FROM qipai_cleaning_tasks t WHERE ${whereSql}`,
params
);
const [rows] = await this.pool.execute<CleaningTaskRow[]>(
`SELECT t.id, t.task_no AS taskNo, t.store_id AS storeId, s.name AS storeName,
t.room_id AS roomId, r.name AS roomName, r.room_no AS roomNo,
t.order_id AS orderId, o.order_no AS orderNo, t.status,
t.cleaner_user_id AS cleanerUserId, t.priority, t.reward_cents AS rewardCents,
t.requirement, t.photo_urls_json AS photoUrlsJson, t.reject_reason AS rejectReason,
t.claimed_at AS claimedAt, t.started_at AS startedAt,
t.submitted_at AS submittedAt, t.completed_at AS completedAt,
t.created_at AS createdAt, t.updated_at AS updatedAt
FROM qipai_cleaning_tasks t
INNER JOIN qipai_stores s ON s.tenant_id = t.tenant_id AND s.id = t.store_id
INNER JOIN qipai_rooms r ON r.tenant_id = t.tenant_id AND r.id = t.room_id
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]
);
return { items: rows.map(publicTask), total: Number(counts[0]?.total ?? 0), page, pageSize };
}
private async getMineTask(input: CleaningActor, taskId: string) {
const result = await this.listMine({ ...input, page: 1, pageSize: 50 });
const task = result.items.find((item) => item.id === taskId);
if (!task) throw new CleaningTaskError('CLEANING_TASK_NOT_FOUND');
return task;
}
private async moveMine(
input: CleaningActor & { taskId: string },
from: CleaningTaskStatus,
to: CleaningTaskStatus,
action: string,
timestampColumn: 'started_at' | 'submitted_at',
note: string,
extra?: { photo_urls_json: string }
) {
this.assertCleaner(input.access, 'write');
const setExtra = extra ? ', photo_urls_json = ?' : '';
const params: Array<string | number> = extra
? [extra.photo_urls_json, input.tenantId, input.taskId, input.userId, from]
: [input.tenantId, input.taskId, input.userId, from];
const [result] = await this.pool.execute<ResultSetHeader>(
`UPDATE qipai_cleaning_tasks
SET status = ?, ${timestampColumn} = UTC_TIMESTAMP(3)${setExtra}
WHERE tenant_id = ? AND id = ? AND cleaner_user_id = ? AND status = ?
AND deleted_at IS NULL`,
[to, ...params]
);
if (result.affectedRows !== 1) throw new CleaningTaskError('CLEANING_TASK_STATUS_CONFLICT');
await this.recordEvent(input, input.taskId, from, to, action, note);
return this.getMineTask(input, input.taskId);
}
private async assertStoreVisible(input: CleaningActor, taskId: string) {
if (input.access.capabilities.includes('tenant.manage') || input.access.roles.includes('PLATFORM_ADMIN')) return;
const [rows] = await this.pool.execute<RowDataPacket[]>(
`SELECT store_id AS storeId FROM qipai_cleaning_tasks
WHERE tenant_id = ? AND id = ? AND deleted_at IS NULL`,
[input.tenantId, taskId]
);
const storeId = rows[0]?.storeId;
if (!storeId || !input.access.storeIds.includes(String(storeId))) {
throw new CleaningTaskError('CLEANING_TASK_FORBIDDEN');
}
}
private async recordEvent(
input: CleaningActor,
taskId: string,
from: CleaningTaskStatus,
to: CleaningTaskStatus,
action: string,
note: string
) {
await this.pool.execute(
`INSERT IGNORE INTO qipai_cleaning_task_events
(tenant_id, task_id, from_status, to_status, action, actor_id, trace_id, note, metadata)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, JSON_OBJECT())`,
[input.tenantId, taskId, from, to, action, input.userId, input.traceId, note.slice(0, 512)]
);
}
private assertCleaner(access: AccessProfile, mode: 'read' | 'write') {
const permission = mode === 'read' ? 'cleaning.task.read' : 'cleaning.task.write';
if (!access.capabilities.includes(permission)
&& !access.capabilities.includes('tenant.manage')
&& !access.roles.includes('PLATFORM_ADMIN')) {
throw new CleaningTaskError('CLEANING_TASK_FORBIDDEN');
}
}
}
function storeScopeSql(access: AccessProfile, storeExpression: string) {
if (access.capabilities.includes('tenant.manage') || access.roles.includes('PLATFORM_ADMIN')) {
return '1 = 1';
}
if (access.storeIds.length === 0) return '1 = 0';
return `${storeExpression} IN (${access.storeIds.map((id) => Number(id)).join(',')})`;
}
function publicTask(row: CleaningTaskRow) {
return {
id: String(row.id),
taskNo: row.taskNo,
storeId: String(row.storeId),
storeName: row.storeName,
roomId: String(row.roomId),
roomName: row.roomName,
roomNo: row.roomNo,
orderId: row.orderId === null ? null : String(row.orderId),
orderNo: row.orderNo,
status: row.status,
cleanerUserId: row.cleanerUserId === null ? null : String(row.cleanerUserId),
priority: Number(row.priority),
rewardCents: Number(row.rewardCents),
requirement: row.requirement,
photoUrls: parseJsonArray(row.photoUrlsJson),
rejectReason: row.rejectReason,
claimedAt: row.claimedAt,
startedAt: row.startedAt,
submittedAt: row.submittedAt,
completedAt: row.completedAt,
createdAt: row.createdAt,
updatedAt: row.updatedAt
};
}
function parseJsonArray(value: string | string[] | null): string[] {
if (Array.isArray(value)) return value.map(String);
if (!value) return [];
try {
const parsed = JSON.parse(value);
return Array.isArray(parsed) ? parsed.map(String) : [];
} catch {
return [];
}
}
+7 -3
View File
@@ -44,7 +44,8 @@ const migrationFiles: Record<MigrationDirection, readonly string[]> = {
'database/migrations/2026062421_m07a_wallet_ledger.up.sql', 'database/migrations/2026062421_m07a_wallet_ledger.up.sql',
'database/migrations/2026062422_m07b_recharge_plans.up.sql', 'database/migrations/2026062422_m07b_recharge_plans.up.sql',
'database/migrations/2026062423_m07c_benefits.up.sql', 'database/migrations/2026062423_m07c_benefits.up.sql',
'database/migrations/2026062524_m08a_recharge_wechat.up.sql' 'database/migrations/2026062524_m08a_recharge_wechat.up.sql',
'database/migrations/2026062525_m08b_cleaner_tasks.up.sql'
], ],
verify: [ verify: [
'database/migrations/2026061601_m01b_core_schema.verify.sql', 'database/migrations/2026061601_m01b_core_schema.verify.sql',
@@ -70,9 +71,11 @@ const migrationFiles: Record<MigrationDirection, readonly string[]> = {
'database/migrations/2026062421_m07a_wallet_ledger.verify.sql', 'database/migrations/2026062421_m07a_wallet_ledger.verify.sql',
'database/migrations/2026062422_m07b_recharge_plans.verify.sql', 'database/migrations/2026062422_m07b_recharge_plans.verify.sql',
'database/migrations/2026062423_m07c_benefits.verify.sql', 'database/migrations/2026062423_m07c_benefits.verify.sql',
'database/migrations/2026062524_m08a_recharge_wechat.verify.sql' 'database/migrations/2026062524_m08a_recharge_wechat.verify.sql',
'database/migrations/2026062525_m08b_cleaner_tasks.verify.sql'
], ],
down: [ down: [
'database/migrations/2026062525_m08b_cleaner_tasks.down.sql',
'database/migrations/2026062524_m08a_recharge_wechat.down.sql', 'database/migrations/2026062524_m08a_recharge_wechat.down.sql',
'database/migrations/2026062423_m07c_benefits.down.sql', 'database/migrations/2026062423_m07c_benefits.down.sql',
'database/migrations/2026062422_m07b_recharge_plans.down.sql', 'database/migrations/2026062422_m07b_recharge_plans.down.sql',
@@ -235,7 +238,8 @@ export async function executeMigrationPlan(
3, 3, 9, 1, 3, 3, 9, 1,
1, 1, 1, 4, 7, 1, 1, 1, 1, 4, 7, 1,
1, 1, 7, 7, 1, 1, 1, 7, 7, 1,
5, 10, 7, 3, 1 5, 10, 7, 3, 1,
2, 8, 4, 3, 1
][index] ?? 1; ][index] ?? 1;
if (!Array.isArray(result) || result.length < minimumRows) { if (!Array.isArray(result) || result.length < minimumRows) {
throw new Error( throw new Error(
+169
View File
@@ -0,0 +1,169 @@
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
import { z } from 'zod';
import type { AuthRepository } from '../auth/auth-repository.js';
import { authenticateAccessToken } from '../auth/authenticate.js';
import type { AccessProfile } from '../auth/rbac-repository.js';
import {
CleaningTaskError,
cleaningTaskStatuses,
type CleaningActor,
type CleaningTaskRepository
} from '../cleaning/cleaning-task-repository.js';
const listSchema = z.object({
page: z.coerce.number().int().min(1).default(1),
pageSize: z.coerce.number().int().min(1).max(50).default(20),
status: z.enum(cleaningTaskStatuses).optional()
});
const paramsSchema = z.object({ taskId: z.string().regex(/^[1-9]\d{0,19}$/) });
const submitSchema = z.object({
photoUrls: z.array(z.string().url()).max(9).default([]),
note: z.string().trim().max(512).optional()
});
export interface CleaningRouteOptions {
repository: Pick<CleaningTaskRepository, 'listHall' | 'listMine' | 'claim' | 'start' | 'submit' | 'stats'>;
authRepository: Pick<AuthRepository, 'validateSession'>;
accessControl: { getAccessProfile(tenantId: string, userId: string): Promise<AccessProfile> };
jwtSecret: string;
}
export async function registerCleaningRoutes(
app: FastifyInstance,
options: CleaningRouteOptions
): Promise<void> {
app.get('/app-api/cleaning/tasks/hall', async (request, reply) => {
const actor = await requireActor(request, reply, options, 'read');
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.repository.listHall({ ...actor, ...query.data }),
traceId: request.traceId
}));
});
app.get('/app-api/cleaning/tasks/mine', async (request, reply) => {
const actor = await requireActor(request, reply, options, 'read');
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.repository.listMine({ ...actor, ...query.data }),
traceId: request.traceId
}));
});
app.get('/app-api/cleaning/stats', async (request, reply) => {
const actor = await requireActor(request, reply, options, 'read');
if (!actor) return;
return handle(reply, request.traceId, async () => ({
code: 0,
data: await options.repository.stats(actor),
traceId: request.traceId
}));
});
app.post('/app-api/cleaning/tasks/:taskId/claim', async (request, reply) => {
const actor = await requireActor(request, reply, options, 'write');
if (!actor) return;
const params = paramsSchema.safeParse(request.params);
if (!params.success) return invalid(reply, request.traceId);
return handle(reply, request.traceId, async () => ({
code: 0,
data: await options.repository.claim({ ...actor, taskId: params.data.taskId }),
traceId: request.traceId
}));
});
app.post('/app-api/cleaning/tasks/:taskId/start', async (request, reply) => {
const actor = await requireActor(request, reply, options, 'write');
if (!actor) return;
const params = paramsSchema.safeParse(request.params);
if (!params.success) return invalid(reply, request.traceId);
return handle(reply, request.traceId, async () => ({
code: 0,
data: await options.repository.start({ ...actor, taskId: params.data.taskId }),
traceId: request.traceId
}));
});
app.post('/app-api/cleaning/tasks/:taskId/submit', async (request, reply) => {
const actor = await requireActor(request, reply, options, 'write');
if (!actor) return;
const params = paramsSchema.safeParse(request.params);
const body = submitSchema.safeParse(request.body ?? {});
if (!params.success || !body.success) return invalid(reply, request.traceId);
return handle(reply, request.traceId, async () => ({
code: 0,
data: await options.repository.submit({
...actor,
taskId: params.data.taskId,
photoUrls: body.data.photoUrls,
note: body.data.note
}),
traceId: request.traceId
}));
});
}
async function requireActor(
request: FastifyRequest,
reply: FastifyReply,
options: CleaningRouteOptions,
mode: 'read' | 'write'
): Promise<CleaningActor | null> {
const auth = await authenticateAccessToken(
request.headers.authorization,
options.authRepository,
options.jwtSecret
);
if (!auth) {
reply.status(401).send({
code: 'AUTH_SESSION_INVALID', message: 'Authentication required.', traceId: request.traceId
});
return null;
}
const access = await options.accessControl.getAccessProfile(auth.session.tenantId, auth.session.user.id);
const permission = mode === 'read' ? 'cleaning.task.read' : 'cleaning.task.write';
if (!access.capabilities.includes(permission)
&& !access.capabilities.includes('tenant.manage')
&& !access.roles.includes('PLATFORM_ADMIN')) {
reply.status(403).send({
code: 'CLEANING_TASK_FORBIDDEN',
message: 'Cleaning task permission is required.',
traceId: request.traceId
});
return null;
}
return {
tenantId: auth.session.tenantId,
userId: auth.session.user.id,
access,
traceId: request.traceId
};
}
async function handle(reply: FastifyReply, traceId: string, work: () => Promise<unknown>) {
try {
return await work();
} catch (error) {
if (!(error instanceof CleaningTaskError)) throw error;
const statusCode = error.code === 'CLEANING_TASK_FORBIDDEN' ? 403 : 409;
return reply.status(statusCode).send({
code: error.code,
message: 'The cleaning task request cannot be completed.',
traceId
});
}
}
function invalid(reply: FastifyReply, traceId: string) {
return reply.status(400).send({
code: 'INVALID_CLEANING_TASK_REQUEST',
message: 'The cleaning task request is invalid.',
traceId
});
}
+7
View File
@@ -37,6 +37,7 @@ import { MemberProfileService } from './wallets/member-profile-service.js';
import { RechargeService } from './wallets/recharge-service.js'; import { RechargeService } from './wallets/recharge-service.js';
import { WalletLedgerService } from './wallets/wallet-ledger-service.js'; import { WalletLedgerService } from './wallets/wallet-ledger-service.js';
import { MarketingBenefitService } from './wallets/marketing-benefit-service.js'; import { MarketingBenefitService } from './wallets/marketing-benefit-service.js';
import { CleaningTaskRepository } from './cleaning/cleaning-task-repository.js';
const config = loadConfig(); const config = loadConfig();
const pool = createMySqlPool(config); const pool = createMySqlPool(config);
@@ -184,6 +185,12 @@ const app = await buildApp({
authRepository, authRepository,
accessControl, accessControl,
jwtSecret: config.auth.jwtSecret jwtSecret: config.auth.jwtSecret
},
cleaning: {
repository: new CleaningTaskRepository(pool),
authRepository,
accessControl,
jwtSecret: config.auth.jwtSecret
} }
}); });
app.addHook('onClose', async () => { app.addHook('onClose', async () => {
+160
View File
@@ -0,0 +1,160 @@
import assert from 'node:assert/strict';
import { buildApp } from '../dist/app.js';
import { signAccessToken } from '../dist/auth/jwt.js';
const secret = 'test-only-cleaning-route-secret';
const token = signAccessToken({
sub: '31', sid: '9c4d3af8-c63c-4edb-bf95-b84127bb3f6e',
tid: '7', aid: '9', rv: 1
}, secret, 900);
const forbiddenToken = signAccessToken({
sub: '32', sid: '8c4d3af8-c63c-4edb-bf95-b84127bb3f6e',
tid: '7', aid: '9', rv: 1
}, secret, 900);
const calls = [];
const app = await buildApp({
cleaning: {
jwtSecret: secret,
authRepository: {
async validateSession(sessionId) {
return {
id: sessionId,
tenantId: '7',
platformAppId: '9',
expiresAt: new Date(Date.now() + 60000),
user: {
id: sessionId.startsWith('8') ? '32' : '31',
tenantId: '7',
userType: 'CUSTOMER',
status: 'ACTIVE',
roleVersion: 1,
nickname: '',
avatarUrl: '',
phone: ''
}
};
}
},
accessControl: {
async getAccessProfile(tenantId, userId) {
return userId === '31'
? {
roles: ['CLEANER'],
capabilities: ['cleaning.task.read', 'cleaning.task.write', 'cleaning.statistics.read'],
storeIds: ['11']
}
: { roles: ['CUSTOMER'], capabilities: ['profile.read'], storeIds: [] };
}
},
repository: {
async listHall(input) {
calls.push(['listHall', input]);
return { items: [task('WAITING')], total: 1, page: input.page, pageSize: input.pageSize };
},
async listMine(input) {
calls.push(['listMine', input]);
return { items: [task('CLAIMED')], total: 1, page: input.page, pageSize: input.pageSize };
},
async claim(input) {
calls.push(['claim', input]);
return task('CLAIMED');
},
async start(input) {
calls.push(['start', input]);
return task('STARTED');
},
async submit(input) {
calls.push(['submit', input]);
return { ...task('SUBMITTED'), photoUrls: input.photoUrls };
},
async stats(input) {
calls.push(['stats', input]);
return { byStatus: { SUBMITTED: 2 }, pendingSettlementCents: 1200 };
}
}
}
});
const hall = await app.inject({
method: 'GET',
url: '/app-api/cleaning/tasks/hall?page=1&pageSize=10',
headers: { authorization: `Bearer ${token}` }
});
assert.equal(hall.statusCode, 200);
assert.equal(hall.json().data.items[0].status, 'WAITING');
assert.equal(calls.at(-1)[1].tenantId, '7');
assert.equal(calls.at(-1)[1].userId, '31');
const mine = await app.inject({
method: 'GET',
url: '/app-api/cleaning/tasks/mine?status=CLAIMED',
headers: { authorization: `Bearer ${token}` }
});
assert.equal(mine.statusCode, 200);
assert.equal(calls.at(-1)[0], 'listMine');
assert.equal(calls.at(-1)[1].status, 'CLAIMED');
const claim = await app.inject({
method: 'POST',
url: '/app-api/cleaning/tasks/101/claim',
headers: { authorization: `Bearer ${token}` }
});
assert.equal(claim.statusCode, 200);
assert.equal(calls.at(-1)[0], 'claim');
assert.equal(calls.at(-1)[1].taskId, '101');
const start = await app.inject({
method: 'POST',
url: '/app-api/cleaning/tasks/101/start',
headers: { authorization: `Bearer ${token}` }
});
assert.equal(start.statusCode, 200);
assert.equal(calls.at(-1)[0], 'start');
const submit = await app.inject({
method: 'POST',
url: '/app-api/cleaning/tasks/101/submit',
headers: { authorization: `Bearer ${token}` },
payload: { photoUrls: ['https://api.txyundm.cn/uploads/cleaning/101.jpg'], note: 'ok' }
});
assert.equal(submit.statusCode, 200);
assert.deepEqual(calls.at(-1)[1].photoUrls, ['https://api.txyundm.cn/uploads/cleaning/101.jpg']);
const stats = await app.inject({
method: 'GET',
url: '/app-api/cleaning/stats',
headers: { authorization: `Bearer ${token}` }
});
assert.equal(stats.statusCode, 200);
assert.equal(stats.json().data.pendingSettlementCents, 1200);
const unauthorized = await app.inject({ method: 'GET', url: '/app-api/cleaning/tasks/hall' });
assert.equal(unauthorized.statusCode, 401);
const forbidden = await app.inject({
method: 'GET',
url: '/app-api/cleaning/tasks/hall',
headers: { authorization: `Bearer ${forbiddenToken}` }
});
assert.equal(forbidden.statusCode, 403);
await app.close();
console.log('PASS: M08-B cleaning routes authenticate and forward cleaner task workflows.');
function task(status) {
return {
id: '101',
taskNo: 'CLN-20260625-0001',
storeId: '11',
storeName: 'Test Store',
roomId: '21',
roomName: 'A Room',
roomNo: 'A01',
orderId: '301',
orderNo: 'O301',
status,
rewardCents: 600,
photoUrls: []
};
}
+21 -1
View File
@@ -81,6 +81,9 @@ const benefitVerifySql = read('database/migrations/2026062423_m07c_benefits.veri
const rechargeWechatUpSql = read('database/migrations/2026062524_m08a_recharge_wechat.up.sql'); const rechargeWechatUpSql = read('database/migrations/2026062524_m08a_recharge_wechat.up.sql');
const rechargeWechatDownSql = read('database/migrations/2026062524_m08a_recharge_wechat.down.sql'); const rechargeWechatDownSql = read('database/migrations/2026062524_m08a_recharge_wechat.down.sql');
const rechargeWechatVerifySql = read('database/migrations/2026062524_m08a_recharge_wechat.verify.sql'); const rechargeWechatVerifySql = read('database/migrations/2026062524_m08a_recharge_wechat.verify.sql');
const cleaningUpSql = read('database/migrations/2026062525_m08b_cleaner_tasks.up.sql');
const cleaningDownSql = read('database/migrations/2026062525_m08b_cleaner_tasks.down.sql');
const cleaningVerifySql = read('database/migrations/2026062525_m08b_cleaner_tasks.verify.sql');
const coreTables = [ const coreTables = [
'qipai_schema_migrations', 'qipai_schema_migrations',
@@ -378,4 +381,21 @@ assert.match(rechargeWechatDownSql, /DROP COLUMN provider_payment_id/);
assert.match(rechargeWechatVerifySql, /'provider_payment_id'/); assert.match(rechargeWechatVerifySql, /'provider_payment_id'/);
assert.match(rechargeWechatVerifySql, /'uq_qipai_recharge_provider_callback'/); assert.match(rechargeWechatVerifySql, /'uq_qipai_recharge_provider_callback'/);
console.log('PASS: M01-B through M08-A migration contracts are present.'); for (const table of ['qipai_cleaning_tasks', 'qipai_cleaning_task_events']) {
assert.match(cleaningUpSql, new RegExp(`CREATE TABLE IF NOT EXISTS ${table}`));
assert.match(cleaningDownSql, new RegExp(`DROP TABLE IF EXISTS ${table}`));
assert.match(cleaningVerifySql, new RegExp(`'${table}'`));
}
for (const status of [
'WAITING', 'CLAIMED', 'STARTED', 'SUBMITTED', 'COMPLETED',
'REJECTED', 'EXEMPT', 'SETTLED', 'CANCELLED'
]) {
assert.match(cleaningUpSql, new RegExp(status));
}
assert.match(cleaningUpSql, /cleaner_user_id BIGINT UNSIGNED NULL/);
assert.match(cleaningUpSql, /photo_urls_json JSON NOT NULL/);
assert.match(cleaningUpSql, /uq_qipai_cleaning_task_order/);
assert.match(cleaningUpSql, /cleaning\.task\.write/);
assert.match(cleaningUpSql, /cleaning\.statistics\.read/);
console.log('PASS: M01-B through M08-B migration contracts are present.');
+2 -1
View File
@@ -34,7 +34,8 @@ assert.match(plan.file, /2026062219_m06b_device_topology\.up\.sql/);
assert.match(plan.file, /2026062220_m06c_iot_messages\.up\.sql/); assert.match(plan.file, /2026062220_m06c_iot_messages\.up\.sql/);
assert.match(plan.file, /2026062421_m07a_wallet_ledger\.up\.sql/); assert.match(plan.file, /2026062421_m07a_wallet_ledger\.up\.sql/);
assert.match(plan.file, /2026062422_m07b_recharge_plans\.up\.sql/); assert.match(plan.file, /2026062422_m07b_recharge_plans\.up\.sql/);
assert.match(plan.file, /2026062524_m08a_recharge_wechat\.up\.sql$/); assert.match(plan.file, /2026062524_m08a_recharge_wechat\.up\.sql/);
assert.match(plan.file, /2026062525_m08b_cleaner_tasks\.up\.sql$/);
assert.match(plan.checksum, /^[a-f0-9]{64}$/); assert.match(plan.checksum, /^[a-f0-9]{64}$/);
assert.ok(plan.statements.length >= 11); assert.ok(plan.statements.length >= 11);
@@ -0,0 +1,12 @@
DELETE rp FROM qipai_role_permissions rp
INNER JOIN qipai_permissions p ON p.id = rp.permission_id
WHERE p.code IN ('cleaning.task.write', 'cleaning.statistics.read');
DELETE FROM qipai_permissions
WHERE code IN ('cleaning.task.write', 'cleaning.statistics.read');
DROP TABLE IF EXISTS qipai_cleaning_task_events;
DROP TABLE IF EXISTS qipai_cleaning_tasks;
DELETE FROM qipai_schema_migrations
WHERE version = '2026062525';
@@ -0,0 +1,76 @@
CREATE TABLE IF NOT EXISTS qipai_cleaning_tasks (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
tenant_id BIGINT UNSIGNED NOT NULL,
store_id BIGINT UNSIGNED NOT NULL,
room_id BIGINT UNSIGNED NOT NULL,
order_id BIGINT UNSIGNED NULL,
task_no VARCHAR(64) NOT NULL,
status VARCHAR(32) NOT NULL DEFAULT 'WAITING',
cleaner_user_id BIGINT UNSIGNED NULL,
priority TINYINT UNSIGNED NOT NULL DEFAULT 5,
reward_cents INT UNSIGNED NOT NULL DEFAULT 0,
requirement VARCHAR(512) NOT NULL DEFAULT '',
photo_urls_json JSON NOT NULL,
reject_reason VARCHAR(512) NOT NULL DEFAULT '',
claimed_at DATETIME(3) NULL,
started_at DATETIME(3) NULL,
submitted_at DATETIME(3) NULL,
completed_at DATETIME(3) NULL,
settled_at DATETIME(3) NULL,
cancelled_at DATETIME(3) NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
deleted_at DATETIME(3) NULL,
CONSTRAINT fk_qipai_cleaning_tasks_tenant FOREIGN KEY (tenant_id) REFERENCES qipai_tenants(id),
CONSTRAINT fk_qipai_cleaning_tasks_store FOREIGN KEY (store_id) REFERENCES qipai_stores(id),
CONSTRAINT fk_qipai_cleaning_tasks_room FOREIGN KEY (room_id) REFERENCES qipai_rooms(id),
CONSTRAINT fk_qipai_cleaning_tasks_order FOREIGN KEY (order_id) REFERENCES qipai_orders(id),
CONSTRAINT fk_qipai_cleaning_tasks_cleaner FOREIGN KEY (cleaner_user_id) REFERENCES qipai_users(id),
CONSTRAINT chk_qipai_cleaning_task_status CHECK (
status IN (
'WAITING', 'CLAIMED', 'STARTED', 'SUBMITTED', 'COMPLETED',
'REJECTED', 'EXEMPT', 'SETTLED', 'CANCELLED'
)
),
UNIQUE KEY uq_qipai_cleaning_task_no (tenant_id, task_no),
UNIQUE KEY uq_qipai_cleaning_task_order (tenant_id, order_id),
KEY idx_qipai_cleaning_hall (tenant_id, store_id, status, priority, created_at),
KEY idx_qipai_cleaning_cleaner (tenant_id, cleaner_user_id, status, updated_at),
KEY idx_qipai_cleaning_room (tenant_id, room_id, status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS qipai_cleaning_task_events (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
tenant_id BIGINT UNSIGNED NOT NULL,
task_id BIGINT UNSIGNED NOT NULL,
from_status VARCHAR(32) NULL,
to_status VARCHAR(32) NOT NULL,
action VARCHAR(32) NOT NULL,
actor_id BIGINT UNSIGNED NOT NULL,
trace_id VARCHAR(128) NOT NULL,
note VARCHAR(512) NOT NULL DEFAULT '',
metadata JSON NOT NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
CONSTRAINT fk_qipai_cleaning_events_tenant FOREIGN KEY (tenant_id) REFERENCES qipai_tenants(id),
CONSTRAINT fk_qipai_cleaning_events_task FOREIGN KEY (task_id) REFERENCES qipai_cleaning_tasks(id),
UNIQUE KEY uq_qipai_cleaning_event_trace (tenant_id, task_id, trace_id),
KEY idx_qipai_cleaning_events_task (tenant_id, task_id, id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
INSERT IGNORE INTO qipai_permissions (code, name, category) VALUES
('cleaning.task.write', '处理保洁任务', 'cleaning'),
('cleaning.statistics.read', '查看保洁统计', 'cleaning');
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 (r.code = 'CLEANER' AND p.code IN (
'cleaning.task.read', 'cleaning.task.write', 'cleaning.statistics.read'
))
OR (r.code IN ('STORE_ADMIN', 'TENANT_ADMIN', 'PLATFORM_ADMIN')
AND p.code IN ('cleaning.task.read', 'cleaning.task.write', 'cleaning.statistics.read'))
WHERE r.status = 'ACTIVE' AND r.deleted_at IS NULL;
INSERT IGNORE INTO qipai_schema_migrations (version, name)
VALUES ('2026062525', 'm08b_cleaner_tasks');
@@ -0,0 +1,30 @@
SELECT table_name
FROM information_schema.tables
WHERE table_schema = DATABASE()
AND table_name IN ('qipai_cleaning_tasks', 'qipai_cleaning_task_events');
SELECT column_name
FROM information_schema.columns
WHERE table_schema = DATABASE()
AND table_name = 'qipai_cleaning_tasks'
AND column_name IN (
'task_no', 'status', 'cleaner_user_id', 'reward_cents',
'photo_urls_json', 'reject_reason', 'started_at', 'submitted_at'
);
SELECT index_name
FROM information_schema.statistics
WHERE table_schema = DATABASE()
AND table_name = 'qipai_cleaning_tasks'
AND index_name IN (
'uq_qipai_cleaning_task_no', 'uq_qipai_cleaning_task_order',
'idx_qipai_cleaning_hall', 'idx_qipai_cleaning_cleaner'
);
SELECT code
FROM qipai_permissions
WHERE code IN ('cleaning.task.read', 'cleaning.task.write', 'cleaning.statistics.read');
SELECT version
FROM qipai_schema_migrations
WHERE version = '2026062525';
+1
View File
@@ -8,6 +8,7 @@
"pages/profile/index", "pages/profile/index",
"pages/benefits/index", "pages/benefits/index",
"pages/recharge/index", "pages/recharge/index",
"pages/cleaner/tasks",
"pages/logs/logs" "pages/logs/logs"
], ],
"window": { "window": {
+108
View File
@@ -0,0 +1,108 @@
const { request, ensureLogin, cents } = require('../../utils/api.js')
Page({
data: {
loading: false,
errorMessage: '',
activeTab: 'hall',
hallTasks: [],
myTasks: [],
stats: { byStatus: {}, pendingSettlementText: cents(0) },
submitPhotoText: '',
},
onShow() {
this.refreshAll()
},
async refreshAll() {
this.setData({ loading: true, errorMessage: '' })
try {
await ensureLogin()
const [hall, mine, stats] = await Promise.all([
request('/cleaning/tasks/hall?page=1&pageSize=20'),
request('/cleaning/tasks/mine?page=1&pageSize=20'),
request('/cleaning/stats'),
])
this.setData({
hallTasks: (hall.data.items || []).map(formatTask),
myTasks: (mine.data.items || []).map(formatTask),
stats: formatStats(stats.data),
})
} catch (error) {
this.setData({ errorMessage: error.message || '保洁任务加载失败' })
} finally {
this.setData({ loading: false })
}
},
switchTab(event) {
const tab = event.currentTarget.dataset.tab
if (!tab || tab === this.data.activeTab) return
this.setData({ activeTab: tab })
},
async claimTask(event) {
await this.mutateTask(event.currentTarget.dataset.taskId, 'claim')
},
async startTask(event) {
await this.mutateTask(event.currentTarget.dataset.taskId, 'start')
},
onPhotoTextInput(event) {
this.setData({ submitPhotoText: event.detail.value })
},
async submitTask(event) {
const photoUrls = this.data.submitPhotoText
.split('\n')
.map((item) => item.trim())
.filter(Boolean)
.slice(0, 9)
await this.mutateTask(event.currentTarget.dataset.taskId, 'submit', { photoUrls })
this.setData({ submitPhotoText: '' })
},
async mutateTask(taskId, action, data = {}) {
if (!taskId) return
this.setData({ loading: true, errorMessage: '' })
try {
await ensureLogin()
await request(`/cleaning/tasks/${encodeURIComponent(taskId)}/${action}`, {
method: 'POST',
data,
})
await this.refreshAll()
} catch (error) {
this.setData({ errorMessage: error.message || '保洁任务处理失败' })
} finally {
this.setData({ loading: false })
}
},
})
function formatTask(task) {
return {
...task,
rewardText: cents(task.rewardCents),
timeText: formatDate(task.updatedAt || task.createdAt),
canClaim: task.status === 'WAITING',
canStart: task.status === 'CLAIMED',
canSubmit: task.status === 'STARTED',
}
}
function formatStats(stats) {
return {
byStatus: stats.byStatus || {},
pendingSettlementText: cents(stats.pendingSettlementCents),
}
}
function formatDate(value) {
if (!value) return ''
const date = new Date(value)
const pad = (input) => String(input).padStart(2, '0')
return `${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`
}
+3
View File
@@ -0,0 +1,3 @@
{
"navigationBarTitleText": "保洁任务"
}
+57
View File
@@ -0,0 +1,57 @@
<scroll-view class="scrollarea" scroll-y type="list">
<view class="page">
<view class="header">
<view>
<view class="title">保洁任务</view>
<view class="muted">待结算 {{stats.pendingSettlementText}}</view>
</view>
<button size="mini" loading="{{loading}}" bindtap="refreshAll">刷新</button>
</view>
<view class="tabs">
<button class="{{activeTab === 'hall' ? 'tab active' : 'tab'}}" data-tab="hall" bindtap="switchTab">大厅</button>
<button class="{{activeTab === 'mine' ? 'tab active' : 'tab'}}" data-tab="mine" bindtap="switchTab">我的</button>
</view>
<view class="stats">
<text>已抢 {{stats.byStatus.CLAIMED || 0}}</text>
<text>进行 {{stats.byStatus.STARTED || 0}}</text>
<text>待验 {{stats.byStatus.SUBMITTED || 0}}</text>
<text>完成 {{stats.byStatus.COMPLETED || 0}}</text>
</view>
<view wx:if="{{errorMessage}}" class="error">{{errorMessage}}</view>
<block wx:if="{{activeTab === 'hall'}}">
<view wx:if="{{!loading && hallTasks.length === 0}}" class="empty">暂无可接任务</view>
<view wx:for="{{hallTasks}}" wx:key="id" class="task-card">
<view class="task-title">{{item.storeName}} · {{item.roomName}}</view>
<view class="muted">任务号 {{item.taskNo}}</view>
<view class="muted">{{item.requirement || '常规保洁'}}</view>
<view class="row">
<text class="status">{{item.status}}</text>
<text class="amount">{{item.rewardText}}</text>
</view>
<button wx:if="{{item.canClaim}}" data-task-id="{{item.id}}" bindtap="claimTask">接单</button>
</view>
</block>
<block wx:if="{{activeTab === 'mine'}}">
<view wx:if="{{!loading && myTasks.length === 0}}" class="empty">暂无我的任务</view>
<view wx:for="{{myTasks}}" wx:key="id" class="task-card">
<view class="task-title">{{item.storeName}} · {{item.roomName}}</view>
<view class="muted">任务号 {{item.taskNo}}</view>
<view class="muted">{{item.timeText}}</view>
<view class="row">
<text class="status">{{item.status}}</text>
<text class="amount">{{item.rewardText}}</text>
</view>
<button wx:if="{{item.canStart}}" data-task-id="{{item.id}}" bindtap="startTask">开始</button>
<view wx:if="{{item.canSubmit}}" class="submit-box">
<textarea value="{{submitPhotoText}}" bindinput="onPhotoTextInput" placeholder="每行一个照片 URL" />
<button data-task-id="{{item.id}}" bindtap="submitTask">提交验收</button>
</view>
</view>
</block>
</view>
</scroll-view>
+106
View File
@@ -0,0 +1,106 @@
.scrollarea {
height: 100vh;
background: #f5f6f8;
}
.page {
padding: 32rpx;
}
.header,
.row,
.stats {
display: flex;
align-items: center;
justify-content: space-between;
}
.title {
font-size: 40rpx;
font-weight: 600;
}
.muted {
margin-top: 8rpx;
color: #667085;
font-size: 26rpx;
}
.tabs {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16rpx;
margin: 28rpx 0 20rpx;
}
.tab {
border: 1rpx solid #d0d5dd;
background: #ffffff;
color: #344054;
}
.active {
border-color: #1f6feb;
background: #eaf2ff;
color: #1f6feb;
}
.stats {
padding: 20rpx 24rpx;
border-radius: 16rpx;
background: #ffffff;
color: #344054;
font-size: 24rpx;
}
.task-card {
margin-top: 20rpx;
padding: 28rpx;
border-radius: 16rpx;
background: #ffffff;
}
.task-title {
font-size: 32rpx;
font-weight: 600;
}
.row {
margin: 18rpx 0;
}
.status {
color: #1f6feb;
font-weight: 600;
}
.amount {
color: #b42318;
font-weight: 700;
}
.submit-box {
margin-top: 16rpx;
}
textarea {
box-sizing: border-box;
width: 100%;
min-height: 144rpx;
margin-bottom: 16rpx;
padding: 18rpx;
border: 1rpx solid #d0d5dd;
border-radius: 12rpx;
background: #f9fafb;
}
.error {
margin: 20rpx 0;
color: #c73535;
}
.empty {
padding: 80rpx 0;
color: #888888;
text-align: center;
}
+21 -2
View File
@@ -14,7 +14,8 @@ for (const page of [
'pages/orders/detail', 'pages/orders/detail',
'pages/profile/index', 'pages/profile/index',
'pages/benefits/index', 'pages/benefits/index',
'pages/recharge/index' 'pages/recharge/index',
'pages/cleaner/tasks'
]) { ]) {
assert.ok(appJson.pages.includes(page), `${page} must be registered`); assert.ok(appJson.pages.includes(page), `${page} must be registered`);
} }
@@ -116,4 +117,22 @@ for (const pattern of [
assert.match(recharge, new RegExp(pattern.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))); assert.match(recharge, new RegExp(pattern.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')));
} }
console.log('PASS: M08-A miniapp customer pages use fixed domain and real app-api calls.'); const cleaner = read('miniapp/pages/cleaner/tasks.js')
+ read('miniapp/pages/cleaner/tasks.wxml')
+ read('miniapp/pages/cleaner/tasks.json');
for (const pattern of [
'/cleaning/tasks/hall',
'/cleaning/tasks/mine',
'/cleaning/stats',
'/cleaning/tasks/${encodeURIComponent(taskId)}/${action}',
'claimTask',
'startTask',
'submitTask',
'photoUrls',
'pendingSettlementText',
'保洁任务'
]) {
assert.match(cleaner, new RegExp(pattern.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')));
}
console.log('PASS: M08-A/M08-B miniapp customer and cleaner pages use fixed domain and real app-api calls.');