Merge branch 'zanllp:main' into fix/comfyui-populated-text

pull/933/head
osirigunso 2026-03-23 21:03:36 +09:00 committed by GitHub
commit a4e503f214
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
87 changed files with 2363 additions and 537 deletions

5
.gitignore vendored
View File

@ -27,7 +27,4 @@ iib.db-wal
CLAUDE.md CLAUDE.md
.claude/* .claude/*
videos/启动&添加文件夹构建索引.mp4 videos/*
videos/图像搜索和链接跳转.mp4
videos/ai智能文件整理.mp4
videos/skills安装&启动.mp4

View File

@ -1,6 +1,81 @@
[跳到中文](#中文) [跳到中文](#中文)
# English # English
## 2026-03-21
### 📁 Proper Filename When Saving Images
Fixed the issue where images saved via right-click menu were named "file" instead of their actual filenames. Now when you right-click to save or drag images to other apps, they keep their original filenames.
## 2026-03-19
### ⚙️ Customizable Panel Settings
Added the ability to customize which feature blocks are displayed in the full-screen info panel.
**Features:**
- **Panel Settings**: Click the settings icon to toggle visibility of individual feature blocks
- **Flexible Display**: Show/hide action buttons, file info tags, custom tags, layout controls, draggable image, and prompt tabs
- **Persistent Storage**: Settings are automatically saved to the backend and restored on next visit
**How to use:**
1. Open the full-screen info panel for any image
2. Click the settings icon (⚙️) in the top-right corner
3. Toggle the switches to show/hide different feature blocks
4. Settings are automatically saved and applied
**Use Case:**
Perfect for users who want a cleaner interface or only use specific features. Each user can customize their panel to match their workflow, showing only the blocks they need and hiding unused sections to reduce visual clutter.
**Note:** Settings are stored per-user in the backend, so your custom layout will be preserved across sessions and devices.
<img width="400" alt="Panel settings" src="docs/imgs/panel-settings.png" />
## 2026-03-17
### 🖼️ ComfyUI-Ready Image Dragging
Added drag-to-transfer functionality specifically designed for ComfyUI workflow integration.
**Features:**
- **One-Click Image Access**: Click to display original images ready for dragging
- **ComfyUI Integration**: Drag images directly into ComfyUI to restore complete workflows
- **Dual Placement**: Available in both full-screen info panel and file grid overlays
**How to use:**
1. Click the "Click to get original image, grab and drag to other apps" button
2. The original image appears instantly
3. Drag the image directly into ComfyUI
4. ComfyUI will automatically extract metadata and reconstruct the workflow
**Use Case:**
Perfect for moving images from IIB to ComfyUI. When you drag an image into ComfyUI:
- The original image is loaded
- Generation parameters are extracted from metadata
- The complete workflow is reconstructed automatically
- No manual parameter input required
**Note:** This feature preserves all generation metadata in the image, enabling seamless workflow transfer to ComfyUI and other compatible tools.
[Video Demo](https://github.com/user-attachments/assets/86ee87c8-887a-49ce-a3ed-07a4a2321253)
## 2026-03-15
### 📝 Editable Generation Information
Added the ability to edit image generation prompts and metadata directly in the UI.
**Features:**
- **Prompt Editor Modal**: Edit positive/negative prompts and generation parameters with a user-friendly interface
- **Key-Value Editor**: Add custom metadata fields with support for both string and JSON value modes
- **Smart Caching**: EXIF data is now cached in the database for faster subsequent retrieval
- **Edit Tracking**: Manually edited prompts are marked and preserved separately from original file metadata
- **Validation**: Built-in validation for required fields and unique key constraints
**How to use:**
1. Click the "Edit" button on any image's generation info panel
2. Modify positive/negative prompts and other parameters in the modal
3. Add custom metadata using the key-value editor if needed
4. Click "Save Prompt" to update the database
5. Edited prompts are marked and will override original file metadata
**Note:** Edited prompts are stored in the database and won't modify the original image files.
<img width="400" alt="Prompt editing" src="docs/imgs/prompt-edit.png" />
## 2026-02-23 ## 2026-02-23
### 🎬 Inline Video Playback ### 🎬 Inline Video Playback
Added inline video playback feature for video items wider than 400px. Added inline video playback feature for video items wider than 400px.
@ -9,7 +84,6 @@ Added inline video playback feature for video items wider than 400px.
- **Play Here Button**: Hover over a video to see the "Play Here" button in the bottom-left corner - **Play Here Button**: Hover over a video to see the "Play Here" button in the bottom-left corner
- **Inline Playback**: Click to play the video directly in the grid item without opening a modal - **Inline Playback**: Click to play the video directly in the grid item without opening a modal
- **Auto-Stop Others**: Automatically stops any other playing videos when starting a new one - **Auto-Stop Others**: Automatically stops any other playing videos when starting a new one
- **Multi-language Support**: Button text is fully internationalized (English, Chinese, German)
- **Smart Reset**: Automatically stops playback when the list is reordered or refreshed - **Smart Reset**: Automatically stops playback when the list is reordered or refreshed
**How to use:** **How to use:**
@ -727,6 +801,80 @@ Triggered under the same circumstances as above, there will be a button to updat
# 中文 # 中文
## 2026-03-21
### 📁 修复右键保存图片文件名问题
修复了通过右键菜单保存图片时,文件名显示为"file"而不是实际文件名的问题。现在右键保存图片或拖拽到其他应用时,都会保留原始文件名。
## 2026-03-19
### ⚙️ 自定义面板设置
新增了自定义全屏信息面板功能区块显示的功能。
**功能特性:**
- **面板设置**:点击设置图标即可开关各个功能区块的显示
- **灵活显示**:可显示/隐藏操作按钮、文件信息标签、自定义标签、布局控制、可拖拽图片、提示词标签页
- **持久化存储**:设置自动保存到后端,下次访问时恢复
**使用方法:**
1. 打开任意图片的全屏信息面板
2. 点击右上角的设置图标(⚙️)
3. 切换开关来显示/隐藏不同的功能区块
4. 设置会自动保存并生效
**使用场景:**
非常适合需要简洁界面或只使用特定功能的用户。每个用户都可以根据自己的工作流程自定义面板,只显示需要的区块,隐藏不使用的部分以减少视觉干扰。
**注意:** 设置按用户存储在后端,因此您的自定义布局将在不同会话和设备间保持。
<img width="400" alt="面板设置" src="docs/imgs/panel-settings.png" />
## 2026-03-17
### 🖼️ 专为ComfyUI设计的图片拖拽功能
新增了专为ComfyUI工作流还原设计的图片拖拽传输功能。
**功能特性:**
- **一键获取原图**:点击即可显示原始图片,随时准备拖拽
- **ComfyUI集成**可直接将图片拖拽到ComfyUI中自动还原完整工作流
- **双重入口**:在全屏信息面板和文件网格中均可使用)
**使用方法:**
1. 点击"点击获取原图,抓取拖拽至其他应用"按钮
2. 原始图片立即显示
3. 将图片直接拖拽到ComfyUI界面中
4. ComfyUI会自动提取元数据并重建工作流
**使用场景:**
完美支持从IIB到ComfyUI的图片转移。当您将图片拖拽到ComfyUI时
- 原始图片被加载
- 从元数据中提取生成参数
- 自动重建完整工作流
- 无需手动输入参数
**注意:** 此功能保留图片中的所有生成元数据实现与ComfyUI及其他兼容工具的无缝工作流传输。
[视频演示](https://github.com/user-attachments/assets/86ee87c8-887a-49ce-a3ed-07a4a2321253)
## 2026-03-15
### 📝 可编辑的生成信息
新增了在界面中直接编辑图片生成提示词和元数据的功能。
**功能特性:**
- **提示词编辑器模态框**:通过友好的界面编辑正负向提示词和生成参数
- **键值对编辑器**添加自定义元数据字段支持字符串和JSON值模式
- **智能缓存**EXIF数据现在会被缓存到数据库中以便更快地后续检索
- **编辑标记**:手动编辑的提示词会被标记,并与原始文件元数据分开保存
- **数据验证**:对必填字段和唯一键约束进行内置验证
**使用方法:**
1. 点击任意图片生成信息面板上的"编辑"按钮
2. 在模态框中修改正负向提示词和其他参数
3. 如需要,使用键值对编辑器添加自定义元数据
4. 点击"保存提示词"更新数据库
5. 编辑过的提示词会被标记,并将覆盖原始文件元数据
**注意:**编辑过的提示词存储在数据库中,不会修改原始图片文件。
<img width="400" alt="Prompt editing" src="docs/imgs/prompt-edit.png" />
## 2026-02-23 ## 2026-02-23
### 🎬 视频原地播放功能 ### 🎬 视频原地播放功能
为宽度超过 400px 的视频 item 添加了原地播放功能。 为宽度超过 400px 的视频 item 添加了原地播放功能。
@ -735,7 +883,6 @@ Triggered under the same circumstances as above, there will be a button to updat
- **"在此播放"按钮**:鼠标悬停在视频上时,左下角会显示播放按钮 - **"在此播放"按钮**:鼠标悬停在视频上时,左下角会显示播放按钮
- **原地播放**:点击按钮直接在网格 item 内播放视频,无需打开弹窗 - **原地播放**:点击按钮直接在网格 item 内播放视频,无需打开弹窗
- **自动停止其他视频**:播放新视频时,会自动停止其他正在播放的视频 - **自动停止其他视频**:播放新视频时,会自动停止其他正在播放的视频
- **多语言支持**:按钮文字支持多语言(英文、简体中文、繁体中文、德语)
- **智能重置**:列表重新排序或刷新时,自动停止播放 - **智能重置**:列表重新排序或刷新时,自动停止播放
**使用方法:** **使用方法:**

Binary file not shown.

After

Width:  |  Height:  |  Size: 210 KiB

BIN
docs/imgs/prompt-edit.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 477 KiB

View File

@ -13,8 +13,8 @@ Promise.resolve().then(async () => {
<link rel="icon" href="/favicon.ico" /> <link rel="icon" href="/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Infinite Image Browsing</title> <title>Infinite Image Browsing</title>
<script type="module" crossorigin src="/infinite_image_browsing/fe-static/assets/index-f2db319b.js"></script> <script type="module" crossorigin src="/infinite_image_browsing/fe-static/assets/index-b01f57e3.js"></script>
<link rel="stylesheet" href="/infinite_image_browsing/fe-static/assets/index-dd273d5b.css"> <link rel="stylesheet" href="/infinite_image_browsing/fe-static/assets/index-882e7f3d.css">
</head> </head>
<body> <body>

213
normalize_filenames.py Normal file
View File

@ -0,0 +1,213 @@
import argparse
from contextlib import closing
from datetime import datetime
import os
import random
from scripts.iib.db.datamodel import DataBase
def get_creation_time_path(file_path: str) -> str:
"""
Get the creation time of a file and format it as YYYYMMDD_HHMMSS_<random>.
Format example: 20260123_011706_8662450
On Windows, this uses creation time. On Unix, falls back to modification time.
Args:
file_path (str): Path to the file.
Returns:
str: Formatted creation time string (YYYYMMDD_HHMMSS_<random>).
"""
# On Windows, st_ctime is creation time
# On Unix, st_ctime is metadata change time, so we use st_mtime
if os.name == 'nt':
timestamp = os.path.getctime(file_path)
else:
timestamp = os.path.getmtime(file_path)
dt = datetime.fromtimestamp(timestamp)
time_str = dt.strftime("%Y%m%d_%H%M%S")
random_fragment = random.randint(1000000, 9999999)
return f"{time_str}_{random_fragment}"
def check_database_exists(conn, file_path: str) -> bool:
"""
Check if a file path exists in the database.
Args:
conn: Database connection object.
file_path (str): Path to check.
Returns:
bool: True if the file path exists in the database.
"""
normalized_path = os.path.normpath(file_path)
with closing(conn.cursor()) as cur:
cur.execute(
"SELECT 1 FROM image WHERE path = ? LIMIT 1",
(normalized_path,)
)
return cur.fetchone() is not None
def update_database_paths(conn, old_path: str, new_path: str):
"""
Update database paths when a file is renamed.
Args:
conn: Database connection object.
old_path (str): Original file path.
new_path (str): New file path after rename.
"""
normalized_old = os.path.normpath(old_path)
normalized_new = os.path.normpath(new_path)
with closing(conn.cursor()) as cur:
# Update image table
cur.execute(
"UPDATE image SET path = ? WHERE path = ?",
(normalized_new, normalized_old)
)
def normalize_single_file(file_path: str, dry_run: bool, conn):
"""
Normalize a single file's name based on its creation time (YYYYMMDD_HHMMSS_<random>).
Args:
file_path (str): Path to the file to normalize.
dry_run (bool): If True, only print without renaming.
conn: Database connection object. If provided, will update database paths.
Returns:
tuple: (status, message) where status is 'normalized', 'skipped', or 'error'
"""
try:
# Get the directory and file extension
dir_path = os.path.dirname(file_path)
filename = os.path.basename(file_path)
name, ext = os.path.splitext(filename)
# Skip if already in the target format (YYYYMMDD_HHMMSS_<random>)
# Format: 8 digits + _ + 6 digits + _ + 7 digits = 23 characters
parts = name.split('_')
if len(parts) == 3 and len(parts[0]) == 8 and parts[0].isdigit() and len(parts[1]) == 6 and parts[1].isdigit() and len(parts[2]) == 7 and parts[2].isdigit():
return 'skipped', f"Skip (already normalized): {filename}"
# Get creation time formatted string
time_str = get_creation_time_path(file_path)
new_filename = f"{time_str}{ext}"
new_path = os.path.join(dir_path, new_filename)
# Handle duplicate filenames by appending a counter
counter = 1
while os.path.exists(new_path) and new_path != file_path:
new_filename = f"{time_str}_{counter}{ext}"
new_path = os.path.join(dir_path, new_filename)
counter += 1
if new_path == file_path:
return 'skipped', f"Skip (same name): {filename}"
if dry_run:
# Check if file exists in database and include in message
db_info = ""
if conn:
if check_database_exists(conn, file_path):
db_info = " [in database]"
else:
db_info = " [not in database]"
return 'normalized', f"Would normalize: {filename} -> {new_filename}{db_info}"
else:
os.rename(file_path, new_path)
# Update database if connection is provided
if conn:
update_database_paths(conn, file_path, new_path)
return 'normalized', f"Normalized: {filename} -> {new_filename}"
except Exception as e:
return 'error', f"Error normalizing {file_path}: {e}"
def normalize_filenames(dir_path: str, recursive: bool = False, dry_run: bool = False, db_path: str = None):
"""
Normalize all filenames in the specified directory to creation time format (YYYYMMDD_HHMMSS_<random>).
Args:
dir_path (str): Path to the directory containing files to normalize.
recursive (bool): Whether to recursively process subdirectories. Default is False.
dry_run (bool): If True, only print what would be normalized without actually renaming. Default is False.
db_path (str): Path to the IIB database file to update with new paths. Default is None.
"""
normalized_count = 0
skipped_count = 0
error_count = 0
# Setup database connection if db_path is provided
conn = None
if db_path:
DataBase.path = os.path.normpath(os.path.join(os.getcwd(), db_path))
conn = DataBase.get_conn()
files_to_process = []
if recursive:
# Walk through all files in directory and subdirectories
for root, _, files in os.walk(dir_path):
for filename in files:
files_to_process.append(os.path.join(root, filename))
else:
# Only process files in the specified directory (non-recursive)
for entry in os.listdir(dir_path):
full_path = os.path.join(dir_path, entry)
if os.path.isfile(full_path):
files_to_process.append(full_path)
# Process all files
for file_path in files_to_process:
status, message = normalize_single_file(file_path, dry_run, conn)
print(message)
if status == 'normalized':
normalized_count += 1
elif status == 'skipped':
skipped_count += 1
elif status == 'error':
error_count += 1
print(f"\nSummary: {'Dry run - ' if dry_run else ''}Normalized: {normalized_count}, Skipped: {skipped_count}, Errors: {error_count}")
if conn and not dry_run:
conn.commit()
print(f"Database updated at: {db_path}")
def setup_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
description="Normalize filenames to creation time format (YYYYMMDD_HHMMSS_<random>) and optionally update IIB database paths."
)
parser.add_argument(
"dir_path", type=str, help="Path to the directory containing files to normalize."
)
parser.add_argument(
"-r", "--recursive", action="store_true", help="Recursively process subdirectories."
)
parser.add_argument(
"--force", action="store_true", help="Actually perform the normalization. Without this flag, runs in dry-run mode."
)
parser.add_argument(
"--db_path", type=str, help="Path to the IIB database file to update with new paths. Default value is 'iib.db'.", default="iib.db"
)
return parser
if __name__ == "__main__":
parser = setup_parser()
args = parser.parse_args()
# Default to dry-run mode unless --force is specified
dry_run = not args.force
normalize_filenames(args.dir_path, args.recursive, dry_run, args.db_path)

View File

@ -15,6 +15,7 @@ from scripts.iib.tool import (
is_media_file, is_media_file,
get_cache_dir, get_cache_dir,
get_formatted_date, get_formatted_date,
get_modified_date,
is_win, is_win,
cwd, cwd,
locale, locale,
@ -655,6 +656,47 @@ def infinite_image_browsing_api(app: FastAPI, **kwargs):
headers={"Cache-Control": "max-age=31536000", "ETag": hash}, headers={"Cache-Control": "max-age=31536000", "ETag": hash},
) )
@app.get(api_base + "/img/{filename}", dependencies=[Depends(verify_secret)])
async def get_image(filename: str, path: str, t: str):
import mimetypes
import urllib.parse
check_path_trust(path)
# 验证文件名是否匹配
actual_filename = os.path.basename(path)
decoded_filename = urllib.parse.unquote(filename)
if actual_filename != decoded_filename:
raise HTTPException(status_code=400, detail="Filename mismatch")
if not os.path.exists(path):
raise HTTPException(status_code=404)
if not os.path.isfile(path):
raise HTTPException(status_code=400, detail=f"{path} is not a file")
# 验证是否为图片文件
media_type, _ = mimetypes.guess_type(path)
if media_type and not media_type.startswith('image/'):
raise HTTPException(status_code=400, detail="Not an image file")
# 设置 Content-Disposition 为 inline带文件名
headers = {}
encoded_filename = urllib.parse.quote(filename.encode('utf-8'))
headers['Content-Disposition'] = f"inline; filename*=UTF-8''{encoded_filename}"
if is_path_under_parents(path) and is_valid_media_path(path):
headers["Cache-Control"] = "public, max-age=31536000"
headers["Expires"] = (datetime.now() + timedelta(days=365)).strftime(
"%a, %d %b %Y %H:%M:%S GMT"
)
return FileResponse(
path,
media_type=media_type,
headers=headers,
)
@app.get(api_base + "/file", dependencies=[Depends(verify_secret)]) @app.get(api_base + "/file", dependencies=[Depends(verify_secret)])
async def get_file(path: str, t: str, disposition: Optional[str] = None): async def get_file(path: str, t: str, disposition: Optional[str] = None):
filename = path filename = path
@ -807,11 +849,24 @@ def infinite_image_browsing_api(app: FastAPI, **kwargs):
@app.get(api_base + "/image_geninfo", dependencies=[Depends(verify_secret)]) @app.get(api_base + "/image_geninfo", dependencies=[Depends(verify_secret)])
async def image_geninfo(path: str): async def image_geninfo(path: str):
# 使用 get_exif_data 函数,它已经支持视频文件
from scripts.iib.db.update_image_data import get_exif_data from scripts.iib.db.update_image_data import get_exif_data
conn = DataBase.get_conn()
try: try:
# 优先从数据库查询
img = DbImg.get(conn, path)
if img and img.exif:
return img.exif
# 数据库中没有,从文件读取
result = get_exif_data(path) result = get_exif_data(path)
return result.raw_info or "" raw_info = result.raw_info or ""
# 如果 DbImg 存在,将读取到的数据缓存到数据库
if img and raw_info:
img.exif = raw_info
img.update(conn)
return raw_info
except Exception as e: except Exception as e:
logger.error(f"Failed to get geninfo for {path}: {e}") logger.error(f"Failed to get geninfo for {path}: {e}")
return "" return ""
@ -859,6 +914,35 @@ def infinite_image_browsing_api(app: FastAPI, **kwargs):
logger.error(f"Failed to get exif for {path}: {e}") logger.error(f"Failed to get exif for {path}: {e}")
return {} return {}
class UpdateExifReq(BaseModel):
path: str
exif: str
@app.post(api_base + "/update_exif", dependencies=[Depends(verify_secret), Depends(write_permission_required)])
async def update_exif(req: UpdateExifReq):
"""更新图片/视频的 exif 信息"""
conn = DataBase.get_conn()
try:
img = DbImg.get(conn, req.path)
if img:
img.update_exif(conn, req.exif)
conn.commit()
return {"success": True, "message": "Exif updated successfully"}
else:
# 如果数据库中没有记录,创建新记录
img = DbImg(path=req.path, exif=req.exif, exif_edited=True)
# 获取文件信息
if os.path.exists(req.path):
stat = os.stat(req.path)
img.size = stat.st_size
img.date = get_modified_date(req.path)
img.save(conn)
conn.commit()
return {"success": True, "message": "Exif created successfully"}
except Exception as e:
logger.error(f"Failed to update exif for {req.path}: {e}", stack_info=True)
raise HTTPException(status_code=500, detail=str(e))
class CheckPathExistsReq(BaseModel): class CheckPathExistsReq(BaseModel):
paths: List[str] paths: List[str]

View File

@ -97,9 +97,10 @@ class DataBase:
class Image: class Image:
def __init__(self, path, exif=None, size=0, date="", id=None): def __init__(self, path, exif=None, size=0, date="", exif_edited=False, id=None):
self.path = path self.path = path
self.exif = exif self.exif = exif
self.exif_edited = exif_edited
self.id = id self.id = id
self.size = size self.size = size
self.date = date self.date = date
@ -120,11 +121,21 @@ class Image:
def save(self, conn): def save(self, conn):
with closing(conn.cursor()) as cur: with closing(conn.cursor()) as cur:
cur.execute( cur.execute(
"INSERT OR REPLACE INTO image (path, exif, size, date) VALUES (?, ?, ?, ?)", "INSERT OR REPLACE INTO image (path, exif, exif_edited, size, date) VALUES (?, ?, ?, ?, ?)",
(self.path, self.exif, self.size, self.date), (self.path, self.exif, int(self.exif_edited), self.size, self.date),
) )
self.id = cur.lastrowid self.id = cur.lastrowid
def update_exif(self, conn: Connection, exif: str, mark_edited: bool = True):
"""更新图片的 exif 信息并标记为已编辑"""
with closing(conn.cursor()) as cur:
cur.execute(
"UPDATE image SET exif = ?, exif_edited = ? WHERE id = ?",
(exif, mark_edited, self.id),
)
self.exif = exif
self.exif_edited = mark_edited
def update_path(self, conn: Connection, new_path: str, force=False): def update_path(self, conn: Connection, new_path: str, force=False):
self.path = os.path.normpath(new_path) self.path = os.path.normpath(new_path)
with closing(conn.cursor()) as cur: with closing(conn.cursor()) as cur:
@ -174,11 +185,22 @@ class Image:
path TEXT UNIQUE, path TEXT UNIQUE,
exif TEXT, exif TEXT,
size INTEGER, size INTEGER,
date TEXT date TEXT,
exif_edited INTEGER DEFAULT 0
)""" )"""
) )
cur.execute("CREATE INDEX IF NOT EXISTS image_idx_path ON image(path)") cur.execute("CREATE INDEX IF NOT EXISTS image_idx_path ON image(path)")
# 数据库迁移:为旧表添加 exif_edited 列
try:
cur.execute(
"""ALTER TABLE image
ADD COLUMN exif_edited INTEGER DEFAULT 0"""
)
except sqlite3.OperationalError:
# 列已存在,忽略
pass
@classmethod @classmethod
def count(cls, conn): def count(cls, conn):
with closing(conn.cursor()) as cur: with closing(conn.cursor()) as cur:
@ -188,7 +210,11 @@ class Image:
@classmethod @classmethod
def from_row(cls, row: tuple): def from_row(cls, row: tuple):
image = cls(path=row[1], exif=row[2], size=row[3], date=row[4]) """从数据库行创建 Image 对象
字段顺序id=0, path=1, exif=2, size=3, date=4, exif_edited=5
"""
image = cls(path=row[1], exif=row[2], size=row[3], date=row[4], exif_edited=bool(row[5]) )
image.id = row[0] image.id = row[0]
return image return image

View File

@ -146,6 +146,11 @@ def get_extra_meta_keys_from_plugins(source_identifier: str):
def build_single_img_idx(conn, file_path, is_rebuild, safe_save_img_tag): def build_single_img_idx(conn, file_path, is_rebuild, safe_save_img_tag):
img = DbImg.get(conn, file_path) img = DbImg.get(conn, file_path)
if img and is_rebuild and img.exif_edited:
logger.info(f"Image {file_path} has been manually edited, skipping rebuild.")
return
parsed_params = None parsed_params = None
if is_rebuild: if is_rebuild:
info = get_exif_data(file_path) info = get_exif_data(file_path)

3
vue/components.d.ts vendored
View File

@ -53,12 +53,15 @@ declare module '@vue/runtime-core' {
BaseFileListInfo: typeof import('./src/components/BaseFileListInfo.vue')['default'] BaseFileListInfo: typeof import('./src/components/BaseFileListInfo.vue')['default']
ChangeIndicator: typeof import('./src/components/ChangeIndicator.vue')['default'] ChangeIndicator: typeof import('./src/components/ChangeIndicator.vue')['default']
ContextMenu: typeof import('./src/components/ContextMenu.vue')['default'] ContextMenu: typeof import('./src/components/ContextMenu.vue')['default']
DraggableImage: typeof import('./src/components/DraggableImage.vue')['default']
ExifBrowser: typeof import('./src/components/ExifBrowser.vue')['default'] ExifBrowser: typeof import('./src/components/ExifBrowser.vue')['default']
FileItem: typeof import('./src/components/FileItem.vue')['default'] FileItem: typeof import('./src/components/FileItem.vue')['default']
HistoryRecord: typeof import('./src/components/HistoryRecord.vue')['default'] HistoryRecord: typeof import('./src/components/HistoryRecord.vue')['default']
KvPairEditor: typeof import('./src/components/KvPairEditor.vue')['default']
MultiSelectKeep: typeof import('./src/components/MultiSelectKeep.vue')['default'] MultiSelectKeep: typeof import('./src/components/MultiSelectKeep.vue')['default']
NumInput: typeof import('./src/components/numInput.vue')['default'] NumInput: typeof import('./src/components/numInput.vue')['default']
OrganizeJobsPanel: typeof import('./src/components/OrganizeJobsPanel.vue')['default'] OrganizeJobsPanel: typeof import('./src/components/OrganizeJobsPanel.vue')['default']
PromptEditorModal: typeof import('./src/components/PromptEditorModal.vue')['default']
RouterLink: typeof import('vue-router')['RouterLink'] RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView'] RouterView: typeof import('vue-router')['RouterView']
SmartOrganizeConfigModal: typeof import('./src/components/SmartOrganizeConfigModal.vue')['default'] SmartOrganizeConfigModal: typeof import('./src/components/SmartOrganizeConfigModal.vue')['default']

1
vue/dist/assets/FileItem-5029b363.css vendored Normal file

File diff suppressed because one or more lines are too long

2
vue/dist/assets/FileItem-56a257c5.js vendored Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -1 +1 @@
import{d as a,o as t,j as n,c as s,c1 as _,n as o}from"./index-f2db319b.js";const c={class:"img-sli-container"},i=a({__name:"ImgSliPagePane",props:{paneIdx:{},tabIdx:{},left:{},right:{}},setup(l){return(e,r)=>(t(),n("div",c,[s(_,{left:e.left,right:e.right},null,8,["left","right"])]))}});const d=o(i,[["__scopeId","data-v-ae3fb9a8"]]);export{d as default}; import{d as a,o as t,j as n,c as s,c1 as _,n as o}from"./index-b01f57e3.js";const c={class:"img-sli-container"},i=a({__name:"ImgSliPagePane",props:{paneIdx:{},tabIdx:{},left:{},right:{}},setup(l){return(e,r)=>(t(),n("div",c,[s(_,{left:e.left,right:e.right},null,8,["left","right"])]))}});const d=o(i,[["__scopeId","data-v-ae3fb9a8"]]);export{d as default};

View File

@ -0,0 +1 @@
.container[data-v-ed0cd2f6]{background:var(--zp-secondary-background);position:relative}.container .action-bar[data-v-ed0cd2f6]{display:flex;align-items:center;user-select:none;gap:4px;padding:4px}.container .action-bar>*[data-v-ed0cd2f6]{flex-wrap:wrap}.container .file-list[data-v-ed0cd2f6]{list-style:none;padding:8px;overflow:auto;height:calc(var(--pane-max-height) - 40px);width:100%}.container .no-res-hint[data-v-ed0cd2f6]{height:var(--pane-max-height);display:flex;align-items:center;flex-direction:column;justify-content:center}.container .no-res-hint .hint[data-v-ed0cd2f6]{font-size:1.6em;margin-bottom:2em;text-align:center}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1 @@
.container[data-v-59722f79]{background:var(--zp-secondary-background);position:relative;height:var(--pane-max-height)}.action-bar[data-v-59722f79]{display:flex;align-items:center;user-select:none;gap:6px;padding:6px 8px}.title[data-v-59722f79]{font-weight:700;max-width:40vw}.file-list[data-v-59722f79]{list-style:none;padding:8px;overflow:auto;height:calc(var(--pane-max-height) - 44px);width:100%}.no-res-hint[data-v-59722f79]{height:calc(var(--pane-max-height) - 44px);display:flex;align-items:center;flex-direction:column;justify-content:center}.no-res-hint .hint[data-v-59722f79]{font-size:1.2em;opacity:.7}.preview-switch[data-v-59722f79]{position:fixed;bottom:24px;right:24px;display:flex;gap:8px;font-size:36px;user-select:none}.disable[data-v-59722f79]{opacity:.3;pointer-events:none}

View File

@ -1 +0,0 @@
.container[data-v-4815fec6]{background:var(--zp-secondary-background);position:relative}.container .action-bar[data-v-4815fec6]{display:flex;align-items:center;user-select:none;gap:4px;padding:4px}.container .action-bar>*[data-v-4815fec6]{flex-wrap:wrap}.container .file-list[data-v-4815fec6]{list-style:none;padding:8px;overflow:auto;height:calc(var(--pane-max-height) - 40px);width:100%}.container .no-res-hint[data-v-4815fec6]{height:var(--pane-max-height);display:flex;align-items:center;flex-direction:column;justify-content:center}.container .no-res-hint .hint[data-v-4815fec6]{font-size:1.6em;margin-bottom:2em;text-align:center}

File diff suppressed because one or more lines are too long

View File

@ -1 +0,0 @@
.container[data-v-aea581a5]{background:var(--zp-secondary-background);position:relative;height:var(--pane-max-height)}.action-bar[data-v-aea581a5]{display:flex;align-items:center;user-select:none;gap:6px;padding:6px 8px}.title[data-v-aea581a5]{font-weight:700;max-width:40vw}.file-list[data-v-aea581a5]{list-style:none;padding:8px;overflow:auto;height:calc(var(--pane-max-height) - 44px);width:100%}.no-res-hint[data-v-aea581a5]{height:calc(var(--pane-max-height) - 44px);display:flex;align-items:center;flex-direction:column;justify-content:center}.no-res-hint .hint[data-v-aea581a5]{font-size:1.2em;opacity:.7}.preview-switch[data-v-aea581a5]{position:fixed;bottom:24px;right:24px;display:flex;gap:8px;font-size:36px;user-select:none}.disable[data-v-aea581a5]{opacity:.3;pointer-events:none}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -1 +0,0 @@
[data-v-4584136c] .float-panel{position:fixed}.regex-icon[data-v-4584136c]{user-select:none;padding:4px;margin:0 4px;cursor:pointer;border:1px solid var(--zp-border);border-radius:4px}.regex-icon img[data-v-4584136c]{height:1.5em}.regex-icon[data-v-4584136c]:hover{background:var(--zp-border)}.regex-icon.selected[data-v-4584136c]{background:var(--primary-color-1);border:1px solid var(--primary-color)}.search-bar[data-v-4584136c]{padding:8px 8px 0;display:flex}.search-bar.last[data-v-4584136c]{padding-bottom:8px}.search-bar .form-name[data-v-4584136c]{flex-shrink:0;padding:4px 8px}.search-bar .actions>*[data-v-4584136c]{margin-right:4px}.tips-wrapper[data-v-4584136c]{padding:0 8px}.container[data-v-4584136c]{background:var(--zp-secondary-background);position:relative}.container .file-list[data-v-4584136c]{list-style:none;padding:8px;height:100%;overflow:auto;height:var(--pane-max-height);width:100%}

View File

@ -0,0 +1 @@
[data-v-4fecbf3c] .float-panel{position:fixed}.regex-icon[data-v-4fecbf3c]{user-select:none;padding:4px;margin:0 4px;cursor:pointer;border:1px solid var(--zp-border);border-radius:4px}.regex-icon img[data-v-4fecbf3c]{height:1.5em}.regex-icon[data-v-4fecbf3c]:hover{background:var(--zp-border)}.regex-icon.selected[data-v-4fecbf3c]{background:var(--primary-color-1);border:1px solid var(--primary-color)}.search-bar[data-v-4fecbf3c]{padding:8px 8px 0;display:flex}.search-bar.last[data-v-4fecbf3c]{padding-bottom:8px}.search-bar .form-name[data-v-4fecbf3c]{flex-shrink:0;padding:4px 8px}.search-bar .actions>*[data-v-4fecbf3c]{margin-right:4px}.tips-wrapper[data-v-4fecbf3c]{padding:0 8px}.container[data-v-4fecbf3c]{background:var(--zp-secondary-background);position:relative}.container .file-list[data-v-4fecbf3c]{list-style:none;padding:8px;height:100%;overflow:auto;height:var(--pane-max-height);width:100%}

File diff suppressed because one or more lines are too long

View File

@ -1,3 +1,3 @@
import{bJ as N,bK as E,bL as q,c as u,A as U,d as R,o as d,j as p,k as _,F as X,K as G,ah as K,C as f,l as g,t as h,E as L,a3 as H,n as z,aq as Q,aC as C,aD as F,r as W,aj as I,X as V,v as Y,bM as Z,m as T,bN as ee,bO as te}from"./index-f2db319b.js";const ge=N(E),me=N(q);var ne={icon:{tag:"svg",attrs:{viewBox:"64 64 896 896",focusable:"false"},children:[{tag:"path",attrs:{d:"M878.3 392.1L631.9 145.7c-6.5-6.5-15-9.7-23.5-9.7s-17 3.2-23.5 9.7L423.8 306.9c-12.2-1.4-24.5-2-36.8-2-73.2 0-146.4 24.1-206.5 72.3-15.4 12.3-16.6 35.4-2.7 49.4l181.7 181.7-215.4 215.2a15.8 15.8 0 00-4.6 9.8l-3.4 37.2c-.9 9.4 6.6 17.4 15.9 17.4.5 0 1 0 1.5-.1l37.2-3.4c3.7-.3 7.2-2 9.8-4.6l215.4-215.4 181.7 181.7c6.5 6.5 15 9.7 23.5 9.7 9.7 0 19.3-4.2 25.9-12.4 56.3-70.3 79.7-158.3 70.2-243.4l161.1-161.1c12.9-12.8 12.9-33.8 0-46.8z"}}]},name:"pushpin",theme:"filled"};const se=ne;function k(i){for(var e=1;e<arguments.length;e++){var t=arguments[e]!=null?Object(arguments[e]):{},n=Object.keys(t);typeof Object.getOwnPropertySymbols=="function"&&(n=n.concat(Object.getOwnPropertySymbols(t).filter(function(s){return Object.getOwnPropertyDescriptor(t,s).enumerable}))),n.forEach(function(s){ie(i,s,t[s])})}return i}function ie(i,e,t){return e in i?Object.defineProperty(i,e,{value:t,enumerable:!0,configurable:!0,writable:!0}):i[e]=t,i}var P=function(e,t){var n=k({},e,t.attrs);return u(U,k({},n,{icon:se}),null)};P.displayName="PushpinFilled";P.inheritAttrs=!1;const re=P,ae={class:"record-container"},oe={style:{flex:"1"}},ce={class:"rec-actions"},le=["onClick"],ue=R({__name:"HistoryRecord",props:{records:{}},emits:["reuseRecord"],setup(i){return(e,t)=>{const n=H;return d(),p("div",null,[_("ul",ae,[(d(!0),p(X,null,G(e.records.getRecords(),s=>(d(),p("li",{key:s.id,class:"record"},[_("div",oe,[K(e.$slots,"default",{record:s},void 0,!0)]),_("div",ce,[u(n,{onClick:o=>e.$emit("reuseRecord",s),type:"primary"},{default:f(()=>[g(h(e.$t("restore")),1)]),_:2},1032,["onClick"]),_("div",{class:"pin",onClick:o=>e.records.switchPin(s)},[u(L(re)),g(" "+h(e.records.isPinned(s)?e.$t("unpin"):e.$t("pin")),1)],8,le)])]))),128))])])}}});const ve=z(ue,[["__scopeId","data-v-834a248f"]]);class m{constructor(e=128,t=[],n=[]){this.maxLength=e,this.records=t,this.pinnedValues=n}isPinned(e){return this.pinnedValues.some(t=>t.id===e.id)}add(e){this.records.length>=this.maxLength&&this.records.pop(),this.records.unshift({...e,id:Q()+Date.now(),time:new Date().toLocaleString()})}pin(e){const t=this.records.findIndex(n=>n.id===e.id);t!==-1&&this.records.splice(t,1),this.pinnedValues.push(e)}unpin(e){const t=this.pinnedValues.findIndex(n=>n.id===e.id);t!==-1&&this.pinnedValues.splice(t,1),this.records.unshift(e)}switchPin(e){this.isPinned(e)?this.unpin(e):this.pin(e)}getRecords(){return[...this.pinnedValues,...this.records]}getPinnedValues(){return this.pinnedValues}}const ye=C(`${F}fuzzy-search-HistoryRecord`,new m,{serializer:{read:i=>{const e=JSON.parse(i);return new m(e.maxLength,e.records,e.pinnedValues)},write:JSON.stringify}}),be=C(`${F}tag-search-HistoryRecord`,new m,{serializer:{read:i=>{const e=JSON.parse(i);return new m(e.maxLength,e.records,e.pinnedValues)},write:JSON.stringify}}),de={key:0,class:"tips-carousel"},pe={class:"tip-text"},_e={key:0,class:"tip-actions"},he=R({__name:"TipsCarousel",props:{interval:{type:Number,default:1e4}},setup(i){const e=i,t=C("iib-dismissed-tips-v2",{}),n=W(0);let s=null;const o=I(()=>{var v;const r=[];for(let a=1;a<=10;a++){const l=`loadingTip${a}`,y=V(l);if(!y||typeof y!="string")continue;const $=y.split("|"),b=$[0],M=((v=$[1])==null?void 0:v.trim())||"info",S=` import{bJ as N,bK as E,bL as q,c as u,A as U,d as R,o as d,j as p,k as _,F as X,K as G,ah as K,C as f,l as g,t as h,E as L,a3 as H,n as z,aq as Q,aC as C,aD as F,r as W,aj as I,X as V,v as Y,bM as Z,m as T,bN as ee,bO as te}from"./index-b01f57e3.js";const ge=N(E),me=N(q);var ne={icon:{tag:"svg",attrs:{viewBox:"64 64 896 896",focusable:"false"},children:[{tag:"path",attrs:{d:"M878.3 392.1L631.9 145.7c-6.5-6.5-15-9.7-23.5-9.7s-17 3.2-23.5 9.7L423.8 306.9c-12.2-1.4-24.5-2-36.8-2-73.2 0-146.4 24.1-206.5 72.3-15.4 12.3-16.6 35.4-2.7 49.4l181.7 181.7-215.4 215.2a15.8 15.8 0 00-4.6 9.8l-3.4 37.2c-.9 9.4 6.6 17.4 15.9 17.4.5 0 1 0 1.5-.1l37.2-3.4c3.7-.3 7.2-2 9.8-4.6l215.4-215.4 181.7 181.7c6.5 6.5 15 9.7 23.5 9.7 9.7 0 19.3-4.2 25.9-12.4 56.3-70.3 79.7-158.3 70.2-243.4l161.1-161.1c12.9-12.8 12.9-33.8 0-46.8z"}}]},name:"pushpin",theme:"filled"};const se=ne;function k(i){for(var e=1;e<arguments.length;e++){var t=arguments[e]!=null?Object(arguments[e]):{},n=Object.keys(t);typeof Object.getOwnPropertySymbols=="function"&&(n=n.concat(Object.getOwnPropertySymbols(t).filter(function(s){return Object.getOwnPropertyDescriptor(t,s).enumerable}))),n.forEach(function(s){ie(i,s,t[s])})}return i}function ie(i,e,t){return e in i?Object.defineProperty(i,e,{value:t,enumerable:!0,configurable:!0,writable:!0}):i[e]=t,i}var P=function(e,t){var n=k({},e,t.attrs);return u(U,k({},n,{icon:se}),null)};P.displayName="PushpinFilled";P.inheritAttrs=!1;const re=P,ae={class:"record-container"},oe={style:{flex:"1"}},ce={class:"rec-actions"},le=["onClick"],ue=R({__name:"HistoryRecord",props:{records:{}},emits:["reuseRecord"],setup(i){return(e,t)=>{const n=H;return d(),p("div",null,[_("ul",ae,[(d(!0),p(X,null,G(e.records.getRecords(),s=>(d(),p("li",{key:s.id,class:"record"},[_("div",oe,[K(e.$slots,"default",{record:s},void 0,!0)]),_("div",ce,[u(n,{onClick:o=>e.$emit("reuseRecord",s),type:"primary"},{default:f(()=>[g(h(e.$t("restore")),1)]),_:2},1032,["onClick"]),_("div",{class:"pin",onClick:o=>e.records.switchPin(s)},[u(L(re)),g(" "+h(e.records.isPinned(s)?e.$t("unpin"):e.$t("pin")),1)],8,le)])]))),128))])])}}});const ve=z(ue,[["__scopeId","data-v-834a248f"]]);class m{constructor(e=128,t=[],n=[]){this.maxLength=e,this.records=t,this.pinnedValues=n}isPinned(e){return this.pinnedValues.some(t=>t.id===e.id)}add(e){this.records.length>=this.maxLength&&this.records.pop(),this.records.unshift({...e,id:Q()+Date.now(),time:new Date().toLocaleString()})}pin(e){const t=this.records.findIndex(n=>n.id===e.id);t!==-1&&this.records.splice(t,1),this.pinnedValues.push(e)}unpin(e){const t=this.pinnedValues.findIndex(n=>n.id===e.id);t!==-1&&this.pinnedValues.splice(t,1),this.records.unshift(e)}switchPin(e){this.isPinned(e)?this.unpin(e):this.pin(e)}getRecords(){return[...this.pinnedValues,...this.records]}getPinnedValues(){return this.pinnedValues}}const ye=C(`${F}fuzzy-search-HistoryRecord`,new m,{serializer:{read:i=>{const e=JSON.parse(i);return new m(e.maxLength,e.records,e.pinnedValues)},write:JSON.stringify}}),be=C(`${F}tag-search-HistoryRecord`,new m,{serializer:{read:i=>{const e=JSON.parse(i);return new m(e.maxLength,e.records,e.pinnedValues)},write:JSON.stringify}}),de={key:0,class:"tips-carousel"},pe={class:"tip-text"},_e={key:0,class:"tip-actions"},he=R({__name:"TipsCarousel",props:{interval:{type:Number,default:1e4}},setup(i){const e=i,t=C("iib-dismissed-tips-v2",{}),n=W(0);let s=null;const o=I(()=>{var v;const r=[];for(let a=1;a<=10;a++){const l=`loadingTip${a}`,y=V(l);if(!y||typeof y!="string")continue;const $=y.split("|"),b=$[0],M=((v=$[1])==null?void 0:v.trim())||"info",S=`
`,w=b.indexOf(S);if(w===-1)continue;const x=b.substring(0,w).trim(),A=b.substring(w+S.length).trim(),O={title:x,content:A,type:M};O.type==="warning"&&t.value[x]||r.push(O)}for(let a=r.length-1;a>0;a--){const l=Math.floor(Math.random()*(a+1));[r[a],r[l]]=[r[l],r[a]]}return r}),c=I(()=>{const r=o.value;return r.length===0?{title:"",content:"",type:"info"}:r[n.value%r.length]}),j=r=>{switch(r){case"warning":return"warning";case"info":return"blue";case"tip":return"green";default:return"default"}},B=()=>{c.value.type==="warning"&&(t.value={...t.value,[c.value.title]:!0})},D=()=>{s&&clearInterval(s),s=setInterval(()=>{o.value.length>1&&(n.value=(n.value+1)%o.value.length)},e.interval)},J=()=>{s&&(clearInterval(s),s=null)};return Y(()=>{D()}),Z(()=>{J()}),(r,v)=>{const a=te,l=H;return o.value.length>0?(d(),p("div",de,[u(ee,{name:"tip-fade",mode:"out-in"},{default:f(()=>[(d(),p("div",{key:n.value,class:"tip-content"},[u(a,{color:j(c.value.type),class:"tip-tag"},{default:f(()=>[g(h(c.value.title),1)]),_:1},8,["color"]),_("span",pe,h(c.value.content),1),c.value.type==="warning"?(d(),p("div",_e,[u(l,{size:"small",type:"link",onClick:B},{default:f(()=>[g(h(L(V)("dontShowAgain")),1)]),_:1})])):T("",!0)]))]),_:1})])):T("",!0)}}});const we=z(he,[["__scopeId","data-v-3b5692ee"]]);export{ve as H,we as T,me as _,ge as a,ye as f,be as t}; `,w=b.indexOf(S);if(w===-1)continue;const x=b.substring(0,w).trim(),A=b.substring(w+S.length).trim(),O={title:x,content:A,type:M};O.type==="warning"&&t.value[x]||r.push(O)}for(let a=r.length-1;a>0;a--){const l=Math.floor(Math.random()*(a+1));[r[a],r[l]]=[r[l],r[a]]}return r}),c=I(()=>{const r=o.value;return r.length===0?{title:"",content:"",type:"info"}:r[n.value%r.length]}),j=r=>{switch(r){case"warning":return"warning";case"info":return"blue";case"tip":return"green";default:return"default"}},B=()=>{c.value.type==="warning"&&(t.value={...t.value,[c.value.title]:!0})},D=()=>{s&&clearInterval(s),s=setInterval(()=>{o.value.length>1&&(n.value=(n.value+1)%o.value.length)},e.interval)},J=()=>{s&&(clearInterval(s),s=null)};return Y(()=>{D()}),Z(()=>{J()}),(r,v)=>{const a=te,l=H;return o.value.length>0?(d(),p("div",de,[u(ee,{name:"tip-fade",mode:"out-in"},{default:f(()=>[(d(),p("div",{key:n.value,class:"tip-content"},[u(a,{color:j(c.value.type),class:"tip-tag"},{default:f(()=>[g(h(c.value.title),1)]),_:1},8,["color"]),_("span",pe,h(c.value.content),1),c.value.type==="warning"?(d(),p("div",_e,[u(l,{size:"small",type:"link",onClick:B},{default:f(()=>[g(h(L(V)("dontShowAgain")),1)]),_:1})])):T("",!0)]))]),_:1})])):T("",!0)}}});const we=z(he,[["__scopeId","data-v-3b5692ee"]]);export{ve as H,we as T,me as _,ge as a,ye as f,be as t};

File diff suppressed because one or more lines are too long

View File

@ -1 +1 @@
import{b2 as i,aO as t,dK as f,aS as n}from"./index-f2db319b.js";function u(e,a,r){if(!i(r))return!1;var s=typeof a;return(s=="number"?t(r)&&f(a,r.length):s=="string"&&a in r)?n(r[a],e):!1}export{u as i}; import{b2 as i,aO as t,dN as f,aS as n}from"./index-b01f57e3.js";function u(e,a,r){if(!i(r))return!1;var s=typeof a;return(s=="number"?t(r)&&f(a,r.length):s=="string"&&a in r)?n(r[a],e):!1}export{u as i};

View File

@ -0,0 +1 @@
.container[data-v-59d2d572]{background:var(--zp-secondary-background);height:100%;overflow:auto;display:flex;flex-direction:column}.container .actions-panel[data-v-59d2d572]{padding:8px;background-color:var(--zp-primary-background)}.container .actions-panel.actions[data-v-59d2d572]{display:flex;align-items:center;gap:16px;z-index:333}.container .file-list[data-v-59d2d572]{flex:1;z-index:222;list-style:none;padding:8px;height:var(--pane-max-height);width:100%}.container .file-list .hint[data-v-59d2d572]{text-align:center;font-size:2em;padding:30vh 128px 0}

View File

@ -1 +0,0 @@
.container[data-v-a2642a17]{background:var(--zp-secondary-background);height:100%;overflow:auto;display:flex;flex-direction:column}.container .actions-panel[data-v-a2642a17]{padding:8px;background-color:var(--zp-primary-background)}.container .actions-panel.actions[data-v-a2642a17]{display:flex;align-items:center;gap:16px;z-index:333}.container .file-list[data-v-a2642a17]{flex:1;z-index:222;list-style:none;padding:8px;height:var(--pane-max-height);width:100%}.container .file-list .hint[data-v-a2642a17]{text-align:center;font-size:2em;padding:30vh 128px 0}

View File

@ -1 +1 @@
import{d as z,p as B,c2 as $,bp as S,o as _,j as w,k as f,c as l,C as d,l as p,t as c,E as s,B as A,U as E,c3 as R,c4 as y,W as x,X as T,a3 as U,a6 as V,n as N}from"./index-f2db319b.js";import{F as j,s as L}from"./FileItem-72718f68.js";import{u as H,b as O,j as W}from"./index-0d856f16.js";import"./index-29e38a15.js";import"./shortcut-869fab50.js";import"./_isIterateeCall-dd643bcf.js";const q={class:"actions-panel actions"},G={class:"item"},P={key:0,class:"file-list"},Q={class:"hint"},X=z({__name:"batchDownload",props:{tabIdx:{},paneIdx:{},id:{}},setup(J){const{stackViewEl:b}=H().toRefs(),{itemSize:h,gridItems:D,cellWidth:g}=O(),i=B(),m=W(),{selectdFiles:a}=$(m),r=S(),v=async e=>{const t=R(e);t&&m.addFiles(t.nodes)},C=async()=>{r.pushAction(async()=>{const e=await y.value.post("/zip",{paths:a.value.map(u=>u.fullpath),compress:i.batchDownloadCompress,pack_only:!1},{responseType:"blob"}),t=window.URL.createObjectURL(new Blob([e.data])),o=document.createElement("a");o.href=t,o.setAttribute("download",`iib_${new Date().toLocaleString()}.zip`),document.body.appendChild(o),o.click()})},I=async()=>{r.pushAction(async()=>{await y.value.post("/zip",{paths:a.value.map(e=>e.fullpath),compress:i.batchDownloadCompress,pack_only:!0},{responseType:"blob"}),x.success(T("success"))})},F=e=>{a.value.splice(e,1)};return(e,t)=>{const o=U,u=V;return _(),w("div",{class:"container",ref_key:"stackViewEl",ref:b,onDrop:v},[f("div",q,[l(o,{onClick:t[0]||(t[0]=n=>s(m).selectdFiles=[])},{default:d(()=>[p(c(e.$t("clear")),1)]),_:1}),f("div",G,[p(c(e.$t("compressFile"))+": ",1),l(u,{checked:s(i).batchDownloadCompress,"onUpdate:checked":t[1]||(t[1]=n=>s(i).batchDownloadCompress=n)},null,8,["checked"])]),l(o,{onClick:I,type:"primary",loading:!s(r).isIdle},{default:d(()=>[p(c(e.$t("packOnlyNotDownload")),1)]),_:1},8,["loading"]),l(o,{onClick:C,type:"primary",loading:!s(r).isIdle},{default:d(()=>[p(c(e.$t("zipDownload")),1)]),_:1},8,["loading"])]),s(a).length?(_(),A(s(L),{key:1,ref:"scroller",class:"file-list",items:s(a).slice(),"item-size":s(h).first,"key-field":"fullpath","item-secondary-size":s(h).second,gridItems:s(D)},{default:d(({item:n,index:k})=>[l(j,{idx:k,file:n,"cell-width":s(g),"enable-close-icon":"",onCloseIconClick:K=>F(k),"full-screen-preview-image-url":s(E)(n),"enable-right-click-menu":!1},null,8,["idx","file","cell-width","onCloseIconClick","full-screen-preview-image-url"])]),_:1},8,["items","item-size","item-secondary-size","gridItems"])):(_(),w("div",P,[f("p",Q,c(e.$t("batchDownloaDDragAndDropHint")),1)]))],544)}}});const oe=N(X,[["__scopeId","data-v-a2642a17"]]);export{oe as default}; import{d as B,p as F,c2 as $,bp as S,o as _,j as w,k as f,c as l,C as d,l as p,t as c,E as s,B as A,U as E,c3 as x,c4 as y,W as R,X as T,a3 as U,a6 as V,n as N}from"./index-b01f57e3.js";import{F as j,s as L}from"./FileItem-56a257c5.js";import{u as H,b as O,j as W}from"./index-59dc4640.js";import"./index-482cb1cf.js";import"./shortcut-bfd52548.js";import"./_isIterateeCall-f668c4e1.js";const q={class:"actions-panel actions"},G={class:"item"},P={key:0,class:"file-list"},Q={class:"hint"},X=B({__name:"batchDownload",props:{tabIdx:{},paneIdx:{},id:{}},setup(J){const{stackViewEl:b}=H().toRefs(),{itemSize:h,gridItems:D,cellWidth:g}=O(),i=F(),m=W(),{selectdFiles:a}=$(m),r=S(),v=async e=>{const t=x(e);t&&m.addFiles(t.nodes)},C=async()=>{r.pushAction(async()=>{const e=await y.value.post("/zip",{paths:a.value.map(u=>u.fullpath),compress:i.batchDownloadCompress,pack_only:!1},{responseType:"blob"}),t=window.URL.createObjectURL(new Blob([e.data])),o=document.createElement("a");o.href=t,o.setAttribute("download",`iib_${new Date().toLocaleString()}.zip`),document.body.appendChild(o),o.click()})},I=async()=>{r.pushAction(async()=>{await y.value.post("/zip",{paths:a.value.map(e=>e.fullpath),compress:i.batchDownloadCompress,pack_only:!0},{responseType:"blob"}),R.success(T("success"))})},z=e=>{a.value.splice(e,1)};return(e,t)=>{const o=U,u=V;return _(),w("div",{class:"container",ref_key:"stackViewEl",ref:b,onDrop:v},[f("div",q,[l(o,{onClick:t[0]||(t[0]=n=>s(m).selectdFiles=[])},{default:d(()=>[p(c(e.$t("clear")),1)]),_:1}),f("div",G,[p(c(e.$t("compressFile"))+": ",1),l(u,{checked:s(i).batchDownloadCompress,"onUpdate:checked":t[1]||(t[1]=n=>s(i).batchDownloadCompress=n)},null,8,["checked"])]),l(o,{onClick:I,type:"primary",loading:!s(r).isIdle},{default:d(()=>[p(c(e.$t("packOnlyNotDownload")),1)]),_:1},8,["loading"]),l(o,{onClick:C,type:"primary",loading:!s(r).isIdle},{default:d(()=>[p(c(e.$t("zipDownload")),1)]),_:1},8,["loading"])]),s(a).length?(_(),A(s(L),{key:1,ref:"scroller",class:"file-list",items:s(a).slice(),"item-size":s(h).first,"key-field":"fullpath","item-secondary-size":s(h).second,gridItems:s(D)},{default:d(({item:n,index:k})=>[l(j,{idx:k,file:n,"cell-width":s(g),"enable-close-icon":"",onCloseIconClick:K=>z(k),"full-screen-preview-image-url":s(E)(n),"enable-right-click-menu":!1},null,8,["idx","file","cell-width","onCloseIconClick","full-screen-preview-image-url"])]),_:1},8,["items","item-size","item-secondary-size","gridItems"])):(_(),w("div",P,[f("p",Q,c(e.$t("batchDownloaDDragAndDropHint")),1)]))],544)}}});const oe=N(X,[["__scopeId","data-v-59d2d572"]]);export{oe as default};

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

1
vue/dist/assets/gridView-baf2e824.css vendored Normal file
View File

@ -0,0 +1 @@
.container[data-v-4c5d9776]{background:var(--zp-secondary-background);height:100%;overflow:auto;display:flex;flex-direction:column}.container .actions-panel[data-v-4c5d9776]{padding:8px;background-color:var(--zp-primary-background)}.container .file-list[data-v-4c5d9776]{flex:1;list-style:none;padding:8px;height:var(--pane-max-height);width:100%}.container .file-list .hint[data-v-4c5d9776]{text-align:center;font-size:2em;padding:30vh 128px 0}

1
vue/dist/assets/gridView-e3e63ff0.js vendored Normal file
View File

@ -0,0 +1 @@
import{F as w,s as y}from"./FileItem-56a257c5.js";import{u as k,b as x}from"./index-59dc4640.js";import{d as I,p as b,bm as h,r as C,c5 as D,c6 as F,o as E,j as V,c as n,C as z,E as e,U as S,c3 as B,c7 as A,n as K}from"./index-b01f57e3.js";import"./index-482cb1cf.js";import"./shortcut-bfd52548.js";import"./_isIterateeCall-f668c4e1.js";const R=I({__name:"gridView",props:{tabIdx:{},paneIdx:{},id:{},removable:{type:Boolean},allowDragAndDrop:{type:Boolean},files:{},paneKey:{}},setup(p){const o=p,m=b(),{stackViewEl:d}=k().toRefs(),{itemSize:i,gridItems:u,cellWidth:g}=x(),f=h(),s=C(o.files??[]),_=async t=>{const l=B(t);o.allowDragAndDrop&&l&&(s.value=A([...s.value,...l.nodes]))},v=t=>{s.value.splice(t,1)};return D(()=>{m.pageFuncExportMap.set(o.paneKey,{getFiles:()=>F(s.value),setFiles:t=>s.value=t})}),(t,l)=>(E(),V("div",{class:"container",ref_key:"stackViewEl",ref:d,onDrop:_},[n(e(y),{ref:"scroller",class:"file-list",items:s.value.slice(),"item-size":e(i).first,"key-field":"fullpath","item-secondary-size":e(i).second,gridItems:e(u)},{default:z(({item:a,index:r})=>{var c;return[n(w,{idx:r,file:a,"cell-width":e(g),"enable-close-icon":o.removable,onCloseIconClick:T=>v(r),"full-screen-preview-image-url":e(S)(a),"extra-tags":(c=a==null?void 0:a.tags)==null?void 0:c.map(e(f).tagConvert),"enable-right-click-menu":!1},null,8,["idx","file","cell-width","enable-close-icon","onCloseIconClick","full-screen-preview-image-url","extra-tags"])]}),_:1},8,["items","item-size","item-secondary-size","gridItems"])],544))}});const M=K(R,[["__scopeId","data-v-4c5d9776"]]);export{M as default};

View File

@ -1 +0,0 @@
import{F as w,s as y}from"./FileItem-72718f68.js";import{u as k,b as x}from"./index-0d856f16.js";import{d as F,p as b,bm as h,r as C,c5 as D,c6 as I,o as E,j as V,c,C as z,E as e,U as S,c3 as B,c7 as R,n as A}from"./index-f2db319b.js";import"./index-29e38a15.js";import"./shortcut-869fab50.js";import"./_isIterateeCall-dd643bcf.js";const K=F({__name:"gridView",props:{tabIdx:{},paneIdx:{},id:{},removable:{type:Boolean},allowDragAndDrop:{type:Boolean},files:{},paneKey:{}},setup(p){const o=p,m=b(),{stackViewEl:d}=k().toRefs(),{itemSize:i,gridItems:u,cellWidth:f}=x(),g=h(),s=C(o.files??[]),_=async t=>{const l=B(t);o.allowDragAndDrop&&l&&(s.value=R([...s.value,...l.nodes]))},v=t=>{s.value.splice(t,1)};return D(()=>{m.pageFuncExportMap.set(o.paneKey,{getFiles:()=>I(s.value),setFiles:t=>s.value=t})}),(t,l)=>(E(),V("div",{class:"container",ref_key:"stackViewEl",ref:d,onDrop:_},[c(e(y),{ref:"scroller",class:"file-list",items:s.value.slice(),"item-size":e(i).first,"key-field":"fullpath","item-secondary-size":e(i).second,gridItems:e(u)},{default:z(({item:a,index:r})=>{var n;return[c(w,{idx:r,file:a,"cell-width":e(f),"enable-close-icon":o.removable,onCloseIconClick:T=>v(r),"full-screen-preview-image-url":e(S)(a),"extra-tags":(n=a==null?void 0:a.tags)==null?void 0:n.map(e(g).tagConvert),"enable-right-click-menu":!1},null,8,["idx","file","cell-width","enable-close-icon","onCloseIconClick","full-screen-preview-image-url","extra-tags"])]}),_:1},8,["items","item-size","item-secondary-size","gridItems"])],544))}});const M=A(K,[["__scopeId","data-v-f35f4802"]]);export{M as default};

View File

@ -1 +0,0 @@
.container[data-v-f35f4802]{background:var(--zp-secondary-background);height:100%;overflow:auto;display:flex;flex-direction:column}.container .actions-panel[data-v-f35f4802]{padding:8px;background-color:var(--zp-primary-background)}.container .file-list[data-v-f35f4802]{flex:1;list-style:none;padding:8px;height:var(--pane-max-height);width:100%}.container .file-list .hint[data-v-f35f4802]{text-align:center;font-size:2em;padding:30vh 128px 0}

View File

@ -1 +1 @@
import{aL as F,r as g,bF as P,bG as S,ar as A,aj as R,bp as q,bH as L,bI as j}from"./index-f2db319b.js";import{u as z,b as H,f as O,c as Q,d as T,e as U,i as W,h as B}from"./index-0d856f16.js";let K=0;const V=()=>++K,X=(n,i,{dataUpdateStrategy:l="replace"}={})=>{const a=F([""]),c=g(!1),t=g(),o=g(!1);let f=g(-1);const v=new Set,w=e=>{var s;l==="replace"?t.value=e:l==="merge"&&(A((Array.isArray(t.value)||typeof t.value>"u")&&Array.isArray(e),"数据更新策略为合并时仅可用于值为数组的情况"),t.value=[...(s=t==null?void 0:t.value)!==null&&s!==void 0?s:[],...e])},d=e=>S(void 0,void 0,void 0,function*(){if(o.value||c.value&&typeof e>"u")return!1;o.value=!0;const s=V();f.value=s;try{let r;if(typeof e=="number"){if(r=a[e],typeof r!="string")return!1}else r=a[a.length-1];const m=yield n(r);if(v.has(s))return v.delete(s),!1;w(i(m));const u=m.cursor;if((e===a.length-1||typeof e!="number")&&(c.value=!u.has_next,u.has_next)){const y=u.next_cursor||u.next;A(typeof y=="string"),a.push(y)}}finally{f.value===s&&(o.value=!1)}return!0}),I=()=>{v.add(f.value),o.value=!1},x=(e=!1)=>S(void 0,void 0,void 0,function*(){const{refetch:s,force:r}=typeof e=="object"?e:{refetch:e};r&&I(),A(!o.value),a.splice(0,a.length,""),o.value=!1,t.value=void 0,c.value=!1,s&&(yield d())}),h=()=>({next:()=>S(void 0,void 0,void 0,function*(){if(o.value)throw new Error("不允许同时迭代");return{done:!(yield d()),value:t.value}})});return P({abort:I,load:c,next:d,res:t,loading:o,cursorStack:a,reset:x,[Symbol.asyncIterator]:h,iter:{[Symbol.asyncIterator]:h}})},ee=n=>F(X(n,i=>i.files,{dataUpdateStrategy:"merge"})),te=n=>{const i=F(new Set),l=R(()=>(n.res??[]).filter(p=>!i.has(p.fullpath))),a=q(),{stackViewEl:c,multiSelectedIdxs:t,stack:o,scroller:f,props:v}=z({images:l}).toRefs(),{itemSize:w,gridItems:d,cellWidth:I,onScroll:x}=H({fetchNext:()=>n.next()}),{showMenuIdx:h}=O(),{onFileDragStart:e,onFileDragEnd:s}=Q(),{showGenInfo:r,imageGenInfo:m,q:u,onContextMenuClick:y,onFileItemClick:C}=T({openNext:L}),{previewIdx:_,previewing:E,onPreviewVisibleChange:M,previewImgMove:D,canPreview:G}=U({loadNext:()=>n.next()}),J=async(p,b,N)=>{o.value=[{curr:"",files:l.value}],await y(p,b,N)};W("removeFiles",async({paths:p})=>{p.forEach(b=>i.add(b))});const k=()=>{j(l.value)};return{images:l,scroller:f,queue:a,iter:n,onContextMenuClickU:J,stackViewEl:c,previewIdx:_,previewing:E,onPreviewVisibleChange:M,previewImgMove:D,canPreview:G,itemSize:w,gridItems:d,showGenInfo:r,imageGenInfo:m,q:u,onContextMenuClick:y,onFileItemClick:C,showMenuIdx:h,multiSelectedIdxs:t,onFileDragStart:e,onFileDragEnd:s,cellWidth:I,onScroll:x,saveLoadedFileAsJson:k,saveAllFileAsJson:async()=>{for(;!n.load;)await n.next();k()},props:v,...B()}};export{ee as c,te as u}; import{aL as F,r as g,bF as P,bG as S,ar as A,aj as R,bp as q,bH as L,bI as j}from"./index-b01f57e3.js";import{u as z,b as H,f as O,c as Q,d as T,e as U,i as W,h as B}from"./index-59dc4640.js";let K=0;const V=()=>++K,X=(n,i,{dataUpdateStrategy:l="replace"}={})=>{const a=F([""]),c=g(!1),t=g(),o=g(!1);let f=g(-1);const v=new Set,w=e=>{var s;l==="replace"?t.value=e:l==="merge"&&(A((Array.isArray(t.value)||typeof t.value>"u")&&Array.isArray(e),"数据更新策略为合并时仅可用于值为数组的情况"),t.value=[...(s=t==null?void 0:t.value)!==null&&s!==void 0?s:[],...e])},d=e=>S(void 0,void 0,void 0,function*(){if(o.value||c.value&&typeof e>"u")return!1;o.value=!0;const s=V();f.value=s;try{let r;if(typeof e=="number"){if(r=a[e],typeof r!="string")return!1}else r=a[a.length-1];const m=yield n(r);if(v.has(s))return v.delete(s),!1;w(i(m));const u=m.cursor;if((e===a.length-1||typeof e!="number")&&(c.value=!u.has_next,u.has_next)){const y=u.next_cursor||u.next;A(typeof y=="string"),a.push(y)}}finally{f.value===s&&(o.value=!1)}return!0}),I=()=>{v.add(f.value),o.value=!1},x=(e=!1)=>S(void 0,void 0,void 0,function*(){const{refetch:s,force:r}=typeof e=="object"?e:{refetch:e};r&&I(),A(!o.value),a.splice(0,a.length,""),o.value=!1,t.value=void 0,c.value=!1,s&&(yield d())}),h=()=>({next:()=>S(void 0,void 0,void 0,function*(){if(o.value)throw new Error("不允许同时迭代");return{done:!(yield d()),value:t.value}})});return P({abort:I,load:c,next:d,res:t,loading:o,cursorStack:a,reset:x,[Symbol.asyncIterator]:h,iter:{[Symbol.asyncIterator]:h}})},ee=n=>F(X(n,i=>i.files,{dataUpdateStrategy:"merge"})),te=n=>{const i=F(new Set),l=R(()=>(n.res??[]).filter(p=>!i.has(p.fullpath))),a=q(),{stackViewEl:c,multiSelectedIdxs:t,stack:o,scroller:f,props:v}=z({images:l}).toRefs(),{itemSize:w,gridItems:d,cellWidth:I,onScroll:x}=H({fetchNext:()=>n.next()}),{showMenuIdx:h}=O(),{onFileDragStart:e,onFileDragEnd:s}=Q(),{showGenInfo:r,imageGenInfo:m,q:u,onContextMenuClick:y,onFileItemClick:C}=T({openNext:L}),{previewIdx:_,previewing:E,onPreviewVisibleChange:M,previewImgMove:D,canPreview:G}=U({loadNext:()=>n.next()}),J=async(p,b,N)=>{o.value=[{curr:"",files:l.value}],await y(p,b,N)};W("removeFiles",async({paths:p})=>{p.forEach(b=>i.add(b))});const k=()=>{j(l.value)};return{images:l,scroller:f,queue:a,iter:n,onContextMenuClickU:J,stackViewEl:c,previewIdx:_,previewing:E,onPreviewVisibleChange:M,previewImgMove:D,canPreview:G,itemSize:w,gridItems:d,showGenInfo:r,imageGenInfo:m,q:u,onContextMenuClick:y,onFileItemClick:C,showMenuIdx:h,multiSelectedIdxs:t,onFileDragStart:e,onFileDragEnd:s,cellWidth:I,onScroll:x,saveLoadedFileAsJson:k,saveAllFileAsJson:async()=>{for(;!n.load;)await n.next();k()},props:v,...B()}};export{ee as c,te as u};

File diff suppressed because one or more lines are too long

View File

@ -1 +0,0 @@
.ant-empty{margin:0 8px;font-size:14px;line-height:1.5715;text-align:center}.ant-empty-image{height:100px;margin-bottom:8px}.ant-empty-image img{height:100%}.ant-empty-image svg{height:100%;margin:auto}.ant-empty-footer{margin-top:16px}.ant-empty-normal{margin:32px 0;color:#00000040}.ant-empty-normal .ant-empty-image{height:40px}.ant-empty-small{margin:8px 0;color:#00000040}.ant-empty-small .ant-empty-image{height:35px}.ant-empty-img-default-ellipse{fill:#f5f5f5;fill-opacity:.8}.ant-empty-img-default-path-1{fill:#aeb8c2}.ant-empty-img-default-path-2{fill:url(#linearGradient-1)}.ant-empty-img-default-path-3{fill:#f5f5f7}.ant-empty-img-default-path-4,.ant-empty-img-default-path-5{fill:#dce0e6}.ant-empty-img-default-g{fill:#fff}.ant-empty-img-simple-ellipse{fill:#f5f5f5}.ant-empty-img-simple-g{stroke:#d9d9d9}.ant-empty-img-simple-path{fill:#fafafa}.ant-empty-rtl{direction:rtl}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -1 +1 @@
import{d as x,p as $,an as g,c8 as b,r as w,o as p,j as d,k as a,c as l,C as i,l as u,t as n,F as B,K as I,E as m,as as W,W as _,X as v,a2 as D,a3 as F,c9 as N,n as R}from"./index-f2db319b.js";const V={class:"container"},E={class:"actions"},K={class:"uni-desc"},L={class:"snapshot"},j=x({__name:"index",props:{tabIdx:{},paneIdx:{},id:{},paneKey:{}},setup(q){const h=$(),t=g(),f=e=>{h.tabList=W(e.tabs)},k=b(async e=>{await N(`workspace_snapshot_${e.id}`),t.snapshots=t.snapshots.filter(c=>c.id!==e.id),_.success(v("deleteSuccess"))}),o=w(""),C=async()=>{if(!o.value){_.error(v("nameRequired"));return}const e=t.createSnapshot(o.value);await t.addSnapshot(e),_.success(v("saveCompleted"))};return(e,c)=>{const y=D,r=F;return p(),d("div",V,[a("div",E,[l(y,{value:o.value,"onUpdate:value":c[0]||(c[0]=s=>o.value=s),placeholder:e.$t("name"),style:{"max-width":"300px"}},null,8,["value","placeholder"]),l(r,{type:"primary",onClick:C},{default:i(()=>[u(n(e.$t("saveWorkspaceSnapshot")),1)]),_:1})]),a("p",K,n(e.$t("WorkspaceSnapshotDesc")),1),a("ul",L,[(p(!0),d(B,null,I(m(t).snapshots,s=>(p(),d("li",{key:s.id},[a("div",null,[a("span",null,n(s.name),1)]),a("div",null,[l(r,{onClick:S=>f(s)},{default:i(()=>[u(n(e.$t("restore")),1)]),_:2},1032,["onClick"]),l(r,{onClick:S=>m(k)(s)},{default:i(()=>[u(n(e.$t("remove")),1)]),_:2},1032,["onClick"])])]))),128))])])}}});const G=R(j,[["__scopeId","data-v-2c44013c"]]);export{G as default}; import{d as x,p as $,an as g,c8 as b,r as w,o as p,j as d,k as a,c as l,C as i,l as u,t as n,F as B,K as I,E as m,as as W,W as _,X as v,a2 as D,a3 as F,c9 as N,n as R}from"./index-b01f57e3.js";const V={class:"container"},E={class:"actions"},K={class:"uni-desc"},L={class:"snapshot"},j=x({__name:"index",props:{tabIdx:{},paneIdx:{},id:{},paneKey:{}},setup(q){const h=$(),t=g(),f=e=>{h.tabList=W(e.tabs)},k=b(async e=>{await N(`workspace_snapshot_${e.id}`),t.snapshots=t.snapshots.filter(c=>c.id!==e.id),_.success(v("deleteSuccess"))}),o=w(""),C=async()=>{if(!o.value){_.error(v("nameRequired"));return}const e=t.createSnapshot(o.value);await t.addSnapshot(e),_.success(v("saveCompleted"))};return(e,c)=>{const y=D,r=F;return p(),d("div",V,[a("div",E,[l(y,{value:o.value,"onUpdate:value":c[0]||(c[0]=s=>o.value=s),placeholder:e.$t("name"),style:{"max-width":"300px"}},null,8,["value","placeholder"]),l(r,{type:"primary",onClick:C},{default:i(()=>[u(n(e.$t("saveWorkspaceSnapshot")),1)]),_:1})]),a("p",K,n(e.$t("WorkspaceSnapshotDesc")),1),a("ul",L,[(p(!0),d(B,null,I(m(t).snapshots,s=>(p(),d("li",{key:s.id},[a("div",null,[a("span",null,n(s.name),1)]),a("div",null,[l(r,{onClick:S=>f(s)},{default:i(()=>[u(n(e.$t("restore")),1)]),_:2},1032,["onClick"]),l(r,{onClick:S=>m(k)(s)},{default:i(()=>[u(n(e.$t("remove")),1)]),_:2},1032,["onClick"])])]))),128))])])}}});const G=R(j,[["__scopeId","data-v-2c44013c"]]);export{G as default};

1
vue/dist/assets/index-882e7f3d.css vendored Normal file

File diff suppressed because one or more lines are too long

320
vue/dist/assets/index-b01f57e3.js vendored Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1 @@
import{d as Y,p as ee,r as N,aC as te,aD as le,v as se,ca as ie,o as v,j as A,c as s,E as e,k as g,C as n,l as k,t as u,G as F,I as oe,H as ae,U as ne,V as R,m as $,B as re,W as w,X as ce,cb as de,a3 as ue,a1 as me,Z as fe,n as pe}from"./index-b01f57e3.js";import{F as ve,s as ge}from"./FileItem-56a257c5.js";import{u as ke,g as we,c as he,b as Ce,d as Se,e as Ie,o as z}from"./index-59dc4640.js";import{M as _e,L as be,R as ye,f as xe}from"./MultiSelectKeep-3861be7f.js";import"./index-482cb1cf.js";import"./shortcut-bfd52548.js";import"./_isIterateeCall-f668c4e1.js";const Ve={class:"refresh-button"},Me={class:"hint"},Te={key:0,class:"preview-switch"},Ne=Y({__name:"randomImage",props:{tabIdx:{},paneIdx:{},id:{},paneKey:{}},setup(Ae){const B=ee(),m=N(!1),l=N([]),r=l,h=te(`${le}randomImageSettingNotificationShown`,!1),P=()=>{h.value||(w.info({content:ce("randomImageSettingNotification"),duration:6,key:"randomImageSetting"}),h.value=!0)},f=async()=>{try{m.value=!0;const i=await de();i.length===0&&w.warn("No data, please generate index in image search page first"),l.value=i}finally{m.value=!1,I()}},C=()=>{if(l.value.length===0){w.warn("没有图片可以浏览");return}z(l.value,a.value||0)};se(()=>{f(),setTimeout(()=>{P()},2e3)});const{stackViewEl:D,multiSelectedIdxs:p,stack:E,scroller:G}=ke({images:l}).toRefs(),{onClearAllSelected:U,onSelectAll:K,onReverseSelect:L}=we();he();const{itemSize:S,gridItems:O,cellWidth:W,onScroll:I}=Ce(),{showGenInfo:c,imageGenInfo:_,q:H,onContextMenuClick:j,onFileItemClick:q}=Se({openNext:ie}),{previewIdx:a,previewing:b,onPreviewVisibleChange:Q,previewImgMove:y,canPreview:x}=Ie(),V=async(i,t,d)=>{E.value=[{curr:"",files:l.value}],await j(i,t,d)};return(i,t)=>{var M;const d=ue,X=me,Z=fe;return v(),A("div",{class:"container",ref_key:"stackViewEl",ref:D},[s(_e,{show:!!e(p).length||e(B).keepMultiSelect,onClearAllSelected:e(U),onSelectAll:e(K),onReverseSelect:e(L)},null,8,["show","onClearAllSelected","onSelectAll","onReverseSelect"]),g("div",Ve,[s(d,{onClick:f,onTouchstart:F(f,["prevent"]),type:"primary",loading:m.value,shape:"round"},{default:n(()=>[k(u(i.$t("shuffle")),1)]),_:1},8,["onTouchstart","loading"]),s(d,{onClick:C,onTouchstart:F(C,["prevent"]),type:"default",disabled:!((M=l.value)!=null&&M.length),shape:"round"},{default:n(()=>[k(u(i.$t("tiktokView")),1)]),_:1},8,["onTouchstart","disabled"])]),s(Z,{visible:e(c),"onUpdate:visible":t[1]||(t[1]=o=>ae(c)?c.value=o:null),width:"70vw","mask-closable":"",onOk:t[2]||(t[2]=o=>c.value=!1)},{cancelText:n(()=>[]),default:n(()=>[s(X,{active:"",loading:!e(H).isIdle},{default:n(()=>[g("div",{style:{width:"100%","word-break":"break-all","white-space":"pre-line","max-height":"70vh",overflow:"auto"},onDblclick:t[0]||(t[0]=o=>e(oe)(e(_)))},[g("div",Me,u(i.$t("doubleClickToCopy")),1),k(" "+u(e(_)),1)],32)]),_:1},8,["loading"])]),_:1},8,["visible"]),s(e(ge),{ref_key:"scroller",ref:G,class:"file-list",items:l.value.slice(),"item-size":e(S).first,"key-field":"fullpath","item-secondary-size":e(S).second,gridItems:e(O),onScroll:e(I)},{default:n(({item:o,index:T})=>[s(ve,{idx:T,file:o,"cell-width":e(W),"full-screen-preview-image-url":e(r)[e(a)]?e(ne)(e(r)[e(a)]):"",onContextMenuClick:V,onPreviewVisibleChange:e(Q),"is-selected-mutil-files":e(p).length>1,selected:e(p).includes(T),onFileItemClick:e(q),onTiktokView:(Fe,J)=>e(z)(l.value,J)},null,8,["idx","file","cell-width","full-screen-preview-image-url","onPreviewVisibleChange","is-selected-mutil-files","selected","onFileItemClick","onTiktokView"])]),_:1},8,["items","item-size","item-secondary-size","gridItems","onScroll"]),e(b)?(v(),A("div",Te,[s(e(be),{onClick:t[3]||(t[3]=o=>e(y)("prev")),class:R({disable:!e(x)("prev")})},null,8,["class"]),s(e(ye),{onClick:t[4]||(t[4]=o=>e(y)("next")),class:R({disable:!e(x)("next")})},null,8,["class"])])):$("",!0),e(b)&&e(r)&&e(r)[e(a)]?(v(),re(xe,{key:1,file:e(r)[e(a)],idx:e(a),onContextMenuClick:V},null,8,["file","idx"])):$("",!0)],512)}}});const Ge=pe(Ne,[["__scopeId","data-v-fc8b4c69"]]);export{Ge as default};

View File

@ -1 +0,0 @@
import{d as Y,p as ee,r as F,aC as te,aD as le,v as se,ca as ie,o as v,j as N,c as s,E as e,k as g,C as n,l as k,t as u,G as R,I as oe,H as ae,U as ne,V as A,m as $,B as re,W as w,X as ce,cb as de,a3 as ue,a1 as me,Z as fe,n as pe}from"./index-f2db319b.js";import{F as ve,s as ge}from"./FileItem-72718f68.js";import{u as ke,g as we,c as he,b as Ce,d as Se,e as _e,o as z}from"./index-0d856f16.js";import{M as Ie,L as ye,R as be,f as xe}from"./MultiSelectKeep-a11efe88.js";import"./index-29e38a15.js";import"./shortcut-869fab50.js";import"./_isIterateeCall-dd643bcf.js";/* empty css */const Ve={class:"refresh-button"},Me={class:"hint"},Te={key:0,class:"preview-switch"},Fe=Y({__name:"randomImage",props:{tabIdx:{},paneIdx:{},id:{},paneKey:{}},setup(Ne){const B=ee(),m=F(!1),l=F([]),r=l,h=te(`${le}randomImageSettingNotificationShown`,!1),P=()=>{h.value||(w.info({content:ce("randomImageSettingNotification"),duration:6,key:"randomImageSetting"}),h.value=!0)},f=async()=>{try{m.value=!0;const i=await de();i.length===0&&w.warn("No data, please generate index in image search page first"),l.value=i}finally{m.value=!1,_()}},C=()=>{if(l.value.length===0){w.warn("没有图片可以浏览");return}z(l.value,a.value||0)};se(()=>{f(),setTimeout(()=>{P()},2e3)});const{stackViewEl:D,multiSelectedIdxs:p,stack:E,scroller:G}=ke({images:l}).toRefs(),{onClearAllSelected:U,onSelectAll:K,onReverseSelect:L}=we();he();const{itemSize:S,gridItems:O,cellWidth:W,onScroll:_}=Ce(),{showGenInfo:c,imageGenInfo:I,q:H,onContextMenuClick:j,onFileItemClick:q}=Se({openNext:ie}),{previewIdx:a,previewing:y,onPreviewVisibleChange:Q,previewImgMove:b,canPreview:x}=_e(),V=async(i,t,d)=>{E.value=[{curr:"",files:l.value}],await j(i,t,d)};return(i,t)=>{var M;const d=ue,X=me,Z=fe;return v(),N("div",{class:"container",ref_key:"stackViewEl",ref:D},[s(Ie,{show:!!e(p).length||e(B).keepMultiSelect,onClearAllSelected:e(U),onSelectAll:e(K),onReverseSelect:e(L)},null,8,["show","onClearAllSelected","onSelectAll","onReverseSelect"]),g("div",Ve,[s(d,{onClick:f,onTouchstart:R(f,["prevent"]),type:"primary",loading:m.value,shape:"round"},{default:n(()=>[k(u(i.$t("shuffle")),1)]),_:1},8,["onTouchstart","loading"]),s(d,{onClick:C,onTouchstart:R(C,["prevent"]),type:"default",disabled:!((M=l.value)!=null&&M.length),shape:"round"},{default:n(()=>[k(u(i.$t("tiktokView")),1)]),_:1},8,["onTouchstart","disabled"])]),s(Z,{visible:e(c),"onUpdate:visible":t[1]||(t[1]=o=>ae(c)?c.value=o:null),width:"70vw","mask-closable":"",onOk:t[2]||(t[2]=o=>c.value=!1)},{cancelText:n(()=>[]),default:n(()=>[s(X,{active:"",loading:!e(H).isIdle},{default:n(()=>[g("div",{style:{width:"100%","word-break":"break-all","white-space":"pre-line","max-height":"70vh",overflow:"auto"},onDblclick:t[0]||(t[0]=o=>e(oe)(e(I)))},[g("div",Me,u(i.$t("doubleClickToCopy")),1),k(" "+u(e(I)),1)],32)]),_:1},8,["loading"])]),_:1},8,["visible"]),s(e(ge),{ref_key:"scroller",ref:G,class:"file-list",items:l.value.slice(),"item-size":e(S).first,"key-field":"fullpath","item-secondary-size":e(S).second,gridItems:e(O),onScroll:e(_)},{default:n(({item:o,index:T})=>[s(ve,{idx:T,file:o,"cell-width":e(W),"full-screen-preview-image-url":e(r)[e(a)]?e(ne)(e(r)[e(a)]):"",onContextMenuClick:V,onPreviewVisibleChange:e(Q),"is-selected-mutil-files":e(p).length>1,selected:e(p).includes(T),onFileItemClick:e(q),onTiktokView:(Re,J)=>e(z)(l.value,J)},null,8,["idx","file","cell-width","full-screen-preview-image-url","onPreviewVisibleChange","is-selected-mutil-files","selected","onFileItemClick","onTiktokView"])]),_:1},8,["items","item-size","item-secondary-size","gridItems","onScroll"]),e(y)?(v(),N("div",Te,[s(e(ye),{onClick:t[3]||(t[3]=o=>e(b)("prev")),class:A({disable:!e(x)("prev")})},null,8,["class"]),s(e(be),{onClick:t[4]||(t[4]=o=>e(b)("next")),class:A({disable:!e(x)("next")})},null,8,["class"])])):$("",!0),e(y)&&e(r)&&e(r)[e(a)]?(v(),re(xe,{key:1,file:e(r)[e(a)],idx:e(a),onContextMenuClick:V},null,8,["file","idx"])):$("",!0)],512)}}});const Ue=pe(Fe,[["__scopeId","data-v-49082269"]]);export{Ue as default};

