Files
goods/admin-frontend/src/components/SubmissionsPage.tsx
T
oyaegeli98668 c9a4404052
CI / Go (api) (pull_request) Failing after 20s
CI / Python (ingestion) (pull_request) Successful in 8s
CI / Migrations (postgres) (pull_request) Failing after 17s
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>
2026-06-20 05:11:07 +00:00

315 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
);
}