feat: extract theme tokens and reusable ui components

This commit is contained in:
czz
2026-05-07 17:42:19 +08:00
parent 2e00ee93ce
commit 003333a8f3
12 changed files with 387 additions and 216 deletions

View File

@@ -1,5 +1,7 @@
@use "./styles/theme.scss";
page {
background: #1b2130;
color: #f5f7fb;
background: var(--color-bg-page);
color: var(--color-text-primary);
font-size: 32rpx;
}

View File

@@ -0,0 +1,65 @@
.action-button {
display: flex;
align-items: center;
justify-content: center;
height: 74rpx;
border-radius: 999rpx;
background: linear-gradient(90deg, var(--color-brand-start) 0%, var(--color-brand-end) 100%);
box-shadow: 0 14rpx 26rpx var(--color-brand-shadow-soft);
}
.action-button--secondary {
margin-top: 34rpx;
}
.action-button__icon {
position: relative;
width: 32rpx;
height: 32rpx;
margin-right: 16rpx;
}
.action-button__icon::before,
.action-button__icon::after {
position: absolute;
content: "";
}
.action-button__icon--scan::before {
inset: 4rpx;
border: 2rpx solid var(--color-text-white);
border-radius: 8rpx;
}
.action-button__icon--scan::after {
left: 8rpx;
right: 8rpx;
top: 15rpx;
height: 2rpx;
background: var(--color-text-white);
}
.action-button__icon--bluetooth::before {
left: 14rpx;
top: 2rpx;
width: 2rpx;
height: 28rpx;
background: var(--color-text-white);
}
.action-button__icon--bluetooth::after {
left: 8rpx;
top: 6rpx;
width: 14rpx;
height: 14rpx;
border-top: 2rpx solid var(--color-text-white);
border-right: 2rpx solid var(--color-text-white);
transform: rotate(45deg);
}
.action-button__label {
color: var(--color-text-white);
font-size: 28rpx;
font-weight: 600;
letter-spacing: 1rpx;
}

View File

@@ -0,0 +1,18 @@
import { Text, View } from "@tarojs/components";
import "./index.scss";
type ActionButtonProps = {
icon: "scan" | "bluetooth";
label: string;
secondary?: boolean;
onClick: () => void;
};
export default function ActionButton({ icon, label, secondary = false, onClick }: ActionButtonProps) {
return (
<View className={`action-button ${secondary ? "action-button--secondary" : ""}`} onClick={onClick}>
<View className={`action-button__icon action-button__icon--${icon}`} />
<Text className="action-button__label">{label}</Text>
</View>
);
}

View File

@@ -0,0 +1,86 @@
.bottom-tabbar {
position: fixed;
left: 0;
right: 0;
bottom: 0;
z-index: 5;
display: flex;
align-items: flex-start;
justify-content: space-around;
padding: 16rpx 24rpx calc(20rpx + env(safe-area-inset-bottom));
background: var(--color-bg-tabbar);
box-shadow: var(--shadow-tabbar);
}
.bottom-tabbar__item {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
min-width: 88rpx;
}
.bottom-tabbar__icon {
position: relative;
width: 34rpx;
height: 34rpx;
margin-bottom: 8rpx;
border-radius: 10rpx;
border: 2rpx solid var(--color-border-tab);
opacity: 0.86;
}
.bottom-tabbar__icon::before,
.bottom-tabbar__icon::after {
position: absolute;
content: "";
}
.bottom-tabbar__icon::before {
left: 8rpx;
right: 8rpx;
top: 10rpx;
height: 2rpx;
background: currentColor;
color: var(--color-border-tab);
}
.bottom-tabbar__icon::after {
left: 8rpx;
right: 8rpx;
bottom: 10rpx;
height: 2rpx;
background: currentColor;
color: var(--color-border-tab);
}
.bottom-tabbar__icon--active {
border-color: var(--color-text-accent);
background: var(--color-bg-accent-soft);
}
.bottom-tabbar__icon--active::before,
.bottom-tabbar__icon--active::after {
color: var(--color-text-accent);
}
.bottom-tabbar__badge {
position: absolute;
top: -4rpx;
right: -4rpx;
width: 10rpx;
height: 10rpx;
border-radius: 50%;
background: var(--color-danger);
box-shadow: 0 0 0 4rpx var(--color-bg-tabbar);
}
.bottom-tabbar__label {
color: var(--color-text-secondary);
font-size: 20rpx;
line-height: 1.2;
}
.bottom-tabbar__label--active {
color: var(--color-text-accent);
}

View File

