Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a110d10e39 | |||
| 8c69e2db5b | |||
| 62057d6022 | |||
| d3f3b4f37b |
20
.gitea/workflows/deploy.yml
Normal file
20
.gitea/workflows/deploy.yml
Normal 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
|
||||
50
.gitea/workflows/preview.yml
Normal file
50
.gitea/workflows/preview.yml
Normal 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
69
.gitea/workflows/test.yml
Normal 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
5
.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
node_modules
|
||||
__pycache__
|
||||
frontend/node_modules
|
||||
frontend/dist
|
||||
*.pyc
|
||||
30
Dockerfile
30
Dockerfile
@@ -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
0
backend/__init__.py
Normal file
218
backend/database.py
Normal file
218
backend/database.py
Normal 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
1071
backend/main.py
Normal file
File diff suppressed because it is too large
Load Diff
3
backend/requirements.txt
Normal file
3
backend/requirements.txt
Normal file
@@ -0,0 +1,3 @@
|
||||
fastapi==0.115.6
|
||||
uvicorn[standard]==0.34.0
|
||||
aiosqlite==0.20.0
|
||||
50
deploy/backup-cronjob.yaml
Normal file
50
deploy/backup-cronjob.yaml
Normal 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
47
deploy/deployment.yaml
Normal 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
23
deploy/ingress.yaml
Normal 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
4
deploy/namespace.yaml
Normal file
@@ -0,0 +1,4 @@
|
||||
apiVersion: v1
|
||||
kind: Namespace
|
||||
metadata:
|
||||
name: planner
|
||||
11
deploy/pvc.yaml
Normal file
11
deploy/pvc.yaml
Normal 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
11
deploy/service.yaml
Normal file
@@ -0,0 +1,11 @@
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: planner
|
||||
namespace: planner
|
||||
spec:
|
||||
selector:
|
||||
app: planner
|
||||
ports:
|
||||
- port: 80
|
||||
targetPort: 8000
|
||||
82
deploy/setup-kubeconfig.sh
Normal file
82
deploy/setup-kubeconfig.sh
Normal 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
362
doc/test-coverage.md
Normal 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 种视口
|
||||
13
frontend/cypress.config.js
Normal file
13
frontend/cypress.config.js
Normal 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,
|
||||
},
|
||||
})
|
||||
231
frontend/cypress/e2e/api-crud.cy.js
Normal file
231
frontend/cypress/e2e/api-crud.cy.js
Normal 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
|
||||
})
|
||||
})
|
||||
})
|
||||
153
frontend/cypress/e2e/api-health.cy.js
Normal file
153
frontend/cypress/e2e/api-health.cy.js
Normal 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)
|
||||
})
|
||||
})
|
||||
})
|
||||
61
frontend/cypress/e2e/app-load.cy.js
Normal file
61
frontend/cypress/e2e/app-load.cy.js
Normal 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')
|
||||
})
|
||||
})
|
||||
55
frontend/cypress/e2e/auth-flow.cy.js
Normal file
55
frontend/cypress/e2e/auth-flow.cy.js
Normal 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')
|
||||
})
|
||||
})
|
||||
45
frontend/cypress/e2e/body-gym.cy.js
Normal file
45
frontend/cypress/e2e/body-gym.cy.js
Normal 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')
|
||||
})
|
||||
})
|
||||
70
frontend/cypress/e2e/body-health.cy.js
Normal file
70
frontend/cypress/e2e/body-health.cy.js
Normal 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')
|
||||
})
|
||||
})
|
||||
45
frontend/cypress/e2e/body-period.cy.js
Normal file
45
frontend/cypress/e2e/body-period.cy.js
Normal 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')
|
||||
})
|
||||
})
|
||||
53
frontend/cypress/e2e/body-sleep.cy.js
Normal file
53
frontend/cypress/e2e/body-sleep.cy.js
Normal 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')
|
||||
})
|
||||
})
|
||||
105
frontend/cypress/e2e/docs-flow.cy.js
Normal file
105
frontend/cypress/e2e/docs-flow.cy.js
Normal 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')
|
||||
})
|
||||
})
|
||||
61
frontend/cypress/e2e/music-flow.cy.js
Normal file
61
frontend/cypress/e2e/music-flow.cy.js
Normal 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')
|
||||
})
|
||||
})
|
||||
71
frontend/cypress/e2e/navigation.cy.js
Normal file
71
frontend/cypress/e2e/navigation.cy.js
Normal 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')
|
||||
})
|
||||
})
|
||||
115
frontend/cypress/e2e/notes-flow.cy.js
Normal file
115
frontend/cypress/e2e/notes-flow.cy.js
Normal 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')
|
||||
})
|
||||
})
|
||||
59
frontend/cypress/e2e/performance.cy.js
Normal file
59
frontend/cypress/e2e/performance.cy.js
Normal 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')
|
||||
})
|
||||
})
|
||||
40
frontend/cypress/e2e/planning-review.cy.js
Normal file
40
frontend/cypress/e2e/planning-review.cy.js
Normal 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')
|
||||
})
|
||||
})
|
||||
75
frontend/cypress/e2e/planning-schedule.cy.js
Normal file
75
frontend/cypress/e2e/planning-schedule.cy.js
Normal 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')
|
||||
})
|
||||
})
|
||||
30
frontend/cypress/e2e/planning-template.cy.js
Normal file
30
frontend/cypress/e2e/planning-template.cy.js
Normal 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')
|
||||
})
|
||||
})
|
||||
73
frontend/cypress/e2e/reminders-flow.cy.js
Normal file
73
frontend/cypress/e2e/reminders-flow.cy.js
Normal 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')
|
||||
})
|
||||
})
|
||||
52
frontend/cypress/e2e/responsive.cy.js
Normal file
52
frontend/cypress/e2e/responsive.cy.js
Normal 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')
|
||||
})
|
||||
})
|
||||
128
frontend/cypress/e2e/sleep-buddy.cy.js
Normal file
128
frontend/cypress/e2e/sleep-buddy.cy.js
Normal 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')
|
||||
})
|
||||
})
|
||||
})
|
||||
140
frontend/cypress/e2e/tasks-flow.cy.js
Normal file
140
frontend/cypress/e2e/tasks-flow.cy.js
Normal 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()
|
||||
})
|
||||
})
|
||||
31
frontend/cypress/support/e2e.js
Normal file
31
frontend/cypress/support/e2e.js
Normal 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
18
frontend/index.html
Normal 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
3361
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
24
frontend/package.json
Normal file
24
frontend/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
13
frontend/public/favicon.svg
Normal file
13
frontend/public/favicon.svg
Normal 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 |
BIN
frontend/public/icon-180.png
Normal file
BIN
frontend/public/icon-180.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 8.0 KiB |
11
frontend/public/manifest.json
Normal file
11
frontend/public/manifest.json
Normal 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" }
|
||||
]
|
||||
}
|
||||
BIN
frontend/public/notebook.jpg
Normal file
BIN
frontend/public/notebook.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 405 KiB |
25
frontend/public/sw.js
Normal file
25
frontend/public/sw.js
Normal 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
158
frontend/src/App.vue
Normal 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>
|
||||
455
frontend/src/assets/styles.css
Normal file
455
frontend/src/assets/styles.css
Normal 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; }
|
||||
41
frontend/src/components/CustomDialog.vue
Normal file
41
frontend/src/components/CustomDialog.vue
Normal 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>
|
||||
33
frontend/src/composables/useApi.js
Normal file
33
frontend/src/composables/useApi.js
Normal 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
|
||||
45
frontend/src/composables/useDialog.js
Normal file
45
frontend/src/composables/useDialog.js
Normal 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
10
frontend/src/main.js
Normal 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')
|
||||
51
frontend/src/router/index.js
Normal file
51
frontend/src/router/index.js
Normal 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
|
||||
42
frontend/src/stores/auth.js
Normal file
42
frontend/src/stores/auth.js
Normal 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 }
|
||||
})
|
||||
360
frontend/src/stores/planner.js
Normal file
360
frontend/src/stores/planner.js
Normal 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
30
frontend/src/stores/ui.js
Normal 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 }
|
||||
})
|
||||
260
frontend/src/views/BodyView.vue
Normal file
260
frontend/src/views/BodyView.vue
Normal 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>
|
||||
144
frontend/src/views/DocsView.vue
Normal file
144
frontend/src/views/DocsView.vue
Normal 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>
|
||||
94
frontend/src/views/MusicView.vue
Normal file
94
frontend/src/views/MusicView.vue
Normal 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>
|
||||
148
frontend/src/views/NotesView.vue
Normal file
148
frontend/src/views/NotesView.vue
Normal 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>
|
||||
224
frontend/src/views/PlanningView.vue
Normal file
224
frontend/src/views/PlanningView.vue
Normal 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>
|
||||
77
frontend/src/views/RemindersView.vue
Normal file
77
frontend/src/views/RemindersView.vue
Normal 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>
|
||||
183
frontend/src/views/SleepBuddyView.vue
Normal file
183
frontend/src/views/SleepBuddyView.vue
Normal 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>
|
||||
209
frontend/src/views/TasksView.vue
Normal file
209
frontend/src/views/TasksView.vue
Normal 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
14
frontend/vite.config.js
Normal 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
265
scripts/deploy-preview.py
Normal 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)
|
||||
Reference in New Issue
Block a user