refactor(mp): CSS 变量主题 + 登录页改造 — UI 优化 Phase 0-2

Phase 0: 建立 design token 体系
- tokens.scss 新增 --tk-pri/--tk-pri-l/--tk-pri-d/--tk-shadow-btn/--tk-shadow-tab
- .doctor-mode 覆盖为靛蓝色系,.elder-mode 非线性放大字号
- variables.scss 新增医生端色彩 + 阴影变量

Phase 1: 组件库 + 页面全局替换
- 75 个页面 SCSS $pri → var(--tk-pri) 全量替换
- 11 个新 UI 组件(PrimaryButton/TabFilter/FormInput/ProgressRing 等)
- 8 个现有组件 SCSS 更新
- 18 个医生端页面 useElderClass → useDoctorClass
- PageHeader 匹配原型 NavBar 规格

Phase 2: 登录页重写
- Logo: 方形+ → 圆形渐变 H
- 登录方式: 纯微信 → 账号密码 + 微信一键登录
- 新增 credentialLogin API + store action
- 字号/间距严格匹配原型 mp-01-login.html
This commit is contained in:
iven
2026-05-16 21:29:13 +08:00
parent 1786f0d707
commit 95e219ad5a
124 changed files with 2306 additions and 1142 deletions

View File

@@ -5,18 +5,18 @@
flex-direction: column;
align-items: center;
justify-content: center;
padding: 120px 40px;
padding: var(--tk-gap-2xl) var(--tk-gap-xl);
}
.empty-state-icon-wrap {
width: 120px;
height: 120px;
width: var(--tk-gap-2xl);
height: var(--tk-gap-2xl);
border-radius: 50%;
background: $surface-alt;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 24px;
margin-bottom: var(--tk-gap-lg);
}
.empty-state-icon-char {
@@ -29,19 +29,19 @@
.empty-state-text {
font-size: var(--tk-font-num);
color: $tx2;
margin-bottom: 8px;
margin-bottom: var(--tk-gap-xs);
}
.empty-state-hint {
font-size: var(--tk-font-h2);
color: var(--tk-text-secondary);
margin-bottom: 32px;
margin-bottom: var(--tk-gap-xl);
}
.empty-state-action {
background: $pri;
background: var(--tk-pri);
border-radius: 40px;
padding: 16px 48px;
padding: var(--tk-gap-md) var(--tk-gap-2xl);
}
.empty-state-action-text {

View File

@@ -13,7 +13,7 @@
width: 64px;
height: 64px;
border-radius: 32px;
background: $pri-l;
background: var(--tk-pri-l);
display: flex;
align-items: center;
justify-content: center;
@@ -24,7 +24,7 @@
font-family: 'Georgia', 'Times New Roman', serif;
font-size: var(--tk-font-h1);
font-weight: 600;
color: $pri-d;
color: var(--tk-pri-d);
}
.error-title {
@@ -41,7 +41,7 @@
}
.error-retry-btn {
background: $pri;
background: var(--tk-pri);
border-radius: $r-sm;
padding: 14px 48px;
}

View File

@@ -5,25 +5,25 @@
flex-direction: column;
align-items: center;
justify-content: center;
padding: 120px 40px;
padding: var(--tk-gap-2xl) var(--tk-gap-xl);
}
.error-state-icon {
font-size: var(--tk-font-display);
margin-bottom: 24px;
margin-bottom: var(--tk-gap-lg);
}
.error-state-text {
font-size: var(--tk-font-body-lg);
color: $tx2;
margin-bottom: 32px;
margin-bottom: var(--tk-gap-xl);
text-align: center;
}
.error-state-retry {
background: $pri;
background: var(--tk-pri);
border-radius: 40px;
padding: 16px 48px;
padding: var(--tk-gap-md) var(--tk-gap-2xl);
}
.error-state-retry-text {

View File

@@ -48,7 +48,7 @@
display: inline-block;
height: 48px;
padding: 0 32px;
background: $pri;
background: var(--tk-pri);
border-radius: $r-pill;
@include flex-center;

View File

@@ -12,7 +12,7 @@
width: 48px;
height: 48px;
border: 4px solid $bd;
border-top-color: $pri;
border-top-color: var(--tk-pri);
border-radius: 50%;
animation: spin 0.8s linear infinite;
margin-bottom: 20px;

View File

@@ -17,7 +17,7 @@
&--active {
.seg-tab__text {
color: $pri;
color: var(--tk-pri);
font-weight: bold;
}
@@ -28,7 +28,7 @@
left: 30%;
right: 30%;
height: 4px;
background: $pri;
background: var(--tk-pri);
border-radius: $r-xs;
}
}

