| @@ -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 | |||
| } | |||
| @@ -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) => <Tag color={statusColorMap[value] || 'grey'}>{value}</Tag>, | |||
| }, | |||
| { title: t('目标国内账号'), dataIndex: 'target_user_id', render: (value) => value || '-' }, | |||
| { title: t('错误信息'), dataIndex: 'error_message', render: (value) => value || '-' }, | |||
| ]; | |||
| return ( | |||
| <div style={{ width: '100%' }}> | |||
| <Typography.Title heading={5} style={{ marginBottom: 12 }}> | |||
| {t('批次详情')} #{selectedBatch.id} | |||
| </Typography.Title> | |||
| <Table | |||
| rowKey='id' | |||
| columns={columns} | |||
| dataSource={items} | |||
| size='small' | |||
| pagination={{ | |||
| currentPage: itemsPage + 1, | |||
| pageSize: itemsPageSize, | |||
| total: itemsTotal, | |||
| pageSizeOpts: [20, 50, 100], | |||
| showSizeChanger: true, | |||
| onPageChange: (page) => fetchItems(selectedBatch.id, page - 1, itemsPageSize), | |||
| onPageSizeChange: (size) => fetchItems(selectedBatch.id, 0, size), | |||
| }} | |||
| /> | |||
| </div> | |||
| ); | |||
| } | |||
| @@ -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) => <Tag color={statusColorMap[status] || 'grey'}>{status}</Tag>, | |||
| }, | |||
| { 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 ( | |||
| <div style={{ width: '100%' }}> | |||
| <Space style={{ marginBottom: 12 }}> | |||
| <Typography.Title heading={5} style={{ margin: 0 }}> | |||
| {t('迁移批次')} | |||
| </Typography.Title> | |||
| <Button icon={<IconPlus />} onClick={() => setVisible(true)}> | |||
| {t('新建批次')} | |||
| </Button> | |||
| <Button icon={<IconRefresh />} onClick={refresh}> | |||
| {t('刷新')} | |||
| </Button> | |||
| </Space> | |||
| <Table | |||
| rowKey='id' | |||
| columns={columns} | |||
| dataSource={batches} | |||
| pagination={false} | |||
| size='small' | |||
| rowSelection={{ | |||
| type: 'radio', | |||
| selectedRowKeys: selectedBatch ? [selectedBatch.id] : [], | |||
| onChange: (_, rows) => rows[0] && selectBatch(rows[0]), | |||
| }} | |||
| empty={t('暂无数据')} | |||
| /> | |||
| <Modal | |||
| visible={visible} | |||
| title={t('创建批次')} | |||
| onCancel={() => setVisible(false)} | |||
| onOk={handleCreate} | |||
| > | |||
| <Form | |||
| getFormApi={(api) => { | |||
| formApiRef.current = api; | |||
| }} | |||
| initValues={{ source_region: 'overseas', target_region: 'cn' }} | |||
| > | |||
| <Form.Input | |||
| field='name' | |||
| label={t('批次名称')} | |||
| rules={[{ required: true, message: t('请输入批次名称') }]} | |||
| /> | |||
| <Form.Input | |||
| field='source_region' | |||
| label={t('源区域')} | |||
| rules={[{ required: true, message: t('请输入源区域') }]} | |||
| /> | |||
| <Form.Input | |||
| field='target_region' | |||
| label={t('目标区域')} | |||
| rules={[{ required: true, message: t('请输入目标区域') }]} | |||
| /> | |||
| </Form> | |||
| </Modal> | |||
| </div> | |||
| ); | |||
| } | |||
| @@ -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) => ( | |||
| <InputNumber | |||
| min={1} | |||
| value={targets[record.id] ?? record.target_user_id ?? 0} | |||
| onChange={(value) => { | |||
| setTargets((prev) => ({ ...prev, [record.id]: value || 0 })); | |||
| }} | |||
| placeholder={t('请输入目标国内用户 ID')} | |||
| /> | |||
| ), | |||
| }, | |||
| { | |||
| title: t('操作'), | |||
| render: (_, record) => ( | |||
| <Space> | |||
| <Button size='small' onClick={() => resolveItem(record.id, 'create_new')}> | |||
| {t('新建国内主账号')} | |||
| </Button> | |||
| <Button | |||
| size='small' | |||
| onClick={() => resolveItem(record.id, 'merge_into_existing', targets[record.id] || record.target_user_id || 0)} | |||
| > | |||
| {t('合并到现有国内账号')} | |||
| </Button> | |||
| <Button size='small' type='danger' onClick={() => resolveItem(record.id, 'skip')}> | |||
| {t('跳过此用户')} | |||
| </Button> | |||
| </Space> | |||
| ), | |||
| }, | |||
| ]; | |||
| return ( | |||
| <div style={{ width: '100%' }}> | |||
| <Typography.Title heading={5} style={{ marginBottom: 12 }}> | |||
| {t('冲突处理')} | |||
| </Typography.Title> | |||
| <Table | |||
| rowKey='id' | |||
| columns={columns} | |||
| dataSource={conflictItems} | |||
| pagination={false} | |||
| size='small' | |||
| empty={t('暂无数据')} | |||
| /> | |||
| </div> | |||
| ); | |||
| } | |||
| @@ -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) => ( | |||
| <Tag color={record.error ? 'red' : 'green'}>{record.error ? t('失败数') : t('验证')}</Tag> | |||
| ), | |||
| }, | |||
| { | |||
| title: t('错误信息'), | |||
| render: (_, record) => record.error || '-', | |||
| }, | |||
| ]; | |||
| return ( | |||
| <div style={{ width: '100%' }}> | |||
| <Space style={{ marginBottom: 12 }}> | |||
| <Typography.Title heading={5} style={{ margin: 0 }}> | |||
| {t('执行与验证')} | |||
| </Typography.Title> | |||
| <Tag color={runningStatusMap[selectedBatch.status] || 'grey'}> | |||
| {selectedBatch.status} | |||
| </Tag> | |||
| <Button onClick={() => scanBatch(selectedBatch.id)}>{t('预检查')}</Button> | |||
| <Popconfirm | |||
| title={t('执行前请确认已进入冻结窗口')} | |||
| onConfirm={() => executeBatch(selectedBatch.id)} | |||
| > | |||
| <Button theme='solid' type='primary'> | |||
| {t('开始迁移')} | |||
| </Button> | |||
| </Popconfirm> | |||
| <Button onClick={() => retryBatch(selectedBatch.id)}>{t('重试失败项')}</Button> | |||
| <Button onClick={() => verifyBatch(selectedBatch.id)}>{t('迁移后验证')}</Button> | |||
| </Space> | |||
| {verifyResults.length > 0 && ( | |||
| <Table | |||
| rowKey={(record) => `${record.source_user_id}-${record.target_user_id}`} | |||
| columns={verifyColumns} | |||
| dataSource={verifyResults} | |||
| pagination={false} | |||
| size='small' | |||
| /> | |||
| )} | |||
| </div> | |||
| ); | |||
| } | |||
| @@ -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 ( | |||
| <Spin spinning={data.loading}> | |||
| <Space vertical align='start' style={{ width: '100%' }} spacing='medium'> | |||
| <BatchList {...data} /> | |||
| {data.selectedBatch && ( | |||
| <> | |||
| <ExecutionPanel {...data} /> | |||
| <ConflictResolver {...data} /> | |||
| <BatchDetail {...data} /> | |||
| </> | |||
| )} | |||
| </Space> | |||
| </Spin> | |||
| ); | |||
| } | |||
| @@ -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, | |||
| }; | |||
| } | |||
| @@ -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" | |||
| } | |||
| @@ -3099,5 +3099,51 @@ | |||
| "该邮箱后缀已存在": "该邮箱后缀已存在", | |||
| "必须以 @ 开头,如 @company.com、@edu.cn": "必须以 @ 开头,如 @company.com、@edu.cn", | |||
| "匹配该后缀的用户注册时获得的初始额度": "匹配该后缀的用户注册时获得的初始额度", | |||
| "确认删除该规则?": "确认删除该规则?" | |||
| "确认删除该规则?": "确认删除该规则?", | |||
| "用户迁移": "用户迁移", | |||
| "迁移批次": "迁移批次", | |||
| "新建批次": "新建批次", | |||
| "创建批次": "创建批次", | |||
| "批次名称": "批次名称", | |||
| "源区域": "源区域", | |||
| "目标区域": "目标区域", | |||
| "请输入批次名称": "请输入批次名称", | |||
| "请输入源区域": "请输入源区域", | |||
| "请输入目标区域": "请输入目标区域", | |||
| "总用户数": "总用户数", | |||
| "冲突数": "冲突数", | |||
| "已迁移": "已迁移", | |||
| "失败数": "失败数", | |||
| "创建时间": "创建时间", | |||
| "执行与验证": "执行与验证", | |||
| "预检查": "预检查", | |||
| "开始迁移": "开始迁移", | |||
| "重试失败项": "重试失败项", | |||
| "迁移后验证": "迁移后验证", | |||
| "执行前请确认已进入冻结窗口": "执行前请确认已进入冻结窗口", | |||
| "海外用户ID": "海外用户ID", | |||
| "目标国内账号": "目标国内账号", | |||
| "错误信息": "错误信息", | |||
| "冲突处理": "冲突处理", | |||
| "冲突类型": "冲突类型", | |||
| "请输入目标国内用户 ID": "请输入目标国内用户 ID", | |||
| "新建国内主账号": "新建国内主账号", | |||
| "合并到现有国内账号": "合并到现有国内账号", | |||
| "跳过此用户": "跳过此用户", | |||
| "批次详情": "批次详情", | |||
| "海外额度": "海外额度", | |||
| "处理策略": "处理策略", | |||
| "获取批次失败": "获取批次失败", | |||
| "获取批次详情失败": "获取批次详情失败", | |||
| "创建批次失败": "创建批次失败", | |||
| "批次已创建": "批次已创建", | |||
| "预检查启动失败": "预检查启动失败", | |||
| "预检查已启动": "预检查已启动", | |||
| "更新策略失败": "更新策略失败", | |||
| "迁移启动失败": "迁移启动失败", | |||
| "迁移已启动": "迁移已启动", | |||
| "重试启动失败": "重试启动失败", | |||
| "重试已启动": "重试已启动", | |||
| "验证完成": "验证完成", | |||
| "暂无数据": "暂无数据" | |||
| } | |||
| @@ -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 = () => { | |||
| <TabPane tab={t('邮箱后缀额度规则')} itemKey='email-quota'> | |||
| <SettingsEmailQuotaRule /> | |||
| </TabPane> | |||
| {isRoot() && ( | |||
| <TabPane tab={t('用户迁移')} itemKey='user-migration'> | |||
| <UserMigrationCenter /> | |||
| </TabPane> | |||
| )} | |||
| </Tabs> | |||
| </div> | |||
| ); | |||