feat(M08-D): 补后台订单运营
This commit is contained in:
+18
-1
@@ -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 });
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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)'
|
||||
]) {
|
||||
|
||||
Reference in New Issue
Block a user