View File

@ -1 +1 @@
.container[data-v-49082269]{background:var(--zp-secondary-background);height:100%;overflow:auto;display:flex;flex-direction:column}.container .actions-panel[data-v-49082269]{padding:8px;background-color:var(--zp-primary-background)}.container .refresh-button[data-v-49082269]{position:absolute;top:90%;left:50%;transform:translate(-50%,-50%);z-index:99;background:white;border-radius:9999px;box-shadow:0 0 20px var(--zp-secondary);padding:4px;display:flex;align-items:center;gap:8px}.container .file-list[data-v-49082269]{flex:1;list-style:none;padding:8px;height:var(--pane-max-height);width:100%}.container .file-list .hint[data-v-49082269]{text-align:center;font-size:2em;padding:30vh 128px 0} .container[data-v-fc8b4c69]{background:var(--zp-secondary-background);height:100%;overflow:auto;display:flex;flex-direction:column}.container .actions-panel[data-v-fc8b4c69]{padding:8px;background-color:var(--zp-primary-background)}.container .refresh-button[data-v-fc8b4c69]{position:absolute;top:90%;left:50%;transform:translate(-50%,-50%);z-index:99;background:white;border-radius:9999px;box-shadow:0 0 20px var(--zp-secondary);padding:4px;display:flex;align-items:center;gap:8px}.container .file-list[data-v-fc8b4c69]{flex:1;list-style:none;padding:8px;height:var(--pane-max-height);width:100%}.container .file-list .hint[data-v-fc8b4c69]{text-align:center;font-size:2em;padding:30vh 128px 0}

