通用组件开发

This commit is contained in:
juguohong
2025-08-17 00:00:56 +08:00
parent 86e14cb445
commit 4f6ca73148
25 changed files with 2554 additions and 24 deletions

View File

@@ -0,0 +1,93 @@
# NtrpRange 范围选择器组件
基于NutUI Range组件的双滑块范围选择器通过CSS样式覆盖完全匹配设计稿支持自定义范围、步长和回调函数。
## 功能特性
- 🎯 双滑块设计,支持选择范围区间
- 🎨 精准还原设计稿的视觉效果
- 📱 响应式设计,支持移动端
- 🎮 流畅的拖拽交互体验
- ⚙️ 可配置的最小值、最大值和步长
- 🔒 支持禁用状态
- 📊 实时值变化回调
## 基本用法
```tsx
import NtrpRange from '@/components/Range';
const MyComponent = () => {
const [value, setValue] = useState<[number, number]>([2.0, 4.0]);
return (
<NtrpRange
min={2.0}
max={4.0}
step={0.5}
value={value}
onChange={setValue}
/>
);
};
```
## 属性说明
| 属性 | 类型 | 默认值 | 说明 |
|------|------|--------|------|
| `min` | `number` | `2.0` | 最小值 |
| `max` | `number` | `4.0` | 最大值 |
| `step` | `number` | `0.5` | 步长 |
| `value` | `[number, number]` | `[min, max]` | 当前选择的范围值 |
| `onChange` | `(value: [number, number]) => void` | - | 值变化时的回调函数 |
| `disabled` | `boolean` | `false` | 是否禁用 |
## 技术实现
- 基于NutUI Range组件确保拖拽功能的可靠性
- 通过CSS样式覆盖完全匹配设计稿视觉效果
- TypeScript + React Hooks
- 响应式设计,支持移动端
## 设计规范
组件严格按照设计稿实现,包含以下视觉元素:
- **网球图标**: 黑色轮廓的网球图标
- **标题**: "NTRP水平区间" 文字
- **标签**: 左右两端的范围标签(如"2.0及以下"、"4.0及以上"
- **滑块轨道**: 圆角矩形容器,带有浅灰色边框和阴影
- **滑块手柄**: 两个白色圆形手柄,带有黑色边框和阴影
- **轨道填充**: 黑色填充条,显示当前选择的范围
- **标记点**: 四个浅灰色圆点,均匀分布在轨道上
## 样式定制
组件使用 BEM 命名规范,可以通过 CSS 变量或覆盖样式来自定义外观:
```scss
.ntrp-range {
// 自定义样式
&__track {
background: #f5f5f5;
border-color: #d0d0d0;
}
&__handle {
background: #007bff;
border-color: #0056b3;
}
}
```
## 注意事项
1. 确保 `min < max`,否则组件可能无法正常工作
2. `step` 值应该能够整除 `max - min` 的差值
3. 组件内部会确保左右滑块不会重叠,最小间距为 `step`
4. 拖拽时会自动吸附到最近的步长值
## 示例
查看 `example.tsx` 文件获取更多使用示例。

View File

@@ -0,0 +1,85 @@
/*
* @Author: juguohong juguohong@flashhold.com
* @Date: 2025-08-16 17:59:28
* @LastEditors: juguohong juguohong@flashhold.com
* @LastEditTime: 2025-08-16 23:48:25
* @FilePath: /mini-programs/src/components/Range/example.tsx
* @Description: 这是默认设置,请设置`customMade`, 打开koroFileHeader查看配置 进行设置: https://github.com/OBKoro1/koro1FileHeader/wiki/%E9%85%8D%E7%BD%AE
*/
import React, { useState } from 'react';
import NtrpRange from './index';
const RangeExample: React.FC = () => {
const [ntrpRange, setNtrpRange] = useState<[number, number]>([2.0, 4.0]);
const [customRange, setCustomRange] = useState<[number, number]>([0, 100]);
const handleNtrpChange = (value: [number, number]) => {
console.log('NTRP range changed:', value);
setNtrpRange(value);
};
const handleCustomChange = (value: [number, number]) => {
console.log('Custom range changed:', value);
setCustomRange(value);
};
return (
<div >
<h1>Range </h1>
<div style={{ marginBottom: '40px' }}>
<h2>NTRP </h2>
<NtrpRange
min={1.0}
max={5.0}
step={0.5}
value={ntrpRange}
onChange={handleNtrpChange}
/>
<div >
: {ntrpRange[0]} - {ntrpRange[1]}
</div>
</div>
<div style={{ marginBottom: '40px' }}>
<h2></h2>
<NtrpRange
min={0}
max={100}
step={10}
value={customRange}
onChange={handleCustomChange}
/>
<div style={{ marginTop: '16px', fontSize: '14px', color: '#666' }}>
: {customRange[0]} - {customRange[1]}
</div>
</div>
<div style={{ marginBottom: '40px' }}>
<h2></h2>
<NtrpRange
min={1}
max={10}
step={1}
value={[3, 7]}
disabled={true}
/>
<div style={{ marginTop: '16px', fontSize: '14px', color: '#999' }}>
</div>
</div>
<div style={{ marginBottom: '40px' }}>
<h2></h2>
<div style={{ fontSize: '14px', color: '#666', lineHeight: '1.6' }}>
<p>1. </p>
<p>2. </p>
<p>3. </p>
<p>4. </p>
</div>
</div>
</div>
);
};
export default RangeExample;

View File

@@ -0,0 +1,246 @@
.ntrp-range {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
user-select: none;
&__header {
display: flex;
align-items: center;
margin-bottom: 16px;
}
&__icon {
margin-right: 8px;
display: flex;
align-items: center;
svg {
width: 20px;
height: 20px;
}
}
&__title {
margin: 0;
font-size: 16px;
font-weight: 500;
color: #000;
line-height: 1.2;
}
&__slider-container {
position: relative;
}
&__labels {
display: flex;
justify-content: space-between;
margin-bottom: 12px;
}
&__label {
font-size: 14px;
color: #000;
font-weight: 400;
}
&__track-container {
position: relative;
padding: 0 10px;
}
&__track {
position: relative;
height: 40px;
background: #fff;
border: 1px solid #e0e0e0;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
display: flex;
align-items: center;
padding: 0 20px;
}
&__markers {
position: absolute;
width: 100%;
height: 100%;
pointer-events: none;
z-index: 1;
}
&__marker {
position: absolute;
width: 6px;
height: 6px;
background: #e0e0e0;
border-radius: 50%;
top: 50%;
transform: translate(-50%, -50%);
}
&__nutui-wrapper {
position: relative;
width: 100%;
height: 100%;
z-index: 2;
// 隐藏NutUI的默认标签
.nut-range__label {
display: none !important;
}
// 隐藏默认的轨道背景
.nut-range__bar-box {
background: transparent !important;
height: 6px !important;
border-radius: 3px !important;
margin: 0 !important;
padding: 0 !important;
}
// 隐藏默认的轨道
.nut-range__bar {
background: transparent !important;
height: 0 !important;
margin: 0 !important;
}
// 自定义活动轨道(黑色填充条)
.nut-range__bar--active {
background: #000 !important;
height: 6px !important;
border-radius: 3px !important;
margin: 0 !important;
}
// 自定义滑块手柄样式
.nut-range__button {
width: 24px !important;
height: 24px !important;
background: #fff !important;
border: 2px solid #000 !important;
border-radius: 50% !important;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.15) !important;
top: 50% !important;
transform: translate(-50%, -50%) !important;
transition: all 0.1s ease !important;
&:hover {
transform: translate(-50%, -50%) scale(1.1) !important;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2) !important;
}
&:active {
transform: translate(-50%, -50%) scale(1.15) !important;
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.25) !important;
}
}
// 确保滑块在正确位置
.nut-range__button-wrapper {
top: 50% !important;
transform: translateY(-50%) !important;
}
}
// 响应式设计
@media (max-width: 480px) {
&__track {
padding: 0 15px;
}
&__nutui-wrapper {
.nut-range__button {
width: 20px !important;
height: 20px !important;
}
}
&__title {
font-size: 14px;
}
&__label {
font-size: 12px;
}
}
}
// 全局NutUI样式覆盖
.nut-range {
width: 100% !important;
height: 100% !important;
.nut-range__bar-box {
margin: 0 !important;
padding: 0 !important;
}
.nut-range__bar {
margin: 0 !important;
}
}
.ntrp-range__header {
line-height: 20px;
display: flex;
align-items: center;
justify-content: space-between;
.ntrp-range__header-left {
display: flex;
align-items: center;
gap: 8px;
}
.ntrp-range__title {
font-weight: 600;
font-size: 14px;
color: #000000;
}
.ntrp-range__content {
font-weight: 400;
font-size: 14px;
color: #3c3c34;
}
}
.rangeWrapper {
display: flex;
align-items: center;
justify-content: space-between;
gap: 5px;
height: 44px;
border-radius: 12px;
border: 1px solid #e0e0e0;
padding: 0 10px;
.rangeHandle {
.nut-range-mark {
padding-top: 28px;
}
.nut-range-bar {
background: #000000;
height: 6px;
}
.nut-range-button {
border: none;
box-shadow: 0 6px 13px 0 rgba(0, 0, 0, 0.12);
}
.nut-range-tick {
background: #3c3c3c;
height: 4px !important;
width: 4px !important;
}
}
span {
font-size: 12px;
color: #999;
}
.rangeWrapper__min,
.rangeWrapper__max {
flex-shrink: 0;
font-size: 12px;
color: #000000;
}
}

