@@ -0,0 +1,229 @@
|
||||
:root {
|
||||
--ink: #17211b;
|
||||
--muted: #657068;
|
||||
--paper: #f3f1e9;
|
||||
--panel: rgba(255, 255, 252, .78);
|
||||
--line: rgba(23, 33, 27, .13);
|
||||
--green: #196548;
|
||||
--lime: #cce85b;
|
||||
--orange: #ef704f;
|
||||
--blue: #6689c7;
|
||||
--radius: 22px;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
html { scroll-behavior: smooth; }
|
||||
body {
|
||||
margin: 0;
|
||||
color: var(--ink);
|
||||
background: var(--paper);
|
||||
font-family: Inter, "PingFang SC", "Microsoft YaHei", sans-serif;
|
||||
min-height: 100vh;
|
||||
}
|
||||
button, input, select, textarea { font: inherit; }
|
||||
button { color: inherit; }
|
||||
.ambient { position: fixed; border-radius: 50%; filter: blur(1px); pointer-events: none; z-index: -1; opacity: .55; }
|
||||
.ambient-one { width: 38vw; height: 38vw; right: -12vw; top: -12vw; background: radial-gradient(circle, #d9ee8c 0, transparent 68%); }
|
||||
.ambient-two { width: 42vw; height: 42vw; left: 12vw; bottom: -26vw; background: radial-gradient(circle, #d7e2f4 0, transparent 68%); }
|
||||
.app-shell { min-height: 100vh; display: grid; grid-template-columns: 230px 1fr; }
|
||||
.sidebar {
|
||||
position: sticky; top: 0; height: 100vh; padding: 30px 22px;
|
||||
border-right: 1px solid var(--line); display: flex; flex-direction: column;
|
||||
background: rgba(243, 241, 233, .74); backdrop-filter: blur(18px);
|
||||
}
|
||||
.brand { display: flex; gap: 12px; align-items: center; color: inherit; text-decoration: none; }
|
||||
.brand-mark {
|
||||
display: grid; place-items: center; width: 42px; height: 42px; border-radius: 13px;
|
||||
background: var(--ink); color: var(--lime); font-family: Georgia, serif; font-size: 24px;
|
||||
}
|
||||
.brand strong, .brand small { display: block; }
|
||||
.brand strong { font-size: 17px; letter-spacing: .08em; }
|
||||
.brand small { margin-top: 3px; color: var(--muted); font-size: 9px; letter-spacing: .22em; }
|
||||
.nav { margin-top: 58px; display: grid; gap: 7px; }
|
||||
.nav-item {
|
||||
border: 0; background: transparent; border-radius: 12px; padding: 13px 12px;
|
||||
text-align: left; cursor: pointer; color: var(--muted); transition: .2s ease;
|
||||
}
|
||||
.nav-item span { display: inline-block; width: 30px; font: 10px/1 Georgia, serif; color: #99a097; }
|
||||
.nav-item:hover, .nav-item.active { color: var(--ink); background: rgba(255,255,255,.7); transform: translateX(3px); }
|
||||
.nav-item.active { box-shadow: inset 3px 0 var(--green); }
|
||||
.sidebar-foot { margin-top: auto; display: grid; gap: 15px; }
|
||||
.system-status { font-size: 11px; color: var(--muted); text-transform: uppercase; letter-spacing: .1em; }
|
||||
.system-status i { width: 7px; height: 7px; margin-right: 7px; display: inline-block; border-radius: 50%; background: #5bb67c; box-shadow: 0 0 0 4px rgba(91,182,124,.13); }
|
||||
.ghost-button, .text-button {
|
||||
background: transparent; border: 1px solid var(--line); border-radius: 10px; padding: 11px 14px; cursor: pointer;
|
||||
}
|
||||
.main { padding: 0 46px 70px; min-width: 0; }
|
||||
.topbar { height: 82px; display: flex; align-items: center; justify-content: space-between; border-bottom: 1px solid var(--line); }
|
||||
.topbar p { color: var(--muted); font-size: 11px; letter-spacing: .08em; }
|
||||
.user-chip { display: flex; align-items: center; gap: 9px; font-size: 13px; }
|
||||
.avatar { width: 31px; height: 31px; border-radius: 50%; display: grid; place-items: center; background: var(--ink); color: white; font-size: 11px; }
|
||||
.view { display: none; animation: rise .35s ease; }
|
||||
.view.active { display: block; }
|
||||
@keyframes rise { from { opacity: 0; transform: translateY(8px); } }
|
||||
.hero-grid { display: grid; grid-template-columns: minmax(0, 1.75fr) minmax(280px, .75fr); gap: 18px; margin-top: 28px; }
|
||||
.hero, .daily-card, .spirit, .content-card, .editor-shell, .profile-panel {
|
||||
border: 1px solid rgba(23,33,27,.1); border-radius: var(--radius); background: var(--panel);
|
||||
box-shadow: 0 24px 70px rgba(38, 48, 40, .06); backdrop-filter: blur(14px);
|
||||
}
|
||||
.hero { min-height: 450px; padding: 55px; display: flex; flex-direction: column; position: relative; overflow: hidden; }
|
||||
.hero::after {
|
||||
content: ""; position: absolute; width: 330px; height: 330px; right: -100px; bottom: -150px;
|
||||
border: 55px solid rgba(25,101,72,.09); border-radius: 50%;
|
||||
}
|
||||
.kicker { color: var(--green); font-size: 10px; font-weight: 700; letter-spacing: .24em; }
|
||||
.hero h1, .page-title h1 { margin: 20px 0 18px; font: 500 clamp(42px, 5vw, 76px)/1.03 Georgia, "Songti SC", serif; letter-spacing: -.04em; }
|
||||
.hero h1 em { color: var(--green); font-weight: inherit; }
|
||||
.hero p { max-width: 600px; color: var(--muted); font-size: 15px; line-height: 1.9; }
|
||||
.hero-actions { margin-top: auto; display: flex; align-items: center; gap: 13px; position: relative; z-index: 1; }
|
||||
.primary-button, .dark-button {
|
||||
border: 0; border-radius: 12px; padding: 14px 19px; cursor: pointer; transition: .2s ease; font-weight: 650;
|
||||
}
|
||||
.primary-button { background: var(--green); color: white; }
|
||||
.primary-button b { margin-left: 22px; }
|
||||
.primary-button:hover, .dark-button:hover { transform: translateY(-2px); box-shadow: 0 12px 25px rgba(23,33,27,.16); }
|
||||
.text-button { border-color: transparent; }
|
||||
.hero-index { position: absolute; top: 35px; right: 45px; color: rgba(25,101,72,.12); font: 110px/1 Georgia, serif; }
|
||||
.daily-card { background: var(--ink); color: white; padding: 32px; display: flex; flex-direction: column; align-items: flex-start; }
|
||||
.card-label { color: #aeb9b1; font-size: 9px; letter-spacing: .22em; }
|
||||
.pulse-orbit {
|
||||
width: 130px; height: 130px; border: 1px solid rgba(204,232,91,.35); border-radius: 50%;
|
||||
margin: 35px auto 25px; align-self: center; display: grid; place-items: center; position: relative;
|
||||
}
|
||||
.pulse-orbit::before { content: ""; position: absolute; inset: 12px; border: 1px dashed rgba(204,232,91,.35); border-radius: 50%; }
|
||||
.pulse-orbit span { color: var(--lime); font: 48px/1 Georgia, serif; }
|
||||
.pulse-orbit small { position: absolute; bottom: 27px; color: #aeb9b1; }
|
||||
.daily-card h2 { margin: 10px 0 7px; font: 27px/1.2 Georgia, serif; }
|
||||
.daily-card p { color: #aeb9b1; line-height: 1.75; font-size: 13px; }
|
||||
.dark-button { margin-top: auto; width: 100%; background: var(--lime); color: var(--ink); }
|
||||
.section { margin-top: 70px; }
|
||||
.section-heading { display: flex; align-items: end; justify-content: space-between; margin-bottom: 22px; }
|
||||
.section-heading h2 { margin: 8px 0 0; font: 32px Georgia, serif; }
|
||||
.section-heading p { margin: 0; color: var(--muted); font-size: 13px; }
|
||||
.spirit-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 13px; }
|
||||
.spirit { min-height: 218px; padding: 24px; position: relative; overflow: hidden; transition: .2s ease; }
|
||||
.spirit:hover { transform: translateY(-5px); }
|
||||
.spirit b { color: #9da49f; font: 12px Georgia, serif; }
|
||||
.spirit h3 { margin: 31px 0 11px; font: 27px Georgia, serif; }
|
||||
.spirit p { color: var(--muted); font-size: 13px; line-height: 1.65; }
|
||||
.spirit span { position: absolute; bottom: 20px; color: var(--green); font-size: 10px; letter-spacing: .08em; }
|
||||
.spirit::after { content: ""; position: absolute; width: 75px; height: 75px; border-radius: 50%; right: -28px; top: -28px; opacity: .55; }
|
||||
.believer::after { background: var(--lime); }.communicator::after { background: var(--blue); }.practitioner::after { background: var(--orange); }.intuitive::after { background: #bb8ac4; }
|
||||
.page-title { padding: 65px 0 35px; }
|
||||
.page-title h1 { font-size: clamp(44px, 6vw, 70px); margin-bottom: 12px; }
|
||||
.page-title p { color: var(--muted); }
|
||||
.content-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 16px; }
|
||||
.content-grid.loading { color: var(--muted); display: block; padding: 40px 0; }
|
||||
.content-card { min-height: 235px; padding: 26px; display: flex; flex-direction: column; }
|
||||
.content-card .meta { color: var(--green); font-size: 9px; font-weight: 700; letter-spacing: .16em; text-transform: uppercase; }
|
||||
.content-card h3 { margin: 25px 0 11px; font: 25px/1.25 Georgia, serif; }
|
||||
.content-card p { color: var(--muted); font-size: 13px; line-height: 1.75; }
|
||||
.content-card footer { margin-top: auto; display: flex; align-items: center; justify-content: space-between; }
|
||||
.content-card footer span { color: var(--muted); font-size: 11px; }
|
||||
.content-card footer button { border: 0; border-bottom: 1px solid var(--ink); padding: 4px 0; background: transparent; cursor: pointer; }
|
||||
.track-switch { display: flex; gap: 5px; margin-bottom: 22px; }
|
||||
.track-switch button { border: 1px solid var(--line); padding: 9px 18px; border-radius: 99px; background: transparent; cursor: pointer; }
|
||||
.track-switch button.active { background: var(--ink); color: white; }
|
||||
.editor-shell { display: grid; grid-template-columns: 1fr 1fr; overflow: hidden; min-height: 430px; }
|
||||
.editor-pane, .preview-pane { padding: 28px; }
|
||||
.editor-pane { background: #1d2721; color: white; }
|
||||
.editor-pane label, .preview-pane > span { display: block; color: #9eaaa2; font-size: 10px; letter-spacing: .16em; text-transform: uppercase; }
|
||||
.editor-pane textarea { width: 100%; height: 330px; margin-top: 22px; resize: none; border: 0; outline: 0; background: transparent; color: #dcefa1; font: 16px/1.8 "SFMono-Regular", Consolas, monospace; }
|
||||
.preview-pane { display: flex; flex-direction: column; }
|
||||
#latex-preview { margin: auto; max-width: 100%; overflow-wrap: anywhere; font: 28px/1.6 Georgia, serif; }
|
||||
.preview-pane button { align-self: flex-end; }
|
||||
.profile-panel { padding: 35px; }
|
||||
.metric-grid { display: grid; grid-template-columns: repeat(5, 1fr); gap: 10px; }
|
||||
.metric { background: rgba(25,101,72,.06); border-radius: 14px; padding: 17px; }
|
||||
.metric b, .metric span { display: block; }.metric b { font: 28px Georgia, serif; }.metric span { margin-top: 5px; color: var(--muted); font-size: 11px; }
|
||||
.tool-grid { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); gap: 12px; margin-bottom: 22px; }
|
||||
.tool-card {
|
||||
min-height: 165px; border: 1px solid var(--line); border-radius: 18px; padding: 22px;
|
||||
background: rgba(255,255,252,.7); text-align: left; cursor: pointer; transition: .2s ease;
|
||||
}
|
||||
.tool-card:hover, .tool-card.active { transform: translateY(-3px); border-color: rgba(25,101,72,.48); box-shadow: 0 16px 35px rgba(38,48,40,.08); }
|
||||
.tool-card.active { background: var(--ink); color: white; }
|
||||
.tool-card > span { display: grid; place-items: center; width: 52px; height: 52px; border-radius: 15px; background: rgba(25,101,72,.08); color: var(--green); font: 700 17px Georgia, serif; }
|
||||
.tool-card.active > span { color: var(--lime); background: rgba(204,232,91,.1); }
|
||||
.tool-card b, .tool-card small { display: block; }.tool-card b { margin-top: 18px; font: 19px Georgia, serif; }.tool-card small { margin-top: 7px; color: var(--muted); line-height: 1.5; }
|
||||
.tool-card.active small { color: #aeb9b1; }
|
||||
.tool-workspace { display: none; border: 1px solid var(--line); border-radius: var(--radius); padding: 30px; background: var(--panel); }
|
||||
.tool-workspace.active { display: block; animation: rise .3s ease; }
|
||||
.workspace-heading { display: flex; justify-content: space-between; gap: 30px; align-items: end; margin-bottom: 25px; }
|
||||
.workspace-heading h2 { margin: 7px 0 0; font: 31px Georgia, serif; }.workspace-heading p { max-width: 480px; color: var(--muted); font-size: 13px; }
|
||||
.calculator-shell { max-width: 800px; margin: auto; }
|
||||
.calculator-display { min-height: 145px; border-radius: 18px; padding: 25px; background: var(--ink); color: white; display: flex; flex-direction: column; justify-content: space-between; text-align: right; }
|
||||
.calculator-display small { color: #9eaaa2; }.calculator-display output { color: var(--lime); font: 48px/1 Georgia, serif; overflow-wrap: anywhere; }
|
||||
.formula-input, .search-input { width: 100%; border: 1px solid var(--line); border-radius: 12px; padding: 14px 16px; background: white; outline-color: var(--green); }
|
||||
.calculator-shell > .formula-input { margin: 14px 0; font: 18px "SFMono-Regular", Consolas, monospace; }
|
||||
.calculator-actions { display: flex; flex-wrap: wrap; gap: 8px; }.calculator-actions button { border: 1px solid var(--line); border-radius: 10px; padding: 11px 15px; background: white; cursor: pointer; }.calculator-actions .primary-button { margin-left: auto; color: white; background: var(--green); border: 0; }
|
||||
.search-input { width: min(370px, 100%); }
|
||||
.symbol-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 10px; }
|
||||
.symbol-card { border: 1px solid var(--line); border-radius: 15px; padding: 18px; background: white; cursor: pointer; text-align: left; }
|
||||
.symbol-card strong { display: block; color: var(--green); font: 29px Georgia, serif; }.symbol-card b { display: block; margin-top: 10px; }.symbol-card small { display: block; margin-top: 5px; color: var(--muted); }.symbol-card code { display: inline-block; margin-top: 12px; padding: 4px 7px; border-radius: 6px; background: #f0f1eb; color: #405048; }
|
||||
.graph-shell { display: grid; grid-template-columns: 245px minmax(0, 1fr); gap: 20px; }
|
||||
.graph-controls { display: flex; flex-direction: column; gap: 17px; }.graph-controls label { display: grid; gap: 8px; color: var(--muted); font-size: 12px; }.graph-controls p { color: #b84136; font-size: 12px; }
|
||||
#graph-canvas { width: 100%; height: auto; border: 1px solid var(--line); border-radius: 17px; background: #fbfbf7; }
|
||||
.discover-title { padding-bottom: 24px; }
|
||||
.ability-map { border: 1px solid var(--line); border-radius: var(--radius); padding: 28px; background: rgba(255,255,252,.64); overflow: hidden; }
|
||||
.map-heading { display: flex; align-items: start; justify-content: space-between; gap: 20px; }.map-heading h2 { margin: 0 0 6px; font: 30px Georgia, serif; }.map-heading p { margin: 0; color: var(--muted); font-size: 13px; }
|
||||
.ability-legend { display: flex; gap: 12px; flex-wrap: wrap; justify-content: end; }.ability-legend span { color: var(--muted); font-size: 10px; }.ability-legend i { display: inline-block; width: 7px; height: 7px; border-radius: 50%; margin-right: 4px; }
|
||||
.map-stage { position: relative; max-width: 1050px; height: 390px; margin: 8px auto 0; }
|
||||
.map-lines { position: absolute; inset: 0; width: 100%; height: 100%; overflow: visible; }.map-lines path { fill: none; stroke: #c8c9c2; stroke-width: 2; stroke-dasharray: 12 14; vector-effect: non-scaling-stroke; }
|
||||
.ability-node { position: absolute; width: 128px; height: 105px; border: 2px solid currentColor; border-radius: 15px; background: #fbfbf8; color: var(--green); cursor: pointer; box-shadow: 0 12px 24px rgba(38,48,40,.05); transition: .2s ease; }
|
||||
.ability-node:hover, .ability-node.active { transform: translateY(-5px); box-shadow: 0 17px 32px rgba(38,48,40,.13); background: white; }
|
||||
.ability-node span, .ability-node b, .ability-node small { display: block; }.ability-node span { font-size: 25px; }.ability-node b { margin-top: 7px; color: var(--ink); }.ability-node small { margin: 7px auto 0; width: 34px; border-radius: 99px; padding: 3px; background: currentColor; color: white; }
|
||||
.node-vision { left: calc(50% - 64px); top: 5px; color: #5d73e8; }.node-humanities { left: 8%; top: 65px; color: #b76d38; }.node-detection { left: 18%; bottom: 12px; color: #287f78; }.node-modeling { right: 18%; bottom: 12px; color: #d98628; }.node-connection { right: 8%; top: 65px; color: #8667b5; }
|
||||
.pet-node { position: absolute; left: calc(50% - 49px); top: 145px; width: 98px; height: 98px; border: 2px solid #b49d8b; border-radius: 50%; background: white; display: grid; place-items: center; box-shadow: 0 13px 25px rgba(38,48,40,.12); }
|
||||
.pet-node b { display: grid; place-items: center; width: 46px; height: 46px; border-radius: 50%; background: #778990; color: white; font: 22px Georgia, serif; }.pet-node span { position: absolute; bottom: 8px; font-size: 10px; color: var(--muted); }
|
||||
.map-hint { margin: 0; text-align: center; color: #a0a49e; font-size: 11px; }
|
||||
.video-browser { margin-top: 28px; }.filter-row { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 11px; }
|
||||
.filter-chip { border: 1px solid var(--line); border-radius: 99px; padding: 9px 14px; background: rgba(255,255,252,.7); color: var(--muted); cursor: pointer; }
|
||||
.filter-chip.active { background: var(--ink); border-color: var(--ink); color: white; }
|
||||
.discipline-filters .filter-chip { border-radius: 9px; }
|
||||
.video-section-heading { margin: 35px 0 16px; display: flex; justify-content: space-between; align-items: end; }.video-section-heading h2 { margin: 8px 0 0; font: 30px Georgia, serif; }.video-section-heading > span { color: var(--muted); font-size: 12px; }
|
||||
.video-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px; }.video-grid.loading { display: block; color: var(--muted); padding: 35px 0; }
|
||||
.video-card { border: 1px solid var(--line); border-radius: 18px; background: rgba(255,255,252,.82); overflow: hidden; cursor: pointer; transition: .2s ease; }
|
||||
.video-card:hover { transform: translateY(-4px); box-shadow: 0 18px 38px rgba(38,48,40,.1); }
|
||||
.video-cover { height: 175px; padding: 15px; display: flex; align-items: center; justify-content: center; position: relative; background: linear-gradient(135deg, var(--cover-a), var(--cover-b)); }
|
||||
.video-cover > span { opacity: .34; font-size: 52px; }.video-duration { position: absolute; left: 13px; top: 12px; border-radius: 7px; padding: 5px 8px; color: white; background: rgba(23,33,27,.62); font-size: 11px; }
|
||||
.video-complete { position: absolute; right: 12px; top: 12px; background: var(--green); color: white; border-radius: 99px; padding: 5px 8px; font-size: 9px; }
|
||||
.video-body { padding: 19px; }.ability-badge { display: inline-block; border: 1px solid var(--badge-color); color: var(--badge-color); border-radius: 8px; padding: 5px 8px; font-size: 10px; }.video-body h3 { min-height: 48px; margin: 14px 0 16px; font: 20px/1.3 Georgia, serif; }.video-metrics, .video-author { display: flex; justify-content: space-between; color: var(--muted); font-size: 11px; }.video-author { justify-content: flex-start; gap: 8px; align-items: center; margin-top: 14px; }.video-author i { display: grid; place-items: center; width: 28px; height: 28px; border-radius: 50%; background: var(--badge-color); color: white; font-style: normal; }
|
||||
.video-dialog { width: min(760px, calc(100% - 30px)); }.video-player { min-height: 300px; border-radius: 17px; background: var(--ink); display: grid; place-items: center; color: white; text-align: center; padding: 30px; }.video-player span { display: block; color: var(--lime); font-size: 55px; }.video-player p { color: #aeb9b1; }.video-detail-meta { display: flex; gap: 12px; flex-wrap: wrap; color: var(--muted); font-size: 11px; margin: 18px 0; }.video-dialog h2 { font: 31px/1.2 Georgia, serif; }.video-dialog .video-description { color: var(--muted); line-height: 1.8; }.video-dialog-actions { display: flex; justify-content: space-between; align-items: center; gap: 12px; margin-top: 22px; }
|
||||
dialog { width: min(460px, calc(100% - 30px)); border: 1px solid var(--line); border-radius: 20px; padding: 30px; color: var(--ink); background: #faf9f3; box-shadow: 0 30px 100px rgba(0,0,0,.22); }
|
||||
dialog::backdrop { background: rgba(17,25,20,.55); backdrop-filter: blur(5px); }
|
||||
.dialog-close { position: absolute; right: 16px; top: 13px; border: 0; background: transparent; font-size: 25px; cursor: pointer; }
|
||||
.dialog-tabs { display: flex; border-bottom: 1px solid var(--line); margin-bottom: 25px; }
|
||||
.dialog-tabs button { border: 0; background: transparent; padding: 10px 15px; color: var(--muted); cursor: pointer; }
|
||||
.dialog-tabs button.active { color: var(--ink); border-bottom: 2px solid var(--green); }
|
||||
.auth-form { display: grid; gap: 15px; }.auth-form h2 { font: 28px Georgia, serif; margin: 0 0 5px; }
|
||||
.auth-form label { display: grid; gap: 7px; color: var(--muted); font-size: 12px; }
|
||||
.auth-form input, .auth-form select { width: 100%; border: 1px solid var(--line); border-radius: 10px; background: white; padding: 12px; outline-color: var(--green); }
|
||||
.hidden { display: none !important; }.form-error { min-height: 18px; color: #b84136; font-size: 12px; }
|
||||
.experience-dialog { width: min(760px, calc(100% - 30px)); }
|
||||
.experience-dialog h2 { font: 34px Georgia, serif; }.experience-dialog .scene { white-space: pre-line; line-height: 1.9; color: #3e4942; }
|
||||
.choice-list { display: grid; gap: 9px; margin-top: 25px; }
|
||||
.choice-list button { text-align: left; border: 1px solid var(--line); border-radius: 12px; padding: 14px; background: white; cursor: pointer; }
|
||||
.choice-list button:hover { border-color: var(--green); }
|
||||
.quiz-progress { height: 4px; background: #e4e3da; margin: 15px 0 30px; }.quiz-progress i { display: block; height: 100%; background: var(--green); transition: width .2s; }
|
||||
.toast { position: fixed; right: 25px; bottom: 25px; background: var(--ink); color: white; border-radius: 12px; padding: 13px 17px; opacity: 0; transform: translateY(10px); pointer-events: none; transition: .2s; z-index: 20; }
|
||||
.toast.show { opacity: 1; transform: none; }
|
||||
.csrf-token { display: none; }
|
||||
|
||||
@media (max-width: 1050px) {
|
||||
.app-shell { grid-template-columns: 80px 1fr; }.sidebar { padding: 25px 13px; }.brand span:last-child, .nav-item:not(.active) span, .nav-item { font-size: 0; }
|
||||
.nav-item { text-align: center; }.nav-item span { width: auto; font-size: 11px !important; }.sidebar-foot { display: none; }
|
||||
.hero-grid { grid-template-columns: 1fr; }.daily-card { min-height: 350px; }.spirit-grid { grid-template-columns: 1fr 1fr; }.content-grid { grid-template-columns: 1fr 1fr; }
|
||||
.tool-grid { grid-template-columns: repeat(3, 1fr); }.symbol-grid { grid-template-columns: repeat(3, 1fr); }.video-grid { grid-template-columns: 1fr 1fr; }
|
||||
}
|
||||
@media (max-width: 700px) {
|
||||
.app-shell { display: block; }.sidebar { position: fixed; top: auto; bottom: 0; width: 100%; height: 68px; z-index: 10; border: 0; border-top: 1px solid var(--line); padding: 8px; }
|
||||
.brand, .sidebar-foot { display: none; }.nav { margin: 0; grid-template-columns: repeat(6, 1fr); gap: 2px; }.nav-item { padding: 11px 2px; font-size: 0; }.nav-item span { display: block; margin: auto; }
|
||||
.main { padding: 0 18px 90px; }.topbar { height: 66px; }.topbar p { max-width: 55%; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.hero { min-height: 500px; padding: 35px 25px; }.hero h1 { font-size: 44px; }.hero-actions { align-items: stretch; flex-direction: column; }
|
||||
.section-heading { display: block; }.section-heading p { margin-top: 12px; }.spirit-grid, .content-grid, .editor-shell, .metric-grid, .tool-grid, .symbol-grid, .video-grid, .graph-shell { grid-template-columns: 1fr; }
|
||||
.content-grid { gap: 11px; }.editor-shell { min-height: 700px; }.page-title { padding-top: 42px; }.page-title h1 { font-size: 45px; }
|
||||
.tool-card { min-height: 125px; }.workspace-heading, .map-heading { display: block; }.workspace-heading p { margin-top: 12px; }.tool-workspace { padding: 20px; }
|
||||
.map-stage { height: 480px; transform: scale(.92); }.ability-node { width: 108px; height: 94px; }.node-vision { left: calc(50% - 54px); }.node-humanities { left: 0; top: 100px; }.node-connection { right: 0; top: 100px; }.node-detection { left: 2%; bottom: 30px; }.node-modeling { right: 2%; bottom: 30px; }.pet-node { top: 190px; }
|
||||
.map-lines { display: none; }.ability-legend { margin-top: 14px; justify-content: start; }.video-cover { height: 150px; }
|
||||
}
|
||||
@@ -0,0 +1,961 @@
|
||||
const state = {
|
||||
user: null,
|
||||
stories: [],
|
||||
contests: [],
|
||||
track: "standard",
|
||||
videoCatalog: null,
|
||||
videos: [],
|
||||
videoAbility: "",
|
||||
videoDiscipline: "",
|
||||
};
|
||||
|
||||
const $ = (selector, root = document) => root.querySelector(selector);
|
||||
const $$ = (selector, root = document) => [...root.querySelectorAll(selector)];
|
||||
|
||||
function csrfToken() {
|
||||
const match = document.cookie.match(/(?:^|; )csrftoken=([^;]+)/);
|
||||
return match ? decodeURIComponent(match[1]) : "";
|
||||
}
|
||||
|
||||
async function api(path, options = {}) {
|
||||
const headers = { Accept: "application/json", ...(options.headers || {}) };
|
||||
if (options.body && typeof options.body !== "string") {
|
||||
headers["Content-Type"] = "application/json";
|
||||
options.body = JSON.stringify(options.body);
|
||||
}
|
||||
if (!["GET", "HEAD"].includes(options.method || "GET")) headers["X-CSRFToken"] = csrfToken();
|
||||
const response = await fetch(`/api/v1/${path}`, { credentials: "same-origin", ...options, headers });
|
||||
if (response.status === 204) return null;
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
if (!response.ok) {
|
||||
const details = payload.error?.details;
|
||||
const message = payload.error?.message || payload.detail ||
|
||||
(details ? Object.values(details).flat().join(" ") : "请求失败");
|
||||
const error = new Error(message);
|
||||
error.status = response.status;
|
||||
throw error;
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
function showToast(message) {
|
||||
const toast = $("#toast");
|
||||
toast.textContent = message;
|
||||
toast.classList.add("show");
|
||||
window.setTimeout(() => toast.classList.remove("show"), 2600);
|
||||
}
|
||||
|
||||
function navigate(view) {
|
||||
$$(".nav-item").forEach((button) => button.classList.toggle("active", button.dataset.view === view));
|
||||
$$(".view").forEach((section) => section.classList.toggle("active", section.id === `view-${view}`));
|
||||
window.scrollTo({ top: 0, behavior: "smooth" });
|
||||
if (view === "profile") loadProfile();
|
||||
}
|
||||
|
||||
function openAuth() {
|
||||
$("#auth-error").textContent = "";
|
||||
$("#auth-dialog").showModal();
|
||||
}
|
||||
|
||||
function requireAuth() {
|
||||
if (state.user) return true;
|
||||
openAuth();
|
||||
showToast("请先登录或使用邀请码注册");
|
||||
return false;
|
||||
}
|
||||
|
||||
function updateUserUI() {
|
||||
const chip = $("#user-chip");
|
||||
chip.replaceChildren();
|
||||
const avatar = document.createElement("span");
|
||||
avatar.className = "avatar";
|
||||
avatar.textContent = state.user ? state.user.nickname.slice(0, 1) : "游";
|
||||
const name = document.createElement("span");
|
||||
name.textContent = state.user ? `${state.user.nickname} · ${state.user.rating}` : "游客";
|
||||
chip.append(avatar, name);
|
||||
$("#auth-button").textContent = state.user ? "退出登录" : "登录 / 注册";
|
||||
}
|
||||
|
||||
async function loadUser() {
|
||||
try {
|
||||
state.user = await api("accounts/me/");
|
||||
} catch (error) {
|
||||
if (error.status !== 401 && error.status !== 403) console.warn(error);
|
||||
state.user = null;
|
||||
}
|
||||
updateUserUI();
|
||||
}
|
||||
|
||||
function card({ meta, title, body, foot, action, onClick, disabled = false }) {
|
||||
const article = document.createElement("article");
|
||||
article.className = "content-card";
|
||||
const metaEl = document.createElement("span");
|
||||
metaEl.className = "meta";
|
||||
metaEl.textContent = meta;
|
||||
const titleEl = document.createElement("h3");
|
||||
titleEl.textContent = title;
|
||||
const bodyEl = document.createElement("p");
|
||||
bodyEl.textContent = body;
|
||||
const footer = document.createElement("footer");
|
||||
const footEl = document.createElement("span");
|
||||
footEl.textContent = foot;
|
||||
const button = document.createElement("button");
|
||||
button.textContent = action;
|
||||
button.disabled = disabled;
|
||||
if (onClick) button.addEventListener("click", onClick);
|
||||
footer.append(footEl, button);
|
||||
article.append(metaEl, titleEl, bodyEl, footer);
|
||||
return article;
|
||||
}
|
||||
|
||||
async function loadStories() {
|
||||
const root = $("#story-list");
|
||||
try {
|
||||
state.stories = await api("math-life/stories/");
|
||||
root.classList.remove("loading");
|
||||
root.replaceChildren(...state.stories.map((story) => card({
|
||||
meta: story.kind === "flagship" ? "旗舰人生" : story.kind === "skill" ? "人物 Skill" : "特别篇",
|
||||
title: story.title,
|
||||
body: story.summary || "在不完整信息中判断机会、关系与代价。",
|
||||
foot: `约 ${story.estimated_minutes} 分钟`,
|
||||
action: story.available ? "开始人生 →" : "查看预告",
|
||||
disabled: !story.available,
|
||||
onClick: () => beginStory(story.slug),
|
||||
})));
|
||||
} catch (error) {
|
||||
root.textContent = error.message;
|
||||
}
|
||||
}
|
||||
|
||||
async function beginStory(slug) {
|
||||
if (!requireAuth()) return;
|
||||
try {
|
||||
const run = await api(`math-life/stories/${slug}/start/`, { method: "POST", body: {} });
|
||||
renderStoryNode(run);
|
||||
$("#experience-dialog").showModal();
|
||||
} catch (error) {
|
||||
showToast(error.message);
|
||||
}
|
||||
}
|
||||
|
||||
function renderStoryNode(run) {
|
||||
const root = $("#experience-content");
|
||||
root.replaceChildren();
|
||||
const label = document.createElement("span");
|
||||
label.className = "kicker";
|
||||
label.textContent = `${run.story} · ${run.current_node}`;
|
||||
const title = document.createElement("h2");
|
||||
title.textContent = run.node.character || "旁白";
|
||||
const scene = document.createElement("p");
|
||||
scene.className = "scene";
|
||||
scene.textContent = run.node.scene;
|
||||
const choices = document.createElement("div");
|
||||
choices.className = "choice-list";
|
||||
if (run.status === "completed") {
|
||||
const ending = document.createElement("p");
|
||||
ending.textContent = "这段人生已经抵达结局,路径已写入你的数学档案。";
|
||||
choices.append(ending);
|
||||
} else {
|
||||
run.node.choices.forEach((choice) => {
|
||||
const button = document.createElement("button");
|
||||
button.textContent = choice.text;
|
||||
button.addEventListener("click", async () => {
|
||||
button.disabled = true;
|
||||
try {
|
||||
const next = await api(`math-life/runs/${run.run_id}/choice/`, {
|
||||
method: "POST",
|
||||
headers: { "Idempotency-Key": crypto.randomUUID() },
|
||||
body: { choice_index: choice.index },
|
||||
});
|
||||
renderStoryNode(next);
|
||||
} catch (error) {
|
||||
showToast(error.message);
|
||||
button.disabled = false;
|
||||
}
|
||||
});
|
||||
choices.append(button);
|
||||
});
|
||||
}
|
||||
root.append(label, title, scene, choices);
|
||||
}
|
||||
|
||||
async function startMathBTI() {
|
||||
try {
|
||||
const assessment = await api("math-life/mathbti/");
|
||||
const questions = assessment.definition.questions;
|
||||
const answers = [];
|
||||
let index = 0;
|
||||
const root = $("#experience-content");
|
||||
const render = () => {
|
||||
const question = questions[index];
|
||||
root.replaceChildren();
|
||||
const label = document.createElement("span");
|
||||
label.className = "kicker";
|
||||
label.textContent = `MATHBTI · ${index + 1} / ${questions.length}`;
|
||||
const progress = document.createElement("div");
|
||||
progress.className = "quiz-progress";
|
||||
const bar = document.createElement("i");
|
||||
bar.style.width = `${((index + 1) / questions.length) * 100}%`;
|
||||
progress.append(bar);
|
||||
const title = document.createElement("h2");
|
||||
title.textContent = question.question;
|
||||
const choices = document.createElement("div");
|
||||
choices.className = "choice-list";
|
||||
question.options.forEach((option, optionIndex) => {
|
||||
const button = document.createElement("button");
|
||||
button.textContent = option.text;
|
||||
button.addEventListener("click", async () => {
|
||||
answers.push(optionIndex);
|
||||
index += 1;
|
||||
if (index < questions.length) {
|
||||
render();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const result = await api("math-life/mathbti/submit/", {
|
||||
method: "POST",
|
||||
body: { version: assessment.version, answers },
|
||||
});
|
||||
renderMathBTIResult(result);
|
||||
} catch (error) {
|
||||
showToast(error.message);
|
||||
}
|
||||
});
|
||||
choices.append(button);
|
||||
});
|
||||
root.append(label, progress, title, choices);
|
||||
};
|
||||
render();
|
||||
$("#experience-dialog").showModal();
|
||||
} catch (error) {
|
||||
showToast(error.message);
|
||||
}
|
||||
}
|
||||
|
||||
function renderMathBTIResult(result) {
|
||||
const root = $("#experience-content");
|
||||
root.replaceChildren();
|
||||
const label = document.createElement("span");
|
||||
label.className = "kicker";
|
||||
label.textContent = `${result.identity.clan} · ${result.identity.code}`;
|
||||
const title = document.createElement("h2");
|
||||
title.textContent = `${result.identity.name} · ${result.identity.mathematician}`;
|
||||
const description = document.createElement("p");
|
||||
description.className = "scene";
|
||||
description.textContent = result.identity.description;
|
||||
const button = document.createElement("button");
|
||||
button.className = "primary-button";
|
||||
button.textContent = state.user ? "进入推荐人生" : "注册并保存身份";
|
||||
button.addEventListener("click", () => {
|
||||
$("#experience-dialog").close();
|
||||
if (state.user) navigate("life"); else openAuth();
|
||||
});
|
||||
root.append(label, title, description, button);
|
||||
}
|
||||
|
||||
async function loadContests() {
|
||||
const root = $("#contest-list");
|
||||
try {
|
||||
state.contests = await api("contests/");
|
||||
renderContests();
|
||||
} catch (error) {
|
||||
root.textContent = error.message;
|
||||
}
|
||||
}
|
||||
|
||||
function renderContests() {
|
||||
const root = $("#contest-list");
|
||||
const contests = state.contests.filter((contest) => contest.track === state.track);
|
||||
root.classList.remove("loading");
|
||||
root.replaceChildren(...contests.map((contest) => card({
|
||||
meta: contest.kind === "realtime" ? "实时竞技" : contest.kind === "daily" ? "今日挑战" : "自由练习",
|
||||
title: contest.title,
|
||||
body: contest.kind === "realtime" ? "Rating 匹配,同题序列,服务端计时和唯一结算。" : "完成整组题目,正式答案只在提交后显示。",
|
||||
foot: `${contest.duration_seconds} 秒`,
|
||||
action: contest.kind === "realtime" ? "开始匹配 →" : "开始答题 →",
|
||||
onClick: () => beginContest(contest),
|
||||
})));
|
||||
}
|
||||
|
||||
async function beginContest(contest) {
|
||||
if (!requireAuth()) return;
|
||||
try {
|
||||
if (contest.kind === "realtime") {
|
||||
const match = await api(`contests/${contest.slug}/matchmaking/`, { method: "POST", body: {} });
|
||||
if (match.status === "waiting") {
|
||||
showToast("已进入匹配队列,等待同赛道对手");
|
||||
return;
|
||||
}
|
||||
renderAttempt(match.attempt);
|
||||
} else {
|
||||
renderAttempt(await api(`contests/${contest.slug}/start/`, { method: "POST", body: {} }));
|
||||
}
|
||||
$("#experience-dialog").showModal();
|
||||
} catch (error) {
|
||||
showToast(error.message);
|
||||
}
|
||||
}
|
||||
|
||||
function renderAttempt(attempt) {
|
||||
const root = $("#experience-content");
|
||||
root.replaceChildren();
|
||||
const label = document.createElement("span");
|
||||
label.className = "kicker";
|
||||
label.textContent = `${attempt.kind} · ${attempt.duration_seconds} 秒`;
|
||||
const title = document.createElement("h2");
|
||||
title.textContent = attempt.contest;
|
||||
if (attempt.status !== "active") {
|
||||
const summary = document.createElement("p");
|
||||
summary.className = "scene";
|
||||
summary.textContent = `得分 ${attempt.score},答对 ${attempt.correct_count} 题,用时 ${(attempt.duration_ms / 1000).toFixed(1)} 秒。`;
|
||||
root.append(label, title, summary);
|
||||
return;
|
||||
}
|
||||
const form = document.createElement("form");
|
||||
form.className = "choice-list";
|
||||
attempt.questions.forEach((question) => {
|
||||
const field = document.createElement("label");
|
||||
field.textContent = `${question.order}. ${question.prompt}`;
|
||||
const input = document.createElement("input");
|
||||
input.name = String(question.order);
|
||||
input.inputMode = "decimal";
|
||||
input.autocomplete = "off";
|
||||
input.style.cssText = "margin-top:7px;width:100%;padding:12px;border:1px solid #d6d8d1;border-radius:9px";
|
||||
field.append(input);
|
||||
form.append(field);
|
||||
});
|
||||
const submit = document.createElement("button");
|
||||
submit.className = "primary-button";
|
||||
submit.type = "submit";
|
||||
submit.textContent = "提交并结算";
|
||||
form.append(submit);
|
||||
form.addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
submit.disabled = true;
|
||||
const data = new FormData(form);
|
||||
const answers = attempt.questions.map((question) => ({
|
||||
order: question.order,
|
||||
answer: data.get(String(question.order)) || "",
|
||||
}));
|
||||
try {
|
||||
const result = await api(`contests/attempts/${attempt.attempt_id}/submit/`, {
|
||||
method: "POST",
|
||||
headers: { "Idempotency-Key": crypto.randomUUID() },
|
||||
body: { answers },
|
||||
});
|
||||
renderAttempt(result);
|
||||
} catch (error) {
|
||||
showToast(error.message);
|
||||
submit.disabled = false;
|
||||
}
|
||||
});
|
||||
root.append(label, title, form);
|
||||
}
|
||||
|
||||
function filterChip(label, value, type, active) {
|
||||
const button = document.createElement("button");
|
||||
button.className = `filter-chip${active ? " active" : ""}`;
|
||||
button.textContent = label;
|
||||
button.dataset[type] = value;
|
||||
button.addEventListener("click", () => {
|
||||
if (type === "ability") state.videoAbility = value;
|
||||
if (type === "discipline") state.videoDiscipline = value;
|
||||
renderVideoFilters();
|
||||
renderVideos();
|
||||
});
|
||||
return button;
|
||||
}
|
||||
|
||||
function renderVideoFilters() {
|
||||
if (!state.videoCatalog) return;
|
||||
const abilityRoot = $("#ability-filters");
|
||||
abilityRoot.replaceChildren(
|
||||
filterChip(`全部 ${state.videoCatalog.total}`, "", "ability", !state.videoAbility),
|
||||
...state.videoCatalog.abilities.map((ability) =>
|
||||
filterChip(
|
||||
`${ability.icon} ${ability.label} ${ability.count}`,
|
||||
ability.id,
|
||||
"ability",
|
||||
state.videoAbility === ability.id,
|
||||
)
|
||||
),
|
||||
);
|
||||
const disciplineRoot = $("#discipline-filters");
|
||||
disciplineRoot.replaceChildren(
|
||||
filterChip("全部专业", "", "discipline", !state.videoDiscipline),
|
||||
...state.videoCatalog.disciplines.map((discipline) =>
|
||||
filterChip(
|
||||
`${discipline.icon || "∑"} ${discipline.name}`,
|
||||
discipline.name,
|
||||
"discipline",
|
||||
state.videoDiscipline === discipline.name,
|
||||
)
|
||||
),
|
||||
);
|
||||
|
||||
const selected = state.videoCatalog.abilities.find(
|
||||
(item) => item.id === state.videoAbility
|
||||
);
|
||||
$("#video-stream-title").textContent = selected ? `${selected.label}视频流` : "全部视频";
|
||||
$$(".ability-node").forEach((node) => {
|
||||
node.classList.toggle("active", node.dataset.ability === state.videoAbility);
|
||||
});
|
||||
}
|
||||
|
||||
function formatCount(value) {
|
||||
if (value >= 10000) return `${(value / 10000).toFixed(1)}万`;
|
||||
return String(value);
|
||||
}
|
||||
|
||||
function formatDuration(seconds) {
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const remaining = seconds % 60;
|
||||
return `${String(minutes).padStart(2, "0")}:${String(remaining).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
function videoCard(item, index) {
|
||||
const article = document.createElement("article");
|
||||
article.className = "video-card";
|
||||
article.setAttribute("role", "button");
|
||||
article.setAttribute("aria-label", `打开视频:${item.title}`);
|
||||
article.tabIndex = 0;
|
||||
const ability = item.ability || { label: "数学探索", icon: "∑", color: "#657068" };
|
||||
article.style.setProperty("--badge-color", ability.color);
|
||||
article.style.setProperty("--cover-a", `${ability.color}22`);
|
||||
article.style.setProperty("--cover-b", `${ability.color}55`);
|
||||
|
||||
const cover = document.createElement("div");
|
||||
cover.className = "video-cover";
|
||||
const duration = document.createElement("b");
|
||||
duration.className = "video-duration";
|
||||
duration.textContent = formatDuration(item.duration_seconds);
|
||||
const icon = document.createElement("span");
|
||||
icon.textContent = item.discipline_icon || ability.icon;
|
||||
cover.append(duration, icon);
|
||||
if (item.progress?.completed) {
|
||||
const complete = document.createElement("i");
|
||||
complete.className = "video-complete";
|
||||
complete.textContent = "已完成";
|
||||
cover.append(complete);
|
||||
}
|
||||
|
||||
const body = document.createElement("div");
|
||||
body.className = "video-body";
|
||||
const badge = document.createElement("span");
|
||||
badge.className = "ability-badge";
|
||||
badge.textContent = `${ability.icon} ${ability.label}`;
|
||||
const title = document.createElement("h3");
|
||||
title.textContent = item.title;
|
||||
const metrics = document.createElement("div");
|
||||
metrics.className = "video-metrics";
|
||||
const views = document.createElement("span");
|
||||
views.textContent = `◉ ${formatCount(item.view_count)}`;
|
||||
const comments = document.createElement("span");
|
||||
comments.textContent = `◌ ${formatCount(item.comment_count)}`;
|
||||
metrics.append(views, comments);
|
||||
const author = document.createElement("div");
|
||||
author.className = "video-author";
|
||||
const avatar = document.createElement("i");
|
||||
avatar.textContent = item.discipline?.slice(0, 1) || "数";
|
||||
const authorName = document.createElement("span");
|
||||
authorName.textContent = item.author || "葫芦数学志愿者";
|
||||
author.append(avatar, authorName);
|
||||
body.append(badge, title, metrics, author);
|
||||
article.append(cover, body);
|
||||
article.addEventListener("click", () => openVideo(item));
|
||||
article.addEventListener("keydown", (event) => {
|
||||
if (event.key === "Enter") openVideo(item);
|
||||
});
|
||||
return article;
|
||||
}
|
||||
|
||||
function renderVideos() {
|
||||
const root = $("#video-list");
|
||||
const videos = state.videos.filter(
|
||||
(item) =>
|
||||
(!state.videoAbility || item.ability_dimension === state.videoAbility) &&
|
||||
(!state.videoDiscipline || item.discipline === state.videoDiscipline)
|
||||
);
|
||||
root.classList.remove("loading");
|
||||
root.replaceChildren(...videos.map(videoCard));
|
||||
$("#video-result-count").textContent = `${videos.length} 条内容`;
|
||||
}
|
||||
|
||||
function openVideo(item) {
|
||||
const root = $("#video-dialog-content");
|
||||
root.replaceChildren();
|
||||
const player = document.createElement("div");
|
||||
player.className = "video-player";
|
||||
const icon = document.createElement("span");
|
||||
icon.textContent = item.discipline_icon || "▶";
|
||||
const status = document.createElement("h3");
|
||||
status.textContent = item.media_url ? "志愿者视频外部播放" : "视频素材待上传";
|
||||
const note = document.createElement("p");
|
||||
note.textContent = item.media_url
|
||||
? "点击下方按钮打开原始视频来源。观看后可回到这里记录完成。"
|
||||
: "内容卡、分类和成长链路已经就绪,运营上传视频地址后即可播放。";
|
||||
player.append(icon, status, note);
|
||||
|
||||
const title = document.createElement("h2");
|
||||
title.textContent = item.title;
|
||||
const meta = document.createElement("div");
|
||||
meta.className = "video-detail-meta";
|
||||
[
|
||||
`${item.discipline_icon || "∑"} ${item.discipline}`,
|
||||
formatDuration(item.duration_seconds),
|
||||
item.author,
|
||||
item.ability?.label || "数学探索",
|
||||
].forEach((value) => {
|
||||
const span = document.createElement("span");
|
||||
span.textContent = value;
|
||||
meta.append(span);
|
||||
});
|
||||
const description = document.createElement("p");
|
||||
description.className = "video-description";
|
||||
description.textContent = item.summary;
|
||||
const actions = document.createElement("div");
|
||||
actions.className = "video-dialog-actions";
|
||||
if (item.media_url) {
|
||||
const link = document.createElement("a");
|
||||
link.className = "ghost-button";
|
||||
link.href = item.media_url;
|
||||
link.target = "_blank";
|
||||
link.rel = "noopener noreferrer";
|
||||
link.textContent = "打开视频来源";
|
||||
actions.append(link);
|
||||
} else {
|
||||
const unavailable = document.createElement("span");
|
||||
unavailable.textContent = "暂时没有可播放地址";
|
||||
actions.append(unavailable);
|
||||
}
|
||||
const complete = document.createElement("button");
|
||||
complete.className = "primary-button";
|
||||
complete.textContent = item.progress?.completed ? "已完成观看" : "标记完成 · +1 能力碎片";
|
||||
complete.disabled = Boolean(item.progress?.completed);
|
||||
complete.addEventListener("click", async () => {
|
||||
if (!requireAuth()) return;
|
||||
complete.disabled = true;
|
||||
try {
|
||||
const result = await api(`content/${item.slug}/complete/`, {
|
||||
method: "POST",
|
||||
body: {},
|
||||
});
|
||||
item.progress = { completed: true, reward_granted: result.reward_granted };
|
||||
complete.textContent = "已完成观看";
|
||||
renderVideos();
|
||||
showToast(result.reward ? "观看完成,获得 1 枚能力碎片" : "观看记录已保存");
|
||||
} catch (error) {
|
||||
complete.disabled = false;
|
||||
showToast(error.message);
|
||||
}
|
||||
});
|
||||
actions.append(complete);
|
||||
root.append(player, title, meta, description, actions);
|
||||
$("#video-dialog").showModal();
|
||||
}
|
||||
|
||||
async function loadContent() {
|
||||
const root = $("#video-list");
|
||||
try {
|
||||
const [catalog, videos] = await Promise.all([
|
||||
api("content/videos/catalog/"),
|
||||
api("content/?kind=video"),
|
||||
]);
|
||||
state.videoCatalog = catalog;
|
||||
state.videos = videos;
|
||||
const abilityRoot = $("#ability-legend");
|
||||
abilityRoot.replaceChildren(
|
||||
...catalog.abilities.map((ability) => {
|
||||
const item = document.createElement("span");
|
||||
const dot = document.createElement("i");
|
||||
dot.style.background = ability.color;
|
||||
item.append(dot, document.createTextNode(`${ability.label} · ${ability.count}`));
|
||||
return item;
|
||||
})
|
||||
);
|
||||
catalog.abilities.forEach((ability) => {
|
||||
const node = $(`.ability-node[data-ability="${ability.id}"]`);
|
||||
if (node) node.querySelector("small").textContent = ability.count;
|
||||
});
|
||||
renderVideoFilters();
|
||||
renderVideos();
|
||||
} catch (error) {
|
||||
root.textContent = error.message;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadProfile() {
|
||||
const root = $("#profile-panel");
|
||||
if (!state.user) {
|
||||
root.innerHTML = '<p>登录后查看完整档案。</p><button class="primary-button" data-open-auth>登录 / 注册</button>';
|
||||
$("[data-open-auth]", root).addEventListener("click", openAuth);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const profile = await api("progression/me/");
|
||||
root.replaceChildren();
|
||||
const heading = document.createElement("h2");
|
||||
heading.textContent = `${state.user.nickname} · Rating ${state.user.rating}`;
|
||||
const pet = document.createElement("p");
|
||||
pet.textContent = `数学精灵:${profile.pet.name},等级 ${profile.pet.level}`;
|
||||
const metrics = document.createElement("div");
|
||||
metrics.className = "metric-grid";
|
||||
profile.abilities.forEach((ability) => {
|
||||
const item = document.createElement("div");
|
||||
item.className = "metric";
|
||||
const value = document.createElement("b");
|
||||
value.textContent = ability.level;
|
||||
const label = document.createElement("span");
|
||||
label.textContent = `${ability.label} · ${ability.fragments} 碎片`;
|
||||
item.append(value, label);
|
||||
metrics.append(item);
|
||||
});
|
||||
root.append(heading, pet, metrics);
|
||||
} catch (error) {
|
||||
root.textContent = error.message;
|
||||
}
|
||||
}
|
||||
|
||||
const MATH_SYMBOLS = [
|
||||
["∑", "求和", "一组数或表达式的总和", "\\sum"],
|
||||
["∏", "连乘", "一组数或表达式的乘积", "\\prod"],
|
||||
["∫", "积分", "连续累积与面积", "\\int"],
|
||||
["∂", "偏导数", "多元函数对一个变量的变化率", "\\partial"],
|
||||
["∞", "无穷", "没有有限边界", "\\infty"],
|
||||
["√", "平方根", "平方后得到原数的值", "\\sqrt{x}"],
|
||||
["≈", "约等于", "数值近似相等", "\\approx"],
|
||||
["≠", "不等于", "两个量不相等", "\\ne"],
|
||||
["≤", "小于等于", "不大于右侧的量", "\\le"],
|
||||
["≥", "大于等于", "不小于右侧的量", "\\ge"],
|
||||
["∈", "属于", "元素属于某个集合", "\\in"],
|
||||
["∉", "不属于", "元素不在某个集合中", "\\notin"],
|
||||
["⊂", "真子集", "集合被另一个集合包含", "\\subset"],
|
||||
["∪", "并集", "属于至少一个集合的元素", "\\cup"],
|
||||
["∩", "交集", "同时属于多个集合的元素", "\\cap"],
|
||||
["∀", "任意", "对所有对象都成立", "\\forall"],
|
||||
["∃", "存在", "至少存在一个对象", "\\exists"],
|
||||
["⇒", "推出", "前件能够推出后件", "\\Rightarrow"],
|
||||
["π", "圆周率", "圆周长与直径之比", "\\pi"],
|
||||
["θ", "角变量", "常用于表示角度", "\\theta"],
|
||||
];
|
||||
|
||||
function evaluateExpression(source, variables = {}) {
|
||||
const input = String(source).replaceAll("π", "pi").replaceAll("×", "*").replaceAll("÷", "/");
|
||||
let position = 0;
|
||||
const functions = {
|
||||
sin: Math.sin,
|
||||
cos: Math.cos,
|
||||
tan: Math.tan,
|
||||
sqrt: Math.sqrt,
|
||||
log: Math.log10,
|
||||
ln: Math.log,
|
||||
abs: Math.abs,
|
||||
};
|
||||
|
||||
const skip = () => {
|
||||
while (/\s/.test(input[position] || "")) position += 1;
|
||||
};
|
||||
const parseExpression = () => {
|
||||
let value = parseTerm();
|
||||
while (true) {
|
||||
skip();
|
||||
const operator = input[position];
|
||||
if (operator !== "+" && operator !== "-") break;
|
||||
position += 1;
|
||||
const right = parseTerm();
|
||||
value = operator === "+" ? value + right : value - right;
|
||||
}
|
||||
return value;
|
||||
};
|
||||
const parseTerm = () => {
|
||||
let value = parsePower();
|
||||
while (true) {
|
||||
skip();
|
||||
const operator = input[position];
|
||||
if (operator !== "*" && operator !== "/") break;
|
||||
position += 1;
|
||||
const right = parsePower();
|
||||
value = operator === "*" ? value * right : value / right;
|
||||
}
|
||||
return value;
|
||||
};
|
||||
const parsePower = () => {
|
||||
let value = parseUnary();
|
||||
skip();
|
||||
if (input[position] === "^") {
|
||||
position += 1;
|
||||
value = value ** parsePower();
|
||||
}
|
||||
return value;
|
||||
};
|
||||
const parseUnary = () => {
|
||||
skip();
|
||||
if (input[position] === "+") {
|
||||
position += 1;
|
||||
return parseUnary();
|
||||
}
|
||||
if (input[position] === "-") {
|
||||
position += 1;
|
||||
return -parseUnary();
|
||||
}
|
||||
return parsePrimary();
|
||||
};
|
||||
const parsePrimary = () => {
|
||||
skip();
|
||||
if (input[position] === "(") {
|
||||
position += 1;
|
||||
const value = parseExpression();
|
||||
skip();
|
||||
if (input[position] !== ")") throw new Error("缺少右括号");
|
||||
position += 1;
|
||||
return value;
|
||||
}
|
||||
const numberMatch = input.slice(position).match(/^(?:\d+\.?\d*|\.\d+)/);
|
||||
if (numberMatch) {
|
||||
position += numberMatch[0].length;
|
||||
return Number(numberMatch[0]);
|
||||
}
|
||||
const identifierMatch = input.slice(position).match(/^[A-Za-z]+/);
|
||||
if (!identifierMatch) throw new Error(`无法识别位置 ${position + 1} 的内容`);
|
||||
const identifier = identifierMatch[0].toLowerCase();
|
||||
position += identifierMatch[0].length;
|
||||
if (identifier === "pi") return Math.PI;
|
||||
if (identifier === "e") return Math.E;
|
||||
if (Object.hasOwn(variables, identifier)) return Number(variables[identifier]);
|
||||
if (!functions[identifier]) throw new Error(`不支持函数 ${identifier}`);
|
||||
skip();
|
||||
if (input[position] !== "(") throw new Error(`${identifier} 后需要括号`);
|
||||
position += 1;
|
||||
const argument = parseExpression();
|
||||
skip();
|
||||
if (input[position] !== ")") throw new Error("函数缺少右括号");
|
||||
position += 1;
|
||||
return functions[identifier](argument);
|
||||
};
|
||||
|
||||
const value = parseExpression();
|
||||
skip();
|
||||
if (position !== input.length) throw new Error(`无法识别位置 ${position + 1} 的内容`);
|
||||
if (!Number.isFinite(value)) throw new Error("结果不是有限数值");
|
||||
return value;
|
||||
}
|
||||
|
||||
function runCalculator() {
|
||||
const input = $("#calc-input").value;
|
||||
try {
|
||||
const result = evaluateExpression(input);
|
||||
$("#calc-history").textContent = input;
|
||||
$("#calc-output").textContent = Number(result.toPrecision(12)).toString();
|
||||
} catch (error) {
|
||||
$("#calc-history").textContent = error.message;
|
||||
$("#calc-output").textContent = "错误";
|
||||
}
|
||||
}
|
||||
|
||||
function renderSymbols(query = "") {
|
||||
const keyword = query.trim().toLowerCase();
|
||||
const matches = MATH_SYMBOLS.filter((symbol) =>
|
||||
symbol.join(" ").toLowerCase().includes(keyword)
|
||||
);
|
||||
$("#symbol-list").replaceChildren(
|
||||
...matches.map(([glyph, name, description, latex]) => {
|
||||
const button = document.createElement("button");
|
||||
button.className = "symbol-card";
|
||||
const symbol = document.createElement("strong");
|
||||
symbol.textContent = glyph;
|
||||
const title = document.createElement("b");
|
||||
title.textContent = name;
|
||||
const detail = document.createElement("small");
|
||||
detail.textContent = description;
|
||||
const code = document.createElement("code");
|
||||
code.textContent = latex;
|
||||
button.append(symbol, title, detail, code);
|
||||
button.addEventListener("click", async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(latex);
|
||||
showToast(`已复制 ${latex}`);
|
||||
} catch {
|
||||
showToast(`LaTeX:${latex}`);
|
||||
}
|
||||
});
|
||||
return button;
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
function drawGraph() {
|
||||
const canvas = $("#graph-canvas");
|
||||
const context = canvas.getContext("2d");
|
||||
const expression = $("#graph-expression").value;
|
||||
const range = Number($("#graph-range").value);
|
||||
const width = canvas.width;
|
||||
const height = canvas.height;
|
||||
$("#graph-range-label").textContent = `−${range} 到 ${range}`;
|
||||
$("#graph-error").textContent = "";
|
||||
context.clearRect(0, 0, width, height);
|
||||
context.fillStyle = "#fbfbf7";
|
||||
context.fillRect(0, 0, width, height);
|
||||
|
||||
const toX = (x) => ((x + range) / (range * 2)) * width;
|
||||
const toY = (y) => height / 2 - (y / range) * (height / 2);
|
||||
context.strokeStyle = "#e4e5df";
|
||||
context.lineWidth = 1;
|
||||
for (let value = -range; value <= range; value += 1) {
|
||||
context.beginPath();
|
||||
context.moveTo(toX(value), 0);
|
||||
context.lineTo(toX(value), height);
|
||||
context.stroke();
|
||||
context.beginPath();
|
||||
context.moveTo(0, toY(value));
|
||||
context.lineTo(width, toY(value));
|
||||
context.stroke();
|
||||
}
|
||||
context.strokeStyle = "#718078";
|
||||
context.lineWidth = 2;
|
||||
context.beginPath();
|
||||
context.moveTo(0, height / 2);
|
||||
context.lineTo(width, height / 2);
|
||||
context.moveTo(width / 2, 0);
|
||||
context.lineTo(width / 2, height);
|
||||
context.stroke();
|
||||
|
||||
context.strokeStyle = "#196548";
|
||||
context.lineWidth = 3;
|
||||
context.beginPath();
|
||||
let drawing = false;
|
||||
try {
|
||||
for (let pixel = 0; pixel <= width; pixel += 2) {
|
||||
const x = (pixel / width) * range * 2 - range;
|
||||
const y = evaluateExpression(expression, { x });
|
||||
const screenY = toY(y);
|
||||
if (screenY < -height * 2 || screenY > height * 3) {
|
||||
drawing = false;
|
||||
continue;
|
||||
}
|
||||
if (!drawing) context.moveTo(pixel, screenY);
|
||||
else context.lineTo(pixel, screenY);
|
||||
drawing = true;
|
||||
}
|
||||
context.stroke();
|
||||
} catch (error) {
|
||||
$("#graph-error").textContent = error.message;
|
||||
}
|
||||
}
|
||||
|
||||
function switchTool(tool) {
|
||||
if (tool === "mental") {
|
||||
navigate("contest");
|
||||
return;
|
||||
}
|
||||
$$(".tool-card").forEach((card) => {
|
||||
card.classList.toggle("active", card.dataset.tool === tool);
|
||||
});
|
||||
$$(".tool-workspace").forEach((workspace) => {
|
||||
workspace.classList.toggle("active", workspace.id === `tool-${tool}`);
|
||||
});
|
||||
if (tool === "graph") window.setTimeout(drawGraph, 30);
|
||||
}
|
||||
|
||||
async function saveFormula() {
|
||||
if (!requireAuth()) return;
|
||||
try {
|
||||
await api("latex/documents/", {
|
||||
method: "POST",
|
||||
body: { title: `公式 ${new Date().toLocaleString()}`, source: $("#latex-source").value },
|
||||
});
|
||||
showToast("公式及首个版本已保存");
|
||||
} catch (error) {
|
||||
showToast(error.message);
|
||||
}
|
||||
}
|
||||
|
||||
function bindUI() {
|
||||
$$(".nav-item").forEach((button) => button.addEventListener("click", () => navigate(button.dataset.view)));
|
||||
$$("[data-jump]").forEach((button) => button.addEventListener("click", () => navigate(button.dataset.jump)));
|
||||
$$("[data-open-auth]").forEach((button) => button.addEventListener("click", openAuth));
|
||||
$("#start-mathbti").addEventListener("click", startMathBTI);
|
||||
$("#save-formula").addEventListener("click", saveFormula);
|
||||
$("#latex-source").addEventListener("input", (event) => { $("#latex-preview").textContent = event.target.value; });
|
||||
$$(".tool-card").forEach((button) => {
|
||||
button.addEventListener("click", () => switchTool(button.dataset.tool));
|
||||
});
|
||||
$("#calc-run").addEventListener("click", runCalculator);
|
||||
$("#calc-input").addEventListener("keydown", (event) => {
|
||||
if (event.key === "Enter") runCalculator();
|
||||
});
|
||||
$$("[data-calc-example]").forEach((button) => {
|
||||
button.addEventListener("click", () => {
|
||||
$("#calc-input").value = button.dataset.calcExample;
|
||||
runCalculator();
|
||||
});
|
||||
});
|
||||
$("#symbol-search").addEventListener("input", (event) => {
|
||||
renderSymbols(event.target.value);
|
||||
});
|
||||
$("#graph-run").addEventListener("click", drawGraph);
|
||||
$("#graph-range").addEventListener("input", drawGraph);
|
||||
$("#graph-expression").addEventListener("keydown", (event) => {
|
||||
if (event.key === "Enter") drawGraph();
|
||||
});
|
||||
$$(".ability-node").forEach((button) => {
|
||||
button.addEventListener("click", () => {
|
||||
state.videoAbility =
|
||||
state.videoAbility === button.dataset.ability ? "" : button.dataset.ability;
|
||||
renderVideoFilters();
|
||||
renderVideos();
|
||||
$("#video-list").scrollIntoView({ behavior: "smooth", block: "start" });
|
||||
});
|
||||
});
|
||||
$("#auth-button").addEventListener("click", async () => {
|
||||
if (!state.user) return openAuth();
|
||||
await api("accounts/logout/", { method: "POST", body: {} });
|
||||
state.user = null;
|
||||
updateUserUI();
|
||||
showToast("已退出登录");
|
||||
});
|
||||
$$("dialog .dialog-close").forEach((button) => button.addEventListener("click", () => button.closest("dialog").close()));
|
||||
$$("[data-auth-tab]").forEach((button) => button.addEventListener("click", () => {
|
||||
$$("[data-auth-tab]").forEach((item) => item.classList.toggle("active", item === button));
|
||||
$("#login-form").classList.toggle("hidden", button.dataset.authTab !== "login");
|
||||
$("#register-form").classList.toggle("hidden", button.dataset.authTab !== "register");
|
||||
$("#auth-error").textContent = "";
|
||||
}));
|
||||
$$("#track-switch button").forEach((button) => button.addEventListener("click", () => {
|
||||
state.track = button.dataset.track;
|
||||
$$("#track-switch button").forEach((item) => item.classList.toggle("active", item === button));
|
||||
renderContests();
|
||||
}));
|
||||
$("#login-form").addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
try {
|
||||
state.user = await api("accounts/login/", { method: "POST", body: Object.fromEntries(new FormData(event.target)) });
|
||||
$("#auth-dialog").close();
|
||||
updateUserUI();
|
||||
await loadContent();
|
||||
showToast("登录成功");
|
||||
} catch (error) {
|
||||
$("#auth-error").textContent = error.message;
|
||||
}
|
||||
});
|
||||
$("#register-form").addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
try {
|
||||
state.user = await api("accounts/register/", { method: "POST", body: Object.fromEntries(new FormData(event.target)) });
|
||||
$("#auth-dialog").close();
|
||||
updateUserUI();
|
||||
await loadContent();
|
||||
showToast("账号和数学档案已建立");
|
||||
} catch (error) {
|
||||
$("#auth-error").textContent = error.message;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function boot() {
|
||||
bindUI();
|
||||
renderSymbols();
|
||||
runCalculator();
|
||||
await Promise.all([loadUser(), loadStories(), loadContests(), loadContent()]);
|
||||
}
|
||||
|
||||
boot();
|
||||
Reference in New Issue
Block a user