Real IMAP connection via imapflow library: - Connects to IMAP server (IMAPFlow, TLS on port 993) - Fetches recent messages (configurable limit, default 20) - Extracts: from, fromEmail, subject, date, snippet (200 chars) - Thread-to-contact matching (PRD §6.1): 1. Match sender email against persons table 2. If no person match, extract domain and match against company.website 3. Returns matchedCompany + matchedPerson for each email - Audit-logged (action: crm.mailsync:imap) - Zod validated (host, port, user, password, folder, limit) - 502 on IMAP connection failure with detail - 400 on validation failure Tested: invalid creds → 502 'IMAP connection failed', missing host → 400
1233 lines
47 KiB
HTML
Executable File
1233 lines
47 KiB
HTML
Executable File
<!DOCTYPE html>
|
||
<html lang="zh-CN">
|
||
<head>
|
||
<meta charset="UTF-8" />
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||
<!-- SWAP-1 标题 -->
|
||
<title>{{COLLECTION_TITLE}} — {{COLLECTION_SUB}}</title>
|
||
|
||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||
<link href="https://fonts.googleapis.com/css2?family=Cormorant+Garamond:ital,wght@0,400;0,500;1,400;1,500&family=Inter:wght@300;400;500&family=JetBrains+Mono:wght@400;500&family=Noto+Serif+SC:wght@400;500&family=Noto+Sans+SC:wght@300;400;500&display=swap" rel="stylesheet">
|
||
|
||
<style>
|
||
/* ===================== design tokens — 冷调钢蓝工业 ===================== */
|
||
/* SWAP-STYLE 这 8 个变量决定整站调性,换风格只改这里 */
|
||
:root{
|
||
--bg: #e6e9ee;
|
||
--bg-soft: #f1f3f6;
|
||
--paper: #c9cfd6;
|
||
--ink: #0c0f14;
|
||
--ink-soft: #535963;
|
||
--line: #aab0b8;
|
||
--line-soft: #c4c9d0;
|
||
--steel: #3a4a5a;
|
||
--steel-dim: #6a7684;
|
||
|
||
--serif: 'Cormorant Garamond', 'Noto Serif SC', 'Songti SC', 'STSong', serif;
|
||
--sans: 'Inter', 'Noto Sans SC', -apple-system, BlinkMacSystemFont, sans-serif;
|
||
--mono: 'JetBrains Mono', ui-monospace, monospace;
|
||
|
||
--text-xs: 10.5px;
|
||
--text-s: 12px;
|
||
--text-m: 14px;
|
||
--text-l: 28px;
|
||
--text-xl: 56px;
|
||
}
|
||
|
||
*{ margin:0; padding:0; box-sizing:border-box; }
|
||
html, body{
|
||
width:100%; height:100%;
|
||
background: var(--bg);
|
||
color: var(--ink);
|
||
font-family: var(--sans);
|
||
font-size: var(--text-m);
|
||
letter-spacing: 0.005em;
|
||
-webkit-font-smoothing: antialiased;
|
||
-moz-osx-font-smoothing: grayscale;
|
||
}
|
||
body{
|
||
overflow:hidden;
|
||
background-image:
|
||
radial-gradient(at 20% 22%, rgba(170,176,184,0.22) 0px, transparent 50%),
|
||
radial-gradient(at 78% 80%, rgba(58,74,90,0.07) 0px, transparent 55%);
|
||
}
|
||
body.detail-open{ overflow-y:auto; }
|
||
::selection{ background: var(--steel); color: var(--bg-soft); }
|
||
a{ color: inherit; text-decoration: none; }
|
||
button{ all: unset; cursor: pointer; }
|
||
|
||
.page-title, h1{
|
||
font-family: var(--serif); font-style: italic; font-weight: 400;
|
||
font-size: var(--text-l); letter-spacing: 0.01em; line-height: 1.1;
|
||
}
|
||
h2{
|
||
font-family: var(--serif); font-style: italic; font-weight: 400;
|
||
font-size: var(--text-xl); letter-spacing: 0.01em; line-height: 1.02;
|
||
}
|
||
.label{
|
||
font-family: var(--mono); font-size: var(--text-xs);
|
||
letter-spacing: 0.14em; text-transform: uppercase;
|
||
color: var(--ink-soft); line-height: 1.55;
|
||
}
|
||
.label--ink{ color: var(--ink); }
|
||
.serif-tag{
|
||
font-family: var(--serif); font-style: italic; font-weight: 400;
|
||
font-size: 17px; letter-spacing: 0.01em; line-height: 1.35;
|
||
}
|
||
|
||
/* ===================== ticker ===================== */
|
||
.ticker{
|
||
position: fixed; top: 0; left: 0; right: 0;
|
||
height: 22px; z-index: 4;
|
||
border-bottom: 1px solid var(--line);
|
||
background: var(--bg-soft);
|
||
display: flex; align-items: center; overflow: hidden;
|
||
font-family: var(--mono); font-size: 10px; letter-spacing: 0.18em;
|
||
color: var(--ink-soft); text-transform: uppercase;
|
||
}
|
||
.ticker-track{
|
||
display:flex; gap: 36px;
|
||
animation: ticker 70s linear infinite;
|
||
padding-left: 100%; white-space: nowrap;
|
||
}
|
||
@keyframes ticker{
|
||
0%{ transform: translateX(0); }
|
||
100%{ transform: translateX(-100%); }
|
||
}
|
||
.ticker .dot{ color: var(--steel); }
|
||
|
||
/* ===================== grid ===================== */
|
||
.stage{
|
||
position: fixed; inset: 0;
|
||
padding-top: 44px;
|
||
display: grid;
|
||
grid-template-columns: 280px 1fr 280px;
|
||
grid-template-rows: 64px 1fr 64px;
|
||
gap: 22px;
|
||
padding: 44px 26px 22px;
|
||
z-index: 5;
|
||
}
|
||
.g1c1{ grid-column:1; grid-row:1; }
|
||
.g1c2{ grid-column:2; grid-row:1; }
|
||
.g1c3{ grid-column:3; grid-row:1; }
|
||
.g2c1{ grid-column:1; grid-row:2; }
|
||
.g2c2{ grid-column:2; grid-row:2; }
|
||
.g2c3{ grid-column:3; grid-row:2; }
|
||
.g3c1{ grid-column:1; grid-row:3; }
|
||
.g3c2{ grid-column:2; grid-row:3; }
|
||
.g3c3{ grid-column:3; grid-row:3; }
|
||
|
||
/* header */
|
||
.head-mark{ display:flex; flex-direction:column; gap:4px; }
|
||
.head-mark .glyph{
|
||
font-family: var(--serif); font-style: italic; font-weight: 400;
|
||
font-size: 32px; line-height: 1; letter-spacing: 0.01em; color: var(--ink);
|
||
}
|
||
.head-center{ display:flex; flex-direction:column; align-items:center; gap:4px; padding-top:4px; }
|
||
.head-right{ display:flex; flex-direction:column; align-items:flex-end; gap:4px; }
|
||
|
||
.nav-btn{
|
||
display:inline-flex; align-items:center; gap:8px;
|
||
padding: 7px 12px;
|
||
border: 1px solid var(--line);
|
||
background: rgba(255,255,255,0.3);
|
||
transition: border-color .35s, color .35s, background .35s;
|
||
color: var(--ink);
|
||
}
|
||
.nav-btn:hover{ border-color: var(--ink); background: var(--ink); color: var(--bg-soft); }
|
||
.nav-btn .arr{ font-family: var(--mono); font-size: 13px; }
|
||
.nav-btn .txt{
|
||
font-family: var(--mono); font-size: var(--text-xs);
|
||
letter-spacing: 0.14em; text-transform: uppercase;
|
||
}
|
||
|
||
/* sidebar */
|
||
.sidebar{ display:flex; flex-direction:column; justify-content:center; position:relative; }
|
||
.sidebar-head{ margin-bottom: 18px; }
|
||
.sidebar-head .rule{ width: 28px; height: 1px; background: var(--ink); margin-top: 8px; }
|
||
|
||
.proj-list{ list-style: none; position: relative; padding-left: 14px; }
|
||
.indicator{
|
||
position: absolute;
|
||
left: 0; top: 0;
|
||
width: 2px; height: 0;
|
||
background: var(--steel);
|
||
transition: top .55s cubic-bezier(.2,.8,.2,1), height .55s cubic-bezier(.2,.8,.2,1);
|
||
}
|
||
.proj-list li{
|
||
position: relative;
|
||
padding: 7px 0;
|
||
cursor: pointer;
|
||
color: var(--ink-soft);
|
||
transition: color .5s cubic-bezier(.2,.8,.2,1),
|
||
transform .55s cubic-bezier(.2,.8,.2,1),
|
||
padding-left .45s cubic-bezier(.2,.8,.2,1);
|
||
display:flex; align-items: baseline; gap: 12px;
|
||
}
|
||
.proj-list li .num{
|
||
font-family: var(--mono); font-size: 10px; letter-spacing: 0.14em;
|
||
color: var(--ink-soft); min-width: 36px;
|
||
transition: color .4s;
|
||
}
|
||
.proj-list li .ttl{
|
||
font-family: var(--serif); font-style: italic; font-weight: 400;
|
||
font-size: var(--text-l); letter-spacing: 0.01em; line-height: 1.12;
|
||
display:inline-block; position:relative;
|
||
}
|
||
.proj-list li .ttl::after{
|
||
content:""; position:absolute; left:0; bottom: 2px;
|
||
width: 0; height: 1px; background: var(--ink);
|
||
transition: width .55s cubic-bezier(.2,.8,.2,1);
|
||
}
|
||
.proj-list li:hover{ color: var(--ink); padding-left: 6px; }
|
||
.proj-list li:hover .num{ color: var(--ink); }
|
||
.proj-list li:hover .ttl::after{ width: 100%; }
|
||
.proj-list li.active{ color: var(--ink); padding-left: 14px; }
|
||
.proj-list li.active .num{ color: var(--steel); }
|
||
.proj-list li.active .ttl::after{ width: 100%; background: var(--steel); }
|
||
.proj-list li .ttl .ch{ display: inline-block; }
|
||
.proj-list li.active .ttl .ch{
|
||
animation: shimmer 2.8s ease-in-out infinite;
|
||
animation-delay: calc(var(--i,0) * 80ms);
|
||
}
|
||
@keyframes shimmer{
|
||
0%, 92%, 100%{ transform: translateY(0); opacity: 1; }
|
||
96%{ transform: translateY(-2px); opacity: .55; }
|
||
}
|
||
|
||
/* viewport (cover) */
|
||
.viewport{
|
||
position: relative; overflow: visible;
|
||
background: #0a0c11;
|
||
cursor: pointer;
|
||
z-index: 10;
|
||
box-shadow:
|
||
inset 0 0 0 1px rgba(170,176,184,0.22),
|
||
0 30px 60px -28px rgba(12,15,20,0.45);
|
||
}
|
||
.viewport::before{
|
||
content: ""; position: absolute; inset: 0; z-index: 1;
|
||
box-shadow: inset 0 0 140px rgba(0,0,0,0.85);
|
||
pointer-events: none;
|
||
}
|
||
.viewport::after{
|
||
content: ""; position: absolute; inset: 0; z-index: 2;
|
||
pointer-events: none; opacity: 0.08;
|
||
background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='220' height='220'><filter id='n'><feTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='2' stitchTiles='stitch'/><feColorMatrix values='0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 0.5 0'/></filter><rect width='100%25' height='100%25' filter='url(%23n)'/></svg>");
|
||
mix-blend-mode: screen;
|
||
}
|
||
.viewport-mask{
|
||
position: absolute; inset: 0; z-index: 20;
|
||
overflow: hidden;
|
||
}
|
||
.viewport.switching .viewport-mask{ overflow: visible; }
|
||
|
||
.cover-stage{
|
||
position: absolute; inset: 0;
|
||
perspective: 600px;
|
||
perspective-origin: 50% 50%;
|
||
transform-style: preserve-3d;
|
||
}
|
||
.cover-flipper{
|
||
position: absolute; inset: 0;
|
||
transform-style: preserve-3d;
|
||
transform: translateZ(0) rotateY(0deg);
|
||
transition: transform 1.05s cubic-bezier(.55,.05,.25,1);
|
||
will-change: transform;
|
||
opacity: 1;
|
||
}
|
||
.cover-face{
|
||
position: absolute; inset: 0;
|
||
backface-visibility: hidden;
|
||
-webkit-backface-visibility: hidden;
|
||
background-color: #b0b6be;
|
||
background-image: linear-gradient(180deg, #bcc2ca 0%, #a4abb4 100%);
|
||
overflow: hidden;
|
||
opacity: 1;
|
||
isolation: isolate;
|
||
box-shadow:
|
||
inset 0 0 0 1px rgba(12,15,20,0.32),
|
||
inset 0 -60px 80px -40px rgba(12,15,20,0.22),
|
||
0 28px 60px -22px rgba(12,15,20,0.55);
|
||
}
|
||
.cover-face.front{ transform: rotateY(0deg); }
|
||
.cover-face.back { transform: rotateY(180deg); }
|
||
.cover-face img{
|
||
display: block;
|
||
width: 100%; height: 100%;
|
||
object-fit: cover;
|
||
filter: contrast(1.05) saturate(0.85);
|
||
}
|
||
.cover-face .frame-marks{
|
||
position:absolute; inset:0; pointer-events:none;
|
||
z-index: 5;
|
||
}
|
||
.cover-face .frame-marks span{
|
||
position:absolute; font-family: var(--mono); font-size:10px;
|
||
letter-spacing:0.18em; color: rgba(241,243,246,0.78);
|
||
}
|
||
.cover-face .frame-marks .ft{ top:14px; left:18px; }
|
||
.cover-face .frame-marks .fr{ top:14px; right:18px; }
|
||
.cover-face .frame-marks .fl{ bottom:14px; left:18px; }
|
||
.cover-face .frame-marks .fb{ bottom:14px; right:18px; }
|
||
.cover-face::after{
|
||
content:""; position:absolute; inset:0;
|
||
background:
|
||
linear-gradient(180deg, rgba(0,0,0,0.0) 55%, rgba(0,0,0,0.55) 100%),
|
||
linear-gradient(0deg, rgba(0,0,0,0.0) 70%, rgba(0,0,0,0.35) 100%);
|
||
pointer-events:none;
|
||
}
|
||
|
||
.cover-caption{
|
||
position: absolute; left: 24px; bottom: 24px; right: 24px;
|
||
z-index: 30;
|
||
color: var(--bg-soft);
|
||
display:flex; justify-content: space-between; align-items: flex-end;
|
||
gap: 24px;
|
||
pointer-events: none;
|
||
}
|
||
.cover-caption .lhs{ max-width: 70%; }
|
||
.cover-caption .ch{
|
||
font-family: var(--serif); font-style: italic; font-weight: 400;
|
||
font-size: clamp(36px, 5vw, 64px); line-height: 1; letter-spacing: 0.01em;
|
||
text-shadow: 0 4px 28px rgba(0,0,0,0.5);
|
||
transition: opacity .45s, transform .55s cubic-bezier(.2,.8,.2,1);
|
||
}
|
||
.cover-caption .en{
|
||
margin-top: 8px;
|
||
font-family: var(--mono); font-size: var(--text-xs);
|
||
letter-spacing: 0.2em; text-transform: uppercase;
|
||
color: rgba(241,243,246,0.9);
|
||
transition: opacity .45s, transform .55s cubic-bezier(.2,.8,.2,1);
|
||
transition-delay: .08s;
|
||
}
|
||
.cover-caption .rhs{
|
||
text-align: right;
|
||
font-family: var(--mono); font-size: var(--text-xs);
|
||
letter-spacing: 0.14em; text-transform: uppercase;
|
||
color: rgba(241,243,246,0.85); line-height: 1.7;
|
||
transition: opacity .45s, transform .55s cubic-bezier(.2,.8,.2,1);
|
||
transition-delay: .16s;
|
||
}
|
||
.cover-caption.swap .ch,
|
||
.cover-caption.swap .en,
|
||
.cover-caption.swap .rhs{ opacity: 0; transform: translateY(8px); }
|
||
|
||
.bracket{ position: absolute; width: 14px; height: 14px; z-index: 40; pointer-events:none; }
|
||
.bracket--tl{ top: 12px; left: 12px; border-top:1px solid var(--bg-soft); border-left:1px solid var(--bg-soft); }
|
||
.bracket--tr{ top: 12px; right:12px; border-top:1px solid var(--bg-soft); border-right:1px solid var(--bg-soft); }
|
||
.bracket--bl{ bottom:12px; left: 12px; border-bottom:1px solid var(--bg-soft); border-left:1px solid var(--bg-soft); }
|
||
.bracket--br{ bottom:12px; right:12px; border-bottom:1px solid var(--bg-soft); border-right:1px solid var(--bg-soft); }
|
||
|
||
.enter-cue{
|
||
position: absolute; top: 18px; right: 18px; z-index: 40;
|
||
font-family: var(--mono); font-size: var(--text-xs);
|
||
letter-spacing: 0.18em; text-transform: uppercase;
|
||
color: var(--bg-soft);
|
||
display:flex; align-items:center; gap: 8px;
|
||
padding: 6px 12px;
|
||
border: 1px solid rgba(241,243,246,0.4);
|
||
backdrop-filter: blur(4px);
|
||
background: rgba(12,15,20,0.28);
|
||
cursor: pointer;
|
||
transition: background .3s, border-color .3s, gap .3s;
|
||
}
|
||
.enter-cue:hover{ background: rgba(12,15,20,0.55); border-color: var(--bg-soft); gap: 14px; }
|
||
|
||
/* meta col */
|
||
.meta-col{
|
||
display:flex; flex-direction: column; justify-content: space-between;
|
||
gap: 16px; padding: 4px 0;
|
||
}
|
||
.meta-block{ border-top: 1px solid var(--line); padding-top: 10px; }
|
||
.meta-block .value{
|
||
font-family: var(--serif); font-style: italic; font-weight: 400;
|
||
font-size: 19px; letter-spacing: 0.01em; line-height: 1.35;
|
||
color: var(--ink); margin-top: 6px;
|
||
transition: opacity .35s, transform .45s cubic-bezier(.2,.8,.2,1);
|
||
}
|
||
.meta-col.swap .meta-block .value{ opacity: 0; transform: translateY(6px); }
|
||
.meta-block .label{ display:block; }
|
||
|
||
/* counter */
|
||
.foot-count{ display:flex; align-items: baseline; gap: 10px; }
|
||
.foot-count .now{
|
||
font-family: var(--serif); font-style: italic; font-weight: 400;
|
||
font-size: 44px; letter-spacing: 0.01em; line-height: 1; color: var(--ink);
|
||
display:inline-block;
|
||
transition: opacity .35s, transform .5s cubic-bezier(.2,.8,.2,1);
|
||
}
|
||
.foot-count.swap .now{ opacity: 0; transform: translateY(8px); }
|
||
.foot-count .of{
|
||
font-family: var(--mono); font-size: var(--text-xs);
|
||
letter-spacing: 0.14em; color: var(--ink-soft);
|
||
}
|
||
.progress-track{
|
||
margin-top: 8px; width: 100%; height: 1px;
|
||
background: var(--line-soft); position: relative;
|
||
}
|
||
.progress-track::after{
|
||
content:""; position:absolute; left:0; top:0; height:100%;
|
||
width: var(--p, 25%);
|
||
background: var(--steel);
|
||
transition: width .6s cubic-bezier(.2,.8,.2,1);
|
||
}
|
||
|
||
.foot-cta{
|
||
display:inline-flex; align-items: center; gap: 12px;
|
||
padding: 10px 18px;
|
||
border: 1px solid var(--ink); background: var(--ink); color: var(--bg-soft);
|
||
font-family: var(--mono); font-size: var(--text-xs);
|
||
letter-spacing: 0.18em; text-transform: uppercase;
|
||
transition: background .4s, border-color .4s, gap .4s;
|
||
}
|
||
.foot-cta:hover{ background: var(--steel); border-color: var(--steel); gap: 22px; }
|
||
|
||
.foot-right-stack{ display:flex; flex-direction: column; align-items:flex-end; gap: 4px; }
|
||
.foot-right-stack a{ border-bottom: 1px solid transparent; transition: border-color .3s; }
|
||
.foot-right-stack a:hover{ border-color: var(--ink-soft); }
|
||
|
||
/* intro splash */
|
||
.intro{
|
||
position: fixed; inset: 0; z-index: 80;
|
||
background: var(--bg);
|
||
display: flex; align-items: center; justify-content: center;
|
||
transition: opacity .9s ease, visibility .9s;
|
||
}
|
||
.intro.hide{ opacity: 0; visibility: hidden; }
|
||
.intro-content{ max-width: 760px; padding: 0 32px; text-align: center; }
|
||
.intro-tag{
|
||
font-family: var(--mono); font-size: var(--text-xs);
|
||
letter-spacing: 0.3em; text-transform: uppercase;
|
||
color: var(--ink-soft);
|
||
display:inline-flex; align-items: center; gap: 14px;
|
||
}
|
||
.intro-tag .ln{ width: 28px; height: 1px; background: var(--line); }
|
||
.intro-title{
|
||
margin-top: 32px;
|
||
font-family: var(--serif); font-style: italic; font-weight: 400;
|
||
font-size: clamp(64px, 9vw, 128px); line-height: 1;
|
||
letter-spacing: 0.005em;
|
||
}
|
||
.intro-title .accent{ color: var(--steel); }
|
||
.intro-sub{
|
||
margin-top: 24px;
|
||
font-family: var(--mono); font-size: 11px;
|
||
letter-spacing: 0.2em; text-transform: uppercase; color: var(--ink-soft);
|
||
}
|
||
.intro-quote{
|
||
margin: 40px auto 0; max-width: 540px;
|
||
font-family: var(--serif); font-style: italic; font-weight: 400;
|
||
font-size: 19px; line-height: 1.5; color: var(--ink);
|
||
letter-spacing: 0.01em;
|
||
}
|
||
.intro-quote::before{ content:"〝 "; color: var(--steel); }
|
||
.intro-quote::after{ content:" 〞"; color: var(--steel); }
|
||
.intro-attr{
|
||
margin-top: 14px; font-family: var(--mono); font-size: 10.5px;
|
||
letter-spacing: 0.2em; text-transform: uppercase; color: var(--ink-soft);
|
||
}
|
||
.intro-cta{
|
||
margin-top: 56px;
|
||
display:inline-flex; align-items: center; gap: 14px;
|
||
padding: 14px 26px;
|
||
border: 1px solid var(--ink); background: transparent; color: var(--ink);
|
||
font-family: var(--mono); font-size: 11px;
|
||
letter-spacing: 0.22em; text-transform: uppercase;
|
||
cursor: pointer;
|
||
transition: background .4s, color .4s, gap .4s;
|
||
}
|
||
.intro-cta:hover{ background: var(--ink); color: var(--bg-soft); gap: 22px; }
|
||
|
||
/* detail page */
|
||
.detail{
|
||
position: fixed; inset: 0; z-index: 60;
|
||
background: var(--bg);
|
||
overflow-y: auto;
|
||
visibility: hidden; opacity: 0;
|
||
transition: opacity .55s ease, visibility .55s;
|
||
}
|
||
.detail.open{ visibility: visible; opacity: 1; }
|
||
.detail-bar{
|
||
position: sticky; top: 0; z-index: 5;
|
||
display:flex; justify-content: space-between; align-items: center;
|
||
padding: 14px 26px;
|
||
border-bottom: 1px solid var(--line);
|
||
background: rgba(230,233,238,0.92);
|
||
backdrop-filter: blur(8px);
|
||
}
|
||
.detail-bar .crumbs{
|
||
font-family: var(--mono); font-size: 10.5px;
|
||
letter-spacing: 0.16em; text-transform: uppercase; color: var(--ink-soft);
|
||
}
|
||
.detail-bar .crumbs .sep{ margin: 0 10px; color: var(--line); }
|
||
.detail-bar .crumbs .cur{ color: var(--ink); }
|
||
.detail-close{
|
||
display:inline-flex; align-items: center; gap: 10px;
|
||
padding: 8px 14px; border: 1px solid var(--line);
|
||
font-family: var(--mono); font-size: 10.5px;
|
||
letter-spacing: 0.16em; text-transform: uppercase; color: var(--ink);
|
||
transition: background .35s, color .35s, border-color .35s;
|
||
}
|
||
.detail-close:hover{ background: var(--ink); color: var(--bg-soft); border-color: var(--ink); }
|
||
|
||
.detail-inner{ max-width: 1320px; margin: 0 auto; padding: 80px 26px 120px; }
|
||
.detail-hero{
|
||
display: grid; grid-template-columns: 1fr 360px;
|
||
gap: 56px; align-items: end;
|
||
}
|
||
.detail-tag-row{ display:flex; align-items: center; gap: 16px; margin-bottom: 22px; }
|
||
.detail-tag-row .steel-pill{
|
||
display:inline-flex; align-items: center; gap: 8px;
|
||
padding: 5px 12px;
|
||
background: var(--steel); color: var(--bg-soft);
|
||
font-family: var(--mono); font-size: 10px;
|
||
letter-spacing: 0.2em; text-transform: uppercase;
|
||
}
|
||
.detail-tag-row .ln{ flex: 1; height: 1px; background: var(--line); }
|
||
|
||
.detail-title{
|
||
font-family: var(--serif); font-style: italic; font-weight: 400;
|
||
font-size: clamp(72px, 9vw, 140px); line-height: 0.95; letter-spacing: 0.005em; color: var(--ink);
|
||
}
|
||
.detail-title .accent-line{
|
||
display: inline-block;
|
||
border-bottom: 1px solid var(--steel);
|
||
padding-bottom: 6px;
|
||
}
|
||
.detail-subtitle{
|
||
margin-top: 18px;
|
||
font-family: var(--mono); font-size: 11px;
|
||
letter-spacing: 0.22em; text-transform: uppercase; color: var(--ink-soft);
|
||
}
|
||
|
||
.detail-spec-table{ border-top: 1px solid var(--ink); padding-top: 16px; }
|
||
.detail-spec-table dl{
|
||
display: grid; grid-template-columns: 1fr 1.4fr;
|
||
gap: 14px 12px; font-size: 13px; line-height: 1.55;
|
||
}
|
||
.detail-spec-table dt{
|
||
font-family: var(--mono); font-size: 10px;
|
||
letter-spacing: 0.16em; text-transform: uppercase; color: var(--ink-soft);
|
||
}
|
||
.detail-spec-table dd{
|
||
font-family: var(--serif); font-style: italic; font-weight: 400;
|
||
font-size: 15px; letter-spacing: 0.01em; color: var(--ink); line-height: 1.4;
|
||
}
|
||
|
||
.detail-hero-img{
|
||
margin-top: 56px;
|
||
width: 100%; aspect-ratio: 21/9;
|
||
overflow: hidden; background: var(--paper); position: relative;
|
||
border: 1px solid var(--line);
|
||
}
|
||
.detail-hero-img img{ width: 100%; height: 100%; display: block; object-fit: cover; }
|
||
.detail-hero-img .caption{
|
||
position: absolute; left: 16px; bottom: 12px;
|
||
font-family: var(--mono); font-size: 10px;
|
||
letter-spacing: 0.16em; text-transform: uppercase; color: var(--ink);
|
||
background: var(--bg-soft); padding: 4px 8px;
|
||
}
|
||
|
||
.detail-body{ margin-top: 64px; display: grid; grid-template-columns: 1fr 2fr; gap: 64px; }
|
||
.detail-body .left h4{
|
||
font-family: var(--mono); font-size: 11px;
|
||
letter-spacing: 0.18em; text-transform: uppercase;
|
||
color: var(--ink-soft); font-weight: 500;
|
||
}
|
||
.detail-body .left p{
|
||
margin-top: 10px;
|
||
font-family: var(--serif); font-style: italic; font-weight: 400;
|
||
font-size: 19px; line-height: 1.4; letter-spacing: 0.01em; color: var(--ink);
|
||
}
|
||
.detail-body .right .lead{
|
||
font-family: var(--serif); font-style: italic; font-weight: 400;
|
||
font-size: 24px; line-height: 1.45; letter-spacing: 0.005em; color: var(--ink);
|
||
border-left: 1px solid var(--steel); padding-left: 24px;
|
||
}
|
||
.detail-body .right .lead em{
|
||
font-style: italic; border-bottom: 1px solid var(--steel); padding-bottom: 1px;
|
||
}
|
||
.detail-body .right .prose{ margin-top: 36px; max-width: 620px; }
|
||
.detail-body .right .prose p{ margin-top: 18px; font-size: 14.5px; line-height: 1.85; color: #1a1d22; }
|
||
.detail-body .right .prose p:first-child{ margin-top: 0; }
|
||
|
||
.detail-gallery{ margin-top: 96px; border-top: 1px solid var(--line); padding-top: 32px; }
|
||
.detail-gallery-head{
|
||
display:flex; justify-content: space-between; align-items: baseline; margin-bottom: 24px;
|
||
}
|
||
.detail-gallery-head h3{
|
||
font-family: var(--serif); font-style: italic; font-weight: 400;
|
||
font-size: 32px; letter-spacing: 0.01em;
|
||
}
|
||
.detail-gallery-head .count{
|
||
font-family: var(--mono); font-size: 10.5px;
|
||
letter-spacing: 0.16em; text-transform: uppercase; color: var(--ink-soft);
|
||
}
|
||
.grid-figs{ display: grid; grid-template-columns: repeat(6, 1fr); gap: 12px; }
|
||
.grid-figs figure{
|
||
margin: 0; background: var(--paper); overflow: hidden; position: relative;
|
||
border: 1px solid var(--line);
|
||
}
|
||
.grid-figs figure img{
|
||
width: 100%; height: 100%; display: block; object-fit: cover;
|
||
transition: transform 1.4s cubic-bezier(.2,.8,.2,1);
|
||
}
|
||
.grid-figs figure:hover img{ transform: scale(1.04); }
|
||
.grid-figs figure figcaption{
|
||
position: absolute; left: 8px; bottom: 8px;
|
||
font-family: var(--mono); font-size: 9.5px;
|
||
letter-spacing: 0.16em; text-transform: uppercase;
|
||
color: var(--ink); background: var(--bg-soft); padding: 3px 7px;
|
||
}
|
||
.grid-figs .f-3{ grid-column: span 3; aspect-ratio: 16/10; }
|
||
.grid-figs .f-2{ grid-column: span 2; aspect-ratio: 4/5; }
|
||
.grid-figs .f-4{ grid-column: span 4; aspect-ratio: 16/9; }
|
||
.grid-figs .f-6{ grid-column: span 6; aspect-ratio: 21/8; }
|
||
|
||
.detail-nav{
|
||
margin-top: 96px; border-top: 1px solid var(--line); padding-top: 32px;
|
||
display:flex; justify-content: space-between; gap: 24px;
|
||
}
|
||
.detail-nav a{ display:flex; flex-direction: column; gap: 6px; transition: opacity .35s; }
|
||
.detail-nav a:hover{ opacity: 0.55; }
|
||
.detail-nav a .nav-lbl{
|
||
font-family: var(--mono); font-size: 10px;
|
||
letter-spacing: 0.2em; text-transform: uppercase; color: var(--ink-soft);
|
||
}
|
||
.detail-nav a .nav-title{
|
||
font-family: var(--serif); font-style: italic; font-weight: 400;
|
||
font-size: 26px; letter-spacing: 0.01em; color: var(--ink);
|
||
}
|
||
.detail-nav .next{ text-align: right; align-items: flex-end; }
|
||
|
||
.cursor-dot{
|
||
position: fixed; top:0; left: 0;
|
||
width: 7px; height: 7px; border-radius: 50%;
|
||
background: var(--steel);
|
||
pointer-events: none; z-index: 200;
|
||
transform: translate(-50%,-50%);
|
||
transition: width .25s, height .25s, opacity .25s;
|
||
mix-blend-mode: difference;
|
||
opacity: 0.9;
|
||
}
|
||
body.cursor-large .cursor-dot{ width: 38px; height: 38px; opacity: .9; }
|
||
|
||
@media (max-width: 1100px){
|
||
.stage{ grid-template-columns: 220px 1fr 220px; }
|
||
}
|
||
@media (max-width: 820px){
|
||
.stage{
|
||
grid-template-columns: 1fr;
|
||
grid-template-rows: 60px 280px 320px auto 60px;
|
||
}
|
||
.g1c1, .g1c3{ display: none; }
|
||
.g1c2{ grid-column: 1; grid-row: 1; }
|
||
.g2c1{ grid-column: 1; grid-row: 4; }
|
||
.g2c2{ grid-column: 1; grid-row: 2; }
|
||
.g2c3{ grid-column: 1; grid-row: 3; flex-direction: row; flex-wrap: wrap; gap: 8px; }
|
||
.g3c1, .g3c3{ display: none; }
|
||
.g3c2{ grid-column: 1; grid-row: 5; }
|
||
.detail-hero{ grid-template-columns: 1fr; gap: 24px; }
|
||
.detail-body{ grid-template-columns: 1fr; gap: 24px; }
|
||
.grid-figs{ grid-template-columns: repeat(2, 1fr); }
|
||
.grid-figs .f-3, .grid-figs .f-2, .grid-figs .f-4, .grid-figs .f-6{ grid-column: span 2; aspect-ratio: 4/3; }
|
||
}
|
||
</style>
|
||
</head>
|
||
|
||
<body>
|
||
|
||
<div class="ticker" aria-hidden="true">
|
||
<div class="ticker-track" id="ticker-track"></div>
|
||
</div>
|
||
|
||
<!-- ===================== intro splash ===================== -->
|
||
<div class="intro" id="intro">
|
||
<div class="intro-content">
|
||
<div class="intro-tag">
|
||
<span class="ln"></span>
|
||
<!-- SWAP-2 顶标 vol -->
|
||
<span>{{INTRO_VOLUME_TAG}}</span>
|
||
<span class="ln"></span>
|
||
</div>
|
||
<!-- SWAP-3 主标题(中文,带分隔点 . 用 .accent 包裹);句中点必须用 <span class="accent">·</span> -->
|
||
<h1 class="intro-title">{{INTRO_TITLE_HTML}}</h1>
|
||
<!-- SWAP-4 主副标 英文 -->
|
||
<p class="intro-sub">{{INTRO_TITLE_EN}}</p>
|
||
<!-- SWAP-5 引言(一句策展话) -->
|
||
<p class="intro-quote">{{INTRO_QUOTE}}</p>
|
||
<!-- SWAP-6 引言出处 -->
|
||
<p class="intro-attr">{{INTRO_ATTRIBUTION}}</p>
|
||
<button class="intro-cta" id="intro-enter">
|
||
<span>进入展厅</span>
|
||
<span>—→</span>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ===================== main stage ===================== -->
|
||
<main class="stage">
|
||
<div class="g1c1 head-mark">
|
||
<!-- SWAP-7 logo 字符(建议 1-2 个 serif 字符,如 Æ / 沉 / Ø) -->
|
||
<span class="glyph">{{HEAD_GLYPH}}</span>
|
||
<!-- SWAP-8 logo 下方说明 -->
|
||
<span class="label">{{HEAD_GLYPH_SUB}}</span>
|
||
</div>
|
||
|
||
<div class="g1c2 head-center">
|
||
<!-- SWAP-9 中央顶标·中文 -->
|
||
<span class="label label--ink">{{HEAD_CENTER_CN}}</span>
|
||
<!-- SWAP-10 中央顶标·英文 -->
|
||
<span class="label">{{HEAD_CENTER_EN}}</span>
|
||
</div>
|
||
|
||
<div class="g1c3 head-right">
|
||
<!-- SWAP-11 顶右上行(日期 / 城市) -->
|
||
<span class="label">{{HEAD_RIGHT_TOP}}</span>
|
||
<!-- SWAP-12 顶右下行(坐标 / 编号) -->
|
||
<span class="label">{{HEAD_RIGHT_BOTTOM}}</span>
|
||
</div>
|
||
|
||
<aside class="g2c1 sidebar">
|
||
<div class="sidebar-head">
|
||
<span class="label">展品目录</span>
|
||
<div class="rule"></div>
|
||
<span class="label" style="margin-top:8px;display:block">SELECTED OBJECTS</span>
|
||
</div>
|
||
<ol class="proj-list" id="proj-list">
|
||
<div class="indicator" id="indicator"></div>
|
||
<!-- proj-list 由 JS 根据 EXHIBITS 数据自动生成,不要手动写 li -->
|
||
</ol>
|
||
</aside>
|
||
|
||
<section class="g2c2 viewport" id="viewport">
|
||
<span class="bracket bracket--tl"></span>
|
||
<span class="bracket bracket--tr"></span>
|
||
<span class="bracket bracket--bl"></span>
|
||
<span class="bracket bracket--br"></span>
|
||
|
||
<button class="enter-cue" id="enter-cue">
|
||
<span>查看展品</span>
|
||
<span>→</span>
|
||
</button>
|
||
|
||
<div class="viewport-mask">
|
||
<div class="cover-stage" id="cover-stage">
|
||
<div class="cover-flipper" id="cover-flipper">
|
||
<div class="cover-face front" id="face-front"></div>
|
||
<div class="cover-face back" id="face-back"></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="cover-caption" id="cover-caption">
|
||
<div class="lhs">
|
||
<div class="ch" id="cover-ch"></div>
|
||
<div class="en" id="cover-en"></div>
|
||
</div>
|
||
<div class="rhs" id="cover-rhs"></div>
|
||
</div>
|
||
</section>
|
||
|
||
<aside class="g2c3 meta-col" id="meta-col">
|
||
<div class="meta-block">
|
||
<span class="label">材料 / MATERIAL</span>
|
||
<div class="value" id="meta-material"></div>
|
||
</div>
|
||
<div class="meta-block">
|
||
<span class="label">产地 / ORIGIN</span>
|
||
<div class="value" id="meta-origin"></div>
|
||
</div>
|
||
<div class="meta-block">
|
||
<span class="label">年代 / YEAR</span>
|
||
<div class="value" id="meta-year"></div>
|
||
</div>
|
||
<div class="meta-block">
|
||
<span class="label">尺寸 / DIMENSION</span>
|
||
<div class="value" id="meta-dim"></div>
|
||
</div>
|
||
</aside>
|
||
|
||
<div class="g3c1">
|
||
<div class="foot-count" id="foot-count">
|
||
<span class="now" id="cur-num">01</span>
|
||
<span class="of" id="cur-total">/ 04 件展品</span>
|
||
</div>
|
||
<div class="progress-track" id="progress-track" style="--p:25%"></div>
|
||
</div>
|
||
|
||
<div class="g3c2" style="display:flex; justify-content:space-between; align-items:flex-end; gap:24px;">
|
||
<button class="nav-btn" id="btn-prev">
|
||
<span class="arr">←</span><span class="txt">上一件</span>
|
||
</button>
|
||
<button class="foot-cta" id="btn-enter">
|
||
<span>进入展品 / VIEW EXHIBIT</span><span>—→</span>
|
||
</button>
|
||
<button class="nav-btn" id="btn-next">
|
||
<span class="txt">下一件</span><span class="arr">→</span>
|
||
</button>
|
||
</div>
|
||
|
||
<div class="g3c3 foot-right-stack">
|
||
<span class="label">策展 / CURATED BY</span>
|
||
<!-- SWAP-13 策展方名称 -->
|
||
<span class="serif-tag">{{CURATOR_NAME}}</span>
|
||
<!-- SWAP-14 联系邮箱 -->
|
||
<span class="label" style="margin-top:6px"><a href="mailto:{{CURATOR_EMAIL}}">{{CURATOR_EMAIL}}</a></span>
|
||
</div>
|
||
</main>
|
||
|
||
<div class="cursor-dot" id="cursor"></div>
|
||
|
||
<!-- detail page -->
|
||
<div class="detail" id="detail" aria-hidden="true">
|
||
<div class="detail-bar">
|
||
<div class="crumbs">
|
||
<!-- SWAP-15 面包屑两个固定项 -->
|
||
<span>{{CRUMB_ROOT}}</span><span class="sep">/</span>
|
||
<span>{{CRUMB_VOLUME}}</span><span class="sep">/</span>
|
||
<span class="cur" id="crumb-cur"></span>
|
||
</div>
|
||
<button class="detail-close" id="detail-close">
|
||
<span>←</span><span>返回展厅</span>
|
||
</button>
|
||
</div>
|
||
<div class="detail-inner" id="detail-inner"></div>
|
||
</div>
|
||
|
||
<!-- ===================== exhibit data =====================
|
||
SWAP-16 ★最关键★ — 这里放 N 个展品对象。可 1—N 件,建议 3—6 件最好看。
|
||
|
||
每个对象字段:
|
||
slug URL 用 ID(小写英文连字符),如 "iron-kettle"
|
||
num 编号,如 "N°01"
|
||
title 中文名,2-4 字最好看
|
||
en 英文名,全大写
|
||
year 年份字符串
|
||
place "国家 · 城市"
|
||
maker "工厂英文名 / 工厂中文名"
|
||
material 材料,可用 <br/> 分行
|
||
origin 产地段落,可用 <br/>
|
||
dim 尺寸,可用 <br/>
|
||
tagline 英文短语 + / + 中文短语,如 "FORGED ESSENTIAL / 锻造的必需品"
|
||
lead 一句开篇话,可用 <em>关键词</em> 强调一处
|
||
spec 数组 [[键, 值], ...],4-6 行
|
||
prose 数组 [段一, 段二, 段三],每段 80-160 字
|
||
cover 主图 URL(必填)— 实物照片 / 工业蓝图 / 渲染图都行
|
||
gallery 数组 [{src, layout, caption}, ...],3-6 张图
|
||
layout 必填,三选一:f-3 (16:10) / f-2 (4:5) / f-4 (16:9) / f-6 (21:8)
|
||
caption 是图片角标短语
|
||
-->
|
||
<script id="exhibits-data" type="application/json">
|
||
[
|
||
{
|
||
"slug": "example-one",
|
||
"num": "N°01",
|
||
"title": "示例物",
|
||
"en": "EXAMPLE OBJECT",
|
||
"year": "1958",
|
||
"place": "丹麦 · 哥本哈根",
|
||
"maker": "Example Værksted / 示例工坊",
|
||
"material": "灰口铸铁 · 山毛榉木柄<br/>黑釉烧结涂层",
|
||
"origin": "丹麦 · 哥本哈根<br/>Example Værksted",
|
||
"dim": "高 18 cm · 容 1.2 L<br/>底径 14 cm",
|
||
"tagline": "FORGED ESSENTIAL / 锻造的必需品",
|
||
"lead": "一只为<em>沉默与水的低温</em>而设计的容器——它的厚度,是为了让热停留得久一点。",
|
||
"spec": [
|
||
["编号", "1958 · K-04"],
|
||
["材料", "灰口铸铁 + 山毛榉"],
|
||
["工艺", "砂模铸造 / 黑釉烧结"],
|
||
["重量", "1.86 kg"],
|
||
["产量", "限量 240 件 · 现存 31"]
|
||
],
|
||
"prose": [
|
||
"示例段落一:来源 / 语境。介绍这件物件的产地、年份、出身工坊,以及它被造出来的那个时代背景。80-160 字。",
|
||
"示例段落二:工艺 / 决定。这件物件最关键的设计决定是什么?哪些细节是手工的、哪些是机器的?为什么要这么做?",
|
||
"示例段落三:呈现 / 影响。它今天还在被使用吗?现存几件?它对后来的同类物件留下了什么?"
|
||
],
|
||
"cover": "https://picsum.photos/seed/industrial-example-1/1600/1000?grayscale",
|
||
"gallery": [
|
||
{"src": "https://picsum.photos/seed/industrial-example-1a/900/600?grayscale", "layout": "f-3", "caption": "正立面"},
|
||
{"src": "https://picsum.photos/seed/industrial-example-1b/600/750?grayscale", "layout": "f-2", "caption": "把手细部"},
|
||
{"src": "https://picsum.photos/seed/industrial-example-1c/600/750?grayscale", "layout": "f-2", "caption": "嘴部细节"},
|
||
{"src": "https://picsum.photos/seed/industrial-example-1d/600/750?grayscale", "layout": "f-2", "caption": "底部印记"},
|
||
{"src": "https://picsum.photos/seed/industrial-example-1e/1200/675?grayscale", "layout": "f-4", "caption": "工坊蓝图"},
|
||
{"src": "https://picsum.photos/seed/industrial-example-1f/1600/610?grayscale", "layout": "f-6", "caption": "陈列剖面"}
|
||
]
|
||
},
|
||
{
|
||
"slug": "example-two",
|
||
"num": "N°02",
|
||
"title": "示例器",
|
||
"en": "EXAMPLE INSTRUMENT",
|
||
"year": "1974",
|
||
"place": "芬兰 · 坦佩雷",
|
||
"maker": "Example Lab / 示例研究所",
|
||
"material": "黄铜外圈 · 钢制滚珠轴承<br/>磨砂硼硅玻璃罩",
|
||
"origin": "芬兰 · 坦佩雷<br/>Example Lab",
|
||
"dim": "悬挑 42 cm · 罩径 Ø 22 cm<br/>整重 1.4 kg",
|
||
"tagline": "PIVOT & LIGHT / 旋转与光",
|
||
"lead": "把<em>工业用的滚珠轴承</em>,从机器的内部,请到一件家用器物的关节里。",
|
||
"spec": [
|
||
["编号", "1974 · A-Lab 17"],
|
||
["关节", "SKF 6204 滚珠轴承"],
|
||
["罩体", "磨砂硼硅玻璃"],
|
||
["产量", "实验批次 80 件"]
|
||
],
|
||
"prose": [
|
||
"示例段落一:废料计划的开端、回收的轴承编号、为什么是它。80-160 字。",
|
||
"示例段落二:装配的精度、可旋转的角度、停在任何角度的可能。",
|
||
"示例段落三:目录里那句冷静的话——废品的精度,仍是合格的精度。"
|
||
],
|
||
"cover": "https://picsum.photos/seed/industrial-example-2/1600/1000?grayscale",
|
||
"gallery": [
|
||
{"src": "https://picsum.photos/seed/industrial-example-2a/900/600?grayscale", "layout": "f-3", "caption": "悬挑全貌"},
|
||
{"src": "https://picsum.photos/seed/industrial-example-2b/600/750?grayscale", "layout": "f-2", "caption": "黄铜外圈"},
|
||
{"src": "https://picsum.photos/seed/industrial-example-2c/600/750?grayscale", "layout": "f-2", "caption": "轴承剖面"},
|
||
{"src": "https://picsum.photos/seed/industrial-example-2d/600/750?grayscale", "layout": "f-2", "caption": "罩体正视"},
|
||
{"src": "https://picsum.photos/seed/industrial-example-2e/1200/675?grayscale", "layout": "f-4", "caption": "节点装配图"},
|
||
{"src": "https://picsum.photos/seed/industrial-example-2f/1600/610?grayscale", "layout": "f-6", "caption": "研究所现场"}
|
||
]
|
||
}
|
||
]
|
||
</script>
|
||
|
||
<!-- ===================== ticker text =====================
|
||
SWAP-17 跑马灯尾巴的固定串。前半截会自动用所有展品拼出来,这里只写最后那段总标语。
|
||
-->
|
||
<script id="ticker-tail" type="text/plain">— {{TICKER_TAIL}}</script>
|
||
|
||
<script>
|
||
/* ============================================================
|
||
App logic — 不要改 JS / 不要改 CSS
|
||
============================================================ */
|
||
|
||
const DATA = JSON.parse(document.getElementById('exhibits-data').textContent);
|
||
const TICKER_TAIL = document.getElementById('ticker-tail').textContent.trim();
|
||
let cur = 0;
|
||
let switching = false;
|
||
|
||
const coverStage = document.getElementById('cover-stage');
|
||
const coverCap = document.getElementById('cover-caption');
|
||
const coverCh = document.getElementById('cover-ch');
|
||
const coverEn = document.getElementById('cover-en');
|
||
const coverRhs = document.getElementById('cover-rhs');
|
||
const projList = document.getElementById('proj-list');
|
||
const indicator = document.getElementById('indicator');
|
||
const metaCol = document.getElementById('meta-col');
|
||
const metaMat = document.getElementById('meta-material');
|
||
const metaOrigin = document.getElementById('meta-origin');
|
||
const metaYear = document.getElementById('meta-year');
|
||
const metaDim = document.getElementById('meta-dim');
|
||
const footCount = document.getElementById('foot-count');
|
||
const curNum = document.getElementById('cur-num');
|
||
const curTotal = document.getElementById('cur-total');
|
||
const progress = document.getElementById('progress-track');
|
||
const btnPrev = document.getElementById('btn-prev');
|
||
const btnNext = document.getElementById('btn-next');
|
||
const btnEnter = document.getElementById('btn-enter');
|
||
const enterCue = document.getElementById('enter-cue');
|
||
const detail = document.getElementById('detail');
|
||
const detailInner = document.getElementById('detail-inner');
|
||
const detailClose = document.getElementById('detail-close');
|
||
const crumbCur = document.getElementById('crumb-cur');
|
||
const introEl = document.getElementById('intro');
|
||
const introEnter = document.getElementById('intro-enter');
|
||
const cursor = document.getElementById('cursor');
|
||
const tickerTrack = document.getElementById('ticker-track');
|
||
|
||
const flipper = document.getElementById('cover-flipper');
|
||
const faceFront = document.getElementById('face-front');
|
||
const faceBack = document.getElementById('face-back');
|
||
let flipperRotation = 0;
|
||
let currentFace = 'front';
|
||
|
||
/* —— 通用图片封面 ——
|
||
用 <img> 替代 reference.html 里的 SVG。frame-marks 在 4 个角加工程图角注,
|
||
保留 reference.html 那种"画框 / 蓝图编号"的工业语境。 */
|
||
function coverHTML(d){
|
||
const safe = (s) => (s || '').replace(/"/g, '"');
|
||
return `
|
||
<img src="${safe(d.cover)}" alt="${safe(d.title)}" onerror="this.style.opacity=0.3"/>
|
||
<div class="frame-marks" aria-hidden="true">
|
||
<span class="ft">${safe(d.num)} / ${safe(d.en)} / ${safe(d.year)}</span>
|
||
<span class="fr">SCALE 1:1</span>
|
||
<span class="fl">${safe((d.maker||'').split('/')[0].trim())}</span>
|
||
<span class="fb">CAT. ${safe((d.num||'').replace(/\D/g,''))} / ${safe(d.year)}</span>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
/* —— 总件数文案 + 进度条 —— */
|
||
curTotal.textContent = `/ ${String(DATA.length).padStart(2,'0')} 件展品`;
|
||
|
||
/* —— 自动生成 sidebar 项目列表 —— */
|
||
function renderSidebar(){
|
||
const items = DATA.map((d, i) => {
|
||
const ttl = d.title.split('').map((c, j) =>
|
||
`<span class="ch" style="--i:${j}">${c}</span>`
|
||
).join('');
|
||
return `<li data-idx="${i}"${i===0?' class="active"':''}>
|
||
<span class="num">${d.num}</span>
|
||
<span class="ttl">${ttl}</span>
|
||
</li>`;
|
||
}).join('');
|
||
// 保留 indicator div,不要覆盖
|
||
const indicatorHTML = '<div class="indicator" id="indicator"></div>';
|
||
projList.innerHTML = indicatorHTML + items;
|
||
}
|
||
renderSidebar();
|
||
// 重新拿 indicator 引用(renderSidebar 重写过 innerHTML)
|
||
const indicatorEl = document.getElementById('indicator');
|
||
|
||
/* —— 初始正面 —— */
|
||
faceFront.innerHTML = coverHTML(DATA[0]);
|
||
|
||
/* —— ticker —— */
|
||
const tickerText = DATA.map(d =>
|
||
`<span><span class="dot">●</span> ${d.num} · ${d.title} / ${d.en} · ${d.year} · ${d.place}</span>`
|
||
).join('') + `<span>${TICKER_TAIL}</span>`;
|
||
tickerTrack.innerHTML = tickerText + tickerText;
|
||
|
||
function applyMeta(d, idx){
|
||
coverCap.classList.add('swap');
|
||
metaCol.classList.add('swap');
|
||
footCount.classList.add('swap');
|
||
|
||
setTimeout(() => {
|
||
coverCh.textContent = d.title;
|
||
coverEn.textContent = `${d.num} · ${d.en} · ${d.year}`;
|
||
coverRhs.innerHTML = `编号 / ${d.num}<br/>${d.place}<br/>${d.maker.split('/')[0].trim()}`;
|
||
metaMat.innerHTML = d.material;
|
||
metaOrigin.innerHTML = d.origin;
|
||
metaYear.innerHTML = d.year;
|
||
metaDim.innerHTML = d.dim;
|
||
curNum.textContent = String(idx + 1).padStart(2, '0');
|
||
progress.style.setProperty('--p', `${((idx + 1) / DATA.length) * 100}%`);
|
||
|
||
coverCap.classList.remove('swap');
|
||
metaCol.classList.remove('swap');
|
||
footCount.classList.remove('swap');
|
||
}, 280);
|
||
|
||
[...projList.querySelectorAll('li')].forEach((li, i) => li.classList.toggle('active', i === idx));
|
||
const activeLi = projList.querySelector('li.active');
|
||
if(activeLi){
|
||
const r1 = projList.getBoundingClientRect();
|
||
const r2 = activeLi.getBoundingClientRect();
|
||
indicatorEl.style.top = (r2.top - r1.top + 8) + 'px';
|
||
indicatorEl.style.height = (r2.height - 16) + 'px';
|
||
}
|
||
}
|
||
|
||
function setCover(idx, direction='next'){
|
||
const incomingFace = currentFace === 'front' ? faceBack : faceFront;
|
||
incomingFace.innerHTML = coverHTML(DATA[idx]);
|
||
flipperRotation += (direction === 'next' ? -180 : 180);
|
||
flipper.style.transform = `translateZ(0) rotateY(${flipperRotation}deg)`;
|
||
currentFace = currentFace === 'front' ? 'back' : 'front';
|
||
}
|
||
|
||
function go(idx){
|
||
if(switching) return;
|
||
const target = (idx + DATA.length) % DATA.length;
|
||
if(target === cur) return;
|
||
const direction =
|
||
(target - cur + DATA.length) % DATA.length === 1 ? 'next' :
|
||
(cur - target + DATA.length) % DATA.length === 1 ? 'prev' :
|
||
(target > cur ? 'next' : 'prev');
|
||
switching = true;
|
||
cur = target;
|
||
const vp = document.getElementById('viewport');
|
||
vp.classList.add('switching');
|
||
setCover(target, direction);
|
||
applyMeta(DATA[target], target);
|
||
setTimeout(() => {
|
||
switching = false;
|
||
vp.classList.remove('switching');
|
||
}, 1050);
|
||
}
|
||
|
||
applyMeta(DATA[0], 0);
|
||
window.addEventListener('load', () => applyMeta(DATA[cur], cur));
|
||
window.addEventListener('resize', () => applyMeta(DATA[cur], cur));
|
||
|
||
btnPrev.addEventListener('click', () => go(cur - 1));
|
||
btnNext.addEventListener('click', () => go(cur + 1));
|
||
projList.addEventListener('click', e => {
|
||
const li = e.target.closest('li[data-idx]');
|
||
if(li) go(Number(li.dataset.idx));
|
||
});
|
||
|
||
function hideIntro(){
|
||
introEl.classList.add('hide');
|
||
setTimeout(() => { introEl.style.display = 'none'; }, 950);
|
||
}
|
||
introEnter.addEventListener('click', hideIntro);
|
||
setTimeout(() => { if(!introEl.classList.contains('hide')) hideIntro(); }, 7000);
|
||
|
||
function renderDetail(idx){
|
||
const d = DATA[idx];
|
||
const prev = DATA[(idx - 1 + DATA.length) % DATA.length];
|
||
const next = DATA[(idx + 1) % DATA.length];
|
||
crumbCur.textContent = `${d.num} · ${d.title}`;
|
||
|
||
const specRows = d.spec.map(([k,v]) => `<dt>${k}</dt><dd>${v}</dd>`).join('');
|
||
const figs = d.gallery.map(g => `
|
||
<figure class="${g.layout}">
|
||
<img src="${g.src}" alt="${g.caption||''}" onerror="this.style.opacity=0.3"/>
|
||
${g.caption ? `<figcaption>${g.caption}</figcaption>` : ''}
|
||
</figure>`).join('');
|
||
|
||
detailInner.innerHTML = `
|
||
<section class="detail-hero">
|
||
<div class="detail-title-block">
|
||
<div class="detail-tag-row">
|
||
<span class="steel-pill">${d.num} · ${d.tagline}</span>
|
||
<span class="ln"></span>
|
||
</div>
|
||
<h2 class="detail-title">${d.title.split('').map(c=>`<span class="accent-line">${c}</span>`).join('')}</h2>
|
||
<p class="detail-subtitle">${d.en} · ${d.year} · ${d.place}</p>
|
||
</div>
|
||
<div class="detail-spec-table"><dl>${specRows}</dl></div>
|
||
</section>
|
||
|
||
<div class="detail-hero-img">
|
||
<img src="${d.cover}" alt="${d.title}" onerror="this.style.opacity=0.3"/>
|
||
<span class="caption">${d.num} · 主图 / Cover plate</span>
|
||
</div>
|
||
|
||
<section class="detail-body">
|
||
<div class="left">
|
||
<h4>制作者 / MAKER</h4>
|
||
<p>${d.maker}</p>
|
||
<h4 style="margin-top:32px">藏品序号 / CAT.</h4>
|
||
<p>${d.year} · ${d.num.replace('N°','')}</p>
|
||
<h4 style="margin-top:32px">现存 / EXTANT</h4>
|
||
<p>${d.spec[d.spec.length - 1][1]}</p>
|
||
</div>
|
||
<div class="right">
|
||
<div class="lead">${d.lead}</div>
|
||
<div class="prose">${d.prose.map(p => `<p>${p}</p>`).join('')}</div>
|
||
</div>
|
||
</section>
|
||
|
||
<section class="detail-gallery">
|
||
<div class="detail-gallery-head">
|
||
<h3>陈列细节 / Gallery</h3>
|
||
<span class="count">${d.gallery.length} 帧 · ${d.num}</span>
|
||
</div>
|
||
<div class="grid-figs">${figs}</div>
|
||
</section>
|
||
|
||
<nav class="detail-nav">
|
||
<a data-go="${DATA.indexOf(prev)}" href="#">
|
||
<span class="nav-lbl">← 上一件 / PREV</span>
|
||
<span class="nav-title">${prev.num} · ${prev.title}</span>
|
||
</a>
|
||
<a class="next" data-go="${DATA.indexOf(next)}" href="#">
|
||
<span class="nav-lbl">下一件 / NEXT →</span>
|
||
<span class="nav-title">${next.num} · ${next.title}</span>
|
||
</a>
|
||
</nav>
|
||
`;
|
||
detailInner.querySelectorAll('.detail-nav a').forEach(a => {
|
||
a.addEventListener('click', e => {
|
||
e.preventDefault();
|
||
const ni = Number(a.dataset.go);
|
||
const dir = ni > cur ? 'next' : 'prev';
|
||
cur = ni;
|
||
applyMeta(DATA[ni], ni);
|
||
setCover(ni, dir);
|
||
renderDetail(ni);
|
||
detail.scrollTop = 0;
|
||
});
|
||
});
|
||
}
|
||
|
||
function openDetail(){
|
||
renderDetail(cur);
|
||
detail.classList.add('open');
|
||
document.body.classList.add('detail-open');
|
||
}
|
||
function closeDetail(){
|
||
detail.classList.remove('open');
|
||
document.body.classList.remove('detail-open');
|
||
}
|
||
btnEnter.addEventListener('click', openDetail);
|
||
enterCue.addEventListener('click', e => { e.stopPropagation(); openDetail(); });
|
||
detailClose.addEventListener('click', closeDetail);
|
||
document.getElementById('viewport').addEventListener('click', e => {
|
||
if(e.target.closest('.enter-cue')) return;
|
||
if(e.target.closest('.bracket')) return;
|
||
openDetail();
|
||
});
|
||
|
||
window.addEventListener('keydown', e => {
|
||
if(detail.classList.contains('open')){
|
||
if(e.key === 'Escape') closeDetail();
|
||
return;
|
||
}
|
||
if(!introEl.classList.contains('hide')){
|
||
if(e.key === 'Enter' || e.key === ' ') { e.preventDefault(); hideIntro(); }
|
||
return;
|
||
}
|
||
if(e.key === 'ArrowLeft') go(cur - 1);
|
||
if(e.key === 'ArrowRight') go(cur + 1);
|
||
if(e.key === 'Enter') openDetail();
|
||
});
|
||
|
||
let mx=0, my=0, cx=0, cy=0;
|
||
window.addEventListener('mousemove', e => { mx = e.clientX; my = e.clientY; });
|
||
(function loop(){
|
||
cx += (mx - cx) * 0.22;
|
||
cy += (my - cy) * 0.22;
|
||
cursor.style.transform = `translate(${cx}px, ${cy}px) translate(-50%, -50%)`;
|
||
requestAnimationFrame(loop);
|
||
})();
|
||
['.proj-list li','.nav-btn','.foot-cta','.intro-cta','.detail-close','.enter-cue','.viewport','.detail-nav a']
|
||
.forEach(sel => {
|
||
document.querySelectorAll(sel).forEach(el => {
|
||
el.addEventListener('mouseenter', () => document.body.classList.add('cursor-large'));
|
||
el.addEventListener('mouseleave', () => document.body.classList.remove('cursor-large'));
|
||
});
|
||
});
|
||
|
||
const viewportEl = document.getElementById('viewport');
|
||
let tiltTargetX = 0, tiltTargetY = 0, tiltX = 0, tiltY = 0;
|
||
let tiltActive = false;
|
||
viewportEl.addEventListener('mousemove', e => {
|
||
const r = viewportEl.getBoundingClientRect();
|
||
tiltTargetY = ((e.clientX - r.left) / r.width - 0.5);
|
||
tiltTargetX = -((e.clientY - r.top) / r.height - 0.5);
|
||
tiltActive = true;
|
||
});
|
||
viewportEl.addEventListener('mouseleave', () => {
|
||
tiltTargetX = 0; tiltTargetY = 0;
|
||
setTimeout(() => { tiltActive = false; }, 800);
|
||
});
|
||
(function tiltLoop(){
|
||
tiltX += (tiltTargetX - tiltX) * 0.06;
|
||
tiltY += (tiltTargetY - tiltY) * 0.06;
|
||
if(tiltActive || Math.abs(tiltX) > 0.005 || Math.abs(tiltY) > 0.005){
|
||
coverStage.style.perspectiveOrigin = `${50 + tiltY * 8}% ${50 + tiltX * 5}%`;
|
||
}
|
||
requestAnimationFrame(tiltLoop);
|
||
})();
|
||
|
||
let autoTimer = setInterval(() => {
|
||
if(detail.classList.contains('open')) return;
|
||
if(!introEl.classList.contains('hide')) return;
|
||
if(DATA.length <= 1) return;
|
||
go(cur + 1);
|
||
}, 9000);
|
||
['mousedown','keydown','touchstart'].forEach(ev =>
|
||
window.addEventListener(ev, () => { clearInterval(autoTimer); autoTimer = null; }, { once: true })
|
||
);
|
||
</script>
|
||
|
||
</body>
|
||
</html>
|