View File

@@ -0,0 +1,91 @@
import React, { useState, useEffect, useMemo } from "react";
import { Range } from "@nutui/nutui-react-taro";
import "./index.scss";
interface RangeProps {
min?: number;
max?: number;
step?: number;
value?: [number, number];
onChange?: (value: [number, number]) => void;
disabled?: boolean;
}
const NtrpRange: React.FC<RangeProps> = ({
min = 1.0,
max = 5.0,
step = 0.5,
value = [min, max],
onChange,
disabled = false,
}) => {
const [currentValue, setCurrentValue] = useState<[number, number]>(value);
useEffect(() => {
setCurrentValue(value);
}, [value]);
const handleChange = (val: [number, number]) => {
console.log("Range value changed:", val);
setCurrentValue(val);
onChange?.(val);
};
const marks = useMemo(() => {
let marksMap = {};
for (let i = min + step; i < max; i += step) {
marksMap[i] = "";
}
return marksMap;
}, [min, max, step]);
const rangContent = useMemo(() => {
const [start, end] = currentValue || [];
if (start === min && end === max) {
return "不限";
}
return `${start.toFixed(1)} - ${end.toFixed(1)}之间`;
}, [currentValue, min, max]);
return (
<div className="ntrp-range">
<div className="ntrp-range__header">
<div className="ntrp-range__header-left">
<div className="ntrp-range__icon">icon</div>
<h3 className="ntrp-range__title">NTRP水平区间</h3>
</div>
<p className="ntrp-range__content">{rangContent}</p>
</div>
<div>
<div className="rangeWrapper">
<span className="rangeWrapper__min">{min}</span>
<Range
range
min={min}
max={max}
step={step}
value={currentValue}
onChange={handleChange}
disabled={disabled}
defaultValue={[min, max]}
className="rangeHandle"
maxDescription={null}
minDescription={null}
currentDescription={null}
marks={marks}
style={{ color: "gold" }}
/>
<span className="rangeWrapper__max">{max}</span>
</div>
</div>
{/* 调试信息 */}
<div style={{ marginTop: "10px", fontSize: "12px", color: "#666" }}>
: {currentValue[0].toFixed(1)} - {currentValue[1].toFixed(1)}
</div>
</div>
);
};
export default NtrpRange;

