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:
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
52
apps/miniprogram/src/components/ui/AlertCard/index.scss
Normal file
52
apps/miniprogram/src/components/ui/AlertCard/index.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
41
apps/miniprogram/src/components/ui/AlertCard/index.tsx
Normal file
41
apps/miniprogram/src/components/ui/AlertCard/index.tsx
Normal 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);
|
||||
37
apps/miniprogram/src/components/ui/ChatBubble/index.scss
Normal file
37
apps/miniprogram/src/components/ui/ChatBubble/index.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
34
apps/miniprogram/src/components/ui/ChatBubble/index.tsx
Normal file
34
apps/miniprogram/src/components/ui/ChatBubble/index.tsx
Normal 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);
|
||||
@@ -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> = ({
|
||||
|
||||
51
apps/miniprogram/src/components/ui/FormInput/index.scss
Normal file
51
apps/miniprogram/src/components/ui/FormInput/index.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
55
apps/miniprogram/src/components/ui/FormInput/index.tsx
Normal file
55
apps/miniprogram/src/components/ui/FormInput/index.tsx
Normal 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);
|
||||
@@ -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;
|
||||
}
|
||||
21
apps/miniprogram/src/components/ui/GradientHeader/index.tsx
Normal file
21
apps/miniprogram/src/components/ui/GradientHeader/index.tsx
Normal 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);
|
||||
27
apps/miniprogram/src/components/ui/InfoRow/index.scss
Normal file
27
apps/miniprogram/src/components/ui/InfoRow/index.scss
Normal 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);
|
||||
}
|
||||
}
|
||||
34
apps/miniprogram/src/components/ui/InfoRow/index.tsx
Normal file
34
apps/miniprogram/src/components/ui/InfoRow/index.tsx
Normal 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);
|
||||
67
apps/miniprogram/src/components/ui/ListItem/index.scss
Normal file
67
apps/miniprogram/src/components/ui/ListItem/index.scss
Normal 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);
|
||||
}
|
||||
}
|
||||
46
apps/miniprogram/src/components/ui/ListItem/index.tsx
Normal file
46
apps/miniprogram/src/components/ui/ListItem/index.tsx
Normal 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);
|
||||
@@ -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 {
|
||||
|
||||
@@ -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> = ({
|
||||
|
||||
54
apps/miniprogram/src/components/ui/PrimaryButton/index.scss
Normal file
54
apps/miniprogram/src/components/ui/PrimaryButton/index.scss
Normal 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); }
|
||||
}
|
||||
38
apps/miniprogram/src/components/ui/PrimaryButton/index.tsx
Normal file
38
apps/miniprogram/src/components/ui/PrimaryButton/index.tsx
Normal 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);
|
||||
28
apps/miniprogram/src/components/ui/ProgressRing/index.scss
Normal file
28
apps/miniprogram/src/components/ui/ProgressRing/index.scss
Normal 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);
|
||||
}
|
||||
}
|
||||
56
apps/miniprogram/src/components/ui/ProgressRing/index.tsx
Normal file
56
apps/miniprogram/src/components/ui/ProgressRing/index.tsx
Normal 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);
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
31
apps/miniprogram/src/components/ui/SecondaryButton/index.tsx
Normal file
31
apps/miniprogram/src/components/ui/SecondaryButton/index.tsx
Normal 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);
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
87
apps/miniprogram/src/components/ui/TabFilter/index.scss
Normal file
87
apps/miniprogram/src/components/ui/TabFilter/index.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
43
apps/miniprogram/src/components/ui/TabFilter/index.tsx
Normal file
43
apps/miniprogram/src/components/ui/TabFilter/index.tsx
Normal 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);
|
||||
41
apps/miniprogram/src/components/ui/VitalCard/index.scss
Normal file
41
apps/miniprogram/src/components/ui/VitalCard/index.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
41
apps/miniprogram/src/components/ui/VitalCard/index.tsx
Normal file
41
apps/miniprogram/src/components/ui/VitalCard/index.tsx
Normal 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);
|
||||
Reference in New Issue
Block a user