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
942c3e15
Commit
942c3e15
authored
Dec 29, 2025
by
song
Browse files
Merge branch 'main' into feature/antigravity_auth_image
parents
caa8c47b
c328b741
Changes
39
Expand all
Hide whitespace changes
Inline
Side-by-side
backend/.golangci.yml
View file @
942c3e15
...
@@ -600,8 +600,3 @@ formatters:
...
@@ -600,8 +600,3 @@ formatters:
replacement
:
'
any'
replacement
:
'
any'
-
pattern
:
'
a[b:len(a)]'
-
pattern
:
'
a[b:len(a)]'
replacement
:
'
a[b:]'
replacement
:
'
a[b:]'
exclusions
:
paths
:
-
internal/pkg/antigravity/claude_types.go
-
internal/pkg/antigravity/gemini_types.go
-
internal/pkg/antigravity/stream_transformer.go
\ No newline at end of file
backend/internal/pkg/antigravity/claude_types.go
View file @
942c3e15
...
@@ -38,8 +38,8 @@ type ClaudeMetadata struct {
...
@@ -38,8 +38,8 @@ type ClaudeMetadata struct {
// ClaudeTool Claude 工具定义
// ClaudeTool Claude 工具定义
type
ClaudeTool
struct
{
type
ClaudeTool
struct
{
Name
string
`json:"name"`
Name
string
`json:"name"`
Description
string
`json:"description,omitempty"`
Description
string
`json:"description,omitempty"`
InputSchema
map
[
string
]
any
`json:"input_schema"`
InputSchema
map
[
string
]
any
`json:"input_schema"`
}
}
...
@@ -58,9 +58,9 @@ type ContentBlock struct {
...
@@ -58,9 +58,9 @@ type ContentBlock struct {
Thinking
string
`json:"thinking,omitempty"`
Thinking
string
`json:"thinking,omitempty"`
Signature
string
`json:"signature,omitempty"`
Signature
string
`json:"signature,omitempty"`
// tool_use
// tool_use
ID
string
`json:"id,omitempty"`
ID
string
`json:"id,omitempty"`
Name
string
`json:"name,omitempty"`
Name
string
`json:"name,omitempty"`
Input
any
`json:"input,omitempty"`
Input
any
`json:"input,omitempty"`
// tool_result
// tool_result
ToolUseID
string
`json:"tool_use_id,omitempty"`
ToolUseID
string
`json:"tool_use_id,omitempty"`
Content
json
.
RawMessage
`json:"content,omitempty"`
Content
json
.
RawMessage
`json:"content,omitempty"`
...
@@ -100,9 +100,9 @@ type ClaudeContentItem struct {
...
@@ -100,9 +100,9 @@ type ClaudeContentItem struct {
Signature
string
`json:"signature,omitempty"`
Signature
string
`json:"signature,omitempty"`
// tool_use
// tool_use
ID
string
`json:"id,omitempty"`
ID
string
`json:"id,omitempty"`
Name
string
`json:"name,omitempty"`
Name
string
`json:"name,omitempty"`
Input
any
`json:"input,omitempty"`
Input
any
`json:"input,omitempty"`
}
}
// ClaudeUsage Claude 用量统计
// ClaudeUsage Claude 用量统计
...
...
backend/internal/pkg/antigravity/gemini_types.go
View file @
942c3e15
...
@@ -47,16 +47,16 @@ type GeminiInlineData struct {
...
@@ -47,16 +47,16 @@ type GeminiInlineData struct {
// GeminiFunctionCall Gemini 函数调用
// GeminiFunctionCall Gemini 函数调用
type
GeminiFunctionCall
struct
{
type
GeminiFunctionCall
struct
{
Name
string
`json:"name"`
Name
string
`json:"name"`
Args
any
`json:"args,omitempty"`
Args
any
`json:"args,omitempty"`
ID
string
`json:"id,omitempty"`
ID
string
`json:"id,omitempty"`
}
}
// GeminiFunctionResponse Gemini 函数响应
// GeminiFunctionResponse Gemini 函数响应
type
GeminiFunctionResponse
struct
{
type
GeminiFunctionResponse
struct
{
Name
string
`json:"name"`
Name
string
`json:"name"`
Response
map
[
string
]
any
`json:"response"`
Response
map
[
string
]
any
`json:"response"`
ID
string
`json:"id,omitempty"`
ID
string
`json:"id,omitempty"`
}
}
// GeminiGenerationConfig Gemini 生成配置
// GeminiGenerationConfig Gemini 生成配置
...
@@ -83,8 +83,8 @@ type GeminiToolDeclaration struct {
...
@@ -83,8 +83,8 @@ type GeminiToolDeclaration struct {
// GeminiFunctionDecl Gemini 函数声明
// GeminiFunctionDecl Gemini 函数声明
type
GeminiFunctionDecl
struct
{
type
GeminiFunctionDecl
struct
{
Name
string
`json:"name"`
Name
string
`json:"name"`
Description
string
`json:"description,omitempty"`
Description
string
`json:"description,omitempty"`
Parameters
map
[
string
]
any
`json:"parameters,omitempty"`
Parameters
map
[
string
]
any
`json:"parameters,omitempty"`
}
}
...
...
backend/internal/pkg/antigravity/stream_transformer.go
View file @
942c3e15
...
@@ -135,18 +135,18 @@ func (p *StreamingProcessor) emitMessageStart(v1Resp *V1InternalResponse) []byte
...
@@ -135,18 +135,18 @@ func (p *StreamingProcessor) emitMessageStart(v1Resp *V1InternalResponse) []byte
responseID
=
"msg_"
+
generateRandomID
()
responseID
=
"msg_"
+
generateRandomID
()
}
}
message
:=
map
[
string
]
interface
{}
{
message
:=
map
[
string
]
any
{
"id"
:
responseID
,
"id"
:
responseID
,
"type"
:
"message"
,
"type"
:
"message"
,
"role"
:
"assistant"
,
"role"
:
"assistant"
,
"content"
:
[]
interface
{}
{},
"content"
:
[]
any
{},
"model"
:
p
.
originalModel
,
"model"
:
p
.
originalModel
,
"stop_reason"
:
nil
,
"stop_reason"
:
nil
,
"stop_sequence"
:
nil
,
"stop_sequence"
:
nil
,
"usage"
:
usage
,
"usage"
:
usage
,
}
}
event
:=
map
[
string
]
interface
{}
{
event
:=
map
[
string
]
any
{
"type"
:
"message_start"
,
"type"
:
"message_start"
,
"message"
:
message
,
"message"
:
message
,
}
}
...
@@ -205,14 +205,14 @@ func (p *StreamingProcessor) processThinking(text, signature string) []byte {
...
@@ -205,14 +205,14 @@ func (p *StreamingProcessor) processThinking(text, signature string) []byte {
// 开始或继续 thinking 块
// 开始或继续 thinking 块
if
p
.
blockType
!=
BlockTypeThinking
{
if
p
.
blockType
!=
BlockTypeThinking
{
_
,
_
=
result
.
Write
(
p
.
startBlock
(
BlockTypeThinking
,
map
[
string
]
interface
{}
{
_
,
_
=
result
.
Write
(
p
.
startBlock
(
BlockTypeThinking
,
map
[
string
]
any
{
"type"
:
"thinking"
,
"type"
:
"thinking"
,
"thinking"
:
""
,
"thinking"
:
""
,
}))
}))
}
}
if
text
!=
""
{
if
text
!=
""
{
_
,
_
=
result
.
Write
(
p
.
emitDelta
(
"thinking_delta"
,
map
[
string
]
interface
{}
{
_
,
_
=
result
.
Write
(
p
.
emitDelta
(
"thinking_delta"
,
map
[
string
]
any
{
"thinking"
:
text
,
"thinking"
:
text
,
}))
}))
}
}
...
@@ -246,11 +246,11 @@ func (p *StreamingProcessor) processText(text, signature string) []byte {
...
@@ -246,11 +246,11 @@ func (p *StreamingProcessor) processText(text, signature string) []byte {
// 非空 text 带签名 - 特殊处理
// 非空 text 带签名 - 特殊处理
if
signature
!=
""
{
if
signature
!=
""
{
_
,
_
=
result
.
Write
(
p
.
startBlock
(
BlockTypeText
,
map
[
string
]
interface
{}
{
_
,
_
=
result
.
Write
(
p
.
startBlock
(
BlockTypeText
,
map
[
string
]
any
{
"type"
:
"text"
,
"type"
:
"text"
,
"text"
:
""
,
"text"
:
""
,
}))
}))
_
,
_
=
result
.
Write
(
p
.
emitDelta
(
"text_delta"
,
map
[
string
]
interface
{}
{
_
,
_
=
result
.
Write
(
p
.
emitDelta
(
"text_delta"
,
map
[
string
]
any
{
"text"
:
text
,
"text"
:
text
,
}))
}))
_
,
_
=
result
.
Write
(
p
.
endBlock
())
_
,
_
=
result
.
Write
(
p
.
endBlock
())
...
@@ -260,13 +260,13 @@ func (p *StreamingProcessor) processText(text, signature string) []byte {
...
@@ -260,13 +260,13 @@ func (p *StreamingProcessor) processText(text, signature string) []byte {
// 普通 text (无签名)
// 普通 text (无签名)
if
p
.
blockType
!=
BlockTypeText
{
if
p
.
blockType
!=
BlockTypeText
{
_
,
_
=
result
.
Write
(
p
.
startBlock
(
BlockTypeText
,
map
[
string
]
interface
{}
{
_
,
_
=
result
.
Write
(
p
.
startBlock
(
BlockTypeText
,
map
[
string
]
any
{
"type"
:
"text"
,
"type"
:
"text"
,
"text"
:
""
,
"text"
:
""
,
}))
}))
}
}
_
,
_
=
result
.
Write
(
p
.
emitDelta
(
"text_delta"
,
map
[
string
]
interface
{}
{
_
,
_
=
result
.
Write
(
p
.
emitDelta
(
"text_delta"
,
map
[
string
]
any
{
"text"
:
text
,
"text"
:
text
,
}))
}))
...
@@ -284,11 +284,11 @@ func (p *StreamingProcessor) processFunctionCall(fc *GeminiFunctionCall, signatu
...
@@ -284,11 +284,11 @@ func (p *StreamingProcessor) processFunctionCall(fc *GeminiFunctionCall, signatu
toolID
=
fmt
.
Sprintf
(
"%s-%s"
,
fc
.
Name
,
generateRandomID
())
toolID
=
fmt
.
Sprintf
(
"%s-%s"
,
fc
.
Name
,
generateRandomID
())
}
}
toolUse
:=
map
[
string
]
interface
{}
{
toolUse
:=
map
[
string
]
any
{
"type"
:
"tool_use"
,
"type"
:
"tool_use"
,
"id"
:
toolID
,
"id"
:
toolID
,
"name"
:
fc
.
Name
,
"name"
:
fc
.
Name
,
"input"
:
map
[
string
]
interface
{}{},
// 必须为空,参数通过 delta 发送
"input"
:
map
[
string
]
any
{},
}
}
if
signature
!=
""
{
if
signature
!=
""
{
...
@@ -300,7 +300,7 @@ func (p *StreamingProcessor) processFunctionCall(fc *GeminiFunctionCall, signatu
...
@@ -300,7 +300,7 @@ func (p *StreamingProcessor) processFunctionCall(fc *GeminiFunctionCall, signatu
// 发送 input_json_delta
// 发送 input_json_delta
if
fc
.
Args
!=
nil
{
if
fc
.
Args
!=
nil
{
argsJSON
,
_
:=
json
.
Marshal
(
fc
.
Args
)
argsJSON
,
_
:=
json
.
Marshal
(
fc
.
Args
)
_
,
_
=
result
.
Write
(
p
.
emitDelta
(
"input_json_delta"
,
map
[
string
]
interface
{}
{
_
,
_
=
result
.
Write
(
p
.
emitDelta
(
"input_json_delta"
,
map
[
string
]
any
{
"partial_json"
:
string
(
argsJSON
),
"partial_json"
:
string
(
argsJSON
),
}))
}))
}
}
...
@@ -311,14 +311,14 @@ func (p *StreamingProcessor) processFunctionCall(fc *GeminiFunctionCall, signatu
...
@@ -311,14 +311,14 @@ func (p *StreamingProcessor) processFunctionCall(fc *GeminiFunctionCall, signatu
}
}
// startBlock 开始新的内容块
// startBlock 开始新的内容块
func
(
p
*
StreamingProcessor
)
startBlock
(
blockType
BlockType
,
contentBlock
map
[
string
]
interface
{}
)
[]
byte
{
func
(
p
*
StreamingProcessor
)
startBlock
(
blockType
BlockType
,
contentBlock
map
[
string
]
any
)
[]
byte
{
var
result
bytes
.
Buffer
var
result
bytes
.
Buffer
if
p
.
blockType
!=
BlockTypeNone
{
if
p
.
blockType
!=
BlockTypeNone
{
_
,
_
=
result
.
Write
(
p
.
endBlock
())
_
,
_
=
result
.
Write
(
p
.
endBlock
())
}
}
event
:=
map
[
string
]
interface
{}
{
event
:=
map
[
string
]
any
{
"type"
:
"content_block_start"
,
"type"
:
"content_block_start"
,
"index"
:
p
.
blockIndex
,
"index"
:
p
.
blockIndex
,
"content_block"
:
contentBlock
,
"content_block"
:
contentBlock
,
...
@@ -340,13 +340,13 @@ func (p *StreamingProcessor) endBlock() []byte {
...
@@ -340,13 +340,13 @@ func (p *StreamingProcessor) endBlock() []byte {
// Thinking 块结束时发送暂存的签名
// Thinking 块结束时发送暂存的签名
if
p
.
blockType
==
BlockTypeThinking
&&
p
.
pendingSignature
!=
""
{
if
p
.
blockType
==
BlockTypeThinking
&&
p
.
pendingSignature
!=
""
{
_
,
_
=
result
.
Write
(
p
.
emitDelta
(
"signature_delta"
,
map
[
string
]
interface
{}
{
_
,
_
=
result
.
Write
(
p
.
emitDelta
(
"signature_delta"
,
map
[
string
]
any
{
"signature"
:
p
.
pendingSignature
,
"signature"
:
p
.
pendingSignature
,
}))
}))
p
.
pendingSignature
=
""
p
.
pendingSignature
=
""
}
}
event
:=
map
[
string
]
interface
{}
{
event
:=
map
[
string
]
any
{
"type"
:
"content_block_stop"
,
"type"
:
"content_block_stop"
,
"index"
:
p
.
blockIndex
,
"index"
:
p
.
blockIndex
,
}
}
...
@@ -360,15 +360,15 @@ func (p *StreamingProcessor) endBlock() []byte {
...
@@ -360,15 +360,15 @@ func (p *StreamingProcessor) endBlock() []byte {
}
}
// emitDelta 发送 delta 事件
// emitDelta 发送 delta 事件
func
(
p
*
StreamingProcessor
)
emitDelta
(
deltaType
string
,
deltaContent
map
[
string
]
interface
{}
)
[]
byte
{
func
(
p
*
StreamingProcessor
)
emitDelta
(
deltaType
string
,
deltaContent
map
[
string
]
any
)
[]
byte
{
delta
:=
map
[
string
]
interface
{}
{
delta
:=
map
[
string
]
any
{
"type"
:
deltaType
,
"type"
:
deltaType
,
}
}
for
k
,
v
:=
range
deltaContent
{
for
k
,
v
:=
range
deltaContent
{
delta
[
k
]
=
v
delta
[
k
]
=
v
}
}
event
:=
map
[
string
]
interface
{}
{
event
:=
map
[
string
]
any
{
"type"
:
"content_block_delta"
,
"type"
:
"content_block_delta"
,
"index"
:
p
.
blockIndex
,
"index"
:
p
.
blockIndex
,
"delta"
:
delta
,
"delta"
:
delta
,
...
@@ -381,14 +381,14 @@ func (p *StreamingProcessor) emitDelta(deltaType string, deltaContent map[string
...
@@ -381,14 +381,14 @@ func (p *StreamingProcessor) emitDelta(deltaType string, deltaContent map[string
func
(
p
*
StreamingProcessor
)
emitEmptyThinkingWithSignature
(
signature
string
)
[]
byte
{
func
(
p
*
StreamingProcessor
)
emitEmptyThinkingWithSignature
(
signature
string
)
[]
byte
{
var
result
bytes
.
Buffer
var
result
bytes
.
Buffer
_
,
_
=
result
.
Write
(
p
.
startBlock
(
BlockTypeThinking
,
map
[
string
]
interface
{}
{
_
,
_
=
result
.
Write
(
p
.
startBlock
(
BlockTypeThinking
,
map
[
string
]
any
{
"type"
:
"thinking"
,
"type"
:
"thinking"
,
"thinking"
:
""
,
"thinking"
:
""
,
}))
}))
_
,
_
=
result
.
Write
(
p
.
emitDelta
(
"thinking_delta"
,
map
[
string
]
interface
{}
{
_
,
_
=
result
.
Write
(
p
.
emitDelta
(
"thinking_delta"
,
map
[
string
]
any
{
"thinking"
:
""
,
"thinking"
:
""
,
}))
}))
_
,
_
=
result
.
Write
(
p
.
emitDelta
(
"signature_delta"
,
map
[
string
]
interface
{}
{
_
,
_
=
result
.
Write
(
p
.
emitDelta
(
"signature_delta"
,
map
[
string
]
any
{
"signature"
:
signature
,
"signature"
:
signature
,
}))
}))
_
,
_
=
result
.
Write
(
p
.
endBlock
())
_
,
_
=
result
.
Write
(
p
.
endBlock
())
...
@@ -422,9 +422,9 @@ func (p *StreamingProcessor) emitFinish(finishReason string) []byte {
...
@@ -422,9 +422,9 @@ func (p *StreamingProcessor) emitFinish(finishReason string) []byte {
OutputTokens
:
p
.
outputTokens
,
OutputTokens
:
p
.
outputTokens
,
}
}
deltaEvent
:=
map
[
string
]
interface
{}
{
deltaEvent
:=
map
[
string
]
any
{
"type"
:
"message_delta"
,
"type"
:
"message_delta"
,
"delta"
:
map
[
string
]
interface
{}
{
"delta"
:
map
[
string
]
any
{
"stop_reason"
:
stopReason
,
"stop_reason"
:
stopReason
,
"stop_sequence"
:
nil
,
"stop_sequence"
:
nil
,
},
},
...
@@ -434,7 +434,7 @@ func (p *StreamingProcessor) emitFinish(finishReason string) []byte {
...
@@ -434,7 +434,7 @@ func (p *StreamingProcessor) emitFinish(finishReason string) []byte {
_
,
_
=
result
.
Write
(
p
.
formatSSE
(
"message_delta"
,
deltaEvent
))
_
,
_
=
result
.
Write
(
p
.
formatSSE
(
"message_delta"
,
deltaEvent
))
if
!
p
.
messageStopSent
{
if
!
p
.
messageStopSent
{
stopEvent
:=
map
[
string
]
interface
{}
{
stopEvent
:=
map
[
string
]
any
{
"type"
:
"message_stop"
,
"type"
:
"message_stop"
,
}
}
_
,
_
=
result
.
Write
(
p
.
formatSSE
(
"message_stop"
,
stopEvent
))
_
,
_
=
result
.
Write
(
p
.
formatSSE
(
"message_stop"
,
stopEvent
))
...
@@ -445,7 +445,7 @@ func (p *StreamingProcessor) emitFinish(finishReason string) []byte {
...
@@ -445,7 +445,7 @@ func (p *StreamingProcessor) emitFinish(finishReason string) []byte {
}
}
// formatSSE 格式化 SSE 事件
// formatSSE 格式化 SSE 事件
func
(
p
*
StreamingProcessor
)
formatSSE
(
eventType
string
,
data
interface
{}
)
[]
byte
{
func
(
p
*
StreamingProcessor
)
formatSSE
(
eventType
string
,
data
any
)
[]
byte
{
jsonData
,
err
:=
json
.
Marshal
(
data
)
jsonData
,
err
:=
json
.
Marshal
(
data
)
if
err
!=
nil
{
if
err
!=
nil
{
return
nil
return
nil
...
...
backend/internal/repository/account_repo.go
View file @
942c3e15
...
@@ -186,7 +186,7 @@ func (r *accountRepository) BatchUpdateLastUsed(ctx context.Context, updates map
...
@@ -186,7 +186,7 @@ func (r *accountRepository) BatchUpdateLastUsed(ctx context.Context, updates map
ids
=
append
(
ids
,
id
)
ids
=
append
(
ids
,
id
)
}
}
caseSql
+=
" END WHERE id IN ?"
caseSql
+=
" END WHERE id IN ?
AND deleted_at IS NULL
"
args
=
append
(
args
,
ids
)
args
=
append
(
args
,
ids
)
return
r
.
db
.
WithContext
(
ctx
)
.
Exec
(
caseSql
,
args
...
)
.
Error
return
r
.
db
.
WithContext
(
ctx
)
.
Exec
(
caseSql
,
args
...
)
.
Error
...
...
backend/internal/repository/proxy_repo.go
View file @
942c3e15
...
@@ -119,6 +119,7 @@ func (r *proxyRepository) CountAccountsByProxyID(ctx context.Context, proxyID in
...
@@ -119,6 +119,7 @@ func (r *proxyRepository) CountAccountsByProxyID(ctx context.Context, proxyID in
var
count
int64
var
count
int64
err
:=
r
.
db
.
WithContext
(
ctx
)
.
Table
(
"accounts"
)
.
err
:=
r
.
db
.
WithContext
(
ctx
)
.
Table
(
"accounts"
)
.
Where
(
"proxy_id = ?"
,
proxyID
)
.
Where
(
"proxy_id = ?"
,
proxyID
)
.
Where
(
"deleted_at IS NULL"
)
.
Count
(
&
count
)
.
Error
Count
(
&
count
)
.
Error
return
count
,
err
return
count
,
err
}
}
...
@@ -134,6 +135,7 @@ func (r *proxyRepository) GetAccountCountsForProxies(ctx context.Context) (map[i
...
@@ -134,6 +135,7 @@ func (r *proxyRepository) GetAccountCountsForProxies(ctx context.Context) (map[i
Table
(
"accounts"
)
.
Table
(
"accounts"
)
.
Select
(
"proxy_id, COUNT(*) as count"
)
.
Select
(
"proxy_id, COUNT(*) as count"
)
.
Where
(
"proxy_id IS NOT NULL"
)
.
Where
(
"proxy_id IS NOT NULL"
)
.
Where
(
"deleted_at IS NULL"
)
.
Group
(
"proxy_id"
)
.
Group
(
"proxy_id"
)
.
Scan
(
&
results
)
.
Error
Scan
(
&
results
)
.
Error
if
err
!=
nil
{
if
err
!=
nil
{
...
...
backend/internal/repository/usage_log_repo.go
View file @
942c3e15
...
@@ -182,6 +182,7 @@ func (r *usageLogRepository) GetDashboardStats(ctx context.Context) (*DashboardS
...
@@ -182,6 +182,7 @@ func (r *usageLogRepository) GetDashboardStats(ctx context.Context) (*DashboardS
COUNT(CASE WHEN rate_limited_at IS NOT NULL AND rate_limit_reset_at > ? THEN 1 END) as ratelimit_accounts,
COUNT(CASE WHEN rate_limited_at IS NOT NULL AND rate_limit_reset_at > ? THEN 1 END) as ratelimit_accounts,
COUNT(CASE WHEN overload_until IS NOT NULL AND overload_until > ? THEN 1 END) as overload_accounts
COUNT(CASE WHEN overload_until IS NOT NULL AND overload_until > ? THEN 1 END) as overload_accounts
FROM accounts
FROM accounts
WHERE deleted_at IS NULL
`
,
service
.
StatusActive
,
service
.
StatusError
,
now
,
now
)
.
Scan
(
&
accountStats
)
.
Error
;
err
!=
nil
{
`
,
service
.
StatusActive
,
service
.
StatusError
,
now
,
now
)
.
Scan
(
&
accountStats
)
.
Error
;
err
!=
nil
{
return
nil
,
err
return
nil
,
err
}
}
...
...
backend/internal/service/gateway_multiplatform_test.go
View file @
942c3e15
...
@@ -20,9 +20,9 @@ func testConfig() *config.Config {
...
@@ -20,9 +20,9 @@ func testConfig() *config.Config {
// mockAccountRepoForPlatform 单平台测试用的 mock
// mockAccountRepoForPlatform 单平台测试用的 mock
type
mockAccountRepoForPlatform
struct
{
type
mockAccountRepoForPlatform
struct
{
accounts
[]
Account
accounts
[]
Account
accountsByID
map
[
int64
]
*
Account
accountsByID
map
[
int64
]
*
Account
listPlatformFunc
func
(
ctx
context
.
Context
,
platform
string
)
([]
Account
,
error
)
listPlatformFunc
func
(
ctx
context
.
Context
,
platform
string
)
([]
Account
,
error
)
}
}
func
(
m
*
mockAccountRepoForPlatform
)
GetByID
(
ctx
context
.
Context
,
id
int64
)
(
*
Account
,
error
)
{
func
(
m
*
mockAccountRepoForPlatform
)
GetByID
(
ctx
context
.
Context
,
id
int64
)
(
*
Account
,
error
)
{
...
...
backend/internal/service/gemini_multiplatform_test.go
View file @
942c3e15
...
@@ -56,7 +56,9 @@ func (m *mockAccountRepoForGemini) ListWithFilters(ctx context.Context, params p
...
@@ -56,7 +56,9 @@ func (m *mockAccountRepoForGemini) ListWithFilters(ctx context.Context, params p
func
(
m
*
mockAccountRepoForGemini
)
ListByGroup
(
ctx
context
.
Context
,
groupID
int64
)
([]
Account
,
error
)
{
func
(
m
*
mockAccountRepoForGemini
)
ListByGroup
(
ctx
context
.
Context
,
groupID
int64
)
([]
Account
,
error
)
{
return
nil
,
nil
return
nil
,
nil
}
}
func
(
m
*
mockAccountRepoForGemini
)
ListActive
(
ctx
context
.
Context
)
([]
Account
,
error
)
{
return
nil
,
nil
}
func
(
m
*
mockAccountRepoForGemini
)
ListActive
(
ctx
context
.
Context
)
([]
Account
,
error
)
{
return
nil
,
nil
}
func
(
m
*
mockAccountRepoForGemini
)
ListByPlatform
(
ctx
context
.
Context
,
platform
string
)
([]
Account
,
error
)
{
func
(
m
*
mockAccountRepoForGemini
)
ListByPlatform
(
ctx
context
.
Context
,
platform
string
)
([]
Account
,
error
)
{
return
nil
,
nil
return
nil
,
nil
}
}
...
...
frontend/package-lock.json
View file @
942c3e15
This diff is collapsed.
Click to expand it.
frontend/package.json
View file @
942c3e15
...
@@ -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 @
942c3e15
This diff is collapsed.
Click to expand it.
frontend/src/components/Guide/steps.ts
0 → 100644
View file @
942c3e15
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 @
942c3e15
...
@@ -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 @
942c3e15
...
@@ -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'"
...
@@ -166,7 +167,7 @@
...
@@ -166,7 +167,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'"
...
@@ -256,7 +257,7 @@
...
@@ -256,7 +257,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'"
...
@@ -338,7 +339,7 @@
...
@@ -338,7 +339,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'"
...
@@ -1014,7 +1015,13 @@
...
@@ -1014,7 +1015,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
"
/>
<
p
class
=
"
input-hint
"
>
{{
t
(
'
admin.accounts.priorityHint
'
)
}}
<
/p
>
<
p
class
=
"
input-hint
"
>
{{
t
(
'
admin.accounts.priorityHint
'
)
}}
<
/p
>
<
/div
>
<
/div
>
<
/div
>
<
/div
>
...
@@ -1056,6 +1063,7 @@
...
@@ -1056,6 +1063,7 @@
:
groups
=
"
groups
"
:
groups
=
"
groups
"
:
platform
=
"
form.platform
"
:
platform
=
"
form.platform
"
:
mixed
-
scheduling
=
"
mixedScheduling
"
:
mixed
-
scheduling
=
"
mixedScheduling
"
data
-
tour
=
"
account-form-groups
"
/>
/>
<
/form
>
<
/form
>
...
@@ -1091,6 +1099,7 @@
...
@@ -1091,6 +1099,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 @
942c3e15
...
@@ -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
>
...
@@ -504,6 +510,7 @@
...
@@ -504,6 +510,7 @@
:
groups
=
"
groups
"
:
groups
=
"
groups
"
:
platform
=
"
account?.platform
"
:
platform
=
"
account?.platform
"
:
mixed
-
scheduling
=
"
mixedScheduling
"
:
mixed
-
scheduling
=
"
mixedScheduling
"
data
-
tour
=
"
account-form-groups
"
/>
/>
<
/form
>
<
/form
>
...
@@ -518,6 +525,7 @@
...
@@ -518,6 +525,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 @
942c3e15
...
@@ -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 @
942c3e15
...
@@ -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 @
942c3e15
<
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 @
942c3e15
<
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
>
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