@@ -0,0 +1,29 @@
import { Text, View } from "@tarojs/components";
import "./index.scss";
export type TabbarItem = {
key: string;
label: string;
active?: boolean;
badge?: boolean;
};
type BottomTabbarProps = {
items: TabbarItem[];
onItemClick: (item: TabbarItem) => void;
};
export default function BottomTabbar({ items, onItemClick }: BottomTabbarProps) {
return (
<View className="bottom-tabbar">
{items.map((item) => (
<View className="bottom-tabbar__item" key={item.key} onClick={() => onItemClick(item)}>
<View className={`bottom-tabbar__icon ${item.active ? "bottom-tabbar__icon--active" : ""}`}>
{item.badge ? <View className="bottom-tabbar__badge" /> : null}
</View>
<Text className={`bottom-tabbar__label ${item.active ? "bottom-tabbar__label--active" : ""}`}>{item.label}</Text>
</View>
))}
</View>
);
}

View File

@@ -0,0 +1,13 @@
.panel-card {
position: relative;
z-index: 1;
border-radius: 24rpx;
background: var(--color-bg-surface);
box-shadow: inset 0 0 0 2rpx var(--color-border-light);
}
.panel-card--warning {
border-radius: 22rpx;
background: var(--color-bg-warning);
box-shadow: var(--shadow-surface);
}

View File

@@ -0,0 +1,13 @@
import { View } from "@tarojs/components";
import type { PropsWithChildren } from "react";
import "./index.scss";
type PanelCardProps = PropsWithChildren<{
className?: string;
warning?: boolean;
}>;
export default function PanelCard({ className = "", warning = false, children }: PanelCardProps) {
const classes = ["panel-card", warning ? "panel-card--warning" : "", className].filter(Boolean).join(" ");
return <View className={classes}>{children}</View>;
}

View File

