feat(M08-B): 建立保洁管理后台

This commit is contained in:
Codex
2026-06-29 11:06:54 +08:00
parent a1bace2dac
commit 1fada09249
22 changed files with 3136 additions and 15 deletions
+341
View File
@@ -0,0 +1,341 @@
<template>
<el-config-provider>
<main class="app-shell">
<aside class="sidebar">
<div class="brand">
<div class="brand-mark"></div>
<div>
<h1>自助棋牌室</h1>
<p>运营后台</p>
</div>
</div>
<nav class="nav-list" aria-label="后台模块">
<button class="nav-item active" type="button">
<Sparkles :size="18" />
<span>保洁运营</span>
</button>
<button class="nav-item" type="button" disabled>
<Store :size="18" />
<span>门店</span>
</button>
<button class="nav-item" type="button" disabled>
<RadioTower :size="18" />
<span>设备</span>
</button>
<button class="nav-item" type="button" disabled>
<WalletCards :size="18" />
<span>支付</span>
</button>
</nav>
</aside>
<section class="workspace">
<header class="topbar">
<div>
<p class="eyebrow">M08-B</p>
<h2>保洁任务与结算</h2>
</div>
<div class="token-box">
<el-input
v-model="tokenDraft"
type="password"
show-password
placeholder="后台访问令牌"
autocomplete="off"
@keyup.enter="saveToken"
/>
<el-tooltip content="保存令牌" placement="bottom">
<el-button :icon="Save" type="primary" @click="saveToken" />
</el-tooltip>
</div>
</header>
<section class="metric-grid" aria-label="保洁概览">
<div class="metric">
<span>待验收</span>
<strong>{{ metrics.submitted }}</strong>
</div>
<div class="metric">
<span>已完成</span>
<strong>{{ metrics.completed }}</strong>
</div>
<div class="metric">
<span>待发放</span>
<strong>{{ metrics.confirmed }}</strong>
</div>
<div class="metric">
<span>本页金额</span>
<strong>{{ money(metrics.amountCents) }}</strong>
</div>
</section>
<el-alert
v-if="lastError"
class="alert-line"
:title="lastError"
type="error"
show-icon
:closable="true"
@close="lastError = ''"
/>
<el-alert
v-if="lastMessage"
class="alert-line"
:title="lastMessage"
type="success"
show-icon
:closable="true"
@close="lastMessage = ''"
/>
<el-tabs v-model="activeTab" class="work-tabs">
<el-tab-pane label="任务" name="tasks">
<CleaningTasksPanel
:session="session"
:loading="loading.tasks"
:items="tasks.items"
:total="tasks.total"
:page="tasks.page"
:page-size="tasks.pageSize"
:status="taskStatus"
@refresh="loadTasks"
@status-change="changeTaskStatus"
@page-change="changeTaskPage"
@assign="handleAssign"
@complete="handleComplete"
@reject="handleReject"
@reclaim="handleReclaim"
/>
</el-tab-pane>
<el-tab-pane label="结算" name="settlements">
<CleaningSettlementsPanel
:session="session"
:loading="loading.settlements"
:items="settlements.items"
:total="settlements.total"
:page="settlements.page"
:page-size="settlements.pageSize"
:status="settlementStatus"
@refresh="loadSettlements"
@status-change="changeSettlementStatus"
@page-change="changeSettlementPage"
@generate="handleGenerateSettlement"
@confirm="handleConfirmSettlement"
@transfer="handleTransfer"
@sync-transfer="handleSyncTransfer"
@failure="handleFailure"
/>
</el-tab-pane>
</el-tabs>
</section>
</main>
</el-config-provider>
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue';
import { ElMessage } from 'element-plus';
import {
RadioTower,
Save,
Sparkles,
Store,
WalletCards
} from '@lucide/vue';
import CleaningTasksPanel from './components/CleaningTasksPanel.vue';
import CleaningSettlementsPanel from './components/CleaningSettlementsPanel.vue';
import {
ApiError,
assignCleaningTask,
completeCleaningTask,
confirmCleaningSettlement,
executeWechatTransfer,
generateCleaningSettlement,
listCleaningSettlements,
listCleaningTasks,
reclaimCleaningTimeouts,
recordPayoutFailure,
rejectCleaningTask,
syncWechatTransfer
} from './api';
import type {
CleaningSettlement,
CleaningTask,
PageResult,
SettlementStatus,
TaskStatus,
TransferMode
} from './types';
import { money } from './format';
const tokenDraft = ref(localStorage.getItem('qipai.admin.token') || '');
const activeTab = ref('tasks');
const lastError = ref('');
const lastMessage = ref('');
const taskStatus = ref<TaskStatus | ''>('SUBMITTED');
const settlementStatus = ref<SettlementStatus | ''>('CONFIRMED');
const session = computed(() => ({ token: tokenDraft.value }));
const loading = reactive({ tasks: false, settlements: false });
const tasks = reactive<PageResult<CleaningTask>>({ items: [], total: 0, page: 1, pageSize: 20 });
const settlements = reactive<PageResult<CleaningSettlement>>({
items: [],
total: 0,
page: 1,
pageSize: 20
});
const metrics = computed(() => ({
submitted: tasks.items.filter((item) => item.status === 'SUBMITTED').length,
completed: tasks.items.filter((item) => item.status === 'COMPLETED').length,
confirmed: settlements.items.filter((item) => item.status === 'CONFIRMED').length,
amountCents: settlements.items.reduce((sum, item) => sum + Number(item.totalRewardCents || 0), 0)
}));
function saveToken() {
localStorage.setItem('qipai.admin.token', tokenDraft.value.trim());
ElMessage.success('已保存');
}
async function runAction(work: () => Promise<void>, success: string) {
lastError.value = '';
lastMessage.value = '';
try {
await work();
lastMessage.value = success;
ElMessage.success(success);
} catch (error) {
lastError.value = error instanceof ApiError
? `${error.code}${error.traceId ? ` · ${error.traceId}` : ''}`
: error instanceof Error ? error.message : '操作失败';
}
}
async function loadTasks() {
loading.tasks = true;
await runAction(async () => {
const result = await listCleaningTasks(session.value, {
page: tasks.page,
pageSize: tasks.pageSize,
status: taskStatus.value || undefined
});
Object.assign(tasks, result);
}, '任务已刷新');
loading.tasks = false;
}
async function loadSettlements() {
loading.settlements = true;
await runAction(async () => {
const result = await listCleaningSettlements(session.value, {
page: settlements.page,
pageSize: settlements.pageSize,
status: settlementStatus.value || undefined
});
Object.assign(settlements, result);
}, '结算已刷新');
loading.settlements = false;
}
function changeTaskStatus(status: TaskStatus | '') {
taskStatus.value = status;
tasks.page = 1;
void loadTasks();
}
function changeSettlementStatus(status: SettlementStatus | '') {
settlementStatus.value = status;
settlements.page = 1;
void loadSettlements();
}
function changeTaskPage(page: number) {
tasks.page = page;
void loadTasks();
}
function changeSettlementPage(page: number) {
settlements.page = page;
void loadSettlements();
}
async function handleAssign(input: { taskId: string; cleanerUserId: string; note?: string }) {
await runAction(async () => {
await assignCleaningTask(session.value, input.taskId, input);
await loadTasks();
}, '已指派');
}
async function handleComplete(input: { taskId: string; note?: string }) {
await runAction(async () => {
await completeCleaningTask(session.value, input.taskId, input.note);
await loadTasks();
}, '已验收');
}
async function handleReject(input: { taskId: string; reason: string }) {
await runAction(async () => {
await rejectCleaningTask(session.value, input.taskId, input.reason);
await loadTasks();
}, '已驳回');
}
async function handleReclaim(input: { olderThanMinutes: number; limit: number }) {
await runAction(async () => {
await reclaimCleaningTimeouts(session.value, input);
await loadTasks();
}, '已回收超时任务');
}
async function handleGenerateSettlement(
input: { cleanerUserId: string; storeId?: string; note?: string }
) {
await runAction(async () => {
await generateCleaningSettlement(session.value, input);
await Promise.all([loadTasks(), loadSettlements()]);
}, '已生成结算单');
}
async function handleConfirmSettlement(input: { settlementId: string; note?: string }) {
await runAction(async () => {
await confirmCleaningSettlement(session.value, input.settlementId, input.note);
await loadSettlements();
}, '已确认结算单');
}
async function handleTransfer(input: { settlementId: string; mode: TransferMode; note?: string }) {
await runAction(async () => {
await executeWechatTransfer(session.value, input.settlementId, {
mode: input.mode,
note: input.note
});
await loadSettlements();
}, '已提交微信转账');
}
async function handleSyncTransfer(input: { settlementId: string; note?: string }) {
await runAction(async () => {
await syncWechatTransfer(session.value, input.settlementId, input.note);
await loadSettlements();
}, '已同步微信状态');
}
async function handleFailure(
input: { settlementId: string; error: string; payoutReference?: string; note?: string }
) {
await runAction(async () => {
await recordPayoutFailure(session.value, input.settlementId, {
error: input.error,
payoutChannel: 'WECHAT_TRANSFER',
payoutReference: input.payoutReference,
note: input.note
});
await loadSettlements();
}, '已记录失败');
}
onMounted(() => {
if (session.value.token) {
void Promise.all([loadTasks(), loadSettlements()]);
}
});
</script>