Merge branch 'master' into feat/mobile

This commit is contained in:
SMGDev
2025-11-21 15:43:50 +00:00
91 changed files with 10905 additions and 7477 deletions

View File

@@ -1,10 +1,11 @@
<script setup lang="ts">
import BaseIcon from "@/components/BaseIcon.vue";
import {useAttrs} from "vue";
import router from "@/router.ts";
import { useAttrs } from "vue";
import { useNav } from "@/utils";
const attrs = useAttrs()
const router = useNav()
function onClick() {
if (!attrs.onClick) {

View File

@@ -7,7 +7,7 @@ interface IProps {
disabled?: boolean
loading?: boolean
size?: 'small' | 'normal' | 'large',
type?: 'primary' | 'link' | 'info'
type?: 'primary' | 'link' | 'info' | 'orange'
}
withDefaults(defineProps<IProps>(), {
@@ -62,7 +62,7 @@ defineEmits(['click'])
color: white;
& + .base-button {
margin-left: var(--space);
margin-left: 1rem;
}
.loading {
@@ -76,8 +76,8 @@ defineEmits(['click'])
}
&.small {
border-radius: 0.2rem;
padding: 0 0.8rem;
border-radius: 0.3rem;
padding: 0 0.6rem;
height: 1.6rem;
font-size: .8rem;
}
@@ -86,6 +86,7 @@ defineEmits(['click'])
padding: 0 1.3rem;
height: 2.4rem;
font-size: 0.9rem;
border-radius: .5rem;
}
& > span {
@@ -97,19 +98,19 @@ defineEmits(['click'])
}
}
&:hover {
opacity: .8;
}
&.primary {
background: var(--btn-primary);
&:hover:not(.disabled) {
opacity: 0.6;
}
}
&.link {
border-radius: 0;
border-bottom: 2px solid transparent;
&:hover {
&:hover:not(.disabled) {
border-bottom: 2px solid var(--color-font-2);
}
}
@@ -118,6 +119,20 @@ defineEmits(['click'])
background: var(--btn-info);
border: 1px solid var(--color-main-text);
color: var(--color-main-text);
&:hover:not(.disabled) {
opacity: 0.6;
}
}
&.orange {
background: #FACC15;
color: black;
&:hover:not(.disabled) {
background: #fbe27e;
color: rgba(0, 0, 0, 0.6);
}
}
&.active {

View File

@@ -14,6 +14,7 @@ import Checkbox from "@/components/base/checkbox/Checkbox.vue";
import DeleteIcon from "@/components/icon/DeleteIcon.vue";
import Dialog from "@/components/dialog/Dialog.vue";
import BaseInput from "@/components/base/BaseInput.vue";
import {Host} from "@/config/env.ts";
let list = defineModel('list')
@@ -283,7 +284,7 @@ defineRender(
<div>短语一行原文一行译文多个请换<span class="color-red"></span></div>
<div>同义词同根词词源请前往官方字典然后编辑其中某个单词参考其格式</div>
<div class="mt-6">
模板下载地址<a href="https://2study.top/libs/单词导入模板.xlsx">单词导入模板</a>
模板下载地址<a href={`https://${Host}/libs/单词导入模板.xlsx`}>单词导入模板</a>
</div>
<div class="mt-4">
<BaseButton

View File

@@ -94,7 +94,7 @@ const studyProgress = $computed(() => {
top: 4px;
right: -22px;
padding: 1px 20px;
background: whitesmoke;
background: var(--color-label-bg);
font-size: 11px;
transform: rotate(45deg);
}

View File

@@ -1,189 +0,0 @@
<script setup lang="ts">
import Close from "@/components/icon/Close.vue";
import BaseButton from "@/components/BaseButton.vue";
import {watch} from "vue";
import {useSettingStore} from "@/stores/setting.ts";
import {isMobile} from "@/utils";
import {ProjectName, Host} from "@/config/env.ts";
let settingStore = useSettingStore()
let showNotice = $ref(false)
let show = $ref(false)
let num = $ref(5)
let timer = -1
let mobile = $ref(isMobile())
const isMac = /macintosh|mac os x/i.test(navigator.userAgent);
function toggleNotice() {
showNotice = true
settingStore.first = false
timer = setInterval(() => {
num--
if (num <= 0) close()
}, 1000)
}
function close() {
clearInterval(timer)
show = settingStore.first = false
}
watch(() => settingStore.load, (n) => {
if (n && settingStore.first) {
setTimeout(() => {
show = true
}, 1000)
}
}, {immediate: true})
</script>
<template>
<transition name="right">
<div class="CollectNotice"
:class="{mobile}"
v-if="show">
<div class="notice">
坚持练习提高外语能力
<span class="active">{{ ProjectName }}</span>
保存为书签永不迷失
</div>
<div class="wrapper">
<transition name="fade">
<div class="collect" v-if="showNotice">
<div class="href-wrapper">
<div class="round">
<div class="href">{{ Host }}</div>
<IconFluentStar12Regular width="22"/>
</div>
<div class="right">
👈
<IconFluentStar20Filled class="star" width="22"/>
点亮它!
</div>
</div>
<div class="collect-keyboard" v-if="!mobile">或使用收藏快捷键<span
class="active">{{ isMac ? 'Command' : 'Ctrl' }} + D</span></div>
</div>
<BaseButton v-else size="large" @click="toggleNotice">我想收藏</BaseButton>
</transition>
</div>
<div class="close-wrapper">
<span v-show="showNotice"><span class="active">{{ num }}s</span> 后自动关闭</span>
<Close @click="close" title="关闭"/>
</div>
</div>
</transition>
</template>
<style scoped lang="scss">
.right-enter-active,
.right-leave-active {
transition: all .5s ease;
}
.right-enter-from,
.right-leave-to {
transform: translateX(110%);
}
.CollectNotice {
position: fixed;
right: var(--space);
top: var(--space);
z-index: 2;
font-size: 1.2rem;
display: flex;
flex-direction: column;
align-items: center;
background: var(--color-notice-bg);
padding: 1.8rem;
border-radius: 0.7rem;
width: 30rem;
gap: 2.4rem;
color: var(--color-font-1);
line-height: 1.5;
border: 1px solid var(--color-item-border);
box-shadow: var(--shadow);
box-sizing: border-box;
&.mobile {
width: 95%;
padding: 0.6rem;
}
.notice {
margin-top: 2.4rem;
}
.active {
color: var(--color-select-bg);
}
.wrapper {
.collect {
display: flex;
flex-direction: column;
align-items: center;
.href-wrapper {
display: flex;
font-size: 1rem;
align-items: center;
gap: 0.6rem;
.round {
color: var(--color-font-1);
border-radius: 3rem;
padding: 0.6rem 0.6rem;
padding-left: 1.2rem;
gap: 2rem;
display: flex;
align-items: center;
justify-content: space-between;
background: var(--color-primary);
.href {
font-size: 0.9rem;
}
}
.star {
color: var(--color-select-bg);
}
.right {
display: flex;
align-items: center;
}
}
.collect-keyboard {
margin-top: 1.2rem;
font-size: 1rem;
span {
margin-left: 0.6rem;
}
}
}
}
.close-wrapper {
right: var(--space);
top: var(--space);
position: absolute;
font-size: 0.9rem;
display: flex;
justify-content: flex-end;
align-items: center;
color: var(--color-font-1);
gap: 0.6rem;
}
}
</style>

29
src/components/Header.vue Normal file
View File

@@ -0,0 +1,29 @@
<script setup lang="ts">
import BackIcon from "@/components/BackIcon.vue";
import { useAttrs } from "vue";
interface IProps {
title: string;
showBackIcon?: boolean;
}
withDefaults(defineProps<IProps>(), {
title: '',
showBackIcon: true,
})
const attrs = useAttrs()
</script>
<template>
<div class="mb-3 text-xl font-bold relative min-h-8">
<BackIcon class="z-2 relative" v-bind="attrs" v-if="showBackIcon" />
<span class="absolute text-center w-full left-0" @click.stop>{{ title }}</span>
</div>
</template>
<style scoped lang="scss">
</style>

View File

@@ -1,17 +1,31 @@
<script lang="jsx">
import {Teleport, Transition} from 'vue'
import BaseButton from "@/components/BaseButton.vue";
export default {
name: "PopConfirm",
components: {
Teleport,
Transition
Transition,
BaseButton
},
props: {
title: {
type: String,
type: [String, Array],
default() {
return ''
},
validator(value) {
// Validate that array items have the correct structure
if (Array.isArray(value)) {
return value.every(item =>
typeof item === 'object' &&
item !== null &&
typeof item.text === 'string' &&
['normal', 'bold', 'red', 'redBold'].includes(item.type)
)
}
return typeof value === 'string'
}
},
disabled: {
@@ -21,6 +35,17 @@ export default {
}
}
},
computed: {
titleItems() {
if (typeof this.title === 'string') {
return [{ text: this.title, type: 'normal' }]
}
if (Array.isArray(this.title)) {
return this.title
}
return []
}
},
data() {
return {
show: false
@@ -35,6 +60,27 @@ export default {
})
},
methods: {
getTextStyle(type) {
const styles = {
normal: {
fontWeight: 'normal',
color: 'inherit'
},
bold: {
fontWeight: 'bold',
color: 'inherit'
},
red: {
fontWeight: 'normal',
color: 'red'
},
redBold: {
fontWeight: 'bold',
color: 'red'
}
}
return styles[type] || styles.normal
},
showPop(e) {
if (this.disabled) return this.$emit('confirm')
e?.stopPropagation()
@@ -60,18 +106,26 @@ export default {
render() {
let Vnode = this.$slots.default()[0]
return (
<div class="pop-confirm">
<div class="pop-confirm leading-none">
<Teleport to="body">
<Transition>
<Transition name="fade">
{
this.show && (
<div ref="tip" class="pop-confirm-content">
<div class="text">
{this.title}
<div ref="tip" class="pop-confirm-content shadow-2xl">
<div class="w-52 title-content">
{this.titleItems.map((item, index) => (
<div
key={index}
style={this.getTextStyle(item.type)}
class="title-item"
>
{item.text}
</div>
))}
</div>
<div class="options">
<div onClick={() => this.show = false}>取消</div>
<div class="main" onClick={() => this.confirm()}>确认</div>
<BaseButton type="info" size="small" onClick={() => this.show = false}>取消</BaseButton>
<BaseButton size="small" onClick={() => this.confirm()}>确认</BaseButton>
</div>
</div>
)
@@ -85,43 +139,27 @@ export default {
}
</script>
<style lang="scss" scoped>
$bg-color: rgb(226, 226, 226);
.pop-confirm-content {
position: fixed;
background: var(--color-tooltip-bg);
padding: 1rem;
border-radius: .3rem;
border-radius: .6rem;
transform: translate(-50%, calc(-100% - .6rem));
box-shadow: 0 0 6px 1px var(--color-tooltip-shadow);
z-index: 999;
.text {
color: var(--color-font-1);
text-align: start;
font-size: 1rem;
width: 9rem;
min-width: 9rem;
.title-content {
.title-item {
margin-bottom: 0.25rem;
&:last-child {
margin-bottom: 0;
}
}
}
.options {
margin-top: .9rem;
display: flex;
justify-content: flex-end;
align-items: center;
gap: .7rem;
font-size: .9rem;
div {
cursor: pointer;
}
.main {
color: gray;
background: $bg-color;
padding: .2rem .6rem;
border-radius: .24rem;
}
text-align: right;
}
}
</style>

View File

@@ -9,6 +9,7 @@ interface IProps {
currentTime?: number;
playbackRate?: number;
disabled?: boolean;
}
const props = withDefaults(defineProps<IProps>(), {
@@ -17,11 +18,13 @@ const props = withDefaults(defineProps<IProps>(), {
volume: 1,
currentTime: 0,
playbackRate: 1,
disabled: false
disabled: false,
});
const emit = defineEmits<{
ended: []
(e: 'ended'): [],
(e: 'update-volume', volume: number): void,
(e: 'update-speed', volume: number): void
}>();
const attrs = useAttrs();
@@ -30,17 +33,20 @@ const attrs = useAttrs();
const audioRef = ref<HTMLAudioElement>();
const progressBarRef = ref<HTMLDivElement>();
const volumeBarRef = ref<HTMLDivElement>();
const volumeFillRef = ref<HTMLElement>();
// 状态管理
const isPlaying = ref(false);
const isLoading = ref(false);
const duration = ref(0);
const currentTime = ref(0);
// const volume = ref(props.volume);
const volume = ref(props.volume);
const playbackRate = ref(props.playbackRate);
const isDragging = ref(false);
const isVolumeDragging = ref(false);
const isVolumeHovering = ref(false); // 添加音量控制hover状态变量
const volumePosition = ref('top') // 音量控制位置,'top'或'down'
const error = ref('');
// 计算属性
@@ -85,17 +91,18 @@ const toggleMute = () => {
volume.value = 1;
audioRef.value.volume = 1;
}
emit('update-volume', Math.floor(volume.value * 100));
};
const changePlaybackRate = () => {
if (!audioRef.value || props.disabled) return;
const rates = [0.5, 0.75, 1, 1.25, 1.5, 2];
const currentIndex = rates.indexOf(playbackRate.value);
const nextIndex = (currentIndex + 1) % rates.length;
playbackRate.value = rates[nextIndex];
audioRef.value.playbackRate = playbackRate.value;
// 提交更新播放速度事件
emit('update-speed', playbackRate.value);
};
// 事件处理
@@ -108,6 +115,10 @@ const handleLoadedData = () => {
};
const handleLoadedMetadata = () => {
if (audioRef.value) {
audioRef.value.volume = volume.value;
}
duration.value = audioRef.value?.duration || 0;
};
@@ -250,26 +261,18 @@ const handleVolumeMouseDown = (event: MouseEvent) => {
const startX = event.clientX;
const startY = event.clientY;
let hasMoved = false;
let lastVolume = 0; // 记录最后音量
const moveThreshold = 3; // 移动阈值,超过这个距离才认为是拖拽
let lastVolume = 0; // 记录最后音量
const moveThreshold = 3; // 超过这个距离才认为是拖拽
// 获取DOM元素引用
const volumeFill = volumeBarRef.value.querySelector('.volume-fill') as HTMLElement;
const volumeThumb = volumeBarRef.value.querySelector('.volume-thumb') as HTMLElement;
const volumeFill = volumeFillRef.value;
// 立即跳转到点击位置
// 计算点击位置对应音量百分比(最上 100%,最下 0%
const clickY = event.clientY - rect.top;
// 计算百分比最上面是0%最下面是100%
const percentage = Math.max(0, Math.min(1, clickY / rect.height));
const percentage = 1 - Math.max(0, Math.min(1, clickY / rect.height));
// 直接更新DOM样式
if (volumeFill && volumeThumb) {
// 更新 UI 与音量
if (volumeFill) {
volumeFill.style.height = `${percentage * 100}%`;
// 设置top而不是bottom
volumeThumb.style.top = `${percentage * 100}%`;
// 重置left样式
volumeThumb.style.left = '50%';
}
volume.value = percentage;
@@ -277,6 +280,7 @@ const handleVolumeMouseDown = (event: MouseEvent) => {
lastVolume = percentage;
isVolumeDragging.value = true;
// 鼠标移动时调整音量
const handleMouseMove = (e: MouseEvent) => {
const deltaX = Math.abs(e.clientX - startX);
const deltaY = Math.abs(e.clientY - startY);
@@ -286,46 +290,41 @@ const handleVolumeMouseDown = (event: MouseEvent) => {
}
if (!hasMoved) return;
// 禁用过渡动画
if (volumeFill && volumeThumb) {
if (volumeFill) {
volumeFill.style.transition = 'none';
volumeThumb.style.transition = 'none';
}
const rect = volumeBarRef.value!.getBoundingClientRect();
const clickY = e.clientY - rect.top;
// 计算百分比最上面是0%最下面是100%
const percentage = Math.max(0, Math.min(1, clickY / rect.height));
const moveY = e.clientY - rect.top;
const percentage = 1 - Math.max(0, Math.min(1, moveY / rect.height));
// 直接更新DOM样式不使用响应式变量
if (volumeFill && volumeThumb) {
if (volumeFill) {
volumeFill.style.height = `${percentage * 100}%`;
// 设置top而不是bottom
volumeThumb.style.top = `${percentage * 100}%`;
}
// 更新响应式变量和音频音量
volume.value = percentage;
lastVolume = percentage;
// 实时更新音频音量
if (audioRef.value) {
audioRef.value.volume = percentage;
}
};
// 鼠标释放时结束拖动
const handleMouseUp = () => {
isVolumeDragging.value = false;
// 恢复过渡动画
if (volumeFill && volumeThumb) {
if (volumeFill) {
volumeFill.style.transition = '';
volumeThumb.style.transition = '';
}
// 如果是拖拽在结束时更新audio元素到最终音量
if (hasMoved && audioRef.value) {
audioRef.value.volume = lastVolume;
}
// 提交更新音量事件
emit('update-volume', Math.floor(volume.value * 100));
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
@@ -335,6 +334,20 @@ const handleVolumeMouseDown = (event: MouseEvent) => {
document.addEventListener('mouseup', handleMouseUp);
};
// 音量控制鼠标移入事件,自动调整音量控制条位置
const onVolumeSectionEnter = (e: MouseEvent) => {
isVolumeHovering.value = true;
const section = e.target as HTMLElement
const top = section.getBoundingClientRect().top + window.scrollY
const dropdownH = section.querySelector('.volume-dropdown').clientHeight
if (top < dropdownH * 1.25) {
volumePosition.value = 'down'
} else {
volumePosition.value = 'top'
}
}
// 监听属性变化
watch(() => props.src, (newSrc) => {
if (audioRef.value) {
@@ -377,52 +390,29 @@ watch(() => props.playbackRate, (newRate) => {
}
});
defineExpose({audioRef})
defineExpose({ audioRef })
</script>
<template>
<div
class="custom-audio"
:class="{ 'disabled': disabled||error, 'has-error': error }"
v-bind="attrs"
>
<div class="custom-audio" :class="{ 'disabled': disabled || error, 'has-error': error }" v-bind="attrs">
<!-- 隐藏的原生audio元素 -->
<audio
ref="audioRef"
:src="src"
preload="auto"
:autoplay="autoplay"
:loop="loop"
:controls="false"
@loadstart="handleLoadStart"
@loadeddata="handleLoadedData"
@loadedmetadata="handleLoadedMetadata"
@canplaythrough="handleCanPlayThrough"
@play="handlePlay"
@pause="handlePause"
@ended="handleEnded"
@error="handleError"
@timeupdate="handleTimeUpdate"
@volumechange="handleVolumeChange"
@ratechange="handleRateChange"
/>
<audio ref="audioRef" :src="src" preload="auto" :autoplay="autoplay" :loop="loop" :controls="false"
@loadstart="handleLoadStart" @loadeddata="handleLoadedData" @loadedmetadata="handleLoadedMetadata"
@canplaythrough="handleCanPlayThrough" @play="handlePlay" @pause="handlePause" @ended="handleEnded"
@error="handleError" @timeupdate="handleTimeUpdate" @volumechange="handleVolumeChange"
@ratechange="handleRateChange" />
<!-- 自定义控制界面 -->
<div class="audio-container">
<!-- 播放/暂停按钮 -->
<button
class="play-button"
:class="{ 'loading': isLoading }"
@click="togglePlay"
:disabled="disabled"
:aria-label="isPlaying ? '暂停' : '播放'"
>
<button class="play-button" :class="{ 'loading': isLoading }" @click="togglePlay" :disabled="disabled"
:aria-label="isPlaying ? '暂停' : '播放'">
<div v-if="isLoading" class="loading-spinner"></div>
<svg v-else-if="isPlaying" class="icon" viewBox="0 0 24 24" fill="currentColor">
<path d="M6 4h4v16H6V4zm8 0h4v16h-4V4z"/>
<path d="M6 4h4v16H6V4zm8 0h4v16h-4V4z" />
</svg>
<svg v-else class="icon" viewBox="0 0 24 24" fill="currentColor">
<path d="M8 5v14l11-7z"/>
<path d="M8 5v14l11-7z" />
</svg>
</button>
@@ -431,70 +421,40 @@ defineExpose({audioRef})
<!-- 时间显示 -->
<span class="time-display">{{ formatTime(currentTime) }} / {{ formatTime(duration) }}</span>
<!-- 进度条 -->
<div
class="progress-container"
@mousedown="handleProgressMouseDown"
ref="progressBarRef"
>
<div class="progress-container" @mousedown="handleProgressMouseDown" ref="progressBarRef">
<div class="progress-track">
<div
class="progress-fill"
:style="{ width: progress + '%' }"
></div>
<div
class="progress-thumb"
:style="{ left: progress + '%' }"
></div>
<div class="progress-fill" :style="{ width: progress + '%' }"></div>
<div class="progress-thumb" :style="{ left: progress + '%' }"></div>
</div>
</div>
</div>
<!-- 音量控制 -->
<div
class="volume-section"
@mouseenter="isVolumeHovering = true"
@mouseleave="isVolumeHovering = false"
>
<button
class="volume-button"
@click="toggleMute"
:disabled="disabled"
:aria-label="volume > 0 ? '静音' : '取消静音'"
>
<div class="volume-section" @mouseenter="onVolumeSectionEnter" @mouseleave="isVolumeHovering = false">
<button class="volume-button" tabindex="-1" @click="toggleMute" :disabled="disabled"
:aria-label="volume > 0 ? '静音' : '取消静音'">
<IconBxVolumeMute v-if="volume === 0" class="icon"></IconBxVolumeMute>
<IconBxVolumeLow v-else-if="volume < 0.5" class="icon"></IconBxVolumeLow>
<IconBxVolumeFull v-else class="icon"></IconBxVolumeFull>
</button>
<!-- 音量下拉控制条 -->
<div class="volume-dropdown" :class="{ 'active': isVolumeHovering || isVolumeDragging }">
<div
class="volume-container"
@mousedown="handleVolumeMouseDown"
ref="volumeBarRef"
>
<div class="volume-dropdown" :class="[{ 'active': isVolumeHovering || isVolumeDragging }, volumePosition]">
<div class="volume-container" @mousedown="handleVolumeMouseDown" ref="volumeBarRef">
<div class="volume-track">
<div
class="volume-fill"
:style="{ height: volumeProgress + '%', top: 0 }"
></div>
<div
class="volume-thumb"
:style="{ top: volumeProgress + '%' }"
></div>
<div class="volume-fill" ref="volumeFillRef" :style="{ height: volumeProgress + '%', bottom: 0 }"></div>
</div>
<div class="volume-num">
<span>{{ Math.floor(volumeProgress) }}%</span>
</div>
</div>
</div>
</div>
<!-- 播放速度控制 -->
<button
class="speed-button"
@click="changePlaybackRate"
:disabled="disabled"
:aria-label="`播放速度: ${playbackRate}x`"
>
<button class="speed-button" @click="changePlaybackRate" :disabled="disabled"
:aria-label="`播放速度: ${playbackRate}x`">
{{ playbackRate }}x
</button>
</div>
@@ -641,6 +601,7 @@ defineExpose({audioRef})
.volume-section {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
flex-shrink: 0;
position: relative;
@@ -671,13 +632,9 @@ defineExpose({audioRef})
.volume-dropdown {
position: absolute;
top: 100%;
left: 50%;
transform: translateX(-50%);
background: var(--color-primary);
border-radius: 4px;
border-radius: 8px;
padding: 8px;
margin-top: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
opacity: 0;
visibility: hidden;
@@ -688,6 +645,14 @@ defineExpose({audioRef})
opacity: 1;
visibility: visible;
}
&.top {
bottom: 42px;
}
&.down {
top: 42px;
}
}
.volume-container {
@@ -705,35 +670,41 @@ defineExpose({audioRef})
width: 6px;
height: 100%;
background: var(--color-second);
border-radius: 2px;
overflow: hidden;
border-radius: 6px;
// overflow: hidden;
}
.volume-num {
display: flex;
position: absolute;
bottom: 0;
font-size: 12px;
color: #333;
transform: scale(0.85);
line-height: normal;
}
.volume-fill {
position: absolute;
top: 0;
bottom: 0;
width: 100%;
height: var(--fill-height);
background: var(--color-fourth);
border-radius: 2px;
}
border-radius: 6px;
display: flex;
justify-content: center;
.volume-thumb {
position: absolute;
left: 50%;
top: var(--thumb-top);
transform: translate(-50%, -50%);
width: 10px;
height: 10px;
background: var(--color-fourth);
border-radius: 50%;
box-shadow: var(--audio-volume-thumb-shadow);
cursor: grab;
opacity: 1;
transition: all 0.2s ease;
&:active {
cursor: grabbing;
&::before {
content: "";
position: absolute;
top: 0;
width: 10px;
height: 10px;
border-radius: 100%;
background: var(--color-fourth);
transform: translateY(-50%);
box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.5);
cursor: grab;
}
}
@@ -772,6 +743,7 @@ defineExpose({audioRef})
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}

View File

@@ -1,13 +1,18 @@
<script setup lang="ts">
import { ref, useAttrs, watch } from 'vue';
import {defineComponent, ref, useAttrs, watch, computed} from 'vue';
import Close from "@/components/icon/Close.vue";
import { useDisableEventListener } from "@/hooks/event.ts";
import {useDisableEventListener} from "@/hooks/event.ts";
defineOptions({
name: "BaseInput",
})
const props = defineProps({
modelValue: [String, Number],
placeholder: String,
disabled: Boolean,
autofocus: Boolean,
error: Boolean,
type: {
type: String,
default: 'text',
@@ -21,40 +26,42 @@ const props = defineProps({
default: false,
},
maxLength: Number,
size: {
type: String,
default: 'normal',
validator: (value: string) => ['normal', 'large'].includes(value)
},
});
const emit = defineEmits(['update:modelValue', 'input', 'change', 'focus', 'blur', 'validation']);
const emit = defineEmits(['update:modelValue', 'input', 'change', 'focus', 'blur', 'validation', 'enter']);
const attrs = useAttrs();
const inputValue = ref(props.modelValue);
const errorMsg = ref('');
let focus = $ref(false)
let inputEl = $ref<HTMLDivElement>()
const passwordVisible = ref(false)
const inputType = computed(() => {
if (props.type === 'password') {
return passwordVisible.value ? 'text' : 'password'
}
return props.type
})
const togglePasswordVisibility = () => {
passwordVisible.value = !passwordVisible.value
}
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;
inputValue.value = target.value;
validate(target.value);
emit('update:modelValue', target.value);
emit('input', e);
emit('change', e);
};
const onChange = (e: Event) => {
@@ -68,14 +75,15 @@ const onFocus = (e: FocusEvent) => {
const onBlur = (e: FocusEvent) => {
focus = false
validate(inputValue.value);
emit('blur', e);
};
const clearInput = () => {
const onEnter = (e: KeyboardEvent) => {
emit('enter', e);
};
const clearInput = () => {
inputValue.value = '';
validate('');
emit('update:modelValue', '');
};
@@ -94,60 +102,97 @@ const vFocus = {
</script>
<template>
<div class="base-input2"
<div class="base-input"
ref="inputEl"
:class="{ 'is-disabled': disabled, 'has-error': errorMsg,focus }">
:class="{ 'is-disabled': disabled, 'error': props.error, focus, [`base-input--${size}`]: true }">
<slot name="subfix"></slot>
<!-- PreIcon slot -->
<div v-if="$slots.preIcon" class="pre-icon">
<slot name="preIcon"></slot>
</div>
<IconFluentLockClosed20Regular class="pre-icon" v-if="type === 'password'"/>
<IconFluentMail20Regular class="pre-icon" v-if="type === 'email'"/>
<IconFluentPhone20Regular class="pre-icon" v-if="type === 'tel'"/>
<IconFluentNumberSymbol20Regular class="pre-icon" v-if="type === 'code'"/>
<input
v-bind="attrs"
:type="type"
:placeholder="placeholder"
:disabled="disabled"
:value="inputValue"
@input="onInput"
@change="onChange"
@focus="onFocus"
@blur="onBlur"
class="inner"
v-focus="autofocus"
:maxlength="maxLength"
v-bind="attrs"
:type="inputType"
:placeholder="placeholder"
:disabled="disabled"
:value="inputValue"
@input="onInput"
@change="onChange"
@focus="onFocus"
@blur="onBlur"
@keydown.enter="onEnter"
class="inner"
v-focus="autofocus"
:maxlength="maxLength"
/>
<slot name="prefix"></slot>
<Close
v-if="clearable && inputValue && !disabled"
@click="clearInput"/>
<div v-if="errorMsg" class="base-input2__error">{{ errorMsg }}</div>
v-if="clearable && inputValue && !disabled"
@click="clearInput"/>
<!-- Password visibility toggle -->
<div
v-if="type === 'password' && !disabled"
class="password-toggle"
@click="togglePasswordVisibility"
:title="passwordVisible ? '隐藏密码' : '显示密码'">
<IconFluentEye16Regular v-if="!passwordVisible"/>
<IconFluentEyeOff16Regular v-else/>
</div>
</div>
</template>
<style scoped lang="scss">
.base-input2 {
.base-input {
position: relative;
display: inline-flex;
box-sizing: border-box;
width: 100%;
border: 1px solid var(--color-input-border);
border-radius: 4px;
border-radius: 6px;
overflow: hidden;
padding: .2rem .3rem;
transition: all .3s;
align-items: center;
background: var(--color-input-bg);
::placeholder {
font-size: 0.9rem;
color: darkgray;
}
// normal size (default)
&--normal {
padding: .2rem .3rem;
.inner {
height: 1.5rem;
font-size: 1rem;
}
}
// large size
&--large {
padding: .4rem .6rem;
border-radius: .5rem;
.inner {
height: 2rem;
font-size: 1.125rem;
}
}
&.is-disabled {
opacity: 0.6;
}
&.has-error {
.base-input2__inner {
border-color: #f56c6c;
}
.base-input2__error {
color: #f56c6c;
font-size: 0.85rem;
margin-top: 0.25rem;
}
&.error {
border-color: #f56c6c;
background: rgba(245, 108, 108, 0.07);
}
&.focus {
@@ -159,8 +204,22 @@ const vFocus = {
cursor: not-allowed;
}
&__error {
padding-left: 0.5rem;
// PreIcon styling
&.has-preicon {
.inner {
padding-left: 2rem;
}
}
.pre-icon {
display: flex;
align-items: center;
justify-content: center;
color: var(--color-input-color);
opacity: 0.6;
z-index: 1;
pointer-events: none;
margin-right: 0.2rem;
}
.inner {
@@ -173,6 +232,24 @@ const vFocus = {
height: 1.5rem;
color: var(--color-input-color);
background: transparent;
width: 100%;
}
.password-toggle {
display: flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
margin-left: 4px;
cursor: pointer;
color: var(--color-input-color);
opacity: 0.6;
transition: opacity 0.2s;
&:hover {
opacity: 1;
}
}
}
</style>

View File

@@ -8,14 +8,16 @@ interface IProps {
strokeWidth?: number;
color?: string;
format?: (percentage: number) => string;
size?: 'normal' | 'large';
}
const props = withDefaults(defineProps<IProps>(), {
showText: true,
textInside: false,
strokeWidth: 6,
color: '#93ADE3',
color: '#409eff',
format: (percentage) => `${percentage}%`,
size: 'normal',
});
const barStyle = computed(() => {
@@ -26,13 +28,15 @@ const barStyle = computed(() => {
});
const trackStyle = computed(() => {
const height = props.size === 'large' ? props.strokeWidth * 2.5 : props.strokeWidth;
return {
height: `${props.strokeWidth}px`,
height: `${height}px`,
};
});
const progressTextSize = computed(() => {
return props.strokeWidth * 0.83 + 6;
const baseSize = props.strokeWidth * 0.83 + 6;
return props.size === 'large' ? baseSize * 1.2 : baseSize;
});
const content = computed(() => {

View File

@@ -5,7 +5,8 @@
</template>
<script setup lang="ts">
import {ref, provide, watch, toRef} from 'vue'
import {provide, ref, toRef} from 'vue'
import type {FormField, FormModel, FormRules} from './types'
interface Field {
prop: string
@@ -14,8 +15,8 @@ interface Field {
}
const props = defineProps({
model: Object,
rules: Object // { word: [{required:true,...}, ...], name: [...] }
model: Object as () => FormModel,
rules: Object as () => FormRules
})
const fields = ref<Field[]>([])
@@ -25,7 +26,7 @@ const registerField = (field: Field) => {
}
// 校验整个表单
const validate = (cb): boolean => {
function validate(cb) {
let valid = true
fields.value.forEach(f => {
const fieldRules = props.rules?.[f.prop] || []
@@ -35,10 +36,23 @@ const validate = (cb): boolean => {
cb(valid)
}
// 校验指定字段
function validateField(fieldName: string, cb?: (valid: boolean) => void): boolean {
const field = fields.value.find(f => f.prop === fieldName)
if (field) {
const fieldRules = props.rules?.[fieldName] || []
const valid = field.validate(fieldRules)
if (cb) cb(valid)
return valid
}
if (cb) cb(true)
return true
}
provide('registerField', registerField)
provide('formModel', toRef(props, 'model'))
provide('formValidate', validate)
provide('formRules', props.rules)
defineExpose({validate})
defineExpose({validate, validateField})
</script>

View File

@@ -11,7 +11,7 @@ let error = $ref('')
// 拿到 form 的 model 和注册函数
const formModel = inject<ref>('formModel')
const registerField = inject('registerField')
const registerField = inject<Function>('registerField')
const formRules = inject('formRules', {})
const myRules = $computed(() => {
@@ -19,9 +19,13 @@ const myRules = $computed(() => {
})
// 校验函数
const validate = (rules) => {
const validate = (rules, isBlur = false) => {
error = ''
const val = formModel.value[props.prop]
//为空并且是非主动触发检验的情况下,不检验
if (isBlur && val.trim() === '') {
return true
}
for (const rule of rules) {
if (rule.required && (!val || !val.toString().trim())) {
error = rule.message
@@ -31,43 +35,93 @@ const validate = (rules) => {
error = rule.message
return false
}
if (rule.min && val && val.toString().length < rule.min) {
error = rule.message
return false
}
if (rule.max && val && val.toString().length > rule.max) {
error = rule.message
return false
}
if (rule.validator) {
try {
rule.validator(rule, val)
} catch (e) {
error = e.message
return false
}
}
}
return true
}
// 自动触发 blur 校验
const handleBlur = () => {
function handleBlur() {
const blurRules = myRules.filter((r) => r.trigger === 'blur')
if (blurRules.length) validate(blurRules)
if (blurRules.length) validate(blurRules, true)
}
function handChange() {
error = ''
}
// 注册到 Form
onMounted(() => {
registerField && registerField({prop: props.prop, modelValue: value, validate})
})
let slot = useSlots()
function patchVNode(vnode, patchFn) {
if (!vnode) return vnode
// 如果当前节点就是我们要找的 BaseInput
if (vnode.type && vnode.type.name) {
return patchFn(vnode)
}
// 如果有子节点,则递归修改
if (Array.isArray(vnode.children)) {
vnode.children = vnode.children.map(child => patchVNode(child, patchFn))
}
return vnode
}
defineRender(() => {
let DefaultNode = slot.default()[0]
return <div class="form-item mb-6 flex gap-space">
let DefaultNode: any = slot.default()[0]
// 对 DefaultNode 深度查找 BaseInput 并加上 onBlur / error
DefaultNode = patchVNode(DefaultNode, vnode => {
return {
...vnode,
props: {
...vnode.props,
error: !!error,
onBlur: handleBlur,
onChange: handChange
},
}
})
return <div class="form-item flex gap-space">
{props.label &&
<label class="w-20 flex items-start mt-1 justify-end">
{myRules.length ? <span class="form-error">*</span> : null} {props.label}
</label>}
<label class="w-20 flex items-start mt-1 justify-end">
{myRules.length ? <span class="form-error">*</span> : null} {props.label}
</label>}
<div class="flex-1 relative">
<DefaultNode onBlur={handleBlur}/>
<div class="form-error absolute top-[100%] anim" style={{opacity: error ? 1 : 0}}>{error}</div>
<DefaultNode/>
<div class="form-error my-0.5 anim" style={{opacity: error ? 1 : 0}}>{error} &nbsp;</div>
</div>
</div>
})
</script>
<style scoped lang="scss">
.form-item {
.form-error {
color: #f56c6c;
font-size: 0.8rem;
}
.form-error {
color: #f56c6c;
font-size: 0.8rem;
}
</style>

View File

@@ -0,0 +1,65 @@
// Form 组件的 TypeScript 类型定义
// 表单字段接口
export interface FormField {
prop: string
modelValue: any
validate: (rules: FormRule[]) => boolean
}
// 表单规则接口
export interface FormRule {
required?: boolean
message?: string
pattern?: RegExp
validator?: (rule: FormRule, value: any, callback: (error?: Error) => void) => void
min?: number
max?: number
len?: number
type?: string
}
// 表单规则对象类型
export type FormRules = Record<string, FormRule[]>
// 表单模型对象类型
export type FormModel = Record<string, any>
// Form 组件的 Props 接口
export interface FormProps {
model?: FormModel
rules?: FormRules
}
// Form 组件的实例接口
export interface FormInstance {
/**
* 校验整个表单
* @param callback 校验完成后的回调函数,接收校验结果
*/
validate: (callback: (valid: boolean) => void) => void
/**
* 校验指定字段
* @param fieldName 要校验的字段名称
* @param callback 可选的回调函数,接收校验结果
* @returns 校验是否通过
*/
validateField: (fieldName: string, callback?: (valid: boolean) => void) => boolean
}
// 注入的上下文类型
export interface FormContext {
registerField: (field: FormField) => void
formModel: FormModel
formValidate: (callback: (valid: boolean) => void) => void
formRules: FormRules
}
// 验证状态枚举
export enum ValidateStatus {
Success = 'success',
Error = 'error',
Validating = 'validating',
Pending = 'pending'
}

View File

@@ -188,7 +188,7 @@ async function cancel() {
<style scoped lang="scss">
$modal-mask-bg: rgba(#000, .45);
$modal-mask-bg: rgba(#000, .6);
$radius: .5rem;
$time: 0.3s;
$header-height: 4rem;
@@ -196,11 +196,9 @@ $header-height: 4rem;
@keyframes bounce-in {
0% {
opacity: 0;
transform: scale(0);
}
100% {
opacity: 1;
transform: scale(1);
}
}
@@ -259,7 +257,6 @@ $header-height: 4rem;
animation: bounce-in $time ease-out;
&.bounce-out {
transform: scale(0);
opacity: 0;
}
}

View File

@@ -4,11 +4,13 @@ import { Article } from "@/types/types.ts";
import BaseList from "@/components/list/BaseList.vue";
import BaseInput from "@/components/base/BaseInput.vue";
const props = withDefaults(defineProps<{
list: Article[],
showTranslate?: boolean
}>(), {
list: [],
interface IProps {
list: Article[];
showTranslate?: boolean;
}
const props = withDefaults(defineProps<IProps>(), {
list: () => [] as Article[],
showTranslate: true,
})
@@ -62,27 +64,23 @@ function scrollToItem(index: number) {
listRef?.scrollToItem(index)
}
defineExpose({scrollToBottom, scrollToItem})
defineExpose({ scrollToBottom, scrollToItem })
</script>
<template>
<div class="list">
<div class="search">
<BaseInput
clearable
v-model="searchKey"
>
<BaseInput clearable v-model="searchKey">
<template #subfix>
<IconFluentSearch24Regular class="text-lg text-gray"/>
<IconFluentSearch24Regular class="text-lg text-gray" />
</template>
</BaseInput>
</div>
<BaseList
ref="listRef"
@click="(e:any) => emit('click',e)"
:list="localList"
v-bind="$attrs">
<BaseList ref="listRef"
@click="(e: any) => emit('click', e)"
:list="localList"
v-bind="$attrs">
<template v-slot:prefix="{ item, index }">
<slot name="prefix" :item="item" :index="index"></slot>
</template>
@@ -91,7 +89,7 @@ defineExpose({scrollToBottom, scrollToItem})
<div class="name"> {{ `${searchKey ? '' : (index + 1) + '. '}${item.title}` }}</div>
</div>
<div class="item-sub-title" v-if="item.titleTranslate && showTranslate">
<div class="item-translate"> {{ ` ${item.titleTranslate}` }}</div>
<div class="item-translate"> {{ ` ${item.titleTranslate}` }}</div>
</div>
</template>
<template v-slot:suffix="{ item, index }">

View File

@@ -5,13 +5,13 @@ import { nextTick, watch } from 'vue'
const props = withDefaults(defineProps<{
list?: any[],
activeIndex?: number,
activeId?: number,
activeId?: number | string,
isActive?: boolean
static?: boolean
}>(), {
list: [],
activeIndex: -1,
activeId: null,
activeId: '',
isActive: false,
static: true
})
@@ -94,7 +94,7 @@ function scrollToItem(index: number) {
function itemIsActive(item: any, index: number) {
return props.activeId ?
props.activeId === item.id
props.activeId == item.id
: props.activeIndex === index
}