添加登录和后端校验

完成后端设计(未在本仓库体现),通过安全的手段完成了登录鉴权
This commit is contained in:
2026-08-06 16:27:36 +08:00
commit b19a56003f
286 changed files with 51650 additions and 0 deletions
+5
View File
@@ -0,0 +1,5 @@
export default definePageConfig({
navigationStyle: 'custom',
// 顶部为深色商品大图,状态栏文字设为白色,确保 iOS 时间/电量可见
navigationBarTextStyle: 'white'
})
+345
View File
@@ -0,0 +1,345 @@
/* ============================================================
Shop Detail Page — 商品详情页
结构:沉浸式详情页(固定Hero + ScrollView内容 + CTA
============================================================ */
.shop-detail-page {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgb(28, 22, 18);
}
/* --- Fixed Hero Area --- */
.shop-hero-fixed {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 65vh; /* hero takes 65% viewport height */
z-index: 0;
overflow: hidden;
}
.shop-hero-clip {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
will-change: height, transform;
}
.shop-hero-img {
width: 100%;
height: 100%;
object-fit: cover;
will-change: top;
}
/* Bottom gradient mask: pseudo-mask approach (better x-compatibility) */
.shop-hero-overlay {
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 55%;
pointer-events: none;
}
/* Hero info (title, price) anchored at bottom */
.shop-hero-info {
position: absolute;
bottom: 0;
left: 0;
right: 0;
z-index: 2;
padding: 80rpx 40rpx 24rpx;
display: flex;
flex-direction: column;
gap: 8rpx;
}
.shop-hero-title {
font-size: 40rpx;
font-weight: 700;
line-height: 1.3;
color: #f0e6d8;
}
.shop-hero-subtitle {
font-size: 22rpx;
color: #b0a395;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.shop-hero-price-row {
display: flex;
align-items: baseline;
gap: 16rpx;
margin-top: 6rpx;
}
.shop-hero-price {
font-size: 44rpx;
font-weight: 700;
color: #ff9a9e;
}
.shop-hero-original {
font-size: 26rpx;
color: #8a7e72;
text-decoration: line-through;
}
/* --- ScrollView Content Layer --- */
.shop-scroll {
position: relative;
z-index: 3;
}
.shop-content {
min-height: 100vh; /* ensure scrollability */
will-change: transform;
}
/* --- Transition Band (sticky-like top info) --- */
.shop-band {
position: -webkit-sticky;
position: sticky;
top: 0;
z-index: 4;
padding: 40rpx 40rpx 32rpx;
display: flex;
flex-direction: column;
gap: 10rpx;
transition: opacity 0.15s ease;
will-change: opacity;
}
.shop-band-title {
font-size: 44rpx;
font-weight: 700;
line-height: 1.3;
color: #f0e6d8;
}
.shop-band-subtitle {
font-size: 24rpx;
color: #b0a395;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.shop-band-price-row {
display: flex;
align-items: baseline;
gap: 16rpx;
margin-top: 6rpx;
}
.shop-band-price {
font-size: 48rpx;
font-weight: 700;
color: #ff9a9e;
}
.shop-band-original {
font-size: 26rpx;
color: #8a7e72;
text-decoration: line-through;
}
/* --- Detail Body --- */
.shop-detail-body {
padding: 32rpx 40rpx 60rpx;
will-change: opacity, transform;
}
.shop-section {
margin-bottom: 32rpx;
}
.shop-section-title {
font-size: 34rpx;
font-weight: 700;
color: #f0e6d8;
margin: 28rpx 0 20rpx;
display: block;
position: relative;
padding-left: 20rpx;
}
.shop-section-title::before {
content: '';
position: absolute;
left: 0;
top: 6rpx;
bottom: 6rpx;
width: 6rpx;
border-radius: 4rpx;
background: linear-gradient(180deg, #ff9a9e, #fecfef);
}
.shop-section-text {
font-size: 28rpx;
color: #c4b8a8;
line-height: 1.75;
display: block;
}
.shop-divider {
height: 2rpx;
background: linear-gradient(90deg, transparent, rgba(255,255,255,0.08), transparent);
margin: 40rpx 0;
}
/* --- Tags --- */
.shop-tag-row {
display: flex;
flex-wrap: wrap;
gap: 16rpx;
margin: 20rpx 0;
}
.shop-tag {
padding: 10rpx 28rpx;
border-radius: 40rpx;
font-size: 22rpx;
font-weight: 500;
background: rgba(255, 154, 158, 0.12);
border: 1rpx solid rgba(255, 154, 158, 0.2);
display: inline-flex;
align-items: center;
justify-content: center;
}
.shop-tag-text {
color: #ff9a9e;
}
/* --- Spec Table --- */
.shop-spec-table {
width: 100%;
border-collapse: collapse;
border-radius: 16rpx;
overflow: hidden;
font-size: 26rpx;
}
.shop-spec-row {
display: flex;
padding: 20rpx 24rpx;
border-bottom: 1rpx solid rgba(255,255,255,0.06);
}
.shop-spec-row:last-child {
border-bottom: none;
}
.shop-spec-key {
width: 35%;
color: #8a7e72;
font-weight: 500;
font-size: 24rpx;
}
.shop-spec-val {
flex: 1;
color: #e8e0d6;
word-break: break-all;
}
.shop-bottom-spacer {
height: 200rpx;
}
/* --- Back Button --- */
.shop-back-btn {
position: fixed;
top: calc(env(safe-area-inset-top, 20px) + 20rpx);
left: 24rpx;
z-index: 50;
width: 80rpx;
height: 80rpx;
border-radius: 50%;
background: rgba(0, 0, 0, 0.35);
backdrop-filter: blur(16rpx) saturate(1.4);
-webkit-backdrop-filter: blur(16rpx) saturate(1.4);
border: 1rpx solid rgba(255,255,255,0.12);
box-shadow: 0 2rpx 16rpx rgba(0,0,0,0.25);
display: flex;
align-items: center;
justify-content: center;
transition: opacity 0.2s ease, transform 0.15s ease;
}
.shop-back-btn:active {
transform: scale(0.92);
}
.shop-back-arrow {
font-size: 36rpx;
color: rgba(255,255,255,0.85);
line-height: 1;
}
/* --- Bottom CTA Bar --- */
.shop-cta-bar {
position: fixed;
bottom: 0;
left: 0;
right: 0;
z-index: 60;
padding: 20rpx 32rpx calc(20rpx + env(safe-area-inset-bottom, 0px));
display: flex;
align-items: center;
justify-content: space-between;
gap: 24rpx;
backdrop-filter: blur(16rpx) saturate(1.2);
-webkit-backdrop-filter: blur(16rpx) saturate(1.2);
transition: background-color 0.4s ease;
}
.shop-cta-price-col {
display: flex;
align-items: baseline;
gap: 12rpx;
}
.shop-cta-price {
font-size: 40rpx;
font-weight: 700;
color: #ff9a9e;
}
.shop-cta-original {
font-size: 24rpx;
color: #8a7e72;
text-decoration: line-through;
}
.shop-cta-btn {
flex: 1;
max-width: 260rpx;
background: linear-gradient(135deg, #ff9a9e 0%, #fecfef 99%);
color: #5c3a3a;
border-radius: 50rpx;
padding: 24rpx 48rpx;
font-size: 30rpx;
font-weight: 600;
text-align: center;
display: flex;
align-items: center;
justify-content: center;
}
.shop-cta-btn-text {
color: inherit;
font-weight: 600;
}
+287
View File
@@ -0,0 +1,287 @@
import { View, Text, Image, ScrollView } from '@tarojs/components'
import Taro from '@tarojs/taro'
import { useState, useEffect, useCallback, useMemo } from 'react'
import './index.scss'
import { PRODUCTS } from '../../../utils/productConfig'
import { ProductCategory } from '../../../types'
import { useSafeArea } from '../../../hooks/useSafeArea'
import { useStatusBar } from '../../../hooks/useStatusBar'
import { useThemeContext } from '../../../context/ThemeContext'
const clamp = (v: number, min: number, max: number) => Math.min(Math.max(v, min), max)
const lerp = (a: number, b: number, t: number) => a + (b - a) * t
const easeOut = (t: number) => 1 - Math.pow(1 - t, 3)
export default function ShopDetailPage() {
const { resolvedTheme } = useThemeContext()
const [winHeight, setWinHeight] = useState(667)
const [progress, setProgress] = useState(0)
const safe = useSafeArea()
// 顶部为沉浸式商品图,状态栏始终使用白字。
useStatusBar(resolvedTheme, { mode: 'light' })
const routerParams = Taro.getCurrentInstance().router?.params
const productId = routerParams?.id || ''
const product: ProductCategory | undefined = PRODUCTS.find(p => p.id === productId)
useEffect(() => {
try {
const info = Taro.getSystemInfoSync()
setWinHeight(info.windowHeight)
} catch (e) {
// fallback to safe default
}
}, [])
const handleScroll = useCallback((e: any) => {
const st = e.detail?.scrollTop ?? 0
const total = winHeight * 1.2
const p = clamp(st / total, 0, 1)
setProgress(p)
}, [winHeight])
/* Scroll-progress → multi-stage hero & content animations */
const heroStyles = useMemo(() => {
const p = progress
// 1. Image crop insets (vh %)
let topCrop = 0, bottomCrop = 0
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
}
// 2. Hero translateY
let translateY = 0
if (p <= 0.4) translateY = 0
else if (p <= 0.7) translateY = lerp(0, -75, (p - 0.4) / 0.3)
else translateY = -75
// 3. Hero bottom info opacity (always visible on open, fades after scroll)
let heroInfoOpacity = 1
if (p <= 0.35) heroInfoOpacity = 1
else if (p <= 0.48) heroInfoOpacity = clamp(1 - (p - 0.35) / 0.13, 0, 1)
else heroInfoOpacity = 0
// 4. Transition band opacity
let bandOpacity = 0
if (p <= 0.55) bandOpacity = 0
else if (p <= 0.7) bandOpacity = (p - 0.55) / 0.15
else bandOpacity = 1
// 5. Content reveal
let contentOpacity = 1, contentOffset = 0
if (p <= 0.05) { contentOpacity = 0; contentOffset = 60 }
else if (p <= 0.22) {
const t = (p - 0.05) / 0.17
contentOpacity = easeOut(t)
contentOffset = lerp(60, 0, easeOut(t))
}
// 6. Back button (fade out as hero scrolls)
let backBtnOpacity = 1
if (p <= 0.15) backBtnOpacity = clamp(1 - p / 0.15, 0, 1)
return {
topCrop,
bottomCrop,
translateY,
heroInfoOpacity,
bandOpacity,
contentOpacity,
contentOffset,
backBtnOpacity,
visibleHeight: 100 - topCrop - bottomCrop
}
}, [progress])
const tone = useMemo(() => product?.tone || [28, 22, 18], [product])
if (!product) {
return (
<View className="shop-detail-page">
<Text style={{ color: '#fff', padding: '40rpx' }}></Text>
</View>
)
}
return (
<View className="shop-detail-page">
{/* Fixed Hero */}
<View className="shop-hero-fixed">
<View
className="shop-hero-clip"
style={{
height: `${winHeight * heroStyles.visibleHeight / 100}px`,
overflow: 'hidden',
transform: `translateY(${winHeight * heroStyles.translateY / 100}px)`
}}
>
<Image
className="shop-hero-img"
src={product.images?.[0] || product.iconImg || ''}
mode="aspectFill"
style={{
width: '100%',
height: `${winHeight}px`,
position: 'absolute',
top: `${-winHeight * heroStyles.topCrop / 100}px`,
left: 0
}}
/>
</View>
{/* Bottom gradient overlay driven by product tone */}
<View
className="shop-hero-overlay"
style={{
background: `linear-gradient(to bottom, rgba(${tone[0]}, ${tone[1]}, ${tone[2]}, 0) 0%, rgba(${tone[0]}, ${tone[1]}, ${tone[2]}, 0.55) 40%, rgba(${tone[0]}, ${tone[1]}, ${tone[2]}, 0.95) 100%)`
}}
/>
<View
className="shop-hero-info"
style={{ opacity: heroStyles.heroInfoOpacity }}
>
<Text className="shop-hero-title">{product.name}</Text>
<Text className="shop-hero-subtitle">{product.subtitle || product.desc}</Text>
<View className="shop-hero-price-row">
<Text className="shop-hero-price">¥{product.price}</Text>
{product.originalPrice && product.originalPrice > product.price && (
<Text className="shop-hero-original">¥{product.originalPrice}</Text>
)}
</View>
</View>
</View>
{/* Scrollable content sits above the fixed hero */}
<ScrollView
key={product.id}
className="shop-scroll"
scrollY
scrollEventThrottle={16}
style={{ height: `${winHeight}px` }}
onScroll={handleScroll}
>
{/* Transparent spacer pushes content below hero */}
<View style={{ height: `${winHeight * 0.65}px` }} />
<View
className="shop-content"
style={{ backgroundColor: `rgb(${tone[0]}, ${tone[1]}, ${tone[2]})` }}
>
{/* Transition band — appears as hero scrolls away */}
<View
className="shop-band"
style={{
top: `${safe.statusBarHeight}px`,
opacity: heroStyles.bandOpacity,
backgroundColor: `rgba(${tone[0]}, ${tone[1]}, ${tone[2]}, 0.88)`,
backdropFilter: 'blur(12px)',
WebkitBackdropFilter: 'blur(12px)'
}}
>
<Text className="shop-band-title">{product.name}</Text>
<Text className="shop-band-subtitle">{product.subtitle || product.desc}</Text>
<View className="shop-band-price-row">
<Text className="shop-band-price">¥{product.price}</Text>
{product.originalPrice && product.originalPrice > product.price && (
<Text className="shop-band-original">¥{product.originalPrice}</Text>
)}
</View>
</View>
{/* Main copy blocks */}
<View
className="shop-detail-body"
style={{
opacity: heroStyles.contentOpacity,
transform: `translateY(${heroStyles.contentOffset}px)`
}}
>
{product.story && (
<View className="shop-section">
<Text className="shop-section-title"></Text>
<Text className="shop-section-text">{product.story}</Text>
</View>
)}
<View className="shop-divider" />
{product.scene && (
<View className="shop-section">
<Text className="shop-section-title">使</Text>
<Text className="shop-section-text">{product.scene}</Text>
</View>
)}
{product.tags && product.tags.length > 0 && (
<View className="shop-section">
<View className="shop-tag-row">
{product.tags.map(tag => (
<View key={tag} className="shop-tag">
<Text className="shop-tag-text">{tag}</Text>
</View>
))}
</View>
</View>
)}
<View className="shop-divider" />
{product.specs && product.specs.length > 0 && (
<View className="shop-section">
<Text className="shop-section-title"></Text>
<View className="shop-spec-table">
{product.specs.map(([k, v]) => (
<View key={k} className="shop-spec-row">
<Text className="shop-spec-key">{k}</Text>
<Text className="shop-spec-val">{v}</Text>
</View>
))}
</View>
</View>
)}
<View className="shop-bottom-spacer" />
</View>
</View>
</ScrollView>
{/* Back button — glass morphism circle */}
<View
className="shop-back-btn"
onTap={() => Taro.navigateBack()}
style={{ top: `${safe.backBtnPaddingTop}px`, opacity: heroStyles.backBtnOpacity }}
>
<Text className="shop-back-arrow"></Text>
</View>
{/* Bottom CTA — fixed, glass, links to existing product page */}
<View
className="shop-cta-bar"
style={{ backgroundColor: `rgba(${tone[0]}, ${tone[1]}, ${tone[2]}, 0.92)` }}
>
<View className="shop-cta-price-col">
<Text className="shop-cta-price">¥{product.price}</Text>
{product.originalPrice && product.originalPrice > product.price && (
<Text className="shop-cta-original">¥{product.originalPrice}</Text>
)}
</View>
<View
className="shop-cta-btn"
onTap={() => Taro.navigateTo({ url: `/pages/product/index?id=${product.id}` })}
>
<Text className="shop-cta-btn-text"></Text>
</View>
</View>
</View>
)
}
+3
View File
@@ -0,0 +1,3 @@
export default definePageConfig({
navigationStyle: 'custom'
})
+3
View File
@@ -0,0 +1,3 @@
{
"usingComponents": {}
}
+93
View File
@@ -0,0 +1,93 @@
/* ============================================================
Shop Page — 商品浏览页(列表页)
============================================================ */
/* --- List Layer --- */
.shop-list {
padding: 0 24rpx 24rpx;
}
.shop-header {
text-align: center;
padding: 40rpx 0 24rpx;
}
.shop-header-title {
font-size: 40rpx;
font-weight: 800;
color: var(--text-primary);
letter-spacing: 4rpx;
display: block;
}
.shop-header-sub {
font-size: 24rpx;
color: var(--text-secondary);
margin-top: 8rpx;
display: block;
}
/* --- Product Grid (2-column) --- */
.shop-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 20rpx;
}
.shop-card {
border-radius: 24rpx;
overflow: hidden;
background: var(--bg-card);
box-shadow: var(--shadow-card);
transition: transform 0.15s ease;
}
.shop-card:active {
transform: scale(0.97);
}
.shop-card-img-wrap {
aspect-ratio: 3 / 4;
overflow: hidden;
background: #2a2218;
}
.shop-card-img {
width: 100%;
height: 100%;
object-fit: cover;
transition: transform 0.4s ease;
}
.shop-card-info {
padding: 16rpx 20rpx 20rpx;
}
.shop-card-name {
font-size: 26rpx;
color: #e8e0d6;
line-height: 1.4;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
min-height: 74rpx;
}
.shop-card-price {
font-size: 30rpx;
font-weight: 700;
color: #ff9a9e;
margin-top: 10rpx;
display: block;
}
/* ============================================================
Responsive: container center on wide screens
============================================================ */
@media (min-width: 750rpx) {
.shop-page {
display: flex;
justify-content: center;
}
}
+59
View File
@@ -0,0 +1,59 @@
import { View, Text, Image } from '@tarojs/components'
import Taro from '@tarojs/taro'
import { useThemeContext } from '../../context/ThemeContext'
import { useSafeArea } from '../../hooks/useSafeArea'
import { useStatusBar } from '../../hooks/useStatusBar'
import './index.scss'
import { PRODUCTS } from '../../utils/productConfig'
export default function ShopPage() {
const { resolvedTheme } = useThemeContext()
const safe = useSafeArea()
useStatusBar(resolvedTheme)
const openDetail = (productId: string) => {
Taro.navigateTo({ url: `/pages/shop/detail/index?id=${productId}` })
}
return (
<View className={`shop-page theme-${resolvedTheme}`}>
<View className="shop-list">
<View className="shop-header" style={{ paddingTop: `${safe.headerPaddingTop}px` }}>
<Text className="shop-header-title"></Text>
<Text className="shop-header-sub"></Text>
</View>
<View className="shop-grid">
{PRODUCTS.map(product => (
<View
key={product.id}
className="shop-card"
onTap={() => openDetail(product.id)}
>
<View className="shop-card-img-wrap">
<Image
className="shop-card-img"
src={product.images?.[0] || product.iconImg || ''}
mode="aspectFill"
lazyLoad
/>
</View>
<View
className="shop-card-info"
style={{
backgroundColor: `rgba(${product.tone?.[0] || 28}, ${product.tone?.[1] || 22}, ${product.tone?.[2] || 18}, 0.92)`
}}
>
<Text className="shop-card-name">{product.name}</Text>
<Text className="shop-card-price">¥{product.price}</Text>
</View>
</View>
))}
</View>
{/* TabBar spacer so list isn't obscured */}
<View style={{ height: '160px' }} />
</View>
</View>
)
}