index.vue 37 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257
  1. <template>
  2. <div>
  3. <div class="flex flex-wrap">
  4. <template v-if="limit == 1">
  5. <div
  6. class="rounded cursor-pointer overflow-hidden relative border border-solid border-color image-wrap mr-[10px]"
  7. :class="{ 'rounded-full': type == 'avatar' }"
  8. :style="style"
  9. >
  10. <div class="w-full h-full relative" v-if="imagesData && imagesData.length > 0 && imagesData[0] != ''">
  11. <div class="w-full h-full flex items-center justify-center">
  12. <el-image class="w-full h-full" :src="imagesData[0].indexOf('data:image') != -1 ? imagesData[0] : img(imagesData[0])"></el-image>
  13. </div>
  14. <!-- 悬停操作层 -->
  15. <div class="absolute z-[1] flex items-center justify-center w-full h-full inset-0 bg-black bg-opacity-60 operation">
  16. <div class="index-badge">1</div>
  17. <span class="op-text mr-[10px]" @click.stop="previewImage(imagesData, 0)">预览</span>
  18. <span class="op-text op-delete" @click.stop="removeImage">删除</span>
  19. </div>
  20. </div>
  21. <div class="w-full h-full flex items-center justify-center flex-col content-wrap" v-else @click="openDialog">
  22. <icon name="element Plus" size="20px" color="var(--el-text-color-secondary)" />
  23. <div class="leading-none text-xs mt-[10px] text-secondary">{{ imageText || '上传图片' }}</div>
  24. </div>
  25. </div>
  26. </template>
  27. <template v-else>
  28. <div class="flex flex-wrap" ref="imgListRef">
  29. <template v-for="(item, index) in imagesData" :key="item + index">
  30. <div
  31. v-if="item && item != ''"
  32. class="rounded cursor-pointer overflow-hidden relative border border-solid border-color image-wrap mr-[10px] mb-[10px]"
  33. :style="style"
  34. >
  35. <div class="w-full h-full relative">
  36. <div class="w-full h-full flex items-center justify-center">
  37. <el-image :src="img(item)" fit="contain"></el-image>
  38. </div>
  39. <!-- 悬停操作层 -->
  40. <div class="absolute z-[1] flex flex-col items-center justify-center w-full h-full inset-0 bg-black bg-opacity-60 operation">
  41. <div class="index-badge">{{ index + 1 }}</div>
  42. <div class="flex items-center justify-center mb-[6px]">
  43. <span class="op-text mr-[10px]" @click.stop="previewImage(imagesData, index)">预览</span>
  44. <span class="op-text op-delete" @click.stop="removeImage(index)">删除</span>
  45. </div>
  46. <div class="flex items-center justify-center gap-[8px]">
  47. <el-icon
  48. :size="16"
  49. :style="{ color: Number(index) === 0 ? 'rgba(255,255,255,0.3)' : '#fff', cursor: Number(index) === 0 ? 'not-allowed' : 'pointer' }"
  50. :title="'向左移动'"
  51. @click.stop="Number(index) > 0 && moveImage(Number(index), Number(index) - 1)"
  52. ><ArrowLeft /></el-icon>
  53. <el-icon
  54. :size="16"
  55. :style="{ color: Number(index) === imagesData.length - 1 ? 'rgba(255,255,255,0.3)' : '#fff', cursor: Number(index) === imagesData.length - 1 ? 'not-allowed' : 'pointer' }"
  56. :title="'向右移动'"
  57. @click.stop="Number(index) < imagesData.length - 1 && moveImage(Number(index), Number(index) + 1)"
  58. ><ArrowRight /></el-icon>
  59. </div>
  60. </div>
  61. </div>
  62. </div>
  63. </template>
  64. <div
  65. class="rounded cursor-pointer overflow-hidden relative border border-solid border-color image-wrap mr-[10px] mb-[10px]"
  66. :style="style"
  67. v-if="imagesData.length < limit"
  68. >
  69. <div class="w-full h-full flex items-center justify-center flex-col content-wrap" @click="openDialog">
  70. <icon name="element Plus" size="20px" color="var(--el-text-color-secondary)" />
  71. <div class="leading-none text-xs mt-[10px] text-secondary">{{ imageText || '上传图片' }}</div>
  72. </div>
  73. </div>
  74. </div>
  75. </template>
  76. </div>
  77. <!-- 选择图片 -->
  78. <el-dialog
  79. v-model="dialogVisible"
  80. title="选择图片"
  81. width="1400"
  82. :close-on-click-modal="false"
  83. class="file-selector-dialog"
  84. :before-close="closeDialog"
  85. >
  86. <div class="dialog-bos">
  87. <!-- 工具栏 -->
  88. <div class="toolbar">
  89. <div class="toolbar-left">
  90. <el-upload
  91. ref="uploadRef"
  92. :action="uploadUrl"
  93. :headers="uploadHeaders"
  94. :before-upload="beforeUpload"
  95. :on-success="onUploadSuccess"
  96. :on-error="onUploadError"
  97. :show-file-list="false"
  98. :accept="getUploadFileAccept()"
  99. >
  100. <el-button type="primary">
  101. <el-icon><Plus /></el-icon>
  102. 上传图片
  103. </el-button>
  104. </el-upload>
  105. </div>
  106. <div class="toolbar-right">
  107. <el-input v-model="queryParams.name" placeholder="请输入图片名称" style="width: 200px" clearable @input="handleSearch">
  108. <template #prefix>
  109. <el-icon><Search /></el-icon>
  110. </template>
  111. </el-input>
  112. <div class="view-toggle">
  113. <el-button :type="viewMode === 'grid' ? 'primary' : 'default'" size="small" @click="viewMode = 'grid'">
  114. <el-icon><Grid /></el-icon>
  115. </el-button>
  116. <el-button :type="viewMode === 'list' ? 'primary' : 'default'" size="small" @click="viewMode = 'list'">
  117. <el-icon><List /></el-icon>
  118. </el-button>
  119. </div>
  120. </div>
  121. </div>
  122. <div class="content-wrapper">
  123. <!-- 左侧分类导航 -->
  124. <div class="sidebar">
  125. <!-- 全部文件 -->
  126. <div @click="handleTreeNodeClick({ id: '' })" class="category-item" :class="{ active: queryParams.categoryId == '' }">
  127. <el-icon class="category-icon"><Folder /></el-icon>
  128. <span>全部图片</span>
  129. <el-dropdown trigger="click" @click.stop class="node-actions">
  130. <el-icon class="more-icon"><MoreFilled /></el-icon>
  131. <template #dropdown>
  132. <el-dropdown-menu>
  133. <el-dropdown-item @click="openClassify({}, 'add')" command="addRoot">添加分类</el-dropdown-item>
  134. </el-dropdown-menu>
  135. </template>
  136. </el-dropdown>
  137. </div>
  138. <!-- 树形控件 -->
  139. <el-tree
  140. ref="categoryTreeRef"
  141. :data="filteredCategoryTree"
  142. :props="treeProps"
  143. :expand-on-click-node="false"
  144. :current-node-key="queryParams.categoryId"
  145. node-key="id"
  146. @node-click="handleTreeNodeClick"
  147. class="category-tree"
  148. >
  149. <template #default="{ data }">
  150. <div class="tree-node-content">
  151. <el-icon class="category-icon"><Folder /></el-icon>
  152. <span class="node-label">{{ data.name }}</span>
  153. <el-dropdown trigger="click" @click.stop class="node-actions pr-[10px]">
  154. <el-icon class="more-icon"><MoreFilled /></el-icon>
  155. <template #dropdown>
  156. <el-dropdown-menu>
  157. <el-dropdown-item @click="openClassify(data, 'add')">添加分类</el-dropdown-item>
  158. <el-dropdown-item @click="openClassify(data, 'edit')">编辑分类</el-dropdown-item>
  159. <el-dropdown-item @click="closeClassify(data)">删除分类</el-dropdown-item>
  160. </el-dropdown-menu>
  161. </template>
  162. </el-dropdown>
  163. </div>
  164. </template>
  165. </el-tree>
  166. </div>
  167. <!-- 右侧文件展示区 -->
  168. <div class="content-area">
  169. <!-- 网格视图 -->
  170. <div v-if="viewMode === 'grid'" class="file-grid">
  171. <div
  172. v-for="(file, index) in fileList"
  173. :key="index"
  174. class="file-item"
  175. :class="{
  176. selected: selectedFiles.some((row: any) => row.id == file.id)
  177. }"
  178. @click="toggleFileSelection(file)"
  179. >
  180. <div class="file-wrapper">
  181. <el-image :src="getImageUrl(file)" fit="cover" class="file-thumbnail" :preview-disabled="true" lazy>
  182. <template #error>
  183. <div class="file-error">
  184. <el-icon size="24" color="#c0c4cc">
  185. <Picture />
  186. </el-icon>
  187. </div>
  188. </template>
  189. <template #placeholder>
  190. <div class="file-loading">
  191. <el-icon size="24" color="#409eff">
  192. <Loading />
  193. </el-icon>
  194. </div>
  195. </template>
  196. </el-image>
  197. <div class="file-checkbox">
  198. <el-checkbox :model-value="selectedFiles.some((row: any) => row.id == file.id)" />
  199. </div>
  200. </div>
  201. <div class="file-info">
  202. <div class="file-name">{{ file.name || file.originalName }}</div>
  203. <div class="file-actions">
  204. <el-button
  205. link
  206. size="small"
  207. @click.stop="
  208. previewImage(
  209. fileList.map((row: any) => row.url),
  210. index
  211. )
  212. "
  213. >预览</el-button
  214. >
  215. <el-button link size="small" @click.stop="handleRename(file)">重命名</el-button>
  216. </div>
  217. </div>
  218. </div>
  219. </div>
  220. <!-- 列表视图 -->
  221. <div v-else class="file-list">
  222. <!-- @selection-change="handleSelectionChange" -->
  223. <el-table height="600" v-loading="loading" :data="fileList" style="width: 100%">
  224. <!-- <el-table-column type="selection" width="55" /> -->
  225. <el-table-column label="预览" width="80">
  226. <template #default="{ row }">
  227. <el-image :src="getImageUrl(row)" style="width: 50px; height: 50px; border-radius: 4px" fit="cover" :preview-disabled="true" lazy>
  228. <template #error>
  229. <div class="list-image-error">
  230. <el-icon size="20" color="#c0c4cc">
  231. <Picture />
  232. </el-icon>
  233. </div>
  234. </template>
  235. <template #placeholder>
  236. <div class="list-image-loading">
  237. <el-icon size="20" color="#409eff">
  238. <Loading />
  239. </el-icon>
  240. </div>
  241. </template>
  242. </el-image>
  243. </template>
  244. </el-table-column>
  245. <el-table-column label="文件名" prop="name" min-width="200">
  246. <template #default="{ row }">
  247. {{ row.name || row.originalName }}
  248. </template>
  249. </el-table-column>
  250. <el-table-column label="大小" width="100">
  251. <template #default="{ row }">
  252. {{ formatFileSize(row.size) }}
  253. </template>
  254. </el-table-column>
  255. <el-table-column label="类型" prop="type" width="120" />
  256. <el-table-column label="上传时间" width="180">
  257. <template #default="{ row }">
  258. {{ formatTime(row.createTime) }}
  259. </template>
  260. </el-table-column>
  261. <el-table-column label="操作" width="150" fixed="right">
  262. <template #default="scope">
  263. <el-button
  264. link
  265. @click="
  266. previewImage(
  267. fileList.map((row: any) => row.url),
  268. scope.$index
  269. )
  270. "
  271. >预览</el-button
  272. >
  273. <el-button link @click="handleRename(scope.row)">重命名</el-button>
  274. </template>
  275. </el-table-column>
  276. </el-table>
  277. </div>
  278. <!-- 分页 -->
  279. <div class="pagination">
  280. <el-pagination
  281. v-model:current-page="queryParams.pageNum"
  282. v-model:page-size="queryParams.pageSize"
  283. :total="total"
  284. :page-sizes="[21, 28, 35, 42]"
  285. layout="total, sizes, prev, pager, next, jumper"
  286. @size-change="getList"
  287. @current-change="getList"
  288. />
  289. </div>
  290. </div>
  291. </div>
  292. </div>
  293. <template #footer>
  294. <div class="dialog-footer">
  295. <el-button @click="closeDialog">取消</el-button>
  296. <el-button type="primary" @click="confirmSelection" :disabled="selectedFiles.length > 0 ? false : true">
  297. 确认选择({{ selectedFiles.length }})
  298. </el-button>
  299. </div>
  300. </template>
  301. </el-dialog>
  302. <!-- 添加编辑分类 -->
  303. <el-dialog v-model="classifyDialog.dialog" :title="classifyDialog.title" width="600">
  304. <el-form ref="categoryFormRef" :model="categoryForm" :rules="categoryRules" label-width="100px">
  305. <!-- 分类名称 -->
  306. <el-form-item label="分类名称" prop="name" required>
  307. <el-input v-model="categoryForm.name" placeholder="请输入分类名称" />
  308. </el-form-item>
  309. </el-form>
  310. <template #footer>
  311. <div class="dialog-footer">
  312. <el-button @click="classifyDialog.dialog = false">取消</el-button>
  313. <el-button type="primary" @click="submitCategory" :loading="classifyDialog.loading">确定</el-button>
  314. </div>
  315. </template>
  316. </el-dialog>
  317. <!-- 重命名对话框 -->
  318. <el-dialog v-model="renameDialogVisible" title="重命名文件" width="400px" append-to-body :close-on-click-modal="false">
  319. <el-form ref="renameFormRef" :model="renameForm" label-width="80px">
  320. <el-form-item label="原文件名">
  321. <el-input v-model="renameForm.originalName" readonly />
  322. </el-form-item>
  323. <el-form-item
  324. label="新文件名"
  325. prop="name"
  326. :rules="[
  327. { required: true, message: '请输入新文件名', trigger: 'blur' },
  328. { min: 1, max: 100, message: '文件名长度在 1 到 100 个字符', trigger: 'blur' }
  329. ]"
  330. >
  331. <el-input v-model="renameForm.name" placeholder="请输入新文件名" />
  332. </el-form-item>
  333. </el-form>
  334. <template #footer>
  335. <div class="dialog-footer">
  336. <el-button @click="renameDialogVisible = false">取消</el-button>
  337. <el-button type="primary" @click="submitRename">确定</el-button>
  338. </div>
  339. </template>
  340. </el-dialog>
  341. <!-- 图片放大 -->
  342. <el-image-viewer
  343. :url-list="previewImageList"
  344. v-if="imageViewer.show"
  345. @close="imageViewer.show = false"
  346. :initial-index="imageViewer.index"
  347. :zoom-rate="1"
  348. />
  349. </div>
  350. </template>
  351. <script lang="ts" setup>
  352. import { ArrowLeft, ArrowRight } from '@element-plus/icons-vue';
  353. import { img } from '@/utils/common';
  354. import { listFileInfo, delFileInfo, addFileInfo, updateDownloadCount, updateFileInfo } from '@/api/file/info';
  355. import { listFileCategoryTree, addFileCategory, updateFileCategory, delFileCategory } from '@/api/file/category';
  356. import { globalHeaders } from '@/utils/request';
  357. const props = defineProps({
  358. type: {
  359. type: String,
  360. default: 'image'
  361. },
  362. modelValue: {
  363. type: String || Array,
  364. default: ''
  365. },
  366. width: {
  367. type: String,
  368. default: '100px'
  369. },
  370. height: {
  371. type: String,
  372. default: '100px'
  373. },
  374. imageText: {
  375. type: String
  376. },
  377. limit: {
  378. type: Number,
  379. default: 1
  380. }
  381. });
  382. const imagesData = ref<any>([]);
  383. const previewImageList = ref<any>([]);
  384. watch(
  385. () => props.modelValue,
  386. () => {
  387. if (props.limit == 1) {
  388. imagesData.value = [props.modelValue];
  389. } else {
  390. if (Array.isArray(props.modelValue)) {
  391. imagesData.value = props.modelValue;
  392. } else {
  393. imagesData.value = props.modelValue.split(',');
  394. imagesData.value = imagesData.value.filter((item: any) => item !== '');
  395. }
  396. }
  397. },
  398. { immediate: true }
  399. );
  400. const emit = defineEmits(['update:modelValue', 'change']);
  401. const dialogVisible = ref<any>(false);
  402. const viewMode = ref<any>('grid');
  403. // 图片列表
  404. const loading = ref<any>(false);
  405. const fileList = ref([]);
  406. const total = ref(0);
  407. const selectedFiles = ref([]);
  408. // 查询参数
  409. const queryParams = ref<any>({
  410. pageNum: 1,
  411. pageSize: 20,
  412. name: null,
  413. categoryId: '',
  414. categoryType: 1
  415. });
  416. // 分类相关数据
  417. const filteredCategoryTree = ref<any>([]);
  418. const categoryTreeRef = ref<any>(null);
  419. const treeProps = {
  420. label: 'name',
  421. children: 'children'
  422. };
  423. const classifyDialog = ref<any>({
  424. dialog: false,
  425. title: '添加分类',
  426. loading: false
  427. });
  428. // 分类表单数据
  429. const categoryFormRef = ref<any>(null);
  430. const categoryForm = ref({
  431. id: null,
  432. name: '',
  433. code: '',
  434. parentId: null,
  435. type: 1,
  436. sort: 1,
  437. description: '',
  438. status: 0
  439. });
  440. // 分类表单验证规则
  441. const categoryRules = ref<any>({
  442. name: [
  443. { required: true, message: '请输入分类名称', trigger: 'blur' },
  444. { min: 2, max: 50, message: '分类名称长度在 2 到 50 个字符', trigger: 'blur' }
  445. ]
  446. });
  447. // 重命名相关数据
  448. const renameDialogVisible = ref(false);
  449. const renameForm = ref({
  450. id: null,
  451. name: '',
  452. originalName: '',
  453. currentFile: null
  454. });
  455. const renameFormRef = ref();
  456. // 上传配置
  457. const uploadUrl = ref(import.meta.env.VITE_APP_BASE_API + '/resource/oss/upload');
  458. const uploadHeaders = ref(globalHeaders());
  459. const uploadFileList = ref([]);
  460. // 打开弹窗
  461. const openDialog = () => {
  462. getCategoryTree();
  463. getList();
  464. selectedFiles.value = [];
  465. dialogVisible.value = true;
  466. };
  467. //关闭弹窗
  468. const closeDialog = () => {
  469. dialogVisible.value = false;
  470. };
  471. //确定
  472. const confirmSelection = () => {
  473. if (props.limit == 1) {
  474. emit('update:modelValue', selectedFiles.value[0].url);
  475. emit('change', selectedFiles.value[0]);
  476. } else {
  477. const result = selectedFiles.value.map((item: any) => item.url);
  478. let resultArray = [];
  479. let resultString = '';
  480. if (Array.isArray(props.modelValue)) {
  481. resultArray = [...result, ...imagesData.value];
  482. emit('update:modelValue', resultArray);
  483. emit('change', resultArray);
  484. } else {
  485. resultString = imagesData.value + ',' + result.join(',');
  486. emit('update:modelValue', resultString);
  487. emit('change', resultString);
  488. }
  489. }
  490. closeDialog();
  491. };
  492. // 搜索文件
  493. const handleSearch = () => {
  494. queryParams.value.pageNum = 1;
  495. getList();
  496. };
  497. // 获取文件列表
  498. const getList = async () => {
  499. try {
  500. loading.value = true;
  501. const response = (await listFileInfo(queryParams.value)) as any;
  502. const data = response?.data ?? response;
  503. if (data && data.rows) {
  504. fileList.value = data.rows as any[];
  505. total.value = data.total || 0;
  506. } else {
  507. fileList.value = [];
  508. total.value = 0;
  509. }
  510. } catch (error) {
  511. console.error('获取文件列表失败:', error);
  512. ElMessage.error('获取文件列表失败');
  513. fileList.value = [];
  514. total.value = 0;
  515. } finally {
  516. loading.value = false;
  517. }
  518. };
  519. const handleSelectionChange = (res: any) => {};
  520. // 切换文件选择状态
  521. const toggleFileSelection = (res: any) => {
  522. if (props.limit == 1) {
  523. selectedFiles.value = [res];
  524. } else {
  525. // 多选模式,实现切换逻辑
  526. const index = selectedFiles.value.findIndex((item: any) => item.id === res.id);
  527. // 计算当前总选择数量(已选文件 + 已上传图片)
  528. const currentTotalCount = selectedFiles.value.length + imagesData.value.length;
  529. if (index > -1) {
  530. // 如果已选中,则取消选中(删除)
  531. selectedFiles.value.splice(index, 1);
  532. } else {
  533. // 如果未选中,检查是否超过限制
  534. if (currentTotalCount >= props.limit) {
  535. ElMessage.warning(`最多只能选择 ${props.limit} 张图片`);
  536. return;
  537. }
  538. // 未超过限制,添加选中
  539. selectedFiles.value.push(res);
  540. }
  541. }
  542. };
  543. // 获取图片URL
  544. const getImageUrl = (file) => {
  545. // 优先使用url字段
  546. if (file.url) {
  547. // 如果是完整的URL,直接返回
  548. if (file.url.startsWith('http://') || file.url.startsWith('https://')) {
  549. return file.url;
  550. }
  551. // 如果是相对路径,添加基础URL
  552. if (file.url.startsWith('/')) {
  553. return import.meta.env.VITE_APP_BASE_API + file.url;
  554. }
  555. return file.url;
  556. }
  557. // 备选方案:使用path字段
  558. if (file.path) {
  559. if (file.path.startsWith('http://') || file.path.startsWith('https://')) {
  560. return file.path;
  561. }
  562. if (file.path.startsWith('/')) {
  563. return import.meta.env.VITE_APP_BASE_API + file.path;
  564. }
  565. return file.path;
  566. }
  567. // 如果都没有,返回空字符串,触发错误处理
  568. return '';
  569. };
  570. // 获取分类树
  571. const getCategoryTree = () => {
  572. listFileCategoryTree().then((res) => {
  573. if (res.code == 200) {
  574. if (res.data.length > 0) {
  575. res.data.forEach((item: any) => {
  576. if (item.type == 1 && item.code == 'IMAGE') {
  577. filteredCategoryTree.value = item.children;
  578. }
  579. });
  580. }
  581. }
  582. });
  583. };
  584. // 添加分类
  585. const openClassify = (res: any, type: any) => {
  586. console.log(res, 'res');
  587. if (type == 'add') {
  588. classifyDialog.value.title = '添加分类';
  589. categoryForm.value.name = '';
  590. categoryForm.value.id = null;
  591. if (res.id) {
  592. categoryForm.value.parentId = res.id;
  593. } else {
  594. categoryForm.value.parentId = 1;
  595. }
  596. } else {
  597. classifyDialog.value.title = '编辑分类';
  598. categoryForm.value.name = res.name;
  599. categoryForm.value.id = res.id;
  600. categoryForm.value.parentId = null;
  601. }
  602. classifyDialog.value.dialog = true;
  603. };
  604. // 新增编辑分类
  605. const submitCategory = async () => {
  606. try {
  607. await categoryFormRef.value?.validate();
  608. classifyDialog.value.loading = true;
  609. const categoryData = {
  610. ...categoryForm.value,
  611. tenantId: '000000'
  612. };
  613. if (categoryForm.value.id) {
  614. await updateFileCategory(categoryData);
  615. } else {
  616. await addFileCategory(categoryData);
  617. }
  618. ElMessage.success(categoryForm.value.id ? '更新成功' : '添加成功');
  619. classifyDialog.value.loading = false;
  620. getCategoryTree();
  621. } catch (error) {
  622. console.error('保存分类失败:', error);
  623. ElMessage.error('保存分类失败');
  624. } finally {
  625. classifyDialog.value.loading = false;
  626. classifyDialog.value.dialog = false;
  627. }
  628. };
  629. // 删除分类
  630. const closeClassify = async (data: any) => {
  631. if (data.children && data.children.length > 0) {
  632. ElMessage.warning('该分类下有子分类,请先删除子分类');
  633. return;
  634. }
  635. try {
  636. await ElMessageBox.confirm(`确定要删除分类"${data.name}"吗?`, '提示', {
  637. confirmButtonText: '确定',
  638. cancelButtonText: '取消',
  639. type: 'warning'
  640. } as any);
  641. await delFileCategory(data.id);
  642. ElMessage.success('删除成功');
  643. getCategoryTree();
  644. } catch (error) {
  645. if (error !== 'cancel') {
  646. console.error('删除分类失败:', error);
  647. ElMessage.error('删除分类失败');
  648. }
  649. }
  650. };
  651. // 树节点点击事件
  652. const handleTreeNodeClick = (res: any) => {
  653. queryParams.value.categoryId = res.id;
  654. if (res.id == '') {
  655. categoryTreeRef.value.setCurrentKey(null);
  656. }
  657. handleSearch();
  658. };
  659. // 图片放大
  660. const imageViewer = reactive({
  661. show: false,
  662. index: 0
  663. });
  664. const previewImage = (list: any, index: any) => {
  665. previewImageList.value = list;
  666. imageViewer.index = index ? index : 0;
  667. imageViewer.show = true;
  668. };
  669. /**
  670. * 移动图片位置
  671. * @param fromIndex 原位置
  672. * @param toIndex 目标位置
  673. */
  674. const moveImage = (fromIndex: number, toIndex: number) => {
  675. const list = [...imagesData.value];
  676. const [item] = list.splice(fromIndex, 1);
  677. list.splice(toIndex, 0, item);
  678. if (Array.isArray(props.modelValue)) {
  679. emit('update:modelValue', list);
  680. emit('change', list);
  681. } else {
  682. emit('update:modelValue', list.join(','));
  683. emit('change', list.join(','));
  684. }
  685. };
  686. /**
  687. * 删除图片
  688. * @param index
  689. */
  690. const removeImage = (index?: any) => {
  691. if (props.limit == 1) {
  692. emit('update:modelValue', '');
  693. emit('change', '');
  694. } else {
  695. const list = [...imagesData.value];
  696. list.splice(index, 1);
  697. if (Array.isArray(props.modelValue)) {
  698. emit('update:modelValue', list);
  699. emit('change', list);
  700. } else {
  701. emit('update:modelValue', list.join(','));
  702. emit('change', list.join(','));
  703. }
  704. }
  705. };
  706. // 重命名文件
  707. const handleRename = (file: any) => {
  708. renameForm.value = {
  709. id: file.id,
  710. name: '',
  711. originalName: file.name || file.originalName || '',
  712. currentFile: file // 保存完整的文件信息
  713. };
  714. renameDialogVisible.value = true;
  715. };
  716. // 提交重命名
  717. const submitRename = async () => {
  718. try {
  719. await renameFormRef.value?.validate();
  720. if (!renameForm.value.name.trim()) {
  721. ElMessage.error('请输入新文件名');
  722. return;
  723. }
  724. if (renameForm.value.name === renameForm.value.originalName) {
  725. ElMessage.warning('新文件名与原文件名相同');
  726. return;
  727. }
  728. // 调用重命名API
  729. const file = renameForm.value.currentFile;
  730. await updateFileInfo({
  731. id: renameForm.value.id,
  732. name: renameForm.value.name.trim(),
  733. originalName: file.originalName,
  734. path: file.path,
  735. url: file.url,
  736. size: file.size,
  737. type: file.type,
  738. extension: file.extension,
  739. categoryId: file.categoryId,
  740. description: file.description
  741. });
  742. ElMessage.success('重命名成功');
  743. renameDialogVisible.value = false;
  744. // 刷新文件列表
  745. handleSearch();
  746. console.log('重命名文件:', {
  747. id: renameForm.value.id,
  748. oldName: renameForm.value.originalName,
  749. newName: renameForm.value.name.trim()
  750. });
  751. } catch (error) {
  752. console.error('重命名失败:', error);
  753. ElMessage.error('重命名失败');
  754. }
  755. };
  756. // 格式化文件大小
  757. const formatFileSize = (size) => {
  758. if (!size) return '0 B';
  759. const units = ['B', 'KB', 'MB', 'GB'];
  760. let index = 0;
  761. let fileSize = size;
  762. while (fileSize >= 1024 && index < units.length - 1) {
  763. fileSize /= 1024;
  764. index++;
  765. }
  766. return fileSize.toFixed(2) + ' ' + units[index];
  767. };
  768. // 格式化时间
  769. const formatTime = (time) => {
  770. if (!time) return '';
  771. return new Date(time).toLocaleString();
  772. };
  773. // 上传前检查
  774. const beforeUpload = (file) => {
  775. // 获取准确的MIME类型
  776. const actualMimeType = getFileMimeType(file);
  777. const fileName = file.name || '';
  778. const extension = fileName.split('.').pop()?.toLowerCase();
  779. let isValidType = false;
  780. let fileTypeText = '';
  781. isValidType = actualMimeType.startsWith('image/') || ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'svg'].includes(extension);
  782. fileTypeText = '图片';
  783. if (!isValidType) {
  784. ElMessage.error(`只能上传${fileTypeText}文件! 检测到的文件类型: ${actualMimeType}`);
  785. return false;
  786. }
  787. // 检查文件大小 (50MB)
  788. const isLtSize = file.size / 1024 / 1024 < 50;
  789. if (!isLtSize) {
  790. ElMessage.error('上传文件大小不能超过 50MB!');
  791. return false;
  792. }
  793. // 非图片文件或非图片分类,直接上传
  794. return true;
  795. };
  796. // 上传成功
  797. const onUploadSuccess = (response, file) => {
  798. if (response.code === 200) {
  799. // 获取文件MIME类型和扩展名
  800. const mimeType = getFileMimeType(file);
  801. const fileName = file.name || '';
  802. const extension = fileName.split('.').pop()?.toLowerCase() || '';
  803. const datas = {
  804. categoryId: queryParams.value.categoryId,
  805. categoryType: 1,
  806. description: '',
  807. downloadCount: 0,
  808. extension: extension,
  809. isPublic: 1,
  810. name: file.name,
  811. originalName: file.name,
  812. ossId: response.data?.ossId || '',
  813. path: response.data?.url || '',
  814. size: file.size,
  815. status: 0,
  816. type: mimeType,
  817. uploadStatus: 1,
  818. url: response.data?.url || '',
  819. viewCount: 0
  820. };
  821. addFileInfo(datas).then((res) => {
  822. if (res.code == 200) {
  823. ElMessage.success('文件上传成功');
  824. handleSearch();
  825. }
  826. });
  827. } else {
  828. ElMessage.error('上传失败:' + response.msg);
  829. }
  830. };
  831. // 上传失败
  832. const onUploadError = (error) => {
  833. console.error('上传失败:', error);
  834. ElMessage.error('上传失败');
  835. };
  836. // 获取上传文件接受类型
  837. const getUploadFileAccept = () => {
  838. return '.jpg,.jpeg,.png,.gif,.bmp,.webp';
  839. };
  840. // 获取文件MIME类型
  841. const getFileMimeType = (file) => {
  842. // 优先使用浏览器检测的MIME类型
  843. if (file.type) {
  844. return file.type;
  845. }
  846. // 如果浏览器无法检测,根据文件扩展名推断
  847. const fileName = file.name || '';
  848. const extension = fileName.split('.').pop()?.toLowerCase();
  849. const mimeTypeMap = {
  850. // 图片类型
  851. 'jpg': 'image/jpeg',
  852. 'jpeg': 'image/jpeg',
  853. 'png': 'image/png',
  854. 'gif': 'image/gif',
  855. 'bmp': 'image/bmp',
  856. 'webp': 'image/webp',
  857. 'svg': 'image/svg+xml',
  858. // 视频类型
  859. 'mp4': 'video/mp4',
  860. 'avi': 'video/x-msvideo',
  861. 'mov': 'video/quicktime',
  862. 'wmv': 'video/x-ms-wmv',
  863. 'flv': 'video/x-flv',
  864. 'mkv': 'video/x-matroska',
  865. 'webm': 'video/webm',
  866. // 音频类型
  867. 'mp3': 'audio/mpeg',
  868. 'wav': 'audio/wav',
  869. 'flac': 'audio/flac',
  870. 'aac': 'audio/aac',
  871. 'ogg': 'audio/ogg',
  872. 'm4a': 'audio/mp4',
  873. // 文档类型
  874. 'pdf': 'application/pdf',
  875. 'doc': 'application/msword',
  876. 'docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
  877. 'xls': 'application/vnd.ms-excel',
  878. 'xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
  879. 'ppt': 'application/vnd.ms-powerpoint',
  880. 'pptx': 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
  881. 'txt': 'text/plain',
  882. // 压缩文件
  883. 'zip': 'application/zip',
  884. 'rar': 'application/vnd.rar',
  885. '7z': 'application/x-7z-compressed',
  886. 'tar': 'application/x-tar',
  887. 'gz': 'application/gzip',
  888. // 其他常见类型
  889. 'json': 'application/json',
  890. 'xml': 'application/xml',
  891. 'csv': 'text/csv',
  892. 'html': 'text/html',
  893. 'css': 'text/css',
  894. 'js': 'application/javascript'
  895. };
  896. return mimeTypeMap[extension] || 'application/octet-stream';
  897. };
  898. const style = computed(() => {
  899. return {
  900. width: props.width,
  901. height: props.height
  902. };
  903. });
  904. </script>
  905. <style lang="scss" scoped>
  906. .image-wrap {
  907. .operation {
  908. display: none;
  909. }
  910. &:hover {
  911. .operation {
  912. display: flex;
  913. }
  914. }
  915. /* 序号角标(仅悬停展示,位于左上角) */
  916. .index-badge {
  917. position: absolute;
  918. top: 4px;
  919. left: 4px;
  920. z-index: 2;
  921. min-width: 18px;
  922. height: 18px;
  923. padding: 0 5px;
  924. border-radius: 3px;
  925. background: rgba(0, 0, 0, 0.75);
  926. color: #fff;
  927. font-size: 11px;
  928. line-height: 18px;
  929. text-align: center;
  930. font-weight: 600;
  931. pointer-events: none;
  932. }
  933. /* 悬停操作文字按钮 */
  934. .op-text {
  935. color: #fff;
  936. font-size: 12px;
  937. line-height: 1;
  938. cursor: pointer;
  939. user-select: none;
  940. &:hover {
  941. opacity: 0.85;
  942. }
  943. }
  944. .op-delete {
  945. color: #ff7875;
  946. &:hover {
  947. color: #f56c6c;
  948. }
  949. }
  950. }
  951. .border-color {
  952. border-color: #e5e7eb;
  953. }
  954. .dialog-bos {
  955. height: 750px;
  956. background: #f5f7fa;
  957. padding: 10px;
  958. border-radius: 0 0 8px 8px;
  959. box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
  960. margin-left: 0px !important;
  961. display: flex;
  962. flex-direction: column;
  963. .toolbar {
  964. display: flex;
  965. justify-content: space-between;
  966. align-items: center;
  967. margin-bottom: 10px;
  968. padding: 16px 20px;
  969. background: white;
  970. .toolbar-left {
  971. display: flex;
  972. align-items: center;
  973. gap: 12px;
  974. }
  975. .toolbar-right {
  976. display: flex;
  977. align-items: center;
  978. gap: 12px;
  979. .view-toggle {
  980. display: flex;
  981. gap: 0;
  982. }
  983. }
  984. }
  985. .content-wrapper {
  986. display: flex;
  987. gap: 20px;
  988. height: 0;
  989. flex: 1;
  990. .sidebar {
  991. width: 280px;
  992. background: white;
  993. border-radius: 8px;
  994. box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
  995. overflow: auto;
  996. display: flex;
  997. flex-direction: column;
  998. height: 100%;
  999. padding: 10px;
  1000. .category-item {
  1001. display: flex;
  1002. align-items: center;
  1003. padding: 0px 15px;
  1004. border-radius: 6px;
  1005. cursor: pointer;
  1006. margin-bottom: 8px;
  1007. background: #f5f7fa;
  1008. transition: background-color 0.3s;
  1009. height: 40px;
  1010. .category-icon {
  1011. margin-right: 10px;
  1012. font-size: 18px;
  1013. }
  1014. &.active {
  1015. background: #409eff;
  1016. color: white;
  1017. .more-icon {
  1018. color: rgba(255, 255, 255, 0.8);
  1019. &:hover {
  1020. color: white;
  1021. background: rgba(255, 255, 255, 0.2);
  1022. }
  1023. }
  1024. }
  1025. }
  1026. /* 树形控件样式 */
  1027. .category-tree {
  1028. background: transparent;
  1029. :deep(.el-tree-node__content) {
  1030. height: 40px;
  1031. border-radius: 6px;
  1032. margin-bottom: 4px;
  1033. background: #f5f7fa;
  1034. transition: background-color 0.3s;
  1035. }
  1036. :deep(.el-tree-node__content:hover) {
  1037. background: #ebeef5;
  1038. }
  1039. :deep(.el-tree-node.is-current > .el-tree-node__content) {
  1040. background: #409eff;
  1041. color: white;
  1042. .more-icon {
  1043. color: rgba(255, 255, 255, 0.8);
  1044. &:hover {
  1045. color: white;
  1046. background: rgba(255, 255, 255, 0.2);
  1047. }
  1048. }
  1049. }
  1050. :deep(.el-tree-node.is-current > .el-tree-node__content:hover) {
  1051. background: #66b1ff;
  1052. }
  1053. }
  1054. .tree-node-content {
  1055. display: flex;
  1056. align-items: center;
  1057. width: 100%;
  1058. padding: 0 5px;
  1059. .category-icon {
  1060. margin-right: 8px;
  1061. font-size: 16px;
  1062. }
  1063. .node-label {
  1064. flex: 1;
  1065. font-size: 14px;
  1066. }
  1067. }
  1068. .node-actions {
  1069. margin-left: auto;
  1070. .more-icon {
  1071. font-size: 16px;
  1072. color: #909399;
  1073. cursor: pointer;
  1074. transition: color 0.3s;
  1075. border-radius: 2px;
  1076. &:hover {
  1077. color: #409eff;
  1078. background: rgba(64, 158, 255, 0.1);
  1079. }
  1080. }
  1081. }
  1082. }
  1083. .content-area {
  1084. flex: 1;
  1085. display: flex;
  1086. flex-direction: column;
  1087. height: 100%;
  1088. overflow: hidden;
  1089. .file-grid {
  1090. display: flex;
  1091. flex-wrap: wrap;
  1092. gap: 15px 10px;
  1093. padding: 10px;
  1094. flex: 1;
  1095. overflow-y: auto;
  1096. min-height: 0;
  1097. .file-item {
  1098. flex: 0 0 calc((100% - 30px) / 4);
  1099. overflow: hidden;
  1100. position: relative;
  1101. cursor: pointer;
  1102. border: 1px solid #ebeef5;
  1103. border-radius: 8px;
  1104. box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
  1105. transition:
  1106. transform 0.3s ease,
  1107. box-shadow 0.3s ease;
  1108. height: 223px;
  1109. &:hover {
  1110. transform: translateY(-5px);
  1111. box-shadow: 0 4px 8px rgba(0, 0, 0, 0.15);
  1112. }
  1113. &.selected {
  1114. border: 2px solid #409eff;
  1115. box-shadow: 0 4px 8px rgba(0, 0, 0, 0.15);
  1116. }
  1117. .file-wrapper {
  1118. position: relative;
  1119. width: 100%;
  1120. height: 150px; /* Fixed height for grid view */
  1121. overflow: hidden;
  1122. .file-thumbnail {
  1123. width: 100%;
  1124. height: 100%;
  1125. object-fit: cover;
  1126. .file-error {
  1127. display: flex;
  1128. justify-content: center;
  1129. align-items: center;
  1130. width: 100%;
  1131. height: 100%;
  1132. background: #f5f7fa;
  1133. color: #c0c4cc;
  1134. }
  1135. .file-loading {
  1136. display: flex;
  1137. justify-content: center;
  1138. align-items: center;
  1139. width: 100%;
  1140. height: 100%;
  1141. background: #f0f9ff;
  1142. color: #409eff;
  1143. }
  1144. }
  1145. .file-checkbox {
  1146. position: absolute;
  1147. top: 8px;
  1148. right: 8px;
  1149. z-index: 10;
  1150. }
  1151. }
  1152. .file-info {
  1153. padding: 5px 5px 10px 5px;
  1154. background: #f5f7fa;
  1155. border-top: 1px solid #ebeef5;
  1156. border-radius: 0 0 8px 8px;
  1157. .file-name {
  1158. font-size: 14px;
  1159. color: #303133;
  1160. overflow: hidden;
  1161. text-overflow: ellipsis;
  1162. white-space: nowrap;
  1163. margin-bottom: 5px;
  1164. }
  1165. .file-actions {
  1166. display: flex;
  1167. justify-content: space-around;
  1168. gap: 8px;
  1169. }
  1170. }
  1171. }
  1172. }
  1173. }
  1174. .pagination {
  1175. display: flex;
  1176. justify-content: center;
  1177. padding: 10px;
  1178. background: white;
  1179. flex-shrink: 0;
  1180. }
  1181. }
  1182. }
  1183. </style>