feat: 新增静态文章模块并支持 NFC 链接分发

- 新增 Article 数据模型 + 迁移(title/subtitle/body/coverImage/caption)
- 后端:公共 /api/articles 查询接口 + 管理端 CRUD/上传/二维码
- 前端:移动端 /article/:id 阅读页(Playfair + 纸张肌理 + 首行缩进)
- Admin:新增文章管理三页(列表/表单/二维码)与侧栏入口
- 导入 6 篇点位解说词:朝天宫/七家湾/运渎/打钉巷/绒庄街/熙南里
- Admin 二维码页增加「复制链接(写入 NFC)」按钮
- 落地页步骤文案从扫码改为 NFC 触碰
- Dockerfile + entrypoint 增加 articles 图片回灌
- 修复 deploy-stamp skill 构建轮询卡住(pgrep 模式错误)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-19 18:14:41 +08:00
parent 711f422558
commit dbe8ea5460
31 changed files with 1156 additions and 27 deletions

View File

@@ -2,12 +2,16 @@ import { Routes, Route, Navigate, useParams } from "react-router-dom";
import { AuthProvider } from "./lib/auth";
import LandingPage from "./pages/LandingPage";
import AlbumPage from "./pages/AlbumPage";
import ArticlePage from "./pages/ArticlePage";
import AdminLogin from "./admin/AdminLogin";
import AdminGuard from "./admin/AdminGuard";
import AdminLayout from "./admin/AdminLayout";
import StampList from "./admin/StampList";
import StampForm from "./admin/StampForm";
import StampQRCode from "./admin/StampQRCode";
import ArticleList from "./admin/ArticleList";
import ArticleForm from "./admin/ArticleForm";
import ArticleQRCode from "./admin/ArticleQRCode";
import RuleList from "./admin/RuleList";
import RuleForm from "./admin/RuleForm";
import RedemptionLog from "./admin/RedemptionLog";
@@ -25,6 +29,7 @@ export default function App() {
<Route path="/" element={<LandingPage />} />
<Route path="/album" element={<AlbumPage />} />
<Route path="/collect/:stampId" element={<CollectRedirect />} />
<Route path="/article/:id" element={<ArticlePage />} />
{/* Admin panel */}
<Route path="/admin" element={<AdminLogin />} />
@@ -34,6 +39,10 @@ export default function App() {
<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/new" element={<ArticleForm />} />
<Route path="/admin/articles/:id/edit" element={<ArticleForm />} />
<Route path="/admin/articles/:id/qrcode" element={<ArticleQRCode />} />
<Route path="/admin/rules" element={<RuleList />} />
<Route path="/admin/rules/new" element={<RuleForm />} />
<Route path="/admin/rules/:id/edit" element={<RuleForm />} />

View File

@@ -2,6 +2,7 @@ import { NavLink, Outlet, useNavigate } from "react-router-dom";
const navItems = [
{ path: "/admin/stamps", label: "图章管理" },
{ path: "/admin/articles", label: "文章管理" },
{ path: "/admin/rules", label: "兑换规则" },
{ path: "/admin/redemptions", label: "兑换记录" },
];

View File

@@ -0,0 +1,221 @@
import { useState, useEffect } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { adminFetch } from "./adminApi";
type Article = {
id: string;
title: string;
subtitle: string | null;
body: string;
coverImage: string;
caption: string | null;
sortOrder: number;
enabled: boolean;
};
export default function ArticleForm() {
const { id } = useParams();
const navigate = useNavigate();
const isEdit = !!id;
const [title, setTitle] = useState("");
const [subtitle, setSubtitle] = useState("");
const [body, setBody] = useState("");
const [caption, setCaption] = useState("");
const [coverImage, setCoverImage] = useState("");
const [sortOrder, setSortOrder] = useState(0);
const [enabled, setEnabled] = useState(true);
const [saving, setSaving] = useState(false);
const [error, setError] = useState("");
useEffect(() => {
if (!id) return;
adminFetch<Article[]>("/articles").then((articles) => {
const article = articles.find((a) => a.id === id);
if (article) {
setTitle(article.title);
setSubtitle(article.subtitle || "");
setBody(article.body);
setCaption(article.caption || "");
setCoverImage(article.coverImage);
setSortOrder(article.sortOrder);
setEnabled(article.enabled);
}
});
}, [id]);
const handleUpload = async (file: File) => {
if (!id) {
setError("请先保存文章后再上传封面");
return;
}
const formData = new FormData();
formData.append("image", file);
const data = await adminFetch<{ path: string }>(`/articles/${id}/upload`, {
method: "POST",
body: formData,
});
setCoverImage(data.path);
};
const handleSave = async () => {
setError("");
if (!title.trim()) {
setError("请输入标题");
return;
}
if (!body.trim()) {
setError("请输入正文");
return;
}
setSaving(true);
try {
const payload = {
title: title.trim(),
subtitle: subtitle.trim() || undefined,
body: body.trim(),
caption: caption.trim() || undefined,
sortOrder,
enabled,
};
if (isEdit) {
await adminFetch(`/articles/${id}`, {
method: "PUT",
body: JSON.stringify(payload),
});
} else {
const article = await adminFetch<Article>("/articles", {
method: "POST",
body: JSON.stringify(payload),
});
navigate(`/admin/articles/${article.id}/edit`, { replace: true });
return;
}
navigate("/admin/articles");
} catch (e) {
setError(e instanceof Error ? e.message : "保存失败");
} finally {
setSaving(false);
}
};
return (
<div className="max-w-2xl">
<h2 className="text-lg font-semibold text-gray-800 mb-4">
{isEdit ? "编辑文章" : "添加文章"}
</h2>
<div className="bg-white rounded-lg border border-gray-200 p-5 space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1"></label>
<input
value={title}
onChange={(e) => setTitle(e.target.value)}
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"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1"></label>
<input
value={subtitle}
onChange={(e) => setSubtitle(e.target.value)}
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"
/>
</div>
<div>
<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
value={body}
onChange={(e) => setBody(e.target.value)}
rows={18}
className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm leading-relaxed
focus:outline-none focus:ring-1 focus:ring-blue-500 font-mono"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1"></label>
<input
value={caption}
onChange={(e) => setCaption(e.target.value)}
placeholder="如1910 年的朝天宫大成殿旧影"
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"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<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>
<div className="flex items-center pt-6">
<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
type="file"
accept="image/*"
onChange={(e) => e.target.files?.[0] && handleUpload(e.target.files[0])}
className="text-xs text-gray-500"
/>
</div>
)}
{!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>
</div>
);
}

View File

@@ -0,0 +1,118 @@
import { useState, useEffect } from "react";
import { Link } from "react-router-dom";
import { adminFetch } from "./adminApi";
type Article = {
id: string;
title: string;
subtitle: string | null;
coverImage: string;
sortOrder: number;
enabled: boolean;
};
export default function ArticleList() {
const [articles, setArticles] = useState<Article[]>([]);
const [loading, setLoading] = useState(true);
const fetchArticles = async () => {
setLoading(true);
try {
const data = await adminFetch<Article[]>("/articles");
setArticles(data);
} finally {
setLoading(false);
}
};
useEffect(() => { fetchArticles(); }, []);
const handleDelete = async (id: string, title: string) => {
if (!confirm(`确定删除文章「${title}」?`)) return;
await adminFetch(`/articles/${id}`, { method: "DELETE" });
fetchArticles();
};
const handleToggle = async (id: string, enabled: boolean) => {
await adminFetch(`/articles/${id}`, {
method: "PUT",
body: JSON.stringify({ enabled: !enabled }),
});
fetchArticles();
};
if (loading) return <p className="text-gray-500">...</p>;
return (
<div>
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold text-gray-800"></h2>
<Link
to="/admin/articles/new"
className="px-4 py-2 bg-blue-600 text-white text-sm rounded-md hover:bg-blue-700"
>
</Link>
</div>
<div className="bg-white rounded-lg border border-gray-200 overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-gray-50 border-b border-gray-200">
<tr>
<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>
<th className="text-left px-4 py-3 font-medium text-gray-600"></th>
<th className="text-center px-4 py-3 font-medium text-gray-600"></th>
<th className="text-center px-4 py-3 font-medium text-gray-600"></th>
<th className="text-right px-4 py-3 font-medium text-gray-600"></th>
</tr>
</thead>
<tbody>
{articles.map((article) => (
<tr key={article.id} className="border-b border-gray-100 hover:bg-gray-50">
<td className="px-4 py-3">
<div className="w-16 h-10 rounded bg-gray-50 border border-gray-200 overflow-hidden flex items-center justify-center shadow-sm">
{article.coverImage && (
<img src={article.coverImage} alt="" className="w-full h-full object-cover" />
)}
</div>
</td>
<td className="px-4 py-3 text-gray-800 font-medium">{article.title}</td>
<td className="px-4 py-3 text-gray-500 max-w-[260px] truncate">{article.subtitle || "—"}</td>
<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 ? "启用" : "禁用"}
</button>
</td>
<td className="px-4 py-3 text-right space-x-2">
<Link to={`/admin/articles/${article.id}/edit`} className="text-blue-600 hover:underline">
</Link>
<Link to={`/admin/articles/${article.id}/qrcode`} className="text-blue-600 hover:underline">
</Link>
<button onClick={() => handleDelete(article.id, article.title)} className="text-red-500 hover:underline">
</button>
</td>
</tr>
))}
{articles.length === 0 && (
<tr>
<td colSpan={6} className="px-4 py-8 text-center text-gray-400">
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
);
}

View File

@@ -0,0 +1,120 @@
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

@@ -129,7 +129,9 @@ export default function StampForm() {
<div>
<label className="block text-sm font-medium text-gray-700 mb-1"></label>
{imageColor && (
<img src={imageColor} alt="彩色" className="w-20 h-20 object-contain bg-gray-50 rounded mb-2" />
<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={imageColor} alt="彩色" className="w-[92%] h-[92%] object-contain" />
</div>
)}
<input
type="file"
@@ -141,7 +143,9 @@ export default function StampForm() {
<div>
<label className="block text-sm font-medium text-gray-700 mb-1"></label>
{imageGrey && (
<img src={imageGrey} alt="灰色" className="w-20 h-20 object-contain bg-gray-50 rounded mb-2" />
<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"

View File

@@ -72,9 +72,9 @@ export default function StampList() {
{stamps.map((stamp) => (
<tr key={stamp.id} className="border-b border-gray-100 hover:bg-gray-50">
<td className="px-4 py-3">
<div className="w-10 h-10 rounded bg-gray-100 overflow-hidden">
<div className="w-10 h-10 rounded-full bg-white border border-gray-200 overflow-hidden flex items-center justify-center shadow-sm">
{stamp.imageColor && (
<img src={stamp.imageColor} alt="" className="w-full h-full object-contain" />
<img src={stamp.imageColor} alt="" className="w-[92%] h-[92%] object-contain" />
)}
</div>
</td>

View File

@@ -12,6 +12,7 @@ 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(() => {
@@ -58,6 +59,25 @@ export default function StampQRCode() {
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>;
@@ -69,7 +89,7 @@ export default function StampQRCode() {
<path d="M15 18l-6-6 6-6" />
</svg>
</Link>
<h2 className="text-lg font-semibold text-gray-800">{data.stampName} </h2>
<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">
@@ -84,12 +104,24 @@ export default function StampQRCode() {
{/* Hidden canvas for composite download */}
<canvas ref={canvasRef} className="hidden" />
<button
onClick={handleDownload}
className="px-4 py-2 bg-blue-600 text-white text-sm rounded-md hover:bg-blue-700"
>
</button>
<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

@@ -21,13 +21,19 @@ export default function StampPopup({ name, imageColor, note, status, onCollect,
<div className="w-72 bg-[var(--bg-cream)] rounded-2xl p-6 text-center shadow-[var(--shadow-lg)]">
{/* Stamp image */}
<div className="w-40 h-40 mx-auto mb-4">
<div className="w-full h-full rounded-xl overflow-hidden animate-stamp-press"
style={{ background: "linear-gradient(135deg, #fdf6ee 0%, #f8eed8 100%)" }}
<div
className="w-full h-full rounded-full bg-white overflow-hidden flex items-center justify-center border border-[var(--border-muted)] animate-stamp-press"
style={{
boxShadow:
status === "preview"
? "0 2px 8px rgba(0,0,0,0.06)"
: "0 4px 14px rgba(212,165,116,0.35), inset 0 0 0 1px rgba(212,165,116,0.2)",
}}
>
<img
src={imageColor}
alt={name}
className="w-full h-full object-contain p-4"
className="w-[92%] h-[92%] object-contain"
onError={(e) => {
const target = e.target as HTMLImageElement;
target.style.display = "none";

View File

@@ -122,15 +122,24 @@
opacity: 0;
animation: fade-in-up 0.5s cubic-bezier(0.22, 1, 0.36, 1) both;
}
.stagger-children > *:nth-child(1) { animation-delay: 0.1s; }
.stagger-children > *:nth-child(2) { animation-delay: 0.2s; }
.stagger-children > *:nth-child(3) { animation-delay: 0.3s; }
.stagger-children > *:nth-child(4) { animation-delay: 0.4s; }
.stagger-children > *:nth-child(5) { animation-delay: 0.5s; }
.stagger-children > *:nth-child(6) { animation-delay: 0.6s; }
.stagger-children > *:nth-child(7) { animation-delay: 0.7s; }
.stagger-children > *:nth-child(8) { animation-delay: 0.8s; }
.stagger-children > *:nth-child(9) { animation-delay: 0.9s; }
.stagger-children > *:nth-child(1) { animation-delay: 0.04s; }
.stagger-children > *:nth-child(2) { animation-delay: 0.08s; }
.stagger-children > *:nth-child(3) { animation-delay: 0.12s; }
.stagger-children > *:nth-child(4) { animation-delay: 0.16s; }
.stagger-children > *:nth-child(5) { animation-delay: 0.20s; }
.stagger-children > *:nth-child(6) { animation-delay: 0.24s; }
.stagger-children > *:nth-child(7) { animation-delay: 0.28s; }
.stagger-children > *:nth-child(8) { animation-delay: 0.32s; }
.stagger-children > *:nth-child(9) { animation-delay: 0.36s; }
.stagger-children > *:nth-child(10) { animation-delay: 0.40s; }
.stagger-children > *:nth-child(11) { animation-delay: 0.44s; }
.stagger-children > *:nth-child(12) { animation-delay: 0.48s; }
.stagger-children > *:nth-child(13) { animation-delay: 0.52s; }
.stagger-children > *:nth-child(14) { animation-delay: 0.56s; }
.stagger-children > *:nth-child(15) { animation-delay: 0.60s; }
.stagger-children > *:nth-child(16) { animation-delay: 0.64s; }
/* 超过 16 的子元素统一立即显示(不错乱) */
.stagger-children > *:nth-child(n+17) { animation-delay: 0.68s; }
/* Stamp Card Effects */
.stamp-border {

View File

@@ -0,0 +1,152 @@
import { useEffect, useState } from "react";
import { useParams, Link } from "react-router-dom";
import { apiFetch } from "../lib/api";
type ArticleDetail = {
id: string;
title: string;
subtitle: string | null;
body: string;
coverImage: string;
caption: string | null;
};
export default function ArticlePage() {
const { id } = useParams();
const [article, setArticle] = useState<ArticleDetail | null>(null);
const [state, setState] = useState<"loading" | "ok" | "error">("loading");
useEffect(() => {
if (!id) return;
setState("loading");
apiFetch<ArticleDetail>(`/articles/${id}`)
.then((data) => {
setArticle(data);
setState("ok");
})
.catch(() => setState("error"));
}, [id]);
if (state === "loading") {
return (
<div className="min-h-svh paper-texture flex items-center justify-center">
<p className="text-[var(--text-muted)] text-sm tracking-wider"></p>
</div>
);
}
if (state === "error" || !article) {
return (
<div className="min-h-svh paper-texture flex flex-col items-center justify-center gap-4 px-6">
<p className="text-[var(--text-secondary)]"></p>
<Link
to="/"
className="px-5 py-2 rounded-full bg-[var(--gold)] text-white text-sm hover:bg-[var(--gold-hover)]"
>
</Link>
</div>
);
}
const paragraphs = article.body.split(/\n\s*\n/).map((p) => p.trim()).filter(Boolean);
return (
<div className="relative paper-texture grain-overlay min-h-svh">
{/* Top back button */}
<Link
to="/"
aria-label="返回"
className="fixed top-4 left-4 z-20 w-10 h-10 rounded-full bg-white/70 backdrop-blur border border-[var(--border-muted)]
flex items-center justify-center text-[var(--text-secondary)] hover:bg-white shadow-sm"
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M15 18l-6-6 6-6" />
</svg>
</Link>
<article className="relative max-w-2xl mx-auto px-6 pt-20 pb-24">
{/* Hero */}
<header className="text-center animate-fade-in-up">
<div className="inline-flex items-center gap-3 mb-6">
<span className="block w-8 h-px bg-[var(--gold)]/50" />
<span className="text-[var(--gold)] text-[10px] tracking-[0.4em] uppercase"
style={{ fontFamily: "'Playfair Display', serif" }}>
CityWalk · Story
</span>
<span className="block w-8 h-px bg-[var(--gold)]/50" />
</div>
<h1
className="text-[var(--text-primary)] leading-[1.15] mb-4"
style={{
fontSize: "clamp(2.25rem, 8vw, 3.25rem)",
fontFamily: "'Playfair Display', serif",
fontWeight: 700,
letterSpacing: "-0.01em",
}}
>
{article.title}
</h1>
{article.subtitle && (
<p className="text-[var(--text-secondary)] text-base tracking-[0.08em] leading-relaxed">
{article.subtitle}
</p>
)}
<div className="ornament-line mt-10" />
</header>
{/* Cover image */}
{article.coverImage && (
<figure className="mt-8 animate-fade-in-up" style={{ animationDelay: "0.1s" }}>
<div className="w-full aspect-[4/3] rounded-lg overflow-hidden bg-[var(--bg-paper)] shadow-[0_8px_24px_rgba(26,26,46,0.12)]">
<img
src={article.coverImage}
alt={article.caption || article.title}
className="w-full h-full object-cover"
/>
</div>
{article.caption && (
<figcaption className="mt-3 text-center text-xs text-[var(--text-muted)] italic tracking-wide">
{article.caption}
</figcaption>
)}
</figure>
)}
{/* Body */}
<section
className="mt-10 animate-fade-in-up"
style={{ animationDelay: "0.2s" }}
>
{paragraphs.map((p, i) => (
<p
key={i}
className="text-[15px] text-[var(--text-primary)] mb-5"
style={{ lineHeight: 2, textIndent: "2em" }}
>
{p}
</p>
))}
</section>
{/* Footer */}
<footer className="mt-16 text-center animate-fade-in-up" style={{ animationDelay: "0.3s" }}>
<div className="ornament-line mb-8" />
<p className="text-[var(--text-muted)] text-[11px] tracking-[0.3em] uppercase mb-5">
Continue Your CityWalk
</p>
<Link
to="/"
className="inline-flex items-center gap-2 px-6 py-2.5 rounded-full border border-[var(--gold)]/60
text-[var(--gold-hover)] text-sm hover:bg-[var(--gold)]/10 transition-colors"
>
</Link>
</footer>
</article>
</div>
);
}

View File

@@ -20,7 +20,7 @@ type CollectState = "idle" | "loading" | "show_stamp" | "needs_register" | "coll
const STEPS = [
{ num: "01", title: "前往点位", desc: "跟随路线,探索散落在城市中的文化地标" },
{ num: "02", title: "扫码集章", desc: "发现点位专属二维码,扫描即刻收入囊中" },
{ num: "02", title: "触碰集章", desc: "手机轻触点位 NFC 芯片,图章即刻收入囊中" },
{ num: "03", title: "兑换好礼", desc: "集满图章,兑换城市限定纪念品" },
];