View File

@@ -44,7 +44,7 @@
z-index: 1;
&.step-current {
background: $pri;
background: var(--tk-pri);
color: white;
}
@@ -61,7 +61,7 @@
text-align: center;
&.step-current {
color: $pri;
color: var(--tk-pri);
font-weight: bold;
}

View File

@@ -15,7 +15,7 @@
.week-arrow {
font-size: var(--tk-font-body-lg);
color: $pri;
color: var(--tk-pri);
padding: 0 16px;
}
@@ -52,7 +52,7 @@
}
.cell-today {
color: $pri;
color: var(--tk-pri);
font-weight: bold;
}
@@ -68,7 +68,7 @@
}
.cell-selected {
background: $pri;
background: var(--tk-pri);
border-radius: $r-sm;
.cell-date { color: white; }

View File

@@ -4,9 +4,10 @@
display: flex;
align-items: center;
justify-content: space-between;
height: var(--tk-touch-min);
height: 44px;
padding: 0 var(--tk-page-padding);
background: $bg;
border-bottom: 1px solid $bd-l;
z-index: 10;
&--sticky {
@@ -17,7 +18,7 @@
&__left {
display: flex;
align-items: center;
gap: 8px;
gap: var(--tk-gap-xs);
min-width: 0;
flex: 1;
}
@@ -27,19 +28,19 @@
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
min-height: var(--tk-touch-min);
height: 44px;
}
&__back-icon {
font-size: 24px;
color: $tx;
font-size: var(--tk-font-h2);
color: var(--tk-pri);
line-height: 1;
}
&__title {
font-size: var(--tk-font-h1);
font-weight: 600;
font-family: Georgia, 'Times New Roman', serif;
font-size: var(--tk-font-nav);
font-weight: 700;
color: $tx;
overflow: hidden;
text-overflow: ellipsis;
@@ -49,7 +50,7 @@
&__right {
display: flex;
align-items: center;
gap: 12px;
gap: var(--tk-gap-sm);
flex-shrink: 0;
}
}

View File

@@ -11,7 +11,7 @@
display: flex;
align-items: center;
justify-content: center;
padding: 8px 20px;
padding: var(--tk-gap-xs) var(--tk-section-gap);
border-radius: $r-sm;
background: var(--tk-card-bg);
border: 1px solid $bd;

View File

@@ -6,16 +6,16 @@
&__input-wrap {
display: flex;
align-items: center;
gap: 8px;
gap: var(--tk-gap-xs);
background: var(--tk-card-bg);
border-radius: var(--tk-card-radius);
padding: 0 16px;
height: var(--tk-touch-min);
padding: 0 var(--tk-gap-md);
height: var(--tk-input-height);
box-shadow: $shadow-sm;
}
&__icon {
font-size: 16px;
font-size: var(--tk-font-body-sm);
flex-shrink: 0;
}

View File

@@ -0,0 +1,52 @@
@import '../../../styles/variables.scss';
.alert-card {
border-radius: $r;
padding: var(--tk-gap-lg);
margin-bottom: var(--tk-gap-md);
// 渐变型 — 智能提醒
&--gradient {
background: linear-gradient(135deg, var(--tk-pri) 0%, var(--tk-pri-d) 100%);
color: $white;
}
// 左边框型 — AI 建议
&--left-border {
background: $acc-l;
border-left: 4px solid $acc;
}
// 全边框型 — 温馨提示
&--bordered {
background: $wrn-l;
border-radius: $r-sm;
}
&__header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: var(--tk-gap-xs);
}
&__title {
font-size: var(--tk-font-body);
font-weight: 600;
}
&--left-border &__title {
color: $acc;
}
&__subtitle {
font-size: var(--tk-font-micro);
opacity: 0.7;
}
&__body {
font-size: var(--tk-font-cap);
color: $tx2;
line-height: 1.6;
}
}

