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:
@@ -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 />} />
|
||||
|
||||
@@ -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: "兑换记录" },
|
||||
];
|
||||
|
||||
221
packages/web/src/admin/ArticleForm.tsx
Normal file
221
packages/web/src/admin/ArticleForm.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
118
packages/web/src/admin/ArticleList.tsx
Normal file
118
packages/web/src/admin/ArticleList.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
120
packages/web/src/admin/ArticleQRCode.tsx
Normal file
120
packages/web/src/admin/ArticleQRCode.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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 {
|
||||
|
||||
152
packages/web/src/pages/ArticlePage.tsx
Normal file
152
packages/web/src/pages/ArticlePage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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: "集满图章,兑换城市限定纪念品" },
|
||||
];
|
||||
|
||||
|
||||
Reference in New Issue
Block a user