File diff suppressed because one or more lines are too long

View File

@ -1 +1 @@
.ant-breadcrumb{box-sizing:border-box;margin:0;padding:0;color:#000000d9;font-variant:tabular-nums;line-height:1.5715;list-style:none;font-feature-settings:"tnum";color:#00000073;font-size:14px}.ant-breadcrumb .anticon{font-size:14px}.ant-breadcrumb a{color:#00000073;transition:color .3s}.ant-breadcrumb a:hover{color:#de632f}.ant-breadcrumb>span:last-child{color:#000000d9}.ant-breadcrumb>span:last-child a{color:#000000d9}.ant-breadcrumb>span:last-child .ant-breadcrumb-separator{display:none}.ant-breadcrumb-separator{margin:0 8px;color:#00000073}.ant-breadcrumb-link>.anticon+span,.ant-breadcrumb-link>.anticon+a{margin-left:4px}.ant-breadcrumb-overlay-link>.anticon{margin-left:4px}.ant-breadcrumb-rtl{direction:rtl}.ant-breadcrumb-rtl:before{display:table;content:""}.ant-breadcrumb-rtl:after{display:table;clear:both;content:""}.ant-breadcrumb-rtl>span{float:right}.ant-breadcrumb-rtl .ant-breadcrumb-link>.anticon+span,.ant-breadcrumb-rtl .ant-breadcrumb-link>.anticon+a{margin-right:4px;margin-left:0}.ant-breadcrumb-rtl .ant-breadcrumb-overlay-link>.anticon{margin-right:4px;margin-left:0}.nprogress{pointer-events:none}.nprogress .bar{background:#29d;position:fixed;z-index:1031;top:0;left:0;width:100%;height:2px}.nprogress .peg{display:block;position:absolute;right:0px;width:100px;height:100%;box-shadow:0 0 10px #29d,0 0 5px #29d;opacity:1;-webkit-transform:rotate(3deg) translate(0px,-4px);-ms-transform:rotate(3deg) translate(0px,-4px);transform:rotate(3deg) translateY(-4px)}.nprogress .spinner{display:block;position:fixed;z-index:1031;top:15px;right:15px}.nprogress .spinner-icon{width:18px;height:18px;box-sizing:border-box;border:solid 2px transparent;border-top-color:#29d;border-left-color:#29d;border-radius:50%;-webkit-animation:nprogress-spinner .4s linear infinite;animation:nprogress-spinner .4s linear infinite}.nprogress-custom-parent{overflow:hidden;position:relative}.nprogress-custom-parent .nprogress .spinner,.nprogress-custom-parent .nprogress .bar{position:absolute}@-webkit-keyframes nprogress-spinner{0%{-webkit-transform:rotate(0deg)}to{-webkit-transform:rotate(360deg)}}@keyframes nprogress-spinner{0%{transform:rotate(0)}to{transform:rotate(360deg)}}.base-info[data-v-afd25667]{position:absolute;padding:4px;font-size:.8em;background:var(--zp-primary-background);color:var(--zp-primary);left:0;bottom:0;border-top-right-radius:4px}.location-act[data-v-9516e28d]{margin-left:8px}.location-act .copy[data-v-9516e28d]{margin-right:4px}@media (max-width: 768px){.location-act[data-v-9516e28d]{display:flex;flex-direction:column}.location-act>*[data-v-9516e28d],.location-act .copy[data-v-9516e28d]{margin:2px}}.breadcrumb[data-v-9516e28d]{display:flex;align-items:center}.breadcrumb>*[data-v-9516e28d]{margin-right:4px}@media (max-width: 768px){.breadcrumb[data-v-9516e28d]{width:100%}.breadcrumb .ant-breadcrumb>*[data-v-9516e28d]{display:inline-block}}.container[data-v-9516e28d]{background:var(--zp-secondary-background);height:var(--pane-max-height)}.location-bar[data-v-9516e28d]{padding:4px 16px;background:var(--zp-primary-background);border-bottom:1px solid var(--zp-border);display:flex;align-items:center;justify-content:space-between}@media (max-width: 768px){.location-bar[data-v-9516e28d]{flex-direction:column}.location-bar[data-v-9516e28d] ::-webkit-scrollbar{height:2px;background-color:var(--zp-secondary-variant-background)}.location-bar .actions[data-v-9516e28d]{padding:4px 0;width:100%;overflow:auto;display:flex;align-items:center}.location-bar .actions>*[data-v-9516e28d]{flex-shrink:0}}.location-bar .actions[data-v-9516e28d]{display:flex;align-items:center;flex-shrink:0}.location-bar a.opt[data-v-9516e28d]{margin-left:8px}.view[data-v-9516e28d]{padding:8px;height:calc(100vh - 48px)}.view .file-list[data-v-9516e28d]{list-style:none;padding:8px;height:100%;overflow:auto}.hint[data-v-9516e28d]{padding:4px;border:4px;background:var(--zp-secondary-background);border:1px solid var(--zp-border)} .ant-breadcrumb{box-sizing:border-box;margin:0;padding:0;color:#000000d9;font-variant:tabular-nums;line-height:1.5715;list-style:none;font-feature-settings:"tnum";color:#00000073;font-size:14px}.ant-breadcrumb .anticon{font-size:14px}.ant-breadcrumb a{color:#00000073;transition:color .3s}.ant-breadcrumb a:hover{color:#de632f}.ant-breadcrumb>span:last-child{color:#000000d9}.ant-breadcrumb>span:last-child a{color:#000000d9}.ant-breadcrumb>span:last-child .ant-breadcrumb-separator{display:none}.ant-breadcrumb-separator{margin:0 8px;color:#00000073}.ant-breadcrumb-link>.anticon+span,.ant-breadcrumb-link>.anticon+a{margin-left:4px}.ant-breadcrumb-overlay-link>.anticon{margin-left:4px}.ant-breadcrumb-rtl{direction:rtl}.ant-breadcrumb-rtl:before{display:table;content:""}.ant-breadcrumb-rtl:after{display:table;clear:both;content:""}.ant-breadcrumb-rtl>span{float:right}.ant-breadcrumb-rtl .ant-breadcrumb-link>.anticon+span,.ant-breadcrumb-rtl .ant-breadcrumb-link>.anticon+a{margin-right:4px;margin-left:0}.ant-breadcrumb-rtl .ant-breadcrumb-overlay-link>.anticon{margin-right:4px;margin-left:0}.nprogress{pointer-events:none}.nprogress .bar{background:#29d;position:fixed;z-index:1031;top:0;left:0;width:100%;height:2px}.nprogress .peg{display:block;position:absolute;right:0px;width:100px;height:100%;box-shadow:0 0 10px #29d,0 0 5px #29d;opacity:1;-webkit-transform:rotate(3deg) translate(0px,-4px);-ms-transform:rotate(3deg) translate(0px,-4px);transform:rotate(3deg) translateY(-4px)}.nprogress .spinner{display:block;position:fixed;z-index:1031;top:15px;right:15px}.nprogress .spinner-icon{width:18px;height:18px;box-sizing:border-box;border:solid 2px transparent;border-top-color:#29d;border-left-color:#29d;border-radius:50%;-webkit-animation:nprogress-spinner .4s linear infinite;animation:nprogress-spinner .4s linear infinite}.nprogress-custom-parent{overflow:hidden;position:relative}.nprogress-custom-parent .nprogress .spinner,.nprogress-custom-parent .nprogress .bar{position:absolute}@-webkit-keyframes nprogress-spinner{0%{-webkit-transform:rotate(0deg)}to{-webkit-transform:rotate(360deg)}}@keyframes nprogress-spinner{0%{transform:rotate(0)}to{transform:rotate(360deg)}}.base-info[data-v-afd25667]{position:absolute;padding:4px;font-size:.8em;background:var(--zp-primary-background);color:var(--zp-primary);left:0;bottom:0;border-top-right-radius:4px}.location-act[data-v-29b8fad9]{margin-left:8px}.location-act .copy[data-v-29b8fad9]{margin-right:4px}@media (max-width: 768px){.location-act[data-v-29b8fad9]{display:flex;flex-direction:column}.location-act>*[data-v-29b8fad9],.location-act .copy[data-v-29b8fad9]{margin:2px}}.breadcrumb[data-v-29b8fad9]{display:flex;align-items:center}.breadcrumb>*[data-v-29b8fad9]{margin-right:4px}@media (max-width: 768px){.breadcrumb[data-v-29b8fad9]{width:100%}.breadcrumb .ant-breadcrumb>*[data-v-29b8fad9]{display:inline-block}}.container[data-v-29b8fad9]{background:var(--zp-secondary-background);height:var(--pane-max-height)}.location-bar[data-v-29b8fad9]{padding:4px 16px;background:var(--zp-primary-background);border-bottom:1px solid var(--zp-border);display:flex;align-items:center;justify-content:space-between}@media (max-width: 768px){.location-bar[data-v-29b8fad9]{flex-direction:column}.location-bar[data-v-29b8fad9] ::-webkit-scrollbar{height:2px;background-color:var(--zp-secondary-variant-background)}.location-bar .actions[data-v-29b8fad9]{padding:4px 0;width:100%;overflow:auto;display:flex;align-items:center}.location-bar .actions>*[data-v-29b8fad9]{flex-shrink:0}}.location-bar .actions[data-v-29b8fad9]{display:flex;align-items:center;flex-shrink:0}.location-bar a.opt[data-v-29b8fad9]{margin-left:8px}.view[data-v-29b8fad9]{padding:8px;height:calc(100vh - 48px)}.view .file-list[data-v-29b8fad9]{list-style:none;padding:8px;height:100%;overflow:auto}.hint[data-v-29b8fad9]{padding:4px;border:4px;background:var(--zp-secondary-background);border:1px solid var(--zp-border)}

1
vue/dist/assets/stackView-857698bf.js vendored Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

4
vue/dist/index.html vendored
View File

@ -7,8 +7,8 @@
<link rel="icon" href="/favicon.ico" /> <link rel="icon" href="/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Infinite Image Browsing</title> <title>Infinite Image Browsing</title>
<script type="module" crossorigin src="/infinite_image_browsing/fe-static/assets/index-f2db319b.js"></script> <script type="module" crossorigin src="/infinite_image_browsing/fe-static/assets/index-b01f57e3.js"></script>
<link rel="stylesheet" href="/infinite_image_browsing/fe-static/assets/index-dd273d5b.css"> <link rel="stylesheet" href="/infinite_image_browsing/fe-static/assets/index-882e7f3d.css">
</head> </head>
<body> <body>

View File

@ -8,6 +8,7 @@ import SplitViewTab from '@/page/SplitViewTab/SplitViewTab.vue'
import OrganizeJobsPanel from '@/components/OrganizeJobsPanel.vue' import OrganizeJobsPanel from '@/components/OrganizeJobsPanel.vue'
import OrganizePreview from '@/page/OrganizeFiles/OrganizePreview.vue' import OrganizePreview from '@/page/OrganizeFiles/OrganizePreview.vue'
import SmartOrganizeConfigModal from '@/components/SmartOrganizeConfigModal.vue' import SmartOrganizeConfigModal from '@/components/SmartOrganizeConfigModal.vue'
import PromptEditorModal from '@/components/PromptEditorModal.vue'
import { Dict, createReactiveQueue, globalEvents, useGlobalEventListen } from './util' import { Dict, createReactiveQueue, globalEvents, useGlobalEventListen } from './util'
import { resolveQueryActions } from './queryActions' import { resolveQueryActions } from './queryActions'
import { refreshTauriConf, tauriConf } from './util/tauriAppConf' import { refreshTauriConf, tauriConf } from './util/tauriAppConf'
@ -252,6 +253,9 @@ onMounted(async () => {
<!-- Smart Organize Config Modal --> <!-- Smart Organize Config Modal -->
<SmartOrganizeConfigModal /> <SmartOrganizeConfigModal />
<!-- Prompt Editor Modal (自包含组件通过全局事件控制) -->
<PromptEditorModal />
<!-- Fullscreen Loading for Moving Files --> <!-- Fullscreen Loading for Moving Files -->
<div v-if="isMovingFiles" class="moving-files-overlay"> <div v-if="isMovingFiles" class="moving-files-overlay">
<div class="moving-files-content"> <div class="moving-files-content">

View File

@ -186,6 +186,11 @@ export const getImageGenerationInfo = async (path: string) => {
.data as string .data as string
} }
export const updateExif = async (path: string, exif: string) => {
const resp = await axiosInst.value.post('/update_exif', { path, exif })
return resp.data as { success: boolean, message: string }
}
export const getImageExif = async (path: string) => { export const getImageExif = async (path: string) => {
return (await axiosInst.value.get(`/image_exif?path=${encodeURIComponent(path)}`)) return (await axiosInst.value.get(`/image_exif?path=${encodeURIComponent(path)}`))
.data as Record<string, string> .data as Record<string, string>

View File

@ -0,0 +1,254 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { toImageUrl } from '@/util/file'
import type { FileNodeInfo } from '@/api/files'
import { DragOutlined } from '@/icon'
interface Props {
file: FileNodeInfo
title?: string
size?: string
}
const props = withDefaults(defineProps<Props>(), {
title: 'Drag to transfer image'
})
const imageUrl = computed(() => {
if (!props.file) return ''
return toImageUrl(props.file)
})
const showImage = ref(false)
function toggleImage() {
showImage.value = !showImage.value
}
const closeImage = () => {
showImage.value=false
}
</script>
<template>
<div class="draggable-image-wrapper"
@mouseleave="closeImage">
<div class="trigger-container" :title="title" @click="toggleImage" >
<slot class="trigger-slot">
<div class="default-trigger">
<DragOutlined class="trigger-icon" />
<span class="trigger-text">{{ $t('dragImageToTransfer') }}</span>
</div>
</slot>
<img
v-if="showImage"
:src="imageUrl"
:alt="file.name"
draggable="true"
class="hover-image"
:style="{ width: size||'256px', height: size || '256px' }"
@mouseleave="closeImage"
@error="(e) => (e.target as HTMLImageElement).style.display = 'none'"
@click.stop
/>
</div>
</div>
</template>
<style scoped lang="scss">
.draggable-image-wrapper {
padding: 4px 0;
margin-top: 8px;
display: flex;
cursor: pointer;
justify-content: center;
align-items: center;
.trigger-container {
display: inline-flex;
align-items: center;
position: relative;
user-select: none;
:deep(.trigger-slot) {
display: inline-flex;
align-items: center;
cursor: pointer;
transition: all 0.3s ease;
}
&:active :deep(.trigger-slot) {
transform: scale(0.95);
}
:deep(.custom-drag-trigger) {
display: inline-flex;
align-items: center;
gap: 10px;
padding: 6px 24px;
border-radius: 8px;
background: var(--zp-primary-background);
color: var(--zp-primary);
transition: all 0.3s ease;
position: relative;
overflow: hidden;
//
&::before {
content: '';
position: absolute;
inset: 0;
border-radius: 8px;
padding: 2px;
background: linear-gradient(
135deg,
#ff6b6b 0%,
#feca57 16.67%,
#48dbfb 33.33%,
#ff9ff3 50%,
#54a0ff 66.67%,
#5f27cd 83.33%,
#ff6b6b 100%
);
-webkit-mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
-webkit-mask-composite: xor;
mask-composite: exclude;
opacity: 0.9;
}
&:hover {
background: linear-gradient(
135deg,
rgba(255, 107, 107, 0.1),
rgba(72, 219, 251, 0.1)
);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15);
transform: translateY(-1px);
}
&:active {
transform: translateY(0);
}
.trigger-icon {
font-size: 18px;
transition: transform 0.3s ease;
position: relative;
z-index: 1;
}
.trigger-text {
font-size: 12px;
white-space: nowrap;
position: relative;
z-index: 1;
letter-spacing: 0.5px;
}
&:hover .trigger-icon {
transform: scale(1.1) rotate(5deg);
}
}
.default-trigger {
display: inline-flex;
align-items: center;
gap: 10px;
padding: 6px 24px;
border-radius: 8px;
background: var(--zp-primary-background);
color: var(--zp-primary);
transition: all 0.3s ease;
position: relative;
overflow: hidden;
//
&::before {
content: '';
position: absolute;
inset: 0;
border-radius: 8px;
padding: 2px;
background: linear-gradient(
135deg,
#ff6b6b 0%,
#feca57 16.67%,
#48dbfb 33.33%,
#ff9ff3 50%,
#54a0ff 66.67%,
#5f27cd 83.33%,
#ff6b6b 100%
);
-webkit-mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
-webkit-mask-composite: xor;
mask-composite: exclude;
opacity: 0.9;
}
&:hover {
background: linear-gradient(
135deg,
rgba(255, 107, 107, 0.1),
rgba(72, 219, 251, 0.1)
);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15);
transform: translateY(-1px);
}
&:active {
transform: translateY(0);
}
.trigger-icon {
font-size: 18px;
transition: transform 0.3s ease;
position: relative;
z-index: 1;
}
.trigger-text {
font-size: 14px;
font-weight: 500;
white-space: nowrap;
position: relative;
z-index: 1;
letter-spacing: 0.5px;
}
&:hover .trigger-icon {
transform: scale(1.1) rotate(5deg);
}
}
.hover-image {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%) scale(1);
object-fit: contain;
border-radius: 8px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2);
background: var(--zp-primary-background);
opacity: 1;
pointer-events: auto;
animation: scaleIn 0.3s cubic-bezier(0.4, 0, 0.2, 1);
z-index: 10000;
cursor: grab;
@keyframes scaleIn {
from {
transform: translate(-50%, -50%) scale(0.8);
opacity: 0;
}
to {
transform: translate(-50%, -50%) scale(1);
opacity: 1;
}
}
&:active {
cursor: grabbing;
}
}
}
}
</style>

