refactor: 收口公共AppBar组件

This commit is contained in:
czz
2026-05-09 15:55:05 +08:00
parent f3eb25b035
commit b7fa2fce9d
25 changed files with 497 additions and 242 deletions

View File

@@ -8,6 +8,7 @@
- 保持项目结构简单、易懂、易修改
- 使用 `Taro + React + TypeScript` 作为核心技术栈
- 明确这是“微信小程序”项目,不是原生 App 或 H5 项目
- 所有说明尽量使用中文
- 在保证可运行的前提下,减少复杂依赖
@@ -51,9 +52,27 @@
- 底部导航
- 列表项
- 状态块
- 图标使用保持语义统一:
- 如果页面有“返回上一级页面”操作,优先使用 `back.svg`
- 如果页面有“加号/新增”操作,优先使用 `add.svg`
- 如果一个结构或样式在两个及以上页面/区域重复出现,优先抽成可复用组件,而不是复制粘贴。
- 组件设计尽量保持“样式可配置、职责单一、命名清晰”,避免做过度复杂的大而全组件。
## 小程序设计适配约定
- 本项目是微信小程序,后续收到的设计稿即使来自 App也不能直接按 App 页面生搬硬套。
- 设计稿如果是 App 视觉稿,落地时必须同时考虑:
- 手机系统状态栏安全区
- 微信小程序原生胶囊按钮区域
- 微信小程序页面顶部 appbar 的可用空间
- 顶部按钮、标题、搜索框、头像、返回区等靠近页面顶部的元素,优先基于小程序原生 `statusBarHeight``getMenuButtonBoundingClientRect()` 等信息定位,而不是只按设计稿静态像素值摆放。
- 当 App 设计稿顶部结构与微信小程序原生导航区域冲突时,优先保证小程序可用性和对齐关系,再尽量还原设计视觉。
- 页面评审时,顶部区域要重点检查这几项:
- 是否遮挡原生胶囊
- 是否与原生胶囊水平对齐
- 是否预留了不同机型的顶部安全区
- 是否在真机上仍然成立
## 修改边界
- 可以新增或修改当前工作区内与本项目直接相关的文件。

View File

