feat: 优化 OAuth 账号导入流程
This commit is contained in:
parent
a466e80ed6
commit
fda1ed459d
1045
backend/internal/handler/admin/account_codex_import.go
Normal file
1045
backend/internal/handler/admin/account_codex_import.go
Normal file
File diff suppressed because it is too large
Load Diff
344
backend/internal/handler/admin/account_codex_import_test.go
Normal file
344
backend/internal/handler/admin/account_codex_import_test.go
Normal file
@ -0,0 +1,344 @@
|
|||||||
|
package admin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestParseCodexSessionImportEntriesSupportsRawTokenJSONAndArray(t *testing.T) {
|
||||||
|
token1 := "raw-access-token-1"
|
||||||
|
token2 := buildCodexImportTestJWT(t, time.Now().Add(time.Hour), map[string]any{
|
||||||
|
"email": "json@example.com",
|
||||||
|
})
|
||||||
|
token3 := "raw-access-token-3"
|
||||||
|
|
||||||
|
req := CodexSessionImportRequest{
|
||||||
|
Content: fmt.Sprintf("%s\n{\"accessToken\":%q}\n[%q]", token1, token2, token3),
|
||||||
|
}
|
||||||
|
|
||||||
|
entries, err := parseCodexSessionImportEntries(req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parseCodexSessionImportEntries error = %v", err)
|
||||||
|
}
|
||||||
|
if len(entries) != 3 {
|
||||||
|
t.Fatalf("len(entries) = %d, want 3", len(entries))
|
||||||
|
}
|
||||||
|
|
||||||
|
first, err := normalizeCodexImportEntry(entries[0])
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("normalize raw token error = %v", err)
|
||||||
|
}
|
||||||
|
if first.Credentials["access_token"] != token1 {
|
||||||
|
t.Fatalf("raw token access_token = %v, want %s", first.Credentials["access_token"], token1)
|
||||||
|
}
|
||||||
|
|
||||||
|
second, err := normalizeCodexImportEntry(entries[1])
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("normalize json token error = %v", err)
|
||||||
|
}
|
||||||
|
if second.Email != "json@example.com" {
|
||||||
|
t.Fatalf("email = %q, want json@example.com", second.Email)
|
||||||
|
}
|
||||||
|
|
||||||
|
third, err := normalizeCodexImportEntry(entries[2])
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("normalize array token error = %v", err)
|
||||||
|
}
|
||||||
|
if third.Credentials["access_token"] != token3 {
|
||||||
|
t.Fatalf("array token access_token = %v, want %s", third.Credentials["access_token"], token3)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseCodexSessionImportEntriesFallsBackToLineModeForMixedJSONAndToken(t *testing.T) {
|
||||||
|
req := CodexSessionImportRequest{
|
||||||
|
Content: "{\"accessToken\":\"json-line-token\"}\nraw-line-token",
|
||||||
|
}
|
||||||
|
|
||||||
|
entries, err := parseCodexSessionImportEntries(req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parseCodexSessionImportEntries error = %v", err)
|
||||||
|
}
|
||||||
|
if len(entries) != 2 {
|
||||||
|
t.Fatalf("len(entries) = %d, want 2", len(entries))
|
||||||
|
}
|
||||||
|
|
||||||
|
first, err := normalizeCodexImportEntry(entries[0])
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("normalize json line error = %v", err)
|
||||||
|
}
|
||||||
|
if first.Credentials["access_token"] != "json-line-token" {
|
||||||
|
t.Fatalf("json line access_token = %v, want json-line-token", first.Credentials["access_token"])
|
||||||
|
}
|
||||||
|
|
||||||
|
second, err := normalizeCodexImportEntry(entries[1])
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("normalize raw line error = %v", err)
|
||||||
|
}
|
||||||
|
if second.Credentials["access_token"] != "raw-line-token" {
|
||||||
|
t.Fatalf("raw line access_token = %v, want raw-line-token", second.Credentials["access_token"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNormalizeCodexSessionJSONExtractsCredentialsAndIgnoresSessionToken(t *testing.T) {
|
||||||
|
accessToken := buildCodexImportTestJWT(t, time.Now().Add(time.Hour), map[string]any{
|
||||||
|
"email": "claim@example.com",
|
||||||
|
"https://api.openai.com/auth": map[string]any{
|
||||||
|
"chatgpt_account_id": "acct-from-claim",
|
||||||
|
"chatgpt_user_id": "user-from-claim",
|
||||||
|
"chatgpt_plan_type": "plus",
|
||||||
|
"poid": "org-from-claim",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
raw := map[string]any{
|
||||||
|
"user": map[string]any{
|
||||||
|
"id": "user-from-json",
|
||||||
|
"name": "Sup OO",
|
||||||
|
"email": "json@example.com",
|
||||||
|
"image": "https://example.com/avatar.png",
|
||||||
|
},
|
||||||
|
"account": map[string]any{
|
||||||
|
"id": "acct-from-json",
|
||||||
|
"planType": "free",
|
||||||
|
},
|
||||||
|
"accessToken": accessToken,
|
||||||
|
"sessionToken": "secret-session-token",
|
||||||
|
"expires": "2026-08-05T13:40:42.836Z",
|
||||||
|
}
|
||||||
|
|
||||||
|
item, err := normalizeCodexImportEntry(codexImportEntry{Index: 1, Value: raw})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("normalizeCodexImportEntry error = %v", err)
|
||||||
|
}
|
||||||
|
if item.Credentials["access_token"] != accessToken {
|
||||||
|
t.Fatalf("access_token not stored")
|
||||||
|
}
|
||||||
|
if item.Credentials["email"] != "json@example.com" {
|
||||||
|
t.Fatalf("email = %v, want json@example.com", item.Credentials["email"])
|
||||||
|
}
|
||||||
|
if item.Credentials["chatgpt_account_id"] != "acct-from-json" {
|
||||||
|
t.Fatalf("chatgpt_account_id = %v, want acct-from-json", item.Credentials["chatgpt_account_id"])
|
||||||
|
}
|
||||||
|
if item.Credentials["chatgpt_user_id"] != "user-from-json" {
|
||||||
|
t.Fatalf("chatgpt_user_id = %v, want user-from-json", item.Credentials["chatgpt_user_id"])
|
||||||
|
}
|
||||||
|
if item.Credentials["plan_type"] != "free" {
|
||||||
|
t.Fatalf("plan_type = %v, want free", item.Credentials["plan_type"])
|
||||||
|
}
|
||||||
|
if _, ok := item.Credentials["session_token"]; ok {
|
||||||
|
t.Fatalf("session_token should not be written to credentials")
|
||||||
|
}
|
||||||
|
if item.Extra["session_token_present"] != true {
|
||||||
|
t.Fatalf("session_token_present = %v, want true", item.Extra["session_token_present"])
|
||||||
|
}
|
||||||
|
if item.Extra["session_expires_at"] != "2026-08-05T13:40:42Z" {
|
||||||
|
t.Fatalf("session_expires_at = %v", item.Extra["session_expires_at"])
|
||||||
|
}
|
||||||
|
if item.TokenExpiresAt == nil {
|
||||||
|
t.Fatalf("TokenExpiresAt should be parsed from accessToken")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMergeCodexImportCredentialsClearsStaleRefreshFieldsWhenIncomingHasNoRefreshToken(t *testing.T) {
|
||||||
|
existing := map[string]any{
|
||||||
|
"access_token": "old-access-token",
|
||||||
|
"refresh_token": "old-refresh-token",
|
||||||
|
"client_id": "old-client-id",
|
||||||
|
"id_token": "old-id-token",
|
||||||
|
"model_mapping": map[string]any{"from": "existing"},
|
||||||
|
"chatgpt_account_id": "acct-old",
|
||||||
|
"unrelated_existing": "keep",
|
||||||
|
}
|
||||||
|
incoming := map[string]any{
|
||||||
|
"access_token": "new-access-token",
|
||||||
|
"expires_at": "2026-08-05T13:40:42Z",
|
||||||
|
"chatgpt_account_id": "acct-new",
|
||||||
|
}
|
||||||
|
item := &codexImportAccount{
|
||||||
|
AccessToken: "new-access-token",
|
||||||
|
}
|
||||||
|
|
||||||
|
merged := mergeCodexImportCredentials(existing, incoming, item)
|
||||||
|
|
||||||
|
if merged["access_token"] != "new-access-token" {
|
||||||
|
t.Fatalf("access_token = %v, want new-access-token", merged["access_token"])
|
||||||
|
}
|
||||||
|
if merged["chatgpt_account_id"] != "acct-new" {
|
||||||
|
t.Fatalf("chatgpt_account_id = %v, want acct-new", merged["chatgpt_account_id"])
|
||||||
|
}
|
||||||
|
if _, ok := merged["refresh_token"]; ok {
|
||||||
|
t.Fatalf("refresh_token should be cleared")
|
||||||
|
}
|
||||||
|
if _, ok := merged["client_id"]; ok {
|
||||||
|
t.Fatalf("client_id should be cleared")
|
||||||
|
}
|
||||||
|
if _, ok := merged["id_token"]; ok {
|
||||||
|
t.Fatalf("id_token should be cleared")
|
||||||
|
}
|
||||||
|
if merged["unrelated_existing"] != "keep" {
|
||||||
|
t.Fatalf("unrelated_existing = %v, want keep", merged["unrelated_existing"])
|
||||||
|
}
|
||||||
|
if _, ok := merged["model_mapping"]; !ok {
|
||||||
|
t.Fatalf("model_mapping should be preserved")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMergeCodexImportCredentialsKeepsRefreshFieldsWhenIncomingHasRefreshToken(t *testing.T) {
|
||||||
|
existing := map[string]any{
|
||||||
|
"refresh_token": "old-refresh-token",
|
||||||
|
"client_id": "old-client-id",
|
||||||
|
"id_token": "old-id-token",
|
||||||
|
}
|
||||||
|
incoming := map[string]any{
|
||||||
|
"access_token": "new-access-token",
|
||||||
|
"refresh_token": "new-refresh-token",
|
||||||
|
"client_id": "new-client-id",
|
||||||
|
"id_token": "new-id-token",
|
||||||
|
}
|
||||||
|
item := &codexImportAccount{
|
||||||
|
AccessToken: "new-access-token",
|
||||||
|
RefreshToken: "new-refresh-token",
|
||||||
|
IDToken: "new-id-token",
|
||||||
|
}
|
||||||
|
|
||||||
|
merged := mergeCodexImportCredentials(existing, incoming, item)
|
||||||
|
|
||||||
|
if merged["refresh_token"] != "new-refresh-token" {
|
||||||
|
t.Fatalf("refresh_token = %v, want new-refresh-token", merged["refresh_token"])
|
||||||
|
}
|
||||||
|
if merged["client_id"] != "new-client-id" {
|
||||||
|
t.Fatalf("client_id = %v, want new-client-id", merged["client_id"])
|
||||||
|
}
|
||||||
|
if merged["id_token"] != "new-id-token" {
|
||||||
|
t.Fatalf("id_token = %v, want new-id-token", merged["id_token"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNormalizeCodexImportRejectsExpiredAccessToken(t *testing.T) {
|
||||||
|
expiredToken := buildCodexImportTestJWT(t, time.Now().Add(-time.Hour), map[string]any{})
|
||||||
|
|
||||||
|
_, err := normalizeCodexImportEntry(codexImportEntry{Index: 1, Value: expiredToken})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("normalizeCodexImportEntry error = nil, want expired token error")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "已过期") {
|
||||||
|
t.Fatalf("error = %v, want expired token message", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveCodexImportExpiryForNoRefreshTokenUsesTokenExpiry(t *testing.T) {
|
||||||
|
tokenExpiresAt := time.Now().Add(time.Hour).UTC()
|
||||||
|
item := &codexImportAccount{
|
||||||
|
AccessToken: "access-token",
|
||||||
|
Credentials: map[string]any{"access_token": "access-token"},
|
||||||
|
TokenExpiresAt: &tokenExpiresAt,
|
||||||
|
WarningTexts: []string{},
|
||||||
|
}
|
||||||
|
disabled := false
|
||||||
|
req := CodexSessionImportRequest{AutoPauseOnExpired: &disabled}
|
||||||
|
|
||||||
|
accountExpiresAt, credentialExpiresAt, autoPause, warnings, err := resolveCodexImportExpiry(req, item)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("resolveCodexImportExpiry error = %v", err)
|
||||||
|
}
|
||||||
|
if accountExpiresAt == nil || *accountExpiresAt != tokenExpiresAt.Unix() {
|
||||||
|
t.Fatalf("account expires_at = %v, want %d", accountExpiresAt, tokenExpiresAt.Unix())
|
||||||
|
}
|
||||||
|
if credentialExpiresAt == nil || credentialExpiresAt.Unix() != tokenExpiresAt.Unix() {
|
||||||
|
t.Fatalf("credential expires_at = %v, want %s", credentialExpiresAt, tokenExpiresAt)
|
||||||
|
}
|
||||||
|
if autoPause == nil || !*autoPause {
|
||||||
|
t.Fatalf("autoPause = %v, want true", autoPause)
|
||||||
|
}
|
||||||
|
if len(warnings) == 0 {
|
||||||
|
t.Fatalf("warnings should not be empty")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveCodexImportExpiryForNoRefreshTokenRequiresExpiry(t *testing.T) {
|
||||||
|
item := &codexImportAccount{
|
||||||
|
AccessToken: "opaque-access-token",
|
||||||
|
Credentials: map[string]any{"access_token": "opaque-access-token"},
|
||||||
|
WarningTexts: []string{},
|
||||||
|
}
|
||||||
|
|
||||||
|
_, _, _, _, err := resolveCodexImportExpiry(CodexSessionImportRequest{}, item)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("resolveCodexImportExpiry error = nil, want missing expiry error")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "无法解析 accessToken 过期时间") {
|
||||||
|
t.Fatalf("error = %v, want missing expiry message", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveCodexImportExpiryForNoRefreshTokenUsesEarlierRequestExpiry(t *testing.T) {
|
||||||
|
tokenExpiresAt := time.Now().Add(2 * time.Hour).UTC()
|
||||||
|
requestExpiresAt := time.Now().Add(time.Hour).UTC()
|
||||||
|
item := &codexImportAccount{
|
||||||
|
AccessToken: "access-token",
|
||||||
|
Credentials: map[string]any{"access_token": "access-token"},
|
||||||
|
TokenExpiresAt: &tokenExpiresAt,
|
||||||
|
WarningTexts: []string{},
|
||||||
|
}
|
||||||
|
reqUnix := requestExpiresAt.Unix()
|
||||||
|
req := CodexSessionImportRequest{ExpiresAt: &reqUnix}
|
||||||
|
|
||||||
|
accountExpiresAt, credentialExpiresAt, _, _, err := resolveCodexImportExpiry(req, item)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("resolveCodexImportExpiry error = %v", err)
|
||||||
|
}
|
||||||
|
if accountExpiresAt == nil || *accountExpiresAt != requestExpiresAt.Unix() {
|
||||||
|
t.Fatalf("account expires_at = %v, want %d", accountExpiresAt, requestExpiresAt.Unix())
|
||||||
|
}
|
||||||
|
if credentialExpiresAt == nil || credentialExpiresAt.Unix() != requestExpiresAt.Unix() {
|
||||||
|
t.Fatalf("credential expires_at = %v, want %s", credentialExpiresAt, requestExpiresAt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCodexIdentityKeysPreferStrongIdentifiers(t *testing.T) {
|
||||||
|
keys := buildCodexIdentityKeys("acct-1", "user-1", "same@example.com", "token")
|
||||||
|
for _, key := range keys {
|
||||||
|
if strings.HasPrefix(key, "email:") {
|
||||||
|
t.Fatalf("strong identity should not include email fallback: %v", keys)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
keys = buildCodexIdentityKeys("", "", "same@example.com", "token")
|
||||||
|
hasEmail := false
|
||||||
|
for _, key := range keys {
|
||||||
|
if key == "email:same@example.com" {
|
||||||
|
hasEmail = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !hasEmail {
|
||||||
|
t.Fatalf("weak identity should include email fallback: %v", keys)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildCodexImportTestJWT(t *testing.T, exp time.Time, extraClaims map[string]any) string {
|
||||||
|
t.Helper()
|
||||||
|
header := map[string]any{
|
||||||
|
"alg": "none",
|
||||||
|
"typ": "JWT",
|
||||||
|
}
|
||||||
|
claims := map[string]any{
|
||||||
|
"sub": "user-from-sub",
|
||||||
|
"exp": exp.Unix(),
|
||||||
|
"iat": time.Now().Unix(),
|
||||||
|
}
|
||||||
|
for k, v := range extraClaims {
|
||||||
|
claims[k] = v
|
||||||
|
}
|
||||||
|
headerBytes, err := json.Marshal(header)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal header: %v", err)
|
||||||
|
}
|
||||||
|
claimBytes, err := json.Marshal(claims)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal claims: %v", err)
|
||||||
|
}
|
||||||
|
return base64.RawURLEncoding.EncodeToString(headerBytes) + "." + base64.RawURLEncoding.EncodeToString(claimBytes) + "."
|
||||||
|
}
|
||||||
@ -282,6 +282,7 @@ func registerAccountRoutes(admin *gin.RouterGroup, h *handler.Handlers) {
|
|||||||
accounts.GET("/:id", h.Admin.Account.GetByID)
|
accounts.GET("/:id", h.Admin.Account.GetByID)
|
||||||
accounts.POST("", h.Admin.Account.Create)
|
accounts.POST("", h.Admin.Account.Create)
|
||||||
accounts.POST("/check-mixed-channel", h.Admin.Account.CheckMixedChannel)
|
accounts.POST("/check-mixed-channel", h.Admin.Account.CheckMixedChannel)
|
||||||
|
accounts.POST("/import/codex-session", h.Admin.Account.ImportCodexSession)
|
||||||
accounts.POST("/sync/crs", h.Admin.Account.SyncFromCRS)
|
accounts.POST("/sync/crs", h.Admin.Account.SyncFromCRS)
|
||||||
accounts.POST("/sync/crs/preview", h.Admin.Account.PreviewFromCRS)
|
accounts.POST("/sync/crs/preview", h.Admin.Account.PreviewFromCRS)
|
||||||
accounts.PUT("/:id", h.Admin.Account.Update)
|
accounts.PUT("/:id", h.Admin.Account.Update)
|
||||||
|
|||||||
@ -52,3 +52,47 @@ func TestOpenAIOAuthService_RefreshAccountToken_NoRefreshTokenUsesExistingAccess
|
|||||||
require.Equal(t, "client-id-1", info.ClientID)
|
require.Equal(t, "client-id-1", info.ClientID)
|
||||||
require.Zero(t, atomic.LoadInt32(&client.refreshCalls), "existing access token should be reused without calling refresh")
|
require.Zero(t, atomic.LoadInt32(&client.refreshCalls), "existing access token should be reused without calling refresh")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestOpenAITokenRefresher_NeedsRefresh_SkipsAccountWithoutRefreshToken(t *testing.T) {
|
||||||
|
refresher := NewOpenAITokenRefresher(nil, nil)
|
||||||
|
expiresAt := time.Now().Add(time.Minute).UTC().Format(time.RFC3339)
|
||||||
|
|
||||||
|
withoutRT := &Account{
|
||||||
|
Platform: PlatformOpenAI,
|
||||||
|
Type: AccountTypeOAuth,
|
||||||
|
Credentials: map[string]any{
|
||||||
|
"access_token": "access-token",
|
||||||
|
"expires_at": expiresAt,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
require.False(t, refresher.NeedsRefresh(withoutRT, 5*time.Minute))
|
||||||
|
|
||||||
|
withRT := &Account{
|
||||||
|
Platform: PlatformOpenAI,
|
||||||
|
Type: AccountTypeOAuth,
|
||||||
|
Credentials: map[string]any{
|
||||||
|
"access_token": "access-token",
|
||||||
|
"refresh_token": "refresh-token",
|
||||||
|
"expires_at": expiresAt,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
require.True(t, refresher.NeedsRefresh(withRT, 5*time.Minute))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOpenAITokenProvider_NoRefreshTokenExpiredAccessTokenReturnsError(t *testing.T) {
|
||||||
|
provider := NewOpenAITokenProvider(nil, nil, nil)
|
||||||
|
expiresAt := time.Now().Add(-time.Minute).UTC().Format(time.RFC3339)
|
||||||
|
account := &Account{
|
||||||
|
Platform: PlatformOpenAI,
|
||||||
|
Type: AccountTypeOAuth,
|
||||||
|
Credentials: map[string]any{
|
||||||
|
"access_token": "expired-access-token",
|
||||||
|
"expires_at": expiresAt,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
token, err := provider.GetAccessToken(context.Background(), account)
|
||||||
|
require.Error(t, err)
|
||||||
|
require.Empty(t, token)
|
||||||
|
require.Contains(t, err.Error(), "refresh_token is missing")
|
||||||
|
}
|
||||||
|
|||||||
@ -152,6 +152,12 @@ func (p *OpenAITokenProvider) GetAccessToken(ctx context.Context, account *Accou
|
|||||||
// 2) Refresh if needed (pre-expiry skew).
|
// 2) Refresh if needed (pre-expiry skew).
|
||||||
expiresAt := account.GetCredentialAsTime("expires_at")
|
expiresAt := account.GetCredentialAsTime("expires_at")
|
||||||
needsRefresh := expiresAt == nil || time.Until(*expiresAt) <= openAITokenRefreshSkew
|
needsRefresh := expiresAt == nil || time.Until(*expiresAt) <= openAITokenRefreshSkew
|
||||||
|
if needsRefresh && strings.TrimSpace(account.GetOpenAIRefreshToken()) == "" {
|
||||||
|
if expiresAt != nil && !time.Now().Before(*expiresAt) {
|
||||||
|
return "", errors.New("openai access_token expired and refresh_token is missing")
|
||||||
|
}
|
||||||
|
needsRefresh = false
|
||||||
|
}
|
||||||
refreshFailed := false
|
refreshFailed := false
|
||||||
|
|
||||||
if needsRefresh && p.refreshAPI != nil && p.executor != nil {
|
if needsRefresh && p.refreshAPI != nil && p.executor != nil {
|
||||||
|
|||||||
@ -424,8 +424,9 @@ func TestOpenAITokenProvider_CacheGetError(t *testing.T) {
|
|||||||
Platform: PlatformOpenAI,
|
Platform: PlatformOpenAI,
|
||||||
Type: AccountTypeOAuth,
|
Type: AccountTypeOAuth,
|
||||||
Credentials: map[string]any{
|
Credentials: map[string]any{
|
||||||
"access_token": "fallback-token",
|
"access_token": "fallback-token",
|
||||||
"expires_at": expiresAt,
|
"refresh_token": "refresh-token",
|
||||||
|
"expires_at": expiresAt,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -650,8 +651,9 @@ func TestOpenAITokenProvider_Real_LockFailedWait(t *testing.T) {
|
|||||||
Platform: PlatformOpenAI,
|
Platform: PlatformOpenAI,
|
||||||
Type: AccountTypeOAuth,
|
Type: AccountTypeOAuth,
|
||||||
Credentials: map[string]any{
|
Credentials: map[string]any{
|
||||||
"access_token": "fallback-token",
|
"access_token": "fallback-token",
|
||||||
"expires_at": expiresAt,
|
"refresh_token": "refresh-token",
|
||||||
|
"expires_at": expiresAt,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -819,8 +821,9 @@ func TestOpenAITokenProvider_Real_LockRace_PollingHitsCache(t *testing.T) {
|
|||||||
Platform: PlatformOpenAI,
|
Platform: PlatformOpenAI,
|
||||||
Type: AccountTypeOAuth,
|
Type: AccountTypeOAuth,
|
||||||
Credentials: map[string]any{
|
Credentials: map[string]any{
|
||||||
"access_token": "fallback-token",
|
"access_token": "fallback-token",
|
||||||
"expires_at": expiresAt,
|
"refresh_token": "refresh-token",
|
||||||
|
"expires_at": expiresAt,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -848,8 +851,9 @@ func TestOpenAITokenProvider_Real_LockRace_ContextCanceled(t *testing.T) {
|
|||||||
Platform: PlatformOpenAI,
|
Platform: PlatformOpenAI,
|
||||||
Type: AccountTypeOAuth,
|
Type: AccountTypeOAuth,
|
||||||
Credentials: map[string]any{
|
Credentials: map[string]any{
|
||||||
"access_token": "fallback-token",
|
"access_token": "fallback-token",
|
||||||
"expires_at": expiresAt,
|
"refresh_token": "refresh-token",
|
||||||
|
"expires_at": expiresAt,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -875,8 +879,9 @@ func TestOpenAITokenProvider_RuntimeMetrics_LockWaitHitAndSnapshot(t *testing.T)
|
|||||||
Platform: PlatformOpenAI,
|
Platform: PlatformOpenAI,
|
||||||
Type: AccountTypeOAuth,
|
Type: AccountTypeOAuth,
|
||||||
Credentials: map[string]any{
|
Credentials: map[string]any{
|
||||||
"access_token": "fallback-token",
|
"access_token": "fallback-token",
|
||||||
"expires_at": expiresAt,
|
"refresh_token": "refresh-token",
|
||||||
|
"expires_at": expiresAt,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
cacheKey := OpenAITokenCacheKey(account)
|
cacheKey := OpenAITokenCacheKey(account)
|
||||||
@ -911,8 +916,9 @@ func TestOpenAITokenProvider_RuntimeMetrics_LockAcquireFailure(t *testing.T) {
|
|||||||
Platform: PlatformOpenAI,
|
Platform: PlatformOpenAI,
|
||||||
Type: AccountTypeOAuth,
|
Type: AccountTypeOAuth,
|
||||||
Credentials: map[string]any{
|
Credentials: map[string]any{
|
||||||
"access_token": "fallback-token",
|
"access_token": "fallback-token",
|
||||||
"expires_at": expiresAt,
|
"refresh_token": "refresh-token",
|
||||||
|
"expires_at": expiresAt,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -2,6 +2,7 @@ package service
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -95,6 +96,9 @@ func (r *OpenAITokenRefresher) CanRefresh(account *Account) bool {
|
|||||||
// NeedsRefresh 检查token是否需要刷新
|
// NeedsRefresh 检查token是否需要刷新
|
||||||
// expires_at 缺失且处于限流状态时需要刷新,防止限流期间 token 静默过期
|
// expires_at 缺失且处于限流状态时需要刷新,防止限流期间 token 静默过期
|
||||||
func (r *OpenAITokenRefresher) NeedsRefresh(account *Account, refreshWindow time.Duration) bool {
|
func (r *OpenAITokenRefresher) NeedsRefresh(account *Account, refreshWindow time.Duration) bool {
|
||||||
|
if strings.TrimSpace(account.GetOpenAIRefreshToken()) == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
expiresAt := account.GetCredentialAsTime("expires_at")
|
expiresAt := account.GetCredentialAsTime("expires_at")
|
||||||
if expiresAt == nil {
|
if expiresAt == nil {
|
||||||
return account.IsRateLimited()
|
return account.IsRateLimited()
|
||||||
|
|||||||
@ -16,6 +16,8 @@ import type {
|
|||||||
TempUnschedulableStatus,
|
TempUnschedulableStatus,
|
||||||
AdminDataPayload,
|
AdminDataPayload,
|
||||||
AdminDataImportResult,
|
AdminDataImportResult,
|
||||||
|
CodexSessionImportRequest,
|
||||||
|
CodexSessionImportResult,
|
||||||
CheckMixedChannelRequest,
|
CheckMixedChannelRequest,
|
||||||
CheckMixedChannelResponse
|
CheckMixedChannelResponse
|
||||||
} from '@/types'
|
} from '@/types'
|
||||||
@ -547,6 +549,11 @@ export async function importData(payload: {
|
|||||||
return data
|
return data
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function importCodexSession(payload: CodexSessionImportRequest): Promise<CodexSessionImportResult> {
|
||||||
|
const { data } = await apiClient.post<CodexSessionImportResult>('/admin/accounts/import/codex-session', payload)
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get Antigravity default model mapping from backend
|
* Get Antigravity default model mapping from backend
|
||||||
* @returns Default model mapping (from -> to)
|
* @returns Default model mapping (from -> to)
|
||||||
@ -663,6 +670,7 @@ export const accountsAPI = {
|
|||||||
syncFromCrs,
|
syncFromCrs,
|
||||||
exportData,
|
exportData,
|
||||||
importData,
|
importData,
|
||||||
|
importCodexSession,
|
||||||
getAntigravityDefaultModelMapping,
|
getAntigravityDefaultModelMapping,
|
||||||
batchClearError,
|
batchClearError,
|
||||||
batchRefresh,
|
batchRefresh,
|
||||||
|
|||||||
@ -2765,6 +2765,7 @@
|
|||||||
:show-mobile-refresh-token-option="form.platform === 'openai'"
|
:show-mobile-refresh-token-option="form.platform === 'openai'"
|
||||||
:show-session-token-option="false"
|
:show-session-token-option="false"
|
||||||
:show-access-token-option="false"
|
:show-access-token-option="false"
|
||||||
|
:show-codex-session-import-option="form.platform === 'openai'"
|
||||||
:platform="form.platform"
|
:platform="form.platform"
|
||||||
:show-project-id="geminiOAuthType === 'code_assist'"
|
:show-project-id="geminiOAuthType === 'code_assist'"
|
||||||
@generate-url="handleGenerateUrl"
|
@generate-url="handleGenerateUrl"
|
||||||
@ -2772,6 +2773,7 @@
|
|||||||
@validate-refresh-token="handleValidateRefreshToken"
|
@validate-refresh-token="handleValidateRefreshToken"
|
||||||
@validate-mobile-refresh-token="handleOpenAIValidateMobileRT"
|
@validate-mobile-refresh-token="handleOpenAIValidateMobileRT"
|
||||||
@validate-session-token="handleValidateSessionToken"
|
@validate-session-token="handleValidateSessionToken"
|
||||||
|
@import-codex-session="handleOpenAIImportCodexSession"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
@ -3119,6 +3121,7 @@ import type {
|
|||||||
AccountType,
|
AccountType,
|
||||||
CheckMixedChannelResponse,
|
CheckMixedChannelResponse,
|
||||||
CreateAccountRequest,
|
CreateAccountRequest,
|
||||||
|
CodexSessionImportMessage,
|
||||||
OpenAICompactMode
|
OpenAICompactMode
|
||||||
} from '@/types'
|
} from '@/types'
|
||||||
import BaseDialog from '@/components/common/BaseDialog.vue'
|
import BaseDialog from '@/components/common/BaseDialog.vue'
|
||||||
@ -3152,6 +3155,7 @@ interface OAuthFlowExposed {
|
|||||||
sessionKey: string
|
sessionKey: string
|
||||||
refreshToken: string
|
refreshToken: string
|
||||||
sessionToken: string
|
sessionToken: string
|
||||||
|
codexSession: string
|
||||||
inputMethod: AuthInputMethod
|
inputMethod: AuthInputMethod
|
||||||
reset: () => void
|
reset: () => void
|
||||||
}
|
}
|
||||||
@ -4631,6 +4635,113 @@ const handleOpenAIExchange = async (authCode: string) => {
|
|||||||
// OpenAI Mobile RT client_id
|
// OpenAI Mobile RT client_id
|
||||||
const OPENAI_MOBILE_RT_CLIENT_ID = 'app_LlGpXReQgckcGGUo2JrYvtJK'
|
const OPENAI_MOBILE_RT_CLIENT_ID = 'app_LlGpXReQgckcGGUo2JrYvtJK'
|
||||||
|
|
||||||
|
const buildOpenAICodexImportCredentialExtras = (): Record<string, unknown> | null => {
|
||||||
|
const credentials: Record<string, unknown> = {}
|
||||||
|
if (!isOpenAIModelRestrictionDisabled.value) {
|
||||||
|
const modelMapping = buildModelMappingObject(modelRestrictionMode.value, allowedModels.value, modelMappings.value)
|
||||||
|
if (modelMapping) {
|
||||||
|
credentials.model_mapping = modelMapping
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const compactModelMapping = buildOpenAICompactModelMapping()
|
||||||
|
if (compactModelMapping) {
|
||||||
|
credentials.compact_model_mapping = compactModelMapping
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!applyTempUnschedConfig(credentials)) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return credentials
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatCodexImportMessages = (messages?: CodexSessionImportMessage[]) => {
|
||||||
|
return (messages || [])
|
||||||
|
.map((item) => {
|
||||||
|
const name = item.name ? ` ${item.name}` : ''
|
||||||
|
return `#${item.index}${name}: ${item.message}`
|
||||||
|
})
|
||||||
|
.join('\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleOpenAIImportCodexSession = async (content: string) => {
|
||||||
|
const oauthClient = openaiOAuth
|
||||||
|
const trimmed = content.trim()
|
||||||
|
if (!trimmed) {
|
||||||
|
oauthClient.error.value = t('admin.accounts.oauth.openai.codexSessionEmpty')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const credentialExtras = buildOpenAICodexImportCredentialExtras()
|
||||||
|
if (credentialExtras === null) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
oauthClient.loading.value = true
|
||||||
|
oauthClient.error.value = ''
|
||||||
|
|
||||||
|
try {
|
||||||
|
const extra = buildOpenAIExtra()
|
||||||
|
const result = await adminAPI.accounts.importCodexSession({
|
||||||
|
content: trimmed,
|
||||||
|
name: form.name,
|
||||||
|
notes: form.notes || null,
|
||||||
|
proxy_id: form.proxy_id,
|
||||||
|
concurrency: form.concurrency,
|
||||||
|
load_factor: form.load_factor ?? undefined,
|
||||||
|
priority: form.priority,
|
||||||
|
rate_multiplier: form.rate_multiplier,
|
||||||
|
group_ids: form.group_ids,
|
||||||
|
expires_at: form.expires_at,
|
||||||
|
auto_pause_on_expired: autoPauseOnExpired.value,
|
||||||
|
credential_extras: Object.keys(credentialExtras).length > 0 ? credentialExtras : undefined,
|
||||||
|
extra,
|
||||||
|
update_existing: true
|
||||||
|
})
|
||||||
|
|
||||||
|
const successCount = result.created + result.updated
|
||||||
|
const params = {
|
||||||
|
created: result.created,
|
||||||
|
updated: result.updated,
|
||||||
|
skipped: result.skipped,
|
||||||
|
failed: result.failed
|
||||||
|
}
|
||||||
|
|
||||||
|
if (successCount > 0 && result.failed === 0) {
|
||||||
|
appStore.showSuccess(t('admin.accounts.oauth.openai.codexSessionImportSuccess', params))
|
||||||
|
emit('created')
|
||||||
|
handleClose()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const errorText = formatCodexImportMessages(result.errors)
|
||||||
|
const warningText = formatCodexImportMessages(result.warnings)
|
||||||
|
oauthClient.error.value = [errorText, warningText].filter(Boolean).join('\n')
|
||||||
|
|
||||||
|
if (result.failed === 0) {
|
||||||
|
appStore.showWarning(t('admin.accounts.oauth.openai.codexSessionImportSuccess', params))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (successCount > 0) {
|
||||||
|
appStore.showWarning(t('admin.accounts.oauth.openai.codexSessionImportPartial', params))
|
||||||
|
emit('created')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
appStore.showError(t('admin.accounts.oauth.openai.codexSessionImportFailed'))
|
||||||
|
} catch (error: any) {
|
||||||
|
oauthClient.error.value =
|
||||||
|
error.response?.data?.detail ||
|
||||||
|
error.response?.data?.message ||
|
||||||
|
error.message ||
|
||||||
|
t('admin.accounts.oauth.openai.codexSessionImportFailed')
|
||||||
|
appStore.showError(oauthClient.error.value)
|
||||||
|
} finally {
|
||||||
|
oauthClient.loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// OpenAI RT 批量验证和创建(共享逻辑)
|
// OpenAI RT 批量验证和创建(共享逻辑)
|
||||||
const handleOpenAIBatchRT = async (refreshTokenInput: string, clientId?: string) => {
|
const handleOpenAIBatchRT = async (refreshTokenInput: string, clientId?: string) => {
|
||||||
const oauthClient = openaiOAuth
|
const oauthClient = openaiOAuth
|
||||||
|
|||||||
@ -81,6 +81,17 @@
|
|||||||
t('admin.accounts.oauth.openai.accessTokenAuth', '手动输入 AT')
|
t('admin.accounts.oauth.openai.accessTokenAuth', '手动输入 AT')
|
||||||
}}</span>
|
}}</span>
|
||||||
</label>
|
</label>
|
||||||
|
<label v-if="showCodexSessionImportOption" class="flex cursor-pointer items-center gap-2">
|
||||||
|
<input
|
||||||
|
v-model="inputMethod"
|
||||||
|
type="radio"
|
||||||
|
value="codex_session"
|
||||||
|
class="text-blue-600 focus:ring-blue-500"
|
||||||
|
/>
|
||||||
|
<span class="text-sm text-blue-900 dark:text-blue-200">{{
|
||||||
|
t('admin.accounts.oauth.openai.codexSessionAuth')
|
||||||
|
}}</span>
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -168,6 +179,85 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Codex JSON / AT 批量输入 -->
|
||||||
|
<div v-if="inputMethod === 'codex_session'" class="space-y-4">
|
||||||
|
<div
|
||||||
|
class="rounded-lg border border-blue-300 bg-white/80 p-4 dark:border-blue-600 dark:bg-gray-800/80"
|
||||||
|
>
|
||||||
|
<p class="mb-3 text-sm text-blue-700 dark:text-blue-300">
|
||||||
|
{{ t('admin.accounts.oauth.openai.codexSessionDesc') }}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="mb-4">
|
||||||
|
<label
|
||||||
|
class="mb-2 flex items-center gap-2 text-sm font-semibold text-gray-700 dark:text-gray-300"
|
||||||
|
>
|
||||||
|
<Icon name="key" size="sm" class="text-blue-500" />
|
||||||
|
{{ t('admin.accounts.oauth.openai.codexSessionInputLabel') }}
|
||||||
|
<span
|
||||||
|
v-if="parsedCodexSessionCount > 1"
|
||||||
|
class="rounded-full bg-blue-500 px-2 py-0.5 text-xs text-white"
|
||||||
|
>
|
||||||
|
{{ t('admin.accounts.oauth.keysCount', { count: parsedCodexSessionCount }) }}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
v-model="codexSessionInput"
|
||||||
|
rows="8"
|
||||||
|
class="input w-full resize-y font-mono text-sm"
|
||||||
|
:placeholder="t('admin.accounts.oauth.openai.codexSessionPlaceholder')"
|
||||||
|
spellcheck="false"
|
||||||
|
></textarea>
|
||||||
|
<p class="mt-1 text-xs text-blue-600 dark:text-blue-400">
|
||||||
|
{{ t('admin.accounts.oauth.openai.codexSessionHint') }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="error"
|
||||||
|
class="mb-4 rounded-lg border border-red-200 bg-red-50 p-3 dark:border-red-700 dark:bg-red-900/30"
|
||||||
|
>
|
||||||
|
<p class="whitespace-pre-line text-sm text-red-600 dark:text-red-400">
|
||||||
|
{{ error }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-primary w-full"
|
||||||
|
:disabled="loading || !codexSessionInput.trim()"
|
||||||
|
@click="handleImportCodexSession"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
v-if="loading"
|
||||||
|
class="-ml-1 mr-2 h-4 w-4 animate-spin"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<circle
|
||||||
|
class="opacity-25"
|
||||||
|
cx="12"
|
||||||
|
cy="12"
|
||||||
|
r="10"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="4"
|
||||||
|
></circle>
|
||||||
|
<path
|
||||||
|
class="opacity-75"
|
||||||
|
fill="currentColor"
|
||||||
|
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||||
|
></path>
|
||||||
|
</svg>
|
||||||
|
<Icon v-else name="sparkles" size="sm" class="mr-2" />
|
||||||
|
{{
|
||||||
|
loading
|
||||||
|
? t('admin.accounts.oauth.openai.validating')
|
||||||
|
: t('admin.accounts.oauth.openai.codexSessionImportAndCreate')
|
||||||
|
}}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Cookie Auto-Auth Form -->
|
<!-- Cookie Auto-Auth Form -->
|
||||||
<div v-if="inputMethod === 'cookie'" class="space-y-4">
|
<div v-if="inputMethod === 'cookie'" class="space-y-4">
|
||||||
<div
|
<div
|
||||||
@ -561,6 +651,7 @@ interface Props {
|
|||||||
showMobileRefreshTokenOption?: boolean // Whether to show mobile refresh token option (OpenAI only)
|
showMobileRefreshTokenOption?: boolean // Whether to show mobile refresh token option (OpenAI only)
|
||||||
showSessionTokenOption?: boolean
|
showSessionTokenOption?: boolean
|
||||||
showAccessTokenOption?: boolean
|
showAccessTokenOption?: boolean
|
||||||
|
showCodexSessionImportOption?: boolean
|
||||||
platform?: AccountPlatform // Platform type for different UI/text
|
platform?: AccountPlatform // Platform type for different UI/text
|
||||||
showProjectId?: boolean // New prop to control project ID visibility
|
showProjectId?: boolean // New prop to control project ID visibility
|
||||||
}
|
}
|
||||||
@ -579,6 +670,7 @@ const props = withDefaults(defineProps<Props>(), {
|
|||||||
showMobileRefreshTokenOption: false,
|
showMobileRefreshTokenOption: false,
|
||||||
showSessionTokenOption: false,
|
showSessionTokenOption: false,
|
||||||
showAccessTokenOption: false,
|
showAccessTokenOption: false,
|
||||||
|
showCodexSessionImportOption: false,
|
||||||
platform: 'anthropic',
|
platform: 'anthropic',
|
||||||
showProjectId: true
|
showProjectId: true
|
||||||
})
|
})
|
||||||
@ -591,6 +683,7 @@ const emit = defineEmits<{
|
|||||||
'validate-mobile-refresh-token': [refreshToken: string]
|
'validate-mobile-refresh-token': [refreshToken: string]
|
||||||
'validate-session-token': [sessionToken: string]
|
'validate-session-token': [sessionToken: string]
|
||||||
'import-access-token': [accessToken: string]
|
'import-access-token': [accessToken: string]
|
||||||
|
'import-codex-session': [content: string]
|
||||||
'update:inputMethod': [method: AuthInputMethod]
|
'update:inputMethod': [method: AuthInputMethod]
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
@ -630,12 +723,13 @@ const authCodeInput = ref('')
|
|||||||
const sessionKeyInput = ref('')
|
const sessionKeyInput = ref('')
|
||||||
const refreshTokenInput = ref('')
|
const refreshTokenInput = ref('')
|
||||||
const sessionTokenInput = ref('')
|
const sessionTokenInput = ref('')
|
||||||
|
const codexSessionInput = ref('')
|
||||||
const showHelpDialog = ref(false)
|
const showHelpDialog = ref(false)
|
||||||
const oauthState = ref('')
|
const oauthState = ref('')
|
||||||
const projectId = ref('')
|
const projectId = ref('')
|
||||||
|
|
||||||
// Computed: show method selection when either cookie or refresh token option is enabled
|
// Computed: show method selection when either cookie or refresh token option is enabled
|
||||||
const showMethodSelection = computed(() => props.showCookieOption || props.showRefreshTokenOption || props.showMobileRefreshTokenOption || props.showSessionTokenOption || props.showAccessTokenOption)
|
const showMethodSelection = computed(() => props.showCookieOption || props.showRefreshTokenOption || props.showMobileRefreshTokenOption || props.showSessionTokenOption || props.showAccessTokenOption || props.showCodexSessionImportOption)
|
||||||
|
|
||||||
// Clipboard
|
// Clipboard
|
||||||
const { copied, copyToClipboard } = useClipboard()
|
const { copied, copyToClipboard } = useClipboard()
|
||||||
@ -656,6 +750,16 @@ const parsedRefreshTokenCount = computed(() => {
|
|||||||
.filter((rt) => rt).length
|
.filter((rt) => rt).length
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const parsedCodexSessionCount = computed(() => {
|
||||||
|
const trimmed = codexSessionInput.value.trim()
|
||||||
|
if (!trimmed) return 0
|
||||||
|
if (trimmed.startsWith('{') || trimmed.startsWith('[')) return 1
|
||||||
|
return trimmed
|
||||||
|
.split('\n')
|
||||||
|
.map((item) => item.trim())
|
||||||
|
.filter((item) => item).length
|
||||||
|
})
|
||||||
|
|
||||||
// Watchers
|
// Watchers
|
||||||
watch(inputMethod, (newVal) => {
|
watch(inputMethod, (newVal) => {
|
||||||
emit('update:inputMethod', newVal)
|
emit('update:inputMethod', newVal)
|
||||||
@ -727,6 +831,12 @@ const handleValidateRefreshToken = () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleImportCodexSession = () => {
|
||||||
|
if (codexSessionInput.value.trim()) {
|
||||||
|
emit('import-codex-session', codexSessionInput.value.trim())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Expose methods and state
|
// Expose methods and state
|
||||||
defineExpose({
|
defineExpose({
|
||||||
authCode: authCodeInput,
|
authCode: authCodeInput,
|
||||||
@ -735,6 +845,7 @@ defineExpose({
|
|||||||
sessionKey: sessionKeyInput,
|
sessionKey: sessionKeyInput,
|
||||||
refreshToken: refreshTokenInput,
|
refreshToken: refreshTokenInput,
|
||||||
sessionToken: sessionTokenInput,
|
sessionToken: sessionTokenInput,
|
||||||
|
codexSession: codexSessionInput,
|
||||||
inputMethod,
|
inputMethod,
|
||||||
reset: () => {
|
reset: () => {
|
||||||
authCodeInput.value = ''
|
authCodeInput.value = ''
|
||||||
@ -743,6 +854,7 @@ defineExpose({
|
|||||||
sessionKeyInput.value = ''
|
sessionKeyInput.value = ''
|
||||||
refreshTokenInput.value = ''
|
refreshTokenInput.value = ''
|
||||||
sessionTokenInput.value = ''
|
sessionTokenInput.value = ''
|
||||||
|
codexSessionInput.value = ''
|
||||||
inputMethod.value = 'manual'
|
inputMethod.value = 'manual'
|
||||||
showHelpDialog.value = false
|
showHelpDialog.value = false
|
||||||
}
|
}
|
||||||
|
|||||||
@ -5,7 +5,6 @@
|
|||||||
<Icon name="refresh" size="md" :class="[loading ? 'animate-spin' : '']" />
|
<Icon name="refresh" size="md" :class="[loading ? 'animate-spin' : '']" />
|
||||||
</button>
|
</button>
|
||||||
<slot name="after"></slot>
|
<slot name="after"></slot>
|
||||||
<button @click="$emit('sync')" class="btn btn-secondary">{{ t('admin.accounts.syncFromCrs') }}</button>
|
|
||||||
<slot name="beforeCreate"></slot>
|
<slot name="beforeCreate"></slot>
|
||||||
<button @click="$emit('create')" class="btn btn-primary">{{ t('admin.accounts.createAccount') }}</button>
|
<button @click="$emit('create')" class="btn btn-primary">{{ t('admin.accounts.createAccount') }}</button>
|
||||||
<slot name="afterCreate"></slot>
|
<slot name="afterCreate"></slot>
|
||||||
@ -17,7 +16,7 @@ import { useI18n } from 'vue-i18n'
|
|||||||
import Icon from '@/components/icons/Icon.vue'
|
import Icon from '@/components/icons/Icon.vue'
|
||||||
|
|
||||||
defineProps(['loading'])
|
defineProps(['loading'])
|
||||||
defineEmits(['refresh', 'sync', 'create'])
|
defineEmits(['refresh', 'create'])
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@ -3,7 +3,7 @@ import { useAppStore } from '@/stores/app'
|
|||||||
import { adminAPI } from '@/api/admin'
|
import { adminAPI } from '@/api/admin'
|
||||||
|
|
||||||
export type AddMethod = 'oauth' | 'setup-token'
|
export type AddMethod = 'oauth' | 'setup-token'
|
||||||
export type AuthInputMethod = 'manual' | 'cookie' | 'refresh_token' | 'mobile_refresh_token' | 'session_token' | 'access_token'
|
export type AuthInputMethod = 'manual' | 'cookie' | 'refresh_token' | 'mobile_refresh_token' | 'session_token' | 'access_token' | 'codex_session'
|
||||||
|
|
||||||
export interface OAuthState {
|
export interface OAuthState {
|
||||||
authUrl: string
|
authUrl: string
|
||||||
|
|||||||
@ -2777,6 +2777,11 @@ export default {
|
|||||||
dataExportSelected: 'Export Selected',
|
dataExportSelected: 'Export Selected',
|
||||||
dataExportIncludeProxies: 'Include proxies linked to the exported accounts',
|
dataExportIncludeProxies: 'Include proxies linked to the exported accounts',
|
||||||
dataImport: 'Import',
|
dataImport: 'Import',
|
||||||
|
moreActions: 'More Actions',
|
||||||
|
dataActions: 'Data',
|
||||||
|
toolActions: 'Tools',
|
||||||
|
viewColumns: 'Columns',
|
||||||
|
selectedCount: '{count} selected',
|
||||||
dataExportConfirmMessage: 'The exported data contains sensitive account and proxy information. Store it securely.',
|
dataExportConfirmMessage: 'The exported data contains sensitive account and proxy information. Store it securely.',
|
||||||
dataExportConfirm: 'Confirm Export',
|
dataExportConfirm: 'Confirm Export',
|
||||||
dataExported: 'Data exported successfully',
|
dataExported: 'Data exported successfully',
|
||||||
@ -3470,6 +3475,16 @@ export default {
|
|||||||
refreshTokenAuth: 'Manual RT Input',
|
refreshTokenAuth: 'Manual RT Input',
|
||||||
refreshTokenDesc: 'Enter your existing OpenAI Refresh Token(s). Supports batch input (one per line). The system will automatically validate and create accounts.',
|
refreshTokenDesc: 'Enter your existing OpenAI Refresh Token(s). Supports batch input (one per line). The system will automatically validate and create accounts.',
|
||||||
refreshTokenPlaceholder: 'Paste your OpenAI Refresh Token...\nSupports multiple, one per line',
|
refreshTokenPlaceholder: 'Paste your OpenAI Refresh Token...\nSupports multiple, one per line',
|
||||||
|
codexSessionAuth: 'Codex JSON / AT Batch Input',
|
||||||
|
codexSessionDesc: 'Paste Codex JSON or an accessToken. Accounts use the step 1 settings.',
|
||||||
|
codexSessionInputLabel: 'Codex JSON or accessToken',
|
||||||
|
codexSessionPlaceholder: 'Multiple lines supported, one token or JSON per line',
|
||||||
|
codexSessionHint: 'sessionToken will not be saved as refresh_token. Without refresh_token, the account expires with the accessToken expiry; import is rejected if the expiry cannot be parsed and step 1 has no expiration.',
|
||||||
|
codexSessionImportAndCreate: 'Import & Create Account',
|
||||||
|
codexSessionEmpty: 'Please enter Codex JSON or accessToken',
|
||||||
|
codexSessionImportFailed: 'Failed to import Codex account',
|
||||||
|
codexSessionImportSuccess: 'Import completed: created {created}, updated {updated}, skipped {skipped}',
|
||||||
|
codexSessionImportPartial: 'Partial success: created {created}, updated {updated}, skipped {skipped}, failed {failed}',
|
||||||
sessionTokenAuth: 'Manual ST Input',
|
sessionTokenAuth: 'Manual ST Input',
|
||||||
sessionTokenDesc: 'Enter your existing Session Token(s). Supports batch input (one per line). The system will automatically validate and create accounts.',
|
sessionTokenDesc: 'Enter your existing Session Token(s). Supports batch input (one per line). The system will automatically validate and create accounts.',
|
||||||
sessionTokenPlaceholder: 'Paste your Session Token...\nSupports multiple, one per line',
|
sessionTokenPlaceholder: 'Paste your Session Token...\nSupports multiple, one per line',
|
||||||
|
|||||||
@ -2853,6 +2853,11 @@ export default {
|
|||||||
dataExportSelected: '导出选中',
|
dataExportSelected: '导出选中',
|
||||||
dataExportIncludeProxies: '导出代理(导出账号关联的代理)',
|
dataExportIncludeProxies: '导出代理(导出账号关联的代理)',
|
||||||
dataImport: '导入',
|
dataImport: '导入',
|
||||||
|
moreActions: '更多操作',
|
||||||
|
dataActions: '数据操作',
|
||||||
|
toolActions: '工具',
|
||||||
|
viewColumns: '列显示',
|
||||||
|
selectedCount: '已选 {count}',
|
||||||
dataExportConfirmMessage: '导出的数据包含账号与代理的敏感信息,请妥善保存。',
|
dataExportConfirmMessage: '导出的数据包含账号与代理的敏感信息,请妥善保存。',
|
||||||
dataExportConfirm: '确认导出',
|
dataExportConfirm: '确认导出',
|
||||||
dataExported: '数据导出成功',
|
dataExported: '数据导出成功',
|
||||||
@ -3605,6 +3610,16 @@ export default {
|
|||||||
refreshTokenAuth: '手动输入 RT',
|
refreshTokenAuth: '手动输入 RT',
|
||||||
refreshTokenDesc: '输入您已有的 OpenAI Refresh Token,支持批量输入(每行一个),系统将自动验证并创建账号。',
|
refreshTokenDesc: '输入您已有的 OpenAI Refresh Token,支持批量输入(每行一个),系统将自动验证并创建账号。',
|
||||||
refreshTokenPlaceholder: '粘贴您的 OpenAI Refresh Token...\n支持多个,每行一个',
|
refreshTokenPlaceholder: '粘贴您的 OpenAI Refresh Token...\n支持多个,每行一个',
|
||||||
|
codexSessionAuth: 'Codex JSON / AT 批量输入',
|
||||||
|
codexSessionDesc: '粘贴 Codex JSON 或 accessToken,按第一步配置创建账号。',
|
||||||
|
codexSessionInputLabel: 'Codex JSON 或 accessToken',
|
||||||
|
codexSessionPlaceholder: '支持多行,每行一个 token 或 JSON',
|
||||||
|
codexSessionHint: 'sessionToken 不会作为 refresh_token 保存;未包含 refresh_token 时会按 accessToken 过期时间设置账号过期,无法解析且第一步未设置过期时间时会拒绝导入。',
|
||||||
|
codexSessionImportAndCreate: '导入并创建账号',
|
||||||
|
codexSessionEmpty: '请输入 Codex JSON 或 accessToken',
|
||||||
|
codexSessionImportFailed: 'Codex 账号导入失败',
|
||||||
|
codexSessionImportSuccess: '导入完成:新增 {created},更新 {updated},跳过 {skipped}',
|
||||||
|
codexSessionImportPartial: '部分成功:新增 {created},更新 {updated},跳过 {skipped},失败 {failed}',
|
||||||
sessionTokenAuth: '手动输入 ST',
|
sessionTokenAuth: '手动输入 ST',
|
||||||
sessionTokenDesc: '输入您已有的 Session Token,支持批量输入(每行一个),系统将自动验证并创建账号。',
|
sessionTokenDesc: '输入您已有的 Session Token,支持批量输入(每行一个),系统将自动验证并创建账号。',
|
||||||
sessionTokenPlaceholder: '粘贴您的 Session Token...\n支持多个,每行一个',
|
sessionTokenPlaceholder: '粘贴您的 Session Token...\n支持多个,每行一个',
|
||||||
|
|||||||
@ -1105,6 +1105,51 @@ export interface AdminDataImportResult {
|
|||||||
errors?: AdminDataImportError[]
|
errors?: AdminDataImportError[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface CodexSessionImportRequest {
|
||||||
|
content?: string
|
||||||
|
contents?: string[]
|
||||||
|
name?: string
|
||||||
|
notes?: string | null
|
||||||
|
group_ids?: number[]
|
||||||
|
proxy_id?: number | null
|
||||||
|
concurrency?: number
|
||||||
|
priority?: number
|
||||||
|
rate_multiplier?: number
|
||||||
|
load_factor?: number | null
|
||||||
|
expires_at?: number | null
|
||||||
|
auto_pause_on_expired?: boolean
|
||||||
|
credential_extras?: Record<string, unknown>
|
||||||
|
extra?: Record<string, unknown>
|
||||||
|
update_existing?: boolean
|
||||||
|
skip_default_group_bind?: boolean
|
||||||
|
confirm_mixed_channel_risk?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CodexSessionImportMessage {
|
||||||
|
index: number
|
||||||
|
name?: string
|
||||||
|
message: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CodexSessionImportItem {
|
||||||
|
index: number
|
||||||
|
name?: string
|
||||||
|
action: 'created' | 'updated' | 'skipped' | 'failed'
|
||||||
|
account_id?: number
|
||||||
|
message?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CodexSessionImportResult {
|
||||||
|
total: number
|
||||||
|
created: number
|
||||||
|
updated: number
|
||||||
|
skipped: number
|
||||||
|
failed: number
|
||||||
|
items?: CodexSessionImportItem[]
|
||||||
|
warnings?: CodexSessionImportMessage[]
|
||||||
|
errors?: CodexSessionImportMessage[]
|
||||||
|
}
|
||||||
|
|
||||||
// ==================== Usage & Redeem Types ====================
|
// ==================== Usage & Redeem Types ====================
|
||||||
|
|
||||||
export type RedeemCodeType = 'balance' | 'concurrency' | 'subscription' | 'invitation'
|
export type RedeemCodeType = 'balance' | 'concurrency' | 'subscription' | 'invitation'
|
||||||
|
|||||||
@ -14,7 +14,6 @@
|
|||||||
<AccountTableActions
|
<AccountTableActions
|
||||||
:loading="loading"
|
:loading="loading"
|
||||||
@refresh="handleManualRefresh"
|
@refresh="handleManualRefresh"
|
||||||
@sync="showSync = true"
|
|
||||||
@create="showCreate = true"
|
@create="showCreate = true"
|
||||||
>
|
>
|
||||||
<template #after>
|
<template #after>
|
||||||
@ -23,7 +22,7 @@
|
|||||||
<button
|
<button
|
||||||
@click="
|
@click="
|
||||||
showAutoRefreshDropdown = !showAutoRefreshDropdown;
|
showAutoRefreshDropdown = !showAutoRefreshDropdown;
|
||||||
showColumnDropdown = false
|
showAccountToolsDropdown = false
|
||||||
"
|
"
|
||||||
class="btn btn-secondary px-2 md:px-3"
|
class="btn btn-secondary px-2 md:px-3"
|
||||||
:title="t('admin.accounts.autoRefresh')"
|
:title="t('admin.accounts.autoRefresh')"
|
||||||
@ -63,68 +62,100 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Error Passthrough Rules -->
|
<!-- More Tools Dropdown -->
|
||||||
<button
|
<div class="relative" ref="accountToolsDropdownRef">
|
||||||
@click="showErrorPassthrough = true"
|
|
||||||
class="btn btn-secondary"
|
|
||||||
:title="t('admin.errorPassthrough.title')"
|
|
||||||
>
|
|
||||||
<Icon name="shield" size="md" class="mr-1.5" />
|
|
||||||
<span class="hidden md:inline">{{ t('admin.errorPassthrough.title') }}</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<!-- TLS Fingerprint Profiles -->
|
|
||||||
<button
|
|
||||||
@click="showTLSFingerprintProfiles = true"
|
|
||||||
class="btn btn-secondary"
|
|
||||||
:title="t('admin.tlsFingerprintProfiles.title')"
|
|
||||||
>
|
|
||||||
<Icon name="lock" size="md" class="mr-1.5" />
|
|
||||||
<span class="hidden md:inline">{{ t('admin.tlsFingerprintProfiles.title') }}</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<!-- Column Settings Dropdown -->
|
|
||||||
<div class="relative" ref="columnDropdownRef">
|
|
||||||
<button
|
<button
|
||||||
@click="
|
@click="
|
||||||
showColumnDropdown = !showColumnDropdown;
|
showAccountToolsDropdown = !showAccountToolsDropdown;
|
||||||
showAutoRefreshDropdown = false
|
showAutoRefreshDropdown = false
|
||||||
"
|
"
|
||||||
class="btn btn-secondary px-2 md:px-3"
|
class="btn btn-secondary px-2 md:px-3"
|
||||||
:title="t('admin.users.columnSettings')"
|
:title="t('admin.accounts.moreActions')"
|
||||||
>
|
>
|
||||||
<svg class="h-4 w-4 md:mr-1.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5">
|
<Icon name="more" size="sm" class="md:mr-1.5" />
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9 4.5v15m6-15v15m-10.875 0h15.75c.621 0 1.125-.504 1.125-1.125V5.625c0-.621-.504-1.125-1.125-1.125H4.125C3.504 4.5 3 5.004 3 5.625v12.75c0 .621.504 1.125 1.125 1.125z" />
|
<span class="hidden md:inline">{{ t('admin.accounts.moreActions') }}</span>
|
||||||
</svg>
|
<Icon name="chevronDown" size="xs" class="ml-1 hidden md:inline" />
|
||||||
<span class="hidden md:inline">{{ t('admin.users.columnSettings') }}</span>
|
|
||||||
</button>
|
</button>
|
||||||
<!-- Dropdown menu -->
|
|
||||||
<div
|
<div
|
||||||
v-if="showColumnDropdown"
|
v-if="showAccountToolsDropdown"
|
||||||
class="absolute right-0 z-50 mt-2 w-48 origin-top-right rounded-lg border border-gray-200 bg-white shadow-lg dark:border-gray-700 dark:bg-gray-800"
|
class="absolute right-0 z-50 mt-2 w-[min(20rem,calc(100vw-2rem))] origin-top-right overflow-hidden rounded-lg border border-gray-200 bg-white shadow-xl dark:border-gray-700 dark:bg-gray-800"
|
||||||
>
|
>
|
||||||
<div class="max-h-80 overflow-y-auto p-2">
|
<div class="max-h-[70vh] overflow-y-auto p-2">
|
||||||
<button
|
<div class="px-2 py-2">
|
||||||
v-for="col in toggleableColumns"
|
<div class="text-xs font-semibold uppercase tracking-wide text-gray-400 dark:text-gray-500">
|
||||||
:key="col.key"
|
{{ t('admin.accounts.dataActions') }}
|
||||||
@click="toggleColumn(col.key)"
|
</div>
|
||||||
class="flex w-full items-center justify-between rounded-md px-3 py-2 text-sm text-gray-700 hover:bg-gray-100 dark:text-gray-200 dark:hover:bg-gray-700"
|
</div>
|
||||||
>
|
<button class="account-tools-menu-item" @click="openSyncFromCrs">
|
||||||
<span>{{ col.label }}</span>
|
<span class="account-tools-menu-icon bg-blue-50 text-blue-600 dark:bg-blue-900/30 dark:text-blue-300">
|
||||||
<Icon v-if="isColumnVisible(col.key)" name="check" size="sm" class="text-primary-500" />
|
<Icon name="sync" size="sm" />
|
||||||
|
</span>
|
||||||
|
<span class="flex-1 text-left">{{ t('admin.accounts.syncFromCrs') }}</span>
|
||||||
</button>
|
</button>
|
||||||
|
<button class="account-tools-menu-item" @click="openImportData">
|
||||||
|
<span class="account-tools-menu-icon bg-emerald-50 text-emerald-600 dark:bg-emerald-900/30 dark:text-emerald-300">
|
||||||
|
<Icon name="upload" size="sm" />
|
||||||
|
</span>
|
||||||
|
<span class="flex-1 text-left">{{ t('admin.accounts.dataImport') }}</span>
|
||||||
|
</button>
|
||||||
|
<button class="account-tools-menu-item" @click="openExportDataDialogFromMenu">
|
||||||
|
<span class="account-tools-menu-icon bg-violet-50 text-violet-600 dark:bg-violet-900/30 dark:text-violet-300">
|
||||||
|
<Icon name="download" size="sm" />
|
||||||
|
</span>
|
||||||
|
<span class="flex-1 text-left">
|
||||||
|
{{ selIds.length ? t('admin.accounts.dataExportSelected') : t('admin.accounts.dataExport') }}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
v-if="selIds.length"
|
||||||
|
class="rounded-full bg-primary-100 px-2 py-0.5 text-xs font-medium text-primary-700 dark:bg-primary-900/40 dark:text-primary-300"
|
||||||
|
>
|
||||||
|
{{ t('admin.accounts.selectedCount', { count: selIds.length }) }}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="my-2 border-t border-gray-100 dark:border-gray-700"></div>
|
||||||
|
<div class="px-2 py-2">
|
||||||
|
<div class="text-xs font-semibold uppercase tracking-wide text-gray-400 dark:text-gray-500">
|
||||||
|
{{ t('admin.accounts.toolActions') }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button class="account-tools-menu-item" @click="openErrorPassthrough">
|
||||||
|
<span class="account-tools-menu-icon bg-amber-50 text-amber-600 dark:bg-amber-900/30 dark:text-amber-300">
|
||||||
|
<Icon name="shield" size="sm" />
|
||||||
|
</span>
|
||||||
|
<span class="flex-1 text-left">{{ t('admin.errorPassthrough.title') }}</span>
|
||||||
|
</button>
|
||||||
|
<button class="account-tools-menu-item" @click="openTLSFingerprintProfiles">
|
||||||
|
<span class="account-tools-menu-icon bg-slate-100 text-slate-600 dark:bg-slate-700 dark:text-slate-200">
|
||||||
|
<Icon name="lock" size="sm" />
|
||||||
|
</span>
|
||||||
|
<span class="flex-1 text-left">{{ t('admin.tlsFingerprintProfiles.title') }}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="my-2 border-t border-gray-100 dark:border-gray-700"></div>
|
||||||
|
<div class="px-2 py-2">
|
||||||
|
<div class="flex items-center justify-between gap-3">
|
||||||
|
<span class="text-xs font-semibold uppercase tracking-wide text-gray-400 dark:text-gray-500">
|
||||||
|
{{ t('admin.accounts.viewColumns') }}
|
||||||
|
</span>
|
||||||
|
<Icon name="grid" size="sm" class="text-gray-400" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-1 gap-1">
|
||||||
|
<button
|
||||||
|
v-for="col in toggleableColumns"
|
||||||
|
:key="col.key"
|
||||||
|
@click="toggleColumn(col.key)"
|
||||||
|
class="flex w-full items-center justify-between rounded-md px-3 py-2 text-sm text-gray-700 transition-colors hover:bg-gray-100 dark:text-gray-200 dark:hover:bg-gray-700"
|
||||||
|
>
|
||||||
|
<span class="truncate">{{ col.label }}</span>
|
||||||
|
<Icon v-if="isColumnVisible(col.key)" name="check" size="sm" class="text-primary-500" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<template #beforeCreate>
|
|
||||||
<button @click="showImportData = true" class="btn btn-secondary">
|
|
||||||
{{ t('admin.accounts.dataImport') }}
|
|
||||||
</button>
|
|
||||||
<button @click="openExportDataDialog" class="btn btn-secondary">
|
|
||||||
{{ selIds.length ? t('admin.accounts.dataExportSelected') : t('admin.accounts.dataExport') }}
|
|
||||||
</button>
|
|
||||||
</template>
|
|
||||||
</AccountTableActions>
|
</AccountTableActions>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
@ -457,9 +488,9 @@ const togglingSchedulable = ref<number | null>(null)
|
|||||||
const menu = reactive<{show:boolean, acc:Account|null, pos:{top:number, left:number}|null}>({ show: false, acc: null, pos: null })
|
const menu = reactive<{show:boolean, acc:Account|null, pos:{top:number, left:number}|null}>({ show: false, acc: null, pos: null })
|
||||||
const exportingData = ref(false)
|
const exportingData = ref(false)
|
||||||
|
|
||||||
// Column settings
|
// Account tools dropdown
|
||||||
const showColumnDropdown = ref(false)
|
const showAccountToolsDropdown = ref(false)
|
||||||
const columnDropdownRef = ref<HTMLElement | null>(null)
|
const accountToolsDropdownRef = ref<HTMLElement | null>(null)
|
||||||
const hiddenColumns = reactive<Set<string>>(new Set())
|
const hiddenColumns = reactive<Set<string>>(new Set())
|
||||||
const DEFAULT_HIDDEN_COLUMNS = ['today_stats', 'proxy', 'notes', 'priority', 'rate_multiplier']
|
const DEFAULT_HIDDEN_COLUMNS = ['today_stats', 'proxy', 'notes', 'priority', 'rate_multiplier']
|
||||||
const HIDDEN_COLUMNS_KEY = 'account-hidden-columns'
|
const HIDDEN_COLUMNS_KEY = 'account-hidden-columns'
|
||||||
@ -820,7 +851,8 @@ const isAnyModalOpen = computed(() => {
|
|||||||
showTest.value ||
|
showTest.value ||
|
||||||
showStats.value ||
|
showStats.value ||
|
||||||
showSchedulePanel.value ||
|
showSchedulePanel.value ||
|
||||||
showErrorPassthrough.value
|
showErrorPassthrough.value ||
|
||||||
|
showTLSFingerprintProfiles.value
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -931,6 +963,35 @@ const handleManualRefresh = async () => {
|
|||||||
usageManualRefreshToken.value += 1
|
usageManualRefreshToken.value += 1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const closeAccountToolsDropdown = () => {
|
||||||
|
showAccountToolsDropdown.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
const openSyncFromCrs = () => {
|
||||||
|
closeAccountToolsDropdown()
|
||||||
|
showSync.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const openImportData = () => {
|
||||||
|
closeAccountToolsDropdown()
|
||||||
|
showImportData.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const openExportDataDialogFromMenu = () => {
|
||||||
|
closeAccountToolsDropdown()
|
||||||
|
openExportDataDialog()
|
||||||
|
}
|
||||||
|
|
||||||
|
const openErrorPassthrough = () => {
|
||||||
|
closeAccountToolsDropdown()
|
||||||
|
showErrorPassthrough.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const openTLSFingerprintProfiles = () => {
|
||||||
|
closeAccountToolsDropdown()
|
||||||
|
showTLSFingerprintProfiles.value = true
|
||||||
|
}
|
||||||
|
|
||||||
const syncPendingListChanges = async () => {
|
const syncPendingListChanges = async () => {
|
||||||
hasPendingListSync.value = false
|
hasPendingListSync.value = false
|
||||||
await load()
|
await load()
|
||||||
@ -944,7 +1005,7 @@ const { pause: pauseAutoRefresh, resume: resumeAutoRefresh } = useIntervalFn(
|
|||||||
if (document.hidden) return
|
if (document.hidden) return
|
||||||
if (loading.value || autoRefreshFetching.value) return
|
if (loading.value || autoRefreshFetching.value) return
|
||||||
if (isAnyModalOpen.value) return
|
if (isAnyModalOpen.value) return
|
||||||
if (menu.show) return
|
if (menu.show || showAccountToolsDropdown.value || showAutoRefreshDropdown.value) return
|
||||||
if (inAutoRefreshSilentWindow()) {
|
if (inAutoRefreshSilentWindow()) {
|
||||||
autoRefreshCountdown.value = Math.max(
|
autoRefreshCountdown.value = Math.max(
|
||||||
0,
|
0,
|
||||||
@ -1572,11 +1633,11 @@ const handleScroll = () => {
|
|||||||
menu.show = false
|
menu.show = false
|
||||||
}
|
}
|
||||||
|
|
||||||
// 点击外部关闭列设置下拉菜单
|
// 点击外部关闭顶部下拉菜单
|
||||||
const handleClickOutside = (event: MouseEvent) => {
|
const handleClickOutside = (event: MouseEvent) => {
|
||||||
const target = event.target as HTMLElement
|
const target = event.target as HTMLElement
|
||||||
if (columnDropdownRef.value && !columnDropdownRef.value.contains(target)) {
|
if (accountToolsDropdownRef.value && !accountToolsDropdownRef.value.contains(target)) {
|
||||||
showColumnDropdown.value = false
|
showAccountToolsDropdown.value = false
|
||||||
}
|
}
|
||||||
if (autoRefreshDropdownRef.value && !autoRefreshDropdownRef.value.contains(target)) {
|
if (autoRefreshDropdownRef.value && !autoRefreshDropdownRef.value.contains(target)) {
|
||||||
showAutoRefreshDropdown.value = false
|
showAutoRefreshDropdown.value = false
|
||||||
@ -1608,3 +1669,13 @@ onUnmounted(() => {
|
|||||||
document.removeEventListener('click', handleClickOutside)
|
document.removeEventListener('click', handleClickOutside)
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.account-tools-menu-item {
|
||||||
|
@apply flex w-full items-center gap-3 rounded-md px-3 py-2 text-sm text-gray-700 transition-colors hover:bg-gray-100 dark:text-gray-200 dark:hover:bg-gray-700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.account-tools-menu-icon {
|
||||||
|
@apply inline-flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-md;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user