feat: 数据概览/操作日志/批量操作 + 首页合格档案数
后台新增「数据概览」(商品/合格/按状态/品牌/分类/待审核) 与「操作日志」(全局审计分页);商品列表支持多选批量改状态/分类。公开首页标题改为「天工」并展示合格档案数;新增公开接口 /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:
@@ -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 />
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user