18 Commits
Author SHA1 Message Date
O96u 430c4de0f4 release: v1.2.7 — 登录人机验证(滑块 / Turnstile / Cap)
Publish Docker image / build (linux/amd64, ubuntu-latest, amd64) (push) Canceled after 0s
Publish Docker image / build (linux/arm64, ubuntu-24.04-arm, arm64) (push) Canceled after 0s
Publish Docker image / merge (push) Canceled after 0s
ci / ci (22, ubuntu-latest) (push) Canceled after 0s
GitHub Release / release (push) Canceled after 0s
2026-09-03 13:53:43 +08:00
O96u 8408d01197 release: v1.2.6 — 新 UI 体验优化与存储标识规则
GitHub Release / release (push) Canceled after 0s
ci / ci (22, ubuntu-latest) (push) Canceled after 0s
Publish Docker image / build (linux/amd64, ubuntu-latest, amd64) (push) Canceled after 0s
Publish Docker image / build (linux/arm64, ubuntu-24.04-arm, arm64) (push) Canceled after 0s
Publish Docker image / merge (push) Canceled after 0s
2026-09-01 21:31:02 +08:00
O96u ac65c1eb9d refactor: 重构设置、API、图库与存储页面前端结构。
将操作日志并入设置侧栏,补齐检查更新提示与版本接口刷新;拆分各模块组件并修复图库搜索分页变量。
2026-09-01 17:15:02 +08:00
O96u 270af4e707 release: v1.2.5 — 图库页、图片详情弹层与列表体验优化
ci / ci (22, ubuntu-latest) (push) Canceled after 0s
Publish Docker image / build (linux/amd64, ubuntu-latest, amd64) (push) Canceled after 0s
Publish Docker image / build (linux/arm64, ubuntu-24.04-arm, arm64) (push) Canceled after 0s
Publish Docker image / merge (push) Canceled after 0s
GitHub Release / release (push) Canceled after 0s
2026-08-31 16:05:53 +08:00
O96u 81dcc2c15e release: v1.2.4 — 修复未配置防盗链时外链图裂
GitHub Release / release (push) Canceled after 0s
Publish Docker image / build (linux/amd64, ubuntu-latest, amd64) (push) Canceled after 0s
Publish Docker image / build (linux/arm64, ubuntu-24.04-arm, arm64) (push) Canceled after 0s
Publish Docker image / merge (push) Canceled after 0s
ci / ci (22, ubuntu-latest) (push) Canceled after 0s
2026-08-31 15:02:42 +08:00
O96u 20afda5869 release: v1.2.3 — 修复简短图片链接在按年/月存储下 404
ci / ci (22, ubuntu-latest) (push) Canceled after 0s
Publish Docker image / build (linux/amd64, ubuntu-latest, amd64) (push) Canceled after 0s
Publish Docker image / build (linux/arm64, ubuntu-24.04-arm, arm64) (push) Canceled after 0s
Publish Docker image / merge (push) Canceled after 0s
GitHub Release / release (push) Canceled after 0s
开启简短链接时纯文件名直链先经 SQLite 索引反查完整 key,不再误解析为扁平 images/文件名路径。
2026-08-30 12:33:33 +08:00
O96u c9c34515dd release: v1.2.2 — 双域名配置与 Host 隔离修复
GitHub Release / release (push) Canceled after 0s
Publish Docker image / build (linux/amd64, ubuntu-latest, amd64) (push) Canceled after 0s
Publish Docker image / build (linux/arm64, ubuntu-24.04-arm, arm64) (push) Canceled after 0s
Publish Docker image / merge (push) Canceled after 0s
ci / ci (22, ubuntu-latest) (push) Canceled after 0s
修复设置页误清空管理域、配置与生效 URL 混淆及第三 Host 绕过隔离;加固 Settings API 与中间件,并更新文档站。
2026-08-30 10:57:26 +08:00
O96u 6c6766221b docs: refresh GitHub About description in metadata and FAQ 2026-08-29 12:04:05 +08:00
O96u 58a8e2082f fix: docs Pages deploy permissions and document GitHub Pages setup
Deploy job failed because Pages source must be GitHub Actions; document
About/description guidance and fix workflow job permissions.
2026-08-29 11:53:20 +08:00
O96u 9b0a3f4944 fix: trigger docs workflow when lockfile changes
docs:build uses npm ci; include package.json and package-lock.json in paths.
2026-08-29 11:50:20 +08:00
O96u e9f1cde06c fix: sync package-lock.json for vitepress devDependency
ci / ci (22, ubuntu-latest) (push) Canceled after 0s
GitHub Release / release (push) Canceled after 0s
Publish Docker image / build (linux/amd64, ubuntu-latest, amd64) (push) Canceled after 0s
Publish Docker image / build (linux/arm64, ubuntu-24.04-arm, arm64) (push) Canceled after 0s
Publish Docker image / merge (push) Canceled after 0s
npm ci failed in CI because vitepress was added to package.json without
updating the lockfile.
2026-08-29 11:46:56 +08:00
O96u ff20f97522 release: v1.2.1 — login captcha, docs site, and bug fixes
Add slider captcha for web login, VitePress documentation on GitHub Pages,
and fixes for gallery delete refresh, pagination jump, and copy-format persistence.
2026-08-29 11:42:18 +08:00
O96u 469d3be735 release: v1.2.0 — unified images/ storage and legacy migration
ci / ci (22, ubuntu-latest) (push) Canceled after 0s
Publish Docker image / build (linux/amd64, ubuntu-latest, amd64) (push) Canceled after 0s
Publish Docker image / build (linux/arm64, ubuntu-24.04-arm, arm64) (push) Canceled after 0s
Publish Docker image / merge (push) Canceled after 0s
GitHub Release / release (push) Canceled after 0s
Consolidate local images under data/images/, add docker migrate CLI,
startup index sync with orphan cleanup, and remove upload folder UI/API.
2026-08-28 17:18:03 +08:00
O96u 3dee0437e6 release: v1.1.5 — gallery preview reliability fixes
ci / ci (22, ubuntu-latest) (push) Canceled after 0s
Publish Docker image / build (linux/amd64, ubuntu-latest, amd64) (push) Canceled after 0s
Publish Docker image / build (linux/arm64, ubuntu-24.04-arm, arm64) (push) Canceled after 0s
Publish Docker image / merge (push) Canceled after 0s
GitHub Release / release (push) Canceled after 0s
Bump version to 1.1.5. Retry gallery thumbnails after transient failures,
verify storage before serving, and stop caching image 404/403 responses.
2026-08-28 14:25:40 +08:00
O96u f25c89855f fix: gallery preview retries and avoid caching failed image responses
Verify storage files before serving from the index, disable CDN caching
on image 404/403 and host-isolation blocks, and retry gallery thumbnails
after transient load failures.
2026-08-28 14:25:04 +08:00
O96u b0a5fbfca5 release: v1.1.4 — storage path layout and bidirectional domain isolation
Bump version to 1.1.4. Add flat vs date storage paths, basename-only
hide-folder URLs, and block image paths on the site host when domain
separation is active.
2026-08-28 09:59:13 +08:00
O96u 438c1d5529 feat: storage path layout, basename URLs, and bidirectional domain isolation
Add storage_use_date_path for flat vs date-key storage layout and emit
basename-only public URLs when hide-folder is enabled. Block image paths
on the site host when domain separation is active so only the image host
serves files. Update settings UI, i18n, tests, and deployment docs.
2026-08-28 09:56:45 +08:00
O96u Build f7488753ca Update LICENSE 2026-08-27 15:32:31 +08:00
181 changed files with 15305 additions and 3541 deletions

No files matched your search

+5 -1
View File
@@ -14,9 +14,13 @@
# 管理后台域名(可选;不填则使用当前请求域名,也可在设置页配置) # 管理后台域名(可选;不填则使用当前请求域名,也可在设置页配置)
# SITE_BASE_URL=https://admin.example.com # SITE_BASE_URL=https://admin.example.com
# 隐藏图片直链中的目录前缀(如 images/2026/08/a.webp → 2026/08/a.webp) # 隐藏图片直链中的目录前缀(如 images/2026/08/a.webp → a.webp)
# HIDE_FOLDER_IN_URL=false # HIDE_FOLDER_IN_URL=false
# 上传目录按年/月分组(true/date 默认;false/flat 则保存为 images/xxx.webp)
# STORAGE_USE_DATE_PATH=true
# STORAGE_LAYOUT=date
# 图片直链域名(可选;不填则使用当前请求域名,也可在设置页配置) # 图片直链域名(可选;不填则使用当前请求域名,也可在设置页配置)
# IMAGE_BASE_URL=https://pic.example.com # IMAGE_BASE_URL=https://pic.example.com
+2 -2
View File
@@ -1,6 +1,6 @@
{ {
"description": "Self-hosted image hosting for your NAS / home server — Nuxt admin UI, local disk storage, server-side WebP compression, referer protection, Twikoo/EasyImage compatible.", "description": "Lightweight self-hosted image hosting — Docker, multi-user, gallery & API, local disk or S3-compatible storage.",
"homepage": "https://github.com/O96u/PicHost#readme", "homepage": "https://o96u.github.io/PicHost/",
"topics": [ "topics": [
"docker", "docker",
"image-hosting", "image-hosting",
+3
View File
@@ -37,3 +37,6 @@ jobs:
- name: Test - name: Test
run: npm run test run: npm run test
- name: Build docs
run: npm run docs:build
+68
View File
@@ -0,0 +1,68 @@
name: docs
on:
push:
branches: [main]
paths:
- 'docs-site/**'
- 'docs/screenshots/**'
- '.github/workflows/docs.yml'
- 'package.json'
- 'package-lock.json'
workflow_dispatch:
permissions:
contents: read
pages: write
id-token: write
concurrency:
group: pages
cancel-in-progress: false
jobs:
build:
runs-on: ubuntu-latest
permissions:
contents: read
pages: write
id-token: write
steps:
- name: Checkout
uses: actions/checkout@v7
- name: Install node
uses: actions/setup-node@v6
with:
node-version: 22
cache: npm
- name: Install npm 11
run: |
npm install -g npm@11.12.1
npm --version
- name: Install dependencies
run: npm ci
- name: Build docs
run: npm run docs:build
- name: Upload Pages artifact
uses: actions/upload-pages-artifact@v4
with:
path: docs-site/.vitepress/dist
deploy:
needs: build
runs-on: ubuntu-latest
permissions:
pages: write
id-token: write
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v4
+5
View File
@@ -40,3 +40,8 @@ git-hooks/
# 本地维护脚本(assign-image-owner、fpk 打包等,不进仓库) # 本地维护脚本(assign-image-owner、fpk 打包等,不进仓库)
scripts/ scripts/
dist-fpk/ dist-fpk/
# VitePress docs site
docs-site/.vitepress/cache
docs-site/.vitepress/dist
docs-site/public/screenshots
+138 -3
View File
@@ -2,6 +2,140 @@
本仓库自 **v1.0.0** 起作为正式版维护;此前历史提交已归档重置。 本仓库自 **v1.0.0** 起作为正式版维护;此前历史提交已归档重置。
## [Unreleased]
## [1.2.7] - 2026-09-03
### 新增
- 登录/注册人机验证:本地滑块、Cloudflare Turnstile、Cap;管理员在「设置 → 访问控制」中切换
- 运维 CLI `slider`:将登录验证重置为本地滑块(`docker exec pichost slider`)
### 改进
- 登出后登录页验证码可正常重新加载
## [1.2.6] - 2026-09-01
### 新增
- 设置、API、图库、存储页 UI 重构(侧栏导航、统计卡片、在线调试等)
- 图库筛选增加存储后端下拉
- 新建对象存储后端按类型生成存储标识:`r2-*`、`cos-*`、`oss-*`、`s3-*`
### 改进
- 操作日志表格:操作/来源标签同行展示,路径列省略号截断、无横向滚动条
- 存储后端卡片:存储标识与类型同一行左对齐排列
- README 与文档站截图、导航说明同步新界面
## [1.2.5] - 2026-08-31
### 新增
- 图库页(`/gallery`)替代原「统计」导航;图片点击放大详情弹层(尺寸、上传方式、存储路径、链接复制)
- 图库列表支持全选、反选;单张删除移至详情弹层
- 图片列表 API 返回上传来源(网页 / API,来自活动日志)
### 改进
- README 补充技术栈说明
### 移除
- `/stats` 路由(不再兼容跳转)
## [1.2.4] - 2026-08-31
### 修复
- 访问控制(Referer 防盗链)留空时仍会拦截外链图片:浏览器直开无 Referer 可访问,博客/论坛嵌入带 Referer 会 403 图裂;现仅在配置了白名单时才校验
## [1.2.3] - 2026-08-30
### 修复
- 开启「简短图片链接」且按年/月分组时,纯文件名直链(如 `/xxx.webp`)误解析为 `images/xxx.webp` 导致 404;现先经 SQLite 索引反查完整 key
## [1.2.2] - 2026-08-30
### 修复
- 双域名:设置页保存时误清空管理域(`site_base_url`);仅改图片域时保护已有网站域配置
- 双域名:设置 API 配置值与 request origin fallback 混淆,导致「当前生效」显示错误
- 双域名:未配置的第三 Host(`pages.dev`、裸 IP 等)可绕过 Host 隔离访问后台
### 改进
- 设置 API 拆分 `siteBaseUrl` / `imageBaseUrl`(配置值)、`effective*`(直链)、`runtime`(当前访问检测)
- `PATCH /api/settings` 增加 `domainSeparation` 字段与保存校验
- 设置页:检测地址展示、关闭双域名确认、反代 Host 不一致警告;首次设置不再自动填入 origin
- Host 中间件:双域名开启时对未知 Host 返回 404(开发 `localhost` 例外)
- 文档站:新增 [更新日志](https://o96u.github.io/PicHost/guide/changelog);重写 Cloudflare 部署说明;同步 FAQ / 双域名文档
## [1.2.1] - 2026-08-29
### 新增
- 登录滑动拼图验证(缺口对齐);验证与登录分两步,登录接口校验 `captchaId` / `captchaPosition`
- VitePress 文档站(`docs-site/`):中英文指南,部署至 GitHub Pages <https://o96u.github.io/PicHost/>
- `npm run docs:dev` / `docs:build` / `docs:preview`;CI 增加 `docs:build`;`.github/workflows/docs.yml` 发布文档
### 修复
- 修复统计页删除图片后图库不刷新的问题
- 修复分页跳转输入框在 `type="number"` 下无法跳转的问题
- 修复非首页路由下复制链接格式偏好不持久化的问题(`useUploadPreferences` 首次使用时自动加载)
- 滑动验证 UI:滑块垂直居中、缺口尺寸与滑块一致
### 改进
- README 精简为产品宣传页,详细说明指向文档站
- 双域名文档补充:未知 Host 风险、Cloudflare 橙云部署、勿用 Pages/Workers 整站反代
- 文档截图更新(登录页等);旧 `docs/*.md` 长文迁入 `docs-site/`
## [1.2.0] - 2026-08-28
### 变更
- 图片统一存放在 `data/images/`;遗留 `blog/`、`twikoo/` 等并列目录需迁移后升级
### 新增
- CLI 迁移工具:`docker exec pichost migrate` / `migrate --apply`(`server/cli/migrate-to-single-images.mjs`)
- 扫描 `data/` 下除 `images` 外所有顶层目录中的图片,输出 `data/mapping.json`
- 启动时自动同步图片索引(扫描磁盘、归一化遗留 key、清理孤儿记录),并打印同步日志
- 迁移文档:[文档站 · v1.2 迁移](https://o96u.github.io/PicHost/guide/migration)
### 改进
- 启动时归一化遗留 SQLite key、清理无文件的孤儿索引;支持删除仅索引无文件的记录
## [1.1.5] - 2026-08-28
### 修复
- 修复 API 上传后图库预览偶发失败、需等待刷新才显示的问题
- 图库卡片加载失败后自动重试,刷新列表时重置预览状态
### 改进
- 出图前校验存储文件实际存在,避免索引与磁盘短暂不一致
- 图片 404/403 与域名隔离 404 禁止 CDN 缓存,降低反代误判影响
## [1.1.4] - 2026-08-28
### 新增
- 上传目录可按年/月分组或扁平保存(`storage_use_date_path` / `STORAGE_USE_DATE_PATH`),设置页可切换
- 双域名分离下网站域禁止直链出图,仅图片域可访问图片路径
### 改进
- 隐藏 `images/` 前缀时外链改为仅文件名(如 `xxx.webp`);图片域仍兼容旧版 `/2026/08/xxx.webp` 短链
- 设置页重构:功能开关、域名配置、存储路径分组展示
- 本地磁盘索引扫描支持扁平路径;文件夹列表 `images` 置顶
## [1.1.3] - 2026-08-27 ## [1.1.3] - 2026-08-27
### 新增 ### 新增
@@ -10,7 +144,7 @@
- `/setup` 与系统设置支持启用/关闭域名分离,设置页可后续关闭并恢复单域 - `/setup` 与系统设置支持启用/关闭域名分离,设置页可后续关闭并恢复单域
- 可选隐藏外链中的 `images/` 前缀(多文件夹时确认提示) - 可选隐藏外链中的 `images/` 前缀(多文件夹时确认提示)
- 设置页加载时检测 GitHub 最新版本并提示更新 - 设置页加载时检测 GitHub 最新版本并提示更新
- 双域名部署文档(Nginx / Lucky / 本地 hosts 测试):`docs/domain-separation.md` - 双域名部署文档(Nginx / Lucky / 本地 hosts 测试):[文档站 · 双域名分离](https://o96u.github.io/PicHost/guide/domain-separation)
### 改进 ### 改进
@@ -98,6 +232,7 @@
- 中英文界面(`@nuxtjs/i18n`),语言切换与主题切换独立菜单 - 中英文界面(`@nuxtjs/i18n`),语言切换与主题切换独立菜单
- 移动端导航:汉堡菜单 + 分组用户菜单(语言 / 外观 / 账户) - 移动端导航:汉堡菜单 + 分组用户菜单(语言 / 外观 / 账户)
- Docker 忘记密码:`docker exec pichost reset-password`(随机密码,见 `server/cli/`) - Docker 忘记密码:`docker exec pichost reset-password`(随机密码,见 `server/cli/`)
- Docker 升级到 v1.2.0 前迁移遗留目录:`docker exec pichost migrate` / `migrate --apply`
### 改进 ### 改进
@@ -134,9 +269,9 @@
### 路线图 ### 路线图
- **v1.1.2**(当前 `main`):操作日志、图库存储筛选、上传限流 - **v1.2.0**(开发中):统一 `images/` 存储、遗留目录迁移 CLI、启动索引同步
[1.1.2]: https://github.com/O96u/PicHost/releases/tag/v1.1.2 [1.2.0]: https://github.com/O96u/PicHost/releases/tag/v1.2.0
[1.1.1]: https://github.com/O96u/PicHost/releases/tag/v1.1.1 [1.1.1]: https://github.com/O96u/PicHost/releases/tag/v1.1.1
[1.1.0]: https://github.com/O96u/PicHost/releases/tag/v1.1.0 [1.1.0]: https://github.com/O96u/PicHost/releases/tag/v1.1.0
[1.0.4]: https://github.com/O96u/PicHost/releases/tag/v1.0.4 [1.0.4]: https://github.com/O96u/PicHost/releases/tag/v1.0.4
+2
View File
@@ -24,6 +24,8 @@ ENV NODE_ENV=production \
COPY --from=build /app/.output ./.output COPY --from=build /app/.output ./.output
COPY server/cli/reset-password.mjs ./server/cli/reset-password.mjs COPY server/cli/reset-password.mjs ./server/cli/reset-password.mjs
COPY server/cli/migrate-to-single-images.mjs ./server/cli/migrate-to-single-images.mjs
COPY server/cli/slider.mjs ./server/cli/slider.mjs
COPY docker-entrypoint.sh /docker-entrypoint.sh COPY docker-entrypoint.sh /docker-entrypoint.sh
RUN chmod +x /docker-entrypoint.sh RUN chmod +x /docker-entrypoint.sh
-16
View File
@@ -1,19 +1,3 @@
PicHost - Self-hosted image hosting
Copyright (C) 2026 O96u
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License version 3 as
published by the Free Software Foundation.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
================================================================================
GNU GENERAL PUBLIC LICENSE GNU GENERAL PUBLIC LICENSE
Version 3, 29 June 2007 Version 3, 29 June 2007
+35 -192
View File
@@ -17,64 +17,52 @@
</p> </p>
<p align="center"> <p align="center">
<a href="#branches">Branches</a> · <a href="https://o96u.github.io/PicHost/en/">Docs</a> ·
<a href="#quick-start">Quick Start</a> ·
<a href="#screenshots">Screenshots</a> · <a href="#screenshots">Screenshots</a> ·
<a href="#features">Features</a> · <a href="#features">Features</a> ·
<a href="#tech-stack">Tech Stack</a> · <a href="#tech-stack">Tech Stack</a> ·
<a href="#roadmap">Roadmap</a> · <a href="#quick-start">Quick Start</a> ·
<a href="README.md">简体中文</a> <a href="README.md">简体中文</a>
</p> </p>
--- ---
## Branches
| Branch | Notes |
| ------ | ----- |
| [**main**](https://github.com/O96u/PicHost/tree/main) (default) | **v1.1.3** — local disk + multi-backend storage, dual-domain separation, [`/storage`](docs/README.md) UI, hybrid URLs |
| [**cloudflare**](https://github.com/O96u/PicHost/tree/cloudflare) | **Cloudflare R2–only line** — streamlined for deployments that use R2 exclusively |
Use **main** for the full product and multiple cloud backends.
If you **only use Cloudflare R2**, check out the dedicated branch:
```bash
git clone https://github.com/O96u/PicHost.git
cd PicHost
git checkout cloudflare
```
---
## Screenshots ## Screenshots
| Upload | Storage | **main** has no public demo. **cloudflare** branch live demo: [pic.roven.cc](https://pic.roven.cc)
| :------------------------------------: | :--------------------------------: |
| ![Upload](docs/screenshots/upload.png) | ![Storage](docs/screenshots/storage.png) |
| Stats & gallery | Settings | | API | Gallery |
| :----------------------------------: | :--------------------------------: | | :--: | :-----: |
| ![Stats](docs/screenshots/stats.png) | ![Settings](docs/screenshots/settings.png) | | ![API](docs/screenshots/api.png) | ![Gallery](docs/screenshots/gallery.png) |
| Storage | Settings |
| :-----: | :-------: |
| ![Storage](docs/screenshots/storage.png) | ![Settings](docs/screenshots/settings-en.png) |
## Features ## Features
- **Drag, click, or Ctrl+V paste** to upload; admins can use custom `folder` paths - **Drag, click, or Ctrl+V paste** — server-side WebP, Referer hotlink protection
- **Server-side WebP** (sharp), Referer hotlink protection, configurable public base URL - **Multi-user** — login with slider captcha, optional registration; users see only their images
- **Multi-user** — username/password login; optional registration; users see only their images - **Multi-backend storage** — local disk + S3-compatible (R2 / COS / OSS / AWS); hybrid `proxy` / `public` URLs
- **Upload preferences** (home card flip): client-side compression, auto-copy links, per-user auto-delete - **Gallery** — browse, search, filter by storage/source, grid/list views, batch delete; stats overview and source breakdown
- **Multi-backend storage**: local disk + S3-compatible (Cloudflare R2 / Tencent COS / Alibaba OSS / AWS S3); admins manage backends at **Storage** (`/storage`) - **API & Twikoo** — global / per-user tokens; `POST /api/index.php` compatible
- **Hybrid URLs**: `proxy` (same-origin via PicHost) or `public` (302 to CDN / bucket URL) - **Zero-config Docker** — first-run web wizard, no secrets upfront
- **Gallery** — browse, search, batch delete; admin sees uploader badges
- **Stats** — upload/delete metrics, folder breakdown; admin sees registered user count ## Tech Stack
- **API** — global token (admin) + per-user tokens; in-app cURL snippets
- **Twikoo** — `POST /api/index.php` (`image` + `token`) | Layer | Technologies |
- **Zero-config Docker** — first-run web wizard, no secrets required upfront | ----- | ------------ |
| Frontend | [Nuxt 4](https://nuxt.com) · [Nuxt UI 4](https://ui.nuxt.com) · [Vue 3](https://vuejs.org) · [Tailwind CSS 4](https://tailwindcss.com) · TypeScript |
| Backend | [Nitro](https://nitro.build) (`node-server`) · REST API |
| Data | SQLite · local `data/pichost.db` |
| Images | [sharp](https://sharp.pixelplumbing.com) (server-side WebP) |
| Storage | Local disk · S3-compatible ([AWS SDK](https://aws.amazon.com/sdk-for-javascript/) · R2 / COS / OSS, etc.) |
| i18n | [@nuxtjs/i18n](https://i18n.nuxtjs.org) (zh-CN / English) |
| Quality & docs | [Vitest](https://vitest.dev) · [VitePress](https://vitepress.dev) docs site |
| Deploy | Docker · Node.js 22 |
## Quick Start ## Quick Start
### Docker (recommended)
```bash ```bash
docker run -d \ docker run -d \
--name pichost \ --name pichost \
@@ -84,159 +72,14 @@ docker run -d \
muxui/pichost:latest muxui/pichost:latest
``` ```
Default port: **6892**. Open `http://<host>:6892` in your browser and complete the setup wizard. Open `http://<host>:6892` and complete the setup wizard.
**Forgot password** (requires `docker exec` / server access): **Full guide** (env vars, upgrades, dual-domain setup, API, Twikoo, reverse proxy, local dev, etc.): **[Documentation](https://o96u.github.io/PicHost/en/)**.
```bash | Branch | Notes |
docker exec pichost reset-password # admin only, when there is exactly one | ------ | ----- |
docker exec pichost reset-password <username> # admin or regular user | [**main**](https://github.com/O96u/PicHost/tree/main) | Default: multi-backend, dual-domain, unified `images/` storage |
``` | [**cloudflare**](https://github.com/O96u/PicHost/tree/cloudflare) | Cloudflare R2 only; live demo [pic.roven.cc](https://pic.roven.cc) |
A random password is printed. Fails if the user does not exist.
If you cloned the repo, you can also use `docker compose up -d` (see `docker-compose.yml`).
### Local development
```bash
npm install
cp .env.example .env # optional
npm run dev
```
Open `http://localhost:3000/setup` to create the admin account. Data defaults to `./data`.
## Tech Stack
| Layer | Technology |
| -------- | --------------------------------------------------- |
| Frontend | Nuxt 4, Vue 3, Nuxt UI 4, Tailwind CSS 4 |
| Backend | Nitro (Node.js), SQLite |
| Images | sharp (WebP transcoding & compression) |
| Auth | Session cookie, scrypt password hashing, API tokens |
| Storage | Local disk + S3-compatible backends (R2 / COS / OSS / AWS); `images` index in SQLite |
| Deploy | Docker (amd64 / arm64), docker compose |
| Tests | Vitest |
## Users & permissions
| Scenario | Auth | Ownership / visibility |
| ------------------------------ | -------------------- | --------------------------- |
| Web upload | Session | Current user |
| API upload + **user token** | `Auth-Token` | Token owner |
| API upload + **global token** | `Auth-Token` | Admin (`userId` null) |
| Twikoo `/api/index.php` | form `token` | Same as global token |
| Gallery list / search / delete | Session or token | Users: own only; admin: all |
| Direct link `GET /images/...` | None (Referer rules) | Public if URL is known |
Regular users are limited to the `images/` folder; custom `folder` requires admin or global token.
## Storage layout
```
/data
├── pichost.db # SQLite: users, sessions, settings, storage_backends, images index
├── images/ # Default upload folder (when using local backend)
├── blog/ # Example custom folder
└── twikoo/ # Twikoo comment images
```
Pattern: `folder/YYYY/MM/randomId.webp` + `.meta.json`. With cloud backends, blobs live in the bucket; SQLite `images` tracks `backend_id` and `key`. Back up all of `/data` plus bucket data.
## API overview
| Method | Path | Description |
| ------ | -------------------- | ---------------------- |
| POST | `/api/images/upload` | Upload image |
| GET | `/api/images` | Paginated list |
| DELETE | `/api/images` | Delete one (`key`) |
| POST | `/api/index.php` | Twikoo / EasyImage 2.0 |
Upload example:
```bash
curl -X POST "https://pic.example.com/api/images/upload" \
-H "Auth-Token: YOUR_TOKEN" \
-F "folder=blog" \
-F "image=@./demo.png"
```
Full docs and copyable cURL snippets are on the **API** page after deployment.
## Twikoo
| Setting | Value |
| ----------------- | ----------------------------------- |
| `IMAGE_CDN` | `easyimage` |
| `IMAGE_CDN_URL` | `https://your-domain/api/index.php` |
| `IMAGE_CDN_TOKEN` | Same as `API_UPLOAD_TOKEN` |
## Reverse proxy
- Proxy to port **6892**; HTTPS recommended
- Nginx: `client_max_body_size` ≥ 12m
- Set `IMAGE_BASE_URL` (single domain) or `SITE_BASE_URL` + `IMAGE_BASE_URL` (dual-domain separation)
### Recommended architecture (dual domains)
**One Docker instance + two domains**, both fully proxied to `6892` (isolation via PicHost middleware):
```nginx
# admin.example.com — admin + API + images
server {
server_name admin.example.com;
location / {
proxy_pass http://127.0.0.1:6892;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
client_max_body_size 12m;
}
}
# pic.example.com — same full proxy; PicHost middleware blocks non-image paths
server {
server_name pic.example.com;
location / {
proxy_pass http://127.0.0.1:6892;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
```
FN NAS [Lucky](https://github.com/gdy666/lucky) reverse proxy (public hostname → backend `http://<LAN-IP>:6892`):
| Site domain (admin) | Image domain |
| :-----------------: | :----------: |
| ![Lucky site](docs/screenshots/lucky-site.png) | ![Lucky image](docs/screenshots/lucky-image.png) |
See **[docs/domain-separation.en.md](docs/domain-separation.en.md)** for Caddy/NPM notes, env vars, and setup fields.
## Development
```bash
npm run dev # Dev server
npm run build # Production build
npm run start # Run .output
npm run lint # ESLint
npm run typecheck # Type check
npm test # Unit tests
```
## Roadmap
| Version | Plan |
| ---------- | -------------------------------------------------------------- |
| **v1.1.3** | Dual-domain separation, optional `images/` URL prefix, update check (current `main`) |
| **v1.1.2** | Activity logs, gallery storage filter, upload rate limits |
| **v1.1.1** | Session check fixes, storage placeholder cleanup, Docker logging |
| **v1.1.0** | Multi-backend object storage, hybrid URLs, storage UI |
| **v1.0.4** | Password visibility toggle, legacy ADMIN_SECRET migration |
| **v1.0.3** | CI and Docker multi-arch build fixes |
| **v1.0.2** | i18n, mobile menus, stats/upload UI, Docker password reset |
| **v1.0.1** | Settings version display, mobile upload preferences layout fix |
| **v1.0.0** | Local storage, multi-user, API, Twikoo |
## License ## License
+35 -197
View File
@@ -17,64 +17,52 @@
</p> </p>
<p align="center"> <p align="center">
<a href="#分支">分支</a> · <a href="https://o96u.github.io/PicHost/">文档</a> ·
<a href="#快速开始">快速开始</a> ·
<a href="#界面预览">预览</a> · <a href="#界面预览">预览</a> ·
<a href="#特性">特性</a> · <a href="#特性">特性</a> ·
<a href="#技术栈">技术栈</a> · <a href="#技术栈">技术栈</a> ·
<a href="#路线图">路线图</a> · <a href="#快速开始">快速开始</a> ·
<a href="README.en.md">English</a> <a href="README.en.md">English</a>
</p> </p>
--- ---
## 分支
| 分支 | 说明 |
| ---- | ---- |
| [**main**](https://github.com/O96u/PicHost/tree/main)(默认) | **v1.1.3** 主线:本地磁盘 + 多对象存储、双域名分离、[`/storage`](docs/README.md) 存储管理、混合直链 |
| [**cloudflare**](https://github.com/O96u/PicHost/tree/cloudflare) | **Cloudflare R2 专用线**:面向「只用 R2」的部署,预设与配置更聚焦 R2 |
日常使用、需要管理多种存储后端,请直接用 **main**。
若图床**只接 Cloudflare R2**、希望更简化的 R2 专用版本,可切换分支:
```bash
git clone https://github.com/O96u/PicHost.git
cd PicHost
git checkout cloudflare
```
---
## 界面预览 ## 界面预览
| 上传 | 存储管理 | **main** 分支暂无公网演示;**cloudflare** 分支在线体验:[pic.roven.cc](https://pic.roven.cc)
| :-----------------------------------: | :-------------------------------------: |
| ![上传](docs/screenshots/upload.png) | ![存储](docs/screenshots/storage.png) |
| 统计与图库 | 系统设置 | | API | 图库 |
| :---------------------------------: | :-------------------------------------: | | :--: | :--: |
| ![统计](docs/screenshots/stats.png) | ![设置](docs/screenshots/settings.png) | | ![API](docs/screenshots/api.png) | ![图库](docs/screenshots/gallery.png) |
| 存储 | 设置 |
| :--: | :--: |
| ![存储](docs/screenshots/storage.png) | ![设置](docs/screenshots/settings.png) |
## 特性 ## 特性
- **拖拽 / 点击 / Ctrl+V 粘贴**上传;管理员可自定义 `folder` 目录 - **拖拽 / 点击 / Ctrl+V 粘贴**上传;服务端 WebP 压缩、Referer 防盗链
- **服务端 WebP 压缩**(sharp)、Referer 防盗链、可配置访问域名 - **多用户**:账号密码登录(滑动验证)、可选开放注册;普通用户仅见自己的图片
- **多用户**:账号密码登录;管理员可开放注册;普通用户仅见自己的图片 - **多后端存储**:本地磁盘 + S3 兼容(R2 / COS / OSS / AWS);混合直链 `proxy` / `public`
- **上传偏好**(首页卡片翻转):客户端预压缩、自动复制链接、按用户自动删除 - **图库**:浏览、搜索、按存储/来源筛选、网格/列表切换、批量删除;统计概览与来源分布
- **多后端存储**:本地磁盘 + S3 兼容(Cloudflare R2 / 腾讯云 COS / 阿里云 OSS / AWS S3);管理员在 **存储**(`/storage`)页管理,新上传写入默认后端 - **API 与 Twikoo**:全局 / 个人 Token;`POST /api/index.php` 兼容
- **混合直链**:`proxy`(PicHost 同源代理)或 `public`(302 到 CDN / 桶公网地址) - **Docker 零配置**:首次访问 Web 引导创建管理员
- **图库**:浏览、搜索、批量删除;管理员可查看上传者标签
- **统计**:上传/删除趋势、目录分布;管理员额外显示注册用户数量 ## 技术栈
- **API**:全局 Token(管理员)+ 每用户个人 Token;后台可复制 cURL
- **Twikoo**:`POST /api/index.php`(`image` + `token`) | 层级 | 技术 |
- **Docker 零配置**:首次访问 Web 引导创建管理员,无需预先写密钥 | ---- | ---- |
| 前端 | [Nuxt 4](https://nuxt.com) · [Nuxt UI 4](https://ui.nuxt.com) · [Vue 3](https://vuejs.org) · [Tailwind CSS 4](https://tailwindcss.com) · TypeScript |
| 后端 | [Nitro](https://nitro.build)(`node-server`)· REST API |
| 数据 | SQLite · 本地 `data/pichost.db` |
| 图片处理 | [sharp](https://sharp.pixelplumbing.com)(服务端 WebP 压缩) |
| 存储 | 本地磁盘 · S3 兼容([AWS SDK](https://aws.amazon.com/sdk-for-javascript/) · R2 / COS / OSS 等) |
| 国际化 | [@nuxtjs/i18n](https://i18n.nuxtjs.org)(简体中文 / English) |
| 质量与文档 | [Vitest](https://vitest.dev) · [VitePress](https://vitepress.dev) 文档站 |
| 部署 | Docker · Node.js 22 |
## 快速开始 ## 快速开始
### Docker(推荐)
```bash ```bash
docker run -d \ docker run -d \
--name pichost \ --name pichost \
@@ -84,164 +72,14 @@ docker run -d \
muxui/pichost:latest muxui/pichost:latest
``` ```
默认端口 **6892**。浏览器打开 `http://<主机IP>:6892`,按引导创建管理员即可。 浏览器打开 `http://<主机IP>:6892`,按引导完成设置即可。
**忘记密码**(需能执行 `docker exec`,相当于服务器权限): **详细说明**(环境变量、升级迁移、双域名、API、Twikoo、反向代理、本地开发等)见 **[用户文档](https://o96u.github.io/PicHost/)**。
```bash | 分支 | 说明 |
# 无参数:重置管理员(仅当系统中只有一个管理员时) | ---- | ---- |
docker exec pichost reset-password | [**main**](https://github.com/O96u/PicHost/tree/main) | 默认主线:多后端、双域名、统一 `images/` 存储 |
| [**cloudflare**](https://github.com/O96u/PicHost/tree/cloudflare) | 仅 Cloudflare R2;在线演示 [pic.roven.cc](https://pic.roven.cc) |
# 指定用户名:可重置管理员或普通用户
docker exec pichost reset-password 用户名
```
终端会打印随机新密码;用户不存在时会报错。登录后请到「修改密码」更换。
本地:`npm run reset-password` 或 `npm run reset-password -- 用户名`
已克隆仓库时也可用 `docker compose up -d`(见仓库内 `docker-compose.yml`)。
### 本地开发
```bash
npm install
cp .env.example .env # 可选
npm run dev
```
访问 `http://localhost:3000/setup` 创建管理员;数据目录默认 `./data`。
## 技术栈
| 层级 | 技术 |
| -------- | ------------------------------------------ |
| 前端 | Nuxt 4、Vue 3、Nuxt UI 4、Tailwind CSS 4 |
| 后端 | Nitro(Node.js)、SQLite |
| 图片处理 | sharp(WebP 转码与压缩) |
| 鉴权 | Session Cookie、scrypt 密码哈希、API Token |
| 存储 | 本地磁盘 + S3 兼容后端(R2 / COS / OSS / AWS);`images` 索引在 SQLite |
| 部署 | Docker(amd64 / arm64)、docker compose |
| 测试 | Vitest |
## 用户与权限
| 场景 | 鉴权 | 图片归属 / 可见范围 |
| -------------------------- | -------------------- | -------------------------- |
| 网页上传 | Session | 当前登录用户 |
| API 上传 + **个人 Token** | `Auth-Token` | 该 Token 所属用户 |
| API 上传 + **全局 Token** | `Auth-Token` | 管理员(`userId` 为空) |
| Twikoo `/api/index.php` | 表单 `token` | 与全局 Token 相同 |
| 图库列表 / 搜索 / 删除 | Session 或 Token | 普通用户仅自己;管理员全部 |
| 图片直链 `GET /images/...` | 无(Referer 防盗链) | 知道 URL 即可访问 |
普通用户上传目录固定为 `images/`;自定义 `folder` 仅管理员或全局 Token 可用。
## 存储结构
```
/data
├── pichost.db # SQLite:用户、会话、设置、storage_backends、images 索引
├── images/ # 默认上传目录(本地后端时)
├── blog/ # 管理员自定义 folder 示例
└── twikoo/ # Twikoo 评论图片
```
路径规则:`目录/年/月/随机ID.webp` + 同名 `.meta.json`。云存储时文件在对应桶内,SQLite `images` 表记录 `backend_id` 与 `key`。备份请包含整个 `/data` 与云桶数据。
## API 概览
| 方法 | 路径 | 说明 |
| ------ | -------------------- | ---------------------- |
| POST | `/api/images/upload` | 上传图片 |
| GET | `/api/images` | 分页列表 |
| DELETE | `/api/images` | 删除单张(`key`) |
| POST | `/api/index.php` | Twikoo / EasyImage 2.0 |
上传示例:
```bash
curl -X POST "https://pic.example.com/api/images/upload" \
-H "Auth-Token: YOUR_TOKEN" \
-F "folder=blog" \
-F "image=@./demo.png"
```
完整接口与可复制 cURL 见部署后后台 **API** 页。
## Twikoo
| 配置项 | 值 |
| ----------------- | -------------------------------- |
| `IMAGE_CDN` | `easyimage` |
| `IMAGE_CDN_URL` | `https://你的域名/api/index.php` |
| `IMAGE_CDN_TOKEN` | 与 `API_UPLOAD_TOKEN` 相同 |
## 反向代理
- 反代到容器 **6892** 端口,建议 HTTPS
- Nginx:`client_max_body_size` ≥ 12m
- 公网访问请配置 `IMAGE_BASE_URL`(单域名)或 `SITE_BASE_URL` + `IMAGE_BASE_URL`(双域名分离)
### 推荐部署架构(双域名分离)
**单 Docker 实例 + 两个域名** 均可全量反代到 `6892`(隔离由 PicHost 中间件负责):
```nginx
# admin.example.com — 后台 + API + 出图
server {
server_name admin.example.com;
location / {
proxy_pass http://127.0.0.1:6892;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
client_max_body_size 12m;
}
}
# pic.example.com — 同样全量反代;PicHost 中间件会拦截非图片路径
server {
server_name pic.example.com;
location / {
proxy_pass http://127.0.0.1:6892;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
```
飞牛 NAS [Lucky](https://github.com/gdy666/lucky) 反向代理示例(前端域名 → 后端 `http://<内网IP>:6892`):
| 网站域名(管理后台) | 图片域名 |
| :------------------: | :------: |
| ![Lucky 网站域名](docs/screenshots/lucky-site.png) | ![Lucky 图片域名](docs/screenshots/lucky-image.png) |
完整说明(Caddy / NPM、环境变量、初始化填写项)见 **[docs/domain-separation.md](docs/domain-separation.md)**。
## 开发
```bash
npm run dev # 开发服务器
npm run build # 生产构建
npm run start # 运行 .output
npm run lint # ESLint
npm run typecheck # 类型检查
npm test # 单元测试
```
## 路线图
| 版本 | 说明 |
| ---------- | ------------------------------------------------- |
| **v1.1.3** | 双域名分离、隐藏 `images/` 前缀、版本更新提示(当前 main) |
| **v1.1.2** | 操作日志、图库存储筛选、上传限流 |
| **v1.1.1** | 会话校验与存储占位修复、Docker 日志增强 |
| **v1.1.0** | S3 / R2 多后端对象存储、混合直链、存储管理页 |
| **v1.0.4** | 密码显示切换、遗留 ADMIN_SECRET 迁移对齐 |
| **v1.0.3** | CI 与 Docker 多架构构建修复 |
| **v1.0.2** | 中英文、移动端菜单、统计/上传 UI、Docker 重置密码 |
| **v1.0.1** | 设置页版本展示、移动端上传偏好布局修复 |
| **v1.0.0** | 本地存储、多用户、API、Twikoo |
## License ## License
+18
View File
@@ -106,3 +106,21 @@
.upload-settings-btn:hover .upload-settings-icon { .upload-settings-btn:hover .upload-settings-icon {
transform: rotate(90deg); transform: rotate(90deg);
} }
.api-debug-response {
scrollbar-width: thin;
scrollbar-color: rgb(82 82 82) transparent;
}
.api-debug-response::-webkit-scrollbar {
width: 6px;
}
.api-debug-response::-webkit-scrollbar-thumb {
border-radius: 9999px;
background-color: rgb(82 82 82);
}
.api-debug-response::-webkit-scrollbar-track {
background: transparent;
}
+43 -6
View File
@@ -1,6 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import logoLight from '~/assets/image/logo-light.png' import logoLight from '~/assets/image/logo-light.png'
import logoDark from '~/assets/image/logo-dark.png' import logoDark from '~/assets/image/logo-dark.png'
import type { LoginVerificationPayload } from '~/types/auth'
const router = useRouter() const router = useRouter()
const { login, fetchStatus, authStatus } = useAuth() const { login, fetchStatus, authStatus } = useAuth()
@@ -11,29 +12,53 @@ const username = ref('')
const password = ref('') const password = ref('')
const secret = ref('') const secret = ref('')
const loading = ref(false) const loading = ref(false)
const verificationRef = ref<{ reset: () => Promise<void> } | null>(null)
const verificationPayload = ref<LoginVerificationPayload | null>(null)
const verificationMountKey = ref(0)
const legacyMode = computed(() => authStatus.value?.legacyMode ?? false) const legacyMode = computed(() => authStatus.value?.legacyMode ?? false)
const allowRegistration = computed(() => authStatus.value?.allowRegistration ?? false) const allowRegistration = computed(() => authStatus.value?.allowRegistration ?? false)
const loginVerification = computed(() =>
const canSubmit = computed(() => authStatus.value?.loginVerification ?? { method: 'slider' as const }
legacyMode.value ? Boolean(secret.value) : Boolean(username.value && password.value)
) )
const canSubmit = computed(() =>
Boolean(verificationPayload.value)
&& (legacyMode.value ? Boolean(secret.value) : Boolean(username.value && password.value))
)
function onVerificationVerified(payload: LoginVerificationPayload) {
verificationPayload.value = payload
}
function onVerificationCleared() {
verificationPayload.value = null
}
async function resetVerification() {
verificationPayload.value = null
await verificationRef.value?.reset()
}
onMounted(async () => { onMounted(async () => {
verificationPayload.value = null
verificationMountKey.value += 1
const status = await fetchStatus() const status = await fetchStatus()
if (!status.initialized && !status.legacyMode) { if (!status.initialized && !status.legacyMode) {
await router.replace('/setup') await router.replace('/setup')
return
} }
}) })
async function submit() { async function submit() {
if (!canSubmit.value || loading.value) return if (!canSubmit.value || loading.value || !verificationPayload.value) return
loading.value = true loading.value = true
try { try {
const verification = verificationPayload.value
const result = legacyMode.value const result = legacyMode.value
? await login({ secret: secret.value }) ? await login({ secret: secret.value }, verification)
: await login({ username: username.value.trim(), password: password.value }) : await login({ username: username.value.trim(), password: password.value }, verification)
if (result.ok) { if (result.ok) {
if (result.needsMigration) { if (result.needsMigration) {
@@ -43,8 +68,10 @@ async function submit() {
username.value = '' username.value = ''
password.value = '' password.value = ''
secret.value = '' secret.value = ''
await resetVerification()
toast.add({ title: t('auth.loginSuccess'), color: 'success' }) toast.add({ title: t('auth.loginSuccess'), color: 'success' })
} else { } else {
await resetVerification()
toast.add({ toast.add({
title: result.error ?? (legacyMode.value ? t('auth.loginFailedSecret') : t('auth.loginFailedCredentials')), title: result.error ?? (legacyMode.value ? t('auth.loginFailedSecret') : t('auth.loginFailedCredentials')),
color: 'error' color: 'error'
@@ -152,6 +179,16 @@ async function submit() {
</div> </div>
</template> </template>
<LoginVerification
:key="verificationMountKey"
ref="verificationRef"
:method="loginVerification.method"
:turnstile-site-key="loginVerification.turnstileSiteKey"
:cap-api-endpoint="loginVerification.capApiEndpoint"
@verified="onVerificationVerified"
@cleared="onVerificationCleared"
/>
<UButton <UButton
type="submit" type="submit"
:label="t('auth.login')" :label="t('auth.login')"
+61
View File
@@ -0,0 +1,61 @@
<script setup lang="ts">
const props = withDefaults(
defineProps<{
code: string
scrollable?: boolean
lang?: string
}>(),
{
scrollable: true,
lang: ''
}
)
const toast = useToast()
const { t } = useI18n()
async function copyCode() {
try {
await navigator.clipboard.writeText(props.code)
toast.add({ title: t('copy.curl'), color: 'success' })
} catch {
toast.add({ title: t('copy.failed'), color: 'error' })
}
}
</script>
<template>
<div class="overflow-hidden rounded-xl bg-neutral-950">
<div
class="flex items-center justify-between border-b border-neutral-800/80 bg-neutral-900 px-3.5 py-2.5"
>
<div
class="flex items-center gap-2"
aria-hidden="true"
>
<span class="size-3 rounded-full border border-black/15 bg-[#ff5f56]" />
<span class="size-3 rounded-full border border-black/15 bg-[#ffbd2e]" />
<span class="size-3 rounded-full border border-black/15 bg-[#27c93f]" />
<span
v-if="lang"
class="ml-1 text-[10px] font-medium tracking-wide text-neutral-500 uppercase"
>
{{ lang }}
</span>
</div>
<UButton
icon="i-lucide-copy"
:label="t('common.copy')"
size="xs"
variant="ghost"
color="neutral"
class="text-neutral-300 hover:text-white"
@click="copyCode"
/>
</div>
<pre
class="overflow-auto p-4 text-xs leading-relaxed text-neutral-100"
:class="scrollable ? 'max-h-80' : ''"
><code class="font-mono whitespace-pre">{{ code }}</code></pre>
</div>
</template>
+3 -8
View File
@@ -13,21 +13,16 @@ const mobileUserOpen = ref(false)
const navItems = computed(() => { const navItems = computed(() => {
const items = [ const items = [
{ label: t('nav.api'), to: '/api', icon: 'i-lucide-code-2' }, { label: t('nav.api'), to: '/api', icon: 'i-lucide-code-2' },
{ label: t('nav.stats'), to: '/stats', icon: 'i-lucide-chart-column' } { label: t('nav.gallery'), to: '/gallery', icon: 'i-lucide-images' }
] ]
if (isAdmin.value) { if (isAdmin.value) {
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
}) })
const accountMenuItems = computed<DropdownMenuItem[]>(() => [ const accountMenuItems = computed<DropdownMenuItem[]>(() => [
{
label: t('nav.logs'),
icon: 'i-lucide-scroll-text',
to: '/logs'
},
{ {
label: t('nav.changePassword'), label: t('nav.changePassword'),
icon: 'i-lucide-key-round', icon: 'i-lucide-key-round',
@@ -48,7 +43,7 @@ const desktopUserMenuItems = computed<DropdownMenuItem[][]>(() => [
function isNavActive(to: string) { function isNavActive(to: string) {
if (to === '/settings') { if (to === '/settings') {
return route.path === '/settings' return route.path.startsWith('/settings')
} }
return route.path.startsWith(to) return route.path.startsWith(to)
} }
+50
View File
@@ -0,0 +1,50 @@
<script setup lang="ts">
import 'cap-widget'
const props = defineProps<{
apiEndpoint: string
}>()
const emit = defineEmits<{
verified: [payload: { capToken: string }]
cleared: []
}>()
const { t } = useI18n()
const widgetRef = ref<HTMLElement | null>(null)
function onSolve(event: Event) {
const token = (event as CustomEvent<{ token: string }>).detail?.token
if (token) {
emit('verified', { capToken: token })
}
}
function onError() {
emit('cleared')
}
function reset() {
const widget = widgetRef.value as (HTMLElement & { reset?: () => void }) | null
widget?.reset?.()
emit('cleared')
}
defineExpose({ reset })
</script>
<template>
<ClientOnly>
<cap-widget
ref="widgetRef"
:data-cap-api-endpoint="props.apiEndpoint"
:data-cap-i18n-initial-state="t('auth.capInitialState')"
:data-cap-i18n-verifying-label="t('auth.capVerifying')"
:data-cap-i18n-solved-label="t('auth.capSolved')"
:data-cap-i18n-error-label="t('auth.capError')"
@solve="onSolve"
@error="onError"
/>
</ClientOnly>
</template>
+4 -1
View File
@@ -6,6 +6,8 @@ const props = defineProps<{
icon?: string icon?: string
block?: boolean block?: boolean
successTitle?: string successTitle?: string
variant?: 'solid' | 'outline' | 'soft' | 'subtle' | 'ghost' | 'link'
color?: 'primary' | 'secondary' | 'success' | 'info' | 'warning' | 'error' | 'neutral'
}>() }>()
const emit = defineEmits<{ const emit = defineEmits<{
@@ -76,7 +78,8 @@ async function copy() {
<template> <template>
<UButton <UButton
size="xs" size="xs"
variant="soft" :variant="variant ?? 'soft'"
:color="color"
:loading="copying" :loading="copying"
:disabled="!value" :disabled="!value"
:label="iconOnly ? undefined : label" :label="iconOnly ? undefined : label"
+216 -22
View File
@@ -1,32 +1,80 @@
<script setup lang="ts"> <script setup lang="ts">
import type { ImageItem } from '~/types/image' import type { ImageItem } from '~/types/image'
import type { CopyFormat } from '~/composables/useUploadPreferences'
type CopyFormat = 'url' | 'markdown' | 'html'
const props = withDefaults(defineProps<{ const props = withDefaults(defineProps<{
image: ImageItem image: ImageItem
selected: boolean selected: boolean
deleting?: boolean
selectable?: boolean selectable?: boolean
showKey?: boolean showKey?: boolean
showStorage?: boolean showStorage?: boolean
compact?: boolean compact?: boolean
gallery?: boolean
allowDelete?: boolean
}>(), { }>(), {
selectable: true, selectable: true,
showKey: true, showKey: true,
showStorage: false, showStorage: false,
compact: false compact: false,
gallery: false,
allowDelete: true
}) })
const emit = defineEmits<{ const emit = defineEmits<{
'update:selected': [value: boolean] 'update:selected': [value: boolean]
'preview': []
'delete': [] 'delete': []
}>() }>()
const { formatFileSize } = useFileSize() const { formatFileSize } = useFileSize()
const { t, locale } = useI18n() const { t, locale } = useI18n()
const toast = useToast()
const { copyFormat } = useUploadPreferences()
const imageError = ref(false) const imageError = ref(false)
const copyFormat = ref<CopyFormat>('url') const retryCount = ref(0)
function selectCopyFormat(value: CopyFormat) {
copyFormat.value = value
}
const MAX_PREVIEW_RETRIES = 3
const PREVIEW_RETRY_DELAYS_MS = [1000, 2000, 4000]
const previewSrc = computed(() => {
if (retryCount.value === 0) return props.image.url
const separator = props.image.url.includes('?') ? '&' : '?'
return `${props.image.url}${separator}retry=${retryCount.value}`
})
watch(() => props.image.key, () => {
imageError.value = false
retryCount.value = 0
})
let retryTimer: ReturnType<typeof setTimeout> | undefined
function clearRetryTimer() {
if (retryTimer !== undefined) {
clearTimeout(retryTimer)
retryTimer = undefined
}
}
function onPreviewError() {
if (retryCount.value >= MAX_PREVIEW_RETRIES) {
imageError.value = true
return
}
const delay = PREVIEW_RETRY_DELAYS_MS[retryCount.value] ?? PREVIEW_RETRY_DELAYS_MS.at(-1)!
clearRetryTimer()
retryTimer = setTimeout(() => {
retryCount.value += 1
retryTimer = undefined
}, delay)
}
onUnmounted(clearRetryTimer)
const copyFormatItems = computed(() => [ const copyFormatItems = computed(() => [
{ label: t('copy.url'), value: 'url' as const }, { label: t('copy.url'), value: 'url' as const },
@@ -68,21 +116,175 @@ const storageIcon = computed(() =>
props.image.storage?.type === 'local' ? 'i-lucide-hard-drive' : 'i-lucide-cloud' props.image.storage?.type === 'local' ? 'i-lucide-hard-drive' : 'i-lucide-cloud'
) )
const uploadSourceLabel = computed(() => {
switch (props.image.uploadSource) {
case 'web':
return t('stats.tagWeb')
case 'api':
return t('stats.tagApi')
default:
return null
}
})
function toggleSelected(value: boolean | 'indeterminate') { function toggleSelected(value: boolean | 'indeterminate') {
emit('update:selected', value === true) emit('update:selected', value === true)
} }
const copyingUrl = ref(false)
async function copyGalleryUrl() {
if (copyingUrl.value || !props.image.url) return
copyingUrl.value = true
try {
if (typeof navigator !== 'undefined' && navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(props.image.url)
} else {
const textarea = document.createElement('textarea')
textarea.value = props.image.url
textarea.setAttribute('readonly', '')
textarea.style.position = 'fixed'
textarea.style.left = '-9999px'
document.body.appendChild(textarea)
textarea.select()
document.execCommand('copy')
document.body.removeChild(textarea)
}
toast.add({ title: t('copy.copiedUrl'), color: 'success' })
} catch {
toast.add({ title: t('copy.failedManual'), color: 'error' })
} finally {
copyingUrl.value = false
}
}
</script> </script>
<template> <template>
<div class="flex flex-col overflow-hidden rounded-xl border border-default bg-elevated shadow-sm"> <div
<div class="relative aspect-square shrink-0 overflow-hidden bg-muted"> v-if="gallery"
class="flex flex-col overflow-hidden rounded-xl border border-default bg-elevated"
>
<div class="relative aspect-[4/3] shrink-0 overflow-hidden bg-muted">
<img <img
v-if="!imageError" v-if="!imageError"
:src="image.url" :key="`${image.key}-${retryCount}`"
:src="previewSrc"
:alt="image.originalName" :alt="image.originalName"
loading="lazy" loading="lazy"
class="absolute inset-0 size-full object-cover object-center" class="size-full cursor-zoom-in object-cover object-center"
@error="imageError = true" role="button"
tabindex="0"
:aria-label="t('image.openPreview')"
@click="emit('preview')"
@keydown.enter="emit('preview')"
@keydown.space.prevent="emit('preview')"
@error="onPreviewError"
>
<div
v-else
class="flex size-full items-center justify-center text-xs text-muted"
>
{{ t('image.previewFailed') }}
</div>
<div
v-if="selectable"
class="absolute top-2 left-2"
@click.stop
>
<UCheckbox
:model-value="selected"
:ui="{ base: 'shadow-none' }"
@update:model-value="toggleSelected"
/>
</div>
</div>
<div class="flex flex-col gap-1.5 p-3">
<p
class="truncate text-sm font-semibold"
:title="image.originalName"
>
{{ image.originalName }}
</p>
<p class="truncate text-xs text-muted">
{{ uploadedLabel }} · {{ formatFileSize(image.size) }}
</p>
<div
v-if="uploadSourceLabel || (showStorage && image.storage)"
class="flex flex-wrap gap-1.5"
>
<span
v-if="uploadSourceLabel"
class="inline-flex rounded-md bg-primary/10 px-2 py-0.5 text-xs font-medium text-primary"
>
{{ uploadSourceLabel }}
</span>
<span
v-if="showStorage && image.storage"
class="inline-flex max-w-full items-center gap-1 rounded-md border border-default px-2 py-0.5 text-xs text-muted"
:title="image.storage.name"
>
<UIcon
:name="storageIcon"
class="size-3 shrink-0"
/>
<span class="truncate">{{ image.storage.name }}</span>
</span>
</div>
</div>
<div class="flex items-center justify-around border-t border-default px-2 py-2 text-muted">
<UButton
icon="i-lucide-link"
variant="ghost"
color="neutral"
size="sm"
:loading="copyingUrl"
:aria-label="t('common.copy')"
@click="copyGalleryUrl"
/>
<UButton
icon="i-lucide-eye"
variant="ghost"
color="neutral"
size="sm"
:aria-label="t('image.openPreview')"
@click="emit('preview')"
/>
<UButton
v-if="allowDelete"
icon="i-lucide-trash-2"
variant="ghost"
color="neutral"
size="sm"
:aria-label="t('common.delete')"
@click="emit('delete')"
/>
</div>
</div>
<div
v-else
class="flex flex-col overflow-hidden rounded-xl border border-default bg-elevated shadow-sm"
>
<div
class="relative aspect-square shrink-0 overflow-hidden bg-muted"
role="button"
tabindex="0"
:aria-label="t('image.openPreview')"
@click="emit('preview')"
@keydown.enter="emit('preview')"
@keydown.space.prevent="emit('preview')"
>
<img
v-if="!imageError"
:key="`${image.key}-${retryCount}`"
:src="previewSrc"
:alt="image.originalName"
loading="lazy"
class="absolute inset-0 size-full cursor-zoom-in object-cover object-center"
@error="onPreviewError"
> >
<div <div
v-else v-else
@@ -93,26 +295,17 @@ function toggleSelected(value: boolean | 'indeterminate') {
<div <div
v-if="selectable" v-if="selectable"
class="absolute top-2 left-2" class="absolute top-2 left-2"
@click.stop
> >
<UCheckbox <UCheckbox
:model-value="selected" :model-value="selected"
@update:model-value="toggleSelected" @update:model-value="toggleSelected"
/> />
</div> </div>
<div class="absolute top-2 right-2">
<UButton
size="xs"
color="error"
variant="solid"
icon="i-lucide-trash-2"
:aria-label="t('common.delete')"
:loading="deleting"
@click="emit('delete')"
/>
</div>
<div <div
v-if="image.owner" v-if="image.owner"
class="absolute bottom-2 left-2 max-w-[calc(50%-0.5rem)]" class="absolute bottom-2 left-2 max-w-[calc(50%-0.5rem)]"
@click.stop
> >
<span <span
class="inline-flex max-w-full items-center gap-1 rounded-md bg-black/65 px-2 py-0.5 text-xs font-medium text-white shadow-sm backdrop-blur-sm" class="inline-flex max-w-full items-center gap-1 rounded-md bg-black/65 px-2 py-0.5 text-xs font-medium text-white shadow-sm backdrop-blur-sm"
@@ -129,6 +322,7 @@ function toggleSelected(value: boolean | 'indeterminate') {
v-if="showStorage && image.storage" v-if="showStorage && image.storage"
class="absolute bottom-2 max-w-[calc(50%-0.5rem)]" class="absolute bottom-2 max-w-[calc(50%-0.5rem)]"
:class="image.owner ? 'right-2' : 'left-2'" :class="image.owner ? 'right-2' : 'left-2'"
@click.stop
> >
<span <span
class="inline-flex max-w-full items-center gap-1 rounded-md bg-black/65 px-2 py-0.5 text-xs font-medium text-white shadow-sm backdrop-blur-sm" class="inline-flex max-w-full items-center gap-1 rounded-md bg-black/65 px-2 py-0.5 text-xs font-medium text-white shadow-sm backdrop-blur-sm"
@@ -190,7 +384,7 @@ function toggleSelected(value: boolean | 'indeterminate') {
:variant="copyFormat === item.value ? 'solid' : 'ghost'" :variant="copyFormat === item.value ? 'solid' : 'ghost'"
:color="copyFormat === item.value ? 'primary' : 'neutral'" :color="copyFormat === item.value ? 'primary' : 'neutral'"
:label="item.label" :label="item.label"
@click="() => { copyFormat = item.value }" @click="selectCopyFormat(item.value)"
/> />
</div> </div>
+24 -7
View File
@@ -4,24 +4,39 @@ import type { ImageItem } from '~/types/image'
const props = withDefaults(defineProps<{ const props = withDefaults(defineProps<{
items: ImageItem[] items: ImageItem[]
selectedKeys: Set<string> selectedKeys: Set<string>
deletingKeys: Set<string>
selectable?: boolean selectable?: boolean
showKey?: boolean showKey?: boolean
showStorage?: boolean showStorage?: boolean
emptyText?: string emptyText?: string
dense?: boolean dense?: boolean
compact?: boolean compact?: boolean
/** 图库:5 列 × 2 行,缩略图略扁 */
gallery?: boolean
allowDelete?: boolean
}>(), { }>(), {
selectable: true, selectable: true,
showKey: true, showKey: true,
showStorage: false, showStorage: false,
dense: false, dense: false,
compact: false compact: false,
gallery: false,
allowDelete: true
})
const gridClass = computed(() => {
if (props.gallery) {
return 'grid items-start grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5'
}
if (props.dense) {
return 'grid items-start grid-cols-2 gap-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4'
}
return 'grid items-start grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4'
}) })
const emit = defineEmits<{ const emit = defineEmits<{
'update:selectedKeys': [value: Set<string>] 'update:selectedKeys': [value: Set<string>]
'delete': [key: string] 'preview': [image: ImageItem]
'delete': [image: ImageItem]
}>() }>()
const { t } = useI18n() const { t } = useI18n()
@@ -39,21 +54,23 @@ function updateSelection(key: string, selected: boolean) {
<template> <template>
<div <div
v-if="items.length" v-if="items.length"
class="grid items-start sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4" class="items-start"
:class="dense ? 'grid-cols-2 gap-3' : 'grid-cols-1 gap-4'" :class="gridClass"
> >
<ImageCard <ImageCard
v-for="image in items" v-for="image in items"
:key="image.key" :key="image.key"
:image="image" :image="image"
:selected="selectedKeys.has(image.key)" :selected="selectedKeys.has(image.key)"
:deleting="deletingKeys.has(image.key)"
:selectable="selectable" :selectable="selectable"
:show-key="showKey" :show-key="showKey"
:show-storage="showStorage" :show-storage="showStorage"
:compact="compact" :compact="compact"
:gallery="gallery"
:allow-delete="allowDelete"
@update:selected="updateSelection(image.key, $event)" @update:selected="updateSelection(image.key, $event)"
@delete="emit('delete', image.key)" @preview="emit('preview', image)"
@delete="emit('delete', image)"
/> />
</div> </div>
<div <div
+426
View File
@@ -0,0 +1,426 @@
<script setup lang="ts">
import type { ImageItem } from '~/types/image'
import type { CopyFormat } from '~/composables/useUploadPreferences'
import { formatImageDimensions } from '~/utils/image-display'
type EmbedFormat = CopyFormat | 'bbcode'
const props = withDefaults(defineProps<{
open: boolean
image: ImageItem | null
showStorage?: boolean
deleting?: boolean
allowDelete?: boolean
}>(), {
showStorage: false,
allowDelete: true
})
const emit = defineEmits<{
'update:open': [value: boolean]
'delete': []
}>()
const toast = useToast()
const { formatFileSize } = useFileSize()
const { t, locale } = useI18n()
const { copyFormat } = useUploadPreferences()
const naturalWidth = ref<number | null>(null)
const naturalHeight = ref<number | null>(null)
const dimensionsLoaded = ref(false)
const copyingUrl = ref(false)
const embedFormat = ref<EmbedFormat>('url')
const embedFormatItems = computed(() => [
{ label: t('copy.url'), value: 'url' as const },
{ label: t('copy.markdown'), value: 'markdown' as const },
{ label: t('copy.html'), value: 'html' as const },
{ label: t('copy.bbcode'), value: 'bbcode' as const }
])
const uploadedLabel = computed(() => {
if (!props.image) return '—'
try {
const date = new Date(props.image.uploadedAt)
const y = date.getFullYear()
const m = String(date.getMonth() + 1).padStart(2, '0')
const d = String(date.getDate()).padStart(2, '0')
const h = String(date.getHours()).padStart(2, '0')
const min = String(date.getMinutes()).padStart(2, '0')
const s = String(date.getSeconds()).padStart(2, '0')
return `${y}/${m}/${d} ${h}:${min}:${s}`
} catch {
return props.image.uploadedAt
}
})
const sizeLabel = computed(() => {
if (!props.image) return '—'
return t('image.previewSizeDetail', {
size: formatFileSize(props.image.size),
bytes: props.image.size.toLocaleString(locale.value)
})
})
const bbcodeValue = computed(() => {
if (!props.image) return ''
return `[img]${props.image.url}[/img]`
})
const previewValue = computed(() => {
if (!props.image) return ''
switch (embedFormat.value) {
case 'markdown':
return props.image.markdown
case 'html':
return props.image.html
case 'bbcode':
return bbcodeValue.value
default:
return props.image.url
}
})
const copySuccessTitle = computed(() => {
switch (embedFormat.value) {
case 'markdown':
return t('copy.copiedMarkdown')
case 'html':
return t('copy.copiedHtml')
case 'bbcode':
return t('copy.copiedBbcode')
default:
return t('copy.copiedUrl')
}
})
const storageIcon = computed(() =>
props.image?.storage?.type === 'local' ? 'i-lucide-hard-drive' : 'i-lucide-cloud'
)
const uploadSourceTag = computed(() => {
switch (props.image?.uploadSource) {
case 'web':
return t('stats.tagWeb')
case 'api':
return t('stats.tagApi')
default:
return null
}
})
const dimensionsLabel = computed(() => {
const formatted = formatImageDimensions(naturalWidth.value, naturalHeight.value)
if (formatted) return formatted
return dimensionsLoaded.value ? '—' : t('image.previewDimensionsUnknown')
})
watch(() => props.image?.key, () => {
naturalWidth.value = null
naturalHeight.value = null
dimensionsLoaded.value = false
})
watch(() => props.open, (open) => {
if (open) {
embedFormat.value = copyFormat.value
}
})
function selectEmbedFormat(value: EmbedFormat) {
embedFormat.value = value
if (value !== 'bbcode') {
copyFormat.value = value
}
}
async function copyText(text: string, successTitle: string) {
if (!text) return false
try {
if (typeof navigator !== 'undefined' && navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(text)
toast.add({ title: successTitle, color: 'success' })
return true
}
} catch {
// fallback below
}
const textarea = document.createElement('textarea')
textarea.value = text
textarea.setAttribute('readonly', '')
textarea.style.position = 'fixed'
textarea.style.left = '-9999px'
document.body.appendChild(textarea)
textarea.select()
let ok = false
try {
ok = document.execCommand('copy')
} catch {
ok = false
} finally {
document.body.removeChild(textarea)
}
if (ok) {
toast.add({ title: successTitle, color: 'success' })
} else {
toast.add({ title: t('copy.failedManual'), color: 'error' })
}
return ok
}
async function copyLink() {
if (!props.image?.url || copyingUrl.value) return
copyingUrl.value = true
try {
await copyText(props.image.url, t('copy.copiedUrl'))
} finally {
copyingUrl.value = false
}
}
function openInNewTab() {
if (!props.image?.url) return
window.open(props.image.url, '_blank', 'noopener,noreferrer')
}
function downloadOriginal() {
if (!props.image?.url) return
const link = document.createElement('a')
link.href = props.image.url
link.download = props.image.originalName || 'image'
link.rel = 'noopener'
link.click()
}
function requestDelete() {
emit('delete')
}
function onImageLoad(event: Event) {
const img = event.target as HTMLImageElement
naturalWidth.value = img.naturalWidth
naturalHeight.value = img.naturalHeight
dimensionsLoaded.value = true
}
</script>
<template>
<UModal
:open="open && !!image"
:title="t('image.previewTitle')"
:description="t('image.previewSubtitle')"
:ui="{ content: 'max-w-5xl' }"
@update:open="emit('update:open', $event)"
>
<template
v-if="image"
#body
>
<div class="grid gap-6 lg:grid-cols-[minmax(0,1fr)_minmax(0,1.15fr)] lg:items-start">
<div class="preview-checkerboard flex items-center justify-center overflow-hidden rounded-xl border border-default p-4">
<img
:src="image.url"
:alt="image.originalName"
class="max-h-[min(60vh,24rem)] w-full object-contain"
@load="onImageLoad"
>
</div>
<div class="flex min-w-0 flex-col gap-5">
<dl class="divide-y divide-default text-sm">
<div class="flex gap-4 py-2.5 first:pt-0">
<dt class="w-20 shrink-0 text-muted">
{{ t('image.previewOriginalName') }}
</dt>
<dd class="min-w-0 flex-1 break-all font-medium">
{{ image.originalName }}
</dd>
</div>
<div class="flex gap-4 py-2.5">
<dt class="w-20 shrink-0 text-muted">
{{ t('image.previewUploadedAt') }}
</dt>
<dd class="min-w-0 flex-1">
{{ uploadedLabel }}
</dd>
</div>
<div class="flex gap-4 py-2.5">
<dt class="w-20 shrink-0 text-muted">
{{ t('image.previewSize') }}
</dt>
<dd class="min-w-0 flex-1">
{{ sizeLabel }}
</dd>
</div>
<div class="flex gap-4 py-2.5">
<dt class="w-20 shrink-0 text-muted">
{{ t('image.previewDimensions') }}
</dt>
<dd class="min-w-0 flex-1">
{{ dimensionsLabel }}
</dd>
</div>
<div class="flex gap-4 py-2.5">
<dt class="w-20 shrink-0 text-muted">
{{ t('image.previewContentType') }}
</dt>
<dd class="min-w-0 flex-1">
{{ image.contentType }}
</dd>
</div>
<div
v-if="showStorage && image.storage"
class="flex gap-4 py-2.5"
>
<dt class="w-20 shrink-0 text-muted">
{{ t('image.previewStorage') }}
</dt>
<dd class="min-w-0 flex-1">
<span class="inline-flex items-center gap-1.5">
<UIcon
:name="storageIcon"
class="size-3.5 shrink-0 text-muted"
/>
{{ image.storage.name }}
</span>
</dd>
</div>
<div
v-if="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 class="inline-flex rounded-md bg-primary/10 px-2 py-0.5 text-xs font-medium text-primary">
{{ uploadSourceTag }}
</span>
</dd>
</div>
<div
v-if="image.owner"
class="flex gap-4 py-2.5"
>
<dt class="w-20 shrink-0 text-muted">
{{ t('image.previewOwner') }}
</dt>
<dd class="min-w-0 flex-1">
{{ image.owner.username }}
</dd>
</div>
<div class="flex gap-4 py-2.5 last:pb-0">
<dt class="w-20 shrink-0 text-muted">
{{ t('image.previewKey') }}
</dt>
<dd class="flex min-w-0 flex-1 items-start gap-2">
<span class="min-w-0 flex-1 break-all font-mono text-xs text-dimmed">
{{ image.key }}
</span>
<CopyButton
icon="i-lucide-copy"
icon-only
variant="ghost"
color="neutral"
class="shrink-0"
:label="t('common.copy')"
:value="image.key"
:success-title="t('copy.copied')"
/>
</dd>
</div>
</dl>
<div class="border-t border-default pt-4">
<p class="mb-2.5 text-sm font-medium">
{{ t('image.previewLinks') }}
</p>
<div class="flex gap-1 rounded-lg border border-default bg-default p-0.5">
<UButton
v-for="item in embedFormatItems"
:key="item.value"
size="xs"
class="flex-1 justify-center"
:variant="embedFormat === item.value ? 'solid' : 'ghost'"
:color="embedFormat === item.value ? 'primary' : 'neutral'"
:label="item.label"
@click="selectEmbedFormat(item.value)"
/>
</div>
<div class="mt-2 flex items-center gap-2">
<UInput
:model-value="previewValue"
readonly
size="sm"
class="min-w-0 flex-1 font-mono text-xs"
/>
<CopyButton
:label="t('common.copy')"
:value="previewValue"
:success-title="copySuccessTitle"
/>
</div>
</div>
</div>
</div>
</template>
<template
v-if="image"
#footer
>
<div class="flex w-full flex-wrap justify-end gap-1.5">
<UButton
:label="t('image.previewOpen')"
icon="i-lucide-external-link"
color="neutral"
variant="outline"
size="sm"
@click="openInNewTab"
/>
<UButton
:label="t('image.previewDownload')"
icon="i-lucide-download"
color="neutral"
variant="outline"
size="sm"
@click="downloadOriginal"
/>
<UButton
:label="t('image.previewCopyLink')"
icon="i-lucide-link"
color="primary"
size="sm"
:loading="copyingUrl"
@click="copyLink"
/>
<UButton
v-if="allowDelete"
:label="t('common.delete')"
icon="i-lucide-trash-2"
color="error"
variant="outline"
size="sm"
:loading="deleting"
@click="requestDelete"
/>
</div>
</template>
</UModal>
</template>
<style scoped>
.preview-checkerboard {
background-color: var(--ui-bg-muted);
background-image:
linear-gradient(45deg, color-mix(in oklab, var(--ui-border) 28%, transparent) 25%, transparent 25%),
linear-gradient(-45deg, color-mix(in oklab, var(--ui-border) 28%, transparent) 25%, transparent 25%),
linear-gradient(45deg, transparent 75%, color-mix(in oklab, var(--ui-border) 28%, transparent) 75%),
linear-gradient(-45deg, transparent 75%, color-mix(in oklab, var(--ui-border) 28%, transparent) 75%);
background-size: 14px 14px;
background-position: 0 0, 0 7px, 7px -7px, -7px 0;
}
</style>
+90
View File
@@ -0,0 +1,90 @@
<script setup lang="ts">
import type {
LoginVerificationMethod,
LoginVerificationPayload
} from '~/types/auth'
const props = defineProps<{
method: LoginVerificationMethod
turnstileSiteKey?: string
capApiEndpoint?: string
}>()
const emit = defineEmits<{
verified: [payload: LoginVerificationPayload]
cleared: []
}>()
const sliderRef = ref<{ reset: () => Promise<void> } | null>(null)
const turnstileRef = ref<{ reset: () => void } | null>(null)
const capRef = ref<{ reset: () => void } | null>(null)
function onSliderVerified(payload: { id: string, positionPercent: number }) {
emit('verified', {
method: 'slider',
captchaId: payload.id,
captchaPosition: payload.positionPercent
})
}
function onTurnstileVerified(payload: { turnstileToken: string }) {
emit('verified', {
method: 'turnstile',
turnstileToken: payload.turnstileToken
})
}
function onCapVerified(payload: { capToken: string }) {
emit('verified', {
method: 'cap',
capToken: payload.capToken
})
}
function onCleared() {
emit('cleared')
}
async function reset() {
emit('cleared')
if (props.method === 'slider') {
await sliderRef.value?.reset()
return
}
if (props.method === 'turnstile') {
turnstileRef.value?.reset()
return
}
capRef.value?.reset()
}
defineExpose({ reset })
</script>
<template>
<SliderCaptcha
v-if="method === 'slider'"
ref="sliderRef"
@verified="onSliderVerified"
/>
<TurnstileCaptcha
v-else-if="method === 'turnstile' && turnstileSiteKey"
ref="turnstileRef"
:site-key="turnstileSiteKey"
@verified="onTurnstileVerified"
@cleared="onCleared"
/>
<CapCaptcha
v-else-if="method === 'cap' && capApiEndpoint"
ref="capRef"
:api-endpoint="capApiEndpoint"
@verified="onCapVerified"
@cleared="onCleared"
/>
<p
v-else
class="rounded-lg border border-warning/25 bg-warning/5 px-3 py-2 text-xs text-warning"
>
{{ $t('auth.verificationMisconfigured') }}
</p>
</template>
-18
View File
@@ -136,24 +136,6 @@ function toggleOpen() {
{{ t('nav.account') }} {{ t('nav.account') }}
</h3> </h3>
<div class="space-y-2"> <div class="space-y-2">
<NuxtLink
to="/logs"
class="flex items-center justify-between rounded-lg border border-default px-3.5 py-2.5 text-sm text-highlighted transition-colors hover:bg-elevated"
@click="open = false"
>
<span class="flex items-center gap-2.5">
<UIcon
name="i-lucide-scroll-text"
class="size-4 opacity-70"
/>
{{ t('nav.logs') }}
</span>
<UIcon
name="i-lucide-chevron-right"
class="size-4 text-muted"
/>
</NuxtLink>
<NuxtLink <NuxtLink
to="/account/password" to="/account/password"
class="flex items-center justify-between rounded-lg border border-default px-3.5 py-2.5 text-sm text-highlighted transition-colors hover:bg-elevated" class="flex items-center justify-between rounded-lg border border-default px-3.5 py-2.5 text-sm text-highlighted transition-colors hover:bg-elevated"
+203 -35
View File
@@ -1,82 +1,250 @@
<script setup lang="ts"> <script setup lang="ts">
const props = defineProps<{ type PageItem = number | 'ellipsis'
const props = withDefaults(defineProps<{
page: number page: number
totalPages: number totalPages: number
total: number total: number
unit?: string unit?: string
loading?: boolean loading?: boolean
}>() pageSize?: number
pageSizeOptions?: number[]
}>(), {
loading: false,
pageSizeOptions: () => [10, 20, 30, 50]
})
const emit = defineEmits<{ const emit = defineEmits<{
'update:page': [page: number] 'update:page': [page: number]
'update:pageSize': [pageSize: number]
}>() }>()
const { t } = useI18n() const { t } = useI18n()
const unitLabel = computed(() => props.unit ?? t('common.itemsUnit')) const unitLabel = computed(() => props.unit ?? t('common.itemsUnit'))
const jumpPageInput = ref('')
const showMobilePageNumbers = computed(() => props.totalPages <= 5)
const visiblePages = computed<PageItem[]>(() => {
const total = Math.max(1, props.totalPages)
const current = Math.min(Math.max(1, props.page), total)
if (total <= 7) {
return Array.from({ length: total }, (_, index) => index + 1)
}
const pages = new Set<number>([1, total])
for (let page = current - 1; page <= current + 1; page++) {
if (page >= 1 && page <= total) pages.add(page)
}
const sorted = [...pages].sort((a, b) => a - b)
const items: PageItem[] = []
for (let index = 0; index < sorted.length; index++) {
const page = sorted[index]!
const prev = sorted[index - 1]
if (prev !== undefined && page - prev > 1) {
items.push('ellipsis')
}
items.push(page)
}
return items
})
const pageSizeItems = computed(() =>
props.pageSizeOptions.map(size => ({
label: t('pagination.perPage', { size }),
value: size
}))
)
watch(
() => props.page,
(page) => {
jumpPageInput.value = String(page)
},
{ immediate: true }
)
function goTo(target: number) { function goTo(target: number) {
if (target < 1 || target > props.totalPages || target === props.page || props.loading) { if (target < 1 || target > props.totalPages || props.loading) return
return if (target === props.page) return
}
emit('update:page', target) emit('update:page', target)
} }
function onPageSizeChange(value: unknown) {
const size = Number(value)
if (!Number.isFinite(size) || size <= 0 || size === props.pageSize) return
emit('update:pageSize', size)
}
function jumpToPage() {
const target = Number.parseInt(jumpPageInput.value, 10)
if (!Number.isFinite(target)) return
goTo(target)
}
function pageButtonClass(target: number) {
return target === props.page
? 'border-primary bg-primary/10 text-primary font-medium'
: 'border-default bg-elevated text-default hover:bg-muted'
}
const navButtonClass = 'inline-flex items-center justify-center rounded-md border border-default bg-elevated text-default transition-colors hover:bg-muted disabled:pointer-events-none disabled:opacity-40'
</script> </script>
<template> <template>
<div <div
v-if="total > 0" v-if="total > 0"
class="flex flex-col items-center gap-2 border-t border-default pt-4 sm:flex-row sm:justify-between" class="border-t border-default pt-4"
> >
<p <div class="flex flex-col gap-3">
v-if="totalPages > 1" <div class="flex items-center justify-between gap-2 sm:hidden">
class="text-sm text-muted" <p class="text-sm text-muted">
> {{ t('pagination.totalCount', { total, unit: unitLabel }) }}
{{ t('pagination.pageOf', { page, totalPages, total, unit: unitLabel }) }}
</p> </p>
<p <USelect
v-else v-if="pageSize !== undefined"
class="text-sm text-muted" :model-value="pageSize"
> :items="pageSizeItems"
{{ t('pagination.totalOnly', { total, unit: unitLabel }) }} value-key="value"
size="sm"
class="w-[6.5rem] shrink-0"
:aria-label="t('pagination.pageSize')"
:disabled="loading"
@update:model-value="onPageSizeChange"
/>
</div>
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<p class="hidden text-sm text-muted sm:block">
{{ t('pagination.totalCount', { total, unit: unitLabel }) }}
</p> </p>
<div <div class="flex flex-col gap-3 sm:flex-row sm:flex-wrap sm:items-center sm:justify-end sm:gap-3">
v-if="totalPages > 1" <USelect
class="flex items-center gap-1" v-if="pageSize !== undefined"
> :model-value="pageSize"
<UButton :items="pageSizeItems"
icon="i-lucide-chevrons-left" value-key="value"
variant="outline"
size="sm" size="sm"
class="hidden w-[7.5rem] shrink-0 sm:block"
:aria-label="t('pagination.pageSize')"
:disabled="loading"
@update:model-value="onPageSizeChange"
/>
<div class="flex items-center justify-center gap-1 sm:justify-end">
<button
type="button"
:class="[navButtonClass, 'hidden size-8 sm:inline-flex']"
:aria-label="t('pagination.first')" :aria-label="t('pagination.first')"
:disabled="page <= 1 || loading" :disabled="page <= 1 || loading"
@click="goTo(1)" @click="goTo(1)"
>
<UIcon
name="i-lucide-chevrons-left"
class="size-4"
/> />
<UButton </button>
icon="i-lucide-chevron-left" <button
variant="outline" type="button"
size="sm" :class="[navButtonClass, 'size-9 sm:size-8']"
:aria-label="t('pagination.prev')" :aria-label="t('pagination.prev')"
:disabled="page <= 1 || loading" :disabled="page <= 1 || loading"
@click="goTo(page - 1)" @click="goTo(page - 1)"
>
<UIcon
name="i-lucide-chevron-left"
class="size-4"
/> />
<UButton </button>
icon="i-lucide-chevron-right"
variant="outline" <p
size="sm" v-if="!showMobilePageNumbers"
class="min-w-[5.5rem] px-1 text-center text-sm text-muted sm:hidden"
>
{{ t('pagination.pageStatus', { page, totalPages }) }}
</p>
<template
v-for="(target, index) in visiblePages"
:key="`${target}-${index}`"
>
<span
v-if="target === 'ellipsis'"
class="inline-flex size-9 items-center justify-center text-sm text-muted sm:size-8"
:class="showMobilePageNumbers ? '' : 'hidden sm:inline-flex'"
aria-hidden="true"
>
…
</span>
<button
v-else
type="button"
class="inline-flex items-center justify-center rounded-md border text-sm transition-colors disabled:pointer-events-none disabled:opacity-40"
:class="[
pageButtonClass(target),
showMobilePageNumbers ? 'size-9 sm:size-8' : 'hidden size-8 sm:inline-flex'
]"
:aria-label="t('pagination.pageNumber', { page: target })"
:aria-current="target === page ? 'page' : undefined"
:disabled="loading"
@click="goTo(target)"
>
{{ target }}
</button>
</template>
<button
type="button"
:class="[navButtonClass, 'size-9 sm:size-8']"
:aria-label="t('pagination.next')" :aria-label="t('pagination.next')"
:disabled="page >= totalPages || loading" :disabled="page >= totalPages || loading"
@click="goTo(page + 1)" @click="goTo(page + 1)"
>
<UIcon
name="i-lucide-chevron-right"
class="size-4"
/> />
<UButton </button>
icon="i-lucide-chevrons-right" <button
variant="outline" type="button"
size="sm" :class="[navButtonClass, 'hidden size-8 sm:inline-flex']"
:aria-label="t('pagination.last')" :aria-label="t('pagination.last')"
:disabled="page >= totalPages || loading" :disabled="page >= totalPages || loading"
@click="goTo(totalPages)" @click="goTo(totalPages)"
>
<UIcon
name="i-lucide-chevrons-right"
class="size-4"
/> />
</button>
</div>
<form
class="hidden items-center gap-1.5 text-sm text-muted sm:flex"
@submit.prevent="jumpToPage"
>
<span>{{ t('pagination.goToPrefix') }}</span>
<UInput
v-model="jumpPageInput"
type="number"
min="1"
:max="totalPages"
size="sm"
class="w-14"
:aria-label="t('pagination.pagePlaceholder')"
:disabled="loading"
@blur="jumpToPage"
/>
<span v-if="t('pagination.goToSuffix')">{{ t('pagination.goToSuffix') }}</span>
</form>
</div>
</div>
</div> </div>
</div> </div>
</template> </template>
+261
View File
@@ -0,0 +1,261 @@
<script setup lang="ts">
const emit = defineEmits<{
verified: [payload: { id: string, positionPercent: number }]
}>()
const { t } = useI18n()
const THUMB_SIZE = 40
const TRACK_INSET = 4
const trackRef = ref<HTMLElement | null>(null)
const challengeId = ref('')
const trackSvg = ref('')
const sliderPercent = ref(0)
const verified = ref(false)
const loading = ref(false)
const dragging = ref(false)
const shake = ref(false)
const confirming = ref(false)
const trackWidth = ref(0)
function updateTrackWidth() {
trackWidth.value = trackRef.value?.clientWidth ?? 0
}
function percentToPx(percent: number) {
const max = Math.max(trackWidth.value - THUMB_SIZE - TRACK_INSET * 2, 0)
return TRACK_INSET + (percent / 100) * max
}
const thumbLeft = computed(() => percentToPx(sliderPercent.value))
const isActive = computed(() => dragging.value || sliderPercent.value > 0.5)
const fillWidth = computed(() => `${Math.max(thumbLeft.value + THUMB_SIZE - TRACK_INSET, 0)}px`)
const trackSvgUrl = computed(() =>
trackSvg.value
? `data:image/svg+xml;charset=utf-8,${encodeURIComponent(trackSvg.value)}`
: ''
)
const hintOpacity = computed(() => {
if (verified.value) return 0
if (!isActive.value) return 1
return Math.max(0, 1 - sliderPercent.value / 40)
})
const successLabelLeft = computed(() => `${thumbLeft.value + THUMB_SIZE + 8}px`)
async function loadChallenge() {
loading.value = true
verified.value = false
dragging.value = false
shake.value = false
sliderPercent.value = 0
trackSvg.value = ''
try {
const data = await $fetch<{ id: string, svg: string }>('/api/auth/captcha')
challengeId.value = data.id
trackSvg.value = data.svg
} finally {
loading.value = false
await nextTick()
updateTrackWidth()
}
}
function setSliderFromClientX(clientX: number) {
if (!trackRef.value || verified.value || loading.value || confirming.value) return
const rect = trackRef.value.getBoundingClientRect()
const max = Math.max(rect.width - THUMB_SIZE - TRACK_INSET * 2, 1)
const x = Math.min(
Math.max(clientX - rect.left - THUMB_SIZE / 2 - TRACK_INSET, 0),
max
)
sliderPercent.value = (x / max) * 100
}
async function tryConfirm() {
if (verified.value || !challengeId.value || confirming.value) return
confirming.value = true
try {
const position = Math.round(sliderPercent.value)
await $fetch('/api/auth/captcha/confirm', {
method: 'POST',
body: {
id: challengeId.value,
captchaPosition: position
}
})
verified.value = true
emit('verified', {
id: challengeId.value,
positionPercent: position
})
} catch {
shake.value = true
sliderPercent.value = 0
window.setTimeout(() => {
shake.value = false
}, 450)
} finally {
confirming.value = false
}
}
function onThumbPointerDown(event: PointerEvent) {
if (verified.value || loading.value || confirming.value) return
dragging.value = true
updateTrackWidth()
;(event.currentTarget as HTMLElement).setPointerCapture(event.pointerId)
}
function onThumbPointerMove(event: PointerEvent) {
if (!dragging.value) return
setSliderFromClientX(event.clientX)
}
function onThumbPointerUp() {
if (!dragging.value) return
dragging.value = false
if (!verified.value) {
void tryConfirm()
}
}
onMounted(() => {
void loadChallenge()
})
let resizeObserver: ResizeObserver | undefined
watch(trackRef, (el, _, onCleanup) => {
resizeObserver?.disconnect()
resizeObserver = undefined
if (!el) return
updateTrackWidth()
if (typeof ResizeObserver === 'undefined') return
resizeObserver = new ResizeObserver(updateTrackWidth)
resizeObserver.observe(el)
onCleanup(() => {
resizeObserver?.disconnect()
resizeObserver = undefined
})
})
defineExpose({ reset: loadChallenge })
</script>
<template>
<div
ref="trackRef"
class="captcha-track relative h-12 select-none overflow-hidden rounded-lg border transition-[background-color,border-color] duration-300"
:class="[
verified
? 'border-primary/35 bg-primary/10'
: 'border-default bg-muted/20',
loading ? 'opacity-70' : '',
shake ? 'captcha-shake' : ''
]"
>
<div
v-if="loading"
class="absolute inset-0 flex items-center justify-center"
>
<UIcon
name="i-lucide-loader-2"
class="size-5 animate-spin text-muted"
/>
</div>
<template v-else>
<img
v-if="trackSvgUrl"
:src="trackSvgUrl"
alt=""
aria-hidden="true"
class="pointer-events-none absolute inset-0 size-full object-fill transition-opacity duration-300"
:class="verified ? 'opacity-0' : 'opacity-100'"
draggable="false"
>
<div
v-if="isActive && !verified"
class="pointer-events-none absolute inset-y-1 left-1 rounded-md bg-primary/20 transition-[width] duration-75"
:style="{ width: fillWidth }"
/>
<p
v-if="!verified"
class="pointer-events-none absolute inset-0 flex items-center justify-center pl-12 text-sm text-muted transition-opacity duration-150"
:style="{ opacity: hintOpacity }"
>
{{ t('auth.captchaHint') }}
</p>
<p
v-if="verified"
class="pointer-events-none absolute inset-y-0 flex items-center pr-3 text-sm font-medium text-primary"
:style="{ left: successLabelLeft }"
>
{{ t('auth.captchaSuccess') }}
</p>
<div
class="absolute top-1/2 -translate-y-1/2 touch-none rounded-md border transition-[left,background-color,border-color,color,box-shadow,transform] duration-150"
:class="[
verified
? 'z-20 border-primary bg-primary text-white shadow-sm shadow-primary/25'
: isActive
? 'z-10 border-primary/45 bg-elevated text-primary shadow-md'
: 'z-10 border-default/90 bg-elevated text-muted shadow-sm',
dragging ? 'scale-[1.02] cursor-grabbing' : verified ? 'cursor-default' : 'cursor-grab'
]"
:style="{
left: `${thumbLeft}px`,
width: `${THUMB_SIZE}px`,
height: `${THUMB_SIZE}px`
}"
@pointerdown="onThumbPointerDown"
@pointermove="onThumbPointerMove"
@pointerup="onThumbPointerUp"
@pointercancel="onThumbPointerUp"
>
<div class="flex size-full items-center justify-center">
<UIcon
v-if="confirming"
name="i-lucide-loader-2"
class="size-4 animate-spin"
:class="verified ? 'text-white' : isActive ? 'text-primary' : 'text-muted'"
/>
<UIcon
v-else
:name="verified ? 'i-lucide-check' : 'i-lucide-chevrons-right'"
class="size-4"
/>
</div>
</div>
</template>
</div>
</template>
<style scoped>
.captcha-shake {
animation: captcha-shake 0.4s ease;
}
@keyframes captcha-shake {
0%,
100% {
transform: translateX(0);
}
25%,
75% {
transform: translateX(-4px);
}
50% {
transform: translateX(4px);
}
}
</style>
-111
View File
@@ -1,111 +0,0 @@
<script setup lang="ts">
export interface DonutSegment {
label: string
value: number
color: string
}
const props = defineProps<{
segments: DonutSegment[]
size?: number
}>()
const total = computed(() =>
props.segments.reduce((sum, item) => sum + item.value, 0)
)
const gradient = computed(() => {
if (!total.value) {
return 'conic-gradient(var(--ui-bg-muted) 0% 100%)'
}
let offset = 0
const stops = props.segments
.filter(item => item.value > 0)
.map((item) => {
const start = offset
const percent = (item.value / total.value) * 100
offset += percent
return `${item.color} ${start}% ${offset}%`
})
if (!stops.length) {
return 'conic-gradient(var(--ui-bg-muted) 0% 100%)'
}
return `conic-gradient(${stops.join(', ')})`
})
function percent(value: number) {
if (!total.value) return 0
return Math.round((value / total.value) * 100)
}
function formatCount(value: number) {
if (value >= 100000000) {
return `${(value / 100000000).toFixed(value >= 1000000000 ? 0 : 1)}亿`
}
if (value >= 10000) {
return `${(value / 10000).toFixed(value >= 100000 ? 0 : 1)}万`
}
return String(value)
}
const centerText = computed(() => formatCount(total.value))
const centerTextClass = computed(() => {
const raw = String(total.value)
if (raw.length >= 7) return 'text-[9px] font-semibold leading-none'
if (raw.length >= 5) return 'text-xs font-semibold leading-none'
if (raw.length >= 4) return 'text-sm font-semibold leading-none'
return 'text-lg font-semibold leading-none'
})
</script>
<template>
<div class="flex items-center gap-4">
<div
class="relative shrink-0 rounded-full"
:style="{
width: `${size ?? 88}px`,
height: `${size ?? 88}px`,
background: gradient
}"
>
<div
class="absolute inset-[20%] flex flex-col items-center justify-center rounded-full bg-elevated px-1 text-center"
>
<p
class="max-w-full truncate tabular-nums"
:class="centerTextClass"
:title="String(total)"
>
{{ centerText }}
</p>
<p class="mt-0.5 text-[10px] text-muted">
次
</p>
</div>
</div>
<ul class="min-w-0 flex-1 space-y-1.5">
<li
v-for="item in segments"
:key="item.label"
class="flex items-center gap-2 text-xs"
>
<span
class="size-2 shrink-0 rounded-full"
:style="{ background: item.color }"
/>
<span class="min-w-0 flex-1 truncate">{{ item.label }}</span>
<span
class="shrink-0 tabular-nums text-muted"
:title="`${item.value}`"
>
{{ formatCount(item.value) }} ({{ percent(item.value) }}%)
</span>
</li>
</ul>
</div>
</template>
+120
View File
@@ -0,0 +1,120 @@
<script setup lang="ts">
const props = defineProps<{
siteKey: string
}>()
const emit = defineEmits<{
verified: [payload: { turnstileToken: string }]
cleared: []
}>()
interface TurnstileRenderOptions {
'sitekey': string
'action'?: string
'callback'?: (token: string) => void
'expired-callback'?: () => void
'error-callback'?: () => void
}
interface TurnstileApi {
render: (container: HTMLElement, options: TurnstileRenderOptions) => string
reset: (widgetId?: string) => void
remove: (widgetId: string) => void
}
declare global {
interface Window {
turnstile?: TurnstileApi
onTurnstileLoad?: () => void
}
}
const containerRef = ref<HTMLElement | null>(null)
const widgetId = ref<string | null>(null)
const scriptLoaded = ref(false)
function onVerified(token: string) {
if (token) {
emit('verified', { turnstileToken: token })
}
}
function onCleared() {
emit('cleared')
}
function renderWidget() {
if (!containerRef.value || !window.turnstile || !props.siteKey) return
if (widgetId.value) {
window.turnstile.remove(widgetId.value)
widgetId.value = null
}
widgetId.value = window.turnstile.render(containerRef.value, {
'sitekey': props.siteKey,
'action': 'login',
'callback': onVerified,
'expired-callback': onCleared,
'error-callback': onCleared
})
}
function tryRenderWidget() {
if (scriptLoaded.value) {
renderWidget()
}
}
function loadScript() {
if (window.turnstile) {
scriptLoaded.value = true
tryRenderWidget()
return
}
window.onTurnstileLoad = () => {
scriptLoaded.value = true
tryRenderWidget()
}
const existing = document.querySelector('script[data-turnstile]')
if (existing) {
if (window.turnstile) {
scriptLoaded.value = true
tryRenderWidget()
}
return
}
const script = document.createElement('script')
script.src = 'https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit&onload=onTurnstileLoad'
script.async = true
script.defer = true
script.dataset.turnstile = 'true'
document.head.appendChild(script)
}
function reset() {
if (window.turnstile && widgetId.value) {
window.turnstile.reset(widgetId.value)
}
onCleared()
}
onMounted(async () => {
loadScript()
await nextTick()
tryRenderWidget()
})
watch([containerRef, () => props.siteKey, scriptLoaded], () => {
tryRenderWidget()
}, { flush: 'post' })
defineExpose({ reset })
</script>
<template>
<ClientOnly>
<div
ref="containerRef"
class="flex min-h-16 items-center justify-center"
/>
</ClientOnly>
</template>
+6 -36
View File
@@ -1,13 +1,4 @@
<script setup lang="ts"> <script setup lang="ts">
withDefaults(
defineProps<{
embedded?: boolean
}>(),
{
embedded: false
}
)
const emit = defineEmits<{ const emit = defineEmits<{
back: [] back: []
}>() }>()
@@ -57,17 +48,8 @@ onMounted(() => {
</script> </script>
<template> <template>
<div <div class="upload-card-surface upload-card-surface--panel flex max-sm:min-h-0 flex-col overflow-hidden max-sm:overflow-visible sm:h-full">
class="flex flex-col overflow-hidden max-sm:overflow-visible" <div class="flex items-start justify-between gap-3 border-b border-default px-5 py-4 sm:px-6">
:class="
embedded
? ''
: 'upload-card-surface upload-card-surface--panel max-sm:min-h-0 sm:h-full'
"
>
<div
class="flex items-start justify-between gap-3 border-b border-default px-5 py-4 sm:px-6"
>
<div class="flex min-w-0 items-start gap-2"> <div class="flex min-w-0 items-start gap-2">
<UIcon <UIcon
name="i-lucide-settings-2" name="i-lucide-settings-2"
@@ -83,7 +65,6 @@ onMounted(() => {
</div> </div>
</div> </div>
<UButton <UButton
v-if="!embedded"
:label="t('common.back')" :label="t('common.back')"
icon="i-lucide-arrow-left" icon="i-lucide-arrow-left"
variant="ghost" variant="ghost"
@@ -95,10 +76,7 @@ onMounted(() => {
/> />
</div> </div>
<div <div class="flex flex-1 flex-col justify-center p-5 sm:p-6">
class="p-5 sm:p-6"
:class="embedded ? '' : 'flex flex-1 flex-col justify-center'"
>
<div class="grid gap-5 sm:grid-cols-3 sm:gap-6"> <div class="grid gap-5 sm:grid-cols-3 sm:gap-6">
<div class="space-y-4"> <div class="space-y-4">
<h3 class="text-sm font-semibold"> <h3 class="text-sm font-semibold">
@@ -128,9 +106,7 @@ onMounted(() => {
class="mt-0.5" class="mt-0.5"
/> />
<span class="min-w-0 flex-1"> <span class="min-w-0 flex-1">
<span <span class="flex flex-wrap items-center gap-x-1.5 gap-y-1 text-sm">
class="flex flex-wrap items-center gap-x-1.5 gap-y-1 text-sm"
>
<span>{{ t('preferences.autoDeletePrefix') }}</span> <span>{{ t('preferences.autoDeletePrefix') }}</span>
<UInput <UInput
v-model.number="userAutoDeleteDaysDraft" v-model.number="userAutoDeleteDaysDraft"
@@ -139,11 +115,7 @@ onMounted(() => {
max="3650" max="3650"
size="xs" size="xs"
class="w-14 tabular-nums" class="w-14 tabular-nums"
:class=" :class="userAutoDeleteEnabled ? '' : 'pointer-events-none opacity-40'"
userAutoDeleteEnabled
? ''
: 'pointer-events-none opacity-40'
"
/> />
<span>{{ t('preferences.autoDeleteSuffix') }}</span> <span>{{ t('preferences.autoDeleteSuffix') }}</span>
</span> </span>
@@ -171,9 +143,7 @@ onMounted(() => {
<p class="text-xs leading-relaxed text-muted"> <p class="text-xs leading-relaxed text-muted">
{{ t('preferences.webpQualityHint') }} {{ t('preferences.webpQualityHint') }}
</p> </p>
<div <div class="flex items-center justify-between gap-2 text-sm text-muted">
class="flex items-center justify-between gap-2 text-sm text-muted"
>
<span>{{ t('preferences.current', { n: clientWebpQuality }) }}</span> <span>{{ t('preferences.current', { n: clientWebpQuality }) }}</span>
<span>{{ clientQualityLevel }}</span> <span>{{ clientQualityLevel }}</span>
</div> </div>
+379
View File
@@ -0,0 +1,379 @@
<script setup lang="ts">
import type { ApiDocEndpoint, ApiDocParam } from '~/composables/useApiDocs'
const props = defineProps<{
endpoint: ApiDocEndpoint
token: string
}>()
const { t } = useI18n()
const toast = useToast()
const authToken = ref('')
const paramValues = ref<Record<string, string>>({})
const fileValues = ref<Record<string, File | null>>({})
const fileInputRefs = ref<Record<string, HTMLInputElement | null>>({})
const sending = ref(false)
const responseStatus = ref<number | null>(null)
const responseBody = ref('')
const responseError = ref(false)
function isDebugParam(param: ApiDocParam) {
if (param.name === 'token' && props.endpoint.requiresAuth) {
return false
}
return true
}
const debugQueryParams = computed(() =>
props.endpoint.params.filter(p => p.location === 'query' && isDebugParam(p))
)
const debugBodyParams = computed(() =>
props.endpoint.params.filter(p =>
(p.location === 'form-data' || p.location === 'body') && isDebugParam(p)
)
)
const showAuthHeader = computed(() => props.endpoint.requiresAuth)
watch(
() => props.endpoint.id,
() => {
resetForm()
},
{ immediate: true }
)
watch(
() => props.token,
(value) => {
if (value) {
authToken.value = value
if (!props.endpoint.requiresAuth && 'token' in paramValues.value) {
paramValues.value.token = value
}
}
},
{ immediate: true }
)
function resetForm() {
const values: Record<string, string> = {}
const files: Record<string, File | null> = {}
for (const param of props.endpoint.params) {
if (!isDebugParam(param)) continue
if (param.type === 'file') {
files[param.name] = null
} else if (param.name === 'keys') {
values[param.name] = '["images/2026/08/example.webp"]'
} else if (param.name === 'token' && props.token) {
values[param.name] = props.token
} else {
values[param.name] = ''
}
}
paramValues.value = values
fileValues.value = files
responseStatus.value = null
responseBody.value = ''
responseError.value = false
}
function fileLabel(name: string) {
return fileValues.value[name]?.name ?? t('api.debug.noFileSelected')
}
function triggerFilePick(name: string) {
fileInputRefs.value[name]?.click()
}
function onFileChange(name: string, event: Event) {
const input = event.target as HTMLInputElement
fileValues.value[name] = input.files?.[0] ?? null
}
function isParamFilled(param: ApiDocParam) {
if (param.type === 'file') {
return fileValues.value[param.name] != null
}
const value = paramValues.value[param.name]?.trim() ?? ''
if (!value) return false
if (param.location === 'body' && param.name === 'keys') {
try {
const parsed = JSON.parse(value) as { keys?: unknown }
return Array.isArray(parsed.keys) && parsed.keys.length > 0
} catch {
return false
}
}
return true
}
const canSendRequest = computed(() => {
if (sending.value) return false
if (showAuthHeader.value && !authToken.value.trim()) return false
const params = [...debugQueryParams.value, ...debugBodyParams.value]
return params.every(param => !param.required || isParamFilled(param))
})
function buildUrl() {
const url = new URL(props.endpoint.path, window.location.origin)
for (const param of debugQueryParams.value) {
const value = paramValues.value[param.name]?.trim()
if (value) {
url.searchParams.set(param.name, value)
}
}
return url.toString()
}
async function sendRequest() {
if (!canSendRequest.value) return
sending.value = true
responseStatus.value = null
responseBody.value = ''
responseError.value = false
try {
const headers: Record<string, string> = {}
if (showAuthHeader.value && authToken.value.trim()) {
headers['Auth-Token'] = authToken.value.trim()
}
let body: BodyInit | undefined
if (debugBodyParams.value.some(p => p.location === 'form-data')) {
const formData = new FormData()
for (const param of debugBodyParams.value) {
if (param.location !== 'form-data') continue
if (param.type === 'file') {
const file = fileValues.value[param.name]
if (file) formData.append(param.name, file)
} else {
const value = paramValues.value[param.name]?.trim()
if (value) formData.append(param.name, value)
}
}
body = formData
} else if (debugBodyParams.value.some(p => p.location === 'body')) {
headers['Content-Type'] = 'application/json'
try {
body = JSON.stringify(JSON.parse(paramValues.value.keys || '{}'))
} catch {
toast.add({ title: t('api.debug.invalidJson'), color: 'error' })
sending.value = false
return
}
}
const response = await fetch(buildUrl(), {
method: props.endpoint.method,
headers,
body,
credentials: 'include'
})
responseStatus.value = response.status
const text = await response.text()
try {
responseBody.value = JSON.stringify(JSON.parse(text), null, 2)
} catch {
responseBody.value = text
}
responseError.value = !response.ok
} catch {
responseError.value = true
responseBody.value = t('api.debug.networkError')
toast.add({ title: t('api.debug.sendFailed'), color: 'error' })
} finally {
sending.value = false
}
}
const responseScrollable = computed(() => responseBody.value.split('\n').length > 14)
</script>
<template>
<aside class="flex w-full shrink-0 flex-col border-t border-default bg-default lg:w-72 lg:border-t-0 lg:border-l xl:w-80">
<div class="flex items-start gap-2 border-b border-default px-5 py-4 sm:px-6">
<UIcon
name="i-lucide-flask-conical"
class="mt-0.5 size-5 shrink-0 text-primary"
/>
<div class="min-w-0">
<h3 class="text-base font-semibold">
{{ t('api.debug.title') }}
</h3>
<div class="mt-1.5 flex flex-wrap items-center gap-2">
<ApiMethodBadge
:method="endpoint.method"
size="sm"
/>
<code class="truncate font-mono text-xs text-muted">
{{ endpoint.path }}
</code>
</div>
</div>
</div>
<div class="flex flex-1 flex-col">
<div class="space-y-3 p-5 sm:p-6">
<section
v-if="showAuthHeader"
class="rounded-xl border border-default p-3 sm:p-4"
>
<h4 class="mb-3 text-xs font-semibold text-highlighted">
{{ t('api.debug.headers') }}
</h4>
<div class="space-y-1.5">
<label class="text-xs text-muted">
Auth-Token
<span class="text-error">*</span>
</label>
<UInput
v-model="authToken"
size="sm"
class="font-mono text-xs"
:placeholder="t('api.debug.tokenPlaceholder')"
/>
</div>
</section>
<section
v-if="debugQueryParams.length"
class="rounded-xl border border-default p-3 sm:p-4"
>
<h4 class="mb-3 text-xs font-semibold text-highlighted">
{{ t('api.debug.query') }}
</h4>
<div class="space-y-3">
<div
v-for="param in debugQueryParams"
:key="param.name"
class="space-y-1.5"
>
<label class="text-xs text-muted">
{{ param.name }}
<span
v-if="param.required"
class="text-error"
>*</span>
</label>
<UInput
v-model="paramValues[param.name]"
size="sm"
class="font-mono text-xs"
/>
</div>
</div>
</section>
<section
v-if="debugBodyParams.length"
class="rounded-xl border border-default p-3 sm:p-4"
>
<h4 class="mb-3 text-xs font-semibold text-highlighted">
{{ t('api.debug.body') }}
</h4>
<div class="space-y-3">
<div
v-for="param in debugBodyParams"
:key="param.name"
class="space-y-1.5"
>
<label class="text-xs text-muted">
{{ param.name }}
<span
v-if="param.required"
class="text-error"
>*</span>
</label>
<div
v-if="param.type === 'file'"
class="flex items-center gap-2"
>
<input
:ref="(el) => { fileInputRefs[param.name] = el as HTMLInputElement }"
type="file"
accept="image/*"
class="hidden"
@change="onFileChange(param.name, $event)"
>
<UButton
size="xs"
variant="outline"
color="neutral"
:label="t('api.debug.selectFile')"
@click="triggerFilePick(param.name)"
/>
<span class="min-w-0 truncate text-xs text-muted">
{{ fileLabel(param.name) }}
</span>
</div>
<UTextarea
v-else-if="param.location === 'body'"
v-model="paramValues[param.name]"
:rows="3"
size="sm"
class="font-mono text-xs"
/>
<UInput
v-else
v-model="paramValues[param.name]"
size="sm"
class="font-mono text-xs"
/>
</div>
</div>
</section>
<UButton
icon="i-lucide-send"
:label="t('api.debug.send')"
color="primary"
block
:loading="sending"
:disabled="!canSendRequest"
@click="sendRequest"
/>
<section
v-if="responseStatus !== null || responseBody"
class="overflow-hidden rounded-xl border border-default"
>
<div class="flex items-center justify-between border-b border-default px-3 py-2">
<span class="text-xs font-semibold text-highlighted">
{{ t('api.debug.response') }}
</span>
<UBadge
v-if="responseStatus !== null"
:color="responseError ? 'error' : 'success'"
variant="subtle"
size="xs"
>
{{ responseStatus }}
</UBadge>
</div>
<pre
class="api-debug-response bg-neutral-950 p-3 text-xs leading-relaxed text-neutral-100"
:class="responseScrollable ? 'max-h-64 overflow-y-auto' : ''"
><code class="font-mono whitespace-pre">{{ responseBody }}</code></pre>
</section>
</div>
<div class="mt-auto border-t border-default p-5 sm:p-6">
<h4 class="mb-2 text-xs font-semibold">
{{ t('api.debug.notesTitle') }}
</h4>
<ul class="space-y-1 text-xs leading-relaxed text-muted">
<li>{{ t('api.debug.noteSuccess') }}</li>
<li>{{ t('api.debug.noteError') }}</li>
<li>{{ t('api.debug.noteLimit') }}</li>
</ul>
</div>
</div>
</aside>
</template>
+121
View File
@@ -0,0 +1,121 @@
<script setup lang="ts">
import type { ApiDocEndpoint } from '~/composables/useApiDocs'
defineProps<{
endpoint: ApiDocEndpoint
}>()
const { t } = useI18n()
</script>
<template>
<section class="p-5 sm:p-6">
<h2 class="text-base font-semibold">
{{ t(endpoint.titleKey) }}
</h2>
<div class="mt-2 flex flex-wrap items-center gap-2">
<ApiMethodBadge
:method="endpoint.method"
size="sm"
/>
<code class="rounded-md border border-primary/20 bg-primary/10 px-2 py-0.5 font-mono text-sm font-medium text-primary">
{{ endpoint.path }}
</code>
</div>
<p class="mt-3 text-sm leading-relaxed text-muted">
{{ t(endpoint.descKey) }}
</p>
<div
v-if="endpoint.params.length"
class="mt-5 overflow-hidden rounded-xl border border-default"
>
<div class="border-b border-default bg-muted/20 px-4 py-2.5">
<h3 class="text-sm font-medium">
{{ t('api.requestParams') }}
</h3>
</div>
<div class="overflow-x-auto">
<table class="w-full min-w-[32rem] text-left text-sm">
<thead>
<tr class="border-b border-default text-xs text-muted">
<th class="px-4 py-2.5 font-medium">
{{ t('api.paramName') }}
</th>
<th class="px-4 py-2.5 font-medium">
{{ t('api.paramType') }}
</th>
<th class="px-4 py-2.5 font-medium">
{{ t('api.paramLocation') }}
</th>
<th class="px-4 py-2.5 font-medium">
{{ t('api.paramRequired') }}
</th>
<th class="px-4 py-2.5 font-medium">
{{ t('api.paramDescription') }}
</th>
</tr>
</thead>
<tbody>
<tr
v-for="param in endpoint.params"
:key="param.name"
class="border-b border-default last:border-b-0"
>
<td class="px-4 py-2.5 font-mono text-xs">
{{ param.name }}
<span
v-if="param.required"
class="text-error"
>*</span>
</td>
<td class="px-4 py-2.5 text-muted">
{{ param.type }}
</td>
<td class="px-4 py-2.5 text-muted">
{{ param.location }}
</td>
<td class="px-4 py-2.5">
<UBadge
:color="param.required ? 'primary' : 'neutral'"
variant="subtle"
size="xs"
>
{{ param.required ? t('api.required') : t('api.optional') }}
</UBadge>
</td>
<td class="px-4 py-2.5 text-muted">
{{ t(param.descriptionKey) }}
</td>
</tr>
</tbody>
</table>
</div>
</div>
<div class="mt-5 space-y-4">
<div>
<p class="mb-2 text-xs font-medium text-muted">
{{ t('api.requestExample') }}
</p>
<ApiCurlBlock
:code="endpoint.curl"
lang="cURL"
/>
</div>
<div>
<p class="mb-2 text-xs font-medium text-muted">
{{ t('api.responseExample') }}
<span class="font-normal">200 OK</span>
</p>
<ApiCurlBlock
:code="endpoint.responseExample"
lang="JSON"
:scrollable="false"
/>
</div>
</div>
</section>
</template>
+34
View File
@@ -0,0 +1,34 @@
<script setup lang="ts">
import type { ApiDocMethod } from '~/composables/useApiDocs'
const props = defineProps<{
method: ApiDocMethod
size?: 'xs' | 'sm'
}>()
const colorClass = computed(() => {
switch (props.method) {
case 'GET':
return 'bg-sky-500/15 text-sky-700 dark:text-sky-300'
case 'POST':
return 'bg-primary/15 text-primary'
case 'DELETE':
return 'bg-red-500/15 text-red-700 dark:text-red-300'
default:
return 'bg-muted text-muted'
}
})
const sizeClass = computed(() =>
props.size === 'sm' ? 'px-2 py-0.5 text-xs' : 'px-1.5 py-0.5 text-[10px]'
)
</script>
<template>
<span
class="inline-flex shrink-0 rounded font-mono font-semibold leading-none"
:class="[colorClass, sizeClass]"
>
{{ method }}
</span>
</template>
+148
View File
@@ -0,0 +1,148 @@
<script setup lang="ts">
import type { ApiDocCategory, ApiDocEndpoint } from '~/composables/useApiDocs'
const props = defineProps<{
endpoints: ApiDocEndpoint[]
activeId: string
}>()
const emit = defineEmits<{
select: [id: string]
}>()
const { t, locale } = useI18n()
const search = ref('')
const docsUrl = computed(() =>
locale.value === 'en'
? 'https://o96u.github.io/PicHost/en/guide/api.html'
: 'https://o96u.github.io/PicHost/guide/api.html'
)
const categories: { key: ApiDocCategory, labelKey: string }[] = [
{ key: 'image', labelKey: 'api.categories.image' },
{ key: 'extension', labelKey: 'api.categories.extension' },
{ key: 'system', labelKey: 'api.categories.system' }
]
const filteredEndpoints = computed(() => {
const q = search.value.trim().toLowerCase()
if (!q) return props.endpoints
return props.endpoints.filter((endpoint) => {
const title = t(endpoint.titleKey).toLowerCase()
const path = endpoint.path.toLowerCase()
return title.includes(q) || path.includes(q) || endpoint.method.toLowerCase().includes(q)
})
})
function endpointsForCategory(category: ApiDocCategory) {
return filteredEndpoints.value.filter(endpoint => endpoint.category === category)
}
function onSelect(id: string) {
emit('select', id)
}
function onSearchKeydown(event: KeyboardEvent) {
if (event.key === 'Escape') {
search.value = ''
event.preventDefault()
}
}
onMounted(() => {
const onGlobalKeydown = (event: KeyboardEvent) => {
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'k') {
event.preventDefault()
document.getElementById('api-endpoint-search')?.focus()
}
}
window.addEventListener('keydown', onGlobalKeydown)
onUnmounted(() => window.removeEventListener('keydown', onGlobalKeydown))
})
</script>
<template>
<aside class="flex w-full shrink-0 flex-col border-b border-default bg-default lg:w-52 lg:border-b-0 lg:border-r lg:bg-neutral-50 dark:lg:bg-neutral-900/30 xl:w-56">
<div class="border-b border-default p-3 sm:p-4">
<UInput
id="api-endpoint-search"
v-model="search"
:placeholder="t('api.searchPlaceholder')"
icon="i-lucide-search"
size="sm"
@keydown="onSearchKeydown"
>
<template #trailing>
<UKbd
size="sm"
class="hidden sm:inline-flex"
>
⌘K
</UKbd>
</template>
</UInput>
</div>
<nav class="flex-1 overflow-y-auto p-2 sm:p-3">
<div
v-for="category in categories"
:key="category.key"
class="mb-4"
>
<p
v-if="endpointsForCategory(category.key).length"
class="mb-1.5 px-2 text-xs font-medium text-muted"
>
{{ t(category.labelKey) }}
</p>
<ul class="space-y-0.5">
<li
v-for="endpoint in endpointsForCategory(category.key)"
:key="endpoint.id"
>
<button
type="button"
class="flex w-full items-center gap-2 rounded-lg px-2 py-2 text-left text-sm transition-colors"
:class="activeId === endpoint.id
? 'bg-primary/10 font-medium text-primary'
: 'text-muted hover:bg-muted/40 hover:text-highlighted'"
@click="onSelect(endpoint.id)"
>
<ApiMethodBadge :method="endpoint.method" />
<span class="min-w-0 truncate">{{ t(endpoint.titleKey) }}</span>
</button>
</li>
</ul>
</div>
</nav>
<div class="hidden border-t border-default p-3 sm:p-4 lg:block">
<div class="rounded-xl border border-primary/15 bg-primary/5 p-3">
<div class="flex items-start gap-2.5">
<UIcon
name="i-lucide-book-open"
class="mt-0.5 size-4 shrink-0 text-primary"
/>
<div class="min-w-0 space-y-2">
<p class="text-xs font-medium text-highlighted">
{{ t('api.helpTitle') }}
</p>
<p class="text-xs leading-relaxed text-muted">
{{ t('api.helpSubtitle') }}
</p>
<UButton
:label="t('api.viewDocs')"
icon="i-lucide-external-link"
size="xs"
variant="outline"
color="neutral"
:to="docsUrl"
target="_blank"
rel="noopener noreferrer"
/>
</div>
</div>
</div>
</div>
</aside>
</template>
+146
View File
@@ -0,0 +1,146 @@
<script setup lang="ts">
const props = defineProps<{
token: string
loading: boolean
regenerating: boolean
canRegenerate: boolean
tokenSource?: 'env' | 'db' | 'none'
envTokenOverride?: boolean
isAdmin?: boolean
}>()
const emit = defineEmits<{
regenerate: []
}>()
const { t } = useI18n()
const toast = useToast()
const tokenVisible = ref(false)
const copying = ref(false)
function toggleTokenVisible() {
tokenVisible.value = !tokenVisible.value
}
const displayValue = computed(() => {
if (!props.token) return t('common.notConfigured')
if (tokenVisible.value) return props.token
return '•'.repeat(Math.min(props.token.length, 40))
})
function tokenSourceLabel(source: 'env' | 'db' | 'none' | undefined) {
switch (source) {
case 'env':
return t('api.tokenSourceEnv')
case 'db':
return t('api.tokenSourceDb')
default:
return t('api.tokenSourceNone')
}
}
async function copyToken() {
if (!props.token || copying.value) return
copying.value = true
try {
await navigator.clipboard.writeText(props.token)
toast.add({ title: t('copy.copied'), color: 'success' })
} catch {
toast.add({ title: t('copy.failed'), color: 'error' })
} finally {
copying.value = false
}
}
</script>
<template>
<section class="border-b border-default">
<div class="flex items-center gap-2 border-b border-default px-5 py-4 sm:px-6">
<UIcon
name="i-lucide-key-round"
class="size-5 shrink-0 text-primary"
/>
<h2 class="text-base font-semibold">
{{ t('api.tokenTitle') }}
</h2>
</div>
<div class="space-y-4 p-5 sm:p-6">
<UAlert
v-if="envTokenOverride"
color="warning"
variant="subtle"
icon="i-lucide-triangle-alert"
:title="t('api.envOverrideTitle')"
:description="t('api.envOverrideDesc')"
/>
<UAlert
v-else-if="!isAdmin"
color="info"
variant="subtle"
icon="i-lucide-info"
:title="t('api.personalTitle')"
:description="t('api.personalDesc')"
/>
<UAlert
v-else-if="!token && !loading"
color="info"
variant="subtle"
icon="i-lucide-info"
:title="t('api.notConfiguredTitle')"
:description="t('api.notConfiguredDesc')"
/>
<div class="flex flex-col gap-2 sm:flex-row sm:items-stretch">
<UInput
:model-value="displayValue"
readonly
class="min-w-0 flex-1 font-mono text-xs sm:text-sm"
:loading="loading"
/>
<div class="flex gap-2">
<UButton
size="sm"
variant="outline"
color="neutral"
:label="tokenVisible ? t('api.hideToken') : t('api.showToken')"
@click="toggleTokenVisible"
/>
<UButton
size="sm"
variant="outline"
color="neutral"
:label="t('common.copy')"
:loading="copying"
:disabled="!token"
@click="copyToken"
/>
<UButton
icon="i-lucide-refresh-cw"
:label="t('api.regenerate')"
color="primary"
variant="soft"
size="sm"
:loading="regenerating"
:disabled="!canRegenerate"
@click="emit('regenerate')"
/>
</div>
</div>
<p
v-if="tokenSource"
class="text-xs text-muted"
>
{{ tokenSourceLabel(tokenSource) }}
· {{ t('api.tokenHeader') }}
<code class="font-mono">Auth-Token: &lt;token&gt;</code>
<template v-if="token && !envTokenOverride">
· {{ t('api.tokenInvalidate') }}
</template>
</p>
</div>
</section>
</template>
+152
View File
@@ -0,0 +1,152 @@
<script setup lang="ts">
const props = withDefaults(defineProps<{
searchQuery: string
storageBackend: string
storageBackendItems: Array<{ label: string, value: string }>
uploadSource: string
uploadSourceItems: Array<{ label: string, value: string }>
viewMode: 'grid' | 'list'
selectedCount?: number
loading?: boolean
showBatchDelete?: boolean
}>(), {
selectedCount: 0,
loading: false,
showBatchDelete: true
})
const emit = defineEmits<{
'update:searchQuery': [value: string]
'update:storageBackend': [value: string]
'update:uploadSource': [value: string]
'update:viewMode': [mode: 'grid' | 'list']
'search': []
'reset': []
'batch-delete': []
}>()
const { t } = useI18n()
function setViewMode(mode: 'grid' | 'list') {
if (mode === props.viewMode || props.loading) return
emit('update:viewMode', mode)
}
function viewButtonClass(active: boolean) {
return active
? 'border-primary bg-primary/10 text-primary'
: 'border-transparent bg-elevated text-muted hover:bg-muted hover:text-default'
}
</script>
<template>
<form
class="flex flex-nowrap items-center gap-2 overflow-x-auto pb-1"
@submit.prevent="emit('search')"
>
<UInput
:model-value="searchQuery"
icon="i-lucide-search"
:placeholder="t('stats.searchPlaceholder')"
class="w-40 shrink-0 sm:w-48"
size="sm"
:disabled="loading"
@update:model-value="emit('update:searchQuery', $event)"
/>
<USelect
:model-value="storageBackend"
:items="storageBackendItems"
class="w-28 shrink-0"
size="sm"
:aria-label="t('stats.storage')"
:disabled="loading"
@update:model-value="emit('update:storageBackend', $event)"
/>
<USelect
:model-value="uploadSource"
:items="uploadSourceItems"
class="w-28 shrink-0"
size="sm"
:aria-label="t('stats.filterSource')"
:disabled="loading"
@update:model-value="emit('update:uploadSource', $event)"
/>
<UButton
type="submit"
icon="i-lucide-search"
size="sm"
color="primary"
class="shrink-0"
:aria-label="t('common.search')"
:loading="loading"
>
{{ t('common.search') }}
</UButton>
<UButton
type="button"
icon="i-lucide-rotate-ccw"
size="sm"
variant="outline"
color="neutral"
class="shrink-0"
:aria-label="t('stats.resetFilters')"
:disabled="loading"
@click="emit('reset')"
>
{{ t('stats.resetFilters') }}
</UButton>
<UButton
v-if="showBatchDelete"
type="button"
icon="i-lucide-trash-2"
size="sm"
variant="outline"
color="error"
class="shrink-0"
:disabled="!selectedCount || loading"
@click="emit('batch-delete')"
>
{{ t('stats.batchDelete') }}
</UButton>
<div
class="inline-flex shrink-0 overflow-hidden rounded-md border border-default"
role="group"
:aria-label="t('stats.viewMode')"
>
<button
type="button"
class="inline-flex size-8 items-center justify-center border-r border-default transition-colors disabled:pointer-events-none disabled:opacity-40"
:class="viewButtonClass(viewMode === 'grid')"
:aria-label="t('stats.viewGrid')"
:aria-pressed="viewMode === 'grid'"
:disabled="loading"
@click="setViewMode('grid')"
>
<UIcon
name="i-lucide-layout-grid"
class="size-4"
/>
</button>
<button
type="button"
class="inline-flex size-8 items-center justify-center transition-colors disabled:pointer-events-none disabled:opacity-40"
:class="viewButtonClass(viewMode === 'list')"
:aria-label="t('stats.viewList')"
:aria-pressed="viewMode === 'list'"
:disabled="loading"
@click="setViewMode('list')"
>
<UIcon
name="i-lucide-list"
class="size-4"
/>
</button>
</div>
</form>
</template>
+224
View File
@@ -0,0 +1,224 @@
<script setup lang="ts">
import type { ImageItem } from '~/types/image'
const props = withDefaults(defineProps<{
items: ImageItem[]
selectedKeys: Set<string>
selectable?: boolean
showStorage?: boolean
emptyText?: string
showDelete?: boolean
}>(), {
selectable: false,
showStorage: false,
showDelete: true
})
const emit = defineEmits<{
'update:selectedKeys': [value: Set<string>]
'preview': [image: ImageItem]
'delete': [image: ImageItem]
}>()
const { t } = useI18n()
const { formatFileSize } = useFileSize()
const displayEmptyText = computed(() => props.emptyText ?? t('image.empty'))
const allSelected = computed(() =>
props.items.length > 0 && props.items.every(item => props.selectedKeys.has(item.key))
)
const someSelected = computed(() =>
props.items.some(item => props.selectedKeys.has(item.key))
)
const headerIndeterminate = computed(() => someSelected.value && !allSelected.value)
function updateSelection(key: string, selected: boolean) {
const next = new Set(props.selectedKeys)
if (selected) next.add(key)
else next.delete(key)
emit('update:selectedKeys', next)
}
function toggleAll(value: boolean | 'indeterminate') {
if (value === true) {
emit('update:selectedKeys', new Set(props.items.map(item => item.key)))
return
}
emit('update:selectedKeys', new Set())
}
function formatUploadedAt(value: string) {
try {
const date = new Date(value)
const y = date.getFullYear()
const m = String(date.getMonth() + 1).padStart(2, '0')
const d = String(date.getDate()).padStart(2, '0')
const h = String(date.getHours()).padStart(2, '0')
const min = String(date.getMinutes()).padStart(2, '0')
const s = String(date.getSeconds()).padStart(2, '0')
return `${y}/${m}/${d} ${h}:${min}:${s}`
} catch {
return value
}
}
function sourceLabel(image: ImageItem) {
switch (image.uploadSource) {
case 'web':
return t('stats.tagWeb')
case 'api':
return t('stats.tagApi')
default:
return null
}
}
function typeLabel(image: ImageItem) {
if (props.showStorage && image.storage) return image.storage.name
return '—'
}
</script>
<template>
<div
v-if="items.length"
class="overflow-hidden rounded-xl border border-default bg-default"
>
<div class="overflow-x-auto">
<table class="min-w-full text-sm">
<thead class="border-b border-default text-xs text-muted">
<tr>
<th
v-if="selectable"
class="w-10 px-4 py-3 text-left font-medium"
>
<UCheckbox
:model-value="allSelected ? true : headerIndeterminate ? 'indeterminate' : false"
:aria-label="t('stats.selectAll')"
@update:model-value="toggleAll"
/>
</th>
<th class="min-w-[16rem] px-4 py-3 text-left font-medium">
{{ t('stats.colFilename') }}
</th>
<th class="hidden w-28 px-4 py-3 text-left font-medium sm:table-cell">
{{ t('stats.colSource') }}
</th>
<th class="w-24 px-4 py-3 text-left font-medium">
{{ t('stats.colSize') }}
</th>
<th class="hidden w-32 px-4 py-3 text-left font-medium md:table-cell">
{{ t('stats.colType') }}
</th>
<th class="hidden w-44 px-4 py-3 text-left font-medium lg:table-cell">
{{ t('stats.colUploadedAt') }}
</th>
<th class="w-28 px-4 py-3 text-right font-medium">
{{ t('stats.colActions') }}
</th>
</tr>
</thead>
<tbody class="divide-y divide-default">
<tr
v-for="image in items"
:key="image.key"
class="transition-colors hover:bg-muted/20"
>
<td
v-if="selectable"
class="px-4 py-3"
>
<UCheckbox
:model-value="selectedKeys.has(image.key)"
@update:model-value="updateSelection(image.key, $event === true)"
/>
</td>
<td class="px-4 py-3">
<div class="flex min-w-0 items-center gap-3">
<button
type="button"
class="relative size-10 shrink-0 overflow-hidden rounded-md border border-default bg-muted"
:aria-label="t('image.openPreview')"
@click="emit('preview', image)"
>
<img
:src="image.url"
:alt="image.originalName"
loading="lazy"
class="size-full object-cover"
>
</button>
<p
class="min-w-0 flex-1 truncate font-medium"
:title="image.originalName"
>
{{ image.originalName }}
</p>
</div>
</td>
<td class="hidden px-4 py-3 sm:table-cell">
<span
v-if="sourceLabel(image)"
class="inline-flex rounded-md bg-primary/10 px-2 py-0.5 text-xs font-medium text-primary"
>
{{ sourceLabel(image) }}
</span>
<span
v-else
class="text-muted"
>—</span>
</td>
<td class="px-4 py-3 text-muted">
{{ formatFileSize(image.size) }}
</td>
<td class="hidden px-4 py-3 text-muted md:table-cell">
<span class="truncate">{{ typeLabel(image) }}</span>
</td>
<td class="hidden px-4 py-3 text-muted lg:table-cell">
{{ formatUploadedAt(image.uploadedAt) }}
</td>
<td class="px-4 py-3">
<div class="flex items-center justify-end gap-0.5 text-muted">
<CopyButton
icon="i-lucide-link"
icon-only
variant="ghost"
color="neutral"
:label="t('common.copy')"
:value="image.url"
:success-title="t('copy.copiedUrl')"
/>
<UButton
icon="i-lucide-eye"
variant="ghost"
color="neutral"
size="xs"
:aria-label="t('image.openPreview')"
@click="emit('preview', image)"
/>
<UButton
v-if="showDelete"
icon="i-lucide-trash-2"
variant="ghost"
color="neutral"
size="xs"
:aria-label="t('common.delete')"
@click="emit('delete', image)"
/>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<div
v-else
class="rounded-xl border border-dashed border-default py-16 text-center text-sm text-muted"
>
{{ displayEmptyText }}
</div>
</template>
@@ -0,0 +1,55 @@
<script setup lang="ts">
import type { ImageItem } from '~/types/image'
const props = defineProps<{
items: ImageItem[]
selectedKeys: Set<string>
}>()
const emit = defineEmits<{
'update:selectedKeys': [value: Set<string>]
'clear': []
}>()
const { t } = useI18n()
const allSelected = computed(() =>
props.items.length > 0 && props.items.every(item => props.selectedKeys.has(item.key))
)
const someSelected = computed(() =>
props.items.some(item => props.selectedKeys.has(item.key))
)
const indeterminate = computed(() => someSelected.value && !allSelected.value)
function toggleAll(value: boolean | 'indeterminate') {
if (value === true) {
emit('update:selectedKeys', new Set(props.items.map(item => item.key)))
return
}
emit('update:selectedKeys', new Set())
}
</script>
<template>
<div class="flex min-w-0 items-center gap-3">
<UCheckbox
:model-value="allSelected ? true : indeterminate ? 'indeterminate' : false"
:disabled="!items.length"
:aria-label="t('stats.selectAll')"
@update:model-value="toggleAll"
/>
<p class="text-sm text-muted">
{{ t('stats.selectedItems', { n: selectedKeys.size }) }}
</p>
<button
type="button"
class="text-sm text-primary hover:underline disabled:pointer-events-none disabled:opacity-40"
:disabled="selectedKeys.size === 0"
@click="emit('clear')"
>
{{ t('stats.clear') }}
</button>
</div>
</template>
@@ -0,0 +1,238 @@
<script setup lang="ts">
export interface GalleryStatsData {
uploadToday: number
uploadYesterday: number
uploadMonth: number
uploadLastMonth: number
uploadTotal: number
storedCount: number
uploadBytesTotal: number
bySource: {
web: number
api: number
}
storageUsage: {
usedBytes: number
totalBytes: number | null
percent: number | null
}
}
const props = defineProps<{
stats: GalleryStatsData | null
loading?: boolean
}>()
const { t } = useI18n()
const { formatFileSize } = useFileSize()
const SOURCE_COLORS = ['var(--ui-primary)', '#10b981'] as const
const sourceItems = computed(() => {
const bySource = props.stats?.bySource
if (!bySource) return []
return [
{ key: 'web', label: t('stats.sourceWeb'), count: bySource.web, color: SOURCE_COLORS[0] },
{ key: 'api', label: t('stats.sourceApi'), count: bySource.api, color: SOURCE_COLORS[1] }
]
})
const sourceTotal = computed(() =>
sourceItems.value.reduce((sum, item) => sum + item.count, 0)
)
function sourcePercent(count: number) {
if (!sourceTotal.value) return 0
return Math.round((count / sourceTotal.value) * 100)
}
function formatTrend(current: number, previous: number) {
if (previous === 0) {
if (current === 0) {
return { text: t('stats.trendFlat'), tone: 'muted' as const }
}
return { text: t('stats.trendUp', { p: 100 }), tone: 'up' as const }
}
const change = Math.round(((current - previous) / previous) * 100)
if (change > 0) {
return { text: t('stats.trendUp', { p: change }), tone: 'up' as const }
}
if (change < 0) {
return { text: t('stats.trendDown', { p: Math.abs(change) }), tone: 'down' as const }
}
return { text: t('stats.trendFlat'), tone: 'muted' as const }
}
const todayTrend = computed(() =>
formatTrend(props.stats?.uploadToday ?? 0, props.stats?.uploadYesterday ?? 0)
)
const monthTrend = computed(() =>
formatTrend(props.stats?.uploadMonth ?? 0, props.stats?.uploadLastMonth ?? 0)
)
const usageLabel = computed(() => {
const usage = props.stats?.storageUsage
if (!usage) return '—'
const used = formatFileSize(usage.usedBytes)
if (usage.totalBytes) {
return `${used} / ${formatFileSize(usage.totalBytes)}`
}
return used
})
const usagePercent = computed(() => {
const percent = props.stats?.storageUsage.percent
if (percent == null) return null
return Math.min(percent, 100)
})
const usageBarWidth = computed(() => {
const usage = props.stats?.storageUsage
if (!usage?.totalBytes || usagePercent.value == null) return 0
return Math.max(usagePercent.value, usage.usedBytes > 0 ? 4 : 0)
})
function displayValue(value: number | undefined) {
if (props.loading) return '—'
return value ?? '—'
}
</script>
<template>
<section class="grid grid-cols-2 gap-3 sm:grid-cols-3 xl:grid-cols-6">
<div class="rounded-xl border border-default bg-elevated p-4">
<p class="text-xs text-muted">
{{ t('stats.uploadToday') }}
</p>
<p class="mt-2 text-2xl font-semibold tabular-nums text-highlighted">
{{ displayValue(stats?.uploadToday) }}
</p>
<p
class="mt-2 text-xs"
:class="{
'text-primary': todayTrend.tone === 'up',
'text-muted': todayTrend.tone === 'muted',
'text-orange-600 dark:text-orange-400': todayTrend.tone === 'down'
}"
>
{{ t('stats.vsYesterday') }} {{ todayTrend.text }}
</p>
</div>
<div class="rounded-xl border border-default bg-elevated p-4">
<p class="text-xs text-muted">
{{ t('stats.uploadMonth') }}
</p>
<p class="mt-2 text-2xl font-semibold tabular-nums text-highlighted">
{{ displayValue(stats?.uploadMonth) }}
</p>
<p
class="mt-2 text-xs"
:class="{
'text-primary': monthTrend.tone === 'up',
'text-muted': monthTrend.tone === 'muted',
'text-orange-600 dark:text-orange-400': monthTrend.tone === 'down'
}"
>
{{ t('stats.vsLastMonth') }} {{ monthTrend.text }}
</p>
</div>
<div class="rounded-xl border border-default bg-elevated p-4">
<p class="text-xs text-muted">
{{ t('stats.uploadTotal') }}
</p>
<p class="mt-2 text-2xl font-semibold tabular-nums text-highlighted">
{{ displayValue(stats?.uploadTotal) }}
</p>
<p class="mt-2 text-xs text-muted">
{{ t('stats.totalFiles') }}
</p>
</div>
<div class="rounded-xl border border-default bg-elevated p-4">
<p class="text-xs text-muted">
{{ t('stats.storedCount') }}
</p>
<p class="mt-2 text-2xl font-semibold tabular-nums text-highlighted">
{{ displayValue(stats?.storedCount) }}
</p>
<p class="mt-2 text-xs text-muted">
{{ stats ? t('stats.totalOccupied', { size: formatFileSize(stats.uploadBytesTotal) }) : '—' }}
</p>
</div>
<div class="rounded-xl border border-default bg-elevated p-4">
<p class="text-xs text-muted">
{{ t('stats.storageUsage') }}
</p>
<p class="mt-2 text-sm font-semibold tabular-nums text-highlighted sm:text-base">
{{ usageLabel }}
</p>
<div
v-if="stats?.storageUsage.totalBytes"
class="mt-3 space-y-1"
>
<div class="h-1.5 overflow-hidden rounded-full bg-muted/50">
<div
class="h-full rounded-full bg-primary transition-all"
:style="{ width: `${usageBarWidth}%` }"
/>
</div>
<p class="text-right text-xs tabular-nums text-muted">
{{ usagePercent }}%
</p>
</div>
<p
v-else
class="mt-2 text-xs text-muted"
>
{{ t('stats.noQuota') }}
</p>
</div>
<div class="col-span-2 rounded-xl border border-default bg-elevated p-4 sm:col-span-1">
<p class="text-xs text-muted">
{{ t('stats.sourceDistribution') }}
</p>
<div
v-if="!sourceTotal"
class="mt-4 text-xs text-muted"
>
{{ t('stats.noData') }}
</div>
<ul
v-else
class="mt-3 space-y-3"
>
<li
v-for="item in sourceItems"
:key="item.key"
>
<div class="flex items-center justify-between gap-2 text-xs">
<span class="flex min-w-0 items-center gap-1.5">
<span
class="size-2 shrink-0 rounded-full"
:style="{ background: item.color }"
/>
<span class="truncate">{{ item.label }}</span>
</span>
<span class="shrink-0 tabular-nums text-muted">
{{ item.count }} ({{ sourcePercent(item.count) }}%)
</span>
</div>
<div class="mt-1.5 h-1.5 overflow-hidden rounded-full bg-muted/40">
<div
class="h-full rounded-full transition-[width]"
:style="{
width: `${sourcePercent(item.count)}%`,
backgroundColor: item.color
}"
/>
</div>
</li>
</ul>
</div>
</section>
</template>
@@ -0,0 +1,5 @@
<template>
<div class="divide-y divide-default overflow-hidden rounded-lg border border-default">
<slot />
</div>
</template>
@@ -0,0 +1,554 @@
<script setup lang="ts">
import type { ActivityLogListResponse, ActivityLogStorage, LogAction, LogSource } from '~/types/logs'
const { isAuthenticated, handleAuthError, isAdmin } = useAuth()
const toast = useToast()
const { t, locale } = useI18n()
const { formatFileSize } = useFileSize()
const loading = ref(true)
const page = ref(1)
const actionFilter = ref<'all' | LogAction>('all')
const sourceFilter = ref<'all' | LogSource>('all')
const userFilter = ref<'all' | number>('all')
const searchQuery = ref('')
const activeSearch = ref('')
const listData = ref<ActivityLogListResponse | null>(null)
const imageLinkBase = ref('')
const hideFolderInUrl = ref(false)
const pageSize = 10
const summaryLabel = computed(() => {
const summary = listData.value?.summary
if (!summary) return ''
return t('logs.summaryAll', {
total: summary.total,
upload: summary.upload,
delete: summary.delete
})
})
const actionItems = computed(() => [
{ label: t('logs.filterActionAll'), value: 'all' as const },
{ label: t('logs.actionUpload'), value: 'upload' as const },
{ label: t('logs.actionDelete'), value: 'delete' as const }
])
const sourceItems = computed(() => [
{ label: t('logs.filterSourceAll'), value: 'all' as const },
{ label: t('logs.sourceWeb'), value: 'web' as const },
{ label: t('logs.sourceApi'), value: 'api' as const }
])
const userItems = computed(() => {
const items: Array<{ label: string, value: 'all' | number }> = [
{ label: t('logs.filterUserAll'), value: 'all' }
]
for (const user of listData.value?.users ?? []) {
items.push({ label: user.username, value: user.id })
}
return items
})
function formatTime(value: string) {
try {
return new Date(value).toLocaleString(locale.value, { hour12: false })
} catch {
return value
}
}
function formatTimeShort(value: string) {
try {
return new Date(value).toLocaleString(locale.value, {
month: 'numeric',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
hour12: false
})
} catch {
return value
}
}
function actionBadgeClass(action: LogAction) {
const base = 'inline-flex items-center gap-1 rounded-md px-2 py-0.5 text-xs font-medium whitespace-nowrap'
return action === 'upload'
? `${base} bg-success/10 text-success`
: `${base} bg-error/10 text-error`
}
function actionIcon(action: LogAction) {
return action === 'upload' ? 'i-lucide-upload' : 'i-lucide-trash-2'
}
function sourceLabel(source: LogSource) {
switch (source) {
case 'web':
return t('logs.sourceWeb')
case 'api':
return t('logs.sourceApi')
}
}
function storageIcon(storage: ActivityLogStorage | null) {
return storage?.type === 'local' ? 'i-lucide-hard-drive' : 'i-lucide-cloud'
}
function publicPathFromKey(key: string) {
if (hideFolderInUrl.value) {
const slash = key.lastIndexOf('/')
return slash >= 0 ? key.slice(slash + 1) : key
}
const prefix = 'images/'
return key.startsWith(prefix) ? key.slice(prefix.length) : key
}
function imageUrl(key: string) {
const base = imageLinkBase.value.replace(/\/$/, '')
const path = publicPathFromKey(key)
if (!base) return `/${path}`
return `${base}/${path}`
}
async function loadImageLinkBase() {
try {
if (isAdmin.value) {
const data = await $fetch<{ effectiveImageBaseUrl: string, hideFolderInUrl: boolean }>('/api/settings', {
credentials: 'include'
})
imageLinkBase.value = data.effectiveImageBaseUrl
hideFolderInUrl.value = data.hideFolderInUrl
return
}
const data = await $fetch<{ env: { imageBaseUrl: string, hideFolderInUrl: boolean } }>('/api/user/api-token', {
credentials: 'include'
})
imageLinkBase.value = data.env.imageBaseUrl
hideFolderInUrl.value = data.env.hideFolderInUrl
} catch {
if (import.meta.client) {
imageLinkBase.value = window.location.origin
}
}
}
async function loadLogs() {
loading.value = true
try {
const query: Record<string, string | number> = {
page: page.value,
limit: pageSize
}
if (actionFilter.value !== 'all') {
query.action = actionFilter.value
}
if (sourceFilter.value !== 'all') {
query.source = sourceFilter.value
}
if (isAdmin.value && userFilter.value !== 'all') {
query.userId = userFilter.value
}
if (activeSearch.value) {
query.q = activeSearch.value
}
listData.value = await $fetch<ActivityLogListResponse>('/api/logs', {
credentials: 'include',
query
})
page.value = listData.value.page
} catch (error: unknown) {
handleAuthError(error)
if (isAuthenticated.value) {
toast.add({ title: t('logs.loadFailed'), color: 'error' })
}
} finally {
loading.value = false
}
}
function applyFilters() {
page.value = 1
void loadLogs()
}
function submitSearch() {
activeSearch.value = searchQuery.value.trim()
applyFilters()
}
function clearSearch() {
searchQuery.value = ''
activeSearch.value = ''
applyFilters()
}
function handlePageChange(target: number) {
page.value = target
void loadLogs()
}
onMounted(async () => {
await loadImageLinkBase()
await loadLogs()
})
</script>
<template>
<div class="min-w-0">
<div class="flex flex-wrap items-start justify-between gap-3 border-b border-default px-5 py-4 sm:px-6">
<div>
<h2 class="text-sm font-semibold">
{{ t('logs.pageTitle') }}
</h2>
<p class="mt-0.5 text-xs text-muted">
{{ isAdmin ? t('logs.pageSubtitleAdmin') : t('logs.pageSubtitle') }}
</p>
</div>
<UButton
icon="i-lucide-refresh-cw"
variant="outline"
color="neutral"
size="sm"
:label="t('common.refresh')"
:loading="loading"
@click="loadLogs"
/>
</div>
<form
class="border-b border-default px-5 py-3 sm:px-6"
@submit.prevent="submitSearch"
>
<div class="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<p
v-if="summaryLabel"
class="text-xs text-muted"
>
{{ summaryLabel }}
</p>
<p
v-else
class="text-xs text-muted"
>
{{ t('stats.counting') }}
</p>
<div class="flex min-w-0 flex-wrap items-center gap-2 sm:justify-end">
<USelect
v-if="isAdmin"
v-model="userFilter"
:items="userItems"
value-key="value"
class="min-w-0 w-full sm:w-36"
size="sm"
@update:model-value="applyFilters"
/>
<USelect
v-model="actionFilter"
:items="actionItems"
value-key="value"
class="min-w-0 w-[calc(50%-0.25rem)] sm:w-32"
size="sm"
@update:model-value="applyFilters"
/>
<USelect
v-model="sourceFilter"
:items="sourceItems"
value-key="value"
class="min-w-0 w-[calc(50%-0.25rem)] sm:w-36"
size="sm"
@update:model-value="applyFilters"
/>
<div class="flex min-w-0 w-full items-center gap-2 sm:w-auto">
<UInput
v-model="searchQuery"
icon="i-lucide-search"
:placeholder="t('logs.searchPlaceholder')"
class="min-w-0 flex-1 sm:w-40 sm:flex-none"
size="sm"
:disabled="loading"
/>
<UButton
type="submit"
icon="i-lucide-search"
size="sm"
class="shrink-0"
:aria-label="t('common.search')"
:loading="loading"
>
<span class="hidden sm:inline">{{ t('common.search') }}</span>
</UButton>
<UButton
v-if="activeSearch"
type="button"
icon="i-lucide-x"
size="sm"
variant="ghost"
color="neutral"
class="shrink-0"
:aria-label="t('stats.clear')"
@click="clearSearch"
>
<span class="hidden sm:inline">{{ t('stats.clear') }}</span>
</UButton>
</div>
</div>
</div>
</form>
<div
v-if="loading"
class="p-4 sm:p-6"
>
<div class="space-y-3 sm:hidden">
<USkeleton
v-for="n in 6"
:key="`mobile-${n}`"
class="h-24 rounded-lg"
/>
</div>
<div class="hidden space-y-2 sm:block">
<USkeleton
v-for="n in 8"
:key="`desktop-${n}`"
class="h-12 rounded-lg"
/>
</div>
</div>
<template v-else-if="listData && listData.items.length">
<div class="min-w-0 sm:hidden">
<div class="divide-y divide-default">
<article
v-for="row in listData.items"
:key="row.id"
class="min-w-0 px-4 py-3.5"
>
<div class="flex items-start justify-between gap-3">
<time class="text-xs text-muted">
{{ formatTimeShort(row.createdAt) }}
</time>
<span class="shrink-0 text-xs tabular-nums text-muted">
{{ formatFileSize(row.size) }}
</span>
</div>
<div class="mt-2 flex min-w-0 max-w-full flex-wrap items-center gap-1.5">
<span :class="actionBadgeClass(row.action)">
<UIcon
:name="actionIcon(row.action)"
class="size-3 shrink-0"
/>
{{ row.action === 'upload' ? t('logs.actionUpload') : t('logs.actionDelete') }}
</span>
<span class="inline-flex items-center rounded-md bg-primary/10 px-2 py-0.5 text-xs font-medium text-primary whitespace-nowrap">
{{ sourceLabel(row.source) }}
</span>
<span
v-if="isAdmin"
class="text-xs text-muted"
>
{{ row.username ?? '—' }}
</span>
<UBadge
v-if="row.storage"
color="neutral"
variant="subtle"
size="xs"
class="max-w-full truncate"
>
<UIcon
:name="storageIcon(row.storage)"
class="mr-1 size-3"
/>
{{ row.storage.name }}
</UBadge>
</div>
<p
class="mt-2 truncate text-sm"
:title="row.originalName"
>
{{ row.originalName }}
</p>
<div class="mt-2 flex min-w-0 items-center gap-1">
<a
v-if="row.action === 'upload'"
:href="imageUrl(row.key)"
target="_blank"
rel="noopener noreferrer"
class="min-w-0 flex-1 truncate font-mono text-xs text-primary hover:underline"
:title="row.key"
>
{{ row.key }}
</a>
<span
v-else
class="min-w-0 flex-1 truncate font-mono text-xs text-dimmed"
:title="row.key"
>
{{ row.key }}
</span>
<CopyButton
icon="i-lucide-copy"
icon-only
class="shrink-0"
:label="t('common.copy')"
:value="row.key"
:success-title="t('copy.copiedUrl')"
/>
</div>
</article>
</div>
</div>
<div class="hidden min-w-0 sm:block">
<table class="w-full table-fixed text-left text-sm">
<thead class="border-b border-default bg-muted/30 text-xs text-muted">
<tr>
<th class="w-36 px-4 py-3 font-medium sm:px-6">
{{ t('logs.colTime') }}
</th>
<th
v-if="isAdmin"
class="w-20 px-4 py-3 font-medium"
>
{{ t('logs.colUser') }}
</th>
<th class="w-20 whitespace-nowrap px-4 py-3 font-medium">
{{ t('logs.colAction') }}
</th>
<th class="w-16 whitespace-nowrap px-4 py-3 font-medium">
{{ t('logs.colSource') }}
</th>
<th class="w-24 px-4 py-3 font-medium">
{{ t('logs.colStorage') }}
</th>
<th class="hidden px-4 py-3 font-medium md:table-cell">
{{ t('logs.colFile') }}
</th>
<th class="w-20 px-4 py-3 font-medium">
{{ t('logs.colSize') }}
</th>
<th class="hidden px-4 py-3 font-medium xl:table-cell">
{{ t('logs.colKey') }}
</th>
</tr>
</thead>
<tbody class="divide-y divide-default">
<tr
v-for="row in listData.items"
:key="row.id"
class="hover:bg-muted/20"
>
<td class="px-4 py-3 text-xs text-muted sm:px-6">
<span class="block truncate">{{ formatTime(row.createdAt) }}</span>
</td>
<td
v-if="isAdmin"
class="px-4 py-3 text-xs"
>
<span class="block truncate">{{ row.username ?? '—' }}</span>
</td>
<td class="whitespace-nowrap px-4 py-3">
<span :class="actionBadgeClass(row.action)">
<UIcon
:name="actionIcon(row.action)"
class="size-3 shrink-0"
/>
{{ row.action === 'upload' ? t('logs.actionUpload') : t('logs.actionDelete') }}
</span>
</td>
<td class="whitespace-nowrap px-4 py-3">
<span class="inline-flex items-center rounded-md bg-primary/10 px-2 py-0.5 text-xs font-medium text-primary">
{{ sourceLabel(row.source) }}
</span>
</td>
<td class="px-4 py-3 text-xs">
<span
v-if="row.storage"
class="flex min-w-0 items-center gap-1"
:title="row.storage.name"
>
<UIcon
:name="storageIcon(row.storage)"
class="size-3.5 shrink-0 opacity-70"
/>
<span class="truncate">{{ row.storage.name }}</span>
</span>
<span
v-else
class="text-muted"
>—</span>
</td>
<td
class="hidden max-w-0 truncate px-4 py-3 md:table-cell"
:title="row.originalName"
>
{{ row.originalName }}
</td>
<td class="whitespace-nowrap px-4 py-3 tabular-nums text-muted">
{{ formatFileSize(row.size) }}
</td>
<td class="hidden max-w-0 px-4 py-3 xl:table-cell">
<div class="flex min-w-0 items-center gap-1">
<a
v-if="row.action === 'upload'"
:href="imageUrl(row.key)"
target="_blank"
rel="noopener noreferrer"
class="min-w-0 truncate font-mono text-xs text-primary hover:underline"
:title="row.key"
>
{{ row.key }}
</a>
<span
v-else
class="min-w-0 truncate font-mono text-xs text-dimmed"
:title="row.key"
>
{{ row.key }}
</span>
<CopyButton
icon="i-lucide-copy"
icon-only
class="shrink-0"
:label="t('common.copy')"
:value="row.key"
:success-title="t('copy.copiedUrl')"
/>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</template>
<div
v-else
class="px-4 py-16 text-center text-sm text-muted sm:px-6"
>
{{ t('logs.empty') }}
</div>
<div
v-if="listData && listData.total > 0"
class="border-t border-default px-4 py-4 sm:px-6"
>
<PaginationBar
:page="listData.page"
:total-pages="listData.totalPages"
:total="listData.total"
:unit="t('logs.entriesUnit')"
:loading="loading"
@update:page="handlePageChange"
/>
</div>
</div>
</template>
@@ -0,0 +1,7 @@
<template>
<div class="overflow-hidden rounded-xl border border-default bg-default p-5 sm:p-6">
<div class="space-y-8">
<slot />
</div>
</div>
</template>
@@ -0,0 +1,37 @@
<script setup lang="ts">
defineProps<{
title: string
hint?: string
}>()
</script>
<template>
<section class="space-y-4">
<div class="flex items-start justify-between gap-4">
<div class="min-w-0">
<div class="flex items-center gap-2">
<span
class="h-4 w-1 shrink-0 rounded-full bg-primary"
aria-hidden="true"
/>
<h2 class="text-sm font-semibold text-highlighted">
{{ title }}
</h2>
</div>
<p
v-if="hint"
class="mt-1.5 pl-3 text-xs leading-relaxed text-muted"
>
{{ hint }}
</p>
</div>
<div
v-if="$slots.action"
class="shrink-0"
>
<slot name="action" />
</div>
</div>
<slot />
</section>
</template>
@@ -0,0 +1,33 @@
<script setup lang="ts">
defineProps<{
hasChanges: boolean
saving: boolean
}>()
const emit = defineEmits<{
save: []
}>()
const { t } = useI18n()
</script>
<template>
<div class="border-t border-default bg-default px-4 py-4 sm:px-6">
<div class="flex items-center justify-end gap-3">
<p
v-if="hasChanges"
class="mr-auto text-xs text-warning"
>
{{ t('settings.unsavedChanges') }}
</p>
<UButton
:label="t('settings.saveSettings')"
icon="i-lucide-save"
color="primary"
:loading="saving"
:disabled="!hasChanges"
@click="emit('save')"
/>
</div>
</div>
</template>
@@ -0,0 +1,47 @@
<script setup lang="ts">
import type { SettingsTab } from '~/types/settings'
defineProps<{
modelValue: SettingsTab
items: Array<{ id: SettingsTab, label: string, icon: string }>
title: string
subtitle: string
}>()
const emit = defineEmits<{
'update:modelValue': [SettingsTab]
}>()
</script>
<template>
<aside class="flex w-full shrink-0 flex-col border-b border-default bg-neutral-50 dark:bg-neutral-900/30 lg:w-56 lg:border-b-0 lg:border-r xl:w-60">
<div class="border-b border-default px-4 py-5 sm:px-5">
<h1 class="text-lg font-semibold tracking-tight text-highlighted">
{{ title }}
</h1>
<p class="mt-1 text-xs leading-relaxed text-muted">
{{ subtitle }}
</p>
</div>
<nav class="flex-1 space-y-0.5 p-3">
<button
v-for="item in items"
:key="item.id"
type="button"
class="flex w-full items-center gap-2.5 rounded-lg px-3 py-2.5 text-left text-sm transition-colors"
:class="modelValue === item.id
? 'bg-primary/10 font-medium text-primary'
: 'text-muted hover:bg-muted/50 hover:text-highlighted'"
@click="emit('update:modelValue', item.id)"
>
<UIcon
:name="item.icon"
class="size-4 shrink-0"
:class="modelValue === item.id ? 'text-primary' : 'opacity-70'"
/>
<span>{{ item.label }}</span>
</button>
</nav>
</aside>
</template>
@@ -0,0 +1,36 @@
<script setup lang="ts">
defineProps<{
appVersion: string
updateAvailable: boolean
latestVersion: string | null
releaseUrl: string | null
}>()
const { t } = useI18n()
</script>
<template>
<div class="pl-3">
<div class="flex flex-wrap items-center gap-2">
<span class="text-sm font-medium text-highlighted">
{{ t('settings.systemVersion') }}
</span>
<UBadge
color="neutral"
variant="subtle"
size="sm"
>
v{{ appVersion }}
</UBadge>
<a
v-if="updateAvailable && latestVersion"
:href="releaseUrl || 'https://github.com/O96u/PicHost/releases'"
target="_blank"
rel="noopener noreferrer"
class="text-xs text-warning hover:underline"
>
{{ t('settings.updateAvailable', { version: `v${latestVersion}` }) }}
</a>
</div>
</div>
</template>
@@ -0,0 +1,34 @@
<script setup lang="ts">
defineProps<{
title?: string
hint?: string
disabled?: boolean
}>()
const model = defineModel<boolean>({ required: true })
</script>
<template>
<div class="flex items-start justify-between gap-4 px-4 py-4 sm:px-5">
<div class="min-w-0 flex-1 pr-2">
<p class="text-sm font-medium text-highlighted">
<slot name="title">
{{ title }}
</slot>
</p>
<p
v-if="hint || $slots.hint"
class="mt-1 text-xs leading-relaxed text-muted"
>
<slot name="hint">
{{ hint }}
</slot>
</p>
</div>
<USwitch
v-model="model"
:disabled="disabled"
class="mt-0.5 shrink-0"
/>
</div>
</template>
@@ -0,0 +1,148 @@
<script setup lang="ts">
const {
compressEnabled,
autoCopyMarkdown,
copyFormat,
clientWebpQuality,
loadPreferences
} = useUploadPreferences()
const { t } = useI18n()
const {
userSettings,
enabled: userAutoDeleteEnabled,
daysDraft: userAutoDeleteDaysDraft,
load: loadUserSettings
} = useUserAutoDeleteSettings()
const copyFormatItems = computed(() => [
{ label: t('copy.url'), value: 'url' as const },
{ label: t('copy.markdown'), value: 'markdown' as const },
{ label: t('copy.html'), value: 'html' as const }
])
const clientQualityLevel = computed(() => {
if (clientWebpQuality.value >= 85) return t('preferences.qualityHigh')
if (clientWebpQuality.value >= 60) return t('preferences.qualityMedium')
return t('preferences.qualityLow')
})
function setCopyFormat(value: 'url' | 'markdown' | 'html') {
copyFormat.value = value
}
function onClientQualityInput(event: Event) {
const value = Number((event.target as HTMLInputElement).value)
clientWebpQuality.value = Number.isFinite(value) ? value : 80
}
onMounted(() => {
loadPreferences()
void loadUserSettings()
})
</script>
<template>
<SettingsSection :title="t('preferences.processing')">
<SettingsGroup>
<SettingsToggleRow
v-model="compressEnabled"
:title="t('preferences.clientCompress')"
:hint="t('preferences.clientCompressHint')"
/>
<div
v-if="userSettings"
class="flex items-start justify-between gap-4 px-4 py-4 sm:px-5"
>
<div class="min-w-0 flex-1 pr-2">
<p class="flex flex-wrap items-center gap-x-1.5 gap-y-1 text-sm font-medium text-highlighted">
<span>{{ t('preferences.autoDeletePrefix') }}</span>
<UInput
v-model.number="userAutoDeleteDaysDraft"
type="number"
min="1"
max="3650"
size="xs"
class="w-14 tabular-nums"
:class="userAutoDeleteEnabled ? '' : 'pointer-events-none opacity-40'"
/>
<span>{{ t('preferences.autoDeleteSuffix') }}</span>
</p>
<p class="mt-1 text-xs leading-relaxed text-muted">
{{ t('preferences.autoDeleteHint') }}
</p>
</div>
<USwitch
v-model="userAutoDeleteEnabled"
class="mt-0.5 shrink-0"
/>
</div>
<div
v-else
class="flex justify-center py-4"
>
<UIcon
name="i-lucide-loader-circle"
class="size-5 animate-spin text-muted"
/>
</div>
<div class="px-4 py-4 sm:px-5">
<div class="flex items-start justify-between gap-4">
<div class="min-w-0 flex-1">
<p class="text-sm font-medium text-highlighted">
{{ t('preferences.webpQuality') }}
</p>
<p class="mt-1 text-xs leading-relaxed text-muted">
{{ t('preferences.webpQualityHint') }}
</p>
</div>
<div class="shrink-0 text-right text-xs text-muted">
<p>{{ t('preferences.current', { n: clientWebpQuality }) }}</p>
<p class="mt-0.5">
{{ clientQualityLevel }}
</p>
</div>
</div>
<input
type="range"
min="1"
max="100"
:value="clientWebpQuality"
class="mt-3 h-1.5 w-full cursor-pointer appearance-none rounded-full bg-muted accent-primary"
@input="onClientQualityInput"
>
</div>
</SettingsGroup>
</SettingsSection>
<SettingsSection :title="t('settings.uploadConvenience')">
<SettingsGroup>
<SettingsToggleRow
v-model="autoCopyMarkdown"
:title="t('preferences.autoCopy')"
:hint="t('preferences.autoCopyHint')"
/>
<div class="flex flex-col gap-3 px-4 py-4 sm:flex-row sm:items-center sm:justify-between sm:px-5">
<p class="text-sm font-medium text-highlighted">
{{ t('settings.defaultCopyFormat') }}
</p>
<div
class="flex w-full max-w-xs gap-1 rounded-lg border border-default p-0.5 sm:w-auto"
:class="autoCopyMarkdown ? '' : 'pointer-events-none opacity-40'"
>
<UButton
v-for="item in copyFormatItems"
:key="item.value"
size="xs"
class="flex-1 justify-center sm:min-w-[4.5rem]"
:variant="copyFormat === item.value ? 'solid' : 'ghost'"
:color="copyFormat === item.value ? 'primary' : 'neutral'"
:label="item.label"
@click="setCopyFormat(item.value)"
/>
</div>
</div>
</SettingsGroup>
</SettingsSection>
</template>
+114 -155
View File
@@ -1,16 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import type { StorageBackendItem } from '~/types/storage' import type { StorageBackendItem } from '~/types/storage'
type CardAction = {
key: string
icon: string
label: string
shortLabel: string
danger?: boolean
disabled?: boolean
onClick: () => void
}
const props = defineProps<{ const props = defineProps<{
backend: StorageBackendItem backend: StorageBackendItem
envOverride?: boolean envOverride?: boolean
@@ -35,181 +25,150 @@ const typeLabel = computed(() =>
props.backend.type === 'local' ? t('storage.backendLocal') : t('storage.backendCloud') props.backend.type === 'local' ? t('storage.backendLocal') : t('storage.backendCloud')
) )
const servingLabel = computed(() =>
props.backend.servingMode === 'public'
? t('storage.servingPublic')
: t('storage.servingProxy')
)
const bucketLine = computed(() => {
if (props.backend.type !== 's3') return null
return props.backend.config.bucket || t('storage.notConfigured')
})
const actionsDisabled = computed(() => props.envOverride || props.busy) const actionsDisabled = computed(() => props.envOverride || props.busy)
const actions = computed<CardAction[]>(() => { const isLocal = computed(() => props.backend.type === 'local')
const isLocal = props.backend.type === 'local'
const items: CardAction[] = []
if (!props.backend.isDefault && props.backend.enabled) { function canSetDefault() {
items.push({ return !props.backend.isDefault && props.backend.enabled
key: 'default', }
icon: 'i-lucide-star',
label: t('storage.setDefault'),
shortLabel: isLocal ? t('storage.setDefault') : t('storage.actionSetDefault'),
onClick: () => emit('setDefault')
})
}
if (props.backend.type === 's3') { function canEdit() {
items.push({ return props.backend.type === 's3'
key: 'edit', }
icon: 'i-lucide-pencil',
label: t('common.edit'),
shortLabel: t('common.edit'),
onClick: () => emit('edit')
})
}
items.push({ function canToggle() {
key: 'test', return props.backend.type !== 'local'
icon: 'i-lucide-plug-zap', }
label: t('storage.testConnection'),
shortLabel: isLocal ? t('storage.testConnection') : t('storage.actionTest'),
onClick: () => emit('test')
})
if (props.backend.type !== 'local') { function canDelete() {
items.push({ return props.backend.type !== 'local' && !props.backend.isDefault
key: 'toggle', }
icon: props.backend.enabled ? 'i-lucide-pause' : 'i-lucide-play',
label: props.backend.enabled ? t('storage.disable') : t('storage.enable'),
shortLabel: props.backend.enabled ? t('storage.disable') : t('storage.enable'),
disabled: props.backend.isDefault && props.backend.enabled,
onClick: () => emit('toggleEnabled')
})
}
if (props.backend.type !== 'local' && !props.backend.isDefault) {
items.push({
key: 'delete',
icon: 'i-lucide-trash-2',
label: t('common.delete'),
shortLabel: t('common.delete'),
danger: true,
onClick: () => emit('delete')
})
}
return items
})
const actionGridClass = computed(() => {
switch (actions.value.length) {
case 1: return 'grid-cols-1'
case 2: return 'grid-cols-2'
case 3: return 'grid-cols-3'
case 4: return 'grid-cols-4'
default: return 'grid-cols-5'
}
})
</script> </script>
<template> <template>
<article <article
class="flex h-full flex-col rounded-2xl border border-default bg-elevated p-4 shadow-sm" class="flex h-full flex-col rounded-xl border border-default bg-elevated p-4 sm:p-5"
:class="{ :class="{ 'opacity-75': !backend.enabled }"
'ring-2 ring-primary/30': backend.isDefault,
'opacity-75': !backend.enabled
}"
>
<div class="mb-3 flex items-start justify-between gap-2">
<div class="flex min-w-0 items-start gap-2.5">
<div
class="flex size-9 shrink-0 items-center justify-center rounded-lg bg-primary/10 text-primary"
> >
<div class="flex items-start justify-between gap-3">
<div class="flex min-w-0 items-center gap-3">
<div class="flex size-10 shrink-0 items-center justify-center rounded-lg bg-primary/10 text-primary">
<UIcon <UIcon
:name="typeIcon" :name="typeIcon"
class="size-4.5" class="size-5"
/> />
</div> </div>
<div class="min-w-0"> <h3 class="truncate text-base font-semibold">
<h3 class="truncate text-sm font-semibold">
{{ backend.name }} {{ backend.name }}
</h3> </h3>
<p class="mt-0.5 text-xs text-muted">
{{ typeLabel }}
<template v-if="bucketLine">
· {{ bucketLine }}
</template>
</p>
</div> </div>
</div> <div class="flex shrink-0 flex-wrap justify-end gap-1.5">
<div class="flex shrink-0 flex-wrap justify-end gap-1"> <span
<UBadge v-if="backend.isDefault"
v-if="backend.enabled" class="inline-flex rounded-md bg-primary/10 px-2 py-0.5 text-xs font-medium text-primary"
color="success" >
variant="subtle" {{ t('storage.badgeDefaultStorage') }}
size="xs" </span>
<span
v-if="isLocal"
class="inline-flex rounded-md border border-default bg-default px-2 py-0.5 text-xs font-medium text-muted"
>
{{ t('storage.badgeLocal') }}
</span>
<span
v-else-if="backend.enabled"
class="inline-flex rounded-md border border-default bg-default px-2 py-0.5 text-xs font-medium text-muted"
> >
{{ t('storage.badgeEnabled') }} {{ t('storage.badgeEnabled') }}
</UBadge> </span>
<UBadge <span
v-if="backend.isDefault" v-else
color="primary" class="inline-flex rounded-md bg-muted px-2 py-0.5 text-xs font-medium text-muted"
variant="subtle"
size="xs"
>
{{ t('storage.badgeDefault') }}
</UBadge>
<UBadge
v-if="!backend.enabled"
color="warning"
variant="subtle"
size="xs"
> >
{{ t('storage.badgeDisabled') }} {{ t('storage.badgeDisabled') }}
</UBadge> </span>
</div> </div>
</div> </div>
<div class="mt-4">
<StorageUsageBar <StorageUsageBar
:capacity="backend.capacity" :capacity="backend.capacity"
:index-bytes="backend.usage.bytes" :index-bytes="backend.usage.bytes"
/> />
<p class="mt-3 text-xs text-muted">
{{ t('storage.servingMode') }}: {{ servingLabel }}
</p>
<div class="mt-4 border-t border-default pt-4">
<div
class="grid gap-1.5"
:class="actionGridClass"
>
<button
v-for="action in actions"
:key="action.key"
type="button"
class="flex flex-col items-center justify-center gap-1 rounded-lg border px-1 py-2 transition disabled:cursor-not-allowed disabled:opacity-40"
:class="action.danger
? 'border-error/30 text-error hover:bg-error/5'
: 'border-default text-muted hover:bg-muted/40 hover:text-highlighted'"
:title="action.label"
:aria-label="action.label"
:disabled="actionsDisabled || action.disabled"
@click="action.onClick"
>
<UIcon
:name="action.icon"
class="size-4 shrink-0"
/>
<span class="w-full truncate text-center text-[11px] leading-none">
{{ action.shortLabel }}
</span>
</button>
</div> </div>
<div class="mt-4 flex flex-wrap items-center gap-x-4 gap-y-1 text-xs text-muted">
<div class="flex min-w-0 items-center gap-2">
<span class="shrink-0">{{ t('storage.storageId') }}:</span>
<code class="min-w-0 truncate font-mono text-dimmed">{{ backend.id }}</code>
<CopyButton
icon="i-lucide-copy"
icon-only
variant="ghost"
color="primary"
class="shrink-0"
:label="t('common.copy')"
:value="backend.id"
:success-title="t('copy.copied')"
/>
</div>
<span>
{{ t('storage.storageType') }}: {{ typeLabel }}
</span>
</div>
<div class="mt-4 flex flex-wrap gap-2 border-t border-default pt-4">
<UButton
v-if="canSetDefault()"
:label="t('storage.setDefault')"
icon="i-lucide-star"
size="sm"
variant="outline"
color="neutral"
:disabled="actionsDisabled"
@click="emit('setDefault')"
/>
<UButton
v-if="canEdit()"
:label="t('common.edit')"
icon="i-lucide-pencil"
size="sm"
variant="outline"
color="neutral"
:disabled="actionsDisabled"
@click="emit('edit')"
/>
<UButton
:label="t('storage.testConnection')"
:icon="isLocal ? 'i-lucide-wand-sparkles' : 'i-lucide-activity'"
size="sm"
variant="outline"
color="neutral"
:loading="busy"
:disabled="actionsDisabled"
@click="emit('test')"
/>
<UButton
v-if="canToggle()"
:label="backend.enabled ? t('storage.disable') : t('storage.enable')"
:icon="backend.enabled ? 'i-lucide-pause' : 'i-lucide-play'"
size="sm"
variant="outline"
color="neutral"
:disabled="actionsDisabled || (backend.isDefault && backend.enabled)"
@click="emit('toggleEnabled')"
/>
<UButton
v-if="canDelete()"
:label="t('common.delete')"
icon="i-lucide-trash-2"
size="sm"
variant="outline"
color="error"
:disabled="actionsDisabled"
@click="emit('delete')"
/>
</div> </div>
</article> </article>
</template> </template>
@@ -6,6 +6,7 @@ type StorageProvider = 'r2' | 'cos' | 'oss' | 'aws' | 'custom'
export interface StorageFormPayload { export interface StorageFormPayload {
name: string name: string
provider: StorageProvider
config: { config: {
endpoint: string endpoint: string
region: string region: string
@@ -158,6 +159,7 @@ watch(provider, (value) => {
function handleSubmit() { function handleSubmit() {
emit('submit', { emit('submit', {
name: name.value.trim() || providerLabel(provider.value), name: name.value.trim() || providerLabel(provider.value),
provider: provider.value,
config: { config: {
endpoint: endpoint.value.trim(), endpoint: endpoint.value.trim(),
region: region.value.trim() || 'auto', region: region.value.trim() || 'auto',
+184
View File
@@ -0,0 +1,184 @@
<script setup lang="ts">
import type { StorageBackendItem } from '~/types/storage'
const props = defineProps<{
backends: StorageBackendItem[]
loading?: boolean
}>()
const { t } = useI18n()
const { formatFileSize } = useFileSize()
const CHART_COLORS = [
'var(--ui-primary)',
'#3b82f6',
'#6366f1',
'#f59e0b',
'#ec4899',
'#8b5cf6'
] as const
function backendUsedBytes(backend: StorageBackendItem) {
return backend.capacity.source === 'disk'
? backend.capacity.usedBytes
: backend.usage.bytes
}
const totalCapacityBytes = computed(() => {
let total = 0
let hasTotal = false
for (const backend of props.backends) {
if (backend.capacity.totalBytes != null && backend.capacity.totalBytes > 0) {
total += backend.capacity.totalBytes
hasTotal = true
}
}
return hasTotal ? total : null
})
const totalUsedBytes = computed(() =>
props.backends.reduce((sum, backend) => sum + backendUsedBytes(backend), 0)
)
const totalFreeBytes = computed(() => {
if (!totalCapacityBytes.value) return null
return Math.max(0, totalCapacityBytes.value - totalUsedBytes.value)
})
const usagePercent = computed(() => {
if (!totalCapacityBytes.value) return null
return Math.min(Math.round((totalUsedBytes.value / totalCapacityBytes.value) * 1000) / 10, 100)
})
const usageBarWidth = computed(() => {
if (usagePercent.value == null) return 0
return Math.max(usagePercent.value, totalUsedBytes.value > 0 ? 4 : 0)
})
const distributionItems = computed(() => {
if (!totalCapacityBytes.value) return []
return props.backends
.filter(backend => backend.capacity.totalBytes != null && backend.capacity.totalBytes > 0)
.map((backend, index) => ({
id: backend.id,
name: backend.name,
color: CHART_COLORS[index % CHART_COLORS.length]!,
percent: Math.round((backend.capacity.totalBytes! / totalCapacityBytes.value!) * 1000) / 10
}))
})
const donutStyle = computed(() => {
const items = distributionItems.value
if (!items.length) return { background: 'var(--ui-bg-muted)' }
let cursor = 0
const segments = items.map((item) => {
const start = cursor
cursor += item.percent
return `${item.color} ${start}% ${cursor}%`
})
return { background: `conic-gradient(${segments.join(', ')})` }
})
function displaySize(bytes: number | null) {
if (props.loading) return '—'
if (bytes == null) return '—'
return formatFileSize(bytes)
}
</script>
<template>
<section class="rounded-xl border border-default bg-elevated p-4 sm:p-5">
<h2 class="text-sm font-semibold">
{{ t('storage.overviewTitle') }}
</h2>
<p class="mt-0.5 text-xs text-muted">
{{ t('storage.overviewSubtitle') }}
</p>
<div class="mt-4 grid gap-5 lg:grid-cols-[minmax(0,1fr)_auto] lg:items-center">
<div>
<div class="grid grid-cols-2 gap-4 sm:grid-cols-4">
<div>
<p class="text-xs text-muted">
{{ t('storage.overviewTotal') }}
</p>
<p class="mt-1 text-lg font-semibold tabular-nums">
{{ displaySize(totalCapacityBytes) }}
</p>
<div
v-if="totalCapacityBytes"
class="mt-2 h-1 overflow-hidden rounded-full bg-muted/50"
>
<div
class="h-full rounded-full bg-primary transition-all"
:style="{ width: `${usageBarWidth}%` }"
/>
</div>
</div>
<div>
<p class="text-xs text-muted">
{{ t('storage.overviewUsed') }}
</p>
<p class="mt-1 text-lg font-semibold tabular-nums">
{{ displaySize(totalUsedBytes) }}
</p>
</div>
<div>
<p class="text-xs text-muted">
{{ t('storage.overviewFree') }}
</p>
<p class="mt-1 text-lg font-semibold tabular-nums">
{{ displaySize(totalFreeBytes) }}
</p>
</div>
<div>
<p class="text-xs text-muted">
{{ t('storage.overviewUsageRate') }}
</p>
<p class="mt-1 text-lg font-semibold tabular-nums">
{{ usagePercent != null ? `${usagePercent}%` : '—' }}
</p>
</div>
</div>
</div>
<div class="flex items-center gap-4">
<div
class="relative size-24 shrink-0 rounded-full p-2"
:style="donutStyle"
>
<div class="flex size-full items-center justify-center rounded-full bg-elevated px-2 text-center">
<p class="text-[10px] leading-tight text-muted">
{{ t('storage.capacityDistribution') }}
</p>
</div>
</div>
<ul
v-if="distributionItems.length"
class="min-w-[8rem] space-y-2"
>
<li
v-for="item in distributionItems"
:key="item.id"
class="flex items-center justify-between gap-3 text-xs"
>
<span class="flex min-w-0 items-center gap-1.5">
<span
class="size-2 shrink-0 rounded-full"
:style="{ background: item.color }"
/>
<span class="truncate">{{ item.name }}</span>
</span>
<span class="shrink-0 tabular-nums text-muted">
{{ item.percent }}%
</span>
</li>
</ul>
</div>
</div>
</section>
</template>
@@ -0,0 +1,62 @@
<script setup lang="ts">
const { t } = useI18n()
const items = computed(() => [
{
icon: 'i-lucide-hard-drive',
iconClass: 'text-primary',
boxClass: 'border-primary/20 bg-primary/5',
title: t('storage.backendLocal'),
desc: t('storage.supportedLocalDesc')
},
{
icon: 'i-lucide-cloud',
iconClass: 'text-orange-500',
boxClass: 'border-orange-500/20 bg-orange-500/5',
title: 'Cloudflare R2',
desc: t('storage.supportedR2Desc')
},
{
icon: 'i-lucide-server',
iconClass: 'text-blue-500',
boxClass: 'border-blue-500/20 bg-blue-500/5',
title: t('storage.supportedS3Title'),
desc: t('storage.supportedS3Desc')
}
])
</script>
<template>
<section class="rounded-xl border border-default bg-elevated p-4 sm:p-5">
<h2 class="text-sm font-semibold">
{{ t('storage.supportedTitle') }}
</h2>
<div class="mt-4 grid grid-cols-1 gap-3 lg:grid-cols-3">
<div
v-for="item in items"
:key="item.title"
class="flex items-start gap-3 rounded-lg border border-default bg-muted/30 px-4 py-3"
>
<div
class="flex size-10 shrink-0 items-center justify-center rounded-lg border"
:class="item.boxClass"
>
<UIcon
:name="item.icon"
class="size-5"
:class="item.iconClass"
/>
</div>
<div class="min-w-0 pt-0.5">
<p class="text-sm font-semibold">
{{ item.title }}
</p>
<p class="mt-0.5 text-xs leading-relaxed text-muted">
{{ item.desc }}
</p>
</div>
</div>
</div>
</section>
</template>
+26 -29
View File
@@ -36,56 +36,53 @@ function formatOptional(bytes: number | null): string {
<template> <template>
<div class="space-y-3"> <div class="space-y-3">
<div class="grid grid-cols-3 gap-2 text-center text-xs"> <div class="grid grid-cols-2 gap-3 sm:grid-cols-4">
<div class="rounded-lg bg-muted/30 px-2 py-2"> <div>
<p class="text-muted"> <p class="text-xs text-muted">
{{ $t('storage.statTotal') }}
</p>
<p class="mt-1 font-medium tabular-nums text-highlighted">
{{ formatOptional(capacity.totalBytes) }}
</p>
</div>
<div class="rounded-lg bg-muted/30 px-2 py-2">
<p class="text-muted">
{{ $t('storage.statUsed') }} {{ $t('storage.statUsed') }}
</p> </p>
<p class="mt-1 font-medium tabular-nums text-highlighted"> <p class="mt-1 text-sm font-semibold tabular-nums">
{{ formatFileSize(displayUsedBytes) }} {{ formatFileSize(displayUsedBytes) }}
</p> </p>
</div> </div>
<div class="rounded-lg bg-muted/30 px-2 py-2"> <div>
<p class="text-muted"> <p class="text-xs text-muted">
{{ $t('storage.statTotal') }}
</p>
<p class="mt-1 text-sm font-semibold tabular-nums">
{{ formatOptional(capacity.totalBytes) }}
</p>
</div>
<div>
<p class="text-xs text-muted">
{{ $t('storage.statFree') }} {{ $t('storage.statFree') }}
</p> </p>
<p <p
class="mt-1 font-medium tabular-nums" class="mt-1 text-sm font-semibold tabular-nums"
:class="overQuota ? 'text-error' : 'text-highlighted'" :class="overQuota ? 'text-error' : ''"
> >
{{ formatOptional(capacity.freeBytes) }} {{ formatOptional(capacity.freeBytes) }}
</p> </p>
</div> </div>
<div>
<p class="text-xs text-muted">
{{ $t('storage.usageRate') }}
</p>
<p class="mt-1 text-sm font-semibold tabular-nums">
{{ hasBar ? `${displayPercent}%` : '—' }}
</p>
</div>
</div> </div>
<div <div
v-if="hasBar" v-if="hasBar"
class="space-y-1" class="h-1 overflow-hidden rounded-full bg-muted/50"
> >
<div class="flex items-center justify-between text-xs">
<span class="text-muted">{{ $t('storage.usageRate') }}</span>
<span
class="tabular-nums font-medium"
:class="overQuota ? 'text-error' : ''"
>
{{ displayPercent }}%
</span>
</div>
<div class="h-1.5 overflow-hidden rounded-full bg-muted/50">
<div <div
class="h-full rounded-full transition-all" class="h-full rounded-full transition-all"
:class="overQuota ? 'bg-error/80' : 'bg-primary/70'" :class="overQuota ? 'bg-error/80' : 'bg-primary'"
:style="{ width: `${barWidth}%` }" :style="{ width: `${barWidth}%` }"
/> />
</div> </div>
</div> </div>
</div>
</template> </template>
+323
View File
@@ -0,0 +1,323 @@
export type ApiDocMethod = 'GET' | 'POST' | 'DELETE'
export type ApiDocCategory = 'image' | 'extension' | 'system'
export type ApiParamLocation = 'form-data' | 'query' | 'header' | 'body'
export interface ApiDocParam {
name: string
type: string
location: ApiParamLocation
required: boolean
descriptionKey: string
}
export interface ApiDocEndpoint {
id: string
category: ApiDocCategory
method: ApiDocMethod
path: string
titleKey: string
descKey: string
params: ApiDocParam[]
requiresAuth: boolean
curl: string
responseExample: string
}
interface ApiDocsContext {
baseUrl: string
authHeader: string
}
function authFlag(authHeader: string) {
return `-H 'Auth-Token: ${authHeader}'`
}
export function buildApiDocs(ctx: ApiDocsContext): ApiDocEndpoint[] {
const { baseUrl, authHeader } = ctx
const auth = authFlag(authHeader)
return [
{
id: 'upload',
category: 'image',
method: 'POST',
path: '/api/images/upload',
titleKey: 'api.docs.upload.title',
descKey: 'api.docs.upload.desc',
requiresAuth: true,
params: [
{
name: 'image',
type: 'file',
location: 'form-data',
required: true,
descriptionKey: 'api.params.upload.image'
},
{
name: 'file',
type: 'file',
location: 'form-data',
required: false,
descriptionKey: 'api.params.upload.file'
},
{
name: 'files',
type: 'file',
location: 'form-data',
required: false,
descriptionKey: 'api.params.upload.files'
},
{
name: 'token',
type: 'string',
location: 'form-data',
required: false,
descriptionKey: 'api.params.upload.token'
}
],
curl: `curl -X POST "${baseUrl}/api/images/upload" \\
${auth} \\
-F "image=@./demo.png"`,
responseExample: `{
"success": true,
"items": [
{
"key": "images/2026/08/abc123.webp",
"url": "https://pic.example.com/abc123.webp",
"originalName": "demo.png",
"contentType": "image/webp",
"size": 12345,
"uploadedAt": "2026-08-01T12:00:00.000Z",
"markdown": "![demo.png](https://pic.example.com/abc123.webp)",
"html": "<img src=\\"https://pic.example.com/abc123.webp\\" alt=\\"demo.png\\">"
}
],
"errors": []
}`
},
{
id: 'list',
category: 'image',
method: 'GET',
path: '/api/images',
titleKey: 'api.docs.list.title',
descKey: 'api.docs.list.desc',
requiresAuth: true,
params: [
{
name: 'limit',
type: 'number',
location: 'query',
required: false,
descriptionKey: 'api.params.list.limit'
},
{
name: 'page',
type: 'number',
location: 'query',
required: false,
descriptionKey: 'api.params.list.page'
},
{
name: 'backendId',
type: 'string',
location: 'query',
required: false,
descriptionKey: 'api.params.backendId'
}
],
curl: `curl "${baseUrl}/api/images?limit=20&page=1" \\
${auth}`,
responseExample: `{
"items": [
{
"key": "images/2026/08/abc123.webp",
"url": "https://pic.example.com/abc123.webp",
"originalName": "demo.png",
"contentType": "image/webp",
"size": 12345,
"uploadedAt": "2026-08-01T12:00:00.000Z",
"markdown": "![demo.png](https://pic.example.com/abc123.webp)",
"html": "<img src=\\"https://pic.example.com/abc123.webp\\" alt=\\"demo.png\\">"
}
],
"total": 1,
"page": 1,
"pageSize": 20,
"totalPages": 1
}`
},
{
id: 'search',
category: 'image',
method: 'GET',
path: '/api/images/search',
titleKey: 'api.docs.search.title',
descKey: 'api.docs.search.desc',
requiresAuth: true,
params: [
{
name: 'q',
type: 'string',
location: 'query',
required: true,
descriptionKey: 'api.params.search.q'
},
{
name: 'limit',
type: 'number',
location: 'query',
required: false,
descriptionKey: 'api.params.list.limit'
},
{
name: 'page',
type: 'number',
location: 'query',
required: false,
descriptionKey: 'api.params.list.page'
},
{
name: 'backendId',
type: 'string',
location: 'query',
required: false,
descriptionKey: 'api.params.backendId'
}
],
curl: `curl "${baseUrl}/api/images/search?q=demo&limit=20&page=1" \\
${auth}`,
responseExample: `{
"items": [],
"total": 0,
"page": 1,
"pageSize": 20,
"totalPages": 0
}`
},
{
id: 'delete',
category: 'image',
method: 'DELETE',
path: '/api/images',
titleKey: 'api.docs.delete.title',
descKey: 'api.docs.delete.desc',
requiresAuth: true,
params: [
{
name: 'key',
type: 'string',
location: 'query',
required: true,
descriptionKey: 'api.params.delete.key'
}
],
curl: `curl -X DELETE "${baseUrl}/api/images?key=images/2026/08/xxxx.webp" \\
${auth}`,
responseExample: `{
"success": true
}`
},
{
id: 'batchDelete',
category: 'image',
method: 'POST',
path: '/api/images/batch-delete',
titleKey: 'api.docs.batchDelete.title',
descKey: 'api.docs.batchDelete.desc',
requiresAuth: true,
params: [
{
name: 'keys',
type: 'string[]',
location: 'body',
required: true,
descriptionKey: 'api.params.batchDelete.keys'
}
],
curl: `curl -X POST "${baseUrl}/api/images/batch-delete" \\
${auth} \\
-H "Content-Type: application/json" \\
-d '{"keys":["images/2026/08/a.webp","images/2026/08/b.webp"]}'`,
responseExample: `{
"success": true,
"deleted": ["images/2026/08/a.webp"],
"failed": []
}`
},
{
id: 'twikoo',
category: 'extension',
method: 'POST',
path: '/api/index.php',
titleKey: 'api.docs.twikoo.title',
descKey: 'api.docs.twikoo.desc',
requiresAuth: false,
params: [
{
name: 'image',
type: 'file',
location: 'form-data',
required: true,
descriptionKey: 'api.params.twikoo.image'
},
{
name: 'token',
type: 'string',
location: 'form-data',
required: true,
descriptionKey: 'api.params.twikoo.token'
}
],
curl: `curl -X POST "${baseUrl}/api/index.php" \\
-F "token=${authHeader}" \\
-F "image=@./demo.png"`,
responseExample: `{
"result": "success",
"code": 200,
"url": "https://pic.example.com/abc123.webp"
}`
},
{
id: 'config',
category: 'system',
method: 'GET',
path: '/api/auth/config',
titleKey: 'api.docs.config.title',
descKey: 'api.docs.config.desc',
requiresAuth: false,
params: [],
curl: `curl "${baseUrl}/api/auth/config"`,
responseExample: `{
"initialized": true,
"allowRegistration": false,
"legacyMode": false,
"adminSecretConfigured": false,
"apiUploadTokenConfigured": true
}`
},
{
id: 'count',
category: 'system',
method: 'GET',
path: '/api/images/count',
titleKey: 'api.docs.count.title',
descKey: 'api.docs.count.desc',
requiresAuth: true,
params: [
{
name: 'backendId',
type: 'string',
location: 'query',
required: false,
descriptionKey: 'api.params.backendId'
}
],
curl: `curl "${baseUrl}/api/images/count" \\
${auth}`,
responseExample: `{
"total": 42
}`
}
]
}
+43 -6
View File
@@ -1,3 +1,5 @@
import type { LoginVerificationPayload } from '~/types/auth'
export type AuthStatus = 'checking' | 'authenticated' | 'unauthenticated' export type AuthStatus = 'checking' | 'authenticated' | 'unauthenticated'
export type UserRole = 'admin' | 'user' export type UserRole = 'admin' | 'user'
@@ -8,11 +10,18 @@ export interface AuthUser {
role: UserRole role: UserRole
} }
export interface LoginVerificationPublicConfig {
method: 'slider' | 'turnstile' | 'cap'
turnstileSiteKey?: string
capApiEndpoint?: string
}
export interface AuthStatusResponse { export interface AuthStatusResponse {
initialized: boolean initialized: boolean
allowRegistration: boolean allowRegistration: boolean
legacyMode: boolean legacyMode: boolean
needsMigration: boolean needsMigration: boolean
loginVerification: LoginVerificationPublicConfig
user: AuthUser | null user: AuthUser | null
} }
@@ -66,6 +75,21 @@ export function getFetchErrorMessage(error: unknown, fallback: string): string {
export type AuthActionResult = { ok: true } | { ok: false, error: string } export type AuthActionResult = { ok: true } | { ok: false, error: string }
export type { LoginVerificationPayload }
function buildVerificationBody(verification: LoginVerificationPayload): Record<string, unknown> {
if (verification.method === 'slider') {
return {
captchaId: verification.captchaId,
captchaPosition: verification.captchaPosition
}
}
if (verification.method === 'turnstile') {
return { turnstileToken: verification.turnstileToken }
}
return { capToken: verification.capToken }
}
export function useAuth() { export function useAuth() {
const { t } = useI18n() const { t } = useI18n()
const status = useState<AuthStatus>('auth-status', () => 'checking') const status = useState<AuthStatus>('auth-status', () => 'checking')
@@ -117,7 +141,8 @@ export function useAuth() {
async function login( async function login(
credentials: { username: string, password: string } credentials: { username: string, password: string }
| { secret: string } | { secret: string },
verification: LoginVerificationPayload
): Promise<{ ok: boolean, needsMigration?: boolean, error?: string }> { ): Promise<{ ok: boolean, needsMigration?: boolean, error?: string }> {
try { try {
const result = await $fetch<{ const result = await $fetch<{
@@ -127,7 +152,10 @@ export function useAuth() {
}>('/api/auth/login', { }>('/api/auth/login', {
method: 'POST', method: 'POST',
credentials: 'include', credentials: 'include',
body: credentials body: {
...credentials,
...buildVerificationBody(verification)
}
}) })
if (result.needsMigration) { if (result.needsMigration) {
return { ok: true, needsMigration: true } return { ok: true, needsMigration: true }
@@ -149,7 +177,8 @@ export function useAuth() {
async function register( async function register(
username: string, username: string,
password: string password: string,
verification: LoginVerificationPayload
): Promise<AuthActionResult> { ): Promise<AuthActionResult> {
const validationError = validateAuthInput(username, password) const validationError = validateAuthInput(username, password)
if (validationError) { if (validationError) {
@@ -162,7 +191,11 @@ export function useAuth() {
{ {
method: 'POST', method: 'POST',
credentials: 'include', credentials: 'include',
body: { username: username.trim(), password } body: {
username: username.trim(),
password,
...buildVerificationBody(verification)
}
} }
) )
user.value = result.user user.value = result.user
@@ -281,15 +314,19 @@ export function useAuth() {
async function logout() { async function logout() {
try { try {
await $fetch('/api/auth/logout', { method: 'POST' }) await $fetch('/api/auth/logout', { method: 'POST', credentials: 'include' })
} catch { } catch {
// 忽略登出失败 // 忽略登出失败
} finally { } finally {
status.value = 'unauthenticated' status.value = 'unauthenticated'
user.value = null user.value = null
authStatus.value = null
hasValidatedOnce.value = true hasValidatedOnce.value = true
} }
try {
await fetchStatus()
} catch {
// 保留既有公开配置(loginVerification 等)
}
} }
function handleAuthError(error: unknown) { function handleAuthError(error: unknown) {
+89 -59
View File
@@ -3,46 +3,46 @@ import type {
ImageListResponse ImageListResponse
} from '~/types/image' } from '~/types/image'
const PAGE_SIZE = 12 const PAGE_SIZE_DEFAULT = 10
export function useImageList() { export function useImageList() {
const { t } = useI18n() const { t } = useI18n()
const items = ref<ImageItem[]>([]) const items = ref<ImageItem[]>([])
const page = ref(1) const page = ref(1)
const pageSize = ref(PAGE_SIZE_DEFAULT)
const totalPages = ref(1) const totalPages = ref(1)
const total = ref(0) const total = ref(0)
const loading = ref(false) const loading = ref(false)
const folderTotal = ref<number | null>(null) const totalCount = ref<number | null>(null)
const loadingTotal = ref(false) const loadingTotal = ref(false)
const searchQuery = ref('') const searchQuery = ref('')
const activeSearch = ref('') const activeSearch = ref('')
const activeFolder = ref('all')
const activeStorageBackend = ref('all') const activeStorageBackend = ref('all')
const activeContentType = ref('all')
const activeUploadSource = ref('all')
const storageBackendOptions = ref<Array<{ id: string, name: string }>>([]) const storageBackendOptions = ref<Array<{ id: string, name: string }>>([])
function folderQueryParams(): Record<string, string> {
const folder = activeFolder.value.trim()
if (!folder || folder === 'all') return {}
return { folder }
}
function storageQueryParams(): Record<string, string> { function storageQueryParams(): Record<string, string> {
const backendId = activeStorageBackend.value.trim() const backendId = activeStorageBackend.value.trim()
if (!backendId || backendId === 'all') return {} if (!backendId || backendId === 'all') return {}
return { backendId } return { backendId }
} }
function listQueryParams(): Record<string, string> { function filterQueryParams(): Record<string, string> {
return { const params: Record<string, string> = { ...storageQueryParams() }
...folderQueryParams(), const contentType = activeContentType.value.trim()
...storageQueryParams() if (contentType && contentType !== 'all') {
params.contentType = contentType
} }
const uploadSource = activeUploadSource.value.trim()
if (uploadSource && uploadSource !== 'all') {
params.uploadSource = uploadSource
}
return params
} }
function matchesActiveFolder(key: string) { function listQueryParams(): Record<string, string> {
const folder = activeFolder.value.trim() return filterQueryParams()
if (!folder || folder === 'all') return true
return key.startsWith(`${folder}/`)
} }
function matchesActiveStorage(item: ImageItem) { function matchesActiveStorage(item: ImageItem) {
@@ -74,9 +74,9 @@ export function useImageList() {
credentials: 'include', credentials: 'include',
query: listQueryParams() query: listQueryParams()
}) })
folderTotal.value = data.total totalCount.value = data.total
} catch { } catch {
folderTotal.value = null totalCount.value = null
} finally { } finally {
loadingTotal.value = false loadingTotal.value = false
} }
@@ -88,7 +88,7 @@ export function useImageList() {
const response = await $fetch<ImageListResponse>('/api/images', { const response = await $fetch<ImageListResponse>('/api/images', {
credentials: 'include', credentials: 'include',
query: { query: {
limit: PAGE_SIZE, limit: pageSize.value,
page: targetPage, page: targetPage,
...listQueryParams() ...listQueryParams()
} }
@@ -116,7 +116,7 @@ export function useImageList() {
credentials: 'include', credentials: 'include',
query: { query: {
q, q,
limit: PAGE_SIZE, limit: pageSize.value,
page: targetPage, page: targetPage,
...listQueryParams() ...listQueryParams()
} }
@@ -138,27 +138,13 @@ export function useImageList() {
await Promise.all([fetchList(1), fetchTotal()]) await Promise.all([fetchList(1), fetchTotal()])
} }
async function initializeList(folder = 'all') { async function initializeList() {
const next = folder.trim() || 'all'
activeFolder.value = next
page.value = 1 page.value = 1
activeSearch.value = '' activeSearch.value = ''
searchQuery.value = '' searchQuery.value = ''
await Promise.all([fetchList(1), fetchTotal(), loadStorageBackendOptions()]) await Promise.all([fetchList(1), fetchTotal(), loadStorageBackendOptions()])
} }
async function setActiveFolder(folder: string) {
const next = folder.trim() || 'all'
if (next === activeFolder.value) return
activeFolder.value = next
page.value = 1
if (activeSearch.value) {
await Promise.all([fetchSearch(1), fetchTotal()])
} else {
await Promise.all([fetchList(1), fetchTotal()])
}
}
async function setActiveStorageBackend(backendId: string) { async function setActiveStorageBackend(backendId: string) {
const next = backendId.trim() || 'all' const next = backendId.trim() || 'all'
if (next === activeStorageBackend.value) return if (next === activeStorageBackend.value) return
@@ -171,6 +157,40 @@ export function useImageList() {
} }
} }
async function setActiveContentType(contentType: string) {
const next = contentType.trim() || 'all'
if (next === activeContentType.value) return
activeContentType.value = next
page.value = 1
if (activeSearch.value) {
await Promise.all([fetchSearch(1), fetchTotal()])
} else {
await Promise.all([fetchList(1), fetchTotal()])
}
}
async function setActiveUploadSource(uploadSource: string) {
const next = uploadSource.trim() || 'all'
if (next === activeUploadSource.value) return
activeUploadSource.value = next
page.value = 1
if (activeSearch.value) {
await Promise.all([fetchSearch(1), fetchTotal()])
} else {
await Promise.all([fetchList(1), fetchTotal()])
}
}
async function resetFilters() {
searchQuery.value = ''
activeSearch.value = ''
activeStorageBackend.value = 'all'
activeContentType.value = 'all'
activeUploadSource.value = 'all'
page.value = 1
await Promise.all([fetchList(1), fetchTotal()])
}
async function submitSearch() { async function submitSearch() {
const q = searchQuery.value.trim() const q = searchQuery.value.trim()
activeSearch.value = q activeSearch.value = q
@@ -191,15 +211,24 @@ export function useImageList() {
} }
} }
async function setPageSize(size: number) {
if (!Number.isFinite(size) || size <= 0 || size === pageSize.value) return
pageSize.value = size
page.value = 1
if (activeSearch.value) {
await Promise.all([fetchSearch(1), fetchTotal()])
} else {
await Promise.all([fetchList(1), fetchTotal()])
}
}
function prependItems(newItems: ImageItem[]) { function prependItems(newItems: ImageItem[]) {
const matched = newItems.filter(item => const matched = newItems.filter(item => matchesActiveStorage(item))
matchesActiveFolder(item.key) && matchesActiveStorage(item)
)
if (!matched.length || page.value !== 1) return if (!matched.length || page.value !== 1) return
items.value = [...matched, ...items.value] items.value = [...matched, ...items.value]
total.value += matched.length total.value += matched.length
if (folderTotal.value !== null) { if (totalCount.value !== null) {
folderTotal.value += matched.length totalCount.value += matched.length
} }
} }
@@ -210,8 +239,8 @@ export function useImageList() {
if (removed > 0) { if (removed > 0) {
total.value = Math.max(0, total.value - removed) total.value = Math.max(0, total.value - removed)
} }
if (folderTotal.value !== null && removed > 0) { if (totalCount.value !== null && removed > 0) {
folderTotal.value = Math.max(0, folderTotal.value - removed) totalCount.value = Math.max(0, totalCount.value - removed)
} }
} }
@@ -223,44 +252,42 @@ export function useImageList() {
}) })
const listSummary = computed(() => { const listSummary = computed(() => {
const folder = activeFolder.value.trim() || 'all'
const folderLabel = folder === 'all' ? t('stats.allFolders') : folder
const storageLabel = activeStorageLabel.value const storageLabel = activeStorageLabel.value
if (loadingTotal.value && folderTotal.value === null) { if (loadingTotal.value && totalCount.value === null) {
return storageLabel return storageLabel
? `${folderLabel} · ${storageLabel} · ${t('stats.counting')}` ? `${storageLabel} · ${t('stats.counting')}`
: `${folderLabel} · ${t('stats.counting')}` : t('stats.counting')
} }
if (activeSearch.value) { if (activeSearch.value) {
if (storageLabel) { if (storageLabel) {
return t('stats.summarySearchStorage', { return t('stats.summarySearchStorage', {
folder: folderLabel,
storage: storageLabel,
q: activeSearch.value, q: activeSearch.value,
total: total.value total: total.value,
storage: storageLabel
}) })
} }
return t('stats.summarySearch', { folder: folderLabel, q: activeSearch.value, total: total.value }) return t('stats.summarySearch', { q: activeSearch.value, total: total.value })
} }
const count = folderTotal.value === null ? total.value : folderTotal.value const count = totalCount.value === null ? total.value : totalCount.value
if (storageLabel) { if (storageLabel) {
return t('stats.summaryTotalStorage', { return t('stats.summaryTotalStorage', {
folder: folderLabel, total: count,
storage: storageLabel, storage: storageLabel
total: count
}) })
} }
return t('stats.summaryTotal', { folder: folderLabel, total: count }) return t('stats.summaryTotal', { total: count })
}) })
return { return {
items, items,
page, page,
pageSize,
totalPages, totalPages,
total, total,
loading, loading,
activeFolder,
activeStorageBackend, activeStorageBackend,
activeContentType,
activeUploadSource,
storageBackendOptions, storageBackendOptions,
searchQuery, searchQuery,
activeSearch, activeSearch,
@@ -270,11 +297,14 @@ export function useImageList() {
fetchTotal, fetchTotal,
refreshList, refreshList,
initializeList, initializeList,
setActiveFolder,
setActiveStorageBackend, setActiveStorageBackend,
setActiveContentType,
setActiveUploadSource,
resetFilters,
loadStorageBackendOptions, loadStorageBackendOptions,
submitSearch, submitSearch,
goToPage, goToPage,
setPageSize,
prependItems, prependItems,
removeItems removeItems
} }
+1 -5
View File
@@ -17,8 +17,7 @@ export function useImageUpload() {
async function uploadFiles( async function uploadFiles(
files: File[], files: File[],
folder = 'images', options?: { notify?: boolean }
options?: { notify?: boolean, includeFolder?: boolean }
) { ) {
if (uploading.value) return if (uploading.value) return
@@ -55,9 +54,6 @@ export function useImageUpload() {
})) }))
const formData = new FormData() const formData = new FormData()
if (options?.includeFolder !== false) {
formData.append('folder', folder.trim() || 'images')
}
for (const file of prepared) { for (const file of prepared) {
formData.append('files', file, file.name) formData.append('files', file, file.name)
} }
+5 -10
View File
@@ -3,7 +3,6 @@ const STORAGE_KEY = 'pichost.upload-preferences'
export type CopyFormat = 'url' | 'markdown' | 'html' export type CopyFormat = 'url' | 'markdown' | 'html'
export interface UploadPreferences { export interface UploadPreferences {
defaultFolder: string
compressEnabled: boolean compressEnabled: boolean
autoCopyMarkdown: boolean autoCopyMarkdown: boolean
copyFormat: CopyFormat copyFormat: CopyFormat
@@ -11,7 +10,6 @@ export interface UploadPreferences {
} }
const DEFAULTS: UploadPreferences = { const DEFAULTS: UploadPreferences = {
defaultFolder: 'images',
compressEnabled: false, compressEnabled: false,
autoCopyMarkdown: false, autoCopyMarkdown: false,
copyFormat: 'markdown', copyFormat: 'markdown',
@@ -36,9 +34,6 @@ function readStored(): UploadPreferences {
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 {
defaultFolder: typeof parsed.defaultFolder === 'string' && parsed.defaultFolder.trim()
? parsed.defaultFolder.trim()
: DEFAULTS.defaultFolder,
compressEnabled: parsed.compressEnabled === true, compressEnabled: parsed.compressEnabled === true,
autoCopyMarkdown: parsed.autoCopyMarkdown === true, autoCopyMarkdown: parsed.autoCopyMarkdown === true,
copyFormat: isCopyFormat(parsed.copyFormat) ? parsed.copyFormat : DEFAULTS.copyFormat, copyFormat: isCopyFormat(parsed.copyFormat) ? parsed.copyFormat : DEFAULTS.copyFormat,
@@ -55,7 +50,6 @@ function writeStored(prefs: UploadPreferences) {
} }
export function useUploadPreferences() { export function useUploadPreferences() {
const defaultFolder = useState('upload-pref-folder', () => DEFAULTS.defaultFolder)
const compressEnabled = useState('upload-pref-compress', () => DEFAULTS.compressEnabled) const compressEnabled = useState('upload-pref-compress', () => DEFAULTS.compressEnabled)
const autoCopyMarkdown = useState('upload-pref-auto-md', () => DEFAULTS.autoCopyMarkdown) const autoCopyMarkdown = useState('upload-pref-auto-md', () => DEFAULTS.autoCopyMarkdown)
const copyFormat = useState<CopyFormat>('upload-pref-copy-format', () => DEFAULTS.copyFormat) const copyFormat = useState<CopyFormat>('upload-pref-copy-format', () => DEFAULTS.copyFormat)
@@ -64,7 +58,6 @@ export function useUploadPreferences() {
function loadPreferences() { function loadPreferences() {
const stored = readStored() const stored = readStored()
defaultFolder.value = stored.defaultFolder
compressEnabled.value = stored.compressEnabled compressEnabled.value = stored.compressEnabled
autoCopyMarkdown.value = stored.autoCopyMarkdown autoCopyMarkdown.value = stored.autoCopyMarkdown
copyFormat.value = stored.copyFormat copyFormat.value = stored.copyFormat
@@ -72,9 +65,12 @@ export function useUploadPreferences() {
loaded.value = true loaded.value = true
} }
if (import.meta.client && !loaded.value) {
loadPreferences()
}
function savePreferences() { function savePreferences() {
writeStored({ writeStored({
defaultFolder: defaultFolder.value.trim() || DEFAULTS.defaultFolder,
compressEnabled: compressEnabled.value, compressEnabled: compressEnabled.value,
autoCopyMarkdown: autoCopyMarkdown.value, autoCopyMarkdown: autoCopyMarkdown.value,
copyFormat: copyFormat.value, copyFormat: copyFormat.value,
@@ -82,14 +78,13 @@ export function useUploadPreferences() {
}) })
} }
watch([defaultFolder, compressEnabled, autoCopyMarkdown, copyFormat, clientWebpQuality], () => { watch([compressEnabled, autoCopyMarkdown, copyFormat, clientWebpQuality], () => {
if (loaded.value) { if (loaded.value) {
savePreferences() savePreferences()
} }
}) })
return { return {
defaultFolder,
compressEnabled, compressEnabled,
autoCopyMarkdown, autoCopyMarkdown,
copyFormat, copyFormat,
+35 -311
View File
@@ -12,15 +12,6 @@ interface SettingsResponse {
} }
} }
interface ApiDocItem {
index: number
method: 'GET' | 'POST' | 'DELETE'
path: string
title: string
description: string
curl: string
}
const { isChecking, isAuthenticated, checkSession, handleAuthError, fetchStatus, isAdmin } = useAuth() const { isChecking, isAuthenticated, checkSession, handleAuthError, fetchStatus, isAdmin } = useAuth()
const toast = useToast() const toast = useToast()
const { t } = useI18n() const { t } = useI18n()
@@ -31,6 +22,7 @@ const regenerating = ref(false)
const confirmRegenerateOpen = ref(false) const confirmRegenerateOpen = ref(false)
const regenerateResultOpen = ref(false) const regenerateResultOpen = ref(false)
const newToken = ref('') const newToken = ref('')
const activeEndpointId = ref('upload')
const baseUrl = computed(() => { const baseUrl = computed(() => {
const configured = settings.value?.env.siteBaseUrl const configured = settings.value?.env.siteBaseUrl
@@ -43,125 +35,24 @@ const baseUrl = computed(() => {
const tokenDisplay = computed(() => settings.value?.apiUploadToken ?? '') const tokenDisplay = computed(() => settings.value?.apiUploadToken ?? '')
const hasToken = computed(() => tokenDisplay.value.length > 0) const hasToken = computed(() => tokenDisplay.value.length > 0)
const canRegenerate = computed(() => !settings.value?.envTokenOverride && !loading.value) const canRegenerate = computed(() =>
!settings.value?.envTokenOverride && !loading.value
)
const authHeader = computed(() => const authHeader = computed(() =>
hasToken.value ? tokenDisplay.value : 'YOUR-TOKEN' hasToken.value ? tokenDisplay.value : 'YOUR-TOKEN'
) )
const authHeaderFlag = computed(() => `-H 'Auth-Token: ${authHeader.value}'`) const apiDocs = computed(() =>
buildApiDocs({
baseUrl: baseUrl.value,
authHeader: authHeader.value
})
)
const apiDocs = computed<ApiDocItem[]>(() => { const activeEndpoint = computed(() =>
const uploadDescription = isAdmin.value apiDocs.value.find(endpoint => endpoint.id === activeEndpointId.value) ?? apiDocs.value[0]
? t('api.docs.upload.descAdmin') )
: t('api.docs.upload.descUser')
const uploadCurl = isAdmin.value
? `curl -X POST "${baseUrl.value}/api/images/upload" \\
${authHeaderFlag.value} \\
-F "image=@./demo.png"
${t('api.docs.upload.curlCustomFolder')}
curl -X POST "${baseUrl.value}/api/images/upload" \\
${authHeaderFlag.value} \\
-F "folder=blog" \\
-F "image=@./demo.png"`
: `curl -X POST "${baseUrl.value}/api/images/upload" \\
${authHeaderFlag.value} \\
-F "image=@./demo.png"`
const listDescription = isAdmin.value
? t('api.docs.list.descAdmin')
: t('api.docs.list.descUser')
const listCurl = isAdmin.value
? `curl "${baseUrl.value}/api/images?limit=20&page=1" \\
${authHeaderFlag.value}
${t('api.docs.list.curlFolderFilter')}
curl "${baseUrl.value}/api/images?folder=blog&limit=20&page=1" \\
${authHeaderFlag.value}`
: `curl "${baseUrl.value}/api/images?limit=20&page=1" \\
${authHeaderFlag.value}`
return [
{
index: 1,
method: 'POST',
path: '/api/images/upload',
title: t('api.docs.upload.title'),
description: uploadDescription,
curl: uploadCurl
},
{
index: 2,
method: 'GET',
path: '/api/images',
title: t('api.docs.list.title'),
description: listDescription,
curl: listCurl
},
{
index: 3,
method: 'GET',
path: '/api/images/search',
title: t('api.docs.search.title'),
description: t('api.docs.search.desc'),
curl: `curl "${baseUrl.value}/api/images/search?q=demo&limit=20&page=1" \\
${authHeaderFlag.value}`
},
{
index: 4,
method: 'DELETE',
path: '/api/images',
title: t('api.docs.delete.title'),
description: t('api.docs.delete.desc'),
curl: `curl -X DELETE "${baseUrl.value}/api/images?key=images/2026/08/xxxx.webp" \\
${authHeaderFlag.value}`
},
{
index: 5,
method: 'POST',
path: '/api/images/batch-delete',
title: t('api.docs.batchDelete.title'),
description: t('api.docs.batchDelete.desc'),
curl: `curl -X POST "${baseUrl.value}/api/images/batch-delete" \\
${authHeaderFlag.value} \\
-H "Content-Type: application/json" \\
-d '{"keys":["images/2026/08/a.webp","images/2026/08/b.webp"]}'`
}
]
})
const twikooDoc = computed(() => ({
method: 'POST' as const,
path: '/api/index.php',
title: t('api.docs.twikoo.title'),
description: t('api.docs.twikoo.desc'),
curl: `curl -X POST "${baseUrl.value}/api/index.php" \\
-F "token=${authHeader.value}" \\
-F "image=@./demo.png"`
}))
function tokenSourceLabel(source: SettingsResponse['tokenSource']) {
switch (source) {
case 'env':
return t('api.tokenSourceEnv')
case 'db':
return t('api.tokenSourceDb')
default:
return t('api.tokenSourceNone')
}
}
function methodBadgeColor(method: ApiDocItem['method']) {
switch (method) {
case 'GET':
return 'info' as const
case 'DELETE':
return 'error' as const
default:
return 'primary' as const
}
}
async function loadSettings() { async function loadSettings() {
loading.value = true loading.value = true
@@ -219,15 +110,6 @@ async function confirmRegenerate() {
} }
} }
async function copyCurl(text: string) {
try {
await navigator.clipboard.writeText(text)
toast.add({ title: t('copy.curl'), color: 'success' })
} catch {
toast.add({ title: t('copy.failed'), color: 'error' })
}
}
function closeConfirmRegenerate() { function closeConfirmRegenerate() {
confirmRegenerateOpen.value = false confirmRegenerateOpen.value = false
} }
@@ -279,194 +161,36 @@ watch(isAuthenticated, async (authed, prev) => {
<AppShell v-else> <AppShell v-else>
<section class="overflow-hidden rounded-2xl border border-default bg-elevated shadow-sm"> <section class="overflow-hidden rounded-2xl border border-default bg-elevated shadow-sm">
<div class="flex items-center gap-2 border-b border-default px-5 py-4 sm:px-6"> <div class="flex flex-col lg:flex-row lg:items-stretch">
<UIcon <ApiSidebar
name="i-lucide-key-round" :endpoints="apiDocs"
class="size-5 text-primary" :active-id="activeEndpointId"
/> @select="activeEndpointId = $event"
<h2 class="text-base font-semibold">
{{ t('api.tokenTitle') }}
</h2>
</div>
<div class="space-y-4 p-5 sm:p-6">
<UAlert
v-if="settings?.envTokenOverride"
color="warning"
variant="subtle"
icon="i-lucide-triangle-alert"
:title="t('api.envOverrideTitle')"
:description="t('api.envOverrideDesc')"
/> />
<UAlert <div class="flex min-w-0 flex-1 flex-col bg-default lg:border-r lg:border-default">
v-else-if="!isAdmin" <ApiTokenCard
color="info" :token="tokenDisplay"
variant="subtle"
icon="i-lucide-info"
:title="t('api.personalTitle')"
:description="t('api.personalDesc')"
/>
<UAlert
v-else-if="settings && !hasToken"
color="info"
variant="subtle"
icon="i-lucide-info"
:title="t('api.notConfiguredTitle')"
:description="t('api.notConfiguredDesc')"
/>
<div class="flex flex-col gap-2 sm:flex-row sm:items-stretch">
<UInput
:model-value="hasToken ? tokenDisplay : t('common.notConfigured')"
readonly
class="min-w-0 flex-1 font-mono text-xs sm:text-sm"
:loading="loading" :loading="loading"
:regenerating="regenerating"
:can-regenerate="canRegenerate"
:token-source="settings?.tokenSource"
:env-token-override="settings?.envTokenOverride"
:is-admin="isAdmin"
@regenerate="requestRegenerate"
/> />
<div class="flex gap-2">
<CopyButton <ApiDocView
:label="t('common.copy')" v-if="activeEndpoint"
:value="tokenDisplay" :endpoint="activeEndpoint"
/> />
<UButton
icon="i-lucide-refresh-cw"
:label="t('api.regenerate')"
color="warning"
variant="soft"
:loading="regenerating"
:disabled="!canRegenerate"
@click="requestRegenerate"
/>
</div>
</div> </div>
<p <ApiDebugger
v-if="settings" v-if="activeEndpoint"
class="text-xs text-muted" :endpoint="activeEndpoint"
> :token="tokenDisplay"
{{ tokenSourceLabel(settings.tokenSource) }}
· {{ t('api.tokenHeader') }}
<code class="font-mono">Auth-Token: &lt;token&gt;</code>
<template v-if="hasToken && !settings.envTokenOverride">
· {{ t('api.tokenInvalidate') }}
</template>
</p>
</div>
</section>
<section class="overflow-hidden rounded-2xl border border-default bg-elevated shadow-sm">
<div class="flex items-center gap-2 border-b border-default px-5 py-4 sm:px-6">
<UIcon
name="i-lucide-book-open"
class="size-5 text-primary"
/> />
<h2 class="text-base font-semibold">
{{ t('api.docsTitle') }}
</h2>
</div>
<div class="divide-y divide-default">
<article
v-for="doc in apiDocs"
:key="doc.index"
class="space-y-4 p-5 sm:p-6"
>
<div class="flex gap-3">
<span
class="flex size-7 shrink-0 items-center justify-center rounded-full bg-primary/10 text-sm font-semibold text-primary"
>
{{ doc.index }}
</span>
<div class="min-w-0 space-y-2">
<div class="flex flex-wrap items-center gap-2">
<UBadge
:color="methodBadgeColor(doc.method)"
variant="subtle"
size="sm"
>
{{ doc.method }}
</UBadge>
<code class="rounded-md border border-primary/20 bg-primary/10 px-2 py-0.5 font-mono text-sm font-medium text-primary">
{{ doc.path }}
</code>
<span class="text-sm font-medium">
{{ doc.title }}
</span>
</div>
<p class="text-sm leading-relaxed text-muted">
{{ doc.description }}
</p>
</div>
</div>
<div>
<p class="mb-2 text-xs font-medium text-muted">
{{ t('api.curlExample') }}
</p>
<div class="relative overflow-hidden rounded-xl bg-neutral-950">
<UButton
icon="i-lucide-copy"
:label="t('common.copy')"
size="xs"
variant="ghost"
color="neutral"
class="absolute top-2 right-2 z-10 text-neutral-300 hover:text-white"
@click="copyCurl(doc.curl)"
/>
<pre class="max-h-80 overflow-auto p-4 pt-10 text-xs leading-relaxed text-neutral-100"><code class="font-mono">{{ doc.curl }}</code></pre>
</div>
</div>
</article>
</div>
</section>
<section class="overflow-hidden rounded-2xl border border-dashed border-default bg-muted/20">
<div class="space-y-4 p-5 sm:p-6">
<div class="flex gap-3">
<UIcon
name="i-lucide-message-square"
class="mt-0.5 size-5 shrink-0 text-muted"
/>
<div class="min-w-0 space-y-2">
<div class="flex flex-wrap items-center gap-2">
<UBadge
color="neutral"
variant="subtle"
size="sm"
>
{{ twikooDoc.method }}
</UBadge>
<code class="rounded-md border border-primary/20 bg-primary/10 px-2 py-0.5 font-mono text-sm font-medium text-primary">
{{ twikooDoc.path }}
</code>
<span class="text-sm font-medium">
{{ twikooDoc.title }}
</span>
</div>
<p class="text-sm leading-relaxed text-muted">
{{ twikooDoc.description }}
</p>
</div>
</div>
<div>
<p class="mb-2 text-xs font-medium text-muted">
{{ t('api.curlExample') }}
</p>
<div class="relative overflow-hidden rounded-xl bg-neutral-950">
<UButton
icon="i-lucide-copy"
:label="t('common.copy')"
size="xs"
variant="ghost"
color="neutral"
class="absolute top-2 right-2 z-10 text-neutral-300 hover:text-white"
@click="copyCurl(twikooDoc.curl)"
/>
<pre class="overflow-auto p-4 pt-10 text-xs leading-relaxed text-neutral-100"><code class="font-mono">{{ twikooDoc.curl }}</code></pre>
</div>
</div>
</div> </div>
</section> </section>
</AppShell> </AppShell>
+493
View File
@@ -0,0 +1,493 @@
<script setup lang="ts">
import type { ImageItem } from '~/types/image'
interface StatsResponse {
uploadToday: number
uploadYesterday: number
uploadMonth: number
uploadLastMonth: number
deleteToday: number
deleteMonth: number
uploadTotal: number
deleteTotal: number
uploadBytesTotal: number
storedCount: number
userCount?: number
bySource: {
web: number
api: number
}
storageUsage: {
usedBytes: number
totalBytes: number | null
percent: number | null
}
}
const {
items,
page: galleryPage,
pageSize: galleryPageSize,
totalPages: galleryTotalPages,
total: galleryTotal,
loading: galleryLoading,
searchQuery,
activeSearch,
fetchList,
fetchSearch,
fetchTotal,
submitSearch,
goToPage,
setPageSize,
removeItems,
activeStorageBackend,
setActiveStorageBackend,
storageBackendOptions,
activeUploadSource,
setActiveUploadSource,
resetFilters,
loadStorageBackendOptions
} = useImageList()
const { isChecking, isAuthenticated, checkSession, handleAuthError, fetchStatus } = useAuth()
const toast = useToast()
const { t } = useI18n()
const stats = ref<StatsResponse | null>(null)
const statsLoading = ref(false)
const pageRefreshing = computed(() => statsLoading.value || galleryLoading.value)
const selectedKeys = ref<Set<string>>(new Set())
const deletingKeys = ref<Set<string>>(new Set())
const deleteModalOpen = ref(false)
const deleteTargetKeys = ref<string[]>([])
const batchDeleting = ref(false)
const showScrollTop = ref(false)
const previewOpen = ref(false)
const previewImage = ref<ImageItem | null>(null)
const viewMode = ref<'grid' | 'list'>('grid')
const currentUploadSource = ref('all')
const currentStorageBackend = ref('all')
const uploadSourceItems = computed(() => [
{ label: t('stats.filterSourceAll'), value: 'all' },
{ label: t('stats.sourceWeb'), value: 'web' },
{ label: t('stats.sourceApi'), value: 'api' }
])
const storageBackendItems = computed(() => [
{ label: t('stats.filterStorageAll'), value: 'all' },
...storageBackendOptions.value.map(backend => ({
label: backend.name,
value: backend.id
}))
])
const selectedCount = computed(() => selectedKeys.value.size)
function onWindowScroll() {
showScrollTop.value = window.scrollY > 400
}
function scrollToTop() {
window.scrollTo({ top: 0, behavior: 'smooth' })
}
async function fetchStats() {
stats.value = await $fetch<StatsResponse>('/api/stats', {
credentials: 'include'
})
}
async function refreshStats() {
statsLoading.value = true
try {
await fetchStats()
} catch (error: unknown) {
handleAuthError(error)
if (isAuthenticated.value) {
toast.add({ title: t('stats.loadFailed'), color: 'error' })
}
} finally {
statsLoading.value = false
}
}
async function reloadGallery() {
if (activeSearch.value) {
await fetchSearch(galleryPage.value)
} else {
await fetchList(galleryPage.value)
}
await fetchTotal()
}
async function refreshAll() {
if (!isAuthenticated.value) return
selectedKeys.value = new Set()
statsLoading.value = true
try {
await Promise.all([
fetchStats(),
reloadGallery(),
loadStorageBackendOptions()
])
currentUploadSource.value = activeUploadSource.value
currentStorageBackend.value = activeStorageBackend.value
} catch (error: unknown) {
handleAuthError(error)
if (isAuthenticated.value) {
toast.add({ title: t('stats.loadFailed'), color: 'error' })
}
} finally {
statsLoading.value = false
}
}
async function handleStorageBackendChange(backendId: string) {
if (!isAuthenticated.value) return
currentStorageBackend.value = backendId
selectedKeys.value = new Set()
try {
await setActiveStorageBackend(backendId)
} catch (error: unknown) {
handleAuthError(error)
}
}
async function handleUploadSourceChange(uploadSource: string) {
if (!isAuthenticated.value) return
currentUploadSource.value = uploadSource
selectedKeys.value = new Set()
try {
await setActiveUploadSource(uploadSource)
} catch (error: unknown) {
handleAuthError(error)
}
}
async function handleResetFilters() {
if (!isAuthenticated.value) return
selectedKeys.value = new Set()
currentUploadSource.value = 'all'
currentStorageBackend.value = 'all'
try {
await resetFilters()
} catch (error: unknown) {
handleAuthError(error)
}
}
function updateSelectedKeys(keys: Set<string>) {
selectedKeys.value = keys
}
async function handleSearch() {
if (!isAuthenticated.value) return
selectedKeys.value = new Set()
try {
await submitSearch()
} catch (error: unknown) {
handleAuthError(error)
}
}
function requestDelete(key: string) {
if (!isAuthenticated.value) return
previewOpen.value = false
previewImage.value = null
deleteTargetKeys.value = [key]
deleteModalOpen.value = true
}
function requestBatchDelete() {
if (!isAuthenticated.value) return
if (!selectedCount.value) {
toast.add({ title: t('stats.selectFirst'), color: 'warning' })
return
}
deleteTargetKeys.value = Array.from(selectedKeys.value)
deleteModalOpen.value = true
}
function clearSelection() {
selectedKeys.value = new Set()
}
async function confirmDelete() {
const keys = [...deleteTargetKeys.value]
if (!keys.length || !isAuthenticated.value) return
batchDeleting.value = true
for (const key of keys) {
deletingKeys.value.add(key)
}
const deleted: string[] = []
const failed: string[] = []
try {
const response = await $fetch<{
deleted: string[]
failed: Array<{ key: string }>
}>('/api/images/batch-delete', {
method: 'POST',
body: { keys }
})
deleted.push(...response.deleted)
failed.push(...response.failed.map(item => item.key))
if (deleted.length) {
removeItems(deleted)
for (const key of deleted) {
selectedKeys.value.delete(key)
}
selectedKeys.value = new Set(selectedKeys.value)
toast.add({
title: t('stats.deleted', { n: deleted.length }),
color: 'success'
})
await refreshStats()
await reloadGallery()
if (items.value.length === 0 && galleryPage.value > 1) {
await goToPage(galleryPage.value - 1)
}
}
if (failed.length) {
toast.add({
title: t('stats.deletePartialFail', { n: failed.length }),
color: 'error'
})
}
deleteModalOpen.value = false
deleteTargetKeys.value = []
} catch (error: unknown) {
handleAuthError(error)
if (isAuthenticated.value) {
toast.add({ title: t('stats.deleteFailed'), color: 'error' })
}
} finally {
batchDeleting.value = false
deletingKeys.value = new Set()
}
}
async function handleGalleryPageChange(targetPage: number) {
selectedKeys.value = new Set()
previewOpen.value = false
previewImage.value = null
try {
await goToPage(targetPage)
} catch (error: unknown) {
handleAuthError(error)
}
}
async function handleGalleryPageSizeChange(size: number) {
selectedKeys.value = new Set()
previewOpen.value = false
previewImage.value = null
try {
await setPageSize(size)
} catch (error: unknown) {
handleAuthError(error)
}
}
function openPreview(image: ImageItem) {
previewImage.value = image
previewOpen.value = true
}
function requestPreviewDelete() {
if (!previewImage.value) return
requestDelete(previewImage.value.key)
}
onMounted(async () => {
window.addEventListener('scroll', onWindowScroll, { passive: true })
const status = await fetchStatus()
if (!status.initialized && !status.legacyMode) {
await navigateTo('/setup')
return
}
await checkSession()
if (isAuthenticated.value) {
await refreshAll()
}
})
onUnmounted(() => {
window.removeEventListener('scroll', onWindowScroll)
})
watch(isAuthenticated, async (authed, prev) => {
if (authed && prev === false) {
await nextTick()
await refreshAll()
} else if (!authed) {
stats.value = null
items.value = []
selectedKeys.value = new Set()
}
})
</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('stats.title') }}
</h1>
<p class="text-sm text-muted">
{{ t('stats.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="pageRefreshing"
@click="refreshAll"
/>
</div>
<GalleryStatsOverview
:stats="stats"
:loading="statsLoading"
/>
<section class="space-y-3">
<div class="flex flex-wrap items-center justify-between gap-x-4 gap-y-2">
<GallerySelectionBar
:items="items"
:selected-keys="selectedKeys"
@update:selected-keys="updateSelectedKeys"
@clear="clearSelection"
/>
<GalleryFilterBar
v-model:search-query="searchQuery"
:storage-backend="currentStorageBackend"
:storage-backend-items="storageBackendItems"
:upload-source="currentUploadSource"
:upload-source-items="uploadSourceItems"
:view-mode="viewMode"
:selected-count="selectedCount"
:loading="galleryLoading"
:show-batch-delete="true"
@update:storage-backend="handleStorageBackendChange"
@update:upload-source="handleUploadSourceChange"
@update:view-mode="viewMode = $event"
@search="handleSearch"
@reset="handleResetFilters"
@batch-delete="requestBatchDelete"
/>
</div>
<div
v-if="galleryLoading"
:class="viewMode === 'grid'
? 'grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5'
: 'space-y-2'"
>
<USkeleton
v-for="n in 10"
:key="n"
:class="viewMode === 'grid' ? 'aspect-[4/3] rounded-lg' : 'h-16 rounded-lg'"
/>
</div>
<ImageGrid
v-else-if="viewMode === 'grid'"
gallery
:show-key="false"
show-storage
:items="items"
:selected-keys="selectedKeys"
:selectable="true"
:allow-delete="true"
@update:selected-keys="updateSelectedKeys"
@preview="openPreview"
@delete="(image) => requestDelete(image.key)"
/>
<GalleryImageList
v-else
show-storage
:items="items"
:selected-keys="selectedKeys"
:selectable="true"
:show-delete="true"
@update:selected-keys="updateSelectedKeys"
@preview="openPreview"
@delete="(image) => requestDelete(image.key)"
/>
<ImagePreviewModal
v-model:open="previewOpen"
:image="previewImage"
show-storage
:deleting="previewImage ? deletingKeys.has(previewImage.key) : false"
:allow-delete="true"
@delete="requestPreviewDelete"
/>
<PaginationBar
v-if="!galleryLoading && galleryTotal > 0"
:page="galleryPage"
:total-pages="galleryTotalPages"
:total="galleryTotal"
:page-size="galleryPageSize"
:unit="t('common.imagesUnit')"
:loading="galleryLoading"
@update:page="handleGalleryPageChange"
@update:page-size="handleGalleryPageSizeChange"
/>
</section>
<DeleteConfirmModal
v-model:open="deleteModalOpen"
:count="deleteTargetKeys.length"
:loading="batchDeleting"
@confirm="confirmDelete"
/>
<UButton
v-show="showScrollTop"
icon="i-lucide-arrow-up"
:aria-label="t('stats.scrollTop')"
color="primary"
class="fixed bottom-6 right-6 z-50 shadow-lg"
size="lg"
@click="scrollToTop"
/>
</AppShell>
</div>
</template>
+27 -31
View File
@@ -4,20 +4,19 @@ import type { ImageItem } from '~/types/image'
const { uploading, progressItems, uploadFiles } = useImageUpload() const { uploading, progressItems, uploadFiles } = useImageUpload()
const { const {
defaultFolder,
autoCopyMarkdown, autoCopyMarkdown,
copyFormat, copyFormat,
loadPreferences loadPreferences
} = useUploadPreferences() } = useUploadPreferences()
const { isChecking, isAuthenticated, checkSession, handleAuthError, fetchStatus, isAdmin } const { isChecking, isAuthenticated, checkSession, handleAuthError, fetchStatus } = useAuth()
= useAuth()
const toast = useToast() const toast = useToast()
const { t } = useI18n() const { t } = useI18n()
const folderOptions = ref<string[]>(['images'])
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>())
const previewOpen = ref(false)
const previewImage = ref<ImageItem | null>(null)
const showProgress = computed( const showProgress = computed(
() => () =>
@@ -30,22 +29,9 @@ const showProgress = computed(
) )
) )
async function loadFolders() {
const data = await $fetch<{ folders: string[] }>('/api/folders', {
credentials: 'include'
})
folderOptions.value = [...data.folders]
if (!folderOptions.value.includes(defaultFolder.value)) {
defaultFolder.value = folderOptions.value[0] ?? 'images'
}
}
async function loadPage() { async function loadPage() {
try { try {
loadPreferences() loadPreferences()
if (isAdmin.value) {
await loadFolders()
}
} catch (error: unknown) { } catch (error: unknown) {
handleAuthError(error) handleAuthError(error)
} }
@@ -112,20 +98,9 @@ async function handleUpload(files: File[]) {
} }
try { try {
const uploadTarget = isAdmin.value const result = await uploadFiles(files)
? (defaultFolder.value.trim() || 'images')
: 'images'
const result = await uploadFiles(files, uploadTarget, {
includeFolder: isAdmin.value
})
if (result?.items.length) { if (result?.items.length) {
sessionItems.value = [...result.items, ...sessionItems.value] sessionItems.value = [...result.items, ...sessionItems.value]
if (isAdmin.value) {
const folder = uploadTarget
if (!folderOptions.value.includes(folder)) {
folderOptions.value = [...folderOptions.value, folder]
}
}
if (autoCopyMarkdown.value && result.items[0]) { if (autoCopyMarkdown.value && result.items[0]) {
const first = result.items[0] const first = result.items[0]
const text const text
@@ -150,6 +125,10 @@ async function handleDelete(key: string) {
query: { key } query: { key }
}) })
sessionItems.value = sessionItems.value.filter(item => item.key !== key) sessionItems.value = sessionItems.value.filter(item => item.key !== key)
if (previewImage.value?.key === key) {
previewOpen.value = false
previewImage.value = null
}
toast.add({ title: t('stats.deletedSingle'), color: 'success' }) toast.add({ title: t('stats.deletedSingle'), color: 'success' })
} catch (error: unknown) { } catch (error: unknown) {
handleAuthError(error) handleAuthError(error)
@@ -159,6 +138,16 @@ async function handleDelete(key: string) {
deletingKeys.value = new Set(deletingKeys.value) deletingKeys.value = new Set(deletingKeys.value)
} }
} }
function openPreview(image: ImageItem) {
previewImage.value = image
previewOpen.value = true
}
function requestPreviewDelete() {
if (!previewImage.value) return
void handleDelete(previewImage.value.key)
}
</script> </script>
<template> <template>
@@ -205,14 +194,21 @@ async function handleDelete(key: string) {
<ImageGrid <ImageGrid
:items="sessionItems" :items="sessionItems"
:selected-keys="emptySelection" :selected-keys="emptySelection"
:deleting-keys="deletingKeys"
:selectable="false" :selectable="false"
:show-key="false" :show-key="false"
:empty-text="t('upload.sessionEmpty')" :empty-text="t('upload.sessionEmpty')"
@update:selected-keys="() => {}" @update:selected-keys="() => {}"
@delete="handleDelete" @preview="openPreview"
/> />
</section> </section>
<ImagePreviewModal
v-model:open="previewOpen"
:image="previewImage"
:deleting="previewImage ? deletingKeys.has(previewImage.key) : false"
:allow-delete="true"
@delete="requestPreviewDelete"
/>
</AppShell> </AppShell>
</div> </div>
</template> </template>
+2 -616
View File
@@ -1,621 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import type { ActivityLogListResponse, ActivityLogStorage, LogAction, LogSource } from '~/types/logs' await navigateTo({ path: '/settings', query: { tab: 'logs' } }, { replace: true })
const { isChecking, isAuthenticated, checkSession, handleAuthError, fetchStatus, isAdmin } = useAuth()
const toast = useToast()
const { t, locale } = useI18n()
const { formatFileSize } = useFileSize()
const loading = ref(true)
const page = ref(1)
const actionFilter = ref<'all' | LogAction>('all')
const sourceFilter = ref<'all' | LogSource>('all')
const userFilter = ref<'all' | number>('all')
const searchQuery = ref('')
const activeSearch = ref('')
const listData = ref<ActivityLogListResponse | null>(null)
const imageLinkBase = ref('')
const hideFolderInUrl = ref(false)
const pageSize = 10
const summaryLabel = computed(() => {
const summary = listData.value?.summary
if (!summary) return ''
return t('logs.summaryAll', {
total: summary.total,
upload: summary.upload,
delete: summary.delete
})
})
const actionItems = computed(() => [
{ label: t('logs.filterActionAll'), value: 'all' as const },
{ label: t('logs.actionUpload'), value: 'upload' as const },
{ label: t('logs.actionDelete'), value: 'delete' as const }
])
const sourceItems = computed(() => [
{ label: t('logs.filterSourceAll'), value: 'all' as const },
{ label: t('logs.sourceWeb'), value: 'web' as const },
{ label: t('logs.sourceApi'), value: 'api' as const },
{ label: t('logs.sourceTwikoo'), value: 'twikoo' as const }
])
const userItems = computed(() => {
const items: Array<{ label: string, value: 'all' | number }> = [
{ label: t('logs.filterUserAll'), value: 'all' }
]
for (const user of listData.value?.users ?? []) {
items.push({ label: user.username, value: user.id })
}
return items
})
function formatTime(value: string) {
try {
return new Date(value).toLocaleString(locale.value, { hour12: false })
} catch {
return value
}
}
function formatTimeShort(value: string) {
try {
return new Date(value).toLocaleString(locale.value, {
month: 'numeric',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
hour12: false
})
} catch {
return value
}
}
function folderFromKey(key: string) {
const slash = key.indexOf('/')
return slash > 0 ? key.slice(0, slash) : '(root)'
}
function actionColor(action: LogAction) {
return action === 'upload' ? 'success' : 'error'
}
function actionIcon(action: LogAction) {
return action === 'upload' ? 'i-lucide-upload' : 'i-lucide-trash-2'
}
function sourceLabel(source: LogSource) {
switch (source) {
case 'web':
return t('logs.sourceWeb')
case 'api':
return t('logs.sourceApi')
case 'twikoo':
return t('logs.sourceTwikoo')
}
}
function storageIcon(storage: ActivityLogStorage | null) {
return storage?.type === 'local' ? 'i-lucide-hard-drive' : 'i-lucide-cloud'
}
function publicPathFromKey(key: string) {
if (!hideFolderInUrl.value) return key
const slash = key.indexOf('/')
return slash > 0 ? key.slice(slash + 1) : key
}
function imageUrl(key: string) {
const base = imageLinkBase.value.replace(/\/$/, '')
const path = publicPathFromKey(key)
if (!base) return `/${path}`
return `${base}/${path}`
}
async function loadImageLinkBase() {
try {
if (isAdmin.value) {
const data = await $fetch<{ imageBaseUrl: string, hideFolderInUrl: boolean }>('/api/settings', {
credentials: 'include'
})
imageLinkBase.value = data.imageBaseUrl
hideFolderInUrl.value = data.hideFolderInUrl
return
}
const data = await $fetch<{ env: { imageBaseUrl: string, hideFolderInUrl: boolean } }>('/api/user/api-token', {
credentials: 'include'
})
imageLinkBase.value = data.env.imageBaseUrl
hideFolderInUrl.value = data.env.hideFolderInUrl
} catch {
if (import.meta.client) {
imageLinkBase.value = window.location.origin
}
}
}
async function loadLogs() {
loading.value = true
try {
const query: Record<string, string | number> = {
page: page.value,
limit: pageSize
}
if (actionFilter.value !== 'all') {
query.action = actionFilter.value
}
if (sourceFilter.value !== 'all') {
query.source = sourceFilter.value
}
if (isAdmin.value && userFilter.value !== 'all') {
query.userId = userFilter.value
}
if (activeSearch.value) {
query.q = activeSearch.value
}
listData.value = await $fetch<ActivityLogListResponse>('/api/logs', {
credentials: 'include',
query
})
page.value = listData.value.page
} catch (error: unknown) {
handleAuthError(error)
if (isAuthenticated.value) {
toast.add({ title: t('logs.loadFailed'), color: 'error' })
}
} finally {
loading.value = false
}
}
function applyFilters() {
page.value = 1
void loadLogs()
}
function submitSearch() {
activeSearch.value = searchQuery.value.trim()
applyFilters()
}
function clearSearch() {
searchQuery.value = ''
activeSearch.value = ''
applyFilters()
}
function handlePageChange(target: number) {
page.value = target
void loadLogs()
}
onMounted(async () => {
const status = await fetchStatus()
if (!status.initialized && !status.legacyMode) {
await navigateTo('/setup')
return
}
await checkSession()
if (!isAuthenticated.value) {
await navigateTo('/')
return
}
await loadImageLinkBase()
await loadLogs()
})
watch(isAuthenticated, async (authed, prev) => {
if (authed && prev === false) {
await loadImageLinkBase()
await loadLogs()
}
})
</script> </script>
<template> <template>
<div class="min-h-screen"> <div />
<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>
<section class="min-w-0 overflow-x-hidden rounded-2xl border border-default bg-elevated shadow-sm">
<div class="flex flex-wrap items-start justify-between gap-3 border-b border-default px-4 py-4 sm:px-6">
<div class="flex items-start gap-2">
<UIcon
name="i-lucide-scroll-text"
class="mt-0.5 size-5 shrink-0 text-primary"
/>
<div>
<h1 class="text-base font-semibold">
{{ t('logs.pageTitle') }}
</h1>
<p class="mt-0.5 text-xs text-muted">
{{ isAdmin ? t('logs.pageSubtitleAdmin') : t('logs.pageSubtitle') }}
</p>
</div>
</div>
<UButton
icon="i-lucide-refresh-cw"
variant="ghost"
color="neutral"
size="sm"
:aria-label="t('common.refresh')"
:loading="loading"
@click="loadLogs"
/>
</div>
<form
class="border-b border-default px-4 py-3 sm:px-6"
@submit.prevent="submitSearch"
>
<div class="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<p
v-if="summaryLabel"
class="text-xs text-muted"
>
{{ summaryLabel }}
</p>
<p
v-else
class="text-xs text-muted"
>
{{ t('stats.counting') }}
</p>
<div class="flex min-w-0 flex-wrap items-center gap-2 sm:justify-end">
<USelect
v-if="isAdmin"
v-model="userFilter"
:items="userItems"
value-key="value"
class="min-w-0 w-full sm:w-36"
size="sm"
@update:model-value="applyFilters"
/>
<USelect
v-model="actionFilter"
:items="actionItems"
value-key="value"
class="min-w-0 w-[calc(50%-0.25rem)] sm:w-32"
size="sm"
@update:model-value="applyFilters"
/>
<USelect
v-model="sourceFilter"
:items="sourceItems"
value-key="value"
class="min-w-0 w-[calc(50%-0.25rem)] sm:w-36"
size="sm"
@update:model-value="applyFilters"
/>
<div class="flex min-w-0 w-full items-center gap-2 sm:w-auto">
<UInput
v-model="searchQuery"
icon="i-lucide-search"
:placeholder="t('logs.searchPlaceholder')"
class="min-w-0 flex-1 sm:w-40 sm:flex-none"
size="sm"
:disabled="loading"
/>
<UButton
type="submit"
icon="i-lucide-search"
size="sm"
class="shrink-0"
:aria-label="t('common.search')"
:loading="loading"
>
<span class="hidden sm:inline">{{ t('common.search') }}</span>
</UButton>
<UButton
v-if="activeSearch"
type="button"
icon="i-lucide-x"
size="sm"
variant="ghost"
color="neutral"
class="shrink-0"
:aria-label="t('stats.clear')"
@click="clearSearch"
>
<span class="hidden sm:inline">{{ t('stats.clear') }}</span>
</UButton>
</div>
</div>
</div>
</form>
<div
v-if="loading"
class="p-4 sm:p-6"
>
<div class="space-y-3 sm:hidden">
<USkeleton
v-for="n in 6"
:key="`mobile-${n}`"
class="h-24 rounded-lg"
/>
</div>
<div class="hidden space-y-2 sm:block">
<USkeleton
v-for="n in 8"
:key="`desktop-${n}`"
class="h-12 rounded-lg"
/>
</div>
</div>
<template v-else-if="listData && listData.items.length">
<div class="min-w-0 sm:hidden">
<div class="divide-y divide-default">
<article
v-for="row in listData.items"
:key="row.id"
class="min-w-0 px-4 py-3.5"
>
<div class="flex items-start justify-between gap-3">
<time class="text-xs text-muted">
{{ formatTimeShort(row.createdAt) }}
</time>
<span class="shrink-0 text-xs tabular-nums text-muted">
{{ formatFileSize(row.size) }}
</span>
</div>
<div class="mt-2 flex min-w-0 max-w-full flex-wrap items-center gap-1.5">
<UBadge
:color="actionColor(row.action)"
variant="subtle"
size="xs"
>
<UIcon
:name="actionIcon(row.action)"
class="mr-1 size-3"
/>
{{ row.action === 'upload' ? t('logs.actionUpload') : t('logs.actionDelete') }}
</UBadge>
<UBadge
color="neutral"
variant="subtle"
size="xs"
>
{{ sourceLabel(row.source) }}
</UBadge>
<span
v-if="isAdmin"
class="text-xs text-muted"
>
{{ row.username ?? '—' }}
</span>
<UBadge
v-if="row.storage"
color="neutral"
variant="subtle"
size="xs"
class="max-w-full truncate"
>
<UIcon
:name="storageIcon(row.storage)"
class="mr-1 size-3"
/>
{{ row.storage.name }}
</UBadge>
</div>
<p
class="mt-2 truncate text-sm"
:title="row.originalName"
>
{{ row.originalName }}
</p>
<div class="mt-2 flex min-w-0 items-center gap-1">
<a
v-if="row.action === 'upload'"
:href="imageUrl(row.key)"
target="_blank"
rel="noopener noreferrer"
class="min-w-0 flex-1 truncate font-mono text-xs text-primary hover:underline"
:title="row.key"
>
{{ row.key }}
</a>
<span
v-else
class="min-w-0 flex-1 truncate font-mono text-xs text-dimmed"
:title="row.key"
>
{{ row.key }}
</span>
<CopyButton
icon="i-lucide-copy"
icon-only
class="shrink-0"
:label="t('common.copy')"
:value="row.key"
:success-title="t('copy.copiedUrl')"
/>
</div>
</article>
</div>
</div>
<div class="hidden overflow-x-auto sm:block">
<table class="min-w-full text-left text-sm">
<thead class="border-b border-default bg-muted/30 text-xs text-muted">
<tr>
<th class="px-4 py-3 font-medium sm:px-6">
{{ t('logs.colTime') }}
</th>
<th
v-if="isAdmin"
class="px-4 py-3 font-medium"
>
{{ t('logs.colUser') }}
</th>
<th class="px-4 py-3 font-medium">
{{ t('logs.colAction') }}
</th>
<th class="px-4 py-3 font-medium">
{{ t('logs.colSource') }}
</th>
<th class="px-4 py-3 font-medium">
{{ t('logs.colStorage') }}
</th>
<th class="hidden px-4 py-3 font-medium md:table-cell">
{{ t('logs.colFile') }}
</th>
<th class="hidden px-4 py-3 font-medium lg:table-cell">
{{ t('logs.colFolder') }}
</th>
<th class="px-4 py-3 font-medium">
{{ t('logs.colSize') }}
</th>
<th class="hidden px-4 py-3 font-medium xl:table-cell">
{{ t('logs.colKey') }}
</th>
</tr>
</thead>
<tbody class="divide-y divide-default">
<tr
v-for="row in listData.items"
:key="row.id"
class="hover:bg-muted/20"
>
<td class="whitespace-nowrap px-4 py-3 text-xs text-muted sm:px-6">
{{ formatTime(row.createdAt) }}
</td>
<td
v-if="isAdmin"
class="px-4 py-3 text-xs"
>
{{ row.username ?? '—' }}
</td>
<td class="px-4 py-3">
<UBadge
:color="actionColor(row.action)"
variant="subtle"
size="xs"
>
<UIcon
:name="actionIcon(row.action)"
class="mr-1 size-3"
/>
{{ row.action === 'upload' ? t('logs.actionUpload') : t('logs.actionDelete') }}
</UBadge>
</td>
<td class="px-4 py-3">
<UBadge
color="neutral"
variant="subtle"
size="xs"
>
{{ sourceLabel(row.source) }}
</UBadge>
</td>
<td class="px-4 py-3 text-xs">
<span
v-if="row.storage"
class="inline-flex max-w-[8rem] items-center gap-1 truncate"
:title="row.storage.name"
>
<UIcon
:name="storageIcon(row.storage)"
class="size-3.5 shrink-0 opacity-70"
/>
<span class="truncate">{{ row.storage.name }}</span>
</span>
<span
v-else
class="text-muted"
>—</span>
</td>
<td
class="hidden max-w-[12rem] truncate px-4 py-3 md:table-cell"
:title="row.originalName"
>
{{ row.originalName }}
</td>
<td class="hidden px-4 py-3 font-mono text-xs text-muted lg:table-cell">
{{ folderFromKey(row.key) }}
</td>
<td class="whitespace-nowrap px-4 py-3 tabular-nums text-muted">
{{ formatFileSize(row.size) }}
</td>
<td class="hidden px-4 py-3 xl:table-cell">
<div class="flex max-w-[16rem] items-center gap-1">
<a
v-if="row.action === 'upload'"
:href="imageUrl(row.key)"
target="_blank"
rel="noopener noreferrer"
class="truncate font-mono text-xs text-primary hover:underline"
:title="row.key"
>
{{ row.key }}
</a>
<span
v-else
class="truncate font-mono text-xs text-dimmed"
:title="row.key"
>
{{ row.key }}
</span>
<CopyButton
icon="i-lucide-copy"
icon-only
:label="t('common.copy')"
:value="row.key"
:success-title="t('copy.copiedUrl')"
/>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</template>
<div
v-else
class="px-4 py-16 text-center text-sm text-muted sm:px-6"
>
{{ t('logs.empty') }}
</div>
<div
v-if="listData && listData.total > 0"
class="border-t border-default px-4 py-4 sm:px-6"
>
<PaginationBar
:page="listData.page"
:total-pages="listData.totalPages"
:total="listData.total"
:unit="t('logs.entriesUnit')"
:loading="loading"
@update:page="handlePageChange"
/>
</div>
</section>
</AppShell>
</div>
</template> </template>
+39 -4
View File
@@ -2,9 +2,10 @@
import logoLight from '~/assets/image/logo-light.png' import logoLight from '~/assets/image/logo-light.png'
import logoDark from '~/assets/image/logo-dark.png' import logoDark from '~/assets/image/logo-dark.png'
import { isPasswordValid } from '~/utils/password-strength' import { isPasswordValid } from '~/utils/password-strength'
import type { LoginVerificationPayload } from '~/types/auth'
const router = useRouter() const router = useRouter()
const { register, fetchStatus } = useAuth() const { register, fetchStatus, authStatus } = useAuth()
const toast = useToast() const toast = useToast()
const { t } = useI18n() const { t } = useI18n()
@@ -12,14 +13,34 @@ const username = ref('')
const password = ref('') const password = ref('')
const confirmPassword = ref('') const confirmPassword = ref('')
const loading = ref(false) const loading = ref(false)
const verificationRef = ref<{ reset: () => Promise<void> } | null>(null)
const verificationPayload = ref<LoginVerificationPayload | null>(null)
const loginVerification = computed(() =>
authStatus.value?.loginVerification ?? { method: 'slider' as const }
)
const canSubmit = computed(() => const canSubmit = computed(() =>
Boolean(username.value && password.value && confirmPassword.value) Boolean(verificationPayload.value)
&& Boolean(username.value && password.value && confirmPassword.value)
&& password.value === confirmPassword.value && password.value === confirmPassword.value
&& isPasswordValid(password.value) && isPasswordValid(password.value)
&& AUTH_USERNAME_PATTERN.test(username.value.trim()) && AUTH_USERNAME_PATTERN.test(username.value.trim())
) )
function onVerificationVerified(payload: LoginVerificationPayload) {
verificationPayload.value = payload
}
function onVerificationCleared() {
verificationPayload.value = null
}
async function resetVerification() {
verificationPayload.value = null
await verificationRef.value?.reset()
}
onMounted(async () => { onMounted(async () => {
const status = await fetchStatus() const status = await fetchStatus()
if (!status.initialized && !status.legacyMode) { if (!status.initialized && !status.legacyMode) {
@@ -32,7 +53,7 @@ onMounted(async () => {
}) })
async function submit() { async function submit() {
if (!canSubmit.value || loading.value) return if (!canSubmit.value || loading.value || !verificationPayload.value) return
if (password.value !== confirmPassword.value) { if (password.value !== confirmPassword.value) {
toast.add({ title: t('auth.passwordMismatch'), color: 'error' }) toast.add({ title: t('auth.passwordMismatch'), color: 'error' })
@@ -41,11 +62,16 @@ async function submit() {
loading.value = true loading.value = true
try { try {
const result = await register(username.value.trim(), password.value) const result = await register(
username.value.trim(),
password.value,
verificationPayload.value
)
if (result.ok) { if (result.ok) {
toast.add({ title: t('auth.registerSuccess'), color: 'success' }) toast.add({ title: t('auth.registerSuccess'), color: 'success' })
await router.replace('/') await router.replace('/')
} else { } else {
await resetVerification()
toast.add({ title: result.error, color: 'error' }) toast.add({ title: result.error, color: 'error' })
} }
} finally { } finally {
@@ -142,6 +168,15 @@ async function submit() {
/> />
</div> </div>
<LoginVerification
ref="verificationRef"
:method="loginVerification.method"
:turnstile-site-key="loginVerification.turnstileSiteKey"
:cap-api-endpoint="loginVerification.capApiEndpoint"
@verified="onVerificationVerified"
@cleared="onVerificationCleared"
/>
<UButton <UButton
type="submit" type="submit"
:label="t('auth.register')" :label="t('auth.register')"
File diff suppressed because it is too large. Load diff
+13 -6
View File
@@ -7,7 +7,13 @@ const route = useRoute()
const router = useRouter() const router = useRouter()
const { setup, migrate, fetchStatus } = useAuth() const { setup, migrate, fetchStatus } = useAuth()
const toast = useToast() const toast = useToast()
const { t } = useI18n() const { t, locale } = useI18n()
const domainSeparationDocUrl = computed(() =>
locale.value === 'en'
? 'https://o96u.github.io/PicHost/en/guide/domain-separation'
: 'https://o96u.github.io/PicHost/guide/domain-separation'
)
const needsMigration = ref(false) const needsMigration = ref(false)
const isMigrate = computed(() => needsMigration.value || route.query.migrate === '1') const isMigrate = computed(() => needsMigration.value || route.query.migrate === '1')
@@ -43,11 +49,12 @@ onMounted(async () => {
await router.replace('/') await router.replace('/')
return return
} }
if (import.meta.client) {
siteBaseUrl.value = window.location.origin
}
}) })
const detectedOriginPlaceholder = computed(() =>
import.meta.client ? window.location.origin : ''
)
async function submit() { async function submit() {
if (!canSubmit.value || loading.value) return if (!canSubmit.value || loading.value) return
@@ -196,7 +203,7 @@ async function submit() {
<p class="text-xs leading-relaxed text-warning"> <p class="text-xs leading-relaxed text-warning">
{{ t('setup.domainSeparationProxyHint') }} {{ t('setup.domainSeparationProxyHint') }}
<a <a
href="https://github.com/O96u/PicHost/blob/main/docs/domain-separation.md" :href="domainSeparationDocUrl"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
class="text-primary hover:underline" class="text-primary hover:underline"
@@ -211,7 +218,7 @@ async function submit() {
</p> </p>
<UInput <UInput
v-model="siteBaseUrl" v-model="siteBaseUrl"
:placeholder="t('setup.siteBaseUrlPlaceholder')" :placeholder="detectedOriginPlaceholder || t('setup.siteBaseUrlPlaceholder')"
class="w-full font-mono text-sm" class="w-full font-mono text-sm"
/> />
</div> </div>
-617
View File
@@ -1,617 +0,0 @@
<script setup lang="ts">
interface StatsResponse {
uploadToday: number
uploadMonth: number
deleteToday: number
deleteMonth: number
uploadTotal: number
deleteTotal: number
uploadBytesTotal: number
storedCount: number
userCount?: number
bySource: {
web: number
api: number
twikoo: number
}
byFolder: Array<{
folder: string
count: number
bytes: number
}>
byFolderUploads: Array<{
folder: string
count: number
}>
}
const {
items,
page: galleryPage,
totalPages: galleryTotalPages,
total: galleryTotal,
loading: galleryLoading,
searchQuery,
activeSearch,
listSummary,
refreshList,
fetchList,
fetchSearch,
fetchTotal,
setActiveFolder,
submitSearch,
goToPage,
removeItems,
activeStorageBackend,
storageBackendOptions,
setActiveStorageBackend,
loadStorageBackendOptions
} = useImageList()
const { formatFileSize } = useFileSize()
const { isChecking, isAuthenticated, checkSession, handleAuthError, fetchStatus, isAdmin } = useAuth()
const toast = useToast()
const { t } = useI18n()
const stats = ref<StatsResponse | null>(null)
const statsLoading = ref(false)
const pageRefreshing = computed(() => statsLoading.value || galleryLoading.value)
const selectedKeys = ref<Set<string>>(new Set())
const deletingKeys = ref<Set<string>>(new Set())
const deleteModalOpen = ref(false)
const deleteTargetKeys = ref<string[]>([])
const batchDeleting = ref(false)
const showScrollTop = ref(false)
const currentFolder = ref('all')
const currentStorage = ref('all')
const folderOptions = ref<string[]>(['images'])
const folderSelectItems = computed(() => [
{ label: t('stats.allFolders'), value: 'all' },
...folderOptions.value.map(folder => ({ label: folder, value: folder }))
])
const storageSelectItems = computed(() => [
{ label: t('stats.filterStorageAll'), value: 'all' },
...storageBackendOptions.value.map(backend => ({
label: backend.name,
value: backend.id
}))
])
const selectedCount = computed(() => selectedKeys.value.size)
const FOLDER_CHART_COLORS = [
'var(--ui-primary)',
'#10b981',
'#f59e0b',
'#8b5cf6',
'#ec4899',
'#06b6d4'
]
const folderUploadItems = computed(() =>
(stats.value?.byFolderUploads ?? []).map((item, index) => ({
key: item.folder,
label: item.folder,
count: item.count,
color: FOLDER_CHART_COLORS[index % FOLDER_CHART_COLORS.length]!
}))
)
const donutSegments = computed(() =>
folderUploadItems.value.map(item => ({
label: item.label,
value: item.count,
color: item.color
}))
)
const folderStats = computed(() => stats.value?.byFolder ?? [])
const folderMaxBytes = computed(() =>
Math.max(...folderStats.value.map(item => item.bytes), 1)
)
const uploadChartTotal = computed(() =>
folderUploadItems.value.reduce((sum, item) => sum + item.count, 0)
)
const statCards = computed(() => {
const s = stats.value
const base = [
{ label: t('stats.uploadToday'), value: s?.uploadToday ?? '—' },
{ label: t('stats.uploadMonth'), value: s?.uploadMonth ?? '—' },
{ label: t('stats.uploadTotal'), value: s?.uploadTotal ?? '—' },
{ label: t('stats.storedCount'), value: s?.storedCount ?? '—' },
{ label: t('stats.deleteToday'), value: s?.deleteToday ?? '—' },
{ label: t('stats.deleteMonth'), value: s?.deleteMonth ?? '—' },
isAdmin.value
? { label: t('stats.userCount'), value: s?.userCount ?? '—' }
: { label: t('stats.deleteTotal'), value: s?.deleteTotal ?? '—' },
{ label: t('stats.uploadBytes'), value: s ? formatFileSize(s.uploadBytesTotal) : '—' }
]
return base
})
function onWindowScroll() {
showScrollTop.value = window.scrollY > 400
}
function scrollToTop() {
window.scrollTo({ top: 0, behavior: 'smooth' })
}
function folderPercent(bytes: number) {
return Math.round((bytes / folderMaxBytes.value) * 100)
}
async function loadFolders() {
const data = await $fetch<{ folders: string[] }>('/api/folders', {
credentials: 'include'
})
folderOptions.value = [...data.folders]
}
async function fetchStats() {
stats.value = await $fetch<StatsResponse>('/api/stats', {
credentials: 'include'
})
}
async function refreshStats() {
statsLoading.value = true
try {
await fetchStats()
} catch (error: unknown) {
handleAuthError(error)
if (isAuthenticated.value) {
toast.add({ title: t('stats.loadFailed'), color: 'error' })
}
} finally {
statsLoading.value = false
}
}
async function refreshGallery() {
if (!isAuthenticated.value) return
selectedKeys.value = new Set()
try {
await Promise.all([refreshList(), loadFolders(), loadStorageBackendOptions()])
currentStorage.value = activeStorageBackend.value
} catch (error: unknown) {
handleAuthError(error)
}
}
async function reloadGallery() {
if (activeSearch.value) {
await fetchSearch(galleryPage.value)
} else {
await fetchList(galleryPage.value)
}
await fetchTotal()
}
async function refreshAll() {
if (!isAuthenticated.value) return
selectedKeys.value = new Set()
statsLoading.value = true
try {
await Promise.all([
fetchStats(),
reloadGallery(),
loadFolders(),
loadStorageBackendOptions()
])
currentStorage.value = activeStorageBackend.value
} catch (error: unknown) {
handleAuthError(error)
if (isAuthenticated.value) {
toast.add({ title: t('stats.loadFailed'), color: 'error' })
}
} finally {
statsLoading.value = false
}
}
async function handleFolderChange(folder: string) {
if (!isAuthenticated.value) return
currentFolder.value = folder
selectedKeys.value = new Set()
try {
await setActiveFolder(folder)
} catch (error: unknown) {
handleAuthError(error)
}
}
async function handleStorageChange(backendId: string) {
if (!isAuthenticated.value) return
currentStorage.value = backendId
selectedKeys.value = new Set()
try {
await setActiveStorageBackend(backendId)
} catch (error: unknown) {
handleAuthError(error)
}
}
async function handleSearch() {
if (!isAuthenticated.value) return
selectedKeys.value = new Set()
try {
await submitSearch()
} catch (error: unknown) {
handleAuthError(error)
}
}
async function clearSearch() {
searchQuery.value = ''
if (!activeSearch.value) return
await refreshGallery()
}
function requestDelete(key: string) {
if (!isAuthenticated.value) return
deleteTargetKeys.value = [key]
deleteModalOpen.value = true
}
function requestBatchDelete() {
if (!isAuthenticated.value) return
if (!selectedCount.value) {
toast.add({ title: t('stats.selectFirst'), color: 'warning' })
return
}
deleteTargetKeys.value = Array.from(selectedKeys.value)
deleteModalOpen.value = true
}
async function confirmDelete() {
const keys = [...deleteTargetKeys.value]
if (!keys.length || !isAuthenticated.value) return
batchDeleting.value = true
for (const key of keys) {
deletingKeys.value.add(key)
}
const deleted: string[] = []
const failed: string[] = []
try {
const response = await $fetch<{
deleted: string[]
failed: Array<{ key: string }>
}>('/api/images/batch-delete', {
method: 'POST',
body: { keys }
})
deleted.push(...response.deleted)
failed.push(...response.failed.map(item => item.key))
if (deleted.length) {
removeItems(deleted)
for (const key of deleted) {
selectedKeys.value.delete(key)
}
selectedKeys.value = new Set(selectedKeys.value)
toast.add({
title: t('stats.deleted', { n: deleted.length }),
color: 'success'
})
await refreshStats()
}
if (failed.length) {
toast.add({
title: t('stats.deletePartialFail', { n: failed.length }),
color: 'error'
})
}
deleteModalOpen.value = false
deleteTargetKeys.value = []
} catch (error: unknown) {
handleAuthError(error)
if (isAuthenticated.value) {
toast.add({ title: t('stats.deleteFailed'), color: 'error' })
}
} finally {
batchDeleting.value = false
deletingKeys.value = new Set()
}
}
async function handleGalleryPageChange(targetPage: number) {
selectedKeys.value = new Set()
try {
await goToPage(targetPage)
} catch (error: unknown) {
handleAuthError(error)
}
}
onMounted(async () => {
window.addEventListener('scroll', onWindowScroll, { passive: true })
const status = await fetchStatus()
if (!status.initialized && !status.legacyMode) {
await navigateTo('/setup')
return
}
await checkSession()
if (isAuthenticated.value) {
await refreshAll()
}
})
onUnmounted(() => {
window.removeEventListener('scroll', onWindowScroll)
})
watch(isAuthenticated, async (authed, prev) => {
if (authed && prev === false) {
await nextTick()
await refreshAll()
} else if (!authed) {
stats.value = null
items.value = []
selectedKeys.value = new Set()
}
})
</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('stats.title') }}
</h1>
<p class="text-sm text-muted">
{{ t('stats.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="pageRefreshing"
@click="refreshAll"
/>
</div>
<section class="grid grid-cols-2 gap-3 lg:grid-cols-4">
<div
v-for="item in statCards"
:key="item.label"
class="rounded-xl border border-default bg-elevated p-4"
>
<p class="text-xs text-muted">
{{ item.label }}
</p>
<p class="mt-1 text-2xl font-semibold tabular-nums">
{{ item.value }}
</p>
</div>
</section>
<section class="grid gap-4 lg:grid-cols-2">
<div class="rounded-xl border border-default bg-elevated p-4">
<h2 class="mb-3 text-sm font-medium text-muted">
{{ t('stats.folderDistribution') }}
</h2>
<div
v-if="!uploadChartTotal"
class="py-6 text-center text-xs text-muted"
>
{{ t('stats.noData') }}
</div>
<StatsDonut
v-else
:segments="donutSegments"
:size="88"
/>
</div>
<div class="rounded-xl border border-default bg-elevated p-4">
<h2 class="mb-3 text-sm font-medium text-muted">
{{ t('stats.folderUsage') }}
</h2>
<div
v-if="!folderStats.length"
class="py-6 text-center text-xs text-muted"
>
{{ t('stats.noData') }}
</div>
<ul
v-else
class="space-y-2.5"
>
<li
v-for="item in folderStats"
:key="item.folder"
>
<div class="mb-1 flex items-center justify-between gap-2 text-xs">
<span class="font-medium">{{ item.folder }}</span>
<span class="tabular-nums text-muted">
{{ t('stats.folderCount', { count: item.count, size: formatFileSize(item.bytes) }) }}
</span>
</div>
<div class="h-1.5 overflow-hidden rounded-full bg-muted/50">
<div
class="h-full rounded-full bg-primary/70"
:style="{ width: `${Math.max(folderPercent(item.bytes), item.bytes ? 4 : 0)}%` }"
/>
</div>
</li>
</ul>
</div>
</section>
<section class="space-y-4">
<div class="flex flex-wrap items-center justify-between gap-x-4 gap-y-3">
<div class="min-w-0">
<h2 class="text-lg font-medium">
{{ t('stats.gallery') }}
</h2>
<p
v-if="!galleryLoading"
class="mt-0.5 text-sm text-muted"
>
{{ listSummary }}
</p>
</div>
<form
class="flex flex-wrap items-center justify-end gap-2"
@submit.prevent="handleSearch"
>
<UBadge
v-if="selectedCount"
color="primary"
variant="subtle"
class="shrink-0"
>
{{ t('stats.selected', { n: selectedCount }) }}
</UBadge>
<USelect
v-model="currentFolder"
:items="folderSelectItems"
class="w-[5.5rem] shrink-0 sm:w-32"
size="sm"
:aria-label="t('stats.folder')"
@update:model-value="handleFolderChange"
/>
<USelect
v-model="currentStorage"
:items="storageSelectItems"
class="w-[6.5rem] shrink-0 sm:w-36"
size="sm"
:aria-label="t('stats.storage')"
@update:model-value="handleStorageChange"
/>
<UInput
v-model="searchQuery"
icon="i-lucide-search"
:placeholder="t('stats.searchPlaceholder')"
class="w-36 shrink-0 sm:w-44"
size="sm"
:disabled="galleryLoading"
/>
<UButton
type="submit"
icon="i-lucide-search"
size="sm"
class="shrink-0"
:aria-label="t('common.search')"
:loading="galleryLoading"
>
<span class="hidden sm:inline">{{ t('common.search') }}</span>
</UButton>
<UButton
v-if="activeSearch"
type="button"
icon="i-lucide-x"
size="sm"
variant="ghost"
color="neutral"
class="shrink-0"
:aria-label="t('stats.clear')"
@click="clearSearch"
>
<span class="hidden sm:inline">{{ t('stats.clear') }}</span>
</UButton>
<UButton
type="button"
icon="i-lucide-trash-2"
color="error"
variant="soft"
size="sm"
class="shrink-0"
:aria-label="t('stats.batchDelete')"
:disabled="!selectedCount"
@click="requestBatchDelete"
>
<span class="hidden sm:inline">{{ t('stats.batchDelete') }}</span>
</UButton>
</form>
</div>
<div
v-if="galleryLoading"
class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4"
>
<USkeleton
v-for="n in 12"
:key="n"
class="aspect-square rounded-lg"
/>
</div>
<ImageGrid
v-else
:show-key="false"
show-storage
:items="items"
:selected-keys="selectedKeys"
:deleting-keys="deletingKeys"
@update:selected-keys="selectedKeys = $event"
@delete="requestDelete"
/>
<PaginationBar
v-if="!galleryLoading && galleryTotal > 0"
:page="galleryPage"
:total-pages="galleryTotalPages"
:total="galleryTotal"
:unit="t('common.imagesUnit')"
:loading="galleryLoading"
@update:page="handleGalleryPageChange"
/>
</section>
<DeleteConfirmModal
v-model:open="deleteModalOpen"
:count="deleteTargetKeys.length"
:loading="batchDeleting"
@confirm="confirmDelete"
/>
<UButton
v-show="showScrollTop"
icon="i-lucide-arrow-up"
:aria-label="t('stats.scrollTop')"
color="primary"
class="fixed bottom-6 right-6 z-50 shadow-lg"
size="lg"
@click="scrollToTop"
/>
</AppShell>
</div>
</template>
+34 -23
View File
@@ -21,6 +21,11 @@ const deleteTarget = ref<StorageBackendItem | null>(null)
const deleteConfirmName = ref('') const deleteConfirmName = ref('')
const deleting = ref(false) const deleting = ref(false)
const defaultBackendName = computed(() => {
const backend = storageData.value?.backends.find(item => item.isDefault)
return backend?.name ?? t('storage.backendLocal')
})
async function loadStorage() { async function loadStorage() {
loading.value = true loading.value = true
try { try {
@@ -43,6 +48,7 @@ function buildPatchBody(payload: StorageFormPayload) {
const quotaBytes = quotaToBytes(payload.quotaValue, payload.quotaUnit) const quotaBytes = quotaToBytes(payload.quotaValue, payload.quotaUnit)
const body: Record<string, unknown> = { const body: Record<string, unknown> = {
name: payload.name, name: payload.name,
provider: payload.provider,
servingMode: payload.servingMode, servingMode: payload.servingMode,
publicUrl: payload.publicUrl, publicUrl: payload.publicUrl,
quotaBytes, quotaBytes,
@@ -232,22 +238,16 @@ watch(isAuthenticated, async (authed) => {
<AdminLoginGate v-else-if="!isAuthenticated" /> <AdminLoginGate v-else-if="!isAuthenticated" />
<AppShell v-else-if="isAdmin"> <AppShell v-else-if="isAdmin">
<section class="overflow-hidden rounded-2xl border border-default bg-elevated shadow-sm"> <div class="flex items-start justify-between gap-3">
<div class="flex flex-wrap items-start justify-between gap-3 border-b border-default px-5 py-4 sm:px-6"> <div class="min-w-0">
<div class="flex items-start gap-2"> <h1 class="text-2xl font-semibold tracking-tight">
<UIcon
name="i-lucide-hard-drive"
class="mt-0.5 size-5 shrink-0 text-primary"
/>
<div>
<h1 class="text-base font-semibold">
{{ t('storage.pageTitle') }} {{ t('storage.pageTitle') }}
</h1> </h1>
<p class="mt-0.5 text-xs text-muted"> <p class="text-sm text-muted">
{{ t('storage.pageSubtitle') }} {{ t('storage.pageSubtitle') }}
</p> </p>
</div> </div>
</div>
<UButton <UButton
:label="t('storage.addBackend')" :label="t('storage.addBackend')"
icon="i-lucide-plus" icon="i-lucide-plus"
@@ -257,20 +257,15 @@ watch(isAuthenticated, async (authed) => {
/> />
</div> </div>
<div class="p-5 sm:p-6"> <section class="mt-5 space-y-5">
<UAlert <UAlert
v-if="storageData?.envOverride" v-if="storageData?.envOverride"
color="warning" color="warning"
variant="subtle" variant="subtle"
class="mb-5"
:title="t('storage.envOverride')" :title="t('storage.envOverride')"
icon="i-lucide-triangle-alert" icon="i-lucide-triangle-alert"
/> />
<p class="mb-5 text-xs text-muted">
{{ t('storage.switchHint') }}
</p>
<div <div
v-if="loading" v-if="loading"
class="flex justify-center py-16" class="flex justify-center py-16"
@@ -281,10 +276,21 @@ watch(isAuthenticated, async (authed) => {
/> />
</div> </div>
<div <template v-else-if="storageData">
v-else-if="storageData" <StorageOverview
class="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3" :backends="storageData.backends"
> :loading="loading"
/>
<UAlert
v-if="!storageData.envOverride"
color="success"
variant="subtle"
icon="i-lucide-info"
:description="t('storage.defaultStorageTip', { name: defaultBackendName })"
/>
<div class="grid grid-cols-1 gap-4 xl:grid-cols-2">
<StorageBackendCard <StorageBackendCard
v-for="backend in storageData.backends" v-for="backend in storageData.backends"
:key="backend.id" :key="backend.id"
@@ -298,11 +304,16 @@ watch(isAuthenticated, async (authed) => {
@delete="confirmDelete(backend)" @delete="confirmDelete(backend)"
/> />
</div> </div>
</div>
<StorageSupportedBackends />
</template>
</section> </section>
</AppShell> </AppShell>
<UModal v-model:open="formOpen"> <UModal
v-model:open="formOpen"
:ui="{ content: 'max-w-2xl sm:max-w-3xl' }"
>
<template #content> <template #content>
<div class="p-5 sm:p-6"> <div class="p-5 sm:p-6">
<h2 class="mb-4 text-base font-semibold"> <h2 class="mb-4 text-base font-semibold">
+19
View File
@@ -0,0 +1,19 @@
export type LoginVerificationMethod = 'slider' | 'turnstile' | 'cap'
export interface LoginVerificationPublicConfig {
method: LoginVerificationMethod
turnstileSiteKey?: string
capApiEndpoint?: string
}
export type LoginVerificationPayload = {
method: 'slider'
captchaId: string
captchaPosition: number
} | {
method: 'turnstile'
turnstileToken: string
} | {
method: 'cap'
capToken: string
}
+2
View File
@@ -42,6 +42,8 @@ export interface ImageItem {
name: string name: string
type: 'local' | 's3' type: 'local' | 's3'
} }
/** 上传来源:网页或 API(来自活动日志) */
uploadSource?: 'web' | 'api'
} }
export interface UploadErrorItem { export interface UploadErrorItem {
+8 -15
View File
@@ -1,15 +1,10 @@
export type LogAction = 'upload' | 'delete' export type LogAction = 'upload' | 'delete'
export type LogSource = 'web' | 'api' | 'twikoo' export type LogSource = 'web' | 'api'
export interface ActivityLogUser {
id: number
username: string
}
export interface ActivityLogStorage { export interface ActivityLogStorage {
id: string id: string
name: string name: string
type: 'local' | 's3' type: string
} }
export interface ActivityLogItem { export interface ActivityLogItem {
@@ -26,18 +21,16 @@ export interface ActivityLogItem {
createdAt: string createdAt: string
} }
export interface ActivityLogSummary {
total: number
upload: number
delete: number
}
export interface ActivityLogListResponse { export interface ActivityLogListResponse {
items: ActivityLogItem[] items: ActivityLogItem[]
total: number total: number
page: number page: number
pageSize: number pageSize: number
totalPages: number totalPages: number
summary: ActivityLogSummary summary: {
users?: ActivityLogUser[] total: number
upload: number
delete: number
}
users?: Array<{ id: number, username: string }>
} }
+5
View File
@@ -0,0 +1,5 @@
export type SettingsTab = 'basic' | 'domains' | 'access' | 'logs'
export const ADMIN_SETTINGS_TABS: SettingsTab[] = ['basic', 'domains', 'access', 'logs']
export const USER_SETTINGS_TABS: SettingsTab[] = ['logs']
+24
View File
@@ -0,0 +1,24 @@
export function getFileExtension(filename: string): string {
const base = filename.split(/[/\\]/).pop() ?? filename
const dot = base.lastIndexOf('.')
if (dot <= 0 || dot === base.length - 1) return '—'
return base.slice(dot + 1).toLowerCase()
}
export function getUrlPathname(url: string): string {
try {
return decodeURIComponent(new URL(url).pathname.replace(/^\/+/, ''))
} catch {
return url
}
}
export function getStorageKeyTail(key: string): string {
const prefix = 'images/'
return key.startsWith(prefix) ? key.slice(prefix.length) : key
}
export function formatImageDimensions(width: number | null, height: number | null): string | null {
if (!width || !height) return null
return `${width} × ${height} px`
}
+1
View File
@@ -10,6 +10,7 @@ services:
- "6892:6892" - "6892:6892"
# 首次访问 Web 引导创建管理员,无需预先配置环境变量 # 首次访问 Web 引导创建管理员,无需预先配置环境变量
# 可选:environment 中设置 API_UPLOAD_TOKEN、ALLOWED_REFERER_HOSTS 等 # 可选:environment 中设置 API_UPLOAD_TOKEN、ALLOWED_REFERER_HOSTS 等
# 运维:docker exec pichost reset-password | migrate [--apply] | slider
volumes: volumes:
# 冒号左边改成 NAS 上的实际存图目录(飞牛示例:/vol1/1000/pichost-data) # 冒号左边改成 NAS 上的实际存图目录(飞牛示例:/vol1/1000/pichost-data)
- ./data:/data - ./data:/data
+9 -1
View File
@@ -2,7 +2,15 @@
set -e set -e
if [ "$1" = "reset-password" ]; then if [ "$1" = "reset-password" ]; then
exec node /app/server/cli/reset-password.mjs exec node /app/server/cli/reset-password.mjs "${@:2}"
fi
if [ "$1" = "migrate" ]; then
exec node /app/server/cli/migrate-to-single-images.mjs "${@:2}"
fi
if [ "$1" = "slider" ]; then
exec node /app/server/cli/slider.mjs "${@:2}"
fi fi
exec node /app/.output/server/index.mjs "$@" exec node /app/.output/server/index.mjs "$@"
+142
View File
@@ -0,0 +1,142 @@
import { defineConfig } from 'vitepress'
import { withMermaid } from 'vitepress-plugin-mermaid'
const docsBase = 'https://o96u.github.io/PicHost'
const zhSidebar = [
{
text: '开始',
items: [
{ text: '文档首页', link: '/guide/' },
{ text: '快速开始', link: '/guide/getting-started' },
{ text: '本地开发', link: '/guide/local-dev' }
]
},
{
text: '部署与配置',
items: [
{ text: '环境变量', link: '/guide/configuration' },
{ text: '反向代理', link: '/guide/reverse-proxy' },
{ text: '双域名分离', link: '/guide/domain-separation' },
{ text: 'Cloudflare / CF 优选', link: '/guide/cloudflare-deployment' }
]
},
{
text: '使用',
items: [
{ text: '存储', link: '/guide/storage' },
{ text: '用户与权限', link: '/guide/users-and-permissions' },
{ text: 'API', link: '/guide/api' },
{ text: 'Twikoo', link: '/guide/twikoo' }
]
},
{
text: '升级',
items: [
{ text: '更新日志', link: '/guide/changelog' },
{ text: 'v1.2 迁移', link: '/guide/migration' },
{ text: '常见问题', link: '/guide/faq' }
]
}
]
const enSidebar = [
{
text: 'Getting started',
items: [
{ text: 'Overview', link: '/en/guide/' },
{ text: 'Quick start', link: '/en/guide/getting-started' },
{ text: 'Local development', link: '/en/guide/local-dev' }
]
},
{
text: 'Deploy & configure',
items: [
{ text: 'Environment variables', link: '/en/guide/configuration' },
{ text: 'Reverse proxy', link: '/en/guide/reverse-proxy' },
{ text: 'Dual-domain separation', link: '/en/guide/domain-separation' },
{ text: 'Cloudflare deployment', link: '/en/guide/cloudflare-deployment' }
]
},
{
text: 'Usage',
items: [
{ text: 'Storage', link: '/en/guide/storage' },
{ text: 'Users & permissions', link: '/en/guide/users-and-permissions' },
{ text: 'API', link: '/en/guide/api' },
{ text: 'Twikoo', link: '/en/guide/twikoo' }
]
},
{
text: 'Upgrade',
items: [
{ text: 'Changelog', link: '/en/guide/changelog' },
{ text: 'v1.2 migration', link: '/en/guide/migration' },
{ text: 'FAQ', link: '/en/guide/faq' }
]
}
]
export default withMermaid(
defineConfig({
base: '/PicHost/',
title: 'PicHost',
description: '自托管图床文档 · Self-hosted image hosting docs',
head: [['link', { rel: 'icon', href: '/PicHost/favicon.ico' }]],
vite: {
optimizeDeps: {
// mermaid → fastdom 为 CJS;dev 模式须预构建,否则整站白屏
include: ['mermaid', 'fastdom']
}
},
locales: {
root: {
label: '简体中文',
lang: 'zh-CN',
themeConfig: {
nav: [
{ text: '指南', link: '/guide/getting-started' },
{ text: 'API', link: '/guide/api' },
{ text: 'GitHub', link: 'https://github.com/O96u/PicHost' }
],
sidebar: zhSidebar,
docFooter: { prev: '上一页', next: '下一页' },
outline: { label: '本页目录' },
lastUpdated: { text: '最后更新' },
returnToTopLabel: '回到顶部',
sidebarMenuLabel: '菜单',
darkModeSwitchLabel: '主题',
langMenuLabel: '语言'
}
},
en: {
label: 'English',
lang: 'en-US',
link: '/en/',
themeConfig: {
nav: [
{ text: 'Guide', link: '/en/guide/getting-started' },
{ text: 'API', link: '/en/guide/api' },
{ text: 'GitHub', link: 'https://github.com/O96u/PicHost' }
],
sidebar: enSidebar,
docFooter: { prev: 'Previous', next: 'Next' },
outline: { label: 'On this page' },
lastUpdated: { text: 'Last updated' },
returnToTopLabel: 'Back to top',
sidebarMenuLabel: 'Menu',
darkModeSwitchLabel: 'Appearance',
langMenuLabel: 'Language'
}
}
},
themeConfig: {
socialLinks: [{ icon: 'github', link: 'https://github.com/O96u/PicHost' }],
search: { provider: 'local' },
editLink: {
pattern: `${docsBase}/edit/main/docs-site/:path`,
text: '在 GitHub 上编辑此页'
}
}
})
)
+6
View File
@@ -0,0 +1,6 @@
:root {
--vp-c-brand-1: #22c55e;
--vp-c-brand-2: #16a34a;
--vp-c-brand-3: #15803d;
--vp-c-brand-soft: rgba(34, 197, 94, 0.14);
}
+4
View File
@@ -0,0 +1,4 @@
import DefaultTheme from 'vitepress/theme'
import './custom.css'
export default DefaultTheme
+16
View File
@@ -0,0 +1,16 @@
import { cpSync, existsSync, mkdirSync } from 'node:fs'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
const root = join(dirname(fileURLToPath(import.meta.url)), '..')
const src = join(root, 'docs', 'screenshots')
const dest = join(root, 'docs-site', 'public', 'screenshots')
if (!existsSync(src)) {
console.error(`Screenshot source missing: ${src}`)
process.exit(1)
}
mkdirSync(dest, { recursive: true })
cpSync(src, dest, { recursive: true })
console.log(`Copied screenshots to ${dest}`)
+99
View File
@@ -0,0 +1,99 @@
# API
PicHost exposes REST endpoints and a Twikoo-compatible upload. After sign-in, open **API** in the nav: a sidebar lists endpoints and token controls, the center shows parameters and copyable cURL, and the right panel supports **live debugging**.
## Authentication
All REST calls use:
```http
Auth-Token: YOUR_TOKEN
```
| Token type | How to obtain | Notes |
| ---------- | ------------- | ----- |
| **Global** | Admin **API** page or `API_UPLOAD_TOKEN` env | Env wins and blocks UI regenerate |
| **Personal** | Each user’s **API** page | Uploads belong to that user |
Form uploads may use field `token` (Twikoo protocol).
## Endpoints
### 1. Upload images
`POST /api/images/upload`
Upload one or more images via `image`; also accepts `file`, `files`. Stored under `images/`.
```bash
curl -X POST "https://admin.example.com/api/images/upload" \
-H "Auth-Token: YOUR_TOKEN" \
-F "image=@./demo.png"
```
### 2. List images
`GET /api/images`
Paginated gallery. `limit` default 20, max 100.
```bash
curl "https://admin.example.com/api/images?limit=20&page=1" \
-H "Auth-Token: YOUR_TOKEN"
```
### 3. Search images
`GET /api/images/search`
Search by filename or path; `q` is required.
```bash
curl "https://admin.example.com/api/images/search?q=demo&limit=20&page=1" \
-H "Auth-Token: YOUR_TOKEN"
```
### 4. Delete image
`DELETE /api/images`
Delete by `key` (storage path, e.g. `images/2026/08/xxx.webp`).
```bash
curl -X DELETE "https://admin.example.com/api/images?key=images/2026/08/xxxx.webp" \
-H "Auth-Token: YOUR_TOKEN"
```
### 5. Batch delete
`POST /api/images/batch-delete`
JSON body: `{"keys":["images/2026/08/a.webp","images/2026/08/b.webp"]}`
```bash
curl -X POST "https://admin.example.com/api/images/batch-delete" \
-H "Auth-Token: YOUR_TOKEN" \
-H "Content-Type: application/json" \
-d '{"keys":["images/2026/08/a.webp","images/2026/08/b.webp"]}'
```
## Errors
REST responses are JSON with `code` and `message` (`UNAUTHORIZED`, `FORBIDDEN`, `INVALID_REQUEST`, etc.). Twikoo uses EasyImage-compatible JSON.
## Visibility
- User token: list/search/delete own images only
- Admin token: all images
- Global token uploads belong to admin
See [Users & permissions](./users-and-permissions.md).
## Twikoo
`POST /api/index.php` — [Twikoo](./twikoo.md).
## See also
- [Environment variables](./configuration.md)
- [Reverse proxy](./reverse-proxy.md)
+168
View File
@@ -0,0 +1,168 @@
# Changelog
Version history and notable changes. The full log is also in the repo root [CHANGELOG.md](https://github.com/O96u/PicHost/blob/main/CHANGELOG.md).
---
## [1.2.7] — 2026-09-03
### Added
- **Login verification**: slider, Cloudflare Turnstile, or Cap on login/register; configure under Settings → Access
- **Ops CLI**: `slider` resets verification to the local slider (`docker exec pichost slider`)
### Improved
- Login captcha reloads correctly after logout
## [1.2.6] — 2026-09-01
### Added
- **Admin UI**: redesigned Settings, API, Gallery, and Storage pages; API page includes live debugging
- **Gallery filter**: filter by storage backend
- **Storage IDs**: new R2 / COS / OSS / S3 backends get `r2-*`, `cos-*`, `oss-*`, or `s3-*` prefixes
### Improved
- Activity log table styling and path truncation
- Storage backend card metadata layout
- Docs site and README screenshots updated
## [1.2.5] — 2026-08-31
### Added
- **Gallery page**: nav “Stats” renamed to “Gallery” (`/gallery`); click a thumbnail for a detail modal (full image, dimensions, upload source, storage path, link copy)
- **List actions**: select all / invert selection; single-image delete moved into the detail modal
- **Upload source**: gallery API returns web / API upload source (from activity logs)
### Improved
- README tech stack section
### Removed
- `/stats` route (no compatibility redirect)
## [1.2.4] — 2026-08-31
### Fix
- **Empty referer whitelist still blocked hotlinks**: with no Referer whitelist configured, direct browser access worked but embedded images on blogs/forums returned 403; protection now runs only when a whitelist is configured
## [1.2.3] — 2026-08-30
### Fix
- **Short links + date-based storage**: bare filename URLs (e.g. `https://host/xxx.webp`) no longer map to flat `images/xxx.webp`; SQLite index resolves the full `images/YYYY/MM/xxx.webp` key so thumbnails and short links work again
---
## [1.2.2] — 2026-08-30
> **Restart the service** after upgrading so middleware and API updates take effect.
### Dual-domain settings & Host isolation
#### Fixes
| Issue | Description |
| ----- | ----------- |
| Site URL cleared by mistake | Saving Settings could write an empty `site_base_url` when disabling dual-domain or editing only the image URL |
| Config vs effective URL mixed | `GET /api/settings` used to blend DB values with `request.origin`, confusing the Settings UI |
| Third-host bypass | Unlisted hosts (`pages.dev`, bare IP, old image domains) were not blocked by middleware |
#### Improvements
**Settings API (`GET /api/settings`)**
| Field | Meaning |
| ----- | ------- |
| `siteBaseUrl` / `imageBaseUrl` | **Configured** values (DB → env), no request fallback |
| `effectiveSiteBaseUrl` / `effectiveImageBaseUrl` | **Link generation**, falls back to request origin when unset |
| `runtime.currentOrigin` | Detected access URL (display only, not saved) |
| `runtime.hostRole` | `site` / `image` / `unknown` / `single` |
**Save API (`PATCH /api/settings`)**
- New body field `domainSeparation` (`true` / `false`)
- `domainSeparation: true` requires both URLs with different hostnames
- `domainSeparation: false` allows clearing the site URL
- Without `domainSeparation`, cannot clear `siteBaseUrl` while dual-domain is active
**Settings / setup UI**
- Form uses configured values; “Active” line shows `effective*`
- Shows detected access URL; **no longer** auto-fills `window.location.origin`
- “Use detected URL” button (opt-in)
- Confirmation modal when disabling dual-domain
- Warning when `hostRole === 'unknown'` with dual-domain on
**Host middleware**
- When dual-domain is on, unlisted hosts get **404** at the app layer (all methods)
- `localhost` / `127.0.0.1` exempt in development
- Complements Nginx `default_server` — use both layers in production
#### Docs
- Rewrote [Cloudflare deployment](./cloudflare-deployment.md)
- Updated [Dual-domain separation](./domain-separation.md) for third-host blocking
- Added this changelog page
#### Upgrade notes
- **No** database migration or CLI step
- If the site URL was cleared earlier: re-enter it in **Settings**, or set `SITE_BASE_URL` in env and restart
- Production: match proxy `server_name` to Settings; keep a default server block
---
## [1.2.1] — 2026-08-29
### Added
- Slide puzzle login captcha
- VitePress docs site on GitHub Pages
- `npm run docs:dev` / `docs:build` / `docs:preview`
### Fixed
- Gallery not refreshing after delete on stats page
- Pagination jump with `type="number"` input
- Copy-link format preference not persisting off home route
- Captcha UI alignment
### Improved
- README trimmed; details moved to docs
- Dual-domain docs: unknown Host risk, Cloudflare guide
---
## [1.2.0] — 2026-08-28
### Changed
- All images under `data/images/`; legacy `blog/` / `twikoo/` need migration
### Added
- CLI: `docker exec pichost migrate` / `migrate --apply`
- Startup index sync (disk scan, legacy key normalization, orphan cleanup)
See [v1.2 migration](./migration.md).
---
## [1.1.x] summary
| Version | Highlights |
| ------- | ---------- |
| 1.1.5 | API upload preview fix; file existence check before serve |
| 1.1.4 | Date-based storage paths; site host blocks image URLs |
| 1.1.3 | Dual-domain (`SITE_BASE_URL` / `IMAGE_BASE_URL`); Host middleware |
| 1.1.0 | Multi storage backends (S3 / R2) |
Older entries: [GitHub CHANGELOG](https://github.com/O96u/PicHost/blob/main/CHANGELOG.md).
+236
View File
@@ -0,0 +1,236 @@
# Cloudflare & preferred-edge deployment
In dual-domain mode, PicHost distinguishes **site** vs **image** traffic from the request **Host** (and `X-Forwarded-Host` from a trusted reverse proxy). Cloudflare orange cloud, preferred-edge IPs, Workers, and Pages are all fine — as long as the hostname identity reaching PicHost stays correct.
> **One-line rule:** The network path may change; the hostname identity must not — `admin → admin`, `img → img`, never `img → admin`.
See [Dual-domain separation](./domain-separation.md) for middleware behavior and [Reverse proxy](./reverse-proxy.md) for general proxy requirements.
---
## Quick checklist
| # | Check | Expected |
| - | ----- | -------- |
| 1 | Browser hostname | `admin.*` or `img.*`; do not mix IP / `pages.dev` |
| 2 | Pages / Worker in the middle | Image host must **not** `fetch(admin…)` |
| 3 | Reverse-proxy upstream | Both hostnames → `http://127.0.0.1:6892` |
| 4 | Nginx `proxy_set_header Host` | `$host` (preserve client hostname) |
| 5 | PicHost settings | `SITE_BASE_URL` / `IMAGE_BASE_URL` match `server_name` |
| 6 | Port `6892` | Reachable only from localhost or internal proxy |
---
## Recommended layout
Both hostnames go through Cloudflare (proxied) and **independently** reverse-proxy to one PicHost instance:
```mermaid
flowchart TB
internet[Internet]
cf[Cloudflare proxied]
admin[admin.example.com]
img[img.example.com]
panel[1Panel / Nginx]
app[PicHost :6892]
internet --> cf
cf --> admin
cf --> img
admin --> panel
img --> panel
panel --> app
```
```env
SITE_BASE_URL=https://admin.example.com
IMAGE_BASE_URL=https://img.example.com
```
```text
admin.example.com → http://127.0.0.1:6892
img.example.com → http://127.0.0.1:6892
```
You do **not** need separate ports for “frontend / backend / images” — one PicHost instance on port `6892` is enough.
### Site hostname ≠ frontend domain
| Hostname | Serves |
| -------- | ------ |
| `admin.example.com` | Admin UI, login, settings, gallery, API, upload |
| `img.example.com` | Image delivery, hotlinks, CDN |
Use **site / image hostname**, not “frontend / backend”. PicHost does **not** require `frontend :3000 + backend :6892` in production.
---
## Orange cloud & preferred edge
**DNS Proxied (orange cloud)** is normal and recommended:
```text
admin.example.com, img.example.com
A/CNAME → origin → Proxied
```
Opening `admin.example.com` should yield `Host: admin.example.com` at PicHost; `img.example.com` should yield `Host: img.example.com`.
**Preferred-edge** setups (alternate IPs, entry hostnames) are also fine. Preferred routing may change the path to Cloudflare’s edge, but must **not** rewrite the image hostname’s origin identity to the site hostname:
```text
admin → CF → admin.example.com → origin → PicHost
img → CF → img.example.com → origin → PicHost
```
---
## 6. Typical bad topologies
These patterns break Host isolation even when the browser still shows the image hostname:
### ❌ Bad 1: Image host fetches site host
```mermaid
flowchart LR
user[User on img.example.com]
worker[Pages / Worker]
admin[fetch admin.example.com]
app[PicHost]
user --> worker --> admin --> app
```
The last hop has `Host: admin.example.com`, so PicHost treats it as site traffic — `https://img.example.com/` may show the admin UI. This is **Host rewrite**, not a cache bug.
### ❌ Bad 2: Preferred proxy upstreams to site host
```text
img.example.com → preferred hostname → admin.example.com → origin
```
Even for routing only, using the site hostname as upstream breaks image isolation.
### ❌ Bad 3: Public exposure of port 6892
```text
any host / IP:6892 → PicHost
```
Users can bypass Cloudflare and Nginx `server_name` rules. Port `6892` should be reachable only from `127.0.0.1` or an internal reverse proxy.
### ✅ Correct pattern
```text
admin → admin → PicHost
img → img → PicHost
```
Do **not** use Pages / Workers as a **full-site reverse proxy** for PicHost (extra `pages.dev` / `workers.dev` entry points). PicHost needs `node-server`, SQLite, `sharp`, and local `data/` — it **cannot** run as a Node app on Pages/Workers. Keep Docker/VPS for the app; use orange-cloud DNS and optional R2 on CF.
---
## Workers / Pages guidance
If you only need headers, auth, Referer rules, logging, or cache — and PicHost remains your origin:
| Prefer | Avoid |
| ------ | ----- |
| `img.example.com` → **Worker Route** → existing `img` origin | `img` → Worker → `fetch(admin.example.com)` |
Cloudflare recommends [Routes](https://developers.cloudflare.com/workers/configuration/routing/routes/) when an external origin already exists, rather than [Custom Domains](https://developers.cloudflare.com/workers/configuration/routing/custom-domains/) used to chain cross-host `fetch()`.
Code like this on the image hostname is a smoking gun:
```js
fetch(`https://admin.example.com${url.pathname}`)
```
---
## Nginx / 1Panel layout
Two `server` blocks, same upstream. Critical line: `proxy_set_header Host $host;`
```nginx
server {
server_name admin.example.com;
location / {
proxy_pass http://127.0.0.1:6892;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
client_max_body_size 12m;
}
}
server {
server_name img.example.com;
location / {
proxy_pass http://127.0.0.1:6892;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
}
# Reject bare IP and unknown hostnames (strongly recommended)
server {
listen 80 default_server;
listen 443 ssl default_server;
server_name _;
return 444;
}
```
With orange cloud, restrict the origin firewall to **[Cloudflare IP ranges](https://www.cloudflare.com/ips/)** only.
---
## Two-layer Host protection
Use reverse proxy and application layers **together**:
| Layer | Role |
| ----- | ---- |
| **Nginx default server** | Reject bare IP, `pages.dev`, and other undeclared hostnames |
| **PicHost middleware (v1.2.x+)** | When dual-domain is on, unconfigured hosts get 404 at the app (`localhost` / `127.0.0.1` exempt in development) |
Even with app-layer blocking, keep a default server at the proxy to reduce junk traffic to PicHost.
---
## X-Forwarded-Host
PicHost may consult `X-Forwarded-Host` behind a reverse proxy, but that header is **forgeable** by clients. Therefore:
- Do not expose port `6892` publicly
- Only trusted proxies should reach the app
- Prefer keeping `Host` correct from the first hop
---
## Troubleshooting: admin UI on image hostname?
**Inspect the proxy chain first — do not purge CF cache first.**
Cache issues usually mean stale JS/CSS/images or occasional 404s. If `/`, `/login`, and `/settings` work **reliably** on the image host, the Host was almost certainly rewritten.
```text
① Browser hostname?
② Pages / Worker / preferred proxy in the middle?
③ What URL does the proxy fetch?
④ Does it fetch admin.example.com?
⑤ Which Host does 1Panel / PicHost see?
```
---
## Related
- [Dual-domain separation](./domain-separation.md)
- [Reverse proxy](./reverse-proxy.md)
- [Environment variables](./configuration.md)
- [FAQ](./faq.md)
+103
View File
@@ -0,0 +1,103 @@
# Environment variables
Configure PicHost via environment variables (Docker / `.env`) or the in-app **Settings** page. Admins use **Settings** (`/settings`) with sidebar sections **Basic**, **Domains & paths**, **Access control**, and **Activity log**. Most values prefer **SQLite** when saved; some env vars override or lock UI editing.
## Priority overview
| Setting | Priority (high → low) | Env overrides UI |
| ------- | --------------------- | ---------------- |
| API upload token | Env → SQLite | **Yes** (cannot regenerate in UI) |
| WebP quality | SQLite → env → default 80 | No |
| Referer whitelist | SQLite → env | No |
| Site / image base URL | SQLite → env (configured in API); links use `effective*` or request origin | No |
| Short image links | SQLite → env → default false | No |
| Date-based paths | SQLite → env → default true | No |
| Global auto-delete days | SQLite → env → default 0 | No |
## Variables
### `API_UPLOAD_TOKEN`
Token for external uploads (Twikoo, userscripts, blogs). Header: `Auth-Token: <token>`; form field `token` still works.
- Generate in **API** page when unset
- **When set in env, it wins** — UI shows “overridden by environment” and cannot regenerate
- Alias: `NUXT_API_UPLOAD_TOKEN`
### `ALLOWED_REFERER_HOSTS`
Comma-separated Referer whitelist. Own hostnames are allowed automatically. Optional; configurable in Settings.
### `SITE_BASE_URL` / `IMAGE_BASE_URL`
- **Site URL**: admin UI, API, Twikoo upload
- **Image URL**: copied links and public image URLs
Single-domain: set `IMAGE_BASE_URL` only or leave empty (request origin). Dual-domain: hostnames must differ — see [Dual-domain separation](./domain-separation.md) and [Cloudflare deployment](./cloudflare-deployment.md). Values must match proxy `server_name`; do not use `localhost`, IP, or unlisted hostnames for production admin access.
In **Settings**, enable dual-domain explicitly and fill both URLs; disabling clears the site URL (with confirmation). Configured values vs effective link URLs are separate in the API — see [Changelog](./changelog.md#1-2-2-2026-08-30).
### `HIDE_FOLDER_IN_URL`
`true`: short links (domain + filename only), e.g. `img.example.com/a8K3xP.webp`. `false`: keep date or custom folders, e.g. `img.example.com/2026/08/a8K3xP.webp`. Public URLs never expose the internal `images/` prefix; legacy `/images/...` paths still work.
### `STORAGE_USE_DATE_PATH` / `STORAGE_LAYOUT`
- `true` or `STORAGE_LAYOUT=date` (default): `images/YYYY/MM/id.webp`
- `false` or `STORAGE_LAYOUT=flat`: `images/id.webp`
Also toggled in Settings.
### `DATA_DIR`
Root for images and SQLite. Default `./data`; `/data` in container.
### `WEBP_QUALITY`
Server WebP quality 1–100, default **80**.
### `AUTO_DELETE_DAYS`
Delete images older than N days globally; `0` disables. Affects **new uploads after enable** only. Also in Settings.
### `DEV_BYPASS_ACCESS`
Local dev only: `true` bypasses login. Never use in production.
### Storage backends (optional)
Admins usually add backends in **Storage** UI; env vars such as `STORAGE_BACKEND=s3` and `S3_*` are also supported. See [Storage](./storage.md).
## Docker Compose example
```yaml
services:
pichost:
image: muxui/pichost:latest
ports:
- "6892:6892"
volumes:
- ./data:/data
environment:
SITE_BASE_URL: https://admin.example.com
IMAGE_BASE_URL: https://pic.example.com
API_UPLOAD_TOKEN: your-secret-token
WEBP_QUALITY: "85"
```
Restart the container after changing env vars.
## Settings mapping
| In-app setting | Environment variable |
| -------------- | ---------------------- |
| API token | `API_UPLOAD_TOKEN` |
| Referer protection | `ALLOWED_REFERER_HOSTS` |
| Site URL | `SITE_BASE_URL` |
| Image URL | `IMAGE_BASE_URL` |
| Hide folder prefix | `HIDE_FOLDER_IN_URL` |
| Date-based paths | `STORAGE_USE_DATE_PATH` / `STORAGE_LAYOUT` |
| WebP quality | `WEBP_QUALITY` |
| Auto-delete | `AUTO_DELETE_DAYS` |
Template: [`.env.example`](https://github.com/O96u/PicHost/blob/main/.env.example) in the repo root.
+175
View File
@@ -0,0 +1,175 @@
# Dual-domain separation · Recommended deployment
When admin and image domains are split, the admin UI, API, and public image links use different hostnames. PicHost enforces isolation in **application middleware**: the image host only serves image paths; other requests return 404.
## Architecture
**One Docker instance + two domains**, both **fully reverse-proxied** to port `6892` (isolation is handled by PicHost middleware; no path splitting in Nginx).
| Domain | Purpose |
| ------ | ------- |
| `admin.example.com` | Admin UI, API, Twikoo upload |
| `pic.example.com` | Public image links (non-image paths blocked) |
Set during `/setup` or in **Settings**:
- **Site URL**: `https://admin.example.com`
- **Image URL**: `https://pic.example.com`
Setup and Settings **do not** auto-fill the site URL from `window.location.origin`; use manual input or “Use detected URL”. Disabling dual-domain requires confirmation and clears the site URL. Saves include `domainSeparation: true/false`. See [Changelog](./changelog.md#1-2-2-2026-08-30).
## Nginx example
```nginx
# admin.example.com — admin + API
server {
server_name admin.example.com;
location / {
proxy_pass http://127.0.0.1:6892;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
client_max_body_size 12m;
}
}
# pic.example.com — same full proxy; PicHost middleware blocks non-image paths
server {
server_name pic.example.com;
location / {
proxy_pass http://127.0.0.1:6892;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
# Default server: reject bare IP and unknown Host values (strongly recommended in production)
server {
listen 443 ssl default_server;
listen 80 default_server;
server_name _;
return 444;
}
```
> Caddy, NPM, and similar tools work the same: proxy both hostnames to `http://<host>:6892` and forward `Host` and `X-Forwarded-Proto`.
## Security notes
### Middleware isolation and third hosts
PicHost uses the request **Host** (and `X-Forwarded-Host` when proxied) to decide site vs image domain:
- **Image host**: non-image paths (e.g. `/`, `/settings`) → 404
- **Site host**: image URL paths (e.g. `/images/...`) → 404
- **Unconfigured third hosts** (e.g. `*.pages.dev`, bare IP, old image domains): **from v1.2.x the app returns 404 for all methods** (`localhost` / `127.0.0.1` are exempt in development for `npm run dev`)
This is independent of hyphenated or long domain names. Older versions did not block third hosts; rely on a reverse-proxy default server as a complement in production.
**Production recommendations:**
1. Configure `server_name` only for the site and image hosts; add a **default server** to reject other Host values and bare IP access (see Nginx example above; complements app-layer blocking)
2. With Cloudflare **orange-cloud** DNS, restrict the origin firewall to **[Cloudflare IP ranges](https://www.cloudflare.com/ips/)** so traffic cannot bypass the CDN
3. Sign in only on the **site hostname** from Settings; do not mix `localhost`, IP, or unlisted domains
4. Do **not** put Cloudflare Pages / Workers in front of PicHost as a **full-site reverse proxy** (`IMAGE_BASE_URL` may point at a CDN URL, but the app itself should run on Docker/VPS)
5. If you must proxy at the edge, preserve the client **`Host` or `X-Forwarded-Host`** or host isolation will fail
6. PicHost **cannot** run as-is on Pages/Workers (needs `node-server`, SQLite, `sharp`, local `data/`). Keep Docker/VPS; use orange-cloud DNS and optional R2 storage on CF
### Cloudflare orange cloud (incl. preferred edge IPs)
Full guide: [Cloudflare deployment](./cloudflare-deployment.md).
**Recommended:** both `admin.example.com` and `pic.example.com` **DNS Proxied** → origin Nginx / 1Panel → port `6892`, SSL **Full (strict)**.
| Feature | Notes |
| ------- | ----- |
| Orange-cloud CDN | Compatible with dual-domain; preserve `Host` and `X-Forwarded-Proto` on origin |
| DNS “preferred IP” setups | Only changes which CF edge IP clients hit; origin identity must stay **admin → admin**, **img → img** |
| R2 storage | Add an R2 backend under **Storage**; independent of orange cloud |
| `cloudflare` Git branch | R2-focused deployment line; still Docker, **not** Workers hosting |
**Do not confuse** “Create Worker” in the CF dashboard (Git + `wrangler deploy`) with deploying PicHost — the repo uses `nitro.preset: 'node-server'` and is not Workers-ready.
**Do not** chain the image hostname to `fetch(admin…)` via Pages/Workers — that rewrites Host and breaks isolation ([bad topologies](./cloudflare-deployment.md#6-typical-bad-topologies)).
See [Reverse proxy](./reverse-proxy.md#cloudflare-orange-cloud) for origin hardening.
## FN NAS · Lucky reverse proxy
In [Lucky](https://github.com/gdy666/lucky), create one **reverse proxy** rule per domain. **Frontend** is the public hostname; **backend** is PicHost on your LAN (e.g. `http://192.168.8.3:6892`).
**Site domain (admin)**
| Field | Example |
| ----- | ------- |
| Service type | Reverse proxy |
| Frontend | `admin.pichost.com` |
| Backend | `http://192.168.8.3:6892` |
**Image domain**
| Field | Example |
| ----- | ------- |
| Service type | Reverse proxy |
| Frontend | `image.pichost.com` |
| Backend | `http://192.168.8.3:6892` |
Both rules can point to the same backend; PicHost applies host-based isolation automatically.
## Environment variables (optional)
```env
SITE_BASE_URL=https://admin.example.com
IMAGE_BASE_URL=https://pic.example.com
```
See [Environment variables](./configuration.md).
## Local testing (Windows / macOS)
### 1. Edit hosts
```text
127.0.0.1 admin.pichost.test
127.0.0.1 pic.pichost.test
```
Prefer the `.test` TLD; avoid `.local` where it conflicts with mDNS.
### 2. Start dev server
```bash
npm run dev
```
Dev binds `127.0.0.1:3000` by default. If the hostname resolves but the browser cannot connect, check whether dev is listening on IPv6 `::1` only.
### 3. Configure dual domains
| Field | Example |
| ----- | ------- |
| Site URL | `http://admin.pichost.test:3000` |
| Image URL | `http://pic.pichost.test:3000` |
Always open admin on the **site hostname**, not `localhost` (cookies and Host must match).
### 4. Expected behavior
| URL | Expected |
| --- | -------- |
| `http://admin.pichost.test:3000/` | Admin UI works |
| `http://admin.pichost.test:3000/images/...` or `/2026/08/xxx.webp` | **404** (use image host) |
| `http://pic.pichost.test:3000/` | **404** (image host has no admin) |
| `http://pic.pichost.test:3000/images/...` | Image served |
| `http://localhost:3000/` or `http://127.0.0.1:3000/` | **Admin still loads** (development exception; block unconfigured hosts at proxy in production) |
After upload, copied links should use `pic.pichost.test`; gallery thumbnails should load.
## Notes
- Hostnames must **differ** (same machine, different subdomains is fine)
- Site / image URLs in Settings must **exactly match** proxy `server_name` (including `www` or not)
- No separate static root on the image domain; unlike EasyImages, PicHost isolates in-app
- Single-domain setups can skip separation and use `IMAGE_BASE_URL` or the request host
- When Referer protection is enabled, site and image hosts are whitelisted automatically
For single-domain proxy basics see [Reverse proxy](./reverse-proxy.md). Chinese version: [双域名分离](/guide/domain-separation)
+77
View File
@@ -0,0 +1,77 @@
# FAQ
## Forgot the admin password?
With `docker exec` access:
```bash
docker exec pichost reset-password
docker exec pichost reset-password <username>
```
Local: `npm run reset-password`. See [Quick start](./getting-started.md#forgot-password).
## Why can’t I regenerate the API token in the UI?
`API_UPLOAD_TOKEN` in the environment **locks** the token. Change env and **restart**. See [Environment variables](./configuration.md#api-upload-token).
## Hotlink / Referer errors?
Check the **Referer whitelist** (`ALLOWED_REFERER_HOSTS` or Settings). Referring sites must be listed; PicHost’s own hosts and dual-domain pair are allowed automatically.
## Why can admin load on pages.dev / workers.dev / IP with dual-domain?
**From v1.2.2:** when dual-domain is on, PicHost returns **404** for Host values **other than** the configured site and image hostnames (`localhost` / `127.0.0.1` exempt in development). See [Changelog](./changelog.md#1-2-2-2026-08-30).
If admin still loads:
1. **Dual-domain not active** (image URL set but site URL empty) — neither path split nor third-host blocking applies
2. **Proxy rewrote Host to site hostname** — e.g. Pages/Worker `fetch(admin…)` on the image hostname
3. **Port `6892` exposed publicly** — bypasses Cloudflare and Nginx `server_name`
Fix: configure both URLs in Settings; add a default server block; with orange cloud, allow only CF IPs on origin; do not full-proxy through Pages/Workers. See [Dual-domain separation](./domain-separation.md#middleware-isolation-and-third-hosts), [Cloudflare deployment](./cloudflare-deployment.md), and [Changelog](./changelog.md#1-2-2-2026-08-30).
## Can I deploy PicHost on Cloudflare Workers / Pages?
**Not with the current codebase.** PicHost needs `node-server`, SQLite, `sharp`, and local `data/` — use Docker/VPS. Connecting the repo in “Create Worker” will not deploy successfully.
Recommended: run PicHost on origin; use **proxied DNS** (and optional **R2** storage). Do not full-proxy the app through Pages/Workers.
## Dual-domain: admin works but thumbnails break?
- Verify **IMAGE_BASE_URL** and proxy to the same instance
- Always open admin on the **site hostname**, not mixed with `localhost`
- See [Dual-domain separation](./domain-separation.md)
## Undeletable gallery items after v1.2 upgrade?
Likely **orphan index** rows (no file on disk). Startup sync removes them on restart. See [v1.2 migration](./migration.md#step-2-startup-index-sync-automatic).
## Do I still need `data/blog/` after migration?
After `migrate --apply`, files live under `data/images/blog/`. Remove leftover empty dirs after backup if you like; dirs with non-image files are kept by the CLI.
## Object storage only — run migrate?
No. Skip the CLI; startup sync focuses on local `data/images/` scanning.
## Replace old image URLs in posts?
Use `data/mapping.json` from `migrate` preview. Keys are **storage paths**, not full legacy URLs. See [Migration](./migration.md#urls).
## Skip login during development?
`DEV_BYPASS_ACCESS=true` (local only). Use `/setup` for normal flows.
## Docs vs README?
This **VitePress site** is the full guide; README is a short overview. Online: <https://o96u.github.io/PicHost/>
## What should the GitHub repo About say?
For **main**:
- **Description**: `Lightweight self-hosted image hosting — Docker, multi-user, gallery & API, local disk or S3-compatible storage.`
- **Website**: `https://o96u.github.io/PicHost/` (docs, not a live demo)
Do not claim the app runs on Cloudflare Pages/Workers. The **cloudflare** branch demo is [pic.roven.cc](https://pic.roven.cc).
+72
View File
@@ -0,0 +1,72 @@
# Quick start
PicHost is a self-hosted image host for NAS, home servers, or VPS. Docker is recommended; the first visit runs a web wizard to create the admin account.
## Try it online
| Branch | Notes |
| ------ | ----- |
| **main** | No public demo — see [screenshots](./index.md#screenshots) or the repository README |
| **cloudflare** | R2-only live demo: [pic.roven.cc](https://pic.roven.cc) |
## Docker (recommended)
```bash
docker run -d \
--name pichost \
-p 6892:6892 \
-v ./data:/data \
--restart unless-stopped \
muxui/pichost:latest
```
Default port: **6892**. Open `http://<host>:6892` and complete setup.
If you cloned the repo:
```bash
docker compose up -d
```
See `docker-compose.yml` in the repository.
## First-time setup
1. Open your instance URL (Docker default `http://<host>:6892`).
2. Complete `/setup` with admin username and password.
3. (Optional) Configure site URL, image URL, Referer rules — see [Environment variables](./configuration.md) and [Dual-domain separation](./domain-separation.md).
4. Upload from the home page; use the top nav for **Gallery**, **Storage** (admin), **Settings**, and **API**.
## Sign in
Management pages (upload, gallery, settings, etc.) require login. Enter username and password, **drag the slider to align with the gap**, then click **Sign in**.
![Login](/screenshots/login.png)
If registration is enabled, create an account from the register page. Legacy deployments may still use a secret key (see the on-screen hint).
## Forgot password
Requires `docker exec` (server access):
```bash
docker exec pichost reset-password # admin only when there is exactly one
docker exec pichost reset-password <username> # admin or regular user
```
A random password is printed. Change it after login.
Local dev: `npm run reset-password` or `npm run reset-password -- <username>`
## Upgrading to v1.2.0
If `data/` has legacy top-level folders next to `images` (e.g. `blog/`, `twikoo/`), read the [v1.2 migration guide](./migration.md) first.
Object-storage-only or files already under `data/images/` — pull the new image and restart.
## Next steps
- [Local development](./local-dev.md)
- [Environment variables](./configuration.md)
- [Storage](./storage.md)
- [API](./api.md)
+36
View File
@@ -0,0 +1,36 @@
# PicHost documentation
Welcome to the PicHost user guide. This site is the single source of truth; the repository README keeps a short overview and quick start.
## Suggested reading order
1. [Quick start](./getting-started.md) — Docker and first-time setup
2. [Environment variables](./configuration.md) — Production config vs in-app settings
3. [Changelog](./changelog.md) — Version history and upgrade notes
4. [Storage](./storage.md) — Backends and URL modes
5. [API](./api.md) — REST endpoints and tokens
## Screenshots
**main** has no public demo. Below are current product screenshots (v1.2.5+).
| API | Gallery |
| :-: | :-----: |
| ![API](/screenshots/api.png) | ![Gallery](/screenshots/gallery.png) |
| Storage | Settings |
| :-----: | :-------: |
| ![Storage](/screenshots/storage.png) | ![Settings](/screenshots/settings-en.png) |
| Activity log |
| :----------: |
| ![Activity log](/screenshots/logs.png) |
## Branches
| Branch | Notes |
| ------ | ----- |
| [**main**](https://github.com/O96u/PicHost/tree/main) | Full product; screenshots above |
| [**cloudflare**](https://github.com/O96u/PicHost/tree/cloudflare) | R2-only; live demo [pic.roven.cc](https://pic.roven.cc) |
Use **main** for multiple cloud backends; use **cloudflare** if you only deploy with Cloudflare R2.
+54
View File
@@ -0,0 +1,54 @@
# Local development
Full-stack Nuxt 4 development at the repository root. Data defaults to `./data`.
## Requirements
- Node.js **22**
- npm **11** (see `packageManager` in `package.json`)
## Start
```bash
npm install
cp .env.example .env # optional
npm run dev
```
Open `http://localhost:3000/setup` to create the admin account. Data directory defaults to `./data` (`DATA_DIR` overrides).
## Common commands
| Command | Description |
| ------- | ----------- |
| `npm run dev` | Dev server |
| `npm run build` | Production build |
| `npm run start` | Run `.output` |
| `npm run lint` | ESLint |
| `npm run typecheck` | Type check |
| `npm test` | Unit tests |
| `npm run reset-password` | Reset password (optional username) |
| `npm run migrate` | Preview legacy dir migration (`-- --apply` to run) |
## Dev-only variables
| Variable | Description |
| -------- | ----------- |
| `DEV_BYPASS_ACCESS` | `true` bypasses login (skips `/setup`; rarely needed) |
| `DATA_DIR` | Data directory, default `./data` |
Full list: [Environment variables](./configuration.md).
## Dual-domain local test
Edit `hosts` and follow [Dual-domain separation](./domain-separation.md) for local verification.
## Documentation site
```bash
npm run docs:dev
npm run docs:build
npm run docs:preview
```
Sources live in `docs-site/`; screenshot sources are in `docs/screenshots/` (including `login.png`), copied to `docs-site/public/screenshots/` before build. Live docs: <https://o96u.github.io/PicHost/>
+129
View File
@@ -0,0 +1,129 @@
# v1.2.0 local disk migration guide
From v1.2.0, **all local image files** live under `data/images/`; SQLite `keys` also start with `images/`. Legacy top-level folders next to `images` (e.g. `data/blog/`, `data/twikoo/`) are no longer used.
Migration is **two steps**: run the **CLI** to move files on disk, then let **startup sync** align the SQLite index.
---
## Migrating from other image hosts (EasyImage, Lsky, Chevereto, …)
There is **no** dedicated “pull from URLs” or “import via vendor API” tool. Moving from **legacy PicHost** or **another self-hosted** install uses the same **local file** workflow:
1. Locate the **image files** on the old server (copy the tree, not just the database).
2. Place them under PicHost `data/` as a **top-level folder** next to `images` (any name — it becomes `images/{name}/...`).
3. Run `migrate` / `migrate --apply`, then restart PicHost.
| Source | Typical path on old server | After copy into PicHost |
| ------ | -------------------------- | ----------------------- |
| PicHost v1.1.x | `data/blog/`, `data/twikoo/` | Already under `data/` → run `migrate` |
| EasyImage(s) | `i/` or `upload/` | e.g. `data/easyimage/` → `migrate` |
| Lsky | `public/uploads/` etc. | e.g. `data/lsky/` → `migrate` |
| Chevereto etc. | `images/` or `content/` | e.g. `data/chevereto/` → `migrate` |
| Custom | your folder name | `data/your-folder/` → `migrate` |
The CLI scans **every top-level directory under `data/` except `images`**, not only `blog` / `twikoo`.
**Limits:** files only — accounts, tokens, and old public URLs are not imported. `mapping.json` maps old **storage keys**, not full old-site URLs. For S3/R2-only legacy hosts, sync blobs to `data/…` or attach the bucket in PicHost **Storage** settings.
---
## Layout before and after
### Before (typical v1.1.x)
```
/data
├── pichost.db
├── blog/
│ └── 2026/08/TeKJ2fD6cB6j.webp
├── twikoo/
└── images/
```
Index keys might look like `blog/2026/08/TeKJ2fD6cB6j.webp`.
### After (v1.2.0)
```
/data
├── pichost.db
├── mapping.json
└── images/
├── blog/
│ └── 2026/08/TeKJ2fD6cB6j.webp
└── twikoo/
```
Index key: `images/blog/2026/08/TeKJ2fD6cB6j.webp`.
---
## Step 1: CLI file migration
Run **before or after** upgrading the image; backup `data/` first.
```bash
docker exec pichost migrate # preview + mapping.json
docker exec pichost migrate --apply # move files
```
Local: `npm run migrate` / `npm run migrate -- --apply`.
| Item | Behavior |
| ---- | -------- |
| Scan | Every top-level directory under `data/` **except `images`** |
| Target | `data/images/{original-top-dir}/{relative-path}` |
| Extensions | jpg, jpeg, png, webp, gif, svg, ico |
| Sidecar | `.meta.json` next to an image is moved if present |
| Other files | Not moved; parent directory kept if anything remains |
| Mapping | `data/mapping.json` — old key → new key |
Script: `server/cli/migrate-to-single-images.mjs`.
---
## Step 2: Startup index sync (automatic)
Runs on every start (`npm run dev`, Docker). No manual command.
```text
[INFO] 开始同步图片索引:扫描本地磁盘 ...
[INFO] 磁盘扫描完成:...
[INFO] 开始归一化遗留图片路径 ...
[INFO] 开始校验索引与磁盘一致性,清理无文件的孤儿记录
[INFO] 图片索引同步结束
```
| Step | Purpose |
| ---- | ------- |
| Scan `data/images/` | Insert missing index rows for files on disk |
| Normalize legacy keys | `blog/...` → `images/blog/...` when files are in place |
| Purge orphans | Remove index rows with no file on disk |
| Repair Content-Type | Fix `application/octet-stream` entries |
The CLI only **moves files**. Index alignment runs at startup — **restart** after `migrate --apply`.
---
## Recommended upgrade flow
1. Backup `data/`.
2. `docker exec pichost migrate` — review output and `mapping.json`.
3. `docker exec pichost migrate --apply`.
4. Deploy v1.2.0 and restart.
5. Check logs for successful index sync.
6. Use `mapping.json` to update external URLs if needed.
Skip step 1 if you only use object storage or everything is already under `data/images/`.
---
## URLs
| Old key | `blog/TeKJ2fD6cB6j.webp` |
| New key | `images/blog/TeKJ2fD6cB6j.webp` |
With “hide folder” mode, short URLs like `/TeKJ2fD6cB6j.webp` may still resolve by filename. Use `mapping.json` for bulk replacements.
Chinese version: [v1.2 迁移指南](/guide/migration)
+83
View File
@@ -0,0 +1,83 @@
# Reverse proxy
In production, terminate HTTPS with Nginx, Caddy, NPM (Nginx Proxy Manager), or your NAS proxy and forward to PicHost on port **6892**.
## Basics
- Upstream: `http://127.0.0.1:6892` (or host IP)
- Forward: `Host`, `X-Forwarded-Proto` (and `X-Forwarded-For` as needed)
- Body size: Nginx `client_max_body_size 12m` or larger
- Use **HTTPS** on the public edge
## Single domain (Nginx)
Admin, API, and images on one hostname:
```nginx
server {
server_name pic.example.com;
location / {
proxy_pass http://127.0.0.1:6892;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
client_max_body_size 12m;
}
}
```
Optional:
```env
IMAGE_BASE_URL=https://pic.example.com
```
Without `IMAGE_BASE_URL`, PicHost infers the public URL from forwarded headers.
## Dual domains
Both hostnames can fully proxy to the same port; isolation is enforced in PicHost middleware.
See [Dual-domain separation](./domain-separation.md).
## Cloudflare orange cloud
See [Cloudflare deployment](./cloudflare-deployment.md).
| Practice | Notes |
| -------- | ----- |
| **Proxied DNS** | Both admin and image hostnames → origin proxy to `6892`, SSL **Full (strict)** |
| Preferred edge IPs | Compatible; keep **admin → admin**, **img → img** on origin |
| **R2** | Object storage backend in **Storage**; not the same as hosting the app on Pages |
| Origin firewall | Allow only [Cloudflare IPs](https://www.cloudflare.com/ips/); do not expose `6892` publicly |
**Avoid:**
- **Cloudflare Pages** (`*.pages.dev`) or **Workers** (`*.workers.dev`) as a **full-site reverse proxy**, or chaining the image host to `fetch(admin…)` — rewrites Host and bypasses dual-domain isolation ([guide](./cloudflare-deployment.md))
- “Create Worker” from Git on this repo — `node-server` preset is not Workers-ready
## Origin hardening (dual-domain)
PicHost middleware does **not** reject unlisted Host values. Complement with:
1. **`server_name` allowlist** — site + image hosts only; `default_server` blocks bare IP and unknown Host
2. **Do not expose 6892 on the public Internet** — only local reverse proxy; public 80/443 only
3. **Orange cloud** — origin firewall limited to Cloudflare IP ranges
## Caddy
```caddy
pic.example.com {
reverse_proxy 127.0.0.1:6892
}
```
Add a second site block for the second hostname with the same upstream.
## Caching
Image 404/403 and domain-isolation 404 responses are **no-cache** to avoid sticky CDN errors. Be careful caching API paths behind a CDN.
## See also
- [Environment variables](./configuration.md)
- [Quick start](./getting-started.md)
+68
View File
@@ -0,0 +1,68 @@
# 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.
![Storage management](/screenshots/storage.png)
## Layout (local)
```
/data
├── pichost.db # SQLite: users, sessions, settings, storage_backends, images index
└── images/ # All files when using local backend
```
- New uploads: `images/randomId.webp` or `images/YYYY/MM/randomId.webp` (flat mode in Settings)
- Migrated legacy: `images/blog/...` and similar
- With cloud backends, blobs live in the bucket; index stays in SQLite `images`
All **keys** start with `images/`. The upload API does **not** accept a `folder` parameter.
## Adding a backend
1. Sign in as admin → **Storage** → **Add backend**.
2. Choose **local disk** or **S3-compatible** (R2 / COS / OSS presets).
3. Enter connection details (bucket, endpoint, keys).
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).
### Cloudflare R2
| Field | Value |
| ----- | ----- |
| Endpoint | `https://<account_id>.r2.cloudflarestorage.com` |
| Region | `auto` |
| Bucket | Your R2 bucket name |
| Keys | R2 API token access / secret |
The **cloudflare** branch streamlines R2-only deploys; see branch notes on the [docs overview](./index.md).
### 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.
## URL modes
| Mode | Behavior |
| ---- | -------- |
| **proxy** | Serve via PicHost (`GET /images/...`) |
| **public** | 302 redirect to bucket/CDN public URL |
Copied links use **IMAGE_BASE_URL**. “Hide folder prefix” may shorten URLs; the server resolves by path or basename.
## Usage & gallery filter
- Per-backend usage on the Storage page
- Gallery filters by **storage backend** and **upload source**; grid/list view toggle
## Backup
Back up `data/images/`, `data/pichost.db`, and bucket objects.
Upgrading from v1.1.x with parallel folders: [v1.2 migration](./migration.md).
## See also
- [Environment variables](./configuration.md)
- [Users & permissions](./users-and-permissions.md)
+49
View File
@@ -0,0 +1,49 @@
# Twikoo
PicHost supports **Twikoo** and **EasyImage 2.0** image upload — configure your comment system like EasyImage.
## Twikoo settings
| Setting | Value |
| ------- | ----- |
| `IMAGE_CDN` | `easyimage` |
| `IMAGE_CDN_URL` | `https://your-domain/api/index.php` |
| `IMAGE_CDN_TOKEN` | Same as global `API_UPLOAD_TOKEN` |
- Use your **site hostname** (admin domain) in `IMAGE_CDN_URL`
- Generate the token in PicHost **API** or set `API_UPLOAD_TOKEN`
## Upload endpoint
`POST /api/index.php`
| Field | Description |
| ----- | ----------- |
| `image` | Image file |
| `token` | Global API token (same as `Auth-Token`) |
```bash
curl -X POST "https://admin.example.com/api/index.php" \
-F "token=YOUR_TOKEN" \
-F "image=@./demo.png"
```
Files are stored under `images/` and owned by the **global token** account (admin).
## REST vs Twikoo
| Aspect | REST `/api/images/upload` | Twikoo `/api/index.php` |
| ------ | ------------------------- | ------------------------ |
| Auth | `Auth-Token` header | form `token` |
| Response | PicHost JSON | EasyImage-compatible JSON |
| Token | Global or personal | Global only |
## Dual-domain
Point Twikoo uploads at **SITE_BASE_URL**; public images use **IMAGE_BASE_URL**. See [Dual-domain separation](./domain-separation.md).
## See also
- [API](./api.md)
- [Users & permissions](./users-and-permissions.md)
- [Environment variables](./configuration.md)
@@ -0,0 +1,58 @@
# Users & permissions
PicHost uses role-based access control (RBAC): **admin** and **user**. Enforcement is server-side.
## Role capabilities
| Capability | Admin | User |
| ---------- | :---: | :--: |
| Upload images | ✓ | ✓ |
| Own gallery | ✓ | ✓ |
| All galleries / uploader badges | ✓ | — |
| Storage management (`/storage`) | ✓ | — |
| System settings (`/settings`) | ✓ | — |
| Allow registration | ✓ | — |
| Global API token | ✓ | — |
| Personal API token | ✓ | ✓ |
| Gallery stats overview (incl. user count) | ✓ | partial |
| Activity log (Settings → Activity log) | ✓ | — |
Regular users see **API**, **Gallery** in the nav; user menu: **Change password**, **Sign out**.
## Auth scenarios
| Scenario | Auth | Ownership / visibility |
| -------- | ---- | ---------------------- |
| Web upload | Session cookie | Current user |
| API upload + **user token** | `Auth-Token` header | Token owner |
| API upload + **global token** | `Auth-Token` header | Admin (`userId` null) |
| Twikoo `POST /api/index.php` | form `token` | Same as global token |
| Gallery list / search / delete | Session or token | Users: own only; admin: all |
| Direct link `GET /images/...` | None (Referer rules) | Public if URL is known |
## Accounts
- By default only admins create users; **allow registration** is an admin setting
- Passwords hashed with scrypt
- Web login requires a slider captcha (align the thumb with the gap)
- Login rate limiting
## Upload preferences & auto-delete
- **Upload preferences**: admins configure client compression and auto-copy in **Settings → Basic**; regular users can flip the home upload card for personal preferences
- **Auto-delete**: global (admin) and per-user policies; affects **new uploads after enable** only
## API tokens
| Type | Managed by | Upload owner | Typical use |
| ---- | ---------- | ------------ | ----------- |
| **Global** | Admin (API page) | Admin account | Twikoo, site-wide scripts |
| **Personal** | Each user (API page) | That user | Personal blog, private scripts |
`API_UPLOAD_TOKEN` in env locks the global token — see [Environment variables](./configuration.md).
## See also
- [API](./api.md)
- [Twikoo](./twikoo.md)
- [Storage](./storage.md)
+32
View File
@@ -0,0 +1,32 @@
---
layout: home
hero:
name: PicHost
text: Lightweight image hosting
tagline: Self-hosted · Multi-user · Docker · API · Twikoo · Local disk or object storage
actions:
- theme: brand
text: Quick start
link: /en/guide/getting-started
- theme: alt
text: API reference
link: /en/guide/api
- theme: alt
text: GitHub
link: https://github.com/O96u/PicHost
features:
- title: Drag, click, paste
details: Server-side WebP compression and Referer hotlink protection.
- title: Multi-backend storage
details: Local disk plus S3-compatible backends (R2 / COS / OSS / AWS); proxy or public URLs.
- title: Users & RBAC
details: Admin and regular users; global and per-user API tokens; gallery scoped by owner.
- title: Zero-config Docker
details: First-run web wizard; reset passwords with `docker exec pichost reset-password`.
- title: Dual-domain separation
details: One instance, two hostnames, full reverse proxy; middleware enforces isolation.
- title: Twikoo compatible
details: "POST /api/index.php follows EasyImage 2.0 for comment systems."
---
+99
View File
@@ -0,0 +1,99 @@
# API
PicHost 提供 REST 接口与 Twikoo 兼容端点。登录后打开顶栏 **API** 页:左侧为接口目录与 Token 管理,中间为参数说明与 cURL 示例,右侧可 **在线调试** 发送请求。
## 鉴权
所有 REST 接口使用请求头:
```http
Auth-Token: YOUR_TOKEN
```
| Token 类型 | 获取方式 | 说明 |
| ---------- | -------- | ---- |
| **全局 Token** | 管理员 **API** 页生成;或 `API_UPLOAD_TOKEN` 环境变量 | 环境变量优先且锁定后台重新生成 |
| **个人 Token** | 各用户 **API** 页 | 仅用于该用户脚本,上传归属本人 |
表单上传仍可使用字段 `token`(Twikoo 协议)。
## 接口列表
### 1. 上传图片
`POST /api/images/upload`
上传单张或多张图片。字段 `image` 传图片;仍兼容 `file`、`files`。存入 `images/`。
```bash
curl -X POST "https://admin.example.com/api/images/upload" \
-H "Auth-Token: YOUR_TOKEN" \
-F "image=@./demo.png"
```
### 2. 获取图片列表
`GET /api/images`
分页列出图库。`limit` 默认 20、最大 100。
```bash
curl "https://admin.example.com/api/images?limit=20&page=1" \
-H "Auth-Token: YOUR_TOKEN"
```
### 3. 搜索图片
`GET /api/images/search`
按文件名或路径关键词搜索,参数 `q` 必填。
```bash
curl "https://admin.example.com/api/images/search?q=demo&limit=20&page=1" \
-H "Auth-Token: YOUR_TOKEN"
```
### 4. 删除图片
`DELETE /api/images`
通过 `key` 删除单张,`key` 为存储路径(如 `images/2026/08/xxx.webp`)。
```bash
curl -X DELETE "https://admin.example.com/api/images?key=images/2026/08/xxxx.webp" \
-H "Auth-Token: YOUR_TOKEN"
```
### 5. 批量删除
`POST /api/images/batch-delete`
请求体 JSON:`{"keys":["images/2026/08/a.webp","images/2026/08/b.webp"]}`
```bash
curl -X POST "https://admin.example.com/api/images/batch-delete" \
-H "Auth-Token: YOUR_TOKEN" \
-H "Content-Type: application/json" \
-d '{"keys":["images/2026/08/a.webp","images/2026/08/b.webp"]}'
```
## 错误格式
REST 接口返回 JSON,含 `code` 与 `message`(如 `UNAUTHORIZED`、`FORBIDDEN`、`INVALID_REQUEST`)。Twikoo 端点使用 EasyImage 兼容 JSON 格式。
## 权限与可见范围
- 普通用户 Token:列表/搜索/删除仅作用于自己的图片
- 管理员 Token:可访问全部图片
- 全局 Token 上传的图片归属管理员
详见 [用户与权限](./users-and-permissions.md)。
## Twikoo
`POST /api/index.php` 见 [Twikoo](./twikoo.md)。
## 相关
- [环境变量](./configuration.md) — `API_UPLOAD_TOKEN`
- [反向代理](./reverse-proxy.md) — 公网 URL 与 HTTPS
+168
View File
@@ -0,0 +1,168 @@
# 更新日志
PicHost 版本历史与重要变更说明。完整记录亦见仓库根目录 [CHANGELOG.md](https://github.com/O96u/PicHost/blob/main/CHANGELOG.md)。
---
## [1.2.7] — 2026-09-03
### 新增
- **登录验证**:登录/注册支持本地滑块、Cloudflare Turnstile、Cap;在「设置 → 访问控制」配置
- **运维 CLI**:`slider` 将验证方式重置为本地滑块(`docker exec pichost slider`)
### 改进
- 登出后登录页验证码可正常重新加载
## [1.2.6] — 2026-09-01
### 新增
- **管理界面**:设置、API、图库、存储页 UI 重构;API 页支持在线调试
- **图库筛选**:按存储后端筛选
- **存储标识**:新建 R2 / COS / OSS / S3 后端按类型生成 `r2-*`、`cos-*`、`oss-*`、`s3-*` 前缀
### 改进
- 操作日志表格样式与路径截断
- 存储后端卡片信息行布局
- 文档站与 README 截图更新
## [1.2.5] — 2026-08-31
### 新增
- **图库页**:导航「统计」改为「图库」(`/gallery`);点击图片打开详情弹层(大图、尺寸、上传方式、存储路径、链接复制)
- **列表操作**:全选 / 反选;单张删除移至详情弹层
- **上传来源**:图库 API 返回网页 / API 上传方式(来自活动日志)
### 改进
- README 补充技术栈章节
### 移除
- `/stats` 路由(不再保留兼容跳转)
## [1.2.4] — 2026-08-31
### 修复
- **访问控制留空仍拦截外链**:未配置 Referer 白名单时,浏览器直开图片正常,但博客/论坛嵌入会因 Referer 校验返回 403 图裂;现仅在配置了白名单时才启用防盗链
## [1.2.3] — 2026-08-30
### 修复
- **简短图片链接 + 按年/月分组**:纯文件名直链(如 `https://域名/xxx.webp`)不再误当成 `images/xxx.webp`,改为经 SQLite 索引反查 `images/年/月/xxx.webp`,缩略图与短链恢复正常
---
## [1.2.2] — 2026-08-30
> 升级后请**重启服务**使中间件与 API 生效。
### 双域名配置与 Host 隔离
#### 修复
| 问题 | 说明 |
| ---- | ---- |
| 管理域被误清空 | 设置页保存时,关闭双域名或仅改图片域可能把 `site_base_url` 写成空字符串,导致 UI 切回单域、管理域「消失」 |
| 配置与生效 URL 混淆 | `GET /api/settings` 的 `siteBaseUrl` / `imageBaseUrl` 曾混入当前请求的 `origin`,设置页「当前生效」与数据库配置难以区分 |
| 第三 Host 绕过隔离 | `pages.dev`、裸 IP、旧图片域等未纳入配置的 Host 此前不被中间件拦截,后台/API 可能仍可访问 |
#### 改进
**设置 API(`GET /api/settings`)**
| 字段 | 含义 |
| ---- | ---- |
| `siteBaseUrl` / `imageBaseUrl` | **配置值**(DB → 环境变量),不含 request fallback |
| `effectiveSiteBaseUrl` / `effectiveImageBaseUrl` | **直链生成用**,未配置时回退到当前请求 origin |
| `runtime.currentOrigin` | 检测到的当前访问地址(仅展示,不写入配置) |
| `runtime.hostRole` | `site` / `image` / `unknown` / `single` |
**保存 API(`PATCH /api/settings`)**
- 新增 body 字段 `domainSeparation`(`true` / `false`),显式表达是否启用双域名
- `domainSeparation: true` 时网站域与图片域**均必填**,且主机名不能相同
- `domainSeparation: false` 时允许清空网站域
- 未传 `domainSeparation` 且双域名已配置时,**禁止**单独把 `siteBaseUrl` 置空
**设置页 / 首次设置**
- 表单回填配置值;「当前生效」显示 `effective*` 字段
- 展示「检测到当前访问」地址,**不再**自动写入 `window.location.origin`
- 提供「填入检测地址」按钮(用户主动点击才填)
- 关闭双域名需确认弹窗
- 双域名开启且 `hostRole === 'unknown'` 时显示反代绕过警告
**Host 中间件**
- 双域名开启时,非 site/image 的 Host **应用层一律 404**(所有 HTTP 方法)
- 开发环境 `localhost` / `127.0.0.1` 例外,便于 `npm run dev`
- 与 Nginx `default_server` **互补**,建议两层都配置
#### 文档
- 重写 [Cloudflare / CF 优选部署](./cloudflare-deployment.md)(精简结构、检查清单、双层防护)
- 更新 [双域名分离](./domain-separation.md) 第三 Host 与应用层拦截说明
- 新增本页 **更新日志**
#### 升级说明
- **无需**数据库迁移或 CLI 操作
- 若管理域此前已被误清空:在 **设置** 中重新填写网站域,或设置环境变量 `SITE_BASE_URL` 后重启
- 生产双域名部署:确认反代 `server_name` 与设置一致;建议保留 Nginx default server
---
## [1.2.1] — 2026-08-29
### 新增
- 登录滑动拼图验证(缺口对齐);验证与登录分两步
- VitePress 文档站(`docs-site/`),部署至 GitHub Pages
- `npm run docs:dev` / `docs:build` / `docs:preview`
### 修复
- 统计页删除图片后图库不刷新
- 分页跳转输入框在 `type="number"` 下无法跳转
- 非首页路由下复制链接格式偏好不持久化
- 滑动验证 UI:滑块垂直居中、缺口尺寸与滑块一致
### 改进
- README 精简,详细说明迁入文档站
- 双域名文档补充未知 Host 风险与 Cloudflare 部署说明
---
## [1.2.0] — 2026-08-28
### 变更
- 图片统一存放在 `data/images/`;遗留 `blog/`、`twikoo/` 等并列目录需迁移
### 新增
- CLI:`docker exec pichost migrate` / `migrate --apply`
- 启动时自动同步图片索引(扫描磁盘、归一化遗留 key、清理孤儿记录)
详见 [v1.2 迁移](./migration.md)。
---
## [1.1.x] 摘要
| 版本 | 要点 |
| ---- | ---- |
| 1.1.5 | API 上传后预览偶发失败修复;出图前校验文件存在 |
| 1.1.4 | 按年/月分组存储;网站域禁止直链出图 |
| 1.1.3 | 双域名分离(`SITE_BASE_URL` / `IMAGE_BASE_URL`);Host 中间件 |
| 1.1.0 | 多存储后端(S3 / R2 等) |
更早版本见 [GitHub CHANGELOG](https://github.com/O96u/PicHost/blob/main/CHANGELOG.md)。
+240
View File
@@ -0,0 +1,240 @@
# Cloudflare / CF 优选部署说明
PicHost 双域名模式下,应用根据请求 **Host**(及可信反代转发的 `X-Forwarded-Host`)区分**管理域**与**图片域**。使用 Cloudflare 橙云、优选线路、Workers 或 Pages 时,只要最终到达 PicHost 的 Host 身份不变,即可正常工作。
> **一句话原则:** 网络线路可以改变,请求的域名身份不能改变 — `admin → admin`,`img → img`,不要 `img → admin`。
基础概念见 [双域名分离](./domain-separation.md);反代通用要求见 [反向代理](./reverse-proxy.md)。
---
## 快速检查清单
部署或排错时,按顺序确认:
| # | 检查项 | 期望 |
| - | ------ | ---- |
| 1 | 浏览器地址栏域名 | `admin.*` 或 `img.*`,不要用 IP / `pages.dev` 混用 |
| 2 | 中间是否经过 Pages / Worker | 图片域**不得** `fetch(admin…)` |
| 3 | 反代 upstream | 两个域名均指向 `http://127.0.0.1:6892` |
| 4 | Nginx `proxy_set_header Host` | `$host`(保持客户端域名) |
| 5 | PicHost 设置 | `SITE_BASE_URL` / `IMAGE_BASE_URL` 与 `server_name` 一致 |
| 6 | 端口 `6892` | 仅本机或内网反代可达,不对公网裸露 |
---
## 推荐拓扑
两个域名经 Cloudflare(橙云)后,**各自独立**反代到同一 PicHost 实例:
```mermaid
flowchart TB
internet[Internet]
cf[Cloudflare 橙云]
admin[admin.example.com]
img[img.example.com]
panel[1Panel / Nginx]
app[PicHost :6892]
internet --> cf
cf --> admin
cf --> img
admin --> panel
img --> panel
panel --> app
```
```env
SITE_BASE_URL=https://admin.example.com
IMAGE_BASE_URL=https://img.example.com
```
```text
admin.example.com → http://127.0.0.1:6892
img.example.com → http://127.0.0.1:6892
```
不需要为「前端 / 后端 / 图片」开不同端口;一个 PicHost 实例、一个内部端口(默认 `6892`)即可。
### 管理域 ≠ 前端域名
| 域名 | 用途 |
| ---- | ---- |
| `admin.example.com` | 后台、登录、设置、图库、API、上传 |
| `img.example.com` | 图片直链、外链、CDN 分发 |
更准确的说法是**管理域 / 图片域**,而非「前端 / 后端」。PicHost 生产环境也**不需要** `前端 :3000 + 后端 :6892` 这种拆分。
---
## Cloudflare 橙云与 CF 优选
**橙云(DNS Proxied)** 是正常且推荐的方式:
```text
admin.example.com、img.example.com
A / CNAME → 源站 → Proxied
```
用户访问 `admin.example.com` 时,PicHost 应收到 `Host: admin.example.com`;访问 `img.example.com` 时应收到 `Host: img.example.com`。
**CF 优选**(优选 IP、优选域名、边缘入口)同样可用。优选可以改变用户到 Cloudflare 边缘的路径,但**不能**把图片域的回源身份变成管理域。两个域名应保持平行、独立的回源链:
```text
admin → CF → admin.example.com → 源站 → PicHost
img → CF → img.example.com → 源站 → PicHost
```
---
## 六、典型错误拓扑
以下方案会破坏 Host 隔离,即使浏览器地址栏仍显示图片域:
### ❌ 错误 1:图片域 fetch 到管理域
```mermaid
flowchart LR
user[用户 img.example.com]
worker[Pages / Worker]
admin[fetch admin.example.com]
app[PicHost]
user --> worker --> admin --> app
```
最后一跳 Host 变成 `admin.example.com`,PicHost 会认为这是管理域 — `https://img.example.com/` 可能看到后台。这是 **Host 被改写**,不是缓存问题。
### ❌ 错误 2:优选回源到管理域
```text
img.example.com → 优选域名 → admin.example.com → 源站
```
即使只为改善线路,只要 upstream 使用管理域,图片域隔离即失效。
### ❌ 错误 3:公网直接暴露 6892
```text
任意域名 / IP:6892 → PicHost
```
用户可绕开 Cloudflare 与 Nginx 的 `server_name` 限制。`6892` 应仅允许 `127.0.0.1` 或内网反代访问。
### ✅ 正确做法
```text
admin → admin → PicHost
img → img → PicHost
```
**不要** Pages / Workers **整站反代** PicHost(会多出 `pages.dev` / `workers.dev` 等第三入口)。PicHost 依赖 `node-server`、SQLite、`sharp` 与本地 `data/`,**不能**作为 Node 应用直接部署到 Pages/Workers;应用本体继续 Docker/VPS 部署,CF 侧用橙云 DNS + 可选 R2 即可。
---
## Workers / Pages 使用建议
若只需改响应头、鉴权、防盗链、日志或缓存,且源站仍是自己的 PicHost:
| 推荐 | 不推荐 |
| ---- | ------ |
| `img.example.com` → **Worker Route** → 原 `img` 源站 | `img` → Worker → `fetch(admin.example.com)` |
Cloudflare 对已有外部源站的场景更推荐 [Routes](https://developers.cloudflare.com/workers/configuration/routing/routes/)(在现有 proxied 域名与源站之前运行),而非用 [Custom Domain](https://developers.cloudflare.com/workers/configuration/routing/custom-domains/) 把 Worker 当作跨域 fetch 的中转。
Worker 中若出现:
```js
fetch(`https://admin.example.com${url.pathname}`)
```
即基本可确定隔离被破坏。
---
## Nginx / 1Panel 配置
两个 `server` 块,upstream 相同,**关键行**是 `proxy_set_header Host $host;`:
```nginx
server {
server_name admin.example.com;
location / {
proxy_pass http://127.0.0.1:6892;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
client_max_body_size 12m;
}
}
server {
server_name img.example.com;
location / {
proxy_pass http://127.0.0.1:6892;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
}
# 拒绝裸 IP 与未配置域名(强烈建议)
server {
listen 80 default_server;
listen 443 ssl default_server;
server_name _;
return 444;
}
```
橙云部署时,源站防火墙建议**仅放行 [Cloudflare IP 段](https://www.cloudflare.com/ips/)**,避免绕过 CDN 直连。
---
## 双层 Host 防护
生产环境建议反代层与应用层**互补**,而非二选一:
| 层级 | 作用 |
| ---- | ---- |
| **Nginx default server** | 拒绝裸 IP、`pages.dev` 等未在 `server_name` 中声明的 Host |
| **PicHost 中间件(v1.2.x+)** | 双域名开启时,非 site/image 的 Host 应用层返回 404(开发环境 `localhost` / `127.0.0.1` 例外) |
即使应用层已拦截第三 Host,仍建议在反代配置 default server — 减少无效流量到达 PicHost。
---
## X-Forwarded-Host 说明
PicHost 在反代场景会参考 `X-Forwarded-Host` 识别请求域名,但**不能无条件信任**该 Header(客户端可伪造)。因此:
- 不要让 `6892` 对公网裸露
- 确保只有可信反代能到达应用
- 首选方案仍是让 `Host` 从第一跳起就保持正确
---
## 排查:图片域能看到后台?
**先查反代链,不要先清 CF 缓存。**
缓存问题通常表现为旧 JS/CSS/图片或偶发 404;若图片域能**稳定**访问 `/`、`/login`、`/settings`,几乎都是 Host 被改写。
```text
① 浏览器实际域名?
② 是否经过 Pages / Worker / 优选反代?
③ 代理最终 fetch 的 URL?
④ 是否 fetch 到 admin.example.com?
⑤ 1Panel / PicHost 最终收到的 Host?
```
---
## 相关
- [双域名分离](./domain-separation.md) — 中间件行为与本地测试
- [反向代理](./reverse-proxy.md) — 单域名与源站加固
- [环境变量](./configuration.md) — `SITE_BASE_URL`、`IMAGE_BASE_URL`
- [常见问题](./faq.md) — Pages / Workers 与第三 Host
+101
View File
@@ -0,0 +1,101 @@
PicHost 支持通过环境变量(Docker / `.env`)与后台 **设置** 页配置。管理员在 **设置与偏好**(`/settings`)左侧切换 **基础设置**、**域名与路径**、**访问控制**、**操作日志**;多数项 **SQLite 优先**;部分项环境变量可覆盖或锁定后台编辑。
## 优先级总览
| 配置项 | 优先级(高 → 低) | 环境变量覆盖后台 |
| ------ | ----------------- | ---------------- |
| API 上传 Token | 环境变量 → SQLite | **是**(设了则后台无法重新生成) |
| WebP 质量 | SQLite → 环境变量 → 默认 80 | 否(未在 DB 存时读 env) |
| 防盗链白名单 | SQLite → 环境变量 | 否 |
| 网站 / 图片域名 | SQLite → 环境变量(设置 API 配置值);直链生成另用 `effective*` 或请求 origin | 否 |
| 简短图片链接 | SQLite → 环境变量 → 默认 false | 否 |
| 按年/月分组存储 | SQLite → 环境变量 → 默认 true | 否 |
| 全局自动删除天数 | SQLite → 环境变量 → 默认 0 | 否 |
## 变量说明
### `API_UPLOAD_TOKEN`
外部脚本上传 Token(Twikoo / 油猴 / 博客)。请求头:`Auth-Token: <token>`;表单上传仍可用 `token` 字段。
- 未配置时可在后台 **API** 页生成
- **环境变量一旦设置,优先级最高**,后台显示「由环境变量覆盖」且无法重新生成
- 别名:`NUXT_API_UPLOAD_TOKEN`
### `ALLOWED_REFERER_HOSTS`
防盗链白名单,逗号分隔域名。自身域名自动放行。可选;不填可在设置页配置。若环境变量有值且 SQLite 未存,则使用环境变量。
### `SITE_BASE_URL` / `IMAGE_BASE_URL`
- **网站域名**:管理后台、API、Twikoo 上传入口
- **图片域名**:复制链接与直链使用的公网地址
双域名分离需两者主机名不同,详见 [双域名分离](./domain-separation.md) 与 [Cloudflare / CF 优选部署](./cloudflare-deployment.md)。填写后须与反代 `server_name` 一致;**勿**通过 `localhost`、IP 或未配置的域名访问生产后台。
在后台 **设置** 中保存时须显式勾选「启用后台与图片域名分离」,并同时填写两个域名;关闭双域名会清除网站域配置(需确认)。设置 API 返回的配置值与「当前生效」直链地址已分离,详见 [更新日志](./changelog.md#1-2-2-2026-08-30)。
### `HIDE_FOLDER_IN_URL`
`true` 时使用简短图片链接(仅域名 + 文件名,如 `img.example.com/a8K3xP.webp`);`false` 时保留日期或自定义目录(如 `img.example.com/2026/08/a8K3xP.webp`)。对外 URL **不会** 暴露内部 `images/` 前缀;旧版含 `images/` 的直链仍兼容。
### `STORAGE_USE_DATE_PATH` / `STORAGE_LAYOUT`
- `STORAGE_USE_DATE_PATH=true`(默认)或 `STORAGE_LAYOUT=date`:新图保存为 `images/年/月/随机ID.webp`
- `false` 或 `STORAGE_LAYOUT=flat`:保存为 `images/随机ID.webp`
也可在设置页切换。
### `DATA_DIR`
图片与 SQLite 根目录。默认 `./data`,容器内为 `/data`。
### `WEBP_QUALITY`
服务端 WebP 压缩质量 1–100,默认 **80**。
### `AUTO_DELETE_DAYS`
全局自动删除超过 N 天的图片,`0` 表示关闭。启用后仅影响**之后新上传**的图片(与按用户策略一致)。也可在设置页配置。
### `DEV_BYPASS_ACCESS`
仅本地开发:`true` 绕过登录验证。生产环境勿用。
### 存储后端(可选)
管理员通常在 **存储** 页添加后端;也可用环境变量引导(如 `STORAGE_BACKEND=s3` 与 `S3_*`)。详见 [存储](./storage.md)。
## Docker Compose 示例
```yaml
services:
pichost:
image: muxui/pichost:latest
ports:
- "6892:6892"
volumes:
- ./data:/data
environment:
SITE_BASE_URL: https://admin.example.com
IMAGE_BASE_URL: https://pic.example.com
API_UPLOAD_TOKEN: your-secret-token
WEBP_QUALITY: "85"
```
修改环境变量后需 **重启容器** 才能生效(Token、质量等读取在启动时或请求时按实现读取)。
## 与后台设置对照
| 后台设置项 | 对应环境变量 |
| ---------- | ------------ |
| API Token | `API_UPLOAD_TOKEN` |
| 防盗链 | `ALLOWED_REFERER_HOSTS` |
| 网站域名 | `SITE_BASE_URL` |
| 图片域名 | `IMAGE_BASE_URL` |
| 隐藏目录前缀 | `HIDE_FOLDER_IN_URL` |
| 按年/月分组 | `STORAGE_USE_DATE_PATH` / `STORAGE_LAYOUT` |
| WebP 质量 | `WEBP_QUALITY` |
| 自动删除 | `AUTO_DELETE_DAYS` |
完整模板见仓库根目录 [`.env.example`](https://github.com/O96u/PicHost/blob/main/.env.example)。
+180
View File
@@ -0,0 +1,180 @@
# 双域名分离 · 推荐部署架构
启用「后台与图片域名分离」后,管理后台、API 与图片直链使用不同域名。PicHost 通过 **Host 中间件** 双向隔离:
- **网站域**:后台、API、上传;**禁止**直链出图(图片路径返回 404)
- **图片域**:仅放行图片路径;其余请求返回 404
## 架构说明
**单 Docker 实例 + 两个域名**,均可 **全量反代** 到 `6892`(隔离由 PicHost 中间件负责,无需在 Nginx 层拆分路径)。
| 域名 | 用途 |
| ---- | ---- |
| `admin.example.com` | 管理后台、API、Twikoo 上传 |
| `pic.example.com` | 图片直链(中间件拦截非图片路径) |
初始化时在 `/setup` 或后台 **设置** 中填写:
- **网站域名**:`https://admin.example.com`
- **图片域名**:`https://pic.example.com`
首次设置与系统设置**不会**自动把当前浏览器地址写入网站域;可手动填写或点击「填入检测地址」。关闭双域名需确认,且会清除网站域配置。保存时 API 会收到 `domainSeparation: true/false` 字段。详见 [更新日志](./changelog.md#1-2-2-2026-08-30)。
## Nginx 示例
```nginx
# admin.example.com — 后台 + API(不出图,直链请走图片域)
server {
server_name admin.example.com;
location / {
proxy_pass http://127.0.0.1:6892;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
client_max_body_size 12m;
}
}
# pic.example.com — 同样全量反代;PicHost 中间件会拦截非图片路径
server {
server_name pic.example.com;
location / {
proxy_pass http://127.0.0.1:6892;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
# 默认 server:拒绝裸 IP、未配置域名等其它 Host(生产环境强烈建议)
server {
listen 443 ssl default_server;
listen 80 default_server;
server_name _;
return 444;
}
```
> Caddy、NPM(Nginx Proxy Manager)等工具同理:两个域名均反代到 `http://<主机>:6892`,并保留 `Host` 与 `X-Forwarded-Proto` 头。
## 安全与注意事项
### 中间件隔离与第三 Host
PicHost 根据请求 **Host**(反代会转发为 `X-Forwarded-Host`,应用会一并识别)判断当前是网站域还是图片域:
- **图片域**:非图片路径(如 `/`、`/settings`)→ 404
- **网站域**:图片直链路径(如 `/images/...`)→ 404
- **未配置的第三 Host**(如 `*.pages.dev`、裸 IP、旧图片域):**v1.2.x 起应用层一律 404**(开发环境 `localhost` / `127.0.0.1` 例外,便于 `npm run dev`)
这与域名是否带 `-`、长短无关;此前版本对第三 Host 不拦截,需依赖反代 default server 封堵。
**生产环境建议:**
1. 反代层仅为网站域、图片域配置 `server_name`,并增加 **default server** 拒绝其它 Host 与裸 IP(见上方 Nginx 示例,与应用层拦截互补)
2. 使用 Cloudflare **橙云** 时,源站防火墙 **仅放行 [Cloudflare IP 段](https://www.cloudflare.com/ips/)**,避免绕过 CDN 直连源站
3. **仅使用** 设置中的网站域名登录后台,不要用 `localhost`、IP 或未配置的域名混用
4. **不要** 用 Cloudflare Pages / Workers 对 PicHost **整站反代**(会多出 `pages.dev` / `workers.dev` 等第三入口;`IMAGE_BASE_URL` 可填 CDN 公网地址,但应用本体应 Docker/VPS 部署)
5. 若必须在边缘做反代,须保留客户端 **`Host` 或 `X-Forwarded-Host`**,否则 Host 隔离会失效
6. PicHost **不能** 作为 Node 应用直接部署到 Pages/Workers(依赖 `node-server`、SQLite、`sharp` 与本地 `data/`);应用请继续 Docker/VPS 部署,CF 侧用橙云 DNS + 可选 R2 存储即可
### Cloudflare 橙云(含 DNS 优选)
完整说明见专章:[Cloudflare / CF 优选部署](./cloudflare-deployment.md)。
**推荐:** `admin.example.com` 与 `pic.example.com` 均 **DNS Proxied(橙云)** → 源站 Nginx / 1Panel 反代 `6892`,SSL 模式 **Full (strict)**。
| 能力 | 说明 |
| ---- | ---- |
| 橙云 CDN | 与 PicHost 双域名兼容;回源须保留 `Host`、`X-Forwarded-Proto` |
| DNS 优选 | 仅改变用户连到 CF 边缘的 IP;**admin → admin、img → img** 的回源身份不能变 |
| R2 对象存储 | 在 **存储** 页添加 R2 后端即可,与是否橙云无关 |
| `cloudflare` Git 分支 | R2 专用部署线,仍是 Docker 运行,**不是** Workers 托管 |
**勿混淆:** CF 控制台「创建 Worker」并连接本仓库 **无法** 直接部署 PicHost;`npm run build` + `wrangler deploy` 与当前 `node-server` 预设不兼容。
**勿用 Pages/Worker 把图片域 `fetch` 到管理域** — 会破坏 Host 隔离(详见 [Cloudflare 部署说明 · 典型错误拓扑](./cloudflare-deployment.md#六典型错误拓扑))。
反代与源站加固详见 [反向代理](./reverse-proxy.md#cloudflare-橙云)。
## 飞牛 NAS · Lucky 反向代理
在 [Lucky](https://github.com/gdy666/lucky) 中为两个域名各建一条 **反向代理** 规则,**前端地址** 填公网域名,**后端地址** 填 PicHost 所在内网 IP 与端口(如 `http://192.168.8.3:6892`)。
**网站域名(管理后台)**
| 项 | 示例值 |
| -- | ------ |
| 服务类型 | 反向代理 |
| 前端地址 | `admin.pichost.com` |
| 后端地址 | `http://192.168.8.3:6892` |
**图片域名**
| 项 | 示例值 |
| -- | ------ |
| 服务类型 | 反向代理 |
| 前端地址 | `image.pichost.com` |
| 后端地址 | `http://192.168.8.3:6892` |
两条规则的后端地址相同即可;域名分离与路径隔离由 PicHost 根据请求 `Host` 自动处理。
## 环境变量(可选)
除 Web 设置外,也可在 `.env` 或 Docker 环境中配置:
```env
SITE_BASE_URL=https://admin.example.com
IMAGE_BASE_URL=https://pic.example.com
```
详见 [环境变量](./configuration.md)。
## 本地测试(Windows / macOS)
### 1. 修改 hosts
```text
127.0.0.1 admin.pichost.test
127.0.0.1 pic.pichost.test
```
建议使用 `.test` 后缀,避免 `.local` 在部分系统上与 mDNS 冲突。
### 2. 启动开发服务
```bash
npm run dev
```
项目已默认绑定 `127.0.0.1:3000`(与 hosts 的 IPv4 一致)。若曾出现 `ping` 通但浏览器连不上,多半是 dev 只监听了 IPv6 `::1`。
### 3. 填写双域名
| 项 | 示例 |
| -- | ---- |
| 网站域名 | `http://admin.pichost.test:3000` |
| 图片域名 | `http://pic.pichost.test:3000` |
**后台请始终用网站域名打开**,不要用 `localhost`(Cookie 与 Host 不一致)。
### 4. 预期结果
| 地址 | 预期 |
| ---- | ---- |
| `http://admin.pichost.test:3000/` | 正常进入后台 |
| `http://admin.pichost.test:3000/images/...` 或 `/2026/08/xxx.webp` | **404**(请用图片域直链) |
| `http://pic.pichost.test:3000/` | **404**(正常,图片域不提供后台) |
| `http://pic.pichost.test:3000/images/...` | 能出图 |
| `http://localhost:3000/` 或 `http://127.0.0.1:3000/` | **能打开后台**(开发环境例外;生产须用反代封禁未配置 Host) |
上传一张图后,复制链接应指向 `pic.pichost.test` 域名;在后台图库中缩略图也应能正常显示。
## 相关说明
- 两个域名 **主机名不能相同**(可同机、不同子域)
- 设置里的网站域 / 图片域须与反代 `server_name` **完全一致**(含是否带 `www`)
- 图片域无需单独部署静态目录;与 EasyImages 等不同,PicHost 在应用内完成隔离
- 单域名部署可不启用分离,仅配置 `IMAGE_BASE_URL` 或使用默认当前访问域名
- 配置了 Referer 防盗链时,网站域与图片域会自动加入白名单,无需手写两个域名
单域名反代要点见 [反向代理](./reverse-proxy.md)。
+78
View File
@@ -0,0 +1,78 @@
# 常见问题
## 忘记管理员密码怎么办?
能执行 `docker exec` 时:
```bash
docker exec pichost reset-password
# 或指定用户名
docker exec pichost reset-password 用户名
```
本地:`npm run reset-password`。详见 [快速开始](./getting-started.md#忘记密码)。
## 为什么后台无法重新生成 API Token?
若设置了环境变量 `API_UPLOAD_TOKEN`,Token 由环境变量 **锁定**,需修改 env 并 **重启服务**。见 [环境变量](./configuration.md#api-upload-token)。
## 图片外链显示「盗链」或无法加载?
检查 **防盗链白名单**(`ALLOWED_REFERER_HOSTS` 或设置页)。引用图片的站点域名需在白名单中;PicHost 自身域名与双域名场景下的网站域、图片域会自动放行。
## 双域名下为什么用 pages.dev / workers.dev / IP 也能进后台?
**v1.2.2 起:** 双域名开启时,PicHost 中间件会对**未配置的网站域、图片域之外**的 Host 返回 **404**(开发环境 `localhost` / `127.0.0.1` 例外)。详见 [更新日志](./changelog.md#1-2-2-2026-08-30)。
若仍能访问,常见原因:
1. **未启用双域名**(仅配置了图片域、网站域为空)— 隔离与第三 Host 拦截均不生效
2. **反代把 Host 改成管理域** — 例如 Pages/Worker 对图片域 `fetch(admin…)`,PicHost 看到的是管理域
3. **源站裸暴露 `6892`** — 绕过 Cloudflare 与 Nginx `server_name`
处理:在设置中正确配置双域名;反代加 default server;橙云时源站只放行 CF IP;勿用 Pages/Workers 整站反代。详见 [双域名分离](./domain-separation.md#中间件隔离与第三-host)、[Cloudflare 部署](./cloudflare-deployment.md) 与 [更新日志](./changelog.md#1-2-2-2026-08-30)。
## 能把 PicHost 部署到 Cloudflare Workers / Pages 吗?
**不能(现状)。** 项目使用 `node-server`、SQLite、`sharp` 与本地 `data/`,需 Docker / VPS。CF 控制台「从 Git 创建 Worker」连本仓库会部署失败。
推荐:源站跑 PicHost,CF 用 **橙云 DNS**(含优选)+ 可选 **R2 存储**;不要用 Pages/Workers 反代整站。
## 双域名下后台能打开但缩略图裂图?
- 确认 **图片域名** 配置正确且反代到同一实例
- 后台请用 **网站域名** 打开,不要用 `localhost` 混用
- 见 [双域名分离](./domain-separation.md)
## 升级 v1.2 后图库有删不掉的记录?
可能是 **孤儿索引**(有记录无文件)。重启后启动同步会自动清理;也可手动删除仅索引项。见 [v1.2 迁移](./migration.md#第二步启动时自动同步索引)。
## 遗留 `data/blog/` 目录还要吗?
执行 `migrate --apply` 后文件已迁入 `data/images/blog/`。若原目录只剩空壳或非图片文件,可自行备份后删除;迁移脚本不会删除仍含非图片文件的目录。
## 只用对象存储需要跑 migrate 吗?
不需要。图片在桶内时跳过 CLI;启动同步主要面向本地 `data/images/` 磁盘扫描。
## 如何替换博客里的旧图片 URL?
使用 `data/mapping.json`(`migrate` 预览时生成)批量替换旧 key。注意映射的是 **存储 key**,不是完整旧站 URL。见 [迁移指南](./migration.md#外链与直链)。
## 开发时如何跳过登录?
`DEV_BYPASS_ACCESS=true`(仅本地,勿用于生产)。正常流程请访问 `/setup` 创建管理员。
## 文档与仓库 README 哪个为准?
**本 VitePress 文档站** 为完整用户指南;README 保留摘要与快速开始。在线地址:<https://o96u.github.io/PicHost/>
## GitHub 仓库 About 怎么写?
**main** 分支建议:
- **Description**:`Lightweight self-hosted image hosting — Docker, multi-user, gallery & API, local disk or S3-compatible storage.`
- **Website**:`https://o96u.github.io/PicHost/`(文档站,非图床演示)
勿写「部署在 Cloudflare Pages/Workers」—— PicHost 应用跑在 Docker/VPS;**cloudflare** 分支在线演示为 [pic.roven.cc](https://pic.roven.cc)。
+75
View File
@@ -0,0 +1,75 @@
# 快速开始
PicHost 适合在 NAS、家用服务器或 VPS 上自托管图床。推荐用 Docker 部署,首次访问通过 Web 向导创建管理员。
## 在线体验
| 分支 | 说明 |
| ---- | ---- |
| **main** | 无公网演示;产品界面见 [文档首页](./index.md#截图预览) 或仓库 README |
| **cloudflare** | R2 专用线在线演示:[pic.roven.cc](https://pic.roven.cc) |
## Docker(推荐)
```bash
docker run -d \
--name pichost \
-p 6892:6892 \
-v ./data:/data \
--restart unless-stopped \
muxui/pichost:latest
```
默认端口 **6892**。浏览器打开 `http://<主机IP>:6892`,按引导创建管理员即可。
已克隆仓库时也可用:
```bash
docker compose up -d
```
见仓库内 `docker-compose.yml`。
## 首次设置
1. 访问实例 URL(Docker 默认 `http://<主机>:6892`)。
2. 在 `/setup` 填写管理员用户名与密码。
3. (可选)配置网站域名、图片域名、防盗链等 — 详见 [环境变量](./configuration.md) 与 [双域名分离](./domain-separation.md)。
4. 登录后可在首页上传图片;顶栏可进入 **图库**、**存储**(管理员)、**设置**、**API**。
## 登录
访问上传、图库、设置等管理页面前需登录。在登录卡片中填写用户名与密码,**拖动滑块对齐缺口**完成验证后点击 **登录**。
![登录页](/screenshots/login.png)
若管理员开启了开放注册,可前往注册页创建账号。遗留部署仍可能使用密钥登录(见界面提示)。
## 忘记密码
需能执行 `docker exec`(相当于服务器权限):
```bash
# 无参数:重置管理员(仅当系统中只有一个管理员时)
docker exec pichost reset-password
# 指定用户名:可重置管理员或普通用户
docker exec pichost reset-password 用户名
```
终端会打印随机新密码;用户不存在时会报错。登录后请到「修改密码」更换。
本地开发:`npm run reset-password` 或 `npm run reset-password -- 用户名`
## 升级到 v1.2.0
若 `data/` 下存在与 `images` 并列的遗留目录(如 `blog/`、`twikoo/`),升级前请阅读 [v1.2 迁移指南](./migration.md)。
仅对象存储、或图片已在 `data/images/` 下时,拉取新镜像并重启即可。
## 下一步
- [本地开发](./local-dev.md) — `npm run dev` 与 `.env`
- [环境变量](./configuration.md) — 生产配置
- [存储](./storage.md) — 多后端与直链
- [API](./api.md) — Token 与 REST 接口
+36
View File
@@ -0,0 +1,36 @@
# PicHost 文档
欢迎使用 PicHost 用户文档。本站为完整指南的单一事实来源;仓库 README 保留特性摘要与快速开始。
## 推荐阅读顺序
1. [快速开始](./getting-started.md) — Docker 部署与首次设置
2. [环境变量](./configuration.md) — 生产环境配置与后台设置对照
3. [更新日志](./changelog.md) — 版本变更与升级说明
4. [存储](./storage.md) — 多后端与直链模式
5. [API](./api.md) — REST 接口与 Token
## 截图预览
**main** 分支暂无公网演示,以下为当前产品界面(v1.2.5+)。
| API | 图库 |
| :-: | :-: |
| ![API](/screenshots/api.png) | ![图库](/screenshots/gallery.png) |
| 存储管理 | 设置与偏好 |
| :------: | :--------: |
| ![存储管理](/screenshots/storage.png) | ![设置与偏好](/screenshots/settings.png) |
| 操作日志 |
| :------: |
| ![操作日志](/screenshots/logs.png) |
## 分支说明
| 分支 | 说明 |
| ---- | ---- |
| [**main**](https://github.com/O96u/PicHost/tree/main) | 主线:多后端、双域名、统一 `images/` 存储;界面见上表 |
| [**cloudflare**](https://github.com/O96u/PicHost/tree/cloudflare) | 仅 Cloudflare R2;在线演示 [pic.roven.cc](https://pic.roven.cc) |
需要多种云存储或完整管理界面请用 **main**;图床只接 R2 可选用 **cloudflare** 分支。
+54
View File
@@ -0,0 +1,54 @@
# 本地开发
在仓库根目录进行 Nuxt 4 全栈开发,数据默认写入 `./data`。
## 环境要求
- Node.js **22**
- npm **11**(与 `package.json` 中 `packageManager` 一致)
## 启动
```bash
npm install
cp .env.example .env # 可选
npm run dev
```
访问 `http://localhost:3000/setup` 创建管理员。数据目录默认 `./data`(`DATA_DIR` 可改)。
## 常用命令
| 命令 | 说明 |
| ---- | ---- |
| `npm run dev` | 开发服务器 |
| `npm run build` | 生产构建 |
| `npm run start` | 运行 `.output` |
| `npm run lint` | ESLint |
| `npm run typecheck` | 类型检查 |
| `npm test` | 单元测试 |
| `npm run reset-password` | 重置密码(可选用户名) |
| `npm run migrate` | 预览遗留目录迁移(加 `-- --apply` 执行) |
## 开发专用环境变量
| 变量 | 说明 |
| ---- | ---- |
| `DEV_BYPASS_ACCESS` | 设为 `true` 可绕过登录(跳过 `/setup`,一般无需开启) |
| `DATA_DIR` | 数据目录,默认 `./data` |
完整变量列表见 [环境变量](./configuration.md)。
## 双域名本地测试
修改系统 `hosts` 后可用两个测试域名验证分离逻辑,步骤见 [双域名分离](./domain-separation.md#本地测试-windows-macos)。
## 文档站
```bash
npm run docs:dev # VitePress 开发
npm run docs:build # 构建静态站点
npm run docs:preview # 预览构建结果
```
文档源码在 `docs-site/`;截图源文件在 `docs/screenshots/`(含 `login.png` 登录页),构建前会复制到 `docs-site/public/screenshots/`。在线文档:<https://o96u.github.io/PicHost/>
+176
View File
@@ -0,0 +1,176 @@
# v1.2.0 本地磁盘迁移指南
v1.2.0 起,**所有本地图片文件**统一放在 `data/images/` 下;SQLite `images` 表中的 `key` 也以 `images/` 开头。旧版在 `data/blog/`、`data/twikoo/` 等与 `images` **并列的顶层目录**不再符合新模型。
迁移分 **两步**:先用手动 CLI **搬文件**,再由服务 **启动时自动同步索引**。
---
## 从其它图床迁入(EasyImage / 兰空 / Chevereto 等)
**没有**单独的「一键从外链拉取」或「对接某某图床 API」工具。从 **PicHost 旧版** 或其它自托管图床迁入,走的是同一套 **本地文件迁移**:
1. 在旧图床服务器上找到图片**物理目录**(整目录拷贝,不要只拷数据库)。
2. 把该目录放到 PicHost 的 `data/` 下,作为与 `images` **并列的顶层文件夹**(文件夹名任意,会保留为 `images/{文件夹名}/...`)。
3. 执行 `migrate` / `migrate --apply`,再重启 PicHost。
### 常见来源示例
| 来源 | 典型图片目录(在旧服务器上) | 放入 PicHost 后示例 |
| ---- | ---------------------------- | ------------------- |
| PicHost v1.1.x | `data/blog/`、`data/twikoo/` | 已在 `data/` 下,直接 `migrate` |
| EasyImage / EasyImages | `i/` 或安装目录下的 `upload/` | 拷到 `data/easyimage/` 再 `migrate` |
| 兰空图床 (Lsky) | `public/uploads/` 等 | 拷到 `data/lsky/` 再 `migrate` |
| Chevereto 等 | `images/` 或 `content/` | 拷到 `data/chevereto/` 再 `migrate` |
| 任意自定义目录 | 你的顶层文件夹名 | 拷到 `data/你的目录名/` 再 `migrate` |
脚本会扫描 `data/` 下**除 `images` 外所有顶层目录**里的图片,不限于 `blog` / `twikoo`。子目录结构会原样保留在 `images/{顶层名}/` 下。
### 操作示例(Docker)
```bash
# 在宿主机:把从旧图床打包的目录放进挂载卷(名称自定)
# 例如:./data/easyimage/2026/01/photo.jpg
docker exec pichost migrate # 预览
docker exec pichost migrate --apply
# 重启容器 → 启动日志会补全 SQLite 索引
```
### 说明与限制
- **只迁移文件**:旧图床的账号、Token、外链 URL **不会**自动导入;`mapping.json` 里的 key 是「旧磁盘相对 key → 新 key」,**不是**旧站完整 URL,替换外链需自行对照。
- **对象存储**:若旧图床图片在 S3/R2 桶里,请用桶工具同步到本地 `data/某目录/`,或直接在 PicHost **存储**页配置同一桶(不经过本 CLI)。
- **已直接在 `data/images/` 下**:无需 `migrate`,重启后启动同步会自动建索引。
---
## 存储结构对比
### 迁移前(v1.1.x 常见)
```
/data
├── pichost.db
├── blog/
│ └── 2026/08/TeKJ2fD6cB6j.webp
├── twikoo/
│ └── xxx.webp
└── images/ # 可能已有部分新图
└── ...
```
SQLite 中 `key` 可能是 `blog/2026/08/TeKJ2fD6cB6j.webp`(与磁盘顶层目录一致)。
### 迁移后(v1.2.0)
```
/data
├── pichost.db
├── mapping.json # CLI 生成:旧 key → 新 key(供替换外链)
└── images/
├── blog/
│ └── 2026/08/TeKJ2fD6cB6j.webp
├── twikoo/
│ └── xxx.webp
└── ... # 新上传:images/随机ID.webp 或 images/年/月/随机ID.webp
```
对应 `key`:`images/blog/2026/08/TeKJ2fD6cB6j.webp`。
---
## 第一步:CLI 迁移磁盘文件
在 **升级镜像之前或之后**均可执行;建议在升级前完成,并先备份整个 `data/` 目录。
### Docker
```bash
# 预览:列出待迁移文件,写入 data/mapping.json(旧 key → 新 key)
docker exec pichost migrate
# 确认无误后执行文件移动
docker exec pichost migrate --apply
```
### 本地开发
```bash
npm run migrate # 预览
npm run migrate -- --apply # 执行
```
### 脚本行为
| 项目 | 说明 |
| ---- | ---- |
| 扫描范围 | `data/` 下**除 `images` 外所有顶层目录**(不限于 blog/twikoo) |
| 迁移目标 | `data/images/{原顶层目录名}/{相对路径}` |
| 图片类型 | `.jpg` `.jpeg` `.png` `.webp` `.gif` `.svg` `.ico` |
| 伴随文件 | 若存在同名的 `.meta.json` 会一并移动 |
| 非图片文件 | 不移动;若目录内仍有其它文件,**保留原目录**不删除 |
| 映射表 | `data/mapping.json`,例如 `"blog/foo.webp": "images/blog/foo.webp"` |
脚本路径:`server/cli/migrate-to-single-images.mjs`。
---
## 第二步:启动时自动同步索引
每次 PicHost 启动(`npm run dev`、Docker 容器启动)会自动执行,**无需手动命令**。日志示例:
```text
[INFO] 开始同步图片索引:扫描本地磁盘 {"path":".../data/images/"}
[INFO] 磁盘扫描完成:已补全缺失索引 {"inserted":3}
[INFO] 开始归一化遗留图片路径(如 blog/ → images/blog/)
[INFO] 遗留路径归一化完成:无需调整
[INFO] 开始校验索引与磁盘一致性,清理无文件的孤儿记录
[INFO] 孤儿索引清理完成:未发现孤儿记录
[INFO] 开始修复存量 Content-Type 元数据
[INFO] Content-Type 修复完成:无需修复
[INFO] 图片索引同步结束
```
| 步骤 | 作用 |
| ---- | ---- |
| 扫描 `data/images/` | 磁盘上有、索引里没有 → **补全** `images` 表 |
| 归一化遗留 key | 将 `blog/...` 等旧 key 更新为 `images/blog/...`(文件已在正确路径时) |
| 清理孤儿索引 | 索引里有记录、磁盘无文件 → **删除**幽灵记录(避免图库出现删不掉的项) |
| 修复 Content-Type | 修正错误的 `application/octet-stream` |
CLI 只负责**搬文件**;索引对齐、遗留 key、孤儿记录由启动同步处理。执行 `migrate --apply` 后**重启一次**服务即可。
---
## 推荐升级流程
1. **备份** `data/`(含 `pichost.db` 与各目录)。
2. `docker exec pichost migrate` 预览,检查输出与 `mapping.json`。
3. `docker exec pichost migrate --apply` 执行迁移。
4. 拉取 / 部署 **v1.2.0** 镜像并重启容器。
5. 查看启动日志,确认「图片索引同步结束」无报错。
6. 用 `mapping.json` 批量替换博客、Twikoo 等外链中的旧路径(若需要)。
仅使用对象存储、或图片早已全部在 `data/images/` 下时,**跳过第一步**;第二步仍会在启动时运行,通常几秒内完成且无变更日志。
---
## 外链与直链
| 场景 | 说明 |
| ---- | ---- |
| 旧 key | `blog/TeKJ2fD6cB6j.webp` |
| 新 key | `images/blog/TeKJ2fD6cB6j.webp` |
| 隐藏目录模式 | 若开启「隐藏 images 前缀」,短链可能为 `/TeKJ2fD6cB6j.webp`;服务按文件名反查索引 |
| 替换建议 | 以 `mapping.json` 为准;或重新在图库复制新链接 |
---
## 相关命令
| 命令 | 说明 |
| ---- | ---- |
| `docker exec pichost migrate` | 预览磁盘迁移 |
| `docker exec pichost migrate --apply` | 执行磁盘迁移 |
| `docker exec pichost reset-password` | 重置密码(与迁移无关) |
+85
View File
@@ -0,0 +1,85 @@
# 反向代理
生产环境建议通过 Nginx、Caddy、NPM(Nginx Proxy Manager)或 NAS 自带反代,将 HTTPS 流量转发到 PicHost 容器 **6892** 端口。
## 基本要求
- 反代目标:`http://127.0.0.1:6892`(或容器所在主机 IP)
- 保留请求头:`Host`、`X-Forwarded-Proto`(及常见的 `X-Forwarded-For`)
- 上传大小:Nginx 建议 `client_max_body_size 12m` 或更大
- 公网访问请配置 **HTTPS**
## 单域名示例(Nginx)
管理、API、出图同一域名:
```nginx
server {
server_name pic.example.com;
location / {
proxy_pass http://127.0.0.1:6892;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
client_max_body_size 12m;
}
}
```
环境变量(可选):
```env
IMAGE_BASE_URL=https://pic.example.com
```
未设置 `IMAGE_BASE_URL` 时,PicHost 会根据反代转发的 `Host` 与 `X-Forwarded-Proto` 推断公网地址。
## 双域名
若后台与图片使用不同域名,两个 `server` 块均可 **全量反代** 到同一端口;隔离由 PicHost 中间件完成。
详见专章:[双域名分离](./domain-separation.md)。
## Cloudflare 橙云
详见专章:[Cloudflare / CF 优选部署](./cloudflare-deployment.md)。
| 做法 | 说明 |
| ---- | ---- |
| **DNS 橙云** | `admin` / `img` 两个子域 Proxied → 源站反代 `6892`,SSL **Full (strict)** |
| **DNS 优选** | 与 PicHost 兼容;须保持 **admin → admin、img → img** 回源 |
| **R2** | 在存储页作对象后端,非 Pages 托管应用 |
| **源站防火墙** | 仅放行 [Cloudflare IP](https://www.cloudflare.com/ips/),禁止公网直连 `6892` |
**不建议:**
- 用 **Cloudflare Pages**(`*.pages.dev`)或 **Workers**(`*.workers.dev`)对 PicHost **整站反代**,或让图片域 **fetch 到管理域** — 会改写 Host、绕过双域名隔离(见 [Cloudflare 部署说明](./cloudflare-deployment.md))
- 在 CF 控制台「从 Git 创建 Worker」部署本仓库 — 当前为 `node-server` 预设,无法直接 `wrangler deploy`
## 源站安全(双域名推荐)
除 PicHost 中间件外,建议在反代 / 防火墙层加固:
1. **`server_name` 白名单** — 仅网站域与图片域;`default_server` 拒绝裸 IP 与其它 Host
2. **禁止公网暴露 6892** — 仅本机或内网反代访问;公网只开 80/443
3. **橙云时限制源站来源 IP** — 仅 Cloudflare 回源 IP 段
双域名场景下 PicHost **不会** 拒绝未配置的 Host;上述措施由运维层补齐。
## Caddy 示例
```caddy
pic.example.com {
reverse_proxy 127.0.0.1:6892
}
```
双域名时添加第二个站点块,后端地址相同即可。
## 缓存注意
图片 404/403 与域名隔离 404 响应带 **禁止缓存** 头,避免 CDN 长时间缓存错误页面。若在前端再加 CDN,请对动态 API 路径谨慎缓存。
## 相关
- [环境变量](./configuration.md) — `SITE_BASE_URL`、`IMAGE_BASE_URL`
- [快速开始](./getting-started.md) — Docker 端口
Loaded 100 of 181 files, more files were not shown because too many files have changed in this diff. Show more