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 }[]