View File

@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { FileOutlined, FolderOpenOutlined, EllipsisOutlined, HeartOutlined, HeartFilled } from '@/icon' import { FileOutlined, FolderOpenOutlined, EllipsisOutlined, HeartOutlined, HeartFilled, DragOutlined } from '@/icon'
import { useGlobalStore } from '@/store/useGlobalStore' import { useGlobalStore } from '@/store/useGlobalStore'
import { fallbackImage, ok } from 'vue3-ts-util' import { fallbackImage, ok } from 'vue3-ts-util'
import type { FileNodeInfo } from '@/api/files' import type { FileNodeInfo } from '@/api/files'
@ -9,6 +9,7 @@ import type { MenuInfo } from 'ant-design-vue/lib/menu/src/interface'
import { computed, ref, nextTick, watch } from 'vue' import { computed, ref, nextTick, watch } from 'vue'
import ContextMenu from './ContextMenu.vue' import ContextMenu from './ContextMenu.vue'
import ChangeIndicator from './ChangeIndicator.vue' import ChangeIndicator from './ChangeIndicator.vue'
import DraggableImage from './DraggableImage.vue'
import { useTagStore } from '@/store/useTagStore' import { useTagStore } from '@/store/useTagStore'
import { CloseCircleOutlined, StarFilled, StarOutlined } from '@/icon' import { CloseCircleOutlined, StarFilled, StarOutlined } from '@/icon'
import { Tag } from '@/api/db' import { Tag } from '@/api/db'
@ -282,6 +283,11 @@ const handleAudioClick = () => {
</a-menu> </a-menu>
</template> </template>
</a-dropdown> </a-dropdown>
<DraggableImage size="192px" v-if="file.type === 'file' && isImageFile(file.fullpath)" :file="file">
<div class="float-btn-wrap">
<DragOutlined />
</div>
</DraggableImage>
</div> </div>
<!-- :key="fullScreenPreviewImageUrl ? undefined : file.fullpath" <!-- :key="fullScreenPreviewImageUrl ? undefined : file.fullpath"
这么复杂是因为再全屏查看时可能因为直接删除导致fullpath变化然后整个预览直接退出--> 这么复杂是因为再全屏查看时可能因为直接删除导致fullpath变化然后整个预览直接退出-->

View File

@ -0,0 +1,283 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { deepComputedEffect } from 'vue3-ts-util'
import { message } from 'ant-design-vue'
import { t } from '@/i18n'
interface KVPair {
key: string
value: any
}
interface KVPairLocal {
key: string
value: string
}
interface Props {
modelValue: KVPair
allKeys?: string[]
}
interface Emits {
(e: 'update:modelValue', value: KVPair): void
(e: 'remove'): void
}
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
// Key
const keyError = ref('')
// key
const validateKey = (key: string): boolean => {
if (!key.trim()) {
keyError.value = t('keyRequired')
return false
}
if (props.allKeys && props.allKeys.includes(key.trim())) {
keyError.value = t('keyMustBeUnique')
return false
}
keyError.value = ''
return true
}
const modeInitial= () => {
const value = localKv.value.value
if (!value) return 'str' //
try {
const parsed = JSON.parse(value)
// JSON
return typeof parsed !== 'string' ? 'json' : 'str'
} catch {
//
return 'str'
}
}
// JSON
const mode = ref('str' as 'str' | 'json')
//
const localKv = deepComputedEffect<KVPairLocal>({
get () {
const val = props.modelValue.value
const isString = typeof val === 'string' && mode.value !== 'json'
return {
...props.modelValue,
value: isString
? val
: JSON.stringify(props.modelValue.value, null, 2)
}
},
set (newValue: KVPairLocal) {
// key emit
if (!validateKey(newValue.key)) {
return
}
const value = newValue.value
const allVal = {
...newValue,
value: mode.value === 'json' ? JSON.parse(value) : value
}
emit('update:modelValue', allVal)
}
})
mode.value = modeInitial()
// JSON
const isValidJson = ref(true)
// JSON computed
const jsonInput = ref('{}')
watch([() => localKv.value.value, () => mode.value], () => {
if (mode.value === 'json') {
try {
const parsed = JSON.parse(localKv.value.value)
jsonInput.value = JSON.stringify(parsed, null, 2)
} catch (e) {
console.warn('Invalid JSON, resetting jsonInput to empty object', localKv.value.value)
jsonInput.value = '{}'
}
}
},{ immediate: true })
const onJsonUpdate = () => {
const newValue = jsonInput.value
if (checkIsValidJson(newValue)) {
localKv.value.value = newValue
isValidJson.value = true
} else {
isValidJson.value = false
}
}
// computed
const stringInput = computed({
get () {
return localKv.value.value
},
set (newValue: string) {
localKv.value.value = newValue
}
})
const checkIsValidJson = (str: string) => {
try {
JSON.parse(str)
return true
} catch {
return false
}
}
//
const handleModeChange = (newMode: 'str' | 'json') => {
const currentValue = localKv.value.value
//
if (!currentValue) {
mode.value = newMode
return
}
//
if (newMode === 'json' && currentValue) {
message.warning(t('clearBeforeSwitchToJson'))
return
}
if (newMode === 'str' && jsonInput.value.trim()) {
message.warning(t('clearBeforeSwitchToString'))
console.warn('Switching to string mode requires empty value', {
val: jsonInput.value
})
return
}
localKv.value.value = '' //
mode.value = newMode
}
//
const handleRemove = () => {
emit('remove')
}
//
const validate = (): boolean => {
const keyValid = validateKey(localKv.value.key)
const jsonValid = mode.value === 'json' ? isValidJson.value : true
return keyValid && jsonValid
}
//
defineExpose({
validate,
keyError,
isValidJson
})
</script>
<template>
<div class="kv-pair-editor">
<div class="kv-key-wrapper">
<a-input v-model:value="localKv.key" :placeholder="t('keyPlaceholder')" class="kv-input kv-key" />
<div v-if="keyError" class="key-error-hint">{{ keyError }}</div>
</div>
<div v-if="mode === 'json'" class="kv-value-wrapper">
<ATextarea v-model:value="jsonInput" @blur="onJsonUpdate" :placeholder="t('jsonValuePlaceholder')"
:auto-size="{ maxRows: 8 }" class="kv-input kv-value" />
<div v-if="!isValidJson" class="json-error-hint">{{ t('jsonFormatError') }}</div>
</div>
<ATextarea v-else :auto-size="{ maxRows: 8 }" v-model:value="stringInput" :placeholder="t('stringValuePlaceholder')" class="kv-input kv-value" />
<a-select :value="mode" size="small" class="mode-selector"
:getPopupContainer="(trigger: any) => trigger.parentNode as HTMLDivElement" @update:value="handleModeChange"
style="width: 80px">
<a-select-option value="str">{{ t('stringMode') }}</a-select-option>
<a-select-option value="json">{{ t('jsonMode') }}</a-select-option>
</a-select>
<a-button size="small" danger @click="handleRemove" class="delete-btn">
{{ t('delete') }}
</a-button>
</div>
</template>
<style scoped lang="scss">
.kv-pair-editor {
display: flex;
gap: 8px;
align-items: center;
}
.kv-input {
font-size: 12px;
}
.kv-key-wrapper {
min-width: 50px;
max-width: 120px;
display: flex;
flex-direction: column;
gap: 4px;
}
.kv-key {
width: 100%;
}
.kv-value {
min-width: 200px;
}
.kv-value-wrapper {
min-width: 200px;
display: flex;
flex-direction: column;
gap: 4px;
flex: 1;
}
.key-error-hint {
font-size: 11px;
color: #ff4d4f;
line-height: 1.2;
}
.json-error-hint {
font-size: 11px;
color: #ff4d4f;
line-height: 1.2;
}
.mode-selector {
font-size: 12px;
flex-shrink: 0;
}
.delete-btn {
font-size: 12px;
flex-shrink: 0;
}
</style>

View File

@ -0,0 +1,309 @@
<script setup lang="ts">
import { ref } from 'vue'
import { message } from 'ant-design-vue'
import { t } from '@/i18n'
import { updateExif, getImageGenerationInfo } from '@/api'
import { parse } from '@/util/stable-diffusion-image-metadata'
import { globalEvents, useGlobalEventListen } from '@/util'
import type { FileNodeInfo } from '@/api/files'
import KvPairEditor from './KvPairEditor.vue'
interface KVPair {
key: string
value: any
}
//
const show = ref(false)
const file = ref<FileNodeInfo | null>(null)
const currentPrompt = ref<string>('')
//
useGlobalEventListen('openPromptEditor', async (data: { file: FileNodeInfo }) => {
file.value = data.file
console.log('Received openPromptEditor event for file:', data.file)
//
try {
const latestPrompt = await getImageGenerationInfo(data.file.fullpath)
currentPrompt.value = latestPrompt
} catch (error) {
console.error('Failed to fetch latest prompt:', error)
currentPrompt.value = ''
}
initializeData()
show.value = true
})
// 使
const defaultMetadata = 'Steps: 20'
//
const computedFields = ['hashes', 'resources']
//
const parsePrompt = (promptStr: string) => {
const parsed = parse(promptStr)
// extraJsonMetaInfo
const otherInfo = Object.entries(parsed)
.filter(([key]) =>
key !== 'prompt' &&
key !== 'negativePrompt' &&
!computedFields.includes(key) &&
key !== 'extraJsonMetaInfo'
)
.map(([key, value]) => `${key.charAt(0).toUpperCase() + key.slice(1)}: ${value}`)
.join('\n')
// extraJsonMetaInfo KV
const extraJsonMetaInfo = parsed.extraJsonMetaInfo as Record<string, any> | undefined
const kvPairs: KVPair[] = []
if (extraJsonMetaInfo) {
Object.entries(extraJsonMetaInfo).forEach(([key, value]) => {
// JSON
kvPairs.push({
key,
value
})
})
}
return {
prompt: parsed.prompt || '',
negativePrompt: parsed.negativePrompt || '',
otherInfo: otherInfo || defaultMetadata,
kvPairs
}
}
//
const buildPrompt = (positive: string, negative: string, other: string, kvPairs: KVPair[]) => {
let result = ''
if (positive) result += positive
result += `\nNegative prompt: ${negative || ''}`
//
if (other) {
result += `\n${other.split('\n').filter(line => line.trim()).join(', ')}`
} else {
result += `\n${defaultMetadata}`
}
// extraJsonMetaInfo
if (kvPairs.length > 0) {
const extraMeta: Record<string, any> = Object.fromEntries(kvPairs.map(kv => [kv.key.trim(), kv.value]))
result += `\nextraJsonMetaInfo: ${JSON.stringify(extraMeta)}`
}
return result.trim()
}
//
const positivePrompt = ref('')
const negativePrompt = ref('')
const otherInfo = ref(defaultMetadata)
const kvPairs = ref<KVPair[]>([])
const saving = ref(false)
const kvEditorRefs = ref<any[]>([])
//
const initializeData = () => {
const data = currentPrompt.value ? parsePrompt(currentPrompt.value) : {
prompt: '',
negativePrompt: '',
otherInfo: defaultMetadata,
kvPairs: []
}
positivePrompt.value = data.prompt
negativePrompt.value = data.negativePrompt
otherInfo.value = data.otherInfo
kvPairs.value = data.kvPairs
}
// KV
const addKvPair = () => {
kvPairs.value.push({ key: '', value: '' })
}
// KV
const removeKvPair = (index: number) => {
kvPairs.value.splice(index, 1)
kvEditorRefs.value.splice(index, 1)
}
//
const handleSave = async () => {
if (!file.value) return
//
if (!positivePrompt.value.trim()) {
message.error(t('positivePromptRequired'))
return
}
// KV
const validators = kvEditorRefs.value
.filter(ref => ref && ref.validate)
.map(ref => ref.validate())
if (validators.some(valid => !valid)) {
message.error(t('fixErrorsBeforeSave'))
return
}
saving.value = true
try {
const fullPrompt = buildPrompt(positivePrompt.value, negativePrompt.value, otherInfo.value, kvPairs.value)
await updateExif(file.value.fullpath, fullPrompt)
message.success(t('savePromptSuccess'))
//
show.value = false
globalEvents.emit('promptEditorUpdated')
} catch (error: any) {
console.error('Save prompt error:', error)
if (error.message && !error.message.includes('Invalid JSON')) {
message.error(t('savePromptFailed'))
}
throw error
} finally {
saving.value = false
}
}
//
const handleCancel = () => {
show.value = false
}
</script>
<template>
<a-modal v-model:visible="show" :title="file ? t('editPromptTitle', { name: file.name }) : ''" :width="'70vw'"
:footer="null" :maskClosable="true" destroyOnClose >
<div class="prompt-editor-modal" @wheel.stop @keydown.stop @keyup.stop @keypress.stop>
<div class="editor-section">
<div class="section-label">{{ t('positivePrompt') }}</div>
<a-textarea v-model:value="positivePrompt" :placeholder="t('positivePrompt')"
:autoSize="{ minRows: 3, maxRows: 8 }" class="prompt-input" />
</div>
<div class="editor-section">
<div class="section-label">{{ t('negativePrompt') }}</div>
<a-textarea v-model:value="negativePrompt" :placeholder="t('negativePrompt')"
:autoSize="{ minRows: 2, maxRows: 6 }" class="prompt-input" />
</div>
<div class="editor-section">
<div class="section-label">
{{ t('otherInfo') }}
<span class="section-hint">({{ t('otherInfoHint') }})</span>
</div>
<a-textarea v-model:value="otherInfo" :placeholder="t('otherInfo')" :autoSize="{ minRows: 2, maxRows: 6 }"
class="prompt-input" />
</div>
<div class="editor-section kv-editor-section">
<div class="kv-header">
<div class="section-label">{{ t('extraMetaInfoTitle') }}</div>
<a-button size="small" @click="addKvPair">{{ t('addKvButton') }}</a-button>
</div>
<div class="section-hint">
{{ t('extraMetaInfoHint') }}
</div>
<div v-if="kvPairs.length === 0" class="empty-state">
{{ t('noExtraMetaInfo') }}
</div>
<div v-else class="kv-list">
<KvPairEditor
v-for="(_, index) in kvPairs"
:key="index"
:ref="(el: any) => { if (el) kvEditorRefs[index] = el }"
v-model="kvPairs[index]"
:allKeys="kvPairs.filter((_, i) => i !== index).map(kv => kv.key)"
@remove="removeKvPair(index)"
/>
</div>
</div>
<div class="modal-footer">
<a-button @click="handleCancel">{{ t('cancel') }}</a-button>
<a-button type="primary" @click="handleSave" :loading="saving">
{{ t('savePrompt') }}
</a-button>
</div>
</div>
</a-modal>
</template>
<style scoped lang="scss">
.prompt-editor-modal {
display: flex;
flex-direction: column;
gap: 16px;
max-height: 60vh;
overflow-y: auto;
}
.editor-section {
display: flex;
flex-direction: column;
gap: 6px;
}
.section-label {
font-size: 12px;
color: var(--zp-primary);
font-weight: 500;
display: flex;
align-items: center;
gap: 8px;
}
.section-hint {
font-size: 11px;
color: var(--zp-secondary);
font-weight: 400;
}
.prompt-input {
font-size: 13px;
}
.kv-editor-section {
border: 1px solid var(--zp-secondary-variant-background);
border-radius: 4px;
padding: 12px;
background: var(--zp-secondary-background);
}
.kv-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
}
.kv-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.empty-state {
text-align: center;
padding: 20px;
color: var(--zp-secondary);
font-size: 12px;
}
.modal-footer {
display: flex;
justify-content: flex-end;
gap: 8px;
padding-top: 8px;
border-top: 1px solid var(--zp-secondary-variant-background);
}
</style>

