diff --git a/package.json b/package.json index e0601b91..43029d68 100755 --- a/package.json +++ b/package.json @@ -59,7 +59,7 @@ "@types/react-dom": "^19.0.0" }, "overrides": { - "uuid": "^9.0.1", + "uuid": "^8.3.2", "react": "^19.0.0", "react-dom": "^19.0.0" }, @@ -117,6 +117,7 @@ "prettier": "2.2.1", "react-error-overlay": "6.0.9", "sharp": "^0.34.4", + "tencentcloud-sdk-nodejs-cdn": "^4.1.161", "ts-node": "^10.9.2", "webpack-bundle-analyzer": "^4.10.2", "yn": "^5.1.0" diff --git a/scripts/deploy-to-cos.js b/scripts/deploy-to-cos.js index ee5a0952..614b41e5 100644 --- a/scripts/deploy-to-cos.js +++ b/scripts/deploy-to-cos.js @@ -13,7 +13,8 @@ const COS = require('cos-nodejs-sdk-v5'); const fs = require('fs'); const path = require('path'); -const { execSync } = require('child_process'); +const https = require('https'); +const crypto = require('crypto'); // ============================================================================ // 配置加载 @@ -227,7 +228,53 @@ async function clearBucket() { } /** - * 上传整个 build 目录 + * 计算文件的 MD5 + */ +function getFileMd5(filePath) { + const content = fs.readFileSync(filePath); + return crypto.createHash('md5').update(content).digest('hex'); +} + +/** + * 获取 COS 上所有文件的 ETag(MD5) + */ +async function getRemoteFiles() { + return new Promise((resolve, reject) => { + const remoteFiles = {}; + let marker = ''; + + const fetchBatch = () => { + cos.getBucket({ + Bucket: config.COS_BUCKET, + Region: config.COS_REGION, + MaxKeys: 1000, + Marker: marker, + }, (err, data) => { + if (err) { + reject(err); + return; + } + + data.Contents.forEach(item => { + // COS 的 ETag 是 MD5,去掉引号 + remoteFiles[item.Key] = item.ETag.replace(/"/g, ''); + }); + + if (data.IsTruncated === 'true') { + marker = data.NextMarker; + fetchBatch(); + } else { + resolve(remoteFiles); + } + }); + }; + + fetchBatch(); + }); +} + +/** + * 上传整个 build 目录(增量同步) */ async function uploadBuildDir() { const buildDir = path.join(__dirname, '..', 'build'); @@ -237,8 +284,38 @@ async function uploadBuildDir() { process.exit(1); } - const files = getAllFiles(buildDir); - console.log(`\n\x1b[36m[上传]\x1b[0m 共 ${files.length} 个文件待上传...\n`); + // 获取本地文件列表 + const localFiles = getAllFiles(buildDir); + console.log(`\n\x1b[36m[扫描]\x1b[0m 本地共 ${localFiles.length} 个文件`); + + // 获取远程文件列表 + console.log('\x1b[36m[同步]\x1b[0m 获取 COS 文件列表...'); + let remoteFiles = {}; + try { + remoteFiles = await getRemoteFiles(); + console.log(` COS 上共 ${Object.keys(remoteFiles).length} 个文件`); + } catch (err) { + console.log(`\x1b[33m[警告]\x1b[0m 获取远程文件列表失败,将全量上传: ${err.message}`); + } + + // 比对找出需要上传的文件 + const filesToUpload = []; + for (const filePath of localFiles) { + const relativePath = path.relative(buildDir, filePath).replace(/\\/g, '/'); + const localMd5 = getFileMd5(filePath); + const remoteMd5 = remoteFiles[relativePath]; + + if (localMd5 !== remoteMd5) { + filesToUpload.push({ filePath, relativePath }); + } + } + + console.log(`\x1b[36m[上传]\x1b[0m 需要上传 ${filesToUpload.length} 个文件(跳过 ${localFiles.length - filesToUpload.length} 个未变更)\n`); + + if (filesToUpload.length === 0) { + console.log('\x1b[32m[✓]\x1b[0m 所有文件已是最新,无需上传'); + return { uploaded: 0, failed: 0, skipped: localFiles.length }; + } let uploaded = 0; let failed = 0; @@ -247,21 +324,19 @@ async function uploadBuildDir() { // 并发上传(限制并发数) const concurrency = 10; const chunks = []; - for (let i = 0; i < files.length; i += concurrency) { - chunks.push(files.slice(i, i + concurrency)); + for (let i = 0; i < filesToUpload.length; i += concurrency) { + chunks.push(filesToUpload.slice(i, i + concurrency)); } for (const chunk of chunks) { - await Promise.all(chunk.map(async (filePath) => { - const relativePath = path.relative(buildDir, filePath).replace(/\\/g, '/'); - + await Promise.all(chunk.map(async ({ filePath, relativePath }) => { try { await uploadFile(filePath, relativePath); uploaded++; // 进度显示 - const progress = Math.round((uploaded + failed) / files.length * 100); - process.stdout.write(`\r 进度: ${progress}% (${uploaded}/${files.length})`); + const progress = Math.round((uploaded + failed) / filesToUpload.length * 100); + process.stdout.write(`\r 进度: ${progress}% (${uploaded}/${filesToUpload.length})`); } catch (err) { failed++; errors.push({ file: relativePath, error: err.message }); @@ -278,31 +353,153 @@ async function uploadBuildDir() { }); } - console.log(`\x1b[32m[✓]\x1b[0m 上传完成: 成功 ${uploaded},失败 ${failed}`); + console.log(`\x1b[32m[✓]\x1b[0m 上传完成: 成功 ${uploaded},跳过 ${localFiles.length - filesToUpload.length},失败 ${failed}`); - return { uploaded, failed }; + return { uploaded, failed, skipped: localFiles.length - filesToUpload.length }; } // ============================================================================ -// CDN 刷新(可选) +// CDN 刷新 (使用原生 HTTPS + TC3 签名,无需额外 SDK) // ============================================================================ +/** + * 生成腾讯云 TC3-HMAC-SHA256 签名 + */ +function generateTC3Signature(secretId, secretKey, service, action, payload) { + const timestamp = Math.floor(Date.now() / 1000); + const date = new Date(timestamp * 1000).toISOString().slice(0, 10); + const host = `${service}.tencentcloudapi.com`; + + // 1. 拼接规范请求串 + const httpRequestMethod = 'POST'; + const canonicalUri = '/'; + const canonicalQueryString = ''; + const hashedPayload = crypto.createHash('sha256').update(payload).digest('hex'); + // 只签名 content-type 和 host(按字母顺序) + const canonicalHeaders = `content-type:application/json; charset=utf-8\nhost:${host}\n`; + const signedHeaders = 'content-type;host'; + const canonicalRequest = `${httpRequestMethod}\n${canonicalUri}\n${canonicalQueryString}\n${canonicalHeaders}\n${signedHeaders}\n${hashedPayload}`; + + // 2. 拼接待签名字符串 + const algorithm = 'TC3-HMAC-SHA256'; + const credentialScope = `${date}/${service}/tc3_request`; + const hashedCanonicalRequest = crypto.createHash('sha256').update(canonicalRequest).digest('hex'); + const stringToSign = `${algorithm}\n${timestamp}\n${credentialScope}\n${hashedCanonicalRequest}`; + + // 3. 计算签名 + const secretDate = crypto.createHmac('sha256', `TC3${secretKey}`).update(date).digest(); + const secretService = crypto.createHmac('sha256', secretDate).update(service).digest(); + const secretSigning = crypto.createHmac('sha256', secretService).update('tc3_request').digest(); + const signature = crypto.createHmac('sha256', secretSigning).update(stringToSign).digest('hex'); + + // 4. 拼接 Authorization + const authorization = `${algorithm} Credential=${secretId}/${credentialScope}, SignedHeaders=${signedHeaders}, Signature=${signature}`; + + return { authorization, timestamp }; +} + +/** + * 调用腾讯云 CDN API + */ +function callCdnApi(secretId, secretKey, action, params) { + return new Promise((resolve, reject) => { + const payload = JSON.stringify(params); + const { authorization, timestamp } = generateTC3Signature(secretId, secretKey, 'cdn', action, payload); + + const options = { + hostname: 'cdn.tencentcloudapi.com', + port: 443, + path: '/', + method: 'POST', + headers: { + 'Content-Type': 'application/json; charset=utf-8', + 'Host': 'cdn.tencentcloudapi.com', + 'X-TC-Action': action, + 'X-TC-Version': '2018-06-06', + 'X-TC-Timestamp': timestamp.toString(), + 'X-TC-Region': '', + 'Authorization': authorization, + }, + }; + + const req = https.request(options, (res) => { + let data = ''; + res.on('data', chunk => data += chunk); + res.on('end', () => { + try { + const result = JSON.parse(data); + if (result.Response && result.Response.Error) { + reject(new Error(`${result.Response.Error.Code}: ${result.Response.Error.Message}`)); + } else { + resolve(result.Response); + } + } catch (e) { + reject(new Error(`解析响应失败: ${data}`)); + } + }); + }); + + req.on('error', reject); + req.write(payload); + req.end(); + }); +} + /** * 刷新 CDN 缓存 - * 注意:需要额外配置 CDN API 权限 + * 自动刷新 index.html 等关键文件,确保用户获取最新版本 */ async function refreshCDN() { if (!config.CDN_DOMAIN) { console.log('\n\x1b[33m[提示]\x1b[0m 未配置 CDN 域名,跳过 CDN 刷新'); console.log(' 如需自动刷新 CDN,请在 .env.cos 中配置 CDN_DOMAIN'); - return; + return { success: false, reason: 'no_domain' }; } console.log('\n\x1b[36m[CDN]\x1b[0m 刷新 CDN 缓存...'); console.log(` 域名: ${config.CDN_DOMAIN}`); - console.log('\n\x1b[33m[提示]\x1b[0m 由于文件名包含 hash,通常不需要手动刷新 CDN'); - console.log(' 如需刷新,请到腾讯云 CDN 控制台操作:'); - console.log(' https://console.cloud.tencent.com/cdn/refresh'); + + try { + // 需要刷新的关键文件(不带 hash 的文件) + const domain = config.CDN_DOMAIN.replace(/^https?:\/\//, ''); + const urlsToRefresh = [ + `https://${domain}/index.html`, + `https://${domain}/`, + `https://${domain}/asset-manifest.json`, + `https://${domain}/manifest.json`, + ]; + + console.log(' 刷新文件:'); + urlsToRefresh.forEach(url => console.log(` - ${url}`)); + + // 调用 PurgeUrlsCache 接口 + const result = await callCdnApi( + config.COS_SECRET_ID, + config.COS_SECRET_KEY, + 'PurgeUrlsCache', + { Urls: urlsToRefresh } + ); + + console.log(`\n\x1b[32m[✓]\x1b[0m CDN 刷新任务已提交`); + console.log(` 任务ID: ${result.TaskId}`); + console.log(' 刷新生效时间约 5 分钟'); + + return { success: true, taskId: result.TaskId }; + + } catch (err) { + console.error(`\n\x1b[33m[警告]\x1b[0m CDN 刷新失败: ${err.message}`); + + if (err.message.includes('AuthFailure')) { + console.log(' 请检查 API 密钥是否正确'); + } else if (err.message.includes('UnauthorizedOperation')) { + console.log(' 请确保 API 密钥有 CDN 刷新权限'); + console.log(' 权限策略: QcloudCDNFullAccess 或自定义 cdn:PurgeUrlsCache'); + } + + console.log('\n 可手动刷新: https://console.cloud.tencent.com/cdn/refresh'); + + return { success: false, reason: 'api_error', error: err.message }; + } } // ============================================================================ @@ -329,8 +526,8 @@ async function main() { // 3. 上传文件 const { uploaded, failed } = await uploadBuildDir(); - // 4. CDN 刷新提示 - await refreshCDN(); + // 4. CDN 刷新 + const cdnResult = await refreshCDN(); // 5. 完成 const duration = ((Date.now() - startTime) / 1000).toFixed(1); @@ -340,9 +537,13 @@ async function main() { console.log('╚════════════════════════════════════════════════════════════════╝'); console.log(`\n 耗时: ${duration} 秒`); console.log(` 上传: ${uploaded} 个文件`); + console.log(` CDN刷新: ${cdnResult.success ? '✓ 已提交' : '✗ 跳过'}`); if (config.CDN_DOMAIN) { console.log(`\n 访问地址: https://${config.CDN_DOMAIN}`); + if (cdnResult.success) { + console.log(' 提示: CDN 刷新约需 5 分钟生效'); + } } console.log('\n'); diff --git a/src/views/Community/components/ThemeCometChart.js b/src/views/Community/components/ThemeCometChart.js index 4f82481f..6e4f3acf 100644 --- a/src/views/Community/components/ThemeCometChart.js +++ b/src/views/Community/components/ThemeCometChart.js @@ -195,30 +195,68 @@ const generateChartOption = (themes) => { */ const ThemeCometChart = ({ onThemeSelect }) => { const [loading, setLoading] = useState(true); - const [data, setData] = useState({ themes: [], currentDate: '', availableDates: [] }); + const [allDatesData, setAllDatesData] = useState({}); // 缓存所有日期的数据 + const [availableDates, setAvailableDates] = useState([]); const [selectedTheme, setSelectedTheme] = useState(null); const [sliderIndex, setSliderIndex] = useState(0); const [showTooltip, setShowTooltip] = useState(false); const { isOpen, onOpen, onClose } = useDisclosure(); const toast = useToast(); - // 加载指定日期的数据 - const loadData = useCallback(async (dateStr = '') => { + // 加载所有日期的数据 + const loadAllData = useCallback(async () => { setLoading(true); try { const apiBase = getApiBase(); - const url = dateStr - ? `${apiBase}/api/v1/zt/theme-scatter?date=${dateStr}&days=5` - : `${apiBase}/api/v1/zt/theme-scatter?days=5`; - const response = await fetch(url); + // 先获取最新数据,拿到可用日期列表 + const response = await fetch(`${apiBase}/api/v1/zt/theme-scatter?days=5`); const result = await response.json(); if (result.success && result.data) { - setData({ - themes: result.data.themes || [], - currentDate: result.data.currentDate || '', - availableDates: result.data.availableDates || [], + const dates = result.data.availableDates || []; + setAvailableDates(dates); + + // 缓存第一个日期(最新)的数据 + const latestDate = dates[0]?.date; + const dataCache = {}; + if (latestDate) { + dataCache[latestDate] = { + themes: result.data.themes || [], + currentDate: result.data.currentDate || '', + }; + } + + // 并行加载其他日期的数据 + const otherDates = dates.slice(1); + const promises = otherDates.map(async (dateInfo) => { + try { + const res = await fetch(`${apiBase}/api/v1/zt/theme-scatter?date=${dateInfo.date}&days=5`); + const data = await res.json(); + if (data.success && data.data) { + return { + date: dateInfo.date, + themes: data.data.themes || [], + currentDate: data.data.currentDate || '', + }; + } + } catch (e) { + console.error(`加载 ${dateInfo.date} 数据失败:`, e); + } + return null; }); + + const results = await Promise.all(promises); + results.forEach((item) => { + if (item) { + dataCache[item.date] = { + themes: item.themes, + currentDate: item.currentDate, + }; + } + }); + + setAllDatesData(dataCache); + setSliderIndex(0); // 默认显示最新日期 } else { throw new Error(result.error || '加载失败'); } @@ -230,43 +268,42 @@ const ThemeCometChart = ({ onThemeSelect }) => { } }, [toast]); - // 初始加载 + // 初始加载所有数据 useEffect(() => { - loadData(); - }, [loadData]); + loadAllData(); + }, [loadAllData]); - // 滑动条变化时加载对应日期数据 + // 滑动条变化时实时切换数据 const handleSliderChange = useCallback((value) => { setSliderIndex(value); }, []); - const handleSliderChangeEnd = useCallback((value) => { - if (data.availableDates && data.availableDates[value]) { - loadData(data.availableDates[value].date); - } - }, [data.availableDates, loadData]); + // 获取当前显示的数据 + const currentDateStr = availableDates[sliderIndex]?.date; + const currentData = allDatesData[currentDateStr] || { themes: [], currentDate: '' }; + const isCurrentDateLoaded = currentDateStr && allDatesData[currentDateStr]; - const chartOption = useMemo(() => generateChartOption(data.themes), [data.themes]); + const chartOption = useMemo(() => generateChartOption(currentData.themes), [currentData.themes]); const handleChartClick = useCallback( (params) => { if (params.data) { - const theme = data.themes.find((t) => t.label === params.data.name); + const theme = currentData.themes.find((t) => t.label === params.data.name); if (theme) { setSelectedTheme(theme); onOpen(); } } }, - [data.themes, onOpen] + [currentData.themes, onOpen] ); const onChartEvents = useMemo(() => ({ click: handleChartClick }), [handleChartClick]); // 当前滑动条对应的日期 - const currentSliderDate = data.availableDates?.[sliderIndex]?.formatted || data.currentDate; + const currentSliderDate = availableDates[sliderIndex]?.formatted || currentData.currentDate; - if (loading && data.themes.length === 0) { + if (loading && Object.keys(allDatesData).length === 0) { return (