feat(account): 添加从 CRS 同步账户功能

- 添加账户同步 API 接口 (account_handler.go)
- 实现 CRS 同步服务 (crs_sync_service.go)
- 添加前端同步对话框组件 (SyncFromCrsModal.vue)
- 更新账户管理界面支持同步操作
- 添加账户仓库批量创建方法
- 添加中英文国际化翻译
- 更新依赖注入配置
This commit is contained in:
ianshaw
2025-12-24 08:48:58 -08:00
parent adcb7bf00e
commit 6553828008
13 changed files with 1127 additions and 3 deletions

View File

@@ -244,6 +244,28 @@ export async function getAvailableModels(id: number): Promise<ClaudeModel[]> {
return data;
}
export async function syncFromCrs(params: {
base_url: string;
username: string;
password: string;
sync_proxies?: boolean;
}): Promise<{
created: number;
updated: number;
skipped: number;
failed: number;
items: Array<{
crs_account_id: string;
kind: string;
name: string;
action: string;
error?: string;
}>;
}> {
const { data } = await apiClient.post('/admin/accounts/sync/crs', params);
return data;
}
export const accountsAPI = {
list,
getById,
@@ -263,6 +285,7 @@ export const accountsAPI = {
generateAuthUrl,
exchangeCode,
batchCreate,
syncFromCrs,
};
export default accountsAPI;

View File

@@ -0,0 +1,165 @@
<template>
<Modal
:show="show"
:title="t('admin.accounts.syncFromCrsTitle')"
size="lg"
close-on-click-outside
@close="handleClose"
>
<div class="space-y-4">
<div class="text-sm text-gray-600 dark:text-dark-300">
{{ t('admin.accounts.syncFromCrsDesc') }}
</div>
<div class="grid grid-cols-1 gap-4">
<div>
<label class="input-label">{{ t('admin.accounts.crsBaseUrl') }}</label>
<input
v-model="form.base_url"
type="text"
class="input"
:placeholder="t('admin.accounts.crsBaseUrlPlaceholder')"
/>
</div>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div>
<label class="input-label">{{ t('admin.accounts.crsUsername') }}</label>
<input
v-model="form.username"
type="text"
class="input"
autocomplete="username"
/>
</div>
<div>
<label class="input-label">{{ t('admin.accounts.crsPassword') }}</label>
<input
v-model="form.password"
type="password"
class="input"
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>
<div v-if="result" class="rounded-xl border border-gray-200 dark:border-dark-700 p-4 space-y-2">
<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 dark:bg-dark-800 p-3 text-xs font-mono">
<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">
<button class="btn btn-secondary" :disabled="syncing" @click="handleClose">
{{ t('common.cancel') }}
</button>
<button class="btn btn-primary" :disabled="syncing" @click="handleSync">
{{ syncing ? t('admin.accounts.syncing') : t('admin.accounts.syncNow') }}
</button>
</div>
</template>
</Modal>
</template>
<script setup lang="ts">
import { computed, reactive, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import Modal from '@/components/common/Modal.vue'
import { useAppStore } from '@/stores/app'
import { adminAPI } from '@/api/admin'
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()
const syncing = ref(false)
const result = ref<Awaited<ReturnType<typeof adminAPI.accounts.syncFromCrs>> | null>(null)
const form = reactive({
base_url: '',
username: '',
password: '',
sync_proxies: true
})
const errorItems = computed(() => {
if (!result.value?.items) return []
return result.value.items.filter((i) => i.action === 'failed' || i.action === 'skipped')
})
watch(
() => props.show,
(open) => {
if (open) {
result.value = null
}
}
)
const handleClose = () => {
emit('close')
}
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
})
result.value = res
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>

View File

@@ -8,3 +8,4 @@ export { default as UsageProgressBar } from './UsageProgressBar.vue'
export { default as AccountStatsModal } from './AccountStatsModal.vue'
export { default as AccountTestModal } from './AccountTestModal.vue'
export { default as AccountTodayStatsCell } from './AccountTodayStatsCell.vue'
export { default as SyncFromCrsModal } from './SyncFromCrsModal.vue'

View File

