feat: align with upstream 1.5.9.9

This commit is contained in:
2026-09-12 18:21:11 +08:00
commit 34f0679117
805 changed files with 376275 additions and 0 deletions
+162
View File
@@ -0,0 +1,162 @@
import { isVoiceHubApi } from '~/utils/url'
export default defineNuxtPlugin({
name: 'auth-client',
enforce: 'pre',
setup(nuxtApp) {
// 立即设置请求拦截器,确保在任何API调用之前生效
const originalFetch = window.fetch
const errorHandler = useErrorHandler()
const auth = useAuth()
const router = useRouter()
let passwordStateRefresh: Promise<unknown> | null = null
const refreshPasswordState = async () => {
if (!passwordStateRefresh) {
passwordStateRefresh = auth.initAuth(true).finally(() => {
passwordStateRefresh = null
})
}
return passwordStateRefresh
}
const handlePasswordChangeRequired = async (errorData: any) => {
const requirePasswordChange =
errorData?.data?.requirePasswordChange === true || errorData?.requirePasswordChange === true
if (!requirePasswordChange) return false
// 以服务端刷新后的状态为准,仅刷新失败时回退信任 403 信号,避免过期信号把已改密用户锁在改密页
let refreshed = true
try {
await refreshPasswordState()
} catch {
refreshed = false
}
if (!refreshed && auth.user.value && auth.user.value.requirePasswordChange !== true) {
auth.user.value.requirePasswordChange = true
}
if (
auth.user.value?.requirePasswordChange === true &&
window.location.pathname !== '/change-password'
) {
await router.replace('/change-password')
}
return true
}
// 拦截window.fetch
window.fetch = async function (
input: RequestInfo | URL,
init?: RequestInit
): Promise<Response> {
// 处理所有API请求
if (isVoiceHubApi(input)) {
init = init || {}
init.headers = init.headers || {}
// 确保cookie会被发送
init.credentials = 'include'
}
const response = await originalFetch(input, init)
if (response.status === 403 && isVoiceHubApi(input)) {
try {
await handlePasswordChangeRequired(await response.clone().json())
} catch {
// 非强制改密的 403 保持原有响应处理。
}
}
// 检查是否为401错误,并且该请求属于VoiceHub API
if (response.status === 401 && isVoiceHubApi(input)) {
const currentPath = window.location.pathname
// 如果在登录页面,解析错误响应体并抛出具体错误信息
if (currentPath === '/login') {
try {
const errorData = await response.clone().json()
const errorMessage = errorData.message || '登录失败,请检查账号密码'
return Promise.reject(new Error(errorMessage))
} catch (parseError) {
return Promise.reject(new Error('登录失败,请检查账号密码'))
}
}
// 只有在非登录页面才触发认证失效处理
await errorHandler.handle401Error('您的登录信息已失效,请重新登录')
return Promise.reject(new Error('Authentication expired'))
}
return response
}
// 初始化认证状态
nuxtApp.hook('app:created', async () => {
await auth.initAuth()
// 拦截$fetch请求,确保cookie会被发送
const originalUseFetch = nuxtApp.$fetch
if (originalUseFetch) {
nuxtApp.$fetch = async function (request: any, options: any = {}) {
// 为所有API请求确保cookie会被发送
if (isVoiceHubApi(request)) {
options.headers = options.headers || {}
// 确保cookie会被发送
options.credentials = 'include'
}
try {
return await originalUseFetch(request, options)
} catch (error: any) {
if ((error?.status === 403 || error?.statusCode === 403) && isVoiceHubApi(request)) {
await handlePasswordChangeRequired(error?.data)
}
// 检查是否为401错误,并且该请求属于VoiceHub API
if ((error?.status === 401 || error?.statusCode === 401) && isVoiceHubApi(request)) {
const currentPath = window.location.pathname
// 如果在登录页面,解析错误信息并抛出具体错误
if (currentPath === '/login') {
// 从error对象中提取具体错误信息,过滤掉网络请求信息
let errorMessage =
error?.data?.message || error?.message || '登录失败,请检查账号密码'
// 如果错误信息包含网络请求格式(如 [POST] "/api/auth/login": <no response>),则提取纯净的错误信息
if (typeof errorMessage === 'string') {
// 匹配并移除网络请求前缀格式
const cleanMessage = errorMessage
.replace(/^\[\w+\]\s+"[^"]+":\s+(<no response>\s+)?/, '')
.trim()
errorMessage = cleanMessage || '登录失败,请检查账号密码'
}
const newError = new Error(errorMessage)
throw newError
}
// 只有在非登录页面才触发认证失效处理
await errorHandler.handle401Error('您的登录信息已失效,请重新登录')
throw error
}
throw error
}
}
}
})
// 全局错误处理
nuxtApp.hook('vue:error', async (error: any) => {
if (error?.status === 401 || error?.statusCode === 401) {
const currentPath = window.location.pathname
if (currentPath !== '/login') {
await errorHandler.handle401Error('您的登录信息已失效,请重新登录')
}
}
})
}
})
+8
View File
@@ -0,0 +1,8 @@
// 服务端认证插件已禁用
// 在serverless环境中,使用useState会导致状态在不同用户之间共享
// 认证状态应该完全由客户端管理,通过API调用获取
export default defineNuxtPlugin(() => {
// 服务端插件已禁用,避免状态共享问题
// 认证状态将完全由客户端通过API调用管理
})
+59
View File
@@ -0,0 +1,59 @@
import {
LOCALE_COOKIE_KEY,
LOCALE_PREFERENCE_KEY,
isSupportedLocale,
loadLocaleMessages,
resolveClientInitialLocale,
resolveLocaleFromAcceptLanguage,
setLocale,
useLocale,
type Locale,
type LocalePreference
} from '~/utils/locale'
// 语言初始化插件(服务端 + 客户端通用)。
// - 仅当用户手动选择过语言(偏好为 manual)时信任 cookie 中保存的语言;
// 否则每次进入都按 Accept-Language / 浏览器语言跟随系统语言,系统语言变化后自动切换。
// - 客户端沿用服务端解析结果(cookie)以保证水合一致,cookie 缺失时才按浏览器语言解析。
// 在渲染前 await 目标语言词典,避免懒加载导致的水合不匹配。
export default defineNuxtPlugin(async () => {
const { currentLocale } = useLocale()
const localeCookie = useCookie<Locale | undefined>(LOCALE_COOKIE_KEY, {
maxAge: 60 * 60 * 24 * 365,
sameSite: 'lax',
path: '/'
})
const preferenceCookie = useCookie<LocalePreference | undefined>(LOCALE_PREFERENCE_KEY, {
maxAge: 60 * 60 * 24 * 365,
sameSite: 'lax',
path: '/'
})
let resolved: Locale
let isManualPreference = false
if (preferenceCookie.value === 'manual' && isSupportedLocale(localeCookie.value)) {
isManualPreference = true
resolved = localeCookie.value
} else if (import.meta.server) {
const headers = useRequestHeaders(['accept-language'])
resolved = resolveLocaleFromAcceptLanguage(headers['accept-language'])
} else if (isSupportedLocale(localeCookie.value)) {
// 沿用服务端解析结果(本次会话跟随系统得到),避免水合不一致
resolved = localeCookie.value
} else {
resolved = resolveClientInitialLocale()
}
// 渲染前确保词典就绪:中文为静态内置;非兜底语言在此 await 其动态 chunk。
await loadLocaleMessages(resolved)
setLocale(resolved, isManualPreference)
// 由当前语言驱动 <html lang>,服务端渲染即写入,客户端切换语言时响应式更新。
useHead({ htmlAttrs: { lang: currentLocale } })
if (import.meta.client) {
watch(currentLocale, (value) => {
localeCookie.value = value
})
}
})
+153
View File
@@ -0,0 +1,153 @@
import * as Sentry from '@sentry/vue'
import type { Event, EventHint } from '@sentry/vue'
import {
getSentryEventSearchText,
isExpectedUpstreamMusicError
} from '~/utils/sentryUpstreamMusicErrors'
let sentryClientInitialized = false
let sentryClientInitializing = false
let instanceId = ''
const TELEMETRY_STORAGE_KEY = 'voicehub.telemetryEnabled'
const isTelemetryDisabledLocally = (): boolean => {
try {
return typeof localStorage !== 'undefined' &&
localStorage.getItem(TELEMETRY_STORAGE_KEY) === 'false'
} catch {
return false
}
}
const setTelemetryStorageValue = (value: string) => {
try {
if (typeof localStorage !== 'undefined') {
localStorage.setItem(TELEMETRY_STORAGE_KEY, value)
}
} catch {
// 隐私模式可能禁用 localStorage,遥测开关以服务端返回为准。
}
}
const shouldDropClientEvent = (event: Event, hint?: EventHint): boolean => {
if (isTelemetryDisabledLocally()) {
return true
}
const originalException = hint?.originalException
if (originalException instanceof DOMException && originalException.name === 'AbortError') {
return true
}
if (typeof navigator !== 'undefined' && navigator.onLine === false) {
return true
}
// 外部音源接口波动属于预期失败,本地交互提示即可。
if (isExpectedUpstreamMusicError(getSentryEventSearchText(event, hint))) {
return true
}
const message = (event.message || '').toLowerCase()
if (message.includes('aborterror') || message.includes('networkerror')) {
return true
}
const frames = event.exception?.values?.flatMap(value => value.stacktrace?.frames || []) || []
if (frames.some(frame => {
const filename = frame.filename || ''
return filename.startsWith('chrome-extension://') || filename.startsWith('moz-extension://')
})) {
return true
}
return false
}
export default defineNuxtPlugin((nuxtApp) => {
if (!import.meta.client || sentryClientInitialized || sentryClientInitializing) {
return
}
const config = useRuntimeConfig()
const sentryConfig = config.public?.sentry
if (!sentryConfig?.enabled || !sentryConfig.dsn) {
return
}
sentryClientInitializing = true
void (async () => {
try {
const response = await $fetch<{
success?: boolean
data?: { instanceId?: string; telemetryEnabled?: boolean }
}>('/api/system/instance')
if (!response?.data?.telemetryEnabled) {
setTelemetryStorageValue('false')
return
}
setTelemetryStorageValue('true')
instanceId = response.data.instanceId || ''
console.log(`[VoiceHub] 遥测已开启,实例 ID: ${instanceId}(提交 Bug 时可提供此 ID 以便开发者定位)`)
const deploymentTarget = config.public?.isNetlify
? 'netlify'
: import.meta.env.VERCEL
? 'vercel'
: 'self-hosted-node'
const sentryRouter = nuxtApp.$router as NonNullable<
Parameters<typeof Sentry.browserTracingIntegration>[0]
>['router']
const integrations = [Sentry.browserTracingIntegration({ router: sentryRouter })]
integrations.push(
Sentry.consoleLoggingIntegration({
levels: import.meta.dev ? ['log', 'warn', 'error'] : ['error']
})
)
if (
sentryConfig.replaysSessionSampleRate > 0 ||
sentryConfig.replaysOnErrorSampleRate > 0
) {
integrations.push(Sentry.replayIntegration())
}
Sentry.init({
app: nuxtApp.vueApp,
dsn: sentryConfig.dsn,
environment: sentryConfig.environment,
release: sentryConfig.release || undefined,
integrations,
enableLogs: true,
tracesSampleRate: sentryConfig.tracesSampleRate,
replaysSessionSampleRate: sentryConfig.replaysSessionSampleRate,
replaysOnErrorSampleRate: sentryConfig.replaysOnErrorSampleRate,
beforeSend(event, hint) {
if (shouldDropClientEvent(event, hint)) {
return null
}
return event
}
})
sentryClientInitialized = true
Sentry.setTag('runtime', 'vue')
Sentry.setTag('deployment_target', deploymentTarget)
if (instanceId) {
Sentry.setTag('instance_id', instanceId)
Sentry.setContext('instance', { instanceId })
}
} catch (error) {
setTelemetryStorageValue('false')
console.warn('[Sentry] Failed to initialize client telemetry:', error)
} finally {
sentryClientInitializing = false
}
})()
})
+39
View File
@@ -0,0 +1,39 @@
/**
* 主题同步插件(客户端)。
* - 更新 <meta name="theme-color"> 以跟随 PWA 状态栏颜色。
* - data-theme attribute 由 useTheme() 初始化和 setTheme() 统一管理,本插件不再重复设置。
*/
import { watch } from 'vue'
import type { Theme } from '~/composables/useTheme'
export default defineNuxtPlugin((nuxtApp) => {
if (import.meta.server) return
const { currentTheme } = useTheme()
/** 根据主题(经典深色/经典浅色/现代浅色)更新 theme-color meta */
function updateMeta(theme) {
const cs = getComputedStyle(document.documentElement)
const colorMap = {
// 经典深色 -> 经典浅色 -> 现代浅色(统一使用 --bg-primary 作为页面主背景色)
ClassicDark: cs.getPropertyValue('--bg-primary').trim() || '#111111',
ClassicLight: cs.getPropertyValue('--bg-primary').trim() || '#fafafa',
ModernLight: cs.getPropertyValue('--bg-primary').trim() || '#fcfcfa'
}
let meta = document.querySelector("meta[name='theme-color']")
if (!meta) {
meta = document.createElement('meta')
meta.setAttribute('name', 'theme-color')
document.head.appendChild(meta)
}
meta.setAttribute('content', colorMap[theme] || cs.getPropertyValue('--bg-primary').trim() || '#111111')
}
// 首次挂载时更新 theme-color meta(data-theme attribute 由 useTheme() 初始化和 setTheme() 统一管理,无需在此重复设置)
nuxtApp.hook('vue:mounted', () => {
updateMeta(currentTheme.value)
})
// 主题变化时同步更新
watch(() => currentTheme.value, updateMeta)
})
+14
View File
@@ -0,0 +1,14 @@
export default defineNuxtPlugin((nuxtApp) => {
if (import.meta.client) {
const { syncTime, now: getSyncedTimestamp, nowDate: getSyncedDate } = useSyncedTime()
// 立即绑定到 globalThis,组件初始化时即可安全调用(同步完成前回退到 Date.now)
if (typeof globalThis !== 'undefined') {
;(globalThis as any).getSyncedTimestamp = getSyncedTimestamp
;(globalThis as any).getSyncedDate = getSyncedDate
}
// 异步同步,不阻塞应用挂载
syncTime().catch(console.error)
}
})