feat(admin): 运营后台(登录/查看/审核编辑/补全)+ 写入API + 审计留痕
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
@@ -0,0 +1,82 @@
|
||||
import { useEffect, useState } from "react";
|
||||
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";
|
||||
|
||||
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 [view, setView] = useState<View>({ name: "list" });
|
||||
|
||||
useEffect(() => {
|
||||
if (!getToken()) {
|
||||
setChecking(false);
|
||||
return;
|
||||
}
|
||||
api
|
||||
.me()
|
||||
.then((r) => {
|
||||
setUsername(r.username);
|
||||
setAuthed(true);
|
||||
})
|
||||
.catch(() => clearToken())
|
||||
.finally(() => setChecking(false));
|
||||
}, []);
|
||||
|
||||
function onLoggedIn(name: string) {
|
||||
setUsername(name);
|
||||
setAuthed(true);
|
||||
setView({ name: "list" });
|
||||
}
|
||||
|
||||
function logout() {
|
||||
clearToken();
|
||||
setAuthed(false);
|
||||
setUsername("");
|
||||
}
|
||||
|
||||
if (checking) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center text-gray-500">
|
||||
加载中…
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!authed) return <Login onLoggedIn={onLoggedIn} />;
|
||||
|
||||
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>
|
||||
<div className="flex items-center gap-4 text-sm text-gray-600">
|
||||
<span>{username}</span>
|
||||
<button
|
||||
onClick={logout}
|
||||
className="flex items-center gap-1 rounded px-2 py-1 text-gray-500 hover:bg-gray-100 hover:text-gray-800"
|
||||
>
|
||||
<LogOut className="h-4 w-4" /> 退出
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
<main className="flex-1 overflow-auto p-6">
|
||||
{view.name === "list" ? (
|
||||
<ProductList onOpen={(id) => setView({ name: "detail", id })} />
|
||||
) : (
|
||||
<ProductDetail
|
||||
id={view.id}
|
||||
onBack={() => setView({ name: "list" })}
|
||||
/>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
// API base derives from Vite's BASE_URL (/ping/) so it matches the nginx prefix.
|
||||
const API_BASE = `${import.meta.env.BASE_URL}api`;
|
||||
const TOKEN_KEY = "opengoods_admin_token";
|
||||
|
||||
export function getToken(): string | null {
|
||||
return localStorage.getItem(TOKEN_KEY);
|
||||
}
|
||||
|
||||
export function setToken(token: string) {
|
||||
localStorage.setItem(TOKEN_KEY, token);
|
||||
}
|
||||
|
||||
export function clearToken() {
|
||||
localStorage.removeItem(TOKEN_KEY);
|
||||
}
|
||||
|
||||
export class ApiError extends Error {
|
||||
status: number;
|
||||
constructor(status: number, message: string) {
|
||||
super(message);
|
||||
this.status = status;
|
||||
}
|
||||
}
|
||||
|
||||
async function request<T>(path: string, options: RequestInit = {}): Promise<T> {
|
||||
const headers: Record<string, string> = {
|
||||
"Content-Type": "application/json",
|
||||
...(options.headers as Record<string, string>),
|
||||
};
|
||||
const token = getToken();
|
||||
if (token) headers.Authorization = `Bearer ${token}`;
|
||||
|
||||
const res = await fetch(`${API_BASE}${path}`, { ...options, headers });
|
||||
if (res.status === 401) {
|
||||
clearToken();
|
||||
throw new ApiError(401, "登录已过期,请重新登录");
|
||||
}
|
||||
const text = await res.text();
|
||||
const data = text ? JSON.parse(text) : null;
|
||||
if (!res.ok) {
|
||||
const msg = data?.error?.message || `请求失败 (${res.status})`;
|
||||
throw new ApiError(res.status, msg);
|
||||
}
|
||||
return data as T;
|
||||
}
|
||||
|
||||
export const api = {
|
||||
login: (username: string, password: string) =>
|
||||
request<{ token: string; username: string }>("/login", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ username, password }),
|
||||
}),
|
||||
me: () => request<{ username: string }>("/me"),
|
||||
listProducts: (q: string, page: number, size: number) =>
|
||||
request<{
|
||||
items: import("./types").ProductRow[];
|
||||
page: number;
|
||||
size: number;
|
||||
total: number;
|
||||
completeness_fields: string[];
|
||||
}>(`/products?q=${encodeURIComponent(q)}&page=${page}&size=${size}`),
|
||||
getProduct: (id: string) =>
|
||||
request<import("./types").ProductDetail>(`/products/${id}`),
|
||||
updateProduct: (id: string, body: unknown) =>
|
||||
request<import("./types").ProductDetail>(`/products/${id}`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
listAudit: (id: string) =>
|
||||
request<{ items: import("./types").AuditEntry[] }>(`/products/${id}/audit`),
|
||||
addImage: (id: string, url: string, kind: string) =>
|
||||
request<import("./types").ProductImage>(`/products/${id}/images`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ url, kind }),
|
||||
}),
|
||||
deleteImage: (id: string, imageId: string) =>
|
||||
request<{ status: string }>(`/products/${id}/images/${imageId}`, {
|
||||
method: "DELETE",
|
||||
}),
|
||||
addMsrp: (id: string, body: unknown) =>
|
||||
request<import("./types").MSRP>(`/products/${id}/msrp`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
deleteMsrp: (id: string, msrpId: string) =>
|
||||
request<{ status: string }>(`/products/${id}/msrp/${msrpId}`, {
|
||||
method: "DELETE",
|
||||
}),
|
||||
listBrands: () =>
|
||||
request<{ items: import("./types").Brand[] }>("/brands"),
|
||||
listCategories: () =>
|
||||
request<{ items: import("./types").Category[] }>("/categories"),
|
||||
};
|
||||
@@ -0,0 +1,75 @@
|
||||
import { useState } from "react";
|
||||
import { api, setToken } from "../api";
|
||||
import { Package } from "lucide-react";
|
||||
|
||||
export default function Login({
|
||||
onLoggedIn,
|
||||
}: {
|
||||
onLoggedIn: (username: string) => void;
|
||||
}) {
|
||||
const [username, setUsername] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
async function submit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setError("");
|
||||
setLoading(true);
|
||||
try {
|
||||
const r = await api.login(username, password);
|
||||
setToken(r.token);
|
||||
onLoggedIn(r.username);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "登录失败");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<form
|
||||
onSubmit={submit}
|
||||
className="w-80 rounded-xl bg-white p-8 shadow-md"
|
||||
>
|
||||
<div className="mb-6 flex flex-col items-center gap-2">
|
||||
<Package className="h-8 w-8 text-emerald-600" />
|
||||
<h1 className="text-lg font-semibold text-gray-800">
|
||||
OpenGoods 管理后台
|
||||
</h1>
|
||||
</div>
|
||||
{error && (
|
||||
<div className="mb-4 rounded bg-red-50 px-3 py-2 text-sm text-red-600">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
<label className="mb-3 block">
|
||||
<span className="mb-1 block text-sm text-gray-600">用户名</span>
|
||||
<input
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
className="w-full rounded border border-gray-300 px-3 py-2 text-sm focus:border-emerald-500 focus:outline-none"
|
||||
autoFocus
|
||||
/>
|
||||
</label>
|
||||
<label className="mb-5 block">
|
||||
<span className="mb-1 block text-sm text-gray-600">密码</span>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="w-full rounded border border-gray-300 px-3 py-2 text-sm focus:border-emerald-500 focus:outline-none"
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full rounded bg-emerald-600 py-2 text-sm font-medium text-white hover:bg-emerald-700 disabled:opacity-60"
|
||||
>
|
||||
{loading ? "登录中…" : "登录"}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,642 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { api } from "../api";
|
||||
import {
|
||||
AuditEntry,
|
||||
Brand,
|
||||
Category,
|
||||
FIELD_LABELS,
|
||||
ProductDetail as Detail,
|
||||
} from "../types";
|
||||
import {
|
||||
ArrowLeft,
|
||||
Plus,
|
||||
Save,
|
||||
Trash2,
|
||||
AlertCircle,
|
||||
History,
|
||||
} from "lucide-react";
|
||||
|
||||
const NUTRIMENT_KEYS: { 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)" },
|
||||
];
|
||||
|
||||
const STATUS_OPTIONS = [
|
||||
{ value: "active", label: "在用" },
|
||||
{ value: "merged", label: "已合并" },
|
||||
{ value: "deprecated", label: "已停用" },
|
||||
];
|
||||
|
||||
const ACTION_LABEL: Record<string, string> = {
|
||||
update: "编辑",
|
||||
add_image: "新增图片",
|
||||
delete_image: "删除图片",
|
||||
add_msrp: "新增建议零售价",
|
||||
delete_msrp: "删除建议零售价",
|
||||
};
|
||||
|
||||
function Card({
|
||||
title,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="rounded-lg border border-gray-200 bg-white p-5">
|
||||
<h3 className="mb-4 text-sm font-semibold text-gray-700">{title}</h3>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({
|
||||
label,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<label className="block">
|
||||
<span className="mb-1 block text-xs text-gray-500">{label}</span>
|
||||
{children}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
const inputCls =
|
||||
"w-full rounded border border-gray-300 px-3 py-2 text-sm focus:border-emerald-500 focus:outline-none";
|
||||
|
||||
export default function ProductDetail({
|
||||
id,
|
||||
onBack,
|
||||
}: {
|
||||
id: string;
|
||||
onBack: () => void;
|
||||
}) {
|
||||
const [d, setD] = useState<Detail | null>(null);
|
||||
const [brands, setBrands] = useState<Brand[]>([]);
|
||||
const [categories, setCategories] = useState<Category[]>([]);
|
||||
const [audit, setAudit] = useState<AuditEntry[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [msg, setMsg] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
|
||||
// editable form state
|
||||
const [name, setName] = useState("");
|
||||
const [gtin, setGtin] = useState("");
|
||||
const [brandName, setBrandName] = useState("");
|
||||
const [categoryId, setCategoryId] = useState("");
|
||||
const [netValue, setNetValue] = useState("");
|
||||
const [netUnit, setNetUnit] = useState("");
|
||||
const [country, setCountry] = useState("");
|
||||
const [status, setStatus] = useState("active");
|
||||
const [ingredients, setIngredients] = useState("");
|
||||
const [allergens, setAllergens] = useState("");
|
||||
const [additives, setAdditives] = useState("");
|
||||
const [nutriments, setNutriments] = useState<Record<string, string>>({});
|
||||
const [basis, setBasis] = useState("");
|
||||
const [serving, setServing] = useState("");
|
||||
const [nutriScore, setNutriScore] = useState("");
|
||||
|
||||
function hydrate(detail: Detail) {
|
||||
setD(detail);
|
||||
setName(detail.name);
|
||||
setGtin(detail.gtin || "");
|
||||
setBrandName(detail.brand || "");
|
||||
setCategoryId(detail.category_id || "");
|
||||
setNetValue(detail.net_content_value?.toString() || "");
|
||||
setNetUnit(detail.net_content_unit || "");
|
||||
setCountry(detail.country_of_origin || "");
|
||||
setStatus(detail.status);
|
||||
setIngredients(detail.ingredients_text || "");
|
||||
setAllergens(detail.allergens.join(", "));
|
||||
setAdditives(detail.additives.join(", "));
|
||||
const nm: Record<string, string> = {};
|
||||
if (detail.nutriments) {
|
||||
for (const [k, v] of Object.entries(detail.nutriments)) nm[k] = String(v);
|
||||
}
|
||||
setNutriments(nm);
|
||||
setBasis(detail.nutrition_basis || "");
|
||||
setServing(detail.serving_size || "");
|
||||
setNutriScore(detail.nutri_score || "");
|
||||
}
|
||||
|
||||
function reload() {
|
||||
setLoading(true);
|
||||
Promise.all([api.getProduct(id), api.listAudit(id)])
|
||||
.then(([detail, a]) => {
|
||||
hydrate(detail);
|
||||
setAudit(a.items);
|
||||
})
|
||||
.catch((e) => setError(e.message))
|
||||
.finally(() => setLoading(false));
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
reload();
|
||||
api.listBrands().then((r) => setBrands(r.items)).catch(() => {});
|
||||
api.listCategories().then((r) => setCategories(r.items)).catch(() => {});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [id]);
|
||||
|
||||
const missing = useMemo(() => d?.missing ?? [], [d]);
|
||||
|
||||
function parseList(s: string): string[] {
|
||||
return s
|
||||
.split(",")
|
||||
.map((x) => x.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
async function save() {
|
||||
setSaving(true);
|
||||
setMsg("");
|
||||
setError("");
|
||||
const nm: Record<string, number> = {};
|
||||
for (const [k, v] of Object.entries(nutriments)) {
|
||||
const n = parseFloat(v);
|
||||
if (!Number.isNaN(n)) nm[k] = n;
|
||||
}
|
||||
const body = {
|
||||
gtin: gtin.trim() || null,
|
||||
name: name.trim(),
|
||||
brand_name: brandName.trim() || null,
|
||||
brand_id: brandName.trim() ? undefined : null,
|
||||
category_id: categoryId || null,
|
||||
net_content_value: netValue.trim() ? parseFloat(netValue) : null,
|
||||
net_content_unit: netUnit.trim() || null,
|
||||
country_of_origin: country.trim() || null,
|
||||
status,
|
||||
ingredients_text: ingredients.trim() || null,
|
||||
allergens: parseList(allergens),
|
||||
additives: parseList(additives),
|
||||
nutriments: nm,
|
||||
nutrition_basis: basis || null,
|
||||
serving_size: serving.trim() || null,
|
||||
nutri_score: nutriScore || null,
|
||||
};
|
||||
try {
|
||||
const updated = await api.updateProduct(id, body);
|
||||
hydrate(updated);
|
||||
const a = await api.listAudit(id);
|
||||
setAudit(a.items);
|
||||
setMsg("已保存");
|
||||
setTimeout(() => setMsg(""), 2500);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "保存失败");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return <div className="text-gray-400">加载中…</div>;
|
||||
}
|
||||
if (!d) {
|
||||
return (
|
||||
<div>
|
||||
<button onClick={onBack} className="text-emerald-600">
|
||||
返回
|
||||
</button>
|
||||
<p className="mt-4 text-red-600">{error || "未找到商品"}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-5xl space-y-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<button
|
||||
onClick={onBack}
|
||||
className="flex items-center gap-1 text-sm text-gray-600 hover:text-gray-900"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" /> 返回列表
|
||||
</button>
|
||||
<div className="flex items-center gap-3">
|
||||
{msg && <span className="text-sm text-emerald-600">{msg}</span>}
|
||||
{error && <span className="text-sm text-red-600">{error}</span>}
|
||||
<span className="text-xs text-gray-400">
|
||||
质量分 {Math.round(d.quality_score * 100)}
|
||||
</span>
|
||||
<button
|
||||
onClick={save}
|
||||
disabled={saving}
|
||||
className="flex items-center gap-1 rounded bg-emerald-600 px-4 py-2 text-sm text-white hover:bg-emerald-700 disabled:opacity-60"
|
||||
>
|
||||
<Save className="h-4 w-4" /> {saving ? "保存中…" : "保存"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{missing.length > 0 && (
|
||||
<div className="flex items-center gap-2 rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-700">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
待补全字段:{missing.map((f) => FIELD_LABELS[f] || f).join("、")}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Card title="基础信息">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Field label="名称 *">
|
||||
<input
|
||||
className={inputCls}
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="条码 (GTIN)">
|
||||
<input
|
||||
className={inputCls}
|
||||
value={gtin}
|
||||
onChange={(e) => setGtin(e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="品牌(不存在将自动创建)">
|
||||
<input
|
||||
className={inputCls}
|
||||
list="brand-list"
|
||||
value={brandName}
|
||||
onChange={(e) => setBrandName(e.target.value)}
|
||||
/>
|
||||
<datalist id="brand-list">
|
||||
{brands.map((b) => (
|
||||
<option key={b.id} value={b.name} />
|
||||
))}
|
||||
</datalist>
|
||||
</Field>
|
||||
<Field label="品类">
|
||||
<select
|
||||
className={inputCls}
|
||||
value={categoryId}
|
||||
onChange={(e) => setCategoryId(e.target.value)}
|
||||
>
|
||||
<option value="">(未分类)</option>
|
||||
{categories.map((c) => (
|
||||
<option key={c.id} value={c.id}>
|
||||
{"\u00A0".repeat(c.level * 2)}
|
||||
{c.name_zh} ({c.path})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
<Field label="净含量">
|
||||
<input
|
||||
className={inputCls}
|
||||
type="number"
|
||||
step="any"
|
||||
value={netValue}
|
||||
onChange={(e) => setNetValue(e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="净含量单位 (g/ml/cl…)">
|
||||
<input
|
||||
className={inputCls}
|
||||
value={netUnit}
|
||||
onChange={(e) => setNetUnit(e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="产地">
|
||||
<input
|
||||
className={inputCls}
|
||||
value={country}
|
||||
onChange={(e) => setCountry(e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="状态">
|
||||
<select
|
||||
className={inputCls}
|
||||
value={status}
|
||||
onChange={(e) => setStatus(e.target.value)}
|
||||
>
|
||||
{STATUS_OPTIONS.map((o) => (
|
||||
<option key={o.value} value={o.value}>
|
||||
{o.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card title="配料与营养">
|
||||
<div className="mb-4 grid grid-cols-2 gap-4">
|
||||
<Field label="配料表">
|
||||
<textarea
|
||||
className={inputCls}
|
||||
rows={3}
|
||||
value={ingredients}
|
||||
onChange={(e) => setIngredients(e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Field label="过敏原(逗号分隔)">
|
||||
<input
|
||||
className={inputCls}
|
||||
value={allergens}
|
||||
onChange={(e) => setAllergens(e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="添加剂(逗号分隔)">
|
||||
<input
|
||||
className={inputCls}
|
||||
value={additives}
|
||||
onChange={(e) => setAdditives(e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="营养基准">
|
||||
<select
|
||||
className={inputCls}
|
||||
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>
|
||||
</Field>
|
||||
<Field label="份量">
|
||||
<input
|
||||
className={inputCls}
|
||||
value={serving}
|
||||
onChange={(e) => setServing(e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Nutri-Score (A-E)">
|
||||
<input
|
||||
className={inputCls}
|
||||
maxLength={1}
|
||||
value={nutriScore}
|
||||
onChange={(e) =>
|
||||
setNutriScore(e.target.value.toUpperCase())
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-4 gap-3">
|
||||
{NUTRIMENT_KEYS.map((n) => (
|
||||
<Field key={n.key} label={n.label}>
|
||||
<input
|
||||
className={inputCls}
|
||||
type="number"
|
||||
step="any"
|
||||
value={nutriments[n.key] ?? ""}
|
||||
onChange={(e) =>
|
||||
setNutriments((prev) => ({ ...prev, [n.key]: e.target.value }))
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<ImagesCard
|
||||
product={d}
|
||||
onChange={reload}
|
||||
onError={setError}
|
||||
/>
|
||||
<MsrpCard product={d} onChange={reload} onError={setError} />
|
||||
|
||||
<Card title="操作记录">
|
||||
{audit.length === 0 ? (
|
||||
<p className="text-sm text-gray-400">暂无记录</p>
|
||||
) : (
|
||||
<ul className="space-y-2 text-sm">
|
||||
{audit.map((a) => (
|
||||
<li
|
||||
key={a.id}
|
||||
className="flex items-center gap-3 text-gray-600"
|
||||
>
|
||||
<History className="h-3.5 w-3.5 text-gray-400" />
|
||||
<span className="text-gray-400">{a.created_at}</span>
|
||||
<span className="font-medium text-gray-700">{a.actor}</span>
|
||||
<span>{ACTION_LABEL[a.action] || a.action}</span>
|
||||
{a.fields.length > 0 && (
|
||||
<span className="text-gray-400">
|
||||
[{a.fields.map((f) => FIELD_LABELS[f] || f).join("、")}]
|
||||
</span>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ImagesCard({
|
||||
product,
|
||||
onChange,
|
||||
onError,
|
||||
}: {
|
||||
product: Detail;
|
||||
onChange: () => void;
|
||||
onError: (m: string) => void;
|
||||
}) {
|
||||
const [url, setUrl] = useState("");
|
||||
const [kind, setKind] = useState("front");
|
||||
|
||||
async function add() {
|
||||
if (!url.trim()) return;
|
||||
try {
|
||||
await api.addImage(product.id, url.trim(), kind);
|
||||
setUrl("");
|
||||
onChange();
|
||||
} catch (e) {
|
||||
onError(e instanceof Error ? e.message : "添加失败");
|
||||
}
|
||||
}
|
||||
async function remove(imageId: string) {
|
||||
try {
|
||||
await api.deleteImage(product.id, imageId);
|
||||
onChange();
|
||||
} catch (e) {
|
||||
onError(e instanceof Error ? e.message : "删除失败");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Card title="图片(仅存 URL)">
|
||||
<div className="mb-3 flex flex-wrap gap-3">
|
||||
{product.images.length === 0 && (
|
||||
<span className="text-sm text-gray-400">暂无图片</span>
|
||||
)}
|
||||
{product.images.map((im) => (
|
||||
<div
|
||||
key={im.id}
|
||||
className="relative h-24 w-24 overflow-hidden rounded border border-gray-200"
|
||||
>
|
||||
<img
|
||||
src={im.url}
|
||||
alt={im.kind}
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
<button
|
||||
onClick={() => remove(im.id)}
|
||||
className="absolute right-1 top-1 rounded bg-black/50 p-1 text-white hover:bg-black/70"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</button>
|
||||
<span className="absolute bottom-0 left-0 bg-black/50 px-1 text-[10px] text-white">
|
||||
{im.kind}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
className={inputCls}
|
||||
placeholder="图片 URL"
|
||||
value={url}
|
||||
onChange={(e) => setUrl(e.target.value)}
|
||||
/>
|
||||
<select
|
||||
className="rounded border border-gray-300 px-2 py-2 text-sm"
|
||||
value={kind}
|
||||
onChange={(e) => setKind(e.target.value)}
|
||||
>
|
||||
<option value="front">正面</option>
|
||||
<option value="ingredients">配料</option>
|
||||
<option value="nutrition">营养</option>
|
||||
<option value="other">其他</option>
|
||||
</select>
|
||||
<button
|
||||
onClick={add}
|
||||
className="flex items-center gap-1 whitespace-nowrap rounded bg-gray-700 px-3 py-2 text-sm text-white hover:bg-gray-800"
|
||||
>
|
||||
<Plus className="h-4 w-4" /> 添加
|
||||
</button>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function MsrpCard({
|
||||
product,
|
||||
onChange,
|
||||
onError,
|
||||
}: {
|
||||
product: Detail;
|
||||
onChange: () => void;
|
||||
onError: (m: string) => void;
|
||||
}) {
|
||||
const [amount, setAmount] = useState("");
|
||||
const [currency, setCurrency] = useState("CNY");
|
||||
const [region, setRegion] = useState("CN");
|
||||
const [date, setDate] = useState("");
|
||||
const [note, setNote] = useState("");
|
||||
|
||||
async function add() {
|
||||
const a = parseFloat(amount);
|
||||
if (Number.isNaN(a)) return;
|
||||
try {
|
||||
await api.addMsrp(product.id, {
|
||||
amount: a,
|
||||
currency,
|
||||
region,
|
||||
effective_date: date || null,
|
||||
note: note.trim() || null,
|
||||
});
|
||||
setAmount("");
|
||||
setNote("");
|
||||
setDate("");
|
||||
onChange();
|
||||
} catch (e) {
|
||||
onError(e instanceof Error ? e.message : "添加失败");
|
||||
}
|
||||
}
|
||||
async function remove(msrpId: string) {
|
||||
try {
|
||||
await api.deleteMsrp(product.id, msrpId);
|
||||
onChange();
|
||||
} catch (e) {
|
||||
onError(e instanceof Error ? e.message : "删除失败");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Card title="官方建议零售价(MSRP 快照,非售卖)">
|
||||
<div className="mb-3 space-y-2">
|
||||
{product.msrp.length === 0 && (
|
||||
<span className="text-sm text-gray-400">暂无记录</span>
|
||||
)}
|
||||
{product.msrp.map((m) => (
|
||||
<div
|
||||
key={m.id}
|
||||
className="flex items-center gap-3 rounded border border-gray-100 bg-gray-50 px-3 py-2 text-sm"
|
||||
>
|
||||
<span className="font-medium text-gray-800">
|
||||
{m.amount} {m.currency}
|
||||
</span>
|
||||
<span className="text-gray-500">{m.region}</span>
|
||||
<span className="text-gray-400">{m.effective_date || ""}</span>
|
||||
<span className="flex-1 text-gray-400">{m.note || ""}</span>
|
||||
<button
|
||||
onClick={() => remove(m.id)}
|
||||
className="text-gray-400 hover:text-red-600"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex flex-wrap items-end gap-2">
|
||||
<Field label="金额">
|
||||
<input
|
||||
className="w-28 rounded border border-gray-300 px-3 py-2 text-sm"
|
||||
type="number"
|
||||
step="any"
|
||||
value={amount}
|
||||
onChange={(e) => setAmount(e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="币种">
|
||||
<input
|
||||
className="w-20 rounded border border-gray-300 px-3 py-2 text-sm"
|
||||
value={currency}
|
||||
onChange={(e) => setCurrency(e.target.value.toUpperCase())}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="地区">
|
||||
<input
|
||||
className="w-20 rounded border border-gray-300 px-3 py-2 text-sm"
|
||||
value={region}
|
||||
onChange={(e) => setRegion(e.target.value.toUpperCase())}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="生效日期">
|
||||
<input
|
||||
className="rounded border border-gray-300 px-3 py-2 text-sm"
|
||||
type="date"
|
||||
value={date}
|
||||
onChange={(e) => setDate(e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="备注">
|
||||
<input
|
||||
className="w-40 rounded border border-gray-300 px-3 py-2 text-sm"
|
||||
value={note}
|
||||
onChange={(e) => setNote(e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<button
|
||||
onClick={add}
|
||||
className="flex items-center gap-1 rounded bg-gray-700 px-3 py-2 text-sm text-white hover:bg-gray-800"
|
||||
>
|
||||
<Plus className="h-4 w-4" /> 添加
|
||||
</button>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { api } from "../api";
|
||||
import { FIELD_LABELS, ProductRow } from "../types";
|
||||
import { Search, AlertCircle } from "lucide-react";
|
||||
|
||||
const STATUS_LABEL: Record<string, string> = {
|
||||
active: "在用",
|
||||
merged: "已合并",
|
||||
deprecated: "已停用",
|
||||
};
|
||||
|
||||
function QualityBadge({ score }: { score: number }) {
|
||||
const pct = Math.round(score * 100);
|
||||
const color =
|
||||
score >= 0.8
|
||||
? "bg-emerald-100 text-emerald-700"
|
||||
: score >= 0.5
|
||||
? "bg-amber-100 text-amber-700"
|
||||
: "bg-red-100 text-red-700";
|
||||
return (
|
||||
<span className={`rounded px-2 py-0.5 text-xs font-medium ${color}`}>
|
||||
{pct}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ProductList({
|
||||
onOpen,
|
||||
}: {
|
||||
onOpen: (id: string) => void;
|
||||
}) {
|
||||
const [q, setQ] = useState("");
|
||||
const [input, setInput] = useState("");
|
||||
const [page, setPage] = useState(1);
|
||||
const [size] = useState(20);
|
||||
const [rows, setRows] = useState<ProductRow[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
api
|
||||
.listProducts(q, page, size)
|
||||
.then((r) => {
|
||||
setRows(r.items);
|
||||
setTotal(r.total);
|
||||
})
|
||||
.catch((e) => setError(e.message))
|
||||
.finally(() => setLoading(false));
|
||||
}, [q, page, size]);
|
||||
|
||||
const pages = Math.max(1, Math.ceil(total / size));
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-6xl">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h2 className="text-xl font-semibold text-gray-800">
|
||||
商品档案 <span className="text-sm font-normal text-gray-400">共 {total} 条</span>
|
||||
</h2>
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
setPage(1);
|
||||
setQ(input.trim());
|
||||
}}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2 top-2.5 h-4 w-4 text-gray-400" />
|
||||
<input
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
placeholder="按名称 / 条码搜索"
|
||||
className="w-64 rounded border border-gray-300 py-2 pl-8 pr-3 text-sm focus:border-emerald-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
<button className="rounded bg-emerald-600 px-3 py-2 text-sm text-white hover:bg-emerald-700">
|
||||
搜索
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mb-3 rounded bg-red-50 px-3 py-2 text-sm text-red-600">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="overflow-hidden rounded-lg border border-gray-200 bg-white">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-gray-50 text-left text-xs uppercase text-gray-500">
|
||||
<tr>
|
||||
<th className="px-4 py-3">名称</th>
|
||||
<th className="px-4 py-3">品牌</th>
|
||||
<th className="px-4 py-3">条码</th>
|
||||
<th className="px-4 py-3">品类</th>
|
||||
<th className="px-4 py-3">状态</th>
|
||||
<th className="px-4 py-3">质量分</th>
|
||||
<th className="px-4 py-3">缺失字段</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100">
|
||||
{loading ? (
|
||||
<tr>
|
||||
<td colSpan={7} className="px-4 py-8 text-center text-gray-400">
|
||||
加载中…
|
||||
</td>
|
||||
</tr>
|
||||
) : rows.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={7} className="px-4 py-8 text-center text-gray-400">
|
||||
暂无数据
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
rows.map((r) => (
|
||||
<tr
|
||||
key={r.id}
|
||||
onClick={() => onOpen(r.id)}
|
||||
className="cursor-pointer hover:bg-emerald-50/50"
|
||||
>
|
||||
<td className="px-4 py-3 font-medium text-gray-800">{r.name}</td>
|
||||
<td className="px-4 py-3 text-gray-600">{r.brand || "—"}</td>
|
||||
<td className="px-4 py-3 font-mono text-xs text-gray-500">
|
||||
{r.gtin || "—"}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-xs text-gray-500">
|
||||
{r.category_path || "—"}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-gray-600">
|
||||
{STATUS_LABEL[r.status] || r.status}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<QualityBadge score={r.quality_score} />
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
{r.missing.length === 0 ? (
|
||||
<span className="text-xs text-emerald-600">完整</span>
|
||||
) : (
|
||||
<span className="flex items-center gap-1 text-xs text-amber-600">
|
||||
<AlertCircle className="h-3.5 w-3.5" />
|
||||
{r.missing
|
||||
.map((f) => FIELD_LABELS[f] || f)
|
||||
.join("、")}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex items-center justify-end gap-2 text-sm text-gray-600">
|
||||
<button
|
||||
disabled={page <= 1}
|
||||
onClick={() => setPage((p) => p - 1)}
|
||||
className="rounded border border-gray-300 px-3 py-1 disabled:opacity-50"
|
||||
>
|
||||
上一页
|
||||
</button>
|
||||
<span>
|
||||
{page} / {pages}
|
||||
</span>
|
||||
<button
|
||||
disabled={page >= pages}
|
||||
onClick={() => setPage((p) => p + 1)}
|
||||
className="rounded border border-gray-300 px-3 py-1 disabled:opacity-50"
|
||||
>
|
||||
下一页
|
||||
</button>
|
||||
</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,87 @@
|
||||
export interface ProductRow {
|
||||
id: string;
|
||||
gtin: string | null;
|
||||
name: string;
|
||||
brand: string | null;
|
||||
category_path: string | null;
|
||||
status: string;
|
||||
quality_score: number;
|
||||
missing: string[];
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface ProductImage {
|
||||
id: string;
|
||||
url: string;
|
||||
kind: string;
|
||||
license: string | null;
|
||||
}
|
||||
|
||||
export interface MSRP {
|
||||
id: string;
|
||||
amount: number;
|
||||
currency: string;
|
||||
region: string;
|
||||
effective_date: string | null;
|
||||
source_url: string | null;
|
||||
note: string | null;
|
||||
}
|
||||
|
||||
export interface ProductDetail {
|
||||
id: string;
|
||||
gtin: string | null;
|
||||
name: string;
|
||||
brand_id: string | null;
|
||||
brand: string | null;
|
||||
category_id: string | null;
|
||||
category_path: string | null;
|
||||
net_content_value: number | null;
|
||||
net_content_unit: string | null;
|
||||
country_of_origin: string | null;
|
||||
status: string;
|
||||
quality_score: number;
|
||||
ingredients_text: string | null;
|
||||
allergens: string[];
|
||||
additives: string[];
|
||||
nutriments: Record<string, number> | null;
|
||||
nutrition_basis: string | null;
|
||||
serving_size: string | null;
|
||||
nutri_score: string | null;
|
||||
images: ProductImage[];
|
||||
msrp: MSRP[];
|
||||
missing: string[];
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface Brand {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface Category {
|
||||
id: string;
|
||||
name_zh: string;
|
||||
name_en: string | null;
|
||||
path: string;
|
||||
level: number;
|
||||
}
|
||||
|
||||
export interface AuditEntry {
|
||||
id: string;
|
||||
actor: string;
|
||||
action: string;
|
||||
fields: string[];
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export const FIELD_LABELS: Record<string, string> = {
|
||||
name: "名称",
|
||||
gtin: "条码",
|
||||
brand: "品牌",
|
||||
category: "品类",
|
||||
net_content: "净含量",
|
||||
country_of_origin: "产地",
|
||||
nutriments: "营养成分",
|
||||
ingredients: "配料",
|
||||
image: "图片",
|
||||
};
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
Reference in New Issue
Block a user