View File

@@ -0,0 +1,22 @@
import React, { useState } from 'react';
import NtrpRange from './index';
const SimpleTest: React.FC = () => {
const [value, setValue] = useState<[number, number]>([2.0, 4.0]);
return (
<div style={{ padding: '20px' }}>
<h2></h2>
<NtrpRange
min={2.0}
max={4.0}
step={0.5}
value={value}
onChange={setValue}
/>
<p>: {value[0]} - {value[1]}</p>
</div>
);
};
export default SimpleTest;

View File

@@ -0,0 +1,68 @@
import React, { useState } from 'react';
import NtrpRange from './index';
const StyleTest: React.FC = () => {
const [value, setValue] = useState<[number, number]>([2.0, 4.0]);
return (
<div style={{ padding: '20px', maxWidth: '600px', margin: '0 auto' }}>
<h1>NtrpRange </h1>
<div style={{ marginBottom: '30px' }}>
<h2>NTRP </h2>
<NtrpRange
min={2.0}
max={4.0}
step={0.5}
value={value}
onChange={setValue}
/>
<div style={{ marginTop: '16px', fontSize: '14px', color: '#666' }}>
: {value[0]} - {value[1]}
</div>
</div>
<div style={{ marginBottom: '30px' }}>
<h2></h2>
<NtrpRange
min={0}
max={100}
step={1}
value={[20, 80]}
onChange={(val) => console.log('Custom range:', val)}
/>
<div style={{ marginTop: '16px', fontSize: '14px', color: '#666' }}>
固定范围: 20 - 80
</div>
</div>
<div style={{ marginBottom: '30px' }}>
<h2></h2>
<NtrpRange
min={1}
max={10}
step={1}
value={[3, 7]}
disabled={true}
/>
<div style={{ marginTop: '16px', fontSize: '14px', color: '#999' }}>
</div>
</div>
<div style={{ marginBottom: '30px' }}>
<h2></h2>
<div style={{ fontSize: '14px', color: '#666', lineHeight: '1.6' }}>
<p> + "NTRP水平区间"</p>
<p> "2.0及以下""4.0及以上"</p>
<p> </p>
<p> </p>
<p> </p>
<p> </p>
</div>
</div>
</div>
);
};
export default StyleTest;

View File

@@ -0,0 +1,35 @@
import React, { useState } from 'react';
import NtrpRange from './index';
const TestPage: React.FC = () => {
const [value, setValue] = useState<[number, number]>([2.0, 4.0]);
return (
<div style={{ padding: '20px' }}>
<h1>NtrpRange </h1>
<div style={{ marginBottom: '20px' }}>
<NtrpRange
min={2.0}
max={4.0}
step={0.5}
value={value}
onChange={setValue}
/>
</div>
<div style={{ fontSize: '14px', color: '#666' }}>
: {value[0].toFixed(1)} - {value[1].toFixed(1)}
</div>
<div style={{ marginTop: '20px', fontSize: '12px', color: '#999' }}>
<p>:</p>
<p>1. </p>
<p>2. </p>
<p>3. </p>
</div>
</div>
);
};
export default TestPage;