View File

@@ -0,0 +1,41 @@
import React, { ReactNode } from 'react';
import { View, Text } from '@tarojs/components';
import './index.scss';
type AlertVariant = 'gradient' | 'left-border' | 'bordered';
interface AlertCardProps {
variant?: AlertVariant;
title?: string;
subtitle?: string;
children?: ReactNode;
className?: string;
}
const AlertCard: React.FC<AlertCardProps> = ({
variant = 'left-border',
title,
subtitle,
children,
className = '',
}) => {
const cls = [
'alert-card',
`alert-card--${variant}`,
className,
].filter(Boolean).join(' ');
return (
<View className={cls}>
{title && (
<View className='alert-card__header'>
<Text className='alert-card__title'>{title}</Text>
{subtitle && <Text className='alert-card__subtitle'>{subtitle}</Text>}
</View>
)}
{children ?? <Text className='alert-card__body'>{subtitle}</Text>}
</View>
);
};
export default React.memo(AlertCard);

View File

@@ -0,0 +1,37 @@
@import '../../../styles/variables.scss';
.chat-bubble-wrap {
display: flex;
flex-direction: column;
margin-bottom: var(--tk-gap-xs);
}
.chat-bubble {
max-width: 75%;
padding: var(--tk-gap-md) var(--tk-gap-lg);
font-size: var(--tk-font-body);
line-height: 1.5;
&--other {
align-self: flex-start;
background: $card;
border-radius: $r $r $r $r-xs;
}
&--mine {
align-self: flex-end;
background: var(--tk-pri-l);
border-radius: $r $r $r-xs $r;
}
&__text {
color: $tx;
}
&__time {
font-size: var(--tk-font-micro);
color: $tx3;
margin-top: 4px;
text-align: center;
}
}

View File

@@ -0,0 +1,34 @@
import React, { ReactNode } from 'react';
import { View, Text } from '@tarojs/components';
import './index.scss';
interface ChatBubbleProps {
content: string;
isMine?: boolean;
time?: string;
className?: string;
}
const ChatBubble: React.FC<ChatBubbleProps> = ({
content,
isMine = false,
time,
className = '',
}) => {
const cls = [
'chat-bubble',
isMine ? 'chat-bubble--mine' : 'chat-bubble--other',
className,
].filter(Boolean).join(' ');
return (
<View className='chat-bubble-wrap'>
<View className={cls}>
<Text className='chat-bubble__text'>{content}</Text>
</View>
{time && <Text className='chat-bubble__time'>{time}</Text>}
</View>
);
};
export default React.memo(ChatBubble);

View File

