58 Commits
Author SHA1 Message Date
Yeomai 8d3616391a chore: release v1.5.3
Publish Docker image / build (linux/amd64, ubuntu-latest, amd64) (push) Canceled after 0s
Publish Docker image / build (linux/arm64, ubuntu-24.04-arm, arm64) (push) Canceled after 0s
Publish Docker image / merge (push) Canceled after 0s
2026-10-18 20:52:50 +08:00
Yeomai b4535d6aea 备份与恢复 2026-10-18 20:28:46 +08:00
Yeomai 5d5fecde1b 数据表 2026-10-18 19:51:59 +08:00
Yeomai 88e5707ab6 安装路径修复 2026-10-18 19:38:47 +08:00
Yeomai 5e024f77a2 启动脚本 2026-10-18 17:20:09 +08:00
Yeomai 202a81d0a9 版本修改 2026-10-18 17:02:34 +08:00
Yeomai a4944a98da 显示修改 2026-10-18 16:59:13 +08:00
Yeomai 1d2d16e710 同步功能更新 2026-10-18 16:50:55 +08:00
Yeomai 21f0dc2830 样式调整 2026-10-18 16:22:52 +08:00
Yeomai ee8772c1b7 打包构建 2026-10-18 16:17:01 +08:00
Yeomai a6e45e0cb1 1.5.1版本,体验性修复、打包fpk、docker镜像、windows包 2026-10-18 15:32:56 +08:00
Yeomai 813407de30 用户资料/重置密码/用户管理 2026-10-18 14:29:49 +08:00
tianmo 5c1f640a2b 批量打标签 2026-10-17 19:33:26 +08:00
tianmo 92aaf6fbb8 同步设置 2026-10-17 19:01:13 +08:00
tianmo 832261739f 上传标签 2026-10-17 18:22:55 +08:00
tianmo 6494ad2918 同步处理 2026-10-17 18:15:42 +08:00
tianmo 9939943e31 同步修改 2026-10-17 17:52:08 +08:00
tianmo d647453085 同步记录 2026-10-17 17:31:30 +08:00
tianmo 818c980dec 修复关闭独立媒体域名时网站域名被误清空、生效地址回落到请求地址的问题 2026-10-17 16:41:10 +08:00
tianmo a2289a564f feat: 完善媒体管理与低内存环境稳定性
- 统一图片、视频和文件的上传策略,支持文件大小、格式和限流配置
- 新上传的图片、视频和文件默认保存到本地磁盘,并支持修改默认存储后端
- 图库、视频库和文件库新增搜索与批量删除功能
- 更新视频与文件 API 文档,并完善服务端扩展名和内容类型校验
- 完善备份恢复统计,分别展示媒体记录和存储对象进度
- 完善用户级清空存储流程,清理当前用户的媒体、任务和缓存
- 修复清空存储后最近任务残留以及媒体逻辑遗漏视频和文件的问题
- 增加站点同步异常恢复和超时兜底处理
- 缩略图 FFmpeg 限制为单线程并增加 10 秒超时保护
- 低内存服务器开启 HLS 转码时显示风险提示
- 完善 m3u8 临时合并文件的下载后、到期和启动时清理
- 更新中英文更新日志,并将版本号提升至 1.5.0
2026-10-17 16:34:38 +08:00
tianmo 76328fa058 feat: add thumbnail timeout and low-memory warning 2026-10-17 16:19:18 +08:00
tianmo 7b2fae6335 忽视文件修改 2026-10-17 16:09:49 +08:00
tianmo fa7c8244b0 服务器配置 2026-10-17 16:09:31 +08:00
tianmo a1b4a63552 同步异常兜底 2026-10-17 15:08:21 +08:00
tianmo 9cb8552a99 站点同步 2026-10-17 14:41:32 +08:00
tianmo a64be50b73 feat: 完善媒体管理与备份迁移流程
- 视频上传生成缩略图并提供占位封面和失败原因展示
- 支持 HLS 按需重新封装 MP4,并限制临时文件生命周期与访问权限
- 完善视频、文件 API 文档、搜索、批量删除和单次转码质量参数
- 修复备份恢复与跨后端迁移遗漏缩略图、HLS 清单和分片的问题
- 将备份任务、归档和下载权限按用户隔离
- 增加仅清理当前用户数据的高危存储清空流程和二次文字确认
- 修复 S3 中文文件名元数据及 CLI 数据库连接问题
- 补充备份恢复、用户隔离、媒体资源和转码相关测试
2026-10-17 13:46:02 +08:00
Yeomai 9ebab8270c 删除 .github/workflows/release.yml
删除release工作流
2026-10-17 10:04:55 +08:00
Yeomai 75a1ea5fbc 删除 .github/workflows/ci.yml
删除ci工作流
2026-10-17 10:03:54 +08:00
Yeomai ede8cb7031 备份导出修复
ci / ci (22, ubuntu-latest) (push) Canceled after 0s
2026-10-17 03:29:06 +08:00
Yeomai 89ea2fb25d feat: 新增视频床与文件床,视频支持 HLS 转码
ci / ci (22, ubuntu-latest) (push) Canceled after 0s
文件床
- 新增 files/ 存储前缀与 media_type 索引列,图片、视频、文件三类资源独立
- 新增文件上传、列表、删除、批量删除接口,禁止 html/htm/svg
- 支持公开直链与 Token 访问两种模式,后台可切换,直链强制附件下载

视频床
- 新增 videos/ 存储前缀与视频上传接口,单文件上限 500MB
- 接入 ffmpeg 转码队列,并发数与单任务核心数可配,默认串行
- 五档转码质量:直接封装 / 速度 / 平衡 / 质量 / 压缩(压缩档可调码率比例)
- 码率控制避免转码后体积反超源文件,音频跟随源音轨不放大不压缩
- 转码进度实时上报,成功后删除源文件,失败时保留源文件与可用链接
- 上传时自动抽帧生成封面,视频与封面各有独立链接
- 删除视频时中断转码并清理切片、封面与源文件

媒体库
- 图库、视频库、文件库三套独立统计,含趋势、占用与来源分布
- 统一筛选、多选、批量删除、批量标签、网格/列表切换,显示方式按类型分别记忆
- 视频详情对齐图片详情,视频库支持在线播放(hls.js,兼容回退原文件)

修复
- 清理无主的切片、封面与开发期遗留的 ffmpeg 进程
- 源文件按设计删除后,索引不再被启动孤儿清理误删
- 视频统计口径与列表一致,转码中的视频不计入统计
- 转码失败时回收已写入的部分切片,避免残留
- 设置页停用检查更新(与上游版本号已不对应)

其他
- Docker 镜像安装 ffmpeg
- git remote 指向内网仓库
2026-10-17 03:14:57 +08:00
O96u 05cc3895f1 release: v1.4.4
docs / build (push) Canceled after 0s
docs / deploy (push) Canceled after 0s
Publish Docker image / build (linux/amd64, ubuntu-latest, amd64) (push) Canceled after 0s
Publish Docker image / build (linux/arm64, ubuntu-24.04-arm, arm64) (push) Canceled after 0s
Publish Docker image / merge (push) Canceled after 0s
GitHub Release / release (push) Canceled after 0s
ci / ci (22, ubuntu-latest) (push) Canceled after 0s
Update Cap login verification so hashwx challenges solve, while older SHA-256 keys still work.
2026-10-10 13:26:42 +08:00
O96u 424bfd9090 fix: give each test worker its own SQLite database
Parallel Vitest workers were opening data/pichost.db together, so host-isolation failed with database is locked.
2026-10-09 15:52:03 +08:00
O96u 10baf9d489 fix: run Telegram storage tests on an isolated database
CI workers were sharing one SQLite file, so schema setup locked and storage_object_refs was missing.
2026-10-09 15:47:09 +08:00
O96u cc19dc6435 release: v1.4.3
GitHub Release / release (push) Canceled after 0s
ci / ci (22, ubuntu-latest) (push) Canceled after 0s
Publish Docker image / build (linux/amd64, ubuntu-latest, amd64) (push) Canceled after 0s
Publish Docker image / build (linux/arm64, ubuntu-24.04-arm, arm64) (push) Canceled after 0s
Publish Docker image / merge (push) Canceled after 0s
Add a Telegram channel or group storage backend, with proxied image URLs and an optional API proxy.
2026-10-09 15:39:24 +08:00
O96u 4ed4692f5e release: v1.4.2
Publish Docker image / build (linux/amd64, ubuntu-latest, amd64) (push) Canceled after 0s
Publish Docker image / build (linux/arm64, ubuntu-24.04-arm, arm64) (push) Canceled after 0s
Publish Docker image / merge (push) Canceled after 0s
GitHub Release / release (push) Canceled after 0s
ci / ci (22, ubuntu-latest) (push) Canceled after 0s
Fix batch tagging and upload token auth; single API token and tagNames-only upload; tag management sort and responsive layout.
2026-09-23 15:38:46 +08:00
O96u 6cc4f27f7d release: v1.4.1 — 上传策略设置、保留原图与上传 tagNames
GitHub Release / release (push) Canceled after 0s
Publish Docker image / build (linux/amd64, ubuntu-latest, amd64) (push) Canceled after 0s
Publish Docker image / build (linux/arm64, ubuntu-24.04-arm, arm64) (push) Canceled after 0s
Publish Docker image / merge (push) Canceled after 0s
ci / ci (22, ubuntu-latest) (push) Canceled after 0s
管理员可在设置页配置上传大小、格式、限流与保留原图;上传 API 支持按标签名称打标。
2026-09-16 10:03:05 +08:00
O96u d0eb364645 fix: host-isolation 单测显式传入 hideFolder 避免 CI 锁库
并行跑测时不再通过 isHideFolderInUrl 打开本地 SQLite。
2026-09-09 15:08:27 +08:00
O96u 91d71a1b40 fix: 修复 CI 活动日志冒烟测试在空数据库下失败
listActivityLogs 会 JOIN storage_backends;CI 无 data/ 目录时需在查询前初始化存储 schema。
2026-09-09 14:57:11 +08:00
O96u 737235acff release: v1.4.0 — 标签系统、操作日志扩展与文档截图更新
ci / ci (22, ubuntu-latest) (push) Canceled after 0s
GitHub Release / release (push) Canceled after 0s
Publish Docker image / build (linux/amd64, ubuntu-latest, amd64) (push) Canceled after 0s
Publish Docker image / build (linux/arm64, ubuntu-24.04-arm, arm64) (push) Canceled after 0s
Publish Docker image / merge (push) Canceled after 0s
- 每用户标签与彩色打标,图库 OR/AND 筛选与批量打标
- 操作日志记录登录、删除、设置与标签操作
- API 标签/打标接口;README 与文档站截图同步
2026-09-09 14:46:52 +08:00
O96u f17d79b236 release: v1.3.0 — 整站备份恢复、跨后端同步与任务中心
ci / ci (22, ubuntu-latest) (push) Canceled after 0s
GitHub Release / release (push) Canceled after 0s
Publish Docker image / build (linux/amd64, ubuntu-latest, amd64) (push) Canceled after 0s
Publish Docker image / build (linux/arm64, ubuntu-24.04-arm, arm64) (push) Canceled after 0s
Publish Docker image / merge (push) Canceled after 0s
2026-09-07 17:05:12 +08:00
O96u b05d572221 release: v1.2.8 — 双域名保存确认与 clear-domains 恢复
GitHub Release / release (push) Canceled after 0s
ci / ci (22, ubuntu-latest) (push) Canceled after 0s
Publish Docker image / build (linux/amd64, ubuntu-latest, amd64) (push) Canceled after 0s
Publish Docker image / build (linux/arm64, ubuntu-24.04-arm, arm64) (push) Canceled after 0s
Publish Docker image / merge (push) Canceled after 0s
启用双域名时以确认框提示锁死风险与恢复方式,不再强制校验 Host。
新增 clear-domains 运维 CLI 与相关文档。
2026-09-04 14:46:37 +08:00
O96u 4789d19d06 docs: 补充 v1.2.7 登录验证(滑块 / Turnstile / Cap)文档 2026-09-03 13:57:20 +08:00
O96u 430c4de0f4 release: v1.2.7 — 登录人机验证(滑块 / Turnstile / Cap)
Publish Docker image / build (linux/amd64, ubuntu-latest, amd64) (push) Canceled after 0s
Publish Docker image / build (linux/arm64, ubuntu-24.04-arm, arm64) (push) Canceled after 0s
Publish Docker image / merge (push) Canceled after 0s
ci / ci (22, ubuntu-latest) (push) Canceled after 0s
GitHub Release / release (push) Canceled after 0s
2026-09-03 13:53:43 +08:00
O96u 8408d01197 release: v1.2.6 — 新 UI 体验优化与存储标识规则
GitHub Release / release (push) Canceled after 0s
ci / ci (22, ubuntu-latest) (push) Canceled after 0s
Publish Docker image / build (linux/amd64, ubuntu-latest, amd64) (push) Canceled after 0s
Publish Docker image / build (linux/arm64, ubuntu-24.04-arm, arm64) (push) Canceled after 0s
Publish Docker image / merge (push) Canceled after 0s
2026-09-01 21:31:02 +08:00
O96u ac65c1eb9d refactor: 重构设置、API、图库与存储页面前端结构。
将操作日志并入设置侧栏,补齐检查更新提示与版本接口刷新;拆分各模块组件并修复图库搜索分页变量。
2026-09-01 17:15:02 +08:00
O96u 270af4e707 release: v1.2.5 — 图库页、图片详情弹层与列表体验优化
ci / ci (22, ubuntu-latest) (push) Canceled after 0s
Publish Docker image / build (linux/amd64, ubuntu-latest, amd64) (push) Canceled after 0s
Publish Docker image / build (linux/arm64, ubuntu-24.04-arm, arm64) (push) Canceled after 0s
Publish Docker image / merge (push) Canceled after 0s
GitHub Release / release (push) Canceled after 0s
2026-08-31 16:05:53 +08:00
O96u 81dcc2c15e release: v1.2.4 — 修复未配置防盗链时外链图裂
GitHub Release / release (push) Canceled after 0s
Publish Docker image / build (linux/amd64, ubuntu-latest, amd64) (push) Canceled after 0s
Publish Docker image / build (linux/arm64, ubuntu-24.04-arm, arm64) (push) Canceled after 0s
Publish Docker image / merge (push) Canceled after 0s
ci / ci (22, ubuntu-latest) (push) Canceled after 0s
2026-08-31 15:02:42 +08:00
O96u 20afda5869 release: v1.2.3 — 修复简短图片链接在按年/月存储下 404
ci / ci (22, ubuntu-latest) (push) Canceled after 0s
Publish Docker image / build (linux/amd64, ubuntu-latest, amd64) (push) Canceled after 0s
Publish Docker image / build (linux/arm64, ubuntu-24.04-arm, arm64) (push) Canceled after 0s
Publish Docker image / merge (push) Canceled after 0s
GitHub Release / release (push) Canceled after 0s
开启简短链接时纯文件名直链先经 SQLite 索引反查完整 key,不再误解析为扁平 images/文件名路径。
2026-08-30 12:33:33 +08:00
O96u c9c34515dd release: v1.2.2 — 双域名配置与 Host 隔离修复
GitHub Release / release (push) Canceled after 0s
Publish Docker image / build (linux/amd64, ubuntu-latest, amd64) (push) Canceled after 0s
Publish Docker image / build (linux/arm64, ubuntu-24.04-arm, arm64) (push) Canceled after 0s
Publish Docker image / merge (push) Canceled after 0s
ci / ci (22, ubuntu-latest) (push) Canceled after 0s
修复设置页误清空管理域、配置与生效 URL 混淆及第三 Host 绕过隔离;加固 Settings API 与中间件,并更新文档站。
2026-08-30 10:57:26 +08:00
O96u 6c6766221b docs: refresh GitHub About description in metadata and FAQ 2026-08-29 12:04:05 +08:00
O96u 58a8e2082f fix: docs Pages deploy permissions and document GitHub Pages setup
Deploy job failed because Pages source must be GitHub Actions; document
About/description guidance and fix workflow job permissions.
2026-08-29 11:53:20 +08:00
O96u 9b0a3f4944 fix: trigger docs workflow when lockfile changes
docs:build uses npm ci; include package.json and package-lock.json in paths.
2026-08-29 11:50:20 +08:00
O96u e9f1cde06c fix: sync package-lock.json for vitepress devDependency
ci / ci (22, ubuntu-latest) (push) Canceled after 0s
GitHub Release / release (push) Canceled after 0s
Publish Docker image / build (linux/amd64, ubuntu-latest, amd64) (push) Canceled after 0s
Publish Docker image / build (linux/arm64, ubuntu-24.04-arm, arm64) (push) Canceled after 0s
Publish Docker image / merge (push) Canceled after 0s
npm ci failed in CI because vitepress was added to package.json without
updating the lockfile.
2026-08-29 11:46:56 +08:00
O96u ff20f97522 release: v1.2.1 — login captcha, docs site, and bug fixes
Add slider captcha for web login, VitePress documentation on GitHub Pages,
and fixes for gallery delete refresh, pagination jump, and copy-format persistence.
2026-08-29 11:42:18 +08:00
O96u 469d3be735 release: v1.2.0 — unified images/ storage and legacy migration
ci / ci (22, ubuntu-latest) (push) Canceled after 0s
Publish Docker image / build (linux/amd64, ubuntu-latest, amd64) (push) Canceled after 0s
Publish Docker image / build (linux/arm64, ubuntu-24.04-arm, arm64) (push) Canceled after 0s
Publish Docker image / merge (push) Canceled after 0s
GitHub Release / release (push) Canceled after 0s
Consolidate local images under data/images/, add docker migrate CLI,
startup index sync with orphan cleanup, and remove upload folder UI/API.
2026-08-28 17:18:03 +08:00
O96u 3dee0437e6 release: v1.1.5 — gallery preview reliability fixes
ci / ci (22, ubuntu-latest) (push) Canceled after 0s
Publish Docker image / build (linux/amd64, ubuntu-latest, amd64) (push) Canceled after 0s
Publish Docker image / build (linux/arm64, ubuntu-24.04-arm, arm64) (push) Canceled after 0s
Publish Docker image / merge (push) Canceled after 0s
GitHub Release / release (push) Canceled after 0s
Bump version to 1.1.5. Retry gallery thumbnails after transient failures,
verify storage before serving, and stop caching image 404/403 responses.
2026-08-28 14:25:40 +08:00
O96u f25c89855f fix: gallery preview retries and avoid caching failed image responses
Verify storage files before serving from the index, disable CDN caching
on image 404/403 and host-isolation blocks, and retry gallery thumbnails
after transient load failures.
2026-08-28 14:25:04 +08:00
O96u b0a5fbfca5 release: v1.1.4 — storage path layout and bidirectional domain isolation
Bump version to 1.1.4. Add flat vs date storage paths, basename-only
hide-folder URLs, and block image paths on the site host when domain
separation is active.
2026-08-28 09:59:13 +08:00
396 changed files with 48437 additions and 4075 deletions

No files matched your search

+7
View File
@@ -5,5 +5,12 @@ node_modules
.github .github
data data
dist dist
dist-fpk
dist-windows
dist-docker
packaging
scripts
.env .env
*.md *.md
._*
.DS_Store
+15
View File
@@ -30,8 +30,23 @@
# 服务端 WebP 压缩质量 1-100(默认 80;也可在设置页配置) # 服务端 WebP 压缩质量 1-100(默认 80;也可在设置页配置)
# WEBP_QUALITY=80 # WEBP_QUALITY=80
# 上传限制(也可在设置 → 访问控制配置;环境变量优先级更高)
# UPLOAD_IMAGE_MAX_FILE_SIZE_MB=10
# UPLOAD_VIDEO_MAX_FILE_SIZE_MB=500
# UPLOAD_FILE_MAX_FILE_SIZE_MB=100
# ALLOWED_MIME_TYPES=image/jpeg,image/png,image/webp,image/gif,image/svg+xml,image/x-icon
# UPLOAD_RATE_IP_MAX=60
# UPLOAD_RATE_TOKEN_MAX=120
# UPLOAD_RATE_WINDOW_MINUTES=15
# LOGIN_RATE_MAX=10
# LOGIN_RATE_WINDOW_MINUTES=15
# PRESERVE_ORIGINAL_UPLOAD=false
# 自动删除超过 N 天的图片(0 表示关闭;也可在设置页配置) # 自动删除超过 N 天的图片(0 表示关闭;也可在设置页配置)
# AUTO_DELETE_DAYS=0 # AUTO_DELETE_DAYS=0
# 仅本地开发:设为 true 可绕过登录验证(跳过 /setup,一般无需开启) # 仅本地开发:设为 true 可绕过登录验证(跳过 /setup,一般无需开启)
# DEV_BYPASS_ACCESS=false # DEV_BYPASS_ACCESS=false
# 登录人机验证(滑块 / Turnstile / Cap)在后台「设置 → 访问控制」配置,无环境变量。
# 配置错误无法登录时:docker exec pichost slider(或 npm run slider)恢复为本地滑块。
+2 -2
View File
@@ -1,6 +1,6 @@
{ {
"description": "Self-hosted image hosting for your NAS / home server — Nuxt admin UI, local disk storage, server-side WebP compression, referer protection, Twikoo/EasyImage compatible.", "description": "Lightweight self-hosted image hosting — Docker, multi-user, gallery & API, local disk or S3-compatible storage.",
"homepage": "https://github.com/O96u/PicHost#readme", "homepage": "https://o96u.github.io/PicHost/",
"topics": [ "topics": [
"docker", "docker",
"image-hosting", "image-hosting",
-39
View File
@@ -1,39 +0,0 @@
name: ci
on: push
jobs:
ci:
runs-on: ${{ matrix.os }}
strategy:
matrix:
os: [ubuntu-latest]
node: [22]
steps:
- name: Checkout
uses: actions/checkout@v7
- name: Install node
uses: actions/setup-node@v6
with:
node-version: ${{ matrix.node }}
cache: npm
- name: Install npm 11 (lockfile generated with npm 11)
run: |
npm install -g npm@11.12.1
npm --version
- name: Install dependencies
run: npm ci
- name: Lint
run: npm run lint
- name: Typecheck
run: npm run typecheck
- name: Test
run: npm run test
+68
View File
@@ -0,0 +1,68 @@
name: docs
on:
push:
branches: [main]
paths:
- 'docs-site/**'
- 'docs/screenshots/**'
- '.github/workflows/docs.yml'
- 'package.json'
- 'package-lock.json'
workflow_dispatch:
permissions:
contents: read
pages: write
id-token: write
concurrency:
group: pages
cancel-in-progress: false
jobs:
build:
runs-on: ubuntu-latest
permissions:
contents: read
pages: write
id-token: write
steps:
- name: Checkout
uses: actions/checkout@v7
- name: Install node
uses: actions/setup-node@v6
with:
node-version: 22
cache: npm
- name: Install npm 11
run: |
npm install -g npm@11.12.1
npm --version
- name: Install dependencies
run: npm ci
- name: Build docs
run: npm run docs:build
- name: Upload Pages artifact
uses: actions/upload-pages-artifact@v4
with:
path: docs-site/.vitepress/dist
deploy:
needs: build
runs-on: ubuntu-latest
permissions:
pages: write
id-token: write
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v4
-71
View File
@@ -1,71 +0,0 @@
name: GitHub Release
on:
push:
tags:
- 'v*'
workflow_dispatch:
inputs:
tag:
description: '要发布 Release 的标签(如 v1.0.1)'
required: true
type: string
permissions:
contents: write
jobs:
release:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v7
with:
ref: ${{ github.event_name == 'workflow_dispatch' && inputs.tag || github.ref }}
- name: Setup Node
uses: actions/setup-node@v6
with:
node-version: 22
cache: npm
- name: Install npm 11
run: npm install -g npm@11.12.1
- name: Install dependencies
run: npm ci
- name: Extract release notes from CHANGELOG
id: notes
run: |
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
TAG="${{ inputs.tag }}"
else
TAG="${GITHUB_REF#refs/tags/}"
fi
VERSION="${TAG#v}"
BODY_FILE="${RUNNER_TEMP}/release-notes.md"
awk -v ver="$VERSION" '
/^## \[/ {
if (found) exit
if ($0 ~ "\\[" ver "\\]") found = 1
next
}
found { print }
' CHANGELOG.md > "$BODY_FILE"
if [ ! -s "$BODY_FILE" ]; then
echo "未在 CHANGELOG.md 找到版本 ${VERSION},使用自动生成说明。" >&2
echo "## PicHost ${TAG}" > "$BODY_FILE"
fi
echo "tag=${TAG}" >> "$GITHUB_OUTPUT"
echo "body_path=${BODY_FILE}" >> "$GITHUB_OUTPUT"
- name: Create GitHub Release
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ steps.notes.outputs.tag }}
name: PicHost ${{ steps.notes.outputs.tag }}
body_path: ${{ steps.notes.outputs.body_path }}
draft: false
prerelease: false
make_latest: true
+8 -2
View File
@@ -5,6 +5,7 @@
.nitro .nitro
.cache .cache
dist dist
dist-windows
# Node dependencies # Node dependencies
node_modules node_modules
@@ -37,6 +38,11 @@ data
# 本地 Git hooks 模板(仅安装在 .git/hooks/,不进仓库) # 本地 Git hooks 模板(仅安装在 .git/hooks/,不进仓库)
git-hooks/ git-hooks/
# 本地维护脚本(assign-image-owner、fpk 打包等,不进仓库) # 打包输出目录
scripts/
dist-fpk/ dist-fpk/
dist-docker/
# VitePress docs site
docs-site/.vitepress/cache
docs-site/.vitepress/dist
docs-site/public/screenshots
+246 -3
View File
@@ -2,6 +2,248 @@
本仓库自 **v1.0.0** 起作为正式版维护;此前历史提交已归档重置。 本仓库自 **v1.0.0** 起作为正式版维护;此前历史提交已归档重置。
## [Unreleased]
## [1.5.0] - 2026-10-17
### 新增
- 图库、视频库和文件库支持搜索与批量删除,列表管理体验进一步完善
- 视频与文件 API 文档按新增上传、查询、批量删除和转码能力同步更新
- 所有媒体上传统一使用可配置策略,包括文件大小、格式限制和上传限流
- 新上传的图片、视频和文件默认保存到「本地磁盘」,支持切换默认存储后端
- 存储页新增高危操作二次确认,可清空当前用户的媒体、任务和缓存且不影响其他用户
- 低内存服务器在开启 HLS 转码时显示风险提示
### 改进
- 备份与恢复统一统计「媒体记录」和「存储对象」,提升迁移前后数据一致性
- 视频上传入口仅允许 MP4、MOV、MKV、WebM 等完整容器格式,服务端增加扩展名与内容类型校验
- 缩略图 FFmpeg 限制为单线程并增加 10 秒超时,降低低内存服务器卡死风险
- m3u8 临时合并文件支持下载后清理、到期清理及启动兜底清理
### 修复
- 修复清空存储后最近任务残留并可继续下载的问题
- 修复部分媒体相关文案和逻辑仍仅针对图片、遗漏视频与文件的问题
- 修复关闭独立媒体域名时网站域名被误清空、生效地址回落到请求地址的问题
- 修复站点同步异常后的兜底恢复流程
## [1.4.4] - 2026-10-10
### 修复
- Cap 登录验证支持新的 hashwx 挑战,旧的 SHA-256 site key 仍可使用
- 求解用的 wasm 改由本站提供,不再依赖 jsDelivr
## [1.4.3] - 2026-10-09
### 新增
- **Telegram 存储后端**:用 Bot 把图片存入频道或群组(含私有);直链仍由 PicHost 代理,不暴露 Bot Token
- 可选 HTTP/HTTPS API 代理,供服务器访问 Telegram
- 群组升级为超级群组时自动改用新的 Chat ID
### 说明
- 单文件约 20MB,同一聊天约 20 条/分钟
- 图库删除会尝试删除对应 Telegram 消息;备份包不含 Bot Token
## [1.4.2] - 2026-09-23
### 修复
- 图库批量打标:管理员/跨归属场景下标签解析与失败提示
- 上传鉴权:`Auth-Token` 与表单 `token` 优先于会话;兼容全局 `API_UPLOAD_TOKEN`(上传归属首位管理员)
### 改进
- **API 页**:每账号单一 API Token,界面与文档统一
- **上传 API**:仅支持 `tagNames`(按名创建或复用标签);Twikoo 兼容路由同步
- **标签管理**:按图片数/最近使用排序;桌面列布局;窄屏卡片列表
- **API 文档**:参数表列宽,避免 form-data 列异常换行
## [1.4.1] - 2026-09-16
### 新增
- **上传策略(设置 → 基础)**:单文件大小、允许格式、上传/登录限流、保留原图(关闭服务端 WebP 转换);支持 `.env` 覆盖
- **上传 API**:可选 `tagNames`(名称不存在则创建,存在则复用);与 `tagIds` 可并用
### 改进
- 登录接口接入可配置的登录限流
- 设置页将全站上传策略并入「上传偏好 / 图片处理」;访问控制保留登录限流
- API 文档补充 `tagIds` / `tagNames` 参数说明
## [1.4.0] - 2026-09-09
### 新增
- **标签系统**:每用户独立标签与彩色标记;设置页管理(新建、改色、合并、删除)
- **图片打标**:上传后单张/批量打标;图库按标签筛选(OR / AND)、批量打标与解标
- **操作日志扩展**:记录登录、删除、设置变更、标签操作等;支持日期、用户、操作类型、来源与 IP 筛选
- **API**:标签 CRUD、图片打标、`batch-tags`;上传支持 `tagIds`;列表/搜索/统计支持标签筛选
### 改进
- 操作日志页 UI 改版(操作/来源徽章、移除备注列)
- 设置侧栏增加「标签管理」
### 文档
- README 与文档站截图更新(上传、图库、标签、操作日志等)
## [1.3.0] - 2026-09-07
### 新增
- **站点备份**:存储页导出 `.phost.tar.gz`(含 `pichost.db` 与全部图片),支持下载与任务进度
- **站点恢复**:存储页或 setup 未初始化时从备份包恢复,支持预检与跳过/覆盖冲突
- **跨后端同步**:在已配置存储后端之间拷贝图片并更新索引;后端卡片快捷「迁入 / 迁出」
- **任务中心**:`backup_jobs` 记录最近导出/恢复/同步任务,失败可重试;列表分页
- 运维 CLI:`backup-export`、`backup-restore`、`storage-sync`(`docker exec pichost …`)
### 移除
- 遗留目录迁移 CLI `migrate` / `migrate-to-single-images.mjs`(由启动索引同步与整站备份恢复替代)
### 文档
- [存储](./docs-site/guide/storage.md) 补充备份与迁移说明;更新截图;移除 v1.2 迁移专页
## [1.2.8] - 2026-09-04
### 改进
- 双域名:保存或初始化时弹出风险确认(说明可能锁死及 `clear-domains` 恢复方式),不再强制校验当前 Host
### 新增
- 运维 CLI `clear-domains`:清除数据库中的网站域 / 图片域配置(`docker exec pichost clear-domains`)
### 文档
- FAQ、双域名、反向代理文档补充锁死原因与恢复步骤
## [1.2.7] - 2026-09-03
### 新增
- 登录/注册人机验证:本地滑块、Cloudflare Turnstile、Cap;管理员在「设置 → 访问控制」中切换
- 运维 CLI `slider`:将登录验证重置为本地滑块(`docker exec pichost slider`)
### 改进
- 登出后登录页验证码可正常重新加载
## [1.2.6] - 2026-09-01
### 新增
- 设置、API、图库、存储页 UI 重构(侧栏导航、统计卡片、在线调试等)
- 图库筛选增加存储后端下拉
- 新建对象存储后端按类型生成存储标识:`r2-*`、`cos-*`、`oss-*`、`s3-*`
### 改进
- 操作日志表格:操作/来源标签同行展示,路径列省略号截断、无横向滚动条
- 存储后端卡片:存储标识与类型同一行左对齐排列
- README 与文档站截图、导航说明同步新界面
## [1.2.5] - 2026-08-31
### 新增
- 图库页(`/gallery`)替代原「统计」导航;图片点击放大详情弹层(尺寸、上传方式、存储路径、链接复制)
- 图库列表支持全选、反选;单张删除移至详情弹层
- 图片列表 API 返回上传来源(网页 / API,来自活动日志)
### 改进
- README 补充技术栈说明
### 移除
- `/stats` 路由(不再兼容跳转)
## [1.2.4] - 2026-08-31
### 修复
- 访问控制(Referer 防盗链)留空时仍会拦截外链图片:浏览器直开无 Referer 可访问,博客/论坛嵌入带 Referer 会 403 图裂;现仅在配置了白名单时才校验
## [1.2.3] - 2026-08-30
### 修复
- 开启「简短图片链接」且按年/月分组时,纯文件名直链(如 `/xxx.webp`)误解析为 `images/xxx.webp` 导致 404;现先经 SQLite 索引反查完整 key
## [1.2.2] - 2026-08-30
### 修复
- 双域名:设置页保存时误清空管理域(`site_base_url`);仅改图片域时保护已有网站域配置
- 双域名:设置 API 配置值与 request origin fallback 混淆,导致「当前生效」显示错误
- 双域名:未配置的第三 Host(`pages.dev`、裸 IP 等)可绕过 Host 隔离访问后台
### 改进
- 设置 API 拆分 `siteBaseUrl` / `imageBaseUrl`(配置值)、`effective*`(直链)、`runtime`(当前访问检测)
- `PATCH /api/settings` 增加 `domainSeparation` 字段与保存校验
- 设置页:检测地址展示、关闭双域名确认、反代 Host 不一致警告;首次设置不再自动填入 origin
- Host 中间件:双域名开启时对未知 Host 返回 404(开发 `localhost` 例外)
- 文档站:新增 [更新日志](https://o96u.github.io/PicHost/guide/changelog);重写 Cloudflare 部署说明;同步 FAQ / 双域名文档
## [1.2.1] - 2026-08-29
### 新增
- 登录滑动拼图验证(缺口对齐);验证与登录分两步,登录接口校验 `captchaId` / `captchaPosition`
- VitePress 文档站(`docs-site/`):中英文指南,部署至 GitHub Pages <https://o96u.github.io/PicHost/>
- `npm run docs:dev` / `docs:build` / `docs:preview`;CI 增加 `docs:build`;`.github/workflows/docs.yml` 发布文档
### 修复
- 修复统计页删除图片后图库不刷新的问题
- 修复分页跳转输入框在 `type="number"` 下无法跳转的问题
- 修复非首页路由下复制链接格式偏好不持久化的问题(`useUploadPreferences` 首次使用时自动加载)
- 滑动验证 UI:滑块垂直居中、缺口尺寸与滑块一致
### 改进
- README 精简为产品宣传页,详细说明指向文档站
- 双域名文档补充:未知 Host 风险、Cloudflare 橙云部署、勿用 Pages/Workers 整站反代
- 文档截图更新(登录页等);旧 `docs/*.md` 长文迁入 `docs-site/`
## [1.2.0] - 2026-08-28
### 变更
- 图片统一存放在 `data/images/`;遗留 `blog/`、`twikoo/` 等并列目录需迁移后升级
### 新增
- CLI 迁移工具:`docker exec pichost migrate` / `migrate --apply`(`server/cli/migrate-to-single-images.mjs`)
- 扫描 `data/` 下除 `images` 外所有顶层目录中的图片,输出 `data/mapping.json`
- 启动时自动同步图片索引(扫描磁盘、归一化遗留 key、清理孤儿记录),并打印同步日志
### 改进
- 启动时归一化遗留 SQLite key、清理无文件的孤儿索引;支持删除仅索引无文件的记录
## [1.1.5] - 2026-08-28
### 修复
- 修复 API 上传后图库预览偶发失败、需等待刷新才显示的问题
- 图库卡片加载失败后自动重试,刷新列表时重置预览状态
### 改进
- 出图前校验存储文件实际存在,避免索引与磁盘短暂不一致
- 图片 404/403 与域名隔离 404 禁止 CDN 缓存,降低反代误判影响
## [1.1.4] - 2026-08-28 ## [1.1.4] - 2026-08-28
### 新增 ### 新增
@@ -23,7 +265,7 @@
- `/setup` 与系统设置支持启用/关闭域名分离,设置页可后续关闭并恢复单域 - `/setup` 与系统设置支持启用/关闭域名分离,设置页可后续关闭并恢复单域
- 可选隐藏外链中的 `images/` 前缀(多文件夹时确认提示) - 可选隐藏外链中的 `images/` 前缀(多文件夹时确认提示)
- 设置页加载时检测 GitHub 最新版本并提示更新 - 设置页加载时检测 GitHub 最新版本并提示更新
- 双域名部署文档(Nginx / Lucky / 本地 hosts 测试):`docs/domain-separation.md` - 双域名部署文档(Nginx / Lucky / 本地 hosts 测试):[文档站 · 双域名分离](https://o96u.github.io/PicHost/guide/domain-separation)
### 改进 ### 改进
@@ -111,6 +353,7 @@
- 中英文界面(`@nuxtjs/i18n`),语言切换与主题切换独立菜单 - 中英文界面(`@nuxtjs/i18n`),语言切换与主题切换独立菜单
- 移动端导航:汉堡菜单 + 分组用户菜单(语言 / 外观 / 账户) - 移动端导航:汉堡菜单 + 分组用户菜单(语言 / 外观 / 账户)
- Docker 忘记密码:`docker exec pichost reset-password`(随机密码,见 `server/cli/`) - Docker 忘记密码:`docker exec pichost reset-password`(随机密码,见 `server/cli/`)
- Docker 升级到 v1.2.0 前迁移遗留目录:`docker exec pichost migrate` / `migrate --apply`
### 改进 ### 改进
@@ -147,9 +390,9 @@
### 路线图 ### 路线图
- **v1.1.2**(当前 `main`):操作日志、图库存储筛选、上传限流 - **v1.2.0**(开发中):统一 `images/` 存储、遗留目录迁移 CLI、启动索引同步
[1.1.2]: https://github.com/O96u/PicHost/releases/tag/v1.1.2 [1.2.0]: https://github.com/O96u/PicHost/releases/tag/v1.2.0
[1.1.1]: https://github.com/O96u/PicHost/releases/tag/v1.1.1 [1.1.1]: https://github.com/O96u/PicHost/releases/tag/v1.1.1
[1.1.0]: https://github.com/O96u/PicHost/releases/tag/v1.1.0 [1.1.0]: https://github.com/O96u/PicHost/releases/tag/v1.1.0
[1.0.4]: https://github.com/O96u/PicHost/releases/tag/v1.0.4 [1.0.4]: https://github.com/O96u/PicHost/releases/tag/v1.0.4
+12
View File
@@ -18,12 +18,24 @@ FROM node:22-alpine
WORKDIR /app WORKDIR /app
# 视频床转码依赖 ffmpeg(单码率 HLS 切片)
RUN apk add --no-cache ffmpeg
ENV NODE_ENV=production \ ENV NODE_ENV=production \
NITRO_PORT=6892 \ NITRO_PORT=6892 \
DATA_DIR=/data DATA_DIR=/data
COPY --from=build /app/.output ./.output COPY --from=build /app/.output ./.output
COPY --from=build /app/server/utils ./server/utils
COPY --from=build /app/node_modules ./node_modules
COPY --from=build /app/package.json ./package.json
COPY server/cli/reset-password.mjs ./server/cli/reset-password.mjs COPY server/cli/reset-password.mjs ./server/cli/reset-password.mjs
COPY server/cli/slider.mjs ./server/cli/slider.mjs
COPY server/cli/clear-domains.mjs ./server/cli/clear-domains.mjs
COPY server/cli/backup-cli-loader.mjs ./server/cli/backup-cli-loader.mjs
COPY server/cli/backup-export.mjs ./server/cli/backup-export.mjs
COPY server/cli/backup-restore.mjs ./server/cli/backup-restore.mjs
COPY server/cli/storage-sync.mjs ./server/cli/storage-sync.mjs
COPY docker-entrypoint.sh /docker-entrypoint.sh COPY docker-entrypoint.sh /docker-entrypoint.sh
RUN chmod +x /docker-entrypoint.sh RUN chmod +x /docker-entrypoint.sh
+49 -193
View File
@@ -17,64 +17,66 @@
</p> </p>
<p align="center"> <p align="center">
<a href="#branches">Branches</a> · <a href="https://o96u.github.io/PicHost/en/">Docs</a> ·
<a href="#quick-start">Quick Start</a> ·
<a href="#screenshots">Screenshots</a> · <a href="#screenshots">Screenshots</a> ·
<a href="#features">Features</a> · <a href="#features">Features</a> ·
<a href="#tech-stack">Tech Stack</a> · <a href="#tech-stack">Tech Stack</a> ·
<a href="#roadmap">Roadmap</a> · <a href="#quick-start">Quick Start</a> ·
<a href="README.md">简体中文</a> <a href="README.md">简体中文</a>
</p> </p>
--- ---
## Branches
| Branch | Notes |
| ------ | ----- |
| [**main**](https://github.com/O96u/PicHost/tree/main) (default) | **v1.1.4** — local disk + multi-backend storage, bidirectional dual-domain isolation, [`/storage`](docs/README.md) UI, hybrid URLs |
| [**cloudflare**](https://github.com/O96u/PicHost/tree/cloudflare) | **Cloudflare R2–only line** — streamlined for deployments that use R2 exclusively |
Use **main** for the full product and multiple cloud backends.
If you **only use Cloudflare R2**, check out the dedicated branch:
```bash
git clone https://github.com/O96u/PicHost.git
cd PicHost
git checkout cloudflare
```
---
## Screenshots ## Screenshots
| Upload | Storage | **main** has no public demo. **cloudflare** branch live demo: [pic.roven.cc](https://pic.roven.cc)
| :------------------------------------: | :--------------------------------: |
| ![Upload](docs/screenshots/upload.png) | ![Storage](docs/screenshots/storage.png) |
| Stats & gallery | Settings | | Upload | Gallery |
| :----------------------------------: | :--------------------------------: | | :----: | :-----: |
| ![Stats](docs/screenshots/stats.png) | ![Settings](docs/screenshots/settings.png) | | ![Upload](docs/screenshots/upload.png) | ![Gallery](docs/screenshots/gallery.png) |
| API | Tag management |
| :-: | :------------: |
| ![API](docs/screenshots/api.png) | ![Tags](docs/screenshots/tags.png) |
| Storage | Settings |
| :-----: | :-------: |
| ![Storage](docs/screenshots/storage.png) | ![Settings](docs/screenshots/settings-en.png) |
| First-time setup | Restore from backup |
| :--------------: | :-----------------: |
| ![Setup](docs/screenshots/setup.png) | ![Setup restore](docs/screenshots/setup-restore.png) |
| Activity log |
| :----------: |
| ![Activity log](docs/screenshots/logs.png) |
## Features ## Features
- **Drag, click, or Ctrl+V paste** to upload; admins can use custom `folder` paths - **Drag, click, or Ctrl+V paste** — server-side WebP, Referer hotlink protection
- **Server-side WebP** (sharp), Referer hotlink protection, configurable public base URL - **Multi-user** — login with slider, Turnstile, or Cap verification; optional registration; users see only their images
- **Multi-user** — username/password login; optional registration; users see only their images - **Multi-backend storage** — local disk + S3-compatible (R2 / COS / OSS / AWS) + Telegram channel/group; hybrid `proxy` / `public` URLs
- **Upload preferences** (home card flip): client-side compression, auto-copy links, per-user auto-delete - **Gallery** — browse, search, filter by storage/source/tags, grid/list views, batch delete and batch tagging; stats overview and source breakdown; image detail modal with link formats
- **Multi-backend storage**: local disk + S3-compatible (Cloudflare R2 / Tencent COS / Alibaba OSS / AWS S3); admins manage backends at **Storage** (`/storage`) - **Tags** — per-user colored tags; tag on upload (single or batch); manage in Settings (create, recolor, merge, delete); API supports `tagIds` and batch tagging
- **Hybrid URLs**: `proxy` (same-origin via PicHost) or `public` (302 to CDN / bucket URL) - **Backup & migration** — full-site export/restore (`.phost.tar.gz`), cross-backend sync; restore from backup on uninitialized `/setup`
- **Gallery** — browse, search, batch delete; admin sees uploader badges - **API & Twikoo** — global / per-user tokens; `POST /api/index.php` compatible
- **Stats** — upload/delete metrics, folder breakdown; admin sees registered user count - **Zero-config Docker** — first-run web wizard, no secrets upfront
- **API** — global token (admin) + per-user tokens; in-app cURL snippets
- **Twikoo** — `POST /api/index.php` (`image` + `token`) ## Tech Stack
- **Zero-config Docker** — first-run web wizard, no secrets required upfront
| Layer | Technologies |
| ----- | ------------ |
| Frontend | [Nuxt 4](https://nuxt.com) · [Nuxt UI 4](https://ui.nuxt.com) · [Vue 3](https://vuejs.org) · [Tailwind CSS 4](https://tailwindcss.com) · TypeScript |
| Backend | [Nitro](https://nitro.build) (`node-server`) · REST API |
| Data | SQLite · local `data/pichost.db` |
| Images | [sharp](https://sharp.pixelplumbing.com) (server-side WebP) |
| Storage | Local disk · S3-compatible ([AWS SDK](https://aws.amazon.com/sdk-for-javascript/) · R2 / COS / OSS, etc.) · Telegram channel/group |
| i18n | [@nuxtjs/i18n](https://i18n.nuxtjs.org) (zh-CN / English) |
| Quality & docs | [Vitest](https://vitest.dev) · [VitePress](https://vitepress.dev) docs site |
| Deploy | Docker · Node.js 22 |
## Quick Start ## Quick Start
### Docker (recommended)
```bash ```bash
docker run -d \ docker run -d \
--name pichost \ --name pichost \
@@ -84,160 +86,14 @@ docker run -d \
muxui/pichost:latest muxui/pichost:latest
``` ```
Default port: **6892**. Open `http://<host>:6892` in your browser and complete the setup wizard. Open `http://<host>:6892` and complete the setup wizard.
**Forgot password** (requires `docker exec` / server access): **Full guide** (env vars, upgrades, dual-domain setup, API, Twikoo, reverse proxy, local dev, etc.): **[Documentation](https://o96u.github.io/PicHost/en/)**.
```bash | Branch | Notes |
docker exec pichost reset-password # admin only, when there is exactly one | ------ | ----- |
docker exec pichost reset-password <username> # admin or regular user | [**main**](https://github.com/O96u/PicHost/tree/main) | Default: multi-backend, dual-domain, unified `images/` storage |
``` | [**cloudflare**](https://github.com/O96u/PicHost/tree/cloudflare) | Cloudflare R2 only; live demo [pic.roven.cc](https://pic.roven.cc) |
A random password is printed. Fails if the user does not exist.
If you cloned the repo, you can also use `docker compose up -d` (see `docker-compose.yml`).
### Local development
```bash
npm install
cp .env.example .env # optional
npm run dev
```
Open `http://localhost:3000/setup` to create the admin account. Data defaults to `./data`.
## Tech Stack
| Layer | Technology |
| -------- | --------------------------------------------------- |
| Frontend | Nuxt 4, Vue 3, Nuxt UI 4, Tailwind CSS 4 |
| Backend | Nitro (Node.js), SQLite |
| Images | sharp (WebP transcoding & compression) |
| Auth | Session cookie, scrypt password hashing, API tokens |
| Storage | Local disk + S3-compatible backends (R2 / COS / OSS / AWS); `images` index in SQLite |
| Deploy | Docker (amd64 / arm64), docker compose |
| Tests | Vitest |
## Users & permissions
| Scenario | Auth | Ownership / visibility |
| ------------------------------ | -------------------- | --------------------------- |
| Web upload | Session | Current user |
| API upload + **user token** | `Auth-Token` | Token owner |
| API upload + **global token** | `Auth-Token` | Admin (`userId` null) |
| Twikoo `/api/index.php` | form `token` | Same as global token |
| Gallery list / search / delete | Session or token | Users: own only; admin: all |
| Direct link `GET /images/...` | None (Referer rules) | Public if URL is known |
Regular users are limited to the `images/` folder; custom `folder` requires admin or global token.
## Storage layout
```
/data
├── pichost.db # SQLite: users, sessions, settings, storage_backends, images index
├── images/ # Default upload folder (when using local backend)
├── blog/ # Example custom folder
└── twikoo/ # Twikoo comment images
```
Pattern: `folder/YYYY/MM/randomId.webp` + `.meta.json`. With cloud backends, blobs live in the bucket; SQLite `images` tracks `backend_id` and `key`. Back up all of `/data` plus bucket data.
## API overview
| Method | Path | Description |
| ------ | -------------------- | ---------------------- |
| POST | `/api/images/upload` | Upload image |
| GET | `/api/images` | Paginated list |
| DELETE | `/api/images` | Delete one (`key`) |
| POST | `/api/index.php` | Twikoo / EasyImage 2.0 |
Upload example:
```bash
curl -X POST "https://pic.example.com/api/images/upload" \
-H "Auth-Token: YOUR_TOKEN" \
-F "folder=blog" \
-F "image=@./demo.png"
```
Full docs and copyable cURL snippets are on the **API** page after deployment.
## Twikoo
| Setting | Value |
| ----------------- | ----------------------------------- |
| `IMAGE_CDN` | `easyimage` |
| `IMAGE_CDN_URL` | `https://your-domain/api/index.php` |
| `IMAGE_CDN_TOKEN` | Same as `API_UPLOAD_TOKEN` |
## Reverse proxy
- Proxy to port **6892**; HTTPS recommended
- Nginx: `client_max_body_size` ≥ 12m
- Set `IMAGE_BASE_URL` (single domain) or `SITE_BASE_URL` + `IMAGE_BASE_URL` (dual-domain separation)
### Recommended architecture (dual domains)
**One Docker instance + two domains**, both fully proxied to `6892` (isolation via PicHost middleware):
```nginx
# admin.example.com — admin + API + images
server {
server_name admin.example.com;
location / {
proxy_pass http://127.0.0.1:6892;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
client_max_body_size 12m;
}
}
# pic.example.com — same full proxy; PicHost middleware blocks non-image paths
server {
server_name pic.example.com;
location / {
proxy_pass http://127.0.0.1:6892;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
```
FN NAS [Lucky](https://github.com/gdy666/lucky) reverse proxy (public hostname → backend `http://<LAN-IP>:6892`):
| Site domain (admin) | Image domain |
| :-----------------: | :----------: |
| ![Lucky site](docs/screenshots/lucky-site.png) | ![Lucky image](docs/screenshots/lucky-image.png) |
See **[docs/domain-separation.en.md](docs/domain-separation.en.md)** for Caddy/NPM notes, env vars, and setup fields.
## Development
```bash
npm run dev # Dev server
npm run build # Production build
npm run start # Run .output
npm run lint # ESLint
npm run typecheck # Type check
npm test # Unit tests
```
## Roadmap
| Version | Plan |
| ---------- | -------------------------------------------------------------- |
| **v1.1.4** | Flat vs date storage paths, basename public URLs, bidirectional domain isolation (current `main`) |
| **v1.1.3** | Dual-domain separation, optional `images/` URL prefix, update check |
| **v1.1.2** | Activity logs, gallery storage filter, upload rate limits |
| **v1.1.1** | Session check fixes, storage placeholder cleanup, Docker logging |
| **v1.1.0** | Multi-backend object storage, hybrid URLs, storage UI |
| **v1.0.4** | Password visibility toggle, legacy ADMIN_SECRET migration |
| **v1.0.3** | CI and Docker multi-arch build fixes |
| **v1.0.2** | i18n, mobile menus, stats/upload UI, Docker password reset |
| **v1.0.1** | Settings version display, mobile upload preferences layout fix |
| **v1.0.0** | Local storage, multi-user, API, Twikoo |
## License ## License
+49 -198
View File
@@ -17,64 +17,66 @@
</p> </p>
<p align="center"> <p align="center">
<a href="#分支">分支</a> · <a href="https://o96u.github.io/PicHost/">文档</a> ·
<a href="#快速开始">快速开始</a> ·
<a href="#界面预览">预览</a> · <a href="#界面预览">预览</a> ·
<a href="#特性">特性</a> · <a href="#特性">特性</a> ·
<a href="#技术栈">技术栈</a> · <a href="#技术栈">技术栈</a> ·
<a href="#路线图">路线图</a> · <a href="#快速开始">快速开始</a> ·
<a href="README.en.md">English</a> <a href="README.en.md">English</a>
</p> </p>
--- ---
## 分支
| 分支 | 说明 |
| ---- | ---- |
| [**main**](https://github.com/O96u/PicHost/tree/main)(默认) | **v1.1.4** 主线:本地磁盘 + 多对象存储、双域名双向隔离、[`/storage`](docs/README.md) 存储管理、混合直链 |
| [**cloudflare**](https://github.com/O96u/PicHost/tree/cloudflare) | **Cloudflare R2 专用线**:面向「只用 R2」的部署,预设与配置更聚焦 R2 |
日常使用、需要管理多种存储后端,请直接用 **main**。
若图床**只接 Cloudflare R2**、希望更简化的 R2 专用版本,可切换分支:
```bash
git clone https://github.com/O96u/PicHost.git
cd PicHost
git checkout cloudflare
```
---
## 界面预览 ## 界面预览
| 上传 | 存储管理 | **main** 分支暂无公网演示;**cloudflare** 分支在线体验:[pic.roven.cc](https://pic.roven.cc)
| :-----------------------------------: | :-------------------------------------: |
| ![上传](docs/screenshots/upload.png) | ![存储](docs/screenshots/storage.png) |
| 统计与图库 | 系统设置 | | 上传 | 图库 |
| :---------------------------------: | :-------------------------------------: | | :--: | :--: |
| ![统计](docs/screenshots/stats.png) | ![设置](docs/screenshots/settings.png) | | ![上传](docs/screenshots/upload.png) | ![图库](docs/screenshots/gallery.png) |
| API | 标签管理 |
| :--: | :------: |
| ![API](docs/screenshots/api.png) | ![标签管理](docs/screenshots/tags.png) |
| 存储 | 设置 |
| :--: | :--: |
| ![存储](docs/screenshots/storage.png) | ![设置](docs/screenshots/settings.png) |
| 首次设置 | 从备份恢复 |
| :--: | :--: |
| ![首次设置](docs/screenshots/setup.png) | ![从备份恢复](docs/screenshots/setup-restore.png) |
| 操作日志 |
| :------: |
| ![操作日志](docs/screenshots/logs.png) |
## 特性 ## 特性
- **拖拽 / 点击 / Ctrl+V 粘贴**上传;管理员可自定义 `folder` 目录 - **拖拽 / 点击 / Ctrl+V 粘贴**上传;服务端 WebP 压缩、Referer 防盗链
- **服务端 WebP 压缩**(sharp)、Referer 防盗链、可配置访问域名 - **多用户**:账号密码登录(滑块 / Turnstile / Cap 人机验证)、可选开放注册;普通用户仅见自己的图片
- **多用户**:账号密码登录;管理员可开放注册;普通用户仅见自己的图片 - **多后端存储**:本地磁盘 + S3 兼容(R2 / COS / OSS / AWS)+ Telegram 频道/群组;混合直链 `proxy` / `public`
- **上传偏好**(首页卡片翻转):客户端预压缩、自动复制链接、按用户自动删除 - **图库**:浏览、搜索、按存储/来源/标签筛选、网格/列表切换、批量删除与批量打标;统计概览与来源分布;点击图片查看详情与多格式链接
- **多后端存储**:本地磁盘 + S3 兼容(Cloudflare R2 / 腾讯云 COS / 阿里云 OSS / AWS S3);管理员在 **存储**(`/storage`)页管理,新上传写入默认后端 - **标签**:每用户独立标签、彩色标记;上传后单张/批量打标;设置页管理(新建、改色、合并、删除);API 支持 `tagIds` 与批量打标
- **混合直链**:`proxy`(PicHost 同源代理)或 `public`(302 到 CDN / 桶公网地址) - **备份与迁移**:整站导出/恢复(`.phost.tar.gz`)、跨后端同步;setup 未初始化时可从备份恢复
- **图库**:浏览、搜索、批量删除;管理员可查看上传者标签 - **API 与 Twikoo**:全局 / 个人 Token;`POST /api/index.php` 兼容
- **统计**:上传/删除趋势、目录分布;管理员额外显示注册用户数量 - **Docker 零配置**:首次访问 Web 引导创建管理员
- **API**:全局 Token(管理员)+ 每用户个人 Token;后台可复制 cURL
- **Twikoo**:`POST /api/index.php`(`image` + `token`) ## 技术栈
- **Docker 零配置**:首次访问 Web 引导创建管理员,无需预先写密钥
| 层级 | 技术 |
| ---- | ---- |
| 前端 | [Nuxt 4](https://nuxt.com) · [Nuxt UI 4](https://ui.nuxt.com) · [Vue 3](https://vuejs.org) · [Tailwind CSS 4](https://tailwindcss.com) · TypeScript |
| 后端 | [Nitro](https://nitro.build)(`node-server`)· REST API |
| 数据 | SQLite · 本地 `data/pichost.db` |
| 图片处理 | [sharp](https://sharp.pixelplumbing.com)(服务端 WebP 压缩) |
| 存储 | 本地磁盘 · S3 兼容([AWS SDK](https://aws.amazon.com/sdk-for-javascript/) · R2 / COS / OSS 等)· Telegram 频道/群组 |
| 国际化 | [@nuxtjs/i18n](https://i18n.nuxtjs.org)(简体中文 / English) |
| 质量与文档 | [Vitest](https://vitest.dev) · [VitePress](https://vitepress.dev) 文档站 |
| 部署 | Docker · Node.js 22 |
## 快速开始 ## 快速开始
### Docker(推荐)
```bash ```bash
docker run -d \ docker run -d \
--name pichost \ --name pichost \
@@ -84,165 +86,14 @@ docker run -d \
muxui/pichost:latest muxui/pichost:latest
``` ```
默认端口 **6892**。浏览器打开 `http://<主机IP>:6892`,按引导创建管理员即可。 浏览器打开 `http://<主机IP>:6892`,按引导完成设置即可。
**忘记密码**(需能执行 `docker exec`,相当于服务器权限): **详细说明**(环境变量、双域名、API、Twikoo、反向代理、本地开发等)见 **[用户文档](https://o96u.github.io/PicHost/)**。
```bash | 分支 | 说明 |
# 无参数:重置管理员(仅当系统中只有一个管理员时) | ---- | ---- |
docker exec pichost reset-password | [**main**](https://github.com/O96u/PicHost/tree/main) | 默认主线:多后端、双域名、统一 `images/` 存储 |
| [**cloudflare**](https://github.com/O96u/PicHost/tree/cloudflare) | 仅 Cloudflare R2;在线演示 [pic.roven.cc](https://pic.roven.cc) |
# 指定用户名:可重置管理员或普通用户
docker exec pichost reset-password 用户名
```
终端会打印随机新密码;用户不存在时会报错。登录后请到「修改密码」更换。
本地:`npm run reset-password` 或 `npm run reset-password -- 用户名`
已克隆仓库时也可用 `docker compose up -d`(见仓库内 `docker-compose.yml`)。
### 本地开发
```bash
npm install
cp .env.example .env # 可选
npm run dev
```
访问 `http://localhost:3000/setup` 创建管理员;数据目录默认 `./data`。
## 技术栈
| 层级 | 技术 |
| -------- | ------------------------------------------ |
| 前端 | Nuxt 4、Vue 3、Nuxt UI 4、Tailwind CSS 4 |
| 后端 | Nitro(Node.js)、SQLite |
| 图片处理 | sharp(WebP 转码与压缩) |
| 鉴权 | Session Cookie、scrypt 密码哈希、API Token |
| 存储 | 本地磁盘 + S3 兼容后端(R2 / COS / OSS / AWS);`images` 索引在 SQLite |
| 部署 | Docker(amd64 / arm64)、docker compose |
| 测试 | Vitest |
## 用户与权限
| 场景 | 鉴权 | 图片归属 / 可见范围 |
| -------------------------- | -------------------- | -------------------------- |
| 网页上传 | Session | 当前登录用户 |
| API 上传 + **个人 Token** | `Auth-Token` | 该 Token 所属用户 |
| API 上传 + **全局 Token** | `Auth-Token` | 管理员(`userId` 为空) |
| Twikoo `/api/index.php` | 表单 `token` | 与全局 Token 相同 |
| 图库列表 / 搜索 / 删除 | Session 或 Token | 普通用户仅自己;管理员全部 |
| 图片直链 `GET /images/...` | 无(Referer 防盗链) | 知道 URL 即可访问 |
普通用户上传目录固定为 `images/`;自定义 `folder` 仅管理员或全局 Token 可用。
## 存储结构
```
/data
├── pichost.db # SQLite:用户、会话、设置、storage_backends、images 索引
├── images/ # 默认上传目录(本地后端时)
├── blog/ # 管理员自定义 folder 示例
└── twikoo/ # Twikoo 评论图片
```
路径规则:`目录/年/月/随机ID.webp` + 同名 `.meta.json`。云存储时文件在对应桶内,SQLite `images` 表记录 `backend_id` 与 `key`。备份请包含整个 `/data` 与云桶数据。
## API 概览
| 方法 | 路径 | 说明 |
| ------ | -------------------- | ---------------------- |
| POST | `/api/images/upload` | 上传图片 |
| GET | `/api/images` | 分页列表 |
| DELETE | `/api/images` | 删除单张(`key`) |
| POST | `/api/index.php` | Twikoo / EasyImage 2.0 |
上传示例:
```bash
curl -X POST "https://pic.example.com/api/images/upload" \
-H "Auth-Token: YOUR_TOKEN" \
-F "folder=blog" \
-F "image=@./demo.png"
```
完整接口与可复制 cURL 见部署后后台 **API** 页。
## Twikoo
| 配置项 | 值 |
| ----------------- | -------------------------------- |
| `IMAGE_CDN` | `easyimage` |
| `IMAGE_CDN_URL` | `https://你的域名/api/index.php` |
| `IMAGE_CDN_TOKEN` | 与 `API_UPLOAD_TOKEN` 相同 |
## 反向代理
- 反代到容器 **6892** 端口,建议 HTTPS
- Nginx:`client_max_body_size` ≥ 12m
- 公网访问请配置 `IMAGE_BASE_URL`(单域名)或 `SITE_BASE_URL` + `IMAGE_BASE_URL`(双域名分离)
### 推荐部署架构(双域名分离)
**单 Docker 实例 + 两个域名** 均可全量反代到 `6892`(隔离由 PicHost 中间件负责):
```nginx
# admin.example.com — 后台 + API + 出图
server {
server_name admin.example.com;
location / {
proxy_pass http://127.0.0.1:6892;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
client_max_body_size 12m;
}
}
# pic.example.com — 同样全量反代;PicHost 中间件会拦截非图片路径
server {
server_name pic.example.com;
location / {
proxy_pass http://127.0.0.1:6892;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
```
飞牛 NAS [Lucky](https://github.com/gdy666/lucky) 反向代理示例(前端域名 → 后端 `http://<内网IP>:6892`):
| 网站域名(管理后台) | 图片域名 |
| :------------------: | :------: |
| ![Lucky 网站域名](docs/screenshots/lucky-site.png) | ![Lucky 图片域名](docs/screenshots/lucky-image.png) |
完整说明(Caddy / NPM、环境变量、初始化填写项)见 **[docs/domain-separation.md](docs/domain-separation.md)**。
## 开发
```bash
npm run dev # 开发服务器
npm run build # 生产构建
npm run start # 运行 .output
npm run lint # ESLint
npm run typecheck # 类型检查
npm test # 单元测试
```
## 路线图
| 版本 | 说明 |
| ---------- | ------------------------------------------------- |
| **v1.1.4** | 存储路径扁平/分组、basename 外链、双域名双向隔离(当前 main) |
| **v1.1.3** | 双域名分离、隐藏 `images/` 前缀、版本更新提示 |
| **v1.1.2** | 操作日志、图库存储筛选、上传限流 |
| **v1.1.1** | 会话校验与存储占位修复、Docker 日志增强 |
| **v1.1.0** | S3 / R2 多后端对象存储、混合直链、存储管理页 |
| **v1.0.4** | 密码显示切换、遗留 ADMIN_SECRET 迁移对齐 |
| **v1.0.3** | CI 与 Docker 多架构构建修复 |
| **v1.0.2** | 中英文、移动端菜单、统计/上传 UI、Docker 重置密码 |
| **v1.0.1** | 设置页版本展示、移动端上传偏好布局修复 |
| **v1.0.0** | 本地存储、多用户、API、Twikoo |
## License ## License
+42 -1
View File
@@ -7,22 +7,44 @@
.upload-card-flip-inner { .upload-card-flip-inner {
position: relative; position: relative;
transition: transform 0.55s cubic-bezier(0.4, 0, 0.2, 1); transition:
transform 0.55s cubic-bezier(0.4, 0, 0.2, 1),
min-height 0.35s ease;
transform-style: preserve-3d; transform-style: preserve-3d;
} }
.upload-card-flip.is-flipped .upload-card-flip-inner { .upload-card-flip.is-flipped .upload-card-flip-inner {
min-height: min(40rem, calc(100vh - 11rem));
transform: rotateY(180deg); transform: rotateY(180deg);
} }
.upload-card-flip--file.is-flipped .upload-card-flip-inner {
min-height: min(27rem, calc(100vh - 11rem));
}
.upload-card-flip.is-flipped .upload-card-face--front {
visibility: hidden;
pointer-events: none;
}
.upload-card-flip.is-flipped .upload-settings-btn {
display: none;
}
.upload-card-face { .upload-card-face {
backface-visibility: hidden; backface-visibility: hidden;
-webkit-backface-visibility: hidden; -webkit-backface-visibility: hidden;
} }
.upload-card-face--front {
position: relative;
z-index: 1;
}
.upload-card-face--back { .upload-card-face--back {
position: absolute; position: absolute;
inset: 0; inset: 0;
z-index: 2;
transform: rotateY(180deg); transform: rotateY(180deg);
} }
@@ -33,6 +55,7 @@
.upload-card-flip.is-flipped .upload-card-flip-inner { .upload-card-flip.is-flipped .upload-card-flip-inner {
transform: none; transform: none;
min-height: 0;
} }
.upload-card-flip .upload-card-face--back { .upload-card-flip .upload-card-face--back {
@@ -106,3 +129,21 @@
.upload-settings-btn:hover .upload-settings-icon { .upload-settings-btn:hover .upload-settings-icon {
transform: rotate(90deg); transform: rotate(90deg);
} }
.api-debug-response {
scrollbar-width: thin;
scrollbar-color: rgb(82 82 82) transparent;
}
.api-debug-response::-webkit-scrollbar {
width: 6px;
}
.api-debug-response::-webkit-scrollbar-thumb {
border-radius: 9999px;
background-color: rgb(82 82 82);
}
.api-debug-response::-webkit-scrollbar-track {
background: transparent;
}
+49 -6
View File
@@ -1,6 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import logoLight from '~/assets/image/logo-light.png' import logoLight from '~/assets/image/logo-light.png'
import logoDark from '~/assets/image/logo-dark.png' import logoDark from '~/assets/image/logo-dark.png'
import type { LoginVerificationPayload } from '~/types/auth'
const router = useRouter() const router = useRouter()
const { login, fetchStatus, authStatus } = useAuth() const { login, fetchStatus, authStatus } = useAuth()
@@ -11,29 +12,53 @@ const username = ref('')
const password = ref('') const password = ref('')
const secret = ref('') const secret = ref('')
const loading = ref(false) const loading = ref(false)
const verificationRef = ref<{ reset: () => Promise<void> } | null>(null)
const verificationPayload = ref<LoginVerificationPayload | null>(null)
const verificationMountKey = ref(0)
const legacyMode = computed(() => authStatus.value?.legacyMode ?? false) const legacyMode = computed(() => authStatus.value?.legacyMode ?? false)
const allowRegistration = computed(() => authStatus.value?.allowRegistration ?? false) const allowRegistration = computed(() => authStatus.value?.allowRegistration ?? false)
const loginVerification = computed(() =>
const canSubmit = computed(() => authStatus.value?.loginVerification ?? { method: 'slider' as const }
legacyMode.value ? Boolean(secret.value) : Boolean(username.value && password.value)
) )
const canSubmit = computed(() =>
Boolean(verificationPayload.value)
&& (legacyMode.value ? Boolean(secret.value) : Boolean(username.value && password.value))
)
function onVerificationVerified(payload: LoginVerificationPayload) {
verificationPayload.value = payload
}
function onVerificationCleared() {
verificationPayload.value = null
}
async function resetVerification() {
verificationPayload.value = null
await verificationRef.value?.reset()
}
onMounted(async () => { onMounted(async () => {
verificationPayload.value = null
verificationMountKey.value += 1
const status = await fetchStatus() const status = await fetchStatus()
if (!status.initialized && !status.legacyMode) { if (!status.initialized && !status.legacyMode) {
await router.replace('/setup') await router.replace('/setup')
return
} }
}) })
async function submit() { async function submit() {
if (!canSubmit.value || loading.value) return if (!canSubmit.value || loading.value || !verificationPayload.value) return
loading.value = true loading.value = true
try { try {
const verification = verificationPayload.value
const result = legacyMode.value const result = legacyMode.value
? await login({ secret: secret.value }) ? await login({ secret: secret.value }, verification)
: await login({ username: username.value.trim(), password: password.value }) : await login({ username: username.value.trim(), password: password.value }, verification)
if (result.ok) { if (result.ok) {
if (result.needsMigration) { if (result.needsMigration) {
@@ -43,8 +68,16 @@ async function submit() {
username.value = '' username.value = ''
password.value = '' password.value = ''
secret.value = '' secret.value = ''
await resetVerification()
toast.add({ title: t('auth.loginSuccess'), color: 'success' }) toast.add({ title: t('auth.loginSuccess'), color: 'success' })
} else if (result.needsPasswordReset) {
await resetVerification()
await router.push({
path: '/reset-password',
query: { username: result.resetUsername ?? username.value.trim() }
})
} else { } else {
await resetVerification()
toast.add({ toast.add({
title: result.error ?? (legacyMode.value ? t('auth.loginFailedSecret') : t('auth.loginFailedCredentials')), title: result.error ?? (legacyMode.value ? t('auth.loginFailedSecret') : t('auth.loginFailedCredentials')),
color: 'error' color: 'error'
@@ -152,6 +185,16 @@ async function submit() {
</div> </div>
</template> </template>
<LoginVerification
:key="verificationMountKey"
ref="verificationRef"
:method="loginVerification.method"
:turnstile-site-key="loginVerification.turnstileSiteKey"
:cap-api-endpoint="loginVerification.capApiEndpoint"
@verified="onVerificationVerified"
@cleared="onVerificationCleared"
/>
<UButton <UButton
type="submit" type="submit"
:label="t('auth.login')" :label="t('auth.login')"
+61
View File
@@ -0,0 +1,61 @@
<script setup lang="ts">
const props = withDefaults(
defineProps<{
code: string
scrollable?: boolean
lang?: string
}>(),
{
scrollable: true,
lang: ''
}
)
const toast = useToast()
const { t } = useI18n()
async function copyCode() {
try {
await navigator.clipboard.writeText(props.code)
toast.add({ title: t('copy.curl'), color: 'success' })
} catch {
toast.add({ title: t('copy.failed'), color: 'error' })
}
}
</script>
<template>
<div class="overflow-hidden rounded-xl bg-neutral-950">
<div
class="flex items-center justify-between border-b border-neutral-800/80 bg-neutral-900 px-3.5 py-2.5"
>
<div
class="flex items-center gap-2"
aria-hidden="true"
>
<span class="size-3 rounded-full border border-black/15 bg-[#ff5f56]" />
<span class="size-3 rounded-full border border-black/15 bg-[#ffbd2e]" />
<span class="size-3 rounded-full border border-black/15 bg-[#27c93f]" />
<span
v-if="lang"
class="ml-1 text-[10px] font-medium tracking-wide text-neutral-500 uppercase"
>
{{ lang }}
</span>
</div>
<UButton
icon="i-lucide-copy"
:label="t('common.copy')"
size="xs"
variant="ghost"
color="neutral"
class="text-neutral-300 hover:text-white"
@click="copyCode"
/>
</div>
<pre
class="overflow-auto p-4 text-xs leading-relaxed text-neutral-100"
:class="scrollable ? 'max-h-80' : ''"
><code class="font-mono whitespace-pre">{{ code }}</code></pre>
</div>
</template>
+55 -8
View File
@@ -13,20 +13,51 @@ const mobileUserOpen = ref(false)
const navItems = computed(() => { const navItems = computed(() => {
const items = [ const items = [
{ label: t('nav.api'), to: '/api', icon: 'i-lucide-code-2' }, { label: t('nav.api'), to: '/api', icon: 'i-lucide-code-2' },
{ label: t('nav.stats'), to: '/stats', icon: 'i-lucide-chart-column' } { label: t('nav.gallery'), to: '/gallery', icon: 'i-lucide-images' },
{ label: t('nav.videoLibrary'), to: '/video-library', icon: 'i-lucide-film' },
{ label: t('nav.fileLibrary'), to: '/file-library', icon: 'i-lucide-folder-open' }
] ]
if (isAdmin.value) { if (isAdmin.value) {
items.push({ label: t('nav.users'), to: '/users', icon: 'i-lucide-users' })
items.push({ label: t('nav.storage'), to: '/storage', icon: 'i-lucide-hard-drive' }) items.push({ label: t('nav.storage'), to: '/storage', icon: 'i-lucide-hard-drive' })
items.push({ label: t('nav.settings'), to: '/settings', icon: 'i-lucide-settings' })
} }
items.push({ label: t('nav.settings'), to: '/settings', icon: 'i-lucide-settings' })
return items return items
}) })
/** 三个上传入口,与顶部 Logo 并列 */
const hostItems = computed(() => [
{
label: t('host.image'),
to: '/',
icon: 'i-lucide-image',
title: t('host.imageFull')
},
{
label: t('host.video'),
to: '/videos',
icon: 'i-lucide-film',
title: t('host.videoFull')
},
{
label: t('host.file'),
to: '/files',
icon: 'i-lucide-folder-open',
title: t('host.fileFull')
}
])
const mobileNavItems = computed(() => [...hostItems.value, ...navItems.value])
function isHostActive(to: string) {
return route.path === to || route.path.startsWith(`${to}/`)
}
const accountMenuItems = computed<DropdownMenuItem[]>(() => [ const accountMenuItems = computed<DropdownMenuItem[]>(() => [
{ {
label: t('nav.logs'), label: t('profile.title'),
icon: 'i-lucide-scroll-text', icon: 'i-lucide-user-round',
to: '/logs' to: '/account/profile'
}, },
{ {
label: t('nav.changePassword'), label: t('nav.changePassword'),
@@ -48,7 +79,7 @@ const desktopUserMenuItems = computed<DropdownMenuItem[][]>(() => [
function isNavActive(to: string) { function isNavActive(to: string) {
if (to === '/settings') { if (to === '/settings') {
return route.path === '/settings' return route.path.startsWith('/settings')
} }
return route.path.startsWith(to) return route.path.startsWith(to)
} }
@@ -71,9 +102,10 @@ async function handleLogout() {
<header class="sticky top-0 z-40 border-b border-default bg-default/80 backdrop-blur"> <header class="sticky top-0 z-40 border-b border-default bg-default/80 backdrop-blur">
<div class="mx-auto max-w-7xl px-4 py-2.5 sm:px-6"> <div class="mx-auto max-w-7xl px-4 py-2.5 sm:px-6">
<div class="flex items-center justify-between gap-3"> <div class="flex items-center justify-between gap-3">
<div class="flex min-w-0 items-center gap-2 sm:gap-3">
<NuxtLink <NuxtLink
to="/" to="/"
:title="t('nav.backToUpload')" :title="t('host.imageFull')"
class="group flex min-w-0 shrink items-center gap-2.5 transition-opacity hover:opacity-90 sm:gap-3" class="group flex min-w-0 shrink items-center gap-2.5 transition-opacity hover:opacity-90 sm:gap-3"
> >
<img <img
@@ -100,6 +132,21 @@ async function handleLogout() {
</div> </div>
</NuxtLink> </NuxtLink>
<nav class="hidden items-center gap-1 sm:flex">
<UButton
v-for="item in hostItems"
:key="item.to"
:to="item.to"
:icon="item.icon"
:label="item.label"
:title="item.title"
size="sm"
:variant="isHostActive(item.to) ? 'soft' : 'ghost'"
:color="isHostActive(item.to) ? 'primary' : 'neutral'"
/>
</nav>
</div>
<ClientOnly> <ClientOnly>
<div class="flex shrink-0 items-center gap-0.5 sm:gap-1"> <div class="flex shrink-0 items-center gap-0.5 sm:gap-1">
<nav class="hidden items-center gap-1 sm:flex"> <nav class="hidden items-center gap-1 sm:flex">
@@ -129,7 +176,7 @@ async function handleLogout() {
/> />
<MobileNavMenu <MobileNavMenu
v-model:open="mobileNavOpen" v-model:open="mobileNavOpen"
:items="navItems" :items="mobileNavItems"
/> />
</div> </div>
+112
View File
@@ -0,0 +1,112 @@
<script setup lang="ts">
import type { ImageTag } from '~/types/image'
const props = defineProps<{
open: boolean
tags: ImageTag[]
selectedCount: number
mode?: 'add' | 'remove'
/** 仅添加标签,隐藏添加/移除切换(首页批量打标) */
addOnly?: boolean
}>()
const emit = defineEmits<{
'update:open': [value: boolean]
'confirm': [tagIds: number[], action: 'add' | 'remove']
}>()
const { t } = useI18n()
const action = ref<'add' | 'remove'>(props.mode ?? 'add')
const selectedTagIds = ref<number[]>([])
const { loadRecentIds } = useRecentTags()
watch(() => props.open, (open) => {
if (open) {
action.value = props.mode ?? 'add'
selectedTagIds.value = []
loadRecentIds()
}
})
function submit() {
if (!selectedTagIds.value.length) return
emit('confirm', selectedTagIds.value, action.value)
}
</script>
<template>
<UModal
:open="open"
:title="addOnly ? t('tags.batchAddTitle') : t('tags.batchTitle')"
:description="addOnly
? t('tags.batchAddSubtitle', { n: selectedCount })
: t('tags.batchSubtitle', { n: selectedCount })"
:ui="{ content: 'max-w-md' }"
@update:open="emit('update:open', $event)"
>
<template #body>
<div class="space-y-4">
<div
v-if="!addOnly"
class="flex gap-1 rounded-lg border border-default p-0.5"
>
<UButton
type="button"
size="xs"
class="flex-1 justify-center"
:variant="action === 'add' ? 'solid' : 'ghost'"
:color="action === 'add' ? 'primary' : 'neutral'"
:label="t('tags.batchAdd')"
@click="() => { action = 'add' }"
/>
<UButton
type="button"
size="xs"
class="flex-1 justify-center"
:variant="action === 'remove' ? 'solid' : 'ghost'"
:color="action === 'remove' ? 'primary' : 'neutral'"
:label="t('tags.batchRemove')"
@click="() => { action = 'remove' }"
/>
</div>
<TagAssignPanel
v-model="selectedTagIds"
:tags="tags"
:allow-create="action === 'add'"
/>
</div>
</template>
<template #footer>
<div class="flex w-full flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<p
v-if="addOnly"
class="flex items-center gap-1.5 text-xs text-muted"
>
<UIcon
name="i-lucide-info"
class="size-3.5 shrink-0"
/>
{{ t('tags.classifyHint') }}
</p>
<div class="flex flex-1 justify-end gap-2">
<UButton
variant="outline"
color="neutral"
@click="emit('update:open', false)"
>
{{ t('common.cancel') }}
</UButton>
<UButton
color="primary"
:disabled="!selectedTagIds.length"
@click="submit"
>
{{ t('common.save') }}
</UButton>
</div>
</div>
</template>
</UModal>
</template>
+62
View File
@@ -0,0 +1,62 @@
<script setup lang="ts">
const props = defineProps<{
apiEndpoint: string
}>()
const emit = defineEmits<{
verified: [payload: { capToken: string }]
cleared: []
}>()
const { t } = useI18n()
const widgetRef = ref<HTMLElement | null>(null)
const ready = ref(false)
onMounted(async () => {
const capWindow = window as Window & {
CAP_CUSTOM_WASM_URL?: string
CAP_CUSTOM_HASHWX_URL?: string
}
// SHA-256 与 hashwx 的求解器都放在本站 /cap,避免组件去 jsDelivr 拉 wasm。
capWindow.CAP_CUSTOM_WASM_URL = '/cap/cap_wasm_bg.wasm'
capWindow.CAP_CUSTOM_HASHWX_URL = '/cap/hashwx.wasm'
await import('cap-widget')
ready.value = true
})
function onSolve(event: Event) {
const token = (event as CustomEvent<{ token: string }>).detail?.token
if (token) {
emit('verified', { capToken: token })
}
}
function onError() {
emit('cleared')
}
function reset() {
const widget = widgetRef.value as (HTMLElement & { reset?: () => void }) | null
widget?.reset?.()
emit('cleared')
}
defineExpose({ reset })
</script>
<template>
<ClientOnly>
<cap-widget
v-if="ready"
ref="widgetRef"
:data-cap-api-endpoint="props.apiEndpoint"
:data-cap-i18n-initial-state="t('auth.capInitialState')"
:data-cap-i18n-verifying-label="t('auth.capVerifying')"
:data-cap-i18n-solved-label="t('auth.capSolved')"
:data-cap-i18n-error-label="t('auth.capError')"
@solve="onSolve"
@error="onError"
/>
</ClientOnly>
</template>
+159
View File
@@ -0,0 +1,159 @@
<script setup lang="ts">
const props = defineProps<{
open: boolean
}>()
const emit = defineEmits<{
'update:open': [value: boolean]
'cleared': []
}>()
const { t } = useI18n()
const toast = useToast()
const step = ref<'warning' | 'confirm'>('warning')
const confirmation = ref('')
const clearing = ref(false)
const canConfirm = computed(() =>
!clearing.value && confirmation.value.trim() === t('storage.clearStoragePhrase')
)
watch(() => props.open, (open) => {
if (open) {
step.value = 'warning'
confirmation.value = ''
clearing.value = false
}
})
function close() {
if (clearing.value) return
emit('update:open', false)
}
function goToConfirm() {
step.value = 'confirm'
}
function clearBrowserCache() {
const keys = [
'pichost.upload-preferences',
'pichost.upload-preferences.video',
'pichost.upload-preferences.file',
'pichost.recent-tag-ids',
'pichost.library-view.image',
'pichost.library-view.video',
'pichost.library-view.file'
]
for (const key of keys) localStorage.removeItem(key)
}
async function confirmClear() {
if (!canConfirm.value) return
clearing.value = true
try {
const result = await $fetch<{
deletedMedia: number
deletedObjects: number
clearedCacheDirs: number
failed: Array<{ key: string }>
}>('/api/user/storage/purge', {
method: 'POST',
credentials: 'include',
body: { confirmation: confirmation.value.trim() }
})
clearBrowserCache()
emit('cleared')
if (result.failed.length) {
toast.add({
title: t('storage.clearStoragePartial', {
failed: result.failed.length,
total: result.deletedMedia + result.failed.length
}),
color: 'warning'
})
} else {
toast.add({
title: t('storage.clearStorageSuccess', {
files: result.deletedObjects,
cache: result.clearedCacheDirs
}),
color: 'success'
})
}
emit('update:open', false)
} catch (error: unknown) {
const message = error && typeof error === 'object' && 'data' in error
? (error as { data?: { error?: { message?: string } } }).data?.error?.message
: undefined
toast.add({
title: message || t('storage.clearStorageFailed'),
color: 'error'
})
} finally {
clearing.value = false
}
}
</script>
<template>
<UModal
:open="open"
:title="step === 'warning' ? t('storage.clearStorageTitle') : t('storage.clearStorageFinalTitle')"
@update:open="emit('update:open', $event)"
>
<template #body>
<template v-if="step === 'warning'">
<UAlert
color="error"
variant="subtle"
icon="i-lucide-octagon-alert"
:description="t('storage.clearStorageWarning')"
/>
<p class="mt-3 text-sm text-muted">
{{ t('storage.clearStorageScope') }}
</p>
</template>
<template v-else>
<p class="text-sm leading-6 text-muted">
{{ t('storage.clearStorageInstruction') }}
</p>
<UInput
v-model="confirmation"
class="mt-3 w-full"
:placeholder="t('storage.clearStoragePlaceholder')"
autofocus
/>
</template>
</template>
<template #footer>
<div class="flex w-full justify-end gap-2">
<UButton
:label="t('common.cancel')"
color="neutral"
variant="outline"
:disabled="clearing"
@click="close"
/>
<UButton
v-if="step === 'warning'"
:label="t('common.continue')"
color="error"
@click="goToConfirm"
/>
<UButton
v-else
:label="t('storage.clearStorageConfirm')"
color="error"
:loading="clearing"
:disabled="!canConfirm"
@click="confirmClear"
/>
</div>
</template>
</UModal>
</template>
+4 -1
View File
@@ -6,6 +6,8 @@ const props = defineProps<{
icon?: string icon?: string
block?: boolean block?: boolean
successTitle?: string successTitle?: string
variant?: 'solid' | 'outline' | 'soft' | 'subtle' | 'ghost' | 'link'
color?: 'primary' | 'secondary' | 'success' | 'info' | 'warning' | 'error' | 'neutral'
}>() }>()
const emit = defineEmits<{ const emit = defineEmits<{
@@ -76,7 +78,8 @@ async function copy() {
<template> <template>
<UButton <UButton
size="xs" size="xs"
variant="soft" :variant="variant ?? 'soft'"
:color="color"
:loading="copying" :loading="copying"
:disabled="!value" :disabled="!value"
:label="iconOnly ? undefined : label" :label="iconOnly ? undefined : label"
+16 -1
View File
@@ -1,10 +1,16 @@
<script setup lang="ts"> <script setup lang="ts">
import type { MediaType } from '~/types/media'
const props = defineProps<{ const props = defineProps<{
open: boolean open: boolean
count: number count: number
loading?: boolean loading?: boolean
/** 删除对象的类型,决定确认文案 */
kind?: MediaType
}>() }>()
const kind = computed<MediaType>(() => props.kind ?? 'image')
const emit = defineEmits<{ const emit = defineEmits<{
'update:open': [value: boolean] 'update:open': [value: boolean]
'confirm': [] 'confirm': []
@@ -12,7 +18,16 @@ const emit = defineEmits<{
const { t } = useI18n() const { t } = useI18n()
const description = computed(() => t('delete.confirmDesc', { count: props.count })) const description = computed(() => {
switch (kind.value) {
case 'video':
return t('delete.confirmDescVideo', { count: props.count })
case 'file':
return t('delete.confirmDescFile', { count: props.count })
default:
return t('delete.confirmDesc', { count: props.count })
}
})
function close() { function close() {
emit('update:open', false) emit('update:open', false)
+107
View File
@@ -0,0 +1,107 @@
<script setup lang="ts">
import type { FileItem } from '~/types/file'
import { formatFileSize as formatSize } from '~/composables/useFileSize'
const props = defineProps<{
item: FileItem
link: string
downloadLink: string
selected?: boolean
selectable?: boolean
}>()
const emit = defineEmits<{
'delete': []
'update:selected': [value: boolean]
}>()
const { t, locale } = useI18n()
const uploadedLabel = computed(() => {
try {
return new Date(props.item.uploadedAt).toLocaleString(locale.value, { hour12: false })
} catch {
return props.item.uploadedAt
}
})
function fileIcon(item: FileItem): string {
const type = item.contentType.toLowerCase()
if (type.startsWith('image/')) return 'i-lucide-image'
if (type.startsWith('video/')) return 'i-lucide-film'
if (type.startsWith('audio/')) return 'i-lucide-music'
if (type.includes('pdf')) return 'i-lucide-file-text'
if (
type.includes('zip')
|| type.includes('rar')
|| type.includes('7z')
|| type.includes('tar')
|| type.includes('gzip')
) {
return 'i-lucide-file-archive'
}
return 'i-lucide-file'
}
</script>
<template>
<div class="flex flex-col overflow-hidden rounded-xl border border-default bg-elevated">
<div class="relative flex aspect-[4/3] shrink-0 items-center justify-center bg-muted">
<UIcon
:name="fileIcon(item)"
class="size-12 text-dimmed"
/>
<div
v-if="selectable"
class="absolute top-2 left-2 z-10"
@click.stop
>
<UCheckbox
:model-value="selected"
@update:model-value="emit('update:selected', $event === true)"
/>
</div>
</div>
<div class="flex flex-1 flex-col gap-1.5 p-3">
<p
class="truncate text-sm font-semibold"
:title="item.originalName"
>
{{ item.originalName }}
</p>
<p class="truncate text-xs text-muted">
{{ uploadedLabel }} · {{ formatSize(item.size) }}
</p>
<TagBadgeList :tags="item.tags" />
</div>
<div class="flex items-center justify-around border-t border-default px-2 py-2">
<CopyButton
icon-only
icon="i-lucide-link"
:label="t('files.copyLink')"
:value="link"
/>
<UButton
size="xs"
variant="ghost"
color="neutral"
icon="i-lucide-download"
:to="downloadLink"
target="_blank"
rel="noopener"
:aria-label="t('files.download')"
/>
<UButton
size="xs"
variant="ghost"
color="neutral"
icon="i-lucide-trash-2"
:aria-label="t('common.delete')"
@click="emit('delete')"
/>
</div>
</div>
</template>
+727
View File
@@ -0,0 +1,727 @@
<script setup lang="ts">
import type { FileItem, FileListResponse } from '~/types/file'
import type { LibraryStatsData } from '~/types/media'
import { formatFileSize } from '~/composables/useFileSize'
import SiteSyncSelectedDialog from '~/components/storage/SiteSyncSelectedDialog.vue'
const props = withDefaults(defineProps<{
/** 文件库(file)与视频库结构一致,保留参数以便复用 */
kind?: 'file' | 'video'
}>(), {
kind: 'file'
})
const { isAuthenticated, handleAuthError } = useAuth()
const toast = useToast()
const { t, locale } = useI18n()
const { items: tagItems, fetchTags, batchUpdateTags } = useTags()
const items = ref<FileItem[]>([])
const total = ref(0)
const page = ref(1)
const pageSize = ref(20)
const totalPages = ref(1)
const loading = ref(false)
const stats = ref<LibraryStatsData | null>(null)
const searchQuery = ref('')
const activeSearch = ref('')
const accessMode = ref<'public' | 'token'>('public')
const apiToken = ref('')
const selectedKeys = ref<Set<string>>(new Set())
const deleteModalOpen = ref(false)
const deleteTargetKeys = ref<string[]>([])
const deleting = ref(false)
const batchTagOpen = ref(false)
const syncOpen = ref(false)
const currentBackend = ref('all')
const currentSource = ref('all')
const backendOptions = ref<Array<{ id: string, name: string }>>([])
const activeTagIds = ref<number[]>([])
const activeTagMode = ref<'or' | 'and'>('or')
const activeUntaggedOnly = ref(false)
const { viewMode } = useLibraryView(props.kind)
const selectedCount = computed(() => selectedKeys.value.size)
const syncKeys = computed(() => [...selectedKeys.value])
const pageText = computed(() => props.kind === 'video'
? {
title: t('library.video.title'),
subtitle: t('library.video.subtitle'),
empty: t('library.video.empty')
}
: {
title: t('library.file.title'),
subtitle: t('library.file.subtitle'),
empty: t('library.file.empty')
})
const allSelected = computed(() =>
items.value.length > 0 && items.value.every(item => selectedKeys.value.has(item.key))
)
const backendItems = computed(() => [
{ label: t('stats.filterStorageAll'), value: 'all' },
...backendOptions.value.map(backend => ({ label: backend.name, value: backend.id }))
])
const sourceItems = computed(() => [
{ label: t('stats.filterSourceAll'), value: 'all' },
{ label: t('stats.sourceWeb'), value: 'web' },
{ label: t('stats.sourceApi'), value: 'api' }
])
function withToken(raw: string | undefined): string {
if (!raw) return ''
if (accessMode.value !== 'token' || !apiToken.value) return raw
const separator = raw.includes('?') ? '&' : '?'
return `${raw}${separator}token=${encodeURIComponent(apiToken.value)}`
}
function fileLink(item: FileItem): string {
return withToken(item.url)
}
function displayName(item: FileItem): string {
return item.originalName
}
function uploadedLabel(item: FileItem): string {
try {
return new Date(item.uploadedAt).toLocaleString(locale.value, { hour12: false })
} catch {
return item.uploadedAt
}
}
function fileIcon(item: FileItem): string {
const type = item.contentType.toLowerCase()
if (type.startsWith('image/')) return 'i-lucide-image'
if (type.startsWith('video/')) return 'i-lucide-film'
if (type.startsWith('audio/')) return 'i-lucide-music'
if (type.includes('pdf')) return 'i-lucide-file-text'
if (
type.includes('zip')
|| type.includes('rar')
|| type.includes('7z')
|| type.includes('tar')
|| type.includes('gzip')
) {
return 'i-lucide-file-archive'
}
return 'i-lucide-file'
}
async function loadApiToken() {
try {
const data = await $fetch<{ apiUploadToken: string }>('/api/user/api-token', {
credentials: 'include'
})
apiToken.value = data.apiUploadToken ?? ''
} catch {
apiToken.value = ''
}
}
async function loadBackendOptions() {
try {
const data = await $fetch<{ backends: Array<{ id: string, name: string }> }>(
'/api/storage-backends',
{ credentials: 'include' }
)
backendOptions.value = data.backends ?? []
} catch (error: unknown) {
handleAuthError(error)
}
}
async function fetchStats() {
if (!isAuthenticated.value) return
try {
stats.value = await $fetch<LibraryStatsData>('/api/library/stats', {
credentials: 'include',
query: { kind: props.kind }
})
} catch (error: unknown) {
handleAuthError(error)
}
}
async function fetchList(targetPage = page.value) {
if (!isAuthenticated.value) return
loading.value = true
try {
const response = await $fetch<FileListResponse>('/api/files', {
credentials: 'include',
query: {
limit: pageSize.value,
page: targetPage,
kind: props.kind,
...(activeSearch.value ? { q: activeSearch.value } : {}),
...(currentBackend.value !== 'all' ? { backendId: currentBackend.value } : {}),
...(currentSource.value !== 'all' ? { uploadSource: currentSource.value } : {}),
...(activeTagIds.value.length ? { tagIds: activeTagIds.value.join(',') } : {}),
...(activeUntaggedOnly.value ? { untagged: '1' } : {}),
...(activeTagMode.value === 'and' ? { tagMode: 'and' } : {})
}
})
items.value = response.items
total.value = response.total
page.value = response.page
totalPages.value = response.totalPages
accessMode.value = response.accessMode
const visible = new Set(response.items.map(item => item.key))
selectedKeys.value = new Set([...selectedKeys.value].filter(key => visible.has(key)))
if (response.accessMode === 'token' && !apiToken.value) {
await loadApiToken()
}
} catch (error: unknown) {
handleAuthError(error)
if (isAuthenticated.value) {
toast.add({ title: t('files.loadFailed'), color: 'error' })
}
} finally {
loading.value = false
}
}
async function refresh() {
await Promise.all([fetchList(1), fetchStats(), fetchTags()])
}
async function submitSearch() {
if (!isAuthenticated.value) return
activeSearch.value = searchQuery.value.trim()
await fetchList(1)
}
async function handleBackendChange(value: string) {
currentBackend.value = value
selectedKeys.value = new Set()
await fetchList(1)
}
async function handleSourceChange(value: string) {
currentSource.value = value
selectedKeys.value = new Set()
await fetchList(1)
}
async function handleTagIdsChange(tagIds: number[]) {
activeTagIds.value = tagIds
selectedKeys.value = new Set()
await fetchList(1)
}
async function handleTagModeChange(tagMode: 'or' | 'and') {
activeTagMode.value = tagMode
await fetchList(1)
}
async function handleUntaggedChange(untaggedOnly: boolean) {
activeUntaggedOnly.value = untaggedOnly
selectedKeys.value = new Set()
await fetchList(1)
}
async function resetFilters() {
searchQuery.value = ''
activeSearch.value = ''
currentBackend.value = 'all'
currentSource.value = 'all'
activeTagIds.value = []
activeTagMode.value = 'or'
activeUntaggedOnly.value = false
selectedKeys.value = new Set()
await fetchList(1)
}
function toggleSelected(key: string, value: boolean) {
const next = new Set(selectedKeys.value)
if (value) next.add(key)
else next.delete(key)
selectedKeys.value = next
}
function toggleSelectAll() {
selectedKeys.value = allSelected.value
? new Set()
: new Set(items.value.map(item => item.key))
}
function clearSelection() {
selectedKeys.value = new Set()
}
function setViewMode(mode: 'grid' | 'list') {
viewMode.value = mode
}
function openBatchTags() {
batchTagOpen.value = true
}
function openSync() {
syncOpen.value = true
}
function requestDelete(item: FileItem) {
if (!isAuthenticated.value) return
deleteTargetKeys.value = [item.key]
deleteModalOpen.value = true
}
function requestBatchDelete() {
if (!isAuthenticated.value || !selectedCount.value) return
deleteTargetKeys.value = [...selectedKeys.value]
deleteModalOpen.value = true
}
async function confirmDelete() {
const keys = [...deleteTargetKeys.value]
if (!keys.length || !isAuthenticated.value) return
deleting.value = true
try {
if (keys.length === 1) {
await $fetch('/api/files', {
method: 'DELETE',
credentials: 'include',
query: { key: keys[0] }
})
} else {
const response = await $fetch<{ deleted: string[], failed: unknown[] }>(
'/api/files/batch-delete',
{ method: 'POST', credentials: 'include', body: { keys } }
)
if (response.failed.length) {
toast.add({
title: t('library.deletePartialFail', { n: response.failed.length }),
color: 'warning'
})
}
}
toast.add({ title: t('library.fileDeleted', { n: keys.length }), color: 'success' })
deleteModalOpen.value = false
deleteTargetKeys.value = []
selectedKeys.value = new Set()
await refresh()
} catch (error: unknown) {
handleAuthError(error)
if (isAuthenticated.value) {
toast.add({ title: t('files.deleteFailed'), color: 'error' })
}
} finally {
deleting.value = false
}
}
async function confirmBatchTags(tagIds: number[], action: 'add' | 'remove') {
const keys = [...selectedKeys.value]
if (!keys.length || !tagIds.length) return
try {
await batchUpdateTags(keys, tagIds, action)
batchTagOpen.value = false
selectedKeys.value = new Set()
toast.add({ title: t('tags.batchSuccess'), color: 'success' })
await Promise.all([fetchList(1), fetchStats(), fetchTags(true)])
} catch (error: unknown) {
handleAuthError(error)
toast.add({ title: t('tags.batchFailed'), color: 'error' })
}
}
async function handlePageChange(targetPage: number) {
selectedKeys.value = new Set()
await fetchList(targetPage)
}
async function handlePageSizeChange(size: number) {
pageSize.value = size
selectedKeys.value = new Set()
await fetchList(1)
}
onMounted(() => {
void loadBackendOptions()
})
defineExpose({ refresh })
</script>
<template>
<div class="space-y-6">
<div class="flex items-start justify-between gap-3">
<div class="min-w-0">
<h1 class="text-2xl font-semibold tracking-tight">
{{ pageText.title }}
</h1>
<p class="text-sm text-muted">
{{ pageText.subtitle }}
</p>
</div>
<UButton
icon="i-lucide-refresh-cw"
variant="ghost"
color="neutral"
size="sm"
class="mt-0.5 shrink-0"
:aria-label="t('common.refresh')"
:loading="loading"
@click="refresh"
/>
</div>
<LibraryStatsPanel
:kind="props.kind"
:stats="stats"
:loading="loading"
/>
<section class="space-y-3">
<div class="flex flex-wrap items-center justify-between gap-x-4 gap-y-2">
<div class="flex items-center gap-2 text-sm">
<UCheckbox
:model-value="allSelected"
:disabled="!items.length"
@update:model-value="toggleSelectAll"
/>
<span class="text-muted">
{{ t('library.selectedCount', { n: selectedCount }) }}
</span>
<UButton
v-if="selectedCount > 0"
variant="link"
color="primary"
size="xs"
:label="t('library.clearSelection')"
@click="clearSelection"
/>
</div>
<div class="flex flex-wrap items-center gap-2">
<UInput
v-model="searchQuery"
icon="i-lucide-search"
:placeholder="t('library.searchPlaceholder')"
size="sm"
class="w-44"
@keydown.enter.prevent="submitSearch"
/>
<USelect
:model-value="currentBackend"
:items="backendItems"
value-key="value"
size="sm"
class="w-32"
:aria-label="t('stats.filterStorageAll')"
@update:model-value="handleBackendChange"
/>
<USelect
:model-value="currentSource"
:items="sourceItems"
value-key="value"
size="sm"
class="w-28"
:aria-label="t('stats.filterSourceAll')"
@update:model-value="handleSourceChange"
/>
<TagFilterSelect
:model-value="activeTagIds"
:tag-mode="activeTagMode"
:untagged-only="activeUntaggedOnly"
:tags="tagItems"
:disabled="loading"
@update:model-value="handleTagIdsChange"
@update:tag-mode="handleTagModeChange"
@update:untagged-only="handleUntaggedChange"
/>
<UButton
icon="i-lucide-search"
size="sm"
:label="t('common.search')"
:loading="loading"
@click="submitSearch"
/>
<UButton
icon="i-lucide-rotate-ccw"
variant="outline"
color="neutral"
size="sm"
:label="t('library.reset')"
@click="resetFilters"
/>
<UButton
v-if="selectedCount > 0"
icon="i-lucide-tags"
variant="outline"
color="primary"
size="sm"
:disabled="!selectedCount || loading"
:label="t('tags.batchAdd')"
@click="openBatchTags"
/>
<UButton
icon="i-lucide-trash-2"
variant="outline"
color="error"
size="sm"
:disabled="!selectedCount || loading"
:label="t('library.batchDelete')"
@click="requestBatchDelete"
/>
<UButton
v-if="selectedCount > 0"
icon="i-lucide-cloud-upload"
variant="outline"
color="neutral"
size="sm"
:disabled="!selectedCount || loading"
:label="t('storage.backup.syncSelectedAction')"
@click="openSync"
/>
<div class="flex gap-0.5 rounded-lg border border-default p-0.5">
<UButton
icon="i-lucide-layout-grid"
size="xs"
:variant="viewMode === 'grid' ? 'solid' : 'ghost'"
:color="viewMode === 'grid' ? 'primary' : 'neutral'"
:aria-label="t('stats.viewGrid')"
@click="setViewMode('grid')"
/>
<UButton
icon="i-lucide-list"
size="xs"
:variant="viewMode === 'list' ? 'solid' : 'ghost'"
:color="viewMode === 'list' ? 'primary' : 'neutral'"
:aria-label="t('stats.viewList')"
@click="setViewMode('list')"
/>
</div>
</div>
</div>
<div
v-if="loading && !items.length"
class="grid gap-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4"
>
<USkeleton
v-for="n in 8"
:key="n"
class="aspect-[4/3] rounded-xl"
/>
</div>
<p
v-else-if="!items.length"
class="rounded-lg border border-default py-10 text-center text-sm text-muted"
>
{{ activeSearch ? t('library.searchEmpty') : pageText.empty }}
</p>
<div
v-else-if="viewMode === 'grid'"
class="grid gap-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4"
>
<FileCard
v-for="item in items"
:key="item.key"
:item="item"
:link="fileLink(item)"
:download-link="fileLink(item)"
selectable
:selected="selectedKeys.has(item.key)"
@update:selected="toggleSelected(item.key, $event)"
@delete="requestDelete(item)"
/>
</div>
<div
v-else
class="overflow-hidden rounded-xl border border-default bg-default"
>
<div class="overflow-x-auto">
<table class="min-w-full text-xs">
<thead class="border-b border-default bg-elevated/40 text-muted">
<tr>
<th class="w-10 px-3 py-2.5 text-left font-medium">
<UCheckbox
:model-value="allSelected"
:aria-label="t('stats.selectAll')"
@update:model-value="toggleSelectAll"
/>
</th>
<th class="min-w-[14rem] px-3 py-2.5 text-left font-medium">
{{ t('stats.colFilename') }}
</th>
<th class="hidden w-24 px-3 py-2.5 text-left font-medium sm:table-cell">
{{ t('stats.colSource') }}
</th>
<th class="w-20 px-3 py-2.5 text-left font-medium">
{{ t('stats.colSize') }}
</th>
<th class="hidden w-28 px-3 py-2.5 text-left font-medium md:table-cell">
{{ t('stats.colType') }}
</th>
<th class="hidden w-40 px-3 py-2.5 text-left font-medium lg:table-cell">
{{ t('stats.colUploadedAt') }}
</th>
<th class="hidden min-w-[9rem] px-3 py-2.5 text-left font-medium md:table-cell">
{{ t('tags.column') }}
</th>
<th class="w-24 px-3 py-2.5 text-right font-medium">
{{ t('stats.colActions') }}
</th>
</tr>
</thead>
<tbody class="divide-y divide-default">
<tr
v-for="item in items"
:key="item.key"
class="transition-colors hover:bg-elevated/50"
:class="selectedKeys.has(item.key) ? 'bg-primary/5' : ''"
>
<td class="px-3 py-2">
<UCheckbox
:model-value="selectedKeys.has(item.key)"
@update:model-value="toggleSelected(item.key, $event === true)"
/>
</td>
<td class="px-3 py-2.5">
<div class="flex min-w-0 items-center gap-3">
<span class="flex size-10 shrink-0 items-center justify-center rounded-md border border-default bg-muted">
<UIcon
:name="fileIcon(item)"
class="size-4 text-dimmed"
/>
</span>
<p
class="min-w-0 flex-1 truncate text-highlighted"
:title="displayName(item)"
>
{{ displayName(item) }}
</p>
</div>
</td>
<td class="hidden px-3 py-2 sm:table-cell">
<span
v-if="item.uploadSource"
class="inline-flex items-center gap-1 rounded-md border border-default px-1.5 py-0.5 text-[11px] leading-4 text-muted"
>
<UIcon
:name="item.uploadSource === 'api' ? 'i-lucide-code-xml' : 'i-lucide-globe'"
class="size-3 shrink-0"
/>
{{ item.uploadSource === 'api' ? t('stats.tagApi') : t('stats.tagWeb') }}
</span>
<span
v-else
class="text-muted"
>—</span>
</td>
<td class="px-3 py-2 tabular-nums text-muted">
{{ formatFileSize(item.size) }}
</td>
<td class="hidden px-3 py-2 md:table-cell">
<span
v-if="item.storage"
class="inline-flex items-center gap-1 rounded-md border border-default px-1.5 py-0.5 text-[11px] leading-4 text-muted"
>
<UIcon
:name="item.storage.type === 'local' ? 'i-lucide-hard-drive' : 'i-lucide-cloud'"
class="size-3 shrink-0"
/>
{{ item.storage.name }}
</span>
<span
v-else
class="text-muted"
>—</span>
</td>
<td class="hidden px-3 py-2 text-muted lg:table-cell">
{{ uploadedLabel(item) }}
</td>
<td class="hidden px-3 py-2 md:table-cell">
<TagBadgeList
:tags="item.tags"
compact
:empty-label="t('tags.unlabeled')"
/>
</td>
<td class="px-3 py-2">
<div class="flex items-center justify-end gap-1">
<CopyButton
icon-only
icon="i-lucide-link"
:label="t('files.copyLink')"
:value="fileLink(item)"
/>
<UButton
size="xs"
variant="ghost"
color="neutral"
icon="i-lucide-download"
:to="fileLink(item)"
target="_blank"
rel="noopener"
:aria-label="t('files.download')"
/>
<UButton
size="xs"
variant="ghost"
color="error"
icon="i-lucide-trash-2"
:aria-label="t('common.delete')"
@click="requestDelete(item)"
/>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<PaginationBar
v-if="!loading && total > 0"
:page="page"
:total-pages="totalPages"
:total="total"
:page-size="pageSize"
unit=""
:loading="loading"
@update:page="handlePageChange"
@update:page-size="handlePageSizeChange"
/>
</section>
<DeleteConfirmModal
v-model:open="deleteModalOpen"
:count="deleteTargetKeys.length"
:loading="deleting"
:kind="props.kind"
@confirm="confirmDelete"
/>
<BatchTagModal
v-model:open="batchTagOpen"
:tags="tagItems"
:selected-count="selectedCount"
@confirm="confirmBatchTags"
/>
<SiteSyncSelectedDialog
v-model:open="syncOpen"
:keys="syncKeys"
scope="file"
/>
</div>
</template>
+100
View File
@@ -0,0 +1,100 @@
<script setup lang="ts">
import type { FileItem } from '~/types/file'
const props = defineProps<{
open: boolean
item: FileItem | null
}>()
const emit = defineEmits<{
'update:open': [value: boolean]
}>()
const { t } = useI18n()
const toast = useToast()
const downloading = ref(false)
watch(() => props.open, (open) => {
if (!open) downloading.value = false
})
async function confirmDownload() {
const item = props.item
if (!item?.hlsKey || downloading.value) return
downloading.value = true
try {
const response = await fetch('/api/files/hls-mp4', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ key: item.key })
})
if (!response.ok) {
const payload = await response.json().catch(() => null) as {
error?: { message?: string }
} | null
throw new Error(payload?.error?.message || t('library.video.downloadMp4Failed'))
}
const blob = await response.blob()
const objectUrl = URL.createObjectURL(blob)
const dot = item.originalName.lastIndexOf('.')
const filename = `${dot > 0 ? item.originalName.slice(0, dot) : item.originalName}.mp4`
const link = document.createElement('a')
link.href = objectUrl
link.download = filename
document.body.appendChild(link)
link.click()
link.remove()
setTimeout(() => URL.revokeObjectURL(objectUrl), 0)
emit('update:open', false)
} catch (error) {
toast.add({
title: error instanceof Error ? error.message : t('library.video.downloadMp4Failed'),
color: 'error'
})
} finally {
downloading.value = false
}
}
</script>
<template>
<UModal
:open="open && !!item"
:title="t('library.video.downloadMp4Title')"
@update:open="emit('update:open', $event)"
>
<template #body>
<p class="text-sm leading-6 text-muted">
{{ t('library.video.downloadMp4Confirm') }}
</p>
<p
v-if="downloading"
class="mt-3 text-sm text-primary"
>
{{ t('library.video.downloadMp4Working') }}
</p>
</template>
<template #footer>
<div class="flex w-full justify-end gap-2">
<UButton
:label="t('common.cancel')"
color="neutral"
variant="outline"
:disabled="downloading"
@click="emit('update:open', false)"
/>
<UButton
:label="t('common.continue')"
color="primary"
:loading="downloading"
@click="confirmDownload"
/>
</div>
</template>
</UModal>
</template>
+352 -18
View File
@@ -1,37 +1,94 @@
<script setup lang="ts"> <script setup lang="ts">
import type { ImageItem } from '~/types/image' import type { ImageItem } from '~/types/image'
import type { CopyFormat } from '~/composables/useUploadPreferences'
type CopyFormat = 'url' | 'markdown' | 'html' import { buildImageBbcode } from '~/utils/image-display'
const props = withDefaults(defineProps<{ const props = withDefaults(defineProps<{
image: ImageItem image: ImageItem
selected: boolean selected: boolean
deleting?: boolean
selectable?: boolean selectable?: boolean
showKey?: boolean showKey?: boolean
showStorage?: boolean showStorage?: boolean
compact?: boolean compact?: boolean
gallery?: boolean
allowDelete?: boolean
showTags?: boolean
showTagAction?: boolean
}>(), { }>(), {
selectable: true, selectable: true,
showKey: true, showKey: true,
showStorage: false, showStorage: false,
compact: false compact: false,
gallery: false,
allowDelete: true,
showTags: false,
showTagAction: false
}) })
const emit = defineEmits<{ const emit = defineEmits<{
'update:selected': [value: boolean] 'update:selected': [value: boolean]
'preview': []
'delete': [] 'delete': []
'tag-click': [tagId: number]
'untagged-click': []
'edit-tags': []
}>() }>()
const { formatFileSize } = useFileSize() const { formatFileSize } = useFileSize()
const { t, locale } = useI18n() const { t, locale } = useI18n()
const toast = useToast()
const { copyFormat } = useUploadPreferences()
const imageError = ref(false) const imageError = ref(false)
const copyFormat = ref<CopyFormat>('url') const retryCount = ref(0)
function selectCopyFormat(value: CopyFormat) {
copyFormat.value = value
}
const MAX_PREVIEW_RETRIES = 3
const PREVIEW_RETRY_DELAYS_MS = [1000, 2000, 4000]
const previewSrc = computed(() => {
if (retryCount.value === 0) return props.image.url
const separator = props.image.url.includes('?') ? '&' : '?'
return `${props.image.url}${separator}retry=${retryCount.value}`
})
watch(() => props.image.key, () => {
imageError.value = false
retryCount.value = 0
})
let retryTimer: ReturnType<typeof setTimeout> | undefined
function clearRetryTimer() {
if (retryTimer !== undefined) {
clearTimeout(retryTimer)
retryTimer = undefined
}
}
function onPreviewError() {
if (retryCount.value >= MAX_PREVIEW_RETRIES) {
imageError.value = true
return
}
const delay = PREVIEW_RETRY_DELAYS_MS[retryCount.value] ?? PREVIEW_RETRY_DELAYS_MS.at(-1)!
clearRetryTimer()
retryTimer = setTimeout(() => {
retryCount.value += 1
retryTimer = undefined
}, delay)
}
onUnmounted(clearRetryTimer)
const copyFormatItems = computed(() => [ const copyFormatItems = computed(() => [
{ label: t('copy.url'), value: 'url' as const }, { label: t('copy.url'), value: 'url' as const },
{ label: t('copy.markdown'), value: 'markdown' as const }, { label: t('copy.markdown'), value: 'markdown' as const },
{ label: t('copy.html'), value: 'html' as const } { label: t('copy.html'), value: 'html' as const },
{ label: t('copy.bbcode'), value: 'bbcode' as const }
]) ])
const uploadedLabel = computed(() => { const uploadedLabel = computed(() => {
@@ -48,6 +105,8 @@ const previewValue = computed(() => {
return props.image.markdown return props.image.markdown
case 'html': case 'html':
return props.image.html return props.image.html
case 'bbcode':
return buildImageBbcode(props.image.url)
default: default:
return props.image.url return props.image.url
} }
@@ -59,6 +118,8 @@ const copySuccessTitle = computed(() => {
return t('copy.copiedMarkdown') return t('copy.copiedMarkdown')
case 'html': case 'html':
return t('copy.copiedHtml') return t('copy.copiedHtml')
case 'bbcode':
return t('copy.copiedBbcode')
default: default:
return t('copy.copiedUrl') return t('copy.copiedUrl')
} }
@@ -68,21 +129,258 @@ const storageIcon = computed(() =>
props.image.storage?.type === 'local' ? 'i-lucide-hard-drive' : 'i-lucide-cloud' props.image.storage?.type === 'local' ? 'i-lucide-hard-drive' : 'i-lucide-cloud'
) )
const uploadSourceLabel = computed(() => {
switch (props.image.uploadSource) {
case 'web':
return t('stats.tagWeb')
case 'api':
return t('stats.tagApi')
default:
return null
}
})
const uploadSourceIcon = computed(() => {
switch (props.image.uploadSource) {
case 'web':
return 'i-lucide-globe'
case 'api':
return 'i-lucide-code-xml'
default:
return null
}
})
const GALLERY_INLINE_TAG_LIMIT = 3
const galleryTagDisplay = computed(() => {
const tags = props.image.tags ?? []
if (tags.length <= GALLERY_INLINE_TAG_LIMIT) {
return { visible: tags, hidden: [] as typeof tags }
}
return {
visible: tags.slice(0, GALLERY_INLINE_TAG_LIMIT),
hidden: tags.slice(GALLERY_INLINE_TAG_LIMIT)
}
})
const sessionVisibleTags = computed(() => props.image.tags ?? [])
const sessionTagActionLabel = computed(() =>
props.image.tags?.length ? t('tags.addTag') : t('tags.untagged')
)
function toggleSelected(value: boolean | 'indeterminate') { function toggleSelected(value: boolean | 'indeterminate') {
emit('update:selected', value === true) emit('update:selected', value === true)
} }
const copyingUrl = ref(false)
async function copyGalleryUrl() {
if (copyingUrl.value || !props.image.url) return
copyingUrl.value = true
try {
if (typeof navigator !== 'undefined' && navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(props.image.url)
} else {
const textarea = document.createElement('textarea')
textarea.value = props.image.url
textarea.setAttribute('readonly', '')
textarea.style.position = 'fixed'
textarea.style.left = '-9999px'
document.body.appendChild(textarea)
textarea.select()
document.execCommand('copy')
document.body.removeChild(textarea)
}
toast.add({ title: t('copy.copiedUrl'), color: 'success' })
} catch {
toast.add({ title: t('copy.failedManual'), color: 'error' })
} finally {
copyingUrl.value = false
}
}
</script> </script>
<template> <template>
<div class="flex flex-col overflow-hidden rounded-xl border border-default bg-elevated shadow-sm"> <div
<div class="relative aspect-square shrink-0 overflow-hidden bg-muted"> v-if="gallery"
class="flex flex-col overflow-hidden rounded-xl border border-default bg-elevated"
>
<div class="relative aspect-[4/3] shrink-0 overflow-hidden bg-muted">
<img <img
v-if="!imageError" v-if="!imageError"
:src="image.url" :key="`${image.key}-${retryCount}`"
:src="previewSrc"
:alt="image.originalName" :alt="image.originalName"
loading="lazy" loading="lazy"
class="absolute inset-0 size-full object-cover object-center" class="size-full cursor-zoom-in object-cover object-center"
@error="imageError = true" role="button"
tabindex="0"
:aria-label="t('image.openPreview')"
@click="emit('preview')"
@keydown.enter="emit('preview')"
@keydown.space.prevent="emit('preview')"
@error="onPreviewError"
>
<div
v-else
class="flex size-full items-center justify-center text-xs text-muted"
>
{{ t('image.previewFailed') }}
</div>
<div
v-if="selectable"
class="absolute top-2 left-2"
@click.stop
>
<UCheckbox
:model-value="selected"
:ui="{ base: 'shadow-none' }"
@update:model-value="toggleSelected"
/>
</div>
</div>
<div class="flex flex-col gap-1.5 p-3">
<p
class="truncate text-sm font-semibold"
:title="image.originalName"
>
{{ image.originalName }}
</p>
<p class="truncate text-xs text-muted">
{{ uploadedLabel }} · {{ formatFileSize(image.size) }}
</p>
<div
v-if="showTags"
class="flex flex-wrap items-center gap-1.5"
>
<template v-if="image.tags?.length">
<TagBadge
v-for="tag in galleryTagDisplay.visible"
:key="tag.id"
:tag="tag"
clickable
@click="emit('tag-click', tag.id)"
/>
<UPopover
v-if="galleryTagDisplay.hidden.length"
:content="{ side: 'bottom', align: 'start' }"
>
<button
type="button"
class="inline-flex rounded-md border border-default px-2 py-0.5 text-xs text-muted transition-colors hover:border-primary/40 hover:text-primary"
:aria-label="t('tags.moreTags', { n: galleryTagDisplay.hidden.length })"
@click.stop
>
+{{ galleryTagDisplay.hidden.length }}
</button>
<template #content>
<div
class="flex max-w-56 flex-wrap gap-1.5 p-2"
@click.stop
>
<TagBadge
v-for="tag in galleryTagDisplay.hidden"
:key="tag.id"
:tag="tag"
clickable
@click="emit('tag-click', tag.id)"
/>
</div>
</template>
</UPopover>
</template>
<button
v-else
type="button"
class="inline-flex rounded-md border border-dashed border-default px-2 py-0.5 text-xs text-muted transition-colors hover:border-primary/40 hover:text-primary"
@click.stop="emit('untagged-click')"
>
{{ t('tags.unlabeled') }}
</button>
</div>
<div
v-if="uploadSourceLabel || (showStorage && image.storage)"
class="flex flex-wrap items-center gap-1.5"
>
<span
v-if="showStorage && image.storage"
class="inline-flex max-w-full items-center gap-1 rounded-md border border-default px-2 py-0.5 text-xs text-muted"
:title="image.storage.name"
>
<UIcon
:name="storageIcon"
class="size-3 shrink-0"
/>
<span class="truncate">{{ image.storage.name }}</span>
</span>
<span
v-if="uploadSourceLabel && uploadSourceIcon"
class="inline-flex max-w-full items-center gap-1 rounded-md border border-default px-2 py-0.5 text-xs text-muted"
>
<UIcon
:name="uploadSourceIcon"
class="size-3 shrink-0"
/>
<span class="truncate">{{ uploadSourceLabel }}</span>
</span>
</div>
</div>
<div class="flex items-center justify-around border-t border-default px-2 py-2 text-muted">
<UButton
icon="i-lucide-link"
variant="ghost"
color="neutral"
size="sm"
:loading="copyingUrl"
:aria-label="t('common.copy')"
@click="copyGalleryUrl"
/>
<UButton
icon="i-lucide-eye"
variant="ghost"
color="neutral"
size="sm"
:aria-label="t('image.openPreview')"
@click="emit('preview')"
/>
<UButton
v-if="allowDelete"
icon="i-lucide-trash-2"
variant="ghost"
color="neutral"
size="sm"
:aria-label="t('common.delete')"
@click="emit('delete')"
/>
</div>
</div>
<div
v-else
class="flex flex-col overflow-hidden rounded-xl border border-default bg-elevated shadow-sm"
>
<div
class="relative aspect-square shrink-0 overflow-hidden bg-muted"
role="button"
tabindex="0"
:aria-label="t('image.openPreview')"
@click="emit('preview')"
@keydown.enter="emit('preview')"
@keydown.space.prevent="emit('preview')"
>
<img
v-if="!imageError"
:key="`${image.key}-${retryCount}`"
:src="previewSrc"
:alt="image.originalName"
loading="lazy"
class="absolute inset-0 size-full cursor-zoom-in object-cover object-center"
@error="onPreviewError"
> >
<div <div
v-else v-else
@@ -93,26 +391,32 @@ function toggleSelected(value: boolean | 'indeterminate') {
<div <div
v-if="selectable" v-if="selectable"
class="absolute top-2 left-2" class="absolute top-2 left-2"
@click.stop
> >
<UCheckbox <UCheckbox
:model-value="selected" :model-value="selected"
@update:model-value="toggleSelected" @update:model-value="toggleSelected"
/> />
</div> </div>
<div class="absolute top-2 right-2"> <div
v-if="allowDelete && showTagAction"
class="absolute top-2 right-2"
@click.stop
>
<UButton <UButton
size="xs"
color="error"
variant="solid"
icon="i-lucide-trash-2" icon="i-lucide-trash-2"
variant="solid"
color="neutral"
size="xs"
class="bg-black/55 text-white hover:bg-black/70"
:aria-label="t('common.delete')" :aria-label="t('common.delete')"
:loading="deleting"
@click="emit('delete')" @click="emit('delete')"
/> />
</div> </div>
<div <div
v-if="image.owner" v-if="image.owner"
class="absolute bottom-2 left-2 max-w-[calc(50%-0.5rem)]" class="absolute bottom-2 left-2 max-w-[calc(50%-0.5rem)]"
@click.stop
> >
<span <span
class="inline-flex max-w-full items-center gap-1 rounded-md bg-black/65 px-2 py-0.5 text-xs font-medium text-white shadow-sm backdrop-blur-sm" class="inline-flex max-w-full items-center gap-1 rounded-md bg-black/65 px-2 py-0.5 text-xs font-medium text-white shadow-sm backdrop-blur-sm"
@@ -129,6 +433,7 @@ function toggleSelected(value: boolean | 'indeterminate') {
v-if="showStorage && image.storage" v-if="showStorage && image.storage"
class="absolute bottom-2 max-w-[calc(50%-0.5rem)]" class="absolute bottom-2 max-w-[calc(50%-0.5rem)]"
:class="image.owner ? 'right-2' : 'left-2'" :class="image.owner ? 'right-2' : 'left-2'"
@click.stop
> >
<span <span
class="inline-flex max-w-full items-center gap-1 rounded-md bg-black/65 px-2 py-0.5 text-xs font-medium text-white shadow-sm backdrop-blur-sm" class="inline-flex max-w-full items-center gap-1 rounded-md bg-black/65 px-2 py-0.5 text-xs font-medium text-white shadow-sm backdrop-blur-sm"
@@ -177,6 +482,20 @@ function toggleSelected(value: boolean | 'indeterminate') {
> >
{{ image.originalName }} {{ image.originalName }}
</p> </p>
<button
v-if="showTagAction && image.tags?.length"
type="button"
class="flex flex-wrap items-center gap-1.5 text-left"
@click.stop="emit('edit-tags')"
>
<TagBadge
v-for="tag in sessionVisibleTags"
:key="tag.id"
:tag="tag"
/>
</button>
<p class="text-xs text-muted"> <p class="text-xs text-muted">
{{ uploadedLabel }} · {{ formatFileSize(image.size) }} {{ uploadedLabel }} · {{ formatFileSize(image.size) }}
</p> </p>
@@ -190,7 +509,7 @@ function toggleSelected(value: boolean | 'indeterminate') {
:variant="copyFormat === item.value ? 'solid' : 'ghost'" :variant="copyFormat === item.value ? 'solid' : 'ghost'"
:color="copyFormat === item.value ? 'primary' : 'neutral'" :color="copyFormat === item.value ? 'primary' : 'neutral'"
:label="item.label" :label="item.label"
@click="() => { copyFormat = item.value }" @click="selectCopyFormat(item.value)"
/> />
</div> </div>
@@ -202,13 +521,28 @@ function toggleSelected(value: boolean | 'indeterminate') {
class="min-w-0 flex-1 font-mono text-xs" class="min-w-0 flex-1 font-mono text-xs"
/> />
<CopyButton <CopyButton
icon="i-lucide-copy"
:label="t('common.copy')" :label="t('common.copy')"
:variant="showTagAction ? 'solid' : 'soft'"
:color="showTagAction ? 'primary' : undefined"
:icon="showTagAction ? undefined : 'i-lucide-copy'"
:value="previewValue" :value="previewValue"
:success-title="copySuccessTitle" :success-title="copySuccessTitle"
/> />
</div> </div>
<button
v-if="showTagAction"
type="button"
class="inline-flex items-center gap-1 self-start text-xs text-muted transition-colors hover:text-primary"
@click.stop="emit('edit-tags')"
>
<UIcon
name="i-lucide-plus"
class="size-3"
/>
{{ sessionTagActionLabel }}
</button>
<p <p
v-if="showKey" v-if="showKey"
class="truncate text-xs text-dimmed" class="truncate text-xs text-dimmed"
+36 -7
View File
@@ -4,24 +4,46 @@ import type { ImageItem } from '~/types/image'
const props = withDefaults(defineProps<{ const props = withDefaults(defineProps<{
items: ImageItem[] items: ImageItem[]
selectedKeys: Set<string> selectedKeys: Set<string>
deletingKeys: Set<string>
selectable?: boolean selectable?: boolean
showKey?: boolean showKey?: boolean
showStorage?: boolean showStorage?: boolean
emptyText?: string emptyText?: string
dense?: boolean dense?: boolean
compact?: boolean compact?: boolean
/** 图库:5 列 × 2 行,缩略图略扁 */
gallery?: boolean
allowDelete?: boolean
showTags?: boolean
showTagAction?: boolean
}>(), { }>(), {
selectable: true, selectable: true,
showKey: true, showKey: true,
showStorage: false, showStorage: false,
dense: false, dense: false,
compact: false compact: false,
gallery: false,
allowDelete: true,
showTags: false,
showTagAction: false
})
const gridClass = computed(() => {
if (props.gallery) {
return 'grid items-start grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5'
}
if (props.dense) {
return 'grid items-start grid-cols-2 gap-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4'
}
return 'grid items-start grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4'
}) })
const emit = defineEmits<{ const emit = defineEmits<{
'update:selectedKeys': [value: Set<string>] 'update:selectedKeys': [value: Set<string>]
'delete': [key: string] 'preview': [image: ImageItem]
'delete': [image: ImageItem]
'tag-click': [tagId: number]
'untagged-click': []
'edit-tags': [image: ImageItem]
}>() }>()
const { t } = useI18n() const { t } = useI18n()
@@ -39,21 +61,28 @@ function updateSelection(key: string, selected: boolean) {
<template> <template>
<div <div
v-if="items.length" v-if="items.length"
class="grid items-start sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4" class="items-start"
:class="dense ? 'grid-cols-2 gap-3' : 'grid-cols-1 gap-4'" :class="gridClass"
> >
<ImageCard <ImageCard
v-for="image in items" v-for="image in items"
:key="image.key" :key="image.key"
:image="image" :image="image"
:selected="selectedKeys.has(image.key)" :selected="selectedKeys.has(image.key)"
:deleting="deletingKeys.has(image.key)"
:selectable="selectable" :selectable="selectable"
:show-key="showKey" :show-key="showKey"
:show-storage="showStorage" :show-storage="showStorage"
:compact="compact" :compact="compact"
:gallery="gallery"
:allow-delete="allowDelete"
:show-tags="showTags"
:show-tag-action="showTagAction"
@update:selected="updateSelection(image.key, $event)" @update:selected="updateSelection(image.key, $event)"
@delete="emit('delete', image.key)" @preview="emit('preview', image)"
@delete="emit('delete', image)"
@tag-click="emit('tag-click', $event)"
@untagged-click="emit('untagged-click')"
@edit-tags="emit('edit-tags', image)"
/> />
</div> </div>
<div <div
+469
View File
@@ -0,0 +1,469 @@
<script setup lang="ts">
import type { ImageItem } from '~/types/image'
import type { CopyFormat } from '~/composables/useUploadPreferences'
import { buildImageBbcode, formatImageDimensions } from '~/utils/image-display'
const props = withDefaults(defineProps<{
open: boolean
image: ImageItem | null
showStorage?: boolean
showTags?: boolean
deleting?: boolean
allowDelete?: boolean
}>(), {
showStorage: false,
showTags: false,
allowDelete: true
})
const emit = defineEmits<{
'update:open': [value: boolean]
'delete': []
}>()
const toast = useToast()
const { formatFileSize } = useFileSize()
const { t, locale } = useI18n()
const { copyFormat } = useUploadPreferences()
const naturalWidth = ref<number | null>(null)
const naturalHeight = ref<number | null>(null)
const dimensionsLoaded = ref(false)
const copyingUrl = ref(false)
const embedFormat = ref<CopyFormat>('url')
const embedFormatItems = computed(() => [
{ label: t('copy.url'), value: 'url' as const },
{ label: t('copy.markdown'), value: 'markdown' as const },
{ label: t('copy.html'), value: 'html' as const },
{ label: t('copy.bbcode'), value: 'bbcode' as const }
])
const uploadedLabel = computed(() => {
if (!props.image) return '—'
try {
const date = new Date(props.image.uploadedAt)
const y = date.getFullYear()
const m = String(date.getMonth() + 1).padStart(2, '0')
const d = String(date.getDate()).padStart(2, '0')
const h = String(date.getHours()).padStart(2, '0')
const min = String(date.getMinutes()).padStart(2, '0')
const s = String(date.getSeconds()).padStart(2, '0')
return `${y}/${m}/${d} ${h}:${min}:${s}`
} catch {
return props.image.uploadedAt
}
})
const sizeLabel = computed(() => {
if (!props.image) return '—'
return t('image.previewSizeDetail', {
size: formatFileSize(props.image.size),
bytes: props.image.size.toLocaleString(locale.value)
})
})
const bbcodeValue = computed(() => {
if (!props.image) return ''
return buildImageBbcode(props.image.url)
})
const previewValue = computed(() => {
if (!props.image) return ''
switch (embedFormat.value) {
case 'markdown':
return props.image.markdown
case 'html':
return props.image.html
case 'bbcode':
return bbcodeValue.value
default:
return props.image.url
}
})
const copySuccessTitle = computed(() => {
switch (embedFormat.value) {
case 'markdown':
return t('copy.copiedMarkdown')
case 'html':
return t('copy.copiedHtml')
case 'bbcode':
return t('copy.copiedBbcode')
default:
return t('copy.copiedUrl')
}
})
const storageIcon = computed(() =>
props.image?.storage?.type === 'local' ? 'i-lucide-hard-drive' : 'i-lucide-cloud'
)
const uploadSourceTag = computed(() => {
switch (props.image?.uploadSource) {
case 'web':
return t('stats.tagWeb')
case 'api':
return t('stats.tagApi')
default:
return null
}
})
const uploadSourceIcon = computed(() => {
switch (props.image?.uploadSource) {
case 'web':
return 'i-lucide-globe'
case 'api':
return 'i-lucide-code-xml'
default:
return null
}
})
const dimensionsLabel = computed(() => {
const formatted = formatImageDimensions(naturalWidth.value, naturalHeight.value)
if (formatted) return formatted
return dimensionsLoaded.value ? '—' : t('image.previewDimensionsUnknown')
})
watch(() => props.image?.key, () => {
naturalWidth.value = null
naturalHeight.value = null
dimensionsLoaded.value = false
})
watch(() => props.open, (open) => {
if (open) {
embedFormat.value = copyFormat.value
}
})
function selectEmbedFormat(value: CopyFormat) {
embedFormat.value = value
copyFormat.value = value
}
async function copyText(text: string, successTitle: string) {
if (!text) return false
try {
if (typeof navigator !== 'undefined' && navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(text)
toast.add({ title: successTitle, color: 'success' })
return true
}
} catch {
// fallback below
}
const textarea = document.createElement('textarea')
textarea.value = text
textarea.setAttribute('readonly', '')
textarea.style.position = 'fixed'
textarea.style.left = '-9999px'
document.body.appendChild(textarea)
textarea.select()
let ok = false
try {
ok = document.execCommand('copy')
} catch {
ok = false
} finally {
document.body.removeChild(textarea)
}
if (ok) {
toast.add({ title: successTitle, color: 'success' })
} else {
toast.add({ title: t('copy.failedManual'), color: 'error' })
}
return ok
}
async function copyLink() {
if (!props.image?.url || copyingUrl.value) return
copyingUrl.value = true
try {
await copyText(props.image.url, t('copy.copiedUrl'))
} finally {
copyingUrl.value = false
}
}
function openInNewTab() {
if (!props.image?.url) return
window.open(props.image.url, '_blank', 'noopener,noreferrer')
}
function downloadOriginal() {
if (!props.image?.url) return
const link = document.createElement('a')
link.href = props.image.url
link.download = props.image.originalName || 'image'
link.rel = 'noopener'
link.click()
}
function requestDelete() {
emit('delete')
}
function onImageLoad(event: Event) {
const img = event.target as HTMLImageElement
naturalWidth.value = img.naturalWidth
naturalHeight.value = img.naturalHeight
dimensionsLoaded.value = true
}
</script>
<template>
<UModal
:open="open && !!image"
:title="t('image.previewTitle')"
:description="t('image.previewSubtitle')"
:ui="{ content: 'max-w-5xl' }"
@update:open="emit('update:open', $event)"
>
<template
v-if="image"
#body
>
<div class="grid gap-6 lg:grid-cols-[minmax(0,1fr)_minmax(0,1.15fr)] lg:items-start">
<div class="preview-checkerboard flex items-center justify-center overflow-hidden rounded-xl border border-default p-4">
<img
:src="image.url"
:alt="image.originalName"
class="max-h-[min(60vh,24rem)] w-full object-contain"
@load="onImageLoad"
>
</div>
<div class="flex min-w-0 flex-col gap-5">
<dl class="divide-y divide-default text-sm">
<div class="flex gap-4 py-2.5 first:pt-0">
<dt class="w-20 shrink-0 text-muted">
{{ t('image.previewOriginalName') }}
</dt>
<dd class="min-w-0 flex-1 break-all font-medium">
{{ image.originalName }}
</dd>
</div>
<div class="flex gap-4 py-2.5">
<dt class="w-20 shrink-0 text-muted">
{{ t('image.previewUploadedAt') }}
</dt>
<dd class="min-w-0 flex-1">
{{ uploadedLabel }}
</dd>
</div>
<div class="flex gap-4 py-2.5">
<dt class="w-20 shrink-0 text-muted">
{{ t('image.previewSize') }}
</dt>
<dd class="min-w-0 flex-1">
{{ sizeLabel }}
</dd>
</div>
<div class="flex gap-4 py-2.5">
<dt class="w-20 shrink-0 text-muted">
{{ t('image.previewDimensions') }}
</dt>
<dd class="min-w-0 flex-1">
{{ dimensionsLabel }}
</dd>
</div>
<div class="flex gap-4 py-2.5">
<dt class="w-20 shrink-0 text-muted">
{{ t('image.previewContentType') }}
</dt>
<dd class="min-w-0 flex-1">
{{ image.contentType }}
</dd>
</div>
<div
v-if="showStorage && image.storage"
class="flex gap-4 py-2.5"
>
<dt class="w-20 shrink-0 text-muted">
{{ t('image.previewStorage') }}
</dt>
<dd class="min-w-0 flex-1">
<span class="inline-flex items-center gap-1.5">
<UIcon
:name="storageIcon"
class="size-3.5 shrink-0 text-muted"
/>
{{ image.storage.name }}
</span>
</dd>
</div>
<div
v-if="showTags"
class="flex gap-4 py-2.5"
>
<dt class="w-20 shrink-0 text-muted">
{{ t('tags.column') }}
</dt>
<dd class="min-w-0 flex-1">
<div
v-if="image.tags?.length"
class="flex flex-wrap gap-1.5"
>
<TagBadge
v-for="tag in image.tags"
:key="tag.id"
:tag="tag"
/>
</div>
<span
v-else
class="inline-flex rounded-md border border-dashed border-default px-2 py-0.5 text-xs text-muted"
>
{{ t('tags.unlabeled') }}
</span>
</dd>
</div>
<div
v-if="uploadSourceTag"
class="flex gap-4 py-2.5"
>
<dt class="w-20 shrink-0 text-muted">
{{ t('image.previewUploadSource') }}
</dt>
<dd class="min-w-0 flex-1">
<span
v-if="uploadSourceIcon"
class="inline-flex items-center gap-1 rounded-md border border-default px-2 py-0.5 text-xs text-muted"
>
<UIcon
:name="uploadSourceIcon"
class="size-3 shrink-0"
/>
{{ uploadSourceTag }}
</span>
</dd>
</div>
<div
v-if="image.owner"
class="flex gap-4 py-2.5"
>
<dt class="w-20 shrink-0 text-muted">
{{ t('image.previewOwner') }}
</dt>
<dd class="min-w-0 flex-1">
{{ image.owner.username }}
</dd>
</div>
<div class="flex gap-4 py-2.5 last:pb-0">
<dt class="w-20 shrink-0 text-muted">
{{ t('image.previewKey') }}
</dt>
<dd class="flex min-w-0 flex-1 items-start gap-2">
<span class="min-w-0 flex-1 break-all font-mono text-xs text-dimmed">
{{ image.key }}
</span>
<CopyButton
icon="i-lucide-copy"
icon-only
variant="ghost"
color="neutral"
class="shrink-0"
:label="t('common.copy')"
:value="image.key"
:success-title="t('copy.copied')"
/>
</dd>
</div>
</dl>
<div class="border-t border-default pt-4">
<p class="mb-2.5 text-sm font-medium">
{{ t('image.previewLinks') }}
</p>
<div class="flex gap-1 rounded-lg border border-default bg-default p-0.5">
<UButton
v-for="item in embedFormatItems"
:key="item.value"
size="xs"
class="flex-1 justify-center"
:variant="embedFormat === item.value ? 'solid' : 'ghost'"
:color="embedFormat === item.value ? 'primary' : 'neutral'"
:label="item.label"
@click="selectEmbedFormat(item.value)"
/>
</div>
<div class="mt-2 flex items-center gap-2">
<UInput
:model-value="previewValue"
readonly
size="sm"
class="min-w-0 flex-1 font-mono text-xs"
/>
<CopyButton
:label="t('common.copy')"
:value="previewValue"
:success-title="copySuccessTitle"
/>
</div>
</div>
</div>
</div>
</template>
<template
v-if="image"
#footer
>
<div class="flex w-full flex-wrap justify-end gap-1.5">
<UButton
:label="t('image.previewOpen')"
icon="i-lucide-external-link"
color="neutral"
variant="outline"
size="sm"
@click="openInNewTab"
/>
<UButton
:label="t('image.previewDownload')"
icon="i-lucide-download"
color="neutral"
variant="outline"
size="sm"
@click="downloadOriginal"
/>
<UButton
:label="t('image.previewCopyLink')"
icon="i-lucide-link"
color="primary"
size="sm"
:loading="copyingUrl"
@click="copyLink"
/>
<UButton
v-if="allowDelete"
:label="t('common.delete')"
icon="i-lucide-trash-2"
color="error"
variant="outline"
size="sm"
:loading="deleting"
@click="requestDelete"
/>
</div>
</template>
</UModal>
</template>
<style scoped>
.preview-checkerboard {
background-color: var(--ui-bg-muted);
background-image:
linear-gradient(45deg, color-mix(in oklab, var(--ui-border) 28%, transparent) 25%, transparent 25%),
linear-gradient(-45deg, color-mix(in oklab, var(--ui-border) 28%, transparent) 25%, transparent 25%),
linear-gradient(45deg, transparent 75%, color-mix(in oklab, var(--ui-border) 28%, transparent) 75%),
linear-gradient(-45deg, transparent 75%, color-mix(in oklab, var(--ui-border) 28%, transparent) 75%);
background-size: 14px 14px;
background-position: 0 0, 0 7px, 7px -7px, -7px 0;
}
</style>
+102
View File
@@ -0,0 +1,102 @@
<script setup lang="ts">
import type { ImageItem, ImageTag } from '~/types/image'
const props = defineProps<{
open: boolean
image: ImageItem | null
tags: ImageTag[]
}>()
const emit = defineEmits<{
'update:open': [value: boolean]
'saved': [tags: ImageTag[]]
}>()
const { t } = useI18n()
const toast = useToast()
const { setImageTags, fetchTags } = useTags()
const { recordRecent, loadRecentIds } = useRecentTags()
const selectedTagIds = ref<number[]>([])
const saving = ref(false)
watch(() => props.open, (open) => {
if (open) {
selectedTagIds.value = props.image?.tags?.map(tag => tag.id) ?? []
loadRecentIds()
void fetchTags(true)
}
})
async function save() {
if (!props.image || saving.value) return
saving.value = true
try {
const tags = await setImageTags(props.image.key, selectedTagIds.value)
recordRecent(selectedTagIds.value)
emit('saved', tags)
emit('update:open', false)
toast.add({ title: t('tags.saved'), color: 'success' })
} catch (error: unknown) {
const message = error instanceof Error ? error.message : t('tags.saveFailed')
toast.add({ title: message, color: 'error' })
} finally {
saving.value = false
}
}
function onTagCreated(tag: ImageTag) {
if (!selectedTagIds.value.includes(tag.id)) {
selectedTagIds.value = [...selectedTagIds.value, tag.id]
}
}
</script>
<template>
<UModal
:open="open"
:title="t('tags.manageImageTitle')"
:description="image?.originalName"
:ui="{ content: 'max-w-md' }"
@update:open="emit('update:open', $event)"
>
<template #body>
<TagAssignPanel
v-model="selectedTagIds"
:tags="tags"
:disabled="saving"
@created="onTagCreated"
/>
</template>
<template #footer>
<div class="flex w-full flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<p class="flex items-center gap-1.5 text-xs text-muted">
<UIcon
name="i-lucide-info"
class="size-3.5 shrink-0"
/>
{{ t('tags.classifyHint') }}
</p>
<div class="flex justify-end gap-2">
<UButton
variant="outline"
color="neutral"
:disabled="saving"
@click="emit('update:open', false)"
>
{{ t('common.cancel') }}
</UButton>
<UButton
color="primary"
:loading="saving"
@click="save"
>
{{ t('common.save') }}
</UButton>
</div>
</div>
</template>
</UModal>
</template>
+236
View File
@@ -0,0 +1,236 @@
<script setup lang="ts">
import type { LibraryStatsData, MediaType } from '~/types/media'
const props = withDefaults(defineProps<{
stats: LibraryStatsData | null
/** 统计口径:图片 / 视频 / 文件各自独立 */
kind?: MediaType
loading?: boolean
}>(), {
kind: 'image'
})
const { t } = useI18n()
const { formatFileSize } = useFileSize()
const SOURCE_COLORS = ['var(--ui-primary)', '#10b981'] as const
const totalHint = computed(() => {
switch (props.kind) {
case 'video':
return t('library.statsTotalHintVideo')
case 'file':
return t('library.statsTotalHintFile')
default:
return t('library.statsTotalHintImage')
}
})
const sourceItems = computed(() => {
const bySource = props.stats?.bySource
if (!bySource) return []
return [
{ key: 'web', label: t('stats.sourceWeb'), count: bySource.web, color: SOURCE_COLORS[0] },
{ key: 'api', label: t('stats.sourceApi'), count: bySource.api, color: SOURCE_COLORS[1] }
]
})
const sourceTotal = computed(() =>
sourceItems.value.reduce((sum, item) => sum + item.count, 0)
)
function sourcePercent(count: number) {
if (!sourceTotal.value) return 0
return Math.round((count / sourceTotal.value) * 100)
}
function formatTrend(current: number, previous: number) {
if (previous === 0) {
if (current === 0) {
return { text: t('stats.trendFlat'), tone: 'muted' as const }
}
return { text: t('stats.trendUp', { p: 100 }), tone: 'up' as const }
}
const change = Math.round(((current - previous) / previous) * 100)
if (change > 0) {
return { text: t('stats.trendUp', { p: change }), tone: 'up' as const }
}
if (change < 0) {
return { text: t('stats.trendDown', { p: Math.abs(change) }), tone: 'down' as const }
}
return { text: t('stats.trendFlat'), tone: 'muted' as const }
}
const todayTrend = computed(() =>
formatTrend(props.stats?.uploadToday ?? 0, props.stats?.uploadYesterday ?? 0)
)
const monthTrend = computed(() =>
formatTrend(props.stats?.uploadMonth ?? 0, props.stats?.uploadLastMonth ?? 0)
)
const usageLabel = computed(() => {
const usage = props.stats?.storageUsage
if (!usage) return '—'
const used = formatFileSize(usage.usedBytes)
if (usage.totalBytes) {
return `${used} / ${formatFileSize(usage.totalBytes)}`
}
return used
})
const usagePercent = computed(() => {
const percent = props.stats?.storageUsage.percent
if (percent == null) return null
return Math.min(percent, 100)
})
const usageBarWidth = computed(() => {
const usage = props.stats?.storageUsage
if (!usage?.totalBytes || usagePercent.value == null) return 0
return Math.max(usagePercent.value, usage.usedBytes > 0 ? 4 : 0)
})
function displayValue(value: number | undefined) {
if (props.loading) return '—'
return value ?? '—'
}
</script>
<template>
<section class="grid grid-cols-2 gap-3 sm:grid-cols-3 xl:grid-cols-6">
<div class="rounded-xl border border-default bg-elevated p-4">
<p class="text-xs text-muted">
{{ t('stats.uploadToday') }}
</p>
<p class="mt-2 text-2xl font-semibold tabular-nums text-highlighted">
{{ displayValue(stats?.uploadToday) }}
</p>
<p
class="mt-2 text-xs"
:class="{
'text-primary': todayTrend.tone === 'up',
'text-muted': todayTrend.tone === 'muted',
'text-orange-600 dark:text-orange-400': todayTrend.tone === 'down'
}"
>
{{ t('stats.vsYesterday') }} {{ todayTrend.text }}
</p>
</div>
<div class="rounded-xl border border-default bg-elevated p-4">
<p class="text-xs text-muted">
{{ t('stats.uploadMonth') }}
</p>
<p class="mt-2 text-2xl font-semibold tabular-nums text-highlighted">
{{ displayValue(stats?.uploadMonth) }}
</p>
<p
class="mt-2 text-xs"
:class="{
'text-primary': monthTrend.tone === 'up',
'text-muted': monthTrend.tone === 'muted',
'text-orange-600 dark:text-orange-400': monthTrend.tone === 'down'
}"
>
{{ t('stats.vsLastMonth') }} {{ monthTrend.text }}
</p>
</div>
<div class="rounded-xl border border-default bg-elevated p-4">
<p class="text-xs text-muted">
{{ t('stats.uploadTotal') }}
</p>
<p class="mt-2 text-2xl font-semibold tabular-nums text-highlighted">
{{ displayValue(stats?.uploadTotal) }}
</p>
<p class="mt-2 text-xs text-muted">
{{ totalHint }}
</p>
</div>
<div class="rounded-xl border border-default bg-elevated p-4">
<p class="text-xs text-muted">
{{ t('stats.storedCount') }}
</p>
<p class="mt-2 text-2xl font-semibold tabular-nums text-highlighted">
{{ displayValue(stats?.storedCount) }}
</p>
<p class="mt-2 text-xs text-muted">
{{ stats ? t('stats.totalOccupied', { size: formatFileSize(stats.uploadBytesTotal) }) : '—' }}
</p>
</div>
<div class="rounded-xl border border-default bg-elevated p-4">
<p class="text-xs text-muted">
{{ t('stats.storageUsage') }}
</p>
<p class="mt-2 text-sm font-semibold tabular-nums text-highlighted sm:text-base">
{{ usageLabel }}
</p>
<div
v-if="stats?.storageUsage.totalBytes"
class="mt-3 space-y-1"
>
<div class="h-1.5 overflow-hidden rounded-full bg-muted/50">
<div
class="h-full rounded-full bg-primary transition-all"
:style="{ width: `${usageBarWidth}%` }"
/>
</div>
<p class="text-right text-xs tabular-nums text-muted">
{{ usagePercent }}%
</p>
</div>
<p
v-else
class="mt-2 text-xs text-muted"
>
{{ t('stats.noQuota') }}
</p>
</div>
<div class="col-span-2 rounded-xl border border-default bg-elevated p-4 sm:col-span-1">
<p class="text-xs text-muted">
{{ t('stats.sourceDistribution') }}
</p>
<div
v-if="!sourceTotal"
class="mt-4 text-xs text-muted"
>
{{ t('stats.noData') }}
</div>
<ul
v-else
class="mt-3 space-y-3"
>
<li
v-for="item in sourceItems"
:key="item.key"
>
<div class="flex items-center justify-between gap-2 text-xs">
<span class="flex min-w-0 items-center gap-1.5">
<span
class="size-2 shrink-0 rounded-full"
:style="{ background: item.color }"
/>
<span class="truncate">{{ item.label }}</span>
</span>
<span class="shrink-0 tabular-nums text-muted">
{{ item.count }} ({{ sourcePercent(item.count) }}%)
</span>
</div>
<div class="mt-1.5 h-1.5 overflow-hidden rounded-full bg-muted/40">
<div
class="h-full rounded-full transition-[width]"
:style="{
width: `${sourcePercent(item.count)}%`,
backgroundColor: item.color
}"
/>
</div>
</li>
</ul>
</div>
</section>
</template>
+90
View File
@@ -0,0 +1,90 @@
<script setup lang="ts">
import type {
LoginVerificationMethod,
LoginVerificationPayload
} from '~/types/auth'
const props = defineProps<{
method: LoginVerificationMethod
turnstileSiteKey?: string
capApiEndpoint?: string
}>()
const emit = defineEmits<{
verified: [payload: LoginVerificationPayload]
cleared: []
}>()
const sliderRef = ref<{ reset: () => Promise<void> } | null>(null)
const turnstileRef = ref<{ reset: () => void } | null>(null)
const capRef = ref<{ reset: () => void } | null>(null)
function onSliderVerified(payload: { id: string, positionPercent: number }) {
emit('verified', {
method: 'slider',
captchaId: payload.id,
captchaPosition: payload.positionPercent
})
}
function onTurnstileVerified(payload: { turnstileToken: string }) {
emit('verified', {
method: 'turnstile',
turnstileToken: payload.turnstileToken
})
}
function onCapVerified(payload: { capToken: string }) {
emit('verified', {
method: 'cap',
capToken: payload.capToken
})
}
function onCleared() {
emit('cleared')
}
async function reset() {
emit('cleared')
if (props.method === 'slider') {
await sliderRef.value?.reset()
return
}
if (props.method === 'turnstile') {
turnstileRef.value?.reset()
return
}
capRef.value?.reset()
}
defineExpose({ reset })
</script>
<template>
<SliderCaptcha
v-if="method === 'slider'"
ref="sliderRef"
@verified="onSliderVerified"
/>
<TurnstileCaptcha
v-else-if="method === 'turnstile' && turnstileSiteKey"
ref="turnstileRef"
:site-key="turnstileSiteKey"
@verified="onTurnstileVerified"
@cleared="onCleared"
/>
<CapCaptcha
v-else-if="method === 'cap' && capApiEndpoint"
ref="capRef"
:api-endpoint="capApiEndpoint"
@verified="onCapVerified"
@cleared="onCleared"
/>
<p
v-else
class="rounded-lg border border-warning/25 bg-warning/5 px-3 py-2 text-xs text-warning"
>
{{ $t('auth.verificationMisconfigured') }}
</p>
</template>
+287
View File
@@ -0,0 +1,287 @@
<script setup lang="ts">
import type { FileItem } from '~/types/file'
import type { CopyFormat, UploadMediaKind } from '~/composables/useUploadPreferences'
const props = defineProps<{
item: FileItem
kind: UploadMediaKind
/** 已按访问模式拼接好的直链 */
link: string
/** 封面缩略图地址 */
thumbLink?: string
}>()
const emit = defineEmits<{
delete: []
}>()
const { formatFileSize } = useFileSize()
const { t, locale } = useI18n()
const transcodeFailureLabel = useTranscodeFailureLabel()
const { copyFormat } = useUploadPreferences(props.kind)
const copyFormatItems = computed(() => [
{ label: t('copy.url'), value: 'url' as const },
{ label: t('copy.markdown'), value: 'markdown' as const },
{ label: t('copy.html'), value: 'html' as const },
{ label: t('copy.bbcode'), value: 'bbcode' as const }
])
const uploadedLabel = computed(() => {
try {
return new Date(props.item.uploadedAt).toLocaleString(locale.value, { hour12: false })
} catch {
return props.item.uploadedAt
}
})
const previewValue = computed(() => {
switch (copyFormat.value) {
case 'markdown':
return `[${props.item.originalName}](${props.link})`
case 'html':
return `<a href="${props.link}">${props.item.originalName}</a>`
case 'bbcode':
return `[url=${props.link}]${props.item.originalName}[/url]`
default:
return props.link
}
})
const copySuccessTitle = computed(() => {
switch (copyFormat.value) {
case 'markdown':
return t('copy.copiedMarkdown')
case 'html':
return t('copy.copiedHtml')
case 'bbcode':
return t('copy.copiedBbcode')
default:
return t('copy.copiedUrl')
}
})
function selectCopyFormat(value: CopyFormat) {
copyFormat.value = value
}
const pending = computed(() => props.item.transcodeStatus === 'pending')
const running = computed(() => props.item.transcodeStatus === 'running')
/** 转码中源文件只是临时文件,链接还不能对外使用 */
const transcoding = computed(() => pending.value || running.value)
/** 转码完成后按 HLS 播放列表展示 */
const displayName = computed(() => {
if (!props.item.hlsUrl) return props.item.originalName
const dot = props.item.originalName.lastIndexOf('.')
const base = dot > 0 ? props.item.originalName.slice(0, dot) : props.item.originalName
return `${base}.m3u8`
})
/** 该视频使用的转码档位(等待/转码中时展示) */
const presetLabel = computed(() => {
const [name, value] = (props.item.transcodePreset ?? '').split(':')
switch (name) {
case 'copy':
return t('preferences.presetCopy')
case 'veryfast':
return t('preferences.presetSpeed')
case 'medium':
return t('preferences.presetBalanced')
case 'slow':
return t('preferences.presetQuality')
case 'compress':
return value
? `${t('preferences.presetCompress')} ${value}%`
: t('preferences.presetCompress')
default:
return null
}
})
function formatDuration(seconds: number): string {
const days = Math.floor(seconds / 86_400)
const hours = Math.floor((seconds % 86_400) / 3_600)
const minutes = Math.floor((seconds % 3_600) / 60)
const secs = seconds % 60
if (days > 0) return `${days} d ${hours} h ${minutes} min ${secs} s`
if (hours > 0) return `${hours} h ${minutes} min ${secs} s`
if (minutes > 0) return `${minutes} min ${secs} s`
return `${secs} s`
}
/** 转码进行中:已耗时(轮询每秒刷新一次) */
const elapsedLabel = computed(() => {
if (!running.value || !props.item.transcodeStartedAt) return null
const started = new Date(props.item.transcodeStartedAt).getTime()
if (!Number.isFinite(started)) return null
return formatDuration(Math.max(0, Math.floor((Date.now() - started) / 1000)))
})
/** 转码结束(成功或失败):固定显示总耗时 */
const totalLabel = computed(() => {
if (running.value || typeof props.item.transcodeDurationMs !== 'number') return null
return formatDuration(Math.floor(props.item.transcodeDurationMs / 1000))
})
</script>
<template>
<div class="flex flex-col overflow-hidden rounded-xl border border-default bg-elevated shadow-sm">
<div class="relative aspect-video shrink-0 overflow-hidden bg-muted">
<VideoThumbnail
v-if="kind === 'video'"
:src="thumbLink"
:alt="item.originalName"
/>
<div
v-else
class="flex size-full items-center justify-center"
>
<UIcon
name="i-lucide-file"
class="size-10 text-dimmed"
/>
</div>
<div class="absolute top-2 right-2">
<UButton
icon="i-lucide-trash-2"
variant="solid"
color="neutral"
size="xs"
class="bg-black/55 text-white hover:bg-black/70"
:aria-label="t('common.delete')"
@click="emit('delete')"
/>
</div>
</div>
<div class="flex flex-1 flex-col gap-2 p-3">
<p
class="truncate text-sm font-medium"
:title="displayName"
>
{{ displayName }}
</p>
<p class="flex items-center gap-2 text-xs text-muted">
<span class="truncate">{{ uploadedLabel }} · {{ formatFileSize(item.size) }}</span>
<UBadge
v-if="pending"
color="neutral"
variant="subtle"
size="xs"
class="shrink-0"
>
{{ t('library.transcodePending') }}
</UBadge>
<UBadge
v-else-if="running"
color="primary"
variant="subtle"
size="xs"
class="shrink-0"
>
{{ t('library.transcoding') }} {{ item.transcodeProgress ?? 0 }}%
</UBadge>
<UBadge
v-else-if="item.transcodeStatus === 'failed'"
color="error"
variant="subtle"
size="xs"
class="max-w-48 shrink-0 truncate"
:title="transcodeFailureLabel(item.transcodeError)"
>
{{ transcodeFailureLabel(item.transcodeError) }}
</UBadge>
<UBadge
v-else-if="item.hlsUrl"
color="success"
variant="subtle"
size="xs"
class="shrink-0"
>
m3u8
</UBadge>
</p>
<TagBadgeList
:tags="item.tags"
compact
/>
<UProgress
v-if="running"
:model-value="item.transcodeProgress ?? 0"
size="xs"
color="primary"
/>
<div
v-if="!transcoding"
class="flex gap-1 rounded-lg border border-default p-0.5"
>
<UButton
v-for="option in copyFormatItems"
:key="option.value"
size="xs"
class="flex-1 justify-center"
:variant="copyFormat === option.value ? 'solid' : 'ghost'"
:color="copyFormat === option.value ? 'primary' : 'neutral'"
:label="option.label"
@click="selectCopyFormat(option.value)"
/>
</div>
<div
v-if="!transcoding"
class="flex items-center gap-1"
>
<UInput
:model-value="previewValue"
readonly
size="xs"
class="min-w-0 flex-1 font-mono text-xs"
/>
<CopyButton
:label="t('common.copy')"
variant="solid"
color="primary"
:value="previewValue"
:success-title="copySuccessTitle"
/>
</div>
<p
class="flex flex-wrap items-center gap-1.5"
>
<UBadge
v-if="presetLabel"
color="primary"
variant="subtle"
size="xs"
>
{{ presetLabel }}
</UBadge>
<UBadge
v-if="elapsedLabel"
color="success"
variant="subtle"
size="xs"
class="!text-white"
>
{{ t('library.transcodeElapsed') }} {{ elapsedLabel }}
</UBadge>
<UBadge
v-if="totalLabel"
color="success"
variant="subtle"
size="xs"
>
{{ t('library.transcodeTotal') }} {{ totalLabel }}
</UBadge>
</p>
</div>
</div>
</template>
+176
View File
@@ -0,0 +1,176 @@
<script setup lang="ts">
import type { FileItem } from '~/types/file'
const props = defineProps<{
/** 视频床或文件床的上传页 */
kind: 'video' | 'file'
}>()
const { t } = useI18n()
const toast = useToast()
const {
policy: uploadPolicy,
load: loadUploadPolicy
} = useUploadPolicy()
const {
uploading,
progressItems,
sessionItems,
linkUrl,
thumbLink,
loadActiveTranscodes,
uploadFiles
} = useMediaUpload(props.kind)
const { items: tagItems, fetchTags, batchUpdateTags } = useTags()
const { recordRecent } = useRecentTags()
const batchTagOpen = ref(false)
onMounted(() => {
void Promise.all([
loadActiveTranscodes(),
loadUploadPolicy().catch(() => {}),
fetchTags()
])
})
const text = computed(() => {
const allowedVideos = uploadPolicy.value?.allowedVideoExtensions ?? []
const forbiddenFiles = uploadPolicy.value?.forbiddenFileExtensions ?? []
const videoMaxFileSizeMb = uploadPolicy.value?.videoMaxFileSizeMb ?? 500
const fileMaxFileSizeMb = uploadPolicy.value?.fileMaxFileSizeMb ?? 100
return props.kind === 'video'
? {
uploadTitle: t('library.video.uploadTitle'),
uploadHint: t('library.video.uploadHint'),
uploadFormats: allowedVideos.length
? t('library.video.uploadFormatsAllowed', {
formats: allowedVideos.map(item => item.toUpperCase()).join(' / '),
size: videoMaxFileSizeMb
})
: t('library.video.uploadFormats', { size: videoMaxFileSizeMb })
}
: {
uploadTitle: t('library.file.uploadTitle'),
uploadHint: t('library.file.uploadHint'),
uploadFormats: forbiddenFiles.length
? t('library.file.uploadFormatsBlocked', {
formats: forbiddenFiles.map(item => item.toUpperCase()).join(' / '),
size: fileMaxFileSizeMb
})
: t('library.file.uploadFormatsUnrestricted', { size: fileMaxFileSizeMb })
}
})
const accept = computed(() => {
if (props.kind !== 'video') return undefined
const extensions = uploadPolicy.value?.allowedVideoExtensions ?? []
if (!extensions.length) return undefined
return extensions
.map(extension => `.${extension}`)
.join(',')
})
const showProgress = computed(() =>
uploading.value
|| progressItems.value.some(
item =>
item.status === 'pending'
|| item.status === 'uploading'
|| item.status === 'error'
)
)
async function removeItem(item: FileItem) {
try {
await $fetch('/api/files', {
method: 'DELETE',
credentials: 'include',
query: { key: item.key }
})
sessionItems.value = sessionItems.value.filter(entry => entry.key !== item.key)
toast.add({ title: t('files.deleted'), color: 'success' })
} catch {
toast.add({ title: t('files.deleteFailed'), color: 'error' })
}
}
async function confirmBatchTags(tagIds: number[]) {
const keys = sessionItems.value.map(item => item.key)
if (!keys.length || !tagIds.length) return
try {
await batchUpdateTags(keys, tagIds, 'add')
batchTagOpen.value = false
recordRecent(tagIds)
await fetchTags(true)
const addedTags = tagItems.value.filter(tag => tagIds.includes(tag.id))
sessionItems.value = sessionItems.value.map((item) => {
const merged = new Map((item.tags ?? []).map(tag => [tag.id, tag]))
for (const tag of addedTags) merged.set(tag.id, tag)
return { ...item, tags: [...merged.values()] }
})
toast.add({ title: t('tags.batchSuccess'), color: 'success' })
} catch {
toast.add({ title: t('tags.batchFailed'), color: 'error' })
}
}
</script>
<template>
<div class="space-y-6">
<UploadFlipCard
:kind="kind"
:title="text.uploadTitle"
:hint="text.uploadHint"
:formats="text.uploadFormats"
:accept="accept"
:disabled="uploading"
@upload="uploadFiles"
/>
<UploadResult
v-if="showProgress"
:items="progressItems"
/>
<section
v-if="sessionItems.length"
class="space-y-4"
>
<div class="flex w-full items-center justify-between gap-2">
<h2 class="text-lg font-medium">
{{ t('upload.sessionTitle') }}
<span class="ml-1 text-sm font-normal text-muted">({{ sessionItems.length }})</span>
</h2>
<UButton
icon="i-lucide-tags"
variant="outline"
color="primary"
size="sm"
:label="t('tags.batchTagAll')"
@click="() => { batchTagOpen = true }"
/>
</div>
<div class="grid gap-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
<MediaSessionCard
v-for="item in sessionItems"
:key="item.key"
:item="item"
:kind="kind"
:link="linkUrl(item)"
:thumb-link="thumbLink(item)"
@delete="removeItem(item)"
/>
</div>
</section>
<BatchTagModal
v-model:open="batchTagOpen"
:tags="tagItems"
:selected-count="sessionItems.length"
add-only
@confirm="confirmBatchTags"
/>
</div>
</template>
-18
View File
@@ -136,24 +136,6 @@ function toggleOpen() {
{{ t('nav.account') }} {{ t('nav.account') }}
</h3> </h3>
<div class="space-y-2"> <div class="space-y-2">
<NuxtLink
to="/logs"
class="flex items-center justify-between rounded-lg border border-default px-3.5 py-2.5 text-sm text-highlighted transition-colors hover:bg-elevated"
@click="open = false"
>
<span class="flex items-center gap-2.5">
<UIcon
name="i-lucide-scroll-text"
class="size-4 opacity-70"
/>
{{ t('nav.logs') }}
</span>
<UIcon
name="i-lucide-chevron-right"
class="size-4 text-muted"
/>
</NuxtLink>
<NuxtLink <NuxtLink
to="/account/password" to="/account/password"
class="flex items-center justify-between rounded-lg border border-default px-3.5 py-2.5 text-sm text-highlighted transition-colors hover:bg-elevated" class="flex items-center justify-between rounded-lg border border-default px-3.5 py-2.5 text-sm text-highlighted transition-colors hover:bg-elevated"
+203 -35
View File
@@ -1,82 +1,250 @@
<script setup lang="ts"> <script setup lang="ts">
const props = defineProps<{ type PageItem = number | 'ellipsis'
const props = withDefaults(defineProps<{
page: number page: number
totalPages: number totalPages: number
total: number total: number
unit?: string unit?: string
loading?: boolean loading?: boolean
}>() pageSize?: number
pageSizeOptions?: number[]
}>(), {
loading: false,
pageSizeOptions: () => [10, 20, 30, 50]
})
const emit = defineEmits<{ const emit = defineEmits<{
'update:page': [page: number] 'update:page': [page: number]
'update:pageSize': [pageSize: number]
}>() }>()
const { t } = useI18n() const { t } = useI18n()
const unitLabel = computed(() => props.unit ?? t('common.itemsUnit')) const unitLabel = computed(() => props.unit ?? t('common.itemsUnit'))
const jumpPageInput = ref('')
const showMobilePageNumbers = computed(() => props.totalPages <= 5)
const visiblePages = computed<PageItem[]>(() => {
const total = Math.max(1, props.totalPages)
const current = Math.min(Math.max(1, props.page), total)
if (total <= 7) {
return Array.from({ length: total }, (_, index) => index + 1)
}
const pages = new Set<number>([1, total])
for (let page = current - 1; page <= current + 1; page++) {
if (page >= 1 && page <= total) pages.add(page)
}
const sorted = [...pages].sort((a, b) => a - b)
const items: PageItem[] = []
for (let index = 0; index < sorted.length; index++) {
const page = sorted[index]!
const prev = sorted[index - 1]
if (prev !== undefined && page - prev > 1) {
items.push('ellipsis')
}
items.push(page)
}
return items
})
const pageSizeItems = computed(() =>
props.pageSizeOptions.map(size => ({
label: t('pagination.perPage', { size }),
value: size
}))
)
watch(
() => props.page,
(page) => {
jumpPageInput.value = String(page)
},
{ immediate: true }
)
function goTo(target: number) { function goTo(target: number) {
if (target < 1 || target > props.totalPages || target === props.page || props.loading) { if (target < 1 || target > props.totalPages || props.loading) return
return if (target === props.page) return
}
emit('update:page', target) emit('update:page', target)
} }
function onPageSizeChange(value: unknown) {
const size = Number(value)
if (!Number.isFinite(size) || size <= 0 || size === props.pageSize) return
emit('update:pageSize', size)
}
function jumpToPage() {
const target = Number.parseInt(jumpPageInput.value, 10)
if (!Number.isFinite(target)) return
goTo(target)
}
function pageButtonClass(target: number) {
return target === props.page
? 'border-primary bg-primary/10 text-primary font-medium'
: 'border-default bg-elevated text-default hover:bg-muted'
}
const navButtonClass = 'inline-flex items-center justify-center rounded-md border border-default bg-elevated text-default transition-colors hover:bg-muted disabled:pointer-events-none disabled:opacity-40'
</script> </script>
<template> <template>
<div <div
v-if="total > 0" v-if="total > 0"
class="flex flex-col items-center gap-2 border-t border-default pt-4 sm:flex-row sm:justify-between" class="border-t border-default pt-4"
> >
<p <div class="flex flex-col gap-3">
v-if="totalPages > 1" <div class="flex items-center justify-between gap-2 sm:hidden">
class="text-sm text-muted" <p class="text-sm text-muted">
> {{ t('pagination.totalCount', { total, unit: unitLabel }) }}
{{ t('pagination.pageOf', { page, totalPages, total, unit: unitLabel }) }}
</p> </p>
<p <USelect
v-else v-if="pageSize !== undefined"
class="text-sm text-muted" :model-value="pageSize"
> :items="pageSizeItems"
{{ t('pagination.totalOnly', { total, unit: unitLabel }) }} value-key="value"
size="sm"
class="w-[6.5rem] shrink-0"
:aria-label="t('pagination.pageSize')"
:disabled="loading"
@update:model-value="onPageSizeChange"
/>
</div>
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<p class="hidden text-sm text-muted sm:block">
{{ t('pagination.totalCount', { total, unit: unitLabel }) }}
</p> </p>
<div <div class="flex flex-col gap-3 sm:flex-row sm:flex-wrap sm:items-center sm:justify-end sm:gap-3">
v-if="totalPages > 1" <USelect
class="flex items-center gap-1" v-if="pageSize !== undefined"
> :model-value="pageSize"
<UButton :items="pageSizeItems"
icon="i-lucide-chevrons-left" value-key="value"
variant="outline"
size="sm" size="sm"
class="hidden w-[7.5rem] shrink-0 sm:block"
:aria-label="t('pagination.pageSize')"
:disabled="loading"
@update:model-value="onPageSizeChange"
/>
<div class="flex items-center justify-center gap-1 sm:justify-end">
<button
type="button"
:class="[navButtonClass, 'hidden size-8 sm:inline-flex']"
:aria-label="t('pagination.first')" :aria-label="t('pagination.first')"
:disabled="page <= 1 || loading" :disabled="page <= 1 || loading"
@click="goTo(1)" @click="goTo(1)"
>
<UIcon
name="i-lucide-chevrons-left"
class="size-4"
/> />
<UButton </button>
icon="i-lucide-chevron-left" <button
variant="outline" type="button"
size="sm" :class="[navButtonClass, 'size-9 sm:size-8']"
:aria-label="t('pagination.prev')" :aria-label="t('pagination.prev')"
:disabled="page <= 1 || loading" :disabled="page <= 1 || loading"
@click="goTo(page - 1)" @click="goTo(page - 1)"
>
<UIcon
name="i-lucide-chevron-left"
class="size-4"
/> />
<UButton </button>
icon="i-lucide-chevron-right"
variant="outline" <p
size="sm" v-if="!showMobilePageNumbers"
class="min-w-[5.5rem] px-1 text-center text-sm text-muted sm:hidden"
>
{{ t('pagination.pageStatus', { page, totalPages }) }}
</p>
<template
v-for="(target, index) in visiblePages"
:key="`${target}-${index}`"
>
<span
v-if="target === 'ellipsis'"
class="inline-flex size-9 items-center justify-center text-sm text-muted sm:size-8"
:class="showMobilePageNumbers ? '' : 'hidden sm:inline-flex'"
aria-hidden="true"
>
…
</span>
<button
v-else
type="button"
class="inline-flex items-center justify-center rounded-md border text-sm transition-colors disabled:pointer-events-none disabled:opacity-40"
:class="[
pageButtonClass(target),
showMobilePageNumbers ? 'size-9 sm:size-8' : 'hidden size-8 sm:inline-flex'
]"
:aria-label="t('pagination.pageNumber', { page: target })"
:aria-current="target === page ? 'page' : undefined"
:disabled="loading"
@click="goTo(target)"
>
{{ target }}
</button>
</template>
<button
type="button"
:class="[navButtonClass, 'size-9 sm:size-8']"
:aria-label="t('pagination.next')" :aria-label="t('pagination.next')"
:disabled="page >= totalPages || loading" :disabled="page >= totalPages || loading"
@click="goTo(page + 1)" @click="goTo(page + 1)"
>
<UIcon
name="i-lucide-chevron-right"
class="size-4"
/> />
<UButton </button>
icon="i-lucide-chevrons-right" <button
variant="outline" type="button"
size="sm" :class="[navButtonClass, 'hidden size-8 sm:inline-flex']"
:aria-label="t('pagination.last')" :aria-label="t('pagination.last')"
:disabled="page >= totalPages || loading" :disabled="page >= totalPages || loading"
@click="goTo(totalPages)" @click="goTo(totalPages)"
>
<UIcon
name="i-lucide-chevrons-right"
class="size-4"
/> />
</button>
</div>
<form
class="hidden items-center gap-1.5 text-sm text-muted sm:flex"
@submit.prevent="jumpToPage"
>
<span>{{ t('pagination.goToPrefix') }}</span>
<UInput
v-model="jumpPageInput"
type="number"
min="1"
:max="totalPages"
size="sm"
class="w-14"
:aria-label="t('pagination.pagePlaceholder')"
:disabled="loading"
@blur="jumpToPage"
/>
<span v-if="t('pagination.goToSuffix')">{{ t('pagination.goToSuffix') }}</span>
</form>
</div>
</div>
</div> </div>
</div> </div>
</template> </template>
+261
View File
@@ -0,0 +1,261 @@
<script setup lang="ts">
const emit = defineEmits<{
verified: [payload: { id: string, positionPercent: number }]
}>()
const { t } = useI18n()
const THUMB_SIZE = 40
const TRACK_INSET = 4
const trackRef = ref<HTMLElement | null>(null)
const challengeId = ref('')
const trackSvg = ref('')
const sliderPercent = ref(0)
const verified = ref(false)
const loading = ref(false)
const dragging = ref(false)
const shake = ref(false)
const confirming = ref(false)
const trackWidth = ref(0)
function updateTrackWidth() {
trackWidth.value = trackRef.value?.clientWidth ?? 0
}
function percentToPx(percent: number) {
const max = Math.max(trackWidth.value - THUMB_SIZE - TRACK_INSET * 2, 0)
return TRACK_INSET + (percent / 100) * max
}
const thumbLeft = computed(() => percentToPx(sliderPercent.value))
const isActive = computed(() => dragging.value || sliderPercent.value > 0.5)
const fillWidth = computed(() => `${Math.max(thumbLeft.value + THUMB_SIZE - TRACK_INSET, 0)}px`)
const trackSvgUrl = computed(() =>
trackSvg.value
? `data:image/svg+xml;charset=utf-8,${encodeURIComponent(trackSvg.value)}`
: ''
)
const hintOpacity = computed(() => {
if (verified.value) return 0
if (!isActive.value) return 1
return Math.max(0, 1 - sliderPercent.value / 40)
})
const successLabelLeft = computed(() => `${thumbLeft.value + THUMB_SIZE + 8}px`)
async function loadChallenge() {
loading.value = true
verified.value = false
dragging.value = false
shake.value = false
sliderPercent.value = 0
trackSvg.value = ''
try {
const data = await $fetch<{ id: string, svg: string }>('/api/auth/captcha')
challengeId.value = data.id
trackSvg.value = data.svg
} finally {
loading.value = false
await nextTick()
updateTrackWidth()
}
}
function setSliderFromClientX(clientX: number) {
if (!trackRef.value || verified.value || loading.value || confirming.value) return
const rect = trackRef.value.getBoundingClientRect()
const max = Math.max(rect.width - THUMB_SIZE - TRACK_INSET * 2, 1)
const x = Math.min(
Math.max(clientX - rect.left - THUMB_SIZE / 2 - TRACK_INSET, 0),
max
)
sliderPercent.value = (x / max) * 100
}
async function tryConfirm() {
if (verified.value || !challengeId.value || confirming.value) return
confirming.value = true
try {
const position = Math.round(sliderPercent.value)
await $fetch('/api/auth/captcha/confirm', {
method: 'POST',
body: {
id: challengeId.value,
captchaPosition: position
}
})
verified.value = true
emit('verified', {
id: challengeId.value,
positionPercent: position
})
} catch {
shake.value = true
sliderPercent.value = 0
window.setTimeout(() => {
shake.value = false
}, 450)
} finally {
confirming.value = false
}
}
function onThumbPointerDown(event: PointerEvent) {
if (verified.value || loading.value || confirming.value) return
dragging.value = true
updateTrackWidth()
;(event.currentTarget as HTMLElement).setPointerCapture(event.pointerId)
}
function onThumbPointerMove(event: PointerEvent) {
if (!dragging.value) return
setSliderFromClientX(event.clientX)
}
function onThumbPointerUp() {
if (!dragging.value) return
dragging.value = false
if (!verified.value) {
void tryConfirm()
}
}
onMounted(() => {
void loadChallenge()
})
let resizeObserver: ResizeObserver | undefined
watch(trackRef, (el, _, onCleanup) => {
resizeObserver?.disconnect()
resizeObserver = undefined
if (!el) return
updateTrackWidth()
if (typeof ResizeObserver === 'undefined') return
resizeObserver = new ResizeObserver(updateTrackWidth)
resizeObserver.observe(el)
onCleanup(() => {
resizeObserver?.disconnect()
resizeObserver = undefined
})
})
defineExpose({ reset: loadChallenge })
</script>
<template>
<div
ref="trackRef"
class="captcha-track relative h-12 select-none overflow-hidden rounded-lg border transition-[background-color,border-color] duration-300"
:class="[
verified
? 'border-primary/35 bg-primary/10'
: 'border-default bg-muted/20',
loading ? 'opacity-70' : '',
shake ? 'captcha-shake' : ''
]"
>
<div
v-if="loading"
class="absolute inset-0 flex items-center justify-center"
>
<UIcon
name="i-lucide-loader-2"
class="size-5 animate-spin text-muted"
/>
</div>
<template v-else>
<img
v-if="trackSvgUrl"
:src="trackSvgUrl"
alt=""
aria-hidden="true"
class="pointer-events-none absolute inset-0 size-full object-fill transition-opacity duration-300"
:class="verified ? 'opacity-0' : 'opacity-100'"
draggable="false"
>
<div
v-if="isActive && !verified"
class="pointer-events-none absolute inset-y-1 left-1 rounded-md bg-primary/20 transition-[width] duration-75"
:style="{ width: fillWidth }"
/>
<p
v-if="!verified"
class="pointer-events-none absolute inset-0 flex items-center justify-center pl-12 text-sm text-muted transition-opacity duration-150"
:style="{ opacity: hintOpacity }"
>
{{ t('auth.captchaHint') }}
</p>
<p
v-if="verified"
class="pointer-events-none absolute inset-y-0 flex items-center pr-3 text-sm font-medium text-primary"
:style="{ left: successLabelLeft }"
>
{{ t('auth.captchaSuccess') }}
</p>
<div
class="absolute top-1/2 -translate-y-1/2 touch-none rounded-md border transition-[left,background-color,border-color,color,box-shadow,transform] duration-150"
:class="[
verified
? 'z-20 border-primary bg-primary text-white shadow-sm shadow-primary/25'
: isActive
? 'z-10 border-primary/45 bg-elevated text-primary shadow-md'
: 'z-10 border-default/90 bg-elevated text-muted shadow-sm',
dragging ? 'scale-[1.02] cursor-grabbing' : verified ? 'cursor-default' : 'cursor-grab'
]"
:style="{
left: `${thumbLeft}px`,
width: `${THUMB_SIZE}px`,
height: `${THUMB_SIZE}px`
}"
@pointerdown="onThumbPointerDown"
@pointermove="onThumbPointerMove"
@pointerup="onThumbPointerUp"
@pointercancel="onThumbPointerUp"
>
<div class="flex size-full items-center justify-center">
<UIcon
v-if="confirming"
name="i-lucide-loader-2"
class="size-4 animate-spin"
:class="verified ? 'text-white' : isActive ? 'text-primary' : 'text-muted'"
/>
<UIcon
v-else
:name="verified ? 'i-lucide-check' : 'i-lucide-chevrons-right'"
class="size-4"
/>
</div>
</div>
</template>
</div>
</template>
<style scoped>
.captcha-shake {
animation: captcha-shake 0.4s ease;
}
@keyframes captcha-shake {
0%,
100% {
transform: translateX(0);
}
25%,
75% {
transform: translateX(-4px);
}
50% {
transform: translateX(4px);
}
}
</style>
-111
View File
@@ -1,111 +0,0 @@
<script setup lang="ts">
export interface DonutSegment {
label: string
value: number
color: string
}
const props = defineProps<{
segments: DonutSegment[]
size?: number
}>()
const total = computed(() =>
props.segments.reduce((sum, item) => sum + item.value, 0)
)
const gradient = computed(() => {
if (!total.value) {
return 'conic-gradient(var(--ui-bg-muted) 0% 100%)'
}
let offset = 0
const stops = props.segments
.filter(item => item.value > 0)
.map((item) => {
const start = offset
const percent = (item.value / total.value) * 100
offset += percent
return `${item.color} ${start}% ${offset}%`
})
if (!stops.length) {
return 'conic-gradient(var(--ui-bg-muted) 0% 100%)'
}
return `conic-gradient(${stops.join(', ')})`
})
function percent(value: number) {
if (!total.value) return 0
return Math.round((value / total.value) * 100)
}
function formatCount(value: number) {
if (value >= 100000000) {
return `${(value / 100000000).toFixed(value >= 1000000000 ? 0 : 1)}亿`
}
if (value >= 10000) {
return `${(value / 10000).toFixed(value >= 100000 ? 0 : 1)}万`
}
return String(value)
}
const centerText = computed(() => formatCount(total.value))
const centerTextClass = computed(() => {
const raw = String(total.value)
if (raw.length >= 7) return 'text-[9px] font-semibold leading-none'
if (raw.length >= 5) return 'text-xs font-semibold leading-none'
if (raw.length >= 4) return 'text-sm font-semibold leading-none'
return 'text-lg font-semibold leading-none'
})
</script>
<template>
<div class="flex items-center gap-4">
<div
class="relative shrink-0 rounded-full"
:style="{
width: `${size ?? 88}px`,
height: `${size ?? 88}px`,
background: gradient
}"
>
<div
class="absolute inset-[20%] flex flex-col items-center justify-center rounded-full bg-elevated px-1 text-center"
>
<p
class="max-w-full truncate tabular-nums"
:class="centerTextClass"
:title="String(total)"
>
{{ centerText }}
</p>
<p class="mt-0.5 text-[10px] text-muted">
次
</p>
</div>
</div>
<ul class="min-w-0 flex-1 space-y-1.5">
<li
v-for="item in segments"
:key="item.label"
class="flex items-center gap-2 text-xs"
>
<span
class="size-2 shrink-0 rounded-full"
:style="{ background: item.color }"
/>
<span class="min-w-0 flex-1 truncate">{{ item.label }}</span>
<span
class="shrink-0 tabular-nums text-muted"
:title="`${item.value}`"
>
{{ formatCount(item.value) }} ({{ percent(item.value) }}%)
</span>
</li>
</ul>
</div>
</template>
+296
View File
@@ -0,0 +1,296 @@
<script setup lang="ts">
import type { ImageTag } from '~/types/image'
import { pickRandomUnusedTagColor, tagPillStyle } from '~/utils/tag-colors'
const props = withDefaults(defineProps<{
modelValue: number[]
tags: ImageTag[]
disabled?: boolean
allowCreate?: boolean
}>(), {
disabled: false,
allowCreate: true
})
const emit = defineEmits<{
'update:modelValue': [value: number[]]
'created': [tag: ImageTag]
}>()
const COMMON_LIMIT = 10
const { t } = useI18n()
const toast = useToast()
const { createTag } = useTags()
const { recentIds, loadRecentIds } = useRecentTags()
const query = ref('')
const creating = ref(false)
onMounted(() => {
loadRecentIds()
})
const isSearching = computed(() => query.value.trim().length > 0)
const selectedTags = computed(() =>
props.tags.filter(tag => props.modelValue.includes(tag.id))
)
const commonTags = computed(() =>
[...props.tags]
.sort((a, b) => (b.imageCount ?? 0) - (a.imageCount ?? 0))
.slice(0, COMMON_LIMIT)
)
const recentTags = computed(() =>
recentIds.value
.map(id => props.tags.find(tag => tag.id === id))
.filter((tag): tag is ImageTag => Boolean(tag))
)
const existingTags = computed(() => {
const q = query.value.trim().toLowerCase()
if (!q) return props.tags
return props.tags.filter(tag => tag.name.toLowerCase().includes(q))
})
const showCreateOption = computed(() => {
if (!props.allowCreate) return false
const q = query.value.trim()
if (!q) return false
return !props.tags.some(tag => tag.name === q)
})
const allExistingSelected = computed(() =>
existingTags.value.length > 0
&& existingTags.value.every(tag => props.modelValue.includes(tag.id))
)
function isSelected(tagId: number) {
return props.modelValue.includes(tagId)
}
function toggleTag(tagId: number) {
if (props.disabled) return
const next = new Set(props.modelValue)
if (next.has(tagId)) next.delete(tagId)
else next.add(tagId)
emit('update:modelValue', [...next])
}
function removeTag(tagId: number) {
if (props.disabled) return
emit('update:modelValue', props.modelValue.filter(id => id !== tagId))
}
function clearSelected() {
if (props.disabled) return
emit('update:modelValue', [])
}
function toggleSelectAllExisting() {
if (props.disabled) return
const visibleIds = existingTags.value.map(tag => tag.id)
if (allExistingSelected.value) {
emit('update:modelValue', props.modelValue.filter(id => !visibleIds.includes(id)))
return
}
emit('update:modelValue', [...new Set([...props.modelValue, ...visibleIds])])
}
async function createFromQuery() {
const name = query.value.trim()
if (!name || creating.value) return
creating.value = true
try {
const color = pickRandomUnusedTagColor(props.tags.map(tag => tag.color))
const tag = await createTag(name, color)
emit('created', tag)
emit('update:modelValue', [...new Set([...props.modelValue, tag.id])])
query.value = ''
toast.add({ title: t('tags.created', { name: tag.name }), color: 'success' })
} catch (error: unknown) {
const message = error instanceof Error ? error.message : t('tags.createFailed')
toast.add({ title: message, color: 'error' })
} finally {
creating.value = false
}
}
defineExpose({ reloadRecent: loadRecentIds })
</script>
<template>
<div class="space-y-4">
<UInput
v-model="query"
size="md"
icon="i-lucide-search"
:placeholder="t('tags.searchOrCreate')"
:disabled="disabled"
@keydown.enter.prevent="showCreateOption ? createFromQuery() : undefined"
/>
<button
v-if="showCreateOption"
type="button"
class="flex w-full items-center gap-1.5 rounded-lg px-1 py-1 text-left text-sm text-primary hover:bg-primary/5"
:disabled="disabled || creating"
@click="createFromQuery"
>
<UIcon
name="i-lucide-plus"
class="size-4 shrink-0"
/>
{{ t('tags.createNamed', { name: query.trim() }) }}
</button>
<div
v-if="isSearching && existingTags.length"
class="space-y-2"
>
<div class="flex items-center justify-between gap-2">
<p class="text-sm font-medium text-highlighted">
{{ t('tags.existingLabel', { n: existingTags.length }) }}
</p>
<button
type="button"
class="text-xs text-muted transition-colors hover:text-highlighted"
:disabled="disabled"
@click="toggleSelectAllExisting"
>
{{ allExistingSelected ? t('tags.deselectAll') : t('tags.selectAll') }}
</button>
</div>
<div class="grid grid-cols-3 gap-2">
<button
v-for="tag in existingTags"
:key="`existing-${tag.id}`"
type="button"
class="inline-flex min-w-0 items-center justify-center gap-1 rounded-full border px-2.5 py-1.5 text-xs font-medium transition-opacity hover:opacity-90"
:style="tagPillStyle(tag.color, isSelected(tag.id))"
:disabled="disabled"
@click="toggleTag(tag.id)"
>
<UIcon
v-if="isSelected(tag.id)"
name="i-lucide-check"
class="size-3 shrink-0"
/>
<span class="truncate">{{ tag.name }}</span>
<UIcon
v-if="isSelected(tag.id)"
name="i-lucide-x"
class="size-3 shrink-0 opacity-80"
/>
</button>
</div>
</div>
<template v-else>
<div
v-if="selectedTags.length"
class="space-y-2"
>
<div class="flex items-center justify-between gap-2">
<p class="text-sm font-medium text-highlighted">
{{ t('tags.selectedLabel', { n: selectedTags.length }) }}
</p>
<button
type="button"
class="text-xs text-muted transition-colors hover:text-highlighted"
:disabled="disabled"
@click="clearSelected"
>
{{ t('tags.clearSelected') }}
</button>
</div>
<div class="flex flex-wrap gap-2">
<button
v-for="tag in selectedTags"
:key="tag.id"
type="button"
class="inline-flex max-w-full items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs font-medium transition-opacity hover:opacity-90"
:style="tagPillStyle(tag.color, true)"
:disabled="disabled"
@click="removeTag(tag.id)"
>
<UIcon
name="i-lucide-check"
class="size-3 shrink-0"
/>
<span class="truncate">{{ tag.name }}</span>
<UIcon
name="i-lucide-x"
class="size-3 shrink-0 opacity-80"
/>
</button>
</div>
</div>
<div
v-if="commonTags.length"
class="space-y-2"
>
<p class="text-sm font-medium text-highlighted">
{{ t('tags.commonTags') }}
</p>
<div class="flex flex-wrap gap-2">
<button
v-for="tag in commonTags"
:key="tag.id"
type="button"
class="inline-flex max-w-full items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs font-medium transition-opacity hover:opacity-90"
:style="tagPillStyle(tag.color, isSelected(tag.id))"
:disabled="disabled"
@click="toggleTag(tag.id)"
>
<UIcon
v-if="isSelected(tag.id)"
name="i-lucide-check"
class="size-3 shrink-0"
/>
<span class="truncate">{{ tag.name }}</span>
</button>
</div>
</div>
<div
v-if="recentTags.length"
class="space-y-2"
>
<p class="text-sm font-medium text-highlighted">
{{ t('tags.recentTags') }}
</p>
<div class="flex flex-wrap gap-2">
<button
v-for="tag in recentTags"
:key="`recent-${tag.id}`"
type="button"
class="inline-flex max-w-full items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs font-medium transition-opacity hover:opacity-90"
:style="tagPillStyle(tag.color, isSelected(tag.id))"
:disabled="disabled"
@click="toggleTag(tag.id)"
>
<UIcon
v-if="isSelected(tag.id)"
name="i-lucide-check"
class="size-3 shrink-0"
/>
<span class="truncate">{{ tag.name }}</span>
</button>
</div>
</div>
</template>
<p
v-if="!tags.length && !showCreateOption"
class="py-6 text-center text-sm text-muted"
>
{{ t('tags.emptyHint') }}
</p>
</div>
</template>
+61
View File
@@ -0,0 +1,61 @@
<script setup lang="ts">
import type { ImageTag } from '~/types/image'
import { defaultTagColor, resolveTagHexColor, tagPillStyle } from '~/utils/tag-colors'
const props = withDefaults(defineProps<{
tag: ImageTag
removable?: boolean
clickable?: boolean
size?: 'xs' | 'sm'
}>(), {
removable: false,
clickable: false,
size: 'xs'
})
const emit = defineEmits<{
remove: []
click: []
}>()
const displayColor = computed(() =>
resolveTagHexColor(props.tag.color, defaultTagColor(props.tag.id))
)
const pillStyle = computed(() => tagPillStyle(displayColor.value))
</script>
<template>
<span
class="inline-flex max-w-full items-center gap-1 rounded-md border px-2 py-0.5 font-medium"
:class="[
size === 'sm' ? 'text-sm' : 'text-xs',
clickable ? 'cursor-pointer hover:opacity-90' : ''
]"
:style="pillStyle"
:role="clickable ? 'button' : undefined"
:tabindex="clickable ? 0 : undefined"
@click="clickable ? emit('click') : undefined"
@keydown.enter.prevent="clickable ? emit('click') : undefined"
@keydown.space.prevent="clickable ? emit('click') : undefined"
>
<span
class="size-1.5 shrink-0 rounded-full"
:style="{ backgroundColor: displayColor }"
aria-hidden="true"
/>
<span class="truncate">{{ tag.name }}</span>
<button
v-if="removable"
type="button"
class="inline-flex shrink-0 rounded p-0.5 opacity-70 hover:opacity-100"
:aria-label="`Remove ${tag.name}`"
@click.stop="emit('remove')"
>
<UIcon
name="i-lucide-x"
class="size-3"
/>
</button>
</span>
</template>
+70
View File
@@ -0,0 +1,70 @@
<script setup lang="ts">
import type { ImageTag } from '~/types/image'
const props = withDefaults(defineProps<{
tags?: ImageTag[]
max?: number
compact?: boolean
emptyLabel?: string
}>(), {
tags: () => [],
max: 3,
compact: false,
emptyLabel: ''
})
const emit = defineEmits<{
'tag-click': [tagId: number]
}>()
const { t } = useI18n()
const visibleTags = computed(() => props.tags.slice(0, props.max))
const hiddenTags = computed(() => props.tags.slice(props.max))
</script>
<template>
<div
v-if="tags.length"
class="flex flex-wrap items-center"
:class="compact ? 'gap-1' : 'gap-1.5'"
>
<TagBadge
v-for="tag in visibleTags"
:key="tag.id"
:tag="tag"
@click="emit('tag-click', tag.id)"
/>
<UPopover
v-if="hiddenTags.length"
:content="{ side: 'bottom', align: 'start' }"
>
<button
type="button"
class="inline-flex rounded-md border border-default text-muted transition-colors hover:border-primary/40 hover:text-primary"
:class="compact ? 'px-1.5 py-0.5 text-[11px]' : 'px-2 py-0.5 text-xs'"
:aria-label="t('tags.moreTags', { n: hiddenTags.length })"
>
+{{ hiddenTags.length }}
</button>
<template #content>
<div
class="flex max-w-56 flex-wrap gap-1.5 p-2"
>
<TagBadge
v-for="tag in hiddenTags"
:key="tag.id"
:tag="tag"
@click="emit('tag-click', tag.id)"
/>
</div>
</template>
</UPopover>
</div>
<span
v-else-if="emptyLabel"
class="text-muted"
>
{{ emptyLabel }}
</span>
</template>
+127
View File
@@ -0,0 +1,127 @@
<script setup lang="ts">
import { TAG_COLOR_PRESETS, visibleTagColorPresets } from '~/utils/tag-colors'
const props = withDefaults(defineProps<{
modelValue: string
/** 已被其他标签占用的预设色,选择器中优先展示未占用颜色 */
usedColors?: (string | null | undefined)[]
/** 固定展示的备选色(新增 8 个 / 编辑 3 个) */
candidates?: string[]
/** 紧凑模式:不显示标题 */
compact?: boolean
}>(), {
compact: false
})
const emit = defineEmits<{
'update:modelValue': [value: string]
}>()
const { t } = useI18n()
const colorInput = ref<HTMLInputElement | null>(null)
const normalizedValue = computed(() => normalizeHex(props.modelValue))
const presetColors = computed(() => {
if (props.candidates?.length) {
const seen = new Set<string>()
const items: string[] = []
for (const color of props.candidates) {
const lower = color.toLowerCase()
if (!seen.has(lower)) {
seen.add(lower)
items.push(lower)
}
}
return items
}
if (!props.usedColors?.length) return TAG_COLOR_PRESETS
return visibleTagColorPresets(props.usedColors, props.modelValue)
})
const isPreset = computed(() =>
TAG_COLOR_PRESETS.some(color => color.toLowerCase() === normalizedValue.value.toLowerCase())
)
const isCustomSelected = computed(() => {
if (props.candidates?.length) {
return !presetColors.value.includes(normalizedValue.value)
}
return !isPreset.value
})
function normalizeHex(value: string) {
const trimmed = value.trim()
if (/^#[0-9A-Fa-f]{6}$/.test(trimmed)) return trimmed.toLowerCase()
return '#22c55e'
}
function selectColor(color: string) {
emit('update:modelValue', color.toLowerCase())
}
function onNativeColorInput(event: Event) {
const value = (event.target as HTMLInputElement).value
if (value) emit('update:modelValue', value.toLowerCase())
}
function openNativePicker() {
colorInput.value?.click()
}
</script>
<template>
<div :class="compact ? '' : 'space-y-2'">
<p
v-if="!compact"
class="text-xs font-medium text-muted"
>
{{ t('tags.pickColor') }}
</p>
<div class="flex flex-wrap items-center gap-2">
<button
v-for="color in presetColors"
:key="color"
type="button"
class="size-7 shrink-0 rounded-full border-2 transition-all hover:scale-110"
:class="normalizedValue === color
? 'border-highlighted ring-2 ring-primary/25 scale-110'
: 'border-transparent'"
:style="{ backgroundColor: color }"
:aria-label="color"
:title="color"
@click="selectColor(color)"
/>
<button
type="button"
class="relative size-7 shrink-0 overflow-hidden rounded-full border-2 transition-all hover:scale-110"
:class="isCustomSelected
? 'border-highlighted ring-2 ring-primary/25 scale-110'
: 'border-dashed border-default'"
:style="isCustomSelected ? { backgroundColor: normalizedValue } : undefined"
:aria-label="t('tags.customColor')"
:title="t('tags.customColor')"
@click="openNativePicker"
>
<span
v-if="!isCustomSelected"
class="flex size-full items-center justify-center bg-muted/40"
>
<UIcon
name="i-lucide-pipette"
class="size-3.5 text-muted"
/>
</span>
<input
ref="colorInput"
type="color"
class="pointer-events-none absolute inset-0 size-full opacity-0"
:value="normalizedValue"
@input="onNativeColorInput"
>
</button>
</div>
</div>
</template>
+158
View File
@@ -0,0 +1,158 @@
<script setup lang="ts">
import type { ImageTag } from '~/types/image'
const props = withDefaults(defineProps<{
modelValue: number[]
tagMode: 'or' | 'and'
untaggedOnly: boolean
tags: ImageTag[]
disabled?: boolean
}>(), {
disabled: false
})
const emit = defineEmits<{
'update:modelValue': [value: number[]]
'update:tagMode': [value: 'or' | 'and']
'update:untaggedOnly': [value: boolean]
'manage': []
}>()
const { t } = useI18n()
const selectedTags = computed(() =>
props.tags.filter(tag => props.modelValue.includes(tag.id))
)
const buttonLabel = computed(() => {
if (props.untaggedOnly) return t('tags.untagged')
if (!selectedTags.value.length) return t('tags.filterAll')
if (selectedTags.value.length === 1) return selectedTags.value[0]!.name
return t('tags.filterSelected', { n: selectedTags.value.length })
})
const tagItems = computed(() =>
props.tags.map(tag => ({
label: tag.name,
value: tag.id
}))
)
const selectValue = computed({
get: () => (props.untaggedOnly ? [] : props.modelValue),
set: (value: number[]) => {
emit('update:untaggedOnly', false)
emit('update:modelValue', value)
}
})
function findTag(tagId: number) {
return props.tags.find(tag => tag.id === tagId)
}
function selectUntagged() {
emit('update:modelValue', [])
emit('update:untaggedOnly', true)
}
function clearFilter() {
emit('update:modelValue', [])
emit('update:untaggedOnly', false)
}
</script>
<template>
<USelect
v-model="selectValue"
multiple
:items="tagItems"
class="w-28 shrink-0"
size="sm"
:disabled="disabled"
value-key="value"
:content="{ align: 'start', sideOffset: 4 }"
:ui="{
content: 'w-72 max-w-[calc(100vw-2rem)]',
viewport: 'max-h-44 overflow-y-auto [scrollbar-width:none] [-ms-overflow-style:none] [&::-webkit-scrollbar]:hidden',
item: 'text-xs',
itemLabel: 'text-xs',
itemTrailingIcon: 'size-3.5 text-primary',
placeholder: 'truncate pointer-events-none text-highlighted',
value: 'truncate pointer-events-none text-highlighted'
}"
>
<template #default>
{{ buttonLabel }}
</template>
<template #content-top>
<div class="space-y-1.5 border-b border-default p-2 text-xs">
<div class="flex items-center justify-between gap-2 px-0.5">
<span class="font-medium text-highlighted">{{ t('tags.filterTitle') }}</span>
<button
type="button"
class="text-primary hover:underline"
@click="emit('manage')"
>
{{ t('tags.manage') }}
</button>
</div>
<div
v-if="modelValue.length > 1"
class="flex gap-1 rounded-md border border-default p-0.5"
>
<button
type="button"
class="flex-1 rounded px-2 py-1 text-center transition-colors"
:class="tagMode === 'or' ? 'bg-primary text-white' : 'text-muted hover:bg-elevated'"
@click="emit('update:tagMode', 'or')"
>
{{ t('tags.modeOr') }}
</button>
<button
type="button"
class="flex-1 rounded px-2 py-1 text-center transition-colors"
:class="tagMode === 'and' ? 'bg-primary text-white' : 'text-muted hover:bg-elevated'"
@click="emit('update:tagMode', 'and')"
>
{{ t('tags.modeAnd') }}
</button>
</div>
<div class="space-y-0.5">
<button
type="button"
class="flex w-full items-center rounded-md px-2 py-1 text-left hover:bg-elevated"
:class="!untaggedOnly && !modelValue.length ? 'bg-primary/10 text-primary' : ''"
@click="clearFilter"
>
{{ t('tags.filterAll') }}
</button>
<button
type="button"
class="flex w-full items-center rounded-md px-2 py-1 text-left hover:bg-elevated"
:class="untaggedOnly ? 'bg-primary/10 text-primary' : ''"
@click="selectUntagged"
>
{{ t('tags.untagged') }}
</button>
</div>
</div>
</template>
<template #item-leading="{ item }">
<span
class="size-2 shrink-0 rounded-full"
:style="{ backgroundColor: findTag(item.value)?.color }"
/>
</template>
<template #item-trailing="{ item }">
<span class="text-[11px] tabular-nums text-muted">
{{ findTag(item.value)?.imageCount ?? 0 }}
</span>
</template>
</USelect>
</template>
+49
View File
@@ -0,0 +1,49 @@
<script setup lang="ts">
import type { ImageTag } from '~/types/image'
const props = defineProps<{
open: boolean
tags: ImageTag[]
}>()
const emit = defineEmits<{
'update:open': [value: boolean]
'changed': []
}>()
const { t } = useI18n()
const { fetchTags } = useTags()
watch(() => props.open, (open) => {
if (open) void fetchTags(true)
})
</script>
<template>
<UModal
:open="open"
:title="t('tags.manageTitle')"
:description="t('tags.manageSubtitle')"
:ui="{ content: 'max-w-5xl' }"
@update:open="emit('update:open', $event)"
>
<template #body>
<TagManagePanel
:tags="tags"
@changed="emit('changed')"
/>
</template>
<template #footer>
<div class="flex w-full justify-end">
<UButton
variant="ghost"
color="neutral"
@click="emit('update:open', false)"
>
{{ t('common.cancel') }}
</UButton>
</div>
</template>
</UModal>
</template>
File diff suppressed because it is too large. Load diff
+233
View File
@@ -0,0 +1,233 @@
<script setup lang="ts">
import type { ImageTag } from '~/types/image'
import { pickRandomUnusedTagColor } from '~/utils/tag-colors'
const props = withDefaults(defineProps<{
modelValue: number[]
tags: ImageTag[]
disabled?: boolean
placeholder?: string
allowCreate?: boolean
/** 在 Modal 内使用内联列表,避免 Popover 被遮罩挡住 */
inline?: boolean
/** 内联列表使用复选框样式 */
checkbox?: boolean
/** 隐藏已选标签徽章(弹窗内用复选框列表即可) */
hideSelectedChips?: boolean
}>(), {
disabled: false,
allowCreate: true,
inline: false,
checkbox: false,
hideSelectedChips: false
})
const emit = defineEmits<{
'update:modelValue': [value: number[]]
'created': [tag: ImageTag]
}>()
const { t } = useI18n()
const toast = useToast()
const { createTag } = useTags()
const open = ref(false)
const query = ref('')
const creating = ref(false)
const selectedTags = computed(() =>
props.tags.filter(tag => props.modelValue.includes(tag.id))
)
const filteredTags = computed(() => {
const q = query.value.trim().toLowerCase()
if (!q) return props.tags
return props.tags.filter(tag => tag.name.toLowerCase().includes(q))
})
const showCreateOption = computed(() => {
if (!props.allowCreate) return false
const q = query.value.trim()
if (!q) return false
return !props.tags.some(tag => tag.name === q)
})
function toggleTag(tagId: number) {
const next = new Set(props.modelValue)
if (next.has(tagId)) {
next.delete(tagId)
} else {
next.add(tagId)
}
emit('update:modelValue', [...next])
}
function removeTag(tagId: number) {
emit('update:modelValue', props.modelValue.filter(id => id !== tagId))
}
async function createFromQuery() {
const name = query.value.trim()
if (!name || creating.value) return
creating.value = true
try {
const color = pickRandomUnusedTagColor(props.tags.map(tag => tag.color))
const tag = await createTag(name, color)
emit('created', tag)
emit('update:modelValue', [...new Set([...props.modelValue, tag.id])])
query.value = ''
toast.add({ title: t('tags.created', { name: tag.name }), color: 'success' })
} catch (error: unknown) {
const message = error instanceof Error ? error.message : t('tags.createFailed')
toast.add({ title: message, color: 'error' })
} finally {
creating.value = false
}
}
</script>
<template>
<div class="space-y-2">
<div
v-if="selectedTags.length && !hideSelectedChips"
class="flex flex-wrap gap-1.5"
>
<TagBadge
v-for="tag in selectedTags"
:key="tag.id"
:tag="tag"
removable
@remove="removeTag(tag.id)"
/>
</div>
<div
v-if="inline"
class="space-y-2 rounded-lg border border-default p-2"
>
<UInput
v-model="query"
size="sm"
icon="i-lucide-search"
:placeholder="checkbox ? t('tags.search') : t('tags.searchOrCreate')"
:disabled="disabled"
@keydown.enter.prevent="showCreateOption ? createFromQuery() : undefined"
/>
<div class="max-h-48 space-y-1 overflow-y-auto">
<button
v-for="tag in filteredTags"
:key="tag.id"
type="button"
class="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm hover:bg-elevated"
:class="modelValue.includes(tag.id) ? 'bg-primary/10' : ''"
@click="toggleTag(tag.id)"
>
<UCheckbox
v-if="checkbox"
:model-value="modelValue.includes(tag.id)"
class="pointer-events-none"
@click.stop
/>
<span
class="size-2 shrink-0 rounded-full"
:style="{ backgroundColor: tag.color }"
/>
<span class="min-w-0 flex-1 truncate">{{ tag.name }}</span>
<UIcon
v-if="!checkbox && modelValue.includes(tag.id)"
name="i-lucide-check"
class="size-4 text-primary"
/>
</button>
<p
v-if="!filteredTags.length && !showCreateOption"
class="px-2 py-3 text-center text-xs text-muted"
>
{{ tags.length ? t('tags.noMatch') : t('tags.emptyHint') }}
</p>
<button
v-if="showCreateOption"
type="button"
class="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm text-primary hover:bg-elevated"
:disabled="creating"
@click="createFromQuery"
>
<UIcon
name="i-lucide-plus"
class="size-4"
/>
{{ t('tags.createNamed', { name: query.trim() }) }}
</button>
</div>
</div>
<UPopover
v-else
v-model:open="open"
>
<UButton
type="button"
size="sm"
variant="outline"
color="neutral"
icon="i-lucide-plus"
:disabled="disabled"
>
{{ placeholder ?? t('tags.addTag') }}
</UButton>
<template #content>
<div class="w-64 space-y-2 p-2">
<UInput
v-model="query"
size="sm"
icon="i-lucide-search"
:placeholder="checkbox ? t('tags.search') : t('tags.searchOrCreate')"
:disabled="disabled"
@keydown.enter.prevent="showCreateOption ? createFromQuery() : undefined"
/>
<div class="max-h-48 space-y-1 overflow-y-auto">
<button
v-for="tag in filteredTags"
:key="tag.id"
type="button"
class="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm hover:bg-elevated"
:class="modelValue.includes(tag.id) ? 'bg-primary/10' : ''"
@click="toggleTag(tag.id)"
>
<span
class="size-2 shrink-0 rounded-full"
:style="{ backgroundColor: tag.color }"
/>
<span class="min-w-0 flex-1 truncate">{{ tag.name }}</span>
<UIcon
v-if="modelValue.includes(tag.id)"
name="i-lucide-check"
class="size-4 text-primary"
/>
</button>
<button
v-if="showCreateOption"
type="button"
class="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm text-primary hover:bg-elevated"
:disabled="creating"
@click="createFromQuery"
>
<UIcon
name="i-lucide-plus"
class="size-4"
/>
{{ t('tags.createNamed', { name: query.trim() }) }}
</button>
</div>
</div>
</template>
</UPopover>
</div>
</template>
@@ -0,0 +1,106 @@
<script setup lang="ts">
const { t } = useI18n()
const { isAdmin } = useAuth()
const {
concurrency,
threads,
maxConcurrency,
maxThreads,
preset,
bitratePercent,
minBitratePercent,
maxBitratePercent,
load
} = useTranscodeSettings()
const presetItems = computed(() => [
{ value: 'copy', label: t('preferences.presetCopy') },
{ value: 'veryfast', label: t('preferences.presetSpeed') },
{ value: 'medium', label: t('preferences.presetBalanced') },
{ value: 'slow', label: t('preferences.presetQuality') },
{ value: 'compress', label: t('preferences.presetCompress') }
])
function onBitrateInput(event: Event) {
const value = Number((event.target as HTMLInputElement).value)
bitratePercent.value = Number.isFinite(value) ? value : 80
}
function selectPreset(value: string) {
preset.value = value
}
onMounted(() => {
void load()
})
</script>
<template>
<div class="space-y-3">
<div class="flex items-center gap-2 text-sm">
<span class="shrink-0">{{ t('preferences.transcodeConcurrency') }}</span>
<UInput
v-model.number="concurrency"
type="number"
min="1"
:max="maxConcurrency"
size="xs"
class="w-16 tabular-nums"
:disabled="!isAdmin"
/>
<span class="text-muted">{{ t('preferences.unitCount') }}</span>
<span class="text-xs text-muted">{{ t('preferences.concurrentHint') }}</span>
</div>
<div class="flex items-center gap-2 text-sm">
<span class="shrink-0">{{ t('preferences.transcodeThreads') }}</span>
<UInput
v-model.number="threads"
type="number"
min="1"
:max="maxThreads"
size="xs"
class="w-16 tabular-nums"
:disabled="!isAdmin"
/>
<span class="text-muted">{{ t('preferences.unitCount') }}</span>
<span class="text-xs text-muted">{{ t('preferences.threadsHint') }}</span>
</div>
<div class="flex flex-wrap items-center gap-2 text-sm">
<span class="shrink-0">{{ t('preferences.transcodeQuality') }}</span>
<div class="flex gap-1 rounded-lg border border-default p-0.5">
<UButton
v-for="item in presetItems"
:key="item.value"
size="xs"
class="flex-1 justify-center"
:variant="preset === item.value ? 'solid' : 'ghost'"
:color="preset === item.value ? 'primary' : 'neutral'"
:label="item.label"
:disabled="!isAdmin"
@click="selectPreset(item.value)"
/>
</div>
</div>
<div
class="flex items-center gap-2 text-sm"
:class="preset === 'compress' ? '' : 'opacity-40'"
>
<span class="shrink-0">{{ t('preferences.compressBitrate') }}</span>
<input
type="range"
:min="minBitratePercent"
:max="maxBitratePercent"
:value="bitratePercent"
:disabled="!isAdmin || preset !== 'compress'"
class="h-2 min-w-24 flex-1 cursor-pointer appearance-none rounded-full bg-default accent-primary disabled:opacity-40"
@input="onBitrateInput"
>
<span class="w-12 shrink-0 text-right tabular-nums text-muted">
{{ bitratePercent }}%
</span>
</div>
</div>
</template>
+120
View File
@@ -0,0 +1,120 @@
<script setup lang="ts">
const props = defineProps<{
siteKey: string
}>()
const emit = defineEmits<{
verified: [payload: { turnstileToken: string }]
cleared: []
}>()
interface TurnstileRenderOptions {
'sitekey': string
'action'?: string
'callback'?: (token: string) => void
'expired-callback'?: () => void
'error-callback'?: () => void
}
interface TurnstileApi {
render: (container: HTMLElement, options: TurnstileRenderOptions) => string
reset: (widgetId?: string) => void
remove: (widgetId: string) => void
}
declare global {
interface Window {
turnstile?: TurnstileApi
onTurnstileLoad?: () => void
}
}
const containerRef = ref<HTMLElement | null>(null)
const widgetId = ref<string | null>(null)
const scriptLoaded = ref(false)
function onVerified(token: string) {
if (token) {
emit('verified', { turnstileToken: token })
}
}
function onCleared() {
emit('cleared')
}
function renderWidget() {
if (!containerRef.value || !window.turnstile || !props.siteKey) return
if (widgetId.value) {
window.turnstile.remove(widgetId.value)
widgetId.value = null
}
widgetId.value = window.turnstile.render(containerRef.value, {
'sitekey': props.siteKey,
'action': 'login',
'callback': onVerified,
'expired-callback': onCleared,
'error-callback': onCleared
})
}
function tryRenderWidget() {
if (scriptLoaded.value) {
renderWidget()
}
}
function loadScript() {
if (window.turnstile) {
scriptLoaded.value = true
tryRenderWidget()
return
}
window.onTurnstileLoad = () => {
scriptLoaded.value = true
tryRenderWidget()
}
const existing = document.querySelector('script[data-turnstile]')
if (existing) {
if (window.turnstile) {
scriptLoaded.value = true
tryRenderWidget()
}
return
}
const script = document.createElement('script')
script.src = 'https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit&onload=onTurnstileLoad'
script.async = true
script.defer = true
script.dataset.turnstile = 'true'
document.head.appendChild(script)
}
function reset() {
if (window.turnstile && widgetId.value) {
window.turnstile.reset(widgetId.value)
}
onCleared()
}
onMounted(async () => {
loadScript()
await nextTick()
tryRenderWidget()
})
watch([containerRef, () => props.siteKey, scriptLoaded], () => {
tryRenderWidget()
}, { flush: 'post' })
defineExpose({ reset })
</script>
<template>
<ClientOnly>
<div
ref="containerRef"
class="flex min-h-16 items-center justify-center"
/>
</ClientOnly>
</template>
@@ -2,9 +2,24 @@
import uploadCardBg from '~/assets/image/upload-card-bg.webp' import uploadCardBg from '~/assets/image/upload-card-bg.webp'
import uploadCardHero from '~/assets/image/upload-card-hero.webp' import uploadCardHero from '~/assets/image/upload-card-hero.webp'
const props = defineProps<{ const props = withDefaults(defineProps<{
disabled?: boolean disabled?: boolean
}>() title?: string
hint?: string
formats?: string
accept?: string
heroImage?: string
backgroundImage?: string
showSettings?: boolean
pasteEnabled?: boolean
/** 拖拽/选择时的类型限制;图片页与视频页各自限定,文件页放开 */
mediaKind?: 'image' | 'video' | 'any'
}>(), {
accept: 'image/jpeg,image/png,image/webp,image/gif,image/svg+xml,image/x-icon,.svg,.ico',
showSettings: true,
pasteEnabled: true,
mediaKind: 'image'
})
const emit = defineEmits<{ const emit = defineEmits<{
'upload': [files: File[]] 'upload': [files: File[]]
@@ -18,9 +33,22 @@ const { t } = useI18n()
const { bindPaste } = useClipboardImage(ref(null)) const { bindPaste } = useClipboardImage(ref(null))
bindPaste((files) => { bindPaste((files) => {
if (!props.disabled) emit('upload', files) if (props.disabled || !props.pasteEnabled) return
emit('upload', files)
}) })
const heroSource = computed(() => props.heroImage ?? uploadCardHero)
const backgroundSource = computed(() => props.backgroundImage ?? uploadCardBg)
const titleText = computed(() => props.title ?? t('upload.title'))
const hintText = computed(() => props.hint ?? t('upload.hint', { ctrl: 'Ctrl', v: 'V' }))
const formatsText = computed(() => props.formats ?? t('upload.formats'))
function matchesMediaKind(file: File): boolean {
if (props.mediaKind === 'any') return true
if (!file.type) return true
return file.type.startsWith(`${props.mediaKind}/`)
}
function openFilePicker() { function openFilePicker() {
if (props.disabled) return if (props.disabled) return
fileInput.value?.click() fileInput.value?.click()
@@ -38,8 +66,7 @@ function onDrop(event: DragEvent) {
dragging.value = false dragging.value = false
if (props.disabled) return if (props.disabled) return
const files = Array.from(event.dataTransfer?.files ?? []) const files = Array.from(event.dataTransfer?.files ?? []).filter(matchesMediaKind)
.filter(file => file.type.startsWith('image/'))
if (files.length) emit('upload', files) if (files.length) emit('upload', files)
} }
@@ -83,7 +110,7 @@ function onKeydown(event: KeyboardEvent) {
<input <input
ref="fileInput" ref="fileInput"
type="file" type="file"
accept="image/jpeg,image/png,image/webp,image/gif,image/svg+xml,image/x-icon,.svg,.ico" :accept="accept"
multiple multiple
class="hidden" class="hidden"
:disabled="disabled" :disabled="disabled"
@@ -91,7 +118,7 @@ function onKeydown(event: KeyboardEvent) {
> >
<img <img
:src="uploadCardBg" :src="backgroundSource"
alt="" alt=""
aria-hidden="true" aria-hidden="true"
class="pointer-events-none absolute inset-0 size-full object-cover opacity-100 dark:opacity-[0.12]" class="pointer-events-none absolute inset-0 size-full object-cover opacity-100 dark:opacity-[0.12]"
@@ -99,7 +126,10 @@ function onKeydown(event: KeyboardEvent) {
draggable="false" draggable="false"
> >
<div class="absolute top-3 right-3 z-20"> <div
v-if="showSettings"
class="absolute top-3 right-3 z-20"
>
<button <button
type="button" type="button"
class="upload-settings-btn inline-flex size-8 items-center justify-center rounded-md bg-default/70 text-muted backdrop-blur-sm transition-colors hover:bg-elevated hover:text-highlighted disabled:pointer-events-none disabled:opacity-40" class="upload-settings-btn inline-flex size-8 items-center justify-center rounded-md bg-default/70 text-muted backdrop-blur-sm transition-colors hover:bg-elevated hover:text-highlighted disabled:pointer-events-none disabled:opacity-40"
@@ -117,7 +147,7 @@ function onKeydown(event: KeyboardEvent) {
<div class="relative z-10 mx-auto flex max-w-lg flex-col items-center"> <div class="relative z-10 mx-auto flex max-w-lg flex-col items-center">
<div class="flex flex-col items-center"> <div class="flex flex-col items-center">
<img <img
:src="uploadCardHero" :src="heroSource"
alt="" alt=""
aria-hidden="true" aria-hidden="true"
class="h-24 w-auto -mb-3 select-none transition-transform duration-200 ease-[cubic-bezier(0.4,0,0.2,1)] sm:-mb-4 sm:h-28 md:h-32" class="h-24 w-auto -mb-3 select-none transition-transform duration-200 ease-[cubic-bezier(0.4,0,0.2,1)] sm:-mb-4 sm:h-28 md:h-32"
@@ -128,16 +158,16 @@ function onKeydown(event: KeyboardEvent) {
<div class="space-y-2"> <div class="space-y-2">
<p class="text-xl font-semibold tracking-tight text-highlighted sm:text-2xl"> <p class="text-xl font-semibold tracking-tight text-highlighted sm:text-2xl">
{{ t('upload.title') }} {{ titleText }}
</p> </p>
<p class="text-sm text-muted sm:text-base"> <p class="text-sm text-muted sm:text-base">
{{ t('upload.hint', { ctrl: 'Ctrl', v: 'V' }) }} {{ hintText }}
</p> </p>
</div> </div>
</div> </div>
<p class="mt-4 max-w-md text-xs leading-relaxed text-dimmed sm:text-sm"> <p class="mt-4 max-w-md text-xs leading-relaxed text-dimmed sm:text-sm">
{{ t('upload.formats') }} {{ formatsText }}
</p> </p>
</div> </div>
</div> </div>
+38 -5
View File
@@ -1,7 +1,20 @@
<script setup lang="ts"> <script setup lang="ts">
defineProps<{ import type { UploadMediaKind } from '~/composables/useUploadPreferences'
const props = withDefaults(defineProps<{
disabled?: boolean disabled?: boolean
}>() /** 上传页类型:决定默认可选文件类型、粘贴支持与偏好面板内容 */
kind?: UploadMediaKind
title?: string
hint?: string
formats?: string
accept?: string
heroImage?: string
backgroundImage?: string
pasteEnabled?: boolean
}>(), {
kind: 'image'
})
const emit = defineEmits<{ const emit = defineEmits<{
upload: [files: File[]] upload: [files: File[]]
@@ -9,6 +22,15 @@ const emit = defineEmits<{
const flipped = ref(false) const flipped = ref(false)
const mediaKind = computed(() => (props.kind === 'file' ? 'any' : props.kind))
const pasteEnabled = computed(() => props.pasteEnabled ?? props.kind === 'image')
const defaultAccept = computed(() => {
if (props.kind === 'video') return 'video/*,.mp4,.webm,.mov,.mkv,.avi,.m4v'
if (props.kind === 'file') return ''
return undefined
})
const accept = computed(() => props.accept ?? defaultAccept.value)
function onOpenSettings() { function onOpenSettings() {
flipped.value = true flipped.value = true
} }
@@ -21,18 +43,29 @@ function onBack() {
<template> <template>
<div <div
class="upload-card-flip" class="upload-card-flip"
:class="{ 'is-flipped': flipped }" :class="[`upload-card-flip--${kind}`, { 'is-flipped': flipped }]"
> >
<div class="upload-card-flip-inner"> <div class="upload-card-flip-inner">
<div class="upload-card-face upload-card-face--front"> <div class="upload-card-face upload-card-face--front">
<ImageUploader <UploadCard
:disabled="disabled" :disabled="disabled"
:title="title"
:hint="hint"
:formats="formats"
:accept="accept"
:hero-image="heroImage"
:background-image="backgroundImage"
:media-kind="mediaKind"
:paste-enabled="pasteEnabled"
@upload="emit('upload', $event)" @upload="emit('upload', $event)"
@open-settings="onOpenSettings" @open-settings="onOpenSettings"
/> />
</div> </div>
<div class="upload-card-face upload-card-face--back"> <div class="upload-card-face upload-card-face--back">
<UploadPreferencesPanel @back="onBack" /> <UploadPreferencesPanel
:kind="kind"
@back="onBack"
/>
</div> </div>
</div> </div>
</div> </div>
+195 -40
View File
@@ -1,12 +1,15 @@
<script setup lang="ts"> <script setup lang="ts">
withDefaults( import {
defineProps<{ normalizeUploadTagNames,
embedded?: boolean type UploadMediaKind
}>(), } from '~/composables/useUploadPreferences'
{
embedded: false const props = withDefaults(defineProps<{
} /** 每个上传页各自一份偏好 */
) kind?: UploadMediaKind
}>(), {
kind: 'image'
})
const emit = defineEmits<{ const emit = defineEmits<{
back: [] back: []
@@ -17,10 +20,43 @@ const {
autoCopyMarkdown, autoCopyMarkdown,
copyFormat, copyFormat,
clientWebpQuality, clientWebpQuality,
uploadTagNames,
transcodeToHls,
loadPreferences loadPreferences
} = useUploadPreferences() } = useUploadPreferences(props.kind)
const uploadTagDraft = ref('')
/** 视频床 / 文件床没有图片压缩相关选项 */
const isImageKind = computed(() => props.kind === 'image')
const isVideoKind = computed(() => props.kind === 'video')
const processingGridClass = computed(() => {
if (isImageKind.value || isVideoKind.value) return 'md:col-start-1 md:row-start-1'
return ''
})
const qualityGridClass = computed(() => {
if (isImageKind.value) return 'md:col-start-1 md:row-start-2'
return ''
})
const convenienceGridClass = computed(() => {
if (isImageKind.value) return 'md:col-start-2 md:row-start-1'
if (isVideoKind.value) return 'md:col-start-1 md:row-start-2'
return ''
})
const tagsGridClass = computed(() => {
if (isImageKind.value) return 'md:col-start-2 md:row-start-2'
if (isVideoKind.value) return 'md:col-start-2 md:row-start-1 md:row-span-2'
return ''
})
const tagsLayoutClass = computed(() => [
tagsGridClass.value
])
const { t } = useI18n() const { t } = useI18n()
const {
lowMemory,
memoryBytes,
load: loadTranscodeSettings
} = useTranscodeSettings()
const { const {
userSettings, userSettings,
@@ -32,7 +68,8 @@ const {
const copyFormatItems = computed(() => [ const copyFormatItems = computed(() => [
{ label: t('copy.url'), value: 'url' as const }, { label: t('copy.url'), value: 'url' as const },
{ label: t('copy.markdown'), value: 'markdown' as const }, { label: t('copy.markdown'), value: 'markdown' as const },
{ label: t('copy.html'), value: 'html' as const } { label: t('copy.html'), value: 'html' as const },
{ label: t('copy.bbcode'), value: 'bbcode' as const }
]) ])
const clientQualityLevel = computed(() => { const clientQualityLevel = computed(() => {
@@ -41,7 +78,13 @@ const clientQualityLevel = computed(() => {
return t('preferences.qualityLow') return t('preferences.qualityLow')
}) })
function setCopyFormat(value: 'url' | 'markdown' | 'html') { const memoryGb = computed(() => (
memoryBytes.value > 0
? (memoryBytes.value / (1024 * 1024 * 1024)).toFixed(1)
: '—'
))
function setCopyFormat(value: 'url' | 'markdown' | 'html' | 'bbcode') {
copyFormat.value = value copyFormat.value = value
} }
@@ -50,24 +93,45 @@ function onClientQualityInput(event: Event) {
clientWebpQuality.value = Number.isFinite(value) ? value : 80 clientWebpQuality.value = Number.isFinite(value) ? value : 80
} }
function commitUploadTags() {
const names = normalizeUploadTagNames(uploadTagDraft.value)
if (!names.length) return
uploadTagNames.value = normalizeUploadTagNames([
...uploadTagNames.value,
...names
])
uploadTagDraft.value = ''
}
function onUploadTagKeydown(event: KeyboardEvent) {
if (event.key === 'Enter' || event.key === ',' || event.key === ',') {
event.preventDefault()
commitUploadTags()
return
}
if (
event.key === 'Backspace'
&& !uploadTagDraft.value
&& uploadTagNames.value.length
) {
uploadTagNames.value = uploadTagNames.value.slice(0, -1)
}
}
function removeUploadTag(name: string) {
uploadTagNames.value = uploadTagNames.value.filter(item => item !== name)
}
onMounted(() => { onMounted(() => {
loadPreferences() loadPreferences()
void loadUserSettings() void loadUserSettings()
void loadTranscodeSettings()
}) })
</script> </script>
<template> <template>
<div <div class="upload-card-surface upload-card-surface--panel flex max-sm:min-h-0 flex-col overflow-hidden max-sm:overflow-visible sm:h-full">
class="flex flex-col overflow-hidden max-sm:overflow-visible" <div class="flex items-start justify-between gap-3 border-b border-default px-5 py-4 sm:px-6">
:class="
embedded
? ''
: 'upload-card-surface upload-card-surface--panel max-sm:min-h-0 sm:h-full'
"
>
<div
class="flex items-start justify-between gap-3 border-b border-default px-5 py-4 sm:px-6"
>
<div class="flex min-w-0 items-start gap-2"> <div class="flex min-w-0 items-start gap-2">
<UIcon <UIcon
name="i-lucide-settings-2" name="i-lucide-settings-2"
@@ -83,7 +147,6 @@ onMounted(() => {
</div> </div>
</div> </div>
<UButton <UButton
v-if="!embedded"
:label="t('common.back')" :label="t('common.back')"
icon="i-lucide-arrow-left" icon="i-lucide-arrow-left"
variant="ghost" variant="ghost"
@@ -95,12 +158,15 @@ onMounted(() => {
/> />
</div> </div>
<div class="flex flex-1 flex-col justify-start overflow-y-auto p-5 sm:p-6">
<div <div
class="p-5 sm:p-6" class="grid gap-5 md:grid-cols-2 md:gap-6"
:class="embedded ? '' : 'flex flex-1 flex-col justify-center'" >
<div
v-if="isImageKind"
class="space-y-4 rounded-xl border border-default/70 bg-muted/10 p-4"
:class="processingGridClass"
> >
<div class="grid gap-5 sm:grid-cols-3 sm:gap-6">
<div class="space-y-4">
<h3 class="text-sm font-semibold"> <h3 class="text-sm font-semibold">
{{ t('preferences.processing') }} {{ t('preferences.processing') }}
</h3> </h3>
@@ -128,9 +194,7 @@ onMounted(() => {
class="mt-0.5" class="mt-0.5"
/> />
<span class="min-w-0 flex-1"> <span class="min-w-0 flex-1">
<span <span class="flex flex-wrap items-center gap-x-1.5 gap-y-1 text-sm">
class="flex flex-wrap items-center gap-x-1.5 gap-y-1 text-sm"
>
<span>{{ t('preferences.autoDeletePrefix') }}</span> <span>{{ t('preferences.autoDeletePrefix') }}</span>
<UInput <UInput
v-model.number="userAutoDeleteDaysDraft" v-model.number="userAutoDeleteDaysDraft"
@@ -139,11 +203,7 @@ onMounted(() => {
max="3650" max="3650"
size="xs" size="xs"
class="w-14 tabular-nums" class="w-14 tabular-nums"
:class=" :class="userAutoDeleteEnabled ? '' : 'pointer-events-none opacity-40'"
userAutoDeleteEnabled
? ''
: 'pointer-events-none opacity-40'
"
/> />
<span>{{ t('preferences.autoDeleteSuffix') }}</span> <span>{{ t('preferences.autoDeleteSuffix') }}</span>
</span> </span>
@@ -164,16 +224,57 @@ onMounted(() => {
</div> </div>
</div> </div>
<div class="space-y-4"> <div
v-if="isVideoKind"
class="space-y-4 rounded-xl border border-default/70 bg-muted/10 p-4"
:class="processingGridClass"
>
<h3 class="text-sm font-semibold">
{{ t('preferences.videoProcessing') }}
</h3>
<UAlert
v-if="lowMemory"
color="warning"
variant="subtle"
icon="i-lucide-triangle-alert"
:title="t('preferences.lowMemoryTranscodeTitle')"
:description="t('preferences.lowMemoryTranscodeHint', { memory: memoryGb })"
/>
<label class="flex cursor-pointer items-start gap-2.5">
<UCheckbox
v-model="transcodeToHls"
class="mt-0.5"
/>
<span>
<span class="block text-sm">{{ t('preferences.transcodeToHls') }}</span>
<span class="mt-1 block text-xs leading-relaxed text-muted">
{{ t('preferences.transcodeToHlsHint') }}
</span>
</span>
</label>
<div
v-if="transcodeToHls"
class="ml-7 space-y-3 border-t border-default pt-4"
>
<TranscodeSettingsControls />
</div>
</div>
<div
v-if="isImageKind"
class="space-y-4 rounded-xl border border-default/70 bg-muted/10 p-4"
:class="qualityGridClass"
>
<h3 class="text-sm font-semibold"> <h3 class="text-sm font-semibold">
{{ t('preferences.webpQuality') }} {{ t('preferences.webpQuality') }}
</h3> </h3>
<p class="text-xs leading-relaxed text-muted"> <p class="text-xs leading-relaxed text-muted">
{{ t('preferences.webpQualityHint') }} {{ t('preferences.webpQualityHint') }}
</p> </p>
<div <div class="flex items-center justify-between gap-2 text-sm text-muted">
class="flex items-center justify-between gap-2 text-sm text-muted"
>
<span>{{ t('preferences.current', { n: clientWebpQuality }) }}</span> <span>{{ t('preferences.current', { n: clientWebpQuality }) }}</span>
<span>{{ clientQualityLevel }}</span> <span>{{ clientQualityLevel }}</span>
</div> </div>
@@ -187,7 +288,10 @@ onMounted(() => {
> >
</div> </div>
<div class="space-y-4"> <div
class="space-y-4 rounded-xl border border-default/70 bg-muted/10 p-4"
:class="convenienceGridClass"
>
<h3 class="text-sm font-semibold"> <h3 class="text-sm font-semibold">
{{ t('preferences.convenience') }} {{ t('preferences.convenience') }}
</h3> </h3>
@@ -219,6 +323,57 @@ onMounted(() => {
/> />
</div> </div>
</div> </div>
<div
class="space-y-3 rounded-xl border border-default/70 bg-muted/10 p-4"
:class="tagsLayoutClass"
>
<div class="space-y-3">
<h3 class="text-sm font-semibold">
{{ t('preferences.uploadTags') }}
</h3>
<p class="text-xs leading-relaxed text-muted">
{{ t('preferences.uploadTagsHint') }}
</p>
</div>
<UInput
v-model="uploadTagDraft"
size="md"
icon="i-lucide-tags"
maxlength="32"
:placeholder="t('preferences.uploadTagsPlaceholder')"
@keydown="onUploadTagKeydown"
@blur="commitUploadTags"
/>
<div
v-if="uploadTagNames.length"
class="space-y-2 pt-1"
>
<p class="text-xs font-medium text-muted">
{{ t('preferences.uploadTagsPending', { n: uploadTagNames.length }) }}
</p>
<div class="flex flex-wrap gap-2.5">
<span
v-for="name in uploadTagNames"
:key="name"
class="relative inline-flex max-w-full items-center rounded-full border border-primary/35 bg-primary/10 px-3 py-1 text-xs font-medium text-primary"
>
<span class="truncate">{{ name }}</span>
<button
type="button"
class="absolute -top-1.5 -right-1.5 inline-flex size-4 items-center justify-center rounded-full border border-primary/40 bg-elevated text-primary shadow-sm transition-colors hover:bg-primary hover:text-inverted"
:aria-label="t('preferences.uploadTagsRemove', { name })"
@click="removeUploadTag(name)"
>
<UIcon
name="i-lucide-x"
class="size-2.5"
/>
</button>
</span>
</div>
</div>
</div>
</div> </div>
</div> </div>
</div> </div>
+160
View File
@@ -0,0 +1,160 @@
<script setup lang="ts">
import type { FileItem } from '~/types/file'
const props = defineProps<{
item: FileItem
/** 已按访问模式拼接好的视频直链 */
link: string
/** 缩略图直链 */
thumbLink: string
/** 原始视频下载地址 */
downloadLink: string
selected?: boolean
selectable?: boolean
}>()
const emit = defineEmits<{
'play': []
'delete': []
'download': []
'update:selected': [value: boolean]
}>()
const { formatFileSize } = useFileSize()
const { t, locale } = useI18n()
const transcodeFailureLabel = useTranscodeFailureLabel()
/** 转码完成后按 HLS 播放列表展示 */
const displayName = computed(() => {
if (!props.item.hlsUrl) return props.item.originalName
const dot = props.item.originalName.lastIndexOf('.')
const base = dot > 0 ? props.item.originalName.slice(0, dot) : props.item.originalName
return `${base}.m3u8`
})
const uploadedLabel = computed(() => {
try {
return new Date(props.item.uploadedAt).toLocaleString(locale.value, { hour12: false })
} catch {
return props.item.uploadedAt
}
})
</script>
<template>
<div class="flex flex-col overflow-hidden rounded-xl border border-default bg-elevated">
<div class="relative aspect-video shrink-0 overflow-hidden bg-muted">
<VideoThumbnail
:src="thumbLink"
:alt="displayName"
/>
<button
type="button"
class="absolute inset-0 flex items-center justify-center transition-colors hover:bg-black/20"
:aria-label="t('library.play')"
@click="emit('play')"
>
<span class="rounded-full bg-black/55 p-3 text-white backdrop-blur-sm">
<UIcon
name="i-lucide-play"
class="size-6"
/>
</span>
</button>
<div
v-if="selectable"
class="absolute top-2 left-2 z-10"
@click.stop
>
<UCheckbox
:model-value="selected"
@update:model-value="emit('update:selected', $event === true)"
/>
</div>
<div
class="absolute top-2 right-2 z-10"
@click.stop
>
<UBadge
v-if="item.hlsUrl"
color="success"
variant="solid"
size="xs"
>
m3u8
</UBadge>
<UBadge
v-else-if="item.transcodeStatus === 'failed'"
color="error"
variant="solid"
size="xs"
class="max-w-48 truncate"
:title="transcodeFailureLabel(item.transcodeError)"
>
{{ transcodeFailureLabel(item.transcodeError) }}
</UBadge>
</div>
</div>
<div class="flex flex-1 flex-col gap-1.5 p-3">
<p
class="truncate text-sm font-semibold"
:title="displayName"
>
{{ displayName }}
</p>
<p class="truncate text-xs text-muted">
{{ uploadedLabel }} · {{ formatFileSize(item.size) }}
</p>
<TagBadgeList :tags="item.tags" />
</div>
<div class="flex items-center justify-around border-t border-default px-2 py-2">
<CopyButton
icon="i-lucide-link"
icon-only
:label="t('library.copyVideoLink')"
:value="link"
:success-title="t('copy.copiedUrl')"
/>
<CopyButton
icon="i-lucide-image"
icon-only
:label="t('library.copyThumbLink')"
:value="thumbLink"
:success-title="t('copy.copiedUrl')"
/>
<UButton
v-if="!item.sourceDeleted"
icon="i-lucide-download"
variant="ghost"
color="neutral"
size="sm"
:to="downloadLink"
target="_blank"
rel="noopener"
:aria-label="t('files.download')"
/>
<UButton
v-else-if="item.hlsUrl"
icon="i-lucide-download"
variant="ghost"
color="neutral"
size="sm"
:aria-label="t('library.video.downloadMp4')"
@click="emit('download')"
/>
<UButton
icon="i-lucide-trash-2"
variant="ghost"
color="neutral"
size="sm"
:aria-label="t('common.delete')"
@click="emit('delete')"
/>
</div>
</div>
</template>
+785
View File
@@ -0,0 +1,785 @@
<script setup lang="ts">
import type { FileItem, FileListResponse } from '~/types/file'
import type { LibraryStatsData } from '~/types/media'
import { formatFileSize } from '~/composables/useFileSize'
import SiteSyncSelectedDialog from '~/components/storage/SiteSyncSelectedDialog.vue'
const { isAuthenticated, handleAuthError } = useAuth()
const toast = useToast()
const { t, locale } = useI18n()
const { items: tagItems, fetchTags, batchUpdateTags } = useTags()
const transcodeFailureLabel = useTranscodeFailureLabel()
const items = ref<FileItem[]>([])
const total = ref(0)
const page = ref(1)
const pageSize = ref(20)
const totalPages = ref(1)
const loading = ref(false)
const searchQuery = ref('')
const activeSearch = ref('')
const accessMode = ref<'public' | 'token'>('public')
const apiToken = ref('')
const stats = ref<LibraryStatsData | null>(null)
const selectedKeys = ref<Set<string>>(new Set())
const deleteModalOpen = ref(false)
const batchTagOpen = ref(false)
const syncOpen = ref(false)
const deleteTargetKeys = ref<string[]>([])
const deleting = ref(false)
const previewOpen = ref(false)
const previewItem = ref<FileItem | null>(null)
const downloadOpen = ref(false)
const downloadItem = ref<FileItem | null>(null)
const currentBackend = ref('all')
const currentSource = ref('all')
const backendOptions = ref<Array<{ id: string, name: string }>>([])
const activeTagIds = ref<number[]>([])
const activeTagMode = ref<'or' | 'and'>('or')
const activeUntaggedOnly = ref(false)
const { viewMode } = useLibraryView('video')
const selectedCount = computed(() => selectedKeys.value.size)
const syncKeys = computed(() => [...selectedKeys.value])
const backendItems = computed(() => [
{ label: t('stats.filterStorageAll'), value: 'all' },
...backendOptions.value.map(backend => ({ label: backend.name, value: backend.id }))
])
const sourceItems = computed(() => [
{ label: t('stats.filterSourceAll'), value: 'all' },
{ label: t('stats.sourceWeb'), value: 'web' },
{ label: t('stats.sourceApi'), value: 'api' }
])
const allSelected = computed(() =>
items.value.length > 0 && items.value.every(item => selectedKeys.value.has(item.key))
)
function withToken(raw: string | undefined): string {
if (!raw) return ''
if (accessMode.value !== 'token' || !apiToken.value) return raw
const separator = raw.includes('?') ? '&' : '?'
return `${raw}${separator}token=${encodeURIComponent(apiToken.value)}`
}
/** 转码完成后主直链指向 m3u8 播放列表 */
function videoLink(item: FileItem): string {
return withToken(item.hlsUrl ?? item.url)
}
function thumbLink(item: FileItem): string {
return withToken(item.thumbUrl) || '/video-placeholder.svg'
}
function downloadUrl(item: FileItem): string {
return withToken(item.url)
}
/** 转码完成后按 HLS 播放列表展示名称 */
function displayName(item: FileItem): string {
if (!item.hlsUrl) return item.originalName
const dot = item.originalName.lastIndexOf('.')
const base = dot > 0 ? item.originalName.slice(0, dot) : item.originalName
return `${base}.m3u8`
}
function uploadedLabel(item: FileItem): string {
try {
return new Date(item.uploadedAt).toLocaleString(locale.value, { hour12: false })
} catch {
return item.uploadedAt
}
}
async function loadApiToken() {
try {
const data = await $fetch<{ apiUploadToken: string }>('/api/user/api-token', {
credentials: 'include'
})
apiToken.value = data.apiUploadToken ?? ''
} catch {
apiToken.value = ''
}
}
async function loadBackendOptions() {
try {
const data = await $fetch<{ backends: Array<{ id: string, name: string }> }>(
'/api/storage-backends',
{ credentials: 'include' }
)
backendOptions.value = data.backends ?? []
} catch (error: unknown) {
handleAuthError(error)
}
}
async function fetchStats() {
if (!isAuthenticated.value) return
try {
stats.value = await $fetch<LibraryStatsData>('/api/library/stats', {
credentials: 'include',
query: { kind: 'video' }
})
} catch (error: unknown) {
handleAuthError(error)
}
}
async function fetchList(targetPage = page.value) {
if (!isAuthenticated.value) return
loading.value = true
try {
const response = await $fetch<FileListResponse>('/api/files', {
credentials: 'include',
query: {
limit: pageSize.value,
page: targetPage,
kind: 'video',
...(activeSearch.value ? { q: activeSearch.value } : {}),
...(currentBackend.value !== 'all' ? { backendId: currentBackend.value } : {}),
...(currentSource.value !== 'all' ? { uploadSource: currentSource.value } : {}),
...(activeTagIds.value.length ? { tagIds: activeTagIds.value.join(',') } : {}),
...(activeUntaggedOnly.value ? { untagged: '1' } : {}),
...(activeTagMode.value === 'and' ? { tagMode: 'and' } : {})
}
})
items.value = response.items
total.value = response.total
page.value = response.page
totalPages.value = response.totalPages
accessMode.value = response.accessMode
const visible = new Set(response.items.map(item => item.key))
selectedKeys.value = new Set(
[...selectedKeys.value].filter(key => visible.has(key))
)
if (response.accessMode === 'token' && !apiToken.value) {
await loadApiToken()
}
} catch (error: unknown) {
handleAuthError(error)
if (isAuthenticated.value) {
toast.add({ title: t('files.loadFailed'), color: 'error' })
}
} finally {
loading.value = false
}
}
async function refresh() {
await Promise.all([fetchList(1), fetchStats(), fetchTags()])
}
async function submitSearch() {
if (!isAuthenticated.value) return
activeSearch.value = searchQuery.value.trim()
await fetchList(1)
}
async function handleBackendChange(value: string) {
currentBackend.value = value
selectedKeys.value = new Set()
await fetchList(1)
}
async function handleSourceChange(value: string) {
currentSource.value = value
selectedKeys.value = new Set()
await fetchList(1)
}
async function handleTagIdsChange(tagIds: number[]) {
activeTagIds.value = tagIds
selectedKeys.value = new Set()
await fetchList(1)
}
async function handleTagModeChange(tagMode: 'or' | 'and') {
activeTagMode.value = tagMode
await fetchList(1)
}
async function handleUntaggedChange(untaggedOnly: boolean) {
activeUntaggedOnly.value = untaggedOnly
selectedKeys.value = new Set()
await fetchList(1)
}
async function resetFilters() {
searchQuery.value = ''
activeSearch.value = ''
currentBackend.value = 'all'
currentSource.value = 'all'
activeTagIds.value = []
activeTagMode.value = 'or'
activeUntaggedOnly.value = false
selectedKeys.value = new Set()
await fetchList(1)
}
function toggleSelected(key: string, value: boolean) {
const next = new Set(selectedKeys.value)
if (value) next.add(key)
else next.delete(key)
selectedKeys.value = next
}
function toggleSelectAll() {
selectedKeys.value = allSelected.value
? new Set()
: new Set(items.value.map(item => item.key))
}
function clearSelection() {
selectedKeys.value = new Set()
}
function setViewMode(mode: 'grid' | 'list') {
viewMode.value = mode
}
function openBatchTags() {
batchTagOpen.value = true
}
function openSync() {
syncOpen.value = true
}
async function confirmBatchTags(tagIds: number[], action: 'add' | 'remove') {
const keys = [...selectedKeys.value]
if (!keys.length || !tagIds.length) return
try {
await batchUpdateTags(keys, tagIds, action)
batchTagOpen.value = false
selectedKeys.value = new Set()
toast.add({ title: t('tags.batchSuccess'), color: 'success' })
await Promise.all([fetchList(1), fetchStats(), fetchTags(true)])
} catch (error: unknown) {
handleAuthError(error)
toast.add({ title: t('tags.batchFailed'), color: 'error' })
}
}
function openPreview(item: FileItem) {
previewItem.value = item
previewOpen.value = true
}
function requestDownload(item: FileItem | null) {
if (!item?.hlsUrl) return
previewOpen.value = false
downloadItem.value = item
downloadOpen.value = true
}
onMounted(() => {
void loadBackendOptions()
})
function requestDelete(item: FileItem) {
if (!isAuthenticated.value) return
deleteTargetKeys.value = [item.key]
deleteModalOpen.value = true
}
function requestBatchDelete() {
if (!isAuthenticated.value) return
if (!selectedCount.value) {
toast.add({ title: t('library.selectFirst'), color: 'warning' })
return
}
deleteTargetKeys.value = [...selectedKeys.value]
deleteModalOpen.value = true
}
async function confirmDelete() {
const keys = [...deleteTargetKeys.value]
if (!keys.length || !isAuthenticated.value) return
deleting.value = true
try {
if (keys.length === 1) {
await $fetch('/api/files', {
method: 'DELETE',
credentials: 'include',
query: { key: keys[0] }
})
} else {
const response = await $fetch<{ deleted: string[], failed: unknown[] }>(
'/api/files/batch-delete',
{ method: 'POST', credentials: 'include', body: { keys } }
)
if (response.failed.length) {
toast.add({
title: t('library.deletePartialFail', { n: response.failed.length }),
color: 'warning'
})
}
}
toast.add({ title: t('library.deleted', { n: keys.length }), color: 'success' })
deleteModalOpen.value = false
deleteTargetKeys.value = []
selectedKeys.value = new Set()
await refresh()
} catch (error: unknown) {
handleAuthError(error)
if (isAuthenticated.value) {
toast.add({ title: t('files.deleteFailed'), color: 'error' })
}
} finally {
deleting.value = false
}
}
async function handlePageChange(targetPage: number) {
selectedKeys.value = new Set()
await fetchList(targetPage)
}
async function handlePageSizeChange(size: number) {
pageSize.value = size
selectedKeys.value = new Set()
await fetchList(1)
}
defineExpose({ refresh })
</script>
<template>
<div class="space-y-6">
<div class="flex items-start justify-between gap-3">
<div class="min-w-0">
<h1 class="text-2xl font-semibold tracking-tight">
{{ t('library.video.title') }}
</h1>
<p class="text-sm text-muted">
{{ t('library.video.subtitle') }}
</p>
</div>
<UButton
icon="i-lucide-refresh-cw"
variant="ghost"
color="neutral"
size="sm"
class="mt-0.5 shrink-0"
:aria-label="t('common.refresh')"
:loading="loading"
@click="refresh"
/>
</div>
<LibraryStatsPanel
kind="video"
:stats="stats"
:loading="loading"
/>
<section class="space-y-3">
<div class="flex flex-wrap items-center justify-between gap-x-4 gap-y-2">
<div class="flex items-center gap-2 text-sm">
<UCheckbox
:model-value="allSelected"
:disabled="!items.length"
@update:model-value="toggleSelectAll"
/>
<span class="text-muted">
{{ t('library.selectedCount', { n: selectedCount }) }}
</span>
<UButton
v-if="selectedCount > 0"
variant="link"
color="primary"
size="xs"
:label="t('library.clearSelection')"
@click="clearSelection"
/>
</div>
<div class="flex flex-wrap items-center gap-2">
<UInput
v-model="searchQuery"
icon="i-lucide-search"
:placeholder="t('library.searchPlaceholder')"
size="sm"
class="w-44"
@keydown.enter.prevent="submitSearch"
/>
<USelect
:model-value="currentBackend"
:items="backendItems"
value-key="value"
size="sm"
class="w-32"
:aria-label="t('stats.filterStorageAll')"
@update:model-value="handleBackendChange"
/>
<USelect
:model-value="currentSource"
:items="sourceItems"
value-key="value"
size="sm"
class="w-28"
:aria-label="t('stats.filterSourceAll')"
@update:model-value="handleSourceChange"
/>
<TagFilterSelect
:model-value="activeTagIds"
:tag-mode="activeTagMode"
:untagged-only="activeUntaggedOnly"
:tags="tagItems"
:disabled="loading"
@update:model-value="handleTagIdsChange"
@update:tag-mode="handleTagModeChange"
@update:untagged-only="handleUntaggedChange"
/>
<UButton
icon="i-lucide-search"
size="sm"
:label="t('common.search')"
:loading="loading"
@click="submitSearch"
/>
<UButton
icon="i-lucide-rotate-ccw"
variant="outline"
color="neutral"
size="sm"
:label="t('library.reset')"
@click="resetFilters"
/>
<UButton
v-if="selectedCount > 0"
icon="i-lucide-tags"
variant="outline"
color="primary"
size="sm"
:disabled="!selectedCount || loading"
:label="t('tags.batchAdd')"
@click="openBatchTags"
/>
<UButton
icon="i-lucide-trash-2"
variant="outline"
color="error"
size="sm"
:disabled="!selectedCount || loading"
:label="t('library.batchDelete')"
@click="requestBatchDelete"
/>
<UButton
v-if="selectedCount > 0"
icon="i-lucide-cloud-upload"
variant="outline"
color="neutral"
size="sm"
:disabled="!selectedCount || loading"
:label="t('storage.backup.syncSelectedAction')"
@click="openSync"
/>
<div class="flex gap-0.5 rounded-lg border border-default p-0.5">
<UButton
icon="i-lucide-layout-grid"
size="xs"
:variant="viewMode === 'grid' ? 'solid' : 'ghost'"
:color="viewMode === 'grid' ? 'primary' : 'neutral'"
:aria-label="t('stats.viewGrid')"
@click="setViewMode('grid')"
/>
<UButton
icon="i-lucide-list"
size="xs"
:variant="viewMode === 'list' ? 'solid' : 'ghost'"
:color="viewMode === 'list' ? 'primary' : 'neutral'"
:aria-label="t('stats.viewList')"
@click="setViewMode('list')"
/>
</div>
</div>
</div>
<div
v-if="loading && !items.length"
class="grid gap-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4"
>
<USkeleton
v-for="n in 8"
:key="n"
class="aspect-[4/3] rounded-xl"
/>
</div>
<p
v-else-if="!items.length"
class="rounded-lg border border-default py-10 text-center text-sm text-muted"
>
{{ activeSearch ? t('library.searchEmpty') : t('library.video.empty') }}
</p>
<div
v-else-if="viewMode === 'grid'"
class="grid gap-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4"
>
<VideoCard
v-for="item in items"
:key="item.key"
:item="item"
:link="videoLink(item)"
:thumb-link="thumbLink(item)"
:download-link="downloadUrl(item)"
selectable
:selected="selectedKeys.has(item.key)"
@update:selected="toggleSelected(item.key, $event)"
@play="openPreview(item)"
@download="requestDownload(item)"
@delete="requestDelete(item)"
/>
</div>
<div
v-else
class="overflow-hidden rounded-xl border border-default bg-default"
>
<div class="overflow-x-auto">
<table class="min-w-full text-xs">
<thead class="border-b border-default bg-elevated/40 text-muted">
<tr>
<th class="w-10 px-3 py-2.5 text-left font-medium">
<UCheckbox
:model-value="allSelected"
:aria-label="t('stats.selectAll')"
@update:model-value="toggleSelectAll"
/>
</th>
<th class="min-w-[14rem] px-3 py-2.5 text-left font-medium">
{{ t('stats.colFilename') }}
</th>
<th class="hidden w-24 px-3 py-2.5 text-left font-medium sm:table-cell">
{{ t('stats.colSource') }}
</th>
<th class="w-20 px-3 py-2.5 text-left font-medium">
{{ t('stats.colSize') }}
</th>
<th class="hidden w-28 px-3 py-2.5 text-left font-medium md:table-cell">
{{ t('stats.colType') }}
</th>
<th class="hidden w-40 px-3 py-2.5 text-left font-medium lg:table-cell">
{{ t('stats.colUploadedAt') }}
</th>
<th class="hidden min-w-[9rem] px-3 py-2.5 text-left font-medium md:table-cell">
{{ t('tags.column') }}
</th>
<th class="w-32 px-3 py-2.5 text-right font-medium">
{{ t('stats.colActions') }}
</th>
</tr>
</thead>
<tbody class="divide-y divide-default">
<tr
v-for="item in items"
:key="item.key"
class="transition-colors hover:bg-elevated/50"
:class="selectedKeys.has(item.key) ? 'bg-primary/5' : ''"
>
<td class="px-3 py-2">
<UCheckbox
:model-value="selectedKeys.has(item.key)"
@update:model-value="toggleSelected(item.key, $event === true)"
/>
</td>
<td class="px-3 py-2.5">
<div class="flex min-w-0 items-center gap-3">
<button
type="button"
class="relative aspect-video w-12 shrink-0 overflow-hidden rounded-md border border-default bg-muted"
:aria-label="t('library.play')"
@click="openPreview(item)"
>
<VideoThumbnail
:src="thumbLink(item)"
:alt="displayName(item)"
/>
</button>
<p
class="min-w-0 flex-1 truncate text-highlighted"
:title="displayName(item)"
>
{{ displayName(item) }}
</p>
<UBadge
v-if="item.hlsUrl"
color="success"
variant="subtle"
size="xs"
class="shrink-0"
>
m3u8
</UBadge>
<UBadge
v-else-if="item.transcodeStatus === 'failed'"
color="error"
variant="subtle"
size="xs"
class="shrink-0"
:title="transcodeFailureLabel(item.transcodeError)"
>
{{ transcodeFailureLabel(item.transcodeError) }}
</UBadge>
</div>
</td>
<td class="hidden px-3 py-2 sm:table-cell">
<span
v-if="item.uploadSource"
class="inline-flex items-center gap-1 rounded-md border border-default px-1.5 py-0.5 text-[11px] leading-4 text-muted"
>
<UIcon
:name="item.uploadSource === 'api' ? 'i-lucide-code-xml' : 'i-lucide-globe'"
class="size-3 shrink-0"
/>
{{ item.uploadSource === 'api' ? t('stats.tagApi') : t('stats.tagWeb') }}
</span>
<span
v-else
class="text-muted"
>—</span>
</td>
<td class="px-3 py-2 tabular-nums text-muted">
{{ formatFileSize(item.size) }}
</td>
<td class="hidden px-3 py-2 md:table-cell">
<span
v-if="item.storage"
class="inline-flex items-center gap-1 rounded-md border border-default px-1.5 py-0.5 text-[11px] leading-4 text-muted"
>
<UIcon
:name="item.storage.type === 'local' ? 'i-lucide-hard-drive' : 'i-lucide-cloud'"
class="size-3 shrink-0"
/>
{{ item.storage.name }}
</span>
<span
v-else
class="text-muted"
>—</span>
</td>
<td class="hidden px-3 py-2 text-muted lg:table-cell">
{{ uploadedLabel(item) }}
</td>
<td class="hidden px-3 py-2 md:table-cell">
<TagBadgeList
:tags="item.tags"
compact
:empty-label="t('tags.unlabeled')"
/>
</td>
<td class="px-3 py-2">
<div class="flex items-center justify-end gap-1">
<CopyButton
icon-only
icon="i-lucide-link"
:label="t('library.copyVideoLink')"
:value="videoLink(item)"
/>
<CopyButton
icon-only
icon="i-lucide-image"
:label="t('library.copyThumbLink')"
:value="thumbLink(item)"
/>
<UButton
v-if="!item.sourceDeleted"
size="xs"
variant="ghost"
color="neutral"
icon="i-lucide-download"
:to="downloadUrl(item)"
target="_blank"
rel="noopener"
:aria-label="t('files.download')"
/>
<UButton
v-else-if="item.hlsUrl"
size="xs"
variant="ghost"
color="neutral"
icon="i-lucide-download"
:aria-label="t('library.video.downloadMp4')"
@click="requestDownload(item)"
/>
<UButton
size="xs"
variant="ghost"
color="error"
icon="i-lucide-trash-2"
:aria-label="t('common.delete')"
@click="requestDelete(item)"
/>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<PaginationBar
v-if="!loading && total > 0"
:page="page"
:total-pages="totalPages"
:total="total"
:page-size="pageSize"
unit=""
:loading="loading"
@update:page="handlePageChange"
@update:page-size="handlePageSizeChange"
/>
</section>
<DeleteConfirmModal
v-model:open="deleteModalOpen"
:count="deleteTargetKeys.length"
:loading="deleting"
kind="video"
@confirm="confirmDelete"
/>
<BatchTagModal
v-model:open="batchTagOpen"
:tags="tagItems"
:selected-count="selectedCount"
@confirm="confirmBatchTags"
/>
<SiteSyncSelectedDialog
v-model:open="syncOpen"
:keys="syncKeys"
scope="video"
/>
<VideoPreviewModal
v-model:open="previewOpen"
:item="previewItem"
:link="previewItem ? videoLink(previewItem) : ''"
:thumb-link="previewItem ? thumbLink(previewItem) : ''"
:download-link="previewItem ? downloadUrl(previewItem) : ''"
:deleting="deleting"
@download="requestDownload(previewItem)"
@delete="previewItem && requestDelete(previewItem)"
/>
<HlsDownloadModal
v-model:open="downloadOpen"
:item="downloadItem"
/>
</div>
</template>
+533
View File
@@ -0,0 +1,533 @@
<script setup lang="ts">
import type { FileItem } from '~/types/file'
import type { CopyFormat } from '~/composables/useUploadPreferences'
const props = withDefaults(defineProps<{
open: boolean
item: FileItem | null
/** 已按访问模式拼接好的视频直链 */
link: string
/** 缩略图直链 */
thumbLink: string
/** 原始视频下载地址 */
downloadLink: string
deleting?: boolean
allowDelete?: boolean
}>(), {
allowDelete: true
})
const emit = defineEmits<{
'update:open': [value: boolean]
'delete': []
'download': []
}>()
const toast = useToast()
const { formatFileSize } = useFileSize()
const { t, locale } = useI18n()
const transcodeFailureLabel = useTranscodeFailureLabel()
const { copyFormat } = useUploadPreferences('video')
const videoEl = ref<HTMLVideoElement | null>(null)
const copyingUrl = ref(false)
const embedFormat = ref<CopyFormat>('url')
let hlsInstance: { destroy: () => void } | null = null
const embedFormatItems = computed(() => [
{ label: t('copy.url'), value: 'url' as const },
{ label: t('copy.markdown'), value: 'markdown' as const },
{ label: t('copy.html'), value: 'html' as const },
{ label: t('copy.bbcode'), value: 'bbcode' as const }
])
/** 转码完成后按 HLS 播放列表展示 */
const displayName = computed(() => {
const item = props.item
if (!item) return '—'
if (!item.hlsUrl) return item.originalName
const dot = item.originalName.lastIndexOf('.')
const base = dot > 0 ? item.originalName.slice(0, dot) : item.originalName
return `${base}.m3u8`
})
const uploadedLabel = computed(() => {
const item = props.item
if (!item) return '—'
try {
return new Date(item.uploadedAt).toLocaleString(locale.value, { hour12: false })
} catch {
return item.uploadedAt
}
})
const sizeLabel = computed(() => {
const item = props.item
if (!item) return '—'
return t('image.previewSizeDetail', {
size: formatFileSize(item.size),
bytes: item.size.toLocaleString(locale.value)
})
})
const transcodeLabel = computed(() => {
const item = props.item
if (!item) return '—'
switch (item.transcodeStatus) {
case 'pending':
return t('library.transcodePending')
case 'running':
return `${t('library.transcoding')} ${item.transcodeProgress ?? 0}%`
case 'done':
return t('library.video.transcodeDone')
case 'failed':
return transcodeFailureLabel(item.transcodeError)
default:
return t('library.video.transcodeNone')
}
})
/** 转码完成后条目实际代表 HLS,路径与类型都按播放列表展示 */
const storagePath = computed(() => props.item?.hlsKey ?? props.item?.key ?? '—')
const contentTypeLabel = computed(() =>
props.item?.hlsKey ? 'application/vnd.apple.mpegurl' : props.item?.contentType ?? '—'
)
const previewValue = computed(() => {
if (!props.item) return ''
switch (embedFormat.value) {
case 'markdown':
return `[${displayName.value}](${props.link})`
case 'html':
return `<a href="${props.link}">${displayName.value}</a>`
case 'bbcode':
return `[url=${props.link}]${displayName.value}[/url]`
default:
return props.link
}
})
const copySuccessTitle = computed(() => {
switch (embedFormat.value) {
case 'markdown':
return t('copy.copiedMarkdown')
case 'html':
return t('copy.copiedHtml')
case 'bbcode':
return t('copy.copiedBbcode')
default:
return t('copy.copiedUrl')
}
})
const storageIcon = computed(() =>
props.item?.storage?.type === 'local' ? 'i-lucide-hard-drive' : 'i-lucide-cloud'
)
const uploadSourceTag = computed(() => {
switch (props.item?.uploadSource) {
case 'web':
return t('stats.tagWeb')
case 'api':
return t('stats.tagApi')
default:
return null
}
})
const uploadSourceIcon = computed(() => {
switch (props.item?.uploadSource) {
case 'web':
return 'i-lucide-globe'
case 'api':
return 'i-lucide-code-xml'
default:
return null
}
})
function destroyPlayer() {
if (hlsInstance) {
hlsInstance.destroy()
hlsInstance = null
}
if (videoEl.value) {
videoEl.value.removeAttribute('src')
videoEl.value.load()
}
}
async function attachPlayerSource() {
const item = props.item
const element = videoEl.value
if (!item || !element) return
element.src = props.link
if (!item.hlsUrl || element.canPlayType('application/vnd.apple.mpegurl')) return
const { default: Hls } = await import('hls.js')
if (!Hls.isSupported()) return
const hls = new Hls()
hls.on(Hls.Events.ERROR, (_event, data) => {
if (!data.fatal) return
// 切片缺失(例如从备份恢复)时回退播放原视频
hls.destroy()
hlsInstance = null
element.src = props.downloadLink
})
hls.loadSource(props.link)
hls.attachMedia(element)
hlsInstance = hls
}
watch(() => props.open, async (open) => {
if (!open) {
destroyPlayer()
return
}
embedFormat.value = copyFormat.value
await nextTick()
await attachPlayerSource()
})
onUnmounted(destroyPlayer)
function selectEmbedFormat(value: CopyFormat) {
embedFormat.value = value
copyFormat.value = value
}
async function copyText(text: string, successTitle: string) {
if (!text) return
try {
if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(text)
toast.add({ title: successTitle, color: 'success' })
return
}
} catch {
// 回退方案见下
}
const textarea = document.createElement('textarea')
textarea.value = text
textarea.setAttribute('readonly', '')
textarea.style.position = 'fixed'
textarea.style.left = '-9999px'
document.body.appendChild(textarea)
textarea.select()
let ok = false
try {
ok = document.execCommand('copy')
} catch {
ok = false
} finally {
document.body.removeChild(textarea)
}
toast.add({
title: ok ? successTitle : t('copy.failedManual'),
color: ok ? 'success' : 'error'
})
}
async function copyLink() {
if (!props.link || copyingUrl.value) return
copyingUrl.value = true
try {
await copyText(props.link, t('copy.copiedUrl'))
} finally {
copyingUrl.value = false
}
}
function openInNewTab() {
if (!props.link) return
window.open(props.link, '_blank', 'noopener,noreferrer')
}
function downloadOriginal() {
if (!props.downloadLink) return
const link = document.createElement('a')
link.href = props.downloadLink
link.download = props.item?.originalName || 'video'
link.rel = 'noopener'
link.click()
}
</script>
<template>
<UModal
:open="open && !!item"
:title="t('library.video.previewTitle')"
:description="t('library.video.previewSubtitle')"
:ui="{ content: 'max-w-5xl' }"
@update:open="emit('update:open', $event)"
>
<template
v-if="item"
#body
>
<div class="grid gap-6 lg:grid-cols-[minmax(0,1fr)_minmax(0,1.15fr)] lg:items-start">
<div class="flex items-center justify-center overflow-hidden rounded-xl border border-default bg-black p-2">
<video
ref="videoEl"
controls
autoplay
playsinline
class="max-h-[min(60vh,24rem)] w-full rounded-lg bg-black"
/>
</div>
<div class="flex min-w-0 flex-col gap-5">
<dl class="divide-y divide-default text-sm">
<div class="flex gap-4 py-2.5 first:pt-0">
<dt class="w-20 shrink-0 text-muted">
{{ t('image.previewOriginalName') }}
</dt>
<dd class="min-w-0 flex-1 break-all font-medium">
{{ displayName }}
</dd>
</div>
<div class="flex gap-4 py-2.5">
<dt class="w-20 shrink-0 text-muted">
{{ t('image.previewUploadedAt') }}
</dt>
<dd class="min-w-0 flex-1">
{{ uploadedLabel }}
</dd>
</div>
<div class="flex gap-4 py-2.5">
<dt class="w-20 shrink-0 text-muted">
{{ t('image.previewSize') }}
</dt>
<dd class="min-w-0 flex-1">
{{ sizeLabel }}
</dd>
</div>
<div class="flex gap-4 py-2.5">
<dt class="w-20 shrink-0 text-muted">
{{ t('image.previewContentType') }}
</dt>
<dd class="min-w-0 flex-1">
{{ contentTypeLabel }}
</dd>
</div>
<div class="flex gap-4 py-2.5">
<dt class="w-20 shrink-0 text-muted">
{{ t('library.video.previewTranscode') }}
</dt>
<dd class="min-w-0 flex-1">
<UBadge
:color="item.hlsUrl ? 'success' : item.transcodeStatus === 'failed' ? 'error' : 'neutral'"
variant="subtle"
size="xs"
>
{{ transcodeLabel }}
</UBadge>
</dd>
</div>
<div
v-if="item.storage"
class="flex gap-4 py-2.5"
>
<dt class="w-20 shrink-0 text-muted">
{{ t('image.previewStorage') }}
</dt>
<dd class="min-w-0 flex-1">
<span class="inline-flex items-center gap-1.5">
<UIcon
:name="storageIcon"
class="size-3.5 shrink-0 text-muted"
/>
{{ item.storage.name }}
</span>
</dd>
</div>
<div
v-if="uploadSourceTag"
class="flex gap-4 py-2.5"
>
<dt class="w-20 shrink-0 text-muted">
{{ t('image.previewUploadSource') }}
</dt>
<dd class="min-w-0 flex-1">
<span
v-if="uploadSourceIcon"
class="inline-flex items-center gap-1 rounded-md border border-default px-2 py-0.5 text-xs text-muted"
>
<UIcon
:name="uploadSourceIcon"
class="size-3 shrink-0"
/>
{{ uploadSourceTag }}
</span>
</dd>
</div>
<div
v-if="item.owner"
class="flex gap-4 py-2.5"
>
<dt class="w-20 shrink-0 text-muted">
{{ t('image.previewOwner') }}
</dt>
<dd class="min-w-0 flex-1">
{{ item.owner.username }}
</dd>
</div>
<div class="flex gap-4 py-2.5">
<dt class="w-20 shrink-0 text-muted">
{{ t('tags.column') }}
</dt>
<dd class="min-w-0 flex-1">
<div
v-if="item.tags?.length"
class="flex flex-wrap gap-1.5"
>
<TagBadge
v-for="tag in item.tags"
:key="tag.id"
:tag="tag"
/>
</div>
<span
v-else
class="inline-flex rounded-md border border-dashed border-default px-2 py-0.5 text-xs text-muted"
>
{{ t('tags.unlabeled') }}
</span>
</dd>
</div>
<div class="flex gap-4 py-2.5 last:pb-0">
<dt class="w-20 shrink-0 text-muted">
{{ t('library.video.previewKey') }}
</dt>
<dd class="flex min-w-0 flex-1 items-start gap-2">
<span class="min-w-0 flex-1 break-all font-mono text-xs text-dimmed">
{{ storagePath }}
</span>
<CopyButton
icon="i-lucide-copy"
icon-only
variant="ghost"
color="neutral"
class="shrink-0"
:label="t('common.copy')"
:value="storagePath"
:success-title="t('copy.copied')"
/>
</dd>
</div>
</dl>
<div class="border-t border-default pt-4">
<p class="mb-2.5 text-sm font-medium">
{{ t('image.previewLinks') }}
</p>
<div class="flex gap-1 rounded-lg border border-default bg-default p-0.5">
<UButton
v-for="option in embedFormatItems"
:key="option.value"
size="xs"
class="flex-1 justify-center"
:variant="embedFormat === option.value ? 'solid' : 'ghost'"
:color="embedFormat === option.value ? 'primary' : 'neutral'"
:label="option.label"
@click="selectEmbedFormat(option.value)"
/>
</div>
<div class="mt-2 flex items-center gap-2">
<UInput
:model-value="previewValue"
readonly
size="sm"
class="min-w-0 flex-1 font-mono text-xs"
/>
<CopyButton
:label="t('common.copy')"
:value="previewValue"
:success-title="copySuccessTitle"
/>
</div>
<div
v-if="thumbLink"
class="mt-3"
>
<p class="mb-1.5 text-xs text-muted">
{{ t('library.video.previewThumbLink') }}
</p>
<div class="flex items-center gap-2">
<UInput
:model-value="thumbLink"
readonly
size="sm"
class="min-w-0 flex-1 font-mono text-xs"
/>
<CopyButton
:label="t('common.copy')"
:value="thumbLink"
:success-title="t('copy.copiedUrl')"
/>
</div>
</div>
</div>
</div>
</div>
</template>
<template
v-if="item"
#footer
>
<div class="flex w-full flex-wrap justify-end gap-1.5">
<UButton
:label="t('image.previewOpen')"
icon="i-lucide-external-link"
color="neutral"
variant="outline"
size="sm"
@click="openInNewTab"
/>
<UButton
v-if="!item.sourceDeleted"
:label="t('image.previewDownload')"
icon="i-lucide-download"
color="neutral"
variant="outline"
size="sm"
@click="downloadOriginal"
/>
<UButton
v-else-if="item.hlsUrl"
:label="t('library.video.downloadMp4')"
icon="i-lucide-download"
color="neutral"
variant="outline"
size="sm"
@click="emit('download')"
/>
<UButton
:label="t('image.previewCopyLink')"
icon="i-lucide-link"
color="primary"
size="sm"
:loading="copyingUrl"
@click="copyLink"
/>
<UButton
v-if="allowDelete"
:label="t('common.delete')"
icon="i-lucide-trash-2"
color="error"
variant="outline"
size="sm"
:loading="deleting"
@click="emit('delete')"
/>
</div>
</template>
</UModal>
</template>
+30
View File
@@ -0,0 +1,30 @@
<script setup lang="ts">
const props = defineProps<{
src?: string
alt: string
}>()
const failed = ref(false)
watch(() => props.src, () => {
failed.value = false
})
</script>
<template>
<img
v-if="src && !failed"
:src="src"
:alt="alt"
loading="lazy"
class="size-full object-cover object-center"
@error="failed = true"
>
<img
v-else
src="/video-placeholder.svg"
:alt="alt"
loading="lazy"
class="size-full object-cover object-center"
>
</template>
+398
View File
@@ -0,0 +1,398 @@
<script setup lang="ts">
import type { ApiDocEndpoint, ApiDocParam } from '~/composables/useApiDocs'
const props = defineProps<{
endpoint: ApiDocEndpoint
token: string
}>()
const { t } = useI18n()
const toast = useToast()
const authToken = ref('')
const paramValues = ref<Record<string, string>>({})
const fileValues = ref<Record<string, File | null>>({})
const fileInputRefs = ref<Record<string, HTMLInputElement | null>>({})
const sending = ref(false)
const responseStatus = ref<number | null>(null)
const responseBody = ref('')
const responseError = ref(false)
function isDebugParam(param: ApiDocParam) {
if (param.name === 'token' && props.endpoint.requiresAuth) {
return false
}
return true
}
const debugQueryParams = computed(() =>
props.endpoint.params.filter(p => p.location === 'query' && isDebugParam(p))
)
const debugBodyParams = computed(() =>
props.endpoint.params.filter(p =>
(p.location === 'form-data' || p.location === 'body') && isDebugParam(p)
)
)
const showAuthHeader = computed(() => props.endpoint.requiresAuth)
watch(
() => props.endpoint.id,
() => {
resetForm()
},
{ immediate: true }
)
watch(
() => props.token,
(value) => {
if (value) {
authToken.value = value
if (!props.endpoint.requiresAuth && 'token' in paramValues.value) {
paramValues.value.token = value
}
}
},
{ immediate: true }
)
function resetForm() {
const values: Record<string, string> = {}
const files: Record<string, File | null> = {}
for (const param of props.endpoint.params) {
if (!isDebugParam(param)) continue
if (param.type === 'file') {
files[param.name] = null
} else if (param.name === 'kind') {
values[param.name] = props.endpoint.category === 'video' ? 'video' : 'file'
} else if (param.name === 'keys') {
values[param.name] = props.endpoint.category === 'video'
? '["videos/2026/10/example.mp4"]'
: props.endpoint.category === 'file'
? '["files/2026/10/example.pdf"]'
: '["images/2026/08/example.webp"]'
} else if (param.name === 'token' && props.token) {
values[param.name] = props.token
} else {
values[param.name] = ''
}
}
paramValues.value = values
fileValues.value = files
responseStatus.value = null
responseBody.value = ''
responseError.value = false
}
function fileLabel(name: string) {
return fileValues.value[name]?.name ?? t('api.debug.noFileSelected')
}
function fileAccept() {
if (props.endpoint.category === 'video') return 'video/*'
if (props.endpoint.category === 'file') return undefined
return 'image/*'
}
function triggerFilePick(name: string) {
fileInputRefs.value[name]?.click()
}
function onFileChange(name: string, event: Event) {
const input = event.target as HTMLInputElement
fileValues.value[name] = input.files?.[0] ?? null
}
function isParamFilled(param: ApiDocParam) {
if (param.type === 'file') {
return fileValues.value[param.name] != null
}
const value = paramValues.value[param.name]?.trim() ?? ''
if (!value) return false
if (param.location === 'body' && param.name === 'keys') {
try {
const parsed = JSON.parse(value) as unknown
return Array.isArray(parsed) && parsed.length > 0
} catch {
return false
}
}
return true
}
const canSendRequest = computed(() => {
if (sending.value) return false
if (showAuthHeader.value && !authToken.value.trim()) return false
const params = [...debugQueryParams.value, ...debugBodyParams.value]
return params.every(param => !param.required || isParamFilled(param))
})
function buildUrl() {
const url = new URL(props.endpoint.path, window.location.origin)
for (const param of debugQueryParams.value) {
const value = paramValues.value[param.name]?.trim()
if (value) {
url.searchParams.set(param.name, value)
}
}
return url.toString()
}
async function sendRequest() {
if (!canSendRequest.value) return
sending.value = true
responseStatus.value = null
responseBody.value = ''
responseError.value = false
try {
const headers: Record<string, string> = {}
if (showAuthHeader.value && authToken.value.trim()) {
headers['Auth-Token'] = authToken.value.trim()
}
let body: BodyInit | undefined
if (debugBodyParams.value.some(p => p.location === 'form-data')) {
const formData = new FormData()
for (const param of debugBodyParams.value) {
if (param.location !== 'form-data') continue
if (param.type === 'file') {
const file = fileValues.value[param.name]
if (file) formData.append(param.name, file)
} else {
const value = paramValues.value[param.name]?.trim()
if (value) formData.append(param.name, value)
}
}
body = formData
} else if (debugBodyParams.value.some(p => p.location === 'body')) {
headers['Content-Type'] = 'application/json'
try {
const payload: Record<string, unknown> = {}
for (const param of debugBodyParams.value) {
if (param.location !== 'body') continue
const value = paramValues.value[param.name]?.trim()
if (!value) continue
payload[param.name] = param.name === 'keys' ? JSON.parse(value) : value
}
body = JSON.stringify(payload)
} catch {
toast.add({ title: t('api.debug.invalidJson'), color: 'error' })
sending.value = false
return
}
}
const response = await fetch(buildUrl(), {
method: props.endpoint.method,
headers,
body,
credentials: 'include'
})
responseStatus.value = response.status
const text = await response.text()
try {
responseBody.value = JSON.stringify(JSON.parse(text), null, 2)
} catch {
responseBody.value = text
}
responseError.value = !response.ok
} catch {
responseError.value = true
responseBody.value = t('api.debug.networkError')
toast.add({ title: t('api.debug.sendFailed'), color: 'error' })
} finally {
sending.value = false
}
}
const responseScrollable = computed(() => responseBody.value.split('\n').length > 14)
</script>
<template>
<aside class="flex w-full shrink-0 flex-col border-t border-default bg-default lg:w-72 lg:border-t-0 lg:border-l xl:w-80">
<div class="flex items-start gap-2 border-b border-default px-5 py-4 sm:px-6">
<UIcon
name="i-lucide-flask-conical"
class="mt-0.5 size-5 shrink-0 text-primary"
/>
<div class="min-w-0">
<h3 class="text-base font-semibold">
{{ t('api.debug.title') }}
</h3>
<div class="mt-1.5 flex flex-wrap items-center gap-2">
<ApiMethodBadge
:method="endpoint.method"
size="sm"
/>
<code class="truncate font-mono text-xs text-muted">
{{ endpoint.path }}
</code>
</div>
</div>
</div>
<div class="flex flex-1 flex-col">
<div class="space-y-3 p-5 sm:p-6">
<section
v-if="showAuthHeader"
class="rounded-xl border border-default p-3 sm:p-4"
>
<h4 class="mb-3 text-xs font-semibold text-highlighted">
{{ t('api.debug.headers') }}
</h4>
<div class="space-y-1.5">
<label class="text-xs text-muted">
Auth-Token
<span class="text-error">*</span>
</label>
<UInput
v-model="authToken"
size="sm"
class="font-mono text-xs"
:placeholder="t('api.debug.tokenPlaceholder')"
/>
</div>
</section>
<section
v-if="debugQueryParams.length"
class="rounded-xl border border-default p-3 sm:p-4"
>
<h4 class="mb-3 text-xs font-semibold text-highlighted">
{{ t('api.debug.query') }}
</h4>
<div class="space-y-3">
<div
v-for="param in debugQueryParams"
:key="param.name"
class="space-y-1.5"
>
<label class="text-xs text-muted">
{{ param.name }}
<span
v-if="param.required"
class="text-error"
>*</span>
</label>
<UInput
v-model="paramValues[param.name]"
size="sm"
class="font-mono text-xs"
/>
</div>
</div>
</section>
<section
v-if="debugBodyParams.length"
class="rounded-xl border border-default p-3 sm:p-4"
>
<h4 class="mb-3 text-xs font-semibold text-highlighted">
{{ t('api.debug.body') }}
</h4>
<div class="space-y-3">
<div
v-for="param in debugBodyParams"
:key="param.name"
class="space-y-1.5"
>
<label class="text-xs text-muted">
{{ param.name }}
<span
v-if="param.required"
class="text-error"
>*</span>
</label>
<div
v-if="param.type === 'file'"
class="flex items-center gap-2"
>
<input
:ref="(el) => { fileInputRefs[param.name] = el as HTMLInputElement }"
type="file"
:accept="fileAccept()"
class="hidden"
@change="onFileChange(param.name, $event)"
>
<UButton
size="xs"
variant="outline"
color="neutral"
:label="t('api.debug.selectFile')"
@click="triggerFilePick(param.name)"
/>
<span class="min-w-0 truncate text-xs text-muted">
{{ fileLabel(param.name) }}
</span>
</div>
<UTextarea
v-else-if="param.location === 'body'"
v-model="paramValues[param.name]"
:rows="3"
size="sm"
class="font-mono text-xs"
/>
<UInput
v-else
v-model="paramValues[param.name]"
size="sm"
class="font-mono text-xs"
/>
</div>
</div>
</section>
<UButton
icon="i-lucide-send"
:label="t('api.debug.send')"
color="primary"
block
:loading="sending"
:disabled="!canSendRequest"
@click="sendRequest"
/>
<section
v-if="responseStatus !== null || responseBody"
class="overflow-hidden rounded-xl border border-default"
>
<div class="flex items-center justify-between border-b border-default px-3 py-2">
<span class="text-xs font-semibold text-highlighted">
{{ t('api.debug.response') }}
</span>
<UBadge
v-if="responseStatus !== null"
:color="responseError ? 'error' : 'success'"
variant="subtle"
size="xs"
>
{{ responseStatus }}
</UBadge>
</div>
<pre
class="api-debug-response bg-neutral-950 p-3 text-xs leading-relaxed text-neutral-100"
:class="responseScrollable ? 'max-h-64 overflow-y-auto' : ''"
><code class="font-mono whitespace-pre">{{ responseBody }}</code></pre>
</section>
</div>
<div class="mt-auto border-t border-default p-5 sm:p-6">
<h4 class="mb-2 text-xs font-semibold">
{{ t('api.debug.notesTitle') }}
</h4>
<ul class="space-y-1 text-xs leading-relaxed text-muted">
<li>{{ t('api.debug.noteSuccess') }}</li>
<li>{{ t('api.debug.noteError') }}</li>
<li>{{ t('api.debug.noteLimit') }}</li>
</ul>
</div>
</div>
</aside>
</template>
+128
View File
@@ -0,0 +1,128 @@
<script setup lang="ts">
import type { ApiDocEndpoint } from '~/composables/useApiDocs'
defineProps<{
endpoint: ApiDocEndpoint
}>()
const { t } = useI18n()
</script>
<template>
<section class="p-5 sm:p-6">
<h2 class="text-base font-semibold">
{{ t(endpoint.titleKey) }}
</h2>
<div class="mt-2 flex flex-wrap items-center gap-2">
<ApiMethodBadge
:method="endpoint.method"
size="sm"
/>
<code class="rounded-md border border-primary/20 bg-primary/10 px-2 py-0.5 font-mono text-sm font-medium text-primary">
{{ endpoint.path }}
</code>
</div>
<p class="mt-3 text-sm leading-relaxed text-muted">
{{ t(endpoint.descKey) }}
</p>
<div
v-if="endpoint.params.length"
class="mt-5 overflow-hidden rounded-xl border border-default"
>
<div class="border-b border-default bg-muted/20 px-4 py-2.5">
<h3 class="text-sm font-medium">
{{ t('api.requestParams') }}
</h3>
</div>
<div class="overflow-x-auto">
<table class="w-full min-w-[36rem] table-fixed text-left text-sm">
<colgroup>
<col class="w-[5.5rem]">
<col class="w-[3.5rem]">
<col class="w-[5.75rem]">
<col class="w-[3.25rem]">
<col>
</colgroup>
<thead>
<tr class="border-b border-default text-xs text-muted">
<th class="px-4 py-2.5 font-medium">
{{ t('api.paramName') }}
</th>
<th class="whitespace-nowrap px-4 py-2.5 font-medium">
{{ t('api.paramType') }}
</th>
<th class="whitespace-nowrap px-4 py-2.5 font-medium">
{{ t('api.paramLocation') }}
</th>
<th class="whitespace-nowrap px-4 py-2.5 font-medium">
{{ t('api.paramRequired') }}
</th>
<th class="px-4 py-2.5 font-medium">
{{ t('api.paramDescription') }}
</th>
</tr>
</thead>
<tbody>
<tr
v-for="param in endpoint.params"
:key="param.name"
class="border-b border-default last:border-b-0"
>
<td class="whitespace-nowrap px-4 py-2.5 font-mono text-xs">
{{ param.name }}
<span
v-if="param.required"
class="text-error"
>*</span>
</td>
<td class="whitespace-nowrap px-4 py-2.5 text-muted">
{{ param.type }}
</td>
<td class="whitespace-nowrap px-4 py-2.5 text-muted">
{{ param.location }}
</td>
<td class="whitespace-nowrap px-4 py-2.5">
<UBadge
:color="param.required ? 'primary' : 'neutral'"
variant="subtle"
size="xs"
>
{{ param.required ? t('api.required') : t('api.optional') }}
</UBadge>
</td>
<td class="px-4 py-2.5 leading-relaxed text-muted break-words">
{{ t(param.descriptionKey) }}
</td>
</tr>
</tbody>
</table>
</div>
</div>
<div class="mt-5 space-y-4">
<div>
<p class="mb-2 text-xs font-medium text-muted">
{{ t('api.requestExample') }}
</p>
<ApiCurlBlock
:code="endpoint.curl"
lang="cURL"
/>
</div>
<div>
<p class="mb-2 text-xs font-medium text-muted">
{{ t('api.responseExample') }}
<span class="font-normal">200 OK</span>
</p>
<ApiCurlBlock
:code="endpoint.responseExample"
lang="JSON"
:scrollable="false"
/>
</div>
</div>
</section>
</template>
+34
View File
@@ -0,0 +1,34 @@
<script setup lang="ts">
import type { ApiDocMethod } from '~/composables/useApiDocs'
const props = defineProps<{
method: ApiDocMethod
size?: 'xs' | 'sm'
}>()
const colorClass = computed(() => {
switch (props.method) {
case 'GET':
return 'bg-sky-500/15 text-sky-700 dark:text-sky-300'
case 'POST':
return 'bg-primary/15 text-primary'
case 'DELETE':
return 'bg-red-500/15 text-red-700 dark:text-red-300'
default:
return 'bg-muted text-muted'
}
})
const sizeClass = computed(() =>
props.size === 'sm' ? 'px-2 py-0.5 text-xs' : 'px-1.5 py-0.5 text-[10px]'
)
</script>
<template>
<span
class="inline-flex shrink-0 rounded font-mono font-semibold leading-none"
:class="[colorClass, sizeClass]"
>
{{ method }}
</span>
</template>
+188
View File
@@ -0,0 +1,188 @@
<script setup lang="ts">
import type { ApiDocCategory, ApiDocEndpoint } from '~/composables/useApiDocs'
const props = defineProps<{
endpoints: ApiDocEndpoint[]
activeId: string
}>()
const emit = defineEmits<{
select: [id: string]
}>()
const { t, locale } = useI18n()
const search = ref('')
const docsUrl = computed(() =>
locale.value === 'en'
? 'https://o96u.github.io/PicHost/en/guide/api.html'
: 'https://o96u.github.io/PicHost/guide/api.html'
)
const categories: { key: ApiDocCategory, labelKey: string }[] = [
{ key: 'image', labelKey: 'api.categories.image' },
{ key: 'video', labelKey: 'api.categories.video' },
{ key: 'file', labelKey: 'api.categories.file' },
{ key: 'extension', labelKey: 'api.categories.extension' },
{ key: 'system', labelKey: 'api.categories.system' }
]
const openCategories = ref<Set<ApiDocCategory>>(
new Set(
categories
.filter(category => category.key !== 'video' && category.key !== 'file')
.map(category => category.key)
)
)
const filteredEndpoints = computed(() => {
const q = search.value.trim().toLowerCase()
if (!q) return props.endpoints
return props.endpoints.filter((endpoint) => {
const title = t(endpoint.titleKey).toLowerCase()
const path = endpoint.path.toLowerCase()
return title.includes(q) || path.includes(q) || endpoint.method.toLowerCase().includes(q)
})
})
function endpointsForCategory(category: ApiDocCategory) {
return filteredEndpoints.value.filter(endpoint => endpoint.category === category)
}
function isCategoryOpen(category: ApiDocCategory) {
return openCategories.value.has(category)
}
function toggleCategory(category: ApiDocCategory) {
const next = new Set(openCategories.value)
if (next.has(category)) next.delete(category)
else next.add(category)
openCategories.value = next
}
function onSelect(id: string) {
emit('select', id)
}
function onSearchKeydown(event: KeyboardEvent) {
if (event.key === 'Escape') {
search.value = ''
event.preventDefault()
}
}
onMounted(() => {
const onGlobalKeydown = (event: KeyboardEvent) => {
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'k') {
event.preventDefault()
document.getElementById('api-endpoint-search')?.focus()
}
}
window.addEventListener('keydown', onGlobalKeydown)
onUnmounted(() => window.removeEventListener('keydown', onGlobalKeydown))
})
watch(search, (value) => {
if (!value.trim()) return
const next = new Set(openCategories.value)
for (const category of categories) {
if (endpointsForCategory(category.key).length) next.add(category.key)
}
openCategories.value = next
})
</script>
<template>
<aside class="flex w-full shrink-0 flex-col border-b border-default bg-default lg:w-52 lg:border-b-0 lg:border-r lg:bg-neutral-50 dark:lg:bg-neutral-900/30 xl:w-56">
<div class="border-b border-default p-3 sm:p-4">
<UInput
id="api-endpoint-search"
v-model="search"
:placeholder="t('api.searchPlaceholder')"
icon="i-lucide-search"
size="sm"
@keydown="onSearchKeydown"
>
<template #trailing>
<UKbd
size="sm"
class="hidden sm:inline-flex"
>
⌘K
</UKbd>
</template>
</UInput>
</div>
<nav class="flex-1 overflow-y-auto p-2 sm:p-3">
<div
v-for="category in categories"
:key="category.key"
class="mb-4"
>
<button
v-if="endpointsForCategory(category.key).length"
type="button"
class="mb-1.5 flex w-full items-center justify-between rounded-md px-2 py-1 text-xs font-medium text-muted transition-colors hover:bg-muted/40 hover:text-highlighted"
:aria-expanded="isCategoryOpen(category.key)"
@click="toggleCategory(category.key)"
>
<span>{{ t(category.labelKey) }}</span>
<UIcon
name="i-lucide-chevron-down"
class="size-3.5 transition-transform"
:class="isCategoryOpen(category.key) ? '' : '-rotate-90'"
/>
</button>
<ul
v-show="isCategoryOpen(category.key)"
class="space-y-0.5"
>
<li
v-for="endpoint in endpointsForCategory(category.key)"
:key="endpoint.id"
>
<button
type="button"
class="flex w-full items-center gap-2 rounded-lg px-2 py-2 text-left text-sm transition-colors"
:class="activeId === endpoint.id
? 'bg-primary/10 font-medium text-primary'
: 'text-muted hover:bg-muted/40 hover:text-highlighted'"
@click="onSelect(endpoint.id)"
>
<ApiMethodBadge :method="endpoint.method" />
<span class="min-w-0 truncate">{{ t(endpoint.titleKey) }}</span>
</button>
</li>
</ul>
</div>
</nav>
<div class="hidden border-t border-default p-3 sm:p-4 lg:block">
<div class="rounded-xl border border-primary/15 bg-primary/5 p-3">
<div class="flex items-start gap-2.5">
<UIcon
name="i-lucide-book-open"
class="mt-0.5 size-4 shrink-0 text-primary"
/>
<div class="min-w-0 space-y-2">
<p class="text-xs font-medium text-highlighted">
{{ t('api.helpTitle') }}
</p>
<p class="text-xs leading-relaxed text-muted">
{{ t('api.helpSubtitle') }}
</p>
<UButton
:label="t('api.viewDocs')"
icon="i-lucide-external-link"
size="xs"
variant="outline"
color="neutral"
:to="docsUrl"
target="_blank"
rel="noopener noreferrer"
/>
</div>
</div>
</div>
</div>
</aside>
</template>
+137
View File
@@ -0,0 +1,137 @@
<script setup lang="ts">
const props = defineProps<{
token: string
loading: boolean
regenerating: boolean
canRegenerate: boolean
tokenSource?: 'env' | 'db' | 'none'
envTokenOverride?: boolean
isAdmin?: boolean
}>()
const emit = defineEmits<{
regenerate: []
}>()
const { t } = useI18n()
const toast = useToast()
const tokenVisible = ref(false)
const copying = ref(false)
function toggleTokenVisible() {
tokenVisible.value = !tokenVisible.value
}
const displayValue = computed(() => {
if (!props.token) return t('common.notConfigured')
if (tokenVisible.value) return props.token
return '•'.repeat(Math.min(props.token.length, 40))
})
function tokenSourceLabel(source: 'env' | 'db' | 'none' | undefined) {
switch (source) {
case 'env':
return t('api.tokenSourceEnv')
case 'db':
return t('api.tokenSourceDb')
default:
return t('api.tokenSourceNone')
}
}
async function copyToken() {
if (!props.token || copying.value) return
copying.value = true
try {
await navigator.clipboard.writeText(props.token)
toast.add({ title: t('copy.copied'), color: 'success' })
} catch {
toast.add({ title: t('copy.failed'), color: 'error' })
} finally {
copying.value = false
}
}
</script>
<template>
<section class="border-b border-default">
<div class="flex items-center gap-2 border-b border-default px-5 py-4 sm:px-6">
<UIcon
name="i-lucide-key-round"
class="size-5 shrink-0 text-primary"
/>
<h2 class="text-base font-semibold">
{{ t('api.tokenTitle') }}
</h2>
</div>
<div class="space-y-4 p-5 sm:p-6">
<UAlert
v-if="envTokenOverride"
color="warning"
variant="subtle"
icon="i-lucide-triangle-alert"
:title="t('api.envOverrideTitle')"
:description="t('api.envOverrideDesc')"
/>
<UAlert
v-else-if="!token && !loading"
color="info"
variant="subtle"
icon="i-lucide-info"
:title="t('api.notConfiguredTitle')"
:description="t('api.notConfiguredDesc')"
/>
<div class="flex flex-col gap-2 sm:flex-row sm:items-stretch">
<UInput
:model-value="displayValue"
readonly
class="min-w-0 flex-1 font-mono text-xs sm:text-sm"
:loading="loading"
/>
<div class="flex gap-2">
<UButton
size="sm"
variant="outline"
color="neutral"
:label="tokenVisible ? t('api.hideToken') : t('api.showToken')"
@click="toggleTokenVisible"
/>
<UButton
size="sm"
variant="outline"
color="neutral"
:label="t('common.copy')"
:loading="copying"
:disabled="!token"
@click="copyToken"
/>
<UButton
icon="i-lucide-refresh-cw"
:label="t('api.regenerate')"
color="primary"
variant="soft"
size="sm"
:loading="regenerating"
:disabled="!canRegenerate"
@click="emit('regenerate')"
/>
</div>
</div>
<p
v-if="tokenSource"
class="text-xs text-muted"
>
{{ tokenSourceLabel(tokenSource) }}
· {{ t('api.tokenHeader') }}
<code class="font-mono">Auth-Token: &lt;token&gt;</code>
<template v-if="token && !envTokenOverride">
· {{ t('api.tokenInvalidate') }}
</template>
</p>
</div>
</section>
</template>
+210
View File
@@ -0,0 +1,210 @@
<script setup lang="ts">
const props = withDefaults(defineProps<{
searchQuery: string
storageBackend: string
storageBackendItems: Array<{ label: string, value: string }>
uploadSource: string
uploadSourceItems: Array<{ label: string, value: string }>
tagIds?: number[]
tagMode?: 'or' | 'and'
untaggedOnly?: boolean
tags?: Array<{ id: number, name: string, color: string, imageCount?: number }>
viewMode: 'grid' | 'list'
selectedCount?: number
loading?: boolean
showBatchDelete?: boolean
showBatchTags?: boolean
showBatchSync?: boolean
}>(), {
selectedCount: 0,
loading: false,
showBatchDelete: true,
showBatchTags: false,
showBatchSync: false,
tagIds: () => [],
tags: () => [],
tagMode: 'or',
untaggedOnly: false
})
const emit = defineEmits<{
'update:searchQuery': [value: string]
'update:storageBackend': [value: string]
'update:uploadSource': [value: string]
'update:tagIds': [value: number[]]
'update:tagMode': [value: 'or' | 'and']
'update:untaggedOnly': [value: boolean]
'update:viewMode': [mode: 'grid' | 'list']
'search': []
'reset': []
'batch-delete': []
'batch-tags': []
'batch-sync': []
'manage-tags': []
}>()
const { t } = useI18n()
function setViewMode(mode: 'grid' | 'list') {
if (mode === props.viewMode || props.loading) return
emit('update:viewMode', mode)
}
function viewButtonClass(active: boolean) {
return active
? 'border-primary bg-primary/10 text-primary'
: 'border-transparent bg-elevated text-muted hover:bg-muted hover:text-default'
}
</script>
<template>
<form
class="flex flex-nowrap items-center gap-2 overflow-x-auto pb-1"
@submit.prevent="emit('search')"
>
<UInput
:model-value="searchQuery"
icon="i-lucide-search"
:placeholder="t('stats.searchPlaceholder')"
class="w-40 shrink-0 sm:w-48"
size="sm"
:disabled="loading"
@update:model-value="emit('update:searchQuery', $event)"
/>
<USelect
:model-value="storageBackend"
:items="storageBackendItems"
class="w-28 shrink-0"
size="sm"
:aria-label="t('stats.storage')"
:disabled="loading"
@update:model-value="emit('update:storageBackend', $event)"
/>
<USelect
:model-value="uploadSource"
:items="uploadSourceItems"
class="w-28 shrink-0"
size="sm"
:aria-label="t('stats.filterSource')"
:disabled="loading"
@update:model-value="emit('update:uploadSource', $event)"
/>
<TagFilterSelect
:model-value="tagIds"
:tag-mode="tagMode"
:untagged-only="untaggedOnly"
:tags="tags"
:disabled="loading"
@update:model-value="emit('update:tagIds', $event)"
@update:tag-mode="emit('update:tagMode', $event)"
@update:untagged-only="emit('update:untaggedOnly', $event)"
@manage="emit('manage-tags')"
/>
<UButton
type="submit"
icon="i-lucide-search"
size="sm"
color="primary"
class="shrink-0"
:aria-label="t('common.search')"
:loading="loading"
>
{{ t('common.search') }}
</UButton>
<UButton
type="button"
icon="i-lucide-rotate-ccw"
size="sm"
variant="outline"
color="neutral"
class="shrink-0"
:aria-label="t('stats.resetFilters')"
:disabled="loading"
@click="emit('reset')"
>
{{ t('stats.resetFilters') }}
</UButton>
<UButton
v-if="showBatchTags"
type="button"
icon="i-lucide-tags"
size="sm"
variant="outline"
color="neutral"
class="shrink-0"
:disabled="!selectedCount || loading"
@click="emit('batch-tags')"
>
{{ t('tags.batchAdd') }}
</UButton>
<UButton
v-if="showBatchDelete"
type="button"
icon="i-lucide-trash-2"
size="sm"
variant="outline"
color="error"
class="shrink-0"
:disabled="!selectedCount || loading"
@click="emit('batch-delete')"
>
{{ t('stats.batchDelete') }}
</UButton>
<UButton
v-if="showBatchSync"
type="button"
icon="i-lucide-cloud-upload"
size="sm"
variant="outline"
color="neutral"
class="shrink-0"
:disabled="!selectedCount || loading"
@click="emit('batch-sync')"
>
{{ t('storage.backup.syncSelectedAction') }}
</UButton>
<div
class="inline-flex shrink-0 overflow-hidden rounded-md border border-default"
role="group"
:aria-label="t('stats.viewMode')"
>
<button
type="button"
class="inline-flex size-8 items-center justify-center border-r border-default transition-colors disabled:pointer-events-none disabled:opacity-40"
:class="viewButtonClass(viewMode === 'grid')"
:aria-label="t('stats.viewGrid')"
:aria-pressed="viewMode === 'grid'"
:disabled="loading"
@click="setViewMode('grid')"
>
<UIcon
name="i-lucide-layout-grid"
class="size-4"
/>
</button>
<button
type="button"
class="inline-flex size-8 items-center justify-center transition-colors disabled:pointer-events-none disabled:opacity-40"
:class="viewButtonClass(viewMode === 'list')"
:aria-label="t('stats.viewList')"
:aria-pressed="viewMode === 'list'"
:disabled="loading"
@click="setViewMode('list')"
>
<UIcon
name="i-lucide-list"
class="size-4"
/>
</button>
</div>
</form>
</template>
+336
View File
@@ -0,0 +1,336 @@
<script setup lang="ts">
import type { ImageItem, ImageTag } from '~/types/image'
const props = withDefaults(defineProps<{
items: ImageItem[]
selectedKeys: Set<string>
selectable?: boolean
showStorage?: boolean
showTags?: boolean
emptyText?: string
showDelete?: boolean
}>(), {
selectable: false,
showStorage: false,
showTags: false,
showDelete: true
})
const emit = defineEmits<{
'update:selectedKeys': [value: Set<string>]
'preview': [image: ImageItem]
'delete': [image: ImageItem]
'tag-click': [tagId: number]
'untagged-click': []
}>()
const { t } = useI18n()
const { formatFileSize } = useFileSize()
const INLINE_TAG_LIMIT = 3
const displayEmptyText = computed(() => props.emptyText ?? t('image.empty'))
const allSelected = computed(() =>
props.items.length > 0 && props.items.every(item => props.selectedKeys.has(item.key))
)
const someSelected = computed(() =>
props.items.some(item => props.selectedKeys.has(item.key))
)
const headerIndeterminate = computed(() => someSelected.value && !allSelected.value)
function updateSelection(key: string, selected: boolean) {
const next = new Set(props.selectedKeys)
if (selected) next.add(key)
else next.delete(key)
emit('update:selectedKeys', next)
}
function toggleAll(value: boolean | 'indeterminate') {
if (value === true) {
emit('update:selectedKeys', new Set(props.items.map(item => item.key)))
return
}
emit('update:selectedKeys', new Set())
}
function formatUploadedAt(value: string) {
try {
const date = new Date(value)
const y = date.getFullYear()
const m = String(date.getMonth() + 1).padStart(2, '0')
const d = String(date.getDate()).padStart(2, '0')
const h = String(date.getHours()).padStart(2, '0')
const min = String(date.getMinutes()).padStart(2, '0')
const s = String(date.getSeconds()).padStart(2, '0')
return `${y}/${m}/${d} ${h}:${min}:${s}`
} catch {
return value
}
}
function sourceLabel(image: ImageItem) {
switch (image.uploadSource) {
case 'web':
return t('stats.tagWeb')
case 'api':
return t('stats.tagApi')
default:
return null
}
}
function sourceIcon(image: ImageItem) {
switch (image.uploadSource) {
case 'web':
return 'i-lucide-globe'
case 'api':
return 'i-lucide-code-xml'
default:
return null
}
}
function storageIcon(image: ImageItem) {
return image.storage?.type === 'local' ? 'i-lucide-hard-drive' : 'i-lucide-cloud'
}
function tagDisplay(tags: ImageTag[] | undefined) {
const list = tags ?? []
if (list.length <= INLINE_TAG_LIMIT) {
return { visible: list, hidden: [] as ImageTag[] }
}
return {
visible: list.slice(0, INLINE_TAG_LIMIT),
hidden: list.slice(INLINE_TAG_LIMIT)
}
}
const metaBadgeClass = 'inline-flex max-w-full items-center gap-1 rounded-md border border-default px-1.5 py-0.5 text-[11px] leading-4 text-muted'
</script>
<template>
<div
v-if="items.length"
class="overflow-hidden rounded-xl border border-default bg-default"
>
<div class="overflow-x-auto">
<table class="min-w-full text-xs">
<thead class="border-b border-default bg-elevated/40 text-muted">
<tr>
<th
v-if="selectable"
class="w-10 px-3 py-2.5 text-left font-medium"
>
<UCheckbox
:model-value="allSelected ? true : headerIndeterminate ? 'indeterminate' : false"
:aria-label="t('stats.selectAll')"
@update:model-value="toggleAll"
/>
</th>
<th class="min-w-[14rem] px-3 py-2.5 text-left font-medium">
{{ t('stats.colFilename') }}
</th>
<th class="hidden w-24 px-3 py-2.5 text-left font-medium sm:table-cell">
{{ t('stats.colSource') }}
</th>
<th class="w-20 px-3 py-2.5 text-left font-medium">
{{ t('stats.colSize') }}
</th>
<th
v-if="showStorage"
class="hidden w-28 px-3 py-2.5 text-left font-medium md:table-cell"
>
{{ t('stats.colType') }}
</th>
<th class="hidden w-40 px-3 py-2.5 text-left font-medium lg:table-cell">
{{ t('stats.colUploadedAt') }}
</th>
<th
v-if="showTags"
class="min-w-[9rem] px-3 py-2.5 text-left font-medium md:table-cell"
>
{{ t('tags.column') }}
</th>
<th class="w-24 px-3 py-2.5 text-right font-medium">
{{ t('stats.colActions') }}
</th>
</tr>
</thead>
<tbody class="divide-y divide-default">
<tr
v-for="image in items"
:key="image.key"
class="transition-colors hover:bg-elevated/50"
:class="selectedKeys.has(image.key) ? 'bg-primary/5' : ''"
>
<td
v-if="selectable"
class="px-3 py-2"
>
<UCheckbox
:model-value="selectedKeys.has(image.key)"
@update:model-value="updateSelection(image.key, $event === true)"
/>
</td>
<td class="px-3 py-2.5">
<div class="flex min-w-0 items-center gap-3">
<button
type="button"
class="relative size-12 shrink-0 overflow-hidden rounded-md border border-default bg-muted"
:aria-label="t('image.openPreview')"
@click="emit('preview', image)"
>
<img
:src="image.url"
:alt="image.originalName"
loading="lazy"
class="size-full object-cover"
>
</button>
<p
class="min-w-0 flex-1 truncate text-highlighted"
:title="image.originalName"
>
{{ image.originalName }}
</p>
</div>
</td>
<td class="hidden px-3 py-2 sm:table-cell">
<span
v-if="sourceLabel(image) && sourceIcon(image)"
:class="metaBadgeClass"
>
<UIcon
:name="sourceIcon(image)!"
class="size-3 shrink-0"
/>
<span class="truncate">{{ sourceLabel(image) }}</span>
</span>
<span
v-else
class="text-muted"
>—</span>
</td>
<td class="px-3 py-2 tabular-nums text-muted">
{{ formatFileSize(image.size) }}
</td>
<td
v-if="showStorage"
class="hidden px-3 py-2 md:table-cell"
>
<span
v-if="image.storage"
:class="metaBadgeClass"
:title="image.storage.name"
>
<UIcon
:name="storageIcon(image)"
class="size-3 shrink-0"
/>
<span class="truncate">{{ image.storage.name }}</span>
</span>
<span
v-else
class="text-muted"
>—</span>
</td>
<td class="hidden px-3 py-2 tabular-nums text-muted lg:table-cell">
{{ formatUploadedAt(image.uploadedAt) }}
</td>
<td
v-if="showTags"
class="px-3 py-2 md:table-cell"
>
<div
v-if="image.tags?.length"
class="flex flex-wrap items-center gap-1"
>
<TagBadge
v-for="tag in tagDisplay(image.tags).visible"
:key="tag.id"
:tag="tag"
clickable
@click="emit('tag-click', tag.id)"
/>
<UPopover
v-if="tagDisplay(image.tags).hidden.length"
:content="{ side: 'bottom', align: 'start' }"
>
<button
type="button"
class="inline-flex rounded-md border border-default px-1.5 py-0.5 text-[11px] text-muted transition-colors hover:border-primary/40 hover:text-primary"
:aria-label="t('tags.moreTags', { n: tagDisplay(image.tags).hidden.length })"
@click.stop
>
+{{ tagDisplay(image.tags).hidden.length }}
</button>
<template #content>
<div
class="flex max-w-56 flex-wrap gap-1 p-2"
@click.stop
>
<TagBadge
v-for="tag in tagDisplay(image.tags).hidden"
:key="tag.id"
:tag="tag"
clickable
@click="emit('tag-click', tag.id)"
/>
</div>
</template>
</UPopover>
</div>
<button
v-else
type="button"
class="rounded-md border border-dashed border-default px-1.5 py-0.5 text-[11px] text-muted transition-colors hover:border-primary/40 hover:text-primary"
@click.stop="emit('untagged-click')"
>
{{ t('tags.unlabeled') }}
</button>
</td>
<td class="px-3 py-2">
<div class="flex items-center justify-end gap-0.5 text-muted">
<CopyButton
icon="i-lucide-link"
icon-only
variant="ghost"
color="neutral"
:label="t('common.copy')"
:value="image.url"
:success-title="t('copy.copiedUrl')"
/>
<UButton
icon="i-lucide-eye"
variant="ghost"
color="neutral"
size="xs"
:aria-label="t('image.openPreview')"
@click="emit('preview', image)"
/>
<UButton
v-if="showDelete"
icon="i-lucide-trash-2"
variant="ghost"
color="neutral"
size="xs"
:aria-label="t('common.delete')"
@click="emit('delete', image)"
/>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<div
v-else
class="rounded-xl border border-dashed border-default py-16 text-center text-xs text-muted"
>
{{ displayEmptyText }}
</div>
</template>
@@ -0,0 +1,55 @@
<script setup lang="ts">
import type { ImageItem } from '~/types/image'
const props = defineProps<{
items: ImageItem[]
selectedKeys: Set<string>
}>()
const emit = defineEmits<{
'update:selectedKeys': [value: Set<string>]
'clear': []
}>()
const { t } = useI18n()
const allSelected = computed(() =>
props.items.length > 0 && props.items.every(item => props.selectedKeys.has(item.key))
)
const someSelected = computed(() =>
props.items.some(item => props.selectedKeys.has(item.key))
)
const indeterminate = computed(() => someSelected.value && !allSelected.value)
function toggleAll(value: boolean | 'indeterminate') {
if (value === true) {
emit('update:selectedKeys', new Set(props.items.map(item => item.key)))
return
}
emit('update:selectedKeys', new Set())
}
</script>
<template>
<div class="flex min-w-0 items-center gap-3">
<UCheckbox
:model-value="allSelected ? true : indeterminate ? 'indeterminate' : false"
:disabled="!items.length"
:aria-label="t('stats.selectAll')"
@update:model-value="toggleAll"
/>
<p class="text-sm text-muted">
{{ t('stats.selectedItems', { n: selectedKeys.size }) }}
</p>
<button
type="button"
class="text-sm text-primary hover:underline disabled:pointer-events-none disabled:opacity-40"
:disabled="selectedKeys.size === 0"
@click="emit('clear')"
>
{{ t('stats.clear') }}
</button>
</div>
</template>
@@ -0,0 +1,182 @@
<script setup lang="ts">
const props = defineProps<{
currentOrigin: string
effectiveSiteUrl: string
}>()
const siteBaseUrl = defineModel<string>('siteBaseUrl', { required: true })
const imageBaseUrl = defineModel<string>('imageBaseUrl', { required: true })
const videoBaseUrl = defineModel<string>('videoBaseUrl', { required: true })
const fileBaseUrl = defineModel<string>('fileBaseUrl', { required: true })
const imageSeparated = defineModel<boolean>('imageSeparated', { required: true })
const videoSeparated = defineModel<boolean>('videoSeparated', { required: true })
const fileSeparated = defineModel<boolean>('fileSeparated', { required: true })
const { t } = useI18n()
function fillDetected(target: 'site' | 'image' | 'video' | 'file') {
if (!props.currentOrigin) return
if (target === 'site') siteBaseUrl.value = props.currentOrigin
else if (target === 'image') imageBaseUrl.value = props.currentOrigin
else if (target === 'video') videoBaseUrl.value = props.currentOrigin
else fileBaseUrl.value = props.currentOrigin
}
function effectiveUrl(separated: boolean, configuredUrl: string): string {
return separated
? configuredUrl.trim() || props.currentOrigin
: siteBaseUrl.value.trim() || props.effectiveSiteUrl || props.currentOrigin
}
</script>
<template>
<div class="grid gap-4 sm:grid-cols-2">
<SettingsGroup>
<div class="space-y-3 py-4 sm:pr-4">
<p class="text-sm font-medium text-highlighted">
{{ t('settings.siteBaseUrl') }}
</p>
<p class="text-xs leading-relaxed text-muted">
{{ t('settings.siteBaseUrlRoleHint') }}
</p>
<div class="flex gap-2">
<UInput
v-model="siteBaseUrl"
size="sm"
:placeholder="t('settings.siteBaseUrlPlaceholder')"
class="min-w-0 flex-1 font-mono"
/>
<UButton
v-if="currentOrigin"
:label="t('settings.fillDetectedOrigin')"
color="neutral"
variant="outline"
size="sm"
class="shrink-0"
@click="fillDetected('site')"
/>
</div>
<p class="truncate rounded-lg bg-muted/30 px-3 py-2 text-xs text-muted">
{{ t('settings.siteBaseUrlActive', { url: effectiveSiteUrl || currentOrigin }) }}
</p>
</div>
</SettingsGroup>
<SettingsGroup>
<div class="space-y-3 py-4 sm:pl-4">
<div class="flex flex-wrap items-center justify-between gap-2">
<p class="text-sm font-medium text-highlighted">
{{ t('settings.imageBaseUrl') }}
</p>
<USwitch v-model="imageSeparated" />
</div>
<p class="text-xs leading-relaxed text-muted">
{{ t('settings.imageBaseUrlRoleHint') }}
</p>
<div
class="flex gap-2"
:class="imageSeparated ? '' : 'pointer-events-none opacity-50'"
>
<UInput
v-model="imageBaseUrl"
size="sm"
:placeholder="t('settings.imageBaseUrlPlaceholder')"
class="min-w-0 flex-1 font-mono"
/>
<UButton
v-if="currentOrigin"
:label="t('settings.fillDetectedOrigin')"
color="neutral"
variant="outline"
size="sm"
class="shrink-0"
@click="fillDetected('image')"
/>
</div>
<p class="truncate rounded-lg bg-muted/30 px-3 py-2 text-xs text-muted">
{{ t('settings.imageBaseUrlActive', {
url: effectiveUrl(imageSeparated, imageBaseUrl)
}) }}
</p>
</div>
</SettingsGroup>
<SettingsGroup>
<div class="space-y-3 py-4 sm:pr-4">
<div class="flex flex-wrap items-center justify-between gap-2">
<p class="text-sm font-medium text-highlighted">
{{ t('settings.videoBaseUrl') }}
</p>
<USwitch v-model="videoSeparated" />
</div>
<p class="text-xs leading-relaxed text-muted">
{{ t('settings.videoBaseUrlRoleHint') }}
</p>
<div
class="flex gap-2"
:class="videoSeparated ? '' : 'pointer-events-none opacity-50'"
>
<UInput
v-model="videoBaseUrl"
size="sm"
:placeholder="t('settings.videoBaseUrlPlaceholder')"
class="min-w-0 flex-1 font-mono"
/>
<UButton
v-if="currentOrigin"
:label="t('settings.fillDetectedOrigin')"
color="neutral"
variant="outline"
size="sm"
class="shrink-0"
@click="fillDetected('video')"
/>
</div>
<p class="truncate rounded-lg bg-muted/30 px-3 py-2 text-xs text-muted">
{{ t('settings.videoBaseUrlActive', {
url: effectiveUrl(videoSeparated, videoBaseUrl)
}) }}
</p>
</div>
</SettingsGroup>
<SettingsGroup>
<div class="space-y-3 py-4 sm:pl-4">
<div class="flex flex-wrap items-center justify-between gap-2">
<p class="text-sm font-medium text-highlighted">
{{ t('settings.fileBaseUrl') }}
</p>
<USwitch v-model="fileSeparated" />
</div>
<p class="text-xs leading-relaxed text-muted">
{{ t('settings.fileBaseUrlRoleHint') }}
</p>
<div
class="flex gap-2"
:class="fileSeparated ? '' : 'pointer-events-none opacity-50'"
>
<UInput
v-model="fileBaseUrl"
size="sm"
:placeholder="t('settings.fileBaseUrlPlaceholder')"
class="min-w-0 flex-1 font-mono"
/>
<UButton
v-if="currentOrigin"
:label="t('settings.fillDetectedOrigin')"
color="neutral"
variant="outline"
size="sm"
class="shrink-0"
@click="fillDetected('file')"
/>
</div>
<p class="truncate rounded-lg bg-muted/30 px-3 py-2 text-xs text-muted">
{{ t('settings.fileBaseUrlActive', {
url: effectiveUrl(fileSeparated, fileBaseUrl)
}) }}
</p>
</div>
</SettingsGroup>
</div>
</template>
@@ -0,0 +1,5 @@
<template>
<div class="divide-y divide-default">
<slot />
</div>
</template>
@@ -0,0 +1,557 @@
<script setup lang="ts">
import type { ActivityLogListResponse, LogAction, LogSource, LogStatus } from '~/types/logs'
const { isAuthenticated, handleAuthError, isAdmin } = useAuth()
const toast = useToast()
const { t, locale } = useI18n()
const { formatFileSize } = useFileSize()
const loading = ref(true)
const page = ref(1)
const pageSize = ref(10)
const actionFilter = ref<'all' | LogAction>('all')
const sourceFilter = ref<'all' | LogSource>('all')
const userFilter = ref<'all' | number>('all')
const dateFrom = ref('')
const dateTo = ref('')
const searchQuery = ref('')
const activeSearch = ref('')
const listData = ref<ActivityLogListResponse | null>(null)
const actionItems = computed(() => [
{ label: t('logs.filterActionAll'), value: 'all' as const },
{ label: t('logs.actionUpload'), value: 'upload' as const },
{ label: t('logs.actionDelete'), value: 'delete' as const },
{ label: t('logs.actionLogin'), value: 'login' as const },
{ label: t('logs.actionEdit'), value: 'edit' as const },
{ label: t('logs.actionSettings'), value: 'settings' as const },
{ label: t('logs.actionTags'), value: 'tags' as const }
])
const sourceItems = computed(() => [
{ label: t('logs.filterSourceAll'), value: 'all' as const },
{ label: t('logs.sourceWeb'), value: 'web' as const },
{ label: t('logs.sourceAdmin'), value: 'admin' as const },
{ label: t('logs.sourceApi'), value: 'api' as const }
])
const userItems = computed(() => {
const items: Array<{ label: string, value: 'all' | number }> = [
{ label: t('logs.filterUserAll'), value: 'all' }
]
for (const user of listData.value?.users ?? []) {
items.push({ label: user.username, value: user.id })
}
return items
})
function formatTime(value: string) {
try {
return new Date(value).toLocaleString(locale.value, { hour12: false })
} catch {
return value
}
}
function formatTimeShort(value: string) {
try {
return new Date(value).toLocaleString(locale.value, {
month: 'numeric',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
hour12: false
})
} catch {
return value
}
}
function actionLabel(action: LogAction) {
const labels: Record<LogAction, string> = {
upload: t('logs.actionUpload'),
delete: t('logs.actionDelete'),
login: t('logs.actionLogin'),
edit: t('logs.actionEdit'),
settings: t('logs.actionSettings'),
tags: t('logs.actionTags')
}
return labels[action]
}
function actionBadgeClass(action: LogAction) {
const base = 'inline-flex items-center gap-1 rounded-md px-2 py-0.5 text-xs font-medium whitespace-nowrap'
switch (action) {
case 'upload':
return `${base} bg-success/10 text-success`
case 'delete':
return `${base} bg-error/10 text-error`
case 'login':
return `${base} bg-sky-500/10 text-sky-600 dark:text-sky-400`
case 'edit':
return `${base} bg-blue-500/10 text-blue-600 dark:text-blue-400`
case 'settings':
return `${base} bg-slate-500/10 text-slate-600 dark:text-slate-400`
case 'tags':
return `${base} bg-violet-500/10 text-violet-600 dark:text-violet-400`
}
}
function actionIcon(action: LogAction) {
switch (action) {
case 'upload':
return 'i-lucide-upload'
case 'delete':
return 'i-lucide-trash-2'
case 'login':
return 'i-lucide-log-in'
case 'edit':
return 'i-lucide-pencil'
case 'settings':
return 'i-lucide-settings'
case 'tags':
return 'i-lucide-tag'
}
}
function sourceIcon(source: LogSource) {
switch (source) {
case 'web':
return 'i-lucide-monitor'
case 'admin':
return 'i-lucide-layout-dashboard'
case 'api':
return 'i-lucide-code-2'
}
}
function sourceLabel(source: LogSource) {
switch (source) {
case 'web':
return t('logs.sourceWeb')
case 'admin':
return t('logs.sourceAdmin')
case 'api':
return t('logs.sourceApi')
}
}
function sourceBadgeClass(source: LogSource) {
const base = 'inline-flex items-center gap-1 rounded-md px-2 py-0.5 text-xs font-medium whitespace-nowrap'
switch (source) {
case 'web':
return `${base} bg-primary/10 text-primary`
case 'admin':
return `${base} bg-orange-500/10 text-orange-600 dark:text-orange-400`
case 'api':
return `${base} bg-violet-500/10 text-violet-600 dark:text-violet-400`
}
}
function statusLabel(status: LogStatus) {
return status === 'failure' ? t('logs.resultFailure') : t('logs.resultSuccess')
}
function statusDotClass(status: LogStatus) {
return status === 'failure' ? 'bg-error' : 'bg-success'
}
function formatIp(value: string | null) {
if (!value || value === '-') return '—'
return value
}
function formatSize(action: LogAction, size: number) {
if (action !== 'upload' && action !== 'delete') return '—'
if (action === 'delete' && size <= 0) return '—'
return formatFileSize(size)
}
async function loadLogs() {
loading.value = true
try {
const query: Record<string, string | number> = {
page: page.value,
limit: pageSize.value
}
if (actionFilter.value !== 'all') {
query.action = actionFilter.value
}
if (sourceFilter.value !== 'all') {
query.source = sourceFilter.value
}
if (isAdmin.value && userFilter.value !== 'all') {
query.userId = userFilter.value
}
if (activeSearch.value) {
query.q = activeSearch.value
}
if (dateFrom.value) {
query.from = dateFrom.value
}
if (dateTo.value) {
query.to = dateTo.value
}
listData.value = await $fetch<ActivityLogListResponse>('/api/logs', {
credentials: 'include',
query
})
page.value = listData.value.page
} catch (error: unknown) {
handleAuthError(error)
if (isAuthenticated.value) {
toast.add({ title: t('logs.loadFailed'), color: 'error' })
}
} finally {
loading.value = false
}
}
function applyFilters() {
page.value = 1
void loadLogs()
}
function submitSearch() {
activeSearch.value = searchQuery.value.trim()
applyFilters()
}
function clearSearch() {
searchQuery.value = ''
activeSearch.value = ''
applyFilters()
}
function handlePageChange(target: number) {
page.value = target
void loadLogs()
}
function handlePageSizeChange(size: number) {
pageSize.value = size
page.value = 1
void loadLogs()
}
onMounted(() => {
void loadLogs()
})
</script>
<template>
<div class="min-w-0">
<div class="mb-6 flex items-start justify-between gap-4">
<div class="min-w-0">
<div class="flex items-center gap-2">
<span
class="h-4 w-1 shrink-0 rounded-full bg-primary"
aria-hidden="true"
/>
<h2 class="text-sm font-semibold text-highlighted">
{{ t('logs.pageTitle') }}
</h2>
</div>
<p class="mt-1.5 pl-3 text-xs leading-relaxed text-muted">
{{ isAdmin ? t('settings.pageSubtitleLogs') : t('logs.pageSubtitle') }}
</p>
</div>
<UButton
icon="i-lucide-refresh-cw"
variant="outline"
color="neutral"
size="sm"
:label="t('common.refresh')"
:loading="loading"
class="shrink-0"
@click="loadLogs"
/>
</div>
<form
class="mb-4 flex flex-col gap-3"
@submit.prevent="submitSearch"
>
<div class="flex flex-col gap-3 xl:flex-row xl:items-center xl:justify-between">
<div class="flex min-w-0 flex-wrap items-center gap-2">
<UInput
v-model="dateFrom"
type="date"
size="sm"
class="min-w-0 w-full sm:w-36"
:aria-label="t('logs.dateFrom')"
:disabled="loading"
@change="applyFilters"
/>
<span class="hidden text-xs text-muted sm:inline">{{ t('logs.dateRangeSeparator') }}</span>
<UInput
v-model="dateTo"
type="date"
size="sm"
class="min-w-0 w-full sm:w-36"
:aria-label="t('logs.dateTo')"
:disabled="loading"
@change="applyFilters"
/>
</div>
<div class="flex min-w-0 flex-wrap items-center gap-2 xl:justify-end">
<USelect
v-if="isAdmin"
v-model="userFilter"
:items="userItems"
value-key="value"
class="min-w-0 w-full sm:w-32"
size="sm"
@update:model-value="applyFilters"
/>
<USelect
v-model="actionFilter"
:items="actionItems"
value-key="value"
class="min-w-0 w-[calc(50%-0.25rem)] sm:w-28"
size="sm"
@update:model-value="applyFilters"
/>
<USelect
v-model="sourceFilter"
:items="sourceItems"
value-key="value"
class="min-w-0 w-[calc(50%-0.25rem)] sm:w-28"
size="sm"
@update:model-value="applyFilters"
/>
<div class="flex min-w-0 w-full items-center gap-2 sm:w-auto">
<UInput
v-model="searchQuery"
icon="i-lucide-search"
:placeholder="t('logs.searchPlaceholder')"
class="min-w-0 flex-1 sm:w-44 sm:flex-none"
size="sm"
:disabled="loading"
/>
<UButton
type="submit"
size="sm"
class="shrink-0"
:loading="loading"
>
{{ t('common.search') }}
</UButton>
<UButton
v-if="activeSearch"
type="button"
icon="i-lucide-x"
size="sm"
variant="ghost"
color="neutral"
class="shrink-0"
:aria-label="t('stats.clear')"
@click="clearSearch"
/>
</div>
</div>
</div>
</form>
<div class="overflow-hidden rounded-xl border border-default bg-default">
<div
v-if="loading"
class="p-4 sm:p-6"
>
<div class="space-y-3 sm:hidden">
<USkeleton
v-for="n in 6"
:key="`mobile-${n}`"
class="h-24 rounded-lg"
/>
</div>
<div class="hidden space-y-2 sm:block">
<USkeleton
v-for="n in 8"
:key="`desktop-${n}`"
class="h-12 rounded-lg"
/>
</div>
</div>
<template v-else-if="listData && listData.items.length">
<div class="min-w-0 sm:hidden">
<div class="divide-y divide-default">
<article
v-for="row in listData.items"
:key="row.id"
class="min-w-0 px-4 py-3.5"
>
<div class="flex items-start justify-between gap-3">
<time class="text-xs text-muted">
{{ formatTimeShort(row.createdAt) }}
</time>
<span class="shrink-0 text-xs tabular-nums text-muted">
{{ formatSize(row.action, row.size) }}
</span>
</div>
<div class="mt-2 flex min-w-0 max-w-full flex-wrap items-center gap-1.5">
<span :class="actionBadgeClass(row.action)">
<UIcon
:name="actionIcon(row.action)"
class="size-3 shrink-0"
/>
{{ actionLabel(row.action) }}
</span>
<span :class="sourceBadgeClass(row.source)">
<UIcon
:name="sourceIcon(row.source)"
class="size-3 shrink-0"
/>
{{ sourceLabel(row.source) }}
</span>
<span
v-if="isAdmin"
class="text-xs text-muted"
>
{{ row.username ?? '—' }}
</span>
</div>
<p
class="mt-2 truncate text-sm"
:title="row.originalName"
>
{{ row.originalName }}
</p>
<div class="mt-2 flex items-center justify-between gap-2 text-xs text-muted">
<span>{{ formatIp(row.ipAddress) }}</span>
<span class="inline-flex items-center gap-1.5">
<span
class="size-1.5 rounded-full"
:class="statusDotClass(row.status)"
/>
{{ statusLabel(row.status) }}
</span>
</div>
</article>
</div>
</div>
<div class="hidden min-w-0 overflow-x-auto sm:block">
<table class="w-full min-w-[56rem] text-left text-sm">
<thead class="border-b border-default bg-muted/30 text-xs text-muted">
<tr>
<th class="whitespace-nowrap px-4 py-3 font-medium sm:px-5">
{{ t('logs.colTime') }}
</th>
<th
v-if="isAdmin"
class="whitespace-nowrap px-4 py-3 font-medium"
>
{{ t('logs.colUser') }}
</th>
<th class="whitespace-nowrap px-4 py-3 font-medium">
{{ t('logs.colAction') }}
</th>
<th class="whitespace-nowrap px-4 py-3 font-medium">
{{ t('logs.colSource') }}
</th>
<th class="px-4 py-3 font-medium">
{{ t('logs.colFile') }}
</th>
<th class="whitespace-nowrap px-4 py-3 font-medium">
{{ t('logs.colSize') }}
</th>
<th class="whitespace-nowrap px-4 py-3 font-medium">
{{ t('logs.colIp') }}
</th>
<th class="whitespace-nowrap px-4 py-3 font-medium sm:px-5">
{{ t('logs.colResult') }}
</th>
</tr>
</thead>
<tbody class="divide-y divide-default">
<tr
v-for="row in listData.items"
:key="row.id"
class="hover:bg-muted/20"
>
<td class="whitespace-nowrap px-4 py-3 text-xs text-muted sm:px-5">
{{ formatTime(row.createdAt) }}
</td>
<td
v-if="isAdmin"
class="whitespace-nowrap px-4 py-3 text-xs"
>
{{ row.username ?? '—' }}
</td>
<td class="whitespace-nowrap px-4 py-3">
<span :class="actionBadgeClass(row.action)">
<UIcon
:name="actionIcon(row.action)"
class="size-3 shrink-0"
/>
{{ actionLabel(row.action) }}
</span>
</td>
<td class="whitespace-nowrap px-4 py-3">
<span :class="sourceBadgeClass(row.source)">
<UIcon
:name="sourceIcon(row.source)"
class="size-3 shrink-0"
/>
{{ sourceLabel(row.source) }}
</span>
</td>
<td
class="max-w-[12rem] truncate px-4 py-3"
:title="row.originalName"
>
{{ row.originalName }}
</td>
<td class="whitespace-nowrap px-4 py-3 tabular-nums text-muted">
{{ formatSize(row.action, row.size) }}
</td>
<td class="whitespace-nowrap px-4 py-3 font-mono text-xs text-muted">
{{ formatIp(row.ipAddress) }}
</td>
<td class="whitespace-nowrap px-4 py-3 sm:px-5">
<span class="inline-flex items-center gap-1.5 text-xs">
<span
class="size-1.5 rounded-full"
:class="statusDotClass(row.status)"
/>
{{ statusLabel(row.status) }}
</span>
</td>
</tr>
</tbody>
</table>
</div>
</template>
<div
v-else
class="px-4 py-16 text-center text-sm text-muted sm:px-6"
>
{{ t('logs.empty') }}
</div>
<div
v-if="listData && listData.total > 0"
class="border-t border-default px-4 py-4 sm:px-5"
>
<PaginationBar
:page="listData.page"
:total-pages="listData.totalPages"
:total="listData.total"
:unit="t('logs.entriesUnit')"
:page-size="pageSize"
:loading="loading"
@update:page="handlePageChange"
@update:page-size="handlePageSizeChange"
/>
</div>
</div>
</div>
</template>
@@ -0,0 +1,29 @@
<script setup lang="ts">
defineProps<{
icon: string
title: string
subtitle?: string
}>()
</script>
<template>
<header class="mb-0 flex items-start gap-3 border-b border-default pb-6">
<div class="flex size-10 shrink-0 items-center justify-center rounded-xl bg-primary/10">
<UIcon
:name="icon"
class="size-5 text-primary"
/>
</div>
<div class="min-w-0 pt-0.5">
<h1 class="text-lg font-semibold tracking-tight text-highlighted">
{{ title }}
</h1>
<p
v-if="subtitle"
class="mt-1 text-xs leading-relaxed text-muted"
>
{{ subtitle }}
</p>
</div>
</header>
</template>
@@ -0,0 +1,5 @@
<template>
<div class="mt-6 space-y-4">
<slot />
</div>
</template>
@@ -0,0 +1,28 @@
<script setup lang="ts">
defineProps<{
title: string
hint?: string
}>()
</script>
<template>
<section class="overflow-hidden rounded-xl border border-default bg-default">
<div class="px-4 py-3">
<div class="flex flex-wrap items-center gap-2">
<h2 class="text-sm font-semibold text-highlighted">
{{ title }}
</h2>
<slot name="badge" />
</div>
<p
v-if="hint"
class="mt-1 text-xs leading-relaxed text-muted"
>
{{ hint }}
</p>
</div>
<div class="border-t border-default px-4 py-4">
<slot />
</div>
</section>
</template>
@@ -0,0 +1,41 @@
<script setup lang="ts">
export type SettingsRadioOption = {
value: string
label: string
description?: string
}
defineProps<{
items: SettingsRadioOption[]
}>()
const model = defineModel<string>({ required: true })
const radioUi = {
fieldset: 'flex flex-col divide-y divide-default',
item: 'flex items-start gap-4 py-4',
wrapper: 'min-w-0 flex-1 pr-2',
base: 'mt-0.5 shrink-0',
label: 'block text-sm font-medium text-highlighted',
description: 'mt-1 block text-xs leading-relaxed text-muted'
}
</script>
<template>
<URadioGroup
v-model="model"
:items="items"
variant="list"
:ui="radioUi"
>
<template #label="{ item }">
<span class="text-sm font-medium text-highlighted">{{ item.label }}</span>
</template>
<template #description="{ item }">
<span
v-if="item.description"
class="mt-1 block text-xs leading-relaxed text-muted"
>{{ item.description }}</span>
</template>
</URadioGroup>
</template>
@@ -0,0 +1,39 @@
<script setup lang="ts">
defineProps<{
title: string
hint?: string
}>()
</script>
<template>
<section class="overflow-hidden rounded-xl border border-default bg-default">
<div class="flex items-start justify-between gap-4 px-4 py-3">
<div class="min-w-0">
<div class="flex items-center gap-2">
<span
class="h-4 w-1 shrink-0 rounded-full bg-primary"
aria-hidden="true"
/>
<h2 class="text-sm font-semibold text-highlighted">
{{ title }}
</h2>
</div>
<p
v-if="hint"
class="mt-1.5 pl-3 text-xs leading-relaxed text-muted"
>
{{ hint }}
</p>
</div>
<div
v-if="$slots.action"
class="shrink-0"
>
<slot name="action" />
</div>
</div>
<div class="border-t border-default px-4">
<slot />
</div>
</section>
</template>
@@ -0,0 +1,40 @@
<script setup lang="ts">
defineProps<{
hasChanges: boolean
saving: boolean
}>()
const emit = defineEmits<{
save: []
restore: []
}>()
const { t } = useI18n()
</script>
<template>
<div class="border-t border-default bg-default px-4 py-4 sm:px-6">
<div class="flex flex-wrap items-center justify-end gap-3">
<p
v-if="hasChanges"
class="mr-auto text-xs text-warning"
>
{{ t('settings.unsavedChanges') }}
</p>
<UButton
:label="t('settings.restoreDefaults')"
color="neutral"
variant="outline"
@click="emit('restore')"
/>
<UButton
:label="t('settings.saveSettings')"
icon="i-lucide-save"
color="primary"
:loading="saving"
:disabled="!hasChanges"
@click="emit('save')"
/>
</div>
</div>
</template>
@@ -0,0 +1,75 @@
<script setup lang="ts">
import type { SettingsTab } from '~/types/settings'
const props = defineProps<{
modelValue: SettingsTab
items: Array<{ id: SettingsTab, label: string, icon: string }>
}>()
const emit = defineEmits<{
'update:modelValue': [SettingsTab]
}>()
const { t } = useI18n()
const searchQuery = ref('')
const filteredItems = computed(() => {
const q = searchQuery.value.trim().toLowerCase()
if (!q) return props.items
return props.items.filter(item => item.label.toLowerCase().includes(q))
})
function selectTab(id: SettingsTab) {
emit('update:modelValue', id)
searchQuery.value = ''
}
</script>
<template>
<aside class="flex w-full shrink-0 flex-col border-b border-default bg-neutral-50 dark:bg-neutral-900/30 lg:w-56 lg:border-b-0 lg:border-r xl:w-60">
<div class="border-b border-default p-3">
<UInput
v-model="searchQuery"
size="sm"
:placeholder="t('settings.searchPlaceholder')"
icon="i-lucide-search"
class="w-full"
>
<template #trailing>
<UKbd
size="sm"
class="hidden sm:inline-flex"
>
⌘K
</UKbd>
</template>
</UInput>
</div>
<nav class="flex-1 space-y-0.5 p-3">
<p
v-if="searchQuery.trim() && !filteredItems.length"
class="px-3 py-6 text-center text-xs text-muted"
>
{{ t('settings.searchEmpty') }}
</p>
<button
v-for="item in filteredItems"
:key="item.id"
type="button"
class="flex w-full items-center gap-2.5 rounded-lg px-3 py-2.5 text-left text-sm transition-colors"
:class="modelValue === item.id
? 'bg-primary/10 font-medium text-primary'
: 'text-muted hover:bg-muted/50 hover:text-highlighted'"
@click="selectTab(item.id)"
>
<UIcon
:name="item.icon"
class="size-4 shrink-0"
:class="modelValue === item.id ? 'text-primary' : 'opacity-70'"
/>
<span>{{ item.label }}</span>
</button>
</nav>
</aside>
</template>
@@ -0,0 +1,71 @@
<script setup lang="ts">
defineProps<{
appVersion: string
updateAvailable: boolean
latestVersion: string | null
releaseUrl: string | null
checking: boolean
}>()
// 「检查更新」已停用(改动较大,与上游版本号已不对应),暂时保留事件定义
defineEmits<{
checkUpdate: []
}>()
const { t } = useI18n()
</script>
<template>
<div class="flex flex-wrap items-center justify-between gap-4 py-4">
<div class="min-w-0">
<p class="text-sm font-medium text-highlighted">
{{ t('settings.systemVersion') }}
</p>
<p class="mt-1 text-xs leading-relaxed text-muted">
{{ t('settings.systemVersionHint') }}
</p>
</div>
<div class="flex flex-wrap items-center gap-2">
<span class="text-sm font-medium tabular-nums text-highlighted">
v{{ appVersion }}
</span>
<!-- 检查更新功能已停用,需要时恢复下面这段
<UBadge
v-if="updateAvailable && latestVersion"
color="warning"
variant="subtle"
size="sm"
>
{{ t('settings.updateAvailable', { version: `v${latestVersion}` }) }}
</UBadge>
<UBadge
v-else
color="success"
variant="subtle"
size="sm"
>
{{ t('settings.upToDate') }}
</UBadge>
<UButton
:label="t('settings.checkUpdate')"
icon="i-lucide-refresh-cw"
variant="outline"
color="neutral"
size="sm"
class="shrink-0"
:loading="checking"
@click="emit('checkUpdate')"
/>
<a
v-if="updateAvailable && latestVersion"
:href="releaseUrl || 'https://github.com/O96u/PicHost/releases'"
target="_blank"
rel="noopener noreferrer"
class="text-xs text-primary hover:underline"
>
{{ t('settings.viewReleases') }}
</a>
-->
</div>
</div>
</template>
@@ -0,0 +1,31 @@
<script setup lang="ts">
const { items: tags, loading, fetchTags } = useTags()
onMounted(() => {
void fetchTags()
})
async function handleChanged() {
await fetchTags(true)
}
</script>
<template>
<SettingsPanel>
<div
v-if="loading && !tags.length"
class="flex justify-center py-16"
>
<UIcon
name="i-lucide-loader-circle"
class="size-6 animate-spin text-muted"
/>
</div>
<TagManagePanel
v-else
:tags="tags"
embedded
@changed="handleChanged"
/>
</SettingsPanel>
</template>
@@ -0,0 +1,34 @@
<script setup lang="ts">
defineProps<{
title?: string
hint?: string
disabled?: boolean
}>()
const model = defineModel<boolean>({ required: true })
</script>
<template>
<div class="flex items-start justify-between gap-4 py-4">
<div class="min-w-0 flex-1 pr-2">
<p class="text-sm font-medium text-highlighted">
<slot name="title">
{{ title }}
</slot>
</p>
<p
v-if="hint || $slots.hint"
class="mt-1 text-xs leading-relaxed text-muted"
>
<slot name="hint">
{{ hint }}
</slot>
</p>
</div>
<USwitch
v-model="model"
:disabled="disabled"
class="mt-0.5 shrink-0"
/>
</div>
</template>
@@ -0,0 +1,546 @@
<script setup lang="ts">
type PolicySource = 'env' | 'db' | 'default'
type MimeType = 'image/jpeg' | 'image/png' | 'image/webp' | 'image/gif' | 'image/svg+xml' | 'image/x-icon'
type VideoExtension = 'mp4' | 'm4v' | 'webm' | 'mov' | 'mkv' | 'avi' | 'flv' | 'wmv' | 'mpg' | 'mpeg'
const props = defineProps<{
part: 'upload-preference' | 'image-processing' | 'login-rate'
uploadImageMaxFileSizeMbSource?: PolicySource
uploadVideoMaxFileSizeMbSource?: PolicySource
uploadFileMaxFileSizeMbSource?: PolicySource
allowedMimeTypesSource?: PolicySource
allowedVideoExtensionsSource?: PolicySource
forbiddenFileExtensionsSource?: PolicySource
uploadRateIpMaxSource?: PolicySource
uploadRateTokenMaxSource?: PolicySource
uploadRateWindowMinutesSource?: PolicySource
loginRateMaxSource?: PolicySource
loginRateWindowMinutesSource?: PolicySource
preserveOriginalUploadSource?: PolicySource
mimeTypeOptions?: ReadonlyArray<{ mime: MimeType, label: string }>
videoExtensionOptions?: ReadonlyArray<{ extension: VideoExtension, label: string }>
forbiddenFileExtensionOptions?: ReadonlyArray<{ extension: string, label: string }>
}>()
const uploadImageMaxFileSizeMbDraft = defineModel<number>('uploadImageMaxFileSizeMb')
const uploadVideoMaxFileSizeMbDraft = defineModel<number>('uploadVideoMaxFileSizeMb')
const uploadFileMaxFileSizeMbDraft = defineModel<number>('uploadFileMaxFileSizeMb')
const allowedMimeTypesDraft = defineModel<MimeType[]>('allowedMimeTypes')
const allowedVideoExtensionsDraft = defineModel<VideoExtension[]>('allowedVideoExtensions')
const forbiddenFileExtensionsDraft = defineModel<string[]>('forbiddenFileExtensions')
const uploadRateIpMaxDraft = defineModel<number>('uploadRateIpMax')
const uploadRateTokenMaxDraft = defineModel<number>('uploadRateTokenMax')
const uploadRateWindowMinutesDraft = defineModel<number>('uploadRateWindowMinutes')
const loginRateMaxDraft = defineModel<number>('loginRateMax')
const loginRateWindowMinutesDraft = defineModel<number>('loginRateWindowMinutes')
const preserveOriginalUploadDraft = defineModel<boolean>('preserveOriginalUpload')
const customForbiddenExtension = ref('')
const { t } = useI18n()
function sourceBadge(source: PolicySource) {
switch (source) {
case 'env':
return { label: t('settings.badgeEnv'), color: 'warning' as const }
case 'db':
return { label: t('settings.badgeSaved'), color: 'success' as const }
default:
return { label: t('settings.badgeDefault'), color: 'neutral' as const }
}
}
function toggleMime(mime: MimeType, checked: boolean) {
if (!allowedMimeTypesDraft.value) return
if (checked) {
if (!allowedMimeTypesDraft.value.includes(mime)) {
allowedMimeTypesDraft.value = [...allowedMimeTypesDraft.value, mime]
}
return
}
if (allowedMimeTypesDraft.value.length <= 1) return
allowedMimeTypesDraft.value = allowedMimeTypesDraft.value.filter(item => item !== mime)
}
function isMimeChecked(mime: MimeType): boolean {
return allowedMimeTypesDraft.value?.includes(mime) ?? false
}
function toggleVideoExtension(extension: VideoExtension, checked: boolean) {
if (!allowedVideoExtensionsDraft.value) return
if (checked) {
if (!allowedVideoExtensionsDraft.value.includes(extension)) {
allowedVideoExtensionsDraft.value = [
...allowedVideoExtensionsDraft.value,
extension
]
}
return
}
if (allowedVideoExtensionsDraft.value.length <= 1) return
allowedVideoExtensionsDraft.value = allowedVideoExtensionsDraft.value
.filter(item => item !== extension)
}
function isVideoExtensionChecked(extension: VideoExtension): boolean {
return allowedVideoExtensionsDraft.value?.includes(extension) ?? false
}
function toggleForbiddenExtension(extension: string, checked: boolean) {
if (!forbiddenFileExtensionsDraft.value) return
if (checked) {
if (!forbiddenFileExtensionsDraft.value.includes(extension)) {
forbiddenFileExtensionsDraft.value = [
...forbiddenFileExtensionsDraft.value,
extension
]
}
return
}
forbiddenFileExtensionsDraft.value = forbiddenFileExtensionsDraft.value
.filter(item => item !== extension)
}
function isForbiddenExtensionChecked(extension: string): boolean {
return forbiddenFileExtensionsDraft.value?.includes(extension) ?? false
}
const allForbiddenExtensionOptions = computed(() => {
const options = new Map(
(props.forbiddenFileExtensionOptions ?? []).map(option => [
option.extension,
option.label
])
)
for (const extension of forbiddenFileExtensionsDraft.value ?? []) {
if (!options.has(extension)) options.set(extension, extension.toUpperCase())
}
return [...options].map(([extension, label]) => ({ extension, label }))
})
function addCustomForbiddenExtension() {
const values = customForbiddenExtension.value
.split(',')
.map(value => value.trim().toLowerCase().replace(/^\.+/, ''))
.filter(value => /^[a-z0-9][a-z0-9_-]{0,15}$/.test(value))
if (!values.length) return
const next = new Set(forbiddenFileExtensionsDraft.value ?? [])
for (const value of values) next.add(value)
forbiddenFileExtensionsDraft.value = [...next]
customForbiddenExtension.value = ''
}
type UploadSizeKind = 'image' | 'video' | 'file'
const uploadSizeFields = computed(() => [
{
kind: 'image' as const,
title: t('settings.uploadImageMaxFileSize'),
hint: t('settings.uploadImageMaxFileSizeHint'),
source: props.uploadImageMaxFileSizeMbSource,
value: uploadImageMaxFileSizeMbDraft.value ?? 10,
max: 100
},
{
kind: 'video' as const,
title: t('settings.uploadVideoMaxFileSize'),
hint: t('settings.uploadVideoMaxFileSizeHint'),
source: props.uploadVideoMaxFileSizeMbSource,
value: uploadVideoMaxFileSizeMbDraft.value ?? 500,
max: 2048
},
{
kind: 'file' as const,
title: t('settings.uploadFileMaxFileSize'),
hint: t('settings.uploadFileMaxFileSizeHint'),
source: props.uploadFileMaxFileSizeMbSource,
value: uploadFileMaxFileSizeMbDraft.value ?? 100,
max: 1024
}
])
function uploadSizeModel(kind: UploadSizeKind) {
if (kind === 'video') return uploadVideoMaxFileSizeMbDraft
if (kind === 'file') return uploadFileMaxFileSizeMbDraft
return uploadImageMaxFileSizeMbDraft
}
function clampUploadMaxFileSizeMb(kind: UploadSizeKind, value: unknown): number {
const num = Number(value)
const model = uploadSizeModel(kind)
const max = kind === 'video' ? 2048 : kind === 'file' ? 1024 : 100
if (!Number.isFinite(num)) return model.value ?? (kind === 'video' ? 500 : kind === 'file' ? 100 : 10)
return Math.min(max, Math.max(0, Math.round(num)))
}
function onUploadMaxFileSizeInput(kind: UploadSizeKind, event: Event) {
uploadSizeModel(kind).value = clampUploadMaxFileSizeMb(kind,
(event.target as HTMLInputElement).value
)
}
function onUploadMaxFileSizeNumberInput(kind: UploadSizeKind, value: string | number) {
uploadSizeModel(kind).value = clampUploadMaxFileSizeMb(kind, value)
}
</script>
<template>
<template v-if="part === 'upload-preference'">
<div class="space-y-5 py-4">
<div
v-for="field in uploadSizeFields"
:key="field.kind"
>
<div class="flex flex-wrap items-center gap-2">
<p class="text-sm font-medium text-highlighted">
{{ field.title }}
</p>
<UBadge
v-if="field.source"
:color="sourceBadge(field.source).color"
variant="subtle"
size="xs"
>
{{ sourceBadge(field.source).label }}
</UBadge>
</div>
<p class="mt-1 text-xs leading-relaxed text-muted">
{{ field.hint }}
</p>
<div
class="mt-3 flex items-center gap-3"
:class="field.source === 'env' ? 'pointer-events-none opacity-60' : ''"
>
<input
type="range"
min="0"
:max="field.max"
:value="field.value"
class="h-1.5 min-w-0 flex-1 cursor-pointer appearance-none rounded-full bg-muted accent-primary"
:disabled="field.source === 'env'"
@input="onUploadMaxFileSizeInput(field.kind, $event)"
>
<div class="flex shrink-0 items-center gap-1.5">
<UInput
:model-value="field.value"
type="number"
:min="0"
:max="field.max"
size="xs"
class="w-16 tabular-nums"
:disabled="field.source === 'env'"
@update:model-value="onUploadMaxFileSizeNumberInput(field.kind, $event)"
/>
<span class="text-xs text-muted">MB</span>
</div>
</div>
</div>
</div>
<div class="border-t border-default/50 py-4">
<div class="flex flex-wrap items-center gap-2">
<p class="text-sm font-medium text-highlighted">
{{ t('settings.allowedMimeTypes') }}
</p>
<UBadge
v-if="allowedMimeTypesSource"
:color="sourceBadge(allowedMimeTypesSource).color"
variant="subtle"
size="xs"
>
{{ sourceBadge(allowedMimeTypesSource).label }}
</UBadge>
</div>
<p class="mt-1 text-xs leading-relaxed text-muted">
{{ t('settings.allowedMimeTypesHint') }}
</p>
<div
class="mt-3 flex flex-wrap gap-2"
:class="allowedMimeTypesSource === 'env' ? 'pointer-events-none opacity-60' : ''"
>
<label
v-for="option in mimeTypeOptions ?? []"
:key="option.mime"
class="inline-flex cursor-pointer items-center gap-2 rounded-lg border px-3 py-2 text-sm transition-colors"
:class="isMimeChecked(option.mime)
? 'border-primary/40 bg-primary/5'
: 'border-default bg-muted/20 hover:bg-muted/40'"
:title="option.mime"
>
<UCheckbox
:model-value="isMimeChecked(option.mime)"
@update:model-value="(v) => toggleMime(option.mime, v === true)"
@click.stop
/>
<span class="font-medium text-highlighted">{{ option.label }}</span>
</label>
</div>
</div>
<div class="border-t border-default/50 py-4">
<div class="flex flex-wrap items-center gap-2">
<p class="text-sm font-medium text-highlighted">
{{ t('settings.allowedVideoExtensions') }}
</p>
<UBadge
v-if="allowedVideoExtensionsSource"
:color="sourceBadge(allowedVideoExtensionsSource).color"
variant="subtle"
size="xs"
>
{{ sourceBadge(allowedVideoExtensionsSource).label }}
</UBadge>
</div>
<p class="mt-1 text-xs leading-relaxed text-muted">
{{ t('settings.allowedVideoExtensionsHint') }}
</p>
<div
class="mt-3 flex flex-wrap gap-2"
:class="allowedVideoExtensionsSource === 'env' ? 'pointer-events-none opacity-60' : ''"
>
<label
v-for="option in videoExtensionOptions ?? []"
:key="option.extension"
class="inline-flex cursor-pointer items-center gap-2 rounded-lg border px-3 py-2 text-sm transition-colors"
:class="isVideoExtensionChecked(option.extension)
? 'border-primary/40 bg-primary/5'
: 'border-default bg-muted/20 hover:bg-muted/40'"
>
<UCheckbox
:model-value="isVideoExtensionChecked(option.extension)"
@update:model-value="(v) => toggleVideoExtension(option.extension, v === true)"
@click.stop
/>
<span class="font-medium text-highlighted">{{ option.label }}</span>
</label>
</div>
</div>
<div class="border-t border-default/50 py-4">
<div class="flex flex-wrap items-center gap-2">
<p class="text-sm font-medium text-highlighted">
{{ t('settings.forbiddenFileExtensions') }}
</p>
<UBadge
v-if="forbiddenFileExtensionsSource"
:color="sourceBadge(forbiddenFileExtensionsSource).color"
variant="subtle"
size="xs"
>
{{ sourceBadge(forbiddenFileExtensionsSource).label }}
</UBadge>
</div>
<p class="mt-1 text-xs leading-relaxed text-muted">
{{ t('settings.forbiddenFileExtensionsHint') }}
</p>
<div
class="mt-3 flex flex-wrap gap-2"
:class="forbiddenFileExtensionsSource === 'env' ? 'pointer-events-none opacity-60' : ''"
>
<label
v-for="option in allForbiddenExtensionOptions"
:key="option.extension"
class="inline-flex cursor-pointer items-center gap-2 rounded-lg border px-3 py-2 text-sm transition-colors"
:class="isForbiddenExtensionChecked(option.extension)
? 'border-error/40 bg-error/5'
: 'border-default bg-muted/20 hover:bg-muted/40'"
>
<UCheckbox
:model-value="isForbiddenExtensionChecked(option.extension)"
@update:model-value="(v) => toggleForbiddenExtension(option.extension, v === true)"
@click.stop
/>
<span class="font-medium text-highlighted">{{ option.label }}</span>
</label>
</div>
<div
class="mt-3 flex max-w-md items-center gap-2"
:class="forbiddenFileExtensionsSource === 'env' ? 'pointer-events-none opacity-60' : ''"
>
<UInput
v-model="customForbiddenExtension"
size="sm"
class="min-w-0 flex-1"
:placeholder="t('settings.forbiddenFileExtensionsCustomPlaceholder')"
:disabled="forbiddenFileExtensionsSource === 'env'"
@keydown.enter.prevent="addCustomForbiddenExtension"
/>
<UButton
:label="t('settings.forbiddenFileExtensionsAdd')"
size="sm"
variant="outline"
color="neutral"
:disabled="forbiddenFileExtensionsSource === 'env'"
@click="addCustomForbiddenExtension"
/>
</div>
</div>
<div class="py-4">
<p class="text-sm font-medium text-highlighted">
{{ t('settings.uploadRateGroup') }}
</p>
<p class="mt-1 text-xs leading-relaxed text-muted">
{{ t('settings.uploadRateFootnote') }}
</p>
<div class="mt-3 grid gap-4 sm:grid-cols-3">
<div>
<div class="flex flex-wrap items-center gap-2">
<p class="text-xs font-medium text-muted">
{{ t('settings.uploadRateIpMax') }}
</p>
<UBadge
v-if="uploadRateIpMaxSource"
:color="sourceBadge(uploadRateIpMaxSource).color"
variant="subtle"
size="xs"
>
{{ sourceBadge(uploadRateIpMaxSource).label }}
</UBadge>
</div>
<UInput
v-model.number="uploadRateIpMaxDraft"
type="number"
:min="1"
:max="1000"
size="sm"
class="mt-2 w-full tabular-nums"
:disabled="uploadRateIpMaxSource === 'env'"
/>
</div>
<div>
<div class="flex flex-wrap items-center gap-2">
<p class="text-xs font-medium text-muted">
{{ t('settings.uploadRateTokenMax') }}
</p>
<UBadge
v-if="uploadRateTokenMaxSource"
:color="sourceBadge(uploadRateTokenMaxSource).color"
variant="subtle"
size="xs"
>
{{ sourceBadge(uploadRateTokenMaxSource).label }}
</UBadge>
</div>
<UInput
v-model.number="uploadRateTokenMaxDraft"
type="number"
:min="1"
:max="5000"
size="sm"
class="mt-2 w-full tabular-nums"
:disabled="uploadRateTokenMaxSource === 'env'"
/>
</div>
<div>
<div class="flex flex-wrap items-center gap-2">
<p class="text-xs font-medium text-muted">
{{ t('settings.uploadRateWindowMinutes') }}
</p>
<UBadge
v-if="uploadRateWindowMinutesSource"
:color="sourceBadge(uploadRateWindowMinutesSource).color"
variant="subtle"
size="xs"
>
{{ sourceBadge(uploadRateWindowMinutesSource).label }}
</UBadge>
</div>
<div class="mt-2 flex items-center gap-2">
<UInput
v-model.number="uploadRateWindowMinutesDraft"
type="number"
:min="1"
:max="1440"
size="sm"
class="w-full tabular-nums"
:disabled="uploadRateWindowMinutesSource === 'env'"
/>
<span class="shrink-0 text-xs text-muted">{{ t('settings.minutesUnit') }}</span>
</div>
</div>
</div>
</div>
</template>
<SettingsToggleRow
v-else-if="part === 'image-processing'"
v-model="preserveOriginalUploadDraft!"
:disabled="preserveOriginalUploadSource === 'env'"
>
<template #title>
<span class="inline-flex flex-wrap items-center gap-2">
{{ t('settings.preserveOriginalUpload') }}
<UBadge
v-if="preserveOriginalUploadSource"
:color="sourceBadge(preserveOriginalUploadSource).color"
variant="subtle"
size="xs"
>
{{ sourceBadge(preserveOriginalUploadSource).label }}
</UBadge>
</span>
</template>
<template #hint>
{{ t('settings.preserveOriginalUploadHint') }}
</template>
</SettingsToggleRow>
<template v-else-if="part === 'login-rate'">
<div class="grid gap-4 py-4 sm:grid-cols-2">
<div>
<div class="flex flex-wrap items-center gap-2">
<p class="text-sm font-medium text-highlighted">
{{ t('settings.loginRateMax') }}
</p>
<UBadge
v-if="loginRateMaxSource"
:color="sourceBadge(loginRateMaxSource).color"
variant="subtle"
size="xs"
>
{{ sourceBadge(loginRateMaxSource).label }}
</UBadge>
</div>
<UInput
v-model.number="loginRateMaxDraft"
type="number"
:min="1"
:max="100"
size="sm"
class="mt-2 w-full tabular-nums"
:disabled="loginRateMaxSource === 'env'"
/>
</div>
<div>
<div class="flex flex-wrap items-center gap-2">
<p class="text-sm font-medium text-highlighted">
{{ t('settings.loginRateWindowMinutes') }}
</p>
<UBadge
v-if="loginRateWindowMinutesSource"
:color="sourceBadge(loginRateWindowMinutesSource).color"
variant="subtle"
size="xs"
>
{{ sourceBadge(loginRateWindowMinutesSource).label }}
</UBadge>
</div>
<div class="mt-2 flex items-center gap-2">
<UInput
v-model.number="loginRateWindowMinutesDraft"
type="number"
:min="1"
:max="1440"
size="sm"
class="w-full tabular-nums"
:disabled="loginRateWindowMinutesSource === 'env'"
/>
<span class="shrink-0 text-xs text-muted">{{ t('settings.minutesUnit') }}</span>
</div>
</div>
</div>
<p class="pb-4 text-xs leading-relaxed text-muted">
{{ t('settings.loginRateFootnote') }}
</p>
</template>
</template>
@@ -0,0 +1,159 @@
<script setup lang="ts">
import type { UploadMediaKind } from '~/composables/useUploadPreferences'
const props = withDefaults(defineProps<{
part: 'upload-preference' | 'image-processing'
kind?: UploadMediaKind
}>(), {
kind: 'image'
})
const {
compressEnabled,
autoCopyMarkdown,
copyFormat,
clientWebpQuality,
loadPreferences
} = useUploadPreferences(props.kind)
const { t } = useI18n()
const {
userSettings,
enabled: userAutoDeleteEnabled,
daysDraft: userAutoDeleteDaysDraft,
load: loadUserSettings
} = useUserAutoDeleteSettings()
const copyFormatItems = computed(() => [
{ label: t('copy.url'), value: 'url' as const },
{ label: t('copy.markdown'), value: 'markdown' as const },
{ label: t('copy.html'), value: 'html' as const },
{ label: t('copy.bbcode'), value: 'bbcode' as const }
])
function setCopyFormat(value: 'url' | 'markdown' | 'html' | 'bbcode') {
copyFormat.value = value
}
function onClientQualityInput(event: Event) {
const value = Number((event.target as HTMLInputElement).value)
clientWebpQuality.value = Number.isFinite(value) ? value : 80
}
function onClientQualityNumberInput(value: string | number) {
const num = Number(value)
clientWebpQuality.value = Number.isFinite(num) ? Math.min(100, Math.max(1, Math.round(num))) : 80
}
onMounted(() => {
loadPreferences()
void loadUserSettings()
})
</script>
<template>
<template v-if="part === 'image-processing'">
<SettingsToggleRow
v-model="compressEnabled"
:title="t('preferences.clientCompress')"
:hint="t('preferences.clientCompressHint')"
/>
<div
v-if="userSettings"
class="flex items-start justify-between gap-4 py-4"
>
<div class="min-w-0 flex-1 pr-2">
<p class="flex flex-wrap items-center gap-x-1.5 gap-y-1 text-sm font-medium text-highlighted">
<span>{{ t('preferences.autoDeletePrefix') }}</span>
<UInput
v-model.number="userAutoDeleteDaysDraft"
type="number"
min="1"
max="3650"
size="xs"
class="w-14 tabular-nums"
:class="userAutoDeleteEnabled ? '' : 'pointer-events-none opacity-40'"
/>
<span>{{ t('preferences.autoDeleteSuffix') }}</span>
</p>
<p class="mt-1 text-xs leading-relaxed text-muted">
{{ t('preferences.autoDeleteHint') }}
</p>
</div>
<USwitch
v-model="userAutoDeleteEnabled"
class="mt-0.5 shrink-0"
/>
</div>
<div
v-else
class="flex justify-center py-4"
>
<UIcon
name="i-lucide-loader-circle"
class="size-5 animate-spin text-muted"
/>
</div>
<div class="py-4">
<div class="min-w-0">
<p class="text-sm font-medium text-highlighted">
{{ t('preferences.webpQuality') }}
</p>
<p class="mt-1 text-xs leading-relaxed text-muted">
{{ t('preferences.webpQualityHint') }}
</p>
</div>
<div class="mt-3 flex items-center gap-3">
<input
type="range"
min="1"
max="100"
:value="clientWebpQuality"
class="h-1.5 min-w-0 flex-1 cursor-pointer appearance-none rounded-full bg-muted accent-primary"
@input="onClientQualityInput"
>
<UInput
:model-value="clientWebpQuality"
type="number"
min="1"
max="100"
size="xs"
class="w-14 shrink-0 tabular-nums"
@update:model-value="onClientQualityNumberInput"
/>
</div>
</div>
</template>
<template v-else>
<SettingsToggleRow
v-model="autoCopyMarkdown"
:title="t('preferences.autoCopy')"
:hint="t('preferences.autoCopyHint')"
/>
<div class="py-4">
<p class="text-sm font-medium text-highlighted">
{{ t('settings.defaultCopyFormat') }}
</p>
<p class="mt-1 text-xs leading-relaxed text-muted">
{{ t('settings.defaultCopyFormatHint') }}
</p>
<div
class="mt-3 flex w-full max-w-md gap-1 rounded-lg border border-default p-0.5"
:class="autoCopyMarkdown ? '' : 'pointer-events-none opacity-40'"
>
<UButton
v-for="item in copyFormatItems"
:key="item.value"
size="xs"
class="flex-1 justify-center"
:variant="copyFormat === item.value ? 'solid' : 'ghost'"
:color="copyFormat === item.value ? 'primary' : 'neutral'"
:label="item.label"
@click="setCopyFormat(item.value)"
/>
</div>
</div>
</template>
</template>
@@ -0,0 +1,43 @@
<script setup lang="ts">
const { transcodeToHls, loadPreferences } = useUploadPreferences('video')
const { t } = useI18n()
const {
lowMemory,
memoryBytes,
load: loadTranscodeSettings
} = useTranscodeSettings()
const memoryGb = computed(() => (
memoryBytes.value > 0
? (memoryBytes.value / (1024 * 1024 * 1024)).toFixed(1)
: '—'
))
onMounted(() => {
loadPreferences()
void loadTranscodeSettings()
})
</script>
<template>
<UAlert
v-if="lowMemory"
color="warning"
variant="subtle"
icon="i-lucide-triangle-alert"
:title="t('preferences.lowMemoryTranscodeTitle')"
:description="t('preferences.lowMemoryTranscodeHint', { memory: memoryGb })"
class="my-4"
/>
<SettingsToggleRow
v-model="transcodeToHls"
:title="t('preferences.transcodeToHls')"
:hint="t('preferences.transcodeToHlsHint')"
/>
<div
v-if="transcodeToHls"
class="border-t border-default/50 py-4"
>
<TranscodeSettingsControls />
</div>
</template>
+444
View File
@@ -0,0 +1,444 @@
<script setup lang="ts">
import type { BackendSyncPreview, SyncDraft } from '~/types/backup'
import type { StorageBackendItem } from '~/types/storage'
const props = defineProps<{
backends: StorageBackendItem[]
syncDraft: SyncDraft | null
highlighted?: boolean
busy: boolean
disabled?: boolean
activeJob?: { progress: number, total: number, message: string } | null
previewSync: (body: { fromBackendId: string, toBackendId: string }) => Promise<BackendSyncPreview>
}>()
const emit = defineEmits<{
sync: [body: {
fromBackendId: string
toBackendId: string
deleteSource: boolean
setDefault: boolean
}]
}>()
const { t, locale } = useI18n()
const { formatFileSize } = useFileSize()
const toast = useToast()
const fromBackendId = ref('')
const toBackendId = ref('')
const deleteSource = ref(false)
const setDefault = ref(false)
const preview = ref<BackendSyncPreview | null>(null)
const previewLoading = ref(false)
const statsLoading = ref(false)
const confirmOpen = ref(false)
const userTouched = ref(false)
let statsTimer: ReturnType<typeof setTimeout> | null = null
const backendOptions = computed(() =>
props.backends.map(backend => ({
label: backend.name,
value: backend.id
}))
)
const backendNameById = computed(() =>
new Map(props.backends.map(backend => [backend.id, backend.name]))
)
const needsMoreBackends = computed(() => props.backends.length < 2)
const syncDisabled = computed(() =>
props.disabled || props.busy || needsMoreBackends.value
)
function formatCount(value: number) {
return value.toLocaleString(locale.value)
}
function initDefaultSelection() {
if (userTouched.value || props.syncDraft) return
const defaultBackend = props.backends.find(backend => backend.isDefault)
const target = props.backends.find(backend => !backend.isDefault && backend.enabled)
if (defaultBackend && target) {
fromBackendId.value = defaultBackend.id
toBackendId.value = target.id
return
}
if (props.backends.length >= 2) {
fromBackendId.value = props.backends[0]!.id
toBackendId.value = props.backends[1]!.id
}
}
function canLoadStats(): boolean {
return !needsMoreBackends.value
&& Boolean(fromBackendId.value)
&& Boolean(toBackendId.value)
&& fromBackendId.value !== toBackendId.value
}
async function refreshStats(immediate = false) {
if (!canLoadStats()) {
preview.value = null
return
}
if (immediate) {
if (statsTimer) clearTimeout(statsTimer)
await loadStats()
return
}
scheduleStatsLoad()
}
async function loadStats() {
if (needsMoreBackends.value || !fromBackendId.value || !toBackendId.value) {
preview.value = null
return
}
if (fromBackendId.value === toBackendId.value) {
preview.value = null
return
}
statsLoading.value = true
try {
preview.value = await props.previewSync({
fromBackendId: fromBackendId.value,
toBackendId: toBackendId.value
})
} catch {
preview.value = null
} finally {
statsLoading.value = false
}
}
function scheduleStatsLoad() {
if (statsTimer) clearTimeout(statsTimer)
statsTimer = setTimeout(() => {
void loadStats()
}, 400)
}
watch(
() => props.backends,
async () => {
initDefaultSelection()
await nextTick()
await refreshStats(true)
},
{ immediate: true }
)
watch(
() => props.syncDraft,
async (draft) => {
if (!draft) return
fromBackendId.value = draft.fromBackendId
toBackendId.value = draft.toBackendId
userTouched.value = true
await refreshStats(true)
},
{ immediate: true }
)
watch([fromBackendId, toBackendId], () => {
void refreshStats()
})
onBeforeUnmount(() => {
if (statsTimer) clearTimeout(statsTimer)
})
function applyDraft(draft: SyncDraft) {
fromBackendId.value = draft.fromBackendId
toBackendId.value = draft.toBackendId
userTouched.value = true
void refreshStats(true)
}
defineExpose({ applyDraft })
function backendName(id: string) {
return backendNameById.value.get(id) ?? id
}
function validateSelection(): boolean {
if (needsMoreBackends.value) {
toast.add({ title: t('storage.backup.syncNeedTwoBackends'), color: 'warning' })
return false
}
if (!fromBackendId.value || !toBackendId.value) {
toast.add({ title: t('storage.backup.syncSelectRequired'), color: 'warning' })
return false
}
if (fromBackendId.value === toBackendId.value) {
toast.add({ title: t('storage.backup.syncSameBackend'), color: 'warning' })
return false
}
return true
}
async function submit() {
if (!validateSelection()) return
previewLoading.value = true
try {
preview.value = await props.previewSync({
fromBackendId: fromBackendId.value,
toBackendId: toBackendId.value
})
confirmOpen.value = true
} catch {
toast.add({ title: t('storage.backup.previewFailed'), color: 'error' })
} finally {
previewLoading.value = false
}
}
function confirmSync() {
emit('sync', {
fromBackendId: fromBackendId.value,
toBackendId: toBackendId.value,
deleteSource: deleteSource.value,
setDefault: setDefault.value
})
confirmOpen.value = false
}
</script>
<template>
<article
class="flex h-full flex-col rounded-xl border bg-elevated p-4 transition-colors sm:p-5"
:class="highlighted ? 'border-primary ring-2 ring-primary/25' : 'border-default'"
>
<div class="flex items-start gap-3">
<div class="flex size-10 shrink-0 items-center justify-center rounded-lg bg-info/10 text-info">
<UIcon
name="i-lucide-arrow-left-right"
class="size-5"
/>
</div>
<div class="min-w-0">
<h3 class="text-base font-semibold">
{{ t('storage.backup.syncTitle') }}
</h3>
<p class="mt-0.5 text-sm text-muted">
{{ t('storage.backup.syncSubtitle') }}
</p>
</div>
</div>
<div class="mt-4 flex flex-1 flex-col">
<UAlert
v-if="needsMoreBackends"
color="warning"
variant="subtle"
icon="i-lucide-triangle-alert"
:description="t('storage.backup.syncNeedTwoBackends')"
/>
<div
v-else
class="flex flex-1 flex-col space-y-3"
>
<div class="grid gap-3 sm:grid-cols-[1fr_auto_1fr] sm:items-end">
<div class="space-y-1.5">
<label class="text-xs text-muted">{{ t('storage.backup.syncFrom') }}</label>
<USelect
v-model="fromBackendId"
:items="backendOptions"
size="sm"
class="w-full"
:disabled="syncDisabled"
@update:model-value="userTouched = true"
/>
</div>
<UIcon
name="i-lucide-arrow-right"
class="hidden size-4 shrink-0 text-muted sm:mb-2.5 sm:block"
/>
<div class="space-y-1.5">
<label class="text-xs text-muted">{{ t('storage.backup.syncTo') }}</label>
<USelect
v-model="toBackendId"
:items="backendOptions"
size="sm"
class="w-full"
:disabled="syncDisabled"
@update:model-value="userTouched = true"
/>
</div>
</div>
<dl class="space-y-1.5 rounded-lg bg-muted/25 px-3 py-2.5 text-sm">
<div class="flex items-baseline justify-between gap-2">
<dt class="text-muted">
{{ t('storage.backup.syncPendingImages') }}
</dt>
<dd class="font-medium tabular-nums">
<template v-if="statsLoading">
…
</template>
<template v-else-if="preview">
{{ formatCount(preview.imageCount) }}
</template>
<template v-else>
—
</template>
</dd>
</div>
<div class="flex items-baseline justify-between gap-2">
<dt class="text-muted">
{{ t('storage.backup.syncPendingSize') }}
</dt>
<dd class="font-medium tabular-nums">
<template v-if="statsLoading">
…
</template>
<template v-else-if="preview">
{{ formatFileSize(preview.imageBytes) }}
</template>
<template v-else>
—
</template>
</dd>
</div>
</dl>
<div class="space-y-2 text-xs">
<label class="flex cursor-pointer items-center gap-2">
<UCheckbox
v-model="deleteSource"
:disabled="syncDisabled"
/>
<span class="text-muted">{{ t('storage.backup.syncDeleteSource') }}</span>
</label>
<label class="flex cursor-pointer items-center gap-2">
<UCheckbox
v-model="setDefault"
:disabled="syncDisabled"
/>
<span class="text-muted">{{ t('storage.backup.syncSetDefault') }}</span>
</label>
</div>
<div class="mt-auto rounded-lg bg-muted/15 px-3 py-2.5">
<p class="text-xs font-medium text-default">
{{ t('storage.backup.syncIncludesTitle') }}
</p>
<ul class="mt-2 space-y-1.5 text-xs leading-relaxed text-muted">
<li class="flex items-start gap-2">
<UIcon
name="i-lucide-check"
class="mt-0.5 size-3.5 shrink-0 text-info"
/>
<span>{{ t('storage.backup.syncIncludesCopy') }}</span>
</li>
<li class="flex items-start gap-2">
<UIcon
name="i-lucide-check"
class="mt-0.5 size-3.5 shrink-0 text-info"
/>
<span>{{ t('storage.backup.syncIncludesIndex') }}</span>
</li>
<li class="flex items-start gap-2">
<UIcon
name="i-lucide-minus"
class="mt-0.5 size-3.5 shrink-0 text-muted"
/>
<span>{{ t('storage.backup.syncIncludesConfig') }}</span>
</li>
</ul>
</div>
<StorageBackupJobProgress
v-if="busy && activeJob"
class="mt-auto"
:message="activeJob.message"
:progress="activeJob.progress"
:total="activeJob.total"
/>
</div>
</div>
<div
v-if="!needsMoreBackends"
class="mt-auto border-t border-default pt-4"
>
<UButton
:label="t('storage.backup.syncPreview')"
icon="i-lucide-play"
size="sm"
:loading="previewLoading || busy"
:disabled="syncDisabled"
@click="submit"
/>
</div>
<UModal
v-model:open="confirmOpen"
:ui="{ content: 'max-w-md' }"
>
<template #content>
<div class="p-5 sm:p-6">
<h2 class="text-base font-semibold">
{{ t('storage.backup.syncConfirmTitle') }}
</h2>
<p class="mt-2 text-sm text-muted">
{{ backendName(fromBackendId) }} → {{ backendName(toBackendId) }}
</p>
<dl
v-if="preview"
class="mt-4 grid grid-cols-2 gap-3 text-sm"
>
<div>
<dt class="text-xs text-muted">
{{ t('storage.backup.previewImages') }}
</dt>
<dd class="mt-0.5 font-semibold tabular-nums">
{{ formatCount(preview.imageCount) }}
</dd>
</div>
<div>
<dt class="text-xs text-muted">
{{ t('storage.backup.previewSize') }}
</dt>
<dd class="mt-0.5 font-semibold tabular-nums">
{{ formatFileSize(preview.imageBytes) }}
</dd>
</div>
</dl>
<UAlert
v-if="deleteSource"
class="mt-4"
color="warning"
variant="subtle"
icon="i-lucide-triangle-alert"
:description="t('storage.backup.syncDeleteWarning')"
/>
<div class="mt-5 flex justify-end gap-2">
<UButton
:label="t('common.cancel')"
size="sm"
variant="outline"
color="neutral"
@click="() => { confirmOpen = false }"
/>
<UButton
:label="t('storage.backup.syncConfirm')"
size="sm"
icon="i-lucide-play"
:loading="busy"
@click="confirmSync"
/>
</div>
</div>
</template>
</UModal>
</article>
</template>
+141
View File
@@ -0,0 +1,141 @@
<script setup lang="ts">
import type { BackupEstimate } from '~/types/backup'
defineProps<{
estimate: BackupEstimate | null
loading: boolean
busy: boolean
disabled?: boolean
activeJob?: { progress: number, total: number, message: string } | null
}>()
const emit = defineEmits<{
export: []
}>()
const { t } = useI18n()
const { formatFileSize } = useFileSize()
</script>
<template>
<article class="flex h-full flex-col rounded-xl border border-default bg-elevated p-4 sm:p-5">
<div class="flex items-start gap-3">
<div class="flex size-10 shrink-0 items-center justify-center rounded-lg bg-primary/10 text-primary">
<UIcon
name="i-lucide-archive"
class="size-5"
/>
</div>
<div class="min-w-0">
<h3 class="text-base font-semibold">
{{ t('storage.backup.exportTitle') }}
</h3>
<p class="mt-0.5 text-sm text-muted">
{{ t('storage.backup.exportSubtitle') }}
</p>
</div>
</div>
<div class="mt-4 flex flex-1 flex-col space-y-2">
<div
v-if="loading"
class="flex items-center gap-2 text-sm text-muted"
>
<UIcon
name="i-lucide-loader-circle"
class="size-4 animate-spin"
/>
{{ t('storage.backup.estimateLoading') }}
</div>
<dl
v-else-if="estimate"
class="space-y-1.5 rounded-lg bg-muted/25 px-3 py-2.5 text-sm"
>
<div class="flex items-baseline justify-between gap-2">
<dt class="text-muted">
{{ t('storage.backup.estimateImageCountLabel') }}
</dt>
<dd class="font-medium tabular-nums">
{{ estimate.imageCount }} · {{ formatFileSize(estimate.imageBytes) }}
</dd>
</div>
<div class="flex items-baseline justify-between gap-2">
<dt class="text-muted">
{{ t('storage.backup.estimateDatabaseSnapshotLabel') }}
</dt>
<dd class="font-medium tabular-nums">
{{ formatFileSize(estimate.databaseBytes) }}
</dd>
</div>
<div class="flex items-baseline justify-between gap-2 border-t border-default/40 pt-1.5">
<dt class="font-medium text-default">
{{ t('storage.backup.estimatePackageSizeLabel') }}
</dt>
<dd class="font-semibold tabular-nums">
{{ formatFileSize(estimate.totalBytes) }}
</dd>
</div>
<div class="flex items-baseline justify-between gap-2 border-t border-default/40 pt-1.5">
<dt class="text-muted">
{{ t('storage.backup.estimateStoragePathLabel') }}
</dt>
<dd class="truncate font-mono text-xs font-medium">
{{ t('storage.backup.estimateStoragePathValue') }}
</dd>
</div>
</dl>
<div
v-if="estimate && !loading"
class="mt-auto rounded-lg bg-muted/15 px-3 py-2.5"
>
<p class="text-xs font-medium text-default">
{{ t('storage.backup.exportIncludesTitle') }}
</p>
<ul class="mt-2 space-y-1.5 text-xs leading-relaxed text-muted">
<li class="flex items-start gap-2">
<UIcon
name="i-lucide-check"
class="mt-0.5 size-3.5 shrink-0 text-primary"
/>
<span>{{ t('storage.backup.exportIncludesDb') }}</span>
</li>
<li class="flex items-start gap-2">
<UIcon
name="i-lucide-check"
class="mt-0.5 size-3.5 shrink-0 text-primary"
/>
<span>{{ t('storage.backup.exportIncludesImages', { count: estimate.imageCount }) }}</span>
</li>
<li class="flex items-start gap-2">
<UIcon
name="i-lucide-minus"
class="mt-0.5 size-3.5 shrink-0 text-muted"
/>
<span>{{ t('storage.backup.exportExcludesSecrets') }}</span>
</li>
</ul>
</div>
<StorageBackupJobProgress
v-if="busy && activeJob"
class="mt-2"
:message="activeJob.message"
:progress="activeJob.progress"
:total="activeJob.total"
/>
</div>
<div class="mt-auto border-t border-default pt-4">
<UButton
:label="t('storage.backup.exportAction')"
icon="i-lucide-download"
size="sm"
:loading="busy"
:disabled="disabled || busy"
@click="emit('export')"
/>
</div>
</article>
</template>
@@ -0,0 +1,32 @@
<script setup lang="ts">
const props = defineProps<{
message: string
progress: number
total: number
}>()
const percent = computed(() => {
if (!props.total) return 0
return Math.min(100, Math.round((props.progress / props.total) * 100))
})
</script>
<template>
<div class="space-y-1.5">
<div class="flex items-center justify-between gap-2 text-xs text-muted">
<span class="truncate">{{ message }}</span>
<span
v-if="total > 0"
class="shrink-0 tabular-nums"
>
{{ progress }} / {{ total }}
</span>
</div>
<div class="h-1 overflow-hidden rounded-full bg-muted">
<div
class="h-full rounded-full bg-primary transition-all duration-300"
:style="{ width: `${percent}%` }"
/>
</div>
</div>
</template>
+310
View File
@@ -0,0 +1,310 @@
<script setup lang="ts">
import type { BackupJobItem } from '~/types/backup'
defineProps<{
jobs: BackupJobItem[]
polling: boolean
page: number
totalPages: number
total: number
pageSize: number
showHidden: boolean
loading?: boolean
}>()
const emit = defineEmits<{
'retry': [jobId: string]
'download': [jobId: string]
'cancel': [jobId: string]
'hide': [jobId: string, hidden: boolean]
'hideAll': [hidden: boolean]
'toggleHidden': []
'view': [job: BackupJobItem]
'update:page': [page: number]
'update:pageSize': [pageSize: number]
}>()
const { t, locale } = useI18n()
function typeLabel(type: BackupJobItem['type']): string {
const map: Record<BackupJobItem['type'], string> = {
full_export: t('storage.backup.jobTypeExport'),
full_restore: t('storage.backup.jobTypeRestore'),
backend_sync: t('storage.backup.jobTypeSync'),
site_sync: t('storage.backup.jobTypeSiteSync')
}
return map[type]
}
function statusColor(status: BackupJobItem['status']): 'success' | 'error' | 'warning' | 'neutral' {
if (status === 'done') return 'success'
if (status === 'failed') return 'error'
if (status === 'running') return 'warning'
return 'neutral'
}
function statusLabel(status: BackupJobItem['status']): string {
const map: Record<BackupJobItem['status'], string> = {
pending: t('storage.backup.statusPending'),
running: t('storage.backup.statusRunning'),
done: t('storage.backup.statusDone'),
failed: t('storage.backup.statusFailed')
}
return map[status]
}
function progressPercent(job: BackupJobItem): number {
if (!job.total) return job.status === 'done' ? 100 : 0
return Math.min(100, Math.round((job.progress / job.total) * 100))
}
function progressUnit(job: BackupJobItem): string {
return job.type === 'full_restore'
? t('storage.backup.progressUnitObjects')
: t('storage.backup.progressUnitMedia')
}
function jobMessage(job: BackupJobItem): string {
if (job.status === 'failed') {
return job.error || job.message || t('storage.backup.jobFailedUnknown')
}
if (job.status === 'running' || job.status === 'pending') {
return job.message
}
if (job.status === 'done' && job.message) {
return job.message
}
return ''
}
function canRetry(job: BackupJobItem): boolean {
return job.type === 'backend_sync' && job.status === 'failed'
}
function canCancel(job: BackupJobItem): boolean {
return job.status === 'running' || job.status === 'pending'
}
function canView(job: BackupJobItem): boolean {
return job.type === 'full_restore' && job.status === 'done' && Boolean(job.message)
}
function canHide(job: BackupJobItem): boolean {
return job.status === 'done' || job.status === 'failed' || job.hidden
}
function formatTime(value: string) {
if (!value) return '—'
return new Date(value).toLocaleString(locale.value, { hour12: false })
}
</script>
<template>
<section class="overflow-hidden rounded-xl border border-default bg-elevated">
<header class="flex items-center justify-between gap-3 border-b border-default/50 px-4 py-4 sm:px-5">
<div>
<h3 class="text-sm font-semibold">
{{ t('storage.backup.jobsTitle') }}
</h3>
<p class="mt-0.5 text-xs text-muted">
{{ t('storage.backup.jobsSubtitle') }}
</p>
</div>
<div class="flex shrink-0 items-center gap-2">
<UIcon
v-if="polling"
name="i-lucide-loader-circle"
class="size-4 animate-spin text-muted"
/>
<UButton
size="xs"
variant="ghost"
color="neutral"
:icon="showHidden ? 'i-lucide-eye-off' : 'i-lucide-eye'"
:label="showHidden
? t('storage.backup.hiddenJobsHide')
: t('storage.backup.hiddenJobsShow')"
@click="emit('toggleHidden')"
/>
<UButton
size="xs"
variant="outline"
color="neutral"
:icon="showHidden ? 'i-lucide-rotate-ccw' : 'i-lucide-x'"
:label="showHidden
? t('storage.backup.jobsRestoreAll')
: t('storage.backup.jobsHideAll')"
@click="emit('hideAll', !showHidden)"
/>
</div>
</header>
<div
v-if="!jobs.length && !loading"
class="flex flex-col items-center gap-2 px-4 py-12 text-center sm:px-5"
>
<UIcon
name="i-lucide-clipboard-list"
class="size-7 text-muted"
/>
<p class="text-sm text-muted">
{{ t('storage.backup.jobsEmpty') }}
</p>
</div>
<div
v-else
class="overflow-x-auto"
>
<table class="w-full min-w-[800px] text-left text-sm">
<thead>
<tr class="border-b border-default/50 text-xs text-muted">
<th class="px-4 py-3 font-medium sm:px-5">
{{ t('storage.backup.colTask') }}
</th>
<th class="px-4 py-3 font-medium sm:px-5">
{{ t('storage.backup.colStatus') }}
</th>
<th class="px-4 py-3 font-medium sm:px-5">
{{ t('storage.backup.colMessage') }}
</th>
<th class="px-4 py-3 font-medium sm:px-5">
{{ t('storage.backup.colProgress') }}
</th>
<th class="px-4 py-3 font-medium sm:px-5">
{{ t('storage.backup.colUpdated') }}
</th>
<th class="px-4 py-3 font-medium sm:px-5">
{{ t('storage.backup.colActions') }}
</th>
</tr>
</thead>
<tbody>
<tr
v-for="job in jobs"
:key="job.id"
class="border-b border-default/40 last:border-0"
>
<td class="px-4 py-3.5 whitespace-nowrap font-medium sm:px-5">
{{ typeLabel(job.type) }}
</td>
<td class="px-4 py-3.5 sm:px-5">
<UBadge
:color="statusColor(job.status)"
variant="subtle"
size="sm"
>
{{ statusLabel(job.status) }}
</UBadge>
</td>
<td class="max-w-xs px-4 py-3.5 sm:px-5">
<p
v-if="jobMessage(job)"
class="line-clamp-2 text-xs leading-relaxed"
:class="job.status === 'failed' ? 'text-error' : 'text-muted'"
:title="jobMessage(job)"
>
{{ jobMessage(job) }}
</p>
<span
v-else
class="text-xs text-dimmed"
>—</span>
</td>
<td class="min-w-40 px-4 py-3.5 sm:px-5">
<div class="flex items-center gap-2">
<div class="h-1 min-w-0 flex-1 overflow-hidden rounded-full bg-muted">
<div
class="h-full rounded-full bg-primary transition-all"
:class="{ 'animate-pulse': job.status === 'running' }"
:style="{ width: `${progressPercent(job)}%` }"
/>
</div>
<span class="flex shrink-0 flex-col text-right">
<span class="text-xs tabular-nums text-muted">
<template v-if="job.total">{{ job.progress }}/{{ job.total }}</template>
<template v-else-if="job.status === 'done'">100%</template>
<template v-else>—</template>
</span>
<span class="text-[10px] text-dimmed">
{{ progressUnit(job) }}
</span>
</span>
</div>
</td>
<td class="px-4 py-3.5 whitespace-nowrap text-xs text-muted sm:px-5">
{{ formatTime(job.updatedAt || job.finishedAt || job.createdAt) }}
</td>
<td class="px-4 py-3.5 sm:px-5">
<div class="flex flex-wrap gap-1.5">
<UButton
v-if="job.downloadable"
:label="t('storage.backup.download')"
icon="i-lucide-download"
size="xs"
variant="outline"
color="neutral"
@click="emit('download', job.id)"
/>
<UButton
v-if="canView(job)"
:label="t('storage.backup.jobView')"
icon="i-lucide-eye"
size="xs"
variant="outline"
color="neutral"
@click="emit('view', job)"
/>
<UButton
v-if="canCancel(job)"
:label="t('storage.backup.cancel')"
icon="i-lucide-x"
size="xs"
variant="outline"
color="warning"
@click="emit('cancel', job.id)"
/>
<UButton
v-if="canRetry(job)"
:label="t('storage.backup.retry')"
icon="i-lucide-rotate-ccw"
size="xs"
variant="outline"
color="neutral"
@click="emit('retry', job.id)"
/>
<UButton
v-if="canHide(job)"
size="xs"
variant="outline"
:color="job.hidden ? 'neutral' : 'error'"
:icon="job.hidden ? 'i-lucide-eye' : 'i-lucide-x'"
:title="job.hidden
? t('storage.backup.jobUnhide')
: t('storage.backup.jobHide')"
@click="emit('hide', job.id, !job.hidden)"
/>
</div>
</td>
</tr>
</tbody>
</table>
</div>
<div
v-if="total > 0"
class="border-t border-default/50 px-4 py-4 sm:px-5"
>
<PaginationBar
:page="page"
:total-pages="totalPages"
:total="total"
:page-size="pageSize"
:loading="loading"
:unit="t('storage.backup.jobsUnit')"
@update:page="emit('update:page', $event)"
@update:page-size="emit('update:pageSize', $event)"
/>
</div>
</section>
</template>
@@ -0,0 +1,322 @@
<script setup lang="ts">
import type { BackupPackageItem, BackupRestorePreview } from '~/types/backup'
const props = defineProps<{
packages: BackupPackageItem[]
busy: boolean
disabled?: boolean
restoreBusy?: boolean
activeJob?: { progress: number, total: number, message: string } | null
previewRestore: (input: { file?: File, archiveName?: string }) => Promise<BackupRestorePreview>
}>()
const emit = defineEmits<{
restore: [input: { file?: File, archiveName?: string, conflictMode: 'skip' | 'overwrite' }]
}>()
const { t, locale } = useI18n()
const { formatFileSize } = useFileSize()
const toast = useToast()
const fileInput = ref<HTMLInputElement | null>(null)
const selectedFile = ref<File | null>(null)
const selectedPackage = ref<string>('')
const conflictMode = ref<'skip' | 'overwrite'>('skip')
const preview = ref<BackupRestorePreview | null>(null)
const previewLoading = ref(false)
const confirmOpen = ref(false)
const conflictItems = computed(() => [
{ label: t('storage.backup.conflictSkip'), value: 'skip' },
{ label: t('storage.backup.conflictOverwrite'), value: 'overwrite' }
])
const selectMenuUi = {
content: 'min-w-[var(--reka-select-trigger-width)] w-max max-w-[min(100vw-2rem,24rem)]',
item: 'whitespace-normal',
itemLabel: 'whitespace-normal'
}
const packageOptions = computed(() =>
props.packages.map(pkg => ({
label: `${shortenName(pkg.name)} (${formatFileSize(pkg.bytes)})`,
value: pkg.name
}))
)
function shortenName(name: string) {
if (name.length <= 36) return name
return `${name.slice(0, 22)}…${name.slice(-10)}`
}
function formatTime(value: string) {
if (!value) return '—'
return new Date(value).toLocaleString(locale.value, { hour12: false })
}
function getErrorMessage(error: unknown) {
const value = error as {
data?: { data?: { message?: string }, message?: string }
statusMessage?: string
message?: string
}
return value?.data?.data?.message
?? value?.data?.message
?? value?.statusMessage
?? value?.message
?? t('storage.backup.previewFailed')
}
function pickFile() {
fileInput.value?.click()
}
function onFileChange(event: Event) {
const input = event.target as HTMLInputElement
const file = input.files?.[0] ?? null
if (!file) return
selectedFile.value = file
selectedPackage.value = ''
preview.value = null
if (input) input.value = ''
}
function selectPackage(name: string | null) {
if (!name) {
selectedPackage.value = ''
return
}
selectedPackage.value = name
selectedFile.value = null
preview.value = null
}
async function submit() {
if (!selectedFile.value && !selectedPackage.value) {
toast.add({ title: t('storage.backup.restoreSelectRequired'), color: 'warning' })
return
}
previewLoading.value = true
try {
preview.value = await props.previewRestore({
file: selectedFile.value ?? undefined,
archiveName: selectedPackage.value || undefined
})
confirmOpen.value = true
} catch (error: unknown) {
toast.add({
title: t('storage.backup.previewFailed'),
description: getErrorMessage(error),
color: 'error'
})
} finally {
previewLoading.value = false
}
}
function confirmRestore() {
emit('restore', {
file: selectedFile.value ?? undefined,
archiveName: selectedPackage.value || undefined,
conflictMode: conflictMode.value
})
confirmOpen.value = false
selectedFile.value = null
selectedPackage.value = ''
preview.value = null
}
const sourceLabel = computed(() => selectedFile.value?.name || selectedPackage.value || '')
</script>
<template>
<article class="flex h-full flex-col rounded-xl border border-default bg-elevated p-4 sm:p-5">
<div class="flex items-start gap-3">
<div class="flex size-10 shrink-0 items-center justify-center rounded-lg bg-warning/10 text-warning">
<UIcon
name="i-lucide-upload"
class="size-5"
/>
</div>
<div class="min-w-0">
<h3 class="text-base font-semibold">
{{ t('storage.backup.restoreTitle') }}
</h3>
<p class="mt-0.5 text-sm text-muted">
{{ t('storage.backup.restoreSubtitle') }}
</p>
</div>
</div>
<div class="mt-4 flex flex-1 flex-col space-y-3">
<div class="space-y-1.5">
<label class="text-xs text-muted">{{ t('storage.backup.restoreUploadLabel') }}</label>
<button
type="button"
class="flex w-full flex-col items-center gap-1.5 rounded-lg border-2 border-dashed px-3 py-2.5 text-center transition-colors disabled:cursor-not-allowed disabled:opacity-60"
:class="selectedFile
? 'border-primary/50 bg-primary/5'
: 'border-warning/40 bg-warning/5 hover:border-warning/60 hover:bg-warning/10'"
:disabled="disabled || busy"
@click="pickFile"
>
<div
class="flex size-8 items-center justify-center rounded-full"
:class="selectedFile ? 'bg-primary/15 text-primary' : 'bg-warning/15 text-warning'"
>
<UIcon
:name="selectedFile ? 'i-lucide-check' : 'i-lucide-upload'"
class="size-4.5"
/>
</div>
<span class="max-w-full truncate text-sm font-medium text-default">
{{ selectedFile?.name ?? t('storage.backup.restoreUpload') }}
</span>
<span
v-if="!selectedFile"
class="text-xs text-muted"
>
{{ t('storage.backup.restoreUploadHint') }}
</span>
</button>
</div>
<div
v-if="packages.length"
class="space-y-1.5"
>
<label class="text-xs text-muted">{{ t('storage.backup.restoreServerPackages') }}</label>
<USelect
:model-value="selectedPackage || undefined"
:items="packageOptions"
size="sm"
class="w-full"
:ui="selectMenuUi"
:placeholder="t('storage.backup.restoreServerSelectPlaceholder')"
:disabled="disabled || busy"
@update:model-value="selectPackage"
/>
</div>
<div class="space-y-2">
<label class="text-xs text-muted">{{ t('storage.backup.conflictMode') }}</label>
<URadioGroup
v-model="conflictMode"
:items="conflictItems"
:disabled="disabled || busy"
/>
</div>
<StorageBackupJobProgress
v-if="restoreBusy && activeJob"
class="mt-auto"
:message="activeJob.message"
:progress="activeJob.progress"
:total="activeJob.total"
/>
</div>
<div class="mt-auto border-t border-default pt-4">
<UButton
:label="t('storage.backup.restorePreviewConfirm')"
icon="i-lucide-search"
size="sm"
:loading="previewLoading || restoreBusy"
:disabled="disabled || busy"
@click="submit"
/>
</div>
<input
ref="fileInput"
type="file"
accept=".phost.tar.gz,.tar.gz"
class="hidden"
@change="onFileChange"
>
<UModal
v-model:open="confirmOpen"
:ui="{ content: 'max-w-md' }"
>
<template #content>
<div class="p-5 sm:p-6">
<h2 class="text-base font-semibold">
{{ t('storage.backup.restoreConfirmTitle') }}
</h2>
<p
v-if="sourceLabel"
class="mt-2 truncate font-mono text-xs text-muted"
:title="sourceLabel"
>
{{ sourceLabel }}
</p>
<dl
v-if="preview"
class="mt-4 grid grid-cols-2 gap-3 text-sm"
>
<div>
<dt class="text-xs text-muted">
{{ t('storage.backup.previewImages') }}
</dt>
<dd class="mt-0.5 font-semibold tabular-nums">
{{ preview.imageCount }}
</dd>
</div>
<div>
<dt class="text-xs text-muted">
{{ t('storage.backup.previewNew') }}
</dt>
<dd class="mt-0.5 font-semibold tabular-nums">
{{ preview.newImages }}
</dd>
</div>
<div>
<dt class="text-xs text-muted">
{{ t('storage.backup.previewConflicts') }}
</dt>
<dd class="mt-0.5 font-semibold tabular-nums">
{{ preview.conflictingImages }}
</dd>
</div>
<div>
<dt class="text-xs text-muted">
{{ t('storage.backup.previewCreated') }}
</dt>
<dd class="mt-0.5 text-xs">
{{ formatTime(preview.createdAt) }}
</dd>
</div>
</dl>
<UAlert
v-if="conflictMode === 'overwrite'"
class="mt-4"
color="warning"
variant="subtle"
icon="i-lucide-triangle-alert"
:description="t('storage.backup.restoreOverwriteWarning')"
/>
<div class="mt-5 flex justify-end gap-2">
<UButton
:label="t('common.cancel')"
size="sm"
variant="outline"
color="neutral"
@click="() => { confirmOpen = false }"
/>
<UButton
:label="t('storage.backup.restoreConfirm')"
size="sm"
color="warning"
:loading="restoreBusy"
@click="confirmRestore"
/>
</div>
</div>
</template>
</UModal>
</article>
</template>
+356
View File
@@ -0,0 +1,356 @@
<script setup lang="ts">
import type {
SiteSyncDirection,
SiteSyncRequest
} from '~/types/backup'
const props = defineProps<{
busy: boolean
disabled?: boolean
activeJob?: {
progress: number
total: number
message: string
payload: Record<string, unknown>
} | null
resumableJob?: {
id: string
payload: Record<string, unknown>
} | null
}>()
const emit = defineEmits<{
sync: [body: SiteSyncRequest]
}>()
const { t } = useI18n()
const targetBaseUrl = ref('')
const token = ref('')
const conflictMode = ref<'skip' | 'overwrite'>('skip')
const direction = ref<SiteSyncDirection>('push')
const scope = ref<'all' | 'image' | 'video' | 'file'>('all')
/** 目标站点地址与 Token 自动保存 24 小时 */
const FORM_STORAGE_KEY = 'pichost.site-sync-form'
const FORM_TTL_MS = 24 * 60 * 60 * 1000
function loadSavedForm() {
if (!import.meta.client) return
try {
const raw = localStorage.getItem(FORM_STORAGE_KEY)
if (!raw) return
const data = JSON.parse(raw) as {
targetBaseUrl?: string
token?: string
savedAt?: number
}
if (!data.savedAt || Date.now() - data.savedAt > FORM_TTL_MS) {
localStorage.removeItem(FORM_STORAGE_KEY)
return
}
if (data.targetBaseUrl) targetBaseUrl.value = data.targetBaseUrl
if (data.token) token.value = data.token
} catch {
localStorage.removeItem(FORM_STORAGE_KEY)
}
}
function persistForm() {
if (!import.meta.client) return
try {
localStorage.setItem(FORM_STORAGE_KEY, JSON.stringify({
targetBaseUrl: targetBaseUrl.value,
token: token.value,
savedAt: Date.now()
}))
} catch {
// 存储不可用时忽略
}
}
watch([targetBaseUrl, token], persistForm)
onMounted(loadSavedForm)
const scopeItems = computed(() => [
{ value: 'all' as const, label: t('storage.backup.siteSyncScopeAll') },
{ value: 'image' as const, label: t('storage.backup.siteSyncScopeImage') },
{ value: 'video' as const, label: t('storage.backup.siteSyncScopeVideo') },
{ value: 'file' as const, label: t('storage.backup.siteSyncScopeFile') }
])
watch(() => props.resumableJob, (job) => {
if (!job) return
direction.value = job.payload.direction === 'pull' ? 'pull' : 'push'
if (typeof job.payload.targetBaseUrl === 'string') {
targetBaseUrl.value = job.payload.targetBaseUrl
}
}, { immediate: true })
const canSubmit = computed(() =>
!props.busy
&& !props.disabled
&& Boolean(targetBaseUrl.value.trim())
&& Boolean(token.value.trim())
)
const canResume = computed(() =>
!props.busy
&& !props.disabled
&& Boolean(props.resumableJob)
&& Boolean(token.value.trim())
)
const fileProgress = computed(() => {
const payload = props.activeJob?.payload
return {
synced: Number(payload?.filesSynced ?? 0),
total: Number(payload?.totalFiles ?? 0)
}
})
const isPull = computed(() => direction.value === 'pull')
const canAbandon = computed(() =>
!props.busy
&& Boolean(props.resumableJob)
)
const abandonConfirm = computed(() => (
isPull.value || token.value.trim()
? t('storage.backup.siteSyncAbandonConfirm')
: t('storage.backup.siteSyncAbandonWithoutTokenConfirm')
))
const directionSubtitle = computed(() => (
isPull.value
? t('storage.backup.siteSyncPullSubtitle')
: t('storage.backup.siteSyncPushSubtitle')
))
const endpointLabel = computed(() => (
isPull.value
? t('storage.backup.siteSyncSource')
: t('storage.backup.siteSyncTarget')
))
const tokenLabel = computed(() => (
isPull.value
? t('storage.backup.siteSyncSourceToken')
: t('storage.backup.siteSyncToken')
))
const tokenPlaceholder = computed(() => (
isPull.value
? t('storage.backup.siteSyncSourceTokenPlaceholder')
: t('storage.backup.siteSyncTokenPlaceholder')
))
const startLabel = computed(() => (
isPull.value
? t('storage.backup.siteSyncStartPull')
: t('storage.backup.siteSyncStartPush')
))
function submit(resume = false) {
if (resume ? !canResume.value : !canSubmit.value) return
emit('sync', {
targetBaseUrl: targetBaseUrl.value.trim(),
token: token.value.trim(),
conflictMode: conflictMode.value,
direction: direction.value,
scope: scope.value,
...(resume && props.resumableJob ? { resumeJobId: props.resumableJob.id } : {})
})
}
function startNew() {
submit(false)
}
function resumeSync() {
submit(true)
}
function abandonSync() {
if (!canAbandon.value || !props.resumableJob) return
if (!window.confirm(abandonConfirm.value)) return
emit('sync', {
targetBaseUrl: targetBaseUrl.value.trim(),
token: token.value.trim(),
conflictMode: conflictMode.value,
direction: direction.value,
abandonJobId: props.resumableJob.id
})
}
function setConflictMode(mode: 'skip' | 'overwrite') {
conflictMode.value = mode
}
function setDirection(value: SiteSyncDirection) {
direction.value = value
}
function setScope(value: 'all' | 'image' | 'video' | 'file') {
scope.value = value
}
</script>
<template>
<article class="rounded-xl border border-default bg-elevated p-4 sm:p-5">
<div class="flex items-start gap-3">
<div class="flex size-10 shrink-0 items-center justify-center rounded-lg bg-primary/10 text-primary">
<UIcon
name="i-lucide-server-cog"
class="size-5"
/>
</div>
<div class="min-w-0">
<h3 class="text-base font-semibold">
{{ t('storage.backup.siteSyncTitle') }}
</h3>
<p class="mt-0.5 text-sm leading-relaxed text-muted">
{{ directionSubtitle }}
</p>
</div>
</div>
<div class="mt-4 grid gap-3 lg:grid-cols-[minmax(0,1.2fr)_minmax(0,1fr)_auto] lg:items-end">
<div class="space-y-1.5">
<label class="text-xs text-muted">{{ endpointLabel }}</label>
<UInput
v-model="targetBaseUrl"
placeholder="https://target.example.com"
size="sm"
class="w-full"
:disabled="busy || disabled"
/>
</div>
<div class="space-y-1.5">
<label class="text-xs text-muted">{{ tokenLabel }}</label>
<UInput
v-model="token"
type="password"
:placeholder="tokenPlaceholder"
size="sm"
class="w-full"
:disabled="busy || disabled"
/>
</div>
<UButton
:label="startLabel"
:icon="isPull ? 'i-lucide-cloud-download' : 'i-lucide-cloud-upload'"
color="primary"
:loading="busy"
:disabled="!canSubmit"
@click="startNew"
/>
<UButton
v-if="resumableJob"
:label="t('storage.backup.siteSyncResume')"
icon="i-lucide-play"
color="warning"
:loading="busy"
:disabled="!canResume"
class="lg:col-start-3"
@click="resumeSync"
/>
<UButton
v-if="resumableJob"
:label="t('storage.backup.siteSyncAbandon')"
icon="i-lucide-trash-2"
color="error"
variant="outline"
:loading="busy"
:disabled="!canAbandon"
class="lg:col-start-3"
@click="abandonSync"
/>
</div>
<div class="mt-3 grid gap-3 sm:grid-cols-2">
<div class="flex flex-wrap items-center gap-2">
<span class="text-xs text-muted">{{ t('storage.backup.siteSyncDirection') }}</span>
<UButton
size="xs"
:variant="direction === 'push' ? 'solid' : 'ghost'"
:color="direction === 'push' ? 'primary' : 'neutral'"
:label="t('storage.backup.siteSyncPush')"
:disabled="busy || disabled"
@click="setDirection('push')"
/>
<UButton
size="xs"
:variant="direction === 'pull' ? 'solid' : 'ghost'"
:color="direction === 'pull' ? 'primary' : 'neutral'"
:label="t('storage.backup.siteSyncPull')"
:disabled="busy || disabled"
@click="setDirection('pull')"
/>
</div>
<div class="flex flex-wrap items-center gap-2 sm:justify-end">
<span class="text-xs text-muted">{{ t('storage.backup.siteSyncConflict') }}</span>
<UButton
size="xs"
:variant="conflictMode === 'skip' ? 'solid' : 'ghost'"
:color="conflictMode === 'skip' ? 'primary' : 'neutral'"
:label="t('storage.backup.siteSyncSkip')"
:disabled="busy || disabled"
@click="setConflictMode('skip')"
/>
<UButton
size="xs"
:variant="conflictMode === 'overwrite' ? 'solid' : 'ghost'"
:color="conflictMode === 'overwrite' ? 'primary' : 'neutral'"
:label="t('storage.backup.siteSyncOverwrite')"
:disabled="busy || disabled"
@click="setConflictMode('overwrite')"
/>
</div>
</div>
<div class="mt-3 flex flex-wrap items-center gap-2">
<span class="text-xs text-muted">{{ t('storage.backup.siteSyncContent') }}</span>
<UButton
v-for="item in scopeItems"
:key="item.value"
size="xs"
:variant="scope === item.value ? 'solid' : 'ghost'"
:color="scope === item.value ? 'primary' : 'neutral'"
:label="item.label"
:disabled="busy || disabled"
@click="setScope(item.value)"
/>
</div>
<UAlert
v-if="activeJob"
class="mt-4"
color="primary"
variant="subtle"
icon="i-lucide-refresh-cw"
:description="activeJob.message"
/>
<div
v-if="activeJob"
class="mt-3 grid gap-3 rounded-lg border border-default bg-default/40 p-3 sm:grid-cols-2"
>
<div>
<p class="text-xs text-muted">
{{ t('storage.backup.siteSyncScanProgress') }}
</p>
<p class="mt-1 font-mono text-sm font-medium">
{{ activeJob.message }}
</p>
</div>
<div>
<p class="text-xs text-muted">
{{ t('storage.backup.siteSyncFileProgress') }}
</p>
<p class="mt-1 font-mono text-sm font-medium">
{{ fileProgress.synced }} / {{ fileProgress.total }}
</p>
</div>
</div>
<BackupJobProgress
v-if="activeJob"
class="mt-2"
:message="activeJob.message"
:progress="activeJob.progress"
:total="activeJob.total"
/>
</article>
</template>
@@ -0,0 +1,119 @@
<script setup lang="ts">
const props = defineProps<{
open: boolean
keys: string[]
scope: 'image' | 'video' | 'file'
}>()
const emit = defineEmits<{
'update:open': [value: boolean]
}>()
const { t } = useI18n()
const { startSiteSync } = useBackup()
const targetBaseUrl = ref('')
const token = ref('')
const conflictMode = ref<'skip' | 'overwrite'>('skip')
const busy = ref(false)
const canSubmit = computed(() =>
!busy.value
&& props.keys.length > 0
&& Boolean(targetBaseUrl.value.trim())
&& Boolean(token.value.trim())
)
function setConflict(mode: 'skip' | 'overwrite') {
conflictMode.value = mode
}
async function submit() {
if (!canSubmit.value) return
busy.value = true
try {
await startSiteSync({
targetBaseUrl: targetBaseUrl.value.trim(),
token: token.value.trim(),
conflictMode: conflictMode.value,
direction: 'push',
scope: props.scope,
keys: props.keys
})
emit('update:open', false)
} catch {
// useBackup 已提示错误
} finally {
busy.value = false
}
}
</script>
<template>
<UModal
:open="open"
:title="t('storage.backup.syncSelectedTitle')"
:description="t('storage.backup.syncSelectedSubtitle', { n: keys.length })"
@update:open="emit('update:open', $event)"
>
<template #body>
<div class="space-y-3">
<div class="space-y-1.5">
<label class="text-xs text-muted">{{ t('storage.backup.siteSyncTarget') }}</label>
<UInput
v-model="targetBaseUrl"
placeholder="https://target.example.com"
size="sm"
class="w-full"
/>
</div>
<div class="space-y-1.5">
<label class="text-xs text-muted">{{ t('storage.backup.siteSyncToken') }}</label>
<UInput
v-model="token"
type="password"
:placeholder="t('storage.backup.siteSyncTokenPlaceholder')"
size="sm"
class="w-full"
/>
</div>
<div class="flex flex-wrap items-center gap-2">
<span class="text-xs text-muted">{{ t('storage.backup.siteSyncConflict') }}</span>
<UButton
size="xs"
:variant="conflictMode === 'skip' ? 'solid' : 'ghost'"
:color="conflictMode === 'skip' ? 'primary' : 'neutral'"
:label="t('storage.backup.siteSyncSkip')"
@click="setConflict('skip')"
/>
<UButton
size="xs"
:variant="conflictMode === 'overwrite' ? 'solid' : 'ghost'"
:color="conflictMode === 'overwrite' ? 'primary' : 'neutral'"
:label="t('storage.backup.siteSyncOverwrite')"
@click="setConflict('overwrite')"
/>
</div>
</div>
</template>
<template #footer>
<div class="flex justify-end gap-2">
<UButton
:label="t('common.cancel')"
color="neutral"
variant="outline"
@click="emit('update:open', false)"
/>
<UButton
:label="t('storage.backup.syncSelectedStart')"
icon="i-lucide-cloud-upload"
color="primary"
:loading="busy"
:disabled="!canSubmit"
@click="submit"
/>
</div>
</template>
</UModal>
</template>
+145 -156
View File
@@ -1,20 +1,12 @@
<script setup lang="ts"> <script setup lang="ts">
import type { StorageBackendItem } from '~/types/storage' import type { StorageBackendItem } from '~/types/storage'
type CardAction = {
key: string
icon: string
label: string
shortLabel: string
danger?: boolean
disabled?: boolean
onClick: () => void
}
const props = defineProps<{ const props = defineProps<{
backend: StorageBackendItem backend: StorageBackendItem
envOverride?: boolean envOverride?: boolean
busy?: boolean busy?: boolean
showMigrateIn?: boolean
showMigrateOut?: boolean
}>() }>()
const emit = defineEmits<{ const emit = defineEmits<{
@@ -23,193 +15,190 @@ const emit = defineEmits<{
test: [] test: []
toggleEnabled: [] toggleEnabled: []
delete: [] delete: []
migrateIn: []
migrateOut: []
}>() }>()
const { t } = useI18n() const { t } = useI18n()
const typeIcon = computed(() => const typeIcon = computed(() => {
props.backend.type === 'local' ? 'i-lucide-hard-drive' : 'i-lucide-cloud' if (props.backend.type === 'local') return 'i-lucide-hard-drive'
) if (props.backend.type === 'telegram') return 'i-lucide-send'
return 'i-lucide-cloud'
})
const typeLabel = computed(() => const typeLabel = computed(() => {
props.backend.type === 'local' ? t('storage.backendLocal') : t('storage.backendCloud') if (props.backend.type === 'local') return t('storage.backendLocal')
) if (props.backend.type === 'telegram') return t('storage.backendTelegram')
return t('storage.backendCloud')
const servingLabel = computed(() =>
props.backend.servingMode === 'public'
? t('storage.servingPublic')
: t('storage.servingProxy')
)
const bucketLine = computed(() => {
if (props.backend.type !== 's3') return null
return props.backend.config.bucket || t('storage.notConfigured')
}) })
const actionsDisabled = computed(() => props.envOverride || props.busy) const actionsDisabled = computed(() => props.envOverride || props.busy)
const actions = computed<CardAction[]>(() => { const isLocal = computed(() => props.backend.type === 'local')
const isLocal = props.backend.type === 'local'
const items: CardAction[] = []
if (!props.backend.isDefault && props.backend.enabled) { function canSetDefault() {
items.push({ return !props.backend.isDefault && props.backend.enabled
key: 'default',
icon: 'i-lucide-star',
label: t('storage.setDefault'),
shortLabel: isLocal ? t('storage.setDefault') : t('storage.actionSetDefault'),
onClick: () => emit('setDefault')
})
} }
if (props.backend.type === 's3') { function canEdit() {
items.push({ return props.backend.type === 's3' || props.backend.type === 'telegram'
key: 'edit',
icon: 'i-lucide-pencil',
label: t('common.edit'),
shortLabel: t('common.edit'),
onClick: () => emit('edit')
})
} }
items.push({ function canToggle() {
key: 'test', return props.backend.type !== 'local'
icon: 'i-lucide-plug-zap',
label: t('storage.testConnection'),
shortLabel: isLocal ? t('storage.testConnection') : t('storage.actionTest'),
onClick: () => emit('test')
})
if (props.backend.type !== 'local') {
items.push({
key: 'toggle',
icon: props.backend.enabled ? 'i-lucide-pause' : 'i-lucide-play',
label: props.backend.enabled ? t('storage.disable') : t('storage.enable'),
shortLabel: props.backend.enabled ? t('storage.disable') : t('storage.enable'),
disabled: props.backend.isDefault && props.backend.enabled,
onClick: () => emit('toggleEnabled')
})
} }
if (props.backend.type !== 'local' && !props.backend.isDefault) { function canDelete() {
items.push({ return props.backend.type !== 'local' && !props.backend.isDefault
key: 'delete',
icon: 'i-lucide-trash-2',
label: t('common.delete'),
shortLabel: t('common.delete'),
danger: true,
onClick: () => emit('delete')
})
} }
return items
})
const actionGridClass = computed(() => {
switch (actions.value.length) {
case 1: return 'grid-cols-1'
case 2: return 'grid-cols-2'
case 3: return 'grid-cols-3'
case 4: return 'grid-cols-4'
default: return 'grid-cols-5'
}
})
</script> </script>
<template> <template>
<article <article
class="flex h-full flex-col rounded-2xl border border-default bg-elevated p-4 shadow-sm" class="flex h-full flex-col rounded-xl border border-default bg-elevated p-4 sm:p-5"
:class="{ :class="{ 'opacity-75': !backend.enabled }"
'ring-2 ring-primary/30': backend.isDefault,
'opacity-75': !backend.enabled
}"
>
<div class="mb-3 flex items-start justify-between gap-2">
<div class="flex min-w-0 items-start gap-2.5">
<div
class="flex size-9 shrink-0 items-center justify-center rounded-lg bg-primary/10 text-primary"
> >
<div class="flex items-start justify-between gap-3">
<div class="flex min-w-0 items-center gap-3">
<div class="flex size-10 shrink-0 items-center justify-center rounded-lg bg-primary/10 text-primary">
<UIcon <UIcon
:name="typeIcon" :name="typeIcon"
class="size-4.5" class="size-5"
/> />
</div> </div>
<div class="min-w-0"> <h3 class="truncate text-base font-semibold">
<h3 class="truncate text-sm font-semibold">
{{ backend.name }} {{ backend.name }}
</h3> </h3>
<p class="mt-0.5 text-xs text-muted">
{{ typeLabel }}
<template v-if="bucketLine">
· {{ bucketLine }}
</template>
</p>
</div> </div>
</div> <div class="flex shrink-0 flex-wrap justify-end gap-1.5">
<div class="flex shrink-0 flex-wrap justify-end gap-1"> <span
<UBadge v-if="backend.isDefault"
v-if="backend.enabled" class="inline-flex rounded-md bg-primary/10 px-2 py-0.5 text-xs font-medium text-primary"
color="success" >
variant="subtle" {{ t('storage.badgeDefaultStorage') }}
size="xs" </span>
<span
v-if="isLocal"
class="inline-flex rounded-md border border-default bg-default px-2 py-0.5 text-xs font-medium text-muted"
>
{{ t('storage.badgeLocal') }}
</span>
<span
v-else-if="backend.enabled"
class="inline-flex rounded-md border border-default bg-default px-2 py-0.5 text-xs font-medium text-muted"
> >
{{ t('storage.badgeEnabled') }} {{ t('storage.badgeEnabled') }}
</UBadge> </span>
<UBadge <span
v-if="backend.isDefault" v-else
color="primary" class="inline-flex rounded-md bg-muted px-2 py-0.5 text-xs font-medium text-muted"
variant="subtle"
size="xs"
>
{{ t('storage.badgeDefault') }}
</UBadge>
<UBadge
v-if="!backend.enabled"
color="warning"
variant="subtle"
size="xs"
> >
{{ t('storage.badgeDisabled') }} {{ t('storage.badgeDisabled') }}
</UBadge> </span>
</div> </div>
</div> </div>
<div class="mt-4">
<StorageUsageBar <StorageUsageBar
:capacity="backend.capacity" :capacity="backend.capacity"
:index-bytes="backend.usage.bytes" :index-bytes="backend.usage.bytes"
/> />
<p class="mt-3 text-xs text-muted">
{{ t('storage.servingMode') }}: {{ servingLabel }}
</p>
<div class="mt-4 border-t border-default pt-4">
<div
class="grid gap-1.5"
:class="actionGridClass"
>
<button
v-for="action in actions"
:key="action.key"
type="button"
class="flex flex-col items-center justify-center gap-1 rounded-lg border px-1 py-2 transition disabled:cursor-not-allowed disabled:opacity-40"
:class="action.danger
? 'border-error/30 text-error hover:bg-error/5'
: 'border-default text-muted hover:bg-muted/40 hover:text-highlighted'"
:title="action.label"
:aria-label="action.label"
:disabled="actionsDisabled || action.disabled"
@click="action.onClick"
>
<UIcon
:name="action.icon"
class="size-4 shrink-0"
/>
<span class="w-full truncate text-center text-[11px] leading-none">
{{ action.shortLabel }}
</span>
</button>
</div> </div>
<div class="mt-4 flex flex-wrap items-center gap-x-4 gap-y-1 text-xs text-muted">
<div class="flex min-w-0 items-center gap-2">
<span class="shrink-0">{{ t('storage.storageId') }}:</span>
<code class="min-w-0 truncate font-mono text-dimmed">{{ backend.id }}</code>
<CopyButton
icon="i-lucide-copy"
icon-only
variant="ghost"
color="primary"
class="shrink-0"
:label="t('common.copy')"
:value="backend.id"
:success-title="t('copy.copied')"
/>
</div>
<span>
{{ t('storage.storageType') }}: {{ typeLabel }}
</span>
</div>
<div class="mt-4 flex flex-wrap gap-2 border-t border-default pt-4">
<UButton
v-if="canSetDefault()"
:label="t('storage.setDefault')"
icon="i-lucide-star"
size="sm"
variant="outline"
color="neutral"
:disabled="actionsDisabled"
@click="emit('setDefault')"
/>
<UButton
v-if="showMigrateIn"
:label="t('storage.backup.migrateIn')"
:title="t('storage.backup.migrateInHint')"
icon="i-lucide-import"
size="sm"
variant="outline"
color="neutral"
:disabled="actionsDisabled"
@click="emit('migrateIn')"
/>
<UButton
v-if="showMigrateOut"
:label="t('storage.backup.migrateOut')"
:title="t('storage.backup.migrateOutHint')"
icon="i-lucide-arrow-up-from-line"
size="sm"
variant="outline"
color="neutral"
:disabled="actionsDisabled"
@click="emit('migrateOut')"
/>
<UButton
v-if="canEdit()"
:label="t('common.edit')"
icon="i-lucide-pencil"
size="sm"
variant="outline"
color="neutral"
:disabled="actionsDisabled"
@click="emit('edit')"
/>
<UButton
:label="t('storage.testConnection')"
:icon="isLocal ? 'i-lucide-wand-sparkles' : 'i-lucide-activity'"
size="sm"
variant="outline"
color="neutral"
:loading="busy"
:disabled="actionsDisabled"
@click="emit('test')"
/>
<UButton
v-if="canToggle()"
:label="backend.enabled ? t('storage.disable') : t('storage.enable')"
:icon="backend.enabled ? 'i-lucide-pause' : 'i-lucide-play'"
size="sm"
variant="outline"
color="neutral"
:disabled="actionsDisabled || (backend.isDefault && backend.enabled)"
@click="emit('toggleEnabled')"
/>
<UButton
v-if="canDelete()"
:label="t('common.delete')"
icon="i-lucide-trash-2"
size="sm"
variant="outline"
color="error"
:disabled="actionsDisabled"
@click="emit('delete')"
/>
</div> </div>
</article> </article>
</template> </template>
+109 -9
View File
@@ -2,20 +2,25 @@
import type { QuotaUnit } from '~/composables/useFileSize' import type { QuotaUnit } from '~/composables/useFileSize'
import type { StorageBackendItem } from '~/types/storage' import type { StorageBackendItem } from '~/types/storage'
type StorageProvider = 'r2' | 'cos' | 'oss' | 'aws' | 'custom' type StorageProvider = 'r2' | 'cos' | 'oss' | 'aws' | 'custom' | 'telegram'
export interface StorageFormPayload { export interface StorageFormPayload {
backendType: 's3' | 'telegram'
name: string name: string
provider: StorageProvider
config: { config: {
endpoint: string endpoint: string
region: string region: string
bucket: string bucket: string
prefix?: string prefix?: string
forcePathStyle: boolean forcePathStyle: boolean
chatId?: string
apiProxyUrl?: string
} }
secrets: { secrets: {
accessKeyId: string accessKeyId: string
secretAccessKey: string secretAccessKey: string
botToken: string
} }
servingMode: 'proxy' | 'public' servingMode: 'proxy' | 'public'
publicUrl: string publicUrl: string
@@ -46,11 +51,18 @@ const prefix = ref('')
const forcePathStyle = ref(false) const forcePathStyle = ref(false)
const accessKeyId = ref('') const accessKeyId = ref('')
const secretAccessKey = ref('') const secretAccessKey = ref('')
const chatId = ref('')
const botToken = ref('')
const apiProxyUrl = ref('')
const servingMode = ref<'proxy' | 'public'>('proxy') const servingMode = ref<'proxy' | 'public'>('proxy')
const publicUrl = ref('') const publicUrl = ref('')
const quotaValue = ref('') const quotaValue = ref('')
const quotaUnit = ref<QuotaUnit>('GB') const quotaUnit = ref<QuotaUnit>('GB')
const isTelegram = computed(() =>
provider.value === 'telegram' || props.backend?.type === 'telegram'
)
const quotaUnitOptions = computed(() => const quotaUnitOptions = computed(() =>
QUOTA_UNITS.map(unit => ({ label: unit, value: unit })) QUOTA_UNITS.map(unit => ({ label: unit, value: unit }))
) )
@@ -60,7 +72,8 @@ const providerOptions = computed(() => [
{ label: t('storage.providerCos'), value: 'cos' as const }, { label: t('storage.providerCos'), value: 'cos' as const },
{ label: t('storage.providerOss'), value: 'oss' as const }, { label: t('storage.providerOss'), value: 'oss' as const },
{ label: t('storage.providerAws'), value: 'aws' as const }, { label: t('storage.providerAws'), value: 'aws' as const },
{ label: t('storage.providerCustom'), value: 'custom' as const } { label: t('storage.providerCustom'), value: 'custom' as const },
{ label: t('storage.providerTelegram'), value: 'telegram' as const }
]) ])
const servingModeOptions = computed(() => [ const servingModeOptions = computed(() => [
@@ -78,6 +91,10 @@ function applyProviderTemplate(value: StorageProvider) {
if (props.createMode) { if (props.createMode) {
name.value = providerLabel(value) name.value = providerLabel(value)
} }
if (value === 'telegram') {
servingMode.value = 'proxy'
return
}
switch (value) { switch (value) {
case 'r2': case 'r2':
endpoint.value = 'https://{account_id}.r2.cloudflarestorage.com' endpoint.value = 'https://{account_id}.r2.cloudflarestorage.com'
@@ -113,9 +130,7 @@ function detectProvider(config: StorageBackendItem['config']): StorageProvider {
return 'custom' return 'custom'
} }
function loadBackend(backend: StorageBackendItem | null | undefined) { function resetCreateDefaults() {
if (!backend || backend.type !== 's3') {
if (props.createMode) {
name.value = '' name.value = ''
provider.value = 'r2' provider.value = 'r2'
applyProviderTemplate('r2') applyProviderTemplate('r2')
@@ -123,11 +138,36 @@ function loadBackend(backend: StorageBackendItem | null | undefined) {
prefix.value = '' prefix.value = ''
accessKeyId.value = '' accessKeyId.value = ''
secretAccessKey.value = '' secretAccessKey.value = ''
chatId.value = ''
botToken.value = ''
apiProxyUrl.value = ''
servingMode.value = 'proxy' servingMode.value = 'proxy'
publicUrl.value = '' publicUrl.value = ''
quotaValue.value = '' quotaValue.value = ''
quotaUnit.value = 'GB' quotaUnit.value = 'GB'
} }
function loadBackend(backend: StorageBackendItem | null | undefined) {
if (!backend) {
if (props.createMode) resetCreateDefaults()
return
}
if (backend.type === 'telegram') {
name.value = backend.name
provider.value = 'telegram'
chatId.value = backend.config.chatId ?? ''
apiProxyUrl.value = backend.config.apiProxyUrl ?? ''
botToken.value = ''
const quota = bytesToQuotaInput(backend.quotaBytes)
quotaValue.value = quota.value
quotaUnit.value = quota.unit
servingMode.value = 'proxy'
return
}
if (backend.type !== 's3') {
if (props.createMode) resetCreateDefaults()
return return
} }
@@ -156,20 +196,26 @@ watch(provider, (value) => {
}) })
function handleSubmit() { function handleSubmit() {
const backendType = isTelegram.value ? 'telegram' : 's3'
emit('submit', { emit('submit', {
backendType,
name: name.value.trim() || providerLabel(provider.value), name: name.value.trim() || providerLabel(provider.value),
provider: provider.value,
config: { config: {
endpoint: endpoint.value.trim(), endpoint: endpoint.value.trim(),
region: region.value.trim() || 'auto', region: region.value.trim() || 'auto',
bucket: bucket.value.trim(), bucket: bucket.value.trim(),
prefix: prefix.value.trim() || undefined, prefix: prefix.value.trim() || undefined,
forcePathStyle: forcePathStyle.value forcePathStyle: forcePathStyle.value,
chatId: chatId.value.trim() || undefined,
apiProxyUrl: apiProxyUrl.value.trim() || undefined
}, },
secrets: { secrets: {
accessKeyId: accessKeyId.value.trim(), accessKeyId: accessKeyId.value.trim(),
secretAccessKey: secretAccessKey.value.trim() secretAccessKey: secretAccessKey.value.trim(),
botToken: botToken.value.trim()
}, },
servingMode: servingMode.value, servingMode: isTelegram.value ? 'proxy' : servingMode.value,
publicUrl: publicUrl.value.trim(), publicUrl: publicUrl.value.trim(),
quotaValue: String(quotaValue.value ?? '').trim(), quotaValue: String(quotaValue.value ?? '').trim(),
quotaUnit: quotaUnit.value quotaUnit: quotaUnit.value
@@ -188,6 +234,7 @@ function handleSubmit() {
v-model="provider" v-model="provider"
:items="providerOptions" :items="providerOptions"
class="w-full" class="w-full"
:disabled="!createMode && backend?.type === 'telegram'"
/> />
</div> </div>
@@ -199,6 +246,58 @@ function handleSubmit() {
/> />
</div> </div>
<template v-if="isTelegram">
<div class="space-y-2">
<label class="text-sm">
{{ t('storage.telegramChatId') }}
<span class="text-error">*</span>
</label>
<UInput
v-model="chatId"
:placeholder="t('storage.telegramChatIdPlaceholder')"
class="w-full font-mono text-sm"
required
/>
<p class="text-xs text-muted">
{{ t('storage.telegramChatIdHint') }}
</p>
</div>
<div class="space-y-2">
<label class="text-sm">
{{ t('storage.telegramBotToken') }}
<span
v-if="createMode"
class="text-error"
>*</span>
</label>
<UInput
v-model="botToken"
type="password"
:placeholder="backend?.secretsMasked.botToken || ''"
class="w-full font-mono text-sm"
:required="createMode"
/>
</div>
<div class="space-y-2">
<label class="text-sm">{{ t('storage.telegramApiProxy') }}</label>
<UInput
v-model="apiProxyUrl"
:placeholder="t('storage.telegramApiProxyPlaceholder')"
class="w-full font-mono text-sm"
/>
<p class="text-xs text-muted">
{{ t('storage.telegramApiProxyHint') }}
</p>
</div>
<p class="text-xs text-muted">
{{ t('storage.telegramServingHint') }}
</p>
</template>
<template v-else>
<div class="space-y-2"> <div class="space-y-2">
<label class="text-sm">{{ t('storage.endpoint') }}</label> <label class="text-sm">{{ t('storage.endpoint') }}</label>
<UInput <UInput
@@ -298,6 +397,7 @@ function handleSubmit() {
class="w-full font-mono text-sm" class="w-full font-mono text-sm"
/> />
</div> </div>
</template>
<div class="space-y-2"> <div class="space-y-2">
<label class="text-sm"> <label class="text-sm">
@@ -322,7 +422,7 @@ function handleSubmit() {
/> />
</div> </div>
<p class="text-xs text-muted"> <p class="text-xs text-muted">
{{ t('storage.quotaHint') }} {{ isTelegram ? t('storage.telegramQuotaHint') : t('storage.quotaHint') }}
</p> </p>
</div> </div>
@@ -0,0 +1,219 @@
<script setup lang="ts">
import type {
BackupJobItem,
SiteSyncRequest,
SyncDraft
} from '~/types/backup'
import type { StorageBackendItem } from '~/types/storage'
const props = defineProps<{
backends: StorageBackendItem[]
}>()
const { t } = useI18n()
const toast = useToast()
const {
jobs,
jobsPage,
jobsPageSize,
jobsTotal,
jobsTotalPages,
jobsLoading,
showHiddenJobs,
estimate,
packages,
loadingEstimate,
polling,
hasActiveJob,
getActiveJob,
loadJobs,
toggleShowHiddenJobs,
hideJob,
hideAllJobs,
setJobsPage,
setJobsPageSize,
loadEstimate,
startPolling,
createExport,
previewRestore,
confirmRestore,
previewSync,
startSync,
startSiteSync,
retryJob,
cancelJob,
downloadUrl
} = useBackup()
const syncDraft = ref<SyncDraft | null>(null)
const syncCardRef = ref<{ applyDraft: (draft: SyncDraft) => void } | null>(null)
const panelRef = ref<HTMLElement | null>(null)
const syncHighlighted = ref(false)
const activeJob = computed(() => getActiveJob())
const exportBusy = computed(() => activeJob.value?.type === 'full_export')
const restoreBusy = computed(() => activeJob.value?.type === 'full_restore')
const syncBusy = computed(() => activeJob.value?.type === 'backend_sync')
const siteSyncBusy = computed(() => activeJob.value?.type === 'site_sync')
const resumableSiteSyncJob = computed(() =>
jobs.value.find(job =>
job.type === 'site_sync'
&& job.status === 'failed'
&& job.payload.resumable === true
) ?? null
)
const anyJobBusy = computed(() => Boolean(activeJob.value))
onMounted(async () => {
await Promise.all([loadJobs(), loadEstimate()])
if (hasActiveJob()) {
startPolling()
}
})
function applySyncDraft(draft: SyncDraft) {
syncDraft.value = draft
syncCardRef.value?.applyDraft(draft)
syncHighlighted.value = true
window.setTimeout(() => {
syncHighlighted.value = false
}, 2500)
nextTick(() => {
panelRef.value?.scrollIntoView({ behavior: 'smooth', block: 'start' })
})
toast.add({ title: t('storage.backup.syncDraftApplied'), color: 'info' })
}
defineExpose({ applySyncDraft })
async function onExport() {
await createExport()
}
async function onRestore(input: {
file?: File
archiveName?: string
conflictMode: 'skip' | 'overwrite'
}) {
await confirmRestore(input)
}
async function onCancel(jobId: string) {
await cancelJob(jobId)
}
async function onHideJob(jobId: string, hidden: boolean) {
await hideJob(jobId, hidden)
}
async function onHideAll(hidden: boolean) {
await hideAllJobs(hidden)
}
async function onSync(body: {
fromBackendId: string
toBackendId: string
deleteSource: boolean
setDefault: boolean
}) {
await startSync(body)
}
async function onSiteSync(body: SiteSyncRequest) {
await startSiteSync(body)
}
function onDownload(jobId: string) {
window.open(downloadUrl(jobId), '_blank')
}
async function onRetry(jobId: string) {
await retryJob(jobId)
}
function onViewJob(job: BackupJobItem) {
toast.add({
title: t('storage.backup.jobTypeRestore'),
description: job.message,
color: 'info'
})
}
</script>
<template>
<section
ref="panelRef"
class="scroll-mt-20 space-y-4 border-t border-default pt-5"
>
<header>
<h2 class="text-base font-semibold">
{{ t('storage.backup.sectionTitle') }}
</h2>
<p class="mt-1 text-sm text-muted">
{{ t('storage.backup.sectionSubtitle') }}
</p>
</header>
<div class="grid grid-cols-1 items-stretch gap-4 xl:grid-cols-3">
<StorageBackupExportCard
class="h-full"
:estimate="estimate"
:loading="loadingEstimate"
:busy="exportBusy"
:active-job="exportBusy ? activeJob : null"
:disabled="anyJobBusy && !exportBusy"
@export="onExport"
/>
<StorageBackupRestoreCard
class="h-full"
:packages="packages"
:busy="anyJobBusy"
:restore-busy="restoreBusy"
:active-job="restoreBusy ? activeJob : null"
:disabled="anyJobBusy && !restoreBusy"
:preview-restore="previewRestore"
@restore="onRestore"
/>
<StorageBackendSyncCard
ref="syncCardRef"
class="h-full"
:backends="props.backends"
:sync-draft="syncDraft"
:highlighted="syncHighlighted"
:busy="syncBusy"
:active-job="syncBusy ? activeJob : null"
:disabled="anyJobBusy && !syncBusy"
:preview-sync="previewSync"
@sync="onSync"
/>
</div>
<StorageSiteSyncCard
:busy="siteSyncBusy"
:disabled="anyJobBusy && !siteSyncBusy"
:active-job="siteSyncBusy ? activeJob : null"
:resumable-job="resumableSiteSyncJob"
@sync="onSiteSync"
/>
<StorageBackupJobTable
:jobs="jobs"
:polling="polling"
:page="jobsPage"
:total-pages="jobsTotalPages"
:total="jobsTotal"
:page-size="jobsPageSize"
:loading="jobsLoading"
:show-hidden="showHiddenJobs"
@update:page="setJobsPage"
@update:page-size="setJobsPageSize"
@download="onDownload"
@retry="onRetry"
@cancel="onCancel"
@hide="onHideJob"
@hide-all="onHideAll"
@toggle-hidden="toggleShowHiddenJobs"
@view="onViewJob"
/>
</section>
</template>
+184
View File
@@ -0,0 +1,184 @@
<script setup lang="ts">
import type { StorageBackendItem } from '~/types/storage'
const props = defineProps<{
backends: StorageBackendItem[]
loading?: boolean
}>()
const { t } = useI18n()
const { formatFileSize } = useFileSize()
const CHART_COLORS = [
'var(--ui-primary)',
'#3b82f6',
'#6366f1',
'#f59e0b',
'#ec4899',
'#8b5cf6'
] as const
function backendUsedBytes(backend: StorageBackendItem) {
return backend.capacity.source === 'disk'
? backend.capacity.usedBytes
: backend.usage.bytes
}
const totalCapacityBytes = computed(() => {
let total = 0
let hasTotal = false
for (const backend of props.backends) {
if (backend.capacity.totalBytes != null && backend.capacity.totalBytes > 0) {
total += backend.capacity.totalBytes
hasTotal = true
}
}
return hasTotal ? total : null
})
const totalUsedBytes = computed(() =>
props.backends.reduce((sum, backend) => sum + backendUsedBytes(backend), 0)
)
const totalFreeBytes = computed(() => {
if (!totalCapacityBytes.value) return null
return Math.max(0, totalCapacityBytes.value - totalUsedBytes.value)
})
const usagePercent = computed(() => {
if (!totalCapacityBytes.value) return null
return Math.min(Math.round((totalUsedBytes.value / totalCapacityBytes.value) * 1000) / 10, 100)
})
const usageBarWidth = computed(() => {
if (usagePercent.value == null) return 0
return Math.max(usagePercent.value, totalUsedBytes.value > 0 ? 4 : 0)
})
const distributionItems = computed(() => {
if (!totalCapacityBytes.value) return []
return props.backends
.filter(backend => backend.capacity.totalBytes != null && backend.capacity.totalBytes > 0)
.map((backend, index) => ({
id: backend.id,
name: backend.name,
color: CHART_COLORS[index % CHART_COLORS.length]!,
percent: Math.round((backend.capacity.totalBytes! / totalCapacityBytes.value!) * 1000) / 10
}))
})
const donutStyle = computed(() => {
const items = distributionItems.value
if (!items.length) return { background: 'var(--ui-bg-muted)' }
let cursor = 0
const segments = items.map((item) => {
const start = cursor
cursor += item.percent
return `${item.color} ${start}% ${cursor}%`
})
return { background: `conic-gradient(${segments.join(', ')})` }
})
function displaySize(bytes: number | null) {
if (props.loading) return '—'
if (bytes == null) return '—'
return formatFileSize(bytes)
}
</script>
<template>
<section class="rounded-xl border border-default bg-elevated p-4 sm:p-5">
<h2 class="text-sm font-semibold">
{{ t('storage.overviewTitle') }}
</h2>
<p class="mt-0.5 text-xs text-muted">
{{ t('storage.overviewSubtitle') }}
</p>
<div class="mt-4 grid gap-5 lg:grid-cols-[minmax(0,1fr)_auto] lg:items-center">
<div>
<div class="grid grid-cols-2 gap-4 sm:grid-cols-4">
<div>
<p class="text-xs text-muted">
{{ t('storage.overviewTotal') }}
</p>
<p class="mt-1 text-lg font-semibold tabular-nums">
{{ displaySize(totalCapacityBytes) }}
</p>
<div
v-if="totalCapacityBytes"
class="mt-2 h-1 overflow-hidden rounded-full bg-muted/50"
>
<div
class="h-full rounded-full bg-primary transition-all"
:style="{ width: `${usageBarWidth}%` }"
/>
</div>
</div>
<div>
<p class="text-xs text-muted">
{{ t('storage.overviewUsed') }}
</p>
<p class="mt-1 text-lg font-semibold tabular-nums">
{{ displaySize(totalUsedBytes) }}
</p>
</div>
<div>
<p class="text-xs text-muted">
{{ t('storage.overviewFree') }}
</p>
<p class="mt-1 text-lg font-semibold tabular-nums">
{{ displaySize(totalFreeBytes) }}
</p>
</div>
<div>
<p class="text-xs text-muted">
{{ t('storage.overviewUsageRate') }}
</p>
<p class="mt-1 text-lg font-semibold tabular-nums">
{{ usagePercent != null ? `${usagePercent}%` : '—' }}
</p>
</div>
</div>
</div>
<div class="flex items-center gap-4">
<div
class="relative size-24 shrink-0 rounded-full p-2"
:style="donutStyle"
>
<div class="flex size-full items-center justify-center rounded-full bg-elevated px-2 text-center">
<p class="text-[10px] leading-tight text-muted">
{{ t('storage.capacityDistribution') }}
</p>
</div>
</div>
<ul
v-if="distributionItems.length"
class="min-w-[8rem] space-y-2"
>
<li
v-for="item in distributionItems"
:key="item.id"
class="flex items-center justify-between gap-3 text-xs"
>
<span class="flex min-w-0 items-center gap-1.5">
<span
class="size-2 shrink-0 rounded-full"
:style="{ background: item.color }"
/>
<span class="truncate">{{ item.name }}</span>
</span>
<span class="shrink-0 tabular-nums text-muted">
{{ item.percent }}%
</span>
</li>
</ul>
</div>
</div>
</section>
</template>
@@ -0,0 +1,69 @@
<script setup lang="ts">
const { t } = useI18n()
const items = computed(() => [
{
icon: 'i-lucide-hard-drive',
iconClass: 'text-primary',
boxClass: 'border-primary/20 bg-primary/5',
title: t('storage.backendLocal'),
desc: t('storage.supportedLocalDesc')
},
{
icon: 'i-lucide-cloud',
iconClass: 'text-orange-500',
boxClass: 'border-orange-500/20 bg-orange-500/5',
title: 'Cloudflare R2',
desc: t('storage.supportedR2Desc')
},
{
icon: 'i-lucide-server',
iconClass: 'text-blue-500',
boxClass: 'border-blue-500/20 bg-blue-500/5',
title: t('storage.supportedS3Title'),
desc: t('storage.supportedS3Desc')
},
{
icon: 'i-lucide-send',
iconClass: 'text-sky-500',
boxClass: 'border-sky-500/20 bg-sky-500/5',
title: t('storage.backendTelegram'),
desc: t('storage.supportedTelegramDesc')
}
])
</script>
<template>
<section class="rounded-xl border border-default bg-elevated p-4 sm:p-5">
<h2 class="text-sm font-semibold">
{{ t('storage.supportedTitle') }}
</h2>
<div class="mt-4 grid grid-cols-1 gap-3 lg:grid-cols-3">
<div
v-for="item in items"
:key="item.title"
class="flex items-start gap-3 rounded-lg border border-default bg-muted/30 px-4 py-3"
>
<div
class="flex size-10 shrink-0 items-center justify-center rounded-lg border"
:class="item.boxClass"
>
<UIcon
:name="item.icon"
class="size-5"
:class="item.iconClass"
/>
</div>
<div class="min-w-0 pt-0.5">
<p class="text-sm font-semibold">
{{ item.title }}
</p>
<p class="mt-0.5 text-xs leading-relaxed text-muted">
{{ item.desc }}
</p>
</div>
</div>
</div>
</section>
</template>
+26 -29
View File
@@ -36,56 +36,53 @@ function formatOptional(bytes: number | null): string {
<template> <template>
<div class="space-y-3"> <div class="space-y-3">
<div class="grid grid-cols-3 gap-2 text-center text-xs"> <div class="grid grid-cols-2 gap-3 sm:grid-cols-4">
<div class="rounded-lg bg-muted/30 px-2 py-2"> <div>
<p class="text-muted"> <p class="text-xs text-muted">
{{ $t('storage.statTotal') }}
</p>
<p class="mt-1 font-medium tabular-nums text-highlighted">
{{ formatOptional(capacity.totalBytes) }}
</p>
</div>
<div class="rounded-lg bg-muted/30 px-2 py-2">
<p class="text-muted">
{{ $t('storage.statUsed') }} {{ $t('storage.statUsed') }}
</p> </p>
<p class="mt-1 font-medium tabular-nums text-highlighted"> <p class="mt-1 text-sm font-semibold tabular-nums">
{{ formatFileSize(displayUsedBytes) }} {{ formatFileSize(displayUsedBytes) }}
</p> </p>
</div> </div>
<div class="rounded-lg bg-muted/30 px-2 py-2"> <div>
<p class="text-muted"> <p class="text-xs text-muted">
{{ $t('storage.statTotal') }}
</p>
<p class="mt-1 text-sm font-semibold tabular-nums">
{{ formatOptional(capacity.totalBytes) }}
</p>
</div>
<div>
<p class="text-xs text-muted">
{{ $t('storage.statFree') }} {{ $t('storage.statFree') }}
</p> </p>
<p <p
class="mt-1 font-medium tabular-nums" class="mt-1 text-sm font-semibold tabular-nums"
:class="overQuota ? 'text-error' : 'text-highlighted'" :class="overQuota ? 'text-error' : ''"
> >
{{ formatOptional(capacity.freeBytes) }} {{ formatOptional(capacity.freeBytes) }}
</p> </p>
</div> </div>
<div>
<p class="text-xs text-muted">
{{ $t('storage.usageRate') }}
</p>
<p class="mt-1 text-sm font-semibold tabular-nums">
{{ hasBar ? `${displayPercent}%` : '—' }}
</p>
</div>
</div> </div>
<div <div
v-if="hasBar" v-if="hasBar"
class="space-y-1" class="h-1 overflow-hidden rounded-full bg-muted/50"
> >
<div class="flex items-center justify-between text-xs">
<span class="text-muted">{{ $t('storage.usageRate') }}</span>
<span
class="tabular-nums font-medium"
:class="overQuota ? 'text-error' : ''"
>
{{ displayPercent }}%
</span>
</div>
<div class="h-1.5 overflow-hidden rounded-full bg-muted/50">
<div <div
class="h-full rounded-full transition-all" class="h-full rounded-full transition-all"
:class="overQuota ? 'bg-error/80' : 'bg-primary/70'" :class="overQuota ? 'bg-error/80' : 'bg-primary'"
:style="{ width: `${barWidth}%` }" :style="{ width: `${barWidth}%` }"
/> />
</div> </div>
</div> </div>
</div>
</template> </template>
+984
View File
@@ -0,0 +1,984 @@
export type ApiDocMethod = 'GET' | 'POST' | 'DELETE'
export type ApiDocCategory = 'image' | 'video' | 'file' | 'extension' | 'system'
export type ApiParamLocation = 'form-data' | 'query' | 'header' | 'body'
export interface ApiDocParam {
name: string
type: string
location: ApiParamLocation
required: boolean
descriptionKey: string
}
export interface ApiDocEndpoint {
id: string
category: ApiDocCategory
method: ApiDocMethod
path: string
titleKey: string
descKey: string
params: ApiDocParam[]
requiresAuth: boolean
curl: string
responseExample: string
}
interface ApiDocsContext {
baseUrl: string
authHeader: string
}
function authFlag(authHeader: string) {
return `-H 'Auth-Token: ${authHeader}'`
}
export function buildApiDocs(ctx: ApiDocsContext): ApiDocEndpoint[] {
const { baseUrl, authHeader } = ctx
const auth = authFlag(authHeader)
return [
{
id: 'upload',
category: 'image',
method: 'POST',
path: '/api/images/upload',
titleKey: 'api.docs.upload.title',
descKey: 'api.docs.upload.desc',
requiresAuth: true,
params: [
{
name: 'image',
type: 'file',
location: 'form-data',
required: true,
descriptionKey: 'api.params.upload.image'
},
{
name: 'file',
type: 'file',
location: 'form-data',
required: false,
descriptionKey: 'api.params.upload.file'
},
{
name: 'files',
type: 'file',
location: 'form-data',
required: false,
descriptionKey: 'api.params.upload.files'
},
{
name: 'token',
type: 'string',
location: 'form-data',
required: false,
descriptionKey: 'api.params.upload.token'
},
{
name: 'tagNames',
type: 'string',
location: 'form-data',
required: false,
descriptionKey: 'api.params.upload.tagNames'
}
],
curl: `curl -X POST "${baseUrl}/api/images/upload" \\
${auth} \\
-F "image=@./demo.png" \\
-F 'tagNames=["博客","截图"]'`,
responseExample: `{
"success": true,
"items": [
{
"key": "images/2026/08/abc123.webp",
"url": "https://pic.example.com/abc123.webp",
"originalName": "demo.png",
"contentType": "image/webp",
"size": 12345,
"uploadedAt": "2026-08-01T12:00:00.000Z",
"markdown": "![demo.png](https://pic.example.com/abc123.webp)",
"html": "<img src=\\"https://pic.example.com/abc123.webp\\" alt=\\"demo.png\\">"
}
],
"errors": []
}`
},
{
id: 'list',
category: 'image',
method: 'GET',
path: '/api/images',
titleKey: 'api.docs.list.title',
descKey: 'api.docs.list.desc',
requiresAuth: true,
params: [
{
name: 'limit',
type: 'number',
location: 'query',
required: false,
descriptionKey: 'api.params.list.limit'
},
{
name: 'page',
type: 'number',
location: 'query',
required: false,
descriptionKey: 'api.params.list.page'
},
{
name: 'backendId',
type: 'string',
location: 'query',
required: false,
descriptionKey: 'api.params.backendId'
}
],
curl: `curl "${baseUrl}/api/images?limit=20&page=1" \\
${auth}`,
responseExample: `{
"items": [
{
"key": "images/2026/08/abc123.webp",
"url": "https://pic.example.com/abc123.webp",
"originalName": "demo.png",
"contentType": "image/webp",
"size": 12345,
"uploadedAt": "2026-08-01T12:00:00.000Z",
"markdown": "![demo.png](https://pic.example.com/abc123.webp)",
"html": "<img src=\\"https://pic.example.com/abc123.webp\\" alt=\\"demo.png\\">"
}
],
"total": 1,
"page": 1,
"pageSize": 20,
"totalPages": 1
}`
},
{
id: 'search',
category: 'image',
method: 'GET',
path: '/api/images/search',
titleKey: 'api.docs.search.title',
descKey: 'api.docs.search.desc',
requiresAuth: true,
params: [
{
name: 'q',
type: 'string',
location: 'query',
required: true,
descriptionKey: 'api.params.search.q'
},
{
name: 'limit',
type: 'number',
location: 'query',
required: false,
descriptionKey: 'api.params.list.limit'
},
{
name: 'page',
type: 'number',
location: 'query',
required: false,
descriptionKey: 'api.params.list.page'
},
{
name: 'backendId',
type: 'string',
location: 'query',
required: false,
descriptionKey: 'api.params.backendId'
}
],
curl: `curl "${baseUrl}/api/images/search?q=demo&limit=20&page=1" \\
${auth}`,
responseExample: `{
"items": [],
"total": 0,
"page": 1,
"pageSize": 20,
"totalPages": 0
}`
},
{
id: 'delete',
category: 'image',
method: 'DELETE',
path: '/api/images',
titleKey: 'api.docs.delete.title',
descKey: 'api.docs.delete.desc',
requiresAuth: true,
params: [
{
name: 'key',
type: 'string',
location: 'query',
required: true,
descriptionKey: 'api.params.delete.key'
}
],
curl: `curl -X DELETE "${baseUrl}/api/images?key=images/2026/08/xxxx.webp" \\
${auth}`,
responseExample: `{
"success": true
}`
},
{
id: 'batchDelete',
category: 'image',
method: 'POST',
path: '/api/images/batch-delete',
titleKey: 'api.docs.batchDelete.title',
descKey: 'api.docs.batchDelete.desc',
requiresAuth: true,
params: [
{
name: 'keys',
type: 'string[]',
location: 'body',
required: true,
descriptionKey: 'api.params.batchDelete.keys'
}
],
curl: `curl -X POST "${baseUrl}/api/images/batch-delete" \\
${auth} \\
-H "Content-Type: application/json" \\
-d '{"keys":["images/2026/08/a.webp","images/2026/08/b.webp"]}'`,
responseExample: `{
"success": true,
"deleted": ["images/2026/08/a.webp"],
"failed": []
}`
},
{
id: 'videoUpload',
category: 'video',
method: 'POST',
path: '/api/videos/upload',
titleKey: 'api.docs.videoUpload.title',
descKey: 'api.docs.videoUpload.desc',
requiresAuth: true,
params: [
{
name: 'video',
type: 'file',
location: 'form-data',
required: true,
descriptionKey: 'api.params.videoUpload.video'
},
{
name: 'file',
type: 'file',
location: 'form-data',
required: false,
descriptionKey: 'api.params.videoUpload.file'
},
{
name: 'files',
type: 'file',
location: 'form-data',
required: false,
descriptionKey: 'api.params.videoUpload.files'
},
{
name: 'transcode',
type: 'string',
location: 'form-data',
required: false,
descriptionKey: 'api.params.videoUpload.transcode'
},
{
name: 'transcodePreset',
type: 'string',
location: 'form-data',
required: false,
descriptionKey: 'api.params.videoUpload.transcodePreset'
},
{
name: 'transcodeBitratePercent',
type: 'number',
location: 'form-data',
required: false,
descriptionKey: 'api.params.videoUpload.transcodeBitratePercent'
},
{
name: 'token',
type: 'string',
location: 'form-data',
required: false,
descriptionKey: 'api.params.videoUpload.token'
},
{
name: 'tagNames',
type: 'string',
location: 'form-data',
required: false,
descriptionKey: 'api.params.upload.tagNames'
}
],
curl: `curl -X POST "${baseUrl}/api/videos/upload" \\
${auth} \\
-F "video=@./demo.mp4" \\
-F "transcode=1" \\
-F "transcodePreset=compress" \\
-F "transcodeBitratePercent=80" \\
-F 'tagNames=["博客","视频"]'`,
responseExample: `{
"success": true,
"items": [
{
"key": "videos/2026/10/abc123.mp4",
"url": "https://pic.example.com/videos/2026/10/abc123.mp4",
"originalName": "demo.mp4",
"contentType": "video/mp4",
"size": 10485760,
"uploadedAt": "2026-10-17T03:00:00.000Z",
"markdown": "[demo.mp4](https://pic.example.com/videos/2026/10/abc123.mp4)",
"thumbUrl": "https://pic.example.com/videos/2026/10/abc123.webp",
"tags": [
{ "id": 3, "name": "博客", "color": "#22c55e" },
{ "id": 8, "name": "视频", "color": "#3b82f6" }
]
}
],
"errors": [],
"accessMode": "public"
}`
},
{
id: 'videoList',
category: 'video',
method: 'GET',
path: '/api/files',
titleKey: 'api.docs.videoList.title',
descKey: 'api.docs.videoList.desc',
requiresAuth: true,
params: [
{
name: 'kind',
type: 'string',
location: 'query',
required: true,
descriptionKey: 'api.params.mediaList.kindVideo'
},
{
name: 'page',
type: 'number',
location: 'query',
required: false,
descriptionKey: 'api.params.mediaList.page'
},
{
name: 'limit',
type: 'number',
location: 'query',
required: false,
descriptionKey: 'api.params.mediaList.limit'
},
{
name: 'q',
type: 'string',
location: 'query',
required: false,
descriptionKey: 'api.params.mediaList.q'
},
{
name: 'processing',
type: 'string',
location: 'query',
required: false,
descriptionKey: 'api.params.mediaList.processing'
},
{
name: 'backendId',
type: 'string',
location: 'query',
required: false,
descriptionKey: 'api.params.backendId'
},
{
name: 'uploadSource',
type: 'string',
location: 'query',
required: false,
descriptionKey: 'api.params.mediaList.uploadSource'
},
{
name: 'tagIds',
type: 'string',
location: 'query',
required: false,
descriptionKey: 'api.params.mediaList.tagIds'
},
{
name: 'tagMode',
type: 'string',
location: 'query',
required: false,
descriptionKey: 'api.params.mediaList.tagMode'
},
{
name: 'untagged',
type: 'string',
location: 'query',
required: false,
descriptionKey: 'api.params.mediaList.untagged'
}
],
curl: `curl "${baseUrl}/api/files?kind=video&page=1&limit=20" \\
${auth}`,
responseExample: `{
"items": [
{
"key": "videos/2026/10/abc123.mp4",
"url": "https://pic.example.com/videos/2026/10/abc123.mp4",
"originalName": "demo.mp4",
"contentType": "video/mp4",
"size": 3580000,
"uploadedAt": "2026-10-17T03:00:00.000Z",
"transcodeStatus": "done",
"transcodeProgress": 100,
"transcodePreset": "veryfast",
"sourceDeleted": true,
"thumbUrl": "https://pic.example.com/videos/2026/10/abc123.webp",
"hlsUrl": "https://pic.example.com/videos/2026/10/abc123/index.m3u8",
"hlsKey": "videos/2026/10/abc123/index.m3u8"
}
],
"total": 1,
"page": 1,
"pageSize": 20,
"totalPages": 1,
"accessMode": "public"
}`
},
{
id: 'videoDelete',
category: 'video',
method: 'DELETE',
path: '/api/files',
titleKey: 'api.docs.videoDelete.title',
descKey: 'api.docs.videoDelete.desc',
requiresAuth: true,
params: [
{
name: 'key',
type: 'string',
location: 'query',
required: true,
descriptionKey: 'api.params.videoDelete.key'
}
],
curl: `curl -X DELETE "${baseUrl}/api/files?key=videos/2026/10/abc123.mp4" \\
${auth}`,
responseExample: `{
"success": true
}`
},
{
id: 'videoSearch',
category: 'video',
method: 'GET',
path: '/api/videos/search',
titleKey: 'api.docs.videoSearch.title',
descKey: 'api.docs.videoSearch.desc',
requiresAuth: true,
params: [
{
name: 'q',
type: 'string',
location: 'query',
required: true,
descriptionKey: 'api.params.mediaSearch.q'
},
{
name: 'page',
type: 'number',
location: 'query',
required: false,
descriptionKey: 'api.params.mediaList.page'
},
{
name: 'limit',
type: 'number',
location: 'query',
required: false,
descriptionKey: 'api.params.mediaList.limit'
},
{
name: 'backendId',
type: 'string',
location: 'query',
required: false,
descriptionKey: 'api.params.backendId'
},
{
name: 'uploadSource',
type: 'string',
location: 'query',
required: false,
descriptionKey: 'api.params.mediaList.uploadSource'
},
{
name: 'tagIds',
type: 'string',
location: 'query',
required: false,
descriptionKey: 'api.params.mediaList.tagIds'
},
{
name: 'tagMode',
type: 'string',
location: 'query',
required: false,
descriptionKey: 'api.params.mediaList.tagMode'
},
{
name: 'untagged',
type: 'string',
location: 'query',
required: false,
descriptionKey: 'api.params.mediaList.untagged'
},
{
name: 'processing',
type: 'string',
location: 'query',
required: false,
descriptionKey: 'api.params.mediaList.processing'
}
],
curl: `curl "${baseUrl}/api/videos/search?q=demo&page=1&limit=20" \\
${auth}`,
responseExample: `{
"items": [
{
"key": "videos/2026/10/demo.mp4",
"originalName": "demo.mp4",
"contentType": "video/mp4",
"size": 10485760,
"transcodeStatus": "done",
"thumbUrl": "https://pic.example.com/videos/2026/10/demo.webp",
"hlsUrl": "https://pic.example.com/videos/2026/10/demo/index.m3u8"
}
],
"total": 1,
"page": 1,
"pageSize": 20,
"totalPages": 1,
"accessMode": "public"
}`
},
{
id: 'videoBatchDelete',
category: 'video',
method: 'POST',
path: '/api/files/batch-delete',
titleKey: 'api.docs.videoBatchDelete.title',
descKey: 'api.docs.videoBatchDelete.desc',
requiresAuth: true,
params: [
{
name: 'keys',
type: 'string[]',
location: 'body',
required: true,
descriptionKey: 'api.params.videoBatchDelete.keys'
}
],
curl: `curl -X POST "${baseUrl}/api/files/batch-delete" \\
${auth} \\
-H "Content-Type: application/json" \\
-d '{"keys":["videos/2026/10/a.mp4","videos/2026/10/b.mp4"]}'`,
responseExample: `{
"success": true,
"deleted": [
"videos/2026/10/a.mp4",
"videos/2026/10/b.mp4"
],
"failed": []
}`
},
{
id: 'fileUpload',
category: 'file',
method: 'POST',
path: '/api/files/upload',
titleKey: 'api.docs.fileUpload.title',
descKey: 'api.docs.fileUpload.desc',
requiresAuth: true,
params: [
{
name: 'file',
type: 'file',
location: 'form-data',
required: true,
descriptionKey: 'api.params.fileUpload.file'
},
{
name: 'files',
type: 'file',
location: 'form-data',
required: false,
descriptionKey: 'api.params.fileUpload.files'
},
{
name: 'token',
type: 'string',
location: 'form-data',
required: false,
descriptionKey: 'api.params.fileUpload.token'
},
{
name: 'tagNames',
type: 'string',
location: 'form-data',
required: false,
descriptionKey: 'api.params.upload.tagNames'
}
],
curl: `curl -X POST "${baseUrl}/api/files/upload" \\
${auth} \\
-F "file=@./report.pdf" \\
-F 'tagNames=["文档","归档"]'`,
responseExample: `{
"success": true,
"items": [
{
"key": "files/2026/10/abc123.pdf",
"url": "https://pic.example.com/files/2026/10/abc123.pdf",
"originalName": "report.pdf",
"contentType": "application/pdf",
"size": 5242880,
"uploadedAt": "2026-10-17T03:00:00.000Z",
"markdown": "[report.pdf](https://pic.example.com/files/2026/10/abc123.pdf)",
"tags": [
{ "id": 4, "name": "文档", "color": "#f59e0b" },
{ "id": 9, "name": "归档", "color": "#64748b" }
]
}
],
"errors": [],
"accessMode": "public"
}`
},
{
id: 'fileList',
category: 'file',
method: 'GET',
path: '/api/files',
titleKey: 'api.docs.fileList.title',
descKey: 'api.docs.fileList.desc',
requiresAuth: true,
params: [
{
name: 'kind',
type: 'string',
location: 'query',
required: true,
descriptionKey: 'api.params.mediaList.kindFile'
},
{
name: 'page',
type: 'number',
location: 'query',
required: false,
descriptionKey: 'api.params.mediaList.page'
},
{
name: 'limit',
type: 'number',
location: 'query',
required: false,
descriptionKey: 'api.params.mediaList.limit'
},
{
name: 'q',
type: 'string',
location: 'query',
required: false,
descriptionKey: 'api.params.mediaList.q'
},
{
name: 'backendId',
type: 'string',
location: 'query',
required: false,
descriptionKey: 'api.params.backendId'
},
{
name: 'uploadSource',
type: 'string',
location: 'query',
required: false,
descriptionKey: 'api.params.mediaList.uploadSource'
},
{
name: 'tagIds',
type: 'string',
location: 'query',
required: false,
descriptionKey: 'api.params.mediaList.tagIds'
},
{
name: 'tagMode',
type: 'string',
location: 'query',
required: false,
descriptionKey: 'api.params.mediaList.tagMode'
},
{
name: 'untagged',
type: 'string',
location: 'query',
required: false,
descriptionKey: 'api.params.mediaList.untagged'
}
],
curl: `curl "${baseUrl}/api/files?kind=file&page=1&limit=20" \\
${auth}`,
responseExample: `{
"items": [
{
"key": "files/2026/10/abc123.pdf",
"url": "https://pic.example.com/files/2026/10/abc123.pdf",
"originalName": "report.pdf",
"contentType": "application/pdf",
"size": 5242880,
"uploadedAt": "2026-10-17T03:00:00.000Z",
"markdown": "[report.pdf](https://pic.example.com/files/2026/10/abc123.pdf)"
}
],
"total": 1,
"page": 1,
"pageSize": 20,
"totalPages": 1,
"accessMode": "public"
}`
},
{
id: 'fileDelete',
category: 'file',
method: 'DELETE',
path: '/api/files',
titleKey: 'api.docs.fileDelete.title',
descKey: 'api.docs.fileDelete.desc',
requiresAuth: true,
params: [
{
name: 'key',
type: 'string',
location: 'query',
required: true,
descriptionKey: 'api.params.fileDelete.key'
}
],
curl: `curl -X DELETE "${baseUrl}/api/files?key=files/2026/10/abc123.pdf" \\
${auth}`,
responseExample: `{
"success": true
}`
},
{
id: 'fileBatchDelete',
category: 'file',
method: 'POST',
path: '/api/files/batch-delete',
titleKey: 'api.docs.fileBatchDelete.title',
descKey: 'api.docs.fileBatchDelete.desc',
requiresAuth: true,
params: [
{
name: 'keys',
type: 'string[]',
location: 'body',
required: true,
descriptionKey: 'api.params.fileBatchDelete.keys'
}
],
curl: `curl -X POST "${baseUrl}/api/files/batch-delete" \\
${auth} \\
-H "Content-Type: application/json" \\
-d '{"keys":["files/2026/10/a.pdf","files/2026/10/b.zip"]}'`,
responseExample: `{
"success": true,
"deleted": [
"files/2026/10/a.pdf",
"files/2026/10/b.zip"
],
"failed": []
}`
},
{
id: 'fileSearch',
category: 'file',
method: 'GET',
path: '/api/files/search',
titleKey: 'api.docs.fileSearch.title',
descKey: 'api.docs.fileSearch.desc',
requiresAuth: true,
params: [
{
name: 'kind',
type: 'string',
location: 'query',
required: true,
descriptionKey: 'api.params.mediaList.kindFile'
},
{
name: 'q',
type: 'string',
location: 'query',
required: true,
descriptionKey: 'api.params.mediaSearch.q'
},
{
name: 'page',
type: 'number',
location: 'query',
required: false,
descriptionKey: 'api.params.mediaList.page'
},
{
name: 'limit',
type: 'number',
location: 'query',
required: false,
descriptionKey: 'api.params.mediaList.limit'
},
{
name: 'backendId',
type: 'string',
location: 'query',
required: false,
descriptionKey: 'api.params.backendId'
},
{
name: 'uploadSource',
type: 'string',
location: 'query',
required: false,
descriptionKey: 'api.params.mediaList.uploadSource'
},
{
name: 'tagIds',
type: 'string',
location: 'query',
required: false,
descriptionKey: 'api.params.mediaList.tagIds'
},
{
name: 'tagMode',
type: 'string',
location: 'query',
required: false,
descriptionKey: 'api.params.mediaList.tagMode'
},
{
name: 'untagged',
type: 'string',
location: 'query',
required: false,
descriptionKey: 'api.params.mediaList.untagged'
}
],
curl: `curl "${baseUrl}/api/files/search?kind=file&q=report&page=1&limit=20" \\
${auth}`,
responseExample: `{
"items": [
{
"key": "files/2026/10/report.pdf",
"url": "https://pic.example.com/files/2026/10/report.pdf",
"originalName": "report.pdf",
"contentType": "application/pdf",
"size": 5242880,
"uploadedAt": "2026-10-17T03:00:00.000Z",
"markdown": "[report.pdf](https://pic.example.com/files/2026/10/report.pdf)"
}
],
"total": 1,
"page": 1,
"pageSize": 20,
"totalPages": 1,
"accessMode": "public"
}`
},
{
id: 'twikoo',
category: 'extension',
method: 'POST',
path: '/api/index.php',
titleKey: 'api.docs.twikoo.title',
descKey: 'api.docs.twikoo.desc',
requiresAuth: false,
params: [
{
name: 'image',
type: 'file',
location: 'form-data',
required: true,
descriptionKey: 'api.params.twikoo.image'
},
{
name: 'token',
type: 'string',
location: 'form-data',
required: true,
descriptionKey: 'api.params.twikoo.token'
}
],
curl: `curl -X POST "${baseUrl}/api/index.php" \\
-F "token=${authHeader}" \\
-F "image=@./demo.png"`,
responseExample: `{
"result": "success",
"code": 200,
"url": "https://pic.example.com/abc123.webp"
}`
},
{
id: 'config',
category: 'system',
method: 'GET',
path: '/api/auth/config',
titleKey: 'api.docs.config.title',
descKey: 'api.docs.config.desc',
requiresAuth: false,
params: [],
curl: `curl "${baseUrl}/api/auth/config"`,
responseExample: `{
"initialized": true,
"allowRegistration": false,
"legacyMode": false,
"adminSecretConfigured": false,
"apiUploadTokenConfigured": true
}`
},
{
id: 'count',
category: 'system',
method: 'GET',
path: '/api/images/count',
titleKey: 'api.docs.count.title',
descKey: 'api.docs.count.desc',
requiresAuth: true,
params: [
{
name: 'backendId',
type: 'string',
location: 'query',
required: false,
descriptionKey: 'api.params.backendId'
}
],
curl: `curl "${baseUrl}/api/images/count" \\
${auth}`,
responseExample: `{
"total": 42
}`
}
]
}
+64 -8
View File
@@ -1,3 +1,5 @@
import type { LoginVerificationPayload } from '~/types/auth'
export type AuthStatus = 'checking' | 'authenticated' | 'unauthenticated' export type AuthStatus = 'checking' | 'authenticated' | 'unauthenticated'
export type UserRole = 'admin' | 'user' export type UserRole = 'admin' | 'user'
@@ -8,11 +10,18 @@ export interface AuthUser {
role: UserRole role: UserRole
} }
export interface LoginVerificationPublicConfig {
method: 'slider' | 'turnstile' | 'cap'
turnstileSiteKey?: string
capApiEndpoint?: string
}
export interface AuthStatusResponse { export interface AuthStatusResponse {
initialized: boolean initialized: boolean
allowRegistration: boolean allowRegistration: boolean
legacyMode: boolean legacyMode: boolean
needsMigration: boolean needsMigration: boolean
loginVerification: LoginVerificationPublicConfig
user: AuthUser | null user: AuthUser | null
} }
@@ -66,6 +75,21 @@ export function getFetchErrorMessage(error: unknown, fallback: string): string {
export type AuthActionResult = { ok: true } | { ok: false, error: string } export type AuthActionResult = { ok: true } | { ok: false, error: string }
export type { LoginVerificationPayload }
function buildVerificationBody(verification: LoginVerificationPayload): Record<string, unknown> {
if (verification.method === 'slider') {
return {
captchaId: verification.captchaId,
captchaPosition: verification.captchaPosition
}
}
if (verification.method === 'turnstile') {
return { turnstileToken: verification.turnstileToken }
}
return { capToken: verification.capToken }
}
export function useAuth() { export function useAuth() {
const { t } = useI18n() const { t } = useI18n()
const status = useState<AuthStatus>('auth-status', () => 'checking') const status = useState<AuthStatus>('auth-status', () => 'checking')
@@ -117,21 +141,40 @@ export function useAuth() {
async function login( async function login(
credentials: { username: string, password: string } credentials: { username: string, password: string }
| { secret: string } | { secret: string },
): Promise<{ ok: boolean, needsMigration?: boolean, error?: string }> { verification: LoginVerificationPayload
): Promise<{
ok: boolean
needsMigration?: boolean
needsPasswordReset?: boolean
resetUsername?: string
error?: string
}> {
try { try {
const result = await $fetch<{ const result = await $fetch<{
success: boolean success: boolean
needsMigration?: boolean needsMigration?: boolean
requiresPasswordReset?: boolean
username?: string
user?: AuthUser user?: AuthUser
}>('/api/auth/login', { }>('/api/auth/login', {
method: 'POST', method: 'POST',
credentials: 'include', credentials: 'include',
body: credentials body: {
...credentials,
...buildVerificationBody(verification)
}
}) })
if (result.needsMigration) { if (result.needsMigration) {
return { ok: true, needsMigration: true } return { ok: true, needsMigration: true }
} }
if (result.requiresPasswordReset) {
return {
ok: false,
needsPasswordReset: true,
resetUsername: result.username
}
}
if (result.user) { if (result.user) {
user.value = result.user user.value = result.user
} }
@@ -149,7 +192,8 @@ export function useAuth() {
async function register( async function register(
username: string, username: string,
password: string password: string,
verification: LoginVerificationPayload
): Promise<AuthActionResult> { ): Promise<AuthActionResult> {
const validationError = validateAuthInput(username, password) const validationError = validateAuthInput(username, password)
if (validationError) { if (validationError) {
@@ -162,7 +206,11 @@ export function useAuth() {
{ {
method: 'POST', method: 'POST',
credentials: 'include', credentials: 'include',
body: { username: username.trim(), password } body: {
username: username.trim(),
password,
...buildVerificationBody(verification)
}
} }
) )
user.value = result.user user.value = result.user
@@ -185,6 +233,8 @@ export function useAuth() {
domainSeparation?: boolean domainSeparation?: boolean
siteBaseUrl?: string siteBaseUrl?: string
imageBaseUrl?: string imageBaseUrl?: string
videoBaseUrl?: string
fileBaseUrl?: string
}): Promise<AuthActionResult> { }): Promise<AuthActionResult> {
const validationError = validateAuthInput(input.username, input.password) const validationError = validateAuthInput(input.username, input.password)
if (validationError) { if (validationError) {
@@ -203,7 +253,9 @@ export function useAuth() {
allowRegistration: input.allowRegistration, allowRegistration: input.allowRegistration,
domainSeparation: input.domainSeparation, domainSeparation: input.domainSeparation,
siteBaseUrl: input.siteBaseUrl, siteBaseUrl: input.siteBaseUrl,
imageBaseUrl: input.imageBaseUrl imageBaseUrl: input.imageBaseUrl,
videoBaseUrl: input.videoBaseUrl,
fileBaseUrl: input.fileBaseUrl
} }
} }
) )
@@ -281,15 +333,19 @@ export function useAuth() {
async function logout() { async function logout() {
try { try {
await $fetch('/api/auth/logout', { method: 'POST' }) await $fetch('/api/auth/logout', { method: 'POST', credentials: 'include' })
} catch { } catch {
// 忽略登出失败 // 忽略登出失败
} finally { } finally {
status.value = 'unauthenticated' status.value = 'unauthenticated'
user.value = null user.value = null
authStatus.value = null
hasValidatedOnce.value = true hasValidatedOnce.value = true
} }
try {
await fetchStatus()
} catch {
// 保留既有公开配置(loginVerification 等)
}
} }
function handleAuthError(error: unknown) { function handleAuthError(error: unknown) {
+421
View File
@@ -0,0 +1,421 @@
import type {
BackupEstimate,
BackupJobItem,
BackupPackageItem,
BackupRestorePreview,
BackendSyncPreview,
SiteSyncRequest
} from '~/types/backup'
const jobs = ref<BackupJobItem[]>([])
const jobsPage = ref(1)
const jobsPageSize = ref(10)
const jobsTotal = ref(0)
const jobsTotalPages = ref(1)
const jobsLoading = ref(false)
const showHiddenJobs = ref(false)
const activeJobState = ref<BackupJobItem | null>(null)
const estimate = ref<BackupEstimate | null>(null)
const packages = ref<BackupPackageItem[]>([])
const loadingEstimate = ref(false)
const polling = ref(false)
let pollTimer: ReturnType<typeof setInterval> | null = null
export function useBackup() {
const toast = useToast()
const { t } = useI18n()
function stopPolling() {
if (pollTimer) {
clearInterval(pollTimer)
pollTimer = null
}
polling.value = false
}
function hasActiveJob(): boolean {
return activeJobState.value !== null
}
function getActiveJob(): BackupJobItem | null {
return activeJobState.value
}
async function cancelJob(jobId: string) {
try {
await $fetch(`/api/admin/backup/jobs/${jobId}/cancel`, {
method: 'POST',
credentials: 'include'
})
await loadJobs()
toast.add({ title: t('storage.backup.cancelDone'), color: 'success' })
} catch (error: unknown) {
handleError(error, t('storage.backup.cancelFailed'))
}
}
async function loadJobs(options?: { page?: number, pageSize?: number }) {
if (options?.page !== undefined) jobsPage.value = options.page
if (options?.pageSize !== undefined) jobsPageSize.value = options.pageSize
jobsLoading.value = true
try {
const data = await $fetch<{
jobs: BackupJobItem[]
total: number
page: number
pageSize: number
totalPages: number
activeJob: BackupJobItem | null
}>('/api/admin/backup/jobs', {
credentials: 'include',
query: {
pageNum: jobsPage.value,
pageSize: jobsPageSize.value,
includeHidden: showHiddenJobs.value
}
})
jobs.value = data.jobs
jobsTotal.value = data.total
jobsPage.value = data.page
jobsPageSize.value = data.pageSize
jobsTotalPages.value = data.totalPages
activeJobState.value = data.activeJob
if (!data.activeJob) {
stopPolling()
void loadEstimate().catch(() => {})
}
return data.jobs
} finally {
jobsLoading.value = false
}
}
async function setJobsPage(page: number) {
jobsPage.value = page
return loadJobs()
}
async function setJobsPageSize(pageSize: number) {
jobsPageSize.value = pageSize
jobsPage.value = 1
return loadJobs()
}
async function refreshJobsFirstPage() {
jobsPage.value = 1
return loadJobs()
}
async function toggleShowHiddenJobs() {
showHiddenJobs.value = !showHiddenJobs.value
jobsPage.value = 1
await loadJobs()
}
async function hideJob(jobId: string, hidden = true) {
try {
await $fetch('/api/admin/backup/jobs/hide', {
method: 'POST',
credentials: 'include',
body: { jobId, hidden }
})
if (hidden) {
showHiddenJobs.value = false
jobsPage.value = 1
}
await loadJobs()
toast.add({
title: hidden
? t('storage.backup.jobHidden')
: t('storage.backup.jobUnhidden'),
color: 'success'
})
} catch (error: unknown) {
handleError(error, t('storage.backup.hideFailed'))
}
}
async function hideAllJobs(hidden = true) {
try {
await $fetch('/api/admin/backup/jobs/hide-all', {
method: 'POST',
credentials: 'include',
body: { hidden }
})
if (hidden) {
showHiddenJobs.value = false
}
jobsPage.value = 1
await loadJobs()
toast.add({
title: hidden
? t('storage.backup.jobsHiddenAll')
: t('storage.backup.jobsUnhiddenAll'),
color: 'success'
})
} catch (error: unknown) {
handleError(error, t('storage.backup.hideFailed'))
}
}
async function loadEstimate() {
loadingEstimate.value = true
try {
const data = await $fetch<{
estimate: BackupEstimate
packages: BackupPackageItem[]
backupsDir: string
}>('/api/admin/backup/estimate', { credentials: 'include' })
estimate.value = data.estimate
packages.value = data.packages
return data
} finally {
loadingEstimate.value = false
}
}
function startPolling() {
if (pollTimer) return
polling.value = true
pollTimer = setInterval(() => {
void loadJobs().catch(() => {})
}, 2000)
}
async function createExport() {
try {
const data = await $fetch<{ job: BackupJobItem, estimate: BackupEstimate }>(
'/api/admin/backup/export',
{ method: 'POST', credentials: 'include' }
)
estimate.value = data.estimate
await refreshJobsFirstPage()
startPolling()
toast.add({ title: t('storage.backup.exportStarted'), color: 'success' })
return data.job
} catch (error: unknown) {
handleError(error, t('storage.backup.exportFailed'))
throw error
}
}
async function previewRestore(input: {
file?: File
archiveName?: string
}): Promise<BackupRestorePreview> {
if (input.file) {
const data = await $fetch<{ preview: BackupRestorePreview }>(
'/api/admin/backup/restore',
{
method: 'POST',
credentials: 'include',
body: input.file,
headers: {
'content-type': 'application/octet-stream',
'x-pichost-dry-run': 'true'
}
}
)
return data.preview
}
const data = await $fetch<{ preview: BackupRestorePreview }>(
'/api/admin/backup/restore',
{
method: 'POST',
credentials: 'include',
body: {
archiveName: input.archiveName,
dryRun: true
}
}
)
return data.preview
}
async function confirmRestore(input: {
file?: File
archiveName?: string
conflictMode: 'skip' | 'overwrite'
}) {
try {
if (input.file) {
const data = await $fetch<{ job: BackupJobItem }>(
'/api/admin/backup/restore',
{
method: 'POST',
credentials: 'include',
body: input.file,
headers: {
'content-type': 'application/octet-stream',
'x-pichost-dry-run': 'false',
'x-pichost-conflict-mode': input.conflictMode
}
}
)
await refreshJobsFirstPage()
startPolling()
toast.add({ title: t('storage.backup.restoreStarted'), color: 'success' })
return data.job
}
const data = await $fetch<{ job: BackupJobItem }>(
'/api/admin/backup/restore',
{
method: 'POST',
credentials: 'include',
body: {
archiveName: input.archiveName,
conflictMode: input.conflictMode,
dryRun: false
}
}
)
await refreshJobsFirstPage()
startPolling()
toast.add({ title: t('storage.backup.restoreStarted'), color: 'success' })
return data.job
} catch (error: unknown) {
handleError(error, t('storage.backup.restoreFailed'))
throw error
}
}
async function previewSync(body: {
fromBackendId: string
toBackendId: string
}): Promise<BackendSyncPreview> {
const data = await $fetch<{ preview: BackendSyncPreview }>(
'/api/admin/storage/sync',
{
method: 'POST',
credentials: 'include',
body: { ...body, dryRun: true }
}
)
return data.preview
}
async function startSync(body: {
fromBackendId: string
toBackendId: string
deleteSource?: boolean
setDefault?: boolean
}) {
try {
const data = await $fetch<{ job: BackupJobItem }>(
'/api/admin/storage/sync',
{
method: 'POST',
credentials: 'include',
body
}
)
await refreshJobsFirstPage()
startPolling()
toast.add({ title: t('storage.backup.syncStarted'), color: 'success' })
return data.job
} catch (error: unknown) {
handleError(error, t('storage.backup.syncFailed'))
throw error
}
}
async function startSiteSync(body: SiteSyncRequest) {
try {
const data = await $fetch<{ job: BackupJobItem }>(
'/api/admin/site-sync',
{
method: 'POST',
credentials: 'include',
body
}
)
await refreshJobsFirstPage()
startPolling()
const cleanupPending = Boolean(
body.abandonJobId
&& data.job.payload.cleanupPending === true
)
toast.add({
title: cleanupPending
? t('storage.backup.siteSyncAbandonedPendingCleanup')
: body.abandonJobId
? t('storage.backup.siteSyncAbandoned')
: t('storage.backup.siteSyncStarted'),
color: cleanupPending ? 'warning' : 'success'
})
return data.job
} catch (error: unknown) {
handleError(error, t('storage.backup.siteSyncFailed'))
throw error
}
}
async function retryJob(jobId: string) {
try {
await $fetch(`/api/admin/backup/jobs/${jobId}/retry`, {
method: 'POST',
credentials: 'include'
})
await refreshJobsFirstPage()
startPolling()
toast.add({ title: t('storage.backup.retryStarted'), color: 'success' })
} catch (error: unknown) {
handleError(error, t('storage.backup.retryFailed'))
}
}
function downloadUrl(jobId: string): string {
return `/api/admin/backup/jobs/${jobId}/download`
}
function handleError(error: unknown, fallback: string) {
const status = typeof error === 'object' && error !== null && 'statusCode' in error
? (error as { statusCode: number }).statusCode
: 0
if (status === 409) {
toast.add({ title: t('storage.backup.jobRunning'), color: 'warning' })
return
}
toast.add({ title: fallback, color: 'error' })
}
onUnmounted(() => {
stopPolling()
})
return {
jobs,
jobsPage,
jobsPageSize,
jobsTotal,
jobsTotalPages,
jobsLoading,
showHiddenJobs,
estimate,
packages,
loadingEstimate,
polling,
hasActiveJob,
getActiveJob,
loadJobs,
toggleShowHiddenJobs,
hideJob,
hideAllJobs,
setJobsPage,
setJobsPageSize,
loadEstimate,
startPolling,
stopPolling,
createExport,
previewRestore,
confirmRestore,
previewSync,
startSync,
startSiteSync,
retryJob,
cancelJob,
downloadUrl
}
}
+150 -60
View File
@@ -3,46 +3,57 @@ import type {
ImageListResponse ImageListResponse
} from '~/types/image' } from '~/types/image'
const PAGE_SIZE = 12 const PAGE_SIZE_DEFAULT = 10
export function useImageList() { export function useImageList() {
const { t } = useI18n() const { t } = useI18n()
const items = ref<ImageItem[]>([]) const items = ref<ImageItem[]>([])
const page = ref(1) const page = ref(1)
const pageSize = ref(PAGE_SIZE_DEFAULT)
const totalPages = ref(1) const totalPages = ref(1)
const total = ref(0) const total = ref(0)
const loading = ref(false) const loading = ref(false)
const folderTotal = ref<number | null>(null) const totalCount = ref<number | null>(null)
const loadingTotal = ref(false) const loadingTotal = ref(false)
const searchQuery = ref('') const searchQuery = ref('')
const activeSearch = ref('') const activeSearch = ref('')
const activeFolder = ref('all')
const activeStorageBackend = ref('all') const activeStorageBackend = ref('all')
const activeContentType = ref('all')
const activeUploadSource = ref('all')
const activeTagIds = ref<number[]>([])
const activeTagMode = ref<'or' | 'and'>('or')
const activeUntaggedOnly = ref(false)
const storageBackendOptions = ref<Array<{ id: string, name: string }>>([]) const storageBackendOptions = ref<Array<{ id: string, name: string }>>([])
function folderQueryParams(): Record<string, string> {
const folder = activeFolder.value.trim()
if (!folder || folder === 'all') return {}
return { folder }
}
function storageQueryParams(): Record<string, string> { function storageQueryParams(): Record<string, string> {
const backendId = activeStorageBackend.value.trim() const backendId = activeStorageBackend.value.trim()
if (!backendId || backendId === 'all') return {} if (!backendId || backendId === 'all') return {}
return { backendId } return { backendId }
} }
function listQueryParams(): Record<string, string> { function filterQueryParams(): Record<string, string> {
return { const params: Record<string, string> = { ...storageQueryParams() }
...folderQueryParams(), const contentType = activeContentType.value.trim()
...storageQueryParams() if (contentType && contentType !== 'all') {
params.contentType = contentType
} }
const uploadSource = activeUploadSource.value.trim()
if (uploadSource && uploadSource !== 'all') {
params.uploadSource = uploadSource
}
if (activeUntaggedOnly.value) {
params.untagged = '1'
} else if (activeTagIds.value.length) {
params.tagIds = activeTagIds.value.join(',')
if (activeTagMode.value === 'and' && activeTagIds.value.length > 1) {
params.tagMode = 'and'
}
}
return params
} }
function matchesActiveFolder(key: string) { function listQueryParams(): Record<string, string> {
const folder = activeFolder.value.trim() return filterQueryParams()
if (!folder || folder === 'all') return true
return key.startsWith(`${folder}/`)
} }
function matchesActiveStorage(item: ImageItem) { function matchesActiveStorage(item: ImageItem) {
@@ -74,9 +85,9 @@ export function useImageList() {
credentials: 'include', credentials: 'include',
query: listQueryParams() query: listQueryParams()
}) })
folderTotal.value = data.total totalCount.value = data.total
} catch { } catch {
folderTotal.value = null totalCount.value = null
} finally { } finally {
loadingTotal.value = false loadingTotal.value = false
} }
@@ -88,7 +99,7 @@ export function useImageList() {
const response = await $fetch<ImageListResponse>('/api/images', { const response = await $fetch<ImageListResponse>('/api/images', {
credentials: 'include', credentials: 'include',
query: { query: {
limit: PAGE_SIZE, limit: pageSize.value,
page: targetPage, page: targetPage,
...listQueryParams() ...listQueryParams()
} }
@@ -116,7 +127,7 @@ export function useImageList() {
credentials: 'include', credentials: 'include',
query: { query: {
q, q,
limit: PAGE_SIZE, limit: pageSize.value,
page: targetPage, page: targetPage,
...listQueryParams() ...listQueryParams()
} }
@@ -138,27 +149,13 @@ export function useImageList() {
await Promise.all([fetchList(1), fetchTotal()]) await Promise.all([fetchList(1), fetchTotal()])
} }
async function initializeList(folder = 'all') { async function initializeList() {
const next = folder.trim() || 'all'
activeFolder.value = next
page.value = 1 page.value = 1
activeSearch.value = '' activeSearch.value = ''
searchQuery.value = '' searchQuery.value = ''
await Promise.all([fetchList(1), fetchTotal(), loadStorageBackendOptions()]) await Promise.all([fetchList(1), fetchTotal(), loadStorageBackendOptions()])
} }
async function setActiveFolder(folder: string) {
const next = folder.trim() || 'all'
if (next === activeFolder.value) return
activeFolder.value = next
page.value = 1
if (activeSearch.value) {
await Promise.all([fetchSearch(1), fetchTotal()])
} else {
await Promise.all([fetchList(1), fetchTotal()])
}
}
async function setActiveStorageBackend(backendId: string) { async function setActiveStorageBackend(backendId: string) {
const next = backendId.trim() || 'all' const next = backendId.trim() || 'all'
if (next === activeStorageBackend.value) return if (next === activeStorageBackend.value) return
@@ -171,6 +168,76 @@ export function useImageList() {
} }
} }
async function setActiveContentType(contentType: string) {
const next = contentType.trim() || 'all'
if (next === activeContentType.value) return
activeContentType.value = next
page.value = 1
if (activeSearch.value) {
await Promise.all([fetchSearch(1), fetchTotal()])
} else {
await Promise.all([fetchList(1), fetchTotal()])
}
}
async function setActiveUploadSource(uploadSource: string) {
const next = uploadSource.trim() || 'all'
if (next === activeUploadSource.value) return
activeUploadSource.value = next
page.value = 1
if (activeSearch.value) {
await Promise.all([fetchSearch(1), fetchTotal()])
} else {
await Promise.all([fetchList(1), fetchTotal()])
}
}
async function setActiveTagFilter(options: {
tagIds?: number[]
tagMode?: 'or' | 'and'
untaggedOnly?: boolean
}) {
if (options.tagIds !== undefined) {
activeTagIds.value = [...options.tagIds]
}
if (options.tagMode !== undefined) {
activeTagMode.value = options.tagMode
}
if (options.untaggedOnly !== undefined) {
activeUntaggedOnly.value = options.untaggedOnly
}
page.value = 1
if (activeSearch.value) {
await Promise.all([fetchSearch(1), fetchTotal()])
} else {
await Promise.all([fetchList(1), fetchTotal()])
}
}
async function toggleTagFilter(tagId: number) {
const next = new Set(activeTagIds.value)
activeUntaggedOnly.value = false
if (next.has(tagId)) {
next.delete(tagId)
} else {
next.add(tagId)
}
await setActiveTagFilter({ tagIds: [...next] })
}
async function resetFilters() {
searchQuery.value = ''
activeSearch.value = ''
activeStorageBackend.value = 'all'
activeContentType.value = 'all'
activeUploadSource.value = 'all'
activeTagIds.value = []
activeTagMode.value = 'or'
activeUntaggedOnly.value = false
page.value = 1
await Promise.all([fetchList(1), fetchTotal()])
}
async function submitSearch() { async function submitSearch() {
const q = searchQuery.value.trim() const q = searchQuery.value.trim()
activeSearch.value = q activeSearch.value = q
@@ -191,15 +258,24 @@ export function useImageList() {
} }
} }
async function setPageSize(size: number) {
if (!Number.isFinite(size) || size <= 0 || size === pageSize.value) return
pageSize.value = size
page.value = 1
if (activeSearch.value) {
await Promise.all([fetchSearch(1), fetchTotal()])
} else {
await Promise.all([fetchList(1), fetchTotal()])
}
}
function prependItems(newItems: ImageItem[]) { function prependItems(newItems: ImageItem[]) {
const matched = newItems.filter(item => const matched = newItems.filter(item => matchesActiveStorage(item))
matchesActiveFolder(item.key) && matchesActiveStorage(item)
)
if (!matched.length || page.value !== 1) return if (!matched.length || page.value !== 1) return
items.value = [...matched, ...items.value] items.value = [...matched, ...items.value]
total.value += matched.length total.value += matched.length
if (folderTotal.value !== null) { if (totalCount.value !== null) {
folderTotal.value += matched.length totalCount.value += matched.length
} }
} }
@@ -210,8 +286,8 @@ export function useImageList() {
if (removed > 0) { if (removed > 0) {
total.value = Math.max(0, total.value - removed) total.value = Math.max(0, total.value - removed)
} }
if (folderTotal.value !== null && removed > 0) { if (totalCount.value !== null && removed > 0) {
folderTotal.value = Math.max(0, folderTotal.value - removed) totalCount.value = Math.max(0, totalCount.value - removed)
} }
} }
@@ -223,44 +299,51 @@ export function useImageList() {
}) })
const listSummary = computed(() => { const listSummary = computed(() => {
const folder = activeFolder.value.trim() || 'all'
const folderLabel = folder === 'all' ? t('stats.allFolders') : folder
const storageLabel = activeStorageLabel.value const storageLabel = activeStorageLabel.value
if (loadingTotal.value && folderTotal.value === null) { if (loadingTotal.value && totalCount.value === null) {
return storageLabel return storageLabel
? `${folderLabel} · ${storageLabel} · ${t('stats.counting')}` ? `${storageLabel} · ${t('stats.counting')}`
: `${folderLabel} · ${t('stats.counting')}` : t('stats.counting')
} }
if (activeSearch.value) { if (activeSearch.value) {
if (storageLabel) { if (storageLabel) {
return t('stats.summarySearchStorage', { return t('stats.summarySearchStorage', {
folder: folderLabel,
storage: storageLabel,
q: activeSearch.value, q: activeSearch.value,
total: total.value total: total.value,
storage: storageLabel
}) })
} }
return t('stats.summarySearch', { folder: folderLabel, q: activeSearch.value, total: total.value }) return t('stats.summarySearch', { q: activeSearch.value, total: total.value })
} }
const count = folderTotal.value === null ? total.value : folderTotal.value const count = totalCount.value === null ? total.value : totalCount.value
if (storageLabel) { if (storageLabel) {
return t('stats.summaryTotalStorage', { return t('stats.summaryTotalStorage', {
folder: folderLabel, total: count,
storage: storageLabel, storage: storageLabel
total: count
}) })
} }
return t('stats.summaryTotal', { folder: folderLabel, total: count }) return t('stats.summaryTotal', { total: count })
}) })
function updateItemTags(key: string, tags: ImageItem['tags']) {
items.value = items.value.map(item =>
item.key === key ? { ...item, tags } : item
)
}
return { return {
items, items,
page, page,
pageSize,
totalPages, totalPages,
total, total,
loading, loading,
activeFolder,
activeStorageBackend, activeStorageBackend,
activeContentType,
activeUploadSource,
activeTagIds,
activeTagMode,
activeUntaggedOnly,
storageBackendOptions, storageBackendOptions,
searchQuery, searchQuery,
activeSearch, activeSearch,
@@ -270,12 +353,19 @@ export function useImageList() {
fetchTotal, fetchTotal,
refreshList, refreshList,
initializeList, initializeList,
setActiveFolder,
setActiveStorageBackend, setActiveStorageBackend,
setActiveContentType,
setActiveUploadSource,
setActiveTagFilter,
toggleTagFilter,
resetFilters,
loadStorageBackendOptions, loadStorageBackendOptions,
submitSearch, submitSearch,
goToPage, goToPage,
setPageSize,
prependItems, prependItems,
removeItems removeItems,
updateItemTags,
listQueryParams
} }
} }
+27 -7
View File
@@ -4,21 +4,22 @@ import type {
} from '~/types/image' } from '~/types/image'
import { isUnauthorizedError } from './useAuth' import { isUnauthorizedError } from './useAuth'
import { prepareFilesForUpload } from './useClipboardImage' import { prepareFilesForUpload } from './useClipboardImage'
import { normalizeUploadTagNames } from './useUploadPreferences'
const MAX_FILES = 10 const MAX_FILES = 10
export function useImageUpload() { export function useImageUpload() {
const toast = useToast() const toast = useToast()
const { t } = useI18n() const { t } = useI18n()
const { compressEnabled, clientWebpQuality } = useUploadPreferences() const { compressEnabled, clientWebpQuality, uploadTagNames } = useUploadPreferences()
const { load: loadUploadPolicy } = useUploadPolicy()
const uploading = ref(false) const uploading = ref(false)
const progressItems = ref<UploadProgressItem[]>([]) const progressItems = ref<UploadProgressItem[]>([])
const lastUploadResult = ref<UploadResponse | null>(null) const lastUploadResult = ref<UploadResponse | null>(null)
async function uploadFiles( async function uploadFiles(
files: File[], files: File[],
folder = 'images', options?: { notify?: boolean }
options?: { notify?: boolean, includeFolder?: boolean }
) { ) {
if (uploading.value) return if (uploading.value) return
@@ -43,10 +44,27 @@ export function useImageUpload() {
})) }))
try { try {
const prepared = await prepareFilesForUpload(files, { const preparedFiles = await prepareFilesForUpload(files, {
enabled: compressEnabled.value, enabled: compressEnabled.value,
quality: clientWebpQuality.value / 100 quality: clientWebpQuality.value / 100
}) })
const currentPolicy = await loadUploadPolicy().catch(() => null)
const maxFileSizeMb = currentPolicy?.imageMaxFileSizeMb ?? 10
const prepared = preparedFiles.filter((file) => {
if (file.size <= maxFileSizeMb * 1024 * 1024) return true
toast.add({
title: t('files.fileTooLarge', {
name: file.name,
limit: maxFileSizeMb
}),
color: 'error'
})
return false
})
if (!prepared.length) {
progressItems.value = []
return
}
progressItems.value = prepared.map((file, index) => ({ progressItems.value = prepared.map((file, index) => ({
name: file.name, name: file.name,
@@ -55,13 +73,15 @@ export function useImageUpload() {
})) }))
const formData = new FormData() const formData = new FormData()
if (options?.includeFolder !== false) {
formData.append('folder', folder.trim() || 'images')
}
for (const file of prepared) { for (const file of prepared) {
formData.append('files', file, file.name) formData.append('files', file, file.name)
} }
const tagNames = normalizeUploadTagNames(uploadTagNames.value).join(',')
if (tagNames) {
formData.append('tagNames', tagNames)
}
const response = await $fetch<UploadResponse>('/api/images/upload', { const response = await $fetch<UploadResponse>('/api/images/upload', {
method: 'POST', method: 'POST',
body: formData body: formData
+40
View File
@@ -0,0 +1,40 @@
const STORAGE_KEY = 'pichost.library-view'
export type LibraryViewKind = 'image' | 'video' | 'file'
export type LibraryViewMode = 'grid' | 'list'
function storageKeyFor(kind: LibraryViewKind): string {
return `${STORAGE_KEY}.${kind}`
}
/**
* 图库 / 视频库 / 文件库各自的显示方式。
* 三个库分别记住(互不影响),且页面切换后保持,不用重新点。
*/
export function useLibraryView(kind: LibraryViewKind = 'image') {
const viewMode = useState<LibraryViewMode>(`library-view-mode-${kind}`, () => 'grid')
const loaded = useState(`library-view-loaded-${kind}`, () => false)
if (import.meta.client && !loaded.value) {
try {
const stored = localStorage.getItem(storageKeyFor(kind))
if (stored === 'grid' || stored === 'list') {
viewMode.value = stored
}
} catch {
// 读取失败保持默认
}
loaded.value = true
}
watch(viewMode, (value) => {
if (!import.meta.client) return
try {
localStorage.setItem(storageKeyFor(kind), value)
} catch {
// 写入失败不影响使用
}
})
return { viewMode }
}
+340
View File
@@ -0,0 +1,340 @@
import type { UploadProgressItem } from '~/types/image'
import type { FileItem, FileListResponse, FileUploadResponse } from '~/types/file'
import type { CopyFormat, UploadMediaKind } from './useUploadPreferences'
import { isUnauthorizedError } from './useAuth'
import { normalizeUploadTagNames } from './useUploadPreferences'
const MAX_FILES = 10
function buildCopyText(item: FileItem, format: CopyFormat): string {
switch (format) {
case 'url':
return item.url
case 'html':
return `<a href="${item.url}">${item.originalName}</a>`
case 'bbcode':
return `[url=${item.url}]${item.originalName}[/url]`
default:
return `[${item.originalName}](${item.url})`
}
}
async function copyToClipboard(text: string): Promise<boolean> {
try {
if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(text)
return true
}
} catch {
// 内网 http:// 等非安全上下文继续走回退方案
}
try {
const textarea = document.createElement('textarea')
textarea.value = text
textarea.style.position = 'fixed'
textarea.style.left = '-9999px'
document.body.appendChild(textarea)
textarea.select()
const ok = document.execCommand('copy')
document.body.removeChild(textarea)
return ok
} catch {
return false
}
}
/** 视频床 / 文件床的上传:走文件床接口,偏好按类型独立 */
export function useMediaUpload(kind: UploadMediaKind) {
const toast = useToast()
const { t } = useI18n()
const {
load: loadUploadPolicy
} = useUploadPolicy()
const {
autoCopyMarkdown,
copyFormat,
transcodeToHls,
uploadTagNames,
loadPreferences
} = useUploadPreferences(kind)
const uploading = ref(false)
const progressItems = ref<UploadProgressItem[]>([])
const lastResult = ref<FileUploadResponse | null>(null)
/** 本次会话内上传的条目,页面刷新后清空 */
const sessionItems = ref<FileItem[]>([])
const accessMode = ref<'public' | 'token'>('public')
const apiToken = ref('')
const endpoint = kind === 'video' ? '/api/videos/upload' : '/api/files/upload'
loadPreferences()
async function loadApiToken() {
try {
const data = await $fetch<{ apiUploadToken: string }>('/api/user/api-token', {
credentials: 'include'
})
apiToken.value = data.apiUploadToken ?? ''
} catch {
apiToken.value = ''
}
}
/** Token 模式下复制出的链接需要带上令牌 */
function linkUrl(item: FileItem): string {
// 转码完成后优先给出 m3u8 播放地址
const base = item.hlsUrl ?? item.url
if (accessMode.value !== 'token' || !apiToken.value) return base
const separator = base.includes('?') ? '&' : '?'
return `${base}${separator}token=${encodeURIComponent(apiToken.value)}`
}
/** 封面缩略图地址(同样按访问模式拼接) */
function thumbLink(item: FileItem): string {
const base = item.thumbUrl
if (!base) return '/video-placeholder.svg'
if (accessMode.value !== 'token' || !apiToken.value) return base
const separator = base.includes('?') ? '&' : '?'
return `${base}${separator}token=${encodeURIComponent(apiToken.value)}`
}
let statusTimer: ReturnType<typeof setInterval> | null = null
function stopStatusPolling() {
if (statusTimer) {
clearInterval(statusTimer)
statusTimer = null
}
}
function hasPendingTranscode(): boolean {
return sessionItems.value.some(
item => item.transcodeStatus === 'pending' || item.transcodeStatus === 'running'
)
}
/** 转码是异步的,轮询列表把状态与 m3u8 地址同步回来 */
async function refreshTranscodeStatus() {
if (!hasPendingTranscode()) {
stopStatusPolling()
return
}
try {
const [active, finished] = await Promise.all([
$fetch<FileListResponse>('/api/files', {
credentials: 'include',
query: { kind: 'video', limit: 100, processing: '1' }
}),
$fetch<FileListResponse>('/api/files', {
credentials: 'include',
query: { kind: 'video', limit: 100 }
})
])
const latest = new Map(
[...active.items, ...finished.items].map(item => [item.key, item])
)
sessionItems.value = sessionItems.value.map(item => latest.get(item.key) ?? item)
} catch {
// 轮询失败保持现状,下个周期重试
}
if (!hasPendingTranscode()) stopStatusPolling()
}
function startStatusPolling() {
if (kind !== 'video' || statusTimer) return
statusTimer = setInterval(() => {
void refreshTranscodeStatus()
}, 1000)
}
onUnmounted(stopStatusPolling)
/** 刷新或换页后仍要显示还在排队/转码中的视频 */
async function loadActiveTranscodes() {
if (kind !== 'video') return
try {
const response = await $fetch<FileListResponse>('/api/files', {
credentials: 'include',
query: { kind: 'video', limit: 100, processing: '1' }
})
if (!response.items.length) return
const known = new Set(sessionItems.value.map(item => item.key))
const missing = response.items.filter(item => !known.has(item.key))
const latest = new Map(response.items.map(item => [item.key, item]))
sessionItems.value = [
...missing,
...sessionItems.value.map(item => latest.get(item.key) ?? item)
]
startStatusPolling()
} catch {
// 拉取失败时保持现状
}
}
async function uploadFiles(files: File[]) {
if (!files.length || uploading.value) return
const currentPolicy = await loadUploadPolicy().catch(() => null)
const acceptedFiles = files.filter((file) => {
const extension = file.name.split('.').pop()?.toLowerCase() ?? ''
if (
kind === 'video'
&& currentPolicy
&& !currentPolicy.allowedVideoExtensions.includes(extension)
) {
toast.add({
title: `${file.name}:${t('files.videoFormatBlocked', {
format: extension ? extension.toUpperCase() : '—'
})}`,
color: 'error'
})
return false
}
if (
kind === 'file'
&& currentPolicy?.forbiddenFileExtensions.includes(extension)
) {
toast.add({
title: `${file.name}:${t('files.fileFormatBlocked', {
format: extension.toUpperCase()
})}`,
color: 'error'
})
return false
}
const maxFileSizeMb = kind === 'video'
? (currentPolicy?.videoMaxFileSizeMb ?? 500)
: (currentPolicy?.fileMaxFileSizeMb ?? 100)
if (file.size > maxFileSizeMb * 1024 * 1024) {
toast.add({
title: t('files.fileTooLarge', {
name: file.name,
limit: maxFileSizeMb
}),
color: 'error'
})
return false
}
return true
})
if (!acceptedFiles.length) return
if (acceptedFiles.length > MAX_FILES) {
toast.add({ title: t('files.maxFiles', { n: MAX_FILES }), color: 'warning' })
return
}
uploading.value = true
progressItems.value = acceptedFiles.map(file => ({
name: file.name,
status: 'pending',
progress: 0
}))
try {
const formData = new FormData()
for (const file of acceptedFiles) {
formData.append('files', file, file.name)
}
if (kind === 'video' && transcodeToHls.value) {
formData.append('transcode', '1')
}
const tagNames = normalizeUploadTagNames(uploadTagNames.value).join(',')
if (tagNames) {
formData.append('tagNames', tagNames)
}
progressItems.value = progressItems.value.map(item => ({
...item,
status: 'uploading',
progress: 50
}))
const response = await $fetch<FileUploadResponse>(endpoint, {
method: 'POST',
body: formData
})
lastResult.value = response
accessMode.value = response.accessMode
if (response.accessMode === 'token' && !apiToken.value) {
await loadApiToken()
}
progressItems.value = acceptedFiles.map((file) => {
const failed = response.errors.find(error => error.name === file.name)
return {
name: file.name,
status: failed ? 'error' : 'success',
progress: 100,
error: failed?.error.message
}
})
if (response.items.length) {
const wantsTranscode = kind === 'video' && transcodeToHls.value
const uploaded = wantsTranscode
? response.items.map(item => ({ ...item, transcodeStatus: 'pending' as const }))
: response.items
sessionItems.value = [...uploaded, ...sessionItems.value]
if (wantsTranscode) startStatusPolling()
toast.add({
title: t('files.uploadSuccess', { n: response.items.length }),
color: 'success'
})
const first = response.items[0]
if (autoCopyMarkdown.value && first) {
const text = buildCopyText(first, copyFormat.value)
if (text && await copyToClipboard(text)) {
toast.add({ title: t('copy.copied'), color: 'success' })
}
}
}
for (const failure of response.errors.slice(0, 3)) {
toast.add({
title: `${failure.name}:${failure.error.message}`,
color: 'error'
})
}
return response
} catch (error: unknown) {
if (!isUnauthorizedError(error)) {
progressItems.value = progressItems.value.map(item => ({
...item,
status: 'error',
progress: 100,
error: t('files.uploadFailed')
}))
toast.add({ title: t('files.uploadFailed'), color: 'error' })
}
throw error
} finally {
uploading.value = false
}
}
return {
uploading,
progressItems,
lastResult,
sessionItems,
accessMode,
linkUrl,
thumbLink,
loadActiveTranscodes,
uploadFiles
}
}
+48
View File
@@ -0,0 +1,48 @@
const STORAGE_KEY = 'pichost.recent-tag-ids'
const MAX_RECENT = 12
function readRecentIds(): number[] {
if (import.meta.server) return []
try {
const raw = localStorage.getItem(STORAGE_KEY)
if (!raw) return []
const parsed = JSON.parse(raw)
if (!Array.isArray(parsed)) return []
return parsed.filter((id: unknown): id is number => Number.isInteger(id) && (id as number) > 0)
} catch {
return []
}
}
function writeRecentIds(ids: number[]) {
if (import.meta.server) return
localStorage.setItem(STORAGE_KEY, JSON.stringify(ids))
}
export function useRecentTags() {
const recentIds = useState<number[]>('recent-tag-ids', () => [])
function loadRecentIds() {
recentIds.value = readRecentIds()
}
if (import.meta.client && !recentIds.value.length) {
loadRecentIds()
}
function recordRecent(tagIds: number[]) {
if (!tagIds.length) return
const merged = [
...tagIds,
...recentIds.value.filter(id => !tagIds.includes(id))
].slice(0, MAX_RECENT)
recentIds.value = merged
writeRecentIds(merged)
}
return {
recentIds,
loadRecentIds,
recordRecent
}
}
+122
View File
@@ -0,0 +1,122 @@
import type { ImageTag } from '~/types/image'
export interface TagListResponse {
items: ImageTag[]
}
export interface TagResponse {
tag: ImageTag
}
export function useTags() {
const items = useState<ImageTag[]>('tags-list', () => [])
const loading = ref(false)
const loaded = ref(false)
async function fetchTags(force = false) {
if (loaded.value && !force) return items.value
loading.value = true
try {
const data = await $fetch<TagListResponse>('/api/tags', {
credentials: 'include'
})
items.value = data.items
loaded.value = true
return data.items
} finally {
loading.value = false
}
}
async function createTag(name: string, color?: string | null) {
const data = await $fetch<TagResponse>('/api/tags', {
method: 'POST',
credentials: 'include',
body: { name, color }
})
await fetchTags(true)
return data.tag
}
async function updateTag(id: number, patch: { name?: string, color?: string | null }) {
const data = await $fetch<TagResponse>(`/api/tags/${id}`, {
method: 'PATCH',
credentials: 'include',
body: patch
})
await fetchTags(true)
return data.tag
}
async function deleteTag(id: number) {
await $fetch(`/api/tags/${id}`, {
method: 'DELETE',
credentials: 'include'
})
await fetchTags(true)
}
async function mergeTags(sourceIds: number[], targetId: number) {
const data = await $fetch<TagResponse>('/api/tags/merge', {
method: 'POST',
credentials: 'include',
body: { sourceIds, targetId }
})
await fetchTags(true)
return data.tag
}
async function addTagsToImage(key: string, tagIds: number[]) {
const data = await $fetch<{ tags: ImageTag[] }>('/api/images/tags', {
method: 'POST',
credentials: 'include',
body: { key, tagIds }
})
return data.tags
}
async function setImageTags(key: string, tagIds: number[]) {
const data = await $fetch<{ tags: ImageTag[] }>('/api/images/tags', {
method: 'PATCH',
credentials: 'include',
body: { key, tagIds }
})
return data.tags
}
async function removeTagFromImage(key: string, tagId: number) {
const data = await $fetch<{ tags: ImageTag[] }>('/api/images/tags', {
method: 'DELETE',
credentials: 'include',
body: { key, tagId }
})
return data.tags
}
async function batchUpdateTags(
keys: string[],
tagIds: number[],
action: 'add' | 'remove' = 'add'
) {
return $fetch<{ success: boolean, updated: number }>('/api/images/batch-tags', {
method: 'POST',
credentials: 'include',
body: { keys, tagIds, action }
})
}
return {
items,
loading,
loaded,
fetchTags,
createTag,
updateTag,
deleteTag,
mergeTags,
addTagsToImage,
setImageTags,
removeTagFromImage,
batchUpdateTags
}
}
@@ -0,0 +1,12 @@
export function useTranscodeFailureLabel() {
const { t } = useI18n()
return (error?: string) => {
const reason = error === 'ffmpeg-missing'
? t('library.video.transcodeErrorFfmpegMissing')
: error === 'permission-denied'
? t('library.video.transcodeErrorPermissionDenied')
: t('library.video.transcodeErrorUnknown')
return `${t('library.transcodeFailed')} · ${reason}`
}
}
+127
View File
@@ -0,0 +1,127 @@
interface TranscodeSettingsResponse {
concurrency: number
threads: number
preset: string
bitratePercent: number
maxConcurrency: number
maxThreads: number
minBitratePercent: number
maxBitratePercent: number
presets: string[]
memoryBytes: number
lowMemoryThresholdBytes: number
lowMemory: boolean
}
/** 服务端转码参数:并发任务数与单任务 CPU 核心数(仅管理员可改) */
export function useTranscodeSettings() {
const { isAuthenticated, isAdmin, handleAuthError } = useAuth()
const toast = useToast()
const { t } = useI18n()
const concurrency = ref(1)
const threads = ref(1)
const maxConcurrency = ref(8)
const maxThreads = ref(64)
const preset = ref('veryfast')
const presets = ref<string[]>(['veryfast', 'medium', 'slow'])
const bitratePercent = ref(80)
const minBitratePercent = ref(10)
const maxBitratePercent = ref(100)
const loaded = ref(false)
const hydrated = ref(false)
const memoryBytes = ref(0)
const lowMemoryThresholdBytes = ref(2 * 1024 * 1024 * 1024)
const lowMemory = ref(false)
let saveTimer: ReturnType<typeof setTimeout> | null = null
function apply(data: TranscodeSettingsResponse) {
hydrated.value = false
concurrency.value = data.concurrency
threads.value = data.threads
preset.value = data.preset
bitratePercent.value = data.bitratePercent
minBitratePercent.value = data.minBitratePercent ?? 10
maxBitratePercent.value = data.maxBitratePercent ?? 100
if (data.presets?.length) presets.value = data.presets
maxConcurrency.value = data.maxConcurrency
maxThreads.value = data.maxThreads
memoryBytes.value = data.memoryBytes ?? 0
lowMemoryThresholdBytes.value = data.lowMemoryThresholdBytes
?? 2 * 1024 * 1024 * 1024
lowMemory.value = data.lowMemory === true
loaded.value = true
nextTick(() => {
hydrated.value = true
})
}
async function load() {
if (!isAuthenticated.value) return
try {
apply(await $fetch<TranscodeSettingsResponse>('/api/videos/transcode-settings', {
credentials: 'include'
}))
} catch (error: unknown) {
handleAuthError(error)
if (isAuthenticated.value) {
toast.add({ title: t('preferences.transcodeLoadFailed'), color: 'error' })
}
}
}
async function save() {
if (!hydrated.value || !isAdmin.value) return
try {
const data = await $fetch<TranscodeSettingsResponse>('/api/videos/transcode-settings', {
method: 'PATCH',
credentials: 'include',
body: {
concurrency: concurrency.value,
threads: threads.value,
preset: preset.value,
bitratePercent: bitratePercent.value
}
})
hydrated.value = false
concurrency.value = data.concurrency
threads.value = data.threads
preset.value = data.preset
bitratePercent.value = data.bitratePercent
nextTick(() => {
hydrated.value = true
})
} catch (error: unknown) {
handleAuthError(error)
if (isAuthenticated.value) {
toast.add({ title: t('preferences.transcodeSaveFailed'), color: 'error' })
}
}
}
watch([concurrency, threads, preset, bitratePercent], () => {
if (!hydrated.value || !isAdmin.value) return
if (saveTimer) clearTimeout(saveTimer)
saveTimer = setTimeout(() => {
void save()
}, 600)
})
return {
concurrency,
threads,
preset,
bitratePercent,
minBitratePercent,
maxBitratePercent,
presets,
maxConcurrency,
maxThreads,
memoryBytes,
lowMemoryThresholdBytes,
lowMemory,
loaded,
load
}
}
Loaded 100 of 396 files, more files were not shown because too many files have changed in this diff. Show more