Initial commit: Essential Oil Formula Cost Calculator
This commit is contained in:
6
.gitignore
vendored
Normal file
6
.gitignore
vendored
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
.DS_Store
|
||||||
|
*.pyc
|
||||||
|
__pycache__/
|
||||||
|
deploy/kubeconfig
|
||||||
|
all_recipes_extracted.json
|
||||||
|
backups/
|
||||||
16
Dockerfile
Normal file
16
Dockerfile
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
FROM python:3.12-slim
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY backend/requirements.txt .
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
COPY backend/ ./backend/
|
||||||
|
COPY frontend/ ./frontend/
|
||||||
|
|
||||||
|
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"]
|
||||||
306
backend/database.py
Normal file
306
backend/database.py
Normal file
@@ -0,0 +1,306 @@
|
|||||||
|
import sqlite3
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import secrets
|
||||||
|
|
||||||
|
DB_PATH = os.environ.get("DB_PATH", "/data/oil_calculator.db")
|
||||||
|
|
||||||
|
|
||||||
|
def get_db():
|
||||||
|
conn = sqlite3.connect(DB_PATH)
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
conn.execute("PRAGMA journal_mode=WAL")
|
||||||
|
conn.execute("PRAGMA foreign_keys=ON")
|
||||||
|
return conn
|
||||||
|
|
||||||
|
|
||||||
|
def init_db():
|
||||||
|
os.makedirs(os.path.dirname(DB_PATH), exist_ok=True)
|
||||||
|
conn = get_db()
|
||||||
|
c = conn.cursor()
|
||||||
|
c.executescript("""
|
||||||
|
CREATE TABLE IF NOT EXISTS oils (
|
||||||
|
name TEXT PRIMARY KEY,
|
||||||
|
bottle_price REAL NOT NULL,
|
||||||
|
drop_count INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS recipes (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
note TEXT DEFAULT '',
|
||||||
|
sort_order INTEGER DEFAULT 0
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS recipe_ingredients (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
recipe_id INTEGER NOT NULL REFERENCES recipes(id) ON DELETE CASCADE,
|
||||||
|
oil_name TEXT NOT NULL,
|
||||||
|
drops REAL NOT NULL
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS tags (
|
||||||
|
name TEXT PRIMARY KEY
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS recipe_tags (
|
||||||
|
recipe_id INTEGER NOT NULL REFERENCES recipes(id) ON DELETE CASCADE,
|
||||||
|
tag_name TEXT NOT NULL,
|
||||||
|
PRIMARY KEY (recipe_id, tag_name)
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
username TEXT UNIQUE NOT NULL,
|
||||||
|
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'))
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS user_inventory (
|
||||||
|
user_id INTEGER NOT NULL,
|
||||||
|
oil_name TEXT NOT NULL,
|
||||||
|
PRIMARY KEY (user_id, oil_name)
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS profit_projects (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
ingredients TEXT NOT NULL DEFAULT '[]',
|
||||||
|
pricing REAL DEFAULT 0,
|
||||||
|
note TEXT DEFAULT '',
|
||||||
|
created_by INTEGER,
|
||||||
|
created_at TEXT DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS user_diary (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
user_id INTEGER NOT NULL,
|
||||||
|
source_recipe_id INTEGER,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
ingredients TEXT NOT NULL DEFAULT '[]',
|
||||||
|
note TEXT DEFAULT '',
|
||||||
|
created_at TEXT DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS diary_entries (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
diary_id INTEGER NOT NULL REFERENCES user_diary(id) ON DELETE CASCADE,
|
||||||
|
content TEXT NOT NULL,
|
||||||
|
created_at TEXT DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS user_favorites (
|
||||||
|
user_id INTEGER NOT NULL,
|
||||||
|
recipe_id INTEGER NOT NULL,
|
||||||
|
created_at TEXT DEFAULT (datetime('now')),
|
||||||
|
PRIMARY KEY (user_id, recipe_id)
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS search_log (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
user_id INTEGER,
|
||||||
|
query TEXT NOT NULL,
|
||||||
|
matched_count INTEGER DEFAULT 0,
|
||||||
|
created_at TEXT DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS notifications (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
target_role TEXT NOT NULL DEFAULT 'admin',
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
body TEXT,
|
||||||
|
is_read INTEGER DEFAULT 0,
|
||||||
|
created_at TEXT DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS category_modules (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
subtitle TEXT DEFAULT '',
|
||||||
|
icon TEXT DEFAULT '🌿',
|
||||||
|
bg_image TEXT DEFAULT '',
|
||||||
|
color_from TEXT DEFAULT '#7a9e7e',
|
||||||
|
color_to TEXT DEFAULT '#5a7d5e',
|
||||||
|
tag_name TEXT NOT NULL,
|
||||||
|
sort_order INTEGER DEFAULT 0
|
||||||
|
);
|
||||||
|
""")
|
||||||
|
|
||||||
|
# Migration: add password and brand fields to users if missing
|
||||||
|
user_cols = [row[1] for row in c.execute("PRAGMA table_info(users)").fetchall()]
|
||||||
|
if "password" not in user_cols:
|
||||||
|
c.execute("ALTER TABLE users ADD COLUMN password TEXT")
|
||||||
|
|
||||||
|
# Create bug_reports table
|
||||||
|
c.execute("""CREATE TABLE IF NOT EXISTS bug_reports (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
user_id INTEGER,
|
||||||
|
content TEXT NOT NULL,
|
||||||
|
is_resolved INTEGER DEFAULT 0,
|
||||||
|
created_at TEXT DEFAULT (datetime('now'))
|
||||||
|
)""")
|
||||||
|
|
||||||
|
# Migration: add priority to bug_reports
|
||||||
|
bug_cols = [row[1] for row in c.execute("PRAGMA table_info(bug_reports)").fetchall()]
|
||||||
|
if bug_cols and "priority" not in bug_cols:
|
||||||
|
c.execute("ALTER TABLE bug_reports ADD COLUMN priority INTEGER DEFAULT 2")
|
||||||
|
|
||||||
|
# Create bug_comments table for activity log
|
||||||
|
c.execute("""CREATE TABLE IF NOT EXISTS bug_comments (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
bug_id INTEGER NOT NULL REFERENCES bug_reports(id) ON DELETE CASCADE,
|
||||||
|
user_id INTEGER,
|
||||||
|
action TEXT NOT NULL,
|
||||||
|
content TEXT DEFAULT '',
|
||||||
|
created_at TEXT DEFAULT (datetime('now'))
|
||||||
|
)""")
|
||||||
|
if "qr_code" not in user_cols:
|
||||||
|
c.execute("ALTER TABLE users ADD COLUMN qr_code TEXT")
|
||||||
|
if "brand_logo" not in user_cols:
|
||||||
|
c.execute("ALTER TABLE users ADD COLUMN brand_logo TEXT")
|
||||||
|
if "brand_name" not in user_cols:
|
||||||
|
c.execute("ALTER TABLE users ADD COLUMN brand_name TEXT")
|
||||||
|
if "brand_bg" not in user_cols:
|
||||||
|
c.execute("ALTER TABLE users ADD COLUMN brand_bg TEXT")
|
||||||
|
if "brand_align" not in user_cols:
|
||||||
|
c.execute("ALTER TABLE users ADD COLUMN brand_align TEXT DEFAULT 'center'")
|
||||||
|
|
||||||
|
# Migration: add tags to user_diary
|
||||||
|
diary_cols = [row[1] for row in c.execute("PRAGMA table_info(user_diary)").fetchall()]
|
||||||
|
if diary_cols and "tags" not in diary_cols:
|
||||||
|
c.execute("ALTER TABLE user_diary ADD COLUMN tags TEXT DEFAULT '[]'")
|
||||||
|
|
||||||
|
# Migration: business verification
|
||||||
|
if "business_verified" not in user_cols:
|
||||||
|
c.execute("ALTER TABLE users ADD COLUMN business_verified INTEGER DEFAULT 0")
|
||||||
|
|
||||||
|
c.execute("""CREATE TABLE IF NOT EXISTS business_applications (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
user_id INTEGER NOT NULL,
|
||||||
|
business_name TEXT,
|
||||||
|
document TEXT,
|
||||||
|
status TEXT DEFAULT 'pending',
|
||||||
|
reject_reason TEXT DEFAULT '',
|
||||||
|
created_at TEXT DEFAULT (datetime('now')),
|
||||||
|
reviewed_at TEXT
|
||||||
|
)""")
|
||||||
|
|
||||||
|
# Translation suggestions table
|
||||||
|
c.execute("""CREATE TABLE IF NOT EXISTS translation_suggestions (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
recipe_id INTEGER,
|
||||||
|
recipe_name TEXT NOT NULL,
|
||||||
|
suggested_en TEXT NOT NULL,
|
||||||
|
user_id INTEGER,
|
||||||
|
status TEXT DEFAULT 'pending',
|
||||||
|
created_at TEXT DEFAULT (datetime('now'))
|
||||||
|
)""")
|
||||||
|
|
||||||
|
# Migration: add reject_reason to business_applications if missing
|
||||||
|
biz_cols = [row[1] for row in c.execute("PRAGMA table_info(business_applications)").fetchall()]
|
||||||
|
if biz_cols and "reject_reason" not in biz_cols:
|
||||||
|
c.execute("ALTER TABLE business_applications ADD COLUMN reject_reason TEXT DEFAULT ''")
|
||||||
|
|
||||||
|
# Migration: add target_user_id to notifications for user-specific notifications
|
||||||
|
notif_cols = [row[1] for row in c.execute("PRAGMA table_info(notifications)").fetchall()]
|
||||||
|
if "target_user_id" not in notif_cols:
|
||||||
|
c.execute("ALTER TABLE notifications ADD COLUMN target_user_id INTEGER")
|
||||||
|
|
||||||
|
# Migration: add assigned_to to bug_reports
|
||||||
|
bug_cols2 = [row[1] for row in c.execute("PRAGMA table_info(bug_reports)").fetchall()]
|
||||||
|
if "assigned_to" not in bug_cols2:
|
||||||
|
c.execute("ALTER TABLE bug_reports ADD COLUMN assigned_to INTEGER")
|
||||||
|
|
||||||
|
# Migration: add version to recipes for optimistic locking
|
||||||
|
recipe_cols = [row[1] for row in c.execute("PRAGMA table_info(recipes)").fetchall()]
|
||||||
|
if "version" not in recipe_cols:
|
||||||
|
c.execute("ALTER TABLE recipes ADD COLUMN version INTEGER DEFAULT 1")
|
||||||
|
|
||||||
|
# Migration: add retail_price and is_active to oils if missing
|
||||||
|
oil_cols = [row[1] for row in c.execute("PRAGMA table_info(oils)").fetchall()]
|
||||||
|
if "retail_price" not in oil_cols:
|
||||||
|
c.execute("ALTER TABLE oils ADD COLUMN retail_price REAL")
|
||||||
|
if "is_active" not in oil_cols:
|
||||||
|
c.execute("ALTER TABLE oils ADD COLUMN is_active INTEGER DEFAULT 1")
|
||||||
|
|
||||||
|
# Migration: add new columns to category_modules if missing
|
||||||
|
cat_cols = [row[1] for row in c.execute("PRAGMA table_info(category_modules)").fetchall()]
|
||||||
|
if cat_cols and "bg_image" not in cat_cols:
|
||||||
|
c.execute("DROP TABLE category_modules")
|
||||||
|
c.execute("""CREATE TABLE category_modules (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
name TEXT NOT NULL, subtitle TEXT DEFAULT '', icon TEXT DEFAULT '🌿',
|
||||||
|
bg_image TEXT DEFAULT '', color_from TEXT DEFAULT '#7a9e7e', color_to TEXT DEFAULT '#5a7d5e',
|
||||||
|
tag_name TEXT NOT NULL, sort_order INTEGER DEFAULT 0)""")
|
||||||
|
|
||||||
|
# Migration: add owner_id to recipes if missing
|
||||||
|
cols = [row[1] for row in c.execute("PRAGMA table_info(recipes)").fetchall()]
|
||||||
|
if "owner_id" not in cols:
|
||||||
|
c.execute("ALTER TABLE recipes ADD COLUMN owner_id INTEGER")
|
||||||
|
if "updated_by" not in cols:
|
||||||
|
c.execute("ALTER TABLE recipes ADD COLUMN updated_by INTEGER")
|
||||||
|
|
||||||
|
# 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 (?, ?, ?, ?)",
|
||||||
|
("hera", admin_token, "admin", "Hera"),
|
||||||
|
)
|
||||||
|
admin_id = c.lastrowid
|
||||||
|
# Assign all existing recipes to admin
|
||||||
|
c.execute("UPDATE recipes SET owner_id = ? WHERE owner_id IS NULL", (admin_id,))
|
||||||
|
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),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def seed_defaults(default_oils_meta: dict, default_recipes: list):
|
||||||
|
"""Seed DB with defaults if empty."""
|
||||||
|
conn = get_db()
|
||||||
|
c = conn.cursor()
|
||||||
|
|
||||||
|
# Seed oils
|
||||||
|
count = c.execute("SELECT COUNT(*) FROM oils").fetchone()[0]
|
||||||
|
if count == 0:
|
||||||
|
for name, meta in default_oils_meta.items():
|
||||||
|
c.execute(
|
||||||
|
"INSERT OR IGNORE INTO oils (name, bottle_price, drop_count) VALUES (?, ?, ?)",
|
||||||
|
(name, meta["bottlePrice"], meta["dropCount"]),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Seed recipes
|
||||||
|
count = c.execute("SELECT COUNT(*) FROM recipes").fetchone()[0]
|
||||||
|
if count == 0:
|
||||||
|
# Get admin user id for ownership
|
||||||
|
admin = c.execute("SELECT id FROM users WHERE role = 'admin' LIMIT 1").fetchone()
|
||||||
|
admin_id = admin[0] if admin else None
|
||||||
|
for r in default_recipes:
|
||||||
|
c.execute(
|
||||||
|
"INSERT INTO recipes (name, note, owner_id) VALUES (?, ?, ?)",
|
||||||
|
(r["name"], r.get("note", ""), admin_id),
|
||||||
|
)
|
||||||
|
rid = c.lastrowid
|
||||||
|
for ing in r["ingredients"]:
|
||||||
|
c.execute(
|
||||||
|
"INSERT INTO recipe_ingredients (recipe_id, oil_name, drops) VALUES (?, ?, ?)",
|
||||||
|
(rid, ing["oil"], ing["drops"]),
|
||||||
|
)
|
||||||
|
for tag in r.get("tags", []):
|
||||||
|
c.execute("INSERT OR IGNORE INTO tags (name) VALUES (?)", (tag,))
|
||||||
|
c.execute(
|
||||||
|
"INSERT OR IGNORE INTO recipe_tags (recipe_id, tag_name) VALUES (?, ?)",
|
||||||
|
(rid, tag),
|
||||||
|
)
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
8091
backend/defaults.json
Normal file
8091
backend/defaults.json
Normal file
File diff suppressed because it is too large
Load Diff
1497
backend/main.py
Normal file
1497
backend/main.py
Normal file
File diff suppressed because it is too large
Load Diff
3
backend/requirements.txt
Normal file
3
backend/requirements.txt
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
fastapi==0.115.6
|
||||||
|
uvicorn[standard]==0.34.0
|
||||||
|
aiosqlite==0.20.0
|
||||||
39
deploy/backup-cronjob.yaml
Normal file
39
deploy/backup-cronjob.yaml
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
apiVersion: batch/v1
|
||||||
|
kind: CronJob
|
||||||
|
metadata:
|
||||||
|
name: hourly-backup
|
||||||
|
namespace: oil-calculator
|
||||||
|
spec:
|
||||||
|
schedule: "0 * * * *" # Every hour
|
||||||
|
successfulJobsHistoryLimit: 3
|
||||||
|
failedJobsHistoryLimit: 2
|
||||||
|
jobTemplate:
|
||||||
|
spec:
|
||||||
|
template:
|
||||||
|
spec:
|
||||||
|
containers:
|
||||||
|
- name: backup
|
||||||
|
image: registry.oci.euphon.net/oil-calculator:latest
|
||||||
|
command:
|
||||||
|
- sh
|
||||||
|
- -c
|
||||||
|
- |
|
||||||
|
BACKUP_DIR=/data/backups
|
||||||
|
mkdir -p $BACKUP_DIR
|
||||||
|
DATE=$(date +%Y%m%d_%H%M%S)
|
||||||
|
# Backup SQLite database using .backup for consistency
|
||||||
|
sqlite3 /data/oil_calculator.db ".backup '$BACKUP_DIR/oil_calculator_${DATE}.db'"
|
||||||
|
echo "Backup done: $BACKUP_DIR/oil_calculator_${DATE}.db ($(du -h $BACKUP_DIR/oil_calculator_${DATE}.db | cut -f1))"
|
||||||
|
# Keep last 48 backups (2 days of hourly)
|
||||||
|
ls -t $BACKUP_DIR/oil_calculator_*.db | tail -n +49 | xargs rm -f 2>/dev/null
|
||||||
|
echo "Backups retained: $(ls $BACKUP_DIR/oil_calculator_*.db | wc -l)"
|
||||||
|
volumeMounts:
|
||||||
|
- name: data
|
||||||
|
mountPath: /data
|
||||||
|
volumes:
|
||||||
|
- name: data
|
||||||
|
persistentVolumeClaim:
|
||||||
|
claimName: oil-calculator-data
|
||||||
|
restartPolicy: OnFailure
|
||||||
|
imagePullSecrets:
|
||||||
|
- name: regcred
|
||||||
30
deploy/cronjob.yaml
Normal file
30
deploy/cronjob.yaml
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
apiVersion: batch/v1
|
||||||
|
kind: CronJob
|
||||||
|
metadata:
|
||||||
|
name: weekly-review
|
||||||
|
namespace: oil-calculator
|
||||||
|
spec:
|
||||||
|
schedule: "0 9 * * 1" # Every Monday 9:00 UTC (17:00 China time)
|
||||||
|
jobTemplate:
|
||||||
|
spec:
|
||||||
|
template:
|
||||||
|
spec:
|
||||||
|
containers:
|
||||||
|
- name: cron
|
||||||
|
image: curlimages/curl:latest
|
||||||
|
command:
|
||||||
|
- sh
|
||||||
|
- -c
|
||||||
|
- |
|
||||||
|
curl -sf -X POST \
|
||||||
|
-H "Authorization: Bearer $(ADMIN_TOKEN)" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{}' \
|
||||||
|
http://oil-calculator.oil-calculator.svc/api/cron/weekly-review
|
||||||
|
env:
|
||||||
|
- name: ADMIN_TOKEN
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef:
|
||||||
|
name: oil-calculator-secrets
|
||||||
|
key: admin-token
|
||||||
|
restartPolicy: OnFailure
|
||||||
47
deploy/deployment.yaml
Normal file
47
deploy/deployment.yaml
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
apiVersion: apps/v1
|
||||||
|
kind: Deployment
|
||||||
|
metadata:
|
||||||
|
name: oil-calculator
|
||||||
|
namespace: oil-calculator
|
||||||
|
spec:
|
||||||
|
replicas: 1
|
||||||
|
selector:
|
||||||
|
matchLabels:
|
||||||
|
app: oil-calculator
|
||||||
|
template:
|
||||||
|
metadata:
|
||||||
|
labels:
|
||||||
|
app: oil-calculator
|
||||||
|
spec:
|
||||||
|
imagePullSecrets:
|
||||||
|
- name: regcred
|
||||||
|
containers:
|
||||||
|
- name: oil-calculator
|
||||||
|
image: registry.oci.euphon.net/oil-calculator:latest
|
||||||
|
imagePullPolicy: Always
|
||||||
|
ports:
|
||||||
|
- containerPort: 8000
|
||||||
|
env:
|
||||||
|
- name: DB_PATH
|
||||||
|
value: /data/oil_calculator.db
|
||||||
|
- name: FRONTEND_DIR
|
||||||
|
value: /app/frontend
|
||||||
|
- name: ADMIN_TOKEN
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef:
|
||||||
|
name: oil-calculator-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: oil-calculator-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: oil-calculator
|
||||||
|
namespace: oil-calculator
|
||||||
|
annotations:
|
||||||
|
traefik.ingress.kubernetes.io/router.tls.certresolver: le
|
||||||
|
spec:
|
||||||
|
ingressClassName: traefik
|
||||||
|
rules:
|
||||||
|
- host: oil.oci.euphon.net
|
||||||
|
http:
|
||||||
|
paths:
|
||||||
|
- path: /
|
||||||
|
pathType: Prefix
|
||||||
|
backend:
|
||||||
|
service:
|
||||||
|
name: oil-calculator
|
||||||
|
port:
|
||||||
|
number: 80
|
||||||
|
tls:
|
||||||
|
- hosts:
|
||||||
|
- oil.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: oil-calculator
|
||||||
11
deploy/pvc.yaml
Normal file
11
deploy/pvc.yaml
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
apiVersion: v1
|
||||||
|
kind: PersistentVolumeClaim
|
||||||
|
metadata:
|
||||||
|
name: oil-calculator-data
|
||||||
|
namespace: oil-calculator
|
||||||
|
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: oil-calculator
|
||||||
|
namespace: oil-calculator
|
||||||
|
spec:
|
||||||
|
selector:
|
||||||
|
app: oil-calculator
|
||||||
|
ports:
|
||||||
|
- port: 80
|
||||||
|
targetPort: 8000
|
||||||
82
deploy/setup-kubeconfig.sh
Normal file
82
deploy/setup-kubeconfig.sh
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Creates a restricted kubeconfig for the oil-calculator namespace only.
|
||||||
|
# Run on the k8s server as a user with cluster-admin access.
|
||||||
|
set -e
|
||||||
|
|
||||||
|
NAMESPACE=oil-calculator
|
||||||
|
SA_NAME=oil-calculator-deployer
|
||||||
|
|
||||||
|
echo "Creating ServiceAccount, Role, and RoleBinding..."
|
||||||
|
|
||||||
|
kubectl apply -f - <<EOF
|
||||||
|
apiVersion: v1
|
||||||
|
kind: ServiceAccount
|
||||||
|
metadata:
|
||||||
|
name: ${SA_NAME}
|
||||||
|
namespace: ${NAMESPACE}
|
||||||
|
---
|
||||||
|
apiVersion: rbac.authorization.k8s.io/v1
|
||||||
|
kind: Role
|
||||||
|
metadata:
|
||||||
|
name: ${SA_NAME}-role
|
||||||
|
namespace: ${NAMESPACE}
|
||||||
|
rules:
|
||||||
|
- apiGroups: ["", "apps", "networking.k8s.io"]
|
||||||
|
resources: ["pods", "services", "deployments", "replicasets", "ingresses", "persistentvolumeclaims", "configmaps", "secrets", "pods/log"]
|
||||||
|
verbs: ["get", "list", "watch", "create", "update", "patch", "delete"]
|
||||||
|
---
|
||||||
|
apiVersion: rbac.authorization.k8s.io/v1
|
||||||
|
kind: RoleBinding
|
||||||
|
metadata:
|
||||||
|
name: ${SA_NAME}-binding
|
||||||
|
namespace: ${NAMESPACE}
|
||||||
|
subjects:
|
||||||
|
- kind: ServiceAccount
|
||||||
|
name: ${SA_NAME}
|
||||||
|
namespace: ${NAMESPACE}
|
||||||
|
roleRef:
|
||||||
|
kind: Role
|
||||||
|
name: ${SA_NAME}-role
|
||||||
|
apiGroup: rbac.authorization.k8s.io
|
||||||
|
---
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Secret
|
||||||
|
metadata:
|
||||||
|
name: ${SA_NAME}-token
|
||||||
|
namespace: ${NAMESPACE}
|
||||||
|
annotations:
|
||||||
|
kubernetes.io/service-account.name: ${SA_NAME}
|
||||||
|
type: kubernetes.io/service-account-token
|
||||||
|
EOF
|
||||||
|
|
||||||
|
echo "Waiting for token..."
|
||||||
|
sleep 3
|
||||||
|
|
||||||
|
# Get cluster info
|
||||||
|
CLUSTER_SERVER=$(kubectl config view --minify -o jsonpath='{.clusters[0].cluster.server}')
|
||||||
|
CLUSTER_CA=$(kubectl config view --raw --minify -o jsonpath='{.clusters[0].cluster.certificate-authority-data}')
|
||||||
|
TOKEN=$(kubectl get secret ${SA_NAME}-token -n ${NAMESPACE} -o jsonpath='{.data.token}' | base64 -d)
|
||||||
|
|
||||||
|
cat > kubeconfig <<EOF
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Config
|
||||||
|
clusters:
|
||||||
|
- cluster:
|
||||||
|
certificate-authority-data: ${CLUSTER_CA}
|
||||||
|
server: ${CLUSTER_SERVER}
|
||||||
|
name: oil-calculator
|
||||||
|
contexts:
|
||||||
|
- context:
|
||||||
|
cluster: oil-calculator
|
||||||
|
namespace: ${NAMESPACE}
|
||||||
|
user: ${SA_NAME}
|
||||||
|
name: oil-calculator
|
||||||
|
current-context: oil-calculator
|
||||||
|
users:
|
||||||
|
- name: ${SA_NAME}
|
||||||
|
user:
|
||||||
|
token: ${TOKEN}
|
||||||
|
EOF
|
||||||
|
|
||||||
|
echo "Kubeconfig written to ./kubeconfig"
|
||||||
|
echo "Test with: KUBECONFIG=./kubeconfig kubectl get pods"
|
||||||
114
doc/deploy.md
Normal file
114
doc/deploy.md
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
# 精油配方计算器 - 部署文档
|
||||||
|
|
||||||
|
## 架构
|
||||||
|
|
||||||
|
- **前端**: 静态 HTML(由 FastAPI 直接 serve)
|
||||||
|
- **后端**: FastAPI + SQLite,端口 8000
|
||||||
|
- **部署**: Kubernetes (k3s) on `oci.euphon.net`
|
||||||
|
- **域名**: https://oil.oci.euphon.net
|
||||||
|
- **TLS**: Traefik + Let's Encrypt (自动)
|
||||||
|
|
||||||
|
## 目录结构
|
||||||
|
|
||||||
|
```
|
||||||
|
├── backend/
|
||||||
|
│ ├── main.py # FastAPI 应用
|
||||||
|
│ ├── database.py # SQLite 数据库操作
|
||||||
|
│ ├── defaults.json # 默认精油和配方数据(首次启动时 seed)
|
||||||
|
│ └── requirements.txt
|
||||||
|
├── frontend/
|
||||||
|
│ └── index.html # 前端页面
|
||||||
|
├── deploy/
|
||||||
|
│ ├── namespace.yaml
|
||||||
|
│ ├── deployment.yaml
|
||||||
|
│ ├── service.yaml
|
||||||
|
│ ├── pvc.yaml # 1Gi 持久卷,存放 SQLite 数据库
|
||||||
|
│ ├── ingress.yaml
|
||||||
|
│ ├── setup-kubeconfig.sh # 生成受限 kubeconfig 的脚本
|
||||||
|
│ └── kubeconfig # 受限 kubeconfig(仅 oil-calculator namespace)
|
||||||
|
├── Dockerfile
|
||||||
|
└── doc/deploy.md
|
||||||
|
```
|
||||||
|
|
||||||
|
## 首次部署
|
||||||
|
|
||||||
|
已完成,以下为记录。
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. SSH 到服务器
|
||||||
|
ssh fam@oci.euphon.net
|
||||||
|
|
||||||
|
# 2. 上传项目
|
||||||
|
scp oil-calc.tar.gz fam@oci.euphon.net:/tmp/
|
||||||
|
ssh fam@oci.euphon.net "mkdir -p ~/oil-calculator && cd ~/oil-calculator && tar xzf /tmp/oil-calc.tar.gz"
|
||||||
|
|
||||||
|
# 3. 构建并推送镜像
|
||||||
|
cd ~/oil-calculator
|
||||||
|
docker build -t registry.oci.euphon.net/oil-calculator:latest .
|
||||||
|
docker push registry.oci.euphon.net/oil-calculator:latest
|
||||||
|
|
||||||
|
# 4. 部署 k8s 资源
|
||||||
|
cd deploy
|
||||||
|
kubectl apply -f namespace.yaml
|
||||||
|
kubectl apply -f pvc.yaml
|
||||||
|
kubectl apply -f deployment.yaml
|
||||||
|
kubectl apply -f service.yaml
|
||||||
|
kubectl apply -f ingress.yaml
|
||||||
|
|
||||||
|
# 5. 需要 regcred 才能拉取私有镜像(从已有 ns 复制)
|
||||||
|
kubectl get secret regcred -n guitar -o yaml | sed 's/namespace: guitar/namespace: oil-calculator/' | kubectl apply -f -
|
||||||
|
```
|
||||||
|
|
||||||
|
## 更新部署
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 在本地打包上传
|
||||||
|
cd "/Users/hera/Hera DOCS/Projects/Essential Oil Formula Cost Calculator"
|
||||||
|
tar czf /tmp/oil-calc.tar.gz Dockerfile backend/ frontend/ deploy/
|
||||||
|
scp /tmp/oil-calc.tar.gz fam@oci.euphon.net:/tmp/
|
||||||
|
|
||||||
|
# SSH 到服务器构建并重启
|
||||||
|
ssh fam@oci.euphon.net
|
||||||
|
cd ~/oil-calculator && tar xzf /tmp/oil-calc.tar.gz
|
||||||
|
docker build -t registry.oci.euphon.net/oil-calculator:latest .
|
||||||
|
docker push registry.oci.euphon.net/oil-calculator:latest
|
||||||
|
kubectl rollout restart deploy/oil-calculator -n oil-calculator
|
||||||
|
```
|
||||||
|
|
||||||
|
或者使用受限 kubeconfig(仅重启,不含构建):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
KUBECONFIG=deploy/kubeconfig kubectl rollout restart deploy/oil-calculator
|
||||||
|
```
|
||||||
|
|
||||||
|
## 受限 Kubeconfig
|
||||||
|
|
||||||
|
文件位于 `deploy/kubeconfig`,权限范围:
|
||||||
|
- **Namespace**: `oil-calculator` only
|
||||||
|
- **ServiceAccount**: `oil-calculator-deployer`
|
||||||
|
- **权限**: pods, services, deployments, replicasets, ingresses, PVC, configmaps, secrets, pods/log 的完整 CRUD
|
||||||
|
- **无法**访问其他 namespace 或集群级资源
|
||||||
|
|
||||||
|
重新生成:`bash deploy/setup-kubeconfig.sh`
|
||||||
|
|
||||||
|
## K8s 配置要点
|
||||||
|
|
||||||
|
- **Ingress class**: `traefik`
|
||||||
|
- **TLS annotation**: `traefik.ingress.kubernetes.io/router.tls.certresolver: le`
|
||||||
|
- **镜像仓库**: `registry.oci.euphon.net`(需要 `regcred` secret)
|
||||||
|
- **数据持久化**: PVC 挂载到 `/data`,SQLite 数据库在 `/data/oil_calculator.db`
|
||||||
|
|
||||||
|
## API 端点
|
||||||
|
|
||||||
|
| Method | Path | 说明 |
|
||||||
|
|--------|------|------|
|
||||||
|
| GET | /api/oils | 所有精油列表 |
|
||||||
|
| POST | /api/oils | 添加/更新精油 |
|
||||||
|
| DELETE | /api/oils/{name} | 删除精油 |
|
||||||
|
| GET | /api/recipes | 所有配方列表 |
|
||||||
|
| POST | /api/recipes | 新增配方 |
|
||||||
|
| PUT | /api/recipes/{id} | 更新配方 |
|
||||||
|
| DELETE | /api/recipes/{id} | 删除配方 |
|
||||||
|
| GET | /api/tags | 所有标签 |
|
||||||
|
| POST | /api/tags | 新增标签 |
|
||||||
|
| DELETE | /api/tags/{name} | 删除标签 |
|
||||||
BIN
frontend/apple-touch-icon-180.png
Normal file
BIN
frontend/apple-touch-icon-180.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 11 KiB |
BIN
frontend/apple-touch-icon-192.png
Normal file
BIN
frontend/apple-touch-icon-192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 8.2 KiB |
BIN
frontend/apple-touch-icon.png
Normal file
BIN
frontend/apple-touch-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 12 KiB |
1
frontend/favicon.svg
Normal file
1
frontend/favicon.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><text y="0.9em" font-size="85">🌿</text></svg>
|
||||||
|
After Width: | Height: | Size: 111 B |
BIN
frontend/icon-192.png
Normal file
BIN
frontend/icon-192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 12 KiB |
8441
frontend/index.html
Normal file
8441
frontend/index.html
Normal file
File diff suppressed because one or more lines are too long
53
scripts/backup.sh
Executable file
53
scripts/backup.sh
Executable file
@@ -0,0 +1,53 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Weekly backup script - run locally or via cron
|
||||||
|
# Backs up: database from server + local code
|
||||||
|
# Keeps last 5 backups
|
||||||
|
|
||||||
|
BACKUP_BASE="$HOME/Hera DOCS/Projects/Essential Oil Formula Cost Calculator/backups"
|
||||||
|
DATE=$(date +%Y%m%d_%H%M%S)
|
||||||
|
BACKUP_DIR="$BACKUP_BASE/$DATE"
|
||||||
|
|
||||||
|
mkdir -p "$BACKUP_DIR"
|
||||||
|
|
||||||
|
echo "📦 Backing up to: $BACKUP_DIR"
|
||||||
|
|
||||||
|
# 1. Download database from server
|
||||||
|
echo " Downloading database..."
|
||||||
|
ssh fam@oci.euphon.net "kubectl exec -n oil-calculator deploy/oil-calculator -- cat /data/oil_calculator.db" > "$BACKUP_DIR/oil_calculator.db" 2>/dev/null
|
||||||
|
|
||||||
|
if [ $? -eq 0 ] && [ -s "$BACKUP_DIR/oil_calculator.db" ]; then
|
||||||
|
echo " ✅ Database: $(du -h "$BACKUP_DIR/oil_calculator.db" | cut -f1)"
|
||||||
|
else
|
||||||
|
echo " ❌ Database download failed"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 2. Copy local code
|
||||||
|
echo " Copying code..."
|
||||||
|
PROJECT="$HOME/Hera DOCS/Projects/Essential Oil Formula Cost Calculator"
|
||||||
|
cp -r "$PROJECT/backend" "$BACKUP_DIR/"
|
||||||
|
cp -r "$PROJECT/frontend" "$BACKUP_DIR/"
|
||||||
|
cp "$PROJECT/Dockerfile" "$BACKUP_DIR/"
|
||||||
|
echo " ✅ Code copied"
|
||||||
|
|
||||||
|
# 3. Export all data as JSON (recipes, oils, users)
|
||||||
|
echo " Exporting data..."
|
||||||
|
ADMIN_TOKEN=$(cat /tmp/oil_admin_token.txt 2>/dev/null)
|
||||||
|
if [ -n "$ADMIN_TOKEN" ]; then
|
||||||
|
curl -s -H "Authorization: Bearer $ADMIN_TOKEN" https://oil.oci.euphon.net/api/recipes > "$BACKUP_DIR/recipes.json" 2>/dev/null
|
||||||
|
curl -s -H "Authorization: Bearer $ADMIN_TOKEN" https://oil.oci.euphon.net/api/oils > "$BACKUP_DIR/oils.json" 2>/dev/null
|
||||||
|
curl -s -H "Authorization: Bearer $ADMIN_TOKEN" https://oil.oci.euphon.net/api/tags > "$BACKUP_DIR/tags.json" 2>/dev/null
|
||||||
|
curl -s -H "Authorization: Bearer $ADMIN_TOKEN" https://oil.oci.euphon.net/api/users > "$BACKUP_DIR/users.json" 2>/dev/null
|
||||||
|
echo " ✅ Data exported"
|
||||||
|
else
|
||||||
|
echo " ⚠️ No admin token, skipping data export"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 4. Keep only last 5 backups
|
||||||
|
echo " Cleaning old backups..."
|
||||||
|
ls -dt "$BACKUP_BASE"/*/ 2>/dev/null | tail -n +31 | xargs rm -rf 2>/dev/null
|
||||||
|
REMAINING=$(ls -d "$BACKUP_BASE"/*/ 2>/dev/null | wc -l)
|
||||||
|
echo " ✅ $REMAINING backups retained"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "✅ Backup complete: $BACKUP_DIR"
|
||||||
|
ls -la "$BACKUP_DIR/"
|
||||||
19
scripts/remote-backup.sh
Executable file
19
scripts/remote-backup.sh
Executable file
@@ -0,0 +1,19 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Remote backup: download database from server to local Mac
|
||||||
|
# Run via cron: crontab -e → 0 * * * * /path/to/remote-backup.sh
|
||||||
|
|
||||||
|
BACKUP_DIR="$HOME/Hera DOCS/Projects/Essential Oil Formula Cost Calculator/backups/remote"
|
||||||
|
mkdir -p "$BACKUP_DIR"
|
||||||
|
DATE=$(date +%Y%m%d_%H%M%S)
|
||||||
|
|
||||||
|
# Download database
|
||||||
|
ssh fam@oci.euphon.net "kubectl exec -n oil-calculator deploy/oil-calculator -- cat /data/oil_calculator.db" > "$BACKUP_DIR/oil_calculator_${DATE}.db" 2>/dev/null
|
||||||
|
|
||||||
|
if [ $? -eq 0 ] && [ -s "$BACKUP_DIR/oil_calculator_${DATE}.db" ]; then
|
||||||
|
echo "✅ Backup: $BACKUP_DIR/oil_calculator_${DATE}.db ($(du -h "$BACKUP_DIR/oil_calculator_${DATE}.db" | cut -f1))"
|
||||||
|
# Keep last 168 backups (7 days hourly)
|
||||||
|
ls -t "$BACKUP_DIR"/oil_calculator_*.db 2>/dev/null | tail -n +169 | xargs rm -f 2>/dev/null
|
||||||
|
else
|
||||||
|
echo "❌ Backup failed"
|
||||||
|
rm -f "$BACKUP_DIR/oil_calculator_${DATE}.db"
|
||||||
|
fi
|
||||||
46
scripts/scan-recipes-folder.py
Normal file
46
scripts/scan-recipes-folder.py
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Scan the local recipe folder for new Excel files and extract recipes.
|
||||||
|
Run periodically (e.g., weekly) to find new content to add to the calculator.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python3 scripts/scan-recipes-folder.py
|
||||||
|
|
||||||
|
It will:
|
||||||
|
1. Read all .xlsx files in the recipe folder
|
||||||
|
2. Compare with existing recipes in the API
|
||||||
|
3. Report any new recipes found
|
||||||
|
4. Optionally upload them (with --upload flag)
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import json
|
||||||
|
import argparse
|
||||||
|
|
||||||
|
RECIPE_FOLDER = os.path.expanduser("~/Hera DOCS/Essential Oil/商业和公司/成本和配方")
|
||||||
|
API_BASE = "https://oil.oci.euphon.net"
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(description="Scan recipe folder for new content")
|
||||||
|
parser.add_argument("--upload", action="store_true", help="Upload new recipes to API")
|
||||||
|
parser.add_argument("--token", help="Admin API token")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
print(f"📁 Scanning: {RECIPE_FOLDER}")
|
||||||
|
if not os.path.isdir(RECIPE_FOLDER):
|
||||||
|
print(f" Folder not found!")
|
||||||
|
return
|
||||||
|
|
||||||
|
files = [f for f in os.listdir(RECIPE_FOLDER) if f.endswith('.xlsx') and not f.startswith('~$')]
|
||||||
|
print(f" Found {len(files)} Excel files:")
|
||||||
|
for f in files:
|
||||||
|
mtime = os.path.getmtime(os.path.join(RECIPE_FOLDER, f))
|
||||||
|
from datetime import datetime
|
||||||
|
print(f" - {f} (modified: {datetime.fromtimestamp(mtime).strftime('%Y-%m-%d %H:%M')})")
|
||||||
|
|
||||||
|
print(f"\n💡 To add recipes from these files, use the web UI's '智能粘贴' feature")
|
||||||
|
print(f" or manually add them via the API.")
|
||||||
|
print(f"\n🔗 Admin URL: {API_BASE}/?token=<your-token>")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
2057
精油配方计算器.html
Normal file
2057
精油配方计算器.html
Normal file
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user