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:
@@ -3,16 +3,28 @@ import { api, clearToken, getToken } from "./api";
|
||||
import Login from "./components/Login";
|
||||
import ProductList from "./components/ProductList";
|
||||
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 };
|
||||
|
||||
export default function App() {
|
||||
const [authed, setAuthed] = useState(false);
|
||||
const [checking, setChecking] = useState(true);
|
||||
const [username, setUsername] = useState("");
|
||||
const [tab, setTab] = useState<Tab>("products");
|
||||
const [pending, setPending] = useState<number | null>(null);
|
||||
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(() => {
|
||||
if (!getToken()) {
|
||||
setChecking(false);
|
||||
@@ -53,9 +65,41 @@ export default function App() {
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
<header className="flex items-center justify-between bg-white px-6 py-3 shadow-sm">
|
||||
<div className="flex items-center gap-2 text-lg font-semibold text-gray-800">
|
||||
<Package className="h-5 w-5 text-emerald-600" />
|
||||
OpenGoods 商品档案后台
|
||||
<div className="flex items-center gap-6">
|
||||
<div className="flex items-center gap-2 text-lg font-semibold text-gray-800">
|
||||
<Package className="h-5 w-5 text-emerald-600" />
|
||||
OpenGoods 商品档案后台
|
||||
</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">
|
||||
<span>{username}</span>
|
||||
@@ -68,13 +112,12 @@ export default function App() {
|
||||
</div>
|
||||
</header>
|
||||
<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 })} />
|
||||
) : (
|
||||
<ProductDetail
|
||||
id={view.id}
|
||||
onBack={() => setView({ name: "list" })}
|
||||
/>
|
||||
<ProductDetail id={view.id} onBack={() => setView({ name: "list" })} />
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -90,4 +90,23 @@ export const api = {
|
||||
request<{ items: import("./types").Brand[] }>("/brands"),
|
||||
listCategories: () =>
|
||||
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;
|
||||
}
|
||||
|
||||
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> = {
|
||||
name: "名称",
|
||||
gtin: "条码",
|
||||
|
||||
Reference in New Issue
Block a user