diff --git a/model/user_migration.go b/model/user_migration.go index de70222..38e1607 100644 --- a/model/user_migration.go +++ b/model/user_migration.go @@ -8,6 +8,7 @@ import ( const ( UserMigrationBatchStatusDraft = "draft" + UserMigrationBatchStatusScanning = "scanning" UserMigrationBatchStatusScanned = "scanned" UserMigrationBatchStatusReady = "ready" UserMigrationBatchStatusRunning = "running" @@ -221,7 +222,7 @@ func TryLockBatchForScan(batchId int) (bool, error) { UserMigrationBatchStatusScanned, UserMigrationBatchStatusReady, }). - Update("status", UserMigrationBatchStatusScanned) + Update("status", UserMigrationBatchStatusScanning) return result.RowsAffected == 1, result.Error } diff --git a/web/src/components/table/users/migration/BatchDetail.jsx b/web/src/components/table/users/migration/BatchDetail.jsx new file mode 100644 index 0000000..3dc5774 --- /dev/null +++ b/web/src/components/table/users/migration/BatchDetail.jsx @@ -0,0 +1,71 @@ +import React from 'react'; +import { Table, Tag, Typography } from '@douyinfe/semi-ui'; +import { useTranslation } from 'react-i18next'; + +function renderFlags(text) { + try { + const value = JSON.parse(text || '[]'); + return Array.isArray(value) && value.length > 0 ? value.join(', ') : '-'; + } catch { + return text || '-'; + } +} + +const statusColorMap = { + conflict: 'orange', + ready: 'green', + migrating: 'blue', + migrated: 'green', + skipped: 'grey', + failed: 'red', +}; + +export default function BatchDetail({ + selectedBatch, + items, + itemsTotal, + itemsPage, + itemsPageSize, + fetchItems, +}) { + const { t } = useTranslation(); + + const columns = [ + { title: t('海外用户ID'), dataIndex: 'source_user_id' }, + { title: t('用户名'), dataIndex: 'source_username' }, + { title: t('邮箱'), dataIndex: 'source_email', render: (value) => value || '-' }, + { title: t('海外额度'), dataIndex: 'source_quota' }, + { title: t('冲突类型'), dataIndex: 'conflict_flags', render: renderFlags }, + { title: t('处理策略'), dataIndex: 'resolution_strategy', render: (value) => value || '-' }, + { + title: t('状态'), + dataIndex: 'status', + render: (value) => {value}, + }, + { title: t('目标国内账号'), dataIndex: 'target_user_id', render: (value) => value || '-' }, + { title: t('错误信息'), dataIndex: 'error_message', render: (value) => value || '-' }, + ]; + + return ( +
+ + {t('批次详情')} #{selectedBatch.id} + + fetchItems(selectedBatch.id, page - 1, itemsPageSize), + onPageSizeChange: (size) => fetchItems(selectedBatch.id, 0, size), + }} + /> + + ); +} diff --git a/web/src/components/table/users/migration/BatchList.jsx b/web/src/components/table/users/migration/BatchList.jsx new file mode 100644 index 0000000..9b3fb9a --- /dev/null +++ b/web/src/components/table/users/migration/BatchList.jsx @@ -0,0 +1,121 @@ +import React, { useRef, useState } from 'react'; +import { + Button, + Form, + Modal, + Space, + Table, + Tag, + Typography, +} from '@douyinfe/semi-ui'; +import { IconPlus, IconRefresh } from '@douyinfe/semi-icons'; +import { useTranslation } from 'react-i18next'; +import { timestamp2string } from '../../../../helpers'; + +const statusColorMap = { + draft: 'grey', + scanning: 'blue', + scanned: 'light-blue', + ready: 'green', + running: 'cyan', + completed: 'green', + failed: 'red', + cancelled: 'grey', +}; + +export default function BatchList({ + batches, + selectedBatch, + createBatch, + refresh, + selectBatch, +}) { + const { t } = useTranslation(); + const [visible, setVisible] = useState(false); + const formApiRef = useRef(null); + + const columns = [ + { title: t('批次名称'), dataIndex: 'name' }, + { + title: t('状态'), + dataIndex: 'status', + render: (status) => {status}, + }, + { title: t('总用户数'), dataIndex: 'total_users' }, + { title: t('冲突数'), dataIndex: 'conflict_users' }, + { title: t('已迁移'), dataIndex: 'migrated_users' }, + { title: t('失败数'), dataIndex: 'failed_users' }, + { + title: t('创建时间'), + dataIndex: 'created_at', + render: (value) => (value ? timestamp2string(value) : '-'), + }, + ]; + + const handleCreate = async () => { + const values = await formApiRef.current.validate(); + const success = await createBatch(values); + if (success) { + setVisible(false); + } + }; + + return ( +
+ + + {t('迁移批次')} + + + + +
rows[0] && selectBatch(rows[0]), + }} + empty={t('暂无数据')} + /> + + setVisible(false)} + onOk={handleCreate} + > +
{ + formApiRef.current = api; + }} + initValues={{ source_region: 'overseas', target_region: 'cn' }} + > + + + + +
+ + ); +} diff --git a/web/src/components/table/users/migration/ConflictResolver.jsx b/web/src/components/table/users/migration/ConflictResolver.jsx new file mode 100644 index 0000000..abcf3fb --- /dev/null +++ b/web/src/components/table/users/migration/ConflictResolver.jsx @@ -0,0 +1,81 @@ +import React, { useMemo, useState } from 'react'; +import { Button, InputNumber, Space, Table, Typography } from '@douyinfe/semi-ui'; +import { useTranslation } from 'react-i18next'; + +function formatFlags(text) { + try { + const value = JSON.parse(text || '[]'); + return Array.isArray(value) && value.length > 0 ? value.join(', ') : '-'; + } catch { + return text || '-'; + } +} + +export default function ConflictResolver({ items, resolveItem }) { + const { t } = useTranslation(); + const [targets, setTargets] = useState({}); + + const conflictItems = useMemo( + () => items.filter((item) => item.status === 'conflict'), + [items], + ); + + const columns = [ + { title: t('海外用户ID'), dataIndex: 'source_user_id' }, + { title: t('用户名'), dataIndex: 'source_username' }, + { title: t('邮箱'), dataIndex: 'source_email', render: (value) => value || '-' }, + { + title: t('冲突类型'), + dataIndex: 'conflict_flags', + render: formatFlags, + }, + { + title: t('目标国内账号'), + render: (_, record) => ( + { + setTargets((prev) => ({ ...prev, [record.id]: value || 0 })); + }} + placeholder={t('请输入目标国内用户 ID')} + /> + ), + }, + { + title: t('操作'), + render: (_, record) => ( + + + + + + ), + }, + ]; + + return ( +
+ + {t('冲突处理')} + +
+ + ); +} diff --git a/web/src/components/table/users/migration/ExecutionPanel.jsx b/web/src/components/table/users/migration/ExecutionPanel.jsx new file mode 100644 index 0000000..f5d84de --- /dev/null +++ b/web/src/components/table/users/migration/ExecutionPanel.jsx @@ -0,0 +1,74 @@ +import React from 'react'; +import { + Button, + Popconfirm, + Space, + Table, + Tag, + Typography, +} from '@douyinfe/semi-ui'; +import { useTranslation } from 'react-i18next'; + +const runningStatusMap = { + scanning: 'blue', + running: 'cyan', +}; + +export default function ExecutionPanel({ + selectedBatch, + scanBatch, + executeBatch, + retryBatch, + verifyBatch, + verifyResults, +}) { + const { t } = useTranslation(); + + const verifyColumns = [ + { title: t('海外用户ID'), dataIndex: 'source_user_id' }, + { title: t('目标国内账号'), dataIndex: 'target_user_id' }, + { + title: t('状态'), + render: (_, record) => ( + {record.error ? t('失败数') : t('验证')} + ), + }, + { + title: t('错误信息'), + render: (_, record) => record.error || '-', + }, + ]; + + return ( +
+ + + {t('执行与验证')} + + + {selectedBatch.status} + + + executeBatch(selectedBatch.id)} + > + + + + + + {verifyResults.length > 0 && ( +
`${record.source_user_id}-${record.target_user_id}`} + columns={verifyColumns} + dataSource={verifyResults} + pagination={false} + size='small' + /> + )} + + ); +} diff --git a/web/src/components/table/users/migration/index.jsx b/web/src/components/table/users/migration/index.jsx new file mode 100644 index 0000000..e4639b9 --- /dev/null +++ b/web/src/components/table/users/migration/index.jsx @@ -0,0 +1,26 @@ +import React from 'react'; +import { Space, Spin } from '@douyinfe/semi-ui'; +import { useUserMigrationData } from '../../../../hooks/users/useUserMigrationData'; +import BatchList from './BatchList'; +import BatchDetail from './BatchDetail'; +import ConflictResolver from './ConflictResolver'; +import ExecutionPanel from './ExecutionPanel'; + +export default function UserMigrationCenter() { + const data = useUserMigrationData(); + + return ( + + + + {data.selectedBatch && ( + <> + + + + + )} + + + ); +} diff --git a/web/src/hooks/users/useUserMigrationData.jsx b/web/src/hooks/users/useUserMigrationData.jsx new file mode 100644 index 0000000..138fa2b --- /dev/null +++ b/web/src/hooks/users/useUserMigrationData.jsx @@ -0,0 +1,238 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { API, showError, showSuccess } from '../../helpers'; + +const POLL_INTERVAL_MS = 3000; +const RUNNING_STATUSES = new Set(['running', 'scanning']); + +export function useUserMigrationData() { + const { t } = useTranslation(); + const [batches, setBatches] = useState([]); + const [selectedBatch, setSelectedBatch] = useState(null); + const [items, setItems] = useState([]); + const [itemsTotal, setItemsTotal] = useState(0); + const [itemsPage, setItemsPage] = useState(0); + const [itemsPageSize, setItemsPageSize] = useState(50); + const [loading, setLoading] = useState(false); + const [verifyResults, setVerifyResults] = useState([]); + const pollTimerRef = useRef(null); + const selectedBatchIdRef = useRef(null); + const itemsPageRef = useRef(0); + const itemsPageSizeRef = useRef(50); + + useEffect(() => { + selectedBatchIdRef.current = selectedBatch?.id || null; + }, [selectedBatch]); + + useEffect(() => { + itemsPageRef.current = itemsPage; + }, [itemsPage]); + + useEffect(() => { + itemsPageSizeRef.current = itemsPageSize; + }, [itemsPageSize]); + + const stopPolling = useCallback(() => { + if (pollTimerRef.current) { + clearInterval(pollTimerRef.current); + pollTimerRef.current = null; + } + }, []); + + const fetchBatches = useCallback(async () => { + try { + const res = await API.get('/api/user-migrations/batches'); + if (!res.data.success) { + showError(res.data.error || t('获取批次失败')); + return []; + } + const data = res.data.data || []; + setBatches(data); + if (selectedBatchIdRef.current) { + const current = data.find((batch) => batch.id === selectedBatchIdRef.current) || null; + setSelectedBatch(current); + } + return data; + } catch (error) { + showError(error.message); + return []; + } + }, []); + + const fetchItems = useCallback(async (batchId, page = 0, pageSize = 50) => { + if (!batchId) { + return; + } + try { + const res = await API.get(`/api/user-migrations/batches/${batchId}?page=${page}&page_size=${pageSize}`); + if (!res.data.success) { + showError(res.data.error || t('获取批次详情失败')); + return; + } + setItems(res.data.items || []); + setItemsTotal(res.data.total || 0); + setItemsPage(page); + setItemsPageSize(pageSize); + setSelectedBatch(res.data.data || null); + } catch (error) { + showError(error.message); + } + }, []); + + const refresh = useCallback(async (silent = false) => { + if (!silent) { + setLoading(true); + } + const latestBatches = await fetchBatches(); + if (selectedBatchIdRef.current) { + await fetchItems( + selectedBatchIdRef.current, + itemsPageRef.current, + itemsPageSizeRef.current, + ); + } + const hasRunning = latestBatches.some((batch) => RUNNING_STATUSES.has(batch.status)); + if (hasRunning && !pollTimerRef.current) { + pollTimerRef.current = setInterval(() => { + void refresh(true); + }, POLL_INTERVAL_MS); + } + if (!hasRunning) { + stopPolling(); + } + if (!silent) { + setLoading(false); + } + }, [fetchBatches, fetchItems, stopPolling]); + + useEffect(() => { + void refresh(); + return () => stopPolling(); + }, [refresh, stopPolling]); + + const selectBatch = useCallback(async (batch) => { + setVerifyResults([]); + setSelectedBatch(batch); + await fetchItems(batch.id, 0, itemsPageSizeRef.current); + }, [fetchItems]); + + const createBatch = async (payload) => { + try { + const res = await API.post('/api/user-migrations/batches', payload); + if (!res.data.success) { + showError(res.data.error || t('创建批次失败')); + return false; + } + showSuccess(t('批次已创建')); + await refresh(); + return true; + } catch (error) { + showError(error.message); + return false; + } + }; + + const scanBatch = async (batchId) => { + try { + const res = await API.post(`/api/user-migrations/batches/${batchId}/scan`); + if (!res.data.success) { + showError(res.data.error || t('预检查启动失败')); + return false; + } + showSuccess(t('预检查已启动')); + await refresh(); + return true; + } catch (error) { + showError(error.message); + return false; + } + }; + + const resolveItem = async (itemId, strategy, targetUserId = 0) => { + try { + const res = await API.post(`/api/user-migrations/items/${itemId}/resolve`, { + resolution_strategy: strategy, + target_user_id: targetUserId, + }); + if (!res.data.success) { + showError(res.data.error || t('更新策略失败')); + return false; + } + await refresh(); + return true; + } catch (error) { + showError(error.message); + return false; + } + }; + + const executeBatch = async (batchId) => { + try { + const res = await API.post(`/api/user-migrations/batches/${batchId}/execute`); + if (!res.data.success) { + showError(res.data.error || t('迁移启动失败')); + return false; + } + showSuccess(t('迁移已启动')); + await refresh(); + return true; + } catch (error) { + showError(error.message); + return false; + } + }; + + const retryBatch = async (batchId) => { + try { + const res = await API.post(`/api/user-migrations/batches/${batchId}/retry`); + if (!res.data.success) { + showError(res.data.error || t('重试启动失败')); + return false; + } + showSuccess(t('重试已启动')); + await refresh(); + return true; + } catch (error) { + showError(error.message); + return false; + } + }; + + const verifyBatch = async (batchId) => { + try { + const res = await API.post(`/api/user-migrations/batches/${batchId}/verify`); + if (!res.data.success) { + showError(res.data.error || t('验证失败')); + return []; + } + const results = res.data.data || []; + setVerifyResults(results); + showSuccess(t('验证完成')); + return results; + } catch (error) { + showError(error.message); + return []; + } + }; + + return { + batches, + selectedBatch, + items, + itemsTotal, + itemsPage, + itemsPageSize, + loading, + verifyResults, + refresh, + selectBatch, + fetchItems, + createBatch, + scanBatch, + resolveItem, + executeBatch, + retryBatch, + verifyBatch, + t, + }; +} diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index 121a702..2cfba25 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -3122,5 +3122,51 @@ "该邮箱后缀已存在": "This email suffix already exists", "必须以 @ 开头,如 @company.com、@edu.cn": "Must start with @, e.g. @company.com, @edu.cn", "匹配该后缀的用户注册时获得的初始额度": "Initial quota for users registering with this email suffix", - "确认删除该规则?": "Confirm delete this rule?" + "确认删除该规则?": "Confirm delete this rule?", + "用户迁移": "User Migration", + "迁移批次": "Migration Batches", + "新建批次": "New Batch", + "创建批次": "Create Batch", + "批次名称": "Batch Name", + "源区域": "Source Region", + "目标区域": "Target Region", + "请输入批次名称": "Enter a batch name", + "请输入源区域": "Enter the source region", + "请输入目标区域": "Enter the target region", + "总用户数": "Total Users", + "冲突数": "Conflicts", + "已迁移": "Migrated", + "失败数": "Failed", + "创建时间": "Created At", + "执行与验证": "Execution and Verification", + "预检查": "Pre-check", + "开始迁移": "Start Migration", + "重试失败项": "Retry Failed", + "迁移后验证": "Verify Migration", + "执行前请确认已进入冻结窗口": "Confirm the freeze window is active before execution", + "海外用户ID": "Overseas User ID", + "目标国内账号": "Target CN Account", + "错误信息": "Error", + "冲突处理": "Conflict Resolution", + "冲突类型": "Conflict Flags", + "请输入目标国内用户 ID": "Enter target CN user ID", + "新建国内主账号": "Create New CN Account", + "合并到现有国内账号": "Merge Into Existing CN Account", + "跳过此用户": "Skip User", + "批次详情": "Batch Detail", + "海外额度": "Overseas Quota", + "处理策略": "Strategy", + "获取批次失败": "Failed to fetch batches", + "获取批次详情失败": "Failed to fetch batch detail", + "创建批次失败": "Failed to create batch", + "批次已创建": "Batch created", + "预检查启动失败": "Failed to start pre-check", + "预检查已启动": "Pre-check started", + "更新策略失败": "Failed to update strategy", + "迁移启动失败": "Failed to start migration", + "迁移已启动": "Migration started", + "重试启动失败": "Failed to start retry", + "重试已启动": "Retry started", + "验证完成": "Verification complete", + "暂无数据": "No data" } diff --git a/web/src/i18n/locales/zh-CN.json b/web/src/i18n/locales/zh-CN.json index e3020c7..7968f8b 100644 --- a/web/src/i18n/locales/zh-CN.json +++ b/web/src/i18n/locales/zh-CN.json @@ -3099,5 +3099,51 @@ "该邮箱后缀已存在": "该邮箱后缀已存在", "必须以 @ 开头,如 @company.com、@edu.cn": "必须以 @ 开头,如 @company.com、@edu.cn", "匹配该后缀的用户注册时获得的初始额度": "匹配该后缀的用户注册时获得的初始额度", - "确认删除该规则?": "确认删除该规则?" + "确认删除该规则?": "确认删除该规则?", + "用户迁移": "用户迁移", + "迁移批次": "迁移批次", + "新建批次": "新建批次", + "创建批次": "创建批次", + "批次名称": "批次名称", + "源区域": "源区域", + "目标区域": "目标区域", + "请输入批次名称": "请输入批次名称", + "请输入源区域": "请输入源区域", + "请输入目标区域": "请输入目标区域", + "总用户数": "总用户数", + "冲突数": "冲突数", + "已迁移": "已迁移", + "失败数": "失败数", + "创建时间": "创建时间", + "执行与验证": "执行与验证", + "预检查": "预检查", + "开始迁移": "开始迁移", + "重试失败项": "重试失败项", + "迁移后验证": "迁移后验证", + "执行前请确认已进入冻结窗口": "执行前请确认已进入冻结窗口", + "海外用户ID": "海外用户ID", + "目标国内账号": "目标国内账号", + "错误信息": "错误信息", + "冲突处理": "冲突处理", + "冲突类型": "冲突类型", + "请输入目标国内用户 ID": "请输入目标国内用户 ID", + "新建国内主账号": "新建国内主账号", + "合并到现有国内账号": "合并到现有国内账号", + "跳过此用户": "跳过此用户", + "批次详情": "批次详情", + "海外额度": "海外额度", + "处理策略": "处理策略", + "获取批次失败": "获取批次失败", + "获取批次详情失败": "获取批次详情失败", + "创建批次失败": "创建批次失败", + "批次已创建": "批次已创建", + "预检查启动失败": "预检查启动失败", + "预检查已启动": "预检查已启动", + "更新策略失败": "更新策略失败", + "迁移启动失败": "迁移启动失败", + "迁移已启动": "迁移已启动", + "重试启动失败": "重试启动失败", + "重试已启动": "重试已启动", + "验证完成": "验证完成", + "暂无数据": "暂无数据" } diff --git a/web/src/pages/User/index.jsx b/web/src/pages/User/index.jsx index 3a21d2c..718c1fc 100644 --- a/web/src/pages/User/index.jsx +++ b/web/src/pages/User/index.jsx @@ -22,6 +22,8 @@ import { Tabs, TabPane } from '@douyinfe/semi-ui'; import { useTranslation } from 'react-i18next'; import UsersTable from '../../components/table/users'; import SettingsEmailQuotaRule from '../Setting/Operation/SettingsEmailQuotaRule'; +import UserMigrationCenter from '../../components/table/users/migration'; +import { isRoot } from '../../helpers'; const User = () => { const { t } = useTranslation(); @@ -34,6 +36,11 @@ const User = () => { + {isRoot() && ( + + + + )} );