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>
|
||||
|
||||
+93
-5
@@ -1,6 +1,9 @@
|
||||
import type {
|
||||
Advertisement,
|
||||
AdvertisementInput,
|
||||
AdminIdentity,
|
||||
AdminSessionPayload,
|
||||
AdminAccess,
|
||||
AuditLog,
|
||||
CleaningSettlement,
|
||||
CleaningSettlementDetail,
|
||||
@@ -66,6 +69,34 @@ export interface ApiSession {
|
||||
token: string;
|
||||
}
|
||||
|
||||
const ACCESS_TOKEN_KEY = 'qipai.admin.token';
|
||||
const REFRESH_TOKEN_KEY = 'qipai.admin.refreshToken';
|
||||
let refreshInFlight: Promise<AdminSessionPayload> | null = null;
|
||||
|
||||
export async function adminPasswordLogin(input: {
|
||||
tenantCode: string; loginName: string; password: string;
|
||||
}) {
|
||||
return publicRequest<AdminSessionPayload>('/auth/login', {
|
||||
method: 'POST', body: JSON.stringify(input)
|
||||
});
|
||||
}
|
||||
|
||||
export function getAdminMe(session: ApiSession) {
|
||||
return request<{ user: AdminIdentity; access: AdminAccess }>(session, '/auth/me');
|
||||
}
|
||||
|
||||
export function logoutAdmin(session: ApiSession) {
|
||||
return request<{ revoked: boolean }>(session, '/auth/logout', { method: 'POST' });
|
||||
}
|
||||
|
||||
export function setAdminCredential(session: ApiSession, userId: string, input: {
|
||||
loginName: string; password: string;
|
||||
}) {
|
||||
return request<{ userId: string; configured: boolean }>(session,
|
||||
`/auth/credentials/${encodeURIComponent(userId)}`,
|
||||
{ method: 'PUT', body: JSON.stringify(input) });
|
||||
}
|
||||
|
||||
export function listManagedStores(session: ApiSession) {
|
||||
return request<ManagedStore[]>(session, '/stores');
|
||||
}
|
||||
@@ -317,14 +348,22 @@ async function request<T>(
|
||||
if (!session.token.trim()) {
|
||||
throw new ApiError('AUTH_TOKEN_REQUIRED', '需要先填入后台访问令牌');
|
||||
}
|
||||
const headers = new Headers(options.headers);
|
||||
headers.set('authorization', `Bearer ${session.token.trim()}`);
|
||||
if (options.body && !headers.has('content-type')) {
|
||||
headers.set('content-type', 'application/json');
|
||||
let response = await authorizedFetch(path, options, session.token.trim());
|
||||
if (response.status === 401 && localStorage.getItem(REFRESH_TOKEN_KEY)) {
|
||||
try {
|
||||
const refreshed = await refreshAccessToken();
|
||||
response = await authorizedFetch(path, options, refreshed.accessToken);
|
||||
} catch {
|
||||
clearStoredSession();
|
||||
window.dispatchEvent(new Event('qipai:admin-unauthorized'));
|
||||
}
|
||||
}
|
||||
const response = await fetch(`${API_BASE}${path}`, { ...options, headers });
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
if (!response.ok || payload.code !== 0) {
|
||||
if (response.status === 401) {
|
||||
clearStoredSession();
|
||||
window.dispatchEvent(new Event('qipai:admin-unauthorized'));
|
||||
}
|
||||
throw new ApiError(
|
||||
String(payload.code || `HTTP_${response.status}`),
|
||||
String(payload.message || '请求失败'),
|
||||
@@ -334,6 +373,55 @@ async function request<T>(
|
||||
return payload.data as T;
|
||||
}
|
||||
|
||||
async function publicRequest<T>(path: string, options: RequestInit): Promise<T> {
|
||||
const headers = new Headers(options.headers);
|
||||
headers.set('content-type', 'application/json');
|
||||
headers.set('x-request-id', requestId());
|
||||
const response = await fetch(`${API_BASE}${path}`, { ...options, headers });
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
if (!response.ok || payload.code !== 0) throw new ApiError(
|
||||
String(payload.code || `HTTP_${response.status}`),
|
||||
String(payload.message || '请求失败'), String(payload.traceId || '')
|
||||
);
|
||||
return payload.data as T;
|
||||
}
|
||||
|
||||
async function authorizedFetch(path: string, options: RequestInit, token: string) {
|
||||
const headers = new Headers(options.headers);
|
||||
headers.set('authorization', `Bearer ${token}`);
|
||||
headers.set('x-request-id', requestId());
|
||||
if (options.body && !headers.has('content-type')) headers.set('content-type', 'application/json');
|
||||
return fetch(`${API_BASE}${path}`, { ...options, headers });
|
||||
}
|
||||
|
||||
async function refreshAccessToken() {
|
||||
if (!refreshInFlight) {
|
||||
const refreshToken = localStorage.getItem(REFRESH_TOKEN_KEY) || '';
|
||||
refreshInFlight = publicRequest<AdminSessionPayload>('/auth/refresh', {
|
||||
method: 'POST', body: JSON.stringify({ refreshToken })
|
||||
}).then((payload) => {
|
||||
storeAdminSession(payload);
|
||||
window.dispatchEvent(new CustomEvent('qipai:admin-refreshed', { detail: payload }));
|
||||
return payload;
|
||||
}).finally(() => { refreshInFlight = null; });
|
||||
}
|
||||
return refreshInFlight;
|
||||
}
|
||||
|
||||
export function storeAdminSession(payload: AdminSessionPayload) {
|
||||
localStorage.setItem(ACCESS_TOKEN_KEY, payload.accessToken);
|
||||
localStorage.setItem(REFRESH_TOKEN_KEY, payload.refreshToken);
|
||||
}
|
||||
|
||||
export function clearStoredSession() {
|
||||
localStorage.removeItem(ACCESS_TOKEN_KEY);
|
||||
localStorage.removeItem(REFRESH_TOKEN_KEY);
|
||||
}
|
||||
|
||||
function requestId() {
|
||||
return globalThis.crypto?.randomUUID?.() || `${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||||
}
|
||||
|
||||
export function listCleaningTasks(
|
||||
session: ApiSession,
|
||||
input: { page: number; pageSize: number; status?: TaskStatus; storeId?: string; cleanerUserId?: string }
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
<template>
|
||||
<main class="login-shell">
|
||||
<section class="login-story" aria-label="平台介绍">
|
||||
<div class="login-brand"><span>棋</span><strong>自助棋牌室运营平台</strong></div>
|
||||
<div><p class="section-kicker">QIPAI OPERATIONS</p><h1>一处掌握门店、订单、设备与资金。</h1><p>统一租户权限、可撤销会话与全链路审计,为日常运营提供可信工作台。</p></div>
|
||||
<ul><li><ShieldCheck :size="18" />短期访问令牌与轮换刷新令牌</li><li><Fingerprint :size="18" />scrypt 密码哈希与失败锁定</li><li><ScrollText :size="18" />关键操作按租户完整留痕</li></ul>
|
||||
</section>
|
||||
<section class="login-form-wrap">
|
||||
<div class="login-card">
|
||||
<header><p class="section-kicker">管理后台</p><h2>欢迎回来</h2><p>使用租户代码和后台账号登录。</p></header>
|
||||
<el-alert v-if="lastError" :title="lastError" type="error" show-icon :closable="false" />
|
||||
<el-form label-position="top" @submit.prevent="login">
|
||||
<el-form-item label="租户代码"><el-input v-model="form.tenantCode" size="large" autocomplete="organization" placeholder="例如 demo" @keyup.enter="login" /></el-form-item>
|
||||
<el-form-item label="登录账号"><el-input v-model="form.loginName" size="large" autocomplete="username" placeholder="请输入后台账号" @keyup.enter="login" /></el-form-item>
|
||||
<el-form-item label="密码"><el-input v-model="form.password" size="large" type="password" show-password autocomplete="current-password" placeholder="请输入密码" @keyup.enter="login" /></el-form-item>
|
||||
<el-button type="primary" size="large" :loading="loading" :disabled="!canSubmit" @click="login">安全登录</el-button>
|
||||
</el-form>
|
||||
<footer><LockKeyhole :size="15" /><span>连续失败 5 次将锁定账号 15 分钟</span></footer>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, reactive, ref } from 'vue';
|
||||
import { Fingerprint, LockKeyhole, ScrollText, ShieldCheck } from '@lucide/vue';
|
||||
import { adminPasswordLogin, ApiError } from '../api';
|
||||
import type { AdminSessionPayload } from '../types';
|
||||
|
||||
const emit = defineEmits<{ authenticated: [payload: AdminSessionPayload] }>();
|
||||
const loading = ref(false); const lastError = ref('');
|
||||
const form = reactive({ tenantCode: localStorage.getItem('qipai.admin.tenantCode') || '', loginName: '', password: '' });
|
||||
const canSubmit = computed(() => form.tenantCode.trim().length >= 2 && form.loginName.trim().length >= 3 && form.password.length > 0);
|
||||
async function login() { if (!canSubmit.value || loading.value) return; loading.value = true; lastError.value = ''; try { const payload = await adminPasswordLogin({ tenantCode: form.tenantCode.trim(), loginName: form.loginName.trim(), ['password']: form.password }); localStorage.setItem('qipai.admin.tenantCode', form.tenantCode.trim()); form.password = ''; emit('authenticated', payload); } catch (error) { lastError.value = error instanceof ApiError ? loginMessage(error) : error instanceof Error ? error.message : '登录失败'; } finally { loading.value = false; } }
|
||||
function loginMessage(error: ApiError) { if (error.code === 'ADMIN_LOGIN_LOCKED') return '登录失败次数过多,账号已暂时锁定,请 15 分钟后重试。'; if (error.code === 'ADMIN_LOGIN_INVALID') return '租户代码、登录账号或密码不正确。'; return `${error.code}${error.traceId ? ` · 追踪号 ${error.traceId}` : ''}`; }
|
||||
</script>
|
||||
@@ -47,7 +47,7 @@
|
||||
<el-table-column label="门店范围" min-width="190"><template #default="{ row }">{{ storeScopeLabel(row.storeIds) }}</template></el-table-column>
|
||||
<el-table-column label="微信 / 最近登录" min-width="170"><template #default="{ row }"><div class="stack"><strong>{{ row.wechatMiniappBound ? '已绑定小程序' : '未绑定小程序' }}</strong><span>{{ shortDate(row.lastLoginAt) }} · {{ row.maskedLastIp || '-' }}</span></div></template></el-table-column>
|
||||
<el-table-column prop="note" label="运营备注" min-width="160" />
|
||||
<el-table-column label="操作" width="250" 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'" @click="toggleStaff(row)">{{ row.status === 'ACTIVE' ? '停用' : '启用' }}</el-button><el-button size="small" :icon="ShieldX" @click="resetSessions(row)">会话</el-button></div></template></el-table-column>
|
||||
<el-table-column label="操作" width="335" fixed="right"><template #default="{ row }"><div class="row-actions"><el-button size="small" :icon="Pencil" @click="openEdit(row)">编辑</el-button><el-button size="small" :icon="KeyRound" @click="openCredential(row)">登录</el-button><el-button size="small" :type="row.status === 'ACTIVE' ? 'warning' : 'success'" @click="toggleStaff(row)">{{ row.status === 'ACTIVE' ? '停用' : '启用' }}</el-button><el-button size="small" :icon="ShieldX" @click="resetSessions(row)">会话</el-button></div></template></el-table-column>
|
||||
</el-table>
|
||||
<div class="pager"><el-pagination layout="prev, pager, next, total" :current-page="staff.page" :page-size="staff.pageSize" :total="staff.total" @current-change="changeStaffPage" /></div>
|
||||
</el-tab-pane>
|
||||
@@ -72,14 +72,26 @@
|
||||
<el-form label-position="top" class="asset-form"><el-form-item label="姓名"><el-input v-model="staffForm.nickname" /></el-form-item><el-form-item :label="staffForm.userId ? '新手机号(留空保持原值)' : '手机号'"><el-input v-model="staffForm.phone" inputmode="tel" /></el-form-item><el-form-item label="角色" class="form-span-2"><el-checkbox-group v-model="staffForm.roles"><el-checkbox v-for="item in roleOptions" :key="item.value" :value="item.value">{{ item.label }}</el-checkbox></el-checkbox-group></el-form-item><el-form-item label="门店授权" class="form-span-2"><el-select v-model="staffForm.storeIds" multiple filterable collapse-tags><el-option v-for="store in stores" :key="store.id" :label="store.name" :value="store.id" /></el-select></el-form-item><el-form-item label="运营备注" class="form-span-2"><el-input v-model="staffForm.note" type="textarea" :rows="3" /></el-form-item></el-form>
|
||||
<template #footer><el-button @click="staffDialog = false">取消</el-button><el-button type="primary" :loading="saving" @click="saveStaff">保存</el-button></template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog v-model="credentialDialog" title="设置后台登录" width="min(480px, 94vw)">
|
||||
<el-alert title="保存后将撤销该员工现有会话;密码不会在后台回显。" type="warning" show-icon :closable="false" />
|
||||
<el-form label-position="top" class="credential-form">
|
||||
<el-form-item label="员工"><el-input :model-value="credentialForm.nickname" disabled /></el-form-item>
|
||||
<el-form-item label="登录账号"><el-input v-model="credentialForm.loginName" autocomplete="off" maxlength="64" placeholder="字母、数字、点、下划线或 @" /></el-form-item>
|
||||
<el-form-item label="新密码"><el-input v-model="credentialForm.password" type="password" show-password autocomplete="new-password" maxlength="128" /></el-form-item>
|
||||
<el-form-item label="确认新密码"><el-input v-model="credentialForm.confirmPassword" type="password" show-password autocomplete="new-password" maxlength="128" /></el-form-item>
|
||||
<small class="field-help">至少 12 位,同时包含字母、数字和特殊字符。</small>
|
||||
</el-form>
|
||||
<template #footer><el-button @click="credentialDialog = false">取消</el-button><el-button type="primary" :loading="saving" @click="saveCredential">保存登录凭据</el-button></template>
|
||||
</el-dialog>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, reactive, ref, watch } from 'vue';
|
||||
import { ElMessage, ElMessageBox } from 'element-plus';
|
||||
import { Eye, Pencil, RefreshCw, ShieldX, UserPlus } from '@lucide/vue';
|
||||
import { ApiError, createStaffUser, getMember, listManagedStores, listMembers, listStaffUsers, resetStaffSessions, updateStaffUser, type ApiSession } from '../api';
|
||||
import { Eye, KeyRound, Pencil, RefreshCw, ShieldX, UserPlus } from '@lucide/vue';
|
||||
import { ApiError, createStaffUser, getMember, listManagedStores, listMembers, listStaffUsers, resetStaffSessions, setAdminCredential, updateStaffUser, type ApiSession } from '../api';
|
||||
import { money, shortDate } from '../format';
|
||||
import type { ManagedStore, ManagedUser, MemberCard, MemberDetail, PageResult, StaffRole, UserStatus } from '../types';
|
||||
|
||||
@@ -96,7 +108,9 @@ const staffFilters = reactive({ search: '', status: '' as UserStatus | '', role:
|
||||
const memberDrawer = ref(false);
|
||||
const memberDetail = ref<MemberDetail | null>(null);
|
||||
const staffDialog = ref(false);
|
||||
const credentialDialog = ref(false);
|
||||
const staffForm = reactive({ userId: '', nickname: '', phone: '', note: '', roles: ['STAFF'] as StaffRole[], storeIds: [] as string[] });
|
||||
const credentialForm = reactive({ userId: '', nickname: '', loginName: '', password: '', confirmPassword: '' });
|
||||
const roleOptions: Array<{ value: StaffRole; label: string }> = [{ value: 'STAFF', label: '普通员工' }, { value: 'CLEANER', label: '保洁员' }, { value: 'STORE_ADMIN', label: '门店管理员' }, { value: 'TENANT_ADMIN', label: '租户管理员' }];
|
||||
const memberBalance = computed(() => members.items.reduce((sum, item) => sum + item.wallet.totalBalanceCents, 0));
|
||||
const memberSpend = computed(() => members.items.reduce((sum, item) => sum + item.orders.paidAmountCents, 0));
|
||||
@@ -113,6 +127,8 @@ function changeStaffPage(page: number) { staff.page = page; void loadStaff(); }
|
||||
async function openMember(memberId: string) { memberDrawer.value = true; memberDetail.value = null; try { memberDetail.value = await capture(() => getMember(props.session, memberId)); } catch { /* displayed */ } }
|
||||
function openCreate() { Object.assign(staffForm, { userId: '', nickname: '', phone: '', note: '', roles: ['STAFF'], storeIds: [] }); staffDialog.value = true; }
|
||||
function openEdit(row: ManagedUser) { Object.assign(staffForm, { userId: row.id, nickname: row.nickname, phone: '', note: row.note, roles: [...row.roles], storeIds: [...row.storeIds] }); staffDialog.value = true; }
|
||||
function openCredential(row: ManagedUser) { Object.assign(credentialForm, { userId: row.id, nickname: row.nickname || `员工 #${row.id}`, loginName: '', password: '', confirmPassword: '' }); credentialDialog.value = true; }
|
||||
async function saveCredential() { const valid = /^[\p{L}\p{N}._@+-]{3,64}$/u.test(credentialForm.loginName.trim()) && credentialForm.password.length >= 12 && /[A-Za-z]/.test(credentialForm.password) && /\d/.test(credentialForm.password) && /[^A-Za-z0-9]/.test(credentialForm.password); if (!valid) return ElMessage.warning('请填写有效登录账号和符合复杂度要求的密码'); if (credentialForm.password !== credentialForm.confirmPassword) return ElMessage.warning('两次输入的密码不一致'); saving.value = true; try { await capture(() => setAdminCredential(props.session, credentialForm.userId, { loginName: credentialForm.loginName.trim(), ['password']: credentialForm.password })); credentialDialog.value = false; ElMessage.success('后台登录凭据已更新,旧会话已撤销'); } catch { /* displayed */ } finally { saving.value = false; } }
|
||||
async function saveStaff() { if (!staffForm.nickname.trim() || (!staffForm.userId && !/^\+?[0-9]{6,20}$/.test(staffForm.phone.trim())) || staffForm.roles.length === 0) return ElMessage.warning('请填写姓名、有效手机号并至少选择一个角色'); saving.value = true; try { if (staffForm.userId) await capture(() => updateStaffUser(props.session, staffForm.userId, { nickname: staffForm.nickname.trim(), ...(staffForm.phone.trim() ? { phone: staffForm.phone.trim() } : {}), note: staffForm.note.trim(), roles: staffForm.roles, storeIds: staffForm.storeIds })); else await capture(() => createStaffUser(props.session, { nickname: staffForm.nickname.trim(), phone: staffForm.phone.trim(), note: staffForm.note.trim(), roles: staffForm.roles, storeIds: staffForm.storeIds })); staffDialog.value = false; ElMessage.success(staffForm.userId ? '员工资料已更新' : '员工账号已创建'); await loadStaff(); } catch { /* displayed */ } finally { saving.value = false; } }
|
||||
async function toggleStaff(row: ManagedUser) { const status: UserStatus = row.status === 'ACTIVE' ? 'DISABLED' : 'ACTIVE'; try { await ElMessageBox.confirm(`${status === 'DISABLED' ? '停用' : '启用'} ${row.nickname || `员工 #${row.id}`}?权限变更会撤销活动会话。`, '确认账号状态', { type: 'warning' }); await capture(() => updateStaffUser(props.session, row.id, { status })); ElMessage.success('员工状态已更新'); await loadStaff(); } catch (error) { if (error !== 'cancel' && error !== 'close') { /* displayed */ } } }
|
||||
async function resetSessions(row: ManagedUser) { try { await ElMessageBox.confirm(`重置 ${row.nickname || `员工 #${row.id}`} 的全部活动会话?`, '确认重置会话', { type: 'warning' }); const result = await capture(() => resetStaffSessions(props.session, row.id)); ElMessage.success(`已撤销 ${result.revokedSessions} 个会话`); await loadStaff(); } catch (error) { if (error !== 'cancel' && error !== 'close') { /* displayed */ } } }
|
||||
|
||||
+177
-4
@@ -6,6 +6,120 @@
|
||||
"Segoe UI", sans-serif;
|
||||
}
|
||||
|
||||
.login-shell {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(360px, 1fr) minmax(420px, .85fr);
|
||||
min-height: 100vh;
|
||||
background: #f4f7fb;
|
||||
}
|
||||
|
||||
.login-story {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
min-height: 100vh;
|
||||
padding: clamp(32px, 6vw, 84px);
|
||||
overflow: hidden;
|
||||
background:
|
||||
radial-gradient(circle at 82% 18%, rgba(73, 123, 180, .28), transparent 32%),
|
||||
linear-gradient(145deg, #101a2b, #173454 62%, #1e4a70);
|
||||
color: #f8fafc;
|
||||
}
|
||||
|
||||
.login-brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.login-brand span {
|
||||
display: grid;
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
place-items: center;
|
||||
border-radius: 11px;
|
||||
background: #e9f2ff;
|
||||
color: #152033;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.login-story h1 {
|
||||
max-width: 680px;
|
||||
margin: 14px 0 18px;
|
||||
font-size: clamp(36px, 5vw, 68px);
|
||||
line-height: 1.08;
|
||||
letter-spacing: -.04em;
|
||||
}
|
||||
|
||||
.login-story > div > p:last-child {
|
||||
max-width: 620px;
|
||||
color: #c6d5e5;
|
||||
font-size: 17px;
|
||||
line-height: 1.8;
|
||||
}
|
||||
|
||||
.login-story ul {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.login-story li {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
color: #dce8f4;
|
||||
}
|
||||
|
||||
.login-form-wrap {
|
||||
display: grid;
|
||||
min-height: 100vh;
|
||||
padding: 32px;
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
.login-card {
|
||||
width: min(100%, 440px);
|
||||
padding: clamp(28px, 5vw, 48px);
|
||||
border: 1px solid #dce4ee;
|
||||
border-radius: 22px;
|
||||
background: #fff;
|
||||
box-shadow: 0 24px 70px rgba(30, 51, 78, .12);
|
||||
}
|
||||
|
||||
.login-card header h2 {
|
||||
margin: 6px 0 8px;
|
||||
font-size: 30px;
|
||||
}
|
||||
|
||||
.login-card header > p:last-child,
|
||||
.login-card footer {
|
||||
color: #6b788c;
|
||||
}
|
||||
|
||||
.login-card .el-alert {
|
||||
margin: 20px 0 0;
|
||||
}
|
||||
|
||||
.login-card .el-form {
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
.login-card .el-button {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.login-card footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 7px;
|
||||
margin-top: 22px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.content-page {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
@@ -564,11 +678,40 @@ textarea {
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.token-box {
|
||||
.session-box,
|
||||
.session-user {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.session-user > span {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(220px, 360px) 40px;
|
||||
gap: 8px;
|
||||
width: min(100%, 420px);
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
place-items: center;
|
||||
border-radius: 50%;
|
||||
background: #e8f1ff;
|
||||
color: #1b416d;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.session-user > div {
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.session-user small {
|
||||
max-width: 260px;
|
||||
overflow: hidden;
|
||||
color: #6b788c;
|
||||
font-size: 11px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.credential-form {
|
||||
margin-top: 18px;
|
||||
}
|
||||
|
||||
.metric-grid {
|
||||
@@ -2152,6 +2295,14 @@ textarea {
|
||||
}
|
||||
|
||||
@media (max-width: 980px) {
|
||||
.login-shell {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
|
||||
.login-story {
|
||||
padding: 36px;
|
||||
}
|
||||
|
||||
.app-shell {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
@@ -2263,6 +2414,28 @@ textarea {
|
||||
}
|
||||
|
||||
@media (max-width: 560px) {
|
||||
.login-shell {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.login-story {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.login-form-wrap {
|
||||
min-height: 100vh;
|
||||
padding: 18px;
|
||||
}
|
||||
|
||||
.login-card {
|
||||
padding: 26px 22px;
|
||||
border-radius: 16px;
|
||||
}
|
||||
|
||||
.session-user > div {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
@@ -575,6 +575,30 @@ export interface SystemOverview {
|
||||
latestMigration: null | { version: string; name: string; appliedAt: string };
|
||||
}
|
||||
|
||||
export interface AdminIdentity {
|
||||
id: string;
|
||||
tenantId: string;
|
||||
userType: string;
|
||||
nickname: string;
|
||||
avatarUrl: string;
|
||||
roleVersion: number;
|
||||
}
|
||||
|
||||
export interface AdminAccess {
|
||||
roles: string[];
|
||||
capabilities: string[];
|
||||
storeIds: string[];
|
||||
menus: string[];
|
||||
}
|
||||
|
||||
export interface AdminSessionPayload {
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
expiresIn: number;
|
||||
user: AdminIdentity;
|
||||
access: AdminAccess;
|
||||
}
|
||||
|
||||
export interface CleaningTask {
|
||||
id: string;
|
||||
taskNo: string;
|
||||
|
||||
Reference in New Issue
Block a user