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>
+170
View File
@@ -0,0 +1,170 @@
import type {
CleaningSettlement,
CleaningTask,
PageResult,
SettlementStatus,
TaskStatus,
TransferMode
} from './types';
const API_BASE = (import.meta.env.VITE_ADMIN_API_BASE_URL || '/admin-api').replace(/\/$/, '');
export class ApiError extends Error {
constructor(
public readonly code: string,
message = code,
public readonly traceId = ''
) {
super(message);
}
}
export interface ApiSession {
token: string;
}
async function request<T>(
session: ApiSession,
path: string,
options: RequestInit = {}
): Promise<T> {
if (!session.token.trim()) {
throw new ApiError('AUTH_TOKEN_REQUIRED', '需要先填入后台访问令牌');
}
const headers = new Headers(options.headers);
headers.set('authorization', `Bearer ${session.token.trim()}`);
if (options.body && !headers.has('content-type')) {
headers.set('content-type', 'application/json');
}
const response = await fetch(`${API_BASE}${path}`, { ...options, headers });
const payload = await response.json().catch(() => ({}));
if (!response.ok || payload.code !== 0) {
throw new ApiError(
String(payload.code || `HTTP_${response.status}`),
String(payload.message || '请求失败'),
String(payload.traceId || '')
);
}
return payload.data as T;
}
export function listCleaningTasks(
session: ApiSession,
input: { page: number; pageSize: number; status?: TaskStatus }
) {
const params = new URLSearchParams({
page: String(input.page),
pageSize: String(input.pageSize)
});
if (input.status) params.set('status', input.status);
return request<PageResult<CleaningTask>>(session, `/cleaning/tasks?${params}`);
}
export function assignCleaningTask(
session: ApiSession,
taskId: string,
input: { cleanerUserId: string; note?: string }
) {
return request<CleaningTask>(session, `/cleaning/tasks/${encodeURIComponent(taskId)}/assign`, {
method: 'POST',
body: JSON.stringify(input)
});
}
export function completeCleaningTask(session: ApiSession, taskId: string, note?: string) {
return request<CleaningTask>(session, `/cleaning/tasks/${encodeURIComponent(taskId)}/complete`, {
method: 'POST',
body: JSON.stringify({ note })
});
}
export function rejectCleaningTask(session: ApiSession, taskId: string, reason: string) {
return request<CleaningTask>(session, `/cleaning/tasks/${encodeURIComponent(taskId)}/reject`, {
method: 'POST',
body: JSON.stringify({ reason })
});
}
export function reclaimCleaningTimeouts(
session: ApiSession,
input: { olderThanMinutes: number; limit: number }
) {
return request<{ reclaimed: number; taskIds: string[] }>(session, '/cleaning/reclaim-timeouts', {
method: 'POST',
body: JSON.stringify(input)
});
}
export function listCleaningSettlements(
session: ApiSession,
input: { page: number; pageSize: number; status?: SettlementStatus }
) {
const params = new URLSearchParams({
page: String(input.page),
pageSize: String(input.pageSize)
});
if (input.status) params.set('status', input.status);
return request<PageResult<CleaningSettlement>>(session, `/cleaning/settlements?${params}`);
}
export function generateCleaningSettlement(
session: ApiSession,
input: { cleanerUserId: string; storeId?: string; note?: string }
) {
return request<CleaningSettlement>(session, '/cleaning/settlements', {
method: 'POST',
body: JSON.stringify(input)
});
}
export function confirmCleaningSettlement(session: ApiSession, settlementId: string, note?: string) {
return request<CleaningSettlement>(
session,
`/cleaning/settlements/${encodeURIComponent(settlementId)}/confirm`,
{
method: 'POST',
body: JSON.stringify({ note })
}
);
}
export function executeWechatTransfer(
session: ApiSession,
settlementId: string,
input: { mode: TransferMode; note?: string }
) {
return request<{ settlement: CleaningSettlement; transferState: string; idempotent: boolean }>(
session,
`/cleaning/settlements/${encodeURIComponent(settlementId)}/wechat-transfer`,
{
method: 'POST',
body: JSON.stringify(input)
}
);
}
export function syncWechatTransfer(session: ApiSession, settlementId: string, note?: string) {
return request<{ settlement: CleaningSettlement; transferState: string; idempotent: boolean }>(
session,
`/cleaning/settlements/${encodeURIComponent(settlementId)}/wechat-transfer/sync`,
{
method: 'POST',
body: JSON.stringify({ note })
}
);
}
export function recordPayoutFailure(
session: ApiSession,
settlementId: string,
input: { error: string; payoutChannel?: string; payoutReference?: string; note?: string }
) {
return request<CleaningSettlement>(
session,
`/cleaning/settlements/${encodeURIComponent(settlementId)}/payout-failure`,
{
method: 'POST',
body: JSON.stringify(input)
}
);
}
@@ -0,0 +1,266 @@
<template>
<section class="panel">
<div class="panel-toolbar">
<el-segmented
:model-value="status"
:options="statusOptions"
@update:model-value="$emit('status-change', $event as SettlementStatus | '')"
/>
<div class="toolbar-actions">
<el-button :icon="FilePlus2" type="primary" @click="generateDialog.open = true">
生成
</el-button>
<el-tooltip content="刷新结算">
<el-button :icon="RefreshCw" :loading="loading" circle @click="$emit('refresh')" />
</el-tooltip>
</div>
</div>
<el-table :data="items" :loading="loading" class="data-table" row-key="id">
<el-table-column prop="settlementNo" label="结算单" min-width="190" fixed>
<template #default="{ row }">
<div class="stack">
<strong>{{ row.settlementNo }}</strong>
<span>{{ row.cleanerName }} · {{ compactText(row.storeName) }}</span>
</div>
</template>
</el-table-column>
<el-table-column prop="status" label="状态" width="115">
<template #default="{ row }">
<el-tag :type="settlementTag(row.status)" effect="light">{{ row.status }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="taskCount" label="任务" width="80" />
<el-table-column prop="totalRewardCents" label="金额" width="110">
<template #default="{ row }">{{ money(row.totalRewardCents) }}</template>
</el-table-column>
<el-table-column prop="payoutState" label="转账状态" width="130">
<template #default="{ row }">{{ compactText(row.payoutState) }}</template>
</el-table-column>
<el-table-column prop="payoutReference" label="流水" min-width="150">
<template #default="{ row }">{{ compactText(row.payoutReference) }}</template>
</el-table-column>
<el-table-column prop="payoutError" label="失败原因" min-width="150">
<template #default="{ row }">{{ compactText(row.payoutError) }}</template>
</el-table-column>
<el-table-column prop="createdAt" label="创建" width="130">
<template #default="{ row }">{{ shortDate(row.createdAt) }}</template>
</el-table-column>
<el-table-column label="操作" width="320" fixed="right">
<template #default="{ row }">
<div class="row-actions">
<el-button
size="small"
:icon="BadgeCheck"
:disabled="row.status !== 'DRAFT'"
@click="$emit('confirm', { settlementId: row.id, note: '后台确认结算' })"
>
确认
</el-button>
<el-button
size="small"
type="primary"
:icon="Send"
:disabled="row.status !== 'CONFIRMED'"
@click="openTransfer(row)"
>
转账
</el-button>
<el-button
size="small"
:icon="RefreshCw"
:disabled="row.status !== 'CONFIRMED'"
@click="$emit('sync-transfer', { settlementId: row.id, note: '后台同步微信转账状态' })"
>
同步
</el-button>
<el-button
size="small"
type="danger"
:icon="CircleAlert"
:disabled="row.status !== 'CONFIRMED'"
@click="openFailure(row)"
>
失败
</el-button>
</div>
</template>
</el-table-column>
</el-table>
<div class="pager">
<el-pagination
layout="prev, pager, next, total"
:current-page="page"
:page-size="pageSize"
:total="total"
@current-change="$emit('page-change', $event)"
/>
</div>
</section>
<el-dialog v-model="generateDialog.open" title="生成结算单" width="420px">
<el-form label-position="top">
<el-form-item label="保洁员用户 ID">
<el-input v-model="generateDialog.cleanerUserId" inputmode="numeric" />
</el-form-item>
<el-form-item label="门店 ID">
<el-input v-model="generateDialog.storeId" inputmode="numeric" />
</el-form-item>
<el-form-item label="备注">
<el-input v-model="generateDialog.note" type="textarea" :rows="3" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="generateDialog.open = false">取消</el-button>
<el-button type="primary" @click="submitGenerate">生成</el-button>
</template>
</el-dialog>
<el-dialog v-model="transferDialog.open" title="微信转账" width="420px">
<el-form label-position="top">
<el-form-item label="模式">
<el-radio-group v-model="transferDialog.mode">
<el-radio-button label="API">API</el-radio-button>
<el-radio-button label="MOCK">MOCK</el-radio-button>
</el-radio-group>
</el-form-item>
<el-form-item label="备注">
<el-input v-model="transferDialog.note" type="textarea" :rows="3" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="transferDialog.open = false">取消</el-button>
<el-button type="primary" @click="submitTransfer">发起</el-button>
</template>
</el-dialog>
<el-dialog v-model="failureDialog.open" title="记录失败" width="420px">
<el-form label-position="top">
<el-form-item label="失败原因">
<el-input v-model="failureDialog.error" />
</el-form-item>
<el-form-item label="外部流水">
<el-input v-model="failureDialog.payoutReference" />
</el-form-item>
<el-form-item label="备注">
<el-input v-model="failureDialog.note" type="textarea" :rows="3" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="failureDialog.open = false">取消</el-button>
<el-button type="danger" @click="submitFailure">记录</el-button>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { reactive } from 'vue';
import {
BadgeCheck,
CircleAlert,
FilePlus2,
RefreshCw,
Send
} from '@lucide/vue';
import { compactText, money, shortDate } from '../format';
import type {
CleaningSettlement,
PageResult,
SettlementStatus,
TransferMode
} from '../types';
defineProps<{
session: { token: string };
loading: boolean;
items: PageResult<CleaningSettlement>['items'];
total: number;
page: number;
pageSize: number;
status: SettlementStatus | '';
}>();
const emit = defineEmits<{
refresh: [];
'status-change': [SettlementStatus | ''];
'page-change': [number];
generate: [{ cleanerUserId: string; storeId?: string; note?: string }];
confirm: [{ settlementId: string; note?: string }];
transfer: [{ settlementId: string; mode: TransferMode; note?: string }];
'sync-transfer': [{ settlementId: string; note?: string }];
failure: [{ settlementId: string; error: string; payoutReference?: string; note?: string }];
}>();
const statusOptions = [
{ label: '全部', value: '' },
{ label: '草稿', value: 'DRAFT' },
{ label: '待发', value: 'CONFIRMED' },
{ label: '已发', value: 'PAID' },
{ label: '取消', value: 'CANCELLED' }
];
const generateDialog = reactive({ open: false, cleanerUserId: '', storeId: '', note: '' });
const transferDialog = reactive({
open: false,
settlementId: '',
mode: 'API' as TransferMode,
note: ''
});
const failureDialog = reactive({
open: false,
settlementId: '',
error: '',
payoutReference: '',
note: ''
});
function settlementTag(status: SettlementStatus) {
if (status === 'PAID') return 'success';
if (status === 'CONFIRMED') return 'warning';
if (status === 'CANCELLED') return 'info';
return 'primary';
}
function submitGenerate() {
emit('generate', {
cleanerUserId: generateDialog.cleanerUserId,
storeId: generateDialog.storeId || undefined,
note: generateDialog.note
});
generateDialog.open = false;
}
function openTransfer(row: CleaningSettlement) {
transferDialog.open = true;
transferDialog.settlementId = row.id;
transferDialog.mode = 'API';
transferDialog.note = '';
}
function submitTransfer() {
emit('transfer', {
settlementId: transferDialog.settlementId,
mode: transferDialog.mode,
note: transferDialog.note
});
transferDialog.open = false;
}
function openFailure(row: CleaningSettlement) {
failureDialog.open = true;
failureDialog.settlementId = row.id;
failureDialog.error = row.payoutError || '';
failureDialog.payoutReference = row.payoutReference || '';
failureDialog.note = '';
}
function submitFailure() {
emit('failure', {
settlementId: failureDialog.settlementId,
error: failureDialog.error,
payoutReference: failureDialog.payoutReference,
note: failureDialog.note
});
failureDialog.open = false;
}
</script>
+205
View File
@@ -0,0 +1,205 @@
<template>
<section class="panel">
<div class="panel-toolbar">
<el-segmented
:model-value="status"
:options="statusOptions"
@update:model-value="$emit('status-change', $event as TaskStatus | '')"
/>
<div class="toolbar-actions">
<el-popover trigger="click" width="280">
<template #reference>
<el-button :icon="RotateCcw">回收</el-button>
</template>
<el-form label-position="top" class="compact-form">
<el-form-item label="分钟">
<el-input-number v-model="reclaimForm.olderThanMinutes" :min="5" :max="1440" />
</el-form-item>
<el-form-item label="数量">
<el-input-number v-model="reclaimForm.limit" :min="1" :max="100" />
</el-form-item>
<el-button type="primary" :icon="Check" @click="$emit('reclaim', reclaimForm)">
执行
</el-button>
</el-form>
</el-popover>
<el-tooltip content="刷新任务">
<el-button :icon="RefreshCw" :loading="loading" circle @click="$emit('refresh')" />
</el-tooltip>
</div>
</div>
<el-table :data="items" :loading="loading" class="data-table" row-key="id">
<el-table-column prop="taskNo" label="任务" min-width="180" fixed>
<template #default="{ row }">
<div class="stack">
<strong>{{ row.taskNo }}</strong>
<span>{{ row.storeName }} · {{ row.roomName || row.roomNo }}</span>
</div>
</template>
</el-table-column>
<el-table-column prop="status" label="状态" width="120">
<template #default="{ row }">
<el-tag :type="taskTag(row.status)" effect="light">{{ row.status }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="cleanerUserId" label="保洁员" width="110">
<template #default="{ row }">{{ compactText(row.cleanerUserId) }}</template>
</el-table-column>
<el-table-column prop="memberCount" label="成员" width="90" />
<el-table-column prop="rewardCents" label="奖励" width="110">
<template #default="{ row }">{{ money(row.rewardCents) }}</template>
</el-table-column>
<el-table-column prop="submittedAt" label="提交" width="130">
<template #default="{ row }">{{ shortDate(row.submittedAt) }}</template>
</el-table-column>
<el-table-column label="照片" width="90">
<template #default="{ row }">
<el-badge :value="row.photoUrls?.length || 0" :hidden="!row.photoUrls?.length">
<Image :size="18" />
</el-badge>
</template>
</el-table-column>
<el-table-column label="操作" width="270" fixed="right">
<template #default="{ row }">
<div class="row-actions">
<el-button size="small" :icon="UserPlus" @click="openAssign(row)">指派</el-button>
<el-button
size="small"
type="success"
:icon="BadgeCheck"
:disabled="row.status !== 'SUBMITTED'"
@click="$emit('complete', { taskId: row.id, note: '后台验收通过' })"
>
验收
</el-button>
<el-button
size="small"
type="danger"
:icon="Undo2"
:disabled="row.status !== 'SUBMITTED'"
@click="openReject(row)"
>
驳回
</el-button>
</div>
</template>
</el-table-column>
</el-table>
<div class="pager">
<el-pagination
layout="prev, pager, next, total"
:current-page="page"
:page-size="pageSize"
:total="total"
@current-change="$emit('page-change', $event)"
/>
</div>
</section>
<el-dialog v-model="assignDialog.open" title="指派保洁员" width="420px">
<el-form label-position="top">
<el-form-item label="保洁员用户 ID">
<el-input v-model="assignDialog.cleanerUserId" inputmode="numeric" />
</el-form-item>
<el-form-item label="备注">
<el-input v-model="assignDialog.note" type="textarea" :rows="3" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="assignDialog.open = false">取消</el-button>
<el-button type="primary" @click="submitAssign">确认</el-button>
</template>
</el-dialog>
<el-dialog v-model="rejectDialog.open" title="驳回任务" width="420px">
<el-input v-model="rejectDialog.reason" type="textarea" :rows="4" maxlength="512" show-word-limit />
<template #footer>
<el-button @click="rejectDialog.open = false">取消</el-button>
<el-button type="danger" @click="submitReject">驳回</el-button>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { reactive } from 'vue';
import {
BadgeCheck,
Check,
Image,
RefreshCw,
RotateCcw,
Undo2,
UserPlus
} from '@lucide/vue';
import { compactText, money, shortDate } from '../format';
import type { CleaningTask, PageResult, TaskStatus } from '../types';
defineProps<{
session: { token: string };
loading: boolean;
items: PageResult<CleaningTask>['items'];
total: number;
page: number;
pageSize: number;
status: TaskStatus | '';
}>();
const emit = defineEmits<{
refresh: [];
'status-change': [TaskStatus | ''];
'page-change': [number];
assign: [{ taskId: string; cleanerUserId: string; note?: string }];
complete: [{ taskId: string; note?: string }];
reject: [{ taskId: string; reason: string }];
reclaim: [{ olderThanMinutes: number; limit: number }];
}>();
const statusOptions = [
{ label: '全部', value: '' },
{ label: '待抢', value: 'WAITING' },
{ label: '已抢', value: 'CLAIMED' },
{ label: '进行', value: 'STARTED' },
{ label: '待验', value: 'SUBMITTED' },
{ label: '完成', value: 'COMPLETED' },
{ label: '驳回', value: 'REJECTED' }
];
const reclaimForm = reactive({ olderThanMinutes: 60, limit: 20 });
const assignDialog = reactive({ open: false, taskId: '', cleanerUserId: '', note: '' });
const rejectDialog = reactive({ open: false, taskId: '', reason: '' });
function taskTag(status: TaskStatus) {
if (status === 'SUBMITTED') return 'warning';
if (status === 'COMPLETED' || status === 'SETTLED') return 'success';
if (status === 'REJECTED') return 'danger';
return 'info';
}
function openAssign(row: CleaningTask) {
assignDialog.open = true;
assignDialog.taskId = row.id;
assignDialog.cleanerUserId = row.cleanerUserId || '';
assignDialog.note = '';
}
function submitAssign() {
emit('assign', {
taskId: assignDialog.taskId,
cleanerUserId: assignDialog.cleanerUserId,
note: assignDialog.note
});
assignDialog.open = false;
}
function openReject(row: CleaningTask) {
rejectDialog.open = true;
rejectDialog.taskId = row.id;
rejectDialog.reason = row.rejectReason || '';
}
function submitReject() {
emit('reject', { taskId: rejectDialog.taskId, reason: rejectDialog.reason });
rejectDialog.open = false;
}
</script>
+19
View File
@@ -0,0 +1,19 @@
export function money(cents: number) {
return `¥${(Number(cents || 0) / 100).toFixed(2)}`;
}
export function shortDate(value?: string | null) {
if (!value) return '-';
const date = new Date(value);
if (Number.isNaN(date.getTime())) return '-';
return date.toLocaleString('zh-CN', {
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
});
}
export function compactText(value?: string | null) {
return value && value.trim().length > 0 ? value : '-';
}
+7
View File
@@ -0,0 +1,7 @@
import { createApp } from 'vue';
import ElementPlus from 'element-plus';
import 'element-plus/dist/index.css';
import './styles.css';
import App from './App.vue';
createApp(App).use(ElementPlus).mount('#app');
+299
View File
@@ -0,0 +1,299 @@
:root {
color: #18212f;
background: #eef2f6;
font-family:
Inter, "PingFang SC", "Microsoft YaHei", system-ui, -apple-system, BlinkMacSystemFont,
"Segoe UI", sans-serif;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
min-width: 320px;
min-height: 100vh;
background:
linear-gradient(180deg, rgba(245, 247, 250, 0.96), rgba(231, 237, 243, 0.96)),
url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='120' height='120' viewBox='0 0 120 120'%3E%3Cg fill='none' stroke='%23d5dde7' stroke-width='1'%3E%3Cpath d='M0 40h120M0 80h120M40 0v120M80 0v120'/%3E%3C/g%3E%3C/svg%3E");
}
button,
input,
textarea {
font: inherit;
}
.app-shell {
display: grid;
grid-template-columns: 240px minmax(0, 1fr);
min-height: 100vh;
}
.sidebar {
background: #152033;
color: #f8fafc;
padding: 24px 18px;
}
.brand {
display: flex;
align-items: center;
gap: 12px;
min-height: 56px;
}
.brand-mark {
display: grid;
width: 42px;
height: 42px;
place-items: center;
border: 1px solid rgba(255, 255, 255, 0.24);
border-radius: 8px;
background: #e8f1ff;
color: #152033;
font-weight: 800;
}
.brand h1,
.brand p,
.workspace h2,
.eyebrow {
margin: 0;
}
.brand h1 {
font-size: 17px;
font-weight: 800;
}
.brand p {
margin-top: 3px;
color: #aab7c8;
font-size: 12px;
}
.nav-list {
display: grid;
gap: 8px;
margin-top: 30px;
}
.nav-item {
display: flex;
align-items: center;
gap: 10px;
width: 100%;
min-height: 42px;
padding: 0 12px;
border: 0;
border-radius: 8px;
background: transparent;
color: #cbd6e5;
cursor: pointer;
}
.nav-item.active {
background: #e8f1ff;
color: #162033;
font-weight: 700;
}
.nav-item:disabled {
color: #6f7d90;
cursor: not-allowed;
}
.workspace {
min-width: 0;
padding: 24px;
}
.topbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
min-height: 58px;
}
.eyebrow {
color: #4d6b92;
font-size: 12px;
font-weight: 800;
letter-spacing: 0;
}
.workspace h2 {
margin-top: 4px;
font-size: 24px;
}
.token-box {
display: grid;
grid-template-columns: minmax(220px, 360px) 40px;
gap: 8px;
width: min(100%, 420px);
}
.metric-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 12px;
margin: 20px 0 14px;
}
.metric {
min-height: 82px;
padding: 14px 16px;
border: 1px solid #d8e0ea;
border-radius: 8px;
background: #ffffff;
}
.metric span {
display: block;
color: #60708a;
font-size: 13px;
}
.metric strong {
display: block;
margin-top: 8px;
font-size: 26px;
}
.alert-line {
margin-bottom: 10px;
}
.work-tabs {
min-width: 0;
}
.panel {
min-width: 0;
border: 1px solid #d8e0ea;
border-radius: 8px;
background: #ffffff;
overflow: hidden;
}
.panel-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 14px;
border-bottom: 1px solid #e6ebf2;
overflow-x: auto;
}
.toolbar-actions,
.row-actions {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.data-table {
width: 100%;
}
.stack {
display: grid;
gap: 4px;
}
.stack strong {
font-size: 13px;
}
.stack span {
color: #69788c;
font-size: 12px;
}
.pager {
display: flex;
justify-content: flex-end;
padding: 12px 14px;
border-top: 1px solid #e6ebf2;
}
.compact-form {
display: grid;
gap: 4px;
}
.el-button {
border-radius: 8px;
}
.el-tabs__nav-wrap::after {
height: 1px;
background: #d8e0ea;
}
@media (max-width: 980px) {
.app-shell {
grid-template-columns: 1fr;
}
.sidebar {
position: sticky;
top: 0;
z-index: 10;
display: grid;
grid-template-columns: auto 1fr;
gap: 14px;
padding: 12px;
}
.nav-list {
grid-auto-flow: column;
grid-auto-columns: max-content;
align-content: center;
margin-top: 0;
overflow-x: auto;
}
.workspace {
padding: 16px;
}
.topbar {
align-items: stretch;
flex-direction: column;
}
.token-box {
grid-template-columns: minmax(0, 1fr) 40px;
width: 100%;
}
.metric-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 560px) {
.sidebar {
grid-template-columns: 1fr;
}
.metric-grid {
grid-template-columns: 1fr;
}
.panel-toolbar {
align-items: stretch;
flex-direction: column;
}
.toolbar-actions {
justify-content: flex-end;
}
}
+70
View File
@@ -0,0 +1,70 @@
export type TaskStatus =
| 'WAITING'
| 'CLAIMED'
| 'STARTED'
| 'SUBMITTED'
| 'COMPLETED'
| 'REJECTED'
| 'EXEMPT'
| 'SETTLED'
| 'CANCELLED';
export type SettlementStatus = 'DRAFT' | 'CONFIRMED' | 'PAID' | 'CANCELLED';
export type TransferMode = 'API' | 'MOCK';
export interface PageResult<T> {
items: T[];
total: number;
page: number;
pageSize: number;
}
export interface CleaningTask {
id: string;
taskNo: string;
storeId: string;
storeName: string;
roomId: string;
roomName: string;
roomNo: string;
orderId: string | null;
orderNo: string | null;
status: TaskStatus;
cleanerUserId: string | null;
priority: number;
rewardCents: number;
requirement: string;
photoUrls: string[];
rejectReason: string;
claimedAt?: string;
startedAt?: string;
submittedAt?: string;
completedAt?: string;
memberCount: number;
createdAt?: string;
updatedAt?: string;
}
export interface CleaningSettlement {
id: string;
settlementNo: string;
cleanerUserId: string;
cleanerName: string;
storeId: string | null;
storeName: string | null;
status: SettlementStatus;
taskCount: number;
totalRewardCents: number;
periodStart: string | null;
periodEnd: string | null;
paidBy: string | null;
confirmedAt: string | null;
paidAt: string | null;
payoutChannel: string;
payoutReference: string;
payoutState: string;
payoutPackageInfo: string;
payoutError: string;
note: string;
createdAt: string;
}