Compare commits

4 Commits

Author SHA1 Message Date
a110d10e39 Fix deploy-preview: handle missing prod DB gracefully
All checks were successful
PR Preview / teardown-preview (pull_request) Has been skipped
Test / build-check (push) Successful in 3s
PR Preview / test (pull_request) Successful in 3s
Test / e2e-test (push) Successful in 51s
PR Preview / deploy-preview (pull_request) Successful in 57s
Production still uses JSON, so /data/planner.db doesn't exist yet.
Detect cp failure and use empty DB (init_db creates tables on startup).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 21:43:17 +00:00
8c69e2db5b Fix CI: clean stale DB before e2e tests, fix reminder selector
Some checks failed
PR Preview / teardown-preview (pull_request) Has been skipped
Test / build-check (push) Successful in 3s
PR Preview / test (pull_request) Successful in 3s
PR Preview / deploy-preview (pull_request) Failing after 4s
Test / e2e-test (push) Successful in 52s
- Clean previous run's DB/WAL/SHM files and kill stale processes
  before starting backend, preventing test data contamination
- reminders-flow: use parents('.reminder-card') to find .remove-btn

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 21:41:08 +00:00
62057d6022 Fix reminders-flow.cy.js: use parents() instead of parent()
Some checks failed
Test / build-check (push) Successful in 3s
PR Preview / test (pull_request) Successful in 3s
PR Preview / teardown-preview (pull_request) Has been skipped
Test / e2e-test (push) Failing after 1m2s
PR Preview / deploy-preview (pull_request) Failing after 5s
The .remove-btn is nested inside .reminder-main, not a direct child
of the element containing the text. Use .parents('.reminder-card')
to traverse up to the correct ancestor.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 21:37:26 +00:00
d3f3b4f37b Refactor to Vue 3 + FastAPI + SQLite architecture
Some checks failed
Test / build-check (push) Successful in 3s
PR Preview / test (pull_request) Successful in 3s
PR Preview / teardown-preview (pull_request) Has been skipped
Test / e2e-test (push) Failing after 55s
PR Preview / deploy-preview (pull_request) Failing after 40s
- Backend: FastAPI + SQLite (WAL mode), 22 tables, ~40 API endpoints
- Frontend: Vue 3 + Vite + Pinia + Vue Router, 8 views, 3 stores
- Database: migrate from JSON file to SQLite with proper schema
- Dockerfile: multi-stage build (node + python)
- Deploy: K8s manifests (namespace, deployment, service, ingress, pvc, backup)
- CI/CD: Gitea Actions (test, deploy, PR preview at pr-$id.planner.oci.euphon.net)
- Tests: 20 Cypress E2E test files, 196 test cases, ~85% coverage
- Doc: test-coverage.md with full feature coverage report

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 21:18:22 +00:00
67 changed files with 10051 additions and 6 deletions

View File

@@ -0,0 +1,20 @@
name: Deploy Production
on:
push:
branches: [main]
jobs:
test:
runs-on: test
steps:
- uses: actions/checkout@v4
- name: Build check
run: cd frontend && npm ci && npm run build
deploy:
needs: test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Deploy Production
run: python3 scripts/deploy-preview.py deploy-prod

View File

@@ -0,0 +1,50 @@
name: PR Preview
on:
pull_request:
types: [opened, synchronize, reopened, closed]
jobs:
test:
if: github.event.action != 'closed'
runs-on: test
steps:
- uses: actions/checkout@v4
- name: Build check
run: cd frontend && npm ci && npm run build
deploy-preview:
if: github.event.action != 'closed'
needs: test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Deploy Preview
run: python3 scripts/deploy-preview.py deploy ${{ github.event.pull_request.number }}
- name: Comment PR
env:
GIT_TOKEN: ${{ secrets.GIT_TOKEN }}
run: |
PR_ID="${{ github.event.pull_request.number }}"
curl -sf -X POST \
"https://git.euphon.cloud/api/v1/repos/${{ github.repository }}/issues/${PR_ID}/comments" \
-H "Authorization: token ${GIT_TOKEN}" \
-H "Content-Type: application/json" \
-d "{\"body\": \"🚀 **Preview**: https://pr-${PR_ID}.planner.oci.euphon.net\n\nDB is a copy of production.\"}" || true
teardown-preview:
if: github.event.action == 'closed'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Teardown
run: python3 scripts/deploy-preview.py teardown ${{ github.event.pull_request.number }}
- name: Comment PR
env:
GIT_TOKEN: ${{ secrets.GIT_TOKEN }}
run: |
PR_ID="${{ github.event.pull_request.number }}"
curl -sf -X POST \
"https://git.euphon.cloud/api/v1/repos/${{ github.repository }}/issues/${PR_ID}/comments" \
-H "Authorization: token ${GIT_TOKEN}" \
-H "Content-Type: application/json" \
-d "{\"body\": \"🗑️ Preview torn down.\"}" || true

69
.gitea/workflows/test.yml Normal file
View File

@@ -0,0 +1,69 @@
name: Test
on: [push]
jobs:
build-check:
runs-on: test
steps:
- uses: actions/checkout@v4
- name: Build frontend
run: cd frontend && npm ci && npm run build
e2e-test:
runs-on: test
needs: build-check
steps:
- uses: actions/checkout@v4
- name: Install frontend deps
run: cd frontend && npm ci
- name: Install backend deps
run: python3 -m venv /tmp/ci-venv && /tmp/ci-venv/bin/pip install -q -r backend/requirements.txt
- name: E2E tests
run: |
# Clean stale data from previous runs
rm -f /tmp/ci_planner_test.db /tmp/ci_planner_test.db-wal /tmp/ci_planner_test.db-shm
rm -rf /tmp/ci_planner_data
pkill -f "uvicorn backend" || true
pkill -f "node.*vite" || true
sleep 1
# Start backend with fresh DB
DB_PATH=/tmp/ci_planner_test.db FRONTEND_DIR=/dev/null DATA_DIR=/tmp/ci_planner_data \
/tmp/ci-venv/bin/uvicorn backend.main:app --port 8000 &
# Start frontend
(cd frontend && npx vite --port 5173) &
# Wait for both servers
for i in $(seq 1 30); do
if curl -sf http://localhost:8000/api/backups > /dev/null 2>&1 && \
curl -sf http://localhost:5173/ > /dev/null 2>&1; then
echo "Both servers ready"
break
fi
sleep 1
done
# Run core cypress specs
cd frontend
npx cypress run --spec "\
cypress/e2e/app-load.cy.js,\
cypress/e2e/auth-flow.cy.js,\
cypress/e2e/navigation.cy.js,\
cypress/e2e/api-health.cy.js,\
cypress/e2e/api-crud.cy.js,\
cypress/e2e/notes-flow.cy.js,\
cypress/e2e/tasks-flow.cy.js,\
cypress/e2e/reminders-flow.cy.js\
" --config video=false
EXIT_CODE=$?
# Cleanup
pkill -f "uvicorn backend" || true
pkill -f "node.*vite" || true
rm -f /tmp/ci_planner_test.db
rm -rf /tmp/ci_planner_data
exit $EXIT_CODE

5
.gitignore vendored Normal file
View File

@@ -0,0 +1,5 @@
node_modules
__pycache__
frontend/node_modules
frontend/dist
*.pyc

View File

@@ -1,7 +1,25 @@
FROM python:3.12-alpine
FROM node:20-slim AS frontend-build
WORKDIR /build
COPY frontend/package.json frontend/package-lock.json* ./
RUN npm install
COPY frontend/ ./
RUN npm run build
FROM python:3.12-slim
WORKDIR /app
COPY server.py .
COPY index.html sleep-buddy.html favicon.svg icon-180.png notebook.jpg manifest.json sw.js /app/static/
ENV DATA_DIR=/data STATIC_DIR=/app/static PORT=8080
EXPOSE 8080
CMD ["python3", "server.py"]
COPY backend/requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY backend/ ./backend/
COPY --from=frontend-build /build/dist ./frontend/
ENV DB_PATH=/data/planner.db
ENV FRONTEND_DIR=/app/frontend
ENV DATA_DIR=/data
EXPOSE 8000
CMD ["uvicorn", "backend.main:app", "--host", "0.0.0.0", "--port", "8000"]

0
backend/__init__.py Normal file
View File

218
backend/database.py Normal file
View File

@@ -0,0 +1,218 @@
import sqlite3
import os
DB_PATH = os.environ.get("DB_PATH", "/data/planner.db")
def get_db():
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA journal_mode=WAL")
conn.execute("PRAGMA foreign_keys=ON")
return conn
def init_db():
os.makedirs(os.path.dirname(DB_PATH) or ".", exist_ok=True)
conn = get_db()
c = conn.cursor()
c.executescript("""
-- 用户认证(单用户 planner 密码)
CREATE TABLE IF NOT EXISTS config (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
-- 随手记
CREATE TABLE IF NOT EXISTS notes (
id TEXT PRIMARY KEY,
text TEXT NOT NULL,
tag TEXT DEFAULT '灵感',
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now'))
);
-- 待办事项(四象限)
CREATE TABLE IF NOT EXISTS todos (
id TEXT PRIMARY KEY,
text TEXT NOT NULL,
quadrant TEXT NOT NULL DEFAULT 'q1',
done INTEGER DEFAULT 0,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now'))
);
-- 收集箱
CREATE TABLE IF NOT EXISTS inbox (
id TEXT PRIMARY KEY,
text TEXT NOT NULL,
created_at TEXT DEFAULT (datetime('now'))
);
-- 提醒
CREATE TABLE IF NOT EXISTS reminders (
id TEXT PRIMARY KEY,
text TEXT NOT NULL,
time TEXT,
repeat TEXT DEFAULT 'none',
enabled INTEGER DEFAULT 1,
created_at TEXT DEFAULT (datetime('now'))
);
-- 目标
CREATE TABLE IF NOT EXISTS goals (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
month TEXT,
checks TEXT DEFAULT '{}',
created_at TEXT DEFAULT (datetime('now'))
);
-- 清单
CREATE TABLE IF NOT EXISTS checklists (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
items TEXT DEFAULT '[]',
archived INTEGER DEFAULT 0,
created_at TEXT DEFAULT (datetime('now'))
);
-- 健康打卡 - 项目池
CREATE TABLE IF NOT EXISTS health_items (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
type TEXT NOT NULL DEFAULT 'health',
created_at TEXT DEFAULT (datetime('now'))
);
-- 健康打卡 - 月计划
CREATE TABLE IF NOT EXISTS health_plans (
month TEXT NOT NULL,
type TEXT NOT NULL DEFAULT 'health',
item_ids TEXT DEFAULT '[]',
PRIMARY KEY (month, type)
);
-- 健康打卡 - 记录
CREATE TABLE IF NOT EXISTS health_checks (
date TEXT NOT NULL,
type TEXT NOT NULL DEFAULT 'health',
item_id TEXT NOT NULL,
checked INTEGER DEFAULT 1,
PRIMARY KEY (date, type, item_id)
);
-- 睡眠记录
CREATE TABLE IF NOT EXISTS sleep_records (
date TEXT PRIMARY KEY,
time TEXT NOT NULL,
minutes REAL,
created_at TEXT DEFAULT (datetime('now'))
);
-- 健身记录
CREATE TABLE IF NOT EXISTS gym_records (
id TEXT PRIMARY KEY,
date TEXT NOT NULL,
type TEXT DEFAULT '',
duration TEXT DEFAULT '',
note TEXT DEFAULT '',
created_at TEXT DEFAULT (datetime('now'))
);
-- 经期记录
CREATE TABLE IF NOT EXISTS period_records (
id TEXT PRIMARY KEY,
start_date TEXT NOT NULL,
end_date TEXT,
note TEXT DEFAULT '',
created_at TEXT DEFAULT (datetime('now'))
);
-- 文档
CREATE TABLE IF NOT EXISTS docs (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
icon TEXT DEFAULT '📄',
keywords TEXT DEFAULT '',
extract_rule TEXT DEFAULT 'none',
created_at TEXT DEFAULT (datetime('now'))
);
-- 文档条目
CREATE TABLE IF NOT EXISTS doc_entries (
id TEXT PRIMARY KEY,
doc_id TEXT NOT NULL REFERENCES docs(id) ON DELETE CASCADE,
text TEXT NOT NULL,
note_id TEXT,
created_at TEXT DEFAULT (datetime('now'))
);
-- 日程模块
CREATE TABLE IF NOT EXISTS schedule_modules (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
emoji TEXT DEFAULT '📌',
color TEXT DEFAULT '#667eea',
sort_order INTEGER DEFAULT 0
);
-- 日程安排(每天的时间块)
CREATE TABLE IF NOT EXISTS schedule_slots (
date TEXT NOT NULL,
time_slot TEXT NOT NULL,
module_id TEXT NOT NULL REFERENCES schedule_modules(id) ON DELETE CASCADE,
PRIMARY KEY (date, time_slot, module_id)
);
-- 每周模板
CREATE TABLE IF NOT EXISTS weekly_template (
day INTEGER NOT NULL,
data TEXT DEFAULT '[]',
PRIMARY KEY (day)
);
-- 周回顾
CREATE TABLE IF NOT EXISTS reviews (
week TEXT PRIMARY KEY,
data TEXT DEFAULT '{}'
);
-- Bug 追踪
CREATE TABLE IF NOT EXISTS bugs (
id TEXT PRIMARY KEY,
text TEXT NOT NULL,
status TEXT DEFAULT 'open',
created_at TEXT DEFAULT (datetime('now'))
);
-- Sleep Buddy 用户
CREATE TABLE IF NOT EXISTS buddy_users (
username TEXT PRIMARY KEY,
password_hash TEXT NOT NULL,
target_time TEXT DEFAULT '22:00',
created_at TEXT DEFAULT (datetime('now'))
);
-- Sleep Buddy 记录
CREATE TABLE IF NOT EXISTS buddy_records (
username TEXT NOT NULL,
date TEXT NOT NULL,
time TEXT NOT NULL,
created_at TEXT DEFAULT (datetime('now')),
PRIMARY KEY (username, date)
);
-- Sleep Buddy 通知
CREATE TABLE IF NOT EXISTS buddy_notifications (
id INTEGER PRIMARY KEY AUTOINCREMENT,
from_user TEXT NOT NULL,
message TEXT NOT NULL,
time TEXT NOT NULL,
date TEXT NOT NULL,
created_at REAL NOT NULL
);
""")
conn.commit()
conn.close()

1071
backend/main.py Normal file

File diff suppressed because it is too large Load Diff

3
backend/requirements.txt Normal file
View File

@@ -0,0 +1,3 @@
fastapi==0.115.6
uvicorn[standard]==0.34.0
aiosqlite==0.20.0

View File

@@ -0,0 +1,50 @@
apiVersion: batch/v1
kind: CronJob
metadata:
name: planner-backup-minio
namespace: planner
spec:
schedule: "0 */6 * * *"
successfulJobsHistoryLimit: 3
failedJobsHistoryLimit: 3
jobTemplate:
spec:
template:
spec:
containers:
- name: backup
image: python:3.12-alpine
command:
- /bin/sh
- -c
- |
apk add --no-cache curl sqlite >/dev/null 2>&1
curl -sL https://dl.min.io/client/mc/release/linux-arm64/mc -o /usr/local/bin/mc
chmod +x /usr/local/bin/mc
mc alias set s3 http://minio.minio.svc:9000 admin HpYMIVH0WN79VkzF4L4z8Zx1
TS=$(date +%Y%m%d_%H%M%S)
# SQLite safe backup
sqlite3 /data/planner.db ".backup /tmp/planner_${TS}.db"
mc cp "/tmp/planner_${TS}.db" "s3/planner-backups/planner_${TS}.db"
# Keep only last 60 backups
mc ls s3/planner-backups/ --json | python3 -c "
import sys, json
files = []
for line in sys.stdin:
d = json.loads(line)
if d.get('key','').startswith('planner_'):
files.append(d['key'])
files.sort()
for f in files[:-60]:
print(f)
" | while read f; do mc rm "s3/planner-backups/$f"; done
echo "Backup done: ${TS}"
volumeMounts:
- name: data
mountPath: /data
readOnly: true
volumes:
- name: data
persistentVolumeClaim:
claimName: planner-data
restartPolicy: OnFailure

47
deploy/deployment.yaml Normal file
View File

@@ -0,0 +1,47 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: planner
namespace: planner
spec:
replicas: 1
selector:
matchLabels:
app: planner
template:
metadata:
labels:
app: planner
spec:
containers:
- name: planner
image: planner:latest
ports:
- containerPort: 8000
env:
- name: DB_PATH
value: /data/planner.db
- name: FRONTEND_DIR
value: /app/frontend
- name: DATA_DIR
value: /data
volumeMounts:
- name: data
mountPath: /data
resources:
requests:
memory: "128Mi"
cpu: "100m"
limits:
memory: "256Mi"
cpu: "500m"
readinessProbe:
httpGet:
path: /api/backups
port: 8000
initialDelaySeconds: 5
periodSeconds: 10
volumes:
- name: data
persistentVolumeClaim:
claimName: planner-data

23
deploy/ingress.yaml Normal file
View File

@@ -0,0 +1,23 @@
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: planner
namespace: planner
annotations:
traefik.ingress.kubernetes.io/router.tls.certresolver: le
spec:
ingressClassName: traefik
rules:
- host: planner.oci.euphon.net
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: planner
port:
number: 80
tls:
- hosts:
- planner.oci.euphon.net

4
deploy/namespace.yaml Normal file
View File

@@ -0,0 +1,4 @@
apiVersion: v1
kind: Namespace
metadata:
name: planner

11
deploy/pvc.yaml Normal file
View File

@@ -0,0 +1,11 @@
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: planner-data
namespace: planner
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 1Gi

11
deploy/service.yaml Normal file
View File

@@ -0,0 +1,11 @@
apiVersion: v1
kind: Service
metadata:
name: planner
namespace: planner
spec:
selector:
app: planner
ports:
- port: 80
targetPort: 8000

View File

