tokenFactory/web/src/components/table/channels/modals/ChannelImportModal.jsx

320 lines
9.7 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/*
Copyright (C) 2025 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/gpl-3.0.html>.
*/
import React, { useRef, useState } from 'react';
import {
Button,
Input,
Modal,
Space,
Spin,
Tag,
Typography,
} from '@douyinfe/semi-ui';
import { IconUpload } from '@douyinfe/semi-icons';
import { useTranslation } from 'react-i18next';
import { API, showError, showSuccess } from '../../../../helpers';
const { Text, Title } = Typography;
/** TokenFactoryOpen (type=60) 渠道类型常量 */
const CHANNEL_TYPE_TOKEN_FACTORY_OPEN = 60;
/**
* ChannelImportModal 渠道导入入口 + 结果展示。
* 支持建站模式:当导入数据中包含 type=60 且 apiKey 为空的渠道时,
* 需要用户填写关联密钥(作为渠道 apiKey
* @param {{ refresh: () => void }} props
*/
export default function ChannelImportModal({ refresh }) {
const { t } = useTranslation();
const fileInputRef = useRef(null);
const [importing, setImporting] = useState(false);
const [resultVisible, setResultVisible] = useState(false);
const [importResult, setImportResult] = useState(null);
// 建站模式相关状态
const [parsedData, setParsedData] = useState(null);
const [siteBuilderApiKey, setSiteBuilderApiKey] = useState('');
const [needsSiteBuilderKey, setNeedsSiteBuilderKey] = useState(false);
const [confirmVisible, setConfirmVisible] = useState(false);
/** 检测导入数据是否包含需要建站密钥的渠道type=60 且 apiKey 为空) */
const checkNeedsSiteBuilderKey = (data) => {
if (!Array.isArray(data?.channels)) return false;
return data.channels.some(
(ch) =>
ch.type === CHANNEL_TYPE_TOKEN_FACTORY_OPEN &&
(!ch.apiKey || String(ch.apiKey).trim() === '')
);
};
/** 触发文件选择器 */
const handleImportClick = () => {
fileInputRef.current?.click();
};
/** 处理文件选择 */
const handleFileChange = async (e) => {
const file = e.target.files?.[0];
if (!file) return;
// 重置 input允许重复选择同一文件
e.target.value = '';
// 文件格式校验
if (!file.name.endsWith('.json') && file.type !== 'application/json') {
showError(t('请选择 JSON 格式的导出文件'));
return;
}
// 解析 JSON
let parsed;
try {
const text = await file.text();
parsed = JSON.parse(text);
} catch {
showError(t('文件解析失败,请确认是合法的 JSON 导出文件'));
return;
}
// 基础结构校验
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
showError(t('文件格式错误:根节点必须为 JSON 对象'));
return;
}
if (!Array.isArray(parsed.channels)) {
showError(t('文件格式错误:缺少 channels 数组字段'));
return;
}
if (parsed.channels.length === 0) {
showError(t('文件中未包含任何渠道数据'));
return;
}
// 检测是否包含建站渠道type=60 且 apiKey 为空)
if (checkNeedsSiteBuilderKey(parsed)) {
setParsedData(parsed);
setNeedsSiteBuilderKey(true);
setConfirmVisible(true);
return;
}
// 普通导入:直接提交
await doImport(parsed);
};
/** 执行导入请求 */
const doImport = async (data, siteBuilderKey = '') => {
setImporting(true);
try {
const payload = { ...data };
if (siteBuilderKey && siteBuilderKey.trim()) {
payload.site_builder_api_key = siteBuilderKey.trim();
}
const res = await API.post('/api/channel/import', payload);
if (!res?.data?.success) {
showError(res?.data?.message || t('导入失败'));
return;
}
setImportResult(res.data.data);
setResultVisible(true);
// 导入成功后自动刷新列表
await refresh?.();
if (res.data.data?.failed === 0) {
showSuccess(t('渠道导入成功'));
}
} catch (err) {
showError(err?.message || t('导入失败'));
} finally {
setImporting(false);
// 清理建站相关状态
setParsedData(null);
setNeedsSiteBuilderKey(false);
setConfirmVisible(false);
setSiteBuilderApiKey('');
}
};
/** 确认导入(建站模式,用户已填写密钥) */
const handleConfirmImport = () => {
if (needsSiteBuilderKey && !siteBuilderApiKey.trim()) {
showError(t('请输入关联密钥'));
return;
}
setConfirmVisible(false);
doImport(parsedData, siteBuilderApiKey);
};
/** 取消建站模式确认 */
const handleCancelConfirm = () => {
setConfirmVisible(false);
setParsedData(null);
setNeedsSiteBuilderKey(false);
setSiteBuilderApiKey('');
};
/** 渲染导入结果弹窗内容 */
const renderImportResult = () => {
if (!importResult) return null;
const { added = 0, updated = 0, failed = 0, failures = [] } = importResult;
return (
<div style={{ lineHeight: 1.8 }}>
{/* 统计数字 */}
<div style={{ marginBottom: 16, display: 'flex', gap: 8, flexWrap: 'wrap' }}>
<Tag color='green' style={{ padding: '4px 12px' }}>
{t('新增')} {added}
</Tag>
<Tag color='blue' style={{ padding: '4px 12px' }}>
{t('更新')} {updated}
</Tag>
{failed > 0 && (
<Tag color='red' style={{ padding: '4px 12px' }}>
{t('失败')} {failed}
</Tag>
)}
</div>
{/* 失败详情列表 */}
{failures.length > 0 && (
<>
<Title heading={6} style={{ marginBottom: 8 }}>
{t('失败详情')}
</Title>
<div
style={{
maxHeight: 240,
overflowY: 'auto',
border: '1px solid var(--semi-color-border)',
borderRadius: 6,
padding: '8px 12px',
}}
>
{failures.map((f, idx) => (
<div
key={idx}
style={{
padding: '4px 0',
borderBottom:
idx < failures.length - 1
? '1px solid var(--semi-color-border)'
: 'none',
}}
>
<Text strong style={{ marginRight: 8 }}>
{f.name}
</Text>
<Text type='danger' size='small'>
{f.reason}
</Text>
</div>
))}
</div>
</>
)}
{failed === 0 && (
<Text type='success'>{t('全部渠道导入成功,无失败记录')}</Text>
)}
</div>
);
};
/** 计算建站渠道数量type=60 且 apiKey 为空) */
const siteBuilderChannelCount = parsedData?.channels?.filter(
(ch) =>
ch.type === CHANNEL_TYPE_TOKEN_FACTORY_OPEN &&
(!ch.apiKey || String(ch.apiKey).trim() === '')
).length || 0;
return (
<>
<Spin spinning={importing}>
<Button
icon={<IconUpload />}
loading={importing}
onClick={handleImportClick}
theme='light'
size='small'
>
{t('导入')}
</Button>
</Spin>
{/* 隐藏的文件选择器,仅接受 .json 文件 */}
<input
ref={fileInputRef}
type='file'
accept='.json,application/json'
style={{ display: 'none' }}
onChange={handleFileChange}
/>
{/* 建站密钥输入弹窗 */}
<Modal
title={t('建站渠道导入 - 设置密钥')}
visible={confirmVisible}
onOk={handleConfirmImport}
onCancel={handleCancelConfirm}
okText={t('确认导入')}
cancelText={t('取消')}
width={520}
okButtonProps={{
disabled: needsSiteBuilderKey && !siteBuilderApiKey.trim(),
}}
>
<div style={{ lineHeight: 1.8 }}>
<Text>
{t('检测到导入数据中包含 {{count}} 个建站渠道TokenFactoryOpen 类型),请填写关联密钥后导入。', {
count: siteBuilderChannelCount,
})}
</Text>
<div style={{ marginTop: 16, marginBottom: 8 }}>
<Text strong>{t('关联密钥')}</Text>
</div>
<Input
value={siteBuilderApiKey}
onChange={setSiteBuilderApiKey}
placeholder={t('请输入渠道密钥')}
showClear
style={{ width: '100%' }}
/>
<div style={{ marginTop: 8 }}>
<Text type='quaternary' size='small'>
{t('此密钥将作为所有建站渠道的 API Key用于访问上游平台。请在目标平台的API Key页面创建获取。')}
</Text>
</div>
</div>
</Modal>
{/* 导入结果弹窗 */}
<Modal
title={t('导入结果')}
visible={resultVisible}
onOk={() => setResultVisible(false)}
onCancel={() => setResultVisible(false)}
cancelButtonProps={{ style: { display: 'none' } }}
okText={t('确定')}
width={480}
>
{renderImportResult()}
</Modal>
</>
);
}