商品浏览页构建上传1.1.0版本
This commit is contained in:
@@ -0,0 +1,819 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover">
|
||||
<title>NICHE FRAGRANCE · 小众沙龙香水</title>
|
||||
<style>
|
||||
/* ============================================================
|
||||
CSS Custom Properties & Reset
|
||||
============================================================ */
|
||||
:root {
|
||||
--scroll-progress: 0;
|
||||
--tone-r: 19; --tone-g: 11; --tone-b: 4;
|
||||
|
||||
--coral: #ff6b35;
|
||||
--amber: #f7931e;
|
||||
--title: #1a1a1a;
|
||||
--body: #4a4a4a;
|
||||
--muted: #999999;
|
||||
--bg-light: #f5f5f5;
|
||||
--shadow-sm: 0 2px 8px rgba(0,0,0,0.06);
|
||||
--shadow-md: 0 4px 20px rgba(0,0,0,0.08);
|
||||
--radius: 12px;
|
||||
--vh: 1vh;
|
||||
}
|
||||
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
html {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
scroll-behavior: smooth;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--bg-light);
|
||||
color: var(--title);
|
||||
line-height: 1.5;
|
||||
overflow-x: hidden;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
img { display: block; max-width: 100%; }
|
||||
|
||||
/* ============================================================
|
||||
List Page
|
||||
============================================================ */
|
||||
#listPage {
|
||||
position: fixed; inset: 0; z-index: 100;
|
||||
background: var(--bg-light);
|
||||
overflow-y: auto; -webkit-overflow-scrolling: touch;
|
||||
transition: opacity 0.35s ease;
|
||||
}
|
||||
#listPage.fading { opacity: 0; pointer-events: none; }
|
||||
|
||||
.list-header {
|
||||
position: sticky; top: 0; z-index: 10;
|
||||
background: rgba(245,245,245,0.92);
|
||||
backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px);
|
||||
padding: calc(env(safe-area-inset-top, 16px) + 8px) 20px 12px;
|
||||
text-align: center;
|
||||
}
|
||||
.list-header h1 {
|
||||
font-size: 18px; font-weight: 700; letter-spacing: 0.5px;
|
||||
background: linear-gradient(90deg, var(--coral), var(--amber));
|
||||
-webkit-background-clip: text; -webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
.list-header .subtitle { font-size: 12px; color: #999; margin-top: 2px; }
|
||||
|
||||
.product-grid {
|
||||
display: grid; grid-template-columns: 1fr 1fr; gap: 12px;
|
||||
padding: 12px 16px;
|
||||
padding-bottom: calc(env(safe-area-inset-bottom, 0px) + 80px);
|
||||
}
|
||||
|
||||
.product-card {
|
||||
border-radius: var(--radius); overflow: hidden;
|
||||
box-shadow: var(--shadow-sm); cursor: pointer;
|
||||
transition: transform 0.15s ease, box-shadow 0.15s ease;
|
||||
-webkit-tap-highlight-color: transparent; user-select: none;
|
||||
}
|
||||
.product-card:active { transform: scale(0.97); box-shadow: 0 1px 4px rgba(0,0,0,0.04); }
|
||||
|
||||
.product-card .card-img-wrap {
|
||||
aspect-ratio: 3 / 4; overflow: hidden; background: #2a2218;
|
||||
}
|
||||
.product-card .card-img-wrap img {
|
||||
width: 100%; height: 100%; object-fit: cover;
|
||||
transition: transform 0.4s ease;
|
||||
}
|
||||
.product-card .card-info { padding: 10px 12px 12px; }
|
||||
|
||||
.product-card .card-name {
|
||||
font-size: 14px; color: #e8e0d6; line-height: 1.4;
|
||||
display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical;
|
||||
overflow: hidden; min-height: 39px;
|
||||
}
|
||||
.product-card .card-price {
|
||||
font-family: "DIN Alternate", "Roboto", sans-serif;
|
||||
font-size: 16px; font-weight: 700; color: var(--coral); margin-top: 6px;
|
||||
}
|
||||
|
||||
.bottom-nav {
|
||||
position: fixed; bottom: 0; left: 0; right: 0; z-index: 20;
|
||||
background: rgba(255,255,255,0.95);
|
||||
backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px);
|
||||
display: flex; justify-content: space-around;
|
||||
padding: 8px 0 calc(env(safe-area-inset-bottom, 0px) + 4px);
|
||||
border-top: 1px solid rgba(0,0,0,0.06);
|
||||
}
|
||||
.nav-item {
|
||||
display: flex; flex-direction: column; align-items: center; gap: 2px;
|
||||
font-size: 11px; color: #999; cursor: pointer; padding: 4px 16px;
|
||||
transition: color 0.2s; -webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
.nav-item.active { color: var(--coral); }
|
||||
.nav-item .nav-icon { width: 24px; height: 24px; display: flex; align-items: center; justify-content: center; font-size: 20px; }
|
||||
|
||||
/* ============================================================
|
||||
FLIP Overlay
|
||||
============================================================ */
|
||||
#flipOverlay {
|
||||
position: fixed; z-index: 200; pointer-events: none; overflow: hidden;
|
||||
background: #000; will-change: transform, top, left, width, height;
|
||||
}
|
||||
#flipOverlay img { width: 100%; height: 100%; object-fit: cover; }
|
||||
|
||||
/* ============================================================
|
||||
Detail Page — colors driven by --tone-r/g/b
|
||||
============================================================ */
|
||||
#detailPage {
|
||||
display: none; position: relative;
|
||||
background: rgb(var(--tone-r), var(--tone-g), var(--tone-b));
|
||||
}
|
||||
#detailPage.active { display: block; }
|
||||
|
||||
.back-btn {
|
||||
position: fixed;
|
||||
top: calc(env(safe-area-inset-top, 16px) + 12px); left: 16px;
|
||||
z-index: 50; width: 40px; height: 40px; border-radius: 50%;
|
||||
background: rgba(var(--tone-r), var(--tone-g), var(--tone-b), 0.35);
|
||||
backdrop-filter: blur(16px) saturate(1.4);
|
||||
-webkit-backdrop-filter: blur(16px) saturate(1.4);
|
||||
border: 1px solid rgba(255,255,255,0.12);
|
||||
box-shadow: 0 2px 16px rgba(0,0,0,0.25), inset 0 1px 0 rgba(255,255,255,0.08);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
cursor: pointer; -webkit-tap-highlight-color: transparent;
|
||||
transition: transform 0.15s ease, opacity 0.3s ease, background 0.5s ease;
|
||||
}
|
||||
.back-btn:active { transform: scale(0.92); }
|
||||
.back-btn svg { width: 20px; height: 20px; }
|
||||
.back-btn svg line, .back-btn svg polyline { stroke: rgba(255,255,255,0.85); }
|
||||
|
||||
.hero-spacer { height: 65vh; height: calc(var(--vh, 1vh) * 65); }
|
||||
|
||||
.dark-bg { position: fixed; inset: 0; z-index: 0; overflow: hidden; }
|
||||
.dark-bg-img {
|
||||
position: absolute; top: -10%; left: -10%; width: 120%; height: 120%;
|
||||
object-fit: cover; filter: blur(60px) brightness(0.25);
|
||||
}
|
||||
.dark-bg-overlay {
|
||||
position: absolute; inset: 0;
|
||||
background: rgba(var(--tone-r), var(--tone-g), var(--tone-b), 0.45);
|
||||
}
|
||||
|
||||
.hero-image {
|
||||
position: fixed; top: 0; left: 0; width: 100vw; height: 100vh;
|
||||
z-index: 3; overflow: hidden;
|
||||
will-change: height, transform; pointer-events: none;
|
||||
}
|
||||
.hero-image img {
|
||||
position: absolute; top: 0; left: 0; width: 100%;
|
||||
height: 100vh; height: calc(var(--vh, 1vh) * 100);
|
||||
object-fit: cover; object-position: center; display: block;
|
||||
will-change: top;
|
||||
}
|
||||
|
||||
.hero-img-wrap {
|
||||
position: absolute; inset: 0;
|
||||
-webkit-mask-image: linear-gradient(to bottom, black 0%, black calc(100% - 55px), transparent 100%);
|
||||
mask-image: linear-gradient(to bottom, black 0%, black calc(100% - 55px), transparent 100%);
|
||||
}
|
||||
|
||||
.hero-info {
|
||||
position: absolute; bottom: 0; left: 0; right: 0; z-index: 2;
|
||||
padding: 50px 20px 16px;
|
||||
background: linear-gradient(to bottom,
|
||||
rgba(var(--tone-r),var(--tone-g),var(--tone-b),0) 0%,
|
||||
rgba(var(--tone-r),var(--tone-g),var(--tone-b),0.5) 30%,
|
||||
rgba(var(--tone-r),var(--tone-g),var(--tone-b),0.85) 60%,
|
||||
rgba(var(--tone-r),var(--tone-g),var(--tone-b),0.95) 100%);
|
||||
opacity: var(--blur-opacity, 0); pointer-events: none;
|
||||
display: flex; flex-direction: column; gap: 6px;
|
||||
}
|
||||
.hero-info-title {
|
||||
font-size: 20px; font-weight: 700; line-height: 1.3;
|
||||
background: linear-gradient(90deg, #ff6b35, #f7931e);
|
||||
-webkit-background-clip: text; -webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
.hero-info-subtitle { font-size: 12px; color: #b0a395; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.hero-info-price-row { display: flex; align-items: baseline; gap: 8px; }
|
||||
.hero-info-price { font-family: "DIN Alternate", "Roboto", sans-serif; font-size: 22px; font-weight: 700; color: #ff6b35; }
|
||||
.hero-info-original { font-family: "DIN Alternate", "Roboto", sans-serif; font-size: 13px; color: #8a7e72; text-decoration: line-through; }
|
||||
|
||||
.transition-band {
|
||||
position: sticky; top: 0; z-index: 2; margin-top: -1px;
|
||||
background: rgba(var(--tone-r), var(--tone-g), var(--tone-b), 0.88);
|
||||
backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px);
|
||||
box-shadow: 0 4px 24px rgba(0,0,0,0.3); overflow: hidden; transition: none;
|
||||
}
|
||||
.band-inner { padding: 24px 20px 20px; display: flex; flex-direction: column; gap: 8px; }
|
||||
.band-title {
|
||||
font-size: 22px; font-weight: 700; line-height: 1.3;
|
||||
background: linear-gradient(90deg, #ff6b35, #f7931e);
|
||||
-webkit-background-clip: text; -webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
.band-subtitle { font-size: 13px; color: #b0a395; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.band-price-row { display: flex; align-items: baseline; gap: 10px; }
|
||||
.band-price { font-family: "DIN Alternate", "Roboto", sans-serif; font-size: 24px; font-weight: 700; color: #ff6b35; }
|
||||
.band-price-original { font-family: "DIN Alternate", "Roboto", sans-serif; font-size: 14px; color: #8a7e72; text-decoration: line-through; }
|
||||
|
||||
.detail-content {
|
||||
position: relative; z-index: 1;
|
||||
background: rgba(var(--tone-r), var(--tone-g), var(--tone-b), 0.85);
|
||||
padding: 24px 20px 60px;
|
||||
}
|
||||
.detail-content section { margin-bottom: 32px; }
|
||||
.detail-content h3 {
|
||||
font-size: 18px; font-weight: 700; color: #f0e6d8;
|
||||
margin: 20px 0 12px; position: relative; padding-left: 14px;
|
||||
}
|
||||
.detail-content h3::before {
|
||||
content: ''; position: absolute; left: 0; top: 4px; bottom: 4px; width: 3px;
|
||||
border-radius: 2px; background: linear-gradient(180deg, var(--coral), var(--amber));
|
||||
}
|
||||
.detail-content p { font-size: 15px; color: #c4b8a8; line-height: 1.75; margin-bottom: 10px; }
|
||||
.detail-content .note-item { margin-bottom: 8px; padding-left: 4px; }
|
||||
.detail-content .note-item strong { color: #f0e6d8; }
|
||||
.detail-content .divider {
|
||||
height: 1px; background: linear-gradient(90deg, transparent, rgba(255,255,255,0.08), transparent);
|
||||
margin: 28px 0;
|
||||
}
|
||||
.detail-content .spec-table { width: 100%; border-collapse: collapse; border-radius: 8px; overflow: hidden; font-size: 14px; }
|
||||
.detail-content .spec-table th, .detail-content .spec-table td { padding: 12px 14px; text-align: left; border-bottom: 1px solid rgba(255,255,255,0.06); }
|
||||
.detail-content .spec-table th { background: rgba(var(--tone-r), var(--tone-g), var(--tone-b), 0.5); color: #8a7e72; font-weight: 500; width: 35%; font-size: 13px; }
|
||||
.detail-content .spec-table td { color: #e8e0d6; }
|
||||
.detail-content .spec-table tr:last-child th, .detail-content .spec-table tr:last-child td { border-bottom: none; }
|
||||
.detail-content .tag-row { display: flex; flex-wrap: wrap; gap: 8px; margin: 12px 0; }
|
||||
.detail-content .tag {
|
||||
padding: 6px 14px; border-radius: 20px; font-size: 12px; font-weight: 500;
|
||||
background: rgba(255,107,53,0.12); border: 1px solid rgba(255,107,53,0.2); color: #ff8c5a;
|
||||
}
|
||||
|
||||
@media (min-width: 500px) {
|
||||
body { display: flex; justify-content: center; }
|
||||
#listPage, #detailPage { max-width: 430px; left: 50% !important; transform: translateX(-50%); }
|
||||
#listPage { width: 430px; }
|
||||
#detailPage { width: 430px; }
|
||||
.bottom-nav { max-width: 430px; left: 50% !important; transform: translateX(-50%); }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<!-- ============================================================
|
||||
LIST PAGE
|
||||
============================================================ -->
|
||||
<div id="listPage">
|
||||
<div class="list-header">
|
||||
<h1>NICHE FRAGRANCE</h1>
|
||||
<div class="subtitle">小众沙龙香水集合店</div>
|
||||
</div>
|
||||
<div class="product-grid" id="productGrid"></div>
|
||||
<nav class="bottom-nav">
|
||||
<div class="nav-item active"><span class="nav-icon">🏠</span><span>首页</span></div>
|
||||
<div class="nav-item"><span class="nav-icon">🔍</span><span>发现</span></div>
|
||||
<div class="nav-item"><span class="nav-icon">🛒</span><span>购物车</span></div>
|
||||
<div class="nav-item"><span class="nav-icon">👤</span><span>我的</span></div>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<div id="flipOverlay"><img src="" alt=""></div>
|
||||
|
||||
<!-- ============================================================
|
||||
DETAIL PAGE
|
||||
============================================================ -->
|
||||
<div id="detailPage">
|
||||
<div class="dark-bg" id="darkBg">
|
||||
<img src="" alt="" class="dark-bg-img" id="darkBgImg">
|
||||
<div class="dark-bg-overlay"></div>
|
||||
</div>
|
||||
<button class="back-btn" id="backBtn" aria-label="返回">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="rgba(255,255,255,0.85)" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<polyline points="15 18 9 12 15 6"></polyline>
|
||||
</svg>
|
||||
</button>
|
||||
<div class="hero-spacer"></div>
|
||||
<div class="hero-image" id="heroImage">
|
||||
<div class="hero-img-wrap">
|
||||
<img src="" alt="" id="heroImg">
|
||||
</div>
|
||||
<div class="hero-info" id="heroInfo">
|
||||
<div class="hero-info-title" id="heroInfoTitle"></div>
|
||||
<div class="hero-info-subtitle" id="heroInfoSubtitle"></div>
|
||||
<div class="hero-info-price-row">
|
||||
<span class="hero-info-price" id="heroInfoPrice"></span>
|
||||
<span class="hero-info-original" id="heroInfoOriginal"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="transition-band" id="transitionBand">
|
||||
<div class="band-inner">
|
||||
<div class="band-title" id="bandTitle"></div>
|
||||
<div class="band-subtitle" id="bandSubtitle"></div>
|
||||
<div class="band-price-row">
|
||||
<span class="band-price" id="bandPrice"></span>
|
||||
<span class="band-price-original" id="bandOriginal"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="detail-content" id="detailContent"></div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function() {
|
||||
'use strict';
|
||||
|
||||
// ============================================================
|
||||
// Product Data — 6 salon perfumes
|
||||
// ============================================================
|
||||
const products = [
|
||||
{
|
||||
id: 0, image: 'images/perfume1.jpg',
|
||||
name: '琥珀之境 · 东方木质调沙龙香水',
|
||||
subtitle: '法国格拉斯调香师手作 · 72小时留香 · 限定典藏版',
|
||||
price: 1280, originalPrice: 1680,
|
||||
tone: [19, 11, 4],
|
||||
notes: {
|
||||
top: '佛手柑、粉红胡椒、藏红花。如晨曦穿透薄雾,明亮而略带辛辣的唤醒。',
|
||||
middle: '琥珀、乳香、鸢尾花。温暖树脂与花香交织,仿佛步入古老图书馆的午后。',
|
||||
base: '乌木、檀香、麝香。深沉木质尾韵,在肌肤上留下72小时的低语,越夜越迷人。'
|
||||
},
|
||||
design: '灵感源自摩洛哥马拉喀什的日落时分。当最后一缕阳光穿透琥珀色的城墙,空气里弥漫着乳香与异域香料的气息。瓶身采用手工吹制玻璃,每一只纹理都独一无二。',
|
||||
scene: '晚宴、商务洽谈、独处阅读。不建议在密闭小空间使用——它的气场需要呼吸的空间。',
|
||||
tags: ['晚宴', '商务', '独处', '中性香', '限定典藏'],
|
||||
specs: [
|
||||
['容量', '50ml / 100ml'],
|
||||
['浓度', 'Extrait de Parfum(香精级,25%精油浓度)'],
|
||||
['产地', '法国格拉斯'],
|
||||
['保质期', '开封后36个月'],
|
||||
['适用性别', '中性香']
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 1, image: 'images/perfume2.jpg',
|
||||
name: '暗夜玫瑰 · 哥特玫瑰乌木香水',
|
||||
subtitle: '暗黑浪漫主义 · 保加利亚玫瑰原精 · 哥特典藏',
|
||||
price: 980, originalPrice: 1280,
|
||||
tone: [34, 19, 26],
|
||||
notes: {
|
||||
top: '粉红胡椒、藏红花、苦艾酒。如同午夜钟声敲响时的一缕辛辣,瞬间攫取感官。',
|
||||
middle: '保加利亚玫瑰原精、鸢尾根、肉桂。暗色花瓣层层绽放,甜中带苦的哥特式浪漫。',
|
||||
base: '乌木、广藿香、安息香。烟熏木质基底如古堡石墙般冷峻,余韵绵长不绝。'
|
||||
},
|
||||
design: '灵感源自布拉格查理大桥下的午夜玫瑰园。哥特式拱门投下阴影,月光洒在深红色花瓣上。瓶身采用黑色玻璃与暗红珐琅镀层,触感如天鹅绒般细腻。',
|
||||
scene: '晚宴、约会、独自漫步夜色。适合秋冬夜晚,不建议烈日下使用——黑暗才是它的主场。',
|
||||
tags: ['晚宴', '约会', '秋冬', '女香偏中性'],
|
||||
specs: [
|
||||
['容量', '50ml / 100ml'],
|
||||
['浓度', 'Eau de Parfum(淡香精,20%精油浓度)'],
|
||||
['产地', '保加利亚 / 法国'],
|
||||
['保质期', '开封后36个月'],
|
||||
['适用性别', '女香偏中性']
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 2, image: 'images/perfume3.jpg',
|
||||
name: '白茶清欢 · 禅意白茶淡香水',
|
||||
subtitle: '东方茶道美学 · 福鼎白毫银针萃取 · 侘寂之香',
|
||||
price: 680, originalPrice: 880,
|
||||
tone: [40, 27, 22],
|
||||
notes: {
|
||||
top: '佛手柑、苦橙叶、白松香。清晨第一缕阳光穿透茶山薄雾,清冽微苦的绿色序章。',
|
||||
middle: '福鼎白毫银针萃取、茉莉、马黛茶。茶韵悠长,仿佛一壶刚沏好的白毫银针在案头袅袅生烟。',
|
||||
base: '白麝香、雪松、琥珀木。干净温润的收束,如同茶席散去后杯底残留的余温。'
|
||||
},
|
||||
design: '灵感源自京都龙安寺的枯山水庭院。极简、留白、时间的痕迹。瓶身采用磨砂白瓷质感,触手温润如玉,盖子为天然竹节切片。',
|
||||
scene: '独处阅读、茶道修习、日常通勤、瑜伽冥想。适合一年四季,不挑场合适配万物。',
|
||||
tags: ['日常', '茶道', '冥想', '中性香', '四季通用'],
|
||||
specs: [
|
||||
['容量', '30ml / 50ml'],
|
||||
['浓度', 'Eau de Toilette(淡香水,10%精油浓度)'],
|
||||
['产地', '中国福鼎 / 法国调制'],
|
||||
['保质期', '开封后24个月'],
|
||||
['适用性别', '中性香']
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 3, image: 'images/perfume4.jpg',
|
||||
name: '深海之息 · 海洋水生调香水',
|
||||
subtitle: '深海探索主题 · 海藻精华与龙涎香 · 极简蓝调',
|
||||
price: 1580, originalPrice: 1980,
|
||||
tone: [32, 38, 21],
|
||||
notes: {
|
||||
top: '佛手柑、海风醛、粉红胡椒。如同潜入海面瞬间溅起的咸涩水花,冷冽而清新。',
|
||||
middle: '海藻精华、薰衣草、天竺葵。深邃海洋的气息层层递进,仿佛置身海底峡谷的幽蓝世界。',
|
||||
base: '龙涎香、檀香木、苔藓。海底沉木般的温润尾韵,在皮肤上留下旷远的寂静。'
|
||||
},
|
||||
design: '灵感源自马里亚纳海沟的科学探索。压力、黑暗、未知之美。瓶身采用渐变深蓝玻璃,由浅至深模拟海洋深度,瓶盖为天然火山石纹理。',
|
||||
scene: '夏日通勤、海滨度假、运动后补香。不建议冬日密闭空间使用——它需要空气流动的空间。',
|
||||
tags: ['夏日', '海洋', '运动', '男香偏中性', '限量'],
|
||||
specs: [
|
||||
['容量', '50ml / 100ml'],
|
||||
['浓度', 'Extrait de Parfum(香精级,22%精油浓度)'],
|
||||
['产地', '法国布列塔尼'],
|
||||
['保质期', '开封后36个月'],
|
||||
['适用性别', '男香偏中性']
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 4, image: 'images/perfume5.jpg',
|
||||
name: '檀道 · 印度迈索尔檀香精粹',
|
||||
subtitle: '古老檀香之路 · 迈索尔产区珍稀檀木 · 修行者之香',
|
||||
price: 2180, originalPrice: 2680,
|
||||
tone: [45, 15, 18],
|
||||
notes: {
|
||||
top: '藏红花、小豆蔻、黑胡椒。如同打开一只古老的印度香料木匣,辛辣与温暖扑面而来。',
|
||||
middle: '迈索尔檀香精油、乳香、没药。寺庙深处千年檀木散发的神圣气息,静谧而深邃。',
|
||||
base: '乌木、琥珀、麝香。檀香在皮肤上持续低语八小时以上,越夜越醇厚。'
|
||||
},
|
||||
design: '灵感源自印度迈索尔老山檀产区千年古寺的香火传承。每一滴精油都来自树龄30年以上的檀木心材。瓶身采用暗红檀木色漆面,配手工雕刻木盖。',
|
||||
scene: '冥想修行、茶道品鉴、重要商务场合。这是一支需要静下心来品闻的香水——喧嚣不属于它。',
|
||||
tags: ['冥想', '商务', '修行', '中性香', '典藏级'],
|
||||
specs: [
|
||||
['容量', '30ml / 50ml'],
|
||||
['浓度', 'Extrait de Parfum(香精级,28%精油浓度)'],
|
||||
['产地', '印度迈索尔 / 法国调制'],
|
||||
['保质期', '开封后48个月'],
|
||||
['适用性别', '中性香']
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 5, image: 'images/perfume6.jpg',
|
||||
name: '雨后花园 · 绿意花香调淡香水',
|
||||
subtitle: '印象派花园美学 · 雨后湿润花瓣萃取 · 春日限定',
|
||||
price: 760, originalPrice: 960,
|
||||
tone: [31, 46, 49],
|
||||
notes: {
|
||||
top: '佛手柑、黑醋栗叶、薄荷。雨后第一缕阳光穿透叶片,水珠折射出七色光谱的清新。',
|
||||
middle: '铃兰、小苍兰、白玫瑰。湿漉漉的花瓣在微风中轻轻摇曳,仿佛走进莫奈笔下的吉维尼花园。',
|
||||
base: '白麝香、鸢尾根、苔藓。泥土与花瓣混合的湿润气息,宁静如雨后初晴的庭院角落。'
|
||||
},
|
||||
design: '灵感源自莫奈吉维尼花园的雨后清晨。光线、水珠、花瓣上的露水。瓶身采用磨砂浅绿色玻璃,如同被雨水冲刷过的叶片,瓶盖为黄铜材质模拟园艺工具。',
|
||||
scene: '春日踏青、花园漫步、周末早午餐。适合春夏季白天,不建议隆重场合——它属于轻松时光。',
|
||||
tags: ['春日', '花园', '清新', '女香', '限定'],
|
||||
specs: [
|
||||
['容量', '50ml / 100ml'],
|
||||
['浓度', 'Eau de Toilette(淡香水,12%精油浓度)'],
|
||||
['产地', '法国格拉斯'],
|
||||
['保质期', '开封后24个月'],
|
||||
['适用性别', '女香']
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
let activeProduct = products[0];
|
||||
|
||||
// ============================================================
|
||||
// DOM refs
|
||||
// ============================================================
|
||||
const listPage = document.getElementById('listPage');
|
||||
const detailPage = document.getElementById('detailPage');
|
||||
const productGrid = document.getElementById('productGrid');
|
||||
const flipOverlay = document.getElementById('flipOverlay');
|
||||
const flipImg = flipOverlay.querySelector('img');
|
||||
const heroImage = document.getElementById('heroImage');
|
||||
const heroImgEl = document.getElementById('heroImg');
|
||||
const heroInfo = document.getElementById('heroInfo');
|
||||
const transitionBand = document.getElementById('transitionBand');
|
||||
const detailContent = document.getElementById('detailContent');
|
||||
const backBtn = document.getElementById('backBtn');
|
||||
const darkBgImg = document.getElementById('darkBgImg');
|
||||
|
||||
let activeCard = null;
|
||||
let activeCardRect = null;
|
||||
let isDetailVisible = false;
|
||||
let ticking = false;
|
||||
let currentProgress = 0;
|
||||
|
||||
// ============================================================
|
||||
// Color extraction from product image
|
||||
// ============================================================
|
||||
function applyTone(product) {
|
||||
const [r, g, b] = product.tone;
|
||||
const root = document.documentElement.style;
|
||||
root.setProperty('--tone-r', r);
|
||||
root.setProperty('--tone-g', g);
|
||||
root.setProperty('--tone-b', b);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Render product cards
|
||||
// ============================================================
|
||||
function renderCards() {
|
||||
productGrid.innerHTML = products.map((p, i) => `
|
||||
<div class="product-card" data-index="${i}" style="background:rgba(${p.tone.join(',')},0.9)">
|
||||
<div class="card-img-wrap">
|
||||
<img src="${p.image}" alt="${p.name}">
|
||||
</div>
|
||||
<div class="card-info">
|
||||
<div class="card-name">${p.name}</div>
|
||||
<div class="card-price">¥${p.price.toLocaleString()}</div>
|
||||
</div>
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Render detail page content
|
||||
// ============================================================
|
||||
function renderDetailContent(product) {
|
||||
return `
|
||||
<section>
|
||||
<h3>香调层次</h3>
|
||||
<p class="note-item"><strong>前调</strong> — ${product.notes.top}</p>
|
||||
<p class="note-item"><strong>中调</strong> — ${product.notes.middle}</p>
|
||||
<p class="note-item"><strong>后调</strong> — ${product.notes.base}</p>
|
||||
</section>
|
||||
<div class="divider"></div>
|
||||
<section>
|
||||
<h3>设计理念</h3>
|
||||
<p>${product.design}</p>
|
||||
</section>
|
||||
<div class="divider"></div>
|
||||
<section>
|
||||
<h3>使用场景</h3>
|
||||
<p>${product.scene}</p>
|
||||
<div class="tag-row">${product.tags.map(t => `<span class="tag">${t}</span>`).join('')}</div>
|
||||
</section>
|
||||
<div class="divider"></div>
|
||||
<section>
|
||||
<h3>规格参数</h3>
|
||||
<table class="spec-table">
|
||||
${product.specs.map(s => `<tr><th>${s[0]}</th><td>${s[1]}</td></tr>`).join('')}
|
||||
</table>
|
||||
</section>
|
||||
<div class="bottom-spacer" style="height:100vh;min-height:600px;"></div>
|
||||
`;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Populate detail page with product data
|
||||
// ============================================================
|
||||
function populateDetail(product) {
|
||||
activeProduct = product;
|
||||
applyTone(product);
|
||||
|
||||
// Hero
|
||||
heroImgEl.src = product.image;
|
||||
darkBgImg.src = product.image;
|
||||
document.getElementById('heroInfoTitle').textContent = product.name;
|
||||
document.getElementById('heroInfoSubtitle').textContent = product.subtitle;
|
||||
document.getElementById('heroInfoPrice').textContent = '¥' + product.price.toLocaleString();
|
||||
document.getElementById('heroInfoOriginal').textContent = '¥' + product.originalPrice.toLocaleString();
|
||||
|
||||
// Transition band
|
||||
document.getElementById('bandTitle').textContent = product.name;
|
||||
document.getElementById('bandSubtitle').textContent = product.subtitle;
|
||||
document.getElementById('bandPrice').textContent = '¥' + product.price.toLocaleString();
|
||||
document.getElementById('bandOriginal').textContent = '¥' + product.originalPrice.toLocaleString();
|
||||
|
||||
// Detail content
|
||||
detailContent.innerHTML = renderDetailContent(product);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Utility
|
||||
// ============================================================
|
||||
function setVH() {
|
||||
document.documentElement.style.setProperty('--vh', (window.innerHeight * 0.01) + 'px');
|
||||
}
|
||||
setVH();
|
||||
window.addEventListener('resize', setVH);
|
||||
window.addEventListener('orientationchange', () => setTimeout(setVH, 100));
|
||||
|
||||
function easeOut(t) { return 1 - Math.pow(1 - t, 3); }
|
||||
function clamp(v, min, max) { return Math.min(Math.max(v, min), max); }
|
||||
function lerp(a, b, t) { return a + (b - a) * t; }
|
||||
|
||||
// ============================================================
|
||||
// Three-stage hero animation
|
||||
// ============================================================
|
||||
function updateHeroFromProgress(progress) {
|
||||
const p = clamp(progress, 0, 1);
|
||||
|
||||
let topCrop, bottomCrop;
|
||||
if (p <= 0.4) {
|
||||
const t = easeOut(p / 0.4);
|
||||
topCrop = lerp(0, 32, t);
|
||||
bottomCrop = lerp(0, 38, t);
|
||||
} else if (p <= 0.55) {
|
||||
const t = (p - 0.4) / 0.15;
|
||||
topCrop = lerp(32, 34, t);
|
||||
bottomCrop = lerp(38, 41, t);
|
||||
} else {
|
||||
topCrop = 34; bottomCrop = 41;
|
||||
}
|
||||
|
||||
let translateY;
|
||||
if (p <= 0.4) {
|
||||
translateY = 0;
|
||||
} else if (p <= 0.7) {
|
||||
translateY = lerp(0, -75, (p - 0.4) / 0.3);
|
||||
} else {
|
||||
translateY = -75;
|
||||
}
|
||||
|
||||
let bandOpacity, bandBlur, bandHeight;
|
||||
if (p <= 0.4) {
|
||||
const t = easeOut(p / 0.4);
|
||||
bandOpacity = t;
|
||||
bandBlur = lerp(0, 20, t);
|
||||
bandHeight = lerp(0, 140, t);
|
||||
} else {
|
||||
bandOpacity = 1; bandBlur = 20; bandHeight = 140;
|
||||
}
|
||||
|
||||
let contentOpacity, contentTranslateY;
|
||||
if (p <= 0.05) {
|
||||
contentOpacity = 0; contentTranslateY = 60;
|
||||
} else if (p <= 0.22) {
|
||||
const t = (p - 0.05) / 0.17;
|
||||
contentOpacity = t;
|
||||
contentTranslateY = lerp(60, 0, easeOut(t));
|
||||
} else {
|
||||
contentOpacity = 1; contentTranslateY = 0;
|
||||
}
|
||||
|
||||
const visibleHeight = 100 - topCrop - bottomCrop;
|
||||
heroImage.style.height = `calc(var(--vh, 1vh) * ${visibleHeight})`;
|
||||
heroImgEl.style.top = `-${topCrop}vh`;
|
||||
heroImage.style.transform = `translateY(${translateY}vh)`;
|
||||
heroInfo.style.setProperty('--blur-opacity', bandOpacity);
|
||||
|
||||
let bandShowOpacity;
|
||||
if (p <= 0.55) { bandShowOpacity = 0; }
|
||||
else if (p <= 0.7) { bandShowOpacity = (p - 0.55) / 0.15; }
|
||||
else { bandShowOpacity = 1; }
|
||||
transitionBand.style.opacity = bandShowOpacity;
|
||||
transitionBand.style.height = bandHeight + 'px';
|
||||
transitionBand.style.boxShadow = bandShowOpacity > 0.5 ? '0 4px 20px rgba(0,0,0,0.08)' : 'none';
|
||||
|
||||
detailContent.style.opacity = contentOpacity;
|
||||
detailContent.style.transform = `translateY(${contentTranslateY}px)`;
|
||||
|
||||
backBtn.style.opacity = p > 0.15 ? 1 : clamp(1 - p / 0.15, 0, 1);
|
||||
transitionBand.style.overflow = bandHeight > 100 ? 'visible' : 'hidden';
|
||||
transitionBand.style.pointerEvents = bandShowOpacity < 0.3 ? 'none' : 'auto';
|
||||
document.documentElement.style.setProperty('--scroll-progress', p);
|
||||
currentProgress = p;
|
||||
}
|
||||
|
||||
function onDetailScroll() {
|
||||
if (!isDetailVisible) return;
|
||||
if (!ticking) {
|
||||
requestAnimationFrame(function() {
|
||||
const scrollHeight = document.documentElement.scrollHeight - window.innerHeight;
|
||||
if (scrollHeight <= 1) { updateHeroFromProgress(0); ticking = false; return; }
|
||||
updateHeroFromProgress(clamp(window.scrollY / scrollHeight, 0, 1));
|
||||
ticking = false;
|
||||
});
|
||||
ticking = true;
|
||||
}
|
||||
}
|
||||
window.addEventListener('scroll', onDetailScroll, { passive: true });
|
||||
|
||||
// ============================================================
|
||||
// FLIP Animation: List → Detail
|
||||
// ============================================================
|
||||
function openDetail(cardEl) {
|
||||
if (isDetailVisible) return;
|
||||
const idx = parseInt(cardEl.dataset.index);
|
||||
const product = products[idx];
|
||||
|
||||
activeCard = cardEl;
|
||||
const cardImg = cardEl.querySelector('.card-img-wrap');
|
||||
activeCardRect = cardImg.getBoundingClientRect();
|
||||
|
||||
populateDetail(product);
|
||||
|
||||
flipImg.src = product.image;
|
||||
flipOverlay.style.top = activeCardRect.top + 'px';
|
||||
flipOverlay.style.left = activeCardRect.left + 'px';
|
||||
flipOverlay.style.width = activeCardRect.width + 'px';
|
||||
flipOverlay.style.height = activeCardRect.height + 'px';
|
||||
flipOverlay.style.borderRadius = '12px';
|
||||
flipOverlay.style.transform = 'scale(1)';
|
||||
flipOverlay.style.opacity = '1';
|
||||
flipOverlay.style.transition = 'none';
|
||||
flipOverlay.style.display = 'block';
|
||||
flipOverlay.style.zIndex = '200';
|
||||
flipOverlay.offsetHeight;
|
||||
|
||||
const allCards = productGrid.querySelectorAll('.product-card');
|
||||
allCards.forEach(c => { if (c !== cardEl) { c.style.transition = 'opacity 0.3s ease'; c.style.opacity = '0.3'; } });
|
||||
cardEl.style.opacity = '0';
|
||||
cardEl.style.transition = 'opacity 0.3s ease';
|
||||
|
||||
detailPage.classList.add('active');
|
||||
detailPage.style.display = 'block';
|
||||
window.scrollTo(0, 0);
|
||||
updateHeroFromProgress(0);
|
||||
|
||||
requestAnimationFrame(function() {
|
||||
flipOverlay.style.transition =
|
||||
'top 0.45s cubic-bezier(0.25, 0.1, 0.25, 1), left 0.45s cubic-bezier(0.25, 0.1, 0.25, 1), width 0.45s cubic-bezier(0.25, 0.1, 0.25, 1), height 0.45s cubic-bezier(0.25, 0.1, 0.25, 1), border-radius 0.45s cubic-bezier(0.25, 0.1, 0.25, 1)';
|
||||
flipOverlay.style.top = '0px'; flipOverlay.style.left = '0px';
|
||||
flipOverlay.style.width = '100vw';
|
||||
flipOverlay.style.height = 'calc(var(--vh, 1vh) * 100)';
|
||||
flipOverlay.style.borderRadius = '0px';
|
||||
});
|
||||
|
||||
setTimeout(function() {
|
||||
flipOverlay.style.display = 'none';
|
||||
listPage.style.display = 'none';
|
||||
detailPage.classList.add('active');
|
||||
isDetailVisible = true;
|
||||
document.body.style.overflow = 'auto';
|
||||
window.scrollTo(0, 0);
|
||||
updateHeroFromProgress(0);
|
||||
allCards.forEach(c => { c.style.opacity = ''; c.style.transition = ''; });
|
||||
}, 480);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// FLIP Animation: Detail → List
|
||||
// ============================================================
|
||||
function closeDetail() {
|
||||
if (!isDetailVisible) return;
|
||||
window.scrollTo({ top: 0, behavior: 'instant' });
|
||||
updateHeroFromProgress(0);
|
||||
|
||||
flipImg.src = activeProduct.image;
|
||||
flipOverlay.style.display = 'block';
|
||||
flipOverlay.style.transition = 'none';
|
||||
flipOverlay.style.top = '0px'; flipOverlay.style.left = '0px';
|
||||
flipOverlay.style.width = '100vw';
|
||||
flipOverlay.style.height = 'calc(var(--vh, 1vh) * 100)';
|
||||
flipOverlay.style.borderRadius = '0px';
|
||||
flipOverlay.style.transform = 'scale(1)';
|
||||
flipOverlay.style.opacity = '1';
|
||||
flipOverlay.style.zIndex = '200';
|
||||
|
||||
detailPage.classList.remove('active');
|
||||
detailPage.style.display = 'none';
|
||||
document.body.style.overflow = 'hidden';
|
||||
isDetailVisible = false;
|
||||
|
||||
listPage.style.display = '';
|
||||
var allCards = productGrid.querySelectorAll('.product-card');
|
||||
allCards.forEach(c => { if (c !== activeCard) { c.style.opacity = '0.3'; c.style.transition = 'opacity 0.35s ease'; } });
|
||||
if (activeCard) { activeCard.style.opacity = '0'; activeCard.style.transition = 'none'; }
|
||||
if (activeCard) { activeCardRect = activeCard.querySelector('.card-img-wrap').getBoundingClientRect(); }
|
||||
|
||||
flipOverlay.offsetHeight;
|
||||
|
||||
requestAnimationFrame(function() {
|
||||
var target = activeCardRect || { top: 100, left: 50, width: 150, height: 200 };
|
||||
flipOverlay.style.transition =
|
||||
'top 0.4s cubic-bezier(0.25, 0.1, 0.25, 1), left 0.4s cubic-bezier(0.25, 0.1, 0.25, 1), width 0.4s cubic-bezier(0.25, 0.1, 0.25, 1), height 0.4s cubic-bezier(0.25, 0.1, 0.25, 1), border-radius 0.4s cubic-bezier(0.25, 0.1, 0.25, 1)';
|
||||
flipOverlay.style.top = target.top + 'px'; flipOverlay.style.left = target.left + 'px';
|
||||
flipOverlay.style.width = target.width + 'px'; flipOverlay.style.height = target.height + 'px';
|
||||
flipOverlay.style.borderRadius = '12px';
|
||||
});
|
||||
|
||||
setTimeout(function() {
|
||||
flipOverlay.style.display = 'none';
|
||||
document.body.style.overflow = 'hidden';
|
||||
if (activeCard) { activeCard.style.opacity = ''; activeCard.style.transition = ''; }
|
||||
allCards.forEach(c => { c.style.opacity = ''; c.style.transition = ''; });
|
||||
window.scrollTo(0, 0);
|
||||
updateHeroFromProgress(0);
|
||||
}, 430);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Event listeners
|
||||
// ============================================================
|
||||
productGrid.addEventListener('click', function(e) {
|
||||
var card = e.target.closest('.product-card');
|
||||
if (!card || isDetailVisible) return;
|
||||
openDetail(card);
|
||||
});
|
||||
|
||||
backBtn.addEventListener('click', closeDetail);
|
||||
|
||||
document.addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Escape' && isDetailVisible) closeDetail();
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// Init
|
||||
// ============================================================
|
||||
renderCards();
|
||||
populateDetail(products[0]);
|
||||
document.body.style.overflow = 'hidden';
|
||||
flipOverlay.style.display = 'none';
|
||||
updateHeroFromProgress(0);
|
||||
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user