feat(M09-B): 完成清洁规则与照片验收闭环

This commit is contained in:
Codex
2026-08-10 21:49:07 +08:00
parent b507e87999
commit bc7cb8fab9
24 changed files with 1650 additions and 81 deletions
+34
View File
@@ -9,8 +9,12 @@ import type {
CleaningSettlementDetail,
CleaningStatistics,
CleaningTask,
CleaningTemplate,
CleaningTemplateScope,
CleaningExemptPolicy,
CleaningTaskEvent,
CleaningTaskMember,
CleaningTaskSubmission,
DecorationComponent,
DecorationVersion,
FranchiseApplication,
@@ -449,6 +453,29 @@ export function getCleaningStatistics(
return request<CleaningStatistics>(session, `/cleaning/statistics${query ? `?${query}` : ''}`);
}
export function listCleaningTemplates(session: ApiSession) {
return request<CleaningTemplate[]>(session, '/cleaning/templates');
}
export function upsertCleaningTemplate(
session: ApiSession,
input: {
scopeType: CleaningTemplateScope;
scopeId?: string;
name: string;
requirement: string;
photoRequired: boolean;
minPhotoCount: number;
maxPhotoCount: number;
exemptPolicy: CleaningExemptPolicy;
status: 'ACTIVE' | 'DISABLED';
}
) {
return request<CleaningTemplate>(session, '/cleaning/templates', {
method: 'PUT', body: JSON.stringify(input)
});
}
export function listStaffUsers(
session: ApiSession,
input: {
@@ -729,6 +756,13 @@ export function listCleaningTaskEvents(session: ApiSession, taskId: string) {
);
}
export function listCleaningTaskSubmissions(session: ApiSession, taskId: string) {
return request<CleaningTaskSubmission[]>(
session,
`/cleaning/tasks/${encodeURIComponent(taskId)}/submissions`
);
}
export function addCleaningTaskMember(
session: ApiSession,
taskId: string,
+246
View File
@@ -0,0 +1,246 @@
<template>
<section class="rules-panel">
<header class="panel-heading">
<div>
<p class="eyebrow">M09-B · 清洁规则</p>
<h3>门店与房间清洁模板</h3>
<p>模板按房间门店租户依次匹配保存后只影响新任务既有任务继续使用创建时快照</p>
</div>
<el-button :loading="loading" @click="loadTemplates">刷新</el-button>
</header>
<el-alert
title="配置变更不会静默改写在途任务;每次保存都会记录审计。"
type="info"
show-icon
:closable="false"
/>
<el-form class="rule-form" label-position="top" @submit.prevent>
<el-form-item label="作用范围">
<el-select v-model="draft.scopeType" @change="handleScopeChange">
<el-option label="租户默认" value="TENANT" />
<el-option label="指定门店" value="STORE" />
<el-option label="指定房间" value="ROOM" />
</el-select>
</el-form-item>
<el-form-item v-if="draft.scopeType === 'STORE'" label="门店">
<el-select v-model="draft.scopeId" filterable placeholder="请选择门店">
<el-option v-for="store in stores" :key="store.id" :label="store.name" :value="store.id" />
</el-select>
</el-form-item>
<el-form-item v-if="draft.scopeType === 'ROOM'" label="所属门店">
<el-select v-model="draft.roomStoreId" filterable placeholder="先选择门店" @change="handleRoomStoreChange">
<el-option v-for="store in stores" :key="store.id" :label="store.name" :value="store.id" />
</el-select>
</el-form-item>
<el-form-item v-if="draft.scopeType === 'ROOM'" label="房间">
<el-select v-model="draft.scopeId" filterable placeholder="请选择房间" :disabled="!draft.roomStoreId">
<el-option
v-for="room in rooms"
:key="room.id"
:label="`${room.roomNo} · ${room.name}`"
:value="room.id"
/>
</el-select>
</el-form-item>
<el-form-item label="模板名称">
<el-input v-model.trim="draft.name" maxlength="128" show-word-limit />
</el-form-item>
<el-form-item label="状态">
<el-select v-model="draft.status">
<el-option label="启用" value="ACTIVE" />
<el-option label="停用" value="DISABLED" />
</el-select>
</el-form-item>
<el-form-item class="wide-field" label="清洁要求">
<el-input v-model.trim="draft.requirement" type="textarea" :rows="3" maxlength="512" show-word-limit />
</el-form-item>
<el-form-item label="必须上传照片">
<el-switch v-model="draft.photoRequired" @change="normalizePhotoRule" />
</el-form-item>
<el-form-item label="最少照片">
<el-input-number v-model="draft.minPhotoCount" :min="draft.photoRequired ? 1 : 0" :max="draft.maxPhotoCount" />
</el-form-item>
<el-form-item label="最多照片">
<el-input-number v-model="draft.maxPhotoCount" :min="Math.max(1, draft.minPhotoCount)" :max="9" />
</el-form-item>
<el-form-item label="免清洁策略">
<el-select v-model="draft.exemptPolicy">
<el-option label="不允许免清洁" value="DISABLED" />
<el-option label="开始前允许" value="BEFORE_START" />
<el-option label="活动阶段均允许" value="ANY_ACTIVE" />
</el-select>
</el-form-item>
<div class="wide-field form-actions">
<el-button type="primary" :loading="saving" @click="saveTemplate">保存模板</el-button>
<el-button @click="resetDraft">新建配置</el-button>
</div>
</el-form>
<el-table v-loading="loading" :data="templates" class="data-table" empty-text="暂无清洁模板">
<el-table-column prop="scopeKey" label="范围" min-width="130" />
<el-table-column prop="name" label="模板" min-width="150" />
<el-table-column label="照片" min-width="130">
<template #default="{ row }">
{{ row.photoRequired ? `${row.minPhotoCount}${row.maxPhotoCount}` : `可选,最多 ${row.maxPhotoCount}` }}
</template>
</el-table-column>
<el-table-column label="免清洁" min-width="130">
<template #default="{ row }">{{ exemptPolicyLabel(row.exemptPolicy) }}</template>
</el-table-column>
<el-table-column label="版本" width="88">
<template #default="{ row }">v{{ row.version }}</template>
</el-table-column>
<el-table-column label="状态" width="88">
<template #default="{ row }">{{ row.status === 'ACTIVE' ? '启用' : '停用' }}</template>
</el-table-column>
<el-table-column label="操作" width="88" fixed="right">
<template #default="{ row }"><el-button link type="primary" @click="editTemplate(row)">编辑</el-button></template>
</el-table-column>
</el-table>
</section>
</template>
<script setup lang="ts">
import { reactive, ref, watch } from 'vue';
import { ElMessage } from 'element-plus';
import {
listCleaningTemplates,
listManagedRooms,
listManagedStores,
upsertCleaningTemplate,
type ApiSession
} from '../api';
import type {
CleaningExemptPolicy,
CleaningTemplate,
CleaningTemplateScope,
ManagedRoom,
ManagedStore
} from '../types';
const props = defineProps<{ session: ApiSession }>();
const templates = ref<CleaningTemplate[]>([]);
const stores = ref<ManagedStore[]>([]);
const rooms = ref<ManagedRoom[]>([]);
const loading = ref(false);
const saving = ref(false);
const draft = reactive({
scopeType: 'TENANT' as CleaningTemplateScope,
scopeId: '',
roomStoreId: '',
name: '默认清洁模板',
requirement: '完成桌面、地面与设备周边清洁',
photoRequired: true,
minPhotoCount: 1,
maxPhotoCount: 9,
exemptPolicy: 'ANY_ACTIVE' as CleaningExemptPolicy,
status: 'ACTIVE' as 'ACTIVE' | 'DISABLED'
});
async function loadTemplates() {
if (!props.session.token || loading.value) return;
loading.value = true;
try {
templates.value = await listCleaningTemplates(props.session);
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '清洁模板加载失败');
} finally {
loading.value = false;
}
}
async function loadStores() {
try {
stores.value = await listManagedStores(props.session);
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '门店列表加载失败');
}
}
async function loadRooms(storeId = draft.roomStoreId) {
if (!storeId) {
rooms.value = [];
return;
}
try {
rooms.value = await listManagedRooms(props.session, storeId);
} catch (error) {
rooms.value = [];
ElMessage.error(error instanceof Error ? error.message : '房间列表加载失败');
}
}
async function saveTemplate() {
if (!draft.name || (draft.scopeType !== 'TENANT' && !/^[1-9]\d{0,19}$/.test(draft.scopeId))) {
return ElMessage.warning('请填写模板名称和有效作用范围');
}
saving.value = true;
try {
await upsertCleaningTemplate(props.session, {
scopeType: draft.scopeType,
scopeId: draft.scopeType === 'TENANT' ? undefined : draft.scopeId,
name: draft.name,
requirement: draft.requirement,
photoRequired: draft.photoRequired,
minPhotoCount: draft.minPhotoCount,
maxPhotoCount: draft.maxPhotoCount,
exemptPolicy: draft.exemptPolicy,
status: draft.status
});
ElMessage.success('清洁模板已保存,仅影响后续新任务');
await loadTemplates();
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '清洁模板保存失败');
} finally {
saving.value = false;
}
}
async function editTemplate(template: CleaningTemplate) {
draft.scopeType = template.scopeType;
draft.scopeId = template.storeId || template.roomId || '';
draft.roomStoreId = template.scopeType === 'ROOM' ? template.storeId || '' : '';
if (draft.roomStoreId) await loadRooms();
draft.name = template.name;
draft.requirement = template.requirement;
draft.photoRequired = template.photoRequired;
draft.minPhotoCount = template.minPhotoCount;
draft.maxPhotoCount = template.maxPhotoCount;
draft.exemptPolicy = template.exemptPolicy;
draft.status = template.status;
}
function resetDraft() {
Object.assign(draft, {
scopeType: 'TENANT', scopeId: '', roomStoreId: '', name: '默认清洁模板',
requirement: '完成桌面、地面与设备周边清洁', photoRequired: true,
minPhotoCount: 1, maxPhotoCount: 9, exemptPolicy: 'ANY_ACTIVE', status: 'ACTIVE'
});
}
function handleScopeChange() { draft.scopeId = ''; draft.roomStoreId = ''; rooms.value = []; }
async function handleRoomStoreChange() { draft.scopeId = ''; await loadRooms(); }
function normalizePhotoRule() { draft.minPhotoCount = draft.photoRequired ? Math.max(1, draft.minPhotoCount) : 0; }
function exemptPolicyLabel(policy: CleaningExemptPolicy) {
return { DISABLED: '不允许', BEFORE_START: '开始前', ANY_ACTIVE: '活动阶段' }[policy];
}
watch(() => props.session.token, (token) => {
if (token) void Promise.all([loadTemplates(), loadStores()]);
}, { immediate: true });
</script>
<style scoped>
.rules-panel { display: grid; gap: 18px; }
.panel-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; }
.panel-heading h3 { margin: 4px 0 6px; color: var(--ink); }
.panel-heading p:last-child { margin: 0; color: var(--muted); }
.rule-form { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 0 14px; padding: 18px; border: 1px solid var(--line); border-radius: 16px; background: var(--surface); }
.wide-field { grid-column: 1 / -1; }
.form-actions { display: flex; justify-content: flex-end; gap: 10px; }
@media (max-width: 980px) { .rule-form { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
@media (max-width: 560px) { .panel-heading { align-items: stretch; flex-direction: column; } .rule-form { grid-template-columns: 1fr; padding: 14px; } .wide-field { grid-column: auto; } .form-actions { justify-content: stretch; } .form-actions :deep(.el-button) { flex: 1; } }
</style>
+79 -13
View File
@@ -149,7 +149,7 @@
size="small"
type="warning"
:icon="Ban"
:disabled="!canExempt(row.status)"
:disabled="!canExempt(row)"
@click="$emit('exempt', { taskId: row.id, note: '\u540e\u53f0\u6807\u8bb0\u514d\u6e05\u6d01' })"
>
{{ '\u514d\u6e05\u6d01' }}
@@ -323,7 +323,7 @@
</template>
</el-dialog>
<el-dialog v-model="detailDialog.open" title="保洁任务详情" width="760px">
<el-dialog v-model="detailDialog.open" title="保洁任务详情" width="880px">
<div v-if="detailDialog.task" class="dialog-stack">
<div class="detail-grid">
<span>任务号<strong>{{ detailDialog.task.taskNo }}</strong></span>
@@ -341,6 +341,12 @@
<el-descriptions-item label="要求" :span="2">
{{ compactText(detailDialog.task.requirement) }}
</el-descriptions-item>
<el-descriptions-item label="照片规则">
{{ detailDialog.task.photoRequired ? `${detailDialog.task.minPhotoCount}${detailDialog.task.maxPhotoCount}` : `可选,最多 ${detailDialog.task.maxPhotoCount}` }}
</el-descriptions-item>
<el-descriptions-item label="任务快照">
模板 v{{ detailDialog.task.cleaningTemplateVersion }} · 照片修订 {{ detailDialog.task.photoRevision }} · 补做 {{ detailDialog.task.reworkCount }}
</el-descriptions-item>
<el-descriptions-item label="驳回原因" :span="2">
{{ compactText(detailDialog.task.rejectReason) }}
</el-descriptions-item>
@@ -351,6 +357,46 @@
<span>提交<strong>{{ shortDate(detailDialog.task.submittedAt) }}</strong></span>
<span>完成<strong>{{ shortDate(detailDialog.task.completedAt) }}</strong></span>
</div>
<section class="stat-block" v-loading="detailDialog.loadingSubmissions">
<header>
<h3>验收照片版本</h3>
<span>{{ detailDialog.submissions.length }} 次提交</span>
</header>
<div v-if="detailDialog.submissions.length" class="submission-list">
<article
v-for="submission in detailDialog.submissions"
:key="submission.id"
class="submission-card"
>
<header>
<strong> {{ submission.revision }} </strong>
<el-tag
:type="submission.status === 'ACCEPTED' ? 'success' : submission.status === 'REJECTED' ? 'danger' : 'warning'"
effect="light"
>
{{ submission.status }}
</el-tag>
<span>{{ shortDate(submission.submittedAt) }}</span>
</header>
<p v-if="submission.rejectReason" class="submission-reason">
驳回原因{{ submission.rejectReason }}
</p>
<p v-if="submission.note" class="submission-note">提交备注{{ submission.note }}</p>
<div v-if="submission.photoUrls.length" class="photo-grid">
<el-image
v-for="url in submission.photoUrls"
:key="`${submission.id}-${url}`"
:src="url"
fit="cover"
:preview-src-list="submission.photoUrls"
preview-teleported
/>
</div>
<el-empty v-else description="本版无照片" :image-size="48" />
</article>
</div>
<el-empty v-else description="暂无提交版本" :image-size="64" />
</section>
<section class="stat-block">
<header>
<h3>操作流水</h3>
@@ -451,6 +497,7 @@ import {
addCleaningTaskMember,
listCleaningTaskEvents,
listCleaningTaskMembers,
listCleaningTaskSubmissions,
removeCleaningTaskMember
} from '../api';
import { compactText, money, shortDate } from '../format';
@@ -458,6 +505,7 @@ import type {
CleaningTask,
CleaningTaskEvent,
CleaningTaskMember,
CleaningTaskSubmission,
ManagedUser,
PageResult,
TaskStatus
@@ -510,16 +558,20 @@ const detailDialog = reactive<{
open: boolean;
loadingEvents: boolean;
loadingMembers: boolean;
loadingSubmissions: boolean;
task: CleaningTask | null;
events: CleaningTaskEvent[];
members: CleaningTaskMember[];
submissions: CleaningTaskSubmission[];
}>({
open: false,
loadingEvents: false,
loadingMembers: false,
loadingSubmissions: false,
task: null,
events: [],
members: []
members: [],
submissions: []
});
const memberDialog = reactive({
open: false,
@@ -536,7 +588,7 @@ const selectedSubmittedTasks = computed(() => selectedTasks.value
.filter((task) => task.status === 'SUBMITTED'));
const selectedSubmittedCount = computed(() => selectedSubmittedTasks.value.length);
const selectedExemptableTasks = computed(() => selectedTasks.value
.filter((task) => canExempt(task.status)));
.filter((task) => canExempt(task)));
const selectedExemptableCount = computed(() => selectedExemptableTasks.value.length);
watch(
@@ -553,11 +605,13 @@ function taskTag(status: TaskStatus) {
}
function canSelectTask(row: CleaningTask) {
return row.status === 'SUBMITTED' || canExempt(row.status);
return row.status === 'SUBMITTED' || canExempt(row);
}
function canExempt(status: TaskStatus) {
return ['WAITING', 'CLAIMED', 'STARTED', 'SUBMITTED', 'REJECTED'].includes(status);
function canExempt(task: CleaningTask) {
if (task.exemptPolicy === 'DISABLED') return false;
if (task.exemptPolicy === 'BEFORE_START') return ['WAITING', 'CLAIMED'].includes(task.status);
return ['WAITING', 'CLAIMED', 'STARTED', 'SUBMITTED', 'REJECTED'].includes(task.status);
}
function handleSelectionChange(rows: CleaningTask[]) {
@@ -583,20 +637,25 @@ async function openDetail(row: CleaningTask) {
detailDialog.task = row;
detailDialog.events = [];
detailDialog.members = [];
detailDialog.submissions = [];
detailDialog.loadingEvents = true;
detailDialog.loadingMembers = true;
detailDialog.loadingSubmissions = true;
try {
const [events, members] = await Promise.all([
const [events, members, submissions] = await Promise.all([
listCleaningTaskEvents(props.session, row.id),
listCleaningTaskMembers(props.session, row.id)
listCleaningTaskMembers(props.session, row.id),
listCleaningTaskSubmissions(props.session, row.id)
]);
detailDialog.events = events;
detailDialog.members = members;
detailDialog.submissions = submissions;
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '任务详情加载失败');
} finally {
detailDialog.loadingEvents = false;
detailDialog.loadingMembers = false;
detailDialog.loadingSubmissions = false;
}
}
@@ -779,10 +838,17 @@ function exportTaskDetail() {
`${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
...detailDialog.submissions.flatMap((submission) => [
[
'submission',
`revision_${submission.revision}`,
`${submission.status} ${submission.rejectReason || submission.note || ''}`
],
...submission.photoUrls.map((url, index) => [
'photo',
`revision_${submission.revision}_photo_${index + 1}`,
url
])
])
]);
}
@@ -144,6 +144,9 @@
@reset-sessions="handleResetCleanerSessions"
/>
</el-tab-pane>
<el-tab-pane label="清洁规则" name="rules">
<CleaningRulesPanel :session="session" />
</el-tab-pane>
<el-tab-pane label="联调" name="handoff">
<CleaningFieldHandoffPanel />
</el-tab-pane>
@@ -159,6 +162,7 @@ import CleaningTasksPanel from './CleaningTasksPanel.vue';
import CleaningSettlementsPanel from './CleaningSettlementsPanel.vue';
import CleaningStatisticsPanel from './CleaningStatisticsPanel.vue';
import CleanersPanel from './CleanersPanel.vue';
import CleaningRulesPanel from './CleaningRulesPanel.vue';
import CleaningFieldHandoffPanel from './CleaningFieldHandoffPanel.vue';
import {
ApiError,
+37
View File
@@ -1291,6 +1291,43 @@ textarea {
background: #f5f8fb;
}
.submission-list {
display: grid;
gap: 12px;
padding: 12px;
}
.submission-card {
display: grid;
gap: 10px;
padding: 12px;
border: 1px solid #e1e8f0;
border-radius: 8px;
background: #fbfcfe;
}
.submission-card > header {
justify-content: flex-start;
padding: 0;
border: 0;
background: transparent;
}
.submission-card > header span:last-child {
margin-left: auto;
}
.submission-reason,
.submission-note {
margin: 0;
color: #60708a;
font-size: 13px;
}
.submission-reason {
color: #b42318;
}
.trend-list {
display: grid;
gap: 10px;
+43
View File
@@ -614,8 +614,16 @@ export interface CleaningTask {
priority: number;
rewardCents: number;
requirement: string;
cleaningTemplateId: string | null;
cleaningTemplateVersion: number;
photoRequired: boolean;
minPhotoCount: number;
maxPhotoCount: number;
exemptPolicy: CleaningExemptPolicy;
photoUrls: string[];
rejectReason: string;
reworkCount: number;
photoRevision: number;
claimedAt?: string;
startedAt?: string;
submittedAt?: string;
@@ -625,6 +633,27 @@ export interface CleaningTask {
updatedAt?: string;
}
export type CleaningTemplateScope = 'TENANT' | 'STORE' | 'ROOM';
export type CleaningExemptPolicy = 'DISABLED' | 'BEFORE_START' | 'ANY_ACTIVE';
export interface CleaningTemplate {
id: string;
scopeKey: string;
scopeType: CleaningTemplateScope;
storeId: string | null;
roomId: string | null;
name: string;
requirement: string;
photoRequired: boolean;
minPhotoCount: number;
maxPhotoCount: number;
exemptPolicy: CleaningExemptPolicy;
version: number;
status: 'ACTIVE' | 'DISABLED';
updatedAt: string;
appliesToExistingTasks: false;
}
export interface CleaningTaskMember {
id: string;
taskId: string;
@@ -649,6 +678,20 @@ export interface CleaningTaskEvent {
createdAt: string;
}
export interface CleaningTaskSubmission {
id: string;
taskId: string;
revision: number;
status: 'SUBMITTED' | 'ACCEPTED' | 'REJECTED';
photoUrls: string[];
note: string;
rejectReason: string;
submittedBy: string;
reviewedBy: string | null;
submittedAt: string;
reviewedAt: string | null;
}
export interface CleaningSettlement {
id: string;
settlementNo: string;