View File

@ -1,11 +1,12 @@
import { Button, Input, Modal, message } from 'ant-design-vue' import { Button, Input, Modal, message, Spin } from 'ant-design-vue'
import { StyleValue, ref } from 'vue' import { StyleValue, ref } from 'vue'
import * as Path from '@/util/path' import * as Path from '@/util/path'
import { FileNodeInfo, mkdirs } from '@/api/files' import { FileNodeInfo, mkdirs } from '@/api/files'
import { setTargetFrameAsCover } from '@/api' import { setTargetFrameAsCover, getImageGenerationInfo } from '@/api'
import { parse } from '@/util/stable-diffusion-image-metadata'
import { t } from '@/i18n' import { t } from '@/i18n'
import { downloadFiles, globalEvents, toRawFileUrl, toStreamVideoUrl, toStreamAudioUrl } from '@/util' import { downloadFiles, globalEvents, toRawFileUrl, toStreamVideoUrl, toStreamAudioUrl } from '@/util'
import { DownloadOutlined } from '@/icon' import { DownloadOutlined, FileTextOutlined, EditOutlined } from '@/icon'
import { isStandalone } from '@/util/env' import { isStandalone } from '@/util/env'
import { addCustomTag, getDbBasicInfo, rebuildImageIndex, renameFile } from '@/api/db' import { addCustomTag, getDbBasicInfo, rebuildImageIndex, renameFile } from '@/api/db'
import { useTagStore } from '@/store/useTagStore' import { useTagStore } from '@/store/useTagStore'
@ -43,10 +44,12 @@ export const MultiSelectTips = () => (
</p> </p>
) )
export const openVideoModal = ( // 合并的视频/音频 modal 实现
const openMediaModalImpl = (
file: FileNodeInfo, file: FileNodeInfo,
onTagClick?: (id: string| number) => void, onTagClick?: (id: string| number) => void,
onTiktokView?: () => void onTiktokView?: () => void,
mediaType: 'video' | 'audio' = 'video'
) => { ) => {
const tagStore = useTagStore() const tagStore = useTagStore()
const global = useGlobalStore() const global = useGlobalStore()
@ -54,17 +57,23 @@ export const openVideoModal = (
return !!tagStore.tagMap.get(file.fullpath)?.some(v => v.id === id) return !!tagStore.tagMap.get(file.fullpath)?.some(v => v.id === id)
} }
const videoRef = ref<HTMLVideoElement | null>(null) const videoRef = ref<HTMLVideoElement | null>(null)
const onSetCurrFrameAsVideoPoster = async () => { const imageGenInfo = ref('')
if (!videoRef.value) { const promptLoading = ref(false)
return
// 加载提示词
const loadPrompt = async () => {
promptLoading.value = true
try {
const info = await getImageGenerationInfo(file.fullpath)
imageGenInfo.value = info
} catch (error) {
console.error('Load prompt error:', error)
imageGenInfo.value = ''
} finally {
promptLoading.value = false
} }
const video = videoRef.value
video.pause()
const base64 = video2base64(video)
await setTargetFrameAsCover({ path: file.fullpath, base64_img: base64, updated_time: file.date } )
file.cover_url = URL.createObjectURL(await base64ToFile(base64, 'cover'))
message.success(t('success') + '! ' + t('clearCacheIfNotTakeEffect'))
} }
const tagBaseStyle: StyleValue = { const tagBaseStyle: StyleValue = {
margin: '2px', margin: '2px',
padding: '2px 16px', padding: '2px 16px',
@ -76,94 +85,95 @@ export const openVideoModal = (
'user-select': 'none', 'user-select': 'none',
} }
const modal = Modal.confirm({ // 解析提示词结构
width: '80vw', const geninfoStruct = () => parse(imageGenInfo.value)
title: file.name,
icon: null, // 计算文本长度中文算3个字符
content: () => ( const getTextLength = (text: string): number => {
<div let length = 0
style={{ for (const char of text) {
display: 'flex', if (/[\u4e00-\u9fa5]/.test(char)) {
alignItems: 'center', length += 3
justifyContent: 'center', } else {
flexDirection: 'column' length += 1
}} }
> }
<video ref={videoRef} style={{ maxHeight: isStandalone ? '80vh' : '60vh', maxWidth: '100%', minWidth: '70%' }} src={toStreamVideoUrl(file)} controls autoplay></video> return length
<div style={{ marginTop: '4px' }}> }
<div onClick={openAddNewTagModal} style={{
background: 'var(--zp-primary-background)', // 判断是否为 tag 风格的提示词
color: 'var(--zp-luminous)', const isTagStylePrompt = (tags: string[]): boolean => {
border: '2px solid var(--zp-luminous)', if (tags.length === 0) return false
...tagBaseStyle
}}> let totalLength = 0
{ t('addNewCustomTag') } for (const tag of tags) {
</div> const tagLength = getTextLength(tag)
{global.conf!.all_custom_tags.map((tag) => totalLength += tagLength
<div key={tag.id} onClick={() => onTagClick?.(tag.id)} style={{
background: isSelected(tag.id) ? tagStore.getColor(tag) : 'var(--zp-primary-background)', if (tagLength > 50) {
color: !isSelected(tag.id) ? tagStore.getColor(tag) : 'white', return false
border: `2px solid ${tagStore.getColor(tag)}`, }
...tagBaseStyle }
}}>
{ tag.name } const avgLength = totalLength / tags.length
</div>)} if (avgLength > 30) {
</div> return false
<div class="actions" style={{ marginTop: '16px' }}> }
<Button onClick={() => downloadFiles([toRawFileUrl(file, true)])}>
{{ return true
icon: <DownloadOutlined/>, }
default: t('download')
}} // 提示词包装函数(支持 tag 风格和自然语言风格)
</Button> const spanWrap = (text: string) => {
{onTiktokView && ( if (!text) return ''
<Button onClick={onTiktokViewWrapper} type="primary">
{{ const specBreakTag = 'BREAK'
default: t('tiktokView') const values = text.replace(/&gt;\s/g, '> ,').replace(/\sBREAK\s/g, ',' + specBreakTag + ',')
}} .split(/[\n,]+/)
</Button> .map(v => v.trim())
)} .filter(v => v)
<Button onClick={onSetCurrFrameAsVideoPoster}>
{{ // 判断是否为 tag 风格
default: t('setCurrFrameAsVideoPoster') if (!isTagStylePrompt(values)) {
}} // 自然语言风格
</Button> return text
</div> .split('\n')
</div> .map(line => line.trim())
), .filter(line => line)
maskClosable: true, .map(line => `<p style="margin:0; padding:4px 0;">${line}</p>`)
wrapClassName: 'hidden-antd-btns-modal' .join('')
}) }
function onTiktokViewWrapper() {
// Tag 风格
const frags: string[] = []
let parenthesisActive = false
for (let i = 0; i < values.length; i++) {
if (values[i] === specBreakTag) {
frags.push('<br><span style="color:var(--zp-secondary); font-weight:bold;">BREAK</span><br>')
continue
}
const trimmedValue = values[i]
if (!parenthesisActive) parenthesisActive = trimmedValue.includes('(')
const styles = ['background: var(--zp-secondary-variant-background)', 'color: var(--zp-primary)', 'padding: 2px 6px', 'border-radius: 4px', 'margin-right: 6px', 'margin-top: 4px', 'display: inline-block']
if (parenthesisActive) styles.push('border: 1px solid var(--zp-secondary)')
if (getTextLength(trimmedValue) < 32) styles.push('font-size: 0.9em')
frags.push(`<span style="${styles.join('; ')}">${trimmedValue}</span>`)
if (parenthesisActive) parenthesisActive = !trimmedValue.includes(')')
}
return frags.join(' ')
}
// 加载提示词
loadPrompt()
const onTiktokViewWrapper = () => {
onTiktokView?.() onTiktokView?.()
closeImageFullscreenPreview() closeImageFullscreenPreview()
modal.destroy() modal.destroy()
} }
}
export const openAudioModal = (
file: FileNodeInfo,
onTagClick?: (id: string| number) => void,
onTiktokView?: () => void
) => {
const tagStore = useTagStore()
const global = useGlobalStore()
const isSelected = (id: string | number) => {
return !!tagStore.tagMap.get(file.fullpath)?.some(v => v.id === id)
}
const tagBaseStyle: StyleValue = {
margin: '2px',
padding: '2px 16px',
'border-radius': '4px',
display: 'inline-block',
cursor: 'pointer',
'font-weight': 'bold',
transition: '.5s all ease',
'user-select': 'none',
}
const modal = Modal.confirm({ const modal = Modal.confirm({
width: '60vw', width: mediaType === 'video' ? '80vw' : '70vw',
title: file.name, title: file.name,
icon: null, icon: null,
content: () => ( content: () => (
@ -175,11 +185,16 @@ export const openAudioModal = (
flexDirection: 'column' flexDirection: 'column'
}} }}
> >
<div style={{ {mediaType === 'video' ? (
fontSize: '80px', <video ref={videoRef} style={{ maxHeight: isStandalone ? '80vh' : '60vh', maxWidth: '100%', minWidth: '70%' }} src={toStreamVideoUrl(file)} controls autoplay></video>
marginBottom: '16px' ) : (
}}>🎵</div> <>
<audio style={{ width: '100%', maxWidth: '500px' }} src={toStreamAudioUrl(file)} controls autoplay></audio> <div style={{ fontSize: '80px', marginBottom: '16px' }}>🎵</div>
<audio style={{ width: '100%', maxWidth: '500px' }} src={toStreamAudioUrl(file)} controls autoplay></audio>
</>
)}
{/* 标签选择区域 */}
<div style={{ marginTop: '16px' }}> <div style={{ marginTop: '16px' }}>
<div onClick={openAddNewTagModal} style={{ <div onClick={openAddNewTagModal} style={{
background: 'var(--zp-primary-background)', background: 'var(--zp-primary-background)',
@ -199,6 +214,8 @@ export const openAudioModal = (
{ tag.name } { tag.name }
</div>)} </div>)}
</div> </div>
{/* 操作按钮 */}
<div class="actions" style={{ marginTop: '16px' }}> <div class="actions" style={{ marginTop: '16px' }}>
<Button onClick={() => downloadFiles([toRawFileUrl(file, true)])}> <Button onClick={() => downloadFiles([toRawFileUrl(file, true)])}>
{{ {{
@ -213,19 +230,83 @@ export const openAudioModal = (
}} }}
</Button> </Button>
)} )}
{mediaType === 'video' && (
<Button onClick={async () => {
if (!videoRef.value) return
const video = videoRef.value
video.pause()
const base64 = video2base64(video)
await setTargetFrameAsCover({ path: file.fullpath, base64_img: base64, updated_time: file.date })
file.cover_url = URL.createObjectURL(await base64ToFile(base64, 'cover'))
message.success(t('success') + '! ' + t('clearCacheIfNotTakeEffect'))
}}>
{{ default: t('setCurrFrameAsVideoPoster') }}
</Button>
)}
<Button onClick={async () => {
await openEditPromptModal(file)
await loadPrompt()
}} icon={<EditOutlined />}>
{{ default: t('editPrompt') }}
</Button>
</div> </div>
{/* 提示词显示区域 */}
{promptLoading.value ? (
<div style={{ marginTop: '24px', width: '100%', textAlign: 'center' }}>
<Spin />
</div>
) : imageGenInfo.value ? (
<div style={{ marginTop: '24px', width: '100%', maxWidth: mediaType === 'video' ? '1000px' : '900px', alignSelf: 'flex-start' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '12px', color: 'var(--zp-primary)', fontSize: '14px', fontWeight: 500 }}>
<FileTextOutlined />
<span>Prompt</span>
</div>
{geninfoStruct().prompt && (
<div style={{ marginBottom: '12px' }}>
<div style={{ fontSize: '12px', color: 'var(--zp-primary)', marginBottom: '6px' }}>Positive</div>
<code style={{ fontSize: '13px', display: 'block', padding: '10px 12px', background: 'var(--zp-primary-background)', borderRadius: '8px', whiteSpace: 'pre-wrap', wordBreak: 'break-word', lineHeight: '1.6em' }} innerHTML={spanWrap(geninfoStruct().prompt ?? '')}></code>
</div>
)}
{geninfoStruct().negativePrompt && (
<div style={{ marginBottom: '12px' }}>
<div style={{ fontSize: '12px', color: 'var(--zp-primary)', marginBottom: '6px' }}>Negative</div>
<code style={{ fontSize: '13px', display: 'block', padding: '10px 12px', background: 'var(--zp-primary-background)', borderRadius: '8px', whiteSpace: 'pre-wrap', wordBreak: 'break-word', lineHeight: '1.6em' }} innerHTML={spanWrap(geninfoStruct().negativePrompt ?? '')}></code>
</div>
)}
{/* Meta 信息 */}
{Object.entries(geninfoStruct()).filter(([key]) => key !== 'prompt' && key !== 'negativePrompt').length > 0 && (
<div>
<div style={{ fontSize: '12px', color: 'var(--zp-primary)', marginBottom: '6px' }}>Meta</div>
<code style={{ fontSize: '12px', display: 'block', padding: '8px 12px', background: 'var(--zp-secondary-background)', borderRadius: '6px', whiteSpace: 'pre-wrap', wordBreak: 'break-word', lineHeight: '1.5em', color: 'var(--zp-primary)', opacity: 0.7 }}>
{Object.entries(geninfoStruct())
.filter(([key]) => key !== 'prompt' && key !== 'negativePrompt')
.map(([key, value]) => `${key.charAt(0).toUpperCase() + key.slice(1)}: ${value}`)
.join('\n')}
</code>
</div>
)}
</div>
) : null}
</div> </div>
), ),
maskClosable: true, maskClosable: true,
wrapClassName: 'hidden-antd-btns-modal' wrapClassName: 'hidden-antd-btns-modal'
}) })
function onTiktokViewWrapper() {
onTiktokView?.()
closeImageFullscreenPreview()
modal.destroy()
}
} }
export const openVideoModal = (
file: FileNodeInfo,
onTagClick?: (id: string| number) => void,
onTiktokView?: () => void
) => openMediaModalImpl(file, onTagClick, onTiktokView, 'video')
export const openAudioModal = (
file: FileNodeInfo,
onTagClick?: (id: string| number) => void,
onTiktokView?: () => void
) => openMediaModalImpl(file, onTagClick, onTiktokView, 'audio')
export const openRebuildImageIndexModal = () => { export const openRebuildImageIndexModal = () => {
Modal.confirm({ Modal.confirm({
title: t('confirmRebuildImageIndex'), title: t('confirmRebuildImageIndex'),
@ -286,3 +367,16 @@ export const openAddNewTagModal = () => {
}) })
} }
export const openEditPromptModal = async (file: FileNodeInfo) => {
globalEvents.off('promptEditorUpdated') // 确保事件监听器不会重复绑定
return new Promise<void>((resolve) => {
const handler = () => {
globalEvents.off('promptEditorUpdated', handler)
resolve()
}
globalEvents.on('promptEditorUpdated', handler)
globalEvents.emit('openPromptEditor', { file })
})
}

View File

@ -1,6 +1,13 @@
import type { IIBI18nMap } from '.' import type { IIBI18nMap } from '.'
export const de: Partial<IIBI18nMap> = { export const de: Partial<IIBI18nMap> = {
blockVisibilitySettings: 'Panel-Einstellungen',
blockName_actionBar: 'Aktionsschaltflächen',
blockName_infoTags: 'Datei-Info-Tags',
blockName_tagsContainer: 'Benutzerdefinierte Tags',
blockName_lrLayoutControl: 'Layout-Steuerung',
blockName_draggableImage: 'Ziehbares Bild',
blockName_tabs: 'Prompt-Registerkarten',
serverKeyRequired: serverKeyRequired:
'Für die weitere Nutzung ist die Eingabe eines Schlüssels erforderlich, der vom Server konfiguriert wurde.', 'Für die weitere Nutzung ist die Eingabe eines Schlüssels erforderlich, der vom Server konfiguriert wurde.',
removeFromSearchScanPathAndQuickMove: 'Schnellzugriff entfernen', removeFromSearchScanPathAndQuickMove: 'Schnellzugriff entfernen',
@ -319,5 +326,42 @@ export const de: Partial<IIBI18nMap> = {
loadingTip10: '🤖 KI-Agenten-Integration\n\nSie können jetzt KI-Agenten IIB nutzen lassen, um bei Bildverwaltung, Tag-Organisation und intelligenter Suche zu helfen. Über die API-Schnittstelle kann die KI auf alle IIB-Funktionen zugreifen und automatisierte Workflows erstellen.|info', loadingTip10: '🤖 KI-Agenten-Integration\n\nSie können jetzt KI-Agenten IIB nutzen lassen, um bei Bildverwaltung, Tag-Organisation und intelligenter Suche zu helfen. Über die API-Schnittstelle kann die KI auf alle IIB-Funktionen zugreifen und automatisierte Workflows erstellen.|info',
// ===== Video Inline Play ===== // ===== Video Inline Play =====
playInline: 'Hier abspielen' playInline: 'Hier abspielen',
// ===== Prompt-Bearbeitung =====
editPrompt: 'bearbeiten',
editPromptTitle: 'Prompt bearbeiten - {name}',
positivePrompt: 'Positiver Prompt',
negativePrompt: 'Negativer Prompt',
otherInfo: 'Weitere Informationen',
savePrompt: 'Prompt speichern',
savePromptSuccess: 'Prompt erfolgreich gespeichert',
savePromptFailed: 'Prompt-Speicherung fehlgeschlagen',
promptEditedMark: 'Manuell bearbeitet',
promptModifiedTip: 'Dieser Prompt wurde manuell bearbeitet und überschreibt den ursprünglichen Prompt aus der Datei',
// PromptEditorModal bezogen
positivePromptRequired: 'Positiver Prompt darf nicht leer sein',
fixErrorsBeforeSave: 'Bitte beheben Sie alle Fehler vor dem Speichern',
extraMetaInfoTitle: 'Extra Meta Info (KV-Editor)',
addKvButton: '+ Hinzufügen',
extraMetaInfoHint: 'Kann beliebige komplexe Informationen speichern, eine perfekte Ergänzung zu den Einschränkungen des Params-Formats. Im JSON-Modus werden auch alle gültigen JSON-Werte unterstützt (Objekte, Arrays, Zahlen, Boolesche Werte, etc.)',
noExtraMetaInfo: 'Keine Extra Meta Info, klicken Sie auf "Hinzufügen" um Schlüssel-Wert-Paare hinzuzufügen',
otherInfoHint: 'Unterstützt nur einfache Zeichenfolgen oder Zahlen, keine speziellen Symbole oder Zeilenumbrüche',
// KvPairEditor bezogen
keyRequired: 'Key darf nicht leer sein',
keyMustBeUnique: 'Key existiert bereits, bitte verwenden Sie einen eindeutigen Key',
jsonFormatError: 'JSON-Formatfehler, bitte Syntax überprüfen',
stringMode: 'Zeichenfolge',
jsonMode: 'JSON',
delete: 'Löschen',
keyPlaceholder: 'Key',
jsonValuePlaceholder: 'JSON-Wert',
stringValuePlaceholder: 'Zeichenfolgenwert',
clearBeforeSwitchToJson: 'Bitte aktuellen Wert leeren vor dem Wechsel zu JSON-Modus',
clearBeforeSwitchToString: 'Bitte aktuellen Wert leeren vor dem Wechsel zu Zeichenfolgen-Modus',
dragImageToTransfer: 'Klicken Sie, um das Originalbild zu erhalten, greifen und ziehen Sie es zu anderen Apps',
dragImage: 'Klicken Sie, um das Originalbild zu erhalten, greifen und ziehen Sie es zu anderen Apps',
close: 'Schließen'
} }

View File

@ -223,6 +223,13 @@ You can specify which snapshot to restore to when starting IIB in the global set
resolution: 'Resolution', resolution: 'Resolution',
fileSize: 'File Size', fileSize: 'File Size',
fullscreenview: 'Fullscreen View', fullscreenview: 'Fullscreen View',
blockVisibilitySettings: 'Panel Settings',
blockName_actionBar: 'Action Buttons',
blockName_infoTags: 'File Info Tags',
blockName_tagsContainer: 'Custom Tags',
blockName_lrLayoutControl: 'Layout Control',
blockName_draggableImage: 'Draggable Image',
blockName_tabs: 'Prompt Tabs',
promptcompare: 'Compare Prompts', promptcompare: 'Compare Prompts',
imgCompare: 'Image Comparison', imgCompare: 'Image Comparison',
share: 'Share', share: 'Share',
@ -579,5 +586,41 @@ You can specify which snapshot to restore to when starting IIB in the global set
loadingTip10: '🤖 AI Agent Integration\n\nYou can now let AI agents use IIB to help with image management, tag organization, and smart search. Through the API interface, AI can access all IIB features for automated workflows.|info', loadingTip10: '🤖 AI Agent Integration\n\nYou can now let AI agents use IIB to help with image management, tag organization, and smart search. Through the API interface, AI can access all IIB features for automated workflows.|info',
// ===== Video Inline Play ===== // ===== Video Inline Play =====
playInline: 'Play Here' playInline: 'Play Here',
// ===== Prompt Editing =====
editPrompt: 'Edit',
editPromptTitle: 'Edit Prompt - {name}',
positivePrompt: 'Positive Prompt',
negativePrompt: 'Negative Prompt',
otherInfo: 'Other Info',
savePrompt: 'Save Prompt',
savePromptSuccess: 'Prompt saved successfully',
savePromptFailed: 'Failed to save prompt',
promptEditedMark: 'Manually edited',
promptModifiedTip: 'This prompt has been manually edited and will override the original prompt from the file',
// PromptEditorModal related
positivePromptRequired: 'Positive prompt cannot be empty',
fixErrorsBeforeSave: 'Please fix all errors before saving',
extraMetaInfoTitle: 'Extra Meta Info (KV Editor)',
addKvButton: '+ Add',
extraMetaInfoHint: 'Can save any complex information, a perfect supplement to the limitations of params format. In JSON mode, it also supports any valid JSON values (objects, arrays, numbers, booleans, etc.)',
noExtraMetaInfo: 'No Extra Meta Info, click "Add" button to add key-value pairs',
otherInfoHint: 'Only supports simple strings or numbers, cannot contain special symbols or line breaks',
// KvPairEditor related
keyRequired: 'Key cannot be empty',
keyMustBeUnique: 'Key already exists, please use a unique key',
jsonFormatError: 'JSON format error, please check syntax',
stringMode: 'String',
jsonMode: 'JSON',
delete: 'Delete',
keyPlaceholder: 'Key',
jsonValuePlaceholder: 'JSON Value',
stringValuePlaceholder: 'String Value',
clearBeforeSwitchToJson: 'Please clear current value before switching to JSON mode',
clearBeforeSwitchToString: 'Please clear current value before switching to string mode',
dragImageToTransfer: 'Click to get original image, grab and drag to other apps',
dragImage: 'Click to get original image, grab and drag to other apps'
} }

