cangku/frontend/src/pages/Settings/ApiKeySettings.jsx

700 lines
22 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.

import React, { useCallback, useEffect, useState } from 'react';
import {
Alert,
Button,
DatePicker,
Form,
Input,
InputNumber,
Modal,
Popconfirm,
Select,
Space,
Switch,
Table,
Tabs,
Tag,
Tooltip,
Typography,
message,
} from 'antd';
import {
CopyOutlined,
DeleteOutlined,
DesktopOutlined,
EditOutlined,
ExportOutlined,
KeyOutlined,
LinkOutlined,
PlusOutlined,
ReloadOutlined,
} from '@ant-design/icons';
import dayjs from 'dayjs';
import { SycmCard } from '../../components/Common';
import {
createApiKey,
createExternalAutomationApp,
deleteApiKey,
deleteExternalAutomationApp,
getApiKeys,
getExternalAutomationApps,
updateApiKey,
updateExternalAutomationApp,
} from '../../api/admin';
const { Paragraph, Text, Title } = Typography;
const API_BASE_URL = 'https://pddg.jiansuntuzhi.cn/api/external/selection-fission';
const PRESALE_API_URL = `${API_BASE_URL}/presale/tasks`;
const PLATFORM_LABELS = {
windows: 'Windows',
macos: 'macOS',
linux: 'Linux',
all: '全平台',
};
function formatDateTime(value) {
if (!value) return '-';
const date = dayjs(value);
return date.isValid() ? date.format('YYYY-MM-DD HH:mm:ss') : '-';
}
const ApiKeySettings = () => {
const [form] = Form.useForm();
const [appForm] = Form.useForm();
const [rows, setRows] = useState([]);
const [appRows, setAppRows] = useState([]);
const [loading, setLoading] = useState(false);
const [appLoading, setAppLoading] = useState(false);
const [saving, setSaving] = useState(false);
const [updatingId, setUpdatingId] = useState(null);
const [editingRecord, setEditingRecord] = useState(null);
const [editorOpen, setEditorOpen] = useState(false);
const [revealedKey, setRevealedKey] = useState('');
const [appEditorOpen, setAppEditorOpen] = useState(false);
const [editingApp, setEditingApp] = useState(null);
const [appSaving, setAppSaving] = useState(false);
const loadRows = useCallback(async () => {
try {
setLoading(true);
const result = await getApiKeys();
setRows(Array.isArray(result?.data) ? result.data : []);
} catch (error) {
message.error(error.response?.data?.error || '加载 API Key 失败');
} finally {
setLoading(false);
}
}, []);
const loadAppRows = useCallback(async () => {
try {
setAppLoading(true);
const result = await getExternalAutomationApps();
setAppRows(Array.isArray(result?.data) ? result.data : []);
} catch (error) {
message.error(error.response?.data?.error || '加载 App 链接失败');
} finally {
setAppLoading(false);
}
}, []);
useEffect(() => {
loadRows();
loadAppRows();
}, [loadAppRows, loadRows]);
const refreshAll = useCallback(() => {
loadRows();
loadAppRows();
}, [loadAppRows, loadRows]);
const copyText = async (value, successText) => {
try {
await navigator.clipboard.writeText(value);
message.success(successText);
} catch {
message.error('复制失败,请手动选择复制');
}
};
const openCreateApp = () => {
setEditingApp(null);
appForm.resetFields();
appForm.setFieldsValue({ platform: 'windows', enabled: true, sortOrder: 0 });
setAppEditorOpen(true);
};
const openEditApp = (record) => {
setEditingApp(record);
appForm.setFieldsValue(record);
setAppEditorOpen(true);
};
const saveApp = async () => {
try {
const values = await appForm.validateFields();
setAppSaving(true);
if (editingApp) {
await updateExternalAutomationApp(editingApp.id, values);
} else {
await createExternalAutomationApp(values);
}
message.success(editingApp ? 'App 链接已更新' : 'App 链接已创建');
setAppEditorOpen(false);
appForm.resetFields();
await loadAppRows();
} catch (error) {
if (!error?.errorFields) {
message.error(error.response?.data?.error || '保存 App 链接失败');
}
} finally {
setAppSaving(false);
}
};
const toggleApp = async (record, enabled) => {
try {
setUpdatingId(`app-${record.id}`);
await updateExternalAutomationApp(record.id, { enabled });
message.success(enabled ? 'App 链接已启用' : 'App 链接已停用');
await loadAppRows();
} catch (error) {
message.error(error.response?.data?.error || '更新 App 链接状态失败');
} finally {
setUpdatingId(null);
}
};
const removeApp = async (record) => {
try {
setUpdatingId(`app-${record.id}`);
await deleteExternalAutomationApp(record.id);
message.success('App 链接已删除');
await loadAppRows();
} catch (error) {
message.error(error.response?.data?.error || '删除 App 链接失败');
} finally {
setUpdatingId(null);
}
};
const openCreate = () => {
setEditingRecord(null);
form.resetFields();
form.setFieldsValue({ enabled: true, hmacRequired: false, allowedIps: '' });
setEditorOpen(true);
};
const openEdit = (record) => {
setEditingRecord(record);
form.setFieldsValue({
name: record.name,
description: record.description || '',
apiKey: '',
allowedIps: Array.isArray(record.allowedIps) ? record.allowedIps.join('\n') : '',
hmacRequired: record.hmacRequired === true,
enabled: record.enabled,
expiresAt: record.expiresAt ? dayjs(record.expiresAt) : null,
});
setEditorOpen(true);
};
const saveKey = async () => {
try {
const values = await form.validateFields();
setSaving(true);
const payload = {
name: values.name,
description: values.description || '',
allowedIps: String(values.allowedIps || '')
.split(/[\s,;]+/)
.map((item) => item.trim())
.filter(Boolean),
hmacRequired: values.hmacRequired === true,
enabled: values.enabled !== false,
expiresAt: values.expiresAt ? values.expiresAt.toISOString() : null,
};
const apiKey = String(values.apiKey || '').trim();
if (apiKey) payload.apiKey = apiKey;
const result = editingRecord
? await updateApiKey(editingRecord.id, payload)
: await createApiKey(payload);
setEditorOpen(false);
form.resetFields();
message.success(editingRecord ? 'API Key 已更新' : 'API Key 已创建');
if (result?.data?.plainApiKey) {
setRevealedKey(result.data.plainApiKey);
}
await loadRows();
} catch (error) {
if (!error?.errorFields) {
message.error(error.response?.data?.error || '保存 API Key 失败');
}
} finally {
setSaving(false);
}
};
const toggleKey = async (record, enabled) => {
try {
setUpdatingId(record.id);
await updateApiKey(record.id, { enabled });
message.success(enabled ? 'API Key 已启用' : 'API Key 已停用');
await loadRows();
} catch (error) {
message.error(error.response?.data?.error || '更新 API Key 状态失败');
} finally {
setUpdatingId(null);
}
};
const removeKey = async (record) => {
try {
setUpdatingId(record.id);
await deleteApiKey(record.id);
message.success('API Key 已删除');
await loadRows();
} catch (error) {
message.error(error.response?.data?.error || '删除 API Key 失败');
} finally {
setUpdatingId(null);
}
};
const copyRevealedKey = async () => {
await copyText(revealedKey, '完整 API Key 已复制');
};
const appColumns = [
{
title: 'App',
dataIndex: 'name',
width: 220,
render: (value, record) => (
<div className="api-key-name">
<Space size={6}>
<Text strong>{value}</Text>
{record.version ? <Tag>{record.version}</Tag> : null}
</Space>
{record.description ? <Text type="secondary">{record.description}</Text> : null}
</div>
),
},
{
title: '平台',
dataIndex: 'platform',
width: 110,
render: (value) => <Tag color="blue">{PLATFORM_LABELS[value] || value}</Tag>,
},
{
title: '下载/启动链接',
dataIndex: 'downloadUrl',
width: 420,
render: (value) => (
<Space size={4} className="external-app-link">
<Text ellipsis={{ tooltip: value }}>{value}</Text>
<Tooltip title="复制链接">
<Button type="text" size="small" icon={<CopyOutlined />} onClick={() => copyText(value, 'App 链接已复制')} />
</Tooltip>
<Tooltip title="打开链接">
<Button type="text" size="small" icon={<ExportOutlined />} href={value} target="_blank" rel="noreferrer" />
</Tooltip>
</Space>
),
},
{
title: '状态',
dataIndex: 'enabled',
width: 120,
render: (enabled, record) => (
<Switch
checked={enabled}
loading={updatingId === `app-${record.id}`}
checkedChildren="启用"
unCheckedChildren="停用"
onChange={(checked) => toggleApp(record, checked)}
/>
),
},
{
title: '更新时间',
dataIndex: 'updatedAt',
width: 170,
render: formatDateTime,
},
{
title: '操作',
key: 'actions',
fixed: 'right',
width: 104,
render: (_, record) => (
<Space size={4}>
<Tooltip title="编辑 App 链接">
<Button type="text" icon={<EditOutlined />} aria-label="编辑 App 链接" onClick={() => openEditApp(record)} />
</Tooltip>
<Popconfirm
title="删除 App 链接?"
description="删除后,系统设置中将不再提供这个下载入口。"
okText="删除"
cancelText="取消"
okButtonProps={{ danger: true }}
onConfirm={() => removeApp(record)}
>
<Tooltip title="删除">
<Button
type="text"
danger
icon={<DeleteOutlined />}
aria-label="删除 App 链接"
loading={updatingId === `app-${record.id}`}
/>
</Tooltip>
</Popconfirm>
</Space>
),
},
];
const columns = [
{
title: '名称',
dataIndex: 'name',
width: 220,
render: (value, record) => (
<div className="api-key-name">
<Text strong>{value}</Text>
{record.description ? <Text type="secondary">{record.description}</Text> : null}
</div>
),
},
{
title: '用途',
dataIndex: 'serviceName',
width: 210,
render: (value) => <Tag color="blue">{value}</Tag>,
},
{
title: 'API Key',
dataIndex: 'keyMasked',
width: 220,
render: (value) => <Text code>{value}</Text>,
},
{
title: '状态',
dataIndex: 'enabled',
width: 170,
render: (enabled, record) => (
<Space>
<Switch
checked={enabled}
loading={updatingId === record.id}
checkedChildren="启用"
unCheckedChildren="停用"
onChange={(checked) => toggleKey(record, checked)}
/>
{record.expired ? <Tag color="error">已过期</Tag> : null}
</Space>
),
},
{
title: '安全策略',
key: 'security',
width: 220,
render: (_, record) => (
<Space size={4} wrap>
<Tag color={record.allowedIps?.length ? 'blue' : 'default'}>
{record.allowedIps?.length ? `IP ${record.allowedIps.length} 个` : '不限 IP'}
</Tag>
<Tag color={record.hmacRequired ? 'green' : 'default'}>
{record.hmacRequired ? '强制 HMAC' : 'API Key'}
</Tag>
</Space>
),
},
{
title: '有效期至',
dataIndex: 'expiresAt',
width: 170,
render: (value) => value ? formatDateTime(value) : <Tag>长期有效</Tag>,
},
{
title: '最近使用',
dataIndex: 'lastUsedAt',
width: 170,
render: formatDateTime,
},
{
title: '创建人',
dataIndex: 'createdBy',
width: 130,
render: (value) => value || '-',
},
{
title: '操作',
key: 'actions',
fixed: 'right',
width: 104,
render: (_, record) => (
<Space size={4}>
<Tooltip title="编辑或轮换 Key">
<Button
type="text"
icon={<EditOutlined />}
aria-label="编辑 API Key"
onClick={() => openEdit(record)}
/>
</Tooltip>
<Popconfirm
title="删除 API Key"
description="删除后,使用该 Key 的第三方请求会立即失效。"
okText="删除"
cancelText="取消"
okButtonProps={{ danger: true }}
onConfirm={() => removeKey(record)}
>
<Tooltip title="删除">
<Button
type="text"
danger
icon={<DeleteOutlined />}
aria-label="删除 API Key"
loading={updatingId === record.id}
/>
</Tooltip>
</Popconfirm>
</Space>
),
},
];
return (
<div className="settings-tab-page api-key-settings-page">
<SycmCard bodyStyle={{ padding: 16 }}>
<div className="api-key-settings-head">
<Space align="center">
<DesktopOutlined className="api-key-settings-icon" />
<div>
<Title level={4}>自动上架 App 接入</Title>
<Text type="secondary">统一管理桌面 App 链接和接口请求密钥</Text>
</div>
</Space>
<Button icon={<ReloadOutlined />} loading={loading || appLoading} onClick={refreshAll}>刷新</Button>
</div>
<Alert
className="external-automation-endpoint"
type="info"
showIcon
icon={<LinkOutlined />}
message="对外接口地址"
description={(
<div className="external-automation-endpoint-list">
<div>
<Text type="secondary">自动上架</Text>
<Text code copyable={{ text: API_BASE_URL }}>{API_BASE_URL}</Text>
</div>
<div>
<Text type="secondary">预售任务</Text>
<Text code copyable={{ text: PRESALE_API_URL }}>{PRESALE_API_URL}</Text>
</div>
</div>
)}
/>
<Tabs
className="external-automation-tabs"
items={[
{
key: 'apps',
label: <span><DesktopOutlined /> App 链接</span>,
children: (
<>
<div className="external-automation-toolbar">
<Text type="secondary">提供桌面端安装包、启动地址和版本更新入口</Text>
<Button type="primary" icon={<PlusOutlined />} onClick={openCreateApp}>新增 App 链接</Button>
</div>
<Table
rowKey="id"
columns={appColumns}
dataSource={appRows}
loading={appLoading}
pagination={appRows.length > 10 ? { pageSize: 10, showSizeChanger: false } : false}
scroll={{ x: 1120 }}
locale={{ emptyText: '暂无 App 链接,点击右上角新增' }}
/>
</>
),
},
{
key: 'keys',
label: <span><KeyOutlined /> 请求 Key</span>,
children: (
<>
<div className="external-automation-toolbar">
<Text type="secondary">同一个 Key 用于自动上架、店铺同步、结果回传和预售任务查询</Text>
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>生成请求 Key</Button>
</div>
<Table
rowKey="id"
columns={columns}
dataSource={rows}
loading={loading}
pagination={rows.length > 10 ? { pageSize: 10, showSizeChanger: false } : false}
scroll={{ x: 1540 }}
locale={{ emptyText: '暂无请求 Key,点击右上角生成' }}
/>
</>
),
},
]}
/>
</SycmCard>
<Modal
title={editingApp ? '编辑 App 链接' : '新增 App 链接'}
open={appEditorOpen}
okText={editingApp ? '保存修改' : '创建'}
cancelText="取消"
confirmLoading={appSaving}
onOk={saveApp}
onCancel={() => setAppEditorOpen(false)}
destroyOnHidden
>
<Form form={appForm} layout="vertical" className="api-key-editor-form">
<Form.Item
label="App 名称"
name="name"
rules={[{ required: true, message: '请输入 App 名称' }, { max: 120 }]}
>
<Input placeholder="例如 自动上架桌面端" />
</Form.Item>
<Space size={12} align="start" className="external-app-form-row">
<Form.Item label="适用平台" name="platform" rules={[{ required: true }]}>
<Select
style={{ width: 160 }}
options={Object.entries(PLATFORM_LABELS).map(([value, label]) => ({ value, label }))}
/>
</Form.Item>
<Form.Item label="版本号" name="version">
<Input placeholder="例如 1.2.0" maxLength={80} />
</Form.Item>
<Form.Item label="排序" name="sortOrder">
<InputNumber min={-9999} max={9999} />
</Form.Item>
</Space>
<Form.Item
label="下载或启动链接"
name="downloadUrl"
rules={[{ required: true, message: '请输入 App 链接' }, { type: 'url', message: '请输入有效链接' }]}
>
<Input prefix={<LinkOutlined />} placeholder="https://..." />
</Form.Item>
<Form.Item label="更新说明链接" name="releaseNotesUrl" rules={[{ type: 'url', warningOnly: true }]}>
<Input prefix={<LinkOutlined />} placeholder="选填,https://..." />
</Form.Item>
<Form.Item label="备注" name="description">
<Input.TextArea rows={2} maxLength={500} showCount placeholder="记录安装要求、发布渠道等信息" />
</Form.Item>
<Form.Item label="启用状态" name="enabled" valuePropName="checked">
<Switch checkedChildren="启用" unCheckedChildren="停用" />
</Form.Item>
</Form>
</Modal>
<Modal
title={editingRecord ? '编辑请求 Key' : '生成请求 Key'}
open={editorOpen}
okText={editingRecord ? '保存修改' : '创建'}
cancelText="取消"
confirmLoading={saving}
onOk={saveKey}
onCancel={() => setEditorOpen(false)}
destroyOnHidden
>
<Form form={form} layout="vertical" className="api-key-editor-form">
<Form.Item
label="名称"
name="name"
rules={[
{ required: true, message: '请输入请求 Key 名称' },
{ max: 120, message: '名称不能超过 120 个字符' },
]}
>
<Input placeholder="例如 ERP 选品同步" />
</Form.Item>
<Form.Item label="备注" name="description">
<Input.TextArea rows={2} maxLength={500} showCount placeholder="记录使用方或接入系统" />
</Form.Item>
<Form.Item
label={editingRecord ? '替换请求 Key' : '请求 Key'}
name="apiKey"
extra={editingRecord ? '留空保留原 Key;填写后会立即轮换。' : '留空时由系统自动生成安全 Key。'}
rules={[{ min: 16, message: 'API Key 长度不能少于 16 位' }]}
>
<Input.Password
autoComplete="new-password"
placeholder={editingRecord ? '留空则不修改' : '留空自动生成'}
/>
</Form.Item>
<Form.Item
label="IP 白名单"
name="allowedIps"
extra="留空表示不限制;每行填写一个 IPv4 或 IPv6 地址。"
>
<Input.TextArea rows={3} placeholder={'例如:\n203.0.113.10\n2001:db8::10'} />
</Form.Item>
<Form.Item
label="强制 HMAC 签名"
name="hmacRequired"
valuePropName="checked"
extra="开启后,第三方每次请求都必须使用当前 API Key 计算 SHA-256 HMAC 签名。"
>
<Switch checkedChildren="强制" unCheckedChildren="关闭" />
</Form.Item>
<Form.Item label="有效期至" name="expiresAt">
<DatePicker
showTime
allowClear
style={{ width: '100%' }}
placeholder="留空表示长期有效"
/>
</Form.Item>
<Form.Item label="启用状态" name="enabled" valuePropName="checked">
<Switch checkedChildren="启用" unCheckedChildren="停用" />
</Form.Item>
</Form>
</Modal>
<Modal
title="完整 API Key"
open={Boolean(revealedKey)}
closable={false}
maskClosable={false}
footer={(
<Button type="primary" onClick={() => setRevealedKey('')}>
我已保存
</Button>
)}
>
<Alert
type="warning"
showIcon
message="完整 Key 仅显示这一次"
description="关闭后系统只保留不可逆摘要,无法再次查看。"
/>
<div className="api-key-reveal">
<Paragraph code copyable={{ text: revealedKey }} ellipsis={false}>
{revealedKey}
</Paragraph>
<Button icon={<CopyOutlined />} onClick={copyRevealedKey}>
复制完整 Key
</Button>
</div>
</Modal>
</div>
);
};
export default ApiKeySettings;