From 9d6a7d3f73231a56455d25bde130b29ca91c2434 Mon Sep 17 00:00:00 2001 From: potaser Date: Fri, 16 Oct 2026 14:48:04 +0800 Subject: [PATCH] =?UTF-8?q?feat(auth):=20=E7=94=A8=E6=88=B7=E7=99=BB?= =?UTF-8?q?=E5=BD=95=E9=A1=B5=20+=20token=20=E6=B3=A8=E5=85=A5=20+=20?= =?UTF-8?q?=E6=8E=88=E6=9D=83=E6=B8=85=E5=8D=95=E9=A9=B1=E5=8A=A8=E7=95=8C?= =?UTF-8?q?=E9=9D=A2(=E7=94=9F=E4=BF=A1/=E8=AE=BA=E6=96=87=E6=8C=89?= =?UTF-8?q?=E6=8E=88=E6=9D=83=E6=98=BE=E9=9A=90=20+=20=E7=99=BB=E5=87=BA)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ui/src/App.vue | 38 ++++++---- ui/src/api/http.js | 15 ++++ ui/src/components/bench/BenchPanel.vue | 4 +- ui/src/components/chat/Composer.vue | 5 +- ui/src/components/layout/AppShell.vue | 4 +- ui/src/components/layout/LoginView.vue | 98 ++++++++++++++++++++++++++ ui/src/components/layout/TopBar.vue | 8 ++- ui/src/stores/auth.js | 78 ++++++++++++++++++++ 8 files changed, 233 insertions(+), 17 deletions(-) create mode 100644 ui/src/components/layout/LoginView.vue create mode 100644 ui/src/stores/auth.js diff --git a/ui/src/App.vue b/ui/src/App.vue index e475c09..a33c379 100644 --- a/ui/src/App.vue +++ b/ui/src/App.vue @@ -1,25 +1,39 @@ - + diff --git a/ui/src/api/http.js b/ui/src/api/http.js index af03ca4..11fdd6e 100644 --- a/ui/src/api/http.js +++ b/ui/src/api/http.js @@ -30,6 +30,15 @@ export class ApiError extends Error { /** 默认读超时:API.md 全表最大值(extraction upload 600s)——前端兜底不得先于 BFF 超时 */ const DEFAULT_TIMEOUT_MS = 600000; +/** 鉴权 token + 401 处理(惰性读 localStorage,避免与 auth store 循环引用) */ +const AUTH_TOKEN_KEY = 'pa-auth-token'; +let _onUnauthorized = null; +export function setUnauthorizedHandler(fn) { _onUnauthorized = fn; } +function authToken() { + try { return localStorage.getItem(AUTH_TOKEN_KEY) || ''; } catch { return ''; } +} +function notifyUnauthorized() { if (_onUnauthorized) _onUnauthorized(); } + function buildQuery(params) { if (!params) return ''; const sp = new URLSearchParams(); @@ -57,6 +66,8 @@ function xhrUpload(url, method, body, { timeout, signal, onUploadProgress }) { return new Promise((resolve, reject) => { const xhr = new XMLHttpRequest(); xhr.open(method, url); + const _token = authToken(); + if (_token) xhr.setRequestHeader('Authorization', 'Bearer ' + _token); // 不设 Content-Type:浏览器自动带 multipart boundary(与 fetch 分支一致) if (body instanceof FormData) { if (body.has('code_zip')) { @@ -102,6 +113,7 @@ function xhrUpload(url, method, body, { timeout, signal, onUploadProgress }) { if (t) clearTimeout(t); if (signal) signal.removeEventListener('abort', onAbort); const payload = _parseXhrPayload(xhr); + if (xhr.status === 401) notifyUnauthorized(); if (xhr.status >= 200 && xhr.status < 300) { resolve(payload); return; @@ -145,6 +157,8 @@ async function request(path, { method = 'GET', body, headers = {}, timeout = DEF } const opts = { method, headers: { ...headers }, signal }; + const token = authToken(); + if (token) opts.headers['Authorization'] = 'Bearer ' + token; if (body !== undefined && body !== null) { if (typeof FormData !== 'undefined' && body instanceof FormData) { // multipart(FR-EXTRACT-004 上传):不设 Content-Type(浏览器自动带 boundary) @@ -165,6 +179,7 @@ async function request(path, { method = 'GET', body, headers = {}, timeout = DEF signal: signal || ctrl.signal, }); if (t) clearTimeout(t); + if (res.status === 401) notifyUnauthorized(); } catch (e) { // AbortError 也可能是调用方主动中断流式请求——继续抛原始错误,由调用方处理 if (e.name === 'AbortError' && signal && signal.aborted) throw e; diff --git a/ui/src/components/bench/BenchPanel.vue b/ui/src/components/bench/BenchPanel.vue index e35e86f..d47c39c 100644 --- a/ui/src/components/bench/BenchPanel.vue +++ b/ui/src/components/bench/BenchPanel.vue @@ -11,6 +11,7 @@ import { BENCH_LABELS } from './benchLabels.js'; // P5 生信面板:探活显隐(FR-BIO-025:健康 200 → 显示 TAB;不可达 → 隐藏) import { BIOFLOW_TABS } from '../../i18n/bioflow.js'; import { useBioflowStore } from '../../stores/bioflow.js'; +import { useAuthStore } from '../../stores/auth.js'; /** P3:tabs 数组 = labels.BENCH_TABS + 文档 TAB(docgen);渲染与选择共用同一数组 */ const baseTabs = [...BENCH_TABS, DOCGEN_TAB]; @@ -31,9 +32,10 @@ const emit = defineEmits(['collapse']); const bench = useBenchStore(); const collapsedRef = ref(props.collapsed); const bioflow = useBioflowStore(); +const auth = useAuthStore(); /** 生信 TAB 显隐(FR-BIO-025 探活);启动即探测一次 */ -const bioflowTabs = computed(() => (bioflow.bioflowAvailable ? BIOFLOW_TABS : [])); +const bioflowTabs = computed(() => (bioflow.bioflowAvailable && auth.has('bioflow') ? BIOFLOW_TABS : [])); const benchTabs = computed(() => [...baseTabs, ...bioflowTabs.value]); onMounted(() => { diff --git a/ui/src/components/chat/Composer.vue b/ui/src/components/chat/Composer.vue index 3cb2c02..41c316a 100644 --- a/ui/src/components/chat/Composer.vue +++ b/ui/src/components/chat/Composer.vue @@ -25,6 +25,7 @@ import { useChatStore } from '../../stores/chat.js'; import { useBioflowStore } from '../../stores/bioflow.js'; import { usePaperStore } from '../../stores/paper.js'; import { useSettingsStore } from '../../stores/settings.js'; +import { useAuthStore } from '../../stores/auth.js'; const props = defineProps({ mode: { type: String, default: 'ask' }, @@ -162,7 +163,7 @@ const activePaperId = computed(() => { const bioflowEnabled = computed(() => { try { const st = useBioflowStore(); - return st.availability !== 'offline'; + return st.availability !== 'offline' && useAuthStore().has('bioflow'); } catch { return true; } @@ -172,7 +173,7 @@ const bioflowEnabled = computed(() => { const paperEnabled = computed(() => { try { const st = usePaperStore(); - return st.availability !== 'offline'; + return st.availability !== 'offline' && useAuthStore().has('paper'); } catch { return true; } diff --git a/ui/src/components/layout/AppShell.vue b/ui/src/components/layout/AppShell.vue index d1e55fc..1385f3d 100644 --- a/ui/src/components/layout/AppShell.vue +++ b/ui/src/components/layout/AppShell.vue @@ -25,6 +25,7 @@ import { useBenchStore } from '../../stores/bench.js'; // P5 生信面板:移动端 tab 条接入(探活显隐 FR-BIO-025) import { BIOFLOW_TABS } from '../../i18n/bioflow.js'; import { useBioflowStore } from '../../stores/bioflow.js'; +import { useAuthStore } from '../../stores/auth.js'; /* ---------- 折叠状态(FR-SHELL-004/005:localStorage 持久化) ---------- */ const SIDE_KEY = 'pa-side-collapsed'; @@ -205,11 +206,12 @@ const shellClass = computed(() => ({ })); /* ---------- 覆盖层 tab 条(tab 状态由 bench store 接管,FR-RESP-004 全屏工作台) ---------- */ const bioflow = useBioflowStore(); +const auth = useAuthStore(); onMounted(() => { bioflow.probe({ force: true }).catch(() => { /* offline 已落状态 */ }); }); const overlayActiveTab = computed(() => bench.activeTab); -const overlayTabs = computed(() => [...BENCH_TABS, ...(bioflow.bioflowAvailable ? BIOFLOW_TABS : [])]); +const overlayTabs = computed(() => [...BENCH_TABS, ...(bioflow.bioflowAvailable && auth.has('bioflow') ? BIOFLOW_TABS : [])]); function overlaySelectTab(tab) { bench.setActiveTab(tab); } diff --git a/ui/src/components/layout/LoginView.vue b/ui/src/components/layout/LoginView.vue new file mode 100644 index 0000000..105e599 --- /dev/null +++ b/ui/src/components/layout/LoginView.vue @@ -0,0 +1,98 @@ + + + + + diff --git a/ui/src/components/layout/TopBar.vue b/ui/src/components/layout/TopBar.vue index 399b3a2..4903636 100644 --- a/ui/src/components/layout/TopBar.vue +++ b/ui/src/components/layout/TopBar.vue @@ -15,6 +15,7 @@ import { BF } from '../../i18n/bioflow.js'; import { useThemeStore } from '../../stores/theme.js'; import { useBioflowStore } from '../../stores/bioflow.js'; import { useAppStore } from '../../stores/app.js'; +import { useAuthStore } from '../../stores/auth.js'; const props = defineProps({ /** 当前会话标题(无会话为 null → 显示「就绪」徽章) */ @@ -38,11 +39,12 @@ const themeStore = useThemeStore(); /* ---------- 生信可用性(右上角生信跳转按钮显隐;=== 'online' 避免 unknown 闪现) ---------- */ const bioflowStore = useBioflowStore(); -const bioflowOnline = computed(() => bioflowStore.availability === 'online'); +const bioflowOnline = computed(() => bioflowStore.availability === 'online' && auth.has('bioflow')); const bioflowStatus = computed(() => bioflowStore.availability); /* ---------- 教育科研入口(步骤5;覆盖层视图切换 D1,同 themeStore 静态导入) ---------- */ const appStore = useAppStore(); +const auth = useAuthStore(); /* ---------- B2/B3 store 数据(动态导入 + 空值守卫) ---------- */ const chatStore = ref(null); @@ -240,6 +242,10 @@ const iconRailEdu = ' {{ T.railSettings }} + diff --git a/ui/src/stores/auth.js b/ui/src/stores/auth.js new file mode 100644 index 0000000..1600e3c --- /dev/null +++ b/ui/src/stores/auth.js @@ -0,0 +1,78 @@ +// stores/auth.js —— 用户登录态 + 授权清单(entitlements)。 +// token 持久化到 localStorage('pa-auth-token'),与 api/http.js 的 authToken() 同源。 +// boot():先查 /api/auth/status —— 后端 auth_enabled=false 则匿名直用(兼容旧形态); +// 为 true 则校验 token → 拉 /api/auth/me。 +import { defineStore } from 'pinia'; +import { get, post, setUnauthorizedHandler } from '../api/http.js'; + +const TOKEN_KEY = 'pa-auth-token'; + +export const useAuthStore = defineStore('auth', { + state: () => ({ + token: typeof localStorage !== 'undefined' ? (localStorage.getItem(TOKEN_KEY) || '') : '', + user: null, // {id, username, role, authorizations, created_at} + status: 'unknown', // unknown | anonymous | needs_login | authenticated + booted: false, + }), + getters: { + authenticated: (s) => !!s.token && !!s.user, + needsLogin: (s) => s.status === 'needs_login', + entitlements: (s) => (s.user && s.user.authorizations) || [], + isAdmin: (s) => !!(s.user && s.user.role === 'admin'), + has: (s) => (unit) => !!(s.user && (s.user.role === 'admin' || (s.user.authorizations || []).includes(unit))), + }, + actions: { + _setToken(t) { + this.token = t || ''; + if (t) localStorage.setItem(TOKEN_KEY, t); + else localStorage.removeItem(TOKEN_KEY); + }, + async boot() { + this.booted = false; + try { + const s = await get('/api/auth/status'); + if (!s.auth_enabled) { + this.status = 'anonymous'; + this.booted = true; + return; + } + } catch (e) { + // 旧后端无 /api/auth/status → 匿名(兼容) + this.status = 'anonymous'; + this.booted = true; + return; + } + await this._loadMe(); + }, + async _loadMe() { + if (!this.token) { this.status = 'needs_login'; this.booted = true; return; } + try { + const r = await get('/api/auth/me'); + this.user = r.user; + this.status = 'authenticated'; + } catch (e) { + this._setToken(''); + this.user = null; + this.status = 'needs_login'; + } finally { + this.booted = true; + } + }, + async login(username, password) { + const r = await post('/api/auth/login', { username, password }); + this._setToken(r.token); + this.user = r.user; + this.status = 'authenticated'; + return r.user; + }, + logout() { + this._setToken(''); + this.user = null; + this.status = 'needs_login'; + }, + /** 已登录态突然 401(token 过期/吊销)→ 登出;登录失败/未登录的 401 忽略 */ + registerUnauthorized() { + setUnauthorizedHandler(() => { if (this.authenticated) this.logout(); }); + }, + }, +});