View File

@ -350,6 +350,13 @@ export const zhHans = {
imgCompare: '图像对比', imgCompare: '图像对比',
close: '关闭', close: '关闭',
fullscreenview: '全屏查看', fullscreenview: '全屏查看',
blockVisibilitySettings: '面板设置',
blockName_actionBar: '操作按钮',
blockName_infoTags: '文件信息标签',
blockName_tagsContainer: '自定义标签',
blockName_lrLayoutControl: '布局控制',
blockName_draggableImage: '可拖拽图片',
blockName_tabs: '提示词标签页',
fileName: '文件名', fileName: '文件名',
resolution: '分辨率', resolution: '分辨率',
fileSize: '文件大小', fileSize: '文件大小',
@ -557,5 +564,41 @@ export const zhHans = {
loadingTip10: '🤖 AI Agent 集成\n\n现在你可以让 AI agent 来使用 IIB 帮助进行图像管理、标签整理和智能搜索。通过 API 接口AI 可以访问所有 IIB 功能,实现自动化工作流程。|info', loadingTip10: '🤖 AI Agent 集成\n\n现在你可以让 AI agent 来使用 IIB 帮助进行图像管理、标签整理和智能搜索。通过 API 接口AI 可以访问所有 IIB 功能,实现自动化工作流程。|info',
// ===== 视频原地播放 ===== // ===== 视频原地播放 =====
playInline: '在此播放' playInline: '在此播放',
// ===== 提示词编辑 =====
editPrompt: '编辑',
editPromptTitle: '编辑提示词 - {name}',
positivePrompt: '正向提示词',
negativePrompt: '负向提示词',
otherInfo: '其他信息',
savePrompt: '保存提示词',
savePromptSuccess: '提示词保存成功',
savePromptFailed: '提示词保存失败',
promptEditedMark: '已手动编辑',
promptModifiedTip: '此提示词已被手动编辑,将覆盖原始文件中的提示词',
// 新增PromptEditorModal 相关
positivePromptRequired: '正向提示词不能为空',
fixErrorsBeforeSave: '请修正所有错误后再保存',
extraMetaInfoTitle: 'Extra Meta Info (KV 编辑器)',
addKvButton: '+ 添加',
extraMetaInfoHint: '可以保存任何的复杂信息是对params格式缺陷的完美补充。json模式下还支持任何合法 JSON 值(对象、数组、数字、布尔等)',
noExtraMetaInfo: '暂无 Extra Meta Info点击"添加"按钮添加键值对',
otherInfoHint: '仅支持简单字符串或数值,不能包含特殊符号或换行',
// 新增KvPairEditor 相关
keyRequired: 'Key 不能为空',
keyMustBeUnique: 'Key 已存在,请使用唯一的 key',
jsonFormatError: 'JSON 格式错误,请检查语法',
stringMode: '字符串',
jsonMode: 'JSON',
delete: '删除',
keyPlaceholder: 'Key',
jsonValuePlaceholder: 'JSON Value',
stringValuePlaceholder: '字符串值',
clearBeforeSwitchToJson: '切换到 JSON 模式前请先清空当前值',
clearBeforeSwitchToString: '切换到字符串模式前请先清空当前值',
dragImageToTransfer: '点击获取原图,抓取拖拽至其他应用',
dragImage: '点击获取原图,抓取拖拽至其他应用'
} }

