diff --git a/.claude/commands/deploy-stamp.md b/.claude/commands/deploy-stamp.md new file mode 100644 index 0000000..c62e8be --- /dev/null +++ b/.claude/commands/deploy-stamp.md @@ -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) diff --git a/.gitignore b/.gitignore index 0e56d1c..69f882f 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,4 @@ uploads/* !uploads/.gitkeep .DS_Store *.tsbuildinfo +.claude/settings.local.json diff --git a/Dockerfile b/Dockerfile index e3446ae..07c561d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh index 0eb3406..1788e2f 100644 --- a/docker/entrypoint.sh +++ b/docker/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 diff --git a/package.json b/package.json index 4511f27..dc052d7 100644 --- a/package.json +++ b/package.json @@ -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" diff --git a/packages/server/package.json b/packages/server/package.json index 0340b71..392fa6b 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -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:*", diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index d38d049..2aa04d2 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -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 diff --git a/packages/server/src/routes/admin.ts b/packages/server/src/routes/admin.ts index a286bc9..8726d37 100644 --- a/packages/server/src/routes/admin.ts +++ b/packages/server/src/routes/admin.ts @@ -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; diff --git a/packages/server/src/routes/articles.ts b/packages/server/src/routes/articles.ts new file mode 100644 index 0000000..c552588 --- /dev/null +++ b/packages/server/src/routes/articles.ts @@ -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; diff --git a/packages/server/src/seed-articles.ts b/packages/server/src/seed-articles.ts new file mode 100644 index 0000000..b655552 --- /dev/null +++ b/packages/server/src/seed-articles.ts @@ -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()); diff --git a/packages/server/uploads/articles/article-01.jpg b/packages/server/uploads/articles/article-01.jpg new file mode 100644 index 0000000..0320a52 Binary files /dev/null and b/packages/server/uploads/articles/article-01.jpg differ diff --git a/packages/server/uploads/articles/article-02.jpg b/packages/server/uploads/articles/article-02.jpg new file mode 100644 index 0000000..1fc3abb Binary files /dev/null and b/packages/server/uploads/articles/article-02.jpg differ diff --git a/packages/server/uploads/articles/article-03.jpg b/packages/server/uploads/articles/article-03.jpg new file mode 100644 index 0000000..ce2eb8a Binary files /dev/null and b/packages/server/uploads/articles/article-03.jpg differ diff --git a/packages/server/uploads/articles/article-04.jpg b/packages/server/uploads/articles/article-04.jpg new file mode 100644 index 0000000..4cec9f0 Binary files /dev/null and b/packages/server/uploads/articles/article-04.jpg differ diff --git a/packages/server/uploads/articles/article-05.jpg b/packages/server/uploads/articles/article-05.jpg new file mode 100644 index 0000000..9f92b88 Binary files /dev/null and b/packages/server/uploads/articles/article-05.jpg differ diff --git a/packages/server/uploads/articles/article-06.jpg b/packages/server/uploads/articles/article-06.jpg new file mode 100644 index 0000000..fe0eb22 Binary files /dev/null and b/packages/server/uploads/articles/article-06.jpg differ diff --git a/packages/shared/src/types.ts b/packages/shared/src/types.ts index b6f0489..608a99c 100644 --- a/packages/shared/src/types.ts +++ b/packages/shared/src/types.ts @@ -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; +}; diff --git a/packages/web/src/App.tsx b/packages/web/src/App.tsx index f6030c2..b20f184 100644 --- a/packages/web/src/App.tsx +++ b/packages/web/src/App.tsx @@ -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() { } /> } /> } /> + } /> {/* Admin panel */} } /> @@ -34,6 +39,10 @@ export default function App() { } /> } /> } /> + } /> + } /> + } /> + } /> } /> } /> } /> diff --git a/packages/web/src/admin/AdminLayout.tsx b/packages/web/src/admin/AdminLayout.tsx index 69a54d3..055fc33 100644 --- a/packages/web/src/admin/AdminLayout.tsx +++ b/packages/web/src/admin/AdminLayout.tsx @@ -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: "兑换记录" }, ]; diff --git a/packages/web/src/admin/ArticleForm.tsx b/packages/web/src/admin/ArticleForm.tsx new file mode 100644 index 0000000..434363b --- /dev/null +++ b/packages/web/src/admin/ArticleForm.tsx @@ -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("/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
("/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 ( +
+

+ {isEdit ? "编辑文章" : "添加文章"} +

+ +
+
+ + 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" + /> +
+ +
+ + 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" + /> +
+ +
+ +