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:
146
.claude/commands/deploy-stamp.md
Normal file
146
.claude/commands/deploy-stamp.md
Normal 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 永远返回 false,while 循环会立即退出,接着的 `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
1
.gitignore
vendored
@@ -7,3 +7,4 @@ uploads/*
|
||||
!uploads/.gitkeep
|
||||
.DS_Store
|
||||
*.tsbuildinfo
|
||||
.claude/settings.local.json
|
||||
|
||||
@@ -30,10 +30,13 @@ RUN pnpm exec prisma generate
|
||||
# Build web SPA; server runs directly from TS via tsx
|
||||
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 \
|
||||
&& cp packages/server/uploads/stamps/*.jpg /app/stamps-seed/ \
|
||||
&& 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
|
||||
RUN chmod +x /usr/local/bin/entrypoint.sh
|
||||
|
||||
@@ -5,6 +5,7 @@ cd /app
|
||||
|
||||
mkdir -p /app/data
|
||||
mkdir -p /app/packages/server/uploads/stamps
|
||||
mkdir -p /app/packages/server/uploads/articles
|
||||
|
||||
# Seed stamp assets on first run (idempotent)
|
||||
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
|
||||
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..."
|
||||
pnpm exec prisma migrate deploy
|
||||
|
||||
|
||||
@@ -8,7 +8,8 @@
|
||||
"db:generate": "prisma generate",
|
||||
"db:migrate": "prisma migrate dev",
|
||||
"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": {
|
||||
"node": ">=20"
|
||||
|
||||
@@ -7,7 +7,8 @@
|
||||
"dev": "tsx watch src/index.ts",
|
||||
"build": "tsc",
|
||||
"start": "node dist/index.js",
|
||||
"seed": "tsx src/seed.ts"
|
||||
"seed": "tsx src/seed.ts",
|
||||
"seed-articles": "tsx src/seed-articles.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@stamp/shared": "workspace:*",
|
||||
|
||||
@@ -4,6 +4,7 @@ import path from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
import authRoutes from "./routes/auth.js";
|
||||
import stampRoutes from "./routes/stamps.js";
|
||||
import articleRoutes from "./routes/articles.js";
|
||||
import redemptionRoutes from "./routes/redemption.js";
|
||||
import adminRoutes from "./routes/admin.js";
|
||||
|
||||
@@ -26,6 +27,7 @@ app.get("/api/health", (_req, res) => {
|
||||
// User-facing routes
|
||||
app.use("/api/auth", authRoutes);
|
||||
app.use("/api/stamps", stampRoutes);
|
||||
app.use("/api/articles", articleRoutes);
|
||||
app.use("/api/redemption", redemptionRoutes);
|
||||
|
||||
// Admin routes
|
||||
|
||||
@@ -194,4 +194,92 @@ router.get("/stats", async (_req, res) => {
|
||||
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;
|
||||
|
||||
37
packages/server/src/routes/articles.ts
Normal file
37
packages/server/src/routes/articles.ts
Normal 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;
|
||||
127
packages/server/src/seed-articles.ts
Normal file
127
packages/server/src/seed-articles.ts
Normal 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());
|
||||
BIN
packages/server/uploads/articles/article-01.jpg
Normal file
BIN
packages/server/uploads/articles/article-01.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 193 KiB |
BIN
packages/server/uploads/articles/article-02.jpg
Normal file
BIN
packages/server/uploads/articles/article-02.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 112 KiB |
BIN
packages/server/uploads/articles/article-03.jpg
Normal file
BIN
packages/server/uploads/articles/article-03.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 381 KiB |
BIN
packages/server/uploads/articles/article-04.jpg
Normal file
BIN
packages/server/uploads/articles/article-04.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 222 KiB |
BIN
packages/server/uploads/articles/article-05.jpg
Normal file
BIN
packages/server/uploads/articles/article-05.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 321 KiB |
BIN
packages/server/uploads/articles/article-06.jpg
Normal file
BIN
packages/server/uploads/articles/article-06.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 772 KiB |
@@ -28,3 +28,21 @@ export type RedemptionRecord = {
|
||||
stampCount: number;
|
||||
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;
|
||||
};
|
||||
|
||||
@@ -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: "集满图章,兑换城市限定纪念品" },
|
||||
];
|
||||
|
||||
|
||||
@@ -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
|
||||
);
|
||||
@@ -66,3 +66,16 @@ model Redemption {
|
||||
|
||||
@@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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user