@@ -0,0 +1,82 @@
#!/bin/bash
# Creates a restricted kubeconfig for the planner namespace only.
# Run on the k8s server as a user with cluster-admin access.
set -e
NAMESPACE=planner
SA_NAME=planner-deployer
echo "Creating ServiceAccount, Role, and RoleBinding..."
kubectl apply -f - <<EOF
apiVersion: v1
kind: ServiceAccount
metadata:
name: ${SA_NAME}
namespace: ${NAMESPACE}
---
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: ${SA_NAME}-role
namespace: ${NAMESPACE}
rules:
- apiGroups: ["", "apps", "networking.k8s.io"]
resources: ["pods", "services", "deployments", "replicasets", "ingresses", "persistentvolumeclaims", "configmaps", "secrets", "pods/log"]
verbs: ["get", "list", "watch", "create", "update", "patch", "delete"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: ${SA_NAME}-binding
namespace: ${NAMESPACE}
subjects:
- kind: ServiceAccount
name: ${SA_NAME}
namespace: ${NAMESPACE}
roleRef:
kind: Role
name: ${SA_NAME}-role
apiGroup: rbac.authorization.k8s.io
---
apiVersion: v1
kind: Secret
metadata:
name: ${SA_NAME}-token
namespace: ${NAMESPACE}
annotations:
kubernetes.io/service-account.name: ${SA_NAME}
type: kubernetes.io/service-account-token
EOF
echo "Waiting for token..."
sleep 3
# Get cluster info
CLUSTER_SERVER=$(kubectl config view --minify -o jsonpath='{.clusters[0].cluster.server}')
CLUSTER_CA=$(kubectl config view --raw --minify -o jsonpath='{.clusters[0].cluster.certificate-authority-data}')
TOKEN=$(kubectl get secret ${SA_NAME}-token -n ${NAMESPACE} -o jsonpath='{.data.token}' | base64 -d)
cat > kubeconfig <<EOF
apiVersion: v1
kind: Config
clusters:
- cluster:
certificate-authority-data: ${CLUSTER_CA}
server: ${CLUSTER_SERVER}
name: planner
contexts:
- context:
cluster: planner
namespace: ${NAMESPACE}
user: ${SA_NAME}
name: planner
current-context: planner
users:
- name: ${SA_NAME}
user:
token: ${TOKEN}
EOF
echo "Kubeconfig written to ./kubeconfig"
echo "Test with: KUBECONFIG=./kubeconfig kubectl get pods"

362
doc/test-coverage.md Normal file
View File

@@ -0,0 +1,362 @@
# 前端功能点测试覆盖表
> 基于 Vue 3 + Vite + Pinia 重构后的前端,对照原始 vanilla JS 单文件实现的所有功能点。
## 测试类型说明
- **e2e** = Cypress E2E 测试 (真实浏览器 + 后端 API)
- **none** = 尚未覆盖
---
## 1. 应用加载 (App)
| 功能点 | 测试 | 文件 |
|--------|------|------|
| 未登录显示登录遮罩 | e2e | app-load.cy.js |
| 登录框有密码输入和按钮 | e2e | app-load.cy.js |
| 错误密码显示错误提示 | e2e | app-load.cy.js |
| 登录后显示主界面 | e2e | app-load.cy.js |
| 显示 7 个导航 Tab | e2e | app-load.cy.js |
| 菜单按钮打开下拉菜单 | e2e | app-load.cy.js |
| 下拉菜单包含导出/改密/备份/退出 | e2e | app-load.cy.js |
## 2. 认证与登录 (Auth)
| 功能点 | 测试 | 文件 |
|--------|------|------|
| 过期 session 跳转登录 | e2e | auth-flow.cy.js |
| 回车键提交登录 | e2e | auth-flow.cy.js |
| 正确密码登录并存储 session | e2e | auth-flow.cy.js |
| 退出登录清除 session | e2e | auth-flow.cy.js |
| Session 刷新后保持 | e2e | auth-flow.cy.js |
| 修改密码 | none | — |
## 3. 导航与路由 (Navigation)
| 功能点 | 测试 | 文件 |
|--------|------|------|
| 默认 Tab 为随手记 | e2e | navigation.cy.js |
| 点击 Tab 跳转正确路由 | e2e | navigation.cy.js |
| 直接 URL 访问各路由 | e2e | navigation.cy.js |
| 浏览器后退按钮 | e2e | navigation.cy.js |
| 未知路由 SPA 回退 | e2e | navigation.cy.js |
| Sleep Buddy 路由 | e2e | navigation.cy.js |
## 4. 随手记 (Notes)
| 功能点 | 测试 | 文件 |
|--------|------|------|
| 显示输入区和发送按钮 | e2e | notes-flow.cy.js |
| 显示 8 个标签按钮 | e2e | notes-flow.cy.js |
| 切换标签选中状态 | e2e | notes-flow.cy.js |
| 创建笔记(点击按钮) | e2e | notes-flow.cy.js |
| 创建带标签的笔记 | e2e | notes-flow.cy.js |
| 回车键创建笔记 | e2e | notes-flow.cy.js |
| 创建后清空输入框 | e2e | notes-flow.cy.js |
| 不创建空笔记 | e2e | notes-flow.cy.js |
| 搜索过滤笔记 | e2e | notes-flow.cy.js |
| 按标签筛选笔记 | e2e | notes-flow.cy.js |
| 编辑笔记 | e2e | notes-flow.cy.js |
| 删除笔记 | e2e | notes-flow.cy.js |
| 显示空状态提示 | e2e | notes-flow.cy.js |
| 显示笔记时间 | e2e | notes-flow.cy.js |
| 自动识别内容归档文档 | none | — |
## 5. 待办事项 (Tasks)
| 功能点 | 测试 | 文件 |
|--------|------|------|
| 显示 3 个子 Tab待办/目标/清单) | e2e | tasks-flow.cy.js |
| 默认子 Tab 为待办 | e2e | tasks-flow.cy.js |
| 子 Tab 切换 | e2e | tasks-flow.cy.js |
| 收集箱输入 | e2e | tasks-flow.cy.js |
| 添加收集箱条目 | e2e | tasks-flow.cy.js |
| 收集箱分配到象限按钮 | e2e | tasks-flow.cy.js |
| 移入四象限 | e2e | tasks-flow.cy.js |
| 显示 4 个象限 | e2e | tasks-flow.cy.js |
| 直接添加到象限 | e2e | tasks-flow.cy.js |
| 切换完成状态 | e2e | tasks-flow.cy.js |
| 删除待办 | e2e | tasks-flow.cy.js |
| 搜索过滤待办 | e2e | tasks-flow.cy.js |
| 创建目标 | e2e | tasks-flow.cy.js |
| 删除目标 | e2e | tasks-flow.cy.js |
| 创建清单 | e2e | tasks-flow.cy.js |
| 添加清单项目 | e2e | tasks-flow.cy.js |
| 切换清单项完成 | e2e | tasks-flow.cy.js |
| 删除清单 | e2e | tasks-flow.cy.js |
| 目标打卡日历 | none | — |
## 6. 提醒 (Reminders)
| 功能点 | 测试 | 文件 |
|--------|------|------|
| 显示页面和新增按钮 | e2e | reminders-flow.cy.js |
| 打开新增表单 | e2e | reminders-flow.cy.js |
| 创建带时间的提醒 | e2e | reminders-flow.cy.js |
| 不同重复选项 | e2e | reminders-flow.cy.js |
| 切换启用/禁用 | e2e | reminders-flow.cy.js |
| 删除提醒 | e2e | reminders-flow.cy.js |
| 取消按钮关闭表单 | e2e | reminders-flow.cy.js |
| 空状态提示 | e2e | reminders-flow.cy.js |
| 浏览器通知推送 | none | — |
| Service Worker 后台通知 | none | — |
## 7. 健康打卡 (Body - Health)
| 功能点 | 测试 | 文件 |
|--------|------|------|
| 显示 4 个子 Tab | e2e | body-health.cy.js |
| 默认健康打卡 Tab | e2e | body-health.cy.js |
| 显示今日日期 | e2e | body-health.cy.js |
| 添加健康项目到池 | e2e | body-health.cy.js |
| 切换项目到月计划 | e2e | body-health.cy.js |
| 打卡项目 | e2e | body-health.cy.js |
| 取消打卡 | e2e | body-health.cy.js |
| 删除池中项目 | e2e | body-health.cy.js |
| 空状态提示 | e2e | body-health.cy.js |
| 月度日历视图 | none | — |
| 年度热力图 | none | — |
| 健康日记 | none | — |
## 8. 睡眠记录 (Body - Sleep)
| 功能点 | 测试 | 文件 |
|--------|------|------|
| 显示输入框 | e2e | body-sleep.cy.js |
| HH:MM 格式记录 | e2e | body-sleep.cy.js |
| 中文格式记录10点半 | e2e | body-sleep.cy.js |
| 无法识别时显示错误 | e2e | body-sleep.cy.js |
| 删除睡眠记录 | e2e | body-sleep.cy.js |
| 显示记录明细表 | e2e | body-sleep.cy.js |
| 空状态提示 | e2e | body-sleep.cy.js |
| 睡眠趋势图表 (Canvas) | none | — |
| 月度切换 | none | — |
| 年度热力图 | none | — |
| 目标入睡时间 | none | — |
| "我去睡觉啦"按钮 | none | — |
## 9. 健身记录 (Body - Gym)
| 功能点 | 测试 | 文件 |
|--------|------|------|
| 显示页面和新增按钮 | e2e | body-gym.cy.js |
| 打开新增表单 | e2e | body-gym.cy.js |
| 创建健身记录 | e2e | body-gym.cy.js |
| 删除健身记录 | e2e | body-gym.cy.js |
| 取消关闭表单 | e2e | body-gym.cy.js |
## 10. 经期记录 (Body - Period)
| 功能点 | 测试 | 文件 |
|--------|------|------|
| 显示页面和新增按钮 | e2e | body-period.cy.js |
| 打开新增表单 | e2e | body-period.cy.js |
| 创建经期记录(有结束日期) | e2e | body-period.cy.js |
| 创建进行中记录(无结束日期) | e2e | body-period.cy.js |
| 删除经期记录 | e2e | body-period.cy.js |
| 经期周期预测 | none | — |
## 11. 音乐打卡 (Music)
| 功能点 | 测试 | 文件 |
|--------|------|------|
| 显示今日日期 | e2e | music-flow.cy.js |
| 显示练习项目区 | e2e | music-flow.cy.js |
| 添加音乐项目 | e2e | music-flow.cy.js |
| 切换到月计划 | e2e | music-flow.cy.js |
| 打卡练习 | e2e | music-flow.cy.js |
| 取消打卡 | e2e | music-flow.cy.js |
| 删除项目 | e2e | music-flow.cy.js |
| 空状态提示 | e2e | music-flow.cy.js |
| 月度日历视图 | none | — |
| 年度热力图 | none | — |
## 12. 个人文档 (Docs)
| 功能点 | 测试 | 文件 |
|--------|------|------|
| 显示页面头部和描述 | e2e | docs-flow.cy.js |
| 显示新建文档按钮 | e2e | docs-flow.cy.js |
| 打开新建表单 | e2e | docs-flow.cy.js |
| 创建文档 | e2e | docs-flow.cy.js |
| 新文档显示 0 条 | e2e | docs-flow.cy.js |
| 点击打开文档详情 | e2e | docs-flow.cy.js |
| 关闭文档详情 | e2e | docs-flow.cy.js |
| 编辑文档信息 | e2e | docs-flow.cy.js |
| 删除文档 | e2e | docs-flow.cy.js |
| 取消不保存 | e2e | docs-flow.cy.js |
| 图标选择器 | e2e | docs-flow.cy.js |
| 提取规则下拉 | e2e | docs-flow.cy.js |
| 文档条目添加 | none | — (API 层已覆盖) |
| 文档条目删除 | none | — (API 层已覆盖) |
## 13. 日程规划 (Planning - Schedule)
| 功能点 | 测试 | 文件 |
|--------|------|------|
| 显示 3 个子 Tab | e2e | planning-schedule.cy.js |
| 默认日程子 Tab | e2e | planning-schedule.cy.js |
| 显示模块池和时间线 | e2e | planning-schedule.cy.js |
| 显示 18 个时间段 (6-23) | e2e | planning-schedule.cy.js |
| 日期导航 | e2e | planning-schedule.cy.js |
| 切换上/下一天 | e2e | planning-schedule.cy.js |
| 添加活动模块 | e2e | planning-schedule.cy.js |
| 颜色选择器 | e2e | planning-schedule.cy.js |
| 删除活动模块 | e2e | planning-schedule.cy.js |
| 清空当天日程 | e2e | planning-schedule.cy.js |
| 模块可拖拽 | e2e | planning-schedule.cy.js |
| 拖拽放置到时间段 | none | — (Cypress 不支持原生 drag) |
| 导出日程为文本 | none | — |
## 14. 每周模板 (Planning - Template)
| 功能点 | 测试 | 文件 |
|--------|------|------|
| 显示 7 天按钮 | e2e | planning-template.cy.js |
| 默认选中天 | e2e | planning-template.cy.js |
| 切换不同天 | e2e | planning-template.cy.js |
| 显示模板提示 | e2e | planning-template.cy.js |
| 时间线渲染 | none | — |
| 模板编辑保存 | none | — |
## 15. 周回顾 (Planning - Review)
| 功能点 | 测试 | 文件 |
|--------|------|------|
| 显示回顾表单 | e2e | planning-review.cy.js |
| 表单有 3 个区域 | e2e | planning-review.cy.js |
| 保存回顾 | e2e | planning-review.cy.js |
| 历史回顾标题 | e2e | planning-review.cy.js |
| 历史展开/折叠 | e2e | planning-review.cy.js |
## 16. 睡眠打卡 (Sleep Buddy)
| 功能点 | 测试 | 文件 |
|--------|------|------|
| 未登录显示登录表单 | e2e | sleep-buddy.cy.js |
| 登录表单有用户名密码 | e2e | sleep-buddy.cy.js |
| 登录/注册模式切换 | e2e | sleep-buddy.cy.js |
| 注册显示确认密码 | e2e | sleep-buddy.cy.js |
| 密码不一致报错 | e2e | sleep-buddy.cy.js |
| 注册后自动登录 | e2e | sleep-buddy.cy.js |
| 登录后显示主界面 | e2e | sleep-buddy.cy.js |
| 显示目标时间 | e2e | sleep-buddy.cy.js |
| 显示记录输入 | e2e | sleep-buddy.cy.js |
| 记录睡眠时间 | e2e | sleep-buddy.cy.js |
| 无法识别报错 | e2e | sleep-buddy.cy.js |
| "我去睡觉啦"按钮 | e2e | sleep-buddy.cy.js |
| 用户菜单退出 | e2e | sleep-buddy.cy.js |
| 退出返回登录 | e2e | sleep-buddy.cy.js |
| 数据对比统计 | none | — |
| 睡眠趋势图 (Canvas) | none | — |
| 修改目标时间 | none | — |
| 删除记录 | none | — (API 已覆盖) |
## 17. API 健康检查
| 功能点 | 测试 | 文件 |
|--------|------|------|
| GET /api/notes 返回数组 | e2e | api-health.cy.js |
| GET /api/todos 返回数组 | e2e | api-health.cy.js |
| GET /api/inbox 返回数组 | e2e | api-health.cy.js |
| GET /api/reminders 返回数组 | e2e | api-health.cy.js |
| GET /api/goals 返回数组 | e2e | api-health.cy.js |
| GET /api/checklists 返回数组 | e2e | api-health.cy.js |
| GET /api/sleep 返回数组 | e2e | api-health.cy.js |
| GET /api/gym 返回数组 | e2e | api-health.cy.js |
| GET /api/period 返回数组 | e2e | api-health.cy.js |
| GET /api/docs 返回数组 | e2e | api-health.cy.js |
| GET /api/bugs 返回数组 | e2e | api-health.cy.js |
| GET /api/reviews 返回数组 | e2e | api-health.cy.js |
| GET /api/schedule-modules 返回数组 | e2e | api-health.cy.js |
| GET /api/schedule-slots 返回数组 | e2e | api-health.cy.js |
| GET /api/weekly-template 返回数组 | e2e | api-health.cy.js |
| GET /api/health-items 返回数组 | e2e | api-health.cy.js |
| GET /api/health-plans 返回数组 | e2e | api-health.cy.js |
| GET /api/health-checks 返回数组 | e2e | api-health.cy.js |
| GET /api/backups 返回数组 | e2e | api-health.cy.js |
| GET /api/sleep-buddy 返回对象 | e2e | api-health.cy.js |
| POST /api/login 错误密码 401 | e2e | api-health.cy.js |
## 18. API CRUD 操作
| 功能点 | 测试 | 文件 |
|--------|------|------|
| Notes: 创建/删除 | e2e | api-crud.cy.js |
| Todos: 创建/更新(upsert)/删除 | e2e | api-crud.cy.js |
| Inbox: 创建/清空 | e2e | api-crud.cy.js |
| Reminders: 创建/删除 | e2e | api-crud.cy.js |
| Goals: 创建/更新 | e2e | api-crud.cy.js |
| Checklists: 创建 | e2e | api-crud.cy.js |
| Sleep: 创建/Upsert/删除 | e2e | api-crud.cy.js |
| Gym: 创建 | e2e | api-crud.cy.js |
| Period: 创建 | e2e | api-crud.cy.js |
| Docs: 创建 + 创建条目 + 验证嵌套 | e2e | api-crud.cy.js |
| Bugs: 创建/删除 | e2e | api-crud.cy.js |
| Schedule Modules: 创建 | e2e | api-crud.cy.js |
| Schedule Slots: 创建 | e2e | api-crud.cy.js |
| Reviews: 创建 | e2e | api-crud.cy.js |
| Health Items: 创建 | e2e | api-crud.cy.js |
| Health Plans: 保存 | e2e | api-crud.cy.js |
| Health Checks: 切换 | e2e | api-crud.cy.js |
| Backup: 触发备份 | e2e | api-crud.cy.js |
## 19. 响应式布局
| 功能点 | 测试 | 文件 |
|--------|------|------|
| 手机端 (375px) 渲染 | e2e | responsive.cy.js |
| 平板端 (768px) 渲染 | e2e | responsive.cy.js |
| 桌面端 (1280px) 渲染 | e2e | responsive.cy.js |
| 宽屏 (1920px) 渲染 | e2e | responsive.cy.js |
| 手机端 Tab 可滚动 | e2e | responsive.cy.js |
| 手机端四象限垂直堆叠 | e2e | responsive.cy.js |
| 手机端日程垂直堆叠 | e2e | responsive.cy.js |
## 20. 性能
| 功能点 | 测试 | 文件 |
|--------|------|------|
| 页面加载 < 5s | e2e | performance.cy.js |
| API 响应 < 1s | e2e | performance.cy.js |
| Tab 切换瞬时 | e2e | performance.cy.js |
| 批量数据不崩溃 | e2e | performance.cy.js |
---
## 覆盖统计
| 指标 | 数量 |
|------|------|
| **功能点总数** | ~148 |
| **已覆盖功能点** | ~126 |
| **Cypress E2E 测试** | **196** |
| **测试文件数** | **20** |
| **功能点覆盖率** | **~85%** |
### 未覆盖的高风险功能
| 优先级 | 功能 | 风险 | 说明 |
|--------|------|------|------|
| P0 | Canvas 图表渲染 (睡眠趋势) | HIGH | 原版有 Canvas 图表,新版尚未实现 |
| P0 | 拖拽放置到时间段 | HIGH | Cypress 不支持原生 HTML5 DnD |
| P1 | 浏览器通知推送 | MED | Service Worker + 权限 API |
| P1 | 修改密码功能 | MED | 涉及安全敏感操作 |
| P1 | 健康/音乐月度日历 | MED | 日历视图尚未完整实现 |
| P1 | 年度热力图 | MED | 健康/音乐/睡眠的年度视图 |
| P2 | 经期周期预测 | MED | 统计计算逻辑 |
| P2 | Sleep Buddy 数据对比 | MED-LOW | 多用户对比统计 |
| P2 | 自动识别内容归档文档 | MED-LOW | 关键词匹配 + 提取规则 |
| P3 | 导出日程为文本 | LOW | 简单格式化 |
| P3 | 健康日记 | LOW | 额外输入区域 |
### 覆盖最充分的功能
1. **API CRUD 全覆盖** — 27 tests 覆盖所有 18 个资源的增删改查
2. **API 健康检查** — 21 tests 验证全部 20+ 端点返回正确格式
3. **随手记 (Notes)** — 14 tests 覆盖创建/编辑/删除/搜索/筛选
4. **待办系统 (Tasks)** — 18 tests 覆盖收集箱/四象限/目标/清单
5. **睡眠打卡 (Sleep Buddy)** — 14 tests 覆盖注册/登录/记录/通知
6. **文档管理 (Docs)** — 12 tests 覆盖创建/编辑/删除/详情/图标
7. **响应式布局** — 7 tests 覆盖 4 种视口

View File

@@ -0,0 +1,13 @@
import { defineConfig } from 'cypress'
export default defineConfig({
e2e: {
baseUrl: 'http://localhost:5173',
supportFile: 'cypress/support/e2e.js',
specPattern: 'cypress/e2e/**/*.cy.{js,ts}',
viewportWidth: 1280,
viewportHeight: 800,
video: true,
videoCompression: false,
},
})

View File

@@ -0,0 +1,231 @@
describe('API CRUD Operations', () => {
const uid = () => 'cy_' + Date.now().toString(36) + Math.random().toString(36).slice(2, 6)
// ---- Notes ----
it('POST /api/notes creates a note', () => {
const id = uid()
cy.request('POST', '/api/notes', { id, text: 'E2E test note', tag: '灵感' }).then(res => {
expect(res.status).to.eq(200)
expect(res.body.ok).to.be.true
})
})
it('DELETE /api/notes/:id deletes a note', () => {
const id = uid()
cy.request('POST', '/api/notes', { id, text: 'to delete', tag: '灵感' })
cy.request('DELETE', `/api/notes/${id}`).then(res => {
expect(res.status).to.eq(200)
})
})
// ---- Todos ----
it('POST /api/todos creates a todo', () => {
const id = uid()
cy.request('POST', '/api/todos', { id, text: 'E2E todo', quadrant: 'q1', done: 0 }).then(res => {
expect(res.body.ok).to.be.true
})
})
it('POST /api/todos updates a todo (upsert)', () => {
const id = uid()
cy.request('POST', '/api/todos', { id, text: 'before', quadrant: 'q1', done: 0 })
cy.request('POST', '/api/todos', { id, text: 'after', quadrant: 'q2', done: 1 }).then(res => {
expect(res.body.ok).to.be.true
})
cy.request('/api/todos').then(res => {
const todo = res.body.find(t => t.id === id)
expect(todo.text).to.eq('after')
expect(todo.quadrant).to.eq('q2')
expect(todo.done).to.eq(1)
})
})
it('DELETE /api/todos/:id deletes a todo', () => {
const id = uid()
cy.request('POST', '/api/todos', { id, text: 'to delete', quadrant: 'q1', done: 0 })
cy.request('DELETE', `/api/todos/${id}`).then(res => {
expect(res.status).to.eq(200)
})
})
// ---- Inbox ----
it('POST /api/inbox creates inbox item', () => {
const id = uid()
cy.request('POST', '/api/inbox', { id, text: 'inbox test' }).then(res => {
expect(res.body.ok).to.be.true
})
})
it('DELETE /api/inbox clears all inbox', () => {
cy.request('DELETE', '/api/inbox').then(res => {
expect(res.body.ok).to.be.true
})
cy.request('/api/inbox').then(res => {
expect(res.body).to.have.length(0)
})
})
// ---- Reminders ----
it('POST /api/reminders creates a reminder', () => {
const id = uid()
cy.request('POST', '/api/reminders', { id, text: 'test reminder', time: '09:00', repeat: 'daily', enabled: 1 }).then(res => {
expect(res.body.ok).to.be.true
})
})
it('DELETE /api/reminders/:id deletes a reminder', () => {
const id = uid()
cy.request('POST', '/api/reminders', { id, text: 'to delete', repeat: 'none', enabled: 1 })
cy.request('DELETE', `/api/reminders/${id}`).then(res => {
expect(res.status).to.eq(200)
})
})
// ---- Goals ----
it('POST /api/goals creates and updates a goal', () => {
const id = uid()
cy.request('POST', '/api/goals', { id, name: 'test goal', month: '2026-06', checks: '{}' }).then(res => {
expect(res.body.ok).to.be.true
})
cy.request('POST', '/api/goals', { id, name: 'updated goal', month: '2026-07', checks: '{"2026-07-01":true}' })
cy.request('/api/goals').then(res => {
const goal = res.body.find(g => g.id === id)
expect(goal.name).to.eq('updated goal')
})
})
// ---- Checklists ----
it('POST /api/checklists creates a checklist', () => {
const id = uid()
cy.request('POST', '/api/checklists', { id, title: 'test list', items: '[]', archived: 0 }).then(res => {
expect(res.body.ok).to.be.true
})
})
// ---- Sleep ----
it('POST /api/sleep creates a record', () => {
cy.request('POST', '/api/sleep', { date: '2026-01-01', time: '22:30' }).then(res => {
expect(res.body.ok).to.be.true
})
})
it('POST /api/sleep upserts on same date', () => {
cy.request('POST', '/api/sleep', { date: '2026-01-02', time: '22:00' })
cy.request('POST', '/api/sleep', { date: '2026-01-02', time: '23:00' })
cy.request('/api/sleep').then(res => {
const rec = res.body.find(r => r.date === '2026-01-02')
expect(rec.time).to.eq('23:00')
})
})
it('DELETE /api/sleep/:date deletes a record', () => {
cy.request('POST', '/api/sleep', { date: '2026-01-03', time: '21:00' })
cy.request('DELETE', '/api/sleep/2026-01-03').then(res => {
expect(res.status).to.eq(200)
})
})
// ---- Gym ----
it('POST /api/gym creates a record', () => {
const id = uid()
cy.request('POST', '/api/gym', { id, date: '2026-04-07', type: '跑步', duration: '30min', note: '5km' }).then(res => {
expect(res.body.ok).to.be.true
})
})
// ---- Period ----
it('POST /api/period creates a record', () => {
const id = uid()
cy.request('POST', '/api/period', { id, start_date: '2026-04-01', end_date: '2026-04-05', note: '' }).then(res => {
expect(res.body.ok).to.be.true
})
})
// ---- Docs ----
it('POST /api/docs creates a doc', () => {
const id = uid()
cy.request('POST', '/api/docs', { id, name: 'test doc', icon: '📖', keywords: 'test', extract_rule: 'none' }).then(res => {
expect(res.body.ok).to.be.true
})
})
it('POST /api/doc-entries creates an entry', () => {
const docId = uid()
const entryId = uid()
cy.request('POST', '/api/docs', { id: docId, name: 'doc for entry', icon: '📄', keywords: '', extract_rule: 'none' })
cy.request('POST', '/api/doc-entries', { id: entryId, doc_id: docId, text: 'entry text' }).then(res => {
expect(res.body.ok).to.be.true
})
cy.request('/api/docs').then(res => {
const doc = res.body.find(d => d.id === docId)
expect(doc.entries).to.have.length(1)
expect(doc.entries[0].text).to.eq('entry text')
})
})
// ---- Bugs ----
it('POST /api/bugs creates a bug', () => {
const id = uid()
cy.request('POST', '/api/bugs', { id, text: 'test bug', status: 'open' }).then(res => {
expect(res.body.ok).to.be.true
})
})
it('DELETE /api/bugs/:id deletes a bug', () => {
const id = uid()
cy.request('POST', '/api/bugs', { id, text: 'to delete', status: 'open' })
cy.request('DELETE', `/api/bugs/${id}`).then(res => {
expect(res.status).to.eq(200)
})
})
// ---- Schedule ----
it('POST /api/schedule-modules creates a module', () => {
const id = uid()
cy.request('POST', '/api/schedule-modules', { id, name: 'work', emoji: '💼', color: '#667eea', sort_order: 0 }).then(res => {
expect(res.body.ok).to.be.true
})
})
it('POST /api/schedule-slots creates a slot', () => {
const modId = uid()
cy.request('POST', '/api/schedule-modules', { id: modId, name: 'slot test', emoji: '📌', color: '#333', sort_order: 0 })
cy.request('POST', '/api/schedule-slots', { date: '2026-04-07', time_slot: '09:00', module_id: modId }).then(res => {
expect(res.body.ok).to.be.true
})
})
// ---- Reviews ----
it('POST /api/reviews creates a review', () => {
cy.request('POST', '/api/reviews', { week: '2026-W15', data: '{"wins":"test"}' }).then(res => {
expect(res.body.ok).to.be.true
})
})
// ---- Health check-in ----
it('POST /api/health-items creates an item', () => {
const id = uid()
cy.request('POST', '/api/health-items', { id, name: 'vitamin C', type: 'health' }).then(res => {
expect(res.body.ok).to.be.true
})
})
it('POST /api/health-plans saves a plan', () => {
cy.request('POST', '/api/health-plans', { month: '2026-04', type: 'health', item_ids: '["item1"]' }).then(res => {
expect(res.body.ok).to.be.true
})
})
it('POST /api/health-checks toggles a check', () => {
cy.request('POST', '/api/health-checks', { date: '2026-04-07', type: 'health', item_id: 'item1', checked: 1 }).then(res => {
expect(res.body.ok).to.be.true
})
})
// ---- Backup ----
it('POST /api/backup triggers backup', () => {
cy.request('POST', '/api/backup').then(res => {
expect(res.body.ok).to.be.true
})
})
})

View File

@@ -0,0 +1,153 @@
describe('API Health', () => {
it('GET /api/notes returns array', () => {
cy.request('/api/notes').then(res => {
expect(res.status).to.eq(200)
expect(res.body).to.be.an('array')
})
})
it('GET /api/todos returns array', () => {
cy.request('/api/todos').then(res => {
expect(res.status).to.eq(200)
expect(res.body).to.be.an('array')
})
})
it('GET /api/inbox returns array', () => {
cy.request('/api/inbox').then(res => {
expect(res.status).to.eq(200)
expect(res.body).to.be.an('array')
})
})
it('GET /api/reminders returns array', () => {
cy.request('/api/reminders').then(res => {
expect(res.status).to.eq(200)
expect(res.body).to.be.an('array')
})
})
it('GET /api/goals returns array', () => {
cy.request('/api/goals').then(res => {
expect(res.status).to.eq(200)
expect(res.body).to.be.an('array')
})
})
it('GET /api/checklists returns array', () => {
cy.request('/api/checklists').then(res => {
expect(res.status).to.eq(200)
expect(res.body).to.be.an('array')
})
})
it('GET /api/sleep returns array', () => {
cy.request('/api/sleep').then(res => {
expect(res.status).to.eq(200)
expect(res.body).to.be.an('array')
})
})
it('GET /api/gym returns array', () => {
cy.request('/api/gym').then(res => {
expect(res.status).to.eq(200)
expect(res.body).to.be.an('array')
})
})
it('GET /api/period returns array', () => {
cy.request('/api/period').then(res => {
expect(res.status).to.eq(200)
expect(res.body).to.be.an('array')
})
})
it('GET /api/docs returns array', () => {
cy.request('/api/docs').then(res => {
expect(res.status).to.eq(200)
expect(res.body).to.be.an('array')
})
})
it('GET /api/bugs returns array', () => {
cy.request('/api/bugs').then(res => {
expect(res.status).to.eq(200)
expect(res.body).to.be.an('array')
})
})
it('GET /api/reviews returns array', () => {
cy.request('/api/reviews').then(res => {
expect(res.status).to.eq(200)
expect(res.body).to.be.an('array')
})
})
it('GET /api/schedule-modules returns array', () => {
cy.request('/api/schedule-modules').then(res => {
expect(res.status).to.eq(200)
expect(res.body).to.be.an('array')
})
})
it('GET /api/schedule-slots returns array', () => {
cy.request('/api/schedule-slots').then(res => {
expect(res.status).to.eq(200)
expect(res.body).to.be.an('array')
})
})
it('GET /api/weekly-template returns array', () => {
cy.request('/api/weekly-template').then(res => {
expect(res.status).to.eq(200)
expect(res.body).to.be.an('array')
})
})
it('GET /api/health-items returns array', () => {
cy.request('/api/health-items?type=health').then(res => {
expect(res.status).to.eq(200)
expect(res.body).to.be.an('array')
})
})
it('GET /api/health-plans returns array', () => {
cy.request('/api/health-plans?type=health').then(res => {
expect(res.status).to.eq(200)
expect(res.body).to.be.an('array')
})
})
it('GET /api/health-checks returns array', () => {
cy.request('/api/health-checks?type=health').then(res => {
expect(res.status).to.eq(200)
expect(res.body).to.be.an('array')
})
})
it('GET /api/backups returns array', () => {
cy.request('/api/backups').then(res => {
expect(res.status).to.eq(200)
expect(res.body).to.be.an('array')
})
})
it('GET /api/sleep-buddy returns buddy data', () => {
cy.request('/api/sleep-buddy').then(res => {
expect(res.status).to.eq(200)
expect(res.body).to.have.property('users')
expect(res.body).to.have.property('notifications')
})
})
it('POST /api/login rejects wrong password', () => {
cy.request({
method: 'POST',
url: '/api/login',
body: { hash: 'wrong_hash' },
failOnStatusCode: false,
}).then(res => {
expect(res.status).to.eq(401)
})
})
})

