agent_9art/frontend/admin/src/pages/CosConfig.tsx
jqb 32d9cbc09c feat: 新增腾讯云COS存储支持功能
此提交实现了完整的腾讯云COS存储集成方案:
1. 新增COS配置管理页面与后端接口,支持配置的增改查、连接测试和历史文件批量迁移
2. 新增COS配置实体、Mapper和VO类,完善数据库表结构初始化脚本
3. 改造媒体下载接口,优先返回COS重定向链接,失败则回退本地读取
4. 新增自动上传逻辑,新产生的媒体文件会自动同步到COS
5. 新增未上传COS的历史媒体文件查询接口,支持批量迁移本地存量文件
6. 引入腾讯云COS SDK依赖,添加侧边栏COS配置菜单入口
2026-06-25 09:42:22 +08:00

225 lines
7.1 KiB
TypeScript
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 { useState, useEffect } from 'react'
import { Card, Form, Input, Switch, Button, message, Space, Tag } from 'antd'
import { CloudUploadOutlined, SaveOutlined, ApiOutlined } from '@ant-design/icons'
import request from '../utils/request'
interface CosConfigForm {
id?: number
secretId: string
secretKey: string
bucket: string
region: string
pathPrefix: string
domain: string
enabled: boolean
}
export default function CosConfig() {
const [form] = Form.useForm()
const [loading, setLoading] = useState(false)
const [testLoading, setTestLoading] = useState(false)
const [migrateLoading, setMigrateLoading] = useState(false)
const [migratedCount, setMigratedCount] = useState<number | null>(null)
const [hasSecretKey, setHasSecretKey] = useState(false)
const loadConfig = async () => {
setLoading(true)
try {
const res: any = await request.get('/v1/archive/cos-config')
const data = res.data
if (data) {
// 后端返回的 secretKey 是脱敏的,不能作为表单默认值,否则保存时会把掩码存回去
const { secretKey, ...rest } = data
form.setFieldsValue({
...rest,
enabled: data.enabled === 1,
})
// 如果返回的 SecretKey 是脱敏的(包含*),说明后端有值
setHasSecretKey(secretKey && secretKey.includes('*'))
} else {
form.setFieldsValue({
pathPrefix: 'archive/media/',
enabled: false,
})
}
} finally {
setLoading(false)
}
}
useEffect(() => {
loadConfig()
}, [])
const handleSave = async (values: CosConfigForm) => {
const payload = {
...values,
enabled: values.enabled ? 1 : 0,
}
// 如果 SecretKey 为空且已有配置,则保留原值由后端处理
if (!payload.secretKey && hasSecretKey) {
payload.secretKey = undefined as any
}
try {
await request.post('/v1/archive/cos-config', payload)
message.success('保存成功')
loadConfig()
} catch (e: any) {
message.error(e.response?.data?.message || '保存失败')
}
}
const handleTest = async () => {
const values = form.getFieldsValue()
if (!values.secretId || !values.bucket || !values.region) {
message.warning('请填写 SecretId、Bucket、Region')
return
}
// 如果 SecretKey 为空且已有原值,提示用户输入 SecretKey 后再测试
if (!values.secretKey && hasSecretKey) {
message.warning('已保存 SecretKey,但测试连接需要重新输入 SecretKey')
return
}
setTestLoading(true)
try {
const res: any = await request.post('/v1/archive/cos-config/test', {
secretId: values.secretId,
secretKey: values.secretKey,
bucket: values.bucket,
region: values.region,
})
if (res.data === true) {
message.success('COS 连接测试成功')
} else {
message.error('COS 连接测试失败,请检查配置')
}
} catch (e: any) {
message.error(e.response?.data?.message || '测试失败')
} finally {
setTestLoading(false)
}
}
const handleMigrate = async () => {
setMigrateLoading(true)
setMigratedCount(null)
try {
const res: any = await request.post('/v1/archive/cos-config/migrate?batchSize=50')
setMigratedCount(res.data || 0)
message.success(`批量迁移完成,共迁移 ${res.data || 0} 个文件`)
} catch (e: any) {
message.error(e.response?.data?.message || '迁移失败')
} finally {
setMigrateLoading(false)
}
}
return (
<div>
<Card title="腾讯云 COS 配置" loading={loading}>
<Form
form={form}
layout="vertical"
onFinish={handleSave}
initialValues={{ pathPrefix: 'archive/media/', enabled: false }}
>
<Form.Item
label="SecretId"
name="secretId"
rules={[{ required: true, message: '请输入 SecretId' }]}
>
<Input placeholder="腾讯云 SecretId" />
</Form.Item>
<Form.Item
label="SecretKey"
name="secretKey"
rules={[{ required: !hasSecretKey, message: '请输入 SecretKey' }]}
extra={hasSecretKey ? '已保存 SecretKey,如需修改请重新填写' : undefined}
>
<Input.Password placeholder="腾讯云 SecretKey" />
</Form.Item>
<Form.Item
label="Bucket"
name="bucket"
rules={[{ required: true, message: '请输入 Bucket 名称' }]}
>
<Input placeholder="存储桶名称,如 my-bucket-1250000000" />
</Form.Item>
<Form.Item
label="Region"
name="region"
rules={[{ required: true, message: '请输入 Region' }]}
>
<Input placeholder="地域,如 ap-shanghai" />
</Form.Item>
<Form.Item
label="路径前缀"
name="pathPrefix"
>
<Input placeholder="COS 上的路径前缀,如 archive/media/" />
</Form.Item>
<Form.Item
label="自定义域名(可选)"
name="domain"
>
<Input placeholder="CDN 或自定义域名,为空则使用默认 COS 域名" />
</Form.Item>
<Form.Item
label="启用 COS"
name="enabled"
valuePropName="checked"
>
<Switch checkedChildren="启用" unCheckedChildren="停用" />
</Form.Item>
<Form.Item>
<Space>
<Button type="primary" htmlType="submit" icon={<SaveOutlined />}>
保存配置
</Button>
<Button icon={<ApiOutlined />} onClick={handleTest} loading={testLoading}>
测试连接
</Button>
</Space>
</Form.Item>
</Form>
</Card>
<Card style={{ marginTop: 16 }} title="历史媒体文件迁移">
<p style={{ color: '#666', marginBottom: 16 }}>
将已下载到本地的会话存档媒体文件批量上传到腾讯云 COS。上传完成后,后续访问将直接走 COS 链接。
</p>
<Button
type="primary"
icon={<CloudUploadOutlined />}
loading={migrateLoading}
onClick={handleMigrate}
>
开始批量迁移
</Button>
{migratedCount !== null && (
<Tag color="green" style={{ marginLeft: 12 }}>
本次迁移 {migratedCount} 个文件
</Tag>
)}
</Card>
<Card style={{ marginTop: 16 }} title="说明">
<ul style={{ color: '#666', paddingLeft: 20 }}>
<li>启用 COS 后,新产生的媒体文件会在下载完成后自动上传到 COS。</li>
<li>媒体访问接口 /api/v1/archive/media/&#123;id&#125; 会优先返回 COS URL(302 重定向)。</li>
<li>如果 COS 未启用或上传失败,会回退到本地文件读取。</li>
<li>自定义域名需以 http:// 或 https:// 开头,保存时会自动处理。</li>
</ul>
</Card>
</div>
)
}