Merge branch 'Wei-Shaw:main' into main

This commit is contained in:
程序猿MT 2025-12-27 21:32:08 +08:00 committed by GitHub
commit f3da4b202e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 392 additions and 59 deletions

View File

@ -145,7 +145,7 @@ func (s *claudeOAuthService) GetAuthorizationCode(ctx context.Context, sessionKe
return fullCode, nil return fullCode, nil
} }
func (s *claudeOAuthService) ExchangeCodeForToken(ctx context.Context, code, codeVerifier, state, proxyURL string) (*oauth.TokenResponse, error) { func (s *claudeOAuthService) ExchangeCodeForToken(ctx context.Context, code, codeVerifier, state, proxyURL string, isSetupToken bool) (*oauth.TokenResponse, error) {
client := s.clientFactory(proxyURL) client := s.clientFactory(proxyURL)
// Parse code which may contain state in format "authCode#state" // Parse code which may contain state in format "authCode#state"
@ -168,6 +168,11 @@ func (s *claudeOAuthService) ExchangeCodeForToken(ctx context.Context, code, cod
reqBody["state"] = codeState reqBody["state"] = codeState
} }
// Setup token requires longer expiration (1 year)
if isSetupToken {
reqBody["expires_in"] = 31536000 // 365 * 24 * 60 * 60 seconds
}
reqBodyJSON, _ := json.Marshal(reqBody) reqBodyJSON, _ := json.Marshal(reqBody)
log.Printf("[OAuth] Step 3: Exchanging code for token at %s", s.tokenURL) log.Printf("[OAuth] Step 3: Exchanging code for token at %s", s.tokenURL)
log.Printf("[OAuth] Step 3 Request Body: %s", string(reqBodyJSON)) log.Printf("[OAuth] Step 3 Request Body: %s", string(reqBodyJSON))

View File

@ -191,12 +191,13 @@ func (s *ClaudeOAuthServiceSuite) TestGetAuthorizationCode() {
func (s *ClaudeOAuthServiceSuite) TestExchangeCodeForToken() { func (s *ClaudeOAuthServiceSuite) TestExchangeCodeForToken() {
tests := []struct { tests := []struct {
name string name string
handler http.HandlerFunc handler http.HandlerFunc
code string code string
wantErr bool isSetupToken bool
wantResp *oauth.TokenResponse wantErr bool
validate func(captured requestCapture) wantResp *oauth.TokenResponse
validate func(captured requestCapture)
}{ }{
{ {
name: "sends_state_when_embedded", name: "sends_state_when_embedded",
@ -210,7 +211,8 @@ func (s *ClaudeOAuthServiceSuite) TestExchangeCodeForToken() {
Scope: "s", Scope: "s",
}) })
}, },
code: "AUTH#STATE2", code: "AUTH#STATE2",
isSetupToken: false,
wantResp: &oauth.TokenResponse{ wantResp: &oauth.TokenResponse{
AccessToken: "at", AccessToken: "at",
RefreshToken: "rt", RefreshToken: "rt",
@ -223,6 +225,29 @@ func (s *ClaudeOAuthServiceSuite) TestExchangeCodeForToken() {
require.Equal(s.T(), oauth.ClientID, captured.bodyJSON["client_id"]) require.Equal(s.T(), oauth.ClientID, captured.bodyJSON["client_id"])
require.Equal(s.T(), oauth.RedirectURI, captured.bodyJSON["redirect_uri"]) require.Equal(s.T(), oauth.RedirectURI, captured.bodyJSON["redirect_uri"])
require.Equal(s.T(), "ver", captured.bodyJSON["code_verifier"]) require.Equal(s.T(), "ver", captured.bodyJSON["code_verifier"])
// Regular OAuth should not include expires_in
require.Nil(s.T(), captured.bodyJSON["expires_in"], "regular OAuth should not include expires_in")
},
},
{
name: "setup_token_includes_expires_in",
handler: func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(oauth.TokenResponse{
AccessToken: "at",
TokenType: "bearer",
ExpiresIn: 31536000,
})
},
code: "AUTH",
isSetupToken: true,
wantResp: &oauth.TokenResponse{
AccessToken: "at",
},
validate: func(captured requestCapture) {
// Setup token should include expires_in with 1 year value
require.Equal(s.T(), float64(31536000), captured.bodyJSON["expires_in"],
"setup token should include expires_in: 31536000")
}, },
}, },
{ {
@ -231,8 +256,9 @@ func (s *ClaudeOAuthServiceSuite) TestExchangeCodeForToken() {
w.WriteHeader(http.StatusBadRequest) w.WriteHeader(http.StatusBadRequest)
_, _ = w.Write([]byte("bad request")) _, _ = w.Write([]byte("bad request"))
}, },
code: "AUTH", code: "AUTH",
wantErr: true, isSetupToken: false,
wantErr: true,
}, },
} }
@ -254,7 +280,7 @@ func (s *ClaudeOAuthServiceSuite) TestExchangeCodeForToken() {
s.client = client s.client = client
s.client.tokenURL = s.srv.URL s.client.tokenURL = s.srv.URL
resp, err := s.client.ExchangeCodeForToken(context.Background(), tt.code, "ver", "", "") resp, err := s.client.ExchangeCodeForToken(context.Background(), tt.code, "ver", "", "", tt.isSetupToken)
if tt.wantErr { if tt.wantErr {
require.Error(s.T(), err) require.Error(s.T(), err)

View File

@ -20,7 +20,7 @@ type OpenAIOAuthClient interface {
type ClaudeOAuthClient interface { type ClaudeOAuthClient interface {
GetOrganizationUUID(ctx context.Context, sessionKey, proxyURL string) (string, error) GetOrganizationUUID(ctx context.Context, sessionKey, proxyURL string) (string, error)
GetAuthorizationCode(ctx context.Context, sessionKey, orgUUID, scope, codeChallenge, state, proxyURL string) (string, error) GetAuthorizationCode(ctx context.Context, sessionKey, orgUUID, scope, codeChallenge, state, proxyURL string) (string, error)
ExchangeCodeForToken(ctx context.Context, code, codeVerifier, state, proxyURL string) (*oauth.TokenResponse, error) ExchangeCodeForToken(ctx context.Context, code, codeVerifier, state, proxyURL string, isSetupToken bool) (*oauth.TokenResponse, error)
RefreshToken(ctx context.Context, refreshToken, proxyURL string) (*oauth.TokenResponse, error) RefreshToken(ctx context.Context, refreshToken, proxyURL string) (*oauth.TokenResponse, error)
} }
@ -142,8 +142,11 @@ func (s *OAuthService) ExchangeCode(ctx context.Context, input *ExchangeCodeInpu
} }
} }
// Determine if this is a setup token (scope is inference only)
isSetupToken := session.Scope == oauth.ScopeInference
// Exchange code for token // Exchange code for token
tokenInfo, err := s.exchangeCodeForToken(ctx, input.Code, session.CodeVerifier, session.State, proxyURL) tokenInfo, err := s.exchangeCodeForToken(ctx, input.Code, session.CodeVerifier, session.State, proxyURL, isSetupToken)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -172,10 +175,12 @@ func (s *OAuthService) CookieAuth(ctx context.Context, input *CookieAuthInput) (
} }
} }
// Determine scope // Determine scope and if this is a setup token
scope := fmt.Sprintf("%s %s", oauth.ScopeProfile, oauth.ScopeInference) scope := fmt.Sprintf("%s %s", oauth.ScopeProfile, oauth.ScopeInference)
isSetupToken := false
if input.Scope == "inference" { if input.Scope == "inference" {
scope = oauth.ScopeInference scope = oauth.ScopeInference
isSetupToken = true
} }
// Step 1: Get organization info using sessionKey // Step 1: Get organization info using sessionKey
@ -203,7 +208,7 @@ func (s *OAuthService) CookieAuth(ctx context.Context, input *CookieAuthInput) (
} }
// Step 4: Exchange code for token // Step 4: Exchange code for token
tokenInfo, err := s.exchangeCodeForToken(ctx, authCode, codeVerifier, state, proxyURL) tokenInfo, err := s.exchangeCodeForToken(ctx, authCode, codeVerifier, state, proxyURL, isSetupToken)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to exchange code: %w", err) return nil, fmt.Errorf("failed to exchange code: %w", err)
} }
@ -228,8 +233,8 @@ func (s *OAuthService) getAuthorizationCode(ctx context.Context, sessionKey, org
} }
// exchangeCodeForToken exchanges authorization code for tokens // exchangeCodeForToken exchanges authorization code for tokens
func (s *OAuthService) exchangeCodeForToken(ctx context.Context, code, codeVerifier, state, proxyURL string) (*TokenInfo, error) { func (s *OAuthService) exchangeCodeForToken(ctx context.Context, code, codeVerifier, state, proxyURL string, isSetupToken bool) (*TokenInfo, error) {
tokenResp, err := s.oauthClient.ExchangeCodeForToken(ctx, code, codeVerifier, state, proxyURL) tokenResp, err := s.oauthClient.ExchangeCodeForToken(ctx, code, codeVerifier, state, proxyURL, isSetupToken)
if err != nil { if err != nil {
return nil, err return nil, err
} }

View File

@ -43,18 +43,23 @@ func (r *ClaudeTokenRefresher) CanRefresh(account *Account) bool {
// NeedsRefresh 检查token是否需要刷新 // NeedsRefresh 检查token是否需要刷新
// 基于 expires_at 字段判断是否在刷新窗口内 // 基于 expires_at 字段判断是否在刷新窗口内
func (r *ClaudeTokenRefresher) NeedsRefresh(account *Account, refreshWindow time.Duration) bool { func (r *ClaudeTokenRefresher) NeedsRefresh(account *Account, refreshWindow time.Duration) bool {
expiresAtStr := account.GetCredential("expires_at") var expiresAt int64
if expiresAtStr == "" {
// 方式1: 通过 GetCredential 获取(处理字符串和部分数字类型)
if s := account.GetCredential("expires_at"); s != "" {
v, err := strconv.ParseInt(s, 10, 64)
if err != nil {
return false
}
expiresAt = v
} else if v, ok := account.Credentials["expires_at"].(float64); ok {
// 方式2: 直接获取 float64处理某些 JSON 解码器将数字解析为 float64 的情况)
expiresAt = int64(v)
} else {
return false return false
} }
expiresAt, err := strconv.ParseInt(expiresAtStr, 10, 64) return time.Until(time.Unix(expiresAt, 0)) < refreshWindow
if err != nil {
return false
}
expiryTime := time.Unix(expiresAt, 0)
return time.Until(expiryTime) < refreshWindow
} }
// Refresh 执行token刷新 // Refresh 执行token刷新

View File

@ -0,0 +1,214 @@
//go:build unit
package service
import (
"strconv"
"testing"
"time"
"github.com/stretchr/testify/require"
)
func TestClaudeTokenRefresher_NeedsRefresh(t *testing.T) {
refresher := &ClaudeTokenRefresher{}
refreshWindow := 30 * time.Minute
tests := []struct {
name string
credentials map[string]any
wantRefresh bool
}{
{
name: "expires_at as string - expired",
credentials: map[string]any{
"expires_at": "1000", // 1970-01-01 00:16:40 UTC, 已过期
},
wantRefresh: true,
},
{
name: "expires_at as float64 - expired",
credentials: map[string]any{
"expires_at": float64(1000), // 数字类型,已过期
},
wantRefresh: true,
},
{
name: "expires_at as string - far future",
credentials: map[string]any{
"expires_at": "9999999999", // 远未来
},
wantRefresh: false,
},
{
name: "expires_at as float64 - far future",
credentials: map[string]any{
"expires_at": float64(9999999999), // 远未来,数字类型
},
wantRefresh: false,
},
{
name: "expires_at missing",
credentials: map[string]any{},
wantRefresh: false,
},
{
name: "expires_at is nil",
credentials: map[string]any{
"expires_at": nil,
},
wantRefresh: false,
},
{
name: "expires_at is invalid string",
credentials: map[string]any{
"expires_at": "invalid",
},
wantRefresh: false,
},
{
name: "credentials is nil",
credentials: nil,
wantRefresh: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
account := &Account{
Platform: PlatformAnthropic,
Type: AccountTypeOAuth,
Credentials: tt.credentials,
}
got := refresher.NeedsRefresh(account, refreshWindow)
require.Equal(t, tt.wantRefresh, got)
})
}
}
func TestClaudeTokenRefresher_NeedsRefresh_WithinWindow(t *testing.T) {
refresher := &ClaudeTokenRefresher{}
refreshWindow := 30 * time.Minute
// 设置一个在刷新窗口内的时间(当前时间 + 15分钟
expiresAt := time.Now().Add(15 * time.Minute).Unix()
tests := []struct {
name string
credentials map[string]any
}{
{
name: "string type - within refresh window",
credentials: map[string]any{
"expires_at": strconv.FormatInt(expiresAt, 10),
},
},
{
name: "float64 type - within refresh window",
credentials: map[string]any{
"expires_at": float64(expiresAt),
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
account := &Account{
Platform: PlatformAnthropic,
Type: AccountTypeOAuth,
Credentials: tt.credentials,
}
got := refresher.NeedsRefresh(account, refreshWindow)
require.True(t, got, "should need refresh when within window")
})
}
}
func TestClaudeTokenRefresher_NeedsRefresh_OutsideWindow(t *testing.T) {
refresher := &ClaudeTokenRefresher{}
refreshWindow := 30 * time.Minute
// 设置一个在刷新窗口外的时间(当前时间 + 1小时
expiresAt := time.Now().Add(1 * time.Hour).Unix()
tests := []struct {
name string
credentials map[string]any
}{
{
name: "string type - outside refresh window",
credentials: map[string]any{
"expires_at": strconv.FormatInt(expiresAt, 10),
},
},
{
name: "float64 type - outside refresh window",
credentials: map[string]any{
"expires_at": float64(expiresAt),
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
account := &Account{
Platform: PlatformAnthropic,
Type: AccountTypeOAuth,
Credentials: tt.credentials,
}
got := refresher.NeedsRefresh(account, refreshWindow)
require.False(t, got, "should not need refresh when outside window")
})
}
}
func TestClaudeTokenRefresher_CanRefresh(t *testing.T) {
refresher := &ClaudeTokenRefresher{}
tests := []struct {
name string
platform string
accType string
want bool
}{
{
name: "anthropic oauth - can refresh",
platform: PlatformAnthropic,
accType: AccountTypeOAuth,
want: true,
},
{
name: "anthropic api-key - cannot refresh",
platform: PlatformAnthropic,
accType: AccountTypeApiKey,
want: false,
},
{
name: "openai oauth - cannot refresh",
platform: PlatformOpenAI,
accType: AccountTypeOAuth,
want: false,
},
{
name: "gemini oauth - cannot refresh",
platform: PlatformGemini,
accType: AccountTypeOAuth,
want: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
account := &Account{
Platform: tt.platform,
Type: tt.accType,
}
got := refresher.CanRefresh(account)
require.Equal(t, tt.want, got)
})
}
}

View File

@ -281,6 +281,30 @@ To change after installation:
sudo systemctl restart sub2api sudo systemctl restart sub2api
``` ```
#### Gemini OAuth Configuration
If you need to use AI Studio OAuth for Gemini accounts, add the OAuth client credentials to the systemd service file:
1. Edit the service file:
```bash
sudo nano /etc/systemd/system/sub2api.service
```
2. Add your OAuth credentials in the `[Service]` section (after the existing `Environment=` lines):
```ini
Environment=GEMINI_OAUTH_CLIENT_ID=your-client-id.apps.googleusercontent.com
Environment=GEMINI_OAUTH_CLIENT_SECRET=GOCSPX-your-client-secret
```
3. Reload and restart:
```bash
sudo systemctl daemon-reload
sudo systemctl restart sub2api
```
> **Note:** Code Assist OAuth does not require any configuration - it uses the built-in Gemini CLI client.
> See the [Gemini OAuth Configuration](#gemini-oauth-configuration) section above for detailed setup instructions.
#### Application Configuration #### Application Configuration
The main config file is at `/etc/sub2api/config.yaml` (created by Setup Wizard). The main config file is at `/etc/sub2api/config.yaml` (created by Setup Wizard).

View File

@ -121,8 +121,8 @@ services:
timeout: 5s timeout: 5s
retries: 5 retries: 5
start_period: 10s start_period: 10s
ports: # 注意:不暴露端口到宿主机,应用通过内部网络连接
- 5433:5432 # 如需调试可临时添加ports: ["127.0.0.1:5433:5432"]
# =========================================================================== # ===========================================================================
# Redis Cache # Redis Cache

View File

@ -1,5 +1,5 @@
<template> <template>
<Modal :show="show" :title="t('admin.accounts.editAccount')" size="lg" @close="handleClose"> <Modal :show="show" :title="t('admin.accounts.editAccount')" size="xl" @close="handleClose">
<form v-if="account" @submit.prevent="handleSubmit" class="space-y-5"> <form v-if="account" @submit.prevent="handleSubmit" class="space-y-5">
<div> <div>
<label class="input-label">{{ t('common.name') }}</label> <label class="input-label">{{ t('common.name') }}</label>

View File

@ -15,7 +15,8 @@
:key="column.key" :key="column.key"
scope="col" scope="col"
:class="[ :class="[
'sticky-header-cell px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-dark-400', 'sticky-header-cell py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-dark-400',
getAdaptivePaddingClass(),
{ 'cursor-pointer hover:bg-gray-100 dark:hover:bg-dark-700': column.sortable }, { 'cursor-pointer hover:bg-gray-100 dark:hover:bg-dark-700': column.sortable },
getStickyColumnClass(column, index) getStickyColumnClass(column, index)
]" ]"
@ -81,7 +82,7 @@
<tbody class="table-body divide-y divide-gray-200 bg-white dark:divide-dark-700 dark:bg-dark-900"> <tbody class="table-body divide-y divide-gray-200 bg-white dark:divide-dark-700 dark:bg-dark-900">
<!-- Loading skeleton --> <!-- Loading skeleton -->
<tr v-if="loading" v-for="i in 5" :key="i"> <tr v-if="loading" v-for="i in 5" :key="i">
<td v-for="column in columns" :key="column.key" class="whitespace-nowrap px-6 py-4"> <td v-for="column in columns" :key="column.key" :class="['whitespace-nowrap py-4', getAdaptivePaddingClass()]">
<div class="animate-pulse"> <div class="animate-pulse">
<div class="h-4 w-3/4 rounded bg-gray-200 dark:bg-dark-700"></div> <div class="h-4 w-3/4 rounded bg-gray-200 dark:bg-dark-700"></div>
</div> </div>
@ -92,7 +93,7 @@
<tr v-else-if="!data || data.length === 0"> <tr v-else-if="!data || data.length === 0">
<td <td
:colspan="columns.length" :colspan="columns.length"
class="px-6 py-12 text-center text-gray-500 dark:text-dark-400" :class="['py-12 text-center text-gray-500 dark:text-dark-400', getAdaptivePaddingClass()]"
> >
<slot name="empty"> <slot name="empty">
<div class="flex flex-col items-center"> <div class="flex flex-col items-center">
@ -128,7 +129,8 @@
v-for="(column, colIndex) in columns" v-for="(column, colIndex) in columns"
:key="column.key" :key="column.key"
:class="[ :class="[
'whitespace-nowrap px-6 py-4 text-sm text-gray-900 dark:text-gray-100', 'whitespace-nowrap py-4 text-sm text-gray-900 dark:text-gray-100',
getAdaptivePaddingClass(),
getStickyColumnClass(column, colIndex) getStickyColumnClass(column, colIndex)
]" ]"
> >
@ -165,24 +167,46 @@ const checkScrollable = () => {
const checkActionsColumnWidth = () => { const checkActionsColumnWidth = () => {
if (!tableWrapperRef.value) return if (!tableWrapperRef.value) return
//
const actionsHeader = tableWrapperRef.value.querySelector('th:has(button[title*="Expand"], button[title*="展开"])')
if (!actionsHeader) return
// //
const firstActionCell = tableWrapperRef.value.querySelector('tbody tr:first-child td:last-child') const firstActionCell = tableWrapperRef.value.querySelector('tbody tr:first-child td:last-child')
if (!firstActionCell) return if (!firstActionCell) return
// // div
const actionsContent = firstActionCell.querySelector('div') const actionsContainer = firstActionCell.querySelector('div')
if (!actionsContent) return if (!actionsContainer) return
// //
const contentWidth = actionsContent.scrollWidth const wasExpanded = actionsExpanded.value
const cellWidth = (firstActionCell as HTMLElement).clientWidth actionsExpanded.value = true
// // DOM
actionsColumnNeedsExpanding.value = contentWidth > cellWidth nextTick(() => {
//
const buttons = actionsContainer.querySelectorAll('button')
if (buttons.length <= 2) {
actionsColumnNeedsExpanding.value = false
actionsExpanded.value = wasExpanded
return
}
// gap
let totalWidth = 0
buttons.forEach((btn, index) => {
totalWidth += (btn as HTMLElement).offsetWidth
if (index < buttons.length - 1) {
totalWidth += 4 // gap-1 = 4px
}
})
// padding
const cellWidth = (firstActionCell as HTMLElement).clientWidth - 32 // padding
//
actionsColumnNeedsExpanding.value = totalWidth > cellWidth
//
actionsExpanded.value = wasExpanded
})
} }
// //
@ -219,6 +243,7 @@ interface Props {
stickyFirstColumn?: boolean stickyFirstColumn?: boolean
stickyActionsColumn?: boolean stickyActionsColumn?: boolean
expandableActions?: boolean expandableActions?: boolean
actionsCount?: number //
} }
const props = withDefaults(defineProps<Props>(), { const props = withDefaults(defineProps<Props>(), {
@ -232,9 +257,10 @@ const sortKey = ref<string>('')
const sortOrder = ref<'asc' | 'desc'>('asc') const sortOrder = ref<'asc' | 'desc'>('asc')
const actionsExpanded = ref(false) const actionsExpanded = ref(false)
// // // /
// actionsExpanded checkActionsColumnWidth
watch( watch(
[() => props.data.length, () => props.columns, actionsExpanded], [() => props.data.length, () => props.columns],
async () => { async () => {
await nextTick() await nextTick()
checkScrollable() checkScrollable()
@ -243,6 +269,12 @@ watch(
{ flush: 'post' } { flush: 'post' }
) )
//
watch(actionsExpanded, async () => {
await nextTick()
checkScrollable()
})
const handleSort = (key: string) => { const handleSort = (key: string) => {
if (sortKey.value === key) { if (sortKey.value === key) {
sortOrder.value = sortOrder.value === 'asc' ? 'desc' : 'asc' sortOrder.value = sortOrder.value === 'asc' ? 'desc' : 'asc'
@ -268,6 +300,12 @@ const sortedData = computed(() => {
// //
const hasExpandableActions = computed(() => { const hasExpandableActions = computed(() => {
// actionsCount使
if (props.actionsCount !== undefined) {
return props.expandableActions && props.columns.some((col) => col.key === 'actions') && props.actionsCount > 2
}
// 使
return ( return (
props.expandableActions && props.expandableActions &&
props.columns.some((col) => col.key === 'actions') && props.columns.some((col) => col.key === 'actions') &&
@ -312,6 +350,22 @@ const getStickyColumnClass = (column: Column, index: number) => {
return classes.join(' ') return classes.join(' ')
} }
//
const getAdaptivePaddingClass = () => {
const columnCount = props.columns.length
//
if (columnCount >= 10) {
return 'px-2' // 8px
} else if (columnCount >= 7) {
return 'px-3' // 12px
} else if (columnCount >= 5) {
return 'px-4' // 16px
} else {
return 'px-6' // 24px ()
}
}
</script> </script>
<style scoped> <style scoped>

View File

@ -1172,9 +1172,9 @@ export default {
batchAdd: 'Quick Add', batchAdd: 'Quick Add',
batchInput: 'Proxy List', batchInput: 'Proxy List',
batchInputPlaceholder: batchInputPlaceholder:
"Enter one proxy per line in the following formats:\nsocks5://user:pass{'@'}192.168.1.1:1080\nhttp://192.168.1.1:8080\nhttps://user:pass{'@'}proxy.example.com:443", "Enter one proxy per line in the following formats:\nsocks5://user:pass@192.168.1.1:1080\nhttp://192.168.1.1:8080\nhttps://user:pass@proxy.example.com:443",
batchInputHint: batchInputHint:
"Supports http, https, socks5 protocols. Format: protocol://[user:pass{'@'}]host:port", "Supports http, https, socks5 protocols. Format: protocol://[user:pass@]host:port",
parsedCount: '{count} valid', parsedCount: '{count} valid',
invalidCount: '{count} invalid', invalidCount: '{count} invalid',
duplicateCount: '{count} duplicate', duplicateCount: '{count} duplicate',
@ -1351,12 +1351,12 @@ export default {
port: 'SMTP Port', port: 'SMTP Port',
portPlaceholder: '587', portPlaceholder: '587',
username: 'SMTP Username', username: 'SMTP Username',
usernamePlaceholder: 'your-email@gmail.com', usernamePlaceholder: "your-email{'@'}gmail.com",
password: 'SMTP Password', password: 'SMTP Password',
passwordPlaceholder: '********', passwordPlaceholder: '********',
passwordHint: 'Leave empty to keep existing password', passwordHint: 'Leave empty to keep existing password',
fromEmail: 'From Email', fromEmail: 'From Email',
fromEmailPlaceholder: 'noreply@example.com', fromEmailPlaceholder: "noreply{'@'}example.com",
fromName: 'From Name', fromName: 'From Name',
fromNamePlaceholder: 'Sub2API', fromNamePlaceholder: 'Sub2API',
useTls: 'Use TLS', useTls: 'Use TLS',
@ -1366,7 +1366,7 @@ export default {
title: 'Send Test Email', title: 'Send Test Email',
description: 'Send a test email to verify your SMTP configuration', description: 'Send a test email to verify your SMTP configuration',
recipientEmail: 'Recipient Email', recipientEmail: 'Recipient Email',
recipientEmailPlaceholder: 'test@example.com', recipientEmailPlaceholder: "test{'@'}example.com",
sendTestEmail: 'Send Test Email', sendTestEmail: 'Send Test Email',
sending: 'Sending...', sending: 'Sending...',
enterRecipientHint: 'Please enter a recipient email address' enterRecipientHint: 'Please enter a recipient email address'

View File

@ -1321,8 +1321,8 @@ export default {
batchAdd: '快捷添加', batchAdd: '快捷添加',
batchInput: '代理列表', batchInput: '代理列表',
batchInputPlaceholder: batchInputPlaceholder:
"每行输入一个代理,支持以下格式:\nsocks5://user:pass{'@'}192.168.1.1:1080\nhttp://192.168.1.1:8080\nhttps://user:pass{'@'}proxy.example.com:443", "每行输入一个代理,支持以下格式:\nsocks5://user:pass@192.168.1.1:1080\nhttp://192.168.1.1:8080\nhttps://user:pass@proxy.example.com:443",
batchInputHint: "支持 http、https、socks5 协议,格式:协议://[用户名:密码{'@'}]主机:端口", batchInputHint: "支持 http、https、socks5 协议,格式:协议://[用户名:密码@]主机:端口",
parsedCount: '有效 {count} 个', parsedCount: '有效 {count} 个',
invalidCount: '无效 {count} 个', invalidCount: '无效 {count} 个',
duplicateCount: '重复 {count} 个', duplicateCount: '重复 {count} 个',
@ -1549,12 +1549,12 @@ export default {
port: 'SMTP 端口', port: 'SMTP 端口',
portPlaceholder: '587', portPlaceholder: '587',
username: 'SMTP 用户名', username: 'SMTP 用户名',
usernamePlaceholder: 'your-email@gmail.com', usernamePlaceholder: "your-email{'@'}gmail.com",
password: 'SMTP 密码', password: 'SMTP 密码',
passwordPlaceholder: '********', passwordPlaceholder: '********',
passwordHint: '留空以保留现有密码', passwordHint: '留空以保留现有密码',
fromEmail: '发件人邮箱', fromEmail: '发件人邮箱',
fromEmailPlaceholder: 'noreply@example.com', fromEmailPlaceholder: "noreply{'@'}example.com",
fromName: '发件人名称', fromName: '发件人名称',
fromNamePlaceholder: 'Sub2API', fromNamePlaceholder: 'Sub2API',
useTls: '使用 TLS', useTls: '使用 TLS',
@ -1564,7 +1564,7 @@ export default {
title: '发送测试邮件', title: '发送测试邮件',
description: '发送测试邮件以验证 SMTP 配置', description: '发送测试邮件以验证 SMTP 配置',
recipientEmail: '收件人邮箱', recipientEmail: '收件人邮箱',
recipientEmailPlaceholder: 'test@example.com', recipientEmailPlaceholder: "test{'@'}example.com",
sendTestEmail: '发送测试邮件', sendTestEmail: '发送测试邮件',
sending: '发送中...', sending: '发送中...',
enterRecipientHint: '请输入收件人邮箱地址' enterRecipientHint: '请输入收件人邮箱地址'

View File

@ -165,7 +165,7 @@
</div> </div>
</div> </div>
<DataTable :columns="columns" :data="accounts" :loading="loading"> <DataTable :columns="columns" :data="accounts" :loading="loading" :actions-count="6">
<template #cell-select="{ row }"> <template #cell-select="{ row }">
<input <input
type="checkbox" type="checkbox"

View File

@ -85,7 +85,7 @@
<!-- Users Table --> <!-- Users Table -->
<template #table> <template #table>
<DataTable :columns="columns" :data="users" :loading="loading"> <DataTable :columns="columns" :data="users" :loading="loading" :actions-count="7">
<template #cell-email="{ value }"> <template #cell-email="{ value }">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<div <div