@@ -3,7 +3,7 @@
min-height: 100vh;
padding: calc(var(--top-safe-height, 0px) + 24rpx) 24rpx 156rpx;
box-sizing: border-box;
background: linear-gradient(180deg, #1e2432 0%, #191f2c 100%);
background: linear-gradient(180deg, var(--color-bg-page-gradient-start) 0%, var(--color-bg-page-gradient-end) 100%);
overflow: hidden;
}
@@ -34,33 +34,37 @@
.device-header {
position: relative;
z-index: 1;
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
padding-right: calc(var(--menu-safe-width, 0px) + 12rpx);
margin-bottom: 26rpx;
margin-bottom: 8rpx;
min-height: calc((var(--menu-top, 0px) - var(--top-safe-height, 0px) - 24rpx) + var(--menu-height, 32px) + 36rpx + 6rpx);
}
.device-header__login {
position: absolute;
left: 0;
top: calc(var(--menu-top, 0px) - var(--top-safe-height, 0px) - 24rpx);
min-width: 92rpx;
height: 48rpx;
height: var(--menu-height, 32px);
padding: 0 24rpx;
border-radius: 999rpx;
background: linear-gradient(90deg, #35e5b3 0%, #20c9bf 100%);
color: #ffffff;
background: linear-gradient(90deg, var(--color-brand-start) 0%, var(--color-brand-end) 100%);
color: var(--color-text-white);
font-size: 22rpx;
line-height: 48rpx;
line-height: var(--menu-height, 32px);
text-align: center;
box-shadow: 0 12rpx 24rpx rgba(32, 214, 181, 0.22);
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);
width: 36rpx;
height: 36rpx;
margin-top: 28rpx;
border: 2rpx solid rgba(255, 255, 255, 0.86);
border: 2rpx solid var(--color-border-strong);
border-radius: 50%;
color: #ffffff;
color: var(--color-text-white);
font-size: 30rpx;
line-height: 30rpx;
text-align: center;
@@ -72,7 +76,9 @@
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 22rpx;
width: 100%;
height: 90rpx;
margin-bottom: 18rpx;
}
.device-summary__title-wrap {
@@ -82,108 +88,29 @@
}
.device-summary__title {
color: #f3f7ff;
color: var(--color-text-title);
font-size: 28rpx;
font-weight: 600;
}
.device-summary__count {
color: #ff964f;
color: var(--color-text-highlight);
font-size: 26rpx;
font-weight: 700;
}
.device-summary__arrow {
color: #8a93a7;
color: var(--color-text-muted);
font-size: 28rpx;
}
.device-actions-card {
position: relative;
z-index: 1;
border-radius: 24rpx;
background: rgba(42, 48, 66, 0.96);
box-shadow: inset 0 0 0 2rpx rgba(255, 255, 255, 0.03);
}
.device-actions-card {
padding: 34rpx 30rpx;
}
.device-action {
display: flex;
align-items: center;
justify-content: center;
height: 74rpx;
border-radius: 999rpx;
background: linear-gradient(90deg, #39e6ad 0%, #1fc9c1 100%);
box-shadow: 0 14rpx 26rpx rgba(20, 184, 166, 0.18);
}
.device-action--secondary {
margin-top: 34rpx;
}
.device-action__icon {
position: relative;
width: 32rpx;
height: 32rpx;
margin-right: 16rpx;
}
.device-action__icon::before,
.device-action__icon::after {
position: absolute;
content: "";
}
.device-action__icon--scan::before {
inset: 4rpx;
border: 2rpx solid #ffffff;
border-radius: 8rpx;
}
.device-action__icon--scan::after {
left: 8rpx;
right: 8rpx;
top: 15rpx;
height: 2rpx;
background: #ffffff;
}
.device-action__icon--bluetooth::before {
left: 14rpx;
top: 2rpx;
width: 2rpx;
height: 28rpx;
background: #ffffff;
}
.device-action__icon--bluetooth::after {
left: 8rpx;
top: 6rpx;
width: 14rpx;
height: 14rpx;
border-top: 2rpx solid #ffffff;
border-right: 2rpx solid #ffffff;
transform: rotate(45deg);
}
.device-action__label {
color: #ffffff;
font-size: 28rpx;
font-weight: 600;
letter-spacing: 1rpx;
}
.device-notice-card {
position: relative;
z-index: 1;
margin-top: 22rpx;
padding: 22rpx 24rpx 24rpx;
border-radius: 22rpx;
background: #fbefc7;
box-shadow: 0 12rpx 24rpx rgba(6, 10, 22, 0.14);
}
.device-notice-card__title-row {
@@ -198,7 +125,7 @@
height: 16rpx;
margin-right: 12rpx;
border-radius: 4rpx;
background: #ff9d57;
background: var(--color-warning-main);
}
.device-notice-card__horn::before,
@@ -214,7 +141,7 @@
height: 0;
border-top: 6rpx solid transparent;
border-bottom: 6rpx solid transparent;
border-left: 8rpx solid #ff9d57;
border-left: 8rpx solid var(--color-warning-main);
}
.device-notice-card__horn::after {
@@ -222,14 +149,14 @@
top: 2rpx;
width: 8rpx;
height: 8rpx;
border-top: 2rpx solid #ff9d57;
border-right: 2rpx solid #ff9d57;
border-top: 2rpx solid var(--color-warning-main);
border-right: 2rpx solid var(--color-warning-main);
border-radius: 0 8rpx 0 0;
transform: rotate(45deg);
}
.device-notice-card__title {
color: #af7e42;
color: var(--color-text-warning);
font-size: 22rpx;
font-weight: 600;
}
@@ -241,94 +168,7 @@
}
.device-notice-card__item {
color: #9f7a4c;
color: var(--color-text-warning-secondary);
font-size: 22rpx;
line-height: 1.7;
}
.device-tabbar {
position: fixed;
left: 0;
right: 0;
bottom: 0;
z-index: 5;
display: flex;
align-items: flex-start;
justify-content: space-around;
padding: 16rpx 24rpx calc(20rpx + env(safe-area-inset-bottom));
background: rgba(30, 35, 48, 0.98);
box-shadow: 0 -8rpx 24rpx rgba(4, 8, 20, 0.34);
}
.device-tabbar__item {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
min-width: 88rpx;
}
.device-tabbar__icon {
position: relative;
width: 34rpx;
height: 34rpx;
margin-bottom: 8rpx;
border-radius: 10rpx;
border: 2rpx solid #8f97a9;
opacity: 0.86;
}
.device-tabbar__icon::before,
.device-tabbar__icon::after {
position: absolute;
content: "";
}
.device-tabbar__icon::before {
left: 8rpx;
right: 8rpx;
top: 10rpx;
height: 2rpx;
background: currentColor;
color: #8f97a9;
}
.device-tabbar__icon::after {
left: 8rpx;
right: 8rpx;
bottom: 10rpx;
height: 2rpx;
background: currentColor;
color: #8f97a9;
}
.device-tabbar__icon--active {
border-color: #36e4aa;
background: rgba(54, 228, 170, 0.12);
}
.device-tabbar__icon--active::before,
.device-tabbar__icon--active::after {
color: #36e4aa;
}
.device-tabbar__badge {
position: absolute;
top: -4rpx;
right: -4rpx;
width: 10rpx;
height: 10rpx;
border-radius: 50%;
background: #ff4d4f;
box-shadow: 0 0 0 4rpx rgba(30, 35, 48, 0.98);
}
.device-tabbar__label {
color: #b0b6c4;
font-size: 20rpx;
line-height: 1.2;
}
.device-tabbar__label--active {
color: #36e4aa;
}

View File

