diff --git a/web/index.html b/web/index.html index ba17d3d..562d187 100644 --- a/web/index.html +++ b/web/index.html @@ -4,18 +4,8 @@ - + + openteam · LLM API 中转站 diff --git a/web/public/theme-init.js b/web/public/theme-init.js new file mode 100644 index 0000000..c9c403d --- /dev/null +++ b/web/public/theme-init.js @@ -0,0 +1,11 @@ +// 防止主题闪烁:CSS 加载前按 localStorage/系统偏好设置 data-theme。 +// 独立外部文件而非内联脚本:CSP script-src 'self' 下内联会被拦掉。 +;(function () { + try { + var m = localStorage.getItem('ot_theme') || 'system' + var dark = m === 'dark' || (m === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches) + document.documentElement.setAttribute('data-theme', dark ? 'dark' : 'light') + } catch (e) { + document.documentElement.setAttribute('data-theme', 'dark') + } +})() diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 82ed4a0..b9bfeb1 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -15,22 +15,37 @@ http.interceptors.request.use((config) => { return config }) +// single-flight:并发 401 共享同一次刷新。refresh 已是轮换式(每次换发新 cookie), +// 两个请求各自刷新会让第二个带着已被轮换的会话状态,容易随机登出。 +let refreshInflight: Promise | null = null + +async function redirectToLogin() { + if (window.location.pathname.startsWith('/login')) return + const { router } = await import('@/router') + await router.push({ name: 'login', query: { redirect: window.location.pathname + window.location.search } }) +} + http.interceptors.response.use( (resp) => resp, async (error) => { - if (error.response?.status === 401) { + const status = error.response?.status + const cfg = error.config ?? {} + const isRefreshCall = String(cfg.url ?? '').includes('/auth/refresh') + if (status === 401 && !cfg._retried && !isRefreshCall) { const auth = useAuthStore() - // 尝试用 refresh cookie 换新 token 后再试一次 - if (auth.accessToken && !error.config?._retried) { - error.config._retried = true - try { - await auth.refresh() - return http.request(error.config) - } catch { - auth.clear() + cfg._retried = true + try { + if (!refreshInflight) { + refreshInflight = auth.refresh().finally(() => { + refreshInflight = null + }) } - } else { + await refreshInflight + return http.request(cfg) + } catch { + // 会话已失效:清状态并回登录页,否则用户停在受保护页面每个操作都报错 auth.clear() + await redirectToLogin() } } return Promise.reject(error) diff --git a/web/src/lib/format.ts b/web/src/lib/format.ts index 31a43ce..6138bb6 100644 --- a/web/src/lib/format.ts +++ b/web/src/lib/format.ts @@ -15,6 +15,8 @@ export function fmtTime(s?: string | null): string { export function fmtCost(v: number): string { if (v === 0) return '$0' - if (v < 0.01) return '$' + v.toExponential(2) + // toLocaleString 对 <0.01 会出科学计数法($5e-3):定点 6 位并去尾零 + if (v < 1e-6) return '<$0.000001' + if (v < 0.01) return '$' + v.toFixed(6).replace(/0+$/, '').replace(/\.$/, '') return fmtMoney(v) } diff --git a/web/src/stores/auth.ts b/web/src/stores/auth.ts index 4ef5428..ee85510 100644 --- a/web/src/stores/auth.ts +++ b/web/src/stores/auth.ts @@ -2,10 +2,16 @@ import { defineStore } from 'pinia' import { http } from '@/api/client' import type { User } from '@/types' +// App.vue 的 onMounted 与路由守卫都可能触发 bootstrap:并发时共享同一次刷新, +// 避免重复打 /auth/refresh +let bootstrapInflight: Promise | null = null + export const useAuthStore = defineStore('auth', { state: () => ({ user: null as User | null, - accessToken: localStorage.getItem('ot_access') ?? '', + // access token 只存内存:XSS 即使发生也拿不到持久凭据。 + // 刷新页面后由 bootstrap 用 HttpOnly refresh cookie 静默换新 token。 + accessToken: '', ready: false, }), getters: { @@ -14,15 +20,22 @@ export const useAuthStore = defineStore('auth', { }, actions: { async bootstrap() { - try { - if (!this.accessToken) { - await this.refresh() - } - await this.fetchMe() - } catch { - this.clear() + if (this.ready) return + if (!bootstrapInflight) { + bootstrapInflight = (async () => { + try { + await this.refresh() + await this.fetchMe() + } catch { + this.clear() + } finally { + this.ready = true + } + })().finally(() => { + bootstrapInflight = null + }) } - this.ready = true + await bootstrapInflight }, async register(username: string, email: string, password: string) { await http.post('/auth/register', { username, email, password }) @@ -34,12 +47,10 @@ export const useAuthStore = defineStore('auth', { setSession(d: { access_token: string; user: User }) { this.accessToken = d.access_token this.user = d.user - localStorage.setItem('ot_access', d.access_token) }, async refresh() { const { data } = await http.post('/auth/refresh') this.accessToken = data.data.access_token as string - localStorage.setItem('ot_access', this.accessToken) }, async fetchMe() { const { data } = await http.get('/auth/me') @@ -56,7 +67,6 @@ export const useAuthStore = defineStore('auth', { clear() { this.accessToken = '' this.user = null - localStorage.removeItem('ot_access') }, }, }) diff --git a/web/src/views/admin/ChannelsView.vue b/web/src/views/admin/ChannelsView.vue index b2a8cf5..14e00ad 100644 --- a/web/src/views/admin/ChannelsView.vue +++ b/web/src/views/admin/ChannelsView.vue @@ -122,12 +122,19 @@ async function save() { return } saving.value = true + // 数字输入被清空时 v-model 是 ''(Number('')==0):直接提交会把权重写成 0(永远选不中)。 + // 省略该字段:创建走后端默认值,更新端点是指针字段不动原值。 + const numOrOmit = (v: unknown) => { + if (v === '' || v === null || v === undefined) return undefined + const n = Number(v) + return Number.isNaN(n) ? undefined : n + } const payload = { ...form, - weight: Number(form.weight), - priority: Number(form.priority), - timeout_ms: Number(form.timeout_ms), - max_concurrency: Number(form.max_concurrency), + weight: numOrOmit(form.weight), + priority: numOrOmit(form.priority), + timeout_ms: numOrOmit(form.timeout_ms), + max_concurrency: numOrOmit(form.max_concurrency), } try { if (editing.value) { diff --git a/web/src/views/admin/UsersView.vue b/web/src/views/admin/UsersView.vue index 2ca73fb..4f59893 100644 --- a/web/src/views/admin/UsersView.vue +++ b/web/src/views/admin/UsersView.vue @@ -42,7 +42,7 @@ const balanceForm = reactive({ amount: 0, remark: '' }) async function load() { try { - const { data } = await http.get(`/admin/users?page=${page.value}&page_size=${pageSize}${q.value ? '&q=' + q.value : ''}`) + const { data } = await http.get(`/admin/users?page=${page.value}&page_size=${pageSize}${q.value ? '&q=' + encodeURIComponent(q.value) : ''}`) users.value = data.data.items total.value = data.data.total } catch (e) { diff --git a/web/src/views/console/SettingsView.vue b/web/src/views/console/SettingsView.vue index e0213bd..0b595ab 100644 --- a/web/src/views/console/SettingsView.vue +++ b/web/src/views/console/SettingsView.vue @@ -95,8 +95,14 @@ async function changePassword() { saving.value = true try { await http.post('/auth/password', { old_password: oldPwd.value, new_password: newPwd.value }) - toast.ok('密码已更新') + toast.ok('密码已更新,请重新登录') oldPwd.value = newPwd.value = confirmPwd.value = '' + // 后端改密即递增会话版本:当前 access/refresh 已全部作废,主动回登录页 + const { useAuthStore } = await import('@/stores/auth') + const auth = useAuthStore() + auth.clear() + const { router } = await import('@/router') + await router.push({ name: 'login' }) } catch (e) { toast.err(errMsg(e)) } finally { diff --git a/web/src/views/console/UsageView.vue b/web/src/views/console/UsageView.vue index 645d028..0951836 100644 --- a/web/src/views/console/UsageView.vue +++ b/web/src/views/console/UsageView.vue @@ -23,7 +23,7 @@ async function load() { const [s, st, l] = await Promise.all([ http.get('/usage/summary'), http.get(`/usage/stats?group=${group.value}`), - http.get(`/usage/logs?page=${page.value}&page_size=${pageSize}${modelFilter.value ? '&model=' + modelFilter.value : ''}`), + http.get(`/usage/logs?page=${page.value}&page_size=${pageSize}${modelFilter.value ? '&model=' + encodeURIComponent(modelFilter.value) : ''}`), ]) summary.value = s.data.data const items = st.data.data.items as { date?: string; model?: string; cost: number; requests: number }[]