```react
import React, { useState, useRef } from 'react';
import { Upload, FileDown, Loader2, AlertCircle, Plus, Trash2, Image as ImageIcon } from 'lucide-react';
export default function App() {
const [imageSrc, setImageSrc] = useState(null);
const [isAnalyzing, setIsAnalyzing] = useState(false);
const [results, setResults] = useState([]);
const [error, setError] = useState(null);
const fileInputRef = useRef(null);
// 画像アップロード処理
const handleImageChange = (e) => {
const file = e.target.files[0];
if (file) {
// 画像ファイルかチェック
if (!file.type.startsWith('image/')) {
setError('画像ファイルを選択してください。');
return;
}
const reader = new FileReader();
reader.onloadend = () => {
setImageSrc(reader.result);
setResults([]); // 新しい画像を読み込んだら結果をリセット
setError(null);
};
reader.onerror = () => {
setError('画像の読み込みに失敗しました。');
};
reader.readAsDataURL(file);
}
};
// Gemini APIを使用して画像を解析する処理
const analyzeImage = async () => {
if (!imageSrc) return;
setIsAnalyzing(true);
setError(null);
const base64Data = imageSrc.split(',')[1];
const mimeType = imageSrc.split(';')[0].split(':')[1];
// API Key (実行環境で自動的に注入されます)
const apiKey = "";
const url = `https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash-preview-09-2025:generateContent?key=${apiKey}`;
const promptText = `
この画像は静岡メロンの陳列画像です。箱やラベルが複数並んでいます。
画像の左上から右下に向かって順番に、各メロンの箱やラベルに記載されている情報を読み取ってください。
以下のルールに厳密に従い、読み取ったメロンのデータを配列として出力してください。
【読み取る項目とルール】
1. producer_id (生産者番号): 「No.」や「番号」などの文字は除外し、数字のみを抽出してください。(例: 「No.123」→「123」)
2. weight (量目): 記載されている重量をそのまま抽出してください。(例: 「8kg」など)
3. grade (等級): 「富士」、「山」、「白」、「雪」、「A」などの等級を表す文字のみを抽出してください。「印」などの文字は除外してください。(例: 「山印」→「山」)
4. class_num (階級): 「4」、「5」、「6」などの数字のみを抽出してください。「入」などの文字は除外してください。(例: 「6入」→「6」)
画像から読み取れるすべてのメロンの情報を、位置が左上にあるものから順番に出力してください。
見えない部分や読み取れない項目がある場合は、空文字 ("") にしてください。
`;
const payload = {
contents: [
{
role: "user",
parts: [
{ text: promptText },
{ inlineData: { mimeType: mimeType, data: base64Data } }
]
}
],
generationConfig: {
responseMimeType: "application/json",
responseSchema: {
type: "ARRAY",
items: {
type: "OBJECT",
properties: {
producer_id: { type: "STRING" },
weight: { type: "STRING" },
grade: { type: "STRING" },
class_num: { type: "STRING" }
},
required: ["producer_id", "weight", "grade", "class_num"]
}
}
}
};
// 指数バックオフを用いたリトライ処理
const delay = (ms) => new Promise(res => setTimeout(res, ms));
const retries = 5;
try {
let data = null;
for (let i = 0; i < retries; i++) {
try {
const response = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload)
});
if (!response.ok) {
throw new Error(`API Error: ${response.status}`);
}
data = await response.json();
break; // 成功したらループを抜ける
} catch (err) {
if (i === retries - 1) throw err;
await delay(Math.pow(2, i) * 1000); // 1s, 2s, 4s, 8s, 16s
}
}
const textResult = data?.candidates?.[0]?.content?.parts?.[0]?.text;
if (!textResult) {
throw new Error("解析結果を取得できませんでした。");
}
const parsedResults = JSON.parse(textResult);
setResults(parsedResults);
} catch (err) {
console.error(err);
setError("画像の解析中にエラーが発生しました。別の画像で試すか、後でもう一度お試しください。");
} finally {
setIsAnalyzing(false);
}
};
// セルの値を更新する処理
const handleCellChange = (index, field, value) => {
const newResults = [...results];
newResults[index][field] = value;
setResults(newResults);
};
// 行を追加する処理
const handleAddRow = () => {
setResults([...results, { producer_id: "", weight: "", grade: "", class_num: "" }]);
};
// 行を削除する処理
const handleRemoveRow = (index) => {
const newResults = results.filter((_, i) => i !== index);
setResults(newResults);
};
// CSVダウンロード処理
const downloadCSV = () => {
if (results.length === 0) return;
// ヘッダー行を追加(必要に応じて削除可能ですが、データ構造をわかりやすくするため付与)
let csvContent = "通し番号(A),生産者番号(B),量目(C),等級(D),階級(E),定数(F)\n";
// データ行の生成
results.forEach((row, index) => {
const colA = index + 1; // 通し番号
const colB = row.producer_id || ""; // 生産者番号
const colC = row.weight || ""; // 量目
const colD = row.grade || ""; // 等級
const colE = row.class_num || ""; // 階級
const colF = "1"; // 定数1
// ダブルクォーテーションで囲んでエスケープ
const escape = (str) => `"${String(str).replace(/"/g, '""')}"`;
csvContent += `${colA},${escape(colB)},${escape(colC)},${escape(colD)},${escape(colE)},${colF}\n`;
});
// Excelで文字化けしないようにBOM (Byte Order Mark) を追加してUTF-8でエクスポート
const bom = new Uint8Array([0xEF, 0xBB, 0xBF]);
const blob = new Blob([bom, csvContent], { type: "text/csv;charset=utf-8;" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `yoshimoto_kun_${new Date().getTime()}.csv`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
};
return (
<div className="min-h-screen bg-emerald-50 text-slate-800 font-sans p-4 md:p-8">
<div className="max-w-6xl mx-auto space-y-6">
{/* ヘッダー */}
<header className="bg-white rounded-2xl shadow-sm p-6 border border-emerald-100 flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="bg-emerald-500 p-3 rounded-xl text-white shadow-emerald-200 shadow-lg">
<ImageIcon size={28} />
</div>
<div>
<h1 className="text-2xl font-bold text-emerald-900">義元くん</h1>
<p className="text-emerald-600 text-sm font-medium">静岡メロン 画像→データベース化アプリ</p>
</div>
</div>
</header>
{error && (
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-xl flex items-center gap-2 shadow-sm">
<AlertCircle size={20} className="shrink-0" />
<p className="text-sm font-medium">{error}</p>
</div>
)}
<div className="grid grid-cols-1 lg:grid-cols-12 gap-6">
{/* 左カラム:画像アップロードとプレビュー */}
<div className="lg:col-span-4 space-y-4">
<div className="bg-white rounded-2xl shadow-sm border border-emerald-100 p-5">
<h2 className="text-lg font-bold text-emerald-800 mb-4 flex items-center gap-2">
<Upload size={20} /> 画像のアップロード
</h2>
<div
onClick={() => fileInputRef.current?.click()}
className={`
border-2 border-dashed rounded-xl p-6 text-center cursor-pointer transition-all duration-200
flex flex-col items-center justify-center min-h-[250px]
${imageSrc ? 'border-emerald-200 bg-emerald-50/50 hover:bg-emerald-50' : 'border-emerald-300 bg-emerald-50 hover:bg-emerald-100 hover:border-emerald-400'}
`}
>
<input
type="file"
accept="image/*"
className="hidden"
ref={fileInputRef}
onChange={handleImageChange}
/>
{imageSrc ? (
<div className="relative w-full h-full flex items-center justify-center">
<img
src={imageSrc}
alt="Uploaded preview"
className="max-h-64 object-contain rounded shadow-sm"
/>
</div>
) : (
<div className="flex flex-col items-center text-emerald-600 space-y-3">
<div className="bg-white p-4 rounded-full shadow-sm">
<Upload size={32} />
</div>
<div>
<p className="font-semibold text-emerald-800">クリックして画像を選択</p>
<p className="text-xs mt-1 opacity-80">JPEG, PNG等のメロン陳列画像</p>
</div>
</div>
)}
</div>
<button
onClick={analyzeImage}
disabled={!imageSrc || isAnalyzing}
className={`
mt-4 w-full py-3 px-4 rounded-xl font-bold flex items-center justify-center gap-2 transition-all shadow-sm
${!imageSrc
? 'bg-slate-100 text-slate-400 cursor-not-allowed'
: isAnalyzing
? 'bg-emerald-500 text-white cursor-wait opacity-80'
: 'bg-emerald-600 hover:bg-emerald-700 text-white hover:shadow-md hover:-translate-y-0.5'}
`}
>
{isAnalyzing ? (
<>
<Loader2 size={20} className="animate-spin" /> 解析中...
</>
) : (
"画像を解析する"
)}
</button>
</div>
<div className="bg-emerald-100/50 rounded-2xl p-4 text-sm text-emerald-800 border border-emerald-200">
<h3 className="font-bold mb-2">出力CSVの仕様</h3>
<ul className="list-disc pl-5 space-y-1 opacity-90">
<li><span className="font-semibold">A列:</span> 通し番号 (1, 2, 3...)</li>
<li><span className="font-semibold">B列:</span> 生産者番号 (No.を除いた数字)</li>
<li><span className="font-semibold">C列:</span> 量目 (例: 8kg)</li>
<li><span className="font-semibold">D列:</span> 等級 (印を除いた文字: 富士, 山, 白...)</li>
<li><span className="font-semibold">E列:</span> 階級 (入を除いた数字: 4, 5, 6...)</li>
<li><span className="font-semibold">F列:</span> 定数 (常に 1)</li>
</ul>
</div>
</div>
{/* 右カラム:データテーブル */}
<div className="lg:col-span-8 bg-white rounded-2xl shadow-sm border border-emerald-100 overflow-hidden flex flex-col">
<div className="p-5 border-b border-emerald-50 flex items-center justify-between bg-emerald-50/30">
<h2 className="text-lg font-bold text-emerald-800">抽出データ</h2>
<button
onClick={downloadCSV}
disabled={results.length === 0}
className={`
py-2 px-4 rounded-lg font-bold flex items-center gap-2 transition-all text-sm
${results.length === 0
? 'bg-slate-100 text-slate-400 cursor-not-allowed'
: 'bg-white border border-emerald-200 text-emerald-700 hover:bg-emerald-50 hover:border-emerald-300 shadow-sm'}
`}
>
<FileDown size={18} /> CSV出力
</button>
</div>
<div className="flex-1 overflow-x-auto p-5">
{results.length === 0 ? (
<div className="h-full min-h-[300px] flex flex-col items-center justify-center text-slate-400 space-y-4">
<ImageIcon size={48} className="opacity-20" />
<p>画像をアップロードして「解析する」をクリックしてください。</p>
</div>
) : (
<div className="min-w-[700px]">
<table className="w-full text-left border-collapse">
<thead>
<tr className="bg-emerald-50 text-emerald-800 text-sm font-semibold border-b-2 border-emerald-200">
<th className="py-3 px-4 w-16 text-center">A列<br/><span className="text-xs font-normal opacity-80">通し番号</span></th>
<th className="py-3 px-4">B列<br/><span className="text-xs font-normal opacity-80">生産者番号</span></th>
<th className="py-3 px-4">C列<br/><span className="text-xs font-normal opacity-80">量目</span></th>
<th className="py-3 px-4">D列<br/><span className="text-xs font-normal opacity-80">等級</span></th>
<th className="py-3 px-4">E列<br/><span className="text-xs font-normal opacity-80">階級</span></th>
<th className="py-3 px-4 w-16 text-center">F列<br/><span className="text-xs font-normal opacity-80">定数</span></th>
<th className="py-3 px-4 w-12 text-center">操作</th>
</tr>
</thead>
<tbody className="divide-y divide-emerald-50 text-slate-700">
{results.map((row, index) => (
<tr key={index} className="hover:bg-emerald-50/50 transition-colors group">
<td className="py-2 px-4 text-center font-medium text-emerald-600 bg-emerald-50/30">
{index + 1}
</td>
<td className="py-2 px-2">
<input
type="text"
value={row.producer_id}
onChange={(e) => handleCellChange(index, 'producer_id', e.target.value)}
placeholder="番号"
className="w-full bg-transparent border border-transparent hover:border-emerald-200 focus:border-emerald-400 focus:ring-1 focus:ring-emerald-400 rounded px-3 py-2 outline-none transition-all"
/>
</td>
<td className="py-2 px-2">
<input
type="text"
value={row.weight}
onChange={(e) => handleCellChange(index, 'weight', e.target.value)}
placeholder="量目"
className="w-full bg-transparent border border-transparent hover:border-emerald-200 focus:border-emerald-400 focus:ring-1 focus:ring-emerald-400 rounded px-3 py-2 outline-none transition-all"
/>
</td>
<td className="py-2 px-2">
<input
type="text"
value={row.grade}
onChange={(e) => handleCellChange(index, 'grade', e.target.value)}
placeholder="等級"
className="w-full bg-transparent border border-transparent hover:border-emerald-200 focus:border-emerald-400 focus:ring-1 focus:ring-emerald-400 rounded px-3 py-2 outline-none transition-all"
/>
</td>
<td className="py-2 px-2">
<input
type="text"
value={row.class_num}
onChange={(e) => handleCellChange(index, 'class_num', e.target.value)}
placeholder="階級"
className="w-full bg-transparent border border-transparent hover:border-emerald-200 focus:border-emerald-400 focus:ring-1 focus:ring-emerald-400 rounded px-3 py-2 outline-none transition-all"
/>
</td>
<td className="py-2 px-4 text-center text-slate-400 bg-emerald-50/30">
1
</td>
<td className="py-2 px-4 text-center">
<button
onClick={() => handleRemoveRow(index)}
className="text-slate-300 hover:text-red-500 transition-colors p-1 rounded hover:bg-red-50"
title="行を削除"
>
<Trash2 size={16} />
</button>
</td>
</tr>
))}
</tbody>
</table>
<div className="mt-4 pt-2">
<button
onClick={handleAddRow}
className="flex items-center gap-1 text-sm font-medium text-emerald-600 hover:text-emerald-800 hover:bg-emerald-50 px-3 py-2 rounded-lg transition-colors"
>
<Plus size={16} /> 新規行を追加
</button>
</div>
</div>
)}
</div>
</div>
</div>
</div>
</div>
);
}
```