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
+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>