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

@@ -0,0 +1,146 @@
---
description: 推送本地变更到 njcq 服务器并重新部署 Docker Compose
argument-hint: "[可选 --no-build 仅 rsync 不重启 | --seed 部署后执行 db:seed危险会清空用户数据]"
---
# 一键部署 citywalk-stamp 到 njcq 服务器
## 部署目标
- 主机SSH 别名 `njcq`
- 目录:`/opt/1panel/apps/citywalk-stamp`
- 容器:`citywalk-stamp`(端口 `127.0.0.1:3001` ← 宿主 3000 被 1Panel Gitea 占用)
- 持久化:`./data/`SQLite`./uploads/`(图章图片)、`.env`(密钥)均在服务器保留,本次部署不会覆盖
## 参数
用户可能通过 `$ARGUMENTS` 传入:
- `--no-build`:跳过 compose up仅同步代码
- `--seed`:部署后执行 `pnpm db:seed`(⚠️ 会清空 Stamp 表并级联删除用户 Collection 数据,仅首次部署或彻底重置时使用)
- 无参数:默认推送代码 + 重新 build 并重启容器
## 步骤
### 1. 预检
确认当前在项目根:
```bash
test -f Dockerfile && test -f docker-compose.yml && test -f pnpm-workspace.yaml
```
如缺文件立即中止。
### 2. rsync 推送代码sudo 身份写 /opt
```bash
rsync -avz --delete --rsync-path="sudo rsync" \
--exclude='.git/' \
--exclude='node_modules/' \
--exclude='**/node_modules/' \
--exclude='**/dist/' \
--exclude='.env' \
--exclude='.env.backup' \
--exclude='.env.local' \
--exclude='.claude/' \
--exclude='prisma/dev.db*' \
--exclude='/data/' \
--exclude='/uploads/' \
--exclude='.DS_Store' \
--exclude='build.log' \
./ njcq:/opt/1panel/apps/citywalk-stamp/
```
**关键陷阱(必须保持以下写法)**
- `--rsync-path="sudo rsync"` 解决 ubuntu 用户对 `/opt` 无写权限(否则 rsync 会静默 status 23目录仍为空
- `/data/``/uploads/` **带前导斜杠**——只排除项目根下这两个目录(它们是 docker bind mount 产生的运行时数据),**不要**写成 `data/``uploads/`,否则会把 `packages/server/uploads/stamps/` 也一并排除
- `.env` 被排除,远端 JWT_SECRET / ADMIN_API_KEY / SITE_URL 不受影响
- `--delete` 会删除远端多余文件,但 exclude 的目录保留
### 3. 如果是 `--no-build`,到这里就结束
只同步代码而不重启服务,只在改了纯文档或静态资源、容器内存里的代码用不上时才用。
### 4. 远端后台启动 compose避免 SSH 断开导致构建中断)
```bash
ssh njcq 'sudo bash -c "cd /opt/1panel/apps/citywalk-stamp && rm -f build.log && nohup docker compose up -d --build > build.log 2>&1 &" && sleep 2 && echo started'
```
`nohup ... &` 让构建脱离 SSH 进程。ssh 立即返回,构建在远端持续进行。
### 5. 轮询等待构建完成(可能 3-5 分钟)
**不要**靠 `pgrep` 判断构建是否结束——服务器用的是 docker v2`docker compose`),没有名为 `docker-compose compose up` 的进程pgrep 永远返回 falsewhile 循环会立即退出,接着的 `docker compose ps` 会在 daemon 忙时阻塞,表现为"卡住"。直接读 `build.log` 的末尾特征字符串才准:
```bash
ssh njcq '
cd /opt/1panel/apps/citywalk-stamp
for i in $(seq 1 40); do # 最多等 10 分钟
tail_out=$(sudo tail -n 30 build.log 2>/dev/null)
# 成功标记docker compose v2 finish line
if echo "$tail_out" | grep -qE "Container .* (Started|Running|Healthy)"; then
echo "=== build finished ==="
break
fi
# 失败标记
if echo "$tail_out" | grep -qE "(failed to solve|Error response from daemon|ERROR: )"; then
echo "=== build FAILED ==="
echo "$tail_out" | tail -15
exit 1
fi
sleep 15
echo "... building $(date +%H:%M:%S)"
echo "$tail_out" | tail -2 | sed "s/^/ /"
done
echo "---"
sudo docker compose ps
echo "=== container logs (last 20 lines) ==="
sudo docker compose logs --tail=20 app
'
```
**Bash `run_in_background: true`** 启动这条命令,然后用 TaskOutput 等待完成——避免 SSH 命令卡住主 Claude 流程。
### 6. 健康检查
```bash
ssh njcq '
echo "--- /api/health ---"
curl -sS http://127.0.0.1:3001/api/health && echo
echo "--- /api/stamps count ---"
curl -sS http://127.0.0.1:3001/api/stamps | python3 -c "import sys,json; d=json.load(sys.stdin); print(len(d[\"data\"]))"
echo "--- /uploads sample ---"
curl -sI http://127.0.0.1:3001/uploads/stamps/stamp-01-color.jpg | head -1
echo "--- /album (SPA) ---"
curl -s -o /dev/null -w "http %{http_code}\n" http://127.0.0.1:3001/album
'
```
期望输出:
- health: `{"success":true,...}`
- stamps count: `16`(如果是首次部署或数据库被清空,可能是 `0`,此时需要 `--seed`
- stamps 首图: `HTTP/1.1 200 OK`
- SPA: `http 200`
### 7. 如传入 `--seed`,执行首次 seed
**仅在首次部署或主动重置时使用**。会触发 `seed.ts` 里的 `prisma.stamp.deleteMany()` 级联删除所有 Collection
```bash
ssh njcq 'cd /opt/1panel/apps/citywalk-stamp && sudo docker compose exec -T app pnpm db:seed' | tail -10
```
## 完成后给用户的反馈
一段简短摘要,包含:
- 镜像是否重建、容器是否在运行
- `/api/stamps` 返回的图章数量
- 访问入口:`https://njcitywalk.njcqit.com`(外部 Nginx 反代到 127.0.0.1:3001
- 如果有异常stamps=0、health 失败、容器重启循环),提示如何查看日志:
`ssh njcq 'cd /opt/1panel/apps/citywalk-stamp && sudo docker compose logs -f app'`
## 不做的事(防止破坏生产数据)
- ❌ 不删除远端 `data/``uploads/`(已排除)
- ❌ 不覆盖 `.env`(已排除)
- ❌ 不自动 `pnpm db:seed`(必须显式 `--seed`
- ❌ 不修改 Nginx 配置或证书
- ❌ 不在远端执行 `git pull`(没 git 仓,只走 rsync

1
.gitignore vendored
View File

@@ -7,3 +7,4 @@ uploads/*
!uploads/.gitkeep !uploads/.gitkeep
.DS_Store .DS_Store
*.tsbuildinfo *.tsbuildinfo
.claude/settings.local.json

View File

@@ -30,10 +30,13 @@ RUN pnpm exec prisma generate
# Build web SPA; server runs directly from TS via tsx # Build web SPA; server runs directly from TS via tsx
RUN pnpm --filter @stamp/web build RUN pnpm --filter @stamp/web build
# Stash initial stamp assets so the uploads volume can be seeded on first run # Stash initial stamp + article assets so the uploads volume can be seeded on first run
RUN mkdir -p /app/stamps-seed \ RUN mkdir -p /app/stamps-seed \
&& cp packages/server/uploads/stamps/*.jpg /app/stamps-seed/ \ && cp packages/server/uploads/stamps/*.jpg /app/stamps-seed/ \
&& rm -rf packages/server/uploads/stamps && rm -rf packages/server/uploads/stamps
RUN mkdir -p /app/articles-seed \
&& cp packages/server/uploads/articles/*.jpg /app/articles-seed/ \
&& rm -rf packages/server/uploads/articles
COPY docker/entrypoint.sh /usr/local/bin/entrypoint.sh COPY docker/entrypoint.sh /usr/local/bin/entrypoint.sh
RUN chmod +x /usr/local/bin/entrypoint.sh RUN chmod +x /usr/local/bin/entrypoint.sh

View File

@@ -5,6 +5,7 @@ cd /app
mkdir -p /app/data mkdir -p /app/data
mkdir -p /app/packages/server/uploads/stamps mkdir -p /app/packages/server/uploads/stamps
mkdir -p /app/packages/server/uploads/articles
# Seed stamp assets on first run (idempotent) # Seed stamp assets on first run (idempotent)
if [ -z "$(ls -A /app/packages/server/uploads/stamps 2>/dev/null)" ]; then if [ -z "$(ls -A /app/packages/server/uploads/stamps 2>/dev/null)" ]; then
@@ -12,6 +13,12 @@ if [ -z "$(ls -A /app/packages/server/uploads/stamps 2>/dev/null)" ]; then
cp /app/stamps-seed/*.jpg /app/packages/server/uploads/stamps/ 2>/dev/null || true cp /app/stamps-seed/*.jpg /app/packages/server/uploads/stamps/ 2>/dev/null || true
fi fi
# Seed article assets on first run (idempotent)
if [ -z "$(ls -A /app/packages/server/uploads/articles 2>/dev/null)" ]; then
echo "→ Seeding article assets into uploads volume..."
cp /app/articles-seed/*.jpg /app/packages/server/uploads/articles/ 2>/dev/null || true
fi
echo "→ Applying database migrations..." echo "→ Applying database migrations..."
pnpm exec prisma migrate deploy pnpm exec prisma migrate deploy

View File

@@ -8,7 +8,8 @@
"db:generate": "prisma generate", "db:generate": "prisma generate",
"db:migrate": "prisma migrate dev", "db:migrate": "prisma migrate dev",
"db:push": "prisma db push", "db:push": "prisma db push",
"db:seed": "pnpm --filter @stamp/server seed" "db:seed": "pnpm --filter @stamp/server seed",
"db:seed-articles": "pnpm --filter @stamp/server seed-articles"
}, },
"engines": { "engines": {
"node": ">=20" "node": ">=20"

View File

@@ -7,7 +7,8 @@
"dev": "tsx watch src/index.ts", "dev": "tsx watch src/index.ts",
"build": "tsc", "build": "tsc",
"start": "node dist/index.js", "start": "node dist/index.js",
"seed": "tsx src/seed.ts" "seed": "tsx src/seed.ts",
"seed-articles": "tsx src/seed-articles.ts"
}, },
"dependencies": { "dependencies": {
"@stamp/shared": "workspace:*", "@stamp/shared": "workspace:*",

View File

@@ -4,6 +4,7 @@ import path from "path";
import { fileURLToPath } from "url"; import { fileURLToPath } from "url";
import authRoutes from "./routes/auth.js"; import authRoutes from "./routes/auth.js";
import stampRoutes from "./routes/stamps.js"; import stampRoutes from "./routes/stamps.js";
import articleRoutes from "./routes/articles.js";
import redemptionRoutes from "./routes/redemption.js"; import redemptionRoutes from "./routes/redemption.js";
import adminRoutes from "./routes/admin.js"; import adminRoutes from "./routes/admin.js";
@@ -26,6 +27,7 @@ app.get("/api/health", (_req, res) => {
// User-facing routes // User-facing routes
app.use("/api/auth", authRoutes); app.use("/api/auth", authRoutes);
app.use("/api/stamps", stampRoutes); app.use("/api/stamps", stampRoutes);
app.use("/api/articles", articleRoutes);
app.use("/api/redemption", redemptionRoutes); app.use("/api/redemption", redemptionRoutes);
// Admin routes // Admin routes

View File

@@ -194,4 +194,92 @@ router.get("/stats", async (_req, res) => {
res.json({ success: true, data: { userCount, collectionCount, redemptionCount } }); res.json({ success: true, data: { userCount, collectionCount, redemptionCount } });
}); });
// ===== Articles CRUD =====
router.get("/articles", async (_req, res) => {
const articles = await prisma.article.findMany({ orderBy: { sortOrder: "asc" } });
res.json({ success: true, data: articles });
});
const articleSchema = z.object({
title: z.string().min(1, "标题不能为空"),
subtitle: z.string().optional(),
body: z.string().min(1, "正文不能为空"),
coverImage: z.string().optional(),
caption: z.string().optional(),
sortOrder: z.number().int().optional(),
enabled: z.boolean().optional(),
});
router.post("/articles", async (req, res) => {
const parsed = articleSchema.safeParse(req.body);
if (!parsed.success) {
res.status(400).json({ success: false, error: { code: "VALIDATION", message: parsed.error.issues[0].message } });
return;
}
const article = await prisma.article.create({
data: {
title: parsed.data.title,
subtitle: parsed.data.subtitle,
body: parsed.data.body,
coverImage: parsed.data.coverImage ?? "",
caption: parsed.data.caption,
sortOrder: parsed.data.sortOrder ?? 0,
enabled: parsed.data.enabled ?? true,
},
});
res.json({ success: true, data: article });
});
router.put("/articles/:id", async (req, res) => {
const parsed = articleSchema.partial().safeParse(req.body);
if (!parsed.success) {
res.status(400).json({ success: false, error: { code: "VALIDATION", message: parsed.error.issues[0].message } });
return;
}
const article = await prisma.article.update({
where: { id: req.params.id },
data: parsed.data,
}).catch(() => null);
if (!article) {
res.status(404).json({ success: false, error: { code: "NOT_FOUND", message: "文章不存在" } });
return;
}
res.json({ success: true, data: article });
});
router.delete("/articles/:id", async (req, res) => {
await prisma.article.delete({ where: { id: req.params.id } }).catch(() => null);
res.json({ success: true, data: null });
});
router.post("/articles/:id/upload", upload.single("image"), async (req, res) => {
if (!req.file) {
res.status(400).json({ success: false, error: { code: "NO_FILE", message: "请选择图片" } });
return;
}
const imagePath = `/uploads/${req.file.filename}`;
const article = await prisma.article.update({
where: { id: req.params.id },
data: { coverImage: imagePath },
}).catch(() => null);
if (!article) {
res.status(404).json({ success: false, error: { code: "NOT_FOUND", message: "文章不存在" } });
return;
}
res.json({ success: true, data: { path: imagePath } });
});
router.get("/articles/:id/qrcode", async (req, res) => {
const article = await prisma.article.findUnique({ where: { id: req.params.id } });
if (!article) {
res.status(404).json({ success: false, error: { code: "NOT_FOUND", message: "文章不存在" } });
return;
}
const siteUrl = process.env.SITE_URL || "http://localhost:5173";
const articleUrl = `${siteUrl}/article/${article.id}`;
const qrDataUrl = await generateQRCodeDataURL(articleUrl, { width: 400 });
res.json({ success: true, data: { qrDataUrl, articleUrl, articleTitle: article.title } });
});
export default router; export default router;

View File

@@ -0,0 +1,37 @@
import { Router } from "express";
import { prisma } from "@stamp/shared";
const router = Router();
router.get("/", async (_req, res) => {
const articles = await prisma.article.findMany({
where: { enabled: true },
orderBy: { sortOrder: "asc" },
select: { id: true, title: true, subtitle: true, coverImage: true, sortOrder: true },
});
res.json({ success: true, data: articles });
});
router.get("/:id", async (req, res) => {
const article = await prisma.article.findUnique({
where: { id: req.params.id },
select: {
id: true,
title: true,
subtitle: true,
body: true,
coverImage: true,
caption: true,
sortOrder: true,
enabled: true,
},
});
if (!article || !article.enabled) {
res.status(404).json({ success: false, error: { code: "NOT_FOUND", message: "文章不存在" } });
return;
}
const { enabled: _, ...data } = article;
res.json({ success: true, data });
});
export default router;

View File

@@ -0,0 +1,127 @@
import { prisma } from "@stamp/shared";
const articles = [
{
title: "朝天宫",
subtitle: "千年冶山,文脉绵延",
caption: "1910 年的朝天宫大成殿旧影",
body: `冶山之巅,文脉悠悠,朝天宫的岁月,始于春秋的冶铸星火。吴王夫差在此筑冶城,熔铁铸剑,炉火映照着金陵最初的城邑印记,冶山之名,自此镌刻在古都的肌理之中。
历经六朝更迭,这片土地几经变迁。东晋立寺,南朝设学,唐宋元数易其名,从道观到学宫,始终是金陵文化氤氲之地。明洪武十七年,朱元璋下诏重建,赐名"朝天宫",黄瓦朱墙间,成为皇家祈福、百官习仪的圣地,尽显大明都城的雍容气象。
清时风云变幻,太平天国战火焚毁旧日宫观,曾国藩主持重建,将其改为文庙与江宁府学,琉璃红墙里,藏起书香文脉,定格下如今的建筑格局。民国岁月,它曾承载司法印记,也见证过乱世里的文化坚守。
如今,朝天宫化作南京市博物馆,古建筑与文物相映成辉。从冶城剑火到明清宫宇,从礼仪圣地到文博殿堂,千年时光在此沉淀。一砖一瓦藏旧事,一草一木诉沧桑,它静静伫立,将金陵千年过往,妥帖珍藏,续写着古都不绝的文脉华章。`,
},
{
title: "七家湾",
subtitle: "曲巷藏史,旧说流芳",
caption: "清代江宁省城图里的七家湾",
body: `城南巷弄深处,七家湾静卧运渎之畔,名字里藏着几段缠绕数百年的金陵旧梦。元代起,这里已是人烟渐聚的市井,而真正让它声名远播的,是明初那几个交织着市井烟火与皇家风云的传说。
流传最广的是"七姓聚居说"。相传明初,陶、马、丁、姚、哈、莫、白七户人家在此结邻而居,街巷曲回如环,"湾"字便描摹了这蜿蜒肌理,七家湾之名由此诞生,成为城南一处安稳的栖居地。
更具传奇色彩的是"幸存七户说"。清末陈作霖《运渎桥道小志》载,朱元璋上元夜微行至此,见灯笼上画着"不缠足妇女怀抱西瓜",疑为讥讽大脚马皇后,龙颜大怒,下令屠尽巷中张灯人家,唯余七户未张灯者幸免于难,街巷遂得名七家湾。这则带着血腥味的传说,为巷陌添了几分悲壮色彩。
还有"七家转弯说"。老南京人常说"七家湾扛毛竹——转不过湾来",此地街巷如迷宫,每过七户人家便要转入另一条街巷,曲折迂回间,"七家湾"的名字也随着行人的脚步流传开来。
另有"从龙七将说",称朱元璋定都南京后,将七位有功的回族将领安置于此,建回回街,七姓人家在此繁衍生息,成为城南一处独特的聚居地。
岁月流转,明清时这里已是城南重要的市井街区,民国年间更添几分繁华。如今的七家湾,依旧保留着老城南的街巷肌理,那些古老传说在青石板路上静静流淌,与现代生活交融共生。一弯巷陌藏旧事,几段传奇映古今,七家湾以它独有的方式,诉说着金陵城的千年沧桑。`,
},
{
title: "运渎",
subtitle: "碧水通衢,灯影千年",
caption: "明代《南都繁会图卷》局部,运渎两岸的各种店招",
body: `秦淮之畔碧水蜿蜒运渎的岁月始于东吴赤乌三年240的凿河声中。孙权定都建业为解宫城漕运之需命左台侍御史郗俭督率民夫开凿了这条南连秦淮河、北接潮沟的人工河道成为向宫城仓城输送粮草物资的水上生命线。作为建康城内第一条人工运河它串联起长干里商埠与台城宫殿也为六朝海上贸易铺就了关键的城内通道。
六朝时期,运渎迎来鼎盛。东吴派朱应、康泰出使南海诸国,开辟长江流域的"海上丝绸之路",来自扶南、林邑等地的香料、珍宝,经石头城入秦淮,循运渎直达宫城,与江南漕粮一同在水面上往来穿梭。这条碧水通衢,见证了建康作为"海上丝路"东方起点的繁华,成为中外文明交融的重要纽带。
隋唐以降,运渎功能渐变,至明清时,河畔笪桥一带兴起笪桥灯市,成为与夫子庙齐名的秦淮灯会发源地。甘熙《白下琐言》载:"笪桥灯市,由来已久,正月初鱼龙杂沓,有银花火树之观",剪纸灯彩与彩帛花灯交相辉映,康熙南巡亦曾微服观灯,灯火映照下的运渎,成了金陵最热闹的元宵胜景。
岁月流转,运渎河道虽渐隐于市井,但其文脉绵延不绝。如今,熙南里街区复兴了笪桥灯市,重现"星河万盏,夜市千灯"的盛景。从东吴漕运要道到六朝贸易枢纽,从明清灯影胜地到今日文化地标,运渎以它独有的方式,将千年漕运史、海上贸易史与民俗文化史,静静流淌在南京的街巷之间。
一渠碧水通古今,两岸灯影映繁华,运渎的每一朵浪花,都在诉说着金陵城的水运传奇与文明交融的故事,在时光长河中,愈发清澈绵长。`,
},
{
title: "打钉巷",
subtitle: "铁铺遗音,锅贴传香",
caption: "1948 年南京市街道详图里的打钉巷",
body: `城南窄巷,烟火绵延,打钉巷的岁月,始于明代"金陵十八坊"的铁砧声中。朱元璋定都南京,"百工各有区肆",铁匠们在此聚集,叮叮当当的敲打声终日不绝,打钉巷之名,自此嵌入老城南的肌理。两百余米的街巷,西接七家湾,东连评事街,成为城南手工业与市井生活的交汇点。
明清更迭,铁铺渐稀,回民聚居于此,清真文化悄然扎根。清乾隆年间,草桥清真寺在巷中兴起,成为回民精神家园。民国时期,这里人口稠密,商肆林立,李记清真馆的前身"李洪兴牛坊"在此营生,草桥扁食店的技艺也于此时传承。铁匠的锤声虽淡,市井的烟火却愈发浓郁。
岁月流转,打钉巷成了南京清真美食的地标。李记清真馆扎根打钉巷 1 号,百年技艺代代相传,招牌牛肉锅贴外酥里嫩,汁水丰盈,获米其林必比登推荐,成为南京人清晨排队的念想。几步之遥的草桥清真牛肉锅贴扁食店,坚守传统风味,扁食与锅贴并香,是老南京心中的地道滋味。两家老店,一巷双璧,共同续写着打钉巷的美食传奇。
如今,打钉巷依旧保留着老城南的街巷肌理,青砖黛瓦间,铁铺遗韵与美食芬芳交融。李记门前长队不绝,草桥店内烟火氤氲,一口锅贴,一碗扁食,藏着百年匠心与市井温情。铁砧声远,滋味长存,这条古巷以舌尖上的传承,诉说着金陵城的人间烟火,在岁月长河中,愈发醇厚绵长。`,
},
{
title: "绒庄街",
subtitle: "丝绒映彩,锦韵流长",
caption: "非遗绒花",
body: `城南古巷,经纬交织,绒庄街的岁月,始于明代"帽儿行"的叫卖声中。朱元璋定都南京,丝织业兴起,这里聚集了众多制帽商户,因"帽儿行"谐音"冇儿行"犯了忌讳,街上大户绒庄老板刘万丰牵头改名,"绒庄街"之名自此嵌入老城南的肌理。百余米的街巷,东接南捕厅,西连绫庄巷,成为城南丝织业的核心枢纽。
明清之际,南京丝织业达至鼎盛,绒庄街成了绒缎批发集散地,《运渎桥道小志》载:"绒庄街,以绒庄得名"。街面两侧绒庄林立,大户临街设店,后屋为坊,蚕丝经打线、染色、起绒,化作华美的绒缎。太平天国时期,绣花馆设于此处,中兴源丝织厂早期也在此营生,成为南京最早的近代工厂之一。这里的绒花技艺更是独树一帜,以蚕丝染色成绒,钢丝勾条制作,谐音"荣华",成了宫廷与民间的吉祥饰物。
影视光影,唤醒非遗记忆。《延禧攻略》中富察皇后"不御珠翠,惟插通草绒花"的清雅形象,《如懿传》里李玉赠予惢心的那支绒花信物,皆出自南京绒花非遗传承人赵树宪之手。这些剧中的绒花,正是绒庄街百年技艺的延续——参照故宫清代皇室发饰收藏,经打绒、传丝、劈丝、勾条等数十道工序,方才绽放出"荣华高升"的美好寓意。2018 年剧集热播,让这条古巷的柔软记忆,随荧幕传遍四海。
岁月流转,绒庄街依旧保留着老城南的街巷肌理,市井巷陌间,丝绒芬芳与影视传奇交融。如今,熙南里街区复兴了绒花技艺,非遗工作室里,蚕丝与铜条在匠人的指尖"变身"成五彩花朵,重现"荣华"之美。从明代帽行到明清绒庄,从太平天国绣馆到今日影视非遗,绒庄街以它独有的方式,将千年丝织史、民俗文化史与影视传播史,静静编织在南京的街巷之间。
一缕丝绒连古今,半巷荣华映光影,绒庄街的每一寸土地,都在诉说着金陵城的丝绸传奇与匠心传承的故事,在时光长河中,愈发柔软绵长。`,
},
{
title: "熙南里",
subtitle: "古巷新声,宅韵悠远",
caption: "号称\"九十九间半\"的甘熙故居俯瞰",
body: `老城南的市井风华,尽数凝萃于熙南里的街巷之间,这片依循南都繁会图景焕新的街区,承载着金陵数百年的人居烟火与人文底蕴,在时代更迭中始终守着城南的根脉与风骨。
清代中期,藏书家甘熙于此营建宅第,这座被称作"九十九间半"的甘熙故居,依巷而筑、格局雅致,是金陵传统民居的经典代表,也成为熙南里的文化核心。宅院之中藏着书香与民俗,见证着城南世家的风雅日常,也留存下老南京的生活印记与民俗技艺,历经岁月洗礼,依旧诉说着旧时城南的人文盛景。
与甘熙故居相依的大板巷,古称习艺街,自古便是匠作云集、市井喧闹之地,街巷串联起周边阡陌巷弄,是老城南商贸与生活的重要脉络。明清至民国年间,大板巷商号林立、人声熙攘,织就出一派鲜活的城南市井画卷,街巷的肌理与烟火气,也就此深深烙印在熙南里的岁月之中。
时光流转,熙南里在保留原有街巷格局与历史遗存的基础上,让古老街区焕发新生。传统民居的雅致底蕴与现代生活的鲜活气息在此相融,甘熙故居里的民俗展演延续着非遗文脉,大板巷间的新潮业态与创意小店则为古巷注入年轻活力。
昔日的城南市井盛景在此重现,非遗技艺、文创潮品、市井烟火交织共生,没有生硬的复刻,只有自然的传承与新生。从清代宅第的书香雅致,到古巷老街的市井喧闹,再到如今古今交融的文化街区,熙南里始终扎根城南土壤,将历史底蕴与当代生活温柔联结。
一宅藏旧事,一巷纳新风,熙南里以温润的姿态,留住老城南的烟火与风雅,让金陵的人文记忆在街巷间缓缓流淌,在岁月更迭中,书写着属于城南的崭新篇章。`,
},
];
async function seed() {
console.log("Seeding articles...");
await prisma.article.deleteMany();
const created = [];
for (let i = 0; i < articles.length; i++) {
const a = articles[i];
const pos = String(i + 1).padStart(2, "0");
const article = await prisma.article.create({
data: {
title: a.title,
subtitle: a.subtitle,
body: a.body,
caption: a.caption,
coverImage: `/uploads/articles/article-${pos}.jpg`,
sortOrder: i + 1,
enabled: true,
},
});
created.push(article);
}
console.log(`Created ${created.length} articles`);
console.log("\nArticle IDs for testing:");
created.forEach((a) => {
console.log(` ${a.sortOrder}. ${a.title}: /article/${a.id}`);
});
console.log("\nSeed complete!");
}
seed()
.catch((e) => {
console.error(e);
process.exit(1);
})
.finally(() => prisma.$disconnect());

Binary file not shown.

After

Width:  |  Height:  |  Size: 193 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 381 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 222 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 321 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 772 KiB

View File

@@ -28,3 +28,21 @@ export type RedemptionRecord = {
stampCount: number; stampCount: number;
redeemedAt: string; redeemedAt: string;
}; };
export type ArticleSummary = {
id: string;
title: string;
subtitle: string | null;
coverImage: string;
sortOrder: number;
};
export type ArticleDetail = {
id: string;
title: string;
subtitle: string | null;
body: string;
coverImage: string;
caption: string | null;
sortOrder: number;
};

View File

@@ -2,12 +2,16 @@ import { Routes, Route, Navigate, useParams } from "react-router-dom";
import { AuthProvider } from "./lib/auth"; import { AuthProvider } from "./lib/auth";
import LandingPage from "./pages/LandingPage"; import LandingPage from "./pages/LandingPage";
import AlbumPage from "./pages/AlbumPage"; import AlbumPage from "./pages/AlbumPage";
import ArticlePage from "./pages/ArticlePage";
import AdminLogin from "./admin/AdminLogin"; 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 StampForm from "./admin/StampForm";
import StampQRCode from "./admin/StampQRCode"; 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 RuleList from "./admin/RuleList";
import RuleForm from "./admin/RuleForm"; import RuleForm from "./admin/RuleForm";
import RedemptionLog from "./admin/RedemptionLog"; import RedemptionLog from "./admin/RedemptionLog";
@@ -25,6 +29,7 @@ export default function App() {
<Route path="/" element={<LandingPage />} /> <Route path="/" element={<LandingPage />} />
<Route path="/album" element={<AlbumPage />} /> <Route path="/album" element={<AlbumPage />} />
<Route path="/collect/:stampId" element={<CollectRedirect />} /> <Route path="/collect/:stampId" element={<CollectRedirect />} />
<Route path="/article/:id" element={<ArticlePage />} />
{/* Admin panel */} {/* Admin panel */}
<Route path="/admin" element={<AdminLogin />} /> <Route path="/admin" element={<AdminLogin />} />
@@ -34,6 +39,10 @@ export default function App() {
<Route path="/admin/stamps/new" element={<StampForm />} /> <Route path="/admin/stamps/new" element={<StampForm />} />
<Route path="/admin/stamps/:id/edit" element={<StampForm />} /> <Route path="/admin/stamps/:id/edit" element={<StampForm />} />
<Route path="/admin/stamps/:id/qrcode" element={<StampQRCode />} /> <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" element={<RuleList />} />
<Route path="/admin/rules/new" element={<RuleForm />} /> <Route path="/admin/rules/new" element={<RuleForm />} />
<Route path="/admin/rules/:id/edit" 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 = [ const navItems = [
{ path: "/admin/stamps", label: "图章管理" }, { path: "/admin/stamps", label: "图章管理" },
{ path: "/admin/articles", label: "文章管理" },
{ path: "/admin/rules", label: "兑换规则" }, { path: "/admin/rules", label: "兑换规则" },
{ path: "/admin/redemptions", 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> <div>
<label className="block text-sm font-medium text-gray-700 mb-1"></label> <label className="block text-sm font-medium text-gray-700 mb-1"></label>
{imageColor && ( {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 <input
type="file" type="file"
@@ -141,7 +143,9 @@ export default function StampForm() {
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-1"></label> <label className="block text-sm font-medium text-gray-700 mb-1"></label>
{imageGrey && ( {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 <input
type="file" type="file"

View File

@@ -72,9 +72,9 @@ export default function StampList() {
{stamps.map((stamp) => ( {stamps.map((stamp) => (
<tr key={stamp.id} className="border-b border-gray-100 hover:bg-gray-50"> <tr key={stamp.id} className="border-b border-gray-100 hover:bg-gray-50">
<td className="px-4 py-3"> <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 && ( {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> </div>
</td> </td>

View File

@@ -12,6 +12,7 @@ export default function StampQRCode() {
const { id } = useParams(); const { id } = useParams();
const [data, setData] = useState<QRData | null>(null); const [data, setData] = useState<QRData | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [copied, setCopied] = useState(false);
const canvasRef = useRef<HTMLCanvasElement>(null); const canvasRef = useRef<HTMLCanvasElement>(null);
useEffect(() => { useEffect(() => {
@@ -58,6 +59,25 @@ export default function StampQRCode() {
link.click(); 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 (loading) return <p className="text-gray-500">...</p>;
if (!data) 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" /> <path d="M15 18l-6-6 6-6" />
</svg> </svg>
</Link> </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>
<div className="bg-white rounded-lg border border-gray-200 p-6 text-center space-y-4"> <div className="bg-white rounded-lg border border-gray-200 p-6 text-center space-y-4">
@@ -84,13 +104,25 @@ export default function StampQRCode() {
{/* Hidden canvas for composite download */} {/* Hidden canvas for composite download */}
<canvas ref={canvasRef} className="hidden" /> <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 <button
onClick={handleDownload} onClick={handleDownload}
className="px-4 py-2 bg-blue-600 text-white text-sm rounded-md hover:bg-blue-700" className="px-4 py-2 border border-gray-300 text-gray-700 text-sm rounded-md hover:bg-gray-50"
> >
</button> </button>
</div> </div>
<p className="text-xs text-gray-400 pt-1">
NFC
</p>
</div>
</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)]"> <div className="w-72 bg-[var(--bg-cream)] rounded-2xl p-6 text-center shadow-[var(--shadow-lg)]">
{/* Stamp image */} {/* Stamp image */}
<div className="w-40 h-40 mx-auto mb-4"> <div className="w-40 h-40 mx-auto mb-4">
<div className="w-full h-full rounded-xl overflow-hidden animate-stamp-press" <div
style={{ background: "linear-gradient(135deg, #fdf6ee 0%, #f8eed8 100%)" }} 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 <img
src={imageColor} src={imageColor}
alt={name} alt={name}
className="w-full h-full object-contain p-4" className="w-[92%] h-[92%] object-contain"
onError={(e) => { onError={(e) => {
const target = e.target as HTMLImageElement; const target = e.target as HTMLImageElement;
target.style.display = "none"; target.style.display = "none";

View File

@@ -122,15 +122,24 @@
opacity: 0; opacity: 0;
animation: fade-in-up 0.5s cubic-bezier(0.22, 1, 0.36, 1) both; 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(1) { animation-delay: 0.04s; }
.stagger-children > *:nth-child(2) { animation-delay: 0.2s; } .stagger-children > *:nth-child(2) { animation-delay: 0.08s; }
.stagger-children > *:nth-child(3) { animation-delay: 0.3s; } .stagger-children > *:nth-child(3) { animation-delay: 0.12s; }
.stagger-children > *:nth-child(4) { animation-delay: 0.4s; } .stagger-children > *:nth-child(4) { animation-delay: 0.16s; }
.stagger-children > *:nth-child(5) { animation-delay: 0.5s; } .stagger-children > *:nth-child(5) { animation-delay: 0.20s; }
.stagger-children > *:nth-child(6) { animation-delay: 0.6s; } .stagger-children > *:nth-child(6) { animation-delay: 0.24s; }
.stagger-children > *:nth-child(7) { animation-delay: 0.7s; } .stagger-children > *:nth-child(7) { animation-delay: 0.28s; }
.stagger-children > *:nth-child(8) { animation-delay: 0.8s; } .stagger-children > *:nth-child(8) { animation-delay: 0.32s; }
.stagger-children > *:nth-child(9) { animation-delay: 0.9s; } .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 Card Effects */
.stamp-border { .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 = [ const STEPS = [
{ num: "01", title: "前往点位", desc: "跟随路线,探索散落在城市中的文化地标" }, { num: "01", title: "前往点位", desc: "跟随路线,探索散落在城市中的文化地标" },
{ num: "02", title: "扫码集章", desc: "发现点位专属二维码,扫描即刻收入囊中" }, { num: "02", title: "触碰集章", desc: "手机轻触点位 NFC 芯片,图章即刻收入囊中" },
{ num: "03", title: "兑换好礼", desc: "集满图章,兑换城市限定纪念品" }, { num: "03", title: "兑换好礼", desc: "集满图章,兑换城市限定纪念品" },
]; ];

View File

@@ -0,0 +1,13 @@
-- CreateTable
CREATE TABLE "Article" (
"id" TEXT NOT NULL PRIMARY KEY,
"title" TEXT NOT NULL,
"subtitle" TEXT,
"body" TEXT NOT NULL,
"coverImage" TEXT NOT NULL DEFAULT '',
"caption" TEXT,
"sortOrder" INTEGER NOT NULL DEFAULT 0,
"enabled" BOOLEAN NOT NULL DEFAULT true,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL
);

View File

@@ -66,3 +66,16 @@ model Redemption {
@@index([userId]) @@index([userId])
} }
model Article {
id String @id @default(uuid())
title String
subtitle String?
body String
coverImage String @default("")
caption String?
sortOrder Int @default(0)
enabled Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}