545 lines
19 KiB
Vue
545 lines
19 KiB
Vue
<template>
|
|
<section class="panel">
|
|
<div class="panel-toolbar">
|
|
<div class="toolbar-actions">
|
|
<el-segmented
|
|
:model-value="status"
|
|
:options="statusOptions"
|
|
@update:model-value="$emit('status-change', $event as TaskStatus | '')"
|
|
/>
|
|
<el-input
|
|
v-model="filterDraft.storeId"
|
|
class="narrow-input"
|
|
placeholder="门店 ID"
|
|
clearable
|
|
@keyup.enter="submitFilters"
|
|
@clear="submitFilters"
|
|
/>
|
|
<el-select
|
|
v-model="filterDraft.cleanerUserId"
|
|
class="filter-select"
|
|
filterable
|
|
clearable
|
|
placeholder="保洁员"
|
|
@change="submitFilters"
|
|
@clear="submitFilters"
|
|
>
|
|
<el-option
|
|
v-for="cleaner in cleaners"
|
|
:key="cleaner.id"
|
|
:label="cleanerLabel(cleaner)"
|
|
:value="cleaner.id"
|
|
/>
|
|
</el-select>
|
|
<el-button :icon="Search" @click="submitFilters">筛选</el-button>
|
|
</div>
|
|
<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="ListChecks" @click="openDetail(row)">详情</el-button>
|
|
<el-button size="small" :icon="UserPlus" @click="openAssign(row)">指派</el-button>
|
|
<el-button size="small" :icon="Users" @click="openMembers(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="保洁员">
|
|
<el-select
|
|
v-model="assignDialog.cleanerUserId"
|
|
class="cleaner-select"
|
|
filterable
|
|
placeholder="选择保洁员"
|
|
:disabled="!cleaners.length"
|
|
>
|
|
<el-option
|
|
v-for="cleaner in cleaners"
|
|
:key="cleaner.id"
|
|
:label="cleanerLabel(cleaner)"
|
|
:value="cleaner.id"
|
|
>
|
|
<div class="option-row">
|
|
<strong>{{ cleaner.nickname || compactText(cleaner.id) }}</strong>
|
|
<span>ID {{ cleaner.id }} · {{ cleaner.maskedPhone || '无手机号' }}</span>
|
|
</div>
|
|
<el-tag :type="cleaner.wechatMiniappBound ? 'success' : 'warning'" effect="light" size="small">
|
|
{{ cleaner.wechatMiniappBound ? '已绑微信' : '未绑微信' }}
|
|
</el-tag>
|
|
</el-option>
|
|
</el-select>
|
|
</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>
|
|
|
|
<el-dialog v-model="memberDialog.open" title="协作者管理" width="640px">
|
|
<div class="dialog-stack">
|
|
<div class="detail-header">
|
|
<strong>{{ memberDialog.taskNo }}</strong>
|
|
<span>任务奖励 {{ money(memberDialog.taskRewardCents) }}</span>
|
|
</div>
|
|
<el-table :data="memberDialog.members" size="small" v-loading="memberDialog.loading">
|
|
<el-table-column prop="nickname" label="成员" min-width="140">
|
|
<template #default="{ row }">
|
|
<div class="stack">
|
|
<strong>{{ row.nickname || compactText(row.userId) }}</strong>
|
|
<span>ID {{ row.userId }}</span>
|
|
</div>
|
|
</template>
|
|
</el-table-column>
|
|
<el-table-column prop="memberRole" label="角色" width="90">
|
|
<template #default="{ row }">
|
|
<el-tag :type="row.memberRole === 'LEAD' ? 'success' : 'info'" effect="light">
|
|
{{ row.memberRole }}
|
|
</el-tag>
|
|
</template>
|
|
</el-table-column>
|
|
<el-table-column prop="rewardCents" label="分账" width="110">
|
|
<template #default="{ row }">{{ money(row.rewardCents) }}</template>
|
|
</el-table-column>
|
|
<el-table-column prop="settledAt" label="结算" width="130">
|
|
<template #default="{ row }">{{ shortDate(row.settledAt) }}</template>
|
|
</el-table-column>
|
|
<el-table-column label="操作" width="90">
|
|
<template #default="{ row }">
|
|
<el-button
|
|
size="small"
|
|
type="danger"
|
|
:icon="UserMinus"
|
|
:disabled="row.memberRole === 'LEAD' || !!row.settledAt"
|
|
@click="removeMember(row.userId)"
|
|
>
|
|
移除
|
|
</el-button>
|
|
</template>
|
|
</el-table-column>
|
|
</el-table>
|
|
<el-form label-position="top" class="member-form">
|
|
<el-form-item label="协作者">
|
|
<el-select
|
|
v-model="memberDialog.cleanerUserId"
|
|
class="cleaner-select"
|
|
filterable
|
|
placeholder="选择协作者"
|
|
:disabled="!cleaners.length"
|
|
>
|
|
<el-option
|
|
v-for="cleaner in cleaners"
|
|
:key="cleaner.id"
|
|
:label="cleanerLabel(cleaner)"
|
|
:value="cleaner.id"
|
|
>
|
|
<div class="option-row">
|
|
<strong>{{ cleaner.nickname || compactText(cleaner.id) }}</strong>
|
|
<span>ID {{ cleaner.id }} · {{ cleaner.maskedPhone || '无手机号' }}</span>
|
|
</div>
|
|
<el-tag :type="cleaner.wechatMiniappBound ? 'success' : 'warning'" effect="light" size="small">
|
|
{{ cleaner.wechatMiniappBound ? '已绑微信' : '未绑微信' }}
|
|
</el-tag>
|
|
</el-option>
|
|
</el-select>
|
|
</el-form-item>
|
|
<el-form-item label="分账金额">
|
|
<el-input-number v-model="memberDialog.rewardCents" :min="0" :max="1000000" />
|
|
</el-form-item>
|
|
<el-form-item label="备注">
|
|
<el-input v-model="memberDialog.note" />
|
|
</el-form-item>
|
|
<el-button type="primary" :icon="UserPlus" @click="submitMember">添加/更新</el-button>
|
|
</el-form>
|
|
</div>
|
|
<template #footer>
|
|
<el-button @click="memberDialog.open = false">关闭</el-button>
|
|
</template>
|
|
</el-dialog>
|
|
|
|
<el-dialog v-model="detailDialog.open" title="保洁任务详情" width="760px">
|
|
<div v-if="detailDialog.task" class="dialog-stack">
|
|
<div class="detail-grid">
|
|
<span>任务号<strong>{{ detailDialog.task.taskNo }}</strong></span>
|
|
<span>状态<strong>{{ detailDialog.task.status }}</strong></span>
|
|
<span>奖励<strong>{{ money(detailDialog.task.rewardCents) }}</strong></span>
|
|
<span>保洁员<strong>{{ compactText(detailDialog.task.cleanerUserId) }}</strong></span>
|
|
</div>
|
|
<el-descriptions :column="2" size="small" border>
|
|
<el-descriptions-item label="门店房间">
|
|
{{ detailDialog.task.storeName }} · {{ detailDialog.task.roomName || detailDialog.task.roomNo }}
|
|
</el-descriptions-item>
|
|
<el-descriptions-item label="订单">
|
|
{{ compactText(detailDialog.task.orderNo) }}
|
|
</el-descriptions-item>
|
|
<el-descriptions-item label="要求" :span="2">
|
|
{{ compactText(detailDialog.task.requirement) }}
|
|
</el-descriptions-item>
|
|
<el-descriptions-item label="驳回原因" :span="2">
|
|
{{ compactText(detailDialog.task.rejectReason) }}
|
|
</el-descriptions-item>
|
|
</el-descriptions>
|
|
<div class="timeline-grid">
|
|
<span>领取<strong>{{ shortDate(detailDialog.task.claimedAt) }}</strong></span>
|
|
<span>开始<strong>{{ shortDate(detailDialog.task.startedAt) }}</strong></span>
|
|
<span>提交<strong>{{ shortDate(detailDialog.task.submittedAt) }}</strong></span>
|
|
<span>完成<strong>{{ shortDate(detailDialog.task.completedAt) }}</strong></span>
|
|
</div>
|
|
<section class="stat-block">
|
|
<header>
|
|
<h3>操作流水</h3>
|
|
<span>最近 {{ detailDialog.events.length }} 条</span>
|
|
</header>
|
|
<el-table :data="detailDialog.events" size="small" v-loading="detailDialog.loadingEvents">
|
|
<el-table-column prop="action" label="动作" width="130" />
|
|
<el-table-column label="状态" width="150">
|
|
<template #default="{ row }">
|
|
{{ compactText(row.fromStatus) }} → {{ row.toStatus }}
|
|
</template>
|
|
</el-table-column>
|
|
<el-table-column prop="actorId" label="操作人" width="100">
|
|
<template #default="{ row }">{{ compactText(row.actorId) }}</template>
|
|
</el-table-column>
|
|
<el-table-column prop="note" label="备注" min-width="160">
|
|
<template #default="{ row }">{{ compactText(row.note) }}</template>
|
|
</el-table-column>
|
|
<el-table-column prop="createdAt" label="时间" width="130">
|
|
<template #default="{ row }">{{ shortDate(row.createdAt) }}</template>
|
|
</el-table-column>
|
|
</el-table>
|
|
</section>
|
|
<div class="photo-grid" v-if="detailDialog.task.photoUrls.length">
|
|
<el-image
|
|
v-for="url in detailDialog.task.photoUrls"
|
|
:key="url"
|
|
:src="url"
|
|
fit="cover"
|
|
:preview-src-list="detailDialog.task.photoUrls"
|
|
preview-teleported
|
|
/>
|
|
</div>
|
|
<el-empty v-else description="暂无保洁照片" :image-size="80" />
|
|
</div>
|
|
<template #footer>
|
|
<el-button @click="detailDialog.open = false">关闭</el-button>
|
|
</template>
|
|
</el-dialog>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { reactive, watch } from 'vue';
|
|
import {
|
|
BadgeCheck,
|
|
Check,
|
|
Image,
|
|
ListChecks,
|
|
RefreshCw,
|
|
RotateCcw,
|
|
Search,
|
|
Undo2,
|
|
UserMinus,
|
|
UserPlus,
|
|
Users
|
|
} from '@lucide/vue';
|
|
import { ElMessage } from 'element-plus';
|
|
import {
|
|
addCleaningTaskMember,
|
|
listCleaningTaskEvents,
|
|
listCleaningTaskMembers,
|
|
removeCleaningTaskMember
|
|
} from '../api';
|
|
import { compactText, money, shortDate } from '../format';
|
|
import type {
|
|
CleaningTask,
|
|
CleaningTaskEvent,
|
|
CleaningTaskMember,
|
|
ManagedUser,
|
|
PageResult,
|
|
TaskStatus
|
|
} from '../types';
|
|
|
|
const props = defineProps<{
|
|
session: { token: string };
|
|
loading: boolean;
|
|
items: PageResult<CleaningTask>['items'];
|
|
cleaners: ManagedUser[];
|
|
total: number;
|
|
page: number;
|
|
pageSize: number;
|
|
status: TaskStatus | '';
|
|
filters: { storeId: string; cleanerUserId: string };
|
|
}>();
|
|
|
|
const emit = defineEmits<{
|
|
refresh: [];
|
|
'status-change': [TaskStatus | ''];
|
|
filter: [{ storeId: string; cleanerUserId: string }];
|
|
'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 filterDraft = reactive({ storeId: props.filters.storeId, cleanerUserId: props.filters.cleanerUserId });
|
|
const reclaimForm = reactive({ olderThanMinutes: 60, limit: 20 });
|
|
const assignDialog = reactive({ open: false, taskId: '', cleanerUserId: '', note: '' });
|
|
const rejectDialog = reactive({ open: false, taskId: '', reason: '' });
|
|
const detailDialog = reactive<{
|
|
open: boolean;
|
|
loadingEvents: boolean;
|
|
task: CleaningTask | null;
|
|
events: CleaningTaskEvent[];
|
|
}>({
|
|
open: false,
|
|
loadingEvents: false,
|
|
task: null,
|
|
events: []
|
|
});
|
|
const memberDialog = reactive({
|
|
open: false,
|
|
loading: false,
|
|
taskId: '',
|
|
taskNo: '',
|
|
taskRewardCents: 0,
|
|
cleanerUserId: '',
|
|
rewardCents: 0,
|
|
note: '',
|
|
members: [] as CleaningTaskMember[]
|
|
});
|
|
|
|
watch(
|
|
() => props.filters,
|
|
(value) => Object.assign(filterDraft, value),
|
|
{ deep: true }
|
|
);
|
|
|
|
function taskTag(status: TaskStatus) {
|
|
if (status === 'SUBMITTED') return 'warning';
|
|
if (status === 'COMPLETED' || status === 'SETTLED') return 'success';
|
|
if (status === 'REJECTED') return 'danger';
|
|
return 'info';
|
|
}
|
|
|
|
function cleanerLabel(cleaner: ManagedUser) {
|
|
const name = cleaner.nickname || `ID ${cleaner.id}`;
|
|
const phone = cleaner.maskedPhone ? ` · ${cleaner.maskedPhone}` : '';
|
|
const stores = cleaner.storeIds.length ? ` · 门店 ${cleaner.storeIds.join('/')}` : '';
|
|
return `${name}${phone}${stores}`;
|
|
}
|
|
|
|
function submitFilters() {
|
|
emit('filter', {
|
|
storeId: filterDraft.storeId.trim(),
|
|
cleanerUserId: filterDraft.cleanerUserId
|
|
});
|
|
}
|
|
|
|
async function openDetail(row: CleaningTask) {
|
|
detailDialog.open = true;
|
|
detailDialog.task = row;
|
|
detailDialog.events = [];
|
|
detailDialog.loadingEvents = true;
|
|
try {
|
|
detailDialog.events = await listCleaningTaskEvents(props.session, row.id);
|
|
} catch (error) {
|
|
ElMessage.error(error instanceof Error ? error.message : '任务流水加载失败');
|
|
} finally {
|
|
detailDialog.loadingEvents = false;
|
|
}
|
|
}
|
|
|
|
function openAssign(row: CleaningTask) {
|
|
assignDialog.open = true;
|
|
assignDialog.taskId = row.id;
|
|
assignDialog.cleanerUserId = row.cleanerUserId || '';
|
|
assignDialog.note = '';
|
|
}
|
|
|
|
function submitAssign() {
|
|
if (!assignDialog.cleanerUserId) {
|
|
ElMessage.warning('请选择保洁员');
|
|
return;
|
|
}
|
|
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;
|
|
}
|
|
|
|
async function loadMembers() {
|
|
memberDialog.loading = true;
|
|
try {
|
|
memberDialog.members = await listCleaningTaskMembers(props.session, memberDialog.taskId);
|
|
} catch (error) {
|
|
ElMessage.error(error instanceof Error ? error.message : '成员加载失败');
|
|
} finally {
|
|
memberDialog.loading = false;
|
|
}
|
|
}
|
|
|
|
async function openMembers(row: CleaningTask) {
|
|
memberDialog.open = true;
|
|
memberDialog.taskId = row.id;
|
|
memberDialog.taskNo = row.taskNo;
|
|
memberDialog.taskRewardCents = row.rewardCents;
|
|
memberDialog.cleanerUserId = '';
|
|
memberDialog.rewardCents = 0;
|
|
memberDialog.note = '';
|
|
await loadMembers();
|
|
}
|
|
|
|
async function submitMember() {
|
|
if (!memberDialog.cleanerUserId) {
|
|
ElMessage.warning('请选择协作者');
|
|
return;
|
|
}
|
|
try {
|
|
memberDialog.members = await addCleaningTaskMember(props.session, memberDialog.taskId, {
|
|
cleanerUserId: memberDialog.cleanerUserId,
|
|
rewardCents: memberDialog.rewardCents,
|
|
note: memberDialog.note
|
|
});
|
|
memberDialog.cleanerUserId = '';
|
|
memberDialog.rewardCents = 0;
|
|
memberDialog.note = '';
|
|
ElMessage.success('协作者已更新');
|
|
emit('refresh');
|
|
} catch (error) {
|
|
ElMessage.error(error instanceof Error ? error.message : '协作者更新失败');
|
|
}
|
|
}
|
|
|
|
async function removeMember(cleanerUserId: string) {
|
|
try {
|
|
memberDialog.members = await removeCleaningTaskMember(
|
|
props.session,
|
|
memberDialog.taskId,
|
|
cleanerUserId,
|
|
'后台移除协作者'
|
|
);
|
|
ElMessage.success('协作者已移除');
|
|
emit('refresh');
|
|
} catch (error) {
|
|
ElMessage.error(error instanceof Error ? error.message : '协作者移除失败');
|
|
}
|
|
}
|
|
</script>
|