Files
qipai/admin/src/App.vue
T
2026-07-06 15:10:17 +08:00

648 lines
21 KiB
Vue

<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>{{ statistics.summary.pendingReview }}</strong>
</div>
<div class="metric">
<span>已完成</span>
<strong>{{ statistics.summary.completed }}</strong>
</div>
<div class="metric">
<span>待发放</span>
<strong>{{ money(statistics.summary.confirmedSettlementCents) }}</strong>
</div>
<div class="metric">
<span>待结算</span>
<strong>{{ money(statistics.summary.pendingSettlementCents) }}</strong>
</div>
</section>
<section class="action-board" aria-label="运营待处理">
<button class="action-tile" type="button" @click="jumpToTaskStatus('SUBMITTED')">
<ClipboardCheck :size="20" />
<span>待验收任务</span>
<strong>{{ statistics.summary.pendingReview }}</strong>
</button>
<button class="action-tile" type="button" @click="jumpToTaskStatus('REJECTED')">
<RotateCcw :size="20" />
<span>驳回补做</span>
<strong>{{ statistics.summary.rejected }}</strong>
</button>
<button class="action-tile" type="button" @click="jumpToSettlementStatus('DRAFT')">
<ListTodo :size="20" />
<span>待确认结算</span>
<strong>{{ draftSettlementTotal }}</strong>
</button>
<button class="action-tile" type="button" @click="jumpToSettlementStatus('CONFIRMED')">
<Send :size="20" />
<span>待发放金额</span>
<strong>{{ money(statistics.summary.confirmedSettlementCents) }}</strong>
</button>
<button class="action-tile" type="button" @click="exportOperationalQueue">
<Download :size="20" />
<span>导出待处理</span>
<strong>{{ operationalQueueTotal }}</strong>
</button>
</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"
:cleaners="cleaners.items"
:total="tasks.total"
:page="tasks.page"
:page-size="tasks.pageSize"
:status="taskStatus"
:filters="taskFilters"
@refresh="loadTasks"
@status-change="changeTaskStatus"
@filter="changeTaskFilters"
@page-change="changeTaskPage"
@assign="handleAssign"
@complete="handleComplete"
@complete-many="handleCompleteMany"
@reject="handleReject"
@reject-many="handleRejectMany"
@reclaim="handleReclaim"
/>
</el-tab-pane>
<el-tab-pane label="结算" name="settlements">
<CleaningSettlementsPanel
:session="session"
:loading="loading.settlements"
:items="settlements.items"
:cleaners="cleaners.items"
:total="settlements.total"
:page="settlements.page"
:page-size="settlements.pageSize"
:status="settlementStatus"
:filters="settlementFilters"
@refresh="loadSettlements"
@status-change="changeSettlementStatus"
@filter="changeSettlementFilters"
@page-change="changeSettlementPage"
@generate="handleGenerateSettlement"
@confirm="handleConfirmSettlement"
@transfer="handleTransfer"
@sync-transfer="handleSyncTransfer"
@sync-transfer-many="handleSyncTransferMany"
@failure="handleFailure"
/>
</el-tab-pane>
<el-tab-pane label="统计" name="statistics">
<CleaningStatisticsPanel
:loading="loading.statistics"
:statistics="statistics"
:filters="statisticsFilters"
:cleaners="cleaners.items"
@refresh="loadStatistics"
@filter="changeStatisticsFilters"
/>
</el-tab-pane>
<el-tab-pane label="保洁员" name="cleaners">
<CleanersPanel
:loading="loading.cleaners"
:items="cleaners.items"
:total="cleaners.total"
:page="cleaners.page"
:page-size="cleaners.pageSize"
:status="cleanerStatus"
:search="cleanerSearch"
@refresh="loadCleaners"
@status-change="changeCleanerStatus"
@search-change="changeCleanerSearch"
@page-change="changeCleanerPage"
@create="handleCreateCleaner"
@update="handleUpdateCleaner"
@status-toggle="handleToggleCleanerStatus"
@reset-sessions="handleResetCleanerSessions"
/>
</el-tab-pane>
<el-tab-pane label="联调" name="handoff">
<CleaningFieldHandoffPanel />
</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 {
ClipboardCheck,
Download,
ListTodo,
RadioTower,
RotateCcw,
Save,
Send,
Sparkles,
Store,
WalletCards
} from '@lucide/vue';
import CleaningTasksPanel from './components/CleaningTasksPanel.vue';
import CleaningSettlementsPanel from './components/CleaningSettlementsPanel.vue';
import CleaningStatisticsPanel from './components/CleaningStatisticsPanel.vue';
import CleanersPanel from './components/CleanersPanel.vue';
import CleaningFieldHandoffPanel from './components/CleaningFieldHandoffPanel.vue';
import {
ApiError,
assignCleaningTask,
completeCleaningTask,
confirmCleaningSettlement,
createStaffUser,
executeWechatTransfer,
generateCleaningSettlement,
getCleaningStatistics,
listCleaningSettlements,
listCleaningTasks,
listStaffUsers,
reclaimCleaningTimeouts,
recordPayoutFailure,
rejectCleaningTask,
resetStaffSessions,
syncWechatTransfer,
updateStaffUser
} from './api';
import type {
CleaningSettlement,
CleaningStatistics,
CleaningTask,
ManagedUser,
PageResult,
PayoutStateFilter,
SettlementStatus,
TaskStatus,
TransferMode,
UserStatus
} 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 cleanerStatus = ref<UserStatus | ''>('ACTIVE');
const cleanerSearch = ref('');
const session = computed(() => ({ token: tokenDraft.value }));
const loading = reactive({ tasks: false, settlements: false, statistics: false, cleaners: 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 cleaners = reactive<PageResult<ManagedUser>>({ items: [], total: 0, page: 1, pageSize: 20 });
const taskFilters = reactive({ storeId: '', cleanerUserId: '' });
const settlementFilters = reactive<{ storeId: string; cleanerUserId: string; payoutState: PayoutStateFilter | '' }>({
storeId: '',
cleanerUserId: '',
payoutState: ''
});
const statisticsFilters = reactive({ from: '', to: '', storeId: '', cleanerUserId: '' });
const statistics = reactive<CleaningStatistics>({
summary: {
taskTotal: 0,
pendingReview: 0,
active: 0,
rejected: 0,
completed: 0,
rewardCents: 0,
pendingSettlementCents: 0,
confirmedSettlementCents: 0,
paidSettlementCents: 0
},
byStatus: [],
byStore: [],
settlements: [],
members: [],
trend: []
});
const draftSettlementTotal = computed(() => statistics.settlements
.find((item) => item.status === 'DRAFT')?.total || 0);
const operationalQueueTotal = computed(() => (
statistics.summary.pendingReview
+ statistics.summary.rejected
+ draftSettlementTotal.value
));
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,
storeId: taskFilters.storeId || undefined,
cleanerUserId: taskFilters.cleanerUserId || 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,
payoutState: settlementFilters.payoutState || undefined,
storeId: settlementFilters.storeId || undefined,
cleanerUserId: settlementFilters.cleanerUserId || undefined
});
Object.assign(settlements, result);
}, '结算已刷新');
loading.settlements = false;
}
async function loadStatistics() {
loading.statistics = true;
await runAction(async () => {
const result = await getCleaningStatistics(session.value, {
from: statisticsFilters.from || undefined,
to: statisticsFilters.to || undefined,
storeId: statisticsFilters.storeId || undefined,
cleanerUserId: statisticsFilters.cleanerUserId || undefined
});
Object.assign(statistics.summary, result.summary);
statistics.byStatus = result.byStatus;
statistics.byStore = result.byStore;
statistics.settlements = result.settlements;
statistics.members = result.members;
statistics.trend = result.trend;
}, '统计已刷新');
loading.statistics = false;
}
async function loadCleaners() {
loading.cleaners = true;
await runAction(async () => {
const result = await listStaffUsers(session.value, {
page: cleaners.page,
pageSize: cleaners.pageSize,
role: 'CLEANER',
status: cleanerStatus.value || undefined,
search: cleanerSearch.value || undefined
});
Object.assign(cleaners, {
items: result.items,
total: result.total,
page: cleaners.page,
pageSize: cleaners.pageSize
});
}, '保洁员已刷新');
loading.cleaners = false;
}
function changeTaskStatus(status: TaskStatus | '') {
taskStatus.value = status;
tasks.page = 1;
void loadTasks();
}
function jumpToTaskStatus(status: TaskStatus) {
activeTab.value = 'tasks';
taskStatus.value = status;
tasks.page = 1;
void loadTasks();
}
function changeTaskFilters(input: { storeId: string; cleanerUserId: string }) {
Object.assign(taskFilters, input);
tasks.page = 1;
void loadTasks();
}
function changeSettlementStatus(status: SettlementStatus | '') {
settlementStatus.value = status;
settlements.page = 1;
void loadSettlements();
}
function jumpToSettlementStatus(status: SettlementStatus) {
activeTab.value = 'settlements';
settlementStatus.value = status;
settlementFilters.payoutState = '';
settlements.page = 1;
void loadSettlements();
}
function exportOperationalQueue() {
downloadCsv(`cleaning-operational-queue-${Date.now()}.csv`, [
['队列', '数量', '金额', '入口'],
['待验收任务', String(statistics.summary.pendingReview), '', '任务/SUBMITTED'],
['驳回补做', String(statistics.summary.rejected), '', '任务/REJECTED'],
['待确认结算', String(draftSettlementTotal.value), '', '结算/DRAFT'],
['待发放金额', '', money(statistics.summary.confirmedSettlementCents), '结算/CONFIRMED'],
['待结算金额', '', money(statistics.summary.pendingSettlementCents), '统计/待结算'],
['已完成任务', String(statistics.summary.completed), '', '统计/已完成']
]);
}
function downloadCsv(filename: string, rows: string[][]) {
const content = rows.map((row) => row.map(csvCell).join(',')).join('\r\n');
const blob = new Blob([`\uFEFF${content}`], { type: 'text/csv;charset=utf-8' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = filename;
link.click();
URL.revokeObjectURL(url);
}
function csvCell(value: string) {
const normalized = value.replace(/\r?\n/g, ' ');
return /[",\r\n]/.test(normalized) ? `"${normalized.replace(/"/g, '""')}"` : normalized;
}
function changeSettlementFilters(input: { storeId: string; cleanerUserId: string; payoutState: PayoutStateFilter | '' }) {
Object.assign(settlementFilters, input);
settlements.page = 1;
void loadSettlements();
}
function changeTaskPage(page: number) {
tasks.page = page;
void loadTasks();
}
function changeSettlementPage(page: number) {
settlements.page = page;
void loadSettlements();
}
function changeStatisticsFilters(input: { from: string; to: string; storeId: string; cleanerUserId: string }) {
Object.assign(statisticsFilters, input);
void loadStatistics();
}
function changeCleanerStatus(status: UserStatus | '') {
cleanerStatus.value = status;
cleaners.page = 1;
void loadCleaners();
}
function changeCleanerSearch(search: string) {
cleanerSearch.value = search;
cleaners.page = 1;
void loadCleaners();
}
function changeCleanerPage(page: number) {
cleaners.page = page;
void loadCleaners();
}
async function handleAssign(input: { taskId: string; cleanerUserId: string; note?: string }) {
await runAction(async () => {
await assignCleaningTask(session.value, input.taskId, input);
await Promise.all([loadTasks(), loadStatistics()]);
}, '已指派');
}
async function handleComplete(input: { taskId: string; note?: string }) {
await runAction(async () => {
await completeCleaningTask(session.value, input.taskId, input.note);
await Promise.all([loadTasks(), loadStatistics()]);
}, '已验收');
}
async function handleCompleteMany(input: { taskIds: string[]; note?: string }) {
await runAction(async () => {
for (const taskId of input.taskIds) {
await completeCleaningTask(session.value, taskId, input.note);
}
await Promise.all([loadTasks(), loadStatistics()]);
}, `已批量验收 ${input.taskIds.length} 个任务`);
}
async function handleReject(input: { taskId: string; reason: string }) {
await runAction(async () => {
await rejectCleaningTask(session.value, input.taskId, input.reason);
await Promise.all([loadTasks(), loadStatistics()]);
}, '已驳回');
}
async function handleRejectMany(input: { taskIds: string[]; reason: string }) {
await runAction(async () => {
for (const taskId of input.taskIds) {
await rejectCleaningTask(session.value, taskId, input.reason);
}
await Promise.all([loadTasks(), loadStatistics()]);
}, `已批量驳回 ${input.taskIds.length} 个任务`);
}
async function handleReclaim(input: { olderThanMinutes: number; limit: number }) {
await runAction(async () => {
await reclaimCleaningTimeouts(session.value, input);
await Promise.all([loadTasks(), loadStatistics()]);
}, '已回收超时任务');
}
async function handleGenerateSettlement(
input: { cleanerUserId: string; storeId?: string; note?: string }
) {
await runAction(async () => {
await generateCleaningSettlement(session.value, input);
await Promise.all([loadTasks(), loadSettlements(), loadStatistics()]);
}, '已生成结算单');
}
async function handleConfirmSettlement(input: { settlementId: string; note?: string }) {
await runAction(async () => {
await confirmCleaningSettlement(session.value, input.settlementId, input.note);
await Promise.all([loadSettlements(), loadStatistics()]);
}, '已确认结算单');
}
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 Promise.all([loadSettlements(), loadStatistics()]);
}, '已提交微信转账');
}
async function handleSyncTransfer(input: { settlementId: string; note?: string }) {
await runAction(async () => {
await syncWechatTransfer(session.value, input.settlementId, input.note);
await Promise.all([loadSettlements(), loadStatistics()]);
}, '已同步微信状态');
}
async function handleSyncTransferMany(input: { settlementIds: string[]; note?: string }) {
await runAction(async () => {
for (const settlementId of input.settlementIds) {
await syncWechatTransfer(session.value, settlementId, input.note);
}
await Promise.all([loadSettlements(), loadStatistics()]);
}, `已批量同步 ${input.settlementIds.length} 个结算单`);
}
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 Promise.all([loadSettlements(), loadStatistics()]);
}, '已记录失败');
}
async function handleCreateCleaner(
input: { nickname: string; phone: string; storeIds: string[]; note?: string }
) {
await runAction(async () => {
await createStaffUser(session.value, {
nickname: input.nickname,
phone: input.phone,
roles: ['CLEANER'],
storeIds: input.storeIds,
note: input.note
});
await loadCleaners();
}, '已新增保洁员');
}
async function handleUpdateCleaner(
input: { userId: string; nickname: string; phone?: string; storeIds: string[]; note?: string }
) {
await runAction(async () => {
await updateStaffUser(session.value, input.userId, {
nickname: input.nickname,
phone: input.phone,
roles: ['CLEANER'],
storeIds: input.storeIds,
note: input.note
});
await loadCleaners();
}, '已更新保洁员');
}
async function handleToggleCleanerStatus(input: { userId: string; status: UserStatus }) {
await runAction(async () => {
await updateStaffUser(session.value, input.userId, { status: input.status });
await loadCleaners();
}, input.status === 'ACTIVE' ? '已启用保洁员' : '已停用保洁员');
}
async function handleResetCleanerSessions(userId: string) {
await runAction(async () => {
await resetStaffSessions(session.value, userId);
await loadCleaners();
}, '已重置保洁员会话');
}
onMounted(() => {
if (session.value.token) {
void Promise.all([loadTasks(), loadSettlements(), loadStatistics(), loadCleaners()]);
}
});
</script>