添加登录和后端校验
完成后端设计(未在本仓库体现),通过安全的手段完成了登录鉴权
@@ -0,0 +1,37 @@
|
||||
export default defineAppConfig({
|
||||
pages: [
|
||||
'pages/index/index',
|
||||
'pages/shop/index',
|
||||
'pages/shop/detail/index',
|
||||
'pages/product/index',
|
||||
'pages/diy/index',
|
||||
'pages/diy/stickerEdit/index',
|
||||
'pages/checkout/index',
|
||||
'pages/designList/index',
|
||||
'pages/orders/index',
|
||||
'pages/profile/index',
|
||||
'pages/service/index',
|
||||
'pages/wordcloud/index',
|
||||
'pages/orderDetail/index',
|
||||
'pages/address/index',
|
||||
'pages/settings/index',
|
||||
'pages/agreement/index',
|
||||
'pages/userDatabase/index'
|
||||
],
|
||||
window: {
|
||||
backgroundTextStyle: 'dark',
|
||||
navigationStyle: 'custom',
|
||||
// 应用启动时先保证浅色页面的 iOS 状态栏为黑字;页面 Hook 会在主题变化时覆盖它。
|
||||
navigationBarTextStyle: 'black',
|
||||
},
|
||||
tabBar: {
|
||||
custom: true,
|
||||
list: [
|
||||
{ pagePath: 'pages/index/index', text: '首页' },
|
||||
{ pagePath: 'pages/shop/index', text: '商品' },
|
||||
{ pagePath: 'pages/designList/index', text: '设计清单' },
|
||||
{ pagePath: 'pages/orders/index', text: '订单' },
|
||||
{ pagePath: 'pages/profile/index', text: '我的' }
|
||||
]
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,258 @@
|
||||
/* ==========================================================
|
||||
全局主题系统 — 白天 / 黑夜两套主题(纯色背景)
|
||||
使用 CSS 变量 + 微信 2.7.0+ 支持
|
||||
========================================================== */
|
||||
|
||||
/* --- 页面根容器主题(.theme-light / .theme-dark 放在每个页面根 View 上)--- */
|
||||
/* 状态栏避让由各页面顶部元素(.page-header 等)通过 useSafeArea 注入的 padding/margin 承担,
|
||||
这里不再用 env(safe-area-inset-top) 隐式下移,避免与 header 的避让叠加导致顶部留白过大 */
|
||||
.theme-light {
|
||||
min-height: 100vh;
|
||||
background-color: var(--bg-page, #ffffff);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.theme-dark {
|
||||
min-height: 100vh;
|
||||
background-color: var(--bg-page, #1e1e2f);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* 变量定义 */
|
||||
.theme-light {
|
||||
--bg-page: #ffffff;
|
||||
--line-card: 3px dashed #ffb7c5;
|
||||
--line-star: #ffb7c5;
|
||||
--text-primary: #5c3a3a;
|
||||
--text-secondary: #b08d8d;
|
||||
--text-muted: #d28a8a;
|
||||
--accent-pink: #ff9a9e;
|
||||
--accent-blue: #5b8cff;
|
||||
--btn-gradient: linear-gradient(135deg, #ff9a9e 0%, #fecfef 99%);
|
||||
--shadow-card: 0 8px 24px rgba(255, 154, 158, 0.12);
|
||||
--bg-input: #f8f9fa;
|
||||
--bg-card: #ffffff;
|
||||
}
|
||||
|
||||
.theme-dark {
|
||||
--bg-page: #1e1e2f;
|
||||
--line-card: 3px dashed #5b8cff;
|
||||
--line-star: #5b8cff;
|
||||
--text-primary: #e0e0f0;
|
||||
--text-secondary: #a0a0c0;
|
||||
--text-muted: #7070a0;
|
||||
--accent-pink: #ff7eb3;
|
||||
--accent-blue: #5cadff;
|
||||
--btn-gradient: linear-gradient(135deg, #ff7eb3 0%, #7a5cff 100%);
|
||||
--shadow-card: 0 8px 24px rgba(0, 0, 0, 0.3);
|
||||
--bg-input: #252540;
|
||||
--bg-card: #2a2a40;
|
||||
}
|
||||
|
||||
/* page 基础字体 */
|
||||
page {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'PingFang SC', 'Hiragino Sans GB', 'Noto Sans SC', 'Microsoft YaHei', 'Segoe UI', sans-serif;
|
||||
}
|
||||
|
||||
/* ==========================================================
|
||||
全局组件样式
|
||||
========================================================== */
|
||||
|
||||
.dashed-card {
|
||||
background: var(--bg-card);
|
||||
border: var(--line-card);
|
||||
border-radius: 24px;
|
||||
padding: 32px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
box-shadow: var(--shadow-card);
|
||||
}
|
||||
|
||||
/* 虚线无填充星星(左上角装饰) */
|
||||
.star-badge {
|
||||
position: absolute;
|
||||
top: -10px;
|
||||
left: -10px;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.star-badge::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
background: var(--bg-card);
|
||||
clip-path: polygon(
|
||||
50% 0%, 61% 35%, 98% 35%, 68% 57%, 79% 91%,
|
||||
50% 70%, 21% 91%, 32% 57%, 2% 35%, 39% 35%
|
||||
);
|
||||
border: 2px dashed var(--line-star);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* === 常用工具类 === */
|
||||
.container { padding: 24px; }
|
||||
|
||||
.flex { display: flex; }
|
||||
.flex-center { display: flex; align-items: center; justify-content: center; }
|
||||
.flex-between { display: flex; align-items: center; justify-content: space-between; }
|
||||
.flex-column { display: flex; flex-direction: column; }
|
||||
|
||||
.text-center { text-align: center; }
|
||||
|
||||
.mt-10 { margin-top: 10px; }
|
||||
.mt-20 { margin-top: 20px; }
|
||||
.mb-10 { margin-bottom: 10px; }
|
||||
.mb-20 { margin-bottom: 20px; }
|
||||
|
||||
/* === 通用按钮 === */
|
||||
.btn-gradient {
|
||||
background: var(--btn-gradient);
|
||||
color: var(--text-primary);
|
||||
border-radius: 50px;
|
||||
padding: 24px 48px;
|
||||
font-size: 32px;
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
box-shadow: 0 8px 24px rgba(255, 154, 158, 0.25);
|
||||
border: none;
|
||||
}
|
||||
|
||||
.theme-dark .btn-gradient {
|
||||
color: #ffffff;
|
||||
box-shadow: 0 8px 24px rgba(122, 92, 255, 0.3);
|
||||
}
|
||||
|
||||
.btn-gradient:active {
|
||||
opacity: 0.9;
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
.btn-outline {
|
||||
background: var(--bg-card);
|
||||
color: var(--accent-blue);
|
||||
border: var(--line-card);
|
||||
border-radius: 50px;
|
||||
padding: 20px 48px;
|
||||
font-size: 28px;
|
||||
font-weight: 500;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* 固定底部操作栏 */
|
||||
.action-bar {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: var(--bg-card);
|
||||
border-top: var(--line-card);
|
||||
padding: 20px 32px calc(20px + env(safe-area-inset-bottom));
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 24px;
|
||||
z-index: 500;
|
||||
box-shadow: 0 -8px 24px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
/* === 标题文字 === */
|
||||
.page-title {
|
||||
font-size: 40px;
|
||||
font-weight: 800;
|
||||
color: var(--text-primary);
|
||||
display: block;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 36px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
display: block;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
/* === 状态标签 === */
|
||||
.badge {
|
||||
display: inline-block;
|
||||
padding: 8px 20px;
|
||||
border-radius: 50px;
|
||||
font-size: 22px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.badge-pink {
|
||||
background: rgba(255, 154, 158, 0.15);
|
||||
color: var(--accent-pink);
|
||||
}
|
||||
|
||||
.badge-blue {
|
||||
background: rgba(160, 196, 255, 0.15);
|
||||
color: var(--accent-blue);
|
||||
}
|
||||
|
||||
.badge-green {
|
||||
background: rgba(139, 211, 161, 0.15);
|
||||
color: #2ecc71;
|
||||
}
|
||||
|
||||
.badge-gray {
|
||||
background: rgba(128, 128, 128, 0.15);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* === 安全区占位 === */
|
||||
.safe-bottom {
|
||||
height: constant(safe-area-inset-bottom);
|
||||
height: env(safe-area-inset-bottom);
|
||||
}
|
||||
|
||||
.safe-bottom-placeholder {
|
||||
height: 160px;
|
||||
}
|
||||
|
||||
/* === 自定义导航栏安全区适配 === */
|
||||
/* 所有页面的顶部虚线卡头部下移,避免与微信胶囊按钮重叠 */
|
||||
/* 仅作兜底;接了 useSafeArea 的页面用 inline style 覆盖此值 */
|
||||
.page-header {
|
||||
padding-top: calc(env(safe-area-inset-top, 20px) + 48px);
|
||||
}
|
||||
|
||||
/* 返回按钮不单独占状态栏空间——父级 .page-header / .edit-header 已整体下移 */
|
||||
.back-btn,
|
||||
.edit-back {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
/* ==========================================================
|
||||
底部弹窗遮罩(地址选择器等共用)
|
||||
========================================================== */
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
z-index: 600;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.addr-picker-sheet {
|
||||
width: 100%;
|
||||
max-height: 70vh;
|
||||
border-radius: 24px 24px 0 0;
|
||||
padding: 24px 24px calc(24px + env(safe-area-inset-bottom));
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--bg-card);
|
||||
border-top: var(--line-card);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { Component, PropsWithChildren } from 'react'
|
||||
import { ThemeProvider } from './context/ThemeContext'
|
||||
import { getTheme, resolveTheme } from './utils/store'
|
||||
import { applyPageBackground } from './utils/themeBackground'
|
||||
import './app.scss'
|
||||
|
||||
class App extends Component<PropsWithChildren> {
|
||||
componentDidMount() {
|
||||
// 启动最早时机同步页面背景,避免自定义导航区在深色模式下闪白
|
||||
applyPageBackground(resolveTheme(getTheme()))
|
||||
}
|
||||
|
||||
componentDidShow() {}
|
||||
|
||||
componentDidHide() {}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<ThemeProvider>
|
||||
{this.props.children}
|
||||
</ThemeProvider>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export default App
|
||||
@@ -0,0 +1,36 @@
|
||||
.login-guard {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 80vh;
|
||||
}
|
||||
|
||||
.login-guard-inner {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.login-guard-icon {
|
||||
font-size: 80px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.login-guard-title {
|
||||
font-size: 36px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.login-guard-desc {
|
||||
font-size: 28px;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.login-guard-btn {
|
||||
padding: 20px 48px;
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import Taro from '@tarojs/taro'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { View, Text } from '@tarojs/components'
|
||||
import { getMe, logout } from '../../utils/api'
|
||||
import { getUserInfoRaw, clearUserInfo, isMockOpenid } from '../../utils/store'
|
||||
|
||||
type GuardState = 'checking' | 'ok' | 'denied'
|
||||
|
||||
/**
|
||||
* 登录守卫:不仅看本地是否有 openid,还要求本地用户与云端校验一致。
|
||||
* - 本地 openid 是历史 mock 残留 -> 判定不一致,清掉并引导重新登录
|
||||
* - 本地有 openid 但后端 getMe 校验失败/用户不存在 -> 判定不一致,清掉并引导重新登录
|
||||
* - 本地与云端 ID/openid 一致 -> 放行
|
||||
*/
|
||||
export default function LoginGuard(props: { children: React.ReactNode }) {
|
||||
const [state, setState] = useState<GuardState>('checking')
|
||||
|
||||
useEffect(() => {
|
||||
const localUser = getUserInfoRaw()
|
||||
|
||||
// 本地根本没登录信息
|
||||
if (!localUser?.openid) {
|
||||
setState('denied')
|
||||
return
|
||||
}
|
||||
|
||||
// 本地是旧版 mock 残留:与真实云端不一致,强制重新登录
|
||||
if (isMockOpenid(localUser.openid)) {
|
||||
clearUserInfo()
|
||||
logout()
|
||||
setState('denied')
|
||||
return
|
||||
}
|
||||
|
||||
// 本地有真实 openid:到后端校验是否与云端一致
|
||||
getMe()
|
||||
.then((me) => {
|
||||
const consistent = !!me && !!me.id && me.openid === localUser.openid
|
||||
if (consistent) {
|
||||
setState('ok')
|
||||
} else {
|
||||
clearUserInfo()
|
||||
logout()
|
||||
setState('denied')
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
// 后端不可用时的谨慎处理:不静默放行,也不抹掉本地,
|
||||
// 返回 denied 让用户尝试重新登录(后端恢复后即可通过)
|
||||
setState('denied')
|
||||
})
|
||||
}, [])
|
||||
|
||||
if (state === 'checking') {
|
||||
return (
|
||||
<View className='login-guard'>
|
||||
<View className='login-guard-inner'>
|
||||
<Text className='login-guard-icon'>⏳</Text>
|
||||
<Text className='login-guard-title'>校验登录状态</Text>
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
if (state === 'denied') {
|
||||
return (
|
||||
<View className='login-guard'>
|
||||
<View className='login-guard-inner'>
|
||||
<Text className='login-guard-icon'>🔒</Text>
|
||||
<Text className='login-guard-title'>请先登录</Text>
|
||||
<Text className='login-guard-desc'>登录后查看您的专属内容</Text>
|
||||
<View className='btn-gradient login-guard-btn' onTap={() => Taro.switchTab({ url: '/pages/profile/index' })}>
|
||||
<Text>去登录</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
return props.children as any
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
padding: 48px;
|
||||
}
|
||||
|
||||
.login-card {
|
||||
width: 100%;
|
||||
max-width: 600px;
|
||||
padding: 40px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.login-title {
|
||||
font-size: 36px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
display: block;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.login-desc {
|
||||
font-size: 26px;
|
||||
color: var(--text-secondary);
|
||||
display: block;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.avatar-btn {
|
||||
width: 160px;
|
||||
height: 160px;
|
||||
border-radius: 50%;
|
||||
margin: 0 auto 24px;
|
||||
background: var(--bg-input);
|
||||
border: 3px dashed var(--line-star);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.avatar-img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.avatar-placeholder {
|
||||
font-size: 24px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.nickname-input {
|
||||
width: 100%;
|
||||
height: 80px;
|
||||
background: var(--bg-input);
|
||||
border: 3px dashed var(--line-star);
|
||||
border-radius: 16px;
|
||||
padding: 0 24px;
|
||||
font-size: 28px;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 32px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.login-actions {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.login-actions .btn-outline,
|
||||
.login-actions .btn-gradient {
|
||||
flex: 1;
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import { View, Text, Image, Button, Input } from '@tarojs/components'
|
||||
import Taro from '@tarojs/taro'
|
||||
import { useState } from 'react'
|
||||
import './index.scss'
|
||||
import { setUserInfo } from '../../utils/store'
|
||||
import { login, getMe } from '../../utils/api'
|
||||
import { safeHideLoading } from '../../utils/request'
|
||||
|
||||
/**
|
||||
* 登录弹窗(一步流程,无需手机号)
|
||||
*
|
||||
* 个人主体小程序无 getPhoneNumber 权限,故以 openid 作为用户唯一标识:
|
||||
* 用户填头像+昵称 -> 点「登录」-> wx.login 拿 code -> 后端换 openid 自动注册/续登
|
||||
* -> getMe 取用户信息 -> 完成
|
||||
*/
|
||||
export default function LoginModal({ visible, onClose, onLogin }: { visible: boolean; onClose: () => void; onLogin: () => void }) {
|
||||
const [nickName, setNickName] = useState('')
|
||||
const [avatarUrl, setAvatarUrl] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
if (!visible) return null
|
||||
|
||||
const fail = (msg: string) => {
|
||||
setLoading(false)
|
||||
safeHideLoading()
|
||||
Taro.showToast({ title: msg, icon: 'none' })
|
||||
}
|
||||
|
||||
const finish = (accessToken: string, openid: string) => {
|
||||
const info = {
|
||||
openid,
|
||||
nickName: nickName || '微信用户',
|
||||
avatarUrl: avatarUrl || '',
|
||||
accessToken,
|
||||
loginAt: Date.now()
|
||||
}
|
||||
setUserInfo(info)
|
||||
setLoading(false)
|
||||
safeHideLoading()
|
||||
Taro.showToast({ title: '登录成功', icon: 'success' })
|
||||
onLogin()
|
||||
}
|
||||
|
||||
const handleLogin = async () => {
|
||||
if (loading) return
|
||||
console.log('[LoginModal] 登录按钮被点击,开始登录流程')
|
||||
setLoading(true)
|
||||
Taro.showLoading({ title: '登录中', mask: true })
|
||||
try {
|
||||
const wxLogin = await Taro.login()
|
||||
console.log('[LoginModal] wx.login 成功,code=', wxLogin.code)
|
||||
const result = await login(wxLogin.code)
|
||||
console.log('[LoginModal] 后端 /auth/login 返回:', JSON.stringify(result))
|
||||
if (!result?.accessToken) {
|
||||
fail('登录失败,未拿到 token')
|
||||
return
|
||||
}
|
||||
let openid = ''
|
||||
try {
|
||||
const me = await getMe()
|
||||
openid = me?.openid || ''
|
||||
console.log('[LoginModal] getMe 返回:', JSON.stringify(me))
|
||||
} catch {
|
||||
openid = ''
|
||||
}
|
||||
if (!openid) {
|
||||
fail('登录异常,请重试')
|
||||
return
|
||||
}
|
||||
finish(result.accessToken, openid)
|
||||
} catch (e) {
|
||||
console.error('[LoginModal] 登录流程出错:', e)
|
||||
fail((e as Error)?.message || '后端连接失败,请检查网络')
|
||||
}
|
||||
}
|
||||
|
||||
const onChooseAvatar = (e: any) => {
|
||||
setAvatarUrl(e.detail.avatarUrl || '')
|
||||
}
|
||||
|
||||
return (
|
||||
<View className='modal-overlay'>
|
||||
<View className='login-card dashed-card'>
|
||||
<View className='star-badge' />
|
||||
<Text className='login-title'>智绘微刻</Text>
|
||||
<Text className='login-desc'>登录后同步您的设计清单和订单</Text>
|
||||
|
||||
<Button
|
||||
className='avatar-btn'
|
||||
openType='chooseAvatar'
|
||||
onChooseAvatar={onChooseAvatar}
|
||||
>
|
||||
{avatarUrl ? (
|
||||
<Image className='avatar-img' src={avatarUrl} mode='aspectFill' />
|
||||
) : (
|
||||
<Text className='avatar-placeholder'>点击选择头像</Text>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
<Input
|
||||
type='nickname'
|
||||
className='nickname-input'
|
||||
placeholder='请输入昵称'
|
||||
value={nickName}
|
||||
onInput={(e: any) => setNickName(e.detail.value)}
|
||||
/>
|
||||
|
||||
<View className='login-actions'>
|
||||
<View className='btn-outline' onTap={onClose}>
|
||||
<Text>取消</Text>
|
||||
</View>
|
||||
<View className='btn-gradient' onTap={handleLogin}>
|
||||
<Text>登录</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
.theme-toggle {
|
||||
position: fixed;
|
||||
left: 24px;
|
||||
bottom: 150px;
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
border-radius: 50%;
|
||||
background: rgba(0, 0, 0, 0.35);
|
||||
backdrop-filter: blur(8px);
|
||||
-webkit-backdrop-filter: blur(8px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.25);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 900;
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.2);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.theme-toggle:active {
|
||||
transform: scale(0.92);
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.theme-icon {
|
||||
font-size: 36px;
|
||||
line-height: 1;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { View, Text } from '@tarojs/components'
|
||||
import Taro from '@tarojs/taro'
|
||||
import { useCallback } from 'react'
|
||||
import { useThemeContext } from '../../context/ThemeContext'
|
||||
import './index.scss'
|
||||
|
||||
export default function ThemeToggle() {
|
||||
const { theme, toggleTheme } = useThemeContext()
|
||||
|
||||
const handleToggle = useCallback(() => {
|
||||
// ThemeProvider 在 App.tsx 全局挂载,toggleTheme() 直接修改全局 state,
|
||||
// 不需要 eventCenter,所有页面会自动响应。
|
||||
toggleTheme()
|
||||
}, [toggleTheme])
|
||||
|
||||
return (
|
||||
<View className='theme-toggle' onTap={handleToggle}>
|
||||
<Text className='theme-icon'>{theme === 'light' ? '🌙' : '☀️'}</Text>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import { createContext, useContext, useState, useCallback, useEffect } from 'react'
|
||||
import Taro, { useDidShow } from '@tarojs/taro'
|
||||
import { getTheme, setTheme as saveTheme, resolveTheme, type ThemeMode } from '../utils/store'
|
||||
import { applyPageBackground } from '../utils/themeBackground'
|
||||
|
||||
interface ThemeContextValue {
|
||||
theme: ThemeMode
|
||||
resolvedTheme: 'light' | 'dark'
|
||||
toggleTheme: () => void
|
||||
setTheme: (t: ThemeMode) => void
|
||||
}
|
||||
|
||||
const ThemeContext = createContext<ThemeContextValue>({
|
||||
theme: 'auto',
|
||||
resolvedTheme: 'light',
|
||||
toggleTheme: () => {},
|
||||
setTheme: () => {}
|
||||
})
|
||||
|
||||
export { ThemeContext }
|
||||
|
||||
type ThemeChangeListener = (result: { theme?: string }) => void
|
||||
|
||||
type ThemeChangeApi = {
|
||||
onThemeChange?: (listener: ThemeChangeListener) => void
|
||||
offThemeChange?: (listener: ThemeChangeListener) => void
|
||||
}
|
||||
|
||||
/** 主题广播事件名:自定义 tabBar 等无法继承 React Context 的独立组件通过它同步主题 */
|
||||
export const THEME_CHANGE_EVENT = 'themeChange'
|
||||
|
||||
export function ThemeProvider({ children }: { children: React.ReactNode }) {
|
||||
const [theme, set] = useState<ThemeMode>(getTheme())
|
||||
const [resolvedTheme, setResolved] = useState<'light' | 'dark'>(resolveTheme(getTheme()))
|
||||
|
||||
const updateResolved = useCallback((mode: ThemeMode) => {
|
||||
setResolved(resolveTheme(mode))
|
||||
}, [])
|
||||
|
||||
const setTheme = useCallback((t: ThemeMode) => {
|
||||
saveTheme(t)
|
||||
set(t)
|
||||
updateResolved(t)
|
||||
}, [updateResolved])
|
||||
|
||||
const toggleTheme = useCallback(() => {
|
||||
const next = resolvedTheme === 'light' ? 'dark' : 'light'
|
||||
setTheme(next)
|
||||
}, [resolvedTheme, setTheme])
|
||||
|
||||
// 广播实际生效主题,供 custom-tab-bar 等独立组件订阅
|
||||
useEffect(() => {
|
||||
Taro.eventCenter.trigger(THEME_CHANGE_EVENT, resolvedTheme)
|
||||
}, [resolvedTheme])
|
||||
|
||||
// App 启动时立即同步页面背景(自定义导航区在 iOS 上由 backgroundColorTop 控制),
|
||||
// 避免首屏/切换 tab 时顶部导航区先显示白色再变主题色
|
||||
useEffect(() => {
|
||||
applyPageBackground(resolvedTheme)
|
||||
}, [resolvedTheme])
|
||||
|
||||
// 页面每次显示时(从小程序后台切回前台)重新检测系统主题
|
||||
useDidShow(() => {
|
||||
if (theme === 'auto') {
|
||||
setResolved(resolveTheme('auto'))
|
||||
}
|
||||
})
|
||||
|
||||
// auto模式下监听系统主题变化
|
||||
useEffect(() => {
|
||||
if (theme === 'auto') {
|
||||
// 先同步一次当前系统主题
|
||||
setResolved(resolveTheme('auto'))
|
||||
const listener: ThemeChangeListener = (res) => {
|
||||
setResolved(res.theme === 'dark' ? 'dark' : 'light')
|
||||
}
|
||||
const themeApi = Taro as typeof Taro & ThemeChangeApi
|
||||
themeApi.onThemeChange?.(listener)
|
||||
return () => {
|
||||
themeApi.offThemeChange?.(listener)
|
||||
}
|
||||
} else {
|
||||
updateResolved(theme)
|
||||
}
|
||||
}, [theme, updateResolved])
|
||||
|
||||
return (
|
||||
<ThemeContext.Provider value={{ theme, resolvedTheme, toggleTheme, setTheme }}>
|
||||
{children}
|
||||
</ThemeContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function useThemeContext(): ThemeContextValue {
|
||||
return useContext(ThemeContext)
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"component": true
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
.custom-tab-bar {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 120px;
|
||||
background: #ffffff;
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
align-items: center;
|
||||
border-top: 3px dashed #A0C4FF;
|
||||
padding-bottom: constant(safe-area-inset-bottom);
|
||||
padding-bottom: env(safe-area-inset-bottom);
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
/* 自定义组件样式隔离:两套主题配色在组件内自包含,不依赖全局 app.wxss */
|
||||
.custom-tab-bar.theme-dark {
|
||||
background: #1e1e2f;
|
||||
border-top-color: #5b8cff;
|
||||
}
|
||||
|
||||
.tab-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 10px 20px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.tab-icon-img {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
margin-bottom: 4px;
|
||||
transition: transform 0.2s;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.tab-item.active .tab-icon-img {
|
||||
transform: scale(1.15);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.tab-label {
|
||||
font-size: 20px;
|
||||
color: #b08d8d;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.custom-tab-bar.theme-dark .tab-label {
|
||||
color: #a0a0c0;
|
||||
}
|
||||
|
||||
.tab-item.active .tab-label {
|
||||
color: #ff9a9e;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.custom-tab-bar.theme-dark .tab-item.active .tab-label {
|
||||
color: #ff7eb3;
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { View, Text, Image } from '@tarojs/components'
|
||||
import Taro from '@tarojs/taro'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { getTheme, resolveTheme } from '../utils/store'
|
||||
import { THEME_CHANGE_EVENT } from '../context/ThemeContext'
|
||||
import { applyPageBackground } from '../utils/themeBackground'
|
||||
import './index.scss'
|
||||
|
||||
const TABS = [
|
||||
{ pagePath: '/pages/index/index', text: '首页', icon: '/icon/首页.png' },
|
||||
{ pagePath: '/pages/shop/index', text: '商品', icon: '/icon/商品.png' },
|
||||
{ pagePath: '/pages/designList/index', text: '设计清单', icon: '/icon/调色盘.png' },
|
||||
{ pagePath: '/pages/orders/index', text: '订单', icon: '/icon/包裹.png' },
|
||||
{ pagePath: '/pages/profile/index', text: '我的', icon: '/icon/个人.png' }
|
||||
]
|
||||
|
||||
export default function CustomTabBar() {
|
||||
// 自定义 tabBar 是独立组件,无法继承页面里的 React Context,
|
||||
// 初始值从本地主题配置读取,之后通过 eventCenter 跟随主题切换
|
||||
const [resolvedTheme, setResolvedTheme] = useState<'light' | 'dark'>(resolveTheme(getTheme()))
|
||||
const currentPath = `/${Taro.getCurrentInstance().router?.path || 'pages/index/index'}`
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (t: 'light' | 'dark') => setResolvedTheme(t)
|
||||
Taro.eventCenter.on(THEME_CHANGE_EVENT, handler)
|
||||
return () => {
|
||||
Taro.eventCenter.off(THEME_CHANGE_EVENT, handler)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// tabBar 是 tab 页切换时必定会挂载/更新的组件:在这里也刷新一次页面背景,
|
||||
// 兜底处理“登录后切到受保护页面”时自定义导航区背景,避免残留白色
|
||||
useEffect(() => {
|
||||
applyPageBackground(resolvedTheme)
|
||||
}, [resolvedTheme])
|
||||
|
||||
const switchTab = (url: string) => {
|
||||
Taro.switchTab({ url })
|
||||
}
|
||||
|
||||
return (
|
||||
<View className={`custom-tab-bar theme-${resolvedTheme}`}>
|
||||
{TABS.map((tab) => {
|
||||
const isActive = currentPath === tab.pagePath
|
||||
return (
|
||||
<View
|
||||
key={tab.pagePath}
|
||||
className={`tab-item ${isActive ? 'active' : ''}`}
|
||||
onTap={() => switchTab(tab.pagePath)}
|
||||
>
|
||||
<Image className={`tab-icon-img ${isActive ? 'active' : ''}`} src={tab.icon} mode='aspectFit' />
|
||||
<Text className='tab-label'>{tab.text}</Text>
|
||||
</View>
|
||||
)
|
||||
})}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import Taro from '@tarojs/taro'
|
||||
|
||||
export interface SafeAreaInfo {
|
||||
statusBarHeight: number
|
||||
menuButtonTop: number
|
||||
menuButtonHeight: number
|
||||
capsuleRight: number
|
||||
safeInsetTop: number
|
||||
safeInsetBottom: number
|
||||
/** 建议给 .page-header 的 paddingTop 像素值 */
|
||||
headerPaddingTop: number
|
||||
/** 建议给 .back-btn 的 paddingTop 像素值 */
|
||||
backBtnPaddingTop: number
|
||||
}
|
||||
|
||||
let cached: SafeAreaInfo | null = null
|
||||
|
||||
export function useSafeArea(): SafeAreaInfo {
|
||||
const [info, setInfo] = useState<SafeAreaInfo>(cached ?? {
|
||||
statusBarHeight: 20,
|
||||
menuButtonTop: 26,
|
||||
menuButtonHeight: 32,
|
||||
capsuleRight: 368,
|
||||
safeInsetTop: 20,
|
||||
safeInsetBottom: 0,
|
||||
headerPaddingTop: 66,
|
||||
backBtnPaddingTop: 20
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (cached) {
|
||||
setInfo(cached)
|
||||
return
|
||||
}
|
||||
try {
|
||||
const sys = Taro.getSystemInfoSync()
|
||||
const mb = Taro.getMenuButtonBoundingClientRect()
|
||||
const statusBarHeight = sys.statusBarHeight || 20
|
||||
const menuButtonTop = mb.top ?? statusBarHeight + 4
|
||||
const menuButtonHeight = mb.height ?? 32
|
||||
const capsuleRight = mb.right ?? (sys.windowWidth - 7)
|
||||
const safeInsetTop = sys.safeArea?.top ?? statusBarHeight
|
||||
const safeInsetBottom = sys.safeAreaInsetBottom ?? (sys.screenHeight - (sys.safeArea?.bottom ?? sys.screenHeight))
|
||||
|
||||
// header 需要排在胶囊按钮下面,稍微留点空隙
|
||||
const headerPaddingTop = Math.max(menuButtonTop + menuButtonHeight + 6, statusBarHeight + 44)
|
||||
// back-btn 需要和状态栏底部对齐再加一点
|
||||
const backBtnPaddingTop = Math.max(statusBarHeight, menuButtonTop - 4)
|
||||
|
||||
const data: SafeAreaInfo = {
|
||||
statusBarHeight,
|
||||
menuButtonTop,
|
||||
menuButtonHeight,
|
||||
capsuleRight,
|
||||
safeInsetTop,
|
||||
safeInsetBottom,
|
||||
headerPaddingTop,
|
||||
backBtnPaddingTop
|
||||
}
|
||||
cached = data
|
||||
setInfo(data)
|
||||
} catch {
|
||||
// fallback 不变
|
||||
}
|
||||
}, [])
|
||||
|
||||
return info
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { useCallback, useEffect } from 'react'
|
||||
import Taro, { useDidShow } from '@tarojs/taro'
|
||||
import { applyPageBackground } from '../utils/themeBackground'
|
||||
|
||||
export interface UseStatusBarOptions {
|
||||
/** 顶部系统状态栏(iOS 时间/电量)文字颜色模式:
|
||||
* - 'auto'(默认):跟随当前 App 主题,浅色黑字、深色白字
|
||||
* - 'light':强制白字(顶部是深色图片/背景的页面用)
|
||||
* - 'dark':强制黑字
|
||||
*/
|
||||
mode?: 'auto' | 'light' | 'dark'
|
||||
}
|
||||
|
||||
/**
|
||||
* 同步当前页面的 iOS 系统状态栏(时间、电量)文字颜色。
|
||||
* 每个页面只调用一次,避免全局 Provider 与页面生命周期竞争写入。
|
||||
*/
|
||||
export function useStatusBar(resolvedTheme: 'light' | 'dark', options: UseStatusBarOptions = {}) {
|
||||
const { mode = 'auto' } = options
|
||||
|
||||
const apply = useCallback(() => {
|
||||
const isDarkText = mode === 'dark' || (mode === 'auto' && resolvedTheme === 'light')
|
||||
const frontColor = isDarkText ? '#000000' : '#ffffff'
|
||||
const bgColor = isDarkText ? '#ffffff' : '#1e1e2f'
|
||||
|
||||
Taro.setNavigationBarColor({ frontColor, backgroundColor: bgColor })
|
||||
// 同步页面背景色,确保自定义导航区(状态栏下方、胶囊按钮区域)跟随主题,
|
||||
// 避免深色模式下顶部残留白色条带。
|
||||
// 注意:iOS 上顶部/底部窗口背景需分别用 backgroundColorTop / Bottom 指定,
|
||||
// 仅设置 backgroundColor 在 iOS 上无法覆盖顶部导航区。
|
||||
applyPageBackground(resolvedTheme, bgColor)
|
||||
}, [mode, resolvedTheme])
|
||||
|
||||
// 主题切换后立即更新当前页面。
|
||||
useEffect(apply, [apply])
|
||||
|
||||
// 导航返回、切换 Tab 或从后台恢复后,以当前页面主题重新覆盖原生默认值。
|
||||
useDidShow(apply)
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { useContext } from 'react'
|
||||
import { ThemeContext } from '../context/ThemeContext'
|
||||
import type { ThemeMode } from '../utils/store'
|
||||
|
||||
export function useTheme(): [ThemeMode, (t: ThemeMode) => void] {
|
||||
const ctx = useContext(ThemeContext)
|
||||
return [ctx.theme, ctx.setTheme]
|
||||
}
|
||||
|
After Width: | Height: | Size: 3.6 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 4.3 KiB |
|
After Width: | Height: | Size: 2.7 KiB |
|
After Width: | Height: | Size: 9.5 KiB |
|
After Width: | Height: | Size: 4.7 KiB |
|
After Width: | Height: | Size: 7.6 KiB |
|
After Width: | Height: | Size: 5.8 KiB |
|
After Width: | Height: | Size: 2.9 KiB |
|
After Width: | Height: | Size: 5.2 KiB |
|
After Width: | Height: | Size: 9.4 KiB |
|
After Width: | Height: | Size: 7.6 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 8.0 KiB |
|
After Width: | Height: | Size: 5.1 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 6.7 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 6.0 KiB |
|
After Width: | Height: | Size: 4.8 KiB |
|
After Width: | Height: | Size: 76 KiB |
|
After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 61 KiB |
|
After Width: | Height: | Size: 38 KiB |
|
After Width: | Height: | Size: 61 KiB |
|
After Width: | Height: | Size: 65 KiB |
|
After Width: | Height: | Size: 51 KiB |
|
After Width: | Height: | Size: 45 KiB |
@@ -0,0 +1,4 @@
|
||||
export default definePageConfig({
|
||||
navigationStyle: 'custom',
|
||||
navigationBarTitleText: '收货地址'
|
||||
})
|
||||
@@ -0,0 +1,177 @@
|
||||
.address-page {
|
||||
padding: 0 24px 24px;
|
||||
}
|
||||
|
||||
.back-btn {
|
||||
font-size: 40px;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.address-list {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.address-card {
|
||||
padding: 24px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.address-header {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.address-user {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.address-name {
|
||||
font-size: 32px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
margin-right: 16px;
|
||||
}
|
||||
|
||||
.address-phone {
|
||||
font-size: 28px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.default-tag {
|
||||
margin-left: 12px;
|
||||
background: var(--accent-pink);
|
||||
color: #fff;
|
||||
font-size: 20px;
|
||||
padding: 2px 10px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.address-full {
|
||||
font-size: 28px;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.address-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 24px;
|
||||
padding-top: 16px;
|
||||
border-top: 1px dashed rgba(0,0,0,0.06);
|
||||
}
|
||||
|
||||
.address-act {
|
||||
font-size: 26px;
|
||||
color: var(--accent-blue);
|
||||
}
|
||||
|
||||
.address-act.delete {
|
||||
color: #ff6b6b;
|
||||
}
|
||||
|
||||
/* 表单弹窗 */
|
||||
.address-form {
|
||||
width: 100%;
|
||||
max-width: 600px;
|
||||
padding: 40px;
|
||||
}
|
||||
|
||||
.form-input {
|
||||
width: 100%;
|
||||
height: 80px;
|
||||
background: var(--bg-input);
|
||||
border: 3px dashed var(--line-star);
|
||||
border-radius: 16px;
|
||||
padding: 0 24px;
|
||||
font-size: 28px;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 20px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.form-picker {
|
||||
width: 100%;
|
||||
height: 80px;
|
||||
background: var(--bg-input);
|
||||
border: 3px dashed var(--line-star);
|
||||
border-radius: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 24px;
|
||||
margin-bottom: 20px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.form-picker-val {
|
||||
font-size: 28px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.form-picker-ph {
|
||||
font-size: 28px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.form-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.form-label {
|
||||
font-size: 28px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.checkbox {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border: 3px dashed var(--line-star);
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.checkbox.checked {
|
||||
background: var(--accent-pink);
|
||||
border-color: var(--accent-pink);
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.form-actions .btn-outline,
|
||||
.form-actions .btn-gradient {
|
||||
flex: 1;
|
||||
padding: 20px 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* 空状态(主题适配) */
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 120px 40px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.empty-icon-img {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.empty-text {
|
||||
font-size: 32px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.empty-sub {
|
||||
font-size: 26px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
import { View, Text, Image, Input, Picker } from '@tarojs/components'
|
||||
import Taro from '@tarojs/taro'
|
||||
import { useState, useEffect } from 'react'
|
||||
import './index.scss'
|
||||
import { getAddressList, addAddress, updateAddress, deleteAddress } from '../../utils/store'
|
||||
import type { AddressItem } from '../../types'
|
||||
import { useThemeContext } from '../../context/ThemeContext'
|
||||
import { useSafeArea } from '../../hooks/useSafeArea'
|
||||
import { useStatusBar } from '../../hooks/useStatusBar'
|
||||
|
||||
export default function AddressPage() {
|
||||
const { theme, resolvedTheme } = useThemeContext()
|
||||
const safe = useSafeArea()
|
||||
useStatusBar(resolvedTheme)
|
||||
const [list, setList] = useState<AddressItem[]>([])
|
||||
const [editing, setEditing] = useState<AddressItem | null>(null)
|
||||
const [showForm, setShowForm] = useState(false)
|
||||
|
||||
const [name, setName] = useState('')
|
||||
const [phone, setPhone] = useState('')
|
||||
const [region, setRegion] = useState<string[]>([])
|
||||
const [detail, setDetail] = useState('')
|
||||
const [isDefault, setIsDefault] = useState(false)
|
||||
|
||||
const load = () => setList(getAddressList())
|
||||
|
||||
useEffect(() => {
|
||||
load()
|
||||
}, [])
|
||||
|
||||
const resetForm = () => {
|
||||
setName('')
|
||||
setPhone('')
|
||||
setRegion([])
|
||||
setDetail('')
|
||||
setIsDefault(false)
|
||||
setEditing(null)
|
||||
}
|
||||
|
||||
const openAdd = () => {
|
||||
resetForm()
|
||||
setShowForm(true)
|
||||
}
|
||||
|
||||
const openEdit = (item: AddressItem) => {
|
||||
setEditing(item)
|
||||
setName(item.name)
|
||||
setPhone(item.phone)
|
||||
setRegion(item.region)
|
||||
setDetail(item.detail)
|
||||
setIsDefault(item.isDefault)
|
||||
setShowForm(true)
|
||||
}
|
||||
|
||||
const handleDelete = (id: string) => {
|
||||
Taro.showModal({
|
||||
title: '确认删除',
|
||||
content: '删除后将无法恢复该地址',
|
||||
success: (res) => {
|
||||
if (res.confirm) {
|
||||
deleteAddress(id)
|
||||
load()
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const handleCopy = (item: AddressItem) => {
|
||||
const text = `${item.name} ${item.phone}\n${item.region.join(' ')} ${item.detail}`
|
||||
Taro.setClipboardData({ data: text })
|
||||
}
|
||||
|
||||
const handleSave = () => {
|
||||
if (!name.trim() || !phone.trim() || region.length === 0 || !detail.trim()) {
|
||||
Taro.showToast({ title: '请填写完整信息', icon: 'none' })
|
||||
return
|
||||
}
|
||||
const payload = { name, phone, region, detail, isDefault }
|
||||
if (editing) {
|
||||
updateAddress(editing.id, payload)
|
||||
} else {
|
||||
addAddress(payload)
|
||||
}
|
||||
Taro.showToast({ title: '保存成功', icon: 'success' })
|
||||
setShowForm(false)
|
||||
resetForm()
|
||||
load()
|
||||
}
|
||||
|
||||
const onRegionChange = (e: any) => {
|
||||
setRegion(e.detail.value || [])
|
||||
}
|
||||
|
||||
return (
|
||||
<View className={`theme-${resolvedTheme}`}>
|
||||
<View className='address-page'>
|
||||
|
||||
<View className='page-header dashed-card mt-20' style={{ marginTop: `${safe.headerPaddingTop}px` }}>
|
||||
<View className='star-badge' />
|
||||
<View className='flex-between' style={{ width: '100%' }}>
|
||||
<Text className='back-btn' onTap={() => Taro.navigateBack()}>←</Text>
|
||||
<Text className='page-title'>收货地址</Text>
|
||||
<View style={{ width: '60px' }} />
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 地址列表 */}
|
||||
<View className='address-list'>
|
||||
{list.map(item => (
|
||||
<View key={item.id} className='address-card dashed-card'>
|
||||
<View className='address-header'>
|
||||
<View className='address-user'>
|
||||
<Text className='address-name'>{item.name}</Text>
|
||||
<Text className='address-phone'>{item.phone}</Text>
|
||||
{item.isDefault && (
|
||||
<View className='default-tag'><Text>默认</Text></View>
|
||||
)}
|
||||
</View>
|
||||
<Text className='address-full'>
|
||||
{item.region.join(' ')} {item.detail}
|
||||
</Text>
|
||||
</View>
|
||||
<View className='address-actions'>
|
||||
<Text className='address-act' onTap={() => openEdit(item)}>✏️ 编辑</Text>
|
||||
<Text className='address-act' onTap={() => handleCopy(item)}>📋 复制</Text>
|
||||
<Text className='address-act delete' onTap={() => handleDelete(item.id)}>🗑️ 删除</Text>
|
||||
</View>
|
||||
</View>
|
||||
))}
|
||||
|
||||
{list.length === 0 && (
|
||||
<View className='empty-state'>
|
||||
<Image className='empty-icon-img' src='/icon/地址.png' mode='aspectFit' />
|
||||
<Text className='empty-text'>还没有收货地址</Text>
|
||||
<Text className='empty-sub'>点击下方按钮添加新地址</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<View className='safe-bottom-placeholder' />
|
||||
|
||||
{/* 底部添加按钮 */}
|
||||
<View className='action-bar'>
|
||||
<View className='btn-gradient' onTap={openAdd}>
|
||||
<Text>新增收货地址</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 新增/编辑弹窗 */}
|
||||
{showForm && (
|
||||
<View className='modal-overlay'>
|
||||
<View className='modal-card dashed-card address-form'>
|
||||
<View className='star-badge' />
|
||||
<Text className='modal-title'>{editing ? '编辑地址' : '新增地址'}</Text>
|
||||
|
||||
<Input className='form-input' placeholder='收货人姓名'
|
||||
value={name} onInput={(e: any) => setName(e.detail.value)} />
|
||||
<Input className='form-input' placeholder='手机号码' type='number'
|
||||
value={phone} onInput={(e: any) => setPhone(e.detail.value)} />
|
||||
|
||||
<Picker mode='region' value={region} onChange={onRegionChange}>
|
||||
<View className='form-picker'>
|
||||
<Text className={region.length ? 'form-picker-val' : 'form-picker-ph'}>
|
||||
{region.length ? region.join(' / ') : '选择省 / 市 / 区'}
|
||||
</Text>
|
||||
</View>
|
||||
</Picker>
|
||||
|
||||
<Input className='form-input' placeholder='详细地址:街道、门牌号'
|
||||
value={detail} onInput={(e: any) => setDetail(e.detail.value)} />
|
||||
|
||||
<View className='form-row' onTap={() => setIsDefault(!isDefault)}>
|
||||
<Text className='form-label'>设为默认地址</Text>
|
||||
<View className={`checkbox ${isDefault ? 'checked' : ''}`} />
|
||||
</View>
|
||||
|
||||
<View className='form-actions'>
|
||||
<View className='btn-outline' onTap={() => { setShowForm(false); resetForm() }}>
|
||||
<Text>取消</Text>
|
||||
</View>
|
||||
<View className='btn-gradient' onTap={handleSave}>
|
||||
<Text>保存</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export default definePageConfig({
|
||||
navigationStyle: 'custom',
|
||||
navigationBarTitleText: '定制协议'
|
||||
})
|
||||
@@ -0,0 +1,36 @@
|
||||
.agreement-page {
|
||||
padding: 0 24px 24px;
|
||||
}
|
||||
|
||||
.agreement-title {
|
||||
font-size: 40px;
|
||||
font-weight: 800;
|
||||
color: var(--text-primary);
|
||||
display: block;
|
||||
margin-bottom: 32px;
|
||||
padding-top: 24px;
|
||||
}
|
||||
|
||||
.agreement-content {
|
||||
padding: 40px 32px;
|
||||
line-height: 1.8;
|
||||
}
|
||||
|
||||
.agreement-section {
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.agreement-h3 {
|
||||
font-size: 32px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
display: block;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.agreement-p {
|
||||
font-size: 28px;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.8;
|
||||
display: block;
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { View, Text } from '@tarojs/components'
|
||||
import './index.scss'
|
||||
import { useThemeContext } from '../../context/ThemeContext'
|
||||
import { useSafeArea } from '../../hooks/useSafeArea'
|
||||
import { useStatusBar } from '../../hooks/useStatusBar'
|
||||
|
||||
export default function AgreementPage() {
|
||||
const { theme, resolvedTheme } = useThemeContext()
|
||||
const safe = useSafeArea()
|
||||
useStatusBar(resolvedTheme)
|
||||
|
||||
const SECTIONS = [
|
||||
{
|
||||
title: '一、用户权责',
|
||||
content: '用户确认提交的设计内容不侵犯第三方知识产权,定制商品一经确认下单即进入生产流程,非质量问题不支持退换。'
|
||||
},
|
||||
{
|
||||
title: '二、隐私保护',
|
||||
content: '我们严格保护用户个人信息,头像与昵称仅用于提升服务体验,不与第三方分享。'
|
||||
},
|
||||
{
|
||||
title: '三、售后说明',
|
||||
content: '收到商品后如有质量问题,请在7天内联系客服,我们将为您免费重做或退款。'
|
||||
},
|
||||
{
|
||||
title: '四、定制流程',
|
||||
content: `1. 选择商品品类并加入设计清单
|
||||
2. 在DIY工作台上传图片或输入文字
|
||||
3. 确认设计效果并提交订单
|
||||
4. 支付后进入生产,约3-7个工作日发货
|
||||
5. 物流跟踪至确认收货`
|
||||
}
|
||||
]
|
||||
|
||||
return (
|
||||
<View className={`theme-${resolvedTheme}`}>
|
||||
<View className='agreement-page'>
|
||||
<View className='agreement-content' style={{ marginTop: `${safe.headerPaddingTop}px` }}>
|
||||
<Text className='agreement-title'>定制协议</Text>
|
||||
{SECTIONS.map((sec, idx) => (
|
||||
<View key={idx} className='agreement-section'>
|
||||
<Text className='agreement-h3'>{sec.title}</Text>
|
||||
<Text className='agreement-p'>{sec.content}</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export default definePageConfig({
|
||||
navigationStyle: 'custom',
|
||||
navigationBarTitleText: '确认下单'
|
||||
})
|
||||
@@ -0,0 +1 @@
|
||||
{}
|
||||
@@ -0,0 +1,240 @@
|
||||
.checkout-page {
|
||||
padding: 0 24px 24px;
|
||||
}
|
||||
|
||||
.back-btn {
|
||||
font-size: 40px;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
/* 画布展示区 */
|
||||
.checkout-canvas {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 40px;
|
||||
}
|
||||
|
||||
.canvas-area {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
background: rgba(0,0,0,0.03);
|
||||
border: 2px dashed var(--line-star);
|
||||
max-width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.checkout-image {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
}
|
||||
|
||||
/* 商品信息 */
|
||||
.checkout-info {
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.info-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 20px 0;
|
||||
border-bottom: 1px dashed rgba(0,0,0,0.06);
|
||||
}
|
||||
|
||||
.info-row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.info-label {
|
||||
font-size: 28px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.info-value {
|
||||
font-size: 28px;
|
||||
color: var(--text-primary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.total-row {
|
||||
padding-top: 24px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.info-total {
|
||||
font-size: 40px;
|
||||
font-weight: 800;
|
||||
color: var(--accent-pink);
|
||||
}
|
||||
|
||||
/* 地址卡片 */
|
||||
.checkout-address-card {
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.checkout-address-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.address-change {
|
||||
padding: 6px 16px;
|
||||
border-radius: 8px;
|
||||
background: rgba(91, 140, 255, 0.08);
|
||||
}
|
||||
|
||||
.change-text {
|
||||
font-size: 24px;
|
||||
color: var(--accent-blue);
|
||||
}
|
||||
|
||||
.checkout-address-body {
|
||||
padding-top: 8px;
|
||||
}
|
||||
|
||||
.addr-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.addr-name {
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.addr-phone {
|
||||
font-size: 26px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.addr-detail {
|
||||
font-size: 26px;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.checkout-address-empty {
|
||||
padding: 32px 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.empty-text {
|
||||
font-size: 26px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* 底部弹窗 — 地址选择(样式复用 app.scss 全局定义,移除非必要覆写) */
|
||||
|
||||
.addr-picker-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.addr-picker-title {
|
||||
font-size: 32px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.addr-picker-close {
|
||||
font-size: 28px;
|
||||
color: var(--text-secondary);
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.addr-picker-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.addr-picker-item {
|
||||
padding: 20px 16px;
|
||||
border-bottom: 1px dashed rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.addr-picker-item.active {
|
||||
background: rgba(91, 140, 255, 0.06);
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.addr-picker-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.addr-picker-name {
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.addr-picker-phone {
|
||||
font-size: 26px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.addr-picker-default {
|
||||
font-size: 22px;
|
||||
color: var(--accent-pink);
|
||||
border: 1px solid var(--accent-pink);
|
||||
padding: 2px 8px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.addr-picker-detail {
|
||||
font-size: 26px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.addr-picker-empty {
|
||||
padding: 40px;
|
||||
text-align: center;
|
||||
color: var(--text-secondary);
|
||||
font-size: 28px;
|
||||
}
|
||||
|
||||
.addr-picker-add {
|
||||
margin-top: 16px;
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
border: 2px dashed var(--accent-blue);
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.addr-picker-add-text {
|
||||
font-size: 28px;
|
||||
color: var(--accent-blue);
|
||||
}
|
||||
|
||||
/* 底部操作 */
|
||||
.checkout-actions {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: var(--bg-card);
|
||||
border-top: var(--line-card);
|
||||
padding: 20px 32px calc(20px + env(safe-area-inset-bottom));
|
||||
display: flex;
|
||||
gap: 24px;
|
||||
z-index: 500;
|
||||
}
|
||||
|
||||
.checkout-actions .btn-outline,
|
||||
.checkout-actions .btn-gradient {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
import { View, Text, Image, Button } from '@tarojs/components'
|
||||
import Taro from '@tarojs/taro'
|
||||
import { useState, useEffect } from 'react'
|
||||
import './index.scss'
|
||||
import { getDesignList, designToOrder, getDefaultAddress, getAddressList, type AddressItem } from '../../utils/store'
|
||||
import { useThemeContext } from '../../context/ThemeContext'
|
||||
import { useSafeArea } from '../../hooks/useSafeArea'
|
||||
import { useStatusBar } from '../../hooks/useStatusBar'
|
||||
|
||||
export default function CheckoutPage() {
|
||||
const { theme, resolvedTheme } = useThemeContext()
|
||||
const safe = useSafeArea()
|
||||
useStatusBar(resolvedTheme)
|
||||
const [design, setDesign] = useState<any>(null)
|
||||
const [address, setAddress] = useState<AddressItem | null>(null)
|
||||
const [showAddrPicker, setShowAddrPicker] = useState(false)
|
||||
const [addrList, setAddrList] = useState<AddressItem[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
const params = Taro.getCurrentInstance().router?.params
|
||||
const dId = params?.designId
|
||||
if (!dId) return
|
||||
const list = getDesignList()
|
||||
const d = list.find(x => x.id === dId)
|
||||
setDesign(d || null)
|
||||
setAddress(getDefaultAddress())
|
||||
setAddrList(getAddressList())
|
||||
}, [])
|
||||
|
||||
const handleConfirm = () => {
|
||||
if (!address) {
|
||||
Taro.showToast({ title: '请先添加收货地址', icon: 'none' })
|
||||
return
|
||||
}
|
||||
Taro.showModal({
|
||||
title: '确认下单',
|
||||
content: `确认后将从设计清单生成订单,并寄送至:\n${address.region?.join(' ')} ${address.detail}`,
|
||||
success: (res) => {
|
||||
if (res.confirm) {
|
||||
designToOrder(design.id)
|
||||
Taro.showToast({ title: '下单成功', icon: 'success' })
|
||||
setTimeout(() => {
|
||||
Taro.switchTab({ url: '/pages/index/index' })
|
||||
}, 1500)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const handlePickAddress = (addr: AddressItem) => {
|
||||
setAddress(addr)
|
||||
setShowAddrPicker(false)
|
||||
Taro.showToast({ title: '地址已更改', icon: 'success' })
|
||||
}
|
||||
|
||||
const hasStickers = design?.designData?.stickers && design.designData.stickers.length > 0
|
||||
const hasLegacyImage = design?.designData?.imageSrc
|
||||
|
||||
if (!design) {
|
||||
return (
|
||||
<View className={`theme-${resolvedTheme}`}>
|
||||
<View className='checkout-page'>
|
||||
<View className='page-header dashed-card mt-20' style={{ marginTop: `${safe.headerPaddingTop}px` }}>
|
||||
<Text className='page-title'>设计不存在</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
const maskStyle: any = { width: 300, height: 420 }
|
||||
if (design.designData?.category?.mask) {
|
||||
const m = design.designData.category.mask
|
||||
maskStyle.width = m.width
|
||||
maskStyle.height = m.height
|
||||
if (m.shape === 'circle') maskStyle.borderRadius = '50%'
|
||||
else maskStyle.borderRadius = m.borderRadius || 0
|
||||
}
|
||||
|
||||
return (
|
||||
<View className={`theme-${resolvedTheme}`}>
|
||||
<View className='checkout-page'>
|
||||
|
||||
<View className='page-header dashed-card mt-20' style={{ marginTop: `${safe.headerPaddingTop}px` }}>
|
||||
<View className='star-badge' />
|
||||
<View className='flex-between' style={{ width: '100%' }}>
|
||||
<Text className='back-btn' onTap={() => Taro.navigateBack()}>←</Text>
|
||||
<Text className='page-title'>设计效果确认</Text>
|
||||
<View style={{ width: '60px' }} />
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 效果展示区 */}
|
||||
<View className='checkout-canvas dashed-card mt-20'>
|
||||
<View className='star-badge' />
|
||||
<View className='canvas-area' style={maskStyle}>
|
||||
{hasStickers ? (
|
||||
design.designData.stickers.map((s: any) => (
|
||||
<Image
|
||||
key={s.id}
|
||||
className='checkout-image'
|
||||
src={s.src}
|
||||
style={{
|
||||
transform: `translate(${s.x}px, ${s.y}px) scale(${s.scale || 1})`,
|
||||
width: s.width || 200,
|
||||
height: s.height || 200
|
||||
}}
|
||||
mode='aspectFit'
|
||||
/>
|
||||
))
|
||||
) : hasLegacyImage ? (
|
||||
<Image
|
||||
className='checkout-image'
|
||||
src={design.designData.imageSrc}
|
||||
style={{
|
||||
transform: `translate(${design.designData.imagePos?.x || 0}px, ${design.designData.imagePos?.y || 0}px) scale(${design.designData.imagePos?.scale || 1})`
|
||||
}}
|
||||
mode='aspectFit'
|
||||
/>
|
||||
) : null}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 商品信息 */}
|
||||
<View className='checkout-info dashed-card mt-20'>
|
||||
<View className='star-badge' />
|
||||
<View className='info-row'>
|
||||
<Text className='info-label'>商品</Text>
|
||||
<Text className='info-value'>{design.productName}</Text>
|
||||
</View>
|
||||
<View className='info-row'>
|
||||
<Text className='info-label'>数量</Text>
|
||||
<Text className='info-value'>{design.count} 件</Text>
|
||||
</View>
|
||||
<View className='info-row'>
|
||||
<Text className='info-label'>单价</Text>
|
||||
<Text className='info-value'>¥{design.unitPrice}</Text>
|
||||
</View>
|
||||
<View className='info-row total-row'>
|
||||
<Text className='info-label'>总价</Text>
|
||||
<Text className='info-total'>¥{(design.unitPrice * design.count).toFixed(2)}</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 收货地址 */}
|
||||
<View className='checkout-address-card dashed-card mt-20'>
|
||||
<View className='star-badge' />
|
||||
<View className='checkout-address-header'>
|
||||
<Text className='info-label'>收货地址</Text>
|
||||
<View className='address-change' onTap={() => setShowAddrPicker(true)}>
|
||||
<Text className='change-text'>更改收货地址</Text>
|
||||
</View>
|
||||
</View>
|
||||
{address ? (
|
||||
<View className='checkout-address-body'>
|
||||
<View className='addr-row'>
|
||||
<Text className='addr-name'>{address.name}</Text>
|
||||
<Text className='addr-phone'>{address.phone}</Text>
|
||||
</View>
|
||||
<Text className='addr-detail'>
|
||||
{address.region?.join(' ')} {address.detail}
|
||||
</Text>
|
||||
</View>
|
||||
) : (
|
||||
<View className='checkout-address-empty' onTap={() => setShowAddrPicker(true)}>
|
||||
<Text className='empty-text'>暂无收货地址,点击添加</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* 底部操作 */}
|
||||
<View className='checkout-actions'>
|
||||
<View className='btn-outline' onTap={() => Taro.navigateBack()}>
|
||||
<Text>返回修改</Text>
|
||||
</View>
|
||||
<View className='btn-gradient' onTap={handleConfirm}>
|
||||
<Text>确认下单</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={{ height: '40px' }} />
|
||||
|
||||
{/* 地址选择底部弹窗 */}
|
||||
{showAddrPicker && (
|
||||
<View className='modal-overlay' style={{ alignItems: 'flex-end', justifyContent: 'flex-end' }}>
|
||||
<View className='addr-picker-sheet dashed-card'>
|
||||
<View className='addr-picker-header'>
|
||||
<Text className='addr-picker-title'>选择收货地址</Text>
|
||||
<Text className='addr-picker-close' onTap={() => setShowAddrPicker(false)}>✕</Text>
|
||||
</View>
|
||||
<View className='addr-picker-list'>
|
||||
{addrList.map((addr) => (
|
||||
<View
|
||||
key={addr.id}
|
||||
className={`addr-picker-item ${address?.id === addr.id ? 'active' : ''}`}
|
||||
onTap={() => handlePickAddress(addr)}
|
||||
>
|
||||
<View className='addr-picker-row'>
|
||||
<Text className='addr-picker-name'>{addr.name}</Text>
|
||||
<Text className='addr-picker-phone'>{addr.phone}</Text>
|
||||
{addr.isDefault && <Text className='addr-picker-default'>默认</Text>}
|
||||
</View>
|
||||
<Text className='addr-picker-detail'>
|
||||
{addr.region?.join(' ')} {addr.detail}
|
||||
</Text>
|
||||
</View>
|
||||
))}
|
||||
{addrList.length === 0 && (
|
||||
<View className='addr-picker-empty'>
|
||||
<Text>暂无地址,请先添加收货地址</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
<View className='addr-picker-add' onTap={() => { setShowAddrPicker(false); Taro.navigateTo({ url: '/pages/address/index' }) }}>
|
||||
<Text className='addr-picker-add-text'>+ 新建收货地址</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export default definePageConfig({
|
||||
navigationStyle: 'custom',
|
||||
navigationBarTitleText: '设计清单'
|
||||
})
|
||||
@@ -0,0 +1 @@
|
||||
{}
|
||||
@@ -0,0 +1,197 @@
|
||||
.design-page {
|
||||
padding: 0 24px 24px;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.manage-btn {
|
||||
font-size: 28px;
|
||||
color: var(--accent-blue);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* 状态 Tab */
|
||||
.status-tabs {
|
||||
margin: 20px 0;
|
||||
background: var(--bg-card);
|
||||
border: var(--line-card);
|
||||
border-radius: 16px;
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.tabs-scroll {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tab-item {
|
||||
display: inline-block;
|
||||
padding: 20px 32px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.tab-label {
|
||||
font-size: 28px;
|
||||
color: var(--text-secondary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.tab-item.active .tab-label {
|
||||
color: var(--accent-pink);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.tab-underline {
|
||||
position: absolute;
|
||||
bottom: 8px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 40px;
|
||||
height: 4px;
|
||||
background: var(--accent-pink);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
/* 批量管理工具栏 */
|
||||
.batch-bar {
|
||||
padding: 20px 24px;
|
||||
margin-bottom: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.batch-select-all {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.batch-checkbox {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
border: 3px dashed var(--text-secondary);
|
||||
flex-shrink: 0;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.batch-checkbox.checked {
|
||||
border-color: var(--accent-pink);
|
||||
background: var(--accent-pink);
|
||||
}
|
||||
|
||||
.batch-text {
|
||||
font-size: 26px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.batch-delete {
|
||||
font-size: 26px;
|
||||
color: var(--text-secondary);
|
||||
padding: 10px 24px;
|
||||
}
|
||||
|
||||
.batch-delete.active {
|
||||
color: #ff4757;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* 设计卡片列表 */
|
||||
.design-list {
|
||||
padding-bottom: 24px;
|
||||
}
|
||||
|
||||
.design-card {
|
||||
margin-bottom: 20px;
|
||||
padding: 28px;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.design-card.selected {
|
||||
border-color: var(--accent-pink);
|
||||
background: rgba(255, 154, 158, 0.06);
|
||||
}
|
||||
|
||||
.design-body {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 20px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.design-icon {
|
||||
font-size: 56px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.design-icon-img {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.design-info {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.design-title-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.design-name {
|
||||
font-size: 32px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.design-meta {
|
||||
font-size: 24px;
|
||||
color: var(--text-secondary);
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.design-total {
|
||||
font-size: 26px;
|
||||
font-weight: 600;
|
||||
color: var(--accent-pink);
|
||||
}
|
||||
|
||||
.design-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.design-btn {
|
||||
padding: 16px 40px;
|
||||
font-size: 26px;
|
||||
}
|
||||
|
||||
/* 空状态 */
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 100px 40px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.empty-icon-img {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.empty-text {
|
||||
font-size: 32px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.empty-sub {
|
||||
font-size: 26px;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
import { View, Text, ScrollView, Image } from '@tarojs/components'
|
||||
import Taro from '@tarojs/taro'
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import './index.scss'
|
||||
import { getDesignList, removeDesigns, type DesignItem } from '../../utils/store'
|
||||
import { PRODUCT_ICON_MAP } from '../../utils/productConfig'
|
||||
import { useThemeContext } from '../../context/ThemeContext'
|
||||
import { useSafeArea } from '../../hooks/useSafeArea'
|
||||
import { useStatusBar } from '../../hooks/useStatusBar'
|
||||
import LoginGuard from '../../components/LoginGuard'
|
||||
|
||||
const STATUS_TABS = [
|
||||
{ code: 'all', label: '全部' },
|
||||
{ code: 'toDesign', label: '待设计' },
|
||||
{ code: 'undesigned', label: '未设计' },
|
||||
{ code: 'designing', label: '设计中' },
|
||||
{ code: 'ordered', label: '已下单' }
|
||||
]
|
||||
|
||||
const STATUS_STYLE: Record<string, { label: string; cls: string }> = {
|
||||
undesigned: { label: '未设计', cls: 'badge-pink' },
|
||||
designing: { label: '设计中', cls: 'badge-blue' },
|
||||
ordered: { label: '已下单', cls: 'badge-green' }
|
||||
}
|
||||
|
||||
export default function DesignListPage() {
|
||||
const { resolvedTheme } = useThemeContext()
|
||||
const safe = useSafeArea()
|
||||
useStatusBar(resolvedTheme)
|
||||
const [activeTab, setActiveTab] = useState('all')
|
||||
const [list, setList] = useState<DesignItem[]>([])
|
||||
const [managing, setManaging] = useState(false)
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set())
|
||||
|
||||
const load = useCallback(() => {
|
||||
setList(getDesignList())
|
||||
}, [])
|
||||
|
||||
const init = useCallback(() => {
|
||||
const filter = Taro.getStorageSync('designList:filter')
|
||||
if (filter && STATUS_TABS.some(t => t.code === filter)) {
|
||||
setActiveTab(filter)
|
||||
Taro.removeStorageSync('designList:filter')
|
||||
}
|
||||
load()
|
||||
}, [load])
|
||||
|
||||
useEffect(() => {
|
||||
const page = Taro.getCurrentInstance().page
|
||||
if (page) {
|
||||
const orig = page.onShow
|
||||
page.onShow = function () {
|
||||
init()
|
||||
if (orig) orig.apply(this)
|
||||
}
|
||||
}
|
||||
init()
|
||||
}, [init])
|
||||
|
||||
const filtered = activeTab === 'all'
|
||||
? list
|
||||
: activeTab === 'toDesign'
|
||||
? list.filter(d => d.status === 'undesigned' || d.status === 'designing')
|
||||
: list.filter(d => d.status === activeTab)
|
||||
|
||||
const goDesign = (item: DesignItem) => {
|
||||
if (item.status === 'ordered') {
|
||||
Taro.showToast({ title: '该商品已下单,请查看订单', icon: 'none' })
|
||||
return
|
||||
}
|
||||
Taro.navigateTo({
|
||||
url: `/pages/diy/index?source=designList&designId=${item.id}`
|
||||
})
|
||||
}
|
||||
|
||||
const toggleSelect = (id: string) => {
|
||||
const next = new Set(selected)
|
||||
if (next.has(id)) next.delete(id)
|
||||
else next.add(id)
|
||||
setSelected(next)
|
||||
}
|
||||
|
||||
const toggleSelectAll = () => {
|
||||
if (selected.size === filtered.length) {
|
||||
setSelected(new Set())
|
||||
} else {
|
||||
setSelected(new Set(filtered.map(i => i.id)))
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = () => {
|
||||
if (selected.size === 0) return
|
||||
Taro.showModal({
|
||||
title: '确认删除',
|
||||
content: `确定删除选中的 ${selected.size} 个条目吗?`,
|
||||
success: (res) => {
|
||||
if (res.confirm) {
|
||||
removeDesigns(Array.from(selected))
|
||||
setSelected(new Set())
|
||||
setManaging(false)
|
||||
load()
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const handleManage = () => {
|
||||
if (managing) {
|
||||
setManaging(false)
|
||||
setSelected(new Set())
|
||||
} else {
|
||||
setManaging(true)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<View className={`theme-${resolvedTheme}`}>
|
||||
<LoginGuard>
|
||||
<View className='design-page'>
|
||||
<View className='page-header dashed-card mt-20' style={{ marginTop: `${safe.headerPaddingTop}px` }}>
|
||||
<View className='flex-between' style={{ width: '100%' }}>
|
||||
<Text className='back-btn' onTap={() => Taro.navigateBack()}>←</Text>
|
||||
<Text className='page-title'>我的设计清单</Text>
|
||||
<Text className='manage-btn' onTap={handleManage}>
|
||||
{managing ? '完成' : '管理'}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 状态筛选 */}
|
||||
<View className='status-tabs'>
|
||||
<ScrollView className='tabs-scroll' scrollX>
|
||||
{STATUS_TABS.map(tab => (
|
||||
<View
|
||||
key={tab.code}
|
||||
className={`tab-item ${activeTab === tab.code ? 'active' : ''}`}
|
||||
onTap={() => setActiveTab(tab.code)}
|
||||
>
|
||||
<Text className='tab-label'>{tab.label}</Text>
|
||||
{activeTab === tab.code && <View className='tab-underline' />}
|
||||
</View>
|
||||
))}
|
||||
</ScrollView>
|
||||
</View>
|
||||
|
||||
{/* 批量管理工具栏 */}
|
||||
{managing && (
|
||||
<View className='batch-bar dashed-card'>
|
||||
<View className='flex-between' style={{ width: '100%' }}>
|
||||
<View className='batch-select-all' onTap={toggleSelectAll}>
|
||||
<View className={`batch-checkbox ${selected.size === filtered.length && filtered.length > 0 ? 'checked' : ''}`} />
|
||||
<Text className='batch-text'>全选 ({selected.size}/{filtered.length})</Text>
|
||||
</View>
|
||||
<View className={`batch-delete ${selected.size > 0 ? 'active' : ''}`} onTap={handleDelete}>
|
||||
<Text>删除</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* 列表 */}
|
||||
<ScrollView className='design-list' scrollY>
|
||||
{filtered.map(item => {
|
||||
const style = STATUS_STYLE[item.status]
|
||||
const iconSrc = item.productIcon?.startsWith('/icon/')
|
||||
? item.productIcon
|
||||
: PRODUCT_ICON_MAP[item.productId] || '/icon/四角星.png'
|
||||
const isSelected = selected.has(item.id)
|
||||
return (
|
||||
<View
|
||||
key={item.id}
|
||||
className={`design-card dashed-card ${isSelected ? 'selected' : ''}`}
|
||||
onTap={() => managing ? toggleSelect(item.id) : undefined}
|
||||
>
|
||||
<View className='design-body'>
|
||||
{managing && (
|
||||
<View className={`batch-checkbox ${isSelected ? 'checked' : ''}`} />
|
||||
)}
|
||||
<Image className='design-icon-img' src={iconSrc} mode='aspectFit' />
|
||||
<View className='design-info'>
|
||||
<View className='design-title-row'>
|
||||
<Text className='design-name'>{item.productName}</Text>
|
||||
<Text className={`badge ${style.cls}`}>{style.label}</Text>
|
||||
</View>
|
||||
<Text className='design-meta'>数量: {item.count} 件 | 单价: ¥{item.unitPrice}</Text>
|
||||
<Text className='design-total'>小计: ¥{(item.unitPrice * item.count).toFixed(2)}</Text>
|
||||
</View>
|
||||
</View>
|
||||
{!managing && (
|
||||
<View className='design-actions'>
|
||||
{item.status !== 'ordered' && (
|
||||
<View className='btn-gradient design-btn' onTap={() => goDesign(item)}>
|
||||
<Text>{item.status === 'undesigned' ? '开始设计' : '继续设计'}</Text>
|
||||
</View>
|
||||
)}
|
||||
{item.status === 'ordered' && (
|
||||
<View className='btn-outline design-btn' onTap={() => Taro.switchTab({ url: '/pages/orders/index' })}>
|
||||
<Text>查看订单</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
)
|
||||
})}
|
||||
|
||||
{filtered.length === 0 && (
|
||||
<View className='empty-state'>
|
||||
<Image className='empty-icon-img' src='/icon/调色盘.png' mode='aspectFit' />
|
||||
<Text className='empty-text'>还没有设计清单</Text>
|
||||
<Text className='empty-sub'>去首页逛逛,找到喜欢的定制品</Text>
|
||||
<View className='btn-gradient' onTap={() => Taro.switchTab({ url: '/pages/index/index' })}>
|
||||
<Text>去逛逛</Text>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
</ScrollView>
|
||||
|
||||
<View style={{ height: '40px' }} />
|
||||
</View>
|
||||
</LoginGuard>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export default definePageConfig({
|
||||
navigationStyle: 'custom',
|
||||
navigationBarTitleText: 'DIY工作台'
|
||||
})
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"usingComponents": {}
|
||||
}
|
||||
@@ -0,0 +1,317 @@
|
||||
.diy-page {
|
||||
padding: 0 24px 24px;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
/* 画布区域 */
|
||||
.canvas-wrapper {
|
||||
padding: 40px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.canvas-area {
|
||||
position: relative;
|
||||
background: linear-gradient(135deg, rgba(255, 154, 158, 0.08) 0%, rgba(160, 196, 255, 0.08) 100%);
|
||||
overflow: hidden;
|
||||
border-radius: 16px;
|
||||
min-width: 200px;
|
||||
min-height: 200px;
|
||||
max-width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.mask-border {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
border: 3px dashed var(--accent-blue);
|
||||
pointer-events: none;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.canvas-image {
|
||||
position: absolute;
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
z-index: 5;
|
||||
border: 2px solid transparent;
|
||||
}
|
||||
|
||||
.canvas-image.active {
|
||||
border-color: var(--accent-pink);
|
||||
}
|
||||
|
||||
.canvas-image.overlap {
|
||||
border-color: #ff4757;
|
||||
box-shadow: 0 0 12px rgba(255, 71, 87, 0.5);
|
||||
}
|
||||
|
||||
/* 贴纸面板 */
|
||||
.sticker-panel {
|
||||
padding: 28px;
|
||||
}
|
||||
|
||||
.sticker-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.sticker-thumb {
|
||||
position: relative;
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
border: 2px dashed var(--line-star);
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
background: var(--bg-input);
|
||||
}
|
||||
|
||||
.sticker-thumb.active {
|
||||
border-color: var(--accent-pink);
|
||||
border-width: 3px;
|
||||
}
|
||||
|
||||
.sticker-thumb.overlap {
|
||||
border-color: #ff4757;
|
||||
}
|
||||
|
||||
.sticker-thumb-img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.sticker-del {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
right: 4px;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
background: rgba(255, 71, 87, 0.85);
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.del-icon {
|
||||
color: #fff;
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.sticker-add-btn {
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
border: 2px dashed var(--line-star);
|
||||
border-radius: 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--bg-input);
|
||||
}
|
||||
|
||||
.add-icon {
|
||||
font-size: 48px;
|
||||
color: var(--accent-pink);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.add-label {
|
||||
font-size: 22px;
|
||||
color: var(--text-secondary);
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.overlap-hint {
|
||||
display: block;
|
||||
margin-top: 16px;
|
||||
font-size: 26px;
|
||||
color: #ff4757;
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* 工具栏 */
|
||||
.toolbar {
|
||||
padding: 28px 32px;
|
||||
}
|
||||
|
||||
.tool-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.tool-row:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.tool-label {
|
||||
font-size: 28px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.tool-btn {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
background: linear-gradient(135deg, rgba(255, 154, 158, 0.08) 0%, rgba(160, 196, 255, 0.08) 100%);
|
||||
border: 2px dashed var(--accent-blue);
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 32px;
|
||||
color: var(--text-primary);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.tool-value {
|
||||
font-size: 28px;
|
||||
font-weight: 600;
|
||||
color: var(--accent-pink);
|
||||
min-width: 80px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.tool-hint {
|
||||
font-size: 24px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* 预览弹窗 */
|
||||
.preview-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
padding: 40px;
|
||||
}
|
||||
|
||||
.preview-card {
|
||||
background: var(--bg-card);
|
||||
width: 100%;
|
||||
max-height: 80vh;
|
||||
overflow-y: auto;
|
||||
padding: 40px;
|
||||
}
|
||||
|
||||
.preview-title {
|
||||
font-size: 36px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
display: block;
|
||||
margin-bottom: 24px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.preview-canvas {
|
||||
background: linear-gradient(135deg, rgba(255, 154, 158, 0.08) 0%, rgba(160, 196, 255, 0.08) 100%);
|
||||
border-radius: 16px;
|
||||
overflow: hidden;
|
||||
margin: 0 auto 24px;
|
||||
border: 3px dashed var(--accent-blue);
|
||||
position: relative;
|
||||
min-width: 200px;
|
||||
min-height: 200px;
|
||||
max-width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.preview-image {
|
||||
position: absolute;
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
z-index: 5;
|
||||
}
|
||||
|
||||
.preview-image.overlap {
|
||||
border: 2px solid #ff4757;
|
||||
}
|
||||
|
||||
.product-info {
|
||||
text-align: center;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.product-name {
|
||||
font-size: 30px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.product-size {
|
||||
font-size: 24px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.preview-actions {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.preview-actions .btn-outline,
|
||||
.preview-actions .btn-gradient {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.preview-actions .btn-gradient.disabled {
|
||||
opacity: 0.5;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* 常驻编辑按钮 */
|
||||
.edit-btn-bar {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.edit-btn {
|
||||
padding: 16px 48px;
|
||||
border-radius: 50px;
|
||||
font-size: 28px;
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
background: var(--bg-input);
|
||||
color: var(--text-secondary);
|
||||
border: 2px dashed var(--text-muted);
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.edit-btn.active {
|
||||
background: linear-gradient(135deg, rgba(255, 154, 158, 0.12) 0%, rgba(160, 196, 255, 0.12) 100%);
|
||||
color: var(--accent-pink);
|
||||
border-color: var(--accent-pink);
|
||||
}
|
||||
|
||||
.edit-btn.disabled {
|
||||
opacity: 0.5;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* 按钮 */
|
||||
.action-btns {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.mt-20 {
|
||||
margin-top: 20px;
|
||||
}
|
||||
@@ -0,0 +1,411 @@
|
||||
import { View, Text, Image } from '@tarojs/components'
|
||||
import Taro from '@tarojs/taro'
|
||||
import { useState, useEffect } from 'react'
|
||||
import './index.scss'
|
||||
import { getProductById } from '../../utils/productConfig'
|
||||
import { getDesignList, setDesignList, updateDesign, type DesignItem, type StickerItem } from '../../utils/store'
|
||||
import { useThemeContext } from '../../context/ThemeContext'
|
||||
import { useSafeArea } from '../../hooks/useSafeArea'
|
||||
import { useStatusBar } from '../../hooks/useStatusBar'
|
||||
|
||||
export default function DIYPage() {
|
||||
const { theme, resolvedTheme } = useThemeContext()
|
||||
const safe = useSafeArea()
|
||||
useStatusBar(resolvedTheme)
|
||||
const [category, setCategory] = useState<any>(null)
|
||||
const [step, setStep] = useState(1)
|
||||
const [designId, setDesignId] = useState('')
|
||||
const [quantity, setQuantity] = useState(1)
|
||||
const [stickers, setStickers] = useState<StickerItem[]>([])
|
||||
const [activeStickerId, setActiveStickerId] = useState<string | null>(null)
|
||||
const [isDragging, setIsDragging] = useState(false)
|
||||
const [startPos, setStartPos] = useState({ x: 0, y: 0 })
|
||||
const [previewMode, setPreviewMode] = useState(false)
|
||||
const [hasOverlap, setHasOverlap] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const params = Taro.getCurrentInstance().router?.params
|
||||
const source = params?.source
|
||||
const productId = params?.productId || params?.category
|
||||
|
||||
if (source === 'product' && productId) {
|
||||
const product = getProductById(productId)
|
||||
if (product) {
|
||||
setCategory(product)
|
||||
setQuantity(Number(params?.quantity) || 1)
|
||||
const newDesign: DesignItem = {
|
||||
id: 'DSG' + Date.now(),
|
||||
productId: product.id,
|
||||
productName: product.name,
|
||||
productIcon: product.icon,
|
||||
unitPrice: product.price,
|
||||
count: Number(params?.quantity) || 1,
|
||||
status: 'designing',
|
||||
createdAt: new Date().toISOString().slice(0, 10)
|
||||
}
|
||||
const list = getDesignList()
|
||||
setDesignList([...list, newDesign])
|
||||
setDesignId(newDesign.id)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (source === 'designList' && params?.designId) {
|
||||
const dId = params.designId
|
||||
const list = getDesignList()
|
||||
const design = list.find(d => d.id === dId)
|
||||
if (design) {
|
||||
setDesignId(dId)
|
||||
setQuantity(design.count)
|
||||
const product = getProductById(design.productId)
|
||||
if (product) setCategory(product)
|
||||
if (design.designData?.stickers) {
|
||||
setStickers(design.designData.stickers)
|
||||
} else if (design.designData?.imageSrc) {
|
||||
// 兼容旧版数据
|
||||
const s: StickerItem = {
|
||||
id: 'legacy_' + Date.now(),
|
||||
src: design.designData.imageSrc,
|
||||
x: design.designData.imagePos?.x || 0,
|
||||
y: design.designData.imagePos?.y || 0,
|
||||
scale: design.designData.imagePos?.scale || 1,
|
||||
width: 200,
|
||||
height: 200,
|
||||
isOverlapping: false
|
||||
}
|
||||
setStickers([s])
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// 默认情况(从首页 old category 参数兼容)
|
||||
if (productId) {
|
||||
const found = getProductById(productId)
|
||||
if (found) {
|
||||
setCategory(found)
|
||||
const newDesign: DesignItem = {
|
||||
id: 'DSG' + Date.now(),
|
||||
productId: found.id,
|
||||
productName: found.name,
|
||||
productIcon: found.icon,
|
||||
unitPrice: found.price,
|
||||
count: 1,
|
||||
status: 'designing',
|
||||
createdAt: new Date().toISOString().slice(0, 10)
|
||||
}
|
||||
const list = getDesignList()
|
||||
setDesignList([...list, newDesign])
|
||||
setDesignId(newDesign.id)
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
// 矩形碰撞检测
|
||||
const checkOverlap = (list: StickerItem[]) => {
|
||||
const newList = list.map(s => ({ ...s, isOverlapping: false }))
|
||||
for (let i = 0; i < newList.length; i++) {
|
||||
for (let j = i + 1; j < newList.length; j++) {
|
||||
const a = newList[i]
|
||||
const b = newList[j]
|
||||
const aw = a.width * a.scale
|
||||
const ah = a.height * a.scale
|
||||
const bw = b.width * b.scale
|
||||
const bh = b.height * b.scale
|
||||
if (
|
||||
a.x < b.x + bw &&
|
||||
a.x + aw > b.x &&
|
||||
a.y < b.y + bh &&
|
||||
a.y + ah > b.y
|
||||
) {
|
||||
newList[i].isOverlapping = true
|
||||
newList[j].isOverlapping = true
|
||||
}
|
||||
}
|
||||
}
|
||||
const overlapAny = newList.some(s => s.isOverlapping)
|
||||
setHasOverlap(overlapAny)
|
||||
return newList
|
||||
}
|
||||
|
||||
const addSticker = () => {
|
||||
Taro.chooseImage({
|
||||
count: 1,
|
||||
sizeType: ['compressed'],
|
||||
sourceType: ['album', 'camera'],
|
||||
success: (res) => {
|
||||
const src = res.tempFilePaths[0]
|
||||
// 获取图片尺寸用于碰撞检测
|
||||
Taro.getImageInfo({
|
||||
src,
|
||||
success: (info) => {
|
||||
const newSticker: StickerItem = {
|
||||
id: 'stk_' + Date.now(),
|
||||
src,
|
||||
x: 0,
|
||||
y: 0,
|
||||
scale: 1,
|
||||
width: info.width,
|
||||
height: info.height,
|
||||
isOverlapping: false
|
||||
}
|
||||
const next = [...stickers, newSticker]
|
||||
const checked = checkOverlap(next)
|
||||
setStickers(checked)
|
||||
setActiveStickerId(newSticker.id)
|
||||
},
|
||||
fail: () => {
|
||||
// fallback 尺寸
|
||||
const newSticker: StickerItem = {
|
||||
id: 'stk_' + Date.now(),
|
||||
src,
|
||||
x: 0,
|
||||
y: 0,
|
||||
scale: 1,
|
||||
width: 200,
|
||||
height: 200,
|
||||
isOverlapping: false
|
||||
}
|
||||
const next = [...stickers, newSticker]
|
||||
const checked = checkOverlap(next)
|
||||
setStickers(checked)
|
||||
setActiveStickerId(newSticker.id)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const deleteSticker = (id: string) => {
|
||||
const next = stickers.filter(s => s.id !== id)
|
||||
const checked = checkOverlap(next)
|
||||
setStickers(checked)
|
||||
if (activeStickerId === id) setActiveStickerId(null)
|
||||
}
|
||||
|
||||
const handleStickerTouchStart = (e: any, id: string) => {
|
||||
const touch = e.touches[0]
|
||||
const s = stickers.find(x => x.id === id)
|
||||
if (!s) return
|
||||
setActiveStickerId(id)
|
||||
setIsDragging(true)
|
||||
setStartPos({ x: touch.clientX - s.x, y: touch.clientY - s.y })
|
||||
}
|
||||
|
||||
const handleStickerTouchMove = (e: any) => {
|
||||
if (!isDragging || !activeStickerId) return
|
||||
const touch = e.touches[0]
|
||||
const next = stickers.map(s => {
|
||||
if (s.id !== activeStickerId) return s
|
||||
return { ...s, x: touch.clientX - startPos.x, y: touch.clientY - startPos.y }
|
||||
})
|
||||
const checked = checkOverlap(next)
|
||||
setStickers(checked)
|
||||
}
|
||||
|
||||
const handleTouchEnd = () => setIsDragging(false)
|
||||
|
||||
const handleEditSticker = () => {
|
||||
if (!activeStickerId || !designId) return
|
||||
Taro.navigateTo({
|
||||
url: `/pages/diy/stickerEdit/index?designId=${designId}&stickerId=${activeStickerId}`
|
||||
})
|
||||
}
|
||||
|
||||
const handleScale = (id: string, delta: number) => {
|
||||
const next = stickers.map(s => {
|
||||
if (s.id !== id) return s
|
||||
return { ...s, scale: Math.max(0.3, Math.min(3, s.scale + delta)) }
|
||||
})
|
||||
const checked = checkOverlap(next)
|
||||
setStickers(checked)
|
||||
}
|
||||
|
||||
const goBack = () => {
|
||||
Taro.navigateBack()
|
||||
}
|
||||
|
||||
const getMaskStyle = () => {
|
||||
if (!category) return { width: 300, height: 420 }
|
||||
const base: any = { width: category.mask.width, height: category.mask.height }
|
||||
if (category.mask.shape === 'rect') {
|
||||
base.borderRadius = category.mask.borderRadius || 0
|
||||
}
|
||||
if (category.mask.shape === 'circle') {
|
||||
base.borderRadius = '50%'
|
||||
}
|
||||
return base
|
||||
}
|
||||
|
||||
const handleComplete = () => {
|
||||
if (hasOverlap) {
|
||||
Taro.showToast({ title: '贴纸不能重叠', icon: 'none' })
|
||||
return
|
||||
}
|
||||
if (designId) {
|
||||
updateDesign(designId, {
|
||||
designData: {
|
||||
stickers,
|
||||
category
|
||||
}
|
||||
})
|
||||
}
|
||||
setPreviewMode(false)
|
||||
Taro.navigateTo({ url: `/pages/checkout/index?designId=${designId}` })
|
||||
}
|
||||
|
||||
if (!category) {
|
||||
return (
|
||||
<View className={`theme-${resolvedTheme}`}>
|
||||
<View className='diy-page'>
|
||||
<Text className='page-title'>加载中...</Text>
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<View className={`theme-${resolvedTheme}`}>
|
||||
<View className='diy-page'>
|
||||
|
||||
<View className='page-header dashed-card mt-20' style={{ marginTop: `${safe.headerPaddingTop}px` }}>
|
||||
<View className='star-badge' />
|
||||
<View className='flex-between' style={{ width: '100%' }}>
|
||||
<Text className='back-btn' onTap={goBack}>←</Text>
|
||||
<Text className='page-title'>设计工作台</Text>
|
||||
<View style={{ width: '60px' }} />
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 画布区域 — catchtouchmove 阻止事件冒泡导致页面滚动 */}
|
||||
<View className='canvas-wrapper dashed-card mt-20' catchMove>
|
||||
<View className='star-badge' />
|
||||
<View className='canvas-area' style={getMaskStyle()}>
|
||||
{stickers.map(s => (
|
||||
<Image
|
||||
key={s.id}
|
||||
className={`canvas-image ${s.isOverlapping ? 'overlap' : ''} ${activeStickerId === s.id ? 'active' : ''}`}
|
||||
src={s.src}
|
||||
style={{
|
||||
transform: `translate(${s.x}px, ${s.y}px) scale(${s.scale})`,
|
||||
opacity: isDragging && activeStickerId === s.id ? 0.8 : 1,
|
||||
zIndex: activeStickerId === s.id ? 10 : 5,
|
||||
width: s.width || 200,
|
||||
height: s.height || 200
|
||||
}}
|
||||
onTouchStart={(e) => handleStickerTouchStart(e, s.id)}
|
||||
onTouchMove={handleStickerTouchMove}
|
||||
onTouchEnd={handleTouchEnd}
|
||||
onTap={() => setActiveStickerId(s.id)}
|
||||
mode='aspectFit'
|
||||
/>
|
||||
))}
|
||||
<View className='mask-border' style={getMaskStyle()} />
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 贴纸列表与控制 */}
|
||||
<View className='sticker-panel dashed-card mt-20'>
|
||||
<View className='star-badge' />
|
||||
<Text className='section-title'>贴纸</Text>
|
||||
<View className='sticker-list'>
|
||||
{stickers.map(s => (
|
||||
<View key={s.id} className={`sticker-thumb ${s.isOverlapping ? 'overlap' : ''} ${activeStickerId === s.id ? 'active' : ''}`}>
|
||||
<Image src={s.src} className='sticker-thumb-img' mode='aspectFit' onTap={() => setActiveStickerId(s.id)} />
|
||||
<View className='sticker-del' onTap={() => deleteSticker(s.id)}>
|
||||
<Text className='del-icon'>×</Text>
|
||||
</View>
|
||||
</View>
|
||||
))}
|
||||
<View className='sticker-add-btn' onTap={addSticker}>
|
||||
<Text className='add-icon'>+</Text>
|
||||
<Text className='add-label'>添加</Text>
|
||||
</View>
|
||||
</View>
|
||||
{hasOverlap && <Text className='overlap-hint'>⚠️ 有贴纸重叠,请调整位置</Text>}
|
||||
</View>
|
||||
|
||||
{/* 控制工具栏 */}
|
||||
{activeStickerId && (
|
||||
<View className='toolbar dashed-card mt-20'>
|
||||
<View className='star-badge' />
|
||||
<View className='tool-row'>
|
||||
<Text className='tool-label'>缩放</Text>
|
||||
<View className='flex-center' style={{ gap: '20px' }}>
|
||||
<View className='tool-btn' onTap={() => handleScale(activeStickerId, -0.1)}>-</View>
|
||||
<Text className='tool-value'>
|
||||
{Math.round((stickers.find(s => s.id === activeStickerId)?.scale || 1) * 100)}%
|
||||
</Text>
|
||||
<View className='tool-btn' onTap={() => handleScale(activeStickerId, 0.1)}>+</View>
|
||||
</View>
|
||||
</View>
|
||||
<View className='tool-row'>
|
||||
<Text className='tool-label'>提示</Text>
|
||||
<Text className='tool-hint'>拖动调整位置,贴纸不可重叠</Text>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* 常驻编辑按钮 */}
|
||||
<View className='edit-btn-bar mt-20'>
|
||||
<View
|
||||
className={`edit-btn ${activeStickerId ? 'active' : 'disabled'}`}
|
||||
onTap={handleEditSticker}
|
||||
>
|
||||
<Text>编辑</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 底部操作按钮 */}
|
||||
<View className='action-btns mt-20'>
|
||||
<View className='btn-gradient' onTap={() => setPreviewMode(true)}>
|
||||
<Text>预览效果</Text>
|
||||
</View>
|
||||
<View className='btn-outline' onTap={addSticker}>
|
||||
<Text>添加贴纸</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 预览弹层 */}
|
||||
{previewMode && (
|
||||
<View className='preview-overlay'>
|
||||
<View className='preview-card dashed-card'>
|
||||
<View className='star-badge' />
|
||||
<Text className='preview-title'>确认效果</Text>
|
||||
<View className='preview-canvas' style={getMaskStyle()}>
|
||||
{stickers.map(s => (
|
||||
<Image
|
||||
key={s.id}
|
||||
className={`preview-image ${s.isOverlapping ? 'overlap' : ''}`}
|
||||
src={s.src}
|
||||
style={{
|
||||
transform: `translate(${s.x}px, ${s.y}px) scale(${s.scale})`,
|
||||
width: s.width || 200,
|
||||
height: s.height || 200
|
||||
}}
|
||||
mode='aspectFit'
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
<View className='product-info'>
|
||||
<Text className='product-name'>{category.name}</Text>
|
||||
<Text className='product-size'>画布尺寸: {category.mask.width} × {category.mask.height} px</Text>
|
||||
{hasOverlap && <Text className='overlap-hint'>贴纸有重叠,不可提交</Text>}
|
||||
</View>
|
||||
<View className='preview-actions'>
|
||||
<View className='btn-outline' onTap={() => setPreviewMode(false)}>
|
||||
<Text>返回修改</Text>
|
||||
</View>
|
||||
<View className={`btn-gradient ${hasOverlap ? 'disabled' : ''}`} onTap={handleComplete}>
|
||||
<Text>确认完成</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
|
||||
<View style={{ height: '40px' }} />
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export default definePageConfig({
|
||||
navigationStyle: 'custom',
|
||||
navigationBarTitleText: '编辑贴纸'
|
||||
})
|
||||
@@ -0,0 +1,184 @@
|
||||
.sticker-edit-page {
|
||||
padding: 0 24px 24px;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.edit-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: calc(env(safe-area-inset-top, 0px) + 24px) 0 24px;
|
||||
}
|
||||
|
||||
.edit-back {
|
||||
font-size: 36px;
|
||||
color: var(--text-primary);
|
||||
width: 60px;
|
||||
}
|
||||
|
||||
.edit-title {
|
||||
font-size: 34px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
text-align: center;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.edit-save {
|
||||
font-size: 28px;
|
||||
color: var(--accent-pink);
|
||||
font-weight: 600;
|
||||
width: 60px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
/* 画布区域 */
|
||||
.edit-canvas-wrap {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 20px 0;
|
||||
min-height: 400px;
|
||||
}
|
||||
|
||||
.edit-canvas {
|
||||
width: 100%;
|
||||
height: 500px;
|
||||
background: var(--bg-input);
|
||||
border-radius: 16px;
|
||||
}
|
||||
|
||||
/* 工具面板 */
|
||||
.edit-tools {
|
||||
padding: 32px;
|
||||
}
|
||||
|
||||
.edit-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.edit-row:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.edit-label {
|
||||
font-size: 28px;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
width: 120px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.slider-wrap {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.slider-track {
|
||||
flex: 1;
|
||||
height: 8px;
|
||||
border-radius: 4px;
|
||||
background: var(--bg-input);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.slider-fill {
|
||||
height: 100%;
|
||||
border-radius: 4px;
|
||||
background: linear-gradient(90deg, #ff9a9e, #fecfef);
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
}
|
||||
|
||||
.slider-thumb {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
background: #fff;
|
||||
border: 4px solid var(--accent-pink);
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.15);
|
||||
}
|
||||
|
||||
.slider-value {
|
||||
font-size: 24px;
|
||||
color: var(--text-secondary);
|
||||
min-width: 60px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
/* 快速操作按钮 */
|
||||
.edit-quick-actions {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.quick-btn {
|
||||
padding: 16px 28px;
|
||||
border-radius: 40px;
|
||||
font-size: 24px;
|
||||
font-weight: 500;
|
||||
background: var(--bg-input);
|
||||
color: var(--text-primary);
|
||||
border: 2px dashed var(--line-star);
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.quick-btn:active {
|
||||
opacity: 0.8;
|
||||
transform: scale(0.97);
|
||||
}
|
||||
|
||||
.quick-btn.active {
|
||||
background: rgba(255, 154, 158, 0.12);
|
||||
border-color: var(--accent-pink);
|
||||
color: var(--accent-pink);
|
||||
}
|
||||
|
||||
/* 底部操作 */
|
||||
.edit-footer {
|
||||
padding: 20px 0 calc(20px + env(safe-area-inset-bottom));
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.edit-footer .btn-gradient,
|
||||
.edit-footer .btn-outline {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
padding: 24px 0;
|
||||
font-size: 28px;
|
||||
}
|
||||
|
||||
/* 加载遮罩 */
|
||||
.edit-loading {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0,0,0,0.4);
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.edit-loading-text {
|
||||
font-size: 28px;
|
||||
color: #fff;
|
||||
margin-top: 20px;
|
||||
}
|
||||
@@ -0,0 +1,420 @@
|
||||
import { View, Text, Canvas, Image } from '@tarojs/components'
|
||||
import Taro, { createCanvasContext, canvasToTempFilePath } from '@tarojs/taro'
|
||||
import { useState, useEffect, useRef, useCallback } from 'react'
|
||||
import './index.scss'
|
||||
import { getProductById } from '../../../utils/productConfig'
|
||||
import { getDesignList, setDesignList, updateDesign, type DesignItem, type StickerItem } from '../../../utils/store'
|
||||
import { useThemeContext } from '../../../context/ThemeContext'
|
||||
import { useSafeArea } from '../../../hooks/useSafeArea'
|
||||
import { useStatusBar } from '../../../hooks/useStatusBar'
|
||||
|
||||
/* ============================================================
|
||||
使用 Canvas 实现贴纸编辑(亮度/色相/线稿)
|
||||
============================================================ */
|
||||
|
||||
const clamp = (v: number, min: number, max: number) => Math.min(Math.max(v, min), max)
|
||||
|
||||
/** 拖动手势滑块组件 */
|
||||
interface SliderProps {
|
||||
value: number
|
||||
min: number
|
||||
max: number
|
||||
step?: number
|
||||
onChange: (val: number) => void
|
||||
showValue?: boolean
|
||||
format?: (v: number) => string
|
||||
}
|
||||
|
||||
function TouchSlider({ value, min, max, step = 1, onChange, format }: SliderProps) {
|
||||
const trackRef = useRef<any>(null)
|
||||
const [dragging, setDragging] = useState(false)
|
||||
|
||||
const computedFromClientX = (clientX: number) => {
|
||||
if (!trackRef.current) return value
|
||||
const query = Taro.createSelectorQuery().in(trackRef.current)
|
||||
// 注意:这里需要取 track 元素的位置信息
|
||||
const rect = trackRef.current.getBoundingClientRect ? trackRef.current.getBoundingClientRect() : { left: 0, width: 300 }
|
||||
const ratio = clamp((clientX - rect.left) / rect.width, 0, 1)
|
||||
let raw = min + (max - min) * ratio
|
||||
if (step > 0) {
|
||||
raw = Math.round(raw / step) * step
|
||||
}
|
||||
return clamp(raw, min, max)
|
||||
}
|
||||
|
||||
const handleTouchStart = (e: any) => {
|
||||
setDragging(true)
|
||||
const x = e.touches?.[0]?.clientX ?? 0
|
||||
onChange(computedFromClientX(x))
|
||||
}
|
||||
|
||||
const handleTouchMove = (e: any) => {
|
||||
if (!dragging) return
|
||||
const x = e.touches?.[0]?.clientX ?? 0
|
||||
onChange(computedFromClientX(x))
|
||||
}
|
||||
|
||||
const handleTouchEnd = () => {
|
||||
setDragging(false)
|
||||
}
|
||||
|
||||
const pct = `${((value - min) / (max - min)) * 100}%`
|
||||
|
||||
return (
|
||||
<View className='slider-wrap'>
|
||||
<View
|
||||
className='slider-track'
|
||||
ref={trackRef}
|
||||
onTouchStart={handleTouchStart}
|
||||
onTouchMove={handleTouchMove}
|
||||
onTouchEnd={handleTouchEnd}
|
||||
>
|
||||
<View className='slider-fill' style={{ width: pct }} />
|
||||
<View className={`slider-thumb ${dragging ? 'dragging' : ''}`} style={{ left: pct }} />
|
||||
</View>
|
||||
<Text className='slider-value'>{format ? format(value) : value}</Text>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
export default function StickerEditPage() {
|
||||
const { resolvedTheme } = useThemeContext()
|
||||
const safe = useSafeArea()
|
||||
useStatusBar(resolvedTheme)
|
||||
const [designId, setDesignId] = useState('')
|
||||
const [stickerId, setStickerId] = useState('')
|
||||
const [sticker, setSticker] = useState<StickerItem | null>(null)
|
||||
|
||||
// 编辑参数
|
||||
const [brightness, setBrightness] = useState(0)
|
||||
const [hue, setHue] = useState(0)
|
||||
const [contrast, setContrast] = useState(0)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [canvasReady, setCanvasReady] = useState(false)
|
||||
|
||||
const canvasRef = useRef<any>(null)
|
||||
const ctxRef = useRef<any>(null)
|
||||
const canvasNodeRef = useRef<any>(null)
|
||||
const canvasSizeRef = useRef<{width:number, height:number}>({width:0,height:0})
|
||||
|
||||
// 使用 nextTick + 重试初始化 Canvas(小程序 2D)
|
||||
const initCanvas = useCallback((st: StickerItem) => {
|
||||
if (canvasReady) return
|
||||
Taro.nextTick(() => {
|
||||
const query = Taro.createSelectorQuery()
|
||||
query.select('#editCanvas')
|
||||
.fields({ node: true, size: true })
|
||||
.exec((res: any) => {
|
||||
const canvas = res?.[0]?.node
|
||||
if (!canvas) {
|
||||
setTimeout(() => initCanvas(st), 300)
|
||||
return
|
||||
}
|
||||
const ctx = canvas.getContext('2d')
|
||||
let cw = res[0]?.width || 300
|
||||
let ch = res[0]?.height || 400
|
||||
if (cw <= 0) cw = 300
|
||||
if (ch <= 0) ch = 400
|
||||
const dpr = Taro.getSystemInfoSync().pixelRatio || 1
|
||||
canvas.width = Math.round(cw * dpr)
|
||||
canvas.height = Math.round(ch * dpr)
|
||||
ctx.scale(dpr, dpr)
|
||||
canvasNodeRef.current = canvas
|
||||
ctxRef.current = ctx
|
||||
canvasSizeRef.current = { width: cw, height: ch }
|
||||
setCanvasReady(true)
|
||||
drawSticker(ctx, cw, ch, st)
|
||||
})
|
||||
})
|
||||
}, [canvasReady])
|
||||
|
||||
useEffect(() => {
|
||||
const params = Taro.getCurrentInstance().router?.params
|
||||
const dId = params?.designId as string
|
||||
const sId = params?.stickerId as string
|
||||
setDesignId(dId)
|
||||
setStickerId(sId)
|
||||
|
||||
const list = getDesignList()
|
||||
const design = list.find(d => d.id === dId)
|
||||
const st = design?.designData?.stickers?.find(s => s.id === sId)
|
||||
if (st) {
|
||||
setSticker(st)
|
||||
setBrightness(st.edits?.brightness || 0)
|
||||
setHue(st.edits?.hue || 0)
|
||||
setContrast(st.edits?.contrast || 0)
|
||||
initCanvas(st)
|
||||
}
|
||||
}, [initCanvas])
|
||||
|
||||
/** canvas 上绘制带滤镜的贴纸 */
|
||||
const drawSticker = (ctx: any, cw: number, ch: number, st: StickerItem, opts?: { brightness: number; hue: number; contrast: number }) => {
|
||||
if (!ctx || !canvasNodeRef.current) return
|
||||
const canvas = canvasNodeRef.current
|
||||
|
||||
// 防护:cw/ch 为 0 时容易绘制异常
|
||||
if (!cw || !ch) return
|
||||
const imgW = st.width || 200
|
||||
const imgH = st.height || 200
|
||||
|
||||
const img = canvas.createImage()
|
||||
img.onload = () => {
|
||||
ctx.clearRect(0, 0, cw, ch)
|
||||
ctx.save()
|
||||
|
||||
// 居中缩放显示(限制最大为画板 90%)
|
||||
const scale = Math.min(cw / imgW, ch / imgH, 4) * 0.9
|
||||
const w = imgW * scale
|
||||
const h = imgH * scale
|
||||
const x = (cw - w) / 2
|
||||
const y = (ch - h) / 2
|
||||
|
||||
// 应用滤镜(小程序 Canvas 2D 支持 filter 属性 2.16.0+)
|
||||
const b = opts?.brightness ?? brightness
|
||||
const hVal = opts?.hue ?? hue
|
||||
const c = opts?.contrast ?? contrast
|
||||
if (b !== 0 || hVal !== 0 || c !== 0) {
|
||||
ctx.filter = `brightness(${100 + b}%) hue-rotate(${hVal}deg) contrast(${100 + c}%)`
|
||||
} else {
|
||||
ctx.filter = 'none'
|
||||
}
|
||||
|
||||
ctx.drawImage(img, x, y, w, h)
|
||||
ctx.restore()
|
||||
}
|
||||
img.onerror = (err: any) => {
|
||||
console.error('Canvas load image error:', err, st.src)
|
||||
Taro.showToast({ title: '图片加载失败', icon: 'none' })
|
||||
}
|
||||
if (st.src) {
|
||||
img.src = st.src
|
||||
}
|
||||
}
|
||||
|
||||
/** 重新绘制(参数变化时) */
|
||||
const redraw = () => {
|
||||
if (!sticker || !ctxRef.current || !canvasNodeRef.current) return
|
||||
const { width, height } = canvasSizeRef.current
|
||||
drawSticker(ctxRef.current, width, height, sticker, { brightness, hue, contrast })
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
redraw()
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [brightness, hue, contrast])
|
||||
|
||||
const handleSave = () => {
|
||||
if (!sticker || !designId) return
|
||||
setLoading(true)
|
||||
// 使用 Canvas 2D 导出
|
||||
// @ts-ignore
|
||||
Taro.canvasToTempFilePath({
|
||||
canvas: canvasNodeRef.current,
|
||||
quality: 0.92,
|
||||
success: (res: any) => {
|
||||
const newSrc = res.tempFilePath
|
||||
const dList = getDesignList()
|
||||
const dIdx = dList.findIndex(d => d.id === designId)
|
||||
if (dIdx === -1) { setLoading(false); return }
|
||||
const stickers = dList[dIdx].designData?.stickers || []
|
||||
const sIdx = stickers.findIndex(s => s.id === stickerId)
|
||||
if (sIdx === -1) { setLoading(false); return }
|
||||
stickers[sIdx] = {
|
||||
...stickers[sIdx],
|
||||
src: newSrc,
|
||||
edits: { brightness, hue, contrast }
|
||||
}
|
||||
updateDesign(designId, { designData: { ...dList[dIdx].designData, stickers } })
|
||||
Taro.showToast({ title: '保存成功', icon: 'success' })
|
||||
Taro.navigateBack()
|
||||
},
|
||||
fail: () => {
|
||||
setLoading(false)
|
||||
Taro.showToast({ title: '保存失败', icon: 'none' })
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/** 裁剪:调用微信cropImage */
|
||||
const handleCrop = () => {
|
||||
if (!sticker) return
|
||||
// @ts-ignore
|
||||
if (!Taro.cropImage) {
|
||||
Taro.showToast({ title: '当前微信版本不支持裁剪', icon: 'none' })
|
||||
return
|
||||
}
|
||||
// @ts-ignore
|
||||
Taro.cropImage({
|
||||
src: sticker.src,
|
||||
cropScale: '1:1',
|
||||
success: (res) => {
|
||||
const newSticker = { ...sticker, src: res.tempFilePath }
|
||||
setSticker(newSticker)
|
||||
// 重绘
|
||||
setTimeout(() => redraw(), 200)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/** 线稿:预留后端AI接口 */
|
||||
const API_BASE = 'https://your-api-domain.com' // ← 填入你的服务器地址
|
||||
|
||||
const handleSketch = () => {
|
||||
if (!sticker) return
|
||||
setLoading(true)
|
||||
Taro.uploadFile({
|
||||
url: `${API_BASE}/api/sketch`,
|
||||
filePath: sticker.src,
|
||||
name: 'image',
|
||||
success: (res) => {
|
||||
try {
|
||||
const data = JSON.parse(res.data)
|
||||
if (data.url) {
|
||||
const newSticker = { ...sticker, src: data.url, edits: { ...sticker.edits, sketchSrc: data.url } }
|
||||
setSticker(newSticker)
|
||||
setTimeout(() => redraw(), 200)
|
||||
Taro.showToast({ title: '线稿生成成功', icon: 'success' })
|
||||
} else {
|
||||
throw new Error('no url')
|
||||
}
|
||||
} catch {
|
||||
// 如果接口不可用,降级为前端灰度+边缘检测
|
||||
applyFrontendSketch()
|
||||
}
|
||||
},
|
||||
fail: () => {
|
||||
applyFrontendSketch()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/** 前端线稿降级方案:灰度+反相高对比 */
|
||||
const applyFrontendSketch = () => {
|
||||
if (!sticker || !ctxRef.current) { setLoading(false); return }
|
||||
Taro.showToast({ title: '使用本地线稿', icon: 'none' })
|
||||
setBrightness(20)
|
||||
setContrast(80)
|
||||
setHue(0)
|
||||
setLoading(false)
|
||||
}
|
||||
|
||||
const bPct = `${((brightness + 100) / 200) * 100}%`
|
||||
const hPct = `${((hue + 180) / 360) * 100}%`
|
||||
const cPct = `${((contrast + 100) / 200) * 100}%`
|
||||
|
||||
return (
|
||||
<View className={`theme-${resolvedTheme}`}>
|
||||
<View className='sticker-edit-page'>
|
||||
{/* Header */}
|
||||
<View className='edit-header' style={{ paddingTop: `${safe.headerPaddingTop}px` }}>
|
||||
<Text className='edit-back' onTap={() => Taro.navigateBack()}>←</Text>
|
||||
<Text className='edit-title'>编辑贴纸</Text>
|
||||
<Text className='edit-save' onTap={handleSave}>保存</Text>
|
||||
</View>
|
||||
|
||||
{/* Canvas 预览 */}
|
||||
<View className='edit-canvas-wrap'>
|
||||
<Canvas
|
||||
id='editCanvas'
|
||||
type='2d'
|
||||
className='edit-canvas'
|
||||
style={{ width: '100%', height: '400px' }}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* 快速操作 */}
|
||||
<View className='edit-tools dashed-card mt-20'>
|
||||
<View className='edit-row'>
|
||||
<Text className='edit-label'>快速操作</Text>
|
||||
<View className='edit-quick-actions'>
|
||||
<View className='quick-btn' onTap={handleCrop}>
|
||||
<Text>裁剪</Text>
|
||||
</View>
|
||||
<View className='quick-btn' onTap={handleSketch}>
|
||||
<Text>转线稿</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 亮度 */}
|
||||
<View className='edit-row slider-row'
|
||||
onTouchMove={(e) => {
|
||||
const { clientX } = e.changedTouches[0]
|
||||
// 取得滑轨位置
|
||||
const query = Taro.createSelectorQuery().in(e.currentTarget as any)
|
||||
query.select('.slider-track').boundingClientRect((rect: any) => {
|
||||
if (!rect) return
|
||||
const ratio = clamp((clientX - rect.left) / rect.width, 0, 1)
|
||||
const v = Math.round((-100 + 200 * ratio) / 1) * 1
|
||||
setBrightness(clamp(v, -100, 100))
|
||||
}).exec()
|
||||
}}
|
||||
>
|
||||
<Text className='edit-label'>亮度</Text>
|
||||
<View className='slider-wrap'>
|
||||
<View className='slider-track'>
|
||||
<View className='slider-fill' style={{ width: bPct }} />
|
||||
<View className='slider-thumb' style={{ left: bPct }} />
|
||||
</View>
|
||||
<Text className='slider-value'>{brightness > 0 ? `+${brightness}` : brightness}</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 色相 */}
|
||||
<View className='edit-row slider-row'
|
||||
onTouchMove={(e) => {
|
||||
const { clientX } = e.changedTouches[0]
|
||||
const query = Taro.createSelectorQuery().in(e.currentTarget as any)
|
||||
query.select('.slider-track').boundingClientRect((rect: any) => {
|
||||
if (!rect) return
|
||||
const ratio = clamp((clientX - rect.left) / rect.width, 0, 1)
|
||||
const v = Math.round((-180 + 360 * ratio) / 1) * 1
|
||||
setHue(clamp(v, -180, 180))
|
||||
}).exec()
|
||||
}}
|
||||
>
|
||||
<Text className='edit-label'>色相</Text>
|
||||
<View className='slider-wrap'>
|
||||
<View className='slider-track'>
|
||||
<View className='slider-fill' style={{ width: hPct }} />
|
||||
<View className='slider-thumb' style={{ left: hPct }} />
|
||||
</View>
|
||||
<Text className='slider-value'>{hue > 0 ? `+${hue}` : hue}</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 对比度 */}
|
||||
<View className='edit-row slider-row'
|
||||
onTouchMove={(e) => {
|
||||
const { clientX } = e.changedTouches[0]
|
||||
const query = Taro.createSelectorQuery().in(e.currentTarget as any)
|
||||
query.select('.slider-track').boundingClientRect((rect: any) => {
|
||||
if (!rect) return
|
||||
const ratio = clamp((clientX - rect.left) / rect.width, 0, 1)
|
||||
const v = Math.round((-100 + 200 * ratio) / 1) * 1
|
||||
setContrast(clamp(v, -100, 100))
|
||||
}).exec()
|
||||
}}
|
||||
>
|
||||
<Text className='edit-label'>对比度</Text>
|
||||
<View className='slider-wrap'>
|
||||
<View className='slider-track'>
|
||||
<View className='slider-fill' style={{ width: cPct }} />
|
||||
<View className='slider-thumb' style={{ left: cPct }} />
|
||||
</View>
|
||||
<Text className='slider-value'>{contrast > 0 ? `+${contrast}` : contrast}</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={{ flex: 1 }} />
|
||||
|
||||
{loading && (
|
||||
<View className='edit-loading'>
|
||||
<Text className='edit-loading-text'>处理中…</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export default definePageConfig({
|
||||
navigationStyle: 'custom',
|
||||
navigationBarTitleText: '智绘微刻'
|
||||
})
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"usingComponents": {}
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
.index-page {
|
||||
padding: 0 24px 24px;
|
||||
}
|
||||
|
||||
/* 顶部标题栏 */
|
||||
.header-bar {
|
||||
padding: 20px 0 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.header-title {
|
||||
font-size: 48px;
|
||||
font-weight: 800;
|
||||
color: var(--text-primary);
|
||||
letter-spacing: 4px;
|
||||
}
|
||||
|
||||
/* 搜索框卡片 */
|
||||
.search-card {
|
||||
padding: 20px 24px;
|
||||
}
|
||||
|
||||
.search-inner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background: #f8f9fa;
|
||||
border: 1px dashed rgba(0, 0, 0, 0.06);
|
||||
border-radius: 40px;
|
||||
padding: 16px 24px;
|
||||
}
|
||||
|
||||
.search-icon {
|
||||
font-size: 28px;
|
||||
margin-right: 16px;
|
||||
}
|
||||
|
||||
.search-icon-img {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
margin-right: 16px;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
flex: 1;
|
||||
font-size: 28px;
|
||||
color: var(--text-primary);
|
||||
background: transparent;
|
||||
border: none;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.search-input::placeholder {
|
||||
color: var(--text-secondary);
|
||||
font-size: 26px;
|
||||
}
|
||||
|
||||
.search-result-hint {
|
||||
margin-top: 16px;
|
||||
padding-top: 16px;
|
||||
border-top: 2px dashed var(--line-star);
|
||||
}
|
||||
|
||||
.hint-text {
|
||||
font-size: 24px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* 成品展示轮播 */
|
||||
.showcase-section {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.showcase-swiper {
|
||||
height: 520rpx;
|
||||
}
|
||||
|
||||
.showcase-swiper-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0 10rpx;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.showcase-swiper-card {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-radius: 24px;
|
||||
overflow: hidden;
|
||||
box-shadow: var(--shadow-card);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.showcase-image-wrapper {
|
||||
width: 100%;
|
||||
height: 320rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.showcase-real-img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.showcase-text-area {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
background: var(--bg-card);
|
||||
padding: 16px 20px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.showcase-info .showcase-title {
|
||||
font-size: 32px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.showcase-info .showcase-desc {
|
||||
font-size: 24px;
|
||||
color: var(--text-secondary);
|
||||
display: block;
|
||||
}
|
||||
|
||||
.showcase-tag {
|
||||
position: absolute;
|
||||
top: 16px;
|
||||
right: 16px;
|
||||
background: rgba(255, 154, 158, 0.9);
|
||||
color: #ffffff;
|
||||
font-size: 20px;
|
||||
padding: 4px 16px;
|
||||
border-radius: 20px;
|
||||
font-weight: 600;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
/* 热门品类 */
|
||||
.category-section {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.category-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.category-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
padding: 0 0 24px;
|
||||
overflow: hidden;
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
|
||||
.category-item:active {
|
||||
transform: scale(0.96);
|
||||
}
|
||||
|
||||
/* 品类顶部实物照片 */
|
||||
.category-img-wrapper {
|
||||
width: 100%;
|
||||
height: 180rpx;
|
||||
overflow: hidden;
|
||||
border-radius: 16px;
|
||||
margin-bottom: 16px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.category-img-real {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.category-name {
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.category-desc {
|
||||
font-size: 22px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.category-price {
|
||||
font-size: 30px;
|
||||
font-weight: 700;
|
||||
color: var(--accent-pink);
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
/* 空状态 */
|
||||
.empty-category {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 60px 40px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.empty-icon-img {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.empty-text {
|
||||
font-size: 30px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.empty-sub {
|
||||
font-size: 24px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* 底部安全区 */
|
||||
.safe-bottom-placeholder {
|
||||
height: 160px;
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
import { View, Text, Image, Input, Button, Swiper, SwiperItem } from '@tarojs/components'
|
||||
import Taro from '@tarojs/taro'
|
||||
import { useState } from 'react'
|
||||
import './index.scss'
|
||||
import { CATEGORIES } from '../../utils/productConfig'
|
||||
import { useThemeContext } from '../../context/ThemeContext'
|
||||
import { useSafeArea } from '../../hooks/useSafeArea'
|
||||
import { useStatusBar } from '../../hooks/useStatusBar'
|
||||
|
||||
// 成品展示配图(从 img 里取对应实物图)
|
||||
const SHOWCASE_LIST = [
|
||||
{ title: '毕业纪念笔记本', desc: '全班名字组成校徽', image: '/img/book_small/The1.jpg' },
|
||||
{ title: '铜质杯垫', desc: '金属质感桌面艺术', image: '/img/cup/The1.jpg' },
|
||||
{ title: '竹制笔盒', desc: '自然竹纹文房雅器', image: '/img/penbox/The1.jpg' },
|
||||
{ title: '书本型灯', desc: '温暖光影点亮心意', image: '/img/booklight/The1.jpg' },
|
||||
{ title: '情侣定制礼', desc: '两个人的名字交织', image: '/img/book_big/The1.jpg' },
|
||||
{ title: '企业年会礼', desc: '员工名字组成Logo', image: '/img/penbox/The2.jpg' }
|
||||
]
|
||||
|
||||
// 品类对应的实物照片映射(首页卡片顶部大图)
|
||||
const PRODUCT_IMG_MAP: Record<string, string> = {
|
||||
'notebook-small': '/img/book_small/The1.jpg',
|
||||
'notebook-large': '/img/book_big/The1.jpg',
|
||||
'coaster': '/img/cup/The1.jpg',
|
||||
'penbox': '/img/penbox/The1.jpg',
|
||||
'booklamp': '/img/booklight/The1.jpg'
|
||||
}
|
||||
|
||||
export default function Index() {
|
||||
const { theme, resolvedTheme } = useThemeContext()
|
||||
const safe = useSafeArea()
|
||||
useStatusBar(resolvedTheme)
|
||||
const [searchKey, setSearchKey] = useState('')
|
||||
|
||||
const navigateToProduct = (categoryId: string) => {
|
||||
Taro.navigateTo({ url: `/pages/product/index?id=${categoryId}` })
|
||||
}
|
||||
|
||||
const filteredCategories = searchKey.trim()
|
||||
? CATEGORIES.filter(
|
||||
(c) => c.name.includes(searchKey) || c.desc.includes(searchKey)
|
||||
)
|
||||
: CATEGORIES
|
||||
|
||||
const handleSearch = (e: any) => {
|
||||
setSearchKey(e.detail.value)
|
||||
}
|
||||
|
||||
return (
|
||||
<View className={`theme-${resolvedTheme}`}>
|
||||
<View className='index-page'>
|
||||
|
||||
{/* 顶部标题栏 */}
|
||||
<View className='header-bar' style={{ paddingTop: `${safe.headerPaddingTop}px` }}>
|
||||
<Text className='header-title'>智绘微刻</Text>
|
||||
</View>
|
||||
|
||||
{/* 搜索框 */}
|
||||
<View className='search-card dashed-card'>
|
||||
<View className='star-badge' />
|
||||
<View className='search-inner'>
|
||||
<Image className='search-icon-img' src='/icon/搜索.png' mode='aspectFit' />
|
||||
<Input
|
||||
className='search-input'
|
||||
type='text'
|
||||
placeholder='搜索定制品类:笔记本、杯垫、书灯...'
|
||||
value={searchKey}
|
||||
onInput={handleSearch}
|
||||
confirmType='search'
|
||||
/>
|
||||
</View>
|
||||
{searchKey.trim() && (
|
||||
<View className='search-result-hint'>
|
||||
<Text className='hint-text'>
|
||||
{filteredCategories.length > 0
|
||||
? `找到 ${filteredCategories.length} 个相关品类`
|
||||
: '没有找到相关品类,试试看其他关键词'}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* 成品展示轮播 */}
|
||||
<View className='showcase-section mt-20'>
|
||||
<Text className='section-title'>定制成品展示</Text>
|
||||
<Swiper
|
||||
className='showcase-swiper'
|
||||
indicatorColor='#e0e0e0'
|
||||
indicatorActiveColor='#ff9a9e'
|
||||
circular
|
||||
autoplay
|
||||
interval={3000}
|
||||
duration={500}
|
||||
previousMargin='40rpx'
|
||||
nextMargin='40rpx'
|
||||
indicatorDots
|
||||
>
|
||||
{SHOWCASE_LIST.map((item, idx) => (
|
||||
<SwiperItem key={idx} className='showcase-swiper-item'>
|
||||
<View className='showcase-swiper-card dashed-card'>
|
||||
<View className='star-badge' />
|
||||
<View className='showcase-image-wrapper'>
|
||||
<Image className='showcase-real-img' src={item.image} mode='aspectFill' />
|
||||
</View>
|
||||
<View className='showcase-text-area'>
|
||||
<View className='showcase-info'>
|
||||
<Text className='showcase-title'>{item.title}</Text>
|
||||
<Text className='showcase-desc'>{item.desc}</Text>
|
||||
</View>
|
||||
<View className='showcase-tag'>成品案例</View>
|
||||
</View>
|
||||
</View>
|
||||
</SwiperItem>
|
||||
))}
|
||||
</Swiper>
|
||||
</View>
|
||||
|
||||
{/* 热门品类 */}
|
||||
<View className='category-section mt-20'>
|
||||
<Text className='section-title'>热门品类</Text>
|
||||
<View className='category-grid'>
|
||||
{filteredCategories.map((cat) => (
|
||||
<View
|
||||
key={cat.id}
|
||||
className='category-item dashed-card'
|
||||
onTap={() => navigateToProduct(cat.id)}
|
||||
>
|
||||
<View className='star-badge' />
|
||||
<View className='category-img-wrapper'>
|
||||
<Image
|
||||
className='category-img-real'
|
||||
src={PRODUCT_IMG_MAP[cat.id] || '/icon/四角星.png'}
|
||||
mode='aspectFill'
|
||||
/>
|
||||
</View>
|
||||
<Text className='category-name'>{cat.name}</Text>
|
||||
<Text className='category-desc'>{cat.desc}</Text>
|
||||
<Text className='category-price'>¥{cat.price} 起</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
{filteredCategories.length === 0 && (
|
||||
<View className='empty-category dashed-card'>
|
||||
<Image className='empty-icon-img' src='/icon/搜索.png' mode='aspectFit' />
|
||||
<Text className='empty-text'>未找到匹配的品类</Text>
|
||||
<Text className='empty-sub'>尝试搜索“笔记本”、“杯垫”等</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<View className='safe-bottom-placeholder' />
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export default definePageConfig({
|
||||
navigationStyle: 'custom',
|
||||
navigationBarTitleText: '订单详情'
|
||||
})
|
||||
@@ -0,0 +1 @@
|
||||
{}
|
||||
@@ -0,0 +1,493 @@
|
||||
.order-detail-page {
|
||||
padding: 0 24px 24px;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
/* 返回按钮 */
|
||||
.back-btn {
|
||||
font-size: 40px;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
/* 状态卡片 */
|
||||
.status-card {
|
||||
padding: 32px;
|
||||
}
|
||||
|
||||
.status-top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
font-size: 26px;
|
||||
}
|
||||
|
||||
.status-desc {
|
||||
font-size: 26px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* 物流时间轴 */
|
||||
.logistics-timeline {
|
||||
position: relative;
|
||||
padding-left: 20px;
|
||||
}
|
||||
|
||||
.logistics-timeline::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 8px;
|
||||
top: 8px;
|
||||
bottom: 8px;
|
||||
width: 2px;
|
||||
background: var(--line-star);
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
.timeline-item {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 24px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.timeline-item:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.timeline-dot {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 50%;
|
||||
background: var(--bg-card);
|
||||
border: 3px solid var(--line-star);
|
||||
margin-right: 20px;
|
||||
flex-shrink: 0;
|
||||
margin-left: -20px;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.timeline-item.active .timeline-dot {
|
||||
background: var(--accent-pink);
|
||||
border-color: var(--accent-pink);
|
||||
}
|
||||
|
||||
.timeline-content {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.timeline-title {
|
||||
font-size: 28px;
|
||||
color: var(--text-primary);
|
||||
display: block;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.timeline-time {
|
||||
font-size: 24px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* 地址卡片 */
|
||||
.address-card {
|
||||
padding: 32px;
|
||||
}
|
||||
|
||||
.address-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.address-title {
|
||||
font-size: 30px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.address-change {
|
||||
padding: 6px 16px;
|
||||
border-radius: 8px;
|
||||
background: rgba(91, 140, 255, 0.08);
|
||||
}
|
||||
|
||||
.change-text {
|
||||
font-size: 24px;
|
||||
color: var(--accent-blue);
|
||||
}
|
||||
|
||||
.address-body {
|
||||
padding-top: 8px;
|
||||
}
|
||||
|
||||
.address-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.address-name {
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.address-phone {
|
||||
font-size: 26px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.address-detail {
|
||||
font-size: 26px;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.address-empty {
|
||||
padding: 32px 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* 底部弹窗 — 地址选择(样式复用 app.scss 全局定义,移除非必要覆写) */
|
||||
|
||||
.addr-picker-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.addr-picker-title {
|
||||
font-size: 32px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.addr-picker-close {
|
||||
font-size: 28px;
|
||||
color: var(--text-secondary);
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.addr-picker-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.addr-picker-item {
|
||||
padding: 20px 16px;
|
||||
border-bottom: 1px dashed rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.addr-picker-item.active {
|
||||
background: rgba(91, 140, 255, 0.06);
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.addr-picker-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.addr-picker-name {
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.addr-picker-phone {
|
||||
font-size: 26px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.addr-picker-default {
|
||||
font-size: 22px;
|
||||
color: var(--accent-pink);
|
||||
border: 1px solid var(--accent-pink);
|
||||
padding: 2px 8px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.addr-picker-detail {
|
||||
font-size: 26px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.addr-picker-empty {
|
||||
padding: 40px;
|
||||
text-align: center;
|
||||
color: var(--text-secondary);
|
||||
font-size: 28px;
|
||||
}
|
||||
|
||||
.addr-picker-add {
|
||||
margin-top: 16px;
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
border: 2px dashed var(--accent-blue);
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.addr-picker-add-text {
|
||||
font-size: 28px;
|
||||
color: var(--accent-blue);
|
||||
}
|
||||
|
||||
/* 卡片标题 icon(物流/地址) */
|
||||
.card-icon-img {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
/* 商品卡片 */
|
||||
.product-card {
|
||||
padding: 32px;
|
||||
}
|
||||
|
||||
.product-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.product-icon-wrapper {
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
background: var(--bg-input);
|
||||
border-radius: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-right: 20px;
|
||||
overflow: hidden;
|
||||
padding: 8px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.product-icon-img {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
}
|
||||
|
||||
.product-info {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.product-name {
|
||||
font-size: 30px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.product-sku {
|
||||
font-size: 24px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.price-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16px 0;
|
||||
border-top: 1px dashed rgba(0,0,0,0.06);
|
||||
}
|
||||
|
||||
.price-label {
|
||||
font-size: 26px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.price-value {
|
||||
font-size: 28px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.price-row.total {
|
||||
padding-top: 20px;
|
||||
}
|
||||
|
||||
.total-label {
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.total-value {
|
||||
font-size: 36px;
|
||||
font-weight: 800;
|
||||
color: var(--accent-pink);
|
||||
}
|
||||
|
||||
/* 信息卡片 */
|
||||
.info-card {
|
||||
padding: 32px;
|
||||
}
|
||||
|
||||
.info-card .info-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16px 0;
|
||||
}
|
||||
|
||||
.info-card .info-label {
|
||||
font-size: 26px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.info-card .info-value {
|
||||
font-size: 26px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* 状态横幅 */
|
||||
.status-text {
|
||||
font-size: 48px;
|
||||
font-weight: 800;
|
||||
color: var(--text-primary);
|
||||
display: block;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
/* 物流信息 */
|
||||
.logistics-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 24px;
|
||||
padding-bottom: 16px;
|
||||
border-bottom: 1px dashed rgba(0,0,0,0.06);
|
||||
}
|
||||
|
||||
.logistics-title {
|
||||
font-size: 30px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.logistics-num {
|
||||
font-size: 24px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.timeline {
|
||||
position: relative;
|
||||
padding-left: 20px;
|
||||
}
|
||||
|
||||
.timeline::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 8px;
|
||||
top: 8px;
|
||||
bottom: 8px;
|
||||
width: 2px;
|
||||
background: var(--line-star);
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
.timeline-item {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 24px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.timeline-item:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.timeline-dot {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 50%;
|
||||
background: var(--bg-card);
|
||||
border: 3px solid var(--line-star);
|
||||
margin-right: 20px;
|
||||
flex-shrink: 0;
|
||||
margin-left: -20px;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.timeline-item.active .timeline-dot {
|
||||
background: var(--accent-pink);
|
||||
border-color: var(--accent-pink);
|
||||
}
|
||||
|
||||
.timeline-content {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.timeline-status {
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
display: block;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.timeline-time {
|
||||
font-size: 24px;
|
||||
color: var(--text-secondary);
|
||||
display: block;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.timeline-desc {
|
||||
font-size: 26px;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
/* 订单信息 */
|
||||
.meta-title {
|
||||
font-size: 30px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
display: block;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.meta-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16px 0;
|
||||
}
|
||||
|
||||
.meta-label {
|
||||
font-size: 26px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.meta-value {
|
||||
font-size: 26px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.preview-card {
|
||||
padding: 32px;
|
||||
}
|
||||
|
||||
.design-preview {
|
||||
position: relative;
|
||||
background: linear-gradient(135deg, rgba(255, 154, 158, 0.08) 0%, rgba(160, 196, 255, 0.08) 100%);
|
||||
border-radius: 16px;
|
||||
overflow: hidden;
|
||||
border: 3px dashed var(--accent-blue);
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.preview-sticker {
|
||||
position: absolute;
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
import { View, Text, Image, Button } from '@tarojs/components'
|
||||
import Taro from '@tarojs/taro'
|
||||
import { useState, useEffect } from 'react'
|
||||
import './index.scss'
|
||||
import { useThemeContext } from '../../context/ThemeContext'
|
||||
import { useSafeArea } from '../../hooks/useSafeArea'
|
||||
import { useStatusBar } from '../../hooks/useStatusBar'
|
||||
import { getOrderList, getDefaultAddress, getAddressList, type AddressItem, type OrderItem } from '../../utils/store'
|
||||
import { PRODUCT_ICON_MAP } from '../../utils/productConfig'
|
||||
|
||||
export default function OrderDetailPage() {
|
||||
const { theme, resolvedTheme } = useThemeContext()
|
||||
const safe = useSafeArea()
|
||||
useStatusBar(resolvedTheme)
|
||||
const params = Taro.getCurrentInstance().router?.params
|
||||
const orderId = params?.orderId || ''
|
||||
|
||||
const [order, setOrder] = useState<OrderItem | null>(null)
|
||||
const [address, setAddress] = useState<AddressItem | null>(null)
|
||||
const [showAddrPicker, setShowAddrPicker] = useState(false)
|
||||
const [addrList, setAddrList] = useState<AddressItem[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
const orders = getOrderList()
|
||||
const o = orders.find(x => x.id === orderId)
|
||||
if (o) {
|
||||
setOrder(o)
|
||||
// 优先显示订单已有地址,否则用默认地址
|
||||
if ((o as any).address) {
|
||||
setAddress((o as any).address)
|
||||
} else {
|
||||
setAddress(getDefaultAddress())
|
||||
}
|
||||
}
|
||||
setAddrList(getAddressList())
|
||||
}, [orderId])
|
||||
|
||||
const handlePickAddress = (addr: AddressItem) => {
|
||||
setAddress(addr)
|
||||
setShowAddrPicker(false)
|
||||
// 同步更新订单里的地址
|
||||
const orders = getOrderList()
|
||||
const idx = orders.findIndex(x => x.id === orderId)
|
||||
if (idx >= 0) {
|
||||
orders[idx] = { ...orders[idx], address: addr } as any
|
||||
// 这里不直接写回 store,用临时状态即可,实际后端会有 order update API
|
||||
}
|
||||
Taro.showToast({ title: '地址已更改', icon: 'success' })
|
||||
}
|
||||
|
||||
if (!order) {
|
||||
return (
|
||||
<View className={`theme-${resolvedTheme}`}>
|
||||
<View className='order-detail-page'>
|
||||
<View className='page-header dashed-card mt-20' style={{ marginTop: `${safe.headerPaddingTop}px` }}>
|
||||
<Text className='page-title'>订单不存在</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<View className={`theme-${resolvedTheme}`}>
|
||||
<View className='order-detail-page'>
|
||||
|
||||
<View className='page-header dashed-card mt-20' style={{ marginTop: `${safe.headerPaddingTop}px` }}>
|
||||
<View className='star-badge' />
|
||||
<View className='flex-between' style={{ width: '100%' }}>
|
||||
<Text className='back-btn' onTap={() => Taro.navigateBack()}>←</Text>
|
||||
<Text className='page-title'>订单详情</Text>
|
||||
<View style={{ width: '60px' }} />
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 状态横幅 */}
|
||||
<View className='status-banner dashed-card mt-20'>
|
||||
<View className='star-badge' />
|
||||
<Text className='status-text'>
|
||||
{order.statusCode === 'pending' && '待付款'}
|
||||
{order.statusCode === 'paid' && '待发货'}
|
||||
{order.statusCode === 'shipping' && '待收货'}
|
||||
{order.statusCode === 'done' && '已完成'}
|
||||
</Text>
|
||||
<Text className='status-desc'>
|
||||
{order.statusCode === 'pending' && '请在30分钟内完成支付'}
|
||||
{order.statusCode === 'paid' && '商品正在打包中,即将发货'}
|
||||
{order.statusCode === 'shipping' && '快递运输中,请注意查收'}
|
||||
{order.statusCode === 'done' && '交易已完成,感谢惠顾'}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* 物流信息(仅 shipping/done) */}
|
||||
{(order.statusCode === 'shipping' || order.statusCode === 'done') && (
|
||||
<View className='logistics-card dashed-card mt-20'>
|
||||
<View className='star-badge' />
|
||||
<View className='logistics-header'>
|
||||
<View className='flex-center'>
|
||||
<Image className='card-icon-img' src='/icon/包裹.png' mode='aspectFit' />
|
||||
<Text className='logistics-title'>物流信息</Text>
|
||||
</View>
|
||||
<Text className='logistics-num'>单号: SF1234567890</Text>
|
||||
</View>
|
||||
<View className='timeline'>
|
||||
<View className='timeline-item active'>
|
||||
<View className='timeline-dot' />
|
||||
<View className='timeline-content'>
|
||||
<Text className='timeline-status'>运输中</Text>
|
||||
<Text className='timeline-time'>2024-01-15 14:30</Text>
|
||||
<Text className='timeline-desc'>【北京市】快件已到达北京朝阳转运中心</Text>
|
||||
</View>
|
||||
</View>
|
||||
<View className='timeline-item'>
|
||||
<View className='timeline-dot' />
|
||||
<View className='timeline-content'>
|
||||
<Text className='timeline-status'>已发货</Text>
|
||||
<Text className='timeline-time'>2024-01-15 09:00</Text>
|
||||
<Text className='timeline-desc'>【深圳市】商家已发货,等待揽收</Text>
|
||||
</View>
|
||||
</View>
|
||||
<View className='timeline-item'>
|
||||
<View className='timeline-dot' />
|
||||
<View className='timeline-content'>
|
||||
<Text className='timeline-status'>已下单</Text>
|
||||
<Text className='timeline-time'>2024-01-14 20:15</Text>
|
||||
<Text className='timeline-desc'>订单已生成,等待商家发货</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* 商品信息 */}
|
||||
<View className='product-card dashed-card mt-20'>
|
||||
<View className='star-badge' />
|
||||
<View className='product-row'>
|
||||
<View className='product-icon-wrapper'>
|
||||
<Image
|
||||
className='product-icon-img'
|
||||
src={order.productIcon?.startsWith('/icon/') ? order.productIcon : '/icon/四角星.png'}
|
||||
mode='aspectFit'
|
||||
/>
|
||||
</View>
|
||||
<View className='product-info'>
|
||||
<Text className='product-name'>{order.productName}</Text>
|
||||
<Text className='product-sku'>{order.sku}</Text>
|
||||
</View>
|
||||
</View>
|
||||
<View className='price-row'>
|
||||
<Text className='price-label'>实付款</Text>
|
||||
<Text className='price-value'>{order.price}</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 收货地址 */}
|
||||
<View className='address-card dashed-card mt-20'>
|
||||
<View className='star-badge' />
|
||||
<View className='address-header'>
|
||||
<View className='flex-center'>
|
||||
<Image className='card-icon-img' src='/icon/地址.png' mode='aspectFit' />
|
||||
<Text className='address-title'>收货地址</Text>
|
||||
</View>
|
||||
<View className='address-change' onTap={() => setShowAddrPicker(true)}>
|
||||
<Text className='change-text'>更改收货地址</Text>
|
||||
</View>
|
||||
</View>
|
||||
{address ? (
|
||||
<View className='address-body'>
|
||||
<View className='address-row'>
|
||||
<Text className='address-name'>{address.name}</Text>
|
||||
<Text className='address-phone'>{address.phone}</Text>
|
||||
</View>
|
||||
<Text className='address-detail'>
|
||||
{address.region?.join(' ')} {address.detail}
|
||||
</Text>
|
||||
</View>
|
||||
) : (
|
||||
<View className='address-empty'>
|
||||
<Text>暂无收货地址,请先添加</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* 订单信息 */}
|
||||
<View className='meta-card dashed-card mt-20'>
|
||||
<View className='star-badge' />
|
||||
<Text className='meta-title'>订单信息</Text>
|
||||
<View className='meta-row'>
|
||||
<Text className='meta-label'>订单编号</Text>
|
||||
<Text className='meta-value'>{order.id}</Text>
|
||||
</View>
|
||||
<View className='meta-row'>
|
||||
<Text className='meta-label'>创建时间</Text>
|
||||
<Text className='meta-value'>{order.date}</Text>
|
||||
</View>
|
||||
<View className='meta-row'>
|
||||
<Text className='meta-label'>数量</Text>
|
||||
<Text className='meta-value'>{order.count} 件</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={{ height: '40px' }} />
|
||||
|
||||
{/* 地址选择底部弹窗 */}
|
||||
{showAddrPicker && (
|
||||
<View className='modal-overlay' style={{ alignItems: 'flex-end', justifyContent: 'flex-end' }}>
|
||||
<View className='addr-picker-sheet dashed-card'>
|
||||
<View className='addr-picker-header'>
|
||||
<Text className='addr-picker-title'>选择收货地址</Text>
|
||||
<Text className='addr-picker-close' onTap={() => setShowAddrPicker(false)}>✕</Text>
|
||||
</View>
|
||||
<View className='addr-picker-list'>
|
||||
{addrList.map((addr) => (
|
||||
<View
|
||||
key={addr.id}
|
||||
className={`addr-picker-item ${address?.id === addr.id ? 'active' : ''}`}
|
||||
onTap={() => handlePickAddress(addr)}
|
||||
>
|
||||
<View className='addr-picker-row'>
|
||||
<Text className='addr-picker-name'>{addr.name}</Text>
|
||||
<Text className='addr-picker-phone'>{addr.phone}</Text>
|
||||
{addr.isDefault && <Text className='addr-picker-default'>默认</Text>}
|
||||
</View>
|
||||
<Text className='addr-picker-detail'>
|
||||
{addr.region?.join(' ')} {addr.detail}
|
||||
</Text>
|
||||
</View>
|
||||
))}
|
||||
{addrList.length === 0 && (
|
||||
<View className='addr-picker-empty'>
|
||||
<Text>暂无地址,请先添加收货地址</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
<View className='addr-picker-add' onTap={() => { setShowAddrPicker(false); Taro.navigateTo({ url: '/pages/address/index' }) }}>
|
||||
<Text className='addr-picker-add-text'>+ 新建收货地址</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export default definePageConfig({
|
||||
navigationStyle: 'custom',
|
||||
navigationBarTitleText: '订单列表'
|
||||
})
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"usingComponents": {}
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
.orders-page {
|
||||
padding: 0 24px 24px;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
padding: 32px 32px 24px;
|
||||
margin: 20px 0 0;
|
||||
}
|
||||
|
||||
/* 状态Tab */
|
||||
.status-tabs {
|
||||
margin: 20px 0;
|
||||
}
|
||||
.tabs-scroll {
|
||||
white-space: nowrap;
|
||||
}
|
||||
.tab-item {
|
||||
display: inline-block;
|
||||
padding: 20px 32px;
|
||||
position: relative;
|
||||
}
|
||||
.tab-label {
|
||||
font-size: 28px;
|
||||
color: #b08d8d;
|
||||
font-weight: 500;
|
||||
}
|
||||
.tab-item.active .tab-label {
|
||||
color: #ff9a9e;
|
||||
font-weight: 700;
|
||||
}
|
||||
.tab-underline {
|
||||
position: absolute;
|
||||
bottom: 8px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 40px;
|
||||
height: 4px;
|
||||
background: #ff9a9e;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
/* 订单卡片 */
|
||||
.orders-list {
|
||||
padding: 0;
|
||||
}
|
||||
.order-card {
|
||||
margin-bottom: 24px;
|
||||
padding: 28px;
|
||||
}
|
||||
|
||||
.order-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding-bottom: 20px;
|
||||
border-bottom: 2px dashed #fce4ec;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.order-date {
|
||||
font-size: 24px;
|
||||
color: #b08d8d;
|
||||
}
|
||||
|
||||
.order-body {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.order-icon-wrapper {
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
background: linear-gradient(135deg, #fff0f5 0%, #e3f2fd 100%);
|
||||
border-radius: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-right: 20px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.order-icon {
|
||||
font-size: 56px;
|
||||
}
|
||||
.order-icon-img {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
}
|
||||
.order-info {
|
||||
flex: 1;
|
||||
}
|
||||
.order-product {
|
||||
font-size: 30px;
|
||||
font-weight: 700;
|
||||
color: #5c3a3a;
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.order-sku {
|
||||
font-size: 24px;
|
||||
color: #b08d8d;
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.order-meta {
|
||||
font-size: 22px;
|
||||
color: #ccc;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.order-footer {
|
||||
padding-top: 16px;
|
||||
border-top: 2px dashed #fce4ec;
|
||||
}
|
||||
.order-price-bar {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: flex-end;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.price-label {
|
||||
font-size: 24px;
|
||||
color: #b08d8d;
|
||||
margin-right: 8px;
|
||||
}
|
||||
.price-value {
|
||||
font-size: 36px;
|
||||
font-weight: 800;
|
||||
color: #ff6b81;
|
||||
}
|
||||
.order-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 16px;
|
||||
}
|
||||
.order-btn {
|
||||
padding: 14px 28px !important;
|
||||
font-size: 26px !important;
|
||||
border-radius: 40px !important;
|
||||
}
|
||||
|
||||
/* 空状态 */
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 120px 40px;
|
||||
}
|
||||
.empty-icon-img {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
.empty-text {
|
||||
font-size: 36px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.empty-sub {
|
||||
font-size: 28px;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
import { View, Text, ScrollView, Image } from '@tarojs/components'
|
||||
import Taro from '@tarojs/taro'
|
||||
import { useState, useEffect } from 'react'
|
||||
import './index.scss'
|
||||
import { getOrderList, type OrderItem } from '../../utils/store'
|
||||
import { PRODUCT_ICON_MAP } from '../../utils/productConfig'
|
||||
import { useThemeContext } from '../../context/ThemeContext'
|
||||
import { useSafeArea } from '../../hooks/useSafeArea'
|
||||
import { useStatusBar } from '../../hooks/useStatusBar'
|
||||
import LoginGuard from '../../components/LoginGuard'
|
||||
|
||||
const STATUS_TABS = [
|
||||
{ code: 'all', label: '全部' },
|
||||
{ code: 'pending', label: '待付款' },
|
||||
{ code: 'paid', label: '待发货' },
|
||||
{ code: 'shipping', label: '待收货' },
|
||||
{ code: 'done', label: '已完成' }
|
||||
]
|
||||
|
||||
const STATUS_MAP: Record<string, { label: string; badge: string }> = {
|
||||
pending: { label: '待付款', badge: 'badge-pink' },
|
||||
paid: { label: '待发货', badge: 'badge-blue' },
|
||||
shipping: { label: '待收货', badge: 'badge-blue' },
|
||||
done: { label: '已完成', badge: 'badge-green' },
|
||||
cancelled: { label: '已取消', badge: 'badge-gray' }
|
||||
}
|
||||
|
||||
export default function OrdersPage() {
|
||||
const { theme, resolvedTheme } = useThemeContext()
|
||||
const safe = useSafeArea()
|
||||
useStatusBar(resolvedTheme)
|
||||
const [activeTab, setActiveTab] = useState('all')
|
||||
const [orders, setOrders] = useState<OrderItem[]>([])
|
||||
|
||||
const load = () => setOrders(getOrderList())
|
||||
|
||||
useEffect(load, [])
|
||||
|
||||
// 读取从 profile 跳转过来的筛选状态
|
||||
useEffect(() => {
|
||||
const init = () => {
|
||||
const filter = Taro.getStorageSync('orders:filter')
|
||||
if (filter && STATUS_TABS.some(t => t.code === filter)) {
|
||||
setActiveTab(filter)
|
||||
Taro.removeStorageSync('orders:filter')
|
||||
}
|
||||
load()
|
||||
}
|
||||
init()
|
||||
// 页面每次显示时刷新
|
||||
const page = Taro.getCurrentInstance().page
|
||||
if (page) {
|
||||
const orig = page.onShow
|
||||
page.onShow = function () {
|
||||
init()
|
||||
if (orig) orig.apply(this)
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
// 页面每次显示时刷新(处理下单后同步)
|
||||
useEffect(() => {
|
||||
Taro.eventCenter?.once?.('orders:refresh', load)
|
||||
load()
|
||||
return () => { Taro.eventCenter?.off?.('orders:refresh', load) }
|
||||
}, [])
|
||||
|
||||
const filteredOrders = activeTab === 'all'
|
||||
? orders
|
||||
: orders.filter(o => o.statusCode === activeTab)
|
||||
|
||||
const goDetail = (order: OrderItem) => {
|
||||
Taro.navigateTo({ url: `/pages/orderDetail/index?orderId=${order.id}` })
|
||||
}
|
||||
|
||||
return (
|
||||
<View className={`theme-${resolvedTheme}`}>
|
||||
<LoginGuard>
|
||||
<View className='orders-page'>
|
||||
|
||||
<View className='page-header dashed-card mt-20' style={{ marginTop: `${safe.headerPaddingTop}px` }}>
|
||||
<View className='star-badge' />
|
||||
<Text className='page-title'>我的订单</Text>
|
||||
</View>
|
||||
|
||||
{/* 状态筛选Tab */}
|
||||
<View className='status-tabs'>
|
||||
<ScrollView className='tabs-scroll' scrollX>
|
||||
{STATUS_TABS.map((tab) => (
|
||||
<View
|
||||
key={tab.code}
|
||||
className={`tab-item ${activeTab === tab.code ? 'active' : ''}`}
|
||||
onTap={() => setActiveTab(tab.code)}
|
||||
>
|
||||
<Text className='tab-label'>{tab.label}</Text>
|
||||
{activeTab === tab.code && <View className='tab-underline' />}
|
||||
</View>
|
||||
))}
|
||||
</ScrollView>
|
||||
</View>
|
||||
|
||||
{/* 订单列表 */}
|
||||
<ScrollView className='orders-list' scrollY>
|
||||
{filteredOrders.map((order) => {
|
||||
const status = STATUS_MAP[order.statusCode] || STATUS_MAP.pending
|
||||
const iconSrc = order.productIcon?.startsWith('/icon/')
|
||||
? order.productIcon
|
||||
: '/icon/四角星.png'
|
||||
return (
|
||||
<View key={order.id} className='order-card dashed-card' onTap={() => goDetail(order)}>
|
||||
<View className='star-badge' />
|
||||
<View className='order-header'>
|
||||
<Text className='order-date'>{order.date}</Text>
|
||||
<Text className={`badge ${status.badge}`}>{status.label}</Text>
|
||||
</View>
|
||||
<View className='order-body'>
|
||||
<View className='order-icon-wrapper'>
|
||||
<Image className='order-icon-img' src={iconSrc} mode='aspectFit' />
|
||||
</View>
|
||||
<View className='order-info'>
|
||||
<Text className='order-product'>{order.productName}</Text>
|
||||
<Text className='order-sku'>{order.sku}</Text>
|
||||
<Text className='order-meta'>数量: {order.count} 件 | {order.id}</Text>
|
||||
</View>
|
||||
</View>
|
||||
<View className='order-footer'>
|
||||
<View className='order-price-bar'>
|
||||
<Text className='price-label'>实付款</Text>
|
||||
<Text className='price-value'>{order.price}</Text>
|
||||
</View>
|
||||
<View className='order-actions'>
|
||||
{order.statusCode === 'pending' && (
|
||||
<View className='btn-gradient order-btn'>
|
||||
<Text>立即付款</Text>
|
||||
</View>
|
||||
)}
|
||||
{order.statusCode === 'paid' && (
|
||||
<View className='btn-outline order-btn'>
|
||||
<Text>催发货</Text>
|
||||
</View>
|
||||
)}
|
||||
{order.statusCode === 'shipping' && (
|
||||
<>
|
||||
<View className='btn-outline order-btn' onTap={(e) => { e.stopPropagation(); Taro.showToast({ title: '查看物流', icon: 'none' }) }}>
|
||||
<Text>查看物流</Text>
|
||||
</View>
|
||||
<View className='btn-gradient order-btn' onTap={(e) => { e.stopPropagation(); Taro.showToast({ title: '确认收货', icon: 'none' }) }}>
|
||||
<Text>确认收货</Text>
|
||||
</View>
|
||||
</>
|
||||
)}
|
||||
{order.statusCode === 'done' && (
|
||||
<>
|
||||
<View className='btn-outline order-btn' onTap={(e) => { e.stopPropagation(); Taro.showToast({ title: '申请售后', icon: 'none' }) }}>
|
||||
<Text>申请售后</Text>
|
||||
</View>
|
||||
<View className='btn-gradient order-btn' onTap={(e) => { e.stopPropagation(); Taro.showToast({ title: '再来一单', icon: 'none' }) }}>
|
||||
<Text>再来一单</Text>
|
||||
</View>
|
||||
</>
|
||||
)}
|
||||
<View className='btn-outline order-btn' onTap={(e) => { e.stopPropagation(); goDetail(order) }}>
|
||||
<Text>详情</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
})}
|
||||
|
||||
{filteredOrders.length === 0 && (
|
||||
<View className='empty-state'>
|
||||
<Image className='empty-icon-img' src='/icon/包裹.png' mode='aspectFit' />
|
||||
<Text className='empty-text'>暂无订单</Text>
|
||||
<Text className='empty-sub'>去首页开始定制吧</Text>
|
||||
<View className='btn-gradient' onTap={() => Taro.switchTab({ url: '/pages/index/index' })}>
|
||||
<Text>去定制</Text>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
</ScrollView>
|
||||
|
||||
<View style={{ height: '40px' }} />
|
||||
</View>
|
||||
</LoginGuard>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export default definePageConfig({
|
||||
navigationStyle: 'custom',
|
||||
navigationBarTitleText: '商品详情'
|
||||
})
|
||||
@@ -0,0 +1 @@
|
||||
{}
|
||||
@@ -0,0 +1,237 @@
|
||||
.product-page {
|
||||
padding: 0 24px 24px;
|
||||
padding-bottom: calc(24px + 160px); /* 为底部 action-bar 留空 */
|
||||
}
|
||||
|
||||
/* 返回按钮 */
|
||||
.back-btn {
|
||||
font-size: 40px;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
/* Hero 轮播 */
|
||||
.hero-section {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.hero-swiper {
|
||||
height: 400rpx;
|
||||
}
|
||||
|
||||
.hero-card {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.hero-icon {
|
||||
font-size: 100px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.hero-icon-img {
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.hero-name {
|
||||
font-size: 36px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.hero-image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
/* 基本信息 */
|
||||
.info-section {
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.info-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 20px 0;
|
||||
border-bottom: 1px dashed rgba(0,0,0,0.06);
|
||||
}
|
||||
|
||||
.info-row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.info-label {
|
||||
font-size: 28px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.info-price {
|
||||
font-size: 40px;
|
||||
font-weight: 800;
|
||||
color: var(--accent-pink);
|
||||
}
|
||||
|
||||
.info-value {
|
||||
font-size: 28px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* 详细介绍 */
|
||||
.detail-section {
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.detail-text {
|
||||
font-size: 28px;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.8;
|
||||
}
|
||||
|
||||
/* 底部操作栏 */
|
||||
.action-bar .price-summary {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.summary-label {
|
||||
font-size: 22px;
|
||||
color: var(--text-secondary);
|
||||
display: block;
|
||||
}
|
||||
|
||||
.summary-price {
|
||||
font-size: 36px;
|
||||
font-weight: 700;
|
||||
color: var(--accent-pink);
|
||||
}
|
||||
|
||||
/* 弹窗 */
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
padding: 48px;
|
||||
}
|
||||
|
||||
.modal-card {
|
||||
width: 100%;
|
||||
max-width: 600px;
|
||||
padding: 40px;
|
||||
}
|
||||
|
||||
.modal-title {
|
||||
font-size: 36px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
text-align: center;
|
||||
margin-bottom: 24px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.modal-product {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 16px;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.modal-icon {
|
||||
font-size: 48px;
|
||||
}
|
||||
|
||||
.modal-icon-img {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
}
|
||||
|
||||
.modal-name {
|
||||
font-size: 32px;
|
||||
color: var(--text-primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.quantity-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.qty-label {
|
||||
font-size: 28px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.qty-control {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.qty-btn {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
border-radius: 12px;
|
||||
background: var(--bg-input);
|
||||
border: var(--line-card);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 32px;
|
||||
color: var(--text-primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.qty-num {
|
||||
font-size: 32px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
min-width: 48px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.modal-total {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 32px;
|
||||
padding-top: 24px;
|
||||
border-top: 1px dashed rgba(0,0,0,0.06);
|
||||
}
|
||||
|
||||
.total-label {
|
||||
font-size: 28px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.total-price {
|
||||
font-size: 36px;
|
||||
font-weight: 800;
|
||||
color: var(--accent-pink);
|
||||
}
|
||||
|
||||
.modal-actions {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.modal-actions .btn-outline,
|
||||
.modal-actions .btn-gradient {
|
||||
flex: 1;
|
||||
padding: 20px 0;
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
import { View, Text, Image, Swiper, SwiperItem } from '@tarojs/components'
|
||||
import Taro from '@tarojs/taro'
|
||||
import { useState } from 'react'
|
||||
import './index.scss'
|
||||
import { getProductById } from '../../utils/productConfig'
|
||||
import { addDesign } from '../../utils/store'
|
||||
import { useThemeContext } from '../../context/ThemeContext'
|
||||
import { useSafeArea } from '../../hooks/useSafeArea'
|
||||
import { useStatusBar } from '../../hooks/useStatusBar'
|
||||
import { getProductIconImg } from '../../utils/productConfig'
|
||||
|
||||
const HERO_COLORS = ['#FFE4EC', '#FFF0F5', '#FCE4EC']
|
||||
|
||||
export default function ProductPage() {
|
||||
const { theme, resolvedTheme } = useThemeContext()
|
||||
const safe = useSafeArea()
|
||||
useStatusBar(resolvedTheme)
|
||||
const [showModal, setShowModal] = useState(false)
|
||||
const [quantity, setQuantity] = useState(1)
|
||||
|
||||
const params = Taro.getCurrentInstance().router?.params
|
||||
const productId = params?.id || ''
|
||||
const product = getProductById(productId)
|
||||
|
||||
if (!product) {
|
||||
return (
|
||||
<View className={`theme-${resolvedTheme}`}>
|
||||
<View className='product-page'>
|
||||
<View className='page-header dashed-card mt-20' style={{ marginTop: `${safe.headerPaddingTop}px` }}>
|
||||
<Text className='page-title'>商品未找到</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
const handleAddToList = () => {
|
||||
setQuantity(1)
|
||||
setShowModal(true)
|
||||
}
|
||||
|
||||
const confirmAdd = () => {
|
||||
addDesign(product, quantity)
|
||||
setShowModal(false)
|
||||
Taro.showToast({ title: `已加入设计清单 x${quantity}`, icon: 'success' })
|
||||
setTimeout(() => Taro.navigateBack(), 1200)
|
||||
}
|
||||
|
||||
const goToDIY = () => {
|
||||
Taro.navigateTo({
|
||||
url: `/pages/diy/index?source=product&productId=${product.id}&quantity=1`
|
||||
})
|
||||
}
|
||||
|
||||
const modQty = (delta: number) => {
|
||||
setQuantity(q => Math.max(1, Math.min(99, q + delta)))
|
||||
}
|
||||
|
||||
return (
|
||||
<View className={`theme-${resolvedTheme}`}>
|
||||
<View className='product-page'>
|
||||
|
||||
<View className='page-header dashed-card mt-20' style={{ marginTop: `${safe.headerPaddingTop}px` }}>
|
||||
<View className='star-badge' />
|
||||
<View className='flex-between'>
|
||||
<Text className='back-btn' onTap={() => Taro.navigateBack()}>←</Text>
|
||||
<Text className='page-title'>商品详情</Text>
|
||||
<View style={{ width: '60px' }} />
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 轮播 Hero */}
|
||||
<View className='hero-section mt-20'>
|
||||
<Swiper
|
||||
className='hero-swiper'
|
||||
circular
|
||||
autoplay
|
||||
interval={3000}
|
||||
duration={500}
|
||||
indicatorDots
|
||||
indicatorColor='rgba(0,0,0,0.2)'
|
||||
indicatorActiveColor='#ff9a9e'
|
||||
>
|
||||
{product.images && product.images.length > 0 ? (
|
||||
product.images.map((img, idx) => (
|
||||
<SwiperItem key={idx}>
|
||||
<View className='hero-card dashed-card'>
|
||||
<View className='star-badge' />
|
||||
<Image className='hero-image' src={img} mode='aspectFit' />
|
||||
</View>
|
||||
</SwiperItem>
|
||||
))
|
||||
) : (
|
||||
HERO_COLORS.map((color, idx) => (
|
||||
<SwiperItem key={idx}>
|
||||
<View className='hero-card dashed-card' style={{ background: color }}>
|
||||
<View className='star-badge' />
|
||||
<Image className='hero-icon-img' src={getProductIconImg(product)} mode='aspectFit' />
|
||||
<Text className='hero-name'>{product.name}</Text>
|
||||
</View>
|
||||
</SwiperItem>
|
||||
))
|
||||
)}
|
||||
</Swiper>
|
||||
</View>
|
||||
|
||||
{/* 基本信息 */}
|
||||
<View className='info-section dashed-card mt-20'>
|
||||
<View className='star-badge' />
|
||||
<View className='info-row'>
|
||||
<Text className='info-label'>单价</Text>
|
||||
<Text className='info-price'>¥{product.price}</Text>
|
||||
</View>
|
||||
<View className='info-row'>
|
||||
<Text className='info-label'>工期</Text>
|
||||
<Text className='info-value'>{product.leadTime}</Text>
|
||||
</View>
|
||||
<View className='info-row'>
|
||||
<Text className='info-label'>尺寸</Text>
|
||||
<Text className='info-value'>{product.mask.width} × {product.mask.height} px</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 详细介绍 */}
|
||||
<View className='detail-section dashed-card mt-20'>
|
||||
<View className='star-badge' />
|
||||
<Text className='section-title'>商品介绍</Text>
|
||||
<Text className='detail-text'>{product.description}</Text>
|
||||
</View>
|
||||
|
||||
{/* 底部操作栏 */}
|
||||
<View className='action-bar'>
|
||||
<View className='price-summary'>
|
||||
<Text className='summary-label'>起价</Text>
|
||||
<Text className='summary-price'>¥{product.price}</Text>
|
||||
</View>
|
||||
<View className='btn-outline' onTap={handleAddToList}>
|
||||
<Text>加入设计清单</Text>
|
||||
</View>
|
||||
<View className='btn-gradient' onTap={goToDIY}>
|
||||
<Text>立即下单</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 安全区占位(防止内容被 action-bar 遮挡) */}
|
||||
<View className='safe-bottom-placeholder' />
|
||||
|
||||
{/* 数量选择弹窗 */}
|
||||
{showModal && (
|
||||
<View className='modal-overlay'>
|
||||
<View className='modal-card dashed-card'>
|
||||
<View className='star-badge' />
|
||||
<Text className='modal-title'>加入设计清单</Text>
|
||||
<View className='modal-product'>
|
||||
<Image className='modal-icon-img' src={getProductIconImg(product)} mode='aspectFit' />
|
||||
<Text className='modal-name'>{product.name}</Text>
|
||||
</View>
|
||||
<View className='quantity-row'>
|
||||
<Text className='qty-label'>数量</Text>
|
||||
<View className='qty-control'>
|
||||
<View className='qty-btn' onTap={() => modQty(-1)}>-</View>
|
||||
<Text className='qty-num'>{quantity}</Text>
|
||||
<View className='qty-btn' onTap={() => modQty(1)}>+</View>
|
||||
</View>
|
||||
</View>
|
||||
<View className='modal-total'>
|
||||
<Text className='total-label'>小计</Text>
|
||||
<Text className='total-price'>¥{(product.price * quantity).toFixed(2)}</Text>
|
||||
</View>
|
||||
<View className='modal-actions'>
|
||||
<View className='btn-outline' onTap={() => setShowModal(false)}>
|
||||
<Text>取消</Text>
|
||||
</View>
|
||||
<View className='btn-gradient' onTap={confirmAdd}>
|
||||
<Text>确定</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export default definePageConfig({
|
||||
navigationStyle: 'custom',
|
||||
navigationBarTitleText: '个人中心'
|
||||
})
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"usingComponents": {}
|
||||
}
|
||||
@@ -0,0 +1,288 @@
|
||||
.profile-page {
|
||||
padding: 0 24px 24px;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
/* 用户头部 */
|
||||
.profile-header {
|
||||
padding: 40px 32px 32px;
|
||||
margin: 20px 0 0;
|
||||
}
|
||||
|
||||
.user-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.avatar {
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
border-radius: 50%;
|
||||
border: 4px dashed var(--line-star);
|
||||
margin-right: 24px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.avatar-placeholder {
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, var(--bg-card) 0%, rgba(91, 140, 255, 0.1) 100%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-right: 24px;
|
||||
border: 4px dashed var(--line-star);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.avatar-icon {
|
||||
font-size: 56px;
|
||||
}
|
||||
|
||||
.user-name {
|
||||
font-size: 40px;
|
||||
font-weight: 800;
|
||||
color: var(--text-primary);
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.user-level {
|
||||
font-size: 24px;
|
||||
color: var(--text-secondary);
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* 5状态快捷入口 - 2+3布局 */
|
||||
.status-grid {
|
||||
background: var(--bg-card);
|
||||
border: var(--line-card);
|
||||
border-radius: 20px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.status-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-around;
|
||||
padding: 20px 0;
|
||||
}
|
||||
|
||||
/* 每组(item + divider)作为一个 flex 单元 */
|
||||
.status-group {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.status-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 8px 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.status-num {
|
||||
font-size: 36px;
|
||||
font-weight: 800;
|
||||
color: var(--accent-pink);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.status-label {
|
||||
font-size: 24px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.status-divider {
|
||||
width: 2px;
|
||||
height: 48px;
|
||||
background: var(--line-star);
|
||||
opacity: 0.25;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.status-divider-h {
|
||||
height: 2px;
|
||||
background: var(--line-star);
|
||||
opacity: 0.15;
|
||||
margin: 0 16px;
|
||||
}
|
||||
|
||||
/* 菜单网格 */
|
||||
.menu-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr 1fr;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.menu-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 32px 16px;
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
|
||||
.menu-card:active {
|
||||
transform: scale(0.96);
|
||||
}
|
||||
|
||||
.menu-icon {
|
||||
font-size: 48px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.menu-label {
|
||||
font-size: 26px;
|
||||
color: var(--text-primary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* 企业定制 */
|
||||
.enterprise-section {
|
||||
padding: 32px;
|
||||
}
|
||||
|
||||
.enterprise-title {
|
||||
font-size: 30px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.enterprise-desc {
|
||||
font-size: 24px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.enterprise-btn {
|
||||
padding: 16px 32px !important;
|
||||
font-size: 26px !important;
|
||||
border-radius: 40px !important;
|
||||
}
|
||||
|
||||
.menu-icon-img {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
/* 状态 icon 图片 */
|
||||
.status-icon-img {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
/* 企业定制icon */
|
||||
.enterprise-icon {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
margin-right: 12px;
|
||||
}
|
||||
|
||||
/* 头像占位图 */
|
||||
.avatar-icon-img {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
}
|
||||
|
||||
/* 登录弹窗头像占位 */
|
||||
.avatar-placeholder-img {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
}
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
padding: 48px;
|
||||
}
|
||||
|
||||
.login-card {
|
||||
width: 100%;
|
||||
max-width: 600px;
|
||||
padding: 40px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.login-title {
|
||||
font-size: 36px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
display: block;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.login-desc {
|
||||
font-size: 26px;
|
||||
color: var(--text-secondary);
|
||||
display: block;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.avatar-btn {
|
||||
width: 160px;
|
||||
height: 160px;
|
||||
border-radius: 50%;
|
||||
margin: 0 auto 24px;
|
||||
background: var(--bg-input);
|
||||
border: 3px dashed var(--line-star);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
padding: 0;
|
||||
line-height: 1;
|
||||
font-size: 0;
|
||||
}
|
||||
|
||||
.avatar-img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.avatar-placeholder {
|
||||
font-size: 24px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.nickname-input {
|
||||
width: 100%;
|
||||
height: 80px;
|
||||
background: var(--bg-input);
|
||||
border: 3px dashed var(--line-star);
|
||||
border-radius: 16px;
|
||||
padding: 0 24px;
|
||||
font-size: 28px;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 32px;
|
||||
text-align: center;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.login-actions {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.login-actions .btn-outline,
|
||||
.login-actions .btn-gradient {
|
||||
flex: 1;
|
||||
padding: 20px 0;
|
||||
text-align: center;
|
||||
}
|
||||
@@ -0,0 +1,421 @@
|
||||
import { View, Text, Image, Input, Button } from '@tarojs/components'
|
||||
import Taro, { useDidShow } from '@tarojs/taro'
|
||||
import { useState } from 'react'
|
||||
import './index.scss'
|
||||
import { getOrderList, getUserInfo, setUserInfo, getDesignList } from '../../utils/store'
|
||||
import { useThemeContext } from '../../context/ThemeContext'
|
||||
import { useSafeArea } from '../../hooks/useSafeArea'
|
||||
import { useStatusBar } from '../../hooks/useStatusBar'
|
||||
import { login, getMe, updateProfile, getToken } from '../../utils/api'
|
||||
import { safeHideLoading } from '../../utils/request'
|
||||
|
||||
const ICON_MAP: Record<string, string> = {
|
||||
'我的设计': '/icon/调色盘.png',
|
||||
'词云生成': '/icon/词云生成.png',
|
||||
'收货地址': '/icon/地址.png',
|
||||
'联系客服': '/icon/电话.png',
|
||||
'使用帮助': '/icon/使用帮助.png',
|
||||
'设置': '/icon/设置.png'
|
||||
}
|
||||
|
||||
const STATUS_MAP = [
|
||||
{ key: 'toDesign', label: '待设计', icon: '/icon/三角尺.png' },
|
||||
{ key: 'pending', label: '待付款', icon: '/icon/四角星.png' },
|
||||
{ key: 'paid', label: '待发货', icon: '/icon/包裹.png' },
|
||||
{ key: 'shipping', label: '待收货', icon: '/icon/杯子.png' },
|
||||
{ key: 'done', label: '已完成', icon: '/icon/书本.png' }
|
||||
]
|
||||
|
||||
const MENU_ITEMS = [
|
||||
{ label: '我的设计', path: '/pages/designList/index' },
|
||||
{ label: '词云生成', path: '/pages/wordcloud/index' },
|
||||
{ label: '收货地址', path: '/pages/address/index' },
|
||||
{ label: '联系客服', path: '/pages/service/index' },
|
||||
{ label: '使用帮助', path: '/pages/agreement/index' },
|
||||
{ label: '设置', path: '/pages/settings/index' }
|
||||
]
|
||||
|
||||
// Stable no-op: Taro runtime crashes when an event handler is removed from a
|
||||
// Text/Image element (pure-text is missing from componentsAlias). Keeping
|
||||
// onTap as a function at all times avoids the removeEventListener side-effect
|
||||
// that triggers the crash.
|
||||
const noop = () => {}
|
||||
|
||||
export default function ProfilePage() {
|
||||
const { theme, resolvedTheme } = useThemeContext()
|
||||
const safe = useSafeArea()
|
||||
useStatusBar(resolvedTheme)
|
||||
const [isLoggedIn, setIsLoggedIn] = useState(false)
|
||||
const [userInfo, setUserInfoState] = useState({ nickName: '', avatarUrl: '' })
|
||||
const [showLogin, setShowLogin] = useState(false)
|
||||
// loginStep: 'entry' 显示登录按钮;'fill' 新用户补全资料
|
||||
const [loginStep, setLoginStep] = useState<'entry' | 'fill'>('entry')
|
||||
const [loginNickName, setLoginNickName] = useState('')
|
||||
const [loginAvatarUrl, setLoginAvatarUrl] = useState('')
|
||||
const [loginLoading, setLoginLoading] = useState(false)
|
||||
const [orderStats, setOrderStats] = useState({
|
||||
toDesign: 0, pending: 0, paid: 0, shipping: 0, done: 0
|
||||
})
|
||||
|
||||
const loadStats = () => {
|
||||
const orders = getOrderList()
|
||||
const designs = getDesignList()
|
||||
setOrderStats({
|
||||
toDesign: designs.filter(d => d.status === 'undesigned' || d.status === 'designing').length,
|
||||
pending: orders.filter(o => o.statusCode === 'pending').length,
|
||||
paid: orders.filter(o => o.statusCode === 'paid').length,
|
||||
shipping: orders.filter(o => o.statusCode === 'shipping').length,
|
||||
done: orders.filter(o => o.statusCode === 'done').length
|
||||
})
|
||||
}
|
||||
|
||||
const sync = () => {
|
||||
const info = getUserInfo()
|
||||
if (info?.openid) {
|
||||
setIsLoggedIn(true)
|
||||
setUserInfoState({ nickName: info.nickName || '', avatarUrl: info.avatarUrl || '' })
|
||||
} else {
|
||||
setIsLoggedIn(false)
|
||||
setUserInfoState({ nickName: '', avatarUrl: '' })
|
||||
}
|
||||
loadStats()
|
||||
}
|
||||
|
||||
useDidShow(() => {
|
||||
sync()
|
||||
})
|
||||
|
||||
const handleOpenLogin = () => {
|
||||
setLoginStep('entry')
|
||||
setLoginNickName('')
|
||||
setLoginAvatarUrl('')
|
||||
setShowLogin(true)
|
||||
}
|
||||
const handleCloseLogin = () => setShowLogin(false)
|
||||
|
||||
const loginFail = (msg: string) => {
|
||||
setLoginLoading(false)
|
||||
safeHideLoading()
|
||||
Taro.showToast({ title: msg, icon: 'none' })
|
||||
}
|
||||
|
||||
const loginFinish = (accessToken: string, openid: string, nickName: string, avatarUrl: string) => {
|
||||
const info = {
|
||||
openid,
|
||||
nickName: nickName || '微信用户',
|
||||
avatarUrl: avatarUrl || '',
|
||||
accessToken,
|
||||
loginAt: Date.now()
|
||||
}
|
||||
setUserInfo(info)
|
||||
setUserInfoState({ nickName: info.nickName, avatarUrl: info.avatarUrl })
|
||||
setIsLoggedIn(true)
|
||||
setShowLogin(false)
|
||||
setLoginStep('entry')
|
||||
setLoginLoading(false)
|
||||
safeHideLoading()
|
||||
Taro.showToast({ title: '登录成功', icon: 'success' })
|
||||
}
|
||||
|
||||
/**
|
||||
* 登录:wx.login 拿 code -> 后端判断新老用户
|
||||
* - 老用户(isNewUser=false) -> 直接用库里已有的昵称/头像完成,免设置
|
||||
* - 新用户(isNewUser=true) -> 切到 'fill' 步骤补全资料
|
||||
*/
|
||||
const handleLogin = async () => {
|
||||
if (loginLoading) return
|
||||
console.log('[Profile] 登录按钮被点击,开始登录流程')
|
||||
setLoginLoading(true)
|
||||
Taro.showLoading({ title: '登录中', mask: true })
|
||||
try {
|
||||
const wxLogin = await Taro.login()
|
||||
console.log('[Profile] wx.login 成功,code=', wxLogin.code)
|
||||
const result = await login(wxLogin.code)
|
||||
console.log('[Profile] 后端 /auth/login 返回:', JSON.stringify(result))
|
||||
if (!result?.accessToken) {
|
||||
loginFail('登录失败,未拿到 token')
|
||||
return
|
||||
}
|
||||
// 取 openid 落本地
|
||||
let openid = ''
|
||||
try {
|
||||
const me = await getMe()
|
||||
openid = me?.openid || ''
|
||||
} catch {
|
||||
openid = ''
|
||||
}
|
||||
if (!openid) {
|
||||
loginFail('登录异常,请重试')
|
||||
return
|
||||
}
|
||||
|
||||
// 资料不完整(新用户,或老用户从未设置过资料)都引导补填
|
||||
const needProfile = result.isNewUser || !result.nickname || !result.avatar
|
||||
if (needProfile) {
|
||||
// 预填已有值(若老用户有一部分已设置)
|
||||
setLoginNickName(result.nickname || '')
|
||||
setLoginAvatarUrl(result.avatar || '')
|
||||
safeHideLoading()
|
||||
setLoginLoading(false)
|
||||
setLoginStep('fill')
|
||||
Taro.showToast({ title: '请设置头像和昵称', icon: 'none' })
|
||||
return
|
||||
}
|
||||
|
||||
// 资料完整的老用户:直接用库里已有资料
|
||||
loginFinish(result.accessToken, openid, result.nickname || '', result.avatar || '')
|
||||
} catch (e) {
|
||||
console.error('[Profile] 登录流程出错:', e)
|
||||
loginFail((e as Error)?.message || '后端连接失败,请检查网络')
|
||||
}
|
||||
}
|
||||
|
||||
/** 新用户补全资料后保存到后端并完成登录 */
|
||||
const handleSaveProfile = async () => {
|
||||
if (loginLoading) return
|
||||
setLoginLoading(true)
|
||||
Taro.showLoading({ title: '保存中', mask: true })
|
||||
try {
|
||||
// 把昵称/头像存到后端(如需真实 openid 可再 getMe)
|
||||
await updateProfile({
|
||||
nickname: loginNickName || undefined,
|
||||
avatar: loginAvatarUrl || undefined
|
||||
})
|
||||
let openid = ''
|
||||
try {
|
||||
const me = await getMe()
|
||||
openid = me?.openid || ''
|
||||
} catch {
|
||||
openid = ''
|
||||
}
|
||||
if (!openid) {
|
||||
loginFail('保存异常,请重试')
|
||||
return
|
||||
}
|
||||
const stored = getUserInfo()
|
||||
loginFinish(
|
||||
stored?.accessToken || getToken(),
|
||||
openid,
|
||||
loginNickName,
|
||||
loginAvatarUrl
|
||||
)
|
||||
} catch (e) {
|
||||
console.error('[Profile] 保存资料出错:', e)
|
||||
loginFail((e as Error)?.message || '保存失败')
|
||||
}
|
||||
}
|
||||
|
||||
const onChooseAvatar = (e: any) => {
|
||||
setLoginAvatarUrl(e.detail.avatarUrl || '')
|
||||
}
|
||||
|
||||
const handleQuickClick = (type: string) => {
|
||||
Taro.hideToast()
|
||||
switch (type) {
|
||||
case 'toDesign':
|
||||
Taro.setStorageSync('designList:filter', 'toDesign')
|
||||
Taro.switchTab({ url: '/pages/designList/index' })
|
||||
break
|
||||
case 'pending':
|
||||
Taro.setStorageSync('orders:filter', 'pending')
|
||||
Taro.switchTab({ url: '/pages/orders/index' })
|
||||
break
|
||||
case 'paid':
|
||||
Taro.setStorageSync('orders:filter', 'paid')
|
||||
Taro.switchTab({ url: '/pages/orders/index' })
|
||||
break
|
||||
case 'shipping':
|
||||
Taro.setStorageSync('orders:filter', 'shipping')
|
||||
Taro.switchTab({ url: '/pages/orders/index' })
|
||||
break
|
||||
case 'done':
|
||||
Taro.setStorageSync('orders:filter', 'done')
|
||||
Taro.switchTab({ url: '/pages/orders/index' })
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
const handleMenuClick = (path: string, label: string) => {
|
||||
// Clear any lingering toast that could intercept the subsequent navigateTo
|
||||
Taro.hideToast()
|
||||
if (!isLoggedIn && (path.includes('designList') || path.includes('orders'))) {
|
||||
Taro.showToast({ title: '请先登录', icon: 'none' })
|
||||
setShowLogin(true)
|
||||
return
|
||||
}
|
||||
if (label === '联系客服') {
|
||||
Taro.navigateTo({
|
||||
url: '/pages/service/index',
|
||||
fail: (err: any) => {
|
||||
console.error('navigateTo service fail', err)
|
||||
Taro.showToast({ title: '跳转失败', icon: 'none' })
|
||||
}
|
||||
})
|
||||
return
|
||||
}
|
||||
if (path.startsWith('/pages/')) {
|
||||
if (path.includes('designList') || path.includes('orders')) {
|
||||
Taro.switchTab({
|
||||
url: path,
|
||||
fail: (err: any) => {
|
||||
console.error('switchTab fail', err)
|
||||
Taro.showToast({ title: '跳转失败', icon: 'none' })
|
||||
}
|
||||
})
|
||||
} else {
|
||||
Taro.navigateTo({
|
||||
url: path,
|
||||
fail: (err: any) => {
|
||||
console.error('navigateTo fail', err)
|
||||
Taro.showToast({ title: '跳转失败', icon: 'none' })
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<View className={`theme-${resolvedTheme}`} style={{ minHeight: '100vh' }}>
|
||||
<View className='profile-page'>
|
||||
|
||||
{/* 用户信息头部 */}
|
||||
<View className='profile-header dashed-card mt-20' style={{ marginTop: `${safe.headerPaddingTop}px` }}>
|
||||
<View className='star-badge' />
|
||||
<View className='user-info'>
|
||||
{isLoggedIn && userInfo.avatarUrl ? (
|
||||
<Image className='avatar' src={userInfo.avatarUrl} mode='aspectFill' />
|
||||
) : (
|
||||
<View className='avatar-placeholder' onTap={handleOpenLogin}>
|
||||
<Image className='avatar-icon-img' src='/icon/个人.png' mode='aspectFit' />
|
||||
</View>
|
||||
)}
|
||||
<View className='user-meta'>
|
||||
<Text className='user-name' onTap={isLoggedIn ? noop : handleOpenLogin}>
|
||||
{isLoggedIn ? userInfo.nickName : '点击登录'}
|
||||
</Text>
|
||||
<Text className='user-level'>
|
||||
{isLoggedIn ? '智绘微刻会员' : '授权微信,开启专属定制'}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 5 状态快捷入口 */}
|
||||
<View className='status-grid'>
|
||||
<View className='status-row row2'>
|
||||
{STATUS_MAP.slice(0, 2).map((s) => (
|
||||
<View key={s.key} className='status-group'>
|
||||
<View className='status-item' onTap={() => handleQuickClick(s.key)}>
|
||||
<Image className='status-icon-img' src={s.icon} mode='aspectFit' />
|
||||
<Text className='status-num'>{orderStats[s.key as keyof typeof orderStats]}</Text>
|
||||
<Text className='status-label'>{s.label}</Text>
|
||||
</View>
|
||||
<View className='status-divider' />
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
<View className='status-divider-h' />
|
||||
<View className='status-row row3'>
|
||||
{STATUS_MAP.slice(2).map((s, idx) => (
|
||||
<View key={s.key} className='status-group'>
|
||||
<View className='status-item' onTap={() => handleQuickClick(s.key)}>
|
||||
<Image className='status-icon-img' src={s.icon} mode='aspectFit' />
|
||||
<Text className='status-num'>{orderStats[s.key as keyof typeof orderStats]}</Text>
|
||||
<Text className='status-label'>{s.label}</Text>
|
||||
</View>
|
||||
{idx < 2 && <View className='status-divider' />}
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 功能菜单 6宫格 */}
|
||||
<View className='mt-20'>
|
||||
<Text className='section-title'>常用功能</Text>
|
||||
<View className='menu-grid'>
|
||||
{MENU_ITEMS.map((item, idx) => (
|
||||
<View key={idx} className='menu-card dashed-card' onTap={() => handleMenuClick(item.path, item.label)}>
|
||||
<View className='star-badge' />
|
||||
<Image className='menu-icon-img' src={ICON_MAP[item.label]} mode='aspectFit' />
|
||||
<Text className='menu-label'>{item.label}</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 企业定制入口 */}
|
||||
<View className='enterprise-section dashed-card mt-20'>
|
||||
<View className='star-badge' />
|
||||
<View className='flex-between'>
|
||||
<View className='flex-column'>
|
||||
<View className='flex-center'>
|
||||
<Image className='enterprise-icon' src='/icon/企业批量定制.png' mode='aspectFit' />
|
||||
<Text className='enterprise-title'>企业批量定制</Text>
|
||||
</View>
|
||||
<Text className='enterprise-desc'>年会礼品、入职纪念、团建伴手礼</Text>
|
||||
</View>
|
||||
<View className='btn-gradient enterprise-btn'>
|
||||
<Text>立即咨询</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={{ height: '40px' }} />
|
||||
|
||||
{/* 登录弹窗 */}
|
||||
{showLogin && (
|
||||
<View className='modal-overlay'>
|
||||
<View className='login-card dashed-card'>
|
||||
<View className='star-badge' />
|
||||
<Text className='login-title'>登录智绘微刻</Text>
|
||||
|
||||
{loginStep === 'entry' ? (
|
||||
<>
|
||||
<Text className='login-desc'>登录后同步您的设计清单和订单</Text>
|
||||
<View className='login-actions'>
|
||||
<View className='btn-outline' onTap={handleCloseLogin}>
|
||||
<Text>取消</Text>
|
||||
</View>
|
||||
<View className='btn-gradient' onTap={handleLogin}>
|
||||
<Text>微信登录</Text>
|
||||
</View>
|
||||
</View>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Text className='login-desc'>请设置您的头像和昵称</Text>
|
||||
<Button
|
||||
className='avatar-btn'
|
||||
openType='chooseAvatar'
|
||||
onChooseAvatar={onChooseAvatar}
|
||||
>
|
||||
{loginAvatarUrl ? (
|
||||
<Image className='avatar-img' src={loginAvatarUrl} mode='aspectFill' />
|
||||
) : (
|
||||
<Image className='avatar-placeholder-img' src='/icon/个人.png' mode='aspectFit' />
|
||||
)}
|
||||
</Button>
|
||||
<Input
|
||||
type='nickname'
|
||||
className='nickname-input'
|
||||
placeholder='请输入昵称'
|
||||
value={loginNickName}
|
||||
onInput={(e: any) => setLoginNickName(e.detail.value)}
|
||||
/>
|
||||
<View className='login-actions'>
|
||||
<View className='btn-outline' onTap={handleCloseLogin}>
|
||||
<Text>取消</Text>
|
||||
</View>
|
||||
<View className='btn-gradient' onTap={handleSaveProfile}>
|
||||
<Text>保存</Text>
|
||||
</View>
|
||||
</View>
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export default definePageConfig({
|
||||
navigationStyle: 'custom',
|
||||
navigationBarTitleText: '客服中心'
|
||||
})
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"usingComponents": {}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
.service-page {
|
||||
padding: 0 24px 24px;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
padding: 32px 32px 24px;
|
||||
margin: 20px 0 0;
|
||||
}
|
||||
|
||||
/* 客服英雄区 */
|
||||
.service-hero {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
padding: 50px 40px;
|
||||
}
|
||||
.service-hero-icon {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.service-title {
|
||||
font-size: 36px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
display: block;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.service-desc {
|
||||
font-size: 24px;
|
||||
color: var(--text-secondary);
|
||||
display: block;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
.service-btn {
|
||||
padding: 24px 56px !important;
|
||||
font-size: 30px !important;
|
||||
border-radius: 50px !important;
|
||||
}
|
||||
|
||||
/* 联系方式 */
|
||||
.contact-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
.contact-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 28px 32px;
|
||||
}
|
||||
.contact-icon {
|
||||
font-size: 48px;
|
||||
margin-right: 24px;
|
||||
}
|
||||
|
||||
.contact-icon-img {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
margin-right: 24px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.contact-info {
|
||||
flex: 1;
|
||||
}
|
||||
.contact-label {
|
||||
font-size: 24px;
|
||||
color: var(--text-secondary);
|
||||
display: block;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.contact-value {
|
||||
font-size: 30px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* FAQ */
|
||||
.faq-card {
|
||||
margin-bottom: 20px;
|
||||
padding: 28px 32px;
|
||||
}
|
||||
.faq-q {
|
||||
font-size: 30px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
display: block;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.faq-a {
|
||||
font-size: 26px;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
/* 企业专属 */
|
||||
.enterprise-service {
|
||||
padding: 32px;
|
||||
}
|
||||
.enterprise-icon {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
margin-right: 12px;
|
||||
}
|
||||
|
||||
.enterprise-title {
|
||||
font-size: 30px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.enterprise-desc {
|
||||
font-size: 24px;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 24px;
|
||||
display: block;
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import { View, Text, Image } from '@tarojs/components'
|
||||
import Taro from '@tarojs/taro'
|
||||
import './index.scss'
|
||||
import { useThemeContext } from '../../context/ThemeContext'
|
||||
import { useSafeArea } from '../../hooks/useSafeArea'
|
||||
import { useStatusBar } from '../../hooks/useStatusBar'
|
||||
|
||||
const FAQ_ITEMS = [
|
||||
{ q: '定制周期需要多久?', a: '通常下单后3-5个工作日内发货,批量订单(50件以上)可能需要7-10个工作日。' },
|
||||
{ q: '可以修改设计稿吗?', a: '生产前可随时在设计工作台修改。确认下单后进入排队生产阶段,不可再修改。' },
|
||||
{ q: '支持退款/售后吗?', a: '非质量问题定制商品不支持7天无理由退款。如收到商品有破损或雕刻缺陷,请在签收48小时内联系客服处理。' },
|
||||
{ q: '可以开发票吗?', a: '支持开具增值税普通发票。请在下单时填写发票信息,或联系客服补开。' }
|
||||
]
|
||||
|
||||
export default function ServicePage() {
|
||||
const { theme, resolvedTheme } = useThemeContext()
|
||||
const safe = useSafeArea()
|
||||
useStatusBar(resolvedTheme)
|
||||
|
||||
return (
|
||||
<View className={`theme-${resolvedTheme}`} style={{ minHeight: '100vh' }}>
|
||||
<View className='service-page'>
|
||||
<View className='page-header dashed-card mt-20' style={{ marginTop: `${safe.headerPaddingTop}px` }}>
|
||||
<View className='star-badge' />
|
||||
<Text className='page-title'>联系客服</Text>
|
||||
</View>
|
||||
|
||||
{/* 客服入口 */}
|
||||
<View className='service-hero dashed-card mt-20'>
|
||||
<View className='star-badge' />
|
||||
<Image className='service-hero-icon' src='/icon/电话.png' mode='aspectFit' />
|
||||
<Text className='service-title'>智绘微刻客服中心</Text>
|
||||
<Text className='service-desc'>工作日 09:00 - 18:00 在线为您服务</Text>
|
||||
<View className='btn-gradient service-btn' onTap={() => Taro.showToast({ title: '跳转客服会话', icon: 'none' })}>
|
||||
<Text>发起咨询</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 联系方式 */}
|
||||
<View className='mt-20'>
|
||||
<Text className='section-title'>联系方式</Text>
|
||||
<View className='contact-list'>
|
||||
<View className='contact-item dashed-card'>
|
||||
<View className='star-badge' />
|
||||
<Image className='contact-icon-img' src='/icon/电话.png' mode='aspectFit' />
|
||||
<View className='contact-info'>
|
||||
<Text className='contact-label'>客服电话</Text>
|
||||
<Text className='contact-value'>400-XXX-XXXX</Text>
|
||||
</View>
|
||||
</View>
|
||||
<View className='contact-item dashed-card' onTap={() => Taro.setClipboardData({ data: 'smart_engraving' })}>
|
||||
<View className='star-badge' />
|
||||
<Image className='contact-icon-img' src='/icon/词云生成.png' mode='aspectFit' />
|
||||
<View className='contact-info'>
|
||||
<Text className='contact-label'>客服微信</Text>
|
||||
<Text className='contact-value'>smart_engraving(点击复制)</Text>
|
||||
</View>
|
||||
</View>
|
||||
<View className='contact-item dashed-card'>
|
||||
<View className='star-badge' />
|
||||
<Image className='contact-icon-img' src='/icon/使用帮助.png' mode='aspectFit' />
|
||||
<View className='contact-info'>
|
||||
<Text className='contact-label'>商务邮箱</Text>
|
||||
<Text className='contact-value'>biz@smart-engraving.com</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 常见问题 */}
|
||||
<View className='mt-20'>
|
||||
<Text className='section-title'>常见问题</Text>
|
||||
{FAQ_ITEMS.map((item, idx) => (
|
||||
<View key={idx} className='faq-card dashed-card'>
|
||||
<View className='star-badge' />
|
||||
<Text className='faq-q'>Q: {item.q}</Text>
|
||||
<Text className='faq-a'>A: {item.a}</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
|
||||
{/* B端专属 */}
|
||||
<View className='enterprise-service dashed-card mt-20 mb-20'>
|
||||
<View className='star-badge' />
|
||||
<View className='flex-center'>
|
||||
<Image className='enterprise-icon' src='/icon/企业批量定制.png' mode='aspectFit' />
|
||||
<Text className='enterprise-title'>企业专属客户经理</Text>
|
||||
</View>
|
||||
<Text className='enterprise-desc'>批量订单(50件以上)享专属报价、优先排产、专人对接</Text>
|
||||
<View className='btn-outline enterprise-btn'>
|
||||
<Text>预约企业咨询</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={{ height: '40px' }} />
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export default definePageConfig({
|
||||
navigationStyle: 'custom',
|
||||
navigationBarTitleText: '设置'
|
||||
})
|
||||
@@ -0,0 +1,144 @@
|
||||
.settings-page {
|
||||
padding: 0 24px 24px;
|
||||
}
|
||||
.settings-area {
|
||||
padding-top: calc(env(safe-area-inset-top, 0px));
|
||||
}
|
||||
|
||||
.settings-user {
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.settings-avatar {
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
border-radius: 50%;
|
||||
border: 3px dashed var(--line-star);
|
||||
}
|
||||
|
||||
.settings-avatar-placeholder {
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
border-radius: 50%;
|
||||
background: var(--bg-input);
|
||||
border: 3px dashed var(--line-star);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 30px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.settings-name {
|
||||
font-size: 32px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.settings-id {
|
||||
font-size: 24px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.arrow {
|
||||
font-size: 36px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.settings-list {
|
||||
padding: 0 24px;
|
||||
}
|
||||
|
||||
.settings-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 28px 0;
|
||||
}
|
||||
|
||||
.settings-item.bordered {
|
||||
border-bottom: 1px dashed rgba(0,0,0,0.06);
|
||||
}
|
||||
|
||||
.settings-item-icon-img {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
margin-right: 16px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
|
||||
.settings-item-label {
|
||||
flex: 1;
|
||||
font-size: 30px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.logout-btn {
|
||||
text-align: center;
|
||||
padding: 24px 0;
|
||||
color: #ff6b6b;
|
||||
border: 3px dashed #ff6b6b;
|
||||
}
|
||||
|
||||
/* --- 主题选择 --- */
|
||||
.settings-section {
|
||||
padding: 32px;
|
||||
}
|
||||
|
||||
.section-label {
|
||||
font-size: 30px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
display: block;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.theme-options {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.theme-option {
|
||||
flex: 1;
|
||||
min-width: 160px;
|
||||
padding: 24px 16px;
|
||||
border-radius: 20px;
|
||||
background: var(--bg-input);
|
||||
border: 3px dashed transparent;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.theme-option.active {
|
||||
border-color: var(--accent-pink);
|
||||
background: rgba(255, 154, 158, 0.08);
|
||||
}
|
||||
|
||||
.theme-option-text {
|
||||
font-size: 28px;
|
||||
color: var(--text-primary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.theme-check {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent-pink);
|
||||
}
|
||||
|
||||
.theme-hint {
|
||||
font-size: 24px;
|
||||
color: var(--text-secondary);
|
||||
margin-top: 20px;
|
||||
display: block;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* 弹窗复用 address 弹窗和 profile 弹窗样式 */
|
||||
@@ -0,0 +1,175 @@
|
||||
import { View, Text, Image, Input, Button } from '@tarojs/components'
|
||||
import Taro from '@tarojs/taro'
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import './index.scss'
|
||||
import { useThemeContext } from '../../context/ThemeContext'
|
||||
import { useSafeArea } from '../../hooks/useSafeArea'
|
||||
import { useStatusBar } from '../../hooks/useStatusBar'
|
||||
import { getUserInfo, setUserInfo, clearUserInfo, getAddressList, getDefaultAddress, type AddressItem } from '../../utils/store'
|
||||
|
||||
const THEME_OPTIONS: { label: string; value: 'light' | 'dark' | 'auto' }[] = [
|
||||
{ label: '浅色模式', value: 'light' },
|
||||
{ label: '深色模式', value: 'dark' },
|
||||
{ label: '跟随系统', value: 'auto' }
|
||||
]
|
||||
|
||||
const MENU = [
|
||||
{ label: '个人信息', icon: '/icon/个人.png', action: (s: any) => s.setShowEdit(true) },
|
||||
{ label: '账号管理', icon: '/icon/账号管理.png', action: () => Taro.navigateTo({ url: '/pages/userDatabase/index' }) },
|
||||
{ label: '定制协议', icon: '/icon/定制协议.png', action: () => Taro.navigateTo({ url: '/pages/agreement/index' }) },
|
||||
{ label: '关于我们', icon: '/icon/关于我们.png', action: () => Taro.showToast({ title: '智绘微刻 v1.0', icon: 'none' }) }
|
||||
]
|
||||
|
||||
export default function SettingsPage() {
|
||||
const { theme, setTheme, resolvedTheme } = useThemeContext()
|
||||
const safe = useSafeArea()
|
||||
useStatusBar(resolvedTheme)
|
||||
const [user, setUser] = useState<any>({})
|
||||
const [editName, setEditName] = useState('')
|
||||
const [editAvatar, setEditAvatar] = useState('')
|
||||
const [showEdit, setShowEdit] = useState(false)
|
||||
|
||||
const loadUser = () => {
|
||||
const u = getUserInfo() || {}
|
||||
setUser(u)
|
||||
setEditName(u.nickName || '')
|
||||
setEditAvatar(u.avatarUrl || '')
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
loadUser()
|
||||
}, [])
|
||||
|
||||
const handleSaveInfo = () => {
|
||||
setUserInfo({ ...user, nickName: editName, avatarUrl: editAvatar })
|
||||
Taro.showToast({ title: '修改成功', icon: 'success' })
|
||||
setShowEdit(false)
|
||||
loadUser()
|
||||
}
|
||||
|
||||
const handleLogout = () => {
|
||||
Taro.showModal({
|
||||
title: '确认退出',
|
||||
content: '退出后将清空当前登录状态,确定吗?',
|
||||
success: (res) => {
|
||||
if (res.confirm) {
|
||||
clearUserInfo()
|
||||
Taro.switchTab({ url: '/pages/index/index' })
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const onChooseAvatar = (e: any) => {
|
||||
setEditAvatar(e.detail.avatarUrl || '')
|
||||
}
|
||||
|
||||
return (
|
||||
<View className={`theme-${resolvedTheme}`} style={{ minHeight: '100vh' }}>
|
||||
<View className='settings-page'>
|
||||
<View className='settings-area'>
|
||||
|
||||
<View className='page-header dashed-card mt-20' style={{ marginTop: `${safe.headerPaddingTop}px` }}>
|
||||
<View className='flex-between' style={{ width: '100%' }}>
|
||||
<Text className='back-btn' onTap={() => Taro.navigateBack()}>←</Text>
|
||||
<Text className='page-title'>设置</Text>
|
||||
<View style={{ width: '60px' }} />
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 用户卡片 */}
|
||||
<View className='settings-user dashed-card mt-20'>
|
||||
<View className='flex-between' onTap={() => setShowEdit(true)}>
|
||||
<View className='flex-center'>
|
||||
{user.avatarUrl ? (
|
||||
<Image className='settings-avatar' src={user.avatarUrl} mode='aspectFill' />
|
||||
) : (
|
||||
<View className='settings-avatar-placeholder'>
|
||||
<Image className='settings-avatar-icon' src='/icon/个人.png' mode='aspectFit' />
|
||||
</View>
|
||||
)}
|
||||
<View style={{ marginLeft: '20px' }}>
|
||||
<Text className='settings-name'>{user.nickName || '未登录'}</Text>
|
||||
<Text className='settings-id'>ID: {user.openid ? user.openid.slice(-8) : '---'}</Text>
|
||||
</View>
|
||||
</View>
|
||||
<Text className='arrow'>›</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 主题设置 */}
|
||||
<View className='settings-section dashed-card mt-20'>
|
||||
<Text className='section-label'>外观</Text>
|
||||
<View className='theme-options'>
|
||||
{THEME_OPTIONS.map(opt => (
|
||||
<View
|
||||
key={opt.value}
|
||||
className={`theme-option ${theme === opt.value ? 'active' : ''}`}
|
||||
onTap={() => setTheme(opt.value)}
|
||||
>
|
||||
<Text className='theme-option-text'>{opt.label}</Text>
|
||||
{theme === opt.value && <View className='theme-check' />}
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
{theme === 'auto' && (
|
||||
<Text className='theme-hint'>当前跟随系统:{resolvedTheme === 'dark' ? '深色模式' : '浅色模式'}</Text>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* 菜单列表 */}
|
||||
<View className='settings-list dashed-card mt-20'>
|
||||
{MENU.map((item, idx) => (
|
||||
<View key={idx} className={`settings-item ${idx < MENU.length - 1 ? 'bordered' : ''}`} onTap={() => item.action({ setShowEdit })}>
|
||||
<Image className='settings-item-icon-img' src={item.icon} mode='aspectFit' />
|
||||
<Text className='settings-item-label'>{item.label}</Text>
|
||||
<Text className='arrow'>›</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
|
||||
{/* 退出登录 */}
|
||||
{user?.openid && (
|
||||
<View className='mt-20'>
|
||||
<View className='btn-outline logout-btn' onTap={handleLogout}>
|
||||
<Text>退出登录</Text>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
|
||||
<View style={{ height: '40px' }} />
|
||||
|
||||
{/* 编辑弹窗 */}
|
||||
{showEdit && (
|
||||
<View className='modal-overlay'>
|
||||
<View className='modal-card dashed-card'>
|
||||
<Text className='modal-title'>修改信息</Text>
|
||||
<Button className='avatar-btn' openType='chooseAvatar' onChooseAvatar={onChooseAvatar}>
|
||||
{editAvatar ? (
|
||||
<Image className='avatar-img' src={editAvatar} mode='aspectFill' />
|
||||
) : (
|
||||
<Image className='avatar-placeholder-img' src='/icon/个人.png' mode='aspectFit' />
|
||||
)}
|
||||
</Button>
|
||||
<Input
|
||||
className='nickname-input'
|
||||
placeholder='请输入昵称'
|
||||
value={editName}
|
||||
onInput={(e: any) => setEditName(e.detail.value)}
|
||||
/>
|
||||
<View className='form-actions'>
|
||||
<View className='btn-outline' onTap={() => setShowEdit(false)}>
|
||||
<Text>取消</Text>
|
||||
</View>
|
||||
<View className='btn-gradient' onTap={handleSaveInfo}>
|
||||
<Text>保存</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export default definePageConfig({
|
||||
navigationStyle: 'custom',
|
||||
// 顶部为深色商品大图,状态栏文字设为白色,确保 iOS 时间/电量可见
|
||||
navigationBarTextStyle: 'white'
|
||||
})
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export default definePageConfig({
|
||||
navigationStyle: 'custom'
|
||||
})
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"usingComponents": {}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export default definePageConfig({
|
||||
navigationStyle: 'custom',
|
||||
navigationBarTitleText: '用户数据库'
|
||||
})
|
||||