| <template> |
| <BaseDialog |
| :show="show" |
| :title="t('admin.accounts.syncFromCrsTitle')" |
| width="normal" |
| close-on-click-outside |
| @close="handleClose" |
| > |
| |
| <form |
| v-if="currentStep === 'input'" |
| id="sync-from-crs-form" |
| class="space-y-4" |
| @submit.prevent="handlePreview" |
| > |
| <div class="text-sm text-gray-600 dark:text-dark-300"> |
| {{ t('admin.accounts.syncFromCrsDesc') }} |
| </div> |
| <div |
| class="rounded-lg bg-gray-50 p-3 text-xs text-gray-500 dark:bg-dark-700/60 dark:text-dark-400" |
| > |
| {{ t('admin.accounts.crsUpdateBehaviorNote') }} |
| </div> |
| <div |
| class="rounded-lg border border-amber-200 bg-amber-50 p-3 text-xs text-amber-600 dark:border-amber-800 dark:bg-amber-900/20 dark:text-amber-400" |
| > |
| {{ t('admin.accounts.crsVersionRequirement') }} |
| </div> |
| |
| <div class="grid grid-cols-1 gap-4"> |
| <div> |
| <label for="crs-base-url" class="input-label">{{ t('admin.accounts.crsBaseUrl') }}</label> |
| <input |
| id="crs-base-url" |
| v-model="form.base_url" |
| type="text" |
| class="input" |
| required |
| :placeholder="t('admin.accounts.crsBaseUrlPlaceholder')" |
| /> |
| </div> |
| |
| <div class="grid grid-cols-1 gap-4 sm:grid-cols-2"> |
| <div> |
| <label for="crs-username" class="input-label">{{ t('admin.accounts.crsUsername') }}</label> |
| <input id="crs-username" v-model="form.username" type="text" class="input" required autocomplete="username" /> |
| </div> |
| <div> |
| <label for="crs-password" class="input-label">{{ t('admin.accounts.crsPassword') }}</label> |
| <input |
| id="crs-password" |
| v-model="form.password" |
| type="password" |
| class="input" |
| required |
| autocomplete="current-password" |
| /> |
| </div> |
| </div> |
| |
| <label class="flex items-center gap-2 text-sm text-gray-700 dark:text-dark-300"> |
| <input |
| v-model="form.sync_proxies" |
| type="checkbox" |
| class="rounded border-gray-300 dark:border-dark-600" |
| /> |
| {{ t('admin.accounts.syncProxies') }} |
| </label> |
| </div> |
| </form> |
| |
| |
| <div v-else-if="currentStep === 'preview' && previewResult" class="space-y-4"> |
| |
| <div |
| v-if="previewResult.existing_accounts.length" |
| class="rounded-lg bg-gray-50 p-3 dark:bg-dark-700/60" |
| > |
| <div class="mb-2 text-sm font-medium text-gray-700 dark:text-dark-300"> |
| {{ t('admin.accounts.crsExistingAccounts') }} |
| <span class="ml-1 text-xs text-gray-400">({{ previewResult.existing_accounts.length }})</span> |
| </div> |
| <div class="max-h-32 overflow-auto text-xs text-gray-500 dark:text-dark-400"> |
| <div |
| v-for="acc in previewResult.existing_accounts" |
| :key="acc.crs_account_id" |
| class="flex items-center gap-2 py-0.5" |
| > |
| <span |
| class="inline-block rounded bg-blue-100 px-1.5 py-0.5 text-[10px] font-medium text-blue-700 dark:bg-blue-900/30 dark:text-blue-400" |
| >{{ acc.platform }} / {{ acc.type }}</span> |
| <span class="truncate">{{ acc.name }}</span> |
| </div> |
| </div> |
| </div> |
| |
| |
| <div v-if="previewResult.new_accounts.length"> |
| <div class="mb-2 flex items-center justify-between"> |
| <div class="text-sm font-medium text-gray-900 dark:text-white"> |
| {{ t('admin.accounts.crsNewAccounts') }} |
| <span class="ml-1 text-xs text-gray-400">({{ previewResult.new_accounts.length }})</span> |
| </div> |
| <div class="flex gap-2"> |
| <button |
| type="button" |
| class="text-xs text-blue-600 hover:text-blue-700 dark:text-blue-400" |
| @click="selectAll" |
| >{{ t('admin.accounts.crsSelectAll') }}</button> |
| <button |
| type="button" |
| class="text-xs text-gray-500 hover:text-gray-600 dark:text-gray-400" |
| @click="selectNone" |
| >{{ t('admin.accounts.crsSelectNone') }}</button> |
| </div> |
| </div> |
| <div |
| class="max-h-48 overflow-auto rounded-lg border border-gray-200 p-2 dark:border-dark-600" |
| > |
| <label |
| v-for="acc in previewResult.new_accounts" |
| :key="acc.crs_account_id" |
| class="flex cursor-pointer items-center gap-2 rounded px-2 py-1.5 hover:bg-gray-50 dark:hover:bg-dark-700/40" |
| > |
| <input |
| type="checkbox" |
| :checked="selectedIds.has(acc.crs_account_id)" |
| class="rounded border-gray-300 dark:border-dark-600" |
| @change="toggleSelect(acc.crs_account_id)" |
| /> |
| <span |
| class="inline-block rounded bg-green-100 px-1.5 py-0.5 text-[10px] font-medium text-green-700 dark:bg-green-900/30 dark:text-green-400" |
| >{{ acc.platform }} / {{ acc.type }}</span> |
| <span class="truncate text-sm text-gray-700 dark:text-dark-300">{{ acc.name }}</span> |
| </label> |
| </div> |
| <div class="mt-1 text-xs text-gray-400"> |
| {{ t('admin.accounts.crsSelectedCount', { count: selectedIds.size }) }} |
| </div> |
| </div> |
| |
| |
| <div class="flex items-center gap-2 text-xs text-gray-500 dark:text-dark-400"> |
| <span>{{ t('admin.accounts.syncProxies') }}:</span> |
| <span :class="form.sync_proxies ? 'text-green-600 dark:text-green-400' : 'text-gray-400 dark:text-dark-500'"> |
| {{ form.sync_proxies ? t('common.yes') : t('common.no') }} |
| </span> |
| </div> |
| |
| |
| <div |
| v-if="!previewResult.new_accounts.length" |
| class="rounded-lg bg-gray-50 p-4 text-center text-sm text-gray-500 dark:bg-dark-700/60 dark:text-dark-400" |
| > |
| {{ t('admin.accounts.crsNoNewAccounts') }} |
| <span v-if="previewResult.existing_accounts.length"> |
| {{ t('admin.accounts.crsWillUpdate', { count: previewResult.existing_accounts.length }) }} |
| </span> |
| </div> |
| </div> |
| |
| |
| <div v-else-if="currentStep === 'result' && result" class="space-y-4"> |
| <div |
| class="space-y-2 rounded-xl border border-gray-200 p-4 dark:border-dark-700" |
| > |
| <div class="text-sm font-medium text-gray-900 dark:text-white"> |
| {{ t('admin.accounts.syncResult') }} |
| </div> |
| <div class="text-sm text-gray-700 dark:text-dark-300"> |
| {{ t('admin.accounts.syncResultSummary', result) }} |
| </div> |
| |
| <div v-if="errorItems.length" class="mt-2"> |
| <div class="text-sm font-medium text-red-600 dark:text-red-400"> |
| {{ t('admin.accounts.syncErrors') }} |
| </div> |
| <div |
| class="mt-2 max-h-48 overflow-auto rounded-lg bg-gray-50 p-3 font-mono text-xs dark:bg-dark-800" |
| > |
| <div v-for="(item, idx) in errorItems" :key="idx" class="whitespace-pre-wrap"> |
| {{ item.kind }} {{ item.crs_account_id }} — {{ item.action |
| }}{{ item.error ? `: ${item.error}` : '' }} |
| </div> |
| </div> |
| </div> |
| </div> |
| </div> |
| |
| <template #footer> |
| <div class="flex justify-end gap-3"> |
| |
| <template v-if="currentStep === 'input'"> |
| <button |
| class="btn btn-secondary" |
| type="button" |
| :disabled="previewing" |
| @click="handleClose" |
| > |
| {{ t('common.cancel') }} |
| </button> |
| <button |
| class="btn btn-primary" |
| type="submit" |
| form="sync-from-crs-form" |
| :disabled="previewing" |
| > |
| {{ previewing ? t('admin.accounts.crsPreviewing') : t('admin.accounts.crsPreview') }} |
| </button> |
| </template> |
| |
| |
| <template v-else-if="currentStep === 'preview'"> |
| <button |
| class="btn btn-secondary" |
| type="button" |
| :disabled="syncing" |
| @click="handleBack" |
| > |
| {{ t('admin.accounts.crsBack') }} |
| </button> |
| <button |
| class="btn btn-primary" |
| type="button" |
| :disabled="syncing || hasNewButNoneSelected" |
| @click="handleSync" |
| > |
| {{ syncing ? t('admin.accounts.syncing') : t('admin.accounts.syncNow') }} |
| </button> |
| </template> |
| |
| |
| <template v-else-if="currentStep === 'result'"> |
| <button class="btn btn-secondary" type="button" @click="handleClose"> |
| {{ t('common.close') }} |
| </button> |
| </template> |
| </div> |
| </template> |
| </BaseDialog> |
| </template> |
| |
| <script setup lang="ts"> |
| import { computed, reactive, ref, watch } from 'vue' |
| import { useI18n } from 'vue-i18n' |
| import BaseDialog from '@/components/common/BaseDialog.vue' |
| import { useAppStore } from '@/stores/app' |
| import { adminAPI } from '@/api/admin' |
| import type { PreviewFromCRSResult } from '@/api/admin/accounts' |
| |
| interface Props { |
| show: boolean |
| } |
| |
| interface Emits { |
| (e: 'close'): void |
| (e: 'synced'): void |
| } |
| |
| const props = defineProps<Props>() |
| const emit = defineEmits<Emits>() |
| |
| const { t } = useI18n() |
| const appStore = useAppStore() |
| |
| type Step = 'input' | 'preview' | 'result' |
| const currentStep = ref<Step>('input') |
| const previewing = ref(false) |
| const syncing = ref(false) |
| const previewResult = ref<PreviewFromCRSResult | null>(null) |
| const selectedIds = ref(new Set<string>()) |
| const result = ref<Awaited<ReturnType<typeof adminAPI.accounts.syncFromCrs>> | null>(null) |
| |
| const form = reactive({ |
| base_url: '', |
| username: '', |
| password: '', |
| sync_proxies: true |
| }) |
| |
| const hasNewButNoneSelected = computed(() => { |
| if (!previewResult.value) return false |
| return previewResult.value.new_accounts.length > 0 && selectedIds.value.size === 0 |
| }) |
| |
| const errorItems = computed(() => { |
| if (!result.value?.items) return [] |
| return result.value.items.filter( |
| (i) => i.action === 'failed' || (i.action === 'skipped' && i.error !== 'not selected') |
| ) |
| }) |
| |
| watch( |
| () => props.show, |
| (open) => { |
| if (open) { |
| currentStep.value = 'input' |
| previewResult.value = null |
| selectedIds.value = new Set() |
| result.value = null |
| form.base_url = '' |
| form.username = '' |
| form.password = '' |
| form.sync_proxies = true |
| } |
| } |
| ) |
| |
| const handleClose = () => { |
| if (syncing.value || previewing.value) { |
| return |
| } |
| emit('close') |
| } |
| |
| const handleBack = () => { |
| currentStep.value = 'input' |
| previewResult.value = null |
| selectedIds.value = new Set() |
| } |
| |
| const selectAll = () => { |
| if (!previewResult.value) return |
| selectedIds.value = new Set(previewResult.value.new_accounts.map((a) => a.crs_account_id)) |
| } |
| |
| const selectNone = () => { |
| selectedIds.value = new Set() |
| } |
| |
| const toggleSelect = (id: string) => { |
| const s = new Set(selectedIds.value) |
| if (s.has(id)) { |
| s.delete(id) |
| } else { |
| s.add(id) |
| } |
| selectedIds.value = s |
| } |
| |
| const handlePreview = async () => { |
| if (!form.base_url.trim() || !form.username.trim() || !form.password.trim()) { |
| appStore.showError(t('admin.accounts.syncMissingFields')) |
| return |
| } |
| |
| previewing.value = true |
| try { |
| const res = await adminAPI.accounts.previewFromCrs({ |
| base_url: form.base_url.trim(), |
| username: form.username.trim(), |
| password: form.password |
| }) |
| previewResult.value = res |
| |
| selectedIds.value = new Set(res.new_accounts.map((a) => a.crs_account_id)) |
| currentStep.value = 'preview' |
| } catch (error: any) { |
| appStore.showError(error?.message || t('admin.accounts.crsPreviewFailed')) |
| } finally { |
| previewing.value = false |
| } |
| } |
| |
| const handleSync = async () => { |
| if (!form.base_url.trim() || !form.username.trim() || !form.password.trim()) { |
| appStore.showError(t('admin.accounts.syncMissingFields')) |
| return |
| } |
| |
| syncing.value = true |
| try { |
| const res = await adminAPI.accounts.syncFromCrs({ |
| base_url: form.base_url.trim(), |
| username: form.username.trim(), |
| password: form.password, |
| sync_proxies: form.sync_proxies, |
| selected_account_ids: [...selectedIds.value] |
| }) |
| result.value = res |
| currentStep.value = 'result' |
| |
| if (res.failed > 0) { |
| appStore.showError(t('admin.accounts.syncCompletedWithErrors', res)) |
| } else { |
| appStore.showSuccess(t('admin.accounts.syncCompleted', res)) |
| } |
| emit('synced') |
| } catch (error: any) { |
| appStore.showError(error?.message || t('admin.accounts.syncFailed')) |
| } finally { |
| syncing.value = false |
| } |
| } |
| </script> |
| |