index.vue 37 KB

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