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('迁移批次')}
+
+ } onClick={() => setVisible(true)}>
+ {t('新建批次')}
+
+ } onClick={refresh}>
+ {t('刷新')}
+
+
+
rows[0] && selectBatch(rows[0]),
+ }}
+ empty={t('暂无数据')}
+ />
+
+ setVisible(false)}
+ onOk={handleCreate}
+ >
+
+
+
+
+
+
+ );
+}
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 (
+
+ );
+}
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() && (
+
+
+
+ )}
);