398 lines
14 KiB
Vue
398 lines
14 KiB
Vue
<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" :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>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { reactive } from 'vue';
|
|
import {
|
|
BadgeCheck,
|
|
Check,
|
|
Image,
|
|
RefreshCw,
|
|
RotateCcw,
|
|
Undo2,
|
|
UserMinus,
|
|
UserPlus,
|
|
Users
|
|
} from '@lucide/vue';
|
|
import { ElMessage } from 'element-plus';
|
|
import {
|
|
addCleaningTaskMember,
|
|
listCleaningTaskMembers,
|
|
removeCleaningTaskMember
|
|
} from '../api';
|
|
import { compactText, money, shortDate } from '../format';
|
|
import type { CleaningTask, 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 | '';
|
|
}>();
|
|
|
|
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: '' });
|
|
const memberDialog = reactive({
|
|
open: false,
|
|
loading: false,
|
|
taskId: '',
|
|
taskNo: '',
|
|
taskRewardCents: 0,
|
|
cleanerUserId: '',
|
|
rewardCents: 0,
|
|
note: '',
|
|
members: [] as CleaningTaskMember[]
|
|
});
|
|
|
|
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 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>
|