Merge branch 'main' into feature/antigravity_auth_image
This commit is contained in:
@@ -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
|
|
||||||
@@ -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 {
|
|||||||
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 {
|
|||||||
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 用量统计
|
||||||
|
|||||||
@@ -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 {
|
|||||||
|
|
||||||
// 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"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
|||||||
|
|
||||||
// 开始或继续 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 {
|
|||||||
|
|
||||||
// 非空 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 {
|
|||||||
|
|
||||||
// 普通 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
|
|||||||
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
|
|||||||
// 发送 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
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 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 {
|
|||||||
|
|
||||||
// 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 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 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
|
|||||||
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 {
|
|||||||
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 {
|
|||||||
_, _ = 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 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
|||||||
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 {
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
1229
frontend/package-lock.json
generated
1229
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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",
|
||||||
|
|||||||
1962
frontend/pnpm-lock.yaml
generated
Normal file
1962
frontend/pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
309
frontend/src/components/Guide/steps.ts
Normal file
309
frontend/src/components/Guide/steps.ts
Normal file
@@ -0,0 +1,309 @@
|
|||||||
|
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']
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
@@ -362,6 +362,10 @@ const resetState = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleClose = () => {
|
const handleClose = () => {
|
||||||
|
// 防止在连接测试进行中关闭对话框
|
||||||
|
if (status.value === 'connecting') {
|
||||||
|
return
|
||||||
|
}
|
||||||
closeEventSource()
|
closeEventSource()
|
||||||
emit('close')
|
emit('close')
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 @@
|
|||||||
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 @@
|
|||||||
<!-- 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 @@
|
|||||||
<!-- 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 @@
|
|||||||
<!-- 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 @@
|
|||||||
</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 @@
|
|||||||
: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 @@
|
|||||||
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"
|
||||||
|
|||||||
@@ -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 @@
|
|||||||
>
|
>
|
||||||
<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 @@
|
|||||||
</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 @@
|
|||||||
: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 @@
|
|||||||
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"
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -151,6 +151,10 @@ watch(
|
|||||||
)
|
)
|
||||||
|
|
||||||
const handleClose = () => {
|
const handleClose = () => {
|
||||||
|
// 防止在同步进行中关闭对话框
|
||||||
|
if (syncing.value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
emit('close')
|
emit('close')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,53 +1,63 @@
|
|||||||
<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>(), {
|
|||||||
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) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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(() => {
|
|||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
document.removeEventListener('keydown', handleEscape)
|
document.removeEventListener('keydown', handleEscape)
|
||||||
document.body.style.overflow = ''
|
// 确保组件卸载时移除滚动锁定
|
||||||
|
document.body.classList.remove('modal-open')
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
68
frontend/src/components/common/ExportProgressDialog.vue
Normal file
68
frontend/src/components/common/ExportProgressDialog.vue
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
<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>
|
||||||
@@ -1,120 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -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>
|
||||||
</Transition>
|
</Teleport>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -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 =>
|
|||||||
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 = containerRef.value.getBoundingClientRect()
|
const rect = triggerRect.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 - triggerRect.bottom
|
const spaceBelow = viewportHeight - rect.bottom
|
||||||
const spaceAbove = triggerRect.top
|
const spaceAbove = rect.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>) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
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(() => {
|
|||||||
.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(() => {
|
|||||||
.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>
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
// 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'
|
|||||||
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'
|
||||||
|
|||||||
@@ -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 @@
|
|||||||
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()
|
|||||||
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() {
|
|||||||
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()
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 @@
|
|||||||
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 @@
|
|||||||
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 @@
|
|||||||
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()
|
|||||||
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() {
|
|||||||
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 {
|
||||||
|
|||||||
569
frontend/src/composables/useOnboardingTour.ts
Normal file
569
frontend/src/composables/useOnboardingTour.ts
Normal file
@@ -0,0 +1,569 @@
|
|||||||
|
import { onMounted, onUnmounted, nextTick } from 'vue'
|
||||||
|
import { driver, type Driver, type DriveStep } from 'driver.js'
|
||||||
|
import 'driver.js/dist/driver.css'
|
||||||
|
import { useAuthStore as useUserStore } from '@/stores/auth'
|
||||||
|
import { useOnboardingStore } from '@/stores/onboarding'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import { getAdminSteps, getUserSteps } from '@/components/Guide/steps'
|
||||||
|
|
||||||
|
export interface OnboardingOptions {
|
||||||
|
storageKey?: string
|
||||||
|
autoStart?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useOnboardingTour(options: OnboardingOptions) {
|
||||||
|
const { t } = useI18n()
|
||||||
|
const userStore = useUserStore()
|
||||||
|
const onboardingStore = useOnboardingStore()
|
||||||
|
const storageVersion = 'v4_interactive' // Bump version for new tour type
|
||||||
|
|
||||||
|
// Timing constants for better maintainability
|
||||||
|
const TIMING = {
|
||||||
|
INTERACTIVE_WAIT_MS: 800, // Default wait time for interactive steps
|
||||||
|
ELEMENT_TIMEOUT_MS: 8000, // Timeout for element detection
|
||||||
|
AUTO_START_DELAY_MS: 1000 // Delay before auto-starting tour
|
||||||
|
} as const
|
||||||
|
|
||||||
|
// Helper: Check if a step is interactive (only close button shown)
|
||||||
|
const isInteractiveStep = (step: DriveStep): boolean => {
|
||||||
|
return step.popover?.showButtons?.length === 1 &&
|
||||||
|
step.popover.showButtons[0] === 'close'
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper: Clean up click listener
|
||||||
|
const cleanupClickListener = () => {
|
||||||
|
if (!currentClickListener) return
|
||||||
|
const { element: el, handler, keyHandler, originalTabIndex, eventTypes } = currentClickListener
|
||||||
|
if (eventTypes) {
|
||||||
|
eventTypes.forEach(type => el.removeEventListener(type, handler))
|
||||||
|
}
|
||||||
|
if (keyHandler) el.removeEventListener('keydown', keyHandler)
|
||||||
|
if (originalTabIndex !== undefined) {
|
||||||
|
if (originalTabIndex === null) el.removeAttribute('tabindex')
|
||||||
|
else el.setAttribute('tabindex', originalTabIndex)
|
||||||
|
}
|
||||||
|
currentClickListener = null
|
||||||
|
}
|
||||||
|
|
||||||
|
// 使用 store 管理的全局 driver 实例
|
||||||
|
let driverInstance: Driver | null = onboardingStore.getDriverInstance()
|
||||||
|
let currentClickListener: {
|
||||||
|
element: HTMLElement
|
||||||
|
handler: () => void
|
||||||
|
keyHandler?: (e: KeyboardEvent) => void
|
||||||
|
originalTabIndex?: string | null
|
||||||
|
eventTypes?: string[] // Track which event types were added
|
||||||
|
} | null = null
|
||||||
|
let autoStartTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
|
let globalKeyboardHandler: ((e: KeyboardEvent) => void) | null = null
|
||||||
|
|
||||||
|
const getStorageKey = () => {
|
||||||
|
const baseKey = options.storageKey ?? 'onboarding_tour'
|
||||||
|
const userId = userStore.user?.id ?? 'guest'
|
||||||
|
const role = userStore.user?.role ?? 'user'
|
||||||
|
return `${baseKey}_${userId}_${role}_${storageVersion}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasSeen = () => {
|
||||||
|
return localStorage.getItem(getStorageKey()) === 'true'
|
||||||
|
}
|
||||||
|
|
||||||
|
const markAsSeen = () => {
|
||||||
|
localStorage.setItem(getStorageKey(), 'true')
|
||||||
|
}
|
||||||
|
|
||||||
|
const clearSeen = () => {
|
||||||
|
localStorage.removeItem(getStorageKey())
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查元素是否存在,如果不存在则重试
|
||||||
|
*/
|
||||||
|
const ensureElement = async (selector: string, timeout = 5000): Promise<boolean> => {
|
||||||
|
const startTime = Date.now()
|
||||||
|
while (Date.now() - startTime < timeout) {
|
||||||
|
const element = document.querySelector(selector)
|
||||||
|
if (element && element.getBoundingClientRect().height > 0) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 150))
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const startTour = async (startIndex = 0) => {
|
||||||
|
// 动态获取当前用户角色和步骤
|
||||||
|
const isAdmin = userStore.user?.role === 'admin'
|
||||||
|
const isSimpleMode = userStore.isSimpleMode
|
||||||
|
const steps = isAdmin ? getAdminSteps(t, isSimpleMode) : getUserSteps(t)
|
||||||
|
|
||||||
|
// 确保 DOM 就绪
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
// 如果指定了起始步骤,确保元素可见
|
||||||
|
const currentStep = steps[startIndex]
|
||||||
|
if (currentStep?.element && typeof currentStep.element === 'string') {
|
||||||
|
await ensureElement(currentStep.element, TIMING.ELEMENT_TIMEOUT_MS)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (driverInstance) {
|
||||||
|
driverInstance.destroy()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建新的 driver 实例并存储到 store
|
||||||
|
driverInstance = driver({
|
||||||
|
showProgress: true,
|
||||||
|
steps,
|
||||||
|
animate: true,
|
||||||
|
allowClose: false, // 禁止点击遮罩关闭
|
||||||
|
stagePadding: 4,
|
||||||
|
popoverClass: 'theme-tour-popover',
|
||||||
|
nextBtnText: t('common.next'),
|
||||||
|
prevBtnText: t('common.back'),
|
||||||
|
doneBtnText: t('common.confirm'),
|
||||||
|
|
||||||
|
// 导航处理
|
||||||
|
onNextClick: async (_el, _step, { config, state }) => {
|
||||||
|
// 如果是最后一步,点击则是"完成"
|
||||||
|
if (state.activeIndex === (config.steps?.length ?? 0) - 1) {
|
||||||
|
markAsSeen()
|
||||||
|
driverInstance?.destroy()
|
||||||
|
onboardingStore.setDriverInstance(null)
|
||||||
|
} else {
|
||||||
|
// 注意:交互式步骤通常隐藏 Next 按钮,此处逻辑为防御性编程
|
||||||
|
const currentIndex = state.activeIndex ?? 0
|
||||||
|
const currentStep = steps[currentIndex]
|
||||||
|
|
||||||
|
if (currentStep && isInteractiveStep(currentStep) && currentStep.element) {
|
||||||
|
const targetElement = typeof currentStep.element === 'string'
|
||||||
|
? document.querySelector(currentStep.element) as HTMLElement
|
||||||
|
: currentStep.element as HTMLElement
|
||||||
|
|
||||||
|
if (targetElement) {
|
||||||
|
const isClickable = !['INPUT', 'TEXTAREA', 'SELECT'].includes(targetElement.tagName)
|
||||||
|
if (isClickable) {
|
||||||
|
targetElement.click()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
driverInstance?.moveNext()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onPrevClick: () => {
|
||||||
|
driverInstance?.movePrevious()
|
||||||
|
},
|
||||||
|
onCloseClick: () => {
|
||||||
|
markAsSeen()
|
||||||
|
driverInstance?.destroy()
|
||||||
|
onboardingStore.setDriverInstance(null)
|
||||||
|
},
|
||||||
|
|
||||||
|
// 渲染时重组 Footer 布局
|
||||||
|
onPopoverRender: (popover, { config, state }) => {
|
||||||
|
// Class name constants for easier maintenance
|
||||||
|
const CLASS_REORGANIZED = 'reorganized'
|
||||||
|
const CLASS_FOOTER_LEFT = 'footer-left'
|
||||||
|
const CLASS_FOOTER_RIGHT = 'footer-right'
|
||||||
|
const CLASS_DONE_BTN = 'driver-popover-done-btn'
|
||||||
|
const CLASS_PROGRESS_TEXT = 'driver-popover-progress-text'
|
||||||
|
const CLASS_NEXT_BTN = 'driver-popover-next-btn'
|
||||||
|
const CLASS_PREV_BTN = 'driver-popover-prev-btn'
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { title: titleEl, footer: footerEl, nextButton, previousButton } = popover
|
||||||
|
|
||||||
|
// Defensive check: ensure popover elements exist
|
||||||
|
if (!titleEl || !footerEl) {
|
||||||
|
console.warn('Onboarding: Missing popover elements')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1.5 交互式步骤提示
|
||||||
|
const currentStep = steps[state.activeIndex ?? 0]
|
||||||
|
|
||||||
|
if (currentStep && isInteractiveStep(currentStep) && popover.description) {
|
||||||
|
const hintClass = 'driver-popover-description-hint'
|
||||||
|
if (!popover.description.querySelector(`.${hintClass}`)) {
|
||||||
|
const hint = document.createElement('div')
|
||||||
|
hint.className = `${hintClass} mt-2 text-xs text-gray-500 flex items-center gap-1`
|
||||||
|
|
||||||
|
const iconSpan = document.createElement('span')
|
||||||
|
iconSpan.className = 'i-mdi-keyboard-return mr-1'
|
||||||
|
|
||||||
|
const textNode = document.createTextNode(
|
||||||
|
t('onboarding.interactiveHint', 'Press Enter or Click to continue'),
|
||||||
|
)
|
||||||
|
|
||||||
|
hint.appendChild(iconSpan)
|
||||||
|
hint.appendChild(textNode)
|
||||||
|
popover.description.appendChild(hint)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 底部:DOM 重组
|
||||||
|
if (!footerEl.classList.contains(CLASS_REORGANIZED)) {
|
||||||
|
footerEl.classList.add(CLASS_REORGANIZED)
|
||||||
|
|
||||||
|
const progressEl = footerEl.querySelector(`.${CLASS_PROGRESS_TEXT}`)
|
||||||
|
const nextBtnEl = nextButton || footerEl.querySelector(`.${CLASS_NEXT_BTN}`)
|
||||||
|
const prevBtnEl = previousButton || footerEl.querySelector(`.${CLASS_PREV_BTN}`)
|
||||||
|
|
||||||
|
const leftContainer = document.createElement('div')
|
||||||
|
leftContainer.className = CLASS_FOOTER_LEFT
|
||||||
|
|
||||||
|
const rightContainer = document.createElement('div')
|
||||||
|
rightContainer.className = CLASS_FOOTER_RIGHT
|
||||||
|
|
||||||
|
if (progressEl) leftContainer.appendChild(progressEl)
|
||||||
|
|
||||||
|
const shortcutsEl = document.createElement('div')
|
||||||
|
shortcutsEl.className = 'footer-shortcuts'
|
||||||
|
|
||||||
|
const shortcut1 = document.createElement('span')
|
||||||
|
shortcut1.className = 'shortcut-item'
|
||||||
|
const kbd1 = document.createElement('kbd')
|
||||||
|
kbd1.textContent = '←'
|
||||||
|
const kbd2 = document.createElement('kbd')
|
||||||
|
kbd2.textContent = '→'
|
||||||
|
shortcut1.appendChild(kbd1)
|
||||||
|
shortcut1.appendChild(kbd2)
|
||||||
|
shortcut1.appendChild(
|
||||||
|
document.createTextNode(` ${t('onboarding.navigation.flipPage')}`),
|
||||||
|
)
|
||||||
|
|
||||||
|
const shortcut2 = document.createElement('span')
|
||||||
|
shortcut2.className = 'shortcut-item'
|
||||||
|
const kbd3 = document.createElement('kbd')
|
||||||
|
kbd3.textContent = 'ESC'
|
||||||
|
shortcut2.appendChild(kbd3)
|
||||||
|
shortcut2.appendChild(
|
||||||
|
document.createTextNode(` ${t('onboarding.navigation.exit')}`),
|
||||||
|
)
|
||||||
|
|
||||||
|
shortcutsEl.appendChild(shortcut1)
|
||||||
|
shortcutsEl.appendChild(shortcut2)
|
||||||
|
leftContainer.appendChild(shortcutsEl)
|
||||||
|
|
||||||
|
if (prevBtnEl) rightContainer.appendChild(prevBtnEl)
|
||||||
|
if (nextBtnEl) rightContainer.appendChild(nextBtnEl)
|
||||||
|
|
||||||
|
footerEl.innerHTML = ''
|
||||||
|
footerEl.appendChild(leftContainer)
|
||||||
|
footerEl.appendChild(rightContainer)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 状态更新
|
||||||
|
const isLastStep = state.activeIndex === (config.steps?.length ?? 0) - 1
|
||||||
|
const activeNextBtn = nextButton || footerEl.querySelector(`.${CLASS_NEXT_BTN}`)
|
||||||
|
|
||||||
|
if (activeNextBtn) {
|
||||||
|
if (isLastStep) {
|
||||||
|
activeNextBtn.classList.add(CLASS_DONE_BTN)
|
||||||
|
} else {
|
||||||
|
activeNextBtn.classList.remove(CLASS_DONE_BTN)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Onboarding Tour Render Error:', e)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// 步骤高亮时触发
|
||||||
|
onHighlightStarted: async (element, step) => {
|
||||||
|
// 清理之前的监听器
|
||||||
|
cleanupClickListener()
|
||||||
|
|
||||||
|
// 尝试等待元素
|
||||||
|
if (!element && step.element && typeof step.element === 'string') {
|
||||||
|
const exists = await ensureElement(step.element, 8000)
|
||||||
|
if (!exists) {
|
||||||
|
console.warn(`Tour element not found after 8s: ${step.element}`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
element = document.querySelector(step.element) as HTMLElement
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isInteractiveStep(step) && element) {
|
||||||
|
const htmlElement = element as HTMLElement
|
||||||
|
|
||||||
|
// Check if this is a submit button - if so, don't bind auto-advance listeners
|
||||||
|
// Let business code (e.g., handleCreateGroup) manually call nextStep after success
|
||||||
|
const isSubmitButton = htmlElement.getAttribute('type') === 'submit' ||
|
||||||
|
(htmlElement.tagName === 'BUTTON' && htmlElement.closest('form'))
|
||||||
|
|
||||||
|
if (isSubmitButton) {
|
||||||
|
return // Don't bind any click listeners for submit buttons
|
||||||
|
}
|
||||||
|
|
||||||
|
const originalTabIndex = htmlElement.getAttribute('tabindex')
|
||||||
|
if (!htmlElement.isContentEditable && htmlElement.tabIndex === -1) {
|
||||||
|
htmlElement.setAttribute('tabindex', '0')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enhanced Select component detection - check both children and self
|
||||||
|
const isSelectComponent = htmlElement.querySelector('.select-trigger') !== null ||
|
||||||
|
htmlElement.classList.contains('select-trigger')
|
||||||
|
|
||||||
|
// Select dropdowns are teleported to <body>, so click events on options
|
||||||
|
// won't bubble through this element. Skip auto-advance for Select components.
|
||||||
|
// Users navigate using Next/Previous buttons after making their selection.
|
||||||
|
if (isSelectComponent) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Single-execution protection flag
|
||||||
|
let hasExecuted = false
|
||||||
|
|
||||||
|
// Capture the step index when binding the handler
|
||||||
|
const boundStepIndex = driverInstance?.getActiveIndex() ?? 0
|
||||||
|
|
||||||
|
const clickHandler = async () => {
|
||||||
|
// Prevent duplicate execution
|
||||||
|
if (hasExecuted) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
hasExecuted = true
|
||||||
|
|
||||||
|
// Wait before advancing to allow user to see the result of their action
|
||||||
|
await new Promise(resolve => setTimeout(resolve, TIMING.INTERACTIVE_WAIT_MS))
|
||||||
|
|
||||||
|
// Verify driver is still active and not destroyed
|
||||||
|
if (!driverInstance || !driverInstance.isActive()) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if we're still on the same step - abort if step changed during wait
|
||||||
|
const currentIndex = driverInstance.getActiveIndex() ?? 0
|
||||||
|
if (currentIndex !== boundStepIndex) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextStep = steps[currentIndex + 1]
|
||||||
|
|
||||||
|
if (nextStep?.element && typeof nextStep.element === 'string') {
|
||||||
|
const exists = await ensureElement(nextStep.element, TIMING.ELEMENT_TIMEOUT_MS)
|
||||||
|
if (!exists) {
|
||||||
|
console.warn(`Onboarding: Next step element not found: ${nextStep.element}`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Final check before moving
|
||||||
|
if (driverInstance && driverInstance.isActive()) {
|
||||||
|
driverInstance.moveNext()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// For input fields, advance on input/change events instead of click
|
||||||
|
const isInputField = ['INPUT', 'TEXTAREA', 'SELECT'].includes(htmlElement.tagName)
|
||||||
|
|
||||||
|
if (isInputField) {
|
||||||
|
const inputHandler = () => {
|
||||||
|
// Remove listener after first input
|
||||||
|
htmlElement.removeEventListener('input', inputHandler)
|
||||||
|
htmlElement.removeEventListener('change', inputHandler)
|
||||||
|
clickHandler()
|
||||||
|
}
|
||||||
|
|
||||||
|
htmlElement.addEventListener('input', inputHandler)
|
||||||
|
htmlElement.addEventListener('change', inputHandler)
|
||||||
|
|
||||||
|
currentClickListener = {
|
||||||
|
element: htmlElement,
|
||||||
|
handler: inputHandler,
|
||||||
|
originalTabIndex,
|
||||||
|
eventTypes: ['input', 'change']
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const keyHandler = (e: KeyboardEvent) => {
|
||||||
|
if (['Enter', ' '].includes(e.key)) {
|
||||||
|
e.preventDefault()
|
||||||
|
clickHandler()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
htmlElement.addEventListener('click', clickHandler, { once: true })
|
||||||
|
htmlElement.addEventListener('keydown', keyHandler)
|
||||||
|
|
||||||
|
currentClickListener = {
|
||||||
|
element: htmlElement,
|
||||||
|
handler: clickHandler as () => void,
|
||||||
|
keyHandler,
|
||||||
|
originalTabIndex,
|
||||||
|
eventTypes: ['click']
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
onDestroyed: () => {
|
||||||
|
cleanupClickListener()
|
||||||
|
// 清理全局监听器 (由此处唯一管理)
|
||||||
|
if (globalKeyboardHandler) {
|
||||||
|
document.removeEventListener('keydown', globalKeyboardHandler, { capture: true })
|
||||||
|
globalKeyboardHandler = null
|
||||||
|
}
|
||||||
|
onboardingStore.setDriverInstance(null)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
onboardingStore.setDriverInstance(driverInstance)
|
||||||
|
|
||||||
|
// 添加全局键盘监听器
|
||||||
|
globalKeyboardHandler = (e: KeyboardEvent) => {
|
||||||
|
if (!driverInstance?.isActive()) return
|
||||||
|
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
markAsSeen()
|
||||||
|
driverInstance.destroy()
|
||||||
|
onboardingStore.setDriverInstance(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (e.key === 'ArrowRight') {
|
||||||
|
const target = e.target as HTMLElement
|
||||||
|
// 允许在输入框中使用方向键
|
||||||
|
if (['INPUT', 'TEXTAREA'].includes(target?.tagName)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
|
||||||
|
// 对于交互式步骤,箭头键应该触发交互而非跳过
|
||||||
|
const currentIndex = driverInstance!.getActiveIndex() ?? 0
|
||||||
|
const currentStep = steps[currentIndex]
|
||||||
|
|
||||||
|
if (currentStep && isInteractiveStep(currentStep) && currentStep.element) {
|
||||||
|
const targetElement = typeof currentStep.element === 'string'
|
||||||
|
? document.querySelector(currentStep.element) as HTMLElement
|
||||||
|
: currentStep.element as HTMLElement
|
||||||
|
|
||||||
|
if (targetElement) {
|
||||||
|
// 对于非输入类元素,提示用户需要点击或按Enter
|
||||||
|
const isClickable = !['INPUT', 'TEXTAREA', 'SELECT'].includes(targetElement.tagName)
|
||||||
|
if (isClickable) {
|
||||||
|
// 不自动触发,只是停留提示
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 非交互式步骤才允许箭头键翻页
|
||||||
|
driverInstance!.moveNext()
|
||||||
|
}
|
||||||
|
else if (e.key === 'Enter') {
|
||||||
|
const target = e.target as HTMLElement
|
||||||
|
// 允许在输入框中使用回车
|
||||||
|
if (['INPUT', 'TEXTAREA'].includes(target?.tagName)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
|
||||||
|
// 回车键处理交互式步骤
|
||||||
|
const currentIndex = driverInstance!.getActiveIndex() ?? 0
|
||||||
|
const currentStep = steps[currentIndex]
|
||||||
|
|
||||||
|
if (currentStep && isInteractiveStep(currentStep) && currentStep.element) {
|
||||||
|
const targetElement = typeof currentStep.element === 'string'
|
||||||
|
? document.querySelector(currentStep.element) as HTMLElement
|
||||||
|
: currentStep.element as HTMLElement
|
||||||
|
|
||||||
|
if (targetElement) {
|
||||||
|
const isClickable = !['INPUT', 'TEXTAREA', 'SELECT'].includes(targetElement.tagName)
|
||||||
|
if (isClickable) {
|
||||||
|
targetElement.click()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
driverInstance!.moveNext()
|
||||||
|
}
|
||||||
|
else if (e.key === 'ArrowLeft') {
|
||||||
|
const target = e.target as HTMLElement
|
||||||
|
// 允许在输入框中使用方向键
|
||||||
|
if (['INPUT', 'TEXTAREA', 'SELECT'].includes(target?.tagName) || target?.isContentEditable) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
driverInstance.movePrevious()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('keydown', globalKeyboardHandler, { capture: true })
|
||||||
|
driverInstance.drive(startIndex)
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextStep = async (delay = 300) => {
|
||||||
|
if (!driverInstance?.isActive()) return
|
||||||
|
if (delay > 0) {
|
||||||
|
await new Promise(resolve => setTimeout(resolve, delay))
|
||||||
|
}
|
||||||
|
driverInstance.moveNext()
|
||||||
|
}
|
||||||
|
|
||||||
|
const isCurrentStep = (elementSelector: string): boolean => {
|
||||||
|
if (!driverInstance?.isActive()) return false
|
||||||
|
const activeElement = driverInstance.getActiveElement()
|
||||||
|
return activeElement?.matches(elementSelector) ?? false
|
||||||
|
}
|
||||||
|
|
||||||
|
const replayTour = () => {
|
||||||
|
clearSeen()
|
||||||
|
void startTour()
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
onboardingStore.setControlMethods({
|
||||||
|
nextStep,
|
||||||
|
isCurrentStep
|
||||||
|
})
|
||||||
|
|
||||||
|
if (onboardingStore.isDriverActive()) {
|
||||||
|
driverInstance = onboardingStore.getDriverInstance()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 简易模式下禁用新手引导
|
||||||
|
if (userStore.isSimpleMode) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 只在管理员+标准模式下自动启动
|
||||||
|
const isAdmin = userStore.user?.role === 'admin'
|
||||||
|
if (!isAdmin) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!options.autoStart || hasSeen()) return
|
||||||
|
autoStartTimer = setTimeout(() => {
|
||||||
|
void startTour()
|
||||||
|
}, TIMING.AUTO_START_DELAY_MS)
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
if (autoStartTimer) {
|
||||||
|
clearTimeout(autoStartTimer)
|
||||||
|
autoStartTimer = null
|
||||||
|
}
|
||||||
|
// 关键修复:不再此处清理 globalKeyboardHandler,交由 driver.onDestroyed 管理
|
||||||
|
onboardingStore.clearControlMethods()
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
startTour,
|
||||||
|
replayTour,
|
||||||
|
nextStep,
|
||||||
|
isCurrentStep,
|
||||||
|
hasSeen,
|
||||||
|
markAsSeen,
|
||||||
|
clearSeen
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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) {
|
||||||
|
|||||||
@@ -327,7 +327,8 @@ export default {
|
|||||||
customKeyHint: 'Only letters, numbers, underscores and hyphens allowed. Minimum 16 characters.',
|
customKeyHint: 'Only letters, numbers, underscores and hyphens allowed. Minimum 16 characters.',
|
||||||
customKeyTooShort: 'Custom key must be at least 16 characters',
|
customKeyTooShort: 'Custom key must be at least 16 characters',
|
||||||
customKeyInvalidChars: 'Custom key can only contain letters, numbers, underscores, and hyphens',
|
customKeyInvalidChars: 'Custom key can only contain letters, numbers, underscores, and hyphens',
|
||||||
customKeyRequired: 'Please enter a custom key'
|
customKeyRequired: 'Please enter a custom key',
|
||||||
|
ccSwitchNotInstalled: 'CC-Switch is not installed or the protocol handler is not registered. Please install CC-Switch first or manually copy the API key.'
|
||||||
},
|
},
|
||||||
|
|
||||||
// Usage
|
// Usage
|
||||||
@@ -346,6 +347,12 @@ export default {
|
|||||||
allApiKeys: 'All API Keys',
|
allApiKeys: 'All API Keys',
|
||||||
timeRange: 'Time Range',
|
timeRange: 'Time Range',
|
||||||
exportCsv: 'Export CSV',
|
exportCsv: 'Export CSV',
|
||||||
|
exportExcel: 'Export Excel',
|
||||||
|
exportingProgress: 'Exporting data...',
|
||||||
|
exportedCount: 'Exported {current}/{total} records',
|
||||||
|
estimatedTime: 'Estimated time remaining: {time}',
|
||||||
|
cancelExport: 'Cancel Export',
|
||||||
|
exportCancelled: 'Export cancelled',
|
||||||
exporting: 'Exporting...',
|
exporting: 'Exporting...',
|
||||||
preparingExport: 'Preparing export...',
|
preparingExport: 'Preparing export...',
|
||||||
model: 'Model',
|
model: 'Model',
|
||||||
@@ -369,6 +376,8 @@ export default {
|
|||||||
noDataToExport: 'No data to export',
|
noDataToExport: 'No data to export',
|
||||||
exportSuccess: 'Usage data exported successfully',
|
exportSuccess: 'Usage data exported successfully',
|
||||||
exportFailed: 'Failed to export usage data',
|
exportFailed: 'Failed to export usage data',
|
||||||
|
exportExcelSuccess: 'Usage data exported successfully (Excel format)',
|
||||||
|
exportExcelFailed: 'Failed to export usage data',
|
||||||
billingType: 'Billing',
|
billingType: 'Billing',
|
||||||
balance: 'Balance',
|
balance: 'Balance',
|
||||||
subscription: 'Subscription'
|
subscription: 'Subscription'
|
||||||
@@ -1329,6 +1338,7 @@ export default {
|
|||||||
account: 'Account',
|
account: 'Account',
|
||||||
group: 'Group',
|
group: 'Group',
|
||||||
requestId: 'Request ID',
|
requestId: 'Request ID',
|
||||||
|
requestIdCopied: 'Request ID copied',
|
||||||
allModels: 'All Models',
|
allModels: 'All Models',
|
||||||
allAccounts: 'All Accounts',
|
allAccounts: 'All Accounts',
|
||||||
allGroups: 'All Groups',
|
allGroups: 'All Groups',
|
||||||
@@ -1338,6 +1348,10 @@ export default {
|
|||||||
outputCost: 'Output Cost',
|
outputCost: 'Output Cost',
|
||||||
cacheCreationCost: 'Cache Creation Cost',
|
cacheCreationCost: 'Cache Creation Cost',
|
||||||
cacheReadCost: 'Cache Read Cost',
|
cacheReadCost: 'Cache Read Cost',
|
||||||
|
inputTokens: 'Input Tokens',
|
||||||
|
outputTokens: 'Output Tokens',
|
||||||
|
cacheCreationTokens: 'Cache Creation Tokens',
|
||||||
|
cacheReadTokens: 'Cache Read Tokens',
|
||||||
failedToLoad: 'Failed to load usage records'
|
failedToLoad: 'Failed to load usage records'
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -1527,5 +1541,150 @@ export default {
|
|||||||
resetIn: 'Resets in {time}',
|
resetIn: 'Resets in {time}',
|
||||||
windowNotActive: 'Awaiting first use',
|
windowNotActive: 'Awaiting first use',
|
||||||
usageOf: '{used} of {limit}'
|
usageOf: '{used} of {limit}'
|
||||||
|
},
|
||||||
|
|
||||||
|
// Onboarding Tour
|
||||||
|
onboarding: {
|
||||||
|
restartTour: 'Restart Onboarding Tour',
|
||||||
|
dontShowAgain: "Don't show again",
|
||||||
|
dontShowAgainTitle: 'Permanently close onboarding guide',
|
||||||
|
confirmDontShow: "Are you sure you don't want to see the onboarding guide again?\n\nYou can restart it anytime from the user menu in the top right corner.",
|
||||||
|
confirmExit: 'Are you sure you want to exit the onboarding guide? You can restart it anytime from the top right menu.',
|
||||||
|
interactiveHint: 'Press Enter or Click to continue',
|
||||||
|
navigation: {
|
||||||
|
flipPage: 'Flip Page',
|
||||||
|
exit: 'Exit'
|
||||||
|
},
|
||||||
|
// Admin tour steps
|
||||||
|
admin: {
|
||||||
|
welcome: {
|
||||||
|
title: '👋 Welcome to Sub2API',
|
||||||
|
description: '<div style="line-height: 1.8;"><p style="margin-bottom: 16px;">Sub2API is a powerful AI service gateway platform that helps you easily manage and distribute AI services.</p><p style="margin-bottom: 12px;"><b>🎯 Core Features:</b></p><ul style="margin-left: 20px; margin-bottom: 16px;"><li>📦 <b>Group Management</b> - Create service tiers (VIP, Free Trial, etc.)</li><li>🔗 <b>Account Pool</b> - Connect multiple upstream AI service accounts</li><li>🔑 <b>Key Distribution</b> - Generate independent API Keys for users</li><li>💰 <b>Billing Control</b> - Flexible rate and quota management</li></ul><p style="color: #10b981; font-weight: 600;">Let\'s complete the initial setup in 3 minutes →</p></div>',
|
||||||
|
nextBtn: 'Start Setup 🚀',
|
||||||
|
prevBtn: 'Skip'
|
||||||
|
},
|
||||||
|
groupManage: {
|
||||||
|
title: '📦 Step 1: Group Management',
|
||||||
|
description: '<div style="line-height: 1.7;"><p style="margin-bottom: 12px;"><b>What is a Group?</b></p><p style="margin-bottom: 12px;">Groups are the core concept of Sub2API, like a "service package":</p><ul style="margin-left: 20px; margin-bottom: 12px; font-size: 13px;"><li>🎯 Each group can contain multiple upstream accounts</li><li>💰 Each group has independent billing multiplier</li><li>👥 Can be set as public or exclusive</li></ul><p style="margin-top: 12px; padding: 8px 12px; background: #f0fdf4; border-left: 3px solid #10b981; border-radius: 4px; font-size: 13px;"><b>💡 Example:</b> You can create "VIP Premium" (high rate) and "Free Trial" (low rate) groups</p><p style="margin-top: 16px; color: #10b981; font-weight: 600;">👉 Click "Group Management" on the left sidebar</p></div>'
|
||||||
|
},
|
||||||
|
createGroup: {
|
||||||
|
title: '➕ Create New Group',
|
||||||
|
description: '<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">Let\'s create your first group.</p><p style="padding: 8px 12px; background: #eff6ff; border-left: 3px solid #3b82f6; border-radius: 4px; font-size: 13px; margin-bottom: 12px;"><b>📝 Tip:</b> Recommend creating a test group first to familiarize yourself with the process</p><p style="color: #10b981; font-weight: 600;">👉 Click the "Create Group" button</p></div>'
|
||||||
|
},
|
||||||
|
groupName: {
|
||||||
|
title: '✏️ 1. Group Name',
|
||||||
|
description: '<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">Give your group an easy-to-identify name.</p><div style="padding: 8px 12px; background: #f0fdf4; border-left: 3px solid #10b981; border-radius: 4px; font-size: 13px; margin-bottom: 12px;"><b>💡 Naming Suggestions:</b><ul style="margin: 8px 0 0 16px;"><li>"Test Group" - For testing</li><li>"VIP Premium" - High-quality service</li><li>"Free Trial" - Trial version</li></ul></div><p style="font-size: 13px; color: #6b7280;">Click "Next" when done</p></div>',
|
||||||
|
nextBtn: 'Next'
|
||||||
|
},
|
||||||
|
groupPlatform: {
|
||||||
|
title: '🤖 2. Select Platform',
|
||||||
|
description: '<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">Choose the AI platform this group supports.</p><div style="padding: 8px 12px; background: #eff6ff; border-left: 3px solid #3b82f6; border-radius: 4px; font-size: 13px; margin-bottom: 12px;"><b>📌 Platform Guide:</b><ul style="margin: 8px 0 0 16px;"><li><b>Anthropic</b> - Claude models</li><li><b>OpenAI</b> - GPT models</li><li><b>Google</b> - Gemini models</li></ul></div><p style="font-size: 13px; color: #6b7280;">One group can only have one platform</p></div>',
|
||||||
|
nextBtn: 'Next'
|
||||||
|
},
|
||||||
|
groupMultiplier: {
|
||||||
|
title: '💰 3. Rate Multiplier',
|
||||||
|
description: '<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">Set the billing multiplier to control user charges.</p><div style="padding: 8px 12px; background: #fef3c7; border-left: 3px solid #f59e0b; border-radius: 4px; font-size: 13px; margin-bottom: 12px;"><b>⚙️ Billing Rules:</b><ul style="margin: 8px 0 0 16px;"><li><b>1.0</b> - Original price (cost price)</li><li><b>1.5</b> - User consumes $1, charged $1.5</li><li><b>2.0</b> - User consumes $1, charged $2</li><li><b>0.8</b> - Subsidy mode (loss-making)</li></ul></div><p style="font-size: 13px; color: #6b7280;">Recommend setting test group to 1.0</p></div>',
|
||||||
|
nextBtn: 'Next'
|
||||||
|
},
|
||||||
|
groupExclusive: {
|
||||||
|
title: '🔒 4. Exclusive Group (Optional)',
|
||||||
|
description: '<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">Control group visibility and access permissions.</p><div style="padding: 8px 12px; background: #eff6ff; border-left: 3px solid #3b82f6; border-radius: 4px; font-size: 13px; margin-bottom: 12px;"><b>🔐 Permission Guide:</b><ul style="margin: 8px 0 0 16px;"><li><b>Off</b> - Public group, visible to all users</li><li><b>On</b> - Exclusive group, only for specified users</li></ul></div><p style="padding: 8px 12px; background: #f0fdf4; border-left: 3px solid #10b981; border-radius: 4px; font-size: 13px;"><b>💡 Use Cases:</b> VIP exclusive, internal testing, special customers</p></div>',
|
||||||
|
nextBtn: 'Next'
|
||||||
|
},
|
||||||
|
groupSubmit: {
|
||||||
|
title: '✅ Save Group',
|
||||||
|
description: '<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">Confirm the information and click create to save the group.</p><p style="padding: 8px 12px; background: #fef3c7; border-left: 3px solid #f59e0b; border-radius: 4px; font-size: 13px; margin-bottom: 12px;"><b>⚠️ Note:</b> Platform type cannot be changed after creation, but other settings can be edited anytime</p><p style="padding: 8px 12px; background: #f0fdf4; border-left: 3px solid #10b981; border-radius: 4px; font-size: 13px;"><b>📌 Next Step:</b> After creation, we\'ll add upstream accounts to this group</p><p style="margin-top: 12px; color: #10b981; font-weight: 600;">👉 Click "Create" button</p></div>'
|
||||||
|
},
|
||||||
|
accountManage: {
|
||||||
|
title: '🔗 Step 2: Add Account',
|
||||||
|
description: '<div style="line-height: 1.7;"><p style="margin-bottom: 12px;"><b>Great! Group created successfully 🎉</b></p><p style="margin-bottom: 12px;">Now add upstream AI service accounts to enable actual service delivery.</p><div style="padding: 8px 12px; background: #eff6ff; border-left: 3px solid #3b82f6; border-radius: 4px; font-size: 13px; margin-bottom: 12px;"><b>🔑 Account Purpose:</b><ul style="margin: 8px 0 0 16px;"><li>Connect to upstream AI services (Claude, GPT, etc.)</li><li>One group can contain multiple accounts (load balancing)</li><li>Supports OAuth and Session Key methods</li></ul></div><p style="margin-top: 16px; color: #10b981; font-weight: 600;">👉 Click "Account Management" on the left sidebar</p></div>'
|
||||||
|
},
|
||||||
|
createAccount: {
|
||||||
|
title: '➕ Add New Account',
|
||||||
|
description: '<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">Click the button to start adding your first upstream account.</p><p style="padding: 8px 12px; background: #f0fdf4; border-left: 3px solid #10b981; border-radius: 4px; font-size: 13px;"><b>💡 Tip:</b> Recommend using OAuth method - more secure and no manual key extraction needed</p><p style="margin-top: 12px; color: #10b981; font-weight: 600;">👉 Click "Add Account" button</p></div>'
|
||||||
|
},
|
||||||
|
accountName: {
|
||||||
|
title: '✏️ 1. Account Name',
|
||||||
|
description: '<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">Set an easy-to-identify name for the account.</p><p style="padding: 8px 12px; background: #f0fdf4; border-left: 3px solid #10b981; border-radius: 4px; font-size: 13px;"><b>💡 Naming Suggestions:</b> "Claude Main", "GPT Backup 1", "Test Account", etc.</p></div>',
|
||||||
|
nextBtn: 'Next'
|
||||||
|
},
|
||||||
|
accountPlatform: {
|
||||||
|
title: '🤖 2. Select Platform',
|
||||||
|
description: '<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">Choose the service provider platform for this account.</p><p style="padding: 8px 12px; background: #fef3c7; border-left: 3px solid #f59e0b; border-radius: 4px; font-size: 13px;"><b>⚠️ Important:</b> Platform must match the group you just created</p></div>',
|
||||||
|
nextBtn: 'Next'
|
||||||
|
},
|
||||||
|
accountType: {
|
||||||
|
title: '🔐 3. Authorization Method',
|
||||||
|
description: '<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">Choose the account authorization method.</p><div style="padding: 8px 12px; background: #f0fdf4; border-left: 3px solid #10b981; border-radius: 4px; font-size: 13px; margin-bottom: 12px;"><b>✅ Recommended: OAuth Method</b><ul style="margin: 8px 0 0 16px;"><li>No manual key extraction needed</li><li>More secure with auto-refresh support</li><li>Works with Claude Code, ChatGPT OAuth</li></ul></div><div style="padding: 8px 12px; background: #eff6ff; border-left: 3px solid #3b82f6; border-radius: 4px; font-size: 13px;"><b>📌 Session Key Method</b><ul style="margin: 8px 0 0 16px;"><li>Requires manual extraction from browser</li><li>May need periodic updates</li><li>For platforms without OAuth support</li></ul></div></div>',
|
||||||
|
nextBtn: 'Next'
|
||||||
|
},
|
||||||
|
accountPriority: {
|
||||||
|
title: '⚖️ 4. Priority (Optional)',
|
||||||
|
description: '<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">Set the account call priority.</p><div style="padding: 8px 12px; background: #eff6ff; border-left: 3px solid #3b82f6; border-radius: 4px; font-size: 13px; margin-bottom: 12px;"><b>📊 Priority Rules:</b><ul style="margin: 8px 0 0 16px;"><li>Higher number = higher priority</li><li>System uses high-priority accounts first</li><li>Same priority = random selection</li></ul></div><p style="padding: 8px 12px; background: #f0fdf4; border-left: 3px solid #10b981; border-radius: 4px; font-size: 13px;"><b>💡 Use Case:</b> Set main account to high priority, backup accounts to low priority</p></div>',
|
||||||
|
nextBtn: 'Next'
|
||||||
|
},
|
||||||
|
accountGroups: {
|
||||||
|
title: '🎯 5. Assign Groups',
|
||||||
|
description: '<div style="line-height: 1.7;"><p style="margin-bottom: 12px;"><b>Key Step!</b> Assign the account to the group you just created.</p><div style="padding: 8px 12px; background: #fee2e2; border-left: 3px solid #ef4444; border-radius: 4px; font-size: 13px; margin-bottom: 12px;"><b>⚠️ Important Reminder:</b><ul style="margin: 8px 0 0 16px;"><li>Must select at least one group</li><li>Unassigned accounts cannot be used</li><li>One account can be assigned to multiple groups</li></ul></div><p style="padding: 8px 12px; background: #f0fdf4; border-left: 3px solid #10b981; border-radius: 4px; font-size: 13px;"><b>💡 Tip:</b> Select the test group you just created</p></div>',
|
||||||
|
nextBtn: 'Next'
|
||||||
|
},
|
||||||
|
accountSubmit: {
|
||||||
|
title: '✅ Save Account',
|
||||||
|
description: '<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">Confirm the information and click save.</p><div style="padding: 8px 12px; background: #eff6ff; border-left: 3px solid #3b82f6; border-radius: 4px; font-size: 13px; margin-bottom: 12px;"><b>📌 OAuth Flow:</b><ul style="margin: 8px 0 0 16px;"><li>Will redirect to service provider page after clicking save</li><li>Complete login and authorization on provider page</li><li>Auto-return after successful authorization</li></ul></div><p style="padding: 8px 12px; background: #f0fdf4; border-left: 3px solid #10b981; border-radius: 4px; font-size: 13px;"><b>📌 Next Step:</b> After adding account, we\'ll create an API key</p><p style="margin-top: 12px; color: #10b981; font-weight: 600;">👉 Click "Save" button</p></div>'
|
||||||
|
},
|
||||||
|
keyManage: {
|
||||||
|
title: '🔑 Step 3: Generate Key',
|
||||||
|
description: '<div style="line-height: 1.7;"><p style="margin-bottom: 12px;"><b>Congratulations! Account setup complete 🎉</b></p><p style="margin-bottom: 12px;">Final step: generate an API Key to test if the service works properly.</p><div style="padding: 8px 12px; background: #eff6ff; border-left: 3px solid #3b82f6; border-radius: 4px; font-size: 13px; margin-bottom: 12px;"><b>🔑 API Key Purpose:</b><ul style="margin: 8px 0 0 16px;"><li>Credential for calling AI services</li><li>Each key is bound to one group</li><li>Can set quota and expiration</li><li>Supports independent usage statistics</li></ul></div><p style="margin-top: 16px; color: #10b981; font-weight: 600;">👉 Click "API Keys" on the left sidebar</p></div>'
|
||||||
|
},
|
||||||
|
createKey: {
|
||||||
|
title: '➕ Create Key',
|
||||||
|
description: '<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">Click the button to create your first API Key.</p><p style="padding: 8px 12px; background: #f0fdf4; border-left: 3px solid #10b981; border-radius: 4px; font-size: 13px;"><b>💡 Tip:</b> Copy and save immediately after creation - key is only shown once</p><p style="margin-top: 12px; color: #10b981; font-weight: 600;">👉 Click "Create Key" button</p></div>'
|
||||||
|
},
|
||||||
|
keyName: {
|
||||||
|
title: '✏️ 1. Key Name',
|
||||||
|
description: '<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">Set an easy-to-manage name for the key.</p><p style="padding: 8px 12px; background: #f0fdf4; border-left: 3px solid #10b981; border-radius: 4px; font-size: 13px;"><b>💡 Naming Suggestions:</b> "Test Key", "Production", "Mobile", etc.</p></div>',
|
||||||
|
nextBtn: 'Next'
|
||||||
|
},
|
||||||
|
keyGroup: {
|
||||||
|
title: '🎯 2. Select Group',
|
||||||
|
description: '<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">Select the group you just configured.</p><div style="padding: 8px 12px; background: #eff6ff; border-left: 3px solid #3b82f6; border-radius: 4px; font-size: 13px; margin-bottom: 12px;"><b>📌 Group Determines:</b><ul style="margin: 8px 0 0 16px;"><li>Which accounts this key can use</li><li>What billing multiplier applies</li><li>Whether it\'s an exclusive key</li></ul></div><p style="padding: 8px 12px; background: #f0fdf4; border-left: 3px solid #10b981; border-radius: 4px; font-size: 13px;"><b>💡 Tip:</b> Select the test group you just created</p></div>',
|
||||||
|
nextBtn: 'Next'
|
||||||
|
},
|
||||||
|
keySubmit: {
|
||||||
|
title: '🎉 Generate and Copy',
|
||||||
|
description: '<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">System will generate a complete API Key after clicking create.</p><div style="padding: 8px 12px; background: #fee2e2; border-left: 3px solid #ef4444; border-radius: 4px; font-size: 13px; margin-bottom: 12px;"><b>⚠️ Important Reminder:</b><ul style="margin: 8px 0 0 16px;"><li>Key is only shown once, copy immediately</li><li>Need to regenerate if lost</li><li>Keep it safe, don\'t share with others</li></ul></div><div style="padding: 8px 12px; background: #f0fdf4; border-left: 3px solid #10b981; border-radius: 4px; font-size: 13px; margin-bottom: 12px;"><b>🚀 Next Steps:</b><ul style="margin: 8px 0 0 16px;"><li>Copy the generated sk-xxx key</li><li>Use in any OpenAI-compatible client</li><li>Start experiencing AI services!</li></ul></div><p style="margin-top: 12px; color: #10b981; font-weight: 600;">👉 Click "Create" button</p></div>'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
// User tour steps
|
||||||
|
user: {
|
||||||
|
welcome: {
|
||||||
|
title: '👋 Welcome to Sub2API',
|
||||||
|
description: '<div style="line-height: 1.8;"><p style="margin-bottom: 16px;">Hello! Welcome to the Sub2API AI service platform.</p><p style="margin-bottom: 12px;"><b>🎯 Quick Start:</b></p><ul style="margin-left: 20px; margin-bottom: 16px;"><li>🔑 Create API Key</li><li>📋 Copy key to your application</li><li>🚀 Start using AI services</li></ul><p style="color: #10b981; font-weight: 600;">Just 1 minute, let\'s get started →</p></div>',
|
||||||
|
nextBtn: 'Start 🚀',
|
||||||
|
prevBtn: 'Skip'
|
||||||
|
},
|
||||||
|
keyManage: {
|
||||||
|
title: '🔑 API Key Management',
|
||||||
|
description: '<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">Manage all your API access keys here.</p><p style="padding: 8px 12px; background: #eff6ff; border-left: 3px solid #3b82f6; border-radius: 4px; font-size: 13px;"><b>📌 What is an API Key?</b><br/>An API key is your credential for accessing AI services, like a key that allows your application to call AI capabilities.</p><p style="margin-top: 12px; color: #10b981; font-weight: 600;">👉 Click to enter key page</p></div>'
|
||||||
|
},
|
||||||
|
createKey: {
|
||||||
|
title: '➕ Create New Key',
|
||||||
|
description: '<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">Click the button to create your first API key.</p><p style="padding: 8px 12px; background: #f0fdf4; border-left: 3px solid #10b981; border-radius: 4px; font-size: 13px;"><b>💡 Tip:</b> Key is only shown once after creation, make sure to copy and save</p><p style="margin-top: 12px; color: #10b981; font-weight: 600;">👉 Click "Create Key"</p></div>'
|
||||||
|
},
|
||||||
|
keyName: {
|
||||||
|
title: '✏️ Key Name',
|
||||||
|
description: '<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">Give your key an easy-to-identify name.</p><p style="padding: 8px 12px; background: #f0fdf4; border-left: 3px solid #10b981; border-radius: 4px; font-size: 13px;"><b>💡 Examples:</b> "My First Key", "For Testing", etc.</p></div>',
|
||||||
|
nextBtn: 'Next'
|
||||||
|
},
|
||||||
|
keyGroup: {
|
||||||
|
title: '🎯 Select Group',
|
||||||
|
description: '<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">Select the service group assigned by the administrator.</p><p style="padding: 8px 12px; background: #eff6ff; border-left: 3px solid #3b82f6; border-radius: 4px; font-size: 13px;"><b>📌 Group Info:</b><br/>Different groups may have different service quality and billing rates, choose according to your needs.</p></div>',
|
||||||
|
nextBtn: 'Next'
|
||||||
|
},
|
||||||
|
keySubmit: {
|
||||||
|
title: '🎉 Complete Creation',
|
||||||
|
description: '<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">Click to confirm and create your API key.</p><div style="padding: 8px 12px; background: #fee2e2; border-left: 3px solid #ef4444; border-radius: 4px; font-size: 13px; margin-bottom: 12px;"><b>⚠️ Important:</b><ul style="margin: 8px 0 0 16px;"><li>Copy the key (sk-xxx) immediately after creation</li><li>Key is only shown once, need to regenerate if lost</li></ul></div><p style="padding: 8px 12px; background: #f0fdf4; border-left: 3px solid #10b981; border-radius: 4px; font-size: 13px;"><b>🚀 How to Use:</b><br/>Configure the key in any OpenAI-compatible client (like ChatBox, OpenCat, etc.) and start using!</p><p style="margin-top: 12px; color: #10b981; font-weight: 600;">👉 Click "Create" button</p></div>'
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -323,7 +323,8 @@ export default {
|
|||||||
customKeyHint: '仅允许字母、数字、下划线和连字符,最少16个字符。',
|
customKeyHint: '仅允许字母、数字、下划线和连字符,最少16个字符。',
|
||||||
customKeyTooShort: '自定义密钥至少需要16个字符',
|
customKeyTooShort: '自定义密钥至少需要16个字符',
|
||||||
customKeyInvalidChars: '自定义密钥只能包含字母、数字、下划线和连字符',
|
customKeyInvalidChars: '自定义密钥只能包含字母、数字、下划线和连字符',
|
||||||
customKeyRequired: '请输入自定义密钥'
|
customKeyRequired: '请输入自定义密钥',
|
||||||
|
ccSwitchNotInstalled: 'CC-Switch 未安装或协议处理程序未注册。请先安装 CC-Switch 或手动复制 API 密钥。'
|
||||||
},
|
},
|
||||||
|
|
||||||
// Usage
|
// Usage
|
||||||
@@ -342,6 +343,12 @@ export default {
|
|||||||
allApiKeys: '全部密钥',
|
allApiKeys: '全部密钥',
|
||||||
timeRange: '时间范围',
|
timeRange: '时间范围',
|
||||||
exportCsv: '导出 CSV',
|
exportCsv: '导出 CSV',
|
||||||
|
exportExcel: '导出 Excel',
|
||||||
|
exportingProgress: '正在导出数据...',
|
||||||
|
exportedCount: '已导出 {current}/{total} 条',
|
||||||
|
estimatedTime: '预计剩余时间:{time}',
|
||||||
|
cancelExport: '取消导出',
|
||||||
|
exportCancelled: '导出已取消',
|
||||||
exporting: '导出中...',
|
exporting: '导出中...',
|
||||||
preparingExport: '正在准备导出...',
|
preparingExport: '正在准备导出...',
|
||||||
model: '模型',
|
model: '模型',
|
||||||
@@ -365,6 +372,8 @@ export default {
|
|||||||
noDataToExport: '没有可导出的数据',
|
noDataToExport: '没有可导出的数据',
|
||||||
exportSuccess: '使用数据导出成功',
|
exportSuccess: '使用数据导出成功',
|
||||||
exportFailed: '使用数据导出失败',
|
exportFailed: '使用数据导出失败',
|
||||||
|
exportExcelSuccess: '使用数据导出成功(Excel格式)',
|
||||||
|
exportExcelFailed: '使用数据导出失败',
|
||||||
billingType: '消费类型',
|
billingType: '消费类型',
|
||||||
balance: '余额',
|
balance: '余额',
|
||||||
subscription: '订阅'
|
subscription: '订阅'
|
||||||
@@ -1528,6 +1537,7 @@ export default {
|
|||||||
account: '账户',
|
account: '账户',
|
||||||
group: '分组',
|
group: '分组',
|
||||||
requestId: '请求ID',
|
requestId: '请求ID',
|
||||||
|
requestIdCopied: '请求ID已复制',
|
||||||
allModels: '全部模型',
|
allModels: '全部模型',
|
||||||
allAccounts: '全部账户',
|
allAccounts: '全部账户',
|
||||||
allGroups: '全部分组',
|
allGroups: '全部分组',
|
||||||
@@ -1537,6 +1547,10 @@ export default {
|
|||||||
outputCost: '输出成本',
|
outputCost: '输出成本',
|
||||||
cacheCreationCost: '缓存创建成本',
|
cacheCreationCost: '缓存创建成本',
|
||||||
cacheReadCost: '缓存读取成本',
|
cacheReadCost: '缓存读取成本',
|
||||||
|
inputTokens: '输入 Token',
|
||||||
|
outputTokens: '输出 Token',
|
||||||
|
cacheCreationTokens: '缓存创建 Token',
|
||||||
|
cacheReadTokens: '缓存读取 Token',
|
||||||
failedToLoad: '加载使用记录失败'
|
failedToLoad: '加载使用记录失败'
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -1723,5 +1737,150 @@ export default {
|
|||||||
resetIn: '{time} 后重置',
|
resetIn: '{time} 后重置',
|
||||||
windowNotActive: '等待首次使用',
|
windowNotActive: '等待首次使用',
|
||||||
usageOf: '已用 {used} / {limit}'
|
usageOf: '已用 {used} / {limit}'
|
||||||
|
},
|
||||||
|
|
||||||
|
// Onboarding Tour
|
||||||
|
onboarding: {
|
||||||
|
restartTour: '重新查看新手引导',
|
||||||
|
dontShowAgain: '不再提示',
|
||||||
|
dontShowAgainTitle: '永久关闭新手引导',
|
||||||
|
confirmDontShow: '确定不再显示新手引导吗?\n\n您可以随时在右上角头像菜单中重新开启。',
|
||||||
|
confirmExit: '确定要退出新手引导吗?您可以随时在右上角菜单重新开始。',
|
||||||
|
interactiveHint: '按 Enter 或点击继续',
|
||||||
|
navigation: {
|
||||||
|
flipPage: '翻页',
|
||||||
|
exit: '退出'
|
||||||
|
},
|
||||||
|
// Admin tour steps
|
||||||
|
admin: {
|
||||||
|
welcome: {
|
||||||
|
title: '👋 欢迎使用 Sub2API',
|
||||||
|
description: '<div style="line-height: 1.8;"><p style="margin-bottom: 16px;">Sub2API 是一个强大的 AI 服务中转平台,让您轻松管理和分发 AI 服务。</p><p style="margin-bottom: 12px;"><b>🎯 核心功能:</b></p><ul style="margin-left: 20px; margin-bottom: 16px;"><li>📦 <b>分组管理</b> - 创建不同的服务套餐(VIP、免费试用等)</li><li>🔗 <b>账号池</b> - 连接多个上游 AI 服务商账号</li><li>🔑 <b>密钥分发</b> - 为用户生成独立的 API Key</li><li>💰 <b>计费管理</b> - 灵活的费率和配额控制</li></ul><p style="color: #10b981; font-weight: 600;">接下来,我们将用 3 分钟带您完成首次配置 →</p></div>',
|
||||||
|
nextBtn: '开始配置 🚀',
|
||||||
|
prevBtn: '跳过'
|
||||||
|
},
|
||||||
|
groupManage: {
|
||||||
|
title: '📦 第一步:分组管理',
|
||||||
|
description: '<div style="line-height: 1.7;"><p style="margin-bottom: 12px;"><b>什么是分组?</b></p><p style="margin-bottom: 12px;">分组是 Sub2API 的核心概念,它就像一个"服务套餐":</p><ul style="margin-left: 20px; margin-bottom: 12px; font-size: 13px;"><li>🎯 每个分组可以包含多个上游账号</li><li>💰 每个分组有独立的计费倍率</li><li>👥 可以设置为公开或专属分组</li></ul><p style="margin-top: 12px; padding: 8px 12px; background: #f0fdf4; border-left: 3px solid #10b981; border-radius: 4px; font-size: 13px;"><b>💡 示例:</b>您可以创建"VIP专线"(高倍率)和"免费试用"(低倍率)两个分组</p><p style="margin-top: 16px; color: #10b981; font-weight: 600;">👉 点击左侧的"分组管理"开始</p></div>'
|
||||||
|
},
|
||||||
|
createGroup: {
|
||||||
|
title: '➕ 创建新分组',
|
||||||
|
description: '<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">现在让我们创建第一个分组。</p><p style="padding: 8px 12px; background: #eff6ff; border-left: 3px solid #3b82f6; border-radius: 4px; font-size: 13px; margin-bottom: 12px;"><b>📝 提示:</b>建议先创建一个测试分组,熟悉流程后再创建正式分组</p><p style="color: #10b981; font-weight: 600;">👉 点击"创建分组"按钮</p></div>'
|
||||||
|
},
|
||||||
|
groupName: {
|
||||||
|
title: '✏️ 1. 分组名称',
|
||||||
|
description: '<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">为您的分组起一个易于识别的名称。</p><div style="padding: 8px 12px; background: #f0fdf4; border-left: 3px solid #10b981; border-radius: 4px; font-size: 13px; margin-bottom: 12px;"><b>💡 命名建议:</b><ul style="margin: 8px 0 0 16px;"><li>"测试分组" - 用于测试</li><li>"VIP专线" - 高质量服务</li><li>"免费试用" - 体验版</li></ul></div><p style="font-size: 13px; color: #6b7280;">填写完成后点击"下一步"继续</p></div>',
|
||||||
|
nextBtn: '下一步'
|
||||||
|
},
|
||||||
|
groupPlatform: {
|
||||||
|
title: '🤖 2. 选择平台',
|
||||||
|
description: '<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">选择该分组支持的 AI 平台。</p><div style="padding: 8px 12px; background: #eff6ff; border-left: 3px solid #3b82f6; border-radius: 4px; font-size: 13px; margin-bottom: 12px;"><b>📌 平台说明:</b><ul style="margin: 8px 0 0 16px;"><li><b>Anthropic</b> - Claude 系列模型</li><li><b>OpenAI</b> - GPT 系列模型</li><li><b>Google</b> - Gemini 系列模型</li></ul></div><p style="font-size: 13px; color: #6b7280;">一个分组只能选择一个平台</p></div>',
|
||||||
|
nextBtn: '下一步'
|
||||||
|
},
|
||||||
|
groupMultiplier: {
|
||||||
|
title: '💰 3. 费率倍数',
|
||||||
|
description: '<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">设置该分组的计费倍率,控制用户的实际扣费。</p><div style="padding: 8px 12px; background: #fef3c7; border-left: 3px solid #f59e0b; border-radius: 4px; font-size: 13px; margin-bottom: 12px;"><b>⚙️ 计费规则:</b><ul style="margin: 8px 0 0 16px;"><li><b>1.0</b> - 原价计费(成本价)</li><li><b>1.5</b> - 用户消耗 $1,扣除 $1.5</li><li><b>2.0</b> - 用户消耗 $1,扣除 $2</li><li><b>0.8</b> - 补贴模式(亏本运营)</li></ul></div><p style="font-size: 13px; color: #6b7280;">建议测试分组设置为 1.0</p></div>',
|
||||||
|
nextBtn: '下一步'
|
||||||
|
},
|
||||||
|
groupExclusive: {
|
||||||
|
title: '🔒 4. 专属分组(可选)',
|
||||||
|
description: '<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">控制分组的可见性和访问权限。</p><div style="padding: 8px 12px; background: #eff6ff; border-left: 3px solid #3b82f6; border-radius: 4px; font-size: 13px; margin-bottom: 12px;"><b>🔐 权限说明:</b><ul style="margin: 8px 0 0 16px;"><li><b>关闭</b> - 公开分组,所有用户可见</li><li><b>开启</b> - 专属分组,仅指定用户可见</li></ul></div><p style="padding: 8px 12px; background: #f0fdf4; border-left: 3px solid #10b981; border-radius: 4px; font-size: 13px;"><b>💡 使用场景:</b>VIP 用户专属、内部测试、特殊客户等</p></div>',
|
||||||
|
nextBtn: '下一步'
|
||||||
|
},
|
||||||
|
groupSubmit: {
|
||||||
|
title: '✅ 保存分组',
|
||||||
|
description: '<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">确认信息无误后,点击创建按钮保存分组。</p><p style="padding: 8px 12px; background: #fef3c7; border-left: 3px solid #f59e0b; border-radius: 4px; font-size: 13px; margin-bottom: 12px;"><b>⚠️ 注意:</b>分组创建后,平台类型不可修改,其他信息可以随时编辑</p><p style="padding: 8px 12px; background: #f0fdf4; border-left: 3px solid #10b981; border-radius: 4px; font-size: 13px;"><b>📌 下一步:</b>创建成功后,我们将添加上游账号到这个分组</p><p style="margin-top: 12px; color: #10b981; font-weight: 600;">👉 点击"创建"按钮</p></div>'
|
||||||
|
},
|
||||||
|
accountManage: {
|
||||||
|
title: '🔗 第二步:添加账号',
|
||||||
|
description: '<div style="line-height: 1.7;"><p style="margin-bottom: 12px;"><b>太棒了!分组已创建成功 🎉</b></p><p style="margin-bottom: 12px;">现在需要添加上游 AI 服务商的账号,让分组能够实际提供服务。</p><div style="padding: 8px 12px; background: #eff6ff; border-left: 3px solid #3b82f6; border-radius: 4px; font-size: 13px; margin-bottom: 12px;"><b>🔑 账号的作用:</b><ul style="margin: 8px 0 0 16px;"><li>连接到上游 AI 服务(Claude、GPT 等)</li><li>一个分组可以包含多个账号(负载均衡)</li><li>支持 OAuth 和 Session Key 两种方式</li></ul></div><p style="margin-top: 16px; color: #10b981; font-weight: 600;">👉 点击左侧的"账号管理"</p></div>'
|
||||||
|
},
|
||||||
|
createAccount: {
|
||||||
|
title: '➕ 添加新账号',
|
||||||
|
description: '<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">点击按钮开始添加您的第一个上游账号。</p><p style="padding: 8px 12px; background: #f0fdf4; border-left: 3px solid #10b981; border-radius: 4px; font-size: 13px;"><b>💡 提示:</b>建议使用 OAuth 方式,更安全且无需手动提取密钥</p><p style="margin-top: 12px; color: #10b981; font-weight: 600;">👉 点击"添加账号"按钮</p></div>'
|
||||||
|
},
|
||||||
|
accountName: {
|
||||||
|
title: '✏️ 1. 账号名称',
|
||||||
|
description: '<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">为账号设置一个便于识别的名称。</p><p style="padding: 8px 12px; background: #f0fdf4; border-left: 3px solid #10b981; border-radius: 4px; font-size: 13px;"><b>💡 命名建议:</b>"Claude主账号"、"GPT备用1"、"测试账号" 等</p></div>',
|
||||||
|
nextBtn: '下一步'
|
||||||
|
},
|
||||||
|
accountPlatform: {
|
||||||
|
title: '🤖 2. 选择平台',
|
||||||
|
description: '<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">选择该账号对应的服务商平台。</p><p style="padding: 8px 12px; background: #fef3c7; border-left: 3px solid #f59e0b; border-radius: 4px; font-size: 13px;"><b>⚠️ 重要:</b>平台必须与刚才创建的分组平台一致</p></div>',
|
||||||
|
nextBtn: '下一步'
|
||||||
|
},
|
||||||
|
accountType: {
|
||||||
|
title: '🔐 3. 授权方式',
|
||||||
|
description: '<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">选择账号的授权方式。</p><div style="padding: 8px 12px; background: #f0fdf4; border-left: 3px solid #10b981; border-radius: 4px; font-size: 13px; margin-bottom: 12px;"><b>✅ 推荐:OAuth 方式</b><ul style="margin: 8px 0 0 16px;"><li>无需手动提取密钥</li><li>更安全,支持自动刷新</li><li>适用于 Claude Code、ChatGPT OAuth</li></ul></div><div style="padding: 8px 12px; background: #eff6ff; border-left: 3px solid #3b82f6; border-radius: 4px; font-size: 13px;"><b>📌 Session Key 方式</b><ul style="margin: 8px 0 0 16px;"><li>需要手动从浏览器提取</li><li>可能需要定期更新</li><li>适用于不支持 OAuth 的平台</li></ul></div></div>',
|
||||||
|
nextBtn: '下一步'
|
||||||
|
},
|
||||||
|
accountPriority: {
|
||||||
|
title: '⚖️ 4. 优先级(可选)',
|
||||||
|
description: '<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">设置账号的调用优先级。</p><div style="padding: 8px 12px; background: #eff6ff; border-left: 3px solid #3b82f6; border-radius: 4px; font-size: 13px; margin-bottom: 12px;"><b>📊 优先级规则:</b><ul style="margin: 8px 0 0 16px;"><li>数字越大,优先级越高</li><li>系统优先使用高优先级账号</li><li>相同优先级则随机选择</li></ul></div><p style="padding: 8px 12px; background: #f0fdf4; border-left: 3px solid #10b981; border-radius: 4px; font-size: 13px;"><b>💡 使用场景:</b>主账号设置高优先级,备用账号设置低优先级</p></div>',
|
||||||
|
nextBtn: '下一步'
|
||||||
|
},
|
||||||
|
accountGroups: {
|
||||||
|
title: '🎯 5. 分配分组',
|
||||||
|
description: '<div style="line-height: 1.7;"><p style="margin-bottom: 12px;"><b>关键步骤!</b>将账号分配到刚才创建的分组。</p><div style="padding: 8px 12px; background: #fee2e2; border-left: 3px solid #ef4444; border-radius: 4px; font-size: 13px; margin-bottom: 12px;"><b>⚠️ 重要提醒:</b><ul style="margin: 8px 0 0 16px;"><li>必须勾选至少一个分组</li><li>未分配分组的账号无法使用</li><li>一个账号可以分配给多个分组</li></ul></div><p style="padding: 8px 12px; background: #f0fdf4; border-left: 3px solid #10b981; border-radius: 4px; font-size: 13px;"><b>💡 提示:</b>请勾选刚才创建的测试分组</p></div>',
|
||||||
|
nextBtn: '下一步'
|
||||||
|
},
|
||||||
|
accountSubmit: {
|
||||||
|
title: '✅ 保存账号',
|
||||||
|
description: '<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">确认信息无误后,点击保存按钮。</p><div style="padding: 8px 12px; background: #eff6ff; border-left: 3px solid #3b82f6; border-radius: 4px; font-size: 13px; margin-bottom: 12px;"><b>📌 OAuth 授权流程:</b><ul style="margin: 8px 0 0 16px;"><li>点击保存后会跳转到服务商页面</li><li>在服务商页面完成登录授权</li><li>授权成功后自动返回</li></ul></div><p style="padding: 8px 12px; background: #f0fdf4; border-left: 3px solid #10b981; border-radius: 4px; font-size: 13px;"><b>📌 下一步:</b>账号添加成功后,我们将创建 API 密钥</p><p style="margin-top: 12px; color: #10b981; font-weight: 600;">👉 点击"保存"按钮</p></div>'
|
||||||
|
},
|
||||||
|
keyManage: {
|
||||||
|
title: '🔑 第三步:生成密钥',
|
||||||
|
description: '<div style="line-height: 1.7;"><p style="margin-bottom: 12px;"><b>恭喜!账号配置完成 🎉</b></p><p style="margin-bottom: 12px;">最后一步,生成 API Key 来测试服务是否正常工作。</p><div style="padding: 8px 12px; background: #eff6ff; border-left: 3px solid #3b82f6; border-radius: 4px; font-size: 13px; margin-bottom: 12px;"><b>🔑 API Key 的作用:</b><ul style="margin: 8px 0 0 16px;"><li>用于调用 AI 服务的凭证</li><li>每个 Key 绑定一个分组</li><li>可以设置配额和有效期</li><li>支持独立的使用统计</li></ul></div><p style="margin-top: 16px; color: #10b981; font-weight: 600;">👉 点击左侧的"API 密钥"</p></div>'
|
||||||
|
},
|
||||||
|
createKey: {
|
||||||
|
title: '➕ 创建密钥',
|
||||||
|
description: '<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">点击按钮创建您的第一个 API Key。</p><p style="padding: 8px 12px; background: #f0fdf4; border-left: 3px solid #10b981; border-radius: 4px; font-size: 13px;"><b>💡 提示:</b>创建后请立即复制保存,密钥只显示一次</p><p style="margin-top: 12px; color: #10b981; font-weight: 600;">👉 点击"创建密钥"按钮</p></div>'
|
||||||
|
},
|
||||||
|
keyName: {
|
||||||
|
title: '✏️ 1. 密钥名称',
|
||||||
|
description: '<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">为密钥设置一个便于管理的名称。</p><p style="padding: 8px 12px; background: #f0fdf4; border-left: 3px solid #10b981; border-radius: 4px; font-size: 13px;"><b>💡 命名建议:</b>"测试密钥"、"生产环境"、"移动端" 等</p></div>',
|
||||||
|
nextBtn: '下一步'
|
||||||
|
},
|
||||||
|
keyGroup: {
|
||||||
|
title: '🎯 2. 选择分组',
|
||||||
|
description: '<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">选择刚才配置好的分组。</p><div style="padding: 8px 12px; background: #eff6ff; border-left: 3px solid #3b82f6; border-radius: 4px; font-size: 13px; margin-bottom: 12px;"><b>📌 分组决定:</b><ul style="margin: 8px 0 0 16px;"><li>该密钥可以使用哪些账号</li><li>计费倍率是多少</li><li>是否为专属密钥</li></ul></div><p style="padding: 8px 12px; background: #f0fdf4; border-left: 3px solid #10b981; border-radius: 4px; font-size: 13px;"><b>💡 提示:</b>选择刚才创建的测试分组</p></div>',
|
||||||
|
nextBtn: '下一步'
|
||||||
|
},
|
||||||
|
keySubmit: {
|
||||||
|
title: '🎉 生成并复制',
|
||||||
|
description: '<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">点击创建后,系统会生成完整的 API Key。</p><div style="padding: 8px 12px; background: #fee2e2; border-left: 3px solid #ef4444; border-radius: 4px; font-size: 13px; margin-bottom: 12px;"><b>⚠️ 重要提醒:</b><ul style="margin: 8px 0 0 16px;"><li>密钥只显示一次,请立即复制</li><li>丢失后需要重新生成</li><li>妥善保管,不要泄露给他人</li></ul></div><div style="padding: 8px 12px; background: #f0fdf4; border-left: 3px solid #10b981; border-radius: 4px; font-size: 13px; margin-bottom: 12px;"><b>🚀 下一步:</b><ul style="margin: 8px 0 0 16px;"><li>复制生成的 sk-xxx 密钥</li><li>在支持 OpenAI 接口的客户端中使用</li><li>开始体验 AI 服务!</li></ul></div><p style="margin-top: 12px; color: #10b981; font-weight: 600;">👉 点击"创建"按钮</p></div>'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
// User tour steps
|
||||||
|
user: {
|
||||||
|
welcome: {
|
||||||
|
title: '👋 欢迎使用 Sub2API',
|
||||||
|
description: '<div style="line-height: 1.8;"><p style="margin-bottom: 16px;">您好!欢迎来到 Sub2API AI 服务平台。</p><p style="margin-bottom: 12px;"><b>🎯 快速开始:</b></p><ul style="margin-left: 20px; margin-bottom: 16px;"><li>🔑 创建 API 密钥</li><li>📋 复制密钥到您的应用</li><li>🚀 开始使用 AI 服务</li></ul><p style="color: #10b981; font-weight: 600;">只需 1 分钟,让我们开始吧 →</p></div>',
|
||||||
|
nextBtn: '开始 🚀',
|
||||||
|
prevBtn: '跳过'
|
||||||
|
},
|
||||||
|
keyManage: {
|
||||||
|
title: '🔑 API 密钥管理',
|
||||||
|
description: '<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">在这里管理您的所有 API 访问密钥。</p><p style="padding: 8px 12px; background: #eff6ff; border-left: 3px solid #3b82f6; border-radius: 4px; font-size: 13px;"><b>📌 什么是 API 密钥?</b><br/>API 密钥是您访问 AI 服务的凭证,就像一把钥匙,让您的应用能够调用 AI 能力。</p><p style="margin-top: 12px; color: #10b981; font-weight: 600;">👉 点击进入密钥页面</p></div>'
|
||||||
|
},
|
||||||
|
createKey: {
|
||||||
|
title: '➕ 创建新密钥',
|
||||||
|
description: '<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">点击按钮创建您的第一个 API 密钥。</p><p style="padding: 8px 12px; background: #f0fdf4; border-left: 3px solid #10b981; border-radius: 4px; font-size: 13px;"><b>💡 提示:</b>创建后密钥只显示一次,请务必复制保存</p><p style="margin-top: 12px; color: #10b981; font-weight: 600;">👉 点击"创建密钥"</p></div>'
|
||||||
|
},
|
||||||
|
keyName: {
|
||||||
|
title: '✏️ 密钥名称',
|
||||||
|
description: '<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">为密钥起一个便于识别的名称。</p><p style="padding: 8px 12px; background: #f0fdf4; border-left: 3px solid #10b981; border-radius: 4px; font-size: 13px;"><b>💡 示例:</b>"我的第一个密钥"、"测试用" 等</p></div>',
|
||||||
|
nextBtn: '下一步'
|
||||||
|
},
|
||||||
|
keyGroup: {
|
||||||
|
title: '🎯 选择分组',
|
||||||
|
description: '<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">选择管理员为您分配的服务分组。</p><p style="padding: 8px 12px; background: #eff6ff; border-left: 3px solid #3b82f6; border-radius: 4px; font-size: 13px;"><b>📌 分组说明:</b><br/>不同分组可能有不同的服务质量和计费标准,请根据需要选择。</p></div>',
|
||||||
|
nextBtn: '下一步'
|
||||||
|
},
|
||||||
|
keySubmit: {
|
||||||
|
title: '🎉 完成创建',
|
||||||
|
description: '<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">点击确认创建您的 API 密钥。</p><div style="padding: 8px 12px; background: #fee2e2; border-left: 3px solid #ef4444; border-radius: 4px; font-size: 13px; margin-bottom: 12px;"><b>⚠️ 重要:</b><ul style="margin: 8px 0 0 16px;"><li>创建后请立即复制密钥(sk-xxx)</li><li>密钥只显示一次,丢失需重新生成</li></ul></div><p style="padding: 8px 12px; background: #f0fdf4; border-left: 3px solid #10b981; border-radius: 4px; font-size: 13px;"><b>🚀 如何使用:</b><br/>将密钥配置到支持 OpenAI 接口的任何客户端(如 ChatBox、OpenCat 等),即可开始使用!</p><p style="margin-top: 12px; color: #10b981; font-weight: 600;">👉 点击"创建"按钮</p></div>'
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
export { useAuthStore } from './auth'
|
export { useAuthStore } from './auth'
|
||||||
export { useAppStore } from './app'
|
export { useAppStore } from './app'
|
||||||
export { useSubscriptionStore } from './subscriptions'
|
export { useSubscriptionStore } from './subscriptions'
|
||||||
|
export { useOnboardingStore } from './onboarding'
|
||||||
|
|
||||||
// Re-export types for convenience
|
// Re-export types for convenience
|
||||||
export type { User, LoginRequest, RegisterRequest, AuthResponse } from '@/types'
|
export type { User, LoginRequest, RegisterRequest, AuthResponse } from '@/types'
|
||||||
|
|||||||
88
frontend/src/stores/onboarding.ts
Normal file
88
frontend/src/stores/onboarding.ts
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
/**
|
||||||
|
* Onboarding Store
|
||||||
|
* Manages onboarding tour state and control methods
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { markRaw, ref, shallowRef } from 'vue'
|
||||||
|
import type { Driver } from 'driver.js'
|
||||||
|
|
||||||
|
type VoidCallback = () => void
|
||||||
|
type NextStepCallback = (delay?: number) => Promise<void>
|
||||||
|
type IsCurrentStepCallback = (selector: string) => boolean
|
||||||
|
|
||||||
|
export const useOnboardingStore = defineStore('onboarding', () => {
|
||||||
|
const replayCallback = ref<VoidCallback | null>(null)
|
||||||
|
const nextStepCallback = ref<NextStepCallback | null>(null)
|
||||||
|
const isCurrentStepCallback = ref<IsCurrentStepCallback | null>(null)
|
||||||
|
|
||||||
|
// 全局 driver 实例,跨组件保持
|
||||||
|
const driverInstance = shallowRef<Driver | null>(null)
|
||||||
|
|
||||||
|
function setReplayCallback(callback: VoidCallback | null): void {
|
||||||
|
replayCallback.value = callback
|
||||||
|
}
|
||||||
|
|
||||||
|
function setControlMethods(methods: {
|
||||||
|
nextStep: NextStepCallback,
|
||||||
|
isCurrentStep: IsCurrentStepCallback
|
||||||
|
}): void {
|
||||||
|
nextStepCallback.value = methods.nextStep
|
||||||
|
isCurrentStepCallback.value = methods.isCurrentStep
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearControlMethods(): void {
|
||||||
|
nextStepCallback.value = null
|
||||||
|
isCurrentStepCallback.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
function setDriverInstance(driver: Driver | null): void {
|
||||||
|
driverInstance.value = driver ? markRaw(driver) : null
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDriverInstance(): Driver | null {
|
||||||
|
return driverInstance.value
|
||||||
|
}
|
||||||
|
|
||||||
|
function isDriverActive(): boolean {
|
||||||
|
return driverInstance.value?.isActive?.() ?? false
|
||||||
|
}
|
||||||
|
|
||||||
|
function replay(): void {
|
||||||
|
if (replayCallback.value) {
|
||||||
|
replayCallback.value()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Manually advance to the next step
|
||||||
|
* @param delay Optional delay in ms (useful for waiting for animations)
|
||||||
|
*/
|
||||||
|
async function nextStep(delay = 0): Promise<void> {
|
||||||
|
if (nextStepCallback.value) {
|
||||||
|
await nextStepCallback.value(delay)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if the tour is currently highlighting a specific element
|
||||||
|
*/
|
||||||
|
function isCurrentStep(selector: string): boolean {
|
||||||
|
if (isCurrentStepCallback.value) {
|
||||||
|
return isCurrentStepCallback.value(selector)
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
setReplayCallback,
|
||||||
|
setControlMethods,
|
||||||
|
clearControlMethods,
|
||||||
|
setDriverInstance,
|
||||||
|
getDriverInstance,
|
||||||
|
isDriverActive,
|
||||||
|
replay,
|
||||||
|
nextStep,
|
||||||
|
isCurrentStep
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -79,6 +79,20 @@
|
|||||||
@apply hover:from-red-600 hover:to-red-700 hover:shadow-lg hover:shadow-red-500/30;
|
@apply hover:from-red-600 hover:to-red-700 hover:shadow-lg hover:shadow-red-500/30;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.btn-success {
|
||||||
|
@apply bg-gradient-to-r from-emerald-500 to-emerald-600;
|
||||||
|
@apply text-white shadow-md shadow-emerald-500/25;
|
||||||
|
@apply hover:from-emerald-600 hover:to-emerald-700 hover:shadow-lg hover:shadow-emerald-500/30;
|
||||||
|
@apply dark:shadow-emerald-500/20;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-warning {
|
||||||
|
@apply bg-gradient-to-r from-amber-500 to-amber-600;
|
||||||
|
@apply text-white shadow-md shadow-amber-500/25;
|
||||||
|
@apply hover:from-amber-600 hover:to-amber-700 hover:shadow-lg hover:shadow-amber-500/30;
|
||||||
|
@apply dark:shadow-amber-500/20;
|
||||||
|
}
|
||||||
|
|
||||||
.btn-sm {
|
.btn-sm {
|
||||||
@apply rounded-lg px-3 py-1.5 text-xs;
|
@apply rounded-lg px-3 py-1.5 text-xs;
|
||||||
}
|
}
|
||||||
@@ -130,6 +144,20 @@
|
|||||||
-moz-appearance: textfield;
|
-moz-appearance: textfield;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ============ 玻璃效果 ============ */
|
||||||
|
.glass {
|
||||||
|
@apply bg-white/80 backdrop-blur-xl dark:bg-dark-800/80;
|
||||||
|
}
|
||||||
|
|
||||||
|
.glass-card {
|
||||||
|
@apply bg-white/70 dark:bg-dark-800/70;
|
||||||
|
@apply backdrop-blur-xl;
|
||||||
|
@apply rounded-2xl;
|
||||||
|
@apply border border-white/20 dark:border-dark-700/50;
|
||||||
|
@apply shadow-glass;
|
||||||
|
@apply transition-all duration-300;
|
||||||
|
}
|
||||||
|
|
||||||
/* ============ 卡片样式 ============ */
|
/* ============ 卡片样式 ============ */
|
||||||
.card {
|
.card {
|
||||||
@apply bg-white dark:bg-dark-800/50;
|
@apply bg-white dark:bg-dark-800/50;
|
||||||
@@ -151,6 +179,20 @@
|
|||||||
@apply shadow-glass;
|
@apply shadow-glass;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.card-header {
|
||||||
|
@apply border-b border-gray-100 dark:border-dark-700;
|
||||||
|
@apply px-6 py-4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-body {
|
||||||
|
@apply p-6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-footer {
|
||||||
|
@apply border-t border-gray-100 dark:border-dark-700;
|
||||||
|
@apply px-6 py-4;
|
||||||
|
}
|
||||||
|
|
||||||
/* ============ 统计卡片 ============ */
|
/* ============ 统计卡片 ============ */
|
||||||
.stat-card {
|
.stat-card {
|
||||||
@apply card p-5;
|
@apply card p-5;
|
||||||
@@ -256,6 +298,10 @@
|
|||||||
@apply bg-gray-100 text-gray-700 dark:bg-dark-700 dark:text-dark-300;
|
@apply bg-gray-100 text-gray-700 dark:bg-dark-700 dark:text-dark-300;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.badge-purple {
|
||||||
|
@apply bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400;
|
||||||
|
}
|
||||||
|
|
||||||
/* ============ 下拉菜单 ============ */
|
/* ============ 下拉菜单 ============ */
|
||||||
.dropdown {
|
.dropdown {
|
||||||
@apply absolute z-50;
|
@apply absolute z-50;
|
||||||
@@ -283,15 +329,19 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.modal-content {
|
.modal-content {
|
||||||
|
@apply w-full;
|
||||||
|
@apply max-h-[95vh] sm:max-h-[90vh];
|
||||||
@apply bg-white dark:bg-dark-800;
|
@apply bg-white dark:bg-dark-800;
|
||||||
@apply rounded-2xl shadow-2xl;
|
@apply rounded-2xl shadow-2xl;
|
||||||
@apply w-full;
|
@apply border border-gray-200 dark:border-dark-700;
|
||||||
@apply max-h-[90vh] overflow-y-auto;
|
@apply flex flex-col;
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-header {
|
.modal-header {
|
||||||
@apply border-b border-gray-100 px-6 py-4 dark:border-dark-700;
|
@apply border-b border-gray-200 px-4 py-3 dark:border-dark-700;
|
||||||
|
@apply sm:px-6 sm:py-4;
|
||||||
@apply flex items-center justify-between;
|
@apply flex items-center justify-between;
|
||||||
|
@apply flex-shrink-0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-title {
|
.modal-title {
|
||||||
@@ -299,12 +349,69 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.modal-body {
|
.modal-body {
|
||||||
@apply px-6 py-4;
|
@apply px-4 py-3;
|
||||||
|
@apply sm:px-6 sm:py-4;
|
||||||
|
@apply flex-1 overflow-y-auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-footer {
|
.modal-footer {
|
||||||
@apply border-t border-gray-100 px-6 py-4 dark:border-dark-700;
|
@apply border-t border-gray-200 px-4 py-3 dark:border-dark-700;
|
||||||
|
@apply sm:px-6 sm:py-4;
|
||||||
@apply flex items-center justify-end gap-3;
|
@apply flex items-center justify-end gap-3;
|
||||||
|
@apply flex-shrink-0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 防止body滚动的工具类 */
|
||||||
|
body.modal-open {
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-enter-active {
|
||||||
|
transition: opacity 250ms ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-leave-active {
|
||||||
|
transition: opacity 200ms ease-in;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-enter-from,
|
||||||
|
.modal-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-enter-active .modal-content {
|
||||||
|
transition: transform 250ms ease-out, opacity 250ms ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-leave-active .modal-content {
|
||||||
|
transition: transform 200ms ease-in, opacity 200ms ease-in;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-enter-from .modal-content,
|
||||||
|
.modal-leave-to .modal-content {
|
||||||
|
transform: scale(0.95);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-enter-to .modal-content,
|
||||||
|
.modal-leave-from .modal-content {
|
||||||
|
transform: scale(1);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.modal-enter-active,
|
||||||
|
.modal-leave-active,
|
||||||
|
.modal-enter-active .modal-content,
|
||||||
|
.modal-leave-active .modal-content {
|
||||||
|
transition-duration: 1ms;
|
||||||
|
transition-delay: 0ms;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-enter-from .modal-content,
|
||||||
|
.modal-leave-to .modal-content {
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ============ Dialog ============ */
|
/* ============ Dialog ============ */
|
||||||
@@ -518,6 +625,43 @@
|
|||||||
@apply overflow-x-auto rounded-xl p-4;
|
@apply overflow-x-auto rounded-xl p-4;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ============ Tour Description ============ */
|
||||||
|
.tour-step-description {
|
||||||
|
@apply space-y-3 text-sm leading-relaxed text-gray-700 dark:text-gray-200;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tour-step-description ul {
|
||||||
|
@apply list-disc pl-5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tour-step-description ol {
|
||||||
|
@apply list-decimal pl-5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tour-step-description li + li {
|
||||||
|
@apply mt-1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tour-info-box {
|
||||||
|
@apply rounded-md border-l-4 border-blue-500 bg-blue-50 px-3 py-2 text-xs text-blue-900;
|
||||||
|
@apply dark:border-blue-400 dark:bg-blue-950/40 dark:text-blue-200;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tour-success-box {
|
||||||
|
@apply rounded-md border-l-4 border-emerald-500 bg-emerald-50 px-3 py-2 text-xs text-emerald-900;
|
||||||
|
@apply dark:border-emerald-400 dark:bg-emerald-950/40 dark:text-emerald-200;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tour-warning-box {
|
||||||
|
@apply rounded-md border-l-4 border-amber-500 bg-amber-50 px-3 py-2 text-xs text-amber-900;
|
||||||
|
@apply dark:border-amber-400 dark:bg-amber-950/40 dark:text-amber-200;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tour-error-box {
|
||||||
|
@apply rounded-md border-l-4 border-red-500 bg-red-50 px-3 py-2 text-xs text-red-900;
|
||||||
|
@apply dark:border-red-400 dark:bg-red-950/40 dark:text-red-200;
|
||||||
|
}
|
||||||
|
|
||||||
/* ============ 表格页面布局优化 ============ */
|
/* ============ 表格页面布局优化 ============ */
|
||||||
/* 表格容器 - 默认仅支持水平滚动 */
|
/* 表格容器 - 默认仅支持水平滚动 */
|
||||||
.table-wrapper {
|
.table-wrapper {
|
||||||
|
|||||||
228
frontend/src/styles/onboarding.css
Normal file
228
frontend/src/styles/onboarding.css
Normal file
@@ -0,0 +1,228 @@
|
|||||||
|
/* Sub2API Interactive Tour Styles - DOM Restructured Version */
|
||||||
|
|
||||||
|
/* 1. Overlay & Highlight */
|
||||||
|
.driver-overlay {
|
||||||
|
position: fixed !important;
|
||||||
|
inset: 0 !important;
|
||||||
|
z-index: 99999998 !important;
|
||||||
|
background-color: transparent !important;
|
||||||
|
/*
|
||||||
|
* 关键修复:让 overlay 不拦截点击事件
|
||||||
|
* 因为已设置 allowClose: false,用户不能通过点击遮罩关闭引导
|
||||||
|
* 这样 Select 下拉菜单等脱离高亮区域的元素才能正常交互
|
||||||
|
* 视觉遮罩效果保持不变(SVG 仍然渲染,pointer-events 只影响交互不影响渲染)
|
||||||
|
*/
|
||||||
|
pointer-events: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.driver-overlay svg {
|
||||||
|
pointer-events: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.driver-active-element {
|
||||||
|
position: relative !important;
|
||||||
|
z-index: 99999999 !important;
|
||||||
|
outline: 4px solid rgba(20, 184, 166, 0.2) !important;
|
||||||
|
border-radius: 4px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 2. Popover Container */
|
||||||
|
.driver-popover.theme-tour-popover {
|
||||||
|
position: fixed !important;
|
||||||
|
z-index: 100000000 !important;
|
||||||
|
background-color: #ffffff !important;
|
||||||
|
border: 1px solid #e5e7eb !important;
|
||||||
|
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04) !important;
|
||||||
|
border-radius: 12px !important;
|
||||||
|
padding: 0 !important;
|
||||||
|
max-width: min(440px, 90vw) !important; /* Responsive on small screens */
|
||||||
|
color: #1f2937 !important;
|
||||||
|
font-family: ui-sans-serif, system-ui, sans-serif !important;
|
||||||
|
overflow: hidden !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .driver-popover.theme-tour-popover {
|
||||||
|
background-color: #1e293b !important;
|
||||||
|
border-color: #334155 !important;
|
||||||
|
color: #f3f4f6 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 3. Header Area */
|
||||||
|
.theme-tour-popover .driver-popover-title {
|
||||||
|
display: flex !important;
|
||||||
|
align-items: center !important;
|
||||||
|
padding: 20px 24px 12px 24px !important;
|
||||||
|
margin: 0 !important;
|
||||||
|
background-color: transparent !important;
|
||||||
|
position: relative !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.driver-popover-title-text {
|
||||||
|
font-size: 18px !important;
|
||||||
|
font-weight: 700 !important;
|
||||||
|
color: #111827 !important;
|
||||||
|
line-height: 1.3 !important;
|
||||||
|
padding-right: 100px !important; /* Ensure title doesn't overlap Skip/Close */
|
||||||
|
}
|
||||||
|
.dark .driver-popover-title-text { color: #ffffff !important; }
|
||||||
|
|
||||||
|
/* Close Button */
|
||||||
|
.theme-tour-popover .driver-popover-close-btn {
|
||||||
|
position: absolute !important;
|
||||||
|
top: 18px !important;
|
||||||
|
right: 20px !important;
|
||||||
|
width: 28px !important;
|
||||||
|
height: 28px !important;
|
||||||
|
padding: 0 !important;
|
||||||
|
color: #9ca3af !important;
|
||||||
|
background-color: transparent !important;
|
||||||
|
border: none !important;
|
||||||
|
z-index: 20 !important;
|
||||||
|
border-radius: 4px !important;
|
||||||
|
display: flex !important;
|
||||||
|
align-items: center !important;
|
||||||
|
justify-content: center !important;
|
||||||
|
}
|
||||||
|
.theme-tour-popover .driver-popover-close-btn:hover { background-color: #f3f4f6 !important; color: #4b5563 !important; }
|
||||||
|
.dark .theme-tour-popover .driver-popover-close-btn:hover { background-color: #334155 !important; }
|
||||||
|
|
||||||
|
/* 4. Body Content */
|
||||||
|
.theme-tour-popover .driver-popover-description {
|
||||||
|
display: block !important;
|
||||||
|
font-size: 14px !important;
|
||||||
|
font-weight: 400 !important;
|
||||||
|
color: #4b5563 !important;
|
||||||
|
padding: 0 24px 24px 24px !important;
|
||||||
|
margin: 0 !important;
|
||||||
|
line-height: 1.6 !important;
|
||||||
|
background-color: transparent !important;
|
||||||
|
}
|
||||||
|
.dark .theme-tour-popover .driver-popover-description { color: #cbd5e1 !important; }
|
||||||
|
|
||||||
|
/* 5. Footer Area - Flex Row with Left/Right Containers */
|
||||||
|
.theme-tour-popover .driver-popover-footer {
|
||||||
|
display: flex !important;
|
||||||
|
align-items: center !important;
|
||||||
|
justify-content: space-between !important; /* Push Left and Right apart */
|
||||||
|
padding: 16px 24px !important;
|
||||||
|
background-color: #f9fafb !important;
|
||||||
|
border-top: 1px solid #f3f4f6 !important;
|
||||||
|
margin: 0 !important;
|
||||||
|
}
|
||||||
|
.dark .theme-tour-popover .driver-popover-footer {
|
||||||
|
background-color: #0f172a !important;
|
||||||
|
border-top-color: #1e293b !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Left Container: Progress + Shortcuts */
|
||||||
|
.footer-left {
|
||||||
|
display: flex !important;
|
||||||
|
align-items: center !important;
|
||||||
|
gap: 16px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Right Container: Buttons */
|
||||||
|
.footer-right {
|
||||||
|
display: flex !important;
|
||||||
|
align-items: center !important;
|
||||||
|
gap: 8px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Progress */
|
||||||
|
.theme-tour-popover .driver-popover-progress-text {
|
||||||
|
font-size: 13px !important;
|
||||||
|
color: #6b7280 !important;
|
||||||
|
margin: 0 !important;
|
||||||
|
font-weight: 500 !important;
|
||||||
|
white-space: nowrap !important;
|
||||||
|
}
|
||||||
|
.dark .theme-tour-popover .driver-popover-progress-text { color: #9ca3af !important; }
|
||||||
|
|
||||||
|
/* Shortcuts (Divider + Keys) */
|
||||||
|
.footer-shortcuts {
|
||||||
|
display: flex !important;
|
||||||
|
align-items: center !important;
|
||||||
|
gap: 12px !important;
|
||||||
|
padding-left: 16px !important;
|
||||||
|
border-left: 1px solid #e5e7eb !important;
|
||||||
|
height: 20px !important;
|
||||||
|
}
|
||||||
|
.dark .footer-shortcuts { border-left-color: #334155 !important; }
|
||||||
|
|
||||||
|
.shortcut-item {
|
||||||
|
display: flex !important;
|
||||||
|
align-items: center !important;
|
||||||
|
gap: 4px !important;
|
||||||
|
font-size: 12px !important;
|
||||||
|
color: #6b7280 !important;
|
||||||
|
white-space: nowrap !important;
|
||||||
|
}
|
||||||
|
.dark .shortcut-item { color: #94a3b8 !important; }
|
||||||
|
|
||||||
|
.shortcut-item kbd {
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, monospace !important;
|
||||||
|
background-color: #ffffff !important;
|
||||||
|
border: 1px solid #e5e7eb !important;
|
||||||
|
border-radius: 4px !important;
|
||||||
|
padding: 1px 6px !important;
|
||||||
|
font-size: 11px !important;
|
||||||
|
font-weight: 600 !important;
|
||||||
|
color: #4b5563 !important;
|
||||||
|
box-shadow: 0 1px 0 rgba(0,0,0,0.05) !important;
|
||||||
|
min-width: 20px !important;
|
||||||
|
text-align: center !important;
|
||||||
|
display: inline-block !important;
|
||||||
|
}
|
||||||
|
.dark .shortcut-item kbd {
|
||||||
|
background-color: #1e293b !important;
|
||||||
|
border-color: #475569 !important;
|
||||||
|
color: #cbd5e1 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Nav Buttons */
|
||||||
|
.theme-tour-popover button {
|
||||||
|
display: inline-flex !important;
|
||||||
|
align-items: center !important;
|
||||||
|
justify-content: center !important;
|
||||||
|
padding: 8px 16px !important;
|
||||||
|
font-size: 13px !important;
|
||||||
|
font-weight: 500 !important;
|
||||||
|
border-radius: 6px !important;
|
||||||
|
cursor: pointer !important;
|
||||||
|
transition: all 0.2s !important;
|
||||||
|
border: 1px solid transparent !important;
|
||||||
|
line-height: 1.2 !important;
|
||||||
|
white-space: nowrap !important; /* Force no wrap */
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-tour-popover .driver-popover-next-btn {
|
||||||
|
background-color: #14b8a6 !important;
|
||||||
|
color: #ffffff !important;
|
||||||
|
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05) !important;
|
||||||
|
}
|
||||||
|
.theme-tour-popover .driver-popover-next-btn:hover { background-color: #0d9488 !important; }
|
||||||
|
|
||||||
|
.theme-tour-popover .driver-popover-prev-btn {
|
||||||
|
background-color: white !important;
|
||||||
|
color: #6b7280 !important;
|
||||||
|
border: 1px solid #e5e7eb !important;
|
||||||
|
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05) !important;
|
||||||
|
}
|
||||||
|
.theme-tour-popover .driver-popover-prev-btn:hover { background-color: #f9fafb !important; color: #374151 !important; }
|
||||||
|
.dark .theme-tour-popover .driver-popover-prev-btn {
|
||||||
|
background-color: #1e293b !important;
|
||||||
|
border-color: #475569 !important;
|
||||||
|
color: #9ca3af !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Arrows */
|
||||||
|
.driver-popover-arrow { z-index: 100000001 !important; }
|
||||||
|
.driver-popover-arrow-side-left.driver-popover-arrow { border-left-color: #ffffff !important; }
|
||||||
|
.driver-popover-arrow-side-right.driver-popover-arrow { border-right-color: #ffffff !important; }
|
||||||
|
.driver-popover-arrow-side-top.driver-popover-arrow { border-top-color: #ffffff !important; }
|
||||||
|
.driver-popover-arrow-side-bottom.driver-popover-arrow { border-bottom-color: #ffffff !important; }
|
||||||
|
|
||||||
|
.dark .driver-popover-arrow-side-left.driver-popover-arrow { border-left-color: #1e293b !important; }
|
||||||
|
.dark .driver-popover-arrow-side-right.driver-popover-arrow { border-right-color: #1e293b !important; }
|
||||||
|
.dark .driver-popover-arrow-side-top.driver-popover-arrow { border-top-color: #1e293b !important; }
|
||||||
|
.dark .driver-popover-arrow-side-bottom.driver-popover-arrow { border-bottom-color: #1e293b !important; }
|
||||||
@@ -38,7 +38,7 @@
|
|||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
<button @click="showCreateModal = true" class="btn btn-primary">
|
<button @click="showCreateModal = true" class="btn btn-primary" data-tour="accounts-create-btn">
|
||||||
<svg
|
<svg
|
||||||
class="mr-2 h-5 w-5"
|
class="mr-2 h-5 w-5"
|
||||||
fill="none"
|
fill="none"
|
||||||
@@ -105,65 +105,65 @@
|
|||||||
|
|
||||||
<template #table>
|
<template #table>
|
||||||
<!-- Bulk Actions Bar -->
|
<!-- Bulk Actions Bar -->
|
||||||
<div
|
<div
|
||||||
v-if="selectedAccountIds.length > 0"
|
v-if="selectedAccountIds.length > 0"
|
||||||
class="card border-primary-200 bg-primary-50 px-4 py-3 dark:border-primary-800 dark:bg-primary-900/20"
|
class="mb-[5px] mt-[10px] px-5 py-1"
|
||||||
>
|
>
|
||||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||||
<div class="flex flex-wrap items-center gap-2">
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
<span class="text-sm font-medium text-primary-900 dark:text-primary-100">
|
<span class="text-sm font-medium text-primary-900 dark:text-primary-100">
|
||||||
{{ t('admin.accounts.bulkActions.selected', { count: selectedAccountIds.length }) }}
|
{{ t('admin.accounts.bulkActions.selected', { count: selectedAccountIds.length }) }}
|
||||||
</span>
|
</span>
|
||||||
<button
|
<button
|
||||||
@click="selectCurrentPageAccounts"
|
@click="selectCurrentPageAccounts"
|
||||||
class="text-xs font-medium text-primary-700 hover:text-primary-800 dark:text-primary-300 dark:hover:text-primary-200"
|
class="text-xs font-medium text-primary-700 hover:text-primary-800 dark:text-primary-300 dark:hover:text-primary-200"
|
||||||
>
|
|
||||||
{{ t('admin.accounts.bulkActions.selectCurrentPage') }}
|
|
||||||
</button>
|
|
||||||
<span class="text-gray-300 dark:text-primary-800">•</span>
|
|
||||||
<button
|
|
||||||
@click="selectedAccountIds = []"
|
|
||||||
class="text-xs font-medium text-primary-700 hover:text-primary-800 dark:text-primary-300 dark:hover:text-primary-200"
|
|
||||||
>
|
|
||||||
{{ t('admin.accounts.bulkActions.clear') }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<button @click="handleBulkDelete" class="btn btn-danger btn-sm">
|
|
||||||
<svg
|
|
||||||
class="mr-1.5 h-4 w-4"
|
|
||||||
fill="none"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="1.5"
|
|
||||||
>
|
>
|
||||||
<path
|
{{ t('admin.accounts.bulkActions.selectCurrentPage') }}
|
||||||
stroke-linecap="round"
|
</button>
|
||||||
stroke-linejoin="round"
|
<span class="text-gray-300 dark:text-primary-800">•</span>
|
||||||
d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0"
|
<button
|
||||||
/>
|
@click="selectedAccountIds = []"
|
||||||
</svg>
|
class="text-xs font-medium text-primary-700 hover:text-primary-800 dark:text-primary-300 dark:hover:text-primary-200"
|
||||||
{{ t('admin.accounts.bulkActions.delete') }}
|
|
||||||
</button>
|
|
||||||
<button @click="showBulkEditModal = true" class="btn btn-primary btn-sm">
|
|
||||||
<svg
|
|
||||||
class="mr-1.5 h-4 w-4"
|
|
||||||
fill="none"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="1.5"
|
|
||||||
>
|
>
|
||||||
<path
|
{{ t('admin.accounts.bulkActions.clear') }}
|
||||||
stroke-linecap="round"
|
</button>
|
||||||
stroke-linejoin="round"
|
</div>
|
||||||
d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L10.582 16.07a4.5 4.5 0 01-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 011.13-1.897l8.932-8.931zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0115.75 21H5.25A2.25 2.25 0 013 18.75V8.25A2.25 2.25 0 015.25 6H10"
|
<div class="flex items-center gap-2">
|
||||||
/>
|
<button @click="handleBulkDelete" class="btn btn-danger btn-sm">
|
||||||
</svg>
|
<svg
|
||||||
{{ t('admin.accounts.bulkActions.edit') }}
|
class="mr-1.5 h-4 w-4"
|
||||||
</button>
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="1.5"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
{{ t('admin.accounts.bulkActions.delete') }}
|
||||||
|
</button>
|
||||||
|
<button @click="showBulkEditModal = true" class="btn btn-primary btn-sm">
|
||||||
|
<svg
|
||||||
|
class="mr-1.5 h-4 w-4"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="1.5"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L10.582 16.07a4.5 4.5 0 01-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 011.13-1.897l8.932-8.931zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0115.75 21H5.25A2.25 2.25 0 013 18.75V8.25A2.25 2.25 0 015.25 6H10"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
{{ t('admin.accounts.bulkActions.edit') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<DataTable :columns="columns" :data="accounts" :loading="loading">
|
<DataTable :columns="columns" :data="accounts" :loading="loading">
|
||||||
<template #cell-select="{ row }">
|
<template #cell-select="{ row }">
|
||||||
@@ -373,7 +373,7 @@
|
|||||||
:proxies="proxies"
|
:proxies="proxies"
|
||||||
:groups="groups"
|
:groups="groups"
|
||||||
@close="showCreateModal = false"
|
@close="showCreateModal = false"
|
||||||
@created="loadAccounts"
|
@created="() => { loadAccounts(); if (onboardingStore.isCurrentStep(`[data-tour='account-form-submit']`)) onboardingStore.nextStep(500) }"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- Edit Account Modal -->
|
<!-- Edit Account Modal -->
|
||||||
@@ -495,6 +495,7 @@ import { ref, reactive, computed, onMounted, onUnmounted, type ComponentPublicIn
|
|||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { useAppStore } from '@/stores/app'
|
import { useAppStore } from '@/stores/app'
|
||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
import { useOnboardingStore } from '@/stores/onboarding'
|
||||||
import { adminAPI } from '@/api/admin'
|
import { adminAPI } from '@/api/admin'
|
||||||
import type { Account, Proxy, Group } from '@/types'
|
import type { Account, Proxy, Group } from '@/types'
|
||||||
import type { Column } from '@/components/common/types'
|
import type { Column } from '@/components/common/types'
|
||||||
@@ -524,6 +525,7 @@ import { formatRelativeTime } from '@/utils/format'
|
|||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const appStore = useAppStore()
|
const appStore = useAppStore()
|
||||||
const authStore = useAuthStore()
|
const authStore = useAuthStore()
|
||||||
|
const onboardingStore = useOnboardingStore()
|
||||||
|
|
||||||
// Table columns
|
// Table columns
|
||||||
const columns = computed<Column[]>(() => {
|
const columns = computed<Column[]>(() => {
|
||||||
|
|||||||
@@ -23,7 +23,11 @@
|
|||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
<button @click="showCreateModal = true" class="btn btn-primary">
|
<button
|
||||||
|
@click="showCreateModal = true"
|
||||||
|
class="btn btn-primary"
|
||||||
|
data-tour="groups-create-btn"
|
||||||
|
>
|
||||||
<svg
|
<svg
|
||||||
class="mr-2 h-5 w-5"
|
class="mr-2 h-5 w-5"
|
||||||
fill="none"
|
fill="none"
|
||||||
@@ -254,6 +258,7 @@
|
|||||||
required
|
required
|
||||||
class="input"
|
class="input"
|
||||||
:placeholder="t('admin.groups.enterGroupName')"
|
:placeholder="t('admin.groups.enterGroupName')"
|
||||||
|
data-tour="group-form-name"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@@ -267,7 +272,11 @@
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="input-label">{{ t('admin.groups.form.platform') }}</label>
|
<label class="input-label">{{ t('admin.groups.form.platform') }}</label>
|
||||||
<Select v-model="createForm.platform" :options="platformOptions" />
|
<Select
|
||||||
|
v-model="createForm.platform"
|
||||||
|
:options="platformOptions"
|
||||||
|
data-tour="group-form-platform"
|
||||||
|
/>
|
||||||
<p class="input-hint">{{ t('admin.groups.platformHint') }}</p>
|
<p class="input-hint">{{ t('admin.groups.platformHint') }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="createForm.subscription_type !== 'subscription'">
|
<div v-if="createForm.subscription_type !== 'subscription'">
|
||||||
@@ -279,10 +288,11 @@
|
|||||||
min="0.001"
|
min="0.001"
|
||||||
required
|
required
|
||||||
class="input"
|
class="input"
|
||||||
|
data-tour="group-form-multiplier"
|
||||||
/>
|
/>
|
||||||
<p class="input-hint">{{ t('admin.groups.rateMultiplierHint') }}</p>
|
<p class="input-hint">{{ t('admin.groups.rateMultiplierHint') }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="createForm.subscription_type !== 'subscription'">
|
<div v-if="createForm.subscription_type !== 'subscription'" data-tour="group-form-exclusive">
|
||||||
<div class="mb-1.5 flex items-center gap-1">
|
<div class="mb-1.5 flex items-center gap-1">
|
||||||
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">
|
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
{{ t('admin.groups.form.exclusive') }}
|
{{ t('admin.groups.form.exclusive') }}
|
||||||
@@ -400,6 +410,7 @@
|
|||||||
form="create-group-form"
|
form="create-group-form"
|
||||||
:disabled="submitting"
|
:disabled="submitting"
|
||||||
class="btn btn-primary"
|
class="btn btn-primary"
|
||||||
|
data-tour="group-form-submit"
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
v-if="submitting"
|
v-if="submitting"
|
||||||
@@ -442,7 +453,13 @@
|
|||||||
>
|
>
|
||||||
<div>
|
<div>
|
||||||
<label class="input-label">{{ t('admin.groups.form.name') }}</label>
|
<label class="input-label">{{ t('admin.groups.form.name') }}</label>
|
||||||
<input v-model="editForm.name" type="text" required class="input" />
|
<input
|
||||||
|
v-model="editForm.name"
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
class="input"
|
||||||
|
data-tour="edit-group-form-name"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="input-label">{{ t('admin.groups.form.description') }}</label>
|
<label class="input-label">{{ t('admin.groups.form.description') }}</label>
|
||||||
@@ -450,7 +467,12 @@
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="input-label">{{ t('admin.groups.form.platform') }}</label>
|
<label class="input-label">{{ t('admin.groups.form.platform') }}</label>
|
||||||
<Select v-model="editForm.platform" :options="platformOptions" :disabled="true" />
|
<Select
|
||||||
|
v-model="editForm.platform"
|
||||||
|
:options="platformOptions"
|
||||||
|
:disabled="true"
|
||||||
|
data-tour="group-form-platform"
|
||||||
|
/>
|
||||||
<p class="input-hint">{{ t('admin.groups.platformNotEditable') }}</p>
|
<p class="input-hint">{{ t('admin.groups.platformNotEditable') }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="editForm.subscription_type !== 'subscription'">
|
<div v-if="editForm.subscription_type !== 'subscription'">
|
||||||
@@ -462,6 +484,7 @@
|
|||||||
min="0.001"
|
min="0.001"
|
||||||
required
|
required
|
||||||
class="input"
|
class="input"
|
||||||
|
data-tour="group-form-multiplier"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="editForm.subscription_type !== 'subscription'">
|
<div v-if="editForm.subscription_type !== 'subscription'">
|
||||||
@@ -590,6 +613,7 @@
|
|||||||
form="edit-group-form"
|
form="edit-group-form"
|
||||||
:disabled="submitting"
|
:disabled="submitting"
|
||||||
class="btn btn-primary"
|
class="btn btn-primary"
|
||||||
|
data-tour="group-form-submit"
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
v-if="submitting"
|
v-if="submitting"
|
||||||
@@ -635,6 +659,7 @@
|
|||||||
import { ref, reactive, computed, onMounted, watch } from 'vue'
|
import { ref, reactive, computed, onMounted, watch } from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { useAppStore } from '@/stores/app'
|
import { useAppStore } from '@/stores/app'
|
||||||
|
import { useOnboardingStore } from '@/stores/onboarding'
|
||||||
import { adminAPI } from '@/api/admin'
|
import { adminAPI } from '@/api/admin'
|
||||||
import type { Group, GroupPlatform, SubscriptionType } from '@/types'
|
import type { Group, GroupPlatform, SubscriptionType } from '@/types'
|
||||||
import type { Column } from '@/components/common/types'
|
import type { Column } from '@/components/common/types'
|
||||||
@@ -650,6 +675,7 @@ import PlatformIcon from '@/components/common/PlatformIcon.vue'
|
|||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const appStore = useAppStore()
|
const appStore = useAppStore()
|
||||||
|
const onboardingStore = useOnboardingStore()
|
||||||
|
|
||||||
const columns = computed<Column[]>(() => [
|
const columns = computed<Column[]>(() => [
|
||||||
{ key: 'name', label: t('admin.groups.columns.name'), sortable: true },
|
{ key: 'name', label: t('admin.groups.columns.name'), sortable: true },
|
||||||
@@ -821,9 +847,14 @@ const handleCreateGroup = async () => {
|
|||||||
appStore.showSuccess(t('admin.groups.groupCreated'))
|
appStore.showSuccess(t('admin.groups.groupCreated'))
|
||||||
closeCreateModal()
|
closeCreateModal()
|
||||||
loadGroups()
|
loadGroups()
|
||||||
|
// Only advance tour if active, on submit step, and creation succeeded
|
||||||
|
if (onboardingStore.isCurrentStep('[data-tour="group-form-submit"]')) {
|
||||||
|
onboardingStore.nextStep(500)
|
||||||
|
}
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
appStore.showError(error.response?.data?.detail || t('admin.groups.failedToCreate'))
|
appStore.showError(error.response?.data?.detail || t('admin.groups.failedToCreate'))
|
||||||
console.error('Error creating group:', error)
|
console.error('Error creating group:', error)
|
||||||
|
// Don't advance tour on error
|
||||||
} finally {
|
} finally {
|
||||||
submitting.value = false
|
submitting.value = false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -300,8 +300,8 @@
|
|||||||
<button @click="resetFilters" class="btn btn-secondary">
|
<button @click="resetFilters" class="btn btn-secondary">
|
||||||
{{ t('common.reset') }}
|
{{ t('common.reset') }}
|
||||||
</button>
|
</button>
|
||||||
<button @click="exportToCSV" class="btn btn-primary">
|
<button @click="exportToExcel" :disabled="exporting" class="btn btn-primary">
|
||||||
{{ t('usage.exportCsv') }}
|
{{ t('usage.exportExcel') }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -361,90 +361,114 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template #cell-tokens="{ row }">
|
<template #cell-tokens="{ row }">
|
||||||
<div class="space-y-1.5 text-sm">
|
<div class="flex items-center gap-1.5">
|
||||||
<!-- Input / Output Tokens -->
|
<div class="space-y-1.5 text-sm">
|
||||||
<div class="flex items-center gap-2">
|
<!-- Input / Output Tokens -->
|
||||||
<!-- Input -->
|
<div class="flex items-center gap-2">
|
||||||
<div class="inline-flex items-center gap-1">
|
<!-- Input -->
|
||||||
<svg
|
<div class="inline-flex items-center gap-1">
|
||||||
class="h-3.5 w-3.5 text-emerald-500"
|
<svg
|
||||||
fill="none"
|
class="h-3.5 w-3.5 text-emerald-500"
|
||||||
stroke="currentColor"
|
fill="none"
|
||||||
viewBox="0 0 24 24"
|
stroke="currentColor"
|
||||||
>
|
viewBox="0 0 24 24"
|
||||||
<path
|
>
|
||||||
stroke-linecap="round"
|
<path
|
||||||
stroke-linejoin="round"
|
stroke-linecap="round"
|
||||||
stroke-width="2"
|
stroke-linejoin="round"
|
||||||
d="M19 14l-7 7m0 0l-7-7m7 7V3"
|
stroke-width="2"
|
||||||
/>
|
d="M19 14l-7 7m0 0l-7-7m7 7V3"
|
||||||
</svg>
|
/>
|
||||||
<span class="font-medium text-gray-900 dark:text-white">{{
|
</svg>
|
||||||
row.input_tokens.toLocaleString()
|
<span class="font-medium text-gray-900 dark:text-white">{{
|
||||||
}}</span>
|
row.input_tokens.toLocaleString()
|
||||||
|
}}</span>
|
||||||
|
</div>
|
||||||
|
<!-- Output -->
|
||||||
|
<div class="inline-flex items-center gap-1">
|
||||||
|
<svg
|
||||||
|
class="h-3.5 w-3.5 text-violet-500"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M5 10l7-7m0 0l7 7m-7-7v18"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<span class="font-medium text-gray-900 dark:text-white">{{
|
||||||
|
row.output_tokens.toLocaleString()
|
||||||
|
}}</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- Output -->
|
<!-- Cache Tokens (Read + Write) -->
|
||||||
<div class="inline-flex items-center gap-1">
|
<div
|
||||||
<svg
|
v-if="row.cache_read_tokens > 0 || row.cache_creation_tokens > 0"
|
||||||
class="h-3.5 w-3.5 text-violet-500"
|
class="flex items-center gap-2"
|
||||||
fill="none"
|
>
|
||||||
stroke="currentColor"
|
<!-- Cache Read -->
|
||||||
viewBox="0 0 24 24"
|
<div v-if="row.cache_read_tokens > 0" class="inline-flex items-center gap-1">
|
||||||
>
|
<svg
|
||||||
<path
|
class="h-3.5 w-3.5 text-sky-500"
|
||||||
stroke-linecap="round"
|
fill="none"
|
||||||
stroke-linejoin="round"
|
stroke="currentColor"
|
||||||
stroke-width="2"
|
viewBox="0 0 24 24"
|
||||||
d="M5 10l7-7m0 0l7 7m-7-7v18"
|
>
|
||||||
/>
|
<path
|
||||||
</svg>
|
stroke-linecap="round"
|
||||||
<span class="font-medium text-gray-900 dark:text-white">{{
|
stroke-linejoin="round"
|
||||||
row.output_tokens.toLocaleString()
|
stroke-width="2"
|
||||||
}}</span>
|
d="M5 8h14M5 8a2 2 0 110-4h14a2 2 0 110 4M5 8v10a2 2 0 002 2h10a2 2 0 002-2V8m-9 4h4"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<span class="font-medium text-sky-600 dark:text-sky-400">{{
|
||||||
|
formatCacheTokens(row.cache_read_tokens)
|
||||||
|
}}</span>
|
||||||
|
</div>
|
||||||
|
<!-- Cache Write -->
|
||||||
|
<div v-if="row.cache_creation_tokens > 0" class="inline-flex items-center gap-1">
|
||||||
|
<svg
|
||||||
|
class="h-3.5 w-3.5 text-amber-500"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<span class="font-medium text-amber-600 dark:text-amber-400">{{
|
||||||
|
formatCacheTokens(row.cache_creation_tokens)
|
||||||
|
}}</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- Cache Tokens (Read + Write) -->
|
<!-- Token Detail Tooltip -->
|
||||||
<div
|
<div
|
||||||
v-if="row.cache_read_tokens > 0 || row.cache_creation_tokens > 0"
|
class="group relative"
|
||||||
class="flex items-center gap-2"
|
@mouseenter="showTokenTooltip($event, row)"
|
||||||
|
@mouseleave="hideTokenTooltip"
|
||||||
>
|
>
|
||||||
<!-- Cache Read -->
|
<div
|
||||||
<div v-if="row.cache_read_tokens > 0" class="inline-flex items-center gap-1">
|
class="flex h-4 w-4 cursor-help items-center justify-center rounded-full bg-gray-100 transition-colors group-hover:bg-blue-100 dark:bg-gray-700 dark:group-hover:bg-blue-900/50"
|
||||||
|
>
|
||||||
<svg
|
<svg
|
||||||
class="h-3.5 w-3.5 text-sky-500"
|
class="h-3 w-3 text-gray-400 group-hover:text-blue-500 dark:text-gray-500 dark:group-hover:text-blue-400"
|
||||||
fill="none"
|
fill="currentColor"
|
||||||
stroke="currentColor"
|
viewBox="0 0 20 20"
|
||||||
viewBox="0 0 24 24"
|
|
||||||
>
|
>
|
||||||
<path
|
<path
|
||||||
stroke-linecap="round"
|
fill-rule="evenodd"
|
||||||
stroke-linejoin="round"
|
d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z"
|
||||||
stroke-width="2"
|
clip-rule="evenodd"
|
||||||
d="M5 8h14M5 8a2 2 0 110-4h14a2 2 0 110 4M5 8v10a2 2 0 002 2h10a2 2 0 002-2V8m-9 4h4"
|
|
||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
<span class="font-medium text-sky-600 dark:text-sky-400">{{
|
|
||||||
formatCacheTokens(row.cache_read_tokens)
|
|
||||||
}}</span>
|
|
||||||
</div>
|
|
||||||
<!-- Cache Write -->
|
|
||||||
<div v-if="row.cache_creation_tokens > 0" class="inline-flex items-center gap-1">
|
|
||||||
<svg
|
|
||||||
class="h-3.5 w-3.5 text-amber-500"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
stroke-width="2"
|
|
||||||
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
<span class="font-medium text-amber-600 dark:text-amber-400">{{
|
|
||||||
formatCacheTokens(row.cache_creation_tokens)
|
|
||||||
}}</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -516,9 +540,50 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template #cell-request_id="{ row }">
|
<template #cell-request_id="{ row }">
|
||||||
<span class="font-mono text-xs text-gray-500 dark:text-gray-400">{{
|
<div v-if="row.request_id" class="flex items-center gap-1.5 max-w-[120px]">
|
||||||
row.request_id || '-'
|
<span
|
||||||
}}</span>
|
class="font-mono text-xs text-gray-500 dark:text-gray-400 truncate"
|
||||||
|
:title="row.request_id"
|
||||||
|
>
|
||||||
|
{{ row.request_id }}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
@click="copyRequestId(row.request_id)"
|
||||||
|
class="flex-shrink-0 rounded p-0.5 transition-colors hover:bg-gray-100 dark:hover:bg-dark-700"
|
||||||
|
:class="
|
||||||
|
copiedRequestId === row.request_id
|
||||||
|
? 'text-green-500'
|
||||||
|
: 'text-gray-400 hover:text-gray-600 dark:hover:text-gray-300'
|
||||||
|
"
|
||||||
|
:title="copiedRequestId === row.request_id ? t('keys.copied') : t('keys.copyToClipboard')"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
v-if="copiedRequestId === row.request_id"
|
||||||
|
class="h-3.5 w-3.5"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke-width="2"
|
||||||
|
>
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
|
||||||
|
</svg>
|
||||||
|
<svg
|
||||||
|
v-else
|
||||||
|
class="h-3.5 w-3.5"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke-width="2"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<span v-else class="text-gray-400 dark:text-gray-500">-</span>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template #empty>
|
<template #empty>
|
||||||
@@ -540,6 +605,63 @@
|
|||||||
</div>
|
</div>
|
||||||
</AppLayout>
|
</AppLayout>
|
||||||
|
|
||||||
|
<ExportProgressDialog
|
||||||
|
:show="exportProgress.show"
|
||||||
|
:progress="exportProgress.progress"
|
||||||
|
:current="exportProgress.current"
|
||||||
|
:total="exportProgress.total"
|
||||||
|
:estimated-time="exportProgress.estimatedTime"
|
||||||
|
@cancel="cancelExport"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Token Tooltip Portal -->
|
||||||
|
<Teleport to="body">
|
||||||
|
<div
|
||||||
|
v-if="tokenTooltipVisible"
|
||||||
|
class="fixed z-[9999] pointer-events-none -translate-y-1/2"
|
||||||
|
:style="{
|
||||||
|
left: tokenTooltipPosition.x + 'px',
|
||||||
|
top: tokenTooltipPosition.y + 'px'
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="whitespace-nowrap rounded-lg border border-gray-700 bg-gray-900 px-3 py-2.5 text-xs text-white shadow-xl dark:border-gray-600 dark:bg-gray-800"
|
||||||
|
>
|
||||||
|
<div class="space-y-1.5">
|
||||||
|
<!-- Token Breakdown -->
|
||||||
|
<div class="mb-2 border-b border-gray-700 pb-1.5">
|
||||||
|
<div class="text-xs font-semibold text-gray-300 mb-1">Token 明细</div>
|
||||||
|
<div v-if="tokenTooltipData && tokenTooltipData.input_tokens > 0" class="flex items-center justify-between gap-4">
|
||||||
|
<span class="text-gray-400">{{ t('admin.usage.inputTokens') }}</span>
|
||||||
|
<span class="font-medium text-white">{{ tokenTooltipData.input_tokens.toLocaleString() }}</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="tokenTooltipData && tokenTooltipData.output_tokens > 0" class="flex items-center justify-between gap-4">
|
||||||
|
<span class="text-gray-400">{{ t('admin.usage.outputTokens') }}</span>
|
||||||
|
<span class="font-medium text-white">{{ tokenTooltipData.output_tokens.toLocaleString() }}</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="tokenTooltipData && tokenTooltipData.cache_creation_tokens > 0" class="flex items-center justify-between gap-4">
|
||||||
|
<span class="text-gray-400">{{ t('admin.usage.cacheCreationTokens') }}</span>
|
||||||
|
<span class="font-medium text-white">{{ tokenTooltipData.cache_creation_tokens.toLocaleString() }}</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="tokenTooltipData && tokenTooltipData.cache_read_tokens > 0" class="flex items-center justify-between gap-4">
|
||||||
|
<span class="text-gray-400">{{ t('admin.usage.cacheReadTokens') }}</span>
|
||||||
|
<span class="font-medium text-white">{{ tokenTooltipData.cache_read_tokens.toLocaleString() }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Total -->
|
||||||
|
<div class="flex items-center justify-between gap-6 border-t border-gray-700 pt-1.5">
|
||||||
|
<span class="text-gray-400">{{ t('usage.totalTokens') }}</span>
|
||||||
|
<span class="font-semibold text-blue-400">{{ ((tokenTooltipData?.input_tokens || 0) + (tokenTooltipData?.output_tokens || 0) + (tokenTooltipData?.cache_creation_tokens || 0) + (tokenTooltipData?.cache_read_tokens || 0)).toLocaleString() }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Tooltip Arrow (left side) -->
|
||||||
|
<div
|
||||||
|
class="absolute right-full top-1/2 h-0 w-0 -translate-y-1/2 border-b-[6px] border-r-[6px] border-t-[6px] border-b-transparent border-r-gray-900 border-t-transparent dark:border-r-gray-800"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Teleport>
|
||||||
|
|
||||||
<!-- Tooltip Portal -->
|
<!-- Tooltip Portal -->
|
||||||
<Teleport to="body">
|
<Teleport to="body">
|
||||||
<div
|
<div
|
||||||
@@ -602,10 +724,14 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
import { ref, computed, reactive, onMounted, onUnmounted } from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import * as XLSX from 'xlsx'
|
||||||
|
import { saveAs } from 'file-saver'
|
||||||
import { useAppStore } from '@/stores/app'
|
import { useAppStore } from '@/stores/app'
|
||||||
|
import { useClipboard } from '@/composables/useClipboard'
|
||||||
import { adminAPI } from '@/api/admin'
|
import { adminAPI } from '@/api/admin'
|
||||||
|
import { adminUsageAPI } from '@/api/admin/usage'
|
||||||
import AppLayout from '@/components/layout/AppLayout.vue'
|
import AppLayout from '@/components/layout/AppLayout.vue'
|
||||||
import DataTable from '@/components/common/DataTable.vue'
|
import DataTable from '@/components/common/DataTable.vue'
|
||||||
import Pagination from '@/components/common/Pagination.vue'
|
import Pagination from '@/components/common/Pagination.vue'
|
||||||
@@ -615,6 +741,7 @@ import Select from '@/components/common/Select.vue'
|
|||||||
import DateRangePicker from '@/components/common/DateRangePicker.vue'
|
import DateRangePicker from '@/components/common/DateRangePicker.vue'
|
||||||
import ModelDistributionChart from '@/components/charts/ModelDistributionChart.vue'
|
import ModelDistributionChart from '@/components/charts/ModelDistributionChart.vue'
|
||||||
import TokenUsageTrend from '@/components/charts/TokenUsageTrend.vue'
|
import TokenUsageTrend from '@/components/charts/TokenUsageTrend.vue'
|
||||||
|
import ExportProgressDialog from '@/components/common/ExportProgressDialog.vue'
|
||||||
import type { UsageLog, TrendDataPoint, ModelStat } from '@/types'
|
import type { UsageLog, TrendDataPoint, ModelStat } from '@/types'
|
||||||
import type { Column } from '@/components/common/types'
|
import type { Column } from '@/components/common/types'
|
||||||
import type {
|
import type {
|
||||||
@@ -626,12 +753,21 @@ import type {
|
|||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const appStore = useAppStore()
|
const appStore = useAppStore()
|
||||||
|
const { copyToClipboard: clipboardCopy } = useClipboard()
|
||||||
|
|
||||||
// Tooltip state
|
// Tooltip state
|
||||||
const tooltipVisible = ref(false)
|
const tooltipVisible = ref(false)
|
||||||
const tooltipPosition = ref({ x: 0, y: 0 })
|
const tooltipPosition = ref({ x: 0, y: 0 })
|
||||||
const tooltipData = ref<UsageLog | null>(null)
|
const tooltipData = ref<UsageLog | null>(null)
|
||||||
|
|
||||||
|
// Token tooltip state
|
||||||
|
const tokenTooltipVisible = ref(false)
|
||||||
|
const tokenTooltipPosition = ref({ x: 0, y: 0 })
|
||||||
|
const tokenTooltipData = ref<UsageLog | null>(null)
|
||||||
|
|
||||||
|
// Request ID copy state
|
||||||
|
const copiedRequestId = ref<string | null>(null)
|
||||||
|
|
||||||
// Usage stats from API
|
// Usage stats from API
|
||||||
const usageStats = ref<AdminUsageStatsResponse | null>(null)
|
const usageStats = ref<AdminUsageStatsResponse | null>(null)
|
||||||
|
|
||||||
@@ -657,6 +793,7 @@ const columns = computed<Column[]>(() => [
|
|||||||
{ key: 'tokens', label: t('usage.tokens'), sortable: false },
|
{ key: 'tokens', label: t('usage.tokens'), sortable: false },
|
||||||
{ key: 'cost', label: t('usage.cost'), sortable: false },
|
{ key: 'cost', label: t('usage.cost'), sortable: false },
|
||||||
{ key: 'billing_type', label: t('usage.billingType'), sortable: false },
|
{ key: 'billing_type', label: t('usage.billingType'), sortable: false },
|
||||||
|
{ key: 'first_token', label: t('usage.firstToken'), sortable: false },
|
||||||
{ key: 'duration', label: t('usage.duration'), sortable: false },
|
{ key: 'duration', label: t('usage.duration'), sortable: false },
|
||||||
{ key: 'created_at', label: t('usage.time'), sortable: true },
|
{ key: 'created_at', label: t('usage.time'), sortable: true },
|
||||||
{ key: 'request_id', label: t('admin.usage.requestId'), sortable: false }
|
{ key: 'request_id', label: t('admin.usage.requestId'), sortable: false }
|
||||||
@@ -669,6 +806,15 @@ const accounts = ref<any[]>([])
|
|||||||
const groups = ref<any[]>([])
|
const groups = ref<any[]>([])
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
let abortController: AbortController | null = null
|
let abortController: AbortController | null = null
|
||||||
|
let exportAbortController: AbortController | null = null
|
||||||
|
const exporting = ref(false)
|
||||||
|
const exportProgress = reactive({
|
||||||
|
show: false,
|
||||||
|
progress: 0,
|
||||||
|
current: 0,
|
||||||
|
total: 0,
|
||||||
|
estimatedTime: ''
|
||||||
|
})
|
||||||
|
|
||||||
// User search state
|
// User search state
|
||||||
const userSearchKeyword = ref('')
|
const userSearchKeyword = ref('')
|
||||||
@@ -868,6 +1014,16 @@ const formatCacheTokens = (value: number): string => {
|
|||||||
return value.toLocaleString()
|
return value.toLocaleString()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const copyRequestId = async (requestId: string) => {
|
||||||
|
const success = await clipboardCopy(requestId, t('admin.usage.requestIdCopied'))
|
||||||
|
if (success) {
|
||||||
|
copiedRequestId.value = requestId
|
||||||
|
setTimeout(() => {
|
||||||
|
copiedRequestId.value = null
|
||||||
|
}, 800)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const isAbortError = (error: unknown): boolean => {
|
const isAbortError = (error: unknown): boolean => {
|
||||||
if (error instanceof DOMException && error.name === 'AbortError') {
|
if (error instanceof DOMException && error.name === 'AbortError') {
|
||||||
return true
|
return true
|
||||||
@@ -879,6 +1035,40 @@ const isAbortError = (error: unknown): boolean => {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const formatExportTimestamp = (date: Date): string => {
|
||||||
|
const pad = (value: number) => String(value).padStart(2, '0')
|
||||||
|
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}_${pad(date.getHours())}-${pad(date.getMinutes())}-${pad(date.getSeconds())}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatRemainingTime = (ms: number): string => {
|
||||||
|
const totalSeconds = Math.max(0, Math.round(ms / 1000))
|
||||||
|
const hours = Math.floor(totalSeconds / 3600)
|
||||||
|
const minutes = Math.floor((totalSeconds % 3600) / 60)
|
||||||
|
const seconds = totalSeconds % 60
|
||||||
|
const parts = []
|
||||||
|
if (hours > 0) {
|
||||||
|
parts.push(`${hours}h`)
|
||||||
|
}
|
||||||
|
if (minutes > 0 || hours > 0) {
|
||||||
|
parts.push(`${minutes}m`)
|
||||||
|
}
|
||||||
|
parts.push(`${seconds}s`)
|
||||||
|
return parts.join(' ')
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateExportProgress = (current: number, total: number, startedAt: number) => {
|
||||||
|
exportProgress.current = current
|
||||||
|
exportProgress.total = total
|
||||||
|
exportProgress.progress = total > 0 ? Math.min(100, Math.round((current / total) * 100)) : 0
|
||||||
|
if (current > 0 && total > 0) {
|
||||||
|
const elapsedMs = Date.now() - startedAt
|
||||||
|
const remainingMs = Math.max(0, Math.round((elapsedMs / current) * (total - current)))
|
||||||
|
exportProgress.estimatedTime = formatRemainingTime(remainingMs)
|
||||||
|
} else {
|
||||||
|
exportProgress.estimatedTime = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const loadUsageLogs = async () => {
|
const loadUsageLogs = async () => {
|
||||||
if (abortController) {
|
if (abortController) {
|
||||||
abortController.abort()
|
abortController.abort()
|
||||||
@@ -1051,52 +1241,129 @@ const handlePageSizeChange = (pageSize: number) => {
|
|||||||
loadUsageLogs()
|
loadUsageLogs()
|
||||||
}
|
}
|
||||||
|
|
||||||
const exportToCSV = () => {
|
const cancelExport = () => {
|
||||||
if (usageLogs.value.length === 0) {
|
if (!exporting.value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
exportAbortController?.abort()
|
||||||
|
}
|
||||||
|
|
||||||
|
const exportToExcel = async () => {
|
||||||
|
if (pagination.value.total === 0) {
|
||||||
appStore.showWarning(t('usage.noDataToExport'))
|
appStore.showWarning(t('usage.noDataToExport'))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const headers = [
|
if (exporting.value) {
|
||||||
'User',
|
return
|
||||||
'API Key',
|
}
|
||||||
'Model',
|
|
||||||
'Type',
|
|
||||||
'Input Tokens',
|
|
||||||
'Output Tokens',
|
|
||||||
'Cache Read Tokens',
|
|
||||||
'Cache Write Tokens',
|
|
||||||
'Total Cost',
|
|
||||||
'Billing Type',
|
|
||||||
'Duration (ms)',
|
|
||||||
'Time'
|
|
||||||
]
|
|
||||||
const rows = usageLogs.value.map((log) => [
|
|
||||||
log.user?.email || '',
|
|
||||||
log.api_key?.name || '',
|
|
||||||
log.model,
|
|
||||||
log.stream ? 'Stream' : 'Sync',
|
|
||||||
log.input_tokens,
|
|
||||||
log.output_tokens,
|
|
||||||
log.cache_read_tokens,
|
|
||||||
log.cache_creation_tokens,
|
|
||||||
log.total_cost.toFixed(6),
|
|
||||||
log.billing_type === 1 ? 'Subscription' : 'Balance',
|
|
||||||
log.duration_ms,
|
|
||||||
log.created_at
|
|
||||||
])
|
|
||||||
|
|
||||||
const csvContent = [headers.join(','), ...rows.map((row) => row.join(','))].join('\n')
|
exporting.value = true
|
||||||
|
exportProgress.show = true
|
||||||
|
exportProgress.progress = 0
|
||||||
|
exportProgress.current = 0
|
||||||
|
exportProgress.total = pagination.value.total
|
||||||
|
exportProgress.estimatedTime = ''
|
||||||
|
|
||||||
const blob = new Blob([csvContent], { type: 'text/csv' })
|
const startedAt = Date.now()
|
||||||
const url = window.URL.createObjectURL(blob)
|
const controller = new AbortController()
|
||||||
const link = document.createElement('a')
|
exportAbortController = controller
|
||||||
link.href = url
|
|
||||||
link.download = `admin_usage_${new Date().toISOString().split('T')[0]}.csv`
|
|
||||||
link.click()
|
|
||||||
window.URL.revokeObjectURL(url)
|
|
||||||
|
|
||||||
appStore.showSuccess(t('usage.exportSuccess'))
|
try {
|
||||||
|
const allLogs: UsageLog[] = []
|
||||||
|
const pageSize = 100
|
||||||
|
let page = 1
|
||||||
|
let total = pagination.value.total
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const params: AdminUsageQueryParams = {
|
||||||
|
page,
|
||||||
|
page_size: pageSize,
|
||||||
|
...filters.value
|
||||||
|
}
|
||||||
|
const response = await adminUsageAPI.list(params, { signal: controller.signal })
|
||||||
|
if (controller.signal.aborted) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if (page === 1) {
|
||||||
|
total = response.total
|
||||||
|
exportProgress.total = total
|
||||||
|
}
|
||||||
|
if (response.items?.length) {
|
||||||
|
allLogs.push(...response.items)
|
||||||
|
}
|
||||||
|
|
||||||
|
updateExportProgress(allLogs.length, total, startedAt)
|
||||||
|
|
||||||
|
if (allLogs.length >= total || response.items.length < pageSize) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
page += 1
|
||||||
|
}
|
||||||
|
|
||||||
|
if (controller.signal.aborted) {
|
||||||
|
appStore.showInfo(t('usage.exportCancelled'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (allLogs.length === 0) {
|
||||||
|
appStore.showWarning(t('usage.noDataToExport'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const headers = [
|
||||||
|
'User',
|
||||||
|
'API Key',
|
||||||
|
'Model',
|
||||||
|
'Type',
|
||||||
|
'Input Tokens',
|
||||||
|
'Output Tokens',
|
||||||
|
'Cache Read Tokens',
|
||||||
|
'Cache Write Tokens',
|
||||||
|
'Total Cost',
|
||||||
|
'Billing Type',
|
||||||
|
'Duration (ms)',
|
||||||
|
'Time'
|
||||||
|
]
|
||||||
|
const rows = allLogs.map((log) => [
|
||||||
|
log.user?.email || '',
|
||||||
|
log.api_key?.name || '',
|
||||||
|
log.model,
|
||||||
|
log.stream ? 'Stream' : 'Sync',
|
||||||
|
log.input_tokens,
|
||||||
|
log.output_tokens,
|
||||||
|
log.cache_read_tokens,
|
||||||
|
log.cache_creation_tokens,
|
||||||
|
Number(log.total_cost.toFixed(6)),
|
||||||
|
log.billing_type === 1 ? 'Subscription' : 'Balance',
|
||||||
|
log.duration_ms,
|
||||||
|
log.created_at
|
||||||
|
])
|
||||||
|
|
||||||
|
const worksheet = XLSX.utils.aoa_to_sheet([headers, ...rows])
|
||||||
|
const workbook = XLSX.utils.book_new()
|
||||||
|
XLSX.utils.book_append_sheet(workbook, worksheet, 'Usage')
|
||||||
|
const excelBuffer = XLSX.write(workbook, { bookType: 'xlsx', type: 'array' })
|
||||||
|
const blob = new Blob([excelBuffer], {
|
||||||
|
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
|
||||||
|
})
|
||||||
|
|
||||||
|
saveAs(blob, `admin_usage_${formatExportTimestamp(new Date())}.xlsx`)
|
||||||
|
appStore.showSuccess(t('usage.exportExcelSuccess'))
|
||||||
|
} catch (error) {
|
||||||
|
if (controller.signal.aborted || isAbortError(error)) {
|
||||||
|
appStore.showInfo(t('usage.exportCancelled'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
appStore.showError(t('usage.exportExcelFailed'))
|
||||||
|
console.error('Excel export failed:', error)
|
||||||
|
} finally {
|
||||||
|
if (exportAbortController === controller) {
|
||||||
|
exportAbortController = null
|
||||||
|
}
|
||||||
|
exporting.value = false
|
||||||
|
exportProgress.show = false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Click outside to close dropdown
|
// Click outside to close dropdown
|
||||||
@@ -1123,6 +1390,22 @@ const hideTooltip = () => {
|
|||||||
tooltipData.value = null
|
tooltipData.value = null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Token tooltip functions
|
||||||
|
const showTokenTooltip = (event: MouseEvent, row: UsageLog) => {
|
||||||
|
const target = event.currentTarget as HTMLElement
|
||||||
|
const rect = target.getBoundingClientRect()
|
||||||
|
|
||||||
|
tokenTooltipData.value = row
|
||||||
|
tokenTooltipPosition.value.x = rect.right + 8
|
||||||
|
tokenTooltipPosition.value.y = rect.top + rect.height / 2
|
||||||
|
tokenTooltipVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const hideTokenTooltip = () => {
|
||||||
|
tokenTooltipVisible.value = false
|
||||||
|
tokenTooltipData.value = null
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
loadFilterOptions()
|
loadFilterOptions()
|
||||||
loadApiKeys()
|
loadApiKeys()
|
||||||
@@ -1140,5 +1423,8 @@ onUnmounted(() => {
|
|||||||
if (abortController) {
|
if (abortController) {
|
||||||
abortController.abort()
|
abortController.abort()
|
||||||
}
|
}
|
||||||
|
if (exportAbortController) {
|
||||||
|
exportAbortController.abort()
|
||||||
|
}
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -23,7 +23,7 @@
|
|||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
<button @click="showCreateModal = true" class="btn btn-primary">
|
<button @click="showCreateModal = true" class="btn btn-primary" data-tour="keys-create-btn">
|
||||||
<svg
|
<svg
|
||||||
class="mr-2 h-5 w-5"
|
class="mr-2 h-5 w-5"
|
||||||
fill="none"
|
fill="none"
|
||||||
@@ -301,7 +301,7 @@
|
|||||||
<BaseDialog
|
<BaseDialog
|
||||||
:show="showCreateModal || showEditModal"
|
:show="showCreateModal || showEditModal"
|
||||||
:title="showEditModal ? t('keys.editKey') : t('keys.createKey')"
|
:title="showEditModal ? t('keys.editKey') : t('keys.createKey')"
|
||||||
width="narrow"
|
width="normal"
|
||||||
@close="closeModals"
|
@close="closeModals"
|
||||||
>
|
>
|
||||||
<form id="key-form" @submit.prevent="handleSubmit" class="space-y-5">
|
<form id="key-form" @submit.prevent="handleSubmit" class="space-y-5">
|
||||||
@@ -313,6 +313,7 @@
|
|||||||
required
|
required
|
||||||
class="input"
|
class="input"
|
||||||
:placeholder="t('keys.namePlaceholder')"
|
:placeholder="t('keys.namePlaceholder')"
|
||||||
|
data-tour="key-form-name"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -322,6 +323,7 @@
|
|||||||
v-model="formData.group_id"
|
v-model="formData.group_id"
|
||||||
:options="groupOptions"
|
:options="groupOptions"
|
||||||
:placeholder="t('keys.selectGroup')"
|
:placeholder="t('keys.selectGroup')"
|
||||||
|
data-tour="key-form-group"
|
||||||
>
|
>
|
||||||
<template #selected="{ option }">
|
<template #selected="{ option }">
|
||||||
<GroupBadge
|
<GroupBadge
|
||||||
@@ -391,7 +393,13 @@
|
|||||||
<button @click="closeModals" type="button" class="btn btn-secondary">
|
<button @click="closeModals" type="button" class="btn btn-secondary">
|
||||||
{{ t('common.cancel') }}
|
{{ t('common.cancel') }}
|
||||||
</button>
|
</button>
|
||||||
<button form="key-form" type="submit" :disabled="submitting" class="btn btn-primary">
|
<button
|
||||||
|
form="key-form"
|
||||||
|
type="submit"
|
||||||
|
:disabled="submitting"
|
||||||
|
class="btn btn-primary"
|
||||||
|
data-tour="key-form-submit"
|
||||||
|
>
|
||||||
<svg
|
<svg
|
||||||
v-if="submitting"
|
v-if="submitting"
|
||||||
class="-ml-1 mr-2 h-4 w-4 animate-spin"
|
class="-ml-1 mr-2 h-4 w-4 animate-spin"
|
||||||
@@ -496,6 +504,7 @@
|
|||||||
import { ref, computed, onMounted, onUnmounted, type ComponentPublicInstance } from 'vue'
|
import { ref, computed, onMounted, onUnmounted, type ComponentPublicInstance } from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { useAppStore } from '@/stores/app'
|
import { useAppStore } from '@/stores/app'
|
||||||
|
import { useOnboardingStore } from '@/stores/onboarding'
|
||||||
import { useClipboard } from '@/composables/useClipboard'
|
import { useClipboard } from '@/composables/useClipboard'
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
@@ -524,6 +533,7 @@ interface GroupOption {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const appStore = useAppStore()
|
const appStore = useAppStore()
|
||||||
|
const onboardingStore = useOnboardingStore()
|
||||||
const { copyToClipboard: clipboardCopy } = useClipboard()
|
const { copyToClipboard: clipboardCopy } = useClipboard()
|
||||||
|
|
||||||
const columns = computed<Column[]>(() => [
|
const columns = computed<Column[]>(() => [
|
||||||
@@ -812,12 +822,17 @@ const handleSubmit = async () => {
|
|||||||
const customKey = formData.value.use_custom_key ? formData.value.custom_key : undefined
|
const customKey = formData.value.use_custom_key ? formData.value.custom_key : undefined
|
||||||
await keysAPI.create(formData.value.name, formData.value.group_id, customKey)
|
await keysAPI.create(formData.value.name, formData.value.group_id, customKey)
|
||||||
appStore.showSuccess(t('keys.keyCreatedSuccess'))
|
appStore.showSuccess(t('keys.keyCreatedSuccess'))
|
||||||
|
// Only advance tour if active, on submit step, and creation succeeded
|
||||||
|
if (onboardingStore.isCurrentStep('[data-tour="key-form-submit"]')) {
|
||||||
|
onboardingStore.nextStep(500)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
closeModals()
|
closeModals()
|
||||||
loadApiKeys()
|
loadApiKeys()
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
const errorMsg = error.response?.data?.detail || t('keys.failedToSave')
|
const errorMsg = error.response?.data?.detail || t('keys.failedToSave')
|
||||||
appStore.showError(errorMsg)
|
appStore.showError(errorMsg)
|
||||||
|
// Don't advance tour on error
|
||||||
} finally {
|
} finally {
|
||||||
submitting.value = false
|
submitting.value = false
|
||||||
}
|
}
|
||||||
@@ -878,7 +893,20 @@ const importToCcswitch = (apiKey: string) => {
|
|||||||
usageAutoInterval: '30'
|
usageAutoInterval: '30'
|
||||||
})
|
})
|
||||||
const deeplink = `ccswitch://v1/import?${params.toString()}`
|
const deeplink = `ccswitch://v1/import?${params.toString()}`
|
||||||
window.open(deeplink, '_self')
|
|
||||||
|
try {
|
||||||
|
window.open(deeplink, '_self')
|
||||||
|
|
||||||
|
// Check if the protocol handler worked by detecting if we're still focused
|
||||||
|
setTimeout(() => {
|
||||||
|
if (document.hasFocus()) {
|
||||||
|
// Still focused means the protocol handler likely failed
|
||||||
|
appStore.showError(t('keys.ccSwitchNotInstalled'))
|
||||||
|
}
|
||||||
|
}, 100)
|
||||||
|
} catch (error) {
|
||||||
|
appStore.showError(t('keys.ccSwitchNotInstalled'))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
|
|||||||
@@ -219,90 +219,114 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template #cell-tokens="{ row }">
|
<template #cell-tokens="{ row }">
|
||||||
<div class="space-y-1.5 text-sm">
|
<div class="flex items-center gap-1.5">
|
||||||
<!-- Input / Output Tokens -->
|
<div class="space-y-1.5 text-sm">
|
||||||
<div class="flex items-center gap-2">
|
<!-- Input / Output Tokens -->
|
||||||
<!-- Input -->
|
<div class="flex items-center gap-2">
|
||||||
<div class="inline-flex items-center gap-1">
|
<!-- Input -->
|
||||||
<svg
|
<div class="inline-flex items-center gap-1">
|
||||||
class="h-3.5 w-3.5 text-emerald-500"
|
<svg
|
||||||
fill="none"
|
class="h-3.5 w-3.5 text-emerald-500"
|
||||||
stroke="currentColor"
|
fill="none"
|
||||||
viewBox="0 0 24 24"
|
stroke="currentColor"
|
||||||
>
|
viewBox="0 0 24 24"
|
||||||
<path
|
>
|
||||||
stroke-linecap="round"
|
<path
|
||||||
stroke-linejoin="round"
|
stroke-linecap="round"
|
||||||
stroke-width="2"
|
stroke-linejoin="round"
|
||||||
d="M19 14l-7 7m0 0l-7-7m7 7V3"
|
stroke-width="2"
|
||||||
/>
|
d="M19 14l-7 7m0 0l-7-7m7 7V3"
|
||||||
</svg>
|
/>
|
||||||
<span class="font-medium text-gray-900 dark:text-white">{{
|
</svg>
|
||||||
row.input_tokens.toLocaleString()
|
<span class="font-medium text-gray-900 dark:text-white">{{
|
||||||
}}</span>
|
row.input_tokens.toLocaleString()
|
||||||
|
}}</span>
|
||||||
|
</div>
|
||||||
|
<!-- Output -->
|
||||||
|
<div class="inline-flex items-center gap-1">
|
||||||
|
<svg
|
||||||
|
class="h-3.5 w-3.5 text-violet-500"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M5 10l7-7m0 0l7 7m-7-7v18"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<span class="font-medium text-gray-900 dark:text-white">{{
|
||||||
|
row.output_tokens.toLocaleString()
|
||||||
|
}}</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- Output -->
|
<!-- Cache Tokens (Read + Write) -->
|
||||||
<div class="inline-flex items-center gap-1">
|
<div
|
||||||
<svg
|
v-if="row.cache_read_tokens > 0 || row.cache_creation_tokens > 0"
|
||||||
class="h-3.5 w-3.5 text-violet-500"
|
class="flex items-center gap-2"
|
||||||
fill="none"
|
>
|
||||||
stroke="currentColor"
|
<!-- Cache Read -->
|
||||||
viewBox="0 0 24 24"
|
<div v-if="row.cache_read_tokens > 0" class="inline-flex items-center gap-1">
|
||||||
>
|
<svg
|
||||||
<path
|
class="h-3.5 w-3.5 text-sky-500"
|
||||||
stroke-linecap="round"
|
fill="none"
|
||||||
stroke-linejoin="round"
|
stroke="currentColor"
|
||||||
stroke-width="2"
|
viewBox="0 0 24 24"
|
||||||
d="M5 10l7-7m0 0l7 7m-7-7v18"
|
>
|
||||||
/>
|
<path
|
||||||
</svg>
|
stroke-linecap="round"
|
||||||
<span class="font-medium text-gray-900 dark:text-white">{{
|
stroke-linejoin="round"
|
||||||
row.output_tokens.toLocaleString()
|
stroke-width="2"
|
||||||
}}</span>
|
d="M5 8h14M5 8a2 2 0 110-4h14a2 2 0 110 4M5 8v10a2 2 0 002 2h10a2 2 0 002-2V8m-9 4h4"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<span class="font-medium text-sky-600 dark:text-sky-400">{{
|
||||||
|
formatCacheTokens(row.cache_read_tokens)
|
||||||
|
}}</span>
|
||||||
|
</div>
|
||||||
|
<!-- Cache Write -->
|
||||||
|
<div v-if="row.cache_creation_tokens > 0" class="inline-flex items-center gap-1">
|
||||||
|
<svg
|
||||||
|
class="h-3.5 w-3.5 text-amber-500"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<span class="font-medium text-amber-600 dark:text-amber-400">{{
|
||||||
|
formatCacheTokens(row.cache_creation_tokens)
|
||||||
|
}}</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- Cache Tokens (Read + Write) -->
|
<!-- Token Detail Tooltip -->
|
||||||
<div
|
<div
|
||||||
v-if="row.cache_read_tokens > 0 || row.cache_creation_tokens > 0"
|
class="group relative"
|
||||||
class="flex items-center gap-2"
|
@mouseenter="showTokenTooltip($event, row)"
|
||||||
|
@mouseleave="hideTokenTooltip"
|
||||||
>
|
>
|
||||||
<!-- Cache Read -->
|
<div
|
||||||
<div v-if="row.cache_read_tokens > 0" class="inline-flex items-center gap-1">
|
class="flex h-4 w-4 cursor-help items-center justify-center rounded-full bg-gray-100 transition-colors group-hover:bg-blue-100 dark:bg-gray-700 dark:group-hover:bg-blue-900/50"
|
||||||
|
>
|
||||||
<svg
|
<svg
|
||||||
class="h-3.5 w-3.5 text-sky-500"
|
class="h-3 w-3 text-gray-400 group-hover:text-blue-500 dark:text-gray-500 dark:group-hover:text-blue-400"
|
||||||
fill="none"
|
fill="currentColor"
|
||||||
stroke="currentColor"
|
viewBox="0 0 20 20"
|
||||||
viewBox="0 0 24 24"
|
|
||||||
>
|
>
|
||||||
<path
|
<path
|
||||||
stroke-linecap="round"
|
fill-rule="evenodd"
|
||||||
stroke-linejoin="round"
|
d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z"
|
||||||
stroke-width="2"
|
clip-rule="evenodd"
|
||||||
d="M5 8h14M5 8a2 2 0 110-4h14a2 2 0 110 4M5 8v10a2 2 0 002 2h10a2 2 0 002-2V8m-9 4h4"
|
|
||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
<span class="font-medium text-sky-600 dark:text-sky-400">{{
|
|
||||||
formatCacheTokens(row.cache_read_tokens)
|
|
||||||
}}</span>
|
|
||||||
</div>
|
|
||||||
<!-- Cache Write -->
|
|
||||||
<div v-if="row.cache_creation_tokens > 0" class="inline-flex items-center gap-1">
|
|
||||||
<svg
|
|
||||||
class="h-3.5 w-3.5 text-amber-500"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
stroke-width="2"
|
|
||||||
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
<span class="font-medium text-amber-600 dark:text-amber-400">{{
|
|
||||||
formatCacheTokens(row.cache_creation_tokens)
|
|
||||||
}}</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -392,6 +416,54 @@
|
|||||||
</TablePageLayout>
|
</TablePageLayout>
|
||||||
</AppLayout>
|
</AppLayout>
|
||||||
|
|
||||||
|
<!-- Token Tooltip Portal -->
|
||||||
|
<Teleport to="body">
|
||||||
|
<div
|
||||||
|
v-if="tokenTooltipVisible"
|
||||||
|
class="fixed z-[9999] pointer-events-none -translate-y-1/2"
|
||||||
|
:style="{
|
||||||
|
left: tokenTooltipPosition.x + 'px',
|
||||||
|
top: tokenTooltipPosition.y + 'px'
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="whitespace-nowrap rounded-lg border border-gray-700 bg-gray-900 px-3 py-2.5 text-xs text-white shadow-xl dark:border-gray-600 dark:bg-gray-800"
|
||||||
|
>
|
||||||
|
<div class="space-y-1.5">
|
||||||
|
<!-- Token Breakdown -->
|
||||||
|
<div class="mb-2 border-b border-gray-700 pb-1.5">
|
||||||
|
<div class="text-xs font-semibold text-gray-300 mb-1">Token 明细</div>
|
||||||
|
<div v-if="tokenTooltipData && tokenTooltipData.input_tokens > 0" class="flex items-center justify-between gap-4">
|
||||||
|
<span class="text-gray-400">{{ t('admin.usage.inputTokens') }}</span>
|
||||||
|
<span class="font-medium text-white">{{ tokenTooltipData.input_tokens.toLocaleString() }}</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="tokenTooltipData && tokenTooltipData.output_tokens > 0" class="flex items-center justify-between gap-4">
|
||||||
|
<span class="text-gray-400">{{ t('admin.usage.outputTokens') }}</span>
|
||||||
|
<span class="font-medium text-white">{{ tokenTooltipData.output_tokens.toLocaleString() }}</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="tokenTooltipData && tokenTooltipData.cache_creation_tokens > 0" class="flex items-center justify-between gap-4">
|
||||||
|
<span class="text-gray-400">{{ t('admin.usage.cacheCreationTokens') }}</span>
|
||||||
|
<span class="font-medium text-white">{{ tokenTooltipData.cache_creation_tokens.toLocaleString() }}</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="tokenTooltipData && tokenTooltipData.cache_read_tokens > 0" class="flex items-center justify-between gap-4">
|
||||||
|
<span class="text-gray-400">{{ t('admin.usage.cacheReadTokens') }}</span>
|
||||||
|
<span class="font-medium text-white">{{ tokenTooltipData.cache_read_tokens.toLocaleString() }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Total -->
|
||||||
|
<div class="flex items-center justify-between gap-6 border-t border-gray-700 pt-1.5">
|
||||||
|
<span class="text-gray-400">{{ t('usage.totalTokens') }}</span>
|
||||||
|
<span class="font-semibold text-blue-400">{{ ((tokenTooltipData?.input_tokens || 0) + (tokenTooltipData?.output_tokens || 0) + (tokenTooltipData?.cache_creation_tokens || 0) + (tokenTooltipData?.cache_read_tokens || 0)).toLocaleString() }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Tooltip Arrow (left side) -->
|
||||||
|
<div
|
||||||
|
class="absolute right-full top-1/2 h-0 w-0 -translate-y-1/2 border-b-[6px] border-r-[6px] border-t-[6px] border-b-transparent border-r-gray-900 border-t-transparent dark:border-r-gray-800"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Teleport>
|
||||||
|
|
||||||
<!-- Tooltip Portal -->
|
<!-- Tooltip Portal -->
|
||||||
<Teleport to="body">
|
<Teleport to="body">
|
||||||
<div
|
<div
|
||||||
@@ -458,6 +530,11 @@ const tooltipVisible = ref(false)
|
|||||||
const tooltipPosition = ref({ x: 0, y: 0 })
|
const tooltipPosition = ref({ x: 0, y: 0 })
|
||||||
const tooltipData = ref<UsageLog | null>(null)
|
const tooltipData = ref<UsageLog | null>(null)
|
||||||
|
|
||||||
|
// Token tooltip state
|
||||||
|
const tokenTooltipVisible = ref(false)
|
||||||
|
const tokenTooltipPosition = ref({ x: 0, y: 0 })
|
||||||
|
const tokenTooltipData = ref<UsageLog | null>(null)
|
||||||
|
|
||||||
// Usage stats from API
|
// Usage stats from API
|
||||||
const usageStats = ref<UsageStatsResponse | null>(null)
|
const usageStats = ref<UsageStatsResponse | null>(null)
|
||||||
|
|
||||||
@@ -778,6 +855,22 @@ const hideTooltip = () => {
|
|||||||
tooltipData.value = null
|
tooltipData.value = null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Token tooltip functions
|
||||||
|
const showTokenTooltip = (event: MouseEvent, row: UsageLog) => {
|
||||||
|
const target = event.currentTarget as HTMLElement
|
||||||
|
const rect = target.getBoundingClientRect()
|
||||||
|
|
||||||
|
tokenTooltipData.value = row
|
||||||
|
tokenTooltipPosition.value.x = rect.right + 8
|
||||||
|
tokenTooltipPosition.value.y = rect.top + rect.height / 2
|
||||||
|
tokenTooltipVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const hideTokenTooltip = () => {
|
||||||
|
tokenTooltipVisible.value = false
|
||||||
|
tokenTooltipData.value = null
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
loadApiKeys()
|
loadApiKeys()
|
||||||
loadUsageLogs()
|
loadUsageLogs()
|
||||||
|
|||||||
Reference in New Issue
Block a user