Index.vue 25 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926
  1. <template>
  2. <div class="image-management">
  3. <el-container>
  4. <!-- 左侧分类树 -->
  5. <el-aside width="280px" class="category-aside">
  6. <div class="aside-header-buttons">
  7. <el-button type="primary" :icon="Plus" @click="handleAddCategory">新增</el-button>
  8. <el-button :icon="Edit" @click="handleEditSelectedCategory" :disabled="!currentCategory || currentCategory.id === '0'">修改</el-button>
  9. <el-button type="danger" :icon="Delete" @click="handleDeleteSelectedCategory" :disabled="!currentCategory || currentCategory.id === '0'">删除</el-button>
  10. <el-button type="warning" :icon="Tickets" @click="handleCategoryBatchAddToTask" style="width: 100%; margin-left: 0; margin-top: 10px;">
  11. 分类批量加入标注任务
  12. </el-button>
  13. </div>
  14. <div class="aside-content">
  15. <el-tree
  16. ref="categoryTree"
  17. :data="categories"
  18. :props="defaultProps"
  19. node-key="id"
  20. default-expand-all
  21. highlight-current
  22. @node-click="handleCategoryClick"
  23. >
  24. <template #default="{ node, data }">
  25. <span class="custom-tree-node">
  26. <span class="label">
  27. <el-icon v-if="data.id === '0'"><FolderOpened /></el-icon>
  28. <el-icon v-else><Folder /></el-icon>
  29. {{ node.label }}
  30. </span>
  31. </span>
  32. </template>
  33. </el-tree>
  34. </div>
  35. </el-aside>
  36. <!-- 右侧图片列表 -->
  37. <el-main class="image-main">
  38. <div class="main-header">
  39. <div class="header-left">
  40. <span class="current-category">{{ currentCategoryName }}</span>
  41. </div>
  42. <div class="header-right">
  43. <el-button
  44. type="primary"
  45. :icon="Tickets"
  46. @click="handleBatchAddToTask"
  47. :disabled="selectedIds.length === 0"
  48. >
  49. 批量加入标注任务
  50. </el-button>
  51. <el-upload
  52. ref="uploadRef"
  53. class="upload-btn"
  54. action="#"
  55. :auto-upload="false"
  56. :show-file-list="false"
  57. multiple
  58. accept="image/*"
  59. :on-change="handleFileChange"
  60. >
  61. <el-button type="success" :icon="Upload">新增图片</el-button>
  62. </el-upload>
  63. </div>
  64. </div>
  65. <div class="image-list" v-loading="loading">
  66. <el-table
  67. :data="images"
  68. style="width: 100%"
  69. height="calc(100vh - 240px)"
  70. @selection-change="handleSelectionChange"
  71. >
  72. <el-table-column type="selection" width="55" />
  73. <el-table-column label="图片预览" width="120">
  74. <template #default="scope">
  75. <el-image
  76. :ref="(el: any) => imageRefs[scope.$index] = el"
  77. class="table-image"
  78. :src="scope.row.image_url"
  79. :preview-src-list="previewSrcList"
  80. :initial-index="scope.$index"
  81. fit="cover"
  82. preview-teleported
  83. >
  84. <template #error>
  85. <div class="image-error">
  86. <el-icon><Picture /></el-icon>
  87. </div>
  88. </template>
  89. </el-image>
  90. </template>
  91. </el-table-column>
  92. <el-table-column prop="image_name" label="图片名称" min-width="150" show-overflow-tooltip />
  93. <el-table-column prop="category_name" label="图片分类" width="120" />
  94. <el-table-column prop="creator_name" label="创建人" width="120" />
  95. <el-table-column prop="created_time" label="创建时间" width="170">
  96. <template #default="scope">
  97. {{ formatDateTime(scope.row.created_time) }}
  98. </template>
  99. </el-table-column>
  100. <el-table-column label="操作" width="120" fixed="right">
  101. <template #default="scope">
  102. <el-button type="primary" link @click="handlePreview(scope.$index)">预览</el-button>
  103. <el-button type="danger" link @click="handleDeleteImage(scope.row)">删除</el-button>
  104. </template>
  105. </el-table-column>
  106. <template #empty>
  107. <el-empty description="暂无图片" />
  108. </template>
  109. </el-table>
  110. <div class="pagination-container">
  111. <el-pagination
  112. v-model:current-page="queryParams.page"
  113. v-model:page-size="queryParams.page_size"
  114. :page-sizes="[10, 20, 50, 100]"
  115. layout="total, sizes, prev, pager, next, jumper"
  116. :total="total"
  117. @size-change="handleSizeChange"
  118. @current-change="handleCurrentChange"
  119. />
  120. </div>
  121. </div>
  122. </el-main>
  123. </el-container>
  124. <!-- 分类弹窗 -->
  125. <el-dialog
  126. v-model="categoryDialogVisible"
  127. :title="categoryForm.id ? '修改分类' : '新增分类'"
  128. width="400px"
  129. >
  130. <el-form :model="categoryForm" label-width="80px">
  131. <el-form-item label="分类名称" required>
  132. <el-input v-model="categoryForm.type_name" placeholder="请输入分类名称" />
  133. </el-form-item>
  134. <el-form-item label="备注">
  135. <el-input v-model="categoryForm.remark" type="textarea" placeholder="请输入备注" />
  136. </el-form-item>
  137. </el-form>
  138. <template #footer>
  139. <el-button @click="categoryDialogVisible = false">取消</el-button>
  140. <el-button type="primary" @click="submitCategory">确定</el-button>
  141. </template>
  142. </el-dialog>
  143. <!-- 图片上传/编辑弹窗 -->
  144. <el-dialog
  145. v-model="uploadDialogVisible"
  146. title="上传图片"
  147. width="500px"
  148. :close-on-click-modal="false"
  149. >
  150. <div class="upload-list-container">
  151. <div v-for="(item, index) in uploadFileList" :key="index" class="upload-item">
  152. <div class="upload-item-preview">
  153. <el-image :src="item.url" fit="cover" />
  154. </div>
  155. <div class="upload-item-info">
  156. <el-input v-model="item.name" placeholder="请输入图片名称">
  157. <template #append>{{ item.ext }}</template>
  158. </el-input>
  159. </div>
  160. <div class="upload-item-ops">
  161. <el-button type="danger" :icon="Delete" circle size="small" @click="removeUploadFile(index)" />
  162. </div>
  163. </div>
  164. </div>
  165. <template #footer>
  166. <el-button @click="uploadDialogVisible = false">取消</el-button>
  167. <el-button type="primary" :loading="uploading" @click="startBatchUpload">开始上传</el-button>
  168. </template>
  169. </el-dialog>
  170. <!-- 加入任务设置弹窗 -->
  171. <el-dialog v-model="taskDialogVisible" title="加入标注任务" width="450px">
  172. <el-form :model="taskForm" :rules="taskRules" ref="taskFormRef" label-width="100px">
  173. <el-form-item label="项目名称" prop="project_name">
  174. <el-input v-model="taskForm.project_name" placeholder="请输入项目名称" clearable />
  175. </el-form-item>
  176. <el-form-item label="任务标签" prop="tags">
  177. <div class="tag-group">
  178. <el-tag
  179. v-for="(tag, index) in taskForm.selectedTags"
  180. :key="tag.id"
  181. closable
  182. @close="handleRemoveTag(index)"
  183. :type="tag.type === 'category' ? 'warning' : 'primary'"
  184. class="tag-item"
  185. >
  186. {{ tag.name }} <span v-if="tag.type === 'category'" class="tag-type-hint">(分类)</span>
  187. </el-tag>
  188. <el-cascader
  189. :key="cascaderKey"
  190. v-model="tempTagValue"
  191. :options="tagTree"
  192. :props="{ checkStrictly: true, emitPath: false }"
  193. placeholder="添加标签"
  194. @change="handleAddTag"
  195. filterable
  196. clearable
  197. class="tag-adder"
  198. >
  199. <template #default="{ data }">
  200. <span>
  201. <el-icon v-if="data.type === 'category'" style="margin-right: 4px; color: #E6A23C;">
  202. <Folder />
  203. </el-icon>
  204. <el-icon v-else style="margin-right: 4px; color: #409EFF;">
  205. <Tickets />
  206. </el-icon>
  207. {{ data.label }}
  208. </span>
  209. </template>
  210. </el-cascader>
  211. </div>
  212. </el-form-item>
  213. </el-form>
  214. <template #footer>
  215. <span class="dialog-footer">
  216. <el-button @click="taskDialogVisible = false">取消</el-button>
  217. <el-button type="primary" @click="confirmAddTask" :loading="taskAdding">
  218. 确认加入
  219. </el-button>
  220. </span>
  221. </template>
  222. </el-dialog>
  223. </div>
  224. </template>
  225. <script setup lang="ts">
  226. import { ref, onMounted, reactive, computed } from 'vue'
  227. import { Plus, Edit, Delete, Upload, Picture, Folder, FolderOpened, Tickets } from '@element-plus/icons-vue'
  228. import { ElMessage, ElMessageBox } from 'element-plus'
  229. import { imageApi, type ImageCategory, type ImageItem } from '@/api/image'
  230. import { documentApi } from '@/api/document'
  231. import axios from 'axios'
  232. // --- 数据定义 ---
  233. const loading = ref(false)
  234. const categories = ref<ImageCategory[]>([])
  235. const images = ref<ImageItem[]>([])
  236. const total = ref(0)
  237. const currentCategory = ref<ImageCategory | null>(null)
  238. const defaultProps = {
  239. children: 'children',
  240. label: 'type_name'
  241. }
  242. const queryParams = reactive({
  243. category_id: '',
  244. page: 1,
  245. page_size: 10
  246. })
  247. const categoryDialogVisible = ref(false)
  248. const uploadDialogVisible = ref(false)
  249. const uploading = ref(false)
  250. const uploadFileList = ref<any[]>([])
  251. const uploadRef = ref()
  252. const imageRefs = reactive<any>({})
  253. const selectedIds = ref<string[]>([])
  254. // 任务相关状态
  255. const taskAdding = ref(false)
  256. const taskDialogVisible = ref(false)
  257. const tempTagValue = ref<number | null>(null)
  258. const cascaderKey = ref(0)
  259. const taskForm = reactive({
  260. project_name: '',
  261. selectedTags: [] as { id: number, name: string, type?: string }[]
  262. })
  263. // 标签相关
  264. const tagTree = ref<any[]>([])
  265. const handleAddTag = (val: any) => {
  266. if (!val) return
  267. const id = val
  268. const node = findTagNode(tagTree.value, id)
  269. if (!node) return
  270. // 检查是否是标签分类(type='category')
  271. if (node.type === 'category' && node.children && node.children.length > 0) {
  272. // 如果是标签分类,添加该分类下的所有标签项
  273. node.children.forEach((child: any) => {
  274. if (!taskForm.selectedTags.find(t => t.id === child.value)) {
  275. taskForm.selectedTags.push({ id: child.value, name: child.label, type: child.type })
  276. }
  277. })
  278. } else {
  279. // 如果是标签项,直接添加
  280. if (node.type !== 'category' && !taskForm.selectedTags.find(t => t.id === id)) {
  281. taskForm.selectedTags.push({ id, name: node.label, type: node.type })
  282. }
  283. }
  284. // 清空选择器并增加 key 以强制重置 (清除搜索缓存等)
  285. tempTagValue.value = null
  286. cascaderKey.value++
  287. }
  288. const handleRemoveTag = (index: number) => {
  289. taskForm.selectedTags.splice(index, 1)
  290. }
  291. const findTagName = (nodes: any[], id: number): string | null => {
  292. for (const node of nodes) {
  293. if (node.value === id) return node.label
  294. if (node.children) {
  295. const name = findTagName(node.children, id)
  296. if (name) return name
  297. }
  298. }
  299. return null
  300. }
  301. const findTagNode = (nodes: any[], id: number): any | null => {
  302. for (const node of nodes) {
  303. if (node.value === id) return node
  304. if (node.children) {
  305. const found = findTagNode(node.children, id)
  306. if (found) return found
  307. }
  308. }
  309. return null
  310. }
  311. const fetchTagTree = async () => {
  312. try {
  313. const res = await documentApi.getTagTree()
  314. if (res.code === '000000' || res.code === 200 || res.code === 0) {
  315. tagTree.value = formatTagTree(res.data)
  316. }
  317. } catch (error: any) {
  318. console.error('获取标签树失败:', error)
  319. }
  320. }
  321. const formatTagTree = (data: any[]): any[] => {
  322. return data.map(node => ({
  323. value: node.id,
  324. label: node.name,
  325. type: node.type,
  326. children: node.children && node.children.length > 0 ? formatTagTree(node.children) : undefined
  327. }))
  328. }
  329. const taskRules = {
  330. project_name: [
  331. { required: true, message: '请输入项目名称', trigger: 'blur' },
  332. { min: 2, max: 50, message: '长度在 2 到 50 个字符', trigger: 'blur' }
  333. ]
  334. }
  335. const taskFormRef = ref()
  336. const isCategoryBatch = ref(false)
  337. const categoryForm = reactive({
  338. id: '',
  339. type_name: '',
  340. parent_id: '0',
  341. remark: ''
  342. })
  343. const currentCategoryName = computed(() => {
  344. return currentCategory.value ? currentCategory.value.type_name : '全部分类'
  345. })
  346. const previewSrcList = computed(() => {
  347. return images.value.map(img => img.image_url).filter(url => !!url)
  348. })
  349. // --- 方法定义 ---
  350. const fetchCategories = async () => {
  351. try {
  352. const res = await imageApi.getCategories()
  353. if (res.code === '000000' || res.code === 0) {
  354. categories.value = [
  355. { id: '0', type_name: '全部分类', parent_id: '-1', children: res.data, created_time: '', updated_time: '' }
  356. ]
  357. }
  358. } catch (error: any) {
  359. console.error('获取分类失败:', error)
  360. }
  361. }
  362. const fetchImages = async () => {
  363. loading.value = true
  364. // 清空引用
  365. for (const key in imageRefs) {
  366. delete imageRefs[key]
  367. }
  368. try {
  369. const res = await imageApi.getList(queryParams)
  370. if (res.code === '000000' || res.code === 0) {
  371. images.value = res.data.list
  372. total.value = res.data.total
  373. }
  374. } catch (error: any) {
  375. console.error('获取图片失败:', error)
  376. } finally {
  377. loading.value = false
  378. }
  379. }
  380. const handleSelectionChange = (selection: ImageItem[]) => {
  381. selectedIds.value = selection.map(item => item.id)
  382. }
  383. const handleBatchAddToTask = async () => {
  384. if (selectedIds.value.length === 0) return
  385. isCategoryBatch.value = false
  386. taskForm.project_name = '' // 重置项目名称
  387. taskForm.selectedTags = [] // 重置标签
  388. tempTagValue.value = null
  389. if (taskFormRef.value) {
  390. taskFormRef.value.clearValidate()
  391. }
  392. // 获取标签树
  393. if (tagTree.value.length === 0) {
  394. await fetchTagTree()
  395. }
  396. taskDialogVisible.value = true
  397. }
  398. const handleCategoryBatchAddToTask = async () => {
  399. const categoryId = currentCategory.value?.id === '0' ? '' : (currentCategory.value?.id || '')
  400. try {
  401. loading.value = true
  402. const res = await imageApi.categoryBatchCheck(categoryId)
  403. loading.value = false
  404. //if (res.code !== 0) {
  405. // ElMessage.error(res.message || '检查分类失败')
  406. // return
  407. //}
  408. // 兼容成功错误码 "000000"、0 和 "0" 等情况
  409. const successCodes = [0, "0", "000000"]
  410. if (!successCodes.includes(res.code)) {
  411. ElMessage.error(res.message || '检查分类失败')
  412. return
  413. }
  414. const { image_count, sub_categories } = res.data
  415. if (image_count === 0) {
  416. ElMessage.warning('该分类下无图片,无法推送')
  417. return
  418. }
  419. let confirmMsg = `该分类下共有 ${image_count} 张图片。`
  420. if (sub_categories && sub_categories.length > 0) {
  421. confirmMsg += `涉及子分类: ${sub_categories.join(', ')}。`
  422. }
  423. confirmMsg += '是否继续推送?'
  424. await ElMessageBox.confirm(confirmMsg, '确认推送', {
  425. confirmButtonText: '继续',
  426. cancelButtonText: '取消',
  427. type: 'info'
  428. })
  429. // 通过后才打开弹窗
  430. isCategoryBatch.value = true
  431. taskForm.project_name = ''
  432. taskForm.selectedTags = []
  433. tempTagValue.value = null
  434. if (taskFormRef.value) {
  435. taskFormRef.value.clearValidate()
  436. }
  437. // 获取标签树
  438. if (tagTree.value.length === 0) {
  439. await fetchTagTree()
  440. }
  441. taskDialogVisible.value = true
  442. } catch (error: any) {
  443. loading.value = false
  444. if (error !== 'cancel') {
  445. console.error('分类检查异常:', error)
  446. }
  447. }
  448. }
  449. const confirmAddTask = async () => {
  450. if (!taskFormRef.value) return
  451. try {
  452. await taskFormRef.value.validate()
  453. } catch (error: any) {
  454. return
  455. }
  456. taskAdding.value = true
  457. try {
  458. const tagNames = taskForm.selectedTags.map(t => t.name)
  459. let res
  460. if (isCategoryBatch.value) {
  461. const categoryId = currentCategory.value?.id === '0' ? '' : (currentCategory.value?.id || '')
  462. res = await imageApi.categoryBatchAddToTask(categoryId, taskForm.project_name, tagNames)
  463. } else {
  464. res = await imageApi.batchAddToTask(selectedIds.value, taskForm.project_name, tagNames)
  465. }
  466. const { code, message, data } = res
  467. if ((code === '000000' || code === 0)) {
  468. let successMsg = message || '批量加入任务成功'
  469. if (isCategoryBatch.value && data) {
  470. if (data.image_count === 0) {
  471. ElMessage.warning('该分类下无图片,无法推送')
  472. taskAdding.value = false
  473. return
  474. }
  475. if (data.sub_categories && data.sub_categories.length > 0) {
  476. successMsg = `成功推送 ${data.image_count} 张图片。涉及子分类: ${data.sub_categories.join(', ')}`
  477. }
  478. }
  479. ElMessage.success(successMsg)
  480. selectedIds.value = []
  481. taskDialogVisible.value = false
  482. fetchImages()
  483. } else {
  484. ElMessage.error(message || '操作失败')
  485. }
  486. } catch (error: any) {
  487. console.error('批量加入任务失败:', error)
  488. ElMessage.error('操作异常')
  489. } finally {
  490. taskAdding.value = false
  491. }
  492. }
  493. const handleCategoryClick = (data: ImageCategory) => {
  494. currentCategory.value = data
  495. queryParams.category_id = data.id === '0' ? '' : data.id
  496. queryParams.page = 1
  497. fetchImages()
  498. }
  499. const handleEditSelectedCategory = () => {
  500. if (currentCategory.value && currentCategory.value.id !== '0') {
  501. handleEditCategory(currentCategory.value)
  502. }
  503. }
  504. const handleDeleteSelectedCategory = () => {
  505. if (currentCategory.value && currentCategory.value.id !== '0') {
  506. handleDeleteCategory(currentCategory.value)
  507. }
  508. }
  509. const handleAddCategory = () => {
  510. categoryForm.id = ''
  511. categoryForm.type_name = ''
  512. categoryForm.remark = ''
  513. categoryForm.parent_id = currentCategory.value?.id === '0' ? '0' : (currentCategory.value?.id || '0')
  514. categoryDialogVisible.value = true
  515. }
  516. const handleEditCategory = (data: ImageCategory) => {
  517. categoryForm.id = data.id
  518. categoryForm.type_name = data.type_name
  519. categoryForm.remark = data.remark || ''
  520. categoryForm.parent_id = data.parent_id
  521. categoryDialogVisible.value = true
  522. }
  523. const handleDeleteCategory = (data: ImageCategory) => {
  524. ElMessageBox.confirm(`确定要删除分类 "${data.type_name}" 吗?`, '提示', {
  525. type: 'warning'
  526. }).then(async () => {
  527. try {
  528. const res = await imageApi.deleteCategory(data.id)
  529. if (res.code === '000000' || res.code === 0) {
  530. ElMessage.success('删除成功')
  531. fetchCategories()
  532. } else {
  533. ElMessage.error(res.message)
  534. }
  535. } catch (error: any) {
  536. ElMessage.error(error.message || '删除失败')
  537. }
  538. })
  539. }
  540. const submitCategory = async () => {
  541. if (!categoryForm.type_name) {
  542. return ElMessage.warning('请输入分类名称')
  543. }
  544. try {
  545. let res
  546. if (categoryForm.id) {
  547. res = await imageApi.updateCategory(categoryForm.id, categoryForm)
  548. } else {
  549. res = await imageApi.addCategory(categoryForm)
  550. }
  551. if (res.code === '000000' || res.code === 0) {
  552. ElMessage.success(categoryForm.id ? '更新成功' : '新增成功')
  553. categoryDialogVisible.value = false
  554. fetchCategories()
  555. } else {
  556. ElMessage.error(res.message)
  557. }
  558. } catch (error: any) {
  559. ElMessage.error(error.message || '操作失败')
  560. }
  561. }
  562. const handleFileChange = (file: any) => {
  563. const categoryId = queryParams.category_id || '0'
  564. if (categoryId === '0') {
  565. ElMessage.warning('请先在左侧选择一个具体的分类再上传图片')
  566. return
  567. }
  568. const name = file.name.replace(/\.[^/.]+$/, "")
  569. const ext = file.name.split('.').pop()
  570. uploadFileList.value.push({
  571. file: file.raw,
  572. name: name,
  573. ext: `.${ext}`,
  574. url: URL.createObjectURL(file.raw)
  575. })
  576. uploadDialogVisible.value = true
  577. }
  578. const removeUploadFile = (index: number) => {
  579. uploadFileList.value.splice(index, 1)
  580. if (uploadFileList.value.length === 0) {
  581. uploadDialogVisible.value = false
  582. }
  583. }
  584. const startBatchUpload = async () => {
  585. if (uploadFileList.value.length === 0) return
  586. uploading.value = true
  587. let successCount = 0
  588. let failCount = 0
  589. const categoryId = queryParams.category_id
  590. for (const item of uploadFileList.value) {
  591. try {
  592. // 1. 获取预签名 URL
  593. const res = await imageApi.getUploadUrl(item.file.name, item.file.type || 'application/octet-stream')
  594. //if (res.code !== 0) throw new Error(res.message)
  595. // 兼容成功错误码 "000000"、0 和 "0" 等情况
  596. const successCodes = [0, "0", "000000"]
  597. if (!successCodes.includes(res.code)) throw new Error(res.message)
  598. const { upload_url, file_url } = res.data
  599. // 2. 直接上传到 MinIO
  600. await axios.put(upload_url, item.file, {
  601. headers: { 'Content-Type': item.file.type || 'application/octet-stream' }
  602. })
  603. // 3. 保存到数据库 (使用修改后的名字)
  604. await imageApi.add({
  605. image_name: item.name,
  606. image_url: file_url,
  607. image_type: categoryId,
  608. description: ''
  609. })
  610. successCount++
  611. } catch (error: any) {
  612. console.error(`图片 ${item.name} 上传失败:`, error)
  613. failCount++
  614. }
  615. }
  616. uploading.value = false
  617. uploadDialogVisible.value = false
  618. uploadFileList.value = []
  619. if (failCount === 0) {
  620. ElMessage.success(`成功上传 ${successCount} 张图片`)
  621. } else {
  622. ElMessage.warning(`上传完成:成功 ${successCount},失败 ${failCount}`)
  623. }
  624. fetchImages()
  625. }
  626. const handleDeleteImage = (row: ImageItem) => {
  627. ElMessageBox.confirm(`确定要删除图片 "${row.image_name}" 吗?`, '提示', {
  628. type: 'warning'
  629. }).then(async () => {
  630. try {
  631. const res = await imageApi.delete(row.id)
  632. if (res.code === '000000' || res.code === 0) {
  633. ElMessage.success('删除成功')
  634. fetchImages()
  635. } else {
  636. ElMessage.error(res.message)
  637. }
  638. } catch (error: any) {
  639. ElMessage.error(error.message || '删除失败')
  640. }
  641. })
  642. }
  643. const handlePreview = (index: number) => {
  644. if (imageRefs[index]) {
  645. // 触发 el-image 的预览
  646. const el = imageRefs[index].$el.querySelector('img')
  647. if (el) el.click()
  648. }
  649. }
  650. const handleSizeChange = (val: number) => {
  651. queryParams.page_size = val
  652. fetchImages()
  653. }
  654. const handleCurrentChange = (val: number) => {
  655. queryParams.page = val
  656. fetchImages()
  657. }
  658. const formatDateTime = (dateStr: string) => {
  659. if (!dateStr) return '-'
  660. const date = new Date(dateStr)
  661. return date.toLocaleString()
  662. }
  663. onMounted(() => {
  664. fetchCategories()
  665. fetchImages()
  666. })
  667. </script>
  668. <style scoped>
  669. .tag-group {
  670. display: flex;
  671. flex-wrap: wrap;
  672. gap: 8px;
  673. align-items: center;
  674. min-height: 32px;
  675. }
  676. .tag-item {
  677. margin: 2px 0;
  678. }
  679. .tag-type-hint {
  680. font-size: 12px;
  681. margin-left: 4px;
  682. opacity: 0.7;
  683. }
  684. .tag-adder {
  685. width: 120px;
  686. }
  687. .image-management {
  688. height: calc(100vh - 120px);
  689. background-color: #f5f7fa;
  690. padding: 20px;
  691. }
  692. .category-aside {
  693. background-color: #fff;
  694. border-radius: 8px;
  695. margin-right: 20px;
  696. display: flex;
  697. flex-direction: column;
  698. box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.05);
  699. }
  700. .aside-header-buttons {
  701. padding: 20px;
  702. border-bottom: 1px solid #f0f2f5;
  703. display: flex;
  704. gap: 10px;
  705. flex-wrap: wrap;
  706. }
  707. .aside-header-buttons .el-button {
  708. margin: 0;
  709. flex: 1;
  710. }
  711. .aside-content {
  712. flex: 1;
  713. padding: 10px;
  714. overflow-y: auto;
  715. }
  716. .custom-tree-node {
  717. flex: 1;
  718. display: flex;
  719. align-items: center;
  720. justify-content: space-between;
  721. font-size: 14px;
  722. padding-right: 8px;
  723. }
  724. .custom-tree-node .label {
  725. display: flex;
  726. align-items: center;
  727. gap: 5px;
  728. }
  729. .custom-tree-node .ops {
  730. display: none;
  731. gap: 8px;
  732. color: #909399;
  733. }
  734. .el-tree-node__content:hover .ops {
  735. display: flex;
  736. }
  737. .ops .el-icon:hover {
  738. color: #409eff;
  739. }
  740. .image-main {
  741. background-color: #fff;
  742. border-radius: 8px;
  743. padding: 20px;
  744. box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.05);
  745. display: flex;
  746. flex-direction: column;
  747. }
  748. .main-header {
  749. display: flex;
  750. justify-content: space-between;
  751. align-items: center;
  752. margin-bottom: 20px;
  753. }
  754. .header-right {
  755. display: flex;
  756. align-items: center;
  757. gap: 12px;
  758. }
  759. .current-category {
  760. font-size: 18px;
  761. font-weight: bold;
  762. color: #303133;
  763. }
  764. .image-list {
  765. flex: 1;
  766. }
  767. .table-image {
  768. width: 80px;
  769. height: 60px;
  770. border-radius: 4px;
  771. }
  772. .image-error {
  773. display: flex;
  774. justify-content: center;
  775. align-items: center;
  776. width: 100%;
  777. height: 100%;
  778. background: #f5f7fa;
  779. color: #909399;
  780. font-size: 20px;
  781. }
  782. .upload-list-container {
  783. max-height: 400px;
  784. overflow-y: auto;
  785. padding: 10px;
  786. }
  787. .upload-item {
  788. display: flex;
  789. align-items: center;
  790. gap: 15px;
  791. padding: 10px;
  792. border-bottom: 1px solid #f0f2f5;
  793. }
  794. .upload-item:last-child {
  795. border-bottom: none;
  796. }
  797. .upload-item-preview {
  798. width: 60px;
  799. height: 60px;
  800. border-radius: 4px;
  801. overflow: hidden;
  802. flex-shrink: 0;
  803. }
  804. .upload-item-info {
  805. flex: 1;
  806. }
  807. .upload-item-ops {
  808. flex-shrink: 0;
  809. }
  810. .pagination-container {
  811. margin-top: 20px;
  812. display: flex;
  813. justify-content: flex-end;
  814. }
  815. .upload-btn {
  816. display: inline-block;
  817. }
  818. </style>