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:
2026-04-19 19:18:37 +08:00
parent 613684384b
commit b4a0e23c7e
22 changed files with 1948 additions and 1147 deletions

View File

@@ -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>

View File

@@ -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>
); );
} }

View File

@@ -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>

View File

@@ -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>
); );
} }

View File

@@ -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 })}
/>
</>
); );
} }

View File

@@ -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>
);
}

View 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>
);
}

View 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>
);
}

View File

@@ -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>
); );
} }

View File

@@ -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 })}
/>
</>
); );
} }

View File

@@ -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>
);
}

View 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>
);

View 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>
);
}

View File

@@ -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> </>
); );
} }

View File

@@ -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>
); );
} }

View File

@@ -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}
/>
</>
); );
} }

View File

@@ -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>
); );

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View 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;
}

View 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;
}

View File

@@ -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;