Spaces:
Sleeping
Sleeping
| <template> | |
| <div class="space-y-8 relative"> | |
| <!-- 全局加载遮罩 --> | |
| <Teleport to="body"> | |
| <div | |
| v-if="isOperating" | |
| class="fixed inset-0 z-[200] flex items-center justify-center bg-background/80 backdrop-blur-sm" | |
| > | |
| <div class="flex flex-col items-center gap-4 rounded-2xl border border-border bg-card p-8 shadow-lg"> | |
| <svg class="h-10 w-10 animate-spin text-primary" viewBox="0 0 24 24" fill="none"> | |
| <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> | |
| <div class="flex flex-col items-center gap-2"> | |
| <p class="text-sm font-medium text-foreground"> | |
| {{ batchProgress ? `处理中 ${batchProgress.current}/${batchProgress.total}` : '操作处理中...' }} | |
| </p> | |
| <div v-if="batchProgress" class="w-48 h-1.5 bg-muted rounded-full overflow-hidden"> | |
| <div | |
| class="h-full bg-primary transition-all duration-300" | |
| :style="{ width: `${(batchProgress.current / batchProgress.total) * 100}%` }" | |
| ></div> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| </Teleport> | |
| <section class="rounded-3xl border border-border bg-card p-6"> | |
| <div class="flex flex-wrap items-center justify-between gap-4"> | |
| <div class="grid w-full grid-cols-2 gap-3 sm:flex sm:w-auto sm:items-center"> | |
| <input | |
| v-model="searchQuery" | |
| type="text" | |
| placeholder="搜索账号 ID" | |
| class="w-full rounded-full border border-input bg-background px-4 py-2 text-sm sm:w-48" | |
| /> | |
| <SelectMenu | |
| v-model="statusFilter" | |
| :options="statusOptions" | |
| class="!w-full sm:!w-40" | |
| /> | |
| </div> | |
| <div class="flex w-full flex-wrap items-center gap-3 text-xs text-muted-foreground sm:w-auto sm:flex-nowrap"> | |
| <Checkbox :modelValue="allSelected" @update:modelValue="toggleSelectAll"> | |
| 全选 | |
| </Checkbox> | |
| <span>已选 {{ selectedCount }} / {{ filteredAccounts.length }} 个账号</span> | |
| <div class="ml-auto flex items-center gap-2 sm:ml-0"> | |
| <button | |
| type="button" | |
| class="inline-flex h-8 w-8 items-center justify-center rounded-full border border-border text-muted-foreground transition-colors | |
| hover:border-primary hover:text-primary" | |
| :class="viewMode === 'table' ? 'bg-accent text-accent-foreground' : ''" | |
| @click="viewMode = 'table'" | |
| aria-label="列表视图" | |
| > | |
| <svg aria-hidden="true" viewBox="0 0 24 24" class="h-4 w-4" fill="currentColor"> | |
| <path d="M4 6h16v2H4V6zm0 5h16v2H4v-2zm0 5h16v2H4v-2z" /> | |
| </svg> | |
| </button> | |
| <button | |
| type="button" | |
| class="inline-flex h-8 w-8 items-center justify-center rounded-full border border-border text-muted-foreground transition-colors | |
| hover:border-primary hover:text-primary" | |
| :class="viewMode === 'card' ? 'bg-accent text-accent-foreground' : ''" | |
| @click="viewMode = 'card'" | |
| aria-label="卡片视图" | |
| > | |
| <svg aria-hidden="true" viewBox="0 0 24 24" class="h-4 w-4" fill="currentColor"> | |
| <path d="M4 6h7v6H4V6zm9 0h7v6h-7V6zM4 14h7v4H4v-4zm9 0h7v4h-7v-4z" /> | |
| </svg> | |
| </button> | |
| </div> | |
| </div> | |
| </div> | |
| <div class="mt-4 flex flex-wrap items-center gap-2"> | |
| <button | |
| class="rounded-full border border-border px-4 py-2 text-sm font-medium text-foreground transition-colors | |
| hover:border-primary hover:text-primary disabled:cursor-not-allowed disabled:opacity-50" | |
| :disabled="isLoading" | |
| @click="refreshAccounts" | |
| > | |
| 刷新列表 | |
| </button> | |
| <button | |
| class="rounded-full border border-border px-4 py-2 text-sm font-medium text-foreground transition-colors | |
| hover:border-primary hover:text-primary" | |
| @click="openConfigPanel" | |
| > | |
| 账户配置 | |
| </button> | |
| <button | |
| class="rounded-full border border-border px-4 py-2 text-sm font-medium text-foreground transition-colors | |
| hover:border-primary hover:text-primary disabled:cursor-not-allowed disabled:opacity-50" | |
| :disabled="isRegistering || isRefreshing" | |
| @click="openRegisterModal" | |
| > | |
| 添加账户 | |
| </button> | |
| <button | |
| class="relative rounded-full border border-border px-4 py-2 text-sm font-medium transition-colors | |
| hover:border-primary hover:text-primary" | |
| @click="openTaskModal" | |
| > | |
| <span class="flex items-center gap-2"> | |
| 任务管理 | |
| <template v-if="isTaskRunning"> | |
| <span class="flex items-center gap-1.5 text-xs text-sky-500"> | |
| <span class="relative flex h-2 w-2"> | |
| <span class="absolute inline-flex h-full w-full animate-ping rounded-full bg-sky-500 opacity-75"></span> | |
| <span class="relative inline-flex h-2 w-2 rounded-full bg-sky-500"></span> | |
| </span> | |
| {{ taskProgressText }} | |
| </span> | |
| </template> | |
| </span> | |
| </button> | |
| <div ref="moreActionsRef" class="relative"> | |
| <button | |
| class="flex items-center gap-2 rounded-full border border-input bg-background px-4 py-2 text-sm font-medium | |
| text-foreground transition-colors hover:border-primary" | |
| :class="showMoreActions ? 'bg-accent text-accent-foreground' : ''" | |
| @click="toggleMoreActions" | |
| > | |
| 更多操作 | |
| <svg aria-hidden="true" viewBox="0 0 20 20" class="h-4 w-4" fill="currentColor"> | |
| <path d="M5 7l5 6 5-6H5z" /> | |
| </svg> | |
| </button> | |
| <div | |
| v-if="showMoreActions" | |
| class="absolute right-0 z-10 mt-2 w-full space-y-1 rounded-2xl border border-border bg-card p-2 shadow-lg" | |
| > | |
| <button | |
| type="button" | |
| class="flex w-full items-center justify-between rounded-xl px-3 py-2.5 text-left text-sm text-foreground transition-colors | |
| hover:bg-accent" | |
| @click="triggerImportFile(); closeMoreActions()" | |
| > | |
| 导入文件 | |
| </button> | |
| <button | |
| type="button" | |
| class="flex w-full items-center justify-between rounded-xl px-3 py-2.5 text-left text-sm text-foreground transition-colors | |
| hover:bg-accent" | |
| @click="openExportModal(); closeMoreActions()" | |
| > | |
| 导出账户 | |
| </button> | |
| <div class="my-1 border-t border-border/60"></div> | |
| <button | |
| type="button" | |
| class="flex w-full items-center justify-between rounded-xl px-3 py-2.5 text-left text-sm transition-colors" | |
| :class="isRegistering | |
| ? 'cursor-not-allowed text-muted-foreground' | |
| : 'text-foreground hover:bg-accent'" | |
| :disabled="isRegistering" | |
| @click="handleRefreshExpiring(); closeMoreActions()" | |
| > | |
| 刷新过期 | |
| </button> | |
| <button | |
| type="button" | |
| class="flex w-full items-center justify-between rounded-xl px-3 py-2.5 text-left text-sm transition-colors" | |
| :class="!selectedCount || isRegistering | |
| ? 'cursor-not-allowed text-muted-foreground' | |
| : 'text-foreground hover:bg-accent'" | |
| :disabled="!selectedCount || isRegistering" | |
| @click="handleRefreshSelected(); closeMoreActions()" | |
| > | |
| 刷新选中 | |
| </button> | |
| <div class="my-1 border-t border-border/60"></div> | |
| <button | |
| type="button" | |
| class="flex w-full items-center justify-between rounded-xl px-3 py-2.5 text-left text-sm transition-colors" | |
| :class="!selectedCount || isOperating | |
| ? 'cursor-not-allowed text-muted-foreground' | |
| : 'text-foreground hover:bg-accent'" | |
| :disabled="!selectedCount || isOperating" | |
| @click="handleBulkEnable(); closeMoreActions()" | |
| > | |
| <span v-if="isOperating" class="flex items-center gap-2"> | |
| <svg class="h-4 w-4 animate-spin" viewBox="0 0 24 24" fill="none"> | |
| <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> | |
| 处理中... | |
| </span> | |
| <span v-else>批量启用</span> | |
| </button> | |
| <button | |
| type="button" | |
| class="flex w-full items-center justify-between rounded-xl px-3 py-2.5 text-left text-sm transition-colors" | |
| :class="!selectedCount || isOperating | |
| ? 'cursor-not-allowed text-muted-foreground' | |
| : 'text-foreground hover:bg-accent'" | |
| :disabled="!selectedCount || isOperating" | |
| @click="handleBulkDisable(); closeMoreActions()" | |
| > | |
| <span v-if="isOperating" class="flex items-center gap-2"> | |
| <svg class="h-4 w-4 animate-spin" viewBox="0 0 24 24" fill="none"> | |
| <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> | |
| 处理中... | |
| </span> | |
| <span v-else>批量禁用</span> | |
| </button> | |
| <button | |
| type="button" | |
| class="flex w-full items-center justify-between rounded-xl px-3 py-2.5 text-left text-sm transition-colors" | |
| :class="!selectedCount || isOperating | |
| ? 'cursor-not-allowed text-muted-foreground' | |
| : 'text-destructive hover:bg-destructive/10'" | |
| :disabled="!selectedCount || isOperating" | |
| @click="handleBulkDelete(); closeMoreActions()" | |
| > | |
| <span v-if="isOperating" class="flex items-center gap-2"> | |
| <svg class="h-4 w-4 animate-spin" viewBox="0 0 24 24" fill="none"> | |
| <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> | |
| 处理中... | |
| </span> | |
| <span v-else>批量删除</span> | |
| </button> | |
| </div> | |
| </div> | |
| </div> | |
| <div v-if="viewMode === 'card'" class="mt-6 grid grid-cols-1 gap-3 md:grid-cols-2 xl:grid-cols-3"> | |
| <div | |
| v-for="account in paginatedAccounts" | |
| :key="account.id" | |
| class="rounded-2xl border border-border bg-card p-4" | |
| :class="rowClass(account)" | |
| @click="toggleSelect(account.id)" | |
| > | |
| <div class="flex items-start justify-between gap-3"> | |
| <div> | |
| <p class="text-xs text-muted-foreground">账号 ID</p> | |
| <p class="mt-1 font-mono text-xs text-foreground">{{ account.id }}</p> | |
| </div> | |
| <Checkbox | |
| :modelValue="selectedIds.has(account.id)" | |
| @update:modelValue="toggleSelect(account.id)" | |
| @click.stop | |
| /> | |
| </div> | |
| <div class="mt-4 grid grid-cols-2 gap-3 text-xs text-muted-foreground"> | |
| <div> | |
| <p>状态</p> | |
| <p class="mt-1 flex flex-wrap items-center gap-1.5 text-sm font-semibold text-foreground"> | |
| <span | |
| class="inline-flex items-center rounded-full border border-border px-2 py-0.5 text-xs" | |
| :class="statusClass(account)" | |
| > | |
| {{ statusLabel(account) }} | |
| </span> | |
| <span | |
| v-if="account.trial_days_remaining != null" | |
| class="inline-flex items-center gap-1 rounded-full border border-border px-2 py-0.5 text-xs font-medium" | |
| :class="trialBadgeClass(account.trial_days_remaining)" | |
| > | |
| <svg class="h-3 w-3 shrink-0" viewBox="0 0 16 16" fill="currentColor" xmlns="http://www.w3.org/2000/svg"> | |
| <path d="M5 1a1 1 0 0 1 1 1v.5h4V2a1 1 0 0 1 2 0v.5h1A1.5 1.5 0 0 1 14.5 4v9A1.5 1.5 0 0 1 13 14.5H3A1.5 1.5 0 0 1 1.5 13V4A1.5 1.5 0 0 1 3 2.5h1V2a1 1 0 0 1 1-1zm-2 4v1.5h10V5H3zm0 3v5h10V8H3z"/> | |
| </svg> | |
| {{ account.trial_days_remaining }}天 | |
| </span> | |
| </p> | |
| </div> | |
| <div> | |
| <p>剩余时间</p> | |
| <p class="mt-1 text-sm font-semibold" :class="remainingClass(account)"> | |
| {{ displayRemaining(account.remaining_display) }} | |
| </p> | |
| <p v-if="account.expires_at" class="mt-1 text-[11px]"> | |
| {{ account.expires_at }} | |
| </p> | |
| </div> | |
| <div> | |
| <p>配额</p> | |
| <div class="mt-1"> | |
| <QuotaBadge v-if="account.quota_status" :quota-status="account.quota_status" /> | |
| <span v-else class="text-xs text-muted-foreground">-</span> | |
| </div> | |
| </div> | |
| <div> | |
| <p>失败数</p> | |
| <p class="mt-1 text-sm font-semibold text-foreground">{{ account.failure_count }}</p> | |
| </div> | |
| <div> | |
| <p>成功数</p> | |
| <p class="mt-1 text-sm font-semibold text-foreground">{{ account.conversation_count }}</p> | |
| </div> | |
| </div> | |
| <div class="mt-4 flex flex-wrap items-center gap-2"> | |
| <button | |
| class="rounded-full border border-border px-3 py-1 text-xs text-foreground transition-colors | |
| hover:border-primary hover:text-primary" | |
| @click.stop="openEdit(account.id)" | |
| > | |
| 编辑 | |
| </button> | |
| <button | |
| v-if="shouldShowEnable(account)" | |
| class="rounded-full border border-border px-3 py-1 text-xs text-foreground transition-colors | |
| hover:border-primary hover:text-primary" | |
| @click.stop | |
| @click="handleEnable(account.id)" | |
| > | |
| 启用 | |
| </button> | |
| <button | |
| v-else | |
| class="rounded-full border border-border px-3 py-1 text-xs text-foreground transition-colors | |
| hover:border-primary hover:text-primary" | |
| @click.stop | |
| @click="handleDisable(account.id)" | |
| > | |
| 禁用 | |
| </button> | |
| <button | |
| class="rounded-full border border-border px-3 py-1 text-xs text-destructive transition-colors | |
| hover:border-destructive hover:text-destructive" | |
| @click.stop | |
| @click="handleDelete(account.id)" | |
| > | |
| 删除 | |
| </button> | |
| </div> | |
| </div> | |
| <div v-if="!filteredAccounts.length && !isLoading" class="rounded-2xl border border-border bg-background p-4 text-center text-xs text-muted-foreground"> | |
| 暂无账号数据,请检查后台配置。 | |
| </div> | |
| </div> | |
| <div v-else class="relative mt-6 overflow-x-auto overflow-y-visible"> | |
| <table class="min-w-full text-left text-sm"> | |
| <thead class="text-xs uppercase tracking-[0.2em] text-muted-foreground"> | |
| <tr> | |
| <th class="py-3 pr-4"> | |
| <Checkbox :modelValue="allSelected" @update:modelValue="toggleSelectAll" /> | |
| </th> | |
| <th class="py-3 pr-6">账号 ID</th> | |
| <th class="py-3 pr-6">状态</th> | |
| <th class="py-3 pr-6"> | |
| <span class="inline-flex items-center gap-2"> | |
| 剩余/过期 | |
| <HelpTip text="过期时间为 12 小时,账户过期以北京时间为准。" /> | |
| </span> | |
| </th> | |
| <th class="py-3 pr-6">配额</th> | |
| <th class="py-3 pr-6">失败数</th> | |
| <th class="py-3 pr-6">成功数</th> | |
| <th class="py-3 text-right">操作</th> | |
| </tr> | |
| </thead> | |
| <tbody class="text-sm text-foreground"> | |
| <tr v-if="!filteredAccounts.length && !isLoading"> | |
| <td colspan="8" class="py-8 text-center text-muted-foreground"> | |
| 暂无账号数据,请检查后台配置。 | |
| </td> | |
| </tr> | |
| <tr | |
| v-for="account in paginatedAccounts" | |
| :key="account.id" | |
| class="border-t border-border" | |
| :class="rowClass(account)" | |
| @click="toggleSelect(account.id)" | |
| > | |
| <td class="py-4 pr-4" @click.stop> | |
| <Checkbox | |
| :modelValue="selectedIds.has(account.id)" | |
| @update:modelValue="toggleSelect(account.id)" | |
| /> | |
| </td> | |
| <td class="py-4 pr-6 font-mono text-xs text-foreground"> | |
| {{ account.id }} | |
| </td> | |
| <td class="py-4 pr-6"> | |
| <div class="flex flex-wrap items-center gap-1.5"> | |
| <span | |
| class="inline-flex items-center rounded-full border border-border px-3 py-1 text-xs" | |
| :class="statusClass(account)" | |
| > | |
| {{ statusLabel(account) }} | |
| </span> | |
| <span | |
| v-if="account.trial_days_remaining != null" | |
| class="inline-flex items-center gap-1 rounded-full border border-border px-2 py-1 text-xs font-medium" | |
| :class="trialBadgeClass(account.trial_days_remaining)" | |
| > | |
| <svg class="h-3 w-3 shrink-0" viewBox="0 0 16 16" fill="currentColor" xmlns="http://www.w3.org/2000/svg"> | |
| <path d="M5 1a1 1 0 0 1 1 1v.5h4V2a1 1 0 0 1 2 0v.5h1A1.5 1.5 0 0 1 14.5 4v9A1.5 1.5 0 0 1 13 14.5H3A1.5 1.5 0 0 1 1.5 13V4A1.5 1.5 0 0 1 3 2.5h1V2a1 1 0 0 1 1-1zm-2 4v1.5h10V5H3zm0 3v5h10V8H3z"/> | |
| </svg> | |
| {{ account.trial_days_remaining }}天 | |
| </span> | |
| </div> | |
| </td> | |
| <td class="py-4 pr-6"> | |
| <div class="text-sm font-semibold" :class="remainingClass(account)"> | |
| {{ displayRemaining(account.remaining_display) }} | |
| </div> | |
| <span v-if="account.expires_at" class="block text-[11px] text-muted-foreground"> | |
| {{ account.expires_at }} | |
| </span> | |
| </td> | |
| <td class="py-4 pr-6"> | |
| <QuotaBadge v-if="account.quota_status" :quota-status="account.quota_status" /> | |
| <span v-else class="text-xs text-muted-foreground">-</span> | |
| </td> | |
| <td class="py-4 pr-6 text-xs text-muted-foreground"> | |
| {{ account.failure_count }} | |
| </td> | |
| <td class="py-4 pr-6 text-xs text-muted-foreground"> | |
| {{ account.conversation_count }} | |
| </td> | |
| <td class="py-4 text-right"> | |
| <div class="flex flex-wrap justify-end gap-2"> | |
| <button | |
| class="rounded-full border border-border px-3 py-1 text-xs text-foreground transition-colors | |
| hover:border-primary hover:text-primary" | |
| @click.stop="openEdit(account.id)" | |
| > | |
| 编辑 | |
| </button> | |
| <button | |
| v-if="shouldShowEnable(account)" | |
| class="rounded-full border border-border px-3 py-1 text-xs text-foreground transition-colors | |
| hover:border-primary hover:text-primary" | |
| @click.stop="handleEnable(account.id)" | |
| > | |
| 启用 | |
| </button> | |
| <button | |
| v-else | |
| class="rounded-full border border-border px-3 py-1 text-xs text-foreground transition-colors | |
| hover:border-primary hover:text-primary" | |
| @click.stop="handleDisable(account.id)" | |
| > | |
| 禁用 | |
| </button> | |
| <button | |
| class="rounded-full border border-border px-3 py-1 text-xs text-destructive transition-colors | |
| hover:border-destructive hover:text-destructive" | |
| @click.stop="handleDelete(account.id)" | |
| > | |
| 删除 | |
| </button> | |
| </div> | |
| </td> | |
| </tr> | |
| </tbody> | |
| </table> | |
| </div> | |
| <!-- Pagination Controls --> | |
| <div v-if="filteredAccounts.length > pageSize" class="mt-6 flex items-center justify-between"> | |
| <div class="text-sm text-muted-foreground"> | |
| 显示 {{ (currentPage - 1) * pageSize + 1 }}-{{ Math.min(currentPage * pageSize, filteredAccounts.length) }} / 共 {{ filteredAccounts.length }} 个账户 | |
| </div> | |
| <div class="flex items-center gap-2"> | |
| <button | |
| class="rounded-full border border-border px-4 py-2 text-sm transition-colors hover:border-primary hover:text-primary disabled:cursor-not-allowed disabled:opacity-50" | |
| :disabled="currentPage === 1" | |
| @click="currentPage--" | |
| > | |
| 上一页 | |
| </button> | |
| <span class="text-sm text-muted-foreground">{{ currentPage }} / {{ totalPages }}</span> | |
| <button | |
| class="rounded-full border border-border px-4 py-2 text-sm transition-colors hover:border-primary hover:text-primary disabled:cursor-not-allowed disabled:opacity-50" | |
| :disabled="currentPage === totalPages" | |
| @click="currentPage++" | |
| > | |
| 下一页 | |
| </button> | |
| </div> | |
| </div> | |
| </section> | |
| </div> | |
| <Teleport to="body"> | |
| <div v-if="isRegisterOpen" class="fixed inset-0 z-[100] flex items-center justify-center bg-black/30 px-4"> | |
| <div class="flex max-h-[90vh] w-full max-w-lg flex-col overflow-hidden rounded-3xl border border-border bg-card shadow-xl"> | |
| <div class="flex items-center justify-between border-b border-border/60 px-6 py-4"> | |
| <div> | |
| <p class="text-sm font-medium text-foreground">添加账户</p> | |
| <p class="mt-1 text-xs text-muted-foreground"> | |
| {{ addMode === 'register' ? '创建临时邮箱账号并自动注册' : '批量导入账户配置' }} | |
| </p> | |
| </div> | |
| <button | |
| class="text-xs text-muted-foreground transition-colors hover:text-foreground" | |
| @click="closeRegisterModal" | |
| > | |
| 关闭 | |
| </button> | |
| </div> | |
| <div class="scrollbar-slim flex-1 overflow-y-auto px-6 py-4"> | |
| <div class="space-y-4 text-sm"> | |
| <div class="flex rounded-full border border-border bg-muted/30 p-1 text-xs"> | |
| <button | |
| type="button" | |
| class="flex-1 rounded-full px-3 py-2 font-medium transition-colors" | |
| :class="addMode === 'register' ? 'bg-foreground text-background' : 'text-muted-foreground hover:text-foreground'" | |
| @click="addMode = 'register'" | |
| > | |
| 自动注册 | |
| </button> | |
| <button | |
| type="button" | |
| class="flex-1 rounded-full px-3 py-2 font-medium transition-colors" | |
| :class="addMode === 'import' ? 'bg-foreground text-background' : 'text-muted-foreground hover:text-foreground'" | |
| @click="addMode = 'import'" | |
| > | |
| 批量导入 | |
| </button> | |
| </div> | |
| <div v-if="addMode === 'register'" class="space-y-4"> | |
| <label class="block text-xs text-muted-foreground">临时邮箱服务</label> | |
| <SelectMenu | |
| v-model="selectedMailProvider" | |
| :options="mailProviderOptions" | |
| class="w-full" | |
| /> | |
| <label class="block text-xs text-muted-foreground">注册数量</label> | |
| <input | |
| v-model.number="registerCount" | |
| type="number" | |
| min="1" | |
| class="w-full rounded-2xl border border-input bg-background px-3 py-2 text-sm" | |
| /> | |
| <p class="text-xs text-muted-foreground"> | |
| 注册前请确认邮箱已配置,<a href="https://github.com/Dreamy-rain/gemini-business2api?tab=readme-ov-file#-%E9%82%AE%E7%AE%B1%E6%8F%90%E4%BE%9B%E5%95%86%E9%85%8D%E7%BD%AE" target="_blank" class="text-primary hover:underline font-medium">查看邮箱配置文档</a> | |
| </p> | |
| <p class="text-xs text-muted-foreground"> | |
| 遇到注册失败、收不到验证码或刷新异常?<a href="https://github.com/Dreamy-rain/gemini-business2api/issues/46" target="_blank" class="text-primary hover:underline font-medium">查看常见问题与解决方案</a> | |
| </p> | |
| </div> | |
| <div v-else class="space-y-4"> | |
| <label class="block text-xs text-muted-foreground">批量导入(每行一个)</label> | |
| <div class="flex items-center gap-2"> | |
| <button | |
| type="button" | |
| class="rounded-full border border-border px-3 py-1 text-xs text-muted-foreground transition-colors | |
| hover:border-primary hover:text-primary" | |
| @click="triggerImportFile" | |
| > | |
| 上传文件 | |
| </button> | |
| <span v-if="importFileName" class="text-xs text-muted-foreground">{{ importFileName }}</span> | |
| </div> | |
| <textarea | |
| v-model="importText" | |
| class="min-h-[140px] w-full rounded-2xl border border-input bg-background px-3 py-2 text-xs font-mono" | |
| placeholder="duckmail----you@example.com----password moemail----you@moemail.app----emailId freemail----you@freemail.local gptmail----you@example.com cfmail----you@example.com----jwtToken user@outlook.com----loginPassword----clientId----refreshToken" | |
| ></textarea> | |
| <div class="rounded-2xl border border-border bg-muted/30 px-3 py-2 text-xs text-muted-foreground"> | |
| <p>支持三种格式:</p> | |
| <p class="mt-1 font-mono">duckmail----email----password</p> | |
| <p class="mt-1 font-mono">moemail----email----emailId</p> | |
| <p class="mt-1 font-mono">freemail----email</p> | |
| <p class="mt-1 font-mono">gptmail----email</p> | |
| <p class="mt-1 font-mono">cfmail----email----jwtToken</p> | |
| <p class="mt-1 font-mono">email----password----clientId----refreshToken</p> | |
| <p class="mt-2">导入后请执行一次"刷新选中"以获取 Cookie。</p> | |
| <p class="mt-1">注册失败建议关闭无头浏览器再试</p> | |
| </div> | |
| <div v-if="importError" class="rounded-2xl border border-rose-200 bg-rose-50 px-3 py-2 text-xs text-rose-600"> | |
| {{ importError }} | |
| </div> | |
| </div> | |
| <div class="rounded-2xl border border-rose-200 bg-rose-50 px-3 py-2 text-[11px] leading-relaxed"> | |
| <p class="flex items-center gap-1.5 text-xs font-bold text-rose-600"> | |
| <svg class="h-4 w-4 flex-shrink-0" viewBox="0 0 20 20" fill="currentColor"> | |
| <path fill-rule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clip-rule="evenodd" /> | |
| </svg> | |
| 严禁滥用:禁止将本工具用于商业用途或任何形式的滥用(无论规模大小) | |
| </p> | |
| <p class="mt-1 text-muted-foreground">详细声明请查看项目 <a href="https://github.com/Dreamy-rain/gemini-business2api/blob/main/docs/DISCLAIMER.md" target="_blank" class="text-primary hover:underline font-medium">DISCLAIMER.md</a></p> | |
| </div> | |
| </div> | |
| </div> | |
| <div class="border-t border-border/60 px-6 py-4"> | |
| <div class="flex items-center justify-end gap-2"> | |
| <button | |
| class="rounded-full border border-border px-4 py-2 text-sm text-muted-foreground transition-colors | |
| hover:border-primary hover:text-primary" | |
| @click="closeRegisterModal" | |
| > | |
| 取消 | |
| </button> | |
| <button | |
| v-if="addMode === 'register'" | |
| class="rounded-full bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-opacity | |
| hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-50" | |
| :disabled="isRegistering" | |
| @click="handleRegister" | |
| > | |
| 开始注册 | |
| </button> | |
| <button | |
| v-else | |
| class="rounded-full bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-opacity | |
| hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-50" | |
| :disabled="isImporting" | |
| @click="handleImport" | |
| > | |
| 导入并保存 | |
| </button> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| </Teleport> | |
| <Teleport to="body"> | |
| <div v-if="isTaskOpen" class="fixed inset-0 z-[100] flex items-center justify-center bg-black/30 px-4"> | |
| <div class="flex h-[80vh] w-full max-w-2xl flex-col overflow-hidden rounded-3xl border border-border bg-card shadow-xl"> | |
| <div class="flex items-center justify-between border-b border-border/60 px-6 py-4"> | |
| <div> | |
| <p class="text-sm font-medium text-foreground">任务管理</p> | |
| <p class="mt-1 text-xs text-muted-foreground">管理注册与刷新任务</p> | |
| </div> | |
| <button | |
| class="text-xs text-muted-foreground transition-colors hover:text-foreground" | |
| @click="closeTaskModal" | |
| > | |
| 关闭 | |
| </button> | |
| </div> | |
| <!-- Tab 导航 --> | |
| <div class="flex border-b border-border/60 px-6"> | |
| <button | |
| type="button" | |
| class="relative px-4 py-3 text-sm font-medium transition-colors" | |
| :class="activeTaskTab === 'current' ? 'text-primary' : 'text-muted-foreground hover:text-foreground'" | |
| @click="activeTaskTab = 'current'" | |
| > | |
| 当前任务 | |
| <span | |
| v-if="activeTaskTab === 'current'" | |
| class="absolute bottom-0 left-0 right-0 h-0.5 bg-primary" | |
| ></span> | |
| </button> | |
| <button | |
| type="button" | |
| class="relative px-4 py-3 text-sm font-medium transition-colors" | |
| :class="activeTaskTab === 'scheduled' ? 'text-primary' : 'text-muted-foreground hover:text-foreground'" | |
| @click="activeTaskTab = 'scheduled'; loadScheduledConfig()" | |
| > | |
| 定时任务 | |
| <span | |
| v-if="activeTaskTab === 'scheduled'" | |
| class="absolute bottom-0 left-0 right-0 h-0.5 bg-primary" | |
| ></span> | |
| </button> | |
| <button | |
| type="button" | |
| class="relative px-4 py-3 text-sm font-medium transition-colors" | |
| :class="activeTaskTab === 'history' ? 'text-primary' : 'text-muted-foreground hover:text-foreground'" | |
| @click="activeTaskTab = 'history'" | |
| > | |
| 历史记录 | |
| <span | |
| v-if="activeTaskTab === 'history'" | |
| class="absolute bottom-0 left-0 right-0 h-0.5 bg-primary" | |
| ></span> | |
| </button> | |
| </div> | |
| <!-- 当前任务 Tab --> | |
| <div v-if="activeTaskTab === 'current'" class="flex min-h-0 flex-1 flex-col"> | |
| <!-- 固定任务信息区域 --> | |
| <div v-if="automationError || registerTask || loginTask" class="px-6 py-4"> | |
| <div v-if="automationError" class="rounded-2xl bg-destructive/10 px-3 py-2 text-xs text-destructive"> | |
| {{ automationError }} | |
| </div> | |
| <div v-if="registerTask || loginTask" class="grid gap-3 text-xs text-muted-foreground"> | |
| <div v-if="registerTask" class="space-y-1"> | |
| <div class="flex items-center justify-between gap-3 font-medium text-foreground"> | |
| <div class="flex items-center gap-2"> | |
| <span | |
| class="h-2.5 w-2.5 rounded-full" | |
| :class="getTaskStatusIndicatorClass(registerTask)" | |
| aria-hidden="true" | |
| ></span> | |
| 注册任务 | |
| </div> | |
| <button | |
| v-if="registerTask.status === 'running' || registerTask.status === 'pending'" | |
| class="rounded-full border border-border px-3 py-1 text-xs text-muted-foreground transition-colors hover:border-rose-500 hover:text-rose-600" | |
| @click="cancelRegister(registerTask.id)" | |
| > | |
| 中断 | |
| </button> | |
| </div> | |
| <div class="flex flex-wrap gap-x-4 gap-y-1"> | |
| <span>状态:{{ formatTaskStatus(registerTask) }}</span> | |
| <span>进度:{{ registerTask.progress }}/{{ registerTask.count }}</span> | |
| <span>成功:{{ registerTask.success_count }}</span> | |
| <span>失败:{{ registerTask.fail_count }}</span> | |
| </div> | |
| </div> | |
| <div v-if="loginTask" class="space-y-1"> | |
| <div class="flex items-center justify-between gap-3 font-medium text-foreground"> | |
| <div class="flex items-center gap-2"> | |
| <span | |
| class="h-2.5 w-2.5 rounded-full" | |
| :class="getTaskStatusIndicatorClass(loginTask)" | |
| aria-hidden="true" | |
| ></span> | |
| 刷新任务 | |
| </div> | |
| <button | |
| v-if="loginTask.status === 'running' || loginTask.status === 'pending'" | |
| class="rounded-full border border-border px-3 py-1 text-xs text-muted-foreground transition-colors hover:border-rose-500 hover:text-rose-600" | |
| @click="cancelLogin(loginTask.id)" | |
| > | |
| 中断 | |
| </button> | |
| </div> | |
| <div class="flex flex-wrap gap-x-4 gap-y-1"> | |
| <span>状态:{{ formatTaskStatus(loginTask) }}</span> | |
| <span>进度:{{ loginTask.progress }}/{{ loginTask.account_ids.length }}</span> | |
| <span>成功:{{ loginTask.success_count }}</span> | |
| <span>失败:{{ loginTask.fail_count }}</span> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- 日志区域(独立滚动) --> | |
| <div | |
| v-if="registerTask || loginTask || registerLogs.length || loginLogs.length" | |
| class="flex min-h-0 flex-1 flex-col px-6 pb-4" | |
| > | |
| <div | |
| ref="taskLogsRef" | |
| class="scrollbar-slim flex-1 overflow-y-auto rounded-2xl border border-border bg-muted/30 p-3" | |
| > | |
| <div v-if="registerLogs.length" class="space-y-2"> | |
| <p class="text-xs font-semibold text-foreground">注册日志</p> | |
| <div class="space-y-1 text-[11px] text-muted-foreground"> | |
| <div v-for="(log, index) in registerLogs" :key="`reg-${index}`" class="font-mono"> | |
| {{ formatLogLine(log) }} | |
| </div> | |
| </div> | |
| </div> | |
| <div v-if="loginLogs.length" class="mt-4 space-y-2"> | |
| <p class="text-xs font-semibold text-foreground">刷新日志</p> | |
| <div class="space-y-1 text-[11px] text-muted-foreground"> | |
| <div v-for="(log, index) in loginLogs" :key="`login-${index}`" class="font-mono"> | |
| {{ formatLogLine(log) }} | |
| </div> | |
| </div> | |
| </div> | |
| <div v-if="!registerLogs.length && !loginLogs.length" class="text-xs text-muted-foreground"> | |
| 日志已清空,新的日志会继续显示。 | |
| </div> | |
| </div> | |
| </div> | |
| <div | |
| v-if="!automationError && !registerTask && !loginTask && !registerLogs.length && !loginLogs.length" | |
| class="flex-1 px-6 py-4" | |
| > | |
| <div class="rounded-2xl border border-border bg-muted/30 px-3 py-2 text-xs text-muted-foreground"> | |
| 暂无任务 | |
| </div> | |
| </div> | |
| <!-- 固定底部按钮区域 --> | |
| <div class="flex items-center justify-end gap-2 border-t border-border/60 px-6 py-4"> | |
| <button | |
| class="rounded-full border border-border px-4 py-2 text-sm text-muted-foreground transition-colors | |
| hover:border-primary hover:text-primary disabled:cursor-not-allowed disabled:opacity-50" | |
| :disabled="!registerLogs.length && !loginLogs.length && !registerTask && !loginTask && !automationError" | |
| @click="clearTaskLogs" | |
| > | |
| 清空日志 | |
| </button> | |
| </div> | |
| </div> | |
| <!-- 定时任务 Tab --> | |
| <div v-if="activeTaskTab === 'scheduled'" class="flex min-h-0 flex-1 flex-col"> | |
| <div class="flex min-h-0 flex-1 flex-col"> | |
| <div class="flex-1 overflow-y-auto px-6 py-4"> | |
| <div class="space-y-4"> | |
| <div class="space-y-3"> | |
| <div class="flex items-center justify-between"> | |
| <div> | |
| <p class="text-sm font-medium text-foreground">启用定时刷新</p> | |
| <p class="mt-1 text-xs text-muted-foreground">自动检测并分批刷新即将过期的账号</p> | |
| </div> | |
| <button | |
| type="button" | |
| class="relative inline-flex h-5 w-10 items-center rounded-full transition-colors" | |
| :class="scheduledRefreshEnabled ? 'bg-primary' : 'bg-muted'" | |
| @click="scheduledRefreshEnabled = !scheduledRefreshEnabled" | |
| > | |
| <span | |
| class="inline-block h-4 w-4 transform rounded-full bg-white transition-transform" | |
| :class="scheduledRefreshEnabled ? 'translate-x-5' : 'translate-x-1'" | |
| ></span> | |
| </button> | |
| </div> | |
| <div class="space-y-2"> | |
| <label class="block text-xs text-muted-foreground">刷新时间</label> | |
| <input | |
| v-model="scheduledRefreshCron" | |
| type="text" | |
| class="w-full rounded-2xl border border-input bg-background px-3 py-2 text-sm" | |
| placeholder="08:00,20:00" | |
| /> | |
| <p class="text-xs text-muted-foreground"> | |
| 支持两种格式,任选其一:<br> | |
| <b>① 每天定时</b>:填写时间点,如 <code>08:00,20:00</code> 表示每天 8 点和 20 点各刷新一次,多个时间用逗号分隔<br> | |
| <b>② 固定间隔</b>:填写 <code>*/分钟数</code>,如 <code>*/120</code> 表示每隔 2 小时刷新一次 | |
| </p> | |
| </div> | |
| <div class="grid grid-cols-3 gap-3"> | |
| <div class="space-y-2"> | |
| <label class="block text-xs text-muted-foreground">每批数量</label> | |
| <input | |
| v-model.number="refreshBatchSize" | |
| type="number" | |
| min="1" | |
| max="20" | |
| class="w-full rounded-2xl border border-input bg-background px-3 py-2 text-sm" | |
| /> | |
| </div> | |
| <div class="space-y-2"> | |
| <label class="block text-xs text-muted-foreground">批次间隔(分)</label> | |
| <input | |
| v-model.number="refreshBatchInterval" | |
| type="number" | |
| min="5" | |
| max="120" | |
| class="w-full rounded-2xl border border-input bg-background px-3 py-2 text-sm" | |
| /> | |
| </div> | |
| <div class="space-y-2"> | |
| <label class="block text-xs text-muted-foreground">冷却时间(小时)</label> | |
| <input | |
| v-model.number="refreshCooldownHours" | |
| type="number" | |
| min="1" | |
| max="48" | |
| class="w-full rounded-2xl border border-input bg-background px-3 py-2 text-sm" | |
| /> | |
| </div> | |
| </div> | |
| <div class="space-y-2"> | |
| <label class="block text-xs text-muted-foreground">过期刷新窗口(小时)</label> | |
| <input | |
| v-model.number="refreshWindowHours" | |
| type="number" | |
| min="1" | |
| max="168" | |
| class="w-full rounded-2xl border border-input bg-background px-3 py-2 text-sm" | |
| /> | |
| <p class="text-xs text-muted-foreground"> | |
| 当账号距离过期小于等于该值时,会触发自动刷新 | |
| </p> | |
| </div> | |
| <div class="rounded-2xl border border-border bg-muted/30 px-4 py-3 text-xs text-muted-foreground"> | |
| <p class="mb-2 font-medium text-foreground">说明</p> | |
| <ul class="list-inside list-disc space-y-1"> | |
| <li>每批刷新指定数量的账号,等当前批完成后再开始下一批</li> | |
| <li>同一账号刷新成功后,在冷却时间内不会被再次选中</li> | |
| <li>修改配置后立即生效,无需重启服务</li> | |
| </ul> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- 固定底部按钮区域 --> | |
| <div class="flex items-center justify-end gap-2 border-t border-border/60 px-6 py-4"> | |
| <button | |
| class="rounded-full border border-border px-4 py-2 text-sm text-muted-foreground transition-colors | |
| hover:border-primary hover:text-primary" | |
| @click="loadScheduledConfig" | |
| > | |
| 重置 | |
| </button> | |
| <button | |
| class="rounded-full bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-opacity | |
| hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-50" | |
| :disabled="isSavingScheduledConfig" | |
| @click="saveScheduledConfig" | |
| > | |
| 保存配置 | |
| </button> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- 历史记录 Tab --> | |
| <div v-if="activeTaskTab === 'history'" class="flex min-h-0 flex-1 flex-col"> | |
| <div class="flex-1 overflow-y-auto px-6 py-4"> | |
| <div v-if="isLoadingHistory" class="flex items-center justify-center py-8"> | |
| <svg class="h-6 w-6 animate-spin text-muted-foreground" viewBox="0 0 24 24" fill="none"> | |
| <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> | |
| </div> | |
| <div v-else-if="taskHistory.length === 0" class="rounded-2xl border border-border bg-muted/30 px-3 py-2 text-xs text-muted-foreground"> | |
| <p class="font-medium text-foreground mb-2">暂无历史记录</p> | |
| <p>完成的任务将显示在这里</p> | |
| </div> | |
| <div v-else class="space-y-3"> | |
| <div | |
| v-for="(record, index) in taskHistory" | |
| :key="index" | |
| class="rounded-2xl border border-border bg-card px-4 py-3 text-sm" | |
| > | |
| <div class="flex items-center justify-between mb-2"> | |
| <span class="flex items-center gap-2 font-medium text-foreground"> | |
| <span | |
| class="h-2.5 w-2.5 rounded-full" | |
| :class="getHistoryStatusIndicatorClass(record)" | |
| aria-hidden="true" | |
| ></span> | |
| {{ record.type === 'login' ? '刷新任务' : '注册任务' }} | |
| </span> | |
| <span class="text-xs text-muted-foreground"> | |
| {{ new Date(record.created_at * 1000).toLocaleString('zh-CN') }} | |
| </span> | |
| </div> | |
| <div class="text-xs text-muted-foreground space-y-1"> | |
| <div> | |
| 状态:<span :class="getHistoryStatusTextClass(record)"> | |
| {{ formatTaskStatus(record) }} | |
| </span> | |
| </div> | |
| <div class="flex flex-wrap gap-x-4 gap-y-1"> | |
| <span>进度:{{ record.progress }}/{{ getHistoryTotal(record) }}</span> | |
| <span>成功:{{ record.success_count }}</span> | |
| <span>失败:{{ record.fail_count }}</span> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- 固定底部按钮区域 --> | |
| <div class="flex items-center justify-end gap-2 border-t border-border/60 px-6 py-4"> | |
| <button | |
| class="rounded-full border border-border px-4 py-2 text-sm text-muted-foreground transition-colors | |
| hover:border-primary hover:text-primary disabled:cursor-not-allowed disabled:opacity-50" | |
| :disabled="taskHistory.length === 0" | |
| @click="clearTaskHistory" | |
| > | |
| 清空历史 | |
| </button> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| </Teleport> | |
| <Teleport to="body"> | |
| <div v-if="isEditOpen" class="fixed inset-0 z-[100] flex items-center justify-center bg-black/30 px-4"> | |
| <div class="w-full max-w-lg rounded-3xl border border-border bg-card p-6 shadow-xl"> | |
| <div class="flex items-center justify-between"> | |
| <p class="text-sm font-medium text-foreground">编辑账号</p> | |
| <button | |
| class="text-xs text-muted-foreground transition-colors hover:text-foreground" | |
| @click="closeEdit" | |
| > | |
| 关闭 | |
| </button> | |
| </div> | |
| <div v-if="editError" class="mt-4 rounded-2xl bg-destructive/10 px-4 py-3 text-sm text-destructive"> | |
| {{ editError }} | |
| </div> | |
| <div class="mt-4 space-y-3 text-sm"> | |
| <label class="block text-xs text-muted-foreground">账号 ID</label> | |
| <input | |
| v-model="editForm.id" | |
| type="text" | |
| class="w-full rounded-2xl border border-input bg-background px-3 py-2 text-sm" | |
| disabled | |
| /> | |
| <label class="block text-xs text-muted-foreground">secure_c_ses</label> | |
| <textarea | |
| v-model="editForm.secure_c_ses" | |
| class="w-full rounded-2xl border border-input bg-background px-3 py-2 text-sm" | |
| rows="3" | |
| ></textarea> | |
| <label class="block text-xs text-muted-foreground">csesidx</label> | |
| <input | |
| v-model="editForm.csesidx" | |
| type="text" | |
| class="w-full rounded-2xl border border-input bg-background px-3 py-2 text-sm" | |
| /> | |
| <label class="block text-xs text-muted-foreground">config_id</label> | |
| <input | |
| v-model="editForm.config_id" | |
| type="text" | |
| class="w-full rounded-2xl border border-input bg-background px-3 py-2 text-sm" | |
| /> | |
| <label class="block text-xs text-muted-foreground">host_c_oses</label> | |
| <input | |
| v-model="editForm.host_c_oses" | |
| type="text" | |
| class="w-full rounded-2xl border border-input bg-background px-3 py-2 text-sm" | |
| /> | |
| <label class="block text-xs text-muted-foreground">expires_at</label> | |
| <input | |
| v-model="editForm.expires_at" | |
| type="text" | |
| class="w-full rounded-2xl border border-input bg-background px-3 py-2 text-sm" | |
| placeholder="2025-12-23 10:59:21" | |
| /> | |
| </div> | |
| <div class="mt-6 flex items-center justify-end gap-2"> | |
| <button | |
| class="rounded-full border border-border px-4 py-2 text-sm text-muted-foreground transition-colors | |
| hover:border-primary hover:text-primary" | |
| @click="closeEdit" | |
| > | |
| 取消 | |
| </button> | |
| <button | |
| class="rounded-full bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-opacity | |
| hover:opacity-90" | |
| @click="saveEdit" | |
| > | |
| 保存 | |
| </button> | |
| </div> | |
| </div> | |
| </div> | |
| </Teleport> | |
| <Teleport to="body"> | |
| <div v-if="isConfigOpen" class="fixed inset-0 z-[100] flex items-center justify-center bg-black/30 px-4"> | |
| <div class="w-full max-w-3xl rounded-3xl border border-border bg-card p-6 shadow-xl"> | |
| <div class="flex items-center justify-between"> | |
| <p class="text-sm font-medium text-foreground">账户配置(JSON)</p> | |
| <div class="flex items-center gap-2"> | |
| <button | |
| class="rounded-full bg-foreground px-3 py-1 text-xs text-background transition-opacity | |
| hover:opacity-90" | |
| @click="toggleConfigMask" | |
| > | |
| {{ configMasked ? '显示原文' : '脱敏显示' }} | |
| </button> | |
| <button | |
| class="text-xs text-muted-foreground transition-colors hover:text-foreground" | |
| @click="closeConfigPanel" | |
| > | |
| 关闭 | |
| </button> | |
| </div> | |
| </div> | |
| <div v-if="configError" class="mt-4 rounded-2xl bg-destructive/10 px-4 py-3 text-sm text-destructive"> | |
| {{ configError }} | |
| </div> | |
| <div class="mt-4"> | |
| <textarea | |
| v-model="configJson" | |
| class="h-96 w-full rounded-2xl border border-input bg-background px-4 py-3 font-mono text-xs text-foreground" | |
| spellcheck="false" | |
| :readonly="configMasked" | |
| ></textarea> | |
| </div> | |
| <div class="mt-6 flex items-center justify-end gap-2"> | |
| <button | |
| class="rounded-full border border-border px-4 py-2 text-sm text-muted-foreground transition-colors | |
| hover:border-primary hover:text-primary" | |
| @click="closeConfigPanel" | |
| > | |
| 取消 | |
| </button> | |
| <button | |
| class="rounded-full bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-opacity | |
| hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-50" | |
| @click="saveConfigPanel" | |
| :disabled="configMasked" | |
| > | |
| 保存 | |
| </button> | |
| </div> | |
| </div> | |
| </div> | |
| </Teleport> | |
| <Teleport to="body"> | |
| <div v-if="isExportOpen" class="fixed inset-0 z-[100] flex items-center justify-center bg-black/30 px-4"> | |
| <div class="flex max-h-[90vh] w-full max-w-md flex-col overflow-hidden rounded-3xl border border-border bg-card shadow-xl"> | |
| <div class="flex items-center justify-between border-b border-border/60 px-6 py-4"> | |
| <div> | |
| <p class="text-sm font-medium text-foreground">导出账号配置</p> | |
| <p class="mt-1 text-xs text-muted-foreground">选择导出范围与格式</p> | |
| </div> | |
| <button | |
| class="text-xs text-muted-foreground transition-colors hover:text-foreground" | |
| @click="closeExportModal" | |
| > | |
| 关闭 | |
| </button> | |
| </div> | |
| <div class="scrollbar-slim flex-1 overflow-y-auto px-6 py-4"> | |
| <div class="space-y-4 text-sm"> | |
| <div class="flex rounded-full border border-border bg-muted/30 p-1 text-xs"> | |
| <button | |
| type="button" | |
| class="flex-1 rounded-full px-3 py-2 font-medium transition-colors" | |
| :class="exportScope === 'all' ? 'bg-foreground text-background' : 'text-muted-foreground hover:text-foreground'" | |
| @click="exportScope = 'all'" | |
| > | |
| 全部 | |
| </button> | |
| <button | |
| type="button" | |
| class="flex-1 rounded-full px-3 py-2 font-medium transition-colors" | |
| :class="exportScope === 'selected' ? 'bg-foreground text-background' : 'text-muted-foreground hover:text-foreground'" | |
| :disabled="!selectedCount" | |
| @click="exportScope = 'selected'" | |
| > | |
| 选中 | |
| </button> | |
| </div> | |
| <div class="flex rounded-full border border-border bg-muted/30 p-1 text-xs"> | |
| <button | |
| type="button" | |
| class="flex-1 rounded-full px-3 py-2 font-medium transition-colors" | |
| :class="exportFormat === 'json' ? 'bg-foreground text-background' : 'text-muted-foreground hover:text-foreground'" | |
| @click="exportFormat = 'json'" | |
| > | |
| JSON | |
| </button> | |
| <button | |
| type="button" | |
| class="flex-1 rounded-full px-3 py-2 font-medium transition-colors" | |
| :class="exportFormat === 'txt' ? 'bg-foreground text-background' : 'text-muted-foreground hover:text-foreground'" | |
| @click="exportFormat = 'txt'" | |
| > | |
| TXT | |
| </button> | |
| </div> | |
| <p class="text-xs text-muted-foreground"> | |
| 选中导出仅包含当前已勾选账号({{ selectedCount }} 个)。 | |
| </p> | |
| <p class="text-xs text-muted-foreground"> | |
| <template v-if="exportFormat === 'json'"> | |
| JSON 格式包含完整数据(Cookie、Token、过期时间等),导入后无需重新刷新。 | |
| </template> | |
| <template v-else> | |
| TXT 格式仅导出邮箱和密码,导入后需要重新刷新获取 Cookie。 | |
| </template> | |
| </p> | |
| </div> | |
| </div> | |
| <div class="border-t border-border/60 px-6 py-4"> | |
| <div class="flex items-center justify-end gap-2"> | |
| <button | |
| class="rounded-full border border-border px-4 py-2 text-sm text-muted-foreground transition-colors | |
| hover:border-primary hover:text-primary" | |
| @click="closeExportModal" | |
| > | |
| 取消 | |
| </button> | |
| <button | |
| class="rounded-full bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-opacity | |
| hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-50" | |
| :disabled="exportScope === 'selected' && !selectedCount" | |
| @click="runExport" | |
| > | |
| 开始导出 | |
| </button> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| </Teleport> | |
| <input | |
| ref="importFileInput" | |
| type="file" | |
| class="hidden" | |
| accept=".txt,.json,application/json,text/plain" | |
| @change="handleImportFile" | |
| /> | |
| </template> | |
| <script setup lang="ts"> | |
| import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue' | |
| import { storeToRefs } from 'pinia' | |
| import { useAccountsStore } from '@/stores/accounts' | |
| import { useSettingsStore } from '@/stores/settings' | |
| import SelectMenu from '@/components/ui/SelectMenu.vue' | |
| import Checkbox from '@/components/ui/Checkbox.vue' | |
| import ConfirmDialog from '@/components/ui/ConfirmDialog.vue' | |
| import QuotaBadge from '@/components/QuotaBadge.vue' | |
| import { useConfirmDialog } from '@/composables/useConfirmDialog' | |
| import { useToast } from '@/composables/useToast' | |
| import HelpTip from '@/components/ui/HelpTip.vue' | |
| import { accountsApi, settingsApi } from '@/api' | |
| import { mailProviderOptions, defaultMailProvider } from '@/constants/mailProviders' | |
| import type { AdminAccount, AccountConfigItem, RegisterTask, LoginTask } from '@/types/api' | |
| const accountsStore = useAccountsStore() | |
| const { accounts, isLoading, isOperating, batchProgress } = storeToRefs(accountsStore) | |
| const settingsStore = useSettingsStore() | |
| const { settings } = storeToRefs(settingsStore) | |
| const confirmDialog = useConfirmDialog() | |
| const toast = useToast() | |
| const searchQuery = ref('') | |
| const statusFilter = ref('all') | |
| const selectedIds = ref<Set<string>>(new Set()) | |
| const viewMode = ref<'table' | 'card'>((localStorage.getItem('accounts_view_mode') as 'table' | 'card') || 'table') | |
| watch(viewMode, (val) => localStorage.setItem('accounts_view_mode', val)) | |
| const currentPage = ref(1) | |
| const pageSize = ref(50) | |
| const isEditOpen = ref(false) | |
| const editError = ref('') | |
| const isConfigOpen = ref(false) | |
| const configError = ref('') | |
| const configJson = ref('') | |
| const configMasked = ref(false) | |
| const configData = ref<AccountConfigItem[]>([]) | |
| const registerCount = ref(1) | |
| const selectedMailProvider = ref(settings.value?.basic?.temp_mail_provider || defaultMailProvider) | |
| const isRegisterOpen = ref(false) | |
| const addMode = ref<'register' | 'import'>('register') | |
| const importText = ref('') | |
| const importError = ref('') | |
| const isImporting = ref(false) | |
| const importFileInput = ref<HTMLInputElement | null>(null) | |
| const importFileName = ref('') | |
| const isExportOpen = ref(false) | |
| const exportScope = ref<'all' | 'selected'>('all') | |
| const exportFormat = ref<'json' | 'txt'>('json') | |
| const isTaskOpen = ref(false) | |
| const activeTaskTab = ref<'current' | 'scheduled' | 'history'>('current') | |
| const showMoreActions = ref(false) | |
| const moreActionsRef = ref<HTMLDivElement | null>(null) | |
| const lastRegisterTaskId = ref<string | null>(null) | |
| const lastLoginTaskId = ref<string | null>(null) | |
| const scheduledRefreshEnabled = ref(false) | |
| const scheduledRefreshCron = ref('08:00,20:00') | |
| const refreshBatchSize = ref(5) | |
| const refreshBatchInterval = ref(30) | |
| const refreshCooldownHours = ref(12) | |
| const refreshWindowHours = ref(24) | |
| const isLoadingScheduledConfig = ref(false) | |
| const isSavingScheduledConfig = ref(false) | |
| const cachedSettings = ref<any>(null) // 缓存配置以避免重复API调用 | |
| const taskHistory = ref<any[]>([]) // 任务历史记录 | |
| const isLoadingHistory = ref(false) // 加载历史记录状态 | |
| type TaskLogLine = { time: string; level: string; message: string } | |
| const registerLogClearMarker = ref<TaskLogLine | null>(null) | |
| const loginLogClearMarker = ref<TaskLogLine | null>(null) | |
| const registerTask = ref<RegisterTask | null>(null) | |
| const loginTask = ref<LoginTask | null>(null) | |
| const refreshingAccountIds = ref<Set<string>>(new Set()) // 正在刷新的账户ID集合(仅用于显示状态) | |
| const taskLogsRef = ref<HTMLDivElement | null>(null) | |
| const isRegistering = ref(false) | |
| const isRefreshing = ref(false) | |
| const automationError = ref('') | |
| const REGISTER_TASK_CACHE_KEY = 'accounts-register-task-cache' | |
| const LOGIN_TASK_CACHE_KEY = 'accounts-login-task-cache' | |
| const REGISTER_CLEAR_KEY = 'accounts-register-log-clear' | |
| const LOGIN_CLEAR_KEY = 'accounts-login-log-clear' | |
| const REGISTER_DISMISS_KEY = 'accounts-register-task-dismissed' | |
| const LOGIN_DISMISS_KEY = 'accounts-login-task-dismissed' | |
| const REGISTER_CLEARED_KEY = 'accounts-register-task-cleared' | |
| const LOGIN_CLEARED_KEY = 'accounts-login-task-cleared' | |
| type TaskKind = 'register' | 'login' | |
| const TASK_KEYS = { | |
| register: { | |
| clearKey: REGISTER_CLEAR_KEY, | |
| dismissKey: REGISTER_DISMISS_KEY, | |
| clearedKey: REGISTER_CLEARED_KEY, | |
| }, | |
| login: { | |
| clearKey: LOGIN_CLEAR_KEY, | |
| dismissKey: LOGIN_DISMISS_KEY, | |
| clearedKey: LOGIN_CLEARED_KEY, | |
| }, | |
| } as const | |
| const editForm = ref<AccountConfigItem>({ | |
| id: '', | |
| secure_c_ses: '', | |
| csesidx: '', | |
| config_id: '', | |
| host_c_oses: '', | |
| expires_at: '', | |
| }) | |
| const editIndex = ref<number | null>(null) | |
| const configAccounts = ref<AccountConfigItem[]>([]) | |
| const statusOptions = [ | |
| { label: '全部状态', value: 'all' }, | |
| { label: '正常', value: '正常' }, | |
| { label: '即将过期', value: '即将过期' }, | |
| { label: '已过期', value: '已过期' }, | |
| { label: '手动禁用', value: '手动禁用' }, | |
| { label: '403 禁用', value: '403 禁用' }, | |
| { label: '429限流', value: '429限流' }, | |
| ] | |
| const filteredAccounts = computed(() => { | |
| const query = searchQuery.value.trim().toLowerCase() | |
| return accounts.value.filter(account => { | |
| const matchesQuery = !query || account.id.toLowerCase().includes(query) | |
| const matchesStatus = statusFilter.value === 'all' || statusLabel(account) === statusFilter.value | |
| return matchesQuery && matchesStatus | |
| }) | |
| }) | |
| const totalPages = computed(() => Math.ceil(filteredAccounts.value.length / pageSize.value)) | |
| const paginatedAccounts = computed(() => { | |
| const start = (currentPage.value - 1) * pageSize.value | |
| const end = start + pageSize.value | |
| return filteredAccounts.value.slice(start, end) | |
| }) | |
| const selectedCount = computed(() => selectedIds.value.size) | |
| const allSelected = computed(() => | |
| filteredAccounts.value.length > 0 && filteredAccounts.value.every(account => selectedIds.value.has(account.id)) | |
| ) | |
| watch([searchQuery, statusFilter], () => { | |
| currentPage.value = 1 | |
| }) | |
| const refreshAccounts = async () => { | |
| await accountsStore.loadAccounts() | |
| selectedIds.value = new Set() | |
| showMoreActions.value = false | |
| } | |
| const readCachedTask = <T,>(key: string): T | null => { | |
| try { | |
| const raw = localStorage.getItem(key) | |
| return raw ? (JSON.parse(raw) as T) : null | |
| } catch { | |
| return null | |
| } | |
| } | |
| const writeCachedTask = (key: string, value: unknown) => { | |
| try { | |
| localStorage.setItem(key, JSON.stringify(value)) | |
| } catch { | |
| // ignore storage errors | |
| } | |
| } | |
| const removeCachedTask = (key: string) => { | |
| try { | |
| localStorage.removeItem(key) | |
| } catch { | |
| // ignore storage errors | |
| } | |
| } | |
| type DismissedTaskMeta = { id?: string; created_at?: number } | null | |
| const readDismissedTaskMeta = (key: string): DismissedTaskMeta => { | |
| try { | |
| const raw = localStorage.getItem(key) | |
| if (!raw) return null | |
| try { | |
| const parsed = JSON.parse(raw) as Partial<{ id: string; created_at: number }> | |
| if (parsed && (parsed.id || typeof parsed.created_at === 'number')) { | |
| return { id: parsed.id, created_at: parsed.created_at } | |
| } | |
| } catch { | |
| // Backward compatibility: plain id string | |
| return { id: raw } | |
| } | |
| return null | |
| } catch { | |
| return null | |
| } | |
| } | |
| const writeDismissedTaskMeta = (key: string, meta: DismissedTaskMeta) => { | |
| try { | |
| if (!meta || (!meta.id && typeof meta.created_at !== 'number')) { | |
| localStorage.removeItem(key) | |
| return | |
| } | |
| localStorage.setItem(key, JSON.stringify(meta)) | |
| } catch { | |
| // ignore storage errors | |
| } | |
| } | |
| const readDismissedTaskId = (key: string) => readDismissedTaskMeta(key)?.id || null | |
| const writeDismissedTaskId = (key: string, taskId: string | null) => { | |
| if (!taskId) { | |
| writeDismissedTaskMeta(key, null) | |
| return | |
| } | |
| writeDismissedTaskMeta(key, { id: taskId }) | |
| } | |
| const isTaskMetaMatch = (task: { id?: string; created_at?: number } | null | undefined, meta: DismissedTaskMeta) => { | |
| if (!task || !meta) return false | |
| if (meta.id && task.id && task.id === meta.id) return true | |
| if (typeof meta.created_at === 'number' && typeof task.created_at === 'number' && task.created_at === meta.created_at) { | |
| return true | |
| } | |
| return false | |
| } | |
| const isTaskDismissed = (task: { id?: string; created_at?: number } | null | undefined, meta: DismissedTaskMeta) => | |
| isTaskMetaMatch(task, meta) | |
| const readClearedTaskMeta = (key: string): DismissedTaskMeta => readDismissedTaskMeta(key) | |
| const writeClearedTaskMeta = (key: string, meta: DismissedTaskMeta) => writeDismissedTaskMeta(key, meta) | |
| const isTaskActive = (task: RegisterTask | LoginTask | null | undefined) => { | |
| const status = task?.status | |
| return status === 'running' || status === 'pending' | |
| } | |
| const getTaskByKind = (kind: TaskKind) => (kind === 'register' ? registerTask.value : loginTask.value) | |
| const markTaskCleared = (kind: TaskKind, task: RegisterTask | LoginTask) => { | |
| const key = TASK_KEYS[kind].clearedKey | |
| writeClearedTaskMeta(key, { | |
| id: task.id, | |
| created_at: task.created_at, | |
| }) | |
| } | |
| const setLogClearMarker = (kind: TaskKind, marker: TaskLogLine | null) => { | |
| if (kind === 'register') { | |
| registerLogClearMarker.value = marker | |
| } else { | |
| loginLogClearMarker.value = marker | |
| } | |
| } | |
| const clearTaskSnapshot = (kind: TaskKind, persist = true) => { | |
| if (kind === 'register') { | |
| syncRegisterTask(null, persist) | |
| } else { | |
| syncLoginTask(null, persist) | |
| } | |
| } | |
| const clearFinishedTask = (kind: TaskKind) => { | |
| const task = getTaskByKind(kind) | |
| if (!task || isTaskActive(task)) return | |
| markTaskCleared(kind, task) | |
| clearTaskSnapshot(kind, true) | |
| } | |
| const handleTaskIdle = (kind: TaskKind) => { | |
| // 后端 idle:保留现有任务快照 | |
| cleanupCancelledTasks() | |
| } | |
| const handleTaskNotFound = (kind: TaskKind) => { | |
| if (kind === 'register') { | |
| clearRegisterTimer() | |
| isRegistering.value = false | |
| } else { | |
| clearLoginTimer() | |
| isRefreshing.value = false | |
| } | |
| } | |
| const handleTaskActive = (kind: TaskKind, task: RegisterTask | LoginTask) => { | |
| if (kind === 'register') { | |
| syncRegisterTask(task) | |
| isRegistering.value = true | |
| startRegisterPolling(task.id) | |
| } else { | |
| syncLoginTask(task) | |
| isRefreshing.value = true | |
| startLoginPolling(task.id) | |
| } | |
| } | |
| const handleTaskInactive = (kind: TaskKind, task: RegisterTask | LoginTask) => { | |
| if (kind === 'register') { | |
| syncRegisterTask(task) | |
| } else { | |
| syncLoginTask(task) | |
| } | |
| } | |
| const shouldKeepInactiveTask = (kind: TaskKind, task: RegisterTask | LoginTask) => { | |
| const dismissedMeta = readDismissedTaskMeta(TASK_KEYS[kind].dismissKey) | |
| const clearedMeta = readClearedTaskMeta(TASK_KEYS[kind].clearedKey) | |
| return !isTaskDismissed(task, dismissedMeta) && !isTaskMetaMatch(task, clearedMeta) | |
| } | |
| const loadCurrentTaskByKind = async (kind: TaskKind) => { | |
| try { | |
| const current = kind === 'register' | |
| ? await accountsApi.getRegisterCurrent() | |
| : await accountsApi.getLoginCurrent() | |
| if (current && 'id' in current) { | |
| const isActive = current.status === 'running' || current.status === 'pending' | |
| if (isActive) { | |
| handleTaskActive(kind, current) | |
| } else if (shouldKeepInactiveTask(kind, current)) { | |
| handleTaskInactive(kind, current) | |
| } | |
| } else { | |
| handleTaskIdle(kind) | |
| } | |
| } catch (error: any) { | |
| if (error?.status === 404 || error?.message === 'Not found') { | |
| handleTaskNotFound(kind) | |
| } else { | |
| automationError.value = error.message || (kind === 'register' ? '加载注册任务失败' : '加载刷新任务失败') | |
| } | |
| } | |
| } | |
| const readClearMarker = (key: string): TaskLogLine | null => { | |
| const raw = localStorage.getItem(key) | |
| if (!raw) return null | |
| // Backward compatibility: older versions stored numeric offsets. | |
| // If we see a number, ignore it so logs still render. | |
| const asNumber = Number(raw) | |
| if (Number.isFinite(asNumber)) return null | |
| try { | |
| const parsed = JSON.parse(raw) as Partial<TaskLogLine> | null | |
| if (!parsed || typeof parsed !== 'object') return null | |
| if (typeof parsed.time !== 'string' || typeof parsed.level !== 'string' || typeof parsed.message !== 'string') { | |
| return null | |
| } | |
| return { time: parsed.time, level: parsed.level, message: parsed.message } | |
| } catch { | |
| return null | |
| } | |
| } | |
| const writeClearMarker = (key: string, value: TaskLogLine | null) => { | |
| try { | |
| if (!value) { | |
| localStorage.removeItem(key) | |
| return | |
| } | |
| localStorage.setItem(key, JSON.stringify(value)) | |
| } catch { | |
| // ignore storage errors | |
| } | |
| } | |
| const syncRegisterTask = (task: RegisterTask | null, persist = true) => { | |
| if (!task) { | |
| registerTask.value = null | |
| lastRegisterTaskId.value = null | |
| registerLogClearMarker.value = null | |
| if (persist) { | |
| removeCachedTask(REGISTER_TASK_CACHE_KEY) | |
| writeClearMarker(REGISTER_CLEAR_KEY, null) | |
| } | |
| return | |
| } | |
| registerTask.value = task | |
| if (task.id && task.id !== lastRegisterTaskId.value) { | |
| lastRegisterTaskId.value = task.id | |
| writeDismissedTaskMeta(REGISTER_DISMISS_KEY, null) | |
| writeClearedTaskMeta(REGISTER_CLEARED_KEY, null) | |
| setLogClearMarker('register', null) | |
| writeClearMarker(REGISTER_CLEAR_KEY, null) | |
| // 新注册任务启动时,自动清理已结束的刷新任务,避免堆叠显示 | |
| clearFinishedTask('login') | |
| } | |
| if (persist) { | |
| writeCachedTask(REGISTER_TASK_CACHE_KEY, task) | |
| } | |
| } | |
| const syncLoginTask = (task: LoginTask | null, persist = true) => { | |
| if (!task) { | |
| loginTask.value = null | |
| lastLoginTaskId.value = null | |
| loginLogClearMarker.value = null | |
| if (persist) { | |
| removeCachedTask(LOGIN_TASK_CACHE_KEY) | |
| writeClearMarker(LOGIN_CLEAR_KEY, null) | |
| } | |
| return | |
| } | |
| loginTask.value = task | |
| if (task.id && task.id !== lastLoginTaskId.value) { | |
| lastLoginTaskId.value = task.id | |
| writeDismissedTaskMeta(LOGIN_DISMISS_KEY, null) | |
| writeClearedTaskMeta(LOGIN_CLEARED_KEY, null) | |
| setLogClearMarker('login', null) | |
| writeClearMarker(LOGIN_CLEAR_KEY, null) | |
| // 新刷新任务启动时,自动清理已结束的注册任务,避免堆叠显示 | |
| clearFinishedTask('register') | |
| } | |
| if (persist) { | |
| writeCachedTask(LOGIN_TASK_CACHE_KEY, task) | |
| } | |
| } | |
| const hydrateTaskCache = () => { | |
| registerLogClearMarker.value = readClearMarker(REGISTER_CLEAR_KEY) | |
| loginLogClearMarker.value = readClearMarker(LOGIN_CLEAR_KEY) | |
| const cachedRegister = readCachedTask<RegisterTask>(REGISTER_TASK_CACHE_KEY) | |
| if (cachedRegister) { | |
| const dismissedMeta = readDismissedTaskMeta(TASK_KEYS.register.dismissKey) | |
| const clearedMeta = readClearedTaskMeta(TASK_KEYS.register.clearedKey) | |
| if (!isTaskDismissed(cachedRegister, dismissedMeta) && !isTaskMetaMatch(cachedRegister, clearedMeta)) { | |
| registerTask.value = cachedRegister | |
| lastRegisterTaskId.value = cachedRegister.id || null | |
| } | |
| } | |
| const cachedLogin = readCachedTask<LoginTask>(LOGIN_TASK_CACHE_KEY) | |
| if (cachedLogin) { | |
| const dismissedMeta = readDismissedTaskMeta(TASK_KEYS.login.dismissKey) | |
| const clearedMeta = readClearedTaskMeta(TASK_KEYS.login.clearedKey) | |
| if (!isTaskDismissed(cachedLogin, dismissedMeta) && !isTaskMetaMatch(cachedLogin, clearedMeta)) { | |
| loginTask.value = cachedLogin | |
| lastLoginTaskId.value = cachedLogin.id || null | |
| } | |
| } | |
| } | |
| const cleanupCancelledTasks = () => { | |
| // Keep completed/cancelled task logs; do not auto-clear. | |
| } | |
| const openRegisterModal = () => { | |
| isRegisterOpen.value = true | |
| addMode.value = 'register' | |
| importText.value = '' | |
| importError.value = '' | |
| isImporting.value = false | |
| importFileName.value = '' | |
| // 重置为设置中的邮箱服务提供商 | |
| selectedMailProvider.value = settings.value?.basic?.temp_mail_provider || defaultMailProvider | |
| } | |
| const openExportModal = (format: 'json' | 'txt' = 'json') => { | |
| exportFormat.value = format | |
| exportScope.value = 'all' | |
| isExportOpen.value = true | |
| } | |
| const closeExportModal = () => { | |
| isExportOpen.value = false | |
| } | |
| const closeRegisterModal = () => { | |
| isRegisterOpen.value = false | |
| } | |
| const IMPORT_EXPIRES_AT = '1970-01-01 00:00:00' | |
| const parseImportLines = (raw: string) => { | |
| const items: AccountConfigItem[] = [] | |
| const errors: string[] = [] | |
| const lines = raw.split(/\r?\n/).map(line => line.trim()).filter(Boolean) | |
| lines.forEach((line, index) => { | |
| const parts = line.split('----').map(part => part.trim()) | |
| const lineNo = index + 1 | |
| if (!parts.length) return | |
| if (parts[0].toLowerCase() === 'duckmail') { | |
| if (parts.length < 3 || !parts[1] || !parts[2]) { | |
| errors.push(`第 ${lineNo} 行格式错误(duckmail)`) | |
| return | |
| } | |
| const email = parts[1] | |
| const password = parts.slice(2).join('----') | |
| items.push({ | |
| id: email, | |
| secure_c_ses: '', | |
| csesidx: '', | |
| config_id: '', | |
| expires_at: IMPORT_EXPIRES_AT, | |
| mail_provider: 'duckmail', | |
| mail_address: email, | |
| mail_password: password, | |
| }) | |
| return | |
| } | |
| if (parts[0].toLowerCase() === 'moemail') { | |
| if (parts.length < 3 || !parts[1] || !parts[2]) { | |
| errors.push(`第 ${lineNo} 行格式错误(moemail)`) | |
| return | |
| } | |
| const email = parts[1] | |
| const emailId = parts[2] // moemail 的 email_id 作为 password 存储 | |
| items.push({ | |
| id: email, | |
| secure_c_ses: '', | |
| csesidx: '', | |
| config_id: '', | |
| expires_at: IMPORT_EXPIRES_AT, | |
| mail_provider: 'moemail', | |
| mail_address: email, | |
| mail_password: emailId, | |
| }) | |
| return | |
| } | |
| if (parts[0].toLowerCase() === 'freemail') { | |
| if (parts.length < 2 || !parts[1]) { | |
| errors.push(`第 ${lineNo} 行格式错误(freemail)`) | |
| return | |
| } | |
| const email = parts[1] | |
| // 完整格式:freemail----email----base_url----jwt_token----verify_ssl----domain | |
| if (parts.length >= 6) { | |
| items.push({ | |
| id: email, | |
| secure_c_ses: '', | |
| csesidx: '', | |
| config_id: '', | |
| expires_at: IMPORT_EXPIRES_AT, | |
| mail_provider: 'freemail', | |
| mail_address: email, | |
| mail_password: '', | |
| mail_base_url: parts[2] || undefined, | |
| mail_jwt_token: parts[3] || undefined, | |
| mail_verify_ssl: parts[4] === 'true' || parts[4] === '1', | |
| mail_domain: parts[5] || undefined, | |
| }) | |
| return | |
| } | |
| // 简化格式:freemail----email | |
| items.push({ | |
| id: email, | |
| secure_c_ses: '', | |
| csesidx: '', | |
| config_id: '', | |
| expires_at: IMPORT_EXPIRES_AT, | |
| mail_provider: 'freemail', | |
| mail_address: email, | |
| mail_password: '', | |
| }) | |
| return | |
| } | |
| if (parts[0].toLowerCase() === 'gptmail') { | |
| if (parts.length < 2 || !parts[1]) { | |
| errors.push(`第 ${lineNo} 行格式错误(gptmail)`) | |
| return | |
| } | |
| const email = parts[1] | |
| items.push({ | |
| id: email, | |
| secure_c_ses: '', | |
| csesidx: '', | |
| config_id: '', | |
| expires_at: IMPORT_EXPIRES_AT, | |
| mail_provider: 'gptmail', | |
| mail_address: email, | |
| mail_password: '', | |
| }) | |
| return | |
| } | |
| if (parts[0].toLowerCase() === 'cfmail') { | |
| if (parts.length < 2 || !parts[1]) { | |
| errors.push(`第 ${lineNo} 行格式错误(cfmail)`) | |
| return | |
| } | |
| const email = parts[1] | |
| const jwt = parts[2] || '' | |
| items.push({ | |
| id: email, | |
| secure_c_ses: '', | |
| csesidx: '', | |
| config_id: '', | |
| expires_at: IMPORT_EXPIRES_AT, | |
| mail_provider: 'cfmail', | |
| mail_address: email, | |
| mail_password: jwt, | |
| }) | |
| return | |
| } | |
| if (parts.length >= 4 && parts[0] && parts[2] && parts[3]) { | |
| const email = parts[0] | |
| const password = parts[1] || '' | |
| const clientId = parts[2] | |
| const refreshToken = parts.slice(3).join('----') | |
| items.push({ | |
| id: email, | |
| secure_c_ses: '', | |
| csesidx: '', | |
| config_id: '', | |
| expires_at: IMPORT_EXPIRES_AT, | |
| mail_provider: 'microsoft', | |
| mail_address: email, | |
| mail_password: password, | |
| mail_client_id: clientId, | |
| mail_refresh_token: refreshToken, | |
| mail_tenant: 'consumers', | |
| }) | |
| return | |
| } | |
| errors.push(`第 ${lineNo} 行格式错误`) | |
| }) | |
| return { items, errors } | |
| } | |
| const triggerImportFile = () => { | |
| importFileInput.value?.click() | |
| } | |
| const handleImportFile = async (event: Event) => { | |
| const target = event.target as HTMLInputElement | |
| const file = target.files?.[0] | |
| if (!file) return | |
| importError.value = '' | |
| importFileName.value = file.name | |
| try { | |
| const content = await file.text() | |
| if (file.name.toLowerCase().endsWith('.json') || file.type.includes('json')) { | |
| const parsed = JSON.parse(content) | |
| const importList = Array.isArray(parsed) ? parsed : parsed?.accounts | |
| if (!Array.isArray(importList)) { | |
| importError.value = 'JSON 格式错误:需要数组或包含 accounts 字段' | |
| return | |
| } | |
| const existing = await loadConfigList() | |
| const next = [...existing] | |
| const indexMap = new Map(next.map((acc, idx) => [acc.id, idx])) | |
| const importedIds: string[] = [] | |
| importList.forEach((item: any) => { | |
| const idx = indexMap.get(item.id || '') | |
| if (idx === undefined) { | |
| next.push(item) | |
| } else { | |
| next[idx] = { ...next[idx], ...item } | |
| } | |
| if (item.id) importedIds.push(item.id) | |
| }) | |
| await accountsStore.updateConfig(next) | |
| selectedIds.value = new Set(importedIds) | |
| toast.success(`导入 ${importList.length} 条账号配置`) | |
| // Check if imported accounts need refresh (no valid cookies) | |
| const needRefresh = importList.some((item: any) => !item.secure_c_ses) | |
| if (needRefresh && importedIds.length > 0) { | |
| closeRegisterModal() | |
| const confirmed = await confirmDialog.ask({ | |
| title: '导入成功', | |
| message: `已导入 ${importedIds.length} 个账户。检测到部分账户缺少 Cookie,是否立即刷新?`, | |
| confirmText: '立即刷新', | |
| cancelText: '稍后手动刷新', | |
| }) | |
| if (confirmed) { | |
| await handleRefreshSelected() | |
| } | |
| } else { | |
| closeRegisterModal() | |
| } | |
| return | |
| } | |
| importText.value = content | |
| await handleImport() | |
| } catch (error: any) { | |
| importError.value = error.message || '文件解析失败' | |
| } finally { | |
| target.value = '' | |
| } | |
| } | |
| const handleImport = async () => { | |
| importError.value = '' | |
| if (!importText.value.trim()) { | |
| importError.value = '请输入导入内容' | |
| return | |
| } | |
| const { items, errors } = parseImportLines(importText.value) | |
| if (!items.length) { | |
| importError.value = errors.length ? errors.join(',') : '未识别到有效账号' | |
| return | |
| } | |
| if (errors.length) { | |
| importError.value = errors.slice(0, 3).join(',') | |
| return | |
| } | |
| isImporting.value = true | |
| try { | |
| const list = await loadConfigList() | |
| const next = [...list] | |
| const indexMap = new Map(next.map((acc, idx) => [acc.id, idx])) | |
| const importedIds: string[] = [] | |
| items.forEach((item) => { | |
| const idx = indexMap.get(item.id || '') | |
| if (idx === undefined) { | |
| next.push(item) | |
| importedIds.push(item.id) | |
| return | |
| } | |
| const existing = next[idx] | |
| const updated: AccountConfigItem = { | |
| ...existing, | |
| mail_provider: item.mail_provider, | |
| mail_address: item.mail_address, | |
| } | |
| if (item.mail_provider === 'microsoft') { | |
| updated.mail_client_id = item.mail_client_id | |
| updated.mail_refresh_token = item.mail_refresh_token | |
| updated.mail_tenant = item.mail_tenant | |
| updated.mail_password = item.mail_password | |
| } else { | |
| updated.mail_password = item.mail_password | |
| updated.mail_client_id = undefined | |
| updated.mail_refresh_token = undefined | |
| updated.mail_tenant = undefined | |
| } | |
| next[idx] = updated | |
| importedIds.push(item.id) | |
| }) | |
| await accountsStore.updateConfig(next) | |
| await refreshAccounts() | |
| selectedIds.value = new Set(importedIds) | |
| toast.success(`成功导入 ${importedIds.length} 个账户`) | |
| closeRegisterModal() | |
| const confirmed = await confirmDialog.ask({ | |
| title: '导入成功', | |
| message: `已导入 ${importedIds.length} 个账户并自动选中。是否立即刷新这些账户以获取 Cookie?`, | |
| confirmText: '立即刷新', | |
| cancelText: '稍后手动刷新', | |
| }) | |
| if (confirmed) { | |
| await handleRefreshSelected() | |
| } | |
| } catch (error: any) { | |
| importError.value = error.message || '导入失败' | |
| toast.error(error.message || '导入失败') | |
| } finally { | |
| isImporting.value = false | |
| } | |
| } | |
| const exportConfig = async (format: 'json' | 'txt', scope: 'all' | 'selected' = 'all') => { | |
| try { | |
| const response = await accountsApi.getConfig() | |
| let list = Array.isArray(response.accounts) ? response.accounts : [] | |
| if (scope === 'selected') { | |
| const selected = selectedIds.value | |
| list = list.filter((item) => selected.has(item.id)) | |
| } | |
| const timestamp = new Date().toISOString().replace(/[:.]/g, '-') | |
| if (format === 'json') { | |
| const payload = JSON.stringify(list, null, 2) | |
| downloadText(payload, `accounts-${timestamp}.json`, 'application/json') | |
| toast.success('导出 JSON 成功') | |
| return | |
| } | |
| const lines = list.map((item) => { | |
| const provider = (item.mail_provider || '').toLowerCase() | |
| const email = item.mail_address || item.id || '' | |
| if (!email) return '' | |
| if (provider === 'moemail') { | |
| return `moemail----${email}----${item.mail_password || ''}` | |
| } | |
| if (provider === 'freemail') { | |
| return `freemail----${email}` | |
| } | |
| if (provider === 'gptmail') { | |
| return `gptmail----${email}` | |
| } | |
| if (provider === 'cfmail') { | |
| return `cfmail----${email}----${item.mail_password || ''}` | |
| } | |
| if (provider === 'duckmail') { | |
| return `duckmail----${email}----${item.mail_password || ''}` | |
| } | |
| if (provider === 'microsoft' || item.mail_client_id || item.mail_refresh_token) { | |
| return `${email}----${item.mail_password || ''}----${item.mail_client_id || ''}----${item.mail_refresh_token || ''}` | |
| } | |
| if (item.mail_password) { | |
| return `duckmail----${email}----${item.mail_password}` | |
| } | |
| return email | |
| }).filter(Boolean) | |
| downloadText(lines.join('\n'), `accounts-${timestamp}.txt`, 'text/plain') | |
| toast.success('导出 TXT 成功') | |
| } catch (error: any) { | |
| toast.error(error.message || '导出失败') | |
| } | |
| } | |
| const runExport = async () => { | |
| await exportConfig(exportFormat.value, exportScope.value) | |
| closeExportModal() | |
| } | |
| const downloadText = (content: string, filename: string, mime: string) => { | |
| const blob = new Blob([content], { type: mime }) | |
| const url = URL.createObjectURL(blob) | |
| const link = document.createElement('a') | |
| link.href = url | |
| link.download = filename | |
| document.body.appendChild(link) | |
| link.click() | |
| document.body.removeChild(link) | |
| URL.revokeObjectURL(url) | |
| } | |
| const refreshTaskSnapshot = async () => { | |
| try { | |
| const tasks: Promise<void>[] = [] | |
| const registerId = registerTask.value?.id | |
| const loginId = loginTask.value?.id | |
| if (registerId) { | |
| tasks.push(updateRegisterTask(registerId)) | |
| } | |
| if (loginId) { | |
| tasks.push(updateLoginTask(loginId)) | |
| } | |
| if (!tasks.length) { | |
| await loadCurrentTasks() | |
| } else { | |
| await Promise.all(tasks) | |
| } | |
| cleanupCancelledTasks() | |
| } catch (error: any) { | |
| automationError.value = error?.message || '任务状态更新失败' | |
| } | |
| } | |
| const openTaskModal = async () => { | |
| isTaskOpen.value = true | |
| activeTaskTab.value = 'current' | |
| await refreshTaskSnapshot() | |
| } | |
| const fetchTaskHistory = async () => { | |
| isLoadingHistory.value = true | |
| try { | |
| const response = await fetch('/admin/task-history', { | |
| headers: { 'Content-Type': 'application/json' } | |
| }) | |
| if (!response.ok) throw new Error('获取历史记录失败') | |
| const data = await response.json() | |
| const history = Array.isArray(data.history) ? data.history : [] | |
| const dismissedRegister = readDismissedTaskMeta(REGISTER_DISMISS_KEY) | |
| const dismissedLogin = readDismissedTaskMeta(LOGIN_DISMISS_KEY) | |
| taskHistory.value = history.filter((record: any) => { | |
| const meta = record?.type === 'register' ? dismissedRegister : dismissedLogin | |
| if (!meta) return true | |
| const id = typeof record?.id === 'string' ? record.id : String(record?.id || '') | |
| const createdAt = typeof record?.created_at === 'number' ? record.created_at : undefined | |
| if (meta.id && id && id === meta.id) return false | |
| if (typeof meta.created_at === 'number' && typeof createdAt === 'number' && createdAt === meta.created_at) { | |
| return false | |
| } | |
| return true | |
| }) | |
| } catch (error: any) { | |
| toast.error(error?.message || '获取历史记录失败') | |
| } finally { | |
| isLoadingHistory.value = false | |
| } | |
| } | |
| const clearTaskHistory = async () => { | |
| const confirmed = await confirmDialog.ask({ | |
| title: '清空历史记录', | |
| message: '确定要清空所有任务历史记录吗?', | |
| confirmText: '清空', | |
| }) | |
| if (!confirmed) return | |
| try { | |
| const response = await fetch('/admin/task-history?confirm=yes', { | |
| method: 'DELETE', | |
| headers: { 'Content-Type': 'application/json' } | |
| }) | |
| if (!response.ok) throw new Error('清空历史记录失败') | |
| taskHistory.value = [] | |
| toast.success('历史记录已清空') | |
| } catch (error: any) { | |
| toast.error(error?.message || '清空历史记录失败') | |
| } | |
| } | |
| const closeTaskModal = () => { | |
| isTaskOpen.value = false | |
| // 关闭弹窗时,确保已中断任务不会被缓存"复活" | |
| cleanupCancelledTasks() | |
| } | |
| const loadScheduledConfig = async () => { | |
| isLoadingScheduledConfig.value = true | |
| try { | |
| const settings = await settingsApi.get() | |
| cachedSettings.value = settings // 缓存配置 | |
| scheduledRefreshEnabled.value = settings.retry.scheduled_refresh_enabled ?? false | |
| scheduledRefreshCron.value = settings.retry.scheduled_refresh_cron ?? '08:00,20:00' | |
| refreshBatchSize.value = settings.retry.refresh_batch_size ?? 5 | |
| refreshBatchInterval.value = settings.retry.refresh_batch_interval_minutes ?? 30 | |
| refreshCooldownHours.value = settings.retry.refresh_cooldown_hours ?? 12 | |
| refreshWindowHours.value = settings.basic.refresh_window_hours ?? 24 | |
| } catch (error: any) { | |
| toast.error(error?.message || '加载定时任务配置失败') | |
| } finally { | |
| isLoadingScheduledConfig.value = false | |
| } | |
| } | |
| const saveScheduledConfig = async () => { | |
| // 验证每批数量 | |
| if (isNaN(refreshBatchSize.value) || refreshBatchSize.value < 1 || refreshBatchSize.value > 20) { | |
| toast.error('每批数量必须在 1-20 之间') | |
| return | |
| } | |
| // 验证批次间隔 | |
| if (isNaN(refreshBatchInterval.value) || refreshBatchInterval.value < 5 || refreshBatchInterval.value > 120) { | |
| toast.error('批次间隔必须在 5-120 分钟之间') | |
| return | |
| } | |
| // 验证冷却时间 | |
| if (isNaN(refreshCooldownHours.value) || refreshCooldownHours.value < 1 || refreshCooldownHours.value > 48) { | |
| toast.error('冷却时间必须在 1-48 小时之间') | |
| return | |
| } | |
| // 验证过期刷新窗口 | |
| if (isNaN(refreshWindowHours.value) || !Number.isInteger(refreshWindowHours.value)) { | |
| toast.error('过期刷新窗口必须是有效的整数') | |
| return | |
| } | |
| if (refreshWindowHours.value < 1 || refreshWindowHours.value > 168) { | |
| toast.error('过期刷新窗口必须在 1-168 小时之间') | |
| return | |
| } | |
| isSavingScheduledConfig.value = true | |
| try { | |
| // 使用缓存的配置,避免重复API调用 | |
| const settings = cachedSettings.value || await settingsApi.get() | |
| settings.retry.scheduled_refresh_enabled = scheduledRefreshEnabled.value | |
| settings.retry.scheduled_refresh_cron = scheduledRefreshCron.value | |
| settings.retry.refresh_batch_size = refreshBatchSize.value | |
| settings.retry.refresh_batch_interval_minutes = refreshBatchInterval.value | |
| settings.retry.refresh_cooldown_hours = refreshCooldownHours.value | |
| settings.basic.refresh_window_hours = refreshWindowHours.value | |
| await settingsApi.update(settings) | |
| cachedSettings.value = settings // 更新缓存 | |
| toast.success('定时任务配置已保存') | |
| } catch (error: any) { | |
| toast.error(error?.message || '保存定时任务配置失败') | |
| } finally { | |
| isSavingScheduledConfig.value = false | |
| } | |
| } | |
| const clearTaskLogs = async () => { | |
| const confirmed = await confirmDialog.ask({ | |
| title: '清空当前日志', | |
| message: '确定要清空当前任务日志吗?', | |
| confirmText: '清空', | |
| }) | |
| if (!confirmed) return | |
| const clearLogsFor = (kind: TaskKind) => { | |
| const task = getTaskByKind(kind) | |
| if (!task) return | |
| if (!isTaskActive(task)) { | |
| markTaskCleared(kind, task) | |
| clearTaskSnapshot(kind, true) | |
| return | |
| } | |
| const logs = (task.logs || []) as TaskLogLine[] | |
| if (!logs.length) return | |
| const marker = logs[logs.length - 1] | |
| setLogClearMarker(kind, marker) | |
| writeClearMarker(TASK_KEYS[kind].clearKey, marker) | |
| } | |
| clearLogsFor('register') | |
| clearLogsFor('login') | |
| automationError.value = '' | |
| toast.success('当前日志已清空') | |
| } | |
| const filterLogsAfterMarker = (logs: TaskLogLine[], marker: TaskLogLine | null) => { | |
| if (!marker) return logs | |
| for (let i = logs.length - 1; i >= 0; i -= 1) { | |
| const item = logs[i] | |
| if (item.time === marker.time && item.level === marker.level && item.message === marker.message) { | |
| return logs.slice(i + 1) | |
| } | |
| } | |
| // Marker not found (e.g., backend truncates to last N logs) — show current logs so new logs keep appearing. | |
| return logs | |
| } | |
| const cancelRegister = async (taskId: string) => { | |
| try { | |
| await accountsApi.cancelRegisterTask(taskId, 'cancelled_by_user') | |
| await refreshTaskSnapshot() | |
| toast.success('已请求中断注册任务') | |
| } catch (error: any) { | |
| toast.error(error?.message || '中断注册任务失败') | |
| } | |
| } | |
| const cancelLogin = async (taskId: string) => { | |
| try { | |
| await accountsApi.cancelLoginTask(taskId, 'cancelled_by_user') | |
| await refreshTaskSnapshot() | |
| toast.success('已请求中断刷新任务') | |
| } catch (error: any) { | |
| toast.error(error?.message || '中断刷新任务失败') | |
| } | |
| } | |
| const toggleMoreActions = () => { | |
| showMoreActions.value = !showMoreActions.value | |
| } | |
| const closeMoreActions = () => { | |
| showMoreActions.value = false | |
| } | |
| const handleMoreActionsClick = (event: MouseEvent) => { | |
| if (!showMoreActions.value) return | |
| const target = event.target as Node | |
| if (moreActionsRef.value && !moreActionsRef.value.contains(target)) { | |
| showMoreActions.value = false | |
| } | |
| } | |
| // 监听标签页切换,自动加载历史记录 | |
| watch(activeTaskTab, async (newTab) => { | |
| if (newTab === 'history') { | |
| await fetchTaskHistory() | |
| } else if (newTab === 'scheduled') { | |
| await loadScheduledConfig() | |
| } | |
| }) | |
| onMounted(async () => { | |
| hydrateTaskCache() | |
| await refreshAccounts() | |
| await loadCurrentTasks() | |
| startBackgroundTaskPolling() | |
| document.addEventListener('click', handleMoreActionsClick) | |
| }) | |
| const registerLogs = computed(() => { | |
| const logs = registerTask.value?.logs || [] | |
| return filterLogsAfterMarker(logs as TaskLogLine[], registerLogClearMarker.value) | |
| }) | |
| const loginLogs = computed(() => { | |
| const logs = loginTask.value?.logs || [] | |
| return filterLogsAfterMarker(logs as TaskLogLine[], loginLogClearMarker.value) | |
| }) | |
| const scrollTaskLogsToBottom = async () => { | |
| await nextTick() | |
| const container = taskLogsRef.value | |
| if (!container) return | |
| container.scrollTop = container.scrollHeight | |
| } | |
| watch([registerLogs, loginLogs, isTaskOpen], async () => { | |
| if (!isTaskOpen.value) return | |
| await scrollTaskLogsToBottom() | |
| }, { deep: true }) | |
| const isTaskRunning = computed(() => { | |
| const registerStatus = registerTask.value?.status | |
| const loginStatus = loginTask.value?.status | |
| return registerStatus === 'running' || | |
| registerStatus === 'pending' || | |
| loginStatus === 'running' || | |
| loginStatus === 'pending' | |
| }) | |
| const taskProgressText = computed(() => { | |
| const register = registerTask.value | |
| const login = loginTask.value | |
| const registerActive = register?.status === 'running' || register?.status === 'pending' | |
| const loginActive = login?.status === 'running' || login?.status === 'pending' | |
| if (registerActive && loginActive) { | |
| return `注册 ${register.progress}/${register.count} | 刷新 ${login.progress}/${login.account_ids.length}` | |
| } | |
| if (registerActive) { | |
| return `注册 ${register.progress}/${register.count}` | |
| } | |
| if (loginActive) { | |
| return `刷新 ${login.progress}/${login.account_ids.length}` | |
| } | |
| return '' | |
| }) | |
| onBeforeUnmount(() => { | |
| clearRegisterTimer() | |
| clearLoginTimer() | |
| clearBackgroundTaskTimer() | |
| document.removeEventListener('click', handleMoreActionsClick) | |
| }) | |
| const statusLabel = (account: AdminAccount) => { | |
| // 检查是否正在刷新 | |
| if (refreshingAccountIds.value.has(account.id)) { | |
| return '刷新中' | |
| } | |
| if (account.cooldown_reason?.includes('429') && account.cooldown_seconds > 0) { | |
| return '429限流' | |
| } | |
| if (account.disabled) { | |
| if (account.disabled_reason?.includes('403')) { | |
| return '403 禁用' | |
| } | |
| return '手动禁用' | |
| } | |
| if (account.status === '已过期') { | |
| return '已过期' | |
| } | |
| if (account.status === '即将过期') { | |
| return '即将过期' | |
| } | |
| return '正常' | |
| } | |
| const statusClass = (account: AdminAccount) => { | |
| const status = statusLabel(account) | |
| if (status === '刷新中') { | |
| return 'bg-sky-500 text-white' | |
| } | |
| if (status === '429限流' || status === '即将过期') { | |
| return 'bg-amber-200 text-amber-900' | |
| } | |
| if (status === '已过期') { | |
| return 'bg-destructive/10 text-destructive' | |
| } | |
| if (status === '手动禁用') { | |
| return 'bg-muted text-muted-foreground' | |
| } | |
| if (status === '403 禁用') { | |
| return 'bg-rose-600 text-white' | |
| } | |
| return 'bg-emerald-500 text-white' | |
| } | |
| const shouldShowEnable = (account: AdminAccount) => { | |
| if (account.cooldown_reason?.includes('429') && account.cooldown_seconds > 0) { | |
| return true | |
| } | |
| return account.disabled | |
| } | |
| const displayRemaining = (value: string) => { | |
| if (value === '已过期') return '过期' | |
| if (value === '未设置') return '未设置' | |
| return value | |
| } | |
| const remainingClass = (account: AdminAccount) => { | |
| if (account.status === '已过期') return 'text-rose-600' | |
| if (account.status === '即将过期') return 'text-amber-700' | |
| if (account.status === '未设置') return 'text-muted-foreground' | |
| return 'text-emerald-600' | |
| } | |
| const trialBadgeClass = (days: number | null | undefined) => { | |
| if (days == null) return '' | |
| if (days > 7) return 'bg-emerald-500 text-white' | |
| if (days >= 3) return 'bg-amber-500 text-white' | |
| return 'bg-rose-500 text-white' | |
| } | |
| const rowClass = (account: AdminAccount) => { | |
| const status = statusLabel(account) | |
| if (status === '手动禁用' || status === '已过期' || status === '403 禁用') { | |
| return 'bg-muted/70' | |
| } | |
| return '' | |
| } | |
| const toggleSelect = (accountId: string) => { | |
| const next = new Set(selectedIds.value) | |
| if (next.has(accountId)) { | |
| next.delete(accountId) | |
| } else { | |
| next.add(accountId) | |
| } | |
| selectedIds.value = next | |
| } | |
| const toggleSelectAll = () => { | |
| if (allSelected.value) { | |
| selectedIds.value = new Set() | |
| return | |
| } | |
| selectedIds.value = new Set(filteredAccounts.value.map(account => account.id)) | |
| } | |
| const getConfigId = (acc: AccountConfigItem, index: number) => | |
| acc.id || `account_${index + 1}` | |
| const loadConfigList = async () => { | |
| const response = await accountsApi.getConfig() | |
| return response.accounts.map((acc, index) => ({ | |
| ...acc, | |
| id: getConfigId(acc, index), | |
| })) | |
| } | |
| const formatLogLine = (log: { time: string; level: string; message: string }) => | |
| `${log.time} [${log.level}] ${log.message}` | |
| const applyEditTarget = (list: AccountConfigItem[], accountId: string) => { | |
| let targetIndex = -1 | |
| for (let i = 0; i < list.length; i += 1) { | |
| if (list[i].id === accountId) { | |
| targetIndex = i | |
| break | |
| } | |
| } | |
| if (targetIndex === -1) { | |
| editError.value = '未找到对应账号配置。' | |
| return false | |
| } | |
| const target = list[targetIndex] | |
| editForm.value = { | |
| id: target.id, | |
| secure_c_ses: target.secure_c_ses, | |
| csesidx: target.csesidx, | |
| config_id: target.config_id, | |
| host_c_oses: target.host_c_oses, | |
| expires_at: target.expires_at, | |
| } | |
| configAccounts.value = list | |
| editIndex.value = targetIndex | |
| isEditOpen.value = true | |
| return true | |
| } | |
| const openEdit = async (accountId: string) => { | |
| editError.value = '' | |
| try { | |
| const list = await loadConfigList() | |
| applyEditTarget(list, accountId) | |
| } catch (error: any) { | |
| editError.value = error.message || '加载账号配置失败' | |
| } | |
| } | |
| const openConfigPanel = async () => { | |
| configError.value = '' | |
| try { | |
| const response = await accountsApi.getConfig() | |
| configData.value = Array.isArray(response.accounts) ? response.accounts : [] | |
| configJson.value = JSON.stringify(maskConfig(configData.value), null, 2) | |
| configMasked.value = true | |
| isConfigOpen.value = true | |
| } catch (error: any) { | |
| configError.value = error.message || '加载账号配置失败' | |
| } | |
| } | |
| const closeConfigPanel = () => { | |
| isConfigOpen.value = false | |
| configError.value = '' | |
| configMasked.value = false | |
| } | |
| const getConfigFromEditor = () => { | |
| const parsed = JSON.parse(configJson.value) | |
| if (!Array.isArray(parsed)) { | |
| throw new Error('配置格式必须是数组。') | |
| } | |
| return parsed as AccountConfigItem[] | |
| } | |
| const maskValue = (value: unknown) => { | |
| if (typeof value !== 'string') return value | |
| if (!value) return value | |
| if (value.length <= 6) return `${value.slice(0, 2)}****` | |
| return `${value.slice(0, 3)}****` | |
| } | |
| const maskConfig = (list: AccountConfigItem[]) => { | |
| const fields = new Set([ | |
| 'secure_c_ses', | |
| 'csesidx', | |
| 'config_id', | |
| 'host_c_oses', | |
| 'mail_password', | |
| 'mail_refresh_token', | |
| 'mail_client_id', | |
| ]) | |
| return list.map((item) => { | |
| const next = { ...item } | |
| fields.forEach((field) => { | |
| const value = (next as Record<string, unknown>)[field] | |
| if (value) { | |
| ;(next as Record<string, unknown>)[field] = maskValue(value) | |
| } | |
| }) | |
| return next | |
| }) | |
| } | |
| const toggleConfigMask = () => { | |
| configError.value = '' | |
| if (!configMasked.value) { | |
| try { | |
| configData.value = getConfigFromEditor() | |
| } catch (error: any) { | |
| configError.value = error.message || 'JSON 格式错误' | |
| return | |
| } | |
| configJson.value = JSON.stringify(maskConfig(configData.value), null, 2) | |
| configMasked.value = true | |
| return | |
| } | |
| configJson.value = JSON.stringify(configData.value, null, 2) | |
| configMasked.value = false | |
| } | |
| const saveConfigPanel = async () => { | |
| configError.value = '' | |
| try { | |
| const parsed = getConfigFromEditor() | |
| await accountsStore.updateConfig(parsed) | |
| toast.success('配置保存成功') | |
| closeConfigPanel() | |
| } catch (error: any) { | |
| configError.value = error.message || '保存失败' | |
| toast.error(error.message || '保存失败') | |
| } | |
| } | |
| const closeEdit = () => { | |
| isEditOpen.value = false | |
| editError.value = '' | |
| } | |
| const saveEdit = async () => { | |
| if (editIndex.value === null) return | |
| const next = [...configAccounts.value] | |
| next[editIndex.value] = { | |
| ...next[editIndex.value], | |
| id: editForm.value.id, | |
| secure_c_ses: editForm.value.secure_c_ses, | |
| csesidx: editForm.value.csesidx, | |
| config_id: editForm.value.config_id, | |
| host_c_oses: editForm.value.host_c_oses || undefined, | |
| expires_at: editForm.value.expires_at || undefined, | |
| } | |
| try { | |
| await accountsStore.updateConfig(next) | |
| toast.success('账号编辑成功') | |
| closeEdit() | |
| } catch (error: any) { | |
| editError.value = error.message || '保存失败' | |
| toast.error(error.message || '保存失败') | |
| } | |
| } | |
| const formatOpErrors = (errors: string[]) => { | |
| if (!errors.length) return '' | |
| const sample = errors[0] | |
| return `失败 ${errors.length} 个${sample ? `,示例:${sample}` : ''}` | |
| } | |
| const handleOpResult = (result: { ok: boolean; errors: string[] }, successMessage: string, failMessage: string) => { | |
| if (result.ok) { | |
| toast.success(successMessage) | |
| return true | |
| } | |
| const detail = formatOpErrors(result.errors) | |
| toast.error(detail ? `${failMessage}(${detail})` : failMessage) | |
| return false | |
| } | |
| const handleBulkEnable = async () => { | |
| if (isOperating.value) return | |
| try { | |
| const result = await accountsStore.bulkEnable(Array.from(selectedIds.value)) | |
| if (handleOpResult(result, '批量启用成功', '批量启用失败')) { | |
| selectedIds.value = new Set() | |
| } | |
| } catch (error: any) { | |
| toast.error(error.message || '批量启用失败') | |
| } | |
| } | |
| const handleBulkDisable = async () => { | |
| const confirmed = await confirmDialog.ask({ | |
| title: '批量禁用', | |
| message: '确定要批量禁用选中的账号吗?', | |
| }) | |
| if (!confirmed) return | |
| if (isOperating.value) return | |
| try { | |
| const result = await accountsStore.bulkDisable(Array.from(selectedIds.value)) | |
| if (handleOpResult(result, '批量禁用成功', '批量禁用失败')) { | |
| selectedIds.value = new Set() | |
| } | |
| } catch (error: any) { | |
| toast.error(error.message || '批量禁用失败') | |
| } | |
| } | |
| const handleBulkDelete = async () => { | |
| if (isOperating.value) return | |
| const confirmed = await confirmDialog.ask({ | |
| title: '批量删除', | |
| message: '确定要批量删除选中的账号吗?', | |
| confirmText: '删除', | |
| }) | |
| if (!confirmed) return | |
| try { | |
| const result = await accountsStore.bulkDelete(Array.from(selectedIds.value)) | |
| if (handleOpResult(result, '批量删除成功', '批量删除失败')) { | |
| selectedIds.value = new Set() | |
| } | |
| } catch (error: any) { | |
| toast.error(error.message || '批量删除失败') | |
| } | |
| } | |
| const handleEnable = async (accountId: string) => { | |
| if (isOperating.value) return | |
| try { | |
| const result = await accountsStore.enableAccount(accountId) | |
| handleOpResult(result, '账号已启用', '启用失败') | |
| } catch (error: any) { | |
| toast.error(error.message || '启用失败') | |
| } | |
| } | |
| const handleDisable = async (accountId: string) => { | |
| if (isOperating.value) return | |
| const confirmed = await confirmDialog.ask({ | |
| title: '禁用账号', | |
| message: '确定要禁用该账号吗?', | |
| }) | |
| if (!confirmed) return | |
| try { | |
| const result = await accountsStore.disableAccount(accountId) | |
| handleOpResult(result, '账号已禁用', '禁用失败') | |
| } catch (error: any) { | |
| toast.error(error.message || '禁用失败') | |
| } | |
| } | |
| const handleDelete = async (accountId: string) => { | |
| if (isOperating.value) return | |
| const confirmed = await confirmDialog.ask({ | |
| title: '删除账号', | |
| message: '确定要删除该账号吗?', | |
| confirmText: '删除', | |
| }) | |
| if (!confirmed) return | |
| try { | |
| const result = await accountsStore.deleteAccount(accountId) | |
| handleOpResult(result, '账号已删除', '删除失败') | |
| } catch (error: any) { | |
| toast.error(error.message || '删除失败') | |
| } | |
| } | |
| let registerTimer: number | null = null | |
| let loginTimer: number | null = null | |
| let backgroundTaskTimer: number | null = null | |
| let backgroundTaskPending = false | |
| const clearRegisterTimer = () => { | |
| if (registerTimer !== null) { | |
| window.clearInterval(registerTimer) | |
| registerTimer = null | |
| } | |
| } | |
| const clearLoginTimer = () => { | |
| if (loginTimer !== null) { | |
| window.clearInterval(loginTimer) | |
| loginTimer = null | |
| } | |
| } | |
| const clearBackgroundTaskTimer = () => { | |
| if (backgroundTaskTimer !== null) { | |
| window.clearInterval(backgroundTaskTimer) | |
| backgroundTaskTimer = null | |
| } | |
| backgroundTaskPending = false | |
| } | |
| const getTaskResultType = ( | |
| status: string, | |
| success: number, | |
| fail: number, | |
| total?: number, | |
| ) => { | |
| if (status === 'pending' || status === 'running' || status === 'cancelled') return status | |
| const s = Number.isFinite(success) ? success : 0 | |
| const f = Number.isFinite(fail) ? fail : 0 | |
| const t = Number.isFinite(total) ? total : s + f | |
| if (s > 0 && f > 0) return 'partial' | |
| if (s > 0 && f === 0) return 'success' | |
| if (f > 0 && s === 0) return 'failed' | |
| if (t === 0) return 'none' | |
| return 'none' | |
| } | |
| const formatTaskStatus = (task: any) => { | |
| const status = task?.status || '' | |
| const success = task?.success_count ?? 0 | |
| const fail = task?.fail_count ?? 0 | |
| const total = Number.isFinite(task?.total) ? task.total : undefined | |
| const result = getTaskResultType(status, success, fail, total) | |
| if (result === 'pending') return '等待中' | |
| if (result === 'running') return '执行中' | |
| if (result === 'cancelled') return '已中断' | |
| if (result === 'success') return '已完成(全部成功)' | |
| if (result === 'failed') return '已完成(全部失败)' | |
| if (result === 'partial') return '已完成(部分失败)' | |
| return '已完成' | |
| } | |
| const getHistoryTotal = (record: any) => { | |
| const total = Number.isFinite(record?.total) ? record.total : undefined | |
| if (typeof total === 'number') return total | |
| const progress = Number.isFinite(record?.progress) ? record.progress : 0 | |
| return progress | |
| } | |
| const getHistoryStatusTextClass = (record: any) => { | |
| const status = record?.status | |
| const success = record?.success_count ?? 0 | |
| const fail = record?.fail_count ?? 0 | |
| const total = getHistoryTotal(record) | |
| const result = getTaskResultType(status, success, fail, total) | |
| if (result === 'running' || result === 'pending') return 'text-sky-600' | |
| if (result === 'success') return 'text-emerald-600' | |
| if (result === 'failed') return 'text-rose-600' | |
| if (result === 'partial') return 'text-amber-600' | |
| if (result === 'cancelled') return 'text-muted-foreground' | |
| return 'text-muted-foreground' | |
| } | |
| const getHistoryStatusIndicatorClass = (record: any) => { | |
| const status = record?.status | |
| const success = record?.success_count ?? 0 | |
| const fail = record?.fail_count ?? 0 | |
| const total = getHistoryTotal(record) | |
| const result = getTaskResultType(status, success, fail, total) | |
| if (result === 'running' || result === 'pending') return 'bg-sky-400' | |
| if (result === 'success') return 'bg-emerald-400' | |
| if (result === 'failed') return 'bg-rose-500' | |
| if (result === 'partial') return 'bg-amber-400' | |
| return 'bg-muted-foreground' | |
| } | |
| const getTaskStatusIndicatorClass = (task: RegisterTask | LoginTask) => { | |
| const status = task.status | |
| const success = task.success_count ?? 0 | |
| const fail = task.fail_count ?? 0 | |
| const total = 'count' in task ? task.count : task.account_ids?.length | |
| const result = getTaskResultType(status, success, fail, total) | |
| if (result === 'running' || result === 'pending') return 'bg-sky-400' | |
| if (result === 'success') return 'bg-emerald-400' | |
| if (result === 'failed') return 'bg-rose-500' | |
| if (result === 'partial') return 'bg-amber-400' | |
| return 'bg-muted-foreground' | |
| } | |
| const updateRegisterTask = async (taskId: string) => { | |
| let task: RegisterTask | |
| try { | |
| task = await accountsApi.getRegisterTask(taskId) | |
| } catch (error: any) { | |
| // 任务已不存在(被清理/过期/后端重启):静默清理,避免弹窗显示 "Not found" | |
| if (error?.status === 404 || error?.message === 'Not found') { | |
| clearRegisterTimer() | |
| isRegistering.value = false | |
| return | |
| } | |
| throw error | |
| } | |
| syncRegisterTask(task) | |
| if (task.status !== 'running' && task.status !== 'pending') { | |
| isRegistering.value = false | |
| clearRegisterTimer() | |
| await refreshAccounts() | |
| // 显示任务完成通知 | |
| const successCount = task.success_count || 0 | |
| const failCount = task.fail_count || 0 | |
| if (successCount > 0 && failCount > 0) { | |
| toast.success(`注册任务完成:成功 ${successCount},失败 ${failCount}`) | |
| } else if (successCount > 0 && failCount === 0) { | |
| toast.success(`注册任务完成:全部成功 (${successCount})`) | |
| } else if (failCount > 0 && successCount === 0) { | |
| toast.error(`注册任务完成:全部失败 (${failCount})`) | |
| } else { | |
| toast.error('注册任务失败') | |
| } | |
| await fetchTaskHistory() | |
| return | |
| } | |
| } | |
| const updateLoginTask = async (taskId: string) => { | |
| let task: LoginTask | |
| try { | |
| task = await accountsApi.getLoginTask(taskId) | |
| } catch (error: any) { | |
| // 任务已不存在(被清理/过期/后端重启):静默清理,避免弹窗显示 "Not found" | |
| if (error?.status === 404 || error?.message === 'Not found') { | |
| clearLoginTimer() | |
| isRefreshing.value = false | |
| refreshingAccountIds.value = new Set() // 清空刷新状态 | |
| return | |
| } | |
| throw error | |
| } | |
| syncLoginTask(task) | |
| // 更新正在刷新的账户列表 | |
| if (task.status === 'running' || task.status === 'pending') { | |
| refreshingAccountIds.value = new Set(task.account_ids || []) | |
| } else { | |
| refreshingAccountIds.value = new Set() // 任务完成,清空刷新状态 | |
| } | |
| if (task.status !== 'running' && task.status !== 'pending') { | |
| isRefreshing.value = false | |
| clearLoginTimer() | |
| await refreshAccounts() | |
| // 显示任务完成通知 | |
| const successCount = task.success_count || 0 | |
| const failCount = task.fail_count || 0 | |
| if (successCount > 0 && failCount > 0) { | |
| toast.success(`刷新任务完成:成功 ${successCount},失败 ${failCount}`) | |
| } else if (successCount > 0 && failCount === 0) { | |
| toast.success(`刷新任务完成:全部成功 (${successCount})`) | |
| } else if (failCount > 0 && successCount === 0) { | |
| toast.error(`刷新任务完成:全部失败 (${failCount})`) | |
| } else { | |
| toast.error('刷新任务失败') | |
| } | |
| await fetchTaskHistory() | |
| return | |
| } | |
| } | |
| const startRegisterPolling = (taskId: string) => { | |
| clearRegisterTimer() | |
| registerTimer = window.setInterval(() => { | |
| updateRegisterTask(taskId).catch((error) => { | |
| automationError.value = error?.message || '注册任务更新失败' | |
| clearRegisterTimer() | |
| isRegistering.value = false | |
| }) | |
| }, 3000) | |
| } | |
| const startLoginPolling = (taskId: string) => { | |
| clearLoginTimer() | |
| loginTimer = window.setInterval(() => { | |
| updateLoginTask(taskId).catch((error) => { | |
| automationError.value = error?.message || '刷新任务更新失败' | |
| clearLoginTimer() | |
| isRefreshing.value = false | |
| }) | |
| }, 3000) | |
| } | |
| const startBackgroundTaskPolling = () => { | |
| if (backgroundTaskTimer !== null) return | |
| backgroundTaskTimer = window.setInterval(async () => { | |
| if (backgroundTaskPending) return | |
| if (isTaskOpen.value) return | |
| if (registerTimer !== null || loginTimer !== null) return | |
| if (!isRegistering.value && !isRefreshing.value && !registerTask.value && !loginTask.value) return | |
| backgroundTaskPending = true | |
| try { | |
| await loadCurrentTasks() | |
| } catch (error: any) { | |
| automationError.value = error?.message || '后台刷新失败' | |
| } finally { | |
| backgroundTaskPending = false | |
| } | |
| }, 6000) | |
| } | |
| const loadCurrentTasks = async () => { | |
| await loadCurrentTaskByKind('register') | |
| await loadCurrentTaskByKind('login') | |
| } | |
| const handleRegister = async () => { | |
| automationError.value = '' | |
| isRegistering.value = true | |
| try { | |
| const count = Number.isFinite(registerCount.value) && registerCount.value > 0 | |
| ? registerCount.value | |
| : undefined | |
| const task = await accountsApi.startRegister(count, undefined, selectedMailProvider.value) | |
| syncRegisterTask(task) | |
| startRegisterPolling(task.id) | |
| isRegisterOpen.value = false | |
| isTaskOpen.value = true | |
| } catch (error: any) { | |
| automationError.value = error.message || '启动注册失败' | |
| isRegistering.value = false | |
| } | |
| } | |
| // 统一的刷新函数 - 所有刷新入口都调用这个 | |
| const startRefresh = async (accountIds: string[]) => { | |
| if (!accountIds.length) return | |
| automationError.value = '' | |
| isRefreshing.value = true | |
| try { | |
| const task = await accountsApi.startLogin(accountIds) | |
| syncLoginTask(task) | |
| // 更新正在刷新的账户列表 | |
| refreshingAccountIds.value = new Set(task.account_ids || []) | |
| startLoginPolling(task.id) | |
| // 自动打开任务状态弹窗 | |
| openTaskModal() | |
| } catch (error: any) { | |
| automationError.value = error.message || '启动刷新失败' | |
| toast.error(error.message || '启动刷新失败') | |
| isRefreshing.value = false | |
| } | |
| } | |
| const handleRefreshSelected = async () => { | |
| if (!selectedIds.value.size) return | |
| await startRefresh(Array.from(selectedIds.value)) | |
| } | |
| const handleRefreshExpiring = async () => { | |
| automationError.value = '' | |
| isRefreshing.value = true | |
| try { | |
| const taskOrIdle = await accountsApi.checkLogin() | |
| if (taskOrIdle && 'id' in taskOrIdle) { | |
| syncLoginTask(taskOrIdle) | |
| // 更新正在刷新的账户列表 | |
| refreshingAccountIds.value = new Set(taskOrIdle.account_ids || []) | |
| startLoginPolling(taskOrIdle.id) | |
| // 自动打开任务状态弹窗 | |
| openTaskModal() | |
| return | |
| } | |
| // 没有新任务时,尝试读取当前任务(可能已有 running/pending) | |
| const current = await accountsApi.getLoginCurrent() | |
| if (current && 'id' in current) { | |
| syncLoginTask(current) | |
| // 更新正在刷新的账户列表 | |
| refreshingAccountIds.value = new Set(current.account_ids || []) | |
| startLoginPolling(current.id) | |
| openTaskModal() | |
| return | |
| } | |
| isRefreshing.value = false | |
| } catch (error: any) { | |
| automationError.value = error.message || '触发刷新失败' | |
| toast.error(error.message || '触发刷新失败') | |
| isRefreshing.value = false | |
| } | |
| } | |
| </script> | |