refactor(M08-D-R1): 后台改用权限路由与懒加载

This commit is contained in:
Codex
2026-08-10 20:57:30 +08:00
parent bb89061671
commit 3faf86872d
17 changed files with 1768 additions and 814 deletions
+184 -764
View File
File diff suppressed because it is too large Load Diff
+615
View File
@@ -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
View File
@@ -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');
+39
View File
@@ -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;
+49
View File
@@ -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);
}
+53
View File
@@ -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;
});
+36
View File
@@ -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;
}
+130
View File
@@ -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;