first commit
This commit is contained in:
336
src/components/ApprovalDrawer/index.vue
Normal file
336
src/components/ApprovalDrawer/index.vue
Normal file
@@ -0,0 +1,336 @@
|
||||
<template>
|
||||
<a-drawer
|
||||
v-model:open="visible"
|
||||
:title="title"
|
||||
placement="right"
|
||||
:width="600"
|
||||
:destroy-on-close="true"
|
||||
class="approval-drawer"
|
||||
>
|
||||
<!-- 业务信息 -->
|
||||
<div class="business-info">
|
||||
<a-descriptions :column="1" bordered size="small">
|
||||
<a-descriptions-item label="业务类型">
|
||||
<a-tag :color="scenarioColor">{{ scenarioLabel }}</a-tag>
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="业务标题">{{ businessTitle }}</a-descriptions-item>
|
||||
<a-descriptions-item label="申请金额" v-if="amount">
|
||||
<span class="money-amount">¥{{ amount.toLocaleString() }}</span>
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="申请人">{{ applicantName }}</a-descriptions-item>
|
||||
<a-descriptions-item label="申请时间">{{ formatDate(applyTime) }}</a-descriptions-item>
|
||||
<slot name="extra-info"></slot>
|
||||
</a-descriptions>
|
||||
</div>
|
||||
|
||||
<!-- 审批进度 -->
|
||||
<div class="approval-progress">
|
||||
<div class="section-title">
|
||||
<CheckCircleOutlined /> 审批进度
|
||||
</div>
|
||||
<a-steps :current="currentStep" direction="vertical" size="small" class="approval-steps">
|
||||
<a-step v-for="(node, index) in approvalNodes" :key="node.id" :status="getNodeStatus(node, index)">
|
||||
<template #title>
|
||||
<div class="step-title">
|
||||
<span>{{ node.name }}</span>
|
||||
<a-tag v-if="node.status" :color="getNodeStatusColor(node.status)" size="small">
|
||||
{{ getNodeStatusText(node.status) }}
|
||||
</a-tag>
|
||||
</div>
|
||||
</template>
|
||||
<template #description>
|
||||
<div class="step-desc">
|
||||
<div class="step-approver">
|
||||
<a-avatar v-if="node.approverAvatar" :src="node.approverAvatar" :size="20" />
|
||||
<span>{{ node.approverName || getApproverTypeText(node.approverType) }}</span>
|
||||
</div>
|
||||
<div class="step-time" v-if="node.operatedAt">
|
||||
{{ formatDate(node.operatedAt) }}
|
||||
</div>
|
||||
<div class="step-comment" v-if="node.comment">
|
||||
<MessageOutlined /> {{ node.comment }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</a-step>
|
||||
</a-steps>
|
||||
</div>
|
||||
|
||||
<!-- 审批操作区域 -->
|
||||
<div class="approval-action" v-if="showAction">
|
||||
<a-divider />
|
||||
<div class="section-title">
|
||||
<EditOutlined /> 审批操作
|
||||
</div>
|
||||
|
||||
<a-form layout="vertical">
|
||||
<a-form-item label="审批意见">
|
||||
<a-textarea
|
||||
v-model:value="approvalComment"
|
||||
:rows="3"
|
||||
placeholder="请输入审批意见(选填)"
|
||||
:maxlength="500"
|
||||
show-count
|
||||
/>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="审批结果">
|
||||
<a-radio-group v-model:value="approvalResult" button-style="solid" size="large">
|
||||
<a-radio-button value="approve">
|
||||
<CheckCircleOutlined /> 通过
|
||||
</a-radio-button>
|
||||
<a-radio-button value="reject">
|
||||
<CloseCircleOutlined /> 驳回
|
||||
</a-radio-button>
|
||||
</a-radio-group>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</div>
|
||||
|
||||
<!-- 底部按钮 -->
|
||||
<template #footer>
|
||||
<div class="drawer-footer">
|
||||
<a-button @click="handleClose">取消</a-button>
|
||||
<a-button
|
||||
v-if="showAction"
|
||||
type="primary"
|
||||
:loading="submitting"
|
||||
:danger="approvalResult === 'reject'"
|
||||
@click="handleSubmit"
|
||||
>
|
||||
{{ approvalResult === 'approve' ? '确认通过' : '确认驳回' }}
|
||||
</a-button>
|
||||
</div>
|
||||
</template>
|
||||
</a-drawer>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { message } from 'ant-design-vue'
|
||||
import {
|
||||
CheckCircleOutlined,
|
||||
CloseCircleOutlined,
|
||||
EditOutlined,
|
||||
MessageOutlined
|
||||
} from '@ant-design/icons-vue'
|
||||
import { ApprovalScenarioMap, ApproverTypeMap } from '@/types/approval'
|
||||
import type { ApprovalScenario, ApproverType } from '@/types/approval'
|
||||
|
||||
interface ApprovalNode {
|
||||
id: number
|
||||
name: string
|
||||
approverType: ApproverType
|
||||
approverName?: string
|
||||
approverAvatar?: string
|
||||
status?: 'pending' | 'approved' | 'rejected' | 'skipped'
|
||||
comment?: string
|
||||
operatedAt?: string
|
||||
order: number
|
||||
}
|
||||
|
||||
interface Props {
|
||||
open: boolean
|
||||
title?: string
|
||||
scenario: ApprovalScenario
|
||||
businessTitle: string
|
||||
amount?: number
|
||||
applicantName: string
|
||||
applyTime: string
|
||||
approvalNodes: ApprovalNode[]
|
||||
currentNodeIndex?: number
|
||||
showAction?: boolean
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
title: '审批详情',
|
||||
showAction: true,
|
||||
currentNodeIndex: 0
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:open', value: boolean): void
|
||||
(e: 'approve', data: { approved: boolean; comment: string }): void
|
||||
}>()
|
||||
|
||||
const visible = computed({
|
||||
get: () => props.open,
|
||||
set: (val) => emit('update:open', val)
|
||||
})
|
||||
|
||||
const approvalComment = ref('')
|
||||
const approvalResult = ref<'approve' | 'reject'>('approve')
|
||||
const submitting = ref(false)
|
||||
|
||||
const scenarioLabel = computed(() => {
|
||||
return ApprovalScenarioMap[props.scenario] || props.scenario
|
||||
})
|
||||
|
||||
const scenarioColor = computed(() => {
|
||||
const colorMap: Record<string, string> = {
|
||||
expense_reimbursement: 'orange',
|
||||
payment_request: 'blue',
|
||||
purchase_request: 'purple',
|
||||
budget_adjustment: 'gold',
|
||||
invoice_apply: 'cyan',
|
||||
withdrawal: 'green',
|
||||
contract: 'magenta'
|
||||
}
|
||||
return colorMap[props.scenario] || 'blue'
|
||||
})
|
||||
|
||||
const currentStep = computed(() => {
|
||||
return props.currentNodeIndex
|
||||
})
|
||||
|
||||
function getNodeStatus(node: ApprovalNode, index: number): 'wait' | 'process' | 'finish' | 'error' {
|
||||
if (node.status === 'approved') return 'finish'
|
||||
if (node.status === 'rejected') return 'error'
|
||||
if (index === props.currentNodeIndex) return 'process'
|
||||
if (index < props.currentNodeIndex) return 'finish'
|
||||
return 'wait'
|
||||
}
|
||||
|
||||
function getNodeStatusColor(status: string): string {
|
||||
const colorMap: Record<string, string> = {
|
||||
pending: 'default',
|
||||
approved: 'green',
|
||||
rejected: 'red',
|
||||
skipped: 'orange'
|
||||
}
|
||||
return colorMap[status] || 'default'
|
||||
}
|
||||
|
||||
function getNodeStatusText(status: string): string {
|
||||
const textMap: Record<string, string> = {
|
||||
pending: '待审批',
|
||||
approved: '已通过',
|
||||
rejected: '已驳回',
|
||||
skipped: '已跳过'
|
||||
}
|
||||
return textMap[status] || status
|
||||
}
|
||||
|
||||
function getApproverTypeText(type: ApproverType): string {
|
||||
return ApproverTypeMap[type] || type
|
||||
}
|
||||
|
||||
function formatDate(dateStr: string): string {
|
||||
if (!dateStr) return '-'
|
||||
const date = new Date(dateStr)
|
||||
return date.toLocaleString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})
|
||||
}
|
||||
|
||||
function handleClose() {
|
||||
visible.value = false
|
||||
approvalComment.value = ''
|
||||
approvalResult.value = 'approve'
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
submitting.value = true
|
||||
try {
|
||||
emit('approve', {
|
||||
approved: approvalResult.value === 'approve',
|
||||
comment: approvalComment.value
|
||||
})
|
||||
|
||||
message.success(approvalResult.value === 'approve' ? '审批通过' : '已驳回')
|
||||
handleClose()
|
||||
} catch (error) {
|
||||
message.error('操作失败')
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch(() => props.open, (newVal) => {
|
||||
if (newVal) {
|
||||
approvalComment.value = ''
|
||||
approvalResult.value = 'approve'
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.approval-drawer :deep(.ant-drawer-body) {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.business-info {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.money-amount {
|
||||
color: #ff4d4f;
|
||||
font-weight: 600;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: #1a1a2e;
|
||||
margin-bottom: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.approval-progress {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.approval-steps {
|
||||
padding-left: 8px;
|
||||
}
|
||||
|
||||
.step-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.step-desc {
|
||||
font-size: 13px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.step-approver {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.step-time {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.step-comment {
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
background: #f5f5f5;
|
||||
padding: 6px 10px;
|
||||
border-radius: 4px;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.approval-action {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.drawer-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
}
|
||||
</style>
|
||||
133
src/components/DuplicateFileModal.vue
Normal file
133
src/components/DuplicateFileModal.vue
Normal file
@@ -0,0 +1,133 @@
|
||||
<template>
|
||||
<a-modal
|
||||
:open="visible"
|
||||
title="同名文件提示"
|
||||
:footer="null"
|
||||
width="600px"
|
||||
:mask-closable="false"
|
||||
@cancel="handleSkipAll"
|
||||
>
|
||||
<div class="duplicate-file-modal">
|
||||
<!-- 警告提示 -->
|
||||
<a-alert
|
||||
message="上传的文件存在同名文件,是否覆盖?"
|
||||
type="warning"
|
||||
show-icon
|
||||
class="warning-alert"
|
||||
/>
|
||||
|
||||
<!-- 文件列表 -->
|
||||
<a-table
|
||||
:columns="columns"
|
||||
:data-source="duplicateFiles"
|
||||
:pagination="false"
|
||||
size="small"
|
||||
class="file-table"
|
||||
:scroll="{ y: 300 }"
|
||||
>
|
||||
<template #bodyCell="{ column, record, index }">
|
||||
<template v-if="column.key === 'index'">
|
||||
{{ index + 1 }}
|
||||
</template>
|
||||
<template v-if="column.key === 'name'">
|
||||
<span class="file-name">{{ record.path || record.name }}</span>
|
||||
</template>
|
||||
<template v-if="column.key === 'size'">
|
||||
<span class="file-size">{{ formatSize(record.size) }}</span>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
|
||||
<!-- 底部按钮 -->
|
||||
<div class="modal-footer">
|
||||
<a-space>
|
||||
<a-button @click="handleSkipAll">跳过</a-button>
|
||||
<a-button type="primary" @click="handleOverwriteAll">覆盖</a-button>
|
||||
</a-space>
|
||||
</div>
|
||||
</div>
|
||||
</a-modal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { DuplicateFile } from '@/types'
|
||||
|
||||
defineProps<{
|
||||
visible: boolean
|
||||
duplicateFiles: DuplicateFile[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:visible', value: boolean): void
|
||||
(e: 'skip-all'): void
|
||||
(e: 'overwrite-all'): void
|
||||
}>()
|
||||
|
||||
const columns = [
|
||||
{ title: '序号', key: 'index', width: 60 },
|
||||
{ title: '名称', key: 'name', ellipsis: true },
|
||||
{ title: '文件大小', key: 'size', width: 120 }
|
||||
]
|
||||
|
||||
/**
|
||||
* 格式化文件大小
|
||||
*/
|
||||
function formatSize(bytes: number): string {
|
||||
if (bytes === 0) return '0 B'
|
||||
const k = 1024
|
||||
const sizes = ['B', 'KB', 'MB', 'GB']
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
|
||||
}
|
||||
|
||||
/**
|
||||
* 跳过所有
|
||||
*/
|
||||
function handleSkipAll() {
|
||||
emit('skip-all')
|
||||
emit('update:visible', false)
|
||||
}
|
||||
|
||||
/**
|
||||
* 覆盖所有
|
||||
*/
|
||||
function handleOverwriteAll() {
|
||||
emit('overwrite-all')
|
||||
emit('update:visible', false)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.duplicate-file-modal {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.warning-alert {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.file-table {
|
||||
border: 1px solid #f0f0f0;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.file-name {
|
||||
font-size: 13px;
|
||||
color: #333;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.file-size {
|
||||
font-size: 13px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid #f0f0f0;
|
||||
}
|
||||
</style>
|
||||
85
src/components/DynamicMenu/index.vue
Normal file
85
src/components/DynamicMenu/index.vue
Normal file
@@ -0,0 +1,85 @@
|
||||
<template>
|
||||
<a-menu
|
||||
v-model:selectedKeys="selectedKeys"
|
||||
v-model:openKeys="openKeys"
|
||||
mode="inline"
|
||||
theme="dark"
|
||||
@click="handleMenuClick"
|
||||
>
|
||||
<template v-for="menu in menuConfig" :key="menu.key">
|
||||
<!-- 单个菜单项 -->
|
||||
<a-menu-item v-if="!menu.children" :key="`item-${menu.key}`">
|
||||
<component :is="menu.icon" v-if="menu.icon" />
|
||||
<span>{{ menu.label }}</span>
|
||||
</a-menu-item>
|
||||
|
||||
<!-- 子菜单 -->
|
||||
<a-sub-menu v-else :key="`sub-${menu.key}`">
|
||||
<template #title>
|
||||
<component :is="menu.icon" v-if="menu.icon" />
|
||||
<span>{{ menu.label }}</span>
|
||||
</template>
|
||||
<a-menu-item v-for="child in menu.children" :key="child.key">
|
||||
{{ child.label }}
|
||||
</a-menu-item>
|
||||
</a-sub-menu>
|
||||
</template>
|
||||
</a-menu>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, onMounted } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { getMenuConfig, getMenuRouteMap, type MenuItem } from '@/config'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
|
||||
const menuConfig = getMenuConfig()
|
||||
const menuRouteMap = getMenuRouteMap()
|
||||
|
||||
const selectedKeys = ref<string[]>(['dashboard'])
|
||||
const openKeys = ref<string[]>([])
|
||||
|
||||
// 菜单点击
|
||||
function handleMenuClick({ key }: { key: string | number }) {
|
||||
const path = menuRouteMap[String(key)]
|
||||
if (path) {
|
||||
router.push(path)
|
||||
}
|
||||
}
|
||||
|
||||
// 根据路由更新菜单选中状态
|
||||
function updateMenuState() {
|
||||
const path = route.path
|
||||
|
||||
// 查找匹配的菜单项
|
||||
for (const [menuKey, menuPath] of Object.entries(menuRouteMap)) {
|
||||
if (path === menuPath || path.startsWith(menuPath + '/')) {
|
||||
selectedKeys.value = [menuKey]
|
||||
|
||||
// 查找父级菜单并展开
|
||||
for (const menu of menuConfig) {
|
||||
if (menu.children?.some(child => child.key === menuKey)) {
|
||||
if (!openKeys.value.includes(menu.key)) {
|
||||
openKeys.value = [...openKeys.value, menu.key]
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
watch(() => route.path, updateMenuState, { immediate: true })
|
||||
|
||||
onMounted(() => {
|
||||
updateMenuState()
|
||||
})
|
||||
|
||||
defineExpose({
|
||||
selectedKeys,
|
||||
openKeys
|
||||
})
|
||||
</script>
|
||||
694
src/components/FlowEditor/index.vue
Normal file
694
src/components/FlowEditor/index.vue
Normal file
@@ -0,0 +1,694 @@
|
||||
<template>
|
||||
<div class="flow-editor">
|
||||
<!-- 工具栏 -->
|
||||
<div class="toolbar">
|
||||
<div class="toolbar-left">
|
||||
<span class="toolbar-title">流程设计器</span>
|
||||
<span class="toolbar-tip">(垂直流向:从上到下连接节点)</span>
|
||||
</div>
|
||||
<div class="toolbar-right">
|
||||
<a-button size="small" @click="layoutCheck">
|
||||
<OrderedListOutlined /> 垂直对齐
|
||||
</a-button>
|
||||
<a-button size="small" @click="handleFitView">
|
||||
<FullscreenOutlined /> 适应画布
|
||||
</a-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flow-container">
|
||||
<!-- 节点面板 -->
|
||||
<div class="node-panel">
|
||||
<div class="panel-title">节点库</div>
|
||||
<div class="node-list">
|
||||
<div
|
||||
v-for="nt in nodeTypeList"
|
||||
:key="nt.type"
|
||||
class="node-item"
|
||||
draggable="true"
|
||||
@dragstart="(e) => onDragStart(e, nt)"
|
||||
>
|
||||
<component :is="nt.icon" class="node-icon" />
|
||||
<span>{{ nt.label }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 流程画布 -->
|
||||
<div
|
||||
ref="flowCanvasRef"
|
||||
class="flow-canvas"
|
||||
@drop="onDrop"
|
||||
@dragover.prevent
|
||||
>
|
||||
<VueFlow
|
||||
v-model:nodes="nodes"
|
||||
v-model:edges="edges"
|
||||
:default-viewport="{ zoom: 1 }"
|
||||
:min-zoom="0.5"
|
||||
:max-zoom="1.5"
|
||||
:connection-mode="ConnectionMode.Loose"
|
||||
:default-edge-options="defaultEdgeOptions"
|
||||
:delete-key-code="['Backspace', 'Delete']"
|
||||
fit-view-on-init
|
||||
@connect="onConnect"
|
||||
@node-click="onNodeClick"
|
||||
@pane-click="onPaneClick"
|
||||
>
|
||||
<Background pattern-color="#aaa" :gap="15" />
|
||||
<Controls />
|
||||
|
||||
<!-- 开始节点 -->
|
||||
<template #node-start="{ data }">
|
||||
<div class="custom-node start-node">
|
||||
<div class="node-content-wrapper center-content">
|
||||
<PlayCircleOutlined class="node-type-icon" />
|
||||
<span>{{ data.label }}</span>
|
||||
</div>
|
||||
<Handle type="source" :position="Position.Bottom" class="custom-handle" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 审批节点 -->
|
||||
<template #node-approval="{ data, selected }">
|
||||
<div
|
||||
class="custom-node approval-node"
|
||||
:class="{ selected: selected }"
|
||||
>
|
||||
<Handle type="target" :position="Position.Top" class="custom-handle" />
|
||||
|
||||
<div class="node-content-wrapper">
|
||||
<div class="node-header">
|
||||
<CheckCircleOutlined class="node-type-icon" />
|
||||
<span class="node-title">{{ data.label }}</span>
|
||||
</div>
|
||||
<div class="node-body">
|
||||
<div class="node-desc">{{ data.approverDesc || '请配置审批人' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Handle type="source" :position="Position.Bottom" class="custom-handle" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 条件节点 -->
|
||||
<template #node-condition="{ data, selected }">
|
||||
<div
|
||||
class="custom-node condition-node"
|
||||
:class="{ selected: selected }"
|
||||
>
|
||||
<Handle type="target" :position="Position.Top" class="custom-handle" />
|
||||
|
||||
<div class="node-content-wrapper">
|
||||
<div class="node-header">
|
||||
<BranchesOutlined class="node-type-icon" />
|
||||
<span class="node-title">{{ data.label }}</span>
|
||||
</div>
|
||||
<div class="node-body">
|
||||
<div class="node-desc">{{ data.conditionDesc || '请配置条件' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Handle type="source" :position="Position.Bottom" class="custom-handle" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 结束节点 -->
|
||||
<template #node-end="{ data }">
|
||||
<div class="custom-node end-node">
|
||||
<Handle type="target" :position="Position.Top" class="custom-handle" />
|
||||
|
||||
<div class="node-content-wrapper center-content">
|
||||
<StopOutlined class="node-type-icon" />
|
||||
<span>{{ data.label }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</VueFlow>
|
||||
</div>
|
||||
|
||||
<!-- 节点配置面板 -->
|
||||
<div class="config-panel">
|
||||
<template v-if="selectedNode">
|
||||
<div class="panel-header">
|
||||
<span>节点配置</span>
|
||||
<a-button type="text" size="small" @click="clearSelection">
|
||||
<CloseOutlined />
|
||||
</a-button>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
<a-form :label-col="{ span: 6 }" :wrapper-col="{ span: 18 }" size="small">
|
||||
<a-form-item label="节点名称">
|
||||
<a-input
|
||||
:value="selectedNode.data.label"
|
||||
placeholder="请输入名称"
|
||||
@input="(e: Event) => updateNodeData('label', (e.target as HTMLInputElement).value)"
|
||||
/>
|
||||
</a-form-item>
|
||||
|
||||
<template v-if="selectedNode.type === 'approval'">
|
||||
<a-form-item label="审批人类型">
|
||||
<a-select
|
||||
:value="selectedNode.data.approverType"
|
||||
@change="(val: any) => { updateNodeData('approverType', val); updateApproverDesc() }"
|
||||
>
|
||||
<a-select-option v-for="(label, key) in ApproverTypeMap" :key="key" :value="key">
|
||||
{{ label }}
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="指定人员" v-if="selectedNode.data.approverType === 'specified'">
|
||||
<a-select
|
||||
:value="selectedNode.data.approverIds"
|
||||
mode="multiple"
|
||||
placeholder="选择审批人"
|
||||
@change="(val: any) => { updateNodeData('approverIds', val); updateApproverDesc() }"
|
||||
>
|
||||
<a-select-option v-for="approver in approverList" :key="approver.id" :value="approver.id">
|
||||
{{ approver.name }}
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="审批角色" v-if="selectedNode.data.approverType === 'role'">
|
||||
<a-input
|
||||
:value="selectedNode.data.approverRole"
|
||||
placeholder="如:财务主管"
|
||||
@input="(e: Event) => { updateNodeData('approverRole', (e.target as HTMLInputElement).value); updateApproverDesc() }"
|
||||
/>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="审批方式">
|
||||
<a-radio-group
|
||||
:value="selectedNode.data.approvalMode"
|
||||
@change="(e: any) => updateNodeData('approvalMode', e.target.value)"
|
||||
>
|
||||
<a-radio value="or">或签</a-radio>
|
||||
<a-radio value="and">会签</a-radio>
|
||||
</a-radio-group>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="超时时间">
|
||||
<a-input-number
|
||||
:value="selectedNode.data.timeoutHours"
|
||||
:min="0"
|
||||
addon-after="小时"
|
||||
style="width: 100%"
|
||||
@change="(val: any) => updateNodeData('timeoutHours', val)"
|
||||
/>
|
||||
</a-form-item>
|
||||
</template>
|
||||
|
||||
<template v-if="selectedNode.type === 'condition'">
|
||||
<a-form-item label="条件表达式">
|
||||
<a-textarea
|
||||
:value="selectedNode.data.conditionExpr"
|
||||
placeholder="如:amount > 10000"
|
||||
:rows="3"
|
||||
@input="(e: Event) => updateNodeData('conditionExpr', (e.target as HTMLTextAreaElement).value)"
|
||||
/>
|
||||
</a-form-item>
|
||||
</template>
|
||||
</a-form>
|
||||
</div>
|
||||
<div class="panel-footer">
|
||||
<a-button type="primary" danger size="small" @click="deleteSelectedNode">
|
||||
<DeleteOutlined /> 删除节点
|
||||
</a-button>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="no-selection">
|
||||
<InfoCircleOutlined />
|
||||
<span>点击节点进行配置</span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch, onMounted } from 'vue'
|
||||
import { VueFlow, Position, useVueFlow, ConnectionMode } from '@vue-flow/core'
|
||||
import { Background } from '@vue-flow/background'
|
||||
import { Controls } from '@vue-flow/controls'
|
||||
import { Handle } from '@vue-flow/core'
|
||||
import type { Node, Edge, Connection } from '@vue-flow/core'
|
||||
import {
|
||||
PlayCircleOutlined,
|
||||
CheckCircleOutlined,
|
||||
BranchesOutlined,
|
||||
StopOutlined,
|
||||
FullscreenOutlined,
|
||||
OrderedListOutlined,
|
||||
CloseOutlined,
|
||||
DeleteOutlined,
|
||||
InfoCircleOutlined
|
||||
} from '@ant-design/icons-vue'
|
||||
import type { ApprovalNode, ApproverInfo } from '@/types'
|
||||
import { ApproverTypeMap } from '@/types'
|
||||
import { mockGetApprovers } from '@/mock'
|
||||
|
||||
interface Props {
|
||||
modelValue?: ApprovalNode[]
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'update:modelValue', value: ApprovalNode[]): void
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
|
||||
|
||||
const defaultEdgeOptions = {
|
||||
type: 'smoothstep',
|
||||
animated: true,
|
||||
style: { stroke: '#1890ff', strokeWidth: 2 },
|
||||
label: '按 Delete 删除'
|
||||
}
|
||||
|
||||
const { fitView } = useVueFlow()
|
||||
const flowCanvasRef = ref<HTMLElement>()
|
||||
|
||||
// 重新回归使用 v-model,这是 VueFlow 最稳定最简单的用法
|
||||
const nodes = ref<Node[]>([
|
||||
{
|
||||
id: 'start',
|
||||
type: 'start',
|
||||
position: { x: 300, y: 50 },
|
||||
data: { label: '开始' },
|
||||
},
|
||||
{
|
||||
id: 'end',
|
||||
type: 'end',
|
||||
position: { x: 300, y: 400 },
|
||||
data: { label: '结束' },
|
||||
}
|
||||
])
|
||||
|
||||
const edges = ref<Edge[]>([
|
||||
{
|
||||
id: 'e-start-end',
|
||||
source: 'start',
|
||||
target: 'end'
|
||||
}
|
||||
])
|
||||
|
||||
const selectedNodeId = ref<string | null>(null)
|
||||
const approverList = ref<ApproverInfo[]>([])
|
||||
|
||||
const nodeTypeList = [
|
||||
{ type: 'approval', label: '审批节点', icon: CheckCircleOutlined },
|
||||
{ type: 'condition', label: '条件节点', icon: BranchesOutlined }
|
||||
]
|
||||
|
||||
const selectedNode = computed(() => {
|
||||
if (!selectedNodeId.value) return null
|
||||
return nodes.value.find(n => n.id === selectedNodeId.value) || null
|
||||
})
|
||||
|
||||
// 拖拽初始逻辑
|
||||
function onDragStart(event: DragEvent, nodeType: { type: string; label: string }) {
|
||||
if (event.dataTransfer) {
|
||||
event.dataTransfer.setData('application/vueflow', JSON.stringify(nodeType))
|
||||
event.dataTransfer.effectAllowed = 'move'
|
||||
}
|
||||
}
|
||||
|
||||
function onDrop(event: DragEvent) {
|
||||
event.preventDefault()
|
||||
const data = event.dataTransfer?.getData('application/vueflow')
|
||||
if (!data) return
|
||||
|
||||
const nodeType = JSON.parse(data)
|
||||
|
||||
const canvas = flowCanvasRef.value
|
||||
if (!canvas) return
|
||||
|
||||
const rect = canvas.getBoundingClientRect()
|
||||
const dropX = event.clientX - rect.left - 100
|
||||
const dropY = event.clientY - rect.top - 20
|
||||
|
||||
const newNode: Node = {
|
||||
id: `${nodeType.type}_${Date.now()}`,
|
||||
type: nodeType.type,
|
||||
position: { x: dropX, y: dropY },
|
||||
data: {
|
||||
label: nodeType.label,
|
||||
approverType: 'specified',
|
||||
approverIds: [],
|
||||
approvalMode: 'or',
|
||||
timeoutHours: 0
|
||||
}
|
||||
}
|
||||
|
||||
nodes.value = [...nodes.value, newNode]
|
||||
}
|
||||
|
||||
// 连接逻辑
|
||||
function onConnect(connection: Connection) {
|
||||
edges.value = [
|
||||
...edges.value,
|
||||
{
|
||||
id: `e-${connection.source}-${connection.target}-${Date.now()}`,
|
||||
source: connection.source!,
|
||||
target: connection.target!
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
// 节点点击
|
||||
function onNodeClick({ node }: { node: Node }) {
|
||||
if (['approval', 'condition'].includes(node.type || '')) {
|
||||
selectedNodeId.value = node.id
|
||||
}
|
||||
}
|
||||
|
||||
function onPaneClick() {
|
||||
selectedNodeId.value = null
|
||||
}
|
||||
|
||||
function clearSelection() {
|
||||
selectedNodeId.value = null
|
||||
}
|
||||
|
||||
// 简单的垂直对齐,保留X轴相对位置
|
||||
function layoutCheck() {
|
||||
const sortedNodes = [...nodes.value].sort((a, b) => a.position.y - b.position.y)
|
||||
|
||||
let currentY = 50
|
||||
const spacingY = 120
|
||||
|
||||
nodes.value = sortedNodes.map(node => {
|
||||
const newNode = { ...node, position: { x: node.position.x, y: currentY } }
|
||||
currentY += spacingY
|
||||
return newNode
|
||||
})
|
||||
}
|
||||
|
||||
function updateNodeData(key: string, value: any) {
|
||||
if (!selectedNodeId.value) return
|
||||
nodes.value = nodes.value.map(node => {
|
||||
if (node.id === selectedNodeId.value) {
|
||||
return { ...node, data: { ...node.data, [key]: value } }
|
||||
}
|
||||
return node
|
||||
})
|
||||
}
|
||||
|
||||
function updateApproverDesc() {
|
||||
if (!selectedNode.value) return
|
||||
const node = selectedNode.value
|
||||
let desc = ''
|
||||
if (node.data.approverType === 'specified' && node.data.approverIds?.length) {
|
||||
const names = node.data.approverIds.map((id: number) => {
|
||||
const approver = approverList.value.find(a => a.id === id)
|
||||
return approver?.name || ''
|
||||
}).filter(Boolean)
|
||||
desc = names.join(', ')
|
||||
} else if (node.data.approverType === 'role' && node.data.approverRole) {
|
||||
desc = `角色: ${node.data.approverRole}`
|
||||
} else {
|
||||
desc = ApproverTypeMap[node.data.approverType as keyof typeof ApproverTypeMap] || ''
|
||||
}
|
||||
updateNodeData('approverDesc', desc)
|
||||
}
|
||||
|
||||
function deleteSelectedNode() {
|
||||
if (!selectedNodeId.value) return
|
||||
const nodeId = selectedNodeId.value
|
||||
nodes.value = nodes.value.filter(n => n.id !== nodeId)
|
||||
edges.value = edges.value.filter(e => e.source !== nodeId && e.target !== nodeId)
|
||||
selectedNodeId.value = null
|
||||
}
|
||||
|
||||
function handleFitView() {
|
||||
fitView()
|
||||
}
|
||||
|
||||
// 数据转换
|
||||
function toApprovalNodes(): any[] {
|
||||
return nodes.value
|
||||
.filter(n => n.type === 'approval' || n.type === 'condition')
|
||||
.sort((a, b) => a.position.y - b.position.y)
|
||||
.map((n, index) => ({
|
||||
id: parseInt(n.id.split('_')[1] || String(Date.now())),
|
||||
name: n.data.label,
|
||||
approverType: n.data.approverType,
|
||||
approverIds: n.data.approverIds,
|
||||
approverRole: n.data.approverRole,
|
||||
approvalMode: n.data.approvalMode,
|
||||
timeoutHours: n.data.timeoutHours,
|
||||
order: index + 1
|
||||
}))
|
||||
}
|
||||
|
||||
|
||||
function loadFromApprovalNodes(approvalNodes: ApprovalNode[]) {
|
||||
const centerX = 300
|
||||
let currentY = 50
|
||||
|
||||
const nodeList: Node[] = []
|
||||
nodeList.push({ id: 'start', type: 'start', position: { x: centerX, y: currentY }, data: { label: '开始' } })
|
||||
currentY += 120
|
||||
|
||||
approvalNodes.forEach((node:any) => {
|
||||
// 解析审批人描述
|
||||
let desc = ''
|
||||
if (node.approverType === 'specified' && node.approverIds?.length) {
|
||||
const names = node.approverIds.map((id:any) => {
|
||||
const approver = approverList.value.find(a => a.id === id)
|
||||
return approver?.name || ''
|
||||
}).filter(Boolean)
|
||||
desc = names.join(', ')
|
||||
} else if (node.approverType === 'role' && node.approverRole) {
|
||||
desc = `角色: ${node.approverRole}`
|
||||
} else {
|
||||
desc = ApproverTypeMap[node.approverType as keyof typeof ApproverTypeMap] || ''
|
||||
}
|
||||
|
||||
nodeList.push({
|
||||
id: `approval_${node.id}`,
|
||||
type: 'approval',
|
||||
position: { x: centerX, y: currentY },
|
||||
data: {
|
||||
label: node.name,
|
||||
approverType: node.approverType,
|
||||
approverIds: node.approverIds || [],
|
||||
approverRole: node.approverRole,
|
||||
approvalMode: node.approvalMode,
|
||||
timeoutHours: node.timeoutHours,
|
||||
approverDesc: desc
|
||||
}
|
||||
})
|
||||
currentY += 120
|
||||
})
|
||||
|
||||
nodeList.push({ id: 'end', type: 'end', position: { x: centerX, y: currentY }, data: { label: '结束' } })
|
||||
nodes.value = nodeList
|
||||
|
||||
const newEdges: Edge[] = []
|
||||
for (let i = 0; i < nodeList.length - 1; i++) {
|
||||
newEdges.push({
|
||||
id: `e-${nodeList[i]!.id}-${nodeList[i+1]!.id}`,
|
||||
source: nodeList[i]!.id,
|
||||
target: nodeList[i+1]!.id,
|
||||
})
|
||||
}
|
||||
edges.value = newEdges
|
||||
}
|
||||
|
||||
|
||||
watch(nodes, () => {
|
||||
emit('update:modelValue', toApprovalNodes())
|
||||
}, { deep: true })
|
||||
|
||||
// 监听 props 变化,支持动态加载
|
||||
watch(() => props.modelValue, (newVal) => {
|
||||
if (newVal?.length) {
|
||||
// 只有当当前编辑器为空或者需要强制刷新时才加载?
|
||||
// 为了简单,如果外部传入了新值,且不是空数组,我们尝试加载
|
||||
// 但要注意不要覆盖用户未保存的编辑。
|
||||
// 由于 destroyOnClose=true,通常只有初始化时会触发。
|
||||
// 这里加一个简单判断:如果当前只有start/end,则加载。
|
||||
if (nodes.value.length <= 2) {
|
||||
loadFromApprovalNodes(newVal)
|
||||
}
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
onMounted(async () => {
|
||||
approverList.value = await mockGetApprovers()
|
||||
// 加载数据,此时 approverList 已就绪,由于 watch immediate 可能会先于 onMounted 执行(但 mock 没回),
|
||||
// 所以这里需要再次检查并重新渲染描述(如果需要)。
|
||||
// 更好的方式是:mock 回来后,如果已经有节点,刷新描述。
|
||||
// 或者:mock 回来后,再调用一次 loadFromApprovalNodes。
|
||||
if (props.modelValue?.length) {
|
||||
loadFromApprovalNodes(props.modelValue)
|
||||
}
|
||||
})
|
||||
|
||||
defineExpose({
|
||||
toApprovalNodes,
|
||||
loadFromApprovalNodes
|
||||
})
|
||||
</script>
|
||||
|
||||
|
||||
<style>
|
||||
/* import the necessary styles for Vue Flow to work */
|
||||
@import '@vue-flow/core/dist/style.css';
|
||||
|
||||
/* import the default theme, this is optional but generally recommended */
|
||||
@import '@vue-flow/core/dist/theme-default.css';
|
||||
|
||||
@import '@vue-flow/controls/dist/style.css';
|
||||
@import '@vue-flow/minimap/dist/style.css';
|
||||
|
||||
/* Edge Selection Styles */
|
||||
.vue-flow__edge.selected .vue-flow__edge-path {
|
||||
stroke: #ff4d4f !important;
|
||||
stroke-width: 3 !important;
|
||||
}
|
||||
|
||||
/* Hide label by default */
|
||||
.vue-flow__edge .vue-flow__edge-text-wrapper {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Show label when selected */
|
||||
.vue-flow__edge.selected .vue-flow__edge-text-wrapper {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Style the label */
|
||||
.vue-flow__edge .vue-flow__edge-text {
|
||||
font-size: 10px;
|
||||
fill: #ff4d4f;
|
||||
}
|
||||
.vue-flow__edge .vue-flow__edge-text-bg {
|
||||
fill: #fff1f0;
|
||||
rx: 4;
|
||||
ry: 4;
|
||||
}
|
||||
</style>
|
||||
|
||||
<style scoped>
|
||||
.flow-editor {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 500px;
|
||||
border: 1px solid #e8e8e8;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 8px 12px;
|
||||
background: #fafafa;
|
||||
border-bottom: 1px solid #e8e8e8;
|
||||
}
|
||||
|
||||
.toolbar-title { font-weight: 600; color: #333; }
|
||||
.toolbar-tip { font-size: 12px; color: #999; margin-left: 8px; }
|
||||
.toolbar-right { display: flex; gap: 8px; }
|
||||
|
||||
.flow-container { display: flex; flex: 1; min-height: 0; }
|
||||
|
||||
.node-panel {
|
||||
width: 160px;
|
||||
background: #fafafa;
|
||||
border-right: 1px solid #e8e8e8;
|
||||
padding: 12px;
|
||||
}
|
||||
.panel-title { font-size: 12px; color: #8c8c8c; margin-bottom: 12px; font-weight: 500; }
|
||||
.node-list { display: flex; flex-direction: column; gap: 8px; }
|
||||
.node-item {
|
||||
display: flex; align-items: center; gap: 8px; padding: 10px 12px;
|
||||
background: #fff; border: 1px solid #e8e8e8; border-radius: 6px;
|
||||
cursor: grab; font-size: 13px; transition: all 0.2s;
|
||||
}
|
||||
.node-item:hover { border-color: #1890ff; box-shadow: 0 2px 8px rgba(24, 144, 255, 0.15); }
|
||||
.node-item .node-icon { font-size: 16px; color: #1890ff; }
|
||||
|
||||
.flow-canvas { flex: 1; background: #f5f7fa; }
|
||||
|
||||
/* Custom Node Styles */
|
||||
.custom-node {
|
||||
width: 200px;
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
||||
cursor: pointer;
|
||||
border: 1px solid transparent;
|
||||
transition: all 0.2s;
|
||||
overflow: visible; /* 必须 visible 否则 Handle 可能被遮挡 */
|
||||
position: relative; /* 定位 Handle */
|
||||
}
|
||||
.custom-node:hover { transform: translateY(-2px); box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12); z-index: 10; }
|
||||
.custom-node.selected { border-color: #1890ff; box-shadow: 0 0 0 2px rgba(24, 144, 255, 0.2); z-index: 10; }
|
||||
|
||||
.node-content-wrapper { padding: 10px 12px; }
|
||||
.center-content { display: flex; justify-content: center; align-items: center; gap: 8px; }
|
||||
|
||||
.node-header { display: flex; align-items: center; gap: 8px; margin-bottom: 6px; padding-bottom: 6px; border-bottom: 1px solid #f0f0f0; }
|
||||
.node-title { font-weight: 500; color: #333; font-size: 13px; }
|
||||
.node-desc { font-size: 12px; color: #8c8c8c; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
|
||||
/* Colors */
|
||||
.start-node {
|
||||
width: 120px;
|
||||
background: #f6ffed;
|
||||
border: 1px solid #b7eb8f;
|
||||
color: #52c41a;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.end-node {
|
||||
width: 120px;
|
||||
background: #fff1f0;
|
||||
border: 1px solid #ffa39e;
|
||||
color: #f5222d;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.approval-node { border: 1px solid #e8e8e8; }
|
||||
.approval-node .node-type-icon { color: #1890ff; }
|
||||
|
||||
.condition-node { border: 1px solid #ffe58f; background: #fffbe6; }
|
||||
.condition-node .node-type-icon { color: #faad14; }
|
||||
|
||||
/* Config Panel */
|
||||
.config-panel { width: 280px; background: #fff; border-left: 1px solid #e8e8e8; display: flex; flex-direction: column; }
|
||||
.panel-header { display: flex; justify-content: space-between; align-items: center; padding: 12px 16px; border-bottom: 1px solid #f0f0f0; font-weight: 500; }
|
||||
.panel-body { flex: 1; padding: 16px; overflow-y: auto; }
|
||||
.panel-footer { padding: 12px 16px; border-top: 1px solid #f0f0f0; }
|
||||
.no-selection { display: flex; flex-direction: column; align-items: center; justify-content: center; height: 100%; color: #8c8c8c; gap: 8px; }
|
||||
.no-selection .anticon { font-size: 32px; }
|
||||
|
||||
/* Handle Styling - 关键:自定义 Handle 样式使其可见、易点击 */
|
||||
:deep(.custom-handle) {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
background: #1890ff;
|
||||
border: 2px solid #fff;
|
||||
border-radius: 50%;
|
||||
z-index: 10;
|
||||
}
|
||||
:deep(.custom-handle:hover) {
|
||||
background: #40a9ff;
|
||||
transform: scale(1.2);
|
||||
}
|
||||
|
||||
:deep(.vue-flow__node) { cursor: grab; }
|
||||
:deep(.vue-flow__node.dragging) { cursor: grabbing; z-index: 1000; }
|
||||
</style>
|
||||
41
src/components/HelloWorld.vue
Normal file
41
src/components/HelloWorld.vue
Normal file
@@ -0,0 +1,41 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
|
||||
defineProps<{ msg: string }>()
|
||||
|
||||
const count = ref(0)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<h1>{{ msg }}</h1>
|
||||
|
||||
<div class="card">
|
||||
<button type="button" @click="count++">count is {{ count }}</button>
|
||||
<p>
|
||||
Edit
|
||||
<code>components/HelloWorld.vue</code> to test HMR
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p>
|
||||
Check out
|
||||
<a href="https://vuejs.org/guide/quick-start.html#local" target="_blank"
|
||||
>create-vue</a
|
||||
>, the official Vue + Vite starter
|
||||
</p>
|
||||
<p>
|
||||
Learn more about IDE Support for Vue in the
|
||||
<a
|
||||
href="https://vuejs.org/guide/scaling-up/tooling.html#ide-support"
|
||||
target="_blank"
|
||||
>Vue Docs Scaling up Guide</a
|
||||
>.
|
||||
</p>
|
||||
<p class="read-the-docs">Click on the Vite and Vue logos to learn more</p>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.read-the-docs {
|
||||
color: #888;
|
||||
}
|
||||
</style>
|
||||
221
src/components/ProjectUpload.vue
Normal file
221
src/components/ProjectUpload.vue
Normal file
@@ -0,0 +1,221 @@
|
||||
<template>
|
||||
<!-- 抽屉模式 -->
|
||||
<a-drawer
|
||||
v-if="mode === 'drawer'"
|
||||
:open="visible"
|
||||
:width="600"
|
||||
placement="right"
|
||||
:closable="true"
|
||||
:mask-closable="false"
|
||||
@close="handleCancel"
|
||||
>
|
||||
<template #title>
|
||||
<div class="upload-drawer-header">
|
||||
<span class="header-title">{{ title || '上传' }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="upload-content">
|
||||
<UploadCore
|
||||
ref="uploadCoreRef"
|
||||
:auto-upload="false"
|
||||
:flex-mode="true"
|
||||
@files-change="handleFilesChange"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<div class="upload-footer">
|
||||
<a-space>
|
||||
<a-button @click="handleCancel">取消</a-button>
|
||||
<a-button
|
||||
type="primary"
|
||||
@click="handleConfirm"
|
||||
:loading="uploading"
|
||||
:disabled="fileCount === 0"
|
||||
>
|
||||
确定上传 ({{ fileCount }})
|
||||
</a-button>
|
||||
</a-space>
|
||||
</div>
|
||||
</template>
|
||||
</a-drawer>
|
||||
|
||||
<!-- 弹窗模式 -->
|
||||
<a-modal
|
||||
v-else
|
||||
:open="visible"
|
||||
:title="title || '上传'"
|
||||
:footer="null"
|
||||
width="700px"
|
||||
:mask-closable="false"
|
||||
:body-style="{ height: '65vh', display: 'flex', flexDirection: 'column' }"
|
||||
@cancel="handleCancel"
|
||||
>
|
||||
<div class="upload-modal-content">
|
||||
<UploadCore
|
||||
ref="uploadCoreRef"
|
||||
:auto-upload="false"
|
||||
:flex-mode="true"
|
||||
@files-change="handleFilesChange"
|
||||
/>
|
||||
|
||||
<div class="upload-modal-footer">
|
||||
<a-space>
|
||||
<a-button @click="handleCancel">取消</a-button>
|
||||
<a-button
|
||||
type="primary"
|
||||
@click="handleConfirm"
|
||||
:loading="uploading"
|
||||
:disabled="fileCount === 0"
|
||||
>
|
||||
确定上传 ({{ fileCount }})
|
||||
</a-button>
|
||||
</a-space>
|
||||
</div>
|
||||
</div>
|
||||
</a-modal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue'
|
||||
import { message, Modal } from 'ant-design-vue'
|
||||
import UploadCore from './UploadCore.vue'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
visible: boolean
|
||||
projectId?: string
|
||||
projectName?: string
|
||||
versionId?: string
|
||||
title?: string
|
||||
mode?: 'drawer' | 'modal'
|
||||
}>(), {
|
||||
mode: 'drawer'
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:visible', value: boolean): void
|
||||
(e: 'uploaded', files: any[]): void
|
||||
}>()
|
||||
|
||||
const uploadCoreRef = ref<InstanceType<typeof UploadCore> | null>(null)
|
||||
const uploading = ref(false)
|
||||
const fileCount = ref(0)
|
||||
|
||||
// 监听visible变化
|
||||
watch(() => props.visible, (newVal) => {
|
||||
if (!newVal) {
|
||||
uploading.value = false
|
||||
fileCount.value = 0
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* 文件列表变化
|
||||
*/
|
||||
function handleFilesChange(count: number) {
|
||||
fileCount.value = count
|
||||
}
|
||||
|
||||
/**
|
||||
* 取消
|
||||
*/
|
||||
function handleCancel() {
|
||||
if (uploading.value) {
|
||||
Modal.confirm({
|
||||
title: '确认取消',
|
||||
content: '当前有文件正在上传,确定要取消吗?',
|
||||
onOk() {
|
||||
emit('update:visible', false)
|
||||
}
|
||||
})
|
||||
} else if (fileCount.value > 0) {
|
||||
Modal.confirm({
|
||||
title: '确认取消',
|
||||
content: '已选择的文件将被清空,确定要取消吗?',
|
||||
onOk() {
|
||||
emit('update:visible', false)
|
||||
}
|
||||
})
|
||||
} else {
|
||||
emit('update:visible', false)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 确定上传
|
||||
*/
|
||||
async function handleConfirm() {
|
||||
if (!uploadCoreRef.value || fileCount.value === 0) return
|
||||
|
||||
uploading.value = true
|
||||
try {
|
||||
const files = await uploadCoreRef.value.startUpload()
|
||||
message.success('上传完成!')
|
||||
emit('uploaded', files)
|
||||
emit('update:visible', false)
|
||||
} catch (error) {
|
||||
message.error('上传失败')
|
||||
} finally {
|
||||
uploading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.upload-drawer-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.close-icon {
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.close-icon:hover {
|
||||
color: #1890ff;
|
||||
}
|
||||
|
||||
.header-title {
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.upload-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: calc(100vh - 160px);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.upload-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.upload-modal-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.upload-modal-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
padding-top: 16px;
|
||||
margin-top: auto;
|
||||
border-top: 1px solid #f0f0f0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
</style>
|
||||
612
src/components/UploadCore.vue
Normal file
612
src/components/UploadCore.vue
Normal file
@@ -0,0 +1,612 @@
|
||||
<template>
|
||||
<div class="upload-core" :class="{ 'upload-core--flex': flexMode }">
|
||||
<!-- 操作按钮 -->
|
||||
<div class="upload-actions">
|
||||
<a-space>
|
||||
<a-button type="primary" @click="selectFiles">
|
||||
<UploadOutlined /> 上传文件
|
||||
</a-button>
|
||||
<a-button type="primary" @click="selectFolder">
|
||||
<FolderOpenOutlined /> 上传文件夹
|
||||
</a-button>
|
||||
</a-space>
|
||||
<a-button @click="clearList" :disabled="fileList.length === 0">
|
||||
清空列表
|
||||
</a-button>
|
||||
</div>
|
||||
|
||||
<!-- 拖拽上传区域 -->
|
||||
<div
|
||||
class="upload-drop-zone"
|
||||
:class="{ 'is-dragover': isDragover }"
|
||||
@dragover.prevent="handleDragover"
|
||||
@dragleave.prevent="handleDragleave"
|
||||
@drop.prevent="handleDrop"
|
||||
>
|
||||
<div class="drop-zone-content">
|
||||
<CloudUploadOutlined class="drop-icon" />
|
||||
<p class="drop-text">将需要上传的文件拖曳到此处</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 当前上传进度 -->
|
||||
<div v-if="currentUploadFile" class="current-upload">
|
||||
<span class="current-file-name">
|
||||
正在上传【{{ currentUploadFile.name }}】....
|
||||
</span>
|
||||
<a-progress
|
||||
:percent="currentUploadFile.percent"
|
||||
:status="currentUploadFile.percent === 100 ? 'success' : 'active'"
|
||||
:show-info="true"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 文件列表 -->
|
||||
<div class="file-list" :class="{ 'file-list--flex': flexMode }">
|
||||
<div
|
||||
v-for="(file, index) in fileList"
|
||||
:key="file.uid"
|
||||
class="file-item"
|
||||
>
|
||||
<div class="file-info">
|
||||
<FileOutlined class="file-icon" />
|
||||
<span class="file-path">{{ file.path || file.name }}</span>
|
||||
</div>
|
||||
<div class="file-status">
|
||||
<CheckOutlined
|
||||
v-if="file.status === 'done'"
|
||||
class="status-done"
|
||||
/>
|
||||
<CloseOutlined
|
||||
v-else-if="file.status === 'error'"
|
||||
class="status-error"
|
||||
/>
|
||||
<span
|
||||
v-else
|
||||
class="status-pending"
|
||||
@click="removeFile(index)"
|
||||
>×</span>
|
||||
</div>
|
||||
</div>
|
||||
<a-empty v-if="fileList.length === 0" description="暂无文件" class="empty-list" />
|
||||
</div>
|
||||
|
||||
<!-- 隐藏的文件输入 -->
|
||||
<input
|
||||
ref="fileInputRef"
|
||||
type="file"
|
||||
multiple
|
||||
style="display: none;"
|
||||
@change="handleFileSelect"
|
||||
/>
|
||||
<input
|
||||
ref="folderInputRef"
|
||||
type="file"
|
||||
webkitdirectory
|
||||
directory
|
||||
multiple
|
||||
style="display: none;"
|
||||
@change="handleFolderSelect"
|
||||
/>
|
||||
|
||||
<!-- 同名文件提示 -->
|
||||
<DuplicateFileModal
|
||||
v-model:visible="duplicateModalVisible"
|
||||
:duplicate-files="duplicateFiles"
|
||||
@skip-all="handleSkipDuplicates"
|
||||
@overwrite-all="handleOverwriteDuplicates"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue'
|
||||
import { message } from 'ant-design-vue'
|
||||
import {
|
||||
UploadOutlined,
|
||||
FolderOpenOutlined,
|
||||
CloudUploadOutlined,
|
||||
FileOutlined,
|
||||
CheckOutlined,
|
||||
CloseOutlined
|
||||
} from '@ant-design/icons-vue'
|
||||
import type { UploadFile, DuplicateFile } from '@/types'
|
||||
import DuplicateFileModal from './DuplicateFileModal.vue'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
autoUpload?: boolean
|
||||
flexMode?: boolean
|
||||
existingFiles?: string[] // 已存在的文件路径列表
|
||||
}>(), {
|
||||
autoUpload: false,
|
||||
flexMode: false,
|
||||
existingFiles: () => []
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'files-change', count: number): void
|
||||
(e: 'upload-complete', files: UploadFile[]): void
|
||||
}>()
|
||||
|
||||
// 文件输入引用
|
||||
const fileInputRef = ref<HTMLInputElement | null>(null)
|
||||
const folderInputRef = ref<HTMLInputElement | null>(null)
|
||||
|
||||
// 拖拽状态
|
||||
const isDragover = ref(false)
|
||||
|
||||
// 文件列表
|
||||
const fileList = ref<UploadFile[]>([])
|
||||
|
||||
// 当前正在上传的文件
|
||||
const currentUploadFile = ref<UploadFile | null>(null)
|
||||
|
||||
// 同名文件检测
|
||||
const duplicateModalVisible = ref(false)
|
||||
const duplicateFiles = ref<DuplicateFile[]>([])
|
||||
const pendingFiles = ref<UploadFile[]>([]) // 等待处理的新文件
|
||||
|
||||
// 监听文件列表变化
|
||||
watch(() => fileList.value.length, (count) => {
|
||||
emit('files-change', count)
|
||||
}, { immediate: true })
|
||||
|
||||
/**
|
||||
* 选择文件
|
||||
*/
|
||||
function selectFiles() {
|
||||
fileInputRef.value?.click()
|
||||
}
|
||||
|
||||
/**
|
||||
* 选择文件夹
|
||||
*/
|
||||
function selectFolder() {
|
||||
folderInputRef.value?.click()
|
||||
}
|
||||
|
||||
/**
|
||||
* 清空列表
|
||||
*/
|
||||
function clearList() {
|
||||
fileList.value = []
|
||||
currentUploadFile.value = null
|
||||
}
|
||||
|
||||
/**
|
||||
* 移除单个文件
|
||||
*/
|
||||
function removeFile(index: number) {
|
||||
const file = fileList.value[index]
|
||||
if (file.status === 'uploading') {
|
||||
message.warning('该文件正在上传中,无法移除')
|
||||
return
|
||||
}
|
||||
fileList.value.splice(index, 1)
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成唯一ID
|
||||
*/
|
||||
function generateUid(): string {
|
||||
return `upload-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理文件选择
|
||||
*/
|
||||
function handleFileSelect(event: Event) {
|
||||
const input = event.target as HTMLInputElement
|
||||
if (input.files) {
|
||||
addFiles(Array.from(input.files))
|
||||
}
|
||||
input.value = ''
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理文件夹选择
|
||||
*/
|
||||
function handleFolderSelect(event: Event) {
|
||||
const input = event.target as HTMLInputElement
|
||||
if (input.files) {
|
||||
addFiles(Array.from(input.files))
|
||||
}
|
||||
input.value = ''
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加文件到列表
|
||||
*/
|
||||
function addFiles(files: File[]) {
|
||||
const newFiles: UploadFile[] = files.map(file => ({
|
||||
uid: generateUid(),
|
||||
name: file.name,
|
||||
path: (file as any).webkitRelativePath || file.name,
|
||||
size: file.size,
|
||||
type: file.type,
|
||||
status: 'pending',
|
||||
percent: 0,
|
||||
file
|
||||
}))
|
||||
|
||||
// 检测同名文件(与已存在文件和已添加文件对比)
|
||||
const existingPaths = new Set([
|
||||
...props.existingFiles,
|
||||
...fileList.value.map(f => f.path)
|
||||
])
|
||||
|
||||
const duplicates: DuplicateFile[] = []
|
||||
const nonDuplicates: UploadFile[] = []
|
||||
|
||||
for (const file of newFiles) {
|
||||
if (existingPaths.has(file.path)) {
|
||||
duplicates.push({
|
||||
uid: file.uid,
|
||||
name: file.name,
|
||||
path: file.path,
|
||||
size: file.size,
|
||||
file: file.file
|
||||
})
|
||||
} else {
|
||||
nonDuplicates.push(file)
|
||||
}
|
||||
}
|
||||
|
||||
// 添加非重复文件
|
||||
if (nonDuplicates.length > 0) {
|
||||
fileList.value.push(...nonDuplicates)
|
||||
}
|
||||
|
||||
// 如果有重复文件,显示提示弹窗
|
||||
if (duplicates.length > 0) {
|
||||
duplicateFiles.value = duplicates
|
||||
pendingFiles.value = newFiles.filter(f => duplicates.some(d => d.uid === f.uid))
|
||||
duplicateModalVisible.value = true
|
||||
} else if (props.autoUpload) {
|
||||
startUpload()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 跳过重复文件
|
||||
*/
|
||||
function handleSkipDuplicates() {
|
||||
// 清空待处理文件,不添加到列表
|
||||
pendingFiles.value = []
|
||||
duplicateFiles.value = []
|
||||
}
|
||||
|
||||
/**
|
||||
* 覆盖重复文件
|
||||
*/
|
||||
function handleOverwriteDuplicates() {
|
||||
// 将待处理文件添加到列表(会覆盖同名文件)
|
||||
for (const file of pendingFiles.value) {
|
||||
// 移除已存在的同名文件
|
||||
const existingIndex = fileList.value.findIndex(f => f.path === file.path)
|
||||
if (existingIndex > -1) {
|
||||
fileList.value.splice(existingIndex, 1)
|
||||
}
|
||||
// 添加新文件
|
||||
fileList.value.push(file)
|
||||
}
|
||||
|
||||
pendingFiles.value = []
|
||||
duplicateFiles.value = []
|
||||
|
||||
if (props.autoUpload) {
|
||||
startUpload()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 拖拽进入
|
||||
*/
|
||||
function handleDragover() {
|
||||
isDragover.value = true
|
||||
}
|
||||
|
||||
/**
|
||||
* 拖拽离开
|
||||
*/
|
||||
function handleDragleave() {
|
||||
isDragover.value = false
|
||||
}
|
||||
|
||||
/**
|
||||
* 拖拽放下
|
||||
*/
|
||||
async function handleDrop(event: DragEvent) {
|
||||
isDragover.value = false
|
||||
|
||||
const items = event.dataTransfer?.items
|
||||
if (!items) return
|
||||
|
||||
const files: File[] = []
|
||||
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
const item = items[i]
|
||||
if (item.kind === 'file') {
|
||||
const entry = item.webkitGetAsEntry?.()
|
||||
if (entry) {
|
||||
await traverseFileTree(entry, '', files)
|
||||
} else {
|
||||
const file = item.getAsFile()
|
||||
if (file) {
|
||||
files.push(file)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (files.length > 0) {
|
||||
addFilesWithPath(files)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 递归遍历文件树
|
||||
*/
|
||||
async function traverseFileTree(
|
||||
entry: FileSystemEntry,
|
||||
path: string,
|
||||
files: File[]
|
||||
): Promise<void> {
|
||||
if (entry.isFile) {
|
||||
const fileEntry = entry as FileSystemFileEntry
|
||||
const file = await new Promise<File>((resolve, reject) => {
|
||||
fileEntry.file(resolve, reject)
|
||||
})
|
||||
Object.defineProperty(file, 'relativePath', {
|
||||
value: path + entry.name,
|
||||
writable: false
|
||||
})
|
||||
files.push(file)
|
||||
} else if (entry.isDirectory) {
|
||||
const dirEntry = entry as FileSystemDirectoryEntry
|
||||
const reader = dirEntry.createReader()
|
||||
|
||||
const entries = await new Promise<FileSystemEntry[]>((resolve, reject) => {
|
||||
reader.readEntries(resolve, reject)
|
||||
})
|
||||
|
||||
for (const childEntry of entries) {
|
||||
await traverseFileTree(childEntry, path + entry.name + '/', files)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加带路径的文件
|
||||
*/
|
||||
function addFilesWithPath(files: File[]) {
|
||||
const newFiles: UploadFile[] = files.map(file => ({
|
||||
uid: generateUid(),
|
||||
name: file.name,
|
||||
path: (file as any).relativePath || (file as any).webkitRelativePath || file.name,
|
||||
size: file.size,
|
||||
type: file.type,
|
||||
status: 'pending',
|
||||
percent: 0,
|
||||
file
|
||||
}))
|
||||
|
||||
fileList.value.push(...newFiles)
|
||||
|
||||
if (props.autoUpload) {
|
||||
startUpload()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 开始上传(暴露给父组件调用)
|
||||
*/
|
||||
async function startUpload(): Promise<UploadFile[]> {
|
||||
const pendingFiles = fileList.value.filter(f => f.status === 'pending')
|
||||
|
||||
for (const file of pendingFiles) {
|
||||
await uploadFile(file)
|
||||
}
|
||||
|
||||
currentUploadFile.value = null
|
||||
emit('upload-complete', fileList.value)
|
||||
|
||||
return fileList.value
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传单个文件
|
||||
*/
|
||||
async function uploadFile(file: UploadFile): Promise<void> {
|
||||
file.status = 'uploading'
|
||||
currentUploadFile.value = file
|
||||
|
||||
// 模拟上传过程
|
||||
return new Promise((resolve) => {
|
||||
let progress = 0
|
||||
const interval = setInterval(() => {
|
||||
progress += Math.random() * 30
|
||||
if (progress >= 100) {
|
||||
progress = 100
|
||||
file.percent = 100
|
||||
file.status = 'done'
|
||||
clearInterval(interval)
|
||||
resolve()
|
||||
} else {
|
||||
file.percent = Math.floor(progress)
|
||||
}
|
||||
}, 100)
|
||||
})
|
||||
|
||||
// TODO: 真实上传逻辑
|
||||
}
|
||||
|
||||
/**
|
||||
* 重置
|
||||
*/
|
||||
function reset() {
|
||||
fileList.value = []
|
||||
currentUploadFile.value = null
|
||||
}
|
||||
|
||||
// 暴露方法给父组件
|
||||
defineExpose({
|
||||
startUpload,
|
||||
reset,
|
||||
fileList
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.upload-core {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.upload-core--flex {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.upload-actions {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.upload-drop-zone {
|
||||
border: 2px dashed #d9d9d9;
|
||||
border-radius: 8px;
|
||||
padding: 40px;
|
||||
text-align: center;
|
||||
transition: all 0.3s;
|
||||
cursor: pointer;
|
||||
background: #fafafa;
|
||||
}
|
||||
|
||||
.upload-drop-zone:hover,
|
||||
.upload-drop-zone.is-dragover {
|
||||
border-color: #1890ff;
|
||||
background: #e6f7ff;
|
||||
}
|
||||
|
||||
.drop-zone-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.drop-icon {
|
||||
font-size: 48px;
|
||||
color: #bfbfbf;
|
||||
}
|
||||
|
||||
.upload-drop-zone:hover .drop-icon,
|
||||
.upload-drop-zone.is-dragover .drop-icon {
|
||||
color: #1890ff;
|
||||
}
|
||||
|
||||
.drop-text {
|
||||
font-size: 14px;
|
||||
color: #8c8c8c;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.current-upload {
|
||||
padding: 12px 16px;
|
||||
background: #f6ffed;
|
||||
border: 1px solid #b7eb8f;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.current-file-name {
|
||||
display: block;
|
||||
font-size: 13px;
|
||||
color: #52c41a;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.file-list {
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
border: 1px solid #f0f0f0;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.file-list--flex {
|
||||
max-height: none;
|
||||
flex: 1;
|
||||
min-height: 150px;
|
||||
}
|
||||
|
||||
.file-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 10px 12px;
|
||||
border-bottom: 1px solid #f5f5f5;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.file-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.file-item:hover {
|
||||
background: #fafafa;
|
||||
}
|
||||
|
||||
.file-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.file-icon {
|
||||
font-size: 16px;
|
||||
color: #8c8c8c;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.file-path {
|
||||
font-size: 13px;
|
||||
color: #333;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.file-status {
|
||||
flex-shrink: 0;
|
||||
width: 24px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.status-done {
|
||||
font-size: 16px;
|
||||
color: #52c41a;
|
||||
}
|
||||
|
||||
.status-error {
|
||||
font-size: 16px;
|
||||
color: #ff4d4f;
|
||||
}
|
||||
|
||||
.status-pending {
|
||||
font-size: 18px;
|
||||
color: #d9d9d9;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.status-pending:hover {
|
||||
color: #ff4d4f;
|
||||
}
|
||||
|
||||
.empty-list {
|
||||
padding: 40px 0;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user