第一期:家庭自学打卡系统
- 家长手机号登录 + 孩子设备一次性激活(长期会话) - 周模板自动生成每日任务快照,支持拍照凭证直传对象存储 - 连击计算(空白日自动达成)、家长补卡带标记 - 家长端三标签:今日概览 / 任务模板 / 设备管理 - Docker 独立输出,经 Traefik 接入 jump.hn12301.net Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"version": "0.0.1",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "dev",
|
||||
"runtimeExecutable": "npm",
|
||||
"runtimeArgs": ["run", "dev"],
|
||||
"port": 3000
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
node_modules
|
||||
.next
|
||||
.git
|
||||
.claude
|
||||
docs
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
*.md
|
||||
@@ -0,0 +1,13 @@
|
||||
# ===== 数据库 =====
|
||||
# 容器内网地址(mysql 为服务器上的数据库容器名)
|
||||
# 本机开发时改走 SSH 隧道:ssh -N -L 13306:<mysql容器IP>:3306 aifield
|
||||
DATABASE_URL=mysql://superjump:在此填写密码@mysql:3306/superjump
|
||||
|
||||
# ===== 阿里云对象存储 =====
|
||||
OSS_REGION=oss-cn-hangzhou
|
||||
OSS_BUCKET=12301-test
|
||||
OSS_ACCESS_KEY_ID=
|
||||
OSS_ACCESS_KEY_SECRET=
|
||||
|
||||
# ===== 公开访问地址 =====
|
||||
PUBLIC_BASE_URL=https://jump.hn12301.net
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
# 依赖与构建产物
|
||||
node_modules/
|
||||
.next/
|
||||
out/
|
||||
|
||||
# 环境变量(含密钥,严禁入库)
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
# 调试与日志
|
||||
npm-debug.log*
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
|
||||
# 系统文件
|
||||
.DS_Store
|
||||
@@ -0,0 +1,2 @@
|
||||
# 国内镜像源,保证依赖安装速度(构建容器时同样生效)
|
||||
registry=https://registry.npmmirror.com
|
||||
@@ -0,0 +1,144 @@
|
||||
# SuperJump 项目指南
|
||||
|
||||
> 本项目部署在「AI 实验场」服务器(47.98.16.70)。以下为该服务器的部署/运维上下文,
|
||||
> 同步自部署手册:`~/Documents/obsidian/工作资料/47.98.16.70(Ai实验场)部署手册_2026-08-02.md`
|
||||
> (手册内含全部凭据明文;本文件会进 git,故只记位置不记密码。)
|
||||
|
||||
## 一、目标服务器
|
||||
|
||||
- 阿里云 ECS:4 核 15G / Ubuntu 26.04 LTS / 99G 系统盘 / 公网 IP `47.98.16.70`
|
||||
- 登录:`ssh aifield`(root,**仅密钥登录**,无密码)
|
||||
- 私钥:本机 `~/.ssh/aifield_47.98.16.70_ed25519`,NAS 有备份
|
||||
- ⚠️ 私钥丢失 = 失去服务器(只能走阿里云控制台 VNC)
|
||||
- 代码托管:本项目 `https://git.hn12301.net/xuchao/SuperJump.git`(自建 Gitea,账号 `xuchao`)
|
||||
|
||||
## 二、架构
|
||||
|
||||
- 纯 Docker Compose 手工管理 + Traefik 自动反代 + Let's Encrypt 自动签发/续期证书
|
||||
- 流量:`:80` 强制 301 → `:443`(Traefik)→ 各容器
|
||||
- 已有域名:`git.hn12301.net`(Gitea)、`status.hn12301.net`(Uptime Kuma)、`traefik.hn12301.net`(Dashboard, Basic Auth);新项目用 `<项目>.hn12301.net`
|
||||
- 两个内部网络:
|
||||
- `proxy` — Traefik 与所有对外服务
|
||||
- `data` — MySQL / Redis / Kuma(需连数据库的服务加入此网络)
|
||||
- MySQL / Redis 宿主机**零端口暴露**,仅 data 内网可达(容器内用 `mysql:3306`、`redis:6379` 访问)
|
||||
|
||||
## 三、凭据获取(不进本文件)
|
||||
|
||||
| 服务 | 账号 | 密码在哪 |
|
||||
| --- | --- | --- |
|
||||
| Gitea | `xuchao` | 服务器 `/opt/services/gitea/.env`,或 Obsidian 手册 |
|
||||
| MySQL | `root` | 服务器 `/opt/services/mysql/.env` |
|
||||
| Redis | — | 服务器 `/opt/services/redis/.env` |
|
||||
| Uptime Kuma | `admin` | 服务器 `/opt/services/uptime-kuma/`,或 Obsidian 手册 |
|
||||
| Traefik Dashboard | `admin` | 服务器 `/opt/services/traefik/.env` |
|
||||
|
||||
- 服务器上各服务密码的权威来源:`/opt/services/*/.env`(权限 600)
|
||||
- 完整凭据表与哈希备份:Obsidian 部署手册(路径见文首)
|
||||
|
||||
## 四、服务器目录结构
|
||||
|
||||
```
|
||||
/opt/services/
|
||||
├── traefik/ # 反向代理
|
||||
├── gitea/ # Git 服务
|
||||
├── gitea-runner/ # CI runner(默认基础镜像 node:24-slim)
|
||||
├── mysql/ # 数据库
|
||||
├── redis/ # 缓存
|
||||
└── uptime-kuma/ # 监控(proxy + data 双网络)
|
||||
```
|
||||
|
||||
数据卷:`mysql_mysql_data`、`redis_redis_data`、`gitea_gitea_data`、`kuma_data`、`runner_data`、`traefik_certs`(证书)。
|
||||
|
||||
## 五、日常运维速查
|
||||
|
||||
```bash
|
||||
ssh aifield # 登录服务器
|
||||
|
||||
cd /opt/services/<服务> && docker compose ps / restart / logs -f --tail 50
|
||||
docker ps # 全部容器
|
||||
|
||||
# MySQL / Redis 客户端(密码从 .env 现取,不要硬编码)
|
||||
docker exec -it mysql mysql -uroot -p"$(grep MYSQL_ROOT_PASSWORD /opt/services/mysql/.env | cut -d= -f2)"
|
||||
docker exec -it redis redis-cli -a "$(grep REDIS_PASSWORD /opt/services/redis/.env | cut -d= -f2)"
|
||||
|
||||
# 改某服务密码:更新对应 .env → docker compose up -d 重建容器
|
||||
```
|
||||
|
||||
## 六、本项目部署接入步骤
|
||||
|
||||
域名:`superjump.hn12301.net`(或自定;若已有泛解析 `*` → `47.98.16.70` 则免加 DNS A 记录)。
|
||||
|
||||
1. 在 `/opt/services/superjump/` 建 `docker-compose.yml`:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
superjump:
|
||||
image: 你的镜像:tag
|
||||
container_name: superjump
|
||||
restart: unless-stopped
|
||||
networks: [proxy, data] # data 仅在需要连 MySQL/Redis 时加
|
||||
labels:
|
||||
- traefik.enable=true
|
||||
- traefik.docker.network=proxy
|
||||
- traefik.http.routers.superjump.rule=Host(`superjump.hn12301.net`)
|
||||
- traefik.http.routers.superjump.entrypoints=websecure
|
||||
- traefik.http.routers.superjump.tls.certresolver=le
|
||||
- traefik.http.services.superjump.loadbalancer.server.port=8080 # 改成容器内实际端口
|
||||
networks:
|
||||
proxy:
|
||||
name: proxy
|
||||
external: true
|
||||
data:
|
||||
name: data
|
||||
external: true
|
||||
```
|
||||
|
||||
2. `docker compose up -d` → 几十秒后 `https://superjump.hn12301.net` 自动可用(证书自动签发),**不用动 Traefik 任何配置**。
|
||||
|
||||
3. 需要数据库时建**专用账号**(勿用 root):
|
||||
|
||||
```sql
|
||||
CREATE DATABASE superjump CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
CREATE USER 'superjump'@'%' IDENTIFIED BY '另起强密码';
|
||||
GRANT ALL ON superjump.* TO 'superjump'@'%';
|
||||
```
|
||||
|
||||
语言镜像映射:Java → JRE 镜像;.NET → `mcr.microsoft.com/dotnet/aspnet:9.0`;Python → uvicorn/gunicorn 镜像。需要构建的走 Gitea Actions。
|
||||
|
||||
## 七、CI(Gitea Actions)
|
||||
|
||||
- Runner 已注册,标签 `ubuntu-latest` / `ubuntu-24.04` / `ubuntu-22.04`,均映射轻量镜像 `node:24-slim`(含 bash/git/node)
|
||||
- workflow 放仓库 `.gitea/workflows/*.yaml`,语法同 GitHub Actions
|
||||
- 重型任务在 workflow 里用 `container:` 指定专用镜像,**不要改 runner 默认**(国内拉 docker.gitea.com 基础镜像极慢)
|
||||
|
||||
## 八、监控(Uptime Kuma)
|
||||
|
||||
- 5 个监控项(60s 间隔):Gitea(HTTP 2xx)、Traefik-Dashboard(HTTP 401 即存活)、MySQL(连接串探针)、Redis-TCP(端口探针,Kuma 1.x 的 redis 类型不支持密码)、Traefik-443-TCP
|
||||
- 告警:QQ 邮箱 SMTP(`smtp.qq.com:465` SSL,`25260062@qq.com`),DOWN/恢复各发一封
|
||||
- 改邮箱授权码:Kuma 网页 → Settings → Notifications → 编辑「邮件告警」
|
||||
|
||||
## 九、安全现状(2026-08-02 已加固验证)
|
||||
|
||||
- SSH 仅密钥登录(`PasswordAuthentication no` 在**主配置 + sshd_config.d/50-cloud-init.conf 两处**都改了)
|
||||
- 阿里云安全组 + UFW:公网仅 22/80/443;fail2ban 运行中
|
||||
- MySQL/Redis 架构级零暴露
|
||||
- **换机/重装后必做**:阿里云重装会重置 sshd 配置,需重装公钥并再次执行两处 `PasswordAuthentication no`
|
||||
- 备份:暂未设置(建议日后:阿里云自动快照 + `/opt/services` 与数据卷定期打包)
|
||||
|
||||
## 十、踩坑记录(务必先看)
|
||||
|
||||
1. **Docker 29 + Traefik v3.5 不兼容**:Docker 29 最低 API 1.44,须用 Traefik ≥ v3.6。
|
||||
2. **docker exec 喂 SQL 必须加 `-i`**:否则 heredoc 内容不进容器且不报错。
|
||||
3. **compose .env 里的 `$` 要写成 `$$`**:htpasswd 的 `$apr1$` 哈希不转义会被当变量,Basic Auth 永远 401。
|
||||
4. **Let's Encrypt 失败重试**:Traefik 启动即尝试签发,DNS 未生效会失败;DNS 好后重启 Traefik 容器重新触发。
|
||||
5. **Docker Hub 国内直连不通**:daemon.json 已配镜像源(docker.m.daocloud.io 等)。
|
||||
6. **Gitea 禁止 root**:容器内执行 Gitea CLI 必须 `-u git`。
|
||||
7. **本机代理 fake-ip 污染探测**:开发机 Clash 类代理会把域名解析成 198.18.x.x,导致 `nc -z` 显示所有端口 OPEN 的假象。判断真实暴露面必须**从服务器侧自探公网 IP**,不要信本机探测。
|
||||
8. **远程 heredoc 里的反引号会被 shell 执行**:JS 模板字符串经 ssh 传输时反引号变命令替换;用 quoted heredoc('EOF')或先本地写文件再传。
|
||||
9. **Kuma SQLite 是 WAL 模式**:拷库离线查询要同时拷 `kuma.db` + `kuma.db-wal` + `kuma.db-shm`。
|
||||
10. **sshd_config.d 优先级高于主配置**:改 SSH 设置时主配置与 `50-cloud-init.conf` 两处都要查。
|
||||
11. **Kuma 1.x socket.io API**:`addNotification` 第二参数是 notificationID(新建传 `null`);mysql 监控用 `database_connection_string`(无 password 字段);详见手册第九节第 8 条。
|
||||
|
||||
---
|
||||
|
||||
*手册验收记录:2026-08-02 全部完成 —— 6 容器全绿;三域名证书生效;Kuma 5 监控项全 UP;SSH 密码登录已关闭;全部凭据已轮换。*
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
# ===== 构建阶段 =====
|
||||
FROM node:22-alpine AS deps
|
||||
WORKDIR /app
|
||||
COPY package.json package-lock.json .npmrc ./
|
||||
RUN npm ci
|
||||
|
||||
FROM node:22-alpine AS builder
|
||||
WORKDIR /app
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY . .
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
RUN npm run build
|
||||
|
||||
# ===== 运行阶段 =====
|
||||
FROM node:22-alpine AS runner
|
||||
WORKDIR /app
|
||||
ENV NODE_ENV=production \
|
||||
NEXT_TELEMETRY_DISABLED=1 \
|
||||
PORT=3000 \
|
||||
HOSTNAME=0.0.0.0
|
||||
|
||||
# standalone 产物自带精简 node_modules(含 mysql2、drizzle-orm)
|
||||
COPY --from=builder /app/.next/standalone ./
|
||||
COPY --from=builder /app/.next/static ./.next/static
|
||||
COPY --from=builder /app/public ./public
|
||||
# 数据库迁移资源与脚本
|
||||
COPY --from=builder /app/drizzle ./drizzle
|
||||
COPY --from=builder /app/scripts/migrate.mjs ./scripts/migrate.mjs
|
||||
COPY docker-entrypoint.sh ./
|
||||
RUN chmod +x docker-entrypoint.sh
|
||||
|
||||
EXPOSE 3000
|
||||
ENTRYPOINT ["./docker-entrypoint.sh"]
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "new-york",
|
||||
"rsc": true,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "",
|
||||
"css": "src/app/globals.css",
|
||||
"baseColor": "neutral",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/components/ui",
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
},
|
||||
"iconLibrary": "lucide"
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
services:
|
||||
superjump:
|
||||
build: .
|
||||
image: superjump:local
|
||||
container_name: superjump
|
||||
restart: unless-stopped
|
||||
env_file: .env
|
||||
networks: [proxy, data]
|
||||
labels:
|
||||
- traefik.enable=true
|
||||
- traefik.docker.network=proxy
|
||||
- traefik.http.routers.superjump.rule=Host(`jump.hn12301.net`)
|
||||
- traefik.http.routers.superjump.entrypoints=websecure
|
||||
- traefik.http.routers.superjump.tls.certresolver=le
|
||||
- traefik.http.services.superjump.loadbalancer.server.port=3000
|
||||
|
||||
networks:
|
||||
proxy:
|
||||
name: proxy
|
||||
external: true
|
||||
data:
|
||||
name: data
|
||||
external: true
|
||||
@@ -0,0 +1,8 @@
|
||||
#!/bin/sh
|
||||
set -e
|
||||
|
||||
echo "==> 执行数据库迁移..."
|
||||
node scripts/migrate.mjs
|
||||
|
||||
echo "==> 启动应用..."
|
||||
exec node server.js
|
||||
@@ -0,0 +1,78 @@
|
||||
# SuperJump 产品规划
|
||||
|
||||
> 状态:第一期开发中
|
||||
> 共识来源:2026-08-02 需求确认(逐条问答式梳理,全部条目经家长确认)
|
||||
|
||||
## 一、产品定位
|
||||
|
||||
- 家庭自学网站,仅自家使用,不对外开放、不做注册
|
||||
- 用户:两个孩子(徐卿玮,初一;徐子惠,小四;各有自己的 iPad)+ 家长
|
||||
- 数据库表结构从第一天起按"多家庭"设计,将来可开放给亲友,无需改表
|
||||
- 全部开发由 Claude Code 完成,一个模块一个模块推进
|
||||
|
||||
## 二、第一期:打卡(当前唯一开发范围)
|
||||
|
||||
### 账号与权限
|
||||
|
||||
- 家长账号:手机号 `18684839400` + 密码登录
|
||||
- 孩子设备:家长为指定孩子生成一次性激活码 → 在 iPad 上输入 → 该设备浏览器获得长期会话,日常零登录
|
||||
- 角色隔离:孩子界面只有"打卡"和"自己的统计",**不存在任何管理入口**(不是禁用,是页面里根本没有)
|
||||
- 家长可在设备管理页注销某台设备
|
||||
|
||||
### 任务
|
||||
|
||||
- 每个孩子一份**周模板**(周一到周日各自的任务清单,任务可标记"需要拍照凭证")
|
||||
- 系统每天自动生成当日任务**快照**;家长可对某一天临时微调
|
||||
- 修改模板不影响历史记录,微调某一天也不污染模板
|
||||
|
||||
### 打卡
|
||||
|
||||
- 孩子逐项勾选完成;标记了"需凭证"的任务必须上传照片才能完成
|
||||
- 照片在设备端压缩后**直传阿里云对象存储**(不经服务器中转)
|
||||
- 一天完成至少 1 项任务 = 当日打卡成功
|
||||
|
||||
### 连击(连续天数)
|
||||
|
||||
- 连续打卡天数中断即清零
|
||||
- 周模板空白日(如故意不设任务的周日)**自动算达成**,不断连
|
||||
|
||||
### 补卡
|
||||
|
||||
- 孩子不能补卡
|
||||
- 家长可对过去的日期补卡,记录永久带"补卡"标记,与普通打卡可区分
|
||||
|
||||
### 家长页面(最小版)
|
||||
|
||||
- 仅"今日概览":每个孩子完成几项/共几项 + 当前连击天数
|
||||
- 无历史查询、无图表(第二期做)
|
||||
|
||||
## 三、技术与部署
|
||||
|
||||
| 层 | 方案 |
|
||||
| --- | --- |
|
||||
| 形态 | 网页应用;iPad 用 Safari"添加到主屏幕"当应用使用 |
|
||||
| 框架 | Next.js(TypeScript)+ Tailwind + shadcn/ui |
|
||||
| 数据库 | 复用服务器现有 MySQL,建 `superjump` 专用账号(非 root) |
|
||||
| 文件存储 | 阿里云对象存储,桶 `12301-test`(华东1杭州,与服务器同地域);私有读写 + 临时签名链接访问;大文件设备端直传 |
|
||||
| 部署 | 代码推送至 Gitea(`git.hn12301.net/xuchao/SuperJump`)→ 服务器拉取并本地构建镜像 → Docker Compose + Traefik 自动路由 |
|
||||
| 域名 | `jump.hn12301.net`,HTTPS 证书由 Traefik 自动签发续期 |
|
||||
|
||||
## 四、明确暂缓
|
||||
|
||||
- 打卡提醒推送(在家口头提醒即可)
|
||||
- 数据备份(与服务器整体备份策略同步,后期再定;照片已由对象存储三重冗余)
|
||||
|
||||
## 五、后续模块候选(顺序待定,第一期上线后再议)
|
||||
|
||||
1. **家长看板全量**:历史日历、完成率、趋势统计
|
||||
2. **试卷上传收集**:家长拍照上传,按孩子/科目/日期归档
|
||||
3. **学习内容**:视频、图文材料,分科目组织
|
||||
4. **检测**:题库、练习、自动判分
|
||||
|
||||
## 六、第一期里程碑
|
||||
|
||||
1. 数据库层:表结构 + 迁移 + 种子(徐家 + 家长 + 两个孩子)
|
||||
2. 认证:家长登录 + 设备激活 + 会话 + 角色中间件
|
||||
3. 打卡核心:任务快照生成、勾选、照片直传、连击计算、家长补卡
|
||||
4. 页面:孩子端今日任务页;家长端今日概览 + 周模板管理 + 设备管理
|
||||
5. 部署验收:`https://jump.hn12301.net` 全流程走通(登录→激活→打卡→传照片→家长查看)
|
||||
@@ -0,0 +1,14 @@
|
||||
import dotenv from "dotenv";
|
||||
import { defineConfig } from "drizzle-kit";
|
||||
|
||||
// 优先读本机开发配置 .env.local,其次 .env
|
||||
dotenv.config({ path: [".env.local", ".env"] });
|
||||
|
||||
export default defineConfig({
|
||||
dialect: "mysql",
|
||||
schema: "./src/db/schema.ts",
|
||||
out: "./drizzle",
|
||||
dbCredentials: {
|
||||
url: process.env.DATABASE_URL!,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,87 @@
|
||||
CREATE TABLE `activation_code` (
|
||||
`id` bigint AUTO_INCREMENT NOT NULL,
|
||||
`child_user_id` bigint NOT NULL,
|
||||
`code_hash` varchar(64) NOT NULL,
|
||||
`created_by` bigint NOT NULL,
|
||||
`created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`expires_at` datetime NOT NULL,
|
||||
`used_at` datetime,
|
||||
CONSTRAINT `activation_code_id` PRIMARY KEY(`id`)
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `family` (
|
||||
`id` bigint AUTO_INCREMENT NOT NULL,
|
||||
`name` varchar(50) NOT NULL,
|
||||
`created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT `family_id` PRIMARY KEY(`id`)
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `photo` (
|
||||
`id` bigint AUTO_INCREMENT NOT NULL,
|
||||
`task_instance_id` bigint NOT NULL,
|
||||
`oss_key` varchar(300) NOT NULL,
|
||||
`created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT `photo_id` PRIMARY KEY(`id`)
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `session` (
|
||||
`id` bigint AUTO_INCREMENT NOT NULL,
|
||||
`user_id` bigint NOT NULL,
|
||||
`token_hash` varchar(64) NOT NULL,
|
||||
`device_label` varchar(100),
|
||||
`created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`expires_at` datetime NOT NULL,
|
||||
CONSTRAINT `session_id` PRIMARY KEY(`id`),
|
||||
CONSTRAINT `uq_session_token` UNIQUE(`token_hash`)
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `task_instance` (
|
||||
`id` bigint AUTO_INCREMENT NOT NULL,
|
||||
`child_user_id` bigint NOT NULL,
|
||||
`date` date NOT NULL,
|
||||
`title` varchar(100) NOT NULL,
|
||||
`requires_photo` boolean NOT NULL DEFAULT false,
|
||||
`status` enum('pending','done') NOT NULL DEFAULT 'pending',
|
||||
`is_makeup` boolean NOT NULL DEFAULT false,
|
||||
`done_at` datetime,
|
||||
`sort_order` int NOT NULL DEFAULT 0,
|
||||
`source_template_id` bigint,
|
||||
`created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT `task_instance_id` PRIMARY KEY(`id`)
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `task_template` (
|
||||
`id` bigint AUTO_INCREMENT NOT NULL,
|
||||
`child_user_id` bigint NOT NULL,
|
||||
`dow` int NOT NULL,
|
||||
`title` varchar(100) NOT NULL,
|
||||
`requires_photo` boolean NOT NULL DEFAULT false,
|
||||
`sort_order` int NOT NULL DEFAULT 0,
|
||||
`created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT `task_template_id` PRIMARY KEY(`id`)
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `user` (
|
||||
`id` bigint AUTO_INCREMENT NOT NULL,
|
||||
`family_id` bigint NOT NULL,
|
||||
`role` enum('parent','child') NOT NULL,
|
||||
`display_name` varchar(50) NOT NULL,
|
||||
`phone` varchar(20),
|
||||
`password_hash` varchar(100),
|
||||
`sort_order` int NOT NULL DEFAULT 0,
|
||||
`created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT `user_id` PRIMARY KEY(`id`),
|
||||
CONSTRAINT `uq_user_phone` UNIQUE(`phone`)
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE `activation_code` ADD CONSTRAINT `activation_code_child_user_id_user_id_fk` FOREIGN KEY (`child_user_id`) REFERENCES `user`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE `activation_code` ADD CONSTRAINT `activation_code_created_by_user_id_fk` FOREIGN KEY (`created_by`) REFERENCES `user`(`id`) ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE `photo` ADD CONSTRAINT `photo_task_instance_id_task_instance_id_fk` FOREIGN KEY (`task_instance_id`) REFERENCES `task_instance`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE `session` ADD CONSTRAINT `session_user_id_user_id_fk` FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE `task_instance` ADD CONSTRAINT `task_instance_child_user_id_user_id_fk` FOREIGN KEY (`child_user_id`) REFERENCES `user`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE `task_template` ADD CONSTRAINT `task_template_child_user_id_user_id_fk` FOREIGN KEY (`child_user_id`) REFERENCES `user`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE `user` ADD CONSTRAINT `user_family_id_family_id_fk` FOREIGN KEY (`family_id`) REFERENCES `family`(`id`) ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE INDEX `idx_photo_instance` ON `photo` (`task_instance_id`);--> statement-breakpoint
|
||||
CREATE INDEX `idx_session_user` ON `session` (`user_id`);--> statement-breakpoint
|
||||
CREATE INDEX `idx_instance_child_date` ON `task_instance` (`child_user_id`,`date`);--> statement-breakpoint
|
||||
CREATE INDEX `idx_template_child_dow` ON `task_template` (`child_user_id`,`dow`);
|
||||
@@ -0,0 +1,619 @@
|
||||
{
|
||||
"version": "5",
|
||||
"dialect": "mysql",
|
||||
"id": "a044f535-beea-4fe6-8db5-3ae0019977de",
|
||||
"prevId": "00000000-0000-0000-0000-000000000000",
|
||||
"tables": {
|
||||
"activation_code": {
|
||||
"name": "activation_code",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "bigint",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": true
|
||||
},
|
||||
"child_user_id": {
|
||||
"name": "child_user_id",
|
||||
"type": "bigint",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"code_hash": {
|
||||
"name": "code_hash",
|
||||
"type": "varchar(64)",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_by": {
|
||||
"name": "created_by",
|
||||
"type": "bigint",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "datetime",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "CURRENT_TIMESTAMP"
|
||||
},
|
||||
"expires_at": {
|
||||
"name": "expires_at",
|
||||
"type": "datetime",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"used_at": {
|
||||
"name": "used_at",
|
||||
"type": "datetime",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"activation_code_child_user_id_user_id_fk": {
|
||||
"name": "activation_code_child_user_id_user_id_fk",
|
||||
"tableFrom": "activation_code",
|
||||
"tableTo": "user",
|
||||
"columnsFrom": [
|
||||
"child_user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"activation_code_created_by_user_id_fk": {
|
||||
"name": "activation_code_created_by_user_id_fk",
|
||||
"tableFrom": "activation_code",
|
||||
"tableTo": "user",
|
||||
"columnsFrom": [
|
||||
"created_by"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {
|
||||
"activation_code_id": {
|
||||
"name": "activation_code_id",
|
||||
"columns": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraint": {}
|
||||
},
|
||||
"family": {
|
||||
"name": "family",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "bigint",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": true
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "varchar(50)",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "datetime",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "CURRENT_TIMESTAMP"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {
|
||||
"family_id": {
|
||||
"name": "family_id",
|
||||
"columns": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraint": {}
|
||||
},
|
||||
"photo": {
|
||||
"name": "photo",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "bigint",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": true
|
||||
},
|
||||
"task_instance_id": {
|
||||
"name": "task_instance_id",
|
||||
"type": "bigint",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"oss_key": {
|
||||
"name": "oss_key",
|
||||
"type": "varchar(300)",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "datetime",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "CURRENT_TIMESTAMP"
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"idx_photo_instance": {
|
||||
"name": "idx_photo_instance",
|
||||
"columns": [
|
||||
"task_instance_id"
|
||||
],
|
||||
"isUnique": false
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"photo_task_instance_id_task_instance_id_fk": {
|
||||
"name": "photo_task_instance_id_task_instance_id_fk",
|
||||
"tableFrom": "photo",
|
||||
"tableTo": "task_instance",
|
||||
"columnsFrom": [
|
||||
"task_instance_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {
|
||||
"photo_id": {
|
||||
"name": "photo_id",
|
||||
"columns": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraint": {}
|
||||
},
|
||||
"session": {
|
||||
"name": "session",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "bigint",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": true
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "bigint",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"token_hash": {
|
||||
"name": "token_hash",
|
||||
"type": "varchar(64)",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"device_label": {
|
||||
"name": "device_label",
|
||||
"type": "varchar(100)",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "datetime",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "CURRENT_TIMESTAMP"
|
||||
},
|
||||
"expires_at": {
|
||||
"name": "expires_at",
|
||||
"type": "datetime",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"uq_session_token": {
|
||||
"name": "uq_session_token",
|
||||
"columns": [
|
||||
"token_hash"
|
||||
],
|
||||
"isUnique": true
|
||||
},
|
||||
"idx_session_user": {
|
||||
"name": "idx_session_user",
|
||||
"columns": [
|
||||
"user_id"
|
||||
],
|
||||
"isUnique": false
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"session_user_id_user_id_fk": {
|
||||
"name": "session_user_id_user_id_fk",
|
||||
"tableFrom": "session",
|
||||
"tableTo": "user",
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {
|
||||
"session_id": {
|
||||
"name": "session_id",
|
||||
"columns": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraint": {}
|
||||
},
|
||||
"task_instance": {
|
||||
"name": "task_instance",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "bigint",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": true
|
||||
},
|
||||
"child_user_id": {
|
||||
"name": "child_user_id",
|
||||
"type": "bigint",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"date": {
|
||||
"name": "date",
|
||||
"type": "date",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"title": {
|
||||
"name": "title",
|
||||
"type": "varchar(100)",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"requires_photo": {
|
||||
"name": "requires_photo",
|
||||
"type": "boolean",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": false
|
||||
},
|
||||
"status": {
|
||||
"name": "status",
|
||||
"type": "enum('pending','done')",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'pending'"
|
||||
},
|
||||
"is_makeup": {
|
||||
"name": "is_makeup",
|
||||
"type": "boolean",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": false
|
||||
},
|
||||
"done_at": {
|
||||
"name": "done_at",
|
||||
"type": "datetime",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"sort_order": {
|
||||
"name": "sort_order",
|
||||
"type": "int",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": 0
|
||||
},
|
||||
"source_template_id": {
|
||||
"name": "source_template_id",
|
||||
"type": "bigint",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "datetime",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "CURRENT_TIMESTAMP"
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"idx_instance_child_date": {
|
||||
"name": "idx_instance_child_date",
|
||||
"columns": [
|
||||
"child_user_id",
|
||||
"date"
|
||||
],
|
||||
"isUnique": false
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"task_instance_child_user_id_user_id_fk": {
|
||||
"name": "task_instance_child_user_id_user_id_fk",
|
||||
"tableFrom": "task_instance",
|
||||
"tableTo": "user",
|
||||
"columnsFrom": [
|
||||
"child_user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {
|
||||
"task_instance_id": {
|
||||
"name": "task_instance_id",
|
||||
"columns": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraint": {}
|
||||
},
|
||||
"task_template": {
|
||||
"name": "task_template",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "bigint",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": true
|
||||
},
|
||||
"child_user_id": {
|
||||
"name": "child_user_id",
|
||||
"type": "bigint",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"dow": {
|
||||
"name": "dow",
|
||||
"type": "int",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"title": {
|
||||
"name": "title",
|
||||
"type": "varchar(100)",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"requires_photo": {
|
||||
"name": "requires_photo",
|
||||
"type": "boolean",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": false
|
||||
},
|
||||
"sort_order": {
|
||||
"name": "sort_order",
|
||||
"type": "int",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": 0
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "datetime",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "CURRENT_TIMESTAMP"
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"idx_template_child_dow": {
|
||||
"name": "idx_template_child_dow",
|
||||
"columns": [
|
||||
"child_user_id",
|
||||
"dow"
|
||||
],
|
||||
"isUnique": false
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"task_template_child_user_id_user_id_fk": {
|
||||
"name": "task_template_child_user_id_user_id_fk",
|
||||
"tableFrom": "task_template",
|
||||
"tableTo": "user",
|
||||
"columnsFrom": [
|
||||
"child_user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {
|
||||
"task_template_id": {
|
||||
"name": "task_template_id",
|
||||
"columns": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraint": {}
|
||||
},
|
||||
"user": {
|
||||
"name": "user",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "bigint",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": true
|
||||
},
|
||||
"family_id": {
|
||||
"name": "family_id",
|
||||
"type": "bigint",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"role": {
|
||||
"name": "role",
|
||||
"type": "enum('parent','child')",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"display_name": {
|
||||
"name": "display_name",
|
||||
"type": "varchar(50)",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"phone": {
|
||||
"name": "phone",
|
||||
"type": "varchar(20)",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"password_hash": {
|
||||
"name": "password_hash",
|
||||
"type": "varchar(100)",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"sort_order": {
|
||||
"name": "sort_order",
|
||||
"type": "int",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": 0
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "datetime",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "CURRENT_TIMESTAMP"
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"uq_user_phone": {
|
||||
"name": "uq_user_phone",
|
||||
"columns": [
|
||||
"phone"
|
||||
],
|
||||
"isUnique": true
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"user_family_id_family_id_fk": {
|
||||
"name": "user_family_id_family_id_fk",
|
||||
"tableFrom": "user",
|
||||
"tableTo": "family",
|
||||
"columnsFrom": [
|
||||
"family_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"columns": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraint": {}
|
||||
}
|
||||
},
|
||||
"views": {},
|
||||
"_meta": {
|
||||
"schemas": {},
|
||||
"tables": {},
|
||||
"columns": {}
|
||||
},
|
||||
"internal": {
|
||||
"tables": {},
|
||||
"indexes": {}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"version": "7",
|
||||
"dialect": "mysql",
|
||||
"entries": [
|
||||
{
|
||||
"idx": 0,
|
||||
"version": "5",
|
||||
"when": 1785682170922,
|
||||
"tag": "0000_init",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
// 独立输出模式:构建产物自带精简 node_modules,供容器部署使用
|
||||
output: "standalone",
|
||||
eslint: { ignoreDuringBuilds: true },
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
Generated
+5350
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"name": "superjump",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"db:generate": "drizzle-kit generate",
|
||||
"db:migrate": "drizzle-kit migrate",
|
||||
"db:push": "drizzle-kit push",
|
||||
"db:seed": "tsx scripts/seed.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"bcryptjs": "^3.0.2",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"drizzle-orm": "^0.44.2",
|
||||
"lucide-react": "^0.525.0",
|
||||
"mysql2": "^3.14.1",
|
||||
"next": "^15.5.0",
|
||||
"next-themes": "^0.4.6",
|
||||
"radix-ui": "^1.6.7",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"sonner": "^2.0.5",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
"zod": "^3.25.67"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4.1.11",
|
||||
"@types/node": "^24.0.0",
|
||||
"@types/react": "^19.1.8",
|
||||
"@types/react-dom": "^19.1.6",
|
||||
"dotenv": "^17.2.0",
|
||||
"drizzle-kit": "^0.31.4",
|
||||
"tailwindcss": "^4.1.11",
|
||||
"tsx": "^4.20.3",
|
||||
"typescript": "^5.8.3"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export default {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,19 @@
|
||||
// 容器启动时执行:按顺序应用 drizzle/ 下尚未执行的迁移
|
||||
// 仅依赖生产 node_modules 中的 drizzle-orm 与 mysql2(standalone 产物自带)
|
||||
import { drizzle } from "drizzle-orm/mysql2";
|
||||
import { migrate } from "drizzle-orm/mysql2/migrator";
|
||||
import mysql from "mysql2/promise";
|
||||
|
||||
const url = process.env.DATABASE_URL;
|
||||
if (!url) {
|
||||
console.error("缺少 DATABASE_URL 环境变量");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const connection = await mysql.createConnection(url);
|
||||
try {
|
||||
await migrate(drizzle(connection), { migrationsFolder: "/app/drizzle" });
|
||||
console.log("数据库迁移完成");
|
||||
} finally {
|
||||
await connection.end();
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
// 种子数据:徐家 + 家长账号 + 两个孩子。可重复执行,已有数据则跳过。
|
||||
import dotenv from "dotenv";
|
||||
dotenv.config({ path: [".env.local", ".env"] });
|
||||
import bcrypt from "bcryptjs";
|
||||
import { drizzle } from "drizzle-orm/mysql2";
|
||||
import { eq } from "drizzle-orm";
|
||||
import mysql from "mysql2/promise";
|
||||
import { families, users } from "../src/db/schema";
|
||||
|
||||
const url = process.env.DATABASE_URL;
|
||||
if (!url) throw new Error("缺少 DATABASE_URL");
|
||||
|
||||
async function main(url: string) {
|
||||
const connection = await mysql.createConnection(url);
|
||||
const db = drizzle(connection);
|
||||
|
||||
try {
|
||||
const [existingParent] = await db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq(users.phone, "18684839400"))
|
||||
.limit(1);
|
||||
|
||||
if (existingParent) {
|
||||
console.log("种子数据已存在,跳过");
|
||||
} else {
|
||||
const [family] = await db
|
||||
.insert(families)
|
||||
.values({ name: "徐家" })
|
||||
.$returningId();
|
||||
|
||||
const passwordHash = await bcrypt.hash("123456Aa", 10);
|
||||
await db.insert(users).values({
|
||||
familyId: family.id,
|
||||
role: "parent",
|
||||
displayName: "家长",
|
||||
phone: "18684839400",
|
||||
passwordHash,
|
||||
});
|
||||
|
||||
await db.insert(users).values([
|
||||
{ familyId: family.id, role: "child", displayName: "徐卿玮", sortOrder: 0 },
|
||||
{ familyId: family.id, role: "child", displayName: "徐子惠", sortOrder: 1 },
|
||||
]);
|
||||
|
||||
console.log("种子数据写入完成:徐家 + 家长 + 徐卿玮 + 徐子惠");
|
||||
}
|
||||
} finally {
|
||||
await connection.end();
|
||||
}
|
||||
}
|
||||
|
||||
main(url);
|
||||
@@ -0,0 +1,59 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
@theme inline {
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--color-card: var(--card);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-border: var(--border);
|
||||
--color-input: var(--input);
|
||||
--color-ring: var(--ring);
|
||||
--radius-sm: calc(var(--radius) - 4px);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) + 4px);
|
||||
}
|
||||
|
||||
:root {
|
||||
--radius: 0.625rem;
|
||||
--background: oklch(1 0 0);
|
||||
--foreground: oklch(0.145 0 0);
|
||||
--card: oklch(1 0 0);
|
||||
--card-foreground: oklch(0.145 0 0);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.145 0 0);
|
||||
--primary: oklch(0.205 0 0);
|
||||
--primary-foreground: oklch(0.985 0 0);
|
||||
--secondary: oklch(0.97 0 0);
|
||||
--secondary-foreground: oklch(0.205 0 0);
|
||||
--muted: oklch(0.97 0 0);
|
||||
--muted-foreground: oklch(0.556 0 0);
|
||||
--accent: oklch(0.97 0 0);
|
||||
--accent-foreground: oklch(0.205 0 0);
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--border: oklch(0.922 0 0);
|
||||
--input: oklch(0.922 0 0);
|
||||
--ring: oklch(0.708 0 0);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
"use server";
|
||||
|
||||
import crypto from "node:crypto";
|
||||
import { and, eq, gt, isNull } from "drizzle-orm";
|
||||
import { redirect } from "next/navigation";
|
||||
import { db } from "@/db";
|
||||
import { activationCodes, users } from "@/db/schema";
|
||||
import { createSession } from "@/lib/auth";
|
||||
|
||||
function hashCode(code: string) {
|
||||
return crypto.createHash("sha256").update(code).digest("hex");
|
||||
}
|
||||
|
||||
export type ActivateState = { error?: string } | null;
|
||||
|
||||
// 孩子设备激活:输入 6 位激活码 → 绑定该设备为对应孩子的长期会话(2 年)
|
||||
export async function activate(
|
||||
_prev: ActivateState,
|
||||
formData: FormData,
|
||||
): Promise<ActivateState> {
|
||||
const code = String(formData.get("code") ?? "").trim();
|
||||
if (!/^\d{6}$/.test(code)) return { error: "激活码是 6 位数字" };
|
||||
|
||||
const [row] = await db
|
||||
.select({ id: activationCodes.id, childUserId: activationCodes.childUserId })
|
||||
.from(activationCodes)
|
||||
.where(
|
||||
and(
|
||||
eq(activationCodes.codeHash, hashCode(code)),
|
||||
isNull(activationCodes.usedAt),
|
||||
gt(activationCodes.expiresAt, new Date()),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (!row) return { error: "激活码无效或已过期,请让家长重新生成" };
|
||||
|
||||
const [child] = await db
|
||||
.select({ id: users.id, displayName: users.displayName })
|
||||
.from(users)
|
||||
.where(eq(users.id, row.childUserId))
|
||||
.limit(1);
|
||||
if (!child) return { error: "激活码对应的孩子不存在" };
|
||||
|
||||
// 标记激活码已用,并建立 2 年的设备会话
|
||||
await db
|
||||
.update(activationCodes)
|
||||
.set({ usedAt: new Date() })
|
||||
.where(eq(activationCodes.id, row.id));
|
||||
await createSession(child.id, `${child.displayName} 的 iPad`, 730);
|
||||
|
||||
redirect("/kid");
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
"use client";
|
||||
|
||||
import { useActionState } from "react";
|
||||
import { activate } from "./actions";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
|
||||
export default function JoinPage() {
|
||||
const [state, formAction, pending] = useActionState(activate, null);
|
||||
|
||||
return (
|
||||
<main className="flex min-h-dvh items-center justify-center bg-muted/40 p-4">
|
||||
<Card className="w-full max-w-sm">
|
||||
<CardHeader className="text-center">
|
||||
<CardTitle className="text-2xl">激活这台设备</CardTitle>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
请让家长在家长端生成激活码,然后输入下面的框里
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form action={formAction} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="code">6 位激活码</Label>
|
||||
<Input
|
||||
id="code"
|
||||
name="code"
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
pattern="\d{6}"
|
||||
maxLength={6}
|
||||
autoComplete="one-time-code"
|
||||
className="text-center text-3xl tracking-[0.5em] font-bold"
|
||||
placeholder="······"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
{state?.error && (
|
||||
<p className="text-sm font-medium text-destructive">{state.error}</p>
|
||||
)}
|
||||
<Button type="submit" className="w-full" size="lg" disabled={pending}>
|
||||
{pending ? "激活中…" : "激活"}
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
"use server";
|
||||
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { db } from "@/db";
|
||||
import { photos, taskInstances } from "@/db/schema";
|
||||
import { todayStr } from "@/lib/dates";
|
||||
import { photoKey, uploadUrl } from "@/lib/oss";
|
||||
import { getSessionUser } from "@/lib/auth";
|
||||
|
||||
// 取当前孩子会话,非法则抛错(客户端会显示)
|
||||
async function requireChild() {
|
||||
const user = await getSessionUser();
|
||||
if (!user || user.role !== "child") throw new Error("会话无效,请重新激活设备");
|
||||
return user;
|
||||
}
|
||||
|
||||
async function ownedTask(childId: number, instanceId: number) {
|
||||
const [task] = await db
|
||||
.select()
|
||||
.from(taskInstances)
|
||||
.where(and(eq(taskInstances.id, instanceId), eq(taskInstances.childUserId, childId)))
|
||||
.limit(1);
|
||||
if (!task) throw new Error("任务不存在");
|
||||
return task;
|
||||
}
|
||||
|
||||
// 勾选 / 取消勾选(仅限今天;需凭证的任务必须有照片才能勾)
|
||||
export async function toggleTask(instanceId: number, done: boolean) {
|
||||
const child = await requireChild();
|
||||
const task = await ownedTask(child.id, instanceId);
|
||||
if (task.date !== todayStr()) throw new Error("只能给今天的任务打卡");
|
||||
|
||||
if (done && task.requiresPhoto) {
|
||||
const [photo] = await db
|
||||
.select({ id: photos.id })
|
||||
.from(photos)
|
||||
.where(eq(photos.taskInstanceId, instanceId))
|
||||
.limit(1);
|
||||
if (!photo) throw new Error("这个任务要先拍照凭证哦");
|
||||
}
|
||||
|
||||
await db
|
||||
.update(taskInstances)
|
||||
.set({ status: done ? "done" : "pending", doneAt: done ? new Date() : null })
|
||||
.where(eq(taskInstances.id, instanceId));
|
||||
|
||||
revalidatePath("/kid");
|
||||
}
|
||||
|
||||
// 签发照片直传地址(浏览器 PUT 到对象存储)
|
||||
export async function getUploadUrl(instanceId: number) {
|
||||
const child = await requireChild();
|
||||
const task = await ownedTask(child.id, instanceId);
|
||||
const count = await db
|
||||
.select({ id: photos.id })
|
||||
.from(photos)
|
||||
.where(eq(photos.taskInstanceId, instanceId));
|
||||
if (count.length >= 3) throw new Error("最多上传 3 张照片");
|
||||
|
||||
const key = photoKey(child.id, task.date);
|
||||
return { url: uploadUrl(key, "image/jpeg"), key };
|
||||
}
|
||||
|
||||
// 直传完成后登记照片
|
||||
export async function confirmPhoto(instanceId: number, key: string) {
|
||||
const child = await requireChild();
|
||||
await ownedTask(child.id, instanceId);
|
||||
if (!key.startsWith(`photos/${child.id}/`)) throw new Error("照片路径非法");
|
||||
await db.insert(photos).values({ taskInstanceId: instanceId, ossKey: key });
|
||||
revalidatePath("/kid");
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { Flame } from "lucide-react";
|
||||
import { redirect } from "next/navigation";
|
||||
import { todayStr } from "@/lib/dates";
|
||||
import { getSessionUser } from "@/lib/auth";
|
||||
import { streakOf } from "@/lib/streak";
|
||||
import { dayTasks } from "@/lib/tasks";
|
||||
import { TaskCard } from "./task-card";
|
||||
|
||||
export default async function KidPage() {
|
||||
const user = await getSessionUser();
|
||||
if (!user) redirect("/join");
|
||||
if (user.role !== "child") redirect("/parent");
|
||||
|
||||
const today = todayStr();
|
||||
const [tasks, streak] = await Promise.all([
|
||||
dayTasks(user.id, today),
|
||||
streakOf(user.id),
|
||||
]);
|
||||
const doneCount = tasks.filter((t) => t.status === "done").length;
|
||||
const allDone = tasks.length > 0 && doneCount === tasks.length;
|
||||
|
||||
return (
|
||||
<main className="mx-auto max-w-lg space-y-5 p-4 pb-10">
|
||||
<header className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">你好,{user.displayName}</h1>
|
||||
<p className="text-sm text-muted-foreground">{today}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 rounded-full bg-orange-100 px-4 py-2 text-orange-600">
|
||||
<Flame className="size-6" fill="currentColor" />
|
||||
<span className="text-xl font-bold">{streak}</span>
|
||||
<span className="text-sm">天</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{tasks.length === 0 ? (
|
||||
<div className="rounded-2xl bg-muted p-8 text-center">
|
||||
<p className="text-2xl">🎉</p>
|
||||
<p className="mt-2 text-lg font-medium">今天没有任务</p>
|
||||
<p className="mt-1 text-sm text-muted-foreground">连击自动保持,去玩吧!</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
已完成 {doneCount} / {tasks.length} 项
|
||||
</p>
|
||||
<div className="space-y-3">
|
||||
{tasks.map((t) => (
|
||||
<TaskCard key={t.id} task={t} />
|
||||
))}
|
||||
</div>
|
||||
{allDone && (
|
||||
<div className="rounded-2xl bg-green-100 p-6 text-center">
|
||||
<p className="text-2xl">💪</p>
|
||||
<p className="mt-2 text-lg font-bold text-green-700">今天全部完成,太棒了!</p>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useState, useTransition } from "react";
|
||||
import { Camera, Check } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { confirmPhoto, getUploadUrl, toggleTask } from "./actions";
|
||||
|
||||
// 客户端压缩:最长边压到 1600 像素,JPEG 质量 0.8
|
||||
async function compress(file: File): Promise<Blob> {
|
||||
const bitmap = await createImageBitmap(file);
|
||||
const max = 1600;
|
||||
const scale = Math.min(1, max / Math.max(bitmap.width, bitmap.height));
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = Math.round(bitmap.width * scale);
|
||||
canvas.height = Math.round(bitmap.height * scale);
|
||||
canvas.getContext("2d")!.drawImage(bitmap, 0, 0, canvas.width, canvas.height);
|
||||
bitmap.close();
|
||||
return new Promise((resolve, reject) =>
|
||||
canvas.toBlob((b) => (b ? resolve(b) : reject(new Error("压缩失败"))), "image/jpeg", 0.8),
|
||||
);
|
||||
}
|
||||
|
||||
export function TaskCard({
|
||||
task,
|
||||
}: {
|
||||
task: {
|
||||
id: number;
|
||||
title: string;
|
||||
requiresPhoto: boolean;
|
||||
status: "pending" | "done";
|
||||
isMakeup: boolean;
|
||||
photoUrls: string[];
|
||||
};
|
||||
}) {
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
const [busy, startTransition] = useTransition();
|
||||
const [error, setError] = useState("");
|
||||
const done = task.status === "done";
|
||||
const canCheck = !task.requiresPhoto || task.photoUrls.length > 0;
|
||||
|
||||
function onToggle() {
|
||||
setError("");
|
||||
if (!done && !canCheck) {
|
||||
setError("先拍张照片当凭证吧");
|
||||
return;
|
||||
}
|
||||
startTransition(async () => {
|
||||
try {
|
||||
await toggleTask(task.id, !done);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "操作失败");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function onPickFile(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
const file = e.target.files?.[0];
|
||||
e.target.value = "";
|
||||
if (!file) return;
|
||||
setError("");
|
||||
startTransition(async () => {
|
||||
try {
|
||||
const blob = await compress(file);
|
||||
const { url, key } = await getUploadUrl(task.id);
|
||||
const res = await fetch(url, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "image/jpeg" },
|
||||
body: blob,
|
||||
});
|
||||
if (!res.ok) throw new Error(`上传失败(${res.status})`);
|
||||
await confirmPhoto(task.id, key);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "上传失败");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`rounded-2xl border-2 p-4 transition-colors ${
|
||||
done ? "border-green-500 bg-green-50" : "border-border bg-card"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggle}
|
||||
disabled={busy}
|
||||
aria-label={done ? "取消完成" : "标记完成"}
|
||||
className={`flex size-12 shrink-0 items-center justify-center rounded-full border-2 transition-colors ${
|
||||
done ? "border-green-500 bg-green-500 text-white" : "border-muted-foreground/40"
|
||||
}`}
|
||||
>
|
||||
{done && <Check className="size-7" strokeWidth={3} />}
|
||||
</button>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className={`text-lg font-medium ${done ? "text-green-700 line-through" : ""}`}>
|
||||
{task.title}
|
||||
</p>
|
||||
<div className="mt-1 flex items-center gap-2 text-sm text-muted-foreground">
|
||||
{task.requiresPhoto && <Camera className="size-4" />}
|
||||
{task.requiresPhoto && <span>需拍照凭证</span>}
|
||||
{task.isMakeup && <span className="text-orange-500">补卡</span>}
|
||||
</div>
|
||||
</div>
|
||||
{task.requiresPhoto && task.photoUrls.length < 3 && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={busy}
|
||||
onClick={() => fileRef.current?.click()}
|
||||
>
|
||||
<Camera className="size-4" />
|
||||
拍照
|
||||
</Button>
|
||||
)}
|
||||
<input
|
||||
ref={fileRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
onChange={onPickFile}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{task.photoUrls.length > 0 && (
|
||||
<div className="mt-3 flex gap-2">
|
||||
{task.photoUrls.map((url) => (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
key={url}
|
||||
src={url}
|
||||
alt="凭证照片"
|
||||
className="size-16 rounded-lg border object-cover"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && <p className="mt-2 text-sm font-medium text-destructive">{error}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { Metadata, Viewport } from "next";
|
||||
import "./globals.css";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "SuperJump",
|
||||
description: "家庭自学打卡",
|
||||
};
|
||||
|
||||
export const viewport: Viewport = {
|
||||
width: "device-width",
|
||||
initialScale: 1,
|
||||
maximumScale: 1,
|
||||
viewportFit: "cover",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{ children: React.ReactNode }>) {
|
||||
return (
|
||||
// 系统字体栈,不加载任何网络字体(避免构建与访问时请求境外服务)
|
||||
<html lang="zh-CN">
|
||||
<body className="min-h-dvh antialiased">{children}</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
"use server";
|
||||
|
||||
import bcrypt from "bcryptjs";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { redirect } from "next/navigation";
|
||||
import { z } from "zod";
|
||||
import { db } from "@/db";
|
||||
import { users } from "@/db/schema";
|
||||
import { createSession, destroySession } from "@/lib/auth";
|
||||
|
||||
const loginSchema = z.object({
|
||||
phone: z.string().regex(/^1\d{10}$/, "请输入 11 位手机号"),
|
||||
password: z.string().min(6, "密码至少 6 位"),
|
||||
});
|
||||
|
||||
export type LoginState = { error?: string } | null;
|
||||
|
||||
// 家长登录:校验通过后建立 30 天会话并跳转家长端
|
||||
export async function login(_prev: LoginState, formData: FormData): Promise<LoginState> {
|
||||
const parsed = loginSchema.safeParse({
|
||||
phone: formData.get("phone"),
|
||||
password: formData.get("password"),
|
||||
});
|
||||
if (!parsed.success) return { error: parsed.error.issues[0].message };
|
||||
|
||||
const [user] = await db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(and(eq(users.phone, parsed.data.phone), eq(users.role, "parent")))
|
||||
.limit(1);
|
||||
|
||||
// 统一报错文案,不暴露"手机号是否存在"
|
||||
if (!user?.passwordHash) return { error: "手机号或密码错误" };
|
||||
const ok = await bcrypt.compare(parsed.data.password, user.passwordHash);
|
||||
if (!ok) return { error: "手机号或密码错误" };
|
||||
|
||||
await createSession(user.id, "家长设备", 30);
|
||||
redirect("/parent");
|
||||
}
|
||||
|
||||
export async function logout() {
|
||||
await destroySession();
|
||||
redirect("/login");
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
"use client";
|
||||
|
||||
import { useActionState } from "react";
|
||||
import { login } from "./actions";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
|
||||
export default function LoginPage() {
|
||||
const [state, formAction, pending] = useActionState(login, null);
|
||||
|
||||
return (
|
||||
<main className="flex min-h-dvh items-center justify-center bg-muted/40 p-4">
|
||||
<Card className="w-full max-w-sm">
|
||||
<CardHeader className="text-center">
|
||||
<CardTitle className="text-3xl">SuperJump</CardTitle>
|
||||
<p className="text-sm text-muted-foreground">家长登录</p>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form action={formAction} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="phone">手机号</Label>
|
||||
<Input
|
||||
id="phone"
|
||||
name="phone"
|
||||
type="tel"
|
||||
inputMode="numeric"
|
||||
autoComplete="username"
|
||||
placeholder="11 位手机号"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password">密码</Label>
|
||||
<Input
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
{state?.error && (
|
||||
<p className="text-sm font-medium text-destructive">{state.error}</p>
|
||||
)}
|
||||
<Button type="submit" className="w-full" size="lg" disabled={pending}>
|
||||
{pending ? "登录中…" : "登录"}
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { getSessionUser } from "@/lib/auth";
|
||||
|
||||
// 根路由:按会话角色分发到对应入口
|
||||
export default async function Home() {
|
||||
const user = await getSessionUser();
|
||||
if (!user) redirect("/login");
|
||||
redirect(user.role === "parent" ? "/parent" : "/kid");
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
"use server";
|
||||
|
||||
import crypto from "node:crypto";
|
||||
import { and, eq, sql } from "drizzle-orm";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { z } from "zod";
|
||||
import { db } from "@/db";
|
||||
import { activationCodes, sessions, taskInstances, taskTemplates, users } from "@/db/schema";
|
||||
import { todayStr } from "@/lib/dates";
|
||||
import { getSessionUser } from "@/lib/auth";
|
||||
|
||||
function hashCode(code: string) {
|
||||
return crypto.createHash("sha256").update(code).digest("hex");
|
||||
}
|
||||
|
||||
export type CreateCodeState = {
|
||||
code?: string;
|
||||
childName?: string;
|
||||
error?: string;
|
||||
} | null;
|
||||
|
||||
// 家长为指定孩子生成一次性激活码(15 分钟有效)
|
||||
export async function createActivationCode(
|
||||
_prev: CreateCodeState,
|
||||
formData: FormData,
|
||||
): Promise<CreateCodeState> {
|
||||
const parent = await getSessionUser();
|
||||
if (!parent || parent.role !== "parent") return { error: "请先以家长身份登录" };
|
||||
|
||||
const childId = Number(formData.get("childId"));
|
||||
const [child] = await db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq(users.id, childId))
|
||||
.limit(1);
|
||||
if (!child || child.role !== "child" || child.familyId !== parent.familyId) {
|
||||
return { error: "孩子不存在" };
|
||||
}
|
||||
|
||||
const code = String(crypto.randomInt(0, 1_000_000)).padStart(6, "0");
|
||||
await db.insert(activationCodes).values({
|
||||
childUserId: child.id,
|
||||
codeHash: hashCode(code),
|
||||
createdBy: parent.id,
|
||||
expiresAt: new Date(Date.now() + 15 * 60_000),
|
||||
});
|
||||
|
||||
return { code, childName: child.displayName };
|
||||
}
|
||||
|
||||
// 家长补卡 / 撤销补卡:仅限过去的日期,补卡记录永久带标记
|
||||
export async function makeupTask(instanceId: number, done: boolean) {
|
||||
const parent = await getSessionUser();
|
||||
if (!parent || parent.role !== "parent") throw new Error("请先以家长身份登录");
|
||||
|
||||
const [row] = await db
|
||||
.select({ date: taskInstances.date, familyId: users.familyId })
|
||||
.from(taskInstances)
|
||||
.innerJoin(users, eq(users.id, taskInstances.childUserId))
|
||||
.where(eq(taskInstances.id, instanceId))
|
||||
.limit(1);
|
||||
|
||||
if (!row || row.familyId !== parent.familyId) throw new Error("任务不存在");
|
||||
if (row.date >= todayStr()) throw new Error("只能对过去的日期补卡");
|
||||
|
||||
await db
|
||||
.update(taskInstances)
|
||||
.set({ status: done ? "done" : "pending", isMakeup: done, doneAt: done ? new Date() : null })
|
||||
.where(eq(taskInstances.id, instanceId));
|
||||
|
||||
revalidatePath("/parent");
|
||||
}
|
||||
|
||||
// ===== 周模板管理 =====
|
||||
|
||||
const templateSchema = z.object({
|
||||
childId: z.coerce.number().int().positive(),
|
||||
dow: z.coerce.number().int().min(0).max(6),
|
||||
title: z.string().trim().min(1, "任务内容不能为空").max(100, "最多 100 字"),
|
||||
requiresPhoto: z.boolean(),
|
||||
});
|
||||
|
||||
async function requireOwnerOfChild(childId: number) {
|
||||
const parent = await getSessionUser();
|
||||
if (!parent || parent.role !== "parent") throw new Error("请先以家长身份登录");
|
||||
const [child] = await db
|
||||
.select({ id: users.id })
|
||||
.from(users)
|
||||
.where(and(eq(users.id, childId), eq(users.familyId, parent.familyId), eq(users.role, "child")))
|
||||
.limit(1);
|
||||
if (!child) throw new Error("孩子不存在");
|
||||
return parent;
|
||||
}
|
||||
|
||||
// 添加模板任务(次日及以后生效,不改动已有历史)
|
||||
export async function addTemplate(formData: FormData) {
|
||||
const parsed = templateSchema.safeParse({
|
||||
childId: formData.get("childId"),
|
||||
dow: formData.get("dow"),
|
||||
title: formData.get("title"),
|
||||
requiresPhoto: formData.get("requiresPhoto") === "on",
|
||||
});
|
||||
if (!parsed.success) throw new Error(parsed.error.issues[0].message);
|
||||
await requireOwnerOfChild(parsed.data.childId);
|
||||
|
||||
const [maxRow] = await db
|
||||
.select({ maxSort: sql<number>`COALESCE(MAX(${taskTemplates.sortOrder}), -1)` })
|
||||
.from(taskTemplates)
|
||||
.where(
|
||||
and(
|
||||
eq(taskTemplates.childUserId, parsed.data.childId),
|
||||
eq(taskTemplates.dow, parsed.data.dow),
|
||||
),
|
||||
);
|
||||
|
||||
await db.insert(taskTemplates).values({
|
||||
childUserId: parsed.data.childId,
|
||||
dow: parsed.data.dow,
|
||||
title: parsed.data.title,
|
||||
requiresPhoto: parsed.data.requiresPhoto,
|
||||
sortOrder: maxRow.maxSort + 1,
|
||||
});
|
||||
revalidatePath("/parent");
|
||||
}
|
||||
|
||||
// 删除模板任务(同样不影响已生成的历史任务)
|
||||
export async function deleteTemplate(templateId: number) {
|
||||
const parent = await getSessionUser();
|
||||
if (!parent || parent.role !== "parent") throw new Error("请先以家长身份登录");
|
||||
const [row] = await db
|
||||
.select({ id: taskTemplates.id })
|
||||
.from(taskTemplates)
|
||||
.innerJoin(users, eq(users.id, taskTemplates.childUserId))
|
||||
.where(and(eq(taskTemplates.id, templateId), eq(users.familyId, parent.familyId)))
|
||||
.limit(1);
|
||||
if (!row) throw new Error("任务不存在");
|
||||
await db.delete(taskTemplates).where(eq(taskTemplates.id, templateId));
|
||||
revalidatePath("/parent");
|
||||
}
|
||||
|
||||
// ===== 设备管理 =====
|
||||
|
||||
// 注销某台设备的会话(孩子那台 iPad 下次打开需要重新激活)
|
||||
export async function revokeSession(sessionId: number) {
|
||||
const parent = await getSessionUser();
|
||||
if (!parent || parent.role !== "parent") throw new Error("请先以家长身份登录");
|
||||
const [row] = await db
|
||||
.select({ id: sessions.id })
|
||||
.from(sessions)
|
||||
.innerJoin(users, eq(users.id, sessions.userId))
|
||||
.where(and(eq(sessions.id, sessionId), eq(users.familyId, parent.familyId)))
|
||||
.limit(1);
|
||||
if (!row) throw new Error("会话不存在");
|
||||
await db.delete(sessions).where(eq(sessions.id, sessionId));
|
||||
revalidatePath("/parent");
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
"use client";
|
||||
|
||||
import { useActionState } from "react";
|
||||
import { createActivationCode, type CreateCodeState } from "./actions";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
|
||||
// 激活码生成卡片:点按钮生成,大号显示 6 位码,15 分钟有效
|
||||
export function ActivationCard({
|
||||
childId,
|
||||
childName,
|
||||
}: {
|
||||
childId: number;
|
||||
childName: string;
|
||||
}) {
|
||||
const [state, formAction, pending] = useActionState<CreateCodeState, FormData>(
|
||||
createActivationCode,
|
||||
null,
|
||||
);
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="flex items-center justify-between gap-4 p-4">
|
||||
<div>
|
||||
<p className="font-medium">{childName}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{state?.code
|
||||
? "激活码 15 分钟内有效,只用一次"
|
||||
: "在孩子的 iPad 上打开网站,输入激活码"}
|
||||
</p>
|
||||
</div>
|
||||
{state?.code ? (
|
||||
<span className="text-3xl font-bold tracking-[0.3em]">{state.code}</span>
|
||||
) : (
|
||||
<form action={formAction}>
|
||||
<input type="hidden" name="childId" value={childId} />
|
||||
<Button type="submit" disabled={pending}>
|
||||
{pending ? "生成中…" : "生成激活码"}
|
||||
</Button>
|
||||
</form>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
"use client";
|
||||
|
||||
import { logout } from "@/app/login/actions";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { DevicesTab } from "./devices-tab";
|
||||
import { OverviewTab } from "./overview-tab";
|
||||
import { TemplatesTab } from "./templates-tab";
|
||||
|
||||
export type OverviewData = {
|
||||
kids: {
|
||||
id: number;
|
||||
name: string;
|
||||
streak: number;
|
||||
today: { done: number; total: number };
|
||||
week: {
|
||||
date: string;
|
||||
isToday: boolean;
|
||||
tasks: { id: number; title: string; status: "pending" | "done"; isMakeup: boolean }[];
|
||||
}[];
|
||||
}[];
|
||||
};
|
||||
|
||||
export type TemplatesData = {
|
||||
kids: {
|
||||
id: number;
|
||||
name: string;
|
||||
items: { id: number; dow: number; title: string; requiresPhoto: boolean }[];
|
||||
}[];
|
||||
};
|
||||
|
||||
export type DevicesData = {
|
||||
rows: {
|
||||
id: number;
|
||||
owner: string;
|
||||
deviceLabel: string | null;
|
||||
createdAt: string;
|
||||
expiresAt: string;
|
||||
isCurrent: boolean;
|
||||
}[];
|
||||
};
|
||||
|
||||
export function ParentDashboard({
|
||||
overview,
|
||||
templates,
|
||||
devices,
|
||||
}: {
|
||||
overview: OverviewData;
|
||||
templates: TemplatesData;
|
||||
devices: DevicesData;
|
||||
}) {
|
||||
const kids = overview.kids.map((k) => ({ id: k.id, name: k.name }));
|
||||
|
||||
return (
|
||||
<main className="mx-auto max-w-2xl space-y-5 p-4 pb-10">
|
||||
<header className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold">家长端</h1>
|
||||
<form action={logout}>
|
||||
<Button type="submit" variant="outline" size="sm">
|
||||
退出登录
|
||||
</Button>
|
||||
</form>
|
||||
</header>
|
||||
|
||||
<Tabs defaultValue="overview">
|
||||
<TabsList className="grid w-full grid-cols-3">
|
||||
<TabsTrigger value="overview">今日概览</TabsTrigger>
|
||||
<TabsTrigger value="templates">任务模板</TabsTrigger>
|
||||
<TabsTrigger value="devices">设备管理</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="overview" className="mt-4">
|
||||
<OverviewTab data={overview} />
|
||||
</TabsContent>
|
||||
<TabsContent value="templates" className="mt-4">
|
||||
<TemplatesTab data={templates} />
|
||||
</TabsContent>
|
||||
<TabsContent value="devices" className="mt-4">
|
||||
<DevicesTab data={devices} kids={kids} />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
"use client";
|
||||
|
||||
import { useTransition } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { revokeSession } from "./actions";
|
||||
import { ActivationCard } from "./activation-card";
|
||||
import type { DevicesData } from "./dashboard";
|
||||
|
||||
export function DevicesTab({
|
||||
data,
|
||||
kids,
|
||||
}: {
|
||||
data: DevicesData;
|
||||
kids: { id: number; name: string }[];
|
||||
}) {
|
||||
const [, startTransition] = useTransition();
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-lg">新设备激活</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{kids.map((kid) => (
|
||||
<ActivationCard key={kid.id} childId={kid.id} childName={kid.name} />
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-lg">当前登录设备</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
{data.rows.map((row) => (
|
||||
<div
|
||||
key={row.id}
|
||||
className="flex items-center justify-between gap-3 rounded-lg border p-3"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<p className="font-medium">
|
||||
{row.owner}
|
||||
<span className="ml-2 text-sm font-normal text-muted-foreground">
|
||||
{row.deviceLabel ?? "未知设备"}
|
||||
</span>
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{row.createdAt} 激活 · 有效期至 {row.expiresAt}
|
||||
</p>
|
||||
</div>
|
||||
{row.isCurrent ? (
|
||||
<span className="shrink-0 text-xs text-muted-foreground">当前设备</span>
|
||||
) : (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="shrink-0 text-destructive"
|
||||
onClick={() => startTransition(() => revokeSession(row.id))}
|
||||
>
|
||||
注销
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
"use client";
|
||||
|
||||
import { useTransition } from "react";
|
||||
import { Flame } from "lucide-react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { makeupTask } from "./actions";
|
||||
import type { OverviewData } from "./dashboard";
|
||||
|
||||
const DOW_LABELS = ["周日", "周一", "周二", "周三", "周四", "周五", "周六"];
|
||||
|
||||
function dateLabel(date: string) {
|
||||
const [y, m, d] = date.split("-").map(Number);
|
||||
const dow = new Date(Date.UTC(y, m - 1, d)).getUTCDay();
|
||||
return `${m}月${d}日 ${DOW_LABELS[dow]}`;
|
||||
}
|
||||
|
||||
export function OverviewTab({ data }: { data: OverviewData }) {
|
||||
const [, startTransition] = useTransition();
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{data.kids.map((kid) => (
|
||||
<Card key={kid.id}>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-lg">{kid.name}</CardTitle>
|
||||
<div className="flex items-center gap-1 text-orange-600">
|
||||
<Flame className="size-5" fill="currentColor" />
|
||||
<span className="font-bold">{kid.streak}</span>
|
||||
<span className="text-xs">天连击</span>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
今日完成 {kid.today.done} / {kid.today.total} 项
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
{kid.week.map((day) => (
|
||||
<div key={day.date} className="rounded-lg border p-2">
|
||||
<p className="mb-1 text-xs font-medium text-muted-foreground">
|
||||
{dateLabel(day.date)}
|
||||
{day.isToday && " · 今天"}
|
||||
</p>
|
||||
{day.tasks.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground">无任务(自动保持连击)</p>
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{day.tasks.map((t) => (
|
||||
<span key={t.id} className="inline-flex items-center gap-1">
|
||||
<Badge variant={t.status === "done" ? "default" : "secondary"}>
|
||||
{t.title}
|
||||
{t.isMakeup && " · 补"}
|
||||
</Badge>
|
||||
{!day.isToday && t.status === "pending" && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-5 px-1.5 text-xs"
|
||||
onClick={() =>
|
||||
startTransition(() => makeupTask(t.id, true))
|
||||
}
|
||||
>
|
||||
补卡
|
||||
</Button>
|
||||
)}
|
||||
{!day.isToday && t.isMakeup && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-5 px-1.5 text-xs"
|
||||
onClick={() =>
|
||||
startTransition(() => makeupTask(t.id, false))
|
||||
}
|
||||
>
|
||||
撤销
|
||||
</Button>
|
||||
)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
import crypto from "node:crypto";
|
||||
import { and, asc, desc, eq, inArray } from "drizzle-orm";
|
||||
import { cookies } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
import { db } from "@/db";
|
||||
import { sessions, taskInstances, taskTemplates, users } from "@/db/schema";
|
||||
import { SESSION_COOKIE, getSessionUser } from "@/lib/auth";
|
||||
import { daysAgo, todayStr } from "@/lib/dates";
|
||||
import { ensureDay } from "@/lib/tasks";
|
||||
import { streakOf } from "@/lib/streak";
|
||||
import { ParentDashboard, type DevicesData, type OverviewData, type TemplatesData } from "./dashboard";
|
||||
|
||||
const SHANGHAI = { timeZone: "Asia/Shanghai" } as const;
|
||||
const fmtDateTime = (d: Date) =>
|
||||
d.toLocaleString("zh-CN", { ...SHANGHAI, hour12: false }).replace(/\//g, "-");
|
||||
|
||||
export default async function ParentPage() {
|
||||
const parent = await getSessionUser();
|
||||
if (!parent) redirect("/login");
|
||||
if (parent.role !== "parent") redirect("/kid");
|
||||
|
||||
const kids = await db
|
||||
.select({ id: users.id, name: users.displayName })
|
||||
.from(users)
|
||||
.where(and(eq(users.familyId, parent.familyId), eq(users.role, "child")))
|
||||
.orderBy(asc(users.sortOrder));
|
||||
|
||||
const today = todayStr();
|
||||
const weekDates = Array.from({ length: 7 }, (_, i) => daysAgo(i)); // 今天在前
|
||||
|
||||
// ===== 今日概览 =====
|
||||
const overview: OverviewData = { kids: [] };
|
||||
for (const kid of kids) {
|
||||
await ensureDay(kid.id, today); // 家长先看时也要生成今天的快照
|
||||
const [streak, rows] = await Promise.all([
|
||||
streakOf(kid.id),
|
||||
db
|
||||
.select({
|
||||
id: taskInstances.id,
|
||||
date: taskInstances.date,
|
||||
title: taskInstances.title,
|
||||
status: taskInstances.status,
|
||||
isMakeup: taskInstances.isMakeup,
|
||||
sortOrder: taskInstances.sortOrder,
|
||||
})
|
||||
.from(taskInstances)
|
||||
.where(and(eq(taskInstances.childUserId, kid.id), inArray(taskInstances.date, weekDates)))
|
||||
.orderBy(desc(taskInstances.date), asc(taskInstances.sortOrder)),
|
||||
]);
|
||||
|
||||
const week = weekDates.map((date) => ({
|
||||
date,
|
||||
isToday: date === today,
|
||||
tasks: rows.filter((r) => r.date === date),
|
||||
}));
|
||||
const todayTasks = week.find((w) => w.isToday)?.tasks ?? [];
|
||||
|
||||
overview.kids.push({
|
||||
id: kid.id,
|
||||
name: kid.name,
|
||||
streak,
|
||||
today: {
|
||||
total: todayTasks.length,
|
||||
done: todayTasks.filter((t) => t.status === "done").length,
|
||||
},
|
||||
week,
|
||||
});
|
||||
}
|
||||
|
||||
// ===== 周模板 =====
|
||||
const templateRows = kids.length
|
||||
? await db
|
||||
.select()
|
||||
.from(taskTemplates)
|
||||
.where(inArray(taskTemplates.childUserId, kids.map((k) => k.id)))
|
||||
.orderBy(asc(taskTemplates.childUserId), asc(taskTemplates.dow), asc(taskTemplates.sortOrder))
|
||||
: [];
|
||||
const templates: TemplatesData = {
|
||||
kids: kids.map((kid) => ({
|
||||
id: kid.id,
|
||||
name: kid.name,
|
||||
items: templateRows
|
||||
.filter((t) => t.childUserId === kid.id)
|
||||
.map((t) => ({
|
||||
id: t.id,
|
||||
dow: t.dow,
|
||||
title: t.title,
|
||||
requiresPhoto: t.requiresPhoto,
|
||||
})),
|
||||
})),
|
||||
};
|
||||
|
||||
// ===== 设备会话 =====
|
||||
const token = (await cookies()).get(SESSION_COOKIE)?.value;
|
||||
const tokenHash = token ? crypto.createHash("sha256").update(token).digest("hex") : null;
|
||||
const sessionRows = await db
|
||||
.select({
|
||||
id: sessions.id,
|
||||
tokenHash: sessions.tokenHash,
|
||||
deviceLabel: sessions.deviceLabel,
|
||||
createdAt: sessions.createdAt,
|
||||
expiresAt: sessions.expiresAt,
|
||||
owner: users.displayName,
|
||||
role: users.role,
|
||||
})
|
||||
.from(sessions)
|
||||
.innerJoin(users, eq(users.id, sessions.userId))
|
||||
.where(eq(users.familyId, parent.familyId))
|
||||
.orderBy(desc(sessions.createdAt));
|
||||
const devices: DevicesData = {
|
||||
rows: sessionRows.map((s) => ({
|
||||
id: s.id,
|
||||
owner: s.role === "parent" ? `${s.owner}(家长)` : s.owner,
|
||||
deviceLabel: s.deviceLabel,
|
||||
createdAt: fmtDateTime(s.createdAt),
|
||||
expiresAt: fmtDateTime(s.expiresAt),
|
||||
isCurrent: s.tokenHash === tokenHash,
|
||||
})),
|
||||
};
|
||||
|
||||
return <ParentDashboard overview={overview} templates={templates} devices={devices} />;
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useTransition } from "react";
|
||||
import { Trash2 } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { addTemplate, deleteTemplate } from "./actions";
|
||||
import type { TemplatesData } from "./dashboard";
|
||||
|
||||
const WEEK_ORDER = [1, 2, 3, 4, 5, 6, 0]; // 展示顺序:周一到周日
|
||||
const DOW_LABELS = ["周日", "周一", "周二", "周三", "周四", "周五", "周六"];
|
||||
|
||||
export function TemplatesTab({ data }: { data: TemplatesData }) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
模板改动从第二天起生效,不影响已生成的历史任务。
|
||||
</p>
|
||||
{data.kids.map((kid) => (
|
||||
<KidTemplateCard key={kid.id} kid={kid} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function KidTemplateCard({
|
||||
kid,
|
||||
}: {
|
||||
kid: TemplatesData["kids"][number];
|
||||
}) {
|
||||
const [dow, setDow] = useState(1);
|
||||
const [title, setTitle] = useState("");
|
||||
const [requiresPhoto, setRequiresPhoto] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [pending, startTransition] = useTransition();
|
||||
|
||||
function onAdd(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setError("");
|
||||
const fd = new FormData();
|
||||
fd.set("childId", String(kid.id));
|
||||
fd.set("dow", String(dow));
|
||||
fd.set("title", title);
|
||||
if (requiresPhoto) fd.set("requiresPhoto", "on");
|
||||
startTransition(async () => {
|
||||
try {
|
||||
await addTemplate(fd);
|
||||
setTitle("");
|
||||
setRequiresPhoto(false);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "添加失败");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-lg">{kid.name}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
{WEEK_ORDER.map((d) => {
|
||||
const items = kid.items.filter((i) => i.dow === d);
|
||||
return (
|
||||
<div key={d} className="flex items-start gap-3">
|
||||
<span className="w-10 shrink-0 pt-1 text-sm font-medium text-muted-foreground">
|
||||
{DOW_LABELS[d]}
|
||||
</span>
|
||||
<div className="flex flex-1 flex-wrap gap-1.5">
|
||||
{items.length === 0 && (
|
||||
<span className="text-sm text-muted-foreground">休息日(自动保持连击)</span>
|
||||
)}
|
||||
{items.map((item) => (
|
||||
<span
|
||||
key={item.id}
|
||||
className="inline-flex items-center gap-1 rounded-full bg-muted px-3 py-1 text-sm"
|
||||
>
|
||||
{item.title}
|
||||
{item.requiresPhoto && " · 拍照"}
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`删除 ${item.title}`}
|
||||
className="ml-1 text-muted-foreground hover:text-destructive"
|
||||
onClick={() => startTransition(() => deleteTemplate(item.id))}
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<form onSubmit={onAdd} className="flex flex-wrap items-end gap-3 border-t pt-3">
|
||||
<div className="space-y-1">
|
||||
<Label>星期</Label>
|
||||
<select
|
||||
value={dow}
|
||||
onChange={(e) => setDow(Number(e.target.value))}
|
||||
className="h-9 rounded-md border border-input bg-background px-2 text-sm"
|
||||
>
|
||||
{WEEK_ORDER.map((d) => (
|
||||
<option key={d} value={d}>
|
||||
{DOW_LABELS[d]}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="min-w-40 flex-1 space-y-1">
|
||||
<Label>任务内容</Label>
|
||||
<Input
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder="如:看书 30 分钟"
|
||||
maxLength={100}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex h-9 items-center gap-2">
|
||||
<Checkbox
|
||||
id={`photo-${kid.id}`}
|
||||
checked={requiresPhoto}
|
||||
onCheckedChange={(v) => setRequiresPhoto(v === true)}
|
||||
/>
|
||||
<Label htmlFor={`photo-${kid.id}`}>需拍照凭证</Label>
|
||||
</div>
|
||||
<Button type="submit" disabled={pending || title.trim() === ""}>
|
||||
添加
|
||||
</Button>
|
||||
</form>
|
||||
{error && <p className="text-sm font-medium text-destructive">{error}</p>}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { Slot } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const badgeVariants = cva(
|
||||
"inline-flex w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-full border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
|
||||
destructive:
|
||||
"bg-destructive text-white focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40 [a&]:hover:bg-destructive/90",
|
||||
outline:
|
||||
"border-border text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
|
||||
ghost: "[a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
|
||||
link: "text-primary underline-offset-4 [a&]:hover:underline",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Badge({
|
||||
className,
|
||||
variant = "default",
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"span"> &
|
||||
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
|
||||
const Comp = asChild ? Slot.Root : "span"
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="badge"
|
||||
data-variant={variant}
|
||||
className={cn(badgeVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants }
|
||||
@@ -0,0 +1,64 @@
|
||||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { Slot } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||
destructive:
|
||||
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40",
|
||||
outline:
|
||||
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
ghost:
|
||||
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default: "h-9 px-4 py-2 has-[>svg]:px-3",
|
||||
xs: "h-6 gap-1 rounded-md px-2 text-xs has-[>svg]:px-1.5 [&_svg:not([class*='size-'])]:size-3",
|
||||
sm: "h-8 gap-1.5 rounded-md px-3 has-[>svg]:px-2.5",
|
||||
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
|
||||
icon: "size-9",
|
||||
"icon-xs": "size-6 rounded-md [&_svg:not([class*='size-'])]:size-3",
|
||||
"icon-sm": "size-8",
|
||||
"icon-lg": "size-10",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Button({
|
||||
className,
|
||||
variant = "default",
|
||||
size = "default",
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"button"> &
|
||||
VariantProps<typeof buttonVariants> & {
|
||||
asChild?: boolean
|
||||
}) {
|
||||
const Comp = asChild ? Slot.Root : "button"
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="button"
|
||||
data-variant={variant}
|
||||
data-size={size}
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Button, buttonVariants }
|
||||
@@ -0,0 +1,92 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Card({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card"
|
||||
className={cn(
|
||||
"flex flex-col gap-6 rounded-xl border bg-card py-6 text-card-foreground shadow-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-header"
|
||||
className={cn(
|
||||
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-title"
|
||||
className={cn("leading-none font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-description"
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-action"
|
||||
className={cn(
|
||||
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-content"
|
||||
className={cn("px-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-footer"
|
||||
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardFooter,
|
||||
CardTitle,
|
||||
CardAction,
|
||||
CardDescription,
|
||||
CardContent,
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { CheckIcon } from "lucide-react"
|
||||
import { Checkbox as CheckboxPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Checkbox({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
|
||||
return (
|
||||
<CheckboxPrimitive.Root
|
||||
data-slot="checkbox"
|
||||
className={cn(
|
||||
"peer size-4 shrink-0 rounded-[4px] border border-input shadow-xs transition-shadow outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 data-[state=checked]:border-primary data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:bg-input/30 dark:aria-invalid:ring-destructive/40 dark:data-[state=checked]:bg-primary",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<CheckboxPrimitive.Indicator
|
||||
data-slot="checkbox-indicator"
|
||||
className="grid place-content-center text-current transition-none"
|
||||
>
|
||||
<CheckIcon className="size-3.5" />
|
||||
</CheckboxPrimitive.Indicator>
|
||||
</CheckboxPrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
export { Checkbox }
|
||||
@@ -0,0 +1,158 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { XIcon } from "lucide-react"
|
||||
import { Dialog as DialogPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
|
||||
function Dialog({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
|
||||
return <DialogPrimitive.Root data-slot="dialog" {...props} />
|
||||
}
|
||||
|
||||
function DialogTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
|
||||
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
|
||||
}
|
||||
|
||||
function DialogPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
|
||||
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
|
||||
}
|
||||
|
||||
function DialogClose({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
|
||||
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
|
||||
}
|
||||
|
||||
function DialogOverlay({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
|
||||
return (
|
||||
<DialogPrimitive.Overlay
|
||||
data-slot="dialog-overlay"
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 bg-black/50 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogContent({
|
||||
className,
|
||||
children,
|
||||
showCloseButton = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
|
||||
showCloseButton?: boolean
|
||||
}) {
|
||||
return (
|
||||
<DialogPortal data-slot="dialog-portal">
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
data-slot="dialog-content"
|
||||
className={cn(
|
||||
"fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border bg-background p-6 shadow-lg duration-200 outline-none data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 sm:max-w-lg",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{showCloseButton && (
|
||||
<DialogPrimitive.Close
|
||||
data-slot="dialog-close"
|
||||
className="absolute top-4 right-4 rounded-xs opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:ring-2 focus:ring-ring focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
|
||||
>
|
||||
<XIcon />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-header"
|
||||
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogFooter({
|
||||
className,
|
||||
showCloseButton = false,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
showCloseButton?: boolean
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-footer"
|
||||
className={cn(
|
||||
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{showCloseButton && (
|
||||
<DialogPrimitive.Close asChild>
|
||||
<Button variant="outline">Close</Button>
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogTitle({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
|
||||
return (
|
||||
<DialogPrimitive.Title
|
||||
data-slot="dialog-title"
|
||||
className={cn("text-lg leading-none font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
|
||||
return (
|
||||
<DialogPrimitive.Description
|
||||
data-slot="dialog-description"
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogOverlay,
|
||||
DialogPortal,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
data-slot="input"
|
||||
className={cn(
|
||||
"h-9 w-full min-w-0 rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none selection:bg-primary selection:text-primary-foreground file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm dark:bg-input/30",
|
||||
"focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50",
|
||||
"aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Input }
|
||||
@@ -0,0 +1,24 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Label as LabelPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Label({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
|
||||
return (
|
||||
<LabelPrimitive.Root
|
||||
data-slot="label"
|
||||
className={cn(
|
||||
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Label }
|
||||
@@ -0,0 +1,40 @@
|
||||
"use client"
|
||||
|
||||
import {
|
||||
CircleCheckIcon,
|
||||
InfoIcon,
|
||||
Loader2Icon,
|
||||
OctagonXIcon,
|
||||
TriangleAlertIcon,
|
||||
} from "lucide-react"
|
||||
import { useTheme } from "next-themes"
|
||||
import { Toaster as Sonner, type ToasterProps } from "sonner"
|
||||
|
||||
const Toaster = ({ ...props }: ToasterProps) => {
|
||||
const { theme = "system" } = useTheme()
|
||||
|
||||
return (
|
||||
<Sonner
|
||||
theme={theme as ToasterProps["theme"]}
|
||||
className="toaster group"
|
||||
icons={{
|
||||
success: <CircleCheckIcon className="size-4" />,
|
||||
info: <InfoIcon className="size-4" />,
|
||||
warning: <TriangleAlertIcon className="size-4" />,
|
||||
error: <OctagonXIcon className="size-4" />,
|
||||
loading: <Loader2Icon className="size-4 animate-spin" />,
|
||||
}}
|
||||
style={
|
||||
{
|
||||
"--normal-bg": "var(--popover)",
|
||||
"--normal-text": "var(--popover-foreground)",
|
||||
"--normal-border": "var(--border)",
|
||||
"--border-radius": "var(--radius)",
|
||||
} as React.CSSProperties
|
||||
}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Toaster }
|
||||
@@ -0,0 +1,91 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { Tabs as TabsPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Tabs({
|
||||
className,
|
||||
orientation = "horizontal",
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.Root>) {
|
||||
return (
|
||||
<TabsPrimitive.Root
|
||||
data-slot="tabs"
|
||||
data-orientation={orientation}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"group/tabs flex gap-2 data-[orientation=horizontal]:flex-col",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const tabsListVariants = cva(
|
||||
"group/tabs-list inline-flex w-fit items-center justify-center rounded-lg p-[3px] text-muted-foreground group-data-[orientation=horizontal]/tabs:h-9 group-data-[orientation=vertical]/tabs:h-fit group-data-[orientation=vertical]/tabs:flex-col data-[variant=line]:rounded-none",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-muted",
|
||||
line: "gap-1 bg-transparent",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function TabsList({
|
||||
className,
|
||||
variant = "default",
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.List> &
|
||||
VariantProps<typeof tabsListVariants>) {
|
||||
return (
|
||||
<TabsPrimitive.List
|
||||
data-slot="tabs-list"
|
||||
data-variant={variant}
|
||||
className={cn(tabsListVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TabsTrigger({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
|
||||
return (
|
||||
<TabsPrimitive.Trigger
|
||||
data-slot="tabs-trigger"
|
||||
className={cn(
|
||||
"relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap text-foreground/60 transition-all group-data-[orientation=vertical]/tabs:w-full group-data-[orientation=vertical]/tabs:justify-start hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1 focus-visible:outline-ring disabled:pointer-events-none disabled:opacity-50 group-data-[variant=default]/tabs-list:data-[state=active]:shadow-sm group-data-[variant=line]/tabs-list:data-[state=active]:shadow-none dark:text-muted-foreground dark:hover:text-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
"group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-[state=active]:bg-transparent dark:group-data-[variant=line]/tabs-list:data-[state=active]:border-transparent dark:group-data-[variant=line]/tabs-list:data-[state=active]:bg-transparent",
|
||||
"data-[state=active]:bg-background data-[state=active]:text-foreground dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 dark:data-[state=active]:text-foreground",
|
||||
"after:absolute after:bg-foreground after:opacity-0 after:transition-opacity group-data-[orientation=horizontal]/tabs:after:inset-x-0 group-data-[orientation=horizontal]/tabs:after:bottom-[-5px] group-data-[orientation=horizontal]/tabs:after:h-0.5 group-data-[orientation=vertical]/tabs:after:inset-y-0 group-data-[orientation=vertical]/tabs:after:-right-1 group-data-[orientation=vertical]/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-[state=active]:after:opacity-100",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TabsContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.Content>) {
|
||||
return (
|
||||
<TabsPrimitive.Content
|
||||
data-slot="tabs-content"
|
||||
className={cn("flex-1 outline-none", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants }
|
||||
@@ -0,0 +1,19 @@
|
||||
import { drizzle } from "drizzle-orm/mysql2";
|
||||
import mysql from "mysql2/promise";
|
||||
import * as schema from "./schema";
|
||||
|
||||
// 连接池缓存到 globalThis,开发模式热更新时不重复建池
|
||||
const globalForDb = globalThis as unknown as { sjPool?: mysql.Pool };
|
||||
|
||||
const pool =
|
||||
globalForDb.sjPool ??
|
||||
mysql.createPool({
|
||||
uri: process.env.DATABASE_URL,
|
||||
connectionLimit: 5,
|
||||
timezone: "+08:00",
|
||||
});
|
||||
|
||||
if (process.env.NODE_ENV !== "production") globalForDb.sjPool = pool;
|
||||
|
||||
export const db = drizzle(pool, { schema, mode: "default" });
|
||||
export { schema };
|
||||
@@ -0,0 +1,131 @@
|
||||
import { sql } from "drizzle-orm";
|
||||
import {
|
||||
bigint,
|
||||
boolean,
|
||||
date,
|
||||
datetime,
|
||||
index,
|
||||
int,
|
||||
mysqlEnum,
|
||||
mysqlTable,
|
||||
uniqueIndex,
|
||||
varchar,
|
||||
} from "drizzle-orm/mysql-core";
|
||||
|
||||
// ===== 家庭 =====
|
||||
export const families = mysqlTable("family", {
|
||||
id: bigint("id", { mode: "number" }).primaryKey().autoincrement(),
|
||||
name: varchar("name", { length: 50 }).notNull(),
|
||||
createdAt: datetime("created_at").notNull().default(sql`CURRENT_TIMESTAMP`),
|
||||
});
|
||||
|
||||
// ===== 用户(家长与孩子同表,角色区分)=====
|
||||
export const users = mysqlTable(
|
||||
"user",
|
||||
{
|
||||
id: bigint("id", { mode: "number" }).primaryKey().autoincrement(),
|
||||
familyId: bigint("family_id", { mode: "number" })
|
||||
.notNull()
|
||||
.references(() => families.id),
|
||||
role: mysqlEnum("role", ["parent", "child"]).notNull(),
|
||||
displayName: varchar("display_name", { length: 50 }).notNull(),
|
||||
// 家长:手机号登录;孩子:无登录名,靠设备会话
|
||||
phone: varchar("phone", { length: 20 }),
|
||||
passwordHash: varchar("password_hash", { length: 100 }),
|
||||
// 孩子展示顺序
|
||||
sortOrder: int("sort_order").notNull().default(0),
|
||||
createdAt: datetime("created_at").notNull().default(sql`CURRENT_TIMESTAMP`),
|
||||
},
|
||||
(t) => [uniqueIndex("uq_user_phone").on(t.phone)],
|
||||
);
|
||||
|
||||
// ===== 会话(家长登录与孩子设备共用,靠过期时间区分长短)=====
|
||||
export const sessions = mysqlTable(
|
||||
"session",
|
||||
{
|
||||
id: bigint("id", { mode: "number" }).primaryKey().autoincrement(),
|
||||
userId: bigint("user_id", { mode: "number" })
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
// 会话令牌只存 sha256 哈希,数据库泄露也拿不到可用令牌
|
||||
tokenHash: varchar("token_hash", { length: 64 }).notNull(),
|
||||
deviceLabel: varchar("device_label", { length: 100 }),
|
||||
createdAt: datetime("created_at").notNull().default(sql`CURRENT_TIMESTAMP`),
|
||||
expiresAt: datetime("expires_at").notNull(),
|
||||
},
|
||||
(t) => [
|
||||
uniqueIndex("uq_session_token").on(t.tokenHash),
|
||||
index("idx_session_user").on(t.userId),
|
||||
],
|
||||
);
|
||||
|
||||
// ===== 设备激活码(绑定到具体某个孩子,一次性)=====
|
||||
export const activationCodes = mysqlTable("activation_code", {
|
||||
id: bigint("id", { mode: "number" }).primaryKey().autoincrement(),
|
||||
childUserId: bigint("child_user_id", { mode: "number" })
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
codeHash: varchar("code_hash", { length: 64 }).notNull(),
|
||||
createdBy: bigint("created_by", { mode: "number" })
|
||||
.notNull()
|
||||
.references(() => users.id),
|
||||
createdAt: datetime("created_at").notNull().default(sql`CURRENT_TIMESTAMP`),
|
||||
expiresAt: datetime("expires_at").notNull(),
|
||||
usedAt: datetime("used_at"),
|
||||
});
|
||||
|
||||
// ===== 周模板(每个孩子 × 每周几 × 每条任务)=====
|
||||
export const taskTemplates = mysqlTable(
|
||||
"task_template",
|
||||
{
|
||||
id: bigint("id", { mode: "number" }).primaryKey().autoincrement(),
|
||||
childUserId: bigint("child_user_id", { mode: "number" })
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
// 0=周日,1=周一……6=周六(与 JS Date.getDay 一致)
|
||||
dow: int("dow").notNull(),
|
||||
title: varchar("title", { length: 100 }).notNull(),
|
||||
requiresPhoto: boolean("requires_photo").notNull().default(false),
|
||||
sortOrder: int("sort_order").notNull().default(0),
|
||||
createdAt: datetime("created_at").notNull().default(sql`CURRENT_TIMESTAMP`),
|
||||
},
|
||||
(t) => [index("idx_template_child_dow").on(t.childUserId, t.dow)],
|
||||
);
|
||||
|
||||
// ===== 每日任务快照(由模板生成,改模板不影响历史)=====
|
||||
export const taskInstances = mysqlTable(
|
||||
"task_instance",
|
||||
{
|
||||
id: bigint("id", { mode: "number" }).primaryKey().autoincrement(),
|
||||
childUserId: bigint("child_user_id", { mode: "number" })
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
// 任务日期,字符串 YYYY-MM-DD,避免时区漂移
|
||||
date: date("date", { mode: "string" }).notNull(),
|
||||
title: varchar("title", { length: 100 }).notNull(),
|
||||
requiresPhoto: boolean("requires_photo").notNull().default(false),
|
||||
status: mysqlEnum("status", ["pending", "done"]).notNull().default("pending"),
|
||||
// 家长补卡标记,永久保留
|
||||
isMakeup: boolean("is_makeup").notNull().default(false),
|
||||
doneAt: datetime("done_at"),
|
||||
sortOrder: int("sort_order").notNull().default(0),
|
||||
// 来源模板;null 表示当天临时添加的任务
|
||||
sourceTemplateId: bigint("source_template_id", { mode: "number" }),
|
||||
createdAt: datetime("created_at").notNull().default(sql`CURRENT_TIMESTAMP`),
|
||||
},
|
||||
(t) => [index("idx_instance_child_date").on(t.childUserId, t.date)],
|
||||
);
|
||||
|
||||
// ===== 照片凭证(存对象存储,库里只记键名)=====
|
||||
export const photos = mysqlTable(
|
||||
"photo",
|
||||
{
|
||||
id: bigint("id", { mode: "number" }).primaryKey().autoincrement(),
|
||||
taskInstanceId: bigint("task_instance_id", { mode: "number" })
|
||||
.notNull()
|
||||
.references(() => taskInstances.id, { onDelete: "cascade" }),
|
||||
ossKey: varchar("oss_key", { length: 300 }).notNull(),
|
||||
createdAt: datetime("created_at").notNull().default(sql`CURRENT_TIMESTAMP`),
|
||||
},
|
||||
(t) => [index("idx_photo_instance").on(t.taskInstanceId)],
|
||||
);
|
||||
@@ -0,0 +1,74 @@
|
||||
// 会话管理:Cookie 里只放随机令牌,数据库里只存令牌的哈希
|
||||
import crypto from "node:crypto";
|
||||
import { cookies } from "next/headers";
|
||||
import { and, eq, gt } from "drizzle-orm";
|
||||
import { db } from "@/db";
|
||||
import { sessions, users } from "@/db/schema";
|
||||
|
||||
export const SESSION_COOKIE = "sj_session";
|
||||
const DAY_MS = 86400_000;
|
||||
|
||||
function hashToken(token: string) {
|
||||
return crypto.createHash("sha256").update(token).digest("hex");
|
||||
}
|
||||
|
||||
export type SessionUser = {
|
||||
id: number;
|
||||
familyId: number;
|
||||
role: "parent" | "child";
|
||||
displayName: string;
|
||||
};
|
||||
|
||||
// 创建会话并写入 Cookie。家长 30 天,孩子设备建议传 730 天
|
||||
export async function createSession(
|
||||
userId: number,
|
||||
deviceLabel: string | null,
|
||||
days: number,
|
||||
) {
|
||||
const token = crypto.randomBytes(32).toString("hex");
|
||||
const expires = new Date(Date.now() + days * DAY_MS);
|
||||
await db.insert(sessions).values({
|
||||
userId,
|
||||
tokenHash: hashToken(token),
|
||||
deviceLabel,
|
||||
expiresAt: expires,
|
||||
});
|
||||
const store = await cookies();
|
||||
store.set(SESSION_COOKIE, token, {
|
||||
httpOnly: true,
|
||||
sameSite: "lax",
|
||||
// 生产环境(https)强制 secure;本机 http 开发时关闭
|
||||
secure: process.env.NODE_ENV === "production",
|
||||
path: "/",
|
||||
expires,
|
||||
});
|
||||
}
|
||||
|
||||
// 读取当前会话用户;无效或过期返回 null
|
||||
export async function getSessionUser(): Promise<SessionUser | null> {
|
||||
const store = await cookies();
|
||||
const token = store.get(SESSION_COOKIE)?.value;
|
||||
if (!token) return null;
|
||||
const rows = await db
|
||||
.select({
|
||||
id: users.id,
|
||||
familyId: users.familyId,
|
||||
role: users.role,
|
||||
displayName: users.displayName,
|
||||
})
|
||||
.from(sessions)
|
||||
.innerJoin(users, eq(users.id, sessions.userId))
|
||||
.where(and(eq(sessions.tokenHash, hashToken(token)), gt(sessions.expiresAt, new Date())))
|
||||
.limit(1);
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
// 销毁当前会话(登出)
|
||||
export async function destroySession() {
|
||||
const store = await cookies();
|
||||
const token = store.get(SESSION_COOKIE)?.value;
|
||||
if (token) {
|
||||
await db.delete(sessions).where(eq(sessions.tokenHash, hashToken(token)));
|
||||
}
|
||||
store.delete(SESSION_COOKIE);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
// 日期工具:全站统一按 Asia/Shanghai 时区计算"今天",与服务器环境变量无关
|
||||
|
||||
// 返回 YYYY-MM-DD 格式的当天日期(上海时区)
|
||||
export function todayStr(): string {
|
||||
return new Date().toLocaleDateString("en-CA", { timeZone: "Asia/Shanghai" });
|
||||
}
|
||||
|
||||
// 由 YYYY-MM-DD 计算星期几:0=周日 …… 6=周六(与模板 dow 字段一致)
|
||||
export function dowOf(dateStr: string): number {
|
||||
const [y, m, d] = dateStr.split("-").map(Number);
|
||||
return new Date(Date.UTC(y, m - 1, d)).getUTCDay();
|
||||
}
|
||||
|
||||
// 今天往前推 n 天的日期字符串
|
||||
export function daysAgo(n: number): string {
|
||||
const now = new Date();
|
||||
// 先定位上海时区的今天零点,再回退 n 天
|
||||
const today = new Date(
|
||||
new Date().toLocaleDateString("en-US", { timeZone: "Asia/Shanghai" }),
|
||||
);
|
||||
today.setDate(today.getDate() - n);
|
||||
void now;
|
||||
return today.toLocaleDateString("en-CA");
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
// 对象存储签名工具:浏览器直传用查询串签名(浏览器无法设置 Date 请求头)
|
||||
import crypto from "node:crypto";
|
||||
|
||||
const REGION = process.env.OSS_REGION ?? "oss-cn-hangzhou";
|
||||
const BUCKET = process.env.OSS_BUCKET ?? "";
|
||||
const ID = process.env.OSS_ACCESS_KEY_ID ?? "";
|
||||
const SECRET = process.env.OSS_ACCESS_KEY_SECRET ?? "";
|
||||
|
||||
const HOST = `${BUCKET}.${REGION}.aliyuncs.com`;
|
||||
|
||||
// 生成带签名的临时访问地址
|
||||
function signUrl(method: string, key: string, seconds: number, contentType = "") {
|
||||
const expires = Math.floor(Date.now() / 1000) + seconds;
|
||||
const resource = `/${BUCKET}/${key}`;
|
||||
const str = `${method}\n\n${contentType}\n${expires}\n${resource}`;
|
||||
const signature = crypto.createHmac("sha1", SECRET).update(str).digest("base64");
|
||||
const qs = new URLSearchParams({
|
||||
OSSAccessKeyId: ID,
|
||||
Expires: String(expires),
|
||||
Signature: signature,
|
||||
});
|
||||
return `https://${HOST}/${encodeURIComponent(key)}?${qs}`;
|
||||
}
|
||||
|
||||
// 直传地址:浏览器收到后直接 fetch PUT,有效期 10 分钟
|
||||
export function uploadUrl(key: string, contentType: string) {
|
||||
return signUrl("PUT", key, 600, contentType);
|
||||
}
|
||||
|
||||
// 展示地址:签名 GET,有效期 5 分钟,用完由页面刷新重新签发
|
||||
export function viewUrl(key: string) {
|
||||
return signUrl("GET", key, 300);
|
||||
}
|
||||
|
||||
// 照片键名:photos/孩子ID/日期/随机16进制.jpg
|
||||
export function photoKey(childId: number, date: string) {
|
||||
return `photos/${childId}/${date}/${crypto.randomBytes(8).toString("hex")}.jpg`;
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
// 连击计算:从今天往前数连续"达成"的天数
|
||||
// 达成 = 当天至少完成 1 项,或当天根本没有任务(模板空白日自动达成)
|
||||
import { eq, sql } from "drizzle-orm";
|
||||
import { db } from "@/db";
|
||||
import { taskInstances, users } from "@/db/schema";
|
||||
import { daysAgo, todayStr } from "@/lib/dates";
|
||||
|
||||
export async function streakOf(childId: number): Promise<number> {
|
||||
const [child] = await db
|
||||
.select({ createdAt: users.createdAt })
|
||||
.from(users)
|
||||
.where(eq(users.id, childId))
|
||||
.limit(1);
|
||||
if (!child) return 0;
|
||||
|
||||
// 账号创建日(上海时区)之前的天数不计入,否则连击会无限回溯
|
||||
const startDay = new Date(child.createdAt).toLocaleDateString("en-CA", {
|
||||
timeZone: "Asia/Shanghai",
|
||||
});
|
||||
|
||||
// 一次取出全部历史:每天是否存在完成项
|
||||
const rows = await db
|
||||
.select({
|
||||
date: taskInstances.date,
|
||||
hasDone: sql<number>`MAX(${taskInstances.status} = 'done')`,
|
||||
})
|
||||
.from(taskInstances)
|
||||
.where(eq(taskInstances.childUserId, childId))
|
||||
.groupBy(taskInstances.date);
|
||||
|
||||
const doneByDate = new Map<string, boolean>();
|
||||
for (const r of rows) doneByDate.set(r.date, r.hasDone === 1);
|
||||
|
||||
let streak = 0;
|
||||
const today = todayStr();
|
||||
for (let i = 0; ; i++) {
|
||||
const day = daysAgo(i);
|
||||
if (day < startDay) break;
|
||||
const achieved = !doneByDate.has(day) || doneByDate.get(day) === true;
|
||||
if (!achieved) {
|
||||
if (day === today) continue; // 今天还没打完:不计数,但不断连
|
||||
break;
|
||||
}
|
||||
streak++;
|
||||
}
|
||||
return streak;
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
// 每日任务:从周模板生成当天快照(幂等),查询当日任务与完成情况
|
||||
import { and, asc, eq, inArray } from "drizzle-orm";
|
||||
import { db } from "@/db";
|
||||
import { photos, taskInstances, taskTemplates } from "@/db/schema";
|
||||
import { dowOf } from "@/lib/dates";
|
||||
import { viewUrl } from "@/lib/oss";
|
||||
|
||||
export type TaskView = {
|
||||
id: number;
|
||||
title: string;
|
||||
requiresPhoto: boolean;
|
||||
status: "pending" | "done";
|
||||
isMakeup: boolean;
|
||||
photoUrls: string[];
|
||||
};
|
||||
|
||||
// 确保某天的任务快照存在:第一次访问当天时由模板生成,之后读快照
|
||||
export async function ensureDay(childId: number, date: string) {
|
||||
await db.transaction(async (tx) => {
|
||||
const existing = await tx
|
||||
.select({ id: taskInstances.id })
|
||||
.from(taskInstances)
|
||||
.where(and(eq(taskInstances.childUserId, childId), eq(taskInstances.date, date)))
|
||||
.limit(1);
|
||||
if (existing.length > 0) return;
|
||||
|
||||
const templates = await tx
|
||||
.select()
|
||||
.from(taskTemplates)
|
||||
.where(and(eq(taskTemplates.childUserId, childId), eq(taskTemplates.dow, dowOf(date))))
|
||||
.orderBy(asc(taskTemplates.sortOrder));
|
||||
|
||||
if (templates.length > 0) {
|
||||
await tx.insert(taskInstances).values(
|
||||
templates.map((t) => ({
|
||||
childUserId: childId,
|
||||
date,
|
||||
title: t.title,
|
||||
requiresPhoto: t.requiresPhoto,
|
||||
sortOrder: t.sortOrder,
|
||||
sourceTemplateId: t.id,
|
||||
})),
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 孩子端当日任务视图(含照片签名地址)
|
||||
export async function dayTasks(childId: number, date: string): Promise<TaskView[]> {
|
||||
await ensureDay(childId, date);
|
||||
const rows = await db
|
||||
.select({
|
||||
id: taskInstances.id,
|
||||
title: taskInstances.title,
|
||||
requiresPhoto: taskInstances.requiresPhoto,
|
||||
status: taskInstances.status,
|
||||
isMakeup: taskInstances.isMakeup,
|
||||
})
|
||||
.from(taskInstances)
|
||||
.where(and(eq(taskInstances.childUserId, childId), eq(taskInstances.date, date)))
|
||||
.orderBy(asc(taskInstances.sortOrder));
|
||||
|
||||
const taskIds = rows.map((r) => r.id);
|
||||
const photoRows =
|
||||
taskIds.length > 0
|
||||
? await db
|
||||
.select({ instanceId: photos.taskInstanceId, ossKey: photos.ossKey })
|
||||
.from(photos)
|
||||
.where(inArray(photos.taskInstanceId, taskIds))
|
||||
: [];
|
||||
|
||||
const byTask = new Map<number, string[]>();
|
||||
for (const p of photoRows) {
|
||||
const list = byTask.get(p.instanceId) ?? [];
|
||||
list.push(viewUrl(p.ossKey));
|
||||
byTask.set(p.instanceId, list);
|
||||
}
|
||||
|
||||
return rows.map((r) => ({ ...r, photoUrls: byTask.get(r.id) ?? [] }));
|
||||
}
|
||||
|
||||
// 家长端概览用:某孩子某天的完成数/总数
|
||||
export async function daySummary(childId: number, date: string) {
|
||||
const rows = await db
|
||||
.select({ id: taskInstances.id, status: taskInstances.status })
|
||||
.from(taskInstances)
|
||||
.where(and(eq(taskInstances.childUserId, childId), eq(taskInstances.date, date)));
|
||||
return {
|
||||
total: rows.length,
|
||||
done: rows.filter((r) => r.status === "done").length,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { clsx, type ClassValue } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
// 合并 Tailwind 类名,冲突时后者覆盖前者
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "preserve",
|
||||
"incremental": true,
|
||||
"plugins": [{ "name": "next" }],
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
Reference in New Issue
Block a user