From 1c04b7a3211687f050cbbc0b57133d2158cc8043 Mon Sep 17 00:00:00 2001 From: zanllp Date: Sun, 25 Jan 2026 19:10:52 +0800 Subject: [PATCH] feat: refine TikTok info panel and preview return --- vue/src/i18n/en.ts | 2 + vue/src/i18n/zh-hans.ts | 2 + vue/src/page/ImgSli/TiktokViewer.vue | 498 ++++++++++++++++-- vue/src/page/fileTransfer/hooks/usePreview.ts | 23 +- vue/src/page/fileTransfer/stackView.vue | 18 +- vue/src/store/useTiktokStore.ts | 11 +- 6 files changed, 515 insertions(+), 39 deletions(-) diff --git a/vue/src/i18n/en.ts b/vue/src/i18n/en.ts index 3667a60..e0aef5f 100644 --- a/vue/src/i18n/en.ts +++ b/vue/src/i18n/en.ts @@ -423,6 +423,8 @@ You can specify which snapshot to restore to when starting IIB in the global set fullscreen: 'Fullscreen', soundOn: 'Sound On', soundOff: 'Sound Off', + details: 'Details', + info: 'Info', like: 'Like', unlike: 'Unlike', tags: 'Tags', diff --git a/vue/src/i18n/zh-hans.ts b/vue/src/i18n/zh-hans.ts index 86e4451..ae0ba6e 100644 --- a/vue/src/i18n/zh-hans.ts +++ b/vue/src/i18n/zh-hans.ts @@ -402,6 +402,8 @@ export const zhHans = { fullscreen: '全屏', soundOn: '开启声音', soundOff: '关闭声音', + details: '详情', + info: '信息', like: '喜欢', unlike: '取消喜欢', tags: '标签', diff --git a/vue/src/page/ImgSli/TiktokViewer.vue b/vue/src/page/ImgSli/TiktokViewer.vue index 03212a5..78e571c 100644 --- a/vue/src/page/ImgSli/TiktokViewer.vue +++ b/vue/src/page/ImgSli/TiktokViewer.vue @@ -4,10 +4,14 @@ import { useTiktokStore, type TiktokMediaItem } from '@/store/useTiktokStore' import { useTagStore } from '@/store/useTagStore' import { useGlobalStore } from '@/store/useGlobalStore' import { useLocalStorage, onLongPress } from '@vueuse/core' -import { isVideoFile, isAudioFile } from '@/util' +import { copy2clipboardI18n, isVideoFile, isAudioFile } from '@/util' import { openAddNewTagModal } from '@/components/functionalCallableComp' import { toggleCustomTagToImg } from '@/api/db' -import { message } from 'ant-design-vue' +import { deleteFiles } from '@/api/files' +import { getImageGenerationInfo, openFolder, openWithDefaultApp } from '@/api' +import { toRawFileUrl } from '@/util/file' +import { parse } from '@/util/stable-diffusion-image-metadata' +import { message, Modal } from 'ant-design-vue' import { CloseOutlined, FullscreenOutlined, @@ -19,7 +23,14 @@ import { SoundFilled, HeartOutlined, HeartFilled, - PlayCircleOutlined + PlayCircleOutlined, + DeleteOutlined, + FolderOpenOutlined, + AppstoreOutlined, + CopyOutlined, + LinkOutlined, + FileTextOutlined, + InfoCircleOutlined } from '@/icon' import { t } from '@/i18n' import type { StyleValue } from 'vue' @@ -99,6 +110,9 @@ const dragOffset = ref(0) // 拖拽偏移量 // TAG 相关状态 const showTags = ref(false) +const imageGenInfo = ref('') +const promptLoading = ref(false) +let promptRequestId = 0 // 控件可见性状态(长按切换) const controlsVisible = ref(true) @@ -329,6 +343,84 @@ const tagBaseStyle: StyleValue = { fontSize: '14px' } +const cleanImageGenInfo = computed(() => imageGenInfo.value.replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, ''')) +const geninfoStruct = computed(() => parse(cleanImageGenInfo.value)) + +function getTextLength (text: string): number { + let length = 0 + for (const char of text) { + if (/[\u4e00-\u9fa5]/.test(char)) { + length += 3 + } else { + length += 1 + } + } + return length +} + +function isTagStylePrompt (tags: string[]): boolean { + if (tags.length === 0) return false + + let totalLength = 0 + for (const tag of tags) { + const tagLength = getTextLength(tag) + totalLength += tagLength + + if (tagLength > 50) { + return false + } + } + + const avgLength = totalLength / tags.length + if (avgLength > 30) { + return false + } + + return true +} + +function spanWrap (text: string) { + if (!text) { + return '' + } + + const specBreakTag = 'BREAK' + const values = text.replace(/>\s/g, '> ,').replace(/\sBREAK\s/g, ',' + specBreakTag + ',') + .split(/[\n,]+/) + .map(v => v.trim()) + .filter(v => v) + + if (!isTagStylePrompt(values)) { + return text + .split('\n') + .map(line => line.trim()) + .filter(line => line) + .map(line => `

${line}

`) + .join('') + } + + const frags = [] as string[] + let parenthesisActive = false + for (let i = 0; i < values.length; i++) { + if (values[i] === specBreakTag) { + frags.push('
BREAK
') + continue + } + const trimmedValue = values[i] + if (!parenthesisActive) parenthesisActive = trimmedValue.includes('(') + const classList = ['tag'] + if (parenthesisActive) classList.push('has-parentheses') + if (trimmedValue.length < 32) classList.push('short-tag') + frags.push(`${trimmedValue}`) + if (parenthesisActive) parenthesisActive = !trimmedValue.includes(')') + } + return frags.join(global.showCommaInInfoPanel ? ',' : ' ') +} + // 切换自动轮播模式 const toggleAutoPlay = () => { const currentIndex = autoPlayOptions.indexOf(autoPlayMode.value) @@ -738,6 +830,108 @@ const loadCurrentItemTags = async () => { } } +const loadCurrentItemPrompt = async () => { + const currentItem = tiktokStore.currentItem + if (!currentItem) { + imageGenInfo.value = '' + return + } + const nameOrUrl = currentItem.name || currentItem.url + if (isVideoFile(nameOrUrl) || isAudioFile(nameOrUrl)) { + imageGenInfo.value = '' + return + } + const fullpath = (currentItem as any)?.fullpath || currentItem.id + if (!fullpath) { + imageGenInfo.value = '' + return + } + + const requestId = ++promptRequestId + promptLoading.value = true + try { + const info = await getImageGenerationInfo(fullpath) + if (requestId !== promptRequestId) return + imageGenInfo.value = info + } catch (error) { + console.error('Load prompt error:', error) + if (requestId !== promptRequestId) return + imageGenInfo.value = '' + } finally { + if (requestId === promptRequestId) { + promptLoading.value = false + } + } +} + +const getCurrentFullpath = () => { + return (currentItem.value as any)?.fullpath || currentItem.value?.id || '' +} + +const getCurrentDisplayName = () => { + return currentItem.value?.name || getCurrentFullpath().split(/[/\\]/).pop() || '' +} + +const removeCurrentItemFromList = () => { + const idx = tiktokStore.currentIndex + if (idx < 0 || idx >= tiktokStore.mediaList.length) return + tiktokStore.mediaList.splice(idx, 1) + if (tiktokStore.mediaList.length === 0) { + tiktokStore.closeView() + return + } + if (idx >= tiktokStore.mediaList.length) { + tiktokStore.currentIndex = tiktokStore.mediaList.length - 1 + } +} + +const handleDeleteCurrent = async () => { + const fullpath = getCurrentFullpath() + if (!fullpath) return + await new Promise((resolve) => { + Modal.confirm({ + title: t('confirmDelete'), + maskClosable: true, + content: getCurrentDisplayName(), + async onOk () { + await deleteFiles([fullpath]) + message.success(t('deleteSuccess')) + removeCurrentItemFromList() + showTags.value = false + resolve() + }, + onCancel () { + resolve() + } + }) + }) +} + +const handleOpenFolder = async () => { + const fullpath = getCurrentFullpath() + if (!fullpath) return + await openFolder(fullpath) +} + +const handleOpenWithDefaultApp = async () => { + const fullpath = getCurrentFullpath() + if (!fullpath) return + await openWithDefaultApp(fullpath) +} + +const handleCopyPath = () => { + const fullpath = getCurrentFullpath() + if (!fullpath) return + copy2clipboardI18n(fullpath) +} + +const handleCopyPreviewUrl = () => { + const file = (currentItem.value as any)?.originalFile + const url = file ? toRawFileUrl(file) : currentItem.value?.url + if (!url) return + copy2clipboardI18n(url) +} + // 长按切换控件可见性 onLongPress( viewportRef, @@ -778,21 +972,31 @@ onUnmounted(() => { // 监听当前项变化 watch(() => tiktokStore.currentIndex, () => { + showTags.value = false updateBuffer() nextTick(() => { preloadMedia() loadCurrentItemTags() + loadCurrentItemPrompt() }) }, { immediate: true }) // 监听媒体列表变化 watch(() => tiktokStore.mediaList, () => { updateBuffer() + nextTick(() => { + loadCurrentItemTags() + loadCurrentItemPrompt() + }) }, { deep: true }) // 监听组件可见性变化 watch(() => tiktokStore.visible, (visible) => { if (!visible) { + showTags.value = false + imageGenInfo.value = '' + promptLoading.value = false + promptRequestId++ // 组件隐藏时停止并清理所有视频 videoRefs.value.forEach(video => { if (video) { @@ -937,9 +1141,9 @@ watch(() => autoPlayMode.value, () => { {{ autoPlayLabels[autoPlayMode] }} - - @@ -979,35 +1183,87 @@ watch(() => autoPlayMode.value, () => { - +
-
-

{{ $t('tags') }}

+
+
+ + {{ $t('details') }} +
-
- -
- {{ $t('addNewCustomTag') }} +
+
+ + + + +
- -
- {{ tag.name }} +
+
+ {{ $t('tags') }} +
+
+ +
+ {{ $t('addNewCustomTag') }} +
+ + +
+ {{ tag.name }} +
+
+
+ +
+
+ Prompt +
+
+
...
+ +
@@ -1438,42 +1694,207 @@ watch(() => autoPlayMode.value, () => { left: 0; right: 0; background: rgba(0, 0, 0, 0.9); - backdrop-filter: blur(20px); + backdrop-filter: blur(25px); border-radius: 20px 20px 0 0; padding: 20px; - max-height: 60vh; - overflow-y: auto; + max-height: 70vh; + overflow: hidden; z-index: 20; + display: flex; + flex-direction: column; + border-top: 1px solid rgba(255, 255, 255, 0.15); + border-left: 1px solid rgba(255, 255, 255, 0.1); + border-right: 1px solid rgba(255, 255, 255, 0.1); + box-shadow: 0 -10px 40px rgba(0, 0, 0, 0.5); } -.tags-header { +.panel-header { display: flex; justify-content: space-between; align-items: center; - margin-bottom: 16px; + margin-bottom: 20px; color: white; + padding-bottom: 12px; + border-bottom: 1px solid rgba(255, 255, 255, 0.1); - h3 { - margin: 0; + .panel-title { + display: flex; + align-items: center; + gap: 10px; font-size: 18px; + font-weight: 500; } .close-tags { - background: none; + background: rgba(255, 255, 255, 0.1); border: none; color: white; - font-size: 20px; + font-size: 18px; cursor: pointer; - padding: 4px; + padding: 6px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + transition: 0.2s ease; + + &:hover { + background: rgba(255, 255, 255, 0.2); + } } } +.panel-body { + flex: 1; + overflow-y: auto; + padding-right: 8px; + padding-bottom: 50px; + overscroll-behavior: contain; + touch-action: pan-y; + + &::-webkit-scrollbar { + width: 4px; + } + &::-webkit-scrollbar-thumb { + background: rgba(255, 255, 255, 0.2); + border-radius: 2px; + } +} + +.panel-section { + margin-bottom: 24px; + background: rgba(255, 255, 255, 0.03); + padding: 16px; + border-radius: 12px; + border: 1px solid rgba(255, 255, 255, 0.05); +} + +.panel-actions { + display: flex; + gap: 12px; + flex-wrap: wrap; +} + +.panel-action-btn { + width: 40px; + height: 40px; + border-radius: 12px; + border: 1px solid rgba(255, 255, 255, 0.1); + background: rgba(255, 255, 255, 0.05); + color: white; + display: inline-flex; + align-items: center; + justify-content: center; + cursor: pointer; + transition: 0.2s cubic-bezier(0.4, 0, 0.2, 1); + font-size: 18px; + + &:hover { + background: rgba(255, 255, 255, 0.12); + transform: translateY(-2px); + border-color: rgba(255, 255, 255, 0.2); + } + + &:active { + transform: translateY(0); + } + + &.danger { + border-color: rgba(255, 86, 86, 0.3); + background: rgba(255, 86, 86, 0.08); + color: #ff6b6b; + + &:hover { + background: rgba(255, 86, 86, 0.15); + border-color: rgba(255, 86, 86, 0.5); + } + } +} + +.section-title { + display: flex; + align-items: center; + gap: 8px; + color: rgba(255, 255, 255, 0.6); + font-size: 13px; + margin-bottom: 12px; + text-transform: uppercase; + letter-spacing: 0.5px; +} + .tags-content { display: flex; flex-wrap: wrap; gap: 8px; } +.prompt-content { + code { + font-size: 13px; + display: block; + padding: 10px 12px; + background: rgba(0, 0, 0, 0.3); + border-radius: 8px; + white-space: pre-wrap; + word-break: break-word; + line-height: 1.6em; + color: rgba(255, 255, 255, 0.9); + border: 1px solid rgba(255, 255, 255, 0.05); + + :deep() { + .natural-text { + margin: 0.5em 0; + line-height: 1.6em; + text-align: justify; + color: rgba(255, 255, 255, 0.8); + } + + .short-tag { + word-break: break-all; + white-space: nowrap; + } + + span.tag { + background: rgba(255, 255, 255, 0.08); + color: rgba(255, 255, 255, 0.9); + padding: 3px 6px; + border-radius: 4px; + margin-right: 6px; + margin-top: 4px; + line-height: 1.3em; + display: inline-block; + border: 1px solid rgba(255, 255, 255, 0.1); + } + + .has-parentheses.tag { + background: rgba(255, 100, 100, 0.15); + border-color: rgba(255, 100, 100, 0.2); + } + } + } +} + +.prompt-block { + margin-bottom: 16px; + + &:last-child { + margin-bottom: 0; + } +} + +.prompt-label { + font-size: 12px; + color: rgba(255, 255, 255, 0.4); + margin-bottom: 8px; + font-weight: 500; +} + +.prompt-empty { + color: rgba(255, 255, 255, 0.3); + font-size: 13px; + padding: 8px 0; +} + // 动画 .slide-up-enter-active, @@ -1536,5 +1957,10 @@ watch(() => autoPlayMode.value, () => { padding: 15px; max-height: 50vh; } + + .panel-action-btn { + width: 32px; + height: 32px; + } } \ No newline at end of file diff --git a/vue/src/page/fileTransfer/hooks/usePreview.ts b/vue/src/page/fileTransfer/hooks/usePreview.ts index 378349c..da7817d 100644 --- a/vue/src/page/fileTransfer/hooks/usePreview.ts +++ b/vue/src/page/fileTransfer/hooks/usePreview.ts @@ -31,6 +31,25 @@ export function usePreview (spec?: { loadNext?: () => void }) { } } + const scrollToIndex = (idx: number) => { + const s = scroller.value + if (!s || idx < 0) return + if (!(idx >= s.$_startIndex && idx <= s.$_endIndex)) { + s.scrollToItem(idx) + } else { + console.log('scrollToIndex already in view', idx, 's', s) + } + } + + const scrollToFileId = (fullpath: string) => { + if (!fullpath) return + const idx = files.value.findIndex(v => v.fullpath === fullpath) + console.log('idx', {idx, files}) + if (idx >= 0) { + scrollToIndex(idx) + } + } + const loadNextIfNeeded = () => { if (canPreview('next')) { return @@ -119,6 +138,8 @@ export function usePreview (spec?: { loadNext?: () => void }) { onPreviewVisibleChange, previewing, previewImgMove, - canPreview + canPreview, + scrollToIndex, + scrollToFileId } } diff --git a/vue/src/page/fileTransfer/stackView.vue b/vue/src/page/fileTransfer/stackView.vue index f67e564..17ac4d9 100644 --- a/vue/src/page/fileTransfer/stackView.vue +++ b/vue/src/page/fileTransfer/stackView.vue @@ -1,6 +1,7 @@