View File

@ -357,6 +357,13 @@ export const zhHant: Partial<IIBI18nMap> = {
imgCompare: '圖片對比', imgCompare: '圖片對比',
close: '關閉', close: '關閉',
fullscreenview: '全屏查看', fullscreenview: '全屏查看',
blockVisibilitySettings: '面板設置',
blockName_actionBar: '操作按鈕',
blockName_infoTags: '文件信息標籤',
blockName_tagsContainer: '自定義標籤',
blockName_lrLayoutControl: '佈局控制',
blockName_draggableImage: '可拖拽圖片',
blockName_tabs: '提示詞標籤頁',
fileName: '文件名稱', fileName: '文件名稱',
resolution: '解析度', resolution: '解析度',
fileSize: '文件大小', fileSize: '文件大小',
@ -559,5 +566,41 @@ export const zhHant: Partial<IIBI18nMap> = {
loadingTip10: '🤖 AI Agent 整合\n\n現在您可以讓 AI agent 使用 IIB 來協助進行圖片管理、標籤整理和智慧搜尋。透過 API 介面AI 可以存取所有 IIB 功能,實現自動化工作流程。|info', loadingTip10: '🤖 AI Agent 整合\n\n現在您可以讓 AI agent 使用 IIB 來協助進行圖片管理、標籤整理和智慧搜尋。透過 API 介面AI 可以存取所有 IIB 功能,實現自動化工作流程。|info',
// ===== 視頻原地播放 ===== // ===== 視頻原地播放 =====
playInline: '在此播放' playInline: '在此播放',
// ===== 提示詞編輯 =====
editPrompt: '編輯',
editPromptTitle: '編輯提示詞 - {name}',
positivePrompt: '正向提示詞',
negativePrompt: '負向提示詞',
otherInfo: '其他信息',
savePrompt: '保存提示詞',
savePromptSuccess: '提示詞保存成功',
savePromptFailed: '提示詞保存失敗',
promptEditedMark: '已手動編輯',
promptModifiedTip: '此提示詞已被手動編輯,將覆蓋原始文件中的提示詞',
// 新增PromptEditorModal 相關
positivePromptRequired: '正向提示詞不能為空',
fixErrorsBeforeSave: '請修正所有錯誤後再保存',
extraMetaInfoTitle: 'Extra Meta Info (KV 編輯器)',
addKvButton: '+ 添加',
extraMetaInfoHint: '可以保存任何的複雜信息是對params格式缺陷的完美補充。json模式下還支持任何合法 JSON 值(對象、數組、數字、布爾等)',
noExtraMetaInfo: '暫無 Extra Meta Info點擊"添加"按鈕添加鍵值對',
otherInfoHint: '僅支持簡單字符串或數值,不能包含特殊符號或換行',
// 新增KvPairEditor 相關
keyRequired: 'Key 不能為空',
keyMustBeUnique: 'Key 已存在,請使用唯一的 key',
jsonFormatError: 'JSON 格式錯誤,請檢查語法',
stringMode: '字符串',
jsonMode: 'JSON',
delete: '刪除',
keyPlaceholder: 'Key',
jsonValuePlaceholder: 'JSON Value',
stringValuePlaceholder: '字符串值',
clearBeforeSwitchToJson: '切換到 JSON 模式前請先清空當前值',
clearBeforeSwitchToString: '切換到字符串模式前請先清空當前值',
dragImageToTransfer: '點擊獲取原圖,抓取拖拽至其他應用',
dragImage: '點擊獲取原圖,抓取拖拽至其他應用'
} }

View File

