feat: 公开首页(搜索+商品详情) + 好心人投稿 + 后台审核收纳
CI / Go (api) (pull_request) Failing after 20s
CI / Python (ingestion) (pull_request) Successful in 8s
CI / Migrations (postgres) (pull_request) Failing after 17s

- 公开前端 SPA(根路径 /):首页大搜索框、检索结果、只读商品详情、贡献档案表单
- 公开写入端点 POST /api/public/submissions(无需登录,基础频率限流),投稿进入 submission 待审核队列,不直接写 product
- 迁移 0006:submission 投稿表 + community 来源(trust=0.50)
- 后台审核队列:列表(待审核/已通过/已驳回) → 查看投稿 → 通过(创建/补全商品 + 记 source=community + 字段级溯源 + 审计 + 重算质量分) / 驳回(记原因)
- 公开只读 api 服务内嵌公开 SPA;Dockerfile.prod 增加 node 构建阶段 + 内嵌 dist

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
oyaegeli98668
2026-06-20 05:11:07 +00:00
parent bad771e172
commit c9a4404052
36 changed files with 4615 additions and 28 deletions
+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>OpenGoods · 商品档案公共库</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+2690
View File
File diff suppressed because it is too large Load Diff
+26
View File
@@ -0,0 +1,26 @@
{
"name": "opengoods-public-frontend",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview"
},
"dependencies": {
"react": "^18.2.0",
"react-dom": "^18.2.0",
"lucide-react": "^0.344.0"
},
"devDependencies": {
"@types/react": "^18.2.55",
"@types/react-dom": "^18.2.19",
"@vitejs/plugin-react": "^4.2.1",
"autoprefixer": "^10.4.17",
"postcss": "^8.4.35",
"tailwindcss": "^3.4.1",
"typescript": "^5.3.3",
"vite": "^5.1.0"
}
}
+6
View File
@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
+71
View File
@@ -0,0 +1,71 @@
import { useState } from "react";
import { Boxes, Search, PlusCircle } from "lucide-react";
import Home from "./components/Home";
import ProductView from "./components/ProductView";
import Contribute from "./components/Contribute";
type View =
| { name: "home" }
| { name: "product"; id: string }
| { name: "contribute" };
export default function App() {
const [view, setView] = useState<View>({ name: "home" });
return (
<div className="min-h-full flex flex-col">
<header className="bg-white border-b">
<div className="max-w-5xl mx-auto px-4 h-14 flex items-center justify-between">
<button
className="flex items-center gap-2 font-semibold text-gray-800"
onClick={() => setView({ name: "home" })}
>
<Boxes className="w-6 h-6 text-emerald-600" />
OpenGoods
<span className="text-gray-400 font-normal text-sm"></span>
</button>
<nav className="flex items-center gap-1 text-sm">
<button
className={`px-3 py-1.5 rounded-md flex items-center gap-1.5 ${
view.name === "home" ? "bg-emerald-50 text-emerald-700" : "text-gray-600 hover:bg-gray-100"
}`}
onClick={() => setView({ name: "home" })}
>
<Search className="w-4 h-4" />
</button>
<button
className={`px-3 py-1.5 rounded-md flex items-center gap-1.5 ${
view.name === "contribute" ? "bg-emerald-50 text-emerald-700" : "text-gray-600 hover:bg-gray-100"
}`}
onClick={() => setView({ name: "contribute" })}
>
<PlusCircle className="w-4 h-4" />
</button>
</nav>
</div>
</header>
<main className="flex-1 max-w-5xl w-full mx-auto px-4 py-6">
{view.name === "home" && (
<Home
onOpen={(id) => setView({ name: "product", id })}
onContribute={() => setView({ name: "contribute" })}
/>
)}
{view.name === "product" && (
<ProductView id={view.id} onBack={() => setView({ name: "home" })} />
)}
{view.name === "contribute" && (
<Contribute onDone={() => setView({ name: "home" })} />
)}
</main>
<footer className="border-t bg-white">
<div className="max-w-5xl mx-auto px-4 py-4 text-xs text-gray-400 leading-relaxed">
OpenGoods /
稿
</div>
</footer>
</div>
);
}
+40
View File
@@ -0,0 +1,40 @@
import type { Category, Product, ProductSummary, SubmissionInput } from "./types";
async function req<T>(path: string, init?: RequestInit): Promise<T> {
const res = await fetch(path, {
...init,
headers: { "Content-Type": "application/json", ...(init?.headers || {}) },
});
if (!res.ok) {
let msg = `请求失败 (${res.status})`;
try {
const body = await res.json();
if (body?.error?.message) msg = body.error.message;
} catch {
/* ignore */
}
throw new Error(msg);
}
return res.json() as Promise<T>;
}
export interface SearchResult {
items: ProductSummary[];
page: number;
size: number;
total: number;
}
export const api = {
search: (q: string, page = 1, size = 20) =>
req<SearchResult>(
`/api/v1/products/search?q=${encodeURIComponent(q)}&page=${page}&size=${size}`,
),
product: (id: string) => req<Product>(`/api/v1/products/${id}`),
categories: () => req<{ items: Category[] }>(`/api/v1/categories`),
submit: (input: SubmissionInput) =>
req<{ id: string; status: string }>(`/api/public/submissions`, {
method: "POST",
body: JSON.stringify(input),
}),
};
@@ -0,0 +1,276 @@
import { useEffect, useState } from "react";
import { CheckCircle2, PlusCircle, Trash2 } from "lucide-react";
import { api } from "../api";
import type { Category, SubmissionImage, SubmissionInput } from "../types";
const NUTRI_FIELDS: { key: string; label: string }[] = [
{ key: "energy_kcal", label: "能量 (kcal)" },
{ key: "energy_kj", label: "能量 (kJ)" },
{ key: "fat", label: "脂肪 (g)" },
{ key: "saturated_fat", label: "饱和脂肪 (g)" },
{ key: "carbohydrates", label: "碳水 (g)" },
{ key: "sugars", label: "糖 (g)" },
{ key: "proteins", label: "蛋白质 (g)" },
{ key: "salt", label: "盐 (g)" },
];
function field(v: string): string | null {
const t = v.trim();
return t === "" ? null : t;
}
export default function Contribute({ onDone }: { onDone: () => void }) {
const [categories, setCategories] = useState<Category[]>([]);
const [done, setDone] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState("");
const [name, setName] = useState("");
const [gtin, setGtin] = useState("");
const [brand, setBrand] = useState("");
const [categoryID, setCategoryID] = useState("");
const [netValue, setNetValue] = useState("");
const [netUnit, setNetUnit] = useState("");
const [country, setCountry] = useState("");
const [ingredients, setIngredients] = useState("");
const [basis, setBasis] = useState("");
const [nutri, setNutri] = useState<Record<string, string>>({});
const [images, setImages] = useState<SubmissionImage[]>([]);
const [imageURL, setImageURL] = useState("");
const [submitter, setSubmitter] = useState("");
const [contact, setContact] = useState("");
const [note, setNote] = useState("");
useEffect(() => {
api.categories().then((r) => setCategories(r.items)).catch(() => undefined);
}, []);
async function submit(e: React.FormEvent) {
e.preventDefault();
if (name.trim() === "") {
setError("请填写商品名称");
return;
}
setSubmitting(true);
setError("");
const nutriments: Record<string, number> = {};
for (const [k, v] of Object.entries(nutri)) {
const n = parseFloat(v);
if (!Number.isNaN(n)) nutriments[k] = n;
}
const input: SubmissionInput = {
name: name.trim(),
gtin: field(gtin),
brand_name: field(brand),
category_id: categoryID || null,
net_content_value: field(netValue) ? parseFloat(netValue) : null,
net_content_unit: field(netUnit),
country_of_origin: field(country),
ingredients_text: field(ingredients),
nutriments: Object.keys(nutriments).length ? nutriments : null,
nutrition_basis: basis || null,
images: images.length ? images : undefined,
submitter_name: field(submitter),
submitter_contact: field(contact),
note: field(note),
};
try {
await api.submit(input);
setDone(true);
} catch (err) {
setError(err instanceof Error ? err.message : "提交失败");
} finally {
setSubmitting(false);
}
}
if (done) {
return (
<div className="max-w-xl mx-auto text-center py-16">
<CheckCircle2 className="w-14 h-14 text-emerald-500 mx-auto" />
<h1 className="mt-4 text-xl font-semibold text-gray-800"></h1>
<p className="mt-2 text-gray-500">
</p>
<button
onClick={onDone}
className="mt-6 px-5 py-2 rounded-lg bg-emerald-600 text-white hover:bg-emerald-700"
>
</button>
</div>
);
}
const input =
"w-full border rounded-md px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-emerald-400";
const label = "block text-xs text-gray-500 mb-1";
return (
<form onSubmit={submit} className="max-w-3xl mx-auto">
<h1 className="text-xl font-semibold text-gray-800"></h1>
<p className="mt-1 text-sm text-gray-500">
<b></b> *
</p>
{error && (
<div className="mt-4 bg-red-50 text-red-700 text-sm rounded-md px-4 py-2">{error}</div>
)}
<div className="bg-white border rounded-lg p-5 mt-4">
<h2 className="font-medium text-gray-700 mb-3"></h2>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="sm:col-span-2">
<label className={label}> *</label>
<input className={input} value={name} onChange={(e) => setName(e.target.value)} />
</div>
<div>
<label className={label}> (GTIN)</label>
<input className={input} value={gtin} onChange={(e) => setGtin(e.target.value)} />
</div>
<div>
<label className={label}></label>
<input className={input} value={brand} onChange={(e) => setBrand(e.target.value)} />
</div>
<div>
<label className={label}></label>
<select className={input} value={categoryID} onChange={(e) => setCategoryID(e.target.value)}>
<option value=""></option>
{categories.map((c) => (
<option key={c.id} value={c.id}>
{c.name_zh} ({c.path})
</option>
))}
</select>
</div>
<div>
<label className={label}></label>
<input className={input} value={country} onChange={(e) => setCountry(e.target.value)} />
</div>
<div>
<label className={label}></label>
<input
type="number"
step="any"
className={input}
value={netValue}
onChange={(e) => setNetValue(e.target.value)}
/>
</div>
<div>
<label className={label}> (g/ml/cl)</label>
<input className={input} value={netUnit} onChange={(e) => setNetUnit(e.target.value)} />
</div>
</div>
</div>
<div className="bg-white border rounded-lg p-5 mt-4">
<h2 className="font-medium text-gray-700 mb-3"></h2>
<label className={label}></label>
<textarea
className={input}
rows={3}
value={ingredients}
onChange={(e) => setIngredients(e.target.value)}
/>
<div className="mt-3">
<label className={label}></label>
<select className={input} value={basis} onChange={(e) => setBasis(e.target.value)}>
<option value=""></option>
<option value="per_100g"> 100g</option>
<option value="per_100ml"> 100ml</option>
<option value="per_serving"></option>
</select>
</div>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3 mt-3">
{NUTRI_FIELDS.map((f) => (
<div key={f.key}>
<label className={label}>{f.label}</label>
<input
type="number"
step="any"
className={input}
value={nutri[f.key] || ""}
onChange={(e) => setNutri({ ...nutri, [f.key]: e.target.value })}
/>
</div>
))}
</div>
</div>
<div className="bg-white border rounded-lg p-5 mt-4">
<h2 className="font-medium text-gray-700 mb-3"> URL</h2>
{images.length > 0 && (
<ul className="mb-3 space-y-1">
{images.map((im, i) => (
<li key={i} className="flex items-center gap-2 text-sm">
<span className="truncate text-gray-600 flex-1">{im.url}</span>
<button
type="button"
onClick={() => setImages(images.filter((_, j) => j !== i))}
className="text-gray-400 hover:text-red-500"
>
<Trash2 className="w-4 h-4" />
</button>
</li>
))}
</ul>
)}
<div className="flex gap-2">
<input
className={input}
placeholder="图片 URL"
value={imageURL}
onChange={(e) => setImageURL(e.target.value)}
/>
<button
type="button"
onClick={() => {
if (imageURL.trim()) {
setImages([...images, { url: imageURL.trim(), kind: "front" }]);
setImageURL("");
}
}}
className="shrink-0 px-3 rounded-md border text-sm flex items-center gap-1 hover:bg-gray-50"
>
<PlusCircle className="w-4 h-4" />
</button>
</div>
</div>
<div className="bg-white border rounded-lg p-5 mt-4">
<h2 className="font-medium text-gray-700 mb-3"></h2>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label className={label}></label>
<input className={input} value={submitter} onChange={(e) => setSubmitter(e.target.value)} />
</div>
<div>
<label className={label}>/</label>
<input className={input} value={contact} onChange={(e) => setContact(e.target.value)} />
</div>
<div className="sm:col-span-2">
<label className={label}> / </label>
<input className={input} value={note} onChange={(e) => setNote(e.target.value)} />
</div>
</div>
</div>
<div className="mt-5 flex items-center gap-3">
<button
type="submit"
disabled={submitting}
className="px-6 py-2.5 rounded-lg bg-emerald-600 text-white font-medium hover:bg-emerald-700 disabled:opacity-60"
>
{submitting ? "提交中…" : "提交审核"}
</button>
<button type="button" onClick={onDone} className="text-sm text-gray-500 hover:underline">
</button>
</div>
</form>
);
}
+110
View File
@@ -0,0 +1,110 @@
import { useState } from "react";
import { Search, PlusCircle } from "lucide-react";
import { api } from "../api";
import type { ProductSummary } from "../types";
export default function Home({
onOpen,
onContribute,
}: {
onOpen: (id: string) => void;
onContribute: () => void;
}) {
const [q, setQ] = useState("");
const [items, setItems] = useState<ProductSummary[]>([]);
const [total, setTotal] = useState(0);
const [searched, setSearched] = useState(false);
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
async function run(e?: React.FormEvent) {
e?.preventDefault();
setLoading(true);
setError("");
try {
const res = await api.search(q.trim(), 1, 30);
setItems(res.items);
setTotal(res.total);
setSearched(true);
} catch (err) {
setError(err instanceof Error ? err.message : "搜索失败");
} finally {
setLoading(false);
}
}
return (
<div>
<div className="text-center py-10">
<h1 className="text-3xl font-bold text-gray-800"></h1>
<p className="mt-2 text-gray-500">
</p>
<form onSubmit={run} className="mt-6 max-w-2xl mx-auto flex gap-2">
<div className="flex-1 flex items-center gap-2 bg-white border rounded-lg px-3 shadow-sm focus-within:ring-2 focus-within:ring-emerald-400">
<Search className="w-5 h-5 text-gray-400" />
<input
autoFocus
value={q}
onChange={(e) => setQ(e.target.value)}
placeholder="例如:可乐、Nutella、5449000000996"
className="flex-1 py-3 outline-none bg-transparent"
/>
</div>
<button
type="submit"
disabled={loading}
className="px-6 rounded-lg bg-emerald-600 text-white font-medium hover:bg-emerald-700 disabled:opacity-60"
>
{loading ? "检索中…" : "检索"}
</button>
</form>
</div>
{error && (
<div className="max-w-2xl mx-auto bg-red-50 text-red-700 text-sm rounded-md px-4 py-2">
{error}
</div>
)}
{searched && (
<div className="mt-2">
<div className="text-sm text-gray-500 mb-2">
{total} {q ? `(关键词:${q}` : ""}
</div>
{items.length === 0 ? (
<div className="bg-white border rounded-lg p-8 text-center text-gray-500">
<p></p>
<button
onClick={onContribute}
className="mt-3 inline-flex items-center gap-1.5 text-emerald-700 hover:underline"
>
<PlusCircle className="w-4 h-4" />
</button>
</div>
) : (
<ul className="bg-white border rounded-lg divide-y">
{items.map((p) => (
<li key={p.id}>
<button
onClick={() => onOpen(p.id)}
className="w-full text-left px-4 py-3 hover:bg-gray-50 flex items-center justify-between gap-4"
>
<div>
<div className="font-medium text-gray-800">{p.name}</div>
<div className="text-xs text-gray-500 mt-0.5">
{p.brand || "未知品牌"}
{p.gtin ? ` · ${p.gtin}` : ""}
</div>
</div>
<span className="text-xs text-gray-400">{p.category_path || ""}</span>
</button>
</li>
))}
</ul>
)}
</div>
)}
</div>
);
}
@@ -0,0 +1,104 @@
import { useEffect, useState } from "react";
import { ArrowLeft } from "lucide-react";
import { api } from "../api";
import type { Product } from "../types";
import { NUTRIMENT_LABELS } from "../types";
function Row({ label, value }: { label: string; value: React.ReactNode }) {
if (value === null || value === undefined || value === "") return null;
return (
<div className="flex py-2 border-b last:border-0 text-sm">
<div className="w-32 shrink-0 text-gray-400">{label}</div>
<div className="text-gray-800">{value}</div>
</div>
);
}
export default function ProductView({ id, onBack }: { id: string; onBack: () => void }) {
const [p, setP] = useState<Product | null>(null);
const [error, setError] = useState("");
useEffect(() => {
api.product(id).then(setP).catch((e) => setError(e.message));
}, [id]);
if (error) {
return (
<div>
<button onClick={onBack} className="text-sm text-gray-500 flex items-center gap-1 mb-4">
<ArrowLeft className="w-4 h-4" />
</button>
<div className="bg-red-50 text-red-700 text-sm rounded-md px-4 py-3">{error}</div>
</div>
);
}
if (!p) return <div className="text-gray-400"></div>;
const basisLabel: Record<string, string> = {
per_100g: "每 100g",
per_100ml: "每 100ml",
per_serving: "每份",
};
const nutriEntries = Object.entries(p.nutriments || {}).filter(
([, v]) => v !== null && v !== undefined,
);
return (
<div>
<button onClick={onBack} className="text-sm text-gray-500 flex items-center gap-1 mb-4">
<ArrowLeft className="w-4 h-4" />
</button>
<div className="bg-white border rounded-lg p-5">
<div className="flex items-start justify-between gap-4">
<h1 className="text-xl font-semibold text-gray-800">{p.name}</h1>
<span className="shrink-0 text-xs bg-emerald-50 text-emerald-700 rounded px-2 py-1">
{Math.round(p.quality_score * 100)}
</span>
</div>
<div className="mt-4">
<Row label="品牌" value={p.brand} />
<Row label="条码 (GTIN)" value={p.gtin} />
<Row label="品类" value={p.category_path} />
<Row
label="净含量"
value={
p.net_content_value != null
? `${p.net_content_value} ${p.net_content_unit || ""}`
: null
}
/>
<Row label="产地" value={p.country_of_origin} />
<Row label="Nutri-Score" value={p.nutri_score} />
<Row label="配料" value={p.ingredients_text} />
<Row
label="过敏原"
value={p.allergens && p.allergens.length ? p.allergens.join("、") : null}
/>
<Row
label="添加剂"
value={p.additives && p.additives.length ? p.additives.join("、") : null}
/>
</div>
</div>
{nutriEntries.length > 0 && (
<div className="bg-white border rounded-lg p-5 mt-4">
<h2 className="font-medium text-gray-700 mb-2">
{p.nutrition_basis ? `${basisLabel[p.nutrition_basis] || p.nutrition_basis}` : ""}
</h2>
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2 text-sm">
{nutriEntries.map(([k, v]) => (
<div key={k} className="bg-gray-50 rounded px-3 py-2">
<div className="text-gray-400 text-xs">{NUTRIMENT_LABELS[k] || k}</div>
<div className="text-gray-800">{String(v)}</div>
</div>
))}
</div>
</div>
)}
</div>
);
}
+16
View File
@@ -0,0 +1,16 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
html,
body,
#root {
height: 100%;
}
body {
margin: 0;
background: #f3f4f6;
font-family: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue",
Arial, "PingFang SC", "Microsoft YaHei", sans-serif;
}
+10
View File
@@ -0,0 +1,10 @@
import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App";
import "./index.css";
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<App />
</React.StrictMode>,
);
+70
View File
@@ -0,0 +1,70 @@
export interface ProductSummary {
id: string;
gtin: string | null;
name: string;
brand: string | null;
category_path: string | null;
}
export interface Product {
id: string;
gtin: string | null;
name: string;
brand: string | null;
category_path: string | null;
net_content_value: number | null;
net_content_unit: string | null;
country_of_origin: string | null;
quality_score: number;
nutriments?: Record<string, unknown> | null;
nutrition_basis?: string | null;
nutri_score?: string | null;
ingredients_text?: string | null;
allergens?: string[] | null;
additives?: string[] | null;
}
export interface Category {
id: string;
name_zh: string;
name_en: string | null;
path: string;
level: number;
}
export interface SubmissionImage {
url: string;
kind: string;
}
export interface SubmissionInput {
gtin?: string | null;
name: string;
brand_name?: string | null;
category_id?: string | null;
net_content_value?: number | null;
net_content_unit?: string | null;
country_of_origin?: string | null;
ingredients_text?: string | null;
nutriments?: Record<string, number> | null;
nutrition_basis?: string | null;
serving_size?: string | null;
nutri_score?: string | null;
images?: SubmissionImage[];
submitter_name?: string | null;
submitter_contact?: string | null;
note?: string | null;
}
export const NUTRIMENT_LABELS: Record<string, string> = {
energy_kcal: "能量 (kcal)",
energy_kj: "能量 (kJ)",
fat: "脂肪 (g)",
saturated_fat: "饱和脂肪 (g)",
carbohydrates: "碳水 (g)",
sugars: "糖 (g)",
proteins: "蛋白质 (g)",
salt: "盐 (g)",
sodium: "钠 (g)",
fiber: "膳食纤维 (g)",
};
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />
+6
View File
@@ -0,0 +1,6 @@
/** @type {import('tailwindcss').Config} */
export default {
content: ["./index.html", "./src/**/*.{ts,tsx}"],
theme: { extend: {} },
plugins: [],
};
+21
View File
@@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}
+11
View File
@@ -0,0 +1,11 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true,
"strict": true
},
"include": ["vite.config.ts"]
}
+9
View File
@@ -0,0 +1,9 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
// Served at the site root by the public read-only Go binary.
export default defineConfig({
base: "/",
plugins: [react()],
build: { outDir: "dist", emptyOutDir: true },
});