feat: align with upstream 1.5.9.9
This commit is contained in:
@@ -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('您的登录信息已失效,请重新登录')
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,8 @@
|
||||
// 服务端认证插件已禁用
|
||||
// 在serverless环境中,使用useState会导致状态在不同用户之间共享
|
||||
// 认证状态应该完全由客户端管理,通过API调用获取
|
||||
|
||||
export default defineNuxtPlugin(() => {
|
||||
// 服务端插件已禁用,避免状态共享问题
|
||||
// 认证状态将完全由客户端通过API调用管理
|
||||
})
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
})()
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user