diff --git a/backend/package.json b/backend/package.json index 99ce020..106aaf5 100644 --- a/backend/package.json +++ b/backend/package.json @@ -17,7 +17,7 @@ "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", "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-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/marketing-benefit-service.test.mjs && node tests/member-profile-service.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/marketing-benefit-service.test.mjs && node tests/member-profile-service.test.mjs" }, "dependencies": { "@fastify/cors": "^11.2.0", diff --git a/backend/src/app.ts b/backend/src/app.ts index c7eb8ef..992494f 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -33,6 +33,9 @@ import { registerPricingRoutes, type PricingRouteOptions } from './routes/pricin import { registerOrderStateRoutes, type OrderStateRouteOptions } from './routes/order-state.js'; +import { + registerOrderQueryRoutes, type OrderQueryRouteOptions +} from './routes/order-query.js'; import { registerOrderManagementRoutes, type OrderManagementRouteOptions } from './routes/order-management.js'; @@ -58,6 +61,7 @@ export interface BuildAppOptions { storeAccess?: StoreAccessRouteOptions; pricing?: PricingRouteOptions; orderState?: OrderStateRouteOptions; + orderQuery?: OrderQueryRouteOptions; orderManagement?: OrderManagementRouteOptions; orderShare?: OrderShareRouteOptions; payment?: PaymentRouteOptions; @@ -137,6 +141,9 @@ export async function buildApp(options: BuildAppOptions = {}): Promise = [input.tenantId, input.userId]; + if (input.status) { + where.push('o.status = ?'); + params.push(input.status); + } + const whereSql = where.join(' AND '); + const offset = (input.page - 1) * input.pageSize; + const [counts] = await this.pool.execute( + `SELECT COUNT(DISTINCT o.id) AS total + FROM qipai_orders o + LEFT JOIN qipai_order_user_access a + ON a.tenant_id = o.tenant_id AND a.order_id = o.id AND a.revoked_at IS NULL + WHERE ${whereSql}`, + params + ); + const [rows] = await this.pool.execute( + `SELECT DISTINCT o.id, o.order_no AS orderNo, o.store_id AS storeId, s.name AS storeName, + o.room_id AS roomId, r.name AS roomName, r.room_no AS roomNo, + o.status, o.start_at AS startAt, o.end_at AS endAt, + o.total_amount_cents AS totalAmountCents, + o.paid_amount_cents AS paidAmountCents, p.id AS latestPaymentId, + p.provider AS latestPaymentProvider, p.status AS latestPaymentStatus, + o.created_at AS createdAt + FROM qipai_orders o + INNER JOIN qipai_stores s ON s.tenant_id = o.tenant_id AND s.id = o.store_id + INNER JOIN qipai_rooms r ON r.tenant_id = o.tenant_id AND r.id = o.room_id + LEFT JOIN qipai_order_user_access a + ON a.tenant_id = o.tenant_id AND a.order_id = o.id AND a.revoked_at IS NULL + LEFT JOIN qipai_payments p + ON p.tenant_id = o.tenant_id AND p.order_id = o.id + AND p.id = ( + SELECT MAX(p2.id) FROM qipai_payments p2 + WHERE p2.tenant_id = o.tenant_id AND p2.order_id = o.id + AND p2.deleted_at IS NULL + ) + WHERE ${whereSql} + ORDER BY o.created_at DESC, o.id DESC + LIMIT ? OFFSET ?`, + [...params, input.pageSize, offset] + ); + return { + items: rows.map(publicOrder), + total: Number(counts[0]?.total ?? 0), + page: input.page, + pageSize: input.pageSize + }; + } + + async getMine(input: OrderQueryAccess & { orderId: string }) { + const result = await this.listMine({ ...input, page: 1, pageSize: 1 }); + const order = result.items.find((item) => item.id === input.orderId); + if (order) return order; + const [rows] = await this.pool.execute( + `SELECT o.id, o.order_no AS orderNo, o.store_id AS storeId, s.name AS storeName, + o.room_id AS roomId, r.name AS roomName, r.room_no AS roomNo, + o.status, o.start_at AS startAt, o.end_at AS endAt, + o.total_amount_cents AS totalAmountCents, + o.paid_amount_cents AS paidAmountCents, p.id AS latestPaymentId, + p.provider AS latestPaymentProvider, p.status AS latestPaymentStatus, + o.created_at AS createdAt + FROM qipai_orders o + INNER JOIN qipai_stores s ON s.tenant_id = o.tenant_id AND s.id = o.store_id + INNER JOIN qipai_rooms r ON r.tenant_id = o.tenant_id AND r.id = o.room_id + LEFT JOIN qipai_order_user_access a + ON a.tenant_id = o.tenant_id AND a.order_id = o.id AND a.revoked_at IS NULL + LEFT JOIN qipai_payments p + ON p.tenant_id = o.tenant_id AND p.order_id = o.id + AND p.id = ( + SELECT MAX(p2.id) FROM qipai_payments p2 + WHERE p2.tenant_id = o.tenant_id AND p2.order_id = o.id + AND p2.deleted_at IS NULL + ) + WHERE o.tenant_id = ? AND o.id = ? AND o.deleted_at IS NULL + AND (a.user_id = ? OR ${managerScopeSql(input.access, 'o.store_id')}) + LIMIT 1`, + [input.tenantId, input.orderId, input.userId] + ); + if (!rows[0]) throw new OrderQueryError('ORDER_NOT_FOUND'); + return publicOrder(rows[0]); + } +} + +function publicOrder(row: OrderListRow) { + return { + id: String(row.id), + orderNo: row.orderNo, + storeId: String(row.storeId), + storeName: row.storeName, + roomId: String(row.roomId), + roomName: row.roomName, + roomNo: row.roomNo, + status: row.status, + startAt: row.startAt, + endAt: row.endAt, + totalAmountCents: row.totalAmountCents, + paidAmountCents: row.paidAmountCents, + latestPayment: row.latestPaymentId === null ? null : { + id: String(row.latestPaymentId), + provider: row.latestPaymentProvider, + status: row.latestPaymentStatus + }, + createdAt: row.createdAt + }; +} + +function managerScopeSql(access: AccessProfile, storeExpression: string) { + if (access.capabilities.includes('tenant.manage') || access.roles.includes('PLATFORM_ADMIN')) { + return '1 = 1'; + } + if (!access.capabilities.includes('store.operation.read') || access.storeIds.length === 0) { + return '1 = 0'; + } + return `${storeExpression} IN (${access.storeIds.map((id) => Number(id)).join(',')})`; +} diff --git a/backend/src/routes/order-query.ts b/backend/src/routes/order-query.ts new file mode 100644 index 0000000..c5d2f0f --- /dev/null +++ b/backend/src/routes/order-query.ts @@ -0,0 +1,103 @@ +import type { FastifyInstance, FastifyReply } 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 { + OrderQueryError, + type OrderQueryRepository +} from '../orders/order-query-repository.js'; +import { type OrderStatus } from '../orders/order-state-repository.js'; + +const orderStatuses = [ + 'DRAFT', 'PENDING_PAYMENT', 'PAID', 'RESERVED', 'IN_PROGRESS', + 'FINISHED', 'CANCELLED', 'REFUNDING', 'REFUNDED', 'CLOSED' +] as const satisfies readonly OrderStatus[]; +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(orderStatuses).optional() +}); +const paramsSchema = z.object({ orderId: z.string().regex(/^[1-9]\d{0,19}$/) }); + +export interface OrderQueryRouteOptions { + repository: Pick; + authRepository: Pick; + accessControl: { getAccessProfile(tenantId: string, userId: string): Promise }; + jwtSecret: string; +} + +export async function registerOrderQueryRoutes( + app: FastifyInstance, + options: OrderQueryRouteOptions +) { + app.get('/app-api/orders', async (request, reply) => { + const auth = await authenticate(request.headers.authorization, options); + const query = listSchema.safeParse(request.query); + if (!auth) return unauthorized(reply, request.traceId); + if (!query.success) return invalid(reply, request.traceId); + return { + code: 0, + data: await options.repository.listMine({ ...auth, ...query.data }), + traceId: request.traceId + }; + }); + + app.get('/app-api/orders/:orderId', async (request, reply) => { + const auth = await authenticate(request.headers.authorization, options); + const params = paramsSchema.safeParse(request.params); + if (!auth) return unauthorized(reply, request.traceId); + if (!params.success) return invalid(reply, request.traceId); + return handle(reply, request.traceId, async () => ({ + code: 0, + data: await options.repository.getMine({ ...auth, orderId: params.data.orderId }), + traceId: request.traceId + })); + }); +} + +async function authenticate( + authorization: string | undefined, + options: OrderQueryRouteOptions +) { + const result = await authenticateAccessToken( + authorization, options.authRepository, options.jwtSecret + ); + if (!result) return null; + const tenantId = result.session.tenantId; + const userId = result.session.user.id; + return { + tenantId, + userId, + access: await options.accessControl.getAccessProfile(tenantId, userId) + }; +} + +async function handle(reply: FastifyReply, traceId: string, work: () => Promise) { + try { + return await work(); + } catch (error) { + if (!(error instanceof OrderQueryError)) throw error; + return reply.status(404).send({ + code: error.code, + message: 'The requested order is not available.', + traceId + }); + } +} + +function unauthorized(reply: FastifyReply, traceId: string) { + return reply.status(401).send({ + code: 'AUTH_SESSION_INVALID', + message: 'Authentication required.', + traceId + }); +} + +function invalid(reply: FastifyReply, traceId: string) { + return reply.status(400).send({ + code: 'INVALID_ORDER_QUERY', + message: 'The order query is invalid.', + traceId + }); +} diff --git a/backend/src/server.ts b/backend/src/server.ts index 2d3eeea..11b5be0 100644 --- a/backend/src/server.ts +++ b/backend/src/server.ts @@ -16,6 +16,7 @@ import { PricingRepository } from './orders/pricing-repository.js'; import { OrderStateRepository } from './orders/order-state-repository.js'; import { OrderManagementRepository } from './orders/order-management-repository.js'; import { OrderShareRepository } from './orders/order-share-repository.js'; +import { OrderQueryRepository } from './orders/order-query-repository.js'; import { PaymentRepository } from './payments/payment-repository.js'; import { FetchWechatPayTransport, parseWechatPayCredentials, WechatPayClient @@ -100,6 +101,12 @@ const app = await buildApp({ jwtSecret: config.auth.jwtSecret, cancellationPolicy: orderManagementRepository }, + orderQuery: { + repository: new OrderQueryRepository(pool), + authRepository, + accessControl, + jwtSecret: config.auth.jwtSecret + }, orderManagement: { repository: orderManagementRepository, authRepository, diff --git a/backend/tests/order-query.test.mjs b/backend/tests/order-query.test.mjs new file mode 100644 index 0000000..f1554f9 --- /dev/null +++ b/backend/tests/order-query.test.mjs @@ -0,0 +1,113 @@ +import assert from 'node:assert/strict'; +import { buildApp } from '../dist/app.js'; +import { signAccessToken } from '../dist/auth/jwt.js'; + +const secret = 'test-only-order-query-jwt-secret-32'; +const token = signAccessToken({ + sub: '21', + sid: '5c4d3af8-c63c-4edb-bf95-b84127bb3f6e', + tid: '7', + aid: '9', + rv: 1 +}, secret, 900); +const authRepository = { + async validateSession() { + return { + id: '5c4d3af8-c63c-4edb-bf95-b84127bb3f6e', + tenantId: '7', + platformAppId: '9', + expiresAt: new Date(Date.now() + 60000), + user: { + id: '21', + tenantId: '7', + userType: 'CUSTOMER', + status: 'ACTIVE', + roleVersion: 1, + nickname: '', + avatarUrl: '', + phone: '' + } + }; + } +}; +let listInput; +let detailInput; +const app = await buildApp({ + orderQuery: { + jwtSecret: secret, + authRepository, + accessControl: { + async getAccessProfile() { + return { roles: ['CUSTOMER'], capabilities: [], storeIds: [] }; + } + }, + repository: { + async listMine(input) { + listInput = input; + return { + items: [{ + id: '31', + orderNo: 'QP202606240001', + storeName: '近店', + roomName: '青竹房', + status: 'PENDING_PAYMENT', + totalAmountCents: 3600, + latestPayment: null + }], + total: 1, + page: input.page, + pageSize: input.pageSize + }; + }, + async getMine(input) { + detailInput = input; + return { + id: input.orderId, + orderNo: 'QP202606240001', + storeName: '近店', + roomName: '青竹房', + status: 'PENDING_PAYMENT', + totalAmountCents: 3600, + latestPayment: null + }; + } + } + } +}); + +const listed = await app.inject({ + method: 'GET', + url: '/app-api/orders?page=2&pageSize=5&status=PENDING_PAYMENT', + headers: { authorization: `Bearer ${token}` } +}); +assert.equal(listed.statusCode, 200); +assert.equal(listed.json().data.items[0].id, '31'); +assert.equal(listInput.tenantId, '7'); +assert.equal(listInput.userId, '21'); +assert.equal(listInput.page, 2); +assert.equal(listInput.pageSize, 5); +assert.equal(listInput.status, 'PENDING_PAYMENT'); + +const detail = await app.inject({ + method: 'GET', + url: '/app-api/orders/31', + headers: { authorization: `Bearer ${token}` } +}); +assert.equal(detail.statusCode, 200); +assert.equal(detailInput.orderId, '31'); + +const unauthenticated = await app.inject({ + method: 'GET', + url: '/app-api/orders' +}); +assert.equal(unauthenticated.statusCode, 401); + +const invalid = await app.inject({ + method: 'GET', + url: '/app-api/orders?pageSize=999', + headers: { authorization: `Bearer ${token}` } +}); +assert.equal(invalid.statusCode, 400); + +await app.close(); +console.log('PASS: M08-A customer order query routes expose scoped list and detail.'); diff --git a/miniapp/app.json b/miniapp/app.json index 5fc4b67..46c3f8e 100644 --- a/miniapp/app.json +++ b/miniapp/app.json @@ -3,6 +3,8 @@ "pages/index/index", "pages/store/detail", "pages/room/detail", + "pages/orders/list", + "pages/orders/detail", "pages/logs/logs" ], "window": { diff --git a/miniapp/pages/index/index.js b/miniapp/pages/index/index.js index 68ab190..d23a2e6 100644 --- a/miniapp/pages/index/index.js +++ b/miniapp/pages/index/index.js @@ -65,6 +65,10 @@ Page({ wx.navigateTo({ url: `/pages/store/detail?storeId=${encodeURIComponent(storeId)}` }) }, + openOrders() { + wx.navigateTo({ url: '/pages/orders/list' }) + }, + async resolveScene(code, sourceType) { this.setData({ loading: true, errorMessage: '' }) try { diff --git a/miniapp/pages/index/index.wxml b/miniapp/pages/index/index.wxml index 5008df8..3f5a741 100644 --- a/miniapp/pages/index/index.wxml +++ b/miniapp/pages/index/index.wxml @@ -1,6 +1,7 @@ 选择门店 + diff --git a/miniapp/pages/orders/detail.js b/miniapp/pages/orders/detail.js new file mode 100644 index 0000000..5b407d6 --- /dev/null +++ b/miniapp/pages/orders/detail.js @@ -0,0 +1,96 @@ +const { request, ensureLogin, cents } = require('../../utils/api.js') + +Page({ + data: { + orderId: '', + loading: false, + errorMessage: '', + successMessage: '', + order: null, + history: [], + cancellation: null, + }, + + onLoad(options) { + this.setData({ orderId: options.orderId || '' }) + if (options.orderId) this.loadOrder(options.orderId) + }, + + async loadOrder(orderId = this.data.orderId) { + const targetOrderId = typeof orderId === 'string' ? orderId : this.data.orderId + this.setData({ loading: true, errorMessage: '', successMessage: '' }) + try { + await ensureLogin() + const [orderResponse, historyResponse] = await Promise.all([ + request(`/orders/${encodeURIComponent(targetOrderId)}`), + request(`/orders/${encodeURIComponent(targetOrderId)}/history`), + ]) + this.setData({ + order: formatOrder(orderResponse.data), + history: (historyResponse.data || []).map((item) => ({ + ...item, + createdText: formatTime(item.createdAt), + })), + }) + } catch (error) { + this.setData({ errorMessage: error.message || '订单加载失败' }) + } finally { + this.setData({ loading: false }) + } + }, + + async loadCancellationQuote() { + if (!this.data.orderId) return + await this.withOrderRequest(async () => { + const response = await request(`/orders/${encodeURIComponent(this.data.orderId)}/cancellation-quote`) + this.setData({ + cancellation: { + ...response.data, + feeText: cents(response.data.feeCents), + refundableText: cents(response.data.refundableCents), + }, + successMessage: '取消测算已更新', + }) + }) + }, + + async cancelOrder() { + if (!this.data.orderId) return + await this.withOrderRequest(async () => { + await request(`/orders/${encodeURIComponent(this.data.orderId)}/cancel`, { + method: 'POST', + data: { reason: '用户小程序取消' }, + }) + this.setData({ successMessage: '订单已提交取消' }) + await this.loadOrder(this.data.orderId) + }) + }, + + async withOrderRequest(work) { + this.setData({ loading: true, errorMessage: '', successMessage: '' }) + try { + await ensureLogin() + await work() + } catch (error) { + this.setData({ errorMessage: error.message || '订单操作失败' }) + } finally { + this.setData({ loading: false }) + } + }, +}) + +function formatOrder(order) { + return { + ...order, + totalText: cents(order.totalAmountCents), + paidText: cents(order.paidAmountCents), + startText: formatTime(order.startAt), + endText: formatTime(order.endAt), + } +} + +function formatTime(value) { + const date = new Date(value) + const pad = (input) => String(input).padStart(2, '0') + return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}` +} diff --git a/miniapp/pages/orders/detail.json b/miniapp/pages/orders/detail.json new file mode 100644 index 0000000..44b1678 --- /dev/null +++ b/miniapp/pages/orders/detail.json @@ -0,0 +1,3 @@ +{ + "navigationBarTitleText": "订单详情" +} diff --git a/miniapp/pages/orders/detail.wxml b/miniapp/pages/orders/detail.wxml new file mode 100644 index 0000000..554e060 --- /dev/null +++ b/miniapp/pages/orders/detail.wxml @@ -0,0 +1,42 @@ + + + 订单详情 + {{errorMessage}} + {{successMessage}} + + + {{order.storeName}} · {{order.roomName}} + 订单号:{{order.orderNo}} + 房间:{{order.roomNo}} + 开始:{{order.startText}} + 结束:{{order.endText}} + + {{order.status}} + {{order.totalText}} + + + 最近支付:{{order.latestPayment.provider}} / {{order.latestPayment.status}} + + + + + + + + + + + 取消测算 + 是否允许:{{cancellation.allowed ? '允许' : '不允许'}} + 取消费用:{{cancellation.feeText}} + 预计退回:{{cancellation.refundableText}} + 免费取消分钟线:{{cancellation.cutoffMinutes}} + + + 状态历史 + + {{item.action}}:{{item.fromStatus || '-'}} → {{item.toStatus}} + {{item.createdText}} + + + diff --git a/miniapp/pages/orders/detail.wxss b/miniapp/pages/orders/detail.wxss new file mode 100644 index 0000000..012bbf0 --- /dev/null +++ b/miniapp/pages/orders/detail.wxss @@ -0,0 +1,70 @@ +.scrollarea { + height: 100vh; + background: #f5f6f8; +} + +.page { + padding: 32rpx; +} + +.title { + margin-bottom: 24rpx; + font-size: 40rpx; + font-weight: 600; +} + +.card, +.history-item { + margin-top: 20rpx; + padding: 28rpx; + border-radius: 16rpx; + background: #ffffff; +} + +.order-title, +.section-title { + font-size: 32rpx; + font-weight: 600; +} + +.muted { + margin-top: 8rpx; + color: #667085; +} + +.row { + display: flex; + justify-content: space-between; + margin-top: 16rpx; +} + +.status { + color: #1f6feb; + font-weight: 600; +} + +.amount { + color: #b42318; + font-weight: 700; +} + +.action-grid { + display: flex; + flex-wrap: wrap; + gap: 16rpx; + margin-top: 24rpx; +} + +.action-grid button { + flex: 1 1 220rpx; +} + +.success { + margin: 16rpx 0; + color: #17823b; +} + +.error { + margin: 16rpx 0; + color: #c73535; +} diff --git a/miniapp/pages/orders/list.js b/miniapp/pages/orders/list.js new file mode 100644 index 0000000..a282d75 --- /dev/null +++ b/miniapp/pages/orders/list.js @@ -0,0 +1,53 @@ +const { request, ensureLogin, cents } = require('../../utils/api.js') + +Page({ + data: { + loading: false, + errorMessage: '', + orders: [], + page: 1, + pageSize: 20, + total: 0, + }, + + onShow() { + this.loadOrders() + }, + + async loadOrders() { + this.setData({ loading: true, errorMessage: '' }) + try { + await ensureLogin() + const response = await request(`/orders?page=${this.data.page}&pageSize=${this.data.pageSize}`) + this.setData({ + orders: (response.data.items || []).map(formatOrder), + total: response.data.total || 0, + }) + } catch (error) { + this.setData({ errorMessage: error.message || '订单加载失败' }) + } finally { + this.setData({ loading: false }) + } + }, + + openOrder(event) { + const orderId = event.currentTarget.dataset.orderId + if (!orderId) return + wx.navigateTo({ url: `/pages/orders/detail?orderId=${encodeURIComponent(orderId)}` }) + }, +}) + +function formatOrder(order) { + return { + ...order, + totalText: cents(order.totalAmountCents), + paidText: cents(order.paidAmountCents), + timeText: `${formatTime(order.startAt)} 至 ${formatTime(order.endAt)}`, + } +} + +function formatTime(value) { + 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())}` +} diff --git a/miniapp/pages/orders/list.json b/miniapp/pages/orders/list.json new file mode 100644 index 0000000..5c1f617 --- /dev/null +++ b/miniapp/pages/orders/list.json @@ -0,0 +1,3 @@ +{ + "navigationBarTitleText": "我的订单" +} diff --git a/miniapp/pages/orders/list.wxml b/miniapp/pages/orders/list.wxml new file mode 100644 index 0000000..1cf0f79 --- /dev/null +++ b/miniapp/pages/orders/list.wxml @@ -0,0 +1,17 @@ + + + 我的订单 + + {{errorMessage}} + 暂无订单 + + {{item.storeName}} · {{item.roomName}} + {{item.timeText}} + 订单号:{{item.orderNo}} + + {{item.status}} + {{item.totalText}} + + + + diff --git a/miniapp/pages/orders/list.wxss b/miniapp/pages/orders/list.wxss new file mode 100644 index 0000000..0de1105 --- /dev/null +++ b/miniapp/pages/orders/list.wxss @@ -0,0 +1,62 @@ +.scrollarea { + height: 100vh; + background: #f5f6f8; +} + +.page { + padding: 32rpx; +} + +.title { + margin-bottom: 24rpx; + font-size: 40rpx; + font-weight: 600; +} + +.order-card { + margin-top: 20rpx; + padding: 28rpx; + border-radius: 16rpx; + background: #ffffff; +} + +.order-card:active { + background: #eef4ff; +} + +.order-title { + font-size: 32rpx; + font-weight: 600; +} + +.muted { + margin-top: 8rpx; + color: #667085; +} + +.row { + display: flex; + justify-content: space-between; + margin-top: 16rpx; +} + +.status { + color: #1f6feb; + font-weight: 600; +} + +.amount { + color: #b42318; + font-weight: 700; +} + +.error { + margin: 20rpx 0; + color: #c73535; +} + +.empty { + padding: 80rpx 0; + color: #888888; + text-align: center; +} diff --git a/miniapp/pages/room/detail.js b/miniapp/pages/room/detail.js index 0df964b..f56c00e 100644 --- a/miniapp/pages/room/detail.js +++ b/miniapp/pages/room/detail.js @@ -113,6 +113,12 @@ Page({ }) }, + openOrder(event) { + const orderId = event.currentTarget.dataset.orderId + if (!orderId) return + wx.navigateTo({ url: `/pages/orders/detail?orderId=${encodeURIComponent(orderId)}` }) + }, + pricingPayload() { const start = defaultStartAt() return { diff --git a/miniapp/pages/room/detail.wxml b/miniapp/pages/room/detail.wxml index 69a1360..c26935f 100644 --- a/miniapp/pages/room/detail.wxml +++ b/miniapp/pages/room/detail.wxml @@ -29,6 +29,7 @@ 订单 订单编号:{{order.orderId}} 预占编号:{{order.reservationId}} + diff --git a/scripts/check-miniapp-m08-a.mjs b/scripts/check-miniapp-m08-a.mjs index 12c4125..32a5858 100644 --- a/scripts/check-miniapp-m08-a.mjs +++ b/scripts/check-miniapp-m08-a.mjs @@ -9,7 +9,9 @@ const appJson = JSON.parse(read('miniapp/app.json')); for (const page of [ 'pages/index/index', 'pages/store/detail', - 'pages/room/detail' + 'pages/room/detail', + 'pages/orders/list', + 'pages/orders/detail' ]) { assert.ok(appJson.pages.includes(page), `${page} must be registered`); } @@ -41,4 +43,16 @@ for (const route of [ assert.match(room, new RegExp(route.replace('/', '\\/'))); } +const orders = read('miniapp/pages/orders/list.js') + + read('miniapp/pages/orders/detail.js'); +for (const route of [ + '/orders?page=', + '/orders/${encodeURIComponent', + '/history', + '/cancellation-quote', + '/cancel' +]) { + assert.match(orders, new RegExp(route.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))); +} + console.log('PASS: M08-A miniapp customer pages use fixed domain and real app-api calls.');