@@ -240,6 +254,7 @@ import { computed, reactive, ref, watch } from 'vue';
import { ElMessage } from 'element-plus';
import {
ClipboardCheck,
+ ClipboardList,
Download,
LayoutDashboard,
ListTodo,
@@ -258,6 +273,7 @@ import CleanersPanel from './components/CleanersPanel.vue';
import CleaningFieldHandoffPanel from './components/CleaningFieldHandoffPanel.vue';
import OperationsOverviewPanel from './components/OperationsOverviewPanel.vue';
import StoresRoomsPanel from './components/StoresRoomsPanel.vue';
+import OrdersPanel from './components/OrdersPanel.vue';
import {
ApiError,
assignCleaningTask,
@@ -294,7 +310,7 @@ import { money } from './format';
const savedToken = ref(localStorage.getItem('qipai.admin.token') || '');
const tokenDraft = ref(savedToken.value);
-const activeModule = ref<'overview' | 'stores' | 'cleaning'>('overview');
+const activeModule = ref<'overview' | 'stores' | 'orders' | 'cleaning'>('overview');
const activeTab = ref('tasks');
const lastError = ref('');
const lastMessage = ref('');
@@ -306,6 +322,7 @@ const session = computed(() => ({ token: savedToken.value }));
const activeModuleMeta = computed(() => ({
overview: { stage: 'M08-D', title: '平台运营总览' },
stores: { stage: 'M08-D', title: '门店与房间管理' },
+ orders: { stage: 'M08-D', title: '订单筛选与运营处置' },
cleaning: { stage: 'M08-B', title: '保洁任务与结算' }
})[activeModule.value]);
const loading = reactive({ tasks: false, settlements: false, statistics: false, cleaners: false });
diff --git a/admin/src/api.ts b/admin/src/api.ts
index 0897dfa..bdba352 100644
--- a/admin/src/api.ts
+++ b/admin/src/api.ts
@@ -7,9 +7,13 @@ import type {
CleaningTaskMember,
BusinessStatistics,
ManagedRoom,
+ ManagedOrder,
ManagedStore,
ManagedUser,
PageResult,
+ OrderAction,
+ OrderHistoryItem,
+ OrderStatus,
PayoutStateFilter,
StaffRole,
SettlementStatus,
@@ -108,6 +112,64 @@ export function addManagedRoomDisabledPeriod(
});
}
+export function listManagedOrders(
+ session: ApiSession,
+ input: { page: number; pageSize: number; status?: OrderStatus; storeId?: string }
+) {
+ const params = new URLSearchParams({ page: String(input.page), pageSize: String(input.pageSize) });
+ if (input.status) params.set('status', input.status);
+ if (input.storeId) params.set('storeId', input.storeId);
+ return request
>(session, `/orders?${params}`);
+}
+
+export function getManagedOrder(session: ApiSession, orderId: string) {
+ return request(session, `/orders/${orderId}`);
+}
+
+export function listManagedOrderHistory(session: ApiSession, orderId: string) {
+ return request(session, `/orders/${orderId}/history`);
+}
+
+export function executeManagedOrderAction(
+ session: ApiSession, orderId: string, action: OrderAction, reason: string
+) {
+ return request<{ orderId: string; status: OrderStatus }>(session, `/orders/${orderId}/actions`, {
+ method: 'POST', body: JSON.stringify({ action, reason })
+ });
+}
+
+export function addManagedOrderNote(session: ApiSession, orderId: string, note: string) {
+ return request<{ orderId: string }>(session, `/orders/${orderId}/note`, {
+ method: 'POST', body: JSON.stringify({ note })
+ });
+}
+
+export function adjustManagedOrderTime(
+ session: ApiSession, orderId: string,
+ input: { startAt?: string; endAt?: string; reason: string }
+) {
+ return request<{ orderId: string }>(session, `/orders/${orderId}/adjust-time`, {
+ method: 'POST', body: JSON.stringify(input)
+ });
+}
+
+export function renewManagedOrder(
+ session: ApiSession, orderId: string,
+ input: { endAt: string; pricingPolicy: 'CURRENT' | 'LOCKED'; reason: string }
+) {
+ return request<{ orderId: string }>(session, `/orders/${orderId}/renew`, {
+ method: 'POST', body: JSON.stringify(input)
+ });
+}
+
+export function changeManagedOrderRoom(
+ session: ApiSession, orderId: string, input: { roomId: string; reason: string }
+) {
+ return request<{ orderId: string }>(session, `/orders/${orderId}/change-room`, {
+ method: 'POST', body: JSON.stringify(input)
+ });
+}
+
export function getBusinessStatistics(
session: ApiSession,
input: { storeId: string; from: string; to: string }
diff --git a/admin/src/components/OrdersPanel.vue b/admin/src/components/OrdersPanel.vue
new file mode 100644
index 0000000..200f695
--- /dev/null
+++ b/admin/src/components/OrdersPanel.vue
@@ -0,0 +1,308 @@
+
+
+
+
+
+
+
+
+ 当前筛选总数{{ orders.total }}
+ 当前页实收{{ money(pagePaidCents) }}
+ 当前页待支付{{ pendingCount }}
+ 当前页进行中{{ activeCount }}
+
+
+
+
+
+ {{ row.orderNo }}#{{ row.id }} · {{ formatDate(row.createdAt) }}
+
+
+
+ {{ row.storeName }}{{ row.roomNo }} · {{ row.roomName }}
+
+
+ {{ formatDate(row.startAt) }}至 {{ formatDate(row.endAt) }}
+
+
+ {{ money(row.paidAmountCents) }}应收 {{ money(row.totalAmountCents) }}
+
+
+ {{ statusLabel(row.status) }}
+
+
+
+
+ 详情
+
+ 状态处置
+ {{ actionLabel(action) }}
+
+ 备注
+ 调整
+
+
+
+
+
+
+
+
+
+
+
+ 门店{{ detail.storeName }}
+ 房间{{ detail.roomNo }} · {{ detail.roomName }}
+ 开始{{ formatDate(detail.startAt) }}
+ 结束{{ formatDate(detail.endAt) }}
+ 应收{{ money(detail.totalAmountCents) }}
+ 实收{{ money(detail.paidAmountCents) }}
+ 支付渠道{{ detail.latestPayment?.provider || '-' }}
+ 支付状态{{ detail.latestPayment?.status || '-' }}
+
+
状态流水
{{ history.length }} 条记录
刷新
+
+
+ {{ actionLabel(item.action) }} · {{ statusLabel(item.toStatus) }}{{ item.fromStatus ? `${statusLabel(item.fromStatus)} → ` : '' }}{{ statusLabel(item.toStatus) }}{{ item.source }} · {{ item.actorId ? `操作人 ${item.actorId}` : item.actorType }} · {{ item.reason || '无备注' }}{{ item.traceId }}
+
+
+
+
+
+
+
+
+ 取消确认处置
+
+
+
+
+ 取消保存备注
+
+
+
+
+
+
+
+
+ 沿用下单价使用当前价
+
+
+
+
+
+
+ 取消确认调整
+
+
+
+
+
diff --git a/admin/src/styles.css b/admin/src/styles.css
index c5ef9a9..74f0c3a 100644
--- a/admin/src/styles.css
+++ b/admin/src/styles.css
@@ -1325,6 +1325,77 @@ textarea {
align-items: center;
}
+.orders-page,
+.order-detail-stack {
+ display: grid;
+ gap: 14px;
+}
+
+.order-toolbar h3,
+.drawer-section-title h3 {
+ margin: 0;
+}
+
+.order-page-summary {
+ display: grid;
+ grid-template-columns: repeat(4, minmax(0, 1fr));
+ gap: 10px;
+ padding: 12px 14px;
+ border-bottom: 1px solid #e6ebf2;
+ background: #f8fafc;
+}
+
+.order-page-summary span {
+ display: grid;
+ gap: 4px;
+ padding: 10px 12px;
+ background: #fff;
+ border: 1px solid #e1e8f0;
+ border-radius: 8px;
+}
+
+.order-page-summary small {
+ color: #69788c;
+}
+
+.order-page-summary strong {
+ color: #172033;
+ font-size: 18px;
+}
+
+.drawer-section-title {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+ margin-top: 8px;
+ padding-top: 16px;
+ border-top: 1px solid #e6ebf2;
+}
+
+.history-card {
+ display: grid;
+ gap: 4px;
+ padding: 10px 12px;
+ border: 1px solid #e1e8f0;
+ border-radius: 8px;
+ background: #f8fafc;
+}
+
+.history-card span,
+.history-card small {
+ color: #69788c;
+ font-size: 12px;
+}
+
+.history-card code {
+ overflow: hidden;
+ color: #526177;
+ font-size: 11px;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
.el-button {
border-radius: 8px;
}
@@ -1424,6 +1495,10 @@ textarea {
grid-template-columns: repeat(4, minmax(76px, 1fr));
}
+ .order-page-summary {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ }
+
.trend-row {
grid-template-columns: 92px minmax(80px, 1fr);
}
@@ -1524,6 +1599,10 @@ textarea {
grid-column: span 2;
}
+ .order-page-summary {
+ grid-template-columns: 1fr;
+ }
+
.toolbar-actions {
justify-content: flex-end;
}
diff --git a/admin/src/types.ts b/admin/src/types.ts
index a9f5bc4..c61794f 100644
--- a/admin/src/types.ts
+++ b/admin/src/types.ts
@@ -98,6 +98,59 @@ export interface ManagedRoom {
export type RoomInput = Omit;
+export type OrderStatus =
+ | 'DRAFT'
+ | 'PENDING_PAYMENT'
+ | 'PAID'
+ | 'RESERVED'
+ | 'IN_PROGRESS'
+ | 'FINISHED'
+ | 'CANCELLED'
+ | 'REFUNDING'
+ | 'REFUNDED'
+ | 'CLOSED';
+
+export type OrderAction =
+ | 'SUBMIT'
+ | 'CONFIRM_PAYMENT'
+ | 'RESERVE'
+ | 'START'
+ | 'FINISH'
+ | 'CANCEL'
+ | 'BEGIN_REFUND'
+ | 'COMPLETE_REFUND'
+ | 'CLOSE';
+
+export interface ManagedOrder {
+ id: string;
+ orderNo: string;
+ storeId: string;
+ storeName: string;
+ roomId: string;
+ roomName: string;
+ roomNo: string;
+ status: OrderStatus;
+ startAt: string;
+ endAt: string;
+ totalAmountCents: number;
+ paidAmountCents: number;
+ latestPayment: null | { id: string; provider: string; status: string };
+ createdAt: string;
+}
+
+export interface OrderHistoryItem {
+ id: string;
+ fromStatus: OrderStatus | null;
+ toStatus: OrderStatus;
+ action: OrderAction | 'CREATED' | 'EXPIRED' | 'MIGRATED';
+ actorType: string;
+ actorId: string | null;
+ source: string;
+ reason: string;
+ traceId: string;
+ createdAt: string;
+}
+
export interface BusinessStatistics {
storeId: string;
from: string;
diff --git a/backend/src/routes/order-query.ts b/backend/src/routes/order-query.ts
index 7b049fa..ed835f8 100644
--- a/backend/src/routes/order-query.ts
+++ b/backend/src/routes/order-query.ts
@@ -32,29 +32,31 @@ 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
- };
- });
+ for (const prefix of ['/app-api/orders', '/admin-api/orders', '/app-api/management/orders']) {
+ app.get(prefix, 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
- }));
- });
+ app.get(`${prefix}/: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(
diff --git a/backend/src/routes/order-state.ts b/backend/src/routes/order-state.ts
index 98a6db2..3704b6e 100644
--- a/backend/src/routes/order-state.ts
+++ b/backend/src/routes/order-state.ts
@@ -27,19 +27,25 @@ export interface OrderStateRouteOptions {
export async function registerOrderStateRoutes(
app: FastifyInstance, options: OrderStateRouteOptions
) {
- app.get('/app-api/orders/:orderId/history', 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.history(
- auth.tenantId, auth.userId, params.data.orderId, auth.access
- ),
- traceId: request.traceId
- }));
- });
+ for (const path of [
+ '/app-api/orders/:orderId/history',
+ '/admin-api/orders/:orderId/history',
+ '/app-api/management/orders/:orderId/history'
+ ]) {
+ app.get(path, 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.history(
+ auth.tenantId, auth.userId, params.data.orderId, auth.access
+ ),
+ traceId: request.traceId
+ }));
+ });
+ }
app.post('/app-api/orders/:orderId/cancel', async (request, reply) => {
const auth = await authenticate(request.headers.authorization, options);
diff --git a/backend/tests/order-query.test.mjs b/backend/tests/order-query.test.mjs
index 73aa3b8..d15806e 100644
--- a/backend/tests/order-query.test.mjs
+++ b/backend/tests/order-query.test.mjs
@@ -89,6 +89,14 @@ assert.equal(listInput.pageSize, 5);
assert.equal(listInput.status, 'PENDING_PAYMENT');
assert.equal(listInput.storeId, '18');
+const adminListed = await app.inject({
+ method: 'GET',
+ url: '/admin-api/orders?page=1&pageSize=20&storeId=18',
+ headers: { authorization: `Bearer ${token}` }
+});
+assert.equal(adminListed.statusCode, 200);
+assert.equal(listInput.storeId, '18');
+
const detail = await app.inject({
method: 'GET',
url: '/app-api/orders/31',
@@ -97,6 +105,13 @@ const detail = await app.inject({
assert.equal(detail.statusCode, 200);
assert.equal(detailInput.orderId, '31');
+const adminDetail = await app.inject({
+ method: 'GET', url: '/admin-api/orders/31',
+ headers: { authorization: `Bearer ${token}` }
+});
+assert.equal(adminDetail.statusCode, 200);
+assert.equal(detailInput.orderId, '31');
+
const unauthenticated = await app.inject({
method: 'GET',
url: '/app-api/orders'
diff --git a/backend/tests/order-state.test.mjs b/backend/tests/order-state.test.mjs
index 9f256bb..af64939 100644
--- a/backend/tests/order-state.test.mjs
+++ b/backend/tests/order-state.test.mjs
@@ -8,6 +8,7 @@ const token = signAccessToken({
tid: '7', aid: '9', rv: 1
}, secret, 900);
let transitionInput;
+let historyInput;
const options = {
jwtSecret: secret,
authRepository: {
@@ -33,7 +34,10 @@ const options = {
transitionInput = { actor, orderId, action, reason };
return { orderId, status: action === 'CANCEL' ? 'CANCELLED' : 'PAID' };
},
- async history() { return []; }
+ async history(tenantId, userId, orderId, access) {
+ historyInput = { tenantId, userId, orderId, access };
+ return [{ id: '1', fromStatus: null, toStatus: 'DRAFT', action: 'CREATED' }];
+ }
}
};
const app = await buildApp({ orderState: options });
@@ -76,6 +80,15 @@ assert.equal(transitionInput.action, 'START');
assert.equal(transitionInput.actor.source, 'ADMIN');
assert.equal(transitionInput.actor.traceId, 'm08c-route-transition');
+const adminHistory = await app.inject({
+ method: 'GET', url: '/admin-api/orders/31/history',
+ headers: { authorization: `Bearer ${token}` }
+});
+assert.equal(adminHistory.statusCode, 200);
+assert.equal(adminHistory.json().data[0].action, 'CREATED');
+assert.equal(historyInput.orderId, '31');
+assert.equal(historyInput.access.roles[0], 'TENANT_ADMIN');
+
const cancelled = await app.inject({
method: 'POST',
url: '/app-api/orders/31/cancel',
diff --git a/scripts/check-admin-m08-d.mjs b/scripts/check-admin-m08-d.mjs
index b6ce1d8..54cd2cd 100644
--- a/scripts/check-admin-m08-d.mjs
+++ b/scripts/check-admin-m08-d.mjs
@@ -11,8 +11,10 @@ for (const pattern of [
"activeModule = 'overview'",
"activeModule = 'cleaning'",
"activeModule = 'stores'",
+ "activeModule = 'orders'",
'平台运营总览',
'StoresRoomsPanel',
+ 'OrdersPanel',
'运营总览',
'savedToken',
'loadCleaningWorkspace'
@@ -80,6 +82,24 @@ for (const pattern of [
assert.match(stores, new RegExp(pattern));
}
+const orders = read('admin/src/components/OrdersPanel.vue');
+for (const pattern of [
+ '订单筛选与处置',
+ 'listManagedOrders',
+ 'getManagedOrder',
+ 'listManagedOrderHistory',
+ 'executeManagedOrderAction',
+ 'addManagedOrderNote',
+ 'adjustManagedOrderTime',
+ 'renewManagedOrder',
+ 'changeManagedOrderRoom',
+ '导出当前页',
+ '状态流水',
+ 'allowedActions'
+]) {
+ assert.match(orders, new RegExp(pattern));
+}
+
const routes = read('backend/src/routes/business-statistics.ts');
assert.match(routes, /'\/app-api\/management\/statistics', '\/admin-api\/statistics'/);
@@ -91,6 +111,11 @@ assert.match(storeRepository, /wifi_password = COALESCE\(\?, wifi_password\)/);
assert.match(storeRepository, /FROM qipai_store_business_hours/);
assert.match(storeRepository, /wifiConfigured/);
+const orderQueryRoutes = read('backend/src/routes/order-query.ts');
+assert.match(orderQueryRoutes, /'\/admin-api\/orders'/);
+const orderStateRoutes = read('backend/src/routes/order-state.ts');
+assert.match(orderStateRoutes, /'\/admin-api\/orders\/:orderId\/history'/);
+
const styles = read('admin/src/styles.css');
for (const pattern of [
'.overview-page',
@@ -102,6 +127,8 @@ for (const pattern of [
'.store-layout',
'.store-card',
'.hours-editor',
+ '.order-page-summary',
+ '.history-card',
'@media (max-width: 980px)',
'@media (max-width: 560px)'
]) {