Author SHA1 Message Date
tianmo 51d3acfbb5 用户管理与数据操作 2026-10-27 11:03:36 +08:00
tianmo ed0f20b754 mac启动停止脚本 2026-10-19 19:02:23 +08:00
tianmo ccfdac2e1b feat: 支持注销用户并展示普通用户使用情况
- 新增管理员注销用户接口,要求输入用户名二次确认
- 注销时清理用户当前图片、视频、文件、标签、会话和自身日志
- 保留备份任务、管理员审计数据和全局配置
- 用户列表展示图片、视频、文件占用,管理员行隐藏使用情况和操作按钮
- 补充注销流程与用量统计测试
2026-10-19 17:48:44 +08:00
tianmo dd0005b7ba 清除密码 2026-10-19 16:27:48 +08:00
tianmo 366b8a4272 fix: 多选同步弹窗复用站点同步表单缓存
- 抽取站点地址与 Token 的 24 小时缓存工具
- 存储页与多选同步弹窗共用 pichost.site-sync-form
- 弹窗打开时读取最新缓存,修改后写回
- 增加缓存读取与过期测试
2026-10-19 16:10:48 +08:00
tianmo 58424fe55d fix: 支持备份恢复与站点同步的大文件上传 版本更新到1.5.4
- 备份恢复的 multipart 分支改用 Web FormData,移除旧解析器约 128 MB 的限制
- 初始化恢复新增 application/octet-stream 流式写盘,前端改为直接上传文件流,避免整包进入内存
- 站点同步改用 Web FormData,并在读取请求体前拒绝超过 500 MB 的文件
- 新增 multipart 字段、重复字段和文件读取回归测试

验证:
- 2 GB 测试备份包流式恢复预览成功,服务内存保持低位
- 130 MB 站点同步上传成功
- 原有备份预览及图片、视频、文件上传正常
- npm test 234 项通过;npm run typecheck 与 eslint 通过
2026-10-19 15:26:13 +08:00
tianmo 3759422222 超大文件上传限制 2026-10-19 15:04:41 +08:00
Yeomai 885534378b 自述修改 2026-10-18 21:35:59 +08:00
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
263 changed files with 25358 additions and 1540 deletions

No files matched your search