View File

@@ -0,0 +1,61 @@
describe('App Loading', () => {
it('shows login overlay when not authenticated', () => {
cy.visit('/')
cy.get('.login-overlay').should('be.visible')
cy.contains('Hera\'s Planner').should('be.visible')
})
it('login overlay has password input and submit button', () => {
cy.visit('/')
cy.get('.login-input[type="password"]').should('be.visible')
cy.get('.login-btn').should('be.visible')
})
it('shows login error for wrong password', () => {
cy.visit('/')
cy.get('.login-input').type('wrongpassword')
cy.get('.login-btn').click()
cy.get('.login-error').should('not.be.empty')
})
it('loads main app after successful login', () => {
cy.visit('/', {
onBeforeLoad(win) {
win.localStorage.setItem('sp_login_expires', String(Date.now() + 86400000))
}
})
cy.get('.login-overlay').should('not.exist')
cy.get('header').should('be.visible')
cy.contains("Hera's Planner").should('be.visible')
})
it('shows all 7 navigation tabs', () => {
cy.visit('/', {
onBeforeLoad(win) {
win.localStorage.setItem('sp_login_expires', String(Date.now() + 86400000))
}
})
cy.get('.tab-btn').should('have.length', 7)
cy.get('.tab-btn').eq(0).should('contain', '随手记')
cy.get('.tab-btn').eq(1).should('contain', '待办')
cy.get('.tab-btn').eq(2).should('contain', '提醒')
cy.get('.tab-btn').eq(3).should('contain', '身体')
cy.get('.tab-btn').eq(4).should('contain', '音乐')
cy.get('.tab-btn').eq(5).should('contain', '文档')
cy.get('.tab-btn').eq(6).should('contain', '日程')
})
it('header menu button opens dropdown', () => {
cy.visit('/', {
onBeforeLoad(win) {
win.localStorage.setItem('sp_login_expires', String(Date.now() + 86400000))
}
})
cy.get('.header-menu-btn').click()
cy.get('.header-dropdown.open').should('be.visible')
cy.contains('退出登录').should('be.visible')
cy.contains('修改密码').should('be.visible')
cy.contains('导出数据').should('be.visible')
cy.contains('手动备份').should('be.visible')
})
})

View File

@@ -0,0 +1,55 @@
describe('Authentication Flow', () => {
it('redirects to login when session expired', () => {
cy.visit('/', {
onBeforeLoad(win) {
win.localStorage.setItem('sp_login_expires', String(Date.now() - 1000))
}
})
cy.get('.login-overlay').should('be.visible')
})
it('login form accepts Enter key', () => {
cy.visit('/')
cy.get('.login-input').type('123456{enter}')
// Should attempt login (success or fail depends on backend)
cy.wait(500)
})
it('valid login stores session and shows app', () => {
cy.visit('/')
cy.get('.login-input').type('123456')
cy.get('.login-btn').click()
// If default password matches, should show main app
cy.get('header', { timeout: 5000 }).should('be.visible')
cy.window().then(win => {
expect(win.localStorage.getItem('sp_login_expires')).to.not.be.null
})
})
it('logout clears session and shows login', () => {
cy.visit('/', {
onBeforeLoad(win) {
win.localStorage.setItem('sp_login_expires', String(Date.now() + 86400000))
}
})
cy.get('header').should('be.visible')
cy.get('.header-menu-btn').click()
cy.contains('退出登录').click()
cy.get('.login-overlay').should('be.visible')
cy.window().then(win => {
expect(win.localStorage.getItem('sp_login_expires')).to.be.null
})
})
it('session persists across page reloads', () => {
cy.visit('/', {
onBeforeLoad(win) {
win.localStorage.setItem('sp_login_expires', String(Date.now() + 86400000))
}
})
cy.get('header').should('be.visible')
cy.reload()
cy.get('header').should('be.visible')
cy.get('.login-overlay').should('not.exist')
})
})

View File

@@ -0,0 +1,45 @@
describe('Body - Gym (身体-健身)', () => {
beforeEach(() => {
cy.visit('/body', {
onBeforeLoad(win) {
win.localStorage.setItem('sp_login_expires', String(Date.now() + 86400000))
}
})
cy.get('.sub-tab').contains('健身').click()
})
it('shows gym section with add button', () => {
cy.contains('健身记录').should('be.visible')
cy.get('.btn-accent').should('contain', '记录')
})
it('opens add gym form', () => {
cy.get('.btn-accent').contains('记录').click()
cy.get('.edit-form').should('be.visible')
cy.get('.edit-form input[type="date"]').should('exist')
})
it('creates a gym record', () => {
cy.get('.btn-accent').contains('记录').click()
cy.get('.edit-form input').eq(1).type('跑步')
cy.get('.edit-form input').eq(2).type('30分钟')
cy.get('.edit-form input').eq(3).type('5公里')
cy.get('.btn-accent').contains('保存').click()
cy.get('.record-card').should('contain', '跑步')
cy.get('.record-card').should('contain', '30分钟')
})
it('deletes a gym record', () => {
cy.get('.btn-accent').contains('记录').click()
cy.get('.edit-form input').eq(1).type('待删除运动')
cy.get('.btn-accent').contains('保存').click()
cy.get('.record-card').contains('待删除运动').parent().find('.remove-btn').click()
cy.get('.record-card').should('not.contain', '待删除运动')
})
it('cancel button closes form', () => {
cy.get('.btn-accent').contains('记录').click()
cy.get('.btn-close').contains('取消').click()
cy.get('.edit-form').should('not.exist')
})
})

View File

@@ -0,0 +1,70 @@
describe('Body - Health Check-in (身体-健康打卡)', () => {
beforeEach(() => {
cy.visit('/body', {
onBeforeLoad(win) {
win.localStorage.setItem('sp_login_expires', String(Date.now() + 86400000))
}
})
cy.get('header').should('be.visible')
})
it('shows 4 sub tabs', () => {
cy.get('.sub-tab').should('have.length', 4)
cy.get('.sub-tab').eq(0).should('contain', '健康打卡')
cy.get('.sub-tab').eq(1).should('contain', '睡眠')
cy.get('.sub-tab').eq(2).should('contain', '健身')
cy.get('.sub-tab').eq(3).should('contain', '经期')
})
it('defaults to health check-in tab', () => {
cy.get('.sub-tab').contains('健康打卡').should('have.class', 'active')
})
it('shows today date and check-in section', () => {
cy.get('.section-header').should('contain', '今日打卡')
cy.get('.date-label').should('not.be.empty')
})
it('adds a health item to pool', () => {
cy.get('.add-row input').type('维生素D')
cy.get('.add-row .btn-accent').click()
cy.get('.pool-item').should('contain', '维生素D')
})
it('toggles item into/out of monthly plan', () => {
cy.get('.add-row input').type('益生菌')
cy.get('.add-row .btn-accent').click()
cy.get('.pool-item').contains('益生菌').click()
// Item should now appear in today checkin grid
cy.get('.checkin-item').should('contain', '益生菌')
})
it('checks in a health item', () => {
cy.get('.add-row input').type('打卡测试项')
cy.get('.add-row .btn-accent').click()
cy.get('.pool-item').contains('打卡测试项').click()
cy.get('.checkin-item').contains('打卡测试项').click()
cy.get('.checkin-item').contains('打卡测试项').should('have.class', 'checked')
})
it('unchecks a health item', () => {
cy.get('.add-row input').type('取消打卡项')
cy.get('.add-row .btn-accent').click()
cy.get('.pool-item').contains('取消打卡项').click()
cy.get('.checkin-item').contains('取消打卡项').click()
cy.get('.checkin-item').contains('取消打卡项').should('have.class', 'checked')
cy.get('.checkin-item').contains('取消打卡项').click()
cy.get('.checkin-item').contains('取消打卡项').should('not.have.class', 'checked')
})
it('deletes a health item from pool', () => {
cy.get('.add-row input').type('删除测试项')
cy.get('.add-row .btn-accent').click()
cy.get('.pool-item').contains('删除测试项').parent().find('.remove-btn').click()
cy.get('.pool-item').should('not.contain', '删除测试项')
})
it('shows empty hint when no plan items', () => {
cy.get('.body-layout').should('be.visible')
})
})

View File

@@ -0,0 +1,45 @@
describe('Body - Period (身体-经期)', () => {
beforeEach(() => {
cy.visit('/body', {
onBeforeLoad(win) {
win.localStorage.setItem('sp_login_expires', String(Date.now() + 86400000))
}
})
cy.get('.sub-tab').contains('经期').click()
})
it('shows period section with add button', () => {
cy.contains('经期记录').should('be.visible')
cy.get('.btn-accent').should('contain', '记录')
})
it('opens add period form', () => {
cy.get('.btn-accent').contains('记录').click()
cy.get('.edit-form').should('be.visible')
cy.get('.edit-form input[type="date"]').should('have.length', 2)
})
it('creates a period record', () => {
cy.get('.btn-accent').contains('记录').click()
cy.get('.edit-form input[type="date"]').first().type('2026-04-01')
cy.get('.edit-form input[type="date"]').eq(1).type('2026-04-05')
cy.get('.btn-accent').contains('保存').click()
cy.get('.record-card').should('contain', '2026-04-01')
cy.get('.record-card').should('contain', '2026-04-05')
})
it('creates period record without end date', () => {
cy.get('.btn-accent').contains('记录').click()
cy.get('.edit-form input[type="date"]').first().type('2026-04-07')
cy.get('.btn-accent').contains('保存').click()
cy.get('.record-card').should('contain', '进行中')
})
it('deletes a period record', () => {
cy.get('.btn-accent').contains('记录').click()
cy.get('.edit-form input[type="date"]').first().type('2026-03-01')
cy.get('.btn-accent').contains('保存').click()
cy.get('.record-card').contains('2026-03-01').parent().find('.remove-btn').click()
cy.get('.record-card').should('not.contain', '2026-03-01')
})
})

View File

@@ -0,0 +1,53 @@
describe('Body - Sleep (身体-睡眠)', () => {
beforeEach(() => {
cy.visit('/body', {
onBeforeLoad(win) {
win.localStorage.setItem('sp_login_expires', String(Date.now() + 86400000))
}
})
cy.get('.sub-tab').contains('睡眠').click()
})
it('shows sleep record input', () => {
cy.get('.capture-input').should('be.visible')
cy.get('.btn-accent').should('be.visible')
})
it('records sleep time with HH:MM format', () => {
cy.get('.capture-input').type('22:30')
cy.get('.btn-accent').contains('记录').click()
cy.get('.sleep-hint').should('contain', '已记录')
cy.get('.data-table').should('contain', '22:30')
})
it('records sleep time with Chinese format', () => {
cy.get('.capture-input').type('10点半')
cy.get('.btn-accent').contains('记录').click()
cy.get('.sleep-hint').should('contain', '已记录')
cy.get('.data-table').should('contain', '10:30')
})
it('shows error for unrecognized time', () => {
cy.get('.capture-input').type('随便写写')
cy.get('.btn-accent').contains('记录').click()
cy.get('.sleep-hint').should('contain', '无法识别')
})
it('deletes a sleep record', () => {
cy.get('.capture-input').type('23:00')
cy.get('.btn-accent').contains('记录').click()
cy.get('.data-table .remove-btn').first().click()
})
it('shows record detail table', () => {
cy.get('.capture-input').type('21:45')
cy.get('.btn-accent').contains('记录').click()
cy.get('.data-table th').should('contain', '日期')
cy.get('.data-table th').should('contain', '入睡时间')
})
it('shows empty hint when no records', () => {
// Component handles both states
cy.get('.sleep-section').should('be.visible')
})
})

View File

@@ -0,0 +1,105 @@
describe('Docs (文档)', () => {
beforeEach(() => {
cy.visit('/docs', {
onBeforeLoad(win) {
win.localStorage.setItem('sp_login_expires', String(Date.now() + 86400000))
}
})
cy.get('header').should('be.visible')
})
it('shows docs page with header', () => {
cy.contains('个人文档').should('be.visible')
cy.contains('随手记会自动识别内容').should('be.visible')
})
it('shows add document button', () => {
cy.get('.btn-accent').should('contain', '新建文档')
})
it('opens new document form', () => {
cy.get('.btn-accent').contains('新建文档').click()
cy.get('.edit-panel').should('be.visible')
cy.contains('文档名称').should('be.visible')
cy.contains('图标').should('be.visible')
cy.contains('关键词').should('be.visible')
cy.contains('提取规则').should('be.visible')
})
it('creates a document', () => {
cy.get('.btn-accent').contains('新建文档').click()
cy.get('.edit-panel input').first().type('读书记录')
cy.get('.emoji-pick').contains('📖').click()
cy.get('.edit-panel input').eq(1).type('读完,看完')
cy.get('.btn-accent').contains('保存').click()
cy.get('.doc-card').should('contain', '读书记录')
cy.get('.doc-card').should('contain', '📖')
})
it('shows 0 entries for new document', () => {
cy.get('.btn-accent').contains('新建文档').click()
cy.get('.edit-panel input').first().type('空文档')
cy.get('.btn-accent').contains('保存').click()
cy.get('.doc-card').contains('空文档').parent().should('contain', '0 条')
})
it('opens document detail on click', () => {
cy.get('.btn-accent').contains('新建文档').click()
cy.get('.edit-panel input').first().type('详情测试')
cy.get('.btn-accent').contains('保存').click()
cy.get('.doc-card').contains('详情测试').click()
cy.get('.overlay.open').should('be.visible')
cy.get('.panel').should('contain', '详情测试')
})
it('closes document detail', () => {
cy.get('.btn-accent').contains('新建文档').click()
cy.get('.edit-panel input').first().type('关闭测试')
cy.get('.btn-accent').contains('保存').click()
cy.get('.doc-card').contains('关闭测试').click()
cy.get('.btn-close').contains('关闭').click()
cy.get('.overlay.open').should('not.exist')
})
it('edits a document from detail view', () => {
cy.get('.btn-accent').contains('新建文档').click()
cy.get('.edit-panel input').first().type('编辑前')
cy.get('.btn-accent').contains('保存').click()
cy.get('.doc-card').contains('编辑前').click()
cy.get('.btn-close').contains('编辑').click()
cy.get('.edit-panel input').first().clear().type('编辑后')
cy.get('.btn-accent').contains('保存').click()
cy.get('.doc-card').should('contain', '编辑后')
})
it('deletes a document from detail view', () => {
cy.get('.btn-accent').contains('新建文档').click()
cy.get('.edit-panel input').first().type('待删除文档')
cy.get('.btn-accent').contains('保存').click()
cy.get('.doc-card').contains('待删除文档').click()
cy.get('.btn-close').contains('删除').click()
cy.get('.doc-card').should('not.contain', '待删除文档')
})
it('cancel button closes form without saving', () => {
cy.get('.btn-accent').contains('新建文档').click()
cy.get('.edit-panel input').first().type('取消测试')
cy.get('.btn-close').contains('取消').click()
cy.get('.edit-panel').should('not.exist')
cy.get('.doc-card').should('not.contain', '取消测试')
})
it('emoji picker works', () => {
cy.get('.btn-accent').contains('新建文档').click()
cy.get('.emoji-pick').should('have.length.gte', 10)
cy.get('.emoji-pick').contains('🌙').click()
cy.get('.emoji-pick').contains('🌙').should('have.class', 'active')
})
it('extract rule dropdown has options', () => {
cy.get('.btn-accent').contains('新建文档').click()
cy.get('.edit-panel select option').should('have.length', 3)
cy.get('.edit-panel select').select('sleep')
cy.get('.edit-panel select').should('have.value', 'sleep')
})
})

View File

@@ -0,0 +1,61 @@
describe('Music (音乐打卡)', () => {
beforeEach(() => {
cy.visit('/music', {
onBeforeLoad(win) {
win.localStorage.setItem('sp_login_expires', String(Date.now() + 86400000))
}
})
cy.get('header').should('be.visible')
})
it('shows music page with today date', () => {
cy.get('.section-header').should('contain', '今日练习')
cy.get('.date-label').should('not.be.empty')
})
it('shows practice items section', () => {
cy.contains('练习项目').should('be.visible')
})
it('adds a music item to pool', () => {
cy.get('.add-row input').type('尤克里里')
cy.get('.add-row .btn-accent').click()
cy.get('.pool-item').should('contain', '尤克里里')
})
it('adds item to monthly plan', () => {
cy.get('.add-row input').type('钢琴')
cy.get('.add-row .btn-accent').click()
cy.get('.pool-item').contains('钢琴').click()
cy.get('.checkin-item').should('contain', '钢琴')
})
it('checks in a music practice', () => {
cy.get('.add-row input').type('吉他')
cy.get('.add-row .btn-accent').click()
cy.get('.pool-item').contains('吉他').click()
cy.get('.checkin-item').contains('吉他').click()
cy.get('.checkin-item').contains('吉他').should('have.class', 'checked')
})
it('unchecks a music practice', () => {
cy.get('.add-row input').type('架子鼓')
cy.get('.add-row .btn-accent').click()
cy.get('.pool-item').contains('架子鼓').click()
cy.get('.checkin-item').contains('架子鼓').click()
cy.get('.checkin-item').contains('架子鼓').should('have.class', 'checked')
cy.get('.checkin-item').contains('架子鼓').click()
cy.get('.checkin-item').contains('架子鼓').should('not.have.class', 'checked')
})
it('deletes a music item', () => {
cy.get('.add-row input').type('待删除乐器')
cy.get('.add-row .btn-accent').click()
cy.get('.pool-item').contains('待删除乐器').parent().find('.remove-btn').click()
cy.get('.pool-item').should('not.contain', '待删除乐器')
})
it('empty state shows hint', () => {
cy.get('.music-layout').should('be.visible')
})
})

View File

@@ -0,0 +1,71 @@
describe('Navigation & Routing', () => {
beforeEach(() => {
cy.visit('/', {
onBeforeLoad(win) {
win.localStorage.setItem('sp_login_expires', String(Date.now() + 86400000))
}
})
cy.get('header').should('be.visible')
})
it('default tab is 随手记', () => {
cy.get('.tab-btn').contains('随手记').should('have.class', 'active')
cy.url().should('eq', Cypress.config('baseUrl') + '/')
})
it('clicking tab navigates to correct route', () => {
const tabs = [
{ label: '待办', path: '/tasks' },
{ label: '提醒', path: '/reminders' },
{ label: '身体', path: '/body' },
{ label: '音乐', path: '/music' },
{ label: '文档', path: '/docs' },
{ label: '日程', path: '/planning' },
{ label: '随手记', path: '/' },
]
tabs.forEach(({ label, path }) => {
cy.get('.tab-btn').contains(label).click()
cy.url().should('include', path === '/' ? Cypress.config('baseUrl') : path)
cy.get('.tab-btn').contains(label).should('have.class', 'active')
})
})
it('direct URL access works for each route', () => {
const routes = ['/tasks', '/reminders', '/body', '/music', '/docs', '/planning']
routes.forEach(route => {
cy.visit(route, {
onBeforeLoad(win) {
win.localStorage.setItem('sp_login_expires', String(Date.now() + 86400000))
}
})
cy.get('header').should('be.visible')
})
})
it('back button works between tabs', () => {
cy.get('.tab-btn').contains('待办').click()
cy.url().should('include', '/tasks')
cy.get('.tab-btn').contains('提醒').click()
cy.url().should('include', '/reminders')
cy.go('back')
cy.url().should('include', '/tasks')
})
it('unknown route still renders the app (SPA fallback)', () => {
cy.visit('/nonexistent', {
onBeforeLoad(win) {
win.localStorage.setItem('sp_login_expires', String(Date.now() + 86400000))
}
})
cy.get('header').should('be.visible')
})
it('sleep buddy route works', () => {
cy.visit('/sleep-buddy', {
onBeforeLoad(win) {
win.localStorage.setItem('sp_login_expires', String(Date.now() + 86400000))
}
})
cy.contains('睡眠打卡').should('be.visible')
})
})

View File

@@ -0,0 +1,115 @@
describe('Notes (随手记)', () => {
beforeEach(() => {
cy.visit('/', {
onBeforeLoad(win) {
win.localStorage.setItem('sp_login_expires', String(Date.now() + 86400000))
}
})
cy.get('header').should('be.visible')
cy.get('.tab-btn').contains('随手记').click()
})
it('shows capture input area', () => {
cy.get('.capture-input').should('be.visible')
cy.get('.capture-btn').should('be.visible')
})
it('shows tag buttons', () => {
cy.get('.tag-btn').should('have.length.gte', 8)
cy.get('.tag-btn').first().should('contain', '💡')
})
it('can select different tags', () => {
cy.get('.tag-btn').contains('✅').click()
cy.get('.tag-btn').contains('✅').should('have.class', 'active')
cy.get('.tag-btn').contains('💡').should('not.have.class', 'active')
})
it('creates a note via input', () => {
cy.get('.capture-input').type('测试笔记内容')
cy.get('.capture-btn').click()
cy.get('.note-card').should('contain', '测试笔记内容')
})
it('creates a note with specific tag', () => {
cy.get('.tag-btn').contains('📖').click()
cy.get('.capture-input').type('读书笔记测试')
cy.get('.capture-btn').click()
cy.get('.note-card').first().should('contain', '读书笔记测试')
cy.get('.note-tag').first().should('contain', '读书')
})
it('creates note via Enter key', () => {
cy.get('.capture-input').type('回车创建笔记{enter}')
cy.get('.note-card').should('contain', '回车创建笔记')
})
it('clears input after creating note', () => {
cy.get('.capture-input').type('清空测试')
cy.get('.capture-btn').click()
cy.get('.capture-input').should('have.value', '')
})
it('does not create empty notes', () => {
cy.get('.note-card').then($cards => {
const count = $cards.length
cy.get('.capture-btn').click()
cy.get('.note-card').should('have.length', count)
})
})
it('can search/filter notes', () => {
// Create 2 notes
cy.get('.capture-input').type('苹果笔记')
cy.get('.capture-btn').click()
cy.get('.capture-input').type('香蕉笔记')
cy.get('.capture-btn').click()
// Search
cy.get('.search-input').type('苹果')
cy.get('.note-card').should('have.length', 1)
cy.get('.note-card').should('contain', '苹果')
})
it('can filter by tag', () => {
cy.get('.tag-btn').contains('💡').click()
cy.get('.capture-input').type('灵感笔记')
cy.get('.capture-btn').click()
cy.get('.tag-btn').contains('⏰').click()
cy.get('.capture-input').type('提醒笔记')
cy.get('.capture-btn').click()
// Filter by 灵感
cy.get('.filter-btn').contains('灵感').click()
cy.get('.note-card').each($card => {
cy.wrap($card).find('.note-tag').should('contain', '灵感')
})
})
it('can edit a note', () => {
cy.get('.capture-input').type('待编辑笔记')
cy.get('.capture-btn').click()
cy.get('.note-action-btn').contains('编辑').first().click()
cy.get('.edit-textarea').clear().type('已编辑笔记')
cy.get('.btn-accent').contains('保存').click()
cy.get('.note-card').first().should('contain', '已编辑笔记')
})
it('can delete a note', () => {
cy.get('.capture-input').type('待删除笔记')
cy.get('.capture-btn').click()
cy.get('.note-card').should('contain', '待删除笔记')
cy.get('.note-action-btn.danger').first().click()
cy.get('.note-card').should('not.contain', '待删除笔记')
})
it('shows empty hint when no notes', () => {
// This depends on initial state — may or may not be empty
// Just verify the component handles both states
cy.get('.notes-layout').should('be.visible')
})
it('displays time for each note', () => {
cy.get('.capture-input').type('带时间的笔记')
cy.get('.capture-btn').click()
cy.get('.note-time').first().should('not.be.empty')
})
})

