Merge branch 'feature_bugfix/20260106' of https://git.valuefrontier.cn/vf/vf_react into feature_bugfix/20260106
* 'feature_bugfix/20260106' of https://git.valuefrontier.cn/vf/vf_react: fix: 修复 CDN API 签名算法 perf: COS 部署改为增量同步,只上传变更文件 fix: CDN 刷新改用原生 HTTPS,移除 SDK 依赖 修改部署js 修改部署js 修改部署js
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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 (
|
||||
<Center h="300px">
|
||||
<VStack spacing={4}>
|
||||
@@ -299,14 +336,21 @@ const ThemeCometChart = ({ onThemeSelect }) => {
|
||||
{loading && <Spinner size="sm" color="yellow.400" />}
|
||||
</HStack>
|
||||
<Text fontSize="sm" color="whiteAlpha.500">
|
||||
{data.currentDate}
|
||||
{currentSliderDate || currentData.currentDate}
|
||||
</Text>
|
||||
</VStack>
|
||||
</HStack>
|
||||
|
||||
{/* 图表区域 */}
|
||||
<Box h="calc(100% - 100px)">
|
||||
{data.themes.length > 0 ? (
|
||||
<Box h="calc(100% - 100px)" position="relative">
|
||||
{!isCurrentDateLoaded && !loading ? (
|
||||
<Center h="100%">
|
||||
<VStack spacing={2}>
|
||||
<Spinner size="md" color="yellow.400" />
|
||||
<Text color="whiteAlpha.500" fontSize="sm">加载中...</Text>
|
||||
</VStack>
|
||||
</Center>
|
||||
) : currentData.themes.length > 0 ? (
|
||||
<ReactECharts
|
||||
option={chartOption}
|
||||
style={{ height: '100%', width: '100%' }}
|
||||
@@ -321,11 +365,11 @@ const ThemeCometChart = ({ onThemeSelect }) => {
|
||||
</Box>
|
||||
|
||||
{/* 时间滑动条 */}
|
||||
{data.availableDates.length > 1 && (
|
||||
{availableDates.length > 1 && (
|
||||
<Box px={2} pt={2}>
|
||||
<HStack spacing={3}>
|
||||
<Text fontSize="sm" color="whiteAlpha.500" whiteSpace="nowrap">
|
||||
{data.availableDates[data.availableDates.length - 1]?.formatted?.slice(5) || ''}
|
||||
{availableDates[availableDates.length - 1]?.formatted?.slice(5) || ''}
|
||||
</Text>
|
||||
<ChakraTooltip
|
||||
hasArrow
|
||||
@@ -338,11 +382,10 @@ const ThemeCometChart = ({ onThemeSelect }) => {
|
||||
<Slider
|
||||
aria-label="date-slider"
|
||||
min={0}
|
||||
max={data.availableDates.length - 1}
|
||||
max={availableDates.length - 1}
|
||||
step={1}
|
||||
value={sliderIndex}
|
||||
onChange={handleSliderChange}
|
||||
onChangeEnd={handleSliderChangeEnd}
|
||||
onMouseEnter={() => setShowTooltip(true)}
|
||||
onMouseLeave={() => setShowTooltip(false)}
|
||||
isReversed
|
||||
@@ -360,7 +403,7 @@ const ThemeCometChart = ({ onThemeSelect }) => {
|
||||
</Slider>
|
||||
</ChakraTooltip>
|
||||
<Text fontSize="sm" color="whiteAlpha.500" whiteSpace="nowrap">
|
||||
{data.availableDates[0]?.formatted?.slice(5) || ''}
|
||||
{availableDates[0]?.formatted?.slice(5) || ''}
|
||||
</Text>
|
||||
</HStack>
|
||||
</Box>
|
||||
|
||||
Reference in New Issue
Block a user