Initial template: Vue 3 + FastAPI + SQLite full-stack with K8s deployment
Some checks are pending
Some checks are pending
Extracted from oil project — business logic removed, auth/db/deploy infrastructure generalized with APP_NAME placeholders. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
commit
d19183923c
22
.gitea/workflows/deploy.yml
Normal file
22
.gitea/workflows/deploy.yml
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
name: Deploy Production
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
|
||||||
|
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:
|
||||||
|
needs: test
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- name: Deploy Production
|
||||||
|
run: python3 scripts/deploy-preview.py deploy-prod
|
||||||
50
.gitea/workflows/preview.yml
Normal file
50
.gitea/workflows/preview.yml
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
name: PR Preview
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
types: [opened, synchronize, reopened, closed]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
if: github.event.action != 'closed'
|
||||||
|
runs-on: test
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- name: Unit tests
|
||||||
|
run: cd frontend && npm ci && npx vitest run
|
||||||
|
|
||||||
|
deploy-preview:
|
||||||
|
if: github.event.action != 'closed'
|
||||||
|
needs: test
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- name: Deploy Preview
|
||||||
|
run: python3 scripts/deploy-preview.py deploy ${{ github.event.pull_request.number }}
|
||||||
|
- name: Comment PR
|
||||||
|
env:
|
||||||
|
GIT_TOKEN: ${{ secrets.GIT_TOKEN }}
|
||||||
|
run: |
|
||||||
|
PR_ID="${{ github.event.pull_request.number }}"
|
||||||
|
curl -sf -X POST \
|
||||||
|
"https://git.euphon.cloud/api/v1/repos/${{ github.repository }}/issues/${PR_ID}/comments" \
|
||||||
|
-H "Authorization: token ${GIT_TOKEN}" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "{\"body\": \"Preview: https://pr-${PR_ID}.APP_NAME.oci.euphon.net\n\nDB is a copy of production.\"}" || true
|
||||||
|
|
||||||
|
teardown-preview:
|
||||||
|
if: github.event.action == 'closed'
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- name: Teardown
|
||||||
|
run: python3 scripts/deploy-preview.py teardown ${{ github.event.pull_request.number }}
|
||||||
|
- name: Comment PR
|
||||||
|
env:
|
||||||
|
GIT_TOKEN: ${{ secrets.GIT_TOKEN }}
|
||||||
|
run: |
|
||||||
|
PR_ID="${{ github.event.pull_request.number }}"
|
||||||
|
curl -sf -X POST \
|
||||||
|
"https://git.euphon.cloud/api/v1/repos/${{ github.repository }}/issues/${PR_ID}/comments" \
|
||||||
|
-H "Authorization: token ${GIT_TOKEN}" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "{\"body\": \"Preview torn down.\"}" || true
|
||||||
57
.gitea/workflows/test.yml
Normal file
57
.gitea/workflows/test.yml
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
name: Test
|
||||||
|
on: [push]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
unit-test:
|
||||||
|
runs-on: test
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: cd frontend && npm ci
|
||||||
|
|
||||||
|
- name: Run unit tests
|
||||||
|
run: |
|
||||||
|
cd frontend
|
||||||
|
npx vitest run --reporter=verbose 2>&1 | tee /tmp/vitest-${{ github.sha }}.log
|
||||||
|
|
||||||
|
e2e-test:
|
||||||
|
runs-on: test
|
||||||
|
needs: unit-test
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Install frontend
|
||||||
|
run: cd frontend && npm ci
|
||||||
|
|
||||||
|
- name: Install backend
|
||||||
|
run: python3 -m venv /tmp/ci-venv-$$ && . /tmp/ci-venv-$$/bin/activate && pip install -q -r backend/requirements.txt
|
||||||
|
|
||||||
|
- name: Start servers
|
||||||
|
run: |
|
||||||
|
. /tmp/ci-venv-*/bin/activate
|
||||||
|
DB_PATH=/tmp/ci_app_${{ github.run_id }}.db FRONTEND_DIR=/dev/null \
|
||||||
|
nohup uvicorn backend.main:app --port 8000 > /tmp/backend.log 2>&1 &
|
||||||
|
cd frontend && nohup npx vite --port 5173 > /tmp/frontend.log 2>&1 &
|
||||||
|
sleep 4
|
||||||
|
curl -sf http://localhost:8000/api/health
|
||||||
|
curl -sf -o /dev/null http://localhost:5173/
|
||||||
|
|
||||||
|
- name: Run E2E tests
|
||||||
|
run: |
|
||||||
|
cd frontend
|
||||||
|
npx cypress run --config video=false 2>&1 | tee /tmp/cypress-${{ github.sha }}.log
|
||||||
|
|
||||||
|
- name: Cleanup
|
||||||
|
if: always()
|
||||||
|
run: |
|
||||||
|
pkill -f "uvicorn backend" || true
|
||||||
|
pkill -f "node.*vite" || true
|
||||||
|
rm -f /tmp/ci_app_${{ github.run_id }}.db
|
||||||
|
|
||||||
|
build-check:
|
||||||
|
runs-on: test
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- name: Build frontend
|
||||||
|
run: cd frontend && npm ci && npm run build
|
||||||
16
.gitignore
vendored
Normal file
16
.gitignore
vendored
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
.DS_Store
|
||||||
|
*.pyc
|
||||||
|
__pycache__/
|
||||||
|
*.db
|
||||||
|
backups/
|
||||||
|
|
||||||
|
# Frontend
|
||||||
|
frontend/node_modules/
|
||||||
|
frontend/dist/
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
|
||||||
|
# Env
|
||||||
|
.env
|
||||||
24
Dockerfile
Normal file
24
Dockerfile
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
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/
|
||||||
|
|
||||||
|
ENV DB_PATH=/data/app.db
|
||||||
|
ENV FRONTEND_DIR=/app/frontend
|
||||||
|
|
||||||
|
EXPOSE 8000
|
||||||
|
|
||||||
|
CMD ["uvicorn", "backend.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||||
73
README.md
Normal file
73
README.md
Normal file
@ -0,0 +1,73 @@
|
|||||||
|
# Base Template
|
||||||
|
|
||||||
|
Vue 3 + FastAPI + SQLite full-stack template with K8s deployment.
|
||||||
|
|
||||||
|
## Tech Stack
|
||||||
|
|
||||||
|
- **Frontend**: Vue 3 + Vite + Pinia + Vue Router
|
||||||
|
- **Backend**: FastAPI + SQLite (WAL mode) + uvicorn
|
||||||
|
- **Testing**: Vitest (unit) + Cypress (E2E)
|
||||||
|
- **Deploy**: Docker multi-stage build → K8s (k3s + Traefik)
|
||||||
|
- **CI/CD**: Gitea Actions (test → deploy, PR preview)
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Frontend
|
||||||
|
cd frontend && npm install && npm run dev
|
||||||
|
|
||||||
|
# Backend (in another terminal)
|
||||||
|
pip install -r backend/requirements.txt
|
||||||
|
DB_PATH=./dev.db uvicorn backend.main:app --reload --port 8000
|
||||||
|
```
|
||||||
|
|
||||||
|
Frontend dev server proxies `/api` to `localhost:8000`.
|
||||||
|
|
||||||
|
## Setup for New Project
|
||||||
|
|
||||||
|
1. Replace all `APP_NAME` placeholders in `deploy/`, `scripts/`, and `.gitea/workflows/`
|
||||||
|
2. Add your tables to `backend/database.py` → `init_db()`
|
||||||
|
3. Add your routes to `backend/main.py`
|
||||||
|
4. Add your pages to `frontend/src/views/` and register in `frontend/src/router/index.js`
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd frontend
|
||||||
|
npm run test:unit # Vitest
|
||||||
|
npm run test:e2e # Cypress (requires both servers running)
|
||||||
|
npm test # Both
|
||||||
|
```
|
||||||
|
|
||||||
|
## Deploy
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Production
|
||||||
|
python3 scripts/deploy-preview.py deploy-prod
|
||||||
|
|
||||||
|
# PR preview
|
||||||
|
python3 scripts/deploy-preview.py deploy <PR_ID>
|
||||||
|
python3 scripts/deploy-preview.py teardown <PR_ID>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
├── frontend/
|
||||||
|
│ ├── src/
|
||||||
|
│ │ ├── composables/useApi.js # HTTP client with auth
|
||||||
|
│ │ ├── stores/auth.js # Auth state (Pinia)
|
||||||
|
│ │ ├── router/index.js # Routes
|
||||||
|
│ │ ├── views/ # Page components
|
||||||
|
│ │ └── assets/styles.css # Design tokens + base styles
|
||||||
|
│ ├── cypress/ # E2E tests
|
||||||
|
│ └── vite.config.js
|
||||||
|
├── backend/
|
||||||
|
│ ├── main.py # FastAPI app + routes
|
||||||
|
│ ├── auth.py # Auth dependencies
|
||||||
|
│ └── database.py # SQLite init + helpers
|
||||||
|
├── deploy/ # K8s manifests (replace APP_NAME)
|
||||||
|
├── scripts/deploy-preview.py # Deploy automation
|
||||||
|
├── .gitea/workflows/ # CI/CD pipelines
|
||||||
|
└── Dockerfile # Multi-stage build
|
||||||
|
```
|
||||||
0
backend/__init__.py
Normal file
0
backend/__init__.py
Normal file
37
backend/auth.py
Normal file
37
backend/auth.py
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
from fastapi import Request, Depends, HTTPException
|
||||||
|
|
||||||
|
from backend.database import get_db
|
||||||
|
|
||||||
|
ANON_USER = {"id": None, "role": "viewer", "username": "anonymous", "display_name": "匿名"}
|
||||||
|
|
||||||
|
|
||||||
|
def get_current_user(request: Request):
|
||||||
|
"""Extract user from Bearer token. Returns anonymous if no/invalid token."""
|
||||||
|
token = request.headers.get("Authorization", "").removeprefix("Bearer ").strip()
|
||||||
|
if not token:
|
||||||
|
return ANON_USER
|
||||||
|
conn = get_db()
|
||||||
|
user = conn.execute(
|
||||||
|
"SELECT id, username, role, display_name, password FROM users WHERE token = ?",
|
||||||
|
(token,),
|
||||||
|
).fetchone()
|
||||||
|
conn.close()
|
||||||
|
if not user:
|
||||||
|
return ANON_USER
|
||||||
|
return dict(user)
|
||||||
|
|
||||||
|
|
||||||
|
def require_role(*roles):
|
||||||
|
"""Dependency that checks the user has one of the given roles."""
|
||||||
|
def checker(user=Depends(get_current_user)):
|
||||||
|
if user["role"] not in roles:
|
||||||
|
raise HTTPException(403, "权限不足")
|
||||||
|
return user
|
||||||
|
return checker
|
||||||
|
|
||||||
|
|
||||||
|
def require_login(user=Depends(get_current_user)):
|
||||||
|
"""Dependency that requires any authenticated user."""
|
||||||
|
if user["id"] is None:
|
||||||
|
raise HTTPException(401, "请先登录")
|
||||||
|
return user
|
||||||
62
backend/database.py
Normal file
62
backend/database.py
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
import sqlite3
|
||||||
|
import os
|
||||||
|
import secrets
|
||||||
|
|
||||||
|
DB_PATH = os.environ.get("DB_PATH", "/data/app.db")
|
||||||
|
|
||||||
|
|
||||||
|
def get_db():
|
||||||
|
conn = sqlite3.connect(DB_PATH)
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
conn.execute("PRAGMA journal_mode=WAL")
|
||||||
|
conn.execute("PRAGMA foreign_keys=ON")
|
||||||
|
return conn
|
||||||
|
|
||||||
|
|
||||||
|
def init_db():
|
||||||
|
"""Initialize database schema. Add your tables here."""
|
||||||
|
os.makedirs(os.path.dirname(DB_PATH), exist_ok=True)
|
||||||
|
conn = get_db()
|
||||||
|
c = conn.cursor()
|
||||||
|
c.executescript("""
|
||||||
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
username TEXT UNIQUE NOT NULL,
|
||||||
|
password TEXT,
|
||||||
|
token TEXT UNIQUE NOT NULL,
|
||||||
|
role TEXT NOT NULL DEFAULT 'viewer',
|
||||||
|
display_name TEXT,
|
||||||
|
created_at TEXT DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS audit_log (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
user_id INTEGER,
|
||||||
|
action TEXT NOT NULL,
|
||||||
|
target_type TEXT,
|
||||||
|
target_id TEXT,
|
||||||
|
target_name TEXT,
|
||||||
|
detail TEXT,
|
||||||
|
created_at TEXT DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
""")
|
||||||
|
|
||||||
|
# Seed admin user if no users exist
|
||||||
|
count = c.execute("SELECT COUNT(*) FROM users").fetchone()[0]
|
||||||
|
if count == 0:
|
||||||
|
admin_token = os.environ.get("ADMIN_TOKEN", secrets.token_hex(24))
|
||||||
|
c.execute(
|
||||||
|
"INSERT INTO users (username, token, role, display_name) VALUES (?, ?, ?, ?)",
|
||||||
|
("admin", admin_token, "admin", "Admin"),
|
||||||
|
)
|
||||||
|
print(f"[INIT] Admin user created. Token: {admin_token}")
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def log_audit(conn, user_id, action, target_type=None, target_id=None, target_name=None, detail=None):
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO audit_log (user_id, action, target_type, target_id, target_name, detail) "
|
||||||
|
"VALUES (?, ?, ?, ?, ?, ?)",
|
||||||
|
(user_id, action, target_type, str(target_id) if target_id else None, target_name, detail),
|
||||||
|
)
|
||||||
115
backend/main.py
Normal file
115
backend/main.py
Normal file
@ -0,0 +1,115 @@
|
|||||||
|
from fastapi import FastAPI, HTTPException, Depends
|
||||||
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from typing import Optional
|
||||||
|
import hashlib
|
||||||
|
import secrets
|
||||||
|
import os
|
||||||
|
import threading
|
||||||
|
import time as _time
|
||||||
|
|
||||||
|
from backend.database import get_db, init_db, log_audit
|
||||||
|
from backend.auth import get_current_user, require_role, require_login
|
||||||
|
|
||||||
|
app = FastAPI(title="App API")
|
||||||
|
|
||||||
|
|
||||||
|
# ── Periodic WAL checkpoint ───────────────────────────
|
||||||
|
def _wal_checkpoint_loop():
|
||||||
|
while True:
|
||||||
|
_time.sleep(300)
|
||||||
|
try:
|
||||||
|
conn = get_db()
|
||||||
|
conn.execute("PRAGMA wal_checkpoint(TRUNCATE)")
|
||||||
|
conn.close()
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
threading.Thread(target=_wal_checkpoint_loop, daemon=True).start()
|
||||||
|
|
||||||
|
|
||||||
|
# ── Models ────────────────────────────────────────────
|
||||||
|
class LoginRequest(BaseModel):
|
||||||
|
username: str
|
||||||
|
password: str
|
||||||
|
|
||||||
|
class RegisterRequest(BaseModel):
|
||||||
|
username: str
|
||||||
|
password: str
|
||||||
|
display_name: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
# ── Health & Version ──────────────────────────────────
|
||||||
|
APP_VERSION = "0.1.0"
|
||||||
|
|
||||||
|
@app.get("/api/health")
|
||||||
|
def health():
|
||||||
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
@app.get("/api/version")
|
||||||
|
def version():
|
||||||
|
return {"version": APP_VERSION}
|
||||||
|
|
||||||
|
|
||||||
|
# ── Auth endpoints ────────────────────────────────────
|
||||||
|
def _hash_password(pw: str) -> str:
|
||||||
|
return hashlib.sha256(pw.encode()).hexdigest()
|
||||||
|
|
||||||
|
@app.get("/api/me")
|
||||||
|
def get_me(user=Depends(get_current_user)):
|
||||||
|
return {
|
||||||
|
"id": user.get("id"),
|
||||||
|
"username": user["username"],
|
||||||
|
"role": user["role"],
|
||||||
|
"display_name": user.get("display_name", ""),
|
||||||
|
}
|
||||||
|
|
||||||
|
@app.post("/api/login")
|
||||||
|
def login(body: LoginRequest):
|
||||||
|
conn = get_db()
|
||||||
|
user = conn.execute(
|
||||||
|
"SELECT id, username, token, password, role, display_name FROM users WHERE username = ?",
|
||||||
|
(body.username,),
|
||||||
|
).fetchone()
|
||||||
|
conn.close()
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(401, "用户名或密码错误")
|
||||||
|
user = dict(user)
|
||||||
|
if user.get("password") and user["password"] != _hash_password(body.password):
|
||||||
|
raise HTTPException(401, "用户名或密码错误")
|
||||||
|
return {"token": user["token"]}
|
||||||
|
|
||||||
|
@app.post("/api/register", status_code=201)
|
||||||
|
def register(body: RegisterRequest):
|
||||||
|
conn = get_db()
|
||||||
|
existing = conn.execute("SELECT id FROM users WHERE username = ?", (body.username,)).fetchone()
|
||||||
|
if existing:
|
||||||
|
conn.close()
|
||||||
|
raise HTTPException(409, "用户名已存在")
|
||||||
|
token = secrets.token_hex(24)
|
||||||
|
pw_hash = _hash_password(body.password)
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO users (username, password, token, role, display_name) VALUES (?, ?, ?, ?, ?)",
|
||||||
|
(body.username, pw_hash, token, "viewer", body.display_name or body.username),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
return {"token": token}
|
||||||
|
|
||||||
|
|
||||||
|
# ── User management (admin) ──────────────────────────
|
||||||
|
@app.get("/api/users")
|
||||||
|
def list_users(user=Depends(require_role("admin"))):
|
||||||
|
conn = get_db()
|
||||||
|
rows = conn.execute("SELECT id, username, role, display_name, token, created_at FROM users ORDER BY id").fetchall()
|
||||||
|
conn.close()
|
||||||
|
return [dict(r) for r in rows]
|
||||||
|
|
||||||
|
|
||||||
|
# ── Static files (frontend) ──────────────────────────
|
||||||
|
@app.on_event("startup")
|
||||||
|
def on_startup():
|
||||||
|
init_db()
|
||||||
|
frontend_dir = os.environ.get("FRONTEND_DIR", "frontend/dist")
|
||||||
|
if os.path.isdir(frontend_dir):
|
||||||
|
app.mount("/", StaticFiles(directory=frontend_dir, html=True), name="frontend")
|
||||||
3
backend/requirements.txt
Normal file
3
backend/requirements.txt
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
fastapi==0.115.6
|
||||||
|
uvicorn[standard]==0.34.0
|
||||||
|
aiosqlite==0.20.0
|
||||||
38
deploy/backup-cronjob.yaml
Normal file
38
deploy/backup-cronjob.yaml
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
apiVersion: batch/v1
|
||||||
|
kind: CronJob
|
||||||
|
metadata:
|
||||||
|
name: hourly-backup
|
||||||
|
namespace: APP_NAME
|
||||||
|
spec:
|
||||||
|
schedule: "0 * * * *"
|
||||||
|
successfulJobsHistoryLimit: 3
|
||||||
|
failedJobsHistoryLimit: 2
|
||||||
|
jobTemplate:
|
||||||
|
spec:
|
||||||
|
template:
|
||||||
|
spec:
|
||||||
|
containers:
|
||||||
|
- name: backup
|
||||||
|
image: registry.oci.euphon.net/APP_NAME:latest
|
||||||
|
command:
|
||||||
|
- sh
|
||||||
|
- -c
|
||||||
|
- |
|
||||||
|
BACKUP_DIR=/data/backups
|
||||||
|
mkdir -p $BACKUP_DIR
|
||||||
|
DATE=$(date +%Y%m%d_%H%M%S)
|
||||||
|
sqlite3 /data/app.db ".backup '$BACKUP_DIR/app_${DATE}.db'"
|
||||||
|
echo "Backup done: $BACKUP_DIR/app_${DATE}.db"
|
||||||
|
# Keep last 48 backups (2 days of hourly)
|
||||||
|
ls -t $BACKUP_DIR/app_*.db | tail -n +49 | xargs rm -f 2>/dev/null
|
||||||
|
echo "Backups retained: $(ls $BACKUP_DIR/app_*.db | wc -l)"
|
||||||
|
volumeMounts:
|
||||||
|
- name: data
|
||||||
|
mountPath: /data
|
||||||
|
volumes:
|
||||||
|
- name: data
|
||||||
|
persistentVolumeClaim:
|
||||||
|
claimName: APP_NAME-data
|
||||||
|
restartPolicy: OnFailure
|
||||||
|
imagePullSecrets:
|
||||||
|
- name: regcred
|
||||||
47
deploy/deployment.yaml
Normal file
47
deploy/deployment.yaml
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
apiVersion: apps/v1
|
||||||
|
kind: Deployment
|
||||||
|
metadata:
|
||||||
|
name: APP_NAME
|
||||||
|
namespace: APP_NAME
|
||||||
|
spec:
|
||||||
|
replicas: 1
|
||||||
|
selector:
|
||||||
|
matchLabels:
|
||||||
|
app: APP_NAME
|
||||||
|
template:
|
||||||
|
metadata:
|
||||||
|
labels:
|
||||||
|
app: APP_NAME
|
||||||
|
spec:
|
||||||
|
imagePullSecrets:
|
||||||
|
- name: regcred
|
||||||
|
containers:
|
||||||
|
- name: APP_NAME
|
||||||
|
image: registry.oci.euphon.net/APP_NAME:latest
|
||||||
|
imagePullPolicy: Always
|
||||||
|
ports:
|
||||||
|
- containerPort: 8000
|
||||||
|
env:
|
||||||
|
- name: DB_PATH
|
||||||
|
value: /data/app.db
|
||||||
|
- name: FRONTEND_DIR
|
||||||
|
value: /app/frontend
|
||||||
|
- name: ADMIN_TOKEN
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef:
|
||||||
|
name: APP_NAME-secrets
|
||||||
|
key: admin-token
|
||||||
|
volumeMounts:
|
||||||
|
- name: data
|
||||||
|
mountPath: /data
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
memory: "64Mi"
|
||||||
|
cpu: "50m"
|
||||||
|
limits:
|
||||||
|
memory: "256Mi"
|
||||||
|
cpu: "500m"
|
||||||
|
volumes:
|
||||||
|
- name: data
|
||||||
|
persistentVolumeClaim:
|
||||||
|
claimName: APP_NAME-data
|
||||||
23
deploy/ingress.yaml
Normal file
23
deploy/ingress.yaml
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
apiVersion: networking.k8s.io/v1
|
||||||
|
kind: Ingress
|
||||||
|
metadata:
|
||||||
|
name: APP_NAME
|
||||||
|
namespace: APP_NAME
|
||||||
|
annotations:
|
||||||
|
traefik.ingress.kubernetes.io/router.tls.certresolver: le
|
||||||
|
spec:
|
||||||
|
ingressClassName: traefik
|
||||||
|
rules:
|
||||||
|
- host: APP_NAME.oci.euphon.net
|
||||||
|
http:
|
||||||
|
paths:
|
||||||
|
- path: /
|
||||||
|
pathType: Prefix
|
||||||
|
backend:
|
||||||
|
service:
|
||||||
|
name: APP_NAME
|
||||||
|
port:
|
||||||
|
number: 80
|
||||||
|
tls:
|
||||||
|
- hosts:
|
||||||
|
- APP_NAME.oci.euphon.net
|
||||||
4
deploy/namespace.yaml
Normal file
4
deploy/namespace.yaml
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
apiVersion: v1
|
||||||
|
kind: Namespace
|
||||||
|
metadata:
|
||||||
|
name: APP_NAME
|
||||||
11
deploy/pvc.yaml
Normal file
11
deploy/pvc.yaml
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
apiVersion: v1
|
||||||
|
kind: PersistentVolumeClaim
|
||||||
|
metadata:
|
||||||
|
name: APP_NAME-data
|
||||||
|
namespace: APP_NAME
|
||||||
|
spec:
|
||||||
|
accessModes:
|
||||||
|
- ReadWriteOnce
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
storage: 1Gi
|
||||||
11
deploy/service.yaml
Normal file
11
deploy/service.yaml
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
apiVersion: v1
|
||||||
|
kind: Service
|
||||||
|
metadata:
|
||||||
|
name: APP_NAME
|
||||||
|
namespace: APP_NAME
|
||||||
|
spec:
|
||||||
|
selector:
|
||||||
|
app: APP_NAME
|
||||||
|
ports:
|
||||||
|
- port: 80
|
||||||
|
targetPort: 8000
|
||||||
13
frontend/cypress.config.js
Normal file
13
frontend/cypress.config.js
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
import { defineConfig } from 'cypress'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
e2e: {
|
||||||
|
baseUrl: 'http://localhost:5173',
|
||||||
|
supportFile: 'cypress/support/e2e.js',
|
||||||
|
specPattern: 'cypress/e2e/**/*.cy.{js,ts}',
|
||||||
|
viewportWidth: 1280,
|
||||||
|
viewportHeight: 800,
|
||||||
|
video: true,
|
||||||
|
videoCompression: false,
|
||||||
|
},
|
||||||
|
})
|
||||||
10
frontend/cypress/e2e/smoke.cy.js
Normal file
10
frontend/cypress/e2e/smoke.cy.js
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
describe('Smoke test', () => {
|
||||||
|
it('loads the home page', () => {
|
||||||
|
cy.visit('/')
|
||||||
|
cy.contains('Welcome')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('health check API responds', () => {
|
||||||
|
cy.request('GET', '/api/health').its('status').should('eq', 200)
|
||||||
|
})
|
||||||
|
})
|
||||||
23
frontend/cypress/support/e2e.js
Normal file
23
frontend/cypress/support/e2e.js
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
Cypress.on('uncaught:exception', (err) => {
|
||||||
|
if (err.message.includes('ResizeObserver')) return false
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
|
||||||
|
// Login as admin via token injection
|
||||||
|
Cypress.Commands.add('loginAsAdmin', () => {
|
||||||
|
cy.request('GET', '/api/users').then((res) => {
|
||||||
|
const admin = res.body.find(u => u.role === 'admin')
|
||||||
|
if (admin) {
|
||||||
|
cy.window().then(win => {
|
||||||
|
win.localStorage.setItem('auth_token', admin.token)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// Login with a specific token
|
||||||
|
Cypress.Commands.add('loginWithToken', (token) => {
|
||||||
|
cy.window().then(win => {
|
||||||
|
win.localStorage.setItem('auth_token', token)
|
||||||
|
})
|
||||||
|
})
|
||||||
12
frontend/index.html
Normal file
12
frontend/index.html
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>App</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/main.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
29
frontend/package.json
Normal file
29
frontend/package.json
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
{
|
||||||
|
"name": "frontend",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"cy:open": "cypress open",
|
||||||
|
"cy:run": "cypress run",
|
||||||
|
"test:e2e": "cypress run",
|
||||||
|
"test:unit": "vitest run",
|
||||||
|
"test": "vitest run && cypress run"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"pinia": "^2.3.1",
|
||||||
|
"vue": "^3.5.32",
|
||||||
|
"vue-router": "^4.6.4"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@vitejs/plugin-vue": "^6.0.5",
|
||||||
|
"@vue/test-utils": "^2.4.6",
|
||||||
|
"cypress": "^15.13.0",
|
||||||
|
"jsdom": "^29.0.1",
|
||||||
|
"vite": "^8.0.4",
|
||||||
|
"vitest": "^4.1.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
41
frontend/src/App.vue
Normal file
41
frontend/src/App.vue
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
<template>
|
||||||
|
<div class="app">
|
||||||
|
<header class="app-header">
|
||||||
|
<div class="header-inner">
|
||||||
|
<h1 class="header-title">App</h1>
|
||||||
|
<nav class="nav-tabs">
|
||||||
|
<router-link
|
||||||
|
v-for="tab in tabs"
|
||||||
|
:key="tab.to"
|
||||||
|
:to="tab.to"
|
||||||
|
class="nav-tab"
|
||||||
|
active-class="active"
|
||||||
|
>{{ tab.label }}</router-link>
|
||||||
|
</nav>
|
||||||
|
<div class="header-right">
|
||||||
|
<template v-if="auth.isLoggedIn">
|
||||||
|
<span class="user-name">{{ auth.user.display_name || auth.user.username }}</span>
|
||||||
|
<button class="btn-link" @click="auth.logout()">退出</button>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<router-link to="/login" class="btn-link">登录</router-link>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<main class="app-main">
|
||||||
|
<router-view />
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { useAuthStore } from './stores/auth'
|
||||||
|
|
||||||
|
const auth = useAuthStore()
|
||||||
|
auth.initToken()
|
||||||
|
|
||||||
|
const tabs = [
|
||||||
|
{ to: '/', label: '首页' },
|
||||||
|
]
|
||||||
|
</script>
|
||||||
149
frontend/src/assets/styles.css
Normal file
149
frontend/src/assets/styles.css
Normal file
@ -0,0 +1,149 @@
|
|||||||
|
/* ── Design tokens ─────────────────────────────────── */
|
||||||
|
:root {
|
||||||
|
--color-primary: #4a7c59;
|
||||||
|
--color-primary-dark: #3a6349;
|
||||||
|
--color-accent: #c9a84c;
|
||||||
|
--color-bg: #faf6f0;
|
||||||
|
--color-bg-card: #ffffff;
|
||||||
|
--color-text: #333333;
|
||||||
|
--color-text-muted: #888888;
|
||||||
|
--color-border: #e0d8cc;
|
||||||
|
--color-danger: #d32f2f;
|
||||||
|
--shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.08);
|
||||||
|
--shadow-md: 0 2px 8px rgba(0, 0, 0, 0.12);
|
||||||
|
--radius: 8px;
|
||||||
|
--font-sans: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Hiragino Sans GB', sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Reset ────────────────────────────────────────── */
|
||||||
|
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
background: var(--color-bg);
|
||||||
|
color: var(--color-text);
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Layout ───────────────────────────────────────── */
|
||||||
|
.app-header {
|
||||||
|
background: var(--color-primary);
|
||||||
|
color: white;
|
||||||
|
padding: 12px 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-inner {
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-title {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-tabs {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-tab {
|
||||||
|
padding: 6px 14px;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
color: rgba(255, 255, 255, 0.8);
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 14px;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-tab:hover { background: rgba(255, 255, 255, 0.15); }
|
||||||
|
.nav-tab.active { background: rgba(255, 255, 255, 0.25); color: white; }
|
||||||
|
|
||||||
|
.header-right {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
font-size: 14px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-main {
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 20px auto;
|
||||||
|
padding: 0 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Components ───────────────────────────────────── */
|
||||||
|
.page { padding: 20px 0; }
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background: var(--color-primary);
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
padding: 8px 20px;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover { background: var(--color-primary-dark); }
|
||||||
|
|
||||||
|
.btn-link {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
text-decoration: underline;
|
||||||
|
font-size: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group {
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group label {
|
||||||
|
display: block;
|
||||||
|
font-size: 14px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error { color: var(--color-danger); font-size: 13px; }
|
||||||
|
|
||||||
|
.card {
|
||||||
|
background: var(--color-bg-card);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast {
|
||||||
|
position: fixed;
|
||||||
|
top: 20px;
|
||||||
|
right: 20px;
|
||||||
|
background: #333;
|
||||||
|
color: white;
|
||||||
|
padding: 10px 20px;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
z-index: 9999;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Responsive ───────────────────────────────────── */
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.header-inner { flex-wrap: wrap; }
|
||||||
|
.nav-tabs { order: 3; width: 100%; overflow-x: auto; }
|
||||||
|
}
|
||||||
51
frontend/src/composables/useApi.js
Normal file
51
frontend/src/composables/useApi.js
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
const API_BASE = '' // same origin, uses vite proxy in dev
|
||||||
|
|
||||||
|
export function getToken() {
|
||||||
|
return localStorage.getItem('auth_token') || ''
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setToken(token) {
|
||||||
|
if (token) localStorage.setItem('auth_token', token)
|
||||||
|
else localStorage.removeItem('auth_token')
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildHeaders(extra = {}) {
|
||||||
|
const headers = { 'Content-Type': 'application/json', ...extra }
|
||||||
|
const token = getToken()
|
||||||
|
if (token) headers['Authorization'] = 'Bearer ' + token
|
||||||
|
return headers
|
||||||
|
}
|
||||||
|
|
||||||
|
async function request(path, opts = {}) {
|
||||||
|
const headers = buildHeaders(opts.headers)
|
||||||
|
const res = await fetch(API_BASE + path, { ...opts, headers })
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
|
||||||
|
async function requestJSON(path, opts = {}) {
|
||||||
|
const res = await request(path, opts)
|
||||||
|
if (!res.ok) {
|
||||||
|
let msg = `${res.status}`
|
||||||
|
try {
|
||||||
|
const body = await res.json()
|
||||||
|
msg = body.detail || body.message || msg
|
||||||
|
} catch {}
|
||||||
|
const err = new Error(msg)
|
||||||
|
err.status = res.status
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
return res.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
function apiFn(path, opts = {}) {
|
||||||
|
return request(path, opts)
|
||||||
|
}
|
||||||
|
|
||||||
|
apiFn.raw = request
|
||||||
|
apiFn.get = (path) => requestJSON(path)
|
||||||
|
apiFn.post = (path, body) => requestJSON(path, { method: 'POST', body: JSON.stringify(body) })
|
||||||
|
apiFn.put = (path, body) => requestJSON(path, { method: 'PUT', body: JSON.stringify(body) })
|
||||||
|
apiFn.del = (path) => requestJSON(path, { method: 'DELETE' })
|
||||||
|
apiFn.delete = (path) => requestJSON(path, { method: 'DELETE' })
|
||||||
|
|
||||||
|
export const api = apiFn
|
||||||
10
frontend/src/main.js
Normal file
10
frontend/src/main.js
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
import { createApp } from 'vue'
|
||||||
|
import { createPinia } from 'pinia'
|
||||||
|
import App from './App.vue'
|
||||||
|
import router from './router'
|
||||||
|
import './assets/styles.css'
|
||||||
|
|
||||||
|
const app = createApp(App)
|
||||||
|
app.use(createPinia())
|
||||||
|
app.use(router)
|
||||||
|
app.mount('#app')
|
||||||
21
frontend/src/router/index.js
Normal file
21
frontend/src/router/index.js
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
import { createRouter, createWebHistory } from 'vue-router'
|
||||||
|
|
||||||
|
const routes = [
|
||||||
|
{
|
||||||
|
path: '/',
|
||||||
|
name: 'Home',
|
||||||
|
component: () => import('../views/Home.vue'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/login',
|
||||||
|
name: 'Login',
|
||||||
|
component: () => import('../views/Login.vue'),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const router = createRouter({
|
||||||
|
history: createWebHistory(),
|
||||||
|
routes,
|
||||||
|
})
|
||||||
|
|
||||||
|
export default router
|
||||||
80
frontend/src/stores/auth.js
Normal file
80
frontend/src/stores/auth.js
Normal file
@ -0,0 +1,80 @@
|
|||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { ref, computed } from 'vue'
|
||||||
|
import { api, setToken } from '../composables/useApi'
|
||||||
|
|
||||||
|
const DEFAULT_USER = {
|
||||||
|
id: null,
|
||||||
|
role: 'viewer',
|
||||||
|
username: 'anonymous',
|
||||||
|
display_name: '匿名',
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useAuthStore = defineStore('auth', () => {
|
||||||
|
const token = ref(localStorage.getItem('auth_token') || '')
|
||||||
|
const user = ref({ ...DEFAULT_USER })
|
||||||
|
|
||||||
|
const isLoggedIn = computed(() => user.value.id !== null)
|
||||||
|
const isAdmin = computed(() => user.value.role === 'admin')
|
||||||
|
const canEdit = computed(() =>
|
||||||
|
['editor', 'admin'].includes(user.value.role)
|
||||||
|
)
|
||||||
|
|
||||||
|
async function initToken() {
|
||||||
|
const params = new URLSearchParams(window.location.search)
|
||||||
|
const urlToken = params.get('token')
|
||||||
|
if (urlToken) {
|
||||||
|
token.value = urlToken
|
||||||
|
setToken(urlToken)
|
||||||
|
const url = new URL(window.location)
|
||||||
|
url.searchParams.delete('token')
|
||||||
|
window.history.replaceState({}, '', url)
|
||||||
|
}
|
||||||
|
if (token.value) {
|
||||||
|
await loadMe()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadMe() {
|
||||||
|
try {
|
||||||
|
const data = await api.get('/api/me')
|
||||||
|
user.value = {
|
||||||
|
id: data.id,
|
||||||
|
role: data.role,
|
||||||
|
username: data.username,
|
||||||
|
display_name: data.display_name,
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
logout()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function login(username, password) {
|
||||||
|
const data = await api.post('/api/login', { username, password })
|
||||||
|
token.value = data.token
|
||||||
|
setToken(data.token)
|
||||||
|
await loadMe()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function register(username, password, displayName) {
|
||||||
|
const data = await api.post('/api/register', {
|
||||||
|
username,
|
||||||
|
password,
|
||||||
|
display_name: displayName,
|
||||||
|
})
|
||||||
|
token.value = data.token
|
||||||
|
setToken(data.token)
|
||||||
|
await loadMe()
|
||||||
|
}
|
||||||
|
|
||||||
|
function logout() {
|
||||||
|
token.value = ''
|
||||||
|
setToken(null)
|
||||||
|
user.value = { ...DEFAULT_USER }
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
token, user,
|
||||||
|
isLoggedIn, isAdmin, canEdit,
|
||||||
|
initToken, loadMe, login, register, logout,
|
||||||
|
}
|
||||||
|
})
|
||||||
6
frontend/src/views/Home.vue
Normal file
6
frontend/src/views/Home.vue
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
<template>
|
||||||
|
<div class="page">
|
||||||
|
<h2>Welcome</h2>
|
||||||
|
<p>App is running.</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
39
frontend/src/views/Login.vue
Normal file
39
frontend/src/views/Login.vue
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
<template>
|
||||||
|
<div class="page login-page">
|
||||||
|
<h2>登录</h2>
|
||||||
|
<form @submit.prevent="handleLogin">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>用户名</label>
|
||||||
|
<input v-model="username" required />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>密码</label>
|
||||||
|
<input v-model="password" type="password" required />
|
||||||
|
</div>
|
||||||
|
<p v-if="error" class="error">{{ error }}</p>
|
||||||
|
<button type="submit" class="btn-primary">登录</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { useAuthStore } from '../stores/auth'
|
||||||
|
|
||||||
|
const auth = useAuthStore()
|
||||||
|
const router = useRouter()
|
||||||
|
const username = ref('')
|
||||||
|
const password = ref('')
|
||||||
|
const error = ref('')
|
||||||
|
|
||||||
|
async function handleLogin() {
|
||||||
|
error.value = ''
|
||||||
|
try {
|
||||||
|
await auth.login(username.value, password.value)
|
||||||
|
router.push('/')
|
||||||
|
} catch (e) {
|
||||||
|
error.value = e.message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
18
frontend/vite.config.js
Normal file
18
frontend/vite.config.js
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import vue from '@vitejs/plugin-vue'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [vue()],
|
||||||
|
server: {
|
||||||
|
proxy: {
|
||||||
|
'/api': 'http://localhost:8000',
|
||||||
|
}
|
||||||
|
},
|
||||||
|
build: {
|
||||||
|
outDir: 'dist'
|
||||||
|
},
|
||||||
|
test: {
|
||||||
|
environment: 'jsdom',
|
||||||
|
globals: true,
|
||||||
|
}
|
||||||
|
})
|
||||||
255
scripts/deploy-preview.py
Normal file
255
scripts/deploy-preview.py
Normal file
@ -0,0 +1,255 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Deploy or teardown a PR preview environment on local k3s.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python3 scripts/deploy-preview.py deploy <PR_ID>
|
||||||
|
python3 scripts/deploy-preview.py teardown <PR_ID>
|
||||||
|
python3 scripts/deploy-preview.py deploy-prod
|
||||||
|
"""
|
||||||
|
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import json
|
||||||
|
import tempfile
|
||||||
|
import textwrap
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# ── Configuration ─────────────────────────────────────
|
||||||
|
# Change these for your project:
|
||||||
|
REGISTRY = "registry.oci.euphon.net"
|
||||||
|
APP_NAME = "APP_NAME"
|
||||||
|
BASE_DOMAIN = f"{APP_NAME}.oci.euphon.net"
|
||||||
|
PROD_NS = APP_NAME
|
||||||
|
|
||||||
|
|
||||||
|
def run(cmd: list[str] | str, *, check=True, capture=False) -> subprocess.CompletedProcess:
|
||||||
|
if isinstance(cmd, str):
|
||||||
|
cmd = ["sh", "-c", cmd]
|
||||||
|
display = " ".join(cmd) if isinstance(cmd, list) else cmd
|
||||||
|
print(f" $ {display}")
|
||||||
|
r = subprocess.run(cmd, text=True, capture_output=capture)
|
||||||
|
if capture and r.stdout.strip():
|
||||||
|
for line in r.stdout.strip().splitlines()[:5]:
|
||||||
|
print(f" {line}")
|
||||||
|
if check and r.returncode != 0:
|
||||||
|
print(f" FAILED (exit {r.returncode})")
|
||||||
|
if capture and r.stderr.strip():
|
||||||
|
print(f" {r.stderr.strip()[:200]}")
|
||||||
|
sys.exit(1)
|
||||||
|
return r
|
||||||
|
|
||||||
|
|
||||||
|
def kubectl(*args, capture=False, check=True) -> subprocess.CompletedProcess:
|
||||||
|
return run(["sudo", "k3s", "kubectl", *args], capture=capture, check=check)
|
||||||
|
|
||||||
|
|
||||||
|
def docker(*args, check=True) -> subprocess.CompletedProcess:
|
||||||
|
return run(["docker", *args], check=check)
|
||||||
|
|
||||||
|
|
||||||
|
def write_temp(content: str, suffix=".yaml") -> Path:
|
||||||
|
f = tempfile.NamedTemporaryFile(mode="w", suffix=suffix, delete=False)
|
||||||
|
f.write(content)
|
||||||
|
f.close()
|
||||||
|
return Path(f.name)
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Deploy PR Preview ─────────────────────────────────
|
||||||
|
|
||||||
|
def deploy(pr_id: str):
|
||||||
|
ns = f"{APP_NAME}-pr-{pr_id}"
|
||||||
|
host = f"pr-{pr_id}.{BASE_DOMAIN}"
|
||||||
|
image = f"{REGISTRY}/{APP_NAME}:pr-{pr_id}"
|
||||||
|
|
||||||
|
print(f"\n{'='*60}")
|
||||||
|
print(f" Deploying: https://{host}")
|
||||||
|
print(f" Namespace: {ns}")
|
||||||
|
print(f"{'='*60}\n")
|
||||||
|
|
||||||
|
# 1. Copy production DB
|
||||||
|
print("[1/5] Copying production database...")
|
||||||
|
Path("data").mkdir(exist_ok=True)
|
||||||
|
prod_pod = kubectl(
|
||||||
|
"get", "pods", "-n", PROD_NS,
|
||||||
|
"-l", f"app={APP_NAME}",
|
||||||
|
"--field-selector=status.phase=Running",
|
||||||
|
"-o", "jsonpath={.items[0].metadata.name}",
|
||||||
|
capture=True, check=False
|
||||||
|
).stdout.strip()
|
||||||
|
|
||||||
|
if prod_pod:
|
||||||
|
kubectl("cp", f"{PROD_NS}/{prod_pod}:/data/app.db", "data/app.db")
|
||||||
|
else:
|
||||||
|
print(" WARNING: No running prod pod, using empty DB")
|
||||||
|
Path("data/app.db").touch()
|
||||||
|
|
||||||
|
# 2. Build and push image
|
||||||
|
print("[2/5] Building Docker image...")
|
||||||
|
dockerfile = textwrap.dedent(f"""\
|
||||||
|
FROM node:20-slim AS frontend-build
|
||||||
|
WORKDIR /build
|
||||||
|
COPY frontend/package.json frontend/package-lock.json ./
|
||||||
|
RUN npm ci
|
||||||
|
COPY frontend/ ./
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
FROM python:3.12-slim
|
||||||
|
WORKDIR /app
|
||||||
|
COPY backend/requirements.txt .
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
COPY backend/ ./backend/
|
||||||
|
COPY --from=frontend-build /build/dist ./frontend/
|
||||||
|
COPY data/app.db /data/app.db
|
||||||
|
ENV DB_PATH=/data/app.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...")
|
||||||
|
run(f"sudo k3s kubectl create namespace {ns} --dry-run=client -o yaml | sudo k3s kubectl apply -f -")
|
||||||
|
|
||||||
|
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: {APP_NAME}
|
||||||
|
namespace: {ns}
|
||||||
|
spec:
|
||||||
|
replicas: 1
|
||||||
|
selector:
|
||||||
|
matchLabels:
|
||||||
|
app: {APP_NAME}
|
||||||
|
template:
|
||||||
|
metadata:
|
||||||
|
labels:
|
||||||
|
app: {APP_NAME}
|
||||||
|
spec:
|
||||||
|
imagePullSecrets:
|
||||||
|
- name: regcred
|
||||||
|
containers:
|
||||||
|
- name: app
|
||||||
|
image: {image}
|
||||||
|
imagePullPolicy: Always
|
||||||
|
ports:
|
||||||
|
- containerPort: 8000
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
cpu: 50m
|
||||||
|
memory: 64Mi
|
||||||
|
limits:
|
||||||
|
cpu: 500m
|
||||||
|
memory: 256Mi
|
||||||
|
---
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Service
|
||||||
|
metadata:
|
||||||
|
name: {APP_NAME}
|
||||||
|
namespace: {ns}
|
||||||
|
spec:
|
||||||
|
selector:
|
||||||
|
app: {APP_NAME}
|
||||||
|
ports:
|
||||||
|
- port: 80
|
||||||
|
targetPort: 8000
|
||||||
|
---
|
||||||
|
apiVersion: networking.k8s.io/v1
|
||||||
|
kind: Ingress
|
||||||
|
metadata:
|
||||||
|
name: {APP_NAME}
|
||||||
|
namespace: {ns}
|
||||||
|
annotations:
|
||||||
|
traefik.ingress.kubernetes.io/router.tls.certresolver: le
|
||||||
|
spec:
|
||||||
|
ingressClassName: traefik
|
||||||
|
tls:
|
||||||
|
- hosts:
|
||||||
|
- {host}
|
||||||
|
rules:
|
||||||
|
- host: {host}
|
||||||
|
http:
|
||||||
|
paths:
|
||||||
|
- path: /
|
||||||
|
pathType: Prefix
|
||||||
|
backend:
|
||||||
|
service:
|
||||||
|
name: {APP_NAME}
|
||||||
|
port:
|
||||||
|
number: 80
|
||||||
|
""")
|
||||||
|
p = write_temp(manifests)
|
||||||
|
kubectl("apply", "-f", str(p))
|
||||||
|
p.unlink()
|
||||||
|
|
||||||
|
# 5. Restart and wait
|
||||||
|
print("[5/5] Restarting deployment...")
|
||||||
|
kubectl("rollout", "restart", f"deploy/{APP_NAME}", "-n", ns)
|
||||||
|
kubectl("rollout", "status", f"deploy/{APP_NAME}", "-n", ns, "--timeout=120s")
|
||||||
|
|
||||||
|
run("rm -rf data/app.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"{APP_NAME}-pr-{pr_id}"
|
||||||
|
image = f"{REGISTRY}/{APP_NAME}:pr-{pr_id}"
|
||||||
|
|
||||||
|
print(f"\n Tearing down: {ns}")
|
||||||
|
kubectl("delete", "namespace", ns, "--ignore-not-found")
|
||||||
|
docker("rmi", image, check=False)
|
||||||
|
print(" Done.\n")
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Deploy Production ─────────────────────────────────
|
||||||
|
|
||||||
|
def deploy_prod():
|
||||||
|
image = f"{REGISTRY}/{APP_NAME}:latest"
|
||||||
|
|
||||||
|
print(f"\n{'='*60}")
|
||||||
|
print(f" Deploying production: https://{BASE_DOMAIN}")
|
||||||
|
print(f"{'='*60}\n")
|
||||||
|
|
||||||
|
docker("build", "-t", image, ".")
|
||||||
|
docker("push", image)
|
||||||
|
kubectl("rollout", "restart", f"deploy/{APP_NAME}", "-n", PROD_NS)
|
||||||
|
kubectl("rollout", "status", f"deploy/{APP_NAME}", "-n", PROD_NS, "--timeout=120s")
|
||||||
|
|
||||||
|
print(f"\n Production deployed: https://{BASE_DOMAIN}\n")
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Main ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
if len(sys.argv) < 2:
|
||||||
|
print(__doc__)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
action = sys.argv[1]
|
||||||
|
if action == "deploy" and len(sys.argv) >= 3:
|
||||||
|
deploy(sys.argv[2])
|
||||||
|
elif action == "teardown" and len(sys.argv) >= 3:
|
||||||
|
teardown(sys.argv[2])
|
||||||
|
elif action == "deploy-prod":
|
||||||
|
deploy_prod()
|
||||||
|
else:
|
||||||
|
print(__doc__)
|
||||||
|
sys.exit(1)
|
||||||
Loading…
x
Reference in New Issue
Block a user