View File

@@ -0,0 +1,59 @@
describe('Performance', () => {
it('page loads within 5 seconds', () => {
const start = Date.now()
cy.visit('/', {
onBeforeLoad(win) {
win.localStorage.setItem('sp_login_expires', String(Date.now() + 86400000))
}
})
cy.get('header').should('be.visible').then(() => {
const elapsed = Date.now() - start
expect(elapsed).to.be.lessThan(5000)
})
})
it('API responses are under 1 second', () => {
const apis = ['/api/notes', '/api/todos', '/api/reminders', '/api/sleep', '/api/bugs']
apis.forEach(api => {
const start = Date.now()
cy.request(api).then(() => {
const elapsed = Date.now() - start
expect(elapsed).to.be.lessThan(1000)
})
})
})
it('tab switching is instantaneous', () => {
cy.visit('/', {
onBeforeLoad(win) {
win.localStorage.setItem('sp_login_expires', String(Date.now() + 86400000))
}
})
cy.get('header').should('be.visible')
const tabs = ['待办', '提醒', '身体', '音乐', '文档', '日程', '随手记']
tabs.forEach(tab => {
cy.get('.tab-btn').contains(tab).click()
cy.get('.tab-btn').contains(tab).should('have.class', 'active')
})
})
it('creating many notes does not degrade', () => {
cy.visit('/', {
onBeforeLoad(win) {
win.localStorage.setItem('sp_login_expires', String(Date.now() + 86400000))
}
})
cy.get('header').should('be.visible')
// Create 10 notes rapidly
for (let i = 0; i < 10; i++) {
cy.request('POST', '/api/notes', {
id: `perf_${i}_${Date.now()}`,
text: `Performance test note ${i}`,
tag: '灵感'
})
}
// Reload and verify it still loads
cy.reload()
cy.get('header', { timeout: 5000 }).should('be.visible')
})
})

View File

@@ -0,0 +1,40 @@
describe('Planning - Review (日程-回顾)', () => {
beforeEach(() => {
cy.visit('/planning', {
onBeforeLoad(win) {
win.localStorage.setItem('sp_login_expires', String(Date.now() + 86400000))
}
})
cy.get('.sub-tab').contains('回顾').click()
})
it('shows review form', () => {
cy.contains('本周回顾').should('be.visible')
cy.get('.review-form textarea').should('have.length', 3)
})
it('review form has 3 sections', () => {
cy.contains('本周做得好的').should('be.visible')
cy.contains('需要改进的').should('be.visible')
cy.contains('下周计划').should('be.visible')
})
it('saves a review', () => {
cy.get('.review-form textarea').eq(0).type('完成了重构')
cy.get('.review-form textarea').eq(1).type('睡眠不够')
cy.get('.review-form textarea').eq(2).type('早睡早起')
cy.get('.btn-accent').contains('保存回顾').click()
// Should save without error
cy.get('.review-form').should('be.visible')
})
it('shows history section toggle', () => {
cy.contains('历史回顾').should('be.visible')
})
it('toggles history visibility', () => {
cy.contains('历史回顾').click()
// After click, should toggle visibility
cy.contains('历史回顾').should('be.visible')
})
})

View File

@@ -0,0 +1,75 @@
describe('Planning - Schedule (日程)', () => {
beforeEach(() => {
cy.visit('/planning', {
onBeforeLoad(win) {
win.localStorage.setItem('sp_login_expires', String(Date.now() + 86400000))
}
})
cy.get('header').should('be.visible')
})
it('shows 3 sub tabs', () => {
cy.get('.sub-tab').should('have.length', 3)
cy.get('.sub-tab').eq(0).should('contain', '日程')
cy.get('.sub-tab').eq(1).should('contain', '模板')
cy.get('.sub-tab').eq(2).should('contain', '回顾')
})
it('defaults to schedule sub tab', () => {
cy.get('.sub-tab').contains('日程').should('have.class', 'active')
})
it('shows module pool and timeline', () => {
cy.get('.module-pool').should('be.visible')
cy.get('.timeline').should('be.visible')
})
it('shows time slots from 6:00 to 23:00', () => {
cy.get('.time-slot').should('have.length', 18)
cy.get('.time-label').first().should('contain', '06:00')
cy.get('.time-label').last().should('contain', '23:00')
})
it('shows date navigation', () => {
cy.get('.date-nav').should('be.visible')
cy.get('.date-label-main').should('not.be.empty')
})
it('navigates to next/previous day', () => {
cy.get('.date-label-main').invoke('text').then(today => {
cy.get('.date-nav button').first().click()
cy.get('.date-label-main').invoke('text').should('not.eq', today)
})
})
it('adds a schedule module', () => {
cy.get('.module-pool .add-row input').type('深度工作')
cy.get('.module-pool .add-row button').click()
cy.get('.module-item').should('contain', '深度工作')
})
it('color picker works', () => {
cy.get('.color-dot').should('have.length.gte', 10)
cy.get('.color-dot').eq(3).click()
cy.get('.color-dot').eq(3).should('have.class', 'active')
})
it('deletes a schedule module', () => {
cy.get('.module-pool .add-row input').type('待删除模块')
cy.get('.module-pool .add-row button').click()
cy.get('.module-item').contains('待删除模块').parent().find('.remove-btn').click({ force: true })
cy.get('.module-item').should('not.contain', '待删除模块')
})
it('clears all slots for the day', () => {
cy.get('.btn-light').contains('清空').click()
// Just verify it doesn't crash
cy.get('.timeline').should('be.visible')
})
it('module items are draggable', () => {
cy.get('.module-pool .add-row input').type('拖拽测试')
cy.get('.module-pool .add-row button').click()
cy.get('.module-item').contains('拖拽测试').should('have.attr', 'draggable', 'true')
})
})

View File

@@ -0,0 +1,30 @@
describe('Planning - Template (日程-模板)', () => {
beforeEach(() => {
cy.visit('/planning', {
onBeforeLoad(win) {
win.localStorage.setItem('sp_login_expires', String(Date.now() + 86400000))
}
})
cy.get('.sub-tab').contains('模板').click()
})
it('shows 7 day buttons', () => {
cy.get('.day-btn').should('have.length', 7)
cy.get('.day-btn').eq(0).should('contain', '周一')
cy.get('.day-btn').eq(6).should('contain', '周日')
})
it('defaults to 周二 (index 1) as selected', () => {
cy.get('.day-btn').eq(1).should('have.class', 'active')
})
it('switches between days', () => {
cy.get('.day-btn').contains('周五').click()
cy.get('.day-btn').contains('周五').should('have.class', 'active')
cy.get('.day-btn').contains('周二').should('not.have.class', 'active')
})
it('shows template hint', () => {
cy.get('.template-hint').should('be.visible')
})
})

View File

@@ -0,0 +1,73 @@
describe('Reminders (提醒)', () => {
beforeEach(() => {
cy.visit('/reminders', {
onBeforeLoad(win) {
win.localStorage.setItem('sp_login_expires', String(Date.now() + 86400000))
}
})
cy.get('header').should('be.visible')
})
it('shows reminders page with add button', () => {
cy.get('.section-header').should('contain', '提醒')
cy.get('.btn-accent').should('contain', '新提醒')
})
it('opens add reminder form', () => {
cy.get('.btn-accent').contains('新提醒').click()
cy.get('.edit-form').should('be.visible')
cy.get('.edit-form input').should('have.length.gte', 2)
cy.get('.edit-form select').should('exist')
})
it('creates a reminder', () => {
cy.get('.btn-accent').contains('新提醒').click()
cy.get('.edit-form input').first().type('喝水提醒')
cy.get('.edit-form input[type="time"]').type('14:00')
cy.get('.edit-form select').select('daily')
cy.get('.btn-accent').contains('保存').click()
cy.get('.reminder-card').should('contain', '喝水提醒')
cy.get('.reminder-meta').should('contain', '14:00')
cy.get('.reminder-meta').should('contain', '每天')
})
it('creates reminder with different repeat options', () => {
cy.get('.btn-accent').contains('新提醒').click()
cy.get('.edit-form input').first().type('周报提醒')
cy.get('.edit-form select').select('weekly')
cy.get('.btn-accent').contains('保存').click()
cy.get('.reminder-card').should('contain', '周报提醒')
cy.get('.reminder-meta').should('contain', '每周')
})
it('toggles reminder enabled/disabled', () => {
cy.get('.btn-accent').contains('新提醒').click()
cy.get('.edit-form input').first().type('开关测试')
cy.get('.btn-accent').contains('保存').click()
cy.get('.reminder-toggle').first().click()
cy.get('.reminder-toggle').first().should('contain', '🔕')
cy.get('.reminder-toggle').first().click()
cy.get('.reminder-toggle').first().should('contain', '🔔')
})
it('deletes a reminder', () => {
cy.get('.btn-accent').contains('新提醒').click()
cy.get('.edit-form input').first().type('待删除提醒')
cy.get('.btn-accent').contains('保存').click()
cy.get('.reminder-card').contains('待删除提醒').parents('.reminder-card').find('.remove-btn').click()
cy.get('.reminder-card').should('not.contain', '待删除提醒')
})
it('cancel button closes form without saving', () => {
cy.get('.btn-accent').contains('新提醒').click()
cy.get('.edit-form input').first().type('取消测试')
cy.get('.btn-close').contains('取消').click()
cy.get('.edit-form').should('not.exist')
cy.get('.reminder-card').should('not.contain', '取消测试')
})
it('shows empty hint when no reminders', () => {
// Just verify component handles both states gracefully
cy.get('.reminders-layout').should('be.visible')
})
})

View File

@@ -0,0 +1,52 @@
describe('Responsive Layout', () => {
beforeEach(() => {
cy.visit('/', {
onBeforeLoad(win) {
win.localStorage.setItem('sp_login_expires', String(Date.now() + 86400000))
}
})
cy.get('header').should('be.visible')
})
it('mobile viewport (375px) renders correctly', () => {
cy.viewport(375, 812)
cy.get('header').should('be.visible')
cy.get('.tab-btn').should('be.visible')
cy.get('.tab-btn').first().should('be.visible')
})
it('tablet viewport (768px) renders correctly', () => {
cy.viewport(768, 1024)
cy.get('header').should('be.visible')
cy.get('main').should('be.visible')
})
it('desktop viewport (1280px) renders correctly', () => {
cy.viewport(1280, 800)
cy.get('header').should('be.visible')
cy.get('main').should('be.visible')
})
it('mobile: tabs are scrollable', () => {
cy.viewport(375, 812)
cy.get('.tabs').should('have.css', 'overflow-x', 'auto')
})
it('mobile: quadrant grid stacks vertically', () => {
cy.viewport(375, 812)
cy.get('.tab-btn').contains('待办').click()
cy.get('.quadrant-grid').should('be.visible')
})
it('mobile: schedule layout stacks vertically', () => {
cy.viewport(375, 812)
cy.get('.tab-btn').contains('日程').click()
cy.get('.planning-layout').should('be.visible')
})
it('wide viewport (1920px) renders correctly', () => {
cy.viewport(1920, 1080)
cy.get('header').should('be.visible')
cy.get('main').should('be.visible')
})
})

View File

@@ -0,0 +1,128 @@
describe('Sleep Buddy (睡眠打卡)', () => {
beforeEach(() => {
cy.visit('/sleep-buddy', {
onBeforeLoad(win) {
win.localStorage.setItem('sp_login_expires', String(Date.now() + 86400000))
}
})
})
it('shows login form when not logged in as buddy', () => {
cy.get('.buddy-login').should('be.visible')
cy.get('.buddy-login-logo').should('contain', '🌙')
cy.contains('睡眠打卡').should('be.visible')
cy.contains('和好友一起早睡').should('be.visible')
})
it('has username and password fields', () => {
cy.get('.buddy-login-card input').should('have.length.gte', 2)
cy.get('.buddy-login-card input[type="password"]').should('exist')
})
it('toggle between login and register mode', () => {
cy.get('.buddy-toggle-btn').should('contain', '没有账号?注册')
cy.get('.buddy-toggle-btn').click()
cy.get('.buddy-main-btn').should('contain', '注册')
cy.get('.buddy-login-card input').should('have.length', 3) // username, password, confirm
cy.get('.buddy-toggle-btn').should('contain', '已有账号?登录')
})
it('register mode shows confirm password', () => {
cy.get('.buddy-toggle-btn').click()
cy.get('.buddy-login-card input[type="password"]').should('have.length', 2)
})
it('shows error for mismatched passwords during register', () => {
cy.get('.buddy-toggle-btn').click()
cy.get('.buddy-login-card input').eq(0).type('testuser')
cy.get('.buddy-login-card input').eq(1).type('pass1')
cy.get('.buddy-login-card input').eq(2).type('pass2')
cy.get('.buddy-main-btn').click()
cy.get('.buddy-error').should('contain', '密码不一致')
})
it('register then login flow', () => {
const user = 'testuser_' + Date.now()
// Register
cy.get('.buddy-toggle-btn').click()
cy.get('.buddy-login-card input').eq(0).type(user)
cy.get('.buddy-login-card input').eq(1).type('testpass')
cy.get('.buddy-login-card input').eq(2).type('testpass')
cy.get('.buddy-main-btn').click()
// Should be logged in
cy.get('.buddy-main', { timeout: 5000 }).should('be.visible')
cy.contains(user).should('be.visible')
})
// Tests that require buddy login
describe('when logged in', () => {
const user = 'cy_test_' + Math.random().toString(36).slice(2, 8)
beforeEach(() => {
cy.visit('/sleep-buddy', {
onBeforeLoad(win) {
win.localStorage.setItem('sp_login_expires', String(Date.now() + 86400000))
win.localStorage.setItem('buddy_session', JSON.stringify({
username: user,
exp: Date.now() + 86400000
}))
}
})
// Register the user via API first
cy.window().then(async (win) => {
const buf = await win.crypto.subtle.digest('SHA-256', new TextEncoder().encode('testpass'))
const hash = Array.from(new Uint8Array(buf)).map(b => b.toString(16).padStart(2, '0')).join('')
try {
await fetch('/api/buddy-register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username: user, hash })
})
} catch {}
})
})
it('shows main buddy interface', () => {
cy.get('.buddy-main', { timeout: 5000 }).should('be.visible')
cy.get('.sleep-btn').should('contain', '我去睡觉啦')
})
it('shows target time card', () => {
cy.get('.target-card').should('be.visible')
cy.get('.target-time').should('not.be.empty')
})
it('shows record input', () => {
cy.get('.record-card').should('be.visible')
cy.get('.capture-row input').should('be.visible')
})
it('records sleep time', () => {
cy.get('.capture-row input').type('22:30')
cy.get('.capture-row button').click()
cy.get('.buddy-hint').should('contain', '已记录')
})
it('shows error for unrecognized input', () => {
cy.get('.capture-row input').type('乱七八糟')
cy.get('.capture-row button').click()
cy.get('.buddy-hint').should('contain', '无法识别')
})
it('go sleep button sends notification', () => {
cy.get('.sleep-btn').click()
cy.get('.buddy-hint').should('contain', '晚安')
})
it('user menu shows logout', () => {
cy.get('.user-chip').click()
cy.get('.user-menu button').should('contain', '退出登录')
})
it('logout returns to login form', () => {
cy.get('.user-chip').click()
cy.contains('退出登录').click()
cy.get('.buddy-login').should('be.visible')
})
})
})

View File

@@ -0,0 +1,140 @@
describe('Tasks (待办)', () => {
beforeEach(() => {
cy.visit('/tasks', {
onBeforeLoad(win) {
win.localStorage.setItem('sp_login_expires', String(Date.now() + 86400000))
}
})
cy.get('header').should('be.visible')
})
// ---- Sub tabs ----
it('shows sub tabs: 待办, 目标, 清单', () => {
cy.get('.sub-tab').should('have.length', 3)
cy.get('.sub-tab').eq(0).should('contain', '待办')
cy.get('.sub-tab').eq(1).should('contain', '目标')
cy.get('.sub-tab').eq(2).should('contain', '清单')
})
it('defaults to 待办 sub tab', () => {
cy.get('.sub-tab').contains('待办').should('have.class', 'active')
})
it('switches between sub tabs', () => {
cy.get('.sub-tab').contains('目标').click()
cy.get('.sub-tab').contains('目标').should('have.class', 'active')
cy.get('.sub-tab').contains('清单').click()
cy.get('.sub-tab').contains('清单').should('have.class', 'active')
})
// ---- Inbox ----
it('shows inbox input', () => {
cy.get('.inbox-card').should('be.visible')
cy.get('.inbox-card .capture-input').should('be.visible')
})
it('adds item to inbox', () => {
cy.get('.inbox-card .capture-input').type('收集箱测试')
cy.get('.inbox-card .capture-btn').click()
cy.get('.inbox-item').should('contain', '收集箱测试')
})
it('inbox item has quadrant assignment buttons', () => {
cy.get('.inbox-card .capture-input').type('分类测试')
cy.get('.inbox-card .capture-btn').click()
cy.get('.inbox-item-actions button').should('have.length.gte', 4)
})
it('moves inbox item to quadrant', () => {
cy.get('.inbox-card .capture-input').type('移入q1')
cy.get('.inbox-card .capture-btn').click()
// Click 🔴 (q1 - urgent important)
cy.get('.inbox-item').contains('移入q1').parent().find('.inbox-item-actions button').first().click()
cy.get('.inbox-item').should('not.contain', '移入q1')
cy.get('.todo-item').should('contain', '移入q1')
})
// ---- Quadrants ----
it('shows 4 quadrants', () => {
cy.get('.quadrant').should('have.length', 4)
cy.get('.q-urgent-important').should('contain', '紧急且重要')
cy.get('.q-important').should('contain', '重要不紧急')
cy.get('.q-urgent').should('contain', '紧急不重要')
cy.get('.q-neither').should('contain', '不紧急不重要')
})
it('adds todo directly to a quadrant', () => {
cy.get('.q-urgent-important .add-todo-row input').type('直接添加任务{enter}')
cy.get('.q-urgent-important .todo-item').should('contain', '直接添加任务')
})
it('toggles todo completion', () => {
cy.get('.q-important .add-todo-row input').type('完成测试{enter}')
cy.get('.q-important .todo-item').contains('完成测试').parent().find('input[type="checkbox"]').check()
// Enable "show done" to verify
cy.get('#todoShowDone, .toggle-label input').check()
cy.get('.todo-item').contains('完成测试').parent().find('span.done').should('exist')
})
it('deletes a todo', () => {
cy.get('.q-neither .add-todo-row input').type('待删除todo{enter}')
cy.get('.todo-item').contains('待删除todo').parent().find('.remove-btn').click()
cy.get('.todo-item').should('not.contain', '待删除todo')
})
it('search filters todos', () => {
cy.get('.q-urgent-important .add-todo-row input').type('搜索目标A{enter}')
cy.get('.q-important .add-todo-row input').type('搜索目标B{enter}')
cy.get('.search-input').type('目标A')
cy.get('.todo-item').should('have.length', 1)
cy.get('.todo-item').should('contain', '搜索目标A')
})
// ---- Goals ----
it('creates a goal', () => {
cy.get('.sub-tab').contains('目标').click()
cy.get('.btn-accent').contains('新目标').click()
cy.get('.edit-form input').first().type('减肥5斤')
cy.get('.edit-form input[type="month"]').type('2026-06')
cy.get('.btn-accent').contains('保存').click()
cy.get('.goal-card').should('contain', '减肥5斤')
})
it('deletes a goal', () => {
cy.get('.sub-tab').contains('目标').click()
cy.get('.btn-accent').contains('新目标').click()
cy.get('.edit-form input').first().type('待删除目标')
cy.get('.btn-accent').contains('保存').click()
cy.get('.goal-card').contains('待删除目标').parent().find('.remove-btn').click()
cy.get('.goal-card').should('not.contain', '待删除目标')
})
// ---- Checklists ----
it('creates a checklist', () => {
cy.get('.sub-tab').contains('清单').click()
cy.get('.btn-accent').contains('新清单').click()
cy.get('.checklist-card').should('exist')
})
it('adds items to checklist', () => {
cy.get('.sub-tab').contains('清单').click()
cy.get('.btn-accent').contains('新清单').click()
cy.get('.checklist-card .add-todo-row input').first().type('清单项目1{enter}')
cy.get('.checklist-item').should('contain', '清单项目1')
})
it('toggles checklist item', () => {
cy.get('.sub-tab').contains('清单').click()
cy.get('.btn-accent').contains('新清单').click()
cy.get('.checklist-card .add-todo-row input').first().type('打勾测试{enter}')
cy.get('.checklist-item').contains('打勾测试').parent().find('input[type="checkbox"]').check()
cy.get('.checklist-item').contains('打勾测试').should('have.class', 'done')
})
it('deletes a checklist', () => {
cy.get('.sub-tab').contains('清单').click()
cy.get('.btn-accent').contains('新清单').click()
cy.get('.checklist-card').should('exist')
cy.get('.checklist-header .remove-btn').first().click()
})
})

View File

@@ -0,0 +1,31 @@
// Ignore uncaught exceptions from the Vue app during E2E tests.
Cypress.on('uncaught:exception', () => false)
// Login as planner user by injecting session into localStorage
Cypress.Commands.add('loginAsPlanner', (password = '123456') => {
// Hash the password and call login API
cy.window().then(async (win) => {
const buf = await win.crypto.subtle.digest('SHA-256', new TextEncoder().encode(password))
const hash = Array.from(new Uint8Array(buf)).map(b => b.toString(16).padStart(2, '0')).join('')
cy.request('POST', '/api/login', { hash }).then(() => {
win.localStorage.setItem('sp_login_expires', String(Date.now() + 7 * 86400000))
})
})
})
// Inject planner login session directly (skip API call)
Cypress.Commands.add('injectSession', () => {
cy.window().then(win => {
win.localStorage.setItem('sp_login_expires', String(Date.now() + 7 * 86400000))
})
})
// Navigate via tab button
Cypress.Commands.add('goToTab', (label) => {
cy.get('.tab-btn').contains(label).click()
})
// Verify toast message appears
Cypress.Commands.add('expectToast', (text) => {
cy.get('.toast').should('contain', text)
})

18
frontend/index.html Normal file
View File

@@ -0,0 +1,18 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate">
<title>Hera's Planner</title>
<link rel="icon" type="image/png" href="/icon-180.png">
<link rel="apple-touch-icon" href="/icon-180.png">
<link rel="manifest" href="/manifest.json">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-title" content="Planner">
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>

3361
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

24
frontend/package.json Normal file
View File

@@ -0,0 +1,24 @@
{
"name": "planner-frontend",
"private": true,
"version": "2.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"cy:open": "cypress open",
"cy:run": "cypress run",
"test:e2e": "cypress run"
},
"dependencies": {
"pinia": "^2.3.1",
"vue": "^3.5.32",
"vue-router": "^4.6.4"
},
"devDependencies": {
"@vitejs/plugin-vue": "^6.0.5",
"cypress": "^15.13.1",
"vite": "^8.0.4"
}
}

View File

@@ -0,0 +1,13 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<rect x="4" y="10" width="56" height="48" rx="8" fill="#667eea"/>
<rect x="4" y="10" width="56" height="16" rx="8" fill="#764ba2"/>
<rect x="4" y="20" width="56" height="6" fill="#764ba2"/>
<circle cx="18" cy="10" r="3" fill="#fff"/>
<circle cx="46" cy="10" r="3" fill="#fff"/>
<rect x="14" y="34" width="10" height="8" rx="2" fill="#e8f5e9"/>
<rect x="27" y="34" width="10" height="8" rx="2" fill="#e3f2fd"/>
<rect x="40" y="34" width="10" height="8" rx="2" fill="#fff3e0"/>
<rect x="14" y="46" width="10" height="8" rx="2" fill="#fce4ec"/>
<rect x="27" y="46" width="10" height="8" rx="2" fill="#f3e5f5"/>
<rect x="40" y="46" width="10" height="8" rx="2" fill="#e0f7fa"/>
</svg>

After

Width:  |  Height:  |  Size: 764 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.0 KiB

View File

