Skip to content
GitLab
Menu
Projects
Groups
Snippets
Loading...
Help
Help
Support
Community forum
Keyboard shortcuts
?
Submit feedback
Sign in / Register
Toggle navigation
Menu
Open sidebar
陈曦
sub2api
Commits
b63b338e
Commit
b63b338e
authored
Dec 30, 2025
by
yangjianbo
Browse files
Merge branch 'main' into test-dev
parents
57db688d
e85b35c6
Changes
30
Expand all
Hide whitespace changes
Inline
Side-by-side
frontend/package-lock.json
View file @
b63b338e
This diff is collapsed.
Click to expand it.
frontend/package.json
View file @
b63b338e
...
@@ -14,13 +14,17 @@
...
@@ -14,13 +14,17 @@
"@vueuse/core"
:
"^10.7.0"
,
"@vueuse/core"
:
"^10.7.0"
,
"axios"
:
"^1.6.2"
,
"axios"
:
"^1.6.2"
,
"chart.js"
:
"^4.4.1"
,
"chart.js"
:
"^4.4.1"
,
"driver.js"
:
"^1.4.0"
,
"file-saver"
:
"^2.0.5"
,
"pinia"
:
"^2.1.7"
,
"pinia"
:
"^2.1.7"
,
"vue"
:
"^3.4.0"
,
"vue"
:
"^3.4.0"
,
"vue-chartjs"
:
"^5.3.0"
,
"vue-chartjs"
:
"^5.3.0"
,
"vue-i18n"
:
"^9.14.5"
,
"vue-i18n"
:
"^9.14.5"
,
"vue-router"
:
"^4.2.5"
"vue-router"
:
"^4.2.5"
,
"xlsx"
:
"^0.18.5"
},
},
"devDependencies"
:
{
"devDependencies"
:
{
"@types/file-saver"
:
"^2.0.7"
,
"@types/node"
:
"^20.10.5"
,
"@types/node"
:
"^20.10.5"
,
"@vitejs/plugin-vue"
:
"^5.2.3"
,
"@vitejs/plugin-vue"
:
"^5.2.3"
,
"autoprefixer"
:
"^10.4.16"
,
"autoprefixer"
:
"^10.4.16"
,
...
...
frontend/pnpm-lock.yaml
0 → 100644
View file @
b63b338e
This diff is collapsed.
Click to expand it.
frontend/src/components/Guide/steps.ts
0 → 100644
View file @
b63b338e
import
{
DriveStep
}
from
'
driver.js
'
/**
* 管理员完整引导流程
* 交互式引导:指引用户实际操作
* @param t 国际化函数
* @param isSimpleMode 是否为简易模式(简易模式下会过滤分组相关步骤)
*/
export
const
getAdminSteps
=
(
t
:
(
key
:
string
)
=>
string
,
isSimpleMode
=
false
):
DriveStep
[]
=>
{
const
allSteps
:
DriveStep
[]
=
[
// ========== 欢迎介绍 ==========
{
popover
:
{
title
:
t
(
'
onboarding.admin.welcome.title
'
),
description
:
t
(
'
onboarding.admin.welcome.description
'
),
align
:
'
center
'
,
nextBtnText
:
t
(
'
onboarding.admin.welcome.nextBtn
'
),
prevBtnText
:
t
(
'
onboarding.admin.welcome.prevBtn
'
)
}
},
// ========== 第一部分:创建分组 ==========
{
element
:
'
#sidebar-group-manage
'
,
popover
:
{
title
:
t
(
'
onboarding.admin.groupManage.title
'
),
description
:
t
(
'
onboarding.admin.groupManage.description
'
),
side
:
'
right
'
,
align
:
'
center
'
,
showButtons
:
[
'
close
'
],
}
},
{
element
:
'
[data-tour="groups-create-btn"]
'
,
popover
:
{
title
:
t
(
'
onboarding.admin.createGroup.title
'
),
description
:
t
(
'
onboarding.admin.createGroup.description
'
),
side
:
'
bottom
'
,
align
:
'
end
'
,
showButtons
:
[
'
close
'
]
}
},
{
element
:
'
[data-tour="group-form-name"]
'
,
popover
:
{
title
:
t
(
'
onboarding.admin.groupName.title
'
),
description
:
t
(
'
onboarding.admin.groupName.description
'
),
side
:
'
right
'
,
align
:
'
start
'
,
showButtons
:
[
'
next
'
,
'
previous
'
]
}
},
{
element
:
'
[data-tour="group-form-platform"]
'
,
popover
:
{
title
:
t
(
'
onboarding.admin.groupPlatform.title
'
),
description
:
t
(
'
onboarding.admin.groupPlatform.description
'
),
side
:
'
right
'
,
align
:
'
start
'
,
showButtons
:
[
'
next
'
,
'
previous
'
]
}
},
{
element
:
'
[data-tour="group-form-multiplier"]
'
,
popover
:
{
title
:
t
(
'
onboarding.admin.groupMultiplier.title
'
),
description
:
t
(
'
onboarding.admin.groupMultiplier.description
'
),
side
:
'
right
'
,
align
:
'
start
'
,
showButtons
:
[
'
next
'
,
'
previous
'
]
}
},
{
element
:
'
[data-tour="group-form-exclusive"]
'
,
popover
:
{
title
:
t
(
'
onboarding.admin.groupExclusive.title
'
),
description
:
t
(
'
onboarding.admin.groupExclusive.description
'
),
side
:
'
top
'
,
align
:
'
start
'
,
showButtons
:
[
'
next
'
,
'
previous
'
]
}
},
{
element
:
'
[data-tour="group-form-submit"]
'
,
popover
:
{
title
:
t
(
'
onboarding.admin.groupSubmit.title
'
),
description
:
t
(
'
onboarding.admin.groupSubmit.description
'
),
side
:
'
left
'
,
align
:
'
center
'
,
showButtons
:
[
'
close
'
]
}
},
// ========== 第二部分:创建账号授权 ==========
{
element
:
'
#sidebar-channel-manage
'
,
popover
:
{
title
:
t
(
'
onboarding.admin.accountManage.title
'
),
description
:
t
(
'
onboarding.admin.accountManage.description
'
),
side
:
'
right
'
,
align
:
'
center
'
,
showButtons
:
[
'
close
'
]
}
},
{
element
:
'
[data-tour="accounts-create-btn"]
'
,
popover
:
{
title
:
t
(
'
onboarding.admin.createAccount.title
'
),
description
:
t
(
'
onboarding.admin.createAccount.description
'
),
side
:
'
bottom
'
,
align
:
'
end
'
,
showButtons
:
[
'
close
'
]
}
},
{
element
:
'
[data-tour="account-form-name"]
'
,
popover
:
{
title
:
t
(
'
onboarding.admin.accountName.title
'
),
description
:
t
(
'
onboarding.admin.accountName.description
'
),
side
:
'
right
'
,
align
:
'
start
'
,
showButtons
:
[
'
next
'
,
'
previous
'
]
}
},
{
element
:
'
[data-tour="account-form-platform"]
'
,
popover
:
{
title
:
t
(
'
onboarding.admin.accountPlatform.title
'
),
description
:
t
(
'
onboarding.admin.accountPlatform.description
'
),
side
:
'
right
'
,
align
:
'
start
'
,
showButtons
:
[
'
next
'
,
'
previous
'
]
}
},
{
element
:
'
[data-tour="account-form-type"]
'
,
popover
:
{
title
:
t
(
'
onboarding.admin.accountType.title
'
),
description
:
t
(
'
onboarding.admin.accountType.description
'
),
side
:
'
right
'
,
align
:
'
start
'
,
showButtons
:
[
'
next
'
,
'
previous
'
]
}
},
{
element
:
'
[data-tour="account-form-priority"]
'
,
popover
:
{
title
:
t
(
'
onboarding.admin.accountPriority.title
'
),
description
:
t
(
'
onboarding.admin.accountPriority.description
'
),
side
:
'
top
'
,
align
:
'
start
'
,
showButtons
:
[
'
next
'
,
'
previous
'
]
}
},
{
element
:
'
[data-tour="account-form-groups"]
'
,
popover
:
{
title
:
t
(
'
onboarding.admin.accountGroups.title
'
),
description
:
t
(
'
onboarding.admin.accountGroups.description
'
),
side
:
'
top
'
,
align
:
'
center
'
,
showButtons
:
[
'
next
'
,
'
previous
'
]
}
},
{
element
:
'
[data-tour="account-form-submit"]
'
,
popover
:
{
title
:
t
(
'
onboarding.admin.accountSubmit.title
'
),
description
:
t
(
'
onboarding.admin.accountSubmit.description
'
),
side
:
'
left
'
,
align
:
'
center
'
,
showButtons
:
[
'
close
'
]
}
},
// ========== 第三部分:创建API密钥 ==========
{
element
:
'
[data-tour="sidebar-my-keys"]
'
,
popover
:
{
title
:
t
(
'
onboarding.admin.keyManage.title
'
),
description
:
t
(
'
onboarding.admin.keyManage.description
'
),
side
:
'
right
'
,
align
:
'
center
'
,
showButtons
:
[
'
close
'
]
}
},
{
element
:
'
[data-tour="keys-create-btn"]
'
,
popover
:
{
title
:
t
(
'
onboarding.admin.createKey.title
'
),
description
:
t
(
'
onboarding.admin.createKey.description
'
),
side
:
'
bottom
'
,
align
:
'
end
'
,
showButtons
:
[
'
close
'
]
}
},
{
element
:
'
[data-tour="key-form-name"]
'
,
popover
:
{
title
:
t
(
'
onboarding.admin.keyName.title
'
),
description
:
t
(
'
onboarding.admin.keyName.description
'
),
side
:
'
right
'
,
align
:
'
start
'
,
showButtons
:
[
'
next
'
,
'
previous
'
]
}
},
{
element
:
'
[data-tour="key-form-group"]
'
,
popover
:
{
title
:
t
(
'
onboarding.admin.keyGroup.title
'
),
description
:
t
(
'
onboarding.admin.keyGroup.description
'
),
side
:
'
right
'
,
align
:
'
start
'
,
showButtons
:
[
'
next
'
,
'
previous
'
]
}
},
{
element
:
'
[data-tour="key-form-submit"]
'
,
popover
:
{
title
:
t
(
'
onboarding.admin.keySubmit.title
'
),
description
:
t
(
'
onboarding.admin.keySubmit.description
'
),
side
:
'
left
'
,
align
:
'
center
'
,
showButtons
:
[
'
close
'
]
}
}
]
// 简易模式下过滤分组相关步骤
if
(
isSimpleMode
)
{
return
allSteps
.
filter
(
step
=>
{
const
element
=
step
.
element
as
string
|
undefined
// 过滤掉分组管理和账号分组选择相关步骤
return
!
element
||
(
!
element
.
includes
(
'
sidebar-group-manage
'
)
&&
!
element
.
includes
(
'
groups-create-btn
'
)
&&
!
element
.
includes
(
'
group-form-
'
)
&&
!
element
.
includes
(
'
account-form-groups
'
)
)
})
}
return
allSteps
}
/**
* 普通用户引导流程
*/
export
const
getUserSteps
=
(
t
:
(
key
:
string
)
=>
string
):
DriveStep
[]
=>
[
{
popover
:
{
title
:
t
(
'
onboarding.user.welcome.title
'
),
description
:
t
(
'
onboarding.user.welcome.description
'
),
align
:
'
center
'
,
nextBtnText
:
t
(
'
onboarding.user.welcome.nextBtn
'
),
prevBtnText
:
t
(
'
onboarding.user.welcome.prevBtn
'
)
}
},
{
element
:
'
[data-tour="sidebar-my-keys"]
'
,
popover
:
{
title
:
t
(
'
onboarding.user.keyManage.title
'
),
description
:
t
(
'
onboarding.user.keyManage.description
'
),
side
:
'
right
'
,
align
:
'
center
'
,
showButtons
:
[
'
close
'
]
}
},
{
element
:
'
[data-tour="keys-create-btn"]
'
,
popover
:
{
title
:
t
(
'
onboarding.user.createKey.title
'
),
description
:
t
(
'
onboarding.user.createKey.description
'
),
side
:
'
bottom
'
,
align
:
'
end
'
,
showButtons
:
[
'
close
'
]
}
},
{
element
:
'
[data-tour="key-form-name"]
'
,
popover
:
{
title
:
t
(
'
onboarding.user.keyName.title
'
),
description
:
t
(
'
onboarding.user.keyName.description
'
),
side
:
'
right
'
,
align
:
'
start
'
,
showButtons
:
[
'
next
'
,
'
previous
'
]
}
},
{
element
:
'
[data-tour="key-form-group"]
'
,
popover
:
{
title
:
t
(
'
onboarding.user.keyGroup.title
'
),
description
:
t
(
'
onboarding.user.keyGroup.description
'
),
side
:
'
right
'
,
align
:
'
start
'
,
showButtons
:
[
'
next
'
,
'
previous
'
]
}
},
{
element
:
'
[data-tour="key-form-submit"]
'
,
popover
:
{
title
:
t
(
'
onboarding.user.keySubmit.title
'
),
description
:
t
(
'
onboarding.user.keySubmit.description
'
),
side
:
'
left
'
,
align
:
'
center
'
,
showButtons
:
[
'
close
'
]
}
}
]
frontend/src/components/account/AccountTestModal.vue
View file @
b63b338e
...
@@ -362,6 +362,10 @@ const resetState = () => {
...
@@ -362,6 +362,10 @@ const resetState = () => {
}
}
const
handleClose
=
()
=>
{
const
handleClose
=
()
=>
{
// 防止在连接测试进行中关闭对话框
if
(
status
.
value
===
'
connecting
'
)
{
return
}
closeEventSource
()
closeEventSource
()
emit
(
'
close
'
)
emit
(
'
close
'
)
}
}
...
...
frontend/src/components/account/CreateAccountModal.vue
View file @
b63b338e
...
@@ -2,7 +2,7 @@
...
@@ -2,7 +2,7 @@
<BaseDialog
<BaseDialog
:show=
"show"
:show=
"show"
:title=
"t('admin.accounts.createAccount')"
:title=
"t('admin.accounts.createAccount')"
width=
"
wide
"
width=
"
normal
"
@
close=
"handleClose"
@
close=
"handleClose"
>
>
<!-- Step Indicator for OAuth accounts -->
<!-- Step Indicator for OAuth accounts -->
...
@@ -53,13 +53,14 @@
...
@@ -53,13 +53,14 @@
required
required
class=
"input"
class=
"input"
:placeholder=
"t('admin.accounts.enterAccountName')"
:placeholder=
"t('admin.accounts.enterAccountName')"
data-tour=
"account-form-name"
/>
/>
</div>
</div>
<!-- Platform Selection - Segmented Control Style -->
<!-- Platform Selection - Segmented Control Style -->
<div>
<div>
<label
class=
"input-label"
>
{{
t
(
'
admin.accounts.platform
'
)
}}
</label>
<label
class=
"input-label"
>
{{
t
(
'
admin.accounts.platform
'
)
}}
</label>
<div
class=
"mt-2 flex rounded-lg bg-gray-100 p-1 dark:bg-dark-700"
>
<div
class=
"mt-2 flex rounded-lg bg-gray-100 p-1 dark:bg-dark-700"
data-tour=
"account-form-platform"
>
<button
<button
type=
"button"
type=
"button"
@
click=
"form.platform = 'anthropic'"
@
click=
"form.platform = 'anthropic'"
...
@@ -141,7 +142,7 @@
...
@@ -141,7 +142,7 @@
<!-- Account Type Selection (Anthropic) -->
<!-- Account Type Selection (Anthropic) -->
<div
v-if=
"form.platform === 'anthropic'"
>
<div
v-if=
"form.platform === 'anthropic'"
>
<label
class=
"input-label"
>
{{
t
(
'
admin.accounts.accountType
'
)
}}
</label>
<label
class=
"input-label"
>
{{
t
(
'
admin.accounts.accountType
'
)
}}
</label>
<div
class=
"mt-2 grid grid-cols-2 gap-3"
>
<div
class=
"mt-2 grid grid-cols-2 gap-3"
data-tour=
"account-form-type"
>
<button
<button
type=
"button"
type=
"button"
@
click=
"accountCategory = 'oauth-based'"
@
click=
"accountCategory = 'oauth-based'"
...
@@ -231,7 +232,7 @@
...
@@ -231,7 +232,7 @@
<!-- Account Type Selection (OpenAI) -->
<!-- Account Type Selection (OpenAI) -->
<div
v-if=
"form.platform === 'openai'"
>
<div
v-if=
"form.platform === 'openai'"
>
<label
class=
"input-label"
>
{{
t
(
'
admin.accounts.accountType
'
)
}}
</label>
<label
class=
"input-label"
>
{{
t
(
'
admin.accounts.accountType
'
)
}}
</label>
<div
class=
"mt-2 grid grid-cols-2 gap-3"
>
<div
class=
"mt-2 grid grid-cols-2 gap-3"
data-tour=
"account-form-type"
>
<button
<button
type=
"button"
type=
"button"
@
click=
"accountCategory = 'oauth-based'"
@
click=
"accountCategory = 'oauth-based'"
...
@@ -313,7 +314,7 @@
...
@@ -313,7 +314,7 @@
<!-- Account Type Selection (Gemini) -->
<!-- Account Type Selection (Gemini) -->
<div
v-if=
"form.platform === 'gemini'"
>
<div
v-if=
"form.platform === 'gemini'"
>
<label
class=
"input-label"
>
{{
t
(
'
admin.accounts.accountType
'
)
}}
</label>
<label
class=
"input-label"
>
{{
t
(
'
admin.accounts.accountType
'
)
}}
</label>
<div
class=
"mt-2 grid grid-cols-2 gap-3"
>
<div
class=
"mt-2 grid grid-cols-2 gap-3"
data-tour=
"account-form-type"
>
<button
<button
type=
"button"
type=
"button"
@
click=
"accountCategory = 'oauth-based'"
@
click=
"accountCategory = 'oauth-based'"
...
@@ -959,18 +960,21 @@
...
@@ -959,18 +960,21 @@
<
/div
>
<
/div
>
<
div
>
<
div
>
<
label
class
=
"
input-label
"
>
{{
t
(
'
admin.accounts.priority
'
)
}}
<
/label
>
<
label
class
=
"
input-label
"
>
{{
t
(
'
admin.accounts.priority
'
)
}}
<
/label
>
<
input
v
-
model
.
number
=
"
form.priority
"
type
=
"
number
"
min
=
"
1
"
class
=
"
input
"
/>
<
input
v
-
model
.
number
=
"
form.priority
"
type
=
"
number
"
min
=
"
1
"
class
=
"
input
"
data
-
tour
=
"
account-form-priority
"
/>
<
p
class
=
"
input-hint
"
>
{{
t
(
'
admin.accounts.priorityHint
'
)
}}
<
/p
>
<
p
class
=
"
input-hint
"
>
{{
t
(
'
admin.accounts.priorityHint
'
)
}}
<
/p
>
<
/div
>
<
/div
>
<
/div
>
<
/div
>
<!--
Group
Selection
-
仅标准模式显示
-->
<!--
Group
Selection
-
仅标准模式显示
-->
<
GroupSelector
<
div
v
-
if
=
"
!authStore.isSimpleMode
"
data
-
tour
=
"
account-form-groups
"
>
v
-
if
=
"
!authStore.isSimpleMode
"
<
GroupSelector
v
-
model
=
"
form.group_ids
"
:
groups
=
"
groups
"
:
platform
=
"
form.platform
"
/>
v
-
model
=
"
form.group_ids
"
<
/div
>
:
groups
=
"
groups
"
:
platform
=
"
form.platform
"
/>
<
/form
>
<
/form
>
...
@@ -1005,6 +1009,7 @@
...
@@ -1005,6 +1009,7 @@
form
=
"
create-account-form
"
form
=
"
create-account-form
"
:
disabled
=
"
submitting
"
:
disabled
=
"
submitting
"
class
=
"
btn btn-primary
"
class
=
"
btn btn-primary
"
data
-
tour
=
"
account-form-submit
"
>
>
<
svg
<
svg
v
-
if
=
"
submitting
"
v
-
if
=
"
submitting
"
...
...
frontend/src/components/account/EditAccountModal.vue
View file @
b63b338e
...
@@ -2,7 +2,7 @@
...
@@ -2,7 +2,7 @@
<BaseDialog
<BaseDialog
:show=
"show"
:show=
"show"
:title=
"t('admin.accounts.editAccount')"
:title=
"t('admin.accounts.editAccount')"
width=
"
wide
"
width=
"
normal
"
@
close=
"handleClose"
@
close=
"handleClose"
>
>
<form
<form
...
@@ -13,7 +13,7 @@
...
@@ -13,7 +13,7 @@
>
>
<div>
<div>
<label
class=
"input-label"
>
{{
t
(
'
common.name
'
)
}}
</label>
<label
class=
"input-label"
>
{{
t
(
'
common.name
'
)
}}
</label>
<input
v-model=
"form.name"
type=
"text"
required
class=
"input"
/>
<input
v-model=
"form.name"
type=
"text"
required
class=
"input"
data-tour=
"edit-account-form-name"
/>
</div>
</div>
<!-- API Key fields (only for apikey type) -->
<!-- API Key fields (only for apikey type) -->
...
@@ -457,7 +457,13 @@
...
@@ -457,7 +457,13 @@
<
/div
>
<
/div
>
<
div
>
<
div
>
<
label
class
=
"
input-label
"
>
{{
t
(
'
admin.accounts.priority
'
)
}}
<
/label
>
<
label
class
=
"
input-label
"
>
{{
t
(
'
admin.accounts.priority
'
)
}}
<
/label
>
<
input
v
-
model
.
number
=
"
form.priority
"
type
=
"
number
"
min
=
"
1
"
class
=
"
input
"
/>
<
input
v
-
model
.
number
=
"
form.priority
"
type
=
"
number
"
min
=
"
1
"
class
=
"
input
"
data
-
tour
=
"
account-form-priority
"
/>
<
/div
>
<
/div
>
<
/div
>
<
/div
>
...
@@ -467,12 +473,9 @@
...
@@ -467,12 +473,9 @@
<
/div
>
<
/div
>
<!--
Group
Selection
-
仅标准模式显示
-->
<!--
Group
Selection
-
仅标准模式显示
-->
<
GroupSelector
<
div
v
-
if
=
"
!authStore.isSimpleMode
"
data
-
tour
=
"
account-form-groups
"
>
v
-
if
=
"
!authStore.isSimpleMode
"
<
GroupSelector
v
-
model
=
"
form.group_ids
"
:
groups
=
"
groups
"
:
platform
=
"
account?.platform
"
/>
v
-
model
=
"
form.group_ids
"
<
/div
>
:
groups
=
"
groups
"
:
platform
=
"
account?.platform
"
/>
<
/form
>
<
/form
>
...
@@ -486,6 +489,7 @@
...
@@ -486,6 +489,7 @@
form
=
"
edit-account-form
"
form
=
"
edit-account-form
"
:
disabled
=
"
submitting
"
:
disabled
=
"
submitting
"
class
=
"
btn btn-primary
"
class
=
"
btn btn-primary
"
data
-
tour
=
"
account-form-submit
"
>
>
<
svg
<
svg
v
-
if
=
"
submitting
"
v
-
if
=
"
submitting
"
...
...
frontend/src/components/account/ReAuthAccountModal.vue
View file @
b63b338e
...
@@ -2,7 +2,7 @@
...
@@ -2,7 +2,7 @@
<BaseDialog
<BaseDialog
:show=
"show"
:show=
"show"
:title=
"t('admin.accounts.reAuthorizeAccount')"
:title=
"t('admin.accounts.reAuthorizeAccount')"
width=
"
wide
"
width=
"
normal
"
@
close=
"handleClose"
@
close=
"handleClose"
>
>
<div
v-if=
"account"
class=
"space-y-4"
>
<div
v-if=
"account"
class=
"space-y-4"
>
...
...
frontend/src/components/account/SyncFromCrsModal.vue
View file @
b63b338e
...
@@ -151,6 +151,10 @@ watch(
...
@@ -151,6 +151,10 @@ watch(
)
)
const
handleClose
=
()
=>
{
const
handleClose
=
()
=>
{
// 防止在同步进行中关闭对话框
if
(
syncing
.
value
)
{
return
}
emit
(
'
close
'
)
emit
(
'
close
'
)
}
}
...
...
frontend/src/components/common/BaseDialog.vue
View file @
b63b338e
<
template
>
<
template
>
<Teleport
to=
"body"
>
<Teleport
to=
"body"
>
<div
<Transition
name=
"modal"
>
v-if=
"show"
<div
class=
"modal-overlay"
v-if=
"show"
aria-labelledby=
"modal-title"
class=
"modal-overlay"
role=
"dialog"
:aria-labelledby=
"dialogId"
aria-modal=
"true"
role=
"dialog"
@
click.self=
"handleClose"
aria-modal=
"true"
>
@
click.self=
"handleClose"
<!-- Modal panel -->
>
<div
:class=
"['modal-content', widthClasses]"
@
click.stop
>
<!-- Modal panel -->
<!-- Header -->
<div
ref=
"dialogRef"
:class=
"['modal-content', widthClasses]"
@
click.stop
>
<div
class=
"modal-header"
>
<!-- Header -->
<h3
id=
"modal-title"
class=
"modal-title"
>
<div
class=
"modal-header"
>
{{
title
}}
<h3
:id=
"dialogId"
class=
"modal-title"
>
</h3>
{{
title
}}
<button
</h3>
@
click=
"emit('close')"
<button
class=
"-mr-2 rounded-xl p-2 text-gray-400 transition-colors hover:bg-gray-100 hover:text-gray-600 dark:text-dark-500 dark:hover:bg-dark-700 dark:hover:text-dark-300"
@
click=
"emit('close')"
aria-label=
"Close modal"
class=
"-mr-2 rounded-xl p-2 text-gray-400 transition-colors hover:bg-gray-100 hover:text-gray-600 dark:text-dark-500 dark:hover:bg-dark-700 dark:hover:text-dark-300"
>
aria-label=
"Close modal"
<svg
class=
"h-5 w-5"
fill=
"none"
stroke=
"currentColor"
viewBox=
"0 0 24 24"
stroke-width=
"1.5"
>
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M6 18L18 6M6 6l12 12"
/>
<svg
</svg>
class=
"h-5 w-5"
</button>
fill=
"none"
</div>
stroke=
"currentColor"
viewBox=
"0 0 24 24"
stroke-width=
"1.5"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
<!-- Body -->
<!-- Body -->
<div
class=
"modal-body"
>
<div
class=
"modal-body"
>
<slot></slot>
<slot></slot>
</div>
</div>
<!-- Footer -->
<!-- Footer -->
<div
v-if=
"$slots.footer"
class=
"modal-footer"
>
<div
v-if=
"$slots.footer"
class=
"modal-footer"
>
<slot
name=
"footer"
></slot>
<slot
name=
"footer"
></slot>
</div>
</div>
</div>
</div>
</div>
</
div
>
</
Transition
>
</Teleport>
</Teleport>
</
template
>
</
template
>
<
script
setup
lang=
"ts"
>
<
script
setup
lang=
"ts"
>
import
{
computed
,
watch
,
onMounted
,
onUnmounted
}
from
'
vue
'
import
{
computed
,
watch
,
onMounted
,
onUnmounted
,
ref
,
nextTick
}
from
'
vue
'
// 生成唯一ID以避免多个对话框时ID冲突
let
dialogIdCounter
=
0
const
dialogId
=
`modal-title-
${
++
dialogIdCounter
}
`
// 焦点管理
const
dialogRef
=
ref
<
HTMLElement
|
null
>
(
null
)
let
previousActiveElement
:
HTMLElement
|
null
=
null
type
DialogWidth
=
'
narrow
'
|
'
normal
'
|
'
wide
'
|
'
extra-wide
'
|
'
full
'
type
DialogWidth
=
'
narrow
'
|
'
normal
'
|
'
wide
'
|
'
extra-wide
'
|
'
full
'
...
@@ -72,12 +82,15 @@ const props = withDefaults(defineProps<Props>(), {
...
@@ -72,12 +82,15 @@ const props = withDefaults(defineProps<Props>(), {
const
emit
=
defineEmits
<
Emits
>
()
const
emit
=
defineEmits
<
Emits
>
()
const
widthClasses
=
computed
(()
=>
{
const
widthClasses
=
computed
(()
=>
{
// Width guidance: narrow=confirm/short prompts, normal=standard forms,
// wide=multi-section forms or rich content, extra-wide=analytics/tables,
// full=full-screen or very dense layouts.
const
widths
:
Record
<
DialogWidth
,
string
>
=
{
const
widths
:
Record
<
DialogWidth
,
string
>
=
{
narrow
:
'
max-w-md
'
,
narrow
:
'
max-w-md
'
,
normal
:
'
max-w-lg
'
,
normal
:
'
max-w-lg
'
,
wide
:
'
max-w-4xl
'
,
wide
:
'
w-full sm:max-w-2xl md:max-w-3xl lg:
max-w-4xl
'
,
'
extra-wide
'
:
'
max-w-6xl
'
,
'
extra-wide
'
:
'
w-full sm:max-w-3xl md:max-w-4xl lg:max-w-5xl xl:
max-w-6xl
'
,
full
:
'
max-w-7xl
'
full
:
'
w-full sm:max-w-4xl md:max-w-5xl lg:max-w-6xl xl:
max-w-7xl
'
}
}
return
widths
[
props
.
width
]
return
widths
[
props
.
width
]
})
})
...
@@ -94,14 +107,31 @@ const handleEscape = (event: KeyboardEvent) => {
...
@@ -94,14 +107,31 @@ const handleEscape = (event: KeyboardEvent) => {
}
}
}
}
// Prevent body scroll when modal is open
// Prevent body scroll when modal is open
and manage focus
watch
(
watch
(
()
=>
props
.
show
,
()
=>
props
.
show
,
(
isOpen
)
=>
{
async
(
isOpen
)
=>
{
if
(
isOpen
)
{
if
(
isOpen
)
{
document
.
body
.
style
.
overflow
=
'
hidden
'
// 保存当前焦点元素
previousActiveElement
=
document
.
activeElement
as
HTMLElement
// 使用CSS类而不是直接操作style,更易于管理多个对话框
document
.
body
.
classList
.
add
(
'
modal-open
'
)
// 等待DOM更新后设置焦点到对话框
await
nextTick
()
if
(
dialogRef
.
value
)
{
const
firstFocusable
=
dialogRef
.
value
.
querySelector
<
HTMLElement
>
(
'
button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])
'
)
firstFocusable
?.
focus
()
}
}
else
{
}
else
{
document
.
body
.
style
.
overflow
=
''
document
.
body
.
classList
.
remove
(
'
modal-open
'
)
// 恢复之前的焦点
if
(
previousActiveElement
&&
typeof
previousActiveElement
.
focus
===
'
function
'
)
{
previousActiveElement
.
focus
()
}
previousActiveElement
=
null
}
}
},
},
{
immediate
:
true
}
{
immediate
:
true
}
...
@@ -113,6 +143,7 @@ onMounted(() => {
...
@@ -113,6 +143,7 @@ onMounted(() => {
onUnmounted
(()
=>
{
onUnmounted
(()
=>
{
document
.
removeEventListener
(
'
keydown
'
,
handleEscape
)
document
.
removeEventListener
(
'
keydown
'
,
handleEscape
)
document
.
body
.
style
.
overflow
=
''
// 确保组件卸载时移除滚动锁定
document
.
body
.
classList
.
remove
(
'
modal-open
'
)
})
})
</
script
>
</
script
>
frontend/src/components/common/ExportProgressDialog.vue
0 → 100644
View file @
b63b338e
<
template
>
<BaseDialog
:show=
"show"
:title=
"t('usage.exporting')"
width=
"narrow"
@
close=
"handleCancel"
>
<div
class=
"space-y-4"
>
<div
class=
"text-sm text-gray-600 dark:text-gray-400"
>
{{
t
(
'
usage.exportingProgress
'
)
}}
</div>
<div
class=
"flex items-center justify-between text-sm text-gray-700 dark:text-gray-300"
>
<span>
{{
t
(
'
usage.exportedCount
'
,
{
current
,
total
}
)
}}
<
/span
>
<
span
class
=
"
font-medium text-gray-900 dark:text-white
"
>
{{
normalizedProgress
}}
%<
/span
>
<
/div
>
<
div
class
=
"
h-2 w-full rounded-full bg-gray-200 dark:bg-dark-700
"
>
<
div
role
=
"
progressbar
"
:
aria
-
valuenow
=
"
normalizedProgress
"
aria
-
valuemin
=
"
0
"
aria
-
valuemax
=
"
100
"
:
aria
-
label
=
"
`${t('usage.exportingProgress')
}
: ${normalizedProgress
}
%`
"
class
=
"
h-2 rounded-full bg-primary-600 transition-all
"
:
style
=
"
{ width: `${normalizedProgress
}
%`
}
"
><
/div
>
<
/div
>
<
div
v
-
if
=
"
estimatedTime
"
class
=
"
text-xs text-gray-500 dark:text-gray-400
"
aria
-
live
=
"
polite
"
aria
-
atomic
=
"
true
"
>
{{
t
(
'
usage.estimatedTime
'
,
{
time
:
estimatedTime
}
)
}}
<
/div
>
<
/div
>
<
template
#
footer
>
<
button
@
click
=
"
handleCancel
"
type
=
"
button
"
class
=
"
rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2 dark:border-dark-600 dark:bg-dark-700 dark:text-gray-200 dark:hover:bg-dark-600 dark:focus:ring-offset-dark-800
"
>
{{
t
(
'
usage.cancelExport
'
)
}}
<
/button
>
<
/template
>
<
/BaseDialog
>
<
/template
>
<
script
setup
lang
=
"
ts
"
>
import
{
computed
}
from
'
vue
'
import
{
useI18n
}
from
'
vue-i18n
'
import
BaseDialog
from
'
./BaseDialog.vue
'
interface
Props
{
show
:
boolean
progress
:
number
current
:
number
total
:
number
estimatedTime
:
string
}
interface
Emits
{
(
e
:
'
cancel
'
):
void
}
const
props
=
defineProps
<
Props
>
()
const
emit
=
defineEmits
<
Emits
>
()
const
{
t
}
=
useI18n
()
const
normalizedProgress
=
computed
(()
=>
{
const
value
=
Number
.
isFinite
(
props
.
progress
)
?
props
.
progress
:
0
return
Math
.
min
(
100
,
Math
.
max
(
0
,
Math
.
round
(
value
)))
}
)
const
handleCancel
=
()
=>
{
emit
(
'
cancel
'
)
}
<
/script
>
frontend/src/components/common/Modal.vue
deleted
100644 → 0
View file @
57db688d
<
template
>
<Teleport
to=
"body"
>
<div
v-if=
"show"
class=
"modal-overlay"
aria-labelledby=
"modal-title"
role=
"dialog"
aria-modal=
"true"
@
click.self=
"handleClose"
>
<!-- Modal panel -->
<div
:class=
"['modal-content', sizeClasses]"
@
click.stop
>
<!-- Header -->
<div
class=
"modal-header"
>
<h3
id=
"modal-title"
class=
"modal-title"
>
{{
title
}}
</h3>
<button
@
click=
"emit('close')"
class=
"-mr-2 rounded-xl p-2 text-gray-400 transition-colors hover:bg-gray-100 hover:text-gray-600 dark:text-dark-500 dark:hover:bg-dark-700 dark:hover:text-dark-300"
aria-label=
"Close modal"
>
<svg
class=
"h-5 w-5"
fill=
"none"
stroke=
"currentColor"
viewBox=
"0 0 24 24"
stroke-width=
"1.5"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
<!-- Body -->
<div
class=
"modal-body"
>
<slot></slot>
</div>
<!-- Footer -->
<div
v-if=
"$slots.footer"
class=
"modal-footer"
>
<slot
name=
"footer"
></slot>
</div>
</div>
</div>
</Teleport>
</
template
>
<
script
setup
lang=
"ts"
>
import
{
computed
,
watch
,
onMounted
,
onUnmounted
}
from
'
vue
'
type
ModalSize
=
'
sm
'
|
'
md
'
|
'
lg
'
|
'
xl
'
|
'
2xl
'
|
'
full
'
interface
Props
{
show
:
boolean
title
:
string
size
?:
ModalSize
closeOnEscape
?:
boolean
closeOnClickOutside
?:
boolean
}
interface
Emits
{
(
e
:
'
close
'
):
void
}
const
props
=
withDefaults
(
defineProps
<
Props
>
(),
{
size
:
'
md
'
,
closeOnEscape
:
true
,
closeOnClickOutside
:
false
})
const
emit
=
defineEmits
<
Emits
>
()
const
sizeClasses
=
computed
(()
=>
{
const
sizes
:
Record
<
ModalSize
,
string
>
=
{
sm
:
'
max-w-sm
'
,
md
:
'
max-w-md
'
,
lg
:
'
max-w-lg
'
,
xl
:
'
max-w-xl
'
,
'
2xl
'
:
'
max-w-5xl
'
,
full
:
'
max-w-4xl
'
}
return
sizes
[
props
.
size
]
})
const
handleClose
=
()
=>
{
if
(
props
.
closeOnClickOutside
)
{
emit
(
'
close
'
)
}
}
const
handleEscape
=
(
event
:
KeyboardEvent
)
=>
{
if
(
props
.
show
&&
props
.
closeOnEscape
&&
event
.
key
===
'
Escape
'
)
{
emit
(
'
close
'
)
}
}
// Prevent body scroll when modal is open
watch
(
()
=>
props
.
show
,
(
isOpen
)
=>
{
console
.
log
(
'
[Modal] show changed to:
'
,
isOpen
)
if
(
isOpen
)
{
document
.
body
.
style
.
overflow
=
'
hidden
'
}
else
{
document
.
body
.
style
.
overflow
=
''
}
},
{
immediate
:
true
}
)
onMounted
(()
=>
{
document
.
addEventListener
(
'
keydown
'
,
handleEscape
)
})
onUnmounted
(()
=>
{
document
.
removeEventListener
(
'
keydown
'
,
handleEscape
)
document
.
body
.
style
.
overflow
=
''
})
</
script
>
frontend/src/components/common/Select.vue
View file @
b63b338e
...
@@ -29,67 +29,73 @@
...
@@ -29,67 +29,73 @@
</span>
</span>
</button>
</button>
<Transition
name=
"select-dropdown"
>
<!-- Teleport dropdown to body to escape stacking context (for driver.js overlay compatibility) -->
<div
<Teleport
to=
"body"
>
v-if=
"isOpen"
<Transition
name=
"select-dropdown"
>
ref=
"dropdownRef"
<div
:class=
"['select-dropdown', dropdownPosition === 'top' && 'select-dropdown-top']"
v-if=
"isOpen"
>
ref=
"dropdownRef"
<!-- Search input -->
class=
"select-dropdown-portal"
<div
v-if=
"searchable"
class=
"select-search"
>
:style=
"dropdownStyle"
<svg
@
click.stop
class=
"h-4 w-4 text-gray-400"
@
mousedown.stop
fill=
"none"
>
stroke=
"currentColor"
<!-- Search input -->
viewBox=
"0 0 24 24"
<div
v-if=
"searchable"
class=
"select-search"
>
stroke-width=
"1.5"
<svg
>
class=
"h-4 w-4 text-gray-400"
<path
fill=
"none"
stroke-linecap=
"round"
stroke=
"currentColor"
stroke-linejoin=
"round"
viewBox=
"0 0 24 24"
d=
"M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607z"
stroke-width=
"1.5"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607z"
/>
</svg>
<input
ref=
"searchInputRef"
v-model=
"searchQuery"
type=
"text"
:placeholder=
"searchPlaceholderText"
class=
"select-search-input"
@
click.stop
/>
/>
</svg>
<input
ref=
"searchInputRef"
v-model=
"searchQuery"
type=
"text"
:placeholder=
"searchPlaceholderText"
class=
"select-search-input"
@
click.stop
/>
</div>
<!-- Options list -->
<div
class=
"select-options"
>
<div
v-for=
"option in filteredOptions"
:key=
"`$
{typeof getOptionValue(option)}:${String(getOptionValue(option) ?? '')}`"
@click="selectOption(option)"
:class="['select-option', isSelected(option)
&&
'select-option-selected']"
>
<slot
name=
"option"
:option=
"option"
:selected=
"isSelected(option)"
>
<span
class=
"select-option-label"
>
{{
getOptionLabel
(
option
)
}}
</span>
<svg
v-if=
"isSelected(option)"
class=
"h-4 w-4 text-primary-500"
fill=
"none"
stroke=
"currentColor"
viewBox=
"0 0 24 24"
stroke-width=
"2"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M4.5 12.75l6 6 9-13.5"
/>
</svg>
</slot>
</div>
</div>
<!-- Empty state -->
<!-- Options list -->
<div
v-if=
"filteredOptions.length === 0"
class=
"select-empty"
>
<div
class=
"select-options"
>
{{
emptyTextDisplay
}}
<div
v-for=
"option in filteredOptions"
:key=
"`$
{typeof getOptionValue(option)}:${String(getOptionValue(option) ?? '')}`"
@click.stop="selectOption(option)"
:class="['select-option', isSelected(option)
&&
'select-option-selected']"
>
<slot
name=
"option"
:option=
"option"
:selected=
"isSelected(option)"
>
<span
class=
"select-option-label"
>
{{
getOptionLabel
(
option
)
}}
</span>
<svg
v-if=
"isSelected(option)"
class=
"h-4 w-4 text-primary-500"
fill=
"none"
stroke=
"currentColor"
viewBox=
"0 0 24 24"
stroke-width=
"2"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M4.5 12.75l6 6 9-13.5"
/>
</svg>
</slot>
</div>
<!-- Empty state -->
<div
v-if=
"filteredOptions.length === 0"
class=
"select-empty"
>
{{
emptyTextDisplay
}}
</div>
</div>
</div>
</div>
</div>
</
div
>
</
Transition
>
</T
ransition
>
</T
eleport
>
</div>
</div>
</
template
>
</
template
>
...
@@ -147,6 +153,28 @@ const containerRef = ref<HTMLElement | null>(null)
...
@@ -147,6 +153,28 @@ const containerRef = ref<HTMLElement | null>(null)
const
searchInputRef
=
ref
<
HTMLInputElement
|
null
>
(
null
)
const
searchInputRef
=
ref
<
HTMLInputElement
|
null
>
(
null
)
const
dropdownRef
=
ref
<
HTMLElement
|
null
>
(
null
)
const
dropdownRef
=
ref
<
HTMLElement
|
null
>
(
null
)
const
dropdownPosition
=
ref
<
'
bottom
'
|
'
top
'
>
(
'
bottom
'
)
const
dropdownPosition
=
ref
<
'
bottom
'
|
'
top
'
>
(
'
bottom
'
)
const
triggerRect
=
ref
<
DOMRect
|
null
>
(
null
)
// Computed style for teleported dropdown
const
dropdownStyle
=
computed
(()
=>
{
if
(
!
triggerRect
.
value
)
return
{}
const
rect
=
triggerRect
.
value
const
style
:
Record
<
string
,
string
>
=
{
position
:
'
fixed
'
,
left
:
`
${
rect
.
left
}
px`
,
minWidth
:
`
${
rect
.
width
}
px`
,
zIndex
:
'
100000020
'
// Higher than driver.js overlay (99999998)
}
if
(
dropdownPosition
.
value
===
'
top
'
)
{
style
.
bottom
=
`
${
window
.
innerHeight
-
rect
.
top
+
8
}
px`
}
else
{
style
.
top
=
`
${
rect
.
bottom
+
8
}
px`
}
return
style
})
const
getOptionValue
=
(
const
getOptionValue
=
(
option
:
SelectOption
|
Record
<
string
,
unknown
>
option
:
SelectOption
|
Record
<
string
,
unknown
>
...
@@ -193,14 +221,17 @@ const isSelected = (option: SelectOption | Record<string, unknown>): boolean =>
...
@@ -193,14 +221,17 @@ const isSelected = (option: SelectOption | Record<string, unknown>): boolean =>
const
calculateDropdownPosition
=
()
=>
{
const
calculateDropdownPosition
=
()
=>
{
if
(
!
containerRef
.
value
)
return
if
(
!
containerRef
.
value
)
return
// Update trigger rect for positioning
triggerRect
.
value
=
containerRef
.
value
.
getBoundingClientRect
()
nextTick
(()
=>
{
nextTick
(()
=>
{
if
(
!
containerRef
.
value
||
!
dropdownRef
.
value
)
return
if
(
!
containerRef
.
value
||
!
dropdownRef
.
value
)
return
const
triggerRect
=
contain
erRe
f
.
value
.
getBoundingClientRect
()
const
rect
=
trigg
erRe
ct
.
value
!
const
dropdownHeight
=
dropdownRef
.
value
.
offsetHeight
||
240
// Max height fallback
const
dropdownHeight
=
dropdownRef
.
value
.
offsetHeight
||
240
// Max height fallback
const
viewportHeight
=
window
.
innerHeight
const
viewportHeight
=
window
.
innerHeight
const
spaceBelow
=
viewportHeight
-
triggerR
ect
.
bottom
const
spaceBelow
=
viewportHeight
-
r
ect
.
bottom
const
spaceAbove
=
triggerR
ect
.
top
const
spaceAbove
=
r
ect
.
top
// If not enough space below but enough space above, show dropdown on top
// If not enough space below but enough space above, show dropdown on top
if
(
spaceBelow
<
dropdownHeight
&&
spaceAbove
>
dropdownHeight
)
{
if
(
spaceBelow
<
dropdownHeight
&&
spaceAbove
>
dropdownHeight
)
{
...
@@ -233,10 +264,21 @@ const selectOption = (option: SelectOption | Record<string, unknown>) => {
...
@@ -233,10 +264,21 @@ const selectOption = (option: SelectOption | Record<string, unknown>) => {
}
}
const
handleClickOutside
=
(
event
:
MouseEvent
)
=>
{
const
handleClickOutside
=
(
event
:
MouseEvent
)
=>
{
if
(
containerRef
.
value
&&
!
containerRef
.
value
.
contains
(
event
.
target
as
Node
))
{
const
target
=
event
.
target
as
HTMLElement
isOpen
.
value
=
false
searchQuery
.
value
=
''
// 使用 closest 检查点击是否在下拉菜单内部(更可靠,不依赖 ref)
if
(
target
.
closest
(
'
.select-dropdown-portal
'
))
{
return
// 点击在下拉菜单内,不关闭
}
}
// 检查是否点击在触发器内
if
(
containerRef
.
value
&&
containerRef
.
value
.
contains
(
target
))
{
return
// 点击在触发器内,让 toggle 处理
}
// 点击在外部,关闭下拉菜单
isOpen
.
value
=
false
searchQuery
.
value
=
''
}
}
const
handleEscape
=
(
event
:
KeyboardEvent
)
=>
{
const
handleEscape
=
(
event
:
KeyboardEvent
)
=>
{
...
@@ -295,54 +337,57 @@ onUnmounted(() => {
...
@@ -295,54 +337,57 @@ onUnmounted(() => {
.select-icon
{
.select-icon
{
@apply
flex-shrink-0
text-gray-400
dark
:
text-dark-400
;
@apply
flex-shrink-0
text-gray-400
dark
:
text-dark-400
;
}
}
</
style
>
.select-dropdown
{
<!-- Global styles for teleported dropdown -->
@apply
absolute
left-0
z-[100]
mt-2
min-w-full
w-max
max-w-[300px];
<
style
>
.select-dropdown-portal
{
@apply
w-max
max-w-[300px];
@apply
bg-white
dark
:
bg-dark-800
;
@apply
bg-white
dark
:
bg-dark-800
;
@apply
rounded-xl;
@apply
rounded-xl;
@apply
border
border-gray-200
dark
:
border-dark-700
;
@apply
border
border-gray-200
dark
:
border-dark-700
;
@apply
shadow-lg
shadow-black/10
dark
:
shadow-black
/
30
;
@apply
shadow-lg
shadow-black/10
dark
:
shadow-black
/
30
;
@apply
overflow-hidden;
@apply
overflow-hidden;
/* 确保下拉菜单在引导期间可点击(覆盖 driver.js 的 pointer-events 影响) */
pointer-events
:
auto
!important
;
}
}
.select-dropdown-top
{
.select-dropdown-portal
.select-search
{
@apply
bottom-full
mb-2
mt-0;
}
.select-search
{
@apply
flex
items-center
gap-2
px-3
py-2;
@apply
flex
items-center
gap-2
px-3
py-2;
@apply
border-b
border-gray-100
dark
:
border-dark-700
;
@apply
border-b
border-gray-100
dark
:
border-dark-700
;
}
}
.select-search-input
{
.select-dropdown-portal
.select-search-input
{
@apply
flex-1
bg-transparent
text-sm;
@apply
flex-1
bg-transparent
text-sm;
@apply
text-gray-900
dark
:
text-gray-100
;
@apply
text-gray-900
dark
:
text-gray-100
;
@apply
placeholder
:
text-gray-400
dark
:
placeholder
:
text-dark-400
;
@apply
placeholder
:
text-gray-400
dark
:
placeholder
:
text-dark-400
;
@apply
focus
:
outline-none
;
@apply
focus
:
outline-none
;
}
}
.select-options
{
.select-dropdown-portal
.select-options
{
@apply
max-h-60
overflow-y-auto
py-1;
@apply
max-h-60
overflow-y-auto
py-1;
}
}
.select-option
{
.select-dropdown-portal
.select-option
{
@apply
flex
items-center
justify-between
gap-2;
@apply
flex
items-center
justify-between
gap-2;
@apply
px-4
py-2.5
text-sm;
@apply
px-4
py-2.5
text-sm;
@apply
text-gray-700
dark
:
text-gray-300
;
@apply
text-gray-700
dark
:
text-gray-300
;
@apply
cursor-pointer
transition-colors
duration-150;
@apply
cursor-pointer
transition-colors
duration-150;
@apply
hover
:
bg-gray-50
dark
:
hover
:
bg-dark-700
;
@apply
hover
:
bg-gray-50
dark
:
hover
:
bg-dark-700
;
/* 确保选项在引导期间可点击 */
pointer-events
:
auto
!important
;
}
}
.select-option-selected
{
.select-dropdown-portal
.select-option-selected
{
@apply
bg-primary-50
dark
:
bg-primary-900
/
20
;
@apply
bg-primary-50
dark
:
bg-primary-900
/
20
;
@apply
text-primary-700
dark
:
text-primary-300
;
@apply
text-primary-700
dark
:
text-primary-300
;
}
}
.select-option-label
{
.select-dropdown-portal
.select-option-label
{
@apply
flex-1
min-w-0
truncate
text-left;
@apply
flex-1
min-w-0
truncate
text-left;
}
}
.select-empty
{
.select-dropdown-portal
.select-empty
{
@apply
px-4
py-8
text-center
text-sm;
@apply
px-4
py-8
text-center
text-sm;
@apply
text-gray-500
dark
:
text-dark-400
;
@apply
text-gray-500
dark
:
text-dark-400
;
}
}
...
@@ -356,17 +401,6 @@ onUnmounted(() => {
...
@@ -356,17 +401,6 @@ onUnmounted(() => {
.select-dropdown-enter-from
,
.select-dropdown-enter-from
,
.select-dropdown-leave-to
{
.select-dropdown-leave-to
{
opacity
:
0
;
opacity
:
0
;
}
/* Animation for dropdown opening downward (default) */
.select-dropdown
:not
(
.select-dropdown-top
)
.select-dropdown-enter-from
,
.select-dropdown
:not
(
.select-dropdown-top
)
.select-dropdown-leave-to
{
transform
:
translateY
(
-8px
);
transform
:
translateY
(
-8px
);
}
}
/* Animation for dropdown opening upward */
.select-dropdown-top.select-dropdown-enter-from
,
.select-dropdown-top.select-dropdown-leave-to
{
transform
:
translateY
(
8px
);
}
</
style
>
</
style
>
frontend/src/components/common/index.ts
View file @
b63b338e
// Export all common components
// Export all common components
export
{
default
as
DataTable
}
from
'
./DataTable.vue
'
export
{
default
as
DataTable
}
from
'
./DataTable.vue
'
export
{
default
as
Pagination
}
from
'
./Pagination.vue
'
export
{
default
as
Pagination
}
from
'
./Pagination.vue
'
export
{
default
as
Modal
}
from
'
./Modal.vue
'
export
{
default
as
BaseDialog
}
from
'
./BaseDialog.vue
'
export
{
default
as
BaseDialog
}
from
'
./BaseDialog.vue
'
export
{
default
as
ConfirmDialog
}
from
'
./ConfirmDialog.vue
'
export
{
default
as
ConfirmDialog
}
from
'
./ConfirmDialog.vue
'
export
{
default
as
StatCard
}
from
'
./StatCard.vue
'
export
{
default
as
StatCard
}
from
'
./StatCard.vue
'
...
@@ -9,6 +8,7 @@ export { default as Toast } from './Toast.vue'
...
@@ -9,6 +8,7 @@ export { default as Toast } from './Toast.vue'
export
{
default
as
LoadingSpinner
}
from
'
./LoadingSpinner.vue
'
export
{
default
as
LoadingSpinner
}
from
'
./LoadingSpinner.vue
'
export
{
default
as
EmptyState
}
from
'
./EmptyState.vue
'
export
{
default
as
EmptyState
}
from
'
./EmptyState.vue
'
export
{
default
as
LocaleSwitcher
}
from
'
./LocaleSwitcher.vue
'
export
{
default
as
LocaleSwitcher
}
from
'
./LocaleSwitcher.vue
'
export
{
default
as
ExportProgressDialog
}
from
'
./ExportProgressDialog.vue
'
// Export types
// Export types
export
type
{
Column
}
from
'
./types
'
export
type
{
Column
}
from
'
./types
'
frontend/src/components/layout/AppHeader.vue
View file @
b63b338e
...
@@ -199,6 +199,17 @@
...
@@ -199,6 +199,17 @@
</div>
</div>
</div>
</div>
<div
v-if=
"showOnboardingButton"
class=
"border-t border-gray-100 py-1 dark:border-dark-700"
>
<button
@
click=
"handleReplayGuide"
class=
"dropdown-item w-full"
>
<svg
class=
"h-4 w-4"
fill=
"currentColor"
viewBox=
"0 0 24 24"
>
<path
d=
"M12 2a10 10 0 100 20 10 10 0 000-20zm0 14a1 1 0 110 2 1 1 0 010-2zm1.07-7.75c0-.6-.49-1.25-1.32-1.25-.7 0-1.22.4-1.43 1.02a1 1 0 11-1.9-.62A3.41 3.41 0 0111.8 5c2.02 0 3.25 1.4 3.25 2.9 0 2-1.83 2.55-2.43 3.12-.43.4-.47.75-.47 1.23a1 1 0 01-2 0c0-1 .16-1.82 1.1-2.7.69-.64 1.82-1.05 1.82-2.06z"
/>
</svg>
{{
$t
(
'
onboarding.restartTour
'
)
}}
</button>
</div>
<div
class=
"border-t border-gray-100 py-1 dark:border-dark-700"
>
<div
class=
"border-t border-gray-100 py-1 dark:border-dark-700"
>
<button
<button
@
click=
"handleLogout"
@
click=
"handleLogout"
...
@@ -232,7 +243,7 @@
...
@@ -232,7 +243,7 @@
import
{
ref
,
computed
,
onMounted
,
onBeforeUnmount
}
from
'
vue
'
import
{
ref
,
computed
,
onMounted
,
onBeforeUnmount
}
from
'
vue
'
import
{
useRouter
,
useRoute
}
from
'
vue-router
'
import
{
useRouter
,
useRoute
}
from
'
vue-router
'
import
{
useI18n
}
from
'
vue-i18n
'
import
{
useI18n
}
from
'
vue-i18n
'
import
{
useAppStore
,
useAuthStore
}
from
'
@/stores
'
import
{
useAppStore
,
useAuthStore
,
useOnboardingStore
}
from
'
@/stores
'
import
LocaleSwitcher
from
'
@/components/common/LocaleSwitcher.vue
'
import
LocaleSwitcher
from
'
@/components/common/LocaleSwitcher.vue
'
import
SubscriptionProgressMini
from
'
@/components/common/SubscriptionProgressMini.vue
'
import
SubscriptionProgressMini
from
'
@/components/common/SubscriptionProgressMini.vue
'
...
@@ -241,12 +252,18 @@ const route = useRoute()
...
@@ -241,12 +252,18 @@ const route = useRoute()
const
{
t
}
=
useI18n
()
const
{
t
}
=
useI18n
()
const
appStore
=
useAppStore
()
const
appStore
=
useAppStore
()
const
authStore
=
useAuthStore
()
const
authStore
=
useAuthStore
()
const
onboardingStore
=
useOnboardingStore
()
const
user
=
computed
(()
=>
authStore
.
user
)
const
user
=
computed
(()
=>
authStore
.
user
)
const
dropdownOpen
=
ref
(
false
)
const
dropdownOpen
=
ref
(
false
)
const
dropdownRef
=
ref
<
HTMLElement
|
null
>
(
null
)
const
dropdownRef
=
ref
<
HTMLElement
|
null
>
(
null
)
const
contactInfo
=
computed
(()
=>
appStore
.
contactInfo
)
const
contactInfo
=
computed
(()
=>
appStore
.
contactInfo
)
// 只在标准模式的管理员下显示新手引导按钮
const
showOnboardingButton
=
computed
(()
=>
{
return
!
authStore
.
isSimpleMode
&&
user
.
value
?.
role
===
'
admin
'
})
const
userInitials
=
computed
(()
=>
{
const
userInitials
=
computed
(()
=>
{
if
(
!
user
.
value
)
return
''
if
(
!
user
.
value
)
return
''
// Prefer username, fallback to email
// Prefer username, fallback to email
...
@@ -300,6 +317,11 @@ async function handleLogout() {
...
@@ -300,6 +317,11 @@ async function handleLogout() {
await
router
.
push
(
'
/login
'
)
await
router
.
push
(
'
/login
'
)
}
}
function
handleReplayGuide
()
{
closeDropdown
()
onboardingStore
.
replay
()
}
function
handleClickOutside
(
event
:
MouseEvent
)
{
function
handleClickOutside
(
event
:
MouseEvent
)
{
if
(
dropdownRef
.
value
&&
!
dropdownRef
.
value
.
contains
(
event
.
target
as
Node
))
{
if
(
dropdownRef
.
value
&&
!
dropdownRef
.
value
.
contains
(
event
.
target
as
Node
))
{
closeDropdown
()
closeDropdown
()
...
...
frontend/src/components/layout/AppLayout.vue
View file @
b63b338e
...
@@ -23,11 +23,30 @@
...
@@ -23,11 +23,30 @@
</
template
>
</
template
>
<
script
setup
lang=
"ts"
>
<
script
setup
lang=
"ts"
>
import
{
computed
}
from
'
vue
'
import
'
@/styles/onboarding.css
'
import
{
computed
,
onMounted
}
from
'
vue
'
import
{
useAppStore
}
from
'
@/stores
'
import
{
useAppStore
}
from
'
@/stores
'
import
{
useAuthStore
}
from
'
@/stores/auth
'
import
{
useOnboardingTour
}
from
'
@/composables/useOnboardingTour
'
import
{
useOnboardingStore
}
from
'
@/stores/onboarding
'
import
AppSidebar
from
'
./AppSidebar.vue
'
import
AppSidebar
from
'
./AppSidebar.vue
'
import
AppHeader
from
'
./AppHeader.vue
'
import
AppHeader
from
'
./AppHeader.vue
'
const
appStore
=
useAppStore
()
const
appStore
=
useAppStore
()
const
authStore
=
useAuthStore
()
const
sidebarCollapsed
=
computed
(()
=>
appStore
.
sidebarCollapsed
)
const
sidebarCollapsed
=
computed
(()
=>
appStore
.
sidebarCollapsed
)
const
isAdmin
=
computed
(()
=>
authStore
.
user
?.
role
===
'
admin
'
)
const
{
replayTour
}
=
useOnboardingTour
({
storageKey
:
isAdmin
.
value
?
'
admin_guide
'
:
'
user_guide
'
,
autoStart
:
true
})
const
onboardingStore
=
useOnboardingStore
()
onMounted
(()
=>
{
onboardingStore
.
setReplayCallback
(
replayTour
)
})
defineExpose
({
replayTour
})
</
script
>
</
script
>
frontend/src/components/layout/AppSidebar.vue
View file @
b63b338e
...
@@ -36,7 +36,16 @@
...
@@ -36,7 +36,16 @@
class=
"sidebar-link mb-1"
class=
"sidebar-link mb-1"
:class=
"
{ 'sidebar-link-active': isActive(item.path) }"
:class=
"
{ 'sidebar-link-active': isActive(item.path) }"
:title="sidebarCollapsed ? item.label : undefined"
:title="sidebarCollapsed ? item.label : undefined"
@click="handleMenuItemClick"
:id="
item.path === '/admin/accounts'
? 'sidebar-channel-manage'
: item.path === '/admin/groups'
? 'sidebar-group-manage'
: item.path === '/admin/redeem'
? 'sidebar-wallet'
: undefined
"
@click="handleMenuItemClick(item.path)"
>
>
<component
:is=
"item.icon"
class=
"h-5 w-5 flex-shrink-0"
/>
<component
:is=
"item.icon"
class=
"h-5 w-5 flex-shrink-0"
/>
<transition
name=
"fade"
>
<transition
name=
"fade"
>
...
@@ -59,7 +68,8 @@
...
@@ -59,7 +68,8 @@
class=
"sidebar-link mb-1"
class=
"sidebar-link mb-1"
:class=
"
{ 'sidebar-link-active': isActive(item.path) }"
:class=
"
{ 'sidebar-link-active': isActive(item.path) }"
:title="sidebarCollapsed ? item.label : undefined"
:title="sidebarCollapsed ? item.label : undefined"
@click="handleMenuItemClick"
:data-tour="item.path === '/keys' ? 'sidebar-my-keys' : undefined"
@click="handleMenuItemClick(item.path)"
>
>
<component
:is=
"item.icon"
class=
"h-5 w-5 flex-shrink-0"
/>
<component
:is=
"item.icon"
class=
"h-5 w-5 flex-shrink-0"
/>
<transition
name=
"fade"
>
<transition
name=
"fade"
>
...
@@ -79,7 +89,8 @@
...
@@ -79,7 +89,8 @@
class=
"sidebar-link mb-1"
class=
"sidebar-link mb-1"
:class=
"
{ 'sidebar-link-active': isActive(item.path) }"
:class=
"
{ 'sidebar-link-active': isActive(item.path) }"
:title="sidebarCollapsed ? item.label : undefined"
:title="sidebarCollapsed ? item.label : undefined"
@click="handleMenuItemClick"
:data-tour="item.path === '/keys' ? 'sidebar-my-keys' : undefined"
@click="handleMenuItemClick(item.path)"
>
>
<component
:is=
"item.icon"
class=
"h-5 w-5 flex-shrink-0"
/>
<component
:is=
"item.icon"
class=
"h-5 w-5 flex-shrink-0"
/>
<transition
name=
"fade"
>
<transition
name=
"fade"
>
...
@@ -136,7 +147,7 @@
...
@@ -136,7 +147,7 @@
import
{
computed
,
h
,
ref
}
from
'
vue
'
import
{
computed
,
h
,
ref
}
from
'
vue
'
import
{
useRoute
}
from
'
vue-router
'
import
{
useRoute
}
from
'
vue-router
'
import
{
useI18n
}
from
'
vue-i18n
'
import
{
useI18n
}
from
'
vue-i18n
'
import
{
useAppStore
,
useAuthStore
}
from
'
@/stores
'
import
{
useAppStore
,
useAuthStore
,
useOnboardingStore
}
from
'
@/stores
'
import
VersionBadge
from
'
@/components/common/VersionBadge.vue
'
import
VersionBadge
from
'
@/components/common/VersionBadge.vue
'
const
{
t
}
=
useI18n
()
const
{
t
}
=
useI18n
()
...
@@ -144,6 +155,7 @@ const { t } = useI18n()
...
@@ -144,6 +155,7 @@ const { t } = useI18n()
const
route
=
useRoute
()
const
route
=
useRoute
()
const
appStore
=
useAppStore
()
const
appStore
=
useAppStore
()
const
authStore
=
useAuthStore
()
const
authStore
=
useAuthStore
()
const
onboardingStore
=
useOnboardingStore
()
const
sidebarCollapsed
=
computed
(()
=>
appStore
.
sidebarCollapsed
)
const
sidebarCollapsed
=
computed
(()
=>
appStore
.
sidebarCollapsed
)
const
mobileOpen
=
computed
(()
=>
appStore
.
mobileOpen
)
const
mobileOpen
=
computed
(()
=>
appStore
.
mobileOpen
)
...
@@ -465,12 +477,24 @@ function closeMobile() {
...
@@ -465,12 +477,24 @@ function closeMobile() {
appStore
.
setMobileOpen
(
false
)
appStore
.
setMobileOpen
(
false
)
}
}
function
handleMenuItemClick
()
{
function
handleMenuItemClick
(
itemPath
:
string
)
{
if
(
mobileOpen
.
value
)
{
if
(
mobileOpen
.
value
)
{
setTimeout
(()
=>
{
setTimeout
(()
=>
{
appStore
.
setMobileOpen
(
false
)
appStore
.
setMobileOpen
(
false
)
},
150
)
},
150
)
}
}
// Map paths to tour selectors
const
pathToSelector
:
Record
<
string
,
string
>
=
{
'
/admin/groups
'
:
'
#sidebar-group-manage
'
,
'
/admin/accounts
'
:
'
#sidebar-channel-manage
'
,
'
/keys
'
:
'
[data-tour="sidebar-my-keys"]
'
}
const
selector
=
pathToSelector
[
itemPath
]
if
(
selector
&&
onboardingStore
.
isCurrentStep
(
selector
))
{
onboardingStore
.
nextStep
(
500
)
}
}
}
function
isActive
(
path
:
string
):
boolean
{
function
isActive
(
path
:
string
):
boolean
{
...
...
frontend/src/composables/useOnboardingTour.ts
0 → 100644
View file @
b63b338e
This diff is collapsed.
Click to expand it.
frontend/src/i18n/index.ts
View file @
b63b338e
...
@@ -27,7 +27,10 @@ export const i18n = createI18n({
...
@@ -27,7 +27,10 @@ export const i18n = createI18n({
messages
:
{
messages
:
{
en
,
en
,
zh
zh
}
},
// 禁用 HTML 消息警告 - 引导步骤使用富文本内容(driver.js 支持 HTML)
// 这些内容是内部定义的,不存在 XSS 风险
warnHtmlMessage
:
false
})
})
export
function
setLocale
(
locale
:
string
)
{
export
function
setLocale
(
locale
:
string
)
{
...
...
frontend/src/i18n/locales/en.ts
View file @
b63b338e
This diff is collapsed.
Click to expand it.
Prev
1
2
Next
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
.
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment