from pathlib import Path import gradio as gr import sbti IMAGE_DIR = Path(__file__).resolve().parent / "image" ALL_QUESTIONS = sbti.QUESTIONS + sbti.SPECIAL_QUESTIONS TOTAL_QUESTIONS = len(ALL_QUESTIONS) QUESTION_SECTIONS = [ ("自我模型", sbti.QUESTIONS[0:6]), ("情感模型", sbti.QUESTIONS[6:12]), ("态度模型", sbti.QUESTIONS[12:18]), ("行动模型", sbti.QUESTIONS[18:24]), ("社交模型", sbti.QUESTIONS[24:30]), ("特殊触发", sbti.SPECIAL_QUESTIONS), ] LEVEL_LABELS = {"L": "低", "M": "中", "H": "高"} QUESTION_SECTION_BY_ID = { question["id"]: section_title for section_title, questions in QUESTION_SECTIONS for question in questions } def resolve_image_path(code: str) -> str: extension = ".jpg" if code in {"Dior-s", "JOKE-R"} else ".png" candidate = IMAGE_DIR / f"{code}{extension}" if candidate.exists(): return str(candidate) if code == "WOC!": fallback = IMAGE_DIR / "WOC!.png" if fallback.exists(): return str(fallback) fallback = IMAGE_DIR / "WOC.png" if fallback.exists(): return str(fallback) raise FileNotFoundError(f"Missing image asset for type: {code}") def normalize_answers(answers): if not isinstance(answers, list) or len(answers) != TOTAL_QUESTIONS: return [None] * TOTAL_QUESTIONS return answers def answered_count(answers): return sum(value is not None for value in answers) def get_question(index: int) -> dict: return ALL_QUESTIONS[index] def get_section_name(question: dict) -> str: return QUESTION_SECTION_BY_ID.get(question["id"], "未分类") def render_progress(index: int, answers: list) -> str: question = get_question(index) done = answered_count(answers) progress = round(done / TOTAL_QUESTIONS * 100) return f"""
{get_section_name(question)} 第 {index + 1} / {TOTAL_QUESTIONS} 题

已完成 {done} / {TOTAL_QUESTIONS} 题

""" def render_question_text(index: int) -> str: question = get_question(index) return f"""
{get_section_name(question)}

第 {index + 1} 题

{question['text']}

""" def render_result_card(result: dict) -> str: final_type = result["final_type"] secondary = result.get("secondary_type") secondary_html = "" if secondary: secondary_html = ( "
" "常规人格参考匹配" f"{secondary['code']} / {secondary['cn']}" f"

相似度 {secondary['similarity']}%,精确命中 {secondary['exact']}/15。

" "
" ) return f"""
{result['mode_kicker']}

{final_type['code']} / {final_type['cn']}

{final_type['intro']}

{result['badge']}

{final_type['desc']}

{result['sub']}

{secondary_html}
""" def render_dimension_markdown(result: dict) -> str: lines = [ "### 15 维度解析", "", "| 维度 | 等级 | 解读 |", "| --- | --- | --- |", ] for dim in sbti.DIMENSION_ORDER: meta = sbti.DIMENSION_META[dim] level = result["levels"][dim] explanation = sbti.DIM_EXPLANATIONS[dim][level] lines.append(f"| {meta['name']} | {LEVEL_LABELS[level]} | {explanation} |") return "\n".join(lines) def build_missing_error(answers: list) -> tuple[int | None, str]: missing = [] for index, (question, value) in enumerate(zip(ALL_QUESTIONS, answers)): if value is None: section = get_section_name(question) missing.append((index, f"{index + 1}. [{section}] {question['text']}")) if not missing: return None, "" preview = "\n".join(f"- {item}" for _, item in missing[:8]) suffix = "" if len(missing) > 8: suffix = f"\n- 另外还有 {len(missing) - 8} 道题未完成。" message = f"你还有 {len(missing)} 道题未作答,请先完成以下题目:\n{preview}{suffix}" return missing[0][0], message def build_question_updates(index: int, answers: list, validation_message: str = ""): answers = normalize_answers(answers) question = get_question(index) choices = [(option["label"], option["value"]) for option in question["options"]] prev_update = gr.update(interactive=index > 0) next_update = gr.update( value="保存并下一题" if index < TOTAL_QUESTIONS - 1 else "已是最后一题", interactive=index < TOTAL_QUESTIONS - 1, ) validation_update = gr.update( value=validation_message, visible=bool(validation_message), ) radio_update = gr.update( choices=choices, value=answers[index], ) return ( index, answers, render_progress(index, answers), render_question_text(index), radio_update, validation_update, prev_update, next_update, ) def persist_current_answer(answers: list, index: int, selected_value): answers = normalize_answers(list(answers)) if selected_value is not None: answers[index] = int(selected_value) return answers def go_prev(selected_value, index, answers): answers = persist_current_answer(answers, index, selected_value) new_index = max(0, index - 1) return build_question_updates(new_index, answers) def go_next(selected_value, index, answers): answers = normalize_answers(list(answers)) if selected_value is None: return build_question_updates(index, answers, f"第 {index + 1} 题还没有作答。") answers[index] = int(selected_value) new_index = min(TOTAL_QUESTIONS - 1, index + 1) return build_question_updates(new_index, answers) def submit_quiz(selected_value, index, answers): answers = persist_current_answer(answers, index, selected_value) first_missing, error_message = build_missing_error(answers) if first_missing is not None: question_updates = build_question_updates(first_missing, answers, error_message) return ( *question_updates, gr.update(value="", visible=False), gr.update(value="", visible=False), gr.update(value=None, visible=False), ) answer_map = { question["id"]: int(value) for question, value in zip(ALL_QUESTIONS, answers) if value is not None } result = sbti.compute_result(answer_map) image_path = resolve_image_path(result["final_type"]["code"]) question_updates = build_question_updates(index, answers) return ( *question_updates, gr.update(value=render_result_card(result), visible=True), gr.update(value=render_dimension_markdown(result), visible=True), gr.update(value=image_path, visible=True), ) def reset_all(): answers = [None] * TOTAL_QUESTIONS question_updates = build_question_updates(0, answers) return ( *question_updates, gr.update(value="", visible=False), gr.update(value="", visible=False), gr.update(value=None, visible=False), ) INITIAL_ANSWERS = [None] * TOTAL_QUESTIONS INITIAL_QUESTION = get_question(0) INITIAL_CHOICES = [(option["label"], option["value"]) for option in INITIAL_QUESTION["options"]] CSS = """ :root { --paper: rgba(255, 251, 245, 0.96); --paper-strong: #fffdf9; --ink: #20150f; --muted: #553c2f; --accent: #8a401b; --accent-strong: #6f2f12; --line: rgba(50, 29, 16, 0.14); } body, .gradio-container { background: radial-gradient(circle at top, rgba(218, 136, 72, 0.22), transparent 30%), linear-gradient(180deg, #f7efe3 0%, #f2dfc6 100%); color: var(--ink); font-family: "Noto Serif SC", "Source Han Serif SC", "Songti SC", serif; } .block-wrap, .panel { border-radius: 24px; } .hero, .progress-card, .question-card, .validation-box, .result-panel, .dimension-panel, .image-panel { border: 1px solid var(--line); background: var(--paper-strong); box-shadow: 0 12px 32px rgba(108, 69, 36, 0.08); } .hero { padding: 28px 30px; border-radius: 24px; background: linear-gradient(135deg, rgba(255, 252, 246, 0.96), rgba(250, 241, 228, 0.94)); } .hero h1 { margin: 0 0 8px; font-size: 2.6rem; color: #5a2a15; } .hero p { margin: 0; color: var(--muted); line-height: 1.7; font-size: 1.05rem; } .progress-card { padding: 16px 18px; border-radius: 18px; } .progress-top { display: flex; justify-content: space-between; gap: 12px; font-weight: 700; color: var(--accent-strong); font-size: 0.98rem; } .progress-card p { margin: 10px 0 0; color: var(--muted); } .progress-bar { margin-top: 12px; height: 10px; border-radius: 999px; background: #efdfcf; overflow: hidden; } .progress-bar span { display: block; height: 100%; border-radius: inherit; background: linear-gradient(135deg, #8a401b, #c56a34); } .question-card { padding: 20px 22px; border-radius: 22px; } .question-kicker { display: inline-block; margin-bottom: 10px; padding: 6px 10px; border-radius: 999px; background: #f3e0cc; color: #6f2f12; font-size: 0.9rem; font-weight: 700; } .question-card h2 { margin: 0 0 10px; color: #5a2a15; font-size: 1.4rem; } .question-card p { margin: 0; color: #2d1b12; font-size: 1.1rem; line-height: 1.75; } .question-radio { margin: 4px 0 2px; padding: 6px 0 0; } .question-radio fieldset { gap: 10px; } .question-radio .wrap, .question-radio .wrap label, .question-radio fieldset label { background: #fffaf3 !important; border: 1px solid #b98a6b !important; border-radius: 14px !important; box-shadow: 0 4px 12px rgba(108, 69, 36, 0.06) !important; } .question-radio .wrap label, .question-radio fieldset label { padding: 10px 16px !important; } .question-radio .wrap label span, .question-radio .wrap label p, .question-radio .wrap label div, .question-radio fieldset label span, .question-radio fieldset label p, .question-radio fieldset label div { color: #2f190f !important; font-weight: 700 !important; opacity: 1 !important; } .question-radio .wrap label input[type="radio"], .question-radio fieldset label input[type="radio"] { accent-color: #8a401b !important; } .question-radio .wrap label:has(input:checked), .question-radio fieldset label:has(input:checked) { background: #7a3416 !important; border: 1px solid #7a3416 !important; box-shadow: 0 8px 18px rgba(122, 52, 22, 0.28) !important; } .question-radio .wrap label:has(input:checked) span, .question-radio .wrap label:has(input:checked) p, .question-radio .wrap label:has(input:checked) div, .question-radio fieldset label:has(input:checked) span, .question-radio fieldset label:has(input:checked) p, .question-radio fieldset label:has(input:checked) div { color: #ffffff !important; } .question-radio .wrap label:has(input:checked) input[type="radio"], .question-radio fieldset label:has(input:checked) input[type="radio"] { accent-color: #ffffff !important; } .validation-box { padding: 14px 18px; border-radius: 18px; background: #fff5ef; } .validation-box, .validation-box *, .validation-box .prose, .validation-box .prose * { color: #8a2e14 !important; font-weight: 700 !important; } .primary-btn button, .secondary-btn button, .ghost-btn button { border-radius: 999px !important; font-weight: 700 !important; } .primary-btn button { background: linear-gradient(135deg, #8a401b, #b25826) !important; color: #fffaf6 !important; } .secondary-btn button { background: var(--paper-strong) !important; color: var(--accent-strong) !important; border: 1px solid rgba(111, 47, 18, 0.18) !important; } .ghost-btn button { background: #efe1d3 !important; color: #5a2a15 !important; border: 1px solid rgba(111, 47, 18, 0.12) !important; } .result-card { padding: 24px; border-radius: 24px; background: linear-gradient(135deg, #2f1f17 0%, #6d3322 55%, #c56a34 100%); color: #fff7ef; box-shadow: 0 22px 50px rgba(96, 42, 24, 0.28); } .result-card h2 { margin: 8px 0 10px; font-size: 2rem; } .eyebrow { display: inline-block; padding: 6px 10px; border-radius: 999px; background: rgba(255, 255, 255, 0.16); font-size: 0.9rem; } .badge { display: inline-block; margin: 8px 0 14px; padding: 8px 12px; border-radius: 12px; background: rgba(240, 179, 95, 0.22); border: 1px solid rgba(255, 255, 255, 0.12); } .intro { margin: 0 0 8px; font-size: 1.05rem; } .desc, .sub { line-height: 1.8; } .sub-card { margin-top: 18px; padding: 14px 16px; border-radius: 16px; background: rgba(255, 255, 255, 0.08); } .sub-card span { display: block; font-size: 0.9rem; opacity: 0.85; } .dimension-panel { padding: 12px 18px; border-radius: 20px; color: #2d1b12 !important; } .dimension-panel, .dimension-panel *, .dimension-panel .prose, .dimension-panel .prose * { color: #2d1b12 !important; } .dimension-panel h1, .dimension-panel h2, .dimension-panel h3, .dimension-panel h4 { color: #6f2f12 !important; font-weight: 800 !important; } .dimension-panel table { width: 100%; border-collapse: collapse; background: #fffaf4 !important; color: #2d1b12 !important; } .dimension-panel th { background: #f3e0cc !important; color: #6f2f12 !important; font-weight: 800 !important; } .dimension-panel td { background: #fffdf9 !important; color: #2d1b12 !important; } .dimension-panel th, .dimension-panel td { border: 1px solid rgba(111, 47, 18, 0.16) !important; } .image-panel { border-radius: 20px; } .image-panel img { border-radius: 18px; } @media (max-width: 768px) { .hero { padding: 20px 18px; } .hero h1 { font-size: 2rem; } .question-card { padding: 18px 16px; } .question-card h2 { font-size: 1.2rem; } .question-card p { font-size: 1rem; } } """ with gr.Blocks(css=CSS, title="SBTI 人格测试") as demo: current_index = gr.State(0) answers_state = gr.State(INITIAL_ANSWERS) gr.HTML( """

SBTI 人格测试

已针对手机端做轻量化优化:改为逐题作答,减少首屏渲染压力,提高打开和交互速度。

""" ) progress_html = gr.HTML(render_progress(0, INITIAL_ANSWERS)) question_html = gr.HTML(render_question_text(0)) answer_radio = gr.Radio( choices=INITIAL_CHOICES, value=None, type="value", show_label=False, elem_classes="question-radio", ) validation_md = gr.Markdown(visible=False, elem_classes="validation-box") with gr.Row(): prev_btn = gr.Button("上一题", variant="secondary", size="lg", elem_classes="secondary-btn", interactive=False) next_btn = gr.Button("保存并下一题", variant="secondary", size="lg", elem_classes="ghost-btn") submit_btn = gr.Button("生成结果", variant="primary", size="lg", elem_classes="primary-btn") reset_btn = gr.Button("重新开始", variant="secondary", size="lg", elem_classes="secondary-btn") result_html = gr.HTML(visible=False, elem_classes="result-panel") dimension_md = gr.Markdown(visible=False, elem_classes="dimension-panel") result_image = gr.Image(label="结果图片", type="filepath", interactive=False, visible=False, elem_classes="image-panel") prev_btn.click( fn=go_prev, inputs=[answer_radio, current_index, answers_state], outputs=[current_index, answers_state, progress_html, question_html, answer_radio, validation_md, prev_btn, next_btn], queue=False, ) next_btn.click( fn=go_next, inputs=[answer_radio, current_index, answers_state], outputs=[current_index, answers_state, progress_html, question_html, answer_radio, validation_md, prev_btn, next_btn], queue=False, ) submit_btn.click( fn=submit_quiz, inputs=[answer_radio, current_index, answers_state], outputs=[ current_index, answers_state, progress_html, question_html, answer_radio, validation_md, prev_btn, next_btn, result_html, dimension_md, result_image, ], ) reset_btn.click( fn=reset_all, outputs=[ current_index, answers_state, progress_html, question_html, answer_radio, validation_md, prev_btn, next_btn, result_html, dimension_md, result_image, ], queue=False, ) if __name__ == "__main__": demo.launch(server_name="0.0.0.0", server_port=7860)