Merge pull request #804 from AAEE86/fix-frontend-eslint

Fix frontend ESLint issues
This commit is contained in:
fawney19
2026-09-07 10:06:40 +08:00
committed by GitHub
8 changed files with 470 additions and 304 deletions
+1 -2
View File
@@ -392,8 +392,7 @@ class ApiClient {
this.isRefreshing = true this.isRefreshing = true
const requestAuthStateVersion = this.authStateVersion const requestAuthStateVersion = this.authStateVersion
let restorePromise!: Promise<string> const restorePromise = (async () => {
restorePromise = (async () => {
const accessToken = await this.coordinatedRefresh() const accessToken = await this.coordinatedRefresh()
if (requestAuthStateVersion !== this.authStateVersion) { if (requestAuthStateVersion !== this.authStateVersion) {
throw new Error('Auth state changed during session restore') throw new Error('Auth state changed during session restore')
@@ -9,7 +9,10 @@
@update:model-value="handleDialogUpdate" @update:model-value="handleDialogUpdate"
> >
<div class="space-y-3.5"> <div class="space-y-3.5">
<nav class="grid grid-cols-3 gap-1.5 rounded-xl bg-muted/40 p-1.5" aria-label="批量导入步骤"> <nav
class="grid grid-cols-3 gap-1.5 rounded-xl bg-muted/40 p-1.5"
aria-label="批量导入步骤"
>
<button <button
v-for="step in steps" v-for="step in steps"
:key="step.id" :key="step.id"
@@ -37,8 +40,12 @@
<div class="flex min-w-0 items-center gap-3"> <div class="flex min-w-0 items-center gap-3">
<span class="flex h-7 w-7 shrink-0 items-center justify-center rounded-md bg-foreground text-xs font-semibold text-background">1</span> <span class="flex h-7 w-7 shrink-0 items-center justify-center rounded-md bg-foreground text-xs font-semibold text-background">1</span>
<div class="min-w-0"> <div class="min-w-0">
<h3 class="text-balance text-sm font-semibold text-foreground">粘贴名称与 Key</h3> <h3 class="text-balance text-sm font-semibold text-foreground">
<p class="text-pretty text-[11px] leading-4 text-muted-foreground">每行一条,仅接受四个短横线分隔</p> 粘贴名称与 Key
</h3>
<p class="text-pretty text-[11px] leading-4 text-muted-foreground">
每行一条,仅接受四个短横线分隔
</p>
</div> </div>
</div> </div>
<Badge <Badge
@@ -50,7 +57,10 @@
</header> </header>
<div class="min-w-0"> <div class="min-w-0">
<Label for="provider-key-batch-input" class="sr-only">Key 列表</Label> <Label
for="provider-key-batch-input"
class="sr-only"
>Key 列表</Label>
<Textarea <Textarea
id="provider-key-batch-input" id="provider-key-batch-input"
v-model="inputText" v-model="inputText"
@@ -68,7 +78,10 @@
> >
{{ item.lineNumber ? `第 ${item.lineNumber} 行:` : '' }}{{ item.message }} {{ item.lineNumber ? `第 ${item.lineNumber} 行:` : '' }}{{ item.message }}
</div> </div>
<div v-if="parsed.errors.length > 6" class="font-medium"> <div
v-if="parsed.errors.length > 6"
class="font-medium"
>
另有 {{ parsed.errors.length - 6 }} 个问题 另有 {{ parsed.errors.length - 6 }} 个问题
</div> </div>
</div> </div>
@@ -98,7 +111,10 @@
>{{ item }}</span> >{{ item }}</span>
</span> </span>
</span> </span>
<Badge :variant="selectedApiFormats.length > 0 ? 'success' : 'destructive'" class="ml-auto shrink-0 tabular-nums"> <Badge
:variant="selectedApiFormats.length > 0 ? 'success' : 'destructive'"
class="ml-auto shrink-0 tabular-nums"
>
{{ selectedApiFormats.length }} 种格式 {{ selectedApiFormats.length }} 种格式
</Badge> </Badge>
</header> </header>
@@ -123,12 +139,18 @@
<header class="flex min-h-[72px] items-center gap-3 border-b border-border/60 bg-muted/15 px-4 py-3"> <header class="flex min-h-[72px] items-center gap-3 border-b border-border/60 bg-muted/15 px-4 py-3">
<span class="flex h-7 w-7 shrink-0 items-center justify-center rounded-md bg-foreground text-xs font-semibold text-background">3</span> <span class="flex h-7 w-7 shrink-0 items-center justify-center rounded-md bg-foreground text-xs font-semibold text-background">3</span>
<div class="min-w-0 flex-1"> <div class="min-w-0 flex-1">
<h3 class="text-balance text-sm font-semibold">逐项确认</h3> <h3 class="text-balance text-sm font-semibold">
<p class="text-pretty text-[11px] leading-4 text-muted-foreground">展开任意 Key 可修改内容或设置单独配置</p> 逐项确认
</h3>
<p class="text-pretty text-[11px] leading-4 text-muted-foreground">
展开任意 Key 可修改内容或设置单独配置
</p>
</div> </div>
<div class="shrink-0 text-right text-[11px] text-muted-foreground"> <div class="shrink-0 text-right text-[11px] text-muted-foreground">
<div><span class="font-semibold tabular-nums text-foreground">{{ reviewItems.length }}</span> 个 Key</div> <div><span class="font-semibold tabular-nums text-foreground">{{ reviewItems.length }}</span> 个 Key</div>
<div v-if="customizedItemCount > 0"><span class="tabular-nums">{{ customizedItemCount }}</span> 个单独配置</div> <div v-if="customizedItemCount > 0">
<span class="tabular-nums">{{ customizedItemCount }}</span> 个单独配置
</div>
</div> </div>
</header> </header>
@@ -156,9 +178,13 @@
v-if="reviewErrorsByIndex.has(entry.index)" v-if="reviewErrorsByIndex.has(entry.index)"
variant="destructive" variant="destructive"
class="shrink-0 text-[10px]" class="shrink-0 text-[10px]"
>需修正</Badge> >
需修正
</Badge>
</div>
<div class="mt-0.5 truncate font-mono text-[10px] text-muted-foreground">
{{ maskSecret(entry.item.apiKey) }}
</div> </div>
<div class="mt-0.5 truncate font-mono text-[10px] text-muted-foreground">{{ maskSecret(entry.item.apiKey) }}</div>
</div> </div>
<div class="hidden shrink-0 items-center gap-1.5 sm:flex"> <div class="hidden shrink-0 items-center gap-1.5 sm:flex">
<span class="rounded-md bg-muted px-2 py-0.5 text-[10px] text-muted-foreground">{{ effectiveAuthLabel(entry.item) }}</span> <span class="rounded-md bg-muted px-2 py-0.5 text-[10px] text-muted-foreground">{{ effectiveAuthLabel(entry.item) }}</span>
@@ -186,18 +212,30 @@
<div class="grid gap-3 sm:grid-cols-2"> <div class="grid gap-3 sm:grid-cols-2">
<div class="space-y-1.5"> <div class="space-y-1.5">
<Label class="text-xs">名称</Label> <Label class="text-xs">名称</Label>
<Input v-model="entry.item.name" class="h-10" placeholder="必填" /> <Input
v-model="entry.item.name"
class="h-10"
placeholder="必填"
/>
</div> </div>
<div class="space-y-1.5"> <div class="space-y-1.5">
<Label class="text-xs">Key</Label> <Label class="text-xs">Key</Label>
<Input v-model="entry.item.apiKey" class="h-10 font-mono text-xs" placeholder="必填" /> <Input
v-model="entry.item.apiKey"
class="h-10 font-mono text-xs"
placeholder="必填"
/>
</div> </div>
</div> </div>
<div class="flex min-h-12 items-center justify-between gap-3 rounded-lg bg-background px-3 shadow-[0_0_0_1px_rgb(0_0_0/0.06)] dark:shadow-[0_0_0_1px_rgb(255_255_255/0.08)]"> <div class="flex min-h-12 items-center justify-between gap-3 rounded-lg bg-background px-3 shadow-[0_0_0_1px_rgb(0_0_0/0.06)] dark:shadow-[0_0_0_1px_rgb(255_255_255/0.08)]">
<div> <div>
<div class="text-xs font-medium">单独配置此 Key</div> <div class="text-xs font-medium">
<div class="text-[11px] text-muted-foreground">开启后覆盖第二步中的统一配置</div> 单独配置此 Key
</div>
<div class="text-[11px] text-muted-foreground">
开启后覆盖第二步中的统一配置
</div>
</div> </div>
<Switch <Switch
:model-value="entry.item.customized" :model-value="entry.item.customized"
@@ -220,7 +258,12 @@
v-if="reviewErrorsByIndex.has(entry.index)" v-if="reviewErrorsByIndex.has(entry.index)"
class="space-y-1 rounded-lg bg-destructive/5 px-3 py-2 text-[11px] text-destructive" class="space-y-1 rounded-lg bg-destructive/5 px-3 py-2 text-[11px] text-destructive"
> >
<div v-for="message in reviewErrorsByIndex.get(entry.index)" :key="message">{{ message }}</div> <div
v-for="message in reviewErrorsByIndex.get(entry.index)"
:key="message"
>
{{ message }}
</div>
</div> </div>
</div> </div>
</article> </article>
@@ -230,12 +273,24 @@
v-if="reviewPageCount > 1" v-if="reviewPageCount > 1"
class="flex min-h-12 items-center justify-between gap-3 border-t border-border/60 bg-muted/10 px-3 sm:px-4" class="flex min-h-12 items-center justify-between gap-3 border-t border-border/60 bg-muted/10 px-3 sm:px-4"
> >
<Button variant="ghost" size="sm" class="h-9" :disabled="reviewPage === 1" @click="changeReviewPage(reviewPage - 1)"> <Button
variant="ghost"
size="sm"
class="h-9"
:disabled="reviewPage === 1"
@click="changeReviewPage(reviewPage - 1)"
>
<ChevronLeft class="mr-1 h-4 w-4" /> <ChevronLeft class="mr-1 h-4 w-4" />
上一页 上一页
</Button> </Button>
<span class="text-[11px] tabular-nums text-muted-foreground">{{ reviewPage }} / {{ reviewPageCount }}</span> <span class="text-[11px] tabular-nums text-muted-foreground">{{ reviewPage }} / {{ reviewPageCount }}</span>
<Button variant="ghost" size="sm" class="h-9" :disabled="reviewPage === reviewPageCount" @click="changeReviewPage(reviewPage + 1)"> <Button
variant="ghost"
size="sm"
class="h-9"
:disabled="reviewPage === reviewPageCount"
@click="changeReviewPage(reviewPage + 1)"
>
下一页 下一页
<ChevronRight class="ml-1 h-4 w-4" /> <ChevronRight class="ml-1 h-4 w-4" />
</Button> </Button>
@@ -245,14 +300,35 @@
<template #footer> <template #footer>
<div class="flex w-full flex-col-reverse gap-2 sm:flex-row sm:justify-end"> <div class="flex w-full flex-col-reverse gap-2 sm:flex-row sm:justify-end">
<Button class="w-full sm:w-auto" variant="outline" :disabled="importing" @click="handleBack"> <Button
<ArrowLeft v-if="currentStep > 1" class="mr-2 h-4 w-4" /> class="w-full sm:w-auto"
variant="outline"
:disabled="importing"
@click="handleBack"
>
<ArrowLeft
v-if="currentStep > 1"
class="mr-2 h-4 w-4"
/>
{{ currentStep === 1 ? '取消' : '上一步' }} {{ currentStep === 1 ? '取消' : '上一步' }}
</Button> </Button>
<Button class="w-full sm:w-auto" :disabled="primaryActionDisabled" @click="handlePrimaryAction"> <Button
<Loader2 v-if="importing" class="mr-2 h-4 w-4 animate-spin" /> class="w-full sm:w-auto"
<ListPlus v-else-if="currentStep === 3" class="mr-2 h-4 w-4" /> :disabled="primaryActionDisabled"
<ArrowRight v-else class="mr-2 h-4 w-4" /> @click="handlePrimaryAction"
>
<Loader2
v-if="importing"
class="mr-2 h-4 w-4 animate-spin"
/>
<ListPlus
v-else-if="currentStep === 3"
class="mr-2 h-4 w-4"
/>
<ArrowRight
v-else
class="mr-2 h-4 w-4"
/>
{{ primaryActionLabel }} {{ primaryActionLabel }}
</Button> </Button>
</div> </div>
@@ -28,8 +28,12 @@
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="api_key">API Key</SelectItem> <SelectItem value="api_key">
<SelectItem value="bearer">Bearer Token</SelectItem> API Key
</SelectItem>
<SelectItem value="bearer">
Bearer Token
</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
@@ -112,8 +116,12 @@
<div class="flex min-h-12 items-center justify-between gap-3 rounded-lg bg-background px-3 shadow-[0_0_0_1px_rgb(0_0_0/0.06),0_1px_2px_rgb(0_0_0/0.04)] dark:shadow-[0_0_0_1px_rgb(255_255_255/0.08)]"> <div class="flex min-h-12 items-center justify-between gap-3 rounded-lg bg-background px-3 shadow-[0_0_0_1px_rgb(0_0_0/0.06),0_1px_2px_rgb(0_0_0/0.04)] dark:shadow-[0_0_0_1px_rgb(255_255_255/0.08)]">
<div> <div>
<div class="text-xs font-medium">导入后立即启用</div> <div class="text-xs font-medium">
<div class="text-[11px] text-muted-foreground">关闭后仍会创建,但不会进入调度</div> 导入后立即启用
</div>
<div class="text-[11px] text-muted-foreground">
关闭后仍会创建,但不会进入调度
</div>
</div> </div>
<Switch <Switch
:model-value="settings.is_active" :model-value="settings.is_active"
@@ -100,6 +100,51 @@ describe('auth store logout', () => {
expect(clearAuthMock).toHaveBeenCalledWith(false, false) expect(clearAuthMock).toHaveBeenCalledWith(false, false)
}) })
it.each(['synchronous', 'asynchronous'] as const)(
'allows a forced retry after a %s restore failure',
async (failureMode) => {
const failure = new Error('temporary refresh failure')
if (failureMode === 'synchronous') {
restoreSessionMock.mockImplementationOnce(() => {
throw failure
})
} else {
restoreSessionMock.mockRejectedValueOnce(failure)
}
restoreSessionMock.mockResolvedValueOnce('retry-access-token')
const store = useAuthStore()
await expect(store.restoreSession()).resolves.toBe(false)
expect(clearAuthMock).toHaveBeenCalledWith(false, false)
await expect(store.restoreSession(true)).resolves.toBe(true)
expect(store.token).toBe('retry-access-token')
expect(restoreSessionMock).toHaveBeenCalledTimes(2)
},
)
it('deduplicates in-flight restores and allows a refresh after completion', async () => {
let resolveRestore!: (token: string) => void
const pendingRestore = new Promise<string>((resolve) => {
resolveRestore = resolve
})
restoreSessionMock.mockReturnValueOnce(pendingRestore)
const store = useAuthStore()
const firstRestore = store.restoreSession()
const secondRestore = store.restoreSession()
expect(restoreSessionMock).toHaveBeenCalledTimes(1)
resolveRestore('restored-access-token')
await expect(Promise.all([firstRestore, secondRestore])).resolves.toEqual([true, true])
expect(store.token).toBe('restored-access-token')
restoreSessionMock.mockResolvedValueOnce('refreshed-access-token')
await expect(store.restoreSession(true)).resolves.toBe(true)
expect(store.token).toBe('refreshed-access-token')
expect(restoreSessionMock).toHaveBeenCalledTimes(2)
})
it('preserves an existing access token when a forced restore fails', async () => { it('preserves an existing access token when a forced restore fails', async () => {
getTokenMock.mockReturnValue('still-valid-access-token') getTokenMock.mockReturnValue('still-valid-access-token')
restoreSessionMock.mockRejectedValue(new Error('temporary refresh conflict')) restoreSessionMock.mockRejectedValue(new Error('temporary refresh conflict'))
+4 -5
View File
@@ -60,8 +60,7 @@ export const useAuthStore = defineStore('auth', () => {
sessionRestoreAttempted = true sessionRestoreAttempted = true
const requestAuthStateVersion = authStateVersion const requestAuthStateVersion = authStateVersion
const requestToken = token.value const requestToken = token.value
let request!: Promise<boolean> const request = (async () => {
request = (async () => {
try { try {
const accessToken = await apiClient.restoreSession(notifyOtherTabs) const accessToken = await apiClient.restoreSession(notifyOtherTabs)
if (requestAuthStateVersion !== authStateVersion) { if (requestAuthStateVersion !== authStateVersion) {
@@ -82,12 +81,12 @@ export const useAuthStore = defineStore('auth', () => {
} }
} }
return false return false
} finally { }
})().finally(() => {
if (sessionRestorePromise === request) { if (sessionRestorePromise === request) {
sessionRestorePromise = null sessionRestorePromise = null
} }
} })
})()
sessionRestorePromise = request sessionRestorePromise = request
return request return request
@@ -6,6 +6,12 @@ import {
safeInternalNavigationPath, safeInternalNavigationPath,
} from '../navigationSecurity' } from '../navigationSecurity'
const unicodeSegments = ['👩\u200d💻', 'راهنمای\u200cکاربر', '\ue000']
const controlCodePoints = [
...Array.from({ length: 32 }, (_value, index) => index),
...Array.from({ length: 33 }, (_value, index) => index + 0x7f),
]
describe('safeInternalNavigationPath', () => { describe('safeInternalNavigationPath', () => {
it('keeps normalized same-origin paths, queries, and fragments', () => { it('keeps normalized same-origin paths, queries, and fragments', () => {
expect(safeInternalNavigationPath('/dashboard/../dashboard/api-keys?tab=active#key-1')).toBe( expect(safeInternalNavigationPath('/dashboard/../dashboard/api-keys?tab=active#key-1')).toBe(
@@ -13,6 +19,17 @@ describe('safeInternalNavigationPath', () => {
) )
}) })
it.each(unicodeSegments)('keeps valid Unicode query values: %s', (segment) => {
expect(safeInternalNavigationPath(`/dashboard?search=${segment}`)).toBe(
`/dashboard?search=${encodeURIComponent(segment)}`,
)
})
it.each(controlCodePoints)('rejects control character code point %i', (codePoint) => {
expect(safeInternalNavigationPath(`/dashboard/before${String.fromCodePoint(codePoint)}after`))
.toBeNull()
})
it.each([ it.each([
'https://attacker.example/steal', 'https://attacker.example/steal',
'//attacker.example/steal', '//attacker.example/steal',
@@ -32,6 +49,17 @@ describe('safeExternalHttpsUrl', () => {
) )
}) })
it.each(unicodeSegments)('allows valid Unicode URL paths: %s', (segment) => {
expect(safeExternalHttpsUrl(`https://provider.example/docs/${segment}`)).toBe(
`https://provider.example/docs/${encodeURIComponent(segment)}`,
)
})
it.each(controlCodePoints)('rejects control character code point %i', (codePoint) => {
expect(safeExternalHttpsUrl(`https://provider.example/before${String.fromCodePoint(codePoint)}after`))
.toBeNull()
})
it.each([ it.each([
'javascript:alert(1)', 'javascript:alert(1)',
'data:text/html,attack', 'data:text/html,attack',
@@ -53,6 +81,17 @@ describe('safeExternalWebUrl', () => {
expect(safeExternalWebUrl(value)).toBe(expected) expect(safeExternalWebUrl(value)).toBe(expected)
}) })
it.each(unicodeSegments)('allows valid Unicode provider websites: %s', (segment) => {
expect(safeExternalWebUrl(`https://provider.example/docs/${segment}`)).toBe(
`https://provider.example/docs/${encodeURIComponent(segment)}`,
)
})
it.each(controlCodePoints)('rejects control character code point %i', (codePoint) => {
expect(safeExternalWebUrl(`https://provider.example/before${String.fromCodePoint(codePoint)}after`))
.toBeNull()
})
it.each([ it.each([
'javascript:alert(1)', 'javascript:alert(1)',
'data:text/html,attack', 'data:text/html,attack',
+2 -2
View File
@@ -1,5 +1,5 @@
const INTERNAL_NAVIGATION_BASE = 'https://aether.invalid' const INTERNAL_NAVIGATION_BASE = 'https://aether.invalid'
const UNSAFE_EXTERNAL_URL_CHARACTERS = /[\\\u0000-\u001f\u007f]/ const UNSAFE_EXTERNAL_URL_CHARACTERS = /[\\\p{Cc}]/u
function safeAbsoluteExternalUrl( function safeAbsoluteExternalUrl(
value: string | null | undefined, value: string | null | undefined,
@@ -38,7 +38,7 @@ export function safeInternalNavigationPath(value: string | null | undefined): st
!candidate.startsWith('/') !candidate.startsWith('/')
|| candidate.startsWith('//') || candidate.startsWith('//')
|| candidate.includes('\\') || candidate.includes('\\')
|| /[\u0000-\u001f\u007f]/.test(candidate) || /\p{Cc}/u.test(candidate)
) { ) {
return null return null
} }