feat: 公开首页(搜索+商品详情) + 好心人投稿 + 后台审核收纳
- 公开前端 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:
@@ -1,6 +1,7 @@
|
|||||||
.git
|
.git
|
||||||
**/node_modules
|
**/node_modules
|
||||||
admin-frontend/dist
|
admin-frontend/dist
|
||||||
|
public-frontend/dist
|
||||||
api/server
|
api/server
|
||||||
*.test
|
*.test
|
||||||
*.out
|
*.out
|
||||||
|
|||||||
+3
-1
@@ -21,9 +21,11 @@ dist/
|
|||||||
# Node / admin frontend
|
# Node / admin frontend
|
||||||
node_modules/
|
node_modules/
|
||||||
|
|
||||||
# Keep the embedded SPA placeholder (real build is injected during Docker build)
|
# Keep the embedded SPA placeholders (real builds are injected during Docker build)
|
||||||
!api/internal/adminweb/dist/
|
!api/internal/adminweb/dist/
|
||||||
!api/internal/adminweb/dist/index.html
|
!api/internal/adminweb/dist/index.html
|
||||||
|
!api/internal/publicweb/dist/
|
||||||
|
!api/internal/publicweb/dist/index.html
|
||||||
|
|
||||||
# OS / editors
|
# OS / editors
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|||||||
@@ -3,16 +3,28 @@ import { api, clearToken, getToken } from "./api";
|
|||||||
import Login from "./components/Login";
|
import Login from "./components/Login";
|
||||||
import ProductList from "./components/ProductList";
|
import ProductList from "./components/ProductList";
|
||||||
import ProductDetail from "./components/ProductDetail";
|
import ProductDetail from "./components/ProductDetail";
|
||||||
import { LogOut, Package } from "lucide-react";
|
import SubmissionsPage from "./components/SubmissionsPage";
|
||||||
|
import { Inbox, LogOut, Package } from "lucide-react";
|
||||||
|
|
||||||
|
type Tab = "products" | "submissions";
|
||||||
type View = { name: "list" } | { name: "detail"; id: string };
|
type View = { name: "list" } | { name: "detail"; id: string };
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const [authed, setAuthed] = useState(false);
|
const [authed, setAuthed] = useState(false);
|
||||||
const [checking, setChecking] = useState(true);
|
const [checking, setChecking] = useState(true);
|
||||||
const [username, setUsername] = useState("");
|
const [username, setUsername] = useState("");
|
||||||
|
const [tab, setTab] = useState<Tab>("products");
|
||||||
|
const [pending, setPending] = useState<number | null>(null);
|
||||||
const [view, setView] = useState<View>({ name: "list" });
|
const [view, setView] = useState<View>({ name: "list" });
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!authed) return;
|
||||||
|
api
|
||||||
|
.listSubmissions("pending", 1, 1)
|
||||||
|
.then((r) => setPending(r.pending))
|
||||||
|
.catch(() => undefined);
|
||||||
|
}, [authed]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!getToken()) {
|
if (!getToken()) {
|
||||||
setChecking(false);
|
setChecking(false);
|
||||||
@@ -53,10 +65,42 @@ export default function App() {
|
|||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col">
|
<div className="flex h-full flex-col">
|
||||||
<header className="flex items-center justify-between bg-white px-6 py-3 shadow-sm">
|
<header className="flex items-center justify-between bg-white px-6 py-3 shadow-sm">
|
||||||
|
<div className="flex items-center gap-6">
|
||||||
<div className="flex items-center gap-2 text-lg font-semibold text-gray-800">
|
<div className="flex items-center gap-2 text-lg font-semibold text-gray-800">
|
||||||
<Package className="h-5 w-5 text-emerald-600" />
|
<Package className="h-5 w-5 text-emerald-600" />
|
||||||
OpenGoods 商品档案后台
|
OpenGoods 商品档案后台
|
||||||
</div>
|
</div>
|
||||||
|
<nav className="flex items-center gap-1 text-sm">
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setTab("products");
|
||||||
|
setView({ name: "list" });
|
||||||
|
}}
|
||||||
|
className={`px-3 py-1.5 rounded-md flex items-center gap-1.5 ${
|
||||||
|
tab === "products"
|
||||||
|
? "bg-emerald-50 text-emerald-700"
|
||||||
|
: "text-gray-600 hover:bg-gray-100"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Package className="h-4 w-4" /> 商品档案
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setTab("submissions")}
|
||||||
|
className={`px-3 py-1.5 rounded-md flex items-center gap-1.5 ${
|
||||||
|
tab === "submissions"
|
||||||
|
? "bg-emerald-50 text-emerald-700"
|
||||||
|
: "text-gray-600 hover:bg-gray-100"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Inbox className="h-4 w-4" /> 待审核投稿
|
||||||
|
{pending != null && pending > 0 && (
|
||||||
|
<span className="ml-1 text-xs bg-amber-500 text-white rounded-full px-1.5">
|
||||||
|
{pending}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
<div className="flex items-center gap-4 text-sm text-gray-600">
|
<div className="flex items-center gap-4 text-sm text-gray-600">
|
||||||
<span>{username}</span>
|
<span>{username}</span>
|
||||||
<button
|
<button
|
||||||
@@ -68,13 +112,12 @@ export default function App() {
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<main className="flex-1 overflow-auto p-6">
|
<main className="flex-1 overflow-auto p-6">
|
||||||
{view.name === "list" ? (
|
{tab === "submissions" ? (
|
||||||
|
<SubmissionsPage onPending={setPending} />
|
||||||
|
) : view.name === "list" ? (
|
||||||
<ProductList onOpen={(id) => setView({ name: "detail", id })} />
|
<ProductList onOpen={(id) => setView({ name: "detail", id })} />
|
||||||
) : (
|
) : (
|
||||||
<ProductDetail
|
<ProductDetail id={view.id} onBack={() => setView({ name: "list" })} />
|
||||||
id={view.id}
|
|
||||||
onBack={() => setView({ name: "list" })}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -90,4 +90,23 @@ export const api = {
|
|||||||
request<{ items: import("./types").Brand[] }>("/brands"),
|
request<{ items: import("./types").Brand[] }>("/brands"),
|
||||||
listCategories: () =>
|
listCategories: () =>
|
||||||
request<{ items: import("./types").Category[] }>("/categories"),
|
request<{ items: import("./types").Category[] }>("/categories"),
|
||||||
|
listSubmissions: (status: string, page: number, size: number) =>
|
||||||
|
request<{
|
||||||
|
items: import("./types").SubmissionRow[];
|
||||||
|
page: number;
|
||||||
|
size: number;
|
||||||
|
total: number;
|
||||||
|
pending: number;
|
||||||
|
}>(`/submissions?status=${encodeURIComponent(status)}&page=${page}&size=${size}`),
|
||||||
|
getSubmission: (id: string) =>
|
||||||
|
request<import("./types").SubmissionDetail>(`/submissions/${id}`),
|
||||||
|
approveSubmission: (id: string) =>
|
||||||
|
request<import("./types").ProductDetail>(`/submissions/${id}/approve`, {
|
||||||
|
method: "POST",
|
||||||
|
}),
|
||||||
|
rejectSubmission: (id: string, note: string) =>
|
||||||
|
request<{ status: string }>(`/submissions/${id}/reject`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ note }),
|
||||||
|
}),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,314 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { api, ApiError } from "../api";
|
||||||
|
import type { SubmissionDetail, SubmissionRow } from "../types";
|
||||||
|
import { FIELD_LABELS } from "../types";
|
||||||
|
import { ArrowLeft, Check, X } from "lucide-react";
|
||||||
|
|
||||||
|
const STATUS_TABS = [
|
||||||
|
{ key: "pending", label: "待审核" },
|
||||||
|
{ key: "approved", label: "已通过" },
|
||||||
|
{ key: "rejected", label: "已驳回" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const STATUS_BADGE: Record<string, string> = {
|
||||||
|
pending: "bg-amber-50 text-amber-700",
|
||||||
|
approved: "bg-emerald-50 text-emerald-700",
|
||||||
|
rejected: "bg-red-50 text-red-700",
|
||||||
|
};
|
||||||
|
const STATUS_TEXT: Record<string, string> = {
|
||||||
|
pending: "待审核",
|
||||||
|
approved: "已通过",
|
||||||
|
rejected: "已驳回",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function SubmissionsPage({ onPending }: { onPending?: (n: number) => void }) {
|
||||||
|
const [tab, setTab] = useState("pending");
|
||||||
|
const [rows, setRows] = useState<SubmissionRow[]>([]);
|
||||||
|
const [openId, setOpenId] = useState<string | null>(null);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
const res = await api.listSubmissions(tab, 1, 50);
|
||||||
|
setRows(res.items);
|
||||||
|
onPending?.(res.pending);
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof ApiError ? e.message : "加载失败");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
load();
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [tab]);
|
||||||
|
|
||||||
|
if (openId) {
|
||||||
|
return (
|
||||||
|
<SubmissionView
|
||||||
|
id={openId}
|
||||||
|
onBack={() => {
|
||||||
|
setOpenId(null);
|
||||||
|
load();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2 mb-4">
|
||||||
|
{STATUS_TABS.map((t) => (
|
||||||
|
<button
|
||||||
|
key={t.key}
|
||||||
|
onClick={() => setTab(t.key)}
|
||||||
|
className={`px-3 py-1.5 rounded-md text-sm ${
|
||||||
|
tab === t.key
|
||||||
|
? "bg-emerald-600 text-white"
|
||||||
|
: "bg-white border text-gray-600 hover:bg-gray-50"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{t.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="mb-3 bg-red-50 text-red-700 text-sm rounded px-4 py-2">{error}</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="bg-white border rounded-lg overflow-hidden">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead className="bg-gray-50 text-gray-500 text-left">
|
||||||
|
<tr>
|
||||||
|
<th className="px-4 py-2 font-medium">商品名称</th>
|
||||||
|
<th className="px-4 py-2 font-medium">条码</th>
|
||||||
|
<th className="px-4 py-2 font-medium">投稿人</th>
|
||||||
|
<th className="px-4 py-2 font-medium">类型</th>
|
||||||
|
<th className="px-4 py-2 font-medium">提交时间</th>
|
||||||
|
<th className="px-4 py-2 font-medium">状态</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y">
|
||||||
|
{rows.length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={6} className="px-4 py-8 text-center text-gray-400">
|
||||||
|
暂无投稿
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : (
|
||||||
|
rows.map((r) => (
|
||||||
|
<tr
|
||||||
|
key={r.id}
|
||||||
|
onClick={() => setOpenId(r.id)}
|
||||||
|
className="cursor-pointer hover:bg-gray-50"
|
||||||
|
>
|
||||||
|
<td className="px-4 py-2 text-gray-800">{r.name}</td>
|
||||||
|
<td className="px-4 py-2 text-gray-500">{r.gtin || "—"}</td>
|
||||||
|
<td className="px-4 py-2 text-gray-500">{r.submitter_name || "匿名"}</td>
|
||||||
|
<td className="px-4 py-2">
|
||||||
|
<span className="text-xs text-gray-500">
|
||||||
|
{r.matched ? "补全已有商品" : "新建商品"}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2 text-gray-500">
|
||||||
|
{new Date(r.created_at).toLocaleString()}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2">
|
||||||
|
<span className={`text-xs rounded px-2 py-0.5 ${STATUS_BADGE[r.status]}`}>
|
||||||
|
{STATUS_TEXT[r.status]}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Field({ label, value }: { label: string; value: React.ReactNode }) {
|
||||||
|
if (value === null || value === undefined || value === "") return null;
|
||||||
|
return (
|
||||||
|
<div className="flex py-1.5 text-sm">
|
||||||
|
<div className="w-28 shrink-0 text-gray-400">{label}</div>
|
||||||
|
<div className="text-gray-800 break-all">{value}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SubmissionView({ id, onBack }: { id: string; onBack: () => void }) {
|
||||||
|
const [d, setD] = useState<SubmissionDetail | null>(null);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [rejecting, setRejecting] = useState(false);
|
||||||
|
const [reason, setReason] = useState("");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
api.getSubmission(id).then(setD).catch((e) => setError(e.message));
|
||||||
|
}, [id]);
|
||||||
|
|
||||||
|
async function approve() {
|
||||||
|
if (!confirm("确认通过该投稿?将写入正式商品库并记录来源 community。")) return;
|
||||||
|
setBusy(true);
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
await api.approveSubmission(id);
|
||||||
|
onBack();
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof ApiError ? e.message : "操作失败");
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function reject() {
|
||||||
|
setBusy(true);
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
await api.rejectSubmission(id, reason.trim());
|
||||||
|
onBack();
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof ApiError ? e.message : "操作失败");
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error && !d) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<button onClick={onBack} className="text-sm text-gray-500 flex items-center gap-1 mb-4">
|
||||||
|
<ArrowLeft className="h-4 w-4" /> 返回
|
||||||
|
</button>
|
||||||
|
<div className="bg-red-50 text-red-700 text-sm rounded px-4 py-3">{error}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!d) return <div className="text-gray-400">加载中…</div>;
|
||||||
|
|
||||||
|
const p = d.payload;
|
||||||
|
const nutri = Object.entries(p.nutriments || {});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-3xl">
|
||||||
|
<button onClick={onBack} className="text-sm text-gray-500 flex items-center gap-1 mb-4">
|
||||||
|
<ArrowLeft className="h-4 w-4" /> 返回投稿队列
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h2 className="text-lg font-semibold text-gray-800">{p.name}</h2>
|
||||||
|
<span className={`text-xs rounded px-2 py-0.5 ${STATUS_BADGE[d.status]}`}>
|
||||||
|
{STATUS_TEXT[d.status]}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <div className="mt-3 bg-red-50 text-red-700 text-sm rounded px-4 py-2">{error}</div>}
|
||||||
|
|
||||||
|
{d.target_product_id && (
|
||||||
|
<div className="mt-3 text-sm bg-blue-50 text-blue-700 rounded px-4 py-2">
|
||||||
|
该条码已存在商品,通过后将<b>补全已有商品</b>(仅覆盖本次提供的字段)。
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="bg-white border rounded-lg p-5 mt-4">
|
||||||
|
<h3 className="font-medium text-gray-700 mb-2">投稿内容</h3>
|
||||||
|
<Field label="商品名称" value={p.name} />
|
||||||
|
<Field label="条码" value={p.gtin} />
|
||||||
|
<Field label="品牌" value={p.brand_name} />
|
||||||
|
<Field
|
||||||
|
label="净含量"
|
||||||
|
value={p.net_content_value != null ? `${p.net_content_value} ${p.net_content_unit || ""}` : null}
|
||||||
|
/>
|
||||||
|
<Field label="产地" value={p.country_of_origin} />
|
||||||
|
<Field label="配料" value={p.ingredients_text} />
|
||||||
|
{nutri.length > 0 && (
|
||||||
|
<Field
|
||||||
|
label="营养成分"
|
||||||
|
value={
|
||||||
|
<span>
|
||||||
|
{p.nutrition_basis ? `(${p.nutrition_basis}) ` : ""}
|
||||||
|
{nutri.map(([k, v]) => `${FIELD_LABELS[k] || k}:${v}`).join(",")}
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{p.images && p.images.length > 0 && (
|
||||||
|
<Field
|
||||||
|
label="图片"
|
||||||
|
value={
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{p.images.map((im, i) => (
|
||||||
|
<a key={i} href={im.url} target="_blank" rel="noreferrer">
|
||||||
|
<img src={im.url} alt="" className="h-20 w-20 object-cover rounded border" />
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white border rounded-lg p-5 mt-4">
|
||||||
|
<h3 className="font-medium text-gray-700 mb-2">投稿人 / 备注</h3>
|
||||||
|
<Field label="称呼" value={p.submitter_name || "匿名"} />
|
||||||
|
<Field label="联系方式" value={p.submitter_contact} />
|
||||||
|
<Field label="备注" value={p.note} />
|
||||||
|
<Field label="提交时间" value={new Date(d.created_at).toLocaleString()} />
|
||||||
|
{d.reviewed_by && <Field label="审核人" value={d.reviewed_by} />}
|
||||||
|
{d.review_note && <Field label="驳回原因" value={d.review_note} />}
|
||||||
|
{d.result_product_id && <Field label="收录商品ID" value={d.result_product_id} />}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{d.status === "pending" && (
|
||||||
|
<div className="mt-5">
|
||||||
|
{rejecting ? (
|
||||||
|
<div className="bg-white border rounded-lg p-4">
|
||||||
|
<label className="block text-xs text-gray-500 mb-1">驳回原因</label>
|
||||||
|
<input
|
||||||
|
className="w-full border rounded-md px-3 py-2 text-sm"
|
||||||
|
value={reason}
|
||||||
|
onChange={(e) => setReason(e.target.value)}
|
||||||
|
placeholder="例如:资料无法核实 / 重复投稿"
|
||||||
|
/>
|
||||||
|
<div className="mt-3 flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={reject}
|
||||||
|
disabled={busy}
|
||||||
|
className="px-4 py-2 rounded bg-red-600 text-white text-sm hover:bg-red-700 disabled:opacity-60"
|
||||||
|
>
|
||||||
|
确认驳回
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setRejecting(false)}
|
||||||
|
className="px-4 py-2 rounded border text-sm text-gray-600"
|
||||||
|
>
|
||||||
|
取消
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<button
|
||||||
|
onClick={approve}
|
||||||
|
disabled={busy}
|
||||||
|
className="px-5 py-2.5 rounded-lg bg-emerald-600 text-white font-medium hover:bg-emerald-700 disabled:opacity-60 flex items-center gap-1.5"
|
||||||
|
>
|
||||||
|
<Check className="h-4 w-4" /> 通过并收录
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setRejecting(true)}
|
||||||
|
disabled={busy}
|
||||||
|
className="px-5 py-2.5 rounded-lg border text-gray-700 font-medium hover:bg-gray-50 flex items-center gap-1.5"
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" /> 驳回
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -74,6 +74,59 @@ export interface AuditEntry {
|
|||||||
created_at: string;
|
created_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface SubmissionRow {
|
||||||
|
id: string;
|
||||||
|
gtin: string | null;
|
||||||
|
name: string;
|
||||||
|
status: string;
|
||||||
|
submitter_name: string | null;
|
||||||
|
matched: boolean;
|
||||||
|
created_at: string;
|
||||||
|
reviewed_at: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SubmissionImage {
|
||||||
|
url: string;
|
||||||
|
kind: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SubmissionPayload {
|
||||||
|
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[] | null;
|
||||||
|
submitter_name: string | null;
|
||||||
|
submitter_contact: string | null;
|
||||||
|
note: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SubmissionDetail {
|
||||||
|
id: string;
|
||||||
|
status: string;
|
||||||
|
gtin: string | null;
|
||||||
|
name: string;
|
||||||
|
submitter_name: string | null;
|
||||||
|
submitter_contact: string | null;
|
||||||
|
note: string | null;
|
||||||
|
review_note: string | null;
|
||||||
|
reviewed_by: string | null;
|
||||||
|
reviewed_at: string | null;
|
||||||
|
created_at: string;
|
||||||
|
target_product_id: string | null;
|
||||||
|
result_product_id: string | null;
|
||||||
|
payload: SubmissionPayload;
|
||||||
|
existing_product?: ProductDetail;
|
||||||
|
}
|
||||||
|
|
||||||
export const FIELD_LABELS: Record<string, string> = {
|
export const FIELD_LABELS: Record<string, string> = {
|
||||||
name: "名称",
|
name: "名称",
|
||||||
gtin: "条码",
|
gtin: "条码",
|
||||||
|
|||||||
+20
-5
@@ -1,14 +1,29 @@
|
|||||||
# Build stage
|
# Public API image: builds the public SPA (homepage + search + contribute),
|
||||||
|
# embeds it into the Go read-only server binary, and ships a static scratch
|
||||||
|
# runtime (used where gcr.io/distroless is not reachable). Build context is the
|
||||||
|
# repo root.
|
||||||
|
|
||||||
|
# Stage 1: build the public SPA.
|
||||||
|
FROM node:22-alpine AS web
|
||||||
|
WORKDIR /web
|
||||||
|
ENV npm_config_registry=https://registry.npmmirror.com
|
||||||
|
COPY public-frontend/package.json public-frontend/package-lock.json* ./
|
||||||
|
RUN npm ci || npm install
|
||||||
|
COPY public-frontend/ ./
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# Stage 2: build the Go server binary with the SPA embedded.
|
||||||
FROM golang:1.23-alpine AS build
|
FROM golang:1.23-alpine AS build
|
||||||
ENV GOPROXY=https://goproxy.cn,direct
|
ENV GOPROXY=https://goproxy.cn,direct
|
||||||
WORKDIR /src
|
WORKDIR /src
|
||||||
COPY go.mod go.sum ./
|
COPY api/go.mod api/go.sum ./
|
||||||
RUN go mod download
|
RUN go mod download
|
||||||
COPY . .
|
COPY api/ ./
|
||||||
|
RUN rm -rf internal/publicweb/dist && mkdir -p internal/publicweb/dist
|
||||||
|
COPY --from=web /web/dist/ internal/publicweb/dist/
|
||||||
RUN CGO_ENABLED=0 go build -o /out/server ./cmd/server
|
RUN CGO_ENABLED=0 go build -o /out/server ./cmd/server
|
||||||
|
|
||||||
# Runtime stage: scratch + ca-certs copied from the build image.
|
# Stage 3: minimal runtime.
|
||||||
# Used for deployments where gcr.io/distroless is not reachable.
|
|
||||||
FROM scratch
|
FROM scratch
|
||||||
COPY --from=build /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
|
COPY --from=build /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
|
||||||
COPY --from=build /out/server /server
|
COPY --from=build /out/server /server
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import (
|
|||||||
|
|
||||||
"github.com/baicai2026-baicai/goods/api/internal/config"
|
"github.com/baicai2026-baicai/goods/api/internal/config"
|
||||||
"github.com/baicai2026-baicai/goods/api/internal/handler"
|
"github.com/baicai2026-baicai/goods/api/internal/handler"
|
||||||
|
"github.com/baicai2026-baicai/goods/api/internal/publicweb"
|
||||||
"github.com/baicai2026-baicai/goods/api/internal/store"
|
"github.com/baicai2026-baicai/goods/api/internal/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -30,7 +31,7 @@ func main() {
|
|||||||
log.Printf("warning: database not reachable at startup: %v", err)
|
log.Printf("warning: database not reachable at startup: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
h := handler.New(store.New(pool))
|
h := handler.New(store.New(pool), publicweb.Dist())
|
||||||
|
|
||||||
srv := &http.Server{
|
srv := &http.Server{
|
||||||
Addr: cfg.Addr,
|
Addr: cfg.Addr,
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"io/fs"
|
"io/fs"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
"github.com/go-chi/chi/v5/middleware"
|
"github.com/go-chi/chi/v5/middleware"
|
||||||
@@ -22,12 +23,19 @@ type Handler struct {
|
|||||||
authn *auth.Authenticator
|
authn *auth.Authenticator
|
||||||
basePath string
|
basePath string
|
||||||
spa fs.FS
|
spa fs.FS
|
||||||
|
submitLimit *rateLimiter
|
||||||
}
|
}
|
||||||
|
|
||||||
// New constructs an admin Handler. basePath is e.g. "/ping" (no trailing slash).
|
// New constructs an admin Handler. basePath is e.g. "/ping" (no trailing slash).
|
||||||
func New(store *adminstore.Store, authn *auth.Authenticator, basePath string, spa fs.FS) *Handler {
|
func New(store *adminstore.Store, authn *auth.Authenticator, basePath string, spa fs.FS) *Handler {
|
||||||
basePath = "/" + strings.Trim(basePath, "/")
|
basePath = "/" + strings.Trim(basePath, "/")
|
||||||
return &Handler{store: store, authn: authn, basePath: basePath, spa: spa}
|
return &Handler{
|
||||||
|
store: store,
|
||||||
|
authn: authn,
|
||||||
|
basePath: basePath,
|
||||||
|
spa: spa,
|
||||||
|
submitLimit: newRateLimiter(5, 10*time.Minute),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Router builds the HTTP handler.
|
// Router builds the HTTP handler.
|
||||||
@@ -56,10 +64,21 @@ func (h *Handler) Router() http.Handler {
|
|||||||
r.Delete("/api/products/{id}/msrp/{msrpID}", h.DeleteMSRP)
|
r.Delete("/api/products/{id}/msrp/{msrpID}", h.DeleteMSRP)
|
||||||
r.Get("/api/brands", h.ListBrands)
|
r.Get("/api/brands", h.ListBrands)
|
||||||
r.Get("/api/categories", h.ListCategories)
|
r.Get("/api/categories", h.ListCategories)
|
||||||
|
|
||||||
|
r.Get("/api/submissions", h.ListSubmissions)
|
||||||
|
r.Get("/api/submissions/{id}", h.GetSubmission)
|
||||||
|
r.Post("/api/submissions/{id}/approve", h.ApproveSubmission)
|
||||||
|
r.Post("/api/submissions/{id}/reject", h.RejectSubmission)
|
||||||
})
|
})
|
||||||
|
|
||||||
r.Handle("/*", http.HandlerFunc(h.serveSPA))
|
r.Handle("/*", http.HandlerFunc(h.serveSPA))
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Public, unauthenticated contribution endpoint (proxied at /api/public/*).
|
||||||
|
// Submissions enter a moderation queue and never touch products until an
|
||||||
|
// admin approves them.
|
||||||
|
r.Post("/api/public/submissions", h.CreateSubmission)
|
||||||
|
|
||||||
return r
|
return r
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -230,8 +249,91 @@ func (h *Handler) ListCategories(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeJSON(w, http.StatusOK, map[string]any{"items": items})
|
writeJSON(w, http.StatusOK, map[string]any{"items": items})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------- submissions ----------
|
||||||
|
|
||||||
|
// CreateSubmission accepts an anonymous public contribution into the queue.
|
||||||
|
func (h *Handler) CreateSubmission(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !h.submitLimit.allow(realIP(r)) {
|
||||||
|
writeError(w, http.StatusTooManyRequests, "rate_limited", "提交过于频繁,请稍后再试")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var in adminstore.SubmissionInput
|
||||||
|
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&in); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "bad_request", "invalid body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(in.Name) == "" {
|
||||||
|
writeError(w, http.StatusBadRequest, "bad_request", "商品名称不能为空")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
id, err := h.store.CreateSubmission(r.Context(), in, realIP(r))
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "internal_error", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusCreated, map[string]string{"id": id, "status": "pending"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListSubmissions returns the moderation queue (admin).
|
||||||
|
func (h *Handler) ListSubmissions(w http.ResponseWriter, r *http.Request) {
|
||||||
|
status := r.URL.Query().Get("status")
|
||||||
|
page, size := pageParams(r)
|
||||||
|
items, total, err := h.store.ListSubmissions(r.Context(), status, size, (page-1)*size)
|
||||||
|
if h.handleErr(w, err) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
pending, _ := h.store.PendingSubmissionCount(r.Context())
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{
|
||||||
|
"items": items, "page": page, "size": size, "total": total, "pending": pending,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetSubmission returns full submission detail (admin).
|
||||||
|
func (h *Handler) GetSubmission(w http.ResponseWriter, r *http.Request) {
|
||||||
|
d, err := h.store.GetSubmission(r.Context(), chi.URLParam(r, "id"))
|
||||||
|
if h.handleErr(w, err) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, d)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ApproveSubmission applies a contribution to the product store (admin).
|
||||||
|
func (h *Handler) ApproveSubmission(w http.ResponseWriter, r *http.Request) {
|
||||||
|
d, err := h.store.ApproveSubmission(r.Context(), chi.URLParam(r, "id"), auth.UserFrom(r.Context()))
|
||||||
|
if h.handleErr(w, err) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, d)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RejectSubmission rejects a contribution with a reviewer note (admin).
|
||||||
|
func (h *Handler) RejectSubmission(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var body struct {
|
||||||
|
Note string `json:"note"`
|
||||||
|
}
|
||||||
|
_ = json.NewDecoder(r.Body).Decode(&body)
|
||||||
|
err := h.store.RejectSubmission(r.Context(), chi.URLParam(r, "id"), auth.UserFrom(r.Context()), body.Note)
|
||||||
|
if h.handleErr(w, err) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]string{"status": "rejected"})
|
||||||
|
}
|
||||||
|
|
||||||
// ---------- helpers ----------
|
// ---------- helpers ----------
|
||||||
|
|
||||||
|
func realIP(r *http.Request) string {
|
||||||
|
if ip := r.Header.Get("X-Forwarded-For"); ip != "" {
|
||||||
|
if i := strings.IndexByte(ip, ','); i >= 0 {
|
||||||
|
return strings.TrimSpace(ip[:i])
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(ip)
|
||||||
|
}
|
||||||
|
if ip := r.Header.Get("X-Real-IP"); ip != "" {
|
||||||
|
return ip
|
||||||
|
}
|
||||||
|
return r.RemoteAddr
|
||||||
|
}
|
||||||
|
|
||||||
func (h *Handler) handleErr(w http.ResponseWriter, err error) bool {
|
func (h *Handler) handleErr(w http.ResponseWriter, err error) bool {
|
||||||
if err == nil {
|
if err == nil {
|
||||||
return false
|
return false
|
||||||
@@ -240,6 +342,10 @@ func (h *Handler) handleErr(w http.ResponseWriter, err error) bool {
|
|||||||
writeError(w, http.StatusNotFound, "not_found", "资源不存在")
|
writeError(w, http.StatusNotFound, "not_found", "资源不存在")
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
if errors.Is(err, adminstore.ErrConflict) {
|
||||||
|
writeError(w, http.StatusConflict, "conflict", "该投稿已被处理")
|
||||||
|
return true
|
||||||
|
}
|
||||||
writeError(w, http.StatusInternalServerError, "internal_error", err.Error())
|
writeError(w, http.StatusInternalServerError, "internal_error", err.Error())
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
package adminhandler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// rateLimiter is a simple fixed-window per-key limiter used to throttle
|
||||||
|
// anonymous public submissions (basic anti-spam; captcha can be added later).
|
||||||
|
type rateLimiter struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
hits map[string][]time.Time
|
||||||
|
limit int
|
||||||
|
window time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
func newRateLimiter(limit int, window time.Duration) *rateLimiter {
|
||||||
|
return &rateLimiter{hits: map[string][]time.Time{}, limit: limit, window: window}
|
||||||
|
}
|
||||||
|
|
||||||
|
// allow reports whether the key may proceed, recording the hit if so.
|
||||||
|
func (r *rateLimiter) allow(key string) bool {
|
||||||
|
now := time.Now()
|
||||||
|
cutoff := now.Add(-r.window)
|
||||||
|
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
|
||||||
|
kept := r.hits[key][:0]
|
||||||
|
for _, t := range r.hits[key] {
|
||||||
|
if t.After(cutoff) {
|
||||||
|
kept = append(kept, t)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(kept) >= r.limit {
|
||||||
|
r.hits[key] = kept
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
r.hits[key] = append(kept, now)
|
||||||
|
return true
|
||||||
|
}
|
||||||
@@ -0,0 +1,421 @@
|
|||||||
|
package adminstore
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ErrConflict is returned when a submission has already been reviewed.
|
||||||
|
var ErrConflict = errors.New("conflict")
|
||||||
|
|
||||||
|
// SubmissionImage is one proposed image URL inside a contribution.
|
||||||
|
type SubmissionImage struct {
|
||||||
|
URL string `json:"url"`
|
||||||
|
Kind string `json:"kind"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SubmissionInput is the public contribution payload (no login required).
|
||||||
|
type SubmissionInput struct {
|
||||||
|
GTIN *string `json:"gtin"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
BrandName *string `json:"brand_name"`
|
||||||
|
CategoryID *string `json:"category_id"`
|
||||||
|
NetContentValue *float64 `json:"net_content_value"`
|
||||||
|
NetContentUnit *string `json:"net_content_unit"`
|
||||||
|
CountryOfOrigin *string `json:"country_of_origin"`
|
||||||
|
IngredientsText *string `json:"ingredients_text"`
|
||||||
|
Nutriments map[string]any `json:"nutriments"`
|
||||||
|
NutritionBasis *string `json:"nutrition_basis"`
|
||||||
|
ServingSize *string `json:"serving_size"`
|
||||||
|
NutriScore *string `json:"nutri_score"`
|
||||||
|
Images []SubmissionImage `json:"images"`
|
||||||
|
MSRP []MSRPInput `json:"msrp"`
|
||||||
|
SubmitterName *string `json:"submitter_name"`
|
||||||
|
SubmitterContact *string `json:"submitter_contact"`
|
||||||
|
Note *string `json:"note"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SubmissionRow is a queue-list row for the admin review table.
|
||||||
|
type SubmissionRow struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
GTIN *string `json:"gtin"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
SubmitterName *string `json:"submitter_name"`
|
||||||
|
Matched bool `json:"matched"`
|
||||||
|
CreatedAt string `json:"created_at"`
|
||||||
|
ReviewedAt *string `json:"reviewed_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SubmissionDetail is the full review view of one contribution.
|
||||||
|
type SubmissionDetail struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
GTIN *string `json:"gtin"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
SubmitterName *string `json:"submitter_name"`
|
||||||
|
SubmitterContact *string `json:"submitter_contact"`
|
||||||
|
Note *string `json:"note"`
|
||||||
|
ReviewNote *string `json:"review_note"`
|
||||||
|
ReviewedBy *string `json:"reviewed_by"`
|
||||||
|
ReviewedAt *string `json:"reviewed_at"`
|
||||||
|
CreatedAt string `json:"created_at"`
|
||||||
|
TargetProductID *string `json:"target_product_id"`
|
||||||
|
ResultProductID *string `json:"result_product_id"`
|
||||||
|
Payload SubmissionInput `json:"payload"`
|
||||||
|
ExistingProduct *ProductDetail `json:"existing_product,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateSubmission validates and stores a public contribution as pending.
|
||||||
|
func (s *Store) CreateSubmission(ctx context.Context, in SubmissionInput, remoteIP string) (string, error) {
|
||||||
|
in.Name = strings.TrimSpace(in.Name)
|
||||||
|
if in.Name == "" {
|
||||||
|
return "", errors.New("商品名称不能为空")
|
||||||
|
}
|
||||||
|
if in.GTIN != nil {
|
||||||
|
g := strings.TrimSpace(*in.GTIN)
|
||||||
|
if g == "" {
|
||||||
|
in.GTIN = nil
|
||||||
|
} else {
|
||||||
|
in.GTIN = &g
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Link to an existing product when the barcode already exists (supplement).
|
||||||
|
var target *string
|
||||||
|
if in.GTIN != nil {
|
||||||
|
var pid string
|
||||||
|
err := s.pool.QueryRow(ctx, "SELECT id FROM product WHERE gtin = $1", *in.GTIN).Scan(&pid)
|
||||||
|
if err == nil {
|
||||||
|
target = &pid
|
||||||
|
} else if !errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
payload, err := json.Marshal(in)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
var id string
|
||||||
|
err = s.pool.QueryRow(ctx, `
|
||||||
|
INSERT INTO submission (gtin, name, payload, target_product_id, submitter_name, submitter_contact, note, remote_ip)
|
||||||
|
VALUES ($1,$2,$3,$4,$5,$6,$7,$8) RETURNING id`,
|
||||||
|
in.GTIN, in.Name, payload, target, in.SubmitterName, in.SubmitterContact, in.Note, remoteIP).Scan(&id)
|
||||||
|
return id, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListSubmissions returns submissions filtered by status (empty = all).
|
||||||
|
func (s *Store) ListSubmissions(ctx context.Context, status string, limit, offset int) ([]SubmissionRow, int, error) {
|
||||||
|
args := []any{}
|
||||||
|
where := "WHERE 1=1"
|
||||||
|
if status != "" {
|
||||||
|
args = append(args, status)
|
||||||
|
where += " AND status = $1"
|
||||||
|
}
|
||||||
|
|
||||||
|
var total int
|
||||||
|
if err := s.pool.QueryRow(ctx, "SELECT count(*) FROM submission "+where, args...).Scan(&total); err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
args = append(args, limit, offset)
|
||||||
|
sql := `
|
||||||
|
SELECT id, gtin, name, status, submitter_name, (target_product_id IS NOT NULL),
|
||||||
|
created_at, reviewed_at
|
||||||
|
FROM submission ` + where +
|
||||||
|
" ORDER BY (status='pending') DESC, created_at DESC LIMIT $" +
|
||||||
|
strconv.Itoa(len(args)-1) + " OFFSET $" + strconv.Itoa(len(args))
|
||||||
|
|
||||||
|
rows, err := s.pool.Query(ctx, sql, args...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
out := []SubmissionRow{}
|
||||||
|
for rows.Next() {
|
||||||
|
var r SubmissionRow
|
||||||
|
var created time.Time
|
||||||
|
var reviewed *time.Time
|
||||||
|
if err := rows.Scan(&r.ID, &r.GTIN, &r.Name, &r.Status, &r.SubmitterName, &r.Matched, &created, &reviewed); err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
r.CreatedAt = created.Format(time.RFC3339)
|
||||||
|
if reviewed != nil {
|
||||||
|
t := reviewed.Format(time.RFC3339)
|
||||||
|
r.ReviewedAt = &t
|
||||||
|
}
|
||||||
|
out = append(out, r)
|
||||||
|
}
|
||||||
|
return out, total, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// PendingSubmissionCount returns the number of submissions awaiting review.
|
||||||
|
func (s *Store) PendingSubmissionCount(ctx context.Context) (int, error) {
|
||||||
|
var n int
|
||||||
|
err := s.pool.QueryRow(ctx, "SELECT count(*) FROM submission WHERE status='pending'").Scan(&n)
|
||||||
|
return n, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetSubmission returns the full review detail for one submission.
|
||||||
|
func (s *Store) GetSubmission(ctx context.Context, id string) (*SubmissionDetail, error) {
|
||||||
|
var d SubmissionDetail
|
||||||
|
var payload []byte
|
||||||
|
var created time.Time
|
||||||
|
var reviewed *time.Time
|
||||||
|
err := s.pool.QueryRow(ctx, `
|
||||||
|
SELECT id, status, gtin, name, submitter_name, submitter_contact, note,
|
||||||
|
review_note, reviewed_by, reviewed_at, created_at, target_product_id, result_product_id, payload
|
||||||
|
FROM submission WHERE id = $1`, id).Scan(
|
||||||
|
&d.ID, &d.Status, &d.GTIN, &d.Name, &d.SubmitterName, &d.SubmitterContact, &d.Note,
|
||||||
|
&d.ReviewNote, &d.ReviewedBy, &reviewed, &created, &d.TargetProductID, &d.ResultProductID, &payload,
|
||||||
|
)
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return nil, ErrNotFound
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
d.CreatedAt = created.Format(time.RFC3339)
|
||||||
|
if reviewed != nil {
|
||||||
|
t := reviewed.Format(time.RFC3339)
|
||||||
|
d.ReviewedAt = &t
|
||||||
|
}
|
||||||
|
if len(payload) > 0 {
|
||||||
|
_ = json.Unmarshal(payload, &d.Payload)
|
||||||
|
}
|
||||||
|
if d.TargetProductID != nil {
|
||||||
|
if ep, err := s.GetProduct(ctx, *d.TargetProductID); err == nil {
|
||||||
|
d.ExistingProduct = ep
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return &d, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RejectSubmission marks a pending submission as rejected with a reviewer note.
|
||||||
|
func (s *Store) RejectSubmission(ctx context.Context, id, actor, note string) error {
|
||||||
|
ct, err := s.pool.Exec(ctx, `
|
||||||
|
UPDATE submission SET status='rejected', review_note=$2, reviewed_by=$3, reviewed_at=now()
|
||||||
|
WHERE id=$1 AND status='pending'`, id, note, actor)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if ct.RowsAffected() == 0 {
|
||||||
|
// Distinguish missing vs already-reviewed.
|
||||||
|
var st string
|
||||||
|
if e := s.pool.QueryRow(ctx, "SELECT status FROM submission WHERE id=$1", id).Scan(&st); errors.Is(e, pgx.ErrNoRows) {
|
||||||
|
return ErrNotFound
|
||||||
|
}
|
||||||
|
return ErrConflict
|
||||||
|
}
|
||||||
|
_ = s.writeAudit(ctx, actor, "reject_submission", "submission", &id, []string{}, nil, map[string]string{"review_note": note})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ApproveSubmission applies a pending contribution to the product store
|
||||||
|
// (creating or supplementing a product), records community provenance + audit,
|
||||||
|
// recomputes quality, and marks the submission approved.
|
||||||
|
func (s *Store) ApproveSubmission(ctx context.Context, id, actor string) (*ProductDetail, error) {
|
||||||
|
sub, err := s.GetSubmission(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if sub.Status != "pending" {
|
||||||
|
return nil, ErrConflict
|
||||||
|
}
|
||||||
|
in := sub.Payload
|
||||||
|
|
||||||
|
tx, err := s.pool.Begin(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer tx.Rollback(ctx)
|
||||||
|
|
||||||
|
communityID, err := s.sourceIDTx(ctx, tx, "community")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve the target product (existing supplement vs new create).
|
||||||
|
productID := ""
|
||||||
|
if sub.TargetProductID != nil {
|
||||||
|
productID = *sub.TargetProductID
|
||||||
|
} else if in.GTIN != nil {
|
||||||
|
var pid string
|
||||||
|
if e := tx.QueryRow(ctx, "SELECT id FROM product WHERE gtin=$1", *in.GTIN).Scan(&pid); e == nil {
|
||||||
|
productID = pid
|
||||||
|
} else if !errors.Is(e, pgx.ErrNoRows) {
|
||||||
|
return nil, e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var brandID *string
|
||||||
|
if in.BrandName != nil && strings.TrimSpace(*in.BrandName) != "" {
|
||||||
|
bid, err := s.ensureBrand(ctx, tx, strings.TrimSpace(*in.BrandName))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
brandID = &bid
|
||||||
|
}
|
||||||
|
var gpc *string
|
||||||
|
if in.CategoryID != nil && *in.CategoryID != "" {
|
||||||
|
if err := tx.QueryRow(ctx, "SELECT gpc_brick_code FROM category WHERE id=$1", *in.CategoryID).Scan(&gpc); err != nil && !errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
canonical, err := s.netCanonical(ctx, tx, in.NetContentValue, in.NetContentUnit)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
fields := submissionFields(in)
|
||||||
|
|
||||||
|
if productID == "" {
|
||||||
|
// Create a new product from the contribution.
|
||||||
|
err = tx.QueryRow(ctx, `
|
||||||
|
INSERT INTO product (gtin, name, brand_id, category_id, gpc_brick_code,
|
||||||
|
net_content_value, net_content_unit, net_content_canonical, country_of_origin, status)
|
||||||
|
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,'active') RETURNING id`,
|
||||||
|
in.GTIN, in.Name, brandID, in.CategoryID, gpc,
|
||||||
|
in.NetContentValue, in.NetContentUnit, canonical, in.CountryOfOrigin).Scan(&productID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Supplement an existing product: only overwrite fields the
|
||||||
|
// contribution actually provides (COALESCE keeps current values).
|
||||||
|
_, err = tx.Exec(ctx, `
|
||||||
|
UPDATE product SET
|
||||||
|
name=COALESCE(NULLIF($2,''), name),
|
||||||
|
brand_id=COALESCE($3, brand_id),
|
||||||
|
category_id=COALESCE($4, category_id),
|
||||||
|
gpc_brick_code=COALESCE($5, gpc_brick_code),
|
||||||
|
net_content_value=COALESCE($6, net_content_value),
|
||||||
|
net_content_unit=COALESCE($7, net_content_unit),
|
||||||
|
net_content_canonical=COALESCE($8, net_content_canonical),
|
||||||
|
country_of_origin=COALESCE($9, country_of_origin),
|
||||||
|
gtin=COALESCE($10, gtin)
|
||||||
|
WHERE id=$1`,
|
||||||
|
productID, in.Name, brandID, in.CategoryID, gpc,
|
||||||
|
in.NetContentValue, in.NetContentUnit, canonical, in.CountryOfOrigin, in.GTIN)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// food_detail: upsert, preserving existing values where not provided.
|
||||||
|
var nutriJSON []byte
|
||||||
|
if len(in.Nutriments) > 0 {
|
||||||
|
nutriJSON, _ = json.Marshal(in.Nutriments)
|
||||||
|
}
|
||||||
|
_, err = tx.Exec(ctx, `
|
||||||
|
INSERT INTO food_detail (product_id, ingredients_text, nutriments, nutrition_basis, serving_size, nutri_score)
|
||||||
|
VALUES ($1,$2,$3,$4,$5,$6)
|
||||||
|
ON CONFLICT (product_id) DO UPDATE SET
|
||||||
|
ingredients_text=COALESCE(EXCLUDED.ingredients_text, food_detail.ingredients_text),
|
||||||
|
nutriments=COALESCE(EXCLUDED.nutriments, food_detail.nutriments),
|
||||||
|
nutrition_basis=COALESCE(EXCLUDED.nutrition_basis, food_detail.nutrition_basis),
|
||||||
|
serving_size=COALESCE(EXCLUDED.serving_size, food_detail.serving_size),
|
||||||
|
nutri_score=COALESCE(EXCLUDED.nutri_score, food_detail.nutri_score)`,
|
||||||
|
productID, in.IngredientsText, nutriJSON, in.NutritionBasis, in.ServingSize, in.NutriScore)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, im := range in.Images {
|
||||||
|
url := strings.TrimSpace(im.URL)
|
||||||
|
if url == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
kind := im.Kind
|
||||||
|
if kind != "front" && kind != "ingredients" && kind != "nutrition" {
|
||||||
|
kind = "other"
|
||||||
|
}
|
||||||
|
if _, err := tx.Exec(ctx, `
|
||||||
|
INSERT INTO product_image (product_id, url, kind, source_id) VALUES ($1,$2,$3,$4)`,
|
||||||
|
productID, url, kind, communityID); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, m := range in.MSRP {
|
||||||
|
if m.Amount <= 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
cur := m.Currency
|
||||||
|
if cur == "" {
|
||||||
|
cur = "CNY"
|
||||||
|
}
|
||||||
|
region := m.Region
|
||||||
|
if region == "" {
|
||||||
|
region = "CN"
|
||||||
|
}
|
||||||
|
if _, err := tx.Exec(ctx, `
|
||||||
|
INSERT INTO product_msrp (product_id, amount, currency, region, source_id, source_url, effective_date, note)
|
||||||
|
VALUES ($1,$2,$3,$4,$5,$6,$7,$8)`,
|
||||||
|
productID, m.Amount, cur, region, communityID, m.SourceURL, m.EffectiveDate, m.Note); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := s.recomputeQualityTx(ctx, tx, productID); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := tx.Exec(ctx, `
|
||||||
|
UPDATE submission SET status='approved', reviewed_by=$2, reviewed_at=now(), result_product_id=$3
|
||||||
|
WHERE id=$1`, id, actor, productID); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Field-level provenance for the contributed fields (community source).
|
||||||
|
if len(fields) > 0 {
|
||||||
|
if _, err := tx.Exec(ctx, `
|
||||||
|
INSERT INTO product_source (product_id, source_id, url, fields, fetched_at, raw)
|
||||||
|
VALUES ($1,$2,NULL,$3,now(),NULL)`, productID, communityID, fields); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tx.Commit(ctx); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = s.writeAudit(ctx, actor, "approve_submission", "product", &productID, fields,
|
||||||
|
map[string]string{"submission_id": id}, map[string]string{"product_id": productID})
|
||||||
|
|
||||||
|
return s.GetProduct(ctx, productID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) sourceIDTx(ctx context.Context, tx pgx.Tx, name string) (string, error) {
|
||||||
|
var id string
|
||||||
|
err := tx.QueryRow(ctx, "SELECT id FROM source WHERE name=$1", name).Scan(&id)
|
||||||
|
return id, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// submissionFields lists the product fields a contribution provides values for.
|
||||||
|
func submissionFields(in SubmissionInput) []string {
|
||||||
|
fields := []string{"name"}
|
||||||
|
add := func(name string, present bool) {
|
||||||
|
if present {
|
||||||
|
fields = append(fields, name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
add("gtin", in.GTIN != nil && *in.GTIN != "")
|
||||||
|
add("brand", in.BrandName != nil && strings.TrimSpace(*in.BrandName) != "")
|
||||||
|
add("category", in.CategoryID != nil && *in.CategoryID != "")
|
||||||
|
add("net_content", in.NetContentValue != nil)
|
||||||
|
add("country_of_origin", in.CountryOfOrigin != nil && *in.CountryOfOrigin != "")
|
||||||
|
add("ingredients", in.IngredientsText != nil && *in.IngredientsText != "")
|
||||||
|
add("nutriments", len(in.Nutriments) > 0)
|
||||||
|
add("image", len(in.Images) > 0)
|
||||||
|
return fields
|
||||||
|
}
|
||||||
@@ -7,8 +7,10 @@ package handler
|
|||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
|
"io/fs"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
"github.com/go-chi/chi/v5/middleware"
|
"github.com/go-chi/chi/v5/middleware"
|
||||||
@@ -27,11 +29,12 @@ const (
|
|||||||
// Handler holds dependencies shared by the HTTP routes.
|
// Handler holds dependencies shared by the HTTP routes.
|
||||||
type Handler struct {
|
type Handler struct {
|
||||||
store *store.Store
|
store *store.Store
|
||||||
|
spa fs.FS
|
||||||
}
|
}
|
||||||
|
|
||||||
// New constructs a Handler backed by the given store.
|
// New constructs a Handler backed by the given store. spa may be nil (JSON-only).
|
||||||
func New(s *store.Store) *Handler {
|
func New(s *store.Store, spa fs.FS) *Handler {
|
||||||
return &Handler{store: s}
|
return &Handler{store: s, spa: spa}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Router builds the top-level HTTP handler with middleware and routes mounted.
|
// Router builds the top-level HTTP handler with middleware and routes mounted.
|
||||||
@@ -56,9 +59,35 @@ func (h *Handler) Router() http.Handler {
|
|||||||
r.Get("/sources/{id}", h.SourceByID)
|
r.Get("/sources/{id}", h.SourceByID)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Public SPA (homepage + search + contribute). API routes above take
|
||||||
|
// precedence; everything else falls back to the embedded single-page app.
|
||||||
|
if h.spa != nil {
|
||||||
|
r.Handle("/*", http.HandlerFunc(h.serveSPA))
|
||||||
|
}
|
||||||
|
|
||||||
return r
|
return r
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *Handler) serveSPA(w http.ResponseWriter, r *http.Request) {
|
||||||
|
rel := strings.TrimPrefix(r.URL.Path, "/")
|
||||||
|
if rel == "" {
|
||||||
|
rel = "index.html"
|
||||||
|
}
|
||||||
|
if f, err := h.spa.Open(rel); err == nil {
|
||||||
|
f.Close()
|
||||||
|
http.FileServer(http.FS(h.spa)).ServeHTTP(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// SPA fallback: serve index.html for client-side routes.
|
||||||
|
data, err := fs.ReadFile(h.spa, "index.html")
|
||||||
|
if err != nil {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
_, _ = w.Write(data)
|
||||||
|
}
|
||||||
|
|
||||||
// Healthz reports liveness of the service.
|
// Healthz reports liveness of the service.
|
||||||
func (h *Handler) Healthz(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) Healthz(w http.ResponseWriter, r *http.Request) {
|
||||||
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ func newTestHandler(t *testing.T) (*Handler, string) {
|
|||||||
_, _ = pool.Exec(context.Background(), "DELETE FROM product WHERE gtin=$1", gtin)
|
_, _ = pool.Exec(context.Background(), "DELETE FROM product WHERE gtin=$1", gtin)
|
||||||
pool.Close()
|
pool.Close()
|
||||||
})
|
})
|
||||||
return New(store.New(pool)), gtin
|
return New(store.New(pool), nil), gtin
|
||||||
}
|
}
|
||||||
|
|
||||||
func doGET(t *testing.T, h *Handler, path string) *httptest.ResponseRecorder {
|
func doGET(t *testing.T, h *Handler, path string) *httptest.ResponseRecorder {
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ func TestHealthz(t *testing.T) {
|
|||||||
req := httptest.NewRequest(http.MethodGet, "/healthz", nil)
|
req := httptest.NewRequest(http.MethodGet, "/healthz", nil)
|
||||||
rec := httptest.NewRecorder()
|
rec := httptest.NewRecorder()
|
||||||
|
|
||||||
New(nil).Router().ServeHTTP(rec, req)
|
New(nil, nil).Router().ServeHTTP(rec, req)
|
||||||
|
|
||||||
if rec.Code != http.StatusOK {
|
if rec.Code != http.StatusOK {
|
||||||
t.Fatalf("expected status %d, got %d", http.StatusOK, rec.Code)
|
t.Fatalf("expected status %d, got %d", http.StatusOK, rec.Code)
|
||||||
|
|||||||
+10
@@ -0,0 +1,10 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<title>OpenGoods</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root">OpenGoods public site placeholder. Built assets are injected during Docker build.</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
// Package publicweb embeds the built public SPA (Vite dist). During Docker
|
||||||
|
// builds the real dist/ is produced by the node stage and copied in before go
|
||||||
|
// build; the committed placeholder keeps the package compilable for
|
||||||
|
// `go build ./...`.
|
||||||
|
package publicweb
|
||||||
|
|
||||||
|
import (
|
||||||
|
"embed"
|
||||||
|
"io/fs"
|
||||||
|
)
|
||||||
|
|
||||||
|
//go:embed all:dist
|
||||||
|
var distFS embed.FS
|
||||||
|
|
||||||
|
// Dist returns the embedded SPA filesystem rooted at dist/.
|
||||||
|
func Dist() fs.FS {
|
||||||
|
sub, err := fs.Sub(distFS, "dist")
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
return sub
|
||||||
|
}
|
||||||
@@ -41,8 +41,8 @@ services:
|
|||||||
|
|
||||||
api:
|
api:
|
||||||
build:
|
build:
|
||||||
context: ./api
|
context: .
|
||||||
dockerfile: Dockerfile.prod
|
dockerfile: api/Dockerfile.prod
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
depends_on:
|
depends_on:
|
||||||
postgres:
|
postgres:
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
DROP TABLE IF EXISTS submission;
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
-- Community contributions: public users may submit new/supplementary product
|
||||||
|
-- archives. Submissions go to a moderation queue and never touch the product
|
||||||
|
-- tables until an admin approves them.
|
||||||
|
|
||||||
|
-- Community source used to record field-level provenance for approved
|
||||||
|
-- public contributions (lower trust than manual operator edits).
|
||||||
|
INSERT INTO source (name, homepage, license, trust_weight, notes)
|
||||||
|
VALUES ('community', NULL, 'user-contributed', 0.50, '公众投稿/众包贡献,经人工审核后收纳')
|
||||||
|
ON CONFLICT (name) DO NOTHING;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS submission (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
gtin VARCHAR(14),
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
payload JSONB NOT NULL DEFAULT '{}',
|
||||||
|
target_product_id UUID REFERENCES product(id) ON DELETE SET NULL,
|
||||||
|
result_product_id UUID REFERENCES product(id) ON DELETE SET NULL,
|
||||||
|
submitter_name TEXT,
|
||||||
|
submitter_contact TEXT,
|
||||||
|
note TEXT,
|
||||||
|
status VARCHAR(16) NOT NULL DEFAULT 'pending',
|
||||||
|
review_note TEXT,
|
||||||
|
reviewed_by TEXT,
|
||||||
|
reviewed_at TIMESTAMPTZ,
|
||||||
|
remote_ip TEXT,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
CONSTRAINT submission_status_chk CHECK (status IN ('pending','approved','rejected'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_submission_status ON submission (status, created_at DESC);
|
||||||
@@ -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>
|
||||||
Generated
+2690
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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>,
|
||||||
|
);
|
||||||
@@ -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)",
|
||||||
|
};
|
||||||
Vendored
+1
@@ -0,0 +1 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
/** @type {import('tailwindcss').Config} */
|
||||||
|
export default {
|
||||||
|
content: ["./index.html", "./src/**/*.{ts,tsx}"],
|
||||||
|
theme: { extend: {} },
|
||||||
|
plugins: [],
|
||||||
|
};
|
||||||
@@ -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" }]
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"composite": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
|
"strict": true
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
||||||
@@ -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 },
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user