码桶
发现社区成员的开源项目
index.html53 KB
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="theme-color" content="#fff2f8">
<title>萝莉气质研究所|隐藏属性测试</title>
<style>
:root {
--paper: #fff2f8;
--surface: #fffafd;
--ink: #523c52;
--muted: #8a7688;
--line: #efc5db;
--coral: #ff709f;
--coral-dark: #c94070;
--teal: #56a2ae;
--yellow: #ffd56f;
--blue: #8794cc;
--plum: #a278af;
--shadow: 0 18px 48px rgba(151, 78, 118, 0.16);
--radius: 8px;
}
* { box-sizing: border-box; }
html { scroll-behavior: smooth; }
body {
margin: 0;
min-width: 320px;
min-height: 100vh;
background: var(--paper);
color: var(--ink);
font-family: "Microsoft YaHei", "PingFang SC", "Noto Sans CJK SC", Arial, sans-serif;
letter-spacing: 0;
}
button, input { font: inherit; }
button { letter-spacing: 0; }
button:focus-visible,
summary:focus-visible {
outline: 3px solid rgba(255, 112, 159, 0.3);
outline-offset: 3px;
}
.app-shell {
width: min(1180px, calc(100% - 32px));
min-height: min(760px, calc(100vh - 48px));
margin: 24px auto;
display: grid;
grid-template-columns: minmax(250px, 0.72fr) minmax(0, 1.55fr);
overflow: hidden;
border: 1px solid #e6a8c7;
border-radius: var(--radius);
background: var(--surface);
box-shadow: var(--shadow);
}
.brand-panel {
position: relative;
display: flex;
min-height: 100%;
flex-direction: column;
overflow: hidden;
padding: 30px;
border-right: 1px solid #efc5db;
background: #fff0f7;
color: var(--ink);
}
.brand-panel::after {
position: absolute;
right: -38px;
bottom: -44px;
width: 156px;
height: 156px;
border: 18px solid #f8c8de;
content: "";
transform: rotate(45deg);
opacity: 0.72;
}
.brand-lockup {
position: relative;
z-index: 1;
}
.eyebrow {
display: flex;
align-items: center;
gap: 9px;
margin: 0 0 14px;
color: #aa6c90;
font-size: 12px;
font-weight: 700;
text-transform: uppercase;
}
.eyebrow::before {
width: 24px;
height: 3px;
background: var(--yellow);
content: "";
}
.brand-title {
margin: 0;
font-size: 31px;
line-height: 1.22;
font-weight: 900;
}
.brand-subtitle {
max-width: 260px;
margin: 12px 0 0;
color: #876f82;
font-size: 14px;
line-height: 1.75;
}
.signal-board {
position: relative;
z-index: 1;
margin: auto 0;
padding: 21px 0;
}
.signal-meta {
display: flex;
justify-content: space-between;
margin-bottom: 11px;
color: #a87996;
font-family: Consolas, monospace;
font-size: 11px;
}
.signal-screen {
position: relative;
height: 190px;
overflow: hidden;
border: 1px solid #efb8d4;
background: #fffafd;
}
.signal-screen::before,
.signal-screen::after {
position: absolute;
content: "";
background: rgba(255, 112, 159, 0.58);
}
.signal-screen::before {
top: 50%;
left: 12px;
right: 12px;
height: 1px;
}
.signal-screen::after {
top: 12px;
bottom: 12px;
left: 50%;
width: 1px;
}
.avatar-mark {
position: absolute;
z-index: 2;
top: 50%;
left: 50%;
width: 112px;
height: 124px;
border: 3px solid #f18ab1;
border-radius: 46% 46% 42% 42%;
background: #efb078;
transform: translate(-50%, -50%);
box-shadow: 7px 7px 0 #c3e8ea;
}
.avatar-mark::before {
position: absolute;
top: 37px;
left: 20px;
width: 68px;
height: 62px;
border: 2px solid #c77b93;
border-radius: 48% 48% 44% 44%;
background: #fff1df;
content: "";
}
.avatar-mark::after {
position: absolute;
top: 61px;
left: 39px;
width: 7px;
height: 7px;
border-radius: 50%;
background: #7096cb;
box-shadow: 27px 0 0 #7096cb;
content: "";
}
.avatar-bow,
.avatar-bow::before,
.avatar-bow::after {
position: absolute;
z-index: 4;
width: 22px;
height: 18px;
border: 2px solid #cf4d7f;
border-radius: 10px 4px 10px 4px;
background: #ff80aa;
content: "";
}
.avatar-bow {
top: 8px;
left: 45px;
width: 20px;
height: 20px;
border-radius: 50%;
}
.avatar-bow::before { top: -3px; left: -23px; transform: rotate(-22deg); }
.avatar-bow::after { top: -3px; left: 19px; transform: rotate(22deg); }
.scan-line {
position: absolute;
z-index: 3;
top: 10px;
right: 8px;
left: 8px;
height: 2px;
background: var(--coral);
box-shadow: 0 0 10px rgba(255, 112, 159, 0.45);
animation: scan 3.2s ease-in-out infinite alternate;
}
.signal-label {
position: absolute;
z-index: 4;
right: 9px;
bottom: 8px;
padding: 4px 6px;
background: #dff5f4;
color: #4b7c82;
font-family: Consolas, monospace;
font-size: 10px;
font-weight: 800;
}
.lab-note {
position: relative;
z-index: 1;
display: grid;
grid-template-columns: 1fr auto;
gap: 16px;
align-items: end;
padding-top: 18px;
border-top: 1px solid #edb6d1;
color: #98788c;
font-size: 12px;
line-height: 1.55;
}
.lab-note strong {
color: #cc4a78;
font-family: Consolas, monospace;
font-size: 21px;
}
.main-panel {
display: flex;
min-width: 0;
flex-direction: column;
padding: 28px 34px 32px;
}
.topbar {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 18px;
align-items: center;
padding-bottom: 21px;
border-bottom: 1px solid var(--line);
}
.progress-wrap {
min-width: 0;
}
.progress-meta {
display: flex;
justify-content: space-between;
gap: 12px;
margin-bottom: 8px;
color: var(--muted);
font-size: 12px;
font-weight: 700;
}
.progress-track {
height: 7px;
overflow: hidden;
border: 1px solid #e9b4cf;
background: #fde5ef;
}
.progress-fill {
width: 8.333%;
height: 100%;
background: var(--coral);
transition: width 360ms ease;
}
.icon-button {
display: inline-grid;
width: 38px;
height: 38px;
place-items: center;
border: 1px solid #df9cbe;
border-radius: 4px;
background: transparent;
color: var(--ink);
cursor: pointer;
font-size: 19px;
transition: background 160ms ease, color 160ms ease, transform 160ms ease;
}
.icon-button:hover { background: var(--coral); border-color: var(--coral); color: #fff; }
.icon-button:active { transform: translateY(1px); }
.test-view {
display: flex;
min-height: 580px;
flex: 1;
flex-direction: column;
padding-top: 38px;
}
.question-kicker {
display: flex;
align-items: center;
gap: 9px;
margin: 0 0 12px;
color: #bb4a76;
font-size: 12px;
font-weight: 800;
}
.question-kicker::before {
width: 9px;
height: 9px;
background: var(--coral);
content: "";
}
.question-title {
max-width: 720px;
margin: 0;
font-size: 25px;
line-height: 1.48;
font-weight: 900;
}
.question-title:focus,
.result-title:focus { outline: none; }
.question-context {
margin: 10px 0 24px;
color: var(--muted);
font-size: 14px;
line-height: 1.65;
}
.options {
display: grid;
gap: 10px;
}
.option-button {
position: relative;
display: grid;
grid-template-columns: 34px minmax(0, 1fr) 22px;
gap: 12px;
align-items: center;
width: 100%;
min-height: 57px;
padding: 10px 14px;
border: 1px solid var(--line);
border-radius: 6px;
background: #fff;
color: var(--ink);
text-align: left;
cursor: pointer;
transition: border-color 160ms ease, background 160ms ease, transform 160ms ease;
}
.option-button:hover {
border-color: #d977a5;
background: #fff2f8;
transform: translateX(3px);
}
.option-button[aria-checked="true"] {
border-color: var(--coral);
background: #ffe8f2;
box-shadow: inset 4px 0 0 var(--coral);
}
.option-key {
display: grid;
width: 30px;
height: 30px;
place-items: center;
border: 1px solid #e6b8ce;
border-radius: 4px;
color: #a76d8d;
font-family: Consolas, monospace;
font-size: 12px;
font-weight: 800;
}
.option-button[aria-checked="true"] .option-key {
border-color: var(--coral);
background: var(--coral);
color: #fff;
}
.option-text {
min-width: 0;
font-size: 15px;
line-height: 1.45;
}
.option-check {
color: transparent;
font-size: 19px;
font-weight: 900;
}
.option-button[aria-checked="true"] .option-check { color: var(--coral-dark); }
.question-footer {
display: flex;
justify-content: space-between;
gap: 16px;
align-items: center;
margin-top: auto;
padding-top: 28px;
}
.hint {
margin: 0;
color: var(--muted);
font-size: 12px;
}
.actions { display: flex; gap: 9px; }
.button {
display: inline-flex;
min-height: 42px;
align-items: center;
justify-content: center;
gap: 8px;
padding: 9px 17px;
border: 1px solid var(--ink);
border-radius: 4px;
cursor: pointer;
font-size: 14px;
font-weight: 800;
transition: background 160ms ease, color 160ms ease, transform 160ms ease;
}
.button:hover { transform: translateY(-1px); }
.button:active { transform: translateY(1px); }
.button-secondary { background: transparent; color: var(--ink); }
.button-secondary:hover { background: #ece8df; }
.button-primary { border-color: var(--coral); background: var(--coral); color: #fff; }
.button-primary:hover { border-color: var(--teal); background: var(--teal); }
.button:disabled {
border-color: #c8c4bc;
background: #dedbd4;
color: #8d918f;
cursor: not-allowed;
transform: none;
}
.result-view { display: none; }
.result-view.is-visible {
display: block;
animation: reveal 420ms ease both;
}
.result-header {
display: grid;
grid-template-columns: minmax(0, 1.1fr) minmax(230px, 0.9fr);
gap: 34px;
align-items: center;
padding: 30px 0 28px;
border-bottom: 1px solid var(--line);
}
.result-stamp {
display: inline-flex;
align-items: center;
gap: 7px;
margin-bottom: 13px;
padding: 5px 8px;
border: 1px solid #f18ab1;
border-radius: 4px;
color: #c94070;
font-size: 11px;
font-weight: 900;
}
.result-title {
margin: 0;
color: #b94173;
font-size: 35px;
line-height: 1.22;
font-weight: 900;
}
.result-summary {
margin: 15px 0 0;
color: #4e5658;
font-size: 15px;
line-height: 1.85;
}
.trait-list {
display: flex;
flex-wrap: wrap;
gap: 7px;
margin: 19px 0 0;
padding: 0;
list-style: none;
}
.trait-list li {
padding: 6px 9px;
border: 1px solid var(--ink);
border-radius: 4px;
background: #ffe49a;
font-size: 12px;
font-weight: 800;
}
.radar-wrap {
position: relative;
width: min(100%, 290px);
margin-inline: auto;
aspect-ratio: 1;
}
#radarCanvas {
width: 100%;
height: 100%;
}
.result-grid {
display: grid;
grid-template-columns: minmax(0, 0.95fr) minmax(0, 1.05fr);
gap: 30px;
padding: 27px 0;
}
.section-title {
margin: 0 0 17px;
font-size: 16px;
font-weight: 900;
}
.dimension-list { display: grid; gap: 14px; }
.dimension-row {
display: grid;
grid-template-columns: 62px minmax(0, 1fr) 38px;
gap: 10px;
align-items: center;
font-size: 12px;
font-weight: 700;
}
.dimension-bar {
height: 9px;
border: 1px solid var(--ink);
background: #e8e4dc;
}
.dimension-fill {
height: 100%;
background: var(--coral);
transform-origin: left;
animation: grow 700ms 160ms ease both;
}
.dimension-value {
color: var(--muted);
font-family: Consolas, monospace;
text-align: right;
}
.insight-box {
padding: 17px 18px;
border-left: 5px solid var(--coral);
background: #fff0f6;
}
.insight-box strong {
display: block;
margin-bottom: 6px;
font-size: 14px;
}
.insight-box p {
margin: 0;
color: #555d5f;
font-size: 13px;
line-height: 1.75;
}
details {
border-top: 1px solid var(--line);
border-bottom: 1px solid var(--line);
}
summary {
display: flex;
min-height: 52px;
align-items: center;
justify-content: space-between;
gap: 20px;
cursor: pointer;
font-size: 14px;
font-weight: 900;
list-style: none;
}
summary::-webkit-details-marker { display: none; }
summary::after {
content: "+";
font-size: 21px;
font-weight: 500;
}
details[open] summary::after { content: "−"; }
.review-list {
display: grid;
gap: 0;
margin: 0;
padding: 0 0 16px;
list-style: none;
}
.review-list li {
display: grid;
grid-template-columns: 30px minmax(0, 1fr);
gap: 9px;
padding: 10px 0;
border-top: 1px dashed var(--line);
font-size: 12px;
line-height: 1.6;
}
.review-index {
color: var(--coral-dark);
font-family: Consolas, monospace;
font-weight: 900;
}
.result-actions {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 9px;
padding-top: 22px;
}
.toast {
position: fixed;
z-index: 20;
bottom: 22px;
left: 50%;
padding: 10px 14px;
border: 1px solid var(--ink);
border-radius: 4px;
background: var(--ink);
color: #fff;
font-size: 13px;
box-shadow: var(--shadow);
opacity: 0;
pointer-events: none;
transform: translate(-50%, 12px);
transition: opacity 180ms ease, transform 180ms ease;
}
.toast.is-visible { opacity: 1; transform: translate(-50%, 0); }
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
@keyframes scan {
from { transform: translateY(6px); }
to { transform: translateY(158px); }
}
@keyframes reveal {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes grow {
from { transform: scaleX(0); }
to { transform: scaleX(1); }
}
@media (max-width: 840px) {
.app-shell {
width: min(720px, calc(100% - 24px));
margin: 12px auto;
grid-template-columns: 1fr;
}
.brand-panel {
min-height: auto;
padding: 23px 24px;
border-right: 0;
border-bottom: 1px solid var(--ink);
}
.brand-panel::after,
.signal-board,
.brand-subtitle { display: none; }
.brand-title { font-size: 24px; }
.lab-note {
position: absolute;
right: 24px;
bottom: 23px;
display: block;
padding: 0;
border: 0;
text-align: right;
}
.lab-note span { display: none; }
.main-panel { padding: 23px 24px 26px; }
.test-view { min-height: 570px; padding-top: 28px; }
.result-header { grid-template-columns: 1fr; }
.radar-wrap { width: 250px; }
.result-grid { grid-template-columns: 1fr; }
}
@media (max-width: 560px) {
.app-shell {
width: 100%;
min-height: 100vh;
margin: 0;
border: 0;
border-radius: 0;
}
.brand-panel { padding: 18px 19px; }
.brand-title { font-size: 21px; }
.eyebrow { margin-bottom: 8px; font-size: 10px; }
.lab-note { right: 19px; bottom: 19px; }
.lab-note strong { font-size: 17px; }
.main-panel { padding: 18px 17px 24px; }
.topbar { padding-bottom: 16px; }
.test-view { min-height: calc(100vh - 135px); padding-top: 24px; }
.question-title { font-size: 21px; }
.question-context { margin-bottom: 19px; }
.option-button { grid-template-columns: 31px minmax(0, 1fr) 18px; min-height: 53px; padding: 9px 11px; }
.option-text { font-size: 14px; }
.question-footer { align-items: flex-end; }
.hint { display: none; }
.actions { width: 100%; }
.actions .button { flex: 1; }
.result-title { font-size: 29px; }
.result-header { gap: 20px; padding-top: 25px; }
.radar-wrap { width: min(100%, 240px); }
.dimension-row { grid-template-columns: 53px minmax(0, 1fr) 34px; }
.result-actions .button { flex: 1 1 140px; }
}
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
scroll-behavior: auto !important;
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}
</style>
</head>
<body>
<main class="app-shell" aria-labelledby="appTitle">
<aside class="brand-panel">
<div class="brand-lockup">
<p class="eyebrow">Loli Profile · 07</p>
<h1 class="brand-title" id="appTitle">萝莉气质研究所</h1>
<p class="brand-subtitle">用 12 个生活切片,识别你的发色、瞳色与专属软萌属性。</p>
</div>
<div class="signal-board" aria-hidden="true">
<div class="signal-meta"><span>LOLI PROFILE</span><span>MATCHING</span></div>
<div class="signal-screen">
<div class="scan-line"></div>
<div class="avatar-mark"><span class="avatar-bow"></span></div>
<span class="signal-label">CUTE MODE</span>
</div>
</div>
<div class="lab-note">
<span>选你最心动的答案<br>第一反应最接近你</span>
<strong id="sideCounter">01/12</strong>
</div>
</aside>
<section class="main-panel">
<header class="topbar">
<div class="progress-wrap">
<div class="progress-meta">
<span id="progressLabel">正在采集日常偏好</span>
<span id="progressPercent">8%</span>
</div>
<div class="progress-track" id="progressTrack" role="progressbar" aria-label="测试进度" aria-valuemin="1" aria-valuemax="12" aria-valuenow="1">
<div class="progress-fill" id="progressFill"></div>
</div>
</div>
<button class="icon-button" id="resetButton" type="button" title="重新开始" aria-label="重新开始">↻</button>
</header>
<section class="test-view" id="testView" aria-live="polite">
<p class="question-kicker" id="questionKicker">生活切片 · 热身</p>
<h2 class="question-title" id="questionTitle"></h2>
<p class="question-context" id="questionContext"></p>
<div class="options" id="options" role="radiogroup" aria-labelledby="questionTitle"></div>
<footer class="question-footer">
<p class="hint">按数字键 1–5 选择,Enter 继续</p>
<div class="actions">
<button class="button button-secondary" id="prevButton" type="button">← 上一题</button>
<button class="button button-primary" id="nextButton" type="button" disabled>下一题 →</button>
</div>
</footer>
</section>
<section class="result-view" id="resultView" aria-live="polite">
<div class="result-header">
<div>
<span class="result-stamp">萝莉档案完成 · CUTE FOUND</span>
<h2 class="result-title" id="resultTitle"></h2>
<p class="result-summary" id="resultSummary"></p>
<ul class="trait-list" id="traitList"></ul>
</div>
<div class="radar-wrap">
<canvas id="radarCanvas" width="580" height="580" aria-label="五维气质雷达图"></canvas>
</div>
</div>
<div class="result-grid">
<div>
<h3 class="section-title">你的五维光谱</h3>
<div class="dimension-list" id="dimensionList"></div>
</div>
<div>
<h3 class="section-title">研究员手记</h3>
<div class="insight-box">
<strong id="insightTitle"></strong>
<p id="insightText"></p>
</div>
</div>
</div>
<details>
<summary>查看我的 12 个选择</summary>
<ol class="review-list" id="reviewList"></ol>
</details>
<div class="result-actions">
<button class="button button-secondary" id="copyButton" type="button">复制结果</button>
<button class="button button-secondary" id="saveButton" type="button">保存结果图</button>
<button class="button button-primary" id="restartButton" type="button">重新测试 ↻</button>
</div>
</section>
</section>
</main>
<div class="toast" id="toast" role="status" aria-live="polite"></div>
<script>
const DIMENSIONS = {
spark: { label: "行动力", short: "行动", color: "#e65f51" },
warmth: { label: "共情力", short: "共情", color: "#f2bd4b" },
logic: { label: "洞察力", short: "洞察", color: "#3f68a8" },
dream: { label: "想象力", short: "想象", color: "#765077" },
ease: { label: "松弛感", short: "自在", color: "#167c80" }
};
const questions = [
{
kicker: "生活切片 · 热身",
text: "闹钟响起后的第一个念头,最像下面哪一句?",
context: "别想太久,抓住刚醒来的那一秒。",
options: [
{ text: "今天会发生什么有趣的事?", scores: { spark: 3, dream: 1 } },
{ text: "先看看有没有人给我发消息", scores: { warmth: 3, ease: 1 } },
{ text: "再睡五分钟,世界稍后再说", scores: { ease: 3, dream: 1 } },
{ text: "快速过一遍今天的安排", scores: { logic: 3, spark: 1 } },
{ text: "刚才那个梦好像还能续上", scores: { dream: 3, warmth: 1 } }
]
},
{
kicker: "生活切片 · 补给",
text: "走进便利店,只能带走一样东西,你会拿什么?",
context: "现在是普通的傍晚,没有折扣,也没有人催你。",
options: [
{ text: "没喝过的新口味饮料", scores: { spark: 3, ease: 1 } },
{ text: "熟悉又稳妥的热饮", scores: { warmth: 3, logic: 1 } },
{ text: "成分表最干净的那一款", scores: { logic: 3, ease: 1 } },
{ text: "包装设计最好看的限定款", scores: { dream: 3, spark: 1 } },
{ text: "随手拿一个,能解渴就行", scores: { ease: 3, warmth: 1 } }
]
},
{
kicker: "关系切片 · 初见",
text: "第一次加入陌生人的聚会,你通常会怎样进入状态?",
context: "大家看起来都挺友好,只是你一个人都不认识。",
options: [
{ text: "主动抛出一个轻松的话题", scores: { spark: 3, warmth: 1 } },
{ text: "先和身边的人小范围聊天", scores: { warmth: 3, ease: 1 } },
{ text: "观察大家的关系和氛围", scores: { logic: 3, dream: 1 } },
{ text: "找到有趣的人,聊点不一样的", scores: { dream: 3, spark: 1 } },
{ text: "不刻意表现,舒服就好", scores: { ease: 3, logic: 1 } }
]
},
{
kicker: "选择切片 · 周末",
text: "突然多出一个完全空白的周六,你更想把它交给谁?",
context: "没有待办、没有工作消息,也没有必须完成的计划。",
options: [
{ text: "一场说走就走的近郊探索", scores: { spark: 3, dream: 1 } },
{ text: "约上喜欢的人好好吃顿饭", scores: { warmth: 3, spark: 1 } },
{ text: "一本书或一部想看很久的片", scores: { logic: 3, ease: 1 } },
{ text: "做点手工、拍照或写点东西", scores: { dream: 3, warmth: 1 } },
{ text: "宅在家里,想做什么就做什么", scores: { ease: 3, logic: 1 } }
]
},
{
kicker: "情绪切片 · 阴天",
text: "计划被突发状况打乱时,你的真实反应更接近?",
context: "允许诚实一点,这里不会给“情绪稳定”加分。",
options: [
{ text: "立刻切换方案,继续往前", scores: { spark: 3, logic: 1 } },
{ text: "先确认有没有影响到别人", scores: { warmth: 3, logic: 1 } },
{ text: "弄清原因,再决定怎么处理", scores: { logic: 3, ease: 1 } },
{ text: "把意外当作剧情的新分支", scores: { dream: 3, spark: 1 } },
{ text: "接受变化,顺着当下走", scores: { ease: 3, warmth: 1 } }
]
},
{
kicker: "审美切片 · 房间",
text: "如果房间里只能保留一件“没什么用但很喜欢”的东西?",
context: "它不需要证明价值,只需要让你愿意多看一眼。",
options: [
{ text: "一件有冒险故事的纪念品", scores: { spark: 3, warmth: 1 } },
{ text: "朋友认真挑给我的礼物", scores: { warmth: 3, dream: 1 } },
{ text: "设计精巧、越看越妙的小物", scores: { logic: 3, dream: 1 } },
{ text: "某个虚构世界的角色周边", scores: { dream: 3, warmth: 1 } },
{ text: "触感很好、看着就舒服的抱枕", scores: { ease: 3, spark: 1 } }
]
},
{
kicker: "能力切片 · 团队",
text: "五人小队接到新任务,你自然会站到哪个位置?",
context: "不是职位选择,而是你最容易进入的状态。",
options: [
{ text: "开路的人:先做起来再调整", scores: { spark: 3, ease: 1 } },
{ text: "连接的人:让每个人都被听见", scores: { warmth: 3, dream: 1 } },
{ text: "判断的人:拆解信息和风险", scores: { logic: 3, spark: 1 } },
{ text: "点子的人:提供意外的新方向", scores: { dream: 3, logic: 1 } },
{ text: "稳场的人:在混乱里保持从容", scores: { ease: 3, warmth: 1 } }
]
},
{
kicker: "直觉切片 · 礼物",
text: "为很重要的人挑礼物,你最在意哪件事?",
context: "预算刚刚好,时间也足够。",
options: [
{ text: "能不能制造一个难忘瞬间", scores: { spark: 3, dream: 1 } },
{ text: "它是不是贴合对方真正的需要", scores: { warmth: 3, logic: 1 } },
{ text: "品质、功能和使用周期", scores: { logic: 3, ease: 1 } },
{ text: "它背后的隐喻与独特故事", scores: { dream: 3, warmth: 1 } },
{ text: "对方收到时会不会毫无压力", scores: { ease: 3, warmth: 1 } }
]
},
{
kicker: "社交切片 · 消息",
text: "朋友深夜发来一句“有空吗”,你会先做什么?",
context: "没有更多上下文,你并不知道发生了什么。",
options: [
{ text: "直接拨过去,马上响应", scores: { spark: 3, warmth: 1 } },
{ text: "回复“在,怎么了?”", scores: { warmth: 3, ease: 1 } },
{ text: "回忆他最近是否有异常", scores: { logic: 3, warmth: 1 } },
{ text: "准备好听一个漫长的故事", scores: { dream: 3, warmth: 1 } },
{ text: "先回一个让人安心的表情", scores: { ease: 3, spark: 1 } }
]
},
{
kicker: "想象切片 · 世界",
text: "如果能在另一个世界生活一个月,你会选择哪里?",
context: "一个月后会平安返回,记忆也会完整保留。",
options: [
{ text: "地图还没画完的奇幻大陆", scores: { spark: 3, dream: 1 } },
{ text: "邻里彼此照顾的海边小镇", scores: { warmth: 3, ease: 1 } },
{ text: "知识开放的未来研究城", scores: { logic: 3, spark: 1 } },
{ text: "梦境会变成实体的漂浮岛", scores: { dream: 3, ease: 1 } },
{ text: "时间很慢、四季温柔的山谷", scores: { ease: 3, warmth: 1 } }
]
},
{
kicker: "价值切片 · 认可",
text: "听到哪一句评价时,你会在心里偷偷高兴很久?",
context: "那个人说得很真诚,而且正好看见了你。",
options: [
{ text: "“和你在一起,总会有新鲜事。”", scores: { spark: 3, dream: 1 } },
{ text: "“你真的很会照顾人的感受。”", scores: { warmth: 3, ease: 1 } },
{ text: "“你总能发现别人忽略的细节。”", scores: { logic: 3, dream: 1 } },
{ text: "“你的想法总有一种独特的美。”", scores: { dream: 3, spark: 1 } },
{ text: "“和你待在一起很放松。”", scores: { ease: 3, warmth: 1 } }
]
},
{
kicker: "核心切片 · 最终题",
text: "故事落幕时,你最希望自己的角色留下什么?",
context: "这是最后一个切片。选那个让你心里轻轻一动的答案。",
options: [
{ text: "“她真的勇敢地活过。”", scores: { spark: 3, warmth: 1 } },
{ text: "“因为她,有人被温柔接住。”", scores: { warmth: 3, dream: 1 } },
{ text: "“她看清了真相,也守住了原则。”", scores: { logic: 3, ease: 1 } },
{ text: "“她让平凡世界多了一点想象。”", scores: { dream: 3, spark: 1 } },
{ text: "“她按自己的节奏,过得很自在。”", scores: { ease: 3, logic: 1 } }
]
}
];
const results = {
spark: {
title: "金发碧眼、元气满满的小萝莉",
summary: "你的萝莉属性是明亮又勇敢的元气系。金色的发梢和碧蓝眼眸藏不住好奇心,看到新鲜事总想第一个出发,把普通日子也过成小小冒险。",
traits: ["金发碧眼", "元气冒险", "笑容发电机"],
insightTitle: "你是会把周围人一起带动起来的小萝莉",
insightText: "你最迷人的地方,是愿意先踏出一步的勇气。保持这份闪闪发光的行动力,同时给自己留一点慢下来补充能量的时间。"
},
warmth: {
title: "粉发异瞳、软萌可爱的小萝莉",
summary: "你的萝莉属性是会治愈人心的软萌系。粉色发丝配着灵动异瞳,让人一眼就想靠近;你总能发现别人没说出口的小情绪,并用温柔把它们接住。",
traits: ["粉发异瞳", "软萌治愈", "贴心守护"],
insightTitle: "你的存在自带让人安心的可爱魔法",
insightText: "你的细腻与善意从不多余,它们让身边的人感到被重视。也别忘了把这份温柔留一部分给自己,软萌的小萝莉也需要被好好照顾。"
},
logic: {
title: "黑发红瞳、安静聪慧的小萝莉",
summary: "你的萝莉属性是冷静又可靠的观察系。乌黑长发与红色眼眸带着一点神秘感,你习惯默默看清每一个细节,关键时刻总能给出让人安心的判断。",
traits: ["黑发红瞳", "安静聪慧", "细节捕手"],
insightTitle: "你是藏着小小智慧库的观察型小萝莉",
insightText: "你总能在热闹背后找到真正重要的线索。保持这份敏锐,也允许自己偶尔跟着直觉和好奇心走一走,会发现更多有趣的故事。"
},
dream: {
title: "银发紫眸、梦幻甜美的小萝莉",
summary: "你的萝莉属性是住在想象星云里的梦幻系。银色长发与紫色眼眸像藏着一整片夜空,你会为日常添上闪闪发光的滤镜,也总能从平凡里发现浪漫。",
traits: ["银发紫眸", "梦幻甜美", "脑洞星球"],
insightTitle: "你是会把现实点缀成童话的小萝莉",
insightText: "你的想象力不是飘忽,而是一种珍贵的创造力。把突然冒出的灵感记下来、画下来或做出来,你的小世界会因此越来越闪耀。"
},
ease: {
title: "棕发琥珀瞳、慵懒治愈的小萝莉",
summary: "你的萝莉属性是轻松又温柔的慵懒系。柔软棕发与琥珀色眼眸像午后阳光,让人想在你身边慢下来;你很懂得按自己的节奏,把日子过得舒服又可爱。",
traits: ["棕发琥珀瞳", "慵懒治愈", "松弛可爱"],
insightTitle: "你是让人忍不住放松下来的治愈小萝莉",
insightText: "你拥有难得的松弛感,也知道如何守住自己的节奏。继续享受小日子里的美好,偶尔主动抓住一个心动机会,会有可爱的惊喜等着你。"
}
};
const state = {
current: 0,
answers: Array(questions.length).fill(null)
};
let autoAdvanceTimer = null;
const elements = {
testView: document.getElementById("testView"),
resultView: document.getElementById("resultView"),
questionKicker: document.getElementById("questionKicker"),
questionTitle: document.getElementById("questionTitle"),
questionContext: document.getElementById("questionContext"),
options: document.getElementById("options"),
prevButton: document.getElementById("prevButton"),
nextButton: document.getElementById("nextButton"),
resetButton: document.getElementById("resetButton"),
progressFill: document.getElementById("progressFill"),
progressPercent: document.getElementById("progressPercent"),
progressTrack: document.getElementById("progressTrack"),
progressLabel: document.getElementById("progressLabel"),
sideCounter: document.getElementById("sideCounter"),
resultTitle: document.getElementById("resultTitle"),
resultSummary: document.getElementById("resultSummary"),
traitList: document.getElementById("traitList"),
dimensionList: document.getElementById("dimensionList"),
insightTitle: document.getElementById("insightTitle"),
insightText: document.getElementById("insightText"),
reviewList: document.getElementById("reviewList"),
radarCanvas: document.getElementById("radarCanvas"),
copyButton: document.getElementById("copyButton"),
saveButton: document.getElementById("saveButton"),
restartButton: document.getElementById("restartButton"),
toast: document.getElementById("toast")
};
function renderQuestion() {
const question = questions[state.current];
const selected = state.answers[state.current];
const progress = Math.round(((state.current + 1) / questions.length) * 100);
elements.questionKicker.textContent = question.kicker;
elements.questionTitle.textContent = question.text;
elements.questionContext.textContent = question.context;
elements.options.innerHTML = "";
question.options.forEach((option, index) => {
const button = document.createElement("button");
const isSelected = selected === index;
button.type = "button";
button.className = "option-button";
button.setAttribute("role", "radio");
button.setAttribute("aria-checked", String(isSelected));
button.dataset.index = index;
button.innerHTML = `
<span class="option-key">${index + 1}</span>
<span class="option-text">${option.text}</span>
<span class="option-check" aria-hidden="true">✓</span>
`;
button.addEventListener("click", () => selectAnswer(index));
elements.options.appendChild(button);
});
elements.progressFill.style.width = `${progress}%`;
elements.progressPercent.textContent = `${progress}%`;
elements.progressTrack.setAttribute("aria-valuenow", String(state.current + 1));
elements.progressLabel.textContent = state.current < 4 ? "正在采集日常偏好" : state.current < 8 ? "正在建立气质坐标" : "正在校准隐藏属性";
elements.sideCounter.textContent = `${String(state.current + 1).padStart(2, "0")}/${questions.length}`;
elements.prevButton.disabled = state.current === 0;
elements.nextButton.disabled = selected === null;
elements.nextButton.textContent = state.current === questions.length - 1 ? "生成报告 →" : "下一题 →";
persistState();
}
function selectAnswer(index) {
const questionIndex = state.current;
state.answers[state.current] = index;
const buttons = elements.options.querySelectorAll(".option-button");
buttons.forEach((button, buttonIndex) => {
button.setAttribute("aria-checked", String(buttonIndex === index));
});
elements.nextButton.disabled = false;
persistState();
window.clearTimeout(autoAdvanceTimer);
autoAdvanceTimer = window.setTimeout(() => {
if (state.current === questionIndex && state.answers[questionIndex] === index && !elements.resultView.classList.contains("is-visible")) {
nextQuestion();
}
}, 420);
}
function nextQuestion() {
window.clearTimeout(autoAdvanceTimer);
autoAdvanceTimer = null;
if (state.answers[state.current] === null) return;
if (state.current === questions.length - 1) {
showResult();
return;
}
state.current += 1;
renderQuestion();
focusQuestion();
}
function previousQuestion() {
window.clearTimeout(autoAdvanceTimer);
autoAdvanceTimer = null;
if (state.current === 0) return;
state.current -= 1;
renderQuestion();
focusQuestion();
}
function focusQuestion() {
elements.questionTitle.setAttribute("tabindex", "-1");
elements.questionTitle.focus({ preventScroll: true });
}
function calculateScores() {
const scores = Object.keys(DIMENSIONS).reduce((acc, key) => ({ ...acc, [key]: 0 }), {});
state.answers.forEach((answerIndex, questionIndex) => {
const answer = questions[questionIndex].options[answerIndex];
Object.entries(answer.scores).forEach(([key, value]) => { scores[key] += value; });
});
return scores;
}
function getPrimaryDimension(scores) {
return Object.keys(DIMENSIONS).reduce((best, key) => scores[key] > scores[best] ? key : best, "spark");
}
function showResult() {
const scores = calculateScores();
const primary = getPrimaryDimension(scores);
const result = results[primary];
const highest = Math.max(...Object.values(scores));
elements.testView.style.display = "none";
elements.resultView.classList.add("is-visible");
elements.progressFill.style.width = "100%";
elements.progressPercent.textContent = "100%";
elements.progressLabel.textContent = "气质报告已生成";
elements.sideCounter.textContent = "DONE";
elements.resultTitle.textContent = result.title;
elements.resultSummary.textContent = result.summary;
elements.insightTitle.textContent = result.insightTitle;
elements.insightText.textContent = result.insightText;
elements.traitList.innerHTML = result.traits.map(trait => `<li>${trait}</li>`).join("");
elements.dimensionList.innerHTML = Object.entries(DIMENSIONS).map(([key, dimension]) => {
const value = Math.round((scores[key] / highest) * 100);
return `
<div class="dimension-row">
<span>${dimension.label}</span>
<div class="dimension-bar" aria-label="${dimension.label} ${value}%">
<div class="dimension-fill" style="width:${value}%;background:${dimension.color}"></div>
</div>
<span class="dimension-value">${value}</span>
</div>
`;
}).join("");
elements.reviewList.innerHTML = state.answers.map((answerIndex, index) => `
<li>
<span class="review-index">${String(index + 1).padStart(2, "0")}</span>
<span><strong>${questions[index].text}</strong><br>${questions[index].options[answerIndex].text}</span>
</li>
`).join("");
drawRadar(scores);
localStorage.removeItem("dimensionLabState");
window.scrollTo({ top: 0, behavior: "smooth" });
elements.resultTitle.setAttribute("tabindex", "-1");
elements.resultTitle.focus({ preventScroll: true });
}
function drawRadar(scores) {
const canvas = elements.radarCanvas;
const ctx = canvas.getContext("2d");
const keys = Object.keys(DIMENSIONS);
const size = canvas.width;
const center = size / 2;
const radius = size * 0.31;
const maxScore = Math.max(...Object.values(scores));
const angleFor = index => -Math.PI / 2 + (Math.PI * 2 * index / keys.length);
ctx.clearRect(0, 0, size, size);
ctx.lineJoin = "round";
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.font = "700 24px Microsoft YaHei, sans-serif";
for (let level = 1; level <= 4; level += 1) {
ctx.beginPath();
keys.forEach((key, index) => {
const angle = angleFor(index);
const r = radius * level / 4;
const x = center + Math.cos(angle) * r;
const y = center + Math.sin(angle) * r;
index === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y);
});
ctx.closePath();
ctx.strokeStyle = level === 4 ? "#202628" : "#d7d3ca";
ctx.lineWidth = level === 4 ? 2.5 : 1.5;
ctx.stroke();
}
keys.forEach((key, index) => {
const angle = angleFor(index);
ctx.beginPath();
ctx.moveTo(center, center);
ctx.lineTo(center + Math.cos(angle) * radius, center + Math.sin(angle) * radius);
ctx.strokeStyle = "#d7d3ca";
ctx.lineWidth = 1.5;
ctx.stroke();
const labelRadius = radius + 50;
ctx.fillStyle = DIMENSIONS[key].color;
ctx.fillText(DIMENSIONS[key].short, center + Math.cos(angle) * labelRadius, center + Math.sin(angle) * labelRadius);
});
ctx.beginPath();
keys.forEach((key, index) => {
const angle = angleFor(index);
const valueRadius = radius * (scores[key] / maxScore);
const x = center + Math.cos(angle) * valueRadius;
const y = center + Math.sin(angle) * valueRadius;
index === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y);
});
ctx.closePath();
ctx.fillStyle = "rgba(22, 124, 128, 0.24)";
ctx.strokeStyle = "#167c80";
ctx.lineWidth = 5;
ctx.fill();
ctx.stroke();
keys.forEach((key, index) => {
const angle = angleFor(index);
const valueRadius = radius * (scores[key] / maxScore);
ctx.beginPath();
ctx.arc(center + Math.cos(angle) * valueRadius, center + Math.sin(angle) * valueRadius, 8, 0, Math.PI * 2);
ctx.fillStyle = DIMENSIONS[key].color;
ctx.fill();
ctx.strokeStyle = "#fffdf8";
ctx.lineWidth = 4;
ctx.stroke();
});
}
function copyResult() {
const scores = calculateScores();
const primary = getPrimaryDimension(scores);
const lines = Object.entries(DIMENSIONS).map(([key, value]) => `${value.label} ${scores[key]}`).join(" · ");
const text = `我的萝莉气质结果是「${results[primary].title}」!\n${results[primary].traits.join(" / ")}\n${lines}\n——来自萝莉气质研究所`;
if (navigator.clipboard && window.isSecureContext) {
navigator.clipboard.writeText(text).then(() => showToast("结果已复制"), () => fallbackCopy(text));
} else {
fallbackCopy(text);
}
}
function fallbackCopy(text) {
const textarea = document.createElement("textarea");
textarea.value = text;
textarea.style.position = "fixed";
textarea.style.opacity = "0";
document.body.appendChild(textarea);
textarea.select();
try {
document.execCommand("copy");
showToast("结果已复制");
} catch (error) {
showToast("复制失败,请手动截图保存");
}
textarea.remove();
}
function saveResultImage() {
const scores = calculateScores();
const primary = getPrimaryDimension(scores);
const result = results[primary];
const canvas = document.createElement("canvas");
canvas.width = 1200;
canvas.height = 1500;
const ctx = canvas.getContext("2d");
ctx.fillStyle = "#fff2f8";
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.strokeStyle = "#e6a8c7";
ctx.lineWidth = 4;
ctx.strokeRect(56, 56, 1088, 1388);
ctx.fillStyle = "#fff0f7";
ctx.fillRect(56, 56, 1088, 200);
ctx.fillStyle = "#ff709f";
ctx.fillRect(88, 94, 64, 8);
ctx.fillStyle = "#523c52";
ctx.font = "900 44px Microsoft YaHei, sans-serif";
ctx.fillText("萝莉气质研究所", 88, 162);
ctx.fillStyle = "#a87996";
ctx.font = "24px Microsoft YaHei, sans-serif";
ctx.fillText("LOLI PROFILE REPORT · 07", 88, 210);
ctx.fillStyle = "#c94070";
ctx.font = "900 25px Microsoft YaHei, sans-serif";
ctx.fillText("你的专属萝莉属性", 88, 342);
ctx.fillStyle = "#523c52";
ctx.font = "900 68px Microsoft YaHei, sans-serif";
ctx.fillText(result.title, 88, 430);
let tagX = 88;
result.traits.forEach(trait => {
ctx.font = "800 24px Microsoft YaHei, sans-serif";
const width = ctx.measureText(trait).width + 40;
ctx.fillStyle = "#ffe49a";
ctx.fillRect(tagX, 470, width, 48);
ctx.strokeStyle = "#d777a3";
ctx.lineWidth = 2;
ctx.strokeRect(tagX, 470, width, 48);
ctx.fillStyle = "#523c52";
ctx.fillText(trait, tagX + 20, 503);
tagX += width + 14;
});
const source = elements.radarCanvas;
ctx.drawImage(source, 150, 560, 500, 500);
ctx.fillStyle = "#523c52";
ctx.font = "900 28px Microsoft YaHei, sans-serif";
ctx.fillText("五维光谱", 720, 612);
const highest = Math.max(...Object.values(scores));
Object.entries(DIMENSIONS).forEach(([key, dimension], index) => {
const y = 680 + index * 94;
const value = Math.round(scores[key] / highest * 100);
ctx.fillStyle = "#523c52";
ctx.font = "700 23px Microsoft YaHei, sans-serif";
ctx.fillText(dimension.label, 720, y);
ctx.fillStyle = "#fde4ef";
ctx.fillRect(720, y + 22, 330, 16);
ctx.fillStyle = dimension.color;
ctx.fillRect(720, y + 22, 330 * value / 100, 16);
ctx.fillStyle = "#697074";
ctx.font = "22px Consolas, monospace";
ctx.fillText(String(value).padStart(3, "0"), 1062, y + 36);
});
ctx.fillStyle = "#fff0f6";
ctx.fillRect(88, 1150, 1024, 174);
ctx.fillStyle = "#ff709f";
ctx.fillRect(88, 1150, 10, 174);
ctx.fillStyle = "#523c52";
ctx.font = "900 26px Microsoft YaHei, sans-serif";
ctx.fillText(result.insightTitle, 128, 1205);
ctx.fillStyle = "#555d5f";
ctx.font = "22px Microsoft YaHei, sans-serif";
wrapCanvasText(ctx, result.insightText, 128, 1254, 920, 34, 2);
ctx.fillStyle = "#697074";
ctx.font = "20px Microsoft YaHei, sans-serif";
ctx.fillText("每一种选择,都会指向专属于你的小萝莉属性。", 88, 1390);
const link = document.createElement("a");
link.download = `萝莉气质研究所-${result.title}.png`;
link.href = canvas.toDataURL("image/png");
link.click();
showToast("结果图已保存");
}
function wrapCanvasText(ctx, text, x, y, maxWidth, lineHeight, maxLines) {
let line = "";
let lineCount = 0;
for (const char of text) {
const testLine = line + char;
if (ctx.measureText(testLine).width > maxWidth && line) {
ctx.fillText(line, x, y + lineCount * lineHeight);
line = char;
lineCount += 1;
if (lineCount >= maxLines) return;
} else {
line = testLine;
}
}
if (lineCount < maxLines) ctx.fillText(line, x, y + lineCount * lineHeight);
}
function showToast(message) {
elements.toast.textContent = message;
elements.toast.classList.add("is-visible");
window.clearTimeout(showToast.timer);
showToast.timer = window.setTimeout(() => elements.toast.classList.remove("is-visible"), 1900);
}
function persistState() {
try {
localStorage.setItem("dimensionLabState", JSON.stringify(state));
} catch (error) {
// The test remains fully functional when storage is unavailable.
}
}
function restoreState() {
try {
const saved = JSON.parse(localStorage.getItem("dimensionLabState"));
if (saved && Number.isInteger(saved.current) && Array.isArray(saved.answers) && saved.answers.length === questions.length) {
state.current = Math.min(Math.max(saved.current, 0), questions.length - 1);
state.answers = saved.answers.map(value => Number.isInteger(value) && value >= 0 && value < 5 ? value : null);
if (state.current > 0 || state.answers.some(answer => answer !== null)) {
window.setTimeout(() => showToast("已恢复上次答题进度"), 350);
}
}
} catch (error) {
localStorage.removeItem("dimensionLabState");
}
}
function restartTest() {
window.clearTimeout(autoAdvanceTimer);
autoAdvanceTimer = null;
state.current = 0;
state.answers = Array(questions.length).fill(null);
localStorage.removeItem("dimensionLabState");
elements.resultView.classList.remove("is-visible");
elements.testView.style.display = "flex";
renderQuestion();
window.scrollTo({ top: 0, behavior: "smooth" });
focusQuestion();
}
elements.nextButton.addEventListener("click", nextQuestion);
elements.prevButton.addEventListener("click", previousQuestion);
elements.resetButton.addEventListener("click", () => {
if (state.answers.some(answer => answer !== null)) {
const confirmed = window.confirm("确定清空当前答案并重新开始吗?");
if (!confirmed) return;
}
restartTest();
});
elements.restartButton.addEventListener("click", restartTest);
elements.copyButton.addEventListener("click", copyResult);
elements.saveButton.addEventListener("click", saveResultImage);
document.addEventListener("keydown", event => {
if (!elements.resultView.classList.contains("is-visible")) {
const numericKey = Number(event.key);
if (numericKey >= 1 && numericKey <= 5) {
selectAnswer(numericKey - 1);
elements.options.children[numericKey - 1].focus();
}
if (event.key === "Enter" && state.answers[state.current] !== null) {
event.preventDefault();
nextQuestion();
}
if (event.key === "ArrowLeft" && state.current > 0) previousQuestion();
}
});
restoreState();
renderQuestion();
</script>
</body>
</html>