feat(M08-B): 补保洁统计明细
This commit is contained in:
+68
-23
@@ -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>
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user