feat(M08-B): 补保洁统计明细

This commit is contained in:
Codex
2026-06-30 10:24:39 +08:00
parent f59beb0e75
commit e48d1f0301
15 changed files with 616 additions and 46 deletions
+68 -23
View File
@@ -53,19 +53,19 @@
<section class="metric-grid" aria-label="保洁概览">
<div class="metric">
<span>待验收</span>
<strong>{{ metrics.submitted }}</strong>
<strong>{{ statistics.summary.pendingReview }}</strong>
</div>
<div class="metric">
<span>已完成</span>
<strong>{{ metrics.completed }}</strong>
<strong>{{ statistics.summary.completed }}</strong>
</div>
<div class="metric">
<span>待发放</span>
<strong>{{ metrics.confirmed }}</strong>
<strong>{{ money(statistics.summary.confirmedSettlementCents) }}</strong>
</div>
<div class="metric">
<span>本页金额</span>
<strong>{{ money(metrics.amountCents) }}</strong>
<span>待结算</span>
<strong>{{ money(statistics.summary.pendingSettlementCents) }}</strong>
</div>
</section>
@@ -126,6 +126,15 @@
@failure="handleFailure"
/>
</el-tab-pane>
<el-tab-pane label="统计" name="statistics">
<CleaningStatisticsPanel
:loading="loading.statistics"
:statistics="statistics"
:filters="statisticsFilters"
@refresh="loadStatistics"
@filter="changeStatisticsFilters"
/>
</el-tab-pane>
</el-tabs>
</section>
</main>
@@ -144,6 +153,7 @@ import {
} from '@lucide/vue';
import CleaningTasksPanel from './components/CleaningTasksPanel.vue';
import CleaningSettlementsPanel from './components/CleaningSettlementsPanel.vue';
import CleaningStatisticsPanel from './components/CleaningStatisticsPanel.vue';
import {
ApiError,
assignCleaningTask,
@@ -151,6 +161,7 @@ import {
confirmCleaningSettlement,
executeWechatTransfer,
generateCleaningSettlement,
getCleaningStatistics,
listCleaningSettlements,
listCleaningTasks,
reclaimCleaningTimeouts,
@@ -160,6 +171,7 @@ import {
} from './api';
import type {
CleaningSettlement,
CleaningStatistics,
CleaningTask,
PageResult,
SettlementStatus,
@@ -175,7 +187,7 @@ 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 loading = reactive({ tasks: false, settlements: false, statistics: false });
const tasks = reactive<PageResult<CleaningTask>>({ items: [], total: 0, page: 1, pageSize: 20 });
const settlements = reactive<PageResult<CleaningSettlement>>({
items: [],
@@ -183,13 +195,24 @@ const settlements = reactive<PageResult<CleaningSettlement>>({
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)
}));
const statisticsFilters = reactive({ from: '', to: '', storeId: '' });
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: []
});
function saveToken() {
localStorage.setItem('qipai.admin.token', tokenDraft.value.trim());
@@ -236,6 +259,23 @@ async function loadSettlements() {
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
});
Object.assign(statistics.summary, result.summary);
statistics.byStatus = result.byStatus;
statistics.byStore = result.byStore;
statistics.settlements = result.settlements;
statistics.members = result.members;
}, '统计已刷新');
loading.statistics = false;
}
function changeTaskStatus(status: TaskStatus | '') {
taskStatus.value = status;
tasks.page = 1;
@@ -258,31 +298,36 @@ function changeSettlementPage(page: number) {
void loadSettlements();
}
function changeStatisticsFilters(input: { from: string; to: string; storeId: string }) {
Object.assign(statisticsFilters, input);
void loadStatistics();
}
async function handleAssign(input: { taskId: string; cleanerUserId: string; note?: string }) {
await runAction(async () => {
await assignCleaningTask(session.value, input.taskId, input);
await loadTasks();
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 loadTasks();
await Promise.all([loadTasks(), loadStatistics()]);
}, '已验收');
}
async function handleReject(input: { taskId: string; reason: string }) {
await runAction(async () => {
await rejectCleaningTask(session.value, input.taskId, input.reason);
await loadTasks();
await Promise.all([loadTasks(), loadStatistics()]);
}, '已驳回');
}
async function handleReclaim(input: { olderThanMinutes: number; limit: number }) {
await runAction(async () => {
await reclaimCleaningTimeouts(session.value, input);
await loadTasks();
await Promise.all([loadTasks(), loadStatistics()]);
}, '已回收超时任务');
}
@@ -291,14 +336,14 @@ async function handleGenerateSettlement(
) {
await runAction(async () => {
await generateCleaningSettlement(session.value, input);
await Promise.all([loadTasks(), loadSettlements()]);
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 loadSettlements();
await Promise.all([loadSettlements(), loadStatistics()]);
}, '已确认结算单');
}
@@ -308,14 +353,14 @@ async function handleTransfer(input: { settlementId: string; mode: TransferMode;
mode: input.mode,
note: input.note
});
await loadSettlements();
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 loadSettlements();
await Promise.all([loadSettlements(), loadStatistics()]);
}, '已同步微信状态');
}
@@ -329,13 +374,13 @@ async function handleFailure(
payoutReference: input.payoutReference,
note: input.note
});
await loadSettlements();
await Promise.all([loadSettlements(), loadStatistics()]);
}, '已记录失败');
}
onMounted(() => {
if (session.value.token) {
void Promise.all([loadTasks(), loadSettlements()]);
void Promise.all([loadTasks(), loadSettlements(), loadStatistics()]);
}
});
</script>
+13
View File
@@ -1,6 +1,7 @@
import type {
CleaningSettlement,
CleaningSettlementDetail,
CleaningStatistics,
CleaningTask,
CleaningTaskMember,
PageResult,
@@ -63,6 +64,18 @@ export function listCleaningTasks(
return request<PageResult<CleaningTask>>(session, `/cleaning/tasks?${params}`);
}
export function getCleaningStatistics(
session: ApiSession,
input: { from?: string; to?: string; storeId?: string } = {}
) {
const params = new URLSearchParams();
if (input.from) params.set('from', input.from);
if (input.to) params.set('to', input.to);
if (input.storeId) params.set('storeId', input.storeId);
const query = params.toString();
return request<CleaningStatistics>(session, `/cleaning/statistics${query ? `?${query}` : ''}`);
}
export function assignCleaningTask(
session: ApiSession,
taskId: string,
@@ -0,0 +1,147 @@
<template>
<section class="panel">
<div class="panel-toolbar">
<el-form class="stats-filter" label-position="top">
<el-form-item label="开始">
<el-input v-model="filterDraft.from" placeholder="2026-06-01" />
</el-form-item>
<el-form-item label="结束">
<el-input v-model="filterDraft.to" placeholder="2026-07-01" />
</el-form-item>
<el-form-item label="门店 ID">
<el-input v-model="filterDraft.storeId" inputmode="numeric" />
</el-form-item>
<el-button type="primary" :icon="Search" @click="emitFilter">查询</el-button>
</el-form>
<el-tooltip content="刷新统计">
<el-button :icon="RefreshCw" :loading="loading" circle @click="$emit('refresh')" />
</el-tooltip>
</div>
<div class="stats-body" v-loading="loading">
<section class="metric-grid compact" aria-label="统计概览">
<div class="metric">
<span>任务总数</span>
<strong>{{ statistics.summary.taskTotal }}</strong>
</div>
<div class="metric">
<span>待验收</span>
<strong>{{ statistics.summary.pendingReview }}</strong>
</div>
<div class="metric">
<span>待结算</span>
<strong>{{ money(statistics.summary.pendingSettlementCents) }}</strong>
</div>
<div class="metric">
<span>已发放</span>
<strong>{{ money(statistics.summary.paidSettlementCents) }}</strong>
</div>
</section>
<div class="stats-grid">
<section class="stat-block">
<header>
<h3>任务状态</h3>
<span>{{ money(statistics.summary.rewardCents) }}</span>
</header>
<el-table :data="statistics.byStatus" size="small">
<el-table-column prop="status" label="状态" />
<el-table-column prop="total" label="数量" width="90" />
<el-table-column prop="rewardCents" label="奖励" width="120">
<template #default="{ row }">{{ money(row.rewardCents) }}</template>
</el-table-column>
</el-table>
</section>
<section class="stat-block">
<header>
<h3>结算口径</h3>
<span>待发 {{ money(statistics.summary.confirmedSettlementCents) }}</span>
</header>
<el-table :data="statistics.settlements" size="small">
<el-table-column prop="status" label="状态" />
<el-table-column prop="total" label="单数" width="90" />
<el-table-column prop="rewardCents" label="金额" width="120">
<template #default="{ row }">{{ money(row.rewardCents) }}</template>
</el-table-column>
</el-table>
</section>
</div>
<section class="stat-block">
<header>
<h3>门店明细</h3>
<span>按待验收优先</span>
</header>
<el-table :data="statistics.byStore" size="small">
<el-table-column prop="storeName" label="门店" min-width="150" />
<el-table-column prop="taskTotal" label="任务" width="90" />
<el-table-column prop="pendingReview" label="待验" width="90" />
<el-table-column prop="completed" label="完成" width="90" />
<el-table-column prop="rejected" label="驳回" width="90" />
<el-table-column prop="rewardCents" label="奖励" width="120">
<template #default="{ row }">{{ money(row.rewardCents) }}</template>
</el-table-column>
</el-table>
</section>
<section class="stat-block">
<header>
<h3>成员分账</h3>
<span>按待结算金额排序</span>
</header>
<el-table :data="statistics.members" size="small">
<el-table-column prop="cleanerName" label="保洁员" min-width="150">
<template #default="{ row }">
<div class="stack">
<strong>{{ row.cleanerName || compactText(row.cleanerUserId) }}</strong>
<span>ID {{ row.cleanerUserId }}</span>
</div>
</template>
</el-table-column>
<el-table-column prop="taskCount" label="任务" width="90" />
<el-table-column prop="pendingSettlementCents" label="待结算" width="130">
<template #default="{ row }">{{ money(row.pendingSettlementCents) }}</template>
</el-table-column>
<el-table-column prop="settledRewardCents" label="已结算" width="130">
<template #default="{ row }">{{ money(row.settledRewardCents) }}</template>
</el-table-column>
</el-table>
</section>
</div>
</section>
</template>
<script setup lang="ts">
import { reactive, watch } from 'vue';
import { RefreshCw, Search } from '@lucide/vue';
import { compactText, money } from '../format';
import type { CleaningStatistics } from '../types';
const props = defineProps<{
loading: boolean;
statistics: CleaningStatistics;
filters: { from: string; to: string; storeId: string };
}>();
const emit = defineEmits<{
refresh: [];
filter: [{ from: string; to: string; storeId: string }];
}>();
const filterDraft = reactive({ ...props.filters });
watch(
() => props.filters,
(value) => Object.assign(filterDraft, value),
{ deep: true }
);
function emitFilter() {
emit('filter', {
from: filterDraft.from.trim(),
to: filterDraft.to.trim(),
storeId: filterDraft.storeId.trim()
});
}
</script>
+57 -1
View File
@@ -144,6 +144,10 @@ textarea {
margin: 20px 0 14px;
}
.metric-grid.compact {
margin: 0;
}
.metric {
min-height: 82px;
padding: 14px 16px;
@@ -314,6 +318,56 @@ textarea {
white-space: nowrap;
}
.stats-filter {
display: grid;
grid-template-columns: repeat(3, minmax(130px, 180px)) auto;
gap: 10px;
align-items: end;
}
.stats-filter .el-form-item {
margin-bottom: 0;
}
.stats-body {
display: grid;
gap: 14px;
padding: 14px;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 14px;
}
.stat-block {
min-width: 0;
border: 1px solid #e1e8f0;
border-radius: 8px;
overflow: hidden;
}
.stat-block header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 10px 12px;
border-bottom: 1px solid #e1e8f0;
background: #f5f8fb;
}
.stat-block h3 {
margin: 0;
font-size: 14px;
}
.stat-block header span {
color: #60708a;
font-size: 12px;
}
.el-button {
border-radius: 8px;
}
@@ -388,7 +442,9 @@ textarea {
}
.member-form,
.detail-grid {
.detail-grid,
.stats-filter,
.stats-grid {
grid-template-columns: 1fr;
}
}
+40
View File
@@ -102,6 +102,46 @@ export interface CleaningSettlementDetail {
items: CleaningSettlementItem[];
}
export interface CleaningStatistics {
summary: {
taskTotal: number;
pendingReview: number;
active: number;
rejected: number;
completed: number;
rewardCents: number;
pendingSettlementCents: number;
confirmedSettlementCents: number;
paidSettlementCents: number;
};
byStatus: Array<{
status: TaskStatus;
total: number;
rewardCents: number;
}>;
byStore: Array<{
storeId: string;
storeName: string;
taskTotal: number;
pendingReview: number;
completed: number;
rejected: number;
rewardCents: number;
}>;
settlements: Array<{
status: SettlementStatus;
total: number;
rewardCents: number;
}>;
members: Array<{
cleanerUserId: string;
cleanerName: string;
taskCount: number;
pendingSettlementCents: number;
settledRewardCents: number;
}>;
}
export type WechatTransferPreflightStatus = 'PASS' | 'WARN' | 'FAIL';
export interface WechatTransferPreflightCheck {