feat(M08-D): 补后台登录与可撤销会话
This commit is contained in:
+38
-26
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user