+7
View File
@@ -5,5 +5,12 @@ node_modules
.github
data
dist
dist-fpk
dist-windows
dist-docker
packaging
scripts
.env
*.md
._*
.DS_Store
+3 -1
View File
@@ -31,7 +31,9 @@
# WEBP_QUALITY=80
# 上传限制(也可在设置 → 访问控制配置;环境变量优先级更高)
# UPLOAD_MAX_FILE_SIZE_MB=10
# 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
-42
View File
@@ -1,42 +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
- name: Build docs
run: npm run docs:build
-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
+3 -2
View File
@@ -5,6 +5,7 @@
.nitro
.cache
dist
dist-windows
# Node dependencies
node_modules
@@ -37,9 +38,9 @@ data
# 本地 Git hooks 模板(仅安装在 .git/hooks/,不进仓库)
git-hooks/
# 本地维护脚本(assign-image-owner、fpk 打包等,不进仓库)
scripts/
# 打包输出目录
dist-fpk/
dist-docker/
# VitePress docs site
docs-site/.vitepress/cache
+59
View File
@@ -4,6 +4,65 @@
## [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
### 新增
+3
View File
@@ -18,6 +18,9 @@ FROM node:22-alpine
WORKDIR /app
# 视频床转码依赖 ffmpeg(单码率 HLS 切片)
RUN apk add --no-cache ffmpeg
ENV NODE_ENV=production \
NITRO_PORT=6892 \
DATA_DIR=/data
+152 -41
View File
@@ -4,32 +4,37 @@
<h1 align="center">PicHost</h1>
<p align="center"><strong>Lightweight personal image hosting</strong> · Simple uploads, clean management</p>
<p align="center"><strong>Self-hosted image, video, and file management platform</strong></p>
<p align="center">Self-hosted / Multi-user / Docker / API / Twikoo · Local disk or object storage</p>
<p align="center">Multi-user · WebP compression · HLS video · File hosting · Tags · Multi-backend storage · Full backup and migration</p>
<p align="center">
<a href="https://github.com/O96u/PicHost/blob/main/package.json"><img src="https://img.shields.io/github/package-json/v/O96u/PicHost?style=flat-square&color=22c55e" alt="version" /></a>
<a href="https://github.com/O96u/PicHost/actions/workflows/ci.yml"><img src="https://github.com/O96u/PicHost/actions/workflows/ci.yml/badge.svg" alt="CI" /></a>
<a href="https://github.com/yeomaiyelove-cpu/PicHost/blob/main/package.json"><img src="https://img.shields.io/github/package-json/v/yeomaiyelove-cpu/PicHost?style=flat-square&color=22c55e" alt="version" /></a>
<img src="https://img.shields.io/badge/Nuxt-4-00DC82?style=flat-square&logo=nuxt.js&logoColor=white" alt="Nuxt 4" />
<a href="https://hub.docker.com/r/muxui/pichost"><img src="https://img.shields.io/badge/Docker-muxui%2Fpichost-2496ED?style=flat-square&logo=docker&logoColor=white" alt="Docker" /></a>
<a href="https://hub.docker.com/r/yeomaiyelove/pichost"><img src="https://img.shields.io/badge/Docker-yeomaiyelove%2Fpichost-2496ED?style=flat-square&logo=docker&logoColor=white" alt="Docker" /></a>
<img src="https://img.shields.io/badge/Windows-x64-0078D4?style=flat-square&logo=windows&logoColor=white" alt="Windows x64" />
<img src="https://img.shields.io/badge/FnOS-x86-00A67E?style=flat-square" alt="FnOS x86" />
<img src="https://img.shields.io/badge/license-GPL--3.0-blue?style=flat-square" alt="GPL-3.0" />
</p>
<p align="center">
<a href="https://o96u.github.io/PicHost/en/">Docs</a> ·
<a href="#screenshots">Screenshots</a> ·
<a href="#features">Features</a> ·
<a href="#tech-stack">Tech Stack</a> ·
<a href="#quick-start">Quick Start</a> ·
<a href="#deployment">Deployment</a> ·
<a href="#development-and-packaging">Development</a> ·
<a href="docs-site/en/">Documentation source</a> ·
<a href="README.md">简体中文</a>
</p>
---
## Screenshots
## Overview
**main** has no public demo. **cloudflare** branch live demo: [pic.roven.cc](https://pic.roven.cc)
PicHost is a media management platform for NAS devices, home servers, and small self-hosted deployments. It manages images, videos, and general-purpose files in one place, with multi-user access, tags, search, multiple storage backends, HLS video processing, full-site backup and restore, backend migration, and site-to-site synchronization.
The first visit opens a web setup wizard. Administrators can either create an account or restore an existing `.phost.tar.gz` backup.
## Screenshots
| Upload | Gallery |
| :----: | :-----: |
@@ -53,29 +58,60 @@
## Features
- **Drag, click, or Ctrl+V paste** — server-side WebP, Referer hotlink protection
- **Multi-user** — login with slider, Turnstile, or Cap verification; optional registration; users see only their images
- **Multi-backend storage** — local disk + S3-compatible (R2 / COS / OSS / AWS); hybrid `proxy` / `public` URLs
- **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
- **Tags** — per-user colored tags; tag on upload (single or batch); manage in Settings (create, recolor, merge, delete); API supports `tagIds` and batch tagging
- **Backup & migration** — full-site export/restore (`.phost.tar.gz`), cross-backend sync; restore from backup on uninitialized `/setup`
- **API & Twikoo** — global / per-user tokens; `POST /api/index.php` compatible
- **Zero-config Docker** — first-run web wizard, no secrets upfront
### Images
## Tech Stack
- Upload by drag and drop, file picker, or `Ctrl+V` paste, with configurable formats, size limits, batch limits, and rate limits.
- Server-side Sharp-based WebP compression, with optional original preservation and optional browser-side WebP compression.
- Search, pagination, storage/source/tag filters, grid or list layouts, detail views, multiple link formats, bulk tagging, and bulk deletion.
- Media counts and source statistics, Referer hotlink protection, and configurable referrer allowlists.
| 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.) |
| 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 |
### Video and HLS
## Quick Start
- Dedicated video upload and library pages for MP4, M4V, WebM, MOV, MKV, AVI, FLV, WMV, MPG, and MPEG.
- Automatic thumbnail generation and optional asynchronous HLS transcoding.
- `copy`, `veryfast`, `medium`, `slow`, and `compress` presets, with configurable concurrency, CPU thread count, and target bitrate.
- Transcode progress and failure reporting, with automatic cleanup of the source, thumbnail, playlist, and HLS segments.
- Original, thumbnail, and HLS links, plus MP4 remux download for HLS videos.
### File Hosting
- A dedicated file library with uploads, search, pagination, tags, bulk deletion, direct links, and download links.
- Configurable file-bed access policy, with separate domains supported for images, videos, and files.
### Users and Access Control
- Administrator and standard-user accounts; standard users only see and manage their own media.
- Slider, Cloudflare Turnstile, and Cap verification, with optional public registration.
- Profile and password management, administrator password reset, and account-system migration.
- Per-user tags and automatic deletion policies.
- Login and upload rate limits, global and personal API tokens, and activity logs.
### Storage
- Local disk, S3-compatible object storage, and Telegram channels or groups.
- S3-compatible support includes R2, COS, OSS, AWS S3, and similar services, with `proxy` or `public` access modes.
- Multiple backends, configurable default and enabled states, and per-backend capacity quotas.
- Migration of images, videos, files, and indexes between configured backends, with an optional target backend switch after migration.
### Backup and Synchronization
- Complete `.phost.tar.gz` site backups containing the database, images, videos, files, thumbnails, HLS content, and tags.
- Restore previews for file count, media size, new files, conflicts, and skip/overwrite conflict handling.
- Streaming uploads for large backups without loading the complete archive into memory.
- Restore directly from the first-run setup page; cloud storage secrets are excluded and must be re-entered after restore.
- Push or pull synchronization between two PicHost sites, for all media or only images, videos, or files.
- Resume support, conflict policies, and job progress; matching usernames must already exist on both sites.
### API and Compatibility
- APIs for images, videos, files, tags, search, bulk operations, backups, and system information.
- Global and personal tokens, accepted through request headers or upload forms.
- Twikoo / EasyImage-compatible uploads through `POST /api/index.php`.
- Built-in API documentation and debugger with generated `curl` examples.
## Deployment
### Docker
```bash
docker run -d \
@@ -83,22 +119,97 @@ docker run -d \
-p 6892:6892 \
-v ./data:/data \
--restart unless-stopped \
muxui/pichost:latest
yeomaiyelove/pichost:latest
```
Open `http://<host>:6892` and complete the setup wizard.
Open `http://<host>:6892` and complete the setup wizard. Data is stored in the host `./data` directory by default.
**Full guide** (env vars, upgrades, dual-domain setup, API, Twikoo, reverse proxy, local dev, etc.): **[Documentation](https://o96u.github.io/PicHost/en/)**.
Docker Hub:
| Branch | Notes |
| ------ | ----- |
| [**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) |
- `yeomaiyelove/pichost:latest`
- `yeomaiyelove/pichost:<version>`
### Windows
The Windows x64 installer is self-contained:
- Node.js and FFmpeg are bundled; no runtime download is required on the target computer.
- The first launch opens the web setup wizard.
- Data is stored in the `data` directory under the installation directory.
- Uninstall asks whether to delete the data directory. If preserved, the database, media, and backups remain on disk.
### FnOS
FnOS uses a dedicated x86 FPK package that runs the bundled Docker image:
1. Import the matching `yeomaiyelove/pichost:<version>` image into FnOS Docker.
2. Confirm that the image appears locally.
3. Install the FPK and choose account creation or backup restore in the wizard.
The FPK uses the local image and does not pull PicHost from the internet.
## Development and Packaging
### Local Development
```bash
npm ci
npm run dev
```
The first visit opens `/setup`. Common verification commands:
```bash
npm test
npm run typecheck
npm run lint
npm run build
```
### Release Artifacts
```bash
npm run pack:docker
npm run pack:fpk
npm run pack:windows
```
On Windows, all artifacts can be built at once:
```bash
npm run pack:all
```
Output directories:
- Docker image archive: `dist-docker/`
- FnOS package: `dist-fpk/`
- Windows installer: `dist-windows/release/`
See [packaging/README.md](packaging/README.md) for requirements and override options.
## Data Directory
- Docker uses `/data` by default and supports the `DATA_DIR` override.
- Windows installs use the `data` directory under the installation path.
- Local development uses `data` in the project root.
The database is stored as `pichost.db`. Create regular full-site backups and keep the `.phost.tar.gz` files on separate storage.
## Tech Stack
| Layer | Technologies |
| ----- | ------------ |
| Frontend | Nuxt 4 · Nuxt UI 4 · Vue 3 · Tailwind CSS 4 · TypeScript |
| Backend | Nitro `node-server` · REST API · H3 |
| Database | SQLite |
| Images | Sharp (WebP compression) |
| Video | FFmpeg · HLS · hls.js |
| Storage | Local disk · S3-compatible object storage · Telegram |
| Internationalization | @nuxtjs/i18n (Simplified Chinese / English) |
| Quality and documentation | Vitest · ESLint · vue-tsc · VitePress |
| Deployment | Docker · Node.js 22 · Electron (Windows) · FnOS FPK |
## License
[GPL-3.0](LICENSE)
## Friend links
[LINUX DO](https://linux.do/)
+154 -42
View File
@@ -4,32 +4,37 @@
<h1 align="center">PicHost</h1>
<p align="center"><strong>个人轻量图床</strong> · 图片放好,管理也要顺手</p>
<p align="center"><strong>自托管图片、视频与文件管理平台</strong></p>
<p align="center">自托管图床 / 多用户 / Docker / API / Twikoo · 本地磁盘或对象存储</p>
<p align="center">多用户 · WebP 压缩 · HLS 视频 · 文件床 · 标签 · 多存储后端 · 整站备份与迁移</p>
<p align="center">
<a href="https://github.com/O96u/PicHost/blob/main/package.json"><img src="https://img.shields.io/github/package-json/v/O96u/PicHost?style=flat-square&color=22c55e" alt="version" /></a>
<a href="https://github.com/O96u/PicHost/actions/workflows/ci.yml"><img src="https://github.com/O96u/PicHost/actions/workflows/ci.yml/badge.svg" alt="CI" /></a>
<a href="https://github.com/yeomaiyelove-cpu/PicHost/blob/main/package.json"><img src="https://img.shields.io/github/package-json/v/yeomaiyelove-cpu/PicHost?style=flat-square&color=22c55e" alt="version" /></a>
<img src="https://img.shields.io/badge/Nuxt-4-00DC82?style=flat-square&logo=nuxt.js&logoColor=white" alt="Nuxt 4" />
<a href="https://hub.docker.com/r/muxui/pichost"><img src="https://img.shields.io/badge/Docker-muxui%2Fpichost-2496ED?style=flat-square&logo=docker&logoColor=white" alt="Docker" /></a>
<a href="https://hub.docker.com/r/yeomaiyelove/pichost"><img src="https://img.shields.io/badge/Docker-yeomaiyelove%2Fpichost-2496ED?style=flat-square&logo=docker&logoColor=white" alt="Docker" /></a>
<img src="https://img.shields.io/badge/Windows-x64-0078D4?style=flat-square&logo=windows&logoColor=white" alt="Windows x64" />
<img src="https://img.shields.io/badge/%E9%A3%9E%E7%89%9B-x86-00A67E?style=flat-square" alt="飞牛 x86" />
<img src="https://img.shields.io/badge/license-GPL--3.0-blue?style=flat-square" alt="GPL-3.0" />
</p>
<p align="center">
<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="docs-site/">文档源码</a> ·
<a href="README.en.md">English</a>
</p>
---
## 界面预览
## 项目简介
**main** 分支暂无公网演示;**cloudflare** 分支在线体验:[pic.roven.cc](https://pic.roven.cc)
PicHost 是一个面向 NAS、家庭服务器和小型自托管场景的媒体管理平台,可统一管理图片、视频和普通文件。除上传与直链外,还提供多用户、标签、搜索、多存储后端、HLS 视频处理、整站备份恢复、跨后端迁移以及两站点同步。
首次启动通过 Web 向导创建管理员,也可以直接从 `.phost.tar.gz` 完整备份恢复。
## 界面预览
| 上传 | 图库 |
| :--: | :--: |
@@ -51,31 +56,63 @@
| :------: |
| ![操作日志](docs/screenshots/logs.png) |
## 特性
## 核心功能
- **拖拽 / 点击 / Ctrl+V 粘贴**上传;服务端 WebP 压缩、Referer 防盗链
- **多用户**:账号密码登录(滑块 / Turnstile / Cap 人机验证)、可选开放注册;普通用户仅见自己的图片
- **多后端存储**:本地磁盘 + S3 兼容(R2 / COS / OSS / AWS);混合直链 `proxy` / `public`
- **图库**:浏览、搜索、按存储/来源/标签筛选、网格/列表切换、批量删除与批量打标;统计概览与来源分布;点击图片查看详情与多格式链接
- **标签**:每用户独立标签、彩色标记;上传后单张/批量打标;设置页管理(新建、改色、合并、删除);API 支持 `tagIds` 与批量打标
- **备份与迁移**:整站导出/恢复(`.phost.tar.gz`)、跨后端同步;setup 未初始化时可从备份恢复
- **API 与 Twikoo**:全局 / 个人 Token;`POST /api/index.php` 兼容
- **Docker 零配置**:首次访问 Web 引导创建管理员
### 图片管理
## 技术栈
- 支持拖拽、文件选择和 `Ctrl+V` 粘贴上传,可配置格式、大小、批量数量和频率限制。
- 服务端使用 Sharp 压缩为 WebP,也可保留原图;支持可选的浏览器端 WebP 压缩。
- 图库支持搜索、分页、存储后端/上传来源/标签筛选,以及网格和列表视图。
- 支持查看详情、复制多种链接格式、批量打标和批量删除,并提供媒体数量与来源统计。
- 支持 Referer 防盗链和来源域名白名单。
| 层级 | 技术 |
| ---- | ---- |
| 前端 | [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 等) |
| 国际化 | [@nuxtjs/i18n](https://i18n.nuxtjs.org)(简体中文 / English) |
| 质量与文档 | [Vitest](https://vitest.dev) · [VitePress](https://vitepress.dev) 文档站 |
| 部署 | Docker · Node.js 22 |
### 视频库与 HLS
## 快速开始
- 独立的视频上传、搜索和管理页面,支持 MP4、M4V、WebM、MOV、MKV、AVI、FLV、WMV、MPG、MPEG 等格式。
- 上传后自动生成视频缩略图,并可根据设置异步转码为 HLS。
- 支持 `copy`、`veryfast`、`medium`、`slow`、`compress` 等转码档位,可限制并发数、CPU 核心数和目标码率。
- 管理转码进度与失败原因,支持删除时同步清理原视频、缩略图、播放列表和 HLS 分片。
- 支持复制原视频、缩略图和 HLS 地址,并可将 HLS 视频重新封装下载为 MP4。
### 文件床
- 独立管理普通文件,提供上传、搜索、分页、标签、批量删除和直链/下载链接。
- 文件床访问策略可在后台配置,文件、图片和视频可分别使用独立域名。
### 多用户与权限
- 管理员与普通用户账号体系,普通用户只能查看和管理自己的媒体。
- 支持滑块、Cloudflare Turnstile 和 Cap 人机验证,可选择开放注册。
- 支持修改资料、修改密码、管理员清空密码和迁移到账号密码登录。
- 每个用户拥有独立标签和自动删除策略,可设置媒体保存天数。
- 提供登录与上传频率限制、全局/个人 API Token 和操作日志。
### 存储与迁移
- 支持本地磁盘、S3 兼容对象存储和 Telegram 频道/群组。
- S3 兼容 R2、COS、OSS、AWS S3 等服务,支持 `proxy` 和 `public` 两种访问方式。
- 可配置多个存储后端、默认后端、启用状态和容量配额。
- 支持在已配置后端之间迁移图片、视频、文件和索引,迁移后可将目标后端设为默认。
### 备份与站点同步
- 创建和恢复完整 `.phost.tar.gz` 站点备份,包含数据库、图片、视频、文件、缩略图、HLS 内容和标签。
- 备份恢复前可预检文件数量、媒体大小、新增与冲突情况,并选择跳过或覆盖冲突。
- 支持大体积备份流式上传,无需将整个压缩包读入内存。
- 未初始化时可在首次设置页直接恢复备份;云存储密钥不会写入备份,恢复后需重新填写。
- 支持两站点双向同步,可推送或拉取全部媒体,也可只同步图片、视频或文件。
- 站点同步支持断点继续、冲突策略和任务进度;目标站点需要预先存在同名用户。
### API 与兼容性
- 提供图片、视频、文件、标签、搜索、批量操作、备份和系统接口。
- 支持全局 Token 和个人 Token,可通过请求头或表单上传。
- 兼容 Twikoo / EasyImage 的 `POST /api/index.php` 上传接口。
- 前端内置 API 文档和调试界面,支持直接生成并复制 `curl` 示例。
## 部署方式
### Docker
```bash
docker run -d \
@@ -83,22 +120,97 @@ docker run -d \
-p 6892:6892 \
-v ./data:/data \
--restart unless-stopped \
muxui/pichost:latest
yeomaiyelove/pichost:latest
```
浏览器打开 `http://<主机IP>:6892`,按引导完成设置即可。
浏览器打开 `http://<主机IP>:6892`,按向导创建管理员即可。数据默认保存在宿主机的 `./data` 目录。
**详细说明**(环境变量、双域名、API、Twikoo、反向代理、本地开发等)见 **[用户文档](https://o96u.github.io/PicHost/)**。
Docker Hub:
| 分支 | 说明 |
- `yeomaiyelove/pichost:latest`
- `yeomaiyelove/pichost:<版本号>`
### Windows
项目提供 Windows x64 一体安装包:
- 内置 Node.js 和 FFmpeg,不依赖目标电脑预装运行环境。
- 首次启动后按 Web 向导完成设置。
- 数据保存在安装目录下的 `data` 子目录。
- 卸载时会询问是否删除数据;选择保留后,数据库、图片、视频和备份不会被删除。
### 飞牛 NAS
飞牛使用独立的 x86 FPK 安装包,FPK 以内置 Docker 镜像方式运行:
1. 在飞牛 Docker 中导入与 FPK 版本对应的镜像包。
2. 确认镜像列表中存在 `yeomaiyelove/pichost:<版本号>`。
3. 安装 FPK,并按向导选择创建管理员或从备份恢复。
FPK 使用本地镜像,不会自动联网拉取 PicHost 镜像。
## 开发与打包
### 本地开发
```bash
npm ci
npm run dev
```
默认开发地址由 Nuxt 输出,首次访问会进入 `/setup`。常用检查命令:
```bash
npm test
npm run typecheck
npm run lint
npm run build
```
### 构建安装产物
```bash
npm run pack:docker
npm run pack:fpk
npm run pack:windows
```
Windows 环境可以一次构建全部产物:
```bash
npm run pack:all
```
输出位置:
- Docker 离线镜像:`dist-docker/`
- 飞牛安装包:`dist-fpk/`
- Windows 安装程序:`dist-windows/release/`
详细环境要求和可覆盖参数见 [打包说明](packaging/README.md)。
## 数据目录
- Docker 默认使用 `/data`,通过 `DATA_DIR` 可覆盖。
- Windows 安装版使用安装目录下的 `data`。
- 本地开发默认使用项目根目录下的 `data`。
数据库文件为 `pichost.db`。建议定期在后台创建整站备份,并将 `.phost.tar.gz` 文件保存到其他磁盘。
## 技术栈
| 层级 | 技术 |
| ---- | ---- |
| [**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) |
| 前端 | Nuxt 4 · Nuxt UI 4 · Vue 3 · Tailwind CSS 4 · TypeScript |
| 后端 | Nitro `node-server` · REST API · H3 |
| 数据库 | SQLite |
| 图片处理 | Sharp(WebP 压缩) |
| 视频处理 | FFmpeg · HLS · hls.js |
| 存储 | 本地磁盘 · S3 兼容对象存储 · Telegram |
| 国际化 | @nuxtjs/i18n(简体中文 / English) |
| 测试与文档 | Vitest · ESLint · vue-tsc · VitePress |
| 部署 | Docker · Node.js 22 · Electron(Windows)· 飞牛 FPK |
## License
[GPL-3.0](LICENSE)
## 友链
[LINUX DO](https://linux.do/)
+24 -1
View File
@@ -7,22 +7,44 @@
.upload-card-flip-inner {
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;
}
.upload-card-flip.is-flipped .upload-card-flip-inner {
min-height: min(40rem, calc(100vh - 11rem));
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 {
backface-visibility: hidden;
-webkit-backface-visibility: hidden;
}
.upload-card-face--front {
position: relative;
z-index: 1;
}
.upload-card-face--back {
position: absolute;
inset: 0;
z-index: 2;
transform: rotateY(180deg);
}
@@ -33,6 +55,7 @@
.upload-card-flip.is-flipped .upload-card-flip-inner {
transform: none;
min-height: 0;
}
.upload-card-flip .upload-card-face--back {
+6
View File
@@ -70,6 +70,12 @@ async function submit() {
secret.value = ''
await resetVerification()
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 {
await resetVerification()
toast.add({
+81 -29
View File
@@ -13,16 +13,52 @@ const mobileUserOpen = ref(false)
const navItems = computed(() => {
const items = [
{ label: t('nav.api'), to: '/api', icon: 'i-lucide-code-2' },
{ label: t('nav.gallery'), to: '/gallery', icon: 'i-lucide-images' }
{ 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) {
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.settings'), to: '/settings', icon: 'i-lucide-settings' })
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[]>(() => [
{
label: t('profile.title'),
icon: 'i-lucide-user-round',
to: '/account/profile'
},
{
label: t('nav.changePassword'),
icon: 'i-lucide-key-round',
@@ -66,34 +102,50 @@ async function handleLogout() {
<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="flex items-center justify-between gap-3">
<NuxtLink
to="/"
:title="t('nav.backToUpload')"
class="group flex min-w-0 shrink items-center gap-2.5 transition-opacity hover:opacity-90 sm:gap-3"
>
<img
:src="logoLight"
alt="PicHost"
class="h-9 w-auto shrink-0 object-contain sm:h-11 dark:hidden"
decoding="async"
draggable="false"
<div class="flex min-w-0 items-center gap-2 sm:gap-3">
<NuxtLink
to="/"
:title="t('host.imageFull')"
class="group flex min-w-0 shrink items-center gap-2.5 transition-opacity hover:opacity-90 sm:gap-3"
>
<img
:src="logoDark"
alt="PicHost"
class="hidden h-9 w-auto shrink-0 object-contain sm:h-11 dark:block"
decoding="async"
draggable="false"
>
<div class="min-w-0 leading-tight">
<p class="text-[15px] font-bold tracking-tight sm:text-base">
<span class="text-highlighted">Pic</span><span class="text-primary">Host</span>
</p>
<p class="mt-0.5 hidden text-[11px] text-muted min-[380px]:block sm:text-xs">
{{ t('app.tagline') }}
</p>
</div>
</NuxtLink>
<img
:src="logoLight"
alt="PicHost"
class="h-9 w-auto shrink-0 object-contain sm:h-11 dark:hidden"
decoding="async"
draggable="false"
>
<img
:src="logoDark"
alt="PicHost"
class="hidden h-9 w-auto shrink-0 object-contain sm:h-11 dark:block"
decoding="async"
draggable="false"
>
<div class="min-w-0 leading-tight">
<p class="text-[15px] font-bold tracking-tight sm:text-base">
<span class="text-highlighted">Pic</span><span class="text-primary">Host</span>
</p>
<p class="mt-0.5 hidden text-[11px] text-muted min-[380px]:block sm:text-xs">
{{ t('app.tagline') }}
</p>
</div>
</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>
<div class="flex shrink-0 items-center gap-0.5 sm:gap-1">
@@ -124,7 +176,7 @@ async function handleLogout() {
/>
<MobileNavMenu
v-model:open="mobileNavOpen"
:items="navItems"
:items="mobileNavItems"
/>
</div>
+14 -2
View File
@@ -1,6 +1,4 @@
<script setup lang="ts">
import 'cap-widget'
const props = defineProps<{
apiEndpoint: string
}>()
@@ -13,6 +11,19 @@ const emit = defineEmits<{
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
@@ -37,6 +48,7 @@ defineExpose({ reset })
<template>
<ClientOnly>
<cap-widget
v-if="ready"
ref="widgetRef"
:data-cap-api-endpoint="props.apiEndpoint"
:data-cap-i18n-initial-state="t('auth.capInitialState')"
+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>
+16 -1
View File
@@ -1,10 +1,16 @@
<script setup lang="ts">
import type { MediaType } from '~/types/media'
const props = defineProps<{
open: boolean
count: number
loading?: boolean
/** 删除对象的类型,决定确认文案 */
kind?: MediaType
}>()
const kind = computed<MediaType>(() => props.kind ?? 'image')
const emit = defineEmits<{
'update:open': [value: boolean]
'confirm': []
@@ -12,7 +18,16 @@ const emit = defineEmits<{
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() {
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>
@@ -1,33 +1,31 @@
<script setup lang="ts">
export interface GalleryStatsData {
uploadToday: number
uploadYesterday: number
uploadMonth: number
uploadLastMonth: number
uploadTotal: number
storedCount: number
uploadBytesTotal: number
bySource: {
web: number
api: number
}
storageUsage: {
usedBytes: number
totalBytes: number | null
percent: number | null
}
}
import type { LibraryStatsData, MediaType } from '~/types/media'
const props = defineProps<{
stats: GalleryStatsData | null
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 []
@@ -147,7 +145,7 @@ function displayValue(value: number | undefined) {
{{ displayValue(stats?.uploadTotal) }}
</p>
<p class="mt-2 text-xs text-muted">
{{ t('stats.totalFiles') }}
{{ totalHint }}
</p>
</div>
+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>
+1 -1
View File
@@ -17,7 +17,7 @@ const emit = defineEmits<{
'created': [tag: ImageTag]
}>()
const COMMON_LIMIT = 12
const COMMON_LIMIT = 10
const { t } = useI18n()
const toast = useToast()
const { createTag } = useTags()
+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>
File diff suppressed because it is too large. Load diff
@@ -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>
@@ -2,9 +2,24 @@
import uploadCardBg from '~/assets/image/upload-card-bg.webp'
import uploadCardHero from '~/assets/image/upload-card-hero.webp'
const props = defineProps<{
const props = withDefaults(defineProps<{
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<{
'upload': [files: File[]]
@@ -18,9 +33,22 @@ const { t } = useI18n()
const { bindPaste } = useClipboardImage(ref(null))
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() {
if (props.disabled) return
fileInput.value?.click()
@@ -38,8 +66,7 @@ function onDrop(event: DragEvent) {
dragging.value = false
if (props.disabled) return
const files = Array.from(event.dataTransfer?.files ?? [])
.filter(file => file.type.startsWith('image/'))
const files = Array.from(event.dataTransfer?.files ?? []).filter(matchesMediaKind)
if (files.length) emit('upload', files)
}
@@ -83,7 +110,7 @@ function onKeydown(event: KeyboardEvent) {
<input
ref="fileInput"
type="file"
accept="image/jpeg,image/png,image/webp,image/gif,image/svg+xml,image/x-icon,.svg,.ico"
:accept="accept"
multiple
class="hidden"
:disabled="disabled"
@@ -91,7 +118,7 @@ function onKeydown(event: KeyboardEvent) {
>
<img
:src="uploadCardBg"
:src="backgroundSource"
alt=""
aria-hidden="true"
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"
>
<div class="absolute top-3 right-3 z-20">
<div
v-if="showSettings"
class="absolute top-3 right-3 z-20"
>
<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"
@@ -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="flex flex-col items-center">
<img
:src="uploadCardHero"
:src="heroSource"
alt=""
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"
@@ -128,16 +158,16 @@ function onKeydown(event: KeyboardEvent) {
<div class="space-y-2">
<p class="text-xl font-semibold tracking-tight text-highlighted sm:text-2xl">
{{ t('upload.title') }}
{{ titleText }}
</p>
<p class="text-sm text-muted sm:text-base">
{{ t('upload.hint', { ctrl: 'Ctrl', v: 'V' }) }}
{{ hintText }}
</p>
</div>
</div>
<p class="mt-4 max-w-md text-xs leading-relaxed text-dimmed sm:text-sm">
{{ t('upload.formats') }}
{{ formatsText }}
</p>
</div>
</div>
+38 -5
View File
@@ -1,7 +1,20 @@
<script setup lang="ts">
defineProps<{
import type { UploadMediaKind } from '~/composables/useUploadPreferences'
const props = withDefaults(defineProps<{
disabled?: boolean
}>()
/** 上传页类型:决定默认可选文件类型、粘贴支持与偏好面板内容 */
kind?: UploadMediaKind
title?: string
hint?: string
formats?: string
accept?: string
heroImage?: string
backgroundImage?: string
pasteEnabled?: boolean
}>(), {
kind: 'image'
})
const emit = defineEmits<{
upload: [files: File[]]
@@ -9,6 +22,15 @@ const emit = defineEmits<{
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() {
flipped.value = true
}
@@ -21,18 +43,29 @@ function onBack() {
<template>
<div
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-face upload-card-face--front">
<ImageUploader
<UploadCard
: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)"
@open-settings="onOpenSettings"
/>
</div>
<div class="upload-card-face upload-card-face--back">
<UploadPreferencesPanel @back="onBack" />
<UploadPreferencesPanel
:kind="kind"
@back="onBack"
/>
</div>
</div>
</div>
+190 -6
View File
@@ -1,4 +1,16 @@
<script setup lang="ts">
import {
normalizeUploadTagNames,
type UploadMediaKind
} from '~/composables/useUploadPreferences'
const props = withDefaults(defineProps<{
/** 每个上传页各自一份偏好 */
kind?: UploadMediaKind
}>(), {
kind: 'image'
})
const emit = defineEmits<{
back: []
}>()
@@ -8,10 +20,43 @@ const {
autoCopyMarkdown,
copyFormat,
clientWebpQuality,
uploadTagNames,
transcodeToHls,
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 {
lowMemory,
memoryBytes,
load: loadTranscodeSettings
} = useTranscodeSettings()
const {
userSettings,
@@ -33,6 +78,12 @@ const clientQualityLevel = computed(() => {
return t('preferences.qualityLow')
})
const memoryGb = computed(() => (
memoryBytes.value > 0
? (memoryBytes.value / (1024 * 1024 * 1024)).toFixed(1)
: '—'
))
function setCopyFormat(value: 'url' | 'markdown' | 'html' | 'bbcode') {
copyFormat.value = value
}
@@ -42,9 +93,39 @@ function onClientQualityInput(event: Event) {
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(() => {
loadPreferences()
void loadUserSettings()
void loadTranscodeSettings()
})
</script>
@@ -77,9 +158,15 @@ onMounted(() => {
/>
</div>
<div class="flex flex-1 flex-col justify-center p-5 sm:p-6">
<div class="grid gap-5 sm:grid-cols-3 sm:gap-6">
<div class="space-y-4">
<div class="flex flex-1 flex-col justify-start overflow-y-auto p-5 sm:p-6">
<div
class="grid gap-5 md:grid-cols-2 md:gap-6"
>
<div
v-if="isImageKind"
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.processing') }}
</h3>
@@ -137,7 +224,50 @@ onMounted(() => {
</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">
{{ t('preferences.webpQuality') }}
</h3>
@@ -158,7 +288,10 @@ onMounted(() => {
>
</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">
{{ t('preferences.convenience') }}
</h3>
@@ -190,6 +323,57 @@ onMounted(() => {
/>
</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>
+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>
+24 -5
View File
@@ -65,8 +65,14 @@ function resetForm() {
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] = '["images/2026/08/example.webp"]'
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 {
@@ -84,6 +90,12 @@ 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()
}
@@ -101,8 +113,8 @@ function isParamFilled(param: ApiDocParam) {
if (!value) return false
if (param.location === 'body' && param.name === 'keys') {
try {
const parsed = JSON.parse(value) as { keys?: unknown }
return Array.isArray(parsed.keys) && parsed.keys.length > 0
const parsed = JSON.parse(value) as unknown
return Array.isArray(parsed) && parsed.length > 0
} catch {
return false
}
@@ -159,7 +171,14 @@ async function sendRequest() {
} else if (debugBodyParams.value.some(p => p.location === 'body')) {
headers['Content-Type'] = 'application/json'
try {
body = JSON.stringify(JSON.parse(paramValues.value.keys || '{}'))
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
@@ -297,7 +316,7 @@ const responseScrollable = computed(() => responseBody.value.split('\n').length
<input
:ref="(el) => { fileInputRefs[param.name] = el as HTMLInputElement }"
type="file"
accept="image/*"
:accept="fileAccept()"
class="hidden"
@change="onFileChange(param.name, $event)"
>
+16 -9
View File
@@ -38,19 +38,26 @@ const { t } = useI18n()
</h3>
</div>
<div class="overflow-x-auto">
<table class="w-full min-w-[32rem] text-left text-sm">
<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="px-4 py-2.5 font-medium">
<th class="whitespace-nowrap px-4 py-2.5 font-medium">
{{ t('api.paramType') }}
</th>
<th class="px-4 py-2.5 font-medium">
<th class="whitespace-nowrap px-4 py-2.5 font-medium">
{{ t('api.paramLocation') }}
</th>
<th class="px-4 py-2.5 font-medium">
<th class="whitespace-nowrap px-4 py-2.5 font-medium">
{{ t('api.paramRequired') }}
</th>
<th class="px-4 py-2.5 font-medium">
@@ -64,20 +71,20 @@ const { t } = useI18n()
:key="param.name"
class="border-b border-default last:border-b-0"
>
<td class="px-4 py-2.5 font-mono text-xs">
<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="px-4 py-2.5 text-muted">
<td class="whitespace-nowrap px-4 py-2.5 text-muted">
{{ param.type }}
</td>
<td class="px-4 py-2.5 text-muted">
<td class="whitespace-nowrap px-4 py-2.5 text-muted">
{{ param.location }}
</td>
<td class="px-4 py-2.5">
<td class="whitespace-nowrap px-4 py-2.5">
<UBadge
:color="param.required ? 'primary' : 'neutral'"
variant="subtle"
@@ -86,7 +93,7 @@ const { t } = useI18n()
{{ param.required ? t('api.required') : t('api.optional') }}
</UBadge>
</td>
<td class="px-4 py-2.5 text-muted">
<td class="px-4 py-2.5 leading-relaxed text-muted break-words">
{{ t(param.descriptionKey) }}
</td>
</tr>
+45 -5
View File
@@ -20,9 +20,18 @@ const docsUrl = computed(() =>
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()
@@ -38,6 +47,17 @@ 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)
}
@@ -59,6 +79,15 @@ onMounted(() => {
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>
@@ -89,13 +118,24 @@ onMounted(() => {
:key="category.key"
class="mb-4"
>
<p
<button
v-if="endpointsForCategory(category.key).length"
class="mb-1.5 px-2 text-xs font-medium text-muted"
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"
>
{{ t(category.labelKey) }}
</p>
<ul class="space-y-0.5">
<li
v-for="endpoint in endpointsForCategory(category.key)"
:key="endpoint.id"
-9
View File
@@ -75,15 +75,6 @@ async function copyToken() {
:description="t('api.envOverrideDesc')"
/>
<UAlert
v-else-if="!isAdmin"
color="info"
variant="subtle"
icon="i-lucide-info"
:title="t('api.personalTitle')"
:description="t('api.personalDesc')"
/>
<UAlert
v-else-if="!token && !loading"
color="info"
@@ -14,11 +14,13 @@ const props = withDefaults(defineProps<{
loading?: boolean
showBatchDelete?: boolean
showBatchTags?: boolean
showBatchSync?: boolean
}>(), {
selectedCount: 0,
loading: false,
showBatchDelete: true,
showBatchTags: false,
showBatchSync: false,
tagIds: () => [],
tags: () => [],
tagMode: 'or',
@@ -37,6 +39,7 @@ const emit = defineEmits<{
'reset': []
'batch-delete': []
'batch-tags': []
'batch-sync': []
'manage-tags': []
}>()
@@ -155,6 +158,20 @@ function viewButtonClass(active: boolean) {
{{ 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"
@@ -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>
@@ -7,7 +7,8 @@ defineProps<{
checking: boolean
}>()
const emit = defineEmits<{
// 「检查更新」已停用(改动较大,与上游版本号已不对应),暂时保留事件定义
defineEmits<{
checkUpdate: []
}>()
@@ -28,6 +29,7 @@ const { t } = useI18n()
<span class="text-sm font-medium tabular-nums text-highlighted">
v{{ appVersion }}
</span>
<!-- 检查更新功能已停用,需要时恢复下面这段
<UBadge
v-if="updateAvailable && latestVersion"
color="warning"
@@ -63,6 +65,7 @@ const { t } = useI18n()
>
{{ t('settings.viewReleases') }}
</a>
-->
</div>
</div>
</template>
@@ -1,11 +1,16 @@
<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'
defineProps<{
const props = defineProps<{
part: 'upload-preference' | 'image-processing' | 'login-rate'
uploadMaxFileSizeMbSource?: PolicySource
uploadImageMaxFileSizeMbSource?: PolicySource
uploadVideoMaxFileSizeMbSource?: PolicySource
uploadFileMaxFileSizeMbSource?: PolicySource
allowedMimeTypesSource?: PolicySource
allowedVideoExtensionsSource?: PolicySource
forbiddenFileExtensionsSource?: PolicySource
uploadRateIpMaxSource?: PolicySource
uploadRateTokenMaxSource?: PolicySource
uploadRateWindowMinutesSource?: PolicySource
@@ -13,16 +18,23 @@ defineProps<{
loginRateWindowMinutesSource?: PolicySource
preserveOriginalUploadSource?: PolicySource
mimeTypeOptions?: ReadonlyArray<{ mime: MimeType, label: string }>
videoExtensionOptions?: ReadonlyArray<{ extension: VideoExtension, label: string }>
forbiddenFileExtensionOptions?: ReadonlyArray<{ extension: string, label: string }>
}>()
const uploadMaxFileSizeMbDraft = defineModel<number>('uploadMaxFileSizeMb')
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()
@@ -53,72 +65,178 @@ function isMimeChecked(mime: MimeType): boolean {
return allowedMimeTypesDraft.value?.includes(mime) ?? false
}
function clampUploadMaxFileSizeMb(value: unknown): number {
const num = Number(value)
if (!Number.isFinite(num)) return uploadMaxFileSizeMbDraft.value ?? 10
return Math.min(100, Math.max(1, Math.round(num)))
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 onUploadMaxFileSizeInput(event: Event) {
uploadMaxFileSizeMbDraft.value = clampUploadMaxFileSizeMb(
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(value: string | number) {
uploadMaxFileSizeMbDraft.value = clampUploadMaxFileSizeMb(value)
function onUploadMaxFileSizeNumberInput(kind: UploadSizeKind, value: string | number) {
uploadSizeModel(kind).value = clampUploadMaxFileSizeMb(kind, value)
}
</script>
<template>
<template v-if="part === 'upload-preference'">
<div class="py-4">
<div class="flex flex-wrap items-center gap-2">
<p class="text-sm font-medium text-highlighted">
{{ t('settings.uploadMaxFileSize') }}
</p>
<UBadge
v-if="uploadMaxFileSizeMbSource"
:color="sourceBadge(uploadMaxFileSizeMbSource).color"
variant="subtle"
size="xs"
>
{{ sourceBadge(uploadMaxFileSizeMbSource).label }}
</UBadge>
</div>
<p class="mt-1 text-xs leading-relaxed text-muted">
{{ t('settings.uploadMaxFileSizeHint') }}
</p>
<div class="space-y-5 py-4">
<div
class="mt-3 flex items-center gap-3"
:class="uploadMaxFileSizeMbSource === 'env' ? 'pointer-events-none opacity-60' : ''"
v-for="field in uploadSizeFields"
:key="field.kind"
>
<input
type="range"
min="1"
max="100"
:value="uploadMaxFileSizeMbDraft"
class="h-1.5 min-w-0 flex-1 cursor-pointer appearance-none rounded-full bg-muted accent-primary"
:disabled="uploadMaxFileSizeMbSource === 'env'"
@input="onUploadMaxFileSizeInput"
>
<div class="flex shrink-0 items-center gap-1.5">
<UInput
:model-value="uploadMaxFileSizeMbDraft"
type="number"
:min="1"
:max="100"
<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"
class="w-14 tabular-nums"
:disabled="uploadMaxFileSizeMbSource === 'env'"
@update:model-value="onUploadMaxFileSizeNumberInput"
/>
<span class="text-xs text-muted">MB</span>
>
{{ 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="py-4">
<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') }}
@@ -158,6 +276,105 @@ function onUploadMaxFileSizeNumberInput(value: string | number) {
</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') }}
@@ -1,7 +1,12 @@
<script setup lang="ts">
defineProps<{
import type { UploadMediaKind } from '~/composables/useUploadPreferences'
const props = withDefaults(defineProps<{
part: 'upload-preference' | 'image-processing'
}>()
kind?: UploadMediaKind
}>(), {
kind: 'image'
})
const {
compressEnabled,
@@ -9,7 +14,7 @@ const {
copyFormat,
clientWebpQuality,
loadPreferences
} = useUploadPreferences()
} = useUploadPreferences(props.kind)
const { t } = useI18n()
@@ -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>
+66 -13
View File
@@ -8,6 +8,7 @@ defineProps<{
totalPages: number
total: number
pageSize: number
showHidden: boolean
loading?: boolean
}>()
@@ -15,6 +16,9 @@ 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]
@@ -26,7 +30,8 @@ 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')
backend_sync: t('storage.backup.jobTypeSync'),
site_sync: t('storage.backup.jobTypeSiteSync')
}
return map[type]
}
@@ -53,6 +58,12 @@ function progressPercent(job: BackupJobItem): number {
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')
@@ -78,6 +89,10 @@ 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 })
@@ -95,11 +110,33 @@ function formatTime(value: string) {
{{ t('storage.backup.jobsSubtitle') }}
</p>
</div>
<UIcon
v-if="polling"
name="i-lucide-loader-circle"
class="size-4 shrink-0 animate-spin text-muted"
/>
<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
@@ -135,7 +172,7 @@ function formatTime(value: string) {
{{ t('storage.backup.colProgress') }}
</th>
<th class="px-4 py-3 font-medium sm:px-5">
{{ t('storage.backup.colCreated') }}
{{ t('storage.backup.colUpdated') }}
</th>
<th class="px-4 py-3 font-medium sm:px-5">
{{ t('storage.backup.colActions') }}
@@ -174,7 +211,7 @@ function formatTime(value: string) {
class="text-xs text-dimmed"
>—</span>
</td>
<td class="min-w-32 px-4 py-3.5 sm:px-5">
<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
@@ -183,15 +220,20 @@ function formatTime(value: string) {
:style="{ width: `${progressPercent(job)}%` }"
/>
</div>
<span class="shrink-0 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 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.createdAt) }}
{{ 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">
@@ -231,6 +273,17 @@ function formatTime(value: string) {
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>
+19 -2
View File
@@ -54,6 +54,19 @@ function formatTime(value: string) {
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()
}
@@ -90,8 +103,12 @@ async function submit() {
archiveName: selectedPackage.value || undefined
})
confirmOpen.value = true
} catch {
toast.add({ title: t('storage.backup.previewFailed'), color: 'error' })
} catch (error: unknown) {
toast.add({
title: t('storage.backup.previewFailed'),
description: getErrorMessage(error),
color: 'error'
})
} finally {
previewLoading.value = false
}
+330
View File
@@ -0,0 +1,330 @@
<script setup lang="ts">
import type {
SiteSyncDirection,
SiteSyncRequest
} from '~/types/backup'
import { loadSiteSyncForm, saveSiteSyncForm } from '~/utils/site-sync-form'
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')
function loadSavedForm() {
if (!import.meta.client) return
const saved = loadSiteSyncForm()
if (saved?.targetBaseUrl) targetBaseUrl.value = saved.targetBaseUrl
if (saved?.token) token.value = saved.token
}
function persistForm() {
saveSiteSyncForm(targetBaseUrl.value, token.value)
}
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,134 @@
<script setup lang="ts">
import { loadSiteSyncForm, saveSiteSyncForm } from '~/utils/site-sync-form'
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)
watch(() => props.open, (open) => {
if (!open) return
const saved = loadSiteSyncForm()
if (saved) {
targetBaseUrl.value = saved.targetBaseUrl
token.value = saved.token
}
}, { immediate: true })
watch([targetBaseUrl, token], () => {
saveSiteSyncForm(targetBaseUrl.value, token.value)
})
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>
+11 -7
View File
@@ -21,13 +21,17 @@ const emit = defineEmits<{
const { t } = useI18n()
const typeIcon = computed(() =>
props.backend.type === 'local' ? 'i-lucide-hard-drive' : 'i-lucide-cloud'
)
const typeIcon = computed(() => {
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(() =>
props.backend.type === 'local' ? t('storage.backendLocal') : t('storage.backendCloud')
)
const typeLabel = computed(() => {
if (props.backend.type === 'local') return t('storage.backendLocal')
if (props.backend.type === 'telegram') return t('storage.backendTelegram')
return t('storage.backendCloud')
})
const actionsDisabled = computed(() => props.envOverride || props.busy)
@@ -38,7 +42,7 @@ function canSetDefault() {
}
function canEdit() {
return props.backend.type === 's3'
return props.backend.type === 's3' || props.backend.type === 'telegram'
}
function canToggle() {
+190 -92
View File
@@ -2,9 +2,10 @@
import type { QuotaUnit } from '~/composables/useFileSize'
import type { StorageBackendItem } from '~/types/storage'
type StorageProvider = 'r2' | 'cos' | 'oss' | 'aws' | 'custom'
type StorageProvider = 'r2' | 'cos' | 'oss' | 'aws' | 'custom' | 'telegram'
export interface StorageFormPayload {
backendType: 's3' | 'telegram'
name: string
provider: StorageProvider
config: {
@@ -13,10 +14,13 @@ export interface StorageFormPayload {
bucket: string
prefix?: string
forcePathStyle: boolean
chatId?: string
apiProxyUrl?: string
}
secrets: {
accessKeyId: string
secretAccessKey: string
botToken: string
}
servingMode: 'proxy' | 'public'
publicUrl: string
@@ -47,11 +51,18 @@ const prefix = ref('')
const forcePathStyle = ref(false)
const accessKeyId = ref('')
const secretAccessKey = ref('')
const chatId = ref('')
const botToken = ref('')
const apiProxyUrl = ref('')
const servingMode = ref<'proxy' | 'public'>('proxy')
const publicUrl = ref('')
const quotaValue = ref('')
const quotaUnit = ref<QuotaUnit>('GB')
const isTelegram = computed(() =>
provider.value === 'telegram' || props.backend?.type === 'telegram'
)
const quotaUnitOptions = computed(() =>
QUOTA_UNITS.map(unit => ({ label: unit, value: unit }))
)
@@ -61,7 +72,8 @@ const providerOptions = computed(() => [
{ label: t('storage.providerCos'), value: 'cos' as const },
{ label: t('storage.providerOss'), value: 'oss' 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(() => [
@@ -79,6 +91,10 @@ function applyProviderTemplate(value: StorageProvider) {
if (props.createMode) {
name.value = providerLabel(value)
}
if (value === 'telegram') {
servingMode.value = 'proxy'
return
}
switch (value) {
case 'r2':
endpoint.value = 'https://{account_id}.r2.cloudflarestorage.com'
@@ -114,21 +130,44 @@ function detectProvider(config: StorageBackendItem['config']): StorageProvider {
return 'custom'
}
function resetCreateDefaults() {
name.value = ''
provider.value = 'r2'
applyProviderTemplate('r2')
bucket.value = ''
prefix.value = ''
accessKeyId.value = ''
secretAccessKey.value = ''
chatId.value = ''
botToken.value = ''
apiProxyUrl.value = ''
servingMode.value = 'proxy'
publicUrl.value = ''
quotaValue.value = ''
quotaUnit.value = 'GB'
}
function loadBackend(backend: StorageBackendItem | null | undefined) {
if (!backend || backend.type !== 's3') {
if (props.createMode) {
name.value = ''
provider.value = 'r2'
applyProviderTemplate('r2')
bucket.value = ''
prefix.value = ''
accessKeyId.value = ''
secretAccessKey.value = ''
servingMode.value = 'proxy'
publicUrl.value = ''
quotaValue.value = ''
quotaUnit.value = 'GB'
}
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
}
@@ -157,7 +196,9 @@ watch(provider, (value) => {
})
function handleSubmit() {
const backendType = isTelegram.value ? 'telegram' : 's3'
emit('submit', {
backendType,
name: name.value.trim() || providerLabel(provider.value),
provider: provider.value,
config: {
@@ -165,13 +206,16 @@ function handleSubmit() {
region: region.value.trim() || 'auto',
bucket: bucket.value.trim(),
prefix: prefix.value.trim() || undefined,
forcePathStyle: forcePathStyle.value
forcePathStyle: forcePathStyle.value,
chatId: chatId.value.trim() || undefined,
apiProxyUrl: apiProxyUrl.value.trim() || undefined
},
secrets: {
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(),
quotaValue: String(quotaValue.value ?? '').trim(),
quotaUnit: quotaUnit.value
@@ -190,6 +234,7 @@ function handleSubmit() {
v-model="provider"
:items="providerOptions"
class="w-full"
:disabled="!createMode && backend?.type === 'telegram'"
/>
</div>
@@ -201,105 +246,158 @@ function handleSubmit() {
/>
</div>
<div class="space-y-2">
<label class="text-sm">{{ t('storage.endpoint') }}</label>
<UInput
v-model="endpoint"
class="w-full font-mono text-sm"
/>
</div>
<div class="grid gap-3 sm:grid-cols-2">
<div class="space-y-2">
<label class="text-sm">{{ t('storage.region') }}</label>
<UInput
v-model="region"
class="w-full font-mono text-sm"
/>
</div>
<template v-if="isTelegram">
<div class="space-y-2">
<label class="text-sm">
{{ t('storage.bucket') }}
{{ t('storage.telegramChatId') }}
<span class="text-error">*</span>
</label>
<UInput
v-model="bucket"
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>
<div class="space-y-2">
<label class="text-sm">{{ t('storage.prefix') }}</label>
<UInput
v-model="prefix"
:placeholder="t('storage.prefixPlaceholder')"
class="w-full font-mono text-sm"
/>
</div>
<label class="flex cursor-pointer items-center gap-2">
<UCheckbox v-model="forcePathStyle" />
<span class="text-sm">{{ t('storage.forcePathStyle') }}</span>
</label>
<div class="grid gap-3 sm:grid-cols-2">
<div class="space-y-2">
<label class="text-sm">
{{ t('storage.accessKey') }}
{{ t('storage.telegramBotToken') }}
<span
v-if="createMode"
class="text-error"
>*</span>
</label>
<UInput
v-model="accessKeyId"
:placeholder="backend?.secretsMasked.accessKeyId || ''"
class="w-full font-mono text-sm"
:required="createMode"
/>
</div>
<div class="space-y-2">
<label class="text-sm">
{{ t('storage.secretKey') }}
<span
v-if="createMode"
class="text-error"
>*</span>
</label>
<UInput
v-model="secretAccessKey"
v-model="botToken"
type="password"
:placeholder="backend?.secretsMasked.secretAccessKey || ''"
:placeholder="backend?.secretsMasked.botToken || ''"
class="w-full font-mono text-sm"
:required="createMode"
/>
</div>
</div>
<div class="space-y-2">
<label class="text-sm">{{ t('storage.servingMode') }}</label>
<URadioGroup
v-model="servingMode"
:items="servingModeOptions"
/>
<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.servingHint') }}
{{ t('storage.telegramServingHint') }}
</p>
</div>
</template>
<div
v-if="servingMode === 'public'"
class="space-y-2"
>
<label class="text-sm">{{ t('storage.publicUrl') }}</label>
<UInput
v-model="publicUrl"
:placeholder="t('storage.publicUrlPlaceholder')"
class="w-full font-mono text-sm"
/>
</div>
<template v-else>
<div class="space-y-2">
<label class="text-sm">{{ t('storage.endpoint') }}</label>
<UInput
v-model="endpoint"
class="w-full font-mono text-sm"
/>
</div>
<div class="grid gap-3 sm:grid-cols-2">
<div class="space-y-2">
<label class="text-sm">{{ t('storage.region') }}</label>
<UInput
v-model="region"
class="w-full font-mono text-sm"
/>
</div>
<div class="space-y-2">
<label class="text-sm">
{{ t('storage.bucket') }}
<span class="text-error">*</span>
</label>
<UInput
v-model="bucket"
class="w-full font-mono text-sm"
required
/>
</div>
</div>
<div class="space-y-2">
<label class="text-sm">{{ t('storage.prefix') }}</label>
<UInput
v-model="prefix"
:placeholder="t('storage.prefixPlaceholder')"
class="w-full font-mono text-sm"
/>
</div>
<label class="flex cursor-pointer items-center gap-2">
<UCheckbox v-model="forcePathStyle" />
<span class="text-sm">{{ t('storage.forcePathStyle') }}</span>
</label>
<div class="grid gap-3 sm:grid-cols-2">
<div class="space-y-2">
<label class="text-sm">
{{ t('storage.accessKey') }}
<span
v-if="createMode"
class="text-error"
>*</span>
</label>
<UInput
v-model="accessKeyId"
:placeholder="backend?.secretsMasked.accessKeyId || ''"
class="w-full font-mono text-sm"
:required="createMode"
/>
</div>
<div class="space-y-2">
<label class="text-sm">
{{ t('storage.secretKey') }}
<span
v-if="createMode"
class="text-error"
>*</span>
</label>
<UInput
v-model="secretAccessKey"
type="password"
:placeholder="backend?.secretsMasked.secretAccessKey || ''"
class="w-full font-mono text-sm"
:required="createMode"
/>
</div>
</div>
<div class="space-y-2">
<label class="text-sm">{{ t('storage.servingMode') }}</label>
<URadioGroup
v-model="servingMode"
:items="servingModeOptions"
/>
<p class="text-xs text-muted">
{{ t('storage.servingHint') }}
</p>
</div>
<div
v-if="servingMode === 'public'"
class="space-y-2"
>
<label class="text-sm">{{ t('storage.publicUrl') }}</label>
<UInput
v-model="publicUrl"
:placeholder="t('storage.publicUrlPlaceholder')"
class="w-full font-mono text-sm"
/>
</div>
</template>
<div class="space-y-2">
<label class="text-sm">
@@ -324,7 +422,7 @@ function handleSubmit() {
/>
</div>
<p class="text-xs text-muted">
{{ t('storage.quotaHint') }}
{{ isTelegram ? t('storage.telegramQuotaHint') : t('storage.quotaHint') }}
</p>
</div>
+42 -1
View File
@@ -1,5 +1,9 @@
<script setup lang="ts">
import type { BackupJobItem, SyncDraft } from '~/types/backup'
import type {
BackupJobItem,
SiteSyncRequest,
SyncDraft
} from '~/types/backup'
import type { StorageBackendItem } from '~/types/storage'
const props = defineProps<{
@@ -15,6 +19,7 @@ const {
jobsTotal,
jobsTotalPages,
jobsLoading,
showHiddenJobs,
estimate,
packages,
loadingEstimate,
@@ -22,6 +27,9 @@ const {
hasActiveJob,
getActiveJob,
loadJobs,
toggleShowHiddenJobs,
hideJob,
hideAllJobs,
setJobsPage,
setJobsPageSize,
loadEstimate,
@@ -31,6 +39,7 @@ const {
confirmRestore,
previewSync,
startSync,
startSiteSync,
retryJob,
cancelJob,
downloadUrl
@@ -45,6 +54,14 @@ 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 () => {
@@ -85,6 +102,14 @@ 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
@@ -94,6 +119,10 @@ async function onSync(body: {
await startSync(body)
}
async function onSiteSync(body: SiteSyncRequest) {
await startSiteSync(body)
}
function onDownload(jobId: string) {
window.open(downloadUrl(jobId), '_blank')
}
@@ -159,6 +188,14 @@ function onViewJob(job: BackupJobItem) {
/>
</div>
<StorageSiteSyncCard
:busy="siteSyncBusy"
:disabled="anyJobBusy && !siteSyncBusy"
:active-job="siteSyncBusy ? activeJob : null"
:resumable-job="resumableSiteSyncJob"
@sync="onSiteSync"
/>
<StorageBackupJobTable
:jobs="jobs"
:polling="polling"
@@ -167,11 +204,15 @@ function onViewJob(job: BackupJobItem) {
: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>
@@ -22,6 +22,13 @@ const items = computed(() => [
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>
+654 -8
View File
@@ -1,5 +1,5 @@
export type ApiDocMethod = 'GET' | 'POST' | 'DELETE'
export type ApiDocCategory = 'image' | 'extension' | 'system'
export type ApiDocCategory = 'image' | 'video' | 'file' | 'extension' | 'system'
export type ApiParamLocation = 'form-data' | 'query' | 'header' | 'body'
export interface ApiDocParam {
@@ -74,13 +74,6 @@ export function buildApiDocs(ctx: ApiDocsContext): ApiDocEndpoint[] {
required: false,
descriptionKey: 'api.params.upload.token'
},
{
name: 'tagIds',
type: 'string',
location: 'form-data',
required: false,
descriptionKey: 'api.params.upload.tagIds'
},
{
name: 'tagNames',
type: 'string',
@@ -258,6 +251,659 @@ export function buildApiDocs(ctx: ApiDocsContext): ApiDocEndpoint[] {
"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"
}`
},
{
+21 -2
View File
@@ -143,11 +143,19 @@ export function useAuth() {
credentials: { username: string, password: string }
| { secret: string },
verification: LoginVerificationPayload
): Promise<{ ok: boolean, needsMigration?: boolean, error?: string }> {
): Promise<{
ok: boolean
needsMigration?: boolean
needsPasswordReset?: boolean
resetUsername?: string
error?: string
}> {
try {
const result = await $fetch<{
success: boolean
needsMigration?: boolean
requiresPasswordReset?: boolean
username?: string
user?: AuthUser
}>('/api/auth/login', {
method: 'POST',
@@ -160,6 +168,13 @@ export function useAuth() {
if (result.needsMigration) {
return { ok: true, needsMigration: true }
}
if (result.requiresPasswordReset) {
return {
ok: false,
needsPasswordReset: true,
resetUsername: result.username
}
}
if (result.user) {
user.value = result.user
}
@@ -218,6 +233,8 @@ export function useAuth() {
domainSeparation?: boolean
siteBaseUrl?: string
imageBaseUrl?: string
videoBaseUrl?: string
fileBaseUrl?: string
}): Promise<AuthActionResult> {
const validationError = validateAuthInput(input.username, input.password)
if (validationError) {
@@ -236,7 +253,9 @@ export function useAuth() {
allowRegistration: input.allowRegistration,
domainSeparation: input.domainSeparation,
siteBaseUrl: input.siteBaseUrl,
imageBaseUrl: input.imageBaseUrl
imageBaseUrl: input.imageBaseUrl,
videoBaseUrl: input.videoBaseUrl,
fileBaseUrl: input.fileBaseUrl
}
}
)
+112 -10
View File
@@ -3,7 +3,8 @@ import type {
BackupJobItem,
BackupPackageItem,
BackupRestorePreview,
BackendSyncPreview
BackendSyncPreview,
SiteSyncRequest
} from '~/types/backup'
const jobs = ref<BackupJobItem[]>([])
@@ -12,6 +13,7 @@ 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[]>([])
@@ -68,7 +70,8 @@ export function useBackup() {
credentials: 'include',
query: {
pageNum: jobsPage.value,
pageSize: jobsPageSize.value
pageSize: jobsPageSize.value,
includeHidden: showHiddenJobs.value
}
})
jobs.value = data.jobs
@@ -103,6 +106,58 @@ export function useBackup() {
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 {
@@ -149,12 +204,17 @@ export function useBackup() {
archiveName?: string
}): Promise<BackupRestorePreview> {
if (input.file) {
const form = new FormData()
form.append('file', input.file)
form.append('dryRun', 'true')
const data = await $fetch<{ preview: BackupRestorePreview }>(
'/api/admin/backup/restore',
{ method: 'POST', credentials: 'include', body: form }
{
method: 'POST',
credentials: 'include',
body: input.file,
headers: {
'content-type': 'application/octet-stream',
'x-pichost-dry-run': 'true'
}
}
)
return data.preview
}
@@ -180,12 +240,18 @@ export function useBackup() {
}) {
try {
if (input.file) {
const form = new FormData()
form.append('file', input.file)
form.append('conflictMode', input.conflictMode)
const data = await $fetch<{ job: BackupJobItem }>(
'/api/admin/backup/restore',
{ method: 'POST', credentials: 'include', body: form }
{
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()
@@ -255,6 +321,37 @@ export function useBackup() {
}
}
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`, {
@@ -295,6 +392,7 @@ export function useBackup() {
jobsTotal,
jobsTotalPages,
jobsLoading,
showHiddenJobs,
estimate,
packages,
loadingEstimate,
@@ -302,6 +400,9 @@ export function useBackup() {
hasActiveJob,
getActiveJob,
loadJobs,
toggleShowHiddenJobs,
hideJob,
hideAllJobs,
setJobsPage,
setJobsPageSize,
loadEstimate,
@@ -312,6 +413,7 @@ export function useBackup() {
confirmRestore,
previewSync,
startSync,
startSiteSync,
retryJob,
cancelJob,
downloadUrl
+38 -13
View File
@@ -4,20 +4,23 @@ import type {
} from '~/types/image'
import { isUnauthorizedError } from './useAuth'
import { prepareFilesForUpload } from './useClipboardImage'
import { normalizeUploadTagNames } from './useUploadPreferences'
import { uploadFormDataWithProgress } from '~/utils/upload-progress'
const MAX_FILES = 10
export function useImageUpload() {
const toast = useToast()
const { t } = useI18n()
const { compressEnabled, clientWebpQuality, lastTagIds } = useUploadPreferences()
const { compressEnabled, clientWebpQuality, uploadTagNames } = useUploadPreferences()
const { load: loadUploadPolicy } = useUploadPolicy()
const uploading = ref(false)
const progressItems = ref<UploadProgressItem[]>([])
const lastUploadResult = ref<UploadResponse | null>(null)
async function uploadFiles(
files: File[],
options?: { notify?: boolean, tagIds?: number[] }
options?: { notify?: boolean }
) {
if (uploading.value) return
@@ -42,15 +45,32 @@ export function useImageUpload() {
}))
try {
const prepared = await prepareFilesForUpload(files, {
const preparedFiles = await prepareFilesForUpload(files, {
enabled: compressEnabled.value,
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 => ({
name: file.name,
status: 'uploading',
progress: Math.round(((index + 1) / prepared.length) * 50)
progress: 0
}))
const formData = new FormData()
@@ -58,16 +78,21 @@ export function useImageUpload() {
formData.append('files', file, file.name)
}
const tagIds = options?.tagIds ?? lastTagIds.value
if (tagIds.length) {
formData.append('tagIds', JSON.stringify(tagIds))
lastTagIds.value = tagIds
const tagNames = normalizeUploadTagNames(uploadTagNames.value).join(',')
if (tagNames) {
formData.append('tagNames', tagNames)
}
const response = await $fetch<UploadResponse>('/api/images/upload', {
method: 'POST',
body: formData
})
const response = await uploadFormDataWithProgress<UploadResponse>(
'/api/images/upload',
formData,
(percent) => {
progressItems.value = progressItems.value.map(item => ({
...item,
progress: Math.min(99, percent)
}))
}
)
lastUploadResult.value = response
+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 }
}
+347
View File
@@ -0,0 +1,347 @@
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'
import { uploadFormDataWithProgress } from '~/utils/upload-progress'
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: 0
}))
const response = await uploadFormDataWithProgress<FileUploadResponse>(
endpoint,
formData,
(percent) => {
progressItems.value = progressItems.value.map(item => ({
...item,
progress: Math.min(99, percent)
}))
}
)
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
}
}
@@ -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
}
}
+46
View File
@@ -0,0 +1,46 @@
interface UploadPolicy {
allowedMimeTypes: string[]
allowedVideoExtensions: string[]
forbiddenFileExtensions: string[]
imageMaxFileSizeMb: number
videoMaxFileSizeMb: number
fileMaxFileSizeMb: number
}
const policy = ref<UploadPolicy | null>(null)
const loading = ref(false)
let pending: Promise<UploadPolicy> | null = null
export function useUploadPolicy() {
async function load(force = false): Promise<UploadPolicy> {
if (policy.value && !force) return policy.value
if (pending && !force) return pending
loading.value = true
pending = $fetch<UploadPolicy>('/api/upload-policy', {
credentials: 'include'
})
.then((data) => {
policy.value = {
allowedMimeTypes: [...data.allowedMimeTypes],
allowedVideoExtensions: [...data.allowedVideoExtensions],
forbiddenFileExtensions: [...data.forbiddenFileExtensions],
imageMaxFileSizeMb: data.imageMaxFileSizeMb,
videoMaxFileSizeMb: data.videoMaxFileSizeMb,
fileMaxFileSizeMb: data.fileMaxFileSizeMb
}
return policy.value
})
.finally(() => {
loading.value = false
pending = null
})
return pending
}
return {
policy,
loading,
load
}
}
+61 -25
View File
@@ -1,13 +1,21 @@
const STORAGE_KEY = 'pichost.upload-preferences'
export type CopyFormat = 'url' | 'markdown' | 'html' | 'bbcode'
export type UploadMediaKind = 'image' | 'video' | 'file'
/** 每种上传页各存一份偏好,互不影响 */
function storageKeyFor(kind: UploadMediaKind): string {
return kind === 'image' ? STORAGE_KEY : `${STORAGE_KEY}.${kind}`
}
export interface UploadPreferences {
compressEnabled: boolean
autoCopyMarkdown: boolean
copyFormat: CopyFormat
clientWebpQuality: number
lastTagIds: number[]
uploadTagNames: string[]
/** 视频床专属:上传后转码为 HLS(m3u8 + 分片) */
transcodeToHls: boolean
}
const DEFAULTS: UploadPreferences = {
@@ -15,7 +23,19 @@ const DEFAULTS: UploadPreferences = {
autoCopyMarkdown: false,
copyFormat: 'markdown',
clientWebpQuality: 80,
lastTagIds: []
uploadTagNames: [],
transcodeToHls: false
}
export function normalizeUploadTagNames(value: string | readonly string[]): string[] {
const parts = typeof value === 'string'
? value.split(/[,,\n]/)
: value.flatMap(item => String(item).split(/[,,\n]/))
return [...new Set(
parts
.map(name => name.trim())
.filter(name => Boolean(name) && name.length <= 32)
)]
}
function clampQuality(value: unknown): number {
@@ -28,11 +48,11 @@ function isCopyFormat(value: unknown): value is CopyFormat {
return value === 'url' || value === 'markdown' || value === 'html' || value === 'bbcode'
}
function readStored(): UploadPreferences {
function readStored(kind: UploadMediaKind): UploadPreferences {
if (import.meta.server) return { ...DEFAULTS }
try {
const raw = localStorage.getItem(STORAGE_KEY)
const raw = localStorage.getItem(storageKeyFor(kind))
if (!raw) return { ...DEFAULTS }
const parsed = JSON.parse(raw) as Partial<UploadPreferences>
return {
@@ -40,35 +60,43 @@ function readStored(): UploadPreferences {
autoCopyMarkdown: parsed.autoCopyMarkdown === true,
copyFormat: isCopyFormat(parsed.copyFormat) ? parsed.copyFormat : DEFAULTS.copyFormat,
clientWebpQuality: clampQuality(parsed.clientWebpQuality),
lastTagIds: Array.isArray(parsed.lastTagIds)
? parsed.lastTagIds.filter(id => Number.isInteger(id) && id > 0)
: DEFAULTS.lastTagIds
/** 标签输入只在当前页面会话内生效,刷新后默认为空。 */
uploadTagNames: [...DEFAULTS.uploadTagNames],
transcodeToHls: parsed.transcodeToHls === true
}
} catch {
return { ...DEFAULTS }
}
}
function writeStored(prefs: UploadPreferences) {
function writeStored(kind: UploadMediaKind, prefs: UploadPreferences) {
if (import.meta.server) return
localStorage.setItem(STORAGE_KEY, JSON.stringify(prefs))
localStorage.setItem(storageKeyFor(kind), JSON.stringify(prefs))
}
export function useUploadPreferences() {
const compressEnabled = useState('upload-pref-compress', () => DEFAULTS.compressEnabled)
const autoCopyMarkdown = useState('upload-pref-auto-md', () => DEFAULTS.autoCopyMarkdown)
const copyFormat = useState<CopyFormat>('upload-pref-copy-format', () => DEFAULTS.copyFormat)
const clientWebpQuality = useState('upload-pref-webp-quality', () => DEFAULTS.clientWebpQuality)
const lastTagIds = useState<number[]>('upload-pref-last-tag-ids', () => DEFAULTS.lastTagIds)
const loaded = useState('upload-pref-loaded', () => false)
export function useUploadPreferences(kind: UploadMediaKind = 'image') {
const compressEnabled = useState(`upload-pref-compress-${kind}`, () => DEFAULTS.compressEnabled)
const autoCopyMarkdown = useState(`upload-pref-auto-md-${kind}`, () => DEFAULTS.autoCopyMarkdown)
const copyFormat = useState<CopyFormat>(`upload-pref-copy-format-${kind}`, () => DEFAULTS.copyFormat)
const clientWebpQuality = useState(`upload-pref-webp-quality-${kind}`, () => DEFAULTS.clientWebpQuality)
const uploadTagNames = useState<string[]>(
`upload-pref-tag-names-${kind}`,
() => [...DEFAULTS.uploadTagNames]
)
const transcodeToHls = useState(`upload-pref-transcode-${kind}`, () => DEFAULTS.transcodeToHls)
const loaded = useState(`upload-pref-loaded-${kind}`, () => false)
function loadPreferences() {
const stored = readStored()
const firstLoad = !loaded.value
const stored = readStored(kind)
compressEnabled.value = stored.compressEnabled
autoCopyMarkdown.value = stored.autoCopyMarkdown
copyFormat.value = stored.copyFormat
clientWebpQuality.value = stored.clientWebpQuality
lastTagIds.value = stored.lastTagIds
if (firstLoad) {
uploadTagNames.value = [...stored.uploadTagNames]
}
transcodeToHls.value = stored.transcodeToHls
loaded.value = true
}
@@ -77,12 +105,13 @@ export function useUploadPreferences() {
}
function savePreferences() {
writeStored({
writeStored(kind, {
compressEnabled: compressEnabled.value,
autoCopyMarkdown: autoCopyMarkdown.value,
copyFormat: copyFormat.value,
clientWebpQuality: clampQuality(clientWebpQuality.value),
lastTagIds: lastTagIds.value
uploadTagNames: [...DEFAULTS.uploadTagNames],
transcodeToHls: transcodeToHls.value
})
}
@@ -91,21 +120,28 @@ export function useUploadPreferences() {
autoCopyMarkdown.value = DEFAULTS.autoCopyMarkdown
copyFormat.value = DEFAULTS.copyFormat
clientWebpQuality.value = DEFAULTS.clientWebpQuality
uploadTagNames.value = [...DEFAULTS.uploadTagNames]
transcodeToHls.value = DEFAULTS.transcodeToHls
savePreferences()
}
watch([compressEnabled, autoCopyMarkdown, copyFormat, clientWebpQuality, lastTagIds], () => {
if (loaded.value) {
savePreferences()
watch(
[compressEnabled, autoCopyMarkdown, copyFormat, clientWebpQuality, transcodeToHls],
() => {
if (loaded.value) {
savePreferences()
}
}
})
)
return {
kind,
compressEnabled,
autoCopyMarkdown,
copyFormat,
clientWebpQuality,
lastTagIds,
uploadTagNames,
transcodeToHls,
loaded,
loadPreferences,
savePreferences,
+93
View File
@@ -0,0 +1,93 @@
<script setup lang="ts">
const { isChecking, isAuthenticated, user, checkSession, fetchStatus, handleAuthError } = useAuth()
const toast = useToast()
const { t } = useI18n()
const username = ref('')
const saving = ref(false)
watch(user, (value) => {
if (value) username.value = value.username
}, { immediate: true })
async function save() {
if (saving.value) return
saving.value = true
try {
const data = await $fetch<{ user: { username: string } }>('/api/user/profile', {
method: 'PATCH',
credentials: 'include',
body: { username: username.value.trim() }
})
await checkSession()
toast.add({ title: t('profile.saved'), color: 'success' })
username.value = data.user.username
} catch (error: unknown) {
handleAuthError(error)
const message = (error as { data?: { message?: string } })?.data?.message
toast.add({ title: message ?? t('profile.saveFailed'), color: 'error' })
} finally {
saving.value = false
}
}
onMounted(async () => {
const status = await fetchStatus()
if (!status.initialized && !status.legacyMode) {
await navigateTo('/setup')
return
}
await checkSession()
})
</script>
<template>
<div class="min-h-screen">
<div
v-if="isChecking"
class="flex min-h-screen items-center justify-center"
>
<UIcon
name="i-lucide-loader-circle"
class="size-8 animate-spin text-muted"
/>
</div>
<AdminLoginGate v-else-if="!isAuthenticated" />
<AppShell v-else>
<div class="mx-auto max-w-md space-y-4">
<div>
<h1 class="text-2xl font-semibold tracking-tight">
{{ t('profile.title') }}
</h1>
<p class="text-sm text-muted">
{{ t('profile.subtitle') }}
</p>
</div>
<form
class="space-y-3 rounded-xl border border-default bg-elevated p-5"
@submit.prevent="save"
>
<label class="block text-sm font-medium">
{{ t('profile.username') }}
</label>
<UInput
v-model="username"
class="w-full"
:placeholder="t('profile.username')"
/>
<p class="text-xs text-muted">
{{ t('profile.usernameHint') }}
</p>
<UButton
type="submit"
:loading="saving"
:label="t('common.save')"
/>
</form>
</div>
</AppShell>
</div>
</template>
+18 -32
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
interface SettingsResponse {
interface UserTokenResponse {
apiUploadToken: string
tokenSource: 'env' | 'db' | 'none'
envTokenOverride: boolean
@@ -8,6 +8,8 @@ interface SettingsResponse {
refererConfigured: boolean
siteBaseUrl: string
imageBaseUrl: string
videoBaseUrl: string
fileBaseUrl: string
appVersion: string
}
}
@@ -16,7 +18,7 @@ const { isChecking, isAuthenticated, checkSession, handleAuthError, fetchStatus,
const toast = useToast()
const { t } = useI18n()
const settings = ref<SettingsResponse | null>(null)
const tokenSettings = ref<UserTokenResponse | null>(null)
const loading = ref(false)
const regenerating = ref(false)
const confirmRegenerateOpen = ref(false)
@@ -25,19 +27,17 @@ const newToken = ref('')
const activeEndpointId = ref('upload')
const baseUrl = computed(() => {
const configured = settings.value?.env.siteBaseUrl
const configured = tokenSettings.value?.env.siteBaseUrl
if (configured) return configured
if (import.meta.client) {
return window.location.origin
}
return settings.value?.env.imageBaseUrl ?? ''
return tokenSettings.value?.env.imageBaseUrl ?? ''
})
const tokenDisplay = computed(() => settings.value?.apiUploadToken ?? '')
const tokenDisplay = computed(() => tokenSettings.value?.apiUploadToken ?? '')
const hasToken = computed(() => tokenDisplay.value.length > 0)
const canRegenerate = computed(() =>
!settings.value?.envTokenOverride && !loading.value
)
const canRegenerate = computed(() => !loading.value)
const authHeader = computed(() =>
hasToken.value ? tokenDisplay.value : 'YOUR-TOKEN'
@@ -54,11 +54,10 @@ const activeEndpoint = computed(() =>
apiDocs.value.find(endpoint => endpoint.id === activeEndpointId.value) ?? apiDocs.value[0]
)
async function loadSettings() {
async function loadTokenSettings() {
loading.value = true
try {
const endpoint = isAdmin.value ? '/api/settings' : '/api/user/api-token'
settings.value = await $fetch<SettingsResponse>(endpoint, {
tokenSettings.value = await $fetch<UserTokenResponse>('/api/user/api-token', {
credentials: 'include'
})
} catch (error: unknown) {
@@ -66,6 +65,7 @@ async function loadSettings() {
if (isAuthenticated.value) {
toast.add({ title: t('api.loadFailed'), color: 'error' })
}
tokenSettings.value = null
} finally {
loading.value = false
}
@@ -80,30 +80,17 @@ async function confirmRegenerate() {
confirmRegenerateOpen.value = false
regenerating.value = true
try {
const endpoint = isAdmin.value
? '/api/settings/api-token/regenerate'
: '/api/user/api-token/regenerate'
const response = await $fetch<{ apiUploadToken: string }>(
endpoint,
'/api/user/api-token/regenerate',
{ method: 'POST', credentials: 'include' }
)
newToken.value = response.apiUploadToken
regenerateResultOpen.value = true
await loadSettings()
await loadTokenSettings()
} catch (error: unknown) {
handleAuthError(error)
if (isAuthenticated.value) {
const status = typeof error === 'object' && error !== null && 'statusCode' in error
? (error as { statusCode: number }).statusCode
: 0
if (status === 409) {
toast.add({
title: t('api.regenerateEnvBlocked'),
color: 'warning'
})
} else {
toast.add({ title: t('api.regenerateFailed'), color: 'error' })
}
toast.add({ title: t('api.regenerateFailed'), color: 'error' })
}
} finally {
regenerating.value = false
@@ -126,16 +113,16 @@ onMounted(async () => {
}
await checkSession()
if (isAuthenticated.value) {
await loadSettings()
await loadTokenSettings()
}
})
watch(isAuthenticated, async (authed, prev) => {
if (authed && prev === false) {
await nextTick()
await loadSettings()
await loadTokenSettings()
} else {
settings.value = null
tokenSettings.value = null
}
})
</script>
@@ -174,8 +161,7 @@ watch(isAuthenticated, async (authed, prev) => {
:loading="loading"
:regenerating="regenerating"
:can-regenerate="canRegenerate"
:token-source="settings?.tokenSource"
:env-token-override="settings?.envTokenOverride"
token-source="db"
:is-admin="isAdmin"
@regenerate="requestRegenerate"
/>
+59
View File
@@ -0,0 +1,59 @@
<script setup lang="ts">
import FileLibraryPanel from '~/components/FileLibraryPanel.vue'
const { isChecking, isAuthenticated, checkSession, fetchStatus } = useAuth()
const { t } = useI18n()
const panelRef = ref<{ refresh: () => Promise<void> } | null>(null)
async function loadPanel() {
if (isAuthenticated.value) {
await panelRef.value?.refresh()
}
}
onMounted(async () => {
const status = await fetchStatus()
if (!status.initialized && !status.legacyMode) {
await navigateTo('/setup')
return
}
await checkSession()
await loadPanel()
})
watch(isAuthenticated, async (authed, prev) => {
if (authed && prev === false) {
await nextTick()
await loadPanel()
}
})
</script>
<template>
<div class="min-h-screen">
<div
v-if="isChecking"
class="flex min-h-screen items-center justify-center"
>
<div class="flex flex-col items-center gap-3 text-muted">
<UIcon
name="i-lucide-loader-circle"
class="size-8 animate-spin"
/>
<p class="text-sm">
{{ t('common.loadingSession') }}
</p>
</div>
</div>
<AdminLoginGate v-else-if="!isAuthenticated" />
<AppShell v-else>
<FileLibraryPanel
ref="panelRef"
kind="file"
/>
</AppShell>
</div>
</template>
+38
View File
@@ -0,0 +1,38 @@
<script setup lang="ts">
const { isChecking, isAuthenticated, checkSession, fetchStatus } = useAuth()
const { t } = useI18n()
onMounted(async () => {
const status = await fetchStatus()
if (!status.initialized && !status.legacyMode) {
await navigateTo('/setup')
return
}
await checkSession()
})
</script>
<template>
<div class="min-h-screen">
<div
v-if="isChecking"
class="flex min-h-screen items-center justify-center"
>
<div class="flex flex-col items-center gap-3 text-muted">
<UIcon
name="i-lucide-loader-circle"
class="size-8 animate-spin"
/>
<p class="text-sm">
{{ t('common.loadingSession') }}
</p>
</div>
</div>
<AdminLoginGate v-else-if="!isAuthenticated" />
<AppShell v-else>
<MediaUploadPanel kind="file" />
</AppShell>
</div>
</template>
+26 -8
View File
@@ -1,5 +1,6 @@
<script setup lang="ts">
import type { ImageItem } from '~/types/image'
import SiteSyncSelectedDialog from '~/components/storage/SiteSyncSelectedDialog.vue'
interface StatsResponse {
uploadToday: number
@@ -51,13 +52,12 @@ const {
activeTagMode,
activeUntaggedOnly,
setActiveTagFilter,
toggleTagFilter,
listQueryParams
toggleTagFilter
} = useImageList()
const { items: tagItems, fetchTags, batchUpdateTags } = useTags()
const { isChecking, isAuthenticated, checkSession, handleAuthError, fetchStatus } = useAuth()
const { isChecking, isAuthenticated, checkSession, handleAuthError, fetchStatus, getFetchErrorMessage } = useAuth()
const toast = useToast()
const { t } = useI18n()
@@ -73,8 +73,9 @@ const batchDeleting = ref(false)
const showScrollTop = ref(false)
const previewOpen = ref(false)
const previewImage = ref<ImageItem | null>(null)
const viewMode = ref<'grid' | 'list'>('grid')
const { viewMode } = useLibraryView('image')
const batchTagOpen = ref(false)
const syncOpen = ref(false)
const currentUploadSource = ref('all')
const currentStorageBackend = ref('all')
@@ -94,6 +95,7 @@ const storageBackendItems = computed(() => [
])
const selectedCount = computed(() => selectedKeys.value.size)
const syncKeys = computed(() => [...selectedKeys.value])
function onWindowScroll() {
showScrollTop.value = window.scrollY > 400
@@ -104,9 +106,9 @@ function scrollToTop() {
}
async function fetchStats() {
stats.value = await $fetch<StatsResponse>('/api/stats', {
stats.value = await $fetch<StatsResponse>('/api/library/stats', {
credentials: 'include',
query: listQueryParams()
query: { kind: 'image' }
})
}
@@ -256,7 +258,10 @@ async function confirmBatchTags(tagIds: number[], action: 'add' | 'remove') {
await Promise.all([reloadGallery(), fetchStats(), fetchTags(true)])
} catch (error: unknown) {
handleAuthError(error)
toast.add({ title: t('tags.batchFailed'), color: 'error' })
toast.add({
title: getFetchErrorMessage(error, t('tags.batchFailed')),
color: 'error'
})
}
}
@@ -264,6 +269,10 @@ function goToTagSettings() {
void navigateTo({ path: '/settings', query: { tab: 'tags' } })
}
function openSync() {
syncOpen.value = true
}
function updateSelectedKeys(keys: Set<string>) {
selectedKeys.value = keys
}
@@ -463,7 +472,8 @@ watch(isAuthenticated, async (authed, prev) => {
/>
</div>
<GalleryStatsOverview
<LibraryStatsPanel
kind="image"
:stats="stats"
:loading="statsLoading"
/>
@@ -492,6 +502,7 @@ watch(isAuthenticated, async (authed, prev) => {
:loading="galleryLoading"
:show-batch-delete="true"
:show-batch-tags="selectedCount > 0"
:show-batch-sync="selectedCount > 0"
@update:storage-backend="handleStorageBackendChange"
@update:upload-source="handleUploadSourceChange"
@update:tag-ids="handleTagIdsChange"
@@ -502,6 +513,7 @@ watch(isAuthenticated, async (authed, prev) => {
@reset="handleResetFilters"
@batch-delete="requestBatchDelete"
@batch-tags="batchTagOpen = true"
@batch-sync="openSync"
@manage-tags="goToTagSettings"
/>
</div>
@@ -588,6 +600,12 @@ watch(isAuthenticated, async (authed, prev) => {
@confirm="confirmBatchTags"
/>
<SiteSyncSelectedDialog
v-model:open="syncOpen"
:keys="syncKeys"
scope="image"
/>
<UButton
v-show="showScrollTop"
icon="i-lucide-arrow-up"
+6
View File
@@ -3,6 +3,7 @@ import type { ImageItem, ImageTag } from '~/types/image'
import { buildImageBbcode } from '~/utils/image-display'
const { uploading, progressItems, uploadFiles } = useImageUpload()
const { policy: uploadPolicy, load: loadUploadPolicy } = useUploadPolicy()
const {
autoCopyMarkdown,
@@ -16,6 +17,9 @@ const { recordRecent } = useRecentTags()
const { isChecking, isAuthenticated, checkSession, handleAuthError, fetchStatus } = useAuth()
const toast = useToast()
const { t } = useI18n()
const imageFormats = computed(() =>
t('upload.formats', { size: uploadPolicy.value?.imageMaxFileSizeMb ?? 10 })
)
const sessionItems = ref<ImageItem[]>([])
const deletingKeys = ref<Set<string>>(new Set())
const emptySelection = ref(new Set<string>())
@@ -39,6 +43,7 @@ const showProgress = computed(
async function loadPage() {
try {
loadPreferences()
await loadUploadPolicy().catch(() => {})
} catch (error: unknown) {
handleAuthError(error)
}
@@ -227,6 +232,7 @@ async function confirmBatchTags(tagIds: number[]) {
<AppShell v-else>
<UploadFlipCard
:disabled="uploading"
:formats="imageFormats"
@upload="handleUpload"
/>
+86
View File
@@ -0,0 +1,86 @@
<script setup lang="ts">
const route = useRoute()
const router = useRouter()
const toast = useToast()
const { t } = useI18n()
const { checkSession } = useAuth()
const username = ref(String(route.query.username ?? ''))
const password = ref('')
const confirm = ref('')
const loading = ref(false)
async function submit() {
if (loading.value) return
if (password.value.length < 6) {
toast.add({ title: t('reset.tooShort'), color: 'warning' })
return
}
if (password.value !== confirm.value) {
toast.add({ title: t('reset.mismatch'), color: 'warning' })
return
}
loading.value = true
try {
await $fetch('/api/auth/reset-password', {
method: 'POST',
credentials: 'include',
body: { username: username.value.trim(), password: password.value }
})
await checkSession()
toast.add({ title: t('reset.success'), color: 'success' })
await router.replace('/')
} catch (error: unknown) {
toast.add({
title: error instanceof Error ? error.message : t('reset.failed'),
color: 'error'
})
} finally {
loading.value = false
}
}
</script>
<template>
<div class="flex min-h-screen items-center justify-center p-4">
<form
class="w-full max-w-sm space-y-4 rounded-xl border border-default bg-elevated p-6"
@submit.prevent="submit"
>
<div>
<h1 class="text-xl font-semibold tracking-tight">
{{ t('reset.title') }}
</h1>
<p class="mt-1 text-sm text-muted">
{{ t('reset.subtitle') }}
</p>
</div>
<UInput
v-model="username"
:placeholder="t('auth.username')"
class="w-full"
/>
<UInput
v-model="password"
type="password"
:placeholder="t('reset.newPassword')"
class="w-full"
/>
<UInput
v-model="confirm"
type="password"
:placeholder="t('reset.confirmPassword')"
class="w-full"
/>
<UButton
type="submit"
block
:loading="loading"
:label="t('reset.submit')"
/>
</form>
</div>
</template>
+298 -159
View File
@@ -1,15 +1,16 @@
<script setup lang="ts">
import type { SettingsTab } from '~/types/settings'
import { ADMIN_SETTINGS_TABS, USER_SETTINGS_TABS } from '~/types/settings'
import { willActivateDomainSeparation } from '~/utils/domain-separation'
import SettingsUserPreferencesFields from '~/components/settings/SettingsUserPreferencesFields.vue'
import SettingsUploadPolicyFields from '~/components/settings/SettingsUploadPolicyFields.vue'
import SettingsVideoProcessingFields from '~/components/settings/SettingsVideoProcessingFields.vue'
type SettingSource = 'env' | 'db' | 'none'
type WebpQualitySource = 'env' | 'db' | 'default'
type PolicySource = 'env' | 'db' | 'default'
type LoginVerificationMethod = 'slider' | 'turnstile' | 'cap'
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'
interface SettingsResponse {
apiUploadToken: string
@@ -23,7 +24,16 @@ interface SettingsResponse {
imageBaseUrl: string
imageBaseUrlConfigured: string
imageBaseUrlSource: SettingSource
imageDomainSeparated: boolean
videoBaseUrl: string
videoBaseUrlSource: SettingSource
videoDomainSeparated: boolean
fileBaseUrl: string
fileBaseUrlSource: SettingSource
fileDomainSeparated: boolean
effectiveImageBaseUrl: string
effectiveVideoBaseUrl: string
effectiveFileBaseUrl: string
siteBaseUrl: string
siteBaseUrlConfigured: string
siteBaseUrlSource: SettingSource
@@ -45,10 +55,18 @@ interface SettingsResponse {
capApiEndpoint: string
capSecret: string
appVersion: string
uploadMaxFileSizeMb: number
uploadMaxFileSizeMbSource: PolicySource
uploadImageMaxFileSizeMb: number
uploadImageMaxFileSizeMbSource: PolicySource
uploadVideoMaxFileSizeMb: number
uploadVideoMaxFileSizeMbSource: PolicySource
uploadFileMaxFileSizeMb: number
uploadFileMaxFileSizeMbSource: PolicySource
allowedMimeTypes: MimeType[]
allowedMimeTypesSource: PolicySource
allowedVideoExtensions: VideoExtension[]
allowedVideoExtensionsSource: PolicySource
forbiddenFileExtensions: string[]
forbiddenFileExtensionsSource: PolicySource
uploadRateIpMax: number
uploadRateIpMaxSource: PolicySource
uploadRateTokenMax: number
@@ -61,7 +79,10 @@ interface SettingsResponse {
loginRateWindowMinutesSource: PolicySource
preserveOriginalUpload: boolean
preserveOriginalUploadSource: PolicySource
fileBedAccess: 'public' | 'token'
mimeTypeOptions: ReadonlyArray<{ mime: MimeType, label: string }>
videoExtensionOptions: ReadonlyArray<{ extension: VideoExtension, label: string }>
forbiddenFileExtensionOptions: ReadonlyArray<{ extension: string, label: string }>
}
const { isChecking, isAuthenticated, checkSession, handleAuthError, fetchStatus, isAdmin } = useAuth()
@@ -135,8 +156,11 @@ const showSaveBar = computed(() =>
)
const checkingRelease = ref(false)
const { resetToDefaults: resetUploadPreferences } = useUploadPreferences()
const { resetToDefaults: resetImagePreferences } = useUploadPreferences('image')
const { resetToDefaults: resetVideoPreferences } = useUploadPreferences('video')
const { resetToDefaults: resetFilePreferences } = useUploadPreferences('file')
const { load: reloadUserSettings } = useUserAutoDeleteSettings()
const { load: reloadUploadPolicy } = useUploadPolicy()
const domainSeparationDocUrl = computed(() =>
locale.value === 'en'
@@ -150,6 +174,11 @@ const savingServer = ref(false)
const refererDraft = ref('')
const siteBaseUrlDraft = ref('')
const imageBaseUrlDraft = ref('')
const videoBaseUrlDraft = ref('')
const fileBaseUrlDraft = ref('')
const imageDomainSeparatedDraft = ref(false)
const videoDomainSeparatedDraft = ref(false)
const fileDomainSeparatedDraft = ref(false)
const hideFolderInUrlDraft = ref(false)
const storageUseDatePathDraft = ref(true)
const allowRegistrationDraft = ref(false)
@@ -158,19 +187,34 @@ const turnstileSiteKeyDraft = ref('')
const turnstileSecretKeyDraft = ref('')
const capApiEndpointDraft = ref('')
const capSecretDraft = ref('')
const domainSeparationDraft = ref(false)
const disableDomainSeparationOpen = ref(false)
const pendingDomainSeparationDisable = ref(false)
const domainSeparationSaveConfirmOpen = ref(false)
const uploadMaxFileSizeMbDraft = ref(10)
const uploadImageMaxFileSizeMbDraft = ref(10)
const uploadVideoMaxFileSizeMbDraft = ref(500)
const uploadFileMaxFileSizeMbDraft = ref(100)
const allowedMimeTypesDraft = ref<MimeType[]>([])
const allowedVideoExtensionsDraft = ref<VideoExtension[]>([])
const forbiddenFileExtensionsDraft = ref<string[]>([])
const uploadRateIpMaxDraft = ref(60)
const uploadRateTokenMaxDraft = ref(120)
const uploadRateWindowMinutesDraft = ref(15)
const loginRateMaxDraft = ref(10)
const loginRateWindowMinutesDraft = ref(15)
const preserveOriginalUploadDraft = ref(false)
const fileBedAccessDraft = ref<'public' | 'token'>('public')
const fileBedAccessOptions = computed(() => [
{
value: 'public',
label: t('settings.fileBedPublic'),
description: t('settings.fileBedPublicHint')
},
{
value: 'token',
label: t('settings.fileBedToken'),
description: t('settings.fileBedTokenHint')
}
])
interface ReleaseCheckResponse {
currentVersion: string
@@ -188,9 +232,14 @@ function mimeListsEqual(left: readonly string[], right: readonly string[]): bool
const hasServerChanges = computed(() => {
if (!settings.value) return false
return refererDraft.value !== settings.value.allowedRefererHosts
|| domainSeparationDraft.value !== settings.value.domainSeparation
|| siteBaseUrlDraft.value !== (settings.value.domainSeparation ? settings.value.siteBaseUrl : '')
|| anyResourceDomainSeparated.value !== settings.value.domainSeparation
|| siteBaseUrlDraft.value !== settings.value.siteBaseUrl
|| imageBaseUrlDraft.value !== settings.value.imageBaseUrl
|| videoBaseUrlDraft.value !== settings.value.videoBaseUrl
|| fileBaseUrlDraft.value !== settings.value.fileBaseUrl
|| imageDomainSeparatedDraft.value !== settings.value.imageDomainSeparated
|| videoDomainSeparatedDraft.value !== settings.value.videoDomainSeparated
|| fileDomainSeparatedDraft.value !== settings.value.fileDomainSeparated
|| hideFolderInUrlDraft.value !== settings.value.hideFolderInUrl
|| storageUseDatePathDraft.value !== settings.value.storageUseDatePath
|| allowRegistrationDraft.value !== settings.value.allowRegistration
@@ -199,29 +248,42 @@ const hasServerChanges = computed(() => {
|| turnstileSecretKeyDraft.value !== settings.value.turnstileSecretKey
|| capApiEndpointDraft.value !== settings.value.capApiEndpoint
|| capSecretDraft.value !== settings.value.capSecret
|| uploadMaxFileSizeMbDraft.value !== settings.value.uploadMaxFileSizeMb
|| uploadImageMaxFileSizeMbDraft.value !== settings.value.uploadImageMaxFileSizeMb
|| uploadVideoMaxFileSizeMbDraft.value !== settings.value.uploadVideoMaxFileSizeMb
|| uploadFileMaxFileSizeMbDraft.value !== settings.value.uploadFileMaxFileSizeMb
|| !mimeListsEqual(allowedMimeTypesDraft.value, settings.value.allowedMimeTypes)
|| !mimeListsEqual(allowedVideoExtensionsDraft.value, settings.value.allowedVideoExtensions)
|| !mimeListsEqual(forbiddenFileExtensionsDraft.value, settings.value.forbiddenFileExtensions)
|| uploadRateIpMaxDraft.value !== settings.value.uploadRateIpMax
|| uploadRateTokenMaxDraft.value !== settings.value.uploadRateTokenMax
|| uploadRateWindowMinutesDraft.value !== settings.value.uploadRateWindowMinutes
|| loginRateMaxDraft.value !== settings.value.loginRateMax
|| loginRateWindowMinutesDraft.value !== settings.value.loginRateWindowMinutes
|| preserveOriginalUploadDraft.value !== settings.value.preserveOriginalUpload
|| fileBedAccessDraft.value !== settings.value.fileBedAccess
})
const anyResourceDomainSeparated = computed(() =>
imageDomainSeparatedDraft.value
|| videoDomainSeparatedDraft.value
|| fileDomainSeparatedDraft.value
)
const domainSeparationWouldActivate = computed(() =>
willActivateDomainSeparation(
domainSeparationDraft.value,
siteBaseUrlDraft.value,
imageBaseUrlDraft.value
)
anyResourceDomainSeparated.value
&& Boolean(siteBaseUrlDraft.value.trim())
)
const domainSeparationFieldsChanged = computed(() => {
if (!settings.value) return false
return domainSeparationDraft.value !== settings.value.domainSeparation
|| siteBaseUrlDraft.value !== (settings.value.domainSeparation ? settings.value.siteBaseUrl : '')
return anyResourceDomainSeparated.value !== settings.value.domainSeparation
|| siteBaseUrlDraft.value !== settings.value.siteBaseUrl
|| imageBaseUrlDraft.value !== settings.value.imageBaseUrl
|| videoBaseUrlDraft.value !== settings.value.videoBaseUrl
|| fileBaseUrlDraft.value !== settings.value.fileBaseUrl
|| imageDomainSeparatedDraft.value !== settings.value.imageDomainSeparated
|| videoDomainSeparatedDraft.value !== settings.value.videoDomainSeparated
|| fileDomainSeparatedDraft.value !== settings.value.fileDomainSeparated
})
const needsDomainSeparationSaveConfirm = computed(() =>
@@ -251,8 +313,13 @@ function errorStatus(error: unknown): number {
function applySettings(data: SettingsResponse) {
settings.value = data
refererDraft.value = data.allowedRefererHosts
siteBaseUrlDraft.value = data.domainSeparation ? data.siteBaseUrl : ''
siteBaseUrlDraft.value = data.siteBaseUrl
imageBaseUrlDraft.value = data.imageBaseUrl
videoBaseUrlDraft.value = data.videoBaseUrl
fileBaseUrlDraft.value = data.fileBaseUrl
imageDomainSeparatedDraft.value = data.imageDomainSeparated
videoDomainSeparatedDraft.value = data.videoDomainSeparated
fileDomainSeparatedDraft.value = data.fileDomainSeparated
hideFolderInUrlDraft.value = data.hideFolderInUrl
storageUseDatePathDraft.value = data.storageUseDatePath
allowRegistrationDraft.value = data.allowRegistration
@@ -261,15 +328,19 @@ function applySettings(data: SettingsResponse) {
turnstileSecretKeyDraft.value = data.turnstileSecretKey
capApiEndpointDraft.value = data.capApiEndpoint
capSecretDraft.value = data.capSecret
domainSeparationDraft.value = data.domainSeparation
uploadMaxFileSizeMbDraft.value = data.uploadMaxFileSizeMb
uploadImageMaxFileSizeMbDraft.value = data.uploadImageMaxFileSizeMb
uploadVideoMaxFileSizeMbDraft.value = data.uploadVideoMaxFileSizeMb
uploadFileMaxFileSizeMbDraft.value = data.uploadFileMaxFileSizeMb
allowedMimeTypesDraft.value = [...data.allowedMimeTypes]
allowedVideoExtensionsDraft.value = [...data.allowedVideoExtensions]
forbiddenFileExtensionsDraft.value = [...data.forbiddenFileExtensions]
uploadRateIpMaxDraft.value = data.uploadRateIpMax
uploadRateTokenMaxDraft.value = data.uploadRateTokenMax
uploadRateWindowMinutesDraft.value = data.uploadRateWindowMinutes
loginRateMaxDraft.value = data.loginRateMax
loginRateWindowMinutesDraft.value = data.loginRateWindowMinutes
preserveOriginalUploadDraft.value = data.preserveOriginalUpload
fileBedAccessDraft.value = data.fileBedAccess
}
async function patchSettings(body: Record<string, unknown>) {
@@ -293,38 +364,28 @@ function handlePatchError(error: unknown, fallback: string) {
toast.add({ title: fallback, color: 'error' })
}
function fillDetectedSiteOrigin() {
if (settings.value?.runtime.currentOrigin) {
siteBaseUrlDraft.value = settings.value.runtime.currentOrigin
}
function fillDetectedOrigin(target: 'site' | 'image' | 'video' | 'file') {
const origin = settings.value?.runtime.currentOrigin
if (!origin) return
if (target === 'site') siteBaseUrlDraft.value = origin
else if (target === 'image') imageBaseUrlDraft.value = origin
else if (target === 'video') videoBaseUrlDraft.value = origin
else fileBaseUrlDraft.value = origin
}
function onDomainSeparationDraftChange(next: boolean | 'indeterminate') {
const enabled = next === true
if (!enabled && domainSeparationDraft.value) {
pendingDomainSeparationDisable.value = true
disableDomainSeparationOpen.value = true
return
}
domainSeparationDraft.value = enabled
}
function confirmDisableDomainSeparation() {
domainSeparationDraft.value = false
siteBaseUrlDraft.value = ''
disableDomainSeparationOpen.value = false
pendingDomainSeparationDisable.value = false
}
function cancelDisableDomainSeparation() {
disableDomainSeparationOpen.value = false
pendingDomainSeparationDisable.value = false
function effectiveResourceDraft(
separated: boolean,
configuredUrl: string
): string {
return separated
? configuredUrl.trim() || settings.value?.runtime.currentOrigin || ''
: siteBaseUrlDraft.value.trim() || settings.value?.runtime.currentOrigin || ''
}
async function saveServerSettings() {
if (!isAuthenticated.value || !hasServerChanges.value) return
if (domainSeparationDraft.value) {
if (anyResourceDomainSeparated.value) {
if (!siteBaseUrlDraft.value.trim() || !imageBaseUrlDraft.value.trim()) {
toast.add({ title: t('settings.domainSeparationRequired'), color: 'error' })
return
@@ -349,12 +410,21 @@ async function performSaveServerSettings() {
toast.add({ title: t('settings.allowedMimeTypesHint'), color: 'error' })
return
}
if (allowedVideoExtensionsDraft.value.length === 0) {
toast.add({ title: t('settings.allowedVideoExtensionsHint'), color: 'error' })
return
}
await patchSettings({
allowedRefererHosts: refererDraft.value,
domainSeparation: domainSeparationDraft.value,
siteBaseUrl: domainSeparationDraft.value ? siteBaseUrlDraft.value : '',
domainSeparation: anyResourceDomainSeparated.value,
siteBaseUrl: siteBaseUrlDraft.value,
imageBaseUrl: imageBaseUrlDraft.value,
videoBaseUrl: videoBaseUrlDraft.value,
fileBaseUrl: fileBaseUrlDraft.value,
imageDomainSeparated: imageDomainSeparatedDraft.value,
videoDomainSeparated: videoDomainSeparatedDraft.value,
fileDomainSeparated: fileDomainSeparatedDraft.value,
hideFolderInUrl: hideFolderInUrlDraft.value,
storageUseDatePath: storageUseDatePathDraft.value,
allowRegistration: allowRegistrationDraft.value,
@@ -363,15 +433,21 @@ async function performSaveServerSettings() {
turnstileSecretKey: turnstileSecretKeyDraft.value,
capApiEndpoint: capApiEndpointDraft.value,
capSecret: capSecretDraft.value,
uploadMaxFileSizeMb: uploadMaxFileSizeMbDraft.value,
uploadImageMaxFileSizeMb: uploadImageMaxFileSizeMbDraft.value,
uploadVideoMaxFileSizeMb: uploadVideoMaxFileSizeMbDraft.value,
uploadFileMaxFileSizeMb: uploadFileMaxFileSizeMbDraft.value,
allowedMimeTypes: allowedMimeTypesDraft.value,
allowedVideoExtensions: allowedVideoExtensionsDraft.value,
forbiddenFileExtensions: forbiddenFileExtensionsDraft.value,
uploadRateIpMax: uploadRateIpMaxDraft.value,
uploadRateTokenMax: uploadRateTokenMaxDraft.value,
uploadRateWindowMinutes: uploadRateWindowMinutesDraft.value,
loginRateMax: loginRateMaxDraft.value,
loginRateWindowMinutes: loginRateWindowMinutesDraft.value,
preserveOriginalUpload: preserveOriginalUploadDraft.value
preserveOriginalUpload: preserveOriginalUploadDraft.value,
fileBedAccess: fileBedAccessDraft.value
})
await reloadUploadPolicy(true)
toast.add({ title: t('settings.saved'), color: 'success' })
} catch (error: unknown) {
handlePatchError(error, t('settings.saveFailed'))
@@ -448,7 +524,9 @@ const loginVerificationOptions = computed(() => [
function restoreDefaults() {
if (activeTab.value === 'basic') {
resetUploadPreferences()
resetImagePreferences()
resetVideoPreferences()
resetFilePreferences()
void reloadUserSettings()
}
if (settings.value) {
@@ -560,18 +638,28 @@ watch(isAdmin, () => {
>
<SettingsGroup>
<SettingsUploadPolicyFields
v-model:upload-max-file-size-mb="uploadMaxFileSizeMbDraft"
v-model:upload-image-max-file-size-mb="uploadImageMaxFileSizeMbDraft"
v-model:upload-video-max-file-size-mb="uploadVideoMaxFileSizeMbDraft"
v-model:upload-file-max-file-size-mb="uploadFileMaxFileSizeMbDraft"
v-model:allowed-mime-types="allowedMimeTypesDraft"
v-model:allowed-video-extensions="allowedVideoExtensionsDraft"
v-model:forbidden-file-extensions="forbiddenFileExtensionsDraft"
v-model:upload-rate-ip-max="uploadRateIpMaxDraft"
v-model:upload-rate-token-max="uploadRateTokenMaxDraft"
v-model:upload-rate-window-minutes="uploadRateWindowMinutesDraft"
part="upload-preference"
:upload-max-file-size-mb-source="settings.uploadMaxFileSizeMbSource"
:upload-image-max-file-size-mb-source="settings.uploadImageMaxFileSizeMbSource"
:upload-video-max-file-size-mb-source="settings.uploadVideoMaxFileSizeMbSource"
:upload-file-max-file-size-mb-source="settings.uploadFileMaxFileSizeMbSource"
:allowed-mime-types-source="settings.allowedMimeTypesSource"
:allowed-video-extensions-source="settings.allowedVideoExtensionsSource"
:forbidden-file-extensions-source="settings.forbiddenFileExtensionsSource"
:upload-rate-ip-max-source="settings.uploadRateIpMaxSource"
:upload-rate-token-max-source="settings.uploadRateTokenMaxSource"
:upload-rate-window-minutes-source="settings.uploadRateWindowMinutesSource"
:mime-type-options="settings.mimeTypeOptions"
:video-extension-options="settings.videoExtensionOptions"
:forbidden-file-extension-options="settings.forbiddenFileExtensionOptions"
/>
<SettingsUserPreferencesFields part="upload-preference" />
</SettingsGroup>
@@ -591,6 +679,15 @@ watch(isAdmin, () => {
</SettingsGroup>
</SettingsSection>
<SettingsSection
:title="t('preferences.videoProcessing')"
:hint="t('settings.videoProcessingHint')"
>
<SettingsGroup>
<SettingsVideoProcessingFields />
</SettingsGroup>
</SettingsSection>
<SettingsSection :title="t('settings.systemInfo')">
<SettingsGroup>
<SettingsSystemInfo
@@ -629,14 +726,8 @@ watch(isAdmin, () => {
{{ t('settings.runtimeDetected', { url: settings.runtime.currentOrigin }) }}
</span>
</p>
<SettingsToggleRow
:model-value="domainSeparationDraft"
:title="t('setup.domainSeparation')"
:hint="t('setup.domainSeparationHint')"
@update:model-value="onDomainSeparationDraftChange"
/>
<div
v-if="domainSeparationDraft"
v-if="anyResourceDomainSeparated"
class="py-4"
>
<div class="flex flex-col gap-3 rounded-xl border border-warning/30 bg-warning/5 px-4 py-3 sm:flex-row sm:items-start">
@@ -670,6 +761,17 @@ watch(isAdmin, () => {
class="mt-4"
/>
</div>
<SettingsDomainConfiguration
v-model:site-base-url="siteBaseUrlDraft"
v-model:image-base-url="imageBaseUrlDraft"
v-model:video-base-url="videoBaseUrlDraft"
v-model:file-base-url="fileBaseUrlDraft"
v-model:image-separated="imageDomainSeparatedDraft"
v-model:video-separated="videoDomainSeparatedDraft"
v-model:file-separated="fileDomainSeparatedDraft"
:current-origin="settings.runtime.currentOrigin"
:effective-site-url="settings.effectiveSiteBaseUrl"
/>
</div>
</SettingsSection>
@@ -722,24 +824,17 @@ watch(isAdmin, () => {
</SettingsGroup>
</SettingsSection>
<SettingsSection :title="t('settings.imageDomainSection')">
<div
v-if="domainSeparationDraft"
class="grid gap-4 sm:grid-cols-2 sm:divide-x sm:divide-default"
>
<SettingsSection
v-if="false && settings"
:title="t('settings.resourceDomainSection')"
>
<div class="grid gap-4 sm:grid-cols-2">
<SettingsGroup>
<div class="space-y-3 py-4 sm:pr-4">
<div class="flex flex-wrap items-center gap-2">
<p class="text-sm font-medium text-highlighted">
{{ t('settings.siteBaseUrl') }}
</p>
<UBadge
color="success"
variant="subtle"
size="xs"
>
{{ t('settings.badgeEnabled') }}
</UBadge>
</div>
<p class="text-xs leading-relaxed text-muted">
{{ t('settings.siteBaseUrlRoleHint') }}
@@ -752,21 +847,21 @@ watch(isAdmin, () => {
class="min-w-0 flex-1 font-mono"
/>
<UButton
v-if="settings.runtime.currentOrigin"
v-if="settings?.runtime.currentOrigin"
:label="t('settings.fillDetectedOrigin')"
color="neutral"
variant="outline"
size="sm"
class="shrink-0"
@click="fillDetectedSiteOrigin"
@click="fillDetectedOrigin('site')"
/>
</div>
<p
v-if="settings.effectiveSiteBaseUrl"
v-if="settings?.effectiveSiteBaseUrl"
class="truncate rounded-lg bg-muted/30 px-3 py-2 text-xs text-muted"
:title="settings.effectiveSiteBaseUrl"
:title="settings?.effectiveSiteBaseUrl"
>
{{ t('settings.siteBaseUrlActive', { url: settings.effectiveSiteBaseUrl }) }}
{{ t('settings.siteBaseUrlActive', { url: settings?.effectiveSiteBaseUrl }) }}
</p>
</div>
</SettingsGroup>
@@ -777,74 +872,129 @@ watch(isAdmin, () => {
<p class="text-sm font-medium text-highlighted">
{{ t('settings.imageBaseUrl') }}
</p>
<UBadge
color="success"
variant="subtle"
size="xs"
>
{{ t('settings.badgeEnabled') }}
</UBadge>
<USwitch v-model="imageDomainSeparatedDraft" />
</div>
<p class="text-xs leading-relaxed text-muted">
{{ t('settings.imageBaseUrlRoleHint') }}
</p>
<UInput
v-model="imageBaseUrlDraft"
size="sm"
:placeholder="t('settings.imageBaseUrlPlaceholder')"
class="w-full font-mono"
/>
<p
v-if="settings.effectiveImageBaseUrl"
class="truncate rounded-lg bg-muted/30 px-3 py-2 text-xs text-muted"
:title="settings.effectiveImageBaseUrl"
<div
class="flex gap-2"
:class="imageDomainSeparatedDraft ? '' : 'pointer-events-none opacity-50'"
>
{{ t('settings.imageBaseUrlActive', { url: settings.effectiveImageBaseUrl }) }}
<UInput
v-model="imageBaseUrlDraft"
size="sm"
:placeholder="t('settings.imageBaseUrlPlaceholder')"
class="min-w-0 flex-1 font-mono"
/>
<UButton
v-if="settings?.runtime.currentOrigin"
:label="t('settings.fillDetectedOrigin')"
color="neutral"
variant="outline"
size="sm"
class="shrink-0"
@click="fillDetectedOrigin('image')"
/>
</div>
<p
v-if="effectiveResourceDraft(imageDomainSeparatedDraft, imageBaseUrlDraft)"
class="truncate rounded-lg bg-muted/30 px-3 py-2 text-xs text-muted"
:title="effectiveResourceDraft(imageDomainSeparatedDraft, imageBaseUrlDraft)"
>
{{ t('settings.imageBaseUrlActive', {
url: effectiveResourceDraft(imageDomainSeparatedDraft, imageBaseUrlDraft)
}) }}
</p>
</div>
</SettingsGroup>
<SettingsGroup>
<div class="space-y-3 py-4 sm:pr-4">
<div class="flex flex-wrap items-center gap-2">
<p class="text-sm font-medium text-highlighted">
{{ t('settings.videoBaseUrl') }}
</p>
<USwitch v-model="videoDomainSeparatedDraft" />
</div>
<p class="text-xs leading-relaxed text-muted">
{{ t('settings.videoBaseUrlRoleHint') }}
</p>
<div
class="flex gap-2"
:class="videoDomainSeparatedDraft ? '' : 'pointer-events-none opacity-50'"
>
<UInput
v-model="videoBaseUrlDraft"
size="sm"
:placeholder="t('settings.videoBaseUrlPlaceholder')"
class="min-w-0 flex-1 font-mono"
/>
<UButton
v-if="settings?.runtime.currentOrigin"
:label="t('settings.fillDetectedOrigin')"
color="neutral"
variant="outline"
size="sm"
class="shrink-0"
@click="fillDetectedOrigin('video')"
/>
</div>
<p
v-if="effectiveResourceDraft(videoDomainSeparatedDraft, videoBaseUrlDraft)"
class="truncate rounded-lg bg-muted/30 px-3 py-2 text-xs text-muted"
:title="effectiveResourceDraft(videoDomainSeparatedDraft, videoBaseUrlDraft)"
>
{{ t('settings.videoBaseUrlActive', {
url: effectiveResourceDraft(videoDomainSeparatedDraft, videoBaseUrlDraft)
}) }}
</p>
</div>
</SettingsGroup>
<SettingsGroup>
<div class="space-y-3 py-4 sm:pl-4">
<div class="flex flex-wrap items-center gap-2">
<p class="text-sm font-medium text-highlighted">
{{ t('settings.fileBaseUrl') }}
</p>
<USwitch v-model="fileDomainSeparatedDraft" />
</div>
<p class="text-xs leading-relaxed text-muted">
{{ t('settings.fileBaseUrlRoleHint') }}
</p>
<div
class="flex gap-2"
:class="fileDomainSeparatedDraft ? '' : 'pointer-events-none opacity-50'"
>
<UInput
v-model="fileBaseUrlDraft"
size="sm"
:placeholder="t('settings.fileBaseUrlPlaceholder')"
class="min-w-0 flex-1 font-mono"
/>
<UButton
v-if="settings?.runtime.currentOrigin"
:label="t('settings.fillDetectedOrigin')"
color="neutral"
variant="outline"
size="sm"
class="shrink-0"
@click="fillDetectedOrigin('file')"
/>
</div>
<p
v-if="effectiveResourceDraft(fileDomainSeparatedDraft, fileBaseUrlDraft)"
class="truncate rounded-lg bg-muted/30 px-3 py-2 text-xs text-muted"
:title="effectiveResourceDraft(fileDomainSeparatedDraft, fileBaseUrlDraft)"
>
{{ t('settings.fileBaseUrlActive', {
url: effectiveResourceDraft(fileDomainSeparatedDraft, fileBaseUrlDraft)
}) }}
</p>
</div>
</SettingsGroup>
</div>
<SettingsGroup v-else>
<div class="space-y-3 py-4">
<div class="flex flex-wrap items-center gap-2">
<p class="text-sm font-medium text-highlighted">
{{ t('settings.imageBaseUrl') }}
</p>
<UBadge
color="success"
variant="subtle"
size="xs"
>
{{ t('settings.badgeOptional') }}
</UBadge>
<UBadge
v-if="settings.imageBaseUrlSource === 'env'"
:color="sourceBadge(settings.imageBaseUrlSource).color"
variant="subtle"
size="xs"
>
{{ sourceBadge(settings.imageBaseUrlSource).label }}
</UBadge>
</div>
<p class="text-xs leading-relaxed text-muted">
{{ t('settings.imageBaseUrlSingleHint') }}
</p>
<UInput
v-model="imageBaseUrlDraft"
size="sm"
:placeholder="t('settings.imageBaseUrlPlaceholder')"
class="w-full font-mono"
/>
<p
v-if="settings.effectiveImageBaseUrl"
class="truncate rounded-lg bg-muted/30 px-3 py-2 text-xs text-muted"
:title="settings.effectiveImageBaseUrl"
>
{{ t('settings.imageBaseUrlActive', { url: settings.effectiveImageBaseUrl }) }}
</p>
</div>
</SettingsGroup>
</SettingsSection>
</SettingsPanel>
<div
@@ -923,6 +1073,18 @@ watch(isAdmin, () => {
</div>
</SettingsPlainSection>
<SettingsSection
:title="t('settings.fileBed')"
:hint="t('settings.fileBedHint')"
>
<SettingsGroup>
<SettingsRadioGroup
v-model="fileBedAccessDraft"
:items="fileBedAccessOptions"
/>
</SettingsGroup>
</SettingsSection>
<SettingsSection
:title="t('settings.loginVerification')"
:hint="t('settings.loginVerificationHint')"
@@ -1038,29 +1200,6 @@ watch(isAdmin, () => {
</section>
</AppShell>
<UModal
:open="disableDomainSeparationOpen"
:title="t('settings.disableDomainSeparationTitle')"
:description="t('settings.disableDomainSeparationDesc')"
@update:open="(v) => { if (!v) cancelDisableDomainSeparation() }"
>
<template #footer>
<div class="flex justify-end gap-2">
<UButton
:label="t('common.cancel')"
color="neutral"
variant="outline"
@click="cancelDisableDomainSeparation"
/>
<UButton
:label="t('settings.disableDomainSeparationConfirm')"
color="warning"
@click="confirmDisableDomainSeparation"
/>
</div>
</template>
</UModal>
<UModal
:open="domainSeparationSaveConfirmOpen"
:title="t('domainSeparationConfirm.title')"
+29 -4
View File
@@ -26,6 +26,8 @@ const allowRegistration = ref(false)
const domainSeparation = ref(false)
const siteBaseUrl = ref('')
const imageBaseUrl = ref('')
const videoBaseUrl = ref('')
const fileBaseUrl = ref('')
const loading = ref(false)
const domainSeparationConfirmOpen = ref(false)
@@ -95,11 +97,10 @@ async function submitRestore() {
if (!restoreFile.value || restoreLoading.value) return
restoreLoading.value = true
try {
const form = new FormData()
form.append('file', restoreFile.value)
const data = await $fetch<{ job: { id: string } }>('/api/setup/restore', {
method: 'POST',
body: form
body: restoreFile.value,
headers: { 'content-type': 'application/octet-stream' }
})
restoreJobId.value = data.job.id
restorePollTimer = setInterval(() => {
@@ -180,7 +181,9 @@ async function performSubmit() {
allowRegistration: allowRegistration.value,
domainSeparation: domainSeparation.value,
siteBaseUrl: domainSeparation.value ? siteBaseUrl.value.trim() : undefined,
imageBaseUrl: domainSeparation.value ? imageBaseUrl.value.trim() : undefined
imageBaseUrl: domainSeparation.value ? imageBaseUrl.value.trim() : undefined,
videoBaseUrl: domainSeparation.value ? videoBaseUrl.value.trim() : undefined,
fileBaseUrl: domainSeparation.value ? fileBaseUrl.value.trim() : undefined
}
const result = isMigrate.value
? await migrate(payload)
@@ -363,6 +366,28 @@ async function performSubmit() {
class="w-full font-mono text-sm"
/>
</div>
<div class="space-y-2">
<label class="text-sm">{{ t('setup.videoBaseUrl') }}</label>
<p class="text-xs text-muted">
{{ t('setup.videoBaseUrlHint') }}
</p>
<UInput
v-model="videoBaseUrl"
:placeholder="t('setup.videoBaseUrlPlaceholder')"
class="w-full font-mono text-sm"
/>
</div>
<div class="space-y-2">
<label class="text-sm">{{ t('setup.fileBaseUrl') }}</label>
<p class="text-xs text-muted">
{{ t('setup.fileBaseUrlHint') }}
</p>
<UInput
v-model="fileBaseUrl"
:placeholder="t('setup.fileBaseUrlPlaceholder')"
class="w-full font-mono text-sm"
/>
</div>
</template>
</div>
+124 -3
View File
@@ -20,6 +20,17 @@ const deleteOpen = ref(false)
const deleteTarget = ref<StorageBackendItem | null>(null)
const deleteConfirmName = ref('')
const deleting = ref(false)
const clearStorageOpen = ref(false)
const backupPanelKey = ref(0)
function openClearStorage() {
clearStorageOpen.value = true
}
function handleStorageCleared() {
backupPanelKey.value += 1
void loadStorage()
}
const backupPanelRef = ref<{ applySyncDraft: (draft: { fromBackendId: string, toBackendId: string }) => void } | null>(null)
@@ -93,16 +104,42 @@ const { quotaToBytes } = useFileSize()
function buildPatchBody(payload: StorageFormPayload) {
const quotaBytes = quotaToBytes(payload.quotaValue, payload.quotaUnit)
if (payload.backendType === 'telegram') {
const body: Record<string, unknown> = {
type: 'telegram',
name: payload.name,
quotaBytes,
config: {
chatId: payload.config.chatId,
apiProxyUrl: payload.config.apiProxyUrl
}
}
if (payload.secrets.botToken) {
body.secrets = { botToken: payload.secrets.botToken }
}
return body
}
const body: Record<string, unknown> = {
type: 's3',
name: payload.name,
provider: payload.provider,
servingMode: payload.servingMode,
publicUrl: payload.publicUrl,
quotaBytes,
config: payload.config
config: {
endpoint: payload.config.endpoint,
region: payload.config.region,
bucket: payload.config.bucket,
prefix: payload.config.prefix,
forcePathStyle: payload.config.forcePathStyle
}
}
if (payload.secrets.accessKeyId || payload.secrets.secretAccessKey) {
body.secrets = payload.secrets
body.secrets = {
accessKeyId: payload.secrets.accessKeyId,
secretAccessKey: payload.secrets.secretAccessKey
}
}
return body
}
@@ -128,7 +165,12 @@ async function onFormSubmit(payload: StorageFormPayload) {
}
if (formMode.value === 'create') {
if (!payload.secrets.accessKeyId || !payload.secrets.secretAccessKey) {
if (payload.backendType === 'telegram') {
if (!payload.secrets.botToken || !payload.config.chatId) {
toast.add({ title: t('storage.telegramSecretsRequired'), color: 'warning' })
return
}
} else if (!payload.secrets.accessKeyId || !payload.secrets.secretAccessKey) {
toast.add({ title: t('storage.secretsRequired'), color: 'warning' })
return
}
@@ -268,6 +310,30 @@ watch(isAuthenticated, async (authed) => {
await loadStorage()
}
})
const exportingCsv = ref(false)
async function exportDatabaseCsv() {
if (exportingCsv.value) return
exportingCsv.value = true
try {
const blob = await $fetch<Blob>('/api/admin/export/csv', {
credentials: 'include',
responseType: 'blob'
})
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = `pichost-db-${new Date().toISOString().slice(0, 10)}.tar.gz`
link.click()
URL.revokeObjectURL(url)
toast.add({ title: t('storage.exportCsvStarted'), color: 'success' })
} catch {
toast.add({ title: t('storage.exportCsvFailed'), color: 'error' })
} finally {
exportingCsv.value = false
}
}
</script>
<template>
@@ -358,11 +424,61 @@ watch(isAuthenticated, async (authed) => {
<StorageBackupPanel
v-if="!storageData.envOverride"
:key="backupPanelKey"
ref="backupPanelRef"
:backends="storageData.backends"
/>
<StorageSupportedBackends />
<section class="rounded-xl border border-default bg-elevated p-5 sm:p-6">
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div class="min-w-0">
<h2 class="flex items-center gap-2 text-base font-semibold">
<UIcon
name="i-lucide-file-archive"
class="size-5 text-primary"
/>
{{ t('storage.exportTitle') }}
</h2>
<p class="mt-1 text-sm leading-relaxed text-muted">
{{ t('storage.exportDescription') }}
</p>
</div>
<UButton
:label="t('storage.exportCsv')"
icon="i-lucide-download"
color="primary"
:loading="exportingCsv"
class="shrink-0"
@click="exportDatabaseCsv"
/>
</div>
</section>
<section class="rounded-2xl border border-error/30 bg-error/5 p-5 sm:p-6">
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div class="min-w-0">
<h2 class="flex items-center gap-2 text-base font-semibold text-error">
<UIcon
name="i-lucide-trash-2"
class="size-5"
/>
{{ t('storage.clearStorageTitle') }}
</h2>
<p class="mt-1 text-sm leading-relaxed text-muted">
{{ t('storage.clearStorageDescription') }}
</p>
</div>
<UButton
:label="t('storage.clearStorage')"
icon="i-lucide-trash-2"
color="error"
class="shrink-0"
@click="openClearStorage"
/>
</div>
</section>
</template>
</section>
</AppShell>
@@ -416,5 +532,10 @@ watch(isAuthenticated, async (authed) => {
</div>
</template>
</UModal>
<ClearStorageModal
v-model:open="clearStorageOpen"
@cleared="handleStorageCleared"
/>
</div>
</template>
+343
View File
@@ -0,0 +1,343 @@
<script setup lang="ts">
import { formatFileSize } from '~/composables/useFileSize'
interface AdminUser {
id: number
username: string
role: 'admin' | 'user'
created_at: string
password_reset: number
disabled_at: string | null
usage?: {
imageBytes: number
videoBytes: number
fileBytes: number
}
}
const {
isChecking,
isAuthenticated,
isAdmin,
checkSession,
fetchStatus,
handleAuthError,
getFetchErrorMessage
} = useAuth()
const toast = useToast()
const { t } = useI18n()
const users = ref<AdminUser[]>([])
const loading = ref(false)
const deleteTarget = ref<AdminUser | null>(null)
const deleteConfirm = ref('')
const deleting = ref(false)
const statusBusyId = ref<number | null>(null)
const canDelete = computed(() =>
Boolean(
deleteTarget.value
&& deleteTarget.value.role !== 'admin'
&& deleteConfirm.value.trim() === deleteTarget.value.username
&& !deleting.value
)
)
async function loadUsers() {
if (!isAdmin.value) return
loading.value = true
try {
const data = await $fetch<{ users: AdminUser[] }>('/api/admin/users', {
credentials: 'include'
})
users.value = data.users
} catch (error: unknown) {
handleAuthError(error)
if (isAuthenticated.value) {
toast.add({ title: t('users.loadFailed'), color: 'error' })
}
} finally {
loading.value = false
}
}
async function clearPassword(user: AdminUser) {
try {
await $fetch(`/api/admin/users/${user.id}/clear-password`, {
method: 'POST',
credentials: 'include'
})
toast.add({ title: t('users.passwordCleared', { name: user.username }), color: 'success' })
await loadUsers()
} catch (error: unknown) {
handleAuthError(error)
toast.add({
title: getFetchErrorMessage(error, t('users.clearFailed')),
color: 'error'
})
}
}
async function toggleDisabled(user: AdminUser) {
if (user.role === 'admin' || statusBusyId.value !== null) return
const disabled = !user.disabled_at
statusBusyId.value = user.id
try {
await $fetch(`/api/admin/users/${user.id}/status`, {
method: 'POST',
credentials: 'include',
body: { disabled }
})
toast.add({
title: t(disabled ? 'users.disableSuccess' : 'users.enableSuccess', {
name: user.username
}),
color: 'success'
})
await loadUsers()
} catch (error: unknown) {
handleAuthError(error)
toast.add({
title: getFetchErrorMessage(error, t('users.statusFailed')),
color: 'error'
})
} finally {
statusBusyId.value = null
}
}
function openDeleteDialog(user: AdminUser) {
if (user.role === 'admin') return
deleteTarget.value = user
deleteConfirm.value = ''
}
function closeDeleteDialog() {
if (deleting.value) return
deleteTarget.value = null
deleteConfirm.value = ''
}
function onDeleteOpenChange(open: boolean) {
if (!open) closeDeleteDialog()
}
async function deleteUser() {
const target = deleteTarget.value
if (!target || !canDelete.value) return
deleting.value = true
try {
await $fetch(`/api/admin/users/${target.id}/delete`, {
method: 'POST',
credentials: 'include',
body: { confirmation: deleteConfirm.value.trim() }
})
toast.add({
title: t('users.userDeleted', { name: target.username }),
color: 'success'
})
deleteTarget.value = null
deleteConfirm.value = ''
await loadUsers()
} catch (error: unknown) {
handleAuthError(error)
toast.add({
title: getFetchErrorMessage(error, t('users.deleteFailed')),
color: 'error'
})
} finally {
deleting.value = false
}
}
function formatDate(value: string) {
try {
return new Date(value).toLocaleString()
} catch {
return value
}
}
onMounted(async () => {
const status = await fetchStatus()
if (!status.initialized && !status.legacyMode) {
await navigateTo('/setup')
return
}
await checkSession()
if (isAuthenticated.value && isAdmin.value) {
await loadUsers()
}
})
</script>
<template>
<div class="min-h-screen">
<div
v-if="isChecking"
class="flex min-h-screen items-center justify-center"
>
<div class="flex flex-col items-center gap-3 text-muted">
<UIcon
name="i-lucide-loader-circle"
class="size-8 animate-spin"
/>
<p class="text-sm">
{{ t('common.loadingSession') }}
</p>
</div>
</div>
<AdminLoginGate v-else-if="!isAuthenticated" />
<AppShell v-else>
<div class="flex items-start justify-between gap-3">
<div class="min-w-0">
<h1 class="text-2xl font-semibold tracking-tight">
{{ t('users.title') }}
</h1>
<p class="text-sm text-muted">
{{ t('users.subtitle') }}
</p>
</div>
<UButton
icon="i-lucide-refresh-cw"
variant="ghost"
color="neutral"
size="sm"
:aria-label="t('common.refresh')"
:loading="loading"
@click="loadUsers"
/>
</div>
<UAlert
v-if="!isAdmin"
color="warning"
variant="subtle"
:title="t('users.adminOnly')"
/>
<ul
v-else
class="rounded-xl border border-default"
>
<li
v-for="user in users"
:key="user.id"
class="flex flex-wrap items-center gap-3 border-b border-default px-4 py-3 last:border-0"
>
<div class="min-w-0 flex-1">
<p class="flex items-center gap-2 text-sm font-medium text-highlighted">
<span class="truncate">{{ user.username }}</span>
<UBadge
:color="user.role === 'admin' ? 'primary' : 'neutral'"
variant="subtle"
size="xs"
>
{{ user.role === 'admin' ? t('users.roleAdmin') : t('users.roleUser') }}
</UBadge>
</p>
<p class="mt-0.5 text-xs text-muted">
{{ formatDate(user.created_at) }}
</p>
</div>
<div
v-if="user.role !== 'admin'"
class="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-muted"
>
<span>{{ t('users.usageImages') }} {{ formatFileSize(user.usage?.imageBytes ?? 0) }}</span>
<span>{{ t('users.usageVideos') }} {{ formatFileSize(user.usage?.videoBytes ?? 0) }}</span>
<span>{{ t('users.usageFiles') }} {{ formatFileSize(user.usage?.fileBytes ?? 0) }}</span>
</div>
<div
v-if="user.role !== 'admin'"
class="flex items-center gap-2"
>
<UBadge
v-if="user.disabled_at"
color="error"
variant="subtle"
size="sm"
>
{{ t('users.disabled') }}
</UBadge>
<UBadge
v-if="user.password_reset === 1"
color="warning"
variant="subtle"
size="sm"
>
{{ t('users.resetPending') }}
</UBadge>
<UButton
variant="outline"
:color="user.disabled_at ? 'primary' : 'warning'"
size="sm"
:label="user.disabled_at ? t('users.enable') : t('users.disable')"
:loading="statusBusyId === user.id"
:disabled="statusBusyId !== null && statusBusyId !== user.id"
@click="toggleDisabled(user)"
/>
<UButton
v-if="user.password_reset !== 1"
variant="outline"
color="warning"
size="sm"
:label="t('users.clearPassword')"
@click="clearPassword(user)"
/>
<UButton
variant="outline"
color="error"
size="sm"
:label="t('users.deleteUser')"
@click="openDeleteDialog(user)"
/>
</div>
</li>
</ul>
</AppShell>
<UModal
:open="Boolean(deleteTarget)"
:title="t('users.deleteTitle')"
:description="t('users.deleteWarning')"
@update:open="onDeleteOpenChange"
>
<template #body>
<div class="space-y-2">
<p class="text-sm text-muted">
{{ t('users.deleteConfirmLabel', { name: deleteTarget?.username ?? '' }) }}
</p>
<UInput
v-model="deleteConfirm"
:placeholder="deleteTarget?.username"
class="w-full"
autocomplete="off"
/>
</div>
</template>
<template #footer>
<div class="flex justify-end gap-2">
<UButton
:label="t('common.cancel')"
color="neutral"
variant="outline"
:disabled="deleting"
@click="closeDeleteDialog"
/>
<UButton
:label="t('users.deleteSubmit')"
color="error"
:loading="deleting"
:disabled="!canDelete"
@click="deleteUser"
/>
</div>
</template>
</UModal>
</div>
</template>
+56
View File
@@ -0,0 +1,56 @@
<script setup lang="ts">
import VideoLibraryPanel from '~/components/VideoLibraryPanel.vue'
const { isChecking, isAuthenticated, checkSession, fetchStatus } = useAuth()
const { t } = useI18n()
const panelRef = ref<{ refresh: () => Promise<void> } | null>(null)
async function loadPanel() {
if (isAuthenticated.value) {
await panelRef.value?.refresh()
}
}
onMounted(async () => {
const status = await fetchStatus()
if (!status.initialized && !status.legacyMode) {
await navigateTo('/setup')
return
}
await checkSession()
await loadPanel()
})
watch(isAuthenticated, async (authed, prev) => {
if (authed && prev === false) {
await nextTick()
await loadPanel()
}
})
</script>
<template>
<div class="min-h-screen">
<div
v-if="isChecking"
class="flex min-h-screen items-center justify-center"
>
<div class="flex flex-col items-center gap-3 text-muted">
<UIcon
name="i-lucide-loader-circle"
class="size-8 animate-spin"
/>
<p class="text-sm">
{{ t('common.loadingSession') }}
</p>
</div>
</div>
<AdminLoginGate v-else-if="!isAuthenticated" />
<AppShell v-else>
<VideoLibraryPanel ref="panelRef" />
</AppShell>
</div>
</template>
+38
View File
@@ -0,0 +1,38 @@
<script setup lang="ts">
const { isChecking, isAuthenticated, checkSession, fetchStatus } = useAuth()
const { t } = useI18n()
onMounted(async () => {
const status = await fetchStatus()
if (!status.initialized && !status.legacyMode) {
await navigateTo('/setup')
return
}
await checkSession()
})
</script>
<template>
<div class="min-h-screen">
<div
v-if="isChecking"
class="flex min-h-screen items-center justify-center"
>
<div class="flex flex-col items-center gap-3 text-muted">
<UIcon
name="i-lucide-loader-circle"
class="size-8 animate-spin"
/>
<p class="text-sm">
{{ t('common.loadingSession') }}
</p>
</div>
</div>
<AdminLoginGate v-else-if="!isAuthenticated" />
<AppShell v-else>
<MediaUploadPanel kind="video" />
</AppShell>
</div>
</template>
+17 -1
View File
@@ -1,5 +1,6 @@
export type BackupJobType = 'full_export' | 'full_restore' | 'backend_sync'
export type BackupJobType = 'full_export' | 'full_restore' | 'backend_sync' | 'site_sync'
export type BackupJobStatus = 'pending' | 'running' | 'done' | 'failed'
export type SiteSyncDirection = 'push' | 'pull'
export interface BackupJobItem {
id: string
@@ -14,6 +15,8 @@ export interface BackupJobItem {
downloadable: boolean
error: string | null
createdAt: string
updatedAt: string
hidden: boolean
finishedAt: string | null
}
@@ -51,3 +54,16 @@ export interface SyncDraft {
fromBackendId: string
toBackendId: string
}
export interface SiteSyncRequest {
targetBaseUrl: string
token: string
conflictMode: 'skip' | 'overwrite'
direction: SiteSyncDirection
/** 同步内容:全站或某一类媒体 */
scope?: 'all' | 'image' | 'video' | 'file'
/** 指定资源(同一类型) */
keys?: string[]
resumeJobId?: string
abandonJobId?: string
}
+64
View File
@@ -0,0 +1,64 @@
import type { ImageTag, UploadErrorItem } from './image'
export interface FileItem {
key: string
url: string
originalName: string
contentType: string
size: number
uploadedAt: string
markdown: string
/** 视频转码状态(仅视频有值) */
transcodeStatus?: 'none' | 'pending' | 'running' | 'done' | 'failed'
/** 转码进度 0–100 */
transcodeProgress?: number
/** 本视频使用的转码档位 */
transcodePreset?: string
/** 转码开始时间(ISO) */
transcodeStartedAt?: string
/** 转码总耗时(毫秒) */
transcodeDurationMs?: number
/** 转码失败原因 */
transcodeError?: string
/** HLS 播放列表地址(转码完成后有值) */
hlsUrl?: string
/** HLS 播放列表在存储中的 key */
hlsKey?: string
/** 视频封面缩略图地址 */
thumbUrl?: string
/** 上传来源(来自活动日志) */
uploadSource?: 'web' | 'api'
/** 转码成功后源视频已删除,不再提供原文件下载 */
sourceDeleted?: boolean
/** 视频标签 */
tags?: ImageTag[]
/** 管理员列表可见:上传者信息 */
owner?: {
userId: number | null
username: string
}
/** 文件所在存储后端 */
storage?: {
id: string
name: string
type: 'local' | 's3' | 'telegram'
}
}
export interface FileListResponse {
items: FileItem[]
total: number
page: number
pageSize: number
totalPages: number
/** 文件床直链访问方式(后台可切换) */
accessMode: 'public' | 'token'
}
export interface FileUploadResponse {
success: boolean
items: FileItem[]
errors: UploadErrorItem[]
/** 直链访问方式,token 模式下复制链接需要附带令牌 */
accessMode: 'public' | 'token'
}
+4 -1
View File
@@ -8,7 +8,10 @@ export type ErrorCode
| 'UNSUPPORTED_FILE_TYPE'
| 'INVALID_FILE_SIGNATURE'
| 'INVALID_IMAGE_KEY'
| 'INVALID_VIDEO_KEY'
| 'IMAGE_NOT_FOUND'
| 'HLS_NOT_FOUND'
| 'HLS_REMUX_FAILED'
| 'UPLOAD_FAILED'
| 'DELETE_FAILED'
@@ -40,7 +43,7 @@ export interface ImageItem {
storage?: {
id: string
name: string
type: 'local' | 's3'
type: 'local' | 's3' | 'telegram'
}
/** 上传来源:网页或 API(来自活动日志) */
uploadSource?: 'web' | 'api'
+21
View File
@@ -0,0 +1,21 @@
export type MediaType = 'image' | 'video' | 'file'
/** 图库 / 视频库 / 文件库共用的统计数据结构 */
export interface LibraryStatsData {
uploadToday: number
uploadYesterday: number
uploadMonth: number
uploadLastMonth: number
uploadTotal: number
storedCount: number
uploadBytesTotal: number
bySource: {
web: number
api: number
}
storageUsage: {
usedBytes: number
totalBytes: number | null
percent: number | null
}
}
+4 -1
View File
@@ -11,17 +11,20 @@ export interface StorageCapacity {
export interface StorageBackendItem {
id: string
name: string
type: 'local' | 's3'
type: 'local' | 's3' | 'telegram'
config: {
endpoint?: string
region?: string
bucket?: string
prefix?: string
forcePathStyle?: boolean
chatId?: string
apiProxyUrl?: string
}
secretsMasked: {
accessKeyId?: string
secretAccessKey?: string
botToken?: string
}
servingMode: ServingMode
publicUrl: string
+1 -1
View File
@@ -8,7 +8,7 @@ export function hostnameFromBaseUrl(value: string): string | null {
}
}
/** 勾选分离且网站域、图片域主机名不同 → 保存后将启用双域名中间件隔离 */
/** 勾选分离且后台域、图片域主机名不同 → 保存后启用多域名中间件隔离 */
export function willActivateDomainSeparation(
enabled: boolean,
siteBaseUrl: string,
+50
View File
@@ -0,0 +1,50 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import {
loadSiteSyncForm,
saveSiteSyncForm,
SITE_SYNC_FORM_STORAGE_KEY
} from './site-sync-form'
function createStorage(): Storage {
const values = new Map<string, string>()
return {
get length() {
return values.size
},
clear: () => values.clear(),
getItem: key => values.get(key) ?? null,
key: index => [...values.keys()][index] ?? null,
removeItem: key => values.delete(key),
setItem: (key, value) => values.set(key, value)
}
}
describe('site sync form cache', () => {
afterEach(() => {
vi.useRealTimers()
vi.unstubAllGlobals()
})
it('reads values saved by another component', () => {
vi.stubGlobal('localStorage', createStorage())
saveSiteSyncForm('http://127.0.0.1:3010', 'secret-token')
expect(loadSiteSyncForm()).toEqual({
targetBaseUrl: 'http://127.0.0.1:3010',
token: 'secret-token'
})
})
it('drops the cache after 24 hours', () => {
const storage = createStorage()
vi.stubGlobal('localStorage', storage)
vi.useFakeTimers()
vi.setSystemTime(new Date('2026-10-19T00:00:00Z'))
saveSiteSyncForm('http://target', 'token')
vi.setSystemTime(new Date('2026-10-20T00:00:01Z'))
expect(loadSiteSyncForm()).toBeNull()
expect(storage.getItem(SITE_SYNC_FORM_STORAGE_KEY)).toBeNull()
})
})
+52
View File
@@ -0,0 +1,52 @@
export const SITE_SYNC_FORM_STORAGE_KEY = 'pichost.site-sync-form'
export const SITE_SYNC_FORM_TTL_MS = 24 * 60 * 60 * 1000
export interface SiteSyncFormCache {
targetBaseUrl: string
token: string
}
function getStorage(): Storage | null {
return typeof localStorage === 'undefined' ? null : localStorage
}
export function loadSiteSyncForm(): SiteSyncFormCache | null {
const storage = getStorage()
if (!storage) return null
try {
const raw = storage.getItem(SITE_SYNC_FORM_STORAGE_KEY)
if (!raw) return null
const data = JSON.parse(raw) as {
targetBaseUrl?: unknown
token?: unknown
savedAt?: unknown
}
if (typeof data.savedAt !== 'number' || Date.now() - data.savedAt > SITE_SYNC_FORM_TTL_MS) {
storage.removeItem(SITE_SYNC_FORM_STORAGE_KEY)
return null
}
return {
targetBaseUrl: typeof data.targetBaseUrl === 'string' ? data.targetBaseUrl : '',
token: typeof data.token === 'string' ? data.token : ''
}
} catch {
storage.removeItem(SITE_SYNC_FORM_STORAGE_KEY)
return null
}
}
export function saveSiteSyncForm(targetBaseUrl: string, token: string): void {
const storage = getStorage()
if (!storage) return
try {
storage.setItem(SITE_SYNC_FORM_STORAGE_KEY, JSON.stringify({
targetBaseUrl,
token,
savedAt: Date.now()
}))
} catch {
// 存储不可用时忽略
}
}
+72
View File
@@ -0,0 +1,72 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { uploadFormDataWithProgress } from './upload-progress'
class FakeXhr {
static current: FakeXhr
upload = {
onprogress: null as ((event: ProgressEvent) => void) | null
}
status = 200
statusText = 'OK'
responseText = '{"ok":true}'
withCredentials = false
onload: (() => void) | null = null
onerror: (() => void) | null = null
constructor() {
FakeXhr.current = this
}
open() {}
send() {}
}
describe('uploadFormDataWithProgress', () => {
afterEach(() => {
vi.unstubAllGlobals()
})
it('reports the uploaded byte percentage', async () => {
vi.stubGlobal('XMLHttpRequest', FakeXhr)
const onProgress = vi.fn()
const result = uploadFormDataWithProgress<{ ok: boolean }>(
'/upload',
new FormData(),
onProgress
)
FakeXhr.current.upload.onprogress!({
lengthComputable: true,
loaded: 40,
total: 100
} as ProgressEvent)
FakeXhr.current.onload!()
await expect(result).resolves.toEqual({ ok: true })
expect(onProgress).toHaveBeenCalledWith(40)
})
it('keeps the HTTP status and server error message', async () => {
vi.stubGlobal('XMLHttpRequest', FakeXhr)
const result = uploadFormDataWithProgress(
'/upload',
new FormData(),
() => {}
)
FakeXhr.current.status = 401
FakeXhr.current.statusText = 'Unauthorized'
FakeXhr.current.responseText = JSON.stringify({
data: { error: { message: '未登录' } }
})
FakeXhr.current.onload!()
await expect(result).rejects.toMatchObject({
statusCode: 401,
message: '未登录'
})
})
})
+60
View File
@@ -0,0 +1,60 @@
interface UploadError extends Error {
statusCode: number
statusMessage: string
data: unknown
}
function uploadErrorMessage(data: unknown, fallback: string): string {
if (!data || typeof data !== 'object') return fallback
const body = data as {
message?: unknown
error?: { message?: unknown }
data?: { error?: { message?: unknown } }
}
const message = body.data?.error?.message ?? body.error?.message ?? body.message
return typeof message === 'string' ? message : fallback
}
export function uploadFormDataWithProgress<T>(
url: string,
formData: FormData,
onProgress: (percent: number) => void
): Promise<T> {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest()
xhr.open('POST', url)
xhr.withCredentials = true
xhr.upload.onprogress = (event) => {
if (event.lengthComputable && event.total > 0) {
onProgress(Math.round((event.loaded / event.total) * 100))
}
}
xhr.onload = () => {
let data: unknown = null
try {
data = xhr.responseText ? JSON.parse(xhr.responseText) : null
} catch {
// 非 JSON 响应交给状态码和状态文本报错
}
if (xhr.status >= 200 && xhr.status < 300) {
resolve(data as T)
return
}
const error = new Error(
uploadErrorMessage(data, xhr.statusText || 'Upload failed')
) as UploadError
error.statusCode = xhr.status
error.statusMessage = xhr.statusText
error.data = data
reject(error)
}
xhr.onerror = () => reject(new Error('Network error'))
xhr.send(formData)
})
}
+178
View File
@@ -0,0 +1,178 @@
const { app, BrowserWindow, dialog, Menu, shell } = require('electron')
const { spawn } = require('node:child_process')
const fs = require('node:fs')
const net = require('node:net')
const path = require('node:path')
let serverProcess = null
let mainWindow = null
let serverUrl = ''
if (!app.requestSingleInstanceLock()) {
app.quit()
process.exit(0)
}
app.setAppUserModelId('com.yeomai.pichost')
app.setName('PicHost')
function runtimeRoot() {
return app.isPackaged
? path.join(process.resourcesPath, 'runtime')
: path.join(__dirname, '..', 'dist-windows', 'staging', 'runtime')
}
function dataDirectory() {
return app.isPackaged
? path.join(path.dirname(process.execPath), 'data')
: path.join(__dirname, '..', 'data')
}
function findFreePort() {
return new Promise((resolve, reject) => {
const server = net.createServer()
server.once('error', reject)
server.listen(0, '127.0.0.1', () => {
const { port } = server.address()
server.close(error => error ? reject(error) : resolve(port))
})
})
}
function waitForHealth(url, process) {
return new Promise((resolve, reject) => {
const startedAt = Date.now()
const timer = setInterval(async () => {
if (process.exitCode !== null) {
clearInterval(timer)
reject(new Error(`PicHost 服务已退出,代码 ${process.exitCode}`))
return
}
try {
const response = await fetch(`${url}/api/health`, {
signal: AbortSignal.timeout(1000)
})
if (response.ok) {
clearInterval(timer)
resolve()
return
}
} catch {
// 忽略轮询中的瞬时错误,继续等待
}
if (Date.now() - startedAt > 30000) {
clearInterval(timer)
reject(new Error('PicHost 服务启动超时'))
}
}, 500)
})
}
function stopServer() {
if (!serverProcess || serverProcess.exitCode !== null) return
spawn('taskkill', ['/pid', String(serverProcess.pid), '/T', '/F'], {
windowsHide: true,
stdio: 'ignore'
})
serverProcess = null
}
async function startServer() {
const root = runtimeRoot()
const appRoot = path.join(root, 'app')
const nodeExe = path.join(root, 'node', 'node.exe')
const ffmpegDir = path.join(root, 'ffmpeg')
const dataDir = dataDirectory()
const port = await findFreePort()
fs.mkdirSync(dataDir, { recursive: true })
const log = fs.createWriteStream(path.join(dataDir, 'desktop-server.log'), {
flags: 'a'
})
serverUrl = `http://127.0.0.1:${port}`
serverProcess = spawn(nodeExe, [path.join(appRoot, '.output', 'server', 'index.mjs')], {
cwd: appRoot,
env: {
...process.env,
DATA_DIR: dataDir,
NODE_ENV: 'production',
NITRO_PORT: String(port),
PORT: String(port),
NITRO_HOST: '127.0.0.1',
HOST: '127.0.0.1',
PATH: `${ffmpegDir};${process.env.PATH || ''}`,
PIC_HOST_DESKTOP: '1'
},
windowsHide: true,
stdio: ['ignore', 'pipe', 'pipe']
})
serverProcess.stdout.pipe(log, { end: false })
serverProcess.stderr.pipe(log, { end: false })
serverProcess.once('close', () => log.end())
await waitForHealth(serverUrl, serverProcess)
}
function createWindow() {
mainWindow = new BrowserWindow({
width: 1440,
height: 920,
minWidth: 980,
minHeight: 680,
show: false,
autoHideMenuBar: true,
backgroundColor: '#111827',
webPreferences: {
nodeIntegration: false,
contextIsolation: true,
sandbox: true
}
})
mainWindow.once('ready-to-show', () => mainWindow.show())
mainWindow.on('closed', () => {
mainWindow = null
app.quit()
})
mainWindow.webContents.setWindowOpenHandler(({ url }) => {
if (url.startsWith(serverUrl)) return { action: 'allow' }
shell.openExternal(url)
return { action: 'deny' }
})
mainWindow.webContents.on('will-navigate', (event, url) => {
if (!url.startsWith(serverUrl)) {
event.preventDefault()
shell.openExternal(url)
}
})
mainWindow.loadURL(serverUrl)
}
app.on('second-instance', () => {
if (!mainWindow) return
if (mainWindow.isMinimized()) mainWindow.restore()
mainWindow.focus()
})
app.on('before-quit', stopServer)
app.on('window-all-closed', () => app.quit())
app.whenReady().then(async () => {
Menu.setApplicationMenu(null)
try {
await startServer()
createWindow()
} catch (error) {
dialog.showErrorBox('PicHost 启动失败', String(error?.message || error))
stopServer()
app.quit()
}
})
+166
View File
@@ -0,0 +1,166 @@
import { createHash } from 'node:crypto'
import { spawnSync } from 'node:child_process'
import {
access,
copyFile,
cp,
mkdir,
readFile,
rm,
writeFile
} from 'node:fs/promises'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import sharp from 'sharp'
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
const outputRoot = path.join(root, 'dist-windows')
const stageRoot = path.join(outputRoot, 'staging')
const runtimeRoot = path.join(stageRoot, 'runtime')
const appRoot = path.join(runtimeRoot, 'app')
const nodeDir = path.join(runtimeRoot, 'node')
const ffmpegDir = path.join(runtimeRoot, 'ffmpeg')
const buildDir = path.join(stageRoot, 'build')
const cacheDir = path.join(outputRoot, 'cache')
const nodeVersion = '22.23.3'
function run(command, args, cwd = root) {
const result = spawnSync(command, args, {
cwd,
encoding: 'utf8',
stdio: 'inherit',
windowsHide: true,
shell: process.platform === 'win32' && /\.(?:cmd|bat)$/i.test(command)
})
if (result.error) throw result.error
if (result.status !== 0) {
throw new Error(`${command} ${args.join(' ')} 失败,退出代码 ${result.status}`)
}
}
async function exists(file) {
try {
await access(file)
return true
} catch {
return false
}
}
async function download(file, url) {
if (await exists(file)) return
await mkdir(path.dirname(file), { recursive: true })
console.log(`下载 ${url}`)
const response = await fetch(url)
if (!response.ok) throw new Error(`下载失败:${response.status} ${url}`)
await writeFile(file, Buffer.from(await response.arrayBuffer()))
}
async function prepareNode() {
const cacheFile = path.join(cacheDir, `node-v${nodeVersion}-win-x64.exe`)
const nodeExe = path.join(nodeDir, 'node.exe')
await download(
cacheFile,
`https://nodejs.org/dist/v${nodeVersion}/win-x64/node.exe`
)
const sums = await (await fetch(
`https://nodejs.org/dist/v${nodeVersion}/SHASUMS256.txt`
)).text()
const expected = sums
.split(/\r?\n/)
.map(line => line.trim().split(/\s+/))
.find(parts => parts[1] === 'win-x64/node.exe')?.[0]
const actual = createHash('sha256')
.update(await readFile(cacheFile))
.digest('hex')
if (!expected || actual !== expected) {
throw new Error('内置 Node.js 校验失败')
}
await mkdir(nodeDir, { recursive: true })
await copyFile(cacheFile, nodeExe)
run(nodeExe, ['--version'])
}
async function findFfmpegDir() {
const candidates = [
process.env.FFMPEG_DIR,
'C:\\ffmpeg-8.0.1-essentials_build\\bin'
].filter(Boolean)
for (const candidate of candidates) {
if (
await exists(path.join(candidate, 'ffmpeg.exe'))
&& await exists(path.join(candidate, 'ffprobe.exe'))
) {
return candidate
}
}
const located = spawnSync('where.exe', ['ffmpeg.exe'], { encoding: 'utf8' })
const first = located.stdout?.trim().split(/\r?\n/)[0]
if (first && await exists(path.join(path.dirname(first), 'ffprobe.exe'))) {
return path.dirname(first)
}
throw new Error('未找到 ffmpeg.exe 和 ffprobe.exe,请设置 FFMPEG_DIR')
}
async function main() {
run('npm.cmd', ['run', 'build'])
await rm(stageRoot, { recursive: true, force: true })
await Promise.all([
mkdir(appRoot, { recursive: true }),
mkdir(buildDir, { recursive: true })
])
await cp(path.join(root, '.output'), path.join(appRoot, '.output'), {
recursive: true,
dereference: true
})
await copyFile(path.join(root, 'package.json'), path.join(appRoot, 'package.json'))
await copyFile(
path.join(root, 'package-lock.json'),
path.join(appRoot, 'package-lock.json')
)
await cp(path.join(root, 'server', 'cli'), path.join(appRoot, 'server', 'cli'), {
recursive: true
})
console.log('安装 Windows 运行时生产依赖')
run(
'npm.cmd',
['ci', '--omit=dev', '--ignore-scripts', '--no-audit', '--no-fund'],
appRoot
)
await prepareNode()
const sourceFfmpeg = await findFfmpegDir()
await mkdir(ffmpegDir, { recursive: true })
await copyFile(
path.join(sourceFfmpeg, 'ffmpeg.exe'),
path.join(ffmpegDir, 'ffmpeg.exe')
)
await copyFile(
path.join(sourceFfmpeg, 'ffprobe.exe'),
path.join(ffmpegDir, 'ffprobe.exe')
)
run(path.join(ffmpegDir, 'ffmpeg.exe'), ['-version'])
await sharp(path.join(root, 'app', 'assets', 'image', 'logo-light.png'))
.resize(512, 512, {
fit: 'contain',
background: { r: 0, g: 0, b: 0, alpha: 0 }
})
.png()
.toFile(path.join(buildDir, 'icon.png'))
console.log(`Windows 运行时已准备到 ${runtimeRoot}`)
}
main().catch((error) => {
console.error(error)
process.exit(1)
})
+6 -6
View File
@@ -12,10 +12,10 @@ All REST calls use:
Auth-Token: YOUR_TOKEN
```
| Token type | How to obtain | Notes |
| ---------- | ------------- | ----- |
| **Global** | Admin **API** page or `API_UPLOAD_TOKEN` env | Env wins and blocks UI regenerate |
| **Personal** | Each user’s **API** page | Uploads belong to that user |
| Token | How to obtain | Notes |
| ----- | ------------- | ----- |
| **API token** | **API** page after sign-in (one per account, auto-created) | `Auth-Token` header; uploads and tagNames belong to that account |
| **Legacy site token** | `API_UPLOAD_TOKEN` env or old admin setting | Still accepted; uploads belong to the **first admin**. Twikoo form `token` also accepts each user’s API token |
Form uploads may use field `token` (Twikoo protocol).
@@ -31,10 +31,10 @@ Upload one or more images via `image`; also accepts `file`, `files`. Stored unde
curl -X POST "https://admin.example.com/api/images/upload" \
-H "Auth-Token: YOUR_TOKEN" \
-F "image=@./demo.png" \
-F 'tagIds=[1,2]'
-F 'tagNames=["blog","screenshot"]'
```
Optional form field `tagIds`: JSON array string or repeated fields. Tags do **not** change storage paths under `images/`.
Optional form field `tagNames`: JSON array or comma-separated names. Tags apply to the user bound to the token (**global token cannot tag**). Tags do **not** change storage paths under `images/`.
### 2. List images
+45
View File
@@ -4,6 +4,51 @@ Version history and notable changes. The full log is also in the repo root [CHAN
---
## [1.5.0] — 2026-10-17
### Added
- Search and batch deletion for the image, video, and file libraries
- Unified upload policy for all media with configurable size, format, and rate limits
- Default storage backend selection for newly uploaded images, videos, and files
- High-risk storage clearing with typed confirmation, scoped to the current user
- Low-memory warning when enabling HLS transcoding
### Improved
- Backup and restore progress now reports media records and storage objects separately
- Video uploads are limited to complete container formats with server-side validation
- Thumbnail FFmpeg processing is single-threaded and protected by a timeout
- Temporary m3u8-to-MP4 files are cleaned after download, on expiry, and during startup
### Fixed
- Stale recent tasks after clearing storage and media logic that only covered images
- Site URL being cleared when separate media domains were disabled
- Recovery fallback for interrupted site synchronization
## [1.4.4] — 2026-10-10
### Fixed
- Cap login verification supports hashwx challenges and still accepts older SHA-256 site keys
## [1.4.3] — 2026-10-09
### Added
- **Telegram storage backend**: store images in a channel or group; public URLs stay on PicHost; optional API proxy
## [1.4.2] — 2026-09-23
### Fixed
- Gallery batch tagging and upload token auth (incl. legacy `API_UPLOAD_TOKEN`)
### Improved
- Single API token per account; upload `tagNames` only; tag management sort/mobile layout; API doc table layout
## [1.4.1] — 2026-09-16
### Added
+21 -4
View File
@@ -1,6 +1,6 @@
# Storage
PicHost supports **local disk** and **S3-compatible object storage** (Cloudflare R2, Tencent COS, Alibaba OSS, AWS S3, etc.). Admins manage backends at **Storage** (`/storage`); new uploads go to the **default** backend. The page shows a **storage overview** (total, used, remaining, usage rate, capacity chart) plus per-backend cards and supported backend types.
PicHost supports **local disk**, **S3-compatible object storage** (Cloudflare R2, Tencent COS, Alibaba OSS, AWS S3, etc.), and a **Telegram channel or group**. Admins manage backends at **Storage** (`/storage`); new uploads go to the **default** backend. The page shows a **storage overview** (total, used, remaining, usage rate, capacity chart) plus per-backend cards and supported backend types.
![Storage management](/screenshots/storage.png)
@@ -23,11 +23,11 @@ All **keys** start with `images/`. The upload API does **not** accept a `folder`
## Adding a backend
1. Sign in as admin → **Storage** → **Add backend**.
2. Choose **local disk** or **S3-compatible** (R2 / COS / OSS presets).
3. Enter connection details (bucket, endpoint, keys).
2. Choose **local disk**, **S3-compatible** (R2 / COS / OSS presets), or **Telegram channel**.
3. Enter connection details (bucket, endpoint, keys, or Bot Token / Chat ID).
4. **Set as default** so new uploads use it.
New object-storage backends get an auto-generated **storage ID** prefix: `local` for disk; `r2-*` for R2; `cos-*` for COS; `oss-*` for OSS; `s3-*` for AWS S3 and custom S3-compatible endpoints (immutable after creation; the image index references this ID).
New backends get an auto-generated **storage ID** prefix: `local` for disk; `r2-*` for R2; `cos-*` for COS; `oss-*` for OSS; `s3-*` for AWS S3 and custom S3-compatible endpoints; `tg-*` for Telegram (immutable after creation; the image index references this ID).
### Cloudflare R2
@@ -40,6 +40,23 @@ New object-storage backends get an auto-generated **storage ID** prefix: `local`
The **cloudflare** branch streamlines R2-only deploys; see branch notes on the [docs overview](./index.md).
### Telegram channel or group
A bot posts each image into a channel or group (private chats work). Visitors still load PicHost URLs; the app never redirects to a Telegram file URL.
1. Create a bot with [@BotFather](https://t.me/BotFather) and copy the token.
2. Make the bot an admin of the channel or group (post messages; also grant delete messages if gallery deletes should remove the Telegram post).
3. On the Storage page choose **Telegram channel**, then enter the Chat ID (supergroups and channels are usually `-100…`), the bot token, and a quota.
4. If the server cannot reach `api.telegram.org` directly, set an optional HTTP/HTTPS API proxy (for example `http://127.0.0.1:7890`).
Limits and behavior:
- Practical per-file cap is about **20MB** (the smaller of this and the site upload limit applies)
- About **20 messages per minute** per chat; faster batches fail with a retry hint
- Serving mode is always **proxy**
- Gallery delete tries to delete the Telegram message. If an upload reaches the chat but PicHost cannot store a file id, that message is removed when possible
- Full backups download files through the bot and are subject to the rate limit. Packages do not include the bot token; enter it again after restore. Restoring onto a default Telegram backend re-uploads images into the chat
### Environment variables (optional)
Some installs seed a default S3 backend via `STORAGE_BACKEND=s3`, `S3_ENDPOINT`, `S3_BUCKET`, `S3_ACCESS_KEY`, `S3_SECRET_KEY`, `S3_REGION`. **UI-configured backends live in SQLite** alongside env-based defaults.
+1 -1
View File
@@ -20,7 +20,7 @@ features:
- title: Drag, click, paste
details: Server-side WebP compression and Referer hotlink protection.
- title: Multi-backend storage
details: Local disk plus S3-compatible backends (R2 / COS / OSS / AWS); proxy or public URLs.
details: Local disk, S3-compatible backends (R2 / COS / OSS / AWS), and Telegram; proxy or public URLs.
- title: Users & RBAC
details: Admin and regular users; global and per-user API tokens; gallery scoped by owner.
- title: Zero-config Docker
+6 -6
View File
@@ -12,10 +12,10 @@ PicHost 提供 REST 接口与 Twikoo 兼容端点。登录后打开顶栏 **API*
Auth-Token: YOUR_TOKEN
```
| Token 类型 | 获取方式 | 说明 |
| ---------- | -------- | ---- |
| **全局 Token** | 管理员 **API** 页生成;或 `API_UPLOAD_TOKEN` 环境变量 | 环境变量优先且锁定后台重新生成 |
| **个人 Token** | 各用户 **API** 页 | 仅用于该用户脚本,上传归属本人 |
| Token | 获取方式 | 说明 |
| ----- | -------- | ---- |
| **API Token** | 登录后 **API** 页(每账号一个,自动生成) | 请求头 `Auth-Token`;上传与 tagNames 归属该账号 |
| **遗留全站 Token** | 环境变量 `API_UPLOAD_TOKEN` 或旧版后台配置 | 仍可用;上传归属**首个管理员**账号。Twikoo 表单 `token` 亦支持各账号 API Token |
表单上传仍可使用字段 `token`(Twikoo 协议)。
@@ -31,10 +31,10 @@ Auth-Token: YOUR_TOKEN
curl -X POST "https://admin.example.com/api/images/upload" \
-H "Auth-Token: YOUR_TOKEN" \
-F "image=@./demo.png" \
-F 'tagIds=[1,2]'
-F 'tagNames=["博客","截图"]'
```
可选表单字段 `tagIds`:JSON 数组字符串(如 `[1,2]`)或重复字段,上传成功后自动打标。存储路径仍为 `images/年/月/id.webp`,**不会**因标签创建子目录。
可选表单字段 `tagNames`:JSON 数组或逗号分隔名称;上传成功后自动打标。存储路径仍为 `images/年/月/id.webp`,**不会**因标签创建子目录。
### 2. 获取图片列表
+44
View File
@@ -4,6 +4,50 @@ PicHost 版本历史与重要变更说明。完整记录亦见仓库根目录 [C
---
## [1.5.0] — 2026-10-17
### 新增
- 图库、视频库和文件库支持搜索与批量删除
- 所有媒体统一上传策略,支持大小、格式和限流配置,并可切换默认存储后端
- 高危清空存储操作增加文字二次确认,仅清理当前用户数据
- 低内存服务器在开启 HLS 转码时显示风险提示
### 改进
- 备份恢复分别展示「媒体记录」和「存储对象」进度
- 视频上传仅允许常见完整容器格式,并增加服务端校验
- 缩略图 FFmpeg 限制为单线程并增加超时保护
- m3u8 临时合并文件支持下载后、到期和启动时自动清理
### 修复
- 修复清空存储后最近任务残留以及媒体功能遗漏视频和文件的问题
- 修复关闭独立媒体域名时网站域名被误清空的问题
- 修复站点同步异常后的兜底恢复流程
## [1.4.4] — 2026-10-10
### 修复
- Cap 登录验证支持 hashwx,并继续兼容旧的 SHA-256 site key
## [1.4.3] — 2026-10-09
### 新增
- **Telegram 存储后端**:频道/群组存图,直链仍经 PicHost;可选 API 代理
## [1.4.2] — 2026-09-23
### 修复
- 图库批量打标与上传 Token 鉴权(含 `API_UPLOAD_TOKEN` 兼容)
### 改进
- API 页单 Token;上传 `tagNames`;标签管理排序与移动端布局;API 文档表格式
## [1.4.1] — 2026-09-16
### 新增
+1
View File
@@ -6,6 +6,7 @@
- Node.js **22**
- npm **11**(与 `package.json` 中 `packageManager` 一致)
- FFmpeg(视频缩略图与 HLS 转码;未安装时视频仍可上传,但使用占位封面)
## 启动
+21 -4
View File
@@ -1,6 +1,6 @@
# 存储
PicHost 支持 **本地磁盘** 与 **S3 兼容对象存储**(Cloudflare R2、腾讯云 COS、阿里云 OSS、AWS S3 等)。管理员在 **存储**(`/storage`)页管理后端;新上传写入 **默认后端**。页面顶部展示 **存储总览**(总量、已用、剩余、使用率与容量分布),下方为各后端卡片与支持的后端类型说明。
PicHost 支持 **本地磁盘**、**S3 兼容对象存储**(Cloudflare R2、腾讯云 COS、阿里云 OSS、AWS S3 等)与 **Telegram 频道/群组**。管理员在 **存储**(`/storage`)页管理后端;新上传写入 **默认后端**。页面顶部展示 **存储总览**(总量、已用、剩余、使用率与容量分布),下方为各后端卡片与支持的后端类型说明。
![存储管理](/screenshots/storage.png)
@@ -23,11 +23,11 @@ PicHost 支持 **本地磁盘** 与 **S3 兼容对象存储**(Cloudflare R2、
## 添加存储后端
1. 以管理员登录,打开 **存储** → **添加存储**。
2. 选择类型:**本地磁盘** 或 **S3 兼容**(含 R2 / COS / OSS 预设)。
3. 填写连接信息(桶名、Endpoint、Access Key 等)。
2. 选择类型:**本地磁盘**、**S3 兼容**(含 R2 / COS / OSS 预设)或 **Telegram 频道**。
3. 填写连接信息(桶名、Endpoint、Access Key,或 Bot Token / Chat ID 等)。
4. 保存后可 **设默认**,新上传将写入该后端。
新建对象存储后端的 **存储标识** 按类型自动生成前缀:本地为 `local`;R2 为 `r2-*`;COS 为 `cos-*`;OSS 为 `oss-*`;AWS S3 及自定义 S3 兼容为 `s3-*`(创建后不可修改,图片索引会引用该 ID)。
新建后端的 **存储标识** 按类型自动生成前缀:本地为 `local`;R2 为 `r2-*`;COS 为 `cos-*`;OSS 为 `oss-*`;AWS S3 及自定义 S3 兼容为 `s3-*`;Telegram 为 `tg-*`(创建后不可修改,图片索引会引用该 ID)。
### Cloudflare R2 要点
@@ -40,6 +40,23 @@ PicHost 支持 **本地磁盘** 与 **S3 兼容对象存储**(Cloudflare R2、
若仓库 **cloudflare** 分支专用于「仅 R2」部署,也可用环境变量预设,见 [文档首页](./index.md) 分支说明。
### Telegram 频道 / 群组
用 Bot 把图片发进指定频道或群组(含私有频道),访客仍通过 PicHost 直链访问,不会跳到 Telegram 文件地址。
1. 在 [@BotFather](https://t.me/BotFather) 创建 Bot,取得 Token。
2. 把 Bot 设为频道或群组管理员(需要发消息;若希望图库删除时同步删掉群消息,还需要删除消息权限)。
3. 在存储页选择 **Telegram 频道**,填写 Chat ID(超级群组/频道多为 `-100…`)、Bot Token,以及套餐配额。
4. 服务器若不能直连 `api.telegram.org`,可填写可选的 HTTP/HTTPS API 代理(如 `http://127.0.0.1:7890`)。
限制与行为:
- 单文件实际上限约 **20MB**(超过站点上传限制时,以更小者为准)
- 同一频道大约 **20 条消息/分钟**;批量上传过快会失败并提示稍后重试
- 直链固定为 **proxy**,不能改为公开 URL
- 图库删除会尝试删除对应的 Telegram 消息;上传失败且未记下文件编号时,会尽量撤回刚发出的消息
- 整站备份会经 Bot 拉取文件,耗时并受上述频率限制;备份包不含 Bot Token,恢复后需重新填写。恢复到 Telegram 默认后端时,会把图片重新发进频道
### 环境变量(可选)
部分部署用环境变量初始化默认 S3 后端(如 `STORAGE_BACKEND=s3`、`S3_ENDPOINT`、`S3_BUCKET`、`S3_ACCESS_KEY`、`S3_SECRET_KEY`、`S3_REGION`)。**UI 中配置的后端信息存于 SQLite**,与环境变量并存时以管理界面为准管理多后端。
+7 -12
View File
@@ -13,8 +13,7 @@ PicHost 使用基于角色的访问控制(RBAC):**管理员(admin)**
| 系统设置(`/settings`) | ✓ | — |
| 开放注册开关 | ✓ | — |
| 登录人机验证方式(滑块 / Turnstile / Cap) | ✓ | — |
| 全局 API Token 管理 | ✓ | — |
| 个人 API Token | ✓ | ✓ |
| API Token(每账号一个) | ✓ | ✓ |
| 图库统计概览(含注册用户数) | ✓ | 部分 |
| 标签管理(设置 → 标签管理) | ✓ | — |
| 操作日志(设置 → 操作日志) | ✓ | — |
@@ -26,9 +25,8 @@ PicHost 使用基于角色的访问控制(RBAC):**管理员(admin)**
| 场景 | 鉴权方式 | 图片归属 / 可见范围 |
| ---- | -------- | ------------------- |
| 网页上传 | Session Cookie | 当前登录用户 |
| API 上传 + **个人 Token** | `Auth-Token` 头 | Token 所属用户 |
| API 上传 + **全局 Token** | `Auth-Token` 头 | 管理员(`userId` 为空) |
| Twikoo `POST /api/index.php` | 表单 `token` | 与全局 Token 相同 |
| API 上传 | `Auth-Token` 或表单 `token` | Token 对应账号(遗留 `API_UPLOAD_TOKEN` 归属首个管理员) |
| Twikoo `POST /api/index.php` | 表单 `token` | 同上 |
| 图库列表 / 搜索 / 删除 | Session 或 Token | 普通用户仅自己;管理员全部 |
| 图片直链 `GET /images/...` | 无(Referer 防盗链) | 知道 URL 即可访问 |
@@ -59,14 +57,11 @@ PicHost 使用基于角色的访问控制(RBAC):**管理员(admin)**
- **上传偏好**:管理员在 **设置 → 基础设置** 配置客户端预压缩、自动复制链接等;普通用户可在首页上传卡片背面调整个人偏好
- **自动删除**:管理员可设全局策略;用户可设个人策略。启用后仅影响 **之后新上传** 的图片
## API Token 说明
## API Token
| 类型 | 谁管理 | 上传归属 | 典型用途 |
| ---- | ------ | -------- | -------- |
| **全局 Token** | 管理员(API 页) | 管理员账号 | Twikoo、站点级脚本 |
| **个人 Token** | 各用户(API 页) | 该用户 | 个人博客、私有脚本 |
若设置了环境变量 `API_UPLOAD_TOKEN`,则全局 Token 被锁定,见 [环境变量](./configuration.md)。
- **每个账号一个 Token**(含管理员),在 **API** 页查看或重新生成;首次打开会自动创建
- 上传、`tagNames` 打标、图库接口均归属 **该 Token 所属账号**
- 环境变量 **`API_UPLOAD_TOKEN`**(旧部署)仍可作为鉴权密钥,上传归属 **首个管理员**,见 [环境变量](./configuration.md)
## 相关
+1 -1
View File
@@ -20,7 +20,7 @@ features:
- title: 拖拽 / 粘贴上传
details: 支持点击、拖拽与 Ctrl+V 粘贴;服务端 WebP 压缩与 Referer 防盗链。
- title: 多后端存储
details: 本地磁盘 + S3 兼容(R2 / COS / OSS / AWS);proxy 或 public 直链模式。
details: 本地磁盘 + S3 兼容(R2 / COS / OSS / AWS)+ Telegram 频道/群组;proxy 或 public 直链。
- title: 多用户与权限
details: 管理员与普通用户 RBAC;全局 Token 与个人 Token;图库按归属隔离。
- title: Docker 零配置
+38
View File
@@ -0,0 +1,38 @@
appId: com.yeomai.pichost
productName: PicHost
asar: true
npmRebuild: false
directories:
output: dist-windows/release
buildResources: dist-windows/staging/build
files:
- desktop/**/*
- package.json
- "!node_modules/**/*"
extraResources:
- from: dist-windows/staging/runtime
to: runtime
win:
target:
- target: nsis
arch:
- x64
icon: icon.png
executableName: PicHost
artifactName: PicHost-Setup-${version}-${arch}.${ext}
nsis:
oneClick: false
perMachine: false
allowElevation: true
include: packaging/windows/installer.nsh
allowToChangeInstallationDirectory: true
createDesktopShortcut: true
createStartMenuShortcut: true
shortcutName: PicHost
uninstallDisplayName: PicHost
runAfterFinish: true
+416 -45
View File
@@ -2,12 +2,13 @@
"app": {
"title": "PicHost - Image Hosting",
"description": "A lightweight self-hosted image hosting system",
"tagline": "Personal lightweight image host"
"tagline": "Personal lightweight storage"
},
"common": {
"loadingSession": "Verifying session…",
"save": "Save",
"cancel": "Cancel",
"continue": "Continue",
"copy": "Copy",
"delete": "Delete",
"edit": "Edit",
@@ -24,10 +25,13 @@
"nav": {
"api": "API",
"gallery": "Gallery",
"videoLibrary": "Video library",
"fileLibrary": "File library",
"stats": "Stats",
"logs": "Activity log",
"settings": "Settings",
"storage": "Storage",
"users": "Users",
"menu": "Menu",
"language": "Language",
"appearance": "Appearance",
@@ -85,8 +89,8 @@
}
},
"domainSeparationConfirm": {
"title": "Save dual-domain settings?",
"description": "After saving, only the site URL can serve the admin UI and API, and only the image URL can serve public image links. Access via IP, an unlisted Host, or a reverse proxy that does not forward Host correctly may lock you out of the admin UI.\n\nIf locked out, run docker exec pichost clear-domains (local dev: npm run clear-domains), or disable domain separation in Settings when you can access it again.\n\nSave anyway?",
"title": "Save separated-domain settings?",
"description": "After saving, the admin URL serves the admin UI and API, while the image, video and file URLs serve only their corresponding media links. Access via IP, an unlisted Host, or a reverse proxy that does not forward Host correctly may lock you out of the admin UI.\n\nIf locked out, run docker exec pichost clear-domains (local dev: npm run clear-domains), or disable domain separation in Settings when you can access it again.\n\nSave anyway?",
"confirm": "Save anyway"
},
"setup": {
@@ -96,9 +100,9 @@
"passwordPlaceholder": "Set login password",
"confirmPlaceholder": "Enter password again",
"allowRegistration": "Allow user registration",
"domainSeparation": "Separate admin and image domains",
"domainSeparationHint": "Admin and API use the site URL; image links use a dedicated image URL",
"domainSeparationProxyHint": "Point both domains to this host and reverse-proxy to port 6892 (Nginx, Caddy, NPM, etc.)",
"domainSeparation": "Separate admin, image, video and file domains",
"domainSeparationHint": "Allow admin, images, videos and files to each use a dedicated domain.",
"domainSeparationProxyHint": "Point the configured domains to this host and reverse-proxy to port 6892 (Nginx, Caddy, NPM, etc.)",
"domainSeparationProxyExample": "View example",
"siteBaseUrl": "Site URL",
"siteBaseUrlHint": "No trailing slash; admin UI and API upload endpoint",
@@ -106,7 +110,13 @@
"imageBaseUrl": "Image URL",
"imageBaseUrlHint": "No trailing slash; public image link prefix",
"imageBaseUrlPlaceholder": "https://pic.example.com",
"domainSeparationInvalid": "Enter valid site and image URLs with different hostnames",
"videoBaseUrl": "Video URL",
"videoBaseUrlHint": "No trailing slash; falls back to the image URL when empty",
"videoBaseUrlPlaceholder": "https://video.example.com",
"fileBaseUrl": "File URL",
"fileBaseUrlHint": "No trailing slash; falls back to the image URL when empty",
"fileBaseUrlPlaceholder": "https://file.example.com",
"domainSeparationInvalid": "Enter valid admin, image, video and file URLs",
"migrateSubmit": "Complete migration",
"initSubmit": "Create and continue",
"migrateSuccess": "Migration complete",
@@ -149,7 +159,7 @@
"upload": {
"title": "Drop images here to upload",
"hint": "Or click to select files, press {ctrl} + {v} to paste",
"formats": "JPEG / PNG / WebP / GIF / SVG / ICO, max 10 MB each, up to 10 files per batch",
"formats": "JPEG / PNG / WebP / GIF / SVG / ICO, max {size} MB each, up to 10 files per batch",
"selectFiles": "Select images",
"settings": "Upload settings",
"progress": "Upload progress",
@@ -196,6 +206,7 @@
"colName": "Tag name",
"colImageCount": "Images",
"colLastUsed": "Last used",
"colCreatedAt": "Created",
"neverUsed": "Never used",
"colActions": "Actions",
"mergeIntoAction": "Merge into…",
@@ -297,15 +308,39 @@
"qualityMedium": "Medium quality",
"qualityLow": "Lossy",
"convenience": "Convenience",
"videoProcessing": "Video processing",
"lowMemoryTranscodeTitle": "Low-memory server warning",
"lowMemoryTranscodeHint": "This machine has about {memory} GB of memory, below 2 GB. HLS transcoding is not recommended for now because it may exhaust memory or make the service unstable.",
"transcodeToHls": "Transcode to m3u8",
"transcodeToHlsHint": "Segment into HLS, uses extra CPU.",
"concurrentHint": "at a time",
"threadsHint": "per job",
"transcodeConcurrency": "Concurrent transcodes",
"transcodeThreads": "CPU threads",
"unitCount": "",
"transcodeQuality": "Quality",
"presetCopy": "Remux",
"presetSpeed": "Speed",
"presetBalanced": "Balanced",
"presetQuality": "Quality",
"presetCompress": "Compress",
"compressBitrate": "Target bitrate",
"transcodeLoadFailed": "Failed to load transcode settings",
"transcodeSaveFailed": "Failed to save transcode settings",
"autoCopy": "Copy link after upload",
"autoCopyHint": "Copy to clipboard in the selected format"
"autoCopyHint": "Copy to clipboard in the selected format",
"uploadTags": "Upload tags",
"uploadTagsHint": "Press Enter, type a comma, or leave the field to add a tag. Leave empty to upload without tags.",
"uploadTagsPlaceholder": "Enter tag",
"uploadTagsPending": "Pending tags ({n})",
"uploadTagsRemove": "Remove tag {name}"
},
"settings": {
"title": "System settings",
"pageTitle": "Settings & preferences",
"pageSubtitle": "Manage system configuration and activity logs",
"pageSubtitleBasic": "Configure basic options so PicHost works better for you.",
"pageSubtitleDomains": "Configure site and image URLs and upload path rules",
"pageSubtitleDomains": "Configure admin, image, video and file domains plus path rules",
"pageSubtitleAccess": "Configure image access policies to prevent hotlink abuse",
"pageSubtitleLogs": "View uploads, deletions, edits, and configuration changes",
"searchPlaceholder": "Search settings…",
@@ -316,11 +351,20 @@
"defaultCopyFormatHint": "Link format copied automatically after upload",
"systemVersionHint": "Currently running PicHost version",
"badgeEnabled": "Enabled",
"domainSeparationProxyTitle": "Both domains must resolve to this host",
"domainSeparationProxyBody": "Point the site and image URLs to this host and reverse-proxy to port 6892 (Nginx, Caddy, NPM, etc.).",
"siteBaseUrlRoleHint": "URL for the admin UI",
"imageBaseUrlRoleHint": "URL for serving and displaying images",
"imageDomainSection": "Image domain",
"domainSeparationProxyTitle": "Configured domains must resolve to this host",
"domainSeparationProxyBody": "Point the admin and configured image, video and file domains to this host and reverse-proxy to port 6892 (Nginx, Caddy, NPM, etc.).",
"siteBaseUrlRoleHint": "Admin and API use this domain; resources use it when their switches are off",
"imageBaseUrlRoleHint": "Use this domain for images when on; use the site domain when off",
"resourceDomainSection": "Resource domains",
"videoBaseUrl": "Video URL",
"videoBaseUrlRoleHint": "Use this domain for videos, HLS and thumbnails when on; use the site domain when off",
"videoBaseUrlPlaceholder": "https://video.example.com",
"videoBaseUrlActive": "Active: {url}",
"fileBaseUrl": "File URL",
"fileBaseUrlRoleHint": "Use this domain for file links and downloads when on; use the site domain when off",
"fileBaseUrlPlaceholder": "https://file.example.com",
"fileBaseUrlActive": "Active: {url}",
"fallbackImageDomain": "Uses image URL",
"navBasic": "Basic",
"navDomains": "Domains & paths",
"navAccess": "Access control",
@@ -355,6 +399,12 @@
"refererUnrestricted": "Unrestricted",
"loginVerification": "Login verification",
"loginVerificationHint": "Human verification for login and registration (choose one)",
"fileBed": "File bed",
"fileBedHint": "Access mode for file download links. Takes effect immediately.",
"fileBedPublic": "Public",
"fileBedPublicHint": "Anyone with the link can download. Best for sharing.",
"fileBedToken": "Token required",
"fileBedTokenHint": "Downloads must carry a token (append ?token= to the link). Best for private files.",
"loginVerificationSlider": "Local slider",
"loginVerificationSliderHint": "Built-in slider puzzle; no third-party service",
"loginVerificationTurnstile": "Cloudflare Turnstile",
@@ -375,7 +425,7 @@
"defaultCopyFormat": "Default copy format",
"refererPlaceholder": "example.com, www.example.com",
"domainSettings": "Domain settings",
"domainSeparationActive": "Domain separation enabled: image host serves images only; site host serves admin and API",
"domainSeparationActive": "Domain separation enabled: admin, images, videos and files can use dedicated domains",
"siteBaseUrl": "Site URL",
"siteBaseUrlHint": "No trailing slash; admin UI and API upload endpoint",
"siteBaseUrlPlaceholder": "https://admin.example.com",
@@ -385,13 +435,17 @@
"imageBaseUrlSingleHint": "No trailing slash; leave empty to use the current request host; single-domain setups do not need a site URL",
"imageBaseUrlPlaceholder": "https://pic.example.com",
"imageBaseUrlActive": "Active: {url}",
"resourceBaseUrl": "Domain",
"resourceBaseUrlSingleHint": "No trailing slash; leave empty to use the current request host. When domain separation is off, this domain serves images, videos and files.",
"resourceBaseUrlPlaceholder": "https://cdn.example.com",
"resourceBaseUrlActive": "Current resource domain: {url}",
"runtimeDetected": "Detected current access: {url} (will not be saved automatically)",
"fillDetectedOrigin": "Use detected URL",
"hostRoleUnknown": "Current Host does not match configured domains; reverse-proxy bypass may be possible",
"disableDomainSeparationTitle": "Disable domain separation",
"disableDomainSeparationDesc": "This clears the site URL and keeps only the image URL. Continue?",
"disableDomainSeparationDesc": "This clears the admin URL and keeps the image, video and file URLs. Continue?",
"disableDomainSeparationConfirm": "Disable",
"domainSeparationRequired": "Both site and image URLs are required when domain separation is enabled",
"domainSeparationRequired": "Admin and image URLs are required when domain separation is enabled",
"hideFolderInUrl": "Use short image links",
"hideFolderInUrlHint": "When on, links are only domain + filename, e.g. img.example.com/abc.webp. When off, date or custom folders are kept, e.g. img.example.com/2026/08/abc.webp",
"storageUseDatePath": "Group uploads by year/month",
@@ -418,14 +472,27 @@
"uploadLimits": "Upload limits",
"uploadLimitsHint": "Site-wide upload policy and your personal convenience options. Applies after save.",
"imageProcessingHint": "Site-wide format conversion and personal compression/cleanup.",
"videoProcessingHint": "Site-wide transcode resources and personal video upload preferences, shared with the video upload page.",
"serverPolicy": "Site-wide",
"personalPreference": "Personal",
"uploadRateGroup": "Upload rate limits",
"minutesUnit": "min",
"uploadMaxFileSize": "Max file size",
"uploadMaxFileSizeHint": "Uploads larger than this are rejected (1–100 MB).",
"uploadImageMaxFileSize": "Image size limit",
"uploadImageMaxFileSizeHint": "Images larger than this are rejected, up to 100 MB. 0 MB blocks uploads.",
"uploadVideoMaxFileSize": "Video size limit",
"uploadVideoMaxFileSizeHint": "Videos larger than this are rejected, up to 2048 MB. 0 MB blocks uploads.",
"uploadFileMaxFileSize": "File size limit",
"uploadFileMaxFileSizeHint": "Regular files larger than this are rejected, up to 1024 MB. 0 MB blocks uploads.",
"allowedMimeTypes": "Allowed image formats",
"allowedMimeTypesHint": "Keep at least one format enabled. The server validates file signatures, not just extensions.",
"allowedVideoExtensions": "Allowed video formats",
"allowedVideoExtensionsHint": "Keep at least one video container enabled. Both the upload page and server enforce this list.",
"forbiddenFileExtensions": "Forbidden file formats",
"forbiddenFileExtensionsHint": "These extensions are rejected by the file upload page and server. You can add custom extensions.",
"forbiddenFileExtensionsCustomPlaceholder": "Add extensions, e.g. exe, php",
"forbiddenFileExtensionsAdd": "Add",
"preserveOriginalUpload": "Preserve originals (disable WebP conversion)",
"preserveOriginalUploadHint": "When enabled, JPEG/PNG/GIF uploads are stored as-is instead of converting to WebP.",
"rateLimits": "Rate limits",
@@ -454,6 +521,18 @@
"providerOss": "Alibaba OSS",
"providerAws": "AWS S3",
"providerCustom": "Custom",
"providerTelegram": "Telegram channel",
"backendTelegram": "Telegram",
"telegramChatId": "Chat ID",
"telegramChatIdPlaceholder": "-1001234567890",
"telegramChatIdHint": "Channel or group ID; the bot must be an admin. Private channels work.",
"telegramBotToken": "Bot Token",
"telegramApiProxy": "API proxy (optional)",
"telegramApiProxyPlaceholder": "http://127.0.0.1:7890",
"telegramApiProxyHint": "HTTP/HTTPS proxy for PicHost → Telegram API; leave empty for direct access.",
"telegramServingHint": "Image URLs are always served through PicHost, never redirected to Telegram.",
"telegramQuotaHint": "Telegram practical limit is ~20MB per file and ~20 messages/min per channel.",
"telegramSecretsRequired": "Bot Token and Chat ID are required",
"endpoint": "Endpoint",
"region": "Region",
"bucket": "Bucket",
@@ -508,14 +587,33 @@
"storageId": "Storage ID",
"storageType": "Storage type",
"badgeLocal": "Local",
"defaultStorageTip": "Default storage: new uploads are saved to “{name}”. Use “Set as default” on another backend to change this.",
"defaultStorageTip": "All new images, videos, and files are saved to “{name}” by default. Use “Set as default” on another backend to change this.",
"clearStorage": "Clear storage",
"exportTitle": "Export database tables",
"exportDescription": "Export every table as CSV and download them as a tar.gz archive. Passwords and tokens are masked.",
"exportCsv": "Export CSV archive",
"exportCsvStarted": "Export finished, downloading",
"exportCsvFailed": "Export failed",
"clearStorageTitle": "Clear my storage",
"clearStorageDescription": "Permanently delete every image, video, file, and cache uploaded by the current account without affecting other users.",
"clearStorageWarning": "High-risk operation: this will clear all files and caches in storage and cannot be undone!",
"clearStorageScope": "Only data and files owned by the current signed-in user are removed. Other users, account settings, storage configuration, and site backups are not affected.",
"clearStorageFinalTitle": "Final confirmation",
"clearStorageInstruction": "Type “I confirm” to continue. The operation cannot be cancelled or undone once started.",
"clearStoragePlaceholder": "I confirm",
"clearStoragePhrase": "I confirm",
"clearStorageConfirm": "Permanently clear",
"clearStorageSuccess": "Cleared {files} files and {cache} temporary caches; storage space has been released",
"clearStoragePartial": "{failed}/{total} files failed to clear; please retry",
"clearStorageFailed": "Failed to clear storage; please try again",
"supportedTitle": "Supported backends",
"supportedSubtitle": "PicHost supports multiple storage backends for different scenarios",
"supportedLocalDesc": "Ideal for small deployments and personal use",
"supportedR2Desc": "Highly available, low-cost object storage",
"supportedS3Title": "S3 compatible",
"supportedS3Desc": "Storage services compatible with the Amazon S3 API",
"footerTip": "The default backend receives all new uploads",
"supportedTelegramDesc": "Store images in a Telegram channel or group; good for light unlimited storage",
"footerTip": "The default backend receives all new images, videos, and files",
"statTotal": "Total",
"statUsed": "Used",
"statFree": "Free",
@@ -546,28 +644,28 @@
"manageStorageHint": "Configure R2 / S3 / COS backends and URL serving",
"backup": {
"sectionTitle": "Backup & migration",
"sectionSubtitle": "Full-site backup, restore, and cross-backend image migration",
"sectionSubtitle": "Full-site backup, restore, and cross-backend file migration",
"siteTitle": "Site backup & restore",
"siteSubtitle": "For migration, disaster recovery, and full-site restore",
"siteFooterHint": "Backups include the database and images, not plain-text secret_json.",
"siteFooterHint": "Backups include the database and all files, not plain-text secret_json.",
"exportTitle": "Create site backup",
"exportSubtitle": "Package the database and all images as .phost.tar.gz",
"exportSubtitle": "Package the database and all files as .phost.tar.gz",
"exportAction": "Start backup",
"exportStarted": "Backup job started",
"exportFailed": "Failed to start backup",
"exportPathHint": "Exports are saved under data/backups/",
"exportIncludesTitle": "Package includes",
"exportIncludesDb": "Database snapshot (pichost.db)",
"exportIncludesImages": "All image files ({count})",
"exportIncludesImages": "All images, videos, and files ({count})",
"exportExcludesSecrets": "Excludes plain-text secret.json for cloud storage",
"exportConcurrencyHint": "Only one backup or migration job can run at a time",
"estimateLoading": "Estimating size…",
"estimateImageCountLabel": "Images",
"estimateImageCountLabel": "Files",
"estimatePackageSizeLabel": "Est. package",
"estimateDatabaseSnapshotLabel": "Database snapshot",
"estimateStoragePathLabel": "Save location",
"estimateStoragePathValue": "data/backups/",
"estimateImages": "{count} images · {size}",
"estimateImages": "{count} files · {size}",
"estimateDatabase": "Database ~{size}",
"estimateTotal": "Total ~{size}",
"restoreTitle": "Restore from backup",
@@ -584,30 +682,68 @@
"restoreConfirm": "Confirm restore",
"restoreConfirmTitle": "Confirm site restore",
"restoreConfirmSource": "Source: {name}",
"restoreOverwriteWarning": "Overwrite mode replaces conflicting images and the database. Ensure you have a current backup.",
"restoreOverwriteWarning": "Overwrite mode replaces conflicting files and the database. Ensure you have a current backup.",
"restoreSelectRequired": "Select or upload a backup file",
"restoreStarted": "Restore job started",
"restoreFailed": "Restore failed",
"conflictMode": "Conflict policy",
"conflictSkip": "Skip existing images",
"conflictOverwrite": "Overwrite existing images and database",
"conflictSkip": "Skip existing files",
"conflictOverwrite": "Overwrite existing files and database",
"syncTitle": "Cross-backend sync",
"siteSyncTitle": "Site-to-site sync",
"siteSyncSubtitle": "Sync media indexes, images, videos, files, thumbnails, HLS segments and tags to another PicHost. Matching usernames must already exist on the target.",
"siteSyncPushSubtitle": "Push media indexes, images, videos, files, thumbnails, HLS segments and tags to another PicHost. Matching usernames must already exist on the target.",
"siteSyncPullSubtitle": "Pull media indexes, images, videos, files, thumbnails, HLS segments and tags from another PicHost. Matching usernames must already exist on this site.",
"siteSyncTarget": "Target site URL",
"siteSyncSource": "Source site URL",
"siteSyncToken": "Target admin API token",
"siteSyncSourceToken": "Source admin API token",
"siteSyncTokenPlaceholder": "Enter target admin token",
"siteSyncSourceTokenPlaceholder": "Enter source admin token",
"siteSyncStart": "Start sync",
"siteSyncStartPush": "Start push",
"siteSyncStartPull": "Start pull",
"siteSyncDirection": "Sync direction:",
"siteSyncPush": "Push to target",
"siteSyncPull": "Pull from target",
"siteSyncConflict": "Conflict policy:",
"siteSyncContent": "Content:",
"siteSyncScopeAll": "Everything",
"siteSyncScopeImage": "Images only",
"siteSyncScopeVideo": "Videos only",
"siteSyncScopeFile": "Files only",
"syncSelectedTitle": "Sync selected",
"syncSelectedSubtitle": "Push the {n} selected item(s) to another PicHost",
"syncSelectedStart": "Start sync",
"syncSelectedAction": "Sync to site",
"siteSyncSkip": "Skip existing",
"siteSyncOverwrite": "Overwrite existing",
"siteSyncResume": "Resume last sync",
"siteSyncAbandon": "Abandon and clean up",
"siteSyncAbandonConfirm": "Abandon this sync and delete partial objects created by it that are not yet referenced by a media record?",
"siteSyncAbandonWithoutTokenConfirm": "No target token is provided. Abandon the task now? Partial objects will be marked for later automatic cleanup on the target.",
"siteSyncAbandoned": "Sync abandoned and partial objects cleaned up",
"siteSyncAbandonedPendingCleanup": "Sync abandoned; partial objects still need cleanup",
"siteSyncScanProgress": "Media scan progress",
"siteSyncFileProgress": "Synced file count",
"siteSyncStarted": "Site-to-site sync started",
"siteSyncFailed": "Failed to start site-to-site sync",
"syncMigrateTitle": "Cross-backend migration",
"syncMigrateSubtitle": "Migrate images between configured backends",
"syncSubtitle": "Copy images between configured backends and update the index",
"syncMigrateSubtitle": "Migrate files between configured backends",
"syncSubtitle": "Copy files between configured backends and update the index",
"syncFrom": "Source backend",
"syncTo": "Target backend",
"syncPendingImages": "Images to migrate",
"syncPendingImages": "Files to migrate",
"syncPendingSize": "Est. size",
"syncIncludesTitle": "What sync does",
"syncIncludesCopy": "Copy image files from source to target backend",
"syncIncludesCopy": "Copy images, videos, and files from source to target backend",
"syncIncludesIndex": "Update storage_backend_id in the index",
"syncIncludesConfig": "Site settings and other backends are unchanged",
"syncDryRun": "Dry run only",
"syncSetDefaultAfter": "Set target as default after migration",
"syncStartMigrate": "Start migration",
"syncPreviewOnly": "Preview",
"syncFooterHint": "Only image files and index are migrated; site settings are unchanged.",
"syncFooterHint": "Only files and index are migrated; site settings are unchanged.",
"syncDeleteSource": "Delete source files after sync",
"syncSetDefault": "Set target as default after sync",
"syncPreview": "Preview & start",
@@ -619,8 +755,8 @@
"syncStarted": "Sync job started",
"syncFailed": "Sync failed",
"previewFailed": "Preview failed",
"previewImages": "Images",
"previewNew": "New images",
"previewImages": "Files",
"previewNew": "New files",
"previewConflicts": "Conflicts",
"previewCreated": "Backup created",
"previewSize": "Data size",
@@ -628,17 +764,32 @@
"jobsSubtitle": "Track backup, restore, and migration progress",
"jobsEmpty": "No backup or migration jobs yet",
"jobsEmptyHint": "Progress appears here after export, restore, or sync starts",
"jobsHideAll": "Hide all",
"jobsRestoreAll": "Restore all",
"hiddenJobsShow": "Show hidden",
"hiddenJobsHide": "Show visible only",
"jobHide": "Hide from list (keeps the record)",
"jobUnhide": "Show again",
"jobHidden": "Job hidden; its record is still retained",
"jobUnhidden": "Job is visible again",
"jobsHiddenAll": "All finished jobs hidden; records are still retained",
"jobsUnhiddenAll": "All hidden jobs are visible again",
"hideFailed": "Failed to update job visibility",
"jobsUnit": "jobs",
"colTask": "Task",
"colType": "Type",
"colStatus": "Status",
"colProgress": "Progress",
"progressUnitMedia": "media records",
"progressUnitObjects": "storage objects",
"colMessage": "Message",
"colCreated": "Created",
"colUpdated": "Updated",
"colActions": "Actions",
"jobTypeExport": "Site export",
"jobTypeRestore": "Site restore",
"jobTypeSync": "Cross-backend sync",
"jobTypeSiteSync": "Site-to-site sync",
"statusPending": "Pending",
"statusRunning": "Running",
"statusDone": "Done",
@@ -652,8 +803,8 @@
"jobRunning": "Another job is already running. Try again later.",
"migrateIn": "Migrate in",
"migrateOut": "Migrate out",
"migrateInHint": "Copy images from the default backend to this one",
"migrateOutHint": "Move images out of this backend",
"migrateInHint": "Copy files from the default backend to this one",
"migrateOutHint": "Move files out of this backend",
"syncDraftApplied": "Sync form prefilled — review and start when ready",
"syncNeedTwoBackends": "At least two storage backends are required to sync",
"selectBackend": "Select backend",
@@ -802,6 +953,8 @@
"storageBackend": "Storage: {name}"
},
"delete": {
"confirmDescVideo": "Delete the selected {count} video(s)? This cannot be undone.",
"confirmDescFile": "Delete the selected {count} file(s)? This cannot be undone.",
"confirmTitle": "Confirm delete",
"confirmDesc": "Delete {count} selected image(s)? This cannot be undone.",
"confirm": "Delete"
@@ -824,13 +977,13 @@
"pagePlaceholder": "Page"
},
"api": {
"tokenTitle": "API Token",
"tokenTitle": "API token",
"envOverrideTitle": "Token overridden by environment",
"envOverrideDesc": "Restart after changing API_UPLOAD_TOKEN; cannot regenerate in UI.",
"personalTitle": "Personal API token",
"personalDesc": "For your scripts only; uploads belong to your account.",
"personalDesc": "For your scripts only; uploaded images, videos and files belong to your account.",
"notConfiguredTitle": "No token configured",
"notConfiguredDesc": "Click Regenerate to create a token for Auth-Token on all endpoints below.",
"notConfiguredDesc": "A token is created automatically on first visit, or click Regenerate.",
"regenerate": "Regenerate",
"tokenSourceEnv": "Source: API_UPLOAD_TOKEN environment variable",
"tokenSourceDb": "Source: generated in app (can regenerate)",
@@ -870,6 +1023,8 @@
"responseExample": "Response example",
"categories": {
"image": "Images",
"video": "Videos",
"file": "Files",
"extension": "Plugins",
"system": "System"
},
@@ -879,8 +1034,7 @@
"file": "Compatibility field, single file",
"files": "Compatibility field, multiple files",
"token": "Twikoo protocol form token",
"tagIds": "Optional JSON array or repeated fields; tag images by ID after upload",
"tagNames": "Optional JSON array or comma-separated names; creates missing tags, reuses existing"
"tagNames": "Optional JSON array or comma-separated names; creates or reuses tags"
},
"list": {
"limit": "Page size, default 20, max 100",
@@ -895,6 +1049,47 @@
"batchDelete": {
"keys": "Array of image keys to delete"
},
"videoUpload": {
"video": "Primary video field; max 500 MB per file",
"file": "Compatibility field, can replace video",
"files": "Compatibility field for multiple videos",
"transcode": "Set to 1 to transcode to HLS asynchronously; otherwise keep the source and generate a thumbnail",
"transcodePreset": "Optional preset: copy, veryfast, medium, slow, or compress (target bitrate)",
"transcodeBitratePercent": "Used by compress only; target bitrate as 10–100% of the source. Uses the current server setting when omitted",
"token": "Optional; the API token can also be sent with the Auth-Token header"
},
"fileUpload": {
"file": "Primary file field; max 100 MB per file. HTML / HTM / SVG are blocked",
"files": "Compatibility field for multiple files",
"token": "Optional; the API token can also be sent with the Auth-Token header"
},
"mediaList": {
"kindVideo": "Set to video to list videos",
"kindFile": "Set to file to list files",
"page": "Page number, starts at 1",
"limit": "Page size, default 20, max 100",
"q": "Search by filename or path",
"processing": "Video only; set to 1 to return queued or running transcodes",
"uploadSource": "Upload source: web or api",
"tagIds": "Filter by comma-separated tag IDs",
"tagMode": "Use or or and for multiple tags; default is or",
"untagged": "Set to 1 to return only untagged items"
},
"mediaSearch": {
"q": "Search keyword matched against filename or storage path; required"
},
"videoDelete": {
"key": "Video storage path; also removes its thumbnail and HLS artifacts"
},
"videoBatchDelete": {
"keys": "Array of video keys; up to 100 per request. Thumbnails and HLS artifacts are also removed"
},
"fileDelete": {
"key": "File storage path, e.g. files/2026/10/xxx.pdf"
},
"fileBatchDelete": {
"keys": "Array of file keys; up to 100 per request"
},
"twikoo": {
"image": "Image file",
"token": "API token"
@@ -915,9 +1110,9 @@
"sendFailed": "Request failed",
"networkError": "Network error or unreachable",
"notesTitle": "Notes",
"noteSuccess": "200 means success with JSON body",
"noteSuccess": "200 means success with a JSON body",
"noteError": "4xx / 5xx means failure; check error field",
"noteLimit": "Upload limit: up to 10 files, 10 MB each"
"noteLimit": "Upload limit: up to 10 files; videos up to 500 MB each, files up to 100 MB each"
},
"docs": {
"upload": {
@@ -940,6 +1135,46 @@
"title": "Batch delete",
"desc": "Delete multiple images; request body is a keys array."
},
"videoUpload": {
"title": "Upload videos",
"desc": "Upload MP4 / WebM / MOV / MKV and more, up to 10 files and 500 MB each. A thumbnail is generated independently; transcode=1 starts asynchronous HLS transcoding."
},
"videoList": {
"title": "List videos",
"desc": "Use kind=video to list videos, including untranscoded, completed and failed items. processing=1 returns only queued or running jobs."
},
"videoDelete": {
"title": "Delete video",
"desc": "Delete a video by key, including its thumbnail and generated HLS playlist and segments."
},
"videoSearch": {
"title": "Search videos",
"desc": "Search by filename or storage path with pagination, backend, upload source and tag filters; q is required."
},
"videoBatchDelete": {
"title": "Batch delete videos",
"desc": "Delete up to 100 videos at once, including each video's thumbnail, HLS playlist and segments."
},
"fileUpload": {
"title": "Upload files",
"desc": "Upload any file, up to 10 files and 100 MB each. HTML, HTM and SVG are blocked."
},
"fileList": {
"title": "List files",
"desc": "Use kind=file to paginate file-bed entries with search, storage, source and tag filters."
},
"fileDelete": {
"title": "Delete file",
"desc": "Delete one file by its storage key. This action cannot be undone."
},
"fileSearch": {
"title": "Search files",
"desc": "Search by filename or storage path with kind=file, pagination, backend, upload source and tag filters."
},
"fileBatchDelete": {
"title": "Batch delete files",
"desc": "Delete up to 100 files at once. The response reports deleted and failed keys separately."
},
"twikoo": {
"title": "Twikoo-compatible upload",
"desc": "Twikoo / EasyImage 2.0 protocol; stored under images/. Fields: image + token."
@@ -954,8 +1189,144 @@
}
}
},
"files": {
"maxFiles": "Up to {n} files per upload",
"uploadSuccess": "Uploaded {n} file(s)",
"uploadFailed": "Upload failed, please try again",
"fileTooLarge": "{name}: exceeds the {limit} MB per-file limit",
"loadFailed": "Failed to load files",
"copyLink": "Copy link",
"download": "Download",
"deleted": "Deleted",
"deleteFailed": "Delete failed",
"videoFormatBlocked": "{format} videos are not allowed",
"fileFormatBlocked": "{format} files are not allowed"
},
"host": {
"image": "Images",
"video": "Video",
"file": "File",
"imageFull": "Personal lightweight image host",
"videoFull": "Personal lightweight video host",
"fileFull": "Personal lightweight file host"
},
"library": {
"play": "Play",
"copyVideoLink": "Copy video link",
"copyThumbLink": "Copy thumbnail link",
"selectedCount": "{n} selected",
"selectFirst": "Select videos first",
"clearSelection": "Clear",
"reset": "Reset",
"batchDelete": "Delete",
"deleted": "Deleted {n} video(s)",
"fileDeleted": "Deleted {n} file(s)",
"deletePartialFail": "{n} video(s) failed to delete",
"statsToday": "Uploaded today",
"statsMonth": "This month",
"statsTotal": "Videos",
"statsBytes": "Total size",
"statsUsage": "Storage usage",
"statsSource": "Upload source",
"statsTotalHintVideo": "Total videos",
"statsTotalHintFile": "Total files",
"statsTotalHintImage": "Total images",
"statsVsYesterday": "vs yesterday {value}",
"statsVsLastMonth": "vs last month {value}",
"transcodePending": "Queued",
"transcoding": "Transcoding",
"transcodeElapsed": "Elapsed",
"transcodeTotal": "Total",
"transcodeFailed": "Transcode failed",
"searchPlaceholder": "Search file name",
"searchEmpty": "Nothing matched",
"clearSearch": "Clear search",
"file": {
"title": "File bed",
"subtitle": "Manage uploaded files, copy links or download",
"uploadTitle": "Drop files here to upload",
"uploadHint": "or click to choose files, up to 10 at a time",
"uploadFormats": "Any type allowed (HTML / HTM / SVG blocked), up to {size} MB per file",
"uploadFormatsBlocked": "{formats} blocked; other file types allowed, up to {size} MB per file",
"uploadFormatsUnrestricted": "Any file type allowed, up to {size} MB per file",
"empty": "No files yet — upload one to get started"
},
"video": {
"title": "Videos",
"subtitle": "Manage uploaded videos, copy links or download",
"previewTitle": "Video details",
"previewSubtitle": "View video info and links",
"previewTranscode": "Transcode",
"previewThumbLink": "Thumbnail link",
"previewKey": "Video path",
"transcodeNone": "Not transcoded",
"transcodeDone": "Done (m3u8)",
"transcodeErrorFfmpegMissing": "No FFmpeg",
"transcodeErrorPermissionDenied": "File permission denied",
"transcodeErrorUnknown": "Unknown reason",
"downloadMp4": "Download MP4",
"downloadMp4Title": "Download MP4 video",
"downloadMp4Confirm": "The video segments will be merged into an MP4 file. Continue?",
"downloadMp4Working": "Merging video segments, please wait…",
"downloadMp4Failed": "Video merge failed. Please try again later.",
"uploadTitle": "Drop videos here to upload",
"uploadHint": "or click to choose video files, up to 10 at a time",
"uploadFormats": "MP4 / WebM / MOV / MKV and more, up to {size} MB per file",
"uploadFormatsAllowed": "{formats} supported, up to {size} MB per file",
"empty": "No videos yet — upload one to get started"
}
},
"userSettings": {
"loadFailed": "Failed to load settings",
"saveFailed": "Failed to save settings"
}
,
"users": {
"title": "Users",
"subtitle": "Review accounts, clear passwords, disable, or delete users",
"adminOnly": "Admins only",
"roleAdmin": "Admin",
"roleUser": "User",
"clearPassword": "Clear password",
"deleteUser": "Delete user and clear all data",
"disable": "Disable",
"enable": "Enable",
"disabled": "Disabled",
"disableSuccess": "Disabled user {name}",
"enableSuccess": "Enabled user {name}",
"statusFailed": "Failed to update account status",
"usageImages": "Images",
"usageVideos": "Videos",
"usageFiles": "Files",
"resetPending": "Reset pending",
"passwordCleared": "Cleared password for {name}",
"clearFailed": "Operation failed",
"deleteTitle": "Delete user?",
"deleteWarning": "This permanently deletes the user's current images, videos, files, tags, and login sessions. Admin and other users' data are not affected. This cannot be undone.",
"deleteConfirmLabel": "Type the username {name} to confirm",
"deleteSubmit": "Delete user",
"deleteFailed": "Delete failed",
"userDeleted": "Deleted user {name}",
"adminProtected": "Admin accounts cannot be cleared, disabled, or deleted",
"loadFailed": "Failed to load users"
},
"reset": {
"title": "Set a new password",
"subtitle": "An admin cleared your password. Set a new one to continue.",
"newPassword": "New password (min 6 characters)",
"confirmPassword": "Confirm new password",
"submit": "Set and sign in",
"tooShort": "Password must be at least 6 characters",
"mismatch": "Passwords do not match",
"success": "Password updated",
"failed": "Failed — make sure an admin has cleared your password"
},
"profile": {
"title": "Profile",
"subtitle": "Change your username",
"username": "Username",
"usernameHint": "2–32 letters, digits, underscore, dot or hyphen",
"saved": "Username updated",
"saveFailed": "Save failed"
}
}
+416 -45
View File
@@ -2,12 +2,13 @@
"app": {
"title": "PicHost - 个人图床",
"description": "个人自用的轻量图床系统",
"tagline": "个人轻量图床"
"tagline": "个人轻量存储"
},
"common": {
"loadingSession": "正在验证登录状态…",
"save": "保存",
"cancel": "取消",
"continue": "继续",
"copy": "复制",
"delete": "删除",
"edit": "编辑",
@@ -24,10 +25,13 @@
"nav": {
"api": "API",
"gallery": "图库",
"videoLibrary": "视频库",
"fileLibrary": "文件库",
"stats": "统计",
"logs": "操作日志",
"settings": "设置",
"storage": "存储",
"users": "用户",
"menu": "菜单",
"language": "语言",
"appearance": "外观",
@@ -85,8 +89,8 @@
}
},
"domainSeparationConfirm": {
"title": "确认保存双域名配置?",
"description": "保存后,仅网站域名可访问管理后台与 API,仅图片域名可提供图片直链。若通过 IP、未配置的 Host,或反代未正确传递 Host 访问,可能无法打开后台。\n\n若发生锁死,可执行 docker exec pichost clear-domains(本地开发:npm run clear-domains)清除域名配置,或在能访问时于设置中关闭「域名分离」。\n\n确定仍要保存吗?",
"title": "确认保存独立域名配置?",
"description": "保存后,后台域名用于管理后台与 API;图片、视频、文件域名分别只提供对应媒体直链。若通过 IP、未配置的 Host,或反代未正确传递 Host 访问,可能无法打开后台。\n\n若发生锁死,可执行 docker exec pichost clear-domains(本地开发:npm run clear-domains)清除域名配置,或在能访问时于设置中关闭「域名分离」。\n\n确定仍要保存吗?",
"confirm": "确认保存"
},
"setup": {
@@ -96,9 +100,9 @@
"passwordPlaceholder": "设置登录密码",
"confirmPlaceholder": "再次输入密码",
"allowRegistration": "允许其他用户注册",
"domainSeparation": "启用后台与图片域名分离",
"domainSeparationHint": "启用后,可以使用不同的域名访问后台和图片资源,便于配置 CDN 和自定义域名。",
"domainSeparationProxyHint": "需将两个域名解析到本机,并通过 Nginx、Caddy、NPM 等反代到端口 6892",
"domainSeparation": "启用后台与图片、视频、文件各自分离",
"domainSeparationHint": "启用后,后台、图片、视频、文件能够各自使用独立的域名。",
"domainSeparationProxyHint": "需将配置的域名解析到本机,并通过 Nginx、Caddy、NPM 等反代到端口 6892",
"domainSeparationProxyExample": "查看示例",
"siteBaseUrl": "网站域名",
"siteBaseUrlHint": "末尾不加 /;管理后台、API 上传地址",
@@ -106,7 +110,13 @@
"imageBaseUrl": "图片域名",
"imageBaseUrlHint": "末尾不加 /;生成图片直链地址",
"imageBaseUrlPlaceholder": "https://pic.example.com",
"domainSeparationInvalid": "请填写有效的网站域名与图片域名,且两者不能相同",
"videoBaseUrl": "视频域名",
"videoBaseUrlHint": "末尾不加 /;留空时沿用图片域名",
"videoBaseUrlPlaceholder": "https://video.example.com",
"fileBaseUrl": "文件域名",
"fileBaseUrlHint": "末尾不加 /;留空时沿用图片域名",
"fileBaseUrlPlaceholder": "https://file.example.com",
"domainSeparationInvalid": "请填写有效的后台、图片、视频、文件域名",
"migrateSubmit": "完成迁移",
"initSubmit": "创建并进入",
"migrateSuccess": "账号迁移完成",
@@ -149,7 +159,7 @@
"upload": {
"title": "拖拽图片到此处上传",
"hint": "或点击选择文件,按 {ctrl} + {v} 粘贴图片",
"formats": "支持 JPEG / PNG / WebP / GIF / SVG / ICO,单张最大 10 MB,每次最多 10 张",
"formats": "支持 JPEG / PNG / WebP / GIF / SVG / ICO,单张最大 {size} MB,每次最多 10 张",
"selectFiles": "选择图片",
"settings": "上传设置",
"progress": "上传进度",
@@ -196,6 +206,7 @@
"colName": "标签名称",
"colImageCount": "图片数",
"colLastUsed": "最近使用",
"colCreatedAt": "创建时间",
"neverUsed": "从未使用",
"colActions": "操作",
"mergeIntoAction": "合并到…",
@@ -297,15 +308,39 @@
"qualityMedium": "中等质量",
"qualityLow": "有损压缩",
"convenience": "便捷功能",
"videoProcessing": "视频处理",
"lowMemoryTranscodeTitle": "低内存服务器提示",
"lowMemoryTranscodeHint": "检测到当前机器内存约 {memory} GB,低于 2 GB。暂不建议开启 HLS 转码,以免转码过程导致内存不足或服务不稳定。",
"transcodeToHls": "转码成 m3u8",
"transcodeToHlsHint": "自动切片为 HLS,会占用 CPU。",
"concurrentHint": "同时转码数",
"threadsHint": "单任务 CPU 上限",
"transcodeConcurrency": "并发转码数量",
"transcodeThreads": "限制核心数",
"unitCount": "个",
"transcodeQuality": "转码质量",
"presetCopy": "直接封装",
"presetSpeed": "速度",
"presetBalanced": "平衡",
"presetQuality": "质量",
"presetCompress": "压缩",
"compressBitrate": "目标码率",
"transcodeLoadFailed": "读取转码设置失败",
"transcodeSaveFailed": "保存转码设置失败",
"autoCopy": "上传成功自动复制链接",
"autoCopyHint": "图片上传成功后,自动复制图片链接到剪贴板。"
"autoCopyHint": "上传成功后,自动复制链接到剪贴板。",
"uploadTags": "上传标签",
"uploadTagsHint": "输入后按 Enter、逗号或移开焦点加入待添加列表;留空则不添加标签。",
"uploadTagsPlaceholder": "输入标签",
"uploadTagsPending": "待添加标签({n})",
"uploadTagsRemove": "删除标签 {name}"
},
"settings": {
"title": "系统设置",
"pageTitle": "设置与偏好",
"pageSubtitle": "管理系统配置与操作日志",
"pageSubtitleBasic": "配置系统的基本选项,让 PicHost 更好地为你服务。",
"pageSubtitleDomains": "配置网站域名、图片域名以及图片存储路径等相关设置",
"pageSubtitleDomains": "配置后台、图片、视频、文件域名以及存储路径等相关设置",
"pageSubtitleAccess": "配置图片访问安全策略,防止资源被恶意盗用。",
"pageSubtitleLogs": "查看用户上传、删除、修改与系统配置记录",
"searchPlaceholder": "搜索设置项…",
@@ -316,11 +351,20 @@
"defaultCopyFormatHint": "设置上传后自动复制的链接格式。",
"systemVersionHint": "当前运行的 PicHost 版本。",
"badgeEnabled": "已启用",
"domainSeparationProxyTitle": "需要将两个域名解析到本机",
"domainSeparationProxyBody": "请将网站域名和图片域名都解析到本机,并通过 Nginx、Caddy、NPM 等反向代理到本机的 6892 端口。",
"siteBaseUrlRoleHint": "用于访问后台管理界面的网站域名。",
"imageBaseUrlRoleHint": "用于访问和显示图片资源的域名。",
"imageDomainSection": "图片域名",
"domainSeparationProxyTitle": "需要将配置的域名解析到本机",
"domainSeparationProxyBody": "请将后台域名和已配置的图片、视频、文件域名解析到本机,并通过 Nginx、Caddy、NPM 等反向代理到本机的 6892 端口。",
"siteBaseUrlRoleHint": "后台与 API 使用该域名;资源开关关闭时,图片、视频、文件也使用它。",
"imageBaseUrlRoleHint": "打开后图片单独走该域名;关闭时使用网站域名。",
"resourceDomainSection": "资源域名",
"videoBaseUrl": "视频域名",
"videoBaseUrlRoleHint": "打开后视频、HLS 和缩略图单独走该域名;关闭时使用网站域名。",
"videoBaseUrlPlaceholder": "https://video.example.com",
"videoBaseUrlActive": "当前生效:{url}",
"fileBaseUrl": "文件域名",
"fileBaseUrlRoleHint": "打开后文件直链和下载单独走该域名;关闭时使用网站域名。",
"fileBaseUrlPlaceholder": "https://file.example.com",
"fileBaseUrlActive": "当前生效:{url}",
"fallbackImageDomain": "沿用图片域名",
"navBasic": "基础设置",
"navDomains": "域名与路径",
"navAccess": "访问控制",
@@ -355,6 +399,12 @@
"refererUnrestricted": "未限制",
"loginVerification": "登录验证",
"loginVerificationHint": "登录与注册时使用的人机验证方式,三选一",
"fileBed": "文件床",
"fileBedHint": "文件直链的访问方式,切换后立即生效",
"fileBedPublic": "公开访问",
"fileBedPublicHint": "任何人拿到链接都能下载,适合分享。",
"fileBedToken": "Token 访问",
"fileBedTokenHint": "下载需要携带 Token(链接后追加 ?token=),适合私密文件。",
"loginVerificationSlider": "本地滑块",
"loginVerificationSliderHint": "内置滑块验证,无需第三方服务。",
"loginVerificationTurnstile": "Cloudflare Turnstile",
@@ -375,7 +425,7 @@
"defaultCopyFormat": "默认复制格式",
"refererPlaceholder": "example.com, www.example.com",
"domainSettings": "域名配置",
"domainSeparationActive": "已启用域名分离:图片域仅出图,管理域提供后台与 API",
"domainSeparationActive": "已启用域名分离:后台、图片、视频、文件可使用独立域名",
"siteBaseUrl": "网站域名",
"siteBaseUrlHint": "末尾不加 /;管理后台、API 上传地址",
"siteBaseUrlPlaceholder": "https://admin.example.com",
@@ -385,13 +435,17 @@
"imageBaseUrlSingleHint": "末尾不加 /;留空则使用当前访问域名;单域名部署无需填写网站域名",
"imageBaseUrlPlaceholder": "https://pic.example.com",
"imageBaseUrlActive": "当前生效:{url}",
"resourceBaseUrl": "域名",
"resourceBaseUrlSingleHint": "末尾不加 /;留空则使用当前访问域名。关闭域名分离时,该域名统一用于图片、视频和文件资源。",
"resourceBaseUrlPlaceholder": "https://cdn.example.com",
"resourceBaseUrlActive": "当前资源域名:{url}",
"runtimeDetected": "检测到当前访问:{url}(不会自动写入配置)",
"fillDetectedOrigin": "填入检测地址",
"hostRoleUnknown": "当前访问 Host 与配置不一致,可能存在反代绕过风险",
"disableDomainSeparationTitle": "关闭域名分离",
"disableDomainSeparationDesc": "将清除管理域名配置,仅保留图片域名。确定继续?",
"disableDomainSeparationDesc": "将清除后台域名配置,并保留图片、视频、文件域名。确定继续?",
"disableDomainSeparationConfirm": "确认关闭",
"domainSeparationRequired": "启用域名分离时需同时填写网站域名与图片域名",
"domainSeparationRequired": "启用域名分离时需同时填写后台域名与图片域名",
"hideFolderInUrl": "使用简短图片链接",
"hideFolderInUrlHint": "启用后,生成的图片链接将使用简短格式(如 /abc123.png),不包含年月目录。",
"storageUseDatePath": "上传目录按年/月分组",
@@ -418,14 +472,27 @@
"uploadLimits": "上传限制",
"uploadLimitsHint": "全站上传策略与个人上传便捷选项,保存后立即生效。",
"imageProcessingHint": "全站格式转换与个人端压缩、清理策略。",
"videoProcessingHint": "全站视频转码资源与个人视频上传偏好,保存后与视频上传页同步。",
"serverPolicy": "全站策略",
"personalPreference": "个人偏好",
"uploadRateGroup": "上传限流",
"minutesUnit": "分钟",
"uploadMaxFileSize": "单文件大小上限",
"uploadMaxFileSizeHint": "超过此大小的图片将被拒绝上传(1–100 MB)。",
"uploadImageMaxFileSize": "图片大小上限",
"uploadImageMaxFileSizeHint": "超过此大小的图片将被拒绝上传,最大 100 MB。0 MB 表示禁止上传。",
"uploadVideoMaxFileSize": "视频大小上限",
"uploadVideoMaxFileSizeHint": "超过此大小的视频将被拒绝上传,最大 2048 MB。0 MB 表示禁止上传。",
"uploadFileMaxFileSize": "文件大小上限",
"uploadFileMaxFileSizeHint": "超过此大小的普通文件将被拒绝上传,最大 1024 MB。0 MB 表示禁止上传。",
"allowedMimeTypes": "允许的图片格式",
"allowedMimeTypesHint": "至少保留一种格式;服务端会校验文件签名,不仅看扩展名。",
"allowedVideoExtensions": "允许的视频格式",
"allowedVideoExtensionsHint": "至少保留一种视频容器格式;视频上传页和服务端都会按此列表校验。",
"forbiddenFileExtensions": "不允许的文件格式",
"forbiddenFileExtensionsHint": "文件上传页和服务端都会拒绝这些扩展名,可额外添加自定义后缀。",
"forbiddenFileExtensionsCustomPlaceholder": "添加后缀,如 exe, php",
"forbiddenFileExtensionsAdd": "添加",
"preserveOriginalUpload": "保留原图(关闭 WebP 转换)",
"preserveOriginalUploadHint": "开启后上传的 JPEG/PNG/GIF 不再转为 WebP,按原格式存储;关闭时可节省空间。",
"rateLimits": "限流",
@@ -454,6 +521,18 @@
"providerOss": "阿里云 OSS",
"providerAws": "AWS S3",
"providerCustom": "自定义",
"providerTelegram": "Telegram 频道",
"backendTelegram": "Telegram",
"telegramChatId": "Chat ID",
"telegramChatIdPlaceholder": "-1001234567890",
"telegramChatIdHint": "频道或群组 ID;Bot 须为管理员。私有频道可用。",
"telegramBotToken": "Bot Token",
"telegramApiProxy": "API 代理(可选)",
"telegramApiProxyPlaceholder": "http://127.0.0.1:7890",
"telegramApiProxyHint": "PicHost 访问 Telegram API 时使用的 HTTP/HTTPS 代理;留空为直连。",
"telegramServingHint": "图片直链固定经 PicHost 代理,不会跳转到 Telegram。",
"telegramQuotaHint": "Telegram 单文件实际可读上限约 20MB;频道约 20 条/分钟。",
"telegramSecretsRequired": "请填写 Bot Token 与 Chat ID",
"endpoint": "Endpoint",
"region": "Region",
"bucket": "Bucket",
@@ -508,14 +587,33 @@
"storageId": "存储标识",
"storageType": "存储类型",
"badgeLocal": "本地",
"defaultStorageTip": "默认存储提示:新上传的图片将默认保存到「{name}」。可点击其他后端的「设为默认」进行修改。",
"defaultStorageTip": "新上传的所有图片、视频、文件将默认保存到「{name}」。可点击其他后端的「设为默认」进行修改。",
"clearStorage": "清空存储",
"exportTitle": "导出数据库表",
"exportDescription": "把数据库中的每张表导出为 CSV,打包成 tar.gz 压缩包下载。密码、令牌等敏感字段会脱敏。",
"exportCsv": "导出 CSV 压缩包",
"exportCsvStarted": "导出完成,开始下载",
"exportCsvFailed": "导出失败",
"clearStorageTitle": "清空我的存储",
"clearStorageDescription": "永久删除当前账号上传的所有图片、视频、文件和缓存,不影响其他用户的数据。",
"clearStorageWarning": "高危风险:此操作会清空存储中的所有文件和缓存,无法撤销!",
"clearStorageScope": "只会清除当前登录用户的数据和文件。其他用户的数据、账户、存储配置和全站备份不会被删除。",
"clearStorageFinalTitle": "最终确认",
"clearStorageInstruction": "请输入“我已确认”以继续。操作开始后无法取消或撤销。",
"clearStoragePlaceholder": "我已确认",
"clearStoragePhrase": "我已确认",
"clearStorageConfirm": "永久清空",
"clearStorageSuccess": "已清空 {files} 个文件和 {cache} 个临时缓存,存储空间已释放",
"clearStoragePartial": "{failed}/{total} 个文件清理失败,请重试",
"clearStorageFailed": "清空存储失败,请稍后重试",
"supportedTitle": "支持的存储后端",
"supportedSubtitle": "PicHost 支持多种存储后端,满足不同场景需求",
"supportedLocalDesc": "适合小型部署和个人使用",
"supportedR2Desc": "高可用、低成本的对象存储",
"supportedS3Title": "S3 兼容",
"supportedS3Desc": "兼容 Amazon S3 协议的存储服务",
"footerTip": "默认存储后端将自动接收所有新上传的图片",
"supportedTelegramDesc": "用 Telegram 频道/群组存图,适合轻量与无限容量场景",
"footerTip": "默认存储后端将自动接收所有新上传的图片、视频和文件",
"statTotal": "总量",
"statUsed": "已用",
"statFree": "剩余",
@@ -546,28 +644,28 @@
"manageStorageHint": "配置 R2 / S3 / COS 等多后端与直链策略",
"backup": {
"sectionTitle": "备份与迁移",
"sectionSubtitle": "整站备份恢复与跨后端图片迁移",
"sectionSubtitle": "整站备份恢复与跨后端文件迁移",
"siteTitle": "站点备份与恢复",
"siteSubtitle": "用于换机、灾备与整站恢复",
"siteFooterHint": "备份包含数据库与图片,不包含 secret_json 明文。",
"siteFooterHint": "备份包含数据库与全部文件,不包含 secret_json 明文。",
"exportTitle": "创建站点备份",
"exportSubtitle": "打包数据库与全部图片为 .phost.tar.gz",
"exportSubtitle": "打包数据库与全部文件为 .phost.tar.gz",
"exportAction": "开始备份",
"exportStarted": "备份任务已开始",
"exportFailed": "创建备份失败",
"exportPathHint": "导出文件保存在 data/backups/",
"exportIncludesTitle": "备份包含",
"exportIncludesDb": "数据库快照(pichost.db)",
"exportIncludesImages": "全部图片文件({count} 张)",
"exportIncludesImages": "全部图片、视频和文件({count} 个)",
"exportExcludesSecrets": "不含云存储 secret.json 明文",
"exportConcurrencyHint": "同时仅可运行一个备份或迁移任务",
"estimateLoading": "正在估算体积…",
"estimateImageCountLabel": "图片总数",
"estimateImageCountLabel": "文件总数",
"estimatePackageSizeLabel": "预计备份包",
"estimateDatabaseSnapshotLabel": "数据库快照",
"estimateStoragePathLabel": "保存位置",
"estimateStoragePathValue": "data/backups/",
"estimateImages": "图片 {count} 张 · {size}",
"estimateImages": "文件 {count} 个 · {size}",
"estimateDatabase": "数据库约 {size}",
"estimateTotal": "合计约 {size}",
"restoreTitle": "从备份恢复",
@@ -584,61 +682,114 @@
"restoreConfirm": "确认恢复",
"restoreConfirmTitle": "确认恢复站点",
"restoreConfirmSource": "来源:{name}",
"restoreOverwriteWarning": "覆盖模式将替换冲突图片与数据库,请确认已做好当前站点备份",
"restoreOverwriteWarning": "覆盖模式将替换冲突文件与数据库,请确认已做好当前站点备份",
"restoreSelectRequired": "请选择或上传备份文件",
"restoreStarted": "恢复任务已开始",
"restoreFailed": "恢复失败",
"conflictMode": "冲突策略",
"conflictSkip": "跳过已存在图片",
"conflictOverwrite": "覆盖已存在图片与数据库",
"conflictSkip": "跳过已存在文件",
"conflictOverwrite": "覆盖已存在文件与数据库",
"syncTitle": "跨后端同步",
"siteSyncTitle": "两站点同步",
"siteSyncSubtitle": "将当前站点的媒体索引、图片、视频、文件、缩略图、HLS 分片和标签同步到另一台 PicHost。目标站点需已有同名用户。",
"siteSyncPushSubtitle": "将当前站点的媒体索引、图片、视频、文件、缩略图、HLS 分片和标签推送到另一台 PicHost。目标站点需已有同名用户。",
"siteSyncPullSubtitle": "从另一台 PicHost 拉取媒体索引、图片、视频、文件、缩略图、HLS 分片和标签。当前站点需已有同名用户。",
"siteSyncTarget": "目标站点地址",
"siteSyncSource": "来源站点地址",
"siteSyncToken": "目标站点管理员 API Token",
"siteSyncSourceToken": "来源站点管理员 API Token",
"siteSyncTokenPlaceholder": "输入目标站点管理员 Token",
"siteSyncSourceTokenPlaceholder": "输入来源站点管理员 Token",
"siteSyncStart": "开始同步",
"siteSyncStartPush": "开始推送",
"siteSyncStartPull": "开始拉取",
"siteSyncDirection": "同步策略:",
"siteSyncPush": "推送到目标站点",
"siteSyncPull": "从目标站点拉取",
"siteSyncConflict": "冲突策略:",
"siteSyncContent": "同步内容:",
"siteSyncScopeAll": "全站",
"siteSyncScopeImage": "仅图库",
"siteSyncScopeVideo": "仅视频库",
"siteSyncScopeFile": "仅文件库",
"syncSelectedTitle": "同步选中资源",
"syncSelectedSubtitle": "将选中的 {n} 项推送到另一台 PicHost",
"syncSelectedStart": "开始同步",
"syncSelectedAction": "同步给目标站点",
"siteSyncSkip": "跳过已存在",
"siteSyncOverwrite": "覆盖已存在",
"siteSyncResume": "继续上次同步",
"siteSyncAbandon": "放弃并清理残留",
"siteSyncAbandonConfirm": "确定放弃这次同步吗?将删除本次同步新建但尚未归入媒体记录的残留对象。",
"siteSyncAbandonWithoutTokenConfirm": "未填写目标站 Token。确定只放弃任务吗?目标站残留将标记为待清理,之后可由目标站自动清理。",
"siteSyncAbandoned": "同步已放弃,残留对象已清理",
"siteSyncAbandonedPendingCleanup": "同步已放弃,残留待清理",
"siteSyncScanProgress": "媒体扫描进度",
"siteSyncFileProgress": "已同步文件数量",
"siteSyncStarted": "两站点同步任务已开始",
"siteSyncFailed": "启动两站点同步失败",
"syncMigrateTitle": "跨后端迁移",
"syncMigrateSubtitle": "在已配置后端之间迁移图片",
"syncSubtitle": "在已配置的后端之间拷贝图片并更新索引",
"syncMigrateSubtitle": "在已配置后端之间迁移文件",
"syncSubtitle": "在已配置的后端之间拷贝文件并更新索引",
"syncFrom": "源后端",
"syncTo": "目标后端",
"syncPendingImages": "待迁移图片",
"syncPendingImages": "待迁移文件",
"syncPendingSize": "预计体积",
"syncIncludesTitle": "同步说明",
"syncIncludesCopy": "拷贝源端图片文件到目标后端",
"syncIncludesCopy": "拷贝源端图片、视频和文件到目标后端",
"syncIncludesIndex": "更新索引中的 storage_backend_id",
"syncIncludesConfig": "不修改站点配置与其它后端数据",
"syncDryRun": "仅预览 Dry Run",
"syncSetDefaultAfter": "迁移后设为默认后端",
"syncStartMigrate": "开始迁移",
"syncPreviewOnly": "预览",
"syncFooterHint": "仅迁移图片文件与索引,不影响站点配置。",
"syncFooterHint": "仅迁移文件与索引,不影响站点配置。",
"syncDeleteSource": "同步后删除源端文件",
"syncSetDefault": "同步后将目标设为默认",
"syncPreview": "预检并开始",
"syncConfirm": "开始同步",
"syncConfirmTitle": "确认跨后端同步",
"syncDeleteWarning": "将删除源后端上的图片文件,此操作不可撤销",
"syncDeleteWarning": "将删除源后端上的文件,此操作不可撤销",
"syncSelectRequired": "请选择源与目标后端",
"syncSameBackend": "源与目标不能相同",
"syncStarted": "同步任务已开始",
"syncFailed": "同步失败",
"previewFailed": "预检失败",
"previewImages": "图片数量",
"previewNew": "新增图片",
"previewConflicts": "冲突图片",
"previewImages": "文件数量",
"previewNew": "新增文件",
"previewConflicts": "冲突文件",
"previewCreated": "备份时间",
"previewSize": "数据量",
"jobsTitle": "最近任务",
"jobsSubtitle": "查看备份、恢复与迁移进度",
"jobsEmpty": "暂无备份或迁移任务",
"jobsEmptyHint": "导出、恢复或同步开始后,进度会显示在这里",
"jobsHideAll": "全部清除",
"jobsRestoreAll": "恢复全部",
"hiddenJobsShow": "显示隐藏记录",
"hiddenJobsHide": "只显示未关闭",
"jobHide": "关闭显示(不删除记录)",
"jobUnhide": "恢复显示",
"jobHidden": "任务已关闭显示,记录仍保留",
"jobUnhidden": "任务已恢复显示",
"jobsHiddenAll": "已关闭全部历史任务的显示,记录仍保留",
"jobsUnhiddenAll": "已恢复显示全部历史任务",
"hideFailed": "更新任务显示状态失败",
"jobsUnit": "条任务",
"colTask": "任务",
"colType": "类型",
"colStatus": "状态",
"colProgress": "进度",
"progressUnitMedia": "媒体记录",
"progressUnitObjects": "存储对象",
"colMessage": "说明",
"colCreated": "创建时间",
"colUpdated": "更新时间",
"colActions": "操作",
"jobTypeExport": "站点导出",
"jobTypeRestore": "站点恢复",
"jobTypeSync": "跨后端同步",
"jobTypeSiteSync": "两站点同步",
"statusPending": "等待中",
"statusRunning": "进行中",
"statusDone": "已完成",
@@ -652,8 +803,8 @@
"jobRunning": "已有任务正在运行,请稍后再试",
"migrateIn": "迁入",
"migrateOut": "迁出",
"migrateInHint": "从默认后端拷贝图片到此后端",
"migrateOutHint": "将图片从此后端迁出",
"migrateInHint": "从默认后端拷贝文件到此后端",
"migrateOutHint": "将文件从此后端迁出",
"syncDraftApplied": "已填入同步表单,请确认后开始",
"syncNeedTwoBackends": "至少需要两个存储后端才能同步",
"selectBackend": "选择后端",
@@ -802,6 +953,8 @@
"storageBackend": "存储:{name}"
},
"delete": {
"confirmDescVideo": "确定要删除选中的 {count} 个视频吗?此操作不可恢复。",
"confirmDescFile": "确定要删除选中的 {count} 个文件吗?此操作不可恢复。",
"confirmTitle": "确认删除",
"confirmDesc": "确定要删除选中的 {count} 张图片吗?此操作不可恢复。",
"confirm": "确认删除"
@@ -828,9 +981,9 @@
"envOverrideTitle": "Token 由环境变量覆盖",
"envOverrideDesc": "修改 API_UPLOAD_TOKEN 后需重启服务;后台无法重新生成。",
"personalTitle": "个人 API Token",
"personalDesc": "此 Token 仅用于您自己的脚本上传,上传的图片归属您的账号。",
"personalDesc": "此 Token 仅用于您自己的脚本上传,上传的图片、视频或文件归属您的账号。",
"notConfiguredTitle": "尚未配置 Token",
"notConfiguredDesc": "点击「重新生成」创建 Token,用于下方所有接口的 Auth-Token 鉴权。",
"notConfiguredDesc": "首次打开会自动创建 Token,或点击「重新生成」。",
"regenerate": "重新生成",
"tokenSourceEnv": "来源:环境变量 API_UPLOAD_TOKEN",
"tokenSourceDb": "来源:后台生成(可重新生成)",
@@ -870,6 +1023,8 @@
"responseExample": "响应示例",
"categories": {
"image": "图片接口",
"video": "视频接口",
"file": "文件接口",
"extension": "插件接口",
"system": "系统通用"
},
@@ -879,8 +1034,7 @@
"file": "兼容字段,单图上传",
"files": "兼容字段,多图上传",
"token": "Twikoo 协议兼容,表单 token",
"tagIds": "可选,JSON 数组或重复字段,上传后按 ID 打标",
"tagNames": "可选,JSON 数组或逗号分隔;名称不存在则自动创建,存在则复用"
"tagNames": "可选,JSON 数组或逗号分隔;名称不存在则创建,存在则复用"
},
"list": {
"limit": "每页条数,默认 20,最大 100",
@@ -898,6 +1052,47 @@
"batchDelete": {
"keys": "要删除的图片 key 数组"
},
"videoUpload": {
"video": "上传视频主字段,单文件最大 500 MB",
"file": "兼容字段,可代替 video",
"files": "兼容字段,可一次上传多个视频",
"transcode": "设为 1 后异步转码为 HLS;不设置则只保留原视频并生成缩略图",
"transcodePreset": "可选转码档位:copy(直接封装)、veryfast(速度)、medium(平衡)、slow(质量)、compress(目标码率压缩)",
"transcodeBitratePercent": "仅 compress 档使用,目标码率为源码率的 10–100% 整数;省略时使用服务端当前设置",
"token": "可选,API Token 也可通过 Auth-Token 请求头传递"
},
"fileUpload": {
"file": "上传文件主字段,单文件最大 100 MB;禁止 HTML / HTM / SVG",
"files": "兼容字段,可一次上传多个文件",
"token": "可选,API Token 也可通过 Auth-Token 请求头传递"
},
"mediaList": {
"kindVideo": "固定为 video,获取视频列表",
"kindFile": "固定为 file,获取文件列表",
"page": "页码,从 1 开始",
"limit": "每页条数,默认 20,最大 100",
"q": "按文件名或路径关键词搜索",
"processing": "视频专用;设为 1 时只返回排队中或转码中的视频",
"uploadSource": "上传来源:web 或 api",
"tagIds": "按标签筛选,逗号分隔标签 ID",
"tagMode": "多标签筛选时使用 or 或 and,默认 or",
"untagged": "设为 1 时仅返回未标记内容"
},
"mediaSearch": {
"q": "搜索关键词,匹配文件名或存储路径;不能为空"
},
"videoDelete": {
"key": "视频存储路径;删除时同时清理缩略图与 HLS 文件"
},
"videoBatchDelete": {
"keys": "要删除的视频 key 数组,单次最多 100 个;同时清理缩略图和 HLS 文件"
},
"fileDelete": {
"key": "文件存储路径,如 files/2026/10/xxx.pdf"
},
"fileBatchDelete": {
"keys": "要删除的文件 key 数组,单次最多 100 个"
},
"twikoo": {
"image": "上传图片",
"token": "API Token"
@@ -920,7 +1115,7 @@
"notesTitle": "响应说明",
"noteSuccess": "200 表示请求成功,返回 JSON 数据",
"noteError": "4xx / 5xx 表示请求失败,查看 error 字段",
"noteLimit": "上传限制:单次最多 10 张,单张最大 10 MB"
"noteLimit": "上传限制:单次最多 10 个;视频单文件最大 500 MB,文件单文件最大 100 MB"
},
"docs": {
"upload": {
@@ -943,6 +1138,46 @@
"title": "批量删除",
"desc": "一次删除多张图片,请求体为 keys 数组。"
},
"videoUpload": {
"title": "上传视频",
"desc": "上传 MP4 / WebM / MOV / MKV 等视频,单次最多 10 个、单文件最大 500 MB。上传后独立生成缩略图;transcode=1 时再异步转码为 HLS。"
},
"videoList": {
"title": "获取视频列表",
"desc": "设置 kind=video 获取视频。默认包含未转码、已完成和失败条目;processing=1 仅查看排队中或转码中的任务。"
},
"videoDelete": {
"title": "删除视频",
"desc": "通过视频 key 删除视频,同时清理缩略图和已生成的 HLS 播放列表与分片。"
},
"videoSearch": {
"title": "搜索视频",
"desc": "按文件名或存储路径搜索视频,支持分页、存储后端、上传来源和标签筛选;q 必填。"
},
"videoBatchDelete": {
"title": "批量删除视频",
"desc": "一次删除最多 100 个视频;每个视频的缩略图、HLS 播放列表与分片会一并清理。"
},
"fileUpload": {
"title": "上传文件",
"desc": "上传任意文件,单次最多 10 个、单文件最大 100 MB;禁止上传 HTML、HTM 和 SVG。"
},
"fileList": {
"title": "获取文件列表",
"desc": "设置 kind=file 分页获取文件床内容,支持搜索、存储后端、来源和标签筛选。"
},
"fileDelete": {
"title": "删除文件",
"desc": "通过文件 key 删除单个文件,操作不可恢复。"
},
"fileSearch": {
"title": "搜索文件",
"desc": "按文件名或存储路径搜索文件,设置 kind=file;支持分页、存储后端、上传来源和标签筛选。"
},
"fileBatchDelete": {
"title": "批量删除文件",
"desc": "一次删除最多 100 个文件,响应会分别返回删除成功和失败的 key。"
},
"twikoo": {
"title": "twikoo 兼容上传",
"desc": "兼容 Twikoo / EasyImage 2.0 协议,图片存入 images/ 目录;字段 image + token。"
@@ -957,8 +1192,144 @@
}
}
},
"files": {
"maxFiles": "每次最多上传 {n} 个文件",
"uploadSuccess": "已上传 {n} 个文件",
"uploadFailed": "上传失败,请稍后重试",
"fileTooLarge": "{name}:超过单文件上限 {limit} MB",
"loadFailed": "读取文件列表失败",
"copyLink": "复制直链",
"download": "下载",
"deleted": "已删除",
"deleteFailed": "删除失败",
"videoFormatBlocked": "不允许上传 {format} 视频格式",
"fileFormatBlocked": "不允许上传 {format} 文件格式"
},
"host": {
"image": "图片",
"video": "视频",
"file": "文件",
"imageFull": "个人轻量图床",
"videoFull": "个人轻量视频床",
"fileFull": "个人轻量文件床"
},
"library": {
"play": "播放",
"copyVideoLink": "复制视频链接",
"copyThumbLink": "复制缩略图链接",
"selectedCount": "已选择 {n} 项",
"selectFirst": "请先选择视频",
"clearSelection": "取消选择",
"reset": "重置",
"batchDelete": "批量删除",
"deleted": "已删除 {n} 个视频",
"fileDeleted": "已删除 {n} 个文件",
"deletePartialFail": "{n} 个视频删除失败",
"statsToday": "今日上传",
"statsMonth": "本月上传",
"statsTotal": "累计视频",
"statsBytes": "总占用",
"statsUsage": "存储使用率",
"statsSource": "上传来源分布",
"statsTotalHintVideo": "总视频数",
"statsTotalHintFile": "总文件数",
"statsTotalHintImage": "总图片数",
"statsVsYesterday": "较昨日 {value}",
"statsVsLastMonth": "较上月 {value}",
"transcodePending": "等待转码中",
"transcoding": "转码中",
"transcodeElapsed": "已耗时",
"transcodeTotal": "总耗时",
"transcodeFailed": "转码失败",
"searchPlaceholder": "搜索文件名",
"searchEmpty": "没有匹配的内容",
"clearSearch": "清除搜索",
"file": {
"title": "文件床",
"subtitle": "管理已上传的文件,复制直链或下载",
"uploadTitle": "拖拽文件到此处上传",
"uploadHint": "或点击选择文件,单次最多 10 个",
"uploadFormats": "支持任意类型(禁止 HTML / HTM / SVG),单个文件最大 {size} MB",
"uploadFormatsBlocked": "禁止 {formats};其他文件类型可上传,单个文件最大 {size} MB",
"uploadFormatsUnrestricted": "支持任意类型,单个文件最大 {size} MB",
"empty": "还没有文件,上传一个试试"
},
"video": {
"title": "视频",
"subtitle": "管理已上传的视频,复制直链或下载",
"previewTitle": "视频详情",
"previewSubtitle": "查看视频信息与链接",
"previewTranscode": "转码状态",
"previewThumbLink": "缩略图链接",
"previewKey": "视频路径",
"transcodeNone": "未转码",
"transcodeDone": "已完成(m3u8)",
"transcodeErrorFfmpegMissing": "无 FFmpeg",
"transcodeErrorPermissionDenied": "无文件权限",
"transcodeErrorUnknown": "原因未知",
"downloadMp4": "下载 MP4",
"downloadMp4Title": "下载 MP4 视频",
"downloadMp4Confirm": "将会合并视频切片到 MP4,是否继续?",
"downloadMp4Working": "正在合并视频切片,请稍候…",
"downloadMp4Failed": "视频合并失败,请稍后重试",
"uploadTitle": "拖拽视频到此处上传",
"uploadHint": "或点击选择视频文件,单次最多 10 个",
"uploadFormats": "支持 MP4 / WebM / MOV / MKV 等,单个文件最大 {size} MB",
"uploadFormatsAllowed": "支持 {formats},单个文件最大 {size} MB",
"empty": "还没有视频,上传一个试试"
}
},
"userSettings": {
"loadFailed": "加载设置失败",
"saveFailed": "保存设置失败"
}
,
"users": {
"title": "用户管理",
"subtitle": "查看账号、清除密码、禁用或注销用户",
"adminOnly": "仅管理员可访问此页面",
"roleAdmin": "管理员",
"roleUser": "普通用户",
"clearPassword": "清除密码",
"deleteUser": "注销该用户并清除所有数据",
"disable": "禁用",
"enable": "启用",
"disabled": "已禁用",
"disableSuccess": "已禁用用户 {name}",
"enableSuccess": "已启用用户 {name}",
"statusFailed": "账号状态修改失败",
"usageImages": "图片",
"usageVideos": "视频",
"usageFiles": "文件",
"resetPending": "待重置密码",
"passwordCleared": "已清除 {name} 的密码",
"clearFailed": "操作失败",
"deleteTitle": "注销用户?",
"deleteWarning": "将永久删除该用户当前的图片、视频、文件、标签和登录会话。管理员数据和其他用户数据不受影响,此操作不可恢复。",
"deleteConfirmLabel": "输入用户名 {name} 确认注销",
"deleteSubmit": "确认注销",
"deleteFailed": "注销失败",
"userDeleted": "已注销用户 {name}",
"adminProtected": "管理员账号不可清除密码、禁用或注销",
"loadFailed": "读取用户列表失败"
},
"reset": {
"title": "设置新密码",
"subtitle": "管理员已清除你的密码,请设置一个新密码继续使用",
"newPassword": "新密码(至少 6 位)",
"confirmPassword": "确认新密码",
"submit": "设置并登录",
"tooShort": "密码至少 6 位",
"mismatch": "两次输入的密码不一致",
"success": "密码已更新",
"failed": "设置失败,请确认管理员已清除你的密码"
},
"profile": {
"title": "个人资料",
"subtitle": "修改你的用户名",
"username": "用户名",
"usernameHint": "2–32 位字母、数字、下划线、点或短横线",
"saved": "用户名已更新",
"saveFailed": "保存失败"
}
}
+7
View File
@@ -50,6 +50,13 @@ export default defineNuxtConfig({
nitro: {
preset: 'node-server',
publicAssets: [
{
baseURL: '/cap',
dir: join(rootDir, 'node_modules/@cap.js/wasm/browser'),
maxAge: 60 * 60 * 24 * 30
}
],
scheduledTasks: {
'0 3 * * *': ['auto-delete']
}
+3010 -7
View File
File diff suppressed because it is too large. Load diff
Loaded 100 of 263 files, more files were not shown because too many files have changed in this diff. Show more