@ -147,7 +147,7 @@ body {
margin: 0; margin: 0;
} }
.ant-modal-wrap,.ant-message, .ant-tooltip { .ant-modal-wrap,.ant-message, .ant-tooltip {
z-index: 10000; z-index: 100000;
} }
.hidden-antd-btns-modal .ant-modal-confirm-btns { .hidden-antd-btns-modal .ant-modal-confirm-btns {

View File

@ -5,7 +5,7 @@ import { useTagStore } from '@/store/useTagStore'
import { useGlobalStore } from '@/store/useGlobalStore' import { useGlobalStore } from '@/store/useGlobalStore'
import { useLocalStorage, onLongPress } from '@vueuse/core' import { useLocalStorage, onLongPress } from '@vueuse/core'
import { copy2clipboardI18n, isVideoFile, isAudioFile } from '@/util' import { copy2clipboardI18n, isVideoFile, isAudioFile } from '@/util'
import { openAddNewTagModal } from '@/components/functionalCallableComp' import { openAddNewTagModal, openEditPromptModal } from '@/components/functionalCallableComp'
import { toggleCustomTagToImg } from '@/api/db' import { toggleCustomTagToImg } from '@/api/db'
import { deleteFiles } from '@/api/files' import { deleteFiles } from '@/api/files'
import { getImageGenerationInfo, openFolder, openWithDefaultApp } from '@/api' import { getImageGenerationInfo, openFolder, openWithDefaultApp } from '@/api'
@ -31,7 +31,8 @@ import {
CopyOutlined, CopyOutlined,
LinkOutlined, LinkOutlined,
FileTextOutlined, FileTextOutlined,
InfoCircleOutlined InfoCircleOutlined,
EditOutlined
} from '@/icon' } from '@/icon'
import { t } from '@/i18n' import { t } from '@/i18n'
import type { StyleValue } from 'vue' import type { StyleValue } from 'vue'
@ -837,11 +838,6 @@ const loadCurrentItemPrompt = async () => {
imageGenInfo.value = '' imageGenInfo.value = ''
return return
} }
const nameOrUrl = currentItem.name || currentItem.url
if (isVideoFile(nameOrUrl) || isAudioFile(nameOrUrl)) {
imageGenInfo.value = ''
return
}
const fullpath = (currentItem as any)?.fullpath || currentItem.id const fullpath = (currentItem as any)?.fullpath || currentItem.id
if (!fullpath) { if (!fullpath) {
imageGenInfo.value = '' imageGenInfo.value = ''
@ -1251,6 +1247,18 @@ watch(() => autoPlayMode.value, () => {
<div class="panel-section prompt-section"> <div class="panel-section prompt-section">
<div class="section-title"> <div class="section-title">
<FileTextOutlined /> <span>Prompt</span> <FileTextOutlined /> <span>Prompt</span>
<button
v-if="!promptLoading"
@click="async () => {
await openEditPromptModal(tiktokStore.currentItem as any)
//
await loadCurrentItemPrompt()
}"
class="edit-prompt-btn"
:title="t('editPrompt')"
>
<EditOutlined />
</button>
</div> </div>
<div class="prompt-content"> <div class="prompt-content">
<div v-if="promptLoading" class="prompt-empty">...</div> <div v-if="promptLoading" class="prompt-empty">...</div>
@ -1831,6 +1839,26 @@ watch(() => autoPlayMode.value, () => {
margin-bottom: 12px; margin-bottom: 12px;
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.5px; letter-spacing: 0.5px;
.edit-prompt-btn {
margin-left: auto;
background: rgba(255, 255, 255, 0.1);
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 6px;
padding: 6px 10px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s ease;
color: rgba(255, 255, 255, 0.8);
&:hover {
background: rgba(255, 255, 255, 0.2);
border-color: rgba(255, 255, 255, 0.3);
color: rgba(255, 255, 255, 1);
}
}
} }
.tags-content { .tags-content {
@ -1856,7 +1884,6 @@ watch(() => autoPlayMode.value, () => {
.natural-text { .natural-text {
margin: 0.5em 0; margin: 0.5em 0;
line-height: 1.6em; line-height: 1.6em;
text-align: justify;
color: rgba(255, 255, 255, 0.8); color: rgba(255, 255, 255, 0.8);
} }

View File

@ -3,7 +3,7 @@ import fileItemCell from '@/components/FileItem.vue'
import '@zanllp/vue-virtual-scroller/dist/vue-virtual-scroller.css' import '@zanllp/vue-virtual-scroller/dist/vue-virtual-scroller.css'
// @ts-ignore // @ts-ignore
import { RecycleScroller } from '@zanllp/vue-virtual-scroller' import { RecycleScroller } from '@zanllp/vue-virtual-scroller'
import { toRawFileUrl } from '@/util/file' import { toImageUrl } from '@/util/file'
import { getImagesByTags, type MatchImageByTagsReq } from '@/api/db' import { getImagesByTags, type MatchImageByTagsReq } from '@/api/db'
import { nextTick, watch, ref } from 'vue' import { nextTick, watch, ref } from 'vue'
import { copy2clipboardI18n } from '@/util' import { copy2clipboardI18n } from '@/util'
@ -162,7 +162,7 @@ const onTiktokViewClick = () => {
@file-item-click="onFileItemClick" @file-item-click="onFileItemClick"
@tiktok-view="(_file, idx) => openTiktokViewWithFiles(images, idx)" @tiktok-view="(_file, idx) => openTiktokViewWithFiles(images, idx)"
:full-screen-preview-image-url=" :full-screen-preview-image-url="
images[previewIdx] ? toRawFileUrl(images[previewIdx]) : '' images[previewIdx] ? toImageUrl(images[previewIdx]) : ''
" "
:selected="multiSelectedIdxs.includes(idx)" :selected="multiSelectedIdxs.includes(idx)"
@context-menu-click="onContextMenuClickU" @context-menu-click="onContextMenuClickU"

View File

@ -4,7 +4,7 @@ import fileItemCell from '@/components/FileItem.vue'
import '@zanllp/vue-virtual-scroller/dist/vue-virtual-scroller.css' import '@zanllp/vue-virtual-scroller/dist/vue-virtual-scroller.css'
// @ts-ignore // @ts-ignore
import { RecycleScroller } from '@zanllp/vue-virtual-scroller' import { RecycleScroller } from '@zanllp/vue-virtual-scroller'
import { toRawFileUrl } from '@/util/file' import { toImageUrl } from '@/util/file'
import { getDbBasicInfo, getExpiredDirs, getImagesBySubstr, updateImageData, type DataBaseBasicInfo, SearchBySubstrReq } from '@/api/db' import { getDbBasicInfo, getExpiredDirs, getImagesBySubstr, updateImageData, type DataBaseBasicInfo, SearchBySubstrReq } from '@/api/db'
import { copy2clipboardI18n, makeAsyncFunctionSingle, useGlobalEventListen } from '@/util' import { copy2clipboardI18n, makeAsyncFunctionSingle, useGlobalEventListen } from '@/util'
import fullScreenContextMenu from '@/page/fileTransfer/fullScreenContextMenu.vue' import fullScreenContextMenu from '@/page/fileTransfer/fullScreenContextMenu.vue'
@ -335,7 +335,7 @@ const { onClearAllSelected, onSelectAll, onReverseSelect } = useKeepMultiSelect(
<template v-slot="{ item: file, index: idx }"> <template v-slot="{ item: file, index: idx }">
<!-- idx 和file有可能丢失 --> <!-- idx 和file有可能丢失 -->
<file-item-cell :idx="idx" :file="file" v-model:show-menu-idx="showMenuIdx" @file-item-click="onFileItemClick" <file-item-cell :idx="idx" :file="file" v-model:show-menu-idx="showMenuIdx" @file-item-click="onFileItemClick"
:full-screen-preview-image-url="images[previewIdx] ? toRawFileUrl(images[previewIdx]) : ''" :full-screen-preview-image-url="images[previewIdx] ? toImageUrl(images[previewIdx]) : ''"
:cell-width="cellWidth" :selected="multiSelectedIdxs.includes(idx)" :cell-width="cellWidth" :selected="multiSelectedIdxs.includes(idx)"
@context-menu-click="onContextMenuClickU" @dragstart="onFileDragStart" @dragend="onFileDragEnd" @context-menu-click="onContextMenuClickU" @dragstart="onFileDragStart" @dragend="onFileDragEnd"
@tiktok-view="(_file, idx) => openTiktokViewWithFiles(images, idx)" @tiktok-view="(_file, idx) => openTiktokViewWithFiles(images, idx)"

View File

@ -3,7 +3,7 @@ import fileItemCell from '@/components/FileItem.vue'
import '@zanllp/vue-virtual-scroller/dist/vue-virtual-scroller.css' import '@zanllp/vue-virtual-scroller/dist/vue-virtual-scroller.css'
// @ts-ignore // @ts-ignore
import { RecycleScroller } from '@zanllp/vue-virtual-scroller' import { RecycleScroller } from '@zanllp/vue-virtual-scroller'
import { toRawFileUrl } from '@/util/file' import { toImageUrl } from '@/util/file'
import { nextTick, watch, reactive } from 'vue' import { nextTick, watch, reactive } from 'vue'
import { copy2clipboardI18n } from '@/util' import { copy2clipboardI18n } from '@/util'
import fullScreenContextMenu from '@/page/fileTransfer/fullScreenContextMenu.vue' import fullScreenContextMenu from '@/page/fileTransfer/fullScreenContextMenu.vue'
@ -173,7 +173,7 @@ const onTiktokViewClick = () => {
@dragend="onFileDragEnd" @dragend="onFileDragEnd"
@file-item-click="onFileItemClick" @file-item-click="onFileItemClick"
@tiktok-view="(_file, idx) => openTiktokViewWithFiles(images, idx)" @tiktok-view="(_file, idx) => openTiktokViewWithFiles(images, idx)"
:full-screen-preview-image-url="images[previewIdx] ? toRawFileUrl(images[previewIdx]) : ''" :full-screen-preview-image-url="images[previewIdx] ? toImageUrl(images[previewIdx]) : ''"
:selected="multiSelectedIdxs.includes(idx)" :selected="multiSelectedIdxs.includes(idx)"
@context-menu-click="onContextMenuClickU" @context-menu-click="onContextMenuClickU"
@preview-visible-change="onPreviewVisibleChange" @preview-visible-change="onPreviewVisibleChange"

View File

@ -7,7 +7,7 @@ import { useBatchDownloadStore } from '@/store/useBatchDownloadStore'
import { useGlobalStore } from '@/store/useGlobalStore' import { useGlobalStore } from '@/store/useGlobalStore'
import { storeToRefs } from 'pinia' import { storeToRefs } from 'pinia'
import { useFilesDisplay, useHookShareState } from '@/page/fileTransfer/hook' import { useFilesDisplay, useHookShareState } from '@/page/fileTransfer/hook'
import { getFileTransferDataFromDragEvent, toRawFileUrl } from '@/util/file' import { getFileTransferDataFromDragEvent, toImageUrl } from '@/util/file'
import { axiosInst } from '@/api' import { axiosInst } from '@/api'
import { createReactiveQueue } from '@/util' import { createReactiveQueue } from '@/util'
import { message } from 'ant-design-vue' import { message } from 'ant-design-vue'
@ -73,7 +73,7 @@ const onDeleteClick = (idx: number) => {
key-field="fullpath" :item-secondary-size="itemSize.second" :gridItems="gridItems"> key-field="fullpath" :item-secondary-size="itemSize.second" :gridItems="gridItems">
<template v-slot="{ item: file, index: idx }"> <template v-slot="{ item: file, index: idx }">
<file-item :idx="idx" :file="file" :cell-width="cellWidth" enable-close-icon <file-item :idx="idx" :file="file" :cell-width="cellWidth" enable-close-icon
@close-icon-click="onDeleteClick(idx)" :full-screen-preview-image-url="toRawFileUrl(file)" @close-icon-click="onDeleteClick(idx)" :full-screen-preview-image-url="toImageUrl(file)"
:enable-right-click-menu="false" /> :enable-right-click-menu="false" />
</template> </template>
</RecycleScroller> </RecycleScroller>

View File

@ -2,6 +2,7 @@
import { getImageGenerationInfo, getImageExif } from '@/api' import { getImageGenerationInfo, getImageExif } from '@/api'
import type { FileNodeInfo } from '@/api/files' import type { FileNodeInfo } from '@/api/files'
import ExifBrowser from '@/components/ExifBrowser.vue' import ExifBrowser from '@/components/ExifBrowser.vue'
import DraggableImage from '@/components/DraggableImage.vue'
import { useGlobalStore } from '@/store/useGlobalStore' import { useGlobalStore } from '@/store/useGlobalStore'
import { useLocalStorage } from '@vueuse/core' import { useLocalStorage } from '@vueuse/core'
import type { MenuInfo } from 'ant-design-vue/lib/menu/src/interface' import type { MenuInfo } from 'ant-design-vue/lib/menu/src/interface'
@ -18,7 +19,9 @@ import {
EllipsisOutlined, EllipsisOutlined,
fullscreen, fullscreen,
SortAscendingOutlined, SortAscendingOutlined,
AppstoreOutlined AppstoreOutlined,
EditOutlined,
SettingOutlined
} from '@/icon' } from '@/icon'
import { t } from '@/i18n' import { t } from '@/i18n'
import { createReactiveQueue, unescapeHtml } from '@/util' import { createReactiveQueue, unescapeHtml } from '@/util'
@ -29,7 +32,7 @@ import { parse } from '@/util/stable-diffusion-image-metadata'
import { useFullscreenLayout } from '@/util/useFullscreenLayout' import { useFullscreenLayout } from '@/util/useFullscreenLayout'
import { useMouseInElement } from '@vueuse/core' import { useMouseInElement } from '@vueuse/core'
import { closeImageFullscreenPreview } from '@/util/imagePreviewOperation' import { closeImageFullscreenPreview } from '@/util/imagePreviewOperation'
import { openAddNewTagModal } from '@/components/functionalCallableComp' import { openAddNewTagModal, openEditPromptModal } from '@/components/functionalCallableComp'
import { prefix } from '@/util/const' import { prefix } from '@/util/const'
// @ts-ignore // @ts-ignore
import * as Pinyin from 'jian-pinyin' import * as Pinyin from 'jian-pinyin'
@ -183,7 +186,7 @@ function getParNode (p: any) {
} }
function getTextLength(text: string): number { function getTextLength(text: string): number {
// 3 // chinese characters are counted as 3 English letters
let length = 0 let length = 0
for (const char of text) { for (const char of text) {
if (/[\u4e00-\u9fa5]/.test(char)) { if (/[\u4e00-\u9fa5]/.test(char)) {
@ -434,6 +437,18 @@ Please return only tag names, do not include any other content.`
} }
} }
//
const editPromptAndReload = async () => {
await openEditPromptModal(props.file)
const path = props.file?.fullpath
if (path) {
q.tasks.forEach((v) => v.cancel())
q.pushAction(() => getImageGenerationInfo(path)).res.then((v) => {
imageGenInfo.value = v
})
}
}
</script> </script>
<template> <template>
@ -442,6 +457,7 @@ Please return only tag names, do not include any other content.`
<div v-if="lr"> <div v-if="lr">
</div> </div>
<div class="container"> <div class="container">
<div class="action-bar"> <div class="action-bar">
<div v-if="!lr" ref="dragHandle" class="icon" style="cursor: grab" :title="t('dragToMovePanel')"> <div v-if="!lr" ref="dragHandle" class="icon" style="cursor: grab" :title="t('dragToMovePanel')">
@ -453,6 +469,22 @@ Please return only tag names, do not include any other content.`
<FullscreenExitOutlined v-if="showFullContent" /> <FullscreenExitOutlined v-if="showFullContent" />
<FullscreenOutlined v-else /> <FullscreenOutlined v-else />
</div> </div>
<a-dropdown :get-popup-container="getParNode" trigger="click">
<div class="icon" style="cursor: pointer">
<SettingOutlined />
</div>
<template #overlay>
<div class="block-visibility-settings">
<div class="settings-title">{{ $t('blockVisibilitySettings') }}</div>
<div class="settings-list">
<div class="settings-item" v-for="(_, key) in global.fullscreenMenuBlockVisibility" :key="key">
<a-switch v-model:checked="global.fullscreenMenuBlockVisibility[key]" size="small" />
<span class="settings-label">{{ $t(`blockName_${key}`) }}</span>
</div>
</div>
</div>
</template>
</a-dropdown>
<div style="display: flex; flex-direction: column; align-items: center; cursor: grab" class="icon" <div style="display: flex; flex-direction: column; align-items: center; cursor: grab" class="icon"
:title="t('fullscreenview')" @click="requestFullscreen"> :title="t('fullscreenview')" @click="requestFullscreen">
<img :src="fullscreen" style="width: 21px;height: 21px;padding-bottom: 2px;" alt=""> <img :src="fullscreen" style="width: 21px;height: 21px;padding-bottom: 2px;" alt="">
@ -467,7 +499,7 @@ Please return only tag names, do not include any other content.`
</template> </template>
</a-dropdown> </a-dropdown>
<div flex-placeholder v-if="showFullContent" /> <div flex-placeholder v-if="showFullContent" />
<div v-if="showFullContent" class="action-bar"> <div block v-if="showFullContent && global.fullscreenMenuBlockVisibility.actionBar" class="action-bar">
<a-dropdown :trigger="['hover']" :get-popup-container="getParNode"> <a-dropdown :trigger="['hover']" :get-popup-container="getParNode">
<a-button>{{ t('openContextMenu') }}</a-button> <a-button>{{ t('openContextMenu') }}</a-button>
@ -516,7 +548,6 @@ Please return only tag names, do not include any other content.`
}}</a-button> }}</a-button>
<a-button <a-button
@click="analyzeTagsWithAI" @click="analyzeTagsWithAI"
type="primary"
:loading="analyzingTags" :loading="analyzingTags"
v-if="imageGenInfo && global.conf?.all_custom_tags?.length" v-if="imageGenInfo && global.conf?.all_custom_tags?.length"
> >
@ -528,11 +559,17 @@ Please return only tag names, do not include any other content.`
type="default" type="default"
> >
{{ $t('tiktokView') }} {{ $t('tiktokView') }}
</a-button> <a-button
@click="editPromptAndReload"
>
<template #icon><EditOutlined /></template>
{{ $t('editPrompt') }}
</a-button> </a-button>
</div> </div>
</div> </div>
<div class="gen-info" v-if="showFullContent"> <div class="gen-info" v-if="showFullContent">
<div class="info-tags">
<div block v-if="global.fullscreenMenuBlockVisibility.infoTags" class="info-tags">
<span class="info-tag"> <span class="info-tag">
<span class="name"> <span class="name">
{{ $t('fileName') }} {{ $t('fileName') }}
@ -554,7 +591,7 @@ Please return only tag names, do not include any other content.`
</span> </span>
</span> </span>
</div> </div>
<div class="tags-container" v-if="global.conf?.all_custom_tags"> <div block class="tags-container" v-if="global.conf?.all_custom_tags && global.fullscreenMenuBlockVisibility.tagsContainer">
<div class="sort-tag-switch" @click="tagA2ZClassify = !tagA2ZClassify"> <div class="sort-tag-switch" @click="tagA2ZClassify = !tagA2ZClassify">
<SortAscendingOutlined v-if="!tagA2ZClassify" /> <SortAscendingOutlined v-if="!tagA2ZClassify" />
<AppstoreOutlined v-else /> <AppstoreOutlined v-else />
@ -583,7 +620,7 @@ Please return only tag names, do not include any other content.`
</div> </div>
</template> </template>
</div> </div>
<div class="lr-layout-control"> <div block class="lr-layout-control" v-if="global.fullscreenMenuBlockVisibility.lrLayoutControl">
<div class="ctrl-item"> <div class="ctrl-item">
{{ $t('experimentalLRLayout') }} <a-switch v-model:checked="lr" size="small" /> {{ $t('experimentalLRLayout') }} <a-switch v-model:checked="lr" size="small" />
</div> </div>
@ -600,22 +637,57 @@ Please return only tag names, do not include any other content.`
</a-tooltip> </a-tooltip>
</template> </template>
</div> </div>
<a-tabs v-model:activeKey="promptTabActivedKey"> <!-- 可拖拽的原图 -->
<DraggableImage block v-if="global.fullscreenMenuBlockVisibility.draggableImage" :file="file">
<div class="custom-drag-trigger">
<DragOutlined class="trigger-icon" />
<span class="trigger-text">{{ $t('dragImageToTransfer') }}</span>
</div>
</DraggableImage>
<a-tabs block v-if="global.fullscreenMenuBlockVisibility.tabs" v-model:activeKey="promptTabActivedKey">
<a-tab-pane key="structedData" :tab="$t('structuredData')"> <a-tab-pane key="structedData" :tab="$t('structuredData')">
<div> <div>
<template v-if="geninfoStruct.prompt"> <template v-if="geninfoStruct.prompt">
<br /> <br />
<h3>Prompt</h3> <div class="section-header">
<h3>Prompt</h3>
<button
class="edit-section-btn"
@click="editPromptAndReload"
:title="$t('editPrompt')"
>
<EditOutlined />
</button>
</div>
<code v-html="spanWrap(geninfoStruct.prompt ?? '')"></code> <code v-html="spanWrap(geninfoStruct.prompt ?? '')"></code>
</template> </template>
<template v-if="geninfoStruct.negativePrompt"> <template v-if="geninfoStruct.negativePrompt">
<br /> <br />
<h3>Negative Prompt</h3> <div class="section-header">
<h3>Negative Prompt</h3>
<button
class="edit-section-btn"
@click="editPromptAndReload"
:title="$t('editPrompt')"
>
<EditOutlined />
</button>
</div>
<code v-html="spanWrap(geninfoStruct.negativePrompt ?? '')"></code> <code v-html="spanWrap(geninfoStruct.negativePrompt ?? '')"></code>
</template> </template>
</div> </div>
<template v-if="Object.keys(geninfoStructNoPrompts).length"> <br /> <template v-if="Object.keys(geninfoStructNoPrompts).length"> <br />
<h3>Params</h3> <div class="section-header">
<h3>Params</h3>
<button
class="edit-section-btn"
@click="editPromptAndReload"
:title="$t('editPrompt')"
>
<EditOutlined />
</button>
</div>
<table> <table>
<tr v-for="txt, key in geninfoStructNoPrompts" :key="key" class="gen-info-frag"> <tr v-for="txt, key in geninfoStructNoPrompts" :key="key" class="gen-info-frag">
<td style="font-weight: 600;text-transform: capitalize;">{{ key }}</td> <td style="font-weight: 600;text-transform: capitalize;">{{ key }}</td>
@ -629,7 +701,16 @@ Please return only tag names, do not include any other content.`
</table> </table>
</template> </template>
<template v-if="extraJsonMetaInfo && Object.keys(extraJsonMetaInfo).length"> <br /> <template v-if="extraJsonMetaInfo && Object.keys(extraJsonMetaInfo).length"> <br />
<h3>Extra Meta Info</h3> <div class="section-header">
<h3>Extra Meta Info</h3>
<button
class="edit-section-btn"
@click="editPromptAndReload"
:title="$t('editPrompt')"
>
<EditOutlined />
</button>
</div>
<table class="extra-meta-table"> <table class="extra-meta-table">
<tr v-for="(val, key) in extraJsonMetaInfo" :key="key" class="gen-info-frag"> <tr v-for="(val, key) in extraJsonMetaInfo" :key="key" class="gen-info-frag">
<td style="font-weight: 600;text-transform: capitalize;">{{ key }}</td> <td style="font-weight: 600;text-transform: capitalize;">{{ key }}</td>
@ -728,7 +809,6 @@ Please return only tag names, do not include any other content.`
.natural-text { .natural-text {
margin: 0.5em 0; margin: 0.5em 0;
line-height: 1.6em; line-height: 1.6em;
text-align: justify;
color: var(--zp-primary); color: var(--zp-primary);
} }
@ -915,4 +995,86 @@ Please return only tag names, do not include any other content.`
flex-wrap: nowrap; flex-wrap: nowrap;
} }
} }
.section-header {
display: flex;
align-items: center;
margin-bottom: 8px;
gap: 6px;
h3 {
margin: 0;
}
.edit-section-btn {
margin: 0;
padding: 2px 6px;
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.15);
border-radius: 4px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s ease;
color: var(--zp-primary);
font-size: 12px;
line-height: 1;
min-width: 22px;
min-height: 22px;
&:hover {
background: rgba(255, 255, 255, 0.1);
border-color: rgba(255, 255, 255, 0.25);
color: var(--zp-luminous);
transform: scale(1.05);
}
&:active {
transform: scale(0.95);
}
:deep(.anticon) {
font-size: 12px;
}
}
}
.block-visibility-settings {
background: var(--zp-primary-background);
border-radius: 8px;
padding: 12px;
min-width: 200px;
max-width: 300px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
border: 1px solid var(--zp-secondary);
.settings-title {
font-size: 14px;
font-weight: 600;
margin-bottom: 12px;
padding-bottom: 8px;
border-bottom: 1px solid var(--zp-secondary);
color: var(--zp-primary);
}
.settings-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.settings-item {
display: flex;
align-items: center;
gap: 8px;
padding: 4px 0;
.settings-label {
flex: 1;
font-size: 13px;
color: var(--zp-primary);
}
}
}
</style> </style>

View File

@ -16,7 +16,7 @@ import {
useGenInfoDiff useGenInfoDiff
} from './hook' } from './hook'
import { SearchSelect } from 'vue3-ts-util' import { SearchSelect } from 'vue3-ts-util'
import { toRawFileUrl } from '@/util/file' import { toImageUrl } from '@/util/file'
import { openTiktokViewWithFiles } from '@/util/tiktokHelper' import { openTiktokViewWithFiles } from '@/util/tiktokHelper'
import 'multi-nprogress/nprogress.css' import 'multi-nprogress/nprogress.css'
@ -385,7 +385,7 @@ onMounted(() => {
<template v-slot="{ item: file, index: idx }"> <template v-slot="{ item: file, index: idx }">
<!-- idx 和file有可能丢失 --> <!-- idx 和file有可能丢失 -->
<file-item :idx="parseInt(idx)" :file="file" <file-item :idx="parseInt(idx)" :file="file"
:full-screen-preview-image-url="sortedFiles[previewIdx] ? toRawFileUrl(sortedFiles[previewIdx]) : ''" :full-screen-preview-image-url="sortedFiles[previewIdx] ? toImageUrl(sortedFiles[previewIdx]) : ''"
v-model:show-menu-idx="showMenuIdx" :selected="multiSelectedIdxs.includes(idx)" :cell-width="cellWidth" v-model:show-menu-idx="showMenuIdx" :selected="multiSelectedIdxs.includes(idx)" :cell-width="cellWidth"
@file-item-click="onFileItemClick" @dragstart="onFileDragStart" @dragend="onFileDragEnd" @file-item-click="onFileItemClick" @dragstart="onFileDragStart" @dragend="onFileDragEnd"
@preview-visible-change="onPreviewVisibleChange" @context-menu-click="onContextMenuClick" @preview-visible-change="onPreviewVisibleChange" @context-menu-click="onContextMenuClick"

View File

@ -4,7 +4,7 @@ import { RecycleScroller } from '@zanllp/vue-virtual-scroller'
import '@zanllp/vue-virtual-scroller/dist/vue-virtual-scroller.css' import '@zanllp/vue-virtual-scroller/dist/vue-virtual-scroller.css'
import FileItem from '@/components/FileItem.vue' import FileItem from '@/components/FileItem.vue'
import { useFilesDisplay, useHookShareState } from '@/page/fileTransfer/hook' import { useFilesDisplay, useHookShareState } from '@/page/fileTransfer/hook'
import { getFileTransferDataFromDragEvent, toRawFileUrl, uniqueFile } from '@/util/file' import { getFileTransferDataFromDragEvent, toImageUrl, uniqueFile } from '@/util/file'
import { ref, watchEffect, toRaw } from 'vue' import { ref, watchEffect, toRaw } from 'vue'
import { GridViewFile, useGlobalStore } from '@/store/useGlobalStore' import { GridViewFile, useGlobalStore } from '@/store/useGlobalStore'
import { useTagStore } from '@/store/useTagStore' import { useTagStore } from '@/store/useTagStore'
@ -52,7 +52,7 @@ watchEffect(() => {
key-field="fullpath" :item-secondary-size="itemSize.second" :gridItems="gridItems"> key-field="fullpath" :item-secondary-size="itemSize.second" :gridItems="gridItems">
<template v-slot="{ item: file, index: idx }"> <template v-slot="{ item: file, index: idx }">
<file-item :idx="idx" :file="file" :cell-width="cellWidth" :enable-close-icon="props.removable" <file-item :idx="idx" :file="file" :cell-width="cellWidth" :enable-close-icon="props.removable"
@close-icon-click="onDeleteClick(idx)" :full-screen-preview-image-url="toRawFileUrl(file)" @close-icon-click="onDeleteClick(idx)" :full-screen-preview-image-url="toImageUrl(file)"
:extra-tags="file?.tags?.map(tag.tagConvert)" :enable-right-click-menu="false" /> :extra-tags="file?.tags?.map(tag.tagConvert)" :enable-right-click-menu="false" />
</template> </template>
</RecycleScroller> </RecycleScroller>

View File

@ -4,7 +4,7 @@ import { RecycleScroller } from '@zanllp/vue-virtual-scroller'
import '@zanllp/vue-virtual-scroller/dist/vue-virtual-scroller.css' import '@zanllp/vue-virtual-scroller/dist/vue-virtual-scroller.css'
import FileItem from '@/components/FileItem.vue' import FileItem from '@/components/FileItem.vue'
import { useFileItemActions, useFilesDisplay, useFileTransfer, useHookShareState, useKeepMultiSelect, usePreview } from '@/page/fileTransfer/hook' import { useFileItemActions, useFilesDisplay, useFileTransfer, useHookShareState, useKeepMultiSelect, usePreview } from '@/page/fileTransfer/hook'
import { toRawFileUrl } from '@/util/file' import { toImageUrl } from '@/util/file'
import { ref, onMounted } from 'vue' import { ref, onMounted } from 'vue'
import { GridViewFile, useGlobalStore } from '@/store/useGlobalStore' import { GridViewFile, useGlobalStore } from '@/store/useGlobalStore'
import { getRandomImages } from '@/api/db' import { getRandomImages } from '@/api/db'
@ -142,7 +142,7 @@ const onContextMenuClickU: typeof onContextMenuClick = async (e, file, idx) => {
<RecycleScroller ref="scroller" class="file-list" :items="files.slice()" :item-size="itemSize.first" <RecycleScroller ref="scroller" class="file-list" :items="files.slice()" :item-size="itemSize.first"
key-field="fullpath" :item-secondary-size="itemSize.second" :gridItems="gridItems" @scroll="onScroll"> key-field="fullpath" :item-secondary-size="itemSize.second" :gridItems="gridItems" @scroll="onScroll">
<template v-slot="{ item: file, index: idx }"> <template v-slot="{ item: file, index: idx }">
<file-item :idx="idx" :file="file" :cell-width="cellWidth" :full-screen-preview-image-url="images[previewIdx] ? toRawFileUrl(images[previewIdx]) : '' <file-item :idx="idx" :file="file" :cell-width="cellWidth" :full-screen-preview-image-url="images[previewIdx] ? toImageUrl(images[previewIdx]) : ''
" @context-menu-click="onContextMenuClickU" @preview-visible-change="onPreviewVisibleChange" " @context-menu-click="onContextMenuClickU" @preview-visible-change="onPreviewVisibleChange"
:is-selected-mutil-files="multiSelectedIdxs.length > 1" :selected="multiSelectedIdxs.includes(idx)" :is-selected-mutil-files="multiSelectedIdxs.length > 1" :selected="multiSelectedIdxs.includes(idx)"
@file-item-click="onFileItemClick" @tiktok-view="(_file, idx) => openTiktokViewWithFiles(files, idx)" /> @file-item-click="onFileItemClick" @tiktok-view="(_file, idx) => openTiktokViewWithFiles(files, idx)" />

View File

@ -168,6 +168,15 @@ export type Shortcut = Record<`toggle_tag_${string}` | 'delete' | 'download' | `
export type DefaultInitinalPage = `workspace_snapshot_${string}` | 'empty' | 'last-workspace-state' export type DefaultInitinalPage = `workspace_snapshot_${string}` | 'empty' | 'last-workspace-state'
export type FullscreenMenuBlockVisibility = {
actionBar: boolean
infoTags: boolean
tagsContainer: boolean
lrLayoutControl: boolean
draggableImage: boolean
tabs: boolean
}
export const copyPane = (pane: TabPane) => { export const copyPane = (pane: TabPane) => {
return cloneDeep({ return cloneDeep({
...pane, ...pane,
@ -226,7 +235,8 @@ export const presistKeys = [
'magicSwitchTiktokView', 'magicSwitchTiktokView',
'showRandomImageInStartup', 'showRandomImageInStartup',
'showTiktokNavigator', 'showTiktokNavigator',
'autoUpdateIndex' 'autoUpdateIndex',
'fullscreenMenuBlockVisibility'
] ]
function cellWidthMap(x: number): number { function cellWidthMap(x: number): number {
@ -381,6 +391,16 @@ export const useGlobalStore = defineStore(
const showRandomImageInStartup = ref(true) const showRandomImageInStartup = ref(true)
const showTiktokNavigator = ref(false) const showTiktokNavigator = ref(false)
// Fullscreen menu block visibility settings
const fullscreenMenuBlockVisibility = ref<FullscreenMenuBlockVisibility>({
actionBar: true,
infoTags: true,
tagsContainer: true,
lrLayoutControl: true,
draggableImage: true,
tabs: true
})
// ===== Organize Jobs Management ===== // ===== Organize Jobs Management =====
interface OrganizeJob { interface OrganizeJob {
job_id: string job_id: string
@ -467,6 +487,7 @@ export const useGlobalStore = defineStore(
magicSwitchTiktokView, magicSwitchTiktokView,
showRandomImageInStartup, showRandomImageInStartup,
autoUpdateIndex: ref(true), autoUpdateIndex: ref(true),
fullscreenMenuBlockVisibility,
// Organize jobs // Organize jobs
activeOrganizeJobs, activeOrganizeJobs,
showOrganizePanel, showOrganizePanel,

View File

@ -7,6 +7,11 @@ const encode = encodeURIComponent
export const toRawFileUrl = (file: FileNodeInfo, download = false) => export const toRawFileUrl = (file: FileNodeInfo, download = false) =>
`${apiBase.value}/file?path=${encode(file.fullpath)}&t=${encode(file.date)}${download ? `&disposition=${encode(file.name)}` : '' `${apiBase.value}/file?path=${encode(file.fullpath)}&t=${encode(file.date)}${download ? `&disposition=${encode(file.name)}` : ''
}` }`
export const toImageUrl = (file: FileNodeInfo) => {
return `${apiBase.value}/img/${encode(file.name)}?path=${encode(file.fullpath)}&t=${encode(file.date)}`
}
export const toImageThumbnailUrl = (file: FileNodeInfo, size: string = '512x512') => { export const toImageThumbnailUrl = (file: FileNodeInfo, size: string = '512x512') => {
return `${apiBase.value}/image-thumbnail?path=${encode(file.fullpath)}&size=${size}&t=${encode( return `${apiBase.value}/image-thumbnail?path=${encode(file.fullpath)}&size=${size}&t=${encode(
file.date file.date

View File

@ -107,6 +107,8 @@ export const { useEventListen: useGlobalEventListen, eventEmitter: globalEvents
closeTabPane(tabIdx: number, key: string): void closeTabPane(tabIdx: number, key: string): void
updateGlobalSettingDone(): void updateGlobalSettingDone(): void
refreshFileView(args?: { paths?: string[] }): void refreshFileView(args?: { paths?: string[] }): void
openPromptEditor(data: { file: { name: string; fullpath: string }}): void
promptEditorUpdated(): void
}>() }>()
type AsyncFunction<T> = (...args: any[]) => Promise<T> type AsyncFunction<T> = (...args: any[]) => Promise<T>

View File

@ -79,7 +79,7 @@ export function parse(parameters: string): ImageMeta {
const extraJsonMetaInfoMatch = parameters.match(/\nextraJsonMetaInfo:\s*(\{[\s\S]*\})\s*$/); const extraJsonMetaInfoMatch = parameters.match(/\nextraJsonMetaInfo:\s*(\{[\s\S]*\})\s*$/);
if (extraJsonMetaInfoMatch) { if (extraJsonMetaInfoMatch) {
try { try {
metadata.extraJsonMetaInfo = JSON.parse(extraJsonMetaInfoMatch[1]); metadata.extraJsonMetaInfo = JSON.parse(unescapeHtml(extraJsonMetaInfoMatch[1]));
// 从原始参数中移除 extraJsonMetaInfo 部分 // 从原始参数中移除 extraJsonMetaInfo 部分
parameters = parameters.replace(/\nextraJsonMetaInfo:\s*\{[\s\S]*\}\s*$/, ''); parameters = parameters.replace(/\nextraJsonMetaInfo:\s*\{[\s\S]*\}\s*$/, '');
} catch (e) { } catch (e) {