Compare commits

...

5 Commits

Author SHA1 Message Date
613684384b feat: 兑换改为扣除指定枚数并新增倒计时确认弹窗
- 后端按 collectedAt 顺序扣除最早的 threshold 枚图章,保留剩余
- 前端新增二次确认弹窗,含工作人员提示与 5 秒倒计时防误触
- 后台日志"图章数"列改为"扣除枚数"

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-19 18:58:51 +08:00
ae63cb1d85 feat: 新增音乐播放模块
- 新增 Music 数据模型 + 迁移(title/subtitle/audioFile)
- 后端:公共 /api/music 查询接口 + 管理端 CRUD
  (音频上传专用 multer,限制 20MB)
- 移动端 /music/:id 播放页:
  - 金色印章式唱片 + 旋转虚线环 + 三重金色涟漪
  - preload=auto + HTTP Range 流式加载
  - 浏览器禁止 autoplay 时显示「轻点聆听」overlay
  - 自定义进度条与时间显示
- Admin:新增音乐管理三页(列表/表单/二维码)与侧栏入口
- 导入示例音乐:朝天宫之歌
- Dockerfile + entrypoint 增加 music 资产回灌

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-19 18:37:44 +08:00
dbe8ea5460 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>
2026-04-19 18:14:41 +08:00
711f422558 feat: 新增 Docker Compose 部署方案
- Dockerfile 单阶段 Alpine 镜像,使用国内镜像(npmmirror / Prisma 引擎 / apk)加速
- entrypoint 首次复制内置图章素材到 uploads volume,自动执行 prisma migrate deploy
- docker-compose.yml 绑定 127.0.0.1:3001,强制走外部 Nginx 反代
- Express 在 production 下同时托管 packages/web/dist 及 SPA fallback
- Prisma schema 增加 linux-musl 二进制目标,支持 Alpine 运行
- 新增 DEPLOY.md 部署指南,含 .env 模板与 Nginx 反代示例

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-19 17:04:25 +08:00
0319557723 feat: 导入 16 枚图章并重构图册为 4x4 圆形布局
- 按 A4 排列顺序导入 16 枚真实商户图章(彩色 + 灰色)到数据库
- 图章素材存放于 packages/server/uploads/stamps/,命名与 sortOrder 一致
- 图册页布局由 3 列改为 4 列,StampCard 采用圆形白底容器承托透明圆章
- 去除邮票打孔/方形渐变背景,已收集态增加金色内描边与柔光阴影
- 优化进度区与兑换按钮视觉:突出数字、显示差额提示、禁用态文案

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-19 14:10:57 +08:00
115 changed files with 2748 additions and 149 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

18
.dockerignore Normal file
View File

@@ -0,0 +1,18 @@
node_modules
**/node_modules
**/dist
.git
.gitignore
.env
.env.local
.env.*
!.env.production.example
prisma/dev.db
prisma/dev.db-journal
*.log
.DS_Store
# 原始素材(镜像只需要 packages/server/uploads/stamps 里的成品)
assets/
# Docker 构建上下文不需要旧的数据目录
data/
uploads/

12
.env.production.example Normal file
View File

@@ -0,0 +1,12 @@
# 部署前复制为 .env 并修改以下值
# cp .env.production.example .env
# JWT 签名密钥32+ 字符随机串)
JWT_SECRET=please-replace-with-a-strong-random-secret
# 管理后台访问密钥(通过 X-Admin-Key 请求头传入)
ADMIN_API_KEY=please-replace-with-a-strong-random-admin-key
# 对外访问的站点根 URL用于生成 QR 码中的收集链接)
# 必须与你在 Nginx 上配置的域名一致
SITE_URL=https://stamp.example.com

1
.gitignore vendored
View File

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

181
DEPLOY.md Normal file
View File

