feat:添加input组件

This commit is contained in:
zyronon
2025-08-13 02:44:53 +08:00
parent 47bc29adb7
commit 47890266bf
14 changed files with 550 additions and 22 deletions

View File

@@ -27,6 +27,7 @@
--article-panel-margin-left: calc(50% + var(--article-width) / 2 + 1rem);
--anim-time: 0.3s;
--color-input-color: black;
--color-input-bg: white;
--color-input-border: #bfbfbf;
--color-input-icon: #d3d4d7;
@@ -98,6 +99,7 @@ html.dark {
--btn-info: transparent;
--color-input-color:white;
--color-input-bg: rgba(14, 18, 23, 1);
--color-input-icon: #383737;

View File

@@ -14,10 +14,11 @@ import {saveAs} from "file-saver";
import {GITHUB} from "@/config/ENV.ts";
import dayjs from "dayjs";
import BasePage from "@/pages/pc/components/BasePage.vue";
import {ElInputNumber, ElRadio, ElRadioGroup, ElSlider} from 'element-plus'
import {ElInputNumber, ElRadio, ElRadioGroup} from 'element-plus'
import Toast from '@/pages/pc/components/base/toast/Toast.ts'
import {Option, Select} from "@/pages/pc/components/base/select";
import Switch from "@/pages/pc/components/base/Switch.vue";
import Slider from "@/pages/pc/components/base/Slider.vue";
const emit = defineEmits<{
toggleDisabledDialogEscKey: [val: boolean]
@@ -197,14 +198,14 @@ function importData(e) {
<div class="row">
<label class="sub-title">音量</label>
<div class="wrapper">
<ElSlider v-model="settingStore.wordSoundVolume"/>
<Slider v-model="settingStore.wordSoundVolume"/>
<span>{{ settingStore.wordSoundVolume }}%</span>
</div>
</div>
<div class="row">
<label class="sub-title">倍速</label>
<div class="wrapper">
<ElSlider v-model="settingStore.wordSoundSpeed" :step="0.1" :min="0.5" :max="3"/>
<Slider v-model="settingStore.wordSoundSpeed" :step="0.1" :min="0.5" :max="3"/>
<span>{{ settingStore.wordSoundSpeed }}</span>
</div>
</div>
@@ -245,7 +246,7 @@ function importData(e) {
<div class="row">
<label class="sub-title">音量</label>
<div class="wrapper">
<ElSlider v-model="settingStore.keyboardSoundVolume"/>
<Slider v-model="settingStore.keyboardSoundVolume"/>
<span>{{ settingStore.keyboardSoundVolume }}%</span>
</div>
</div>
@@ -263,7 +264,7 @@ function importData(e) {
<div class="row">
<label class="sub-title">音量</label>
<div class="wrapper">
<ElSlider v-model="settingStore.effectSoundVolume"/>
<Slider v-model="settingStore.effectSoundVolume"/>
<span>{{ settingStore.effectSoundVolume }}%</span>
</div>
</div>
@@ -337,7 +338,7 @@ function importData(e) {
<div class="row">
<label class="sub-title">外语字体</label>
<div class="wrapper">
<ElSlider
<Slider
:min="10"
:max="100"
v-model="settingStore.fontSize.wordForeignFontSize"/>
@@ -347,7 +348,7 @@ function importData(e) {
<div class="row">
<label class="sub-title">中文字体</label>
<div class="wrapper">
<ElSlider
<Slider
:min="10"
:max="100"
v-model="settingStore.fontSize.wordTranslateFontSize"/>

View File

@@ -53,7 +53,7 @@ const searchList = computed<any[]>(() => {
<div class="flex items-center relative gap-2">
<BackIcon class="z-2" @Click='router.back()'/>
<div class="flex flex-1 gap-4" v-if="showSearchInput">
<Input placeholder="请输入书籍名称/缩写/类别" v-model="searchKey" class="flex-1" autofocus/>
<Input prefix-icon placeholder="请输入书籍名称/缩写/类别" v-model="searchKey" class="flex-1" autofocus/>
<BaseButton @click="showSearchInput = false, searchKey = ''">取消</BaseButton>
</div>
<div class="py-1 flex flex-1 justify-end" v-else>

View File

@@ -11,6 +11,8 @@ import {useBaseStore} from "@/stores/base.ts";
import BaseButton from "@/components/BaseButton.vue";
import {getDefaultDict} from "@/types/func.ts";
import {Option, Select} from "@/pages/pc/components/base/select";
import Input from "@/pages/pc/components/Input.vue";
import BaseInput from "@/pages/pc/components/base/BaseInput.vue";
const props = defineProps<{
isAdd: boolean,
@@ -99,10 +101,10 @@ onMounted(() => {
:model="dictForm"
label-width="8rem">
<ElFormItem label="名称" prop="name">
<ElInput v-model="dictForm.name"/>
<BaseInput v-model="dictForm.name"/>
</ElFormItem>
<ElFormItem label="描述">
<ElInput v-model="dictForm.description" type="textarea"/>
<BaseInput v-model="dictForm.description" textarea/>
</ElFormItem>
<ElFormItem label="原文语言">
<Select v-model="dictForm.language" placeholder="请选择选项">

View File

@@ -134,6 +134,7 @@ defineRender(
class="flex gap-4"
>
<Input
prefixIcon
modelValue={searchKey}
onUpdate:modelValue=
{debounce(e => searchKey = e)}

View File

@@ -4,6 +4,7 @@ import BaseButton from "@/components/BaseButton.vue";
import {ElInput} from "element-plus";
import {watchEffect} from "vue";
import BaseInput from "@/pages/pc/components/base/BaseInput.vue";
interface IProps {
value: string,
@@ -38,9 +39,10 @@ function toggle() {
<div
v-if="edit"
class="edit-text">
<ElInput
<BaseInput
v-model="editVal"
ref="inputRef"
textarea
autosize
autofocus
type="textarea"

View File

@@ -8,6 +8,7 @@ defineProps<{
modelValue: string
placeholder?: string
autofocus?: boolean
prefixIcon?: boolean
}>()
defineEmits(['update:modelValue'])
@@ -37,6 +38,7 @@ const vFocus = {
ref="inputEl"
>
<Icon icon="fluent:search-24-regular"
v-if="prefixIcon"
width="20"/>
<input type="text"
:value="modelValue"
@@ -61,7 +63,6 @@ const vFocus = {
transition: all .3s;
display: flex;
align-items: center;
transition: all .3s;
background: var(--color-input-bg);
:deep(svg) {

View File

@@ -0,0 +1,278 @@
<script setup lang="ts">
import {ref, computed, watch, defineProps, defineEmits, useAttrs, nextTick, PropType} from 'vue';
interface Autosize {
minRows?: number;
maxRows?: number;
}
const props = defineProps({
modelValue: [String, Number],
placeholder: String,
disabled: Boolean,
type: {
type: String,
default: 'text',
},
clearable: {
type: Boolean,
default: false,
},
textarea: {
type: Boolean,
default: false,
},
required: {
type: Boolean,
default: false,
},
maxLength: Number,
autosize: {
type: [Boolean, Object] as PropType<boolean | Autosize>,
default: false,
},
});
const emit = defineEmits(['update:modelValue', 'input', 'change', 'focus', 'blur', 'validation']);
const attrs = useAttrs();
const inputValue = ref(props.modelValue);
const errorMsg = ref('');
const textareaRef = ref<HTMLTextAreaElement | null>(null);
watch(() => props.modelValue, (val) => {
inputValue.value = val;
validate(val);
});
const validate = (val: string | number | null | undefined) => {
let err = '';
const strVal = val == null ? '' : String(val);
if (props.required && !strVal.trim()) {
err = '不能为空';
} else if (props.maxLength && strVal.length > props.maxLength) {
err = `长度不能超过 ${props.maxLength} 个字符`;
}
errorMsg.value = err;
emit('validation', err === '', err);
return err === '';
};
const onInput = (e: Event) => {
const target = e.target as HTMLInputElement | HTMLTextAreaElement;
inputValue.value = target.value;
validate(target.value);
emit('update:modelValue', target.value);
emit('input', e);
if (props.textarea && props.autosize) {
nextTick(() => {
calcTextareaHeight();
});
}
};
const onChange = (e: Event) => {
emit('change', e);
};
const onFocus = (e: FocusEvent) => {
emit('focus', e);
};
const onBlur = (e: FocusEvent) => {
validate(inputValue.value);
emit('blur', e);
};
const clearInput = () => {
inputValue.value = '';
validate('');
emit('update:modelValue', '');
if (props.textarea && props.autosize) {
nextTick(() => {
calcTextareaHeight();
});
}
};
// 计算并设置 textarea 高度,支持 autosize 功能
const calcTextareaHeight = () => {
if (!textareaRef.value) return;
const ta = textareaRef.value;
ta.style.height = 'auto'; // 先重置高度
const style = window.getComputedStyle(ta);
const lineHeight = parseFloat(style.lineHeight);
const paddingTop = parseFloat(style.paddingTop);
const paddingBottom = parseFloat(style.paddingBottom);
let height = ta.scrollHeight;
let minRows = 1;
let maxRows = Infinity;
if (typeof props.autosize === 'object') {
if (props.autosize.minRows) minRows = props.autosize.minRows;
if (props.autosize.maxRows) maxRows = props.autosize.maxRows;
} else if (props.autosize === true) {
minRows = 1;
maxRows = Infinity;
}
const minHeight = lineHeight * minRows + paddingTop + paddingBottom;
const maxHeight = lineHeight * maxRows + paddingTop + paddingBottom;
height = Math.min(Math.max(height, minHeight), maxHeight);
ta.style.height = height + 'px';
};
// 组件初始化时,调整高度(针对多行)
if (props.textarea && props.autosize) {
nextTick(() => {
calcTextareaHeight();
});
}
</script>
<template>
<div class="custom-input" :class="{ 'is-disabled': disabled, 'has-error': errorMsg }">
<template v-if="textarea">
<textarea
v-bind="attrs"
ref="textareaRef"
:placeholder="placeholder"
:disabled="disabled"
:value="inputValue"
@input="onInput"
@change="onChange"
@focus="onFocus"
@blur="onBlur"
class="custom-input__textarea"
:maxlength="maxLength"
rows="1"
:style="autosize ? {overflowY: 'hidden'} : {}"
></textarea>
<button
v-if="clearable && inputValue && !disabled"
type="button"
class="custom-input__clear"
@click="clearInput"
aria-label="Clear input"
>×
</button>
</template>
<template v-else>
<input
v-bind="attrs"
:type="type"
:placeholder="placeholder"
:disabled="disabled"
:value="inputValue"
@input="onInput"
@change="onChange"
@focus="onFocus"
@blur="onBlur"
class="custom-input__inner"
:maxlength="maxLength"
/>
<button
v-if="clearable && inputValue && !disabled"
type="button"
class="custom-input__clear"
@click="clearInput"
aria-label="Clear input"
>×
</button>
</template>
<div v-if="errorMsg" class="custom-input__error">{{ errorMsg }}</div>
</div>
</template>
<style scoped lang="scss">
.custom-input {
position: relative;
display: inline-block;
width: 100%;
&.is-disabled {
opacity: 0.6;
}
&.has-error {
.custom-input__inner,
.custom-input__textarea {
border-color: #f56c6c;
}
.custom-input__error {
color: #f56c6c;
font-size: 0.85rem;
margin-top: 0.25rem;
}
}
&__inner,
&__textarea {
width: 100%;
padding: 0.4rem 1.5rem 0.4rem 0.5rem;
border: 1px solid #ccc;
border-radius: 4px;
font-size: 1rem;
box-sizing: border-box;
resize: vertical;
transition: all .3s;
color: var(--color-input-color);
background: var(--color-input-bg);
&:focus {
outline: none;
border-color: #409eff;
box-shadow: 0 0 3px #409eff;
}
&:disabled {
background-color: #f5f5f5;
cursor: not-allowed;
}
}
&__textarea {
min-height: 5rem;
overflow-y: auto;
}
&__clear {
position: absolute;
right: 0.4rem;
top: 50%;
transform: translateY(-50%);
border: none;
background: transparent;
font-size: 1.2rem;
line-height: 1;
cursor: pointer;
color: #999;
padding: 0;
user-select: none;
&:hover {
color: #666;
}
}
&__error {
padding-left: 0.5rem;
}
}
.custom-input__textarea {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
}
</style>

View File

@@ -0,0 +1,236 @@
<script setup lang="ts">
import {ref, computed, watch, defineProps, defineEmits, onMounted, nextTick} from 'vue';
const props = defineProps<{
modelValue: number;
min?: number;
max?: number;
step?: number;
disabled?: boolean;
showText?: boolean;
showValue?: boolean; // 是否显示当前值
}>();
const emit = defineEmits(['update:modelValue']);
const min = props.min ?? 0;
const max = props.max ?? 100;
const step = props.step ?? 1;
const sliderRef = ref<HTMLElement | null>(null);
const isDragging = ref(false);
const sliderLeft = ref(0);
const sliderWidth = ref(0);
const currentValue = ref(props.modelValue);
watch(() => props.modelValue, (val) => {
currentValue.value = val;
});
const valueToPercent = (value: number) => ((value - min) / (max - min)) * 100;
// 计算一个数字的小数位数
function countDecimals(value: number) {
if (Math.floor(value) === value) return 0;
const str = value.toString();
if (str.indexOf('e-') >= 0) {
// 科学计数法处理
const [, trail] = str.split('e-');
return parseInt(trail, 10);
}
return str.split('.')[1]?.length || 0;
}
// 对数值按步长对齐,并控制精度,避免浮点误差
function alignToStep(value: number, step: number) {
const decimals = countDecimals(step);
return Number((Math.round(value / step) * step).toFixed(decimals));
}
const percentToValue = (percent: number) => {
let val = min + ((max - min) * percent) / 100;
val = alignToStep(val, step);
if (val < min) val = min;
if (val > max) val = max;
return val;
};
const updateSliderRect = () => {
if (!sliderRef.value) return;
const rect = sliderRef.value.getBoundingClientRect();
sliderLeft.value = rect.left;
sliderWidth.value = rect.width;
};
const setValueFromPosition = (pageX: number) => {
let percent = ((pageX - sliderLeft.value) / sliderWidth.value) * 100;
if (percent < 0) percent = 0;
if (percent > 100) percent = 100;
currentValue.value = percentToValue(percent);
emit('update:modelValue', currentValue.value);
};
const onMouseDown = (e: MouseEvent) => {
if (props.disabled) return;
e.preventDefault();
updateSliderRect();
isDragging.value = true;
setValueFromPosition(e.pageX);
window.addEventListener('mousemove', onMouseMove);
window.addEventListener('mouseup', onMouseUp);
};
const onTouchStart = (e: TouchEvent) => {
if (props.disabled) return;
updateSliderRect();
isDragging.value = true;
setValueFromPosition(e.touches[0].pageX);
window.addEventListener('touchmove', onTouchMove);
window.addEventListener('touchend', onTouchEnd);
};
const onMouseMove = (e: MouseEvent) => {
if (!isDragging.value) return;
e.preventDefault();
setValueFromPosition(e.pageX);
};
const onTouchMove = (e: TouchEvent) => {
if (!isDragging.value) return;
setValueFromPosition(e.touches[0].pageX);
};
const onMouseUp = () => {
if (!isDragging.value) return;
isDragging.value = false;
window.removeEventListener('mousemove', onMouseMove);
window.removeEventListener('mouseup', onMouseUp);
};
const onTouchEnd = () => {
if (!isDragging.value) return;
isDragging.value = false;
window.removeEventListener('touchmove', onTouchMove);
window.removeEventListener('touchend', onTouchEnd);
};
const onClickTrack = (e: MouseEvent) => {
if (props.disabled) return;
updateSliderRect();
setValueFromPosition(e.pageX);
};
onMounted(() => {
nextTick(() => {
updateSliderRect();
window.addEventListener('resize', updateSliderRect);
});
});
</script>
<template>
<div class="w-full">
<div
ref="sliderRef"
class="custom-slider"
:class="{ 'is-disabled': disabled }"
@mousedown="onClickTrack"
@touchstart.prevent="onClickTrack"
>
<div class="custom-slider__track"></div>
<div
class="custom-slider__fill"
:style="{ width: valueToPercent(currentValue) + '%' }"
></div>
<div
class="custom-slider__thumb"
:style="{ left: valueToPercent(currentValue) + '%' }"
@mousedown.stop.prevent="onMouseDown"
@touchstart.stop.prevent="onTouchStart"
tabindex="0"
role="slider"
:aria-valuemin="min"
:aria-valuemax="max"
:aria-valuenow="currentValue"
:aria-disabled="disabled"
></div>
<div v-if="showValue" class="custom-slider__value">{{ currentValue }}</div>
</div>
<div class="text flex justify-between text-sm color-gray" v-if="showText">
<span>{{ min }}</span>
<span>{{ max }}</span>
</div>
</div>
</template>
<style scoped lang="scss">
.custom-slider {
position: relative;
width: 100%;
height: 24px;
user-select: none;
touch-action: none;
cursor: pointer;
&.is-disabled {
opacity: 0.5;
cursor: not-allowed;
}
&__track {
position: absolute;
top: 50%;
left: 0;
right: 0;
height: 6px;
background-color: #ddd;
border-radius: 2px;
transform: translateY(-50%);
}
&__fill {
position: absolute;
top: 50%;
left: 0;
height: 6px;
background-color: #409eff;
border-radius: 2px 0 0 2px;
transform: translateY(-50%);
pointer-events: none;
}
&__thumb {
position: absolute;
top: 50%;
width: 16px;
height: 16px;
background-color: #fff;
border: 2px solid #409eff;
border-radius: 50%;
transform: translate(-50%, -50%);
cursor: grab;
transition: box-shadow 0.2s;
}
&__thumb:focus {
outline: none;
box-shadow: 0 0 5px #409eff;
cursor: grabbing;
}
&__value {
position: absolute;
top: 100%;
left: 50%;
transform: translate(-50%, 4px);
font-size: 0.75rem;
color: #666;
user-select: none;
}
}
</style>

View File

@@ -48,7 +48,7 @@ defineExpose({scrollToBottom, scrollToItem})
<template>
<div class="list">
<div class="search">
<Input v-model="searchKey"/>
<Input prefix-icon v-model="searchKey"/>
</div>
<BaseList
ref="listRef"

View File

@@ -100,7 +100,7 @@ defineExpose({scrollBottom})
ref="el"
>
<div class="search">
<Input v-model="searchKey"/>
<Input prefix-icon v-model="searchKey"/>
</div>
<transition-group name="drag" class="list" tag="div">
<div class="item"

View File

@@ -19,6 +19,7 @@ import {useRoute, useRouter} from "vue-router";
import {useBaseStore} from "@/stores/base.ts";
import EditBook from "@/pages/pc/article/components/EditBook.vue";
import {getDefaultDict} from "@/types/func.ts";
import BaseInput from "@/pages/pc/components/base/BaseInput.vue";
const runtimeStore = useRuntimeStore()
const base = useBaseStore()
@@ -268,19 +269,19 @@ defineRender(() => {
model={wordForm}
label-width="7rem">
<ElFormItem label="单词" prop="word">
<ElInput
<BaseInput
modelValue={wordForm.word}
onUpdate:modelValue={e => wordForm.word = e}
/>
</ElFormItem>
<ElFormItem label="英音音标">
<ElInput
<BaseInput
modelValue={wordForm.phonetic0}
onUpdate:modelValue={e => wordForm.phonetic0 = e}
/>
</ElFormItem>
<ElFormItem label="美音音标">
<ElInput
<BaseInput
modelValue={wordForm.phonetic1}
onUpdate:modelValue={e => wordForm.phonetic1 = e}/>
</ElFormItem>

View File

@@ -80,7 +80,7 @@ const searchList = computed<any[]>(() => {
<div class="flex items-center relative gap-2">
<BackIcon class="z-2" @Click='router.back()'/>
<div class="flex flex-1 gap-4" v-if="showSearchInput">
<Input placeholder="请输入词典名称/缩写/类别" v-model="searchKey" class="flex-1" autofocus/>
<Input prefix-icon placeholder="请输入词典名称/缩写/类别" v-model="searchKey" class="flex-1" autofocus/>
<BaseButton @click="showSearchInput = false, searchKey = ''">取消</BaseButton>
</div>
<div class="py-1 flex flex-1 justify-end" v-else>

View File

@@ -12,11 +12,11 @@ import {getCurrentStudyWord} from "@/hooks/dict.ts";
import {useRuntimeStore} from "@/stores/runtime.ts";
import Book from "@/pages/pc/components/Book.vue";
import PopConfirm from "@/pages/pc/components/PopConfirm.vue";
import {ElSlider} from 'element-plus';
import Progress from '@/pages/pc/components/base/Progress.vue';
import Toast from '@/pages/pc/components/base/toast/Toast.ts';
import BaseButton from "@/components/BaseButton.vue";
import {getDefaultDict} from "@/types/func.ts";
import Slider from "@/pages/pc/components/base/Slider.vue";
const store = useBaseStore()
const router = useRouter()
@@ -224,9 +224,13 @@ const progressTextRight = $computed(() => {
<div class="center text-sm" :style="{ opacity: tempPerDayStudyNumber === 20 ? 1 : 0 }">
推荐
</div>
<ElSlider :min="10" :step="10" show-stops :marks="{ 10: '10', 200: '200' }" size="small" class="my-6"
:max="200" v-model="tempPerDayStudyNumber"/>
<div class="flex gap-2 mb-2 mt-10 items-center">
<Slider :min="10"
:step="10"
show-stops
class="mt-3"
show-text
:max="200" v-model="tempPerDayStudyNumber"/>
<div class="flex gap-2 mb-2 mt-2 items-center">
<div>预计</div>
<span class="text-2xl" style="color:rgb(176,116,211)">{{
_getAccomplishDays(store.sdict.words.length, tempPerDayStudyNumber)