feat(mbti): 替换 mbti 简单实现 sbti acgti 功能

This commit is contained in:
SXP-Simon
2026-04-13 21:43:25 +08:00
parent 06ea0e7812
commit ed85876b36
14 changed files with 702 additions and 175 deletions
+1
View File
@@ -33,3 +33,4 @@ scripts/output_all/debug_HatsuneMiku_test_group_*.pdf
scripts/output/mock_report_test_group_mock_*.pdf
astrbot-lark-group-daily-analysis-main/**
.ace-tool/
debug_atri.html
+36 -1
View File
File diff suppressed because one or more lines are too long
+246
View File
@@ -0,0 +1,246 @@
{
"sbti": [
{
"code": "ATM-er",
"file": "https://fastly.jsdelivr.net/gh/SXP-Simon/profile_assets@main/sbti/ATM-er.png"
},
{
"code": "BOSS",
"file": "https://fastly.jsdelivr.net/gh/SXP-Simon/profile_assets@main/sbti/BOSS.png"
},
{
"code": "CTRL",
"file": "https://fastly.jsdelivr.net/gh/SXP-Simon/profile_assets@main/sbti/CTRL.png"
},
{
"code": "DEAD",
"file": "https://fastly.jsdelivr.net/gh/SXP-Simon/profile_assets@main/sbti/DEAD.png"
},
{
"code": "Dior-s",
"file": "https://fastly.jsdelivr.net/gh/SXP-Simon/profile_assets@main/sbti/Dior-s.jpg"
},
{
"code": "DRUNK",
"file": "https://fastly.jsdelivr.net/gh/SXP-Simon/profile_assets@main/sbti/DRUNK.png"
},
{
"code": "FAKE",
"file": "https://fastly.jsdelivr.net/gh/SXP-Simon/profile_assets@main/sbti/FAKE.png"
},
{
"code": "FUCK",
"file": "https://fastly.jsdelivr.net/gh/SXP-Simon/profile_assets@main/sbti/FUCK.png"
},
{
"code": "GOGO",
"file": "https://fastly.jsdelivr.net/gh/SXP-Simon/profile_assets@main/sbti/GOGO.png"
},
{
"code": "HHHH",
"file": "https://fastly.jsdelivr.net/gh/SXP-Simon/profile_assets@main/sbti/HHHH.png"
},
{
"code": "IMFW",
"file": "https://fastly.jsdelivr.net/gh/SXP-Simon/profile_assets@main/sbti/IMFW.png"
},
{
"code": "IMSB",
"file": "https://fastly.jsdelivr.net/gh/SXP-Simon/profile_assets@main/sbti/IMSB.png"
},
{
"code": "JOKE-R",
"file": "https://fastly.jsdelivr.net/gh/SXP-Simon/profile_assets@main/sbti/JOKE-R.jpg"
},
{
"code": "LOVE-R",
"file": "https://fastly.jsdelivr.net/gh/SXP-Simon/profile_assets@main/sbti/LOVE-R.png"
},
{
"code": "MALO",
"file": "https://fastly.jsdelivr.net/gh/SXP-Simon/profile_assets@main/sbti/MALO.png"
},
{
"code": "MONK",
"file": "https://fastly.jsdelivr.net/gh/SXP-Simon/profile_assets@main/sbti/MONK.png"
},
{
"code": "MUM",
"file": "https://fastly.jsdelivr.net/gh/SXP-Simon/profile_assets@main/sbti/MUM.png"
},
{
"code": "OH-NO",
"file": "https://fastly.jsdelivr.net/gh/SXP-Simon/profile_assets@main/sbti/OH-NO.png"
},
{
"code": "OJBK",
"file": "https://fastly.jsdelivr.net/gh/SXP-Simon/profile_assets@main/sbti/OJBK.png"
},
{
"code": "POOR",
"file": "https://fastly.jsdelivr.net/gh/SXP-Simon/profile_assets@main/sbti/POOR.png"
},
{
"code": "SEXY",
"file": "https://fastly.jsdelivr.net/gh/SXP-Simon/profile_assets@main/sbti/SEXY.png"
},
{
"code": "SHIT",
"file": "https://fastly.jsdelivr.net/gh/SXP-Simon/profile_assets@main/sbti/SHIT.png"
},
{
"code": "SOLO",
"file": "https://fastly.jsdelivr.net/gh/SXP-Simon/profile_assets@main/sbti/SOLO.png"
},
{
"code": "THAN-K",
"file": "https://fastly.jsdelivr.net/gh/SXP-Simon/profile_assets@main/sbti/THAN-K.png"
},
{
"code": "THIN-K",
"file": "https://fastly.jsdelivr.net/gh/SXP-Simon/profile_assets@main/sbti/THIN-K.png"
},
{
"code": "WOC",
"file": "https://fastly.jsdelivr.net/gh/SXP-Simon/profile_assets@main/sbti/WOC.png"
},
{
"code": "ZZZZ",
"file": "https://fastly.jsdelivr.net/gh/SXP-Simon/profile_assets@main/sbti/ZZZZ.png"
}
],
"acgti": [
{
"code": "CIRN",
"name": "琪露诺",
"mbti": "ESTP",
"file": "https://fastly.jsdelivr.net/gh/SXP-Simon/profile_assets@main/acgti/characters/CIRN.png"
},
{
"code": "MIKT",
"name": "御坂美琴",
"mbti": "ESTJ",
"file": "https://fastly.jsdelivr.net/gh/SXP-Simon/profile_assets@main/acgti/characters/MIKT.png"
},
{
"code": "MADK",
"name": "鹿目圆",
"mbti": "INFP",
"file": "https://fastly.jsdelivr.net/gh/SXP-Simon/profile_assets@main/acgti/characters/MADK.png"
},
{
"code": "FRNA",
"name": "芙宁娜",
"mbti": "ESFP",
"file": "https://fastly.jsdelivr.net/gh/SXP-Simon/profile_assets@main/acgti/characters/FRNA.png"
},
{
"code": "GGGG-A",
"name": "高松灯",
"mbti": "INFP",
"file": "https://fastly.jsdelivr.net/gh/SXP-Simon/profile_assets@main/acgti/characters/GGGG-A.png"
},
{
"code": "ANON",
"name": "千早爱音",
"mbti": "ESFJ",
"file": "https://fastly.jsdelivr.net/gh/SXP-Simon/profile_assets@main/acgti/characters/ANON.png"
},
{
"code": "BAFE",
"name": "要乐奈",
"mbti": "ISTP",
"file": "https://fastly.jsdelivr.net/gh/SXP-Simon/profile_assets@main/acgti/characters/BAFE.png"
},
{
"code": "SOYO",
"name": "长崎爽世",
"mbti": "ISFJ",
"file": "https://fastly.jsdelivr.net/gh/SXP-Simon/profile_assets@main/acgti/characters/SOYO.png"
},
{
"code": "RIKI",
"name": "椎名立希",
"mbti": "ESTJ",
"file": "https://fastly.jsdelivr.net/gh/SXP-Simon/profile_assets@main/acgti/characters/RIKI.png"
},
{
"code": "SAKI",
"name": "丰川祥子",
"mbti": "ENTJ",
"file": "https://fastly.jsdelivr.net/gh/SXP-Simon/profile_assets@main/acgti/characters/SAKI.png"
},
{
"code": "MRTS",
"name": "若叶睦",
"mbti": "ISTJ",
"file": "https://fastly.jsdelivr.net/gh/SXP-Simon/profile_assets@main/acgti/characters/MRTS.png"
},
{
"code": "MRTS-X",
"name": "Mortis",
"mbti": "INTJ",
"file": "https://fastly.jsdelivr.net/gh/SXP-Simon/profile_assets@main/acgti/characters/MRTS-X.png"
},
{
"code": "DLRS",
"name": "三角初华 / Doloris",
"mbti": "INFJ",
"file": "https://fastly.jsdelivr.net/gh/SXP-Simon/profile_assets@main/acgti/characters/DLRS.png"
},
{
"code": "TMRS",
"name": "八幡海铃 / Timoris",
"mbti": "ISTJ",
"file": "https://fastly.jsdelivr.net/gh/SXP-Simon/profile_assets@main/acgti/characters/TMRS.png"
},
{
"code": "AMRS",
"name": "祐天寺若麦 / Amoris",
"mbti": "ENTP",
"file": "https://fastly.jsdelivr.net/gh/SXP-Simon/profile_assets@main/acgti/characters/AMRS.png"
},
{
"code": "DINA",
"name": "嘉然",
"mbti": "ENFP",
"file": "https://fastly.jsdelivr.net/gh/SXP-Simon/profile_assets@main/acgti/characters/DINA.png"
},
{
"code": "TAFI",
"name": "永雏塔菲",
"mbti": "ENTP",
"file": "https://fastly.jsdelivr.net/gh/SXP-Simon/profile_assets@main/acgti/characters/TAFI.png"
},
{
"code": "NEUR",
"name": "Neuro-sama",
"mbti": "ENTP",
"file": "https://fastly.jsdelivr.net/gh/SXP-Simon/profile_assets@main/acgti/characters/NEUR.png"
},
{
"code": "EVIL",
"name": "Evil Neuro",
"mbti": "INTJ",
"file": "https://fastly.jsdelivr.net/gh/SXP-Simon/profile_assets@main/acgti/characters/EVIL.png"
},
{
"code": "RINN",
"name": "镜音铃",
"mbti": "ESFP",
"file": "https://fastly.jsdelivr.net/gh/SXP-Simon/profile_assets@main/acgti/characters/RINN.png"
},
{
"code": "LTYI",
"name": "洛天依",
"mbti": "ISFP",
"file": "https://fastly.jsdelivr.net/gh/SXP-Simon/profile_assets@main/acgti/characters/LTYI.png"
},
{
"code": "YCYO",
"name": "月见八千代",
"mbti": "ENFJ",
"file": "https://fastly.jsdelivr.net/gh/SXP-Simon/profile_assets@main/acgti/characters/YCYO.png"
}
]
}
+98 -112
View File
@@ -64,8 +64,17 @@ from src.infrastructure.reporting.generators import ReportGenerator # noqa: E40
class MockConfigManager:
def __init__(self, template_name: str = "scrapbook") -> None:
def __init__(
self,
template_name: str = "scrapbook",
profile_mode: str = "mbti",
profile_image_opacity: float = 0.20,
profile_mapping_config: str = "",
) -> None:
self.template_name = template_name
self.profile_mode = profile_mode
self.profile_image_opacity = profile_image_opacity
self.profile_mapping_config = profile_mapping_config
def get_report_template(self) -> str:
return self.template_name
@@ -74,16 +83,16 @@ class MockConfigManager:
return 8
def get_max_user_titles(self) -> int:
return 8
return 16
def get_max_golden_quotes(self) -> int:
return 8
def get_pdf_output_dir(self) -> str:
return "data/pdf"
def get_html_output_dir(self) -> str:
return "data/html"
def get_pdf_filename_format(self) -> str:
return "report_{group_id}_{date}.pdf"
def get_html_filename_format(self) -> str:
return "report_{group_id}_{date}.html"
def get_enable_user_card(self) -> bool:
return True
@@ -101,6 +110,21 @@ class MockConfigManager:
def get_llm_max_concurrent(self) -> int:
return 2
def get_profile_display_mode(self) -> str:
return self.profile_mode
def get_profile_image_opacity(self) -> float:
return self.profile_image_opacity
def get_profile_image_size_mode(self) -> str:
return "contain"
def get_profile_mapping_config(self) -> str:
return self.profile_mapping_config
def get_html_base_url(self) -> str:
return ""
async def mock_get_user_avatar(user_id: str) -> str:
# Return a known avatar URL for testing
@@ -108,10 +132,15 @@ async def mock_get_user_avatar(user_id: str) -> str:
async def debug_render(
template_name: str, output_file: str = "debug_output.html"
template_name: str,
output_file: str = "debug_output.html",
profile_mode: str = "mbti",
) -> None:
# 1. Setup Mock Data
config_manager = MockConfigManager(template_name)
config_manager = MockConfigManager(
template_name=template_name,
profile_mode=profile_mode,
)
# 2. Mock Analysis Result using Data Models
stats = GroupStatistics(
@@ -177,81 +206,48 @@ async def debug_render(
SummaryTopic(
topic="新出的3A大作测评",
contributors=["周八", "吴九"],
detail="[123456789] 分享了最新游戏的通关体验,讨论了画面表现和剧情走向",
),
SummaryTopic(
topic="新出的3A大作测评",
contributors=["周八", "吴九"],
detail="[123456789] 分享了最新游戏的通关体验,讨论了画面表现和剧情走向。",
),
SummaryTopic(
topic="新出的3A大作测评",
contributors=["周八", "吴九"],
detail="[123456789] 分享了最新游戏的通关体验,讨论了画面表现和剧情走向。",
),
SummaryTopic(
topic="新出的3A大作测评",
contributors=["周八", "吴九"],
detail="[123456789] 分享了最新游戏的通关体验,讨论了画面表现和剧情走向。",
),
SummaryTopic(
topic="新出的3A大作测评",
contributors=["周八", "吴九"],
detail="[123456789] 分享了最新游戏的通关体验,讨论了画面表现和剧情走向。",
detail="[200000000] 开始尝试新的游戏,发现 INTJ 类型的玩家在策略游戏中非常吃香",
),
]
user_titles = [
UserTitle(
name="张三",
user_id="123456789",
title="代码收割机",
mbti="INTJ",
reason="在短短一小时内提交了10个PR,效率惊人。",
),
UserTitle(
name="李四",
user_id="987654321",
title="群聊气氛组",
mbti="ENFP",
reason="总能精准接住每一个冷笑话,让群里充满快活的气息。",
),
UserTitle(
name="潜水员",
user_id="112233445",
title="深夜潜水员",
mbti="INFP",
reason="总是在凌晨三点出没,留下几句深奥的话语后消失。",
),
UserTitle(
name="潜水员",
user_id="112233445",
title="深夜潜水员",
mbti="INFP",
reason="总是在凌晨三点出没,留下几句深奥的话语后消失。",
),
UserTitle(
name="潜水员",
user_id="112233445",
title="深夜潜水员",
mbti="INFP",
reason="总是在凌晨三点出没,留下几句深奥的话语后消失。",
),
UserTitle(
name="潜水员",
user_id="112233445",
title="深夜潜水员",
mbti="INFP",
reason="总是在凌晨三点出没,留下几句深奥的话语后消失。",
),
UserTitle(
name="潜水员",
user_id="112233445",
title="深夜潜水员",
mbti="INFP",
reason="总是在凌晨三点出没,留下几句深奥的话语后消失。",
),
mbti_types = [
"INTJ",
"INTP",
"ENTJ",
"ENTP",
"INFJ",
"INFP",
"ENFJ",
"ENFP",
"ISTJ",
"ISFJ",
"ESTJ",
"ESTP",
"ISTP",
"ISFP",
"ESFJ",
"ESFP",
]
user_titles = []
user_analysis = {
"123456789": {"nickname": "张三"},
"987654321": {"nickname": "李四"},
"112233445": {"nickname": "潜水员"},
}
for i, mbti in enumerate(mbti_types):
uid = str(200000000 + i)
uname = f"用户_{mbti}"
user_titles.append(
UserTitle(
name=uname,
user_id=uid,
title=f"{mbti} 王者",
mbti=mbti,
reason=f"在日常交流中表现出极强的 {mbti} 特征,获得了大家的认可。",
)
)
user_analysis[uid] = {"nickname": uname}
golden_quotes = [
GoldenQuote(
@@ -269,31 +265,7 @@ async def debug_render(
GoldenQuote(
content="PHP是世界上最好的语言!",
sender="王五",
reason="引发了长达3小时的群聊大讨论",
user_id="112233445",
),
GoldenQuote(
content="PHP是世界上最好的语言!",
sender="王五",
reason="引发了长达3小时的群聊大讨论",
user_id="112233445",
),
GoldenQuote(
content="PHP是世界上最好的语言!",
sender="王五",
reason="引发了长达3小时的群聊大讨论",
user_id="112233445",
),
GoldenQuote(
content="PHP是世界上最好的语言!",
sender="王五",
reason="引发了长达3小时的群聊大讨论",
user_id="112233445",
),
GoldenQuote(
content="PHP是世界上最好的语言!",
sender="王五",
reason="引发了长达3小时的群聊大讨论",
reason="[200000001] 表示强烈赞同,并引发了后续的长篇大论。",
user_id="112233445",
),
]
@@ -305,15 +277,11 @@ async def debug_render(
"statistics": stats,
"topics": topics,
"user_titles": user_titles,
"user_analysis": {
"123456789": {"nickname": "张三"},
"987654321": {"nickname": "李四"},
"112233445": {"nickname": "潜水员"},
},
"user_analysis": user_analysis,
"chat_quality_review": stats.chat_quality_review,
"analysis_date": "2026年02月11日",
"group_id": "123456",
"group_name": "试群",
"group_name": "插件逻辑调试群",
}
# 3. Initialize Generator
@@ -321,6 +289,16 @@ async def debug_render(
data_dir.mkdir(parents=True, exist_ok=True)
generator = ReportGenerator(config_manager, data_dir)
# Mock avatar cache to avoid KeyError in debug mode
class MockCache(dict):
def __getitem__(self, key):
return self.get(key, "")
def set(self, key, value, expire=None):
self[key] = value
generator._avatar_cache = MockCache()
# 4. Prepare Render Data
# Note: _prepare_render_data handles converting Entities to template-friendly dicts
render_payload = await generator._prepare_render_data(
@@ -340,7 +318,7 @@ async def debug_render(
await generator.close()
print(
f"Successfully rendered template '{template_name}' to {output_path.absolute()}"
f"Successfully rendered template '{template_name}' in mode '{profile_mode}' to {output_path.absolute()}"
)
print("You can now open this file with your browser to debug your HTML/CSS.")
@@ -363,9 +341,17 @@ def main() -> None:
default="debug_output.html",
help="Output HTML file path (default: debug_output.html)",
)
parser.add_argument(
"-m",
"--mode",
type=str,
default="mbti",
choices=["mbti", "sbti", "acgti"],
help="Profile display mode to render (default: mbti)",
)
args = parser.parse_args()
asyncio.run(debug_render(args.template, args.output))
asyncio.run(debug_render(args.template, args.output, args.mode))
if __name__ == "__main__":
@@ -621,6 +621,34 @@ class ConfigManager:
self._ensure_group("basic")["enable_analysis_reply"] = enabled
self.config.save_config()
def get_profile_display_mode(self) -> str:
"""获取人格标签展示模式。"""
mode = str(self._get_group("basic").get("profile_display_mode", "mbti")).lower()
if mode not in {"mbti", "sbti", "acgti"}:
return "mbti"
return mode
def get_profile_image_opacity(self) -> float:
"""获取人格背景图透明度。"""
value = self._get_group("basic").get("profile_image_opacity", 0.12)
try:
return max(0.0, min(1.0, float(value)))
except (TypeError, ValueError):
return 0.12
def get_profile_image_size_mode(self) -> str:
"""获取人格背景图尺寸模式。"""
mode = str(
self._get_group("basic").get("profile_image_size_mode", "contain")
).lower()
if mode not in {"contain", "cover"}:
return "contain"
return mode
def get_profile_mapping_config(self) -> str:
"""获取人格映射配置(JSON 文本)。"""
return str(self._get_group("basic").get("profile_mapping_config", "")).strip()
# ========== 群文件/群相册上传配置 ==========
def get_enable_group_file_upload(self) -> bool:
+206
View File
@@ -6,6 +6,7 @@
import asyncio
import base64
import html
import json
import os
import re
from dataclasses import asdict, is_dataclass
@@ -28,6 +29,63 @@ from .templates import HTMLTemplates
MAX_CONCURRENT_DOWNLOADS = 10
AVATAR_CACHE_EXPIRE_TIME = 259200
DEFAULT_PROFILE_MAPPING = {
"mbti": {
"INTJ": {"code": "INTJ", "name_zh": "建筑师"},
"INTP": {"code": "INTP", "name_zh": "逻辑学家"},
"ENTJ": {"code": "ENTJ", "name_zh": "指挥官"},
"ENTP": {"code": "ENTP", "name_zh": "辩论家"},
"INFJ": {"code": "INFJ", "name_zh": "提倡者"},
"INFP": {"code": "INFP", "name_zh": "调停者"},
"ENFJ": {"code": "ENFJ", "name_zh": "主人公"},
"ENFP": {"code": "ENFP", "name_zh": "竞选者"},
"ISTJ": {"code": "ISTJ", "name_zh": "物流师"},
"ISFJ": {"code": "ISFJ", "name_zh": "守卫者"},
"ESTJ": {"code": "ESTJ", "name_zh": "总经理"},
"ESTP": {"code": "ESTP", "name_zh": "企业家"},
"ISTP": {"code": "ISTP", "name_zh": "鉴赏家"},
"ISFP": {"code": "ISFP", "name_zh": "探险家"},
"ESFJ": {"code": "ESFJ", "name_zh": "执政官"},
"ESFP": {"code": "ESFP", "name_zh": "表演者"},
},
"sbti": {
"INTJ": {"code": "CTRL", "name_zh": "拿捏者", "asset_code": "CTRL"},
"INTP": {"code": "THIN-K", "name_zh": "思考者", "asset_code": "THIN-K"},
"ENTJ": {"code": "BOSS", "name_zh": "领导者", "asset_code": "BOSS"},
"ENTP": {"code": "JOKE-R", "name_zh": "小丑", "asset_code": "JOKE-R"},
"INFJ": {"code": "LOVE-R", "name_zh": "多情者", "asset_code": "LOVE-R"},
"INFP": {"code": "SOLO", "name_zh": "孤儿", "asset_code": "SOLO"},
"ENFJ": {"code": "THAN-K", "name_zh": "感恩者", "asset_code": "THAN-K"},
"ENFP": {"code": "GOGO", "name_zh": "行者", "asset_code": "GOGO"},
"ISTJ": {"code": "OH-NO", "name_zh": "哦不人", "asset_code": "OH-NO"},
"ISTP": {"code": "POOR", "name_zh": "贫困者", "asset_code": "POOR"},
"ESTJ": {"code": "SHIT", "name_zh": "愤世者", "asset_code": "SHIT"},
"ESTP": {"code": "WOC!", "name_zh": "握草人", "asset_code": "WOC"},
"ISFJ": {"code": "MUM", "name_zh": "妈妈", "asset_code": "MUM"},
"ISFP": {"code": "MALO", "name_zh": "吗喽", "asset_code": "MALO"},
"ESFJ": {"code": "ATM-er", "name_zh": "送钱者", "asset_code": "ATM-er"},
"ESFP": {"code": "SEXY", "name_zh": "尤物", "asset_code": "SEXY"},
},
"acgti": {
"INTJ": {"code": "MRTS-X", "name_zh": "Mortis"},
"INTP": {"code": "KNAN", "name_zh": "江户川柯南"},
"ENTJ": {"code": "SAKI", "name_zh": "丰川祥子"},
"ENTP": {"code": "CHKA", "name_zh": "藤原千花"},
"INFJ": {"code": "DLRS", "name_zh": "三角初华"},
"INFP": {"code": "BCHI", "name_zh": "后藤一里"},
"ENFJ": {"code": "YCYO", "name_zh": "月见八千代"},
"ENFP": {"code": "HTMK", "name_zh": "初音未来"},
"ISTJ": {"code": "MRTS", "name_zh": "若叶睦"},
"ISTP": {"code": "AYRE", "name_zh": "绫波丽"},
"ESTJ": {"code": "MIKT", "name_zh": "御坂美琴"},
"ESTP": {"code": "ASKA", "name_zh": "明日香"},
"ISFJ": {"code": "SOYO", "name_zh": "长崎爽世"},
"ISFP": {"code": "LTYI", "name_zh": "洛天依"},
"ESFJ": {"code": "ANON", "name_zh": "千早爱音"},
"ESFP": {"code": "FRNA", "name_zh": "芙宁娜"},
},
}
class ReportGenerator(IReportGenerator):
"""报告生成器"""
@@ -51,6 +109,148 @@ class ReportGenerator(IReportGenerator):
MAX_CONCURRENT_DOWNLOADS
)
self._avatar_session = None
self._profile_asset_manifest = self._load_profile_asset_manifest()
def _load_profile_asset_manifest(self) -> dict[str, dict]:
"""加载人格资源清单。"""
manifest_path = (
Path(__file__).resolve().parents[3]
/ "assets"
/ "profile_assets"
/ "manifest.json"
)
if not manifest_path.exists():
logger.warning(f"人格资源清单不存在: {manifest_path}")
return {"sbti": {}, "acgti": {}}
try:
raw = json.loads(manifest_path.read_text(encoding="utf-8-sig"))
except Exception as e:
logger.warning(f"加载人格资源清单失败: {e}")
return {"sbti": {}, "acgti": {}}
manifest: dict[str, dict] = {"sbti": {}, "acgti": {}}
for item in raw.get("sbti", []):
code = str(item.get("code", "")).strip()
if code:
manifest["sbti"][code] = item
for item in raw.get("acgti", []):
code = str(item.get("code", "")).strip()
if code:
manifest["acgti"][code] = item
return manifest
def _get_profile_mapping_overrides(self) -> dict[str, dict]:
"""解析用户配置的人格映射覆盖项。"""
raw = self.config_manager.get_profile_mapping_config()
if not raw:
return {}
try:
data = json.loads(raw)
if isinstance(data, dict):
return data
except Exception as e:
logger.warning(f"人格映射配置 JSON 解析失败,已回退到默认映射: {e}")
return {}
def _build_profile_image_from_manifest_pattern(
self, profile_mode: str, asset_code: str
) -> str:
"""当 manifest 缺少具体 code 时,根据已有资源路径模式推导图片地址。"""
system_manifest = self._profile_asset_manifest.get(profile_mode, {})
for item in system_manifest.values():
if not isinstance(item, dict):
continue
sample_code = str(item.get("code", "")).strip()
sample_file = str(item.get("file", "")).strip()
if not sample_code or not sample_file:
continue
code_token = f"/{sample_code}."
if code_token not in sample_file:
continue
return sample_file.replace(code_token, f"/{asset_code}.", 1)
return ""
def _get_manifest_profile_item_by_mbti(
self, profile_mode: str, mbti: str
) -> dict | None:
"""按 MBTI 从 manifest 中寻找可用资源。"""
normalized_mbti = str(mbti or "").strip().upper()
system_manifest = self._profile_asset_manifest.get(profile_mode, {})
for item in system_manifest.values():
if not isinstance(item, dict):
continue
item_mbti = str(item.get("mbti", "")).strip().upper()
if item_mbti == normalized_mbti:
return item
return None
def _resolve_profile_info(
self,
mbti: str,
profile_mode: str,
overrides: dict[str, dict],
) -> dict[str, str | float]:
"""根据当前展示模式解析人格标签展示信息。"""
normalized_mbti = str(mbti or "").strip().upper()
# 1. 基础信息获取:从默认映射或用户覆盖中获取核心属性
profile_defaults = DEFAULT_PROFILE_MAPPING.get(profile_mode, {})
base_info = dict(profile_defaults.get(normalized_mbti, {}))
# 用户覆盖优先级最高
user_override = overrides.get(profile_mode, {}).get(normalized_mbti, {})
if isinstance(user_override, dict):
base_info.update(user_override)
code = str(base_info.get("code", normalized_mbti)).strip() or normalized_mbti
name_zh = str(base_info.get("name_zh", "")).strip()
asset_code = str(base_info.get("asset_code", code)).strip() or code
image = str(base_info.get("image", "")).strip()
# 2. 图片与属性补全 (基于 manifest.json 可信源)
if not image:
system_manifest = self._profile_asset_manifest.get(profile_mode, {})
# A. 优先按 asset_code 索引
asset_item = system_manifest.get(asset_code)
if isinstance(asset_item, dict):
image = str(asset_item.get("file", "")).strip()
if not name_zh:
name_zh = str(asset_item.get("name", "")).strip()
# B. 对于 acgti 模式,如果没找到,尝试通过 MBTI 反查第一个匹配的资源
if not image and profile_mode == "acgti":
fallback_item = self._get_manifest_profile_item_by_mbti(
profile_mode, normalized_mbti
)
if isinstance(fallback_item, dict):
image = str(fallback_item.get("file", "")).strip()
if not name_zh:
name_zh = str(fallback_item.get("name", "")).strip()
if not code or code == normalized_mbti:
code = str(fallback_item.get("code", code)).strip()
# C. 最后底线:根据 manifest 路径模式推导
if not image:
image = self._build_profile_image_from_manifest_pattern(
profile_mode, asset_code
)
# 3. 构造显示文本 (Code + 中文名)
display = str(base_info.get("display", "")).strip()
if not display:
display = f"{code}{name_zh}" if name_zh else code
return {
"profile_mode": profile_mode,
"profile_code": code,
"profile_name_zh": name_zh,
"profile_display": display,
"profile_image": image,
"profile_image_opacity": self.config_manager.get_profile_image_opacity(),
"profile_image_size_mode": self.config_manager.get_profile_image_size_mode(),
}
@staticmethod
def _sanitize_path_component(name: str) -> str:
@@ -506,11 +706,16 @@ class ReportGenerator(IReportGenerator):
# 使用Jinja2模板构建用户称号HTML(批量渲染,包含头像)
max_user_titles = self.config_manager.get_max_user_titles()
titles_list = []
profile_mode = self.config_manager.get_profile_display_mode()
profile_mapping_overrides = self._get_profile_mapping_overrides()
for title in user_titles[:max_user_titles]:
# 获取用户头像
avatar_data = await self._get_user_avatar(
str(title.user_id), avatar_url_getter
)
profile_info = self._resolve_profile_info(
title.mbti, profile_mode, profile_mapping_overrides
)
title_data = {
"name": title.name,
"title": title.title,
@@ -518,6 +723,7 @@ class ReportGenerator(IReportGenerator):
"reason": title.reason,
"avatar_data": avatar_data,
}
title_data.update(profile_info)
titles_list.append(title_data)
titles_html = self.html_templates.render_template(
@@ -1,39 +1,42 @@
{% set title_emojis = [
'https://tc.ciallo.ccwu.cc/file/1775132813334_1774881267181_得意.gif',
'https://tc.ciallo.ccwu.cc/file/1775132808652_1774881267385_得意-1.gif',
'https://tc.ciallo.ccwu.cc/file/1775132804506_1774881263342_观察.gif',
'https://tc.ciallo.ccwu.cc/file/1775132811492_1774881264336_不要.gif'
] %}
{% set title_badge_colors = [
{'bg': 'linear-gradient(135deg, #ffcadf 0%, #ffc1cc 45%, #ffd9b8 100%)', 'text': '#b85c7b', 'shadow': 'rgba(255, 177, 203, 0.28)', 'border': 'rgba(255, 184, 210, 0.72)'},
{'bg': 'linear-gradient(135deg, #bfe7ff 0%, #c8dbff 48%, #d8ccff 100%)', 'text': '#5d71b8', 'shadow': 'rgba(168, 198, 255, 0.26)', 'border': 'rgba(173, 204, 255, 0.74)'},
{'bg': 'linear-gradient(135deg, #c7f3df 0%, #d3f1ff 50%, #e1ddff 100%)', 'text': '#4e8e88', 'shadow': 'rgba(161, 232, 205, 0.24)', 'border': 'rgba(178, 230, 214, 0.72)'},
{'bg': 'linear-gradient(135deg, #ffe5b7 0%, #ffd6c7 48%, #ffd7ef 100%)', 'text': '#b57752', 'shadow': 'rgba(255, 210, 163, 0.28)', 'border': 'rgba(255, 214, 178, 0.76)'}
] %}
{% for title in titles %}
{% set badge = title_badge_colors[loop.index0 % (title_badge_colors|length)] %}
<li class="item item-with-emoji">
<div class="item-main">
<div style="display: flex; align-items: center; gap: 15px;">
{% if title.avatar_data %}
<img src="{{ title.avatar_data }}"
alt="头像"
style="width: 50px; height: 50px; border-radius: 50%; object-fit: cover; border: 2px solid #667eea;">
{% endif %}
<div style="flex: 1;">
<div class="item-title">
{{ title.name }}
<span style="display: inline-flex; align-items: center; margin-left: 10px; padding: 5px 10px; border-radius: 999px; font-size: 0.78em; font-weight: 700; letter-spacing: 0.2px; color: {{ badge.text }}; background: {{ badge.bg }}; border: 1px solid {{ badge.border }}; box-shadow: 0 6px 14px {{ badge.shadow }}, inset 0 1px 0 rgba(255,255,255,0.55); vertical-align: middle;">
{{ title.title }}
</span>
</div>
<div class="item-content">
<p><strong>MBTI:</strong> {{ title.mbti }}</p>
<p><strong>称号理由:</strong> {{ title.reason }}</p>
</div>
</div>
</div>
</div>
<img class="item-emoji title-emoji" src="{{ title_emojis | random }}" alt="称号表情">
</li>
{% endfor %}
{% set title_emojis = [
'https://tc.ciallo.ccwu.cc/file/1775132813334_1774881267181_得意.gif',
'https://tc.ciallo.ccwu.cc/file/1775132808652_1774881267385_得意-1.gif',
'https://tc.ciallo.ccwu.cc/file/1775132804506_1774881263342_观察.gif',
'https://tc.ciallo.ccwu.cc/file/1775132811492_1774881264336_不要.gif'
] %}
{% set title_badge_colors = [
{'bg': 'linear-gradient(135deg, #ffcadf 0%, #ffc1cc 45%, #ffd9b8 100%)', 'text': '#b85c7b', 'shadow': 'rgba(255, 177, 203, 0.28)', 'border': 'rgba(255, 184, 210, 0.72)'},
{'bg': 'linear-gradient(135deg, #bfe7ff 0%, #c8dbff 48%, #d8ccff 100%)', 'text': '#5d71b8', 'shadow': 'rgba(168, 198, 255, 0.26)', 'border': 'rgba(173, 204, 255, 0.74)'},
{'bg': 'linear-gradient(135deg, #c7f3df 0%, #d3f1ff 50%, #e1ddff 100%)', 'text': '#4e8e88', 'shadow': 'rgba(161, 232, 205, 0.24)', 'border': 'rgba(178, 230, 214, 0.72)'},
{'bg': 'linear-gradient(135deg, #ffe5b7 0%, #ffd6c7 48%, #ffd7ef 100%)', 'text': '#b57752', 'shadow': 'rgba(255, 210, 163, 0.28)', 'border': 'rgba(255, 214, 178, 0.76)'}
] %}
{% for title in titles %}
{% set badge = title_badge_colors[loop.index0 % (title_badge_colors|length)] %}
<li class="item item-with-emoji" style="position: relative; overflow: hidden;">
<div class="item-main" style="position: relative; z-index: 1;">
<div style="display: flex; align-items: center; gap: 15px;">
{% if title.avatar_data %}
<img src="{{ title.avatar_data }}"
alt="头像"
style="width: 50px; height: 50px; border-radius: 50%; object-fit: cover; border: 2px solid #667eea;">
{% endif %}
<div style="flex: 1;">
<div class="item-title">
{{ title.name }}
<span style="display: inline-flex; align-items: center; margin-left: 10px; padding: 5px 10px; border-radius: 999px; font-size: 0.78em; font-weight: 700; letter-spacing: 0.2px; color: {{ badge.text }}; background: {{ badge.bg }}; border: 1px solid {{ badge.border }}; box-shadow: 0 6px 14px {{ badge.shadow }}, inset 0 1px 0 rgba(255,255,255,0.55); vertical-align: middle;">
{{ title.title }}
</span>
</div>
<div class="item-content">
<p><strong>人格标签:</strong> {{ title.profile_display or title.mbti }}</p>
<p><strong>称号理由:</strong> {{ title.reason }}</p>
</div>
</div>
</div>
</div>
{% if title.profile_image %}
<img src="{{ title.profile_image }}" alt="" style="position: absolute; right: 8px; bottom: 8px; width: 36%; max-height: 78%; object-fit: {{ title.profile_image_size_mode or 'contain' }}; opacity: {{ title.profile_image_opacity or 0.12 }}; pointer-events: none; z-index: 1;">
{% endif %}
<img class="item-emoji title-emoji" src="{{ title_emojis | random }}" alt="称号表情" style="z-index: 2;">
</li>
{% endfor %}
@@ -1,7 +1,7 @@
{% if titles %}
<div style="display: grid; grid-template-columns: repeat(2, 1fr); gap: 20px;">
{% for title in titles %}
<div class="card-common" style="padding: 24px; margin-bottom: 0; border-top: 4px solid var(--miku-light); display: flex; flex-direction: column; gap: 15px;">
<div class="card-common" style="padding: 24px; margin-bottom: 0; border-top: 4px solid var(--miku-light); display: flex; flex-direction: column; gap: 15px; position: relative; overflow: hidden;">
<div style="display: flex; align-items: center; gap: 15px;">
<div style="width: 60px; height: 60px; border-radius: 50%; border: 3px solid white; box-shadow: 0 4px 10px rgba(57,197,187,0.2); overflow: hidden; flex-shrink: 0; background: var(--bg-page-base);">
{% if title.avatar_data %}
@@ -16,8 +16,8 @@
<div style="font-weight: 800; font-size: 1.15rem; color: var(--accent-dark); overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">{{ title.name }}</div>
<div style="display: flex; gap: 6px; margin-top: 6px;">
<span style="background: var(--miku-blue); color: var(--miku-dark); font-size: 0.7rem; padding: 2px 8px; border-radius: 6px; font-weight: 800; border: 1px solid rgba(57,197,187,0.3);">{{ title.title }}</span>
{% if title.mbti %}
<span style="background: var(--miku-pink-light); color: white; font-size: 0.7rem; padding: 2px 8px; border-radius: 6px; font-family: var(--font-num); font-weight: 800; box-shadow: 0 2px 5px rgba(255,107,149,0.2);">{{ title.mbti }}</span>
{% if title.profile_display or title.mbti %}
<span style="background: var(--miku-pink-light); color: white; font-size: 0.7rem; padding: 2px 8px; border-radius: 6px; font-family: var(--font-num); font-weight: 800; box-shadow: 0 2px 5px rgba(255,107,149,0.2);">{{ title.profile_display or title.mbti }}</span>
{% endif %}
</div>
</div>
@@ -26,7 +26,11 @@
{{ title.reason }}
<svg viewBox="0 0 24 24" fill="var(--miku-base)" style="position: absolute; right: 8px; bottom: 8px; width: 16px; height: 16px; opacity: 0.2;">
<path d="M3 21c3 0 7-1 7-8V5c0-1.25-.75-2-2-2H4c-1.25 0-2 .75-2 2v5c0 1.25.75 2 2 2h3c0 4-4 6-4 6Zm11 0c3 0 7-1 7-8V5c0-1.25-.75-2-2-2h-4c-1.25 0-2 .75-2 2v5c0 1.25.75 2 2 2h3c0 4-4 6-4 6Z"/>
</svg> </div>
</svg>
</div>
{% if title.profile_image %}
<img src="{{ title.profile_image }}" alt="" style="position: absolute; right: 8px; bottom: 8px; width: 38%; max-height: 78%; object-fit: {{ title.profile_image_size_mode or 'contain' }}; opacity: {{ title.profile_image_opacity or 0.12 }}; pointer-events: none; z-index: 1;">
{% endif %}
</div>
{% endfor %}
</div>
@@ -3,7 +3,7 @@
<h2 class="section-title">群友称号</h2>
<div class="users-grid">
{% for title in titles %}
<div class="user-title">
<div class="user-title" style="position: relative; overflow: hidden;">
<div class="user-info">
{% if title.avatar_data %}
<img src="{{ title.avatar_data }}" class="user-avatar" alt="头像">
@@ -14,13 +14,16 @@
<div class="user-name">{{ title.name }}</div>
<div class="user-badges">
<div class="user-title-badge">{{ title.title }}</div>
<div class="user-mbti">{{ title.mbti }}</div>
<div class="user-mbti">{{ title.profile_display or title.mbti }}</div>
</div>
</div>
</div>
<div class="user-reason">{{ title.reason }}</div>
{% if title.profile_image %}
<img src="{{ title.profile_image }}" alt="" style="position: absolute; right: 8px; bottom: 8px; width: 38%; max-height: 82%; object-fit: {{ title.profile_image_size_mode or 'contain' }}; opacity: {{ title.profile_image_opacity or 0.12 }}; pointer-events: none; z-index: 1;">
{% endif %}
</div>
{% endfor %}
</div>
</div>
{% endif %}
{% endif %}
@@ -1,7 +1,7 @@
{% if titles %}
<div class="user-grid">
{% for title in titles %}
<div class="user-card avoid-break">
<div class="user-card avoid-break" style="position: relative; overflow: hidden;">
<div class="user-avatar">
{% if title.avatar_data %}
<img src="{{ title.avatar_data }}" alt="{{ title.name }}"
@@ -15,12 +15,15 @@
<div class="user-info">
<h4>{{ title.name }}</h4>
<div style="margin-bottom: 10px;">
<span class="tag mbti">{{ title.mbti }}</span>
<span class="tag mbti">{{ title.profile_display or title.mbti }}</span>
<span class="tag title">{{ title.title }}</span>
</div>
<div style="font-size: 0.85rem; color: var(--text-secondary); line-height: 1.4;">{{ title.reason }}</div>
</div>
{% if title.profile_image %}
<img src="{{ title.profile_image }}" alt="" style="position: absolute; right: 8px; bottom: 8px; width: 36%; max-height: 78%; object-fit: {{ title.profile_image_size_mode or 'contain' }}; opacity: {{ title.profile_image_opacity or 0.12 }}; pointer-events: none; z-index: 1;">
{% endif %}
</div>
{% endfor %}
</div>
{% endif %}
{% endif %}
@@ -1,6 +1,6 @@
{% if titles %}
{% for title in titles %}
<div class="title-item">
<div class="title-item" style="position: relative; overflow: hidden;">
<div class="title-avatar-wrap">
{% if title.avatar_data %}
<img src="{{ title.avatar_data }}" alt="{{ title.name }}">
@@ -13,12 +13,15 @@
<div class="title-info">
<h4>{{ title.name }}</h4>
<div class="title-badge">
<span>{{ title.mbti }}</span>
<span>{{ title.profile_display or title.mbti }}</span>
<span style="color:var(--c-dec-3);">//</span>
<span>{{ title.title }}</span>
</div>
<div class="title-reason">{{ title.reason }}</div>
</div>
{% if title.profile_image %}
<img src="{{ title.profile_image }}" alt="" style="position: absolute; right: 8px; bottom: 8px; width: 35%; max-height: 80%; object-fit: {{ title.profile_image_size_mode or 'contain' }}; opacity: {{ title.profile_image_opacity or 0.12 }}; pointer-events: none; z-index: 1;">
{% endif %}
</div>
{% endfor %}
{% endif %}
{% endif %}
@@ -9,7 +9,7 @@
</div>
<div class="masonry-grid">
{% for title in titles %}
<div class="user-card avoid-break">
<div class="user-card avoid-break" style="position: relative; overflow: hidden;">
<div class="card-tape"></div>
<div class="user-header">
<div class="u-avatar">
@@ -26,15 +26,18 @@
<div class="u-name">{{ title.name }}</div>
<div class="badges">
<span class="badge title">{{ title.title }}</span>
<span class="badge mbti">{{ title.mbti }}</span>
<span class="badge mbti">{{ title.profile_display or title.mbti }}</span>
</div>
</div>
</div>
<div class="u-reason">
{{ title.reason }}
</div>
{% if title.profile_image %}
<img src="{{ title.profile_image }}" alt="" style="position: absolute; right: 8px; bottom: 8px; width: 38%; max-height: 80%; object-fit: {{ title.profile_image_size_mode or 'contain' }}; opacity: {{ title.profile_image_opacity or 0.12 }}; pointer-events: none; z-index: 1;">
{% endif %}
</div>
{% endfor %}
</div>
</div>
{% endif %}
{% endif %}
@@ -2,11 +2,14 @@
<div class="section">
<h2>群友称号</h2>
{% for item in titles %}
<div style="margin-bottom: 10px; border: 1px solid #eee; padding: 5px;">
<div style="margin-bottom: 10px; border: 1px solid #eee; padding: 5px; position: relative; overflow: hidden;">
<div style="font-weight: bold;">{{ item.name }} - {{ item.title }}</div>
<div style="font-size: 0.8em; background: #eee; display: inline-block; padding: 2px 5px; border-radius: 3px;">{{ item.mbti }}</div>
<div style="font-size: 0.9em; color: #555; margin-top: 5px;">{{ item.reason }}</div>
<div style="font-size: 0.8em; background: #eee; display: inline-block; padding: 2px 5px; border-radius: 3px; position: relative; z-index: 1;">{{ item.profile_display or item.mbti }}</div>
<div style="font-size: 0.9em; color: #555; margin-top: 5px; position: relative; z-index: 1;">{{ item.reason }}</div>
{% if item.profile_image %}
<img src="{{ item.profile_image }}" alt="" style="position: absolute; right: 4px; bottom: 4px; width: 36%; max-height: 90%; object-fit: {{ item.profile_image_size_mode or 'contain' }}; opacity: {{ item.profile_image_opacity or 0.12 }}; pointer-events: none; z-index: 2;">
{% endif %}
</div>
{% endfor %}
</div>
{% endif %}
{% endif %}
@@ -1,7 +1,7 @@
{% if titles %}
<div class="sf-user-masonry">
{% for title in titles %}
<div class="sf-user-card avoid-break">
<div class="sf-user-card avoid-break" style="position: relative; overflow: hidden;">
<div class="sf-card-header">
<div class="sf-avatar-wrap">
{% if title.avatar_data %}
@@ -14,13 +14,16 @@
<div class="sf-user-info">
<h4 class="sf-user-name" style="margin: 0 0 5px; font-size: 1.3rem;">{{ title.name }}</h4>
<div class="sf-user-badges">
<span class="sf-badge mbti">{{ title.mbti }}</span>
<span class="sf-badge mbti">{{ title.profile_display or title.mbti }}</span>
<span class="sf-badge sf-title">{{ title.title }}</span>
</div>
</div>
</div>
<div class="sf-user-reason">{{ title.reason }}</div>
{% if title.profile_image %}
<img src="{{ title.profile_image }}" alt="" style="position: absolute; right: 8px; bottom: 8px; width: 36%; max-height: 78%; object-fit: {{ title.profile_image_size_mode or 'contain' }}; opacity: {{ title.profile_image_opacity or 0.12 }}; pointer-events: none; z-index: 1;">
{% endif %}
</div>
{% endfor %}
</div>
{% endif %}
{% endif %}