@@ -2,6 +2,9 @@ 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 BottomTabbar, { type TabbarItem } from "../../components/bottom-tabbar";
import PanelCard from "../../components/panel-card";
import "./index.scss";
type BluetoothStatus = "idle" | "searching" | "empty" | "success";
@@ -18,7 +21,7 @@ const notices = [
"3. 扫码功能需开启相机权限"
];
const navItems = [
const navItems: TabbarItem[] = [
{ key: "home", label: "首页", active: true },
{ key: "report", label: "报告", active: false },
{ key: "assistant", label: "小e", active: false },
@@ -55,6 +58,8 @@ export default function Index() {
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(() => {
@@ -66,6 +71,8 @@ export default function Index() {
setTopSafeHeight(safeTop);
setMenuSafeWidth(safeRight);
setMenuTop(menuButtonRect?.top || Math.max((windowInfo.statusBarHeight || 0) + 6, 0));
setMenuHeight(menuButtonRect?.height || 32);
return () => {
if (discoveryTimerRef.current) {
@@ -240,7 +247,9 @@ export default function Index() {
const pageStyle = {
"--top-safe-height": `${topSafeHeight}px`,
"--menu-safe-width": `${menuSafeWidth}px`
"--menu-safe-width": `${menuSafeWidth}px`,
"--menu-top": `${menuTop}px`,
"--menu-height": `${menuHeight}px`
} as CSSProperties;
return (
@@ -266,19 +275,12 @@ export default function Index() {
<Text className="device-summary__arrow">&gt;</Text>
</View>
<View className="device-actions-card">
<View className="device-action" onClick={handleScanBind}>
<View className="device-action__icon device-action__icon--scan" />
<Text className="device-action__label"> </Text>
</View>
<PanelCard className="device-actions-card">
<ActionButton icon="scan" label="扫码 添加新设备" onClick={handleScanBind} />
<ActionButton icon="bluetooth" label="蓝牙搜附近的设备" secondary onClick={handleBluetoothBind} />
</PanelCard>
<View className="device-action device-action--secondary" onClick={handleBluetoothBind}>
<View className="device-action__icon device-action__icon--bluetooth" />
<Text className="device-action__label"></Text>
</View>
</View>
<View className="device-notice-card">
<PanelCard className="device-notice-card" warning>
<View className="device-notice-card__title-row">
<View className="device-notice-card__horn" />
<Text className="device-notice-card__title"></Text>
@@ -291,18 +293,9 @@ export default function Index() {
</Text>
))}
</View>
</View>
</PanelCard>
<View className="device-tabbar">
{navItems.map((item) => (
<View className="device-tabbar__item" key={item.key} onClick={() => handleTabClick(item.label)}>
<View className={`device-tabbar__icon ${item.active ? "device-tabbar__icon--active" : ""}`}>
{item.badge ? <View className="device-tabbar__badge" /> : null}
</View>
<Text className={`device-tabbar__label ${item.active ? "device-tabbar__label--active" : ""}`}>{item.label}</Text>
</View>
))}
</View>
<BottomTabbar items={navItems} onItemClick={(item) => handleTabClick(item.label)} />
</View>
);
}

35
src/styles/theme.scss Normal file
View File

@@ -0,0 +1,35 @@
:root,
page {
--color-bg-page: #1b2130;
--color-bg-page-gradient-start: #1e2432;
--color-bg-page-gradient-end: #191f2c;
--color-bg-surface: rgba(42, 48, 66, 0.96);
--color-bg-tabbar: rgba(30, 35, 48, 0.98);
--color-bg-accent-soft: rgba(54, 228, 170, 0.12);
--color-bg-warning: #fbefc7;
--color-text-primary: #f5f7fb;
--color-text-title: #f3f7ff;
--color-text-secondary: #b0b6c4;
--color-text-muted: #8a93a7;
--color-text-white: #ffffff;
--color-text-accent: #36e4aa;
--color-text-warning: #af7e42;
--color-text-warning-secondary: #9f7a4c;
--color-text-highlight: #ff964f;
--color-border-light: rgba(255, 255, 255, 0.03);
--color-border-strong: rgba(255, 255, 255, 0.86);
--color-border-tab: #8f97a9;
--color-brand-start: #35e5b3;
--color-brand-end: #20c9bf;
--color-brand-shadow: rgba(32, 214, 181, 0.22);
--color-brand-shadow-soft: rgba(20, 184, 166, 0.18);
--color-warning-main: #ff9d57;
--color-danger: #ff4d4f;
--shadow-surface: 0 12rpx 24rpx rgba(6, 10, 22, 0.14);
--shadow-tabbar: 0 -8rpx 24rpx rgba(4, 8, 20, 0.34);
}