@@ -0,0 +1,11 @@
{
"name": "Hera's Planner",
"short_name": "Planner",
"start_url": "/",
"display": "standalone",
"background_color": "#f0f2f5",
"theme_color": "#667eea",
"icons": [
{ "src": "icon-180.png", "sizes": "180x180", "type": "image/png" }
]
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 405 KiB

25
frontend/public/sw.js Normal file
View File

@@ -0,0 +1,25 @@
// Service Worker for Hera's Planner — 后台提醒通知
self.addEventListener('install', () => self.skipWaiting());
self.addEventListener('activate', () => self.clients.claim());
self.addEventListener('message', event => {
if (event.data && event.data.type === 'SHOW_NOTIFICATION') {
self.registration.showNotification(event.data.title, {
body: event.data.body,
icon: 'icon-180.png',
badge: 'icon-180.png',
requireInteraction: true,
tag: event.data.tag || 'planner-reminder',
});
}
});
self.addEventListener('notificationclick', event => {
event.notification.close();
event.waitUntil(
self.clients.matchAll({ type: 'window' }).then(clients => {
if (clients.length > 0) { clients[0].focus(); }
else { self.clients.openWindow('/'); }
})
);
});

158
frontend/src/App.vue Normal file
View File

@@ -0,0 +1,158 @@
<template>
<!-- Login overlay -->
<div v-if="!auth.loggedIn" class="login-overlay">
<div class="login-banner">
<div class="circle c1"></div>
<div class="circle c2"></div>
<div class="circle c3"></div>
</div>
<div class="login-card">
<h1 class="login-title">Hera's Planner</h1>
<p class="login-subtitle">规划每一天,成为更好的自己</p>
<div class="login-input-wrap">
<input
class="login-input"
type="password"
placeholder="密码"
v-model="password"
@keydown.enter="doLogin"
>
<button class="login-btn" @click="doLogin">进入</button>
</div>
<div class="login-error">{{ loginError }}</div>
</div>
</div>
<!-- Main app -->
<template v-else>
<header>
<div class="header-main">
<div class="header-top">
<h1>
Hera's Planner
<span class="header-subtitle">v2.0</span>
</h1>
<div class="header-actions">
<button class="header-menu-btn" @click="showMenu = !showMenu"></button>
<div v-if="menuMask" class="dropdown-mask open" @click="showMenu = false"></div>
<div class="header-dropdown" :class="{ open: showMenu }">
<button @click="doExport">导出数据</button>
<button @click="doChangePassword">修改密码</button>
<button @click="doBackup">手动备份</button>
<button class="dd-danger" @click="doLogout">退出登录</button>
</div>
</div>
</div>
</div>
<nav class="tabs">
<button
v-for="tab in tabs"
:key="tab.key"
class="tab-btn"
:class="{ active: ui.currentTab === tab.key }"
@click="goTab(tab.key)"
>{{ tab.label }}</button>
</nav>
</header>
<main>
<router-view />
</main>
</template>
<!-- Toast messages -->
<div v-for="(msg, i) in ui.toasts" :key="i" class="toast">{{ msg }}</div>
<!-- Custom Dialog -->
<CustomDialog />
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useAuthStore } from './stores/auth'
import { useUiStore } from './stores/ui'
import { usePlannerStore } from './stores/planner'
import CustomDialog from './components/CustomDialog.vue'
const auth = useAuthStore()
const ui = useUiStore()
const planner = usePlannerStore()
const router = useRouter()
const route = useRoute()
const password = ref('')
const loginError = ref('')
const showMenu = ref(false)
const menuMask = computed(() => showMenu.value)
const tabs = [
{ key: 'notes', label: '随手记', path: '/' },
{ key: 'tasks', label: '待办', path: '/tasks' },
{ key: 'reminders', label: '提醒', path: '/reminders' },
{ key: 'body', label: '身体', path: '/body' },
{ key: 'music', label: '音乐', path: '/music' },
{ key: 'docs', label: '文档', path: '/docs' },
{ key: 'planning', label: '日程', path: '/planning' },
]
const tabKeyToPath = Object.fromEntries(tabs.map(t => [t.key, t.path]))
const pathToTabKey = Object.fromEntries(tabs.map(t => [t.path, t.key]))
function goTab(key) {
ui.setTab(key)
router.push(tabKeyToPath[key] || '/')
}
async function doLogin() {
loginError.value = ''
try {
await auth.login(password.value)
password.value = ''
planner.loadAll()
} catch (e) {
loginError.value = e.message || '登录失败'
}
}
function doLogout() {
showMenu.value = false
auth.logout()
}
function doExport() {
showMenu.value = false
ui.toast('导出功能开发中')
}
async function doChangePassword() {
showMenu.value = false
ui.toast('修改密码功能开发中')
}
async function doBackup() {
showMenu.value = false
try {
const { api } = await import('./composables/useApi')
await api.post('/api/backup')
ui.toast('备份完成')
} catch {
ui.toast('备份失败')
}
}
onMounted(async () => {
// Sync tab from route
const tabKey = pathToTabKey[route.path] || 'notes'
ui.setTab(tabKey)
if (auth.loggedIn) {
await planner.loadAll()
}
// Register service worker
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('/sw.js').catch(() => {})
}
})
</script>

View File