@@ -0,0 +1,40 @@
const assert = require("node:assert/strict");
const fs = require("node:fs");
const path = require("node:path");
const appBarSource = fs.readFileSync(
path.join(__dirname, "../../src/components/app-bar/index.tsx"),
"utf8"
);
const appBarStyles = fs.readFileSync(
path.join(__dirname, "../../src/components/app-bar/index.scss"),
"utf8"
);
function run(name, fn) {
try {
fn();
console.log(`PASS ${name}`);
} catch (error) {
console.error(`FAIL ${name}`);
throw error;
}
}
run("AppBar back action uses the shared back.svg asset", () => {
assert.match(appBarSource, /back\.svg/);
assert.match(appBarSource, /<Image[\s\S]*className="app-bar__back-icon"/);
});
run("AppBar back action no longer renders a text arrow", () => {
assert.doesNotMatch(appBarSource, /&lt;|>\s*<\s*</);
});
run("AppBar back icon uses a fixed mini-program sized constraint", () => {
assert.match(appBarStyles, /\.app-bar__back-icon\s*\{[\s\S]*width:\s*32rpx;/);
assert.match(appBarStyles, /\.app-bar__back-icon\s*\{[\s\S]*height:\s*32rpx;/);
});
run("AppBar props stay focused on title and back navigation only", () => {
assert.doesNotMatch(appBarSource, /subtitle\?:|eyebrow\?:|align\?:|rightText\?:|onRightAction\?:|leftSlot\?:|rightSlot\?:|bottomSlot\?:/);
});

View File

@@ -0,0 +1,45 @@
const assert = require("node:assert/strict");
const fs = require("node:fs");
const path = require("node:path");
const messagePageStyles = fs.readFileSync(
path.join(__dirname, "../../src/pages/message/index.scss"),
"utf8"
);
const messagePageSource = fs.readFileSync(
path.join(__dirname, "../../src/pages/message/index.tsx"),
"utf8"
);
function run(name, fn) {
try {
fn();
console.log(`PASS ${name}`);
} catch (error) {
console.error(`FAIL ${name}`);
throw error;
}
}
run("message tabs container spans full width without manual gap spacing", () => {
assert.match(messagePageStyles, /\.message-tabs\s*\{[\s\S]*width:\s*100%;/);
assert.doesNotMatch(messagePageStyles, /\.message-tabs\s*\{[\s\S]*gap:\s*110rpx;/);
});
run("each message tab item takes half of the row", () => {
assert.match(messagePageStyles, /\.message-tabs__item\s*\{[\s\S]*flex:\s*1;/);
assert.match(messagePageStyles, /\.message-tabs__item\s*\{[\s\S]*min-width:\s*0;/);
});
run("message tab label and unread dot are grouped in a centered content wrapper", () => {
assert.match(messagePageSource, /className="message-tabs__content"/);
assert.match(messagePageStyles, /\.message-tabs__content\s*\{[\s\S]*display:\s*inline-flex;/);
});
run("message tabs are rendered below AppBar instead of inside bottomSlot", () => {
assert.doesNotMatch(messagePageSource, /bottomSlot=\{/);
assert.match(
messagePageSource,
/<AppBar[\s\S]*\/>\s*<View className="message-tabs">/
);
});

11
src/assets/svg/add.svg Normal file
View File

@@ -0,0 +1,11 @@
<svg viewBox="0 0 37.1797 37.1798" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="37.179688" height="37.179787" fill="none" customFrame="#000000">
<g id="组 9850">
<g id="组 9848">
<circle id="椭圆 1364" cx="18.5898952" cy="18.5898952" r="17.5898952" stroke="rgb(255,255,255)" stroke-linecap="round" stroke-linejoin="round" stroke-width="0.000000" />
</g>
<g id="组 9849">
<line id="直线 573" x1="10.6748047" x2="26.5057106" y1="18.5898972" y2="18.5898972" stroke="rgb(255,255,255)" stroke-linecap="round" stroke-linejoin="round" stroke-width="2.000000" />
<line id="直线 574" x1="0" x2="15.830905" y1="0" y2="0" stroke="rgb(255,255,255)" stroke-linecap="round" stroke-linejoin="round" stroke-width="2.000000" transform="matrix(0,1,-1,0,18.5898,10.6744)" />
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 841 B

6
src/assets/svg/back.svg Normal file
View File

@@ -0,0 +1,6 @@
<svg viewBox="0 0 42 42" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="42.000000" height="42.000000" fill="none" customFrame="#000000">
<g id="组 10388">
<rect id="矩形 3479" width="42.000000" height="42.000000" x="0.000000" y="0.000000" />
<path id="路径 15460" d="M28.2715 6.45681L13.7285 21L28.2715 35.5432" stroke="rgb(255,255,255)" stroke-linecap="round" stroke-linejoin="round" stroke-width="2.000000" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 473 B

View File

@@ -0,0 +1,65 @@
.app-bar {
position: relative;
z-index: 10;
width: 100%;
box-sizing: border-box;
padding-top: var(--appbar-top-inset);
}
.app-bar__row {
width: 100%;
min-height: var(--appbar-menu-height);
display: flex;
align-items: center;
box-sizing: border-box;
}
.app-bar__side {
width: var(--appbar-capsule-safe-width);
min-width: var(--appbar-capsule-safe-width);
display: flex;
align-items: center;
}
.app-bar__side--left {
justify-content: flex-start;
}
.app-bar__side--right {
justify-content: flex-end;
}
.app-bar__content {
flex: 1;
min-width: 0;
display: flex;
align-items: center;
justify-content: center;
text-align: center;
}
.app-bar__title {
color: #f3f7ff;
font-size: 32rpx;
font-weight: 600;
line-height: 1.4;
}
.app-bar__action {
min-width: 56rpx;
height: 56rpx;
display: flex;
align-items: center;
justify-content: center;
}
.app-bar__action--placeholder {
opacity: 0;
}
.app-bar__back-icon {
width: 32rpx;
height: 32rpx;
display: block;
flex-shrink: 0;
}

View File

@@ -0,0 +1,62 @@
import { Image, Text, View } from "@tarojs/components";
import type { CSSProperties } from "react";
import { useEffect, useState } from "react";
import backIcon from "../../assets/svg/back.svg";
import { getAppBarMetrics } from "../../utils/app-bar";
import type { AppBarMetrics } from "../../utils/app-bar-metrics";
import "./index.scss";
type AppBarProps = {
title: string;
showBack?: boolean;
onBack?: () => void;
};
const defaultMetrics: AppBarMetrics = {
topInset: 0,
menuTop: 6,
menuHeight: 32,
capsuleSafeWidth: 96
};
export default function AppBar({
title,
showBack = false,
onBack
}: AppBarProps) {
const [metrics, setMetrics] = useState<AppBarMetrics>(defaultMetrics);
useEffect(() => {
setMetrics(getAppBarMetrics());
}, []);
const style = {
"--appbar-top-inset": `${metrics.topInset}px`,
"--appbar-menu-height": `${metrics.menuHeight}px`,
"--appbar-capsule-safe-width": `${metrics.capsuleSafeWidth}px`
} as CSSProperties;
return (
<View className="app-bar" style={style}>
<View className="app-bar__row">
<View className="app-bar__side app-bar__side--left">
{showBack ? (
<View className="app-bar__action" onClick={onBack}>
<Image className="app-bar__back-icon" src={backIcon} mode="aspectFit" />
</View>
) : (
<View className="app-bar__action app-bar__action--placeholder" />
)}
</View>
<View className="app-bar__content">
<Text className="app-bar__title">{title}</Text>
</View>
<View className="app-bar__side app-bar__side--right">
<View className="app-bar__action app-bar__action--placeholder" />
</View>
</View>
</View>
);
}

View File

@@ -1,4 +1,6 @@
import { Text, View } from "@tarojs/components";
import { navigateBackWithFallback } from "../../utils/app-bar";
import AppBar from "../app-bar";
import "./index.scss";
export type SecondaryPageItem = {
@@ -27,9 +29,10 @@ export default function SecondaryPage(props: SecondaryPageProps) {
<View className="secondary-page__halo secondary-page__halo--large" />
<View className="secondary-page__halo secondary-page__halo--small" />
<AppBar title={title} showBack onBack={() => navigateBackWithFallback("/pages/mine/index")} />
<View className="secondary-page__hero">
<Text className="secondary-page__eyebrow">{eyebrow}</Text>
<Text className="secondary-page__title">{title}</Text>
<Text className="secondary-page__description">{description}</Text>
</View>

View File

@@ -1,7 +1,7 @@
.devices-page {
position: relative;
min-height: 100vh;
padding: 26rpx 24rpx 44rpx;
padding: 0 24rpx 44rpx;
box-sizing: border-box;
background: linear-gradient(
180deg,
@@ -36,6 +36,16 @@
background: radial-gradient(circle at center, rgba(255, 255, 255, 0.04), transparent 70%);
}
.devices-page__subtitle {
position: relative;
z-index: 1;
display: block;
margin: 12rpx 0 24rpx;
color: var(--color-text-secondary);
font-size: 24rpx;
line-height: 1.6;
}
.devices-page__content {
position: relative;
z-index: 1;

View File

@@ -1,5 +1,7 @@
import { Text, View } from "@tarojs/components";
import Taro from "@tarojs/taro";
import AppBar from "../../components/app-bar";
import { navigateBackWithFallback } from "../../utils/app-bar";
import { getDeviceCards, type DeviceCard } from "./device-data";
import "./index.scss";
@@ -18,6 +20,9 @@ export default function DevicesPage() {
<View className="devices-page__halo devices-page__halo--large" />
<View className="devices-page__halo devices-page__halo--small" />
<AppBar title="我的设备" showBack onBack={() => navigateBackWithFallback("/pages/mine/index")} />
<Text className="devices-page__subtitle"></Text>
<View className="devices-page__content">
{deviceCards.map((card) => (
<View

View File

@@ -1,7 +1,7 @@
.device-page {
position: relative;
min-height: 100vh;
padding: calc(var(--top-safe-height, 0px) + 24rpx) 24rpx 156rpx;
padding: 0 24rpx 156rpx;
box-sizing: border-box;
background: linear-gradient(180deg, var(--color-bg-page-gradient-start) 0%, var(--color-bg-page-gradient-end) 100%);
overflow: hidden;
@@ -34,40 +34,48 @@
.device-header {
position: relative;
z-index: 1;
width: 100%;
padding-right: calc(var(--menu-safe-width, 0px) + 12rpx);
margin-bottom: 8rpx;
min-height: calc((var(--menu-top, 0px) - var(--top-safe-height, 0px) - 24rpx) + var(--menu-height, 32px) + 36rpx + 6rpx);
}
.device-header__top-actions {
position: relative;
z-index: 1;
display: flex;
align-items: center;
justify-content: space-between;
margin: 12rpx 0 18rpx;
}
.device-header__login {
position: absolute;
left: 0;
top: calc(var(--menu-top, 0px) - var(--top-safe-height, 0px) - 24rpx);
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 92rpx;
height: var(--menu-height, 32px);
min-height: 56rpx;
padding: 0 24rpx;
border-radius: 999rpx;
background: linear-gradient(90deg, var(--color-brand-start) 0%, var(--color-brand-end) 100%);
color: var(--color-text-white);
font-size: 22rpx;
line-height: var(--menu-height, 32px);
text-align: center;
box-shadow: 0 12rpx 24rpx var(--color-brand-shadow);
}
.device-header__add {
position: absolute;
right: calc(var(--menu-safe-width, 0px) + 2rpx);
top: calc((var(--menu-top, 0px) - var(--top-safe-height, 0px) - 24rpx) + var(--menu-height, 32px) + 6rpx);
display: inline-flex;
align-items: center;
justify-content: center;
width: 36rpx;
height: 36rpx;
border: 2rpx solid var(--color-border-strong);
border-radius: 50%;
}
.device-header__login-text {
color: var(--color-text-white);
font-size: 22rpx;
}
.device-header__add-text {
color: var(--color-text-white);
font-size: 30rpx;
line-height: 30rpx;
text-align: center;
line-height: 1;
}
.device-summary {

View File

@@ -1,8 +1,8 @@
import { Text, View } from "@tarojs/components";
import Taro from "@tarojs/taro";
import type { CSSProperties } from "react";
import { useEffect, useRef, useState } from "react";
import ActionButton from "../../components/action-button";
import AppBar from "../../components/app-bar";
import BottomTabbar from "../../components/bottom-tabbar";
import PanelCard from "../../components/panel-card";
import { createMainTabItems, handleMainTabNavigation } from "../../utils/main-tabbar";
@@ -49,24 +49,9 @@ function parseDeviceCode(result?: string) {
export default function Index() {
const [deviceCount, setDeviceCount] = useState(0);
const [bluetoothStatus, setBluetoothStatus] = useState<BluetoothStatus>("idle");
const [topSafeHeight, setTopSafeHeight] = useState(0);
const [menuSafeWidth, setMenuSafeWidth] = useState(0);
const [menuTop, setMenuTop] = useState(0);
const [menuHeight, setMenuHeight] = useState(0);
const discoveryTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
const windowInfo = typeof Taro.getWindowInfo === "function" ? Taro.getWindowInfo() : Taro.getSystemInfoSync();
const menuButtonRect =
typeof Taro.getMenuButtonBoundingClientRect === "function" ? Taro.getMenuButtonBoundingClientRect() : null;
const safeTop = menuButtonRect?.bottom || windowInfo.statusBarHeight || 0;
const safeRight = menuButtonRect ? windowInfo.windowWidth - menuButtonRect.left : 0;
setTopSafeHeight(safeTop);
setMenuSafeWidth(safeRight);
setMenuTop(menuButtonRect?.top || Math.max((windowInfo.statusBarHeight || 0) + 6, 0));
setMenuHeight(menuButtonRect?.height || 32);
return () => {
if (discoveryTimerRef.current) {
clearTimeout(discoveryTimerRef.current);
@@ -236,25 +221,19 @@ export default function Index() {
}
};
const pageStyle = {
"--top-safe-height": `${topSafeHeight}px`,
"--menu-safe-width": `${menuSafeWidth}px`,
"--menu-top": `${menuTop}px`,
"--menu-height": `${menuHeight}px`
} as CSSProperties;
return (
<View className="device-page" style={pageStyle}>
<View className="device-page">
<View className="device-page__halo device-page__halo--large" />
<View className="device-page__halo device-page__halo--small" />
<View className="device-header">
<AppBar title="" />
<View className="device-header__top-actions">
<View className="device-header__login" onClick={handleLogin}>
<Text className="device-header__login-text"></Text>
</View>
<View className="device-header__add" onClick={handleAdd}>
+
<Text className="device-header__add-text">+</Text>
</View>
</View>

View File

@@ -1,7 +1,7 @@
.message-page {
position: relative;
min-height: 100vh;
padding: calc(var(--top-safe-height, 0px) + 24rpx) 24rpx 156rpx;
padding: 0 24rpx 156rpx;
box-sizing: border-box;
background: linear-gradient(180deg, #1d2331 0%, #171d29 100%);
overflow: hidden;
@@ -29,37 +29,39 @@
background: radial-gradient(circle, rgba(86, 116, 184, 0.12), transparent 72%);
}
.message-header {
.message-tabs {
position: relative;
z-index: 1;
width: 100%;
padding-right: calc(var(--menu-safe-width, 0px) + 12rpx);
margin-bottom: 10rpx;
box-sizing: border-box;
min-height: calc((var(--menu-top, 0px) - var(--top-safe-height, 0px) - 24rpx) + var(--menu-height, 32px) + 24rpx);
}
.message-tabs {
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
gap: 110rpx;
min-height: inherit;
padding: 0;
margin-top: 12rpx;
margin-bottom: 24rpx;
}
.message-tabs__item {
position: relative;
flex: 1;
height: 64rpx;
min-width: 0;
display: flex;
align-items: center;
justify-content: center;
min-width: 120rpx;
}
.message-tabs__content {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8rpx;
}
.message-tabs__label {
color: #8f97ac;
font-size: 24rpx;
line-height: 1.3;
text-align: center;
}
.message-tabs__label--active {
@@ -68,13 +70,12 @@
}
.message-tabs__dot {
position: absolute;
top: 6rpx;
right: -10rpx;
width: 10rpx;
height: 10rpx;
border-radius: 50%;
background: #ff4d4f;
flex-shrink: 0;
transform: translateY(-8rpx);
}
.message-tabs__line {

View File

@@ -1,7 +1,6 @@
import { Text, View } from "@tarojs/components";
import Taro from "@tarojs/taro";
import type { CSSProperties } from "react";
import { useEffect, useState } from "react";
import { useState } from "react";
import AppBar from "../../components/app-bar";
import BottomTabbar from "../../components/bottom-tabbar";
import { createMainTabItems, handleMainTabNavigation } from "../../utils/main-tabbar";
import "./index.scss";
@@ -87,54 +86,32 @@ const fieldLabels = {
export default function MessagePage() {
const [activeTab, setActiveTab] = useState<MessageTabKey>("vital");
const [topSafeHeight, setTopSafeHeight] = useState(0);
const [menuSafeWidth, setMenuSafeWidth] = useState(0);
const [menuTop, setMenuTop] = useState(0);
const [menuHeight, setMenuHeight] = useState(0);
const currentMessages = messageList.filter((item) => item.category === activeTab);
useEffect(() => {
const windowInfo = typeof Taro.getWindowInfo === "function" ? Taro.getWindowInfo() : Taro.getSystemInfoSync();
const menuButtonRect =
typeof Taro.getMenuButtonBoundingClientRect === "function" ? Taro.getMenuButtonBoundingClientRect() : null;
const safeTop = menuButtonRect?.bottom || windowInfo.statusBarHeight || 0;
const safeRight = menuButtonRect ? windowInfo.windowWidth - menuButtonRect.left : 0;
setTopSafeHeight(safeTop);
setMenuSafeWidth(safeRight);
setMenuTop(menuButtonRect?.top || Math.max((windowInfo.statusBarHeight || 0) + 6, 0));
setMenuHeight(menuButtonRect?.height || 32);
}, []);
const navItems = createMainTabItems("message");
const pageStyle = {
"--top-safe-height": `${topSafeHeight}px`,
"--menu-safe-width": `${menuSafeWidth}px`,
"--menu-top": `${menuTop}px`,
"--menu-height": `${menuHeight}px`
} as CSSProperties;
return (
<View className="message-page" style={pageStyle}>
<View className="message-page">
<View className="message-page__glow message-page__glow--top" />
<View className="message-page__glow message-page__glow--side" />
<View className="message-header">
<View className="message-tabs">
{tabs.map((item) => {
const isActive = item.key === activeTab;
<AppBar title="" />
return (
<View className="message-tabs__item" key={item.key} onClick={() => setActiveTab(item.key)}>
<View className="message-tabs">
{tabs.map((item) => {
const isActive = item.key === activeTab;
return (
<View className="message-tabs__item" key={item.key} onClick={() => setActiveTab(item.key)}>
<View className="message-tabs__content">
<Text className={`message-tabs__label ${isActive ? "message-tabs__label--active" : ""}`}>{item.label}</Text>
{item.hasDot ? <View className="message-tabs__dot" /> : null}
{isActive ? <View className="message-tabs__line" /> : null}
</View>
);
})}
</View>
{isActive ? <View className="message-tabs__line" /> : null}
</View>
);
})}
</View>
<View className="message-list">

View File

@@ -1,7 +1,7 @@
.mine-page {
position: relative;
min-height: 100vh;
padding: calc(var(--top-safe-height, 0px) + 34rpx) 24rpx 170rpx;
padding: 0 24rpx 170rpx;
box-sizing: border-box;
background: linear-gradient(180deg, #1f2534 0%, #171d29 100%);
overflow: hidden;
@@ -31,6 +31,14 @@
background: radial-gradient(circle at center, rgba(255, 255, 255, 0.04), transparent 70%);
}
.mine-page__top-actions {
position: relative;
z-index: 1;
display: flex;
justify-content: flex-end;
margin: 12rpx 0 18rpx;
}
.mine-page__header-card {
position: relative;
z-index: 1;
@@ -132,7 +140,6 @@
flex-direction: column;
align-items: flex-end;
padding-left: 18rpx;
padding-right: calc(var(--menu-safe-width, 0px) + 6rpx);
}
.mine-page__icon-actions {

View File

@@ -1,7 +1,6 @@
import { Text, View } from "@tarojs/components";
import Taro from "@tarojs/taro";
import type { CSSProperties } from "react";
import { useEffect, useState } from "react";
import AppBar from "../../components/app-bar";
import BottomTabbar from "../../components/bottom-tabbar";
import { createMainTabItems, handleMainTabNavigation } from "../../utils/main-tabbar";
import "./index.scss";
@@ -40,20 +39,6 @@ const featureItems: FeatureItem[] = [
];
export default function MinePage() {
const [topSafeHeight, setTopSafeHeight] = useState(0);
const [menuSafeWidth, setMenuSafeWidth] = useState(0);
useEffect(() => {
const windowInfo = typeof Taro.getWindowInfo === "function" ? Taro.getWindowInfo() : Taro.getSystemInfoSync();
const menuButtonRect =
typeof Taro.getMenuButtonBoundingClientRect === "function" ? Taro.getMenuButtonBoundingClientRect() : null;
const safeTop = menuButtonRect?.bottom || windowInfo.statusBarHeight || 0;
const safeRight = menuButtonRect ? windowInfo.windowWidth - menuButtonRect.left : 0;
setTopSafeHeight(safeTop);
setMenuSafeWidth(safeRight);
}, []);
const showToast = (title: string) => {
Taro.showToast({
title,
@@ -76,16 +61,19 @@ export default function MinePage() {
const tabItems = createMainTabItems("mine");
const pageStyle = {
"--top-safe-height": `${topSafeHeight}px`,
"--menu-safe-width": `${menuSafeWidth}px`
} as CSSProperties;
return (
<View className="mine-page" style={pageStyle}>
<View className="mine-page">
<View className="mine-page__halo mine-page__halo--large" />
<View className="mine-page__halo mine-page__halo--small" />
<AppBar title="" />
<View className="mine-page__top-actions">
<View className="mine-page__icon-actions">
<View className="mine-page__circle-action mine-page__circle-action--support" onClick={() => openPage("/pages/support/index")} />
<View className="mine-page__circle-action mine-page__circle-action--settings" onClick={() => openPage("/pages/settings/index")} />
</View>
</View>
<View className="mine-page__header-card">
<View className="mine-page__header-main">
<View className="mine-page__avatar">
@@ -111,11 +99,6 @@ export default function MinePage() {
</View>
<View className="mine-page__header-actions">
<View className="mine-page__icon-actions">
<View className="mine-page__circle-action mine-page__circle-action--support" onClick={() => openPage("/pages/support/index")} />
<View className="mine-page__circle-action mine-page__circle-action--settings" onClick={() => openPage("/pages/settings/index")} />
</View>
<Text className="mine-page__profile-link" onClick={() => openPage("/pages/profile/index")}>
</Text>

View File

@@ -1,7 +1,7 @@
.repair-detail-page {
position: relative;
min-height: 100vh;
padding: 28rpx 24rpx 72rpx;
padding: 0 24rpx 72rpx;
box-sizing: border-box;
background: linear-gradient(180deg, #0b1220 0%, #121a2c 100%);
overflow: hidden;
@@ -36,6 +36,16 @@
z-index: 1;
}
.repair-detail-page__header-note {
position: relative;
z-index: 1;
display: block;
margin-top: 12rpx;
color: rgba(255, 255, 255, 0.58);
font-size: 24rpx;
text-align: center;
}
.repair-detail-page__hero {
display: flex;
flex-direction: column;

View File

@@ -1,5 +1,7 @@
import { Button, Text, View } from "@tarojs/components";
import Taro from "@tarojs/taro";
import AppBar from "../../components/app-bar";
import { navigateBackWithFallback } from "../../utils/app-bar";
import {
REPAIR_DEVICE_TYPE_LABELS,
REPAIR_DRAFT_STORAGE_KEY,
@@ -30,6 +32,9 @@ export default function RepairDetailPage() {
<View className="repair-detail-page__glow repair-detail-page__glow--left" />
<View className="repair-detail-page__glow repair-detail-page__glow--right" />
<AppBar title="报修详情" showBack onBack={() => navigateBackWithFallback("/pages/repair/index")} />
<Text className="repair-detail-page__header-note"></Text>
<View className="repair-detail-page__hero">
<View className="repair-detail-page__success-ring">
<View className="repair-detail-page__success-check" />
@@ -91,7 +96,7 @@ export default function RepairDetailPage() {
</View>
</View>
<Button className="repair-detail-page__button" onClick={() => Taro.navigateBack()}>
<Button className="repair-detail-page__button" onClick={() => navigateBackWithFallback("/pages/repair/index")}>
</Button>
</View>

View File

@@ -1,7 +1,7 @@
.repair-page {
position: relative;
min-height: 100vh;
padding: 28rpx 24rpx 72rpx;
padding: 0 24rpx 72rpx;
box-sizing: border-box;
background: linear-gradient(180deg, #0b1220 0%, #121a2c 100%);
overflow: hidden;
@@ -29,7 +29,6 @@
background: radial-gradient(circle, rgba(76, 118, 214, 0.14) 0%, rgba(76, 118, 214, 0) 72%);
}
.repair-page__toolbar,
.repair-page__tabs,
.repair-page__card,
.repair-page__add-card,
@@ -38,56 +37,27 @@
z-index: 1;
}
.repair-page__toolbar {
.repair-page__header-row {
position: relative;
z-index: 1;
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 24rpx;
gap: 20rpx;
margin: 12rpx 0 24rpx;
}
.repair-page__toolbar-spacer {
width: 1rpx;
height: 1rpx;
.repair-page__header-subtitle {
flex: 1;
color: rgba(255, 255, 255, 0.58);
font-size: 24rpx;
line-height: 1.6;
}
.repair-page__history {
display: flex;
align-items: center;
gap: 12rpx;
padding: 8rpx 0 8rpx 12rpx;
}
.repair-page__clock {
position: relative;
width: 34rpx;
height: 34rpx;
border: 2rpx solid rgba(255, 255, 255, 0.8);
border-radius: 50%;
}
.repair-page__clock-hand {
position: absolute;
left: 50%;
bottom: 50%;
width: 2rpx;
background: rgba(255, 255, 255, 0.8);
border-radius: 999rpx;
transform-origin: bottom center;
}
.repair-page__clock-hand--hour {
height: 9rpx;
transform: translateX(-50%) rotate(18deg);
}
.repair-page__clock-hand--minute {
height: 12rpx;
transform: translateX(-50%) rotate(112deg);
}
.repair-page__history-text {
color: rgba(255, 255, 255, 0.76);
font-size: 22rpx;
.repair-page__header-action {
flex-shrink: 0;
color: #35e5b3;
font-size: 24rpx;
}
.repair-page__tabs {

View File

@@ -1,6 +1,8 @@
import { Button, Image, Input, Text, Textarea, View } from "@tarojs/components";
import Taro from "@tarojs/taro";
import { useMemo, useState } from "react";
import AppBar from "../../components/app-bar";
import { navigateBackWithFallback } from "../../utils/app-bar";
import {
REPAIR_DESCRIPTION_LIMIT,
REPAIR_DEVICE_TYPE_LABELS,
@@ -236,16 +238,16 @@ export default function RepairPage() {
<View className="repair-page__glow repair-page__glow--left" />
<View className="repair-page__glow repair-page__glow--right" />
<View className="repair-page__toolbar">
<View className="repair-page__toolbar-spacer" />
<View className="repair-page__history" onClick={() => showToast("历史记录功能待开放")}>
<View className="repair-page__clock">
<View className="repair-page__clock-hand repair-page__clock-hand--hour" />
<View className="repair-page__clock-hand repair-page__clock-hand--minute" />
</View>
<Text className="repair-page__history-text"></Text>
</View>
<AppBar
title="申请报修"
showBack
onBack={() => navigateBackWithFallback("/pages/mine/index")}
/>
<View className="repair-page__header-row">
<Text className="repair-page__header-subtitle"></Text>
<Text className="repair-page__header-action" onClick={() => showToast("历史记录功能待开放")}>
</Text>
</View>
<View className="repair-page__tabs">

View File

@@ -9,7 +9,7 @@
position: relative;
z-index: 1;
min-height: 100vh;
padding: calc(var(--report-top-gap, 0px) + 20rpx) 24rpx 56rpx;
padding: 0 24rpx 56rpx;
box-sizing: border-box;
}
@@ -35,51 +35,25 @@
background: radial-gradient(circle, rgba(36, 174, 255, 0.14) 0%, rgba(36, 174, 255, 0) 70%);
}
.report-header {
.report-page__meta-bar {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 24rpx;
gap: 20rpx;
margin: 12rpx 0 24rpx;
}
.report-header__action {
display: flex;
align-items: center;
justify-content: center;
min-width: 68rpx;
height: 52rpx;
padding: 0 16rpx;
border-radius: 18rpx;
background: rgba(32, 42, 58, 0.82);
box-shadow: inset 0 0 0 2rpx rgba(255, 255, 255, 0.04);
}
.report-header__action--share {
min-width: 96rpx;
}
.report-header__action-text {
color: #dce7f8;
font-size: 24rpx;
}
.report-header__content {
.report-page__date-label {
flex: 1;
padding: 0 20rpx;
color: #91a3b8;
font-size: 24rpx;
line-height: 1.6;
}
.report-header__title {
display: block;
color: #f7fbff;
font-size: 34rpx;
font-weight: 600;
}
.report-header__subtitle {
display: block;
margin-top: 6rpx;
color: #8ea0b6;
font-size: 22rpx;
.report-page__share-action {
flex-shrink: 0;
color: #36e4aa;
font-size: 24rpx;
}
.report-toolbar {

View File

@@ -1,7 +1,7 @@
import { ScrollView, Text, View } from "@tarojs/components";
import Taro from "@tarojs/taro";
import type { CSSProperties } from "react";
import { useMemo, useState } from "react";
import AppBar from "../../components/app-bar";
import { ReportChartCard } from "../../components/report/chart-card";
import { ReportChipGroup } from "../../components/report/chip-group";
import { ReportDaytimePrediction } from "../../components/report/daytime-prediction";
@@ -9,9 +9,9 @@ import { ReportDistributionCard } from "../../components/report/distribution-car
import { ReportInsightList } from "../../components/report/insight-list";
import { ReportKvList } from "../../components/report/kv-list";
import { ReportMetricGrid } from "../../components/report/metric-grid";
import { ReportPageHeader } from "../../components/report/page-header";
import { ReportScoreOverview } from "../../components/report/score-overview";
import { ReportSummaryBlock } from "../../components/report/summary-block";
import { navigateBackWithFallback } from "../../utils/app-bar";
import { reportDimensions, reportOptions, reportRecords } from "./mock";
import { getSleepLevel, pickReportRecord } from "./report-utils";
import type { ReportDimension } from "./types";
@@ -81,27 +81,31 @@ export default function ReportPage() {
resetSelections(dimension, dateKey, roomKey, nextDevice);
};
const pageStyle = {
"--report-top-gap": `${(typeof Taro.getWindowInfo === "function" ? Taro.getWindowInfo().statusBarHeight : Taro.getSystemInfoSync().statusBarHeight) || 0}px`
} as CSSProperties;
return (
<ScrollView className="report-page" scrollY style={pageStyle}>
<ScrollView className="report-page" scrollY>
<View className="report-page__glow report-page__glow--one" />
<View className="report-page__glow report-page__glow--two" />
<View className="report-page__content">
<ReportPageHeader
<AppBar
title={currentRecord.babyName}
subtitle={currentRecord.dateLabel}
onBack={() => Taro.navigateBack({ delta: 1 })}
onShare={() =>
Taro.showToast({
title: "分享功能待接入",
icon: "none"
})
}
showBack
onBack={() => navigateBackWithFallback("/pages/index/index")}
/>
<View className="report-page__meta-bar">
<Text className="report-page__date-label">{currentRecord.dateLabel}</Text>
<Text
className="report-page__share-action"
onClick={() =>
Taro.showToast({
title: "分享功能待接入",
icon: "none"
})
}
>
</Text>
</View>
<View className="report-toolbar">
<ReportChipGroup options={reportDimensions} value={dimension} onChange={handleDimensionChange} />

View File

@@ -0,0 +1,38 @@
type AppBarMetricInput = {
menuButtonRect?: Partial<{
top: number;
left: number;
height: number;
}> | null;
statusBarHeight?: number;
windowWidth?: number;
};
export type AppBarMetrics = {
capsuleSafeWidth: number;
menuHeight: number;
menuTop: number;
topInset: number;
};
export function computeAppBarMetrics(input: AppBarMetricInput): AppBarMetrics {
const topInset = Math.max(input.statusBarHeight || 0, 0);
const menuHeight = input.menuButtonRect?.height && input.menuButtonRect.height > 0 ? input.menuButtonRect.height : 32;
const menuTop =
typeof input.menuButtonRect?.top === "number" ? input.menuButtonRect.top : Math.max(topInset + 6, topInset);
const capsuleSafeWidth =
typeof input.menuButtonRect?.left === "number" && input.windowWidth
? Math.max(input.windowWidth - input.menuButtonRect.left, 96)
: 96;
return {
topInset,
menuTop,
menuHeight,
capsuleSafeWidth
};
}
export function resolveBackNavigation(pageStackLength: number) {
return pageStackLength > 1 ? "navigateBack" : "redirectHome";
}

27
src/utils/app-bar.ts Normal file
View File

@@ -0,0 +1,27 @@
import Taro from "@tarojs/taro";
import { computeAppBarMetrics, resolveBackNavigation, type AppBarMetrics } from "./app-bar-metrics";
export { computeAppBarMetrics, resolveBackNavigation, type AppBarMetrics } from "./app-bar-metrics";
export function getAppBarMetrics() {
const windowInfo = typeof Taro.getWindowInfo === "function" ? Taro.getWindowInfo() : Taro.getSystemInfoSync();
const menuButtonRect =
typeof Taro.getMenuButtonBoundingClientRect === "function" ? Taro.getMenuButtonBoundingClientRect() : null;
return computeAppBarMetrics({
menuButtonRect,
statusBarHeight: windowInfo.statusBarHeight,
windowWidth: windowInfo.windowWidth
});
}
export function navigateBackWithFallback(fallbackUrl: string) {
const action = resolveBackNavigation(Taro.getCurrentPages().length);
if (action === "navigateBack") {
Taro.navigateBack({ delta: 1 });
return;
}
Taro.redirectTo({ url: fallbackUrl });
}

4
types/global.d.ts vendored
View File

@@ -1 +1,5 @@
declare module "*.scss";
declare module "*.svg" {
const src: string;
export default src;
}