feat: 数据概览/操作日志/批量操作 + 首页合格档案数
CI / Go (api) (pull_request) Successful in 13s
CI / Python (ingestion) (pull_request) Successful in 9s
CI / Migrations (postgres) (pull_request) Successful in 14s

后台新增「数据概览」(商品/合格/按状态/品牌/分类/待审核) 与「操作日志」(全局审计分页);商品列表支持多选批量改状态/分类。公开首页标题改为「天工」并展示合格档案数;新增公开接口 /api/v1/stats 与后台 /api/stats、/api/audit、/api/products/bulk。合格口径=quality_score≥0.6 且在用。

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
sulaimaannaasif6866
2026-06-21 01:45:28 +00:00
parent a36700076e
commit f382c27200
15 changed files with 847 additions and 9 deletions
+36 -3
View File
@@ -7,9 +7,18 @@ import SubmissionsPage from "./components/SubmissionsPage";
import ApiKeysPage from "./components/ApiKeysPage";
import CategoriesPage from "./components/CategoriesPage";
import BrandsPage from "./components/BrandsPage";
import { FolderTree, Inbox, KeyRound, LogOut, Package, Tag } from "lucide-react";
import StatsPage from "./components/StatsPage";
import AuditLogPage from "./components/AuditLogPage";
import { BarChart3, FolderTree, Inbox, KeyRound, LogOut, Package, ScrollText, Tag } from "lucide-react";
type Tab = "products" | "submissions" | "categories" | "brands" | "keys";
type Tab =
| "overview"
| "products"
| "submissions"
| "categories"
| "brands"
| "audit"
| "keys";
type View = { name: "list" } | { name: "detail"; id: string };
export default function App() {
@@ -74,6 +83,16 @@ export default function App() {
·
</div>
<nav className="flex items-center gap-1 text-sm">
<button
onClick={() => setTab("overview")}
className={`px-3 py-1.5 rounded-md flex items-center gap-1.5 ${
tab === "overview"
? "bg-emerald-50 text-emerald-700"
: "text-gray-600 hover:bg-gray-100"
}`}
>
<BarChart3 className="h-4 w-4" />
</button>
<button
onClick={() => {
setTab("products");
@@ -125,6 +144,16 @@ export default function App() {
>
<Tag className="h-4 w-4" />
</button>
<button
onClick={() => setTab("audit")}
className={`px-3 py-1.5 rounded-md flex items-center gap-1.5 ${
tab === "audit"
? "bg-emerald-50 text-emerald-700"
: "text-gray-600 hover:bg-gray-100"
}`}
>
<ScrollText className="h-4 w-4" />
</button>
<button
onClick={() => setTab("keys")}
className={`px-3 py-1.5 rounded-md flex items-center gap-1.5 ${
@@ -148,7 +177,11 @@ export default function App() {
</div>
</header>
<main className="flex-1 overflow-auto p-6">
{tab === "keys" ? (
{tab === "overview" ? (
<StatsPage />
) : tab === "audit" ? (
<AuditLogPage />
) : tab === "keys" ? (
<ApiKeysPage />
) : tab === "categories" ? (
<CategoriesPage />
+18
View File
@@ -171,4 +171,22 @@ export const api = {
),
revokeApiKey: (id: string) =>
request<{ status: string }>(`/keys/${id}`, { method: "DELETE" }),
stats: () => request<import("./types").AdminStats>("/stats"),
auditLog: (page: number, size: number) =>
request<{
items: import("./types").AuditLogRow[];
page: number;
size: number;
total: number;
}>(`/audit?page=${page}&size=${size}`),
bulkProducts: (body: {
ids: string[];
action: "status" | "category";
status?: string;
category_id?: string | null;
}) =>
request<{ status: string; affected: number }>("/products/bulk", {
method: "POST",
body: JSON.stringify(body),
}),
};
@@ -0,0 +1,115 @@
import { useEffect, useState } from "react";
import { api, ApiError } from "../api";
import type { AuditLogRow } from "../types";
import { ScrollText } from "lucide-react";
const ACTION_LABEL: Record<string, string> = {
create: "新建",
update: "修改",
delete: "删除",
add_image: "添加图片",
delete_image: "删除图片",
bulk_status: "批量改状态",
bulk_category: "批量改分类",
};
const ENTITY_LABEL: Record<string, string> = {
product: "商品",
category: "分类",
brand: "品牌",
};
export default function AuditLogPage() {
const [rows, setRows] = useState<AuditLogRow[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const size = 30;
const [error, setError] = useState("");
useEffect(() => {
setError("");
api
.auditLog(page, size)
.then((r) => {
setRows(r.items);
setTotal(r.total);
})
.catch((e) => setError(e instanceof ApiError ? e.message : "加载失败"));
}, [page]);
const pages = Math.max(1, Math.ceil(total / size));
return (
<div className="max-w-5xl">
<h2 className="mb-4 flex items-center gap-2 text-lg font-semibold text-gray-800">
<ScrollText className="h-5 w-5 text-emerald-600" />
<span className="text-sm font-normal text-gray-400"> {total} </span>
</h2>
{error && (
<div className="mb-3 rounded bg-red-50 px-4 py-2 text-sm text-red-700">{error}</div>
)}
<div className="overflow-hidden rounded-lg border 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-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={5} className="px-4 py-8 text-center text-gray-400">
</td>
</tr>
) : (
rows.map((e) => (
<tr key={e.id} className="hover:bg-gray-50">
<td className="whitespace-nowrap px-4 py-2 text-gray-500">
{new Date(e.created_at).toLocaleString()}
</td>
<td className="px-4 py-2 text-gray-700">{e.actor}</td>
<td className="px-4 py-2 text-gray-700">
{ACTION_LABEL[e.action] || e.action}
</td>
<td className="px-4 py-2 text-gray-600">
{ENTITY_LABEL[e.entity] || e.entity}
</td>
<td className="px-4 py-2 text-xs text-gray-400">
{e.fields.length ? e.fields.join(", ") : "—"}
</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>
);
}
+151 -4
View File
@@ -38,8 +38,13 @@ export default function ProductList({
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const [creating, setCreating] = useState(false);
const [selected, setSelected] = useState<Set<string>>(new Set());
const [categories, setCategories] = useState<Category[]>([]);
const [bulkStatus, setBulkStatus] = useState("");
const [bulkCategory, setBulkCategory] = useState("");
const [bulkBusy, setBulkBusy] = useState(false);
useEffect(() => {
function reload() {
setLoading(true);
setError("");
api
@@ -50,8 +55,72 @@ export default function ProductList({
})
.catch((e) => setError(e.message))
.finally(() => setLoading(false));
}
useEffect(() => {
setSelected(new Set());
reload();
}, [q, page, size]);
useEffect(() => {
api.listCategories().then((r) => setCategories(r.items)).catch(() => {});
}, []);
function toggle(id: string) {
setSelected((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
}
function toggleAll() {
setSelected((prev) =>
prev.size === rows.length ? new Set() : new Set(rows.map((r) => r.id)),
);
}
async function applyBulkStatus() {
if (!bulkStatus || selected.size === 0) return;
setBulkBusy(true);
setError("");
try {
await api.bulkProducts({
ids: [...selected],
action: "status",
status: bulkStatus,
});
setSelected(new Set());
setBulkStatus("");
reload();
} catch (e) {
setError(e instanceof ApiError ? e.message : "批量操作失败");
} finally {
setBulkBusy(false);
}
}
async function applyBulkCategory() {
if (selected.size === 0) return;
setBulkBusy(true);
setError("");
try {
await api.bulkProducts({
ids: [...selected],
action: "category",
category_id: bulkCategory || null,
});
setSelected(new Set());
setBulkCategory("");
reload();
} catch (e) {
setError(e instanceof ApiError ? e.message : "批量操作失败");
} finally {
setBulkBusy(false);
}
}
const pages = Math.max(1, Math.ceil(total / size));
return (
@@ -106,10 +175,75 @@ export default function ProductList({
</div>
)}
{selected.size > 0 && (
<div className="mb-3 flex flex-wrap items-center gap-3 rounded-lg border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm">
<span className="font-medium text-emerald-800">
{selected.size}
</span>
<div className="flex items-center gap-1.5">
<span className="text-gray-500"></span>
<select
value={bulkStatus}
onChange={(e) => setBulkStatus(e.target.value)}
className="rounded border border-gray-300 bg-white px-2 py-1"
>
<option value=""></option>
<option value="active"></option>
<option value="deprecated"></option>
<option value="merged"></option>
</select>
<button
onClick={applyBulkStatus}
disabled={bulkBusy || !bulkStatus}
className="rounded bg-emerald-600 px-3 py-1 text-white hover:bg-emerald-700 disabled:opacity-50"
>
</button>
</div>
<div className="flex items-center gap-1.5">
<span className="text-gray-500"></span>
<select
value={bulkCategory}
onChange={(e) => setBulkCategory(e.target.value)}
className="rounded border border-gray-300 bg-white px-2 py-1"
>
<option value=""></option>
{categories.map((c) => (
<option key={c.id} value={c.id}>
{"\u00A0".repeat(c.level * 2)}
{c.name_zh}
</option>
))}
</select>
<button
onClick={applyBulkCategory}
disabled={bulkBusy}
className="rounded bg-emerald-600 px-3 py-1 text-white hover:bg-emerald-700 disabled:opacity-50"
>
</button>
</div>
<button
onClick={() => setSelected(new Set())}
className="text-gray-500 hover:text-gray-700"
>
</button>
</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="w-10 px-4 py-3">
<input
type="checkbox"
checked={rows.length > 0 && selected.size === rows.length}
onChange={toggleAll}
aria-label="全选"
/>
</th>
<th className="px-4 py-3"></th>
<th className="px-4 py-3"></th>
<th className="px-4 py-3"></th>
@@ -122,13 +256,13 @@ export default function ProductList({
<tbody className="divide-y divide-gray-100">
{loading ? (
<tr>
<td colSpan={7} className="px-4 py-8 text-center text-gray-400">
<td colSpan={8} 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 colSpan={8} className="px-4 py-8 text-center text-gray-400">
</td>
</tr>
@@ -137,8 +271,21 @@ export default function ProductList({
<tr
key={r.id}
onClick={() => onOpen(r.id)}
className="cursor-pointer hover:bg-emerald-50/50"
className={`cursor-pointer hover:bg-emerald-50/50 ${
selected.has(r.id) ? "bg-emerald-50/60" : ""
}`}
>
<td
className="px-4 py-3"
onClick={(e) => e.stopPropagation()}
>
<input
type="checkbox"
checked={selected.has(r.id)}
onChange={() => toggle(r.id)}
aria-label="选择"
/>
</td>
<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">
+125
View File
@@ -0,0 +1,125 @@
import { useEffect, useState } from "react";
import { api, ApiError } from "../api";
import type { AdminStats } from "../types";
import { BarChart3, Package, CheckCircle2, Tag, FolderTree, Inbox } from "lucide-react";
const STATUS_LABEL: Record<string, string> = {
active: "在用",
merged: "已合并",
deprecated: "已停用",
};
function Card({
icon,
label,
value,
hint,
}: {
icon: React.ReactNode;
label: string;
value: string | number;
hint?: string;
}) {
return (
<div className="rounded-lg border bg-white p-5">
<div className="flex items-center gap-2 text-sm text-gray-500">
{icon}
{label}
</div>
<div className="mt-2 text-2xl font-semibold text-gray-800">{value}</div>
{hint && <div className="mt-1 text-xs text-gray-400">{hint}</div>}
</div>
);
}
export default function StatsPage() {
const [stats, setStats] = useState<AdminStats | null>(null);
const [error, setError] = useState("");
useEffect(() => {
api
.stats()
.then(setStats)
.catch((e) => setError(e instanceof ApiError ? e.message : "加载失败"));
}, []);
return (
<div className="max-w-5xl">
<h2 className="mb-4 flex items-center gap-2 text-lg font-semibold text-gray-800">
<BarChart3 className="h-5 w-5 text-emerald-600" />
</h2>
{error && (
<div className="mb-3 rounded bg-red-50 px-4 py-2 text-sm text-red-700">{error}</div>
)}
{!stats ? (
<div className="text-sm text-gray-400"></div>
) : (
<>
<div className="grid grid-cols-2 gap-4 md:grid-cols-3">
<Card
icon={<Package className="h-4 w-4" />}
label="商品总数"
value={stats.products.toLocaleString()}
/>
<Card
icon={<CheckCircle2 className="h-4 w-4 text-emerald-600" />}
label="合格档案"
value={stats.qualified.toLocaleString()}
hint={`质量分 ≥ ${stats.min_score} 且在用`}
/>
<Card
icon={<BarChart3 className="h-4 w-4" />}
label="平均质量分"
value={Math.round(stats.avg_quality * 100)}
hint="满分 100"
/>
<Card
icon={<Tag className="h-4 w-4" />}
label="品牌数"
value={stats.brands.toLocaleString()}
/>
<Card
icon={<FolderTree className="h-4 w-4" />}
label="分类数"
value={stats.categories.toLocaleString()}
/>
<Card
icon={<Inbox className="h-4 w-4" />}
label="待审核投稿"
value={stats.pending_submissions.toLocaleString()}
/>
</div>
<div className="mt-6 rounded-lg border bg-white p-5">
<h3 className="mb-3 text-sm font-medium text-gray-700"></h3>
<div className="space-y-2">
{Object.keys(stats.by_status).length === 0 ? (
<div className="text-sm text-gray-400"></div>
) : (
Object.entries(stats.by_status).map(([st, n]) => {
const pct = stats.products > 0 ? (n / stats.products) * 100 : 0;
return (
<div key={st} className="flex items-center gap-3 text-sm">
<span className="w-16 text-gray-600">
{STATUS_LABEL[st] || st}
</span>
<div className="h-3 flex-1 overflow-hidden rounded bg-gray-100">
<div
className="h-full bg-emerald-500"
style={{ width: `${pct}%` }}
/>
</div>
<span className="w-12 text-right text-gray-500">{n}</span>
</div>
);
})
)}
</div>
</div>
</>
)}
</div>
);
}
+21
View File
@@ -96,6 +96,27 @@ export interface AuditEntry {
created_at: string;
}
export interface AuditLogRow {
id: string;
actor: string;
action: string;
entity: string;
entity_id: string | null;
fields: string[];
created_at: string;
}
export interface AdminStats {
products: number;
qualified: number;
min_score: number;
by_status: Record<string, number>;
brands: number;
categories: number;
pending_submissions: number;
avg_quality: number;
}
export interface SubmissionRow {
id: string;
gtin: string | null;