feat: 迁入 MiGu.Server、平台前端与车辆列表 reflection 回退
从 Simple-FR 拆出 Platform.Server 并重命名为 MiGu.Server;frontends 源码与构建脚本迁入本仓库。地图监控在 projection/cars 失败或为空时回退 reflection 车辆列表;Simple 仓库已移除旧 Platform.Server。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,164 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import type { AuthUser, EffectivePermissions, LoginRequest, RunMode, Scope } from '@/types/auth'
|
||||
import {
|
||||
login as apiLogin,
|
||||
logout as apiLogout,
|
||||
switchScope as apiSwitchScope,
|
||||
getMe as apiGetMe
|
||||
} from '@/api/auth'
|
||||
|
||||
interface AuthState {
|
||||
token: string | null
|
||||
user: AuthUser | null
|
||||
scope: Scope | null
|
||||
runMode: RunMode | null
|
||||
effectivePermissions: EffectivePermissions | null
|
||||
/**
|
||||
* 本次浏览器会话内是否向后端实校过 token。
|
||||
* 仅内存态,不写 localStorage —— 页面刷新后必须重新 validate,确保 Platform.Server
|
||||
* 在用户离开期间重启(JWT secret 重生)的场景下能立刻被发现并跳登录。
|
||||
*/
|
||||
validated: boolean
|
||||
}
|
||||
|
||||
const TOKEN_KEY = 'simple.auth.token'
|
||||
const USER_KEY = 'simple.auth.user'
|
||||
const SCOPE_KEY = 'simple.auth.scope'
|
||||
const RUN_MODE_KEY = 'simple.auth.runMode'
|
||||
const PERM_KEY = 'simple.auth.perm'
|
||||
|
||||
// 会话 45 HC-2:每个字段独立 try/catch,避免一个 corrupt 字段把整个 state 干净化。
|
||||
function safeJsonParse<T>(key: string): T | null {
|
||||
try {
|
||||
const raw = localStorage.getItem(key)
|
||||
return raw == null ? null : (JSON.parse(raw) as T)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function safeReadString(key: string): string | null {
|
||||
try { return localStorage.getItem(key) } catch { return null }
|
||||
}
|
||||
|
||||
function loadState(): AuthState {
|
||||
return {
|
||||
token: safeReadString(TOKEN_KEY),
|
||||
user: safeJsonParse<AuthUser>(USER_KEY),
|
||||
scope: safeReadString(SCOPE_KEY) as Scope | null,
|
||||
runMode: safeReadString(RUN_MODE_KEY) as RunMode | null,
|
||||
effectivePermissions: safeJsonParse<EffectivePermissions>(PERM_KEY),
|
||||
// 刷新页面后默认未校验:路由守卫会在受保护路由首次进入前 await validate()。
|
||||
validated: false
|
||||
}
|
||||
}
|
||||
|
||||
/** 清空登录态(内存 + localStorage)。validate 失败 / logout / 401 拦截共用,避免 5 行重复。 */
|
||||
function clearLocalAuth(target: AuthState) {
|
||||
target.token = null
|
||||
target.user = null
|
||||
target.scope = null
|
||||
target.runMode = null
|
||||
target.effectivePermissions = null
|
||||
target.validated = false
|
||||
try {
|
||||
localStorage.removeItem(TOKEN_KEY)
|
||||
localStorage.removeItem(USER_KEY)
|
||||
localStorage.removeItem(SCOPE_KEY)
|
||||
localStorage.removeItem(RUN_MODE_KEY)
|
||||
localStorage.removeItem(PERM_KEY)
|
||||
} catch { /* 隐私模式 / iframe 沙箱可能禁用 localStorage */ }
|
||||
}
|
||||
|
||||
export const useAuthStore = defineStore('auth', {
|
||||
state: (): AuthState => loadState(),
|
||||
getters: {
|
||||
isAuthed: (s) => !!s.token,
|
||||
hasOp:
|
||||
(s) =>
|
||||
(code: string): boolean => {
|
||||
const ops = s.effectivePermissions?.allowedOps ?? []
|
||||
return ops.includes('*') || ops.includes(code)
|
||||
},
|
||||
widgetOf:
|
||||
(s) =>
|
||||
(widgetId: string): 'hidden' | 'readonly' | 'interactive' => {
|
||||
const grant = s.effectivePermissions?.visibleWidgets.find((w) => w.widgetId === widgetId)
|
||||
return grant?.visibility ?? 'interactive'
|
||||
}
|
||||
},
|
||||
actions: {
|
||||
async login(req: LoginRequest) {
|
||||
const resp = await apiLogin(req)
|
||||
this.token = resp.token
|
||||
this.user = resp.user
|
||||
this.scope = resp.scope
|
||||
this.runMode = resp.runMode
|
||||
this.effectivePermissions = resp.effectivePermissions
|
||||
// 登录响应本身就是后端的身份背书,等同于一次成功的 /me;省一次往返。
|
||||
this.validated = true
|
||||
localStorage.setItem(TOKEN_KEY, resp.token)
|
||||
localStorage.setItem(USER_KEY, JSON.stringify(resp.user))
|
||||
localStorage.setItem(SCOPE_KEY, resp.scope)
|
||||
localStorage.setItem(RUN_MODE_KEY, resp.runMode)
|
||||
localStorage.setItem(PERM_KEY, JSON.stringify(resp.effectivePermissions))
|
||||
return resp
|
||||
},
|
||||
async logout() {
|
||||
try { await apiLogout() } catch { /* ignore */ }
|
||||
clearLocalAuth(this)
|
||||
},
|
||||
/**
|
||||
* 用本地 token 让后端实校一次身份。
|
||||
* - 成功:刷新 user/scope/runMode/perm 并标记 validated=true。
|
||||
* - 失败(401/403/网络/etc):清空登录态并抛出错误,由调用方(路由守卫)跳 /login。
|
||||
* - mock 模式下 apiGetMe 主动抛 'mock-mode-skip-me-validation',视为「无须实校」直接放行。
|
||||
*/
|
||||
async validate() {
|
||||
if (!this.token) {
|
||||
// 没 token 直接清干净,避免有半残数据导致 isAuthed 抖动。
|
||||
clearLocalAuth(this)
|
||||
throw new Error('no-token')
|
||||
}
|
||||
try {
|
||||
const me = await apiGetMe()
|
||||
this.user = me.user
|
||||
this.scope = me.scope
|
||||
this.runMode = me.runMode
|
||||
this.effectivePermissions = me.effectivePermissions
|
||||
this.validated = true
|
||||
localStorage.setItem(USER_KEY, JSON.stringify(me.user))
|
||||
localStorage.setItem(SCOPE_KEY, me.scope)
|
||||
localStorage.setItem(RUN_MODE_KEY, me.runMode)
|
||||
localStorage.setItem(PERM_KEY, JSON.stringify(me.effectivePermissions))
|
||||
} catch (err) {
|
||||
if (err instanceof Error && err.message === 'mock-mode-skip-me-validation') {
|
||||
this.validated = true
|
||||
return
|
||||
}
|
||||
clearLocalAuth(this)
|
||||
throw err
|
||||
}
|
||||
},
|
||||
// AR-6 (会话 45):原实现客户端直接改 `allowedOps = ['*']` —— 是伪权限,
|
||||
// 既不安全(后端不再校验时立刻翻车)也容易和后端 ops list 漂移。
|
||||
// 改为请求 /api/auth/switch-scope 让服务端按账号实际允许的 scope 重发 token + perms。
|
||||
// 后端若不允许该 scope(如 ops 账号尝试切到 Platform),会返回 403 → 由调用方 toast 提示。
|
||||
async switchScope(target: Scope) {
|
||||
const resp = await apiSwitchScope(target)
|
||||
this.token = resp.token
|
||||
this.user = resp.user
|
||||
this.scope = resp.scope
|
||||
this.runMode = resp.runMode
|
||||
this.effectivePermissions = resp.effectivePermissions
|
||||
// SwitchScope 后端重发了 token + perm,等同于一次成功的 /me,保持 validated 为 true。
|
||||
this.validated = true
|
||||
localStorage.setItem(TOKEN_KEY, resp.token)
|
||||
localStorage.setItem(USER_KEY, JSON.stringify(resp.user))
|
||||
localStorage.setItem(SCOPE_KEY, resp.scope)
|
||||
localStorage.setItem(RUN_MODE_KEY, resp.runMode)
|
||||
localStorage.setItem(PERM_KEY, JSON.stringify(resp.effectivePermissions))
|
||||
return resp
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,42 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import type { ConfigEnvelope, ConfigSection, OpsConfig } from '@/types/config'
|
||||
import { getConfig, putConfig } from '@/api/config'
|
||||
import { normalizeOpsConfig } from '@/utils/opsMonitorConfig'
|
||||
|
||||
interface ConfigState {
|
||||
cache: Partial<Record<ConfigSection, ConfigEnvelope>>
|
||||
loading: Partial<Record<ConfigSection, boolean>>
|
||||
}
|
||||
|
||||
export const useConfigStore = defineStore('config', {
|
||||
state: (): ConfigState => ({ cache: {}, loading: {} }),
|
||||
actions: {
|
||||
async load<T>(section: ConfigSection, force = false): Promise<ConfigEnvelope<T>> {
|
||||
if (!force && this.cache[section]) return this.cache[section] as ConfigEnvelope<T>
|
||||
this.loading[section] = true
|
||||
try {
|
||||
const env = await getConfig<T>(section)
|
||||
if (section === 'ops' && env.payload) {
|
||||
env.payload = normalizeOpsConfig(env.payload as unknown as OpsConfig) as T
|
||||
}
|
||||
this.cache[section] = env as ConfigEnvelope
|
||||
return env
|
||||
} finally {
|
||||
this.loading[section] = false
|
||||
}
|
||||
},
|
||||
async save<T>(section: ConfigSection, payload: T): Promise<ConfigEnvelope<T>> {
|
||||
this.loading[section] = true
|
||||
try {
|
||||
const body = section === 'ops'
|
||||
? (normalizeOpsConfig(payload as unknown as OpsConfig) as T)
|
||||
: payload
|
||||
const env = await putConfig<T>(section, body)
|
||||
this.cache[section] = env as ConfigEnvelope
|
||||
return env
|
||||
} finally {
|
||||
this.loading[section] = false
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,138 @@
|
||||
import { defineStore } from 'pinia'
|
||||
|
||||
import {
|
||||
DEFAULT_THEME_ID,
|
||||
applyThemeVars,
|
||||
findTheme,
|
||||
THEMES,
|
||||
type ThemePreset
|
||||
} from '@/styles/themes'
|
||||
import {
|
||||
mergeThemePreset,
|
||||
type ThemeColorKey,
|
||||
type ThemeColorOverrides,
|
||||
type ThemeOverridesMap
|
||||
} from '@/styles/themeCustomize'
|
||||
|
||||
interface UiState {
|
||||
sidebarCollapsed: boolean
|
||||
/**
|
||||
* @deprecated 仅保留兼容字段;实际外观由 themeId 决定,可于下一个迁移窗口删除。
|
||||
* 旧 localStorage 里可能仍残留 'light' / 'dark',反序列化时容错读取。
|
||||
*/
|
||||
theme: 'light' | 'dark'
|
||||
/** 主题色板 ID(对应 themes.ts 的 ThemePreset.id) */
|
||||
themeId: string
|
||||
/** 各主题取色盘自定义(仅存三项品牌色,其余由 derive 推导) */
|
||||
themeOverrides: ThemeOverridesMap
|
||||
}
|
||||
|
||||
const STORAGE_KEY = 'simple.ui.state'
|
||||
|
||||
const DEFAULT_STATE: UiState = {
|
||||
sidebarCollapsed: false,
|
||||
theme: 'light',
|
||||
themeId: DEFAULT_THEME_ID,
|
||||
themeOverrides: {}
|
||||
}
|
||||
|
||||
function loadInitial(): UiState {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY)
|
||||
if (!raw) return { ...DEFAULT_STATE }
|
||||
const parsed = JSON.parse(raw) as Partial<UiState>
|
||||
return {
|
||||
sidebarCollapsed: parsed.sidebarCollapsed ?? DEFAULT_STATE.sidebarCollapsed,
|
||||
theme: parsed.theme === 'dark' ? 'dark' : 'light',
|
||||
themeId: parsed.themeId ?? DEFAULT_STATE.themeId,
|
||||
themeOverrides: parsed.themeOverrides ?? {}
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('[ui.store] localStorage 中的 UI 状态解析失败,已回退默认:', err)
|
||||
return { ...DEFAULT_STATE }
|
||||
}
|
||||
}
|
||||
|
||||
export const useUiStore = defineStore('ui', {
|
||||
state: (): UiState => loadInitial(),
|
||||
getters: {
|
||||
/** 当前生效的主题预设(含自定义配色) */
|
||||
activeTheme(state): ThemePreset {
|
||||
return mergeThemePreset(findTheme(state.themeId), state.themeOverrides[state.themeId])
|
||||
},
|
||||
/** 所有可选主题列表 */
|
||||
availableThemes(): ThemePreset[] {
|
||||
return THEMES
|
||||
}
|
||||
},
|
||||
actions: {
|
||||
toggleSidebar() {
|
||||
this.sidebarCollapsed = !this.sidebarCollapsed
|
||||
this.persist()
|
||||
},
|
||||
|
||||
setTheme(t: 'light' | 'dark') {
|
||||
this.theme = t
|
||||
document.documentElement.classList.toggle('dark', t === 'dark')
|
||||
this.persist()
|
||||
},
|
||||
|
||||
getMergedTheme(preset: ThemePreset): ThemePreset {
|
||||
return mergeThemePreset(preset, this.themeOverrides[preset.id])
|
||||
},
|
||||
|
||||
getThemeColor(themeId: string, key: ThemeColorKey): string {
|
||||
const preset = findTheme(themeId)
|
||||
return this.themeOverrides[themeId]?.[key] ?? preset.vars[key]
|
||||
},
|
||||
|
||||
setThemeColor(themeId: string, key: ThemeColorKey, hex: string) {
|
||||
if (!this.themeOverrides[themeId]) this.themeOverrides[themeId] = {}
|
||||
this.themeOverrides[themeId][key] = hex
|
||||
if (this.themeId === themeId) {
|
||||
applyThemeVars(this.getMergedTheme(findTheme(themeId)))
|
||||
}
|
||||
this.persist()
|
||||
},
|
||||
|
||||
clearThemeOverrides(themeId: string) {
|
||||
delete this.themeOverrides[themeId]
|
||||
if (this.themeId === themeId) applyThemeVars(findTheme(themeId))
|
||||
this.persist()
|
||||
},
|
||||
|
||||
clearAllThemeOverrides() {
|
||||
this.themeOverrides = {}
|
||||
applyThemeVars(this.activeTheme)
|
||||
this.persist()
|
||||
},
|
||||
|
||||
/** 切换品牌主题色板 */
|
||||
setThemeId(id: string) {
|
||||
const preset = findTheme(id)
|
||||
this.themeId = preset.id
|
||||
applyThemeVars(this.getMergedTheme(preset))
|
||||
this.persist()
|
||||
},
|
||||
|
||||
/** 应用当前已保存的主题(启动时调用) */
|
||||
applyCurrentTheme() {
|
||||
applyThemeVars(this.activeTheme)
|
||||
},
|
||||
|
||||
/** 仅持久化稳定字段,避免把瞬态/敏感字段误写入 localStorage。 */
|
||||
persist() {
|
||||
const snapshot: UiState = {
|
||||
sidebarCollapsed: this.sidebarCollapsed,
|
||||
theme: this.theme,
|
||||
themeId: this.themeId,
|
||||
themeOverrides: this.themeOverrides
|
||||
}
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(snapshot))
|
||||
} catch (err) {
|
||||
console.warn('[ui.store] 持久化 UI 状态失败:', err)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user