jvs-ppt-ui 0.0.17 → 0.0.19

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1524 @@
1
+ <script setup lang="ts">
2
+ import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
3
+ import {
4
+ Search,
5
+ CircleCloseFilled,
6
+ View,
7
+ VideoPlay
8
+ } from '@element-plus/icons-vue'
9
+ import { useSlides } from '../../composables/useSlides'
10
+ import { useDamAuth } from './useDamAuth'
11
+ import type { MaterialItem } from '../TemplateGallery.vue'
12
+
13
+ export interface DamProjectItem {
14
+ id: string
15
+ name: string
16
+ type: 'PERSONAL' | 'TEAM' | string
17
+ description?: string
18
+ }
19
+
20
+ export interface DamFolderNode {
21
+ id: string
22
+ projectId: string
23
+ parentId?: string
24
+ name: string
25
+ defaultFlag?: boolean
26
+ children?: DamFolderNode[]
27
+ }
28
+
29
+ const emit = defineEmits<{
30
+ (e: 'select', item: MaterialItem): void
31
+ (e: 'preview', item: MaterialItem, list: MaterialItem[], event?: Event): void
32
+ (e: 'use-as-ref', item: MaterialItem): void
33
+ }>()
34
+
35
+ const { notify } = useSlides()
36
+ const { damFetchJson, isDamLoggedIn } = useDamAuth()
37
+
38
+ // 搜索与过滤状态
39
+ const searchKeyword = ref('')
40
+ const mediaTypeFilter = ref<'ALL' | 'IMAGE' | 'VIDEO'>('ALL')
41
+ const activeSourceSubTab = ref<'recent' | 'ai' | 'uploads'>('recent')
42
+
43
+ // 项目与文件夹树状态
44
+ const projects = ref<DamProjectItem[]>([])
45
+ const folderTrees = ref<Record<string, DamFolderNode[]>>({})
46
+ const expandedProjectIds = ref<Record<string, boolean>>({})
47
+ const selectedProjectId = ref<string>('')
48
+ const selectedFolderId = ref<string>('')
49
+ const selectedTreeLabel = ref<string>('全部项目资产')
50
+ const treePopoverVisible = ref(false)
51
+ const treeTriggerRef = ref<HTMLElement | null>(null)
52
+
53
+ // 列表与分页状态
54
+ const loading = ref(false)
55
+ const loadingMore = ref(false)
56
+ const isUploading = ref(false)
57
+ const currentPage = ref(1)
58
+ const pageSize = 20
59
+ const reachEnd = ref(false)
60
+ const assetList = ref<MaterialItem[]>([])
61
+ const fileInputRef = ref<HTMLInputElement | null>(null)
62
+
63
+ const PROJECT_COLORS = ['#3b82f6', '#10b981', '#8b5cf6', '#f59e0b', '#ec4899']
64
+
65
+ function getProjectColor(proj: DamProjectItem, idx: number): string {
66
+ if (proj.type === 'PERSONAL') return '#3b82f6'
67
+ return PROJECT_COLORS[idx % PROJECT_COLORS.length] || '#10b981'
68
+ }
69
+
70
+ function getProjectDisplayName(proj: DamProjectItem): string {
71
+ if (!proj) return '项目'
72
+ if (proj.name === 'Personal project' || proj.type === 'PERSONAL') {
73
+ return proj.name === 'Personal project' ? '个人项目' : proj.name
74
+ }
75
+ return proj.name
76
+ }
77
+
78
+ /**
79
+ * 组装多级文件夹树结构
80
+ */
81
+ function buildFolderTree(rawList: any[]): DamFolderNode[] {
82
+ if (!Array.isArray(rawList) || rawList.length === 0) return []
83
+ const hasNested = rawList.some(item => Array.isArray(item.children) && item.children.length > 0)
84
+ if (hasNested) return rawList
85
+
86
+ const nodeMap = new Map<string, DamFolderNode>()
87
+ const roots: DamFolderNode[] = []
88
+
89
+ rawList.forEach(item => {
90
+ nodeMap.set(String(item.id), {
91
+ id: String(item.id),
92
+ projectId: String(item.projectId || ''),
93
+ parentId: item.parentId ? String(item.parentId) : '0',
94
+ name: item.name || '文件夹',
95
+ defaultFlag: item.defaultFlag,
96
+ children: []
97
+ })
98
+ })
99
+
100
+ nodeMap.forEach(node => {
101
+ const pid = node.parentId
102
+ if (pid && pid !== '0' && nodeMap.has(pid)) {
103
+ nodeMap.get(pid)!.children!.push(node)
104
+ } else {
105
+ roots.push(node)
106
+ }
107
+ })
108
+
109
+ return roots
110
+ }
111
+
112
+ /**
113
+ * 拉取当前用户的项目列表及默认展开项目的文件夹树
114
+ */
115
+ async function fetchProjectsAndFolders() {
116
+ if (!isDamLoggedIn.value) return
117
+ try {
118
+ const res = await damFetchJson('/project/my')
119
+ let rawProjects: any[] = []
120
+ if (Array.isArray(res)) rawProjects = res
121
+ else if (Array.isArray(res?.data?.data)) rawProjects = res.data.data
122
+ else if (Array.isArray(res?.data)) rawProjects = res.data
123
+
124
+ if (rawProjects.length === 0) {
125
+ // 尝试初始化并获取个人项目
126
+ const pRes = await damFetchJson('/project/personal')
127
+ const pData = pRes?.data?.data || pRes?.data || pRes
128
+ if (pData && pData.id) {
129
+ rawProjects = [pData]
130
+ }
131
+ }
132
+
133
+ projects.value = rawProjects.map(p => ({
134
+ id: String(p.id),
135
+ name: p.name || '未命名项目',
136
+ type: p.type || 'PERSONAL',
137
+ description: p.description
138
+ }))
139
+
140
+ // 默认预加载首个项目的文件夹树
141
+ const firstProj = projects.value[0]
142
+ if (firstProj) {
143
+ expandedProjectIds.value[firstProj.id] = true
144
+ await fetchFolderTreeForProject(firstProj.id)
145
+ }
146
+ } catch (err) {
147
+ console.warn('[MyAssets] 获取项目列表失败:', err)
148
+ }
149
+ }
150
+
151
+ async function fetchFolderTreeForProject(projectId: string) {
152
+ if (!projectId) return
153
+ try {
154
+ const res = await damFetchJson('/folder/tree', {
155
+ params: { projectId }
156
+ })
157
+ let rawList: any[] = []
158
+ if (Array.isArray(res)) rawList = res
159
+ else if (Array.isArray(res?.data?.data)) rawList = res.data.data
160
+ else if (Array.isArray(res?.data)) rawList = res.data
161
+
162
+ folderTrees.value = {
163
+ ...folderTrees.value,
164
+ [projectId]: buildFolderTree(rawList)
165
+ }
166
+ } catch (err) {
167
+ console.warn('[MyAssets] 获取文件夹树失败:', err)
168
+ }
169
+ }
170
+
171
+ async function toggleProjectExpand(projectId: string, e: Event) {
172
+ e.stopPropagation()
173
+ const nextState = !expandedProjectIds.value[projectId]
174
+ expandedProjectIds.value[projectId] = nextState
175
+ if (nextState && !folderTrees.value[projectId]) {
176
+ await fetchFolderTreeForProject(projectId)
177
+ }
178
+ }
179
+
180
+ function selectAllAssets() {
181
+ selectedProjectId.value = ''
182
+ selectedFolderId.value = ''
183
+ selectedTreeLabel.value = '全部项目资产'
184
+ treePopoverVisible.value = false
185
+ reloadAssets()
186
+ }
187
+
188
+ function selectProjectNode(proj: DamProjectItem) {
189
+ selectedProjectId.value = proj.id
190
+ selectedFolderId.value = ''
191
+ selectedTreeLabel.value = getProjectDisplayName(proj)
192
+ treePopoverVisible.value = false
193
+ if (!folderTrees.value[proj.id]) {
194
+ fetchFolderTreeForProject(proj.id)
195
+ }
196
+ reloadAssets()
197
+ }
198
+
199
+ function selectFolderNode(proj: DamProjectItem, folder: DamFolderNode) {
200
+ selectedProjectId.value = proj.id
201
+ selectedFolderId.value = folder.id
202
+ selectedTreeLabel.value = `${getProjectDisplayName(proj)} / ${folder.name}`
203
+ treePopoverVisible.value = false
204
+ reloadAssets()
205
+ }
206
+
207
+ function isVideoAsset(raw: any): boolean {
208
+ const assetType = String(raw.assetType || raw.mediaType || raw.type || '').toUpperCase()
209
+ if (assetType === 'VIDEO') return true
210
+ const u = String(raw.fileUrl || raw.url || raw.ossUrl || raw.imageUrl || '').split('?')[0].toLowerCase()
211
+ return u.endsWith('.mp4') || u.endsWith('.webm') || u.endsWith('.mov') || u.endsWith('.m4v')
212
+ }
213
+
214
+ /**
215
+ * 将后端 Asset / RecentAssetVO / AiGenerationRecord / UploadFile 统一映射为 MaterialItem
216
+ */
217
+ function normalizeDamAsset(raw: any): MaterialItem | null {
218
+ if (!raw) return null
219
+ // semantic/search 接口返回的条目可能包裹在 asset 属性中
220
+ const entity = raw.asset && typeof raw.asset === 'object' ? { ...raw.asset, score: raw.score } : raw
221
+
222
+ const url =
223
+ entity.fileUrl ||
224
+ entity.imageUrl ||
225
+ entity.ossUrl ||
226
+ entity.fileLink ||
227
+ entity.url ||
228
+ entity.previewUrl ||
229
+ entity.sourceUrl ||
230
+ ''
231
+ if (!url) return null
232
+
233
+ const isVid = isVideoAsset(entity)
234
+ let meta: any = {}
235
+ if (entity.metaDataJson) {
236
+ try {
237
+ meta = typeof entity.metaDataJson === 'string' ? JSON.parse(entity.metaDataJson) : entity.metaDataJson
238
+ } catch {
239
+ meta = {}
240
+ }
241
+ }
242
+
243
+ const rawTitle =
244
+ entity.name ||
245
+ entity.prompt ||
246
+ meta.prompt ||
247
+ entity.fileName ||
248
+ entity.fileOriginalName ||
249
+ entity.portrait?.summary ||
250
+ (isVid ? '视频资产' : '图片资产')
251
+
252
+ const cleanTitle = String(rawTitle).replace(/\.[a-zA-Z0-9]+$/, '').replace(/_\d{10,14}$/, '')
253
+ const sourceType = String(entity.sourceType || '').toUpperCase()
254
+ let badgeLabel = '资产'
255
+ if (isVid) badgeLabel = '视频'
256
+ else if (sourceType === 'GENERATE' || activeSourceSubTab.value === 'ai' || entity.recordId || entity.sessionId) {
257
+ badgeLabel = 'AI 生成'
258
+ } else if (sourceType === 'UPLOAD' || activeSourceSubTab.value === 'uploads') {
259
+ badgeLabel = '已上传'
260
+ }
261
+
262
+ const w = Number(meta.width || entity.width || entity.originalWidth || 0)
263
+ const h = Number(meta.height || entity.height || entity.originalHeight || 0)
264
+
265
+ return {
266
+ id: String(entity.id || entity.fileId || entity.recordId || `dam-${Math.random().toString(36).slice(2, 9)}`),
267
+ title: cleanTitle || '我的素材',
268
+ type: isVid ? 'video' : 'image',
269
+ assetType: isVid ? 'VIDEO' : 'IMAGE',
270
+ sourceType: sourceType || (activeSourceSubTab.value === 'ai' ? 'GENERATE' : 'UPLOAD'),
271
+ badgeLabel,
272
+ url,
273
+ previewUrl: entity.previewUrl || url,
274
+ sourceUrl: url,
275
+ originalWidth: w > 0 ? w : undefined,
276
+ originalHeight: h > 0 ? h : undefined,
277
+ projectId: entity.projectId,
278
+ folderId: entity.folderId,
279
+ model: entity.model || meta.model || '',
280
+ createTime: entity.createTime || entity.uploadTime || ''
281
+ }
282
+ }
283
+
284
+ function extractRecordsFromResponse(res: any): any[] {
285
+ if (!res) return []
286
+ if (Array.isArray(res)) return res
287
+ if (Array.isArray(res?.data?.records)) return res.data.records
288
+ if (Array.isArray(res?.records)) return res.records
289
+ if (Array.isArray(res?.data?.data?.records)) return res.data.data.records
290
+ if (Array.isArray(res?.data?.data)) return res.data.data
291
+ if (Array.isArray(res?.data?.list)) return res.data.list
292
+ if (Array.isArray(res?.data)) return res.data
293
+ return []
294
+ }
295
+
296
+ /**
297
+ * 加载我的素材列表
298
+ */
299
+ async function fetchMyAssets(page = 1, append = false) {
300
+ if (!isDamLoggedIn.value) {
301
+ assetList.value = []
302
+ reachEnd.value = true
303
+ return
304
+ }
305
+
306
+ try {
307
+ loading.value = !append
308
+ loadingMore.value = append
309
+
310
+ const kw = searchKeyword.value.trim()
311
+ const assetTypeParam = mediaTypeFilter.value === 'ALL' ? undefined : mediaTypeFilter.value
312
+ let rawItems: any[] = []
313
+
314
+ if (activeSourceSubTab.value === 'ai') {
315
+ // 1. AI 创作历史记录 (/jvs-dam/ai/record/page)
316
+ const res = await damFetchJson('/ai/record/page', {
317
+ params: {
318
+ current: page,
319
+ size: pageSize,
320
+ mediaType: assetTypeParam || 'IMAGE',
321
+ operationType: 'GENERATE'
322
+ }
323
+ })
324
+ rawItems = extractRecordsFromResponse(res)
325
+ } else if (activeSourceSubTab.value === 'uploads') {
326
+ // 2. 本地已上传文件列表 (/jvs-dam/upload/file/list)
327
+ const res = await damFetchJson('/upload/file/list', {
328
+ params: {
329
+ current: page,
330
+ size: pageSize,
331
+ projectId: selectedProjectId.value || undefined,
332
+ folderId: selectedFolderId.value || undefined,
333
+ name: kw || undefined
334
+ }
335
+ })
336
+ rawItems = extractRecordsFromResponse(res)
337
+ } else {
338
+ // 3. 最近资产 / 项目文件夹筛选 / 语义检索
339
+ if (kw && !selectedFolderId.value) {
340
+ // 有搜索词且未限定具体子文件夹时,优先走语义检索接口(后端自带关键词降级)
341
+ try {
342
+ const semRes = await damFetchJson('/asset/semantic/search', {
343
+ params: {
344
+ query: kw,
345
+ assetType: assetTypeParam,
346
+ projectId: selectedProjectId.value || undefined,
347
+ current: page,
348
+ size: pageSize
349
+ }
350
+ })
351
+ rawItems = extractRecordsFromResponse(semRes)
352
+ } catch {
353
+ // 降级到 recent 接口
354
+ const recRes = await damFetchJson('/asset/recent', {
355
+ params: {
356
+ projectId: selectedProjectId.value || undefined,
357
+ folderId: selectedFolderId.value || undefined,
358
+ name: kw,
359
+ assetType: assetTypeParam,
360
+ current: page,
361
+ size: pageSize
362
+ }
363
+ })
364
+ rawItems = extractRecordsFromResponse(recRes)
365
+ }
366
+ } else if (selectedProjectId.value && selectedFolderId.value) {
367
+ // 选中了具体文件夹时,走 /asset/page
368
+ const pageRes = await damFetchJson('/asset/page', {
369
+ params: {
370
+ projectId: selectedProjectId.value,
371
+ folderId: selectedFolderId.value,
372
+ name: kw || undefined,
373
+ current: page,
374
+ size: pageSize
375
+ }
376
+ })
377
+ rawItems = extractRecordsFromResponse(pageRes)
378
+ } else {
379
+ // 默认浏览最近资产(支持全部项目或单个 projectId)
380
+ const recRes = await damFetchJson('/asset/recent', {
381
+ params: {
382
+ projectId: selectedProjectId.value || undefined,
383
+ folderId: selectedFolderId.value || undefined,
384
+ name: kw || undefined,
385
+ assetType: assetTypeParam,
386
+ current: page,
387
+ size: pageSize
388
+ }
389
+ })
390
+ rawItems = extractRecordsFromResponse(recRes)
391
+ }
392
+ }
393
+
394
+ const normalized = rawItems
395
+ .map(normalizeDamAsset)
396
+ .filter((item): item is MaterialItem => Boolean(item))
397
+ .filter(item => {
398
+ if (mediaTypeFilter.value === 'IMAGE') return item.type !== 'video'
399
+ if (mediaTypeFilter.value === 'VIDEO') return item.type === 'video'
400
+ if (kw && activeSourceSubTab.value === 'ai') {
401
+ return item.title.toLowerCase().includes(kw.toLowerCase())
402
+ }
403
+ return true
404
+ })
405
+
406
+ if (rawItems.length < pageSize) {
407
+ reachEnd.value = true
408
+ } else {
409
+ reachEnd.value = false
410
+ }
411
+
412
+ if (append) {
413
+ const existingIds = new Set(assetList.value.map(i => i.id))
414
+ const uniqueNew = normalized.filter(i => !existingIds.has(i.id))
415
+ assetList.value = [...assetList.value, ...uniqueNew]
416
+ } else {
417
+ assetList.value = normalized
418
+ }
419
+ currentPage.value = page
420
+ } catch (err: any) {
421
+ console.warn('[MyAssets] 加载个人资产异常:', err)
422
+ if (!append) {
423
+ assetList.value = []
424
+ }
425
+ reachEnd.value = true
426
+ } finally {
427
+ loading.value = false
428
+ loadingMore.value = false
429
+ }
430
+ }
431
+
432
+ function reloadAssets() {
433
+ currentPage.value = 1
434
+ reachEnd.value = false
435
+ fetchMyAssets(1, false)
436
+ }
437
+
438
+ function handleSearchEnter() {
439
+ reloadAssets()
440
+ }
441
+
442
+ function clearSearch() {
443
+ searchKeyword.value = ''
444
+ reloadAssets()
445
+ }
446
+
447
+ function switchMediaType(type: 'ALL' | 'IMAGE' | 'VIDEO') {
448
+ if (mediaTypeFilter.value === type) return
449
+ mediaTypeFilter.value = type
450
+ reloadAssets()
451
+ }
452
+
453
+ function switchSourceSubTab(sub: 'recent' | 'ai' | 'uploads') {
454
+ if (activeSourceSubTab.value === sub) return
455
+ activeSourceSubTab.value = sub
456
+ reloadAssets()
457
+ }
458
+
459
+ function loadMoreAssets() {
460
+ if (loading.value || loadingMore.value || reachEnd.value) return
461
+ fetchMyAssets(currentPage.value + 1, true)
462
+ }
463
+
464
+ // 双列贪心高度平衡瀑布流
465
+ const waterfallColumns = computed<[MaterialItem[], MaterialItem[]]>(() => {
466
+ const left: MaterialItem[] = []
467
+ const right: MaterialItem[] = []
468
+ let leftH = 0
469
+ let rightH = 0
470
+
471
+ assetList.value.forEach((item, idx) => {
472
+ let ratio = 0.85
473
+ const w = Number(item.originalWidth || 0)
474
+ const h = Number(item.originalHeight || 0)
475
+ if (w > 0 && h > 0) {
476
+ ratio = Math.max(0.55, Math.min(1.6, h / w))
477
+ } else if (item.type === 'video') {
478
+ ratio = 0.65
479
+ } else if (idx % 3 === 1) {
480
+ ratio = 1.1
481
+ }
482
+
483
+ if (leftH <= rightH) {
484
+ left.push(item)
485
+ leftH += ratio
486
+ } else {
487
+ right.push(item)
488
+ rightH += ratio
489
+ }
490
+ })
491
+
492
+ return [left, right]
493
+ })
494
+
495
+ /**
496
+ * 触发本地文件选择上传
497
+ */
498
+ function triggerUploadClick() {
499
+ fileInputRef.value?.click()
500
+ }
501
+
502
+ function extractUploadedUrl(res: any): string {
503
+ if (!res) return ''
504
+ const body = res.data !== undefined ? res.data : res
505
+ if (typeof body === 'string') return body
506
+ const inner = body?.data !== undefined ? body.data : body
507
+ if (typeof inner === 'string') return inner
508
+ return (
509
+ inner?.ossUrl ||
510
+ inner?.fileLink ||
511
+ inner?.fileUrl ||
512
+ inner?.url ||
513
+ inner?.link ||
514
+ body?.ossUrl ||
515
+ body?.fileLink ||
516
+ body?.fileUrl ||
517
+ body?.url ||
518
+ ''
519
+ )
520
+ }
521
+
522
+ async function handleFileUploadChange(e: Event) {
523
+ const input = e.target as HTMLInputElement
524
+ const files = input.files ? Array.from(input.files) : []
525
+ if (files.length === 0) return
526
+
527
+ isUploading.value = true
528
+ try {
529
+ // 若用户已在下拉树中选择了具体项目则传入,否则交由后端 /jvs-dam/upload/file 自动归入当前用户的个人项目
530
+ const personal = projects.value.find(p => p.type === 'PERSONAL') || projects.value[0]
531
+ const targetProjId = selectedProjectId.value || personal?.id || ''
532
+
533
+ let uploadedCount = 0
534
+ for (const file of files) {
535
+ const isVid = file.type.startsWith('video/')
536
+ const formData = new FormData()
537
+ formData.append('file', file)
538
+ formData.append('path', '/dam/media/')
539
+ formData.append('fileName', file.name)
540
+ formData.append('name', file.name)
541
+ formData.append('id', String(Date.now()))
542
+ if (targetProjId) formData.append('projectId', targetProjId)
543
+ if (selectedFolderId.value) formData.append('folderId', selectedFolderId.value)
544
+ formData.append('assetType', isVid ? 'VIDEO' : 'IMAGE')
545
+
546
+ const uploadRes = await damFetchJson('/upload/file', {
547
+ method: 'POST',
548
+ body: formData
549
+ })
550
+ const fileUrl = extractUploadedUrl(uploadRes)
551
+ if (fileUrl) {
552
+ uploadedCount++
553
+ }
554
+ }
555
+
556
+ if (uploadedCount > 0) {
557
+ notify(`已成功上传 ${uploadedCount} 个素材到我的资产库`)
558
+ reloadAssets()
559
+ } else {
560
+ notify('上传未返回有效文件地址')
561
+ }
562
+ } catch (err: any) {
563
+ notify(`上传素材失败: ${err.message || '网络异常'}`)
564
+ } finally {
565
+ isUploading.value = false
566
+ input.value = ''
567
+ }
568
+ }
569
+
570
+ function handleOutsideClick(e: MouseEvent) {
571
+ if (!treePopoverVisible.value) return
572
+ const target = e.target as Node
573
+ if (treeTriggerRef.value && !treeTriggerRef.value.contains(target)) {
574
+ treePopoverVisible.value = false
575
+ }
576
+ }
577
+
578
+ onMounted(() => {
579
+ fetchProjectsAndFolders()
580
+ fetchMyAssets(1, false)
581
+ window.addEventListener('click', handleOutsideClick)
582
+ })
583
+
584
+ onUnmounted(() => {
585
+ window.removeEventListener('click', handleOutsideClick)
586
+ })
587
+
588
+ watch(isDamLoggedIn, (logged) => {
589
+ if (logged) {
590
+ fetchProjectsAndFolders()
591
+ fetchMyAssets(1, false)
592
+ } else {
593
+ assetList.value = []
594
+ }
595
+ })
596
+
597
+ defineExpose({
598
+ reloadAssets
599
+ })
600
+ </script>
601
+
602
+ <template>
603
+ <div class="my-assets-panel">
604
+ <!-- 隐藏的文件上传控件 -->
605
+ <input
606
+ ref="fileInputRef"
607
+ type="file"
608
+ multiple
609
+ accept="image/*,video/*"
610
+ style="display: none"
611
+ @change="handleFileUploadChange"
612
+ />
613
+
614
+ <!-- 1. 顶部搜索框 + 本地上传按钮 -->
615
+ <div class="mine-top-search-row">
616
+ <div class="mine-search-box">
617
+ <el-icon class="mine-search-icon" @click="handleSearchEnter">
618
+ <Search />
619
+ </el-icon>
620
+ <input
621
+ v-model="searchKeyword"
622
+ type="text"
623
+ placeholder="语义搜索我的资产/描述词..."
624
+ class="mine-search-input"
625
+ @keyup.enter="handleSearchEnter"
626
+ />
627
+ <el-icon
628
+ v-if="searchKeyword"
629
+ class="mine-search-clear"
630
+ @click="clearSearch"
631
+ >
632
+ <CircleCloseFilled />
633
+ </el-icon>
634
+ </div>
635
+
636
+ <button
637
+ type="button"
638
+ class="mine-upload-btn"
639
+ :disabled="isUploading"
640
+ title="上传本地图片或视频至我的资产库"
641
+ @click="triggerUploadClick"
642
+ >
643
+ <span v-if="isUploading" class="mini-spin-ring"></span>
644
+ <span v-else>+</span>
645
+ <span>{{ isUploading ? '上传中' : '上传' }}</span>
646
+ </button>
647
+ </div>
648
+
649
+ <!-- 2. 项目/文件夹树级联选择器 + 媒介类型过滤胶囊 -->
650
+ <div class="mine-filter-bar">
651
+ <div ref="treeTriggerRef" class="project-tree-selector-wrap">
652
+ <button
653
+ type="button"
654
+ class="project-tree-trigger"
655
+ :class="{ active: treePopoverVisible || selectedProjectId }"
656
+ @click.stop="treePopoverVisible = !treePopoverVisible"
657
+ >
658
+ <span class="proj-dot" :class="{ 'is-filtered': !!selectedProjectId }"></span>
659
+ <span class="proj-label" :title="selectedTreeLabel">{{ selectedTreeLabel }}</span>
660
+ <svg class="chevron-svg" viewBox="0 0 24 24" width="11" height="11" fill="none" stroke="currentColor" stroke-width="2.2">
661
+ <polyline points="6 9 12 15 18 9" />
662
+ </svg>
663
+ </button>
664
+
665
+ <!-- 项目 -> 多级文件夹树弹出面板 -->
666
+ <transition name="pop-fade">
667
+ <div v-if="treePopoverVisible" class="project-tree-popover" @click.stop>
668
+ <div class="popover-section-header">选择项目或子文件夹</div>
669
+
670
+ <div
671
+ class="tree-node-row all-node"
672
+ :class="{ active: !selectedProjectId && !selectedFolderId }"
673
+ @click="selectAllAssets"
674
+ >
675
+ <span class="node-icon">🌐</span>
676
+ <span class="node-name">全部可访问资产</span>
677
+ </div>
678
+
679
+ <div class="tree-projects-scroll">
680
+ <div
681
+ v-for="(proj, pIdx) in projects"
682
+ :key="proj.id"
683
+ class="tree-project-block"
684
+ >
685
+ <div
686
+ class="tree-node-row project-node"
687
+ :class="{ active: selectedProjectId === proj.id && !selectedFolderId }"
688
+ @click="selectProjectNode(proj)"
689
+ >
690
+ <button
691
+ type="button"
692
+ class="tree-expand-btn"
693
+ :class="{ expanded: expandedProjectIds[proj.id] }"
694
+ @click="toggleProjectExpand(proj.id, $event)"
695
+ >
696
+ <svg viewBox="0 0 24 24" width="10" height="10" fill="none" stroke="currentColor" stroke-width="2.5">
697
+ <polyline points="9 18 15 12 9 6" />
698
+ </svg>
699
+ </button>
700
+ <span class="project-color-sq" :style="{ backgroundColor: getProjectColor(proj, pIdx) }"></span>
701
+ <span class="node-name">{{ getProjectDisplayName(proj) }}</span>
702
+ <span v-if="proj.type === 'TEAM'" class="team-tag">团队</span>
703
+ </div>
704
+
705
+ <!-- 展开项目下的文件夹列表 -->
706
+ <div v-if="expandedProjectIds[proj.id]" class="tree-folders-children">
707
+ <template v-if="folderTrees[proj.id] && folderTrees[proj.id].length > 0">
708
+ <template v-for="folder in folderTrees[proj.id]" :key="folder.id">
709
+ <div
710
+ class="tree-node-row folder-node"
711
+ :class="{ active: selectedFolderId === folder.id }"
712
+ @click="selectFolderNode(proj, folder)"
713
+ >
714
+ <span class="folder-icon">📁</span>
715
+ <span class="node-name">{{ folder.name }}</span>
716
+ </div>
717
+ <!-- 二级子文件夹 -->
718
+ <template v-if="folder.children && folder.children.length > 0">
719
+ <div
720
+ v-for="sub in folder.children"
721
+ :key="sub.id"
722
+ class="tree-node-row folder-node level-2"
723
+ :class="{ active: selectedFolderId === sub.id }"
724
+ @click="selectFolderNode(proj, sub)"
725
+ >
726
+ <span class="folder-icon">📁</span>
727
+ <span class="node-name">{{ sub.name }}</span>
728
+ </div>
729
+ </template>
730
+ </template>
731
+ </template>
732
+ <div v-else class="tree-empty-folder-tip">默认目录</div>
733
+ </div>
734
+ </div>
735
+ </div>
736
+ </div>
737
+ </transition>
738
+ </div>
739
+
740
+ <!-- 媒介类型胶囊 (全部 / 图片 / 视频) -->
741
+ <div class="media-type-pills">
742
+ <button
743
+ type="button"
744
+ class="media-pill"
745
+ :class="{ active: mediaTypeFilter === 'ALL' }"
746
+ @click="switchMediaType('ALL')"
747
+ >
748
+ 全部
749
+ </button>
750
+ <button
751
+ type="button"
752
+ class="media-pill"
753
+ :class="{ active: mediaTypeFilter === 'IMAGE' }"
754
+ @click="switchMediaType('IMAGE')"
755
+ >
756
+ 图片
757
+ </button>
758
+ <button
759
+ type="button"
760
+ class="media-pill"
761
+ :class="{ active: mediaTypeFilter === 'VIDEO' }"
762
+ @click="switchMediaType('VIDEO')"
763
+ >
764
+ 视频
765
+ </button>
766
+ </div>
767
+ </div>
768
+
769
+ <!-- 3. 二级来源切换栏 (最近资产 / AI 创作记录 / 已上传) -->
770
+ <div class="mine-sub-tabs">
771
+ <button
772
+ type="button"
773
+ class="mine-sub-tab"
774
+ :class="{ active: activeSourceSubTab === 'recent' }"
775
+ @click="switchSourceSubTab('recent')"
776
+ >
777
+ 最近资产
778
+ </button>
779
+ <button
780
+ type="button"
781
+ class="mine-sub-tab"
782
+ :class="{ active: activeSourceSubTab === 'ai' }"
783
+ @click="switchSourceSubTab('ai')"
784
+ >
785
+ AI 创作记录
786
+ </button>
787
+ <button
788
+ type="button"
789
+ class="mine-sub-tab"
790
+ :class="{ active: activeSourceSubTab === 'uploads' }"
791
+ @click="switchSourceSubTab('uploads')"
792
+ >
793
+ 已上传
794
+ </button>
795
+ </div>
796
+
797
+ <!-- 4. 资产瀑布流列表区 -->
798
+ <div v-if="loading && assetList.length === 0" class="mine-loading-box">
799
+ <div class="loading-ring-spinner"></div>
800
+ <span class="loading-tip-text">正在加载我的资产...</span>
801
+ </div>
802
+
803
+ <div v-else class="mine-waterfall-scroll">
804
+ <div v-if="assetList.length > 0" class="mine-masonry-wrapper">
805
+ <div class="mine-masonry-columns">
806
+ <div
807
+ v-for="(colItems, colIdx) in waterfallColumns"
808
+ :key="colIdx"
809
+ class="mine-masonry-col"
810
+ >
811
+ <div
812
+ v-for="item in colItems"
813
+ :key="item.id"
814
+ class="mine-asset-card"
815
+ @click="emit('select', item)"
816
+ >
817
+ <video
818
+ v-if="item.type === 'video'"
819
+ :src="item.url"
820
+ muted
821
+ preload="metadata"
822
+ playsinline
823
+ class="mine-card-media"
824
+ @mouseenter="($event.target as HTMLVideoElement)?.play?.()"
825
+ @mouseleave="($event.target as HTMLVideoElement)?.pause?.()"
826
+ />
827
+ <img
828
+ v-else
829
+ :src="item.previewUrl || item.url"
830
+ :alt="item.title"
831
+ loading="lazy"
832
+ class="mine-card-media"
833
+ />
834
+
835
+ <!-- 左上角来源徽标 -->
836
+ <span
837
+ class="mine-card-badge"
838
+ :class="{
839
+ 'is-ai': item.badgeLabel === 'AI 生成',
840
+ 'is-video': item.type === 'video'
841
+ }"
842
+ >
843
+ {{ item.badgeLabel || '资产' }}
844
+ </span>
845
+
846
+ <!-- 视频播放角标 -->
847
+ <span v-if="item.type === 'video'" class="video-play-badge">
848
+ <el-icon><VideoPlay /></el-icon>
849
+ </span>
850
+
851
+ <!-- 右上角悬浮按钮组:设为参考图 + 大图预览 -->
852
+ <div class="mine-card-actions">
853
+ <button
854
+ v-if="item.type !== 'video'"
855
+ type="button"
856
+ class="card-act-btn ref-btn"
857
+ title="作为 AI 生图参考素材"
858
+ @click.stop="emit('use-as-ref', item)"
859
+ >
860
+ <span>@参考</span>
861
+ </button>
862
+ <button
863
+ type="button"
864
+ class="card-act-btn icon-btn"
865
+ title="大图预览"
866
+ @click.stop="emit('preview', item, assetList, $event)"
867
+ >
868
+ <el-icon><View /></el-icon>
869
+ </button>
870
+ </div>
871
+
872
+ <!-- 底部标题渐隐层 -->
873
+ <div class="mine-card-bottom">
874
+ <span class="mine-card-title" :title="item.title">{{ item.title }}</span>
875
+ </div>
876
+ </div>
877
+ </div>
878
+ </div>
879
+
880
+ <!-- 底部加载更多 -->
881
+ <div class="mine-footer-status">
882
+ <div v-if="loadingMore" class="loading-more-row">
883
+ <span class="mini-spin-ring blue"></span>
884
+ <span>正在加载更多...</span>
885
+ </div>
886
+ <button
887
+ v-else-if="!reachEnd"
888
+ type="button"
889
+ class="load-more-action-btn"
890
+ @click="loadMoreAssets"
891
+ >
892
+ 加载更多
893
+ </button>
894
+ <div v-else class="all-loaded-divider">
895
+ <span>— 已展示全部资产 —</span>
896
+ </div>
897
+ </div>
898
+ </div>
899
+
900
+ <!-- 空状态 -->
901
+ <div v-else class="mine-empty-state">
902
+ <svg viewBox="0 0 24 24" width="38" height="38" fill="none" stroke="#cbd5e1" stroke-width="1.3">
903
+ <path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z" />
904
+ </svg>
905
+ <p class="empty-title">
906
+ {{ searchKeyword ? `未找到与 "${searchKeyword}" 匹配的资产` : '当前目录下暂无资产' }}
907
+ </p>
908
+ <button type="button" class="empty-upload-cta" @click="triggerUploadClick">
909
+ + 上传本地素材
910
+ </button>
911
+ </div>
912
+ </div>
913
+ </div>
914
+ </template>
915
+
916
+ <style scoped lang="scss">
917
+ .my-assets-panel {
918
+ flex: 1;
919
+ min-height: 0;
920
+ display: flex;
921
+ flex-direction: column;
922
+ overflow: hidden;
923
+ }
924
+
925
+ .mine-top-search-row {
926
+ display: flex;
927
+ align-items: center;
928
+ gap: 6px;
929
+ padding: 10px 14px 6px;
930
+ flex-shrink: 0;
931
+ }
932
+
933
+ .mine-search-box {
934
+ flex: 1;
935
+ min-width: 0;
936
+ display: flex;
937
+ align-items: center;
938
+ height: 34px;
939
+ background: #f5f6f7;
940
+ border: 1px solid #e2e8f0;
941
+ border-radius: 7px;
942
+ padding: 0 9px;
943
+ gap: 6px;
944
+ transition: all 0.2s ease;
945
+
946
+ &:focus-within {
947
+ background: #ffffff;
948
+ border-color: #1e6fff;
949
+ }
950
+ }
951
+
952
+ .mine-search-icon {
953
+ font-size: 14px;
954
+ color: #64748b;
955
+ cursor: pointer;
956
+ flex-shrink: 0;
957
+ }
958
+
959
+ .mine-search-input {
960
+ flex: 1;
961
+ min-width: 0;
962
+ border: none;
963
+ outline: none;
964
+ background: transparent;
965
+ font-size: 12.5px;
966
+ color: #1e293b;
967
+
968
+ &::placeholder {
969
+ color: #94a3b8;
970
+ }
971
+ }
972
+
973
+ .mine-search-clear {
974
+ font-size: 13px;
975
+ color: #94a3b8;
976
+ cursor: pointer;
977
+ flex-shrink: 0;
978
+
979
+ &:hover {
980
+ color: #475569;
981
+ }
982
+ }
983
+
984
+ .mine-upload-btn {
985
+ height: 34px;
986
+ padding: 0 10px;
987
+ border-radius: 7px;
988
+ border: none;
989
+ background: #0f172a;
990
+ color: #ffffff;
991
+ font-size: 12px;
992
+ font-weight: 500;
993
+ display: inline-flex;
994
+ align-items: center;
995
+ gap: 4px;
996
+ cursor: pointer;
997
+ flex-shrink: 0;
998
+ transition: background 0.15s ease;
999
+
1000
+ &:hover:not(:disabled) {
1001
+ background: #1e293b;
1002
+ }
1003
+
1004
+ &:disabled {
1005
+ opacity: 0.65;
1006
+ cursor: not-allowed;
1007
+ }
1008
+ }
1009
+
1010
+ .mine-filter-bar {
1011
+ display: flex;
1012
+ align-items: center;
1013
+ justify-content: space-between;
1014
+ gap: 6px;
1015
+ padding: 4px 14px 6px;
1016
+ flex-shrink: 0;
1017
+ }
1018
+
1019
+ .project-tree-selector-wrap {
1020
+ position: relative;
1021
+ min-width: 0;
1022
+ flex: 1;
1023
+ }
1024
+
1025
+ .project-tree-trigger {
1026
+ display: inline-flex;
1027
+ align-items: center;
1028
+ gap: 5px;
1029
+ max-width: 100%;
1030
+ height: 26px;
1031
+ padding: 0 8px;
1032
+ border-radius: 6px;
1033
+ border: 1px solid #e2e8f0;
1034
+ background: #f8fafc;
1035
+ color: #334155;
1036
+ font-size: 12px;
1037
+ cursor: pointer;
1038
+ transition: all 0.15s ease;
1039
+
1040
+ &:hover,
1041
+ &.active {
1042
+ border-color: #93c5fd;
1043
+ background: #eff6ff;
1044
+ color: #1d4ed8;
1045
+ }
1046
+
1047
+ .proj-dot {
1048
+ width: 7px;
1049
+ height: 7px;
1050
+ border-radius: 2px;
1051
+ background: #3b82f6;
1052
+ flex-shrink: 0;
1053
+ }
1054
+
1055
+ .proj-label {
1056
+ overflow: hidden;
1057
+ text-overflow: ellipsis;
1058
+ white-space: nowrap;
1059
+ font-weight: 500;
1060
+ }
1061
+
1062
+ .chevron-svg {
1063
+ flex-shrink: 0;
1064
+ color: #64748b;
1065
+ }
1066
+ }
1067
+
1068
+ .project-tree-popover {
1069
+ position: absolute;
1070
+ top: 30px;
1071
+ left: 0;
1072
+ width: 244px;
1073
+ background: #ffffff;
1074
+ border: 1px solid #e2e8f0;
1075
+ border-radius: 10px;
1076
+ box-shadow: 0 10px 28px rgba(15, 23, 42, 0.14);
1077
+ padding: 6px;
1078
+ z-index: 40;
1079
+ }
1080
+
1081
+ .popover-section-header {
1082
+ font-size: 11px;
1083
+ font-weight: 600;
1084
+ color: #94a3b8;
1085
+ padding: 4px 8px;
1086
+ }
1087
+
1088
+ .tree-projects-scroll {
1089
+ max-height: 220px;
1090
+ overflow-y: auto;
1091
+ margin-top: 2px;
1092
+
1093
+ &::-webkit-scrollbar {
1094
+ width: 4px;
1095
+ }
1096
+ &::-webkit-scrollbar-thumb {
1097
+ background: #cbd5e1;
1098
+ border-radius: 2px;
1099
+ }
1100
+ }
1101
+
1102
+ .tree-project-block {
1103
+ border-top: 1px solid #f1f5f9;
1104
+ padding-top: 3px;
1105
+ margin-top: 3px;
1106
+ }
1107
+
1108
+ .tree-node-row {
1109
+ display: flex;
1110
+ align-items: center;
1111
+ gap: 6px;
1112
+ height: 28px;
1113
+ padding: 0 8px;
1114
+ border-radius: 6px;
1115
+ font-size: 12px;
1116
+ color: #334155;
1117
+ cursor: pointer;
1118
+ transition: background 0.12s ease;
1119
+
1120
+ &:hover {
1121
+ background: #f1f5f9;
1122
+ }
1123
+
1124
+ &.active {
1125
+ background: #eff6ff;
1126
+ color: #1e6fff;
1127
+ font-weight: 600;
1128
+ }
1129
+
1130
+ .node-name {
1131
+ flex: 1;
1132
+ min-width: 0;
1133
+ overflow: hidden;
1134
+ text-overflow: ellipsis;
1135
+ white-space: nowrap;
1136
+ }
1137
+ }
1138
+
1139
+ .tree-expand-btn {
1140
+ width: 16px;
1141
+ height: 16px;
1142
+ border: none;
1143
+ background: transparent;
1144
+ color: #64748b;
1145
+ display: inline-flex;
1146
+ align-items: center;
1147
+ justify-content: center;
1148
+ padding: 0;
1149
+ cursor: pointer;
1150
+ transition: transform 0.15s ease;
1151
+
1152
+ &.expanded {
1153
+ transform: rotate(90deg);
1154
+ }
1155
+ }
1156
+
1157
+ .project-color-sq {
1158
+ width: 8px;
1159
+ height: 8px;
1160
+ border-radius: 2px;
1161
+ flex-shrink: 0;
1162
+ }
1163
+
1164
+ .team-tag {
1165
+ font-size: 10px;
1166
+ padding: 1px 4px;
1167
+ border-radius: 4px;
1168
+ background: #ecfdf5;
1169
+ color: #059669;
1170
+ flex-shrink: 0;
1171
+ }
1172
+
1173
+ .tree-folders-children {
1174
+ padding-left: 16px;
1175
+ }
1176
+
1177
+ .folder-node {
1178
+ height: 26px;
1179
+ font-size: 11.5px;
1180
+ color: #475569;
1181
+
1182
+ &.level-2 {
1183
+ padding-left: 18px;
1184
+ }
1185
+ }
1186
+
1187
+ .tree-empty-folder-tip {
1188
+ font-size: 11px;
1189
+ color: #94a3b8;
1190
+ padding: 3px 8px 4px 20px;
1191
+ }
1192
+
1193
+ .media-type-pills {
1194
+ display: flex;
1195
+ align-items: center;
1196
+ gap: 4px;
1197
+ flex-shrink: 0;
1198
+ }
1199
+
1200
+ .media-pill {
1201
+ height: 24px;
1202
+ padding: 0 8px;
1203
+ border-radius: 12px;
1204
+ border: none;
1205
+ background: #f1f5f9;
1206
+ color: #64748b;
1207
+ font-size: 11.5px;
1208
+ cursor: pointer;
1209
+ transition: all 0.15s ease;
1210
+
1211
+ &:hover {
1212
+ color: #1e293b;
1213
+ }
1214
+
1215
+ &.active {
1216
+ background: #ebf3ff;
1217
+ color: #1e6fff;
1218
+ font-weight: 600;
1219
+ }
1220
+ }
1221
+
1222
+ .mine-sub-tabs {
1223
+ display: flex;
1224
+ align-items: center;
1225
+ gap: 14px;
1226
+ padding: 0 14px;
1227
+ border-bottom: 1px solid #f1f5f9;
1228
+ flex-shrink: 0;
1229
+ }
1230
+
1231
+ .mine-sub-tab {
1232
+ border: none;
1233
+ background: transparent;
1234
+ padding: 6px 0;
1235
+ font-size: 12px;
1236
+ color: #64748b;
1237
+ cursor: pointer;
1238
+ position: relative;
1239
+ transition: color 0.15s ease;
1240
+
1241
+ &:hover {
1242
+ color: #1e293b;
1243
+ }
1244
+
1245
+ &.active {
1246
+ color: #1e6fff;
1247
+ font-weight: 600;
1248
+
1249
+ &::after {
1250
+ content: '';
1251
+ position: absolute;
1252
+ left: 0;
1253
+ right: 0;
1254
+ bottom: -1px;
1255
+ height: 2px;
1256
+ background: #1e6fff;
1257
+ border-radius: 1px;
1258
+ }
1259
+ }
1260
+ }
1261
+
1262
+ .mine-waterfall-scroll {
1263
+ flex: 1;
1264
+ min-height: 0;
1265
+ overflow-y: auto;
1266
+ padding: 10px 14px 16px;
1267
+
1268
+ &::-webkit-scrollbar {
1269
+ width: 4px;
1270
+ }
1271
+ &::-webkit-scrollbar-thumb {
1272
+ background: #cbd5e1;
1273
+ border-radius: 2px;
1274
+ }
1275
+ }
1276
+
1277
+ .mine-masonry-columns {
1278
+ display: flex;
1279
+ align-items: flex-start;
1280
+ gap: 8px;
1281
+ }
1282
+
1283
+ .mine-masonry-col {
1284
+ flex: 1;
1285
+ min-width: 0;
1286
+ display: flex;
1287
+ flex-direction: column;
1288
+ gap: 8px;
1289
+ }
1290
+
1291
+ .mine-asset-card {
1292
+ position: relative;
1293
+ width: 100%;
1294
+ border-radius: 8px;
1295
+ overflow: hidden;
1296
+ background: #f1f5f9;
1297
+ border: 1px solid #f1f5f9;
1298
+ cursor: pointer;
1299
+
1300
+ &:hover .mine-card-media {
1301
+ transform: scale(1.03);
1302
+ }
1303
+
1304
+ &:hover .mine-card-actions {
1305
+ opacity: 1;
1306
+ }
1307
+ }
1308
+
1309
+ .mine-card-media {
1310
+ width: 100%;
1311
+ height: auto;
1312
+ min-height: 64px;
1313
+ max-height: 210px;
1314
+ object-fit: cover;
1315
+ display: block;
1316
+ transition: transform 0.22s ease;
1317
+ }
1318
+
1319
+ .mine-card-badge {
1320
+ position: absolute;
1321
+ top: 6px;
1322
+ left: 6px;
1323
+ padding: 1px 6px;
1324
+ border-radius: 4px;
1325
+ background: rgba(15, 23, 42, 0.65);
1326
+ color: #ffffff;
1327
+ font-size: 10.5px;
1328
+ font-weight: 500;
1329
+ pointer-events: none;
1330
+
1331
+ &.is-ai {
1332
+ background: rgba(79, 70, 229, 0.85);
1333
+ }
1334
+
1335
+ &.is-video {
1336
+ background: rgba(5, 150, 105, 0.85);
1337
+ }
1338
+ }
1339
+
1340
+ .video-play-badge {
1341
+ position: absolute;
1342
+ left: 6px;
1343
+ bottom: 24px;
1344
+ width: 20px;
1345
+ height: 20px;
1346
+ border-radius: 50%;
1347
+ background: rgba(15, 23, 42, 0.6);
1348
+ color: #ffffff;
1349
+ display: flex;
1350
+ align-items: center;
1351
+ justify-content: center;
1352
+ font-size: 12px;
1353
+ pointer-events: none;
1354
+ }
1355
+
1356
+ .mine-card-actions {
1357
+ position: absolute;
1358
+ top: 6px;
1359
+ right: 6px;
1360
+ display: flex;
1361
+ align-items: center;
1362
+ gap: 4px;
1363
+ opacity: 0;
1364
+ transition: opacity 0.15s ease;
1365
+ }
1366
+
1367
+ .card-act-btn {
1368
+ border: none;
1369
+ background: rgba(15, 23, 42, 0.65);
1370
+ color: #ffffff;
1371
+ height: 22px;
1372
+ border-radius: 11px;
1373
+ display: inline-flex;
1374
+ align-items: center;
1375
+ justify-content: center;
1376
+ font-size: 11px;
1377
+ cursor: pointer;
1378
+ padding: 0 6px;
1379
+ transition: background 0.15s ease;
1380
+
1381
+ &.icon-btn {
1382
+ width: 22px;
1383
+ padding: 0;
1384
+ border-radius: 50%;
1385
+ }
1386
+
1387
+ &:hover {
1388
+ background: #1e6fff;
1389
+ }
1390
+ }
1391
+
1392
+ .mine-card-bottom {
1393
+ position: absolute;
1394
+ left: 0;
1395
+ right: 0;
1396
+ bottom: 0;
1397
+ padding: 12px 6px 4px;
1398
+ background: linear-gradient(180deg, rgba(0, 0, 0, 0) 0%, rgba(15, 23, 42, 0.72) 100%);
1399
+ pointer-events: none;
1400
+ }
1401
+
1402
+ .mine-card-title {
1403
+ display: block;
1404
+ font-size: 11px;
1405
+ color: #ffffff;
1406
+ overflow: hidden;
1407
+ text-overflow: ellipsis;
1408
+ white-space: nowrap;
1409
+ }
1410
+
1411
+ .mine-loading-box {
1412
+ display: flex;
1413
+ flex-direction: column;
1414
+ align-items: center;
1415
+ justify-content: center;
1416
+ padding: 56px 0;
1417
+ gap: 10px;
1418
+ }
1419
+
1420
+ .loading-ring-spinner {
1421
+ width: 22px;
1422
+ height: 22px;
1423
+ border: 2.5px solid #e2e8f0;
1424
+ border-top-color: #1e6fff;
1425
+ border-radius: 50%;
1426
+ animation: spin 0.8s linear infinite;
1427
+ }
1428
+
1429
+ .mini-spin-ring {
1430
+ width: 12px;
1431
+ height: 12px;
1432
+ border: 2px solid rgba(255, 255, 255, 0.35);
1433
+ border-top-color: #ffffff;
1434
+ border-radius: 50%;
1435
+ animation: spin 0.8s linear infinite;
1436
+
1437
+ &.blue {
1438
+ border-color: rgba(30, 111, 255, 0.25);
1439
+ border-top-color: #1e6fff;
1440
+ }
1441
+ }
1442
+
1443
+ .loading-tip-text {
1444
+ font-size: 12px;
1445
+ color: #64748b;
1446
+ }
1447
+
1448
+ .mine-footer-status {
1449
+ padding: 12px 0 6px;
1450
+ display: flex;
1451
+ align-items: center;
1452
+ justify-content: center;
1453
+ }
1454
+
1455
+ .loading-more-row {
1456
+ display: flex;
1457
+ align-items: center;
1458
+ gap: 6px;
1459
+ font-size: 12px;
1460
+ color: #64748b;
1461
+ }
1462
+
1463
+ .load-more-action-btn {
1464
+ width: 100%;
1465
+ height: 32px;
1466
+ border-radius: 6px;
1467
+ border: 1px solid #e2e8f0;
1468
+ background: #f8fafc;
1469
+ color: #334155;
1470
+ font-size: 12px;
1471
+ font-weight: 500;
1472
+ cursor: pointer;
1473
+
1474
+ &:hover {
1475
+ background: #eff6ff;
1476
+ border-color: #bfdbfe;
1477
+ color: #1e6fff;
1478
+ }
1479
+ }
1480
+
1481
+ .all-loaded-divider {
1482
+ font-size: 11px;
1483
+ color: #94a3b8;
1484
+ }
1485
+
1486
+ .mine-empty-state {
1487
+ display: flex;
1488
+ flex-direction: column;
1489
+ align-items: center;
1490
+ justify-content: center;
1491
+ padding: 48px 16px;
1492
+ gap: 8px;
1493
+ text-align: center;
1494
+
1495
+ .empty-title {
1496
+ font-size: 12px;
1497
+ color: #64748b;
1498
+ margin: 0;
1499
+ }
1500
+
1501
+ .empty-upload-cta {
1502
+ margin-top: 4px;
1503
+ height: 30px;
1504
+ padding: 0 12px;
1505
+ border-radius: 6px;
1506
+ border: 1px solid #cbd5e1;
1507
+ background: #ffffff;
1508
+ color: #1e293b;
1509
+ font-size: 12px;
1510
+ font-weight: 500;
1511
+ cursor: pointer;
1512
+
1513
+ &:hover {
1514
+ border-color: #1e6fff;
1515
+ color: #1e6fff;
1516
+ }
1517
+ }
1518
+ }
1519
+
1520
+ @keyframes spin {
1521
+ from { transform: rotate(0deg); }
1522
+ to { transform: rotate(360deg); }
1523
+ }
1524
+ </style>