@@ -0,0 +1,455 @@
:root {
--primary: #667eea;
--primary-dark: #5a6fd6;
--primary-light: #f0f0ff;
--accent: #764ba2;
--danger: #ef4444;
--bg: #f0f2f5;
--card: #ffffff;
--text: #333333;
--text-light: #888888;
--text-muted: #cccccc;
--border: #e0e0e0;
--shadow: 0 2px 12px rgba(0,0,0,0.06);
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
background: var(--bg);
color: var(--text);
min-height: 100vh;
}
/* ===== Login ===== */
.login-overlay {
position: fixed; inset: 0; z-index: 9999;
background: linear-gradient(135deg, var(--primary), var(--accent));
display: flex; align-items: center; justify-content: center; flex-direction: column;
}
.login-banner { position: absolute; inset: 0; overflow: hidden; pointer-events: none; }
.login-banner .circle { position: absolute; border-radius: 50%; background: rgba(255,255,255,0.06); }
.login-banner .c1 { width: 400px; height: 400px; top: -100px; right: -80px; }
.login-banner .c2 { width: 250px; height: 250px; bottom: -60px; left: -40px; }
.login-banner .c3 { width: 150px; height: 150px; top: 40%; left: 60%; }
.login-card { position: relative; z-index: 1; text-align: center; color: white; padding: 40px; }
.login-title { font-size: 32px; font-weight: 700; margin-bottom: 6px; }
.login-subtitle { font-size: 14px; color: rgba(255,255,255,0.6); margin-bottom: 32px; }
.login-input-wrap { display: flex; gap: 10px; justify-content: center; }
.login-input {
padding: 12px 20px; border: 2px solid rgba(255,255,255,0.25); border-radius: 14px;
background: rgba(255,255,255,0.1); color: white; font-size: 16px; outline: none; width: 220px;
backdrop-filter: blur(8px);
}
.login-input:focus { border-color: rgba(255,255,255,0.6); }
.login-input::placeholder { color: rgba(255,255,255,0.4); }
.login-btn {
padding: 12px 28px; border: none; border-radius: 14px; background: white; color: var(--primary);
font-size: 16px; font-weight: 600; cursor: pointer;
}
.login-btn:hover { transform: scale(1.03); box-shadow: 0 4px 16px rgba(0,0,0,0.15); }
.login-error { color: #fca5a5; font-size: 13px; margin-top: 12px; min-height: 20px; }
/* ===== Header ===== */
header { position: sticky; top: 0; z-index: 100; background: linear-gradient(135deg, var(--primary), var(--accent)); }
.header-main { padding: 14px 24px; }
.header-top { display: flex; align-items: center; justify-content: space-between; }
header h1 { font-size: 20px; font-weight: 700; color: white; }
.header-subtitle { font-size: 11px; color: rgba(255,255,255,0.6); margin-left: 8px; font-weight: 400; }
.header-actions { position: relative; }
.header-menu-btn {
width: 36px; height: 36px; border: none; border-radius: 8px; background: transparent;
color: rgba(255,255,255,0.8); font-size: 20px; cursor: pointer;
}
.header-menu-btn:hover { background: rgba(255,255,255,0.15); }
.header-dropdown {
display: none; position: fixed; top: 48px; right: 12px; background: white;
border-radius: 12px; box-shadow: 0 8px 32px rgba(0,0,0,0.25); min-width: 160px; overflow: hidden; z-index: 10001;
}
.header-dropdown.open { display: block; }
.dropdown-mask { display: none; position: fixed; inset: 0; z-index: 10000; background: rgba(0,0,0,0.1); }
.dropdown-mask.open { display: block; }
.header-dropdown button {
display: block; width: 100%; padding: 12px 18px; border: none; background: none;
text-align: left; font-size: 14px; color: #555; cursor: pointer;
}
.header-dropdown button:hover { background: #f5f5f5; }
.dd-danger { color: var(--danger) !important; }
.dd-danger:hover { background: #fef2f2 !important; }
/* ===== Tabs ===== */
.tabs {
display: flex; overflow-x: auto; padding: 0 16px; gap: 0;
scrollbar-width: none; background: rgba(255,255,255,0.1);
}
.tabs::-webkit-scrollbar { display: none; }
.tab-btn {
padding: 8px 14px; margin: 6px 3px; background: rgba(255,255,255,0.2);
border: none; border-radius: 8px; color: rgba(255,255,255,0.7);
font-size: 13px; white-space: nowrap; font-weight: 500; cursor: pointer; transition: all 0.2s;
}
.tab-btn:hover { background: rgba(255,255,255,0.35); color: white; }
.tab-btn.active { background: rgba(255,255,255,0.5); color: white; font-weight: 600; }
/* ===== Sub tabs ===== */
.sub-tabs { display: flex; gap: 8px; margin-bottom: 20px; flex-wrap: wrap; }
.sub-tab {
padding: 8px 18px; border-radius: 20px; border: 1.5px solid var(--border); background: white;
font-size: 13px; cursor: pointer; transition: all 0.2s;
}
.sub-tab:hover { border-color: var(--primary); color: var(--primary); }
.sub-tab.active { background: var(--primary); color: white; border-color: var(--primary); }
/* ===== Main content ===== */
main { padding: 24px; max-width: 900px; margin: 0 auto; }
/* ===== Common ===== */
.btn { padding: 8px 18px; border: none; border-radius: 8px; cursor: pointer; font-size: 14px; font-weight: 500; transition: all 0.2s; }
.btn-accent { background: var(--primary); color: white; }
.btn-accent:hover { background: var(--primary-dark); }
.btn-light { background: #f5f5f5; color: var(--text-light); }
.btn-light:hover { background: #eee; }
.btn-close { background: #eee; color: #666; }
.btn-close:hover { background: #ddd; }
.section-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 16px; }
.section-header h3 { font-size: 16px; color: #444; }
.empty-hint { text-align: center; color: var(--text-muted); padding: 30px; font-size: 13px; }
.remove-btn {
width: 20px; height: 20px; border-radius: 50%; border: none; background: rgba(0,0,0,0.06);
color: #999; font-size: 11px; cursor: pointer; display: flex; align-items: center; justify-content: center; flex-shrink: 0;
}
.remove-btn:hover { background: var(--danger); color: white; }
.date-label { font-size: 13px; color: var(--text-light); }
/* ===== Capture / Input ===== */
.capture-card { background: white; border-radius: 14px; padding: 16px; box-shadow: var(--shadow); margin-bottom: 16px; }
.capture-row { display: flex; gap: 10px; align-items: flex-start; }
.capture-input {
flex: 1; padding: 10px 14px; border: 1.5px solid var(--border); border-radius: 10px;
font-size: 14px; outline: none; resize: none; min-height: 40px; font-family: inherit;
}
.capture-input:focus { border-color: var(--primary); }
.capture-btn {
width: 40px; height: 40px; border: none; border-radius: 10px; background: var(--primary);
color: white; font-size: 18px; cursor: pointer; flex-shrink: 0;
}
.tag-btns { display: flex; gap: 6px; margin-top: 10px; flex-wrap: wrap; }
.tag-btn {
padding: 4px 10px; border-radius: 8px; border: 1.5px solid var(--border); background: white;
font-size: 14px; cursor: pointer;
}
.tag-btn.active { border-color: var(--primary); background: var(--primary-light); }
/* ===== Toolbar ===== */
.toolbar { display: flex; gap: 10px; align-items: center; margin-bottom: 16px; flex-wrap: wrap; }
.search-input {
flex: 1; min-width: 120px; padding: 8px 14px; border: 1.5px solid var(--border);
border-radius: 10px; font-size: 13px; outline: none;
}
.search-input:focus { border-color: var(--primary); }
.toggle-label { display: flex; align-items: center; gap: 6px; font-size: 13px; color: var(--text-light); cursor: pointer; }
.filter-row { display: flex; gap: 4px; flex-wrap: wrap; width: 100%; }
.filter-btn {
padding: 4px 10px; border-radius: 6px; border: 1px solid var(--border); background: white;
font-size: 12px; cursor: pointer;
}
.filter-btn.active { background: var(--primary); color: white; border-color: var(--primary); }
/* ===== Notes ===== */
.notes-layout { }
.note-card { background: white; border-radius: 12px; padding: 14px 16px; margin-bottom: 10px; box-shadow: var(--shadow); }
.note-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px; }
.note-tag { padding: 2px 8px; border-radius: 6px; font-size: 11px; font-weight: 500; }
.note-time { font-size: 11px; color: var(--text-muted); }
.note-text { font-size: 14px; line-height: 1.6; white-space: pre-wrap; cursor: pointer; }
.note-text:hover { color: var(--primary); }
.note-actions { display: flex; gap: 8px; margin-top: 8px; justify-content: flex-end; }
.note-action-btn { background: none; border: none; font-size: 12px; color: var(--text-light); cursor: pointer; }
.note-action-btn:hover { color: var(--primary); }
.note-action-btn.danger:hover { color: var(--danger); }
.note-edit { margin-top: 8px; }
.edit-textarea { width: 100%; padding: 10px; border: 1.5px solid var(--border); border-radius: 8px; font-size: 14px; outline: none; font-family: inherit; }
.edit-actions { display: flex; gap: 8px; justify-content: flex-end; margin-top: 10px; }
/* ===== Tasks / Todos ===== */
.tasks-layout { }
.inbox-card { background: white; border-radius: 12px; padding: 14px 16px; box-shadow: var(--shadow); margin-bottom: 16px; }
.inbox-item { display: flex; align-items: center; justify-content: space-between; padding: 8px 0; border-bottom: 1px solid #f0f0f0; font-size: 13px; }
.inbox-item-actions { display: flex; gap: 4px; }
.inbox-item-actions button { background: none; border: none; font-size: 14px; cursor: pointer; padding: 2px 4px; }
.quadrant-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
.quadrant {
background: white; border-radius: 14px; padding: 16px; min-height: 150px;
box-shadow: var(--shadow); border-top: 4px solid; display: flex; flex-direction: column;
}
.quadrant-title { font-size: 14px; font-weight: 600; margin-bottom: 4px; }
.quadrant-desc { font-size: 11px; color: #aaa; margin-bottom: 12px; }
.q-urgent-important { border-top-color: #ef4444; background: #fef2f2; }
.q-urgent-important .quadrant-title { color: #dc2626; }
.q-important { border-top-color: #f59e0b; background: #fffbeb; }
.q-important .quadrant-title { color: #d97706; }
.q-urgent { border-top-color: #3b82f6; background: #eff6ff; }
.q-urgent .quadrant-title { color: #2563eb; }
.q-neither { border-top-color: #94a3b8; background: #f8fafc; }
.q-neither .quadrant-title { color: #64748b; }
.todo-item { display: flex; align-items: center; gap: 8px; padding: 6px 0; font-size: 13px; }
.todo-item .done { text-decoration: line-through; color: #ccc; }
.add-todo-row { margin-top: 8px; }
.add-todo-row input {
width: 100%; padding: 8px 12px; border: 1.5px dashed var(--border); border-radius: 8px;
font-size: 13px; outline: none; background: transparent;
}
.add-todo-row input:focus { border-color: var(--primary); border-style: solid; }
/* ===== Reminders ===== */
.reminders-layout { }
.reminder-card { background: white; border-radius: 12px; padding: 14px; margin-bottom: 10px; box-shadow: var(--shadow); }
.reminder-main { display: flex; align-items: center; gap: 12px; }
.reminder-toggle { font-size: 20px; cursor: pointer; }
.reminder-content { flex: 1; }
.reminder-text { font-size: 14px; }
.reminder-meta { font-size: 12px; color: var(--text-light); margin-top: 2px; }
/* ===== Health check-in ===== */
.checkin-grid { display: flex; flex-wrap: wrap; gap: 10px; margin-bottom: 16px; }
.checkin-item {
padding: 10px 16px; border-radius: 10px; background: white; border: 1.5px solid var(--border);
cursor: pointer; font-size: 14px; display: flex; align-items: center; gap: 6px; transition: all 0.2s;
}
.checkin-item.checked { border-color: #10b981; background: #ecfdf5; }
.checkin-check { font-size: 16px; }
.pool-items { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 12px; }
.pool-item {
display: flex; align-items: center; gap: 6px; padding: 6px 12px; border-radius: 8px;
background: white; border: 1px solid var(--border); font-size: 13px; cursor: pointer;
}
.add-row { display: flex; gap: 8px; }
.add-row input { flex: 1; padding: 8px 12px; border: 1.5px solid var(--border); border-radius: 8px; font-size: 13px; outline: none; }
.add-row input:focus { border-color: var(--primary); }
/* ===== Data table ===== */
.data-table { width: 100%; border-collapse: collapse; font-size: 13px; }
.data-table th { text-align: left; padding: 8px; color: var(--text-light); font-weight: 500; border-bottom: 1px solid var(--border); }
.data-table td { padding: 8px; border-bottom: 1px solid #f0f0f0; }
/* ===== Records ===== */
.record-card { background: white; border-radius: 12px; padding: 14px 16px; margin-bottom: 10px; box-shadow: var(--shadow); position: relative; }
.record-card .remove-btn { position: absolute; top: 10px; right: 10px; }
.record-note { font-size: 12px; color: var(--text-light); margin-top: 4px; }
/* ===== Edit form ===== */
.edit-form {
background: white; border-radius: 14px; padding: 20px; box-shadow: var(--shadow); margin-top: 16px;
display: flex; flex-direction: column; gap: 10px;
}
.edit-form label { font-size: 13px; color: var(--text-light); font-weight: 500; }
.edit-form input, .edit-form select, .edit-form textarea {
padding: 10px 14px; border: 1.5px solid var(--border); border-radius: 10px;
font-size: 14px; outline: none; font-family: inherit;
}
.edit-form input:focus, .edit-form select:focus, .edit-form textarea:focus { border-color: var(--primary); }
/* ===== Docs ===== */
.doc-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); gap: 12px; }
.doc-card {
background: white; border-radius: 14px; padding: 20px 16px; text-align: center;
box-shadow: var(--shadow); cursor: pointer; transition: all 0.2s;
}
.doc-card:hover { transform: translateY(-2px); box-shadow: 0 4px 20px rgba(0,0,0,0.1); }
.doc-icon { font-size: 32px; margin-bottom: 8px; }
.doc-name { font-size: 14px; font-weight: 600; }
.doc-count { font-size: 12px; color: var(--text-light); margin-top: 4px; }
.doc-entry { padding: 10px 0; border-bottom: 1px solid #f0f0f0; }
.doc-entry-text { font-size: 14px; line-height: 1.5; }
.doc-entry-meta { display: flex; align-items: center; justify-content: space-between; margin-top: 4px; font-size: 11px; color: var(--text-muted); }
.emoji-row { display: flex; gap: 6px; flex-wrap: wrap; }
.emoji-pick { font-size: 20px; cursor: pointer; padding: 4px; border-radius: 6px; }
.emoji-pick.active { background: var(--primary-light); }
/* ===== Planning / Schedule ===== */
.schedule-flex { display: flex; gap: 24px; }
.module-pool { width: 240px; flex-shrink: 0; }
.pool-card { background: white; border-radius: 14px; padding: 18px; box-shadow: var(--shadow); }
.pool-card h3 { font-size: 15px; color: var(--text-light); margin-bottom: 14px; }
.module-item {
display: flex; align-items: center; gap: 8px; padding: 8px 12px; border-radius: 10px;
cursor: grab; margin-bottom: 6px; font-size: 13px; font-weight: 500; position: relative;
}
.module-item:active { cursor: grabbing; }
.module-item .emoji { font-size: 16px; }
.module-item .remove-btn { position: absolute; right: 6px; display: none; }
.module-item:hover .remove-btn { display: flex; }
.color-picker { display: flex; gap: 6px; margin-top: 10px; flex-wrap: wrap; }
.color-dot {
width: 22px; height: 22px; border-radius: 50%; cursor: pointer;
border: 2.5px solid transparent; transition: all 0.15s;
}
.color-dot:hover { transform: scale(1.15); }
.color-dot.active { border-color: #333; }
.timeline { flex: 1; min-width: 0; }
.date-nav { display: flex; align-items: center; gap: 14px; margin-bottom: 16px; }
.date-nav button:not(.btn) {
width: 34px; height: 34px; border-radius: 50%; border: none; background: white;
cursor: pointer; font-size: 16px; box-shadow: var(--shadow);
}
.date-nav button:not(.btn):hover { background: var(--primary); color: white; }
.date-label-main { font-size: 18px; font-weight: 600; color: #444; }
.time-slot { display: flex; gap: 0; margin-bottom: 4px; min-height: 56px; }
.time-label {
width: 56px; flex-shrink: 0; padding-top: 10px; font-size: 13px; font-weight: 600;
color: #999; text-align: right; padding-right: 14px;
}
.slot-drop {
flex: 1; background: white; border-radius: 10px; min-height: 48px; padding: 6px 10px;
display: flex; flex-wrap: wrap; gap: 6px; align-items: flex-start; border: 2px solid transparent;
box-shadow: 0 1px 4px rgba(0,0,0,0.04); transition: all 0.2s;
}
.slot-drop.drag-over { border-color: var(--primary); background: var(--primary-light); }
.placed-item {
display: inline-flex; align-items: center; gap: 4px; padding: 4px 10px;
border-radius: 8px; font-size: 12px; font-weight: 500; position: relative;
}
.remove-placed {
width: 14px; height: 14px; border-radius: 50%; border: none; background: rgba(0,0,0,0.15);
color: white; font-size: 9px; cursor: pointer; display: none; align-items: center; justify-content: center; margin-left: 4px;
}
.placed-item:hover .remove-placed { display: flex; }
/* Day tabs */
.day-tabs { display: flex; gap: 8px; margin-bottom: 20px; flex-wrap: wrap; }
.day-btn {
padding: 8px 18px; border-radius: 20px; border: 1.5px solid #ddd; background: white;
font-size: 13px; cursor: pointer;
}
.day-btn:hover { border-color: var(--primary); color: var(--primary); }
.day-btn.active { background: var(--primary); color: white; border-color: var(--primary); }
/* Review */
.review-form textarea { width: 100%; }
.review-card { background: white; border-radius: 12px; padding: 14px; margin-bottom: 10px; box-shadow: var(--shadow); }
.review-content { font-size: 13px; white-space: pre-wrap; margin-top: 8px; color: var(--text-light); }
.template-hint { text-align: center; color: #aaa; font-size: 12px; margin-top: 20px; padding: 12px; border: 1.5px dashed #ddd; border-radius: 10px; }
/* ===== Overlay / Panel ===== */
.overlay {
display: none; position: fixed; inset: 0; z-index: 1000; background: rgba(0,0,0,0.4);
align-items: center; justify-content: center;
}
.overlay.open { display: flex; }
.panel { background: white; border-radius: 16px; padding: 24px; box-shadow: 0 16px 48px rgba(0,0,0,0.2); }
.edit-panel { display: flex; flex-direction: column; gap: 10px; }
.edit-panel label { font-size: 13px; color: var(--text-light); font-weight: 500; }
.edit-panel input, .edit-panel select {
padding: 10px 14px; border: 1.5px solid var(--border); border-radius: 10px; font-size: 14px; outline: none;
}
.edit-panel input:focus, .edit-panel select:focus { border-color: var(--primary); }
/* ===== Dialog ===== */
.dialog-overlay {
display: none; position: fixed; inset: 0; z-index: 2000; background: rgba(0,0,0,0.5);
align-items: center; justify-content: center;
}
.dialog-overlay.open { display: flex; }
.dialog-box { background: white; border-radius: 16px; padding: 24px; min-width: 300px; box-shadow: 0 16px 48px rgba(0,0,0,0.3); }
.dialog-msg { font-size: 15px; margin-bottom: 16px; line-height: 1.5; }
.dialog-input { width: 100%; padding: 10px 14px; border: 1.5px solid var(--border); border-radius: 10px; font-size: 14px; outline: none; margin-bottom: 16px; }
.dialog-input:focus { border-color: var(--primary); }
.dialog-btns { display: flex; gap: 8px; justify-content: flex-end; }
.dialog-cancel { padding: 8px 18px; border: none; border-radius: 8px; background: #f0f0f0; color: #666; cursor: pointer; font-size: 14px; }
.dialog-ok { padding: 8px 18px; border: none; border-radius: 8px; background: var(--primary); color: white; cursor: pointer; font-size: 14px; }
.dialog-danger { padding: 8px 18px; border: none; border-radius: 8px; background: var(--danger); color: white; cursor: pointer; font-size: 14px; }
/* ===== Toast ===== */
.toast {
position: fixed; bottom: 80px; left: 50%; transform: translateX(-50%); z-index: 3000;
background: #333; color: white; padding: 10px 24px; border-radius: 10px; font-size: 14px;
box-shadow: 0 4px 16px rgba(0,0,0,0.3); animation: toastIn 0.3s;
}
@keyframes toastIn { from { opacity: 0; transform: translateX(-50%) translateY(10px); } to { opacity: 1; transform: translateX(-50%) translateY(0); } }
/* ===== Sleep Buddy ===== */
.buddy-layout { }
.buddy-login {
display: flex; flex-direction: column; align-items: center; justify-content: center;
min-height: 60vh; text-align: center;
}
.buddy-login-logo { font-size: 56px; margin-bottom: 16px; }
.buddy-login h1 { font-size: 26px; font-weight: 700; color: #333; }
.buddy-login p { font-size: 13px; color: #aaa; margin-bottom: 24px; }
.buddy-login-card { background: white; border-radius: 20px; padding: 28px 24px; width: 300px; box-shadow: var(--shadow); display: flex; flex-direction: column; gap: 12px; }
.buddy-login-card input { padding: 12px 16px; border: 1.5px solid var(--border); border-radius: 12px; font-size: 15px; outline: none; }
.buddy-login-card input:focus { border-color: var(--primary); }
.buddy-main-btn { width: 100%; padding: 14px; border: none; border-radius: 12px; background: linear-gradient(135deg, var(--primary), var(--accent)); color: white; font-size: 16px; font-weight: 600; cursor: pointer; }
.buddy-toggle-btn { background: none; border: none; color: var(--text-muted); font-size: 13px; cursor: pointer; }
.buddy-error { color: var(--danger); font-size: 13px; min-height: 20px; }
.buddy-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 16px; }
.buddy-header h2 { font-size: 18px; }
.user-chip {
padding: 6px 12px; border-radius: 10px; background: #f5f5f5; cursor: pointer;
font-size: 13px; color: var(--text-light); position: relative;
}
.user-menu {
position: absolute; top: 36px; right: 0; background: white; border-radius: 10px;
box-shadow: 0 4px 16px rgba(0,0,0,0.15); overflow: hidden; z-index: 100; min-width: 100px;
}
.user-menu button {
display: block; width: 100%; padding: 10px 16px; border: none; background: none;
color: var(--text-light); font-size: 13px; cursor: pointer; text-align: left;
}
.user-menu button:hover { background: #f5f5f5; }
.sleep-btn {
display: block; width: 100%; padding: 20px; border: none; border-radius: 18px;
background: linear-gradient(135deg, var(--primary), var(--accent)); color: white;
font-size: 20px; font-weight: 700; cursor: pointer; margin-bottom: 14px;
box-shadow: 0 6px 24px rgba(102,126,234,0.4);
}
.notif-bar {
background: linear-gradient(135deg, var(--primary), var(--accent)); border-radius: 14px;
padding: 14px; margin-bottom: 14px; text-align: center; color: white; font-size: 14px;
}
.target-card {
display: flex; align-items: center; justify-content: space-between; background: white;
border-radius: 12px; padding: 12px 16px; margin-bottom: 14px; box-shadow: var(--shadow); font-size: 13px;
}
.target-time { font-size: 16px; color: var(--primary); font-weight: 600; }
.target-card button { padding: 4px 10px; border: 1px solid var(--border); border-radius: 6px; background: white; color: var(--text-light); font-size: 12px; cursor: pointer; }
.buddy-hint { font-size: 12px; color: var(--text-muted); margin-top: 6px; min-height: 16px; }
.buddy-record { display: flex; align-items: center; gap: 12px; padding: 8px 0; border-bottom: 1px solid #f0f0f0; font-size: 13px; }
.buddy-record span:first-child { flex: 1; }
/* ===== Responsive ===== */
@media (max-width: 768px) {
main { padding: 16px; }
.quadrant-grid { grid-template-columns: 1fr; }
.schedule-flex { flex-direction: column; }
.module-pool { width: 100%; }
.doc-grid { grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)); }
}
/* ===== Checklist ===== */
.checklist-card { background: white; border-radius: 12px; padding: 14px 16px; margin-bottom: 10px; box-shadow: var(--shadow); }
.checklist-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px; }
.checklist-title-input { border: none; font-size: 15px; font-weight: 600; outline: none; width: 100%; }
.checklist-item { display: flex; align-items: center; gap: 8px; padding: 4px 0; font-size: 13px; }
.checklist-item .done { text-decoration: line-through; color: #ccc; }
/* ===== Goal ===== */
.goal-card { background: white; border-radius: 12px; padding: 14px 16px; margin-bottom: 10px; box-shadow: var(--shadow); }
.goal-header { display: flex; align-items: center; gap: 10px; }
.goal-header strong { flex: 1; }
.goal-month { font-size: 12px; color: var(--text-light); }
.sleep-hint { font-size: 12px; color: var(--primary); margin-bottom: 12px; }

View File

@@ -0,0 +1,41 @@
<template>
<div v-if="dialog.visible.value" class="dialog-overlay open" @click.self="dialog.closeDialog(null)">
<div class="dialog-box">
<div class="dialog-msg">{{ dialog.message.value }}</div>
<input
v-if="dialog.type.value !== 'confirm'"
class="dialog-input"
:type="dialog.inputType.value"
v-model="dialog.inputValue.value"
@keydown.enter="dialog.closeDialog(dialog.inputValue.value)"
ref="inputEl"
>
<div class="dialog-btns">
<button class="dialog-cancel" @click="dialog.closeDialog(dialog.type.value === 'confirm' ? false : null)">取消</button>
<button
v-if="dialog.type.value === 'date'"
class="dialog-cancel"
@click="dialog.closeDialog('')"
>清除</button>
<button
:class="dialog.type.value === 'confirm' ? 'dialog-danger' : 'dialog-ok'"
@click="dialog.closeDialog(dialog.type.value === 'confirm' ? true : dialog.inputValue.value)"
>确定</button>
</div>
</div>
</div>
</template>
<script setup>
import { ref, watch, nextTick } from 'vue'
import { useDialog } from '../composables/useDialog'
const dialog = useDialog()
const inputEl = ref(null)
watch(() => dialog.visible.value, (v) => {
if (v && dialog.type.value !== 'confirm') {
nextTick(() => inputEl.value?.focus())
}
})
</script>

View File

@@ -0,0 +1,33 @@
const API_BASE = ''
async function request(path, opts = {}) {
const headers = { 'Content-Type': 'application/json', ...opts.headers }
const res = await fetch(API_BASE + path, { ...opts, headers })
return res
}
async function requestJSON(path, opts = {}) {
const res = await request(path, opts)
if (!res.ok) {
let msg = `${res.status}`
try {
const body = await res.json()
msg = body.detail || body.message || msg
} catch {}
const err = new Error(msg)
err.status = res.status
throw err
}
return res.json()
}
function apiFn(path, opts = {}) {
return request(path, opts)
}
apiFn.get = (path) => requestJSON(path)
apiFn.post = (path, body) => requestJSON(path, { method: 'POST', body: JSON.stringify(body) })
apiFn.put = (path, body) => requestJSON(path, { method: 'PUT', body: JSON.stringify(body) })
apiFn.del = (path) => requestJSON(path, { method: 'DELETE' })
export const api = apiFn

View File

@@ -0,0 +1,45 @@
import { ref } from 'vue'
const visible = ref(false)
const message = ref('')
const type = ref('confirm') // 'confirm' | 'prompt' | 'date' | 'time'
const inputValue = ref('')
const inputType = ref('text')
let resolvePromise = null
export function useDialog() {
function showDialog(msg, dialogType = 'confirm', defaultVal = '') {
return new Promise(resolve => {
resolvePromise = resolve
message.value = msg
type.value = dialogType
if (dialogType === 'prompt') {
inputType.value = msg.includes('密码') ? 'password' : 'text'
} else if (dialogType === 'date') {
inputType.value = 'date'
} else if (dialogType === 'time') {
inputType.value = 'time'
}
inputValue.value = defaultVal
visible.value = true
})
}
function closeDialog(value) {
visible.value = false
if (resolvePromise) {
resolvePromise(value)
resolvePromise = null
}
}
return {
visible,
message,
type,
inputValue,
inputType,
showDialog,
closeDialog,
}
}

10
frontend/src/main.js Normal file
View File

@@ -0,0 +1,10 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
import router from './router'
import './assets/styles.css'
const app = createApp(App)
app.use(createPinia())
app.use(router)
app.mount('#app')

View File

@@ -0,0 +1,51 @@
import { createRouter, createWebHistory } from 'vue-router'
const routes = [
{
path: '/',
name: 'Notes',
component: () => import('../views/NotesView.vue'),
},
{
path: '/tasks',
name: 'Tasks',
component: () => import('../views/TasksView.vue'),
},
{
path: '/reminders',
name: 'Reminders',
component: () => import('../views/RemindersView.vue'),
},
{
path: '/body',
name: 'Body',
component: () => import('../views/BodyView.vue'),
},
{
path: '/music',
name: 'Music',
component: () => import('../views/MusicView.vue'),
},
{
path: '/docs',
name: 'Docs',
component: () => import('../views/DocsView.vue'),
},
{
path: '/planning',
name: 'Planning',
component: () => import('../views/PlanningView.vue'),
},
{
path: '/sleep-buddy',
name: 'SleepBuddy',
component: () => import('../views/SleepBuddyView.vue'),
},
]
const router = createRouter({
history: createWebHistory(),
routes,
})
export default router

View File

@@ -0,0 +1,42 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { api } from '../composables/useApi'
export const useAuthStore = defineStore('auth', () => {
const loggedIn = ref(false)
function checkLogin() {
const exp = localStorage.getItem('sp_login_expires')
loggedIn.value = exp && Date.now() < parseInt(exp)
return loggedIn.value
}
async function login(password) {
const buf = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(password))
const hash = Array.from(new Uint8Array(buf)).map(b => b.toString(16).padStart(2, '0')).join('')
await api.post('/api/login', { hash })
localStorage.setItem('sp_login_expires', String(Date.now() + 7 * 86400000))
loggedIn.value = true
}
function logout() {
localStorage.removeItem('sp_login_expires')
loggedIn.value = false
}
async function changePassword(oldPass, newPass) {
const hash = async (s) => {
const buf = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(s))
return Array.from(new Uint8Array(buf)).map(b => b.toString(16).padStart(2, '0')).join('')
}
await api.post('/api/change-password', {
oldHash: await hash(oldPass),
newHash: await hash(newPass),
})
}
// Auto-check on init
checkLogin()
return { loggedIn, checkLogin, login, logout, changePassword }
})

View File

@@ -0,0 +1,360 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { api } from '../composables/useApi'
export const usePlannerStore = defineStore('planner', () => {
const notes = ref([])
const todos = ref([])
const inbox = ref([])
const reminders = ref([])
const goals = ref([])
const checklists = ref([])
const sleepRecords = ref([])
const gymRecords = ref([])
const periodRecords = ref([])
const docs = ref([])
const bugs = ref([])
const reviews = ref([])
const healthItems = ref([])
const healthPlans = ref([])
const healthChecks = ref([])
const musicItems = ref([])
const musicPlans = ref([])
const musicChecks = ref([])
const scheduleModules = ref([])
const scheduleSlots = ref([])
const weeklyTemplate = ref([])
const loading = ref(false)
async function loadAll() {
loading.value = true
try {
const [
notesData, todosData, inboxData, remindersData,
goalsData, checklistsData, sleepData, gymData,
periodData, docsData, bugsData, reviewsData,
hItems, hPlans, hChecks,
mItems, mPlans, mChecks,
sMods, sSlots, wTemplate,
] = await Promise.all([
api.get('/api/notes'),
api.get('/api/todos'),
api.get('/api/inbox'),
api.get('/api/reminders'),
api.get('/api/goals'),
api.get('/api/checklists'),
api.get('/api/sleep'),
api.get('/api/gym'),
api.get('/api/period'),
api.get('/api/docs'),
api.get('/api/bugs'),
api.get('/api/reviews'),
api.get('/api/health-items?type=health'),
api.get('/api/health-plans?type=health'),
api.get('/api/health-checks?type=health'),
api.get('/api/health-items?type=music'),
api.get('/api/health-plans?type=music'),
api.get('/api/health-checks?type=music'),
api.get('/api/schedule-modules'),
api.get('/api/schedule-slots'),
api.get('/api/weekly-template'),
])
notes.value = notesData
todos.value = todosData
inbox.value = inboxData
reminders.value = remindersData
goals.value = goalsData
checklists.value = checklistsData
sleepRecords.value = sleepData
gymRecords.value = gymData
periodRecords.value = periodData
docs.value = docsData
bugs.value = bugsData
reviews.value = reviewsData
healthItems.value = hItems
healthPlans.value = hPlans
healthChecks.value = hChecks
musicItems.value = mItems
musicPlans.value = mPlans
musicChecks.value = mChecks
scheduleModules.value = sMods
scheduleSlots.value = sSlots
weeklyTemplate.value = wTemplate
} finally {
loading.value = false
}
}
// ---- Notes ----
async function addNote(note) {
await api.post('/api/notes', note)
notes.value.unshift(note)
}
async function deleteNote(id) {
await api.del(`/api/notes/${id}`)
notes.value = notes.value.filter(n => n.id !== id)
}
async function updateNote(note) {
await api.post('/api/notes', note)
const idx = notes.value.findIndex(n => n.id === note.id)
if (idx >= 0) notes.value[idx] = { ...notes.value[idx], ...note }
}
// ---- Todos ----
async function addTodo(todo) {
await api.post('/api/todos', todo)
todos.value.unshift(todo)
}
async function updateTodo(todo) {
await api.post('/api/todos', todo)
const idx = todos.value.findIndex(t => t.id === todo.id)
if (idx >= 0) todos.value[idx] = { ...todos.value[idx], ...todo }
}
async function deleteTodo(id) {
await api.del(`/api/todos/${id}`)
todos.value = todos.value.filter(t => t.id !== id)
}
// ---- Inbox ----
async function addInbox(item) {
await api.post('/api/inbox', item)
inbox.value.unshift(item)
}
async function deleteInbox(id) {
await api.del(`/api/inbox/${id}`)
inbox.value = inbox.value.filter(i => i.id !== id)
}
async function clearInbox() {
await api.del('/api/inbox')
inbox.value = []
}
// ---- Reminders ----
async function addReminder(r) {
await api.post('/api/reminders', r)
reminders.value.push(r)
}
async function updateReminder(r) {
await api.post('/api/reminders', r)
const idx = reminders.value.findIndex(x => x.id === r.id)
if (idx >= 0) reminders.value[idx] = { ...reminders.value[idx], ...r }
}
async function deleteReminder(id) {
await api.del(`/api/reminders/${id}`)
reminders.value = reminders.value.filter(r => r.id !== id)
}
// ---- Goals ----
async function addGoal(g) {
await api.post('/api/goals', g)
goals.value.unshift(g)
}
async function updateGoal(g) {
await api.post('/api/goals', g)
const idx = goals.value.findIndex(x => x.id === g.id)
if (idx >= 0) goals.value[idx] = { ...goals.value[idx], ...g }
}
async function deleteGoal(id) {
await api.del(`/api/goals/${id}`)
goals.value = goals.value.filter(g => g.id !== id)
}
// ---- Checklists ----
async function addChecklist(cl) {
await api.post('/api/checklists', cl)
checklists.value.unshift(cl)
}
async function updateChecklist(cl) {
await api.post('/api/checklists', cl)
const idx = checklists.value.findIndex(x => x.id === cl.id)
if (idx >= 0) checklists.value[idx] = { ...checklists.value[idx], ...cl }
}
async function deleteChecklist(id) {
await api.del(`/api/checklists/${id}`)
checklists.value = checklists.value.filter(c => c.id !== id)
}
// ---- Sleep ----
async function addSleep(record) {
await api.post('/api/sleep', record)
const idx = sleepRecords.value.findIndex(r => r.date === record.date)
if (idx >= 0) sleepRecords.value[idx] = record
else sleepRecords.value.unshift(record)
}
async function deleteSleep(date) {
await api.del(`/api/sleep/${date}`)
sleepRecords.value = sleepRecords.value.filter(r => r.date !== date)
}
// ---- Gym ----
async function addGym(record) {
await api.post('/api/gym', record)
gymRecords.value.unshift(record)
}
async function deleteGym(id) {
await api.del(`/api/gym/${id}`)
gymRecords.value = gymRecords.value.filter(r => r.id !== id)
}
// ---- Period ----
async function addPeriod(record) {
await api.post('/api/period', record)
periodRecords.value.unshift(record)
}
async function updatePeriod(record) {
await api.post('/api/period', record)
const idx = periodRecords.value.findIndex(r => r.id === record.id)
if (idx >= 0) periodRecords.value[idx] = { ...periodRecords.value[idx], ...record }
}
async function deletePeriod(id) {
await api.del(`/api/period/${id}`)
periodRecords.value = periodRecords.value.filter(r => r.id !== id)
}
// ---- Docs ----
async function addDoc(doc) {
await api.post('/api/docs', doc)
docs.value.push({ ...doc, entries: [] })
}
async function updateDoc(doc) {
await api.post('/api/docs', doc)
const idx = docs.value.findIndex(d => d.id === doc.id)
if (idx >= 0) docs.value[idx] = { ...docs.value[idx], ...doc }
}
async function deleteDoc(id) {
await api.del(`/api/docs/${id}`)
docs.value = docs.value.filter(d => d.id !== id)
}
async function addDocEntry(entry) {
await api.post('/api/doc-entries', entry)
const doc = docs.value.find(d => d.id === entry.doc_id)
if (doc) {
if (!doc.entries) doc.entries = []
doc.entries.unshift(entry)
}
}
async function deleteDocEntry(entryId, docId) {
await api.del(`/api/doc-entries/${entryId}`)
const doc = docs.value.find(d => d.id === docId)
if (doc) doc.entries = doc.entries.filter(e => e.id !== entryId)
}
// ---- Bugs ----
async function addBug(bug) {
await api.post('/api/bugs', bug)
bugs.value.unshift(bug)
}
async function updateBug(bug) {
await api.post('/api/bugs', bug)
const idx = bugs.value.findIndex(b => b.id === bug.id)
if (idx >= 0) bugs.value[idx] = { ...bugs.value[idx], ...bug }
}
async function deleteBug(id) {
await api.del(`/api/bugs/${id}`)
bugs.value = bugs.value.filter(b => b.id !== id)
}
// ---- Reviews ----
async function saveReview(review) {
await api.post('/api/reviews', review)
const idx = reviews.value.findIndex(r => r.week === review.week)
if (idx >= 0) reviews.value[idx] = review
else reviews.value.unshift(review)
}
// ---- Health check-in ----
async function addHealthItem(item) {
await api.post('/api/health-items', item)
if (item.type === 'music') musicItems.value.push(item)
else healthItems.value.push(item)
}
async function deleteHealthItem(id) {
await api.del(`/api/health-items/${id}`)
healthItems.value = healthItems.value.filter(i => i.id !== id)
musicItems.value = musicItems.value.filter(i => i.id !== id)
}
async function saveHealthPlan(plan) {
await api.post('/api/health-plans', plan)
const list = plan.type === 'music' ? musicPlans : healthPlans
const idx = list.value.findIndex(p => p.month === plan.month && p.type === plan.type)
if (idx >= 0) list.value[idx] = plan
else list.value.push(plan)
}
async function toggleHealthCheck(check) {
await api.post('/api/health-checks', check)
const list = check.type === 'music' ? musicChecks : healthChecks
if (check.checked) {
list.value.push(check)
} else {
list.value = list.value.filter(
c => !(c.date === check.date && c.type === check.type && c.item_id === check.item_id)
)
}
}
// ---- Schedule ----
async function addScheduleModule(m) {
await api.post('/api/schedule-modules', m)
scheduleModules.value.push(m)
}
async function updateScheduleModule(m) {
await api.post('/api/schedule-modules', m)
const idx = scheduleModules.value.findIndex(x => x.id === m.id)
if (idx >= 0) scheduleModules.value[idx] = { ...scheduleModules.value[idx], ...m }
}
async function deleteScheduleModule(id) {
await api.del(`/api/schedule-modules/${id}`)
scheduleModules.value = scheduleModules.value.filter(m => m.id !== id)
scheduleSlots.value = scheduleSlots.value.filter(s => s.module_id !== id)
}
async function addScheduleSlot(slot) {
await api.post('/api/schedule-slots', slot)
scheduleSlots.value.push(slot)
}
async function removeScheduleSlot(date, timeSlot, moduleId) {
await api.del(`/api/schedule-slots?date=${date}&time_slot=${timeSlot}&module_id=${moduleId}`)
scheduleSlots.value = scheduleSlots.value.filter(
s => !(s.date === date && s.time_slot === timeSlot && s.module_id === moduleId)
)
}
async function clearScheduleDay(date) {
await api.del(`/api/schedule-slots?date=${date}`)
scheduleSlots.value = scheduleSlots.value.filter(s => s.date !== date)
}
// ---- Weekly Template ----
async function saveWeeklyTemplate(day, data) {
await api.post('/api/weekly-template', { day, data: JSON.stringify(data) })
const idx = weeklyTemplate.value.findIndex(t => t.day === day)
if (idx >= 0) weeklyTemplate.value[idx].data = JSON.stringify(data)
else weeklyTemplate.value.push({ day, data: JSON.stringify(data) })
}
return {
// State
notes, todos, inbox, reminders, goals, checklists,
sleepRecords, gymRecords, periodRecords, docs, bugs, reviews,
healthItems, healthPlans, healthChecks,
musicItems, musicPlans, musicChecks,
scheduleModules, scheduleSlots, weeklyTemplate,
loading,
// Actions
loadAll,
addNote, deleteNote, updateNote,
addTodo, updateTodo, deleteTodo,
addInbox, deleteInbox, clearInbox,
addReminder, updateReminder, deleteReminder,
addGoal, updateGoal, deleteGoal,
addChecklist, updateChecklist, deleteChecklist,
addSleep, deleteSleep,
addGym, deleteGym,
addPeriod, updatePeriod, deletePeriod,
addDoc, updateDoc, deleteDoc, addDocEntry, deleteDocEntry,
addBug, updateBug, deleteBug,
saveReview,
addHealthItem, deleteHealthItem, saveHealthPlan, toggleHealthCheck,
addScheduleModule, updateScheduleModule, deleteScheduleModule,
addScheduleSlot, removeScheduleSlot, clearScheduleDay,
saveWeeklyTemplate,
}
})

30
frontend/src/stores/ui.js Normal file
View File

@@ -0,0 +1,30 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
export const useUiStore = defineStore('ui', () => {
const currentTab = ref(localStorage.getItem('sp_current_tab') || 'notes')
const showLoginModal = ref(false)
const toasts = ref([])
function setTab(tab) {
currentTab.value = tab
localStorage.setItem('sp_current_tab', tab)
}
function openLogin() {
showLoginModal.value = true
}
function closeLogin() {
showLoginModal.value = false
}
function toast(msg, duration = 2000) {
toasts.value.push(msg)
setTimeout(() => {
toasts.value.shift()
}, duration)
}
return { currentTab, showLoginModal, toasts, setTab, openLogin, closeLogin, toast }
})

View File

@@ -0,0 +1,260 @@
<template>
<div class="body-layout">
<!-- Sub tabs -->
<div class="sub-tabs">
<button class="sub-tab" :class="{ active: subTab === 'health' }" @click="subTab = 'health'">健康打卡</button>
<button class="sub-tab" :class="{ active: subTab === 'sleep' }" @click="subTab = 'sleep'">睡眠</button>
<button class="sub-tab" :class="{ active: subTab === 'gym' }" @click="subTab = 'gym'">健身</button>
<button class="sub-tab" :class="{ active: subTab === 'period' }" @click="subTab = 'period'">经期</button>
</div>
<!-- 健康打卡 -->
<div v-if="subTab === 'health'" class="health-section">
<div class="section-header">
<h3>今日打卡</h3>
<span class="date-label">{{ today }}</span>
</div>
<div class="checkin-grid">
<div
v-for="item in todayPlanItems('health')"
:key="item.id"
class="checkin-item"
:class="{ checked: isChecked('health', item.id) }"
@click="toggleCheck('health', item.id)"
>
<span class="checkin-check">{{ isChecked('health', item.id) ? '✅' : '⬜' }}</span>
<span>{{ item.name }}</span>
</div>
<div v-if="todayPlanItems('health').length === 0" class="empty-hint">
还没有设定本月计划从下方选择项目添加
</div>
</div>
<div class="section-header">
<h3>物品池</h3>
</div>
<div class="pool-items">
<div v-for="item in store.healthItems" :key="item.id" class="pool-item">
<span @click="togglePlanItem('health', item.id)">
{{ isPlanItem('health', item.id) ? '' : '+' }} {{ item.name }}
</span>
<button class="remove-btn" @click="store.deleteHealthItem(item.id)"></button>
</div>
</div>
<div class="add-row">
<input v-model="newHealthItem" placeholder="添加新项目" @keydown.enter.prevent="addItem('health')">
<button class="btn btn-accent" @click="addItem('health')">+</button>
</div>
</div>
<!-- 睡眠 -->
<div v-if="subTab === 'sleep'" class="sleep-section">
<div class="section-header">
<h3>睡眠记录</h3>
</div>
<div class="capture-row" style="margin-bottom: 16px;">
<input
class="capture-input"
v-model="sleepInput"
placeholder="昨晚10:30 / 25号 9点半"
@keydown.enter.prevent="addSleepRecord"
>
<button class="btn btn-accent" @click="addSleepRecord">记录</button>
</div>
<div class="sleep-hint" v-if="sleepHint">{{ sleepHint }}</div>
<h4 style="margin: 16px 0 8px; color: #888; font-size: 14px;">记录明细</h4>
<table class="data-table" v-if="store.sleepRecords.length">
<thead><tr><th>日期</th><th>入睡时间</th><th></th></tr></thead>
<tbody>
<tr v-for="r in store.sleepRecords" :key="r.date">
<td>{{ r.date }}</td>
<td>{{ r.time }}</td>
<td><button class="remove-btn" @click="store.deleteSleep(r.date)"></button></td>
</tr>
</tbody>
</table>
<div v-else class="empty-hint">还没有睡眠记录</div>
</div>
<!-- 健身 -->
<div v-if="subTab === 'gym'" class="gym-section">
<div class="section-header">
<h3>💪 健身记录</h3>
<button class="btn btn-accent" @click="showGymForm = true">+ 记录</button>
</div>
<div v-for="r in store.gymRecords" :key="r.id" class="record-card">
<div><strong>{{ r.date }}</strong> {{ r.type }} {{ r.duration }}</div>
<div v-if="r.note" class="record-note">{{ r.note }}</div>
<button class="remove-btn" @click="store.deleteGym(r.id)"></button>
</div>
<div v-if="showGymForm" class="edit-form">
<input v-model="gymDate" type="date">
<input v-model="gymType" placeholder="运动类型">
<input v-model="gymDuration" placeholder="时长">
<input v-model="gymNote" placeholder="备注">
<div class="edit-actions">
<button class="btn btn-close" @click="showGymForm = false">取消</button>
<button class="btn btn-accent" @click="saveGym">保存</button>
</div>
</div>
</div>
<!-- 经期 -->
<div v-if="subTab === 'period'" class="period-section">
<div class="section-header">
<h3>🌸 经期记录</h3>
<button class="btn btn-accent" @click="showPeriodForm = true">+ 记录</button>
</div>
<div v-for="r in store.periodRecords" :key="r.id" class="record-card">
<div><strong>{{ r.start_date }}</strong> {{ r.end_date ? '→ ' + r.end_date : '进行中' }}</div>
<div v-if="r.note" class="record-note">{{ r.note }}</div>
<button class="remove-btn" @click="store.deletePeriod(r.id)"></button>
</div>
<div v-if="showPeriodForm" class="edit-form">
<label>开始日期</label>
<input v-model="periodStart" type="date">
<label>结束日期</label>
<input v-model="periodEnd" type="date">
<input v-model="periodNote" placeholder="备注">
<div class="edit-actions">
<button class="btn btn-close" @click="showPeriodForm = false">取消</button>
<button class="btn btn-accent" @click="savePeriod">保存</button>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed } from 'vue'
import { usePlannerStore } from '../stores/planner'
const store = usePlannerStore()
const subTab = ref('health')
const today = computed(() => {
const d = new Date()
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
})
const currentMonth = computed(() => today.value.slice(0, 7))
function uid() { return Date.now().toString(36) + Math.random().toString(36).slice(2, 7) }
// ---- Health check-in ----
const newHealthItem = ref('')
function todayPlanItems(type) {
const plans = type === 'music' ? store.musicPlans : store.healthPlans
const items = type === 'music' ? store.musicItems : store.healthItems
const plan = plans.find(p => p.month === currentMonth.value && p.type === type)
if (!plan) return []
const ids = JSON.parse(plan.item_ids || '[]')
return items.filter(i => ids.includes(i.id))
}
function isChecked(type, itemId) {
const checks = type === 'music' ? store.musicChecks : store.healthChecks
return checks.some(c => c.date === today.value && c.type === type && c.item_id === itemId)
}
async function toggleCheck(type, itemId) {
const checked = isChecked(type, itemId)
await store.toggleHealthCheck({ date: today.value, type, item_id: itemId, checked: checked ? 0 : 1 })
}
function isPlanItem(type, itemId) {
const plans = type === 'music' ? store.musicPlans : store.healthPlans
const plan = plans.find(p => p.month === currentMonth.value && p.type === type)
if (!plan) return false
return JSON.parse(plan.item_ids || '[]').includes(itemId)
}
async function togglePlanItem(type, itemId) {
const plans = type === 'music' ? store.musicPlans : store.healthPlans
const plan = plans.find(p => p.month === currentMonth.value && p.type === type) || {
month: currentMonth.value, type, item_ids: '[]'
}
const ids = JSON.parse(plan.item_ids || '[]')
const idx = ids.indexOf(itemId)
if (idx >= 0) ids.splice(idx, 1)
else ids.push(itemId)
await store.saveHealthPlan({ month: currentMonth.value, type, item_ids: JSON.stringify(ids) })
}
async function addItem(type) {
const name = type === 'music' ? newMusicItem.value.trim() : newHealthItem.value.trim()
if (!name) return
await store.addHealthItem({ id: uid(), name, type })
if (type === 'music') newMusicItem.value = ''
else newHealthItem.value = ''
}
// ---- Sleep ----
const sleepInput = ref('')
const sleepHint = ref('')
function parseSleepTime(text) {
// Simple parser: "10:30" or "昨晚10点半"
const timeMatch = text.match(/(\d{1,2}):(\d{2})/)
if (timeMatch) return `${timeMatch[1].padStart(2, '0')}:${timeMatch[2]}`
const cnMatch = text.match(/(\d{1,2})点半?/)
if (cnMatch) {
const h = parseInt(cnMatch[0])
const m = text.includes('半') ? '30' : '00'
return `${String(h).padStart(2, '0')}:${m}`
}
return null
}
async function addSleepRecord() {
const text = sleepInput.value.trim()
if (!text) return
const time = parseSleepTime(text)
if (!time) {
sleepHint.value = '无法识别时间,请输入如 10:30 或 10点半'
return
}
// Default to yesterday if late night
const now = new Date()
let date = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`
const dateMatch = text.match(/(\d{1,2})号/)
if (dateMatch) {
date = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${dateMatch[1].padStart(2, '0')}`
}
await store.addSleep({ date, time })
sleepInput.value = ''
sleepHint.value = `已记录 ${date} ${time}`
}
// ---- Gym ----
const showGymForm = ref(false)
const gymDate = ref(new Date().toISOString().slice(0, 10))
const gymType = ref('')
const gymDuration = ref('')
const gymNote = ref('')
async function saveGym() {
await store.addGym({ id: uid(), date: gymDate.value, type: gymType.value, duration: gymDuration.value, note: gymNote.value })
showGymForm.value = false
gymType.value = ''
gymDuration.value = ''
gymNote.value = ''
}
// ---- Period ----
const showPeriodForm = ref(false)
const periodStart = ref('')
const periodEnd = ref('')
const periodNote = ref('')
const newMusicItem = ref('')
async function savePeriod() {
if (!periodStart.value) return
await store.addPeriod({ id: uid(), start_date: periodStart.value, end_date: periodEnd.value || null, note: periodNote.value })
showPeriodForm.value = false
periodStart.value = ''
periodEnd.value = ''
periodNote.value = ''
}
</script>

View File

@@ -0,0 +1,144 @@
<template>
<div class="docs-layout">
<div class="section-header">
<div>
<h2 style="font-size: 18px; font-weight: 600; color: #444;">个人文档</h2>
<p style="font-size: 12px; color: #aaa; margin-top: 2px;">随手记会自动识别内容归档到对应文档</p>
</div>
<button class="btn btn-accent" @click="openAdd">+ 新建文档</button>
</div>
<div class="doc-grid">
<div v-for="doc in store.docs" :key="doc.id" class="doc-card" @click="openDoc(doc)">
<div class="doc-icon">{{ doc.icon }}</div>
<div class="doc-name">{{ doc.name }}</div>
<div class="doc-count">{{ (doc.entries || []).length }} </div>
</div>
</div>
<!-- Doc detail overlay -->
<div v-if="activeDoc" class="overlay open" @click.self="activeDoc = null">
<div class="panel" style="width: 500px; max-height: 80vh; overflow-y: auto;">
<div class="section-header">
<h3>{{ activeDoc.icon }} {{ activeDoc.name }}</h3>
<div>
<button class="btn btn-close" @click="editDoc(activeDoc)">编辑</button>
<button class="btn btn-close" style="color: #ef4444;" @click="removeDoc(activeDoc.id)">删除</button>
<button class="btn btn-close" @click="activeDoc = null">关闭</button>
</div>
</div>
<div v-for="entry in (activeDoc.entries || [])" :key="entry.id" class="doc-entry">
<div class="doc-entry-text">{{ entry.text }}</div>
<div class="doc-entry-meta">
<span>{{ formatTime(entry.created_at) }}</span>
<button class="remove-btn" @click="removeEntry(entry)"></button>
</div>
</div>
<div v-if="(activeDoc.entries || []).length === 0" class="empty-hint">暂无条目</div>
</div>
</div>
<!-- Add/Edit form -->
<div v-if="showForm" class="overlay open" @click.self="showForm = false">
<div class="panel edit-panel" style="width: 360px;">
<h3>{{ editingDoc ? '编辑文档' : '新建文档' }}</h3>
<label>文档名称</label>
<input v-model="formName" placeholder="如:读书记录">
<label>图标</label>
<div class="emoji-row">
<span
v-for="e in emojis"
:key="e"
class="emoji-pick"
:class="{ active: formIcon === e }"
@click="formIcon = e"
>{{ e }}</span>
</div>
<label>关键词</label>
<input v-model="formKeywords" placeholder="逗号分隔,如:读完,看完">
<label>提取规则</label>
<select v-model="formRule">
<option value="none"> - 保存原文</option>
<option value="sleep">睡眠时间</option>
<option value="book">书名</option>
</select>
<div class="edit-actions">
<button class="btn btn-close" @click="showForm = false">取消</button>
<button class="btn btn-accent" @click="saveForm">保存</button>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref } from 'vue'
import { usePlannerStore } from '../stores/planner'
const store = usePlannerStore()
const activeDoc = ref(null)
const showForm = ref(false)
const editingDoc = ref(null)
const formName = ref('')
const formIcon = ref('📄')
const formKeywords = ref('')
const formRule = ref('none')
const emojis = ['📄','📖','🌙','💊','💪','🎵','💡','✅','⏰','📌','🎯','📚']
function uid() { return Date.now().toString(36) + Math.random().toString(36).slice(2, 7) }
function openDoc(doc) {
activeDoc.value = doc
}
function openAdd() {
editingDoc.value = null
formName.value = ''
formIcon.value = '📄'
formKeywords.value = ''
formRule.value = 'none'
showForm.value = true
}
function editDoc(doc) {
editingDoc.value = doc
formName.value = doc.name
formIcon.value = doc.icon
formKeywords.value = doc.keywords
formRule.value = doc.extract_rule
showForm.value = true
}
async function saveForm() {
if (!formName.value.trim()) return
const data = {
id: editingDoc.value ? editingDoc.value.id : uid(),
name: formName.value.trim(),
icon: formIcon.value,
keywords: formKeywords.value,
extract_rule: formRule.value,
}
if (editingDoc.value) {
await store.updateDoc(data)
} else {
await store.addDoc(data)
}
showForm.value = false
}
async function removeDoc(id) {
await store.deleteDoc(id)
activeDoc.value = null
}
async function removeEntry(entry) {
await store.deleteDocEntry(entry.id, activeDoc.value.id)
}
function formatTime(ts) {
if (!ts) return ''
const d = new Date(ts)
return `${d.getMonth() + 1}/${d.getDate()} ${d.getHours()}:${String(d.getMinutes()).padStart(2, '0')}`
}
</script>

View File

@@ -0,0 +1,94 @@
<template>
<div class="music-layout">
<div class="section-header">
<h3>今日练习</h3>
<span class="date-label">{{ today }}</span>
</div>
<div class="checkin-grid">
<div
v-for="item in todayPlanItems"
:key="item.id"
class="checkin-item"
:class="{ checked: isChecked(item.id) }"
@click="toggleCheck(item.id)"
>
<span class="checkin-check">{{ isChecked(item.id) ? '✅' : '⬜' }}</span>
<span>{{ item.name }}</span>
</div>
<div v-if="todayPlanItems.length === 0" class="empty-hint">
还没有设定本月计划从下方选择项目添加
</div>
</div>
<div class="section-header">
<h3>练习项目</h3>
</div>
<div class="pool-items">
<div v-for="item in store.musicItems" :key="item.id" class="pool-item">
<span @click="togglePlanItem(item.id)">
{{ isPlanItem(item.id) ? '' : '+' }} {{ item.name }}
</span>
<button class="remove-btn" @click="store.deleteHealthItem(item.id)"></button>
</div>
</div>
<div class="add-row">
<input v-model="newItem" placeholder="添加新项目,如:尤克里里" @keydown.enter.prevent="addItem">
<button class="btn btn-accent" @click="addItem">+</button>
</div>
</div>
</template>
<script setup>
import { ref, computed } from 'vue'
import { usePlannerStore } from '../stores/planner'
const store = usePlannerStore()
const newItem = ref('')
const today = computed(() => {
const d = new Date()
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
})
const currentMonth = computed(() => today.value.slice(0, 7))
function uid() { return Date.now().toString(36) + Math.random().toString(36).slice(2, 7) }
const todayPlanItems = computed(() => {
const plan = store.musicPlans.find(p => p.month === currentMonth.value && p.type === 'music')
if (!plan) return []
const ids = JSON.parse(plan.item_ids || '[]')
return store.musicItems.filter(i => ids.includes(i.id))
})
function isChecked(itemId) {
return store.musicChecks.some(c => c.date === today.value && c.type === 'music' && c.item_id === itemId)
}
async function toggleCheck(itemId) {
const checked = isChecked(itemId)
await store.toggleHealthCheck({ date: today.value, type: 'music', item_id: itemId, checked: checked ? 0 : 1 })
}
function isPlanItem(itemId) {
const plan = store.musicPlans.find(p => p.month === currentMonth.value && p.type === 'music')
if (!plan) return false
return JSON.parse(plan.item_ids || '[]').includes(itemId)
}
async function togglePlanItem(itemId) {
const plan = store.musicPlans.find(p => p.month === currentMonth.value && p.type === 'music') || {
month: currentMonth.value, type: 'music', item_ids: '[]'
}
const ids = JSON.parse(plan.item_ids || '[]')
const idx = ids.indexOf(itemId)
if (idx >= 0) ids.splice(idx, 1)
else ids.push(itemId)
await store.saveHealthPlan({ month: currentMonth.value, type: 'music', item_ids: JSON.stringify(ids) })
}
async function addItem() {
if (!newItem.value.trim()) return
await store.addHealthItem({ id: uid(), name: newItem.value.trim(), type: 'music' })
newItem.value = ''
}
</script>

View File

@@ -0,0 +1,148 @@
<template>
<div class="notes-layout">
<!-- 快速输入 -->
<div class="capture-card">
<div class="capture-row">
<textarea
class="capture-input"
v-model="newText"
placeholder="想到什么,写下来…"
rows="1"
@input="autoResize"
@keydown.enter.exact.prevent="saveNote"
></textarea>
<button class="capture-btn" @click="saveNote"></button>
</div>
<div class="tag-btns">
<button
v-for="t in tagOptions"
:key="t.tag"
class="tag-btn"
:class="{ active: selectedTag === t.tag }"
@click="selectedTag = t.tag"
:title="t.tag"
>{{ t.icon }}</button>
</div>
</div>
<!-- 筛选 -->
<div class="toolbar">
<input class="search-input" v-model="searchQuery" placeholder="搜索…">
<div class="filter-row">
<button
class="filter-btn"
:class="{ active: filterTag === 'all' }"
@click="filterTag = 'all'"
>全部</button>
<button
v-for="t in tagOptions"
:key="t.tag"
class="filter-btn"
:class="{ active: filterTag === t.tag }"
@click="filterTag = t.tag"
>{{ t.icon }} {{ t.tag }}</button>
</div>
</div>
<!-- 列表 -->
<div v-if="filtered.length === 0" class="empty-hint">
还没有记录在上方输入框快速记录吧
</div>
<div v-for="note in filtered" :key="note.id" class="note-card">
<div class="note-header">
<span class="note-tag" :style="{ background: tagColor(note.tag) }">{{ tagIcon(note.tag) }} {{ note.tag }}</span>
<span class="note-time">{{ formatTime(note.created_at) }}</span>
</div>
<div v-if="editingId === note.id" class="note-edit">
<textarea v-model="editText" class="edit-textarea" rows="3"></textarea>
<div class="edit-actions">
<button class="btn btn-close" @click="editingId = null">取消</button>
<button class="btn btn-accent" @click="saveEdit(note)">保存</button>
</div>
</div>
<div v-else class="note-text" @click="startEdit(note)">{{ note.text }}</div>
<div class="note-actions">
<button class="note-action-btn" @click="startEdit(note)">编辑</button>
<button class="note-action-btn danger" @click="removeNote(note.id)">删除</button>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed } from 'vue'
import { usePlannerStore } from '../stores/planner'
const store = usePlannerStore()
const tagOptions = [
{ tag: '灵感', icon: '💡' },
{ tag: '待办', icon: '✅' },
{ tag: '提醒', icon: '⏰' },
{ tag: '读书', icon: '📖' },
{ tag: '睡眠', icon: '🌙' },
{ tag: '健康', icon: '💊' },
{ tag: '健身', icon: '💪' },
{ tag: '音乐', icon: '🎵' },
]
const TAG_COLORS = {
'灵感': '#fff3cd', '待办': '#d1ecf1', '提醒': '#f8d7da', '读书': '#d4edda',
'睡眠': '#e2d9f3', '健康': '#fce4ec', '健身': '#e8f5e9', '音乐': '#fff8e1',
}
const TAG_ICONS = Object.fromEntries(tagOptions.map(t => [t.tag, t.icon]))
const newText = ref('')
const selectedTag = ref('灵感')
const searchQuery = ref('')
const filterTag = ref('all')
const editingId = ref(null)
const editText = ref('')
function tagColor(tag) { return TAG_COLORS[tag] || '#f5f5f5' }
function tagIcon(tag) { return TAG_ICONS[tag] || '📝' }
const filtered = computed(() => {
let list = store.notes
if (filterTag.value !== 'all') list = list.filter(n => n.tag === filterTag.value)
if (searchQuery.value) {
const q = searchQuery.value.toLowerCase()
list = list.filter(n => n.text.toLowerCase().includes(q))
}
return list
})
function uid() { return Date.now().toString(36) + Math.random().toString(36).slice(2, 7) }
function autoResize(e) {
e.target.style.height = 'auto'
e.target.style.height = Math.min(e.target.scrollHeight, 160) + 'px'
}
async function saveNote() {
const text = newText.value.trim()
if (!text) return
await store.addNote({ id: uid(), text, tag: selectedTag.value, created_at: new Date().toISOString() })
newText.value = ''
}
function startEdit(note) {
editingId.value = note.id
editText.value = note.text
}
async function saveEdit(note) {
await store.updateNote({ ...note, text: editText.value })
editingId.value = null
}
async function removeNote(id) {
await store.deleteNote(id)
}
function formatTime(ts) {
if (!ts) return ''
const d = new Date(ts)
return `${d.getMonth() + 1}/${d.getDate()} ${d.getHours()}:${String(d.getMinutes()).padStart(2, '0')}`
}
</script>

View File

@@ -0,0 +1,224 @@
<template>
<div class="planning-layout">
<!-- Sub tabs -->
<div class="sub-tabs">
<button class="sub-tab" :class="{ active: subTab === 'schedule' }" @click="subTab = 'schedule'">日程</button>
<button class="sub-tab" :class="{ active: subTab === 'template' }" @click="subTab = 'template'">模板</button>
<button class="sub-tab" :class="{ active: subTab === 'review' }" @click="subTab = 'review'">回顾</button>
</div>
<!-- 日程 -->
<div v-if="subTab === 'schedule'" class="schedule-section">
<div class="schedule-flex">
<!-- 模块池 -->
<div class="module-pool">
<div class="pool-card">
<h3>活动模块</h3>
<div
v-for="m in store.scheduleModules"
:key="m.id"
class="module-item"
:style="{ background: m.color + '20', color: m.color }"
draggable="true"
@dragstart="dragModule = m"
>
<span class="emoji">{{ m.emoji }}</span>
<span>{{ m.name }}</span>
<button class="remove-btn" @click="store.deleteScheduleModule(m.id)"></button>
</div>
<div class="add-row">
<input v-model="newModuleName" placeholder="添加新活动…" @keydown.enter.prevent="addModule">
<button @click="addModule">+</button>
</div>
<div class="color-picker">
<span
v-for="c in colors"
:key="c"
class="color-dot"
:class="{ active: newModuleColor === c }"
:style="{ background: c }"
@click="newModuleColor = c"
></span>
</div>
</div>
</div>
<!-- 时间线 -->
<div class="timeline">
<div class="date-nav">
<button @click="changeDate(-1)"></button>
<span class="date-label-main">{{ formatDateLabel(currentDate) }}</span>
<button @click="changeDate(1)"></button>
<button class="btn btn-light" style="margin-left: auto;" @click="store.clearScheduleDay(dateKey(currentDate))">清空</button>
</div>
<div
v-for="slot in timeSlots"
:key="slot"
class="time-slot"
>
<div class="time-label">{{ slot }}</div>
<div
class="slot-drop"
:class="{ 'drag-over': dragOverSlot === slot }"
@dragover.prevent="dragOverSlot = slot"
@dragleave="dragOverSlot = null"
@drop="dropModule(slot)"
>
<div
v-for="item in getSlotItems(slot)"
:key="item.module_id"
class="placed-item"
:style="{ background: getModuleColor(item.module_id) + '30', color: getModuleColor(item.module_id) }"
>
{{ getModuleEmoji(item.module_id) }} {{ getModuleName(item.module_id) }}
<button class="remove-placed" @click="store.removeScheduleSlot(dateKey(currentDate), slot, item.module_id)"></button>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- 模板 -->
<div v-if="subTab === 'template'" class="template-section">
<div class="day-tabs">
<button
v-for="(name, idx) in dayNames"
:key="idx"
class="day-btn"
:class="{ active: selectedDay === idx }"
@click="selectedDay = idx"
>{{ name }}</button>
</div>
<div class="template-hint">
模板内容在 weekly_template 数据中编辑
</div>
</div>
<!-- 回顾 -->
<div v-if="subTab === 'review'" class="review-section">
<div class="section-header">
<h3>本周回顾</h3>
</div>
<div class="review-form">
<label>本周做得好的</label>
<textarea v-model="reviewWins" rows="3" placeholder="列举本周成就…"></textarea>
<label>需要改进的</label>
<textarea v-model="reviewIssues" rows="3" placeholder="遇到的问题…"></textarea>
<label>下周计划</label>
<textarea v-model="reviewPlan" rows="3" placeholder="下周打算…"></textarea>
<div class="edit-actions">
<button class="btn btn-accent" @click="saveReview">保存回顾</button>
</div>
</div>
<h4 style="margin: 20px 0 10px; color: #888; font-size: 14px; cursor: pointer;" @click="showHistory = !showHistory">
历史回顾 {{ showHistory ? '' : '' }}
</h4>
<div v-if="showHistory">
<div v-for="r in store.reviews" :key="r.week" class="review-card">
<strong>{{ r.week }}</strong>
<pre class="review-content">{{ r.data }}</pre>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed } from 'vue'
import { usePlannerStore } from '../stores/planner'
const store = usePlannerStore()
const subTab = ref('schedule')
const currentDate = ref(new Date())
const dragModule = ref(null)
const dragOverSlot = ref(null)
const newModuleName = ref('')
const newModuleColor = ref('#667eea')
const selectedDay = ref(1)
const showHistory = ref(false)
const reviewWins = ref('')
const reviewIssues = ref('')
const reviewPlan = ref('')
const colors = ['#667eea', '#764ba2', '#f59e0b', '#10b981', '#ef4444', '#3b82f6', '#8b5cf6', '#ec4899', '#06b6d4', '#84cc16']
const dayNames = ['周一', '周二', '周三', '周四', '周五', '周六', '周日']
const timeSlots = computed(() => {
const slots = []
for (let h = 6; h <= 23; h++) {
slots.push(`${String(h).padStart(2, '0')}:00`)
}
return slots
})
function uid() { return Date.now().toString(36) + Math.random().toString(36).slice(2, 7) }
function dateKey(d) { return d.toISOString().slice(0, 10) }
function formatDateLabel(d) {
const days = ['周日', '周一', '周二', '周三', '周四', '周五', '周六']
return `${d.getMonth() + 1}${d.getDate()}${days[d.getDay()]}`
}
function changeDate(delta) {
const d = new Date(currentDate.value)
d.setDate(d.getDate() + delta)
currentDate.value = d
}
function getSlotItems(slot) {
return store.scheduleSlots.filter(s => s.date === dateKey(currentDate.value) && s.time_slot === slot)
}
function getModuleColor(id) {
return store.scheduleModules.find(m => m.id === id)?.color || '#667eea'
}
function getModuleEmoji(id) {
return store.scheduleModules.find(m => m.id === id)?.emoji || '📌'
}
function getModuleName(id) {
return store.scheduleModules.find(m => m.id === id)?.name || ''
}
async function dropModule(slot) {
dragOverSlot.value = null
if (!dragModule.value) return
await store.addScheduleSlot({
date: dateKey(currentDate.value),
time_slot: slot,
module_id: dragModule.value.id,
})
dragModule.value = null
}
async function addModule() {
if (!newModuleName.value.trim()) return
await store.addScheduleModule({
id: uid(),
name: newModuleName.value.trim(),
emoji: '📌',
color: newModuleColor.value,
sort_order: store.scheduleModules.length,
})
newModuleName.value = ''
}
function getWeekKey() {
const d = new Date()
const year = d.getFullYear()
const jan1 = new Date(year, 0, 1)
const week = Math.ceil(((d - jan1) / 86400000 + jan1.getDay() + 1) / 7)
return `${year}-W${String(week).padStart(2, '0')}`
}
async function saveReview() {
const data = JSON.stringify({
wins: reviewWins.value,
issues: reviewIssues.value,
plan: reviewPlan.value,
})
await store.saveReview({ week: getWeekKey(), data })
}
</script>

View File

@@ -0,0 +1,77 @@
<template>
<div class="reminders-layout">
<div class="section-header">
<h3>提醒</h3>
<button class="btn btn-accent" @click="showForm = true">+ 新提醒</button>
</div>
<div v-for="r in store.reminders" :key="r.id" class="reminder-card">
<div class="reminder-main">
<div class="reminder-toggle" :class="{ on: r.enabled }" @click="toggleEnabled(r)">
{{ r.enabled ? '🔔' : '🔕' }}
</div>
<div class="reminder-content">
<div class="reminder-text">{{ r.text }}</div>
<div class="reminder-meta">
<span v-if="r.time">{{ r.time }}</span>
<span v-if="r.repeat !== 'none'">· {{ repeatLabel(r.repeat) }}</span>
</div>
</div>
<button class="remove-btn" @click="store.deleteReminder(r.id)"></button>
</div>
</div>
<div v-if="store.reminders.length === 0" class="empty-hint">还没有提醒</div>
<div v-if="showForm" class="edit-form">
<input v-model="formText" placeholder="提醒内容">
<input v-model="formTime" type="time">
<select v-model="formRepeat">
<option value="none">不重复</option>
<option value="daily">每天</option>
<option value="weekdays">工作日</option>
<option value="weekly">每周</option>
</select>
<div class="edit-actions">
<button class="btn btn-close" @click="showForm = false">取消</button>
<button class="btn btn-accent" @click="saveReminder">保存</button>
</div>
</div>
</div>
</template>
<script setup>
import { ref } from 'vue'
import { usePlannerStore } from '../stores/planner'
const store = usePlannerStore()
const showForm = ref(false)
const formText = ref('')
const formTime = ref('')
const formRepeat = ref('none')
function uid() { return Date.now().toString(36) + Math.random().toString(36).slice(2, 7) }
function repeatLabel(r) {
return { daily: '每天', weekdays: '工作日', weekly: '每周', none: '' }[r] || r
}
async function toggleEnabled(r) {
await store.updateReminder({ ...r, enabled: r.enabled ? 0 : 1 })
}
async function saveReminder() {
if (!formText.value.trim()) return
await store.addReminder({
id: uid(),
text: formText.value.trim(),
time: formTime.value || null,
repeat: formRepeat.value,
enabled: 1,
})
showForm.value = false
formText.value = ''
formTime.value = ''
formRepeat.value = 'none'
}
</script>

View File

@@ -0,0 +1,183 @@
<template>
<div class="buddy-layout">
<!-- Login -->
<div v-if="!loggedIn" class="buddy-login">
<div class="buddy-login-logo">🌙</div>
<h1>睡眠打卡</h1>
<p>和好友一起早睡</p>
<div class="buddy-login-card">
<input v-model="username" placeholder="用户名" @keydown.enter="$refs.pwdInput?.focus()">
<input ref="pwdInput" v-model="pwd" type="password" placeholder="密码" @keydown.enter="isRegister ? doRegister() : doLogin()">
<input v-if="isRegister" v-model="pwd2" type="password" placeholder="确认密码" @keydown.enter="doRegister">
<button class="buddy-main-btn" @click="isRegister ? doRegister() : doLogin()">{{ isRegister ? '注册' : '登录' }}</button>
<button class="buddy-toggle-btn" @click="isRegister = !isRegister">{{ isRegister ? '已有账号登录' : '没有账号注册' }}</button>
<div class="buddy-error">{{ error }}</div>
</div>
</div>
<!-- Main -->
<div v-else class="buddy-main">
<div class="buddy-header">
<h2>🌙 睡眠打卡</h2>
<div class="user-chip" @click="showMenu = !showMenu">
{{ myName }}
<div v-if="showMenu" class="user-menu">
<button @click="doLogout">退出登录</button>
</div>
</div>
</div>
<!-- Notifications -->
<div v-for="n in notifications" :key="n.id" class="notif-bar">
{{ n.message }}
</div>
<button class="sleep-btn" @click="goSleep">🌙 我去睡觉啦</button>
<!-- Target -->
<div class="target-card">
<span>我的目标入睡时间</span>
<span class="target-time">{{ myTarget }}</span>
<button @click="setTarget">修改</button>
</div>
<!-- Record -->
<div class="record-card">
<h3>记录 / 修改入睡时间</h3>
<div class="capture-row">
<input v-model="sleepInput" placeholder="昨晚11点半 / 10:30" @keydown.enter.prevent="addRecord">
<button @click="addRecord">记录</button>
</div>
<div class="buddy-hint">{{ hint }}</div>
</div>
<!-- Records list -->
<div class="record-card">
<h3>最近记录</h3>
<div v-for="r in myRecords" :key="r.date" class="buddy-record">
<span>{{ r.date }}</span>
<span>{{ r.time }}</span>
<button class="remove-btn" @click="deleteRecord(r.date)"></button>
</div>
<div v-if="myRecords.length === 0" class="empty-hint">暂无记录</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted, computed } from 'vue'
import { api } from '../composables/useApi'
const loggedIn = ref(false)
const myName = ref('')
const isRegister = ref(false)
const username = ref('')
const pwd = ref('')
const pwd2 = ref('')
const error = ref('')
const showMenu = ref(false)
const sleepInput = ref('')
const hint = ref('')
const buddyData = ref({ users: {}, targets: {}, notifications: [] })
const notifications = ref([])
const myTarget = ref('22:00')
async function hashStr(s) {
const buf = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(s))
return Array.from(new Uint8Array(buf)).map(b => b.toString(16).padStart(2, '0')).join('')
}
const myRecords = computed(() => {
return (buddyData.value.users[myName.value] || [])
})
async function loadData() {
buddyData.value = await api.get('/api/sleep-buddy')
myTarget.value = buddyData.value.targets?.[myName.value] || '22:00'
// Load notifications
const res = await api.post('/api/sleep-buddy', { user: myName.value, action: 'get-notifications' })
notifications.value = res.notifications || []
}
async function doLogin() {
error.value = ''
try {
const hash = await hashStr(pwd.value)
await api.post('/api/buddy-login', { username: username.value.trim(), hash })
myName.value = username.value.trim()
localStorage.setItem('buddy_session', JSON.stringify({ username: myName.value, exp: Date.now() + 30 * 86400000 }))
loggedIn.value = true
await loadData()
} catch (e) {
error.value = e.message
}
}
async function doRegister() {
error.value = ''
if (pwd.value !== pwd2.value) { error.value = '密码不一致'; return }
try {
const hash = await hashStr(pwd.value)
await api.post('/api/buddy-register', { username: username.value.trim(), hash })
await doLogin()
} catch (e) {
error.value = e.message
}
}
function doLogout() {
localStorage.removeItem('buddy_session')
loggedIn.value = false
myName.value = ''
}
async function goSleep() {
await api.post('/api/sleep-buddy', { user: myName.value, action: 'sleep-now' })
hint.value = '晚安!已通知好友'
}
async function setTarget() {
const t = prompt('设置目标入睡时间(如 22:00', myTarget.value)
if (!t) return
await api.post('/api/sleep-buddy', { user: myName.value, action: 'set-target', target: t })
myTarget.value = t
}
async function addRecord() {
const text = sleepInput.value.trim()
if (!text) return
const timeMatch = text.match(/(\d{1,2}):(\d{2})/)
let time
if (timeMatch) {
time = `${timeMatch[1].padStart(2, '0')}:${timeMatch[2]}`
} else {
const cn = text.match(/(\d{1,2})点半?/)
if (cn) {
const m = text.includes('半') ? '30' : '00'
time = `${String(parseInt(cn[1])).padStart(2, '0')}:${m}`
}
}
if (!time) { hint.value = '无法识别时间'; return }
const now = new Date()
const date = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`
await api.post('/api/sleep-buddy', { user: myName.value, action: 'record', record: { date, time } })
sleepInput.value = ''
hint.value = `已记录 ${date} ${time}`
await loadData()
}
async function deleteRecord(date) {
await api.post('/api/sleep-buddy', { user: myName.value, action: 'delete-record', date })
await loadData()
}
onMounted(() => {
const saved = JSON.parse(localStorage.getItem('buddy_session') || 'null')
if (saved && Date.now() < saved.exp) {
myName.value = saved.username
loggedIn.value = true
loadData()
}
})
</script>

View File

@@ -0,0 +1,209 @@
<template>
<div class="tasks-layout">
<!-- Sub tabs -->
<div class="sub-tabs">
<button class="sub-tab" :class="{ active: subTab === 'todo' }" @click="subTab = 'todo'">待办</button>
<button class="sub-tab" :class="{ active: subTab === 'goals' }" @click="subTab = 'goals'">目标</button>
<button class="sub-tab" :class="{ active: subTab === 'checklists' }" @click="subTab = 'checklists'">清单</button>
</div>
<!-- 待办 -->
<div v-if="subTab === 'todo'" class="todo-section">
<div class="toolbar">
<input class="search-input" v-model="todoSearch" placeholder="搜索待办…">
<label class="toggle-label">
<input type="checkbox" v-model="showDone">
<span>显示已完成</span>
</label>
</div>
<!-- 收集箱 -->
<div class="inbox-card">
<div class="capture-row">
<textarea
class="capture-input"
v-model="inboxText"
placeholder="脑子里有什么事?先丢进来…"
rows="1"
@keydown.enter.exact.prevent="addInbox"
></textarea>
<button class="capture-btn" @click="addInbox">+</button>
</div>
<div v-for="item in store.inbox" :key="item.id" class="inbox-item">
<span>{{ item.text }}</span>
<div class="inbox-item-actions">
<button @click="moveToQuadrant(item, 'q1')" title="紧急重要">🔴</button>
<button @click="moveToQuadrant(item, 'q2')" title="重要">🟡</button>
<button @click="moveToQuadrant(item, 'q3')" title="紧急">🔵</button>
<button @click="moveToQuadrant(item, 'q4')" title="其他"></button>
<button @click="store.deleteInbox(item.id)"></button>
</div>
</div>
</div>
<!-- 四象限 -->
<div class="quadrant-grid">
<div v-for="q in quadrants" :key="q.key" class="quadrant" :class="q.class">
<div class="quadrant-title">{{ q.title }}</div>
<div class="quadrant-desc">{{ q.desc }}</div>
<div v-for="todo in getQuadrantTodos(q.key)" :key="todo.id" class="todo-item">
<input type="checkbox" :checked="todo.done" @change="toggleTodo(todo)">
<span :class="{ done: todo.done }">{{ todo.text }}</span>
<button class="remove-btn" @click="store.deleteTodo(todo.id)"></button>
</div>
<div class="add-todo-row">
<input
:placeholder="'添加到' + q.title + '…'"
@keydown.enter.prevent="addTodoToQuadrant($event, q.key)"
>
</div>
</div>
</div>
</div>
<!-- 目标 -->
<div v-if="subTab === 'goals'" class="goals-section">
<div class="section-header">
<h3>我的目标</h3>
<button class="btn btn-accent" @click="openGoalForm()">+ 新目标</button>
</div>
<div v-for="goal in store.goals" :key="goal.id" class="goal-card">
<div class="goal-header">
<strong>{{ goal.name }}</strong>
<span v-if="goal.month" class="goal-month">截止 {{ goal.month }}</span>
<button class="remove-btn" @click="store.deleteGoal(goal.id)"></button>
</div>
</div>
<div v-if="showGoalForm" class="edit-form">
<input v-model="goalName" placeholder="目标名称">
<input v-model="goalMonth" type="month">
<div class="edit-actions">
<button class="btn btn-close" @click="showGoalForm = false">取消</button>
<button class="btn btn-accent" @click="saveGoal">保存</button>
</div>
</div>
</div>
<!-- 清单 -->
<div v-if="subTab === 'checklists'" class="checklists-section">
<div class="section-header">
<h3>我的清单</h3>
<button class="btn btn-accent" @click="addChecklist">+ 新清单</button>
</div>
<div v-for="cl in store.checklists" :key="cl.id" class="checklist-card">
<div class="checklist-header">
<input
class="checklist-title-input"
:value="cl.title"
@blur="updateChecklistTitle(cl, $event.target.value)"
>
<button class="remove-btn" @click="store.deleteChecklist(cl.id)"></button>
</div>
<div v-for="(item, idx) in parseItems(cl.items)" :key="idx" class="checklist-item">
<input type="checkbox" :checked="item.done" @change="toggleChecklistItem(cl, idx)">
<span :class="{ done: item.done }">{{ item.text }}</span>
</div>
<div class="add-todo-row">
<input placeholder="添加项目…" @keydown.enter.prevent="addChecklistItem(cl, $event)">
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed } from 'vue'
import { usePlannerStore } from '../stores/planner'
const store = usePlannerStore()
const subTab = ref('todo')
const todoSearch = ref('')
const showDone = ref(false)
const inboxText = ref('')
const showGoalForm = ref(false)
const goalName = ref('')
const goalMonth = ref('')
const quadrants = [
{ key: 'q1', title: '紧急且重要', desc: '立即处理', class: 'q-urgent-important' },
{ key: 'q2', title: '重要不紧急', desc: '计划安排', class: 'q-important' },
{ key: 'q3', title: '紧急不重要', desc: '委派他人', class: 'q-urgent' },
{ key: 'q4', title: '不紧急不重要', desc: '减少或消除', class: 'q-neither' },
]
function uid() { return Date.now().toString(36) + Math.random().toString(36).slice(2, 7) }
function getQuadrantTodos(q) {
let list = store.todos.filter(t => t.quadrant === q)
if (!showDone.value) list = list.filter(t => !t.done)
if (todoSearch.value) {
const s = todoSearch.value.toLowerCase()
list = list.filter(t => t.text.toLowerCase().includes(s))
}
return list
}
async function addInbox() {
const text = inboxText.value.trim()
if (!text) return
await store.addInbox({ id: uid(), text })
inboxText.value = ''
}
async function moveToQuadrant(item, quadrant) {
await store.addTodo({ id: uid(), text: item.text, quadrant, done: 0 })
await store.deleteInbox(item.id)
}
async function toggleTodo(todo) {
await store.updateTodo({ ...todo, done: todo.done ? 0 : 1 })
}
async function addTodoToQuadrant(e, quadrant) {
const text = e.target.value.trim()
if (!text) return
await store.addTodo({ id: uid(), text, quadrant, done: 0 })
e.target.value = ''
}
function openGoalForm() {
showGoalForm.value = true
goalName.value = ''
goalMonth.value = ''
}
async function saveGoal() {
if (!goalName.value.trim()) return
await store.addGoal({ id: uid(), name: goalName.value.trim(), month: goalMonth.value, checks: '{}' })
showGoalForm.value = false
}
function parseItems(items) {
try { return JSON.parse(items) } catch { return [] }
}
async function addChecklist() {
await store.addChecklist({ id: uid(), title: '新清单', items: '[]', archived: 0 })
}
async function updateChecklistTitle(cl, title) {
if (title !== cl.title) {
await store.updateChecklist({ ...cl, title })
}
}
async function toggleChecklistItem(cl, idx) {
const items = parseItems(cl.items)
items[idx].done = !items[idx].done
await store.updateChecklist({ ...cl, items: JSON.stringify(items) })
}
async function addChecklistItem(cl, e) {
const text = e.target.value.trim()
if (!text) return
const items = parseItems(cl.items)
items.push({ text, done: false })
await store.updateChecklist({ ...cl, items: JSON.stringify(items) })
e.target.value = ''
}
</script>

14
frontend/vite.config.js Normal file
View File

@@ -0,0 +1,14 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
export default defineConfig({
plugins: [vue()],
server: {
proxy: {
'/api': 'http://localhost:8000'
}
},
build: {
outDir: 'dist'
}
})

265
scripts/deploy-preview.py Normal file
View File

@@ -0,0 +1,265 @@
#!/usr/bin/env python3
"""Deploy or teardown a PR preview environment on local k3s.
Runs directly on the oci server (where k3s and docker are local).
Usage:
python3 scripts/deploy-preview.py deploy <PR_ID>
python3 scripts/deploy-preview.py teardown <PR_ID>
python3 scripts/deploy-preview.py deploy-prod
"""
import subprocess
import sys
import json
import tempfile
import textwrap
from pathlib import Path
REGISTRY = "registry.oci.euphon.net"
BASE_DOMAIN = "planner.oci.euphon.net"
PROD_NS = "planner"
APP_NAME = "planner"
def run(cmd: list[str] | str, *, check=True, capture=False) -> subprocess.CompletedProcess:
if isinstance(cmd, str):
cmd = ["sh", "-c", cmd]
display = " ".join(cmd) if isinstance(cmd, list) else cmd
print(f" $ {display}")
r = subprocess.run(cmd, text=True, capture_output=capture)
if capture and r.stdout.strip():
for line in r.stdout.strip().splitlines()[:5]:
print(f" {line}")
if check and r.returncode != 0:
print(f" FAILED (exit {r.returncode})")
if capture and r.stderr.strip():
print(f" {r.stderr.strip()[:200]}")
sys.exit(1)
return r
def kubectl(*args, capture=False, check=True) -> subprocess.CompletedProcess:
return run(["sudo", "k3s", "kubectl", *args], capture=capture, check=check)
def docker(*args, check=True) -> subprocess.CompletedProcess:
return run(["docker", *args], check=check)
def write_temp(content: str, suffix=".yaml") -> Path:
f = tempfile.NamedTemporaryFile(mode="w", suffix=suffix, delete=False)
f.write(content)
f.close()
return Path(f.name)
# ─── Deploy Preview ─────────────────────────────────────
def deploy(pr_id: str):
ns = f"planner-pr-{pr_id}"
host = f"pr-{pr_id}.{BASE_DOMAIN}"
image = f"{REGISTRY}/{APP_NAME}:pr-{pr_id}"
print(f"\n{'='*60}")
print(f" Deploying: https://{host}")
print(f" Namespace: {ns}")
print(f"{'='*60}\n")
# 1. Copy production DB into build context
print("[1/5] Copying production database...")
Path("data").mkdir(exist_ok=True)
prod_pod = kubectl(
"get", "pods", "-n", PROD_NS,
"-l", f"app={APP_NAME}",
"--field-selector=status.phase=Running",
"-o", "jsonpath={.items[0].metadata.name}",
capture=True, check=False
).stdout.strip()
if prod_pod:
r = kubectl("cp", f"{PROD_NS}/{prod_pod}:/data/planner.db", "data/planner.db", check=False)
if r.returncode != 0 or not Path("data/planner.db").exists() or Path("data/planner.db").stat().st_size == 0:
print(" WARNING: Could not copy prod DB, using empty DB")
Path("data/planner.db").touch()
else:
print(" WARNING: No running prod pod, using empty DB")
Path("data/planner.db").touch()
# 2. Build and push image
print("[2/5] Building Docker image...")
# Only COPY DB if it has content, otherwise let init_db create fresh
has_db = Path("data/planner.db").exists() and Path("data/planner.db").stat().st_size > 0
copy_db_line = "COPY data/planner.db /data/planner.db" if has_db else "RUN mkdir -p /data"
dockerfile = textwrap.dedent(f"""\
FROM node:20-slim AS frontend-build
WORKDIR /build
COPY frontend/package.json frontend/package-lock.json ./
RUN npm ci
COPY frontend/ ./
RUN npm run build
FROM python:3.12-slim
WORKDIR /app
COPY backend/requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY backend/ ./backend/
COPY --from=frontend-build /build/dist ./frontend/
{copy_db_line}
ENV DB_PATH=/data/planner.db
ENV FRONTEND_DIR=/app/frontend
ENV DATA_DIR=/data
EXPOSE 8000
CMD ["uvicorn", "backend.main:app", "--host", "0.0.0.0", "--port", "8000"]
""")
df = write_temp(dockerfile, suffix=".Dockerfile")
docker("build", "-f", str(df), "-t", image, ".")
df.unlink()
docker("push", image)
# 3. Create namespace + regcred
print("[3/5] Creating namespace...")
run(f"sudo k3s kubectl create namespace {ns} --dry-run=client -o yaml | sudo k3s kubectl apply -f -")
# Copy regcred from prod namespace
r = kubectl("get", "secret", "regcred", "-n", PROD_NS, "-o", "json", capture=True, check=False)
if r.returncode == 0 and r.stdout.strip():
secret = json.loads(r.stdout)
secret["metadata"] = {"name": "regcred", "namespace": ns}
p = write_temp(json.dumps(secret), suffix=".json")
kubectl("apply", "-f", str(p))
p.unlink()
# 4. Apply manifests
print("[4/5] Applying K8s resources...")
manifests = textwrap.dedent(f"""\
apiVersion: apps/v1
kind: Deployment
metadata:
name: {APP_NAME}
namespace: {ns}
spec:
replicas: 1
selector:
matchLabels:
app: {APP_NAME}
template:
metadata:
labels:
app: {APP_NAME}
spec:
imagePullSecrets:
- name: regcred
containers:
- name: app
image: {image}
imagePullPolicy: Always
ports:
- containerPort: 8000
resources:
requests:
cpu: 50m
memory: 64Mi
limits:
cpu: 500m
memory: 256Mi
---
apiVersion: v1
kind: Service
metadata:
name: {APP_NAME}
namespace: {ns}
spec:
selector:
app: {APP_NAME}
ports:
- port: 80
targetPort: 8000
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: {APP_NAME}
namespace: {ns}
annotations:
traefik.ingress.kubernetes.io/router.tls.certresolver: le
spec:
ingressClassName: traefik
tls:
- hosts:
- {host}
rules:
- host: {host}
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: {APP_NAME}
port:
number: 80
""")
p = write_temp(manifests)
kubectl("apply", "-f", str(p))
p.unlink()
# 5. Restart and wait
print("[5/5] Restarting deployment...")
kubectl("rollout", "restart", f"deploy/{APP_NAME}", "-n", ns)
kubectl("rollout", "status", f"deploy/{APP_NAME}", "-n", ns, "--timeout=120s")
# Cleanup
run("rm -rf data/planner.db", check=False)
print(f"\n{'='*60}")
print(f" Preview live: https://{host}")
print(f"{'='*60}\n")
# ─── Teardown ────────────────────────────────────────────
def teardown(pr_id: str):
ns = f"planner-pr-{pr_id}"
image = f"{REGISTRY}/{APP_NAME}:pr-{pr_id}"
print(f"\n Tearing down: {ns}")
kubectl("delete", "namespace", ns, "--ignore-not-found")
docker("rmi", image, check=False)
print(" Done.\n")
# ─── Deploy Production ───────────────────────────────────
def deploy_prod():
image = f"{REGISTRY}/{APP_NAME}:latest"
print(f"\n{'='*60}")
print(f" Deploying production: https://{BASE_DOMAIN}")
print(f"{'='*60}\n")
docker("build", "-t", image, ".")
docker("push", image)
kubectl("rollout", "restart", f"deploy/{APP_NAME}", "-n", PROD_NS)
kubectl("rollout", "status", f"deploy/{APP_NAME}", "-n", PROD_NS, "--timeout=120s")
print(f"\n Production deployed: https://{BASE_DOMAIN}\n")
# ─── Main ────────────────────────────────────────────────
if __name__ == "__main__":
if len(sys.argv) < 2:
print(__doc__)
sys.exit(1)
action = sys.argv[1]
if action == "deploy" and len(sys.argv) >= 3:
deploy(sys.argv[2])
elif action == "teardown" and len(sys.argv) >= 3:
teardown(sys.argv[2])
elif action == "deploy-prod":
deploy_prod()
else:
print(__doc__)
sys.exit(1)