first commit

This commit is contained in:
super
2025-12-28 22:12:08 +08:00
commit 82dcc17968
72 changed files with 23293 additions and 0 deletions

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

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

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

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

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

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

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