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] }}
-
-