@@ -15,9 +15,9 @@ interface ContentCardProps {
const PADDING_MAP = {
none: '0',
sm: '12px',
sm: 'var(--tk-card-padding-sm)',
md: 'var(--tk-card-padding)',
lg: '32px',
lg: 'var(--tk-card-padding-lg)',
} as const;
const ContentCard: React.FC<ContentCardProps> = ({

View File

@@ -0,0 +1,51 @@
@import '../../../styles/variables.scss';
.form-input {
&__label {
display: block;
font-size: var(--tk-font-cap);
color: $tx3;
margin-bottom: 6px;
}
&__field {
height: var(--tk-input-height);
background: $card;
border: 1.5px solid $bd;
border-radius: $r;
padding: 0 var(--tk-gap-lg);
display: flex;
align-items: center;
transition: border-color 0.2s;
}
&__control {
width: 100%;
height: 100%;
font-size: var(--tk-font-body);
color: $tx;
}
&__placeholder {
color: $tx3;
}
&--error &__field {
border-color: $dan;
}
&--disabled &__field {
opacity: 0.5;
}
&--focus &__field {
border-color: var(--tk-pri);
}
&__error {
display: block;
font-size: var(--tk-font-cap);
color: $dan;
margin-top: 4px;
}
}

View File

@@ -0,0 +1,55 @@
import React from 'react';
import { View, Text, Input } from '@tarojs/components';
import './index.scss';
interface FormInputProps {
label?: string;
placeholder?: string;
value?: string;
onInput?: (value: string) => void;
type?: 'text' | 'number' | 'idcard' | 'digit';
maxLength?: number;
disabled?: boolean;
error?: string;
className?: string;
}
const FormInput: React.FC<FormInputProps> = ({
label,
placeholder,
value,
onInput,
type = 'text',
maxLength,
disabled = false,
error,
className = '',
}) => {
const cls = [
'form-input',
error && 'form-input--error',
disabled && 'form-input--disabled',
className,
].filter(Boolean).join(' ');
return (
<View className={cls}>
{label && <Text className='form-input__label'>{label}</Text>}
<View className='form-input__field'>
<Input
className='form-input__control'
placeholder={placeholder}
placeholderClass='form-input__placeholder'
value={value}
onInput={e => onInput?.(e.detail.value)}
type={type}
maxlength={maxLength}
disabled={disabled}
/>
</View>
{error && <Text className='form-input__error'>{error}</Text>}
</View>
);
};
export default React.memo(FormInput);

View File

@@ -0,0 +1,8 @@
@import '../../../styles/variables.scss';
.gradient-header {
background: linear-gradient(135deg, var(--tk-pri) 0%, var(--tk-pri-d) 100%);
border-radius: $r;
padding: 18px;
color: $white;
}

View File

@@ -0,0 +1,21 @@
import React, { ReactNode } from 'react';
import { View } from '@tarojs/components';
import './index.scss';
interface GradientHeaderProps {
children: ReactNode;
className?: string;
}
const GradientHeader: React.FC<GradientHeaderProps> = ({
children,
className = '',
}) => {
return (
<View className={`gradient-header ${className}`}>
{children}
</View>
);
};
export default React.memo(GradientHeader);

View File

@@ -0,0 +1,27 @@
@import '../../../styles/variables.scss';
.info-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: var(--tk-gap-md) 0;
border-bottom: 1px solid $bd-l;
&--last {
border-bottom: none;
}
&__label {
font-size: var(--tk-font-body);
color: $tx2;
flex-shrink: 0;
}
&__value {
font-size: var(--tk-font-body-lg);
color: $tx;
text-align: right;
flex: 1;
margin-left: var(--tk-gap-md);
}
}

View File

@@ -0,0 +1,34 @@
import React, { ReactNode } from 'react';
import { View, Text } from '@tarojs/components';
import './index.scss';
interface InfoRowProps {
label: string;
value?: string;
valueNode?: ReactNode;
last?: boolean;
className?: string;
}
const InfoRow: React.FC<InfoRowProps> = ({
label,
value,
valueNode,
last = false,
className = '',
}) => {
const cls = [
'info-row',
last && 'info-row--last',
className,
].filter(Boolean).join(' ');
return (
<View className={cls}>
<Text className='info-row__label'>{label}</Text>
{valueNode ?? <Text className='info-row__value'>{value}</Text>}
</View>
);
};
export default React.memo(InfoRow);

View File

@@ -0,0 +1,67 @@
@import '../../../styles/variables.scss';
.list-item {
display: flex;
align-items: center;
background: $card;
border-radius: $r;
padding: var(--tk-gap-lg);
box-shadow: $shadow-sm;
gap: var(--tk-gap-md);
&--pressable {
&:active {
opacity: var(--tk-touch-feedback-opacity);
}
}
&--read {
opacity: 0.7;
}
&__icon {
flex-shrink: 0;
width: 44px;
height: 44px;
border-radius: 22px;
display: flex;
align-items: center;
justify-content: center;
}
&__body {
flex: 1;
min-width: 0;
}
&__title {
display: block;
font-size: var(--tk-font-body);
font-weight: 500;
color: $tx;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
&__subtitle {
display: block;
font-size: var(--tk-font-cap);
color: $tx3;
margin-top: 4px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
&__extra {
flex-shrink: 0;
}
&__arrow {
flex-shrink: 0;
font-size: var(--tk-font-body-lg);
color: $tx3;
margin-left: var(--tk-gap-2xs);
}
}

View File

@@ -0,0 +1,46 @@
import React, { ReactNode } from 'react';
import { View, Text } from '@tarojs/components';
import './index.scss';
interface ListItemProps {
title: string;
subtitle?: string;
extra?: ReactNode;
leftIcon?: ReactNode;
onPress?: () => void;
showArrow?: boolean;
unread?: boolean;
className?: string;
}
const ListItem: React.FC<ListItemProps> = ({
title,
subtitle,
extra,
leftIcon,
onPress,
showArrow = false,
unread = false,
className = '',
}) => {
const cls = [
'list-item',
onPress && 'list-item--pressable',
!unread && 'list-item--read',
className,
].filter(Boolean).join(' ');
return (
<View className={cls} onClick={onPress}>
{leftIcon && <View className='list-item__icon'>{leftIcon}</View>}
<View className='list-item__body'>
<Text className='list-item__title'>{title}</Text>
{subtitle && <Text className='list-item__subtitle'>{subtitle}</Text>}
</View>
{extra && <View className='list-item__extra'>{extra}</View>}
{showArrow && <Text className='list-item__arrow'></Text>}
</View>
);
};
export default React.memo(ListItem);

View File

@@ -35,7 +35,7 @@
&__row {
display: flex;
align-items: center;
gap: 12px;
gap: var(--tk-gap-sm);
}
&__circle {
@@ -50,7 +50,7 @@
flex: 1;
display: flex;
flex-direction: column;
gap: 8px;
gap: var(--tk-gap-xs);
}
&__line {

View File

@@ -13,9 +13,9 @@ interface PageShellProps {
const PADDING_MAP = {
none: '0',
sm: '16px',
sm: 'var(--tk-gap-md)',
md: 'var(--tk-page-padding)',
lg: '32px',
lg: 'var(--tk-gap-xl)',
} as const;
const PageShell: React.FC<PageShellProps> = ({

View File

@@ -0,0 +1,54 @@
@import '../../../styles/variables.scss';
.primary-btn {
display: flex;
align-items: center;
justify-content: center;
gap: var(--tk-gap-xs);
width: 100%;
background: var(--tk-pri);
color: $white;
font-weight: 600;
border: none;
border-radius: 14px;
box-shadow: var(--tk-shadow-btn);
transition: opacity 0.15s, transform 0.15s;
&--default {
height: var(--tk-btn-primary-h);
font-size: var(--tk-font-body-lg);
}
&--large {
height: 54px;
font-size: var(--tk-font-h2);
}
&:active:not(&--disabled):not(&--loading) {
opacity: var(--tk-touch-feedback-opacity);
transform: scale(0.98);
}
&--disabled {
opacity: 0.5;
box-shadow: none;
}
&__spinner {
width: 18px;
height: 18px;
border: 2px solid rgba(255, 255, 255, 0.3);
border-top-color: $white;
border-radius: 50%;
animation: primary-btn-spin 0.6s linear infinite;
}
&__text {
color: $white;
font-weight: 600;
}
}
@keyframes primary-btn-spin {
to { transform: rotate(360deg); }
}

View File

@@ -0,0 +1,38 @@
import React from 'react';
import { View, Text } from '@tarojs/components';
import './index.scss';
interface PrimaryButtonProps {
children: React.ReactNode;
onClick?: () => void;
disabled?: boolean;
loading?: boolean;
size?: 'default' | 'large';
className?: string;
}
const PrimaryButton: React.FC<PrimaryButtonProps> = ({
children,
onClick,
disabled = false,
loading = false,
size = 'default',
className = '',
}) => {
const cls = [
'primary-btn',
`primary-btn--${size}`,
disabled && 'primary-btn--disabled',
loading && 'primary-btn--loading',
className,
].filter(Boolean).join(' ');
return (
<View className={cls} onClick={!disabled && !loading ? onClick : undefined}>
{loading && <View className='primary-btn__spinner' />}
<Text className='primary-btn__text'>{children}</Text>
</View>
);
};
export default React.memo(PrimaryButton);

View File

@@ -0,0 +1,28 @@
@import '../../../styles/variables.scss';
.progress-ring {
position: relative;
flex-shrink: 0;
&__center {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
}
&__pct {
font-family: Georgia, 'Times New Roman', serif;
font-size: var(--tk-font-cap);
font-weight: 700;
color: var(--tk-pri);
}
&__label {
font-family: Georgia, 'Times New Roman', serif;
font-size: var(--tk-font-micro);
font-weight: 700;
color: var(--tk-pri);
}
}

View File

@@ -0,0 +1,56 @@
import React from 'react';
import { View, Text } from '@tarojs/components';
import './index.scss';
interface ProgressRingProps {
progress: number;
size?: 'sm' | 'lg';
label?: string;
className?: string;
}
const ProgressRing: React.FC<ProgressRingProps> = ({
progress,
size = 'sm',
label,
className = '',
}) => {
const px = size === 'sm' ? 64 : 80;
const r = (px / 2) - 4;
const circumference = 2 * Math.PI * r;
const offset = circumference * (1 - Math.min(progress, 1));
const cls = ['progress-ring', `progress-ring--${size}`, className].filter(Boolean).join(' ');
return (
<View className={cls} style={{ width: px, height: px }}>
<svg width={px} height={px} viewBox={`0 0 ${px} ${px}`}>
<circle
cx={px / 2} cy={px / 2} r={r}
fill='none'
stroke='var(--tk-pri-l, #E8E2DC)'
strokeWidth={4}
/>
<circle
cx={px / 2} cy={px / 2} r={r}
fill='none'
stroke='var(--tk-pri)'
strokeWidth={4}
strokeDasharray={circumference}
strokeDashoffset={offset}
strokeLinecap='round'
transform={`rotate(-90 ${px / 2} ${px / 2})`}
/>
</svg>
<View className='progress-ring__center'>
{label ? (
<Text className='progress-ring__label'>{label}</Text>
) : (
<Text className='progress-ring__pct'>{Math.round(progress * 100)}%</Text>
)}
</View>
</View>
);
};
export default React.memo(ProgressRing);

View File

@@ -0,0 +1,30 @@
@import '../../../styles/variables.scss';
.secondary-btn {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: var(--tk-btn-primary-h);
background: transparent;
color: var(--tk-pri);
font-weight: 600;
border: 2px solid var(--tk-pri);
border-radius: 14px;
transition: opacity 0.15s, transform 0.15s;
&:active:not(&--disabled) {
opacity: var(--tk-touch-feedback-opacity);
transform: scale(0.98);
}
&--disabled {
opacity: 0.5;
}
&__text {
color: var(--tk-pri);
font-size: var(--tk-font-body-lg);
font-weight: 600;
}
}

View File

@@ -0,0 +1,31 @@
import React from 'react';
import { View, Text } from '@tarojs/components';
import './index.scss';
interface SecondaryButtonProps {
children: React.ReactNode;
onClick?: () => void;
disabled?: boolean;
className?: string;
}
const SecondaryButton: React.FC<SecondaryButtonProps> = ({
children,
onClick,
disabled = false,
className = '',
}) => {
const cls = [
'secondary-btn',
disabled && 'secondary-btn--disabled',
className,
].filter(Boolean).join(' ');
return (
<View className={cls} onClick={!disabled ? onClick : undefined}>
<Text className='secondary-btn__text'>{children}</Text>
</View>
);
};
export default React.memo(SecondaryButton);

View File

@@ -9,13 +9,13 @@
&__left {
display: flex;
align-items: center;
gap: 10px;
gap: var(--tk-gap-sm);
}
&__bar {
width: 3px;
height: 20px;
background: $pri;
background: var(--tk-pri);
border-radius: 2px;
flex-shrink: 0;
}
@@ -43,7 +43,7 @@
&__action {
font-size: var(--tk-font-body-sm);
color: $pri;
color: var(--tk-pri);
min-height: var(--tk-touch-min);
display: flex;
align-items: center;

View File

@@ -11,7 +11,7 @@
white-space: nowrap;
&--sm {
padding: 2px 8px;
font-size: 11px;
padding: 2px var(--tk-gap-xs);
font-size: var(--tk-font-micro);
}
}

View File

@@ -0,0 +1,87 @@
@import '../../../styles/variables.scss';
.tab-filter {
display: flex;
gap: var(--tk-gap-xs);
// 填充型 — 体征类型切换
&--fill {
.tab-filter__item {
flex: 1;
height: 44px;
border-radius: $r-sm;
background: $surface-alt;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s;
&--active {
background: var(--tk-pri);
box-shadow: var(--tk-shadow-tab);
.tab-filter__text {
color: $white;
font-weight: 600;
}
}
}
}
// Pill型 — 文章分类
&--pill {
flex-wrap: wrap;
gap: var(--tk-gap-xs);
.tab-filter__item {
height: 32px;
padding: 0 var(--tk-gap-lg);
border-radius: $r-pill;
background: $surface-alt;
display: flex;
align-items: center;
justify-content: center;
&--active {
background: var(--tk-pri);
.tab-filter__text {
color: $white;
font-weight: 600;
}
}
}
}
// 段控型 — 消息页咨询/通知
&--segment {
background: $surface-alt;
border-radius: $r-xs;
padding: 3px;
.tab-filter__item {
flex: 1;
height: 40px;
border-radius: 6px;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s;
&--active {
background: $card;
box-shadow: $shadow-sm;
.tab-filter__text {
color: $tx;
font-weight: 600;
}
}
}
}
&__text {
font-size: var(--tk-font-cap);
color: $tx2;
}
}

View File

@@ -0,0 +1,43 @@
import React from 'react';
import { View, Text } from '@tarojs/components';
import './index.scss';
type TabVariant = 'fill' | 'pill' | 'segment';
interface TabFilterProps {
tabs: string[];
activeIndex: number;
onChange: (index: number) => void;
variant?: TabVariant;
className?: string;
}
const TabFilter: React.FC<TabFilterProps> = ({
tabs,
activeIndex,
onChange,
variant = 'fill',
className = '',
}) => {
const cls = [
'tab-filter',
`tab-filter--${variant}`,
className,
].filter(Boolean).join(' ');
return (
<View className={cls}>
{tabs.map((tab, i) => (
<View
key={i}
className={`tab-filter__item ${i === activeIndex ? 'tab-filter__item--active' : ''}`}
onClick={() => onChange(i)}
>
<Text className='tab-filter__text'>{tab}</Text>
</View>
))}
</View>
);
};
export default React.memo(TabFilter);

View File

@@ -0,0 +1,41 @@
@import '../../../styles/variables.scss';
.vital-card {
background: $card;
border-radius: $r;
padding: 14px var(--tk-gap-lg);
box-shadow: $shadow-sm;
&--pressable {
&:active {
opacity: var(--tk-touch-feedback-opacity);
}
}
&__label {
display: block;
font-size: var(--tk-font-cap);
color: $tx2;
margin-bottom: 6px;
}
&__row {
display: flex;
align-items: baseline;
margin-bottom: 6px;
}
&__value {
font-family: Georgia, 'Times New Roman', serif;
font-size: var(--tk-font-num);
font-weight: 700;
color: $tx;
line-height: 1;
}
&__unit {
font-size: var(--tk-font-micro);
color: $tx3;
margin-left: 3px;
}
}

View File

@@ -0,0 +1,41 @@
import React from 'react';
import { View, Text } from '@tarojs/components';
import StatusTag from '../StatusTag';
import './index.scss';
interface VitalCardProps {
label: string;
value: string;
unit?: string;
status?: string;
onPress?: () => void;
className?: string;
}
const VitalCard: React.FC<VitalCardProps> = ({
label,
value,
unit,
status,
onPress,
className = '',
}) => {
const cls = [
'vital-card',
onPress && 'vital-card--pressable',
className,
].filter(Boolean).join(' ');
return (
<View className={cls} onClick={onPress}>
<Text className='vital-card__label'>{label}</Text>
<View className='vital-card__row'>
<Text className='vital-card__value'>{value}</Text>
{unit && <Text className='vital-card__unit'>{unit}</Text>}
</View>
{status && <StatusTag status={status} size='sm' />}
</View>
);
};
export default React.memo(VitalCard);