refactor(M08-D-R1): 后台改用权限路由与懒加载
This commit is contained in:
+184
-764
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,615 @@
|
||||
<template>
|
||||
<section class="cleaning-workspace">
|
||||
<section class="metric-grid" aria-label="保洁概览">
|
||||
<div class="metric">
|
||||
<span>待验收</span>
|
||||
<strong>{{ statistics.summary.pendingReview }}</strong>
|
||||
</div>
|
||||
<div class="metric">
|
||||
<span>已完成</span>
|
||||
<strong>{{ statistics.summary.completed }}</strong>
|
||||
</div>
|
||||
<div class="metric">
|
||||
<span>待发放</span>
|
||||
<strong>{{ money(statistics.summary.confirmedSettlementCents) }}</strong>
|
||||
</div>
|
||||
<div class="metric">
|
||||
<span>待结算</span>
|
||||
<strong>{{ money(statistics.summary.pendingSettlementCents) }}</strong>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="action-board" aria-label="运营待处理">
|
||||
<button class="action-tile" type="button" @click="jumpToTaskStatus('SUBMITTED')">
|
||||
<ClipboardCheck :size="20" />
|
||||
<span>待验收任务</span>
|
||||
<strong>{{ statistics.summary.pendingReview }}</strong>
|
||||
</button>
|
||||
<button class="action-tile" type="button" @click="jumpToTaskStatus('REJECTED')">
|
||||
<RotateCcw :size="20" />
|
||||
<span>驳回补做</span>
|
||||
<strong>{{ statistics.summary.rejected }}</strong>
|
||||
</button>
|
||||
<button class="action-tile" type="button" @click="jumpToSettlementStatus('DRAFT')">
|
||||
<ListTodo :size="20" />
|
||||
<span>待确认结算</span>
|
||||
<strong>{{ draftSettlementTotal }}</strong>
|
||||
</button>
|
||||
<button class="action-tile" type="button" @click="jumpToSettlementStatus('CONFIRMED')">
|
||||
<Send :size="20" />
|
||||
<span>待发放金额</span>
|
||||
<strong>{{ money(statistics.summary.confirmedSettlementCents) }}</strong>
|
||||
</button>
|
||||
<button class="action-tile" type="button" @click="exportOperationalQueue">
|
||||
<Download :size="20" />
|
||||
<span>导出待处理</span>
|
||||
<strong>{{ operationalQueueTotal }}</strong>
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<el-alert
|
||||
v-if="lastError"
|
||||
class="alert-line"
|
||||
:title="lastError"
|
||||
type="error"
|
||||
show-icon
|
||||
closable
|
||||
@close="lastError = ''"
|
||||
/>
|
||||
<el-alert
|
||||
v-if="lastMessage"
|
||||
class="alert-line"
|
||||
:title="lastMessage"
|
||||
type="success"
|
||||
show-icon
|
||||
closable
|
||||
@close="lastMessage = ''"
|
||||
/>
|
||||
|
||||
<el-tabs v-model="activeTab" class="work-tabs">
|
||||
<el-tab-pane label="任务" name="tasks">
|
||||
<CleaningTasksPanel
|
||||
:session="session"
|
||||
:loading="loading.tasks"
|
||||
:items="tasks.items"
|
||||
:cleaners="cleaners.items"
|
||||
:total="tasks.total"
|
||||
:page="tasks.page"
|
||||
:page-size="tasks.pageSize"
|
||||
:status="taskStatus"
|
||||
:filters="taskFilters"
|
||||
@refresh="loadTasks"
|
||||
@status-change="changeTaskStatus"
|
||||
@filter="changeTaskFilters"
|
||||
@page-change="changeTaskPage"
|
||||
@assign="handleAssign"
|
||||
@complete="handleComplete"
|
||||
@complete-many="handleCompleteMany"
|
||||
@reject="handleReject"
|
||||
@reject-many="handleRejectMany"
|
||||
@exempt="handleExempt"
|
||||
@exempt-many="handleExemptMany"
|
||||
@reclaim="handleReclaim"
|
||||
/>
|
||||
</el-tab-pane>
|
||||
<el-tab-pane label="结算" name="settlements">
|
||||
<CleaningSettlementsPanel
|
||||
:session="session"
|
||||
:loading="loading.settlements"
|
||||
:items="settlements.items"
|
||||
:cleaners="cleaners.items"
|
||||
:total="settlements.total"
|
||||
:page="settlements.page"
|
||||
:page-size="settlements.pageSize"
|
||||
:status="settlementStatus"
|
||||
:filters="settlementFilters"
|
||||
@refresh="loadSettlements"
|
||||
@status-change="changeSettlementStatus"
|
||||
@filter="changeSettlementFilters"
|
||||
@page-change="changeSettlementPage"
|
||||
@generate="handleGenerateSettlement"
|
||||
@confirm="handleConfirmSettlement"
|
||||
@transfer="handleTransfer"
|
||||
@sync-transfer="handleSyncTransfer"
|
||||
@sync-transfer-many="handleSyncTransferMany"
|
||||
@failure="handleFailure"
|
||||
/>
|
||||
</el-tab-pane>
|
||||
<el-tab-pane label="统计" name="statistics">
|
||||
<CleaningStatisticsPanel
|
||||
:loading="loading.statistics"
|
||||
:statistics="statistics"
|
||||
:filters="statisticsFilters"
|
||||
:cleaners="cleaners.items"
|
||||
@refresh="loadStatistics"
|
||||
@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-tab-pane label="联调" name="handoff">
|
||||
<CleaningFieldHandoffPanel />
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref, toRef } from 'vue';
|
||||
import { ElMessage } from 'element-plus';
|
||||
import { ClipboardCheck, Download, ListTodo, RotateCcw, Send } from '@lucide/vue';
|
||||
import CleaningTasksPanel from './CleaningTasksPanel.vue';
|
||||
import CleaningSettlementsPanel from './CleaningSettlementsPanel.vue';
|
||||
import CleaningStatisticsPanel from './CleaningStatisticsPanel.vue';
|
||||
import CleanersPanel from './CleanersPanel.vue';
|
||||
import CleaningFieldHandoffPanel from './CleaningFieldHandoffPanel.vue';
|
||||
import {
|
||||
ApiError,
|
||||
assignCleaningTask,
|
||||
completeCleaningTask,
|
||||
confirmCleaningSettlement,
|
||||
createStaffUser,
|
||||
exemptCleaningTask,
|
||||
executeWechatTransfer,
|
||||
generateCleaningSettlement,
|
||||
getCleaningStatistics,
|
||||
listCleaningSettlements,
|
||||
listCleaningTasks,
|
||||
listStaffUsers,
|
||||
reclaimCleaningTimeouts,
|
||||
recordPayoutFailure,
|
||||
rejectCleaningTask,
|
||||
resetStaffSessions,
|
||||
syncWechatTransfer,
|
||||
updateStaffUser,
|
||||
type ApiSession
|
||||
} from '../api';
|
||||
import type {
|
||||
CleaningSettlement,
|
||||
CleaningStatistics,
|
||||
CleaningTask,
|
||||
ManagedUser,
|
||||
PageResult,
|
||||
PayoutStateFilter,
|
||||
SettlementStatus,
|
||||
TaskStatus,
|
||||
TransferMode,
|
||||
UserStatus
|
||||
} from '../types';
|
||||
import { money } from '../format';
|
||||
|
||||
const props = defineProps<{ session: ApiSession }>();
|
||||
const session = toRef(props, 'session');
|
||||
const activeTab = ref('tasks');
|
||||
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 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: [],
|
||||
total: 0,
|
||||
page: 1,
|
||||
pageSize: 20
|
||||
});
|
||||
const cleaners = reactive<PageResult<ManagedUser>>({ items: [], total: 0, page: 1, pageSize: 20 });
|
||||
const taskFilters = reactive({ storeId: '', cleanerUserId: '' });
|
||||
const settlementFilters = reactive<{
|
||||
storeId: string;
|
||||
cleanerUserId: string;
|
||||
payoutState: PayoutStateFilter | '';
|
||||
}>({ storeId: '', cleanerUserId: '', payoutState: '' });
|
||||
const statisticsFilters = reactive({ from: '', to: '', storeId: '', cleanerUserId: '' });
|
||||
const statistics = reactive<CleaningStatistics>({
|
||||
summary: {
|
||||
taskTotal: 0,
|
||||
pendingReview: 0,
|
||||
active: 0,
|
||||
rejected: 0,
|
||||
exempted: 0,
|
||||
completed: 0,
|
||||
rewardCents: 0,
|
||||
pendingSettlementCents: 0,
|
||||
confirmedSettlementCents: 0,
|
||||
paidSettlementCents: 0
|
||||
},
|
||||
byStatus: [],
|
||||
byStore: [],
|
||||
settlements: [],
|
||||
members: [],
|
||||
trend: []
|
||||
});
|
||||
const draftSettlementTotal = computed(() => statistics.settlements
|
||||
.find((item) => item.status === 'DRAFT')?.total || 0);
|
||||
const operationalQueueTotal = computed(() => (
|
||||
statistics.summary.pendingReview
|
||||
+ statistics.summary.rejected
|
||||
+ draftSettlementTotal.value
|
||||
));
|
||||
|
||||
function loadCleaningWorkspace() {
|
||||
return Promise.all([loadTasks(), loadSettlements(), loadStatistics(), loadCleaners()]);
|
||||
}
|
||||
|
||||
async function runAction(work: () => Promise<void>, success: string) {
|
||||
lastError.value = '';
|
||||
lastMessage.value = '';
|
||||
try {
|
||||
await work();
|
||||
lastMessage.value = success;
|
||||
ElMessage.success(success);
|
||||
} catch (error) {
|
||||
lastError.value = error instanceof ApiError
|
||||
? `${error.code}${error.traceId ? ` · ${error.traceId}` : ''}`
|
||||
: error instanceof Error ? error.message : '操作失败';
|
||||
}
|
||||
}
|
||||
|
||||
async function loadTasks() {
|
||||
loading.tasks = true;
|
||||
await runAction(async () => {
|
||||
Object.assign(tasks, await listCleaningTasks(session.value, {
|
||||
page: tasks.page,
|
||||
pageSize: tasks.pageSize,
|
||||
status: taskStatus.value || undefined,
|
||||
storeId: taskFilters.storeId || undefined,
|
||||
cleanerUserId: taskFilters.cleanerUserId || undefined
|
||||
}));
|
||||
}, '任务已刷新');
|
||||
loading.tasks = false;
|
||||
}
|
||||
|
||||
async function loadSettlements() {
|
||||
loading.settlements = true;
|
||||
await runAction(async () => {
|
||||
Object.assign(settlements, await listCleaningSettlements(session.value, {
|
||||
page: settlements.page,
|
||||
pageSize: settlements.pageSize,
|
||||
status: settlementStatus.value || undefined,
|
||||
payoutState: settlementFilters.payoutState || undefined,
|
||||
storeId: settlementFilters.storeId || undefined,
|
||||
cleanerUserId: settlementFilters.cleanerUserId || undefined
|
||||
}));
|
||||
}, '结算已刷新');
|
||||
loading.settlements = false;
|
||||
}
|
||||
|
||||
async function loadStatistics() {
|
||||
loading.statistics = true;
|
||||
await runAction(async () => {
|
||||
const result = await getCleaningStatistics(session.value, {
|
||||
from: statisticsFilters.from || undefined,
|
||||
to: statisticsFilters.to || undefined,
|
||||
storeId: statisticsFilters.storeId || undefined,
|
||||
cleanerUserId: statisticsFilters.cleanerUserId || undefined
|
||||
});
|
||||
Object.assign(statistics.summary, result.summary);
|
||||
statistics.byStatus = result.byStatus;
|
||||
statistics.byStore = result.byStore;
|
||||
statistics.settlements = result.settlements;
|
||||
statistics.members = result.members;
|
||||
statistics.trend = result.trend;
|
||||
}, '统计已刷新');
|
||||
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;
|
||||
void loadTasks();
|
||||
}
|
||||
|
||||
function jumpToTaskStatus(status: TaskStatus) {
|
||||
activeTab.value = 'tasks';
|
||||
taskStatus.value = status;
|
||||
tasks.page = 1;
|
||||
void loadTasks();
|
||||
}
|
||||
|
||||
function changeTaskFilters(input: { storeId: string; cleanerUserId: string }) {
|
||||
Object.assign(taskFilters, input);
|
||||
tasks.page = 1;
|
||||
void loadTasks();
|
||||
}
|
||||
|
||||
function changeSettlementStatus(status: SettlementStatus | '') {
|
||||
settlementStatus.value = status;
|
||||
settlements.page = 1;
|
||||
void loadSettlements();
|
||||
}
|
||||
|
||||
function jumpToSettlementStatus(status: SettlementStatus) {
|
||||
activeTab.value = 'settlements';
|
||||
settlementStatus.value = status;
|
||||
settlementFilters.payoutState = '';
|
||||
settlements.page = 1;
|
||||
void loadSettlements();
|
||||
}
|
||||
|
||||
function exportOperationalQueue() {
|
||||
downloadCsv(`cleaning-operational-queue-${Date.now()}.csv`, [
|
||||
['队列', '数量', '金额', '入口'],
|
||||
['待验收任务', String(statistics.summary.pendingReview), '', '任务/SUBMITTED'],
|
||||
['驳回补做', String(statistics.summary.rejected), '', '任务/REJECTED'],
|
||||
['待确认结算', String(draftSettlementTotal.value), '', '结算/DRAFT'],
|
||||
['待发放金额', '', money(statistics.summary.confirmedSettlementCents), '结算/CONFIRMED'],
|
||||
['待结算金额', '', money(statistics.summary.pendingSettlementCents), '统计/待结算'],
|
||||
['已完成任务', String(statistics.summary.completed), '', '统计/已完成']
|
||||
]);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
function changeSettlementFilters(input: {
|
||||
storeId: string;
|
||||
cleanerUserId: string;
|
||||
payoutState: PayoutStateFilter | '';
|
||||
}) {
|
||||
Object.assign(settlementFilters, input);
|
||||
settlements.page = 1;
|
||||
void loadSettlements();
|
||||
}
|
||||
|
||||
function changeTaskPage(page: number) {
|
||||
tasks.page = page;
|
||||
void loadTasks();
|
||||
}
|
||||
|
||||
function changeSettlementPage(page: number) {
|
||||
settlements.page = page;
|
||||
void loadSettlements();
|
||||
}
|
||||
|
||||
function changeStatisticsFilters(input: {
|
||||
from: string;
|
||||
to: string;
|
||||
storeId: string;
|
||||
cleanerUserId: string;
|
||||
}) {
|
||||
Object.assign(statisticsFilters, input);
|
||||
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);
|
||||
await Promise.all([loadTasks(), loadStatistics()]);
|
||||
}, '已指派');
|
||||
}
|
||||
|
||||
async function handleComplete(input: { taskId: string; note?: string }) {
|
||||
await runAction(async () => {
|
||||
await completeCleaningTask(session.value, input.taskId, input.note);
|
||||
await Promise.all([loadTasks(), loadStatistics()]);
|
||||
}, '已验收');
|
||||
}
|
||||
|
||||
async function handleCompleteMany(input: { taskIds: string[]; note?: string }) {
|
||||
await runAction(async () => {
|
||||
for (const taskId of input.taskIds) await completeCleaningTask(session.value, taskId, input.note);
|
||||
await Promise.all([loadTasks(), loadStatistics()]);
|
||||
}, `已批量验收 ${input.taskIds.length} 个任务`);
|
||||
}
|
||||
|
||||
async function handleReject(input: { taskId: string; reason: string }) {
|
||||
await runAction(async () => {
|
||||
await rejectCleaningTask(session.value, input.taskId, input.reason);
|
||||
await Promise.all([loadTasks(), loadStatistics()]);
|
||||
}, '已驳回');
|
||||
}
|
||||
|
||||
async function handleRejectMany(input: { taskIds: string[]; reason: string }) {
|
||||
await runAction(async () => {
|
||||
for (const taskId of input.taskIds) await rejectCleaningTask(session.value, taskId, input.reason);
|
||||
await Promise.all([loadTasks(), loadStatistics()]);
|
||||
}, `已批量驳回 ${input.taskIds.length} 个任务`);
|
||||
}
|
||||
|
||||
async function handleExempt(input: { taskId: string; note?: string }) {
|
||||
await runAction(async () => {
|
||||
await exemptCleaningTask(session.value, input.taskId, input.note);
|
||||
await Promise.all([loadTasks(), loadStatistics()]);
|
||||
}, '已标记免清洁');
|
||||
}
|
||||
|
||||
async function handleExemptMany(input: { taskIds: string[]; note?: string }) {
|
||||
await runAction(async () => {
|
||||
for (const taskId of input.taskIds) await exemptCleaningTask(session.value, taskId, input.note);
|
||||
await Promise.all([loadTasks(), loadStatistics()]);
|
||||
}, `已批量标记免清洁 ${input.taskIds.length} 个任务`);
|
||||
}
|
||||
|
||||
async function handleReclaim(input: { olderThanMinutes: number; limit: number }) {
|
||||
await runAction(async () => {
|
||||
await reclaimCleaningTimeouts(session.value, input);
|
||||
await Promise.all([loadTasks(), loadStatistics()]);
|
||||
}, '已回收超时任务');
|
||||
}
|
||||
|
||||
async function handleGenerateSettlement(input: {
|
||||
cleanerUserId: string;
|
||||
storeId?: string;
|
||||
note?: string;
|
||||
}) {
|
||||
await runAction(async () => {
|
||||
await generateCleaningSettlement(session.value, input);
|
||||
await Promise.all([loadTasks(), loadSettlements(), loadStatistics()]);
|
||||
}, '已生成结算单');
|
||||
}
|
||||
|
||||
async function handleConfirmSettlement(input: { settlementId: string; note?: string }) {
|
||||
await runAction(async () => {
|
||||
await confirmCleaningSettlement(session.value, input.settlementId, input.note);
|
||||
await Promise.all([loadSettlements(), loadStatistics()]);
|
||||
}, '已确认结算单');
|
||||
}
|
||||
|
||||
async function handleTransfer(input: { settlementId: string; mode: TransferMode; note?: string }) {
|
||||
await runAction(async () => {
|
||||
await executeWechatTransfer(session.value, input.settlementId, {
|
||||
mode: input.mode,
|
||||
note: input.note
|
||||
});
|
||||
await Promise.all([loadSettlements(), loadStatistics()]);
|
||||
}, '已提交微信转账');
|
||||
}
|
||||
|
||||
async function handleSyncTransfer(input: { settlementId: string; note?: string }) {
|
||||
await runAction(async () => {
|
||||
await syncWechatTransfer(session.value, input.settlementId, input.note);
|
||||
await Promise.all([loadSettlements(), loadStatistics()]);
|
||||
}, '已同步微信状态');
|
||||
}
|
||||
|
||||
async function handleSyncTransferMany(input: { settlementIds: string[]; note?: string }) {
|
||||
await runAction(async () => {
|
||||
for (const settlementId of input.settlementIds) {
|
||||
await syncWechatTransfer(session.value, settlementId, input.note);
|
||||
}
|
||||
await Promise.all([loadSettlements(), loadStatistics()]);
|
||||
}, `已批量同步 ${input.settlementIds.length} 个结算单`);
|
||||
}
|
||||
|
||||
async function handleFailure(input: {
|
||||
settlementId: string;
|
||||
error: string;
|
||||
payoutReference?: string;
|
||||
note?: string;
|
||||
}) {
|
||||
await runAction(async () => {
|
||||
await recordPayoutFailure(session.value, input.settlementId, {
|
||||
error: input.error,
|
||||
payoutChannel: 'WECHAT_TRANSFER',
|
||||
payoutReference: input.payoutReference,
|
||||
note: input.note
|
||||
});
|
||||
await Promise.all([loadSettlements(), loadStatistics()]);
|
||||
}, '已记录失败');
|
||||
}
|
||||
|
||||
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;
|
||||
phone?: string;
|
||||
storeIds: string[];
|
||||
note?: string;
|
||||
}) {
|
||||
await runAction(async () => {
|
||||
await updateStaffUser(session.value, input.userId, {
|
||||
nickname: input.nickname,
|
||||
phone: input.phone,
|
||||
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 loadCleaningWorkspace();
|
||||
});
|
||||
</script>
|
||||
+2
-3
@@ -1,7 +1,6 @@
|
||||
import { createApp } from 'vue';
|
||||
import ElementPlus from 'element-plus';
|
||||
import 'element-plus/dist/index.css';
|
||||
import './styles.css';
|
||||
import App from './App.vue';
|
||||
import { router } from './router';
|
||||
|
||||
createApp(App).use(ElementPlus).mount('#app');
|
||||
createApp(App).use(router).mount('#app');
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
export type AdminMenuKey =
|
||||
| 'overview'
|
||||
| 'stores'
|
||||
| 'orders'
|
||||
| 'payments'
|
||||
| 'thirdParty'
|
||||
| 'people'
|
||||
| 'cleaning'
|
||||
| 'devices'
|
||||
| 'platformApps'
|
||||
| 'content'
|
||||
| 'franchise'
|
||||
| 'system';
|
||||
|
||||
export interface AdminNavigationItem {
|
||||
menuKey: AdminMenuKey;
|
||||
path: string;
|
||||
stage: string;
|
||||
title: string;
|
||||
label: string;
|
||||
icon: string;
|
||||
capabilities: readonly string[];
|
||||
roles: readonly string[];
|
||||
}
|
||||
|
||||
export interface AdminNavigationAccess {
|
||||
menus: readonly string[];
|
||||
capabilities: readonly string[];
|
||||
roles: readonly string[];
|
||||
}
|
||||
|
||||
export const ADMIN_NAVIGATION: readonly AdminNavigationItem[];
|
||||
export function isMenuAllowed(menus: readonly string[], menuKey: AdminMenuKey): boolean;
|
||||
export function pathForMenu(menuKey: AdminMenuKey): string | null;
|
||||
export function firstAllowedPath(menus: readonly string[]): string | null;
|
||||
export function resolveProtectedPath(menus: readonly string[], requestedMenu: AdminMenuKey): string | null;
|
||||
export function isNavigationAllowed(access: AdminNavigationAccess, item: AdminNavigationItem): boolean;
|
||||
export function firstAuthorizedPath(access: AdminNavigationAccess): string | null;
|
||||
export function resolveAuthorizedPath(access: AdminNavigationAccess, requestedMenu: AdminMenuKey): string | null;
|
||||
@@ -0,0 +1,49 @@
|
||||
export const ADMIN_NAVIGATION = Object.freeze([
|
||||
{ menuKey: 'overview', path: '/overview', stage: 'M08-D', title: '平台运营总览', label: '运营总览', icon: 'LayoutDashboard', capabilities: ['store.operation.read', 'tenant.manage', 'platform.manage'], roles: ['PLATFORM_ADMIN'] },
|
||||
{ menuKey: 'stores', path: '/stores', stage: 'M08-D', title: '门店与房间管理', label: '门店房间', icon: 'Store', capabilities: ['store.operation.read', 'tenant.manage', 'platform.manage'], roles: ['PLATFORM_ADMIN'] },
|
||||
{ menuKey: 'orders', path: '/orders', stage: 'M08-D', title: '订单筛选与运营处置', label: '订单运营', icon: 'ClipboardList', capabilities: ['store.operation.read', 'tenant.manage', 'platform.manage'], roles: ['PLATFORM_ADMIN'] },
|
||||
{ menuKey: 'payments', path: '/payments', stage: 'M08-D', title: '支付、退款与分账', label: '支付分账', icon: 'WalletCards', capabilities: ['tenant.manage', 'platform.manage'], roles: ['PLATFORM_ADMIN'] },
|
||||
{ menuKey: 'thirdParty', path: '/third-party', stage: 'M08-D', title: '团购与第三方平台运营', label: '团购平台', icon: 'TicketCheck', capabilities: ['store.operation.read', 'tenant.manage', 'platform.manage'], roles: ['PLATFORM_ADMIN'] },
|
||||
{ menuKey: 'people', path: '/people', stage: 'M08-D', title: '会员与员工用户管理', label: '会员员工', icon: 'UsersRound', capabilities: ['tenant.manage', 'platform.manage'], roles: ['PLATFORM_ADMIN'] },
|
||||
{ menuKey: 'cleaning', path: '/cleaning', stage: 'M08-B/M09', title: '保洁任务与结算', label: '保洁运营', icon: 'Sparkles', capabilities: ['cleaning.task.read', 'tenant.manage', 'platform.manage'], roles: ['PLATFORM_ADMIN'] },
|
||||
{ menuKey: 'devices', path: '/devices', stage: 'M08-D', title: '设备资产、拓扑与控制', label: '设备', icon: 'RadioTower', capabilities: ['device.read', 'tenant.manage', 'platform.manage'], roles: ['PLATFORM_ADMIN'] },
|
||||
{ menuKey: 'platformApps', path: '/apps', stage: 'M08-D', title: '多小程序与租户品牌配置', label: '小程序租户', icon: 'PanelsTopLeft', capabilities: ['tenant.manage', 'platform.manage'], roles: ['PLATFORM_ADMIN'] },
|
||||
{ menuKey: 'content', path: '/content', stage: 'M08-D', title: '广告投放与门店装修', label: '广告装修', icon: 'Images', capabilities: ['tenant.manage', 'platform.manage'], roles: ['PLATFORM_ADMIN'] },
|
||||
{ menuKey: 'franchise', path: '/franchise', stage: 'M08-D', title: '加盟申请与跟进运营', label: '加盟跟进', icon: 'Handshake', capabilities: ['tenant.manage', 'platform.manage'], roles: ['PLATFORM_ADMIN'] },
|
||||
{ menuKey: 'system', path: '/system', stage: 'M08-D', title: '安全审计与系统配置', label: '日志配置', icon: 'ScrollText', capabilities: ['tenant.manage', 'platform.manage'], roles: ['PLATFORM_ADMIN'] }
|
||||
]);
|
||||
|
||||
export function isMenuAllowed(menus, menuKey) {
|
||||
return menus.includes(menuKey);
|
||||
}
|
||||
|
||||
export function pathForMenu(menuKey) {
|
||||
return ADMIN_NAVIGATION.find((item) => item.menuKey === menuKey)?.path ?? null;
|
||||
}
|
||||
|
||||
export function firstAllowedPath(menus) {
|
||||
return ADMIN_NAVIGATION.find((item) => menus.includes(item.menuKey))?.path ?? null;
|
||||
}
|
||||
|
||||
export function resolveProtectedPath(menus, requestedMenu) {
|
||||
return isMenuAllowed(menus, requestedMenu)
|
||||
? pathForMenu(requestedMenu)
|
||||
: firstAllowedPath(menus);
|
||||
}
|
||||
|
||||
export function isNavigationAllowed(access, item) {
|
||||
if (!isMenuAllowed(access.menus, item.menuKey)) return false;
|
||||
return item.capabilities.some((capability) => access.capabilities.includes(capability))
|
||||
|| item.roles.some((role) => access.roles.includes(role));
|
||||
}
|
||||
|
||||
export function firstAuthorizedPath(access) {
|
||||
return ADMIN_NAVIGATION.find((item) => isNavigationAllowed(access, item))?.path ?? null;
|
||||
}
|
||||
|
||||
export function resolveAuthorizedPath(access, requestedMenu) {
|
||||
const requested = ADMIN_NAVIGATION.find((item) => item.menuKey === requestedMenu);
|
||||
return requested && isNavigationAllowed(access, requested)
|
||||
? requested.path
|
||||
: firstAuthorizedPath(access);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router';
|
||||
import {
|
||||
ADMIN_NAVIGATION,
|
||||
firstAuthorizedPath,
|
||||
isNavigationAllowed,
|
||||
type AdminMenuKey
|
||||
} from './navigation.mjs';
|
||||
import { adminSessionState } from './session';
|
||||
|
||||
const routeComponents = {
|
||||
overview: () => import('./components/OperationsOverviewPanel.vue'),
|
||||
stores: () => import('./components/StoresRoomsPanel.vue'),
|
||||
orders: () => import('./components/OrdersPanel.vue'),
|
||||
payments: () => import('./components/PaymentsPanel.vue'),
|
||||
thirdParty: () => import('./components/ThirdPartyPanel.vue'),
|
||||
people: () => import('./components/MembersStaffPanel.vue'),
|
||||
cleaning: () => import('./components/CleaningWorkspace.vue'),
|
||||
devices: () => import('./components/DevicesPanel.vue'),
|
||||
platformApps: () => import('./components/PlatformAppsPanel.vue'),
|
||||
content: () => import('./components/ContentManagementPanel.vue'),
|
||||
franchise: () => import('./components/FranchisePanel.vue'),
|
||||
system: () => import('./components/LogsSystemPanel.vue')
|
||||
} satisfies Record<AdminMenuKey, () => Promise<unknown>>;
|
||||
|
||||
const businessRoutes = ADMIN_NAVIGATION.map<RouteRecordRaw>((item) => ({
|
||||
path: item.path,
|
||||
name: item.menuKey,
|
||||
component: routeComponents[item.menuKey],
|
||||
meta: {
|
||||
menuKey: item.menuKey,
|
||||
stage: item.stage,
|
||||
title: item.title
|
||||
}
|
||||
}));
|
||||
|
||||
export const router = createRouter({
|
||||
history: createWebHistory(import.meta.env.BASE_URL),
|
||||
routes: [
|
||||
{ path: '/', redirect: '/overview' },
|
||||
...businessRoutes,
|
||||
{ path: '/:pathMatch(.*)*', redirect: '/overview' }
|
||||
],
|
||||
scrollBehavior: () => ({ top: 0 })
|
||||
});
|
||||
|
||||
router.beforeEach((to) => {
|
||||
const menuKey = to.meta.menuKey as AdminMenuKey | undefined;
|
||||
if (!menuKey || !adminSessionState.token || !adminSessionState.ready) return true;
|
||||
const navigation = ADMIN_NAVIGATION.find((item) => item.menuKey === menuKey);
|
||||
if (navigation && isNavigationAllowed(adminSessionState.access, navigation)) return true;
|
||||
const fallback = firstAuthorizedPath(adminSessionState.access);
|
||||
return fallback ? { path: fallback, replace: true } : false;
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
import { reactive } from 'vue';
|
||||
import type { AdminAccess, AdminIdentity, AdminSessionPayload } from './types';
|
||||
|
||||
const emptyAccess = (): AdminAccess => ({ roles: [], capabilities: [], storeIds: [], menus: [] });
|
||||
|
||||
export const adminSessionState = reactive<{
|
||||
token: string;
|
||||
user: AdminIdentity | null;
|
||||
access: AdminAccess;
|
||||
ready: boolean;
|
||||
}>({
|
||||
token: localStorage.getItem('qipai.admin.token') || '',
|
||||
user: null,
|
||||
access: emptyAccess(),
|
||||
ready: !localStorage.getItem('qipai.admin.token')
|
||||
});
|
||||
|
||||
export function applyAdminSession(payload: AdminSessionPayload) {
|
||||
adminSessionState.token = payload.accessToken;
|
||||
adminSessionState.user = payload.user;
|
||||
adminSessionState.access = payload.access;
|
||||
adminSessionState.ready = true;
|
||||
}
|
||||
|
||||
export function applyAdminIdentity(user: AdminIdentity, access: AdminAccess) {
|
||||
adminSessionState.user = user;
|
||||
adminSessionState.access = access;
|
||||
adminSessionState.ready = true;
|
||||
}
|
||||
|
||||
export function clearAdminSessionState() {
|
||||
adminSessionState.token = '';
|
||||
adminSessionState.user = null;
|
||||
adminSessionState.access = emptyAccess();
|
||||
adminSessionState.ready = true;
|
||||
}
|
||||
@@ -678,6 +678,46 @@ textarea {
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.route-alert,
|
||||
.route-state,
|
||||
.session-recovery-card {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.route-state,
|
||||
.session-recovery-card {
|
||||
padding: 24px;
|
||||
border: 1px solid #d8e0ea;
|
||||
border-radius: 12px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.session-recovery {
|
||||
display: grid;
|
||||
min-height: 100vh;
|
||||
padding: 24px;
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
.session-recovery-card {
|
||||
width: min(100%, 520px);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.session-recovery-card h1 {
|
||||
margin: 0 0 12px;
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.session-recovery-card p {
|
||||
color: #60708a;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.cleaning-workspace {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.session-box,
|
||||
.session-user {
|
||||
display: flex;
|
||||
@@ -2413,6 +2453,96 @@ textarea {
|
||||
}
|
||||
}
|
||||
|
||||
/* M08-D-R1: 768px tablet and 375px phone share a horizontal-scroll-free core layout. */
|
||||
@media (max-width: 768px) {
|
||||
.workspace {
|
||||
max-width: 100vw;
|
||||
overflow-x: clip;
|
||||
}
|
||||
|
||||
.workspace :where(.el-form, .el-form-item, .el-input, .el-select, .el-date-editor),
|
||||
.workspace :where(.el-dialog, .el-drawer) {
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.workspace .el-dialog {
|
||||
width: calc(100vw - 32px) !important;
|
||||
margin: 16px auto !important;
|
||||
}
|
||||
|
||||
.workspace .el-drawer {
|
||||
width: min(92vw, 520px) !important;
|
||||
}
|
||||
|
||||
.workspace .data-table {
|
||||
width: 100% !important;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.workspace .data-table .el-table__inner-wrapper::before,
|
||||
.workspace .data-table .el-table__header-wrapper {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.workspace .data-table .el-table__body-wrapper,
|
||||
.workspace .data-table .el-scrollbar__view,
|
||||
.workspace .data-table .el-table__body,
|
||||
.workspace .data-table .el-table__body tbody {
|
||||
display: block;
|
||||
width: 100% !important;
|
||||
}
|
||||
|
||||
.workspace .data-table .el-table__body colgroup {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.workspace .data-table .el-table__body tbody {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.workspace .data-table .el-table__row {
|
||||
display: grid;
|
||||
width: 100%;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid #d8e0ea;
|
||||
border-radius: 10px;
|
||||
box-sizing: border-box;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.workspace .data-table .el-table__cell {
|
||||
position: static !important;
|
||||
display: block;
|
||||
width: 100% !important;
|
||||
padding: 6px 0;
|
||||
border: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.workspace .data-table .cell {
|
||||
padding: 0;
|
||||
overflow: visible;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.workspace .data-table .row-actions {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.asset-form,
|
||||
.member-form,
|
||||
.detail-grid,
|
||||
.timeline-grid,
|
||||
.stats-filter,
|
||||
.handoff-session .el-form,
|
||||
.handoff-execution-form,
|
||||
.handoff-category-progress {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 560px) {
|
||||
.login-shell {
|
||||
grid-template-columns: 1fr;
|
||||
|
||||
Reference in New Issue
Block a user