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:
zhaowei.huang
2026-05-29 18:16:34 +08:00
co-authored by Cursor
parent 804aa68ade
commit 42978930ca
280 changed files with 30046 additions and 8 deletions
@@ -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)
}
}
}
})