diff --git a/.github/workflows/downstream-verify.yml b/.github/workflows/downstream-verify.yml
index dcba0bffd250..be465c5ec61d 100644
--- a/.github/workflows/downstream-verify.yml
+++ b/.github/workflows/downstream-verify.yml
@@ -130,6 +130,13 @@ jobs:
src/i18n/__tests__/accountConsoleLocales.spec.ts \
src/__tests__/integration/data-import.spec.ts \
src/components/admin/account-jobs/__tests__/AccountTaskDrawer.spec.ts \
+ src/stores/__tests__/accountJobs.spec.ts \
+ src/composables/__tests__/useTicketSelection.spec.ts \
+ src/api/__tests__/accountOperationIdempotency.spec.ts \
+ src/components/admin/account/__tests__/CodexTicketHarvestModal.spec.ts \
+ src/components/admin/account/__tests__/BatchTestAccountModal.spec.ts \
+ src/components/admin/account/__tests__/AccountBulkActionsBar.spec.ts \
+ src/components/admin/account/__tests__/AccountActionMenu.spark_shadow.spec.ts \
src/components/admin/account/__tests__/AccountConsoleFilters.spec.ts \
src/components/admin/account/__tests__/AccountUsageViews.spec.ts \
src/components/account/__tests__/AccountUsageCell.spec.ts \
diff --git a/backend/internal/repository/account_repo_codex_lifecycle_integration_test.go b/backend/internal/repository/account_repo_codex_lifecycle_integration_test.go
index 8f3986ff22e5..e1f5e54cd44a 100644
--- a/backend/internal/repository/account_repo_codex_lifecycle_integration_test.go
+++ b/backend/internal/repository/account_repo_codex_lifecycle_integration_test.go
@@ -110,14 +110,14 @@ func TestCodexTicketRepositoryConcurrentLeaseAndAccountInvalidation(t *testing.T
var lease string
require.NoError(t, integrationDB.QueryRowContext(ctx, `SELECT lease_id FROM openai_codex_ticket_runtime WHERE account_id=$1 AND model=$2`, a.ID, model).Scan(&lease))
require.Equal(t, claims[0].LeaseID, lease)
- // Disable and re-enable before IO completes must still reject its old result.
+ // Business status changes do not revoke a credential owner's ticket claim.
_, err = integrationDB.ExecContext(ctx, `UPDATE accounts SET status='disabled' WHERE id=$1`, a.ID)
require.NoError(t, err)
_, err = integrationDB.ExecContext(ctx, `UPDATE accounts SET status='active' WHERE id=$1`, a.ID)
require.NoError(t, err)
applied, err := r.FinishCodexTicket(ctx, claims[0], successfulTicket(a, model, now), service.CodexTicketResult{Success: true}, now)
require.NoError(t, err)
- require.False(t, applied)
+ require.True(t, applied)
// A legacy ticket without an identity stamp cannot survive changing owner.
raw, _ := json.Marshal(map[string]any{"codex_turn_ticket:" + model: map[string]any{"state": "gAAAAA" + strings.Repeat("B", 286), "length": 292, "expires_at": now.Add(time.Hour)}})
_, err = integrationDB.ExecContext(ctx, `UPDATE accounts SET extra=extra||$2::jsonb WHERE id=$1`, a.ID, string(raw))
@@ -129,6 +129,47 @@ func TestCodexTicketRepositoryConcurrentLeaseAndAccountInvalidation(t *testing.T
require.NotContains(t, loaded.Extra, "codex_turn_ticket:"+model)
}
+func TestCodexTicketInactiveAccountsHarvestAndRenewWithoutEnabling(t *testing.T) {
+ for _, accountType := range []string{service.AccountTypeOAuth, service.AccountTypeSetupToken} {
+ for _, status := range []string{"disabled", "error"} {
+ t.Run(accountType+"/"+status, func(t *testing.T) {
+ ctx := context.Background()
+ r, a, now := newTicketIntegrationAccount(t)
+ _, err := integrationDB.ExecContext(ctx, `UPDATE accounts SET type=$2,status=$3,schedulable=false,error_message='keep diagnostic' WHERE id=$1`, a.ID, accountType, status)
+ require.NoError(t, err)
+ a, err = r.GetByID(ctx, a.ID)
+ require.NoError(t, err)
+ const model = "gpt-6-astra"
+ claim, err := r.ClaimCodexTicket(ctx, a.ID, model, "manual", 0, true, false, now)
+ require.NoError(t, err)
+ ticket := successfulTicket(a, model, now)
+ applied, err := r.FinishCodexTicket(ctx, claim, ticket, service.CodexTicketResult{Success: true}, now)
+ require.NoError(t, err)
+ require.True(t, applied)
+ renewAt := ticket.ExpiresAt.Add(-time.Minute)
+ renewal, err := r.ClaimCodexTicket(ctx, a.ID, model, "renewal", 0, false, false, renewAt)
+ require.NoError(t, err)
+ applied, err = r.FinishCodexTicket(ctx, renewal, successfulTicket(a, model, renewAt), service.CodexTicketResult{Success: true}, renewAt)
+ require.NoError(t, err)
+ require.True(t, applied)
+ loaded, err := r.GetByID(ctx, a.ID)
+ require.NoError(t, err)
+ require.Equal(t, status, loaded.Status)
+ require.False(t, loaded.Schedulable)
+ require.Equal(t, "keep diagnostic", loaded.ErrorMessage)
+ // Deletion still revokes a live claim and rejects the late result.
+ claim, err = r.ClaimCodexTicket(ctx, a.ID, model, "manual-again", 0, true, true, renewAt)
+ require.NoError(t, err)
+ _, err = integrationDB.ExecContext(ctx, `UPDATE accounts SET deleted_at=NOW() WHERE id=$1`, a.ID)
+ require.NoError(t, err)
+ applied, err = r.FinishCodexTicket(ctx, claim, successfulTicket(a, model, renewAt), service.CodexTicketResult{Success: true}, renewAt)
+ require.NoError(t, err)
+ require.False(t, applied)
+ })
+ }
+ }
+}
+
func TestCodexTicketRepositoryInterruptedRenewalIsConsumed(t *testing.T) {
ctx := context.Background()
r, a, now := newTicketIntegrationAccount(t)
diff --git a/backend/internal/service/codex_ticket_lifecycle.go b/backend/internal/service/codex_ticket_lifecycle.go
index cb9b93ffd282..7945cf927e72 100644
--- a/backend/internal/service/codex_ticket_lifecycle.go
+++ b/backend/internal/service/codex_ticket_lifecycle.go
@@ -86,14 +86,16 @@ func CodexTicketAccountIdentity(a *Account) string {
}
func CodexTicketAccountEligible(a *Account) bool {
- return isOpenAICodexTicketAccount(a) && a.Status == StatusActive
+ // Ticket ownership is independent of business scheduling. Administrators may
+ // prepare and renew tickets while an OAuth-like account is disabled/in error.
+ return isOpenAICodexTicketAccount(a)
}
func CodexTicketFailure(code string) CodexTicketResult {
messages := map[string]string{
"ticket_disabled": "票据总开关未开启",
"ticket_proxy_missing": "未配置打票代理",
- "ticket_ineligible": "仅启用状态的 OpenAI OAuth/Setup Token 非影子账号可打票",
+ "ticket_ineligible": "仅 OpenAI OAuth/Setup Token 非影子账号可打票",
"ticket_model_invalid": "模型不在票据配置范围内",
"ticket_busy": "该账号和模型已有打票任务",
"ticket_interrupted": "上次请求已开始,结果未确认;不会自动重复发送",
diff --git a/backend/internal/service/codex_ticket_manual_test.go b/backend/internal/service/codex_ticket_manual_test.go
index b89799aa5cf6..6690927a8a54 100644
--- a/backend/internal/service/codex_ticket_manual_test.go
+++ b/backend/internal/service/codex_ticket_manual_test.go
@@ -191,3 +191,39 @@ func TestCodexTicketManualOnlyOneRequestAndPersistence(t *testing.T) {
})
}
}
+
+func TestCodexTicketEligibilityIndependentOfAccountStatus(t *testing.T) {
+ for _, accountType := range []string{AccountTypeOAuth, AccountTypeSetupToken} {
+ for _, status := range []string{StatusActive, "disabled", "error"} {
+ t.Run(accountType+"/"+status, func(t *testing.T) {
+ a := ticketTestAccount(41)
+ a.Type, a.Status, a.Schedulable, a.ErrorMessage = accountType, status, false, "preserve diagnostic"
+ r := newCodexTicketMemoryStore(a)
+ var calls atomic.Int64
+ upstream := &codexTicketFuncUpstream{do: func(*http.Request) (*http.Response, error) {
+ calls.Add(1)
+ resp := codexTicketResponse()
+ resp.Header.Set(openAICodexTurnStateHeader, fakeCodexTicketState(292))
+ return resp, nil
+ }}
+ s := ticketTestService(t, config.OpenAICodexTicketConfig{Enabled: true, HarvestProxyURL: "http://proxy.example.com:8080"}, upstream)
+ s.accountRepo = r
+ result := s.HarvestCodexTicket(context.Background(), a.ID, "gpt-6-astra", "manual", 0, false)
+ require.True(t, result.Success)
+ require.Equal(t, int64(1), calls.Load())
+ require.Equal(t, "ready", r.states["gpt-6-astra"].Phase)
+ require.Equal(t, status, a.Status)
+ require.False(t, a.Schedulable)
+ require.Equal(t, "preserve diagnostic", a.ErrorMessage)
+ })
+ }
+ }
+ for _, a := range []*Account{
+ {Platform: PlatformOpenAI, Type: AccountTypeAPIKey},
+ {Platform: PlatformAnthropic, Type: AccountTypeOAuth},
+ {Platform: PlatformOpenAI, Type: AccountTypeOAuth, ParentAccountID: func() *int64 { id := int64(1); return &id }()},
+ nil,
+ } {
+ require.False(t, CodexTicketAccountEligible(a))
+ }
+}
diff --git a/backend/migrations/245_codex_ticket_account_status_independent.sql b/backend/migrations/245_codex_ticket_account_status_independent.sql
new file mode 100644
index 000000000000..ebacf8add71b
--- /dev/null
+++ b/backend/migrations/245_codex_ticket_account_status_independent.sql
@@ -0,0 +1,25 @@
+-- Account status controls business scheduling, not ticket preparation/renewal.
+-- Preserve deletion and credential-owner invalidation, including in-flight IO.
+-- Existing stopped renewals are deliberately not enrolled by this migration.
+CREATE OR REPLACE FUNCTION invalidate_codex_ticket_account_claims() RETURNS TRIGGER AS $$
+DECLARE principal_changed BOOLEAN;
+BEGIN
+ principal_changed := NEW.platform IS DISTINCT FROM OLD.platform
+ OR NEW.type IS DISTINCT FROM OLD.type
+ OR NEW.parent_account_id IS DISTINCT FROM OLD.parent_account_id
+ OR COALESCE(NEW.credentials->>'chatgpt_account_id','') IS DISTINCT FROM COALESCE(OLD.credentials->>'chatgpt_account_id','')
+ OR COALESCE(NEW.credentials->>'chatgpt_user_id','') IS DISTINCT FROM COALESCE(OLD.credentials->>'chatgpt_user_id','')
+ OR COALESCE(NEW.credentials->>'organization_id','') IS DISTINCT FROM COALESCE(OLD.credentials->>'organization_id','')
+ OR (COALESCE(NEW.credentials->>'chatgpt_account_id','')='' AND COALESCE(NEW.credentials->>'chatgpt_user_id','')='' AND
+ COALESCE(NEW.credentials->>'email','') IS DISTINCT FROM COALESCE(OLD.credentials->>'email',''));
+ IF NEW.deleted_at IS DISTINCT FROM OLD.deleted_at OR principal_changed THEN
+ UPDATE openai_codex_ticket_runtime SET phase='stopped',next_at=NULL,lease_id='',lease_until=NULL,updated_at=NOW()
+ WHERE account_id=NEW.id;
+ NEW.extra := (SELECT COALESCE(jsonb_object_agg(e.key, CASE WHEN e.key LIKE 'codex_ticket_runtime:%'
+ THEN e.value || '{"phase":"stopped","next_attempt_at":null}'::jsonb ELSE e.value END), '{}'::jsonb)
+ FROM jsonb_each(CASE WHEN jsonb_typeof(NEW.extra)='object' THEN NEW.extra ELSE '{}'::jsonb END) e
+ WHERE NOT (principal_changed AND e.key LIKE 'codex_turn_ticket:%'));
+ END IF;
+ RETURN NEW;
+END;
+$$ LANGUAGE plpgsql;
diff --git a/frontend/src/__tests__/integration/data-import.spec.ts b/frontend/src/__tests__/integration/data-import.spec.ts
index b38651a029a6..fa67ac9ab9e0 100644
--- a/frontend/src/__tests__/integration/data-import.spec.ts
+++ b/frontend/src/__tests__/integration/data-import.spec.ts
@@ -1,3 +1,4 @@
+vi.mock('@/components/admin/account-jobs/AccountOperationDialog.vue', () => ({ default: { props: ['job', 'show'], template: '
' } }))
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'
import ImportDataModal from '@/components/admin/account/ImportDataModal.vue'
diff --git a/frontend/src/api/__tests__/accountOperationIdempotency.spec.ts b/frontend/src/api/__tests__/accountOperationIdempotency.spec.ts
new file mode 100644
index 000000000000..0c82a74102e6
--- /dev/null
+++ b/frontend/src/api/__tests__/accountOperationIdempotency.spec.ts
@@ -0,0 +1,29 @@
+import { webcrypto } from 'node:crypto'
+import { AxiosHeaders, type InternalAxiosRequestConfig } from 'axios'
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import { stabilizeAccountOperationKey, settleAccountOperation, clearAccountOperationKeys } from '../accountOperationIdempotency'
+const request = (key: string, ids = [1]) => ({ method: 'post', url: '/admin/accounts/batch-delete', data: { account_ids: ids }, headers: new AxiosHeaders({ 'Idempotency-Key': key }) }) as InternalAxiosRequestConfig
+afterEach(() => { clearAccountOperationKeys(); vi.unstubAllGlobals() })
+describe('uncertain account operation submission', () => {
+ it('reuses the key after a lost response, isolates changed targets and releases confirmed submissions', async () => {
+ vi.stubGlobal('crypto', webcrypto)
+ const first = request('first'); await stabilizeAccountOperationKey(first)
+ const retry = request('new-key'); await stabilizeAccountOperationKey(retry)
+ expect(retry.headers.get('Idempotency-Key')).toBe('first')
+ const changed = request('changed', [2]); await stabilizeAccountOperationKey(changed)
+ expect(changed.headers.get('Idempotency-Key')).toBe('changed')
+ settleAccountOperation(retry)
+ const next = request('next'); await stabilizeAccountOperationKey(next)
+ expect(next.headers.get('Idempotency-Key')).toBe('next')
+ })
+ it('does not retain another administrator session or affect non-operation requests', async () => {
+ vi.stubGlobal('crypto', webcrypto)
+ await stabilizeAccountOperationKey(request('old'))
+ clearAccountOperationKeys()
+ const next = request('next'); await stabilizeAccountOperationKey(next)
+ expect(next.headers.get('Idempotency-Key')).toBe('next')
+ const unrelated = request('unrelated'); unrelated.url = '/admin/settings'
+ await stabilizeAccountOperationKey(unrelated)
+ expect(unrelated.headers.get('Idempotency-Key')).toBe('unrelated')
+ })
+})
diff --git a/frontend/src/api/accountOperationIdempotency.ts b/frontend/src/api/accountOperationIdempotency.ts
new file mode 100644
index 000000000000..adda06c05c38
--- /dev/null
+++ b/frontend/src/api/accountOperationIdempotency.ts
@@ -0,0 +1,24 @@
+import type { InternalAxiosRequestConfig } from 'axios'
+
+// Keep only unresolved submissions. A timeout may have happened after the
+// server committed the operation; retrying that payload must reuse its key.
+const unresolved = new Map()
+let session = 0
+type OperationConfig = InternalAxiosRequestConfig & { accountOperationSignature?: string }
+export async function stabilizeAccountOperationKey(config: OperationConfig): Promise {
+ const url = String(config.url || '')
+ const key = config.headers?.get('Idempotency-Key')
+ if (!key || !/^\/admin\/(accounts(?:\/|$)|account-jobs(?:\/|$))/.test(url)) return
+ const epoch = session
+ const payload = `${config.method}:${url}:${typeof config.data === 'string' ? config.data : JSON.stringify(config.data)}`
+ const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(payload))
+ if (epoch !== session) throw new Error('Account operation session changed')
+ const signature = `${epoch}:` + Array.from(new Uint8Array(digest), n => n.toString(16).padStart(2, '0')).join('')
+ if (!unresolved.has(signature)) unresolved.set(signature, String(key))
+ config.headers.set('Idempotency-Key', unresolved.get(signature)!)
+ config.accountOperationSignature = signature
+}
+export function settleAccountOperation(config?: OperationConfig): void {
+ if (config?.accountOperationSignature) unresolved.delete(config.accountOperationSignature)
+}
+export function clearAccountOperationKeys(): void { unresolved.clear(); session++ }
diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts
index 245896cd3ecd..57d310e4aae1 100644
--- a/frontend/src/api/client.ts
+++ b/frontend/src/api/client.ts
@@ -14,6 +14,7 @@ import {
} from './adminUIRequest'
import { refreshAuthTokens } from './tokenRefresh'
import { getAPIBaseURL } from './url'
+import { settleAccountOperation, stabilizeAccountOperationKey } from './accountOperationIdempotency'
export { buildApiUrl, buildGatewayUrl } from './url'
// ==================== Axios Instance Configuration ====================
@@ -39,7 +40,8 @@ const getUserTimezone = (): string => {
}
apiClient.interceptors.request.use(
- (config: InternalAxiosRequestConfig) => {
+ async (config: InternalAxiosRequestConfig) => {
+ await stabilizeAccountOperationKey(config)
// Attach token from localStorage
const token = localStorage.getItem('auth_token')
if (token && config.headers) {
@@ -80,6 +82,7 @@ apiClient.interceptors.request.use(
apiClient.interceptors.response.use(
(response: AxiosResponse) => {
+ settleAccountOperation(response.config)
// Unwrap standard API response format { code, message, data }
const apiResponse = response.data as ApiResponse
if (apiResponse && typeof apiResponse === 'object' && 'code' in apiResponse) {
@@ -112,6 +115,7 @@ apiClient.interceptors.response.use(
// Handle common errors
if (error.response) {
const { status, data } = error.response
+ if (status >= 400 && status < 500 && status !== 408) settleAccountOperation(error.config)
const url = String(error.config?.url || '')
// Validate `data` shape to avoid HTML error pages breaking our error handling.
diff --git a/frontend/src/components/account/BulkEditAccountModal.vue b/frontend/src/components/account/BulkEditAccountModal.vue
index 8d4452fef50f..bb58d1963614 100644
--- a/frontend/src/components/account/BulkEditAccountModal.vue
+++ b/frontend/src/components/account/BulkEditAccountModal.vue
@@ -1,5 +1,5 @@
-
-
+
()
const { t } = useI18n()
+const operationJob = ref(null)
+watch(() => props.show, show => { if (show) operationJob.value = null })
const appStore = useAppStore()
// Platform awareness
@@ -2318,8 +2320,8 @@ const submitBulkUpdate = async (baseUpdates: Record) => {
})
: await adminAPI.accounts.bulkUpdate(props.accountIds, updates)
pendingUpdatesForConfirm.value = null
+ operationJob.value = job
emit('updated', job)
- handleClose()
} catch (error: any) {
// 兜底:多平台混合场景下,预检查跳过,由后端 409 触发确认框
if (error.status === 409 && error.error === 'mixed_channel_warning') {
diff --git a/frontend/src/components/account/CreateAccountModal.vue b/frontend/src/components/account/CreateAccountModal.vue
index 2d4a1683ae5e..3ba0257a335f 100644
--- a/frontend/src/components/account/CreateAccountModal.vue
+++ b/frontend/src/components/account/CreateAccountModal.vue
@@ -1,5 +1,5 @@
-
-
+
()
+const operationJob = ref(null)
+watch(() => props.show, show => { if (show) operationJob.value = null })
const emit = defineEmits<{
close: []
created: [job?: AccountJob]
@@ -6773,8 +6776,8 @@ const handleOpenAIImportCodexSession = async (content: string) => {
update_existing: true
})
+ operationJob.value = job
emit('created', job)
- handleClose()
} catch (error: any) {
oauthClient.error.value =
error.response?.data?.detail ||
diff --git a/frontend/src/components/account/__tests__/BulkEditAccountModal.spec.ts b/frontend/src/components/account/__tests__/BulkEditAccountModal.spec.ts
index 97f314238a09..460444713485 100644
--- a/frontend/src/components/account/__tests__/BulkEditAccountModal.spec.ts
+++ b/frontend/src/components/account/__tests__/BulkEditAccountModal.spec.ts
@@ -1,3 +1,4 @@
+vi.mock('@/components/admin/account-jobs/AccountOperationDialog.vue', () => ({ default: { props: ['job', 'show'], template: '
' } }))
import { describe, expect, it, vi, beforeEach } from 'vitest'
import { flushPromises, mount } from '@vue/test-utils'
import { nextTick } from 'vue'
diff --git a/frontend/src/components/account/__tests__/CreateAccountModal.spec.ts b/frontend/src/components/account/__tests__/CreateAccountModal.spec.ts
index 17255f391c96..bb9caf075a4b 100644
--- a/frontend/src/components/account/__tests__/CreateAccountModal.spec.ts
+++ b/frontend/src/components/account/__tests__/CreateAccountModal.spec.ts
@@ -1,3 +1,4 @@
+vi.mock('@/components/admin/account-jobs/AccountOperationDialog.vue', () => ({ default: { props: ['job', 'show'], template: '
' } }))
import { defineComponent, type PropType } from 'vue'
import { flushPromises, mount } from '@vue/test-utils'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
diff --git a/frontend/src/components/admin/account-jobs/AccountOperationConfirmDialog.vue b/frontend/src/components/admin/account-jobs/AccountOperationConfirmDialog.vue
new file mode 100644
index 000000000000..cc8f7913f541
--- /dev/null
+++ b/frontend/src/components/admin/account-jobs/AccountOperationConfirmDialog.vue
@@ -0,0 +1,29 @@
+
+
+ {{ message }}
+ {{ error }}
+
+
+
+
+
+
+
diff --git a/frontend/src/components/admin/account-jobs/AccountOperationDialog.vue b/frontend/src/components/admin/account-jobs/AccountOperationDialog.vue
new file mode 100644
index 000000000000..e485eb310bba
--- /dev/null
+++ b/frontend/src/components/admin/account-jobs/AccountOperationDialog.vue
@@ -0,0 +1,48 @@
+
+
+
+
+ {{ t('admin.accountTasks.configuration') }}
+
+
+
+
+
+
+
+
+
diff --git a/frontend/src/components/admin/account-jobs/AccountOperationProgress.vue b/frontend/src/components/admin/account-jobs/AccountOperationProgress.vue
new file mode 100644
index 000000000000..fa9f821a253f
--- /dev/null
+++ b/frontend/src/components/admin/account-jobs/AccountOperationProgress.vue
@@ -0,0 +1,144 @@
+
+
+
+
+
+
+
+
+
{{ job.cancel_requested_at && !terminal ? t('admin.accountTasks.stopping') : statusLabel(job.status) }}
+
{{ t('admin.accountTasks.progress', { processed: job.processed_count, total: job.target_count }) }}
+
+
+
{{ progress }}%
+
+
+
+
{{ job.succeeded_count }}{{ t('admin.accountTasks.statuses.succeeded') }}
+
{{ job.failed_count }}{{ t('admin.accountTasks.statuses.failed') }}
+
{{ job.canceled_count }}{{ t('admin.accountTasks.statuses.canceled') }}
+
+
{{ t('admin.accountTasks.reconnecting') }}
+
{{ error || job.error_message }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{{ itemLabel(item) }}
+
{{ item.metadata.model_id }}
+
+
{{ ticketResult(item)?.code === 'ticket_skipped' ? t('admin.accountTasks.skipped') : statusLabel(item.status) }}
+
+
{{ item.error_message }}
+
{{ ticketResultLabel(item) }}
+
{{ item.metadata.latency_ms }} ms
+
+
{{ t(store.loadingCurrent ? 'common.loading' : store.itemFilter ? 'admin.accountTasks.noFailures' : 'admin.accountTasks.awaitingResults') }}
+
+
+
{{ store.itemPage.page }} / {{ Math.ceil(store.itemPage.total / store.itemPage.pageSize) }}
+
+
+
+
+
{{ t(terminal ? 'admin.accountTasks.savedInHistory' : 'admin.accountTasks.continuesInBackground') }}
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/src/components/admin/account-jobs/AccountTaskDrawer.vue b/frontend/src/components/admin/account-jobs/AccountTaskDrawer.vue
index 7595a69fe6ec..b45bc4db9057 100644
--- a/frontend/src/components/admin/account-jobs/AccountTaskDrawer.vue
+++ b/frontend/src/components/admin/account-jobs/AccountTaskDrawer.vue
@@ -1,397 +1,80 @@
+
+
+
+
+
+
{{ t('admin.accountTasks.description') }}
+
+
+
+
+
{{ t(store.loadingJobs ? 'common.loading' : 'admin.accountTasks.noTasks') }}
+
+
+ {{ store.jobPage.page }} / {{ Math.max(1, Math.ceil(store.jobPage.total / store.jobPage.pageSize)) }}
+
+
+
+
-
@@ -73,8 +74,9 @@ import { useResizeObserver, useWindowSize } from '@vueuse/core'
import { useI18n } from 'vue-i18n'
import { Icon } from '@/components/icons'
import type { Account } from '@/types'
+import { canManageCodexTickets } from '@/utils/codexTicketEligibility'
-const props = defineProps<{ show: boolean; account: Account | null; anchorRect: DOMRect | null }>()
+const props = defineProps<{ show: boolean; account: Account | null; anchorRect: DOMRect | null; busy?: boolean }>()
const emit = defineEmits(['harvest-tickets', 'close', 'test', 'stats', 'schedule', 'duplicate', 'reauth', 'refresh-token', 'recover-state', 'recover-cindy-balance', 'reset-quota', 'set-privacy', 'create-spark-shadow'])
const { t } = useI18n()
const menuRef = ref(null)
diff --git a/frontend/src/components/admin/account/AccountBulkActionsBar.vue b/frontend/src/components/admin/account/AccountBulkActionsBar.vue
index 21333b03e900..018fb6682ddc 100644
--- a/frontend/src/components/admin/account/AccountBulkActionsBar.vue
+++ b/frontend/src/components/admin/account/AccountBulkActionsBar.vue
@@ -46,7 +46,7 @@
-
+
@@ -83,6 +83,7 @@ defineProps<{
totalResults: number
selectingAll: boolean
allResultsSelected: boolean
+ canHarvestTickets?: boolean
}>()
defineEmits([
diff --git a/frontend/src/components/admin/account/AccountBulkTaxonomyModal.vue b/frontend/src/components/admin/account/AccountBulkTaxonomyModal.vue
index 40271e7c1c53..e0ff2522b437 100644
--- a/frontend/src/components/admin/account/AccountBulkTaxonomyModal.vue
+++ b/frontend/src/components/admin/account/AccountBulkTaxonomyModal.vue
@@ -1,5 +1,5 @@
-
-
+