feat(M08-D): 补后台登录与可撤销会话

This commit is contained in:
Codex
2026-08-10 13:54:03 +08:00
parent 93d83e81b9
commit 05110087d1
21 changed files with 1059 additions and 48 deletions
+38 -26
View File
@@ -1,6 +1,7 @@
<template>
<el-config-provider>
<main class="app-shell">
<AdminLoginPanel v-if="!savedToken" @authenticated="handleAuthenticated" />
<main v-else class="app-shell">
<aside class="sidebar">
<div class="brand">
<div class="brand-mark"></div>
@@ -10,11 +11,12 @@
</div>
</div>
<nav class="nav-list" aria-label="后台模块">
<button class="nav-item" :class="{ active: activeModule === 'platformApps' }" type="button" @click="activeModule = 'platformApps'"><PanelsTopLeft :size="18" /><span>小程序租户</span></button>
<button class="nav-item" :class="{ active: activeModule === 'content' }" type="button" @click="activeModule = 'content'"><Images :size="18" /><span>广告装修</span></button>
<button class="nav-item" :class="{ active: activeModule === 'franchise' }" type="button" @click="activeModule = 'franchise'"><Handshake :size="18" /><span>加盟跟进</span></button>
<button class="nav-item" :class="{ active: activeModule === 'system' }" type="button" @click="activeModule = 'system'"><ScrollText :size="18" /><span>日志配置</span></button>
<button v-if="canOpen('platformApps')" class="nav-item" :class="{ active: activeModule === 'platformApps' }" type="button" @click="activeModule = 'platformApps'"><PanelsTopLeft :size="18" /><span>小程序租户</span></button>
<button v-if="canOpen('content')" class="nav-item" :class="{ active: activeModule === 'content' }" type="button" @click="activeModule = 'content'"><Images :size="18" /><span>广告装修</span></button>
<button v-if="canOpen('franchise')" class="nav-item" :class="{ active: activeModule === 'franchise' }" type="button" @click="activeModule = 'franchise'"><Handshake :size="18" /><span>加盟跟进</span></button>
<button v-if="canOpen('system')" class="nav-item" :class="{ active: activeModule === 'system' }" type="button" @click="activeModule = 'system'"><ScrollText :size="18" /><span>日志配置</span></button>
<button
v-if="canOpen('overview')"
class="nav-item"
:class="{ active: activeModule === 'overview' }"
type="button"
@@ -24,6 +26,7 @@
<span>运营总览</span>
</button>
<button
v-if="canOpen('cleaning')"
class="nav-item"
:class="{ active: activeModule === 'cleaning' }"
type="button"
@@ -33,6 +36,7 @@
<span>保洁运营</span>
</button>
<button
v-if="canOpen('stores')"
class="nav-item"
:class="{ active: activeModule === 'stores' }"
type="button"
@@ -42,6 +46,7 @@
<span>门店房间</span>
</button>
<button
v-if="canOpen('orders')"
class="nav-item"
:class="{ active: activeModule === 'orders' }"
type="button"
@@ -51,6 +56,7 @@
<span>订单运营</span>
</button>
<button
v-if="canOpen('devices')"
class="nav-item"
:class="{ active: activeModule === 'devices' }"
type="button"
@@ -60,6 +66,7 @@
<span>设备</span>
</button>
<button
v-if="canOpen('payments')"
class="nav-item"
:class="{ active: activeModule === 'payments' }"
type="button"
@@ -69,6 +76,7 @@
<span>支付分账</span>
</button>
<button
v-if="canOpen('thirdParty')"
class="nav-item"
:class="{ active: activeModule === 'thirdParty' }"
type="button"
@@ -78,6 +86,7 @@
<span>团购平台</span>
</button>
<button
v-if="canOpen('people')"
class="nav-item"
:class="{ active: activeModule === 'people' }"
type="button"
@@ -95,18 +104,9 @@
<p class="eyebrow">{{ activeModuleMeta.stage }}</p>
<h2>{{ activeModuleMeta.title }}</h2>
</div>
<div class="token-box">
<el-input
v-model="tokenDraft"
type="password"
show-password
placeholder="后台访问令牌"
autocomplete="off"
@keyup.enter="saveToken"
/>
<el-tooltip content="保存令牌" placement="bottom">
<el-button :icon="Save" type="primary" @click="saveToken" />
</el-tooltip>
<div class="session-box">
<div class="session-user"><span>{{ currentUser?.nickname?.slice(0, 1) || '管' }}</span><div><strong>{{ currentUser?.nickname || '管理员' }}</strong><small>{{ currentAccess.roles.join(' · ') || '已登录' }}</small></div></div>
<el-tooltip content="安全退出" placement="bottom"><el-button :icon="LogOut" circle @click="handleLogout" /></el-tooltip>
</div>
</header>
@@ -322,7 +322,7 @@
</template>
<script setup lang="ts">
import { computed, reactive, ref, watch } from 'vue';
import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue';
import { ElMessage } from 'element-plus';
import {
ClipboardCheck,
@@ -336,7 +336,7 @@ import {
RadioTower,
RotateCcw,
ScrollText,
Save,
LogOut,
Send,
Sparkles,
Store,
@@ -360,6 +360,7 @@ import PlatformAppsPanel from './components/PlatformAppsPanel.vue';
import ContentManagementPanel from './components/ContentManagementPanel.vue';
import FranchisePanel from './components/FranchisePanel.vue';
import LogsSystemPanel from './components/LogsSystemPanel.vue';
import AdminLoginPanel from './components/AdminLoginPanel.vue';
import {
ApiError,
assignCleaningTask,
@@ -370,20 +371,27 @@ import {
executeWechatTransfer,
generateCleaningSettlement,
getCleaningStatistics,
getAdminMe,
listCleaningSettlements,
listCleaningTasks,
listStaffUsers,
logoutAdmin,
reclaimCleaningTimeouts,
recordPayoutFailure,
rejectCleaningTask,
resetStaffSessions,
syncWechatTransfer,
clearStoredSession,
storeAdminSession,
updateStaffUser
} from './api';
import type {
CleaningSettlement,
CleaningStatistics,
CleaningTask,
AdminAccess,
AdminIdentity,
AdminSessionPayload,
ManagedUser,
PageResult,
PayoutStateFilter,
@@ -395,7 +403,8 @@ import type {
import { money } from './format';
const savedToken = ref(localStorage.getItem('qipai.admin.token') || '');
const tokenDraft = ref(savedToken.value);
const currentUser = ref<AdminIdentity | null>(null);
const currentAccess = reactive<AdminAccess>({ roles: [], capabilities: [], storeIds: [], menus: [] });
const activeModule = ref<'overview' | 'platformApps' | 'content' | 'franchise' | 'system' | 'stores' | 'orders' | 'devices' | 'payments' | 'thirdParty' | 'people' | 'cleaning'>('overview');
const activeTab = ref('tasks');
const lastError = ref('');
@@ -462,12 +471,13 @@ const operationalQueueTotal = computed(() => (
+ draftSettlementTotal.value
));
function saveToken() {
savedToken.value = tokenDraft.value.trim();
localStorage.setItem('qipai.admin.token', savedToken.value);
ElMessage.success('已保存');
if (activeModule.value === 'cleaning' && savedToken.value) void loadCleaningWorkspace();
}
function handleAuthenticated(payload: AdminSessionPayload) { storeAdminSession(payload); savedToken.value = payload.accessToken; currentUser.value = payload.user; Object.assign(currentAccess, payload.access); selectAllowedModule(); ElMessage.success('登录成功'); }
async function handleLogout() { try { await logoutAdmin(session.value); } catch { /* local cleanup is authoritative */ } clearSession(); }
function clearSession() { clearStoredSession(); savedToken.value = ''; currentUser.value = null; Object.assign(currentAccess, { roles: [], capabilities: [], storeIds: [], menus: [] }); }
function handleRefreshed(event: Event) { const payload = (event as CustomEvent<AdminSessionPayload>).detail; savedToken.value = payload.accessToken; currentUser.value = payload.user; Object.assign(currentAccess, payload.access); selectAllowedModule(); }
async function restoreSession() { if (!savedToken.value) return; try { const result = await getAdminMe(session.value); currentUser.value = result.user; Object.assign(currentAccess, result.access); selectAllowedModule(); } catch { /* request layer refreshes or clears */ } }
function canOpen(module: string) { return currentAccess.menus.includes(module); }
function selectAllowedModule() { if (!canOpen(activeModule.value)) activeModule.value = (currentAccess.menus[0] || 'overview') as typeof activeModule.value; }
function loadCleaningWorkspace() {
return Promise.all([loadTasks(), loadSettlements(), loadStatistics(), loadCleaners()]);
@@ -821,4 +831,6 @@ async function handleResetCleanerSessions(userId: string) {
watch(activeModule, (module) => {
if (module === 'cleaning' && session.value.token) void loadCleaningWorkspace();
});
onMounted(() => { window.addEventListener('qipai:admin-refreshed', handleRefreshed); window.addEventListener('qipai:admin-unauthorized', clearSession); void restoreSession(); });
onBeforeUnmount(() => { window.removeEventListener('qipai:admin-refreshed', handleRefreshed); window.removeEventListener('qipai:admin-unauthorized', clearSession); });
</script>