refactor: 重构管理后台为现代化编辑风 UI 并改用模态交互
- 参考收集端落地页的奶油纸质感 + 深海蓝侧栏 + Playfair Display + 金/陶/玉配色,重塑整体视觉 - 编辑、二维码从跳转路由改为模态弹窗,新增"复制链接"快捷操作 - 抽取 Modal / Toast / QRCodeModal / PageHeader / FormPrimitives 通用基建 - 合并三份 QRCode 页面为统一组件,精简路由配置 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -8,16 +8,9 @@ import AdminLogin from "./admin/AdminLogin";
|
|||||||
import AdminGuard from "./admin/AdminGuard";
|
import AdminGuard from "./admin/AdminGuard";
|
||||||
import AdminLayout from "./admin/AdminLayout";
|
import AdminLayout from "./admin/AdminLayout";
|
||||||
import StampList from "./admin/StampList";
|
import StampList from "./admin/StampList";
|
||||||
import StampForm from "./admin/StampForm";
|
|
||||||
import StampQRCode from "./admin/StampQRCode";
|
|
||||||
import ArticleList from "./admin/ArticleList";
|
import ArticleList from "./admin/ArticleList";
|
||||||
import ArticleForm from "./admin/ArticleForm";
|
|
||||||
import ArticleQRCode from "./admin/ArticleQRCode";
|
|
||||||
import MusicList from "./admin/MusicList";
|
import MusicList from "./admin/MusicList";
|
||||||
import MusicForm from "./admin/MusicForm";
|
|
||||||
import MusicQRCode from "./admin/MusicQRCode";
|
|
||||||
import RuleList from "./admin/RuleList";
|
import RuleList from "./admin/RuleList";
|
||||||
import RuleForm from "./admin/RuleForm";
|
|
||||||
import RedemptionLog from "./admin/RedemptionLog";
|
import RedemptionLog from "./admin/RedemptionLog";
|
||||||
|
|
||||||
function CollectRedirect() {
|
function CollectRedirect() {
|
||||||
@@ -41,20 +34,9 @@ export default function App() {
|
|||||||
<Route element={<AdminGuard />}>
|
<Route element={<AdminGuard />}>
|
||||||
<Route element={<AdminLayout />}>
|
<Route element={<AdminLayout />}>
|
||||||
<Route path="/admin/stamps" element={<StampList />} />
|
<Route path="/admin/stamps" element={<StampList />} />
|
||||||
<Route path="/admin/stamps/new" element={<StampForm />} />
|
|
||||||
<Route path="/admin/stamps/:id/edit" element={<StampForm />} />
|
|
||||||
<Route path="/admin/stamps/:id/qrcode" element={<StampQRCode />} />
|
|
||||||
<Route path="/admin/articles" element={<ArticleList />} />
|
<Route path="/admin/articles" element={<ArticleList />} />
|
||||||
<Route path="/admin/articles/new" element={<ArticleForm />} />
|
|
||||||
<Route path="/admin/articles/:id/edit" element={<ArticleForm />} />
|
|
||||||
<Route path="/admin/articles/:id/qrcode" element={<ArticleQRCode />} />
|
|
||||||
<Route path="/admin/music" element={<MusicList />} />
|
<Route path="/admin/music" element={<MusicList />} />
|
||||||
<Route path="/admin/music/new" element={<MusicForm />} />
|
|
||||||
<Route path="/admin/music/:id/edit" element={<MusicForm />} />
|
|
||||||
<Route path="/admin/music/:id/qrcode" element={<MusicQRCode />} />
|
|
||||||
<Route path="/admin/rules" element={<RuleList />} />
|
<Route path="/admin/rules" element={<RuleList />} />
|
||||||
<Route path="/admin/rules/new" element={<RuleForm />} />
|
|
||||||
<Route path="/admin/rules/:id/edit" element={<RuleForm />} />
|
|
||||||
<Route path="/admin/redemptions" element={<RedemptionLog />} />
|
<Route path="/admin/redemptions" element={<RedemptionLog />} />
|
||||||
</Route>
|
</Route>
|
||||||
</Route>
|
</Route>
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import { NavLink, Outlet, useNavigate } from "react-router-dom";
|
import { NavLink, Outlet, useNavigate } from "react-router-dom";
|
||||||
|
import { ToastProvider } from "./Toast";
|
||||||
|
|
||||||
const navItems = [
|
const navItems = [
|
||||||
{ path: "/admin/stamps", label: "图章管理" },
|
{ path: "/admin/stamps", label: "图章管理", eyebrow: "01", tag: "Stamps" },
|
||||||
{ path: "/admin/articles", label: "文章管理" },
|
{ path: "/admin/articles", label: "文章管理", eyebrow: "02", tag: "Articles" },
|
||||||
{ path: "/admin/music", label: "音乐管理" },
|
{ path: "/admin/music", label: "音乐管理", eyebrow: "03", tag: "Music" },
|
||||||
{ path: "/admin/rules", label: "兑换规则" },
|
{ path: "/admin/rules", label: "兑换规则", eyebrow: "04", tag: "Rules" },
|
||||||
{ path: "/admin/redemptions", label: "兑换记录" },
|
{ path: "/admin/redemptions", label: "兑换记录", eyebrow: "05", tag: "Log" },
|
||||||
];
|
];
|
||||||
|
|
||||||
export default function AdminLayout() {
|
export default function AdminLayout() {
|
||||||
@@ -17,40 +18,131 @@ export default function AdminLayout() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gray-50 flex">
|
<ToastProvider>
|
||||||
{/* Sidebar */}
|
<div className="min-h-screen flex bg-[var(--bg-cream)] grain-overlay">
|
||||||
<aside className="w-56 bg-white border-r border-gray-200 flex flex-col shrink-0">
|
{/* ═══════════ Sidebar ═══════════ */}
|
||||||
<div className="px-5 py-4 border-b border-gray-200">
|
<aside className="w-64 shrink-0 relative flex flex-col text-[var(--text-inverted)]">
|
||||||
<h1 className="text-base font-semibold text-gray-800">图章管理后台</h1>
|
<div className="absolute inset-0 bg-[var(--bg-dark-deep)]" />
|
||||||
</div>
|
<div
|
||||||
<nav className="flex-1 py-3">
|
className="absolute inset-0 pointer-events-none"
|
||||||
{navItems.map((item) => (
|
style={{
|
||||||
<NavLink
|
backgroundImage: `
|
||||||
key={item.path}
|
radial-gradient(ellipse 80% 40% at 50% 0%, rgba(212, 165, 116, 0.08) 0%, transparent 70%),
|
||||||
to={item.path}
|
radial-gradient(circle at 20% 90%, rgba(199, 91, 57, 0.06) 0%, transparent 60%)
|
||||||
className={({ isActive }) =>
|
`,
|
||||||
`block px-5 py-2.5 text-sm transition-colors ${
|
}}
|
||||||
isActive
|
/>
|
||||||
? "text-blue-600 bg-blue-50 font-medium border-r-2 border-blue-600"
|
<div
|
||||||
: "text-gray-600 hover:bg-gray-50"
|
className="absolute inset-0 opacity-[0.025] pointer-events-none"
|
||||||
}`
|
style={{
|
||||||
}
|
backgroundImage: `
|
||||||
>
|
linear-gradient(rgba(212, 165, 116, 0.6) 1px, transparent 1px),
|
||||||
{item.label}
|
linear-gradient(90deg, rgba(212, 165, 116, 0.6) 1px, transparent 1px)
|
||||||
</NavLink>
|
`,
|
||||||
))}
|
backgroundSize: "40px 40px",
|
||||||
</nav>
|
}}
|
||||||
<div className="px-5 py-3 border-t border-gray-200">
|
/>
|
||||||
<button onClick={handleLogout} className="text-sm text-gray-500 hover:text-gray-700">
|
|
||||||
退出管理
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</aside>
|
|
||||||
|
|
||||||
{/* Main content */}
|
{/* Brand */}
|
||||||
<main className="flex-1 p-6 overflow-auto">
|
<div className="relative px-7 pt-8 pb-7">
|
||||||
<Outlet />
|
<div className="flex items-center gap-2.5 mb-3">
|
||||||
</main>
|
<span className="block w-5 h-px bg-[var(--gold)]/60" />
|
||||||
</div>
|
<span
|
||||||
|
className="text-[var(--gold)] text-[10px] tracking-[0.4em] uppercase"
|
||||||
|
style={{ fontFamily: "'Playfair Display', serif", fontWeight: 500 }}
|
||||||
|
>
|
||||||
|
Atelier
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<h1
|
||||||
|
className="text-[var(--text-inverted)] text-[22px] leading-tight"
|
||||||
|
style={{ fontFamily: "'Playfair Display', serif", fontWeight: 700, letterSpacing: "-0.01em" }}
|
||||||
|
>
|
||||||
|
CityWalk
|
||||||
|
<br />
|
||||||
|
<span className="text-[var(--gold)]">图章后台</span>
|
||||||
|
</h1>
|
||||||
|
<div className="mt-4 h-px bg-gradient-to-r from-[var(--gold)]/40 via-[var(--gold)]/10 to-transparent" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Nav */}
|
||||||
|
<nav className="relative flex-1 px-4">
|
||||||
|
{navItems.map((item) => (
|
||||||
|
<NavLink
|
||||||
|
key={item.path}
|
||||||
|
to={item.path}
|
||||||
|
className={({ isActive }) =>
|
||||||
|
`group relative block px-3 py-3 my-0.5 rounded-lg transition-all duration-300 ${
|
||||||
|
isActive
|
||||||
|
? "bg-[rgba(212,165,116,0.08)]"
|
||||||
|
: "hover:bg-white/[0.04]"
|
||||||
|
}`
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{({ isActive }) => (
|
||||||
|
<>
|
||||||
|
<span
|
||||||
|
className={`absolute left-0 top-1/2 -translate-y-1/2 w-[3px] rounded-r transition-all duration-300 ${
|
||||||
|
isActive ? "h-5 bg-[var(--gold)]" : "h-0 bg-transparent"
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
<div className="flex items-baseline gap-3">
|
||||||
|
<span
|
||||||
|
className={`text-[10px] tracking-[0.3em] uppercase shrink-0 transition-colors ${
|
||||||
|
isActive ? "text-[var(--gold)]" : "text-[var(--text-inverted)]/30"
|
||||||
|
}`}
|
||||||
|
style={{ fontFamily: "'Playfair Display', serif" }}
|
||||||
|
>
|
||||||
|
{item.eyebrow}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className={`text-[14px] transition-colors ${
|
||||||
|
isActive
|
||||||
|
? "text-[var(--text-inverted)] font-medium"
|
||||||
|
: "text-[var(--text-inverted)]/60 group-hover:text-[var(--text-inverted)]/90"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={`ml-[38px] mt-0.5 text-[9px] tracking-[0.32em] uppercase transition-colors ${
|
||||||
|
isActive ? "text-[var(--gold)]/60" : "text-[var(--text-inverted)]/15"
|
||||||
|
}`}
|
||||||
|
style={{ fontFamily: "'Playfair Display', serif" }}
|
||||||
|
>
|
||||||
|
{item.tag}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</NavLink>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div className="relative px-7 py-5 border-t border-white/5">
|
||||||
|
<button
|
||||||
|
onClick={handleLogout}
|
||||||
|
className="group flex items-center gap-2.5 text-[var(--text-inverted)]/45 hover:text-[var(--gold)] text-sm transition-colors"
|
||||||
|
>
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<path d="M9 21H5a2 2 0 01-2-2V5a2 2 0 012-2h4M16 17l5-5-5-5M21 12H9" />
|
||||||
|
</svg>
|
||||||
|
<span>退出管理</span>
|
||||||
|
</button>
|
||||||
|
<p className="mt-3 text-[9px] tracking-[0.3em] uppercase text-[var(--text-inverted)]/20">
|
||||||
|
v1.0 · Curated by Stamp
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
{/* ═══════════ Main ═══════════ */}
|
||||||
|
<main className="flex-1 overflow-auto paper-texture">
|
||||||
|
<div className="min-h-full px-10 py-10">
|
||||||
|
<Outlet />
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</ToastProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,28 +27,117 @@ export default function AdminLogin() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
|
<div className="min-h-screen flex items-center justify-center relative overflow-hidden grain-overlay">
|
||||||
<div className="w-80 bg-white rounded-lg shadow-sm p-6 border border-gray-200">
|
<div className="absolute inset-0 bg-[var(--bg-dark-deep)]" />
|
||||||
<h1 className="text-lg font-semibold text-gray-800 mb-4 text-center">管理后台</h1>
|
<div
|
||||||
<div className="space-y-3">
|
className="absolute inset-0"
|
||||||
<input
|
style={{
|
||||||
type="password"
|
backgroundImage: `
|
||||||
value={key}
|
radial-gradient(ellipse 60% 50% at 50% 40%, rgba(212, 165, 116, 0.08) 0%, transparent 100%),
|
||||||
onChange={(e) => setKey(e.target.value)}
|
radial-gradient(circle at 15% 80%, rgba(199, 91, 57, 0.06) 0%, transparent 50%),
|
||||||
onKeyDown={(e) => e.key === "Enter" && handleLogin()}
|
radial-gradient(circle at 85% 20%, rgba(45, 106, 79, 0.04) 0%, transparent 40%)
|
||||||
placeholder="输入管理密钥"
|
`,
|
||||||
className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm
|
}}
|
||||||
focus:outline-none focus:ring-1 focus:ring-blue-500 focus:border-blue-500"
|
/>
|
||||||
/>
|
<div
|
||||||
{error && <p className="text-sm text-red-500">{error}</p>}
|
className="absolute inset-0 opacity-[0.03]"
|
||||||
<button
|
style={{
|
||||||
onClick={handleLogin}
|
backgroundImage: `
|
||||||
disabled={loading || !key}
|
linear-gradient(rgba(212, 165, 116, 0.5) 1px, transparent 1px),
|
||||||
className="w-full py-2 bg-blue-600 text-white text-sm rounded-md
|
linear-gradient(90deg, rgba(212, 165, 116, 0.5) 1px, transparent 1px)
|
||||||
hover:bg-blue-700 disabled:opacity-50 transition-colors"
|
`,
|
||||||
|
backgroundSize: "60px 60px",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="relative z-10 w-full max-w-sm px-6">
|
||||||
|
{/* Brand */}
|
||||||
|
<div className="text-center mb-10 animate-fade-in">
|
||||||
|
<div className="inline-flex items-center gap-3 mb-6">
|
||||||
|
<span className="block w-10 h-px bg-[var(--gold)]/50" />
|
||||||
|
<span
|
||||||
|
className="text-[var(--gold)] text-[11px] tracking-[0.4em] uppercase"
|
||||||
|
style={{ fontFamily: "'Playfair Display', serif", fontWeight: 500 }}
|
||||||
|
>
|
||||||
|
CityWalk · Atelier
|
||||||
|
</span>
|
||||||
|
<span className="block w-10 h-px bg-[var(--gold)]/50" />
|
||||||
|
</div>
|
||||||
|
<h1
|
||||||
|
className="text-[var(--text-inverted)] text-4xl leading-none mb-3"
|
||||||
|
style={{ fontFamily: "'Playfair Display', serif", fontWeight: 700, letterSpacing: "-0.02em" }}
|
||||||
>
|
>
|
||||||
{loading ? "验证中..." : "登录"}
|
图章后台
|
||||||
</button>
|
</h1>
|
||||||
|
<p className="text-[var(--gold-light)]/50 text-[11px] tracking-[0.28em] uppercase">
|
||||||
|
Stamp · Admin Console
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Card */}
|
||||||
|
<div
|
||||||
|
className="relative animate-fade-in-up"
|
||||||
|
style={{ animationDelay: "0.15s" }}
|
||||||
|
>
|
||||||
|
{/* Corner flourishes */}
|
||||||
|
<span className="absolute -top-1.5 -left-1.5 w-6 h-6 border-t border-l border-[var(--gold)]/40" />
|
||||||
|
<span className="absolute -top-1.5 -right-1.5 w-6 h-6 border-t border-r border-[var(--gold)]/40" />
|
||||||
|
<span className="absolute -bottom-1.5 -left-1.5 w-6 h-6 border-b border-l border-[var(--gold)]/40" />
|
||||||
|
<span className="absolute -bottom-1.5 -right-1.5 w-6 h-6 border-b border-r border-[var(--gold)]/40" />
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="rounded-xl bg-[var(--bg-cream)] p-7"
|
||||||
|
style={{ boxShadow: "0 32px 80px rgba(0,0,0,0.4)" }}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2 mb-5">
|
||||||
|
<span className="block w-4 h-px bg-[var(--gold)]" />
|
||||||
|
<span
|
||||||
|
className="text-[var(--gold)] text-[10px] tracking-[0.32em] uppercase"
|
||||||
|
style={{ fontFamily: "'Playfair Display', serif" }}
|
||||||
|
>
|
||||||
|
Access
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label className="block text-[13px] text-[var(--text-secondary)] mb-2">管理密钥</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={key}
|
||||||
|
onChange={(e) => setKey(e.target.value)}
|
||||||
|
onKeyDown={(e) => e.key === "Enter" && key && handleLogin()}
|
||||||
|
placeholder="••••••••"
|
||||||
|
className="w-full px-4 py-3 bg-white border border-[var(--border-default)] rounded-lg text-sm text-[var(--text-primary)] placeholder:text-[var(--text-muted)]/50 focus:outline-none focus:border-[var(--gold)] focus:ring-2 focus:ring-[var(--gold)]/15 transition-all"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="mt-3 flex items-center gap-2 text-[13px] text-[var(--terracotta)]">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.4">
|
||||||
|
<circle cx="12" cy="12" r="10" />
|
||||||
|
<path d="M12 8v4M12 16h.01" />
|
||||||
|
</svg>
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handleLogin}
|
||||||
|
disabled={loading || !key}
|
||||||
|
className="mt-6 w-full py-3 rounded-lg text-white text-sm font-medium transition-all disabled:opacity-40 hover:brightness-110"
|
||||||
|
style={{
|
||||||
|
backgroundColor: "var(--terracotta)",
|
||||||
|
boxShadow: "0 8px 24px rgba(199,91,57,0.35)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{loading ? "验证中..." : "进入后台"}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="mt-6 pt-5 border-t border-[var(--border-muted)]">
|
||||||
|
<p className="text-[10px] tracking-[0.25em] uppercase text-[var(--text-muted)] text-center">
|
||||||
|
仅限授权访问
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { useNavigate, useParams } from "react-router-dom";
|
import Modal from "./Modal";
|
||||||
import { adminFetch } from "./adminApi";
|
import { adminFetch } from "./adminApi";
|
||||||
|
import { useToast } from "./Toast";
|
||||||
|
import { Field, ErrorRow, FormFooter, HintRow, fieldCls } from "./FormPrimitives";
|
||||||
|
|
||||||
type Article = {
|
type Article = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -13,11 +15,16 @@ type Article = {
|
|||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function ArticleForm() {
|
type Props = {
|
||||||
const { id } = useParams();
|
open: boolean;
|
||||||
const navigate = useNavigate();
|
id: string | null;
|
||||||
const isEdit = !!id;
|
onClose: () => void;
|
||||||
|
onSaved: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function ArticleForm({ open, id, onClose, onSaved }: Props) {
|
||||||
|
const toast = useToast();
|
||||||
|
const [currentId, setCurrentId] = useState<string | null>(null);
|
||||||
const [title, setTitle] = useState("");
|
const [title, setTitle] = useState("");
|
||||||
const [subtitle, setSubtitle] = useState("");
|
const [subtitle, setSubtitle] = useState("");
|
||||||
const [body, setBody] = useState("");
|
const [body, setBody] = useState("");
|
||||||
@@ -28,8 +35,17 @@ export default function ArticleForm() {
|
|||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
|
const isEdit = !!currentId;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!id) return;
|
if (!open) return;
|
||||||
|
setCurrentId(id);
|
||||||
|
setError("");
|
||||||
|
if (!id) {
|
||||||
|
setTitle(""); setSubtitle(""); setBody(""); setCaption("");
|
||||||
|
setCoverImage(""); setSortOrder(0); setEnabled(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
adminFetch<Article[]>("/articles").then((articles) => {
|
adminFetch<Article[]>("/articles").then((articles) => {
|
||||||
const article = articles.find((a) => a.id === id);
|
const article = articles.find((a) => a.id === id);
|
||||||
if (article) {
|
if (article) {
|
||||||
@@ -42,32 +58,33 @@ export default function ArticleForm() {
|
|||||||
setEnabled(article.enabled);
|
setEnabled(article.enabled);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}, [id]);
|
}, [open, id]);
|
||||||
|
|
||||||
const handleUpload = async (file: File) => {
|
const handleUpload = async (file: File) => {
|
||||||
if (!id) {
|
if (!currentId) {
|
||||||
setError("请先保存文章后再上传封面");
|
setError("请先保存文章后再上传封面");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
setError("");
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append("image", file);
|
formData.append("image", file);
|
||||||
const data = await adminFetch<{ path: string }>(`/articles/${id}/upload`, {
|
try {
|
||||||
method: "POST",
|
const data = await adminFetch<{ path: string }>(`/articles/${currentId}/upload`, {
|
||||||
body: formData,
|
method: "POST",
|
||||||
});
|
body: formData,
|
||||||
setCoverImage(data.path);
|
});
|
||||||
|
setCoverImage(data.path);
|
||||||
|
toast.show("封面已上传");
|
||||||
|
onSaved();
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : "上传失败");
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
setError("");
|
setError("");
|
||||||
if (!title.trim()) {
|
if (!title.trim()) return setError("请输入标题");
|
||||||
setError("请输入标题");
|
if (!body.trim()) return setError("请输入正文");
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!body.trim()) {
|
|
||||||
setError("请输入正文");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
try {
|
try {
|
||||||
const payload = {
|
const payload = {
|
||||||
@@ -79,19 +96,22 @@ export default function ArticleForm() {
|
|||||||
enabled,
|
enabled,
|
||||||
};
|
};
|
||||||
if (isEdit) {
|
if (isEdit) {
|
||||||
await adminFetch(`/articles/${id}`, {
|
await adminFetch(`/articles/${currentId}`, {
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
body: JSON.stringify(payload),
|
body: JSON.stringify(payload),
|
||||||
});
|
});
|
||||||
|
toast.show("已保存");
|
||||||
|
onSaved();
|
||||||
|
onClose();
|
||||||
} else {
|
} else {
|
||||||
const article = await adminFetch<Article>("/articles", {
|
const article = await adminFetch<Article>("/articles", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify(payload),
|
body: JSON.stringify(payload),
|
||||||
});
|
});
|
||||||
navigate(`/admin/articles/${article.id}/edit`, { replace: true });
|
setCurrentId(article.id);
|
||||||
return;
|
toast.show("已创建,现在可以上传封面");
|
||||||
|
onSaved();
|
||||||
}
|
}
|
||||||
navigate("/admin/articles");
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(e instanceof Error ? e.message : "保存失败");
|
setError(e instanceof Error ? e.message : "保存失败");
|
||||||
} finally {
|
} finally {
|
||||||
@@ -100,122 +120,104 @@ export default function ArticleForm() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-2xl">
|
<Modal
|
||||||
<h2 className="text-lg font-semibold text-gray-800 mb-4">
|
open={open}
|
||||||
{isEdit ? "编辑文章" : "添加文章"}
|
onClose={onClose}
|
||||||
</h2>
|
size="lg"
|
||||||
|
eyebrow={isEdit ? "Edit Article" : "New Article"}
|
||||||
<div className="bg-white rounded-lg border border-gray-200 p-5 space-y-4">
|
title={isEdit ? "编辑文章" : "添加文章"}
|
||||||
<div>
|
subtitle={isEdit ? "调整内容与上传封面" : "先保存文章内容,再上传封面图"}
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">标题</label>
|
>
|
||||||
|
<div className="px-7 py-6 space-y-5">
|
||||||
|
<Field label="标题" required>
|
||||||
<input
|
<input
|
||||||
value={title}
|
value={title}
|
||||||
onChange={(e) => setTitle(e.target.value)}
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
placeholder="如:朝天宫"
|
placeholder="如:朝天宫"
|
||||||
className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm
|
className={fieldCls}
|
||||||
focus:outline-none focus:ring-1 focus:ring-blue-500"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</Field>
|
||||||
|
|
||||||
<div>
|
<Field label="副标题">
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">副标题</label>
|
|
||||||
<input
|
<input
|
||||||
value={subtitle}
|
value={subtitle}
|
||||||
onChange={(e) => setSubtitle(e.target.value)}
|
onChange={(e) => setSubtitle(e.target.value)}
|
||||||
placeholder="如:千年冶山,文脉绵延"
|
placeholder="如:千年冶山,文脉绵延"
|
||||||
className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm
|
className={fieldCls}
|
||||||
focus:outline-none focus:ring-1 focus:ring-blue-500"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</Field>
|
||||||
|
|
||||||
<div>
|
<Field label="正文" required hint="段落之间用空行分隔">
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
|
||||||
正文
|
|
||||||
<span className="text-xs text-gray-400 font-normal ml-2">段落之间用空行分隔</span>
|
|
||||||
</label>
|
|
||||||
<textarea
|
<textarea
|
||||||
value={body}
|
value={body}
|
||||||
onChange={(e) => setBody(e.target.value)}
|
onChange={(e) => setBody(e.target.value)}
|
||||||
rows={18}
|
rows={14}
|
||||||
className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm leading-relaxed
|
placeholder="在这里撰写文章正文…"
|
||||||
focus:outline-none focus:ring-1 focus:ring-blue-500 font-mono"
|
className={fieldCls + " font-mono text-[13px] leading-relaxed resize-y"}
|
||||||
/>
|
/>
|
||||||
</div>
|
</Field>
|
||||||
|
|
||||||
<div>
|
<Field label="图片说明">
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">图片说明</label>
|
|
||||||
<input
|
<input
|
||||||
value={caption}
|
value={caption}
|
||||||
onChange={(e) => setCaption(e.target.value)}
|
onChange={(e) => setCaption(e.target.value)}
|
||||||
placeholder="如:1910 年的朝天宫大成殿旧影"
|
placeholder="如:1910 年的朝天宫大成殿旧影"
|
||||||
className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm
|
className={fieldCls}
|
||||||
focus:outline-none focus:ring-1 focus:ring-blue-500"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</Field>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-[auto_1fr] gap-5 items-end">
|
||||||
<div>
|
<Field label="排序">
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">排序</label>
|
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
value={sortOrder}
|
value={sortOrder}
|
||||||
onChange={(e) => setSortOrder(Number(e.target.value))}
|
onChange={(e) => setSortOrder(Number(e.target.value))}
|
||||||
className="w-24 px-3 py-2 border border-gray-300 rounded-md text-sm
|
className={fieldCls + " w-28"}
|
||||||
focus:outline-none focus:ring-1 focus:ring-blue-500"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</Field>
|
||||||
<div className="flex items-center pt-6">
|
<label className="flex items-center gap-2.5 cursor-pointer select-none pb-2.5">
|
||||||
<label className="flex items-center gap-2 text-sm text-gray-700">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={enabled}
|
|
||||||
onChange={(e) => setEnabled(e.target.checked)}
|
|
||||||
/>
|
|
||||||
启用
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{isEdit && (
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">封面图片</label>
|
|
||||||
{coverImage && (
|
|
||||||
<div className="w-64 aspect-[4/3] rounded-md bg-gray-50 border border-gray-200 overflow-hidden shadow-sm mb-2">
|
|
||||||
<img src={coverImage} alt="封面" className="w-full h-full object-cover" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<input
|
<input
|
||||||
type="file"
|
type="checkbox"
|
||||||
accept="image/*"
|
checked={enabled}
|
||||||
onChange={(e) => e.target.files?.[0] && handleUpload(e.target.files[0])}
|
onChange={(e) => setEnabled(e.target.checked)}
|
||||||
className="text-xs text-gray-500"
|
className="w-4 h-4 accent-[var(--jade)]"
|
||||||
/>
|
/>
|
||||||
</div>
|
<span className="text-sm text-[var(--text-secondary)]">启用(访客可访问)</span>
|
||||||
)}
|
</label>
|
||||||
|
|
||||||
{!isEdit && (
|
|
||||||
<p className="text-xs text-gray-400">保存后可上传封面图片</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{error && <p className="text-sm text-red-500">{error}</p>}
|
|
||||||
|
|
||||||
<div className="flex gap-3 pt-2">
|
|
||||||
<button
|
|
||||||
onClick={handleSave}
|
|
||||||
disabled={saving}
|
|
||||||
className="px-4 py-2 bg-blue-600 text-white text-sm rounded-md
|
|
||||||
hover:bg-blue-700 disabled:opacity-50"
|
|
||||||
>
|
|
||||||
{saving ? "保存中..." : "保存"}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => navigate("/admin/articles")}
|
|
||||||
className="px-4 py-2 border border-gray-300 text-gray-600 text-sm rounded-md hover:bg-gray-50"
|
|
||||||
>
|
|
||||||
取消
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{isEdit ? (
|
||||||
|
<Field label="封面图片">
|
||||||
|
<div className="rounded-xl border border-[var(--border-muted)] bg-white p-4">
|
||||||
|
{coverImage && (
|
||||||
|
<div className="w-full max-w-sm aspect-[4/3] rounded-lg bg-[var(--bg-paper)] overflow-hidden border border-[var(--border-muted)] mb-3">
|
||||||
|
<img src={coverImage} alt="封面" className="w-full h-full object-cover" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<label className="cursor-pointer inline-flex items-center gap-2 text-[13px] text-[var(--text-secondary)] hover:text-[var(--terracotta)] transition-colors">
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
onChange={(e) => e.target.files?.[0] && handleUpload(e.target.files[0])}
|
||||||
|
className="hidden"
|
||||||
|
/>
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4M17 8l-5-5-5 5M12 3v12" />
|
||||||
|
</svg>
|
||||||
|
<span className="underline underline-offset-2 decoration-dotted">
|
||||||
|
{coverImage ? "更换封面" : "选择封面图片"}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</Field>
|
||||||
|
) : (
|
||||||
|
<HintRow text="保存文章后,即可上传封面图片" />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && <ErrorRow text={error} />}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
<FormFooter onCancel={onClose} onSave={handleSave} saving={saving} primaryLabel={isEdit ? "保存" : "创建"} />
|
||||||
|
</Modal>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,21 @@
|
|||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { Link } from "react-router-dom";
|
|
||||||
import { adminFetch } from "./adminApi";
|
import { adminFetch } from "./adminApi";
|
||||||
|
import { useToast } from "./Toast";
|
||||||
|
import { copyItemLink } from "./utils";
|
||||||
|
import ArticleForm from "./ArticleForm";
|
||||||
|
import QRCodeModal from "./QRCodeModal";
|
||||||
|
import PageHeader, {
|
||||||
|
PrimaryButton,
|
||||||
|
StatusChip,
|
||||||
|
ActionButton,
|
||||||
|
EmptyState,
|
||||||
|
LoadingBlock,
|
||||||
|
IconEdit,
|
||||||
|
IconCopy,
|
||||||
|
IconQR,
|
||||||
|
IconDelete,
|
||||||
|
} from "./PageHeader";
|
||||||
|
import { TableCard, TableHeadRow } from "./StampList";
|
||||||
|
|
||||||
type Article = {
|
type Article = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -12,11 +27,14 @@ type Article = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default function ArticleList() {
|
export default function ArticleList() {
|
||||||
|
const toast = useToast();
|
||||||
const [articles, setArticles] = useState<Article[]>([]);
|
const [articles, setArticles] = useState<Article[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
const [formState, setFormState] = useState<{ open: boolean; id: string | null }>({ open: false, id: null });
|
||||||
|
const [qrState, setQrState] = useState<{ open: boolean; id: string | null }>({ open: false, id: null });
|
||||||
|
|
||||||
const fetchArticles = async () => {
|
const fetchArticles = async () => {
|
||||||
setLoading(true);
|
|
||||||
try {
|
try {
|
||||||
const data = await adminFetch<Article[]>("/articles");
|
const data = await adminFetch<Article[]>("/articles");
|
||||||
setArticles(data);
|
setArticles(data);
|
||||||
@@ -25,12 +43,19 @@ export default function ArticleList() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => { fetchArticles(); }, []);
|
useEffect(() => {
|
||||||
|
fetchArticles();
|
||||||
|
}, []);
|
||||||
|
|
||||||
const handleDelete = async (id: string, title: string) => {
|
const handleDelete = async (id: string, title: string) => {
|
||||||
if (!confirm(`确定删除文章「${title}」?`)) return;
|
if (!confirm(`确定删除文章「${title}」?`)) return;
|
||||||
await adminFetch(`/articles/${id}`, { method: "DELETE" });
|
try {
|
||||||
fetchArticles();
|
await adminFetch(`/articles/${id}`, { method: "DELETE" });
|
||||||
|
toast.show("已删除");
|
||||||
|
fetchArticles();
|
||||||
|
} catch (e) {
|
||||||
|
toast.show(e instanceof Error ? e.message : "删除失败", "error");
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleToggle = async (id: string, enabled: boolean) => {
|
const handleToggle = async (id: string, enabled: boolean) => {
|
||||||
@@ -41,78 +66,124 @@ export default function ArticleList() {
|
|||||||
fetchArticles();
|
fetchArticles();
|
||||||
};
|
};
|
||||||
|
|
||||||
if (loading) return <p className="text-gray-500">加载中...</p>;
|
const handleCopyLink = async (id: string) => {
|
||||||
|
try {
|
||||||
|
await copyItemLink("article", id);
|
||||||
|
toast.show("链接已复制");
|
||||||
|
} catch {
|
||||||
|
toast.show("复制失败", "error");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<>
|
||||||
<div className="flex items-center justify-between mb-4">
|
<PageHeader
|
||||||
<h2 className="text-lg font-semibold text-gray-800">文章管理</h2>
|
eyebrow="02 · Articles"
|
||||||
<Link
|
title="文章管理"
|
||||||
to="/admin/articles/new"
|
caption="静态文章与对应点位的 NFC 链接"
|
||||||
className="px-4 py-2 bg-blue-600 text-white text-sm rounded-md hover:bg-blue-700"
|
action={<PrimaryButton onClick={() => setFormState({ open: true, id: null })}>添加文章</PrimaryButton>}
|
||||||
>
|
/>
|
||||||
添加文章
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="bg-white rounded-lg border border-gray-200 overflow-hidden">
|
{loading ? (
|
||||||
<table className="w-full text-sm">
|
<LoadingBlock />
|
||||||
<thead className="bg-gray-50 border-b border-gray-200">
|
) : (
|
||||||
<tr>
|
<TableCard>
|
||||||
<th className="text-left px-4 py-3 font-medium text-gray-600">封面</th>
|
{articles.length === 0 ? (
|
||||||
<th className="text-left px-4 py-3 font-medium text-gray-600">标题</th>
|
<EmptyState
|
||||||
<th className="text-left px-4 py-3 font-medium text-gray-600">副标题</th>
|
message="尚未创建文章"
|
||||||
<th className="text-center px-4 py-3 font-medium text-gray-600">排序</th>
|
action={
|
||||||
<th className="text-center px-4 py-3 font-medium text-gray-600">状态</th>
|
<PrimaryButton onClick={() => setFormState({ open: true, id: null })}>
|
||||||
<th className="text-right px-4 py-3 font-medium text-gray-600">操作</th>
|
添加第一篇文章
|
||||||
</tr>
|
</PrimaryButton>
|
||||||
</thead>
|
}
|
||||||
<tbody>
|
/>
|
||||||
{articles.map((article) => (
|
) : (
|
||||||
<tr key={article.id} className="border-b border-gray-100 hover:bg-gray-50">
|
<table className="w-full">
|
||||||
<td className="px-4 py-3">
|
<thead>
|
||||||
<div className="w-16 h-10 rounded bg-gray-50 border border-gray-200 overflow-hidden flex items-center justify-center shadow-sm">
|
<TableHeadRow cols={["封面", "标题 · 副标题", "排序", "状态", "操作"]} />
|
||||||
{article.coverImage && (
|
</thead>
|
||||||
<img src={article.coverImage} alt="" className="w-full h-full object-cover" />
|
<tbody>
|
||||||
)}
|
{articles.map((article, i) => (
|
||||||
</div>
|
<tr
|
||||||
</td>
|
key={article.id}
|
||||||
<td className="px-4 py-3 text-gray-800 font-medium">{article.title}</td>
|
className="border-t border-[var(--border-muted)] hover:bg-white/60 transition-colors animate-admin-row"
|
||||||
<td className="px-4 py-3 text-gray-500 max-w-[260px] truncate">{article.subtitle || "—"}</td>
|
style={{ animationDelay: `${Math.min(i, 12) * 0.03}s` }}
|
||||||
<td className="px-4 py-3 text-center text-gray-500">{article.sortOrder}</td>
|
|
||||||
<td className="px-4 py-3 text-center">
|
|
||||||
<button
|
|
||||||
onClick={() => handleToggle(article.id, article.enabled)}
|
|
||||||
className={`px-2 py-0.5 rounded text-xs ${
|
|
||||||
article.enabled ? "bg-green-100 text-green-700" : "bg-gray-100 text-gray-500"
|
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
{article.enabled ? "启用" : "禁用"}
|
<td className="px-5 py-4 w-[110px]">
|
||||||
</button>
|
<div className="w-[72px] h-12 rounded-md bg-[var(--bg-paper)] border border-[var(--border-muted)] overflow-hidden shadow-sm">
|
||||||
</td>
|
{article.coverImage ? (
|
||||||
<td className="px-4 py-3 text-right space-x-2">
|
<img src={article.coverImage} alt="" className="w-full h-full object-cover" />
|
||||||
<Link to={`/admin/articles/${article.id}/edit`} className="text-blue-600 hover:underline">
|
) : (
|
||||||
编辑
|
<div className="w-full h-full flex items-center justify-center">
|
||||||
</Link>
|
<span className="text-[9px] tracking-[0.2em] uppercase text-[var(--text-muted)]/60">
|
||||||
<Link to={`/admin/articles/${article.id}/qrcode`} className="text-blue-600 hover:underline">
|
—
|
||||||
二维码
|
</span>
|
||||||
</Link>
|
</div>
|
||||||
<button onClick={() => handleDelete(article.id, article.title)} className="text-red-500 hover:underline">
|
)}
|
||||||
删除
|
</div>
|
||||||
</button>
|
</td>
|
||||||
</td>
|
<td className="px-5 py-4">
|
||||||
</tr>
|
<p className="text-[15px] font-medium text-[var(--text-primary)]">{article.title}</p>
|
||||||
))}
|
{article.subtitle && (
|
||||||
{articles.length === 0 && (
|
<p className="text-xs text-[var(--text-muted)] mt-0.5 max-w-[360px] truncate">
|
||||||
<tr>
|
{article.subtitle}
|
||||||
<td colSpan={6} className="px-4 py-8 text-center text-gray-400">
|
</p>
|
||||||
暂无文章,点击右上角添加
|
)}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
<td className="px-5 py-4 text-center w-[80px]">
|
||||||
)}
|
<span
|
||||||
</tbody>
|
className="text-[13px] text-[var(--text-secondary)]"
|
||||||
</table>
|
style={{ fontFamily: "'Playfair Display', serif" }}
|
||||||
</div>
|
>
|
||||||
</div>
|
{article.sortOrder}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-5 py-4 text-center w-[110px]">
|
||||||
|
<StatusChip
|
||||||
|
enabled={article.enabled}
|
||||||
|
onClick={() => handleToggle(article.id, article.enabled)}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td className="px-5 py-4 w-[180px]">
|
||||||
|
<div className="flex items-center justify-end gap-1">
|
||||||
|
<ActionButton title="编辑" onClick={() => setFormState({ open: true, id: article.id })}>
|
||||||
|
{IconEdit}
|
||||||
|
</ActionButton>
|
||||||
|
<ActionButton title="复制链接" onClick={() => handleCopyLink(article.id)}>
|
||||||
|
{IconCopy}
|
||||||
|
</ActionButton>
|
||||||
|
<ActionButton title="二维码" onClick={() => setQrState({ open: true, id: article.id })}>
|
||||||
|
{IconQR}
|
||||||
|
</ActionButton>
|
||||||
|
<ActionButton
|
||||||
|
title="删除"
|
||||||
|
variant="danger"
|
||||||
|
onClick={() => handleDelete(article.id, article.title)}
|
||||||
|
>
|
||||||
|
{IconDelete}
|
||||||
|
</ActionButton>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
</TableCard>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<ArticleForm
|
||||||
|
open={formState.open}
|
||||||
|
id={formState.id}
|
||||||
|
onClose={() => setFormState({ open: false, id: null })}
|
||||||
|
onSaved={fetchArticles}
|
||||||
|
/>
|
||||||
|
<QRCodeModal
|
||||||
|
open={qrState.open}
|
||||||
|
type="article"
|
||||||
|
id={qrState.id}
|
||||||
|
onClose={() => setQrState({ open: false, id: null })}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,120 +0,0 @@
|
|||||||
import { useState, useEffect, useRef } from "react";
|
|
||||||
import { useParams, Link } from "react-router-dom";
|
|
||||||
import { adminFetch } from "./adminApi";
|
|
||||||
|
|
||||||
type QRData = {
|
|
||||||
qrDataUrl: string;
|
|
||||||
articleUrl: string;
|
|
||||||
articleTitle: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function ArticleQRCode() {
|
|
||||||
const { id } = useParams();
|
|
||||||
const [data, setData] = useState<QRData | null>(null);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [copied, setCopied] = useState(false);
|
|
||||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!id) return;
|
|
||||||
adminFetch<QRData>(`/articles/${id}/qrcode`)
|
|
||||||
.then(setData)
|
|
||||||
.finally(() => setLoading(false));
|
|
||||||
}, [id]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!data || !canvasRef.current) return;
|
|
||||||
|
|
||||||
const canvas = canvasRef.current;
|
|
||||||
const ctx = canvas.getContext("2d")!;
|
|
||||||
const img = new Image();
|
|
||||||
img.onload = () => {
|
|
||||||
const padding = 20;
|
|
||||||
const textHeight = 40;
|
|
||||||
canvas.width = img.width + padding * 2;
|
|
||||||
canvas.height = img.height + padding * 2 + textHeight;
|
|
||||||
|
|
||||||
ctx.fillStyle = "#ffffff";
|
|
||||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
|
||||||
|
|
||||||
ctx.drawImage(img, padding, padding);
|
|
||||||
|
|
||||||
ctx.fillStyle = "#666666";
|
|
||||||
ctx.font = "12px sans-serif";
|
|
||||||
ctx.textAlign = "center";
|
|
||||||
ctx.fillText(data.articleUrl, canvas.width / 2, img.height + padding * 2 + 12);
|
|
||||||
};
|
|
||||||
img.src = data.qrDataUrl;
|
|
||||||
}, [data]);
|
|
||||||
|
|
||||||
const handleDownload = () => {
|
|
||||||
if (!canvasRef.current || !data) return;
|
|
||||||
const link = document.createElement("a");
|
|
||||||
link.download = `${data.articleTitle}-二维码.png`;
|
|
||||||
link.href = canvasRef.current.toDataURL("image/png");
|
|
||||||
link.click();
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCopy = async () => {
|
|
||||||
if (!data) return;
|
|
||||||
try {
|
|
||||||
await navigator.clipboard.writeText(data.articleUrl);
|
|
||||||
setCopied(true);
|
|
||||||
setTimeout(() => setCopied(false), 1800);
|
|
||||||
} catch {
|
|
||||||
const ta = document.createElement("textarea");
|
|
||||||
ta.value = data.articleUrl;
|
|
||||||
document.body.appendChild(ta);
|
|
||||||
ta.select();
|
|
||||||
document.execCommand("copy");
|
|
||||||
document.body.removeChild(ta);
|
|
||||||
setCopied(true);
|
|
||||||
setTimeout(() => setCopied(false), 1800);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (loading) return <p className="text-gray-500">加载中...</p>;
|
|
||||||
if (!data) return <p className="text-gray-500">文章不存在</p>;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="max-w-md">
|
|
||||||
<div className="flex items-center gap-2 mb-4">
|
|
||||||
<Link to="/admin/articles" className="text-gray-400 hover:text-gray-600">
|
|
||||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
|
||||||
<path d="M15 18l-6-6 6-6" />
|
|
||||||
</svg>
|
|
||||||
</Link>
|
|
||||||
<h2 className="text-lg font-semibold text-gray-800">{data.articleTitle} — 点位链接</h2>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="bg-white rounded-lg border border-gray-200 p-6 text-center space-y-4">
|
|
||||||
<div className="inline-block">
|
|
||||||
<img src={data.qrDataUrl} alt="二维码" className="w-64 h-64" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p className="text-xs text-gray-500 break-all select-all">{data.articleUrl}</p>
|
|
||||||
|
|
||||||
<canvas ref={canvasRef} className="hidden" />
|
|
||||||
|
|
||||||
<div className="flex flex-wrap items-center justify-center gap-3">
|
|
||||||
<button
|
|
||||||
onClick={handleCopy}
|
|
||||||
className="px-4 py-2 bg-blue-600 text-white text-sm rounded-md hover:bg-blue-700 min-w-[160px]"
|
|
||||||
>
|
|
||||||
{copied ? "已复制 ✓" : "复制链接(写入 NFC)"}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={handleDownload}
|
|
||||||
className="px-4 py-2 border border-gray-300 text-gray-700 text-sm rounded-md hover:bg-gray-50"
|
|
||||||
>
|
|
||||||
下载二维码
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p className="text-xs text-gray-400 pt-1">
|
|
||||||
点位主要通过 NFC 触碰分发,二维码作为备用方式保留
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
94
packages/web/src/admin/FormPrimitives.tsx
Normal file
94
packages/web/src/admin/FormPrimitives.tsx
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
|
export const fieldCls =
|
||||||
|
"w-full px-3.5 py-2.5 bg-white border border-[var(--border-default)] rounded-lg text-sm text-[var(--text-primary)] placeholder:text-[var(--text-muted)]/60 focus:outline-none focus:border-[var(--gold)] focus:ring-2 focus:ring-[var(--gold)]/15 transition-all";
|
||||||
|
|
||||||
|
export function Field({
|
||||||
|
label,
|
||||||
|
required,
|
||||||
|
hint,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
required?: boolean;
|
||||||
|
hint?: string;
|
||||||
|
children: ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<label className="flex items-baseline justify-between mb-1.5">
|
||||||
|
<span className="text-[13px] font-medium text-[var(--text-secondary)]">
|
||||||
|
{label}
|
||||||
|
{required && <span className="text-[var(--terracotta)] ml-0.5">*</span>}
|
||||||
|
</span>
|
||||||
|
{hint && <span className="text-[10px] tracking-[0.2em] uppercase text-[var(--text-muted)]">{hint}</span>}
|
||||||
|
</label>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ErrorRow({ text }: { text: string }) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="flex items-center gap-2 px-3.5 py-2.5 rounded-lg text-sm"
|
||||||
|
style={{ backgroundColor: "rgba(199, 91, 57, 0.08)", color: "var(--terracotta)" }}
|
||||||
|
>
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2">
|
||||||
|
<circle cx="12" cy="12" r="10" />
|
||||||
|
<path d="M12 8v4M12 16h.01" />
|
||||||
|
</svg>
|
||||||
|
{text}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function HintRow({ text }: { text: string }) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2.5 px-3.5 py-2.5 rounded-lg bg-[var(--bg-paper)] border border-[var(--border-muted)]">
|
||||||
|
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="var(--gold)" strokeWidth="2">
|
||||||
|
<circle cx="12" cy="12" r="10" />
|
||||||
|
<path d="M12 16v-4M12 8h.01" />
|
||||||
|
</svg>
|
||||||
|
<span className="text-xs text-[var(--text-secondary)]">{text}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FormFooter({
|
||||||
|
onCancel,
|
||||||
|
onSave,
|
||||||
|
saving,
|
||||||
|
primaryLabel = "保存",
|
||||||
|
disabled,
|
||||||
|
}: {
|
||||||
|
onCancel: () => void;
|
||||||
|
onSave: () => void;
|
||||||
|
saving: boolean;
|
||||||
|
primaryLabel?: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="px-7 py-5 bg-white/40 border-t border-[var(--border-muted)] flex justify-end gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onCancel}
|
||||||
|
className="px-5 py-2.5 rounded-lg text-sm font-medium text-[var(--text-secondary)] hover:bg-[var(--bg-paper)] transition-colors"
|
||||||
|
>
|
||||||
|
取消
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onSave}
|
||||||
|
disabled={saving || disabled}
|
||||||
|
className="px-6 py-2.5 rounded-lg text-white text-sm font-medium transition-all disabled:opacity-40 hover:brightness-110"
|
||||||
|
style={{
|
||||||
|
backgroundColor: "var(--terracotta)",
|
||||||
|
boxShadow: "0 4px 14px rgba(199,91,57,0.3)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{saving ? "保存中..." : primaryLabel}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
100
packages/web/src/admin/Modal.tsx
Normal file
100
packages/web/src/admin/Modal.tsx
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
import { useEffect, type ReactNode } from "react";
|
||||||
|
|
||||||
|
type ModalProps = {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
children: ReactNode;
|
||||||
|
size?: "sm" | "md" | "lg";
|
||||||
|
title?: ReactNode;
|
||||||
|
eyebrow?: string;
|
||||||
|
subtitle?: ReactNode;
|
||||||
|
dismissable?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const sizeMap = { sm: "max-w-md", md: "max-w-2xl", lg: "max-w-4xl" };
|
||||||
|
|
||||||
|
export default function Modal({
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
children,
|
||||||
|
size = "md",
|
||||||
|
title,
|
||||||
|
eyebrow,
|
||||||
|
subtitle,
|
||||||
|
dismissable = true,
|
||||||
|
}: ModalProps) {
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
const onKey = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === "Escape" && dismissable) onClose();
|
||||||
|
};
|
||||||
|
window.addEventListener("keydown", onKey);
|
||||||
|
const prev = document.body.style.overflow;
|
||||||
|
document.body.style.overflow = "hidden";
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("keydown", onKey);
|
||||||
|
document.body.style.overflow = prev;
|
||||||
|
};
|
||||||
|
}, [open, onClose, dismissable]);
|
||||||
|
|
||||||
|
if (!open) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-50 flex items-start justify-center overflow-y-auto px-4 py-8 sm:py-12 animate-overlay-fade"
|
||||||
|
style={{ backgroundColor: "rgba(16, 16, 30, 0.6)", backdropFilter: "blur(6px)" }}
|
||||||
|
onClick={(e) => {
|
||||||
|
if (!dismissable) return;
|
||||||
|
if (e.target === e.currentTarget) onClose();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className={`relative w-full ${sizeMap[size]} my-auto animate-scale-in`}>
|
||||||
|
<div
|
||||||
|
className="bg-[var(--bg-cream)] rounded-2xl overflow-hidden border border-[var(--border-muted)] relative"
|
||||||
|
style={{ boxShadow: "0 32px 80px rgba(16, 16, 30, 0.5)" }}
|
||||||
|
>
|
||||||
|
{/* Decorative corner flourishes */}
|
||||||
|
<span className="absolute top-3 left-3 w-5 h-5 border-t border-l border-[var(--gold)]/30 pointer-events-none" />
|
||||||
|
<span className="absolute top-3 right-3 w-5 h-5 border-t border-r border-[var(--gold)]/30 pointer-events-none" />
|
||||||
|
|
||||||
|
{(title || eyebrow) && (
|
||||||
|
<div className="relative px-7 pt-7 pb-5 border-b border-[var(--border-muted)]">
|
||||||
|
{eyebrow && (
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<span className="block w-5 h-px bg-[var(--gold)]" />
|
||||||
|
<span
|
||||||
|
className="text-[var(--gold)] text-[10px] tracking-[0.32em] uppercase"
|
||||||
|
style={{ fontFamily: "'Playfair Display', serif", fontWeight: 500 }}
|
||||||
|
>
|
||||||
|
{eyebrow}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{title && (
|
||||||
|
<h3
|
||||||
|
className="text-[22px] text-[var(--text-primary)] leading-tight pr-8"
|
||||||
|
style={{ fontFamily: "'Playfair Display', serif", fontWeight: 600 }}
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
</h3>
|
||||||
|
)}
|
||||||
|
{subtitle && <p className="text-xs text-[var(--text-muted)] mt-1.5">{subtitle}</p>}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
className="absolute top-6 right-5 w-8 h-8 flex items-center justify-center rounded-full text-[var(--text-muted)] hover:bg-[var(--bg-paper)] hover:text-[var(--text-primary)] transition-colors"
|
||||||
|
aria-label="关闭"
|
||||||
|
>
|
||||||
|
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2">
|
||||||
|
<path d="M18 6L6 18M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div>{children}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { useNavigate, useParams } from "react-router-dom";
|
import Modal from "./Modal";
|
||||||
import { adminFetch } from "./adminApi";
|
import { adminFetch } from "./adminApi";
|
||||||
|
import { useToast } from "./Toast";
|
||||||
|
import { Field, ErrorRow, FormFooter, HintRow, fieldCls } from "./FormPrimitives";
|
||||||
|
|
||||||
type Music = {
|
type Music = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -11,11 +13,16 @@ type Music = {
|
|||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function MusicForm() {
|
type Props = {
|
||||||
const { id } = useParams();
|
open: boolean;
|
||||||
const navigate = useNavigate();
|
id: string | null;
|
||||||
const isEdit = !!id;
|
onClose: () => void;
|
||||||
|
onSaved: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function MusicForm({ open, id, onClose, onSaved }: Props) {
|
||||||
|
const toast = useToast();
|
||||||
|
const [currentId, setCurrentId] = useState<string | null>(null);
|
||||||
const [title, setTitle] = useState("");
|
const [title, setTitle] = useState("");
|
||||||
const [subtitle, setSubtitle] = useState("");
|
const [subtitle, setSubtitle] = useState("");
|
||||||
const [audioFile, setAudioFile] = useState("");
|
const [audioFile, setAudioFile] = useState("");
|
||||||
@@ -25,8 +32,17 @@ export default function MusicForm() {
|
|||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
|
const isEdit = !!currentId;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!id) return;
|
if (!open) return;
|
||||||
|
setCurrentId(id);
|
||||||
|
setError("");
|
||||||
|
if (!id) {
|
||||||
|
setTitle(""); setSubtitle(""); setAudioFile("");
|
||||||
|
setSortOrder(0); setEnabled(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
adminFetch<Music[]>("/music").then((list) => {
|
adminFetch<Music[]>("/music").then((list) => {
|
||||||
const item = list.find((m) => m.id === id);
|
const item = list.find((m) => m.id === id);
|
||||||
if (item) {
|
if (item) {
|
||||||
@@ -37,10 +53,10 @@ export default function MusicForm() {
|
|||||||
setEnabled(item.enabled);
|
setEnabled(item.enabled);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}, [id]);
|
}, [open, id]);
|
||||||
|
|
||||||
const handleUpload = async (file: File) => {
|
const handleUpload = async (file: File) => {
|
||||||
if (!id) {
|
if (!currentId) {
|
||||||
setError("请先保存后再上传音频");
|
setError("请先保存后再上传音频");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -49,11 +65,13 @@ export default function MusicForm() {
|
|||||||
try {
|
try {
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append("audio", file);
|
formData.append("audio", file);
|
||||||
const data = await adminFetch<{ path: string }>(`/music/${id}/upload`, {
|
const data = await adminFetch<{ path: string }>(`/music/${currentId}/upload`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: formData,
|
body: formData,
|
||||||
});
|
});
|
||||||
setAudioFile(data.path);
|
setAudioFile(data.path);
|
||||||
|
toast.show("音频已上传");
|
||||||
|
onSaved();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(e instanceof Error ? e.message : "上传失败");
|
setError(e instanceof Error ? e.message : "上传失败");
|
||||||
} finally {
|
} finally {
|
||||||
@@ -63,10 +81,7 @@ export default function MusicForm() {
|
|||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
setError("");
|
setError("");
|
||||||
if (!title.trim()) {
|
if (!title.trim()) return setError("请输入标题");
|
||||||
setError("请输入标题");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
try {
|
try {
|
||||||
const payload = {
|
const payload = {
|
||||||
@@ -76,19 +91,22 @@ export default function MusicForm() {
|
|||||||
enabled,
|
enabled,
|
||||||
};
|
};
|
||||||
if (isEdit) {
|
if (isEdit) {
|
||||||
await adminFetch(`/music/${id}`, {
|
await adminFetch(`/music/${currentId}`, {
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
body: JSON.stringify(payload),
|
body: JSON.stringify(payload),
|
||||||
});
|
});
|
||||||
|
toast.show("已保存");
|
||||||
|
onSaved();
|
||||||
|
onClose();
|
||||||
} else {
|
} else {
|
||||||
const music = await adminFetch<Music>("/music", {
|
const music = await adminFetch<Music>("/music", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify(payload),
|
body: JSON.stringify(payload),
|
||||||
});
|
});
|
||||||
navigate(`/admin/music/${music.id}/edit`, { replace: true });
|
setCurrentId(music.id);
|
||||||
return;
|
toast.show("已创建,现在可以上传音频");
|
||||||
|
onSaved();
|
||||||
}
|
}
|
||||||
navigate("/admin/music");
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(e instanceof Error ? e.message : "保存失败");
|
setError(e instanceof Error ? e.message : "保存失败");
|
||||||
} finally {
|
} finally {
|
||||||
@@ -97,102 +115,88 @@ export default function MusicForm() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-xl">
|
<Modal
|
||||||
<h2 className="text-lg font-semibold text-gray-800 mb-4">
|
open={open}
|
||||||
{isEdit ? "编辑音乐" : "添加音乐"}
|
onClose={onClose}
|
||||||
</h2>
|
size="md"
|
||||||
|
eyebrow={isEdit ? "Edit Music" : "New Music"}
|
||||||
<div className="bg-white rounded-lg border border-gray-200 p-5 space-y-4">
|
title={isEdit ? "编辑音乐" : "添加音乐"}
|
||||||
<div>
|
subtitle={isEdit ? "调整信息与上传音频" : "先保存基础信息,再上传音频文件"}
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">标题</label>
|
>
|
||||||
|
<div className="px-7 py-6 space-y-5">
|
||||||
|
<Field label="标题" required>
|
||||||
<input
|
<input
|
||||||
value={title}
|
value={title}
|
||||||
onChange={(e) => setTitle(e.target.value)}
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
placeholder="如:朝天宫之歌"
|
placeholder="如:朝天宫之歌"
|
||||||
className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm
|
className={fieldCls}
|
||||||
focus:outline-none focus:ring-1 focus:ring-blue-500"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</Field>
|
||||||
|
|
||||||
<div>
|
<Field label="副标题">
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">副标题</label>
|
|
||||||
<input
|
<input
|
||||||
value={subtitle}
|
value={subtitle}
|
||||||
onChange={(e) => setSubtitle(e.target.value)}
|
onChange={(e) => setSubtitle(e.target.value)}
|
||||||
placeholder="选填,如:金陵千年韵"
|
placeholder="选填,如:金陵千年韵"
|
||||||
className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm
|
className={fieldCls}
|
||||||
focus:outline-none focus:ring-1 focus:ring-blue-500"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</Field>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-[auto_1fr] gap-5 items-end">
|
||||||
<div>
|
<Field label="排序">
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">排序</label>
|
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
value={sortOrder}
|
value={sortOrder}
|
||||||
onChange={(e) => setSortOrder(Number(e.target.value))}
|
onChange={(e) => setSortOrder(Number(e.target.value))}
|
||||||
className="w-24 px-3 py-2 border border-gray-300 rounded-md text-sm
|
className={fieldCls + " w-28"}
|
||||||
focus:outline-none focus:ring-1 focus:ring-blue-500"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</Field>
|
||||||
<div className="flex items-center pt-6">
|
<label className="flex items-center gap-2.5 cursor-pointer select-none pb-2.5">
|
||||||
<label className="flex items-center gap-2 text-sm text-gray-700">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={enabled}
|
|
||||||
onChange={(e) => setEnabled(e.target.checked)}
|
|
||||||
/>
|
|
||||||
启用
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{isEdit && (
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
|
||||||
音频文件
|
|
||||||
<span className="text-xs text-gray-400 font-normal ml-2">
|
|
||||||
支持 MP3 / M4A / WAV,≤ 20 MB
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
{audioFile && (
|
|
||||||
<audio src={audioFile} controls preload="metadata" className="w-full mb-2" />
|
|
||||||
)}
|
|
||||||
<input
|
<input
|
||||||
type="file"
|
type="checkbox"
|
||||||
accept="audio/*,.mp3,.m4a,.wav,.ogg"
|
checked={enabled}
|
||||||
onChange={(e) => e.target.files?.[0] && handleUpload(e.target.files[0])}
|
onChange={(e) => setEnabled(e.target.checked)}
|
||||||
disabled={uploading}
|
className="w-4 h-4 accent-[var(--jade)]"
|
||||||
className="text-xs text-gray-500"
|
|
||||||
/>
|
/>
|
||||||
{uploading && <p className="text-xs text-gray-500 mt-1">上传中…</p>}
|
<span className="text-sm text-[var(--text-secondary)]">启用(访客可访问)</span>
|
||||||
</div>
|
</label>
|
||||||
)}
|
|
||||||
|
|
||||||
{!isEdit && (
|
|
||||||
<p className="text-xs text-gray-400">保存后可上传音频文件</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{error && <p className="text-sm text-red-500">{error}</p>}
|
|
||||||
|
|
||||||
<div className="flex gap-3 pt-2">
|
|
||||||
<button
|
|
||||||
onClick={handleSave}
|
|
||||||
disabled={saving}
|
|
||||||
className="px-4 py-2 bg-blue-600 text-white text-sm rounded-md
|
|
||||||
hover:bg-blue-700 disabled:opacity-50"
|
|
||||||
>
|
|
||||||
{saving ? "保存中..." : "保存"}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => navigate("/admin/music")}
|
|
||||||
className="px-4 py-2 border border-gray-300 text-gray-600 text-sm rounded-md hover:bg-gray-50"
|
|
||||||
>
|
|
||||||
取消
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{isEdit ? (
|
||||||
|
<Field label="音频文件" hint="MP3 / M4A / WAV,≤ 20 MB">
|
||||||
|
<div className="rounded-xl border border-[var(--border-muted)] bg-white p-4 space-y-3">
|
||||||
|
{audioFile ? (
|
||||||
|
<audio src={audioFile} controls preload="metadata" className="w-full" />
|
||||||
|
) : (
|
||||||
|
<div className="text-xs text-[var(--text-muted)] py-4 text-center border border-dashed border-[var(--border-muted)] rounded-lg">
|
||||||
|
尚未上传音频
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<label className="cursor-pointer inline-flex items-center gap-2 text-[13px] text-[var(--text-secondary)] hover:text-[var(--terracotta)] transition-colors">
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept="audio/*,.mp3,.m4a,.wav,.ogg"
|
||||||
|
onChange={(e) => e.target.files?.[0] && handleUpload(e.target.files[0])}
|
||||||
|
disabled={uploading}
|
||||||
|
className="hidden"
|
||||||
|
/>
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4M17 8l-5-5-5 5M12 3v12" />
|
||||||
|
</svg>
|
||||||
|
<span className="underline underline-offset-2 decoration-dotted">
|
||||||
|
{uploading ? "上传中…" : audioFile ? "更换音频" : "选择音频文件"}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</Field>
|
||||||
|
) : (
|
||||||
|
<HintRow text="保存基础信息后,即可上传音频文件" />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && <ErrorRow text={error} />}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
<FormFooter onCancel={onClose} onSave={handleSave} saving={saving} primaryLabel={isEdit ? "保存" : "创建"} />
|
||||||
|
</Modal>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,21 @@
|
|||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { Link } from "react-router-dom";
|
|
||||||
import { adminFetch } from "./adminApi";
|
import { adminFetch } from "./adminApi";
|
||||||
|
import { useToast } from "./Toast";
|
||||||
|
import { copyItemLink } from "./utils";
|
||||||
|
import MusicForm from "./MusicForm";
|
||||||
|
import QRCodeModal from "./QRCodeModal";
|
||||||
|
import PageHeader, {
|
||||||
|
PrimaryButton,
|
||||||
|
StatusChip,
|
||||||
|
ActionButton,
|
||||||
|
EmptyState,
|
||||||
|
LoadingBlock,
|
||||||
|
IconEdit,
|
||||||
|
IconCopy,
|
||||||
|
IconQR,
|
||||||
|
IconDelete,
|
||||||
|
} from "./PageHeader";
|
||||||
|
import { TableCard, TableHeadRow } from "./StampList";
|
||||||
|
|
||||||
type Music = {
|
type Music = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -12,11 +27,14 @@ type Music = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default function MusicList() {
|
export default function MusicList() {
|
||||||
|
const toast = useToast();
|
||||||
const [music, setMusic] = useState<Music[]>([]);
|
const [music, setMusic] = useState<Music[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
const [formState, setFormState] = useState<{ open: boolean; id: string | null }>({ open: false, id: null });
|
||||||
|
const [qrState, setQrState] = useState<{ open: boolean; id: string | null }>({ open: false, id: null });
|
||||||
|
|
||||||
const fetchMusic = async () => {
|
const fetchMusic = async () => {
|
||||||
setLoading(true);
|
|
||||||
try {
|
try {
|
||||||
const data = await adminFetch<Music[]>("/music");
|
const data = await adminFetch<Music[]>("/music");
|
||||||
setMusic(data);
|
setMusic(data);
|
||||||
@@ -25,12 +43,19 @@ export default function MusicList() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => { fetchMusic(); }, []);
|
useEffect(() => {
|
||||||
|
fetchMusic();
|
||||||
|
}, []);
|
||||||
|
|
||||||
const handleDelete = async (id: string, title: string) => {
|
const handleDelete = async (id: string, title: string) => {
|
||||||
if (!confirm(`确定删除音乐「${title}」?`)) return;
|
if (!confirm(`确定删除音乐「${title}」?`)) return;
|
||||||
await adminFetch(`/music/${id}`, { method: "DELETE" });
|
try {
|
||||||
fetchMusic();
|
await adminFetch(`/music/${id}`, { method: "DELETE" });
|
||||||
|
toast.show("已删除");
|
||||||
|
fetchMusic();
|
||||||
|
} catch (e) {
|
||||||
|
toast.show(e instanceof Error ? e.message : "删除失败", "error");
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleToggle = async (id: string, enabled: boolean) => {
|
const handleToggle = async (id: string, enabled: boolean) => {
|
||||||
@@ -41,78 +66,134 @@ export default function MusicList() {
|
|||||||
fetchMusic();
|
fetchMusic();
|
||||||
};
|
};
|
||||||
|
|
||||||
if (loading) return <p className="text-gray-500">加载中...</p>;
|
const handleCopyLink = async (id: string) => {
|
||||||
|
try {
|
||||||
|
await copyItemLink("music", id);
|
||||||
|
toast.show("链接已复制");
|
||||||
|
} catch {
|
||||||
|
toast.show("复制失败", "error");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<>
|
||||||
<div className="flex items-center justify-between mb-4">
|
<PageHeader
|
||||||
<h2 className="text-lg font-semibold text-gray-800">音乐管理</h2>
|
eyebrow="03 · Music"
|
||||||
<Link
|
title="音乐管理"
|
||||||
to="/admin/music/new"
|
caption="音频作品与对应点位的 NFC 链接"
|
||||||
className="px-4 py-2 bg-blue-600 text-white text-sm rounded-md hover:bg-blue-700"
|
action={<PrimaryButton onClick={() => setFormState({ open: true, id: null })}>添加音乐</PrimaryButton>}
|
||||||
>
|
/>
|
||||||
添加音乐
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="bg-white rounded-lg border border-gray-200 overflow-hidden">
|
{loading ? (
|
||||||
<table className="w-full text-sm">
|
<LoadingBlock />
|
||||||
<thead className="bg-gray-50 border-b border-gray-200">
|
) : (
|
||||||
<tr>
|
<TableCard>
|
||||||
<th className="text-left px-4 py-3 font-medium text-gray-600">标题</th>
|
{music.length === 0 ? (
|
||||||
<th className="text-left px-4 py-3 font-medium text-gray-600">副标题</th>
|
<EmptyState
|
||||||
<th className="text-left px-4 py-3 font-medium text-gray-600">音频</th>
|
message="尚未上传音乐"
|
||||||
<th className="text-center px-4 py-3 font-medium text-gray-600">排序</th>
|
action={
|
||||||
<th className="text-center px-4 py-3 font-medium text-gray-600">状态</th>
|
<PrimaryButton onClick={() => setFormState({ open: true, id: null })}>
|
||||||
<th className="text-right px-4 py-3 font-medium text-gray-600">操作</th>
|
添加第一首音乐
|
||||||
</tr>
|
</PrimaryButton>
|
||||||
</thead>
|
}
|
||||||
<tbody>
|
/>
|
||||||
{music.map((item) => (
|
) : (
|
||||||
<tr key={item.id} className="border-b border-gray-100 hover:bg-gray-50">
|
<table className="w-full">
|
||||||
<td className="px-4 py-3 text-gray-800 font-medium">{item.title}</td>
|
<thead>
|
||||||
<td className="px-4 py-3 text-gray-500 max-w-[220px] truncate">{item.subtitle || "—"}</td>
|
<TableHeadRow cols={["标题 · 副标题", "音频", "排序", "状态", "操作"]} />
|
||||||
<td className="px-4 py-3 text-gray-500 text-xs">
|
</thead>
|
||||||
{item.audioFile ? (
|
<tbody>
|
||||||
<audio src={item.audioFile} controls preload="none" className="h-8 max-w-[220px]" />
|
{music.map((item, i) => (
|
||||||
) : (
|
<tr
|
||||||
<span className="text-gray-300">未上传</span>
|
key={item.id}
|
||||||
)}
|
className="border-t border-[var(--border-muted)] hover:bg-white/60 transition-colors animate-admin-row"
|
||||||
</td>
|
style={{ animationDelay: `${Math.min(i, 12) * 0.03}s` }}
|
||||||
<td className="px-4 py-3 text-center text-gray-500">{item.sortOrder}</td>
|
|
||||||
<td className="px-4 py-3 text-center">
|
|
||||||
<button
|
|
||||||
onClick={() => handleToggle(item.id, item.enabled)}
|
|
||||||
className={`px-2 py-0.5 rounded text-xs ${
|
|
||||||
item.enabled ? "bg-green-100 text-green-700" : "bg-gray-100 text-gray-500"
|
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
{item.enabled ? "启用" : "禁用"}
|
<td className="px-5 py-4">
|
||||||
</button>
|
<div className="flex items-center gap-3">
|
||||||
</td>
|
<div
|
||||||
<td className="px-4 py-3 text-right space-x-2 whitespace-nowrap">
|
className="w-10 h-10 rounded-full flex items-center justify-center shrink-0 border border-[var(--border-muted)]"
|
||||||
<Link to={`/admin/music/${item.id}/edit`} className="text-blue-600 hover:underline">
|
style={{ backgroundColor: "rgba(212,165,116,0.08)" }}
|
||||||
编辑
|
>
|
||||||
</Link>
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="var(--gold)" strokeWidth="1.8">
|
||||||
<Link to={`/admin/music/${item.id}/qrcode`} className="text-blue-600 hover:underline">
|
<path d="M9 18V5l12-2v13M9 9l12-2" />
|
||||||
二维码
|
<circle cx="6" cy="18" r="3" />
|
||||||
</Link>
|
<circle cx="18" cy="16" r="3" />
|
||||||
<button onClick={() => handleDelete(item.id, item.title)} className="text-red-500 hover:underline">
|
</svg>
|
||||||
删除
|
</div>
|
||||||
</button>
|
<div className="min-w-0">
|
||||||
</td>
|
<p className="text-[15px] font-medium text-[var(--text-primary)]">{item.title}</p>
|
||||||
</tr>
|
{item.subtitle && (
|
||||||
))}
|
<p className="text-xs text-[var(--text-muted)] mt-0.5 max-w-[260px] truncate">
|
||||||
{music.length === 0 && (
|
{item.subtitle}
|
||||||
<tr>
|
</p>
|
||||||
<td colSpan={6} className="px-4 py-8 text-center text-gray-400">
|
)}
|
||||||
暂无音乐,点击右上角添加
|
</div>
|
||||||
</td>
|
</div>
|
||||||
</tr>
|
</td>
|
||||||
)}
|
<td className="px-5 py-4 w-[260px]">
|
||||||
</tbody>
|
{item.audioFile ? (
|
||||||
</table>
|
<audio
|
||||||
</div>
|
src={item.audioFile}
|
||||||
</div>
|
controls
|
||||||
|
preload="none"
|
||||||
|
className="h-8 max-w-[240px]"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span className="text-xs text-[var(--text-muted)]/60">未上传</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-5 py-4 text-center w-[80px]">
|
||||||
|
<span
|
||||||
|
className="text-[13px] text-[var(--text-secondary)]"
|
||||||
|
style={{ fontFamily: "'Playfair Display', serif" }}
|
||||||
|
>
|
||||||
|
{item.sortOrder}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-5 py-4 text-center w-[110px]">
|
||||||
|
<StatusChip enabled={item.enabled} onClick={() => handleToggle(item.id, item.enabled)} />
|
||||||
|
</td>
|
||||||
|
<td className="px-5 py-4 w-[180px]">
|
||||||
|
<div className="flex items-center justify-end gap-1">
|
||||||
|
<ActionButton title="编辑" onClick={() => setFormState({ open: true, id: item.id })}>
|
||||||
|
{IconEdit}
|
||||||
|
</ActionButton>
|
||||||
|
<ActionButton title="复制链接" onClick={() => handleCopyLink(item.id)}>
|
||||||
|
{IconCopy}
|
||||||
|
</ActionButton>
|
||||||
|
<ActionButton title="二维码" onClick={() => setQrState({ open: true, id: item.id })}>
|
||||||
|
{IconQR}
|
||||||
|
</ActionButton>
|
||||||
|
<ActionButton
|
||||||
|
title="删除"
|
||||||
|
variant="danger"
|
||||||
|
onClick={() => handleDelete(item.id, item.title)}
|
||||||
|
>
|
||||||
|
{IconDelete}
|
||||||
|
</ActionButton>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
</TableCard>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<MusicForm
|
||||||
|
open={formState.open}
|
||||||
|
id={formState.id}
|
||||||
|
onClose={() => setFormState({ open: false, id: null })}
|
||||||
|
onSaved={fetchMusic}
|
||||||
|
/>
|
||||||
|
<QRCodeModal
|
||||||
|
open={qrState.open}
|
||||||
|
type="music"
|
||||||
|
id={qrState.id}
|
||||||
|
onClose={() => setQrState({ open: false, id: null })}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,120 +0,0 @@
|
|||||||
import { useState, useEffect, useRef } from "react";
|
|
||||||
import { useParams, Link } from "react-router-dom";
|
|
||||||
import { adminFetch } from "./adminApi";
|
|
||||||
|
|
||||||
type QRData = {
|
|
||||||
qrDataUrl: string;
|
|
||||||
musicUrl: string;
|
|
||||||
musicTitle: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function MusicQRCode() {
|
|
||||||
const { id } = useParams();
|
|
||||||
const [data, setData] = useState<QRData | null>(null);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [copied, setCopied] = useState(false);
|
|
||||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!id) return;
|
|
||||||
adminFetch<QRData>(`/music/${id}/qrcode`)
|
|
||||||
.then(setData)
|
|
||||||
.finally(() => setLoading(false));
|
|
||||||
}, [id]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!data || !canvasRef.current) return;
|
|
||||||
|
|
||||||
const canvas = canvasRef.current;
|
|
||||||
const ctx = canvas.getContext("2d")!;
|
|
||||||
const img = new Image();
|
|
||||||
img.onload = () => {
|
|
||||||
const padding = 20;
|
|
||||||
const textHeight = 40;
|
|
||||||
canvas.width = img.width + padding * 2;
|
|
||||||
canvas.height = img.height + padding * 2 + textHeight;
|
|
||||||
|
|
||||||
ctx.fillStyle = "#ffffff";
|
|
||||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
|
||||||
|
|
||||||
ctx.drawImage(img, padding, padding);
|
|
||||||
|
|
||||||
ctx.fillStyle = "#666666";
|
|
||||||
ctx.font = "12px sans-serif";
|
|
||||||
ctx.textAlign = "center";
|
|
||||||
ctx.fillText(data.musicUrl, canvas.width / 2, img.height + padding * 2 + 12);
|
|
||||||
};
|
|
||||||
img.src = data.qrDataUrl;
|
|
||||||
}, [data]);
|
|
||||||
|
|
||||||
const handleDownload = () => {
|
|
||||||
if (!canvasRef.current || !data) return;
|
|
||||||
const link = document.createElement("a");
|
|
||||||
link.download = `${data.musicTitle}-二维码.png`;
|
|
||||||
link.href = canvasRef.current.toDataURL("image/png");
|
|
||||||
link.click();
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCopy = async () => {
|
|
||||||
if (!data) return;
|
|
||||||
try {
|
|
||||||
await navigator.clipboard.writeText(data.musicUrl);
|
|
||||||
setCopied(true);
|
|
||||||
setTimeout(() => setCopied(false), 1800);
|
|
||||||
} catch {
|
|
||||||
const ta = document.createElement("textarea");
|
|
||||||
ta.value = data.musicUrl;
|
|
||||||
document.body.appendChild(ta);
|
|
||||||
ta.select();
|
|
||||||
document.execCommand("copy");
|
|
||||||
document.body.removeChild(ta);
|
|
||||||
setCopied(true);
|
|
||||||
setTimeout(() => setCopied(false), 1800);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (loading) return <p className="text-gray-500">加载中...</p>;
|
|
||||||
if (!data) return <p className="text-gray-500">音乐不存在</p>;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="max-w-md">
|
|
||||||
<div className="flex items-center gap-2 mb-4">
|
|
||||||
<Link to="/admin/music" className="text-gray-400 hover:text-gray-600">
|
|
||||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
|
||||||
<path d="M15 18l-6-6 6-6" />
|
|
||||||
</svg>
|
|
||||||
</Link>
|
|
||||||
<h2 className="text-lg font-semibold text-gray-800">{data.musicTitle} — 点位链接</h2>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="bg-white rounded-lg border border-gray-200 p-6 text-center space-y-4">
|
|
||||||
<div className="inline-block">
|
|
||||||
<img src={data.qrDataUrl} alt="二维码" className="w-64 h-64" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p className="text-xs text-gray-500 break-all select-all">{data.musicUrl}</p>
|
|
||||||
|
|
||||||
<canvas ref={canvasRef} className="hidden" />
|
|
||||||
|
|
||||||
<div className="flex flex-wrap items-center justify-center gap-3">
|
|
||||||
<button
|
|
||||||
onClick={handleCopy}
|
|
||||||
className="px-4 py-2 bg-blue-600 text-white text-sm rounded-md hover:bg-blue-700 min-w-[160px]"
|
|
||||||
>
|
|
||||||
{copied ? "已复制 ✓" : "复制链接(写入 NFC)"}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={handleDownload}
|
|
||||||
className="px-4 py-2 border border-gray-300 text-gray-700 text-sm rounded-md hover:bg-gray-50"
|
|
||||||
>
|
|
||||||
下载二维码
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p className="text-xs text-gray-400 pt-1">
|
|
||||||
点位主要通过 NFC 触碰分发,二维码作为备用方式保留
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
161
packages/web/src/admin/PageHeader.tsx
Normal file
161
packages/web/src/admin/PageHeader.tsx
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
eyebrow: string;
|
||||||
|
title: string;
|
||||||
|
caption?: string;
|
||||||
|
action?: ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function PageHeader({ eyebrow, title, caption, action }: Props) {
|
||||||
|
return (
|
||||||
|
<div className="mb-8 flex items-end justify-between gap-6 flex-wrap">
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-3 mb-2.5">
|
||||||
|
<span className="block w-8 h-px bg-[var(--gold)]" />
|
||||||
|
<span
|
||||||
|
className="text-[var(--gold)] text-[10px] tracking-[0.35em] uppercase"
|
||||||
|
style={{ fontFamily: "'Playfair Display', serif", fontWeight: 500 }}
|
||||||
|
>
|
||||||
|
{eyebrow}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<h1
|
||||||
|
className="text-[32px] text-[var(--text-primary)] leading-none"
|
||||||
|
style={{ fontFamily: "'Playfair Display', serif", fontWeight: 600, letterSpacing: "-0.01em" }}
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
</h1>
|
||||||
|
{caption && <p className="mt-2 text-[13px] text-[var(--text-muted)]">{caption}</p>}
|
||||||
|
</div>
|
||||||
|
{action && <div>{action}</div>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PrimaryButton({ onClick, children }: { onClick: () => void; children: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={onClick}
|
||||||
|
className="inline-flex items-center gap-2 px-5 py-2.5 rounded-lg text-white text-sm font-medium transition-all hover:brightness-110"
|
||||||
|
style={{
|
||||||
|
backgroundColor: "var(--terracotta)",
|
||||||
|
boxShadow: "0 6px 18px rgba(199,91,57,0.28)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2">
|
||||||
|
<path d="M12 5v14M5 12h14" />
|
||||||
|
</svg>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StatusChip({
|
||||||
|
enabled,
|
||||||
|
onClick,
|
||||||
|
}: {
|
||||||
|
enabled: boolean;
|
||||||
|
onClick: () => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={onClick}
|
||||||
|
className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-[11px] font-medium transition-colors"
|
||||||
|
style={
|
||||||
|
enabled
|
||||||
|
? { backgroundColor: "rgba(45,106,79,0.1)", color: "var(--jade)" }
|
||||||
|
: { backgroundColor: "rgba(138,132,148,0.12)", color: "var(--text-muted)" }
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="inline-block w-1.5 h-1.5 rounded-full"
|
||||||
|
style={{
|
||||||
|
backgroundColor: enabled ? "var(--jade)" : "var(--text-muted)",
|
||||||
|
boxShadow: enabled ? "0 0 6px rgba(45,106,79,0.5)" : "none",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{enabled ? "启用" : "禁用"}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ActionButton({
|
||||||
|
title,
|
||||||
|
onClick,
|
||||||
|
variant = "default",
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
onClick: () => void;
|
||||||
|
variant?: "default" | "danger";
|
||||||
|
children: ReactNode;
|
||||||
|
}) {
|
||||||
|
const danger = variant === "danger";
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
title={title}
|
||||||
|
onClick={onClick}
|
||||||
|
className={`w-8 h-8 flex items-center justify-center rounded-full transition-all ${
|
||||||
|
danger
|
||||||
|
? "text-[var(--text-muted)] hover:bg-[rgba(199,91,57,0.1)] hover:text-[var(--terracotta)]"
|
||||||
|
: "text-[var(--text-muted)] hover:bg-[rgba(212,165,116,0.12)] hover:text-[var(--text-primary)]"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EmptyState({ message, action }: { message: string; action?: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="py-16 flex flex-col items-center gap-4 text-center">
|
||||||
|
<div className="relative">
|
||||||
|
<span className="absolute inset-0 rounded-full border border-dashed border-[var(--gold)]/30 animate-[rotate-slow_30s_linear_infinite]" />
|
||||||
|
<div className="w-16 h-16 rounded-full flex items-center justify-center bg-[var(--bg-paper)]">
|
||||||
|
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="var(--gold)" strokeWidth="1.6">
|
||||||
|
<circle cx="12" cy="12" r="10" />
|
||||||
|
<path d="M12 8v4M12 16h.01" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-[var(--text-muted)]">{message}</p>
|
||||||
|
{action}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LoadingBlock() {
|
||||||
|
return (
|
||||||
|
<div className="py-20 flex justify-center">
|
||||||
|
<div className="w-8 h-8 border-2 border-[var(--gold)] border-t-transparent rounded-full animate-spin" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ————— Row-action icons ————— */
|
||||||
|
export const IconEdit = (
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<path d="M11 4H4a2 2 0 00-2 2v14a2 2 0 002 2h14a2 2 0 002-2v-7" />
|
||||||
|
<path d="M18.5 2.5a2.121 2.121 0 013 3L12 15l-4 1 1-4 9.5-9.5z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
export const IconCopy = (
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<rect x="9" y="9" width="13" height="13" rx="2" ry="2" />
|
||||||
|
<path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
export const IconQR = (
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<rect x="3" y="3" width="7" height="7" />
|
||||||
|
<rect x="14" y="3" width="7" height="7" />
|
||||||
|
<rect x="3" y="14" width="7" height="7" />
|
||||||
|
<path d="M14 14h3v3h-3zM17 17h4M17 21h4M21 14v3" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
export const IconDelete = (
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<path d="M3 6h18M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2M19 6l-1 14a2 2 0 01-2 2H8a2 2 0 01-2-2L5 6M10 11v6M14 11v6" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
143
packages/web/src/admin/QRCodeModal.tsx
Normal file
143
packages/web/src/admin/QRCodeModal.tsx
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import Modal from "./Modal";
|
||||||
|
import { adminFetch } from "./adminApi";
|
||||||
|
import { copyText, type LinkType } from "./utils";
|
||||||
|
import { useToast } from "./Toast";
|
||||||
|
|
||||||
|
type QRResponse = {
|
||||||
|
qrDataUrl: string;
|
||||||
|
[urlKey: string]: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const CFG: Record<LinkType, { path: (id: string) => string; urlKey: string; nameKey: string; label: string }> = {
|
||||||
|
stamp: { path: (id) => `/stamps/${id}/qrcode`, urlKey: "collectUrl", nameKey: "stampName", label: "Stamp" },
|
||||||
|
article: { path: (id) => `/articles/${id}/qrcode`, urlKey: "articleUrl", nameKey: "articleTitle", label: "Article" },
|
||||||
|
music: { path: (id) => `/music/${id}/qrcode`, urlKey: "musicUrl", nameKey: "musicTitle", label: "Music" },
|
||||||
|
};
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
open: boolean;
|
||||||
|
type: LinkType;
|
||||||
|
id: string | null;
|
||||||
|
onClose: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function QRCodeModal({ open, type, id, onClose }: Props) {
|
||||||
|
const cfg = CFG[type];
|
||||||
|
const [data, setData] = useState<{ qrDataUrl: string; url: string; name: string } | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
|
const toast = useToast();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open || !id) {
|
||||||
|
setData(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setLoading(true);
|
||||||
|
adminFetch<QRResponse>(cfg.path(id))
|
||||||
|
.then((res) =>
|
||||||
|
setData({
|
||||||
|
qrDataUrl: res.qrDataUrl,
|
||||||
|
url: res[cfg.urlKey],
|
||||||
|
name: res[cfg.nameKey],
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, [open, id, cfg]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!data || !canvasRef.current) return;
|
||||||
|
const canvas = canvasRef.current;
|
||||||
|
const ctx = canvas.getContext("2d");
|
||||||
|
if (!ctx) return;
|
||||||
|
const img = new Image();
|
||||||
|
img.onload = () => {
|
||||||
|
const pad = 24;
|
||||||
|
const textH = 40;
|
||||||
|
canvas.width = img.width + pad * 2;
|
||||||
|
canvas.height = img.height + pad * 2 + textH;
|
||||||
|
ctx.fillStyle = "#ffffff";
|
||||||
|
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||||
|
ctx.drawImage(img, pad, pad);
|
||||||
|
ctx.fillStyle = "#4a4553";
|
||||||
|
ctx.font = "12px sans-serif";
|
||||||
|
ctx.textAlign = "center";
|
||||||
|
ctx.fillText(data.url, canvas.width / 2, img.height + pad * 2 + 14);
|
||||||
|
};
|
||||||
|
img.src = data.qrDataUrl;
|
||||||
|
}, [data]);
|
||||||
|
|
||||||
|
const handleCopy = async () => {
|
||||||
|
if (!data) return;
|
||||||
|
await copyText(data.url);
|
||||||
|
toast.show("链接已复制");
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDownload = () => {
|
||||||
|
if (!canvasRef.current || !data) return;
|
||||||
|
const link = document.createElement("a");
|
||||||
|
link.download = `${data.name}-二维码.png`;
|
||||||
|
link.href = canvasRef.current.toDataURL("image/png");
|
||||||
|
link.click();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal open={open} onClose={onClose} size="sm" eyebrow={cfg.label} title={data?.name ?? "点位链接"}>
|
||||||
|
<div className="px-7 py-7">
|
||||||
|
{loading || !data ? (
|
||||||
|
<div className="py-16 flex justify-center">
|
||||||
|
<div className="w-8 h-8 border-2 border-[var(--gold)] border-t-transparent rounded-full animate-spin" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="relative mx-auto w-56 h-56">
|
||||||
|
<span className="absolute -top-1 -left-1 w-4 h-4 border-t-2 border-l-2 border-[var(--gold)]/50" />
|
||||||
|
<span className="absolute -top-1 -right-1 w-4 h-4 border-t-2 border-r-2 border-[var(--gold)]/50" />
|
||||||
|
<span className="absolute -bottom-1 -left-1 w-4 h-4 border-b-2 border-l-2 border-[var(--gold)]/50" />
|
||||||
|
<span className="absolute -bottom-1 -right-1 w-4 h-4 border-b-2 border-r-2 border-[var(--gold)]/50" />
|
||||||
|
<div
|
||||||
|
className="w-full h-full rounded-xl bg-white p-3"
|
||||||
|
style={{ boxShadow: "0 12px 40px rgba(16,16,30,0.12)" }}
|
||||||
|
>
|
||||||
|
<img src={data.qrDataUrl} alt="二维码" className="w-full h-full" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-7 text-center">
|
||||||
|
<p className="text-[10px] tracking-[0.3em] uppercase text-[var(--text-muted)] mb-2">URL</p>
|
||||||
|
<p className="text-xs text-[var(--text-secondary)] break-all select-all font-mono leading-relaxed px-2">
|
||||||
|
{data.url}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<canvas ref={canvasRef} className="hidden" />
|
||||||
|
|
||||||
|
<div className="mt-7 flex gap-3">
|
||||||
|
<button
|
||||||
|
onClick={handleCopy}
|
||||||
|
className="flex-1 py-3 rounded-xl text-sm font-medium text-white transition-all hover:brightness-110"
|
||||||
|
style={{
|
||||||
|
backgroundColor: "var(--terracotta)",
|
||||||
|
boxShadow: "0 6px 18px rgba(199,91,57,0.3)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
复制链接
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleDownload}
|
||||||
|
className="flex-1 py-3 rounded-xl text-sm font-medium border border-[var(--border-default)] text-[var(--text-secondary)] bg-white hover:bg-[var(--bg-paper)] transition-colors"
|
||||||
|
>
|
||||||
|
下载二维码
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="mt-5 text-[11px] text-[var(--text-muted)] text-center leading-relaxed">
|
||||||
|
点位主要通过 NFC 触碰分发,二维码作为备用
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { adminFetch } from "./adminApi";
|
import { adminFetch } from "./adminApi";
|
||||||
|
import PageHeader, { LoadingBlock } from "./PageHeader";
|
||||||
|
import { TableCard, TableHeadRow } from "./StampList";
|
||||||
|
|
||||||
type RedemptionRecord = {
|
type RedemptionRecord = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -16,6 +18,12 @@ type Stats = {
|
|||||||
redemptionCount: number;
|
redemptionCount: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const STAT_CARDS: { key: keyof Stats; label: string; eyebrow: string; accent: string }[] = [
|
||||||
|
{ key: "userCount", label: "注册用户", eyebrow: "Users", accent: "var(--jade)" },
|
||||||
|
{ key: "collectionCount", label: "当前收集数", eyebrow: "Collected", accent: "var(--gold)" },
|
||||||
|
{ key: "redemptionCount", label: "累计兑换", eyebrow: "Redeemed", accent: "var(--terracotta)" },
|
||||||
|
];
|
||||||
|
|
||||||
export default function RedemptionLog() {
|
export default function RedemptionLog() {
|
||||||
const [records, setRecords] = useState<RedemptionRecord[]>([]);
|
const [records, setRecords] = useState<RedemptionRecord[]>([]);
|
||||||
const [stats, setStats] = useState<Stats | null>(null);
|
const [stats, setStats] = useState<Stats | null>(null);
|
||||||
@@ -33,62 +41,126 @@ export default function RedemptionLog() {
|
|||||||
.finally(() => setLoading(false));
|
.finally(() => setLoading(false));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
if (loading) return <p className="text-gray-500">加载中...</p>;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<>
|
||||||
<h2 className="text-lg font-semibold text-gray-800 mb-4">兑换记录</h2>
|
<PageHeader
|
||||||
|
eyebrow="05 · Log"
|
||||||
|
title="兑换记录"
|
||||||
|
caption="账户、图章收集与兑换的完整轨迹"
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Stats */}
|
{loading ? (
|
||||||
{stats && (
|
<LoadingBlock />
|
||||||
<div className="grid grid-cols-3 gap-4 mb-6">
|
) : (
|
||||||
{[
|
<>
|
||||||
{ label: "注册用户", value: stats.userCount },
|
{/* Stats */}
|
||||||
{ label: "当前收集数", value: stats.collectionCount },
|
{stats && (
|
||||||
{ label: "累计兑换", value: stats.redemptionCount },
|
<div className="grid grid-cols-3 gap-5 mb-8">
|
||||||
].map((s) => (
|
{STAT_CARDS.map((card, i) => (
|
||||||
<div key={s.label} className="bg-white rounded-lg border border-gray-200 p-4 text-center">
|
<div
|
||||||
<p className="text-2xl font-semibold text-gray-800">{s.value}</p>
|
key={card.key}
|
||||||
<p className="text-xs text-gray-500 mt-1">{s.label}</p>
|
className="relative rounded-2xl bg-white/80 border border-[var(--border-muted)] p-6 overflow-hidden animate-admin-row"
|
||||||
|
style={{
|
||||||
|
animationDelay: `${i * 0.08}s`,
|
||||||
|
boxShadow: "0 1px 2px rgba(16,16,30,0.04), 0 8px 24px rgba(16,16,30,0.04)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="absolute top-0 left-0 right-0 h-0.5"
|
||||||
|
style={{ backgroundColor: card.accent }}
|
||||||
|
/>
|
||||||
|
<div className="flex items-center gap-2 mb-3">
|
||||||
|
<span className="block w-4 h-px" style={{ backgroundColor: card.accent, opacity: 0.7 }} />
|
||||||
|
<span
|
||||||
|
className="text-[10px] tracking-[0.3em] uppercase"
|
||||||
|
style={{
|
||||||
|
fontFamily: "'Playfair Display', serif",
|
||||||
|
fontWeight: 500,
|
||||||
|
color: card.accent,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{card.eyebrow}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p
|
||||||
|
className="text-[44px] leading-none text-[var(--text-primary)]"
|
||||||
|
style={{ fontFamily: "'Playfair Display', serif", fontWeight: 600, letterSpacing: "-0.02em" }}
|
||||||
|
>
|
||||||
|
{stats[card.key]}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-[var(--text-secondary)] mt-2">{card.label}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
))}
|
)}
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Records table */}
|
{/* Records */}
|
||||||
<div className="bg-white rounded-lg border border-gray-200 overflow-hidden">
|
<div className="flex items-center gap-3 mb-4">
|
||||||
<table className="w-full text-sm">
|
<span className="block w-5 h-px bg-[var(--gold)]/40" />
|
||||||
<thead className="bg-gray-50 border-b border-gray-200">
|
<span
|
||||||
<tr>
|
className="text-[10px] tracking-[0.3em] uppercase text-[var(--text-muted)]"
|
||||||
<th className="text-left px-4 py-3 font-medium text-gray-600">用户</th>
|
style={{ fontFamily: "'Playfair Display', serif" }}
|
||||||
<th className="text-left px-4 py-3 font-medium text-gray-600">手机号</th>
|
>
|
||||||
<th className="text-left px-4 py-3 font-medium text-gray-600">兑换奖品</th>
|
Ledger
|
||||||
<th className="text-center px-4 py-3 font-medium text-gray-600">扣除枚数</th>
|
</span>
|
||||||
<th className="text-right px-4 py-3 font-medium text-gray-600">时间</th>
|
</div>
|
||||||
</tr>
|
|
||||||
</thead>
|
<TableCard>
|
||||||
<tbody>
|
{records.length === 0 ? (
|
||||||
{records.map((r) => (
|
<div className="py-16 flex flex-col items-center gap-3">
|
||||||
<tr key={r.id} className="border-b border-gray-100">
|
<div className="w-14 h-14 rounded-full flex items-center justify-center bg-[var(--bg-paper)]">
|
||||||
<td className="px-4 py-3 text-gray-800">{r.user.username}</td>
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="var(--gold)" strokeWidth="1.6">
|
||||||
<td className="px-4 py-3 text-gray-500">{r.user.phone}</td>
|
<path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z" />
|
||||||
<td className="px-4 py-3 text-gray-700">{r.rule.name}</td>
|
<path d="M14 2v6h6M16 13H8M16 17H8M10 9H8" />
|
||||||
<td className="px-4 py-3 text-center text-gray-500">{r.stampCount}</td>
|
</svg>
|
||||||
<td className="px-4 py-3 text-right text-gray-500">
|
</div>
|
||||||
{new Date(r.redeemedAt).toLocaleString("zh-CN")}
|
<p className="text-sm text-[var(--text-muted)]">暂无兑换记录</p>
|
||||||
</td>
|
</div>
|
||||||
</tr>
|
) : (
|
||||||
))}
|
<table className="w-full">
|
||||||
{records.length === 0 && (
|
<thead>
|
||||||
<tr>
|
<TableHeadRow cols={["用户", "手机号", "兑换奖品", "扣除枚数", "时间"]} />
|
||||||
<td colSpan={5} className="px-4 py-8 text-center text-gray-400">
|
</thead>
|
||||||
暂无兑换记录
|
<tbody>
|
||||||
</td>
|
{records.map((r, i) => (
|
||||||
</tr>
|
<tr
|
||||||
|
key={r.id}
|
||||||
|
className="border-t border-[var(--border-muted)] hover:bg-white/60 transition-colors animate-admin-row"
|
||||||
|
style={{ animationDelay: `${Math.min(i, 12) * 0.03}s` }}
|
||||||
|
>
|
||||||
|
<td className="px-5 py-4">
|
||||||
|
<span className="text-[15px] font-medium text-[var(--text-primary)]">
|
||||||
|
{r.user.username}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-5 py-4">
|
||||||
|
<span className="text-sm text-[var(--text-secondary)] font-mono">{r.user.phone}</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-5 py-4">
|
||||||
|
<span className="text-sm text-[var(--text-secondary)]">{r.rule.name}</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-5 py-4 text-center w-[140px]">
|
||||||
|
<span
|
||||||
|
className="inline-flex items-baseline gap-1 text-[var(--terracotta)]"
|
||||||
|
style={{ fontFamily: "'Playfair Display', serif" }}
|
||||||
|
>
|
||||||
|
<span className="text-xl font-semibold leading-none">−{r.stampCount}</span>
|
||||||
|
<span className="text-[10px] tracking-[0.2em] uppercase opacity-70">枚</span>
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-5 py-4 text-right w-[200px]">
|
||||||
|
<span className="text-xs text-[var(--text-muted)] font-mono">
|
||||||
|
{new Date(r.redeemedAt).toLocaleString("zh-CN")}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
)}
|
)}
|
||||||
</tbody>
|
</TableCard>
|
||||||
</table>
|
</>
|
||||||
</div>
|
)}
|
||||||
</div>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { useNavigate, useParams } from "react-router-dom";
|
import Modal from "./Modal";
|
||||||
import { adminFetch } from "./adminApi";
|
import { adminFetch } from "./adminApi";
|
||||||
|
import { useToast } from "./Toast";
|
||||||
|
import { Field, ErrorRow, FormFooter, fieldCls } from "./FormPrimitives";
|
||||||
|
|
||||||
type Rule = {
|
type Rule = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -11,11 +13,15 @@ type Rule = {
|
|||||||
sortOrder: number;
|
sortOrder: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function RuleForm() {
|
type Props = {
|
||||||
const { id } = useParams();
|
open: boolean;
|
||||||
const navigate = useNavigate();
|
id: string | null;
|
||||||
const isEdit = !!id;
|
onClose: () => void;
|
||||||
|
onSaved: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function RuleForm({ open, id, onClose, onSaved }: Props) {
|
||||||
|
const toast = useToast();
|
||||||
const [name, setName] = useState("");
|
const [name, setName] = useState("");
|
||||||
const [description, setDescription] = useState("");
|
const [description, setDescription] = useState("");
|
||||||
const [threshold, setThreshold] = useState(1);
|
const [threshold, setThreshold] = useState(1);
|
||||||
@@ -23,8 +29,15 @@ export default function RuleForm() {
|
|||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
|
const isEdit = !!id;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!id) return;
|
if (!open) return;
|
||||||
|
setError("");
|
||||||
|
if (!id) {
|
||||||
|
setName(""); setDescription(""); setThreshold(1); setSortOrder(0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
adminFetch<Rule[]>("/rules").then((rules) => {
|
adminFetch<Rule[]>("/rules").then((rules) => {
|
||||||
const rule = rules.find((r) => r.id === id);
|
const rule = rules.find((r) => r.id === id);
|
||||||
if (rule) {
|
if (rule) {
|
||||||
@@ -34,14 +47,11 @@ export default function RuleForm() {
|
|||||||
setSortOrder(rule.sortOrder);
|
setSortOrder(rule.sortOrder);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}, [id]);
|
}, [open, id]);
|
||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
setError("");
|
setError("");
|
||||||
if (!name.trim()) {
|
if (!name.trim()) return setError("请输入奖品名称");
|
||||||
setError("请输入奖品名称");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
try {
|
try {
|
||||||
const body = {
|
const body = {
|
||||||
@@ -55,7 +65,9 @@ export default function RuleForm() {
|
|||||||
} else {
|
} else {
|
||||||
await adminFetch("/rules", { method: "POST", body: JSON.stringify(body) });
|
await adminFetch("/rules", { method: "POST", body: JSON.stringify(body) });
|
||||||
}
|
}
|
||||||
navigate("/admin/rules");
|
toast.show("已保存");
|
||||||
|
onSaved();
|
||||||
|
onClose();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(e instanceof Error ? e.message : "保存失败");
|
setError(e instanceof Error ? e.message : "保存失败");
|
||||||
} finally {
|
} finally {
|
||||||
@@ -64,75 +76,57 @@ export default function RuleForm() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-xl">
|
<Modal
|
||||||
<h2 className="text-lg font-semibold text-gray-800 mb-4">
|
open={open}
|
||||||
{isEdit ? "编辑兑换规则" : "添加兑换规则"}
|
onClose={onClose}
|
||||||
</h2>
|
size="md"
|
||||||
|
eyebrow={isEdit ? "Edit Rule" : "New Rule"}
|
||||||
<div className="bg-white rounded-lg border border-gray-200 p-5 space-y-4">
|
title={isEdit ? "编辑兑换规则" : "添加兑换规则"}
|
||||||
<div>
|
>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">奖品名称</label>
|
<div className="px-7 py-6 space-y-5">
|
||||||
|
<Field label="奖品名称" required>
|
||||||
<input
|
<input
|
||||||
value={name}
|
value={name}
|
||||||
onChange={(e) => setName(e.target.value)}
|
onChange={(e) => setName(e.target.value)}
|
||||||
className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm
|
placeholder="如:城市限定明信片"
|
||||||
focus:outline-none focus:ring-1 focus:ring-blue-500"
|
className={fieldCls}
|
||||||
/>
|
/>
|
||||||
</div>
|
</Field>
|
||||||
|
|
||||||
<div>
|
<Field label="奖品描述">
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">奖品描述</label>
|
|
||||||
<textarea
|
<textarea
|
||||||
value={description}
|
value={description}
|
||||||
onChange={(e) => setDescription(e.target.value)}
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
rows={2}
|
rows={3}
|
||||||
className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm
|
placeholder="选填"
|
||||||
focus:outline-none focus:ring-1 focus:ring-blue-500"
|
className={fieldCls + " resize-none"}
|
||||||
/>
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-5">
|
||||||
|
<Field label="所需图章数" required hint="≥ 1">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
value={threshold}
|
||||||
|
onChange={(e) => setThreshold(Number(e.target.value))}
|
||||||
|
className={fieldCls + " w-full"}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="排序" hint="数字小的在前">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={sortOrder}
|
||||||
|
onChange={(e) => setSortOrder(Number(e.target.value))}
|
||||||
|
className={fieldCls + " w-full"}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
{error && <ErrorRow text={error} />}
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">所需图章数</label>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
min={1}
|
|
||||||
value={threshold}
|
|
||||||
onChange={(e) => setThreshold(Number(e.target.value))}
|
|
||||||
className="w-24 px-3 py-2 border border-gray-300 rounded-md text-sm
|
|
||||||
focus:outline-none focus:ring-1 focus:ring-blue-500"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">排序</label>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
value={sortOrder}
|
|
||||||
onChange={(e) => setSortOrder(Number(e.target.value))}
|
|
||||||
className="w-24 px-3 py-2 border border-gray-300 rounded-md text-sm
|
|
||||||
focus:outline-none focus:ring-1 focus:ring-blue-500"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{error && <p className="text-sm text-red-500">{error}</p>}
|
|
||||||
|
|
||||||
<div className="flex gap-3 pt-2">
|
|
||||||
<button
|
|
||||||
onClick={handleSave}
|
|
||||||
disabled={saving}
|
|
||||||
className="px-4 py-2 bg-blue-600 text-white text-sm rounded-md
|
|
||||||
hover:bg-blue-700 disabled:opacity-50"
|
|
||||||
>
|
|
||||||
{saving ? "保存中..." : "保存"}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => navigate("/admin/rules")}
|
|
||||||
className="px-4 py-2 border border-gray-300 text-gray-600 text-sm rounded-md hover:bg-gray-50"
|
|
||||||
>
|
|
||||||
取消
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
<FormFooter onCancel={onClose} onSave={handleSave} saving={saving} primaryLabel={isEdit ? "保存" : "创建"} />
|
||||||
|
</Modal>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,17 @@
|
|||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { Link } from "react-router-dom";
|
|
||||||
import { adminFetch } from "./adminApi";
|
import { adminFetch } from "./adminApi";
|
||||||
|
import { useToast } from "./Toast";
|
||||||
|
import RuleForm from "./RuleForm";
|
||||||
|
import PageHeader, {
|
||||||
|
PrimaryButton,
|
||||||
|
StatusChip,
|
||||||
|
ActionButton,
|
||||||
|
EmptyState,
|
||||||
|
LoadingBlock,
|
||||||
|
IconEdit,
|
||||||
|
IconDelete,
|
||||||
|
} from "./PageHeader";
|
||||||
|
import { TableCard, TableHeadRow } from "./StampList";
|
||||||
|
|
||||||
type Rule = {
|
type Rule = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -12,11 +23,12 @@ type Rule = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default function RuleList() {
|
export default function RuleList() {
|
||||||
|
const toast = useToast();
|
||||||
const [rules, setRules] = useState<Rule[]>([]);
|
const [rules, setRules] = useState<Rule[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [formState, setFormState] = useState<{ open: boolean; id: string | null }>({ open: false, id: null });
|
||||||
|
|
||||||
const fetchRules = async () => {
|
const fetchRules = async () => {
|
||||||
setLoading(true);
|
|
||||||
try {
|
try {
|
||||||
const data = await adminFetch<Rule[]>("/rules");
|
const data = await adminFetch<Rule[]>("/rules");
|
||||||
setRules(data);
|
setRules(data);
|
||||||
@@ -25,12 +37,19 @@ export default function RuleList() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => { fetchRules(); }, []);
|
useEffect(() => {
|
||||||
|
fetchRules();
|
||||||
|
}, []);
|
||||||
|
|
||||||
const handleDelete = async (id: string, name: string) => {
|
const handleDelete = async (id: string, name: string) => {
|
||||||
if (!confirm(`确定删除规则「${name}」?`)) return;
|
if (!confirm(`确定删除规则「${name}」?`)) return;
|
||||||
await adminFetch(`/rules/${id}`, { method: "DELETE" });
|
try {
|
||||||
fetchRules();
|
await adminFetch(`/rules/${id}`, { method: "DELETE" });
|
||||||
|
toast.show("已删除");
|
||||||
|
fetchRules();
|
||||||
|
} catch (e) {
|
||||||
|
toast.show(e instanceof Error ? e.message : "删除失败", "error");
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleToggle = async (id: string, enabled: boolean) => {
|
const handleToggle = async (id: string, enabled: boolean) => {
|
||||||
@@ -41,67 +60,92 @@ export default function RuleList() {
|
|||||||
fetchRules();
|
fetchRules();
|
||||||
};
|
};
|
||||||
|
|
||||||
if (loading) return <p className="text-gray-500">加载中...</p>;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<>
|
||||||
<div className="flex items-center justify-between mb-4">
|
<PageHeader
|
||||||
<h2 className="text-lg font-semibold text-gray-800">兑换规则</h2>
|
eyebrow="04 · Rules"
|
||||||
<Link
|
title="兑换规则"
|
||||||
to="/admin/rules/new"
|
caption="设置可兑换的奖品与所需图章数"
|
||||||
className="px-4 py-2 bg-blue-600 text-white text-sm rounded-md hover:bg-blue-700"
|
action={<PrimaryButton onClick={() => setFormState({ open: true, id: null })}>添加规则</PrimaryButton>}
|
||||||
>
|
/>
|
||||||
添加规则
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="bg-white rounded-lg border border-gray-200 overflow-hidden">
|
{loading ? (
|
||||||
<table className="w-full text-sm">
|
<LoadingBlock />
|
||||||
<thead className="bg-gray-50 border-b border-gray-200">
|
) : (
|
||||||
<tr>
|
<TableCard>
|
||||||
<th className="text-left px-4 py-3 font-medium text-gray-600">奖品名称</th>
|
{rules.length === 0 ? (
|
||||||
<th className="text-left px-4 py-3 font-medium text-gray-600">描述</th>
|
<EmptyState
|
||||||
<th className="text-center px-4 py-3 font-medium text-gray-600">所需图章</th>
|
message="尚未创建兑换规则"
|
||||||
<th className="text-center px-4 py-3 font-medium text-gray-600">状态</th>
|
action={
|
||||||
<th className="text-right px-4 py-3 font-medium text-gray-600">操作</th>
|
<PrimaryButton onClick={() => setFormState({ open: true, id: null })}>
|
||||||
</tr>
|
添加第一条规则
|
||||||
</thead>
|
</PrimaryButton>
|
||||||
<tbody>
|
}
|
||||||
{rules.map((rule) => (
|
/>
|
||||||
<tr key={rule.id} className="border-b border-gray-100 hover:bg-gray-50">
|
) : (
|
||||||
<td className="px-4 py-3 text-gray-800">{rule.name}</td>
|
<table className="w-full">
|
||||||
<td className="px-4 py-3 text-gray-500 max-w-[250px] truncate">{rule.description || "—"}</td>
|
<thead>
|
||||||
<td className="px-4 py-3 text-center font-medium text-gray-700">{rule.threshold}</td>
|
<TableHeadRow cols={["奖品", "描述", "所需图章", "状态", "操作"]} />
|
||||||
<td className="px-4 py-3 text-center">
|
</thead>
|
||||||
<button
|
<tbody>
|
||||||
onClick={() => handleToggle(rule.id, rule.enabled)}
|
{rules.map((rule, i) => (
|
||||||
className={`px-2 py-0.5 rounded text-xs ${
|
<tr
|
||||||
rule.enabled ? "bg-green-100 text-green-700" : "bg-gray-100 text-gray-500"
|
key={rule.id}
|
||||||
}`}
|
className="border-t border-[var(--border-muted)] hover:bg-white/60 transition-colors animate-admin-row"
|
||||||
|
style={{ animationDelay: `${Math.min(i, 12) * 0.03}s` }}
|
||||||
>
|
>
|
||||||
{rule.enabled ? "启用" : "禁用"}
|
<td className="px-5 py-4">
|
||||||
</button>
|
<p className="text-[15px] font-medium text-[var(--text-primary)]">{rule.name}</p>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3 text-right space-x-2">
|
<td className="px-5 py-4">
|
||||||
<Link to={`/admin/rules/${rule.id}/edit`} className="text-blue-600 hover:underline">
|
<p className="text-sm text-[var(--text-muted)] max-w-[340px] truncate">
|
||||||
编辑
|
{rule.description || "—"}
|
||||||
</Link>
|
</p>
|
||||||
<button onClick={() => handleDelete(rule.id, rule.name)} className="text-red-500 hover:underline">
|
</td>
|
||||||
删除
|
<td className="px-5 py-4 text-center w-[140px]">
|
||||||
</button>
|
<div className="inline-flex items-baseline gap-1.5">
|
||||||
</td>
|
<span
|
||||||
</tr>
|
className="text-2xl text-[var(--terracotta)] leading-none"
|
||||||
))}
|
style={{ fontFamily: "'Playfair Display', serif", fontWeight: 600 }}
|
||||||
{rules.length === 0 && (
|
>
|
||||||
<tr>
|
{rule.threshold}
|
||||||
<td colSpan={5} className="px-4 py-8 text-center text-gray-400">
|
</span>
|
||||||
暂无规则
|
<span className="text-[10px] tracking-[0.2em] uppercase text-[var(--text-muted)]">
|
||||||
</td>
|
枚
|
||||||
</tr>
|
</span>
|
||||||
)}
|
</div>
|
||||||
</tbody>
|
</td>
|
||||||
</table>
|
<td className="px-5 py-4 text-center w-[110px]">
|
||||||
</div>
|
<StatusChip enabled={rule.enabled} onClick={() => handleToggle(rule.id, rule.enabled)} />
|
||||||
</div>
|
</td>
|
||||||
|
<td className="px-5 py-4 w-[140px]">
|
||||||
|
<div className="flex items-center justify-end gap-1">
|
||||||
|
<ActionButton title="编辑" onClick={() => setFormState({ open: true, id: rule.id })}>
|
||||||
|
{IconEdit}
|
||||||
|
</ActionButton>
|
||||||
|
<ActionButton
|
||||||
|
title="删除"
|
||||||
|
variant="danger"
|
||||||
|
onClick={() => handleDelete(rule.id, rule.name)}
|
||||||
|
>
|
||||||
|
{IconDelete}
|
||||||
|
</ActionButton>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
</TableCard>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<RuleForm
|
||||||
|
open={formState.open}
|
||||||
|
id={formState.id}
|
||||||
|
onClose={() => setFormState({ open: false, id: null })}
|
||||||
|
onSaved={fetchRules}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { useNavigate, useParams } from "react-router-dom";
|
import Modal from "./Modal";
|
||||||
import { adminFetch } from "./adminApi";
|
import { adminFetch } from "./adminApi";
|
||||||
|
import { useToast } from "./Toast";
|
||||||
|
import { Field, ErrorRow, FormFooter, HintRow, fieldCls } from "./FormPrimitives";
|
||||||
|
|
||||||
type Stamp = {
|
type Stamp = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -12,11 +14,16 @@ type Stamp = {
|
|||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function StampForm() {
|
type Props = {
|
||||||
const { id } = useParams();
|
open: boolean;
|
||||||
const navigate = useNavigate();
|
id: string | null;
|
||||||
const isEdit = !!id;
|
onClose: () => void;
|
||||||
|
onSaved: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function StampForm({ open, id, onClose, onSaved }: Props) {
|
||||||
|
const toast = useToast();
|
||||||
|
const [currentId, setCurrentId] = useState<string | null>(null);
|
||||||
const [name, setName] = useState("");
|
const [name, setName] = useState("");
|
||||||
const [note, setNote] = useState("");
|
const [note, setNote] = useState("");
|
||||||
const [sortOrder, setSortOrder] = useState(0);
|
const [sortOrder, setSortOrder] = useState(0);
|
||||||
@@ -25,8 +32,20 @@ export default function StampForm() {
|
|||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
|
const isEdit = !!currentId;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!id) return;
|
if (!open) return;
|
||||||
|
setCurrentId(id);
|
||||||
|
setError("");
|
||||||
|
if (!id) {
|
||||||
|
setName("");
|
||||||
|
setNote("");
|
||||||
|
setSortOrder(0);
|
||||||
|
setImageColor("");
|
||||||
|
setImageGrey("");
|
||||||
|
return;
|
||||||
|
}
|
||||||
adminFetch<Stamp[]>("/stamps").then((stamps) => {
|
adminFetch<Stamp[]>("/stamps").then((stamps) => {
|
||||||
const stamp = stamps.find((s) => s.id === id);
|
const stamp = stamps.find((s) => s.id === id);
|
||||||
if (stamp) {
|
if (stamp) {
|
||||||
@@ -37,22 +56,29 @@ export default function StampForm() {
|
|||||||
setImageGrey(stamp.imageGrey);
|
setImageGrey(stamp.imageGrey);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}, [id]);
|
}, [open, id]);
|
||||||
|
|
||||||
const handleUpload = async (file: File, field: "imageColor" | "imageGrey") => {
|
const handleUpload = async (file: File, field: "imageColor" | "imageGrey") => {
|
||||||
if (!id) {
|
if (!currentId) {
|
||||||
setError("请先保存图章后再上传图片");
|
setError("请先保存图章后再上传图片");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
setError("");
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append("image", file);
|
formData.append("image", file);
|
||||||
formData.append("field", field);
|
formData.append("field", field);
|
||||||
const data = await adminFetch<{ path: string }>(`/stamps/${id}/upload`, {
|
try {
|
||||||
method: "POST",
|
const data = await adminFetch<{ path: string }>(`/stamps/${currentId}/upload`, {
|
||||||
body: formData,
|
method: "POST",
|
||||||
});
|
body: formData,
|
||||||
if (field === "imageColor") setImageColor(data.path);
|
});
|
||||||
else setImageGrey(data.path);
|
if (field === "imageColor") setImageColor(data.path);
|
||||||
|
else setImageGrey(data.path);
|
||||||
|
toast.show("图片已上传");
|
||||||
|
onSaved();
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : "上传失败");
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
@@ -63,20 +89,24 @@ export default function StampForm() {
|
|||||||
}
|
}
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
try {
|
try {
|
||||||
|
const payload = { name: name.trim(), note: note.trim() || undefined, sortOrder };
|
||||||
if (isEdit) {
|
if (isEdit) {
|
||||||
await adminFetch(`/stamps/${id}`, {
|
await adminFetch(`/stamps/${currentId}`, {
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
body: JSON.stringify({ name: name.trim(), note: note.trim() || undefined, sortOrder }),
|
body: JSON.stringify(payload),
|
||||||
});
|
});
|
||||||
|
toast.show("已保存");
|
||||||
|
onSaved();
|
||||||
|
onClose();
|
||||||
} else {
|
} else {
|
||||||
const stamp = await adminFetch<Stamp>("/stamps", {
|
const stamp = await adminFetch<Stamp>("/stamps", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify({ name: name.trim(), note: note.trim() || undefined, sortOrder }),
|
body: JSON.stringify(payload),
|
||||||
});
|
});
|
||||||
navigate(`/admin/stamps/${stamp.id}/edit`, { replace: true });
|
setCurrentId(stamp.id);
|
||||||
return;
|
toast.show("已创建,现在可以上传图片");
|
||||||
|
onSaved();
|
||||||
}
|
}
|
||||||
navigate("/admin/stamps");
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(e instanceof Error ? e.message : "保存失败");
|
setError(e instanceof Error ? e.message : "保存失败");
|
||||||
} finally {
|
} finally {
|
||||||
@@ -85,100 +115,113 @@ export default function StampForm() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-xl">
|
<Modal
|
||||||
<h2 className="text-lg font-semibold text-gray-800 mb-4">
|
open={open}
|
||||||
{isEdit ? "编辑图章" : "添加图章"}
|
onClose={onClose}
|
||||||
</h2>
|
size="md"
|
||||||
|
eyebrow={isEdit ? "Edit Stamp" : "New Stamp"}
|
||||||
<div className="bg-white rounded-lg border border-gray-200 p-5 space-y-4">
|
title={isEdit ? "编辑图章" : "添加图章"}
|
||||||
<div>
|
subtitle={isEdit ? "调整信息与上传图片" : "先保存基础信息,再上传图章图片"}
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">名称</label>
|
>
|
||||||
|
<div className="px-7 py-6 space-y-5">
|
||||||
|
<Field label="名称" required>
|
||||||
<input
|
<input
|
||||||
value={name}
|
value={name}
|
||||||
onChange={(e) => setName(e.target.value)}
|
onChange={(e) => setName(e.target.value)}
|
||||||
className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm
|
placeholder="如:朝天宫"
|
||||||
focus:outline-none focus:ring-1 focus:ring-blue-500"
|
className={fieldCls}
|
||||||
/>
|
/>
|
||||||
</div>
|
</Field>
|
||||||
|
|
||||||
<div>
|
<Field label="备注">
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">备注</label>
|
|
||||||
<textarea
|
<textarea
|
||||||
value={note}
|
value={note}
|
||||||
onChange={(e) => setNote(e.target.value)}
|
onChange={(e) => setNote(e.target.value)}
|
||||||
rows={2}
|
rows={2}
|
||||||
className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm
|
placeholder="选填"
|
||||||
focus:outline-none focus:ring-1 focus:ring-blue-500"
|
className={fieldCls + " resize-none"}
|
||||||
/>
|
/>
|
||||||
</div>
|
</Field>
|
||||||
|
|
||||||
<div>
|
<Field label="排序" hint="数字小的在前">
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">排序</label>
|
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
value={sortOrder}
|
value={sortOrder}
|
||||||
onChange={(e) => setSortOrder(Number(e.target.value))}
|
onChange={(e) => setSortOrder(Number(e.target.value))}
|
||||||
className="w-24 px-3 py-2 border border-gray-300 rounded-md text-sm
|
className={fieldCls + " w-28"}
|
||||||
focus:outline-none focus:ring-1 focus:ring-blue-500"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</Field>
|
||||||
|
|
||||||
{/* Image uploads - only available after saving */}
|
{isEdit ? (
|
||||||
{isEdit && (
|
<div className="grid grid-cols-2 gap-5">
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<ImageSlot
|
||||||
<div>
|
label="彩色图章"
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">彩色图章</label>
|
kind="color"
|
||||||
{imageColor && (
|
image={imageColor}
|
||||||
<div className="w-20 h-20 rounded-full bg-white border border-gray-200 overflow-hidden flex items-center justify-center shadow-sm mb-2">
|
onUpload={(f) => handleUpload(f, "imageColor")}
|
||||||
<img src={imageColor} alt="彩色" className="w-[92%] h-[92%] object-contain" />
|
/>
|
||||||
</div>
|
<ImageSlot
|
||||||
)}
|
label="灰色图章"
|
||||||
<input
|
kind="grey"
|
||||||
type="file"
|
image={imageGrey}
|
||||||
accept="image/*"
|
onUpload={(f) => handleUpload(f, "imageGrey")}
|
||||||
onChange={(e) => e.target.files?.[0] && handleUpload(e.target.files[0], "imageColor")}
|
/>
|
||||||
className="text-xs text-gray-500"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">灰色图章</label>
|
|
||||||
{imageGrey && (
|
|
||||||
<div className="w-20 h-20 rounded-full bg-white border border-gray-200 overflow-hidden flex items-center justify-center shadow-sm mb-2">
|
|
||||||
<img src={imageGrey} alt="灰色" className="w-[92%] h-[92%] object-contain" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<input
|
|
||||||
type="file"
|
|
||||||
accept="image/*"
|
|
||||||
onChange={(e) => e.target.files?.[0] && handleUpload(e.target.files[0], "imageGrey")}
|
|
||||||
className="text-xs text-gray-500"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
) : (
|
||||||
|
<HintRow text="保存基础信息后,即可上传图章图片" />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!isEdit && (
|
{error && <ErrorRow text={error} />}
|
||||||
<p className="text-xs text-gray-400">保存后可上传图章图片</p>
|
</div>
|
||||||
)}
|
|
||||||
|
|
||||||
{error && <p className="text-sm text-red-500">{error}</p>}
|
<FormFooter onCancel={onClose} onSave={handleSave} saving={saving} primaryLabel={isEdit ? "保存" : "创建"} />
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
<div className="flex gap-3 pt-2">
|
function ImageSlot({
|
||||||
<button
|
label,
|
||||||
onClick={handleSave}
|
kind,
|
||||||
disabled={saving}
|
image,
|
||||||
className="px-4 py-2 bg-blue-600 text-white text-sm rounded-md
|
onUpload,
|
||||||
hover:bg-blue-700 disabled:opacity-50"
|
}: {
|
||||||
>
|
label: string;
|
||||||
{saving ? "保存中..." : "保存"}
|
kind: "color" | "grey";
|
||||||
</button>
|
image: string;
|
||||||
<button
|
onUpload: (f: File) => void;
|
||||||
onClick={() => navigate("/admin/stamps")}
|
}) {
|
||||||
className="px-4 py-2 border border-gray-300 text-gray-600 text-sm rounded-md hover:bg-gray-50"
|
return (
|
||||||
>
|
<div>
|
||||||
取消
|
<label className="flex items-baseline justify-between mb-1.5">
|
||||||
</button>
|
<span className="text-[13px] font-medium text-[var(--text-secondary)]">{label}</span>
|
||||||
|
<span
|
||||||
|
className="text-[9px] tracking-[0.3em] uppercase text-[var(--gold)]"
|
||||||
|
style={{ fontFamily: "'Playfair Display', serif" }}
|
||||||
|
>
|
||||||
|
{kind === "color" ? "Color" : "Grey"}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<div className="rounded-xl border border-[var(--border-muted)] bg-white p-4 flex flex-col items-center gap-3">
|
||||||
|
<div className="w-20 h-20 rounded-full bg-[var(--bg-paper)] overflow-hidden flex items-center justify-center border border-[var(--border-muted)]">
|
||||||
|
{image ? (
|
||||||
|
<img src={image} alt="" className="w-[92%] h-[92%] object-contain" />
|
||||||
|
) : (
|
||||||
|
<span className="text-[10px] tracking-[0.2em] uppercase text-[var(--text-muted)]/60">
|
||||||
|
No image
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
<label className="cursor-pointer text-[12px] text-[var(--text-secondary)] hover:text-[var(--terracotta)] transition-colors">
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
onChange={(e) => e.target.files?.[0] && onUpload(e.target.files[0])}
|
||||||
|
className="hidden"
|
||||||
|
/>
|
||||||
|
<span className="underline underline-offset-2 decoration-dotted">
|
||||||
|
{image ? "更换图片" : "选择图片"}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,6 +1,20 @@
|
|||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { Link } from "react-router-dom";
|
|
||||||
import { adminFetch } from "./adminApi";
|
import { adminFetch } from "./adminApi";
|
||||||
|
import { useToast } from "./Toast";
|
||||||
|
import { copyItemLink } from "./utils";
|
||||||
|
import StampForm from "./StampForm";
|
||||||
|
import QRCodeModal from "./QRCodeModal";
|
||||||
|
import PageHeader, {
|
||||||
|
PrimaryButton,
|
||||||
|
StatusChip,
|
||||||
|
ActionButton,
|
||||||
|
EmptyState,
|
||||||
|
LoadingBlock,
|
||||||
|
IconEdit,
|
||||||
|
IconCopy,
|
||||||
|
IconQR,
|
||||||
|
IconDelete,
|
||||||
|
} from "./PageHeader";
|
||||||
|
|
||||||
type Stamp = {
|
type Stamp = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -13,11 +27,14 @@ type Stamp = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default function StampList() {
|
export default function StampList() {
|
||||||
|
const toast = useToast();
|
||||||
const [stamps, setStamps] = useState<Stamp[]>([]);
|
const [stamps, setStamps] = useState<Stamp[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
const [formState, setFormState] = useState<{ open: boolean; id: string | null }>({ open: false, id: null });
|
||||||
|
const [qrState, setQrState] = useState<{ open: boolean; id: string | null }>({ open: false, id: null });
|
||||||
|
|
||||||
const fetchStamps = async () => {
|
const fetchStamps = async () => {
|
||||||
setLoading(true);
|
|
||||||
try {
|
try {
|
||||||
const data = await adminFetch<Stamp[]>("/stamps");
|
const data = await adminFetch<Stamp[]>("/stamps");
|
||||||
setStamps(data);
|
setStamps(data);
|
||||||
@@ -26,12 +43,19 @@ export default function StampList() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => { fetchStamps(); }, []);
|
useEffect(() => {
|
||||||
|
fetchStamps();
|
||||||
|
}, []);
|
||||||
|
|
||||||
const handleDelete = async (id: string, name: string) => {
|
const handleDelete = async (id: string, name: string) => {
|
||||||
if (!confirm(`确定删除图章「${name}」?`)) return;
|
if (!confirm(`确定删除图章「${name}」?`)) return;
|
||||||
await adminFetch(`/stamps/${id}`, { method: "DELETE" });
|
try {
|
||||||
fetchStamps();
|
await adminFetch(`/stamps/${id}`, { method: "DELETE" });
|
||||||
|
toast.show("已删除");
|
||||||
|
fetchStamps();
|
||||||
|
} catch (e) {
|
||||||
|
toast.show(e instanceof Error ? e.message : "删除失败", "error");
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleToggle = async (id: string, enabled: boolean) => {
|
const handleToggle = async (id: string, enabled: boolean) => {
|
||||||
@@ -42,78 +66,152 @@ export default function StampList() {
|
|||||||
fetchStamps();
|
fetchStamps();
|
||||||
};
|
};
|
||||||
|
|
||||||
if (loading) return <p className="text-gray-500">加载中...</p>;
|
const handleCopyLink = async (id: string) => {
|
||||||
|
try {
|
||||||
|
await copyItemLink("stamp", id);
|
||||||
|
toast.show("链接已复制");
|
||||||
|
} catch {
|
||||||
|
toast.show("复制失败", "error");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<>
|
||||||
<div className="flex items-center justify-between mb-4">
|
<PageHeader
|
||||||
<h2 className="text-lg font-semibold text-gray-800">图章管理</h2>
|
eyebrow="01 · Stamps"
|
||||||
<Link
|
title="图章管理"
|
||||||
to="/admin/stamps/new"
|
caption="收集图章、生成点位 NFC 链接与备用二维码"
|
||||||
className="px-4 py-2 bg-blue-600 text-white text-sm rounded-md hover:bg-blue-700"
|
action={
|
||||||
>
|
<PrimaryButton onClick={() => setFormState({ open: true, id: null })}>添加图章</PrimaryButton>
|
||||||
添加图章
|
}
|
||||||
</Link>
|
/>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="bg-white rounded-lg border border-gray-200 overflow-hidden">
|
{loading ? (
|
||||||
<table className="w-full text-sm">
|
<LoadingBlock />
|
||||||
<thead className="bg-gray-50 border-b border-gray-200">
|
) : (
|
||||||
<tr>
|
<TableCard>
|
||||||
<th className="text-left px-4 py-3 font-medium text-gray-600">图章</th>
|
{stamps.length === 0 ? (
|
||||||
<th className="text-left px-4 py-3 font-medium text-gray-600">名称</th>
|
<EmptyState
|
||||||
<th className="text-left px-4 py-3 font-medium text-gray-600">备注</th>
|
message="尚未创建图章"
|
||||||
<th className="text-center px-4 py-3 font-medium text-gray-600">排序</th>
|
action={
|
||||||
<th className="text-center px-4 py-3 font-medium text-gray-600">状态</th>
|
<PrimaryButton onClick={() => setFormState({ open: true, id: null })}>
|
||||||
<th className="text-right px-4 py-3 font-medium text-gray-600">操作</th>
|
添加第一枚图章
|
||||||
</tr>
|
</PrimaryButton>
|
||||||
</thead>
|
}
|
||||||
<tbody>
|
/>
|
||||||
{stamps.map((stamp) => (
|
) : (
|
||||||
<tr key={stamp.id} className="border-b border-gray-100 hover:bg-gray-50">
|
<table className="w-full">
|
||||||
<td className="px-4 py-3">
|
<thead>
|
||||||
<div className="w-10 h-10 rounded-full bg-white border border-gray-200 overflow-hidden flex items-center justify-center shadow-sm">
|
<TableHeadRow cols={["图章", "名称 · 备注", "排序", "状态", "操作"]} />
|
||||||
{stamp.imageColor && (
|
</thead>
|
||||||
<img src={stamp.imageColor} alt="" className="w-[92%] h-[92%] object-contain" />
|
<tbody>
|
||||||
)}
|
{stamps.map((stamp, i) => (
|
||||||
</div>
|
<tr
|
||||||
</td>
|
key={stamp.id}
|
||||||
<td className="px-4 py-3 text-gray-800">{stamp.name}</td>
|
className="border-t border-[var(--border-muted)] hover:bg-white/60 transition-colors animate-admin-row"
|
||||||
<td className="px-4 py-3 text-gray-500 max-w-[200px] truncate">{stamp.note || "—"}</td>
|
style={{ animationDelay: `${Math.min(i, 12) * 0.03}s` }}
|
||||||
<td className="px-4 py-3 text-center text-gray-500">{stamp.sortOrder}</td>
|
|
||||||
<td className="px-4 py-3 text-center">
|
|
||||||
<button
|
|
||||||
onClick={() => handleToggle(stamp.id, stamp.enabled)}
|
|
||||||
className={`px-2 py-0.5 rounded text-xs ${
|
|
||||||
stamp.enabled ? "bg-green-100 text-green-700" : "bg-gray-100 text-gray-500"
|
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
{stamp.enabled ? "启用" : "禁用"}
|
<td className="px-5 py-4 w-[90px]">
|
||||||
</button>
|
<div className="w-12 h-12 rounded-full bg-white border border-[var(--border-muted)] overflow-hidden flex items-center justify-center shadow-sm">
|
||||||
</td>
|
{stamp.imageColor ? (
|
||||||
<td className="px-4 py-3 text-right space-x-2">
|
<img src={stamp.imageColor} alt="" className="w-[92%] h-[92%] object-contain" />
|
||||||
<Link to={`/admin/stamps/${stamp.id}/edit`} className="text-blue-600 hover:underline">
|
) : (
|
||||||
编辑
|
<span className="text-[9px] tracking-[0.2em] uppercase text-[var(--text-muted)]/60">
|
||||||
</Link>
|
No image
|
||||||
<Link to={`/admin/stamps/${stamp.id}/qrcode`} className="text-blue-600 hover:underline">
|
</span>
|
||||||
二维码
|
)}
|
||||||
</Link>
|
</div>
|
||||||
<button onClick={() => handleDelete(stamp.id, stamp.name)} className="text-red-500 hover:underline">
|
</td>
|
||||||
删除
|
<td className="px-5 py-4">
|
||||||
</button>
|
<p className="text-[15px] font-medium text-[var(--text-primary)]">{stamp.name}</p>
|
||||||
</td>
|
{stamp.note && (
|
||||||
</tr>
|
<p className="text-xs text-[var(--text-muted)] mt-0.5 max-w-[360px] truncate">
|
||||||
))}
|
{stamp.note}
|
||||||
{stamps.length === 0 && (
|
</p>
|
||||||
<tr>
|
)}
|
||||||
<td colSpan={6} className="px-4 py-8 text-center text-gray-400">
|
</td>
|
||||||
暂无图章,点击右上角添加
|
<td className="px-5 py-4 text-center w-[80px]">
|
||||||
</td>
|
<span
|
||||||
</tr>
|
className="text-[13px] text-[var(--text-secondary)]"
|
||||||
)}
|
style={{ fontFamily: "'Playfair Display', serif" }}
|
||||||
</tbody>
|
>
|
||||||
</table>
|
{stamp.sortOrder}
|
||||||
</div>
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-5 py-4 text-center w-[110px]">
|
||||||
|
<StatusChip enabled={stamp.enabled} onClick={() => handleToggle(stamp.id, stamp.enabled)} />
|
||||||
|
</td>
|
||||||
|
<td className="px-5 py-4 w-[180px]">
|
||||||
|
<div className="flex items-center justify-end gap-1">
|
||||||
|
<ActionButton title="编辑" onClick={() => setFormState({ open: true, id: stamp.id })}>
|
||||||
|
{IconEdit}
|
||||||
|
</ActionButton>
|
||||||
|
<ActionButton title="复制链接" onClick={() => handleCopyLink(stamp.id)}>
|
||||||
|
{IconCopy}
|
||||||
|
</ActionButton>
|
||||||
|
<ActionButton title="二维码" onClick={() => setQrState({ open: true, id: stamp.id })}>
|
||||||
|
{IconQR}
|
||||||
|
</ActionButton>
|
||||||
|
<ActionButton
|
||||||
|
title="删除"
|
||||||
|
variant="danger"
|
||||||
|
onClick={() => handleDelete(stamp.id, stamp.name)}
|
||||||
|
>
|
||||||
|
{IconDelete}
|
||||||
|
</ActionButton>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
</TableCard>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<StampForm
|
||||||
|
open={formState.open}
|
||||||
|
id={formState.id}
|
||||||
|
onClose={() => setFormState({ open: false, id: null })}
|
||||||
|
onSaved={fetchStamps}
|
||||||
|
/>
|
||||||
|
<QRCodeModal
|
||||||
|
open={qrState.open}
|
||||||
|
type="stamp"
|
||||||
|
id={qrState.id}
|
||||||
|
onClose={() => setQrState({ open: false, id: null })}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ————— Shared table pieces (also used by other list pages) ————— */
|
||||||
|
|
||||||
|
export function TableCard({ children }: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="rounded-2xl bg-white/70 border border-[var(--border-muted)] overflow-hidden"
|
||||||
|
style={{ boxShadow: "0 1px 2px rgba(16,16,30,0.04), 0 8px 24px rgba(16,16,30,0.04)" }}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function TableHeadRow({ cols }: { cols: string[] }) {
|
||||||
|
return (
|
||||||
|
<tr className="bg-[var(--bg-paper)]/60">
|
||||||
|
{cols.map((c, i) => (
|
||||||
|
<th
|
||||||
|
key={c}
|
||||||
|
className={`px-5 py-3.5 text-[10px] tracking-[0.25em] uppercase text-[var(--text-muted)] font-medium ${
|
||||||
|
i === cols.length - 1 ? "text-right" : i === 0 ? "text-left" : "text-left"
|
||||||
|
}`}
|
||||||
|
style={{ fontFamily: "'Playfair Display', serif" }}
|
||||||
|
>
|
||||||
|
{i === cols.length - 1 ? <span className="inline-block pr-2">{c}</span> : c}
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,128 +0,0 @@
|
|||||||
import { useState, useEffect, useRef } from "react";
|
|
||||||
import { useParams, Link } from "react-router-dom";
|
|
||||||
import { adminFetch } from "./adminApi";
|
|
||||||
|
|
||||||
type QRData = {
|
|
||||||
qrDataUrl: string;
|
|
||||||
collectUrl: string;
|
|
||||||
stampName: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function StampQRCode() {
|
|
||||||
const { id } = useParams();
|
|
||||||
const [data, setData] = useState<QRData | null>(null);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [copied, setCopied] = useState(false);
|
|
||||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!id) return;
|
|
||||||
adminFetch<QRData>(`/stamps/${id}/qrcode`)
|
|
||||||
.then(setData)
|
|
||||||
.finally(() => setLoading(false));
|
|
||||||
}, [id]);
|
|
||||||
|
|
||||||
// Render composite image (QR code + URL text) to canvas
|
|
||||||
useEffect(() => {
|
|
||||||
if (!data || !canvasRef.current) return;
|
|
||||||
|
|
||||||
const canvas = canvasRef.current;
|
|
||||||
const ctx = canvas.getContext("2d")!;
|
|
||||||
const img = new Image();
|
|
||||||
img.onload = () => {
|
|
||||||
const padding = 20;
|
|
||||||
const textHeight = 40;
|
|
||||||
canvas.width = img.width + padding * 2;
|
|
||||||
canvas.height = img.height + padding * 2 + textHeight;
|
|
||||||
|
|
||||||
// White background
|
|
||||||
ctx.fillStyle = "#ffffff";
|
|
||||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
|
||||||
|
|
||||||
// QR code
|
|
||||||
ctx.drawImage(img, padding, padding);
|
|
||||||
|
|
||||||
// URL text below
|
|
||||||
ctx.fillStyle = "#666666";
|
|
||||||
ctx.font = "12px sans-serif";
|
|
||||||
ctx.textAlign = "center";
|
|
||||||
ctx.fillText(data.collectUrl, canvas.width / 2, img.height + padding * 2 + 12);
|
|
||||||
};
|
|
||||||
img.src = data.qrDataUrl;
|
|
||||||
}, [data]);
|
|
||||||
|
|
||||||
const handleDownload = () => {
|
|
||||||
if (!canvasRef.current || !data) return;
|
|
||||||
const link = document.createElement("a");
|
|
||||||
link.download = `${data.stampName}-二维码.png`;
|
|
||||||
link.href = canvasRef.current.toDataURL("image/png");
|
|
||||||
link.click();
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCopy = async () => {
|
|
||||||
if (!data) return;
|
|
||||||
try {
|
|
||||||
await navigator.clipboard.writeText(data.collectUrl);
|
|
||||||
setCopied(true);
|
|
||||||
setTimeout(() => setCopied(false), 1800);
|
|
||||||
} catch {
|
|
||||||
// Fallback for older browsers / insecure context
|
|
||||||
const ta = document.createElement("textarea");
|
|
||||||
ta.value = data.collectUrl;
|
|
||||||
document.body.appendChild(ta);
|
|
||||||
ta.select();
|
|
||||||
document.execCommand("copy");
|
|
||||||
document.body.removeChild(ta);
|
|
||||||
setCopied(true);
|
|
||||||
setTimeout(() => setCopied(false), 1800);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (loading) return <p className="text-gray-500">加载中...</p>;
|
|
||||||
if (!data) return <p className="text-gray-500">图章不存在</p>;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="max-w-md">
|
|
||||||
<div className="flex items-center gap-2 mb-4">
|
|
||||||
<Link to="/admin/stamps" className="text-gray-400 hover:text-gray-600">
|
|
||||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
|
||||||
<path d="M15 18l-6-6 6-6" />
|
|
||||||
</svg>
|
|
||||||
</Link>
|
|
||||||
<h2 className="text-lg font-semibold text-gray-800">{data.stampName} — 点位链接</h2>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="bg-white rounded-lg border border-gray-200 p-6 text-center space-y-4">
|
|
||||||
{/* QR code display */}
|
|
||||||
<div className="inline-block">
|
|
||||||
<img src={data.qrDataUrl} alt="二维码" className="w-64 h-64" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* URL display */}
|
|
||||||
<p className="text-xs text-gray-500 break-all select-all">{data.collectUrl}</p>
|
|
||||||
|
|
||||||
{/* Hidden canvas for composite download */}
|
|
||||||
<canvas ref={canvasRef} className="hidden" />
|
|
||||||
|
|
||||||
<div className="flex flex-wrap items-center justify-center gap-3">
|
|
||||||
<button
|
|
||||||
onClick={handleCopy}
|
|
||||||
className="px-4 py-2 bg-blue-600 text-white text-sm rounded-md hover:bg-blue-700 min-w-[160px]"
|
|
||||||
>
|
|
||||||
{copied ? "已复制 ✓" : "复制链接(写入 NFC)"}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={handleDownload}
|
|
||||||
className="px-4 py-2 border border-gray-300 text-gray-700 text-sm rounded-md hover:bg-gray-50"
|
|
||||||
>
|
|
||||||
下载二维码
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p className="text-xs text-gray-400 pt-1">
|
|
||||||
点位主要通过 NFC 触碰分发,二维码作为备用方式保留
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
51
packages/web/src/admin/Toast.tsx
Normal file
51
packages/web/src/admin/Toast.tsx
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import { createContext, useCallback, useContext, useState, type ReactNode } from "react";
|
||||||
|
|
||||||
|
type ToastKind = "success" | "error";
|
||||||
|
type ToastItem = { id: number; message: string; kind: ToastKind };
|
||||||
|
type ToastContextValue = { show: (message: string, kind?: ToastKind) => void };
|
||||||
|
|
||||||
|
const ToastContext = createContext<ToastContextValue | null>(null);
|
||||||
|
|
||||||
|
export function ToastProvider({ children }: { children: ReactNode }) {
|
||||||
|
const [toasts, setToasts] = useState<ToastItem[]>([]);
|
||||||
|
|
||||||
|
const show = useCallback((message: string, kind: ToastKind = "success") => {
|
||||||
|
const id = Date.now() + Math.random();
|
||||||
|
setToasts((list) => [...list, { id, message, kind }]);
|
||||||
|
setTimeout(() => setToasts((list) => list.filter((t) => t.id !== id)), 2400);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ToastContext.Provider value={{ show }}>
|
||||||
|
{children}
|
||||||
|
<div className="fixed top-5 left-1/2 -translate-x-1/2 z-[70] pointer-events-none flex flex-col items-center gap-2">
|
||||||
|
{toasts.map((t) => (
|
||||||
|
<div
|
||||||
|
key={t.id}
|
||||||
|
className="animate-toast-in pointer-events-auto flex items-center gap-2 pl-3 pr-4 py-2 rounded-full text-[13px] font-medium"
|
||||||
|
style={{
|
||||||
|
backgroundColor: t.kind === "success" ? "var(--text-primary)" : "var(--terracotta)",
|
||||||
|
color: "#fff",
|
||||||
|
boxShadow: "0 12px 32px rgba(16,16,30,0.35)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="inline-block w-1.5 h-1.5 rounded-full"
|
||||||
|
style={{
|
||||||
|
backgroundColor: t.kind === "success" ? "var(--gold)" : "#fff",
|
||||||
|
boxShadow: t.kind === "success" ? "0 0 8px rgba(212,165,116,0.6)" : "none",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{t.message}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</ToastContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useToast() {
|
||||||
|
const ctx = useContext(ToastContext);
|
||||||
|
if (!ctx) throw new Error("useToast must be used within ToastProvider");
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
36
packages/web/src/admin/utils.ts
Normal file
36
packages/web/src/admin/utils.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import { adminFetch } from "./adminApi";
|
||||||
|
|
||||||
|
export type LinkType = "stamp" | "article" | "music";
|
||||||
|
|
||||||
|
const LINK_CFG: Record<LinkType, { path: (id: string) => string; urlKey: string }> = {
|
||||||
|
stamp: { path: (id) => `/stamps/${id}/qrcode`, urlKey: "collectUrl" },
|
||||||
|
article: { path: (id) => `/articles/${id}/qrcode`, urlKey: "articleUrl" },
|
||||||
|
music: { path: (id) => `/music/${id}/qrcode`, urlKey: "musicUrl" },
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function fetchItemUrl(type: LinkType, id: string): Promise<string> {
|
||||||
|
const cfg = LINK_CFG[type];
|
||||||
|
const data = await adminFetch<Record<string, string>>(cfg.path(id));
|
||||||
|
return data[cfg.urlKey];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function copyText(text: string) {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(text);
|
||||||
|
} catch {
|
||||||
|
const ta = document.createElement("textarea");
|
||||||
|
ta.value = text;
|
||||||
|
ta.style.position = "fixed";
|
||||||
|
ta.style.opacity = "0";
|
||||||
|
document.body.appendChild(ta);
|
||||||
|
ta.select();
|
||||||
|
document.execCommand("copy");
|
||||||
|
document.body.removeChild(ta);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function copyItemLink(type: LinkType, id: string): Promise<string> {
|
||||||
|
const url = await fetchItemUrl(type, id);
|
||||||
|
await copyText(url);
|
||||||
|
return url;
|
||||||
|
}
|
||||||
@@ -111,6 +111,16 @@
|
|||||||
to { transform: rotate(360deg); }
|
to { transform: rotate(360deg); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@keyframes toast-in {
|
||||||
|
from { opacity: 0; transform: translate(-50%, -12px); }
|
||||||
|
to { opacity: 1; transform: translate(-50%, 0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes admin-row-in {
|
||||||
|
from { opacity: 0; transform: translateY(4px); }
|
||||||
|
to { opacity: 1; transform: translateY(0); }
|
||||||
|
}
|
||||||
|
|
||||||
/* ===== Component Classes (in @layer components) ===== */
|
/* ===== Component Classes (in @layer components) ===== */
|
||||||
@layer components {
|
@layer components {
|
||||||
.safe-bottom {
|
.safe-bottom {
|
||||||
@@ -128,6 +138,8 @@
|
|||||||
.animate-scale-in { opacity: 0; animation: scale-in 0.5s cubic-bezier(0.22, 1, 0.36, 1) both; }
|
.animate-scale-in { opacity: 0; animation: scale-in 0.5s cubic-bezier(0.22, 1, 0.36, 1) both; }
|
||||||
.animate-music-ripple { animation: music-ripple 2.4s cubic-bezier(0.22, 1, 0.36, 1) infinite; }
|
.animate-music-ripple { animation: music-ripple 2.4s cubic-bezier(0.22, 1, 0.36, 1) infinite; }
|
||||||
.animate-music-spin { animation: music-disc-spin 18s linear infinite; }
|
.animate-music-spin { animation: music-disc-spin 18s linear infinite; }
|
||||||
|
.animate-toast-in { animation: toast-in 0.3s cubic-bezier(0.22, 1, 0.36, 1) both; }
|
||||||
|
.animate-admin-row { animation: admin-row-in 0.35s cubic-bezier(0.22, 1, 0.36, 1) both; }
|
||||||
|
|
||||||
.music-progress {
|
.music-progress {
|
||||||
-webkit-appearance: none;
|
-webkit-appearance: none;
|
||||||
|
|||||||
Reference in New Issue
Block a user