Compare commits
40 Commits
2645d2afe5
...
fix/save-t
| Author | SHA1 | Date | |
|---|---|---|---|
| b07b97bf1e | |||
| 2ab192c3ba | |||
| 70413971e3 | |||
| 6804835e85 | |||
| 2088019ed7 | |||
| 86be739667 | |||
| 4655040153 | |||
| d68f5b35ee | |||
| cd833a6232 | |||
| 43f57c55f5 | |||
| f89cfff20b | |||
| 6563a6f7d2 | |||
| 2bec4a2d26 | |||
| eaab1276a2 | |||
| a4b79ebe65 | |||
| 7fd52f7a86 | |||
| f884bff452 | |||
| 9c85ed21b3 | |||
| a27c30ea7c | |||
| f88521c9be | |||
| c115c47e61 | |||
| 56d0c9b469 | |||
| 4fbd18c952 | |||
| ec25aebdd9 | |||
| 0d0a563fab | |||
| bc27863930 | |||
| 9628a3357e | |||
| 34c671f9f3 | |||
| 7dbcd2778e | |||
| 81efad83f9 | |||
| 8443f1a564 | |||
| cd65fd35be | |||
| 9c1c71d21a | |||
| 67ccf1771f | |||
| 22b60c1716 | |||
| b515cf162b | |||
| d6058c8d02 | |||
| 2ee0c7c241 | |||
| 3424fd1fd0 | |||
| 7ba1e28370 |
@@ -4,20 +4,19 @@ on:
|
|||||||
branches: [main]
|
branches: [main]
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
|
test:
|
||||||
|
runs-on: test
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- name: Unit tests
|
||||||
|
run: cd frontend && npm ci && npx vitest run
|
||||||
|
- name: Build check
|
||||||
|
run: cd frontend && npm run build
|
||||||
|
|
||||||
deploy:
|
deploy:
|
||||||
|
needs: test
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
- name: Deploy Production
|
||||||
- name: Unit tests
|
run: python3 scripts/deploy-preview.py deploy-prod
|
||||||
run: cd frontend && npm ci && npm run test:unit
|
|
||||||
|
|
||||||
- name: Build & Deploy
|
|
||||||
run: |
|
|
||||||
rsync -az --exclude node_modules --exclude .git --exclude .venv . oci:~/oil-calculator/
|
|
||||||
ssh oci "
|
|
||||||
cd ~/oil-calculator &&
|
|
||||||
docker build -t registry.oci.euphon.net/oil-calculator:latest . &&
|
|
||||||
docker push registry.oci.euphon.net/oil-calculator:latest &&
|
|
||||||
sudo k3s kubectl rollout restart deploy/oil-calculator -n oil-calculator
|
|
||||||
"
|
|
||||||
|
|||||||
@@ -3,188 +3,48 @@ on:
|
|||||||
pull_request:
|
pull_request:
|
||||||
types: [opened, synchronize, reopened, closed]
|
types: [opened, synchronize, reopened, closed]
|
||||||
|
|
||||||
env:
|
|
||||||
REGISTRY: registry.oci.euphon.net
|
|
||||||
BASE_DOMAIN: oil.oci.euphon.net
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
|
test:
|
||||||
|
if: github.event.action != 'closed'
|
||||||
|
runs-on: test
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- name: Unit tests
|
||||||
|
run: cd frontend && npm ci && npx vitest run
|
||||||
|
|
||||||
deploy-preview:
|
deploy-preview:
|
||||||
if: github.event.action != 'closed'
|
if: github.event.action != 'closed'
|
||||||
|
needs: test
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
- name: Deploy Preview
|
||||||
- name: Unit tests
|
run: python3 scripts/deploy-preview.py deploy ${{ github.event.pull_request.number }}
|
||||||
run: cd frontend && npm ci && npm run test:unit
|
- name: Comment PR
|
||||||
|
env:
|
||||||
- name: Deploy Preview Environment
|
GIT_TOKEN: ${{ secrets.GIT_TOKEN }}
|
||||||
run: |
|
run: |
|
||||||
PR_ID="${{ github.event.pull_request.number }}"
|
PR_ID="${{ github.event.pull_request.number }}"
|
||||||
NS="oil-pr-${PR_ID}"
|
curl -sf -X POST \
|
||||||
HOST="pr-${PR_ID}.${BASE_DOMAIN}"
|
|
||||||
IMAGE="${REGISTRY}/oil-calculator:pr-${PR_ID}"
|
|
||||||
|
|
||||||
# Sync source to oci build server
|
|
||||||
rsync -az --exclude node_modules --exclude .git --exclude .venv \
|
|
||||||
. oci:/tmp/oil-pr-${PR_ID}-build/
|
|
||||||
|
|
||||||
ssh oci bash -s "${PR_ID}" "${NS}" "${HOST}" "${IMAGE}" << 'DEPLOY_SCRIPT'
|
|
||||||
set -e
|
|
||||||
PR_ID="$1"; NS="$2"; HOST="$3"; IMAGE="$4"
|
|
||||||
|
|
||||||
cd /tmp/oil-pr-${PR_ID}-build
|
|
||||||
|
|
||||||
# Copy production DB into build context so it's baked into image
|
|
||||||
PROD_POD=$(sudo k3s kubectl get pods -n oil-calculator -l app=oil-calculator \
|
|
||||||
--field-selector=status.phase=Running -o jsonpath='{.items[0].metadata.name}' 2>/dev/null || echo "")
|
|
||||||
if [ -n "$PROD_POD" ]; then
|
|
||||||
sudo k3s kubectl cp "oil-calculator/${PROD_POD}:/data/oil_calculator.db" /tmp/pr-${PR_ID}.db
|
|
||||||
mkdir -p data
|
|
||||||
cp /tmp/pr-${PR_ID}.db data/oil_calculator.db
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Build image (with DB baked in)
|
|
||||||
cat > Dockerfile.preview << 'DEOF'
|
|
||||||
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/
|
|
||||||
# Bake production DB copy into image
|
|
||||||
COPY data/oil_calculator.db /data/oil_calculator.db
|
|
||||||
ENV DB_PATH=/data/oil_calculator.db
|
|
||||||
ENV FRONTEND_DIR=/app/frontend
|
|
||||||
EXPOSE 8000
|
|
||||||
CMD ["uvicorn", "backend.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
|
||||||
DEOF
|
|
||||||
|
|
||||||
docker build -f Dockerfile.preview -t "${IMAGE}" .
|
|
||||||
docker push "${IMAGE}"
|
|
||||||
|
|
||||||
# Create namespace
|
|
||||||
sudo k3s kubectl create namespace "${NS}" --dry-run=client -o yaml | sudo k3s kubectl apply -f -
|
|
||||||
|
|
||||||
# Copy regcred from production namespace
|
|
||||||
sudo k3s kubectl get secret regcred -n oil-calculator -o json | \
|
|
||||||
sed "s/\"namespace\":\"oil-calculator\"/\"namespace\":\"${NS}\"/" | \
|
|
||||||
sudo k3s kubectl apply -f -
|
|
||||||
|
|
||||||
# Deploy pod + service + ingress (no PVC needed, DB is in image)
|
|
||||||
cat << EOYAML | sudo k3s kubectl apply -f -
|
|
||||||
apiVersion: apps/v1
|
|
||||||
kind: Deployment
|
|
||||||
metadata:
|
|
||||||
name: oil-calculator
|
|
||||||
namespace: ${NS}
|
|
||||||
spec:
|
|
||||||
replicas: 1
|
|
||||||
selector:
|
|
||||||
matchLabels:
|
|
||||||
app: oil-calculator
|
|
||||||
template:
|
|
||||||
metadata:
|
|
||||||
labels:
|
|
||||||
app: oil-calculator
|
|
||||||
spec:
|
|
||||||
imagePullSecrets:
|
|
||||||
- name: regcred
|
|
||||||
containers:
|
|
||||||
- name: app
|
|
||||||
image: ${IMAGE}
|
|
||||||
ports:
|
|
||||||
- containerPort: 8000
|
|
||||||
resources:
|
|
||||||
requests:
|
|
||||||
cpu: 50m
|
|
||||||
memory: 64Mi
|
|
||||||
limits:
|
|
||||||
cpu: 500m
|
|
||||||
memory: 256Mi
|
|
||||||
---
|
|
||||||
apiVersion: v1
|
|
||||||
kind: Service
|
|
||||||
metadata:
|
|
||||||
name: oil-calculator
|
|
||||||
namespace: ${NS}
|
|
||||||
spec:
|
|
||||||
selector:
|
|
||||||
app: oil-calculator
|
|
||||||
ports:
|
|
||||||
- port: 80
|
|
||||||
targetPort: 8000
|
|
||||||
---
|
|
||||||
apiVersion: networking.k8s.io/v1
|
|
||||||
kind: Ingress
|
|
||||||
metadata:
|
|
||||||
name: oil-calculator
|
|
||||||
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: oil-calculator
|
|
||||||
port:
|
|
||||||
number: 80
|
|
||||||
EOYAML
|
|
||||||
|
|
||||||
# Wait for rollout
|
|
||||||
sudo k3s kubectl rollout status deploy/oil-calculator -n "${NS}" --timeout=120s
|
|
||||||
|
|
||||||
# Cleanup build dir
|
|
||||||
rm -rf /tmp/oil-pr-${PR_ID}-build /tmp/pr-${PR_ID}.db
|
|
||||||
|
|
||||||
echo "Preview deployed: https://${HOST}"
|
|
||||||
DEPLOY_SCRIPT
|
|
||||||
|
|
||||||
- name: Comment PR with preview URL
|
|
||||||
run: |
|
|
||||||
PR_ID="${{ github.event.pull_request.number }}"
|
|
||||||
HOST="pr-${PR_ID}.${BASE_DOMAIN}"
|
|
||||||
curl -s -X POST \
|
|
||||||
"https://git.euphon.cloud/api/v1/repos/${{ github.repository }}/issues/${PR_ID}/comments" \
|
"https://git.euphon.cloud/api/v1/repos/${{ github.repository }}/issues/${PR_ID}/comments" \
|
||||||
-H "Authorization: token ${{ secrets.GITEA_TOKEN }}" \
|
-H "Authorization: token ${GIT_TOKEN}" \
|
||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
-d "{\"body\": \"🚀 Preview deployed: https://${HOST}\n\nDB is a copy of production. Changes here won't affect prod.\"}"
|
-d "{\"body\": \"🚀 **Preview**: https://pr-${PR_ID}.oil.oci.euphon.net\n\nDB is a copy of production.\"}" || true
|
||||||
|
|
||||||
teardown-preview:
|
teardown-preview:
|
||||||
if: github.event.action == 'closed'
|
if: github.event.action == 'closed'
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Teardown Preview Environment
|
- uses: actions/checkout@v4
|
||||||
run: |
|
- name: Teardown
|
||||||
PR_ID="${{ github.event.pull_request.number }}"
|
run: python3 scripts/deploy-preview.py teardown ${{ github.event.pull_request.number }}
|
||||||
NS="oil-pr-${PR_ID}"
|
|
||||||
IMAGE="${REGISTRY}/oil-calculator:pr-${PR_ID}"
|
|
||||||
|
|
||||||
ssh oci "
|
|
||||||
sudo k3s kubectl delete namespace ${NS} --ignore-not-found
|
|
||||||
docker rmi ${IMAGE} 2>/dev/null || true
|
|
||||||
"
|
|
||||||
|
|
||||||
- name: Comment PR
|
- name: Comment PR
|
||||||
|
env:
|
||||||
|
GIT_TOKEN: ${{ secrets.GIT_TOKEN }}
|
||||||
run: |
|
run: |
|
||||||
PR_ID="${{ github.event.pull_request.number }}"
|
PR_ID="${{ github.event.pull_request.number }}"
|
||||||
curl -s -X POST \
|
curl -sf -X POST \
|
||||||
"https://git.euphon.cloud/api/v1/repos/${{ github.repository }}/issues/${PR_ID}/comments" \
|
"https://git.euphon.cloud/api/v1/repos/${{ github.repository }}/issues/${PR_ID}/comments" \
|
||||||
-H "Authorization: token ${{ secrets.GITEA_TOKEN }}" \
|
-H "Authorization: token ${GIT_TOKEN}" \
|
||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
-d "{\"body\": \"🗑️ Preview environment torn down.\"}"
|
-d "{\"body\": \"🗑️ Preview torn down.\"}" || true
|
||||||
|
|||||||
@@ -2,16 +2,66 @@ name: Test
|
|||||||
on: [push]
|
on: [push]
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
test:
|
unit-test:
|
||||||
runs-on: ubuntu-latest
|
runs-on: test
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- name: Install & Run unit tests
|
||||||
|
run: cd frontend && npm ci && npx vitest run --reporter=verbose
|
||||||
|
|
||||||
|
e2e-test:
|
||||||
|
runs-on: test
|
||||||
|
needs: unit-test
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install frontend deps
|
||||||
run: cd frontend && npm ci
|
run: cd frontend && npm ci
|
||||||
|
|
||||||
- name: Unit tests
|
- name: Install backend deps
|
||||||
run: cd frontend && npm run test:unit
|
run: python3 -m venv /tmp/ci-venv && /tmp/ci-venv/bin/pip install -q -r backend/requirements.txt
|
||||||
|
|
||||||
- name: Build check
|
- name: E2E tests
|
||||||
run: cd frontend && npm run build
|
run: |
|
||||||
|
# Start backend
|
||||||
|
DB_PATH=/tmp/ci_oil_test.db FRONTEND_DIR=/dev/null \
|
||||||
|
/tmp/ci-venv/bin/uvicorn backend.main:app --port 8000 &
|
||||||
|
|
||||||
|
# Start frontend (in subshell to not change cwd)
|
||||||
|
(cd frontend && npx vite --port 5173) &
|
||||||
|
|
||||||
|
# Wait for both servers
|
||||||
|
for i in $(seq 1 30); do
|
||||||
|
if curl -sf http://localhost:8000/api/version > /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 (proven stable)
|
||||||
|
cd frontend
|
||||||
|
npx cypress run --spec "\
|
||||||
|
cypress/e2e/recipe-detail.cy.js,\
|
||||||
|
cypress/e2e/oil-reference.cy.js,\
|
||||||
|
cypress/e2e/oil-data-integrity.cy.js,\
|
||||||
|
cypress/e2e/recipe-cost-parity.cy.js,\
|
||||||
|
cypress/e2e/category-modules.cy.js,\
|
||||||
|
cypress/e2e/notification-flow.cy.js,\
|
||||||
|
cypress/e2e/registration-flow.cy.js\
|
||||||
|
" --config video=false
|
||||||
|
EXIT_CODE=$?
|
||||||
|
|
||||||
|
# Cleanup
|
||||||
|
pkill -f "uvicorn backend" || true
|
||||||
|
pkill -f "node.*vite" || true
|
||||||
|
rm -f /tmp/ci_oil_test.db
|
||||||
|
exit $EXIT_CODE
|
||||||
|
|
||||||
|
build-check:
|
||||||
|
runs-on: test
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- name: Build frontend
|
||||||
|
run: cd frontend && npm ci && npm run build
|
||||||
|
|||||||
@@ -238,6 +238,8 @@ def init_db():
|
|||||||
c.execute("ALTER TABLE recipes ADD COLUMN owner_id INTEGER")
|
c.execute("ALTER TABLE recipes ADD COLUMN owner_id INTEGER")
|
||||||
if "updated_by" not in cols:
|
if "updated_by" not in cols:
|
||||||
c.execute("ALTER TABLE recipes ADD COLUMN updated_by INTEGER")
|
c.execute("ALTER TABLE recipes ADD COLUMN updated_by INTEGER")
|
||||||
|
if "en_name" not in cols:
|
||||||
|
c.execute("ALTER TABLE recipes ADD COLUMN en_name TEXT DEFAULT ''")
|
||||||
|
|
||||||
# Seed admin user if no users exist
|
# Seed admin user if no users exist
|
||||||
count = c.execute("SELECT COUNT(*) FROM users").fetchone()[0]
|
count = c.execute("SELECT COUNT(*) FROM users").fetchone()[0]
|
||||||
|
|||||||
@@ -6,9 +6,35 @@ import json
|
|||||||
import os
|
import os
|
||||||
|
|
||||||
from backend.database import get_db, init_db, seed_defaults, log_audit
|
from backend.database import get_db, init_db, seed_defaults, log_audit
|
||||||
|
import hashlib
|
||||||
|
import secrets as _secrets
|
||||||
|
|
||||||
app = FastAPI(title="Essential Oil Formula Calculator API")
|
app = FastAPI(title="Essential Oil Formula Calculator API")
|
||||||
|
|
||||||
|
|
||||||
|
# ── Password hashing (PBKDF2-SHA256, stdlib) ─────────
|
||||||
|
def hash_password(password: str) -> str:
|
||||||
|
salt = _secrets.token_hex(16)
|
||||||
|
h = hashlib.pbkdf2_hmac("sha256", password.encode(), salt.encode(), 200_000)
|
||||||
|
return f"{salt}${h.hex()}"
|
||||||
|
|
||||||
|
|
||||||
|
def verify_password(password: str, stored: str) -> bool:
|
||||||
|
if not stored:
|
||||||
|
return False
|
||||||
|
if "$" not in stored:
|
||||||
|
# Legacy plaintext — compare directly
|
||||||
|
return password == stored
|
||||||
|
salt, h = stored.split("$", 1)
|
||||||
|
return hashlib.pbkdf2_hmac("sha256", password.encode(), salt.encode(), 200_000).hex() == h
|
||||||
|
|
||||||
|
|
||||||
|
def _upgrade_password_if_needed(conn, user_id: int, password: str, stored: str):
|
||||||
|
"""If stored password is legacy plaintext, upgrade to hashed."""
|
||||||
|
if stored and "$" not in stored:
|
||||||
|
conn.execute("UPDATE users SET password = ? WHERE id = ?", (hash_password(password), user_id))
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
# Periodic WAL checkpoint to ensure data is flushed to main DB file
|
# Periodic WAL checkpoint to ensure data is flushed to main DB file
|
||||||
import threading, time as _time
|
import threading, time as _time
|
||||||
def _wal_checkpoint_loop():
|
def _wal_checkpoint_loop():
|
||||||
@@ -69,6 +95,7 @@ class RecipeIn(BaseModel):
|
|||||||
|
|
||||||
class RecipeUpdate(BaseModel):
|
class RecipeUpdate(BaseModel):
|
||||||
name: Optional[str] = None
|
name: Optional[str] = None
|
||||||
|
en_name: Optional[str] = None
|
||||||
note: Optional[str] = None
|
note: Optional[str] = None
|
||||||
ingredients: Optional[list[IngredientIn]] = None
|
ingredients: Optional[list[IngredientIn]] = None
|
||||||
tags: Optional[list[str]] = None
|
tags: Optional[list[str]] = None
|
||||||
@@ -282,7 +309,7 @@ def symptom_search(body: dict, user=Depends(get_current_user)):
|
|||||||
conn = get_db()
|
conn = get_db()
|
||||||
# Search in recipe names
|
# Search in recipe names
|
||||||
rows = conn.execute(
|
rows = conn.execute(
|
||||||
"SELECT id, name, note, owner_id, version FROM recipes ORDER BY id"
|
"SELECT id, name, note, owner_id, version, en_name FROM recipes ORDER BY id"
|
||||||
).fetchall()
|
).fetchall()
|
||||||
exact = []
|
exact = []
|
||||||
related = []
|
related = []
|
||||||
@@ -312,7 +339,6 @@ def symptom_search(body: dict, user=Depends(get_current_user)):
|
|||||||
# ── Register ────────────────────────────────────────────
|
# ── Register ────────────────────────────────────────────
|
||||||
@app.post("/api/register", status_code=201)
|
@app.post("/api/register", status_code=201)
|
||||||
def register(body: dict):
|
def register(body: dict):
|
||||||
import secrets
|
|
||||||
username = body.get("username", "").strip()
|
username = body.get("username", "").strip()
|
||||||
password = body.get("password", "").strip()
|
password = body.get("password", "").strip()
|
||||||
display_name = body.get("display_name", "").strip()
|
display_name = body.get("display_name", "").strip()
|
||||||
@@ -320,12 +346,12 @@ def register(body: dict):
|
|||||||
raise HTTPException(400, "用户名至少2个字符")
|
raise HTTPException(400, "用户名至少2个字符")
|
||||||
if not password or len(password) < 4:
|
if not password or len(password) < 4:
|
||||||
raise HTTPException(400, "密码至少4位")
|
raise HTTPException(400, "密码至少4位")
|
||||||
token = secrets.token_hex(24)
|
token = _secrets.token_hex(24)
|
||||||
conn = get_db()
|
conn = get_db()
|
||||||
try:
|
try:
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"INSERT INTO users (username, token, role, display_name, password) VALUES (?, ?, ?, ?, ?)",
|
"INSERT INTO users (username, token, role, display_name, password) VALUES (?, ?, ?, ?, ?)",
|
||||||
(username, token, "viewer", display_name or username, password)
|
(username, token, "viewer", display_name or username, hash_password(password))
|
||||||
)
|
)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
except Exception:
|
except Exception:
|
||||||
@@ -343,14 +369,19 @@ def login(body: dict):
|
|||||||
if not username or not password:
|
if not username or not password:
|
||||||
raise HTTPException(400, "请输入用户名和密码")
|
raise HTTPException(400, "请输入用户名和密码")
|
||||||
conn = get_db()
|
conn = get_db()
|
||||||
user = conn.execute("SELECT token, password, display_name, role FROM users WHERE username = ?", (username,)).fetchone()
|
user = conn.execute("SELECT id, token, password, display_name, role FROM users WHERE username = ?", (username,)).fetchone()
|
||||||
conn.close()
|
|
||||||
if not user:
|
if not user:
|
||||||
|
conn.close()
|
||||||
raise HTTPException(401, "用户名不存在")
|
raise HTTPException(401, "用户名不存在")
|
||||||
if not user["password"]:
|
if not user["password"]:
|
||||||
|
conn.close()
|
||||||
raise HTTPException(401, "该账号未设置密码,请使用链接登录后设置密码")
|
raise HTTPException(401, "该账号未设置密码,请使用链接登录后设置密码")
|
||||||
if user["password"] != password:
|
if not verify_password(password, user["password"]):
|
||||||
|
conn.close()
|
||||||
raise HTTPException(401, "密码错误")
|
raise HTTPException(401, "密码错误")
|
||||||
|
# Auto-upgrade legacy plaintext password to hashed
|
||||||
|
_upgrade_password_if_needed(conn, user["id"], password, user["password"])
|
||||||
|
conn.close()
|
||||||
return {"token": user["token"], "display_name": user["display_name"], "role": user["role"]}
|
return {"token": user["token"], "display_name": user["display_name"], "role": user["role"]}
|
||||||
|
|
||||||
|
|
||||||
@@ -385,11 +416,11 @@ def update_me(body: dict, user=Depends(get_current_user)):
|
|||||||
raise HTTPException(400, "新密码至少4位")
|
raise HTTPException(400, "新密码至少4位")
|
||||||
old_pw = body.get("old_password", "").strip()
|
old_pw = body.get("old_password", "").strip()
|
||||||
current_pw = user.get("password") or ""
|
current_pw = user.get("password") or ""
|
||||||
if current_pw and old_pw != current_pw:
|
if current_pw and not verify_password(old_pw, current_pw):
|
||||||
conn.close()
|
conn.close()
|
||||||
raise HTTPException(400, "当前密码不正确")
|
raise HTTPException(400, "当前密码不正确")
|
||||||
if pw:
|
if pw:
|
||||||
conn.execute("UPDATE users SET password = ? WHERE id = ?", (pw, user["id"]))
|
conn.execute("UPDATE users SET password = ? WHERE id = ?", (hash_password(pw), user["id"]))
|
||||||
conn.commit()
|
conn.commit()
|
||||||
conn.close()
|
conn.close()
|
||||||
return {"ok": True}
|
return {"ok": True}
|
||||||
@@ -404,7 +435,7 @@ def set_password(body: dict, user=Depends(get_current_user)):
|
|||||||
if not pw or len(pw) < 4:
|
if not pw or len(pw) < 4:
|
||||||
raise HTTPException(400, "密码至少4位")
|
raise HTTPException(400, "密码至少4位")
|
||||||
conn = get_db()
|
conn = get_db()
|
||||||
conn.execute("UPDATE users SET password = ? WHERE id = ?", (pw, user["id"]))
|
conn.execute("UPDATE users SET password = ? WHERE id = ?", (hash_password(pw), user["id"]))
|
||||||
conn.commit()
|
conn.commit()
|
||||||
conn.close()
|
conn.close()
|
||||||
return {"ok": True}
|
return {"ok": True}
|
||||||
@@ -666,6 +697,7 @@ def _recipe_to_dict(conn, row):
|
|||||||
return {
|
return {
|
||||||
"id": rid,
|
"id": rid,
|
||||||
"name": row["name"],
|
"name": row["name"],
|
||||||
|
"en_name": row["en_name"] if "en_name" in row.keys() else "",
|
||||||
"note": row["note"],
|
"note": row["note"],
|
||||||
"owner_id": row["owner_id"],
|
"owner_id": row["owner_id"],
|
||||||
"owner_name": (owner["display_name"] or owner["username"]) if owner else None,
|
"owner_name": (owner["display_name"] or owner["username"]) if owner else None,
|
||||||
@@ -680,19 +712,19 @@ def list_recipes(user=Depends(get_current_user)):
|
|||||||
conn = get_db()
|
conn = get_db()
|
||||||
# Admin sees all; others see admin-owned (adopted) + their own
|
# Admin sees all; others see admin-owned (adopted) + their own
|
||||||
if user["role"] == "admin":
|
if user["role"] == "admin":
|
||||||
rows = conn.execute("SELECT id, name, note, owner_id, version FROM recipes ORDER BY id").fetchall()
|
rows = conn.execute("SELECT id, name, note, owner_id, version, en_name FROM recipes ORDER BY id").fetchall()
|
||||||
else:
|
else:
|
||||||
admin = conn.execute("SELECT id FROM users WHERE role = 'admin' LIMIT 1").fetchone()
|
admin = conn.execute("SELECT id FROM users WHERE role = 'admin' LIMIT 1").fetchone()
|
||||||
admin_id = admin["id"] if admin else 1
|
admin_id = admin["id"] if admin else 1
|
||||||
user_id = user.get("id")
|
user_id = user.get("id")
|
||||||
if user_id:
|
if user_id:
|
||||||
rows = conn.execute(
|
rows = conn.execute(
|
||||||
"SELECT id, name, note, owner_id, version FROM recipes WHERE owner_id = ? OR owner_id = ? ORDER BY id",
|
"SELECT id, name, note, owner_id, version, en_name FROM recipes WHERE owner_id = ? OR owner_id = ? ORDER BY id",
|
||||||
(admin_id, user_id)
|
(admin_id, user_id)
|
||||||
).fetchall()
|
).fetchall()
|
||||||
else:
|
else:
|
||||||
rows = conn.execute(
|
rows = conn.execute(
|
||||||
"SELECT id, name, note, owner_id, version FROM recipes WHERE owner_id = ? ORDER BY id",
|
"SELECT id, name, note, owner_id, version, en_name FROM recipes WHERE owner_id = ? ORDER BY id",
|
||||||
(admin_id,)
|
(admin_id,)
|
||||||
).fetchall()
|
).fetchall()
|
||||||
result = [_recipe_to_dict(conn, r) for r in rows]
|
result = [_recipe_to_dict(conn, r) for r in rows]
|
||||||
@@ -703,7 +735,7 @@ def list_recipes(user=Depends(get_current_user)):
|
|||||||
@app.get("/api/recipes/{recipe_id}")
|
@app.get("/api/recipes/{recipe_id}")
|
||||||
def get_recipe(recipe_id: int):
|
def get_recipe(recipe_id: int):
|
||||||
conn = get_db()
|
conn = get_db()
|
||||||
row = conn.execute("SELECT id, name, note, owner_id, version FROM recipes WHERE id = ?", (recipe_id,)).fetchone()
|
row = conn.execute("SELECT id, name, note, owner_id, version, en_name FROM recipes WHERE id = ?", (recipe_id,)).fetchone()
|
||||||
if not row:
|
if not row:
|
||||||
conn.close()
|
conn.close()
|
||||||
raise HTTPException(404, "Recipe not found")
|
raise HTTPException(404, "Recipe not found")
|
||||||
@@ -713,7 +745,9 @@ def get_recipe(recipe_id: int):
|
|||||||
|
|
||||||
|
|
||||||
@app.post("/api/recipes", status_code=201)
|
@app.post("/api/recipes", status_code=201)
|
||||||
def create_recipe(recipe: RecipeIn, user=Depends(require_role("admin", "senior_editor", "editor"))):
|
def create_recipe(recipe: RecipeIn, user=Depends(get_current_user)):
|
||||||
|
if not user.get("id"):
|
||||||
|
raise HTTPException(401, "请先登录")
|
||||||
conn = get_db()
|
conn = get_db()
|
||||||
c = conn.cursor()
|
c = conn.cursor()
|
||||||
c.execute("INSERT INTO recipes (name, note, owner_id) VALUES (?, ?, ?)",
|
c.execute("INSERT INTO recipes (name, note, owner_id) VALUES (?, ?, ?)",
|
||||||
@@ -748,13 +782,15 @@ def _check_recipe_permission(conn, recipe_id, user):
|
|||||||
raise HTTPException(404, "Recipe not found")
|
raise HTTPException(404, "Recipe not found")
|
||||||
if user["role"] in ("admin", "senior_editor"):
|
if user["role"] in ("admin", "senior_editor"):
|
||||||
return row
|
return row
|
||||||
if user["role"] == "editor" and row["owner_id"] == user["id"]:
|
if row["owner_id"] == user.get("id"):
|
||||||
return row
|
return row
|
||||||
raise HTTPException(403, "只能修改自己创建的配方")
|
raise HTTPException(403, "只能修改自己创建的配方")
|
||||||
|
|
||||||
|
|
||||||
@app.put("/api/recipes/{recipe_id}")
|
@app.put("/api/recipes/{recipe_id}")
|
||||||
def update_recipe(recipe_id: int, update: RecipeUpdate, user=Depends(require_role("admin", "senior_editor", "editor"))):
|
def update_recipe(recipe_id: int, update: RecipeUpdate, user=Depends(get_current_user)):
|
||||||
|
if not user.get("id"):
|
||||||
|
raise HTTPException(401, "请先登录")
|
||||||
conn = get_db()
|
conn = get_db()
|
||||||
c = conn.cursor()
|
c = conn.cursor()
|
||||||
_check_recipe_permission(conn, recipe_id, user)
|
_check_recipe_permission(conn, recipe_id, user)
|
||||||
@@ -770,6 +806,8 @@ def update_recipe(recipe_id: int, update: RecipeUpdate, user=Depends(require_rol
|
|||||||
c.execute("UPDATE recipes SET name = ? WHERE id = ?", (update.name, recipe_id))
|
c.execute("UPDATE recipes SET name = ? WHERE id = ?", (update.name, recipe_id))
|
||||||
if update.note is not None:
|
if update.note is not None:
|
||||||
c.execute("UPDATE recipes SET note = ? WHERE id = ?", (update.note, recipe_id))
|
c.execute("UPDATE recipes SET note = ? WHERE id = ?", (update.note, recipe_id))
|
||||||
|
if update.en_name is not None:
|
||||||
|
c.execute("UPDATE recipes SET en_name = ? WHERE id = ?", (update.en_name, recipe_id))
|
||||||
if update.ingredients is not None:
|
if update.ingredients is not None:
|
||||||
c.execute("DELETE FROM recipe_ingredients WHERE recipe_id = ?", (recipe_id,))
|
c.execute("DELETE FROM recipe_ingredients WHERE recipe_id = ?", (recipe_id,))
|
||||||
for ing in update.ingredients:
|
for ing in update.ingredients:
|
||||||
@@ -793,11 +831,13 @@ def update_recipe(recipe_id: int, update: RecipeUpdate, user=Depends(require_rol
|
|||||||
|
|
||||||
|
|
||||||
@app.delete("/api/recipes/{recipe_id}")
|
@app.delete("/api/recipes/{recipe_id}")
|
||||||
def delete_recipe(recipe_id: int, user=Depends(require_role("admin", "senior_editor", "editor"))):
|
def delete_recipe(recipe_id: int, user=Depends(get_current_user)):
|
||||||
|
if not user.get("id"):
|
||||||
|
raise HTTPException(401, "请先登录")
|
||||||
conn = get_db()
|
conn = get_db()
|
||||||
row = _check_recipe_permission(conn, recipe_id, user)
|
row = _check_recipe_permission(conn, recipe_id, user)
|
||||||
# Save full snapshot for undo
|
# Save full snapshot for undo
|
||||||
full = conn.execute("SELECT id, name, note, owner_id, version FROM recipes WHERE id = ?", (recipe_id,)).fetchone()
|
full = conn.execute("SELECT id, name, note, owner_id, version, en_name FROM recipes WHERE id = ?", (recipe_id,)).fetchone()
|
||||||
snapshot = _recipe_to_dict(conn, full)
|
snapshot = _recipe_to_dict(conn, full)
|
||||||
log_audit(conn, user["id"], "delete_recipe", "recipe", recipe_id, row["name"],
|
log_audit(conn, user["id"], "delete_recipe", "recipe", recipe_id, row["name"],
|
||||||
json.dumps(snapshot, ensure_ascii=False))
|
json.dumps(snapshot, ensure_ascii=False))
|
||||||
@@ -890,8 +930,7 @@ def list_users(user=Depends(require_role("admin"))):
|
|||||||
|
|
||||||
@app.post("/api/users", status_code=201)
|
@app.post("/api/users", status_code=201)
|
||||||
def create_user(body: UserIn, user=Depends(require_role("admin"))):
|
def create_user(body: UserIn, user=Depends(require_role("admin"))):
|
||||||
import secrets
|
token = _secrets.token_hex(24)
|
||||||
token = secrets.token_hex(24)
|
|
||||||
conn = get_db()
|
conn = get_db()
|
||||||
try:
|
try:
|
||||||
conn.execute(
|
conn.execute(
|
||||||
@@ -1301,7 +1340,7 @@ def recipes_by_inventory(user=Depends(get_current_user)):
|
|||||||
if not inv:
|
if not inv:
|
||||||
conn.close()
|
conn.close()
|
||||||
return []
|
return []
|
||||||
rows = conn.execute("SELECT id, name, note, owner_id, version FROM recipes ORDER BY id").fetchall()
|
rows = conn.execute("SELECT id, name, note, owner_id, version, en_name FROM recipes ORDER BY id").fetchall()
|
||||||
result = []
|
result = []
|
||||||
for r in rows:
|
for r in rows:
|
||||||
recipe = _recipe_to_dict(conn, r)
|
recipe = _recipe_to_dict(conn, r)
|
||||||
@@ -1494,4 +1533,18 @@ def startup():
|
|||||||
seed_defaults(data["oils_meta"], data["recipes"])
|
seed_defaults(data["oils_meta"], data["recipes"])
|
||||||
|
|
||||||
if os.path.isdir(FRONTEND_DIR):
|
if os.path.isdir(FRONTEND_DIR):
|
||||||
app.mount("/", StaticFiles(directory=FRONTEND_DIR, html=True), name="frontend")
|
# Serve static assets (js/css/images) directly
|
||||||
|
app.mount("/assets", StaticFiles(directory=os.path.join(FRONTEND_DIR, "assets")), name="assets")
|
||||||
|
app.mount("/public", StaticFiles(directory=FRONTEND_DIR), name="public")
|
||||||
|
|
||||||
|
# SPA fallback: any non-API, non-asset route returns index.html
|
||||||
|
from fastapi.responses import FileResponse
|
||||||
|
|
||||||
|
@app.get("/{path:path}")
|
||||||
|
async def spa_fallback(path: str):
|
||||||
|
# Serve actual files if they exist (favicon, icons, etc.)
|
||||||
|
file_path = os.path.join(FRONTEND_DIR, path)
|
||||||
|
if os.path.isfile(file_path):
|
||||||
|
return FileResponse(file_path)
|
||||||
|
# Otherwise return index.html for Vue Router
|
||||||
|
return FileResponse(os.path.join(FRONTEND_DIR, "index.html"))
|
||||||
|
|||||||
@@ -9,5 +9,6 @@ export default defineConfig({
|
|||||||
viewportHeight: 800,
|
viewportHeight: 800,
|
||||||
video: true,
|
video: true,
|
||||||
videoCompression: false,
|
videoCompression: false,
|
||||||
|
allowCypressEnv: false,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,48 +1,39 @@
|
|||||||
describe('Admin Flow', () => {
|
describe('Admin Flow', () => {
|
||||||
|
const ADMIN_TOKEN = 'c86ae7afbe10fabe3c1d5e1a7fee74feaadfd5dc7be2ab62'
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
const token = Cypress.env('ADMIN_TOKEN')
|
|
||||||
if (!token) {
|
|
||||||
cy.log('ADMIN_TOKEN not set, skipping admin tests')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
cy.visit('/', {
|
cy.visit('/', {
|
||||||
onBeforeLoad(win) {
|
onBeforeLoad(win) {
|
||||||
win.localStorage.setItem('oil_auth_token', token)
|
win.localStorage.setItem('oil_auth_token', ADMIN_TOKEN)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
// Wait for app to load with admin privileges
|
|
||||||
cy.get('.nav-tab', { timeout: 10000 }).should('have.length.gte', 6)
|
cy.get('.nav-tab', { timeout: 10000 }).should('have.length.gte', 6)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('shows admin-only tabs', () => {
|
it('shows admin-only tabs', () => {
|
||||||
if (!Cypress.env('ADMIN_TOKEN')) return
|
|
||||||
cy.get('.nav-tab').contains('操作日志').should('be.visible')
|
cy.get('.nav-tab').contains('操作日志').should('be.visible')
|
||||||
cy.get('.nav-tab').contains('Bug').should('be.visible')
|
cy.get('.nav-tab').contains('Bug').should('be.visible')
|
||||||
cy.get('.nav-tab').contains('用户管理').should('be.visible')
|
cy.get('.nav-tab').contains('用户管理').should('be.visible')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('can access manage recipes page', () => {
|
it('can access manage recipes page', () => {
|
||||||
if (!Cypress.env('ADMIN_TOKEN')) return
|
|
||||||
cy.get('.nav-tab').contains('管理配方').click()
|
cy.get('.nav-tab').contains('管理配方').click()
|
||||||
cy.url().should('include', '/manage')
|
cy.url().should('include', '/manage')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('can access audit log page', () => {
|
it('can access audit log page', () => {
|
||||||
if (!Cypress.env('ADMIN_TOKEN')) return
|
|
||||||
cy.get('.nav-tab').contains('操作日志').click()
|
cy.get('.nav-tab').contains('操作日志').click()
|
||||||
cy.url().should('include', '/audit')
|
cy.url().should('include', '/audit')
|
||||||
cy.contains('操作日志').should('be.visible')
|
cy.contains('操作日志').should('be.visible')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('can access user management page', () => {
|
it('can access user management page', () => {
|
||||||
if (!Cypress.env('ADMIN_TOKEN')) return
|
|
||||||
cy.get('.nav-tab').contains('用户管理').click()
|
cy.get('.nav-tab').contains('用户管理').click()
|
||||||
cy.url().should('include', '/users')
|
cy.url().should('include', '/users')
|
||||||
cy.contains('用户管理').should('be.visible')
|
cy.contains('用户管理').should('be.visible')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('can access bug tracker page', () => {
|
it('can access bug tracker page', () => {
|
||||||
if (!Cypress.env('ADMIN_TOKEN')) return
|
|
||||||
cy.get('.nav-tab').contains('Bug').click()
|
cy.get('.nav-tab').contains('Bug').click()
|
||||||
cy.url().should('include', '/bugs')
|
cy.url().should('include', '/bugs')
|
||||||
cy.contains('Bug').should('be.visible')
|
cy.contains('Bug').should('be.visible')
|
||||||
|
|||||||
@@ -46,12 +46,7 @@ describe('API Health Check', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('GET /api/me returns authenticated user with valid token', () => {
|
it('GET /api/me returns authenticated user with valid token', () => {
|
||||||
// Use the admin token from env or skip
|
const token = 'c86ae7afbe10fabe3c1d5e1a7fee74feaadfd5dc7be2ab62'
|
||||||
const token = Cypress.env('ADMIN_TOKEN')
|
|
||||||
if (!token) {
|
|
||||||
cy.log('ADMIN_TOKEN not set, skipping auth test')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
cy.request({
|
cy.request({
|
||||||
url: '/api/me',
|
url: '/api/me',
|
||||||
headers: { Authorization: `Bearer ${token}` }
|
headers: { Authorization: `Bearer ${token}` }
|
||||||
|
|||||||
74
frontend/cypress/e2e/endpoint-parity.cy.js
Normal file
74
frontend/cypress/e2e/endpoint-parity.cy.js
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
// Verify that Vue frontend pages call the correct backend API endpoints.
|
||||||
|
// This test catches mismatched endpoint names (e.g. /api/bugs vs /api/bug-reports).
|
||||||
|
|
||||||
|
const ADMIN_TOKEN = 'c86ae7afbe10fabe3c1d5e1a7fee74feaadfd5dc7be2ab62'
|
||||||
|
|
||||||
|
describe('API Endpoint Parity', () => {
|
||||||
|
function visitAsAdmin(path) {
|
||||||
|
cy.visit(path, {
|
||||||
|
onBeforeLoad(win) {
|
||||||
|
win.localStorage.setItem('oil_auth_token', ADMIN_TOKEN)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
it('search page loads recipes from /api/recipes', () => {
|
||||||
|
cy.intercept('GET', '/api/recipes').as('recipes')
|
||||||
|
visitAsAdmin('/')
|
||||||
|
cy.wait('@recipes').its('response.statusCode').should('eq', 200)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('search page loads oils from /api/oils', () => {
|
||||||
|
cy.intercept('GET', '/api/oils').as('oils')
|
||||||
|
visitAsAdmin('/')
|
||||||
|
cy.wait('@oils').its('response.statusCode').should('eq', 200)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('oil reference page loads oils', () => {
|
||||||
|
cy.intercept('GET', '/api/oils').as('oils')
|
||||||
|
visitAsAdmin('/oils')
|
||||||
|
cy.wait('@oils').its('response.statusCode').should('eq', 200)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('audit log page loads from /api/audit-log', () => {
|
||||||
|
cy.intercept('GET', '/api/audit-log*').as('audit')
|
||||||
|
visitAsAdmin('/audit')
|
||||||
|
cy.wait('@audit').its('response.statusCode').should('eq', 200)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('audit log page does NOT call /api/audit-logs (wrong endpoint)', () => {
|
||||||
|
cy.intercept('GET', '/api/audit-logs*').as('wrongAudit')
|
||||||
|
visitAsAdmin('/audit')
|
||||||
|
cy.wait(2000)
|
||||||
|
cy.get('@wrongAudit.all').should('have.length', 0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('bug tracker page loads from /api/bug-reports', () => {
|
||||||
|
cy.intercept('GET', '/api/bug-reports').as('bugs')
|
||||||
|
visitAsAdmin('/bugs')
|
||||||
|
cy.wait('@bugs').its('response.statusCode').should('eq', 200)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('bug tracker page does NOT call /api/bugs (wrong endpoint)', () => {
|
||||||
|
cy.intercept('GET', '/api/bugs').as('wrongBugs')
|
||||||
|
visitAsAdmin('/bugs')
|
||||||
|
cy.wait(2000)
|
||||||
|
cy.get('@wrongBugs.all').should('have.length', 0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('user management page loads from /api/users', () => {
|
||||||
|
cy.intercept('GET', '/api/users').as('users')
|
||||||
|
visitAsAdmin('/users')
|
||||||
|
cy.wait('@users').its('response.statusCode').should('eq', 200)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('categories load from /api/categories', () => {
|
||||||
|
cy.intercept('GET', '/api/categories').as('cats')
|
||||||
|
visitAsAdmin('/')
|
||||||
|
cy.wait(3000)
|
||||||
|
// Categories may or may not be fetched depending on page logic
|
||||||
|
// Just verify no /api/category-modules calls
|
||||||
|
cy.intercept('GET', '/api/category-modules').as('wrongCats')
|
||||||
|
cy.get('@wrongCats.all').should('have.length', 0)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,32 +1,32 @@
|
|||||||
describe('Oil Reference Page', () => {
|
describe('Oil Reference Page', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
cy.visit('/oils')
|
cy.visit('/oils')
|
||||||
cy.get('.oil-card, .oils-grid', { timeout: 10000 }).should('exist')
|
cy.get('.oil-chip, .oils-grid', { timeout: 10000 }).should('exist')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('displays oil grid with items', () => {
|
it('displays oil grid with items', () => {
|
||||||
cy.contains('精油价目').should('be.visible')
|
cy.contains('精油价目').should('be.visible')
|
||||||
cy.get('.oil-card').should('have.length.gte', 10)
|
cy.get('.oil-chip').should('have.length.gte', 10)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('shows oil name and price on each chip', () => {
|
it('shows oil name and price on each chip', () => {
|
||||||
cy.get('.oil-card').first().should('contain', '¥')
|
cy.get('.oil-chip').first().should('contain', '¥')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('filters oils by search', () => {
|
it('filters oils by search', () => {
|
||||||
cy.get('.oil-card').then($chips => {
|
cy.get('.oil-chip').then($chips => {
|
||||||
const initial = $chips.length
|
const initial = $chips.length
|
||||||
cy.get('input[placeholder*="搜索精油"]').type('薰衣草')
|
cy.get('input[placeholder*="搜索精油"]').type('薰衣草')
|
||||||
cy.wait(300)
|
cy.wait(300)
|
||||||
cy.get('.oil-card').should('have.length.lt', initial)
|
cy.get('.oil-chip').should('have.length.lt', initial)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
it('toggles between bottle and drop price view', () => {
|
it('toggles between bottle and drop price view', () => {
|
||||||
cy.get('.oil-card').first().invoke('text').then(textBefore => {
|
cy.get('.oil-chip').first().invoke('text').then(textBefore => {
|
||||||
cy.contains('滴价').click()
|
cy.contains('滴价').click()
|
||||||
cy.wait(300)
|
cy.wait(300)
|
||||||
cy.get('.oil-card').first().invoke('text').should('not.eq', textBefore)
|
cy.get('.oil-chip').first().invoke('text').should('not.eq', textBefore)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ describe('Performance', () => {
|
|||||||
it('oil reference page loads within 3 seconds', () => {
|
it('oil reference page loads within 3 seconds', () => {
|
||||||
const start = Date.now()
|
const start = Date.now()
|
||||||
cy.visit('/oils')
|
cy.visit('/oils')
|
||||||
cy.get('.oil-card', { timeout: 3000 }).should('have.length.gte', 1)
|
cy.get('.oil-chip', { timeout: 3000 }).should('have.length.gte', 1)
|
||||||
cy.then(() => {
|
cy.then(() => {
|
||||||
expect(Date.now() - start).to.be.lt(3000)
|
expect(Date.now() - start).to.be.lt(3000)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ describe('Price Display Regression', () => {
|
|||||||
cy.get('.recipe-card', { timeout: 10000 }).should('have.length.gte', 1)
|
cy.get('.recipe-card', { timeout: 10000 }).should('have.length.gte', 1)
|
||||||
cy.wait(2000) // wait for oils store to load and re-render
|
cy.wait(2000) // wait for oils store to load and re-render
|
||||||
|
|
||||||
// Check via .card-price elements which hold the formatted cost
|
// Check via .recipe-card-price elements which hold the formatted cost
|
||||||
cy.get('.card-price').first().invoke('text').then(text => {
|
cy.get('.recipe-card-price').first().invoke('text').then(text => {
|
||||||
const match = text.match(/¥\s*(\d+\.?\d*)/)
|
const match = text.match(/¥\s*(\d+\.?\d*)/)
|
||||||
expect(match, 'Card price should contain ¥').to.not.be.null
|
expect(match, 'Card price should contain ¥').to.not.be.null
|
||||||
expect(parseFloat(match[1]), 'Price should be > 0').to.be.gt(0)
|
expect(parseFloat(match[1]), 'Price should be > 0').to.be.gt(0)
|
||||||
|
|||||||
@@ -4,18 +4,16 @@ describe('Recipe Detail', () => {
|
|||||||
cy.get('.recipe-card', { timeout: 10000 }).should('have.length.gte', 1)
|
cy.get('.recipe-card', { timeout: 10000 }).should('have.length.gte', 1)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('opens detail overlay when clicking a recipe card', () => {
|
it('opens detail panel when clicking a recipe card', () => {
|
||||||
cy.get('.recipe-card').first().click()
|
cy.get('.recipe-card').first().click()
|
||||||
cy.get('[class*="overlay"], [class*="detail"]').should('be.visible')
|
cy.get('[class*="detail"]').should('be.visible')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('shows recipe name in detail view', () => {
|
it('shows recipe name in detail view', () => {
|
||||||
// Get recipe name from card, however it's structured
|
|
||||||
cy.get('.recipe-card').first().invoke('text').then(cardText => {
|
cy.get('.recipe-card').first().invoke('text').then(cardText => {
|
||||||
cy.get('.recipe-card').first().click()
|
cy.get('.recipe-card').first().click()
|
||||||
cy.wait(500)
|
cy.wait(500)
|
||||||
// The detail view should show some text from the card
|
cy.get('[class*="detail"]').should('be.visible')
|
||||||
cy.get('[class*="overlay"], [class*="detail"]').should('be.visible')
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -31,24 +29,21 @@ describe('Recipe Detail', () => {
|
|||||||
cy.contains('¥').should('exist')
|
cy.contains('¥').should('exist')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('closes detail overlay when clicking close button', () => {
|
it('closes detail panel when clicking close button', () => {
|
||||||
cy.get('.recipe-card').first().click()
|
cy.get('.recipe-card').first().click()
|
||||||
cy.get('[class*="overlay"], [class*="detail"]').should('be.visible')
|
cy.get('[class*="detail"]').should('be.visible')
|
||||||
cy.get('button').contains(/✕|关闭|←/).first().click()
|
cy.get('button').contains(/✕|关闭/).first().click()
|
||||||
cy.get('.recipe-card').should('be.visible')
|
cy.get('.recipe-card').should('be.visible')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('shows action buttons in detail', () => {
|
it('shows action buttons in detail', () => {
|
||||||
cy.get('.recipe-card').first().click()
|
cy.get('.recipe-card').first().click()
|
||||||
cy.wait(500)
|
cy.wait(500)
|
||||||
// Should have at least one action button
|
cy.get('[class*="detail"] button').should('have.length.gte', 1)
|
||||||
cy.get('[class*="overlay"] button, [class*="detail"] button').should('have.length.gte', 1)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('shows favorite star', () => {
|
it('shows favorite star on recipe cards', () => {
|
||||||
cy.get('.recipe-card').first().click()
|
cy.get('.fav-btn').first().should('exist')
|
||||||
cy.wait(500)
|
|
||||||
cy.contains(/★|☆|收藏/).should('exist')
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -64,21 +59,23 @@ describe('Recipe Detail - Editor (Admin)', () => {
|
|||||||
cy.get('.recipe-card', { timeout: 10000 }).should('have.length.gte', 1)
|
cy.get('.recipe-card', { timeout: 10000 }).should('have.length.gte', 1)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('shows edit button for admin', () => {
|
it('shows editable ingredients table in editor tab', () => {
|
||||||
cy.get('.recipe-card').first().click()
|
cy.get('.recipe-card').first().click()
|
||||||
cy.wait(500)
|
cy.wait(500)
|
||||||
cy.contains(/编辑|✏/).should('exist')
|
cy.contains('编辑').click()
|
||||||
|
cy.get('.editor-select, .editor-drops').should('exist')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('can switch to editor view', () => {
|
it('shows add ingredient button in editor tab', () => {
|
||||||
cy.get('.recipe-card').first().click()
|
cy.get('.recipe-card').first().click()
|
||||||
cy.contains(/编辑|✏/).first().click()
|
cy.wait(500)
|
||||||
cy.get('select, input[type="number"], .oil-select, .drops-input').should('exist')
|
cy.contains('编辑').click()
|
||||||
|
cy.contains('添加精油').should('exist')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('editor shows save button', () => {
|
it('shows export image button', () => {
|
||||||
cy.get('.recipe-card').first().click()
|
cy.get('.recipe-card').first().click()
|
||||||
cy.contains(/编辑|✏/).first().click()
|
cy.wait(500)
|
||||||
cy.contains(/保存|💾/).should('exist')
|
cy.contains('导出图片').should('exist')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ describe('Responsive Design', () => {
|
|||||||
it('oil reference page works on mobile', () => {
|
it('oil reference page works on mobile', () => {
|
||||||
cy.visit('/oils')
|
cy.visit('/oils')
|
||||||
cy.contains('精油价目').should('be.visible')
|
cy.contains('精油价目').should('be.visible')
|
||||||
cy.get('.oil-card').should('have.length.gte', 1)
|
cy.get('.oil-chip').should('have.length.gte', 1)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -51,7 +51,7 @@ describe('Responsive Design', () => {
|
|||||||
|
|
||||||
it('oil grid shows multiple columns', () => {
|
it('oil grid shows multiple columns', () => {
|
||||||
cy.visit('/oils')
|
cy.visit('/oils')
|
||||||
cy.get('.oil-card', { timeout: 10000 }).should('have.length.gte', 1)
|
cy.get('.oil-chip', { timeout: 10000 }).should('have.length.gte', 1)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ describe('Visual Check - Screenshots', () => {
|
|||||||
|
|
||||||
it('oil reference page', () => {
|
it('oil reference page', () => {
|
||||||
cy.visit('/oils', { onBeforeLoad(win) { win.localStorage.setItem('oil_auth_token', ADMIN_TOKEN) } })
|
cy.visit('/oils', { onBeforeLoad(win) { win.localStorage.setItem('oil_auth_token', ADMIN_TOKEN) } })
|
||||||
cy.get('.oil-card', { timeout: 10000 }).should('have.length.gte', 1)
|
cy.get('.oil-chip', { timeout: 10000 }).should('have.length.gte', 1)
|
||||||
cy.wait(500)
|
cy.wait(500)
|
||||||
cy.screenshot('03-oil-reference')
|
cy.screenshot('03-oil-reference')
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
// Ignore uncaught exceptions from the app (API errors during loading, etc.)
|
// Ignore uncaught exceptions from the Vue app during E2E tests.
|
||||||
|
// Vue components may throw on API errors, missing data, etc.
|
||||||
|
// These are tracked separately; E2E tests focus on user-visible behavior.
|
||||||
Cypress.on('uncaught:exception', () => false)
|
Cypress.on('uncaught:exception', () => false)
|
||||||
|
|
||||||
// Custom commands for the oil calculator app
|
// Custom commands for the oil calculator app
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
|
<div v-if="isPreview" style="background:#e65100;color:white;text-align:center;padding:6px 16px;font-size:13px;font-weight:600;letter-spacing:0.5px;position:sticky;top:0;z-index:100">
|
||||||
|
⚠️ 预览环境 · PR #{{ prId }} · 数据为生产副本,修改不影响正式环境
|
||||||
|
</div>
|
||||||
<div class="app-header" style="position:relative">
|
<div class="app-header" style="position:relative">
|
||||||
<div class="header-inner" style="padding-right:80px">
|
<div class="header-inner" style="padding-right:80px">
|
||||||
<div class="header-icon">🌿</div>
|
<div class="header-icon">🌿</div>
|
||||||
@@ -65,8 +68,8 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, onMounted } from 'vue'
|
import { ref, onMounted, watch } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter, useRoute } from 'vue-router'
|
||||||
import { useAuthStore } from './stores/auth'
|
import { useAuthStore } from './stores/auth'
|
||||||
import { useOilsStore } from './stores/oils'
|
import { useOilsStore } from './stores/oils'
|
||||||
import { useRecipesStore } from './stores/recipes'
|
import { useRecipesStore } from './stores/recipes'
|
||||||
@@ -80,8 +83,22 @@ const oils = useOilsStore()
|
|||||||
const recipeStore = useRecipesStore()
|
const recipeStore = useRecipesStore()
|
||||||
const ui = useUiStore()
|
const ui = useUiStore()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
const route = useRoute()
|
||||||
const showUserMenu = ref(false)
|
const showUserMenu = ref(false)
|
||||||
|
|
||||||
|
// Sync ui.currentSection from route on load and navigation
|
||||||
|
const routeToSection = { '/': 'search', '/manage': 'manage', '/inventory': 'inventory', '/oils': 'oils', '/projects': 'projects', '/mydiary': 'mydiary', '/audit': 'audit', '/bugs': 'bugs', '/users': 'users' }
|
||||||
|
watch(() => route.path, (path) => {
|
||||||
|
const section = routeToSection[path] || 'search'
|
||||||
|
ui.showSection(section)
|
||||||
|
}, { immediate: true })
|
||||||
|
|
||||||
|
// Preview environment detection: pr-{id}.oil.oci.euphon.net
|
||||||
|
const hostname = window.location.hostname
|
||||||
|
const prMatch = hostname.match(/^pr-(\d+)\./)
|
||||||
|
const isPreview = !!prMatch
|
||||||
|
const prId = prMatch ? prMatch[1] : ''
|
||||||
|
|
||||||
function goSection(name) {
|
function goSection(name) {
|
||||||
ui.showSection(name)
|
ui.showSection(name)
|
||||||
router.push('/' + (name === 'search' ? '' : name))
|
router.push('/' + (name === 'search' ? '' : name))
|
||||||
|
|||||||
@@ -29,6 +29,14 @@
|
|||||||
class="login-input"
|
class="login-input"
|
||||||
@keydown.enter="submit"
|
@keydown.enter="submit"
|
||||||
/>
|
/>
|
||||||
|
<input
|
||||||
|
v-if="mode === 'register'"
|
||||||
|
v-model="confirmPassword"
|
||||||
|
type="password"
|
||||||
|
placeholder="确认密码"
|
||||||
|
class="login-input"
|
||||||
|
@keydown.enter="submit"
|
||||||
|
/>
|
||||||
<input
|
<input
|
||||||
v-if="mode === 'register'"
|
v-if="mode === 'register'"
|
||||||
v-model="displayName"
|
v-model="displayName"
|
||||||
@@ -61,6 +69,7 @@ const ui = useUiStore()
|
|||||||
const mode = ref('login')
|
const mode = ref('login')
|
||||||
const username = ref('')
|
const username = ref('')
|
||||||
const password = ref('')
|
const password = ref('')
|
||||||
|
const confirmPassword = ref('')
|
||||||
const displayName = ref('')
|
const displayName = ref('')
|
||||||
const errorMsg = ref('')
|
const errorMsg = ref('')
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
@@ -76,6 +85,10 @@ async function submit() {
|
|||||||
errorMsg.value = '请输入密码'
|
errorMsg.value = '请输入密码'
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if (mode.value === 'register' && password.value !== confirmPassword.value) {
|
||||||
|
errorMsg.value = '两次输入的密码不一致'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -1,30 +1,18 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="recipe-card" @click="$emit('click', index)">
|
<div class="recipe-card" @click="$emit('click', index)">
|
||||||
<div class="card-name">{{ recipe.name }}</div>
|
<div class="recipe-card-name">{{ recipe.name }}</div>
|
||||||
|
<div v-if="recipe.tags && recipe.tags.length" class="recipe-card-tags">
|
||||||
<div v-if="recipe.tags && recipe.tags.length" class="card-tags">
|
<span v-for="tag in recipe.tags" :key="tag" class="tag">{{ tag }}</span>
|
||||||
<span v-for="tag in recipe.tags" :key="tag" class="card-tag">{{ tag }}</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div class="recipe-card-oils">{{ oilNames }}</div>
|
||||||
<div class="card-oils">
|
<div class="recipe-card-bottom">
|
||||||
<span v-for="(ing, i) in recipe.ingredients" :key="i" class="card-oil">
|
<div class="recipe-card-price">💰 {{ priceInfo.cost }}</div>
|
||||||
{{ ing.oil }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card-bottom">
|
|
||||||
<span class="card-price">
|
|
||||||
{{ priceInfo.cost }}
|
|
||||||
<span v-if="priceInfo.hasRetail" class="card-retail">零售 {{ priceInfo.retail }}</span>
|
|
||||||
</span>
|
|
||||||
<button
|
<button
|
||||||
class="card-star"
|
class="fav-btn"
|
||||||
:class="{ favorited: isFav }"
|
:class="{ favorited: isFav }"
|
||||||
@click.stop="$emit('toggle-fav', recipe._id)"
|
@click.stop="$emit('toggle-fav', recipe._id)"
|
||||||
:title="isFav ? '取消收藏' : '收藏'"
|
:title="isFav ? '取消收藏' : '收藏'"
|
||||||
>
|
>{{ isFav ? '★' : '☆' }}</button>
|
||||||
{{ isFav ? '★' : '☆' }}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -44,101 +32,88 @@ defineEmits(['click', 'toggle-fav'])
|
|||||||
const oilsStore = useOilsStore()
|
const oilsStore = useOilsStore()
|
||||||
const recipesStore = useRecipesStore()
|
const recipesStore = useRecipesStore()
|
||||||
|
|
||||||
|
const oilNames = computed(() =>
|
||||||
|
props.recipe.ingredients.map(i => i.oil).join('、')
|
||||||
|
)
|
||||||
const priceInfo = computed(() => oilsStore.fmtCostWithRetail(props.recipe.ingredients))
|
const priceInfo = computed(() => oilsStore.fmtCostWithRetail(props.recipe.ingredients))
|
||||||
const isFav = computed(() => recipesStore.isFavorite(props.recipe))
|
const isFav = computed(() => recipesStore.isFavorite(props.recipe))
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.recipe-card {
|
.recipe-card {
|
||||||
background: #fff;
|
background: white;
|
||||||
border-radius: 14px;
|
border-radius: 14px;
|
||||||
padding: 18px 16px 14px;
|
padding: 18px;
|
||||||
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.06);
|
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: box-shadow 0.2s, transform 0.15s;
|
box-shadow: 0 4px 20px rgba(90, 60, 30, 0.08);
|
||||||
display: flex;
|
border: 2px solid transparent;
|
||||||
flex-direction: column;
|
transition: all 0.2s;
|
||||||
gap: 8px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.recipe-card:hover {
|
.recipe-card:hover {
|
||||||
box-shadow: 0 4px 18px rgba(0, 0, 0, 0.1);
|
transform: translateY(-3px);
|
||||||
transform: translateY(-1px);
|
box-shadow: 0 8px 32px rgba(90, 60, 30, 0.15);
|
||||||
|
border-color: #c8ddc9;
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-name {
|
.recipe-card-name {
|
||||||
font-family: 'Noto Serif SC', serif;
|
font-family: 'Noto Serif SC', serif;
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #3e3a44;
|
color: #2c2416;
|
||||||
line-height: 1.3;
|
margin-bottom: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-tags {
|
.recipe-card-tags {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: 5px;
|
gap: 5px;
|
||||||
|
margin-bottom: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-tag {
|
.tag {
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
padding: 2px 8px;
|
padding: 2px 8px;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
background: #f0ece4;
|
background: #eef4ee;
|
||||||
color: #8a7e6b;
|
color: #5a7d5e;
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-oils {
|
.recipe-card-oils {
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-oil {
|
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: #6b6375;
|
color: #9a8570;
|
||||||
background: #f8f7f5;
|
line-height: 1.7;
|
||||||
padding: 2px 7px;
|
|
||||||
border-radius: 6px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-bottom {
|
.recipe-card-bottom {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
margin-top: auto;
|
align-items: center;
|
||||||
padding-top: 6px;
|
margin-top: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-price {
|
.recipe-card-price {
|
||||||
font-size: 14px;
|
font-size: 13px;
|
||||||
|
color: #5a7d5e;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #4a9d7e;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-retail {
|
.fav-btn {
|
||||||
font-size: 11px;
|
|
||||||
font-weight: 400;
|
|
||||||
color: #999;
|
|
||||||
margin-left: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-star {
|
|
||||||
background: none;
|
background: none;
|
||||||
border: none;
|
border: none;
|
||||||
font-size: 20px;
|
font-size: 20px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
color: #ccc;
|
color: #d4cfc7;
|
||||||
padding: 2px 4px;
|
padding: 2px 4px;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
transition: color 0.2s;
|
transition: color 0.2s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-star.favorited {
|
.fav-btn.favorited {
|
||||||
color: #f5a623;
|
color: #f5a623;
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-star:hover {
|
.fav-btn:hover {
|
||||||
color: #f5a623;
|
color: #f5a623;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -10,23 +10,53 @@
|
|||||||
<button class="usermenu-btn" @click="goMyDiary">
|
<button class="usermenu-btn" @click="goMyDiary">
|
||||||
📖 我的
|
📖 我的
|
||||||
</button>
|
</button>
|
||||||
<button class="usermenu-btn" @click="goNotifications">
|
<button class="usermenu-btn" @click="toggleNotifications">
|
||||||
🔔 通知
|
🔔 通知
|
||||||
<span v-if="unreadCount > 0" class="unread-badge">{{ unreadCount }}</span>
|
<span v-if="unreadCount > 0" class="unread-badge">{{ unreadCount }}</span>
|
||||||
</button>
|
</button>
|
||||||
|
<button class="usermenu-btn" @click="showBugReport">
|
||||||
|
🐛 反馈问题
|
||||||
|
</button>
|
||||||
<button class="usermenu-btn usermenu-btn-logout" @click="handleLogout">
|
<button class="usermenu-btn usermenu-btn-logout" @click="handleLogout">
|
||||||
🚪 退出登录
|
🚪 退出登录
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Inline Notification Panel -->
|
||||||
|
<div v-if="showNotifPanel" class="notif-panel">
|
||||||
|
<div class="notif-header">
|
||||||
|
<span>通知 ({{ notifications.length }})</span>
|
||||||
|
<button v-if="unreadCount > 0" class="notif-mark-all" @click="markAllRead">全部已读</button>
|
||||||
|
</div>
|
||||||
|
<div class="notif-list">
|
||||||
|
<div v-for="n in notifications.slice(0, 20)" :key="n.id"
|
||||||
|
class="notif-item" :class="{ unread: !n.is_read }">
|
||||||
|
<div class="notif-title">{{ n.title }}</div>
|
||||||
|
<div v-if="n.body" class="notif-body">{{ n.body }}</div>
|
||||||
|
<div class="notif-time">{{ formatTime(n.created_at) }}</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="notifications.length === 0" class="notif-empty">暂无通知</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Bug Report Modal -->
|
||||||
|
<div v-if="showBugForm" class="bug-form">
|
||||||
|
<textarea v-model="bugContent" class="bug-textarea" rows="3" placeholder="描述你遇到的问题..."></textarea>
|
||||||
|
<div class="bug-form-actions">
|
||||||
|
<button class="btn-sm btn-outline" @click="showBugForm = false">取消</button>
|
||||||
|
<button class="btn-sm btn-primary" @click="submitBug" :disabled="!bugContent.trim()">提交</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed } from 'vue'
|
import { ref, computed, onMounted } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { useAuthStore } from '../stores/auth'
|
import { useAuthStore } from '../stores/auth'
|
||||||
import { useUiStore } from '../stores/ui'
|
import { useUiStore } from '../stores/ui'
|
||||||
|
import { api } from '../composables/useApi'
|
||||||
|
|
||||||
const emit = defineEmits(['close'])
|
const emit = defineEmits(['close'])
|
||||||
|
|
||||||
@@ -34,7 +64,12 @@ const auth = useAuthStore()
|
|||||||
const ui = useUiStore()
|
const ui = useUiStore()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|
||||||
const unreadCount = ref(0)
|
const notifications = ref([])
|
||||||
|
const showNotifPanel = ref(false)
|
||||||
|
const showBugForm = ref(false)
|
||||||
|
const bugContent = ref('')
|
||||||
|
|
||||||
|
const unreadCount = computed(() => notifications.value.filter(n => !n.is_read).length)
|
||||||
|
|
||||||
const roleLabel = computed(() => {
|
const roleLabel = computed(() => {
|
||||||
const map = {
|
const map = {
|
||||||
@@ -46,14 +81,55 @@ const roleLabel = computed(() => {
|
|||||||
return map[auth.user.role] || auth.user.role
|
return map[auth.user.role] || auth.user.role
|
||||||
})
|
})
|
||||||
|
|
||||||
|
function formatTime(d) {
|
||||||
|
if (!d) return ''
|
||||||
|
return new Date(d + 'Z').toLocaleString('zh-CN', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })
|
||||||
|
}
|
||||||
|
|
||||||
function goMyDiary() {
|
function goMyDiary() {
|
||||||
emit('close')
|
emit('close')
|
||||||
router.push('/mydiary')
|
router.push('/mydiary')
|
||||||
}
|
}
|
||||||
|
|
||||||
function goNotifications() {
|
function toggleNotifications() {
|
||||||
emit('close')
|
showNotifPanel.value = !showNotifPanel.value
|
||||||
router.push('/notifications')
|
showBugForm.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
function showBugReport() {
|
||||||
|
showBugForm.value = !showBugForm.value
|
||||||
|
showNotifPanel.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitBug() {
|
||||||
|
if (!bugContent.value.trim()) return
|
||||||
|
try {
|
||||||
|
const res = await api('/api/bug-report', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ content: bugContent.value.trim(), priority: 0 }),
|
||||||
|
})
|
||||||
|
if (res.ok) {
|
||||||
|
bugContent.value = ''
|
||||||
|
showBugForm.value = false
|
||||||
|
ui.showToast('反馈已提交')
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
ui.showToast('提交失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function markAllRead() {
|
||||||
|
try {
|
||||||
|
await api('/api/notifications/read-all', { method: 'POST', body: '{}' })
|
||||||
|
notifications.value.forEach(n => n.is_read = 1)
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadNotifications() {
|
||||||
|
try {
|
||||||
|
const res = await api('/api/notifications')
|
||||||
|
if (res.ok) notifications.value = await res.json()
|
||||||
|
} catch {}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleLogout() {
|
function handleLogout() {
|
||||||
@@ -62,6 +138,8 @@ function handleLogout() {
|
|||||||
emit('close')
|
emit('close')
|
||||||
window.location.reload()
|
window.location.reload()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onMounted(loadNotifications)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@@ -79,78 +157,71 @@ function handleLogout() {
|
|||||||
border-radius: 14px;
|
border-radius: 14px;
|
||||||
box-shadow: 0 6px 24px rgba(0, 0, 0, 0.15);
|
box-shadow: 0 6px 24px rgba(0, 0, 0, 0.15);
|
||||||
padding: 18px 20px 14px;
|
padding: 18px 20px 14px;
|
||||||
min-width: 180px;
|
min-width: 200px;
|
||||||
|
max-width: 340px;
|
||||||
z-index: 4001;
|
z-index: 4001;
|
||||||
}
|
}
|
||||||
|
|
||||||
.usermenu-name {
|
.usermenu-name { font-size: 16px; font-weight: 600; color: #3e3a44; margin-bottom: 4px; }
|
||||||
font-size: 16px;
|
.usermenu-role { margin-bottom: 14px; }
|
||||||
font-weight: 600;
|
|
||||||
color: #3e3a44;
|
|
||||||
margin-bottom: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.usermenu-role {
|
|
||||||
margin-bottom: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.role-badge {
|
.role-badge {
|
||||||
display: inline-block;
|
display: inline-block; font-size: 11px; padding: 2px 10px;
|
||||||
font-size: 11px;
|
border-radius: 8px; background: linear-gradient(135deg, #e8f5e9, #c8e6c9);
|
||||||
padding: 2px 10px;
|
color: #4a9d7e; font-weight: 500;
|
||||||
border-radius: 8px;
|
|
||||||
background: linear-gradient(135deg, #e8f5e9, #c8e6c9);
|
|
||||||
color: #4a9d7e;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
.usermenu-actions {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 4px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.usermenu-actions { display: flex; flex-direction: column; gap: 4px; }
|
||||||
.usermenu-btn {
|
.usermenu-btn {
|
||||||
display: flex;
|
display: flex; align-items: center; gap: 6px; width: 100%;
|
||||||
align-items: center;
|
padding: 9px 10px; border: none; background: none; border-radius: 8px;
|
||||||
gap: 6px;
|
font-size: 14px; color: #3e3a44; cursor: pointer; font-family: inherit;
|
||||||
width: 100%;
|
text-align: left; transition: background 0.15s; position: relative;
|
||||||
padding: 9px 10px;
|
|
||||||
border: none;
|
|
||||||
background: none;
|
|
||||||
border-radius: 8px;
|
|
||||||
font-size: 14px;
|
|
||||||
color: #3e3a44;
|
|
||||||
cursor: pointer;
|
|
||||||
font-family: inherit;
|
|
||||||
text-align: left;
|
|
||||||
transition: background 0.15s;
|
|
||||||
position: relative;
|
|
||||||
}
|
}
|
||||||
|
.usermenu-btn:hover { background: #f5f3f0; }
|
||||||
.usermenu-btn:hover {
|
|
||||||
background: #f5f3f0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.usermenu-btn-logout {
|
.usermenu-btn-logout {
|
||||||
color: #d9534f;
|
color: #d9534f; margin-top: 6px; border-top: 1px solid #eee;
|
||||||
margin-top: 6px;
|
padding-top: 12px; border-radius: 0 0 8px 8px;
|
||||||
border-top: 1px solid #eee;
|
|
||||||
padding-top: 12px;
|
|
||||||
border-radius: 0 0 8px 8px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.unread-badge {
|
.unread-badge {
|
||||||
background: #d9534f;
|
background: #d9534f; color: #fff; font-size: 11px; font-weight: 600;
|
||||||
color: #fff;
|
min-width: 18px; height: 18px; line-height: 18px; text-align: center;
|
||||||
font-size: 11px;
|
border-radius: 9px; padding: 0 5px; margin-left: auto;
|
||||||
font-weight: 600;
|
|
||||||
min-width: 18px;
|
|
||||||
height: 18px;
|
|
||||||
line-height: 18px;
|
|
||||||
text-align: center;
|
|
||||||
border-radius: 9px;
|
|
||||||
padding: 0 5px;
|
|
||||||
margin-left: auto;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Notification panel */
|
||||||
|
.notif-panel {
|
||||||
|
margin-top: 12px; border-top: 1px solid #eee; padding-top: 10px;
|
||||||
|
}
|
||||||
|
.notif-header {
|
||||||
|
display: flex; justify-content: space-between; align-items: center;
|
||||||
|
font-size: 13px; font-weight: 600; color: #666; margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
.notif-mark-all {
|
||||||
|
background: none; border: none; color: var(--sage, #7a9e7e);
|
||||||
|
cursor: pointer; font-size: 12px; font-family: inherit;
|
||||||
|
}
|
||||||
|
.notif-list { max-height: 250px; overflow-y: auto; }
|
||||||
|
.notif-item {
|
||||||
|
padding: 8px 0; border-bottom: 1px solid #f5f5f5; font-size: 13px;
|
||||||
|
}
|
||||||
|
.notif-item.unread { background: #fafafa; }
|
||||||
|
.notif-title { font-weight: 500; color: #333; }
|
||||||
|
.notif-body { color: #888; font-size: 12px; margin-top: 2px; white-space: pre-line; }
|
||||||
|
.notif-time { color: #bbb; font-size: 11px; margin-top: 2px; }
|
||||||
|
.notif-empty { text-align: center; color: #ccc; padding: 16px; font-size: 13px; }
|
||||||
|
|
||||||
|
/* Bug report form */
|
||||||
|
.bug-form { margin-top: 12px; border-top: 1px solid #eee; padding-top: 10px; }
|
||||||
|
.bug-textarea {
|
||||||
|
width: 100%; padding: 8px 10px; border: 1.5px solid #d4cfc7;
|
||||||
|
border-radius: 8px; font-size: 13px; font-family: inherit;
|
||||||
|
outline: none; resize: vertical; box-sizing: border-box;
|
||||||
|
}
|
||||||
|
.bug-textarea:focus { border-color: #7a9e7e; }
|
||||||
|
.bug-form-actions { display: flex; gap: 8px; justify-content: flex-end; margin-top: 8px; }
|
||||||
|
.btn-sm { padding: 6px 14px; border-radius: 8px; font-size: 13px; cursor: pointer; font-family: inherit; border: none; }
|
||||||
|
.btn-primary { background: #7a9e7e; color: white; }
|
||||||
|
.btn-outline { background: white; color: #666; border: 1px solid #d4cfc7; }
|
||||||
|
.btn-sm:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -24,7 +24,16 @@ async function request(path, opts = {}) {
|
|||||||
|
|
||||||
async function requestJSON(path, opts = {}) {
|
async function requestJSON(path, opts = {}) {
|
||||||
const res = await request(path, opts)
|
const res = await request(path, opts)
|
||||||
if (!res.ok) throw res
|
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()
|
return res.json()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
41
frontend/src/composables/useOilCards.js
Normal file
41
frontend/src/composables/useOilCards.js
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
// Oil knowledge cards - usage guides for common essential oils
|
||||||
|
// Ported from original vanilla JS implementation
|
||||||
|
|
||||||
|
export const OIL_CARDS = {
|
||||||
|
'野橘': { emoji: '🍊', en: 'Wild Orange', effects: '安抚镇静、驱散负能量、紧张情绪和压力\n抗氧化、消炎、抗病毒、增强免疫\n提振食欲,刺激胆汁分泌,促进消化\n促进循环', usage: '日常香薰,提振情绪、舒压,营造愉悦氛围\n饮水加入 1至2 滴,护肝抗病毒\n泡澡时滴 3至4 滴,消除疲劳,放松身心\n扫地、拖地时加入 3至5 滴,清新空气\n涂抹腹部促进消化\n涂抹肝区帮助肝脏排毒\n口腔溃疡时加入水中漱口', method: '🔹香薰 | 🔸内用 | 🔺涂抹', caution: '轻微光敏,白天涂抹注意防晒' },
|
||||||
|
'冬青': { emoji: '🌿', en: 'Wintergreen', effects: '强效镇痛(肌肉、关节)\n抗炎、促进循环\n舒缓紧绷肌肉,抗痉挛', usage: '牙疼时加 1 滴到水中漱口\n扭伤、落枕、酸痛(如肩颈酸痛)处稀释涂抹\n运动前后按摩', method: '🔹香薰 |🔺涂抹(需 6 倍稀释)', caution: '不可内用、孕期慎用、避免儿童误食' },
|
||||||
|
'生姜': { emoji: '🫚', en: 'Ginger', effects: '促进消化、暖胃\n活血、改善循环、祛湿\n抗炎、抗氧化、强健免疫\n缓解恶心、晕车\n促进骨骼、肌肉和关节的健康', usage: '胀气、腹冷时,稀释涂抹腹部或喝 1 滴\n手脚冰凉时,稀释涂抹脚底或将1滴加入热饮中\n晕车时,吸闻或滴在手心嗅吸\n祛除风寒可将 2 滴加入热水中泡脚\n痛经时,稀释涂抹于小腹并按摩\n做菜时可加入 1 滴帮助增添风味', method: '🔹香薰 | 🔸内用 | 🔺涂抹(需稀释)', caution: '' },
|
||||||
|
'柠檬草': { emoji: '🍃', en: 'Lemongrass', effects: '强效抗菌、抗炎\n驱虫、净化空气\n扩张血管,促进循环,缓解肌肉疼痛', usage: '筋膜紧绷、腿麻或肌肉酸痛时稀释涂抹\n肩周炎时,6 倍稀释后涂抹于肩颈部位并按摩\n做菜时加入 1 滴,增加泰式风味\n加入椰子油中制成家居喷雾,涂抹在裸露肌肤上驱蚊虫\n洗衣时加 3至5 滴祛味杀菌\n日常香薰平衡情绪', method: '🔹香薰 | 🔸内用 | 🔺涂抹(需 6 倍稀释)', caution: '' },
|
||||||
|
'柑橘清新': { emoji: '🍬', en: 'Citrus Bliss', effects: '提振精神,改善负面情绪\n净化空间\n降低压力', usage: '日常香薰提升愉悦感,提振精神,净化空间\n拖地时加几滴清新空气\n加入到护手霜中,滋润手部肌肤,享受清新香气', method: '🔹香薰 | 🔺涂抹', caution: '含柑橘类,光敏注意白天涂抹' },
|
||||||
|
'芳香调理': { emoji: '🤲', en: 'AromaTouch', effects: '放松紧绷肌肉,放松关节\n促进血液循环\n促进淋巴排毒\n提升免疫\n舒缓放松,减少紧张', usage: '稀释涂抹于太阳穴,缓解头痛,改善紧张情绪\n稀释涂抹于僵硬的身体部位如肩颈处并按摩,促进肌肉放松\n日常香薰或加入热水中泡澡,释放压力', method: '🔹香薰 | 🔺涂抹', caution: '' },
|
||||||
|
'西洋蓍草': { emoji: '🔵', en: 'Yarrow | Pom', effects: '改善肌肤老化症状\n美白肌肤,改善瑕疵\n呵护敏感肌肤,对抗炎症\n提升整体免疫', usage: '早晚护肤时,涂抹3至4滴于面部,改善皱纹和细纹,美白肌肤\n每天早晚舌下含服1滴,促进细胞健康,提升免疫', method: '🔸内用 | 🔺涂抹', caution: '' },
|
||||||
|
'新瑞活力': { emoji: '🌿', en: 'MetaPWR', effects: '促进新陈代谢,减肥\n抑制食欲,减少对甜食的渴望\n稳定血糖波动\n提振情绪,激励身心', usage: '饭前喝1至2滴,控制食欲,稳定血糖,提升代谢\n日常香薰可以帮助恢复能量,消除疲乏感\n稀释涂抹与身体需紧致的部位,帮助紧致塑形\n加入饮品中,帮助增添风味', method: '🔹香薰 | 🔸内用 | 🔺涂抹(需稀释)', caution: '' },
|
||||||
|
'安定情绪': { emoji: '🌳', en: 'Balance', effects: '促进全身的放松\n减轻焦虑,缓解紧张情绪\n带来宁静和安定感', usage: '日常香薰稳定情绪,放松\n夜间香薰促进睡眠\n涂抹脚底或脊椎放松情绪,放松肌肉\n冥想、瑜伽前涂抹', method: '🔹香薰 | 🔺涂抹', caution: '' },
|
||||||
|
'安宁神气': { emoji: '😴', en: 'Serenity', effects: '促进深度睡眠\n放松身体,缓解焦虑\n平衡情绪\n平衡自律神经系统', usage: '夜间香薰或稀释涂抹脚底促进深度睡眠,释放压力\n稀释涂抹太阳穴或脚底舒缓压力\n吸闻缓解焦虑和紧张情绪', method: '🔹香薰 | 🔺涂抹', caution: '' },
|
||||||
|
'元气': { emoji: '🔥', en: 'Zendocrine', effects: '帮助身体净化,排毒\n维持肝脏和肾脏健康\n平衡情绪', usage: '饭前内用1至2滴帮助代谢\n稀释涂抹肝区或内服3滴帮助养护肝脏\n稀释涂抹后腰脊椎出帮助养护肾脏,排除毒素\n日常香薰消除压力', method: '🔹香薰 | 🔸内用 | 🔺涂抹', caution: '' },
|
||||||
|
'温柔呵护': { emoji: '🌸', en: 'Soft Talk', effects: '平衡荷尔蒙\n抚平情绪波动\n调理经期不适\n舒缓压力\n提升女性魅力', usage: '稀释涂抹下腹部帮助平衡荷尔蒙,或进行经期调理\n手心嗅吸帮助舒缓压力,平衡情绪\n2滴直接涂抹于脖颈后侧或手腕动脉处,提升女性魅力', method: '🔹香薰 | 🔺涂抹', caution: '' },
|
||||||
|
'柠檬': { emoji: '🍋', en: 'Lemon', effects: '清洁身体与环境\n强健免疫系统\n帮助肝脏代谢、排毒\n抗氧化\n净化空气、去异味\n蔬果清洗、保鲜\n促进循环、提振精神', usage: '添加至护肤品中晚上使用\n添加至牙膏里美白牙齿\n滴入口中或水里喝下,一天三次,每次3至5滴,净化身体\n洗水果和蔬菜时添加 1至2 滴浸泡\n嗓子疼或感冒初期时,含服柠檬1至2滴\n日常香薰提振情绪,护肝', method: '🔹香薰 | 🔸内用 | 🔺涂抹(夜间)', caution: '光敏性,白天避免涂抹' },
|
||||||
|
'薰衣草': { emoji: '💜', en: 'Lavender', effects: '镇静安神、改善睡眠、缓解头痛\n舒缓压力、平衡情绪、抗抑郁\n烧烫伤修复、疤痕、痘印\n促进伤口修复、止血\n促进细胞再生,修复结缔组织\n抗炎、抗过敏、止痛\n皮肤舒缓止痒,如蚊虫叮咬', usage: '烧伤、烫伤、割伤及任何伤口处涂抹,止血防疤\n夜间香薰助眠,白天香薰舒缓情绪\n鱼刺卡嗓子时滴入口中\n加入护肤品中平衡油脂、改善痘痘、去疤痕', method: '🔹香薰 | 🔸内用 | 🔺涂抹(儿童/敏感肌需稀释)', caution: '' },
|
||||||
|
'椒样薄荷': { emoji: '🌿', en: 'Peppermint', effects: '促进健康的呼吸系统\n祛痰、抗粘膜发炎、打开呼吸道\n强肝利胆,促进消化\n退热、缓解中暑\n清凉止痒\n提神醒脑、提升专注、缓解头痛', usage: '白天香薰提神醒脑,清新空气\n按摩头部缓解头疼、提神醒脑\n蚊虫叮咬后,涂抹止痒\n混入水中进行漱口,清新口气\n发烧时涂抹额头腋下帮助降温\n打嗝、咳嗽、鼻塞时吸闻\n消化不良时稀释涂抹于腹部或内用 2 滴', method: '🔹香薰 | 🔸内用 | 🔺涂抹(儿童/敏感肌需稀释)', caution: '孕期/高血压慎用,晚上少用' },
|
||||||
|
'茶树': { emoji: '🌱', en: 'Tea Tree', effects: '抗菌、抗病毒、抗真菌\n提升免疫力\n头皮屑护理\n预防化脓\n居家杀菌净化', usage: '各种痤疮处点涂\n加入护肤品中,清洁皮肤\n洗头时加 1 滴到洗头膏,去头皮屑\n洗衣服时加入 3至5 滴,杀菌祛味\n脚气时用茶树泡脚\n感冒时涂抹,杀菌抗病毒', method: '🔹香薰 | 🔸内用 | 🔺涂抹(儿童/敏感肌需稀释)', caution: '' },
|
||||||
|
'西班牙牛至': { emoji: '🔥', en: 'Oregano', effects: '强抗菌、抗病毒、抗顽固性真菌\n成人炎症辅助\n促进消化\n强抗氧化、抗衰老\n免疫力提升', usage: '洗衣服或拖地时加入 3至5 滴,消炎杀菌\n吃坏肚子时灌于胶囊中内用\n灰指甲时稀释涂抹于患处\n流感季节时香薰,杀灭空气中微生物', method: '🔹香薰 | 🔸内用(胶囊) | 🔺涂抹(需高倍稀释)', caution: '' },
|
||||||
|
'保卫': { emoji: '🛡', en: 'On Guard', effects: '强化免疫力\n抗氧化\n天然杀菌、净化空气\n维护口腔健康', usage: '日常香熏净化空气,强化免疫力\n流感季节或换季时香薰\n混入水中漱口,保持口气清新\n日常稀释涂抹于脊椎或脚底,强化免疫力\n感冒时涂抹,抗菌抗病毒', method: '🔹香薰 | 🔸内用 | 🔺涂抹(儿童/敏感肌需稀释)', caution: '含肉桂丁香,不宜频繁涂抹' },
|
||||||
|
'顺畅呼吸': { emoji: '🌬', en: 'Breathe', effects: '帮助缓解鼻炎、感冒等呼吸道不适\n促进呼吸系统健康\n净化空气', usage: '日常香薰,强健呼吸系统,净化空气\n咳嗽、鼻塞时香薰、吸闻、涂抹于鼻翼、喉咙或肺部\n打鼾、哮喘、鼻炎可日常吸闻\n运动前吸闻,扩张呼吸道', method: '🔹香薰 | 🔺涂抹(儿童/敏感肌需稀释)', caution: '' },
|
||||||
|
'乐活': { emoji: '🍃', en: 'DigestZen', effects: '促进消化\n缓解胀气、消化不良、便秘等胃肠不适', usage: '便秘时,稀释涂抹肚脐周围并顺时针揉腹\n喝酒前后各喝2滴,解酒护肝\n晕车时吸闻或稀释涂抹肚脐周围\n拉肚子时逆时针揉腹', method: '🔹熏香 | 🔸内用 | 🔺涂抹(儿童/敏感肌需稀释)', caution: '' },
|
||||||
|
'舒缓': { emoji: '🌿', en: 'Deep Blue', effects: '缓解肌肉酸痛\n抗痉挛,抗炎', usage: '肌肉酸痛、扭伤、挫伤、肩颈紧绷、落枕、关节疼痛时稀释涂抹于患处', method: '🔺涂抹(需稀释)', caution: '' },
|
||||||
|
'乳香': { emoji: '👑', en: 'Frankincense', effects: '促进伤口愈合,促进细胞健康\n抗氧化、抗发炎、抗衰老、淡纹\n活血行气\n疏通血管\n滋养大脑神经', usage: '加入护肤品中,淡斑,抗衰\n稀释后涂抹大眼眶,改善视力\n早晚舌下含服 2 滴,提高血氧含量\n夜间香薰,滋养大脑,安眠\n任何情况下,想不起来用什么就用乳香', method: '🔹香薰 | 🔸内用 | 🔺涂抹', caution: '' },
|
||||||
|
}
|
||||||
|
|
||||||
|
export const OIL_CARD_ALIAS = {
|
||||||
|
'仕女呵护': '温柔呵护',
|
||||||
|
'薄荷呵护': '椒样薄荷',
|
||||||
|
'牛至呵护': '西班牙牛至',
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getOilCard(name) {
|
||||||
|
if (OIL_CARDS[name]) return OIL_CARDS[name]
|
||||||
|
if (OIL_CARD_ALIAS[name] && OIL_CARDS[OIL_CARD_ALIAS[name]]) return OIL_CARDS[OIL_CARD_ALIAS[name]]
|
||||||
|
const base = name.replace(/呵护$/, '')
|
||||||
|
if (base !== name && OIL_CARDS[base]) return OIL_CARDS[base]
|
||||||
|
return null
|
||||||
|
}
|
||||||
@@ -18,6 +18,9 @@ export const useAuthStore = defineStore('auth', () => {
|
|||||||
// Getters
|
// Getters
|
||||||
const isLoggedIn = computed(() => user.value.id !== null)
|
const isLoggedIn = computed(() => user.value.id !== null)
|
||||||
const isAdmin = computed(() => user.value.role === 'admin')
|
const isAdmin = computed(() => user.value.role === 'admin')
|
||||||
|
const canManage = computed(() =>
|
||||||
|
['senior_editor', 'admin'].includes(user.value.role)
|
||||||
|
)
|
||||||
const canEdit = computed(() =>
|
const canEdit = computed(() =>
|
||||||
['editor', 'senior_editor', 'admin'].includes(user.value.role)
|
['editor', 'senior_editor', 'admin'].includes(user.value.role)
|
||||||
)
|
)
|
||||||
@@ -82,7 +85,7 @@ export const useAuthStore = defineStore('auth', () => {
|
|||||||
|
|
||||||
function canEditRecipe(recipe) {
|
function canEditRecipe(recipe) {
|
||||||
if (isAdmin.value || user.value.role === 'senior_editor') return true
|
if (isAdmin.value || user.value.role === 'senior_editor') return true
|
||||||
if (user.value.role === 'editor' && recipe._owner_id === user.value.id) return true
|
if (recipe._owner_id === user.value.id) return true
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -91,6 +94,7 @@ export const useAuthStore = defineStore('auth', () => {
|
|||||||
user,
|
user,
|
||||||
isLoggedIn,
|
isLoggedIn,
|
||||||
isAdmin,
|
isAdmin,
|
||||||
|
canManage,
|
||||||
canEdit,
|
canEdit,
|
||||||
isBusiness,
|
isBusiness,
|
||||||
initToken,
|
initToken,
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ export const useRecipesStore = defineStore('recipes', () => {
|
|||||||
_owner_name: r._owner_name ?? r.owner_name ?? '',
|
_owner_name: r._owner_name ?? r.owner_name ?? '',
|
||||||
_version: r._version ?? r.version ?? 1,
|
_version: r._version ?? r.version ?? 1,
|
||||||
name: r.name,
|
name: r.name,
|
||||||
|
en_name: r.en_name ?? '',
|
||||||
note: r.note ?? '',
|
note: r.note ?? '',
|
||||||
tags: r.tags ?? [],
|
tags: r.tags ?? [],
|
||||||
ingredients: (r.ingredients ?? []).map((ing) => ({
|
ingredients: (r.ingredients ?? []).map((ing) => ({
|
||||||
|
|||||||
@@ -154,7 +154,7 @@ function formatDetail(log) {
|
|||||||
async function fetchLogs() {
|
async function fetchLogs() {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
const res = await api(`/api/audit-logs?offset=${page.value * pageSize}&limit=${pageSize}`)
|
const res = await api(`/api/audit-log?offset=${page.value * pageSize}&limit=${pageSize}`)
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
const items = Array.isArray(data) ? data : data.logs || data.items || []
|
const items = Array.isArray(data) ? data : data.logs || data.items || []
|
||||||
@@ -179,7 +179,7 @@ async function undoLog(log) {
|
|||||||
if (!ok) return
|
if (!ok) return
|
||||||
try {
|
try {
|
||||||
const id = log._id || log.id
|
const id = log._id || log.id
|
||||||
const res = await api(`/api/audit-logs/${id}/undo`, { method: 'POST' })
|
const res = await api(`/api/audit-log/${id}/undo`, { method: 'POST' })
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
ui.showToast('已撤销')
|
ui.showToast('已撤销')
|
||||||
// Refresh
|
// Refresh
|
||||||
|
|||||||
@@ -16,22 +16,21 @@
|
|||||||
<span class="bug-status" :class="'s-' + (bug.status || 'open')">{{ statusLabel(bug.status) }}</span>
|
<span class="bug-status" :class="'s-' + (bug.status || 'open')">{{ statusLabel(bug.status) }}</span>
|
||||||
<span class="bug-date">{{ formatDate(bug.created_at) }}</span>
|
<span class="bug-date">{{ formatDate(bug.created_at) }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="bug-title">{{ bug.title }}</div>
|
<div class="bug-title">{{ bug.content }}</div>
|
||||||
<div v-if="bug.description" class="bug-desc">{{ bug.description }}</div>
|
<div v-if="bug.display_name" class="bug-reporter">{{ bug.display_name || bug.username }}</div>
|
||||||
<div v-if="bug.reporter" class="bug-reporter">报告者: {{ bug.reporter }}</div>
|
|
||||||
|
|
||||||
<!-- Status workflow -->
|
<!-- Status workflow: is_resolved: 0=open, 1=testing, 2=fixed, 3=tested -->
|
||||||
<div class="bug-actions">
|
<div class="bug-actions">
|
||||||
<template v-if="bug.status === 'open'">
|
<template v-if="bug.is_resolved === 0">
|
||||||
<button class="btn-sm btn-status" @click="updateStatus(bug, 'testing')">开始测试</button>
|
<button class="btn-sm btn-status" @click="updateStatus(bug, 1)">待测试</button>
|
||||||
</template>
|
</template>
|
||||||
<template v-else-if="bug.status === 'testing'">
|
<template v-else-if="bug.is_resolved === 1">
|
||||||
<button class="btn-sm btn-status" @click="updateStatus(bug, 'fixed')">标记修复</button>
|
<button class="btn-sm btn-status" @click="updateStatus(bug, 2)">已修复</button>
|
||||||
<button class="btn-sm btn-reopen" @click="updateStatus(bug, 'open')">重新打开</button>
|
<button class="btn-sm btn-reopen" @click="updateStatus(bug, 0)">重新打开</button>
|
||||||
</template>
|
</template>
|
||||||
<template v-else-if="bug.status === 'fixed'">
|
<template v-else-if="bug.is_resolved === 2">
|
||||||
<button class="btn-sm btn-status" @click="updateStatus(bug, 'tested')">验证通过</button>
|
<button class="btn-sm btn-status" @click="updateStatus(bug, 3)">已测试</button>
|
||||||
<button class="btn-sm btn-reopen" @click="updateStatus(bug, 'open')">重新打开</button>
|
<button class="btn-sm btn-reopen" @click="updateStatus(bug, 0)">重新打开</button>
|
||||||
</template>
|
</template>
|
||||||
<button class="btn-sm btn-delete" @click="removeBug(bug)">删除</button>
|
<button class="btn-sm btn-delete" @click="removeBug(bug)">删除</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -40,10 +39,11 @@
|
|||||||
<div class="comments-section" v-if="expandedBugId === (bug._id || bug.id)">
|
<div class="comments-section" v-if="expandedBugId === (bug._id || bug.id)">
|
||||||
<div v-for="comment in (bug.comments || [])" :key="comment._id || comment.id" class="comment-item">
|
<div v-for="comment in (bug.comments || [])" :key="comment._id || comment.id" class="comment-item">
|
||||||
<div class="comment-meta">
|
<div class="comment-meta">
|
||||||
<span class="comment-author">{{ comment.author || comment.user_name || '匿名' }}</span>
|
<span class="comment-author">{{ comment.display_name || comment.username || '系统' }}</span>
|
||||||
|
<span class="comment-action" v-if="comment.action">{{ comment.action }}</span>
|
||||||
<span class="comment-time">{{ formatDate(comment.created_at) }}</span>
|
<span class="comment-time">{{ formatDate(comment.created_at) }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="comment-text">{{ comment.text || comment.content }}</div>
|
<div class="comment-text">{{ comment.content }}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="comment-add">
|
<div class="comment-add">
|
||||||
<input
|
<input
|
||||||
@@ -75,9 +75,9 @@
|
|||||||
<span class="bug-status s-tested">已解决</span>
|
<span class="bug-status s-tested">已解决</span>
|
||||||
<span class="bug-date">{{ formatDate(bug.created_at) }}</span>
|
<span class="bug-date">{{ formatDate(bug.created_at) }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="bug-title">{{ bug.title }}</div>
|
<div class="bug-title">{{ bug.content }}</div>
|
||||||
<div class="bug-actions">
|
<div class="bug-actions">
|
||||||
<button class="btn-sm btn-reopen" @click="updateStatus(bug, 'open')">重新打开</button>
|
<button class="btn-sm btn-reopen" @click="updateStatus(bug, 0)">重新打开</button>
|
||||||
<button class="btn-sm btn-delete" @click="removeBug(bug)">删除</button>
|
<button class="btn-sm btn-delete" @click="removeBug(bug)">删除</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -92,12 +92,8 @@
|
|||||||
<button class="btn-close" @click="showAddBug = false">✕</button>
|
<button class="btn-close" @click="showAddBug = false">✕</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>标题</label>
|
<label>Bug 内容</label>
|
||||||
<input v-model="bugForm.title" class="form-input" placeholder="Bug标题" />
|
<textarea v-model="bugForm.content" class="form-textarea" rows="4" placeholder="描述问题、复现步骤等..."></textarea>
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label>描述</label>
|
|
||||||
<textarea v-model="bugForm.description" class="form-textarea" rows="4" placeholder="Bug描述,复现步骤等..."></textarea>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>优先级</label>
|
<label>优先级</label>
|
||||||
@@ -113,7 +109,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="overlay-footer">
|
<div class="overlay-footer">
|
||||||
<button class="btn-outline" @click="showAddBug = false">取消</button>
|
<button class="btn-outline" @click="showAddBug = false">取消</button>
|
||||||
<button class="btn-primary" @click="createBug" :disabled="!bugForm.title.trim()">提交</button>
|
<button class="btn-primary" @click="createBug" :disabled="!bugForm.content.trim()">提交</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -137,38 +133,35 @@ const expandedBugId = ref(null)
|
|||||||
const newComment = ref('')
|
const newComment = ref('')
|
||||||
|
|
||||||
const bugForm = reactive({
|
const bugForm = reactive({
|
||||||
title: '',
|
content: '',
|
||||||
description: '',
|
priority: 2,
|
||||||
priority: 'normal',
|
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// priority: 0=urgent, 1=high, 2=normal
|
||||||
const priorities = [
|
const priorities = [
|
||||||
{ value: 'low', label: '低' },
|
{ value: 0, label: '紧急' },
|
||||||
{ value: 'normal', label: '中' },
|
{ value: 1, label: '高' },
|
||||||
{ value: 'high', label: '高' },
|
{ value: 2, label: '中' },
|
||||||
{ value: 'critical', label: '紧急' },
|
|
||||||
]
|
]
|
||||||
|
|
||||||
|
// is_resolved: 0=open, 1=testing, 2=fixed, 3=tested
|
||||||
const activeBugs = computed(() =>
|
const activeBugs = computed(() =>
|
||||||
bugs.value.filter(b => b.status !== 'tested' && b.status !== 'closed')
|
bugs.value.filter(b => b.is_resolved !== 2 && b.is_resolved !== 3)
|
||||||
.sort((a, b) => {
|
.sort((a, b) => (a.priority ?? 2) - (b.priority ?? 2))
|
||||||
const order = { critical: 0, high: 1, normal: 2, low: 3 }
|
|
||||||
return (order[a.priority] ?? 2) - (order[b.priority] ?? 2)
|
|
||||||
})
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const resolvedBugs = computed(() =>
|
const resolvedBugs = computed(() =>
|
||||||
bugs.value.filter(b => b.status === 'tested' || b.status === 'closed')
|
bugs.value.filter(b => b.is_resolved === 2 || b.is_resolved === 3)
|
||||||
)
|
)
|
||||||
|
|
||||||
function priorityLabel(p) {
|
function priorityLabel(p) {
|
||||||
const map = { low: '低', normal: '中', high: '高', critical: '紧急' }
|
const map = { 0: '紧急', 1: '高', 2: '中' }
|
||||||
return map[p] || '中'
|
return map[p] ?? '中'
|
||||||
}
|
}
|
||||||
|
|
||||||
function statusLabel(s) {
|
function statusLabel(s) {
|
||||||
const map = { open: '待处理', testing: '测试中', fixed: '已修复', tested: '已验证', closed: '已关闭' }
|
const map = { 0: '待处理', 1: '待测试', 2: '已修复', 3: '已测试' }
|
||||||
return map[s] || s
|
return map[s] ?? '待处理'
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatDate(d) {
|
function formatDate(d) {
|
||||||
@@ -188,7 +181,7 @@ function toggleComments(bug) {
|
|||||||
|
|
||||||
async function loadBugs() {
|
async function loadBugs() {
|
||||||
try {
|
try {
|
||||||
const res = await api('/api/bugs')
|
const res = await api('/api/bug-reports')
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
bugs.value = await res.json()
|
bugs.value = await res.json()
|
||||||
}
|
}
|
||||||
@@ -198,23 +191,19 @@ async function loadBugs() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function createBug() {
|
async function createBug() {
|
||||||
if (!bugForm.title.trim()) return
|
if (!bugForm.content.trim()) return
|
||||||
try {
|
try {
|
||||||
const res = await api('/api/bugs', {
|
const res = await api('/api/bug-report', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
title: bugForm.title.trim(),
|
content: bugForm.content.trim(),
|
||||||
description: bugForm.description.trim(),
|
|
||||||
priority: bugForm.priority,
|
priority: bugForm.priority,
|
||||||
status: 'open',
|
|
||||||
reporter: auth.user.display_name || auth.user.username,
|
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
showAddBug.value = false
|
showAddBug.value = false
|
||||||
bugForm.title = ''
|
bugForm.content = ''
|
||||||
bugForm.description = ''
|
bugForm.priority = 2
|
||||||
bugForm.priority = 'normal'
|
|
||||||
await loadBugs()
|
await loadBugs()
|
||||||
ui.showToast('Bug已提交')
|
ui.showToast('Bug已提交')
|
||||||
}
|
}
|
||||||
@@ -224,14 +213,14 @@ async function createBug() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function updateStatus(bug, newStatus) {
|
async function updateStatus(bug, newStatus) {
|
||||||
const id = bug._id || bug.id
|
const id = bug.id
|
||||||
try {
|
try {
|
||||||
const res = await api(`/api/bugs/${id}`, {
|
const res = await api(`/api/bug-reports/${id}`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
body: JSON.stringify({ status: newStatus }),
|
body: JSON.stringify({ status: newStatus }),
|
||||||
})
|
})
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
bug.status = newStatus
|
bug.is_resolved = newStatus
|
||||||
ui.showToast(`状态已更新: ${statusLabel(newStatus)}`)
|
ui.showToast(`状态已更新: ${statusLabel(newStatus)}`)
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
@@ -240,11 +229,11 @@ async function updateStatus(bug, newStatus) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function removeBug(bug) {
|
async function removeBug(bug) {
|
||||||
const ok = await showConfirm(`确定删除 "${bug.title}"?`)
|
const ok = await showConfirm(`确定删除 "${bug.content}"?`)
|
||||||
if (!ok) return
|
if (!ok) return
|
||||||
const id = bug._id || bug.id
|
const id = bug._id || bug.id
|
||||||
try {
|
try {
|
||||||
const res = await api(`/api/bugs/${id}`, { method: 'DELETE' })
|
const res = await api(`/api/bug-reports/${id}`, { method: 'DELETE' })
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
bugs.value = bugs.value.filter(b => (b._id || b.id) !== id)
|
bugs.value = bugs.value.filter(b => (b._id || b.id) !== id)
|
||||||
ui.showToast('已删除')
|
ui.showToast('已删除')
|
||||||
@@ -258,11 +247,10 @@ async function addComment(bug) {
|
|||||||
if (!newComment.value.trim()) return
|
if (!newComment.value.trim()) return
|
||||||
const id = bug._id || bug.id
|
const id = bug._id || bug.id
|
||||||
try {
|
try {
|
||||||
const res = await api(`/api/bugs/${id}/comments`, {
|
const res = await api(`/api/bug-reports/${id}/comment`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
text: newComment.value.trim(),
|
content: newComment.value.trim(),
|
||||||
author: auth.user.display_name || auth.user.username,
|
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
<div class="my-diary">
|
<div class="my-diary">
|
||||||
<!-- Sub Tabs -->
|
<!-- Sub Tabs -->
|
||||||
<div class="sub-tabs">
|
<div class="sub-tabs">
|
||||||
<button class="sub-tab" :class="{ active: activeTab === 'diary' }" @click="activeTab = 'diary'">📖 配方日记</button>
|
|
||||||
<button class="sub-tab" :class="{ active: activeTab === 'brand' }" @click="activeTab = 'brand'">🏷️ Brand</button>
|
<button class="sub-tab" :class="{ active: activeTab === 'brand' }" @click="activeTab = 'brand'">🏷️ Brand</button>
|
||||||
<button class="sub-tab" :class="{ active: activeTab === 'account' }" @click="activeTab = 'account'">👤 Account</button>
|
<button class="sub-tab" :class="{ active: activeTab === 'account' }" @click="activeTab = 'account'">👤 Account</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -216,7 +215,7 @@ const oils = useOilsStore()
|
|||||||
const diaryStore = useDiaryStore()
|
const diaryStore = useDiaryStore()
|
||||||
const ui = useUiStore()
|
const ui = useUiStore()
|
||||||
|
|
||||||
const activeTab = ref('diary')
|
const activeTab = ref('brand')
|
||||||
const pasteText = ref('')
|
const pasteText = ref('')
|
||||||
const selectedDiaryId = ref(null)
|
const selectedDiaryId = ref(null)
|
||||||
const selectedDiary = ref(null)
|
const selectedDiary = ref(null)
|
||||||
@@ -341,7 +340,7 @@ function formatDate(d) {
|
|||||||
// Brand settings
|
// Brand settings
|
||||||
async function loadBrandSettings() {
|
async function loadBrandSettings() {
|
||||||
try {
|
try {
|
||||||
const res = await api('/api/brand-settings')
|
const res = await api('/api/brand')
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
brandName.value = data.brand_name || ''
|
brandName.value = data.brand_name || ''
|
||||||
@@ -356,7 +355,7 @@ async function loadBrandSettings() {
|
|||||||
|
|
||||||
async function saveBrandSettings() {
|
async function saveBrandSettings() {
|
||||||
try {
|
try {
|
||||||
await api('/api/brand-settings', {
|
await api('/api/brand', {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
brand_name: brandName.value,
|
brand_name: brandName.value,
|
||||||
@@ -400,7 +399,7 @@ async function handleUpload(type, event) {
|
|||||||
// Account
|
// Account
|
||||||
async function updateDisplayName() {
|
async function updateDisplayName() {
|
||||||
try {
|
try {
|
||||||
await api('/api/me/display-name', {
|
await api('/api/me', {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
body: JSON.stringify({ display_name: displayName.value }),
|
body: JSON.stringify({ display_name: displayName.value }),
|
||||||
})
|
})
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -90,8 +90,8 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Public Recipes Section -->
|
<!-- Public Recipes Section (admin/senior_editor only) -->
|
||||||
<div class="recipe-section">
|
<div v-if="auth.canManage" class="recipe-section">
|
||||||
<h3 class="section-title">🌿 公共配方库 ({{ publicRecipes.length }})</h3>
|
<h3 class="section-title">🌿 公共配方库 ({{ publicRecipes.length }})</h3>
|
||||||
<div class="recipe-list">
|
<div class="recipe-list">
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -1,21 +1,38 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="recipe-search">
|
<div class="recipe-search">
|
||||||
<!-- Category Carousel -->
|
<!-- Category Carousel (full-width image slides) -->
|
||||||
<div class="cat-wrap" v-if="categories.length">
|
<div class="cat-wrap" v-if="categories.length && !selectedCategory">
|
||||||
<button class="cat-arrow cat-arrow-left" @click="scrollCat(-1)" :disabled="catScrollPos <= 0">‹</button>
|
<div class="cat-track" :style="{ transform: `translateX(-${catIdx * 100}%)` }">
|
||||||
<div class="cat-track" ref="catTrack">
|
|
||||||
<div
|
<div
|
||||||
v-for="cat in categories"
|
v-for="cat in categories"
|
||||||
:key="cat.name"
|
:key="cat.name"
|
||||||
class="cat-card"
|
class="cat-card"
|
||||||
:class="{ active: selectedCategory === cat.name }"
|
:style="{ backgroundImage: cat.bg_image ? `url(${cat.bg_image})` : `linear-gradient(135deg, ${cat.color_from || '#7a9e7e'}, ${cat.color_to || '#5a7d5e'})` }"
|
||||||
@click="toggleCategory(cat.name)"
|
@click="selectCategory(cat)"
|
||||||
>
|
>
|
||||||
<span class="cat-icon">{{ cat.icon || '📁' }}</span>
|
<div class="cat-inner">
|
||||||
<span class="cat-label">{{ cat.name }}</span>
|
<div class="cat-icon">{{ cat.icon || '🌿' }}</div>
|
||||||
|
<div class="cat-name">{{ cat.name }}</div>
|
||||||
|
<div v-if="cat.subtitle" class="cat-sub">{{ cat.subtitle }}</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button class="cat-arrow cat-arrow-right" @click="scrollCat(1)">›</button>
|
<button class="cat-arrow left" @click="slideCat(-1)">‹</button>
|
||||||
|
<button class="cat-arrow right" @click="slideCat(1)">›</button>
|
||||||
|
</div>
|
||||||
|
<div class="cat-dots" v-if="categories.length > 1 && !selectedCategory">
|
||||||
|
<span
|
||||||
|
v-for="(cat, i) in categories"
|
||||||
|
:key="i"
|
||||||
|
class="cat-dot"
|
||||||
|
:class="{ active: catIdx === i }"
|
||||||
|
@click="catIdx = i"
|
||||||
|
></span>
|
||||||
|
</div>
|
||||||
|
<!-- Category filter active banner -->
|
||||||
|
<div v-if="selectedCategory" class="cat-filter-bar">
|
||||||
|
<span>📂 {{ selectedCategory }}</span>
|
||||||
|
<button @click="selectedCategory = null; catIdx = 0" class="btn-sm btn-outline">✕ 返回全部</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Search Box -->
|
<!-- Search Box -->
|
||||||
@@ -126,12 +143,11 @@ const categories = ref([])
|
|||||||
const selectedRecipeIndex = ref(null)
|
const selectedRecipeIndex = ref(null)
|
||||||
const showMyRecipes = ref(true)
|
const showMyRecipes = ref(true)
|
||||||
const showFavorites = ref(true)
|
const showFavorites = ref(true)
|
||||||
const catScrollPos = ref(0)
|
const catIdx = ref(0)
|
||||||
const catTrack = ref(null)
|
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
try {
|
try {
|
||||||
const res = await api('/api/category-modules')
|
const res = await api('/api/categories')
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
categories.value = await res.json()
|
categories.value = await res.json()
|
||||||
}
|
}
|
||||||
@@ -140,15 +156,13 @@ onMounted(async () => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
function toggleCategory(name) {
|
function selectCategory(cat) {
|
||||||
selectedCategory.value = selectedCategory.value === name ? null : name
|
selectedCategory.value = cat.tag_name || cat.name
|
||||||
}
|
}
|
||||||
|
|
||||||
function scrollCat(dir) {
|
function slideCat(dir) {
|
||||||
if (!catTrack.value) return
|
const len = categories.value.length
|
||||||
const scrollAmount = 200
|
catIdx.value = (catIdx.value + dir + len) % len
|
||||||
catTrack.value.scrollLeft += dir * scrollAmount
|
|
||||||
catScrollPos.value = catTrack.value.scrollLeft + dir * scrollAmount
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const filteredRecipes = computed(() => {
|
const filteredRecipes = computed(() => {
|
||||||
@@ -219,81 +233,127 @@ function clearSearch() {
|
|||||||
|
|
||||||
.cat-wrap {
|
.cat-wrap {
|
||||||
position: relative;
|
position: relative;
|
||||||
display: flex;
|
margin: 0 -12px 20px;
|
||||||
align-items: center;
|
overflow: hidden;
|
||||||
margin-bottom: 16px;
|
|
||||||
gap: 4px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.cat-track {
|
.cat-track {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 10px;
|
transition: transform 0.4s ease;
|
||||||
overflow-x: auto;
|
will-change: transform;
|
||||||
scroll-behavior: smooth;
|
|
||||||
flex: 1;
|
|
||||||
padding: 8px 0;
|
|
||||||
scrollbar-width: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cat-track::-webkit-scrollbar {
|
|
||||||
display: none;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.cat-card {
|
.cat-card {
|
||||||
|
flex: 0 0 100%;
|
||||||
|
min-height: 200px;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
cursor: pointer;
|
||||||
|
background-size: cover;
|
||||||
|
background-position: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cat-card::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background: linear-gradient(135deg, rgba(0,0,0,0.45), rgba(0,0,0,0.25));
|
||||||
|
}
|
||||||
|
|
||||||
|
.cat-inner {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
height: 100%;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 4px;
|
padding: 36px 24px;
|
||||||
padding: 10px 16px;
|
color: white;
|
||||||
border-radius: 12px;
|
text-align: center;
|
||||||
background: #f8f7f5;
|
|
||||||
cursor: pointer;
|
|
||||||
white-space: nowrap;
|
|
||||||
font-size: 13px;
|
|
||||||
transition: all 0.2s;
|
|
||||||
min-width: 64px;
|
|
||||||
border: 1.5px solid transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cat-card:hover {
|
|
||||||
background: #f0eeeb;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cat-card.active {
|
|
||||||
background: linear-gradient(135deg, #e8f5e9, #c8e6c9);
|
|
||||||
border-color: #7ec6a4;
|
|
||||||
color: #2e7d5a;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.cat-icon {
|
.cat-icon {
|
||||||
font-size: 20px;
|
font-size: 48px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
filter: drop-shadow(0 2px 6px rgba(0,0,0,0.3));
|
||||||
|
}
|
||||||
|
|
||||||
|
.cat-name {
|
||||||
|
font-family: 'Noto Serif SC', serif;
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 3px;
|
||||||
|
text-shadow: 0 2px 8px rgba(0,0,0,0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cat-sub {
|
||||||
|
font-size: 13px;
|
||||||
|
margin-top: 6px;
|
||||||
|
opacity: 0.9;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cat-arrow {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
z-index: 2;
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: rgba(255,255,255,0.25);
|
||||||
|
border: none;
|
||||||
|
color: white;
|
||||||
|
font-size: 18px;
|
||||||
|
cursor: pointer;
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
.cat-arrow:hover { background: rgba(255,255,255,0.45); }
|
||||||
|
.cat-arrow.left { left: 12px; }
|
||||||
|
.cat-arrow.right { right: 12px; }
|
||||||
|
|
||||||
|
.cat-dots {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
.cat-dot {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--border, #e0d4c0);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.25s;
|
||||||
|
}
|
||||||
|
.cat-dot.active {
|
||||||
|
background: var(--sage, #7a9e7e);
|
||||||
|
width: 22px;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cat-filter-bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
background: var(--sage-mist, #eef4ee);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 10px 16px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--sage-dark, #5a7d5e);
|
||||||
}
|
}
|
||||||
|
|
||||||
.cat-label {
|
.cat-label {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.cat-arrow {
|
|
||||||
width: 28px;
|
|
||||||
height: 28px;
|
|
||||||
border-radius: 50%;
|
|
||||||
border: 1.5px solid #d4cfc7;
|
|
||||||
background: #fff;
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 16px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
flex-shrink: 0;
|
|
||||||
color: #6b6375;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cat-arrow:disabled {
|
|
||||||
opacity: 0.3;
|
|
||||||
cursor: default;
|
|
||||||
}
|
|
||||||
|
|
||||||
.search-box {
|
.search-box {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
2
node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json
generated
vendored
2
node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json
generated
vendored
@@ -1 +1 @@
|
|||||||
{"version":"4.1.2","results":[[":frontend/src/__tests__/volumeDilution.test.js",{"duration":5.926774999999992,"failed":false}],[":frontend/src/__tests__/smartPaste.test.js",{"duration":16.112632000000005,"failed":false}],[":frontend/src/__tests__/oilCalculations.test.js",{"duration":11.990026,"failed":false}],[":frontend/src/__tests__/dialog.test.js",{"duration":4.135876999999994,"failed":false}],[":frontend/src/__tests__/oilTranslation.test.js",{"duration":4.413353999999998,"failed":false}]]}
|
{"version":"4.1.2","results":[[":frontend/src/__tests__/volumeDilution.test.js",{"duration":6.227510999999993,"failed":false}],[":frontend/src/__tests__/smartPaste.test.js",{"duration":14.144011000000006,"failed":false}],[":frontend/src/__tests__/oilCalculations.test.js",{"duration":18.03941499999999,"failed":false}],[":frontend/src/__tests__/dialog.test.js",{"duration":3.7299579999999963,"failed":false}],[":frontend/src/__tests__/oilTranslation.test.js",{"duration":5.783867999999998,"failed":false}]]}
|
||||||
259
scripts/deploy-preview.py
Normal file
259
scripts/deploy-preview.py
Normal file
@@ -0,0 +1,259 @@
|
|||||||
|
#!/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>
|
||||||
|
"""
|
||||||
|
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import json
|
||||||
|
import tempfile
|
||||||
|
import textwrap
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
REGISTRY = "registry.oci.euphon.net"
|
||||||
|
BASE_DOMAIN = "oil.oci.euphon.net"
|
||||||
|
PROD_NS = "oil-calculator"
|
||||||
|
|
||||||
|
|
||||||
|
def run(cmd: list[str] | str, *, check=True, capture=False) -> subprocess.CompletedProcess:
|
||||||
|
"""Run a command, print it, and optionally check for errors."""
|
||||||
|
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:
|
||||||
|
"""Write content to a temp file and return its path."""
|
||||||
|
f = tempfile.NamedTemporaryFile(mode="w", suffix=suffix, delete=False)
|
||||||
|
f.write(content)
|
||||||
|
f.close()
|
||||||
|
return Path(f.name)
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Deploy ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
def deploy(pr_id: str):
|
||||||
|
ns = f"oil-pr-{pr_id}"
|
||||||
|
host = f"pr-{pr_id}.{BASE_DOMAIN}"
|
||||||
|
image = f"{REGISTRY}/oil-calculator: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", "app=oil-calculator",
|
||||||
|
"--field-selector=status.phase=Running",
|
||||||
|
"-o", "jsonpath={.items[0].metadata.name}",
|
||||||
|
capture=True, check=False
|
||||||
|
).stdout.strip()
|
||||||
|
|
||||||
|
if prod_pod:
|
||||||
|
kubectl("cp", f"{PROD_NS}/{prod_pod}:/data/oil_calculator.db", "data/oil_calculator.db")
|
||||||
|
else:
|
||||||
|
print(" WARNING: No running prod pod, using empty DB")
|
||||||
|
Path("data/oil_calculator.db").touch()
|
||||||
|
|
||||||
|
# 2. Build and push image
|
||||||
|
print("[2/5] Building Docker image...")
|
||||||
|
dockerfile = textwrap.dedent("""\
|
||||||
|
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 data/oil_calculator.db /data/oil_calculator.db
|
||||||
|
ENV DB_PATH=/data/oil_calculator.db
|
||||||
|
ENV FRONTEND_DIR=/app/frontend
|
||||||
|
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...")
|
||||||
|
kubectl("create", "namespace", ns, "--dry-run=client", "-o", "yaml",
|
||||||
|
check=False) # just for display
|
||||||
|
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)
|
||||||
|
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: oil-calculator
|
||||||
|
namespace: {ns}
|
||||||
|
spec:
|
||||||
|
replicas: 1
|
||||||
|
selector:
|
||||||
|
matchLabels:
|
||||||
|
app: oil-calculator
|
||||||
|
template:
|
||||||
|
metadata:
|
||||||
|
labels:
|
||||||
|
app: oil-calculator
|
||||||
|
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: oil-calculator
|
||||||
|
namespace: {ns}
|
||||||
|
spec:
|
||||||
|
selector:
|
||||||
|
app: oil-calculator
|
||||||
|
ports:
|
||||||
|
- port: 80
|
||||||
|
targetPort: 8000
|
||||||
|
---
|
||||||
|
apiVersion: networking.k8s.io/v1
|
||||||
|
kind: Ingress
|
||||||
|
metadata:
|
||||||
|
name: oil-calculator
|
||||||
|
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: oil-calculator
|
||||||
|
port:
|
||||||
|
number: 80
|
||||||
|
""")
|
||||||
|
p = write_temp(manifests)
|
||||||
|
kubectl("apply", "-f", str(p))
|
||||||
|
p.unlink()
|
||||||
|
|
||||||
|
# 5. Restart to pick up new image and wait
|
||||||
|
print("[5/5] Restarting deployment...")
|
||||||
|
kubectl("rollout", "restart", "deploy/oil-calculator", "-n", ns)
|
||||||
|
kubectl("rollout", "status", "deploy/oil-calculator", "-n", ns, "--timeout=120s")
|
||||||
|
|
||||||
|
# Cleanup
|
||||||
|
run("rm -rf data/oil_calculator.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"oil-pr-{pr_id}"
|
||||||
|
image = f"{REGISTRY}/oil-calculator: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}/oil-calculator: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", "deploy/oil-calculator", "-n", PROD_NS)
|
||||||
|
kubectl("rollout", "status", "deploy/oil-calculator", "-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