Compare commits
14 Commits
db74381f13
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| f87d16021e | |||
| 3b3878ea5c | |||
| f84815611d | |||
| f2c71ff91a | |||
| bcb167b67d | |||
| 2c179cd19a | |||
| 394b643304 | |||
| 52169ac71d | |||
| b4a0e23c7e | |||
| 613684384b | |||
| ae63cb1d85 | |||
| dbe8ea5460 | |||
| 711f422558 | |||
| 0319557723 |
146
.claude/commands/deploy-stamp.md
Normal file
@@ -0,0 +1,146 @@
|
||||
---
|
||||
description: 推送本地变更到 njcq 服务器并重新部署 Docker Compose
|
||||
argument-hint: "[可选 --no-build 仅 rsync 不重启 | --seed 部署后执行 db:seed(危险:会清空用户数据)]"
|
||||
---
|
||||
|
||||
# 一键部署 citywalk-stamp 到 njcq 服务器
|
||||
|
||||
## 部署目标
|
||||
- 主机:SSH 别名 `njcq`
|
||||
- 目录:`/opt/1panel/apps/citywalk-stamp`
|
||||
- 容器:`citywalk-stamp`(端口 `127.0.0.1:3001` ← 宿主 3000 被 1Panel Gitea 占用)
|
||||
- 持久化:`./data/`(SQLite)、`./uploads/`(图章图片)、`.env`(密钥)均在服务器保留,本次部署不会覆盖
|
||||
|
||||
## 参数
|
||||
|
||||
用户可能通过 `$ARGUMENTS` 传入:
|
||||
- `--no-build`:跳过 compose up,仅同步代码
|
||||
- `--seed`:部署后执行 `pnpm db:seed`(⚠️ 会清空 Stamp 表并级联删除用户 Collection 数据,仅首次部署或彻底重置时使用)
|
||||
- 无参数:默认推送代码 + 重新 build 并重启容器
|
||||
|
||||
## 步骤
|
||||
|
||||
### 1. 预检
|
||||
|
||||
确认当前在项目根:
|
||||
```bash
|
||||
test -f Dockerfile && test -f docker-compose.yml && test -f pnpm-workspace.yaml
|
||||
```
|
||||
如缺文件立即中止。
|
||||
|
||||
### 2. rsync 推送代码(sudo 身份写 /opt)
|
||||
|
||||
```bash
|
||||
rsync -avz --delete --rsync-path="sudo rsync" \
|
||||
--exclude='.git/' \
|
||||
--exclude='node_modules/' \
|
||||
--exclude='**/node_modules/' \
|
||||
--exclude='**/dist/' \
|
||||
--exclude='.env' \
|
||||
--exclude='.env.backup' \
|
||||
--exclude='.env.local' \
|
||||
--exclude='.claude/' \
|
||||
--exclude='prisma/dev.db*' \
|
||||
--exclude='/data/' \
|
||||
--exclude='/uploads/' \
|
||||
--exclude='.DS_Store' \
|
||||
--exclude='build.log' \
|
||||
./ njcq:/opt/1panel/apps/citywalk-stamp/
|
||||
```
|
||||
|
||||
**关键陷阱(必须保持以下写法)**:
|
||||
- `--rsync-path="sudo rsync"` 解决 ubuntu 用户对 `/opt` 无写权限(否则 rsync 会静默 status 23,目录仍为空)
|
||||
- `/data/` 和 `/uploads/` **带前导斜杠**——只排除项目根下这两个目录(它们是 docker bind mount 产生的运行时数据),**不要**写成 `data/` 或 `uploads/`,否则会把 `packages/server/uploads/stamps/` 也一并排除
|
||||
- `.env` 被排除,远端 JWT_SECRET / ADMIN_API_KEY / SITE_URL 不受影响
|
||||
- `--delete` 会删除远端多余文件,但 exclude 的目录保留
|
||||
|
||||
### 3. 如果是 `--no-build`,到这里就结束
|
||||
|
||||
只同步代码而不重启服务,只在改了纯文档或静态资源、容器内存里的代码用不上时才用。
|
||||
|
||||
### 4. 远端后台启动 compose(避免 SSH 断开导致构建中断)
|
||||
|
||||
```bash
|
||||
ssh njcq 'sudo bash -c "cd /opt/1panel/apps/citywalk-stamp && rm -f build.log && nohup docker compose up -d --build > build.log 2>&1 &" && sleep 2 && echo started'
|
||||
```
|
||||
|
||||
`nohup ... &` 让构建脱离 SSH 进程。ssh 立即返回,构建在远端持续进行。
|
||||
|
||||
### 5. 轮询等待构建完成(可能 3-5 分钟)
|
||||
|
||||
**不要**靠 `pgrep` 判断构建是否结束——服务器用的是 docker v2(`docker compose`),没有名为 `docker-compose compose up` 的进程,pgrep 永远返回 false,while 循环会立即退出,接着的 `docker compose ps` 会在 daemon 忙时阻塞,表现为"卡住"。直接读 `build.log` 的末尾特征字符串才准:
|
||||
|
||||
```bash
|
||||
ssh njcq '
|
||||
cd /opt/1panel/apps/citywalk-stamp
|
||||
for i in $(seq 1 40); do # 最多等 10 分钟
|
||||
tail_out=$(sudo tail -n 30 build.log 2>/dev/null)
|
||||
# 成功标记:docker compose v2 finish line
|
||||
if echo "$tail_out" | grep -qE "Container .* (Started|Running|Healthy)"; then
|
||||
echo "=== build finished ==="
|
||||
break
|
||||
fi
|
||||
# 失败标记
|
||||
if echo "$tail_out" | grep -qE "(failed to solve|Error response from daemon|ERROR: )"; then
|
||||
echo "=== build FAILED ==="
|
||||
echo "$tail_out" | tail -15
|
||||
exit 1
|
||||
fi
|
||||
sleep 15
|
||||
echo "... building $(date +%H:%M:%S)"
|
||||
echo "$tail_out" | tail -2 | sed "s/^/ /"
|
||||
done
|
||||
echo "---"
|
||||
sudo docker compose ps
|
||||
echo "=== container logs (last 20 lines) ==="
|
||||
sudo docker compose logs --tail=20 app
|
||||
'
|
||||
```
|
||||
|
||||
用 **Bash `run_in_background: true`** 启动这条命令,然后用 TaskOutput 等待完成——避免 SSH 命令卡住主 Claude 流程。
|
||||
|
||||
### 6. 健康检查
|
||||
|
||||
```bash
|
||||
ssh njcq '
|
||||
echo "--- /api/health ---"
|
||||
curl -sS http://127.0.0.1:3001/api/health && echo
|
||||
echo "--- /api/stamps count ---"
|
||||
curl -sS http://127.0.0.1:3001/api/stamps | python3 -c "import sys,json; d=json.load(sys.stdin); print(len(d[\"data\"]))"
|
||||
echo "--- /uploads sample ---"
|
||||
curl -sI http://127.0.0.1:3001/uploads/stamps/stamp-01-color.jpg | head -1
|
||||
echo "--- /album (SPA) ---"
|
||||
curl -s -o /dev/null -w "http %{http_code}\n" http://127.0.0.1:3001/album
|
||||
'
|
||||
```
|
||||
|
||||
期望输出:
|
||||
- health: `{"success":true,...}`
|
||||
- stamps count: `16`(如果是首次部署或数据库被清空,可能是 `0`,此时需要 `--seed`)
|
||||
- stamps 首图: `HTTP/1.1 200 OK`
|
||||
- SPA: `http 200`
|
||||
|
||||
### 7. 如传入 `--seed`,执行首次 seed
|
||||
|
||||
**仅在首次部署或主动重置时使用**。会触发 `seed.ts` 里的 `prisma.stamp.deleteMany()` 级联删除所有 Collection:
|
||||
|
||||
```bash
|
||||
ssh njcq 'cd /opt/1panel/apps/citywalk-stamp && sudo docker compose exec -T app pnpm db:seed' | tail -10
|
||||
```
|
||||
|
||||
## 完成后给用户的反馈
|
||||
|
||||
一段简短摘要,包含:
|
||||
- 镜像是否重建、容器是否在运行
|
||||
- `/api/stamps` 返回的图章数量
|
||||
- 访问入口:`https://njcitywalk.njcqit.com`(外部 Nginx 反代到 127.0.0.1:3001)
|
||||
- 如果有异常(stamps=0、health 失败、容器重启循环),提示如何查看日志:
|
||||
`ssh njcq 'cd /opt/1panel/apps/citywalk-stamp && sudo docker compose logs -f app'`
|
||||
|
||||
## 不做的事(防止破坏生产数据)
|
||||
|
||||
- ❌ 不删除远端 `data/` 或 `uploads/`(已排除)
|
||||
- ❌ 不覆盖 `.env`(已排除)
|
||||
- ❌ 不自动 `pnpm db:seed`(必须显式 `--seed`)
|
||||
- ❌ 不修改 Nginx 配置或证书
|
||||
- ❌ 不在远端执行 `git pull`(没 git 仓,只走 rsync)
|
||||
18
.dockerignore
Normal 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
@@ -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
|
||||
2
.gitignore
vendored
@@ -5,5 +5,7 @@ dist/
|
||||
.env
|
||||
uploads/*
|
||||
!uploads/.gitkeep
|
||||
packages/server/uploads/videos/
|
||||
.DS_Store
|
||||
*.tsbuildinfo
|
||||
.claude/settings.local.json
|
||||
|
||||
@@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
||||
|
||||
## Project Overview
|
||||
|
||||
CityWalk 图章收集系统 — 移动端 H5 + 管理后台。游客在城市点位扫描二维码收集图章,集满可兑换奖品,兑换后图章清空,支持重复收集。
|
||||
CityWalk 图章收集系统 — 移动端 H5 + 管理后台。游客在城市点位扫描二维码收集图章,每枚图章绑定一个特定奖品(带库存),已收集的图章可兑换对应奖品。兑换后图章保持彩色点亮并标记为"已兑换",同一图章不可二次收集或二次兑换。
|
||||
|
||||
## Commands
|
||||
|
||||
@@ -17,7 +17,7 @@ pnpm dev:web # Vite dev server on :5173
|
||||
pnpm db:generate # Generate Prisma client after schema changes
|
||||
pnpm db:migrate # Create and apply migrations (prisma migrate dev)
|
||||
pnpm db:push # Push schema directly (dev only, no migration file)
|
||||
pnpm db:seed # Seed sample data (9 stamps + 4 redemption rules)
|
||||
pnpm db:seed # Seed sample data (16 stamps, each with a Prize of stock 100)
|
||||
|
||||
# Build
|
||||
pnpm build # Build all packages
|
||||
@@ -48,7 +48,6 @@ All endpoints return: `{ success: boolean, data?: T, error?: { code: string, mes
|
||||
/collect/:stampId → Redirects to /?stamp={stampId} (QR code entry point)
|
||||
/admin → AdminLogin
|
||||
/admin/stamps → Stamp CRUD + QR code generation
|
||||
/admin/rules → Redemption rule CRUD
|
||||
/admin/redemptions → Redemption history + stats
|
||||
```
|
||||
|
||||
@@ -58,7 +57,7 @@ QR codes encode `/collect/{stampId}` → redirects to `/?stamp={stampId}` → La
|
||||
|
||||
### Redemption Transaction
|
||||
|
||||
Atomic: `prisma.$transaction` creates Redemption record + deletes all user Collections. The `@@unique([userId, stampId])` constraint resets after deletion, allowing re-collection.
|
||||
Each `Stamp` has an optional `Prize` (1:1, `Prize.stampId @unique`). Redemption is atomic: inside `prisma.$transaction` we check the user has a `Collection` for the stamp, no existing `Redemption` for (user, stamp), the prize is `enabled`, then `prisma.prize.updateMany({ where: { id, stock: { gt: 0 } }, data: { stock: { decrement: 1 } } })` acts as a stock lock (throws `OUT_OF_STOCK` if zero rows updated) before creating the `Redemption` record with a `prizeName` snapshot. `Collection` rows are **not** deleted — the `@@unique([userId, stampId])` constraints on both `Collection` and `Redemption` naturally block re-collection and re-redemption of the same stamp.
|
||||
|
||||
## Critical: Tailwind CSS v4 Layer Architecture
|
||||
|
||||
|
||||
181
DEPLOY.md
Normal 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
@@ -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"]
|
||||
@@ -32,5 +32,5 @@ packages/
|
||||
server/ Express API(认证、图章、兑换、管理)
|
||||
web/ React SPA(移动端 H5 + PC 管理后台)
|
||||
prisma/
|
||||
schema.prisma 数据模型(User, Stamp, Collection, RedemptionRule, Redemption)
|
||||
schema.prisma 数据模型(User, Stamp, Prize, Collection, Redemption)
|
||||
```
|
||||
|
||||
BIN
assets/stamps/16个彩色圆章/1.jpg
Normal file
|
After Width: | Height: | Size: 177 KiB |
BIN
assets/stamps/16个彩色圆章/10.jpg
Normal file
|
After Width: | Height: | Size: 177 KiB |
BIN
assets/stamps/16个彩色圆章/11.jpg
Normal file
|
After Width: | Height: | Size: 180 KiB |
BIN
assets/stamps/16个彩色圆章/12.jpg
Normal file
|
After Width: | Height: | Size: 167 KiB |
BIN
assets/stamps/16个彩色圆章/13.jpg
Normal file
|
After Width: | Height: | Size: 171 KiB |
BIN
assets/stamps/16个彩色圆章/14.jpg
Normal file
|
After Width: | Height: | Size: 182 KiB |
BIN
assets/stamps/16个彩色圆章/15.jpg
Normal file
|
After Width: | Height: | Size: 183 KiB |
BIN
assets/stamps/16个彩色圆章/16.jpg
Normal file
|
After Width: | Height: | Size: 207 KiB |
BIN
assets/stamps/16个彩色圆章/2.jpg
Normal file
|
After Width: | Height: | Size: 160 KiB |
BIN
assets/stamps/16个彩色圆章/3.jpg
Normal file
|
After Width: | Height: | Size: 159 KiB |
BIN
assets/stamps/16个彩色圆章/4.jpg
Normal file
|
After Width: | Height: | Size: 192 KiB |
BIN
assets/stamps/16个彩色圆章/5.jpg
Normal file
|
After Width: | Height: | Size: 167 KiB |
BIN
assets/stamps/16个彩色圆章/6.jpg
Normal file
|
After Width: | Height: | Size: 166 KiB |
BIN
assets/stamps/16个彩色圆章/7.jpg
Normal file
|
After Width: | Height: | Size: 198 KiB |
BIN
assets/stamps/16个彩色圆章/8.jpg
Normal file
|
After Width: | Height: | Size: 170 KiB |
BIN
assets/stamps/16个彩色圆章/9.jpg
Normal file
|
After Width: | Height: | Size: 183 KiB |
BIN
assets/stamps/16个黑白圆章/1.jpg
Normal file
|
After Width: | Height: | Size: 133 KiB |
BIN
assets/stamps/16个黑白圆章/10.jpg
Normal file
|
After Width: | Height: | Size: 114 KiB |
BIN
assets/stamps/16个黑白圆章/11.jpg
Normal file
|
After Width: | Height: | Size: 104 KiB |
BIN
assets/stamps/16个黑白圆章/12.jpg
Normal file
|
After Width: | Height: | Size: 110 KiB |
BIN
assets/stamps/16个黑白圆章/13.jpg
Normal file
|
After Width: | Height: | Size: 111 KiB |
BIN
assets/stamps/16个黑白圆章/14.jpg
Normal file
|
After Width: | Height: | Size: 107 KiB |
BIN
assets/stamps/16个黑白圆章/15.jpg
Normal file
|
After Width: | Height: | Size: 118 KiB |
BIN
assets/stamps/16个黑白圆章/16.jpg
Normal file
|
After Width: | Height: | Size: 141 KiB |
BIN
assets/stamps/16个黑白圆章/2.jpg
Normal file
|
After Width: | Height: | Size: 111 KiB |
BIN
assets/stamps/16个黑白圆章/3.jpg
Normal file
|
After Width: | Height: | Size: 108 KiB |
BIN
assets/stamps/16个黑白圆章/4.jpg
Normal file
|
After Width: | Height: | Size: 108 KiB |
BIN
assets/stamps/16个黑白圆章/5.jpg
Normal file
|
After Width: | Height: | Size: 104 KiB |
BIN
assets/stamps/16个黑白圆章/6.jpg
Normal file
|
After Width: | Height: | Size: 98 KiB |
BIN
assets/stamps/16个黑白圆章/7.jpg
Normal file
|
After Width: | Height: | Size: 126 KiB |
BIN
assets/stamps/16个黑白圆章/8.jpg
Normal file
|
After Width: | Height: | Size: 124 KiB |
BIN
assets/stamps/16个黑白圆章/9.jpg
Normal file
|
After Width: | Height: | Size: 132 KiB |
BIN
assets/stamps/章排列16个.jpg
Normal file
|
After Width: | Height: | Size: 439 KiB |
19
docker-compose.yml
Normal 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 -> 容器 3000(3000 已被宿主其它服务占用,例如 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
@@ -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
|
||||
@@ -8,7 +8,10 @@
|
||||
"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",
|
||||
"db:seed-music": "pnpm --filter @stamp/server seed-music",
|
||||
"db:update-brand-rules": "pnpm --filter @stamp/server update-brand-rules"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
|
||||
@@ -7,7 +7,10 @@
|
||||
"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",
|
||||
"seed-music": "tsx src/seed-music.ts",
|
||||
"update-brand-rules": "tsx src/scripts/update-brand-rules.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@stamp/shared": "workspace:*",
|
||||
|
||||
@@ -4,6 +4,8 @@ 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 musicRoutes from "./routes/music.js";
|
||||
import redemptionRoutes from "./routes/redemption.js";
|
||||
import adminRoutes from "./routes/admin.js";
|
||||
|
||||
@@ -26,11 +28,24 @@ 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/music", musicRoutes);
|
||||
app.use("/api/redemption", redemptionRoutes);
|
||||
|
||||
// Admin routes
|
||||
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, () => {
|
||||
console.log(`Server running on http://localhost:${PORT}`);
|
||||
});
|
||||
|
||||
@@ -18,6 +18,7 @@ const storage = multer.diskStorage({
|
||||
},
|
||||
});
|
||||
const upload = multer({ storage, limits: { fileSize: 5 * 1024 * 1024 } });
|
||||
const audioUpload = multer({ storage, limits: { fileSize: 20 * 1024 * 1024 } });
|
||||
|
||||
const router = Router();
|
||||
router.use(requireAdmin);
|
||||
@@ -25,7 +26,10 @@ router.use(requireAdmin);
|
||||
// ===== Stamps CRUD =====
|
||||
|
||||
router.get("/stamps", async (_req, res) => {
|
||||
const stamps = await prisma.stamp.findMany({ orderBy: { sortOrder: "asc" } });
|
||||
const stamps = await prisma.stamp.findMany({
|
||||
orderBy: { sortOrder: "asc" },
|
||||
include: { prize: true },
|
||||
});
|
||||
res.json({ success: true, data: stamps });
|
||||
});
|
||||
|
||||
@@ -120,69 +124,58 @@ router.get("/stamps/:id/qrcode", async (req, res) => {
|
||||
res.json({ success: true, data: { qrDataUrl, collectUrl, stampName: stamp.name } });
|
||||
});
|
||||
|
||||
// ===== Redemption Rules CRUD =====
|
||||
// ===== Prize (per-stamp) =====
|
||||
|
||||
router.get("/rules", async (_req, res) => {
|
||||
const rules = await prisma.redemptionRule.findMany({ orderBy: { sortOrder: "asc" } });
|
||||
res.json({ success: true, data: rules });
|
||||
});
|
||||
|
||||
const ruleSchema = z.object({
|
||||
const prizeSchema = z.object({
|
||||
name: z.string().min(1, "奖品名称不能为空"),
|
||||
description: z.string().optional(),
|
||||
threshold: z.number().int().min(1, "兑换门槛至少为 1"),
|
||||
stock: z.number().int().min(0, "库存不能为负数"),
|
||||
enabled: z.boolean().optional(),
|
||||
sortOrder: z.number().int().optional(),
|
||||
});
|
||||
|
||||
router.post("/rules", async (req, res) => {
|
||||
const parsed = ruleSchema.safeParse(req.body);
|
||||
router.put("/stamps/:id/prize", async (req, res) => {
|
||||
const parsed = prizeSchema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
res.status(400).json({ success: false, error: { code: "VALIDATION", message: parsed.error.issues[0].message } });
|
||||
return;
|
||||
}
|
||||
const rule = await prisma.redemptionRule.create({
|
||||
data: {
|
||||
name: parsed.data.name,
|
||||
description: parsed.data.description,
|
||||
threshold: parsed.data.threshold,
|
||||
enabled: parsed.data.enabled ?? true,
|
||||
sortOrder: parsed.data.sortOrder ?? 0,
|
||||
},
|
||||
const stamp = await prisma.stamp.findUnique({ where: { id: req.params.id } });
|
||||
if (!stamp) {
|
||||
res.status(404).json({ success: false, error: { code: "NOT_FOUND", message: "图章不存在" } });
|
||||
return;
|
||||
}
|
||||
const data = {
|
||||
name: parsed.data.name,
|
||||
description: parsed.data.description ?? null,
|
||||
stock: parsed.data.stock,
|
||||
enabled: parsed.data.enabled ?? true,
|
||||
};
|
||||
const prize = await prisma.prize.upsert({
|
||||
where: { stampId: stamp.id },
|
||||
create: { stampId: stamp.id, ...data },
|
||||
update: data,
|
||||
});
|
||||
res.json({ success: true, data: rule });
|
||||
});
|
||||
|
||||
router.put("/rules/:id", async (req, res) => {
|
||||
const parsed = ruleSchema.partial().safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
res.status(400).json({ success: false, error: { code: "VALIDATION", message: parsed.error.issues[0].message } });
|
||||
return;
|
||||
}
|
||||
const rule = await prisma.redemptionRule.update({
|
||||
where: { id: req.params.id },
|
||||
data: parsed.data,
|
||||
}).catch(() => null);
|
||||
if (!rule) {
|
||||
res.status(404).json({ success: false, error: { code: "NOT_FOUND", message: "规则不存在" } });
|
||||
return;
|
||||
}
|
||||
res.json({ success: true, data: rule });
|
||||
});
|
||||
|
||||
router.delete("/rules/:id", async (req, res) => {
|
||||
await prisma.redemptionRule.delete({ where: { id: req.params.id } }).catch(() => null);
|
||||
res.json({ success: true, data: null });
|
||||
res.json({ success: true, data: prize });
|
||||
});
|
||||
|
||||
// ===== Redemption Records & Stats =====
|
||||
|
||||
router.get("/redemptions", async (_req, res) => {
|
||||
const records = await prisma.redemption.findMany({
|
||||
include: { user: { select: { username: true, phone: true } }, rule: { select: { name: true } } },
|
||||
include: {
|
||||
user: { select: { username: true, phone: true } },
|
||||
stamp: { select: { name: true } },
|
||||
},
|
||||
orderBy: { redeemedAt: "desc" },
|
||||
});
|
||||
res.json({ success: true, data: records });
|
||||
const data = records.map((r) => ({
|
||||
id: r.id,
|
||||
redeemedAt: r.redeemedAt,
|
||||
user: r.user,
|
||||
stampName: r.stamp.name,
|
||||
prizeName: r.prizeName,
|
||||
}));
|
||||
res.json({ success: true, data });
|
||||
});
|
||||
|
||||
router.get("/stats", async (_req, res) => {
|
||||
@@ -194,4 +187,241 @@ router.get("/stats", async (_req, res) => {
|
||||
res.json({ success: true, data: { userCount, collectionCount, redemptionCount } });
|
||||
});
|
||||
|
||||
// Asia/Shanghai 边界(UTC+8,无夏令时)
|
||||
function shanghaiBoundaries() {
|
||||
const OFFSET_MS = 8 * 60 * 60 * 1000;
|
||||
const nowShanghai = new Date(Date.now() + OFFSET_MS);
|
||||
const y = nowShanghai.getUTCFullYear();
|
||||
const m = nowShanghai.getUTCMonth();
|
||||
const d = nowShanghai.getUTCDate();
|
||||
const weekdaySun0 = nowShanghai.getUTCDay();
|
||||
const mondayOffset = (weekdaySun0 + 6) % 7;
|
||||
const startOfToday = new Date(Date.UTC(y, m, d) - OFFSET_MS);
|
||||
const startOfWeek = new Date(startOfToday.getTime() - mondayOffset * 86_400_000);
|
||||
const startOfMonth = new Date(Date.UTC(y, m, 1) - OFFSET_MS);
|
||||
return { startOfToday, startOfWeek, startOfMonth };
|
||||
}
|
||||
|
||||
router.get("/dashboard", async (_req, res) => {
|
||||
const { startOfToday, startOfWeek, startOfMonth } = shanghaiBoundaries();
|
||||
|
||||
const [
|
||||
uTotal, uDay, uWeek, uMonth,
|
||||
cTotal, cDay, cWeek, cMonth,
|
||||
rTotal, rDay, rWeek, rMonth,
|
||||
] = await Promise.all([
|
||||
prisma.user.count(),
|
||||
prisma.user.count({ where: { createdAt: { gte: startOfToday } } }),
|
||||
prisma.user.count({ where: { createdAt: { gte: startOfWeek } } }),
|
||||
prisma.user.count({ where: { createdAt: { gte: startOfMonth } } }),
|
||||
prisma.collection.count(),
|
||||
prisma.collection.count({ where: { collectedAt: { gte: startOfToday } } }),
|
||||
prisma.collection.count({ where: { collectedAt: { gte: startOfWeek } } }),
|
||||
prisma.collection.count({ where: { collectedAt: { gte: startOfMonth } } }),
|
||||
prisma.redemption.count(),
|
||||
prisma.redemption.count({ where: { redeemedAt: { gte: startOfToday } } }),
|
||||
prisma.redemption.count({ where: { redeemedAt: { gte: startOfWeek } } }),
|
||||
prisma.redemption.count({ where: { redeemedAt: { gte: startOfMonth } } }),
|
||||
]);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
users: { total: uTotal, today: uDay, thisWeek: uWeek, thisMonth: uMonth },
|
||||
collections: { total: cTotal, today: cDay, thisWeek: cWeek, thisMonth: cMonth },
|
||||
redemptions: { total: rTotal, today: rDay, thisWeek: rWeek, thisMonth: rMonth },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
router.get("/users", async (_req, res) => {
|
||||
const users = await prisma.user.findMany({
|
||||
orderBy: { createdAt: "desc" },
|
||||
include: {
|
||||
_count: { select: { collections: true, redemptions: true } },
|
||||
},
|
||||
});
|
||||
const data = users.map((u) => ({
|
||||
id: u.id,
|
||||
username: u.username,
|
||||
phone: u.phone,
|
||||
createdAt: u.createdAt,
|
||||
collectionCount: u._count.collections,
|
||||
redemptionCount: u._count.redemptions,
|
||||
}));
|
||||
res.json({ success: true, data });
|
||||
});
|
||||
|
||||
// ===== 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;
|
||||
|
||||
37
packages/server/src/routes/articles.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { Router } from "express";
|
||||
import { prisma } from "@stamp/shared";
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.get("/", async (_req, res) => {
|
||||
const articles = await prisma.article.findMany({
|
||||
where: { enabled: true },
|
||||
orderBy: { sortOrder: "asc" },
|
||||
select: { id: true, title: true, subtitle: true, coverImage: true, sortOrder: true },
|
||||
});
|
||||
res.json({ success: true, data: articles });
|
||||
});
|
||||
|
||||
router.get("/:id", async (req, res) => {
|
||||
const article = await prisma.article.findUnique({
|
||||
where: { id: req.params.id },
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
subtitle: true,
|
||||
body: true,
|
||||
coverImage: true,
|
||||
caption: true,
|
||||
sortOrder: true,
|
||||
enabled: true,
|
||||
},
|
||||
});
|
||||
if (!article || !article.enabled) {
|
||||
res.status(404).json({ success: false, error: { code: "NOT_FOUND", message: "文章不存在" } });
|
||||
return;
|
||||
}
|
||||
const { enabled: _, ...data } = article;
|
||||
res.json({ success: true, data });
|
||||
});
|
||||
|
||||
export default router;
|
||||
35
packages/server/src/routes/music.ts
Normal 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;
|
||||
@@ -5,17 +5,8 @@ import { requireAuth } from "../middleware/auth.js";
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.get("/rules", async (_req, res) => {
|
||||
const rules = await prisma.redemptionRule.findMany({
|
||||
where: { enabled: true },
|
||||
orderBy: { sortOrder: "asc" },
|
||||
select: { id: true, name: true, description: true, threshold: true },
|
||||
});
|
||||
res.json({ success: true, data: rules });
|
||||
});
|
||||
|
||||
const redeemSchema = z.object({
|
||||
ruleId: z.string().uuid("规则 ID 格式不正确"),
|
||||
stampId: z.string().uuid("图章 ID 格式不正确"),
|
||||
});
|
||||
|
||||
router.post("/redeem", requireAuth, async (req, res) => {
|
||||
@@ -25,55 +16,94 @@ router.post("/redeem", requireAuth, async (req, res) => {
|
||||
return;
|
||||
}
|
||||
|
||||
const rule = await prisma.redemptionRule.findUnique({ where: { id: parsed.data.ruleId, enabled: true } });
|
||||
if (!rule) {
|
||||
res.status(404).json({ success: false, error: { code: "NOT_FOUND", message: "兑换规则不存在" } });
|
||||
return;
|
||||
}
|
||||
const { stampId } = parsed.data;
|
||||
const userId = req.userId!;
|
||||
|
||||
const collectionCount = await prisma.collection.count({ where: { userId: req.userId! } });
|
||||
if (collectionCount < rule.threshold) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: { code: "INSUFFICIENT", message: `需要收集 ${rule.threshold} 枚图章,当前只有 ${collectionCount} 枚` },
|
||||
try {
|
||||
const redemption = await prisma.$transaction(async (tx) => {
|
||||
const collection = await tx.collection.findUnique({
|
||||
where: { userId_stampId: { userId, stampId } },
|
||||
});
|
||||
if (!collection) {
|
||||
throw new RedeemError("NOT_COLLECTED", "你还没有收集这枚图章", 400);
|
||||
}
|
||||
|
||||
const already = await tx.redemption.findUnique({
|
||||
where: { userId_stampId: { userId, stampId } },
|
||||
});
|
||||
if (already) {
|
||||
throw new RedeemError("ALREADY_REDEEMED", "你已经兑换过这枚图章对应的奖品", 409);
|
||||
}
|
||||
|
||||
const prize = await tx.prize.findUnique({ where: { stampId } });
|
||||
if (!prize || !prize.enabled) {
|
||||
throw new RedeemError("PRIZE_UNAVAILABLE", "该图章暂无可兑换的奖品", 400);
|
||||
}
|
||||
|
||||
const decremented = await tx.prize.updateMany({
|
||||
where: { id: prize.id, stock: { gt: 0 } },
|
||||
data: { stock: { decrement: 1 } },
|
||||
});
|
||||
if (decremented.count === 0) {
|
||||
throw new RedeemError("OUT_OF_STOCK", "奖品已兑完", 400);
|
||||
}
|
||||
|
||||
return tx.redemption.create({
|
||||
data: {
|
||||
userId,
|
||||
stampId,
|
||||
prizeId: prize.id,
|
||||
prizeName: prize.name,
|
||||
},
|
||||
include: { stamp: { select: { name: true } } },
|
||||
});
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const redemption = await prisma.$transaction(async (tx) => {
|
||||
const record = await tx.redemption.create({
|
||||
data: { userId: req.userId!, ruleId: rule.id, stampCount: collectionCount },
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
id: redemption.id,
|
||||
stampId: redemption.stampId,
|
||||
stampName: redemption.stamp.name,
|
||||
prizeName: redemption.prizeName,
|
||||
redeemedAt: redemption.redeemedAt.toISOString(),
|
||||
},
|
||||
});
|
||||
await tx.collection.deleteMany({ where: { userId: req.userId! } });
|
||||
return record;
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
id: redemption.id,
|
||||
ruleName: rule.name,
|
||||
stampCount: redemption.stampCount,
|
||||
redeemedAt: redemption.redeemedAt.toISOString(),
|
||||
},
|
||||
});
|
||||
} catch (e) {
|
||||
if (e instanceof RedeemError) {
|
||||
res.status(e.status).json({ success: false, error: { code: e.code, message: e.message } });
|
||||
return;
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
});
|
||||
|
||||
router.get("/history", requireAuth, async (req, res) => {
|
||||
const records = await prisma.redemption.findMany({
|
||||
where: { userId: req.userId! },
|
||||
include: { rule: { select: { name: true } } },
|
||||
include: { stamp: { select: { name: true } } },
|
||||
orderBy: { redeemedAt: "desc" },
|
||||
});
|
||||
|
||||
const data = records.map((r) => ({
|
||||
id: r.id,
|
||||
ruleName: r.rule.name,
|
||||
stampCount: r.stampCount,
|
||||
stampId: r.stampId,
|
||||
stampName: r.stamp.name,
|
||||
prizeName: r.prizeName,
|
||||
redeemedAt: r.redeemedAt.toISOString(),
|
||||
}));
|
||||
|
||||
res.json({ success: true, data });
|
||||
});
|
||||
|
||||
class RedeemError extends Error {
|
||||
constructor(
|
||||
public code: string,
|
||||
message: string,
|
||||
public status: number,
|
||||
) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -8,20 +8,25 @@ router.get("/", optionalAuth, async (req, res) => {
|
||||
const stamps = await prisma.stamp.findMany({
|
||||
where: { enabled: true },
|
||||
orderBy: { sortOrder: "asc" },
|
||||
include: { prize: true },
|
||||
});
|
||||
|
||||
let collections: Set<string> = new Set();
|
||||
let collectionMap: Map<string, Date> = new Map();
|
||||
const collectionMap = new Map<string, Date>();
|
||||
const redeemedSet = new Set<string>();
|
||||
|
||||
if (req.userId) {
|
||||
const userCollections = await prisma.collection.findMany({
|
||||
where: { userId: req.userId },
|
||||
select: { stampId: true, collectedAt: true },
|
||||
});
|
||||
userCollections.forEach((c) => {
|
||||
collections.add(c.stampId);
|
||||
collectionMap.set(c.stampId, c.collectedAt);
|
||||
});
|
||||
const [userCollections, userRedemptions] = await Promise.all([
|
||||
prisma.collection.findMany({
|
||||
where: { userId: req.userId },
|
||||
select: { stampId: true, collectedAt: true },
|
||||
}),
|
||||
prisma.redemption.findMany({
|
||||
where: { userId: req.userId },
|
||||
select: { stampId: true },
|
||||
}),
|
||||
]);
|
||||
userCollections.forEach((c) => collectionMap.set(c.stampId, c.collectedAt));
|
||||
userRedemptions.forEach((r) => redeemedSet.add(r.stampId));
|
||||
}
|
||||
|
||||
const data = stamps.map((s) => ({
|
||||
@@ -31,15 +36,28 @@ router.get("/", optionalAuth, async (req, res) => {
|
||||
imageColor: s.imageColor,
|
||||
imageGrey: s.imageGrey,
|
||||
sortOrder: s.sortOrder,
|
||||
collected: collections.has(s.id),
|
||||
collected: collectionMap.has(s.id),
|
||||
collectedAt: collectionMap.get(s.id)?.toISOString() ?? null,
|
||||
redeemed: redeemedSet.has(s.id),
|
||||
prize: s.prize
|
||||
? {
|
||||
id: s.prize.id,
|
||||
name: s.prize.name,
|
||||
description: s.prize.description,
|
||||
stock: s.prize.stock,
|
||||
enabled: s.prize.enabled,
|
||||
}
|
||||
: null,
|
||||
}));
|
||||
|
||||
res.json({ success: true, data });
|
||||
});
|
||||
|
||||
router.get("/:id", async (req, res) => {
|
||||
const stamp = await prisma.stamp.findUnique({ where: { id: req.params.id } });
|
||||
const stamp = await prisma.stamp.findUnique({
|
||||
where: { id: req.params.id },
|
||||
include: { prize: true },
|
||||
});
|
||||
if (!stamp) {
|
||||
res.status(404).json({ success: false, error: { code: "NOT_FOUND", message: "图章不存在" } });
|
||||
return;
|
||||
@@ -53,6 +71,15 @@ router.get("/:id", async (req, res) => {
|
||||
imageColor: stamp.imageColor,
|
||||
imageGrey: stamp.imageGrey,
|
||||
sortOrder: stamp.sortOrder,
|
||||
prize: stamp.prize
|
||||
? {
|
||||
id: stamp.prize.id,
|
||||
name: stamp.prize.name,
|
||||
description: stamp.prize.description,
|
||||
stock: stamp.prize.stock,
|
||||
enabled: stamp.prize.enabled,
|
||||
}
|
||||
: null,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
73
packages/server/src/scripts/rename-prize-names.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { prisma } from "@stamp/shared";
|
||||
|
||||
const OLD_SUFFIX = "· 纪念章";
|
||||
const NEW_SUFFIX = "· 专属奖品";
|
||||
const OLD_DESC_RE = /^在「(.+)」集到的专属纪念奖品$/;
|
||||
const NEW_DESC = (brand: string) => `在「${brand}」可兑换的专属奖品`;
|
||||
|
||||
async function main() {
|
||||
const apply = process.argv.includes("--apply");
|
||||
|
||||
const prizes = await prisma.prize.findMany();
|
||||
const prizeNameHits = prizes.filter((p) => p.name.endsWith(OLD_SUFFIX));
|
||||
const prizeDescHits = prizes.filter((p) => p.description && OLD_DESC_RE.test(p.description));
|
||||
|
||||
const redemptions = await prisma.redemption.findMany();
|
||||
const redemptionHits = redemptions.filter((r) => r.prizeName.endsWith(OLD_SUFFIX));
|
||||
|
||||
console.log(`=== Prize.name (${prizeNameHits.length}) ===`);
|
||||
prizeNameHits.slice(0, 20).forEach((p) => {
|
||||
const next = p.name.slice(0, -OLD_SUFFIX.length) + NEW_SUFFIX;
|
||||
console.log(` "${p.name}" → "${next}"`);
|
||||
});
|
||||
if (prizeNameHits.length > 20) console.log(` ...共 ${prizeNameHits.length} 条`);
|
||||
|
||||
console.log(`\n=== Prize.description (${prizeDescHits.length}) ===`);
|
||||
prizeDescHits.slice(0, 20).forEach((p) => {
|
||||
const m = p.description!.match(OLD_DESC_RE)!;
|
||||
console.log(` "${p.description}" → "${NEW_DESC(m[1])}"`);
|
||||
});
|
||||
if (prizeDescHits.length > 20) console.log(` ...共 ${prizeDescHits.length} 条`);
|
||||
|
||||
console.log(`\n=== Redemption.prizeName (${redemptionHits.length}) ===`);
|
||||
redemptionHits.slice(0, 20).forEach((r) => {
|
||||
const next = r.prizeName.slice(0, -OLD_SUFFIX.length) + NEW_SUFFIX;
|
||||
console.log(` "${r.prizeName}" → "${next}"`);
|
||||
});
|
||||
if (redemptionHits.length > 20) console.log(` ...共 ${redemptionHits.length} 条`);
|
||||
|
||||
const total = prizeNameHits.length + prizeDescHits.length + redemptionHits.length;
|
||||
if (total === 0) {
|
||||
console.log("\n✓ 没有需要替换的记录。");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!apply) {
|
||||
console.log(`\nDRY RUN — 共 ${total} 条待改。传入 --apply 执行写入。`);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`\n正在写入...`);
|
||||
await prisma.$transaction(async (tx) => {
|
||||
for (const p of prizeNameHits) {
|
||||
const next = p.name.slice(0, -OLD_SUFFIX.length) + NEW_SUFFIX;
|
||||
await tx.prize.update({ where: { id: p.id }, data: { name: next } });
|
||||
}
|
||||
for (const p of prizeDescHits) {
|
||||
const m = p.description!.match(OLD_DESC_RE)!;
|
||||
await tx.prize.update({ where: { id: p.id }, data: { description: NEW_DESC(m[1]) } });
|
||||
}
|
||||
for (const r of redemptionHits) {
|
||||
const next = r.prizeName.slice(0, -OLD_SUFFIX.length) + NEW_SUFFIX;
|
||||
await tx.redemption.update({ where: { id: r.id }, data: { prizeName: next } });
|
||||
}
|
||||
});
|
||||
console.log(`\n完成。已更新 ${total} 条。`);
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((e) => {
|
||||
console.error(e);
|
||||
process.exit(1);
|
||||
})
|
||||
.finally(() => prisma.$disconnect());
|
||||
125
packages/server/src/scripts/update-brand-rules.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
import { prisma } from "@stamp/shared";
|
||||
|
||||
type Rule = { matchKey: string; canonicalName: string; description: string };
|
||||
|
||||
const RULES: Rule[] = [
|
||||
{ matchKey: "孟令军", canonicalName: "孟令军炒货",
|
||||
description: "进店消费 19.9 元,可赠送人气爆款谷物锅巴 1 份。" },
|
||||
{ matchKey: "春山", canonicalName: "春山酒窖",
|
||||
description: "全场产品 85 折优惠。" },
|
||||
{ matchKey: "金陵绣男", canonicalName: "金陵绣男",
|
||||
description: "全场产品 8 折优惠。" },
|
||||
{ matchKey: "陶玉梅", canonicalName: "南京陶玉梅服饰(梦幻城店)",
|
||||
description: "可获得「非遗宋锦书签制作」体验券 1 张。" },
|
||||
{ matchKey: "LBZ", canonicalName: "LBZ 量不准咖啡",
|
||||
description: "「三元巷」定制咖啡 8 折优惠。" },
|
||||
{ matchKey: "芳婆", canonicalName: "芳婆糕团",
|
||||
description: "单笔消费满 10 元,即赠糕点 1 块,每日限量 66 份,送完即止。" },
|
||||
{ matchKey: "紫金", canonicalName: "紫金农商银行秦淮支行",
|
||||
description: "到店拍照打卡即可获得「南京市民俗(非遗)博物馆 甘家大院」参观券 1 张。" },
|
||||
{ matchKey: "尹氏", canonicalName: "尹氏汤包",
|
||||
description: "可获得「亲子家庭汤包制作活动」体验券 1 张。" },
|
||||
{ matchKey: "闲鱼", canonicalName: "闲鱼循环商店",
|
||||
description: "到店参与寄卖服务,即可加盖闲鱼文创纪念章。" },
|
||||
{ matchKey: "闽南", canonicalName: "闽南茶叶店",
|
||||
description: "1. 全店茶叶产品 85 折优惠;2. 可获得「茶分享和体验活动」体验券 1 张。" },
|
||||
{ matchKey: "移动", canonicalName: "南京移动朝天宫双塘分局",
|
||||
description: "1. 免费手机贴膜 1 次(仅限直面屏手机);2. 免费领取 80G 流量体验卡 1 张;3. 购买 AI 手机返 100 元话费。" },
|
||||
{ matchKey: "二条", canonicalName: "二条商店",
|
||||
description: "全店当日单次实付满 380 元送二条原创小鲍挂件 1 个。" },
|
||||
{ matchKey: "书", canonicalName: "锦创书城",
|
||||
description: "1. 图书与咖啡产品享 7 折,文创产品享 8 折;2. 到店打卡赠送定制书签 1 枚;3. 可获得书城线下主题活动体验券 1 张。" },
|
||||
{ matchKey: "魏", canonicalName: "魏虾神",
|
||||
description: "到店就餐即赠奶皮子酸奶酪 1 份。" },
|
||||
{ matchKey: "李记", canonicalName: "李记清真馆",
|
||||
description: "可获得「亲子包锅贴体验活动」体验券 1 张。" },
|
||||
{ matchKey: "农家", canonicalName: "农家小院",
|
||||
description: "(小红书打卡、朋友圈转发)可享菜品 8 折优惠。" },
|
||||
];
|
||||
|
||||
async function main() {
|
||||
const apply = process.argv.includes("--apply");
|
||||
const stamps = await prisma.stamp.findMany({ include: { prize: true } });
|
||||
console.log(`Found ${stamps.length} stamps in DB.\n`);
|
||||
console.log("Current stamp names:");
|
||||
stamps.forEach((s) => console.log(` - ${s.name}`));
|
||||
console.log();
|
||||
|
||||
type Entry = { stampId: string; old: string; next: string; desc: string };
|
||||
const plan: Entry[] = [];
|
||||
const issues: string[] = [];
|
||||
const usedStampIds = new Set<string>();
|
||||
|
||||
for (const rule of RULES) {
|
||||
const hits = stamps.filter((s) => s.name.includes(rule.matchKey) && !usedStampIds.has(s.id));
|
||||
if (hits.length === 0) {
|
||||
issues.push(`❌ "${rule.matchKey}" → 0 matches (target: ${rule.canonicalName})`);
|
||||
continue;
|
||||
}
|
||||
if (hits.length > 1) {
|
||||
issues.push(
|
||||
`⚠️ "${rule.matchKey}" → ${hits.length} matches (${hits.map((h) => h.name).join(", ")}) — 使用更精确的 matchKey`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
const stamp = hits[0];
|
||||
usedStampIds.add(stamp.id);
|
||||
plan.push({ stampId: stamp.id, old: stamp.name, next: rule.canonicalName, desc: rule.description });
|
||||
}
|
||||
|
||||
console.log("=== Match plan ===");
|
||||
plan.forEach((p) => {
|
||||
const rename = p.old !== p.next ? ` [RENAME]` : ``;
|
||||
console.log(` "${p.old}"${rename}`);
|
||||
console.log(` ⟶ "${p.next}"`);
|
||||
console.log(` ${p.desc}\n`);
|
||||
});
|
||||
|
||||
if (issues.length) {
|
||||
console.log("=== Issues ===");
|
||||
issues.forEach((i) => console.log(` ${i}`));
|
||||
console.log("\n请修正 matchKey 后重试。");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const leftover = stamps.filter((s) => !usedStampIds.has(s.id));
|
||||
if (leftover.length) {
|
||||
console.log(`=== 未被映射覆盖的图章(保持不变) ===`);
|
||||
leftover.forEach((s) => console.log(` - ${s.name}`));
|
||||
console.log();
|
||||
}
|
||||
|
||||
if (!apply) {
|
||||
console.log(`DRY RUN — 不写库。传入 --apply 执行写入。`);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`正在写入 ${plan.length} 条...\n`);
|
||||
for (const p of plan) {
|
||||
await prisma.$transaction(async (tx) => {
|
||||
if (p.old !== p.next) {
|
||||
await tx.stamp.update({ where: { id: p.stampId }, data: { name: p.next } });
|
||||
}
|
||||
await tx.prize.upsert({
|
||||
where: { stampId: p.stampId },
|
||||
create: {
|
||||
stampId: p.stampId,
|
||||
name: `${p.next} · 品牌权益`,
|
||||
description: p.desc,
|
||||
stock: 100,
|
||||
enabled: true,
|
||||
},
|
||||
update: { description: p.desc },
|
||||
});
|
||||
});
|
||||
console.log(`✓ ${p.next}`);
|
||||
}
|
||||
console.log(`\n完成。已更新 ${plan.length} 枚图章。`);
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((e) => {
|
||||
console.error(e);
|
||||
process.exit(1);
|
||||
})
|
||||
.finally(() => prisma.$disconnect());
|
||||
127
packages/server/src/seed-articles.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
import { prisma } from "@stamp/shared";
|
||||
|
||||
const articles = [
|
||||
{
|
||||
title: "朝天宫",
|
||||
subtitle: "千年冶山,文脉绵延",
|
||||
caption: "1910 年的朝天宫大成殿旧影",
|
||||
body: `冶山之巅,文脉悠悠,朝天宫的岁月,始于春秋的冶铸星火。吴王夫差在此筑冶城,熔铁铸剑,炉火映照着金陵最初的城邑印记,冶山之名,自此镌刻在古都的肌理之中。
|
||||
|
||||
历经六朝更迭,这片土地几经变迁。东晋立寺,南朝设学,唐宋元数易其名,从道观到学宫,始终是金陵文化氤氲之地。明洪武十七年,朱元璋下诏重建,赐名"朝天宫",黄瓦朱墙间,成为皇家祈福、百官习仪的圣地,尽显大明都城的雍容气象。
|
||||
|
||||
清时风云变幻,太平天国战火焚毁旧日宫观,曾国藩主持重建,将其改为文庙与江宁府学,琉璃红墙里,藏起书香文脉,定格下如今的建筑格局。民国岁月,它曾承载司法印记,也见证过乱世里的文化坚守。
|
||||
|
||||
如今,朝天宫化作南京市博物馆,古建筑与文物相映成辉。从冶城剑火到明清宫宇,从礼仪圣地到文博殿堂,千年时光在此沉淀。一砖一瓦藏旧事,一草一木诉沧桑,它静静伫立,将金陵千年过往,妥帖珍藏,续写着古都不绝的文脉华章。`,
|
||||
},
|
||||
{
|
||||
title: "七家湾",
|
||||
subtitle: "曲巷藏史,旧说流芳",
|
||||
caption: "清代江宁省城图里的七家湾",
|
||||
body: `城南巷弄深处,七家湾静卧运渎之畔,名字里藏着几段缠绕数百年的金陵旧梦。元代起,这里已是人烟渐聚的市井,而真正让它声名远播的,是明初那几个交织着市井烟火与皇家风云的传说。
|
||||
|
||||
流传最广的是"七姓聚居说"。相传明初,陶、马、丁、姚、哈、莫、白七户人家在此结邻而居,街巷曲回如环,"湾"字便描摹了这蜿蜒肌理,七家湾之名由此诞生,成为城南一处安稳的栖居地。
|
||||
|
||||
更具传奇色彩的是"幸存七户说"。清末陈作霖《运渎桥道小志》载,朱元璋上元夜微行至此,见灯笼上画着"不缠足妇女怀抱西瓜",疑为讥讽大脚马皇后,龙颜大怒,下令屠尽巷中张灯人家,唯余七户未张灯者幸免于难,街巷遂得名七家湾。这则带着血腥味的传说,为巷陌添了几分悲壮色彩。
|
||||
|
||||
还有"七家转弯说"。老南京人常说"七家湾扛毛竹——转不过湾来",此地街巷如迷宫,每过七户人家便要转入另一条街巷,曲折迂回间,"七家湾"的名字也随着行人的脚步流传开来。
|
||||
|
||||
另有"从龙七将说",称朱元璋定都南京后,将七位有功的回族将领安置于此,建回回街,七姓人家在此繁衍生息,成为城南一处独特的聚居地。
|
||||
|
||||
岁月流转,明清时这里已是城南重要的市井街区,民国年间更添几分繁华。如今的七家湾,依旧保留着老城南的街巷肌理,那些古老传说在青石板路上静静流淌,与现代生活交融共生。一弯巷陌藏旧事,几段传奇映古今,七家湾以它独有的方式,诉说着金陵城的千年沧桑。`,
|
||||
},
|
||||
{
|
||||
title: "运渎",
|
||||
subtitle: "碧水通衢,灯影千年",
|
||||
caption: "明代《南都繁会图卷》局部,运渎两岸的各种店招",
|
||||
body: `秦淮之畔,碧水蜿蜒,运渎的岁月,始于东吴赤乌三年(240)的凿河声中。孙权定都建业,为解宫城漕运之需,命左台侍御史郗俭督率民夫,开凿了这条南连秦淮河、北接潮沟的人工河道,成为向宫城仓城输送粮草物资的水上生命线。作为建康城内第一条人工运河,它串联起长干里商埠与台城宫殿,也为六朝海上贸易铺就了关键的城内通道。
|
||||
|
||||
六朝时期,运渎迎来鼎盛。东吴派朱应、康泰出使南海诸国,开辟长江流域的"海上丝绸之路",来自扶南、林邑等地的香料、珍宝,经石头城入秦淮,循运渎直达宫城,与江南漕粮一同在水面上往来穿梭。这条碧水通衢,见证了建康作为"海上丝路"东方起点的繁华,成为中外文明交融的重要纽带。
|
||||
|
||||
隋唐以降,运渎功能渐变,至明清时,河畔笪桥一带兴起笪桥灯市,成为与夫子庙齐名的秦淮灯会发源地。甘熙《白下琐言》载:"笪桥灯市,由来已久,正月初鱼龙杂沓,有银花火树之观",剪纸灯彩与彩帛花灯交相辉映,康熙南巡亦曾微服观灯,灯火映照下的运渎,成了金陵最热闹的元宵胜景。
|
||||
|
||||
岁月流转,运渎河道虽渐隐于市井,但其文脉绵延不绝。如今,熙南里街区复兴了笪桥灯市,重现"星河万盏,夜市千灯"的盛景。从东吴漕运要道到六朝贸易枢纽,从明清灯影胜地到今日文化地标,运渎以它独有的方式,将千年漕运史、海上贸易史与民俗文化史,静静流淌在南京的街巷之间。
|
||||
|
||||
一渠碧水通古今,两岸灯影映繁华,运渎的每一朵浪花,都在诉说着金陵城的水运传奇与文明交融的故事,在时光长河中,愈发清澈绵长。`,
|
||||
},
|
||||
{
|
||||
title: "打钉巷",
|
||||
subtitle: "铁铺遗音,锅贴传香",
|
||||
caption: "1948 年南京市街道详图里的打钉巷",
|
||||
body: `城南窄巷,烟火绵延,打钉巷的岁月,始于明代"金陵十八坊"的铁砧声中。朱元璋定都南京,"百工各有区肆",铁匠们在此聚集,叮叮当当的敲打声终日不绝,打钉巷之名,自此嵌入老城南的肌理。两百余米的街巷,西接七家湾,东连评事街,成为城南手工业与市井生活的交汇点。
|
||||
|
||||
明清更迭,铁铺渐稀,回民聚居于此,清真文化悄然扎根。清乾隆年间,草桥清真寺在巷中兴起,成为回民精神家园。民国时期,这里人口稠密,商肆林立,李记清真馆的前身"李洪兴牛坊"在此营生,草桥扁食店的技艺也于此时传承。铁匠的锤声虽淡,市井的烟火却愈发浓郁。
|
||||
|
||||
岁月流转,打钉巷成了南京清真美食的地标。李记清真馆扎根打钉巷 1 号,百年技艺代代相传,招牌牛肉锅贴外酥里嫩,汁水丰盈,获米其林必比登推荐,成为南京人清晨排队的念想。几步之遥的草桥清真牛肉锅贴扁食店,坚守传统风味,扁食与锅贴并香,是老南京心中的地道滋味。两家老店,一巷双璧,共同续写着打钉巷的美食传奇。
|
||||
|
||||
如今,打钉巷依旧保留着老城南的街巷肌理,青砖黛瓦间,铁铺遗韵与美食芬芳交融。李记门前长队不绝,草桥店内烟火氤氲,一口锅贴,一碗扁食,藏着百年匠心与市井温情。铁砧声远,滋味长存,这条古巷以舌尖上的传承,诉说着金陵城的人间烟火,在岁月长河中,愈发醇厚绵长。`,
|
||||
},
|
||||
{
|
||||
title: "绒庄街",
|
||||
subtitle: "丝绒映彩,锦韵流长",
|
||||
caption: "非遗绒花",
|
||||
body: `城南古巷,经纬交织,绒庄街的岁月,始于明代"帽儿行"的叫卖声中。朱元璋定都南京,丝织业兴起,这里聚集了众多制帽商户,因"帽儿行"谐音"冇儿行"犯了忌讳,街上大户绒庄老板刘万丰牵头改名,"绒庄街"之名自此嵌入老城南的肌理。百余米的街巷,东接南捕厅,西连绫庄巷,成为城南丝织业的核心枢纽。
|
||||
|
||||
明清之际,南京丝织业达至鼎盛,绒庄街成了绒缎批发集散地,《运渎桥道小志》载:"绒庄街,以绒庄得名"。街面两侧绒庄林立,大户临街设店,后屋为坊,蚕丝经打线、染色、起绒,化作华美的绒缎。太平天国时期,绣花馆设于此处,中兴源丝织厂早期也在此营生,成为南京最早的近代工厂之一。这里的绒花技艺更是独树一帜,以蚕丝染色成绒,钢丝勾条制作,谐音"荣华",成了宫廷与民间的吉祥饰物。
|
||||
|
||||
影视光影,唤醒非遗记忆。《延禧攻略》中富察皇后"不御珠翠,惟插通草绒花"的清雅形象,《如懿传》里李玉赠予惢心的那支绒花信物,皆出自南京绒花非遗传承人赵树宪之手。这些剧中的绒花,正是绒庄街百年技艺的延续——参照故宫清代皇室发饰收藏,经打绒、传丝、劈丝、勾条等数十道工序,方才绽放出"荣华高升"的美好寓意。2018 年剧集热播,让这条古巷的柔软记忆,随荧幕传遍四海。
|
||||
|
||||
岁月流转,绒庄街依旧保留着老城南的街巷肌理,市井巷陌间,丝绒芬芳与影视传奇交融。如今,熙南里街区复兴了绒花技艺,非遗工作室里,蚕丝与铜条在匠人的指尖"变身"成五彩花朵,重现"荣华"之美。从明代帽行到明清绒庄,从太平天国绣馆到今日影视非遗,绒庄街以它独有的方式,将千年丝织史、民俗文化史与影视传播史,静静编织在南京的街巷之间。
|
||||
|
||||
一缕丝绒连古今,半巷荣华映光影,绒庄街的每一寸土地,都在诉说着金陵城的丝绸传奇与匠心传承的故事,在时光长河中,愈发柔软绵长。`,
|
||||
},
|
||||
{
|
||||
title: "熙南里",
|
||||
subtitle: "古巷新声,宅韵悠远",
|
||||
caption: "号称\"九十九间半\"的甘熙故居俯瞰",
|
||||
body: `老城南的市井风华,尽数凝萃于熙南里的街巷之间,这片依循南都繁会图景焕新的街区,承载着金陵数百年的人居烟火与人文底蕴,在时代更迭中始终守着城南的根脉与风骨。
|
||||
|
||||
清代中期,藏书家甘熙于此营建宅第,这座被称作"九十九间半"的甘熙故居,依巷而筑、格局雅致,是金陵传统民居的经典代表,也成为熙南里的文化核心。宅院之中藏着书香与民俗,见证着城南世家的风雅日常,也留存下老南京的生活印记与民俗技艺,历经岁月洗礼,依旧诉说着旧时城南的人文盛景。
|
||||
|
||||
与甘熙故居相依的大板巷,古称习艺街,自古便是匠作云集、市井喧闹之地,街巷串联起周边阡陌巷弄,是老城南商贸与生活的重要脉络。明清至民国年间,大板巷商号林立、人声熙攘,织就出一派鲜活的城南市井画卷,街巷的肌理与烟火气,也就此深深烙印在熙南里的岁月之中。
|
||||
|
||||
时光流转,熙南里在保留原有街巷格局与历史遗存的基础上,让古老街区焕发新生。传统民居的雅致底蕴与现代生活的鲜活气息在此相融,甘熙故居里的民俗展演延续着非遗文脉,大板巷间的新潮业态与创意小店则为古巷注入年轻活力。
|
||||
|
||||
昔日的城南市井盛景在此重现,非遗技艺、文创潮品、市井烟火交织共生,没有生硬的复刻,只有自然的传承与新生。从清代宅第的书香雅致,到古巷老街的市井喧闹,再到如今古今交融的文化街区,熙南里始终扎根城南土壤,将历史底蕴与当代生活温柔联结。
|
||||
|
||||
一宅藏旧事,一巷纳新风,熙南里以温润的姿态,留住老城南的烟火与风雅,让金陵的人文记忆在街巷间缓缓流淌,在岁月更迭中,书写着属于城南的崭新篇章。`,
|
||||
},
|
||||
];
|
||||
|
||||
async function seed() {
|
||||
console.log("Seeding articles...");
|
||||
|
||||
await prisma.article.deleteMany();
|
||||
|
||||
const created = [];
|
||||
for (let i = 0; i < articles.length; i++) {
|
||||
const a = articles[i];
|
||||
const pos = String(i + 1).padStart(2, "0");
|
||||
const article = await prisma.article.create({
|
||||
data: {
|
||||
title: a.title,
|
||||
subtitle: a.subtitle,
|
||||
body: a.body,
|
||||
caption: a.caption,
|
||||
coverImage: `/uploads/articles/article-${pos}.jpg`,
|
||||
sortOrder: i + 1,
|
||||
enabled: true,
|
||||
},
|
||||
});
|
||||
created.push(article);
|
||||
}
|
||||
|
||||
console.log(`Created ${created.length} articles`);
|
||||
console.log("\nArticle IDs for testing:");
|
||||
created.forEach((a) => {
|
||||
console.log(` ${a.sortOrder}. ${a.title}: /article/${a.id}`);
|
||||
});
|
||||
|
||||
console.log("\nSeed complete!");
|
||||
}
|
||||
|
||||
seed()
|
||||
.catch((e) => {
|
||||
console.error(e);
|
||||
process.exit(1);
|
||||
})
|
||||
.finally(() => prisma.$disconnect());
|
||||
45
packages/server/src/seed-music.ts
Normal 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());
|
||||
@@ -1,63 +1,57 @@
|
||||
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() {
|
||||
console.log("Seeding database...");
|
||||
|
||||
// Create sample stamps
|
||||
const stamps = await Promise.all([
|
||||
prisma.stamp.create({
|
||||
data: { name: "古桥印记", note: "始建于明代的石拱桥", imageColor: "/uploads/placeholder-color.svg", imageGrey: "/uploads/placeholder-grey.svg", sortOrder: 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 },
|
||||
}),
|
||||
]);
|
||||
// Clear existing stamps (cascades to collections + prize)
|
||||
await prisma.stamp.deleteMany();
|
||||
|
||||
console.log(`Created ${stamps.length} stamps`);
|
||||
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,
|
||||
prize: {
|
||||
create: {
|
||||
name: `${s.name} · 专属奖品`,
|
||||
description: `在「${s.name}」可兑换的专属奖品`,
|
||||
stock: 100,
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
// Create redemption rules
|
||||
const rules = await Promise.all([
|
||||
prisma.redemptionRule.create({
|
||||
data: { name: "纪念书签", description: "精美城市纪念书签一枚", threshold: 3, sortOrder: 1 },
|
||||
}),
|
||||
prisma.redemptionRule.create({
|
||||
data: { name: "手绘明信片套装", description: "一套 5 张手绘城市明信片", threshold: 5, sortOrder: 2 },
|
||||
}),
|
||||
prisma.redemptionRule.create({
|
||||
data: { name: "限定徽章礼盒", description: "城市限定版金属徽章礼盒", threshold: 7, sortOrder: 3 },
|
||||
}),
|
||||
prisma.redemptionRule.create({
|
||||
data: { name: "城市记忆礼包", description: "包含纪念 T 恤、帆布袋和城市画册", threshold: 9, sortOrder: 4 },
|
||||
}),
|
||||
]);
|
||||
console.log(`Created ${stamps.length} stamps with prizes`);
|
||||
|
||||
console.log(`Created ${rules.length} redemption rules`);
|
||||
|
||||
// Print stamp IDs for QR code testing
|
||||
console.log("\nStamp IDs for testing:");
|
||||
stamps.forEach((s) => {
|
||||
console.log(` ${s.name}: /collect/${s.id}`);
|
||||
console.log(` ${s.sortOrder}. ${s.name}: /collect/${s.id}`);
|
||||
});
|
||||
|
||||
console.log("\nSeed complete!");
|
||||
|
||||
BIN
packages/server/uploads/articles/article-01.jpg
Normal file
|
After Width: | Height: | Size: 193 KiB |
BIN
packages/server/uploads/articles/article-02.jpg
Normal file
|
After Width: | Height: | Size: 112 KiB |
BIN
packages/server/uploads/articles/article-03.jpg
Normal file
|
After Width: | Height: | Size: 381 KiB |
BIN
packages/server/uploads/articles/article-04.jpg
Normal file
|
After Width: | Height: | Size: 222 KiB |
BIN
packages/server/uploads/articles/article-05.jpg
Normal file
|
After Width: | Height: | Size: 321 KiB |
BIN
packages/server/uploads/articles/article-06.jpg
Normal file
|
After Width: | Height: | Size: 772 KiB |
BIN
packages/server/uploads/music/chaotiangong.m4a
Normal file
BIN
packages/server/uploads/stamps/stamp-01-color.jpg
Normal file
|
After Width: | Height: | Size: 180 KiB |
BIN
packages/server/uploads/stamps/stamp-01-grey.jpg
Normal file
|
After Width: | Height: | Size: 104 KiB |
BIN
packages/server/uploads/stamps/stamp-02-color.jpg
Normal file
|
After Width: | Height: | Size: 192 KiB |
BIN
packages/server/uploads/stamps/stamp-02-grey.jpg
Normal file
|
After Width: | Height: | Size: 108 KiB |
BIN
packages/server/uploads/stamps/stamp-03-color.jpg
Normal file
|
After Width: | Height: | Size: 159 KiB |
BIN
packages/server/uploads/stamps/stamp-03-grey.jpg
Normal file
|
After Width: | Height: | Size: 108 KiB |
BIN
packages/server/uploads/stamps/stamp-04-color.jpg
Normal file
|
After Width: | Height: | Size: 167 KiB |
BIN
packages/server/uploads/stamps/stamp-04-grey.jpg
Normal file
|
After Width: | Height: | Size: 104 KiB |
BIN
packages/server/uploads/stamps/stamp-05-color.jpg
Normal file
|
After Width: | Height: | Size: 170 KiB |
BIN
packages/server/uploads/stamps/stamp-05-grey.jpg
Normal file
|
After Width: | Height: | Size: 124 KiB |
BIN
packages/server/uploads/stamps/stamp-06-color.jpg
Normal file
|
After Width: | Height: | Size: 198 KiB |
BIN
packages/server/uploads/stamps/stamp-06-grey.jpg
Normal file
|
After Width: | Height: | Size: 126 KiB |
BIN
packages/server/uploads/stamps/stamp-07-color.jpg
Normal file
|
After Width: | Height: | Size: 166 KiB |
BIN
packages/server/uploads/stamps/stamp-07-grey.jpg
Normal file
|
After Width: | Height: | Size: 98 KiB |
BIN
packages/server/uploads/stamps/stamp-08-color.jpg
Normal file
|
After Width: | Height: | Size: 183 KiB |
BIN
packages/server/uploads/stamps/stamp-08-grey.jpg
Normal file
|
After Width: | Height: | Size: 132 KiB |
BIN
packages/server/uploads/stamps/stamp-09-color.jpg
Normal file
|
After Width: | Height: | Size: 160 KiB |
BIN
packages/server/uploads/stamps/stamp-09-grey.jpg
Normal file
|
After Width: | Height: | Size: 111 KiB |
BIN
packages/server/uploads/stamps/stamp-10-color.jpg
Normal file
|
After Width: | Height: | Size: 177 KiB |
BIN
packages/server/uploads/stamps/stamp-10-grey.jpg
Normal file
|
After Width: | Height: | Size: 114 KiB |
BIN
packages/server/uploads/stamps/stamp-11-color.jpg
Normal file
|
After Width: | Height: | Size: 177 KiB |
BIN
packages/server/uploads/stamps/stamp-11-grey.jpg
Normal file
|
After Width: | Height: | Size: 133 KiB |
BIN
packages/server/uploads/stamps/stamp-12-color.jpg
Normal file
|
After Width: | Height: | Size: 167 KiB |
BIN
packages/server/uploads/stamps/stamp-12-grey.jpg
Normal file
|
After Width: | Height: | Size: 110 KiB |
BIN
packages/server/uploads/stamps/stamp-13-color.jpg
Normal file
|
After Width: | Height: | Size: 183 KiB |
BIN
packages/server/uploads/stamps/stamp-13-grey.jpg
Normal file
|
After Width: | Height: | Size: 118 KiB |
BIN
packages/server/uploads/stamps/stamp-14-color.jpg
Normal file
|
After Width: | Height: | Size: 182 KiB |
BIN
packages/server/uploads/stamps/stamp-14-grey.jpg
Normal file
|
After Width: | Height: | Size: 107 KiB |
BIN
packages/server/uploads/stamps/stamp-15-color.jpg
Normal file
|
After Width: | Height: | Size: 207 KiB |
BIN
packages/server/uploads/stamps/stamp-15-grey.jpg
Normal file
|
After Width: | Height: | Size: 141 KiB |
BIN
packages/server/uploads/stamps/stamp-16-color.jpg
Normal file
|
After Width: | Height: | Size: 171 KiB |
BIN
packages/server/uploads/stamps/stamp-16-grey.jpg
Normal file
|
After Width: | Height: | Size: 111 KiB |
@@ -4,6 +4,14 @@ export type ApiResponse<T = unknown> = {
|
||||
error?: { code: string; message: string };
|
||||
};
|
||||
|
||||
export type PrizeInfo = {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
stock: number;
|
||||
enabled: boolean;
|
||||
};
|
||||
|
||||
export type StampWithStatus = {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -13,18 +21,48 @@ export type StampWithStatus = {
|
||||
sortOrder: number;
|
||||
collected: boolean;
|
||||
collectedAt: string | null;
|
||||
};
|
||||
|
||||
export type RedemptionRuleInfo = {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
threshold: number;
|
||||
redeemed: boolean;
|
||||
prize: PrizeInfo | null;
|
||||
};
|
||||
|
||||
export type RedemptionRecord = {
|
||||
id: string;
|
||||
ruleName: string;
|
||||
stampCount: number;
|
||||
stampId: string;
|
||||
stampName: string;
|
||||
prizeName: 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;
|
||||
};
|
||||
|
||||
BIN
packages/web/public/poster.jpg
Normal file
|
After Width: | Height: | Size: 125 KiB |
@@ -2,14 +2,17 @@ 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 MusicPage from "./pages/MusicPage";
|
||||
import VideoPage from "./pages/VideoPage";
|
||||
import AdminLogin from "./admin/AdminLogin";
|
||||
import AdminGuard from "./admin/AdminGuard";
|
||||
import AdminLayout from "./admin/AdminLayout";
|
||||
import Dashboard from "./admin/Dashboard";
|
||||
import StampList from "./admin/StampList";
|
||||
import StampForm from "./admin/StampForm";
|
||||
import StampQRCode from "./admin/StampQRCode";
|
||||
import RuleList from "./admin/RuleList";
|
||||
import RuleForm from "./admin/RuleForm";
|
||||
import ArticleList from "./admin/ArticleList";
|
||||
import MusicList from "./admin/MusicList";
|
||||
import UsersList from "./admin/UsersList";
|
||||
import RedemptionLog from "./admin/RedemptionLog";
|
||||
|
||||
function CollectRedirect() {
|
||||
@@ -25,18 +28,19 @@ export default function App() {
|
||||
<Route path="/" element={<LandingPage />} />
|
||||
<Route path="/album" element={<AlbumPage />} />
|
||||
<Route path="/collect/:stampId" element={<CollectRedirect />} />
|
||||
<Route path="/article/:id" element={<ArticlePage />} />
|
||||
<Route path="/music/:id" element={<MusicPage />} />
|
||||
<Route path="/video/:id" element={<VideoPage />} />
|
||||
|
||||
{/* Admin panel */}
|
||||
<Route path="/admin" element={<AdminLogin />} />
|
||||
<Route element={<AdminGuard />}>
|
||||
<Route element={<AdminLayout />}>
|
||||
<Route path="/admin/dashboard" element={<Dashboard />} />
|
||||
<Route path="/admin/stamps" element={<StampList />} />
|
||||
<Route path="/admin/stamps/new" element={<StampForm />} />
|
||||
<Route path="/admin/stamps/:id/edit" element={<StampForm />} />
|
||||
<Route path="/admin/stamps/:id/qrcode" element={<StampQRCode />} />
|
||||
<Route path="/admin/rules" element={<RuleList />} />
|
||||
<Route path="/admin/rules/new" element={<RuleForm />} />
|
||||
<Route path="/admin/rules/:id/edit" element={<RuleForm />} />
|
||||
<Route path="/admin/articles" element={<ArticleList />} />
|
||||
<Route path="/admin/music" element={<MusicList />} />
|
||||
<Route path="/admin/users" element={<UsersList />} />
|
||||
<Route path="/admin/redemptions" element={<RedemptionLog />} />
|
||||
</Route>
|
||||
</Route>
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import { NavLink, Outlet, useNavigate } from "react-router-dom";
|
||||
import { ToastProvider } from "./Toast";
|
||||
|
||||
const navItems = [
|
||||
{ path: "/admin/stamps", label: "图章管理" },
|
||||
{ path: "/admin/rules", label: "兑换规则" },
|
||||
{ path: "/admin/redemptions", label: "兑换记录" },
|
||||
{ path: "/admin/dashboard", label: "数据看板", eyebrow: "01", tag: "Dashboard" },
|
||||
{ path: "/admin/stamps", label: "图章管理", eyebrow: "02", tag: "Stamps" },
|
||||
{ path: "/admin/articles", label: "文章管理", eyebrow: "03", tag: "Articles" },
|
||||
{ path: "/admin/music", label: "音乐管理", eyebrow: "04", tag: "Music" },
|
||||
{ path: "/admin/users", label: "用户管理", eyebrow: "05", tag: "Users" },
|
||||
{ path: "/admin/redemptions", label: "兑换记录", eyebrow: "06", tag: "Log" },
|
||||
];
|
||||
|
||||
export default function AdminLayout() {
|
||||
@@ -15,40 +19,131 @@ export default function AdminLayout() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 flex">
|
||||
{/* Sidebar */}
|
||||
<aside className="w-56 bg-white border-r border-gray-200 flex flex-col shrink-0">
|
||||
<div className="px-5 py-4 border-b border-gray-200">
|
||||
<h1 className="text-base font-semibold text-gray-800">图章管理后台</h1>
|
||||
</div>
|
||||
<nav className="flex-1 py-3">
|
||||
{navItems.map((item) => (
|
||||
<NavLink
|
||||
key={item.path}
|
||||
to={item.path}
|
||||
className={({ isActive }) =>
|
||||
`block px-5 py-2.5 text-sm transition-colors ${
|
||||
isActive
|
||||
? "text-blue-600 bg-blue-50 font-medium border-r-2 border-blue-600"
|
||||
: "text-gray-600 hover:bg-gray-50"
|
||||
}`
|
||||
}
|
||||
>
|
||||
{item.label}
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
<div className="px-5 py-3 border-t border-gray-200">
|
||||
<button onClick={handleLogout} className="text-sm text-gray-500 hover:text-gray-700">
|
||||
退出管理
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
<ToastProvider>
|
||||
<div className="min-h-screen flex bg-[var(--bg-cream)] grain-overlay">
|
||||
{/* ═══════════ Sidebar ═══════════ */}
|
||||
<aside className="w-64 shrink-0 relative flex flex-col text-[var(--text-inverted)]">
|
||||
<div className="absolute inset-0 bg-[var(--bg-dark-deep)]" />
|
||||
<div
|
||||
className="absolute inset-0 pointer-events-none"
|
||||
style={{
|
||||
backgroundImage: `
|
||||
radial-gradient(ellipse 80% 40% at 50% 0%, rgba(212, 165, 116, 0.08) 0%, transparent 70%),
|
||||
radial-gradient(circle at 20% 90%, rgba(199, 91, 57, 0.06) 0%, transparent 60%)
|
||||
`,
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className="absolute inset-0 opacity-[0.025] pointer-events-none"
|
||||
style={{
|
||||
backgroundImage: `
|
||||
linear-gradient(rgba(212, 165, 116, 0.6) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(212, 165, 116, 0.6) 1px, transparent 1px)
|
||||
`,
|
||||
backgroundSize: "40px 40px",
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Main content */}
|
||||
<main className="flex-1 p-6 overflow-auto">
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
{/* Brand */}
|
||||
<div className="relative px-7 pt-8 pb-7">
|
||||
<div className="flex items-center gap-2.5 mb-3">
|
||||
<span className="block w-5 h-px bg-[var(--gold)]/60" />
|
||||
<span
|
||||
className="text-[var(--gold)] text-[10px] tracking-[0.4em] uppercase"
|
||||
style={{ fontFamily: "'Playfair Display', serif", fontWeight: 500 }}
|
||||
>
|
||||
Atelier
|
||||
</span>
|
||||
</div>
|
||||
<h1
|
||||
className="text-[var(--text-inverted)] text-[22px] leading-tight"
|
||||
style={{ fontFamily: "'Playfair Display', serif", fontWeight: 700, letterSpacing: "-0.01em" }}
|
||||
>
|
||||
CityWalk
|
||||
<br />
|
||||
<span className="text-[var(--gold)]">图章后台</span>
|
||||
</h1>
|
||||
<div className="mt-4 h-px bg-gradient-to-r from-[var(--gold)]/40 via-[var(--gold)]/10 to-transparent" />
|
||||
</div>
|
||||
|
||||
{/* Nav */}
|
||||
<nav className="relative flex-1 px-4">
|
||||
{navItems.map((item) => (
|
||||
<NavLink
|
||||
key={item.path}
|
||||
to={item.path}
|
||||
className={({ isActive }) =>
|
||||
`group relative block px-3 py-3 my-0.5 rounded-lg transition-all duration-300 ${
|
||||
isActive
|
||||
? "bg-[rgba(212,165,116,0.08)]"
|
||||
: "hover:bg-white/[0.04]"
|
||||
}`
|
||||
}
|
||||
>
|
||||
{({ isActive }) => (
|
||||
<>
|
||||
<span
|
||||
className={`absolute left-0 top-1/2 -translate-y-1/2 w-[3px] rounded-r transition-all duration-300 ${
|
||||
isActive ? "h-5 bg-[var(--gold)]" : "h-0 bg-transparent"
|
||||
}`}
|
||||
/>
|
||||
<div className="flex items-baseline gap-3">
|
||||
<span
|
||||
className={`text-[10px] tracking-[0.3em] uppercase shrink-0 transition-colors ${
|
||||
isActive ? "text-[var(--gold)]" : "text-[var(--text-inverted)]/30"
|
||||
}`}
|
||||
style={{ fontFamily: "'Playfair Display', serif" }}
|
||||
>
|
||||
{item.eyebrow}
|
||||
</span>
|
||||
<span
|
||||
className={`text-[14px] transition-colors ${
|
||||
isActive
|
||||
? "text-[var(--text-inverted)] font-medium"
|
||||
: "text-[var(--text-inverted)]/60 group-hover:text-[var(--text-inverted)]/90"
|
||||
}`}
|
||||
>
|
||||
{item.label}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
className={`ml-[38px] mt-0.5 text-[9px] tracking-[0.32em] uppercase transition-colors ${
|
||||
isActive ? "text-[var(--gold)]/60" : "text-[var(--text-inverted)]/15"
|
||||
}`}
|
||||
style={{ fontFamily: "'Playfair Display', serif" }}
|
||||
>
|
||||
{item.tag}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="relative px-7 py-5 border-t border-white/5">
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="group flex items-center gap-2.5 text-[var(--text-inverted)]/45 hover:text-[var(--gold)] text-sm transition-colors"
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M9 21H5a2 2 0 01-2-2V5a2 2 0 012-2h4M16 17l5-5-5-5M21 12H9" />
|
||||
</svg>
|
||||
<span>退出管理</span>
|
||||
</button>
|
||||
<p className="mt-3 text-[9px] tracking-[0.3em] uppercase text-[var(--text-inverted)]/20">
|
||||
v1.0 · Curated by Stamp
|
||||
</p>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* ═══════════ Main ═══════════ */}
|
||||
<main className="flex-1 overflow-auto paper-texture">
|
||||
<div className="min-h-full px-10 py-10">
|
||||
<Outlet />
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</ToastProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ export default function AdminLogin() {
|
||||
const json = await res.json();
|
||||
if (json.success) {
|
||||
sessionStorage.setItem("admin_key", key);
|
||||
navigate("/admin/stamps");
|
||||
navigate("/admin/dashboard");
|
||||
} else {
|
||||
setError("密钥不正确");
|
||||
}
|
||||
@@ -27,28 +27,117 @@ export default function AdminLogin() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
|
||||
<div className="w-80 bg-white rounded-lg shadow-sm p-6 border border-gray-200">
|
||||
<h1 className="text-lg font-semibold text-gray-800 mb-4 text-center">管理后台</h1>
|
||||
<div className="space-y-3">
|
||||
<input
|
||||
type="password"
|
||||
value={key}
|
||||
onChange={(e) => setKey(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && handleLogin()}
|
||||
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 focus:border-blue-500"
|
||||
/>
|
||||
{error && <p className="text-sm text-red-500">{error}</p>}
|
||||
<button
|
||||
onClick={handleLogin}
|
||||
disabled={loading || !key}
|
||||
className="w-full py-2 bg-blue-600 text-white text-sm rounded-md
|
||||
hover:bg-blue-700 disabled:opacity-50 transition-colors"
|
||||
<div className="min-h-screen flex items-center justify-center relative overflow-hidden grain-overlay">
|
||||
<div className="absolute inset-0 bg-[var(--bg-dark-deep)]" />
|
||||
<div
|
||||
className="absolute inset-0"
|
||||
style={{
|
||||
backgroundImage: `
|
||||
radial-gradient(ellipse 60% 50% at 50% 40%, rgba(212, 165, 116, 0.08) 0%, transparent 100%),
|
||||
radial-gradient(circle at 15% 80%, rgba(199, 91, 57, 0.06) 0%, transparent 50%),
|
||||
radial-gradient(circle at 85% 20%, rgba(45, 106, 79, 0.04) 0%, transparent 40%)
|
||||
`,
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className="absolute inset-0 opacity-[0.03]"
|
||||
style={{
|
||||
backgroundImage: `
|
||||
linear-gradient(rgba(212, 165, 116, 0.5) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(212, 165, 116, 0.5) 1px, transparent 1px)
|
||||
`,
|
||||
backgroundSize: "60px 60px",
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="relative z-10 w-full max-w-sm px-6">
|
||||
{/* Brand */}
|
||||
<div className="text-center mb-10 animate-fade-in">
|
||||
<div className="inline-flex items-center gap-3 mb-6">
|
||||
<span className="block w-10 h-px bg-[var(--gold)]/50" />
|
||||
<span
|
||||
className="text-[var(--gold)] text-[11px] tracking-[0.4em] uppercase"
|
||||
style={{ fontFamily: "'Playfair Display', serif", fontWeight: 500 }}
|
||||
>
|
||||
CityWalk · Atelier
|
||||
</span>
|
||||
<span className="block w-10 h-px bg-[var(--gold)]/50" />
|
||||
</div>
|
||||
<h1
|
||||
className="text-[var(--text-inverted)] text-4xl leading-none mb-3"
|
||||
style={{ fontFamily: "'Playfair Display', serif", fontWeight: 700, letterSpacing: "-0.02em" }}
|
||||
>
|
||||
{loading ? "验证中..." : "登录"}
|
||||
</button>
|
||||
图章后台
|
||||
</h1>
|
||||
<p className="text-[var(--gold-light)]/50 text-[11px] tracking-[0.28em] uppercase">
|
||||
Stamp · Admin Console
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Card */}
|
||||
<div
|
||||
className="relative animate-fade-in-up"
|
||||
style={{ animationDelay: "0.15s" }}
|
||||
>
|
||||
{/* Corner flourishes */}
|
||||
<span className="absolute -top-1.5 -left-1.5 w-6 h-6 border-t border-l border-[var(--gold)]/40" />
|
||||
<span className="absolute -top-1.5 -right-1.5 w-6 h-6 border-t border-r border-[var(--gold)]/40" />
|
||||
<span className="absolute -bottom-1.5 -left-1.5 w-6 h-6 border-b border-l border-[var(--gold)]/40" />
|
||||
<span className="absolute -bottom-1.5 -right-1.5 w-6 h-6 border-b border-r border-[var(--gold)]/40" />
|
||||
|
||||
<div
|
||||
className="rounded-xl bg-[var(--bg-cream)] p-7"
|
||||
style={{ boxShadow: "0 32px 80px rgba(0,0,0,0.4)" }}
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-5">
|
||||
<span className="block w-4 h-px bg-[var(--gold)]" />
|
||||
<span
|
||||
className="text-[var(--gold)] text-[10px] tracking-[0.32em] uppercase"
|
||||
style={{ fontFamily: "'Playfair Display', serif" }}
|
||||
>
|
||||
Access
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<label className="block text-[13px] text-[var(--text-secondary)] mb-2">管理密钥</label>
|
||||
<input
|
||||
type="password"
|
||||
value={key}
|
||||
onChange={(e) => setKey(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && key && handleLogin()}
|
||||
placeholder="••••••••"
|
||||
className="w-full px-4 py-3 bg-white border border-[var(--border-default)] rounded-lg text-sm text-[var(--text-primary)] placeholder:text-[var(--text-muted)]/50 focus:outline-none focus:border-[var(--gold)] focus:ring-2 focus:ring-[var(--gold)]/15 transition-all"
|
||||
autoFocus
|
||||
/>
|
||||
|
||||
{error && (
|
||||
<div className="mt-3 flex items-center gap-2 text-[13px] text-[var(--terracotta)]">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.4">
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<path d="M12 8v4M12 16h.01" />
|
||||
</svg>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={handleLogin}
|
||||
disabled={loading || !key}
|
||||
className="mt-6 w-full py-3 rounded-lg text-white text-sm font-medium transition-all disabled:opacity-40 hover:brightness-110"
|
||||
style={{
|
||||
backgroundColor: "var(--terracotta)",
|
||||
boxShadow: "0 8px 24px rgba(199,91,57,0.35)",
|
||||
}}
|
||||
>
|
||||
{loading ? "验证中..." : "进入后台"}
|
||||
</button>
|
||||
|
||||
<div className="mt-6 pt-5 border-t border-[var(--border-muted)]">
|
||||
<p className="text-[10px] tracking-[0.25em] uppercase text-[var(--text-muted)] text-center">
|
||||
仅限授权访问
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||