814 lines
28 KiB
Vue
814 lines
28 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-button :icon="Download" :disabled="items.length === 0" @click="exportTasks">
|
|
导出
|
|
</el-button>
|
|
<el-button
|
|
type="success"
|
|
:icon="BadgeCheck"
|
|
:disabled="selectedSubmittedCount === 0"
|
|
@click="submitBatchComplete"
|
|
>
|
|
批量验收
|
|
</el-button>
|
|
<el-button
|
|
type="danger"
|
|
:icon="Undo2"
|
|
:disabled="selectedSubmittedCount === 0"
|
|
@click="openBatchReject"
|
|
>
|
|
批量驳回
|
|
</el-button>
|
|
<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"
|
|
@selection-change="handleSelectionChange"
|
|
>
|
|
<el-table-column type="selection" width="44" :selectable="canSelectTask" />
|
|
<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="340" 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>
|
|
<el-button
|
|
size="small"
|
|
type="warning"
|
|
:icon="Ban"
|
|
:disabled="!canExempt(row.status)"
|
|
@click="$emit('exempt', { taskId: row.id, note: '\u540e\u53f0\u6807\u8bb0\u514d\u6e05\u6d01' })"
|
|
>
|
|
{{ '\u514d\u6e05\u6d01' }}
|
|
</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="batchRejectDialog.open" title="批量驳回待验收任务" width="460px">
|
|
<div class="dialog-stack">
|
|
<el-alert
|
|
:title="`将驳回 ${selectedSubmittedCount} 个已选择的待验收任务`"
|
|
type="warning"
|
|
show-icon
|
|
:closable="false"
|
|
/>
|
|
<el-input
|
|
v-model="batchRejectDialog.reason"
|
|
type="textarea"
|
|
:rows="4"
|
|
maxlength="512"
|
|
show-word-limit
|
|
placeholder="请输入统一驳回原因"
|
|
/>
|
|
</div>
|
|
<template #footer>
|
|
<el-button @click="batchRejectDialog.open = false">取消</el-button>
|
|
<el-button type="danger" @click="submitBatchReject">批量驳回</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>
|
|
<div class="row-actions">
|
|
<el-button size="small" :icon="Download" :disabled="!memberDialog.members.length" @click="exportTaskMembers">
|
|
导出成员
|
|
</el-button>
|
|
</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>
|
|
<div class="row-actions">
|
|
<el-button size="small" :icon="Download" :disabled="!detailDialog.events.length" @click="exportTaskEvents">
|
|
导出流水
|
|
</el-button>
|
|
</div>
|
|
<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>
|
|
<section class="stat-block">
|
|
<header>
|
|
<h3>协作分账</h3>
|
|
<span>{{ detailDialog.members.length }} 人</span>
|
|
</header>
|
|
<el-table :data="detailDialog.members" size="small" v-loading="detailDialog.loadingMembers">
|
|
<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>
|
|
</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 :icon="Download" :disabled="!detailDialog.task" @click="exportTaskDetail">
|
|
导出详情
|
|
</el-button>
|
|
<el-button @click="detailDialog.open = false">关闭</el-button>
|
|
</template>
|
|
</el-dialog>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { computed, reactive, ref, watch } from 'vue';
|
|
import {
|
|
BadgeCheck,
|
|
Ban,
|
|
Check,
|
|
Download,
|
|
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 }];
|
|
'complete-many': [{ taskIds: string[]; note?: string }];
|
|
reject: [{ taskId: string; reason: string }];
|
|
'reject-many': [{ taskIds: string[]; reason: string }];
|
|
exempt: [{ taskId: string; note?: 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: '\u9a73\u56de', value: 'REJECTED' },
|
|
{ label: '\u514d\u6e05\u6d01', value: 'EXEMPT' }
|
|
];
|
|
const filterDraft = reactive({ storeId: props.filters.storeId, cleanerUserId: props.filters.cleanerUserId });
|
|
const reclaimForm = reactive({ olderThanMinutes: 60, limit: 20 });
|
|
const selectedTasks = ref<CleaningTask[]>([]);
|
|
const assignDialog = reactive({ open: false, taskId: '', cleanerUserId: '', note: '' });
|
|
const rejectDialog = reactive({ open: false, taskId: '', reason: '' });
|
|
const batchRejectDialog = reactive({ open: false, reason: '' });
|
|
const detailDialog = reactive<{
|
|
open: boolean;
|
|
loadingEvents: boolean;
|
|
loadingMembers: boolean;
|
|
task: CleaningTask | null;
|
|
events: CleaningTaskEvent[];
|
|
members: CleaningTaskMember[];
|
|
}>({
|
|
open: false,
|
|
loadingEvents: false,
|
|
loadingMembers: false,
|
|
task: null,
|
|
events: [],
|
|
members: []
|
|
});
|
|
const memberDialog = reactive({
|
|
open: false,
|
|
loading: false,
|
|
taskId: '',
|
|
taskNo: '',
|
|
taskRewardCents: 0,
|
|
cleanerUserId: '',
|
|
rewardCents: 0,
|
|
note: '',
|
|
members: [] as CleaningTaskMember[]
|
|
});
|
|
const selectedSubmittedTasks = computed(() => selectedTasks.value
|
|
.filter((task) => task.status === 'SUBMITTED'));
|
|
const selectedSubmittedCount = computed(() => selectedSubmittedTasks.value.length);
|
|
|
|
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 canSelectTask(row: CleaningTask) {
|
|
return row.status === 'SUBMITTED';
|
|
}
|
|
|
|
function canExempt(status: TaskStatus) {
|
|
return ['WAITING', 'CLAIMED', 'STARTED', 'SUBMITTED', 'REJECTED'].includes(status);
|
|
}
|
|
|
|
function handleSelectionChange(rows: CleaningTask[]) {
|
|
selectedTasks.value = rows;
|
|
}
|
|
|
|
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.members = [];
|
|
detailDialog.loadingEvents = true;
|
|
detailDialog.loadingMembers = true;
|
|
try {
|
|
const [events, members] = await Promise.all([
|
|
listCleaningTaskEvents(props.session, row.id),
|
|
listCleaningTaskMembers(props.session, row.id)
|
|
]);
|
|
detailDialog.events = events;
|
|
detailDialog.members = members;
|
|
} catch (error) {
|
|
ElMessage.error(error instanceof Error ? error.message : '任务详情加载失败');
|
|
} finally {
|
|
detailDialog.loadingEvents = false;
|
|
detailDialog.loadingMembers = 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;
|
|
}
|
|
|
|
function submitBatchComplete() {
|
|
const taskIds = selectedSubmittedTasks.value.map((task) => task.id);
|
|
if (!taskIds.length) {
|
|
ElMessage.warning('请选择待验收任务');
|
|
return;
|
|
}
|
|
emit('complete-many', { taskIds, note: '后台批量验收通过' });
|
|
}
|
|
|
|
function openBatchReject() {
|
|
if (!selectedSubmittedCount.value) {
|
|
ElMessage.warning('请选择待验收任务');
|
|
return;
|
|
}
|
|
batchRejectDialog.open = true;
|
|
batchRejectDialog.reason = '';
|
|
}
|
|
|
|
function submitBatchReject() {
|
|
const reason = batchRejectDialog.reason.trim();
|
|
if (!reason) {
|
|
ElMessage.warning('请输入驳回原因');
|
|
return;
|
|
}
|
|
const taskIds = selectedSubmittedTasks.value.map((task) => task.id);
|
|
emit('reject-many', { taskIds, reason });
|
|
batchRejectDialog.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 : '协作者移除失败');
|
|
}
|
|
}
|
|
|
|
function exportTasks() {
|
|
downloadCsv(`cleaning-tasks-${Date.now()}.csv`, [
|
|
['任务号', '状态', '门店', '房间', '订单号', '保洁员ID', '协作人数', '奖励金额', '照片数', '提交时间', '完成时间', '驳回原因'],
|
|
...props.items.map((item) => [
|
|
item.taskNo,
|
|
item.status,
|
|
item.storeName,
|
|
item.roomName || item.roomNo,
|
|
item.orderNo || '',
|
|
item.cleanerUserId || '',
|
|
String(item.memberCount || 0),
|
|
money(item.rewardCents),
|
|
String(item.photoUrls?.length || 0),
|
|
shortDate(item.submittedAt),
|
|
shortDate(item.completedAt),
|
|
item.rejectReason || ''
|
|
])
|
|
]);
|
|
}
|
|
|
|
function exportTaskDetail() {
|
|
const task = detailDialog.task;
|
|
if (!task) return;
|
|
downloadCsv(`cleaning-task-detail-${task.taskNo}-${Date.now()}.csv`, [
|
|
['类别', '字段', '值'],
|
|
['task', '任务号', task.taskNo],
|
|
['task', '状态', task.status],
|
|
['task', '门店', task.storeName],
|
|
['task', '房间', task.roomName || task.roomNo],
|
|
['task', '订单号', task.orderNo || ''],
|
|
['task', '保洁员ID', task.cleanerUserId || ''],
|
|
['task', '奖励金额', money(task.rewardCents)],
|
|
['task', '照片数', String(task.photoUrls?.length || 0)],
|
|
['task', '领取时间', shortDate(task.claimedAt)],
|
|
['task', '开始时间', shortDate(task.startedAt)],
|
|
['task', '提交时间', shortDate(task.submittedAt)],
|
|
['task', '完成时间', shortDate(task.completedAt)],
|
|
['task', '保洁要求', task.requirement || ''],
|
|
['task', '驳回原因', task.rejectReason || ''],
|
|
...detailDialog.members.map((member) => [
|
|
'member',
|
|
`${member.memberRole}:${member.userId}`,
|
|
`${member.nickname || ''} ${money(member.rewardCents)} ${member.settledAt ? shortDate(member.settledAt) : '未结算'}`
|
|
]),
|
|
...detailDialog.events.map((event) => [
|
|
'event',
|
|
`${event.action}:${shortDate(event.createdAt)}`,
|
|
`${compactText(event.fromStatus)} -> ${event.toStatus} ${compactText(event.actorId)} ${compactText(event.note)}`
|
|
]),
|
|
...task.photoUrls.map((url, index) => [
|
|
'photo',
|
|
`photo_${index + 1}`,
|
|
url
|
|
])
|
|
]);
|
|
}
|
|
|
|
function exportTaskEvents() {
|
|
const task = detailDialog.task;
|
|
if (!task) return;
|
|
downloadCsv(`cleaning-task-events-${task.taskNo}-${Date.now()}.csv`, [
|
|
['任务号', '动作', '原状态', '新状态', '操作人', 'TraceId', '备注', '时间'],
|
|
...detailDialog.events.map((event) => [
|
|
task.taskNo,
|
|
event.action,
|
|
event.fromStatus || '',
|
|
event.toStatus,
|
|
event.actorId,
|
|
event.traceId,
|
|
event.note,
|
|
shortDate(event.createdAt)
|
|
])
|
|
]);
|
|
}
|
|
|
|
function exportTaskMembers() {
|
|
downloadCsv(`cleaning-task-members-${memberDialog.taskNo}-${Date.now()}.csv`, [
|
|
['任务号', '成员ID', '昵称', '角色', '分账金额', '结算时间'],
|
|
...memberDialog.members.map((member) => [
|
|
memberDialog.taskNo,
|
|
member.userId,
|
|
member.nickname || '',
|
|
member.memberRole,
|
|
money(member.rewardCents),
|
|
shortDate(member.settledAt)
|
|
])
|
|
]);
|
|
}
|
|
|
|
function downloadCsv(filename: string, rows: string[][]) {
|
|
const content = rows.map((row) => row.map(csvCell).join(',')).join('\r\n');
|
|
const blob = new Blob([`\uFEFF${content}`], { type: 'text/csv;charset=utf-8' });
|
|
const url = URL.createObjectURL(blob);
|
|
const link = document.createElement('a');
|
|
link.href = url;
|
|
link.download = filename;
|
|
link.click();
|
|
URL.revokeObjectURL(url);
|
|
}
|
|
|
|
function csvCell(value: string) {
|
|
const normalized = value.replace(/\r?\n/g, ' ');
|
|
return /[",\r\n]/.test(normalized) ? `"${normalized.replace(/"/g, '""')}"` : normalized;
|
|
}
|
|
</script>
|