新增CSV导入导出功能,支持导入CSV数据并生成合成图像,同时增加导出CSV hex数据选项,优化文件处理逻辑,提升用户交互体验。

This commit is contained in:
zihanjian
2025-06-06 15:50:49 +08:00
parent f9c1caab67
commit 8cfb9c50ac
4 changed files with 320 additions and 31 deletions

View File

@@ -18,7 +18,7 @@ import {
// 导入新的类型和组件
import { GridDownloadOptions } from '../types/downloadTypes';
import DownloadSettingsModal, { gridLineColorOptions } from '../components/DownloadSettingsModal';
import { downloadImage } from '../utils/imageDownloader';
import { downloadImage, importCsvData } from '../utils/imageDownloader';
import {
colorSystemOptions,
@@ -138,7 +138,8 @@ export default function Home() {
gridInterval: 10,
showCoordinates: true,
gridLineColor: gridLineColorOptions[0].value,
includeStats: true // 默认包含统计信息
includeStats: true, // 默认包含统计信息
exportCsv: false // 默认不导出CSV
});
// 新增:高亮相关状态
@@ -147,6 +148,9 @@ export default function Home() {
// 新增:完整色板切换状态
const [showFullPalette, setShowFullPalette] = useState<boolean>(false);
// 添加标记来区分是否是CSV导入的图像
const [isFromCsvImport, setIsFromCsvImport] = useState<boolean>(false);
// 新增:颜色替换相关状态
const [colorReplaceState, setColorReplaceState] = useState<{
isActive: boolean;
@@ -434,12 +438,13 @@ export default function Home() {
// 更严格的文件类型检查
const allowedTypes = ['image/jpeg', 'image/jpg', 'image/png'];
const fileType = file.type.toLowerCase();
const fileName = file.name.toLowerCase();
if (allowedTypes.includes(fileType) || file.type.startsWith('image/')) {
if (allowedTypes.includes(fileType) || file.type.startsWith('image/') || fileName.endsWith('.csv')) {
setExcludedColorKeys(new Set()); // ++ 重置排除列表 ++
processFile(file);
} else {
alert(`不支持的文件类型: ${file.type || '未知'}。请拖放 JPGPNG 格式的图片文件。`);
alert(`不支持的文件类型: ${file.type || '未知'}。请拖放 JPGPNG 格式的图片文件,或 CSV 数据文件`);
}
}
} catch (error) {
@@ -453,32 +458,131 @@ export default function Home() {
event.stopPropagation();
};
const processFile = (file: File) => {
const reader = new FileReader();
reader.onload = (e) => {
const result = e.target?.result as string;
setOriginalImageSrc(result);
setMappedPixelData(null);
setGridDimensions(null);
setColorCounts(null);
setTotalBeadCount(0);
setInitialGridColorKeys(new Set()); // ++ 重置初始键 ++
// ++ 重置横轴格子数量为默认值 ++
const defaultGranularity = 100;
setGranularity(defaultGranularity);
setGranularityInput(defaultGranularity.toString());
setRemapTrigger(prev => prev + 1); // Trigger full remap for new image
};
reader.onerror = () => {
console.error("文件读取失败");
alert("无法读取文件。");
setInitialGridColorKeys(new Set()); // ++ 重置初始键 ++
// 根据mappedPixelData生成合成的originalImageSrc
const generateSyntheticImageFromPixelData = (pixelData: MappedPixel[][], dimensions: { N: number; M: number }): string => {
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
if (!ctx) {
console.error('无法创建canvas上下文');
return '';
}
// 设置画布尺寸每个像素用8x8像素来表示以确保清晰度
const pixelSize = 8;
canvas.width = dimensions.N * pixelSize;
canvas.height = dimensions.M * pixelSize;
// 绘制每个像素
pixelData.forEach((row, rowIndex) => {
row.forEach((cell, colIndex) => {
if (cell) {
// 使用颜色,外部单元格用白色
const color = cell.isExternal ? '#FFFFFF' : cell.color;
ctx.fillStyle = color;
ctx.fillRect(
colIndex * pixelSize,
rowIndex * pixelSize,
pixelSize,
pixelSize
);
}
});
});
// 转换为dataURL
return canvas.toDataURL('image/png');
};
const processFile = (file: File) => {
// 检查文件类型
const fileExtension = file.name.split('.').pop()?.toLowerCase();
if (fileExtension === 'csv') {
// 处理CSV文件
console.log('正在导入CSV文件...');
importCsvData(file)
.then(({ mappedPixelData, gridDimensions }) => {
console.log(`成功导入CSV文件: ${gridDimensions.N}x${gridDimensions.M}`);
// 设置导入的数据
setMappedPixelData(mappedPixelData);
setGridDimensions(gridDimensions);
setOriginalImageSrc(null); // CSV导入时没有原始图片
// 计算颜色统计
const colorCountsMap: { [key: string]: { count: number; color: string } } = {};
let totalCount = 0;
mappedPixelData.forEach(row => {
row.forEach(cell => {
if (cell && !cell.isExternal) {
const colorKey = cell.color.toUpperCase();
if (colorCountsMap[colorKey]) {
colorCountsMap[colorKey].count++;
} else {
colorCountsMap[colorKey] = {
count: 1,
color: cell.color
};
}
totalCount++;
}
});
});
setColorCounts(colorCountsMap);
setTotalBeadCount(totalCount);
setInitialGridColorKeys(new Set(Object.keys(colorCountsMap)));
// 根据mappedPixelData生成合成的originalImageSrc
const syntheticImageSrc = generateSyntheticImageFromPixelData(mappedPixelData, gridDimensions);
setOriginalImageSrc(syntheticImageSrc);
// 重置状态
setIsManualColoringMode(false);
setSelectedColor(null);
setIsEraseMode(false);
// 设置格子数量为导入的尺寸,避免重新映射时尺寸被修改
setGranularity(gridDimensions.N);
setGranularityInput(gridDimensions.N.toString());
alert(`成功导入CSV文件图纸尺寸${gridDimensions.N}x${gridDimensions.M},共使用${Object.keys(colorCountsMap).length}种颜色。`);
})
.catch(error => {
console.error('CSV导入失败:', error);
alert(`CSV导入失败${error.message}`);
});
} else {
// 处理图片文件
const reader = new FileReader();
reader.onload = (e) => {
const result = e.target?.result as string;
setOriginalImageSrc(result);
setMappedPixelData(null);
setGridDimensions(null);
setColorCounts(null);
setTotalBeadCount(0);
setInitialGridColorKeys(new Set()); // ++ 重置初始键 ++
// ++ 重置横轴格子数量为默认值 ++
const defaultGranularity = 100;
setGranularity(defaultGranularity);
setGranularityInput(defaultGranularity.toString());
setRemapTrigger(prev => prev + 1); // Trigger full remap for new image
};
reader.onerror = () => {
console.error("文件读取失败");
alert("无法读取文件。");
setInitialGridColorKeys(new Set()); // ++ 重置初始键 ++
}
reader.readAsDataURL(file);
// ++ Reset manual coloring mode when a new file is processed ++
setIsManualColoringMode(false);
setSelectedColor(null);
setIsEraseMode(false);
}
reader.readAsDataURL(file);
// ++ Reset manual coloring mode when a new file is processed ++
setIsManualColoringMode(false);
setSelectedColor(null);
setIsEraseMode(false);
};
// 处理一键擦除模式切换
@@ -1734,7 +1838,7 @@ export default function Home() {
{/* Text color */}
<p className="text-xs sm:text-sm text-gray-500 dark:text-gray-400"><span className="font-medium text-blue-600 dark:text-blue-400"></span></p>
{/* Text color */}
<p className="text-xs text-gray-400 dark:text-gray-500 mt-1"> JPG, PNG </p>
<p className="text-xs text-gray-400 dark:text-gray-500 mt-1"> JPG, PNG CSV </p>
</div>
{/* Apply dark mode styles to the Tip Box */}
@@ -1751,7 +1855,7 @@ export default function Home() {
</div>
)}
<input type="file" accept="image/jpeg, image/png" onChange={handleFileChange} ref={fileInputRef} className="hidden" />
<input type="file" accept="image/jpeg, image/png, .csv" onChange={handleFileChange} ref={fileInputRef} className="hidden" />
{/* Controls and Output Area */}
{originalImageSrc && (

View File

@@ -167,6 +167,27 @@ const DownloadSettingsModal: React.FC<DownloadSettingsModalProps> = ({
<div className="w-11 h-6 bg-gray-200 peer-focus:outline-none rounded-full peer dark:bg-gray-700 peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-blue-600"></div>
</label>
</div>
{/* 新增: 导出CSV hex数据选项 */}
<div className="flex items-center justify-between">
<div className="flex flex-col">
<label className="flex items-center text-sm font-medium text-gray-700 dark:text-gray-300">
CSV数据
</label>
<span className="text-xs text-gray-500 dark:text-gray-400 mt-1">
hex颜色值的CSV文件
</span>
</div>
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
className="sr-only peer"
checked={tempOptions.exportCsv}
onChange={(e) => handleOptionChange('exportCsv', e.target.checked)}
/>
<div className="w-11 h-6 bg-gray-200 peer-focus:outline-none rounded-full peer dark:bg-gray-700 peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-blue-600"></div>
</label>
</div>
</div>
<div className="flex justify-end mt-6 space-x-3">

View File

@@ -5,4 +5,5 @@ export type GridDownloadOptions = {
showCoordinates: boolean;
gridLineColor: string;
includeStats: boolean;
exportCsv: boolean; // 新增是否同时导出CSV hex数据
};

View File

@@ -46,6 +46,160 @@ function sortColorKeys(a: string, b: string): number {
return a.localeCompare(b);
}
// 导出CSV hex数据的函数
export function exportCsvData({
mappedPixelData,
gridDimensions,
selectedColorSystem
}: {
mappedPixelData: MappedPixel[][] | null;
gridDimensions: { N: number; M: number } | null;
selectedColorSystem: ColorSystem;
}): void {
if (!mappedPixelData || !gridDimensions) {
console.error("导出失败: 映射数据或尺寸无效。");
alert("无法导出CSV数据未生成或无效。");
return;
}
const { N, M } = gridDimensions;
// 生成CSV内容每行代表图纸的一行
const csvLines: string[] = [];
for (let row = 0; row < M; row++) {
const rowData: string[] = [];
for (let col = 0; col < N; col++) {
const cellData = mappedPixelData[row][col];
if (cellData && !cellData.isExternal) {
// 内部单元格记录hex颜色值
rowData.push(cellData.color);
} else {
// 外部单元格或空白,使用特殊标记
rowData.push('TRANSPARENT');
}
}
csvLines.push(rowData.join(','));
}
// 创建CSV内容
const csvContent = csvLines.join('\n');
// 创建并下载CSV文件
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
const link = document.createElement('a');
const url = URL.createObjectURL(blob);
link.setAttribute('href', url);
link.setAttribute('download', `bead-pattern-${N}x${M}-${selectedColorSystem}.csv`);
link.style.visibility = 'hidden';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
// 释放URL对象
URL.revokeObjectURL(url);
console.log("CSV数据导出完成");
}
// 导入CSV hex数据的函数
export function importCsvData(file: File): Promise<{
mappedPixelData: MappedPixel[][];
gridDimensions: { N: number; M: number };
}> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = (e) => {
try {
const text = e.target?.result as string;
if (!text) {
reject(new Error('无法读取文件内容'));
return;
}
// 解析CSV内容
const lines = text.trim().split('\n');
const M = lines.length; // 行数
if (M === 0) {
reject(new Error('CSV文件为空'));
return;
}
// 解析第一行获取列数
const firstRowData = lines[0].split(',');
const N = firstRowData.length; // 列数
if (N === 0) {
reject(new Error('CSV文件格式无效'));
return;
}
// 创建映射数据
const mappedPixelData: MappedPixel[][] = [];
for (let row = 0; row < M; row++) {
const rowData = lines[row].split(',');
const mappedRow: MappedPixel[] = [];
// 确保每行都有正确的列数
if (rowData.length !== N) {
reject(new Error(`${row + 1}行的列数不匹配,期望${N}列,实际${rowData.length}`));
return;
}
for (let col = 0; col < N; col++) {
const cellValue = rowData[col].trim();
if (cellValue === 'TRANSPARENT' || cellValue === '') {
// 外部/透明单元格
mappedRow.push({
key: 'TRANSPARENT',
color: '#FFFFFF',
isExternal: true
});
} else {
// 验证hex颜色格式
const hexPattern = /^#[0-9A-Fa-f]{6}$/;
if (!hexPattern.test(cellValue)) {
reject(new Error(`${row + 1}行第${col + 1}列的颜色值无效:${cellValue}`));
return;
}
// 内部单元格
mappedRow.push({
key: cellValue.toUpperCase(),
color: cellValue.toUpperCase(),
isExternal: false
});
}
}
mappedPixelData.push(mappedRow);
}
// 返回解析结果
resolve({
mappedPixelData,
gridDimensions: { N, M }
});
} catch (error) {
reject(new Error(`解析CSV文件失败${error}`));
}
};
reader.onerror = () => {
reject(new Error('读取文件失败'));
};
reader.readAsText(file, 'utf-8');
});
}
// 下载图片的主函数
export async function downloadImage({
mappedPixelData,
@@ -666,6 +820,15 @@ export async function downloadImage({
link.click();
document.body.removeChild(link);
console.log("Grid image download initiated.");
// 如果启用了CSV导出同时导出CSV文件
if (options.exportCsv) {
exportCsvData({
mappedPixelData,
gridDimensions,
selectedColorSystem
});
}
} catch (e) {
console.error("下载图纸失败:", e);
alert("无法生成图纸下载链接。");