feat(account): 添加从 CRS 同步账户功能
- 添加账户同步 API 接口 (account_handler.go) - 实现 CRS 同步服务 (crs_sync_service.go) - 添加前端同步对话框组件 (SyncFromCrsModal.vue) - 更新账户管理界面支持同步操作 - 添加账户仓库批量创建方法 - 添加中英文国际化翻译 - 更新依赖注入配置
This commit is contained in:
@@ -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;
|
||||
|
||||
165
frontend/src/components/account/SyncFromCrsModal.vue
Normal file
165
frontend/src/components/account/SyncFromCrsModal.vue
Normal 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>
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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...',
|
||||
|
||||
@@ -780,6 +780,23 @@ export default {
|
||||
title: '账号管理',
|
||||
description: '管理 AI 平台账号和 Cookie',
|
||||
createAccount: '添加账号',
|
||||
syncFromCrs: '从 CRS 同步',
|
||||
syncFromCrsTitle: '从 CRS 同步账号',
|
||||
syncFromCrsDesc: '将 claude-relay-service(CRS)中的账号同步到当前系统(不会在浏览器侧直接请求 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}' 吗?",
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user