redesign homepage: 测测-style layout, icon grid for personality/zodiac tests, remove clinical terminology from headers

This commit is contained in:
jackyu66git
2026-08-02 14:25:37 +08:00
parent e6ad46fe87
commit 2686866376
14 changed files with 25369 additions and 168 deletions
+582 -47
View File
@@ -1,80 +1,615 @@
#!/usr/bin/env python3
"""Minimal HTTP server for digital-psychology serves static files + mindmap persistence."""
import json, os, sys
"""Digital Psychology server — static files + mindmap + scale library API."""
import json, os, sys, sqlite3, time, uuid
from http.server import HTTPServer, SimpleHTTPRequestHandler
from urllib.parse import urlparse, parse_qs
ROOT = os.path.dirname(os.path.abspath(__file__))
MINDMAP_FILE = os.path.join(ROOT, "mindmap_data.json")
SCALES_FILE = os.path.join(ROOT, "scales_data.json")
RESULTS_DB = os.path.join(ROOT, "scale_results.db")
# ---- SQLite setup ----
def get_db():
db = sqlite3.connect(RESULTS_DB)
db.row_factory = sqlite3.Row
return db
def init_db():
db = get_db()
db.execute("""
CREATE TABLE IF NOT EXISTS results (
id TEXT PRIMARY KEY,
scale_slug TEXT NOT NULL,
scale_name TEXT NOT NULL,
answers TEXT NOT NULL,
scores TEXT,
created_at TEXT NOT NULL,
user_agent TEXT,
ip TEXT
)
""")
db.commit()
db.close()
init_db()
# ============================================================
# Scoring engine — known rules for major scales
# ============================================================
SCORE_SCALE = {
# (option_start, reverse_items, transform, rating_thresholds, factor_map)
# option_start: 0=0-indexed (PHQ/GAD), 1=1-indexed (SDS/SAS/SCL-90)
# reverse_items: 1-indexed question numbers to reverse-score
# transform: (multiplier, round) applied to raw score, or None
# rating_thresholds: [(max_score, label), ...]
# factor_map: {factor_name: [1-indexed question numbers]}
}
SCORING = {
# === 抑郁症测试 ===
"yyzcs": { # SDS 抑郁自评量表
"name": "抑郁自评量表(SDS)",
"option_base": 1, # 1-4
"reverse": [2, 5, 6, 11, 12, 14, 16, 17, 18, 20],
"transform": (1.25, 0), # std = raw * 1.25, rounded
"ratings": [
(52, "可能没有抑郁"),
(62, "可能轻度抑郁"),
(72, "可能中度抑郁"),
(100, "可能重度抑郁"),
],
"factors": {
"精神性情感症状": [1, 3],
"躯体性障碍": [2, 4, 5, 6, 7, 8, 9, 10],
"精神运动性障碍": [12, 13],
"抑郁的心理障碍": [11, 14, 15, 16, 17, 18, 19, 20],
},
"use_std": True,
},
"yyz": { # BDI-II 贝克抑郁量表
"name": "贝克抑郁量表(BDI-II)",
"option_base": 0, # 0-3
"reverse": [],
"transform": None,
"ratings": [
(13, "无抑郁或极轻微"),
(19, "轻度抑郁"),
(28, "中度抑郁"),
(63, "重度抑郁"),
],
"factors": {},
"use_std": False,
},
"yy9": { # 中学生抑郁自评量表
"name": "中学生抑郁自评量表",
"option_base": 1,
"reverse": [],
"transform": None,
"ratings": [
(39, "可能没有抑郁"),
(47, "可能轻度抑郁"),
(55, "可能中度抑郁"),
(80, "可能重度抑郁"),
],
"factors": {},
"use_std": False,
},
"yy4": { # CES-D 流调用抑郁量表
"name": "流调用抑郁量表(CES-D)",
"option_base": 0,
"reverse": [4, 8, 12, 16],
"transform": None,
"ratings": [
(15, "无抑郁症状"),
(19, "可能有抑郁倾向"),
(24, "可能有抑郁症状"),
(60, "可能有严重抑郁症状"),
],
"factors": {},
"use_std": False,
},
"yy6": { # PHQ-9
"name": "抑郁症筛查量表(PHQ-9)",
"option_base": 0,
"reverse": [],
"transform": None,
"ratings": [
(4, "无抑郁"),
(9, "轻度抑郁"),
(14, "中度抑郁"),
(19, "中重度抑郁"),
(27, "重度抑郁"),
],
"factors": {},
"use_std": False,
},
"yy7": { # CDI 儿童青少年抑郁量表
"name": "儿童青少年抑郁量表(CDI)",
"option_base": 0,
"reverse": [],
"transform": None,
"ratings": [
(19, "无抑郁"),
(29, "轻度抑郁"),
(39, "中度抑郁"),
(54, "重度抑郁"),
],
"factors": {},
"use_std": False,
},
"yy8": { # DSRSC 儿童抑郁障碍自评
"name": "儿童抑郁障碍自评量表(DSRSC)",
"option_base": 0,
"reverse": [],
"transform": None,
"ratings": [
(13, "正常"),
(17, "可能有抑郁"),
(36, "很可能有抑郁"),
],
"factors": {},
"use_std": False,
},
"yy10": { # GDS 老年抑郁量表
"name": "老年抑郁量表(GDS)",
"option_base": 0,
"reverse": [1, 5, 7, 9, 15, 19, 21, 27, 29, 30],
"transform": None,
"ratings": [
(10, "正常"),
(19, "轻度抑郁"),
(30, "重度抑郁"),
],
"factors": {},
"use_std": False,
},
"yy11": { # EPDS 爱丁堡产后抑郁
"name": "爱丁堡产后抑郁量表(EPDS)",
"option_base": 0,
"reverse": [],
"transform": None,
"ratings": [
(9, "正常"),
(12, "可能存在抑郁"),
(30, "很可能存在抑郁"),
],
"factors": {},
"use_std": False,
},
"yy12": { # HAD 焦虑抑郁量表
"name": "焦虑抑郁量表(HAD)",
"option_base": 0,
"reverse": [],
"transform": None,
"ratings": [
(7, "正常"),
(10, "临界"),
(21, "异常"),
],
"factors": {},
"use_std": False,
},
"yy13": { # HAMD 汉密尔顿抑郁
"name": "汉密尔顿抑郁量表(HAMD)",
"option_base": 0,
"reverse": [],
"transform": None,
"ratings": [
(7, "正常"),
(17, "可能有轻中度抑郁"),
(24, "可能有重度抑郁"),
(72, "可能有极重度抑郁"),
],
"factors": {},
"use_std": False,
},
# === 焦虑症测试 ===
"jl1": { # SAS 焦虑自评量表
"name": "焦虑自评量表(SAS)",
"option_base": 1,
"reverse": [5, 9, 13, 17, 19],
"transform": (1.25, 0),
"ratings": [
(49, "可能没有焦虑"),
(59, "可能轻度焦虑"),
(69, "可能中度焦虑"),
(100, "可能重度焦虑"),
],
"factors": {},
"use_std": True,
},
"jlz": { # BAI 贝克焦虑量表
"name": "贝克焦虑测试量表(BAI)",
"option_base": 0,
"reverse": [],
"transform": None,
"ratings": [
(7, "无焦虑"),
(15, "轻度焦虑"),
(25, "中度焦虑"),
(63, "重度焦虑"),
],
"factors": {},
"use_std": False,
},
"jl5": { # GAD-7
"name": "焦虑症筛查量表(GAD-7)",
"option_base": 0,
"reverse": [],
"transform": None,
"ratings": [
(4, "无焦虑"),
(9, "轻度焦虑"),
(14, "中度焦虑"),
(21, "重度焦虑"),
],
"factors": {},
"use_std": False,
},
"jl6": { # TAS 考试焦虑
"name": "考试焦虑量表(TAS)",
"option_base": 0,
"reverse": [],
"transform": None,
"ratings": [
(12, "较低考试焦虑"),
(20, "中等考试焦虑"),
(37, "较高考试焦虑"),
],
"factors": {},
"use_std": False,
},
"jl8": { # 中学生焦虑自评
"name": "中学生焦虑自评量表",
"option_base": 1,
"reverse": [],
"transform": None,
"ratings": [
(39, "可能没有焦虑"),
(48, "可能轻度焦虑"),
(56, "可能中度焦虑"),
(80, "可能重度焦虑"),
],
"factors": {},
"use_std": False,
},
# === 强迫症测试 ===
"qp1": { # YBOCS
"name": "耶鲁布朗强迫标准量表(YBOCS)",
"option_base": 0,
"reverse": [],
"transform": None,
"ratings": [
(7, "亚临床"),
(15, "轻度"),
(23, "中度"),
(31, "重度"),
(40, "极重度"),
],
"factors": {},
"use_std": False,
},
# === 人格测试 ===
"xl16": { # NEO-FFI 大五人格
"name": "大五人格测试(NEO-FFI)",
"option_base": 1,
"reverse": [], # varies by factor
"transform": None,
"ratings": [], # factor-based
"factors": {
"神经质": [1, 6, 11, 16, 21, 26, 31, 36, 41, 46, 51, 56], # approximate
"外向性": [2, 7, 12, 17, 22, 27, 32, 37, 42, 47, 52, 57],
"开放性": [3, 8, 13, 18, 23, 28, 33, 38, 43, 48, 53, 58],
"宜人性": [4, 9, 14, 19, 24, 29, 34, 39, 44, 49, 54, 59],
"尽责性": [5, 10, 15, 20, 25, 30, 35, 40, 45, 50, 55, 60],
},
"use_std": False,
},
# === 智商/天赋测试 ===
"duoyuan80": {
"name": "加德纳多元智能测试(MI-80)",
"option_base": 1, # 1-5
"num_options": 5,
"reverse": [],
"transform": None,
"ratings": [], # factor-based, no global rating
"factors": {
"语言智能": [1,2,3,4,5,6,7,8,9,10],
"逻辑数学智能": [11,12,13,14,15,16,17,18,19,20],
"空间智能": [21,22,23,24,25,26,27,28,29,30],
"身体运动智能": [31,32,33,34,35,36,37,38,39,40],
"音乐智能": [41,42,43,44,45,46,47,48,49,50],
"自然观察智能": [51,52,53,54,55,56,57,58,59,60],
"人际智能": [61,62,63,64,65,66,67,68,69,70],
"内省智能": [71,72,73,74,75,76,77,78,79,80],
},
"use_std": False,
},
}
def calculate_score(slug, answers):
"""Calculate scores with known rules, fall back to raw sum."""
scoring = SCORING.get(slug)
num_options = scoring.get("num_options", 4) if scoring else 4
if scoring:
base = scoring["option_base"]
reverse_items = set(scoring["reverse"])
raw = 0
for i, ans in enumerate(answers):
if ans is None:
continue
q_num = i + 1 # 1-indexed question number
opt_idx = int(ans)
if q_num in reverse_items:
# Reverse: last option → first score
raw += base + (num_options - 1 - opt_idx)
else:
raw += base + opt_idx
# Factor scores
factors = {}
if scoring.get("factors"):
for fname, qnums in scoring["factors"].items():
f_raw = 0
f_count = 0
for qn in qnums:
idx = qn - 1
if idx < len(answers) and answers[idx] is not None:
opt_idx = int(answers[idx])
if qn in reverse_items:
f_raw += base + (num_options - 1 - opt_idx)
else:
f_raw += base + opt_idx
f_count += 1
if f_count > 0:
factors[fname] = f_raw
# Transform (e.g. SDS std = raw * 1.25)
std_score = None
if scoring.get("transform"):
mult, rnd = scoring["transform"]
std_score = round(raw * mult) if rnd == 0 else int(raw * mult)
used_score = std_score if (scoring.get("use_std") and std_score is not None) else raw
# Rating
rating = ""
for threshold, label in scoring.get("ratings", []):
if used_score <= threshold:
rating = label
break
if not rating and scoring.get("ratings"):
rating = scoring["ratings"][-1][1]
max_raw = len([a for a in answers if a is not None]) * (base + num_options - 1)
# Max per factor (for front-end bar charts)
factor_max = {}
if scoring.get("factors"):
for fname, qnums in scoring["factors"].items():
factor_max[fname] = len(qnums) * (base + num_options - 1)
# Per-question scores (1-indexed options: 选项A=1分, B=2分, ...)
question_scores = []
for i, ans in enumerate(answers):
if ans is None:
question_scores.append(None)
elif (i + 1) in reverse_items:
question_scores.append(base + (num_options - 1 - int(ans)))
else:
question_scores.append(base + int(ans))
return {
"raw_score": raw,
"std_score": std_score,
"max_score": max_raw,
"rating": rating,
"factors": factors,
"factor_max": factor_max,
"answer_count": len([a for a in answers if a is not None]),
"question_scores": question_scores,
}
else:
# Generic: option index + 1
raw = sum(int(a) + 1 for a in answers if a is not None)
question_scores = [(int(a) + 1) if a is not None else None for a in answers]
return {
"raw_score": raw,
"std_score": None,
"max_score": len([a for a in answers if a is not None]) * num_options,
"rating": "",
"factors": {},
"factor_max": {},
"answer_count": len([a for a in answers if a is not None]),
"question_scores": question_scores,
}
class Handler(SimpleHTTPRequestHandler):
def __init__(self, *args, **kwargs):
super().__init__(*args, directory=ROOT, **kwargs)
def end_headers(self):
# no-cache for HTML and JSON endpoints
if self.path == '/' or self.path.endswith('.html') or self.path == '/load':
no_cache_paths = ['/', '/load', '/api/']
if any(self.path == p or self.path.startswith(p) for p in no_cache_paths):
self.send_header("Cache-Control", "no-cache, no-store, must-revalidate")
self.send_header("Pragma", "no-cache")
self.send_header("Expires", "0")
super().end_headers()
def _json(self, data, code=200):
self.send_response(code)
self.send_header("Content-Type", "application/json; charset=utf-8")
self.send_header("Access-Control-Allow-Origin", "*")
self.end_headers()
self.wfile.write(json.dumps(data, ensure_ascii=False).encode())
def _read_body(self):
length = int(self.headers.get("Content-Length", 0))
return self.rfile.read(length) if length else b""
# ==== API routing ====
def do_GET(self):
if self.path == "/load":
if os.path.exists(MINDMAP_FILE):
with open(MINDMAP_FILE, "r", encoding="utf-8") as f:
data = f.read()
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.end_headers()
self.wfile.write(data.encode())
else:
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.end_headers()
self.wfile.write(b"{}")
else:
super().do_GET()
path = urlparse(self.path).path
# Scale API
if path == "/api/scales":
return self.api_scales_list()
if path.startswith("/api/scales/") and not path.startswith("/api/scales/result"):
slug = path.split("/api/scales/")[1]
return self.api_scale_detail(slug)
if path == "/api/scales/results":
return self.api_results_list()
if path.startswith("/api/scales/results/"):
rid = path.split("/api/scales/results/")[1]
return self.api_result_detail(rid)
# Mindmap
if path == "/load":
return self.mindmap_load()
return super().do_GET()
def do_POST(self):
if self.path == "/save":
length = int(self.headers.get("Content-Length", 0))
data = self.rfile.read(length)
try:
json.loads(data)
except json.JSONDecodeError:
self.send_error(400, "Invalid JSON")
return
with open(MINDMAP_FILE, "w", encoding="utf-8") as f:
f.write(data.decode())
path = urlparse(self.path).path
# Scale result submission
if path == "/api/scales/result":
return self.api_submit_result()
# Mindmap save
if path == "/save":
return self.mindmap_save()
return super().do_POST()
def do_OPTIONS(self):
self.send_response(204)
self.send_header("Access-Control-Allow-Origin", "*")
self.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
self.send_header("Access-Control-Allow-Headers", "Content-Type")
self.end_headers()
# ---- Scale APIs ----
def api_scales_list(self):
if not os.path.exists(SCALES_FILE):
return self._json({"error": "No scales data"}, 404)
with open(SCALES_FILE, "r", encoding="utf-8") as f:
scales = json.load(f)
# Return lightweight list (no questions)
result = []
for s in scales:
result.append({
"slug": s["slug"], "name": s["name"], "category": s["category"],
"question_count": s["question_count"], "description": s.get("description", ""),
})
self._json(result)
def api_scale_detail(self, slug):
if not os.path.exists(SCALES_FILE):
return self._json({"error": "No scales data"}, 404)
with open(SCALES_FILE, "r", encoding="utf-8") as f:
scales = json.load(f)
for s in scales:
if s["slug"] == slug:
return self._json(s)
self._json({"error": "Scale not found"}, 404)
def api_submit_result(self):
body = self._read_body()
try:
data = json.loads(body)
except json.JSONDecodeError:
return self._json({"error": "Invalid JSON"}, 400)
slug = data.get("scale_slug", "")
scale_name = data.get("scale_name", "")
answers = data.get("answers", []) # list of option indices
if not slug or not answers:
return self._json({"error": "Missing scale_slug or answers"}, 400)
rid = str(uuid.uuid4())[:8]
created = time.strftime("%Y-%m-%d %H:%M:%S")
# Calculate score using known rules
scores = calculate_score(slug, answers)
# Get the scale config for display name
scale_config = SCORING.get(slug, {})
scores["scale_name"] = scale_name
scores["scale_slug"] = slug
db = get_db()
db.execute(
"INSERT INTO results (id, scale_slug, scale_name, answers, scores, created_at, user_agent, ip) VALUES (?,?,?,?,?,?,?,?)",
(rid, slug, scale_name, json.dumps(answers), json.dumps(scores), created,
self.headers.get("User-Agent", ""), self.client_address[0])
)
db.commit()
db.close()
self._json({"id": rid, "scores": scores, "created_at": created})
def api_results_list(self):
db = get_db()
rows = db.execute("SELECT id, scale_slug, scale_name, scores, created_at FROM results ORDER BY created_at DESC LIMIT 50").fetchall()
db.close()
results = []
for r in rows:
results.append({
"id": r["id"], "scale_slug": r["scale_slug"], "scale_name": r["scale_name"],
"scores": json.loads(r["scores"]), "created_at": r["created_at"],
})
self._json(results)
def api_result_detail(self, rid):
db = get_db()
r = db.execute("SELECT * FROM results WHERE id = ?", (rid,)).fetchone()
db.close()
if not r:
return self._json({"error": "Not found"}, 404)
self._json({
"id": r["id"], "scale_slug": r["scale_slug"], "scale_name": r["scale_name"],
"answers": json.loads(r["answers"]), "scores": json.loads(r["scores"]),
"created_at": r["created_at"],
})
# ---- Mindmap (existing) ----
def mindmap_load(self):
if os.path.exists(MINDMAP_FILE):
with open(MINDMAP_FILE, "r", encoding="utf-8") as f:
data = f.read()
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.end_headers()
self.wfile.write(b'{"ok":true}')
elif self.path == "/load":
if os.path.exists(MINDMAP_FILE):
with open(MINDMAP_FILE, "r", encoding="utf-8") as f:
data = f.read()
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.end_headers()
self.wfile.write(data.encode())
else:
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.end_headers()
self.wfile.write(b"{}")
self.wfile.write(data.encode())
else:
self.send_error(404)
self._json({})
def mindmap_save(self):
data = self._read_body()
try:
json.loads(data)
except json.JSONDecodeError:
self.send_error(400, "Invalid JSON")
return
with open(MINDMAP_FILE, "w", encoding="utf-8") as f:
f.write(data.decode())
self._json({"ok": True})
def log_message(self, format, *args):
if "/save" in str(args) or "/load" in str(args):
if any(x in str(args) for x in ["/save", "/load", "/api/"]):
print(f"[{self.log_date_time_string()}] {args[0]}")
if __name__ == "__main__":
port = int(sys.argv[1]) if len(sys.argv) > 1 else 8001
server = HTTPServer(("127.0.0.1", port), Handler)
server.socket.settimeout(30) # 30s timeout so hung clients don't block the single-thread server
print(f"Serving {ROOT} on port {port} (save/load enabled)")
server.socket.settimeout(30)
print(f"愈心谷 server on :{port} (scales API enabled)")
try:
server.serve_forever()
except KeyboardInterrupt: