Merge branch 'Wei-Shaw:main' into main

This commit is contained in:
程序猿MT
2025-12-27 21:32:08 +08:00
committed by GitHub
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