Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8d3616391a | ||
|
|
b4535d6aea | ||
|
|
5d5fecde1b | ||
|
|
88e5707ab6 | ||
|
|
5e024f77a2 | ||
|
|
202a81d0a9 | ||
|
|
a4944a98da | ||
|
|
1d2d16e710 | ||
|
|
21f0dc2830 | ||
|
|
ee8772c1b7 | ||
|
|
a6e45e0cb1 | ||
|
|
813407de30 | ||
|
|
5c1f640a2b | ||
|
|
92aaf6fbb8 | ||
|
|
832261739f | ||
|
|
6494ad2918 | ||
|
|
9939943e31 | ||
|
|
d647453085 | ||
|
|
818c980dec | ||
|
|
a2289a564f | ||
|
|
76328fa058 | ||
|
|
7b2fae6335 | ||
|
|
fa7c8244b0 | ||
|
|
a1b4a63552 | ||
|
|
9cb8552a99 | ||
|
|
a64be50b73 | ||
|
|
9ebab8270c | ||
|
|
75a1ea5fbc | ||
|
|
ede8cb7031 | ||
|
|
89ea2fb25d | ||
|
|
05cc3895f1 | ||
|
|
424bfd9090 | ||
|
|
10baf9d489 | ||
|
|
cc19dc6435 |
No files matched your search
@@ -5,5 +5,12 @@ node_modules
|
|||||||
.github
|
.github
|
||||||
data
|
data
|
||||||
dist
|
dist
|
||||||
|
dist-fpk
|
||||||
|
dist-windows
|
||||||
|
dist-docker
|
||||||
|
packaging
|
||||||
|
scripts
|
||||||
.env
|
.env
|
||||||
*.md
|
*.md
|
||||||
|
._*
|
||||||
|
.DS_Store
|
||||||
+3
-1
@@ -31,7 +31,9 @@
|
|||||||
# WEBP_QUALITY=80
|
# 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
|
# ALLOWED_MIME_TYPES=image/jpeg,image/png,image/webp,image/gif,image/svg+xml,image/x-icon
|
||||||
# UPLOAD_RATE_IP_MAX=60
|
# UPLOAD_RATE_IP_MAX=60
|
||||||
# UPLOAD_RATE_TOKEN_MAX=120
|
# UPLOAD_RATE_TOKEN_MAX=120
|
||||||
|
|||||||
@@ -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
|
|
||||||
@@ -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
@@ -5,6 +5,7 @@
|
|||||||
.nitro
|
.nitro
|
||||||
.cache
|
.cache
|
||||||
dist
|
dist
|
||||||
|
dist-windows
|
||||||
|
|
||||||
# Node dependencies
|
# Node dependencies
|
||||||
node_modules
|
node_modules
|
||||||
@@ -37,9 +38,9 @@ data
|
|||||||
# 本地 Git hooks 模板(仅安装在 .git/hooks/,不进仓库)
|
# 本地 Git hooks 模板(仅安装在 .git/hooks/,不进仓库)
|
||||||
git-hooks/
|
git-hooks/
|
||||||
|
|
||||||
# 本地维护脚本(assign-image-owner、fpk 打包等,不进仓库)
|
# 打包输出目录
|
||||||
scripts/
|
|
||||||
dist-fpk/
|
dist-fpk/
|
||||||
|
dist-docker/
|
||||||
|
|
||||||
# VitePress docs site
|
# VitePress docs site
|
||||||
docs-site/.vitepress/cache
|
docs-site/.vitepress/cache
|
||||||
|
|||||||
@@ -4,6 +4,51 @@
|
|||||||
|
|
||||||
## [Unreleased]
|
## [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
|
## [1.4.2] - 2026-09-23
|
||||||
|
|
||||||
### 修复
|
### 修复
|
||||||
|
|||||||
@@ -18,6 +18,9 @@ FROM node:22-alpine
|
|||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
|
# 视频床转码依赖 ffmpeg(单码率 HLS 切片)
|
||||||
|
RUN apk add --no-cache ffmpeg
|
||||||
|
|
||||||
ENV NODE_ENV=production \
|
ENV NODE_ENV=production \
|
||||||
NITRO_PORT=6892 \
|
NITRO_PORT=6892 \
|
||||||
DATA_DIR=/data
|
DATA_DIR=/data
|
||||||
|
|||||||
+2
-2
@@ -55,7 +55,7 @@
|
|||||||
|
|
||||||
- **Drag, click, or Ctrl+V paste** — server-side WebP, Referer hotlink protection
|
- **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-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
|
- **Multi-backend storage** — local disk + S3-compatible (R2 / COS / OSS / AWS) + Telegram channel/group; 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
|
- **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
|
- **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`
|
- **Backup & migration** — full-site export/restore (`.phost.tar.gz`), cross-backend sync; restore from backup on uninitialized `/setup`
|
||||||
@@ -70,7 +70,7 @@
|
|||||||
| Backend | [Nitro](https://nitro.build) (`node-server`) · REST API |
|
| Backend | [Nitro](https://nitro.build) (`node-server`) · REST API |
|
||||||
| Data | SQLite · local `data/pichost.db` |
|
| Data | SQLite · local `data/pichost.db` |
|
||||||
| Images | [sharp](https://sharp.pixelplumbing.com) (server-side WebP) |
|
| 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.) |
|
| Storage | Local disk · S3-compatible ([AWS SDK](https://aws.amazon.com/sdk-for-javascript/) · R2 / COS / OSS, etc.) · Telegram channel/group |
|
||||||
| i18n | [@nuxtjs/i18n](https://i18n.nuxtjs.org) (zh-CN / English) |
|
| i18n | [@nuxtjs/i18n](https://i18n.nuxtjs.org) (zh-CN / English) |
|
||||||
| Quality & docs | [Vitest](https://vitest.dev) · [VitePress](https://vitepress.dev) docs site |
|
| Quality & docs | [Vitest](https://vitest.dev) · [VitePress](https://vitepress.dev) docs site |
|
||||||
| Deploy | Docker · Node.js 22 |
|
| Deploy | Docker · Node.js 22 |
|
||||||
|
|||||||
@@ -55,7 +55,7 @@
|
|||||||
|
|
||||||
- **拖拽 / 点击 / Ctrl+V 粘贴**上传;服务端 WebP 压缩、Referer 防盗链
|
- **拖拽 / 点击 / Ctrl+V 粘贴**上传;服务端 WebP 压缩、Referer 防盗链
|
||||||
- **多用户**:账号密码登录(滑块 / Turnstile / Cap 人机验证)、可选开放注册;普通用户仅见自己的图片
|
- **多用户**:账号密码登录(滑块 / Turnstile / Cap 人机验证)、可选开放注册;普通用户仅见自己的图片
|
||||||
- **多后端存储**:本地磁盘 + S3 兼容(R2 / COS / OSS / AWS);混合直链 `proxy` / `public`
|
- **多后端存储**:本地磁盘 + S3 兼容(R2 / COS / OSS / AWS)+ Telegram 频道/群组;混合直链 `proxy` / `public`
|
||||||
- **图库**:浏览、搜索、按存储/来源/标签筛选、网格/列表切换、批量删除与批量打标;统计概览与来源分布;点击图片查看详情与多格式链接
|
- **图库**:浏览、搜索、按存储/来源/标签筛选、网格/列表切换、批量删除与批量打标;统计概览与来源分布;点击图片查看详情与多格式链接
|
||||||
- **标签**:每用户独立标签、彩色标记;上传后单张/批量打标;设置页管理(新建、改色、合并、删除);API 支持 `tagIds` 与批量打标
|
- **标签**:每用户独立标签、彩色标记;上传后单张/批量打标;设置页管理(新建、改色、合并、删除);API 支持 `tagIds` 与批量打标
|
||||||
- **备份与迁移**:整站导出/恢复(`.phost.tar.gz`)、跨后端同步;setup 未初始化时可从备份恢复
|
- **备份与迁移**:整站导出/恢复(`.phost.tar.gz`)、跨后端同步;setup 未初始化时可从备份恢复
|
||||||
@@ -70,7 +70,7 @@
|
|||||||
| 后端 | [Nitro](https://nitro.build)(`node-server`)· REST API |
|
| 后端 | [Nitro](https://nitro.build)(`node-server`)· REST API |
|
||||||
| 数据 | SQLite · 本地 `data/pichost.db` |
|
| 数据 | SQLite · 本地 `data/pichost.db` |
|
||||||
| 图片处理 | [sharp](https://sharp.pixelplumbing.com)(服务端 WebP 压缩) |
|
| 图片处理 | [sharp](https://sharp.pixelplumbing.com)(服务端 WebP 压缩) |
|
||||||
| 存储 | 本地磁盘 · S3 兼容([AWS SDK](https://aws.amazon.com/sdk-for-javascript/) · R2 / COS / OSS 等) |
|
| 存储 | 本地磁盘 · S3 兼容([AWS SDK](https://aws.amazon.com/sdk-for-javascript/) · R2 / COS / OSS 等)· Telegram 频道/群组 |
|
||||||
| 国际化 | [@nuxtjs/i18n](https://i18n.nuxtjs.org)(简体中文 / English) |
|
| 国际化 | [@nuxtjs/i18n](https://i18n.nuxtjs.org)(简体中文 / English) |
|
||||||
| 质量与文档 | [Vitest](https://vitest.dev) · [VitePress](https://vitepress.dev) 文档站 |
|
| 质量与文档 | [Vitest](https://vitest.dev) · [VitePress](https://vitepress.dev) 文档站 |
|
||||||
| 部署 | Docker · Node.js 22 |
|
| 部署 | Docker · Node.js 22 |
|
||||||
|
|||||||
+24
-1
@@ -7,22 +7,44 @@
|
|||||||
|
|
||||||
.upload-card-flip-inner {
|
.upload-card-flip-inner {
|
||||||
position: relative;
|
position: relative;
|
||||||
transition: transform 0.55s cubic-bezier(0.4, 0, 0.2, 1);
|
transition:
|
||||||
|
transform 0.55s cubic-bezier(0.4, 0, 0.2, 1),
|
||||||
|
min-height 0.35s ease;
|
||||||
transform-style: preserve-3d;
|
transform-style: preserve-3d;
|
||||||
}
|
}
|
||||||
|
|
||||||
.upload-card-flip.is-flipped .upload-card-flip-inner {
|
.upload-card-flip.is-flipped .upload-card-flip-inner {
|
||||||
|
min-height: min(40rem, calc(100vh - 11rem));
|
||||||
transform: rotateY(180deg);
|
transform: rotateY(180deg);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.upload-card-flip--file.is-flipped .upload-card-flip-inner {
|
||||||
|
min-height: min(27rem, calc(100vh - 11rem));
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-card-flip.is-flipped .upload-card-face--front {
|
||||||
|
visibility: hidden;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-card-flip.is-flipped .upload-settings-btn {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
.upload-card-face {
|
.upload-card-face {
|
||||||
backface-visibility: hidden;
|
backface-visibility: hidden;
|
||||||
-webkit-backface-visibility: hidden;
|
-webkit-backface-visibility: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.upload-card-face--front {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
.upload-card-face--back {
|
.upload-card-face--back {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
|
z-index: 2;
|
||||||
transform: rotateY(180deg);
|
transform: rotateY(180deg);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -33,6 +55,7 @@
|
|||||||
|
|
||||||
.upload-card-flip.is-flipped .upload-card-flip-inner {
|
.upload-card-flip.is-flipped .upload-card-flip-inner {
|
||||||
transform: none;
|
transform: none;
|
||||||
|
min-height: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.upload-card-flip .upload-card-face--back {
|
.upload-card-flip .upload-card-face--back {
|
||||||
|
|||||||
@@ -70,6 +70,12 @@ async function submit() {
|
|||||||
secret.value = ''
|
secret.value = ''
|
||||||
await resetVerification()
|
await resetVerification()
|
||||||
toast.add({ title: t('auth.loginSuccess'), color: 'success' })
|
toast.add({ title: t('auth.loginSuccess'), color: 'success' })
|
||||||
|
} else if (result.needsPasswordReset) {
|
||||||
|
await resetVerification()
|
||||||
|
await router.push({
|
||||||
|
path: '/reset-password',
|
||||||
|
query: { username: result.resetUsername ?? username.value.trim() }
|
||||||
|
})
|
||||||
} else {
|
} else {
|
||||||
await resetVerification()
|
await resetVerification()
|
||||||
toast.add({
|
toast.add({
|
||||||
|
|||||||
+81
-29
@@ -13,16 +13,52 @@ const mobileUserOpen = ref(false)
|
|||||||
const navItems = computed(() => {
|
const navItems = computed(() => {
|
||||||
const items = [
|
const items = [
|
||||||
{ label: t('nav.api'), to: '/api', icon: 'i-lucide-code-2' },
|
{ label: t('nav.api'), to: '/api', icon: 'i-lucide-code-2' },
|
||||||
{ label: t('nav.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) {
|
if (isAdmin.value) {
|
||||||
|
items.push({ label: t('nav.users'), to: '/users', icon: 'i-lucide-users' })
|
||||||
items.push({ label: t('nav.storage'), to: '/storage', icon: 'i-lucide-hard-drive' })
|
items.push({ label: t('nav.storage'), to: '/storage', icon: 'i-lucide-hard-drive' })
|
||||||
}
|
}
|
||||||
items.push({ label: t('nav.settings'), to: '/settings', icon: 'i-lucide-settings' })
|
items.push({ label: t('nav.settings'), to: '/settings', icon: 'i-lucide-settings' })
|
||||||
return items
|
return items
|
||||||
})
|
})
|
||||||
|
|
||||||
|
/** 三个上传入口,与顶部 Logo 并列 */
|
||||||
|
const hostItems = computed(() => [
|
||||||
|
{
|
||||||
|
label: t('host.image'),
|
||||||
|
to: '/',
|
||||||
|
icon: 'i-lucide-image',
|
||||||
|
title: t('host.imageFull')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t('host.video'),
|
||||||
|
to: '/videos',
|
||||||
|
icon: 'i-lucide-film',
|
||||||
|
title: t('host.videoFull')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t('host.file'),
|
||||||
|
to: '/files',
|
||||||
|
icon: 'i-lucide-folder-open',
|
||||||
|
title: t('host.fileFull')
|
||||||
|
}
|
||||||
|
])
|
||||||
|
|
||||||
|
const mobileNavItems = computed(() => [...hostItems.value, ...navItems.value])
|
||||||
|
|
||||||
|
function isHostActive(to: string) {
|
||||||
|
return route.path === to || route.path.startsWith(`${to}/`)
|
||||||
|
}
|
||||||
|
|
||||||
const accountMenuItems = computed<DropdownMenuItem[]>(() => [
|
const accountMenuItems = computed<DropdownMenuItem[]>(() => [
|
||||||
|
{
|
||||||
|
label: t('profile.title'),
|
||||||
|
icon: 'i-lucide-user-round',
|
||||||
|
to: '/account/profile'
|
||||||
|
},
|
||||||
{
|
{
|
||||||
label: t('nav.changePassword'),
|
label: t('nav.changePassword'),
|
||||||
icon: 'i-lucide-key-round',
|
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">
|
<header class="sticky top-0 z-40 border-b border-default bg-default/80 backdrop-blur">
|
||||||
<div class="mx-auto max-w-7xl px-4 py-2.5 sm:px-6">
|
<div class="mx-auto max-w-7xl px-4 py-2.5 sm:px-6">
|
||||||
<div class="flex items-center justify-between gap-3">
|
<div class="flex items-center justify-between gap-3">
|
||||||
<NuxtLink
|
<div class="flex min-w-0 items-center gap-2 sm:gap-3">
|
||||||
to="/"
|
<NuxtLink
|
||||||
:title="t('nav.backToUpload')"
|
to="/"
|
||||||
class="group flex min-w-0 shrink items-center gap-2.5 transition-opacity hover:opacity-90 sm:gap-3"
|
: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="logoLight"
|
|
||||||
alt="PicHost"
|
|
||||||
class="h-9 w-auto shrink-0 object-contain sm:h-11 dark:hidden"
|
|
||||||
decoding="async"
|
|
||||||
draggable="false"
|
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
:src="logoDark"
|
:src="logoLight"
|
||||||
alt="PicHost"
|
alt="PicHost"
|
||||||
class="hidden h-9 w-auto shrink-0 object-contain sm:h-11 dark:block"
|
class="h-9 w-auto shrink-0 object-contain sm:h-11 dark:hidden"
|
||||||
decoding="async"
|
decoding="async"
|
||||||
draggable="false"
|
draggable="false"
|
||||||
>
|
>
|
||||||
<div class="min-w-0 leading-tight">
|
<img
|
||||||
<p class="text-[15px] font-bold tracking-tight sm:text-base">
|
:src="logoDark"
|
||||||
<span class="text-highlighted">Pic</span><span class="text-primary">Host</span>
|
alt="PicHost"
|
||||||
</p>
|
class="hidden h-9 w-auto shrink-0 object-contain sm:h-11 dark:block"
|
||||||
<p class="mt-0.5 hidden text-[11px] text-muted min-[380px]:block sm:text-xs">
|
decoding="async"
|
||||||
{{ t('app.tagline') }}
|
draggable="false"
|
||||||
</p>
|
>
|
||||||
</div>
|
<div class="min-w-0 leading-tight">
|
||||||
</NuxtLink>
|
<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>
|
<ClientOnly>
|
||||||
<div class="flex shrink-0 items-center gap-0.5 sm:gap-1">
|
<div class="flex shrink-0 items-center gap-0.5 sm:gap-1">
|
||||||
@@ -124,7 +176,7 @@ async function handleLogout() {
|
|||||||
/>
|
/>
|
||||||
<MobileNavMenu
|
<MobileNavMenu
|
||||||
v-model:open="mobileNavOpen"
|
v-model:open="mobileNavOpen"
|
||||||
:items="navItems"
|
:items="mobileNavItems"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,4 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import 'cap-widget'
|
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
apiEndpoint: string
|
apiEndpoint: string
|
||||||
}>()
|
}>()
|
||||||
@@ -13,6 +11,19 @@ const emit = defineEmits<{
|
|||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
|
||||||
const widgetRef = ref<HTMLElement | null>(null)
|
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) {
|
function onSolve(event: Event) {
|
||||||
const token = (event as CustomEvent<{ token: string }>).detail?.token
|
const token = (event as CustomEvent<{ token: string }>).detail?.token
|
||||||
@@ -37,6 +48,7 @@ defineExpose({ reset })
|
|||||||
<template>
|
<template>
|
||||||
<ClientOnly>
|
<ClientOnly>
|
||||||
<cap-widget
|
<cap-widget
|
||||||
|
v-if="ready"
|
||||||
ref="widgetRef"
|
ref="widgetRef"
|
||||||
:data-cap-api-endpoint="props.apiEndpoint"
|
:data-cap-api-endpoint="props.apiEndpoint"
|
||||||
:data-cap-i18n-initial-state="t('auth.capInitialState')"
|
:data-cap-i18n-initial-state="t('auth.capInitialState')"
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -1,10 +1,16 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import type { MediaType } from '~/types/media'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
open: boolean
|
open: boolean
|
||||||
count: number
|
count: number
|
||||||
loading?: boolean
|
loading?: boolean
|
||||||
|
/** 删除对象的类型,决定确认文案 */
|
||||||
|
kind?: MediaType
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
|
const kind = computed<MediaType>(() => props.kind ?? 'image')
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
'update:open': [value: boolean]
|
'update:open': [value: boolean]
|
||||||
'confirm': []
|
'confirm': []
|
||||||
@@ -12,7 +18,16 @@ const emit = defineEmits<{
|
|||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
|
||||||
const description = computed(() => t('delete.confirmDesc', { count: props.count }))
|
const description = computed(() => {
|
||||||
|
switch (kind.value) {
|
||||||
|
case 'video':
|
||||||
|
return t('delete.confirmDescVideo', { count: props.count })
|
||||||
|
case 'file':
|
||||||
|
return t('delete.confirmDescFile', { count: props.count })
|
||||||
|
default:
|
||||||
|
return t('delete.confirmDesc', { count: props.count })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
function close() {
|
function close() {
|
||||||
emit('update:open', false)
|
emit('update:open', false)
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
+20
-22
@@ -1,33 +1,31 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
export interface GalleryStatsData {
|
import type { LibraryStatsData, MediaType } from '~/types/media'
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = withDefaults(defineProps<{
|
||||||
stats: GalleryStatsData | null
|
stats: LibraryStatsData | null
|
||||||
|
/** 统计口径:图片 / 视频 / 文件各自独立 */
|
||||||
|
kind?: MediaType
|
||||||
loading?: boolean
|
loading?: boolean
|
||||||
}>()
|
}>(), {
|
||||||
|
kind: 'image'
|
||||||
|
})
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const { formatFileSize } = useFileSize()
|
const { formatFileSize } = useFileSize()
|
||||||
|
|
||||||
const SOURCE_COLORS = ['var(--ui-primary)', '#10b981'] as const
|
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 sourceItems = computed(() => {
|
||||||
const bySource = props.stats?.bySource
|
const bySource = props.stats?.bySource
|
||||||
if (!bySource) return []
|
if (!bySource) return []
|
||||||
@@ -147,7 +145,7 @@ function displayValue(value: number | undefined) {
|
|||||||
{{ displayValue(stats?.uploadTotal) }}
|
{{ displayValue(stats?.uploadTotal) }}
|
||||||
</p>
|
</p>
|
||||||
<p class="mt-2 text-xs text-muted">
|
<p class="mt-2 text-xs text-muted">
|
||||||
{{ t('stats.totalFiles') }}
|
{{ totalHint }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -17,7 +17,7 @@ const emit = defineEmits<{
|
|||||||
'created': [tag: ImageTag]
|
'created': [tag: ImageTag]
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const COMMON_LIMIT = 12
|
const COMMON_LIMIT = 10
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
const { createTag } = useTags()
|
const { createTag } = useTags()
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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 uploadCardBg from '~/assets/image/upload-card-bg.webp'
|
||||||
import uploadCardHero from '~/assets/image/upload-card-hero.webp'
|
import uploadCardHero from '~/assets/image/upload-card-hero.webp'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = withDefaults(defineProps<{
|
||||||
disabled?: boolean
|
disabled?: boolean
|
||||||
}>()
|
title?: string
|
||||||
|
hint?: string
|
||||||
|
formats?: string
|
||||||
|
accept?: string
|
||||||
|
heroImage?: string
|
||||||
|
backgroundImage?: string
|
||||||
|
showSettings?: boolean
|
||||||
|
pasteEnabled?: boolean
|
||||||
|
/** 拖拽/选择时的类型限制;图片页与视频页各自限定,文件页放开 */
|
||||||
|
mediaKind?: 'image' | 'video' | 'any'
|
||||||
|
}>(), {
|
||||||
|
accept: 'image/jpeg,image/png,image/webp,image/gif,image/svg+xml,image/x-icon,.svg,.ico',
|
||||||
|
showSettings: true,
|
||||||
|
pasteEnabled: true,
|
||||||
|
mediaKind: 'image'
|
||||||
|
})
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
'upload': [files: File[]]
|
'upload': [files: File[]]
|
||||||
@@ -18,9 +33,22 @@ const { t } = useI18n()
|
|||||||
const { bindPaste } = useClipboardImage(ref(null))
|
const { bindPaste } = useClipboardImage(ref(null))
|
||||||
|
|
||||||
bindPaste((files) => {
|
bindPaste((files) => {
|
||||||
if (!props.disabled) emit('upload', files)
|
if (props.disabled || !props.pasteEnabled) return
|
||||||
|
emit('upload', files)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const heroSource = computed(() => props.heroImage ?? uploadCardHero)
|
||||||
|
const backgroundSource = computed(() => props.backgroundImage ?? uploadCardBg)
|
||||||
|
const titleText = computed(() => props.title ?? t('upload.title'))
|
||||||
|
const hintText = computed(() => props.hint ?? t('upload.hint', { ctrl: 'Ctrl', v: 'V' }))
|
||||||
|
const formatsText = computed(() => props.formats ?? t('upload.formats'))
|
||||||
|
|
||||||
|
function matchesMediaKind(file: File): boolean {
|
||||||
|
if (props.mediaKind === 'any') return true
|
||||||
|
if (!file.type) return true
|
||||||
|
return file.type.startsWith(`${props.mediaKind}/`)
|
||||||
|
}
|
||||||
|
|
||||||
function openFilePicker() {
|
function openFilePicker() {
|
||||||
if (props.disabled) return
|
if (props.disabled) return
|
||||||
fileInput.value?.click()
|
fileInput.value?.click()
|
||||||
@@ -38,8 +66,7 @@ function onDrop(event: DragEvent) {
|
|||||||
dragging.value = false
|
dragging.value = false
|
||||||
if (props.disabled) return
|
if (props.disabled) return
|
||||||
|
|
||||||
const files = Array.from(event.dataTransfer?.files ?? [])
|
const files = Array.from(event.dataTransfer?.files ?? []).filter(matchesMediaKind)
|
||||||
.filter(file => file.type.startsWith('image/'))
|
|
||||||
if (files.length) emit('upload', files)
|
if (files.length) emit('upload', files)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -83,7 +110,7 @@ function onKeydown(event: KeyboardEvent) {
|
|||||||
<input
|
<input
|
||||||
ref="fileInput"
|
ref="fileInput"
|
||||||
type="file"
|
type="file"
|
||||||
accept="image/jpeg,image/png,image/webp,image/gif,image/svg+xml,image/x-icon,.svg,.ico"
|
:accept="accept"
|
||||||
multiple
|
multiple
|
||||||
class="hidden"
|
class="hidden"
|
||||||
:disabled="disabled"
|
:disabled="disabled"
|
||||||
@@ -91,7 +118,7 @@ function onKeydown(event: KeyboardEvent) {
|
|||||||
>
|
>
|
||||||
|
|
||||||
<img
|
<img
|
||||||
:src="uploadCardBg"
|
:src="backgroundSource"
|
||||||
alt=""
|
alt=""
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
class="pointer-events-none absolute inset-0 size-full object-cover opacity-100 dark:opacity-[0.12]"
|
class="pointer-events-none absolute inset-0 size-full object-cover opacity-100 dark:opacity-[0.12]"
|
||||||
@@ -99,7 +126,10 @@ function onKeydown(event: KeyboardEvent) {
|
|||||||
draggable="false"
|
draggable="false"
|
||||||
>
|
>
|
||||||
|
|
||||||
<div class="absolute top-3 right-3 z-20">
|
<div
|
||||||
|
v-if="showSettings"
|
||||||
|
class="absolute top-3 right-3 z-20"
|
||||||
|
>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="upload-settings-btn inline-flex size-8 items-center justify-center rounded-md bg-default/70 text-muted backdrop-blur-sm transition-colors hover:bg-elevated hover:text-highlighted disabled:pointer-events-none disabled:opacity-40"
|
class="upload-settings-btn inline-flex size-8 items-center justify-center rounded-md bg-default/70 text-muted backdrop-blur-sm transition-colors hover:bg-elevated hover:text-highlighted disabled:pointer-events-none disabled:opacity-40"
|
||||||
@@ -117,7 +147,7 @@ function onKeydown(event: KeyboardEvent) {
|
|||||||
<div class="relative z-10 mx-auto flex max-w-lg flex-col items-center">
|
<div class="relative z-10 mx-auto flex max-w-lg flex-col items-center">
|
||||||
<div class="flex flex-col items-center">
|
<div class="flex flex-col items-center">
|
||||||
<img
|
<img
|
||||||
:src="uploadCardHero"
|
:src="heroSource"
|
||||||
alt=""
|
alt=""
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
class="h-24 w-auto -mb-3 select-none transition-transform duration-200 ease-[cubic-bezier(0.4,0,0.2,1)] sm:-mb-4 sm:h-28 md:h-32"
|
class="h-24 w-auto -mb-3 select-none transition-transform duration-200 ease-[cubic-bezier(0.4,0,0.2,1)] sm:-mb-4 sm:h-28 md:h-32"
|
||||||
@@ -128,16 +158,16 @@ function onKeydown(event: KeyboardEvent) {
|
|||||||
|
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<p class="text-xl font-semibold tracking-tight text-highlighted sm:text-2xl">
|
<p class="text-xl font-semibold tracking-tight text-highlighted sm:text-2xl">
|
||||||
{{ t('upload.title') }}
|
{{ titleText }}
|
||||||
</p>
|
</p>
|
||||||
<p class="text-sm text-muted sm:text-base">
|
<p class="text-sm text-muted sm:text-base">
|
||||||
{{ t('upload.hint', { ctrl: 'Ctrl', v: 'V' }) }}
|
{{ hintText }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p class="mt-4 max-w-md text-xs leading-relaxed text-dimmed sm:text-sm">
|
<p class="mt-4 max-w-md text-xs leading-relaxed text-dimmed sm:text-sm">
|
||||||
{{ t('upload.formats') }}
|
{{ formatsText }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1,7 +1,20 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
defineProps<{
|
import type { UploadMediaKind } from '~/composables/useUploadPreferences'
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<{
|
||||||
disabled?: boolean
|
disabled?: boolean
|
||||||
}>()
|
/** 上传页类型:决定默认可选文件类型、粘贴支持与偏好面板内容 */
|
||||||
|
kind?: UploadMediaKind
|
||||||
|
title?: string
|
||||||
|
hint?: string
|
||||||
|
formats?: string
|
||||||
|
accept?: string
|
||||||
|
heroImage?: string
|
||||||
|
backgroundImage?: string
|
||||||
|
pasteEnabled?: boolean
|
||||||
|
}>(), {
|
||||||
|
kind: 'image'
|
||||||
|
})
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
upload: [files: File[]]
|
upload: [files: File[]]
|
||||||
@@ -9,6 +22,15 @@ const emit = defineEmits<{
|
|||||||
|
|
||||||
const flipped = ref(false)
|
const flipped = ref(false)
|
||||||
|
|
||||||
|
const mediaKind = computed(() => (props.kind === 'file' ? 'any' : props.kind))
|
||||||
|
const pasteEnabled = computed(() => props.pasteEnabled ?? props.kind === 'image')
|
||||||
|
const defaultAccept = computed(() => {
|
||||||
|
if (props.kind === 'video') return 'video/*,.mp4,.webm,.mov,.mkv,.avi,.m4v'
|
||||||
|
if (props.kind === 'file') return ''
|
||||||
|
return undefined
|
||||||
|
})
|
||||||
|
const accept = computed(() => props.accept ?? defaultAccept.value)
|
||||||
|
|
||||||
function onOpenSettings() {
|
function onOpenSettings() {
|
||||||
flipped.value = true
|
flipped.value = true
|
||||||
}
|
}
|
||||||
@@ -21,18 +43,29 @@ function onBack() {
|
|||||||
<template>
|
<template>
|
||||||
<div
|
<div
|
||||||
class="upload-card-flip"
|
class="upload-card-flip"
|
||||||
:class="{ 'is-flipped': flipped }"
|
:class="[`upload-card-flip--${kind}`, { 'is-flipped': flipped }]"
|
||||||
>
|
>
|
||||||
<div class="upload-card-flip-inner">
|
<div class="upload-card-flip-inner">
|
||||||
<div class="upload-card-face upload-card-face--front">
|
<div class="upload-card-face upload-card-face--front">
|
||||||
<ImageUploader
|
<UploadCard
|
||||||
:disabled="disabled"
|
:disabled="disabled"
|
||||||
|
:title="title"
|
||||||
|
:hint="hint"
|
||||||
|
:formats="formats"
|
||||||
|
:accept="accept"
|
||||||
|
:hero-image="heroImage"
|
||||||
|
:background-image="backgroundImage"
|
||||||
|
:media-kind="mediaKind"
|
||||||
|
:paste-enabled="pasteEnabled"
|
||||||
@upload="emit('upload', $event)"
|
@upload="emit('upload', $event)"
|
||||||
@open-settings="onOpenSettings"
|
@open-settings="onOpenSettings"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="upload-card-face upload-card-face--back">
|
<div class="upload-card-face upload-card-face--back">
|
||||||
<UploadPreferencesPanel @back="onBack" />
|
<UploadPreferencesPanel
|
||||||
|
:kind="kind"
|
||||||
|
@back="onBack"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,16 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import {
|
||||||
|
normalizeUploadTagNames,
|
||||||
|
type UploadMediaKind
|
||||||
|
} from '~/composables/useUploadPreferences'
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<{
|
||||||
|
/** 每个上传页各自一份偏好 */
|
||||||
|
kind?: UploadMediaKind
|
||||||
|
}>(), {
|
||||||
|
kind: 'image'
|
||||||
|
})
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
back: []
|
back: []
|
||||||
}>()
|
}>()
|
||||||
@@ -8,10 +20,43 @@ const {
|
|||||||
autoCopyMarkdown,
|
autoCopyMarkdown,
|
||||||
copyFormat,
|
copyFormat,
|
||||||
clientWebpQuality,
|
clientWebpQuality,
|
||||||
|
uploadTagNames,
|
||||||
|
transcodeToHls,
|
||||||
loadPreferences
|
loadPreferences
|
||||||
} = useUploadPreferences()
|
} = useUploadPreferences(props.kind)
|
||||||
|
const uploadTagDraft = ref('')
|
||||||
|
|
||||||
|
/** 视频床 / 文件床没有图片压缩相关选项 */
|
||||||
|
const isImageKind = computed(() => props.kind === 'image')
|
||||||
|
const isVideoKind = computed(() => props.kind === 'video')
|
||||||
|
const processingGridClass = computed(() => {
|
||||||
|
if (isImageKind.value || isVideoKind.value) return 'md:col-start-1 md:row-start-1'
|
||||||
|
return ''
|
||||||
|
})
|
||||||
|
const qualityGridClass = computed(() => {
|
||||||
|
if (isImageKind.value) return 'md:col-start-1 md:row-start-2'
|
||||||
|
return ''
|
||||||
|
})
|
||||||
|
const convenienceGridClass = computed(() => {
|
||||||
|
if (isImageKind.value) return 'md:col-start-2 md:row-start-1'
|
||||||
|
if (isVideoKind.value) return 'md:col-start-1 md:row-start-2'
|
||||||
|
return ''
|
||||||
|
})
|
||||||
|
const tagsGridClass = computed(() => {
|
||||||
|
if (isImageKind.value) return 'md:col-start-2 md:row-start-2'
|
||||||
|
if (isVideoKind.value) return 'md:col-start-2 md:row-start-1 md:row-span-2'
|
||||||
|
return ''
|
||||||
|
})
|
||||||
|
const tagsLayoutClass = computed(() => [
|
||||||
|
tagsGridClass.value
|
||||||
|
])
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
const {
|
||||||
|
lowMemory,
|
||||||
|
memoryBytes,
|
||||||
|
load: loadTranscodeSettings
|
||||||
|
} = useTranscodeSettings()
|
||||||
|
|
||||||
const {
|
const {
|
||||||
userSettings,
|
userSettings,
|
||||||
@@ -33,6 +78,12 @@ const clientQualityLevel = computed(() => {
|
|||||||
return t('preferences.qualityLow')
|
return t('preferences.qualityLow')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const memoryGb = computed(() => (
|
||||||
|
memoryBytes.value > 0
|
||||||
|
? (memoryBytes.value / (1024 * 1024 * 1024)).toFixed(1)
|
||||||
|
: '—'
|
||||||
|
))
|
||||||
|
|
||||||
function setCopyFormat(value: 'url' | 'markdown' | 'html' | 'bbcode') {
|
function setCopyFormat(value: 'url' | 'markdown' | 'html' | 'bbcode') {
|
||||||
copyFormat.value = value
|
copyFormat.value = value
|
||||||
}
|
}
|
||||||
@@ -42,9 +93,39 @@ function onClientQualityInput(event: Event) {
|
|||||||
clientWebpQuality.value = Number.isFinite(value) ? value : 80
|
clientWebpQuality.value = Number.isFinite(value) ? value : 80
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function commitUploadTags() {
|
||||||
|
const names = normalizeUploadTagNames(uploadTagDraft.value)
|
||||||
|
if (!names.length) return
|
||||||
|
uploadTagNames.value = normalizeUploadTagNames([
|
||||||
|
...uploadTagNames.value,
|
||||||
|
...names
|
||||||
|
])
|
||||||
|
uploadTagDraft.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function onUploadTagKeydown(event: KeyboardEvent) {
|
||||||
|
if (event.key === 'Enter' || event.key === ',' || event.key === ',') {
|
||||||
|
event.preventDefault()
|
||||||
|
commitUploadTags()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
event.key === 'Backspace'
|
||||||
|
&& !uploadTagDraft.value
|
||||||
|
&& uploadTagNames.value.length
|
||||||
|
) {
|
||||||
|
uploadTagNames.value = uploadTagNames.value.slice(0, -1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeUploadTag(name: string) {
|
||||||
|
uploadTagNames.value = uploadTagNames.value.filter(item => item !== name)
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
loadPreferences()
|
loadPreferences()
|
||||||
void loadUserSettings()
|
void loadUserSettings()
|
||||||
|
void loadTranscodeSettings()
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -77,9 +158,15 @@ onMounted(() => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex flex-1 flex-col justify-center p-5 sm:p-6">
|
<div class="flex flex-1 flex-col justify-start overflow-y-auto p-5 sm:p-6">
|
||||||
<div class="grid gap-5 sm:grid-cols-3 sm:gap-6">
|
<div
|
||||||
<div class="space-y-4">
|
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">
|
<h3 class="text-sm font-semibold">
|
||||||
{{ t('preferences.processing') }}
|
{{ t('preferences.processing') }}
|
||||||
</h3>
|
</h3>
|
||||||
@@ -137,7 +224,50 @@ onMounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="space-y-4">
|
<div
|
||||||
|
v-if="isVideoKind"
|
||||||
|
class="space-y-4 rounded-xl border border-default/70 bg-muted/10 p-4"
|
||||||
|
:class="processingGridClass"
|
||||||
|
>
|
||||||
|
<h3 class="text-sm font-semibold">
|
||||||
|
{{ t('preferences.videoProcessing') }}
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<UAlert
|
||||||
|
v-if="lowMemory"
|
||||||
|
color="warning"
|
||||||
|
variant="subtle"
|
||||||
|
icon="i-lucide-triangle-alert"
|
||||||
|
:title="t('preferences.lowMemoryTranscodeTitle')"
|
||||||
|
:description="t('preferences.lowMemoryTranscodeHint', { memory: memoryGb })"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<label class="flex cursor-pointer items-start gap-2.5">
|
||||||
|
<UCheckbox
|
||||||
|
v-model="transcodeToHls"
|
||||||
|
class="mt-0.5"
|
||||||
|
/>
|
||||||
|
<span>
|
||||||
|
<span class="block text-sm">{{ t('preferences.transcodeToHls') }}</span>
|
||||||
|
<span class="mt-1 block text-xs leading-relaxed text-muted">
|
||||||
|
{{ t('preferences.transcodeToHlsHint') }}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="transcodeToHls"
|
||||||
|
class="ml-7 space-y-3 border-t border-default pt-4"
|
||||||
|
>
|
||||||
|
<TranscodeSettingsControls />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="isImageKind"
|
||||||
|
class="space-y-4 rounded-xl border border-default/70 bg-muted/10 p-4"
|
||||||
|
:class="qualityGridClass"
|
||||||
|
>
|
||||||
<h3 class="text-sm font-semibold">
|
<h3 class="text-sm font-semibold">
|
||||||
{{ t('preferences.webpQuality') }}
|
{{ t('preferences.webpQuality') }}
|
||||||
</h3>
|
</h3>
|
||||||
@@ -158,7 +288,10 @@ onMounted(() => {
|
|||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="space-y-4">
|
<div
|
||||||
|
class="space-y-4 rounded-xl border border-default/70 bg-muted/10 p-4"
|
||||||
|
:class="convenienceGridClass"
|
||||||
|
>
|
||||||
<h3 class="text-sm font-semibold">
|
<h3 class="text-sm font-semibold">
|
||||||
{{ t('preferences.convenience') }}
|
{{ t('preferences.convenience') }}
|
||||||
</h3>
|
</h3>
|
||||||
@@ -190,6 +323,57 @@ onMounted(() => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="space-y-3 rounded-xl border border-default/70 bg-muted/10 p-4"
|
||||||
|
:class="tagsLayoutClass"
|
||||||
|
>
|
||||||
|
<div class="space-y-3">
|
||||||
|
<h3 class="text-sm font-semibold">
|
||||||
|
{{ t('preferences.uploadTags') }}
|
||||||
|
</h3>
|
||||||
|
<p class="text-xs leading-relaxed text-muted">
|
||||||
|
{{ t('preferences.uploadTagsHint') }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<UInput
|
||||||
|
v-model="uploadTagDraft"
|
||||||
|
size="md"
|
||||||
|
icon="i-lucide-tags"
|
||||||
|
maxlength="32"
|
||||||
|
:placeholder="t('preferences.uploadTagsPlaceholder')"
|
||||||
|
@keydown="onUploadTagKeydown"
|
||||||
|
@blur="commitUploadTags"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
v-if="uploadTagNames.length"
|
||||||
|
class="space-y-2 pt-1"
|
||||||
|
>
|
||||||
|
<p class="text-xs font-medium text-muted">
|
||||||
|
{{ t('preferences.uploadTagsPending', { n: uploadTagNames.length }) }}
|
||||||
|
</p>
|
||||||
|
<div class="flex flex-wrap gap-2.5">
|
||||||
|
<span
|
||||||
|
v-for="name in uploadTagNames"
|
||||||
|
:key="name"
|
||||||
|
class="relative inline-flex max-w-full items-center rounded-full border border-primary/35 bg-primary/10 px-3 py-1 text-xs font-medium text-primary"
|
||||||
|
>
|
||||||
|
<span class="truncate">{{ name }}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="absolute -top-1.5 -right-1.5 inline-flex size-4 items-center justify-center rounded-full border border-primary/40 bg-elevated text-primary shadow-sm transition-colors hover:bg-primary hover:text-inverted"
|
||||||
|
:aria-label="t('preferences.uploadTagsRemove', { name })"
|
||||||
|
@click="removeUploadTag(name)"
|
||||||
|
>
|
||||||
|
<UIcon
|
||||||
|
name="i-lucide-x"
|
||||||
|
class="size-2.5"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -65,8 +65,14 @@ function resetForm() {
|
|||||||
if (!isDebugParam(param)) continue
|
if (!isDebugParam(param)) continue
|
||||||
if (param.type === 'file') {
|
if (param.type === 'file') {
|
||||||
files[param.name] = null
|
files[param.name] = null
|
||||||
|
} else if (param.name === 'kind') {
|
||||||
|
values[param.name] = props.endpoint.category === 'video' ? 'video' : 'file'
|
||||||
} else if (param.name === 'keys') {
|
} 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) {
|
} else if (param.name === 'token' && props.token) {
|
||||||
values[param.name] = props.token
|
values[param.name] = props.token
|
||||||
} else {
|
} else {
|
||||||
@@ -84,6 +90,12 @@ function fileLabel(name: string) {
|
|||||||
return fileValues.value[name]?.name ?? t('api.debug.noFileSelected')
|
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) {
|
function triggerFilePick(name: string) {
|
||||||
fileInputRefs.value[name]?.click()
|
fileInputRefs.value[name]?.click()
|
||||||
}
|
}
|
||||||
@@ -101,8 +113,8 @@ function isParamFilled(param: ApiDocParam) {
|
|||||||
if (!value) return false
|
if (!value) return false
|
||||||
if (param.location === 'body' && param.name === 'keys') {
|
if (param.location === 'body' && param.name === 'keys') {
|
||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(value) as { keys?: unknown }
|
const parsed = JSON.parse(value) as unknown
|
||||||
return Array.isArray(parsed.keys) && parsed.keys.length > 0
|
return Array.isArray(parsed) && parsed.length > 0
|
||||||
} catch {
|
} catch {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
@@ -159,7 +171,14 @@ async function sendRequest() {
|
|||||||
} else if (debugBodyParams.value.some(p => p.location === 'body')) {
|
} else if (debugBodyParams.value.some(p => p.location === 'body')) {
|
||||||
headers['Content-Type'] = 'application/json'
|
headers['Content-Type'] = 'application/json'
|
||||||
try {
|
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 {
|
} catch {
|
||||||
toast.add({ title: t('api.debug.invalidJson'), color: 'error' })
|
toast.add({ title: t('api.debug.invalidJson'), color: 'error' })
|
||||||
sending.value = false
|
sending.value = false
|
||||||
@@ -297,7 +316,7 @@ const responseScrollable = computed(() => responseBody.value.split('\n').length
|
|||||||
<input
|
<input
|
||||||
:ref="(el) => { fileInputRefs[param.name] = el as HTMLInputElement }"
|
:ref="(el) => { fileInputRefs[param.name] = el as HTMLInputElement }"
|
||||||
type="file"
|
type="file"
|
||||||
accept="image/*"
|
:accept="fileAccept()"
|
||||||
class="hidden"
|
class="hidden"
|
||||||
@change="onFileChange(param.name, $event)"
|
@change="onFileChange(param.name, $event)"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -20,9 +20,18 @@ const docsUrl = computed(() =>
|
|||||||
|
|
||||||
const categories: { key: ApiDocCategory, labelKey: string }[] = [
|
const categories: { key: ApiDocCategory, labelKey: string }[] = [
|
||||||
{ key: 'image', labelKey: 'api.categories.image' },
|
{ 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: 'extension', labelKey: 'api.categories.extension' },
|
||||||
{ key: 'system', labelKey: 'api.categories.system' }
|
{ 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 filteredEndpoints = computed(() => {
|
||||||
const q = search.value.trim().toLowerCase()
|
const q = search.value.trim().toLowerCase()
|
||||||
@@ -38,6 +47,17 @@ function endpointsForCategory(category: ApiDocCategory) {
|
|||||||
return filteredEndpoints.value.filter(endpoint => endpoint.category === category)
|
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) {
|
function onSelect(id: string) {
|
||||||
emit('select', id)
|
emit('select', id)
|
||||||
}
|
}
|
||||||
@@ -59,6 +79,15 @@ onMounted(() => {
|
|||||||
window.addEventListener('keydown', onGlobalKeydown)
|
window.addEventListener('keydown', onGlobalKeydown)
|
||||||
onUnmounted(() => window.removeEventListener('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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -89,13 +118,24 @@ onMounted(() => {
|
|||||||
:key="category.key"
|
:key="category.key"
|
||||||
class="mb-4"
|
class="mb-4"
|
||||||
>
|
>
|
||||||
<p
|
<button
|
||||||
v-if="endpointsForCategory(category.key).length"
|
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
|
<li
|
||||||
v-for="endpoint in endpointsForCategory(category.key)"
|
v-for="endpoint in endpointsForCategory(category.key)"
|
||||||
:key="endpoint.id"
|
:key="endpoint.id"
|
||||||
|
|||||||
@@ -14,11 +14,13 @@ const props = withDefaults(defineProps<{
|
|||||||
loading?: boolean
|
loading?: boolean
|
||||||
showBatchDelete?: boolean
|
showBatchDelete?: boolean
|
||||||
showBatchTags?: boolean
|
showBatchTags?: boolean
|
||||||
|
showBatchSync?: boolean
|
||||||
}>(), {
|
}>(), {
|
||||||
selectedCount: 0,
|
selectedCount: 0,
|
||||||
loading: false,
|
loading: false,
|
||||||
showBatchDelete: true,
|
showBatchDelete: true,
|
||||||
showBatchTags: false,
|
showBatchTags: false,
|
||||||
|
showBatchSync: false,
|
||||||
tagIds: () => [],
|
tagIds: () => [],
|
||||||
tags: () => [],
|
tags: () => [],
|
||||||
tagMode: 'or',
|
tagMode: 'or',
|
||||||
@@ -37,6 +39,7 @@ const emit = defineEmits<{
|
|||||||
'reset': []
|
'reset': []
|
||||||
'batch-delete': []
|
'batch-delete': []
|
||||||
'batch-tags': []
|
'batch-tags': []
|
||||||
|
'batch-sync': []
|
||||||
'manage-tags': []
|
'manage-tags': []
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
@@ -155,6 +158,20 @@ function viewButtonClass(active: boolean) {
|
|||||||
{{ t('stats.batchDelete') }}
|
{{ t('stats.batchDelete') }}
|
||||||
</UButton>
|
</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
|
<div
|
||||||
class="inline-flex shrink-0 overflow-hidden rounded-md border border-default"
|
class="inline-flex shrink-0 overflow-hidden rounded-md border border-default"
|
||||||
role="group"
|
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
|
checking: boolean
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const emit = defineEmits<{
|
// 「检查更新」已停用(改动较大,与上游版本号已不对应),暂时保留事件定义
|
||||||
|
defineEmits<{
|
||||||
checkUpdate: []
|
checkUpdate: []
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
@@ -28,6 +29,7 @@ const { t } = useI18n()
|
|||||||
<span class="text-sm font-medium tabular-nums text-highlighted">
|
<span class="text-sm font-medium tabular-nums text-highlighted">
|
||||||
v{{ appVersion }}
|
v{{ appVersion }}
|
||||||
</span>
|
</span>
|
||||||
|
<!-- 检查更新功能已停用,需要时恢复下面这段
|
||||||
<UBadge
|
<UBadge
|
||||||
v-if="updateAvailable && latestVersion"
|
v-if="updateAvailable && latestVersion"
|
||||||
color="warning"
|
color="warning"
|
||||||
@@ -63,6 +65,7 @@ const { t } = useI18n()
|
|||||||
>
|
>
|
||||||
{{ t('settings.viewReleases') }}
|
{{ t('settings.viewReleases') }}
|
||||||
</a>
|
</a>
|
||||||
|
-->
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -1,11 +1,16 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
type PolicySource = 'env' | 'db' | 'default'
|
type PolicySource = 'env' | 'db' | 'default'
|
||||||
type MimeType = 'image/jpeg' | 'image/png' | 'image/webp' | 'image/gif' | 'image/svg+xml' | 'image/x-icon'
|
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'
|
part: 'upload-preference' | 'image-processing' | 'login-rate'
|
||||||
uploadMaxFileSizeMbSource?: PolicySource
|
uploadImageMaxFileSizeMbSource?: PolicySource
|
||||||
|
uploadVideoMaxFileSizeMbSource?: PolicySource
|
||||||
|
uploadFileMaxFileSizeMbSource?: PolicySource
|
||||||
allowedMimeTypesSource?: PolicySource
|
allowedMimeTypesSource?: PolicySource
|
||||||
|
allowedVideoExtensionsSource?: PolicySource
|
||||||
|
forbiddenFileExtensionsSource?: PolicySource
|
||||||
uploadRateIpMaxSource?: PolicySource
|
uploadRateIpMaxSource?: PolicySource
|
||||||
uploadRateTokenMaxSource?: PolicySource
|
uploadRateTokenMaxSource?: PolicySource
|
||||||
uploadRateWindowMinutesSource?: PolicySource
|
uploadRateWindowMinutesSource?: PolicySource
|
||||||
@@ -13,16 +18,23 @@ defineProps<{
|
|||||||
loginRateWindowMinutesSource?: PolicySource
|
loginRateWindowMinutesSource?: PolicySource
|
||||||
preserveOriginalUploadSource?: PolicySource
|
preserveOriginalUploadSource?: PolicySource
|
||||||
mimeTypeOptions?: ReadonlyArray<{ mime: MimeType, label: string }>
|
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 allowedMimeTypesDraft = defineModel<MimeType[]>('allowedMimeTypes')
|
||||||
|
const allowedVideoExtensionsDraft = defineModel<VideoExtension[]>('allowedVideoExtensions')
|
||||||
|
const forbiddenFileExtensionsDraft = defineModel<string[]>('forbiddenFileExtensions')
|
||||||
const uploadRateIpMaxDraft = defineModel<number>('uploadRateIpMax')
|
const uploadRateIpMaxDraft = defineModel<number>('uploadRateIpMax')
|
||||||
const uploadRateTokenMaxDraft = defineModel<number>('uploadRateTokenMax')
|
const uploadRateTokenMaxDraft = defineModel<number>('uploadRateTokenMax')
|
||||||
const uploadRateWindowMinutesDraft = defineModel<number>('uploadRateWindowMinutes')
|
const uploadRateWindowMinutesDraft = defineModel<number>('uploadRateWindowMinutes')
|
||||||
const loginRateMaxDraft = defineModel<number>('loginRateMax')
|
const loginRateMaxDraft = defineModel<number>('loginRateMax')
|
||||||
const loginRateWindowMinutesDraft = defineModel<number>('loginRateWindowMinutes')
|
const loginRateWindowMinutesDraft = defineModel<number>('loginRateWindowMinutes')
|
||||||
const preserveOriginalUploadDraft = defineModel<boolean>('preserveOriginalUpload')
|
const preserveOriginalUploadDraft = defineModel<boolean>('preserveOriginalUpload')
|
||||||
|
const customForbiddenExtension = ref('')
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
|
||||||
@@ -53,72 +65,178 @@ function isMimeChecked(mime: MimeType): boolean {
|
|||||||
return allowedMimeTypesDraft.value?.includes(mime) ?? false
|
return allowedMimeTypesDraft.value?.includes(mime) ?? false
|
||||||
}
|
}
|
||||||
|
|
||||||
function clampUploadMaxFileSizeMb(value: unknown): number {
|
function toggleVideoExtension(extension: VideoExtension, checked: boolean) {
|
||||||
const num = Number(value)
|
if (!allowedVideoExtensionsDraft.value) return
|
||||||
if (!Number.isFinite(num)) return uploadMaxFileSizeMbDraft.value ?? 10
|
if (checked) {
|
||||||
return Math.min(100, Math.max(1, Math.round(num)))
|
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) {
|
function isVideoExtensionChecked(extension: VideoExtension): boolean {
|
||||||
uploadMaxFileSizeMbDraft.value = clampUploadMaxFileSizeMb(
|
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
|
(event.target as HTMLInputElement).value
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function onUploadMaxFileSizeNumberInput(value: string | number) {
|
function onUploadMaxFileSizeNumberInput(kind: UploadSizeKind, value: string | number) {
|
||||||
uploadMaxFileSizeMbDraft.value = clampUploadMaxFileSizeMb(value)
|
uploadSizeModel(kind).value = clampUploadMaxFileSizeMb(kind, value)
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<template v-if="part === 'upload-preference'">
|
<template v-if="part === 'upload-preference'">
|
||||||
<div class="py-4">
|
<div class="space-y-5 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
|
<div
|
||||||
class="mt-3 flex items-center gap-3"
|
v-for="field in uploadSizeFields"
|
||||||
:class="uploadMaxFileSizeMbSource === 'env' ? 'pointer-events-none opacity-60' : ''"
|
:key="field.kind"
|
||||||
>
|
>
|
||||||
<input
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
type="range"
|
<p class="text-sm font-medium text-highlighted">
|
||||||
min="1"
|
{{ field.title }}
|
||||||
max="100"
|
</p>
|
||||||
:value="uploadMaxFileSizeMbDraft"
|
<UBadge
|
||||||
class="h-1.5 min-w-0 flex-1 cursor-pointer appearance-none rounded-full bg-muted accent-primary"
|
v-if="field.source"
|
||||||
:disabled="uploadMaxFileSizeMbSource === 'env'"
|
:color="sourceBadge(field.source).color"
|
||||||
@input="onUploadMaxFileSizeInput"
|
variant="subtle"
|
||||||
>
|
|
||||||
<div class="flex shrink-0 items-center gap-1.5">
|
|
||||||
<UInput
|
|
||||||
:model-value="uploadMaxFileSizeMbDraft"
|
|
||||||
type="number"
|
|
||||||
:min="1"
|
|
||||||
:max="100"
|
|
||||||
size="xs"
|
size="xs"
|
||||||
class="w-14 tabular-nums"
|
>
|
||||||
:disabled="uploadMaxFileSizeMbSource === 'env'"
|
{{ sourceBadge(field.source).label }}
|
||||||
@update:model-value="onUploadMaxFileSizeNumberInput"
|
</UBadge>
|
||||||
/>
|
</div>
|
||||||
<span class="text-xs text-muted">MB</span>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="py-4">
|
<div class="border-t border-default/50 py-4">
|
||||||
<div class="flex flex-wrap items-center gap-2">
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
<p class="text-sm font-medium text-highlighted">
|
<p class="text-sm font-medium text-highlighted">
|
||||||
{{ t('settings.allowedMimeTypes') }}
|
{{ t('settings.allowedMimeTypes') }}
|
||||||
@@ -158,6 +276,105 @@ function onUploadMaxFileSizeNumberInput(value: string | number) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="border-t border-default/50 py-4">
|
||||||
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
|
<p class="text-sm font-medium text-highlighted">
|
||||||
|
{{ t('settings.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">
|
<div class="py-4">
|
||||||
<p class="text-sm font-medium text-highlighted">
|
<p class="text-sm font-medium text-highlighted">
|
||||||
{{ t('settings.uploadRateGroup') }}
|
{{ t('settings.uploadRateGroup') }}
|
||||||
|
|||||||
@@ -1,7 +1,12 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
defineProps<{
|
import type { UploadMediaKind } from '~/composables/useUploadPreferences'
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<{
|
||||||
part: 'upload-preference' | 'image-processing'
|
part: 'upload-preference' | 'image-processing'
|
||||||
}>()
|
kind?: UploadMediaKind
|
||||||
|
}>(), {
|
||||||
|
kind: 'image'
|
||||||
|
})
|
||||||
|
|
||||||
const {
|
const {
|
||||||
compressEnabled,
|
compressEnabled,
|
||||||
@@ -9,7 +14,7 @@ const {
|
|||||||
copyFormat,
|
copyFormat,
|
||||||
clientWebpQuality,
|
clientWebpQuality,
|
||||||
loadPreferences
|
loadPreferences
|
||||||
} = useUploadPreferences()
|
} = useUploadPreferences(props.kind)
|
||||||
|
|
||||||
const { t } = useI18n()
|
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>
|
||||||
@@ -8,6 +8,7 @@ defineProps<{
|
|||||||
totalPages: number
|
totalPages: number
|
||||||
total: number
|
total: number
|
||||||
pageSize: number
|
pageSize: number
|
||||||
|
showHidden: boolean
|
||||||
loading?: boolean
|
loading?: boolean
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
@@ -15,6 +16,9 @@ const emit = defineEmits<{
|
|||||||
'retry': [jobId: string]
|
'retry': [jobId: string]
|
||||||
'download': [jobId: string]
|
'download': [jobId: string]
|
||||||
'cancel': [jobId: string]
|
'cancel': [jobId: string]
|
||||||
|
'hide': [jobId: string, hidden: boolean]
|
||||||
|
'hideAll': [hidden: boolean]
|
||||||
|
'toggleHidden': []
|
||||||
'view': [job: BackupJobItem]
|
'view': [job: BackupJobItem]
|
||||||
'update:page': [page: number]
|
'update:page': [page: number]
|
||||||
'update:pageSize': [pageSize: number]
|
'update:pageSize': [pageSize: number]
|
||||||
@@ -26,7 +30,8 @@ function typeLabel(type: BackupJobItem['type']): string {
|
|||||||
const map: Record<BackupJobItem['type'], string> = {
|
const map: Record<BackupJobItem['type'], string> = {
|
||||||
full_export: t('storage.backup.jobTypeExport'),
|
full_export: t('storage.backup.jobTypeExport'),
|
||||||
full_restore: t('storage.backup.jobTypeRestore'),
|
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]
|
return map[type]
|
||||||
}
|
}
|
||||||
@@ -53,6 +58,12 @@ function progressPercent(job: BackupJobItem): number {
|
|||||||
return Math.min(100, Math.round((job.progress / job.total) * 100))
|
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 {
|
function jobMessage(job: BackupJobItem): string {
|
||||||
if (job.status === 'failed') {
|
if (job.status === 'failed') {
|
||||||
return job.error || job.message || t('storage.backup.jobFailedUnknown')
|
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)
|
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) {
|
function formatTime(value: string) {
|
||||||
if (!value) return '—'
|
if (!value) return '—'
|
||||||
return new Date(value).toLocaleString(locale.value, { hour12: false })
|
return new Date(value).toLocaleString(locale.value, { hour12: false })
|
||||||
@@ -95,11 +110,33 @@ function formatTime(value: string) {
|
|||||||
{{ t('storage.backup.jobsSubtitle') }}
|
{{ t('storage.backup.jobsSubtitle') }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<UIcon
|
<div class="flex shrink-0 items-center gap-2">
|
||||||
v-if="polling"
|
<UIcon
|
||||||
name="i-lucide-loader-circle"
|
v-if="polling"
|
||||||
class="size-4 shrink-0 animate-spin text-muted"
|
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>
|
</header>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
@@ -135,7 +172,7 @@ function formatTime(value: string) {
|
|||||||
{{ t('storage.backup.colProgress') }}
|
{{ t('storage.backup.colProgress') }}
|
||||||
</th>
|
</th>
|
||||||
<th class="px-4 py-3 font-medium sm:px-5">
|
<th class="px-4 py-3 font-medium sm:px-5">
|
||||||
{{ t('storage.backup.colCreated') }}
|
{{ t('storage.backup.colUpdated') }}
|
||||||
</th>
|
</th>
|
||||||
<th class="px-4 py-3 font-medium sm:px-5">
|
<th class="px-4 py-3 font-medium sm:px-5">
|
||||||
{{ t('storage.backup.colActions') }}
|
{{ t('storage.backup.colActions') }}
|
||||||
@@ -174,7 +211,7 @@ function formatTime(value: string) {
|
|||||||
class="text-xs text-dimmed"
|
class="text-xs text-dimmed"
|
||||||
>—</span>
|
>—</span>
|
||||||
</td>
|
</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="flex items-center gap-2">
|
||||||
<div class="h-1 min-w-0 flex-1 overflow-hidden rounded-full bg-muted">
|
<div class="h-1 min-w-0 flex-1 overflow-hidden rounded-full bg-muted">
|
||||||
<div
|
<div
|
||||||
@@ -183,15 +220,20 @@ function formatTime(value: string) {
|
|||||||
:style="{ width: `${progressPercent(job)}%` }"
|
:style="{ width: `${progressPercent(job)}%` }"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<span class="shrink-0 text-xs tabular-nums text-muted">
|
<span class="flex shrink-0 flex-col text-right">
|
||||||
<template v-if="job.total">{{ job.progress }}/{{ job.total }}</template>
|
<span class="text-xs tabular-nums text-muted">
|
||||||
<template v-else-if="job.status === 'done'">100%</template>
|
<template v-if="job.total">{{ job.progress }}/{{ job.total }}</template>
|
||||||
<template v-else>—</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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td class="px-4 py-3.5 whitespace-nowrap text-xs text-muted sm:px-5">
|
<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>
|
||||||
<td class="px-4 py-3.5 sm:px-5">
|
<td class="px-4 py-3.5 sm:px-5">
|
||||||
<div class="flex flex-wrap gap-1.5">
|
<div class="flex flex-wrap gap-1.5">
|
||||||
@@ -231,6 +273,17 @@ function formatTime(value: string) {
|
|||||||
color="neutral"
|
color="neutral"
|
||||||
@click="emit('retry', job.id)"
|
@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>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@@ -54,6 +54,19 @@ function formatTime(value: string) {
|
|||||||
return new Date(value).toLocaleString(locale.value, { hour12: false })
|
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() {
|
function pickFile() {
|
||||||
fileInput.value?.click()
|
fileInput.value?.click()
|
||||||
}
|
}
|
||||||
@@ -90,8 +103,12 @@ async function submit() {
|
|||||||
archiveName: selectedPackage.value || undefined
|
archiveName: selectedPackage.value || undefined
|
||||||
})
|
})
|
||||||
confirmOpen.value = true
|
confirmOpen.value = true
|
||||||
} catch {
|
} catch (error: unknown) {
|
||||||
toast.add({ title: t('storage.backup.previewFailed'), color: 'error' })
|
toast.add({
|
||||||
|
title: t('storage.backup.previewFailed'),
|
||||||
|
description: getErrorMessage(error),
|
||||||
|
color: 'error'
|
||||||
|
})
|
||||||
} finally {
|
} finally {
|
||||||
previewLoading.value = false
|
previewLoading.value = false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,356 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type {
|
||||||
|
SiteSyncDirection,
|
||||||
|
SiteSyncRequest
|
||||||
|
} from '~/types/backup'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
busy: boolean
|
||||||
|
disabled?: boolean
|
||||||
|
activeJob?: {
|
||||||
|
progress: number
|
||||||
|
total: number
|
||||||
|
message: string
|
||||||
|
payload: Record<string, unknown>
|
||||||
|
} | null
|
||||||
|
resumableJob?: {
|
||||||
|
id: string
|
||||||
|
payload: Record<string, unknown>
|
||||||
|
} | null
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
sync: [body: SiteSyncRequest]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
const targetBaseUrl = ref('')
|
||||||
|
const token = ref('')
|
||||||
|
const conflictMode = ref<'skip' | 'overwrite'>('skip')
|
||||||
|
const direction = ref<SiteSyncDirection>('push')
|
||||||
|
const scope = ref<'all' | 'image' | 'video' | 'file'>('all')
|
||||||
|
|
||||||
|
/** 目标站点地址与 Token 自动保存 24 小时 */
|
||||||
|
const FORM_STORAGE_KEY = 'pichost.site-sync-form'
|
||||||
|
const FORM_TTL_MS = 24 * 60 * 60 * 1000
|
||||||
|
|
||||||
|
function loadSavedForm() {
|
||||||
|
if (!import.meta.client) return
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(FORM_STORAGE_KEY)
|
||||||
|
if (!raw) return
|
||||||
|
const data = JSON.parse(raw) as {
|
||||||
|
targetBaseUrl?: string
|
||||||
|
token?: string
|
||||||
|
savedAt?: number
|
||||||
|
}
|
||||||
|
if (!data.savedAt || Date.now() - data.savedAt > FORM_TTL_MS) {
|
||||||
|
localStorage.removeItem(FORM_STORAGE_KEY)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (data.targetBaseUrl) targetBaseUrl.value = data.targetBaseUrl
|
||||||
|
if (data.token) token.value = data.token
|
||||||
|
} catch {
|
||||||
|
localStorage.removeItem(FORM_STORAGE_KEY)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function persistForm() {
|
||||||
|
if (!import.meta.client) return
|
||||||
|
try {
|
||||||
|
localStorage.setItem(FORM_STORAGE_KEY, JSON.stringify({
|
||||||
|
targetBaseUrl: targetBaseUrl.value,
|
||||||
|
token: token.value,
|
||||||
|
savedAt: Date.now()
|
||||||
|
}))
|
||||||
|
} catch {
|
||||||
|
// 存储不可用时忽略
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch([targetBaseUrl, token], persistForm)
|
||||||
|
|
||||||
|
onMounted(loadSavedForm)
|
||||||
|
|
||||||
|
const scopeItems = computed(() => [
|
||||||
|
{ value: 'all' as const, label: t('storage.backup.siteSyncScopeAll') },
|
||||||
|
{ value: 'image' as const, label: t('storage.backup.siteSyncScopeImage') },
|
||||||
|
{ value: 'video' as const, label: t('storage.backup.siteSyncScopeVideo') },
|
||||||
|
{ value: 'file' as const, label: t('storage.backup.siteSyncScopeFile') }
|
||||||
|
])
|
||||||
|
|
||||||
|
watch(() => props.resumableJob, (job) => {
|
||||||
|
if (!job) return
|
||||||
|
direction.value = job.payload.direction === 'pull' ? 'pull' : 'push'
|
||||||
|
if (typeof job.payload.targetBaseUrl === 'string') {
|
||||||
|
targetBaseUrl.value = job.payload.targetBaseUrl
|
||||||
|
}
|
||||||
|
}, { immediate: true })
|
||||||
|
|
||||||
|
const canSubmit = computed(() =>
|
||||||
|
!props.busy
|
||||||
|
&& !props.disabled
|
||||||
|
&& Boolean(targetBaseUrl.value.trim())
|
||||||
|
&& Boolean(token.value.trim())
|
||||||
|
)
|
||||||
|
const canResume = computed(() =>
|
||||||
|
!props.busy
|
||||||
|
&& !props.disabled
|
||||||
|
&& Boolean(props.resumableJob)
|
||||||
|
&& Boolean(token.value.trim())
|
||||||
|
)
|
||||||
|
const fileProgress = computed(() => {
|
||||||
|
const payload = props.activeJob?.payload
|
||||||
|
return {
|
||||||
|
synced: Number(payload?.filesSynced ?? 0),
|
||||||
|
total: Number(payload?.totalFiles ?? 0)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
const isPull = computed(() => direction.value === 'pull')
|
||||||
|
const canAbandon = computed(() =>
|
||||||
|
!props.busy
|
||||||
|
&& Boolean(props.resumableJob)
|
||||||
|
)
|
||||||
|
const abandonConfirm = computed(() => (
|
||||||
|
isPull.value || token.value.trim()
|
||||||
|
? t('storage.backup.siteSyncAbandonConfirm')
|
||||||
|
: t('storage.backup.siteSyncAbandonWithoutTokenConfirm')
|
||||||
|
))
|
||||||
|
const directionSubtitle = computed(() => (
|
||||||
|
isPull.value
|
||||||
|
? t('storage.backup.siteSyncPullSubtitle')
|
||||||
|
: t('storage.backup.siteSyncPushSubtitle')
|
||||||
|
))
|
||||||
|
const endpointLabel = computed(() => (
|
||||||
|
isPull.value
|
||||||
|
? t('storage.backup.siteSyncSource')
|
||||||
|
: t('storage.backup.siteSyncTarget')
|
||||||
|
))
|
||||||
|
const tokenLabel = computed(() => (
|
||||||
|
isPull.value
|
||||||
|
? t('storage.backup.siteSyncSourceToken')
|
||||||
|
: t('storage.backup.siteSyncToken')
|
||||||
|
))
|
||||||
|
const tokenPlaceholder = computed(() => (
|
||||||
|
isPull.value
|
||||||
|
? t('storage.backup.siteSyncSourceTokenPlaceholder')
|
||||||
|
: t('storage.backup.siteSyncTokenPlaceholder')
|
||||||
|
))
|
||||||
|
const startLabel = computed(() => (
|
||||||
|
isPull.value
|
||||||
|
? t('storage.backup.siteSyncStartPull')
|
||||||
|
: t('storage.backup.siteSyncStartPush')
|
||||||
|
))
|
||||||
|
|
||||||
|
function submit(resume = false) {
|
||||||
|
if (resume ? !canResume.value : !canSubmit.value) return
|
||||||
|
emit('sync', {
|
||||||
|
targetBaseUrl: targetBaseUrl.value.trim(),
|
||||||
|
token: token.value.trim(),
|
||||||
|
conflictMode: conflictMode.value,
|
||||||
|
direction: direction.value,
|
||||||
|
scope: scope.value,
|
||||||
|
...(resume && props.resumableJob ? { resumeJobId: props.resumableJob.id } : {})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function startNew() {
|
||||||
|
submit(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
function resumeSync() {
|
||||||
|
submit(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
function abandonSync() {
|
||||||
|
if (!canAbandon.value || !props.resumableJob) return
|
||||||
|
if (!window.confirm(abandonConfirm.value)) return
|
||||||
|
emit('sync', {
|
||||||
|
targetBaseUrl: targetBaseUrl.value.trim(),
|
||||||
|
token: token.value.trim(),
|
||||||
|
conflictMode: conflictMode.value,
|
||||||
|
direction: direction.value,
|
||||||
|
abandonJobId: props.resumableJob.id
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function setConflictMode(mode: 'skip' | 'overwrite') {
|
||||||
|
conflictMode.value = mode
|
||||||
|
}
|
||||||
|
|
||||||
|
function setDirection(value: SiteSyncDirection) {
|
||||||
|
direction.value = value
|
||||||
|
}
|
||||||
|
|
||||||
|
function setScope(value: 'all' | 'image' | 'video' | 'file') {
|
||||||
|
scope.value = value
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<article class="rounded-xl border border-default bg-elevated p-4 sm:p-5">
|
||||||
|
<div class="flex items-start gap-3">
|
||||||
|
<div class="flex size-10 shrink-0 items-center justify-center rounded-lg bg-primary/10 text-primary">
|
||||||
|
<UIcon
|
||||||
|
name="i-lucide-server-cog"
|
||||||
|
class="size-5"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="min-w-0">
|
||||||
|
<h3 class="text-base font-semibold">
|
||||||
|
{{ t('storage.backup.siteSyncTitle') }}
|
||||||
|
</h3>
|
||||||
|
<p class="mt-0.5 text-sm leading-relaxed text-muted">
|
||||||
|
{{ directionSubtitle }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-4 grid gap-3 lg:grid-cols-[minmax(0,1.2fr)_minmax(0,1fr)_auto] lg:items-end">
|
||||||
|
<div class="space-y-1.5">
|
||||||
|
<label class="text-xs text-muted">{{ endpointLabel }}</label>
|
||||||
|
<UInput
|
||||||
|
v-model="targetBaseUrl"
|
||||||
|
placeholder="https://target.example.com"
|
||||||
|
size="sm"
|
||||||
|
class="w-full"
|
||||||
|
:disabled="busy || disabled"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-1.5">
|
||||||
|
<label class="text-xs text-muted">{{ tokenLabel }}</label>
|
||||||
|
<UInput
|
||||||
|
v-model="token"
|
||||||
|
type="password"
|
||||||
|
:placeholder="tokenPlaceholder"
|
||||||
|
size="sm"
|
||||||
|
class="w-full"
|
||||||
|
:disabled="busy || disabled"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<UButton
|
||||||
|
:label="startLabel"
|
||||||
|
:icon="isPull ? 'i-lucide-cloud-download' : 'i-lucide-cloud-upload'"
|
||||||
|
color="primary"
|
||||||
|
:loading="busy"
|
||||||
|
:disabled="!canSubmit"
|
||||||
|
@click="startNew"
|
||||||
|
/>
|
||||||
|
<UButton
|
||||||
|
v-if="resumableJob"
|
||||||
|
:label="t('storage.backup.siteSyncResume')"
|
||||||
|
icon="i-lucide-play"
|
||||||
|
color="warning"
|
||||||
|
:loading="busy"
|
||||||
|
:disabled="!canResume"
|
||||||
|
class="lg:col-start-3"
|
||||||
|
@click="resumeSync"
|
||||||
|
/>
|
||||||
|
<UButton
|
||||||
|
v-if="resumableJob"
|
||||||
|
:label="t('storage.backup.siteSyncAbandon')"
|
||||||
|
icon="i-lucide-trash-2"
|
||||||
|
color="error"
|
||||||
|
variant="outline"
|
||||||
|
:loading="busy"
|
||||||
|
:disabled="!canAbandon"
|
||||||
|
class="lg:col-start-3"
|
||||||
|
@click="abandonSync"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-3 grid gap-3 sm:grid-cols-2">
|
||||||
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
|
<span class="text-xs text-muted">{{ t('storage.backup.siteSyncDirection') }}</span>
|
||||||
|
<UButton
|
||||||
|
size="xs"
|
||||||
|
:variant="direction === 'push' ? 'solid' : 'ghost'"
|
||||||
|
:color="direction === 'push' ? 'primary' : 'neutral'"
|
||||||
|
:label="t('storage.backup.siteSyncPush')"
|
||||||
|
:disabled="busy || disabled"
|
||||||
|
@click="setDirection('push')"
|
||||||
|
/>
|
||||||
|
<UButton
|
||||||
|
size="xs"
|
||||||
|
:variant="direction === 'pull' ? 'solid' : 'ghost'"
|
||||||
|
:color="direction === 'pull' ? 'primary' : 'neutral'"
|
||||||
|
:label="t('storage.backup.siteSyncPull')"
|
||||||
|
:disabled="busy || disabled"
|
||||||
|
@click="setDirection('pull')"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-wrap items-center gap-2 sm:justify-end">
|
||||||
|
<span class="text-xs text-muted">{{ t('storage.backup.siteSyncConflict') }}</span>
|
||||||
|
<UButton
|
||||||
|
size="xs"
|
||||||
|
:variant="conflictMode === 'skip' ? 'solid' : 'ghost'"
|
||||||
|
:color="conflictMode === 'skip' ? 'primary' : 'neutral'"
|
||||||
|
:label="t('storage.backup.siteSyncSkip')"
|
||||||
|
:disabled="busy || disabled"
|
||||||
|
@click="setConflictMode('skip')"
|
||||||
|
/>
|
||||||
|
<UButton
|
||||||
|
size="xs"
|
||||||
|
:variant="conflictMode === 'overwrite' ? 'solid' : 'ghost'"
|
||||||
|
:color="conflictMode === 'overwrite' ? 'primary' : 'neutral'"
|
||||||
|
:label="t('storage.backup.siteSyncOverwrite')"
|
||||||
|
:disabled="busy || disabled"
|
||||||
|
@click="setConflictMode('overwrite')"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-3 flex flex-wrap items-center gap-2">
|
||||||
|
<span class="text-xs text-muted">{{ t('storage.backup.siteSyncContent') }}</span>
|
||||||
|
<UButton
|
||||||
|
v-for="item in scopeItems"
|
||||||
|
:key="item.value"
|
||||||
|
size="xs"
|
||||||
|
:variant="scope === item.value ? 'solid' : 'ghost'"
|
||||||
|
:color="scope === item.value ? 'primary' : 'neutral'"
|
||||||
|
:label="item.label"
|
||||||
|
:disabled="busy || disabled"
|
||||||
|
@click="setScope(item.value)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<UAlert
|
||||||
|
v-if="activeJob"
|
||||||
|
class="mt-4"
|
||||||
|
color="primary"
|
||||||
|
variant="subtle"
|
||||||
|
icon="i-lucide-refresh-cw"
|
||||||
|
:description="activeJob.message"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="activeJob"
|
||||||
|
class="mt-3 grid gap-3 rounded-lg border border-default bg-default/40 p-3 sm:grid-cols-2"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<p class="text-xs text-muted">
|
||||||
|
{{ t('storage.backup.siteSyncScanProgress') }}
|
||||||
|
</p>
|
||||||
|
<p class="mt-1 font-mono text-sm font-medium">
|
||||||
|
{{ activeJob.message }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-xs text-muted">
|
||||||
|
{{ t('storage.backup.siteSyncFileProgress') }}
|
||||||
|
</p>
|
||||||
|
<p class="mt-1 font-mono text-sm font-medium">
|
||||||
|
{{ fileProgress.synced }} / {{ fileProgress.total }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<BackupJobProgress
|
||||||
|
v-if="activeJob"
|
||||||
|
class="mt-2"
|
||||||
|
:message="activeJob.message"
|
||||||
|
:progress="activeJob.progress"
|
||||||
|
:total="activeJob.total"
|
||||||
|
/>
|
||||||
|
</article>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
const props = defineProps<{
|
||||||
|
open: boolean
|
||||||
|
keys: string[]
|
||||||
|
scope: 'image' | 'video' | 'file'
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'update:open': [value: boolean]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
const { startSiteSync } = useBackup()
|
||||||
|
|
||||||
|
const targetBaseUrl = ref('')
|
||||||
|
const token = ref('')
|
||||||
|
const conflictMode = ref<'skip' | 'overwrite'>('skip')
|
||||||
|
const busy = ref(false)
|
||||||
|
|
||||||
|
const canSubmit = computed(() =>
|
||||||
|
!busy.value
|
||||||
|
&& props.keys.length > 0
|
||||||
|
&& Boolean(targetBaseUrl.value.trim())
|
||||||
|
&& Boolean(token.value.trim())
|
||||||
|
)
|
||||||
|
|
||||||
|
function setConflict(mode: 'skip' | 'overwrite') {
|
||||||
|
conflictMode.value = mode
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submit() {
|
||||||
|
if (!canSubmit.value) return
|
||||||
|
busy.value = true
|
||||||
|
try {
|
||||||
|
await startSiteSync({
|
||||||
|
targetBaseUrl: targetBaseUrl.value.trim(),
|
||||||
|
token: token.value.trim(),
|
||||||
|
conflictMode: conflictMode.value,
|
||||||
|
direction: 'push',
|
||||||
|
scope: props.scope,
|
||||||
|
keys: props.keys
|
||||||
|
})
|
||||||
|
emit('update:open', false)
|
||||||
|
} catch {
|
||||||
|
// useBackup 已提示错误
|
||||||
|
} finally {
|
||||||
|
busy.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<UModal
|
||||||
|
:open="open"
|
||||||
|
:title="t('storage.backup.syncSelectedTitle')"
|
||||||
|
:description="t('storage.backup.syncSelectedSubtitle', { n: keys.length })"
|
||||||
|
@update:open="emit('update:open', $event)"
|
||||||
|
>
|
||||||
|
<template #body>
|
||||||
|
<div class="space-y-3">
|
||||||
|
<div class="space-y-1.5">
|
||||||
|
<label class="text-xs text-muted">{{ t('storage.backup.siteSyncTarget') }}</label>
|
||||||
|
<UInput
|
||||||
|
v-model="targetBaseUrl"
|
||||||
|
placeholder="https://target.example.com"
|
||||||
|
size="sm"
|
||||||
|
class="w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-1.5">
|
||||||
|
<label class="text-xs text-muted">{{ t('storage.backup.siteSyncToken') }}</label>
|
||||||
|
<UInput
|
||||||
|
v-model="token"
|
||||||
|
type="password"
|
||||||
|
:placeholder="t('storage.backup.siteSyncTokenPlaceholder')"
|
||||||
|
size="sm"
|
||||||
|
class="w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
|
<span class="text-xs text-muted">{{ t('storage.backup.siteSyncConflict') }}</span>
|
||||||
|
<UButton
|
||||||
|
size="xs"
|
||||||
|
:variant="conflictMode === 'skip' ? 'solid' : 'ghost'"
|
||||||
|
:color="conflictMode === 'skip' ? 'primary' : 'neutral'"
|
||||||
|
:label="t('storage.backup.siteSyncSkip')"
|
||||||
|
@click="setConflict('skip')"
|
||||||
|
/>
|
||||||
|
<UButton
|
||||||
|
size="xs"
|
||||||
|
:variant="conflictMode === 'overwrite' ? 'solid' : 'ghost'"
|
||||||
|
:color="conflictMode === 'overwrite' ? 'primary' : 'neutral'"
|
||||||
|
:label="t('storage.backup.siteSyncOverwrite')"
|
||||||
|
@click="setConflict('overwrite')"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<div class="flex justify-end gap-2">
|
||||||
|
<UButton
|
||||||
|
:label="t('common.cancel')"
|
||||||
|
color="neutral"
|
||||||
|
variant="outline"
|
||||||
|
@click="emit('update:open', false)"
|
||||||
|
/>
|
||||||
|
<UButton
|
||||||
|
:label="t('storage.backup.syncSelectedStart')"
|
||||||
|
icon="i-lucide-cloud-upload"
|
||||||
|
color="primary"
|
||||||
|
:loading="busy"
|
||||||
|
:disabled="!canSubmit"
|
||||||
|
@click="submit"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</UModal>
|
||||||
|
</template>
|
||||||
@@ -21,13 +21,17 @@ const emit = defineEmits<{
|
|||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
|
||||||
const typeIcon = computed(() =>
|
const typeIcon = computed(() => {
|
||||||
props.backend.type === 'local' ? 'i-lucide-hard-drive' : 'i-lucide-cloud'
|
if (props.backend.type === 'local') return 'i-lucide-hard-drive'
|
||||||
)
|
if (props.backend.type === 'telegram') return 'i-lucide-send'
|
||||||
|
return 'i-lucide-cloud'
|
||||||
|
})
|
||||||
|
|
||||||
const typeLabel = computed(() =>
|
const typeLabel = computed(() => {
|
||||||
props.backend.type === 'local' ? t('storage.backendLocal') : t('storage.backendCloud')
|
if (props.backend.type === 'local') return t('storage.backendLocal')
|
||||||
)
|
if (props.backend.type === 'telegram') return t('storage.backendTelegram')
|
||||||
|
return t('storage.backendCloud')
|
||||||
|
})
|
||||||
|
|
||||||
const actionsDisabled = computed(() => props.envOverride || props.busy)
|
const actionsDisabled = computed(() => props.envOverride || props.busy)
|
||||||
|
|
||||||
@@ -38,7 +42,7 @@ function canSetDefault() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function canEdit() {
|
function canEdit() {
|
||||||
return props.backend.type === 's3'
|
return props.backend.type === 's3' || props.backend.type === 'telegram'
|
||||||
}
|
}
|
||||||
|
|
||||||
function canToggle() {
|
function canToggle() {
|
||||||
|
|||||||
@@ -2,9 +2,10 @@
|
|||||||
import type { QuotaUnit } from '~/composables/useFileSize'
|
import type { QuotaUnit } from '~/composables/useFileSize'
|
||||||
import type { StorageBackendItem } from '~/types/storage'
|
import type { StorageBackendItem } from '~/types/storage'
|
||||||
|
|
||||||
type StorageProvider = 'r2' | 'cos' | 'oss' | 'aws' | 'custom'
|
type StorageProvider = 'r2' | 'cos' | 'oss' | 'aws' | 'custom' | 'telegram'
|
||||||
|
|
||||||
export interface StorageFormPayload {
|
export interface StorageFormPayload {
|
||||||
|
backendType: 's3' | 'telegram'
|
||||||
name: string
|
name: string
|
||||||
provider: StorageProvider
|
provider: StorageProvider
|
||||||
config: {
|
config: {
|
||||||
@@ -13,10 +14,13 @@ export interface StorageFormPayload {
|
|||||||
bucket: string
|
bucket: string
|
||||||
prefix?: string
|
prefix?: string
|
||||||
forcePathStyle: boolean
|
forcePathStyle: boolean
|
||||||
|
chatId?: string
|
||||||
|
apiProxyUrl?: string
|
||||||
}
|
}
|
||||||
secrets: {
|
secrets: {
|
||||||
accessKeyId: string
|
accessKeyId: string
|
||||||
secretAccessKey: string
|
secretAccessKey: string
|
||||||
|
botToken: string
|
||||||
}
|
}
|
||||||
servingMode: 'proxy' | 'public'
|
servingMode: 'proxy' | 'public'
|
||||||
publicUrl: string
|
publicUrl: string
|
||||||
@@ -47,11 +51,18 @@ const prefix = ref('')
|
|||||||
const forcePathStyle = ref(false)
|
const forcePathStyle = ref(false)
|
||||||
const accessKeyId = ref('')
|
const accessKeyId = ref('')
|
||||||
const secretAccessKey = ref('')
|
const secretAccessKey = ref('')
|
||||||
|
const chatId = ref('')
|
||||||
|
const botToken = ref('')
|
||||||
|
const apiProxyUrl = ref('')
|
||||||
const servingMode = ref<'proxy' | 'public'>('proxy')
|
const servingMode = ref<'proxy' | 'public'>('proxy')
|
||||||
const publicUrl = ref('')
|
const publicUrl = ref('')
|
||||||
const quotaValue = ref('')
|
const quotaValue = ref('')
|
||||||
const quotaUnit = ref<QuotaUnit>('GB')
|
const quotaUnit = ref<QuotaUnit>('GB')
|
||||||
|
|
||||||
|
const isTelegram = computed(() =>
|
||||||
|
provider.value === 'telegram' || props.backend?.type === 'telegram'
|
||||||
|
)
|
||||||
|
|
||||||
const quotaUnitOptions = computed(() =>
|
const quotaUnitOptions = computed(() =>
|
||||||
QUOTA_UNITS.map(unit => ({ label: unit, value: unit }))
|
QUOTA_UNITS.map(unit => ({ label: unit, value: unit }))
|
||||||
)
|
)
|
||||||
@@ -61,7 +72,8 @@ const providerOptions = computed(() => [
|
|||||||
{ label: t('storage.providerCos'), value: 'cos' as const },
|
{ label: t('storage.providerCos'), value: 'cos' as const },
|
||||||
{ label: t('storage.providerOss'), value: 'oss' as const },
|
{ label: t('storage.providerOss'), value: 'oss' as const },
|
||||||
{ label: t('storage.providerAws'), value: 'aws' as const },
|
{ label: t('storage.providerAws'), value: 'aws' as const },
|
||||||
{ label: t('storage.providerCustom'), value: 'custom' as const }
|
{ label: t('storage.providerCustom'), value: 'custom' as const },
|
||||||
|
{ label: t('storage.providerTelegram'), value: 'telegram' as const }
|
||||||
])
|
])
|
||||||
|
|
||||||
const servingModeOptions = computed(() => [
|
const servingModeOptions = computed(() => [
|
||||||
@@ -79,6 +91,10 @@ function applyProviderTemplate(value: StorageProvider) {
|
|||||||
if (props.createMode) {
|
if (props.createMode) {
|
||||||
name.value = providerLabel(value)
|
name.value = providerLabel(value)
|
||||||
}
|
}
|
||||||
|
if (value === 'telegram') {
|
||||||
|
servingMode.value = 'proxy'
|
||||||
|
return
|
||||||
|
}
|
||||||
switch (value) {
|
switch (value) {
|
||||||
case 'r2':
|
case 'r2':
|
||||||
endpoint.value = 'https://{account_id}.r2.cloudflarestorage.com'
|
endpoint.value = 'https://{account_id}.r2.cloudflarestorage.com'
|
||||||
@@ -114,21 +130,44 @@ function detectProvider(config: StorageBackendItem['config']): StorageProvider {
|
|||||||
return 'custom'
|
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) {
|
function loadBackend(backend: StorageBackendItem | null | undefined) {
|
||||||
if (!backend || backend.type !== 's3') {
|
if (!backend) {
|
||||||
if (props.createMode) {
|
if (props.createMode) resetCreateDefaults()
|
||||||
name.value = ''
|
return
|
||||||
provider.value = 'r2'
|
}
|
||||||
applyProviderTemplate('r2')
|
|
||||||
bucket.value = ''
|
if (backend.type === 'telegram') {
|
||||||
prefix.value = ''
|
name.value = backend.name
|
||||||
accessKeyId.value = ''
|
provider.value = 'telegram'
|
||||||
secretAccessKey.value = ''
|
chatId.value = backend.config.chatId ?? ''
|
||||||
servingMode.value = 'proxy'
|
apiProxyUrl.value = backend.config.apiProxyUrl ?? ''
|
||||||
publicUrl.value = ''
|
botToken.value = ''
|
||||||
quotaValue.value = ''
|
const quota = bytesToQuotaInput(backend.quotaBytes)
|
||||||
quotaUnit.value = 'GB'
|
quotaValue.value = quota.value
|
||||||
}
|
quotaUnit.value = quota.unit
|
||||||
|
servingMode.value = 'proxy'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (backend.type !== 's3') {
|
||||||
|
if (props.createMode) resetCreateDefaults()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -157,7 +196,9 @@ watch(provider, (value) => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
function handleSubmit() {
|
function handleSubmit() {
|
||||||
|
const backendType = isTelegram.value ? 'telegram' : 's3'
|
||||||
emit('submit', {
|
emit('submit', {
|
||||||
|
backendType,
|
||||||
name: name.value.trim() || providerLabel(provider.value),
|
name: name.value.trim() || providerLabel(provider.value),
|
||||||
provider: provider.value,
|
provider: provider.value,
|
||||||
config: {
|
config: {
|
||||||
@@ -165,13 +206,16 @@ function handleSubmit() {
|
|||||||
region: region.value.trim() || 'auto',
|
region: region.value.trim() || 'auto',
|
||||||
bucket: bucket.value.trim(),
|
bucket: bucket.value.trim(),
|
||||||
prefix: prefix.value.trim() || undefined,
|
prefix: prefix.value.trim() || undefined,
|
||||||
forcePathStyle: forcePathStyle.value
|
forcePathStyle: forcePathStyle.value,
|
||||||
|
chatId: chatId.value.trim() || undefined,
|
||||||
|
apiProxyUrl: apiProxyUrl.value.trim() || undefined
|
||||||
},
|
},
|
||||||
secrets: {
|
secrets: {
|
||||||
accessKeyId: accessKeyId.value.trim(),
|
accessKeyId: accessKeyId.value.trim(),
|
||||||
secretAccessKey: secretAccessKey.value.trim()
|
secretAccessKey: secretAccessKey.value.trim(),
|
||||||
|
botToken: botToken.value.trim()
|
||||||
},
|
},
|
||||||
servingMode: servingMode.value,
|
servingMode: isTelegram.value ? 'proxy' : servingMode.value,
|
||||||
publicUrl: publicUrl.value.trim(),
|
publicUrl: publicUrl.value.trim(),
|
||||||
quotaValue: String(quotaValue.value ?? '').trim(),
|
quotaValue: String(quotaValue.value ?? '').trim(),
|
||||||
quotaUnit: quotaUnit.value
|
quotaUnit: quotaUnit.value
|
||||||
@@ -190,6 +234,7 @@ function handleSubmit() {
|
|||||||
v-model="provider"
|
v-model="provider"
|
||||||
:items="providerOptions"
|
:items="providerOptions"
|
||||||
class="w-full"
|
class="w-full"
|
||||||
|
:disabled="!createMode && backend?.type === 'telegram'"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -201,105 +246,158 @@ function handleSubmit() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="space-y-2">
|
<template v-if="isTelegram">
|
||||||
<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">
|
<div class="space-y-2">
|
||||||
<label class="text-sm">
|
<label class="text-sm">
|
||||||
{{ t('storage.bucket') }}
|
{{ t('storage.telegramChatId') }}
|
||||||
<span class="text-error">*</span>
|
<span class="text-error">*</span>
|
||||||
</label>
|
</label>
|
||||||
<UInput
|
<UInput
|
||||||
v-model="bucket"
|
v-model="chatId"
|
||||||
|
:placeholder="t('storage.telegramChatIdPlaceholder')"
|
||||||
class="w-full font-mono text-sm"
|
class="w-full font-mono text-sm"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
|
<p class="text-xs text-muted">
|
||||||
|
{{ t('storage.telegramChatIdHint') }}
|
||||||
|
</p>
|
||||||
</div>
|
</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">
|
<div class="space-y-2">
|
||||||
<label class="text-sm">
|
<label class="text-sm">
|
||||||
{{ t('storage.accessKey') }}
|
{{ t('storage.telegramBotToken') }}
|
||||||
<span
|
<span
|
||||||
v-if="createMode"
|
v-if="createMode"
|
||||||
class="text-error"
|
class="text-error"
|
||||||
>*</span>
|
>*</span>
|
||||||
</label>
|
</label>
|
||||||
<UInput
|
<UInput
|
||||||
v-model="accessKeyId"
|
v-model="botToken"
|
||||||
: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"
|
type="password"
|
||||||
:placeholder="backend?.secretsMasked.secretAccessKey || ''"
|
:placeholder="backend?.secretsMasked.botToken || ''"
|
||||||
class="w-full font-mono text-sm"
|
class="w-full font-mono text-sm"
|
||||||
:required="createMode"
|
:required="createMode"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<label class="text-sm">{{ t('storage.servingMode') }}</label>
|
<label class="text-sm">{{ t('storage.telegramApiProxy') }}</label>
|
||||||
<URadioGroup
|
<UInput
|
||||||
v-model="servingMode"
|
v-model="apiProxyUrl"
|
||||||
:items="servingModeOptions"
|
: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">
|
<p class="text-xs text-muted">
|
||||||
{{ t('storage.servingHint') }}
|
{{ t('storage.telegramServingHint') }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</template>
|
||||||
|
|
||||||
<div
|
<template v-else>
|
||||||
v-if="servingMode === 'public'"
|
<div class="space-y-2">
|
||||||
class="space-y-2"
|
<label class="text-sm">{{ t('storage.endpoint') }}</label>
|
||||||
>
|
<UInput
|
||||||
<label class="text-sm">{{ t('storage.publicUrl') }}</label>
|
v-model="endpoint"
|
||||||
<UInput
|
class="w-full font-mono text-sm"
|
||||||
v-model="publicUrl"
|
/>
|
||||||
:placeholder="t('storage.publicUrlPlaceholder')"
|
</div>
|
||||||
class="w-full font-mono text-sm"
|
|
||||||
/>
|
<div class="grid gap-3 sm:grid-cols-2">
|
||||||
</div>
|
<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">
|
<div class="space-y-2">
|
||||||
<label class="text-sm">
|
<label class="text-sm">
|
||||||
@@ -324,7 +422,7 @@ function handleSubmit() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<p class="text-xs text-muted">
|
<p class="text-xs text-muted">
|
||||||
{{ t('storage.quotaHint') }}
|
{{ isTelegram ? t('storage.telegramQuotaHint') : t('storage.quotaHint') }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
<script setup lang="ts">
|
<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'
|
import type { StorageBackendItem } from '~/types/storage'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
@@ -15,6 +19,7 @@ const {
|
|||||||
jobsTotal,
|
jobsTotal,
|
||||||
jobsTotalPages,
|
jobsTotalPages,
|
||||||
jobsLoading,
|
jobsLoading,
|
||||||
|
showHiddenJobs,
|
||||||
estimate,
|
estimate,
|
||||||
packages,
|
packages,
|
||||||
loadingEstimate,
|
loadingEstimate,
|
||||||
@@ -22,6 +27,9 @@ const {
|
|||||||
hasActiveJob,
|
hasActiveJob,
|
||||||
getActiveJob,
|
getActiveJob,
|
||||||
loadJobs,
|
loadJobs,
|
||||||
|
toggleShowHiddenJobs,
|
||||||
|
hideJob,
|
||||||
|
hideAllJobs,
|
||||||
setJobsPage,
|
setJobsPage,
|
||||||
setJobsPageSize,
|
setJobsPageSize,
|
||||||
loadEstimate,
|
loadEstimate,
|
||||||
@@ -31,6 +39,7 @@ const {
|
|||||||
confirmRestore,
|
confirmRestore,
|
||||||
previewSync,
|
previewSync,
|
||||||
startSync,
|
startSync,
|
||||||
|
startSiteSync,
|
||||||
retryJob,
|
retryJob,
|
||||||
cancelJob,
|
cancelJob,
|
||||||
downloadUrl
|
downloadUrl
|
||||||
@@ -45,6 +54,14 @@ const activeJob = computed(() => getActiveJob())
|
|||||||
const exportBusy = computed(() => activeJob.value?.type === 'full_export')
|
const exportBusy = computed(() => activeJob.value?.type === 'full_export')
|
||||||
const restoreBusy = computed(() => activeJob.value?.type === 'full_restore')
|
const restoreBusy = computed(() => activeJob.value?.type === 'full_restore')
|
||||||
const syncBusy = computed(() => activeJob.value?.type === 'backend_sync')
|
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))
|
const anyJobBusy = computed(() => Boolean(activeJob.value))
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
@@ -85,6 +102,14 @@ async function onCancel(jobId: string) {
|
|||||||
await cancelJob(jobId)
|
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: {
|
async function onSync(body: {
|
||||||
fromBackendId: string
|
fromBackendId: string
|
||||||
toBackendId: string
|
toBackendId: string
|
||||||
@@ -94,6 +119,10 @@ async function onSync(body: {
|
|||||||
await startSync(body)
|
await startSync(body)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function onSiteSync(body: SiteSyncRequest) {
|
||||||
|
await startSiteSync(body)
|
||||||
|
}
|
||||||
|
|
||||||
function onDownload(jobId: string) {
|
function onDownload(jobId: string) {
|
||||||
window.open(downloadUrl(jobId), '_blank')
|
window.open(downloadUrl(jobId), '_blank')
|
||||||
}
|
}
|
||||||
@@ -159,6 +188,14 @@ function onViewJob(job: BackupJobItem) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<StorageSiteSyncCard
|
||||||
|
:busy="siteSyncBusy"
|
||||||
|
:disabled="anyJobBusy && !siteSyncBusy"
|
||||||
|
:active-job="siteSyncBusy ? activeJob : null"
|
||||||
|
:resumable-job="resumableSiteSyncJob"
|
||||||
|
@sync="onSiteSync"
|
||||||
|
/>
|
||||||
|
|
||||||
<StorageBackupJobTable
|
<StorageBackupJobTable
|
||||||
:jobs="jobs"
|
:jobs="jobs"
|
||||||
:polling="polling"
|
:polling="polling"
|
||||||
@@ -167,11 +204,15 @@ function onViewJob(job: BackupJobItem) {
|
|||||||
:total="jobsTotal"
|
:total="jobsTotal"
|
||||||
:page-size="jobsPageSize"
|
:page-size="jobsPageSize"
|
||||||
:loading="jobsLoading"
|
:loading="jobsLoading"
|
||||||
|
:show-hidden="showHiddenJobs"
|
||||||
@update:page="setJobsPage"
|
@update:page="setJobsPage"
|
||||||
@update:page-size="setJobsPageSize"
|
@update:page-size="setJobsPageSize"
|
||||||
@download="onDownload"
|
@download="onDownload"
|
||||||
@retry="onRetry"
|
@retry="onRetry"
|
||||||
@cancel="onCancel"
|
@cancel="onCancel"
|
||||||
|
@hide="onHideJob"
|
||||||
|
@hide-all="onHideAll"
|
||||||
|
@toggle-hidden="toggleShowHiddenJobs"
|
||||||
@view="onViewJob"
|
@view="onViewJob"
|
||||||
/>
|
/>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -22,6 +22,13 @@ const items = computed(() => [
|
|||||||
boxClass: 'border-blue-500/20 bg-blue-500/5',
|
boxClass: 'border-blue-500/20 bg-blue-500/5',
|
||||||
title: t('storage.supportedS3Title'),
|
title: t('storage.supportedS3Title'),
|
||||||
desc: t('storage.supportedS3Desc')
|
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>
|
</script>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
export type ApiDocMethod = 'GET' | 'POST' | 'DELETE'
|
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 type ApiParamLocation = 'form-data' | 'query' | 'header' | 'body'
|
||||||
|
|
||||||
export interface ApiDocParam {
|
export interface ApiDocParam {
|
||||||
@@ -251,6 +251,659 @@ export function buildApiDocs(ctx: ApiDocsContext): ApiDocEndpoint[] {
|
|||||||
"success": true,
|
"success": true,
|
||||||
"deleted": ["images/2026/08/a.webp"],
|
"deleted": ["images/2026/08/a.webp"],
|
||||||
"failed": []
|
"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"
|
||||||
}`
|
}`
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -143,11 +143,19 @@ export function useAuth() {
|
|||||||
credentials: { username: string, password: string }
|
credentials: { username: string, password: string }
|
||||||
| { secret: string },
|
| { secret: string },
|
||||||
verification: LoginVerificationPayload
|
verification: LoginVerificationPayload
|
||||||
): Promise<{ ok: boolean, needsMigration?: boolean, error?: string }> {
|
): Promise<{
|
||||||
|
ok: boolean
|
||||||
|
needsMigration?: boolean
|
||||||
|
needsPasswordReset?: boolean
|
||||||
|
resetUsername?: string
|
||||||
|
error?: string
|
||||||
|
}> {
|
||||||
try {
|
try {
|
||||||
const result = await $fetch<{
|
const result = await $fetch<{
|
||||||
success: boolean
|
success: boolean
|
||||||
needsMigration?: boolean
|
needsMigration?: boolean
|
||||||
|
requiresPasswordReset?: boolean
|
||||||
|
username?: string
|
||||||
user?: AuthUser
|
user?: AuthUser
|
||||||
}>('/api/auth/login', {
|
}>('/api/auth/login', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -160,6 +168,13 @@ export function useAuth() {
|
|||||||
if (result.needsMigration) {
|
if (result.needsMigration) {
|
||||||
return { ok: true, needsMigration: true }
|
return { ok: true, needsMigration: true }
|
||||||
}
|
}
|
||||||
|
if (result.requiresPasswordReset) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
needsPasswordReset: true,
|
||||||
|
resetUsername: result.username
|
||||||
|
}
|
||||||
|
}
|
||||||
if (result.user) {
|
if (result.user) {
|
||||||
user.value = result.user
|
user.value = result.user
|
||||||
}
|
}
|
||||||
@@ -218,6 +233,8 @@ export function useAuth() {
|
|||||||
domainSeparation?: boolean
|
domainSeparation?: boolean
|
||||||
siteBaseUrl?: string
|
siteBaseUrl?: string
|
||||||
imageBaseUrl?: string
|
imageBaseUrl?: string
|
||||||
|
videoBaseUrl?: string
|
||||||
|
fileBaseUrl?: string
|
||||||
}): Promise<AuthActionResult> {
|
}): Promise<AuthActionResult> {
|
||||||
const validationError = validateAuthInput(input.username, input.password)
|
const validationError = validateAuthInput(input.username, input.password)
|
||||||
if (validationError) {
|
if (validationError) {
|
||||||
@@ -236,7 +253,9 @@ export function useAuth() {
|
|||||||
allowRegistration: input.allowRegistration,
|
allowRegistration: input.allowRegistration,
|
||||||
domainSeparation: input.domainSeparation,
|
domainSeparation: input.domainSeparation,
|
||||||
siteBaseUrl: input.siteBaseUrl,
|
siteBaseUrl: input.siteBaseUrl,
|
||||||
imageBaseUrl: input.imageBaseUrl
|
imageBaseUrl: input.imageBaseUrl,
|
||||||
|
videoBaseUrl: input.videoBaseUrl,
|
||||||
|
fileBaseUrl: input.fileBaseUrl
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
+112
-10
@@ -3,7 +3,8 @@ import type {
|
|||||||
BackupJobItem,
|
BackupJobItem,
|
||||||
BackupPackageItem,
|
BackupPackageItem,
|
||||||
BackupRestorePreview,
|
BackupRestorePreview,
|
||||||
BackendSyncPreview
|
BackendSyncPreview,
|
||||||
|
SiteSyncRequest
|
||||||
} from '~/types/backup'
|
} from '~/types/backup'
|
||||||
|
|
||||||
const jobs = ref<BackupJobItem[]>([])
|
const jobs = ref<BackupJobItem[]>([])
|
||||||
@@ -12,6 +13,7 @@ const jobsPageSize = ref(10)
|
|||||||
const jobsTotal = ref(0)
|
const jobsTotal = ref(0)
|
||||||
const jobsTotalPages = ref(1)
|
const jobsTotalPages = ref(1)
|
||||||
const jobsLoading = ref(false)
|
const jobsLoading = ref(false)
|
||||||
|
const showHiddenJobs = ref(false)
|
||||||
const activeJobState = ref<BackupJobItem | null>(null)
|
const activeJobState = ref<BackupJobItem | null>(null)
|
||||||
const estimate = ref<BackupEstimate | null>(null)
|
const estimate = ref<BackupEstimate | null>(null)
|
||||||
const packages = ref<BackupPackageItem[]>([])
|
const packages = ref<BackupPackageItem[]>([])
|
||||||
@@ -68,7 +70,8 @@ export function useBackup() {
|
|||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
query: {
|
query: {
|
||||||
pageNum: jobsPage.value,
|
pageNum: jobsPage.value,
|
||||||
pageSize: jobsPageSize.value
|
pageSize: jobsPageSize.value,
|
||||||
|
includeHidden: showHiddenJobs.value
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
jobs.value = data.jobs
|
jobs.value = data.jobs
|
||||||
@@ -103,6 +106,58 @@ export function useBackup() {
|
|||||||
return loadJobs()
|
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() {
|
async function loadEstimate() {
|
||||||
loadingEstimate.value = true
|
loadingEstimate.value = true
|
||||||
try {
|
try {
|
||||||
@@ -149,12 +204,17 @@ export function useBackup() {
|
|||||||
archiveName?: string
|
archiveName?: string
|
||||||
}): Promise<BackupRestorePreview> {
|
}): Promise<BackupRestorePreview> {
|
||||||
if (input.file) {
|
if (input.file) {
|
||||||
const form = new FormData()
|
|
||||||
form.append('file', input.file)
|
|
||||||
form.append('dryRun', 'true')
|
|
||||||
const data = await $fetch<{ preview: BackupRestorePreview }>(
|
const data = await $fetch<{ preview: BackupRestorePreview }>(
|
||||||
'/api/admin/backup/restore',
|
'/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
|
return data.preview
|
||||||
}
|
}
|
||||||
@@ -180,12 +240,18 @@ export function useBackup() {
|
|||||||
}) {
|
}) {
|
||||||
try {
|
try {
|
||||||
if (input.file) {
|
if (input.file) {
|
||||||
const form = new FormData()
|
|
||||||
form.append('file', input.file)
|
|
||||||
form.append('conflictMode', input.conflictMode)
|
|
||||||
const data = await $fetch<{ job: BackupJobItem }>(
|
const data = await $fetch<{ job: BackupJobItem }>(
|
||||||
'/api/admin/backup/restore',
|
'/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()
|
await refreshJobsFirstPage()
|
||||||
startPolling()
|
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) {
|
async function retryJob(jobId: string) {
|
||||||
try {
|
try {
|
||||||
await $fetch(`/api/admin/backup/jobs/${jobId}/retry`, {
|
await $fetch(`/api/admin/backup/jobs/${jobId}/retry`, {
|
||||||
@@ -295,6 +392,7 @@ export function useBackup() {
|
|||||||
jobsTotal,
|
jobsTotal,
|
||||||
jobsTotalPages,
|
jobsTotalPages,
|
||||||
jobsLoading,
|
jobsLoading,
|
||||||
|
showHiddenJobs,
|
||||||
estimate,
|
estimate,
|
||||||
packages,
|
packages,
|
||||||
loadingEstimate,
|
loadingEstimate,
|
||||||
@@ -302,6 +400,9 @@ export function useBackup() {
|
|||||||
hasActiveJob,
|
hasActiveJob,
|
||||||
getActiveJob,
|
getActiveJob,
|
||||||
loadJobs,
|
loadJobs,
|
||||||
|
toggleShowHiddenJobs,
|
||||||
|
hideJob,
|
||||||
|
hideAllJobs,
|
||||||
setJobsPage,
|
setJobsPage,
|
||||||
setJobsPageSize,
|
setJobsPageSize,
|
||||||
loadEstimate,
|
loadEstimate,
|
||||||
@@ -312,6 +413,7 @@ export function useBackup() {
|
|||||||
confirmRestore,
|
confirmRestore,
|
||||||
previewSync,
|
previewSync,
|
||||||
startSync,
|
startSync,
|
||||||
|
startSiteSync,
|
||||||
retryJob,
|
retryJob,
|
||||||
cancelJob,
|
cancelJob,
|
||||||
downloadUrl
|
downloadUrl
|
||||||
|
|||||||
@@ -4,21 +4,22 @@ import type {
|
|||||||
} from '~/types/image'
|
} from '~/types/image'
|
||||||
import { isUnauthorizedError } from './useAuth'
|
import { isUnauthorizedError } from './useAuth'
|
||||||
import { prepareFilesForUpload } from './useClipboardImage'
|
import { prepareFilesForUpload } from './useClipboardImage'
|
||||||
|
import { normalizeUploadTagNames } from './useUploadPreferences'
|
||||||
|
|
||||||
const MAX_FILES = 10
|
const MAX_FILES = 10
|
||||||
|
|
||||||
export function useImageUpload() {
|
export function useImageUpload() {
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const { compressEnabled, clientWebpQuality, lastTagIds } = useUploadPreferences()
|
const { compressEnabled, clientWebpQuality, uploadTagNames } = useUploadPreferences()
|
||||||
const { items: tagItems } = useTags()
|
const { load: loadUploadPolicy } = useUploadPolicy()
|
||||||
const uploading = ref(false)
|
const uploading = ref(false)
|
||||||
const progressItems = ref<UploadProgressItem[]>([])
|
const progressItems = ref<UploadProgressItem[]>([])
|
||||||
const lastUploadResult = ref<UploadResponse | null>(null)
|
const lastUploadResult = ref<UploadResponse | null>(null)
|
||||||
|
|
||||||
async function uploadFiles(
|
async function uploadFiles(
|
||||||
files: File[],
|
files: File[],
|
||||||
options?: { notify?: boolean, tagIds?: number[] }
|
options?: { notify?: boolean }
|
||||||
) {
|
) {
|
||||||
if (uploading.value) return
|
if (uploading.value) return
|
||||||
|
|
||||||
@@ -43,10 +44,27 @@ export function useImageUpload() {
|
|||||||
}))
|
}))
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const prepared = await prepareFilesForUpload(files, {
|
const preparedFiles = await prepareFilesForUpload(files, {
|
||||||
enabled: compressEnabled.value,
|
enabled: compressEnabled.value,
|
||||||
quality: clientWebpQuality.value / 100
|
quality: clientWebpQuality.value / 100
|
||||||
})
|
})
|
||||||
|
const currentPolicy = await loadUploadPolicy().catch(() => null)
|
||||||
|
const maxFileSizeMb = currentPolicy?.imageMaxFileSizeMb ?? 10
|
||||||
|
const prepared = preparedFiles.filter((file) => {
|
||||||
|
if (file.size <= maxFileSizeMb * 1024 * 1024) return true
|
||||||
|
toast.add({
|
||||||
|
title: t('files.fileTooLarge', {
|
||||||
|
name: file.name,
|
||||||
|
limit: maxFileSizeMb
|
||||||
|
}),
|
||||||
|
color: 'error'
|
||||||
|
})
|
||||||
|
return false
|
||||||
|
})
|
||||||
|
if (!prepared.length) {
|
||||||
|
progressItems.value = []
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
progressItems.value = prepared.map((file, index) => ({
|
progressItems.value = prepared.map((file, index) => ({
|
||||||
name: file.name,
|
name: file.name,
|
||||||
@@ -59,15 +77,9 @@ export function useImageUpload() {
|
|||||||
formData.append('files', file, file.name)
|
formData.append('files', file, file.name)
|
||||||
}
|
}
|
||||||
|
|
||||||
const tagIds = options?.tagIds ?? lastTagIds.value
|
const tagNames = normalizeUploadTagNames(uploadTagNames.value).join(',')
|
||||||
if (tagIds.length) {
|
if (tagNames) {
|
||||||
const names = tagIds
|
formData.append('tagNames', tagNames)
|
||||||
.map(id => tagItems.value.find(tag => tag.id === id)?.name)
|
|
||||||
.filter((name): name is string => Boolean(name))
|
|
||||||
if (names.length) {
|
|
||||||
formData.append('tagNames', JSON.stringify(names))
|
|
||||||
}
|
|
||||||
lastTagIds.value = tagIds
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await $fetch<UploadResponse>('/api/images/upload', {
|
const response = await $fetch<UploadResponse>('/api/images/upload', {
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
}
|
||||||
@@ -0,0 +1,340 @@
|
|||||||
|
import type { UploadProgressItem } from '~/types/image'
|
||||||
|
import type { FileItem, FileListResponse, FileUploadResponse } from '~/types/file'
|
||||||
|
import type { CopyFormat, UploadMediaKind } from './useUploadPreferences'
|
||||||
|
import { isUnauthorizedError } from './useAuth'
|
||||||
|
import { normalizeUploadTagNames } from './useUploadPreferences'
|
||||||
|
|
||||||
|
const MAX_FILES = 10
|
||||||
|
|
||||||
|
function buildCopyText(item: FileItem, format: CopyFormat): string {
|
||||||
|
switch (format) {
|
||||||
|
case 'url':
|
||||||
|
return item.url
|
||||||
|
case 'html':
|
||||||
|
return `<a href="${item.url}">${item.originalName}</a>`
|
||||||
|
case 'bbcode':
|
||||||
|
return `[url=${item.url}]${item.originalName}[/url]`
|
||||||
|
default:
|
||||||
|
return `[${item.originalName}](${item.url})`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function copyToClipboard(text: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
if (navigator.clipboard?.writeText) {
|
||||||
|
await navigator.clipboard.writeText(text)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// 内网 http:// 等非安全上下文继续走回退方案
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const textarea = document.createElement('textarea')
|
||||||
|
textarea.value = text
|
||||||
|
textarea.style.position = 'fixed'
|
||||||
|
textarea.style.left = '-9999px'
|
||||||
|
document.body.appendChild(textarea)
|
||||||
|
textarea.select()
|
||||||
|
const ok = document.execCommand('copy')
|
||||||
|
document.body.removeChild(textarea)
|
||||||
|
return ok
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 视频床 / 文件床的上传:走文件床接口,偏好按类型独立 */
|
||||||
|
export function useMediaUpload(kind: UploadMediaKind) {
|
||||||
|
const toast = useToast()
|
||||||
|
const { t } = useI18n()
|
||||||
|
const {
|
||||||
|
load: loadUploadPolicy
|
||||||
|
} = useUploadPolicy()
|
||||||
|
const {
|
||||||
|
autoCopyMarkdown,
|
||||||
|
copyFormat,
|
||||||
|
transcodeToHls,
|
||||||
|
uploadTagNames,
|
||||||
|
loadPreferences
|
||||||
|
} = useUploadPreferences(kind)
|
||||||
|
|
||||||
|
const uploading = ref(false)
|
||||||
|
const progressItems = ref<UploadProgressItem[]>([])
|
||||||
|
const lastResult = ref<FileUploadResponse | null>(null)
|
||||||
|
/** 本次会话内上传的条目,页面刷新后清空 */
|
||||||
|
const sessionItems = ref<FileItem[]>([])
|
||||||
|
const accessMode = ref<'public' | 'token'>('public')
|
||||||
|
const apiToken = ref('')
|
||||||
|
|
||||||
|
const endpoint = kind === 'video' ? '/api/videos/upload' : '/api/files/upload'
|
||||||
|
|
||||||
|
loadPreferences()
|
||||||
|
|
||||||
|
async function loadApiToken() {
|
||||||
|
try {
|
||||||
|
const data = await $fetch<{ apiUploadToken: string }>('/api/user/api-token', {
|
||||||
|
credentials: 'include'
|
||||||
|
})
|
||||||
|
apiToken.value = data.apiUploadToken ?? ''
|
||||||
|
} catch {
|
||||||
|
apiToken.value = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Token 模式下复制出的链接需要带上令牌 */
|
||||||
|
function linkUrl(item: FileItem): string {
|
||||||
|
// 转码完成后优先给出 m3u8 播放地址
|
||||||
|
const base = item.hlsUrl ?? item.url
|
||||||
|
if (accessMode.value !== 'token' || !apiToken.value) return base
|
||||||
|
const separator = base.includes('?') ? '&' : '?'
|
||||||
|
return `${base}${separator}token=${encodeURIComponent(apiToken.value)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 封面缩略图地址(同样按访问模式拼接) */
|
||||||
|
function thumbLink(item: FileItem): string {
|
||||||
|
const base = item.thumbUrl
|
||||||
|
if (!base) return '/video-placeholder.svg'
|
||||||
|
if (accessMode.value !== 'token' || !apiToken.value) return base
|
||||||
|
const separator = base.includes('?') ? '&' : '?'
|
||||||
|
return `${base}${separator}token=${encodeURIComponent(apiToken.value)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
let statusTimer: ReturnType<typeof setInterval> | null = null
|
||||||
|
|
||||||
|
function stopStatusPolling() {
|
||||||
|
if (statusTimer) {
|
||||||
|
clearInterval(statusTimer)
|
||||||
|
statusTimer = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasPendingTranscode(): boolean {
|
||||||
|
return sessionItems.value.some(
|
||||||
|
item => item.transcodeStatus === 'pending' || item.transcodeStatus === 'running'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 转码是异步的,轮询列表把状态与 m3u8 地址同步回来 */
|
||||||
|
async function refreshTranscodeStatus() {
|
||||||
|
if (!hasPendingTranscode()) {
|
||||||
|
stopStatusPolling()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const [active, finished] = await Promise.all([
|
||||||
|
$fetch<FileListResponse>('/api/files', {
|
||||||
|
credentials: 'include',
|
||||||
|
query: { kind: 'video', limit: 100, processing: '1' }
|
||||||
|
}),
|
||||||
|
$fetch<FileListResponse>('/api/files', {
|
||||||
|
credentials: 'include',
|
||||||
|
query: { kind: 'video', limit: 100 }
|
||||||
|
})
|
||||||
|
])
|
||||||
|
const latest = new Map(
|
||||||
|
[...active.items, ...finished.items].map(item => [item.key, item])
|
||||||
|
)
|
||||||
|
sessionItems.value = sessionItems.value.map(item => latest.get(item.key) ?? item)
|
||||||
|
} catch {
|
||||||
|
// 轮询失败保持现状,下个周期重试
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasPendingTranscode()) stopStatusPolling()
|
||||||
|
}
|
||||||
|
|
||||||
|
function startStatusPolling() {
|
||||||
|
if (kind !== 'video' || statusTimer) return
|
||||||
|
statusTimer = setInterval(() => {
|
||||||
|
void refreshTranscodeStatus()
|
||||||
|
}, 1000)
|
||||||
|
}
|
||||||
|
|
||||||
|
onUnmounted(stopStatusPolling)
|
||||||
|
|
||||||
|
/** 刷新或换页后仍要显示还在排队/转码中的视频 */
|
||||||
|
async function loadActiveTranscodes() {
|
||||||
|
if (kind !== 'video') return
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await $fetch<FileListResponse>('/api/files', {
|
||||||
|
credentials: 'include',
|
||||||
|
query: { kind: 'video', limit: 100, processing: '1' }
|
||||||
|
})
|
||||||
|
if (!response.items.length) return
|
||||||
|
|
||||||
|
const known = new Set(sessionItems.value.map(item => item.key))
|
||||||
|
const missing = response.items.filter(item => !known.has(item.key))
|
||||||
|
const latest = new Map(response.items.map(item => [item.key, item]))
|
||||||
|
|
||||||
|
sessionItems.value = [
|
||||||
|
...missing,
|
||||||
|
...sessionItems.value.map(item => latest.get(item.key) ?? item)
|
||||||
|
]
|
||||||
|
startStatusPolling()
|
||||||
|
} catch {
|
||||||
|
// 拉取失败时保持现状
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function uploadFiles(files: File[]) {
|
||||||
|
if (!files.length || uploading.value) return
|
||||||
|
|
||||||
|
const currentPolicy = await loadUploadPolicy().catch(() => null)
|
||||||
|
const acceptedFiles = files.filter((file) => {
|
||||||
|
const extension = file.name.split('.').pop()?.toLowerCase() ?? ''
|
||||||
|
if (
|
||||||
|
kind === 'video'
|
||||||
|
&& currentPolicy
|
||||||
|
&& !currentPolicy.allowedVideoExtensions.includes(extension)
|
||||||
|
) {
|
||||||
|
toast.add({
|
||||||
|
title: `${file.name}:${t('files.videoFormatBlocked', {
|
||||||
|
format: extension ? extension.toUpperCase() : '—'
|
||||||
|
})}`,
|
||||||
|
color: 'error'
|
||||||
|
})
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
kind === 'file'
|
||||||
|
&& currentPolicy?.forbiddenFileExtensions.includes(extension)
|
||||||
|
) {
|
||||||
|
toast.add({
|
||||||
|
title: `${file.name}:${t('files.fileFormatBlocked', {
|
||||||
|
format: extension.toUpperCase()
|
||||||
|
})}`,
|
||||||
|
color: 'error'
|
||||||
|
})
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
const maxFileSizeMb = kind === 'video'
|
||||||
|
? (currentPolicy?.videoMaxFileSizeMb ?? 500)
|
||||||
|
: (currentPolicy?.fileMaxFileSizeMb ?? 100)
|
||||||
|
if (file.size > maxFileSizeMb * 1024 * 1024) {
|
||||||
|
toast.add({
|
||||||
|
title: t('files.fileTooLarge', {
|
||||||
|
name: file.name,
|
||||||
|
limit: maxFileSizeMb
|
||||||
|
}),
|
||||||
|
color: 'error'
|
||||||
|
})
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
if (!acceptedFiles.length) return
|
||||||
|
|
||||||
|
if (acceptedFiles.length > MAX_FILES) {
|
||||||
|
toast.add({ title: t('files.maxFiles', { n: MAX_FILES }), color: 'warning' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
uploading.value = true
|
||||||
|
progressItems.value = acceptedFiles.map(file => ({
|
||||||
|
name: file.name,
|
||||||
|
status: 'pending',
|
||||||
|
progress: 0
|
||||||
|
}))
|
||||||
|
|
||||||
|
try {
|
||||||
|
const formData = new FormData()
|
||||||
|
for (const file of acceptedFiles) {
|
||||||
|
formData.append('files', file, file.name)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (kind === 'video' && transcodeToHls.value) {
|
||||||
|
formData.append('transcode', '1')
|
||||||
|
}
|
||||||
|
const tagNames = normalizeUploadTagNames(uploadTagNames.value).join(',')
|
||||||
|
if (tagNames) {
|
||||||
|
formData.append('tagNames', tagNames)
|
||||||
|
}
|
||||||
|
|
||||||
|
progressItems.value = progressItems.value.map(item => ({
|
||||||
|
...item,
|
||||||
|
status: 'uploading',
|
||||||
|
progress: 50
|
||||||
|
}))
|
||||||
|
|
||||||
|
const response = await $fetch<FileUploadResponse>(endpoint, {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData
|
||||||
|
})
|
||||||
|
|
||||||
|
lastResult.value = response
|
||||||
|
accessMode.value = response.accessMode
|
||||||
|
if (response.accessMode === 'token' && !apiToken.value) {
|
||||||
|
await loadApiToken()
|
||||||
|
}
|
||||||
|
|
||||||
|
progressItems.value = acceptedFiles.map((file) => {
|
||||||
|
const failed = response.errors.find(error => error.name === file.name)
|
||||||
|
return {
|
||||||
|
name: file.name,
|
||||||
|
status: failed ? 'error' : 'success',
|
||||||
|
progress: 100,
|
||||||
|
error: failed?.error.message
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (response.items.length) {
|
||||||
|
const wantsTranscode = kind === 'video' && transcodeToHls.value
|
||||||
|
const uploaded = wantsTranscode
|
||||||
|
? response.items.map(item => ({ ...item, transcodeStatus: 'pending' as const }))
|
||||||
|
: response.items
|
||||||
|
|
||||||
|
sessionItems.value = [...uploaded, ...sessionItems.value]
|
||||||
|
if (wantsTranscode) startStatusPolling()
|
||||||
|
|
||||||
|
toast.add({
|
||||||
|
title: t('files.uploadSuccess', { n: response.items.length }),
|
||||||
|
color: 'success'
|
||||||
|
})
|
||||||
|
|
||||||
|
const first = response.items[0]
|
||||||
|
if (autoCopyMarkdown.value && first) {
|
||||||
|
const text = buildCopyText(first, copyFormat.value)
|
||||||
|
if (text && await copyToClipboard(text)) {
|
||||||
|
toast.add({ title: t('copy.copied'), color: 'success' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const failure of response.errors.slice(0, 3)) {
|
||||||
|
toast.add({
|
||||||
|
title: `${failure.name}:${failure.error.message}`,
|
||||||
|
color: 'error'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return response
|
||||||
|
} catch (error: unknown) {
|
||||||
|
if (!isUnauthorizedError(error)) {
|
||||||
|
progressItems.value = progressItems.value.map(item => ({
|
||||||
|
...item,
|
||||||
|
status: 'error',
|
||||||
|
progress: 100,
|
||||||
|
error: t('files.uploadFailed')
|
||||||
|
}))
|
||||||
|
toast.add({ title: t('files.uploadFailed'), color: 'error' })
|
||||||
|
}
|
||||||
|
throw error
|
||||||
|
} finally {
|
||||||
|
uploading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
uploading,
|
||||||
|
progressItems,
|
||||||
|
lastResult,
|
||||||
|
sessionItems,
|
||||||
|
accessMode,
|
||||||
|
linkUrl,
|
||||||
|
thumbLink,
|
||||||
|
loadActiveTranscodes,
|
||||||
|
uploadFiles
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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}`
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,13 +1,21 @@
|
|||||||
const STORAGE_KEY = 'pichost.upload-preferences'
|
const STORAGE_KEY = 'pichost.upload-preferences'
|
||||||
|
|
||||||
export type CopyFormat = 'url' | 'markdown' | 'html' | 'bbcode'
|
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 {
|
export interface UploadPreferences {
|
||||||
compressEnabled: boolean
|
compressEnabled: boolean
|
||||||
autoCopyMarkdown: boolean
|
autoCopyMarkdown: boolean
|
||||||
copyFormat: CopyFormat
|
copyFormat: CopyFormat
|
||||||
clientWebpQuality: number
|
clientWebpQuality: number
|
||||||
lastTagIds: number[]
|
uploadTagNames: string[]
|
||||||
|
/** 视频床专属:上传后转码为 HLS(m3u8 + 分片) */
|
||||||
|
transcodeToHls: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULTS: UploadPreferences = {
|
const DEFAULTS: UploadPreferences = {
|
||||||
@@ -15,7 +23,19 @@ const DEFAULTS: UploadPreferences = {
|
|||||||
autoCopyMarkdown: false,
|
autoCopyMarkdown: false,
|
||||||
copyFormat: 'markdown',
|
copyFormat: 'markdown',
|
||||||
clientWebpQuality: 80,
|
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 {
|
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'
|
return value === 'url' || value === 'markdown' || value === 'html' || value === 'bbcode'
|
||||||
}
|
}
|
||||||
|
|
||||||
function readStored(): UploadPreferences {
|
function readStored(kind: UploadMediaKind): UploadPreferences {
|
||||||
if (import.meta.server) return { ...DEFAULTS }
|
if (import.meta.server) return { ...DEFAULTS }
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const raw = localStorage.getItem(STORAGE_KEY)
|
const raw = localStorage.getItem(storageKeyFor(kind))
|
||||||
if (!raw) return { ...DEFAULTS }
|
if (!raw) return { ...DEFAULTS }
|
||||||
const parsed = JSON.parse(raw) as Partial<UploadPreferences>
|
const parsed = JSON.parse(raw) as Partial<UploadPreferences>
|
||||||
return {
|
return {
|
||||||
@@ -40,35 +60,43 @@ function readStored(): UploadPreferences {
|
|||||||
autoCopyMarkdown: parsed.autoCopyMarkdown === true,
|
autoCopyMarkdown: parsed.autoCopyMarkdown === true,
|
||||||
copyFormat: isCopyFormat(parsed.copyFormat) ? parsed.copyFormat : DEFAULTS.copyFormat,
|
copyFormat: isCopyFormat(parsed.copyFormat) ? parsed.copyFormat : DEFAULTS.copyFormat,
|
||||||
clientWebpQuality: clampQuality(parsed.clientWebpQuality),
|
clientWebpQuality: clampQuality(parsed.clientWebpQuality),
|
||||||
lastTagIds: Array.isArray(parsed.lastTagIds)
|
/** 标签输入只在当前页面会话内生效,刷新后默认为空。 */
|
||||||
? parsed.lastTagIds.filter(id => Number.isInteger(id) && id > 0)
|
uploadTagNames: [...DEFAULTS.uploadTagNames],
|
||||||
: DEFAULTS.lastTagIds
|
transcodeToHls: parsed.transcodeToHls === true
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
return { ...DEFAULTS }
|
return { ...DEFAULTS }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function writeStored(prefs: UploadPreferences) {
|
function writeStored(kind: UploadMediaKind, prefs: UploadPreferences) {
|
||||||
if (import.meta.server) return
|
if (import.meta.server) return
|
||||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(prefs))
|
localStorage.setItem(storageKeyFor(kind), JSON.stringify(prefs))
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useUploadPreferences() {
|
export function useUploadPreferences(kind: UploadMediaKind = 'image') {
|
||||||
const compressEnabled = useState('upload-pref-compress', () => DEFAULTS.compressEnabled)
|
const compressEnabled = useState(`upload-pref-compress-${kind}`, () => DEFAULTS.compressEnabled)
|
||||||
const autoCopyMarkdown = useState('upload-pref-auto-md', () => DEFAULTS.autoCopyMarkdown)
|
const autoCopyMarkdown = useState(`upload-pref-auto-md-${kind}`, () => DEFAULTS.autoCopyMarkdown)
|
||||||
const copyFormat = useState<CopyFormat>('upload-pref-copy-format', () => DEFAULTS.copyFormat)
|
const copyFormat = useState<CopyFormat>(`upload-pref-copy-format-${kind}`, () => DEFAULTS.copyFormat)
|
||||||
const clientWebpQuality = useState('upload-pref-webp-quality', () => DEFAULTS.clientWebpQuality)
|
const clientWebpQuality = useState(`upload-pref-webp-quality-${kind}`, () => DEFAULTS.clientWebpQuality)
|
||||||
const lastTagIds = useState<number[]>('upload-pref-last-tag-ids', () => DEFAULTS.lastTagIds)
|
const uploadTagNames = useState<string[]>(
|
||||||
const loaded = useState('upload-pref-loaded', () => false)
|
`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() {
|
function loadPreferences() {
|
||||||
const stored = readStored()
|
const firstLoad = !loaded.value
|
||||||
|
const stored = readStored(kind)
|
||||||
compressEnabled.value = stored.compressEnabled
|
compressEnabled.value = stored.compressEnabled
|
||||||
autoCopyMarkdown.value = stored.autoCopyMarkdown
|
autoCopyMarkdown.value = stored.autoCopyMarkdown
|
||||||
copyFormat.value = stored.copyFormat
|
copyFormat.value = stored.copyFormat
|
||||||
clientWebpQuality.value = stored.clientWebpQuality
|
clientWebpQuality.value = stored.clientWebpQuality
|
||||||
lastTagIds.value = stored.lastTagIds
|
if (firstLoad) {
|
||||||
|
uploadTagNames.value = [...stored.uploadTagNames]
|
||||||
|
}
|
||||||
|
transcodeToHls.value = stored.transcodeToHls
|
||||||
loaded.value = true
|
loaded.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -77,12 +105,13 @@ export function useUploadPreferences() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function savePreferences() {
|
function savePreferences() {
|
||||||
writeStored({
|
writeStored(kind, {
|
||||||
compressEnabled: compressEnabled.value,
|
compressEnabled: compressEnabled.value,
|
||||||
autoCopyMarkdown: autoCopyMarkdown.value,
|
autoCopyMarkdown: autoCopyMarkdown.value,
|
||||||
copyFormat: copyFormat.value,
|
copyFormat: copyFormat.value,
|
||||||
clientWebpQuality: clampQuality(clientWebpQuality.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
|
autoCopyMarkdown.value = DEFAULTS.autoCopyMarkdown
|
||||||
copyFormat.value = DEFAULTS.copyFormat
|
copyFormat.value = DEFAULTS.copyFormat
|
||||||
clientWebpQuality.value = DEFAULTS.clientWebpQuality
|
clientWebpQuality.value = DEFAULTS.clientWebpQuality
|
||||||
|
uploadTagNames.value = [...DEFAULTS.uploadTagNames]
|
||||||
|
transcodeToHls.value = DEFAULTS.transcodeToHls
|
||||||
savePreferences()
|
savePreferences()
|
||||||
}
|
}
|
||||||
|
|
||||||
watch([compressEnabled, autoCopyMarkdown, copyFormat, clientWebpQuality, lastTagIds], () => {
|
watch(
|
||||||
if (loaded.value) {
|
[compressEnabled, autoCopyMarkdown, copyFormat, clientWebpQuality, transcodeToHls],
|
||||||
savePreferences()
|
() => {
|
||||||
|
if (loaded.value) {
|
||||||
|
savePreferences()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
kind,
|
||||||
compressEnabled,
|
compressEnabled,
|
||||||
autoCopyMarkdown,
|
autoCopyMarkdown,
|
||||||
copyFormat,
|
copyFormat,
|
||||||
clientWebpQuality,
|
clientWebpQuality,
|
||||||
lastTagIds,
|
uploadTagNames,
|
||||||
|
transcodeToHls,
|
||||||
loaded,
|
loaded,
|
||||||
loadPreferences,
|
loadPreferences,
|
||||||
savePreferences,
|
savePreferences,
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -8,6 +8,8 @@ interface UserTokenResponse {
|
|||||||
refererConfigured: boolean
|
refererConfigured: boolean
|
||||||
siteBaseUrl: string
|
siteBaseUrl: string
|
||||||
imageBaseUrl: string
|
imageBaseUrl: string
|
||||||
|
videoBaseUrl: string
|
||||||
|
fileBaseUrl: string
|
||||||
appVersion: string
|
appVersion: string
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
+21
-6
@@ -1,5 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { ImageItem } from '~/types/image'
|
import type { ImageItem } from '~/types/image'
|
||||||
|
import SiteSyncSelectedDialog from '~/components/storage/SiteSyncSelectedDialog.vue'
|
||||||
|
|
||||||
interface StatsResponse {
|
interface StatsResponse {
|
||||||
uploadToday: number
|
uploadToday: number
|
||||||
@@ -51,8 +52,7 @@ const {
|
|||||||
activeTagMode,
|
activeTagMode,
|
||||||
activeUntaggedOnly,
|
activeUntaggedOnly,
|
||||||
setActiveTagFilter,
|
setActiveTagFilter,
|
||||||
toggleTagFilter,
|
toggleTagFilter
|
||||||
listQueryParams
|
|
||||||
} = useImageList()
|
} = useImageList()
|
||||||
|
|
||||||
const { items: tagItems, fetchTags, batchUpdateTags } = useTags()
|
const { items: tagItems, fetchTags, batchUpdateTags } = useTags()
|
||||||
@@ -73,8 +73,9 @@ const batchDeleting = ref(false)
|
|||||||
const showScrollTop = ref(false)
|
const showScrollTop = ref(false)
|
||||||
const previewOpen = ref(false)
|
const previewOpen = ref(false)
|
||||||
const previewImage = ref<ImageItem | null>(null)
|
const previewImage = ref<ImageItem | null>(null)
|
||||||
const viewMode = ref<'grid' | 'list'>('grid')
|
const { viewMode } = useLibraryView('image')
|
||||||
const batchTagOpen = ref(false)
|
const batchTagOpen = ref(false)
|
||||||
|
const syncOpen = ref(false)
|
||||||
|
|
||||||
const currentUploadSource = ref('all')
|
const currentUploadSource = ref('all')
|
||||||
const currentStorageBackend = ref('all')
|
const currentStorageBackend = ref('all')
|
||||||
@@ -94,6 +95,7 @@ const storageBackendItems = computed(() => [
|
|||||||
])
|
])
|
||||||
|
|
||||||
const selectedCount = computed(() => selectedKeys.value.size)
|
const selectedCount = computed(() => selectedKeys.value.size)
|
||||||
|
const syncKeys = computed(() => [...selectedKeys.value])
|
||||||
|
|
||||||
function onWindowScroll() {
|
function onWindowScroll() {
|
||||||
showScrollTop.value = window.scrollY > 400
|
showScrollTop.value = window.scrollY > 400
|
||||||
@@ -104,9 +106,9 @@ function scrollToTop() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function fetchStats() {
|
async function fetchStats() {
|
||||||
stats.value = await $fetch<StatsResponse>('/api/stats', {
|
stats.value = await $fetch<StatsResponse>('/api/library/stats', {
|
||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
query: listQueryParams()
|
query: { kind: 'image' }
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -267,6 +269,10 @@ function goToTagSettings() {
|
|||||||
void navigateTo({ path: '/settings', query: { tab: 'tags' } })
|
void navigateTo({ path: '/settings', query: { tab: 'tags' } })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function openSync() {
|
||||||
|
syncOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
function updateSelectedKeys(keys: Set<string>) {
|
function updateSelectedKeys(keys: Set<string>) {
|
||||||
selectedKeys.value = keys
|
selectedKeys.value = keys
|
||||||
}
|
}
|
||||||
@@ -466,7 +472,8 @@ watch(isAuthenticated, async (authed, prev) => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<GalleryStatsOverview
|
<LibraryStatsPanel
|
||||||
|
kind="image"
|
||||||
:stats="stats"
|
:stats="stats"
|
||||||
:loading="statsLoading"
|
:loading="statsLoading"
|
||||||
/>
|
/>
|
||||||
@@ -495,6 +502,7 @@ watch(isAuthenticated, async (authed, prev) => {
|
|||||||
:loading="galleryLoading"
|
:loading="galleryLoading"
|
||||||
:show-batch-delete="true"
|
:show-batch-delete="true"
|
||||||
:show-batch-tags="selectedCount > 0"
|
:show-batch-tags="selectedCount > 0"
|
||||||
|
:show-batch-sync="selectedCount > 0"
|
||||||
@update:storage-backend="handleStorageBackendChange"
|
@update:storage-backend="handleStorageBackendChange"
|
||||||
@update:upload-source="handleUploadSourceChange"
|
@update:upload-source="handleUploadSourceChange"
|
||||||
@update:tag-ids="handleTagIdsChange"
|
@update:tag-ids="handleTagIdsChange"
|
||||||
@@ -505,6 +513,7 @@ watch(isAuthenticated, async (authed, prev) => {
|
|||||||
@reset="handleResetFilters"
|
@reset="handleResetFilters"
|
||||||
@batch-delete="requestBatchDelete"
|
@batch-delete="requestBatchDelete"
|
||||||
@batch-tags="batchTagOpen = true"
|
@batch-tags="batchTagOpen = true"
|
||||||
|
@batch-sync="openSync"
|
||||||
@manage-tags="goToTagSettings"
|
@manage-tags="goToTagSettings"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -591,6 +600,12 @@ watch(isAuthenticated, async (authed, prev) => {
|
|||||||
@confirm="confirmBatchTags"
|
@confirm="confirmBatchTags"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<SiteSyncSelectedDialog
|
||||||
|
v-model:open="syncOpen"
|
||||||
|
:keys="syncKeys"
|
||||||
|
scope="image"
|
||||||
|
/>
|
||||||
|
|
||||||
<UButton
|
<UButton
|
||||||
v-show="showScrollTop"
|
v-show="showScrollTop"
|
||||||
icon="i-lucide-arrow-up"
|
icon="i-lucide-arrow-up"
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import type { ImageItem, ImageTag } from '~/types/image'
|
|||||||
import { buildImageBbcode } from '~/utils/image-display'
|
import { buildImageBbcode } from '~/utils/image-display'
|
||||||
|
|
||||||
const { uploading, progressItems, uploadFiles } = useImageUpload()
|
const { uploading, progressItems, uploadFiles } = useImageUpload()
|
||||||
|
const { policy: uploadPolicy, load: loadUploadPolicy } = useUploadPolicy()
|
||||||
|
|
||||||
const {
|
const {
|
||||||
autoCopyMarkdown,
|
autoCopyMarkdown,
|
||||||
@@ -16,6 +17,9 @@ const { recordRecent } = useRecentTags()
|
|||||||
const { isChecking, isAuthenticated, checkSession, handleAuthError, fetchStatus } = useAuth()
|
const { isChecking, isAuthenticated, checkSession, handleAuthError, fetchStatus } = useAuth()
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
const imageFormats = computed(() =>
|
||||||
|
t('upload.formats', { size: uploadPolicy.value?.imageMaxFileSizeMb ?? 10 })
|
||||||
|
)
|
||||||
const sessionItems = ref<ImageItem[]>([])
|
const sessionItems = ref<ImageItem[]>([])
|
||||||
const deletingKeys = ref<Set<string>>(new Set())
|
const deletingKeys = ref<Set<string>>(new Set())
|
||||||
const emptySelection = ref(new Set<string>())
|
const emptySelection = ref(new Set<string>())
|
||||||
@@ -39,6 +43,7 @@ const showProgress = computed(
|
|||||||
async function loadPage() {
|
async function loadPage() {
|
||||||
try {
|
try {
|
||||||
loadPreferences()
|
loadPreferences()
|
||||||
|
await loadUploadPolicy().catch(() => {})
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
handleAuthError(error)
|
handleAuthError(error)
|
||||||
}
|
}
|
||||||
@@ -227,6 +232,7 @@ async function confirmBatchTags(tagIds: number[]) {
|
|||||||
<AppShell v-else>
|
<AppShell v-else>
|
||||||
<UploadFlipCard
|
<UploadFlipCard
|
||||||
:disabled="uploading"
|
:disabled="uploading"
|
||||||
|
:formats="imageFormats"
|
||||||
@upload="handleUpload"
|
@upload="handleUpload"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -1,15 +1,16 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { SettingsTab } from '~/types/settings'
|
import type { SettingsTab } from '~/types/settings'
|
||||||
import { ADMIN_SETTINGS_TABS, USER_SETTINGS_TABS } 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 SettingsUserPreferencesFields from '~/components/settings/SettingsUserPreferencesFields.vue'
|
||||||
import SettingsUploadPolicyFields from '~/components/settings/SettingsUploadPolicyFields.vue'
|
import SettingsUploadPolicyFields from '~/components/settings/SettingsUploadPolicyFields.vue'
|
||||||
|
import SettingsVideoProcessingFields from '~/components/settings/SettingsVideoProcessingFields.vue'
|
||||||
|
|
||||||
type SettingSource = 'env' | 'db' | 'none'
|
type SettingSource = 'env' | 'db' | 'none'
|
||||||
type WebpQualitySource = 'env' | 'db' | 'default'
|
type WebpQualitySource = 'env' | 'db' | 'default'
|
||||||
type PolicySource = 'env' | 'db' | 'default'
|
type PolicySource = 'env' | 'db' | 'default'
|
||||||
type LoginVerificationMethod = 'slider' | 'turnstile' | 'cap'
|
type LoginVerificationMethod = 'slider' | 'turnstile' | 'cap'
|
||||||
type MimeType = 'image/jpeg' | 'image/png' | 'image/webp' | 'image/gif' | 'image/svg+xml' | 'image/x-icon'
|
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 {
|
interface SettingsResponse {
|
||||||
apiUploadToken: string
|
apiUploadToken: string
|
||||||
@@ -23,7 +24,16 @@ interface SettingsResponse {
|
|||||||
imageBaseUrl: string
|
imageBaseUrl: string
|
||||||
imageBaseUrlConfigured: string
|
imageBaseUrlConfigured: string
|
||||||
imageBaseUrlSource: SettingSource
|
imageBaseUrlSource: SettingSource
|
||||||
|
imageDomainSeparated: boolean
|
||||||
|
videoBaseUrl: string
|
||||||
|
videoBaseUrlSource: SettingSource
|
||||||
|
videoDomainSeparated: boolean
|
||||||
|
fileBaseUrl: string
|
||||||
|
fileBaseUrlSource: SettingSource
|
||||||
|
fileDomainSeparated: boolean
|
||||||
effectiveImageBaseUrl: string
|
effectiveImageBaseUrl: string
|
||||||
|
effectiveVideoBaseUrl: string
|
||||||
|
effectiveFileBaseUrl: string
|
||||||
siteBaseUrl: string
|
siteBaseUrl: string
|
||||||
siteBaseUrlConfigured: string
|
siteBaseUrlConfigured: string
|
||||||
siteBaseUrlSource: SettingSource
|
siteBaseUrlSource: SettingSource
|
||||||
@@ -45,10 +55,18 @@ interface SettingsResponse {
|
|||||||
capApiEndpoint: string
|
capApiEndpoint: string
|
||||||
capSecret: string
|
capSecret: string
|
||||||
appVersion: string
|
appVersion: string
|
||||||
uploadMaxFileSizeMb: number
|
uploadImageMaxFileSizeMb: number
|
||||||
uploadMaxFileSizeMbSource: PolicySource
|
uploadImageMaxFileSizeMbSource: PolicySource
|
||||||
|
uploadVideoMaxFileSizeMb: number
|
||||||
|
uploadVideoMaxFileSizeMbSource: PolicySource
|
||||||
|
uploadFileMaxFileSizeMb: number
|
||||||
|
uploadFileMaxFileSizeMbSource: PolicySource
|
||||||
allowedMimeTypes: MimeType[]
|
allowedMimeTypes: MimeType[]
|
||||||
allowedMimeTypesSource: PolicySource
|
allowedMimeTypesSource: PolicySource
|
||||||
|
allowedVideoExtensions: VideoExtension[]
|
||||||
|
allowedVideoExtensionsSource: PolicySource
|
||||||
|
forbiddenFileExtensions: string[]
|
||||||
|
forbiddenFileExtensionsSource: PolicySource
|
||||||
uploadRateIpMax: number
|
uploadRateIpMax: number
|
||||||
uploadRateIpMaxSource: PolicySource
|
uploadRateIpMaxSource: PolicySource
|
||||||
uploadRateTokenMax: number
|
uploadRateTokenMax: number
|
||||||
@@ -61,7 +79,10 @@ interface SettingsResponse {
|
|||||||
loginRateWindowMinutesSource: PolicySource
|
loginRateWindowMinutesSource: PolicySource
|
||||||
preserveOriginalUpload: boolean
|
preserveOriginalUpload: boolean
|
||||||
preserveOriginalUploadSource: PolicySource
|
preserveOriginalUploadSource: PolicySource
|
||||||
|
fileBedAccess: 'public' | 'token'
|
||||||
mimeTypeOptions: ReadonlyArray<{ mime: MimeType, label: string }>
|
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()
|
const { isChecking, isAuthenticated, checkSession, handleAuthError, fetchStatus, isAdmin } = useAuth()
|
||||||
@@ -135,8 +156,11 @@ const showSaveBar = computed(() =>
|
|||||||
)
|
)
|
||||||
|
|
||||||
const checkingRelease = ref(false)
|
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: reloadUserSettings } = useUserAutoDeleteSettings()
|
||||||
|
const { load: reloadUploadPolicy } = useUploadPolicy()
|
||||||
|
|
||||||
const domainSeparationDocUrl = computed(() =>
|
const domainSeparationDocUrl = computed(() =>
|
||||||
locale.value === 'en'
|
locale.value === 'en'
|
||||||
@@ -150,6 +174,11 @@ const savingServer = ref(false)
|
|||||||
const refererDraft = ref('')
|
const refererDraft = ref('')
|
||||||
const siteBaseUrlDraft = ref('')
|
const siteBaseUrlDraft = ref('')
|
||||||
const imageBaseUrlDraft = 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 hideFolderInUrlDraft = ref(false)
|
||||||
const storageUseDatePathDraft = ref(true)
|
const storageUseDatePathDraft = ref(true)
|
||||||
const allowRegistrationDraft = ref(false)
|
const allowRegistrationDraft = ref(false)
|
||||||
@@ -158,19 +187,34 @@ const turnstileSiteKeyDraft = ref('')
|
|||||||
const turnstileSecretKeyDraft = ref('')
|
const turnstileSecretKeyDraft = ref('')
|
||||||
const capApiEndpointDraft = ref('')
|
const capApiEndpointDraft = ref('')
|
||||||
const capSecretDraft = ref('')
|
const capSecretDraft = ref('')
|
||||||
const domainSeparationDraft = ref(false)
|
|
||||||
const disableDomainSeparationOpen = ref(false)
|
|
||||||
const pendingDomainSeparationDisable = ref(false)
|
|
||||||
const domainSeparationSaveConfirmOpen = 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 allowedMimeTypesDraft = ref<MimeType[]>([])
|
||||||
|
const allowedVideoExtensionsDraft = ref<VideoExtension[]>([])
|
||||||
|
const forbiddenFileExtensionsDraft = ref<string[]>([])
|
||||||
const uploadRateIpMaxDraft = ref(60)
|
const uploadRateIpMaxDraft = ref(60)
|
||||||
const uploadRateTokenMaxDraft = ref(120)
|
const uploadRateTokenMaxDraft = ref(120)
|
||||||
const uploadRateWindowMinutesDraft = ref(15)
|
const uploadRateWindowMinutesDraft = ref(15)
|
||||||
const loginRateMaxDraft = ref(10)
|
const loginRateMaxDraft = ref(10)
|
||||||
const loginRateWindowMinutesDraft = ref(15)
|
const loginRateWindowMinutesDraft = ref(15)
|
||||||
const preserveOriginalUploadDraft = ref(false)
|
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 {
|
interface ReleaseCheckResponse {
|
||||||
currentVersion: string
|
currentVersion: string
|
||||||
@@ -188,9 +232,14 @@ function mimeListsEqual(left: readonly string[], right: readonly string[]): bool
|
|||||||
const hasServerChanges = computed(() => {
|
const hasServerChanges = computed(() => {
|
||||||
if (!settings.value) return false
|
if (!settings.value) return false
|
||||||
return refererDraft.value !== settings.value.allowedRefererHosts
|
return refererDraft.value !== settings.value.allowedRefererHosts
|
||||||
|| domainSeparationDraft.value !== settings.value.domainSeparation
|
|| anyResourceDomainSeparated.value !== settings.value.domainSeparation
|
||||||
|| siteBaseUrlDraft.value !== (settings.value.domainSeparation ? settings.value.siteBaseUrl : '')
|
|| siteBaseUrlDraft.value !== settings.value.siteBaseUrl
|
||||||
|| imageBaseUrlDraft.value !== settings.value.imageBaseUrl
|
|| 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
|
|| hideFolderInUrlDraft.value !== settings.value.hideFolderInUrl
|
||||||
|| storageUseDatePathDraft.value !== settings.value.storageUseDatePath
|
|| storageUseDatePathDraft.value !== settings.value.storageUseDatePath
|
||||||
|| allowRegistrationDraft.value !== settings.value.allowRegistration
|
|| allowRegistrationDraft.value !== settings.value.allowRegistration
|
||||||
@@ -199,29 +248,42 @@ const hasServerChanges = computed(() => {
|
|||||||
|| turnstileSecretKeyDraft.value !== settings.value.turnstileSecretKey
|
|| turnstileSecretKeyDraft.value !== settings.value.turnstileSecretKey
|
||||||
|| capApiEndpointDraft.value !== settings.value.capApiEndpoint
|
|| capApiEndpointDraft.value !== settings.value.capApiEndpoint
|
||||||
|| capSecretDraft.value !== settings.value.capSecret
|
|| 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(allowedMimeTypesDraft.value, settings.value.allowedMimeTypes)
|
||||||
|
|| !mimeListsEqual(allowedVideoExtensionsDraft.value, settings.value.allowedVideoExtensions)
|
||||||
|
|| !mimeListsEqual(forbiddenFileExtensionsDraft.value, settings.value.forbiddenFileExtensions)
|
||||||
|| uploadRateIpMaxDraft.value !== settings.value.uploadRateIpMax
|
|| uploadRateIpMaxDraft.value !== settings.value.uploadRateIpMax
|
||||||
|| uploadRateTokenMaxDraft.value !== settings.value.uploadRateTokenMax
|
|| uploadRateTokenMaxDraft.value !== settings.value.uploadRateTokenMax
|
||||||
|| uploadRateWindowMinutesDraft.value !== settings.value.uploadRateWindowMinutes
|
|| uploadRateWindowMinutesDraft.value !== settings.value.uploadRateWindowMinutes
|
||||||
|| loginRateMaxDraft.value !== settings.value.loginRateMax
|
|| loginRateMaxDraft.value !== settings.value.loginRateMax
|
||||||
|| loginRateWindowMinutesDraft.value !== settings.value.loginRateWindowMinutes
|
|| loginRateWindowMinutesDraft.value !== settings.value.loginRateWindowMinutes
|
||||||
|| preserveOriginalUploadDraft.value !== settings.value.preserveOriginalUpload
|
|| preserveOriginalUploadDraft.value !== settings.value.preserveOriginalUpload
|
||||||
|
|| fileBedAccessDraft.value !== settings.value.fileBedAccess
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const anyResourceDomainSeparated = computed(() =>
|
||||||
|
imageDomainSeparatedDraft.value
|
||||||
|
|| videoDomainSeparatedDraft.value
|
||||||
|
|| fileDomainSeparatedDraft.value
|
||||||
|
)
|
||||||
|
|
||||||
const domainSeparationWouldActivate = computed(() =>
|
const domainSeparationWouldActivate = computed(() =>
|
||||||
willActivateDomainSeparation(
|
anyResourceDomainSeparated.value
|
||||||
domainSeparationDraft.value,
|
&& Boolean(siteBaseUrlDraft.value.trim())
|
||||||
siteBaseUrlDraft.value,
|
|
||||||
imageBaseUrlDraft.value
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const domainSeparationFieldsChanged = computed(() => {
|
const domainSeparationFieldsChanged = computed(() => {
|
||||||
if (!settings.value) return false
|
if (!settings.value) return false
|
||||||
return domainSeparationDraft.value !== settings.value.domainSeparation
|
return anyResourceDomainSeparated.value !== settings.value.domainSeparation
|
||||||
|| siteBaseUrlDraft.value !== (settings.value.domainSeparation ? settings.value.siteBaseUrl : '')
|
|| siteBaseUrlDraft.value !== settings.value.siteBaseUrl
|
||||||
|| imageBaseUrlDraft.value !== settings.value.imageBaseUrl
|
|| 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(() =>
|
const needsDomainSeparationSaveConfirm = computed(() =>
|
||||||
@@ -251,8 +313,13 @@ function errorStatus(error: unknown): number {
|
|||||||
function applySettings(data: SettingsResponse) {
|
function applySettings(data: SettingsResponse) {
|
||||||
settings.value = data
|
settings.value = data
|
||||||
refererDraft.value = data.allowedRefererHosts
|
refererDraft.value = data.allowedRefererHosts
|
||||||
siteBaseUrlDraft.value = data.domainSeparation ? data.siteBaseUrl : ''
|
siteBaseUrlDraft.value = data.siteBaseUrl
|
||||||
imageBaseUrlDraft.value = data.imageBaseUrl
|
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
|
hideFolderInUrlDraft.value = data.hideFolderInUrl
|
||||||
storageUseDatePathDraft.value = data.storageUseDatePath
|
storageUseDatePathDraft.value = data.storageUseDatePath
|
||||||
allowRegistrationDraft.value = data.allowRegistration
|
allowRegistrationDraft.value = data.allowRegistration
|
||||||
@@ -261,15 +328,19 @@ function applySettings(data: SettingsResponse) {
|
|||||||
turnstileSecretKeyDraft.value = data.turnstileSecretKey
|
turnstileSecretKeyDraft.value = data.turnstileSecretKey
|
||||||
capApiEndpointDraft.value = data.capApiEndpoint
|
capApiEndpointDraft.value = data.capApiEndpoint
|
||||||
capSecretDraft.value = data.capSecret
|
capSecretDraft.value = data.capSecret
|
||||||
domainSeparationDraft.value = data.domainSeparation
|
uploadImageMaxFileSizeMbDraft.value = data.uploadImageMaxFileSizeMb
|
||||||
uploadMaxFileSizeMbDraft.value = data.uploadMaxFileSizeMb
|
uploadVideoMaxFileSizeMbDraft.value = data.uploadVideoMaxFileSizeMb
|
||||||
|
uploadFileMaxFileSizeMbDraft.value = data.uploadFileMaxFileSizeMb
|
||||||
allowedMimeTypesDraft.value = [...data.allowedMimeTypes]
|
allowedMimeTypesDraft.value = [...data.allowedMimeTypes]
|
||||||
|
allowedVideoExtensionsDraft.value = [...data.allowedVideoExtensions]
|
||||||
|
forbiddenFileExtensionsDraft.value = [...data.forbiddenFileExtensions]
|
||||||
uploadRateIpMaxDraft.value = data.uploadRateIpMax
|
uploadRateIpMaxDraft.value = data.uploadRateIpMax
|
||||||
uploadRateTokenMaxDraft.value = data.uploadRateTokenMax
|
uploadRateTokenMaxDraft.value = data.uploadRateTokenMax
|
||||||
uploadRateWindowMinutesDraft.value = data.uploadRateWindowMinutes
|
uploadRateWindowMinutesDraft.value = data.uploadRateWindowMinutes
|
||||||
loginRateMaxDraft.value = data.loginRateMax
|
loginRateMaxDraft.value = data.loginRateMax
|
||||||
loginRateWindowMinutesDraft.value = data.loginRateWindowMinutes
|
loginRateWindowMinutesDraft.value = data.loginRateWindowMinutes
|
||||||
preserveOriginalUploadDraft.value = data.preserveOriginalUpload
|
preserveOriginalUploadDraft.value = data.preserveOriginalUpload
|
||||||
|
fileBedAccessDraft.value = data.fileBedAccess
|
||||||
}
|
}
|
||||||
|
|
||||||
async function patchSettings(body: Record<string, unknown>) {
|
async function patchSettings(body: Record<string, unknown>) {
|
||||||
@@ -293,38 +364,28 @@ function handlePatchError(error: unknown, fallback: string) {
|
|||||||
toast.add({ title: fallback, color: 'error' })
|
toast.add({ title: fallback, color: 'error' })
|
||||||
}
|
}
|
||||||
|
|
||||||
function fillDetectedSiteOrigin() {
|
function fillDetectedOrigin(target: 'site' | 'image' | 'video' | 'file') {
|
||||||
if (settings.value?.runtime.currentOrigin) {
|
const origin = settings.value?.runtime.currentOrigin
|
||||||
siteBaseUrlDraft.value = 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') {
|
function effectiveResourceDraft(
|
||||||
const enabled = next === true
|
separated: boolean,
|
||||||
if (!enabled && domainSeparationDraft.value) {
|
configuredUrl: string
|
||||||
pendingDomainSeparationDisable.value = true
|
): string {
|
||||||
disableDomainSeparationOpen.value = true
|
return separated
|
||||||
return
|
? configuredUrl.trim() || settings.value?.runtime.currentOrigin || ''
|
||||||
}
|
: siteBaseUrlDraft.value.trim() || settings.value?.runtime.currentOrigin || ''
|
||||||
domainSeparationDraft.value = enabled
|
|
||||||
}
|
|
||||||
|
|
||||||
function confirmDisableDomainSeparation() {
|
|
||||||
domainSeparationDraft.value = false
|
|
||||||
siteBaseUrlDraft.value = ''
|
|
||||||
disableDomainSeparationOpen.value = false
|
|
||||||
pendingDomainSeparationDisable.value = false
|
|
||||||
}
|
|
||||||
|
|
||||||
function cancelDisableDomainSeparation() {
|
|
||||||
disableDomainSeparationOpen.value = false
|
|
||||||
pendingDomainSeparationDisable.value = false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function saveServerSettings() {
|
async function saveServerSettings() {
|
||||||
if (!isAuthenticated.value || !hasServerChanges.value) return
|
if (!isAuthenticated.value || !hasServerChanges.value) return
|
||||||
|
|
||||||
if (domainSeparationDraft.value) {
|
if (anyResourceDomainSeparated.value) {
|
||||||
if (!siteBaseUrlDraft.value.trim() || !imageBaseUrlDraft.value.trim()) {
|
if (!siteBaseUrlDraft.value.trim() || !imageBaseUrlDraft.value.trim()) {
|
||||||
toast.add({ title: t('settings.domainSeparationRequired'), color: 'error' })
|
toast.add({ title: t('settings.domainSeparationRequired'), color: 'error' })
|
||||||
return
|
return
|
||||||
@@ -349,12 +410,21 @@ async function performSaveServerSettings() {
|
|||||||
toast.add({ title: t('settings.allowedMimeTypesHint'), color: 'error' })
|
toast.add({ title: t('settings.allowedMimeTypesHint'), color: 'error' })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if (allowedVideoExtensionsDraft.value.length === 0) {
|
||||||
|
toast.add({ title: t('settings.allowedVideoExtensionsHint'), color: 'error' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
await patchSettings({
|
await patchSettings({
|
||||||
allowedRefererHosts: refererDraft.value,
|
allowedRefererHosts: refererDraft.value,
|
||||||
domainSeparation: domainSeparationDraft.value,
|
domainSeparation: anyResourceDomainSeparated.value,
|
||||||
siteBaseUrl: domainSeparationDraft.value ? siteBaseUrlDraft.value : '',
|
siteBaseUrl: siteBaseUrlDraft.value,
|
||||||
imageBaseUrl: imageBaseUrlDraft.value,
|
imageBaseUrl: imageBaseUrlDraft.value,
|
||||||
|
videoBaseUrl: videoBaseUrlDraft.value,
|
||||||
|
fileBaseUrl: fileBaseUrlDraft.value,
|
||||||
|
imageDomainSeparated: imageDomainSeparatedDraft.value,
|
||||||
|
videoDomainSeparated: videoDomainSeparatedDraft.value,
|
||||||
|
fileDomainSeparated: fileDomainSeparatedDraft.value,
|
||||||
hideFolderInUrl: hideFolderInUrlDraft.value,
|
hideFolderInUrl: hideFolderInUrlDraft.value,
|
||||||
storageUseDatePath: storageUseDatePathDraft.value,
|
storageUseDatePath: storageUseDatePathDraft.value,
|
||||||
allowRegistration: allowRegistrationDraft.value,
|
allowRegistration: allowRegistrationDraft.value,
|
||||||
@@ -363,15 +433,21 @@ async function performSaveServerSettings() {
|
|||||||
turnstileSecretKey: turnstileSecretKeyDraft.value,
|
turnstileSecretKey: turnstileSecretKeyDraft.value,
|
||||||
capApiEndpoint: capApiEndpointDraft.value,
|
capApiEndpoint: capApiEndpointDraft.value,
|
||||||
capSecret: capSecretDraft.value,
|
capSecret: capSecretDraft.value,
|
||||||
uploadMaxFileSizeMb: uploadMaxFileSizeMbDraft.value,
|
uploadImageMaxFileSizeMb: uploadImageMaxFileSizeMbDraft.value,
|
||||||
|
uploadVideoMaxFileSizeMb: uploadVideoMaxFileSizeMbDraft.value,
|
||||||
|
uploadFileMaxFileSizeMb: uploadFileMaxFileSizeMbDraft.value,
|
||||||
allowedMimeTypes: allowedMimeTypesDraft.value,
|
allowedMimeTypes: allowedMimeTypesDraft.value,
|
||||||
|
allowedVideoExtensions: allowedVideoExtensionsDraft.value,
|
||||||
|
forbiddenFileExtensions: forbiddenFileExtensionsDraft.value,
|
||||||
uploadRateIpMax: uploadRateIpMaxDraft.value,
|
uploadRateIpMax: uploadRateIpMaxDraft.value,
|
||||||
uploadRateTokenMax: uploadRateTokenMaxDraft.value,
|
uploadRateTokenMax: uploadRateTokenMaxDraft.value,
|
||||||
uploadRateWindowMinutes: uploadRateWindowMinutesDraft.value,
|
uploadRateWindowMinutes: uploadRateWindowMinutesDraft.value,
|
||||||
loginRateMax: loginRateMaxDraft.value,
|
loginRateMax: loginRateMaxDraft.value,
|
||||||
loginRateWindowMinutes: loginRateWindowMinutesDraft.value,
|
loginRateWindowMinutes: loginRateWindowMinutesDraft.value,
|
||||||
preserveOriginalUpload: preserveOriginalUploadDraft.value
|
preserveOriginalUpload: preserveOriginalUploadDraft.value,
|
||||||
|
fileBedAccess: fileBedAccessDraft.value
|
||||||
})
|
})
|
||||||
|
await reloadUploadPolicy(true)
|
||||||
toast.add({ title: t('settings.saved'), color: 'success' })
|
toast.add({ title: t('settings.saved'), color: 'success' })
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
handlePatchError(error, t('settings.saveFailed'))
|
handlePatchError(error, t('settings.saveFailed'))
|
||||||
@@ -448,7 +524,9 @@ const loginVerificationOptions = computed(() => [
|
|||||||
|
|
||||||
function restoreDefaults() {
|
function restoreDefaults() {
|
||||||
if (activeTab.value === 'basic') {
|
if (activeTab.value === 'basic') {
|
||||||
resetUploadPreferences()
|
resetImagePreferences()
|
||||||
|
resetVideoPreferences()
|
||||||
|
resetFilePreferences()
|
||||||
void reloadUserSettings()
|
void reloadUserSettings()
|
||||||
}
|
}
|
||||||
if (settings.value) {
|
if (settings.value) {
|
||||||
@@ -560,18 +638,28 @@ watch(isAdmin, () => {
|
|||||||
>
|
>
|
||||||
<SettingsGroup>
|
<SettingsGroup>
|
||||||
<SettingsUploadPolicyFields
|
<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-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-ip-max="uploadRateIpMaxDraft"
|
||||||
v-model:upload-rate-token-max="uploadRateTokenMaxDraft"
|
v-model:upload-rate-token-max="uploadRateTokenMaxDraft"
|
||||||
v-model:upload-rate-window-minutes="uploadRateWindowMinutesDraft"
|
v-model:upload-rate-window-minutes="uploadRateWindowMinutesDraft"
|
||||||
part="upload-preference"
|
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-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-ip-max-source="settings.uploadRateIpMaxSource"
|
||||||
:upload-rate-token-max-source="settings.uploadRateTokenMaxSource"
|
:upload-rate-token-max-source="settings.uploadRateTokenMaxSource"
|
||||||
:upload-rate-window-minutes-source="settings.uploadRateWindowMinutesSource"
|
:upload-rate-window-minutes-source="settings.uploadRateWindowMinutesSource"
|
||||||
:mime-type-options="settings.mimeTypeOptions"
|
:mime-type-options="settings.mimeTypeOptions"
|
||||||
|
:video-extension-options="settings.videoExtensionOptions"
|
||||||
|
:forbidden-file-extension-options="settings.forbiddenFileExtensionOptions"
|
||||||
/>
|
/>
|
||||||
<SettingsUserPreferencesFields part="upload-preference" />
|
<SettingsUserPreferencesFields part="upload-preference" />
|
||||||
</SettingsGroup>
|
</SettingsGroup>
|
||||||
@@ -591,6 +679,15 @@ watch(isAdmin, () => {
|
|||||||
</SettingsGroup>
|
</SettingsGroup>
|
||||||
</SettingsSection>
|
</SettingsSection>
|
||||||
|
|
||||||
|
<SettingsSection
|
||||||
|
:title="t('preferences.videoProcessing')"
|
||||||
|
:hint="t('settings.videoProcessingHint')"
|
||||||
|
>
|
||||||
|
<SettingsGroup>
|
||||||
|
<SettingsVideoProcessingFields />
|
||||||
|
</SettingsGroup>
|
||||||
|
</SettingsSection>
|
||||||
|
|
||||||
<SettingsSection :title="t('settings.systemInfo')">
|
<SettingsSection :title="t('settings.systemInfo')">
|
||||||
<SettingsGroup>
|
<SettingsGroup>
|
||||||
<SettingsSystemInfo
|
<SettingsSystemInfo
|
||||||
@@ -629,14 +726,8 @@ watch(isAdmin, () => {
|
|||||||
{{ t('settings.runtimeDetected', { url: settings.runtime.currentOrigin }) }}
|
{{ t('settings.runtimeDetected', { url: settings.runtime.currentOrigin }) }}
|
||||||
</span>
|
</span>
|
||||||
</p>
|
</p>
|
||||||
<SettingsToggleRow
|
|
||||||
:model-value="domainSeparationDraft"
|
|
||||||
:title="t('setup.domainSeparation')"
|
|
||||||
:hint="t('setup.domainSeparationHint')"
|
|
||||||
@update:model-value="onDomainSeparationDraftChange"
|
|
||||||
/>
|
|
||||||
<div
|
<div
|
||||||
v-if="domainSeparationDraft"
|
v-if="anyResourceDomainSeparated"
|
||||||
class="py-4"
|
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">
|
<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"
|
class="mt-4"
|
||||||
/>
|
/>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</SettingsSection>
|
</SettingsSection>
|
||||||
|
|
||||||
@@ -722,24 +824,17 @@ watch(isAdmin, () => {
|
|||||||
</SettingsGroup>
|
</SettingsGroup>
|
||||||
</SettingsSection>
|
</SettingsSection>
|
||||||
|
|
||||||
<SettingsSection :title="t('settings.imageDomainSection')">
|
<SettingsSection
|
||||||
<div
|
v-if="false && settings"
|
||||||
v-if="domainSeparationDraft"
|
:title="t('settings.resourceDomainSection')"
|
||||||
class="grid gap-4 sm:grid-cols-2 sm:divide-x sm:divide-default"
|
>
|
||||||
>
|
<div class="grid gap-4 sm:grid-cols-2">
|
||||||
<SettingsGroup>
|
<SettingsGroup>
|
||||||
<div class="space-y-3 py-4 sm:pr-4">
|
<div class="space-y-3 py-4 sm:pr-4">
|
||||||
<div class="flex flex-wrap items-center gap-2">
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
<p class="text-sm font-medium text-highlighted">
|
<p class="text-sm font-medium text-highlighted">
|
||||||
{{ t('settings.siteBaseUrl') }}
|
{{ t('settings.siteBaseUrl') }}
|
||||||
</p>
|
</p>
|
||||||
<UBadge
|
|
||||||
color="success"
|
|
||||||
variant="subtle"
|
|
||||||
size="xs"
|
|
||||||
>
|
|
||||||
{{ t('settings.badgeEnabled') }}
|
|
||||||
</UBadge>
|
|
||||||
</div>
|
</div>
|
||||||
<p class="text-xs leading-relaxed text-muted">
|
<p class="text-xs leading-relaxed text-muted">
|
||||||
{{ t('settings.siteBaseUrlRoleHint') }}
|
{{ t('settings.siteBaseUrlRoleHint') }}
|
||||||
@@ -752,21 +847,21 @@ watch(isAdmin, () => {
|
|||||||
class="min-w-0 flex-1 font-mono"
|
class="min-w-0 flex-1 font-mono"
|
||||||
/>
|
/>
|
||||||
<UButton
|
<UButton
|
||||||
v-if="settings.runtime.currentOrigin"
|
v-if="settings?.runtime.currentOrigin"
|
||||||
:label="t('settings.fillDetectedOrigin')"
|
:label="t('settings.fillDetectedOrigin')"
|
||||||
color="neutral"
|
color="neutral"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
class="shrink-0"
|
class="shrink-0"
|
||||||
@click="fillDetectedSiteOrigin"
|
@click="fillDetectedOrigin('site')"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<p
|
<p
|
||||||
v-if="settings.effectiveSiteBaseUrl"
|
v-if="settings?.effectiveSiteBaseUrl"
|
||||||
class="truncate rounded-lg bg-muted/30 px-3 py-2 text-xs text-muted"
|
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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</SettingsGroup>
|
</SettingsGroup>
|
||||||
@@ -777,74 +872,129 @@ watch(isAdmin, () => {
|
|||||||
<p class="text-sm font-medium text-highlighted">
|
<p class="text-sm font-medium text-highlighted">
|
||||||
{{ t('settings.imageBaseUrl') }}
|
{{ t('settings.imageBaseUrl') }}
|
||||||
</p>
|
</p>
|
||||||
<UBadge
|
<USwitch v-model="imageDomainSeparatedDraft" />
|
||||||
color="success"
|
|
||||||
variant="subtle"
|
|
||||||
size="xs"
|
|
||||||
>
|
|
||||||
{{ t('settings.badgeEnabled') }}
|
|
||||||
</UBadge>
|
|
||||||
</div>
|
</div>
|
||||||
<p class="text-xs leading-relaxed text-muted">
|
<p class="text-xs leading-relaxed text-muted">
|
||||||
{{ t('settings.imageBaseUrlRoleHint') }}
|
{{ t('settings.imageBaseUrlRoleHint') }}
|
||||||
</p>
|
</p>
|
||||||
<UInput
|
<div
|
||||||
v-model="imageBaseUrlDraft"
|
class="flex gap-2"
|
||||||
size="sm"
|
:class="imageDomainSeparatedDraft ? '' : 'pointer-events-none opacity-50'"
|
||||||
: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 }) }}
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</SettingsGroup>
|
</SettingsGroup>
|
||||||
</div>
|
</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>
|
</SettingsSection>
|
||||||
</SettingsPanel>
|
</SettingsPanel>
|
||||||
<div
|
<div
|
||||||
@@ -923,6 +1073,18 @@ watch(isAdmin, () => {
|
|||||||
</div>
|
</div>
|
||||||
</SettingsPlainSection>
|
</SettingsPlainSection>
|
||||||
|
|
||||||
|
<SettingsSection
|
||||||
|
:title="t('settings.fileBed')"
|
||||||
|
:hint="t('settings.fileBedHint')"
|
||||||
|
>
|
||||||
|
<SettingsGroup>
|
||||||
|
<SettingsRadioGroup
|
||||||
|
v-model="fileBedAccessDraft"
|
||||||
|
:items="fileBedAccessOptions"
|
||||||
|
/>
|
||||||
|
</SettingsGroup>
|
||||||
|
</SettingsSection>
|
||||||
|
|
||||||
<SettingsSection
|
<SettingsSection
|
||||||
:title="t('settings.loginVerification')"
|
:title="t('settings.loginVerification')"
|
||||||
:hint="t('settings.loginVerificationHint')"
|
:hint="t('settings.loginVerificationHint')"
|
||||||
@@ -1038,29 +1200,6 @@ watch(isAdmin, () => {
|
|||||||
</section>
|
</section>
|
||||||
</AppShell>
|
</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
|
<UModal
|
||||||
:open="domainSeparationSaveConfirmOpen"
|
:open="domainSeparationSaveConfirmOpen"
|
||||||
:title="t('domainSeparationConfirm.title')"
|
:title="t('domainSeparationConfirm.title')"
|
||||||
|
|||||||
+27
-1
@@ -26,6 +26,8 @@ const allowRegistration = ref(false)
|
|||||||
const domainSeparation = ref(false)
|
const domainSeparation = ref(false)
|
||||||
const siteBaseUrl = ref('')
|
const siteBaseUrl = ref('')
|
||||||
const imageBaseUrl = ref('')
|
const imageBaseUrl = ref('')
|
||||||
|
const videoBaseUrl = ref('')
|
||||||
|
const fileBaseUrl = ref('')
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const domainSeparationConfirmOpen = ref(false)
|
const domainSeparationConfirmOpen = ref(false)
|
||||||
|
|
||||||
@@ -180,7 +182,9 @@ async function performSubmit() {
|
|||||||
allowRegistration: allowRegistration.value,
|
allowRegistration: allowRegistration.value,
|
||||||
domainSeparation: domainSeparation.value,
|
domainSeparation: domainSeparation.value,
|
||||||
siteBaseUrl: domainSeparation.value ? siteBaseUrl.value.trim() : undefined,
|
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
|
const result = isMigrate.value
|
||||||
? await migrate(payload)
|
? await migrate(payload)
|
||||||
@@ -363,6 +367,28 @@ async function performSubmit() {
|
|||||||
class="w-full font-mono text-sm"
|
class="w-full font-mono text-sm"
|
||||||
/>
|
/>
|
||||||
</div>
|
</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>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
+124
-3
@@ -20,6 +20,17 @@ const deleteOpen = ref(false)
|
|||||||
const deleteTarget = ref<StorageBackendItem | null>(null)
|
const deleteTarget = ref<StorageBackendItem | null>(null)
|
||||||
const deleteConfirmName = ref('')
|
const deleteConfirmName = ref('')
|
||||||
const deleting = ref(false)
|
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)
|
const backupPanelRef = ref<{ applySyncDraft: (draft: { fromBackendId: string, toBackendId: string }) => void } | null>(null)
|
||||||
|
|
||||||
@@ -93,16 +104,42 @@ const { quotaToBytes } = useFileSize()
|
|||||||
|
|
||||||
function buildPatchBody(payload: StorageFormPayload) {
|
function buildPatchBody(payload: StorageFormPayload) {
|
||||||
const quotaBytes = quotaToBytes(payload.quotaValue, payload.quotaUnit)
|
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> = {
|
const body: Record<string, unknown> = {
|
||||||
|
type: 's3',
|
||||||
name: payload.name,
|
name: payload.name,
|
||||||
provider: payload.provider,
|
provider: payload.provider,
|
||||||
servingMode: payload.servingMode,
|
servingMode: payload.servingMode,
|
||||||
publicUrl: payload.publicUrl,
|
publicUrl: payload.publicUrl,
|
||||||
quotaBytes,
|
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) {
|
if (payload.secrets.accessKeyId || payload.secrets.secretAccessKey) {
|
||||||
body.secrets = payload.secrets
|
body.secrets = {
|
||||||
|
accessKeyId: payload.secrets.accessKeyId,
|
||||||
|
secretAccessKey: payload.secrets.secretAccessKey
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return body
|
return body
|
||||||
}
|
}
|
||||||
@@ -128,7 +165,12 @@ async function onFormSubmit(payload: StorageFormPayload) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (formMode.value === 'create') {
|
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' })
|
toast.add({ title: t('storage.secretsRequired'), color: 'warning' })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -268,6 +310,30 @@ watch(isAuthenticated, async (authed) => {
|
|||||||
await loadStorage()
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -358,11 +424,61 @@ watch(isAuthenticated, async (authed) => {
|
|||||||
|
|
||||||
<StorageBackupPanel
|
<StorageBackupPanel
|
||||||
v-if="!storageData.envOverride"
|
v-if="!storageData.envOverride"
|
||||||
|
:key="backupPanelKey"
|
||||||
ref="backupPanelRef"
|
ref="backupPanelRef"
|
||||||
:backends="storageData.backends"
|
:backends="storageData.backends"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<StorageSupportedBackends />
|
<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>
|
</template>
|
||||||
</section>
|
</section>
|
||||||
</AppShell>
|
</AppShell>
|
||||||
@@ -416,5 +532,10 @@ watch(isAuthenticated, async (authed) => {
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</UModal>
|
</UModal>
|
||||||
|
|
||||||
|
<ClearStorageModal
|
||||||
|
v-model:open="clearStorageOpen"
|
||||||
|
@cleared="handleStorageCleared"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
interface AdminUser {
|
||||||
|
id: number
|
||||||
|
username: string
|
||||||
|
role: 'admin' | 'user'
|
||||||
|
created_at: string
|
||||||
|
password_reset: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const { isChecking, isAuthenticated, isAdmin, checkSession, fetchStatus, handleAuthError } = useAuth()
|
||||||
|
const toast = useToast()
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
|
const users = ref<AdminUser[]>([])
|
||||||
|
const loading = ref(false)
|
||||||
|
|
||||||
|
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: t('users.clearFailed'), color: 'error' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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>
|
||||||
|
|
||||||
|
<UBadge
|
||||||
|
v-if="user.password_reset === 1"
|
||||||
|
color="warning"
|
||||||
|
variant="subtle"
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
{{ t('users.resetPending') }}
|
||||||
|
</UBadge>
|
||||||
|
<UButton
|
||||||
|
v-else
|
||||||
|
variant="outline"
|
||||||
|
color="warning"
|
||||||
|
size="sm"
|
||||||
|
:label="t('users.clearPassword')"
|
||||||
|
@click="clearPassword(user)"
|
||||||
|
/>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</AppShell>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -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>
|
||||||
@@ -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
@@ -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 BackupJobStatus = 'pending' | 'running' | 'done' | 'failed'
|
||||||
|
export type SiteSyncDirection = 'push' | 'pull'
|
||||||
|
|
||||||
export interface BackupJobItem {
|
export interface BackupJobItem {
|
||||||
id: string
|
id: string
|
||||||
@@ -14,6 +15,8 @@ export interface BackupJobItem {
|
|||||||
downloadable: boolean
|
downloadable: boolean
|
||||||
error: string | null
|
error: string | null
|
||||||
createdAt: string
|
createdAt: string
|
||||||
|
updatedAt: string
|
||||||
|
hidden: boolean
|
||||||
finishedAt: string | null
|
finishedAt: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -51,3 +54,16 @@ export interface SyncDraft {
|
|||||||
fromBackendId: string
|
fromBackendId: string
|
||||||
toBackendId: 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
|
||||||
|
}
|
||||||
@@ -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
@@ -8,7 +8,10 @@ export type ErrorCode
|
|||||||
| 'UNSUPPORTED_FILE_TYPE'
|
| 'UNSUPPORTED_FILE_TYPE'
|
||||||
| 'INVALID_FILE_SIGNATURE'
|
| 'INVALID_FILE_SIGNATURE'
|
||||||
| 'INVALID_IMAGE_KEY'
|
| 'INVALID_IMAGE_KEY'
|
||||||
|
| 'INVALID_VIDEO_KEY'
|
||||||
| 'IMAGE_NOT_FOUND'
|
| 'IMAGE_NOT_FOUND'
|
||||||
|
| 'HLS_NOT_FOUND'
|
||||||
|
| 'HLS_REMUX_FAILED'
|
||||||
| 'UPLOAD_FAILED'
|
| 'UPLOAD_FAILED'
|
||||||
| 'DELETE_FAILED'
|
| 'DELETE_FAILED'
|
||||||
|
|
||||||
@@ -40,7 +43,7 @@ export interface ImageItem {
|
|||||||
storage?: {
|
storage?: {
|
||||||
id: string
|
id: string
|
||||||
name: string
|
name: string
|
||||||
type: 'local' | 's3'
|
type: 'local' | 's3' | 'telegram'
|
||||||
}
|
}
|
||||||
/** 上传来源:网页或 API(来自活动日志) */
|
/** 上传来源:网页或 API(来自活动日志) */
|
||||||
uploadSource?: 'web' | 'api'
|
uploadSource?: 'web' | 'api'
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,17 +11,20 @@ export interface StorageCapacity {
|
|||||||
export interface StorageBackendItem {
|
export interface StorageBackendItem {
|
||||||
id: string
|
id: string
|
||||||
name: string
|
name: string
|
||||||
type: 'local' | 's3'
|
type: 'local' | 's3' | 'telegram'
|
||||||
config: {
|
config: {
|
||||||
endpoint?: string
|
endpoint?: string
|
||||||
region?: string
|
region?: string
|
||||||
bucket?: string
|
bucket?: string
|
||||||
prefix?: string
|
prefix?: string
|
||||||
forcePathStyle?: boolean
|
forcePathStyle?: boolean
|
||||||
|
chatId?: string
|
||||||
|
apiProxyUrl?: string
|
||||||
}
|
}
|
||||||
secretsMasked: {
|
secretsMasked: {
|
||||||
accessKeyId?: string
|
accessKeyId?: string
|
||||||
secretAccessKey?: string
|
secretAccessKey?: string
|
||||||
|
botToken?: string
|
||||||
}
|
}
|
||||||
servingMode: ServingMode
|
servingMode: ServingMode
|
||||||
publicUrl: string
|
publicUrl: string
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ export function hostnameFromBaseUrl(value: string): string | null {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 勾选分离且网站域、图片域主机名不同 → 保存后将启用双域名中间件隔离 */
|
/** 勾选分离且后台域、图片域主机名不同 → 保存后启用多域名中间件隔离 */
|
||||||
export function willActivateDomainSeparation(
|
export function willActivateDomainSeparation(
|
||||||
enabled: boolean,
|
enabled: boolean,
|
||||||
siteBaseUrl: string,
|
siteBaseUrl: string,
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
@@ -4,6 +4,41 @@ 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
|
## [1.4.2] — 2026-09-23
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Storage
|
# 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.
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
@@ -23,11 +23,11 @@ All **keys** start with `images/`. The upload API does **not** accept a `folder`
|
|||||||
## Adding a backend
|
## Adding a backend
|
||||||
|
|
||||||
1. Sign in as admin → **Storage** → **Add backend**.
|
1. Sign in as admin → **Storage** → **Add backend**.
|
||||||
2. Choose **local disk** or **S3-compatible** (R2 / COS / OSS presets).
|
2. Choose **local disk**, **S3-compatible** (R2 / COS / OSS presets), or **Telegram channel**.
|
||||||
3. Enter connection details (bucket, endpoint, keys).
|
3. Enter connection details (bucket, endpoint, keys, or Bot Token / Chat ID).
|
||||||
4. **Set as default** so new uploads use it.
|
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
|
### 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).
|
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)
|
### 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.
|
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.
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ features:
|
|||||||
- title: Drag, click, paste
|
- title: Drag, click, paste
|
||||||
details: Server-side WebP compression and Referer hotlink protection.
|
details: Server-side WebP compression and Referer hotlink protection.
|
||||||
- title: Multi-backend storage
|
- 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
|
- title: Users & RBAC
|
||||||
details: Admin and regular users; global and per-user API tokens; gallery scoped by owner.
|
details: Admin and regular users; global and per-user API tokens; gallery scoped by owner.
|
||||||
- title: Zero-config Docker
|
- title: Zero-config Docker
|
||||||
|
|||||||
@@ -4,6 +4,40 @@ 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
|
## [1.4.2] — 2026-09-23
|
||||||
|
|
||||||
### 修复
|
### 修复
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
|
|
||||||
- Node.js **22**
|
- Node.js **22**
|
||||||
- npm **11**(与 `package.json` 中 `packageManager` 一致)
|
- npm **11**(与 `package.json` 中 `packageManager` 一致)
|
||||||
|
- FFmpeg(视频缩略图与 HLS 转码;未安装时视频仍可上传,但使用占位封面)
|
||||||
|
|
||||||
## 启动
|
## 启动
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# 存储
|
# 存储
|
||||||
|
|
||||||
PicHost 支持 **本地磁盘** 与 **S3 兼容对象存储**(Cloudflare R2、腾讯云 COS、阿里云 OSS、AWS S3 等)。管理员在 **存储**(`/storage`)页管理后端;新上传写入 **默认后端**。页面顶部展示 **存储总览**(总量、已用、剩余、使用率与容量分布),下方为各后端卡片与支持的后端类型说明。
|
PicHost 支持 **本地磁盘**、**S3 兼容对象存储**(Cloudflare R2、腾讯云 COS、阿里云 OSS、AWS S3 等)与 **Telegram 频道/群组**。管理员在 **存储**(`/storage`)页管理后端;新上传写入 **默认后端**。页面顶部展示 **存储总览**(总量、已用、剩余、使用率与容量分布),下方为各后端卡片与支持的后端类型说明。
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
@@ -23,11 +23,11 @@ PicHost 支持 **本地磁盘** 与 **S3 兼容对象存储**(Cloudflare R2、
|
|||||||
## 添加存储后端
|
## 添加存储后端
|
||||||
|
|
||||||
1. 以管理员登录,打开 **存储** → **添加存储**。
|
1. 以管理员登录,打开 **存储** → **添加存储**。
|
||||||
2. 选择类型:**本地磁盘** 或 **S3 兼容**(含 R2 / COS / OSS 预设)。
|
2. 选择类型:**本地磁盘**、**S3 兼容**(含 R2 / COS / OSS 预设)或 **Telegram 频道**。
|
||||||
3. 填写连接信息(桶名、Endpoint、Access Key 等)。
|
3. 填写连接信息(桶名、Endpoint、Access Key,或 Bot Token / Chat ID 等)。
|
||||||
4. 保存后可 **设默认**,新上传将写入该后端。
|
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 要点
|
### Cloudflare R2 要点
|
||||||
|
|
||||||
@@ -40,6 +40,23 @@ PicHost 支持 **本地磁盘** 与 **S3 兼容对象存储**(Cloudflare R2、
|
|||||||
|
|
||||||
若仓库 **cloudflare** 分支专用于「仅 R2」部署,也可用环境变量预设,见 [文档首页](./index.md) 分支说明。
|
若仓库 **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**,与环境变量并存时以管理界面为准管理多后端。
|
部分部署用环境变量初始化默认 S3 后端(如 `STORAGE_BACKEND=s3`、`S3_ENDPOINT`、`S3_BUCKET`、`S3_ACCESS_KEY`、`S3_SECRET_KEY`、`S3_REGION`)。**UI 中配置的后端信息存于 SQLite**,与环境变量并存时以管理界面为准管理多后端。
|
||||||
|
|||||||
+1
-1
@@ -20,7 +20,7 @@ features:
|
|||||||
- title: 拖拽 / 粘贴上传
|
- title: 拖拽 / 粘贴上传
|
||||||
details: 支持点击、拖拽与 Ctrl+V 粘贴;服务端 WebP 压缩与 Referer 防盗链。
|
details: 支持点击、拖拽与 Ctrl+V 粘贴;服务端 WebP 压缩与 Referer 防盗链。
|
||||||
- title: 多后端存储
|
- title: 多后端存储
|
||||||
details: 本地磁盘 + S3 兼容(R2 / COS / OSS / AWS);proxy 或 public 直链模式。
|
details: 本地磁盘 + S3 兼容(R2 / COS / OSS / AWS)+ Telegram 频道/群组;proxy 或 public 直链。
|
||||||
- title: 多用户与权限
|
- title: 多用户与权限
|
||||||
details: 管理员与普通用户 RBAC;全局 Token 与个人 Token;图库按归属隔离。
|
details: 管理员与普通用户 RBAC;全局 Token 与个人 Token;图库按归属隔离。
|
||||||
- title: Docker 零配置
|
- title: Docker 零配置
|
||||||
|
|||||||
@@ -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
|
||||||
+395
-41
@@ -2,12 +2,13 @@
|
|||||||
"app": {
|
"app": {
|
||||||
"title": "PicHost - Image Hosting",
|
"title": "PicHost - Image Hosting",
|
||||||
"description": "A lightweight self-hosted image hosting system",
|
"description": "A lightweight self-hosted image hosting system",
|
||||||
"tagline": "Personal lightweight image host"
|
"tagline": "Personal lightweight storage"
|
||||||
},
|
},
|
||||||
"common": {
|
"common": {
|
||||||
"loadingSession": "Verifying session…",
|
"loadingSession": "Verifying session…",
|
||||||
"save": "Save",
|
"save": "Save",
|
||||||
"cancel": "Cancel",
|
"cancel": "Cancel",
|
||||||
|
"continue": "Continue",
|
||||||
"copy": "Copy",
|
"copy": "Copy",
|
||||||
"delete": "Delete",
|
"delete": "Delete",
|
||||||
"edit": "Edit",
|
"edit": "Edit",
|
||||||
@@ -24,10 +25,13 @@
|
|||||||
"nav": {
|
"nav": {
|
||||||
"api": "API",
|
"api": "API",
|
||||||
"gallery": "Gallery",
|
"gallery": "Gallery",
|
||||||
|
"videoLibrary": "Video library",
|
||||||
|
"fileLibrary": "File library",
|
||||||
"stats": "Stats",
|
"stats": "Stats",
|
||||||
"logs": "Activity log",
|
"logs": "Activity log",
|
||||||
"settings": "Settings",
|
"settings": "Settings",
|
||||||
"storage": "Storage",
|
"storage": "Storage",
|
||||||
|
"users": "Users",
|
||||||
"menu": "Menu",
|
"menu": "Menu",
|
||||||
"language": "Language",
|
"language": "Language",
|
||||||
"appearance": "Appearance",
|
"appearance": "Appearance",
|
||||||
@@ -85,8 +89,8 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"domainSeparationConfirm": {
|
"domainSeparationConfirm": {
|
||||||
"title": "Save dual-domain settings?",
|
"title": "Save separated-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?",
|
"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"
|
"confirm": "Save anyway"
|
||||||
},
|
},
|
||||||
"setup": {
|
"setup": {
|
||||||
@@ -96,9 +100,9 @@
|
|||||||
"passwordPlaceholder": "Set login password",
|
"passwordPlaceholder": "Set login password",
|
||||||
"confirmPlaceholder": "Enter password again",
|
"confirmPlaceholder": "Enter password again",
|
||||||
"allowRegistration": "Allow user registration",
|
"allowRegistration": "Allow user registration",
|
||||||
"domainSeparation": "Separate admin and image domains",
|
"domainSeparation": "Separate admin, image, video and file domains",
|
||||||
"domainSeparationHint": "Admin and API use the site URL; image links use a dedicated image URL",
|
"domainSeparationHint": "Allow admin, images, videos and files to each use a dedicated domain.",
|
||||||
"domainSeparationProxyHint": "Point both domains to this host and reverse-proxy to port 6892 (Nginx, Caddy, NPM, etc.)",
|
"domainSeparationProxyHint": "Point the configured domains to this host and reverse-proxy to port 6892 (Nginx, Caddy, NPM, etc.)",
|
||||||
"domainSeparationProxyExample": "View example",
|
"domainSeparationProxyExample": "View example",
|
||||||
"siteBaseUrl": "Site URL",
|
"siteBaseUrl": "Site URL",
|
||||||
"siteBaseUrlHint": "No trailing slash; admin UI and API upload endpoint",
|
"siteBaseUrlHint": "No trailing slash; admin UI and API upload endpoint",
|
||||||
@@ -106,7 +110,13 @@
|
|||||||
"imageBaseUrl": "Image URL",
|
"imageBaseUrl": "Image URL",
|
||||||
"imageBaseUrlHint": "No trailing slash; public image link prefix",
|
"imageBaseUrlHint": "No trailing slash; public image link prefix",
|
||||||
"imageBaseUrlPlaceholder": "https://pic.example.com",
|
"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",
|
"migrateSubmit": "Complete migration",
|
||||||
"initSubmit": "Create and continue",
|
"initSubmit": "Create and continue",
|
||||||
"migrateSuccess": "Migration complete",
|
"migrateSuccess": "Migration complete",
|
||||||
@@ -149,7 +159,7 @@
|
|||||||
"upload": {
|
"upload": {
|
||||||
"title": "Drop images here to upload",
|
"title": "Drop images here to upload",
|
||||||
"hint": "Or click to select files, press {ctrl} + {v} to paste",
|
"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",
|
"selectFiles": "Select images",
|
||||||
"settings": "Upload settings",
|
"settings": "Upload settings",
|
||||||
"progress": "Upload progress",
|
"progress": "Upload progress",
|
||||||
@@ -298,15 +308,39 @@
|
|||||||
"qualityMedium": "Medium quality",
|
"qualityMedium": "Medium quality",
|
||||||
"qualityLow": "Lossy",
|
"qualityLow": "Lossy",
|
||||||
"convenience": "Convenience",
|
"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",
|
"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": {
|
"settings": {
|
||||||
"title": "System settings",
|
"title": "System settings",
|
||||||
"pageTitle": "Settings & preferences",
|
"pageTitle": "Settings & preferences",
|
||||||
"pageSubtitle": "Manage system configuration and activity logs",
|
"pageSubtitle": "Manage system configuration and activity logs",
|
||||||
"pageSubtitleBasic": "Configure basic options so PicHost works better for you.",
|
"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",
|
"pageSubtitleAccess": "Configure image access policies to prevent hotlink abuse",
|
||||||
"pageSubtitleLogs": "View uploads, deletions, edits, and configuration changes",
|
"pageSubtitleLogs": "View uploads, deletions, edits, and configuration changes",
|
||||||
"searchPlaceholder": "Search settings…",
|
"searchPlaceholder": "Search settings…",
|
||||||
@@ -317,11 +351,20 @@
|
|||||||
"defaultCopyFormatHint": "Link format copied automatically after upload",
|
"defaultCopyFormatHint": "Link format copied automatically after upload",
|
||||||
"systemVersionHint": "Currently running PicHost version",
|
"systemVersionHint": "Currently running PicHost version",
|
||||||
"badgeEnabled": "Enabled",
|
"badgeEnabled": "Enabled",
|
||||||
"domainSeparationProxyTitle": "Both domains must resolve to this host",
|
"domainSeparationProxyTitle": "Configured 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.).",
|
"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": "URL for the admin UI",
|
"siteBaseUrlRoleHint": "Admin and API use this domain; resources use it when their switches are off",
|
||||||
"imageBaseUrlRoleHint": "URL for serving and displaying images",
|
"imageBaseUrlRoleHint": "Use this domain for images when on; use the site domain when off",
|
||||||
"imageDomainSection": "Image domain",
|
"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",
|
"navBasic": "Basic",
|
||||||
"navDomains": "Domains & paths",
|
"navDomains": "Domains & paths",
|
||||||
"navAccess": "Access control",
|
"navAccess": "Access control",
|
||||||
@@ -356,6 +399,12 @@
|
|||||||
"refererUnrestricted": "Unrestricted",
|
"refererUnrestricted": "Unrestricted",
|
||||||
"loginVerification": "Login verification",
|
"loginVerification": "Login verification",
|
||||||
"loginVerificationHint": "Human verification for login and registration (choose one)",
|
"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",
|
"loginVerificationSlider": "Local slider",
|
||||||
"loginVerificationSliderHint": "Built-in slider puzzle; no third-party service",
|
"loginVerificationSliderHint": "Built-in slider puzzle; no third-party service",
|
||||||
"loginVerificationTurnstile": "Cloudflare Turnstile",
|
"loginVerificationTurnstile": "Cloudflare Turnstile",
|
||||||
@@ -376,7 +425,7 @@
|
|||||||
"defaultCopyFormat": "Default copy format",
|
"defaultCopyFormat": "Default copy format",
|
||||||
"refererPlaceholder": "example.com, www.example.com",
|
"refererPlaceholder": "example.com, www.example.com",
|
||||||
"domainSettings": "Domain settings",
|
"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",
|
"siteBaseUrl": "Site URL",
|
||||||
"siteBaseUrlHint": "No trailing slash; admin UI and API upload endpoint",
|
"siteBaseUrlHint": "No trailing slash; admin UI and API upload endpoint",
|
||||||
"siteBaseUrlPlaceholder": "https://admin.example.com",
|
"siteBaseUrlPlaceholder": "https://admin.example.com",
|
||||||
@@ -386,13 +435,17 @@
|
|||||||
"imageBaseUrlSingleHint": "No trailing slash; leave empty to use the current request host; single-domain setups do not need a site URL",
|
"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",
|
"imageBaseUrlPlaceholder": "https://pic.example.com",
|
||||||
"imageBaseUrlActive": "Active: {url}",
|
"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)",
|
"runtimeDetected": "Detected current access: {url} (will not be saved automatically)",
|
||||||
"fillDetectedOrigin": "Use detected URL",
|
"fillDetectedOrigin": "Use detected URL",
|
||||||
"hostRoleUnknown": "Current Host does not match configured domains; reverse-proxy bypass may be possible",
|
"hostRoleUnknown": "Current Host does not match configured domains; reverse-proxy bypass may be possible",
|
||||||
"disableDomainSeparationTitle": "Disable domain separation",
|
"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",
|
"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",
|
"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",
|
"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",
|
"storageUseDatePath": "Group uploads by year/month",
|
||||||
@@ -419,14 +472,27 @@
|
|||||||
"uploadLimits": "Upload limits",
|
"uploadLimits": "Upload limits",
|
||||||
"uploadLimitsHint": "Site-wide upload policy and your personal convenience options. Applies after save.",
|
"uploadLimitsHint": "Site-wide upload policy and your personal convenience options. Applies after save.",
|
||||||
"imageProcessingHint": "Site-wide format conversion and personal compression/cleanup.",
|
"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",
|
"serverPolicy": "Site-wide",
|
||||||
"personalPreference": "Personal",
|
"personalPreference": "Personal",
|
||||||
"uploadRateGroup": "Upload rate limits",
|
"uploadRateGroup": "Upload rate limits",
|
||||||
"minutesUnit": "min",
|
"minutesUnit": "min",
|
||||||
"uploadMaxFileSize": "Max file size",
|
"uploadMaxFileSize": "Max file size",
|
||||||
"uploadMaxFileSizeHint": "Uploads larger than this are rejected (1–100 MB).",
|
"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",
|
"allowedMimeTypes": "Allowed image formats",
|
||||||
"allowedMimeTypesHint": "Keep at least one format enabled. The server validates file signatures, not just extensions.",
|
"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)",
|
"preserveOriginalUpload": "Preserve originals (disable WebP conversion)",
|
||||||
"preserveOriginalUploadHint": "When enabled, JPEG/PNG/GIF uploads are stored as-is instead of converting to WebP.",
|
"preserveOriginalUploadHint": "When enabled, JPEG/PNG/GIF uploads are stored as-is instead of converting to WebP.",
|
||||||
"rateLimits": "Rate limits",
|
"rateLimits": "Rate limits",
|
||||||
@@ -455,6 +521,18 @@
|
|||||||
"providerOss": "Alibaba OSS",
|
"providerOss": "Alibaba OSS",
|
||||||
"providerAws": "AWS S3",
|
"providerAws": "AWS S3",
|
||||||
"providerCustom": "Custom",
|
"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",
|
"endpoint": "Endpoint",
|
||||||
"region": "Region",
|
"region": "Region",
|
||||||
"bucket": "Bucket",
|
"bucket": "Bucket",
|
||||||
@@ -509,14 +587,33 @@
|
|||||||
"storageId": "Storage ID",
|
"storageId": "Storage ID",
|
||||||
"storageType": "Storage type",
|
"storageType": "Storage type",
|
||||||
"badgeLocal": "Local",
|
"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",
|
"supportedTitle": "Supported backends",
|
||||||
"supportedSubtitle": "PicHost supports multiple storage backends for different scenarios",
|
"supportedSubtitle": "PicHost supports multiple storage backends for different scenarios",
|
||||||
"supportedLocalDesc": "Ideal for small deployments and personal use",
|
"supportedLocalDesc": "Ideal for small deployments and personal use",
|
||||||
"supportedR2Desc": "Highly available, low-cost object storage",
|
"supportedR2Desc": "Highly available, low-cost object storage",
|
||||||
"supportedS3Title": "S3 compatible",
|
"supportedS3Title": "S3 compatible",
|
||||||
"supportedS3Desc": "Storage services compatible with the Amazon S3 API",
|
"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",
|
"statTotal": "Total",
|
||||||
"statUsed": "Used",
|
"statUsed": "Used",
|
||||||
"statFree": "Free",
|
"statFree": "Free",
|
||||||
@@ -547,28 +644,28 @@
|
|||||||
"manageStorageHint": "Configure R2 / S3 / COS backends and URL serving",
|
"manageStorageHint": "Configure R2 / S3 / COS backends and URL serving",
|
||||||
"backup": {
|
"backup": {
|
||||||
"sectionTitle": "Backup & migration",
|
"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",
|
"siteTitle": "Site backup & restore",
|
||||||
"siteSubtitle": "For migration, disaster recovery, and full-site 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",
|
"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",
|
"exportAction": "Start backup",
|
||||||
"exportStarted": "Backup job started",
|
"exportStarted": "Backup job started",
|
||||||
"exportFailed": "Failed to start backup",
|
"exportFailed": "Failed to start backup",
|
||||||
"exportPathHint": "Exports are saved under data/backups/",
|
"exportPathHint": "Exports are saved under data/backups/",
|
||||||
"exportIncludesTitle": "Package includes",
|
"exportIncludesTitle": "Package includes",
|
||||||
"exportIncludesDb": "Database snapshot (pichost.db)",
|
"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",
|
"exportExcludesSecrets": "Excludes plain-text secret.json for cloud storage",
|
||||||
"exportConcurrencyHint": "Only one backup or migration job can run at a time",
|
"exportConcurrencyHint": "Only one backup or migration job can run at a time",
|
||||||
"estimateLoading": "Estimating size…",
|
"estimateLoading": "Estimating size…",
|
||||||
"estimateImageCountLabel": "Images",
|
"estimateImageCountLabel": "Files",
|
||||||
"estimatePackageSizeLabel": "Est. package",
|
"estimatePackageSizeLabel": "Est. package",
|
||||||
"estimateDatabaseSnapshotLabel": "Database snapshot",
|
"estimateDatabaseSnapshotLabel": "Database snapshot",
|
||||||
"estimateStoragePathLabel": "Save location",
|
"estimateStoragePathLabel": "Save location",
|
||||||
"estimateStoragePathValue": "data/backups/",
|
"estimateStoragePathValue": "data/backups/",
|
||||||
"estimateImages": "{count} images · {size}",
|
"estimateImages": "{count} files · {size}",
|
||||||
"estimateDatabase": "Database ~{size}",
|
"estimateDatabase": "Database ~{size}",
|
||||||
"estimateTotal": "Total ~{size}",
|
"estimateTotal": "Total ~{size}",
|
||||||
"restoreTitle": "Restore from backup",
|
"restoreTitle": "Restore from backup",
|
||||||
@@ -585,30 +682,68 @@
|
|||||||
"restoreConfirm": "Confirm restore",
|
"restoreConfirm": "Confirm restore",
|
||||||
"restoreConfirmTitle": "Confirm site restore",
|
"restoreConfirmTitle": "Confirm site restore",
|
||||||
"restoreConfirmSource": "Source: {name}",
|
"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",
|
"restoreSelectRequired": "Select or upload a backup file",
|
||||||
"restoreStarted": "Restore job started",
|
"restoreStarted": "Restore job started",
|
||||||
"restoreFailed": "Restore failed",
|
"restoreFailed": "Restore failed",
|
||||||
"conflictMode": "Conflict policy",
|
"conflictMode": "Conflict policy",
|
||||||
"conflictSkip": "Skip existing images",
|
"conflictSkip": "Skip existing files",
|
||||||
"conflictOverwrite": "Overwrite existing images and database",
|
"conflictOverwrite": "Overwrite existing files and database",
|
||||||
"syncTitle": "Cross-backend sync",
|
"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",
|
"syncMigrateTitle": "Cross-backend migration",
|
||||||
"syncMigrateSubtitle": "Migrate images between configured backends",
|
"syncMigrateSubtitle": "Migrate files between configured backends",
|
||||||
"syncSubtitle": "Copy images between configured backends and update the index",
|
"syncSubtitle": "Copy files between configured backends and update the index",
|
||||||
"syncFrom": "Source backend",
|
"syncFrom": "Source backend",
|
||||||
"syncTo": "Target backend",
|
"syncTo": "Target backend",
|
||||||
"syncPendingImages": "Images to migrate",
|
"syncPendingImages": "Files to migrate",
|
||||||
"syncPendingSize": "Est. size",
|
"syncPendingSize": "Est. size",
|
||||||
"syncIncludesTitle": "What sync does",
|
"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",
|
"syncIncludesIndex": "Update storage_backend_id in the index",
|
||||||
"syncIncludesConfig": "Site settings and other backends are unchanged",
|
"syncIncludesConfig": "Site settings and other backends are unchanged",
|
||||||
"syncDryRun": "Dry run only",
|
"syncDryRun": "Dry run only",
|
||||||
"syncSetDefaultAfter": "Set target as default after migration",
|
"syncSetDefaultAfter": "Set target as default after migration",
|
||||||
"syncStartMigrate": "Start migration",
|
"syncStartMigrate": "Start migration",
|
||||||
"syncPreviewOnly": "Preview",
|
"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",
|
"syncDeleteSource": "Delete source files after sync",
|
||||||
"syncSetDefault": "Set target as default after sync",
|
"syncSetDefault": "Set target as default after sync",
|
||||||
"syncPreview": "Preview & start",
|
"syncPreview": "Preview & start",
|
||||||
@@ -620,8 +755,8 @@
|
|||||||
"syncStarted": "Sync job started",
|
"syncStarted": "Sync job started",
|
||||||
"syncFailed": "Sync failed",
|
"syncFailed": "Sync failed",
|
||||||
"previewFailed": "Preview failed",
|
"previewFailed": "Preview failed",
|
||||||
"previewImages": "Images",
|
"previewImages": "Files",
|
||||||
"previewNew": "New images",
|
"previewNew": "New files",
|
||||||
"previewConflicts": "Conflicts",
|
"previewConflicts": "Conflicts",
|
||||||
"previewCreated": "Backup created",
|
"previewCreated": "Backup created",
|
||||||
"previewSize": "Data size",
|
"previewSize": "Data size",
|
||||||
@@ -629,17 +764,32 @@
|
|||||||
"jobsSubtitle": "Track backup, restore, and migration progress",
|
"jobsSubtitle": "Track backup, restore, and migration progress",
|
||||||
"jobsEmpty": "No backup or migration jobs yet",
|
"jobsEmpty": "No backup or migration jobs yet",
|
||||||
"jobsEmptyHint": "Progress appears here after export, restore, or sync starts",
|
"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",
|
"jobsUnit": "jobs",
|
||||||
"colTask": "Task",
|
"colTask": "Task",
|
||||||
"colType": "Type",
|
"colType": "Type",
|
||||||
"colStatus": "Status",
|
"colStatus": "Status",
|
||||||
"colProgress": "Progress",
|
"colProgress": "Progress",
|
||||||
|
"progressUnitMedia": "media records",
|
||||||
|
"progressUnitObjects": "storage objects",
|
||||||
"colMessage": "Message",
|
"colMessage": "Message",
|
||||||
"colCreated": "Created",
|
"colCreated": "Created",
|
||||||
|
"colUpdated": "Updated",
|
||||||
"colActions": "Actions",
|
"colActions": "Actions",
|
||||||
"jobTypeExport": "Site export",
|
"jobTypeExport": "Site export",
|
||||||
"jobTypeRestore": "Site restore",
|
"jobTypeRestore": "Site restore",
|
||||||
"jobTypeSync": "Cross-backend sync",
|
"jobTypeSync": "Cross-backend sync",
|
||||||
|
"jobTypeSiteSync": "Site-to-site sync",
|
||||||
"statusPending": "Pending",
|
"statusPending": "Pending",
|
||||||
"statusRunning": "Running",
|
"statusRunning": "Running",
|
||||||
"statusDone": "Done",
|
"statusDone": "Done",
|
||||||
@@ -653,8 +803,8 @@
|
|||||||
"jobRunning": "Another job is already running. Try again later.",
|
"jobRunning": "Another job is already running. Try again later.",
|
||||||
"migrateIn": "Migrate in",
|
"migrateIn": "Migrate in",
|
||||||
"migrateOut": "Migrate out",
|
"migrateOut": "Migrate out",
|
||||||
"migrateInHint": "Copy images from the default backend to this one",
|
"migrateInHint": "Copy files from the default backend to this one",
|
||||||
"migrateOutHint": "Move images out of this backend",
|
"migrateOutHint": "Move files out of this backend",
|
||||||
"syncDraftApplied": "Sync form prefilled — review and start when ready",
|
"syncDraftApplied": "Sync form prefilled — review and start when ready",
|
||||||
"syncNeedTwoBackends": "At least two storage backends are required to sync",
|
"syncNeedTwoBackends": "At least two storage backends are required to sync",
|
||||||
"selectBackend": "Select backend",
|
"selectBackend": "Select backend",
|
||||||
@@ -803,6 +953,8 @@
|
|||||||
"storageBackend": "Storage: {name}"
|
"storageBackend": "Storage: {name}"
|
||||||
},
|
},
|
||||||
"delete": {
|
"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",
|
"confirmTitle": "Confirm delete",
|
||||||
"confirmDesc": "Delete {count} selected image(s)? This cannot be undone.",
|
"confirmDesc": "Delete {count} selected image(s)? This cannot be undone.",
|
||||||
"confirm": "Delete"
|
"confirm": "Delete"
|
||||||
@@ -829,7 +981,7 @@
|
|||||||
"envOverrideTitle": "Token overridden by environment",
|
"envOverrideTitle": "Token overridden by environment",
|
||||||
"envOverrideDesc": "Restart after changing API_UPLOAD_TOKEN; cannot regenerate in UI.",
|
"envOverrideDesc": "Restart after changing API_UPLOAD_TOKEN; cannot regenerate in UI.",
|
||||||
"personalTitle": "Personal API token",
|
"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",
|
"notConfiguredTitle": "No token configured",
|
||||||
"notConfiguredDesc": "A token is created automatically on first visit, or click Regenerate.",
|
"notConfiguredDesc": "A token is created automatically on first visit, or click Regenerate.",
|
||||||
"regenerate": "Regenerate",
|
"regenerate": "Regenerate",
|
||||||
@@ -871,6 +1023,8 @@
|
|||||||
"responseExample": "Response example",
|
"responseExample": "Response example",
|
||||||
"categories": {
|
"categories": {
|
||||||
"image": "Images",
|
"image": "Images",
|
||||||
|
"video": "Videos",
|
||||||
|
"file": "Files",
|
||||||
"extension": "Plugins",
|
"extension": "Plugins",
|
||||||
"system": "System"
|
"system": "System"
|
||||||
},
|
},
|
||||||
@@ -895,6 +1049,47 @@
|
|||||||
"batchDelete": {
|
"batchDelete": {
|
||||||
"keys": "Array of image keys to delete"
|
"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": {
|
"twikoo": {
|
||||||
"image": "Image file",
|
"image": "Image file",
|
||||||
"token": "API token"
|
"token": "API token"
|
||||||
@@ -915,9 +1110,9 @@
|
|||||||
"sendFailed": "Request failed",
|
"sendFailed": "Request failed",
|
||||||
"networkError": "Network error or unreachable",
|
"networkError": "Network error or unreachable",
|
||||||
"notesTitle": "Notes",
|
"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",
|
"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": {
|
"docs": {
|
||||||
"upload": {
|
"upload": {
|
||||||
@@ -940,6 +1135,46 @@
|
|||||||
"title": "Batch delete",
|
"title": "Batch delete",
|
||||||
"desc": "Delete multiple images; request body is a keys array."
|
"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": {
|
"twikoo": {
|
||||||
"title": "Twikoo-compatible upload",
|
"title": "Twikoo-compatible upload",
|
||||||
"desc": "Twikoo / EasyImage 2.0 protocol; stored under images/. Fields: image + token."
|
"desc": "Twikoo / EasyImage 2.0 protocol; stored under images/. Fields: image + token."
|
||||||
@@ -954,8 +1189,127 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"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": {
|
"userSettings": {
|
||||||
"loadFailed": "Failed to load settings",
|
"loadFailed": "Failed to load settings",
|
||||||
"saveFailed": "Failed to save settings"
|
"saveFailed": "Failed to save settings"
|
||||||
}
|
}
|
||||||
|
,
|
||||||
|
"users": {
|
||||||
|
"title": "Users",
|
||||||
|
"subtitle": "Review accounts and clear passwords; users set a new one on next login",
|
||||||
|
"adminOnly": "Admins only",
|
||||||
|
"roleAdmin": "Admin",
|
||||||
|
"roleUser": "User",
|
||||||
|
"clearPassword": "Clear password",
|
||||||
|
"resetPending": "Reset pending",
|
||||||
|
"passwordCleared": "Cleared password for {name}",
|
||||||
|
"clearFailed": "Operation failed",
|
||||||
|
"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"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
+396
-42
@@ -2,12 +2,13 @@
|
|||||||
"app": {
|
"app": {
|
||||||
"title": "PicHost - 个人图床",
|
"title": "PicHost - 个人图床",
|
||||||
"description": "个人自用的轻量图床系统",
|
"description": "个人自用的轻量图床系统",
|
||||||
"tagline": "个人轻量图床"
|
"tagline": "个人轻量存储"
|
||||||
},
|
},
|
||||||
"common": {
|
"common": {
|
||||||
"loadingSession": "正在验证登录状态…",
|
"loadingSession": "正在验证登录状态…",
|
||||||
"save": "保存",
|
"save": "保存",
|
||||||
"cancel": "取消",
|
"cancel": "取消",
|
||||||
|
"continue": "继续",
|
||||||
"copy": "复制",
|
"copy": "复制",
|
||||||
"delete": "删除",
|
"delete": "删除",
|
||||||
"edit": "编辑",
|
"edit": "编辑",
|
||||||
@@ -24,10 +25,13 @@
|
|||||||
"nav": {
|
"nav": {
|
||||||
"api": "API",
|
"api": "API",
|
||||||
"gallery": "图库",
|
"gallery": "图库",
|
||||||
|
"videoLibrary": "视频库",
|
||||||
|
"fileLibrary": "文件库",
|
||||||
"stats": "统计",
|
"stats": "统计",
|
||||||
"logs": "操作日志",
|
"logs": "操作日志",
|
||||||
"settings": "设置",
|
"settings": "设置",
|
||||||
"storage": "存储",
|
"storage": "存储",
|
||||||
|
"users": "用户",
|
||||||
"menu": "菜单",
|
"menu": "菜单",
|
||||||
"language": "语言",
|
"language": "语言",
|
||||||
"appearance": "外观",
|
"appearance": "外观",
|
||||||
@@ -85,8 +89,8 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"domainSeparationConfirm": {
|
"domainSeparationConfirm": {
|
||||||
"title": "确认保存双域名配置?",
|
"title": "确认保存独立域名配置?",
|
||||||
"description": "保存后,仅网站域名可访问管理后台与 API,仅图片域名可提供图片直链。若通过 IP、未配置的 Host,或反代未正确传递 Host 访问,可能无法打开后台。\n\n若发生锁死,可执行 docker exec pichost clear-domains(本地开发:npm run clear-domains)清除域名配置,或在能访问时于设置中关闭「域名分离」。\n\n确定仍要保存吗?",
|
"description": "保存后,后台域名用于管理后台与 API;图片、视频、文件域名分别只提供对应媒体直链。若通过 IP、未配置的 Host,或反代未正确传递 Host 访问,可能无法打开后台。\n\n若发生锁死,可执行 docker exec pichost clear-domains(本地开发:npm run clear-domains)清除域名配置,或在能访问时于设置中关闭「域名分离」。\n\n确定仍要保存吗?",
|
||||||
"confirm": "确认保存"
|
"confirm": "确认保存"
|
||||||
},
|
},
|
||||||
"setup": {
|
"setup": {
|
||||||
@@ -96,9 +100,9 @@
|
|||||||
"passwordPlaceholder": "设置登录密码",
|
"passwordPlaceholder": "设置登录密码",
|
||||||
"confirmPlaceholder": "再次输入密码",
|
"confirmPlaceholder": "再次输入密码",
|
||||||
"allowRegistration": "允许其他用户注册",
|
"allowRegistration": "允许其他用户注册",
|
||||||
"domainSeparation": "启用后台与图片域名分离",
|
"domainSeparation": "启用后台与图片、视频、文件各自分离",
|
||||||
"domainSeparationHint": "启用后,可以使用不同的域名访问后台和图片资源,便于配置 CDN 和自定义域名。",
|
"domainSeparationHint": "启用后,后台、图片、视频、文件能够各自使用独立的域名。",
|
||||||
"domainSeparationProxyHint": "需将两个域名解析到本机,并通过 Nginx、Caddy、NPM 等反代到端口 6892",
|
"domainSeparationProxyHint": "需将配置的域名解析到本机,并通过 Nginx、Caddy、NPM 等反代到端口 6892",
|
||||||
"domainSeparationProxyExample": "查看示例",
|
"domainSeparationProxyExample": "查看示例",
|
||||||
"siteBaseUrl": "网站域名",
|
"siteBaseUrl": "网站域名",
|
||||||
"siteBaseUrlHint": "末尾不加 /;管理后台、API 上传地址",
|
"siteBaseUrlHint": "末尾不加 /;管理后台、API 上传地址",
|
||||||
@@ -106,7 +110,13 @@
|
|||||||
"imageBaseUrl": "图片域名",
|
"imageBaseUrl": "图片域名",
|
||||||
"imageBaseUrlHint": "末尾不加 /;生成图片直链地址",
|
"imageBaseUrlHint": "末尾不加 /;生成图片直链地址",
|
||||||
"imageBaseUrlPlaceholder": "https://pic.example.com",
|
"imageBaseUrlPlaceholder": "https://pic.example.com",
|
||||||
"domainSeparationInvalid": "请填写有效的网站域名与图片域名,且两者不能相同",
|
"videoBaseUrl": "视频域名",
|
||||||
|
"videoBaseUrlHint": "末尾不加 /;留空时沿用图片域名",
|
||||||
|
"videoBaseUrlPlaceholder": "https://video.example.com",
|
||||||
|
"fileBaseUrl": "文件域名",
|
||||||
|
"fileBaseUrlHint": "末尾不加 /;留空时沿用图片域名",
|
||||||
|
"fileBaseUrlPlaceholder": "https://file.example.com",
|
||||||
|
"domainSeparationInvalid": "请填写有效的后台、图片、视频、文件域名",
|
||||||
"migrateSubmit": "完成迁移",
|
"migrateSubmit": "完成迁移",
|
||||||
"initSubmit": "创建并进入",
|
"initSubmit": "创建并进入",
|
||||||
"migrateSuccess": "账号迁移完成",
|
"migrateSuccess": "账号迁移完成",
|
||||||
@@ -149,7 +159,7 @@
|
|||||||
"upload": {
|
"upload": {
|
||||||
"title": "拖拽图片到此处上传",
|
"title": "拖拽图片到此处上传",
|
||||||
"hint": "或点击选择文件,按 {ctrl} + {v} 粘贴图片",
|
"hint": "或点击选择文件,按 {ctrl} + {v} 粘贴图片",
|
||||||
"formats": "支持 JPEG / PNG / WebP / GIF / SVG / ICO,单张最大 10 MB,每次最多 10 张",
|
"formats": "支持 JPEG / PNG / WebP / GIF / SVG / ICO,单张最大 {size} MB,每次最多 10 张",
|
||||||
"selectFiles": "选择图片",
|
"selectFiles": "选择图片",
|
||||||
"settings": "上传设置",
|
"settings": "上传设置",
|
||||||
"progress": "上传进度",
|
"progress": "上传进度",
|
||||||
@@ -298,15 +308,39 @@
|
|||||||
"qualityMedium": "中等质量",
|
"qualityMedium": "中等质量",
|
||||||
"qualityLow": "有损压缩",
|
"qualityLow": "有损压缩",
|
||||||
"convenience": "便捷功能",
|
"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": "上传成功自动复制链接",
|
"autoCopy": "上传成功自动复制链接",
|
||||||
"autoCopyHint": "图片上传成功后,自动复制图片链接到剪贴板。"
|
"autoCopyHint": "上传成功后,自动复制链接到剪贴板。",
|
||||||
|
"uploadTags": "上传标签",
|
||||||
|
"uploadTagsHint": "输入后按 Enter、逗号或移开焦点加入待添加列表;留空则不添加标签。",
|
||||||
|
"uploadTagsPlaceholder": "输入标签",
|
||||||
|
"uploadTagsPending": "待添加标签({n})",
|
||||||
|
"uploadTagsRemove": "删除标签 {name}"
|
||||||
},
|
},
|
||||||
"settings": {
|
"settings": {
|
||||||
"title": "系统设置",
|
"title": "系统设置",
|
||||||
"pageTitle": "设置与偏好",
|
"pageTitle": "设置与偏好",
|
||||||
"pageSubtitle": "管理系统配置与操作日志",
|
"pageSubtitle": "管理系统配置与操作日志",
|
||||||
"pageSubtitleBasic": "配置系统的基本选项,让 PicHost 更好地为你服务。",
|
"pageSubtitleBasic": "配置系统的基本选项,让 PicHost 更好地为你服务。",
|
||||||
"pageSubtitleDomains": "配置网站域名、图片域名以及图片存储路径等相关设置",
|
"pageSubtitleDomains": "配置后台、图片、视频、文件域名以及存储路径等相关设置",
|
||||||
"pageSubtitleAccess": "配置图片访问安全策略,防止资源被恶意盗用。",
|
"pageSubtitleAccess": "配置图片访问安全策略,防止资源被恶意盗用。",
|
||||||
"pageSubtitleLogs": "查看用户上传、删除、修改与系统配置记录",
|
"pageSubtitleLogs": "查看用户上传、删除、修改与系统配置记录",
|
||||||
"searchPlaceholder": "搜索设置项…",
|
"searchPlaceholder": "搜索设置项…",
|
||||||
@@ -317,11 +351,20 @@
|
|||||||
"defaultCopyFormatHint": "设置上传后自动复制的链接格式。",
|
"defaultCopyFormatHint": "设置上传后自动复制的链接格式。",
|
||||||
"systemVersionHint": "当前运行的 PicHost 版本。",
|
"systemVersionHint": "当前运行的 PicHost 版本。",
|
||||||
"badgeEnabled": "已启用",
|
"badgeEnabled": "已启用",
|
||||||
"domainSeparationProxyTitle": "需要将两个域名解析到本机",
|
"domainSeparationProxyTitle": "需要将配置的域名解析到本机",
|
||||||
"domainSeparationProxyBody": "请将网站域名和图片域名都解析到本机,并通过 Nginx、Caddy、NPM 等反向代理到本机的 6892 端口。",
|
"domainSeparationProxyBody": "请将后台域名和已配置的图片、视频、文件域名解析到本机,并通过 Nginx、Caddy、NPM 等反向代理到本机的 6892 端口。",
|
||||||
"siteBaseUrlRoleHint": "用于访问后台管理界面的网站域名。",
|
"siteBaseUrlRoleHint": "后台与 API 使用该域名;资源开关关闭时,图片、视频、文件也使用它。",
|
||||||
"imageBaseUrlRoleHint": "用于访问和显示图片资源的域名。",
|
"imageBaseUrlRoleHint": "打开后图片单独走该域名;关闭时使用网站域名。",
|
||||||
"imageDomainSection": "图片域名",
|
"resourceDomainSection": "资源域名",
|
||||||
|
"videoBaseUrl": "视频域名",
|
||||||
|
"videoBaseUrlRoleHint": "打开后视频、HLS 和缩略图单独走该域名;关闭时使用网站域名。",
|
||||||
|
"videoBaseUrlPlaceholder": "https://video.example.com",
|
||||||
|
"videoBaseUrlActive": "当前生效:{url}",
|
||||||
|
"fileBaseUrl": "文件域名",
|
||||||
|
"fileBaseUrlRoleHint": "打开后文件直链和下载单独走该域名;关闭时使用网站域名。",
|
||||||
|
"fileBaseUrlPlaceholder": "https://file.example.com",
|
||||||
|
"fileBaseUrlActive": "当前生效:{url}",
|
||||||
|
"fallbackImageDomain": "沿用图片域名",
|
||||||
"navBasic": "基础设置",
|
"navBasic": "基础设置",
|
||||||
"navDomains": "域名与路径",
|
"navDomains": "域名与路径",
|
||||||
"navAccess": "访问控制",
|
"navAccess": "访问控制",
|
||||||
@@ -356,6 +399,12 @@
|
|||||||
"refererUnrestricted": "未限制",
|
"refererUnrestricted": "未限制",
|
||||||
"loginVerification": "登录验证",
|
"loginVerification": "登录验证",
|
||||||
"loginVerificationHint": "登录与注册时使用的人机验证方式,三选一",
|
"loginVerificationHint": "登录与注册时使用的人机验证方式,三选一",
|
||||||
|
"fileBed": "文件床",
|
||||||
|
"fileBedHint": "文件直链的访问方式,切换后立即生效",
|
||||||
|
"fileBedPublic": "公开访问",
|
||||||
|
"fileBedPublicHint": "任何人拿到链接都能下载,适合分享。",
|
||||||
|
"fileBedToken": "Token 访问",
|
||||||
|
"fileBedTokenHint": "下载需要携带 Token(链接后追加 ?token=),适合私密文件。",
|
||||||
"loginVerificationSlider": "本地滑块",
|
"loginVerificationSlider": "本地滑块",
|
||||||
"loginVerificationSliderHint": "内置滑块验证,无需第三方服务。",
|
"loginVerificationSliderHint": "内置滑块验证,无需第三方服务。",
|
||||||
"loginVerificationTurnstile": "Cloudflare Turnstile",
|
"loginVerificationTurnstile": "Cloudflare Turnstile",
|
||||||
@@ -376,7 +425,7 @@
|
|||||||
"defaultCopyFormat": "默认复制格式",
|
"defaultCopyFormat": "默认复制格式",
|
||||||
"refererPlaceholder": "example.com, www.example.com",
|
"refererPlaceholder": "example.com, www.example.com",
|
||||||
"domainSettings": "域名配置",
|
"domainSettings": "域名配置",
|
||||||
"domainSeparationActive": "已启用域名分离:图片域仅出图,管理域提供后台与 API",
|
"domainSeparationActive": "已启用域名分离:后台、图片、视频、文件可使用独立域名",
|
||||||
"siteBaseUrl": "网站域名",
|
"siteBaseUrl": "网站域名",
|
||||||
"siteBaseUrlHint": "末尾不加 /;管理后台、API 上传地址",
|
"siteBaseUrlHint": "末尾不加 /;管理后台、API 上传地址",
|
||||||
"siteBaseUrlPlaceholder": "https://admin.example.com",
|
"siteBaseUrlPlaceholder": "https://admin.example.com",
|
||||||
@@ -386,13 +435,17 @@
|
|||||||
"imageBaseUrlSingleHint": "末尾不加 /;留空则使用当前访问域名;单域名部署无需填写网站域名",
|
"imageBaseUrlSingleHint": "末尾不加 /;留空则使用当前访问域名;单域名部署无需填写网站域名",
|
||||||
"imageBaseUrlPlaceholder": "https://pic.example.com",
|
"imageBaseUrlPlaceholder": "https://pic.example.com",
|
||||||
"imageBaseUrlActive": "当前生效:{url}",
|
"imageBaseUrlActive": "当前生效:{url}",
|
||||||
|
"resourceBaseUrl": "域名",
|
||||||
|
"resourceBaseUrlSingleHint": "末尾不加 /;留空则使用当前访问域名。关闭域名分离时,该域名统一用于图片、视频和文件资源。",
|
||||||
|
"resourceBaseUrlPlaceholder": "https://cdn.example.com",
|
||||||
|
"resourceBaseUrlActive": "当前资源域名:{url}",
|
||||||
"runtimeDetected": "检测到当前访问:{url}(不会自动写入配置)",
|
"runtimeDetected": "检测到当前访问:{url}(不会自动写入配置)",
|
||||||
"fillDetectedOrigin": "填入检测地址",
|
"fillDetectedOrigin": "填入检测地址",
|
||||||
"hostRoleUnknown": "当前访问 Host 与配置不一致,可能存在反代绕过风险",
|
"hostRoleUnknown": "当前访问 Host 与配置不一致,可能存在反代绕过风险",
|
||||||
"disableDomainSeparationTitle": "关闭域名分离",
|
"disableDomainSeparationTitle": "关闭域名分离",
|
||||||
"disableDomainSeparationDesc": "将清除管理域名配置,仅保留图片域名。确定继续?",
|
"disableDomainSeparationDesc": "将清除后台域名配置,并保留图片、视频、文件域名。确定继续?",
|
||||||
"disableDomainSeparationConfirm": "确认关闭",
|
"disableDomainSeparationConfirm": "确认关闭",
|
||||||
"domainSeparationRequired": "启用域名分离时需同时填写网站域名与图片域名",
|
"domainSeparationRequired": "启用域名分离时需同时填写后台域名与图片域名",
|
||||||
"hideFolderInUrl": "使用简短图片链接",
|
"hideFolderInUrl": "使用简短图片链接",
|
||||||
"hideFolderInUrlHint": "启用后,生成的图片链接将使用简短格式(如 /abc123.png),不包含年月目录。",
|
"hideFolderInUrlHint": "启用后,生成的图片链接将使用简短格式(如 /abc123.png),不包含年月目录。",
|
||||||
"storageUseDatePath": "上传目录按年/月分组",
|
"storageUseDatePath": "上传目录按年/月分组",
|
||||||
@@ -419,14 +472,27 @@
|
|||||||
"uploadLimits": "上传限制",
|
"uploadLimits": "上传限制",
|
||||||
"uploadLimitsHint": "全站上传策略与个人上传便捷选项,保存后立即生效。",
|
"uploadLimitsHint": "全站上传策略与个人上传便捷选项,保存后立即生效。",
|
||||||
"imageProcessingHint": "全站格式转换与个人端压缩、清理策略。",
|
"imageProcessingHint": "全站格式转换与个人端压缩、清理策略。",
|
||||||
|
"videoProcessingHint": "全站视频转码资源与个人视频上传偏好,保存后与视频上传页同步。",
|
||||||
"serverPolicy": "全站策略",
|
"serverPolicy": "全站策略",
|
||||||
"personalPreference": "个人偏好",
|
"personalPreference": "个人偏好",
|
||||||
"uploadRateGroup": "上传限流",
|
"uploadRateGroup": "上传限流",
|
||||||
"minutesUnit": "分钟",
|
"minutesUnit": "分钟",
|
||||||
"uploadMaxFileSize": "单文件大小上限",
|
"uploadMaxFileSize": "单文件大小上限",
|
||||||
"uploadMaxFileSizeHint": "超过此大小的图片将被拒绝上传(1–100 MB)。",
|
"uploadMaxFileSizeHint": "超过此大小的图片将被拒绝上传(1–100 MB)。",
|
||||||
|
"uploadImageMaxFileSize": "图片大小上限",
|
||||||
|
"uploadImageMaxFileSizeHint": "超过此大小的图片将被拒绝上传,最大 100 MB。0 MB 表示禁止上传。",
|
||||||
|
"uploadVideoMaxFileSize": "视频大小上限",
|
||||||
|
"uploadVideoMaxFileSizeHint": "超过此大小的视频将被拒绝上传,最大 2048 MB。0 MB 表示禁止上传。",
|
||||||
|
"uploadFileMaxFileSize": "文件大小上限",
|
||||||
|
"uploadFileMaxFileSizeHint": "超过此大小的普通文件将被拒绝上传,最大 1024 MB。0 MB 表示禁止上传。",
|
||||||
"allowedMimeTypes": "允许的图片格式",
|
"allowedMimeTypes": "允许的图片格式",
|
||||||
"allowedMimeTypesHint": "至少保留一种格式;服务端会校验文件签名,不仅看扩展名。",
|
"allowedMimeTypesHint": "至少保留一种格式;服务端会校验文件签名,不仅看扩展名。",
|
||||||
|
"allowedVideoExtensions": "允许的视频格式",
|
||||||
|
"allowedVideoExtensionsHint": "至少保留一种视频容器格式;视频上传页和服务端都会按此列表校验。",
|
||||||
|
"forbiddenFileExtensions": "不允许的文件格式",
|
||||||
|
"forbiddenFileExtensionsHint": "文件上传页和服务端都会拒绝这些扩展名,可额外添加自定义后缀。",
|
||||||
|
"forbiddenFileExtensionsCustomPlaceholder": "添加后缀,如 exe, php",
|
||||||
|
"forbiddenFileExtensionsAdd": "添加",
|
||||||
"preserveOriginalUpload": "保留原图(关闭 WebP 转换)",
|
"preserveOriginalUpload": "保留原图(关闭 WebP 转换)",
|
||||||
"preserveOriginalUploadHint": "开启后上传的 JPEG/PNG/GIF 不再转为 WebP,按原格式存储;关闭时可节省空间。",
|
"preserveOriginalUploadHint": "开启后上传的 JPEG/PNG/GIF 不再转为 WebP,按原格式存储;关闭时可节省空间。",
|
||||||
"rateLimits": "限流",
|
"rateLimits": "限流",
|
||||||
@@ -455,6 +521,18 @@
|
|||||||
"providerOss": "阿里云 OSS",
|
"providerOss": "阿里云 OSS",
|
||||||
"providerAws": "AWS S3",
|
"providerAws": "AWS S3",
|
||||||
"providerCustom": "自定义",
|
"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",
|
"endpoint": "Endpoint",
|
||||||
"region": "Region",
|
"region": "Region",
|
||||||
"bucket": "Bucket",
|
"bucket": "Bucket",
|
||||||
@@ -509,14 +587,33 @@
|
|||||||
"storageId": "存储标识",
|
"storageId": "存储标识",
|
||||||
"storageType": "存储类型",
|
"storageType": "存储类型",
|
||||||
"badgeLocal": "本地",
|
"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": "支持的存储后端",
|
"supportedTitle": "支持的存储后端",
|
||||||
"supportedSubtitle": "PicHost 支持多种存储后端,满足不同场景需求",
|
"supportedSubtitle": "PicHost 支持多种存储后端,满足不同场景需求",
|
||||||
"supportedLocalDesc": "适合小型部署和个人使用",
|
"supportedLocalDesc": "适合小型部署和个人使用",
|
||||||
"supportedR2Desc": "高可用、低成本的对象存储",
|
"supportedR2Desc": "高可用、低成本的对象存储",
|
||||||
"supportedS3Title": "S3 兼容",
|
"supportedS3Title": "S3 兼容",
|
||||||
"supportedS3Desc": "兼容 Amazon S3 协议的存储服务",
|
"supportedS3Desc": "兼容 Amazon S3 协议的存储服务",
|
||||||
"footerTip": "默认存储后端将自动接收所有新上传的图片",
|
"supportedTelegramDesc": "用 Telegram 频道/群组存图,适合轻量与无限容量场景",
|
||||||
|
"footerTip": "默认存储后端将自动接收所有新上传的图片、视频和文件",
|
||||||
"statTotal": "总量",
|
"statTotal": "总量",
|
||||||
"statUsed": "已用",
|
"statUsed": "已用",
|
||||||
"statFree": "剩余",
|
"statFree": "剩余",
|
||||||
@@ -547,28 +644,28 @@
|
|||||||
"manageStorageHint": "配置 R2 / S3 / COS 等多后端与直链策略",
|
"manageStorageHint": "配置 R2 / S3 / COS 等多后端与直链策略",
|
||||||
"backup": {
|
"backup": {
|
||||||
"sectionTitle": "备份与迁移",
|
"sectionTitle": "备份与迁移",
|
||||||
"sectionSubtitle": "整站备份恢复与跨后端图片迁移",
|
"sectionSubtitle": "整站备份恢复与跨后端文件迁移",
|
||||||
"siteTitle": "站点备份与恢复",
|
"siteTitle": "站点备份与恢复",
|
||||||
"siteSubtitle": "用于换机、灾备与整站恢复",
|
"siteSubtitle": "用于换机、灾备与整站恢复",
|
||||||
"siteFooterHint": "备份包含数据库与图片,不包含 secret_json 明文。",
|
"siteFooterHint": "备份包含数据库与全部文件,不包含 secret_json 明文。",
|
||||||
"exportTitle": "创建站点备份",
|
"exportTitle": "创建站点备份",
|
||||||
"exportSubtitle": "打包数据库与全部图片为 .phost.tar.gz",
|
"exportSubtitle": "打包数据库与全部文件为 .phost.tar.gz",
|
||||||
"exportAction": "开始备份",
|
"exportAction": "开始备份",
|
||||||
"exportStarted": "备份任务已开始",
|
"exportStarted": "备份任务已开始",
|
||||||
"exportFailed": "创建备份失败",
|
"exportFailed": "创建备份失败",
|
||||||
"exportPathHint": "导出文件保存在 data/backups/",
|
"exportPathHint": "导出文件保存在 data/backups/",
|
||||||
"exportIncludesTitle": "备份包含",
|
"exportIncludesTitle": "备份包含",
|
||||||
"exportIncludesDb": "数据库快照(pichost.db)",
|
"exportIncludesDb": "数据库快照(pichost.db)",
|
||||||
"exportIncludesImages": "全部图片文件({count} 张)",
|
"exportIncludesImages": "全部图片、视频和文件({count} 个)",
|
||||||
"exportExcludesSecrets": "不含云存储 secret.json 明文",
|
"exportExcludesSecrets": "不含云存储 secret.json 明文",
|
||||||
"exportConcurrencyHint": "同时仅可运行一个备份或迁移任务",
|
"exportConcurrencyHint": "同时仅可运行一个备份或迁移任务",
|
||||||
"estimateLoading": "正在估算体积…",
|
"estimateLoading": "正在估算体积…",
|
||||||
"estimateImageCountLabel": "图片总数",
|
"estimateImageCountLabel": "文件总数",
|
||||||
"estimatePackageSizeLabel": "预计备份包",
|
"estimatePackageSizeLabel": "预计备份包",
|
||||||
"estimateDatabaseSnapshotLabel": "数据库快照",
|
"estimateDatabaseSnapshotLabel": "数据库快照",
|
||||||
"estimateStoragePathLabel": "保存位置",
|
"estimateStoragePathLabel": "保存位置",
|
||||||
"estimateStoragePathValue": "data/backups/",
|
"estimateStoragePathValue": "data/backups/",
|
||||||
"estimateImages": "图片 {count} 张 · {size}",
|
"estimateImages": "文件 {count} 个 · {size}",
|
||||||
"estimateDatabase": "数据库约 {size}",
|
"estimateDatabase": "数据库约 {size}",
|
||||||
"estimateTotal": "合计约 {size}",
|
"estimateTotal": "合计约 {size}",
|
||||||
"restoreTitle": "从备份恢复",
|
"restoreTitle": "从备份恢复",
|
||||||
@@ -585,61 +682,114 @@
|
|||||||
"restoreConfirm": "确认恢复",
|
"restoreConfirm": "确认恢复",
|
||||||
"restoreConfirmTitle": "确认恢复站点",
|
"restoreConfirmTitle": "确认恢复站点",
|
||||||
"restoreConfirmSource": "来源:{name}",
|
"restoreConfirmSource": "来源:{name}",
|
||||||
"restoreOverwriteWarning": "覆盖模式将替换冲突图片与数据库,请确认已做好当前站点备份",
|
"restoreOverwriteWarning": "覆盖模式将替换冲突文件与数据库,请确认已做好当前站点备份",
|
||||||
"restoreSelectRequired": "请选择或上传备份文件",
|
"restoreSelectRequired": "请选择或上传备份文件",
|
||||||
"restoreStarted": "恢复任务已开始",
|
"restoreStarted": "恢复任务已开始",
|
||||||
"restoreFailed": "恢复失败",
|
"restoreFailed": "恢复失败",
|
||||||
"conflictMode": "冲突策略",
|
"conflictMode": "冲突策略",
|
||||||
"conflictSkip": "跳过已存在图片",
|
"conflictSkip": "跳过已存在文件",
|
||||||
"conflictOverwrite": "覆盖已存在图片与数据库",
|
"conflictOverwrite": "覆盖已存在文件与数据库",
|
||||||
"syncTitle": "跨后端同步",
|
"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": "跨后端迁移",
|
"syncMigrateTitle": "跨后端迁移",
|
||||||
"syncMigrateSubtitle": "在已配置后端之间迁移图片",
|
"syncMigrateSubtitle": "在已配置后端之间迁移文件",
|
||||||
"syncSubtitle": "在已配置的后端之间拷贝图片并更新索引",
|
"syncSubtitle": "在已配置的后端之间拷贝文件并更新索引",
|
||||||
"syncFrom": "源后端",
|
"syncFrom": "源后端",
|
||||||
"syncTo": "目标后端",
|
"syncTo": "目标后端",
|
||||||
"syncPendingImages": "待迁移图片",
|
"syncPendingImages": "待迁移文件",
|
||||||
"syncPendingSize": "预计体积",
|
"syncPendingSize": "预计体积",
|
||||||
"syncIncludesTitle": "同步说明",
|
"syncIncludesTitle": "同步说明",
|
||||||
"syncIncludesCopy": "拷贝源端图片文件到目标后端",
|
"syncIncludesCopy": "拷贝源端图片、视频和文件到目标后端",
|
||||||
"syncIncludesIndex": "更新索引中的 storage_backend_id",
|
"syncIncludesIndex": "更新索引中的 storage_backend_id",
|
||||||
"syncIncludesConfig": "不修改站点配置与其它后端数据",
|
"syncIncludesConfig": "不修改站点配置与其它后端数据",
|
||||||
"syncDryRun": "仅预览 Dry Run",
|
"syncDryRun": "仅预览 Dry Run",
|
||||||
"syncSetDefaultAfter": "迁移后设为默认后端",
|
"syncSetDefaultAfter": "迁移后设为默认后端",
|
||||||
"syncStartMigrate": "开始迁移",
|
"syncStartMigrate": "开始迁移",
|
||||||
"syncPreviewOnly": "预览",
|
"syncPreviewOnly": "预览",
|
||||||
"syncFooterHint": "仅迁移图片文件与索引,不影响站点配置。",
|
"syncFooterHint": "仅迁移文件与索引,不影响站点配置。",
|
||||||
"syncDeleteSource": "同步后删除源端文件",
|
"syncDeleteSource": "同步后删除源端文件",
|
||||||
"syncSetDefault": "同步后将目标设为默认",
|
"syncSetDefault": "同步后将目标设为默认",
|
||||||
"syncPreview": "预检并开始",
|
"syncPreview": "预检并开始",
|
||||||
"syncConfirm": "开始同步",
|
"syncConfirm": "开始同步",
|
||||||
"syncConfirmTitle": "确认跨后端同步",
|
"syncConfirmTitle": "确认跨后端同步",
|
||||||
"syncDeleteWarning": "将删除源后端上的图片文件,此操作不可撤销",
|
"syncDeleteWarning": "将删除源后端上的文件,此操作不可撤销",
|
||||||
"syncSelectRequired": "请选择源与目标后端",
|
"syncSelectRequired": "请选择源与目标后端",
|
||||||
"syncSameBackend": "源与目标不能相同",
|
"syncSameBackend": "源与目标不能相同",
|
||||||
"syncStarted": "同步任务已开始",
|
"syncStarted": "同步任务已开始",
|
||||||
"syncFailed": "同步失败",
|
"syncFailed": "同步失败",
|
||||||
"previewFailed": "预检失败",
|
"previewFailed": "预检失败",
|
||||||
"previewImages": "图片数量",
|
"previewImages": "文件数量",
|
||||||
"previewNew": "新增图片",
|
"previewNew": "新增文件",
|
||||||
"previewConflicts": "冲突图片",
|
"previewConflicts": "冲突文件",
|
||||||
"previewCreated": "备份时间",
|
"previewCreated": "备份时间",
|
||||||
"previewSize": "数据量",
|
"previewSize": "数据量",
|
||||||
"jobsTitle": "最近任务",
|
"jobsTitle": "最近任务",
|
||||||
"jobsSubtitle": "查看备份、恢复与迁移进度",
|
"jobsSubtitle": "查看备份、恢复与迁移进度",
|
||||||
"jobsEmpty": "暂无备份或迁移任务",
|
"jobsEmpty": "暂无备份或迁移任务",
|
||||||
"jobsEmptyHint": "导出、恢复或同步开始后,进度会显示在这里",
|
"jobsEmptyHint": "导出、恢复或同步开始后,进度会显示在这里",
|
||||||
|
"jobsHideAll": "全部清除",
|
||||||
|
"jobsRestoreAll": "恢复全部",
|
||||||
|
"hiddenJobsShow": "显示隐藏记录",
|
||||||
|
"hiddenJobsHide": "只显示未关闭",
|
||||||
|
"jobHide": "关闭显示(不删除记录)",
|
||||||
|
"jobUnhide": "恢复显示",
|
||||||
|
"jobHidden": "任务已关闭显示,记录仍保留",
|
||||||
|
"jobUnhidden": "任务已恢复显示",
|
||||||
|
"jobsHiddenAll": "已关闭全部历史任务的显示,记录仍保留",
|
||||||
|
"jobsUnhiddenAll": "已恢复显示全部历史任务",
|
||||||
|
"hideFailed": "更新任务显示状态失败",
|
||||||
"jobsUnit": "条任务",
|
"jobsUnit": "条任务",
|
||||||
"colTask": "任务",
|
"colTask": "任务",
|
||||||
"colType": "类型",
|
"colType": "类型",
|
||||||
"colStatus": "状态",
|
"colStatus": "状态",
|
||||||
"colProgress": "进度",
|
"colProgress": "进度",
|
||||||
|
"progressUnitMedia": "媒体记录",
|
||||||
|
"progressUnitObjects": "存储对象",
|
||||||
"colMessage": "说明",
|
"colMessage": "说明",
|
||||||
"colCreated": "创建时间",
|
"colCreated": "创建时间",
|
||||||
|
"colUpdated": "更新时间",
|
||||||
"colActions": "操作",
|
"colActions": "操作",
|
||||||
"jobTypeExport": "站点导出",
|
"jobTypeExport": "站点导出",
|
||||||
"jobTypeRestore": "站点恢复",
|
"jobTypeRestore": "站点恢复",
|
||||||
"jobTypeSync": "跨后端同步",
|
"jobTypeSync": "跨后端同步",
|
||||||
|
"jobTypeSiteSync": "两站点同步",
|
||||||
"statusPending": "等待中",
|
"statusPending": "等待中",
|
||||||
"statusRunning": "进行中",
|
"statusRunning": "进行中",
|
||||||
"statusDone": "已完成",
|
"statusDone": "已完成",
|
||||||
@@ -653,8 +803,8 @@
|
|||||||
"jobRunning": "已有任务正在运行,请稍后再试",
|
"jobRunning": "已有任务正在运行,请稍后再试",
|
||||||
"migrateIn": "迁入",
|
"migrateIn": "迁入",
|
||||||
"migrateOut": "迁出",
|
"migrateOut": "迁出",
|
||||||
"migrateInHint": "从默认后端拷贝图片到此后端",
|
"migrateInHint": "从默认后端拷贝文件到此后端",
|
||||||
"migrateOutHint": "将图片从此后端迁出",
|
"migrateOutHint": "将文件从此后端迁出",
|
||||||
"syncDraftApplied": "已填入同步表单,请确认后开始",
|
"syncDraftApplied": "已填入同步表单,请确认后开始",
|
||||||
"syncNeedTwoBackends": "至少需要两个存储后端才能同步",
|
"syncNeedTwoBackends": "至少需要两个存储后端才能同步",
|
||||||
"selectBackend": "选择后端",
|
"selectBackend": "选择后端",
|
||||||
@@ -803,6 +953,8 @@
|
|||||||
"storageBackend": "存储:{name}"
|
"storageBackend": "存储:{name}"
|
||||||
},
|
},
|
||||||
"delete": {
|
"delete": {
|
||||||
|
"confirmDescVideo": "确定要删除选中的 {count} 个视频吗?此操作不可恢复。",
|
||||||
|
"confirmDescFile": "确定要删除选中的 {count} 个文件吗?此操作不可恢复。",
|
||||||
"confirmTitle": "确认删除",
|
"confirmTitle": "确认删除",
|
||||||
"confirmDesc": "确定要删除选中的 {count} 张图片吗?此操作不可恢复。",
|
"confirmDesc": "确定要删除选中的 {count} 张图片吗?此操作不可恢复。",
|
||||||
"confirm": "确认删除"
|
"confirm": "确认删除"
|
||||||
@@ -829,7 +981,7 @@
|
|||||||
"envOverrideTitle": "Token 由环境变量覆盖",
|
"envOverrideTitle": "Token 由环境变量覆盖",
|
||||||
"envOverrideDesc": "修改 API_UPLOAD_TOKEN 后需重启服务;后台无法重新生成。",
|
"envOverrideDesc": "修改 API_UPLOAD_TOKEN 后需重启服务;后台无法重新生成。",
|
||||||
"personalTitle": "个人 API Token",
|
"personalTitle": "个人 API Token",
|
||||||
"personalDesc": "此 Token 仅用于您自己的脚本上传,上传的图片归属您的账号。",
|
"personalDesc": "此 Token 仅用于您自己的脚本上传,上传的图片、视频或文件归属您的账号。",
|
||||||
"notConfiguredTitle": "尚未配置 Token",
|
"notConfiguredTitle": "尚未配置 Token",
|
||||||
"notConfiguredDesc": "首次打开会自动创建 Token,或点击「重新生成」。",
|
"notConfiguredDesc": "首次打开会自动创建 Token,或点击「重新生成」。",
|
||||||
"regenerate": "重新生成",
|
"regenerate": "重新生成",
|
||||||
@@ -871,6 +1023,8 @@
|
|||||||
"responseExample": "响应示例",
|
"responseExample": "响应示例",
|
||||||
"categories": {
|
"categories": {
|
||||||
"image": "图片接口",
|
"image": "图片接口",
|
||||||
|
"video": "视频接口",
|
||||||
|
"file": "文件接口",
|
||||||
"extension": "插件接口",
|
"extension": "插件接口",
|
||||||
"system": "系统通用"
|
"system": "系统通用"
|
||||||
},
|
},
|
||||||
@@ -898,6 +1052,47 @@
|
|||||||
"batchDelete": {
|
"batchDelete": {
|
||||||
"keys": "要删除的图片 key 数组"
|
"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": {
|
"twikoo": {
|
||||||
"image": "上传图片",
|
"image": "上传图片",
|
||||||
"token": "API Token"
|
"token": "API Token"
|
||||||
@@ -920,7 +1115,7 @@
|
|||||||
"notesTitle": "响应说明",
|
"notesTitle": "响应说明",
|
||||||
"noteSuccess": "200 表示请求成功,返回 JSON 数据",
|
"noteSuccess": "200 表示请求成功,返回 JSON 数据",
|
||||||
"noteError": "4xx / 5xx 表示请求失败,查看 error 字段",
|
"noteError": "4xx / 5xx 表示请求失败,查看 error 字段",
|
||||||
"noteLimit": "上传限制:单次最多 10 张,单张最大 10 MB"
|
"noteLimit": "上传限制:单次最多 10 个;视频单文件最大 500 MB,文件单文件最大 100 MB"
|
||||||
},
|
},
|
||||||
"docs": {
|
"docs": {
|
||||||
"upload": {
|
"upload": {
|
||||||
@@ -943,6 +1138,46 @@
|
|||||||
"title": "批量删除",
|
"title": "批量删除",
|
||||||
"desc": "一次删除多张图片,请求体为 keys 数组。"
|
"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": {
|
"twikoo": {
|
||||||
"title": "twikoo 兼容上传",
|
"title": "twikoo 兼容上传",
|
||||||
"desc": "兼容 Twikoo / EasyImage 2.0 协议,图片存入 images/ 目录;字段 image + token。"
|
"desc": "兼容 Twikoo / EasyImage 2.0 协议,图片存入 images/ 目录;字段 image + token。"
|
||||||
@@ -957,8 +1192,127 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"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": {
|
"userSettings": {
|
||||||
"loadFailed": "加载设置失败",
|
"loadFailed": "加载设置失败",
|
||||||
"saveFailed": "保存设置失败"
|
"saveFailed": "保存设置失败"
|
||||||
}
|
}
|
||||||
|
,
|
||||||
|
"users": {
|
||||||
|
"title": "用户管理",
|
||||||
|
"subtitle": "查看账号并清除密码,用户下次登录时重新设置",
|
||||||
|
"adminOnly": "仅管理员可访问此页面",
|
||||||
|
"roleAdmin": "管理员",
|
||||||
|
"roleUser": "普通用户",
|
||||||
|
"clearPassword": "清除密码",
|
||||||
|
"resetPending": "待重置密码",
|
||||||
|
"passwordCleared": "已清除 {name} 的密码",
|
||||||
|
"clearFailed": "操作失败",
|
||||||
|
"loadFailed": "读取用户列表失败"
|
||||||
|
},
|
||||||
|
"reset": {
|
||||||
|
"title": "设置新密码",
|
||||||
|
"subtitle": "管理员已清除你的密码,请设置一个新密码继续使用",
|
||||||
|
"newPassword": "新密码(至少 6 位)",
|
||||||
|
"confirmPassword": "确认新密码",
|
||||||
|
"submit": "设置并登录",
|
||||||
|
"tooShort": "密码至少 6 位",
|
||||||
|
"mismatch": "两次输入的密码不一致",
|
||||||
|
"success": "密码已更新",
|
||||||
|
"failed": "设置失败,请确认管理员已清除你的密码"
|
||||||
|
},
|
||||||
|
"profile": {
|
||||||
|
"title": "个人资料",
|
||||||
|
"subtitle": "修改你的用户名",
|
||||||
|
"username": "用户名",
|
||||||
|
"usernameHint": "2–32 位字母、数字、下划线、点或短横线",
|
||||||
|
"saved": "用户名已更新",
|
||||||
|
"saveFailed": "保存失败"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -50,6 +50,13 @@ export default defineNuxtConfig({
|
|||||||
|
|
||||||
nitro: {
|
nitro: {
|
||||||
preset: 'node-server',
|
preset: 'node-server',
|
||||||
|
publicAssets: [
|
||||||
|
{
|
||||||
|
baseURL: '/cap',
|
||||||
|
dir: join(rootDir, 'node_modules/@cap.js/wasm/browser'),
|
||||||
|
maxAge: 60 * 60 * 24 * 30
|
||||||
|
}
|
||||||
|
],
|
||||||
scheduledTasks: {
|
scheduledTasks: {
|
||||||
'0 3 * * *': ['auto-delete']
|
'0 3 * * *': ['auto-delete']
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+3010
-7
File diff suppressed because it is too large.
Load diff
+17
-4
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "pichost",
|
"name": "pichost",
|
||||||
"version": "1.4.2",
|
"version": "1.5.3",
|
||||||
"description": "Self-hosted image hosting for your NAS / home server — Nuxt admin UI, local disk or S3-compatible storage, server-side WebP compression, referer protection, Twikoo/EasyImage compatible.",
|
"description": "Self-hosted image hosting for your NAS / home server — Nuxt admin UI, local disk or S3-compatible storage, server-side WebP compression, referer protection, Twikoo/EasyImage compatible.",
|
||||||
"homepage": "https://github.com/O96u/PicHost#readme",
|
"homepage": "https://github.com/O96u/PicHost#readme",
|
||||||
"repository": {
|
"repository": {
|
||||||
@@ -22,6 +22,7 @@
|
|||||||
"license": "GPL-3.0-only",
|
"license": "GPL-3.0-only",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
"main": "desktop/main.cjs",
|
||||||
"packageManager": "npm@11.12.1",
|
"packageManager": "npm@11.12.1",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "nuxt build",
|
"build": "nuxt build",
|
||||||
@@ -42,23 +43,35 @@
|
|||||||
"docs:prepare": "node docs-site/copy-screenshots.mjs",
|
"docs:prepare": "node docs-site/copy-screenshots.mjs",
|
||||||
"docs:dev": "npm run docs:prepare && vitepress dev docs-site",
|
"docs:dev": "npm run docs:prepare && vitepress dev docs-site",
|
||||||
"docs:build": "npm run docs:prepare && vitepress build docs-site",
|
"docs:build": "npm run docs:prepare && vitepress build docs-site",
|
||||||
"docs:preview": "npm run docs:prepare && vitepress preview docs-site"
|
"docs:preview": "npm run docs:prepare && vitepress preview docs-site",
|
||||||
|
"desktop:prepare": "node desktop/prepare-runtime.mjs",
|
||||||
|
"desktop:dev": "npm run desktop:prepare && electron .",
|
||||||
|
"desktop:build": "npm run desktop:prepare && electron-builder --win nsis --x64",
|
||||||
|
"pack:docker": "node scripts/build-docker.mjs",
|
||||||
|
"pack:fpk": "node scripts/build-fpk.mjs",
|
||||||
|
"pack:windows": "npm run desktop:build",
|
||||||
|
"pack:all": "npm run pack:docker && npm run pack:fpk && npm run pack:windows"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@aws-sdk/client-s3": "^3.876.0",
|
"@aws-sdk/client-s3": "^3.876.0",
|
||||||
|
"@cap.js/wasm": "^0.0.8",
|
||||||
"@iconify-json/lucide": "^1.2.114",
|
"@iconify-json/lucide": "^1.2.114",
|
||||||
"@nuxt/ui": "^4.9.0",
|
"@nuxt/ui": "^4.9.0",
|
||||||
"@nuxtjs/i18n": "^10.6.0",
|
"@nuxtjs/i18n": "^10.6.0",
|
||||||
"cap-widget": "^0.1.57",
|
"cap-widget": "^0.1.58",
|
||||||
|
"hls.js": "^1.7.3",
|
||||||
"nuxt": "^4.4.8",
|
"nuxt": "^4.4.8",
|
||||||
"sharp": "^0.35.3",
|
"sharp": "^0.35.3",
|
||||||
"tailwindcss": "^4.3.1",
|
"tailwindcss": "^4.3.1",
|
||||||
"tar": "^7.5.22"
|
"tar": "^7.5.22",
|
||||||
|
"undici": "^7.30.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@nuxt/eslint": "^1.16.0",
|
"@nuxt/eslint": "^1.16.0",
|
||||||
"@types/node": "^22.15.0",
|
"@types/node": "^22.15.0",
|
||||||
"@types/tar": "^6.1.13",
|
"@types/tar": "^6.1.13",
|
||||||
|
"electron": "^44.9.0",
|
||||||
|
"electron-builder": "^26.15.3",
|
||||||
"eslint": "^10.6.0",
|
"eslint": "^10.6.0",
|
||||||
"mermaid": "^11.17.2",
|
"mermaid": "^11.17.2",
|
||||||
"typescript": "^6.0.3",
|
"typescript": "^6.0.3",
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
# PicHost 打包
|
||||||
|
|
||||||
|
版本号统一从根目录 `package.json` 的 `version` 读取。
|
||||||
|
|
||||||
|
## 环境要求
|
||||||
|
|
||||||
|
- Node.js 22 或更高版本
|
||||||
|
- npm 11
|
||||||
|
- Docker(构建 Docker 镜像和飞牛运行镜像)
|
||||||
|
- Windows 构建需要 `ffmpeg.exe` 与 `ffprobe.exe`,可通过 `FFMPEG_DIR` 指定目录
|
||||||
|
|
||||||
|
## 构建命令
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm ci
|
||||||
|
npm run pack:docker
|
||||||
|
npm run pack:fpk
|
||||||
|
npm run pack:windows
|
||||||
|
```
|
||||||
|
|
||||||
|
在 Windows 上可以一次构建全部三类产物:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run pack:all
|
||||||
|
```
|
||||||
|
|
||||||
|
输出位置:
|
||||||
|
|
||||||
|
- Docker 镜像:`dist-docker/*.tar`
|
||||||
|
- 飞牛应用:`dist-fpk/*.fpk`
|
||||||
|
- Windows 安装程序:`dist-windows/*.exe`
|
||||||
|
|
||||||
|
`dist-docker`、`dist-fpk` 和 `dist-windows` 是生成目录,不提交到 Git。可复用的源文件位于 `packaging/fnos`、`desktop` 和 `scripts`。
|
||||||
|
|
||||||
|
## 飞牛安装顺序
|
||||||
|
|
||||||
|
1. 在飞牛 Docker 中手动导入 `dist-docker/*.tar`。
|
||||||
|
2. 确认镜像列表中出现 `yeomaiyelove/pichost:<version>`。
|
||||||
|
3. 安装 `dist-fpk/*.fpk`。
|
||||||
|
|
||||||
|
FPK 使用 `pull_policy: never`,不会联网拉取 PicHost 镜像。
|
||||||
|
|
||||||
|
## 可覆盖参数
|
||||||
|
|
||||||
|
- `PICHOST_IMAGE_REPOSITORY`:Docker 镜像仓库名,默认 `yeomaiyelove/pichost`
|
||||||
|
- `DOCKER_PLATFORM`:Docker 构建平台,默认 `linux/amd64`
|
||||||
|
- `PICHOST_IMAGE`:FPK 使用的完整镜像标签
|
||||||
|
- `FPK_PLATFORM`:FPK 平台标识,默认 `x86`
|
||||||
|
- `FFMPEG_DIR`:Windows 构建使用的 ffmpeg 目录
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"defaults": {
|
||||||
|
"run-as": "package"
|
||||||
|
},
|
||||||
|
"username": "docker-pichost",
|
||||||
|
"groupname": "docker-pichost"
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"docker-project": {
|
||||||
|
"projects": [
|
||||||
|
{
|
||||||
|
"name": "pichost",
|
||||||
|
"path": "docker"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"data-share": {
|
||||||
|
"shares": [
|
||||||
|
{
|
||||||
|
"name": "pichost/data",
|
||||||
|
"permission": {
|
||||||
|
"rw": [
|
||||||
|
"docker-pichost"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
services:
|
||||||
|
pichost:
|
||||||
|
image: yeomaiyelove/pichost:1.5.1
|
||||||
|
pull_policy: never
|
||||||
|
container_name: pichost
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "${wizard_port}:6892"
|
||||||
|
volumes:
|
||||||
|
- "/${TRIM_APPDEST_VOL}/@appshare/pichost/data:/data"
|
||||||
|
- "${TRIM_APPDEST}/docker/start.sh:/fnos-start.sh:ro"
|
||||||
|
entrypoint: ["/bin/sh", "/fnos-start.sh"]
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# 飞牛 compose 入口:安装向导写入配置后,启动官方镜像前执行初始化或备份恢复。
|
||||||
|
if [ -f /data/.fnos-restore.pending ]; then
|
||||||
|
ARCHIVE="$(tr -d '\r\n' < /data/.fnos-restore.pending)"
|
||||||
|
if [ -n "${ARCHIVE}" ] && [ -f "${ARCHIVE}" ]; then
|
||||||
|
if node /app/server/cli/backup-restore.mjs "${ARCHIVE}" --overwrite; then
|
||||||
|
rm -f /data/.fnos-restore.pending /data/.fnos-restore.failed
|
||||||
|
else
|
||||||
|
printf 'restore failed: %s\n' "${ARCHIVE}" > /data/.fnos-restore.failed
|
||||||
|
rm -f /data/.fnos-restore.pending
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
elif [ -f /data/fnos-bootstrap.mjs ]; then
|
||||||
|
node /data/fnos-bootstrap.mjs || true
|
||||||
|
fi
|
||||||
|
exec /docker-entrypoint.sh
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
".url": {
|
||||||
|
"pichost.main": {
|
||||||
|
"title": "PicHost",
|
||||||
|
"icon": "images/icon_{0}.png",
|
||||||
|
"type": "url",
|
||||||
|
"protocol": "http",
|
||||||
|
"port": "${wizard_port}",
|
||||||
|
"url": "/",
|
||||||
|
"allUsers": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
exit 0
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
exit 0
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
import { existsSync, mkdirSync, readFileSync, renameSync } from 'node:fs'
|
||||||
|
import { join } from 'node:path'
|
||||||
|
import { randomBytes, scryptSync } from 'node:crypto'
|
||||||
|
import { DatabaseSync } from 'node:sqlite'
|
||||||
|
|
||||||
|
const USERNAME_PATTERN = /^[a-zA-Z0-9_]{3,32}$/
|
||||||
|
const MIN_PASSWORD_LENGTH = 8
|
||||||
|
const SETTINGS_ALLOW_REGISTRATION = 'allow_registration'
|
||||||
|
|
||||||
|
const dataDir = process.env.DATA_DIR || '/data'
|
||||||
|
const configPath = join(dataDir, '.fnos-setup.json')
|
||||||
|
const donePath = join(dataDir, '.fnos-setup.done')
|
||||||
|
|
||||||
|
function log(message) {
|
||||||
|
console.log(`[PicHost fnos-bootstrap] ${message}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
function fail(message) {
|
||||||
|
console.error(`[PicHost fnos-bootstrap] ${message}`)
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
function hashPassword(password) {
|
||||||
|
const salt = randomBytes(16).toString('hex')
|
||||||
|
const derived = scryptSync(password, salt, 64, { N: 16384, r: 8, p: 1 })
|
||||||
|
return `scrypt$16384$8$1$${salt}$${derived.toString('hex')}`
|
||||||
|
}
|
||||||
|
|
||||||
|
// 与 server/utils/db.ts 初始建表一致;完整迁移由应用启动后的 getDb() 负责。
|
||||||
|
function initDb(database) {
|
||||||
|
database.exec(`
|
||||||
|
PRAGMA journal_mode = WAL;
|
||||||
|
CREATE TABLE IF NOT EXISTS activity_logs (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
action TEXT NOT NULL,
|
||||||
|
key TEXT NOT NULL,
|
||||||
|
original_name TEXT NOT NULL,
|
||||||
|
size INTEGER NOT NULL DEFAULT 0,
|
||||||
|
content_type TEXT NOT NULL DEFAULT '',
|
||||||
|
source TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_activity_logs_created_at
|
||||||
|
ON activity_logs(created_at DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_activity_logs_action_created
|
||||||
|
ON activity_logs(action, created_at DESC);
|
||||||
|
CREATE TABLE IF NOT EXISTS settings (
|
||||||
|
key TEXT PRIMARY KEY,
|
||||||
|
value TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
username TEXT NOT NULL UNIQUE,
|
||||||
|
password_hash TEXT NOT NULL,
|
||||||
|
role TEXT NOT NULL CHECK(role IN ('admin', 'user')),
|
||||||
|
created_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS sessions (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
user_id INTEGER NOT NULL,
|
||||||
|
token_hash TEXT NOT NULL,
|
||||||
|
expires_at TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_sessions_user_id ON sessions(user_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_sessions_expires_at ON sessions(expires_at);
|
||||||
|
`)
|
||||||
|
|
||||||
|
const columns = database.prepare('PRAGMA table_info(users)').all()
|
||||||
|
if (!columns.some(column => column.name === 'api_token')) {
|
||||||
|
database.exec('ALTER TABLE users ADD COLUMN api_token TEXT')
|
||||||
|
database.exec(
|
||||||
|
'CREATE UNIQUE INDEX IF NOT EXISTS idx_users_api_token ON users(api_token) WHERE api_token IS NOT NULL'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (!columns.some(column => column.name === 'auto_delete_days')) {
|
||||||
|
database.exec('ALTER TABLE users ADD COLUMN auto_delete_days INTEGER NOT NULL DEFAULT 0')
|
||||||
|
}
|
||||||
|
const userColumns = database.prepare('PRAGMA table_info(users)').all()
|
||||||
|
if (!userColumns.some(column => column.name === 'auto_delete_enabled_at')) {
|
||||||
|
database.exec('ALTER TABLE users ADD COLUMN auto_delete_enabled_at TEXT')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function countUsers(database) {
|
||||||
|
const row = database.prepare('SELECT COUNT(*) AS count FROM users').get()
|
||||||
|
return Number(row?.count ?? 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
function setSetting(database, key, value) {
|
||||||
|
const updatedAt = new Date().toISOString()
|
||||||
|
database.prepare(`
|
||||||
|
INSERT INTO settings (key, value, updated_at)
|
||||||
|
VALUES (?, ?, ?)
|
||||||
|
ON CONFLICT(key) DO UPDATE SET
|
||||||
|
value = excluded.value,
|
||||||
|
updated_at = excluded.updated_at
|
||||||
|
`).run(key, value, updatedAt)
|
||||||
|
}
|
||||||
|
|
||||||
|
mkdirSync(dataDir, { recursive: true })
|
||||||
|
const dbPath = join(dataDir, 'pichost.db')
|
||||||
|
const db = new DatabaseSync(dbPath)
|
||||||
|
initDb(db)
|
||||||
|
|
||||||
|
if (countUsers(db) > 0) {
|
||||||
|
if (existsSync(configPath)) {
|
||||||
|
renameSync(configPath, donePath)
|
||||||
|
}
|
||||||
|
log('already initialized')
|
||||||
|
process.exit(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!existsSync(configPath)) {
|
||||||
|
log('no setup file, waiting for install wizard')
|
||||||
|
process.exit(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
let setup
|
||||||
|
try {
|
||||||
|
setup = JSON.parse(readFileSync(configPath, 'utf8'))
|
||||||
|
} catch {
|
||||||
|
fail('invalid .fnos-setup.json')
|
||||||
|
}
|
||||||
|
|
||||||
|
const username = String(setup.username ?? '').trim()
|
||||||
|
const password = String(setup.password ?? '')
|
||||||
|
const allowRegistration = Boolean(setup.allowRegistration)
|
||||||
|
|
||||||
|
if (!USERNAME_PATTERN.test(username)) {
|
||||||
|
fail('invalid admin username in setup file')
|
||||||
|
}
|
||||||
|
if (password.length < MIN_PASSWORD_LENGTH) {
|
||||||
|
fail('invalid admin password in setup file')
|
||||||
|
}
|
||||||
|
|
||||||
|
const createdAt = new Date().toISOString()
|
||||||
|
db.prepare(`
|
||||||
|
INSERT INTO users (username, password_hash, role, created_at)
|
||||||
|
VALUES (?, ?, 'admin', ?)
|
||||||
|
`).run(username, hashPassword(password), createdAt)
|
||||||
|
|
||||||
|
setSetting(db, SETTINGS_ALLOW_REGISTRATION, allowRegistration ? 'true' : 'false')
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
renameSync(configPath, donePath)
|
||||||
|
log(`admin "${username}" created`)
|
||||||
Loaded 100 of 237 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user