feat(M08-D): 补后台订单运营

This commit is contained in:
Codex
2026-08-10 11:52:25 +08:00
parent 3502f5b5f7
commit 20a292e4ce
10 changed files with 619 additions and 37 deletions
+18 -1
View File
@@ -37,6 +37,15 @@
<Store :size="18" />
<span>门店房间</span>
</button>
<button
class="nav-item"
:class="{ active: activeModule === 'orders' }"
type="button"
@click="activeModule = 'orders'"
>
<ClipboardList :size="18" />
<span>订单运营</span>
</button>
<button class="nav-item" type="button" disabled>
<RadioTower :size="18" />
<span>设备</span>
@@ -80,6 +89,11 @@
:session="session"
/>
<OrdersPanel
v-else-if="activeModule === 'orders'"
:session="session"
/>
<template v-else>
<section class="metric-grid" aria-label="保洁概览">
<div class="metric">
@@ -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 });
+62
View File
@@ -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<PageResult<ManagedOrder>>(session, `/orders?${params}`);
}
export function getManagedOrder(session: ApiSession, orderId: string) {
return request<ManagedOrder>(session, `/orders/${orderId}`);
}
export function listManagedOrderHistory(session: ApiSession, orderId: string) {
return request<OrderHistoryItem[]>(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 }
+308
View File
@@ -0,0 +1,308 @@
<template>
<section class="orders-page">
<el-alert v-if="lastError" :title="lastError" type="error" show-icon closable @close="lastError = ''" />
<section class="panel">
<header class="panel-toolbar order-toolbar">
<div>
<p class="section-kicker">M08-D · 订单运营</p>
<h3>订单筛选与处置</h3>
</div>
<div class="toolbar-actions">
<el-select v-model="storeId" class="filter-select" filterable placeholder="全部授权门店" @change="applyFilters">
<el-option label="全部授权门店" value="" />
<el-option v-for="store in stores" :key="store.id" :label="store.name" :value="store.id" />
</el-select>
<el-select v-model="status" class="filter-select" placeholder="全部状态" @change="applyFilters">
<el-option label="全部状态" value="" />
<el-option v-for="item in statusOptions" :key="item.value" :label="item.label" :value="item.value" />
</el-select>
<el-button :icon="Download" :disabled="orders.items.length === 0" @click="exportOrders">导出当前页</el-button>
<el-button :icon="RefreshCw" :loading="loading" @click="loadOrders">刷新</el-button>
</div>
</header>
<div class="order-page-summary">
<span><small>当前筛选总数</small><strong>{{ orders.total }}</strong></span>
<span><small>当前页实收</small><strong>{{ money(pagePaidCents) }}</strong></span>
<span><small>当前页待支付</small><strong>{{ pendingCount }}</strong></span>
<span><small>当前页进行中</small><strong>{{ activeCount }}</strong></span>
</div>
<el-table v-loading="loading" :data="orders.items" class="data-table" row-key="id">
<el-table-column label="订单" min-width="190">
<template #default="{ row }">
<div class="stack"><strong>{{ row.orderNo }}</strong><span>#{{ row.id }} · {{ formatDate(row.createdAt) }}</span></div>
</template>
</el-table-column>
<el-table-column label="门店 / 房间" min-width="190">
<template #default="{ row }"><div class="stack"><strong>{{ row.storeName }}</strong><span>{{ row.roomNo }} · {{ row.roomName }}</span></div></template>
</el-table-column>
<el-table-column label="使用时段" min-width="205">
<template #default="{ row }"><div class="stack"><strong>{{ formatDate(row.startAt) }}</strong><span> {{ formatDate(row.endAt) }}</span></div></template>
</el-table-column>
<el-table-column label="金额" min-width="135">
<template #default="{ row }"><div class="stack"><strong>{{ money(row.paidAmountCents) }}</strong><span>应收 {{ money(row.totalAmountCents) }}</span></div></template>
</el-table-column>
<el-table-column label="状态" width="130">
<template #default="{ row }"><el-tag :type="statusType(row.status)">{{ statusLabel(row.status) }}</el-tag></template>
</el-table-column>
<el-table-column label="操作" min-width="260" fixed="right">
<template #default="{ row }">
<div class="row-actions">
<el-button link type="primary" @click="openDetail(row)">详情</el-button>
<el-dropdown v-if="getAllowedActions(row.status).length" trigger="click" @command="openAction(row, $event)">
<el-button link type="primary">状态处置<ChevronDown :size="14" /></el-button>
<template #dropdown><el-dropdown-menu><el-dropdown-item v-for="action in getAllowedActions(row.status)" :key="action" :command="action">{{ actionLabel(action) }}</el-dropdown-item></el-dropdown-menu></template>
</el-dropdown>
<el-button link @click="openNote(row)">备注</el-button>
<el-button v-if="isAdjustable(row.status)" link @click="openAdjustment(row)">调整</el-button>
</div>
</template>
</el-table-column>
</el-table>
<footer class="pager"><el-pagination v-model:current-page="orders.page" :page-size="orders.pageSize" :total="orders.total" layout="prev, pager, next, total" @current-change="loadOrders" /></footer>
</section>
<el-drawer v-model="detailDrawer" title="订单详情" size="min(720px, 94vw)">
<div v-if="detail" class="order-detail-stack">
<div class="detail-header"><div class="stack"><strong>{{ detail.orderNo }}</strong><span>#{{ detail.id }}</span></div><el-tag :type="statusType(detail.status)">{{ statusLabel(detail.status) }}</el-tag></div>
<div class="detail-grid">
<span><small>门店</small><strong>{{ detail.storeName }}</strong></span>
<span><small>房间</small><strong>{{ detail.roomNo }} · {{ detail.roomName }}</strong></span>
<span><small>开始</small><strong>{{ formatDate(detail.startAt) }}</strong></span>
<span><small>结束</small><strong>{{ formatDate(detail.endAt) }}</strong></span>
<span><small>应收</small><strong>{{ money(detail.totalAmountCents) }}</strong></span>
<span><small>实收</small><strong>{{ money(detail.paidAmountCents) }}</strong></span>
<span><small>支付渠道</small><strong>{{ detail.latestPayment?.provider || '-' }}</strong></span>
<span><small>支付状态</small><strong>{{ detail.latestPayment?.status || '-' }}</strong></span>
</div>
<header class="drawer-section-title"><div><p class="section-kicker">状态流水</p><h3>{{ history.length }} 条记录</h3></div><el-button :icon="RefreshCw" :loading="loadingDetail" @click="loadDetail">刷新</el-button></header>
<el-timeline>
<el-timeline-item v-for="item in history" :key="item.id" :timestamp="formatDate(item.createdAt)" placement="top">
<div class="history-card"><strong>{{ actionLabel(item.action) }} · {{ statusLabel(item.toStatus) }}</strong><span>{{ item.fromStatus ? `${statusLabel(item.fromStatus)}` : '' }}{{ statusLabel(item.toStatus) }}</span><small>{{ item.source }} · {{ item.actorId ? `操作人 ${item.actorId}` : item.actorType }} · {{ item.reason || '无备注' }}</small><code v-if="item.traceId">{{ item.traceId }}</code></div>
</el-timeline-item>
</el-timeline>
<el-empty v-if="!loadingDetail && history.length === 0" description="暂无状态流水" />
</div>
</el-drawer>
<el-dialog v-model="actionDialog.open" :title="actionLabel(actionDialog.action)" width="min(520px, 94vw)">
<el-form label-position="top"><el-form-item label="处置原因"><el-input v-model="actionDialog.reason" type="textarea" :rows="4" maxlength="512" show-word-limit /></el-form-item></el-form>
<template #footer><el-button @click="actionDialog.open = false">取消</el-button><el-button type="primary" :loading="saving" @click="saveAction">确认处置</el-button></template>
</el-dialog>
<el-dialog v-model="noteDialog.open" title="订单运营备注" width="min(520px, 94vw)">
<el-form label-position="top"><el-form-item label="备注"><el-input v-model="noteDialog.note" type="textarea" :rows="4" maxlength="512" show-word-limit /></el-form-item></el-form>
<template #footer><el-button @click="noteDialog.open = false">取消</el-button><el-button type="primary" :loading="saving" @click="saveNote">保存备注</el-button></template>
</el-dialog>
<el-dialog v-model="adjustDialog.open" title="调整订单" width="min(620px, 94vw)">
<el-tabs v-model="adjustDialog.mode">
<el-tab-pane label="调整时段" name="time">
<el-form label-position="top"><el-form-item label="开始时间"><el-date-picker v-model="adjustDialog.startAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ssZ" /></el-form-item><el-form-item label="结束时间"><el-date-picker v-model="adjustDialog.endAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ssZ" /></el-form-item></el-form>
</el-tab-pane>
<el-tab-pane label="续费延时" name="renew">
<el-form label-position="top"><el-form-item label="新的结束时间"><el-date-picker v-model="adjustDialog.renewEndAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ssZ" /></el-form-item><el-form-item label="计价策略"><el-radio-group v-model="adjustDialog.pricingPolicy"><el-radio value="LOCKED">沿用下单价</el-radio><el-radio value="CURRENT">使用当前价</el-radio></el-radio-group></el-form-item></el-form>
</el-tab-pane>
<el-tab-pane label="更换房间" name="room">
<el-form label-position="top"><el-form-item label="目标房间"><el-select v-model="adjustDialog.roomId" filterable><el-option v-for="room in availableRooms" :key="room.id" :label="`${room.roomNo} · ${room.name} · ${money(room.basePriceCents)}`" :value="room.id" /></el-select></el-form-item></el-form>
</el-tab-pane>
</el-tabs>
<el-form label-position="top"><el-form-item label="调整原因"><el-input v-model="adjustDialog.reason" type="textarea" maxlength="512" show-word-limit /></el-form-item></el-form>
<template #footer><el-button @click="adjustDialog.open = false">取消</el-button><el-button type="primary" :loading="saving" @click="saveAdjustment">确认调整</el-button></template>
</el-dialog>
</section>
</template>
<script setup lang="ts">
import { computed, reactive, ref, watch } from 'vue';
import { ElMessage } from 'element-plus';
import { ChevronDown, Download, RefreshCw } from '@lucide/vue';
import {
ApiError,
addManagedOrderNote,
adjustManagedOrderTime,
changeManagedOrderRoom,
executeManagedOrderAction,
getManagedOrder,
listManagedOrderHistory,
listManagedOrders,
listManagedRooms,
listManagedStores,
renewManagedOrder,
type ApiSession
} from '../api';
import { money } from '../format';
import type { ManagedOrder, ManagedRoom, ManagedStore, OrderAction, OrderHistoryItem, OrderStatus, PageResult } from '../types';
const props = defineProps<{ session: ApiSession }>();
const stores = ref<ManagedStore[]>([]);
const availableRooms = ref<ManagedRoom[]>([]);
const orders = reactive<PageResult<ManagedOrder>>({ items: [], total: 0, page: 1, pageSize: 20 });
const storeId = ref('');
const status = ref<OrderStatus | ''>('');
const loading = ref(false);
const loadingDetail = ref(false);
const saving = ref(false);
const lastError = ref('');
const detailDrawer = ref(false);
const detail = ref<ManagedOrder | null>(null);
const history = ref<OrderHistoryItem[]>([]);
const actionDialog = reactive<{ open: boolean; orderId: string; action: OrderAction; reason: string }>({ open: false, orderId: '', action: 'SUBMIT', reason: '' });
const noteDialog = reactive({ open: false, orderId: '', note: '' });
const adjustDialog = reactive({ open: false, orderId: '', storeId: '', currentRoomId: '', mode: 'time' as 'time' | 'renew' | 'room', startAt: '', endAt: '', renewEndAt: '', pricingPolicy: 'LOCKED' as 'CURRENT' | 'LOCKED', roomId: '', reason: '' });
const statusOptions: Array<{ value: OrderStatus; label: string }> = [
{ value: 'DRAFT', label: '草稿' }, { value: 'PENDING_PAYMENT', label: '待支付' },
{ value: 'PAID', label: '已支付' }, { value: 'RESERVED', label: '已预订' },
{ value: 'IN_PROGRESS', label: '使用中' }, { value: 'FINISHED', label: '已完成' },
{ value: 'CANCELLED', label: '已取消' }, { value: 'REFUNDING', label: '退款中' },
{ value: 'REFUNDED', label: '已退款' }, { value: 'CLOSED', label: '已关闭' }
];
const allowedActions: Record<OrderStatus, OrderAction[]> = {
DRAFT: ['SUBMIT', 'CANCEL', 'CLOSE'], PENDING_PAYMENT: ['CONFIRM_PAYMENT', 'CANCEL', 'CLOSE'],
PAID: ['RESERVE', 'START', 'CANCEL', 'BEGIN_REFUND'], RESERVED: ['START', 'CANCEL', 'BEGIN_REFUND'],
IN_PROGRESS: ['FINISH', 'BEGIN_REFUND'], FINISHED: ['BEGIN_REFUND', 'CLOSE'],
CANCELLED: ['BEGIN_REFUND', 'CLOSE'], REFUNDING: ['COMPLETE_REFUND'], REFUNDED: ['CLOSE'], CLOSED: []
};
const actionLabels: Record<string, string> = {
SUBMIT: '提交待支付', CONFIRM_PAYMENT: '确认支付', RESERVE: '确认预订', START: '开始使用',
FINISH: '结束使用', CANCEL: '取消订单', BEGIN_REFUND: '发起退款', COMPLETE_REFUND: '完成退款',
CLOSE: '关闭订单', CREATED: '创建订单', EXPIRED: '订单过期', MIGRATED: '历史迁移'
};
const pagePaidCents = computed(() => orders.items.reduce((sum, item) => sum + Number(item.paidAmountCents || 0), 0));
const pendingCount = computed(() => orders.items.filter((item) => item.status === 'PENDING_PAYMENT').length);
const activeCount = computed(() => orders.items.filter((item) => ['PAID', 'RESERVED', 'IN_PROGRESS'].includes(item.status)).length);
async function capture<T>(work: () => Promise<T>) {
lastError.value = '';
try { return await work(); }
catch (error) {
lastError.value = error instanceof ApiError ? `${error.code}${error.traceId ? ` · ${error.traceId}` : ''}` : error instanceof Error ? error.message : '操作失败';
throw error;
}
}
async function loadWorkspace() {
try {
stores.value = await capture(() => listManagedStores(props.session));
await loadOrders();
} catch { /* displayed */ }
}
async function loadOrders() {
if (!props.session.token) return;
loading.value = true;
try {
Object.assign(orders, await capture(() => listManagedOrders(props.session, {
page: orders.page, pageSize: orders.pageSize, status: status.value || undefined, storeId: storeId.value || undefined
})));
} catch { /* displayed */ } finally { loading.value = false; }
}
function applyFilters() { orders.page = 1; void loadOrders(); }
async function openDetail(order: ManagedOrder) {
detail.value = order;
detailDrawer.value = true;
await loadDetail();
}
async function loadDetail() {
if (!detail.value) return;
loadingDetail.value = true;
try {
const [order, items] = await Promise.all([
capture(() => getManagedOrder(props.session, detail.value!.id)),
capture(() => listManagedOrderHistory(props.session, detail.value!.id))
]);
detail.value = order;
history.value = items;
} catch { /* displayed */ } finally { loadingDetail.value = false; }
}
function openAction(order: ManagedOrder, command: unknown) {
Object.assign(actionDialog, { open: true, orderId: order.id, action: String(command) as OrderAction, reason: '' });
}
async function saveAction() {
if (!actionDialog.reason.trim()) return ElMessage.warning('请填写处置原因');
saving.value = true;
try {
await capture(() => executeManagedOrderAction(props.session, actionDialog.orderId, actionDialog.action, actionDialog.reason.trim()));
actionDialog.open = false;
ElMessage.success('订单状态已更新');
await loadOrders();
if (detail.value?.id === actionDialog.orderId) await loadDetail();
} catch { /* displayed */ } finally { saving.value = false; }
}
function openNote(order: ManagedOrder) { Object.assign(noteDialog, { open: true, orderId: order.id, note: '' }); }
async function saveNote() {
if (!noteDialog.note.trim()) return ElMessage.warning('请填写备注');
saving.value = true;
try {
await capture(() => addManagedOrderNote(props.session, noteDialog.orderId, noteDialog.note.trim()));
noteDialog.open = false;
ElMessage.success('订单备注已保存');
if (detail.value?.id === noteDialog.orderId) await loadDetail();
} catch { /* displayed */ } finally { saving.value = false; }
}
async function openAdjustment(order: ManagedOrder) {
Object.assign(adjustDialog, {
open: true, orderId: order.id, storeId: order.storeId, currentRoomId: order.roomId,
mode: 'time', startAt: toPickerValue(order.startAt), endAt: toPickerValue(order.endAt),
renewEndAt: toPickerValue(order.endAt), pricingPolicy: 'LOCKED', roomId: '', reason: ''
});
try { availableRooms.value = (await capture(() => listManagedRooms(props.session, order.storeId))).filter((room) => room.id !== order.roomId && room.configurationStatus === 'ENABLED'); }
catch { availableRooms.value = []; }
}
async function saveAdjustment() {
if (!adjustDialog.reason.trim()) return ElMessage.warning('请填写调整原因');
saving.value = true;
try {
if (adjustDialog.mode === 'time') {
if (!adjustDialog.startAt && !adjustDialog.endAt) return ElMessage.warning('至少填写一个时间');
await capture(() => adjustManagedOrderTime(props.session, adjustDialog.orderId, {
startAt: adjustDialog.startAt || undefined, endAt: adjustDialog.endAt || undefined, reason: adjustDialog.reason.trim()
}));
} else if (adjustDialog.mode === 'renew') {
if (!adjustDialog.renewEndAt) return ElMessage.warning('请选择新的结束时间');
await capture(() => renewManagedOrder(props.session, adjustDialog.orderId, {
endAt: adjustDialog.renewEndAt, pricingPolicy: adjustDialog.pricingPolicy, reason: adjustDialog.reason.trim()
}));
} else {
if (!adjustDialog.roomId) return ElMessage.warning('请选择目标房间');
await capture(() => changeManagedOrderRoom(props.session, adjustDialog.orderId, {
roomId: adjustDialog.roomId, reason: adjustDialog.reason.trim()
}));
}
adjustDialog.open = false;
ElMessage.success('订单调整已完成');
await loadOrders();
if (detail.value?.id === adjustDialog.orderId) await loadDetail();
} catch { /* displayed */ } finally { saving.value = false; }
}
function exportOrders() {
const rows = [['订单号', '门店', '房间', '状态', '开始', '结束', '应收', '实收', '支付渠道', '支付状态']];
for (const order of orders.items) rows.push([order.orderNo, order.storeName, `${order.roomNo} ${order.roomName}`, statusLabel(order.status), formatDate(order.startAt), formatDate(order.endAt), money(order.totalAmountCents), money(order.paidAmountCents), order.latestPayment?.provider || '', order.latestPayment?.status || '']);
const csv = rows.map((row) => row.map(csvCell).join(',')).join('\r\n');
const url = URL.createObjectURL(new Blob([`\uFEFF${csv}`], { type: 'text/csv;charset=utf-8' }));
const link = document.createElement('a'); link.href = url; link.download = `orders-${Date.now()}.csv`; link.click(); URL.revokeObjectURL(url);
}
function csvCell(value: string) { const safe = String(value).replace(/\r?\n/g, ' '); return /[",\r\n]/.test(safe) ? `"${safe.replace(/"/g, '""')}"` : safe; }
function statusLabel(value: OrderStatus) { return statusOptions.find((item) => item.value === value)?.label || value; }
function statusType(value: OrderStatus) { return value === 'IN_PROGRESS' ? 'success' : ['PENDING_PAYMENT', 'REFUNDING'].includes(value) ? 'warning' : ['CANCELLED', 'CLOSED'].includes(value) ? 'info' : 'primary'; }
function actionLabel(value: string) { return actionLabels[value] || value; }
function getAllowedActions(value: OrderStatus) { return allowedActions[value] || []; }
function formatDate(value: string) { const date = new Date(value); return Number.isNaN(date.getTime()) ? value : new Intl.DateTimeFormat('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hour12: false }).format(date); }
function toPickerValue(value: string) { const date = new Date(value); return Number.isNaN(date.getTime()) ? '' : date.toISOString(); }
function isAdjustable(value: OrderStatus) { return ['PENDING_PAYMENT', 'PAID', 'RESERVED', 'IN_PROGRESS'].includes(value); }
watch(() => props.session.token, (token) => { if (token) void loadWorkspace(); }, { immediate: true });
</script>
+79
View File
@@ -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;
}
+53
View File
@@ -98,6 +98,59 @@ export interface ManagedRoom {
export type RoomInput = Omit<ManagedRoom, 'id'>;
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;
+4 -2
View File
@@ -32,7 +32,8 @@ export async function registerOrderQueryRoutes(
app: FastifyInstance,
options: OrderQueryRouteOptions
) {
app.get('/app-api/orders', async (request, reply) => {
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);
@@ -44,7 +45,7 @@ export async function registerOrderQueryRoutes(
};
});
app.get('/app-api/orders/:orderId', async (request, reply) => {
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);
@@ -56,6 +57,7 @@ export async function registerOrderQueryRoutes(
}));
});
}
}
async function authenticate(
authorization: string | undefined,
+7 -1
View File
@@ -27,7 +27,12 @@ export interface OrderStateRouteOptions {
export async function registerOrderStateRoutes(
app: FastifyInstance, options: OrderStateRouteOptions
) {
app.get('/app-api/orders/:orderId/history', async (request, reply) => {
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);
@@ -40,6 +45,7 @@ export async function registerOrderStateRoutes(
traceId: request.traceId
}));
});
}
app.post('/app-api/orders/:orderId/cancel', async (request, reply) => {
const auth = await authenticate(request.headers.authorization, options);
+15
View File
@@ -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'
+14 -1
View File
@@ -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',
+27
View File
@@ -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)'
]) {