feat(auth): 用户登录页 + token 注入 + 授权清单驱动界面(生信/论文按授权显隐 + 登出)
This commit is contained in:
+26
-12
@@ -1,25 +1,39 @@
|
||||
<!-- App.vue —— B1 归属。挂载三栏 AppShell + 教育科研覆盖层(步骤5;v-show 切换不卸载)。 -->
|
||||
<!-- App.vue —— B1 归属。未登录显示登录页;登录后挂载 AppShell + EduWorkspace(v-show 切换不卸载)。 -->
|
||||
<script setup>
|
||||
import { onMounted, onUnmounted } from 'vue';
|
||||
import { onMounted, onUnmounted, watch, computed } from 'vue';
|
||||
import AppShell from './components/layout/AppShell.vue';
|
||||
import EduWorkspace from './components/edu/EduWorkspace.vue';
|
||||
import LoginView from './components/layout/LoginView.vue';
|
||||
import { useHealthStore } from './stores/health.js';
|
||||
import { useAppStore } from './stores/app.js';
|
||||
import { useAuthStore } from './stores/auth.js';
|
||||
|
||||
// FR-SHELL-007 首屏初始化:并行请求健康与 QA 元信息(失败不阻塞渲染)
|
||||
const health = useHealthStore();
|
||||
const appStore = useAppStore();
|
||||
onMounted(() => {
|
||||
health.start();
|
||||
health.fetchQaInfo();
|
||||
});
|
||||
onUnmounted(() => {
|
||||
health.stop();
|
||||
const auth = useAuthStore();
|
||||
auth.registerUnauthorized();
|
||||
|
||||
// 应用可见 = boot 完成且不需要登录(匿名或已登录)
|
||||
const showApp = computed(() => auth.booted && !auth.needsLogin);
|
||||
|
||||
watch(showApp, (v) => {
|
||||
if (v) {
|
||||
health.start();
|
||||
health.fetchQaInfo();
|
||||
} else {
|
||||
health.stop();
|
||||
}
|
||||
});
|
||||
|
||||
onMounted(() => auth.boot());
|
||||
onUnmounted(() => health.stop());
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- 覆盖层语义(D1):两者始终挂载,仅 display 切换——RAG 会话/上传/滚动零丢失 -->
|
||||
<AppShell v-show="appStore.isChat" />
|
||||
<EduWorkspace v-show="appStore.isEdu" />
|
||||
<LoginView v-if="auth.booted && auth.needsLogin" />
|
||||
<template v-else-if="auth.booted">
|
||||
<!-- 覆盖层语义(D1):两者始终挂载,仅 display 切换——RAG 会话/上传/滚动零丢失 -->
|
||||
<AppShell v-show="appStore.isChat" />
|
||||
<EduWorkspace v-show="appStore.isEdu" />
|
||||
</template>
|
||||
</template>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
<script setup>
|
||||
// views/Login.vue —— 用户登录页(海古纪 PlantAI 工作台)。
|
||||
import { ref } from 'vue';
|
||||
import { useAuthStore } from '../../stores/auth.js';
|
||||
|
||||
const auth = useAuthStore();
|
||||
const username = ref('');
|
||||
const password = ref('');
|
||||
const error = ref('');
|
||||
const busy = ref(false);
|
||||
|
||||
async function submit() {
|
||||
if (busy.value) return;
|
||||
if (!username.value || !password.value) { error.value = '请输入用户名和密码'; return; }
|
||||
busy.value = true;
|
||||
error.value = '';
|
||||
try {
|
||||
await auth.login(username.value, password.value);
|
||||
} catch (e) {
|
||||
error.value = e.message || '登录失败';
|
||||
} finally {
|
||||
busy.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="login-stage">
|
||||
<div class="aurora"></div>
|
||||
<div class="card">
|
||||
<div class="brand">
|
||||
<svg class="leaf" viewBox="0 0 36 36" fill="none" aria-hidden="true">
|
||||
<defs><linearGradient id="lg" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="#4d8df5"/><stop offset="1" stop-color="#37c78a"/></linearGradient></defs>
|
||||
<path d="M6 30 C6 18 14 8 28 6 C30 20 22 30 10 30 C8.4 30 7 30 6 30 Z" fill="url(#lg)"/>
|
||||
<path d="M14 22 C16 16 22 12 30 11" stroke="#0a0e18" stroke-width="2" stroke-linecap="round" fill="none"/>
|
||||
</svg>
|
||||
<div>
|
||||
<div class="brand-name">海古纪 PlantAI</div>
|
||||
<div class="brand-sub">植物科研大模型 · 知识工作台</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="u">用户名</label>
|
||||
<input id="u" v-model="username" autocomplete="username" placeholder="中枢分配的用户名" @keyup.enter="submit">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="p">密码</label>
|
||||
<input id="p" v-model="password" type="password" autocomplete="current-password" placeholder="请输入密码" @keyup.enter="submit">
|
||||
</div>
|
||||
<button class="btn" :disabled="busy" @click="submit">{{ busy ? '登录中…' : '登录' }}</button>
|
||||
<p v-if="error" class="err">{{ error }}</p>
|
||||
|
||||
<div class="hint">账号由中枢管理员开通,登录后按授权清单使用各项能力。<br>登录与所有操作均会留痕。</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.login-stage {
|
||||
position: relative;
|
||||
height: 100vh;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
background: #0a0e18; color: #e9eef7; overflow: hidden;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", "Microsoft YaHei", "Segoe UI", Roboto, sans-serif;
|
||||
}
|
||||
.aurora { position: absolute; inset: 0; pointer-events: none; }
|
||||
.aurora::before, .aurora::after { content: ""; position: absolute; border-radius: 50%; filter: blur(90px); }
|
||||
.aurora::before { width: 720px; height: 560px; left: -160px; top: -180px; background: radial-gradient(closest-side, rgba(77,141,245,0.30), transparent); }
|
||||
.aurora::after { width: 640px; height: 600px; right: -140px; bottom: -220px; background: radial-gradient(closest-side, rgba(55,199,138,0.22), transparent); }
|
||||
.card {
|
||||
position: relative; width: 380px;
|
||||
background: linear-gradient(180deg, rgba(20,26,44,0.92), rgba(14,19,34,0.92));
|
||||
border: 1px solid #1e2740; border-radius: 16px; padding: 38px 34px 32px;
|
||||
box-shadow: 0 24px 80px rgba(0,0,0,0.45);
|
||||
}
|
||||
.brand { display: flex; align-items: center; gap: 12px; margin-bottom: 26px; }
|
||||
.leaf { width: 36px; height: 36px; flex: none; }
|
||||
.brand-name { font-size: 19px; font-weight: 700; letter-spacing: 0.3px; }
|
||||
.brand-sub { color: #5d6a83; font-size: 12.5px; }
|
||||
.field { margin-bottom: 16px; }
|
||||
.field label { display: block; font-size: 12px; color: #9aa6bd; margin-bottom: 7px; }
|
||||
.field input {
|
||||
width: 100%; padding: 11px 13px; font-size: 14px; color: #e9eef7;
|
||||
background: #0c1120; border: 1px solid #1e2740; border-radius: 9px; outline: none;
|
||||
transition: border-color .15s, box-shadow .15s; box-sizing: border-box;
|
||||
}
|
||||
.field input:focus { border-color: #4d8df5; box-shadow: 0 0 0 3px rgba(77,141,245,0.16); }
|
||||
.btn {
|
||||
width: 100%; margin-top: 8px; padding: 12px 16px; font-size: 14.5px; font-weight: 600;
|
||||
border: none; border-radius: 9px; color: #fff; cursor: pointer;
|
||||
background: linear-gradient(135deg, #4d8df5, #2f6fe0); transition: filter .15s;
|
||||
}
|
||||
.btn:hover { filter: brightness(1.06); }
|
||||
.btn:disabled { opacity: 0.6; cursor: default; }
|
||||
.err { color: #ef6b6b; font-size: 12px; margin: 10px 0 0; }
|
||||
.hint { margin-top: 22px; padding-top: 16px; border-top: 1px solid #1e2740; font-size: 12px; color: #5d6a83; line-height: 1.6; }
|
||||
</style>
|
||||
@@ -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 = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||
<span v-html="iconRailSettings"></span>
|
||||
<span class="rail-label">{{ T.railSettings }}</span>
|
||||
</button>
|
||||
<button v-if="auth.authenticated" class="icon-btn top-rail-btn" type="button" title="退出登录" aria-label="退出登录"
|
||||
@click="auth.logout()">
|
||||
<span class="rail-label">{{ auth.user?.username }}</span>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<!-- 主题切换(日月 SVG + rotate 180° 400ms,FR-THEME-002) -->
|
||||
|
||||
@@ -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(); });
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user