@@ -682,6 +682,25 @@ export default {
title: 'Account Management',
description: 'Manage AI platform accounts and credentials',
createAccount: 'Create Account',
syncFromCrs: 'Sync from CRS',
syncFromCrsTitle: 'Sync Accounts from CRS',
syncFromCrsDesc:
'Sync accounts from claude-relay-service (CRS) into this system (CRS is called server-to-server).',
crsBaseUrl: 'CRS Base URL',
crsBaseUrlPlaceholder: 'e.g. http://127.0.0.1:3000',
crsUsername: 'Username',
crsPassword: 'Password',
syncProxies: 'Also sync proxies (match by host/port/auth or create)',
syncNow: 'Sync Now',
syncing: 'Syncing...',
syncMissingFields: 'Please fill base URL, username and password',
syncResult: 'Sync Result',
syncResultSummary: 'Created {created}, updated {updated}, skipped {skipped}, failed {failed}',
syncErrors: 'Errors / Skipped Details',
syncCompleted: 'Sync completed: created {created}, updated {updated}',
syncCompletedWithErrors:
'Sync completed with errors: failed {failed} (created {created}, updated {updated})',
syncFailed: 'Sync failed',
editAccount: 'Edit Account',
deleteAccount: 'Delete Account',
searchAccounts: 'Search accounts...',

View File

@@ -780,6 +780,23 @@ export default {
title: '账号管理',
description: '管理 AI 平台账号和 Cookie',
createAccount: '添加账号',
syncFromCrs: '从 CRS 同步',
syncFromCrsTitle: '从 CRS 同步账号',
syncFromCrsDesc: '将 claude-relay-serviceCRS中的账号同步到当前系统不会在浏览器侧直接请求 CRS。',
crsBaseUrl: 'CRS 服务地址',
crsBaseUrlPlaceholder: '例如http://127.0.0.1:3000',
crsUsername: '用户名',
crsPassword: '密码',
syncProxies: '同时同步代理(按 host/port/账号匹配或自动创建)',
syncNow: '开始同步',
syncing: '同步中...',
syncMissingFields: '请填写服务地址、用户名和密码',
syncResult: '同步结果',
syncResultSummary: '创建 {created},更新 {updated},跳过 {skipped},失败 {failed}',
syncErrors: '错误/跳过详情',
syncCompleted: '同步完成:创建 {created},更新 {updated}',
syncCompletedWithErrors: '同步完成但有错误:失败 {failed}(创建 {created},更新 {updated}',
syncFailed: '同步失败',
editAccount: '编辑账号',
deleteAccount: '删除账号',
deleteConfirmMessage: "确定要删除账号 '{name}' 吗?",

View File

@@ -16,6 +16,15 @@
<path stroke-linecap="round" stroke-linejoin="round" d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99" />
</svg>
</button>
<button
@click="showCrsSyncModal = true"
class="btn btn-secondary"
>
<svg class="w-5 h-5 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99" />
</svg>
{{ t('admin.accounts.syncFromCrs') }}
</button>
<button
@click="showCreateModal = true"
class="btn btn-primary"
@@ -315,6 +324,12 @@
@confirm="confirmDelete"
@cancel="showDeleteDialog = false"
/>
<SyncFromCrsModal
:show="showCrsSyncModal"
@close="showCrsSyncModal = false"
@synced="handleCrsSynced"
/>
</AppLayout>
</template>
@@ -331,7 +346,7 @@ import Pagination from '@/components/common/Pagination.vue'
import ConfirmDialog from '@/components/common/ConfirmDialog.vue'
import EmptyState from '@/components/common/EmptyState.vue'
import Select from '@/components/common/Select.vue'
import { CreateAccountModal, EditAccountModal, ReAuthAccountModal, AccountStatsModal } from '@/components/account'
import { CreateAccountModal, EditAccountModal, ReAuthAccountModal, AccountStatsModal, SyncFromCrsModal } from '@/components/account'
import AccountStatusIndicator from '@/components/account/AccountStatusIndicator.vue'
import AccountUsageCell from '@/components/account/AccountUsageCell.vue'
import AccountTodayStatsCell from '@/components/account/AccountTodayStatsCell.vue'
@@ -404,6 +419,7 @@ const showReAuthModal = ref(false)
const showDeleteDialog = ref(false)
const showTestModal = ref(false)
const showStatsModal = ref(false)
const showCrsSyncModal = ref(false)
const editingAccount = ref<Account | null>(null)
const reAuthAccount = ref<Account | null>(null)
const deletingAccount = ref<Account | null>(null)
@@ -480,6 +496,11 @@ const handlePageChange = (page: number) => {
loadAccounts()
}
const handleCrsSynced = () => {
showCrsSyncModal.value = false
loadAccounts()
}
// Edit modal
const handleEdit = (account: Account) => {
editingAccount.value = account