init
This commit is contained in:
414
src/components/BasicControls/Field/index.vue
Normal file
414
src/components/BasicControls/Field/index.vue
Normal file
@ -0,0 +1,414 @@
|
||||
<template>
|
||||
<div class="field-container" :class="{
|
||||
'field-disabled': disabled,
|
||||
'field-drag-over': isDragOver,
|
||||
'field-has-value': hasValue
|
||||
}" @dragover.prevent="handleDragOver" @dragleave="handleDragLeave" @drop.prevent="handleDrop">
|
||||
<!-- 字段标签 -->
|
||||
<div class="field-label" v-if="label">
|
||||
{{ label }}
|
||||
</div>
|
||||
|
||||
<!-- 字段内容区域 -->
|
||||
<div class="field-content">
|
||||
<!-- 有值时显示的内容 -->
|
||||
<div v-if="hasValue" class="field-value">
|
||||
<!-- 图标 -->
|
||||
<div class="field-icon">
|
||||
<component :is="getIconComponent()" />
|
||||
</div>
|
||||
|
||||
<!-- 名称 -->
|
||||
<div class="field-name">
|
||||
{{ modelValue?.name || 'Unnamed' }}
|
||||
</div>
|
||||
|
||||
<!-- 清除按钮 -->
|
||||
<button class="field-clear" @click="clearValue" :disabled="disabled">
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 空值时显示的占位符 -->
|
||||
<div v-else class="field-placeholder">
|
||||
<div class="field-icon-placeholder">
|
||||
<component :is="getIconComponent()" />
|
||||
</div>
|
||||
<div class="field-text">
|
||||
None ({{ acceptedType }})
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 拖拽提示 -->
|
||||
<div v-if="isDragOver" class="field-drag-hint">
|
||||
<div class="drag-hint-text">
|
||||
{{ getDragHintText() }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, h } from 'vue'
|
||||
import type { FieldProps, FieldEmits, FieldValue, AcceptedType } from './types'
|
||||
import { userDragStore } from 'stores/Filed'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useCursorStore } from '@/stores/Cursor'
|
||||
const dragStore = userDragStore()
|
||||
const cursorStore = useCursorStore()
|
||||
const { dragType, dragData } = storeToRefs(dragStore)
|
||||
|
||||
// 图标组件
|
||||
const TextureIcon = () => h('svg', {
|
||||
width: '16',
|
||||
height: '16',
|
||||
viewBox: '0 0 24 24',
|
||||
fill: 'currentColor'
|
||||
}, [
|
||||
h('path', {
|
||||
d: 'M8.5,13.5L11,16.5L14.5,12L19,18H5M21,19V5C21,3.89 20.1,3 19,3H5A2,2 0 0,0 3,5V19A2,2 0 0,0 5,21H19A2,2 0 0,0 21,19Z'
|
||||
})
|
||||
])
|
||||
|
||||
const MeshIcon = () => h('svg', {
|
||||
width: '16',
|
||||
height: '16',
|
||||
viewBox: '0 0 24 24',
|
||||
fill: 'currentColor'
|
||||
}, [
|
||||
h('path', {
|
||||
d: 'M12,2L13.09,8.26L22,9L17.74,15.74L19,22L12,19L5,22L6.26,15.74L2,9L10.91,8.26L12,2Z'
|
||||
})
|
||||
])
|
||||
|
||||
const MaterialIcon = () => h('svg', {
|
||||
width: '16',
|
||||
height: '16',
|
||||
viewBox: '0 0 24 24',
|
||||
fill: 'currentColor'
|
||||
}, [
|
||||
h('path', {
|
||||
d: 'M12,2A10,10 0 0,1 22,12A10,10 0 0,1 12,22A10,10 0 0,1 2,12A10,10 0 0,1 12,2M12,4A8,8 0 0,0 4,12A8,8 0 0,0 12,20A8,8 0 0,0 20,12A8,8 0 0,0 12,4Z'
|
||||
})
|
||||
])
|
||||
|
||||
const props = withDefaults(defineProps<FieldProps>(), {
|
||||
disabled: false,
|
||||
acceptedType: 'Texture',
|
||||
allowClear: true
|
||||
})
|
||||
|
||||
const emit = defineEmits<FieldEmits>()
|
||||
|
||||
// 状态
|
||||
const isDragOver = ref(false)
|
||||
|
||||
// 计算属性
|
||||
const hasValue = computed(() => props.modelValue !== null && props.modelValue !== undefined)
|
||||
|
||||
// 获取图标组件
|
||||
const getIconComponent = () => {
|
||||
switch (props.acceptedType) {
|
||||
case 'Texture':
|
||||
return TextureIcon
|
||||
case 'Mesh':
|
||||
return MeshIcon
|
||||
case 'Material':
|
||||
return MaterialIcon
|
||||
default:
|
||||
return TextureIcon
|
||||
}
|
||||
}
|
||||
|
||||
// 获取拖拽提示文本
|
||||
const getDragHintText = () => {
|
||||
return `Drop ${props.acceptedType} here`
|
||||
}
|
||||
|
||||
// 检查拖拽的元素是否为支持的类型
|
||||
const isValidDragItem = (dragData: any): boolean => {
|
||||
if (!dragData) return false
|
||||
|
||||
// 检查类型是否匹配
|
||||
if (dragData.type !== props.acceptedType) {
|
||||
return false
|
||||
}
|
||||
|
||||
// 可以添加更多的验证逻辑
|
||||
// 例如检查文件扩展名、MIME类型等
|
||||
if (props.acceptedType === 'Texture') {
|
||||
const validExtensions = ['.png', '.jpg', '.jpeg', '.tga', '.bmp', '.gif']
|
||||
const fileName = dragData.name || ''
|
||||
return validExtensions.some(ext => fileName.toLowerCase().endsWith(ext))
|
||||
}
|
||||
|
||||
if (props.acceptedType === 'Mesh') {
|
||||
const validExtensions = ['.fbx', '.obj', '.dae', '.3ds', '.blend']
|
||||
const fileName = dragData.name || ''
|
||||
return validExtensions.some(ext => fileName.toLowerCase().endsWith(ext))
|
||||
}
|
||||
|
||||
if (props.acceptedType === 'Material') {
|
||||
const validExtensions = ['.mat', '.material']
|
||||
const fileName = dragData.name || ''
|
||||
return validExtensions.some(ext => fileName.toLowerCase().endsWith(ext))
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// 拖拽事件处理
|
||||
const handleDragOver = (event: DragEvent) => {
|
||||
|
||||
if (props.disabled) return
|
||||
|
||||
try {
|
||||
const match = dragType.value == props.acceptedType
|
||||
if (match) {
|
||||
isDragOver.value = true
|
||||
cursorStore.setCursor('default')
|
||||
} else {
|
||||
cursorStore.setCursor('not-allowed')
|
||||
}
|
||||
} catch {
|
||||
// 如果无法解析数据,则拒绝拖拽
|
||||
cursorStore.setCursor('not-allowed')
|
||||
}
|
||||
}
|
||||
|
||||
const handleDragLeave = () => {
|
||||
isDragOver.value = false
|
||||
}
|
||||
|
||||
const handleDrop = (event: DragEvent) => {
|
||||
if (props.disabled) return
|
||||
|
||||
isDragOver.value = false
|
||||
|
||||
|
||||
try {
|
||||
const match = dragType.value == props.acceptedType
|
||||
if (match) {
|
||||
|
||||
if (isValidDragItem(dragData.value)) {
|
||||
const fieldValue: FieldValue = {
|
||||
id: dragData.value.id || Date.now().toString(),
|
||||
name: dragData.value.name || 'Unnamed',
|
||||
type: dragData.value.type,
|
||||
path: dragData.value.path || '',
|
||||
metadata: dragData.value.metadata || {}
|
||||
}
|
||||
|
||||
emit('update:modelValue', fieldValue)
|
||||
emit('change', fieldValue)
|
||||
} else {
|
||||
// 发出错误事件
|
||||
emit('error', {
|
||||
type: 'invalid_type',
|
||||
message: `Cannot assign ${dragData.value.type || 'unknown'} to ${props.acceptedType} field`,
|
||||
expectedType: props.acceptedType,
|
||||
actualType: dragData.value.type
|
||||
})
|
||||
}
|
||||
|
||||
} else {
|
||||
cursorStore.setCursor('not-allowed')
|
||||
}
|
||||
} catch {
|
||||
// 如果无法解析数据,则拒绝拖拽
|
||||
cursorStore.setCursor('not-allowed')
|
||||
}
|
||||
|
||||
// try {
|
||||
// const dragData = JSON.parse(event.dataTransfer?.getData('application/json') || '{}')
|
||||
|
||||
// if (isValidDragItem(dragData)) {
|
||||
// const fieldValue: FieldValue = {
|
||||
// id: dragData.id || Date.now().toString(),
|
||||
// name: dragData.name || 'Unnamed',
|
||||
// type: dragData.type,
|
||||
// path: dragData.path || '',
|
||||
// metadata: dragData.metadata || {}
|
||||
// }
|
||||
|
||||
// emit('update:modelValue', fieldValue)
|
||||
// emit('change', fieldValue)
|
||||
// } else {
|
||||
// // 发出错误事件
|
||||
// emit('error', {
|
||||
// type: 'invalid_type',
|
||||
// message: `Cannot assign ${dragData.type || 'unknown'} to ${props.acceptedType} field`,
|
||||
// expectedType: props.acceptedType,
|
||||
// actualType: dragData.type
|
||||
// })
|
||||
// }
|
||||
// } catch (error) {
|
||||
// emit('error', {
|
||||
// type: 'parse_error',
|
||||
// message: 'Failed to parse drag data',
|
||||
// error
|
||||
// })
|
||||
// }
|
||||
}
|
||||
|
||||
// 清除值
|
||||
const clearValue = () => {
|
||||
if (props.disabled || !props.allowClear) return
|
||||
|
||||
emit('update:modelValue', null)
|
||||
emit('change', null)
|
||||
emit('clear')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.field-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.field-label {
|
||||
color: #cccccc;
|
||||
font-size: 10px;
|
||||
font-weight: 500;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.field-content {
|
||||
position: relative;
|
||||
min-height: 20px;
|
||||
background: #2d2d2d;
|
||||
border: 1px solid #4a4a4a;
|
||||
border-radius: 2px;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.field-container.field-drag-over .field-content {
|
||||
border-color: #409eff;
|
||||
background: #353535;
|
||||
box-shadow: 0 0 0 1px rgba(64, 158, 255, 0.2);
|
||||
}
|
||||
|
||||
.field-container.field-disabled .field-content {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.field-container.field-has-value .field-content {
|
||||
border-color: #5a5a5a;
|
||||
}
|
||||
|
||||
.field-value {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 3px 6px;
|
||||
gap: 6px;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.field-placeholder {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 3px 6px;
|
||||
gap: 6px;
|
||||
height: 100%;
|
||||
color: #888888;
|
||||
}
|
||||
|
||||
.field-icon,
|
||||
.field-icon-placeholder {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
flex-shrink: 0;
|
||||
color: #888888;
|
||||
}
|
||||
|
||||
.field-has-value .field-icon {
|
||||
color: #cccccc;
|
||||
}
|
||||
|
||||
.field-name {
|
||||
flex: 1;
|
||||
color: #cccccc;
|
||||
font-size: 10px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.field-text {
|
||||
flex: 1;
|
||||
font-size: 10px;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.field-clear {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
background: none;
|
||||
border: none;
|
||||
color: #888888;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 1px;
|
||||
font-size: 12px;
|
||||
line-height: 1;
|
||||
transition: all 0.2s ease;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.field-clear:hover {
|
||||
background: #4a4a4a;
|
||||
color: #cccccc;
|
||||
}
|
||||
|
||||
.field-clear:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.field-drag-hint {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(64, 158, 255, 0.1);
|
||||
border: 2px dashed #409eff;
|
||||
border-radius: 2px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.drag-hint-text {
|
||||
color: #409eff;
|
||||
font-size: 9px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* 悬停效果 */
|
||||
.field-content:hover {
|
||||
border-color: #5a5a5a;
|
||||
}
|
||||
|
||||
.field-container.field-disabled .field-content:hover {
|
||||
border-color: #4a4a4a;
|
||||
}
|
||||
|
||||
/* 焦点效果 */
|
||||
.field-content:focus-within {
|
||||
border-color: #409eff;
|
||||
box-shadow: 0 0 0 1px rgba(64, 158, 255, 0.2);
|
||||
}
|
||||
</style>
|
||||
34
src/components/BasicControls/Field/types.ts
Normal file
34
src/components/BasicControls/Field/types.ts
Normal file
@ -0,0 +1,34 @@
|
||||
// Field 组件类型定义
|
||||
|
||||
export type AcceptedType = 'Texture' | 'Mesh' | 'Material'
|
||||
|
||||
export interface FieldValue {
|
||||
id: string
|
||||
name: string
|
||||
type: AcceptedType
|
||||
path: string
|
||||
metadata?: Record<string, any>
|
||||
}
|
||||
|
||||
export interface FieldError {
|
||||
type: 'invalid_type' | 'parse_error'
|
||||
message: string
|
||||
expectedType?: AcceptedType
|
||||
actualType?: string
|
||||
error?: any
|
||||
}
|
||||
|
||||
export interface FieldProps {
|
||||
modelValue?: FieldValue | null
|
||||
label?: string
|
||||
acceptedType?: AcceptedType
|
||||
disabled?: boolean
|
||||
allowClear?: boolean
|
||||
}
|
||||
|
||||
export interface FieldEmits {
|
||||
'update:modelValue': [value: FieldValue | null]
|
||||
'change': [value: FieldValue | null]
|
||||
'clear': []
|
||||
'error': [error: FieldError]
|
||||
}
|
||||
163
src/components/BasicControls/FileNode/index.css
Normal file
163
src/components/BasicControls/FileNode/index.css
Normal file
@ -0,0 +1,163 @@
|
||||
/* FileNode 组件样式 - 文件夹形式 */
|
||||
.file-node {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 2px 2px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
border-radius: 6px;
|
||||
position: relative;
|
||||
/* background: #353535; */
|
||||
border: 2px solid transparent;
|
||||
}
|
||||
|
||||
.file-node:hover {
|
||||
background-color: #404040;
|
||||
}
|
||||
|
||||
.file-node.dragging {
|
||||
opacity: 0.5;
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
.file-node.drag-over {
|
||||
background-color: rgba(64, 158, 255, 0.2);
|
||||
border: 2px dashed #409eff;
|
||||
}
|
||||
|
||||
/* 拖拽指示器 */
|
||||
.file-node.drag-over-before::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -2px;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 2px;
|
||||
background-color: #409eff;
|
||||
border-radius: 1px;
|
||||
}
|
||||
|
||||
.file-node.drag-over-after::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: -2px;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 2px;
|
||||
background-color: #409eff;
|
||||
border-radius: 1px;
|
||||
}
|
||||
|
||||
/* 文件图标 */
|
||||
.file-node-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 32px;
|
||||
margin-bottom: 4px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* 文件名 */
|
||||
.file-node-name {
|
||||
font-size: 11px;
|
||||
color: #cccccc;
|
||||
text-align: center;
|
||||
word-break: break-word;
|
||||
line-height: 1.2;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
padding: 2px 4px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
/* 选中效果只应用到文件名 */
|
||||
.file-node.selected .file-node-name {
|
||||
background-color: #2e70b3b7;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.file-node-name.editing {
|
||||
/* background-color: #2d2d2d;
|
||||
border: 1px solid #409eff; */
|
||||
border-radius: 2px;
|
||||
padding: 2px 4px;
|
||||
outline: none;
|
||||
width: 90%;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* 文件大小 */
|
||||
.file-node-size {
|
||||
font-size: 10px;
|
||||
color: #888;
|
||||
margin-top: 4px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* 拖拽鼠标样式 */
|
||||
.drag-cursor-grab {
|
||||
cursor: grab !important;
|
||||
}
|
||||
|
||||
.drag-cursor-grabbing {
|
||||
cursor: grabbing !important;
|
||||
}
|
||||
|
||||
.drag-cursor-copy {
|
||||
cursor: copy !important;
|
||||
}
|
||||
|
||||
.drag-cursor-move {
|
||||
cursor: move !important;
|
||||
}
|
||||
|
||||
.drag-cursor-not-allowed {
|
||||
cursor: not-allowed !important;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
/* 响应式设计 */
|
||||
@media (max-width: 768px) {
|
||||
.file-node {
|
||||
padding: 8px 6px;
|
||||
min-height: 70px;
|
||||
width: 80px;
|
||||
}
|
||||
|
||||
.file-node-icon {
|
||||
font-size: 28px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.file-node-name {
|
||||
font-size: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
/* 滚动条样式 */
|
||||
.file-node-container::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
.file-node-container::-webkit-scrollbar-track {
|
||||
background: #2d2d2d;
|
||||
}
|
||||
|
||||
.file-node-container::-webkit-scrollbar-thumb {
|
||||
background: #4a4a4a;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.file-node-container::-webkit-scrollbar-thumb:hover {
|
||||
background: #5a5a5a;
|
||||
}
|
||||
5
src/components/BasicControls/FileNode/index.ts
Normal file
5
src/components/BasicControls/FileNode/index.ts
Normal file
@ -0,0 +1,5 @@
|
||||
// 导出类型和常量
|
||||
export * from './types'
|
||||
|
||||
// 导出组件
|
||||
export { default } from './index.vue'
|
||||
258
src/components/BasicControls/FileNode/index.vue
Normal file
258
src/components/BasicControls/FileNode/index.vue
Normal file
@ -0,0 +1,258 @@
|
||||
<template>
|
||||
<div
|
||||
:class="nodeClasses"
|
||||
:style="nodeStyles"
|
||||
@click="handleClick"
|
||||
@dblclick="handleDoubleClick"
|
||||
@dragstart="handleDragStart"
|
||||
@dragend="handleDragEnd"
|
||||
@dragover="handleDragOver"
|
||||
@dragleave="handleDragLeave"
|
||||
@drop="handleDrop"
|
||||
:draggable="draggable"
|
||||
>
|
||||
<!-- 文件图标 -->
|
||||
<div class="file-node-icon">
|
||||
{{ FILE_ICONS[node.type] }}
|
||||
</div>
|
||||
|
||||
<!-- 文件名 -->
|
||||
<div
|
||||
v-if="!isEditing"
|
||||
class="file-node-name"
|
||||
@dblclick.stop="startEdit"
|
||||
>
|
||||
{{ node.name }}
|
||||
</div>
|
||||
<input
|
||||
v-else
|
||||
ref="editInput"
|
||||
v-model="editName"
|
||||
class="file-node-name editing"
|
||||
@blur="finishEdit"
|
||||
@keyup.enter="finishEdit"
|
||||
@keyup.esc="cancelEdit"
|
||||
@click.stop
|
||||
/>
|
||||
|
||||
<!-- 文件大小(可选显示) -->
|
||||
<div
|
||||
v-if="showSize && node.size !== undefined"
|
||||
class="file-node-size"
|
||||
>
|
||||
{{ formatFileSize(node.size) }}
|
||||
</div>
|
||||
|
||||
<!-- 修改时间 -->
|
||||
<div
|
||||
v-if="showDetails && node.lastModified"
|
||||
class="file-node-date"
|
||||
>
|
||||
{{ formatDate(node.lastModified) }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, nextTick, watch } from 'vue'
|
||||
import { FileNode, FileNodeType, FILE_ICONS, DRAG_CURSORS } from './types'
|
||||
import { formatFileSize, formatDate } from '../../../utils/Tools'
|
||||
import './index.css'
|
||||
|
||||
interface Props {
|
||||
node: FileNode
|
||||
depth?: number
|
||||
draggable?: boolean
|
||||
showDetails?: boolean
|
||||
selectedId?: string
|
||||
dragState?: {
|
||||
isDragging: boolean
|
||||
dragNode: FileNode | null
|
||||
dragOverNode: FileNode | null
|
||||
dropPosition: 'before' | 'after' | 'inside' | null
|
||||
}
|
||||
showSize?: boolean
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'click', node: FileNode): void
|
||||
(e: 'double-click', node: FileNode): void
|
||||
(e: 'expand', node: FileNode): void
|
||||
(e: 'rename', node: FileNode, newName: string): void
|
||||
(e: 'drag-start', node: FileNode, event: DragEvent): void
|
||||
(e: 'drag-end', node: FileNode, event: DragEvent): void
|
||||
(e: 'drag-over', node: FileNode, event: DragEvent): void
|
||||
(e: 'drag-leave', node: FileNode, event: DragEvent): void
|
||||
(e: 'drop', targetNode: FileNode, dragNode: FileNode, position: string, event: DragEvent): void
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
depth: 0,
|
||||
draggable: true,
|
||||
showDetails: false,
|
||||
showSize: false
|
||||
})
|
||||
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
// 编辑状态
|
||||
const isEditing = ref(false)
|
||||
const editName = ref('')
|
||||
const editInput = ref<HTMLInputElement>()
|
||||
|
||||
// 计算属性
|
||||
const hasChildren = computed(() => {
|
||||
return props.node.children && props.node.children.length > 0
|
||||
})
|
||||
|
||||
const nodeClasses = computed(() => {
|
||||
const classes = [
|
||||
'file-node',
|
||||
`type-${props.node.type.toLowerCase()}`
|
||||
]
|
||||
|
||||
if (props.node.isSelected || props.selectedId === props.node.id) {
|
||||
classes.push('selected')
|
||||
}
|
||||
|
||||
if (props.node.isDragging) {
|
||||
classes.push('dragging')
|
||||
}
|
||||
|
||||
if (props.dragState?.dragOverNode?.id === props.node.id) {
|
||||
classes.push('drag-over')
|
||||
if (props.dragState.dropPosition === 'before') {
|
||||
classes.push('drag-over-before')
|
||||
} else if (props.dragState.dropPosition === 'after') {
|
||||
classes.push('drag-over-after')
|
||||
}
|
||||
}
|
||||
|
||||
return classes
|
||||
})
|
||||
|
||||
const nodeStyles = computed(() => {
|
||||
const styles: Record<string, string> = {}
|
||||
|
||||
if (props.dragState?.isDragging) {
|
||||
if (props.dragState.dragNode?.id === props.node.id) {
|
||||
styles.cursor = DRAG_CURSORS.GRABBING
|
||||
} else {
|
||||
styles.cursor = DRAG_CURSORS.GRAB
|
||||
}
|
||||
}
|
||||
|
||||
return styles
|
||||
})
|
||||
|
||||
// 事件处理
|
||||
const handleClick = (event: MouseEvent) => {
|
||||
if (!isEditing.value) {
|
||||
emit('click', props.node)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDoubleClick = (event: MouseEvent) => {
|
||||
if (!isEditing.value) {
|
||||
emit('double-click', props.node)
|
||||
}
|
||||
}
|
||||
|
||||
const toggleExpand = () => {
|
||||
if (props.node.type === FileNodeType.Folder) {
|
||||
emit('expand', props.node)
|
||||
}
|
||||
}
|
||||
|
||||
// 编辑功能
|
||||
const startEdit = () => {
|
||||
if (props.node.type !== FileNodeType.Folder) {
|
||||
isEditing.value = true
|
||||
editName.value = props.node.name
|
||||
nextTick(() => {
|
||||
editInput.value?.focus()
|
||||
editInput.value?.select()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const finishEdit = () => {
|
||||
if (editName.value.trim() && editName.value !== props.node.name) {
|
||||
emit('rename', props.node, editName.value.trim())
|
||||
}
|
||||
isEditing.value = false
|
||||
}
|
||||
|
||||
const cancelEdit = () => {
|
||||
editName.value = props.node.name
|
||||
isEditing.value = false
|
||||
}
|
||||
|
||||
// 拖拽功能
|
||||
const handleDragStart = (event: DragEvent) => {
|
||||
if (!props.draggable) {
|
||||
event.preventDefault()
|
||||
return
|
||||
}
|
||||
|
||||
event.dataTransfer!.effectAllowed = 'move'
|
||||
event.dataTransfer!.setData('text/plain', props.node.id)
|
||||
|
||||
// 设置拖拽图像
|
||||
const dragImage = event.currentTarget as HTMLElement
|
||||
event.dataTransfer!.setDragImage(dragImage, 0, 0)
|
||||
|
||||
emit('drag-start', props.node, event)
|
||||
}
|
||||
|
||||
const handleDragEnd = (event: DragEvent) => {
|
||||
emit('drag-end', props.node, event)
|
||||
}
|
||||
|
||||
const handleDragOver = (event: DragEvent) => {
|
||||
event.preventDefault()
|
||||
event.dataTransfer!.dropEffect = 'move'
|
||||
|
||||
// 计算拖拽位置
|
||||
const rect = (event.currentTarget as HTMLElement).getBoundingClientRect()
|
||||
const y = event.clientY - rect.top
|
||||
const height = rect.height
|
||||
|
||||
let position: 'before' | 'after' | 'inside' = 'inside'
|
||||
|
||||
if (props.node.type === FileNodeType.Folder) {
|
||||
if (y < height * 0.25) {
|
||||
position = 'before'
|
||||
} else if (y > height * 0.75) {
|
||||
position = 'after'
|
||||
} else {
|
||||
position = 'inside'
|
||||
}
|
||||
} else {
|
||||
position = y < height * 0.5 ? 'before' : 'after'
|
||||
}
|
||||
|
||||
emit('drag-over', props.node, event)
|
||||
}
|
||||
|
||||
const handleDragLeave = (event: DragEvent) => {
|
||||
emit('drag-leave', props.node, event)
|
||||
}
|
||||
|
||||
const handleDrop = (event: DragEvent) => {
|
||||
event.preventDefault()
|
||||
|
||||
const dragNodeId = event.dataTransfer!.getData('text/plain')
|
||||
if (dragNodeId && props.dragState?.dragNode) {
|
||||
const position = props.dragState.dropPosition || 'inside'
|
||||
emit('drop', props.node, props.dragState.dragNode, position, event)
|
||||
}
|
||||
}
|
||||
|
||||
// 监听选中状态变化
|
||||
watch(() => props.selectedId, (newId) => {
|
||||
if (newId !== props.node.id && isEditing.value) {
|
||||
cancelEdit()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
82
src/components/BasicControls/FileNode/types.ts
Normal file
82
src/components/BasicControls/FileNode/types.ts
Normal file
@ -0,0 +1,82 @@
|
||||
// 文件节点类型枚举
|
||||
export enum FileNodeType {
|
||||
Text = 'Text',
|
||||
Folder = 'Folder',
|
||||
Model = 'Model',
|
||||
Audio = 'Audio',
|
||||
Video = 'Video',
|
||||
Material = 'Material',
|
||||
Texture = 'Texture',
|
||||
Image = 'Image',
|
||||
Script = 'Script',
|
||||
Scene = 'Scene'
|
||||
}
|
||||
|
||||
// 文件节点接口
|
||||
export interface FileNode {
|
||||
id: string
|
||||
name: string
|
||||
type: FileNodeType
|
||||
path: string
|
||||
size?: number
|
||||
lastModified?: Date
|
||||
children?: FileNode[]
|
||||
parent?: string
|
||||
isExpanded?: boolean
|
||||
isSelected?: boolean
|
||||
isDragging?: boolean
|
||||
}
|
||||
|
||||
// 拖拽状态
|
||||
export interface DragState {
|
||||
isDragging: boolean
|
||||
dragNode: FileNode | null
|
||||
dragOverNode: FileNode | null
|
||||
dropPosition: 'before' | 'after' | 'inside' | null
|
||||
}
|
||||
|
||||
// 文件图标映射
|
||||
export const FILE_ICONS: Record<FileNodeType, string> = {
|
||||
[FileNodeType.Text]: '📄',
|
||||
[FileNodeType.Folder]: '📁',
|
||||
[FileNodeType.Model]: '🎯',
|
||||
[FileNodeType.Audio]: '🎵',
|
||||
[FileNodeType.Video]: '🎬',
|
||||
[FileNodeType.Material]: '🎨',
|
||||
[FileNodeType.Texture]: '🖼️',
|
||||
[FileNodeType.Image]: '🖼️',
|
||||
[FileNodeType.Script]: '📜',
|
||||
[FileNodeType.Scene]: '🌍'
|
||||
}
|
||||
|
||||
// 文件扩展名映射
|
||||
export const FILE_EXTENSIONS: Record<string, FileNodeType> = {
|
||||
'.txt': FileNodeType.Text,
|
||||
'.md': FileNodeType.Text,
|
||||
'.js': FileNodeType.Script,
|
||||
'.ts': FileNodeType.Script,
|
||||
'.vue': FileNodeType.Script,
|
||||
'.fbx': FileNodeType.Model,
|
||||
'.obj': FileNodeType.Model,
|
||||
'.gltf': FileNodeType.Model,
|
||||
'.glb': FileNodeType.Model,
|
||||
'.mp3': FileNodeType.Audio,
|
||||
'.wav': FileNodeType.Audio,
|
||||
'.mp4': FileNodeType.Video,
|
||||
'.avi': FileNodeType.Video,
|
||||
'.png': FileNodeType.Image,
|
||||
'.jpg': FileNodeType.Image,
|
||||
'.jpeg': FileNodeType.Image,
|
||||
'.gif': FileNodeType.Image,
|
||||
'.mat': FileNodeType.Material,
|
||||
'.material': FileNodeType.Material
|
||||
}
|
||||
|
||||
// 拖拽鼠标样式
|
||||
export const DRAG_CURSORS = {
|
||||
GRAB: 'grab',
|
||||
GRABBING: 'grabbing',
|
||||
COPY: 'copy',
|
||||
MOVE: 'move',
|
||||
NOT_ALLOWED: 'not-allowed'
|
||||
}
|
||||
104
src/components/BasicControls/Input/ColorInput.vue
Normal file
104
src/components/BasicControls/Input/ColorInput.vue
Normal file
@ -0,0 +1,104 @@
|
||||
<template>
|
||||
<div class="color-input">
|
||||
<div class="color-preview" :style="{ backgroundColor: modelValue }" @click="openColorPicker"></div>
|
||||
<input
|
||||
ref="colorInput"
|
||||
type="color"
|
||||
:value="modelValue"
|
||||
@input="updateColor"
|
||||
class="color-picker"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
:value="modelValue"
|
||||
@input="updateColorText"
|
||||
@blur="validateColor"
|
||||
class="color-text"
|
||||
placeholder="#ffffff"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
|
||||
// 本地类型定义
|
||||
interface ColorInputProps {
|
||||
modelValue: string
|
||||
}
|
||||
|
||||
interface ColorInputEmits {
|
||||
(e: 'update:modelValue', value: string): void
|
||||
}
|
||||
|
||||
const props = defineProps<ColorInputProps>()
|
||||
const emit = defineEmits<ColorInputEmits>()
|
||||
|
||||
const colorInput = ref<HTMLInputElement>()
|
||||
|
||||
const openColorPicker = () => {
|
||||
colorInput.value?.click()
|
||||
}
|
||||
|
||||
const updateColor = (event: Event) => {
|
||||
const target = event.target as HTMLInputElement
|
||||
emit('update:modelValue', target.value)
|
||||
}
|
||||
|
||||
const updateColorText = (event: Event) => {
|
||||
const target = event.target as HTMLInputElement
|
||||
emit('update:modelValue', target.value)
|
||||
}
|
||||
|
||||
const validateColor = (event: Event) => {
|
||||
const target = event.target as HTMLInputElement
|
||||
const value = target.value
|
||||
|
||||
// 验证颜色格式
|
||||
if (!/^#[0-9A-Fa-f]{6}$/.test(value)) {
|
||||
target.value = props.modelValue
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.color-input {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.color-preview {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border: 1px solid #333333;
|
||||
border-radius: 2px;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.color-picker {
|
||||
position: absolute;
|
||||
left: -9999px;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.color-text {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding: 2px 4px;
|
||||
background: #2a2a2a;
|
||||
border: 1px solid #333333;
|
||||
border-radius: 2px;
|
||||
color: #ffffff;
|
||||
font-size: 11px;
|
||||
font-family: 'Consolas', 'Monaco', monospace;
|
||||
}
|
||||
|
||||
.color-text:focus {
|
||||
outline: none;
|
||||
border-color: #007acc;
|
||||
}
|
||||
</style>
|
||||
71
src/components/BasicControls/Input/TextInput.vue
Normal file
71
src/components/BasicControls/Input/TextInput.vue
Normal file
@ -0,0 +1,71 @@
|
||||
<template>
|
||||
<input
|
||||
type="text"
|
||||
:value="modelValue"
|
||||
@input="handleInput"
|
||||
@focus="onFocus"
|
||||
:placeholder="placeholder"
|
||||
:class="['text-input', { 'center': textAlign === 'center', 'uppercase': uppercase }]"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { TextInputProps, TextInputEmits } from './types'
|
||||
|
||||
const props = withDefaults(defineProps<TextInputProps>(), {
|
||||
textAlign: 'left',
|
||||
uppercase: false
|
||||
})
|
||||
|
||||
const emit = defineEmits<TextInputEmits>()
|
||||
|
||||
const handleInput = (e: Event) => {
|
||||
const target = e.target as HTMLInputElement
|
||||
emit('update:modelValue', target.value)
|
||||
}
|
||||
|
||||
const onFocus = (e: Event) => {
|
||||
(e.target as HTMLInputElement).select()
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.text-input {
|
||||
width: 100%;
|
||||
height: 16px;
|
||||
background: #2d2d2d;
|
||||
border: 1px solid #4a4a4a;
|
||||
border-radius: 2px;
|
||||
color: #cccccc;
|
||||
font-size: 11px;
|
||||
padding: 0 3px;
|
||||
text-align: left;
|
||||
outline: none;
|
||||
font-family: inherit;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.text-input.center {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.text-input.uppercase {
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.text-input:focus {
|
||||
border-color: #409eff;
|
||||
background: #353535;
|
||||
}
|
||||
|
||||
.text-input:hover {
|
||||
border-color: #5a5a5a;
|
||||
}
|
||||
|
||||
/* 容器自适应 */
|
||||
@container (max-width: 200px) {
|
||||
.text-input {
|
||||
font-size: 10px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
92
src/components/BasicControls/Input/index.vue
Normal file
92
src/components/BasicControls/Input/index.vue
Normal file
@ -0,0 +1,92 @@
|
||||
<template>
|
||||
<input
|
||||
type="number"
|
||||
:value="modelValue"
|
||||
@input="handleInput"
|
||||
@focus="onFocus"
|
||||
:step="step"
|
||||
:min="min"
|
||||
:max="max"
|
||||
:placeholder="placeholder"
|
||||
:class="['number-input', { 'full-width': fullWidth }]"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { NumberInputProps, NumberInputEmits } from './types'
|
||||
|
||||
const props = withDefaults(defineProps<NumberInputProps>(), {
|
||||
step: 0.1,
|
||||
fullWidth: false
|
||||
})
|
||||
|
||||
const emit = defineEmits<NumberInputEmits>()
|
||||
|
||||
const handleInput = (e: Event) => {
|
||||
const target = e.target as HTMLInputElement
|
||||
const value = parseFloat(target.value)
|
||||
if (!isNaN(value)) {
|
||||
emit('update:modelValue', value)
|
||||
}
|
||||
}
|
||||
|
||||
const onFocus = (e: Event) => {
|
||||
(e.target as HTMLInputElement).select()
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.number-input {
|
||||
width: 100%;
|
||||
min-width: 30px;
|
||||
|
||||
height: 16px;
|
||||
background: #2d2d2d;
|
||||
border: 1px solid #4a4a4a;
|
||||
border-radius: 2px;
|
||||
color: #cccccc;
|
||||
font-size: 11px;
|
||||
padding: 0 3px;
|
||||
text-align: right;
|
||||
outline: none;
|
||||
font-family: inherit;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.number-input.full-width {
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
.number-input:focus {
|
||||
border-color: #409eff;
|
||||
background: #353535;
|
||||
}
|
||||
|
||||
.number-input:hover {
|
||||
border-color: #5a5a5a;
|
||||
}
|
||||
|
||||
/* 隐藏数字输入框的箭头 */
|
||||
.number-input::-webkit-outer-spin-button,
|
||||
.number-input::-webkit-inner-spin-button {
|
||||
-webkit-appearance: none;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.number-input[type="number"] {
|
||||
-moz-appearance: textfield;
|
||||
}
|
||||
|
||||
/* 容器自适应 */
|
||||
@container (max-width: 200px) {
|
||||
.number-input {
|
||||
font-size: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 250px) {
|
||||
.number-input {
|
||||
max-width: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
34
src/components/BasicControls/Input/types.ts
Normal file
34
src/components/BasicControls/Input/types.ts
Normal file
@ -0,0 +1,34 @@
|
||||
// NumberInput 组件类型
|
||||
export interface NumberInputProps {
|
||||
modelValue: number
|
||||
step?: number
|
||||
min?: number
|
||||
max?: number
|
||||
placeholder?: string
|
||||
fullWidth?: boolean
|
||||
}
|
||||
|
||||
export interface NumberInputEmits {
|
||||
(e: 'update:modelValue', value: number): void
|
||||
}
|
||||
|
||||
// TextInput 组件类型
|
||||
export interface TextInputProps {
|
||||
modelValue: string
|
||||
placeholder?: string
|
||||
textAlign?: 'left' | 'center' | 'right'
|
||||
uppercase?: boolean
|
||||
}
|
||||
|
||||
export interface TextInputEmits {
|
||||
(e: 'update:modelValue', value: string): void
|
||||
}
|
||||
|
||||
// ColorInput 组件类型
|
||||
export interface ColorInputProps {
|
||||
modelValue: string
|
||||
}
|
||||
|
||||
export interface ColorInputEmits {
|
||||
(e: 'update:modelValue', value: string): void
|
||||
}
|
||||
69
src/components/BasicControls/PropertyRow/index.vue
Normal file
69
src/components/BasicControls/PropertyRow/index.vue
Normal file
@ -0,0 +1,69 @@
|
||||
<template>
|
||||
<div class="property-row">
|
||||
<Tooltip v-if="tooltip" :content="tooltip" placement="top">
|
||||
<div class="property-label">{{ label }}</div>
|
||||
</Tooltip>
|
||||
<div v-else class="property-label">{{ label }}</div>
|
||||
<div class="property-content">
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Tooltip from '../../public/Tooltip.vue'
|
||||
import type { PropertyRowProps } from './types'
|
||||
|
||||
defineProps<PropertyRowProps>()
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.property-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 2px;
|
||||
min-height: 18px;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.property-label {
|
||||
width: 80px;
|
||||
color: #cccccc;
|
||||
font-size: 11px;
|
||||
flex-shrink: 0;
|
||||
text-align: left;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.property-content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* 响应式设计 */
|
||||
@media (max-width: 250px) {
|
||||
.property-row {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.property-label {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.property-content {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
/* 容器自适应 */
|
||||
@container (max-width: 200px) {
|
||||
.property-label {
|
||||
width: 60px;
|
||||
font-size: 10px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
5
src/components/BasicControls/PropertyRow/types.ts
Normal file
5
src/components/BasicControls/PropertyRow/types.ts
Normal file
@ -0,0 +1,5 @@
|
||||
// PropertyRow 组件类型定义
|
||||
export interface PropertyRowProps {
|
||||
label: string
|
||||
tooltip?: string
|
||||
}
|
||||
66
src/components/BasicControls/Select/index.vue
Normal file
66
src/components/BasicControls/Select/index.vue
Normal file
@ -0,0 +1,66 @@
|
||||
<template>
|
||||
<select
|
||||
:value="modelValue"
|
||||
@change="handleChange"
|
||||
class="select-input"
|
||||
>
|
||||
<option
|
||||
v-for="option in options"
|
||||
:key="option.value"
|
||||
:value="option.value"
|
||||
>
|
||||
{{ option.label }}
|
||||
</option>
|
||||
</select>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { SelectProps, SelectEmits } from './types'
|
||||
|
||||
defineProps<SelectProps>()
|
||||
const emit = defineEmits<SelectEmits>()
|
||||
|
||||
const handleChange = (e: Event) => {
|
||||
const target = e.target as HTMLSelectElement
|
||||
const value = target.value
|
||||
emit('update:modelValue', value)
|
||||
emit('change', value)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.select-input {
|
||||
width: 100%;
|
||||
height: 18px;
|
||||
background: #2d2d2d;
|
||||
border: 1px solid #4a4a4a;
|
||||
border-radius: 2px;
|
||||
color: #cccccc;
|
||||
font-size: 11px;
|
||||
padding: 0 3px;
|
||||
outline: none;
|
||||
font-family: inherit;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.select-input option {
|
||||
background: #2d2d2d;
|
||||
color: #cccccc;
|
||||
}
|
||||
|
||||
.select-input:focus {
|
||||
border-color: #409eff;
|
||||
background: #353535;
|
||||
}
|
||||
|
||||
.select-input:hover {
|
||||
border-color: #5a5a5a;
|
||||
}
|
||||
|
||||
/* 容器自适应 */
|
||||
@container (max-width: 200px) {
|
||||
.select-input {
|
||||
font-size: 10px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
15
src/components/BasicControls/Select/types.ts
Normal file
15
src/components/BasicControls/Select/types.ts
Normal file
@ -0,0 +1,15 @@
|
||||
// Select 组件类型定义
|
||||
export interface SelectOption {
|
||||
value: string | number
|
||||
label: string
|
||||
}
|
||||
|
||||
export interface SelectProps {
|
||||
modelValue: string | number
|
||||
options: SelectOption[]
|
||||
}
|
||||
|
||||
export interface SelectEmits {
|
||||
(e: 'update:modelValue', value: string | number): void
|
||||
(e: 'change', value: string | number): void
|
||||
}
|
||||
326
src/components/BasicControls/Slider/index.vue
Normal file
326
src/components/BasicControls/Slider/index.vue
Normal file
@ -0,0 +1,326 @@
|
||||
<template>
|
||||
<div :class="['slider-wrapper', { 'slider-vertical': vertical }]">
|
||||
<div
|
||||
ref="sliderTrack"
|
||||
:class="['slider-track', { 'slider-track-vertical': vertical }]"
|
||||
@mousedown="handleMouseDown"
|
||||
>
|
||||
<div
|
||||
:class="['slider-fill', { 'slider-fill-vertical': vertical, 'no-transition': isDragging }]"
|
||||
:style="fillStyle"
|
||||
></div>
|
||||
<div
|
||||
ref="sliderThumb"
|
||||
:class="['slider-thumb', { 'slider-thumb-vertical': vertical, 'dragging': isDragging }]"
|
||||
:style="thumbStyle"
|
||||
@mousedown="handleThumbMouseDown"
|
||||
></div>
|
||||
</div>
|
||||
|
||||
<!-- 数值显示 -->
|
||||
<div v-if="showValue && !showInput" class="slider-value">{{ formatValue(modelValue) }}</div>
|
||||
|
||||
<!-- 输入框 -->
|
||||
<NumberInput
|
||||
v-if="showInput"
|
||||
:model-value="modelValue"
|
||||
@update:model-value="handleInputChange"
|
||||
:min="min"
|
||||
:max="max"
|
||||
:step="step"
|
||||
class="slider-input"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import NumberInput from '../Input/index.vue'
|
||||
import type { SliderProps, SliderEmits } from './types'
|
||||
|
||||
const props = withDefaults(defineProps<SliderProps>(), {
|
||||
min: 0,
|
||||
max: 100,
|
||||
step: 1,
|
||||
showValue: false,
|
||||
showInput: true,
|
||||
vertical: false
|
||||
})
|
||||
|
||||
const emit = defineEmits<SliderEmits>()
|
||||
|
||||
const sliderTrack = ref<HTMLElement>()
|
||||
const sliderThumb = ref<HTMLElement>()
|
||||
const isDragging = ref(false)
|
||||
|
||||
// 计算百分比
|
||||
const percentage = computed(() => {
|
||||
const range = props.max - props.min
|
||||
if (range === 0) return 0
|
||||
return ((props.modelValue - props.min) / range) * 100
|
||||
})
|
||||
|
||||
// 填充样式
|
||||
const fillStyle = computed(() => {
|
||||
if (props.vertical) {
|
||||
return { height: `${percentage.value}%` }
|
||||
} else {
|
||||
return { width: `${percentage.value}%` }
|
||||
}
|
||||
})
|
||||
|
||||
// 滑块样式
|
||||
const thumbStyle = computed(() => {
|
||||
if (props.vertical) {
|
||||
return { bottom: `calc(${percentage.value}% - 6px)` }
|
||||
} else {
|
||||
return { left: `calc(${percentage.value}% - 6px)` }
|
||||
}
|
||||
})
|
||||
|
||||
// 格式化显示值
|
||||
const formatValue = (value: number) => {
|
||||
if (props.step < 1) {
|
||||
return value.toFixed(2)
|
||||
} else if (props.step < 0.1) {
|
||||
return value.toFixed(3)
|
||||
} else {
|
||||
return Math.round(value).toString()
|
||||
}
|
||||
}
|
||||
|
||||
// 处理输入框数值变化
|
||||
const handleInputChange = (value: number) => {
|
||||
// 确保值在范围内
|
||||
const clampedValue = Math.max(props.min, Math.min(props.max, value))
|
||||
|
||||
if (clampedValue !== props.modelValue) {
|
||||
emit('update:modelValue', clampedValue)
|
||||
emit('change', clampedValue)
|
||||
}
|
||||
}
|
||||
|
||||
// 处理轨道鼠标按下
|
||||
const handleMouseDown = (e: MouseEvent) => {
|
||||
// 如果点击的是滑块,不处理
|
||||
if (e.target === sliderThumb.value) return
|
||||
|
||||
isDragging.value = true
|
||||
updateValue(e)
|
||||
|
||||
// 使用 requestAnimationFrame 优化性能
|
||||
const handleMove = (e: MouseEvent) => {
|
||||
if (!isDragging.value) return
|
||||
requestAnimationFrame(() => updateValue(e))
|
||||
}
|
||||
|
||||
const handleUp = (e: MouseEvent) => {
|
||||
isDragging.value = false
|
||||
document.removeEventListener('mousemove', handleMove)
|
||||
document.removeEventListener('mouseup', handleUp)
|
||||
e.preventDefault()
|
||||
}
|
||||
|
||||
document.addEventListener('mousemove', handleMove)
|
||||
document.addEventListener('mouseup', handleUp)
|
||||
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
}
|
||||
|
||||
// 处理滑块鼠标按下
|
||||
const handleThumbMouseDown = (e: MouseEvent) => {
|
||||
isDragging.value = true
|
||||
|
||||
// 记录初始偏移量
|
||||
const rect = sliderTrack.value!.getBoundingClientRect()
|
||||
const thumbRect = sliderThumb.value!.getBoundingClientRect()
|
||||
|
||||
let offset = 0
|
||||
if (props.vertical) {
|
||||
offset = e.clientY - (thumbRect.top + thumbRect.height / 2)
|
||||
} else {
|
||||
offset = e.clientX - (thumbRect.left + thumbRect.width / 2)
|
||||
}
|
||||
|
||||
const handleMove = (e: MouseEvent) => {
|
||||
if (!isDragging.value) return
|
||||
|
||||
// 调整鼠标位置以考虑初始偏移
|
||||
const adjustedEvent = {
|
||||
...e,
|
||||
clientX: props.vertical ? e.clientX : e.clientX - offset,
|
||||
clientY: props.vertical ? e.clientY - offset : e.clientY
|
||||
}
|
||||
|
||||
requestAnimationFrame(() => updateValue(adjustedEvent as MouseEvent))
|
||||
}
|
||||
|
||||
const handleUp = (e: MouseEvent) => {
|
||||
isDragging.value = false
|
||||
document.removeEventListener('mousemove', handleMove)
|
||||
document.removeEventListener('mouseup', handleUp)
|
||||
e.preventDefault()
|
||||
}
|
||||
|
||||
document.addEventListener('mousemove', handleMove)
|
||||
document.addEventListener('mouseup', handleUp)
|
||||
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
}
|
||||
|
||||
// 更新值
|
||||
const updateValue = (e: MouseEvent) => {
|
||||
if (!sliderTrack.value) return
|
||||
|
||||
const rect = sliderTrack.value.getBoundingClientRect()
|
||||
let percentage: number
|
||||
|
||||
if (props.vertical) {
|
||||
percentage = (rect.bottom - e.clientY) / rect.height
|
||||
} else {
|
||||
percentage = (e.clientX - rect.left) / rect.width
|
||||
}
|
||||
|
||||
// 限制百分比在 0-1 之间
|
||||
percentage = Math.max(0, Math.min(1, percentage))
|
||||
|
||||
// 计算新值
|
||||
const range = props.max - props.min
|
||||
let newValue = props.min + percentage * range
|
||||
|
||||
// 应用步长
|
||||
if (props.step > 0) {
|
||||
newValue = Math.round(newValue / props.step) * props.step
|
||||
}
|
||||
|
||||
// 确保值在范围内
|
||||
newValue = Math.max(props.min, Math.min(props.max, newValue))
|
||||
|
||||
// 修复浮点数精度问题
|
||||
if (props.step < 1) {
|
||||
const decimals = props.step.toString().split('.')[1]?.length || 0
|
||||
newValue = parseFloat(newValue.toFixed(decimals))
|
||||
}
|
||||
|
||||
// 减少不必要的更新
|
||||
if (Math.abs(newValue - props.modelValue) > 0.0001) {
|
||||
emit('update:modelValue', newValue)
|
||||
emit('change', newValue)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.slider-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.slider-wrapper.slider-vertical {
|
||||
flex-direction: column;
|
||||
height: 100px;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.slider-track {
|
||||
position: relative;
|
||||
height: 4px;
|
||||
background: #4a4a4a;
|
||||
border-radius: 2px;
|
||||
cursor: pointer;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.slider-track.slider-track-vertical {
|
||||
width: 4px;
|
||||
height: 100%;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.slider-fill {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
height: 100%;
|
||||
background: #409eff;
|
||||
border-radius: 2px;
|
||||
transition: width 0.1s ease;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.slider-fill.no-transition {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.slider-fill.slider-fill-vertical {
|
||||
bottom: 0;
|
||||
top: auto;
|
||||
width: 100%;
|
||||
transition: height 0.1s ease;
|
||||
}
|
||||
|
||||
.slider-fill.slider-fill-vertical.no-transition {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.slider-thumb {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
background: #ffffff;
|
||||
border: 2px solid #409eff;
|
||||
border-radius: 50%;
|
||||
transform: translateY(-50%);
|
||||
cursor: grab;
|
||||
transition: all 0.1s ease;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.slider-thumb.dragging {
|
||||
transition: none;
|
||||
cursor: grabbing;
|
||||
transform: translateY(-50%) scale(1.1);
|
||||
}
|
||||
|
||||
.slider-thumb.slider-thumb-vertical {
|
||||
left: 50%;
|
||||
top: auto;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
|
||||
.slider-thumb.slider-thumb-vertical.dragging {
|
||||
transform: translateX(-50%) scale(1.1);
|
||||
}
|
||||
|
||||
.slider-thumb:hover:not(.dragging) {
|
||||
transform: translateY(-50%) scale(1.1);
|
||||
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
.slider-thumb.slider-thumb-vertical:hover:not(.dragging) {
|
||||
transform: translateX(-50%) scale(1.1);
|
||||
}
|
||||
|
||||
.slider-thumb:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.slider-value {
|
||||
min-width: 30px;
|
||||
color: #cccccc;
|
||||
font-size: 11px;
|
||||
text-align: center;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.slider-input {
|
||||
width: 60px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
</style>
|
||||
16
src/components/BasicControls/Slider/types.ts
Normal file
16
src/components/BasicControls/Slider/types.ts
Normal file
@ -0,0 +1,16 @@
|
||||
// Slider 组件类型定义
|
||||
export interface SliderProps {
|
||||
modelValue: number
|
||||
min?: number
|
||||
max?: number
|
||||
step?: number
|
||||
showValue?: boolean
|
||||
showInput?: boolean
|
||||
vertical?: boolean
|
||||
a?:number
|
||||
}
|
||||
|
||||
export interface SliderEmits {
|
||||
(e: 'update:modelValue', value: number): void
|
||||
(e: 'change', value: number): void
|
||||
}
|
||||
155
src/components/BasicControls/Switch/index.vue
Normal file
155
src/components/BasicControls/Switch/index.vue
Normal file
@ -0,0 +1,155 @@
|
||||
<template>
|
||||
<div class="switch-wrapper" :class="{ 'switch-disabled': disabled }">
|
||||
<input
|
||||
type="checkbox"
|
||||
:id="switchId"
|
||||
:checked="modelValue"
|
||||
:disabled="disabled"
|
||||
@change="handleChange"
|
||||
class="switch-checkbox"
|
||||
/>
|
||||
<label :for="switchId" class="switch-label" :class="[`switch-${size}`]"></label>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import type { SwitchProps, SwitchEmits } from './types'
|
||||
|
||||
const props = withDefaults(defineProps<SwitchProps>(), {
|
||||
disabled: false,
|
||||
size: 'medium'
|
||||
})
|
||||
|
||||
const emit = defineEmits<SwitchEmits>()
|
||||
|
||||
// 生成唯一的 ID
|
||||
const switchId = computed(() => `switch-${Math.random().toString(36).substr(2, 9)}`)
|
||||
|
||||
const handleChange = (event: Event) => {
|
||||
if (props.disabled) return
|
||||
|
||||
const target = event.target as HTMLInputElement
|
||||
const newValue = target.checked
|
||||
emit('update:modelValue', newValue)
|
||||
emit('change', newValue)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.switch-wrapper {
|
||||
height: 16px;
|
||||
width: 16px;
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.switch-wrapper.switch-disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.switch-checkbox {
|
||||
opacity: 0;
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.switch-checkbox:disabled {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.switch-label {
|
||||
display: inline-block;
|
||||
background: #2d2d2d;
|
||||
border: 1px solid #5a5a5a;
|
||||
border-radius: 2px;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
transition: all 0.15s ease;
|
||||
box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.switch-label:hover {
|
||||
border-color: #6a6a6a;
|
||||
background: #353535;
|
||||
}
|
||||
|
||||
/* 选中状态的勾选标记 */
|
||||
.switch-checkbox:checked + .switch-label::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
border: solid #ffffff;
|
||||
border-width: 0 2px 2px 0;
|
||||
transform: rotate(45deg);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
|
||||
/* Small size */
|
||||
.switch-small {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
}
|
||||
|
||||
.switch-checkbox:checked + .switch-small::after {
|
||||
left: 2px;
|
||||
top: -1px;
|
||||
width: 6px;
|
||||
height: 4px;
|
||||
border-width: 0 1px 1px 0;
|
||||
}
|
||||
|
||||
/* Medium size (default) */
|
||||
.switch-medium {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
.switch-checkbox:checked + .switch-medium::after {
|
||||
left: 2px;
|
||||
top: 0px;
|
||||
width: 8px;
|
||||
height: 6px;
|
||||
border-width: 0 1.5px 1.5px 0;
|
||||
}
|
||||
|
||||
/* Large size */
|
||||
.switch-large {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.switch-checkbox:checked + .switch-large::after {
|
||||
left: 3px;
|
||||
top: 1px;
|
||||
width: 9px;
|
||||
height: 7px;
|
||||
border-width: 0 2px 2px 0;
|
||||
}
|
||||
|
||||
/* Focus styles */
|
||||
.switch-checkbox:focus + .switch-label {
|
||||
outline: 2px solid #409eff;
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
/* Disabled state */
|
||||
.switch-disabled .switch-checkbox,
|
||||
.switch-disabled .switch-label {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.switch-disabled .switch-label:hover {
|
||||
border-color: #5a5a5a;
|
||||
background: #2d2d2d;
|
||||
}
|
||||
|
||||
.switch-disabled .switch-checkbox:checked + .switch-label:hover {
|
||||
background: #409eff;
|
||||
border-color: #409eff;
|
||||
}
|
||||
</style>
|
||||
11
src/components/BasicControls/Switch/types.ts
Normal file
11
src/components/BasicControls/Switch/types.ts
Normal file
@ -0,0 +1,11 @@
|
||||
// Switch 组件类型定义
|
||||
export interface SwitchProps {
|
||||
modelValue: boolean
|
||||
disabled?: boolean
|
||||
size?: 'small' | 'medium' | 'large'
|
||||
}
|
||||
|
||||
export interface SwitchEmits {
|
||||
(e: 'update:modelValue', value: boolean): void
|
||||
(e: 'change', value: boolean): void
|
||||
}
|
||||
19
src/components/BasicControls/index.ts
Normal file
19
src/components/BasicControls/index.ts
Normal file
@ -0,0 +1,19 @@
|
||||
// 基础组件导出
|
||||
export { default as PropertyRow } from './PropertyRow/index.vue'
|
||||
export { default as NumberInput } from './Input/index.vue'
|
||||
export { default as TextInput } from './Input/TextInput.vue'
|
||||
export { default as ColorInput } from './Input/ColorInput.vue'
|
||||
export { default as Select } from './Select/index.vue'
|
||||
export { default as Switch } from './Switch/index.vue'
|
||||
export { default as Slider } from './Slider/index.vue'
|
||||
export { default as Field } from './Field/index.vue'
|
||||
|
||||
// 类型导出
|
||||
export type { Vector3, RangeValue, SelectOption } from './types'
|
||||
export type { PropertyRowProps } from './PropertyRow/types'
|
||||
export type { NumberInputProps, NumberInputEmits, TextInputProps, TextInputEmits } from './Input/types'
|
||||
export type { SelectProps, SelectEmits } from './Select/types'
|
||||
export type { SwitchProps, SwitchEmits } from './Switch/types'
|
||||
export type { SliderProps, SliderEmits } from './Slider/types'
|
||||
export type { FieldProps, FieldEmits, FieldValue, FieldError, AcceptedType } from './Field/types'
|
||||
|
||||
17
src/components/BasicControls/types.ts
Normal file
17
src/components/BasicControls/types.ts
Normal file
@ -0,0 +1,17 @@
|
||||
// 基础组件通用类型定义
|
||||
|
||||
export interface Vector3 {
|
||||
x: number
|
||||
y: number
|
||||
z: number
|
||||
}
|
||||
|
||||
export interface RangeValue {
|
||||
min: number
|
||||
max: number
|
||||
}
|
||||
|
||||
export interface SelectOption {
|
||||
value: string | number
|
||||
label: string
|
||||
}
|
||||
Reference in New Issue
Block a user