feat(M08-B): 补保洁员资料管理
This commit is contained in:
+113
-4
@@ -135,6 +135,25 @@
|
||||
@filter="changeStatisticsFilters"
|
||||
/>
|
||||
</el-tab-pane>
|
||||
<el-tab-pane label="保洁员" name="cleaners">
|
||||
<CleanersPanel
|
||||
:loading="loading.cleaners"
|
||||
:items="cleaners.items"
|
||||
:total="cleaners.total"
|
||||
:page="cleaners.page"
|
||||
:page-size="cleaners.pageSize"
|
||||
:status="cleanerStatus"
|
||||
:search="cleanerSearch"
|
||||
@refresh="loadCleaners"
|
||||
@status-change="changeCleanerStatus"
|
||||
@search-change="changeCleanerSearch"
|
||||
@page-change="changeCleanerPage"
|
||||
@create="handleCreateCleaner"
|
||||
@update="handleUpdateCleaner"
|
||||
@status-toggle="handleToggleCleanerStatus"
|
||||
@reset-sessions="handleResetCleanerSessions"
|
||||
/>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
</section>
|
||||
</main>
|
||||
@@ -154,29 +173,36 @@ import {
|
||||
import CleaningTasksPanel from './components/CleaningTasksPanel.vue';
|
||||
import CleaningSettlementsPanel from './components/CleaningSettlementsPanel.vue';
|
||||
import CleaningStatisticsPanel from './components/CleaningStatisticsPanel.vue';
|
||||
import CleanersPanel from './components/CleanersPanel.vue';
|
||||
import {
|
||||
ApiError,
|
||||
assignCleaningTask,
|
||||
completeCleaningTask,
|
||||
confirmCleaningSettlement,
|
||||
createStaffUser,
|
||||
executeWechatTransfer,
|
||||
generateCleaningSettlement,
|
||||
getCleaningStatistics,
|
||||
listCleaningSettlements,
|
||||
listCleaningTasks,
|
||||
listStaffUsers,
|
||||
reclaimCleaningTimeouts,
|
||||
recordPayoutFailure,
|
||||
rejectCleaningTask,
|
||||
syncWechatTransfer
|
||||
resetStaffSessions,
|
||||
syncWechatTransfer,
|
||||
updateStaffUser
|
||||
} from './api';
|
||||
import type {
|
||||
CleaningSettlement,
|
||||
CleaningStatistics,
|
||||
CleaningTask,
|
||||
ManagedUser,
|
||||
PageResult,
|
||||
SettlementStatus,
|
||||
TaskStatus,
|
||||
TransferMode
|
||||
TransferMode,
|
||||
UserStatus
|
||||
} from './types';
|
||||
import { money } from './format';
|
||||
|
||||
@@ -186,8 +212,10 @@ const lastError = ref('');
|
||||
const lastMessage = ref('');
|
||||
const taskStatus = ref<TaskStatus | ''>('SUBMITTED');
|
||||
const settlementStatus = ref<SettlementStatus | ''>('CONFIRMED');
|
||||
const cleanerStatus = ref<UserStatus | ''>('ACTIVE');
|
||||
const cleanerSearch = ref('');
|
||||
const session = computed(() => ({ token: tokenDraft.value }));
|
||||
const loading = reactive({ tasks: false, settlements: false, statistics: false });
|
||||
const loading = reactive({ tasks: false, settlements: false, statistics: false, cleaners: false });
|
||||
const tasks = reactive<PageResult<CleaningTask>>({ items: [], total: 0, page: 1, pageSize: 20 });
|
||||
const settlements = reactive<PageResult<CleaningSettlement>>({
|
||||
items: [],
|
||||
@@ -195,6 +223,7 @@ const settlements = reactive<PageResult<CleaningSettlement>>({
|
||||
page: 1,
|
||||
pageSize: 20
|
||||
});
|
||||
const cleaners = reactive<PageResult<ManagedUser>>({ items: [], total: 0, page: 1, pageSize: 20 });
|
||||
const statisticsFilters = reactive({ from: '', to: '', storeId: '' });
|
||||
const statistics = reactive<CleaningStatistics>({
|
||||
summary: {
|
||||
@@ -276,6 +305,26 @@ async function loadStatistics() {
|
||||
loading.statistics = false;
|
||||
}
|
||||
|
||||
async function loadCleaners() {
|
||||
loading.cleaners = true;
|
||||
await runAction(async () => {
|
||||
const result = await listStaffUsers(session.value, {
|
||||
page: cleaners.page,
|
||||
pageSize: cleaners.pageSize,
|
||||
role: 'CLEANER',
|
||||
status: cleanerStatus.value || undefined,
|
||||
search: cleanerSearch.value || undefined
|
||||
});
|
||||
Object.assign(cleaners, {
|
||||
items: result.items,
|
||||
total: result.total,
|
||||
page: cleaners.page,
|
||||
pageSize: cleaners.pageSize
|
||||
});
|
||||
}, '保洁员已刷新');
|
||||
loading.cleaners = false;
|
||||
}
|
||||
|
||||
function changeTaskStatus(status: TaskStatus | '') {
|
||||
taskStatus.value = status;
|
||||
tasks.page = 1;
|
||||
@@ -303,6 +352,23 @@ function changeStatisticsFilters(input: { from: string; to: string; storeId: str
|
||||
void loadStatistics();
|
||||
}
|
||||
|
||||
function changeCleanerStatus(status: UserStatus | '') {
|
||||
cleanerStatus.value = status;
|
||||
cleaners.page = 1;
|
||||
void loadCleaners();
|
||||
}
|
||||
|
||||
function changeCleanerSearch(search: string) {
|
||||
cleanerSearch.value = search;
|
||||
cleaners.page = 1;
|
||||
void loadCleaners();
|
||||
}
|
||||
|
||||
function changeCleanerPage(page: number) {
|
||||
cleaners.page = page;
|
||||
void loadCleaners();
|
||||
}
|
||||
|
||||
async function handleAssign(input: { taskId: string; cleanerUserId: string; note?: string }) {
|
||||
await runAction(async () => {
|
||||
await assignCleaningTask(session.value, input.taskId, input);
|
||||
@@ -378,9 +444,52 @@ async function handleFailure(
|
||||
}, '已记录失败');
|
||||
}
|
||||
|
||||
async function handleCreateCleaner(
|
||||
input: { nickname: string; phone: string; storeIds: string[]; note?: string }
|
||||
) {
|
||||
await runAction(async () => {
|
||||
await createStaffUser(session.value, {
|
||||
nickname: input.nickname,
|
||||
phone: input.phone,
|
||||
roles: ['CLEANER'],
|
||||
storeIds: input.storeIds,
|
||||
note: input.note
|
||||
});
|
||||
await loadCleaners();
|
||||
}, '已新增保洁员');
|
||||
}
|
||||
|
||||
async function handleUpdateCleaner(
|
||||
input: { userId: string; nickname: string; storeIds: string[]; note?: string }
|
||||
) {
|
||||
await runAction(async () => {
|
||||
await updateStaffUser(session.value, input.userId, {
|
||||
nickname: input.nickname,
|
||||
roles: ['CLEANER'],
|
||||
storeIds: input.storeIds,
|
||||
note: input.note
|
||||
});
|
||||
await loadCleaners();
|
||||
}, '已更新保洁员');
|
||||
}
|
||||
|
||||
async function handleToggleCleanerStatus(input: { userId: string; status: UserStatus }) {
|
||||
await runAction(async () => {
|
||||
await updateStaffUser(session.value, input.userId, { status: input.status });
|
||||
await loadCleaners();
|
||||
}, input.status === 'ACTIVE' ? '已启用保洁员' : '已停用保洁员');
|
||||
}
|
||||
|
||||
async function handleResetCleanerSessions(userId: string) {
|
||||
await runAction(async () => {
|
||||
await resetStaffSessions(session.value, userId);
|
||||
await loadCleaners();
|
||||
}, '已重置保洁员会话');
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (session.value.token) {
|
||||
void Promise.all([loadTasks(), loadSettlements(), loadStatistics()]);
|
||||
void Promise.all([loadTasks(), loadSettlements(), loadStatistics(), loadCleaners()]);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -4,10 +4,13 @@ import type {
|
||||
CleaningStatistics,
|
||||
CleaningTask,
|
||||
CleaningTaskMember,
|
||||
ManagedUser,
|
||||
PageResult,
|
||||
StaffRole,
|
||||
SettlementStatus,
|
||||
TaskStatus,
|
||||
TransferMode,
|
||||
UserStatus,
|
||||
WechatTransferPreflight
|
||||
} from './types';
|
||||
|
||||
@@ -76,6 +79,62 @@ export function getCleaningStatistics(
|
||||
return request<CleaningStatistics>(session, `/cleaning/statistics${query ? `?${query}` : ''}`);
|
||||
}
|
||||
|
||||
export function listStaffUsers(
|
||||
session: ApiSession,
|
||||
input: {
|
||||
page: number;
|
||||
pageSize: number;
|
||||
role?: StaffRole;
|
||||
status?: UserStatus;
|
||||
search?: string;
|
||||
}
|
||||
) {
|
||||
const params = new URLSearchParams({
|
||||
page: String(input.page),
|
||||
pageSize: String(input.pageSize)
|
||||
});
|
||||
if (input.role) params.set('role', input.role);
|
||||
if (input.status) params.set('status', input.status);
|
||||
if (input.search) params.set('search', input.search);
|
||||
return request<{ items: ManagedUser[]; total: number }>(session, `/users?${params}`);
|
||||
}
|
||||
|
||||
export function createStaffUser(
|
||||
session: ApiSession,
|
||||
input: { nickname: string; phone: string; note?: string; roles: StaffRole[]; storeIds: string[] }
|
||||
) {
|
||||
return request<{ userId: string }>(session, '/staff', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(input)
|
||||
});
|
||||
}
|
||||
|
||||
export function updateStaffUser(
|
||||
session: ApiSession,
|
||||
userId: string,
|
||||
input: Partial<{
|
||||
nickname: string;
|
||||
phone: string;
|
||||
note: string;
|
||||
status: UserStatus;
|
||||
roles: StaffRole[];
|
||||
storeIds: string[];
|
||||
}>
|
||||
) {
|
||||
return request<{ userId: string }>(session, `/users/${encodeURIComponent(userId)}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(input)
|
||||
});
|
||||
}
|
||||
|
||||
export function resetStaffSessions(session: ApiSession, userId: string) {
|
||||
return request<{ userId: string; revokedSessions: number }>(
|
||||
session,
|
||||
`/users/${encodeURIComponent(userId)}/reset-sessions`,
|
||||
{ method: 'POST' }
|
||||
);
|
||||
}
|
||||
|
||||
export function assignCleaningTask(
|
||||
session: ApiSession,
|
||||
taskId: string,
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
<template>
|
||||
<section class="panel">
|
||||
<div class="panel-toolbar">
|
||||
<div class="toolbar-actions">
|
||||
<el-input
|
||||
v-model="searchDraft"
|
||||
class="search-input"
|
||||
placeholder="姓名/手机号/ID"
|
||||
clearable
|
||||
@keyup.enter="emitSearch"
|
||||
/>
|
||||
<el-segmented
|
||||
:model-value="status"
|
||||
:options="statusOptions"
|
||||
@update:model-value="$emit('status-change', $event as UserStatus | '')"
|
||||
/>
|
||||
</div>
|
||||
<div class="toolbar-actions">
|
||||
<el-button type="primary" :icon="UserPlus" @click="createDialog.open = true">
|
||||
新增
|
||||
</el-button>
|
||||
<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="nickname" label="保洁员" min-width="160" fixed>
|
||||
<template #default="{ row }">
|
||||
<div class="stack">
|
||||
<strong>{{ row.nickname || compactText(row.id) }}</strong>
|
||||
<span>ID {{ row.id }} · {{ row.maskedPhone }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="status" label="状态" width="110">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.status === 'ACTIVE' ? 'success' : 'info'" effect="light">
|
||||
{{ row.status }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="门店范围" min-width="150">
|
||||
<template #default="{ row }">{{ row.storeIds.join(', ') || '未配置' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="note" label="备注" min-width="180">
|
||||
<template #default="{ row }">{{ compactText(row.note) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="lastLoginAt" label="最近登录" width="130">
|
||||
<template #default="{ row }">{{ shortDate(row.lastLoginAt) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="maskedLastIp" label="最近 IP" width="120" />
|
||||
<el-table-column label="操作" width="310" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<div class="row-actions">
|
||||
<el-button size="small" :icon="Pencil" @click="openEdit(row)">编辑</el-button>
|
||||
<el-button
|
||||
size="small"
|
||||
:type="row.status === 'ACTIVE' ? 'warning' : 'success'"
|
||||
:icon="row.status === 'ACTIVE' ? UserX : UserCheck"
|
||||
@click="$emit('status-toggle', { userId: row.id, status: row.status === 'ACTIVE' ? 'DISABLED' : 'ACTIVE' })"
|
||||
>
|
||||
{{ row.status === 'ACTIVE' ? '停用' : '启用' }}
|
||||
</el-button>
|
||||
<el-button size="small" :icon="ShieldX" @click="$emit('reset-sessions', row.id)">
|
||||
会话
|
||||
</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="createDialog.open" title="新增保洁员" width="460px">
|
||||
<el-form label-position="top">
|
||||
<el-form-item label="姓名">
|
||||
<el-input v-model="createDialog.nickname" />
|
||||
</el-form-item>
|
||||
<el-form-item label="手机号">
|
||||
<el-input v-model="createDialog.phone" inputmode="tel" />
|
||||
</el-form-item>
|
||||
<el-form-item label="门店 ID,逗号分隔">
|
||||
<el-input v-model="createDialog.storeIdsText" />
|
||||
</el-form-item>
|
||||
<el-form-item label="备注">
|
||||
<el-input v-model="createDialog.note" type="textarea" :rows="3" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="createDialog.open = false">取消</el-button>
|
||||
<el-button type="primary" @click="submitCreate">创建</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog v-model="editDialog.open" title="编辑保洁员" width="460px">
|
||||
<el-form label-position="top">
|
||||
<el-form-item label="姓名">
|
||||
<el-input v-model="editDialog.nickname" />
|
||||
</el-form-item>
|
||||
<el-form-item label="门店 ID,逗号分隔">
|
||||
<el-input v-model="editDialog.storeIdsText" />
|
||||
</el-form-item>
|
||||
<el-form-item label="备注">
|
||||
<el-input v-model="editDialog.note" type="textarea" :rows="3" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="editDialog.open = false">取消</el-button>
|
||||
<el-button type="primary" @click="submitEdit">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { reactive, ref, watch } from 'vue';
|
||||
import {
|
||||
Pencil,
|
||||
RefreshCw,
|
||||
ShieldX,
|
||||
UserCheck,
|
||||
UserPlus,
|
||||
UserX
|
||||
} from '@lucide/vue';
|
||||
import { compactText, shortDate } from '../format';
|
||||
import type { ManagedUser, PageResult, UserStatus } from '../types';
|
||||
|
||||
const props = defineProps<{
|
||||
loading: boolean;
|
||||
items: PageResult<ManagedUser>['items'];
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
status: UserStatus | '';
|
||||
search: string;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
refresh: [];
|
||||
'status-change': [UserStatus | ''];
|
||||
'search-change': [string];
|
||||
'page-change': [number];
|
||||
create: [{ nickname: string; phone: string; storeIds: string[]; note?: string }];
|
||||
update: [{ userId: string; nickname: string; storeIds: string[]; note?: string }];
|
||||
'status-toggle': [{ userId: string; status: UserStatus }];
|
||||
'reset-sessions': [string];
|
||||
}>();
|
||||
|
||||
const statusOptions = [
|
||||
{ label: '全部', value: '' },
|
||||
{ label: '启用', value: 'ACTIVE' },
|
||||
{ label: '停用', value: 'DISABLED' }
|
||||
];
|
||||
const searchDraft = ref(props.search);
|
||||
const createDialog = reactive({
|
||||
open: false,
|
||||
nickname: '',
|
||||
phone: '',
|
||||
storeIdsText: '',
|
||||
note: ''
|
||||
});
|
||||
const editDialog = reactive({
|
||||
open: false,
|
||||
userId: '',
|
||||
nickname: '',
|
||||
storeIdsText: '',
|
||||
note: ''
|
||||
});
|
||||
|
||||
watch(
|
||||
() => props.search,
|
||||
(value) => { searchDraft.value = value; }
|
||||
);
|
||||
|
||||
function parseStoreIds(value: string) {
|
||||
return value.split(/[,\s,]+/).map((item) => item.trim()).filter(Boolean);
|
||||
}
|
||||
|
||||
function emitSearch() {
|
||||
emit('search-change', searchDraft.value.trim());
|
||||
}
|
||||
|
||||
function submitCreate() {
|
||||
emit('create', {
|
||||
nickname: createDialog.nickname,
|
||||
phone: createDialog.phone,
|
||||
storeIds: parseStoreIds(createDialog.storeIdsText),
|
||||
note: createDialog.note
|
||||
});
|
||||
Object.assign(createDialog, { open: false, nickname: '', phone: '', storeIdsText: '', note: '' });
|
||||
}
|
||||
|
||||
function openEdit(row: ManagedUser) {
|
||||
editDialog.open = true;
|
||||
editDialog.userId = row.id;
|
||||
editDialog.nickname = row.nickname;
|
||||
editDialog.storeIdsText = row.storeIds.join(', ');
|
||||
editDialog.note = row.note;
|
||||
}
|
||||
|
||||
function submitEdit() {
|
||||
emit('update', {
|
||||
userId: editDialog.userId,
|
||||
nickname: editDialog.nickname,
|
||||
storeIds: parseStoreIds(editDialog.storeIdsText),
|
||||
note: editDialog.note
|
||||
});
|
||||
editDialog.open = false;
|
||||
}
|
||||
</script>
|
||||
@@ -2,6 +2,9 @@
|
||||
<section class="panel">
|
||||
<div class="panel-toolbar">
|
||||
<el-form class="stats-filter" label-position="top">
|
||||
<el-form-item label="周期">
|
||||
<el-segmented :options="quickOptions" @update:model-value="applyQuickRange" />
|
||||
</el-form-item>
|
||||
<el-form-item label="开始">
|
||||
<el-input v-model="filterDraft.from" placeholder="2026-06-01" />
|
||||
</el-form-item>
|
||||
@@ -130,6 +133,11 @@ const emit = defineEmits<{
|
||||
}>();
|
||||
|
||||
const filterDraft = reactive({ ...props.filters });
|
||||
const quickOptions = [
|
||||
{ label: '今日', value: 'today' },
|
||||
{ label: '近7天', value: '7d' },
|
||||
{ label: '本月', value: 'month' }
|
||||
];
|
||||
|
||||
watch(
|
||||
() => props.filters,
|
||||
@@ -144,4 +152,26 @@ function emitFilter() {
|
||||
storeId: filterDraft.storeId.trim()
|
||||
});
|
||||
}
|
||||
|
||||
function applyQuickRange(value: string | number | boolean) {
|
||||
const now = new Date();
|
||||
const end = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1);
|
||||
let start = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
||||
if (value === '7d') {
|
||||
start = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 6);
|
||||
}
|
||||
if (value === 'month') {
|
||||
start = new Date(now.getFullYear(), now.getMonth(), 1);
|
||||
}
|
||||
filterDraft.from = formatDate(start);
|
||||
filterDraft.to = formatDate(end);
|
||||
emitFilter();
|
||||
}
|
||||
|
||||
function formatDate(date: Date) {
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(date.getDate()).padStart(2, '0');
|
||||
return `${year}-${month}-${day}`;
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -202,6 +202,10 @@ textarea {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
width: min(240px, 100%);
|
||||
}
|
||||
|
||||
.data-table {
|
||||
width: 100%;
|
||||
}
|
||||
@@ -320,7 +324,7 @@ textarea {
|
||||
|
||||
.stats-filter {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(130px, 180px)) auto;
|
||||
grid-template-columns: minmax(160px, 210px) repeat(3, minmax(130px, 180px)) auto;
|
||||
gap: 10px;
|
||||
align-items: end;
|
||||
}
|
||||
|
||||
@@ -11,6 +11,8 @@ export type TaskStatus =
|
||||
|
||||
export type SettlementStatus = 'DRAFT' | 'CONFIRMED' | 'PAID' | 'CANCELLED';
|
||||
export type TransferMode = 'API' | 'MOCK';
|
||||
export type UserStatus = 'ACTIVE' | 'DISABLED';
|
||||
export type StaffRole = 'CLEANER' | 'STAFF' | 'STORE_ADMIN' | 'TENANT_ADMIN';
|
||||
|
||||
export interface PageResult<T> {
|
||||
items: T[];
|
||||
@@ -19,6 +21,20 @@ export interface PageResult<T> {
|
||||
pageSize: number;
|
||||
}
|
||||
|
||||
export interface ManagedUser {
|
||||
id: string;
|
||||
userType: string;
|
||||
status: UserStatus;
|
||||
nickname: string;
|
||||
maskedPhone: string;
|
||||
maskedLastIp: string;
|
||||
note: string;
|
||||
roles: StaffRole[];
|
||||
storeIds: string[];
|
||||
registeredAt: string;
|
||||
lastLoginAt: string | null;
|
||||
}
|
||||
|
||||
export interface CleaningTask {
|
||||
id: string;
|
||||
taskNo: string;
|
||||
|
||||
Reference in New Issue
Block a user