@@ -0,0 +1,181 @@
# 部署指南
基于 Docker Compose 的单容器部署方案。容器内 Express 同时托管:
- `/api/*` — API
- `/uploads/*` — 图章静态资源
- 其余路径 — React SPA`packages/web/dist`
外部通过宿主机 Nginx 反向代理到容器的 3000 端口(仅绑定 127.0.0.1,不直接暴露)。
---
## 1. 前置条件
- Linux 服务器Docker ≥ 24、Docker Compose v2
- 已解析到服务器的域名(例 `stamp.example.com`
- 已签发的 SSL 证书Let's Encrypt / acme.sh 等任选)
---
## 2. 拉取代码并配置环境变量
```bash
git clone <repo-url> citywalk-stamp
cd citywalk-stamp
cp .env.production.example .env
vim .env # 填入 JWT_SECRET / ADMIN_API_KEY / SITE_URL
```
必填项:
| 变量 | 说明 |
|---|---|
| `JWT_SECRET` | 用户 JWT 签名密钥,建议 `openssl rand -hex 32` 生成 |
| `ADMIN_API_KEY` | 管理后台访问密钥,同上 |
| `SITE_URL` | 对外域名,例 `https://stamp.example.com`(影响 QR 码链接) |
---
## 3. 启动容器
```bash
docker compose up -d --build
```
首次启动会自动完成:
- Prisma migrate deploy建表
- 把内置的 16 枚图章素材复制到 `./uploads/stamps/`
查看日志确认启动成功:
```bash
docker compose logs -f app
# 看到 "Server running on http://localhost:3000" 即 OK
```
---
## 4. 首次导入图章数据
容器默认不自动 seed 数据库(避免覆盖后续运营数据)。首次部署需手动执行一次:
```bash
docker compose exec app pnpm db:seed
```
> 再次执行会**清空并重建**所有图章,同时级联删除用户的收集记录。生产环境请勿随意重跑。
---
## 5. 配置 Nginx 反向代理
在宿主机上配置 Nginx
```nginx
server {
listen 80;
server_name stamp.example.com;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl http2;
server_name stamp.example.com;
ssl_certificate /etc/letsencrypt/live/stamp.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/stamp.example.com/privkey.pem;
# 单入口反代:容器里 Express 自己处理 /api、/uploads、SPA 路由
location / {
proxy_pass http://127.0.0.1:3001;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# 静态资源缓存(可选)
location ^~ /uploads/ {
proxy_pass http://127.0.0.1:3001;
proxy_set_header Host $host;
expires 7d;
add_header Cache-Control "public, max-age=604800";
}
# 上传体积(管理后台上传图章)
client_max_body_size 10m;
}
```
测试并 reload
```bash
sudo nginx -t && sudo systemctl reload nginx
```
访问 `https://stamp.example.com` 即可看到首页。
---
## 6. 数据持久化与备份
容器两个 bind mount
- `./data/` — SQLite 数据库(`prod.db`
- `./uploads/` — 图章图片(含首次填充的 16 枚 + 后续管理后台上传的)
备份:
```bash
# 停机备份最稳妥SQLite 文件锁)
docker compose stop app
tar czf backup-$(date +%F).tgz data/ uploads/ .env
docker compose start app
```
---
## 7. 常用运维命令
```bash
# 查看日志
docker compose logs -f app
# 进入容器
docker compose exec app sh
# 重启
docker compose restart app
# 升级(拉新代码后重建)
git pull
docker compose up -d --build
# 查看当前图章数
docker compose exec app sh -c 'echo "SELECT COUNT(*) FROM Stamp;" | sqlite3 /app/data/prod.db' \
|| docker compose exec app node -e "
import('@prisma/client').then(async ({PrismaClient}) => {
const p = new PrismaClient();
console.log(await p.stamp.count());
await p.\$disconnect();
});
"
# 完全重置(会删除所有用户和收集数据,谨慎)
docker compose down
rm -rf data/ uploads/
docker compose up -d --build
docker compose exec app pnpm db:seed
```
---
## 8. 故障排查
- **容器启动失败 `PrismaClientInitializationError`**:检查 `./data/` 目录权限、`DATABASE_URL`
- **图章图片 404**:进入容器 `ls /app/packages/server/uploads/stamps` 看是否被 seed 进来;若空,手动执行 `cp /app/stamps-seed/*.jpg /app/packages/server/uploads/stamps/`
- **QR 码扫出来是 `localhost`**`.env``SITE_URL` 没改对,修正后 `docker compose restart app`
- **Nginx 502**:确认容器跑着 `docker compose ps`,端口 `ss -tlnp | grep 3000`

50
Dockerfile Normal file
View File

@@ -0,0 +1,50 @@
FROM node:22-alpine
WORKDIR /app
# 国内构建环境加速(仅容器构建期生效,不影响本地/正式 registry
# 1) Alpine apk 源换清华
RUN sed -i 's|https\?://dl-cdn.alpinelinux.org|https://mirrors.tuna.tsinghua.edu.cn|g' /etc/apk/repositories
RUN corepack enable && corepack prepare pnpm@9 --activate \
&& apk add --no-cache openssl
# 2) npm / pnpm registry 换淘宝镜像
# 3) Prisma 下载引擎走淘宝镜像
ENV PRISMA_ENGINES_MIRROR=https://registry.npmmirror.com/-/binary/prisma
RUN npm config set registry https://registry.npmmirror.com/ \
&& pnpm config set registry https://registry.npmmirror.com/
# Copy everything (.dockerignore filters out node_modules, dist, .git, etc.)
COPY . .
# 强制项目级 registry兜底防止项目根 .npmrc 未覆盖)
RUN echo "registry=https://registry.npmmirror.com/" > /app/.npmrc
# Install all deps (tsx is used at runtime via devDependencies)
RUN pnpm install --frozen-lockfile --registry=https://registry.npmmirror.com/
# Generate Prisma client (produces linux-musl binary per schema.prisma)
RUN pnpm exec prisma generate
# Build web SPA; server runs directly from TS via tsx
RUN pnpm --filter @stamp/web build
# 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
RUN mkdir -p /app/music-seed \
&& cp packages/server/uploads/music/* /app/music-seed/ 2>/dev/null || true \
&& rm -rf packages/server/uploads/music
COPY docker/entrypoint.sh /usr/local/bin/entrypoint.sh
RUN chmod +x /usr/local/bin/entrypoint.sh
ENV NODE_ENV=production
EXPOSE 3000
ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]

Binary file not shown.

After

Width:  |  Height:  |  Size: 177 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 177 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 180 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 167 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 171 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 182 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 183 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 207 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 160 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 159 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 192 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 167 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 166 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 198 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 170 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 183 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 133 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 114 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 110 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 111 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 141 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 111 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 124 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 132 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 439 KiB

19
docker-compose.yml Normal file
View File

@@ -0,0 +1,19 @@
services:
app:
build: .
image: citywalk-stamp:latest
container_name: citywalk-stamp
restart: unless-stopped
# Bind to loopback only: the outside world must go through your host Nginx
# 宿主机 3001 -> 容器 30003000 已被宿主其它服务占用,例如 1Panel Gitea
ports:
- "127.0.0.1:3001:3000"
volumes:
- ./data:/app/data
- ./uploads:/app/packages/server/uploads
env_file:
- .env
environment:
NODE_ENV: production
SERVER_PORT: 3000
DATABASE_URL: file:/app/data/prod.db

34
docker/entrypoint.sh Normal file
View File

@@ -0,0 +1,34 @@
#!/bin/sh
set -e
cd /app
mkdir -p /app/data
mkdir -p /app/packages/server/uploads/stamps
mkdir -p /app/packages/server/uploads/articles
mkdir -p /app/packages/server/uploads/music
# Seed stamp assets on first run (idempotent)
if [ -z "$(ls -A /app/packages/server/uploads/stamps 2>/dev/null)" ]; then
echo "→ Seeding stamp assets into uploads volume..."
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
# Seed music assets on first run (idempotent)
if [ -z "$(ls -A /app/packages/server/uploads/music 2>/dev/null)" ]; then
echo "→ Seeding music assets into uploads volume..."
cp /app/music-seed/* /app/packages/server/uploads/music/ 2>/dev/null || true
fi
echo "→ Applying database migrations..."
pnpm exec prisma migrate deploy
echo "→ Starting server on :${SERVER_PORT:-3000}..."
cd /app/packages/server
exec pnpm exec tsx src/index.ts

View File

@@ -8,7 +8,9 @@
"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",
"db:seed-music": "pnpm --filter @stamp/server seed-music"
}, },
"engines": { "engines": {
"node": ">=20" "node": ">=20"

View File

@@ -7,7 +7,9 @@
"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",
"seed-music": "tsx src/seed-music.ts"
}, },
"dependencies": { "dependencies": {
"@stamp/shared": "workspace:*", "@stamp/shared": "workspace:*",

View File

@@ -4,6 +4,8 @@ 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 musicRoutes from "./routes/music.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,11 +28,24 @@ 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/music", musicRoutes);
app.use("/api/redemption", redemptionRoutes); app.use("/api/redemption", redemptionRoutes);
// Admin routes // Admin routes
app.use("/api/admin", adminRoutes); app.use("/api/admin", adminRoutes);
// Serve built web frontend in production (single-container deployment)
if (process.env.NODE_ENV === "production") {
const webDist = path.join(__dirname, "../../web/dist");
app.use(express.static(webDist));
app.use((req, res, next) => {
if (req.method !== "GET" && req.method !== "HEAD") return next();
if (req.path.startsWith("/api") || req.path.startsWith("/uploads")) return next();
res.sendFile(path.join(webDist, "index.html"));
});
}
app.listen(PORT, () => { app.listen(PORT, () => {
console.log(`Server running on http://localhost:${PORT}`); console.log(`Server running on http://localhost:${PORT}`);
}); });

View File

@@ -18,6 +18,7 @@ const storage = multer.diskStorage({
}, },
}); });
const upload = multer({ storage, limits: { fileSize: 5 * 1024 * 1024 } }); const upload = multer({ storage, limits: { fileSize: 5 * 1024 * 1024 } });
const audioUpload = multer({ storage, limits: { fileSize: 20 * 1024 * 1024 } });
const router = Router(); const router = Router();
router.use(requireAdmin); router.use(requireAdmin);
@@ -194,4 +195,176 @@ 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 } });
});
// ===== Music CRUD =====
router.get("/music", async (_req, res) => {
const music = await prisma.music.findMany({ orderBy: { sortOrder: "asc" } });
res.json({ success: true, data: music });
});
const musicSchema = z.object({
title: z.string().min(1, "标题不能为空"),
subtitle: z.string().optional(),
audioFile: z.string().optional(),
sortOrder: z.number().int().optional(),
enabled: z.boolean().optional(),
});
router.post("/music", async (req, res) => {
const parsed = musicSchema.safeParse(req.body);
if (!parsed.success) {
res.status(400).json({ success: false, error: { code: "VALIDATION", message: parsed.error.issues[0].message } });
return;
}
const music = await prisma.music.create({
data: {
title: parsed.data.title,
subtitle: parsed.data.subtitle,
audioFile: parsed.data.audioFile ?? "",
sortOrder: parsed.data.sortOrder ?? 0,
enabled: parsed.data.enabled ?? true,
},
});
res.json({ success: true, data: music });
});
router.put("/music/:id", async (req, res) => {
const parsed = musicSchema.partial().safeParse(req.body);
if (!parsed.success) {
res.status(400).json({ success: false, error: { code: "VALIDATION", message: parsed.error.issues[0].message } });
return;
}
const music = await prisma.music.update({
where: { id: req.params.id },
data: parsed.data,
}).catch(() => null);
if (!music) {
res.status(404).json({ success: false, error: { code: "NOT_FOUND", message: "音乐不存在" } });
return;
}
res.json({ success: true, data: music });
});
router.delete("/music/:id", async (req, res) => {
await prisma.music.delete({ where: { id: req.params.id } }).catch(() => null);
res.json({ success: true, data: null });
});
router.post("/music/:id/upload", audioUpload.single("audio"), async (req, res) => {
if (!req.file) {
res.status(400).json({ success: false, error: { code: "NO_FILE", message: "请选择音频文件" } });
return;
}
const audioPath = `/uploads/${req.file.filename}`;
const music = await prisma.music.update({
where: { id: req.params.id },
data: { audioFile: audioPath },
}).catch(() => null);
if (!music) {
res.status(404).json({ success: false, error: { code: "NOT_FOUND", message: "音乐不存在" } });
return;
}
res.json({ success: true, data: { path: audioPath } });
});
router.get("/music/:id/qrcode", async (req, res) => {
const music = await prisma.music.findUnique({ where: { id: req.params.id } });
if (!music) {
res.status(404).json({ success: false, error: { code: "NOT_FOUND", message: "音乐不存在" } });
return;
}
const siteUrl = process.env.SITE_URL || "http://localhost:5173";
const musicUrl = `${siteUrl}/music/${music.id}`;
const qrDataUrl = await generateQRCodeDataURL(musicUrl, { width: 400 });
res.json({ success: true, data: { qrDataUrl, musicUrl, musicTitle: music.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,35 @@
import { Router } from "express";
import { prisma } from "@stamp/shared";
const router = Router();
router.get("/", async (_req, res) => {
const music = await prisma.music.findMany({
where: { enabled: true },
orderBy: { sortOrder: "asc" },
select: { id: true, title: true, subtitle: true, audioFile: true, sortOrder: true },
});
res.json({ success: true, data: music });
});
router.get("/:id", async (req, res) => {
const music = await prisma.music.findUnique({
where: { id: req.params.id },
select: {
id: true,
title: true,
subtitle: true,
audioFile: true,
sortOrder: true,
enabled: true,
},
});
if (!music || !music.enabled) {
res.status(404).json({ success: false, error: { code: "NOT_FOUND", message: "音乐不存在" } });
return;
}
const { enabled: _, ...data } = music;
res.json({ success: true, data });
});
export default router;

View File

@@ -41,10 +41,19 @@ router.post("/redeem", requireAuth, async (req, res) => {
} }
const redemption = await prisma.$transaction(async (tx) => { const redemption = await prisma.$transaction(async (tx) => {
const record = await tx.redemption.create({ // Deduct the oldest N collections (chronological order by collectedAt)
data: { userId: req.userId!, ruleId: rule.id, stampCount: collectionCount }, const toDelete = await tx.collection.findMany({
where: { userId: req.userId! },
orderBy: { collectedAt: "asc" },
take: rule.threshold,
select: { id: true },
});
await tx.collection.deleteMany({
where: { id: { in: toDelete.map((c) => c.id) } },
});
const record = await tx.redemption.create({
data: { userId: req.userId!, ruleId: rule.id, stampCount: rule.threshold },
}); });
await tx.collection.deleteMany({ where: { userId: req.userId! } });
return record; return record;
}); });

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());

View File

@@ -0,0 +1,45 @@
import { prisma } from "@stamp/shared";
const tracks = [
{
title: "朝天宫之歌",
subtitle: "金陵千年韵",
audioFile: "/uploads/music/chaotiangong.m4a",
},
];
async function seed() {
console.log("Seeding music...");
await prisma.music.deleteMany();
const created = [];
for (let i = 0; i < tracks.length; i++) {
const t = tracks[i];
const music = await prisma.music.create({
data: {
title: t.title,
subtitle: t.subtitle,
audioFile: t.audioFile,
sortOrder: i + 1,
enabled: true,
},
});
created.push(music);
}
console.log(`Created ${created.length} music track(s)`);
console.log("\nMusic IDs for testing:");
created.forEach((m) => {
console.log(` ${m.sortOrder}. ${m.title}: /music/${m.id}`);
});
console.log("\nSeed complete!");
}
seed()
.catch((e) => {
console.error(e);
process.exit(1);
})
.finally(() => prisma.$disconnect());

View File

@@ -1,63 +1,71 @@
import { prisma } from "@stamp/shared"; import { prisma } from "@stamp/shared";
const stampData = [
{ name: "尹氏汤包" },
{ name: "中国移动 5G" },
{ name: "紫金农商银行" },
{ name: "孟令军炒货铺" },
{ name: "春山酒窖" },
{ name: "金陵绣男" },
{ name: "LBZ" },
{ name: "二条商店" },
{ name: "陶玉梅" },
{ name: "芳婆糕团" },
{ name: "书锦城创" },
{ name: "闲鱼循环商店" },
{ name: "闽南茶叶店" },
{ name: "魏鬼虾神" },
{ name: "农家小院" },
{ name: "李记清真馆" },
];
async function seed() { async function seed() {
console.log("Seeding database..."); console.log("Seeding database...");
// Create sample stamps // Clear existing stamps (cascades to collections)
const stamps = await Promise.all([ await prisma.stamp.deleteMany();
prisma.stamp.create({
data: { name: "古桥印记", note: "始建于明代的石拱桥", imageColor: "/uploads/placeholder-color.svg", imageGrey: "/uploads/placeholder-grey.svg", sortOrder: 1 }, const stamps = await Promise.all(
stampData.map((s, idx) => {
const pos = String(idx + 1).padStart(2, "0");
return prisma.stamp.create({
data: {
name: s.name,
imageColor: `/uploads/stamps/stamp-${pos}-color.jpg`,
imageGrey: `/uploads/stamps/stamp-${pos}-grey.jpg`,
sortOrder: idx + 1,
},
});
}), }),
prisma.stamp.create({ );
data: { name: "老街风韵", note: "百年历史的商业老街", imageColor: "/uploads/placeholder-color.svg", imageGrey: "/uploads/placeholder-grey.svg", sortOrder: 2 },
}),
prisma.stamp.create({
data: { name: "园林雅趣", note: "江南古典园林", imageColor: "/uploads/placeholder-color.svg", imageGrey: "/uploads/placeholder-grey.svg", sortOrder: 3 },
}),
prisma.stamp.create({
data: { name: "茶馆时光", note: "百年老茶馆", imageColor: "/uploads/placeholder-color.svg", imageGrey: "/uploads/placeholder-grey.svg", sortOrder: 4 },
}),
prisma.stamp.create({
data: { name: "水乡晨曲", note: "清晨的水乡渔市", imageColor: "/uploads/placeholder-color.svg", imageGrey: "/uploads/placeholder-grey.svg", sortOrder: 5 },
}),
prisma.stamp.create({
data: { name: "戏台余韵", note: "古戏台与昆曲", imageColor: "/uploads/placeholder-color.svg", imageGrey: "/uploads/placeholder-grey.svg", sortOrder: 6 },
}),
prisma.stamp.create({
data: { name: "巷弄深处", note: "青石板铺就的幽深小巷", imageColor: "/uploads/placeholder-color.svg", imageGrey: "/uploads/placeholder-grey.svg", sortOrder: 7 },
}),
prisma.stamp.create({
data: { name: "月下拱桥", note: "夜晚灯火映照的拱桥", imageColor: "/uploads/placeholder-color.svg", imageGrey: "/uploads/placeholder-grey.svg", sortOrder: 8 },
}),
prisma.stamp.create({
data: { name: "匠心工坊", note: "传统手工艺作坊", imageColor: "/uploads/placeholder-color.svg", imageGrey: "/uploads/placeholder-grey.svg", sortOrder: 9 },
}),
]);
console.log(`Created ${stamps.length} stamps`); console.log(`Created ${stamps.length} stamps`);
// Create redemption rules // Create redemption rules if none exist
const existingRules = await prisma.redemptionRule.count();
if (existingRules === 0) {
const rules = await Promise.all([ const rules = await Promise.all([
prisma.redemptionRule.create({ prisma.redemptionRule.create({
data: { name: "纪念书签", description: "精美城市纪念书签一枚", threshold: 3, sortOrder: 1 }, data: { name: "纪念书签", description: "精美城市纪念书签一枚", threshold: 4, sortOrder: 1 },
}), }),
prisma.redemptionRule.create({ prisma.redemptionRule.create({
data: { name: "手绘明信片套装", description: "一套 5 张手绘城市明信片", threshold: 5, sortOrder: 2 }, data: { name: "手绘明信片套装", description: "一套 5 张手绘城市明信片", threshold: 8, sortOrder: 2 },
}), }),
prisma.redemptionRule.create({ prisma.redemptionRule.create({
data: { name: "限定徽章礼盒", description: "城市限定版金属徽章礼盒", threshold: 7, sortOrder: 3 }, data: { name: "限定徽章礼盒", description: "城市限定版金属徽章礼盒", threshold: 12, sortOrder: 3 },
}), }),
prisma.redemptionRule.create({ prisma.redemptionRule.create({
data: { name: "城市记忆礼包", description: "包含纪念 T 恤、帆布袋和城市画册", threshold: 9, sortOrder: 4 }, data: { name: "城市记忆礼包", description: "包含纪念 T 恤、帆布袋和城市画册", threshold: 16, sortOrder: 4 },
}), }),
]); ]);
console.log(`Created ${rules.length} redemption rules`); console.log(`Created ${rules.length} redemption rules`);
} else {
console.log(`Kept existing ${existingRules} redemption rules`);
}
// Print stamp IDs for QR code testing
console.log("\nStamp IDs for testing:"); console.log("\nStamp IDs for testing:");
stamps.forEach((s) => { stamps.forEach((s) => {
console.log(` ${s.name}: /collect/${s.id}`); console.log(` ${s.sortOrder}. ${s.name}: /collect/${s.id}`);
}); });
console.log("\nSeed complete!"); console.log("\nSeed complete!");

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

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 180 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 192 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 159 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 167 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 170 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 124 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 198 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 166 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 183 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 132 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 160 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 111 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 177 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 114 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 177 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 133 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 167 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 110 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 183 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 182 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 207 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 141 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 171 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 111 KiB

View File

@@ -28,3 +28,37 @@ 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;
};
export type MusicSummary = {
id: string;
title: string;
subtitle: string | null;
audioFile: string;
sortOrder: number;
};
export type MusicDetail = {
id: string;
title: string;
subtitle: string | null;
audioFile: string;
sortOrder: number;
};

View File

@@ -2,12 +2,20 @@ 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 MusicPage from "./pages/MusicPage";
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 MusicList from "./admin/MusicList";
import MusicForm from "./admin/MusicForm";
import MusicQRCode from "./admin/MusicQRCode";
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 +33,8 @@ 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 />} />
<Route path="/music/:id" element={<MusicPage />} />
{/* Admin panel */} {/* Admin panel */}
<Route path="/admin" element={<AdminLogin />} /> <Route path="/admin" element={<AdminLogin />} />
@@ -34,6 +44,14 @@ 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/music" element={<MusicList />} />
<Route path="/admin/music/new" element={<MusicForm />} />
<Route path="/admin/music/:id/edit" element={<MusicForm />} />
<Route path="/admin/music/:id/qrcode" element={<MusicQRCode />} />
<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,8 @@ 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/music", 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

@@ -0,0 +1,198 @@
import { useState, useEffect } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { adminFetch } from "./adminApi";
type Music = {
id: string;
title: string;
subtitle: string | null;
audioFile: string;
sortOrder: number;
enabled: boolean;
};
export default function MusicForm() {
const { id } = useParams();
const navigate = useNavigate();
const isEdit = !!id;
const [title, setTitle] = useState("");
const [subtitle, setSubtitle] = useState("");
const [audioFile, setAudioFile] = useState("");
const [sortOrder, setSortOrder] = useState(0);
const [enabled, setEnabled] = useState(true);
const [uploading, setUploading] = useState(false);
const [saving, setSaving] = useState(false);
const [error, setError] = useState("");
useEffect(() => {
if (!id) return;
adminFetch<Music[]>("/music").then((list) => {
const item = list.find((m) => m.id === id);
if (item) {
setTitle(item.title);
setSubtitle(item.subtitle || "");
setAudioFile(item.audioFile);
setSortOrder(item.sortOrder);
setEnabled(item.enabled);
}
});
}, [id]);
const handleUpload = async (file: File) => {
if (!id) {
setError("请先保存后再上传音频");
return;
}
setError("");
setUploading(true);
try {
const formData = new FormData();
formData.append("audio", file);
const data = await adminFetch<{ path: string }>(`/music/${id}/upload`, {
method: "POST",
body: formData,
});
setAudioFile(data.path);
} catch (e) {
setError(e instanceof Error ? e.message : "上传失败");
} finally {
setUploading(false);
}
};
const handleSave = async () => {
setError("");
if (!title.trim()) {
setError("请输入标题");
return;
}
setSaving(true);
try {
const payload = {
title: title.trim(),
subtitle: subtitle.trim() || undefined,
sortOrder,
enabled,
};
if (isEdit) {
await adminFetch(`/music/${id}`, {
method: "PUT",
body: JSON.stringify(payload),
});
} else {
const music = await adminFetch<Music>("/music", {
method: "POST",
body: JSON.stringify(payload),
});
navigate(`/admin/music/${music.id}/edit`, { replace: true });
return;
}
navigate("/admin/music");
} catch (e) {
setError(e instanceof Error ? e.message : "保存失败");
} finally {
setSaving(false);
}
};
return (
<div className="max-w-xl">
<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 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">
<span className="text-xs text-gray-400 font-normal ml-2">
MP3 / M4A / WAV 20 MB
</span>
</label>
{audioFile && (
<audio src={audioFile} controls preload="metadata" className="w-full mb-2" />
)}
<input
type="file"
accept="audio/*,.mp3,.m4a,.wav,.ogg"
onChange={(e) => e.target.files?.[0] && handleUpload(e.target.files[0])}
disabled={uploading}
className="text-xs text-gray-500"
/>
{uploading && <p className="text-xs text-gray-500 mt-1"></p>}
</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/music")}
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 Music = {
id: string;
title: string;
subtitle: string | null;
audioFile: string;
sortOrder: number;
enabled: boolean;
};
export default function MusicList() {
const [music, setMusic] = useState<Music[]>([]);
const [loading, setLoading] = useState(true);
const fetchMusic = async () => {
setLoading(true);
try {
const data = await adminFetch<Music[]>("/music");
setMusic(data);
} finally {
setLoading(false);
}
};
useEffect(() => { fetchMusic(); }, []);
const handleDelete = async (id: string, title: string) => {
if (!confirm(`确定删除音乐「${title}」?`)) return;
await adminFetch(`/music/${id}`, { method: "DELETE" });
fetchMusic();
};
const handleToggle = async (id: string, enabled: boolean) => {
await adminFetch(`/music/${id}`, {
method: "PUT",
body: JSON.stringify({ enabled: !enabled }),
});
fetchMusic();
};
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/music/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>
{music.map((item) => (
<tr key={item.id} className="border-b border-gray-100 hover:bg-gray-50">
<td className="px-4 py-3 text-gray-800 font-medium">{item.title}</td>
<td className="px-4 py-3 text-gray-500 max-w-[220px] truncate">{item.subtitle || "—"}</td>
<td className="px-4 py-3 text-gray-500 text-xs">
{item.audioFile ? (
<audio src={item.audioFile} controls preload="none" className="h-8 max-w-[220px]" />
) : (
<span className="text-gray-300"></span>
)}
</td>
<td className="px-4 py-3 text-center text-gray-500">{item.sortOrder}</td>
<td className="px-4 py-3 text-center">
<button
onClick={() => handleToggle(item.id, item.enabled)}
className={`px-2 py-0.5 rounded text-xs ${
item.enabled ? "bg-green-100 text-green-700" : "bg-gray-100 text-gray-500"
}`}
>
{item.enabled ? "启用" : "禁用"}
</button>
</td>
<td className="px-4 py-3 text-right space-x-2 whitespace-nowrap">
<Link to={`/admin/music/${item.id}/edit`} className="text-blue-600 hover:underline">
</Link>
<Link to={`/admin/music/${item.id}/qrcode`} className="text-blue-600 hover:underline">
</Link>
<button onClick={() => handleDelete(item.id, item.title)} className="text-red-500 hover:underline">
</button>
</td>
</tr>
))}
{music.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;
musicUrl: string;
musicTitle: string;
};
export default function MusicQRCode() {
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>(`/music/${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.musicUrl, 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.musicTitle}-二维码.png`;
link.href = canvasRef.current.toDataURL("image/png");
link.click();
};
const handleCopy = async () => {
if (!data) return;
try {
await navigator.clipboard.writeText(data.musicUrl);
setCopied(true);
setTimeout(() => setCopied(false), 1800);
} catch {
const ta = document.createElement("textarea");
ta.value = data.musicUrl;
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/music" 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.musicTitle} </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.musicUrl}</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

@@ -63,7 +63,7 @@ export default function RedemptionLog() {
<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-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> <th className="text-right px-4 py-3 font-medium text-gray-600"></th>
</tr> </tr>
</thead> </thead>

Some files were not shown because too many files have changed in this diff Show More