This commit is contained in:
2025-12-26 16:45:58 +08:00
commit 1a20560753
190 changed files with 37841 additions and 0 deletions

View 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>

View 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]
}

View 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;
}

View File

@ -0,0 +1,5 @@
// 导出类型和常量
export * from './types'
// 导出组件
export { default } from './index.vue'

View 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>

View 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'
}

View 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>

View 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>

View 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>

View 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
}

View 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>

View File

@ -0,0 +1,5 @@
// PropertyRow 组件类型定义
export interface PropertyRowProps {
label: string
tooltip?: string
}

View 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>

View 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
}

View 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>

View 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
}

View 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>

View 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
}

View 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'

View 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
}