| @@ -8,6 +8,7 @@ import ( | |||||
| const ( | const ( | ||||
| UserMigrationBatchStatusDraft = "draft" | UserMigrationBatchStatusDraft = "draft" | ||||
| UserMigrationBatchStatusScanning = "scanning" | |||||
| UserMigrationBatchStatusScanned = "scanned" | UserMigrationBatchStatusScanned = "scanned" | ||||
| UserMigrationBatchStatusReady = "ready" | UserMigrationBatchStatusReady = "ready" | ||||
| UserMigrationBatchStatusRunning = "running" | UserMigrationBatchStatusRunning = "running" | ||||
| @@ -221,7 +222,7 @@ func TryLockBatchForScan(batchId int) (bool, error) { | |||||
| UserMigrationBatchStatusScanned, | UserMigrationBatchStatusScanned, | ||||
| UserMigrationBatchStatusReady, | UserMigrationBatchStatusReady, | ||||
| }). | }). | ||||
| Update("status", UserMigrationBatchStatusScanned) | |||||
| Update("status", UserMigrationBatchStatusScanning) | |||||
| return result.RowsAffected == 1, result.Error | 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", | "该邮箱后缀已存在": "This email suffix already exists", | ||||
| "必须以 @ 开头,如 @company.com、@edu.cn": "Must start with @, e.g. @company.com, @edu.cn", | "必须以 @ 开头,如 @company.com、@edu.cn": "Must start with @, e.g. @company.com, @edu.cn", | ||||
| "匹配该后缀的用户注册时获得的初始额度": "Initial quota for users registering with this email suffix", | "匹配该后缀的用户注册时获得的初始额度": "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", | "必须以 @ 开头,如 @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 { useTranslation } from 'react-i18next'; | ||||
| import UsersTable from '../../components/table/users'; | import UsersTable from '../../components/table/users'; | ||||
| import SettingsEmailQuotaRule from '../Setting/Operation/SettingsEmailQuotaRule'; | import SettingsEmailQuotaRule from '../Setting/Operation/SettingsEmailQuotaRule'; | ||||
| import UserMigrationCenter from '../../components/table/users/migration'; | |||||
| import { isRoot } from '../../helpers'; | |||||
| const User = () => { | const User = () => { | ||||
| const { t } = useTranslation(); | const { t } = useTranslation(); | ||||
| @@ -34,6 +36,11 @@ const User = () => { | |||||
| <TabPane tab={t('邮箱后缀额度规则')} itemKey='email-quota'> | <TabPane tab={t('邮箱后缀额度规则')} itemKey='email-quota'> | ||||
| <SettingsEmailQuotaRule /> | <SettingsEmailQuotaRule /> | ||||
| </TabPane> | </TabPane> | ||||
| {isRoot() && ( | |||||
| <TabPane tab={t('用户迁移')} itemKey='user-migration'> | |||||
| <UserMigrationCenter /> | |||||
| </TabPane> | |||||
| )} | |||||
| </Tabs> | </Tabs> | ||||
| </div> | </div> | ||||
| ); | ); | ||||