Merge branch 'feat/r4-upload-wordcloud' into master

This commit is contained in:
2026-08-18 23:46:23 +08:00
23 changed files with 394 additions and 62 deletions
+2 -1
View File
@@ -18,7 +18,8 @@
"Bash(npm ls *)",
"Bash(npm install *)",
"Bash(node compress-images.js)",
"Bash(cd \"F:\\\\\\\\weixin_wordc\")"
"Bash(cd \"F:\\\\\\\\weixin_wordc\")",
"Bash(find ~/.claude /Users/broccoli/Project/wechat_wc/.claude -path '*skills*' -iname 'SKILL.md' 2>/dev/null | xargs grep -l -i cloudbase 2>/dev/null; echo \"---dirs---\"; find ~/.claude /Users/broccoli/Project/wechat_wc/.claude -type d -iname '*cloudbase*' 2>/dev/null)"
]
}
}
+1 -1
View File
File diff suppressed because one or more lines are too long
Vendored
+1 -1
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -1 +1 @@
{"darkmode":true,"themeLocation":"theme.json","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","navigationBarBackgroundColor":"#faf7f2","backgroundColor":"#faf7f2","backgroundColorTop":"#faf7f2","backgroundColorBottom":"#faf7f2","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":"我的"}]}}
{"darkmode":true,"themeLocation":"theme.json","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":{"navigationStyle":"custom"},"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":"我的"}]}}
+1 -1
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -1 +1 @@
"use strict";(wx["webpackJsonp"]=wx["webpackJsonp"]||[]).push([[777],{653:function(e,n,t){var a=t(7842),i=t(5544),c=t(118),o=t(758),r=t.n(o),s=t(6540),u=t(1457),f=t(9703),g=t(6606),h=t(8199),l=t(4848),v=[{pagePath:"/pages/index/index",label:"\u9996\u9875",icon:(0,h.uq)("/icon/\u9996\u9875.svg"),iconActive:(0,h.uq)("/icon/\u9996\u9875-fill.svg")},{pagePath:"/pages/shop/index",label:"\u5546\u54c1",icon:(0,h.uq)("/icon/\u5546\u54c1.svg"),iconActive:(0,h.uq)("/icon/\u5546\u54c1-fill.svg")},{pagePath:"/pages/designList/index",label:"\u8bbe\u8ba1\u6e05\u5355",icon:(0,h.uq)("/icon/\u8c03\u8272\u76d8.svg"),iconActive:(0,h.uq)("/icon/\u8c03\u8272\u76d8-fill.svg")},{pagePath:"/pages/orders/index",label:"\u8ba2\u5355",icon:(0,h.uq)("/icon/\u5305\u88f9.svg"),iconActive:(0,h.uq)("/icon/\u5305\u88f9-fill.svg")},{pagePath:"/pages/profile/index",label:"\u6211\u7684",icon:(0,h.uq)("/icon/\u4e2a\u4eba.svg"),iconActive:(0,h.uq)("/icon/\u4e2a\u4eba-fill.svg")}];function p(e){var n=v.findIndex(function(n){return e.endsWith(n.pagePath)});return v[n>=0?n:0].pagePath}function b(){var e=r().getCurrentInstance().router,n=e&&e.path?e.path:"pages/index/index";return p(n)}function m(){var e=(0,s.useState)((0,u.eW)((0,u.O4)())),n=(0,i.A)(e,2),t=n[0],a=n[1],o=(0,s.useState)(b),h=(0,i.A)(o,2),m=h[0],d=h[1];(0,s.useEffect)(function(){var e=function(e){return a(e)};return r().eventCenter.on(f.Wo,e),function(){r().eventCenter.off(f.Wo,e)}},[]),(0,s.useEffect)(function(){var e=function(e){!e||"dark"!==e.theme&&"light"!==e.theme||((0,u.Rq)(e.theme),"auto"===(0,u.O4)()&&a(e.theme))},n=r();return n.onThemeChange&&n.onThemeChange(e),function(){n.offThemeChange&&n.offThemeChange(e)}},[]),(0,s.useEffect)(function(){var e=function(e){var n=r().getCurrentInstance().router,t=n&&n.path?n.path:"pages/index/index";d(p(e||t))};return r().eventCenter.on("tabBarChange",e),function(){r().eventCenter.off("tabBarChange",e)}},[]),(0,s.useEffect)(function(){(0,g.G)(t)},[t]);var x=function(e){d(p(e)),r().switchTab({url:e}),r().eventCenter.trigger("tabBarChange",e)};return(0,l.jsxs)(c.Ss,{className:"tab-bar-root theme-".concat(t),children:[(0,l.jsx)(c.Ss,{className:"tab-bar-gradient"}),(0,l.jsx)(c.Ss,{className:"custom-tab-bar theme-".concat(t),children:v.map(function(e){var n=e.pagePath===m;return(0,l.jsxs)(c.Ss,{className:"tab-item ".concat(n?"active":""),onTap:function(){return x(e.pagePath)},children:[n&&(0,l.jsx)(c.Ss,{className:"tab-indicator"}),(0,l.jsx)(c._V,{className:"tab-icon-img ".concat(n?"active":""),src:n?e.iconActive:e.icon,mode:"aspectFit"}),(0,l.jsx)(c.EY,{className:"tab-label",children:e.label})]},e.pagePath)})})]})}Component((0,a.createComponentConfig)(m,"custom-tab-bar/index"))}},function(e){var n=function(n){return e(e.s=n)};e.O(0,[907,96,76],function(){return n(653)});e.O()}]);
"use strict";(wx["webpackJsonp"]=wx["webpackJsonp"]||[]).push([[777],{7914:function(e,n,t){var a=t(7842),c=t(5544),r=t(118),u=t(758),i=t.n(u),o=t(6540),s=t(1457),l=t(9703),f=t(6606),h=t(8199);function g(e,n,t){return Number.isFinite(e)?Math.min(t,Math.max(n,e)):n}function v(e){return g(e,0,100)}function d(e,n){return!Number.isFinite(e)||n<=0?0:g(Math.floor(v(e)/100*n),0,n-1)}function m(e,n,t){return Math.abs(n-e)>=t}function p(e){var n=e/750;return{left:36*n,width:Math.max(0,e-72*n)}}var b=t(4848),x=[{pagePath:"/pages/index/index",label:"\u9996\u9875",icon:(0,h.uq)("/icon/\u9996\u9875.svg"),iconActive:(0,h.uq)("/icon/\u9996\u9875-fill.svg")},{pagePath:"/pages/shop/index",label:"\u5546\u54c1",icon:(0,h.uq)("/icon/\u5546\u54c1.svg"),iconActive:(0,h.uq)("/icon/\u5546\u54c1-fill.svg")},{pagePath:"/pages/designList/index",label:"\u8bbe\u8ba1\u6e05\u5355",icon:(0,h.uq)("/icon/\u8c03\u8272\u76d8.svg"),iconActive:(0,h.uq)("/icon/\u8c03\u8272\u76d8-fill.svg")},{pagePath:"/pages/orders/index",label:"\u8ba2\u5355",icon:(0,h.uq)("/icon/\u5305\u88f9.svg"),iconActive:(0,h.uq)("/icon/\u5305\u88f9-fill.svg")},{pagePath:"/pages/profile/index",label:"\u6211\u7684",icon:(0,h.uq)("/icon/\u4e2a\u4eba.svg"),iconActive:(0,h.uq)("/icon/\u4e2a\u4eba-fill.svg")}],C=100/x.length,S=12;function P(e){var n=x.findIndex(function(n){return e.endsWith(n.pagePath)});return x[n>=0?n:0].pagePath}function T(){var e=i().getCurrentInstance().router,n=e&&e.path?e.path:"pages/index/index";return P(n)}function q(){var e=(0,o.useState)((0,s.eW)((0,s.O4)())),n=(0,c.A)(e,2),t=n[0],a=n[1],u=(0,o.useState)(T),h=(0,c.A)(u,2),g=h[0],q=h[1],N=(0,o.useState)(null),w=(0,c.A)(N,2),A=w[0],j=w[1],M=(0,o.useState)(!1),E=(0,c.A)(M,2),I=E[0],R=E[1],W=(0,o.useRef)(!1),k=(0,o.useRef)(null),O=(0,o.useRef)(0),X=(0,o.useRef)(null);(0,o.useEffect)(function(){var e=function(e){return a(e)};return i().eventCenter.on(l.Wo,e),function(){i().eventCenter.off(l.Wo,e)}},[]),(0,o.useEffect)(function(){var e=function(e){!e||"dark"!==e.theme&&"light"!==e.theme||((0,s.Rq)(e.theme),"auto"===(0,s.O4)()&&a(e.theme))},n=i();return n.onThemeChange&&n.onThemeChange(e),function(){n.offThemeChange&&n.offThemeChange(e)}},[]),(0,o.useEffect)(function(){var e=function(e){var n=i().getCurrentInstance().router,t=n&&n.path?n.path:"pages/index/index";q(P(e||t))};return i().eventCenter.on("tabBarChange",e),function(){i().eventCenter.off("tabBarChange",e)}},[]),(0,o.useEffect)(function(){(0,f.G)(t)},[t]);var y=function(e){q(P(e)),i().switchTab({url:e}),i().eventCenter.trigger("tabBarChange",e)},B=function(e){var n=e.touches&&e.touches[0];if(n){var t=p(i().getSystemInfoSync().windowWidth);X.current=t,O.current=n.clientX,W.current=!1,R(!1),k.current=null,j(null)}},F=function(e){var n=X.current,t=e.touches&&e.touches[0];if(n&&t){if(!W.current){if(!m(O.current,t.clientX,S))return;W.current=!0,R(!0)}var a=v((t.clientX-n.left)/n.width*100);k.current=a,j(a)}},J=function(e){var n=X.current;if(n){var t;if(W.current){var a;t=d(null!==(a=k.current)&&void 0!==a?a:0,x.length),W.current=!1,R(!1),i().nextTick(function(){k.current=null,j(null)})}else{var c=e.changedTouches&&e.changedTouches[0],r=c?c.clientX:O.current;t=d(v((r-n.left)/n.width*100),x.length)}var u=x[t];u&&u.pagePath!==g&&y(u.pagePath),X.current=null,O.current=0}},G=function(){X.current=null,O.current=0,W.current=!1,R(!1),k.current=null,j(null)},L=Math.max(0,x.findIndex(function(e){return e.pagePath===g}));return(0,b.jsxs)(r.Ss,{className:"tab-bar-root theme-".concat(t),children:[(0,b.jsx)(r.Ss,{className:"tab-bar-gradient"}),(0,b.jsxs)(r.Ss,{className:"custom-tab-bar theme-".concat(t),onTouchStart:B,onTouchMove:F,onTouchEnd:J,onTouchCancel:G,catchMove:!0,children:[(0,b.jsx)(r.Ss,{className:"tab-bar-caustic"}),(0,b.jsx)(r.Ss,{className:"tab-bar-rim"}),(0,b.jsx)(r.Ss,{className:"tab-indicator ".concat(I?"dragging":""),style:{left:"".concat(null===A?(L+.5)*C:A,"%")}}),x.map(function(e){var n=e.pagePath===g;return(0,b.jsxs)(r.Ss,{className:"tab-item ".concat(n?"active":""),children:[(0,b.jsx)(r._V,{className:"tab-icon-img ".concat(n?"active":""),src:n?e.iconActive:e.icon,mode:"aspectFit"}),(0,b.jsx)(r.EY,{className:"tab-label",children:e.label})]},e.pagePath)})]})]})}Component((0,a.createComponentConfig)(q,"custom-tab-bar/index"))}},function(e){var n=function(n){return e(e.s=n)};e.O(0,[907,96,76],function(){return n(7914)});e.O()}]);
+1 -1
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1 -1
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
File diff suppressed because one or more lines are too long
+2 -9
View File
@@ -23,16 +23,9 @@ export default defineAppConfig({
'pages/userDatabase/index'
],
window: {
backgroundTextStyle: 'dark',
// 初始窗口/导航背景交给 theme.json 按系统外观自动选择,
// 避免首帧先固定成浅色再被 JS 切到深色而闪烁。
navigationStyle: 'custom',
// 顶部/窗口颜色先显式固定为小程序浅色主题,避免 iOS 顶部区域跟随系统深色;
// 应用启动后页面 Hook 会按小程序内主题实时覆盖上面的值。
navigationBarBackgroundColor: '#faf7f2',
backgroundColor: '#faf7f2',
backgroundColorTop: '#faf7f2',
backgroundColorBottom: '#faf7f2',
// 状态栏文字默认黑字;各页面 useStatusBar 会按小程序主题覆盖它。
navigationBarTextStyle: 'black',
},
tabBar: {
custom: true,
+28 -8
View File
@@ -209,19 +209,39 @@ page {
align-items: center !important;
justify-content: center !important;
border-radius: 50% !important;
background: rgba(255, 255, 255, 0.55) !important;
backdrop-filter: blur(32px) saturate(1.2) !important;
-webkit-backdrop-filter: blur(32px) saturate(1.2) !important;
border: 1rpx solid rgba(255, 255, 255, 0.55) !important;
box-shadow: 0 12rpx 40rpx rgba(0, 0, 0, 0.18), 0 4rpx 12rpx rgba(0, 0, 0, 0.08) !important;
background: linear-gradient(
135deg,
rgba(255, 255, 255, 0.55) 0%,
rgba(255, 255, 255, 0.28) 45%,
rgba(255, 255, 255, 0.42) 100%
) !important;
backdrop-filter: blur(24px) saturate(1.7) brightness(1.05) !important;
-webkit-backdrop-filter: blur(24px) saturate(1.7) brightness(1.05) !important;
border: 1rpx solid rgba(255, 255, 255, 0.35) !important;
box-shadow:
inset 0 2rpx 3rpx rgba(255, 255, 255, 0.65),
inset 0 -2rpx 4rpx rgba(0, 0, 0, 0.06),
inset 0 0 0 1rpx rgba(255, 255, 255, 0.18),
0 12rpx 40rpx rgba(0, 0, 0, 0.18),
0 4rpx 12rpx rgba(0, 0, 0, 0.08) !important;
}
.theme-dark .back-btn,
.theme-dark .edit-back,
.theme-dark .shop-back-btn {
background: rgba(25, 25, 25, 0.55) !important;
border-color: rgba(255, 255, 255, 0.14) !important;
box-shadow: 0 12rpx 40rpx rgba(0, 0, 0, 0.5), 0 4rpx 12rpx rgba(0, 0, 0, 0.3) !important;
background: linear-gradient(
135deg,
rgba(255, 255, 255, 0.16) 0%,
rgba(255, 255, 255, 0.06) 45%,
rgba(255, 255, 255, 0.12) 100%
) !important;
border-color: rgba(255, 255, 255, 0.18) !important;
box-shadow:
inset 0 2rpx 3rpx rgba(255, 255, 255, 0.22),
inset 0 -2rpx 4rpx rgba(0, 0, 0, 0.28),
inset 0 0 0 1rpx rgba(255, 255, 255, 0.1),
0 12rpx 40rpx rgba(0, 0, 0, 0.5),
0 4rpx 12rpx rgba(0, 0, 0, 0.3) !important;
}
/* ==========================================================
+5
View File
@@ -7,6 +7,11 @@ import { refreshSession, ensureSessionFresh } from './utils/session'
import './app.scss'
class App extends Component<PropsWithChildren> {
componentWillMount() {
// 首帧前先把原生窗口背景切成当前主题,避免深色/浅色下先闪系统默认色
applyPageBackground(resolveTheme(getTheme()))
}
componentDidMount() {
// 启动最早时机同步页面背景,避免自定义导航区在深色模式下闪白
applyPageBackground(resolveTheme(getTheme()))
+28 -8
View File
@@ -44,17 +44,37 @@
justify-content: center;
border-radius: 50%;
pointer-events: auto;
background: rgba(255, 255, 255, 0.55);
backdrop-filter: blur(32px) saturate(1.2);
-webkit-backdrop-filter: blur(32px) saturate(1.2);
border: 1rpx solid rgba(255, 255, 255, 0.55);
box-shadow: 0 12rpx 40rpx rgba(0, 0, 0, 0.18), 0 4rpx 12rpx rgba(0, 0, 0, 0.08);
background: linear-gradient(
135deg,
rgba(255, 255, 255, 0.55) 0%,
rgba(255, 255, 255, 0.28) 45%,
rgba(255, 255, 255, 0.42) 100%
);
backdrop-filter: blur(24px) saturate(1.7) brightness(1.05);
-webkit-backdrop-filter: blur(24px) saturate(1.7) brightness(1.05);
border: 1rpx solid rgba(255, 255, 255, 0.35);
box-shadow:
inset 0 2rpx 3rpx rgba(255, 255, 255, 0.65),
inset 0 -2rpx 4rpx rgba(0, 0, 0, 0.06),
inset 0 0 0 1rpx rgba(255, 255, 255, 0.18),
0 12rpx 40rpx rgba(0, 0, 0, 0.18),
0 4rpx 12rpx rgba(0, 0, 0, 0.08);
}
.theme-dark .scroll-top-back {
background: rgba(25, 25, 25, 0.55);
border-color: rgba(255, 255, 255, 0.14);
box-shadow: 0 12rpx 40rpx rgba(0, 0, 0, 0.5), 0 4rpx 12rpx rgba(0, 0, 0, 0.3);
background: linear-gradient(
135deg,
rgba(255, 255, 255, 0.16) 0%,
rgba(255, 255, 255, 0.06) 45%,
rgba(255, 255, 255, 0.12) 100%
);
border-color: rgba(255, 255, 255, 0.18);
box-shadow:
inset 0 2rpx 3rpx rgba(255, 255, 255, 0.22),
inset 0 -2rpx 4rpx rgba(0, 0, 0, 0.28),
inset 0 0 0 1rpx rgba(255, 255, 255, 0.1),
0 12rpx 40rpx rgba(0, 0, 0, 0.5),
0 4rpx 12rpx rgba(0, 0, 0, 0.3);
}
.scroll-top-back-arrow {
+20 -1
View File
@@ -1,4 +1,4 @@
import { createContext, useContext, useState, useCallback, useEffect } from 'react'
import { createContext, useContext, useState, useCallback, useEffect, useRef } from 'react'
import Taro, { useDidShow } from '@tarojs/taro'
import { View } from '@tarojs/components'
import { getTheme, setTheme as saveTheme, resolveTheme, setDetectedSystemTheme, type ThemeMode } from '../utils/store'
@@ -31,6 +31,7 @@ type ThemeChangeApi = {
export function ThemeProvider({ children }: { children: React.ReactNode }) {
const [theme, set] = useState<ThemeMode>(getTheme())
const [resolvedTheme, setResolved] = useState<'light' | 'dark'>(resolveTheme(getTheme()))
const systemThemeRef = useRef<'light' | 'dark' | null>(null)
const updateResolved = useCallback((mode: ThemeMode) => {
setResolved(resolveTheme(mode))
@@ -42,6 +43,7 @@ export function ThemeProvider({ children }: { children: React.ReactNode }) {
*/
const updateFromSystem = useCallback((t: 'light' | 'dark') => {
setDetectedSystemTheme(t)
systemThemeRef.current = t
if (getTheme() === 'auto') {
setResolved(t)
}
@@ -102,6 +104,23 @@ export function ThemeProvider({ children }: { children: React.ReactNode }) {
}
}, [updateFromSystem])
// 部分机型/工具上 onThemeChange 不回调,自动模式再轮询兜底,保证系统切换能跟上
useEffect(() => {
if (theme !== 'auto') return
const timer = setInterval(() => {
try {
const info = Taro.getAppBaseInfo()
const next = info.theme === 'dark' ? 'dark' : 'light'
if (next !== systemThemeRef.current) {
updateFromSystem(next)
}
} catch {
/* ignore */
}
}, 2000)
return () => clearInterval(timer)
}, [theme, updateFromSystem])
// 广播实际生效主题,供 custom-tab-bar 等独立组件订阅
useEffect(() => {
Taro.eventCenter.trigger(THEME_CHANGE_EVENT, resolvedTheme)
+89 -15
View File
@@ -32,25 +32,84 @@
border-radius: 64rpx;
height: 116rpx;
box-sizing: border-box;
background: rgba(255, 255, 255, 0.55);
backdrop-filter: blur(32px) saturate(1.2);
-webkit-backdrop-filter: blur(32px) saturate(1.2);
overflow: hidden;
background: rgba(255, 255, 255, 0.24);
backdrop-filter: blur(32px) saturate(1.7) brightness(1.05);
-webkit-backdrop-filter: blur(32px) saturate(1.7) brightness(1.05);
display: flex;
justify-content: space-around;
align-items: center;
border: 1rpx solid rgba(255, 255, 255, 0.55);
box-shadow: 0 12rpx 40rpx rgba(0, 0, 0, 0.18), 0 4rpx 12rpx rgba(0, 0, 0, 0.08);
border: 1rpx solid rgba(255, 255, 255, 0.14);
box-shadow:
0 24rpx 60rpx rgba(20, 14, 10, 0.22),
0 4rpx 12rpx rgba(0, 0, 0, 0.08),
inset 0 2rpx 0 rgba(255, 255, 255, 0.35);
pointer-events: auto;
z-index: 1000;
}
/* 自定义组件样式隔离:两套主题配色在组件内自包含,不依赖全局 app.wxss */
.custom-tab-bar.theme-dark {
background: rgba(25, 25, 25, 0.55);
backdrop-filter: blur(32px) saturate(1.2);
-webkit-backdrop-filter: blur(32px) saturate(1.2);
border-color: rgba(255, 255, 255, 0.14);
box-shadow: 0 12rpx 40rpx rgba(0, 0, 0, 0.5), 0 4rpx 12rpx rgba(0, 0, 0, 0.3);
background: rgba(25, 25, 25, 0.24);
backdrop-filter: blur(32px) saturate(1.7) brightness(1.05);
-webkit-backdrop-filter: blur(32px) saturate(1.7) brightness(1.05);
border-color: rgba(255, 255, 255, 0.1);
box-shadow:
0 24rpx 60rpx rgba(0, 0, 0, 0.55),
0 4rpx 12rpx rgba(0, 0, 0, 0.3),
inset 0 2rpx 0 rgba(255, 255, 255, 0.12);
}
/* 顶部弯月面聚光带 */
.tab-bar-caustic {
position: absolute;
top: 0;
left: 0;
right: 0;
height: 56rpx;
border-radius: 64rpx 64rpx 0 0;
background: linear-gradient(to bottom, rgba(255, 255, 255, 0.32), rgba(255, 255, 255, 0));
box-shadow: inset 0 1rpx 0 rgba(255, 255, 255, 0.55);
pointer-events: none;
z-index: 2;
}
.custom-tab-bar.theme-dark .tab-bar-caustic {
background: linear-gradient(to bottom, rgba(255, 255, 255, 0.12), rgba(255, 255, 255, 0));
box-shadow: inset 0 1rpx 0 rgba(255, 255, 255, 0.22);
}
/* 非均匀银丝玻璃边缘 */
.tab-bar-rim {
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
border-radius: inherit;
padding: 1rpx;
background: linear-gradient(
135deg,
rgba(255, 255, 255, 0.6) 0%,
rgba(255, 255, 255, 0.18) 32%,
rgba(255, 255, 255, 0.03) 68%,
rgba(255, 255, 255, 0.24) 100%
);
-webkit-mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
-webkit-mask-composite: xor;
mask-composite: exclude;
pointer-events: none;
z-index: 3;
}
.custom-tab-bar.theme-dark .tab-bar-rim {
background: linear-gradient(
135deg,
rgba(255, 255, 255, 0.34) 0%,
rgba(255, 255, 255, 0.12) 32%,
rgba(255, 255, 255, 0.02) 68%,
rgba(255, 255, 255, 0.16) 100%
);
}
.tab-item {
@@ -63,23 +122,38 @@
flex: 1;
height: 100%;
max-width: 160px;
z-index: 10;
}
.tab-indicator {
position: absolute;
left: 50%;
transform: translateX(-50%);
bottom: 6rpx;
width: 120rpx;
height: 100rpx;
border-radius: 50rpx;
background: rgba(0, 0, 0, 0.14);
box-shadow: 0 0 6rpx rgba(0, 0, 0, 0.07);
pointer-events: none;
z-index: 4;
transition: left 0.25s ease;
background: linear-gradient(180deg, rgba(0, 0, 0, 0.1), rgba(0, 0, 0, 0.16));
border: 1rpx solid rgba(255, 255, 255, 0.25);
box-shadow:
inset 0 2rpx 4rpx rgba(255, 255, 255, 0.35),
inset 0 -2rpx 4rpx rgba(0, 0, 0, 0.06),
0 4rpx 10rpx rgba(0, 0, 0, 0.08);
}
.custom-tab-bar.theme-dark .tab-indicator {
background: rgba(255, 255, 255, 0.14);
box-shadow: 0 0 6rpx rgba(255, 255, 255, 0.07);
background: linear-gradient(180deg, rgba(255, 255, 255, 0.08), rgba(255, 255, 255, 0.16));
border-color: rgba(255, 255, 255, 0.18);
box-shadow:
inset 0 2rpx 4rpx rgba(255, 255, 255, 0.12),
inset 0 -2rpx 4rpx rgba(0, 0, 0, 0.3),
0 4rpx 10rpx rgba(0, 0, 0, 0.2);
}
.tab-indicator.dragging {
transition: none;
}
.tab-icon-img {
+86 -8
View File
@@ -1,10 +1,11 @@
import { View, Text, Image } from '@tarojs/components'
import Taro from '@tarojs/taro'
import { useEffect, useState } from 'react'
import { useEffect, useRef, useState } from 'react'
import { getTheme, resolveTheme, setDetectedSystemTheme } from '../utils/store'
import { THEME_CHANGE_EVENT } from '../context/ThemeContext'
import { applyPageBackground } from '../utils/themeBackground'
import { assetUrl } from '../utils/asset'
import { clampPercent, getTabBarFrame, hasDragged, tabIndexAtPercent } from './tabBarGeometry'
import './index.scss'
const TABS = [
@@ -15,6 +16,9 @@ const TABS = [
{ pagePath: '/pages/profile/index', label: '我的', icon: assetUrl('/icon/个人.svg'), iconActive: assetUrl('/icon/个人-fill.svg') }
]
const TAB_CENTER_STEP = 100 / TABS.length
const DRAG_THRESHOLD_PX = 12
function matchTab(path: string): string {
const idx = TABS.findIndex((t) => path.endsWith(t.pagePath))
return TABS[idx >= 0 ? idx : 0].pagePath
@@ -36,6 +40,12 @@ export default function CustomTabBar() {
// 微信自定义 tabBar 不会自动感知 page 路由变化,用 state 保持选中态响应式
const [activeTab, setActiveTab] = useState<string>(currentTabPath)
const [dragPercent, setDragPercent] = useState<number | null>(null)
const [isDragging, setIsDragging] = useState<boolean>(false)
const isDraggingRef = useRef<boolean>(false)
const dragPercentRef = useRef<number | null>(null)
const startXRef = useRef<number>(0)
const barFrameRef = useRef<{ left: number; width: number } | null>(null)
useEffect(() => {
const handler = (t: 'light' | 'dark') => setResolvedTheme(t)
@@ -91,19 +101,87 @@ export default function CustomTabBar() {
Taro.eventCenter.trigger('tabBarChange', url)
}
const startDrag = (e: any) => {
const touch = e.touches && e.touches[0]
if (!touch) return
const frame = getTabBarFrame(Taro.getSystemInfoSync().windowWidth)
barFrameRef.current = frame
startXRef.current = touch.clientX
isDraggingRef.current = false
setIsDragging(false)
dragPercentRef.current = null
setDragPercent(null)
}
const moveDrag = (e: any) => {
const frame = barFrameRef.current
const touch = e.touches && e.touches[0]
if (!frame || !touch) return
if (!isDraggingRef.current) {
if (!hasDragged(startXRef.current, touch.clientX, DRAG_THRESHOLD_PX)) return
isDraggingRef.current = true
setIsDragging(true)
}
const nextPercent = clampPercent(((touch.clientX - frame.left) / frame.width) * 100)
dragPercentRef.current = nextPercent
setDragPercent(nextPercent)
}
const endDrag = (e: any) => {
const frame = barFrameRef.current
if (!frame) return
let targetIndex: number
if (isDraggingRef.current) {
targetIndex = tabIndexAtPercent(dragPercentRef.current ?? 0, TABS.length)
isDraggingRef.current = false
setIsDragging(false)
Taro.nextTick(() => {
dragPercentRef.current = null
setDragPercent(null)
})
} else {
const touch = e.changedTouches && e.changedTouches[0]
const x = touch ? touch.clientX : startXRef.current
targetIndex = tabIndexAtPercent(clampPercent(((x - frame.left) / frame.width) * 100), TABS.length)
}
const target = TABS[targetIndex]
if (target && target.pagePath !== activeTab) switchTab(target.pagePath)
barFrameRef.current = null
startXRef.current = 0
}
const cancelDrag = () => {
barFrameRef.current = null
startXRef.current = 0
isDraggingRef.current = false
setIsDragging(false)
dragPercentRef.current = null
setDragPercent(null)
}
const activeIndex = Math.max(0, TABS.findIndex((t) => t.pagePath === activeTab))
return (
<View className={`tab-bar-root theme-${resolvedTheme}`}>
<View className='tab-bar-gradient' />
<View className={`custom-tab-bar theme-${resolvedTheme}`}>
<View
className={`custom-tab-bar theme-${resolvedTheme}`}
onTouchStart={startDrag}
onTouchMove={moveDrag}
onTouchEnd={endDrag}
onTouchCancel={cancelDrag}
catchMove
>
<View className='tab-bar-caustic' />
<View className='tab-bar-rim' />
<View
className={`tab-indicator ${isDragging ? 'dragging' : ''}`}
style={{ left: dragPercent === null ? `${(activeIndex + 0.5) * TAB_CENTER_STEP}%` : `${dragPercent}%` }}
/>
{TABS.map((tab) => {
const isActive = tab.pagePath === activeTab
return (
<View
key={tab.pagePath}
className={`tab-item ${isActive ? 'active' : ''}`}
onTap={() => switchTab(tab.pagePath)}
>
{isActive && <View className='tab-indicator' />}
<View key={tab.pagePath} className={`tab-item ${isActive ? 'active' : ''}`}>
<Image className={`tab-icon-img ${isActive ? 'active' : ''}`} src={isActive ? tab.iconActive : tab.icon} mode='aspectFit' />
<Text className='tab-label'>{tab.label}</Text>
</View>
+22
View File
@@ -0,0 +1,22 @@
export function clamp(value, min, max) {
if (!Number.isFinite(value)) return min
return Math.min(max, Math.max(min, value))
}
export function clampPercent(value) {
return clamp(value, 0, 100)
}
export function tabIndexAtPercent(percent, tabCount) {
if (!Number.isFinite(percent) || tabCount <= 0) return 0
return clamp(Math.floor((clampPercent(percent) / 100) * tabCount), 0, tabCount - 1)
}
export function hasDragged(startX, currentX, threshold) {
return Math.abs(currentX - startX) >= threshold
}
export function getTabBarFrame(windowWidth) {
const rpx = windowWidth / 750
return { left: 36 * rpx, width: Math.max(0, windowWidth - 72 * rpx) }
}
+43
View File
@@ -0,0 +1,43 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import { fileURLToPath } from 'node:url'
import path from 'node:path'
import { clampPercent, getTabBarFrame, hasDragged, tabIndexAtPercent } from '../src/custom-tab-bar/tabBarGeometry.js'
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
test('maps a drag percent to the tab under the finger', () => {
assert.equal(tabIndexAtPercent(10, 5), 0)
assert.equal(tabIndexAtPercent(30, 5), 1)
assert.equal(tabIndexAtPercent(50, 5), 2)
assert.equal(tabIndexAtPercent(70, 5), 3)
assert.equal(tabIndexAtPercent(90, 5), 4)
})
test('clamps percent and maps out-of-range to edge tabs', () => {
assert.equal(clampPercent(-5), 0)
assert.equal(clampPercent(120), 100)
assert.equal(clampPercent(Number.NaN), 0)
assert.equal(tabIndexAtPercent(-5, 5), 0)
assert.equal(tabIndexAtPercent(120, 5), 4)
})
test('only treats a move as a drag beyond the threshold', () => {
assert.equal(hasDragged(100, 110, 12), false)
assert.equal(hasDragged(100, 112, 12), true)
assert.equal(hasDragged(100, 88, 12), true)
})
test('derives the floating bar frame from window width', () => {
assert.deepEqual(getTabBarFrame(375), { left: 18, width: 339 })
assert.deepEqual(getTabBarFrame(750), { left: 36, width: 678 })
})
test('enables the slide transition before snapping the indicator to the target', () => {
const source = readFileSync(path.join(root, 'src/custom-tab-bar/index.tsx'), 'utf8')
assert.match(
source,
/isDraggingRef\.current = false\s+setIsDragging\(false\)\s+Taro\.nextTick\(\(\) => \{[\s\S]*?setDragPercent\(null\)/
)
})
+57
View File
@@ -0,0 +1,57 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import { fileURLToPath } from 'node:url'
import path from 'node:path'
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
const read = (rel) => readFileSync(path.join(root, rel), 'utf8')
test('custom tab bar renders the liquid-glass overlay layers', () => {
const source = read('src/custom-tab-bar/index.tsx')
assert.match(source, /tab-bar-caustic/)
assert.match(source, /tab-bar-rim/)
})
test('custom tab bar uses bright glass material and the silver rim', () => {
const css = read('src/custom-tab-bar/index.scss')
assert.match(css, /background: rgba\(255, 255, 255, 0\.24\)/)
assert.match(css, /background: rgba\(25, 25, 25, 0\.24\)/)
assert.match(css, /backdrop-filter: blur\(32px\) saturate\(1\.7\) brightness\(1\.05\)/)
assert.match(css, /-webkit-backdrop-filter: blur\(32px\) saturate\(1\.7\) brightness\(1\.05\)/)
assert.match(css, /mask-composite: exclude/)
assert.match(css, /linear-gradient\(\s*135deg/)
})
test('global back buttons use the liquid-glass gradient and inner shading', () => {
const css = read('src/app.scss')
assert.match(css, /background: linear-gradient\(\s*135deg/)
assert.match(css, /backdrop-filter: blur\(24px\) saturate\(1\.7\) brightness\(1\.05\)/)
assert.match(css, /\.theme-dark \.back-btn/)
})
test('scroll top back button matches the global liquid-glass back style', () => {
const css = read('src/components/ScrollTopMask/index.scss')
assert.match(css, /background: linear-gradient\(\s*135deg/)
assert.match(css, /backdrop-filter: blur\(24px\) saturate\(1\.7\) brightness\(1\.05\)/)
})
test('app sets page background before first render without a blinking full fade', () => {
const appSource = read('src/app.tsx')
const appCss = read('src/app.scss')
assert.match(appSource, /componentWillMount/)
assert.doesNotMatch(appCss, /animation:\s*page-fade-in/)
})
test('delegates initial background to themeLocation instead of hardcoding light', () => {
const config = read('src/app.config.ts')
assert.match(config, /themeLocation:\s*'theme\.json'/)
assert.doesNotMatch(config, /navigationBarBackgroundColor:\s*'#faf7f2'/)
assert.doesNotMatch(config, /backgroundColorTop:\s*'#faf7f2'/)
})
test('polls the system theme while following the system', () => {
const ctx = read('src/context/ThemeContext.tsx')
assert.match(ctx, /setInterval/)
assert.match(ctx, /getAppBaseInfo\(\)/)
})