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

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

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

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

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

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

No files matched your search

+7
View File
@@ -5,5 +5,12 @@ node_modules
.github
data
dist
dist-fpk
dist-windows
dist-docker
packaging
scripts
.env
*.md
._*
.DS_Store
+26 -1
View File
@@ -11,7 +11,17 @@
# 可选;不填可在设置页配置。若在此填写则覆盖后台设置
# ALLOWED_REFERER_HOSTS=example.com,www.example.com
# 图片直链域名(可选;不填则使用请求域名,也可在设置页配置)
# 管理后台域名(可选;不填则使用当前请求域名,也可在设置页配置)
# SITE_BASE_URL=https://admin.example.com
# 隐藏图片直链中的目录前缀(如 images/2026/08/a.webp → a.webp)
# 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
# 图片存储目录(默认 ./data,容器内为 /data)
@@ -20,8 +30,23 @@
# 服务端 WebP 压缩质量 1-100(默认 80;也可在设置页配置)
# WEBP_QUALITY=80
# 上传限制(也可在设置 → 访问控制配置;环境变量优先级更高)
# UPLOAD_IMAGE_MAX_FILE_SIZE_MB=10
# UPLOAD_VIDEO_MAX_FILE_SIZE_MB=500
# UPLOAD_FILE_MAX_FILE_SIZE_MB=100
# ALLOWED_MIME_TYPES=image/jpeg,image/png,image/webp,image/gif,image/svg+xml,image/x-icon
# UPLOAD_RATE_IP_MAX=60
# UPLOAD_RATE_TOKEN_MAX=120
# UPLOAD_RATE_WINDOW_MINUTES=15
# LOGIN_RATE_MAX=10
# LOGIN_RATE_WINDOW_MINUTES=15
# PRESERVE_ORIGINAL_UPLOAD=false
# 自动删除超过 N 天的图片(0 表示关闭;也可在设置页配置)
# AUTO_DELETE_DAYS=0
# 仅本地开发:设为 true 可绕过登录验证(跳过 /setup,一般无需开启)
# DEV_BYPASS_ACCESS=false
# 登录人机验证(滑块 / Turnstile / Cap)在后台「设置 → 访问控制」配置,无环境变量。
# 配置错误无法登录时:docker exec pichost slider(或 npm run slider)恢复为本地滑块。
+2 -2
View File
@@ -1,6 +1,6 @@
{
"description": "Self-hosted image hosting for your NAS / home server — Nuxt admin UI, local disk storage, server-side WebP compression, referer protection, Twikoo/EasyImage compatible.",
"homepage": "https://github.com/O96u/PicHost#readme",
"description": "Lightweight self-hosted image hosting — Docker, multi-user, gallery & API, local disk or S3-compatible storage.",
"homepage": "https://o96u.github.io/PicHost/",
"topics": [
"docker",
"image-hosting",
-39
View File
@@ -1,39 +0,0 @@
name: ci
on: push
jobs:
ci:
runs-on: ${{ matrix.os }}
strategy:
matrix:
os: [ubuntu-latest]
node: [22]
steps:
- name: Checkout
uses: actions/checkout@v7
- name: Install node
uses: actions/setup-node@v6
with:
node-version: ${{ matrix.node }}
cache: npm
- name: Install npm 11 (lockfile generated with npm 11)
run: |
npm install -g npm@11.12.1
npm --version
- name: Install dependencies
run: npm ci
- name: Lint
run: npm run lint
- name: Typecheck
run: npm run typecheck
- name: Test
run: npm run test
+68
View File
@@ -0,0 +1,68 @@
name: docs
on:
push:
branches: [main]
paths:
- 'docs-site/**'
- 'docs/screenshots/**'
- '.github/workflows/docs.yml'
- 'package.json'
- 'package-lock.json'
workflow_dispatch:
permissions:
contents: read
pages: write
id-token: write
concurrency:
group: pages
cancel-in-progress: false
jobs:
build:
runs-on: ubuntu-latest
permissions:
contents: read
pages: write
id-token: write
steps:
- name: Checkout
uses: actions/checkout@v7
- name: Install node
uses: actions/setup-node@v6
with:
node-version: 22
cache: npm
- name: Install npm 11
run: |
npm install -g npm@11.12.1
npm --version
- name: Install dependencies
run: npm ci
- name: Build docs
run: npm run docs:build
- name: Upload Pages artifact
uses: actions/upload-pages-artifact@v4
with:
path: docs-site/.vitepress/dist
deploy:
needs: build
runs-on: ubuntu-latest
permissions:
pages: write
id-token: write
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v4
-71
View File
@@ -1,71 +0,0 @@
name: GitHub Release
on:
push:
tags:
- 'v*'
workflow_dispatch:
inputs:
tag:
description: '要发布 Release 的标签(如 v1.0.1)'
required: true
type: string
permissions:
contents: write
jobs:
release:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v7
with:
ref: ${{ github.event_name == 'workflow_dispatch' && inputs.tag || github.ref }}
- name: Setup Node
uses: actions/setup-node@v6
with:
node-version: 22
cache: npm
- name: Install npm 11
run: npm install -g npm@11.12.1
- name: Install dependencies
run: npm ci
- name: Extract release notes from CHANGELOG
id: notes
run: |
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
TAG="${{ inputs.tag }}"
else
TAG="${GITHUB_REF#refs/tags/}"
fi
VERSION="${TAG#v}"
BODY_FILE="${RUNNER_TEMP}/release-notes.md"
awk -v ver="$VERSION" '
/^## \[/ {
if (found) exit
if ($0 ~ "\\[" ver "\\]") found = 1
next
}
found { print }
' CHANGELOG.md > "$BODY_FILE"
if [ ! -s "$BODY_FILE" ]; then
echo "未在 CHANGELOG.md 找到版本 ${VERSION},使用自动生成说明。" >&2
echo "## PicHost ${TAG}" > "$BODY_FILE"
fi
echo "tag=${TAG}" >> "$GITHUB_OUTPUT"
echo "body_path=${BODY_FILE}" >> "$GITHUB_OUTPUT"
- name: Create GitHub Release
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ steps.notes.outputs.tag }}
name: PicHost ${{ steps.notes.outputs.tag }}
body_path: ${{ steps.notes.outputs.body_path }}
draft: false
prerelease: false
make_latest: true
+8 -2
View File
@@ -5,6 +5,7 @@
.nitro
.cache
dist
dist-windows
# Node dependencies
node_modules
@@ -37,6 +38,11 @@ data
# 本地 Git hooks 模板(仅安装在 .git/hooks/,不进仓库)
git-hooks/
# 飞牛 fpk 工程与产物(仅本地维护,不进仓库)
scripts/fpk/
# 打包输出目录
dist-fpk/
dist-docker/
# VitePress docs site
docs-site/.vitepress/cache
docs-site/.vitepress/dist
docs-site/public/screenshots
+327 -1
View File
@@ -2,6 +2,327 @@
本仓库自 **v1.0.0** 起作为正式版维护;此前历史提交已归档重置。
## [Unreleased]
## [1.5.0] - 2026-10-17
### 新增
- 图库、视频库和文件库支持搜索与批量删除,列表管理体验进一步完善
- 视频与文件 API 文档按新增上传、查询、批量删除和转码能力同步更新
- 所有媒体上传统一使用可配置策略,包括文件大小、格式限制和上传限流
- 新上传的图片、视频和文件默认保存到「本地磁盘」,支持切换默认存储后端
- 存储页新增高危操作二次确认,可清空当前用户的媒体、任务和缓存且不影响其他用户
- 低内存服务器在开启 HLS 转码时显示风险提示
### 改进
- 备份与恢复统一统计「媒体记录」和「存储对象」,提升迁移前后数据一致性
- 视频上传入口仅允许 MP4、MOV、MKV、WebM 等完整容器格式,服务端增加扩展名与内容类型校验
- 缩略图 FFmpeg 限制为单线程并增加 10 秒超时,降低低内存服务器卡死风险
- m3u8 临时合并文件支持下载后清理、到期清理及启动兜底清理
### 修复
- 修复清空存储后最近任务残留并可继续下载的问题
- 修复部分媒体相关文案和逻辑仍仅针对图片、遗漏视频与文件的问题
- 修复关闭独立媒体域名时网站域名被误清空、生效地址回落到请求地址的问题
- 修复站点同步异常后的兜底恢复流程
## [1.4.4] - 2026-10-10
### 修复
- Cap 登录验证支持新的 hashwx 挑战,旧的 SHA-256 site key 仍可使用
- 求解用的 wasm 改由本站提供,不再依赖 jsDelivr
## [1.4.3] - 2026-10-09
### 新增
- **Telegram 存储后端**:用 Bot 把图片存入频道或群组(含私有);直链仍由 PicHost 代理,不暴露 Bot Token
- 可选 HTTP/HTTPS API 代理,供服务器访问 Telegram
- 群组升级为超级群组时自动改用新的 Chat ID
### 说明
- 单文件约 20MB,同一聊天约 20 条/分钟
- 图库删除会尝试删除对应 Telegram 消息;备份包不含 Bot Token
## [1.4.2] - 2026-09-23
### 修复
- 图库批量打标:管理员/跨归属场景下标签解析与失败提示
- 上传鉴权:`Auth-Token` 与表单 `token` 优先于会话;兼容全局 `API_UPLOAD_TOKEN`(上传归属首位管理员)
### 改进
- **API 页**:每账号单一 API Token,界面与文档统一
- **上传 API**:仅支持 `tagNames`(按名创建或复用标签);Twikoo 兼容路由同步
- **标签管理**:按图片数/最近使用排序;桌面列布局;窄屏卡片列表
- **API 文档**:参数表列宽,避免 form-data 列异常换行
## [1.4.1] - 2026-09-16
### 新增
- **上传策略(设置 → 基础)**:单文件大小、允许格式、上传/登录限流、保留原图(关闭服务端 WebP 转换);支持 `.env` 覆盖
- **上传 API**:可选 `tagNames`(名称不存在则创建,存在则复用);与 `tagIds` 可并用
### 改进
- 登录接口接入可配置的登录限流
- 设置页将全站上传策略并入「上传偏好 / 图片处理」;访问控制保留登录限流
- API 文档补充 `tagIds` / `tagNames` 参数说明
## [1.4.0] - 2026-09-09
### 新增
- **标签系统**:每用户独立标签与彩色标记;设置页管理(新建、改色、合并、删除)
- **图片打标**:上传后单张/批量打标;图库按标签筛选(OR / AND)、批量打标与解标
- **操作日志扩展**:记录登录、删除、设置变更、标签操作等;支持日期、用户、操作类型、来源与 IP 筛选
- **API**:标签 CRUD、图片打标、`batch-tags`;上传支持 `tagIds`;列表/搜索/统计支持标签筛选
### 改进
- 操作日志页 UI 改版(操作/来源徽章、移除备注列)
- 设置侧栏增加「标签管理」
### 文档
- README 与文档站截图更新(上传、图库、标签、操作日志等)
## [1.3.0] - 2026-09-07
### 新增
- **站点备份**:存储页导出 `.phost.tar.gz`(含 `pichost.db` 与全部图片),支持下载与任务进度
- **站点恢复**:存储页或 setup 未初始化时从备份包恢复,支持预检与跳过/覆盖冲突
- **跨后端同步**:在已配置存储后端之间拷贝图片并更新索引;后端卡片快捷「迁入 / 迁出」
- **任务中心**:`backup_jobs` 记录最近导出/恢复/同步任务,失败可重试;列表分页
- 运维 CLI:`backup-export`、`backup-restore`、`storage-sync`(`docker exec pichost …`)
### 移除
- 遗留目录迁移 CLI `migrate` / `migrate-to-single-images.mjs`(由启动索引同步与整站备份恢复替代)
### 文档
- [存储](./docs-site/guide/storage.md) 补充备份与迁移说明;更新截图;移除 v1.2 迁移专页
## [1.2.8] - 2026-09-04
### 改进
- 双域名:保存或初始化时弹出风险确认(说明可能锁死及 `clear-domains` 恢复方式),不再强制校验当前 Host
### 新增
- 运维 CLI `clear-domains`:清除数据库中的网站域 / 图片域配置(`docker exec pichost clear-domains`)
### 文档
- FAQ、双域名、反向代理文档补充锁死原因与恢复步骤
## [1.2.7] - 2026-09-03
### 新增
- 登录/注册人机验证:本地滑块、Cloudflare Turnstile、Cap;管理员在「设置 → 访问控制」中切换
- 运维 CLI `slider`:将登录验证重置为本地滑块(`docker exec pichost slider`)
### 改进
- 登出后登录页验证码可正常重新加载
## [1.2.6] - 2026-09-01
### 新增
- 设置、API、图库、存储页 UI 重构(侧栏导航、统计卡片、在线调试等)
- 图库筛选增加存储后端下拉
- 新建对象存储后端按类型生成存储标识:`r2-*`、`cos-*`、`oss-*`、`s3-*`
### 改进
- 操作日志表格:操作/来源标签同行展示,路径列省略号截断、无横向滚动条
- 存储后端卡片:存储标识与类型同一行左对齐排列
- README 与文档站截图、导航说明同步新界面
## [1.2.5] - 2026-08-31
### 新增
- 图库页(`/gallery`)替代原「统计」导航;图片点击放大详情弹层(尺寸、上传方式、存储路径、链接复制)
- 图库列表支持全选、反选;单张删除移至详情弹层
- 图片列表 API 返回上传来源(网页 / API,来自活动日志)
### 改进
- README 补充技术栈说明
### 移除
- `/stats` 路由(不再兼容跳转)
## [1.2.4] - 2026-08-31
### 修复
- 访问控制(Referer 防盗链)留空时仍会拦截外链图片:浏览器直开无 Referer 可访问,博客/论坛嵌入带 Referer 会 403 图裂;现仅在配置了白名单时才校验
## [1.2.3] - 2026-08-30
### 修复
- 开启「简短图片链接」且按年/月分组时,纯文件名直链(如 `/xxx.webp`)误解析为 `images/xxx.webp` 导致 404;现先经 SQLite 索引反查完整 key
## [1.2.2] - 2026-08-30
### 修复
- 双域名:设置页保存时误清空管理域(`site_base_url`);仅改图片域时保护已有网站域配置
- 双域名:设置 API 配置值与 request origin fallback 混淆,导致「当前生效」显示错误
- 双域名:未配置的第三 Host(`pages.dev`、裸 IP 等)可绕过 Host 隔离访问后台
### 改进
- 设置 API 拆分 `siteBaseUrl` / `imageBaseUrl`(配置值)、`effective*`(直链)、`runtime`(当前访问检测)
- `PATCH /api/settings` 增加 `domainSeparation` 字段与保存校验
- 设置页:检测地址展示、关闭双域名确认、反代 Host 不一致警告;首次设置不再自动填入 origin
- Host 中间件:双域名开启时对未知 Host 返回 404(开发 `localhost` 例外)
- 文档站:新增 [更新日志](https://o96u.github.io/PicHost/guide/changelog);重写 Cloudflare 部署说明;同步 FAQ / 双域名文档
## [1.2.1] - 2026-08-29
### 新增
- 登录滑动拼图验证(缺口对齐);验证与登录分两步,登录接口校验 `captchaId` / `captchaPosition`
- VitePress 文档站(`docs-site/`):中英文指南,部署至 GitHub Pages <https://o96u.github.io/PicHost/>
- `npm run docs:dev` / `docs:build` / `docs:preview`;CI 增加 `docs:build`;`.github/workflows/docs.yml` 发布文档
### 修复
- 修复统计页删除图片后图库不刷新的问题
- 修复分页跳转输入框在 `type="number"` 下无法跳转的问题
- 修复非首页路由下复制链接格式偏好不持久化的问题(`useUploadPreferences` 首次使用时自动加载)
- 滑动验证 UI:滑块垂直居中、缺口尺寸与滑块一致
### 改进
- README 精简为产品宣传页,详细说明指向文档站
- 双域名文档补充:未知 Host 风险、Cloudflare 橙云部署、勿用 Pages/Workers 整站反代
- 文档截图更新(登录页等);旧 `docs/*.md` 长文迁入 `docs-site/`
## [1.2.0] - 2026-08-28
### 变更
- 图片统一存放在 `data/images/`;遗留 `blog/`、`twikoo/` 等并列目录需迁移后升级
### 新增
- CLI 迁移工具:`docker exec pichost migrate` / `migrate --apply`(`server/cli/migrate-to-single-images.mjs`)
- 扫描 `data/` 下除 `images` 外所有顶层目录中的图片,输出 `data/mapping.json`
- 启动时自动同步图片索引(扫描磁盘、归一化遗留 key、清理孤儿记录),并打印同步日志
### 改进
- 启动时归一化遗留 SQLite key、清理无文件的孤儿索引;支持删除仅索引无文件的记录
## [1.1.5] - 2026-08-28
### 修复
- 修复 API 上传后图库预览偶发失败、需等待刷新才显示的问题
- 图库卡片加载失败后自动重试,刷新列表时重置预览状态
### 改进
- 出图前校验存储文件实际存在,避免索引与磁盘短暂不一致
- 图片 404/403 与域名隔离 404 禁止 CDN 缓存,降低反代误判影响
## [1.1.4] - 2026-08-28
### 新增
- 上传目录可按年/月分组或扁平保存(`storage_use_date_path` / `STORAGE_USE_DATE_PATH`),设置页可切换
- 双域名分离下网站域禁止直链出图,仅图片域可访问图片路径
### 改进
- 隐藏 `images/` 前缀时外链改为仅文件名(如 `xxx.webp`);图片域仍兼容旧版 `/2026/08/xxx.webp` 短链
- 设置页重构:功能开关、域名配置、存储路径分组展示
- 本地磁盘索引扫描支持扁平路径;文件夹列表 `images` 置顶
## [1.1.3] - 2026-08-27
### 新增
- 后台与图片域名分离(`SITE_BASE_URL` / `IMAGE_BASE_URL`):单实例双域名,Host 中间件隔离图片域非图片路径
- `/setup` 与系统设置支持启用/关闭域名分离,设置页可后续关闭并恢复单域
- 可选隐藏外链中的 `images/` 前缀(多文件夹时确认提示)
- 设置页加载时检测 GitHub 最新版本并提示更新
- 双域名部署文档(Nginx / Lucky / 本地 hosts 测试):[文档站 · 双域名分离](https://o96u.github.io/PicHost/guide/domain-separation)
### 改进
- 双域名下防盗链自动放行网站域与图片域主机名
- 开发服务器默认绑定 `127.0.0.1:3000`,便于 hosts 双域本地测试
- API 文档补充自定义文件夹的 cURL 示例
### 其他
- 许可证变更为 GPL-3.0-only
- `scripts/` 目录改为本地维护,不再纳入仓库
## [1.1.2] - 2026-08-26
### 新增
- 操作日志页(`/logs`):记录上传/删除,管理员可查看全部用户并筛选
- 图库记录支持按存储后端筛选
- 上传 IP / API Token 限流(15 分钟窗口)
### 改进
- 操作日志记录存储后端;图库卡片可展示存储来源
- 存储筛选下拉固定「本地磁盘」排在第二位
- 系统设置页版本号链接至 GitHub Releases
### 修复
- 修复删除存储后端后确认弹窗闪出空名称
## [1.1.1] - 2026-08-26
### 修复
- 移除启动时自动创建的空白「S3 兼容存储」占位;升级后自动清理旧占位记录
- 修复管理页切换路由时反复闪现「正在验证登录状态」
- 修复静默会话校验后设置 / 统计 / API 页不加载数据
### 改进
- Docker 日志:4xx/5xx 分级输出,记录错误原因与堆栈;上传/启动任务失败写入详细错误
## [1.1.0] - 2026-08-26
### 新增
- S3 兼容对象存储后端(R2 / AWS S3 / 腾讯云 COS / 阿里云 OSS)
- `storage_backends` + `images` 索引表,列表/搜索/统计改查 SQLite
- 混合直链:`proxy`(默认,PicHost 代理)与 `public`(302 到 CDN)
- 管理员 `/storage` 存储管理页:多后端卡片、用量条、添加/编辑/删除云存储实例
- 可选环境变量覆盖存储配置(`STORAGE_BACKEND`、`S3_*`)
### 改进
- 启动时自动将本地磁盘图片扫描迁入 `images` 索引(幂等)
- 上传/删除/直链/auto-delete 按 `backend_id` 路由到对应后端
## [1.0.4] - 2026-08-25
### 新增
@@ -32,6 +353,7 @@
- 中英文界面(`@nuxtjs/i18n`),语言切换与主题切换独立菜单
- 移动端导航:汉堡菜单 + 分组用户菜单(语言 / 外观 / 账户)
- Docker 忘记密码:`docker exec pichost reset-password`(随机密码,见 `server/cli/`)
- Docker 升级到 v1.2.0 前迁移遗留目录:`docker exec pichost migrate` / `migrate --apply`
### 改进
@@ -68,8 +390,12 @@
### 路线图
- **v1.1.0**(计划中):S3、Cloudflare R2 及更多对象存储后端
- **v1.2.0**(开发中):统一 `images/` 存储、遗留目录迁移 CLI、启动索引同步
[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.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.3]: https://github.com/O96u/PicHost/releases/tag/v1.0.3
[1.0.2]: https://github.com/O96u/PicHost/releases/tag/v1.0.2
[1.0.1]: https://github.com/O96u/PicHost/releases/tag/v1.0.1
+12
View File
@@ -18,12 +18,24 @@ FROM node:22-alpine
WORKDIR /app
# 视频床转码依赖 ffmpeg(单码率 HLS 切片)
RUN apk add --no-cache ffmpeg
ENV NODE_ENV=production \
NITRO_PORT=6892 \
DATA_DIR=/data
COPY --from=build /app/.output ./.output
COPY --from=build /app/server/utils ./server/utils
COPY --from=build /app/node_modules ./node_modules
COPY --from=build /app/package.json ./package.json
COPY server/cli/reset-password.mjs ./server/cli/reset-password.mjs
COPY server/cli/slider.mjs ./server/cli/slider.mjs
COPY server/cli/clear-domains.mjs ./server/cli/clear-domains.mjs
COPY server/cli/backup-cli-loader.mjs ./server/cli/backup-cli-loader.mjs
COPY server/cli/backup-export.mjs ./server/cli/backup-export.mjs
COPY server/cli/backup-restore.mjs ./server/cli/backup-restore.mjs
COPY server/cli/storage-sync.mjs ./server/cli/storage-sync.mjs
COPY docker-entrypoint.sh /docker-entrypoint.sh
RUN chmod +x /docker-entrypoint.sh
+670 -17
View File
@@ -1,21 +1,674 @@
MIT License
Copyright (c) 2026 O96u
GNU GENERAL PUBLIC LICENSE
Version 3, 29 June 2007
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
Preamble
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
The GNU General Public License is a free, copyleft license for
software and other kinds of works.
The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast,
the GNU General Public License is intended to guarantee your freedom to
share and change all versions of a program--to make sure it remains free
software for all its users. We, the Free Software Foundation, use the
GNU General Public License for most of our software; it applies also to
any other work released this way by its authors. You can apply it to
your programs, too.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
them if you wish), that you receive source code or can get it if you
want it, that you can change the software or use pieces of it in new
free programs, and that you know you can do these things.
To protect your rights, we need to prevent others from denying you
these rights or asking you to surrender the rights. Therefore, you have
certain responsibilities if you distribute copies of the software, or if
you modify it: responsibilities to respect the freedom of others.
For example, if you distribute copies of such a program, whether
gratis or for a fee, you must pass on to the recipients the same
freedoms that you received. You must make sure that they, too, receive
or can get the source code. And you must show them these terms so they
know their rights.
Developers that use the GNU GPL protect your rights with two steps:
(1) assert copyright on the software, and (2) offer you this License
giving you legal permission to copy, distribute and/or modify it.
For the developers' and authors' protection, the GPL clearly explains
that there is no warranty for this free software. For both users' and
authors' sake, the GPL requires that modified versions be marked as
changed, so that their problems will not be attributed erroneously to
authors of previous versions.
Some devices are designed to deny users access to install or run
modified versions of the software inside them, although the manufacturer
can do so. This is fundamentally incompatible with the aim of
protecting users' freedom to change the software. The systematic
pattern of such abuse occurs in the area of products for individuals to
use, which is precisely where it is most unacceptable. Therefore, we
have designed this version of the GPL to prohibit the practice for those
products. If such problems arise substantially in other domains, we
stand ready to extend this provision to those domains in future versions
of the GPL, as needed to protect the freedom of users.
Finally, every program is threatened constantly by software patents.
States should not allow patents to restrict development and use of
software on general-purpose computers, but in those that do, we wish to
avoid the special danger that patents applied to a free program could
make it effectively proprietary. To prevent this, the GPL assures that
patents cannot be used to render the program non-free.
The precise terms and conditions for copying, distribution and
modification follow.
TERMS AND CONDITIONS
0. Definitions.
"This License" refers to version 3 of the GNU General Public License.
"Copyright" also means copyright-like laws that apply to other kinds of
works, such as semiconductor masks.
"The Program" refers to any copyrightable work licensed under this
License. Each licensee is addressed as "you". "Licensees" and
"recipients" may be individuals or organizations.
To "modify" a work means to copy from or adapt all or part of the work
in a fashion requiring copyright permission, other than the making of an
exact copy. The resulting work is called a "modified version" of the
earlier work or a work "based on" the earlier work.
A "covered work" means either the unmodified Program or a work based
on the Program.
To "propagate" a work means to do anything with it that, without
permission, would make you directly or secondarily liable for
infringement under applicable copyright law, except executing it on a
computer or modifying a private copy. Propagation includes copying,
distribution (with or without modification), making available to the
public, and in some countries other activities as well.
To "convey" a work means any kind of propagation that enables other
parties to make or receive copies. Mere interaction with a user through
a computer network, with no transfer of a copy, is not conveying.
An interactive user interface displays "Appropriate Legal Notices"
to the extent that it includes a convenient and prominently visible
feature that (1) displays an appropriate copyright notice, and (2)
tells the user that there is no warranty for the work (except to the
extent that warranties are provided), that licensees may convey the
work under this License, and how to view a copy of this License. If
the interface presents a list of user commands or options, such as a
menu, a prominent item in the list meets this criterion.
1. Source Code.
The "source code" for a work means the preferred form of the work
for making modifications to it. "Object code" means any non-source
form of a work.
A "Standard Interface" means an interface that either is an official
standard defined by a recognized standards body, or, in the case of
interfaces specified for a particular programming language, one that
is widely used among developers working in that language.
The "System Libraries" of an executable work include anything, other
than the work as a whole, that (a) is included in the normal form of
packaging a Major Component, but which is not part of that Major
Component, and (b) serves only to enable use of the work with that
Major Component, or to implement a Standard Interface for which an
implementation is available to the public in source code form. A
"Major Component", in this context, means a major essential component
(kernel, window system, and so on) of the specific operating system
(if any) on which the executable work runs, or a compiler used to
produce the work, or an object code interpreter used to run it.
The "Corresponding Source" for a work in object code form means all
the source code needed to generate, install, and (for an executable
work) run the object code and to modify the work, including scripts to
control those activities. However, it does not include the work's
System Libraries, or general-purpose tools or generally available free
programs which are used unmodified in performing those activities but
which are not part of the work. For example, Corresponding Source
includes interface definition files associated with source files for
the work, and the source code for shared libraries and dynamically
linked subprograms that the work is specifically designed to require,
such as by intimate data communication or control flow between those
subprograms and other parts of the work.
The Corresponding Source need not include anything that users
can regenerate automatically from other parts of the Corresponding
Source.
The Corresponding Source for a work in source code form is that
same work.
2. Basic Permissions.
All rights granted under this License are granted for the term of
copyright on the Program, and are irrevocable provided the stated
conditions are met. This License explicitly affirms your unlimited
permission to run the unmodified Program. The output from running a
covered work is covered by this License only if the output, given its
content, constitutes a covered work. This License acknowledges your
rights of fair use or other equivalent, as provided by copyright law.
You may make, run and propagate covered works that you do not
convey, without conditions so long as your license otherwise remains
in force. You may convey covered works to others for the sole purpose
of having them make modifications exclusively for you, or provide you
with facilities for running those works, provided that you comply with
the terms of this License in conveying all material for which you do
not control copyright. Those thus making or running the covered works
for you must do so exclusively on your behalf, under your direction
and control, on terms that prohibit them from making any copies of
your copyrighted material outside their relationship with you.
Conveying under any other circumstances is permitted solely under
the conditions stated below. Sublicensing is not allowed; section 10
makes it unnecessary.
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
No covered work shall be deemed part of an effective technological
measure under any applicable law fulfilling obligations under article
11 of the WIPO copyright treaty adopted on 20 December 1996, or
similar laws prohibiting or restricting circumvention of such
measures.
When you convey a covered work, you waive any legal power to forbid
circumvention of technological measures to the extent such circumvention
is effected by exercising rights under this License with respect to
the covered work, and you disclaim any intention to limit operation or
modification of the work as a means of enforcing, against the work's
users, your or third parties' legal rights to forbid circumvention of
technological measures.
4. Conveying Verbatim Copies.
You may convey verbatim copies of the Program's source code as you
receive it, in any medium, provided that you conspicuously and
appropriately publish on each copy an appropriate copyright notice;
keep intact all notices stating that this License and any
non-permissive terms added in accord with section 7 apply to the code;
keep intact all notices of the absence of any warranty; and give all
recipients a copy of this License along with the Program.
You may charge any price or no price for each copy that you convey,
and you may offer support or warranty protection for a fee.
5. Conveying Modified Source Versions.
You may convey a work based on the Program, or the modifications to
produce it from the Program, in the form of source code under the
terms of section 4, provided that you also meet all of these conditions:
a) The work must carry prominent notices stating that you modified
it, and giving a relevant date.
b) The work must carry prominent notices stating that it is
released under this License and any conditions added under section
7. This requirement modifies the requirement in section 4 to
"keep intact all notices".
c) You must license the entire work, as a whole, under this
License to anyone who comes into possession of a copy. This
License will therefore apply, along with any applicable section 7
additional terms, to the whole of the work, and all its parts,
regardless of how they are packaged. This License gives no
permission to license the work in any other way, but it does not
invalidate such permission if you have separately received it.
d) If the work has interactive user interfaces, each must display
Appropriate Legal Notices; however, if the Program has interactive
interfaces that do not display Appropriate Legal Notices, your
work need not make them do so.
A compilation of a covered work with other separate and independent
works, which are not by their nature extensions of the covered work,
and which are not combined with it such as to form a larger program,
in or on a volume of a storage or distribution medium, is called an
"aggregate" if the compilation and its resulting copyright are not
used to limit the access or legal rights of the compilation's users
beyond what the individual works permit. Inclusion of a covered work
in an aggregate does not cause this License to apply to the other
parts of the aggregate.
6. Conveying Non-Source Forms.
You may convey a covered work in object code form under the terms
of sections 4 and 5, provided that you also convey the
machine-readable Corresponding Source under the terms of this License,
in one of these ways:
a) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by the
Corresponding Source fixed on a durable physical medium
customarily used for software interchange.
b) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by a
written offer, valid for at least three years and valid for as
long as you offer spare parts or customer support for that product
model, to give anyone who possesses the object code either (1) a
copy of the Corresponding Source for all the software in the
product that is covered by this License, on a durable physical
medium customarily used for software interchange, for a price no
more than your reasonable cost of physically performing this
conveying of source, or (2) access to copy the
Corresponding Source from a network server at no charge.
c) Convey individual copies of the object code with a copy of the
written offer to provide the Corresponding Source. This
alternative is allowed only occasionally and noncommercially, and
only if you received the object code with such an offer, in accord
with subsection 6b.
d) Convey the object code by offering access from a designated
place (gratis or for a charge), and offer equivalent access to the
Corresponding Source in the same way through the same place at no
further charge. You need not require recipients to copy the
Corresponding Source along with the object code. If the place to
copy the object code is a network server, the Corresponding Source
may be on a different server (operated by you or a third party)
that supports equivalent copying facilities, provided you maintain
clear directions next to the object code saying where to find the
Corresponding Source. Regardless of what server hosts the
Corresponding Source, you remain obligated to ensure that it is
available for as long as needed to satisfy these requirements.
e) Convey the object code using peer-to-peer transmission, provided
you inform other peers where the object code and Corresponding
Source of the work are being offered to the general public at no
charge under subsection 6d.
A separable portion of the object code, whose source code is excluded
from the Corresponding Source as a System Library, need not be
included in conveying the object code work.
A "User Product" is either (1) a "consumer product", which means any
tangible personal property which is normally used for personal, family,
or household purposes, or (2) anything designed or sold for incorporation
into a dwelling. In determining whether a product is a consumer product,
doubtful cases shall be resolved in favor of coverage. For a particular
product received by a particular user, "normally used" refers to a
typical or common use of that class of product, regardless of the status
of the particular user or of the way in which the particular user
actually uses, or expects or is expected to use, the product. A product
is a consumer product regardless of whether the product has substantial
commercial, industrial or non-consumer uses, unless such uses represent
the only significant mode of use of the product.
"Installation Information" for a User Product means any methods,
procedures, authorization keys, or other information required to install
and execute modified versions of a covered work in that User Product from
a modified version of its Corresponding Source. The information must
suffice to ensure that the continued functioning of the modified object
code is in no case prevented or interfered with solely because
modification has been made.
If you convey an object code work under this section in, or with, or
specifically for use in, a User Product, and the conveying occurs as
part of a transaction in which the right of possession and use of the
User Product is transferred to the recipient in perpetuity or for a
fixed term (regardless of how the transaction is characterized), the
Corresponding Source conveyed under this section must be accompanied
by the Installation Information. But this requirement does not apply
if neither you nor any third party retains the ability to install
modified object code on the User Product (for example, the work has
been installed in ROM).
The requirement to provide Installation Information does not include a
requirement to continue to provide support service, warranty, or updates
for a work that has been modified or installed by the recipient, or for
the User Product in which it has been modified or installed. Access to a
network may be denied when the modification itself materially and
adversely affects the operation of the network or violates the rules and
protocols for communication across the network.
Corresponding Source conveyed, and Installation Information provided,
in accord with this section must be in a format that is publicly
documented (and with an implementation available to the public in
source code form), and must require no special password or key for
unpacking, reading or copying.
7. Additional Terms.
"Additional permissions" are terms that supplement the terms of this
License by making exceptions from one or more of its conditions.
Additional permissions that are applicable to the entire Program shall
be treated as though they were included in this License, to the extent
that they are valid under applicable law. If additional permissions
apply only to part of the Program, that part may be used separately
under those permissions, but the entire Program remains governed by
this License without regard to the additional permissions.
When you convey a copy of a covered work, you may at your option
remove any additional permissions from that copy, or from any part of
it. (Additional permissions may be written to require their own
removal in certain cases when you modify the work.) You may place
additional permissions on material, added by you to a covered work,
for which you have or can give appropriate copyright permission.
Notwithstanding any other provision of this License, for material you
add to a covered work, you may (if authorized by the copyright holders of
that material) supplement the terms of this License with terms:
a) Disclaiming warranty or limiting liability differently from the
terms of sections 15 and 16 of this License; or
b) Requiring preservation of specified reasonable legal notices or
author attributions in that material or in the Appropriate Legal
Notices displayed by works containing it; or
c) Prohibiting misrepresentation of the origin of that material, or
requiring that modified versions of such material be marked in
reasonable ways as different from the original version; or
d) Limiting the use for publicity purposes of names of licensors or
authors of the material; or
e) Declining to grant rights under trademark law for use of some
trade names, trademarks, or service marks; or
f) Requiring indemnification of licensors and authors of that
material by anyone who conveys the material (or modified versions of
it) with contractual assumptions of liability to the recipient, for
any liability that these contractual assumptions directly impose on
those licensors and authors.
All other non-permissive additional terms are considered "further
restrictions" within the meaning of section 10. If the Program as you
received it, or any part of it, contains a notice stating that it is
governed by this License along with a term that is a further
restriction, you may remove that term. If a license document contains
a further restriction but permits relicensing or conveying under this
License, you may add to a covered work material governed by the terms
of that license document, provided that the further restriction does
not survive such relicensing or conveying.
If you add terms to a covered work in accord with this section, you
must place, in the relevant source files, a statement of the
additional terms that apply to those files, or a notice indicating
where to find the applicable terms.
Additional terms, permissive or non-permissive, may be stated in the
form of a separately written license, or stated as exceptions;
the above requirements apply either way.
8. Termination.
You may not propagate or modify a covered work except as expressly
provided under this License. Any attempt otherwise to propagate or
modify it is void, and will automatically terminate your rights under
this License (including any patent licenses granted under the third
paragraph of section 11).
However, if you cease all violation of this License, then your
license from a particular copyright holder is reinstated (a)
provisionally, unless and until the copyright holder explicitly and
finally terminates your license, and (b) permanently, if the copyright
holder fails to notify you of the violation by some reasonable means
prior to 60 days after the cessation.
Moreover, your license from a particular copyright holder is
reinstated permanently if the copyright holder notifies you of the
violation by some reasonable means, this is the first time you have
received notice of violation of this License (for any work) from that
copyright holder, and you cure the violation prior to 30 days after
your receipt of the notice.
Termination of your rights under this section does not terminate the
licenses of parties who have received copies or rights from you under
this License. If your rights have been terminated and not permanently
reinstated, you do not qualify to receive new licenses for the same
material under section 10.
9. Acceptance Not Required for Having Copies.
You are not required to accept this License in order to receive or
run a copy of the Program. Ancillary propagation of a covered work
occurring solely as a consequence of using peer-to-peer transmission
to receive a copy likewise does not require acceptance. However,
nothing other than this License grants you permission to propagate or
modify any covered work. These actions infringe copyright if you do
not accept this License. Therefore, by modifying or propagating a
covered work, you indicate your acceptance of this License to do so.
10. Automatic Licensing of Downstream Recipients.
Each time you convey a covered work, the recipient automatically
receives a license from the original licensors, to run, modify and
propagate that work, subject to this License. You are not responsible
for enforcing compliance by third parties with this License.
An "entity transaction" is a transaction transferring control of an
organization, or substantially all assets of one, or subdividing an
organization, or merging organizations. If propagation of a covered
work results from an entity transaction, each party to that
transaction who receives a copy of the work also receives whatever
licenses to the work the party's predecessor in interest had or could
give under the previous paragraph, plus a right to possession of the
Corresponding Source of the work from the predecessor in interest, if
the predecessor has it or can get it with reasonable efforts.
You may not impose any further restrictions on the exercise of the
rights granted or affirmed under this License. For example, you may
not impose a license fee, royalty, or other charge for exercise of
rights granted under this License, and you may not initiate litigation
(including a cross-claim or counterclaim in a lawsuit) alleging that
any patent claim is infringed by making, using, selling, offering for
sale, or importing the Program or any portion of it.
11. Patents.
A "contributor" is a copyright holder who authorizes use under this
License of the Program or a work on which the Program is based. The
work thus licensed is called the contributor's "contributor version".
A contributor's "essential patent claims" are all patent claims
owned or controlled by the contributor, whether already acquired or
hereafter acquired, that would be infringed by some manner, permitted
by this License, of making, using, or selling its contributor version,
but do not include claims that would be infringed only as a
consequence of further modification of the contributor version. For
purposes of this definition, "control" includes the right to grant
patent sublicenses in a manner consistent with the requirements of
this License.
Each contributor grants you a non-exclusive, worldwide, royalty-free
patent license under the contributor's essential patent claims, to
make, use, sell, offer for sale, import and otherwise run, modify and
propagate the contents of its contributor version.
In the following three paragraphs, a "patent license" is any express
agreement or commitment, however denominated, not to enforce a patent
(such as an express permission to practice a patent or covenant not to
sue for patent infringement). To "grant" such a patent license to a
party means to make such an agreement or commitment not to enforce a
patent against the party.
If you convey a covered work, knowingly relying on a patent license,
and the Corresponding Source of the work is not available for anyone
to copy, free of charge and under the terms of this License, through a
publicly available network server or other readily accessible means,
then you must either (1) cause the Corresponding Source to be so
available, or (2) arrange to deprive yourself of the benefit of the
patent license for this particular work, or (3) arrange, in a manner
consistent with the requirements of this License, to extend the patent
license to downstream recipients. "Knowingly relying" means you have
actual knowledge that, but for the patent license, your conveying the
covered work in a country, or your recipient's use of the covered work
in a country, would infringe one or more identifiable patents in that
country that you have reason to believe are valid.
If, pursuant to or in connection with a single transaction or
arrangement, you convey, or propagate by procuring conveyance of, a
covered work, and grant a patent license to some of the parties
receiving the covered work authorizing them to use, propagate, modify
or convey a specific copy of the covered work, then the patent license
you grant is automatically extended to all recipients of the covered
work and works based on it.
A patent license is "discriminatory" if it does not include within
the scope of its coverage, prohibits the exercise of, or is
conditioned on the non-exercise of one or more of the rights that are
specifically granted under this License. You may not convey a covered
work if you are a party to an arrangement with a third party that is
in the business of distributing software, under which you make payment
to the third party based on the extent of your activity of conveying
the work, and under which the third party grants, to any of the
parties who would receive the covered work from you, a discriminatory
patent license (a) in connection with copies of the covered work
conveyed by you (or copies made from those copies), or (b) primarily
for and in connection with specific products or compilations that
contain the covered work, unless you entered into that arrangement,
or that patent license was granted, prior to 28 March 2007.
Nothing in this License shall be construed as excluding or limiting
any implied license or other defenses to infringement that may
otherwise be available to you under applicable patent law.
12. No Surrender of Others' Freedom.
If conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot convey a
covered work so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you may
not convey it at all. For example, if you agree to terms that obligate you
to collect a royalty for further conveying from those to whom you convey
the Program, the only way you could satisfy both those terms and this
License would be to refrain entirely from conveying the Program.
13. Use with the GNU Affero General Public License.
Notwithstanding any other provision of this License, you have
permission to link or combine any covered work with a work licensed
under version 3 of the GNU Affero General Public License into a single
combined work, and to convey the resulting work. The terms of this
License will continue to apply to the part which is the covered work,
but the special requirements of the GNU Affero General Public License,
section 13, concerning interaction through a network will apply to the
combination as such.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of
the GNU General Public License from time to time. Such new versions will
be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the
Program specifies that a certain numbered version of the GNU General
Public License "or any later version" applies to it, you have the
option of following the terms and conditions either of that numbered
version or of any later version published by the Free Software
Foundation. If the Program does not specify a version number of the
GNU General Public License, you may choose any version ever published
by the Free Software Foundation.
If the Program specifies that a proxy can decide which future
versions of the GNU General Public License can be used, that proxy's
public statement of acceptance of a version permanently authorizes you
to choose that version for the Program.
Later license versions may give you additional or different
permissions. However, no additional obligations are imposed on any
author or copyright holder as a result of your choosing to follow a
later version.
15. Disclaimer of Warranty.
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
16. Limitation of Liability.
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
SUCH DAMAGES.
17. Interpretation of Sections 15 and 16.
If the disclaimer of warranty and limitation of liability provided
above cannot be given local legal effect according to their terms,
reviewing courts shall apply local law that most closely approximates
an absolute waiver of all civil liability in connection with the
Program, unless a warranty or assumption of liability accompanies a
copy of the Program in return for a fee.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
state the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
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/>.
Also add information on how to contact you by electronic and paper mail.
If the program does terminal interaction, make it output a short
notice like this when it starts in an interactive mode:
<program> Copyright (C) <year> <name of author>
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
This is free software, and you are welcome to redistribute it
under certain conditions; type `show c' for details.
The hypothetical commands `show w' and `show c' should show the appropriate
parts of the General Public License. Of course, your program's commands
might be different; for a GUI interface, you would use an "about box".
You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary.
For more information on this, and how to apply and follow the GNU GPL, see
<https://www.gnu.org/licenses/>.
The GNU General Public License does not permit incorporating your program
into proprietary programs. If your program is a subroutine library, you
may consider it more useful to permit linking proprietary applications with
the library. If this is what you want to do, use the GNU Lesser General
Public License instead of this License. But first, please read
<https://www.gnu.org/licenses/why-not-lgpl.html>.
+163 -157
View File
@@ -4,58 +4,114 @@
<h1 align="center">PicHost</h1>
<p align="center"><strong>Lightweight personal image hosting</strong> · Simple uploads, clean management</p>
<p align="center"><strong>Self-hosted image, video, and file management platform</strong></p>
<p align="center">Self-hosted / Multi-user / Docker / API / Twikoo · Zero-config setup</p>
<p align="center">Multi-user · WebP compression · HLS video · File hosting · Tags · Multi-backend storage · Full backup and migration</p>
<p align="center">
<a href="https://github.com/O96u/PicHost/blob/main/package.json"><img src="https://img.shields.io/github/package-json/v/O96u/PicHost?style=flat-square&color=22c55e" alt="version" /></a>
<a href="https://github.com/O96u/PicHost/actions/workflows/ci.yml"><img src="https://github.com/O96u/PicHost/actions/workflows/ci.yml/badge.svg" alt="CI" /></a>
<a href="https://github.com/yeomaiyelove-cpu/PicHost/blob/main/package.json"><img src="https://img.shields.io/github/package-json/v/yeomaiyelove-cpu/PicHost?style=flat-square&color=22c55e" alt="version" /></a>
<img src="https://img.shields.io/badge/Nuxt-4-00DC82?style=flat-square&logo=nuxt.js&logoColor=white" alt="Nuxt 4" />
<a href="https://hub.docker.com/r/muxui/pichost"><img src="https://img.shields.io/badge/Docker-muxui%2Fpichost-2496ED?style=flat-square&logo=docker&logoColor=white" alt="Docker" /></a>
<img src="https://img.shields.io/badge/license-MIT-blue?style=flat-square" alt="MIT" />
<a href="https://hub.docker.com/r/yeomaiyelove/pichost"><img src="https://img.shields.io/badge/Docker-yeomaiyelove%2Fpichost-2496ED?style=flat-square&logo=docker&logoColor=white" alt="Docker" /></a>
<img src="https://img.shields.io/badge/Windows-x64-0078D4?style=flat-square&logo=windows&logoColor=white" alt="Windows x64" />
<img src="https://img.shields.io/badge/FnOS-x86-00A67E?style=flat-square" alt="FnOS x86" />
<img src="https://img.shields.io/badge/license-GPL--3.0-blue?style=flat-square" alt="GPL-3.0" />
</p>
<p align="center">
<a href="#quick-start">Quick Start</a> ·
<a href="#screenshots">Screenshots</a> ·
<a href="#features">Features</a> ·
<a href="#tech-stack">Tech Stack</a> ·
<a href="#roadmap">Roadmap</a> ·
<a href="#deployment">Deployment</a> ·
<a href="#development-and-packaging">Development</a> ·
<a href="docs-site/en/">Documentation source</a> ·
<a href="README.md">简体中文</a>
</p>
---
## Overview
PicHost is a media management platform for NAS devices, home servers, and small self-hosted deployments. It manages images, videos, and general-purpose files in one place, with multi-user access, tags, search, multiple storage backends, HLS video processing, full-site backup and restore, backend migration, and site-to-site synchronization.
The first visit opens a web setup wizard. Administrators can either create an account or restore an existing `.phost.tar.gz` backup.
## Screenshots
| Login | Upload |
| :----------------------------------: | :------------------------------------: |
| ![Login](docs/screenshots/login.png) | ![Upload](docs/screenshots/upload.png) |
| Upload | Gallery |
| :----: | :-----: |
| ![Upload](docs/screenshots/upload.png) | ![Gallery](docs/screenshots/gallery.png) |
| Upload preferences | Stats & gallery |
| :----------------------------------------------: | :----------------------------------: |
| ![Preferences](docs/screenshots/preferences.png) | ![Stats](docs/screenshots/stats.png) |
| API | Tag management |
| :-: | :------------: |
| ![API](docs/screenshots/api.png) | ![Tags](docs/screenshots/tags.png) |
| Settings | |
| :----------------------------------------: | :-: |
| ![Settings](docs/screenshots/settings.png) | |
| Storage | Settings |
| :-----: | :-------: |
| ![Storage](docs/screenshots/storage.png) | ![Settings](docs/screenshots/settings-en.png) |
| First-time setup | Restore from backup |
| :--------------: | :-----------------: |
| ![Setup](docs/screenshots/setup.png) | ![Setup restore](docs/screenshots/setup-restore.png) |
| Activity log |
| :----------: |
| ![Activity log](docs/screenshots/logs.png) |
## Features
- **Drag, click, or Ctrl+V paste** to upload; admins can use custom `folder` paths
- **Server-side WebP** (sharp), Referer hotlink protection, configurable public base URL
- **Multi-user** — username/password login; optional registration; users see only their images
- **Upload preferences** (card flip UI): client-side compression, auto-copy links, per-user auto-delete
- **Gallery** — browse, search, batch delete; admin sees uploader badges
- **Stats** — upload/delete metrics, folder breakdown; admin sees registered user count
- **API** — global token (admin) + per-user tokens; in-app cURL snippets
- **Twikoo** — `POST /api/index.php` (`image` + `token`)
- **Zero-config Docker** — first-run web wizard, no secrets required upfront
### Images
## Quick Start
- Upload by drag and drop, file picker, or `Ctrl+V` paste, with configurable formats, size limits, batch limits, and rate limits.
- Server-side Sharp-based WebP compression, with optional original preservation and optional browser-side WebP compression.
- Search, pagination, storage/source/tag filters, grid or list layouts, detail views, multiple link formats, bulk tagging, and bulk deletion.
- Media counts and source statistics, Referer hotlink protection, and configurable referrer allowlists.
### Docker (recommended)
### Video and HLS
- Dedicated video upload and library pages for MP4, M4V, WebM, MOV, MKV, AVI, FLV, WMV, MPG, and MPEG.
- Automatic thumbnail generation and optional asynchronous HLS transcoding.
- `copy`, `veryfast`, `medium`, `slow`, and `compress` presets, with configurable concurrency, CPU thread count, and target bitrate.
- Transcode progress and failure reporting, with automatic cleanup of the source, thumbnail, playlist, and HLS segments.
- Original, thumbnail, and HLS links, plus MP4 remux download for HLS videos.
### File Hosting
- A dedicated file library with uploads, search, pagination, tags, bulk deletion, direct links, and download links.
- Configurable file-bed access policy, with separate domains supported for images, videos, and files.
### Users and Access Control
- Administrator and standard-user accounts; standard users only see and manage their own media.
- Slider, Cloudflare Turnstile, and Cap verification, with optional public registration.
- Profile and password management, administrator password reset, and account-system migration.
- Per-user tags and automatic deletion policies.
- Login and upload rate limits, global and personal API tokens, and activity logs.
### Storage
- Local disk, S3-compatible object storage, and Telegram channels or groups.
- S3-compatible support includes R2, COS, OSS, AWS S3, and similar services, with `proxy` or `public` access modes.
- Multiple backends, configurable default and enabled states, and per-backend capacity quotas.
- Migration of images, videos, files, and indexes between configured backends, with an optional target backend switch after migration.
### Backup and Synchronization
- Complete `.phost.tar.gz` site backups containing the database, images, videos, files, thumbnails, HLS content, and tags.
- Restore previews for file count, media size, new files, conflicts, and skip/overwrite conflict handling.
- Streaming uploads for large backups without loading the complete archive into memory.
- Restore directly from the first-run setup page; cloud storage secrets are excluded and must be re-entered after restore.
- Push or pull synchronization between two PicHost sites, for all media or only images, videos, or files.
- Resume support, conflict policies, and job progress; matching usernames must already exist on both sites.
### API and Compatibility
- APIs for images, videos, files, tags, search, bulk operations, backups, and system information.
- Global and personal tokens, accepted through request headers or upload forms.
- Twikoo / EasyImage-compatible uploads through `POST /api/index.php`.
- Built-in API documentation and debugger with generated `curl` examples.
## Deployment
### Docker
```bash
docker run -d \
@@ -63,147 +119,97 @@ docker run -d \
-p 6892:6892 \
-v ./data:/data \
--restart unless-stopped \
muxui/pichost:latest
yeomaiyelove/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. Data is stored in the host `./data` directory by default.
**Forgot password** (requires `docker exec` / server access):
Docker Hub:
- `yeomaiyelove/pichost:latest`
- `yeomaiyelove/pichost:<version>`
### Windows
The Windows x64 installer is self-contained:
- Node.js and FFmpeg are bundled; no runtime download is required on the target computer.
- The first launch opens the web setup wizard.
- Data is stored in the `data` directory under the installation directory.
- Uninstall asks whether to delete the data directory. If preserved, the database, media, and backups remain on disk.
### FnOS
FnOS uses a dedicated x86 FPK package that runs the bundled Docker image:
1. Import the matching `yeomaiyelove/pichost:<version>` image into FnOS Docker.
2. Confirm that the image appears locally.
3. Install the FPK and choose account creation or backup restore in the wizard.
The FPK uses the local image and does not pull PicHost from the internet.
## Development and Packaging
### Local Development
```bash
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. 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 ci
npm run dev
```
Open `http://localhost:3000/setup` to create the admin account. Data defaults to `./data`.
The first visit opens `/setup`. Common verification commands:
### Legacy migration
```bash
npm test
npm run typecheck
npm run lint
npm run build
```
If you only had `ADMIN_SECRET` and no user table, log in with the secret once and follow the migration wizard.
### Release Artifacts
```bash
npm run pack:docker
npm run pack:fpk
npm run pack:windows
```
On Windows, all artifacts can be built at once:
```bash
npm run pack:all
```
Output directories:
- Docker image archive: `dist-docker/`
- FnOS package: `dist-fpk/`
- Windows installer: `dist-windows/release/`
See [packaging/README.md](packaging/README.md) for requirements and override options.
## Data Directory
- Docker uses `/data` by default and supports the `DATA_DIR` override.
- Windows installs use the `data` directory under the installation path.
- Local development uses `data` in the project root.
The database is stored as `pichost.db`. Create regular full-site backups and keep the `.phost.tar.gz` files on separate storage.
## Tech Stack
| Layer | 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 (`DATA_DIR`) |
| Deploy | Docker (amd64 / arm64), docker compose |
| Tests | Vitest |
## Environment variables
| Variable | Description |
| ----------------------- | --------------------------------------------------------------------------- |
| `API_UPLOAD_TOKEN` | Global upload token (`Auth-Token` header); can be generated in the API page |
| `ALLOWED_REFERER_HOSTS` | Hotlink allowlist (comma-separated hostnames) |
| `IMAGE_BASE_URL` | Public base URL for image links |
| `WEBP_QUALITY` | Server WebP quality 1–100, default 80 |
| `AUTO_DELETE_DAYS` | Global auto-delete days (0 = off); admin + orphan images only |
| `DATA_DIR` | Data directory, default `/data` (container) or `./data` (local) |
| `ADMIN_SECRET` | Legacy v1.0 migration only |
| `DEV_BYPASS_ACCESS` | Dev only — bypass auth (never in production) |
Environment variables override database settings. See [`.env.example`](.env.example).
## 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, logs
├── images/ # Default upload folder
├── blog/ # Example custom folder
└── twikoo/ # Twikoo comment images
```
Pattern: `folder/YYYY/MM/randomId.webp` + `.meta.json`. Back up the entire `/data` directory.
## 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` for public access
## 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.0.3** | CI and Docker multi-arch build fixes (current) |
| **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 |
| **v1.1.0** | S3, Cloudflare R2, and more object storage backends |
| Layer | Technologies |
| ----- | ------------ |
| Frontend | Nuxt 4 · Nuxt UI 4 · Vue 3 · Tailwind CSS 4 · TypeScript |
| Backend | Nitro `node-server` · REST API · H3 |
| Database | SQLite |
| Images | Sharp (WebP compression) |
| Video | FFmpeg · HLS · hls.js |
| Storage | Local disk · S3-compatible object storage · Telegram |
| Internationalization | @nuxtjs/i18n (Simplified Chinese / English) |
| Quality and documentation | Vitest · ESLint · vue-tsc · VitePress |
| Deployment | Docker · Node.js 22 · Electron (Windows) · FnOS FPK |
## License
[MIT](LICENSE)
## Friend links
[LINUX DO](https://linux.do/)
[GPL-3.0](LICENSE)
+159 -157
View File
@@ -4,58 +4,115 @@
<h1 align="center">PicHost</h1>
<p align="center"><strong>个人轻量图床</strong> · 图片放好,管理也要顺手</p>
<p align="center"><strong>自托管图片、视频与文件管理平台</strong></p>
<p align="center">自托管图床 / 多用户 / Docker / API / Twikoo · 零配置引导</p>
<p align="center">多用户 · WebP 压缩 · HLS 视频 · 文件床 · 标签 · 多存储后端 · 整站备份与迁移</p>
<p align="center">
<a href="https://github.com/O96u/PicHost/blob/main/package.json"><img src="https://img.shields.io/github/package-json/v/O96u/PicHost?style=flat-square&color=22c55e" alt="version" /></a>
<a href="https://github.com/O96u/PicHost/actions/workflows/ci.yml"><img src="https://github.com/O96u/PicHost/actions/workflows/ci.yml/badge.svg" alt="CI" /></a>
<a href="https://github.com/yeomaiyelove-cpu/PicHost/blob/main/package.json"><img src="https://img.shields.io/github/package-json/v/yeomaiyelove-cpu/PicHost?style=flat-square&color=22c55e" alt="version" /></a>
<img src="https://img.shields.io/badge/Nuxt-4-00DC82?style=flat-square&logo=nuxt.js&logoColor=white" alt="Nuxt 4" />
<a href="https://hub.docker.com/r/muxui/pichost"><img src="https://img.shields.io/badge/Docker-muxui%2Fpichost-2496ED?style=flat-square&logo=docker&logoColor=white" alt="Docker" /></a>
<img src="https://img.shields.io/badge/license-MIT-blue?style=flat-square" alt="MIT" />
<a href="https://hub.docker.com/r/yeomaiyelove/pichost"><img src="https://img.shields.io/badge/Docker-yeomaiyelove%2Fpichost-2496ED?style=flat-square&logo=docker&logoColor=white" alt="Docker" /></a>
<img src="https://img.shields.io/badge/Windows-x64-0078D4?style=flat-square&logo=windows&logoColor=white" alt="Windows x64" />
<img src="https://img.shields.io/badge/%E9%A3%9E%E7%89%9B-x86-00A67E?style=flat-square" alt="飞牛 x86" />
<img src="https://img.shields.io/badge/license-GPL--3.0-blue?style=flat-square" alt="GPL-3.0" />
</p>
<p align="center">
<a href="#快速开始">快速开始</a> ·
<a href="#界面预览">预览</a> ·
<a href="#特性">特性</a> ·
<a href="#技术栈">技术栈</a> ·
<a href="#路线图">路线图</a> ·
<a href="#核心功能">功能</a> ·
<a href="#部署方式">部署</a> ·
<a href="#开发与打包">开发与打包</a> ·
<a href="docs-site/">文档源码</a> ·
<a href="README.en.md">English</a>
</p>
---
## 项目简介
PicHost 是一个面向 NAS、家庭服务器和小型自托管场景的媒体管理平台,可统一管理图片、视频和普通文件。除上传与直链外,还提供多用户、标签、搜索、多存储后端、HLS 视频处理、整站备份恢复、跨后端迁移以及两站点同步。
首次启动通过 Web 向导创建管理员,也可以直接从 `.phost.tar.gz` 完整备份恢复。
## 界面预览
| 登录 | 上传 |
| :---------------------------------: | :----------------------------------: |
| ![登录](docs/screenshots/login.png) | ![上传](docs/screenshots/upload.png) |
| 上传 | 图库 |
| :--: | :--: |
| ![上传](docs/screenshots/upload.png) | ![图库](docs/screenshots/gallery.png) |
| 上传偏好 | 统计与图库 |
| :-------------------------------------------: | :---------------------------------: |
| ![上传偏好](docs/screenshots/preferences.png) | ![统计](docs/screenshots/stats.png) |
| API | 标签管理 |
| :--: | :------: |
| ![API](docs/screenshots/api.png) | ![标签管理](docs/screenshots/tags.png) |
| 系统设置 | |
| :------------------------------------: | :-: |
| ![设置](docs/screenshots/settings.png) | |
| 存储 | 设置 |
| :--: | :--: |
| ![存储](docs/screenshots/storage.png) | ![设置](docs/screenshots/settings.png) |
## 特性
| 首次设置 | 从备份恢复 |
| :--: | :--: |
| ![首次设置](docs/screenshots/setup.png) | ![从备份恢复](docs/screenshots/setup-restore.png) |
- **拖拽 / 点击 / Ctrl+V 粘贴**上传;管理员可自定义 `folder` 目录
- **服务端 WebP 压缩**(sharp)、Referer 防盗链、可配置访问域名
- **多用户**:账号密码登录;管理员可开放注册;普通用户仅见自己的图片
- **上传偏好**(卡片翻转设置):客户端预压缩、自动复制链接、按用户自动删除
- **图库**:浏览、搜索、批量删除;管理员可查看上传者标签
- **统计**:上传/删除趋势、目录分布;管理员额外显示注册用户数量
- **API**:全局 Token(管理员)+ 每用户个人 Token;后台可复制 cURL
- **Twikoo**:`POST /api/index.php`(`image` + `token`)
- **Docker 零配置**:首次访问 Web 引导创建管理员,无需预先写密钥
| 操作日志 |
| :------: |
| ![操作日志](docs/screenshots/logs.png) |
## 快速开始
## 核心功能
### Docker(推荐)
### 图片管理
- 支持拖拽、文件选择和 `Ctrl+V` 粘贴上传,可配置格式、大小、批量数量和频率限制。
- 服务端使用 Sharp 压缩为 WebP,也可保留原图;支持可选的浏览器端 WebP 压缩。
- 图库支持搜索、分页、存储后端/上传来源/标签筛选,以及网格和列表视图。
- 支持查看详情、复制多种链接格式、批量打标和批量删除,并提供媒体数量与来源统计。
- 支持 Referer 防盗链和来源域名白名单。
### 视频库与 HLS
- 独立的视频上传、搜索和管理页面,支持 MP4、M4V、WebM、MOV、MKV、AVI、FLV、WMV、MPG、MPEG 等格式。
- 上传后自动生成视频缩略图,并可根据设置异步转码为 HLS。
- 支持 `copy`、`veryfast`、`medium`、`slow`、`compress` 等转码档位,可限制并发数、CPU 核心数和目标码率。
- 管理转码进度与失败原因,支持删除时同步清理原视频、缩略图、播放列表和 HLS 分片。
- 支持复制原视频、缩略图和 HLS 地址,并可将 HLS 视频重新封装下载为 MP4。
### 文件床
- 独立管理普通文件,提供上传、搜索、分页、标签、批量删除和直链/下载链接。
- 文件床访问策略可在后台配置,文件、图片和视频可分别使用独立域名。
### 多用户与权限
- 管理员与普通用户账号体系,普通用户只能查看和管理自己的媒体。
- 支持滑块、Cloudflare Turnstile 和 Cap 人机验证,可选择开放注册。
- 支持修改资料、修改密码、管理员清空密码和迁移到账号密码登录。
- 每个用户拥有独立标签和自动删除策略,可设置媒体保存天数。
- 提供登录与上传频率限制、全局/个人 API Token 和操作日志。
### 存储与迁移
- 支持本地磁盘、S3 兼容对象存储和 Telegram 频道/群组。
- S3 兼容 R2、COS、OSS、AWS S3 等服务,支持 `proxy` 和 `public` 两种访问方式。
- 可配置多个存储后端、默认后端、启用状态和容量配额。
- 支持在已配置后端之间迁移图片、视频、文件和索引,迁移后可将目标后端设为默认。
### 备份与站点同步
- 创建和恢复完整 `.phost.tar.gz` 站点备份,包含数据库、图片、视频、文件、缩略图、HLS 内容和标签。
- 备份恢复前可预检文件数量、媒体大小、新增与冲突情况,并选择跳过或覆盖冲突。
- 支持大体积备份流式上传,无需将整个压缩包读入内存。
- 未初始化时可在首次设置页直接恢复备份;云存储密钥不会写入备份,恢复后需重新填写。
- 支持两站点双向同步,可推送或拉取全部媒体,也可只同步图片、视频或文件。
- 站点同步支持断点继续、冲突策略和任务进度;目标站点需要预先存在同名用户。
### API 与兼容性
- 提供图片、视频、文件、标签、搜索、批量操作、备份和系统接口。
- 支持全局 Token 和个人 Token,可通过请求头或表单上传。
- 兼容 Twikoo / EasyImage 的 `POST /api/index.php` 上传接口。
- 前端内置 API 文档和调试界面,支持直接生成并复制 `curl` 示例。
## 部署方式
### Docker
```bash
docker run -d \
@@ -63,152 +120,97 @@ docker run -d \
-p 6892:6892 \
-v ./data:/data \
--restart unless-stopped \
muxui/pichost:latest
yeomaiyelove/pichost:latest
```
默认端口 **6892**。浏览器打开 `http://<主机IP>:6892`,按引导创建管理员即可。
浏览器打开 `http://<主机IP>:6892`,按向导创建管理员即可。数据默认保存在宿主机的 `./data` 目录。
**忘记密码**(需能执行 `docker exec`,相当于服务器权限):
Docker Hub:
```bash
# 无参数:重置管理员(仅当系统中只有一个管理员时)
docker exec pichost reset-password
- `yeomaiyelove/pichost:latest`
- `yeomaiyelove/pichost:<版本号>`
# 指定用户名:可重置管理员或普通用户
docker exec pichost reset-password 用户名
```
### Windows
终端会打印随机新密码;用户不存在时会报错。登录后请到「修改密码」更换。
项目提供 Windows x64 一体安装包:
本地:`npm run reset-password` 或 `npm run reset-password -- 用户名`
- 内置 Node.js 和 FFmpeg,不依赖目标电脑预装运行环境。
- 首次启动后按 Web 向导完成设置。
- 数据保存在安装目录下的 `data` 子目录。
- 卸载时会询问是否删除数据;选择保留后,数据库、图片、视频和备份不会被删除。
已克隆仓库时也可用 `docker compose up -d`(见仓库内 `docker-compose.yml`)。
### 飞牛 NAS
飞牛使用独立的 x86 FPK 安装包,FPK 以内置 Docker 镜像方式运行:
1. 在飞牛 Docker 中导入与 FPK 版本对应的镜像包。
2. 确认镜像列表中存在 `yeomaiyelove/pichost:<版本号>`。
3. 安装 FPK,并按向导选择创建管理员或从备份恢复。
FPK 使用本地镜像,不会自动联网拉取 PicHost 镜像。
## 开发与打包
### 本地开发
```bash
npm install
cp .env.example .env # 可选
npm ci
npm run dev
```
访问 `http://localhost:3000/setup` 创建管理员;数据目录默认 `./data`。
默认开发地址由 Nuxt 输出,首次访问会进入 `/setup`。常用检查命令:
### 遗留部署迁移
```bash
npm test
npm run typecheck
npm run lint
npm run build
```
若旧版仅配置了 `ADMIN_SECRET`、尚无用户表,可用密钥登录一次,Web 引导迁移为账号密码。
### 构建安装产物
```bash
npm run pack:docker
npm run pack:fpk
npm run pack:windows
```
Windows 环境可以一次构建全部产物:
```bash
npm run pack:all
```
输出位置:
- Docker 离线镜像:`dist-docker/`
- 飞牛安装包:`dist-fpk/`
- Windows 安装程序:`dist-windows/release/`
详细环境要求和可覆盖参数见 [打包说明](packaging/README.md)。
## 数据目录
- Docker 默认使用 `/data`,通过 `DATA_DIR` 可覆盖。
- Windows 安装版使用安装目录下的 `data`。
- 本地开发默认使用项目根目录下的 `data`。
数据库文件为 `pichost.db`。建议定期在后台创建整站备份,并将 `.phost.tar.gz` 文件保存到其他磁盘。
## 技术栈
| 层级 | 技术 |
| -------- | ------------------------------------------ |
| 前端 | Nuxt 4、Vue 3、Nuxt UI 4、Tailwind CSS 4 |
| 后端 | Nitro(Node.js)、SQLite |
| 图片处理 | sharp(WebP 转码与压缩) |
| 鉴权 | Session Cookie、scrypt 密码哈希、API Token |
| 存储 | 本地磁盘(`DATA_DIR`) |
| 部署 | Docker(amd64 / arm64)、docker compose |
| 测试 | Vitest |
## 环境变量
| 变量 | 说明 |
| ----------------------- | ------------------------------------------------------------ |
| `API_UPLOAD_TOKEN` | 全局上传 Token(`Auth-Token` 头);未配置可在后台 API 页生成 |
| `ALLOWED_REFERER_HOSTS` | 防盗链白名单(逗号分隔 hostname) |
| `IMAGE_BASE_URL` | 图片直链公网域名(反代时建议填写) |
| `WEBP_QUALITY` | 服务端 WebP 质量 1–100,默认 80 |
| `AUTO_DELETE_DAYS` | 全局自动删除天数(0 关闭);仅影响管理员及无归属历史图 |
| `DATA_DIR` | 数据目录,默认 `/data`(容器)或 `./data`(本地) |
| `ADMIN_SECRET` | 仅遗留 v1.0 迁移,新安装不需要 |
| `DEV_BYPASS_ACCESS` | 开发用,跳过登录(生产勿开) |
环境变量优先级高于后台设置。详见 [`.env.example`](.env.example)。
## 用户与权限
| 场景 | 鉴权 | 图片归属 / 可见范围 |
| -------------------------- | -------------------- | -------------------------- |
| 网页上传 | 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:用户、会话、设置、操作日志
├── images/ # 默认上传目录
├── blog/ # 管理员自定义 folder 示例
└── twikoo/ # Twikoo 评论图片
```
路径规则:`目录/年/月/随机ID.webp` + 同名 `.meta.json`。备份整个 `/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`
## 开发
```bash
npm run dev # 开发服务器
npm run build # 生产构建
npm run start # 运行 .output
npm run lint # ESLint
npm run typecheck # 类型检查
npm test # 单元测试
```
## 路线图
| 版本 | 计划 |
| ---------- | ------------------------------------------------- |
| **v1.0.3** | CI 与 Docker 多架构构建修复(当前) |
| **v1.0.2** | 中英文、移动端菜单、统计/上传 UI、Docker 重置密码 |
| **v1.0.1** | 设置页版本展示、移动端上传偏好布局修复 |
| **v1.0.0** | 本地存储、多用户、API、Twikoo |
| **v1.1.0** | S3、Cloudflare R2 等对象存储后端 |
| 层级 | 技术 |
| ---- | ---- |
| 前端 | Nuxt 4 · Nuxt UI 4 · Vue 3 · Tailwind CSS 4 · TypeScript |
| 后端 | Nitro `node-server` · REST API · H3 |
| 数据库 | SQLite |
| 图片处理 | Sharp(WebP 压缩) |
| 视频处理 | FFmpeg · HLS · hls.js |
| 存储 | 本地磁盘 · S3 兼容对象存储 · Telegram |
| 国际化 | @nuxtjs/i18n(简体中文 / English) |
| 测试与文档 | Vitest · ESLint · vue-tsc · VitePress |
| 部署 | Docker · Node.js 22 · Electron(Windows)· 飞牛 FPK |
## License
[MIT](LICENSE)
## 友链
[LINUX DO](https://linux.do/)
[GPL-3.0](LICENSE)
+42 -1
View File
@@ -7,22 +7,44 @@
.upload-card-flip-inner {
position: relative;
transition: transform 0.55s cubic-bezier(0.4, 0, 0.2, 1);
transition:
transform 0.55s cubic-bezier(0.4, 0, 0.2, 1),
min-height 0.35s ease;
transform-style: preserve-3d;
}
.upload-card-flip.is-flipped .upload-card-flip-inner {
min-height: min(40rem, calc(100vh - 11rem));
transform: rotateY(180deg);
}
.upload-card-flip--file.is-flipped .upload-card-flip-inner {
min-height: min(27rem, calc(100vh - 11rem));
}
.upload-card-flip.is-flipped .upload-card-face--front {
visibility: hidden;
pointer-events: none;
}
.upload-card-flip.is-flipped .upload-settings-btn {
display: none;
}
.upload-card-face {
backface-visibility: hidden;
-webkit-backface-visibility: hidden;
}
.upload-card-face--front {
position: relative;
z-index: 1;
}
.upload-card-face--back {
position: absolute;
inset: 0;
z-index: 2;
transform: rotateY(180deg);
}
@@ -33,6 +55,7 @@
.upload-card-flip.is-flipped .upload-card-flip-inner {
transform: none;
min-height: 0;
}
.upload-card-flip .upload-card-face--back {
@@ -106,3 +129,21 @@
.upload-settings-btn:hover .upload-settings-icon {
transform: rotate(90deg);
}
.api-debug-response {
scrollbar-width: thin;
scrollbar-color: rgb(82 82 82) transparent;
}
.api-debug-response::-webkit-scrollbar {
width: 6px;
}
.api-debug-response::-webkit-scrollbar-thumb {
border-radius: 9999px;
background-color: rgb(82 82 82);
}
.api-debug-response::-webkit-scrollbar-track {
background: transparent;
}
+49 -6
View File
@@ -1,6 +1,7 @@
<script setup lang="ts">
import logoLight from '~/assets/image/logo-light.png'
import logoDark from '~/assets/image/logo-dark.png'
import type { LoginVerificationPayload } from '~/types/auth'
const router = useRouter()
const { login, fetchStatus, authStatus } = useAuth()
@@ -11,29 +12,53 @@ const username = ref('')
const password = ref('')
const secret = ref('')
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 allowRegistration = computed(() => authStatus.value?.allowRegistration ?? false)
const canSubmit = computed(() =>
legacyMode.value ? Boolean(secret.value) : Boolean(username.value && password.value)
const loginVerification = computed(() =>
authStatus.value?.loginVerification ?? { method: 'slider' as const }
)
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 () => {
verificationPayload.value = null
verificationMountKey.value += 1
const status = await fetchStatus()
if (!status.initialized && !status.legacyMode) {
await router.replace('/setup')
return
}
})
async function submit() {
if (!canSubmit.value || loading.value) return
if (!canSubmit.value || loading.value || !verificationPayload.value) return
loading.value = true
try {
const verification = verificationPayload.value
const result = legacyMode.value
? await login({ secret: secret.value })
: await login({ username: username.value.trim(), password: password.value })
? await login({ secret: secret.value }, verification)
: await login({ username: username.value.trim(), password: password.value }, verification)
if (result.ok) {
if (result.needsMigration) {
@@ -43,8 +68,16 @@ async function submit() {
username.value = ''
password.value = ''
secret.value = ''
await resetVerification()
toast.add({ title: t('auth.loginSuccess'), color: 'success' })
} else if (result.needsPasswordReset) {
await resetVerification()
await router.push({
path: '/reset-password',
query: { username: result.resetUsername ?? username.value.trim() }
})
} else {
await resetVerification()
toast.add({
title: result.error ?? (legacyMode.value ? t('auth.loginFailedSecret') : t('auth.loginFailedCredentials')),
color: 'error'
@@ -152,6 +185,16 @@ async function submit() {
</div>
</template>
<LoginVerification
:key="verificationMountKey"
ref="verificationRef"
:method="loginVerification.method"
:turnstile-site-key="loginVerification.turnstileSiteKey"
:cap-api-endpoint="loginVerification.capApiEndpoint"
@verified="onVerificationVerified"
@cleared="onVerificationCleared"
/>
<UButton
type="submit"
: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>
+93 -50
View File
@@ -13,47 +13,74 @@ const mobileUserOpen = ref(false)
const navItems = computed(() => {
const items = [
{ label: t('nav.api'), to: '/api', icon: 'i-lucide-code-2' },
{ label: t('nav.stats'), to: '/stats', icon: 'i-lucide-chart-column' }
{ label: t('nav.gallery'), to: '/gallery', icon: 'i-lucide-images' },
{ label: t('nav.videoLibrary'), to: '/video-library', icon: 'i-lucide-film' },
{ label: t('nav.fileLibrary'), to: '/file-library', icon: 'i-lucide-folder-open' }
]
if (isAdmin.value) {
items.push({ label: t('nav.settings'), to: '/settings', icon: 'i-lucide-settings' })
items.push({ label: t('nav.users'), to: '/users', icon: 'i-lucide-users' })
items.push({ label: t('nav.storage'), to: '/storage', icon: 'i-lucide-hard-drive' })
}
items.push({ label: t('nav.settings'), to: '/settings', icon: 'i-lucide-settings' })
return items
})
const accountMenuItems = computed<DropdownMenuItem[]>(() => {
const items: DropdownMenuItem[] = [
{
label: t('nav.changePassword'),
icon: 'i-lucide-key-round',
to: '/account/password'
}
]
if (isAdmin.value) {
items.push({
label: t('nav.systemSettings'),
icon: 'i-lucide-settings',
to: '/settings'
})
/** 三个上传入口,与顶部 Logo 并列 */
const hostItems = computed(() => [
{
label: t('host.image'),
to: '/',
icon: 'i-lucide-image',
title: t('host.imageFull')
},
{
label: t('host.video'),
to: '/videos',
icon: 'i-lucide-film',
title: t('host.videoFull')
},
{
label: t('host.file'),
to: '/files',
icon: 'i-lucide-folder-open',
title: t('host.fileFull')
}
])
items.push({
const mobileNavItems = computed(() => [...hostItems.value, ...navItems.value])
function isHostActive(to: string) {
return route.path === to || route.path.startsWith(`${to}/`)
}
const accountMenuItems = computed<DropdownMenuItem[]>(() => [
{
label: t('profile.title'),
icon: 'i-lucide-user-round',
to: '/account/profile'
},
{
label: t('nav.changePassword'),
icon: 'i-lucide-key-round',
to: '/account/password'
},
{
label: t('nav.logout'),
icon: 'i-lucide-log-out',
onSelect() {
void handleLogout()
}
})
return items
})
}
])
const desktopUserMenuItems = computed<DropdownMenuItem[][]>(() => [
accountMenuItems.value
])
function isNavActive(to: string) {
if (to === '/settings') {
return route.path.startsWith('/settings')
}
return route.path.startsWith(to)
}
@@ -75,34 +102,50 @@ async function handleLogout() {
<header class="sticky top-0 z-40 border-b border-default bg-default/80 backdrop-blur">
<div class="mx-auto max-w-7xl px-4 py-2.5 sm:px-6">
<div class="flex items-center justify-between gap-3">
<NuxtLink
to="/"
:title="t('nav.backToUpload')"
class="group flex min-w-0 shrink items-center gap-2.5 transition-opacity hover:opacity-90 sm:gap-3"
>
<img
:src="logoLight"
alt="PicHost"
class="h-9 w-auto shrink-0 object-contain sm:h-11 dark:hidden"
decoding="async"
draggable="false"
<div class="flex min-w-0 items-center gap-2 sm:gap-3">
<NuxtLink
to="/"
:title="t('host.imageFull')"
class="group flex min-w-0 shrink items-center gap-2.5 transition-opacity hover:opacity-90 sm:gap-3"
>
<img
:src="logoDark"
alt="PicHost"
class="hidden h-9 w-auto shrink-0 object-contain sm:h-11 dark:block"
decoding="async"
draggable="false"
>
<div class="min-w-0 leading-tight">
<p class="text-[15px] font-bold tracking-tight sm:text-base">
<span class="text-highlighted">Pic</span><span class="text-primary">Host</span>
</p>
<p class="mt-0.5 hidden text-[11px] text-muted min-[380px]:block sm:text-xs">
{{ t('app.tagline') }}
</p>
</div>
</NuxtLink>
<img
:src="logoLight"
alt="PicHost"
class="h-9 w-auto shrink-0 object-contain sm:h-11 dark:hidden"
decoding="async"
draggable="false"
>
<img
:src="logoDark"
alt="PicHost"
class="hidden h-9 w-auto shrink-0 object-contain sm:h-11 dark:block"
decoding="async"
draggable="false"
>
<div class="min-w-0 leading-tight">
<p class="text-[15px] font-bold tracking-tight sm:text-base">
<span class="text-highlighted">Pic</span><span class="text-primary">Host</span>
</p>
<p class="mt-0.5 hidden text-[11px] text-muted min-[380px]:block sm:text-xs">
{{ t('app.tagline') }}
</p>
</div>
</NuxtLink>
<nav class="hidden items-center gap-1 sm:flex">
<UButton
v-for="item in hostItems"
:key="item.to"
:to="item.to"
:icon="item.icon"
:label="item.label"
:title="item.title"
size="sm"
:variant="isHostActive(item.to) ? 'soft' : 'ghost'"
:color="isHostActive(item.to) ? 'primary' : 'neutral'"
/>
</nav>
</div>
<ClientOnly>
<div class="flex shrink-0 items-center gap-0.5 sm:gap-1">
@@ -133,7 +176,7 @@ async function handleLogout() {
/>
<MobileNavMenu
v-model:open="mobileNavOpen"
:items="navItems"
:items="mobileNavItems"
/>
</div>
+112
View File
@@ -0,0 +1,112 @@
<script setup lang="ts">
import type { ImageTag } from '~/types/image'
const props = defineProps<{
open: boolean
tags: ImageTag[]
selectedCount: number
mode?: 'add' | 'remove'
/** 仅添加标签,隐藏添加/移除切换(首页批量打标) */
addOnly?: boolean
}>()
const emit = defineEmits<{
'update:open': [value: boolean]
'confirm': [tagIds: number[], action: 'add' | 'remove']
}>()
const { t } = useI18n()
const action = ref<'add' | 'remove'>(props.mode ?? 'add')
const selectedTagIds = ref<number[]>([])
const { loadRecentIds } = useRecentTags()
watch(() => props.open, (open) => {
if (open) {
action.value = props.mode ?? 'add'
selectedTagIds.value = []
loadRecentIds()
}
})
function submit() {
if (!selectedTagIds.value.length) return
emit('confirm', selectedTagIds.value, action.value)
}
</script>
<template>
<UModal
:open="open"
:title="addOnly ? t('tags.batchAddTitle') : t('tags.batchTitle')"
:description="addOnly
? t('tags.batchAddSubtitle', { n: selectedCount })
: t('tags.batchSubtitle', { n: selectedCount })"
:ui="{ content: 'max-w-md' }"
@update:open="emit('update:open', $event)"
>
<template #body>
<div class="space-y-4">
<div
v-if="!addOnly"
class="flex gap-1 rounded-lg border border-default p-0.5"
>
<UButton
type="button"
size="xs"
class="flex-1 justify-center"
:variant="action === 'add' ? 'solid' : 'ghost'"
:color="action === 'add' ? 'primary' : 'neutral'"
:label="t('tags.batchAdd')"
@click="() => { action = 'add' }"
/>
<UButton
type="button"
size="xs"
class="flex-1 justify-center"
:variant="action === 'remove' ? 'solid' : 'ghost'"
:color="action === 'remove' ? 'primary' : 'neutral'"
:label="t('tags.batchRemove')"
@click="() => { action = 'remove' }"
/>
</div>
<TagAssignPanel
v-model="selectedTagIds"
:tags="tags"
:allow-create="action === 'add'"
/>
</div>
</template>
<template #footer>
<div class="flex w-full flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<p
v-if="addOnly"
class="flex items-center gap-1.5 text-xs text-muted"
>
<UIcon
name="i-lucide-info"
class="size-3.5 shrink-0"
/>
{{ t('tags.classifyHint') }}
</p>
<div class="flex flex-1 justify-end gap-2">
<UButton
variant="outline"
color="neutral"
@click="emit('update:open', false)"
>
{{ t('common.cancel') }}
</UButton>
<UButton
color="primary"
:disabled="!selectedTagIds.length"
@click="submit"
>
{{ t('common.save') }}
</UButton>
</div>
</div>
</template>
</UModal>
</template>
+62
View File
@@ -0,0 +1,62 @@
<script setup lang="ts">
const props = defineProps<{
apiEndpoint: string
}>()
const emit = defineEmits<{
verified: [payload: { capToken: string }]
cleared: []
}>()
const { t } = useI18n()
const widgetRef = ref<HTMLElement | null>(null)
const ready = ref(false)
onMounted(async () => {
const capWindow = window as Window & {
CAP_CUSTOM_WASM_URL?: string
CAP_CUSTOM_HASHWX_URL?: string
}
// SHA-256 与 hashwx 的求解器都放在本站 /cap,避免组件去 jsDelivr 拉 wasm。
capWindow.CAP_CUSTOM_WASM_URL = '/cap/cap_wasm_bg.wasm'
capWindow.CAP_CUSTOM_HASHWX_URL = '/cap/hashwx.wasm'
await import('cap-widget')
ready.value = true
})
function onSolve(event: Event) {
const token = (event as CustomEvent<{ token: string }>).detail?.token
if (token) {
emit('verified', { capToken: token })
}
}
function onError() {
emit('cleared')
}
function reset() {
const widget = widgetRef.value as (HTMLElement & { reset?: () => void }) | null
widget?.reset?.()
emit('cleared')
}
defineExpose({ reset })
</script>
<template>
<ClientOnly>
<cap-widget
v-if="ready"
ref="widgetRef"
:data-cap-api-endpoint="props.apiEndpoint"
:data-cap-i18n-initial-state="t('auth.capInitialState')"
:data-cap-i18n-verifying-label="t('auth.capVerifying')"
:data-cap-i18n-solved-label="t('auth.capSolved')"
:data-cap-i18n-error-label="t('auth.capError')"
@solve="onSolve"
@error="onError"
/>
</ClientOnly>
</template>
+159
View File
@@ -0,0 +1,159 @@
<script setup lang="ts">
const props = defineProps<{
open: boolean
}>()
const emit = defineEmits<{
'update:open': [value: boolean]
'cleared': []
}>()
const { t } = useI18n()
const toast = useToast()
const step = ref<'warning' | 'confirm'>('warning')
const confirmation = ref('')
const clearing = ref(false)
const canConfirm = computed(() =>
!clearing.value && confirmation.value.trim() === t('storage.clearStoragePhrase')
)
watch(() => props.open, (open) => {
if (open) {
step.value = 'warning'
confirmation.value = ''
clearing.value = false
}
})
function close() {
if (clearing.value) return
emit('update:open', false)
}
function goToConfirm() {
step.value = 'confirm'
}
function clearBrowserCache() {
const keys = [
'pichost.upload-preferences',
'pichost.upload-preferences.video',
'pichost.upload-preferences.file',
'pichost.recent-tag-ids',
'pichost.library-view.image',
'pichost.library-view.video',
'pichost.library-view.file'
]
for (const key of keys) localStorage.removeItem(key)
}
async function confirmClear() {
if (!canConfirm.value) return
clearing.value = true
try {
const result = await $fetch<{
deletedMedia: number
deletedObjects: number
clearedCacheDirs: number
failed: Array<{ key: string }>
}>('/api/user/storage/purge', {
method: 'POST',
credentials: 'include',
body: { confirmation: confirmation.value.trim() }
})
clearBrowserCache()
emit('cleared')
if (result.failed.length) {
toast.add({
title: t('storage.clearStoragePartial', {
failed: result.failed.length,
total: result.deletedMedia + result.failed.length
}),
color: 'warning'
})
} else {
toast.add({
title: t('storage.clearStorageSuccess', {
files: result.deletedObjects,
cache: result.clearedCacheDirs
}),
color: 'success'
})
}
emit('update:open', false)
} catch (error: unknown) {
const message = error && typeof error === 'object' && 'data' in error
? (error as { data?: { error?: { message?: string } } }).data?.error?.message
: undefined
toast.add({
title: message || t('storage.clearStorageFailed'),
color: 'error'
})
} finally {
clearing.value = false
}
}
</script>
<template>
<UModal
:open="open"
:title="step === 'warning' ? t('storage.clearStorageTitle') : t('storage.clearStorageFinalTitle')"
@update:open="emit('update:open', $event)"
>
<template #body>
<template v-if="step === 'warning'">
<UAlert
color="error"
variant="subtle"
icon="i-lucide-octagon-alert"
:description="t('storage.clearStorageWarning')"
/>
<p class="mt-3 text-sm text-muted">
{{ t('storage.clearStorageScope') }}
</p>
</template>
<template v-else>
<p class="text-sm leading-6 text-muted">
{{ t('storage.clearStorageInstruction') }}
</p>
<UInput
v-model="confirmation"
class="mt-3 w-full"
:placeholder="t('storage.clearStoragePlaceholder')"
autofocus
/>
</template>
</template>
<template #footer>
<div class="flex w-full justify-end gap-2">
<UButton
:label="t('common.cancel')"
color="neutral"
variant="outline"
:disabled="clearing"
@click="close"
/>
<UButton
v-if="step === 'warning'"
:label="t('common.continue')"
color="error"
@click="goToConfirm"
/>
<UButton
v-else
:label="t('storage.clearStorageConfirm')"
color="error"
:loading="clearing"
:disabled="!canConfirm"
@click="confirmClear"
/>
</div>
</template>
</UModal>
</template>
+4 -1
View File
@@ -6,6 +6,8 @@ const props = defineProps<{
icon?: string
block?: boolean
successTitle?: string
variant?: 'solid' | 'outline' | 'soft' | 'subtle' | 'ghost' | 'link'
color?: 'primary' | 'secondary' | 'success' | 'info' | 'warning' | 'error' | 'neutral'
}>()
const emit = defineEmits<{
@@ -76,7 +78,8 @@ async function copy() {
<template>
<UButton
size="xs"
variant="soft"
:variant="variant ?? 'soft'"
:color="color"
:loading="copying"
:disabled="!value"
:label="iconOnly ? undefined : label"
+16 -1
View File
@@ -1,10 +1,16 @@
<script setup lang="ts">
import type { MediaType } from '~/types/media'
const props = defineProps<{
open: boolean
count: number
loading?: boolean
/** 删除对象的类型,决定确认文案 */
kind?: MediaType
}>()
const kind = computed<MediaType>(() => props.kind ?? 'image')
const emit = defineEmits<{
'update:open': [value: boolean]
'confirm': []
@@ -12,7 +18,16 @@ const emit = defineEmits<{
const { t } = useI18n()
const description = computed(() => t('delete.confirmDesc', { count: props.count }))
const description = computed(() => {
switch (kind.value) {
case 'video':
return t('delete.confirmDescVideo', { count: props.count })
case 'file':
return t('delete.confirmDescFile', { count: props.count })
default:
return t('delete.confirmDesc', { count: props.count })
}
})
function close() {
emit('update:open', false)
+107
View File
@@ -0,0 +1,107 @@
<script setup lang="ts">
import type { FileItem } from '~/types/file'
import { formatFileSize as formatSize } from '~/composables/useFileSize'
const props = defineProps<{
item: FileItem
link: string
downloadLink: string
selected?: boolean
selectable?: boolean
}>()
const emit = defineEmits<{
'delete': []
'update:selected': [value: boolean]
}>()
const { t, locale } = useI18n()
const uploadedLabel = computed(() => {
try {
return new Date(props.item.uploadedAt).toLocaleString(locale.value, { hour12: false })
} catch {
return props.item.uploadedAt
}
})
function fileIcon(item: FileItem): string {
const type = item.contentType.toLowerCase()
if (type.startsWith('image/')) return 'i-lucide-image'
if (type.startsWith('video/')) return 'i-lucide-film'
if (type.startsWith('audio/')) return 'i-lucide-music'
if (type.includes('pdf')) return 'i-lucide-file-text'
if (
type.includes('zip')
|| type.includes('rar')
|| type.includes('7z')
|| type.includes('tar')
|| type.includes('gzip')
) {
return 'i-lucide-file-archive'
}
return 'i-lucide-file'
}
</script>
<template>
<div class="flex flex-col overflow-hidden rounded-xl border border-default bg-elevated">
<div class="relative flex aspect-[4/3] shrink-0 items-center justify-center bg-muted">
<UIcon
:name="fileIcon(item)"
class="size-12 text-dimmed"
/>
<div
v-if="selectable"
class="absolute top-2 left-2 z-10"
@click.stop
>
<UCheckbox
:model-value="selected"
@update:model-value="emit('update:selected', $event === true)"
/>
</div>
</div>
<div class="flex flex-1 flex-col gap-1.5 p-3">
<p
class="truncate text-sm font-semibold"
:title="item.originalName"
>
{{ item.originalName }}
</p>
<p class="truncate text-xs text-muted">
{{ uploadedLabel }} · {{ formatSize(item.size) }}
</p>
<TagBadgeList :tags="item.tags" />
</div>
<div class="flex items-center justify-around border-t border-default px-2 py-2">
<CopyButton
icon-only
icon="i-lucide-link"
:label="t('files.copyLink')"
:value="link"
/>
<UButton
size="xs"
variant="ghost"
color="neutral"
icon="i-lucide-download"
:to="downloadLink"
target="_blank"
rel="noopener"
:aria-label="t('files.download')"
/>
<UButton
size="xs"
variant="ghost"
color="neutral"
icon="i-lucide-trash-2"
:aria-label="t('common.delete')"
@click="emit('delete')"
/>
</div>
</div>
</template>
+727
View File
@@ -0,0 +1,727 @@
<script setup lang="ts">
import type { FileItem, FileListResponse } from '~/types/file'
import type { LibraryStatsData } from '~/types/media'
import { formatFileSize } from '~/composables/useFileSize'
import SiteSyncSelectedDialog from '~/components/storage/SiteSyncSelectedDialog.vue'
const props = withDefaults(defineProps<{
/** 文件库(file)与视频库结构一致,保留参数以便复用 */
kind?: 'file' | 'video'
}>(), {
kind: 'file'
})
const { isAuthenticated, handleAuthError } = useAuth()
const toast = useToast()
const { t, locale } = useI18n()
const { items: tagItems, fetchTags, batchUpdateTags } = useTags()
const items = ref<FileItem[]>([])
const total = ref(0)
const page = ref(1)
const pageSize = ref(20)
const totalPages = ref(1)
const loading = ref(false)
const stats = ref<LibraryStatsData | null>(null)
const searchQuery = ref('')
const activeSearch = ref('')
const accessMode = ref<'public' | 'token'>('public')
const apiToken = ref('')
const selectedKeys = ref<Set<string>>(new Set())
const deleteModalOpen = ref(false)
const deleteTargetKeys = ref<string[]>([])
const deleting = ref(false)
const batchTagOpen = ref(false)
const syncOpen = ref(false)
const currentBackend = ref('all')
const currentSource = ref('all')
const backendOptions = ref<Array<{ id: string, name: string }>>([])
const activeTagIds = ref<number[]>([])
const activeTagMode = ref<'or' | 'and'>('or')
const activeUntaggedOnly = ref(false)
const { viewMode } = useLibraryView(props.kind)
const selectedCount = computed(() => selectedKeys.value.size)
const syncKeys = computed(() => [...selectedKeys.value])
const pageText = computed(() => props.kind === 'video'
? {
title: t('library.video.title'),
subtitle: t('library.video.subtitle'),
empty: t('library.video.empty')
}
: {
title: t('library.file.title'),
subtitle: t('library.file.subtitle'),
empty: t('library.file.empty')
})
const allSelected = computed(() =>
items.value.length > 0 && items.value.every(item => selectedKeys.value.has(item.key))
)
const backendItems = computed(() => [
{ label: t('stats.filterStorageAll'), value: 'all' },
...backendOptions.value.map(backend => ({ label: backend.name, value: backend.id }))
])
const sourceItems = computed(() => [
{ label: t('stats.filterSourceAll'), value: 'all' },
{ label: t('stats.sourceWeb'), value: 'web' },
{ label: t('stats.sourceApi'), value: 'api' }
])
function withToken(raw: string | undefined): string {
if (!raw) return ''
if (accessMode.value !== 'token' || !apiToken.value) return raw
const separator = raw.includes('?') ? '&' : '?'
return `${raw}${separator}token=${encodeURIComponent(apiToken.value)}`
}
function fileLink(item: FileItem): string {
return withToken(item.url)
}
function displayName(item: FileItem): string {
return item.originalName
}
function uploadedLabel(item: FileItem): string {
try {
return new Date(item.uploadedAt).toLocaleString(locale.value, { hour12: false })
} catch {
return item.uploadedAt
}
}
function fileIcon(item: FileItem): string {
const type = item.contentType.toLowerCase()
if (type.startsWith('image/')) return 'i-lucide-image'
if (type.startsWith('video/')) return 'i-lucide-film'
if (type.startsWith('audio/')) return 'i-lucide-music'
if (type.includes('pdf')) return 'i-lucide-file-text'
if (
type.includes('zip')
|| type.includes('rar')
|| type.includes('7z')
|| type.includes('tar')
|| type.includes('gzip')
) {
return 'i-lucide-file-archive'
}
return 'i-lucide-file'
}
async function loadApiToken() {
try {
const data = await $fetch<{ apiUploadToken: string }>('/api/user/api-token', {
credentials: 'include'
})
apiToken.value = data.apiUploadToken ?? ''
} catch {
apiToken.value = ''
}
}
async function loadBackendOptions() {
try {
const data = await $fetch<{ backends: Array<{ id: string, name: string }> }>(
'/api/storage-backends',
{ credentials: 'include' }
)
backendOptions.value = data.backends ?? []
} catch (error: unknown) {
handleAuthError(error)
}
}
async function fetchStats() {
if (!isAuthenticated.value) return
try {
stats.value = await $fetch<LibraryStatsData>('/api/library/stats', {
credentials: 'include',
query: { kind: props.kind }
})
} catch (error: unknown) {
handleAuthError(error)
}
}
async function fetchList(targetPage = page.value) {
if (!isAuthenticated.value) return
loading.value = true
try {
const response = await $fetch<FileListResponse>('/api/files', {
credentials: 'include',
query: {
limit: pageSize.value,
page: targetPage,
kind: props.kind,
...(activeSearch.value ? { q: activeSearch.value } : {}),
...(currentBackend.value !== 'all' ? { backendId: currentBackend.value } : {}),
...(currentSource.value !== 'all' ? { uploadSource: currentSource.value } : {}),
...(activeTagIds.value.length ? { tagIds: activeTagIds.value.join(',') } : {}),
...(activeUntaggedOnly.value ? { untagged: '1' } : {}),
...(activeTagMode.value === 'and' ? { tagMode: 'and' } : {})
}
})
items.value = response.items
total.value = response.total
page.value = response.page
totalPages.value = response.totalPages
accessMode.value = response.accessMode
const visible = new Set(response.items.map(item => item.key))
selectedKeys.value = new Set([...selectedKeys.value].filter(key => visible.has(key)))
if (response.accessMode === 'token' && !apiToken.value) {
await loadApiToken()
}
} catch (error: unknown) {
handleAuthError(error)
if (isAuthenticated.value) {
toast.add({ title: t('files.loadFailed'), color: 'error' })
}
} finally {
loading.value = false
}
}
async function refresh() {
await Promise.all([fetchList(1), fetchStats(), fetchTags()])
}
async function submitSearch() {
if (!isAuthenticated.value) return
activeSearch.value = searchQuery.value.trim()
await fetchList(1)
}
async function handleBackendChange(value: string) {
currentBackend.value = value
selectedKeys.value = new Set()
await fetchList(1)
}
async function handleSourceChange(value: string) {
currentSource.value = value
selectedKeys.value = new Set()
await fetchList(1)
}
async function handleTagIdsChange(tagIds: number[]) {
activeTagIds.value = tagIds
selectedKeys.value = new Set()
await fetchList(1)
}
async function handleTagModeChange(tagMode: 'or' | 'and') {
activeTagMode.value = tagMode
await fetchList(1)
}
async function handleUntaggedChange(untaggedOnly: boolean) {
activeUntaggedOnly.value = untaggedOnly
selectedKeys.value = new Set()
await fetchList(1)
}
async function resetFilters() {
searchQuery.value = ''
activeSearch.value = ''
currentBackend.value = 'all'
currentSource.value = 'all'
activeTagIds.value = []
activeTagMode.value = 'or'
activeUntaggedOnly.value = false
selectedKeys.value = new Set()
await fetchList(1)
}
function toggleSelected(key: string, value: boolean) {
const next = new Set(selectedKeys.value)
if (value) next.add(key)
else next.delete(key)
selectedKeys.value = next
}
function toggleSelectAll() {
selectedKeys.value = allSelected.value
? new Set()
: new Set(items.value.map(item => item.key))
}
function clearSelection() {
selectedKeys.value = new Set()
}
function setViewMode(mode: 'grid' | 'list') {
viewMode.value = mode
}
function openBatchTags() {
batchTagOpen.value = true
}
function openSync() {
syncOpen.value = true
}
function requestDelete(item: FileItem) {
if (!isAuthenticated.value) return
deleteTargetKeys.value = [item.key]
deleteModalOpen.value = true
}
function requestBatchDelete() {
if (!isAuthenticated.value || !selectedCount.value) return
deleteTargetKeys.value = [...selectedKeys.value]
deleteModalOpen.value = true
}
async function confirmDelete() {
const keys = [...deleteTargetKeys.value]
if (!keys.length || !isAuthenticated.value) return
deleting.value = true
try {
if (keys.length === 1) {
await $fetch('/api/files', {
method: 'DELETE',
credentials: 'include',
query: { key: keys[0] }
})
} else {
const response = await $fetch<{ deleted: string[], failed: unknown[] }>(
'/api/files/batch-delete',
{ method: 'POST', credentials: 'include', body: { keys } }
)
if (response.failed.length) {
toast.add({
title: t('library.deletePartialFail', { n: response.failed.length }),
color: 'warning'
})
}
}
toast.add({ title: t('library.fileDeleted', { n: keys.length }), color: 'success' })
deleteModalOpen.value = false
deleteTargetKeys.value = []
selectedKeys.value = new Set()
await refresh()
} catch (error: unknown) {
handleAuthError(error)
if (isAuthenticated.value) {
toast.add({ title: t('files.deleteFailed'), color: 'error' })
}
} finally {
deleting.value = false
}
}
async function confirmBatchTags(tagIds: number[], action: 'add' | 'remove') {
const keys = [...selectedKeys.value]
if (!keys.length || !tagIds.length) return
try {
await batchUpdateTags(keys, tagIds, action)
batchTagOpen.value = false
selectedKeys.value = new Set()
toast.add({ title: t('tags.batchSuccess'), color: 'success' })
await Promise.all([fetchList(1), fetchStats(), fetchTags(true)])
} catch (error: unknown) {
handleAuthError(error)
toast.add({ title: t('tags.batchFailed'), color: 'error' })
}
}
async function handlePageChange(targetPage: number) {
selectedKeys.value = new Set()
await fetchList(targetPage)
}
async function handlePageSizeChange(size: number) {
pageSize.value = size
selectedKeys.value = new Set()
await fetchList(1)
}
onMounted(() => {
void loadBackendOptions()
})
defineExpose({ refresh })
</script>
<template>
<div class="space-y-6">
<div class="flex items-start justify-between gap-3">
<div class="min-w-0">
<h1 class="text-2xl font-semibold tracking-tight">
{{ pageText.title }}
</h1>
<p class="text-sm text-muted">
{{ pageText.subtitle }}
</p>
</div>
<UButton
icon="i-lucide-refresh-cw"
variant="ghost"
color="neutral"
size="sm"
class="mt-0.5 shrink-0"
:aria-label="t('common.refresh')"
:loading="loading"
@click="refresh"
/>
</div>
<LibraryStatsPanel
:kind="props.kind"
:stats="stats"
:loading="loading"
/>
<section class="space-y-3">
<div class="flex flex-wrap items-center justify-between gap-x-4 gap-y-2">
<div class="flex items-center gap-2 text-sm">
<UCheckbox
:model-value="allSelected"
:disabled="!items.length"
@update:model-value="toggleSelectAll"
/>
<span class="text-muted">
{{ t('library.selectedCount', { n: selectedCount }) }}
</span>
<UButton
v-if="selectedCount > 0"
variant="link"
color="primary"
size="xs"
:label="t('library.clearSelection')"
@click="clearSelection"
/>
</div>
<div class="flex flex-wrap items-center gap-2">
<UInput
v-model="searchQuery"
icon="i-lucide-search"
:placeholder="t('library.searchPlaceholder')"
size="sm"
class="w-44"
@keydown.enter.prevent="submitSearch"
/>
<USelect
:model-value="currentBackend"
:items="backendItems"
value-key="value"
size="sm"
class="w-32"
:aria-label="t('stats.filterStorageAll')"
@update:model-value="handleBackendChange"
/>
<USelect
:model-value="currentSource"
:items="sourceItems"
value-key="value"
size="sm"
class="w-28"
:aria-label="t('stats.filterSourceAll')"
@update:model-value="handleSourceChange"
/>
<TagFilterSelect
:model-value="activeTagIds"
:tag-mode="activeTagMode"
:untagged-only="activeUntaggedOnly"
:tags="tagItems"
:disabled="loading"
@update:model-value="handleTagIdsChange"
@update:tag-mode="handleTagModeChange"
@update:untagged-only="handleUntaggedChange"
/>
<UButton
icon="i-lucide-search"
size="sm"
:label="t('common.search')"
:loading="loading"
@click="submitSearch"
/>
<UButton
icon="i-lucide-rotate-ccw"
variant="outline"
color="neutral"
size="sm"
:label="t('library.reset')"
@click="resetFilters"
/>
<UButton
v-if="selectedCount > 0"
icon="i-lucide-tags"
variant="outline"
color="primary"
size="sm"
:disabled="!selectedCount || loading"
:label="t('tags.batchAdd')"
@click="openBatchTags"
/>
<UButton
icon="i-lucide-trash-2"
variant="outline"
color="error"
size="sm"
:disabled="!selectedCount || loading"
:label="t('library.batchDelete')"
@click="requestBatchDelete"
/>
<UButton
v-if="selectedCount > 0"
icon="i-lucide-cloud-upload"
variant="outline"
color="neutral"
size="sm"
:disabled="!selectedCount || loading"
:label="t('storage.backup.syncSelectedAction')"
@click="openSync"
/>
<div class="flex gap-0.5 rounded-lg border border-default p-0.5">
<UButton
icon="i-lucide-layout-grid"
size="xs"
:variant="viewMode === 'grid' ? 'solid' : 'ghost'"
:color="viewMode === 'grid' ? 'primary' : 'neutral'"
:aria-label="t('stats.viewGrid')"
@click="setViewMode('grid')"
/>
<UButton
icon="i-lucide-list"
size="xs"
:variant="viewMode === 'list' ? 'solid' : 'ghost'"
:color="viewMode === 'list' ? 'primary' : 'neutral'"
:aria-label="t('stats.viewList')"
@click="setViewMode('list')"
/>
</div>
</div>
</div>
<div
v-if="loading && !items.length"
class="grid gap-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4"
>
<USkeleton
v-for="n in 8"
:key="n"
class="aspect-[4/3] rounded-xl"
/>
</div>
<p
v-else-if="!items.length"
class="rounded-lg border border-default py-10 text-center text-sm text-muted"
>
{{ activeSearch ? t('library.searchEmpty') : pageText.empty }}
</p>
<div
v-else-if="viewMode === 'grid'"
class="grid gap-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4"
>
<FileCard
v-for="item in items"
:key="item.key"
:item="item"
:link="fileLink(item)"
:download-link="fileLink(item)"
selectable
:selected="selectedKeys.has(item.key)"
@update:selected="toggleSelected(item.key, $event)"
@delete="requestDelete(item)"
/>
</div>
<div
v-else
class="overflow-hidden rounded-xl border border-default bg-default"
>
<div class="overflow-x-auto">
<table class="min-w-full text-xs">
<thead class="border-b border-default bg-elevated/40 text-muted">
<tr>
<th class="w-10 px-3 py-2.5 text-left font-medium">
<UCheckbox
:model-value="allSelected"
:aria-label="t('stats.selectAll')"
@update:model-value="toggleSelectAll"
/>
</th>
<th class="min-w-[14rem] px-3 py-2.5 text-left font-medium">
{{ t('stats.colFilename') }}
</th>
<th class="hidden w-24 px-3 py-2.5 text-left font-medium sm:table-cell">
{{ t('stats.colSource') }}
</th>
<th class="w-20 px-3 py-2.5 text-left font-medium">
{{ t('stats.colSize') }}
</th>
<th class="hidden w-28 px-3 py-2.5 text-left font-medium md:table-cell">
{{ t('stats.colType') }}
</th>
<th class="hidden w-40 px-3 py-2.5 text-left font-medium lg:table-cell">
{{ t('stats.colUploadedAt') }}
</th>
<th class="hidden min-w-[9rem] px-3 py-2.5 text-left font-medium md:table-cell">
{{ t('tags.column') }}
</th>
<th class="w-24 px-3 py-2.5 text-right font-medium">
{{ t('stats.colActions') }}
</th>
</tr>
</thead>
<tbody class="divide-y divide-default">
<tr
v-for="item in items"
:key="item.key"
class="transition-colors hover:bg-elevated/50"
:class="selectedKeys.has(item.key) ? 'bg-primary/5' : ''"
>
<td class="px-3 py-2">
<UCheckbox
:model-value="selectedKeys.has(item.key)"
@update:model-value="toggleSelected(item.key, $event === true)"
/>
</td>
<td class="px-3 py-2.5">
<div class="flex min-w-0 items-center gap-3">
<span class="flex size-10 shrink-0 items-center justify-center rounded-md border border-default bg-muted">
<UIcon
:name="fileIcon(item)"
class="size-4 text-dimmed"
/>
</span>
<p
class="min-w-0 flex-1 truncate text-highlighted"
:title="displayName(item)"
>
{{ displayName(item) }}
</p>
</div>
</td>
<td class="hidden px-3 py-2 sm:table-cell">
<span
v-if="item.uploadSource"
class="inline-flex items-center gap-1 rounded-md border border-default px-1.5 py-0.5 text-[11px] leading-4 text-muted"
>
<UIcon
:name="item.uploadSource === 'api' ? 'i-lucide-code-xml' : 'i-lucide-globe'"
class="size-3 shrink-0"
/>
{{ item.uploadSource === 'api' ? t('stats.tagApi') : t('stats.tagWeb') }}
</span>
<span
v-else
class="text-muted"
>—</span>
</td>
<td class="px-3 py-2 tabular-nums text-muted">
{{ formatFileSize(item.size) }}
</td>
<td class="hidden px-3 py-2 md:table-cell">
<span
v-if="item.storage"
class="inline-flex items-center gap-1 rounded-md border border-default px-1.5 py-0.5 text-[11px] leading-4 text-muted"
>
<UIcon
:name="item.storage.type === 'local' ? 'i-lucide-hard-drive' : 'i-lucide-cloud'"
class="size-3 shrink-0"
/>
{{ item.storage.name }}
</span>
<span
v-else
class="text-muted"
>—</span>
</td>
<td class="hidden px-3 py-2 text-muted lg:table-cell">
{{ uploadedLabel(item) }}
</td>
<td class="hidden px-3 py-2 md:table-cell">
<TagBadgeList
:tags="item.tags"
compact
:empty-label="t('tags.unlabeled')"
/>
</td>
<td class="px-3 py-2">
<div class="flex items-center justify-end gap-1">
<CopyButton
icon-only
icon="i-lucide-link"
:label="t('files.copyLink')"
:value="fileLink(item)"
/>
<UButton
size="xs"
variant="ghost"
color="neutral"
icon="i-lucide-download"
:to="fileLink(item)"
target="_blank"
rel="noopener"
:aria-label="t('files.download')"
/>
<UButton
size="xs"
variant="ghost"
color="error"
icon="i-lucide-trash-2"
:aria-label="t('common.delete')"
@click="requestDelete(item)"
/>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<PaginationBar
v-if="!loading && total > 0"
:page="page"
:total-pages="totalPages"
:total="total"
:page-size="pageSize"
unit=""
:loading="loading"
@update:page="handlePageChange"
@update:page-size="handlePageSizeChange"
/>
</section>
<DeleteConfirmModal
v-model:open="deleteModalOpen"
:count="deleteTargetKeys.length"
:loading="deleting"
:kind="props.kind"
@confirm="confirmDelete"
/>
<BatchTagModal
v-model:open="batchTagOpen"
:tags="tagItems"
:selected-count="selectedCount"
@confirm="confirmBatchTags"
/>
<SiteSyncSelectedDialog
v-model:open="syncOpen"
:keys="syncKeys"
scope="file"
/>
</div>
</template>
+100
View File
@@ -0,0 +1,100 @@
<script setup lang="ts">
import type { FileItem } from '~/types/file'
const props = defineProps<{
open: boolean
item: FileItem | null
}>()
const emit = defineEmits<{
'update:open': [value: boolean]
}>()
const { t } = useI18n()
const toast = useToast()
const downloading = ref(false)
watch(() => props.open, (open) => {
if (!open) downloading.value = false
})
async function confirmDownload() {
const item = props.item
if (!item?.hlsKey || downloading.value) return
downloading.value = true
try {
const response = await fetch('/api/files/hls-mp4', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ key: item.key })
})
if (!response.ok) {
const payload = await response.json().catch(() => null) as {
error?: { message?: string }
} | null
throw new Error(payload?.error?.message || t('library.video.downloadMp4Failed'))
}
const blob = await response.blob()
const objectUrl = URL.createObjectURL(blob)
const dot = item.originalName.lastIndexOf('.')
const filename = `${dot > 0 ? item.originalName.slice(0, dot) : item.originalName}.mp4`
const link = document.createElement('a')
link.href = objectUrl
link.download = filename
document.body.appendChild(link)
link.click()
link.remove()
setTimeout(() => URL.revokeObjectURL(objectUrl), 0)
emit('update:open', false)
} catch (error) {
toast.add({
title: error instanceof Error ? error.message : t('library.video.downloadMp4Failed'),
color: 'error'
})
} finally {
downloading.value = false
}
}
</script>
<template>
<UModal
:open="open && !!item"
:title="t('library.video.downloadMp4Title')"
@update:open="emit('update:open', $event)"
>
<template #body>
<p class="text-sm leading-6 text-muted">
{{ t('library.video.downloadMp4Confirm') }}
</p>
<p
v-if="downloading"
class="mt-3 text-sm text-primary"
>
{{ t('library.video.downloadMp4Working') }}
</p>
</template>
<template #footer>
<div class="flex w-full justify-end gap-2">
<UButton
:label="t('common.cancel')"
color="neutral"
variant="outline"
:disabled="downloading"
@click="emit('update:open', false)"
/>
<UButton
:label="t('common.continue')"
color="primary"
:loading="downloading"
@click="confirmDownload"
/>
</div>
</template>
</UModal>
</template>
+375 -19
View File
@@ -1,35 +1,94 @@
<script setup lang="ts">
import type { ImageItem } from '~/types/image'
type CopyFormat = 'url' | 'markdown' | 'html'
import type { CopyFormat } from '~/composables/useUploadPreferences'
import { buildImageBbcode } from '~/utils/image-display'
const props = withDefaults(defineProps<{
image: ImageItem
selected: boolean
deleting?: boolean
selectable?: boolean
showKey?: boolean
showStorage?: boolean
compact?: boolean
gallery?: boolean
allowDelete?: boolean
showTags?: boolean
showTagAction?: boolean
}>(), {
selectable: true,
showKey: true,
compact: false
showStorage: false,
compact: false,
gallery: false,
allowDelete: true,
showTags: false,
showTagAction: false
})
const emit = defineEmits<{
'update:selected': [value: boolean]
'preview': []
'delete': []
'tag-click': [tagId: number]
'untagged-click': []
'edit-tags': []
}>()
const { formatFileSize } = useFileSize()
const { t, locale } = useI18n()
const toast = useToast()
const { copyFormat } = useUploadPreferences()
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(() => [
{ 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.html'), value: 'html' as const },
{ label: t('copy.bbcode'), value: 'bbcode' as const }
])
const uploadedLabel = computed(() => {
@@ -46,6 +105,8 @@ const previewValue = computed(() => {
return props.image.markdown
case 'html':
return props.image.html
case 'bbcode':
return buildImageBbcode(props.image.url)
default:
return props.image.url
}
@@ -57,26 +118,269 @@ const copySuccessTitle = computed(() => {
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 uploadSourceLabel = computed(() => {
switch (props.image.uploadSource) {
case 'web':
return t('stats.tagWeb')
case 'api':
return t('stats.tagApi')
default:
return null
}
})
const uploadSourceIcon = computed(() => {
switch (props.image.uploadSource) {
case 'web':
return 'i-lucide-globe'
case 'api':
return 'i-lucide-code-xml'
default:
return null
}
})
const GALLERY_INLINE_TAG_LIMIT = 3
const galleryTagDisplay = computed(() => {
const tags = props.image.tags ?? []
if (tags.length <= GALLERY_INLINE_TAG_LIMIT) {
return { visible: tags, hidden: [] as typeof tags }
}
return {
visible: tags.slice(0, GALLERY_INLINE_TAG_LIMIT),
hidden: tags.slice(GALLERY_INLINE_TAG_LIMIT)
}
})
const sessionVisibleTags = computed(() => props.image.tags ?? [])
const sessionTagActionLabel = computed(() =>
props.image.tags?.length ? t('tags.addTag') : t('tags.untagged')
)
function toggleSelected(value: boolean | 'indeterminate') {
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>
<template>
<div 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">
<div
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
v-if="!imageError"
:src="image.url"
:key="`${image.key}-${retryCount}`"
:src="previewSrc"
:alt="image.originalName"
loading="lazy"
class="absolute inset-0 size-full object-cover object-center"
@error="imageError = true"
class="size-full cursor-zoom-in object-cover object-center"
role="button"
tabindex="0"
:aria-label="t('image.openPreview')"
@click="emit('preview')"
@keydown.enter="emit('preview')"
@keydown.space.prevent="emit('preview')"
@error="onPreviewError"
>
<div
v-else
class="flex size-full items-center justify-center text-xs text-muted"
>
{{ t('image.previewFailed') }}
</div>
<div
v-if="selectable"
class="absolute top-2 left-2"
@click.stop
>
<UCheckbox
:model-value="selected"
:ui="{ base: 'shadow-none' }"
@update:model-value="toggleSelected"
/>
</div>
</div>
<div class="flex flex-col gap-1.5 p-3">
<p
class="truncate text-sm font-semibold"
:title="image.originalName"
>
{{ image.originalName }}
</p>
<p class="truncate text-xs text-muted">
{{ uploadedLabel }} · {{ formatFileSize(image.size) }}
</p>
<div
v-if="showTags"
class="flex flex-wrap items-center gap-1.5"
>
<template v-if="image.tags?.length">
<TagBadge
v-for="tag in galleryTagDisplay.visible"
:key="tag.id"
:tag="tag"
clickable
@click="emit('tag-click', tag.id)"
/>
<UPopover
v-if="galleryTagDisplay.hidden.length"
:content="{ side: 'bottom', align: 'start' }"
>
<button
type="button"
class="inline-flex rounded-md border border-default px-2 py-0.5 text-xs text-muted transition-colors hover:border-primary/40 hover:text-primary"
:aria-label="t('tags.moreTags', { n: galleryTagDisplay.hidden.length })"
@click.stop
>
+{{ galleryTagDisplay.hidden.length }}
</button>
<template #content>
<div
class="flex max-w-56 flex-wrap gap-1.5 p-2"
@click.stop
>
<TagBadge
v-for="tag in galleryTagDisplay.hidden"
:key="tag.id"
:tag="tag"
clickable
@click="emit('tag-click', tag.id)"
/>
</div>
</template>
</UPopover>
</template>
<button
v-else
type="button"
class="inline-flex rounded-md border border-dashed border-default px-2 py-0.5 text-xs text-muted transition-colors hover:border-primary/40 hover:text-primary"
@click.stop="emit('untagged-click')"
>
{{ t('tags.unlabeled') }}
</button>
</div>
<div
v-if="uploadSourceLabel || (showStorage && image.storage)"
class="flex flex-wrap items-center gap-1.5"
>
<span
v-if="showStorage && image.storage"
class="inline-flex max-w-full items-center gap-1 rounded-md border border-default px-2 py-0.5 text-xs text-muted"
:title="image.storage.name"
>
<UIcon
:name="storageIcon"
class="size-3 shrink-0"
/>
<span class="truncate">{{ image.storage.name }}</span>
</span>
<span
v-if="uploadSourceLabel && uploadSourceIcon"
class="inline-flex max-w-full items-center gap-1 rounded-md border border-default px-2 py-0.5 text-xs text-muted"
>
<UIcon
:name="uploadSourceIcon"
class="size-3 shrink-0"
/>
<span class="truncate">{{ uploadSourceLabel }}</span>
</span>
</div>
</div>
<div class="flex items-center justify-around border-t border-default px-2 py-2 text-muted">
<UButton
icon="i-lucide-link"
variant="ghost"
color="neutral"
size="sm"
:loading="copyingUrl"
:aria-label="t('common.copy')"
@click="copyGalleryUrl"
/>
<UButton
icon="i-lucide-eye"
variant="ghost"
color="neutral"
size="sm"
:aria-label="t('image.openPreview')"
@click="emit('preview')"
/>
<UButton
v-if="allowDelete"
icon="i-lucide-trash-2"
variant="ghost"
color="neutral"
size="sm"
:aria-label="t('common.delete')"
@click="emit('delete')"
/>
</div>
</div>
<div
v-else
class="flex flex-col overflow-hidden rounded-xl border border-default bg-elevated shadow-sm"
>
<div
class="relative aspect-square shrink-0 overflow-hidden bg-muted"
role="button"
tabindex="0"
:aria-label="t('image.openPreview')"
@click="emit('preview')"
@keydown.enter="emit('preview')"
@keydown.space.prevent="emit('preview')"
>
<img
v-if="!imageError"
:key="`${image.key}-${retryCount}`"
:src="previewSrc"
:alt="image.originalName"
loading="lazy"
class="absolute inset-0 size-full cursor-zoom-in object-cover object-center"
@error="onPreviewError"
>
<div
v-else
@@ -87,26 +391,32 @@ function toggleSelected(value: boolean | 'indeterminate') {
<div
v-if="selectable"
class="absolute top-2 left-2"
@click.stop
>
<UCheckbox
:model-value="selected"
@update:model-value="toggleSelected"
/>
</div>
<div class="absolute top-2 right-2">
<div
v-if="allowDelete && showTagAction"
class="absolute top-2 right-2"
@click.stop
>
<UButton
size="xs"
color="error"
variant="solid"
icon="i-lucide-trash-2"
variant="solid"
color="neutral"
size="xs"
class="bg-black/55 text-white hover:bg-black/70"
:aria-label="t('common.delete')"
:loading="deleting"
@click="emit('delete')"
/>
</div>
<div
v-if="image.owner"
class="absolute bottom-2 left-2 max-w-[calc(100%-1rem)]"
class="absolute bottom-2 left-2 max-w-[calc(50%-0.5rem)]"
@click.stop
>
<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"
@@ -119,6 +429,23 @@ function toggleSelected(value: boolean | 'indeterminate') {
<span class="truncate">{{ image.owner.username }}</span>
</span>
</div>
<div
v-if="showStorage && image.storage"
class="absolute bottom-2 max-w-[calc(50%-0.5rem)]"
:class="image.owner ? 'right-2' : 'left-2'"
@click.stop
>
<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"
:title="t('image.storageBackend', { name: image.storage.name })"
>
<UIcon
:name="storageIcon"
class="size-3 shrink-0"
/>
<span class="truncate">{{ image.storage.name }}</span>
</span>
</div>
</div>
<div
@@ -155,6 +482,20 @@ function toggleSelected(value: boolean | 'indeterminate') {
>
{{ image.originalName }}
</p>
<button
v-if="showTagAction && image.tags?.length"
type="button"
class="flex flex-wrap items-center gap-1.5 text-left"
@click.stop="emit('edit-tags')"
>
<TagBadge
v-for="tag in sessionVisibleTags"
:key="tag.id"
:tag="tag"
/>
</button>
<p class="text-xs text-muted">
{{ uploadedLabel }} · {{ formatFileSize(image.size) }}
</p>
@@ -168,7 +509,7 @@ function toggleSelected(value: boolean | 'indeterminate') {
:variant="copyFormat === item.value ? 'solid' : 'ghost'"
:color="copyFormat === item.value ? 'primary' : 'neutral'"
:label="item.label"
@click="() => { copyFormat = item.value }"
@click="selectCopyFormat(item.value)"
/>
</div>
@@ -180,13 +521,28 @@ function toggleSelected(value: boolean | 'indeterminate') {
class="min-w-0 flex-1 font-mono text-xs"
/>
<CopyButton
icon="i-lucide-copy"
:label="t('common.copy')"
:variant="showTagAction ? 'solid' : 'soft'"
:color="showTagAction ? 'primary' : undefined"
:icon="showTagAction ? undefined : 'i-lucide-copy'"
:value="previewValue"
:success-title="copySuccessTitle"
/>
</div>
<button
v-if="showTagAction"
type="button"
class="inline-flex items-center gap-1 self-start text-xs text-muted transition-colors hover:text-primary"
@click.stop="emit('edit-tags')"
>
<UIcon
name="i-lucide-plus"
class="size-3"
/>
{{ sessionTagActionLabel }}
</button>
<p
v-if="showKey"
class="truncate text-xs text-dimmed"
+39 -7
View File
@@ -4,22 +4,46 @@ import type { ImageItem } from '~/types/image'
const props = withDefaults(defineProps<{
items: ImageItem[]
selectedKeys: Set<string>
deletingKeys: Set<string>
selectable?: boolean
showKey?: boolean
showStorage?: boolean
emptyText?: string
dense?: boolean
compact?: boolean
/** 图库:5 列 × 2 行,缩略图略扁 */
gallery?: boolean
allowDelete?: boolean
showTags?: boolean
showTagAction?: boolean
}>(), {
selectable: true,
showKey: true,
showStorage: false,
dense: false,
compact: false
compact: false,
gallery: false,
allowDelete: true,
showTags: false,
showTagAction: false
})
const gridClass = computed(() => {
if (props.gallery) {
return 'grid items-start grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5'
}
if (props.dense) {
return 'grid items-start grid-cols-2 gap-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4'
}
return 'grid items-start grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4'
})
const emit = defineEmits<{
'update:selectedKeys': [value: Set<string>]
'delete': [key: string]
'preview': [image: ImageItem]
'delete': [image: ImageItem]
'tag-click': [tagId: number]
'untagged-click': []
'edit-tags': [image: ImageItem]
}>()
const { t } = useI18n()
@@ -37,20 +61,28 @@ function updateSelection(key: string, selected: boolean) {
<template>
<div
v-if="items.length"
class="grid items-start sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4"
:class="dense ? 'grid-cols-2 gap-3' : 'grid-cols-1 gap-4'"
class="items-start"
:class="gridClass"
>
<ImageCard
v-for="image in items"
:key="image.key"
:image="image"
:selected="selectedKeys.has(image.key)"
:deleting="deletingKeys.has(image.key)"
:selectable="selectable"
:show-key="showKey"
:show-storage="showStorage"
:compact="compact"
:gallery="gallery"
:allow-delete="allowDelete"
:show-tags="showTags"
:show-tag-action="showTagAction"
@update:selected="updateSelection(image.key, $event)"
@delete="emit('delete', image.key)"
@preview="emit('preview', image)"
@delete="emit('delete', image)"
@tag-click="emit('tag-click', $event)"
@untagged-click="emit('untagged-click')"
@edit-tags="emit('edit-tags', image)"
/>
</div>
<div
+469
View File
@@ -0,0 +1,469 @@
<script setup lang="ts">
import type { ImageItem } from '~/types/image'
import type { CopyFormat } from '~/composables/useUploadPreferences'
import { buildImageBbcode, formatImageDimensions } from '~/utils/image-display'
const props = withDefaults(defineProps<{
open: boolean
image: ImageItem | null
showStorage?: boolean
showTags?: boolean
deleting?: boolean
allowDelete?: boolean
}>(), {
showStorage: false,
showTags: false,
allowDelete: true
})
const emit = defineEmits<{
'update:open': [value: boolean]
'delete': []
}>()
const toast = useToast()
const { formatFileSize } = useFileSize()
const { t, locale } = useI18n()
const { copyFormat } = useUploadPreferences()
const naturalWidth = ref<number | null>(null)
const naturalHeight = ref<number | null>(null)
const dimensionsLoaded = ref(false)
const copyingUrl = ref(false)
const embedFormat = ref<CopyFormat>('url')
const embedFormatItems = computed(() => [
{ label: t('copy.url'), value: 'url' as const },
{ label: t('copy.markdown'), value: 'markdown' as const },
{ label: t('copy.html'), value: 'html' as const },
{ label: t('copy.bbcode'), value: 'bbcode' as const }
])
const uploadedLabel = computed(() => {
if (!props.image) return '—'
try {
const date = new Date(props.image.uploadedAt)
const y = date.getFullYear()
const m = String(date.getMonth() + 1).padStart(2, '0')
const d = String(date.getDate()).padStart(2, '0')
const h = String(date.getHours()).padStart(2, '0')
const min = String(date.getMinutes()).padStart(2, '0')
const s = String(date.getSeconds()).padStart(2, '0')
return `${y}/${m}/${d} ${h}:${min}:${s}`
} catch {
return props.image.uploadedAt
}
})
const sizeLabel = computed(() => {
if (!props.image) return '—'
return t('image.previewSizeDetail', {
size: formatFileSize(props.image.size),
bytes: props.image.size.toLocaleString(locale.value)
})
})
const bbcodeValue = computed(() => {
if (!props.image) return ''
return buildImageBbcode(props.image.url)
})
const previewValue = computed(() => {
if (!props.image) return ''
switch (embedFormat.value) {
case 'markdown':
return props.image.markdown
case 'html':
return props.image.html
case 'bbcode':
return bbcodeValue.value
default:
return props.image.url
}
})
const copySuccessTitle = computed(() => {
switch (embedFormat.value) {
case 'markdown':
return t('copy.copiedMarkdown')
case 'html':
return t('copy.copiedHtml')
case 'bbcode':
return t('copy.copiedBbcode')
default:
return t('copy.copiedUrl')
}
})
const storageIcon = computed(() =>
props.image?.storage?.type === 'local' ? 'i-lucide-hard-drive' : 'i-lucide-cloud'
)
const uploadSourceTag = computed(() => {
switch (props.image?.uploadSource) {
case 'web':
return t('stats.tagWeb')
case 'api':
return t('stats.tagApi')
default:
return null
}
})
const uploadSourceIcon = computed(() => {
switch (props.image?.uploadSource) {
case 'web':
return 'i-lucide-globe'
case 'api':
return 'i-lucide-code-xml'
default:
return null
}
})
const dimensionsLabel = computed(() => {
const formatted = formatImageDimensions(naturalWidth.value, naturalHeight.value)
if (formatted) return formatted
return dimensionsLoaded.value ? '—' : t('image.previewDimensionsUnknown')
})
watch(() => props.image?.key, () => {
naturalWidth.value = null
naturalHeight.value = null
dimensionsLoaded.value = false
})
watch(() => props.open, (open) => {
if (open) {
embedFormat.value = copyFormat.value
}
})
function selectEmbedFormat(value: CopyFormat) {
embedFormat.value = value
copyFormat.value = value
}
async function copyText(text: string, successTitle: string) {
if (!text) return false
try {
if (typeof navigator !== 'undefined' && navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(text)
toast.add({ title: successTitle, color: 'success' })
return true
}
} catch {
// fallback below
}
const textarea = document.createElement('textarea')
textarea.value = text
textarea.setAttribute('readonly', '')
textarea.style.position = 'fixed'
textarea.style.left = '-9999px'
document.body.appendChild(textarea)
textarea.select()
let ok = false
try {
ok = document.execCommand('copy')
} catch {
ok = false
} finally {
document.body.removeChild(textarea)
}
if (ok) {
toast.add({ title: successTitle, color: 'success' })
} else {
toast.add({ title: t('copy.failedManual'), color: 'error' })
}
return ok
}
async function copyLink() {
if (!props.image?.url || copyingUrl.value) return
copyingUrl.value = true
try {
await copyText(props.image.url, t('copy.copiedUrl'))
} finally {
copyingUrl.value = false
}
}
function openInNewTab() {
if (!props.image?.url) return
window.open(props.image.url, '_blank', 'noopener,noreferrer')
}
function downloadOriginal() {
if (!props.image?.url) return
const link = document.createElement('a')
link.href = props.image.url
link.download = props.image.originalName || 'image'
link.rel = 'noopener'
link.click()
}
function requestDelete() {
emit('delete')
}
function onImageLoad(event: Event) {
const img = event.target as HTMLImageElement
naturalWidth.value = img.naturalWidth
naturalHeight.value = img.naturalHeight
dimensionsLoaded.value = true
}
</script>
<template>
<UModal
:open="open && !!image"
:title="t('image.previewTitle')"
:description="t('image.previewSubtitle')"
:ui="{ content: 'max-w-5xl' }"
@update:open="emit('update:open', $event)"
>
<template
v-if="image"
#body
>
<div class="grid gap-6 lg:grid-cols-[minmax(0,1fr)_minmax(0,1.15fr)] lg:items-start">
<div class="preview-checkerboard flex items-center justify-center overflow-hidden rounded-xl border border-default p-4">
<img
:src="image.url"
:alt="image.originalName"
class="max-h-[min(60vh,24rem)] w-full object-contain"
@load="onImageLoad"
>
</div>
<div class="flex min-w-0 flex-col gap-5">
<dl class="divide-y divide-default text-sm">
<div class="flex gap-4 py-2.5 first:pt-0">
<dt class="w-20 shrink-0 text-muted">
{{ t('image.previewOriginalName') }}
</dt>
<dd class="min-w-0 flex-1 break-all font-medium">
{{ image.originalName }}
</dd>
</div>
<div class="flex gap-4 py-2.5">
<dt class="w-20 shrink-0 text-muted">
{{ t('image.previewUploadedAt') }}
</dt>
<dd class="min-w-0 flex-1">
{{ uploadedLabel }}
</dd>
</div>
<div class="flex gap-4 py-2.5">
<dt class="w-20 shrink-0 text-muted">
{{ t('image.previewSize') }}
</dt>
<dd class="min-w-0 flex-1">
{{ sizeLabel }}
</dd>
</div>
<div class="flex gap-4 py-2.5">
<dt class="w-20 shrink-0 text-muted">
{{ t('image.previewDimensions') }}
</dt>
<dd class="min-w-0 flex-1">
{{ dimensionsLabel }}
</dd>
</div>
<div class="flex gap-4 py-2.5">
<dt class="w-20 shrink-0 text-muted">
{{ t('image.previewContentType') }}
</dt>
<dd class="min-w-0 flex-1">
{{ image.contentType }}
</dd>
</div>
<div
v-if="showStorage && image.storage"
class="flex gap-4 py-2.5"
>
<dt class="w-20 shrink-0 text-muted">
{{ t('image.previewStorage') }}
</dt>
<dd class="min-w-0 flex-1">
<span class="inline-flex items-center gap-1.5">
<UIcon
:name="storageIcon"
class="size-3.5 shrink-0 text-muted"
/>
{{ image.storage.name }}
</span>
</dd>
</div>
<div
v-if="showTags"
class="flex gap-4 py-2.5"
>
<dt class="w-20 shrink-0 text-muted">
{{ t('tags.column') }}
</dt>
<dd class="min-w-0 flex-1">
<div
v-if="image.tags?.length"
class="flex flex-wrap gap-1.5"
>
<TagBadge
v-for="tag in image.tags"
:key="tag.id"
:tag="tag"
/>
</div>
<span
v-else
class="inline-flex rounded-md border border-dashed border-default px-2 py-0.5 text-xs text-muted"
>
{{ t('tags.unlabeled') }}
</span>
</dd>
</div>
<div
v-if="uploadSourceTag"
class="flex gap-4 py-2.5"
>
<dt class="w-20 shrink-0 text-muted">
{{ t('image.previewUploadSource') }}
</dt>
<dd class="min-w-0 flex-1">
<span
v-if="uploadSourceIcon"
class="inline-flex items-center gap-1 rounded-md border border-default px-2 py-0.5 text-xs text-muted"
>
<UIcon
:name="uploadSourceIcon"
class="size-3 shrink-0"
/>
{{ uploadSourceTag }}
</span>
</dd>
</div>
<div
v-if="image.owner"
class="flex gap-4 py-2.5"
>
<dt class="w-20 shrink-0 text-muted">
{{ t('image.previewOwner') }}
</dt>
<dd class="min-w-0 flex-1">
{{ image.owner.username }}
</dd>
</div>
<div class="flex gap-4 py-2.5 last:pb-0">
<dt class="w-20 shrink-0 text-muted">
{{ t('image.previewKey') }}
</dt>
<dd class="flex min-w-0 flex-1 items-start gap-2">
<span class="min-w-0 flex-1 break-all font-mono text-xs text-dimmed">
{{ image.key }}
</span>
<CopyButton
icon="i-lucide-copy"
icon-only
variant="ghost"
color="neutral"
class="shrink-0"
:label="t('common.copy')"
:value="image.key"
:success-title="t('copy.copied')"
/>
</dd>
</div>
</dl>
<div class="border-t border-default pt-4">
<p class="mb-2.5 text-sm font-medium">
{{ t('image.previewLinks') }}
</p>
<div class="flex gap-1 rounded-lg border border-default bg-default p-0.5">
<UButton
v-for="item in embedFormatItems"
:key="item.value"
size="xs"
class="flex-1 justify-center"
:variant="embedFormat === item.value ? 'solid' : 'ghost'"
:color="embedFormat === item.value ? 'primary' : 'neutral'"
:label="item.label"
@click="selectEmbedFormat(item.value)"
/>
</div>
<div class="mt-2 flex items-center gap-2">
<UInput
:model-value="previewValue"
readonly
size="sm"
class="min-w-0 flex-1 font-mono text-xs"
/>
<CopyButton
:label="t('common.copy')"
:value="previewValue"
:success-title="copySuccessTitle"
/>
</div>
</div>
</div>
</div>
</template>
<template
v-if="image"
#footer
>
<div class="flex w-full flex-wrap justify-end gap-1.5">
<UButton
:label="t('image.previewOpen')"
icon="i-lucide-external-link"
color="neutral"
variant="outline"
size="sm"
@click="openInNewTab"
/>
<UButton
:label="t('image.previewDownload')"
icon="i-lucide-download"
color="neutral"
variant="outline"
size="sm"
@click="downloadOriginal"
/>
<UButton
:label="t('image.previewCopyLink')"
icon="i-lucide-link"
color="primary"
size="sm"
:loading="copyingUrl"
@click="copyLink"
/>
<UButton
v-if="allowDelete"
:label="t('common.delete')"
icon="i-lucide-trash-2"
color="error"
variant="outline"
size="sm"
:loading="deleting"
@click="requestDelete"
/>
</div>
</template>
</UModal>
</template>
<style scoped>
.preview-checkerboard {
background-color: var(--ui-bg-muted);
background-image:
linear-gradient(45deg, color-mix(in oklab, var(--ui-border) 28%, transparent) 25%, transparent 25%),
linear-gradient(-45deg, color-mix(in oklab, var(--ui-border) 28%, transparent) 25%, transparent 25%),
linear-gradient(45deg, transparent 75%, color-mix(in oklab, var(--ui-border) 28%, transparent) 75%),
linear-gradient(-45deg, transparent 75%, color-mix(in oklab, var(--ui-border) 28%, transparent) 75%);
background-size: 14px 14px;
background-position: 0 0, 0 7px, 7px -7px, -7px 0;
}
</style>
+102
View File
@@ -0,0 +1,102 @@
<script setup lang="ts">
import type { ImageItem, ImageTag } from '~/types/image'
const props = defineProps<{
open: boolean
image: ImageItem | null
tags: ImageTag[]
}>()
const emit = defineEmits<{
'update:open': [value: boolean]
'saved': [tags: ImageTag[]]
}>()
const { t } = useI18n()
const toast = useToast()
const { setImageTags, fetchTags } = useTags()
const { recordRecent, loadRecentIds } = useRecentTags()
const selectedTagIds = ref<number[]>([])
const saving = ref(false)
watch(() => props.open, (open) => {
if (open) {
selectedTagIds.value = props.image?.tags?.map(tag => tag.id) ?? []
loadRecentIds()
void fetchTags(true)
}
})
async function save() {
if (!props.image || saving.value) return
saving.value = true
try {
const tags = await setImageTags(props.image.key, selectedTagIds.value)
recordRecent(selectedTagIds.value)
emit('saved', tags)
emit('update:open', false)
toast.add({ title: t('tags.saved'), color: 'success' })
} catch (error: unknown) {
const message = error instanceof Error ? error.message : t('tags.saveFailed')
toast.add({ title: message, color: 'error' })
} finally {
saving.value = false
}
}
function onTagCreated(tag: ImageTag) {
if (!selectedTagIds.value.includes(tag.id)) {
selectedTagIds.value = [...selectedTagIds.value, tag.id]
}
}
</script>
<template>
<UModal
:open="open"
:title="t('tags.manageImageTitle')"
:description="image?.originalName"
:ui="{ content: 'max-w-md' }"
@update:open="emit('update:open', $event)"
>
<template #body>
<TagAssignPanel
v-model="selectedTagIds"
:tags="tags"
:disabled="saving"
@created="onTagCreated"
/>
</template>
<template #footer>
<div class="flex w-full flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<p class="flex items-center gap-1.5 text-xs text-muted">
<UIcon
name="i-lucide-info"
class="size-3.5 shrink-0"
/>
{{ t('tags.classifyHint') }}
</p>
<div class="flex justify-end gap-2">
<UButton
variant="outline"
color="neutral"
:disabled="saving"
@click="emit('update:open', false)"
>
{{ t('common.cancel') }}
</UButton>
<UButton
color="primary"
:loading="saving"
@click="save"
>
{{ t('common.save') }}
</UButton>
</div>
</div>
</template>
</UModal>
</template>
+236
View File
@@ -0,0 +1,236 @@
<script setup lang="ts">
import type { LibraryStatsData, MediaType } from '~/types/media'
const props = withDefaults(defineProps<{
stats: LibraryStatsData | null
/** 统计口径:图片 / 视频 / 文件各自独立 */
kind?: MediaType
loading?: boolean
}>(), {
kind: 'image'
})
const { t } = useI18n()
const { formatFileSize } = useFileSize()
const SOURCE_COLORS = ['var(--ui-primary)', '#10b981'] as const
const totalHint = computed(() => {
switch (props.kind) {
case 'video':
return t('library.statsTotalHintVideo')
case 'file':
return t('library.statsTotalHintFile')
default:
return t('library.statsTotalHintImage')
}
})
const sourceItems = computed(() => {
const bySource = props.stats?.bySource
if (!bySource) return []
return [
{ key: 'web', label: t('stats.sourceWeb'), count: bySource.web, color: SOURCE_COLORS[0] },
{ key: 'api', label: t('stats.sourceApi'), count: bySource.api, color: SOURCE_COLORS[1] }
]
})
const sourceTotal = computed(() =>
sourceItems.value.reduce((sum, item) => sum + item.count, 0)
)
function sourcePercent(count: number) {
if (!sourceTotal.value) return 0
return Math.round((count / sourceTotal.value) * 100)
}
function formatTrend(current: number, previous: number) {
if (previous === 0) {
if (current === 0) {
return { text: t('stats.trendFlat'), tone: 'muted' as const }
}
return { text: t('stats.trendUp', { p: 100 }), tone: 'up' as const }
}
const change = Math.round(((current - previous) / previous) * 100)
if (change > 0) {
return { text: t('stats.trendUp', { p: change }), tone: 'up' as const }
}
if (change < 0) {
return { text: t('stats.trendDown', { p: Math.abs(change) }), tone: 'down' as const }
}
return { text: t('stats.trendFlat'), tone: 'muted' as const }
}
const todayTrend = computed(() =>
formatTrend(props.stats?.uploadToday ?? 0, props.stats?.uploadYesterday ?? 0)
)
const monthTrend = computed(() =>
formatTrend(props.stats?.uploadMonth ?? 0, props.stats?.uploadLastMonth ?? 0)
)
const usageLabel = computed(() => {
const usage = props.stats?.storageUsage
if (!usage) return '—'
const used = formatFileSize(usage.usedBytes)
if (usage.totalBytes) {
return `${used} / ${formatFileSize(usage.totalBytes)}`
}
return used
})
const usagePercent = computed(() => {
const percent = props.stats?.storageUsage.percent
if (percent == null) return null
return Math.min(percent, 100)
})
const usageBarWidth = computed(() => {
const usage = props.stats?.storageUsage
if (!usage?.totalBytes || usagePercent.value == null) return 0
return Math.max(usagePercent.value, usage.usedBytes > 0 ? 4 : 0)
})
function displayValue(value: number | undefined) {
if (props.loading) return '—'
return value ?? '—'
}
</script>
<template>
<section class="grid grid-cols-2 gap-3 sm:grid-cols-3 xl:grid-cols-6">
<div class="rounded-xl border border-default bg-elevated p-4">
<p class="text-xs text-muted">
{{ t('stats.uploadToday') }}
</p>
<p class="mt-2 text-2xl font-semibold tabular-nums text-highlighted">
{{ displayValue(stats?.uploadToday) }}
</p>
<p
class="mt-2 text-xs"
:class="{
'text-primary': todayTrend.tone === 'up',
'text-muted': todayTrend.tone === 'muted',
'text-orange-600 dark:text-orange-400': todayTrend.tone === 'down'
}"
>
{{ t('stats.vsYesterday') }} {{ todayTrend.text }}
</p>
</div>
<div class="rounded-xl border border-default bg-elevated p-4">
<p class="text-xs text-muted">
{{ t('stats.uploadMonth') }}
</p>
<p class="mt-2 text-2xl font-semibold tabular-nums text-highlighted">
{{ displayValue(stats?.uploadMonth) }}
</p>
<p
class="mt-2 text-xs"
:class="{
'text-primary': monthTrend.tone === 'up',
'text-muted': monthTrend.tone === 'muted',
'text-orange-600 dark:text-orange-400': monthTrend.tone === 'down'
}"
>
{{ t('stats.vsLastMonth') }} {{ monthTrend.text }}
</p>
</div>
<div class="rounded-xl border border-default bg-elevated p-4">
<p class="text-xs text-muted">
{{ t('stats.uploadTotal') }}
</p>
<p class="mt-2 text-2xl font-semibold tabular-nums text-highlighted">
{{ displayValue(stats?.uploadTotal) }}
</p>
<p class="mt-2 text-xs text-muted">
{{ totalHint }}
</p>
</div>
<div class="rounded-xl border border-default bg-elevated p-4">
<p class="text-xs text-muted">
{{ t('stats.storedCount') }}
</p>
<p class="mt-2 text-2xl font-semibold tabular-nums text-highlighted">
{{ displayValue(stats?.storedCount) }}
</p>
<p class="mt-2 text-xs text-muted">
{{ stats ? t('stats.totalOccupied', { size: formatFileSize(stats.uploadBytesTotal) }) : '—' }}
</p>
</div>
<div class="rounded-xl border border-default bg-elevated p-4">
<p class="text-xs text-muted">
{{ t('stats.storageUsage') }}
</p>
<p class="mt-2 text-sm font-semibold tabular-nums text-highlighted sm:text-base">
{{ usageLabel }}
</p>
<div
v-if="stats?.storageUsage.totalBytes"
class="mt-3 space-y-1"
>
<div class="h-1.5 overflow-hidden rounded-full bg-muted/50">
<div
class="h-full rounded-full bg-primary transition-all"
:style="{ width: `${usageBarWidth}%` }"
/>
</div>
<p class="text-right text-xs tabular-nums text-muted">
{{ usagePercent }}%
</p>
</div>
<p
v-else
class="mt-2 text-xs text-muted"
>
{{ t('stats.noQuota') }}
</p>
</div>
<div class="col-span-2 rounded-xl border border-default bg-elevated p-4 sm:col-span-1">
<p class="text-xs text-muted">
{{ t('stats.sourceDistribution') }}
</p>
<div
v-if="!sourceTotal"
class="mt-4 text-xs text-muted"
>
{{ t('stats.noData') }}
</div>
<ul
v-else
class="mt-3 space-y-3"
>
<li
v-for="item in sourceItems"
:key="item.key"
>
<div class="flex items-center justify-between gap-2 text-xs">
<span class="flex min-w-0 items-center gap-1.5">
<span
class="size-2 shrink-0 rounded-full"
:style="{ background: item.color }"
/>
<span class="truncate">{{ item.label }}</span>
</span>
<span class="shrink-0 tabular-nums text-muted">
{{ item.count }} ({{ sourcePercent(item.count) }}%)
</span>
</div>
<div class="mt-1.5 h-1.5 overflow-hidden rounded-full bg-muted/40">
<div
class="h-full rounded-full transition-[width]"
:style="{
width: `${sourcePercent(item.count)}%`,
backgroundColor: item.color
}"
/>
</div>
</li>
</ul>
</div>
</section>
</template>
+90
View File
@@ -0,0 +1,90 @@
<script setup lang="ts">
import type {
LoginVerificationMethod,
LoginVerificationPayload
} from '~/types/auth'
const props = defineProps<{
method: LoginVerificationMethod
turnstileSiteKey?: string
capApiEndpoint?: string
}>()
const emit = defineEmits<{
verified: [payload: LoginVerificationPayload]
cleared: []
}>()
const sliderRef = ref<{ reset: () => Promise<void> } | null>(null)
const turnstileRef = ref<{ reset: () => void } | null>(null)
const capRef = ref<{ reset: () => void } | null>(null)
function onSliderVerified(payload: { id: string, positionPercent: number }) {
emit('verified', {
method: 'slider',
captchaId: payload.id,
captchaPosition: payload.positionPercent
})
}
function onTurnstileVerified(payload: { turnstileToken: string }) {
emit('verified', {
method: 'turnstile',
turnstileToken: payload.turnstileToken
})
}
function onCapVerified(payload: { capToken: string }) {
emit('verified', {
method: 'cap',
capToken: payload.capToken
})
}
function onCleared() {
emit('cleared')
}
async function reset() {
emit('cleared')
if (props.method === 'slider') {
await sliderRef.value?.reset()
return
}
if (props.method === 'turnstile') {
turnstileRef.value?.reset()
return
}
capRef.value?.reset()
}
defineExpose({ reset })
</script>
<template>
<SliderCaptcha
v-if="method === 'slider'"
ref="sliderRef"
@verified="onSliderVerified"
/>
<TurnstileCaptcha
v-else-if="method === 'turnstile' && turnstileSiteKey"
ref="turnstileRef"
:site-key="turnstileSiteKey"
@verified="onTurnstileVerified"
@cleared="onCleared"
/>
<CapCaptcha
v-else-if="method === 'cap' && capApiEndpoint"
ref="capRef"
:api-endpoint="capApiEndpoint"
@verified="onCapVerified"
@cleared="onCleared"
/>
<p
v-else
class="rounded-lg border border-warning/25 bg-warning/5 px-3 py-2 text-xs text-warning"
>
{{ $t('auth.verificationMisconfigured') }}
</p>
</template>
+287
View File
@@ -0,0 +1,287 @@
<script setup lang="ts">
import type { FileItem } from '~/types/file'
import type { CopyFormat, UploadMediaKind } from '~/composables/useUploadPreferences'
const props = defineProps<{
item: FileItem
kind: UploadMediaKind
/** 已按访问模式拼接好的直链 */
link: string
/** 封面缩略图地址 */
thumbLink?: string
}>()
const emit = defineEmits<{
delete: []
}>()
const { formatFileSize } = useFileSize()
const { t, locale } = useI18n()
const transcodeFailureLabel = useTranscodeFailureLabel()
const { copyFormat } = useUploadPreferences(props.kind)
const copyFormatItems = computed(() => [
{ label: t('copy.url'), value: 'url' as const },
{ label: t('copy.markdown'), value: 'markdown' as const },
{ label: t('copy.html'), value: 'html' as const },
{ label: t('copy.bbcode'), value: 'bbcode' as const }
])
const uploadedLabel = computed(() => {
try {
return new Date(props.item.uploadedAt).toLocaleString(locale.value, { hour12: false })
} catch {
return props.item.uploadedAt
}
})
const previewValue = computed(() => {
switch (copyFormat.value) {
case 'markdown':
return `[${props.item.originalName}](${props.link})`
case 'html':
return `<a href="${props.link}">${props.item.originalName}</a>`
case 'bbcode':
return `[url=${props.link}]${props.item.originalName}[/url]`
default:
return props.link
}
})
const copySuccessTitle = computed(() => {
switch (copyFormat.value) {
case 'markdown':
return t('copy.copiedMarkdown')
case 'html':
return t('copy.copiedHtml')
case 'bbcode':
return t('copy.copiedBbcode')
default:
return t('copy.copiedUrl')
}
})
function selectCopyFormat(value: CopyFormat) {
copyFormat.value = value
}
const pending = computed(() => props.item.transcodeStatus === 'pending')
const running = computed(() => props.item.transcodeStatus === 'running')
/** 转码中源文件只是临时文件,链接还不能对外使用 */
const transcoding = computed(() => pending.value || running.value)
/** 转码完成后按 HLS 播放列表展示 */
const displayName = computed(() => {
if (!props.item.hlsUrl) return props.item.originalName
const dot = props.item.originalName.lastIndexOf('.')
const base = dot > 0 ? props.item.originalName.slice(0, dot) : props.item.originalName
return `${base}.m3u8`
})
/** 该视频使用的转码档位(等待/转码中时展示) */
const presetLabel = computed(() => {
const [name, value] = (props.item.transcodePreset ?? '').split(':')
switch (name) {
case 'copy':
return t('preferences.presetCopy')
case 'veryfast':
return t('preferences.presetSpeed')
case 'medium':
return t('preferences.presetBalanced')
case 'slow':
return t('preferences.presetQuality')
case 'compress':
return value
? `${t('preferences.presetCompress')} ${value}%`
: t('preferences.presetCompress')
default:
return null
}
})
function formatDuration(seconds: number): string {
const days = Math.floor(seconds / 86_400)
const hours = Math.floor((seconds % 86_400) / 3_600)
const minutes = Math.floor((seconds % 3_600) / 60)
const secs = seconds % 60
if (days > 0) return `${days} d ${hours} h ${minutes} min ${secs} s`
if (hours > 0) return `${hours} h ${minutes} min ${secs} s`
if (minutes > 0) return `${minutes} min ${secs} s`
return `${secs} s`
}
/** 转码进行中:已耗时(轮询每秒刷新一次) */
const elapsedLabel = computed(() => {
if (!running.value || !props.item.transcodeStartedAt) return null
const started = new Date(props.item.transcodeStartedAt).getTime()
if (!Number.isFinite(started)) return null
return formatDuration(Math.max(0, Math.floor((Date.now() - started) / 1000)))
})
/** 转码结束(成功或失败):固定显示总耗时 */
const totalLabel = computed(() => {
if (running.value || typeof props.item.transcodeDurationMs !== 'number') return null
return formatDuration(Math.floor(props.item.transcodeDurationMs / 1000))
})
</script>
<template>
<div class="flex flex-col overflow-hidden rounded-xl border border-default bg-elevated shadow-sm">
<div class="relative aspect-video shrink-0 overflow-hidden bg-muted">
<VideoThumbnail
v-if="kind === 'video'"
:src="thumbLink"
:alt="item.originalName"
/>
<div
v-else
class="flex size-full items-center justify-center"
>
<UIcon
name="i-lucide-file"
class="size-10 text-dimmed"
/>
</div>
<div class="absolute top-2 right-2">
<UButton
icon="i-lucide-trash-2"
variant="solid"
color="neutral"
size="xs"
class="bg-black/55 text-white hover:bg-black/70"
:aria-label="t('common.delete')"
@click="emit('delete')"
/>
</div>
</div>
<div class="flex flex-1 flex-col gap-2 p-3">
<p
class="truncate text-sm font-medium"
:title="displayName"
>
{{ displayName }}
</p>
<p class="flex items-center gap-2 text-xs text-muted">
<span class="truncate">{{ uploadedLabel }} · {{ formatFileSize(item.size) }}</span>
<UBadge
v-if="pending"
color="neutral"
variant="subtle"
size="xs"
class="shrink-0"
>
{{ t('library.transcodePending') }}
</UBadge>
<UBadge
v-else-if="running"
color="primary"
variant="subtle"
size="xs"
class="shrink-0"
>
{{ t('library.transcoding') }} {{ item.transcodeProgress ?? 0 }}%
</UBadge>
<UBadge
v-else-if="item.transcodeStatus === 'failed'"
color="error"
variant="subtle"
size="xs"
class="max-w-48 shrink-0 truncate"
:title="transcodeFailureLabel(item.transcodeError)"
>
{{ transcodeFailureLabel(item.transcodeError) }}
</UBadge>
<UBadge
v-else-if="item.hlsUrl"
color="success"
variant="subtle"
size="xs"
class="shrink-0"
>
m3u8
</UBadge>
</p>
<TagBadgeList
:tags="item.tags"
compact
/>
<UProgress
v-if="running"
:model-value="item.transcodeProgress ?? 0"
size="xs"
color="primary"
/>
<div
v-if="!transcoding"
class="flex gap-1 rounded-lg border border-default p-0.5"
>
<UButton
v-for="option in copyFormatItems"
:key="option.value"
size="xs"
class="flex-1 justify-center"
:variant="copyFormat === option.value ? 'solid' : 'ghost'"
:color="copyFormat === option.value ? 'primary' : 'neutral'"
:label="option.label"
@click="selectCopyFormat(option.value)"
/>
</div>
<div
v-if="!transcoding"
class="flex items-center gap-1"
>
<UInput
:model-value="previewValue"
readonly
size="xs"
class="min-w-0 flex-1 font-mono text-xs"
/>
<CopyButton
:label="t('common.copy')"
variant="solid"
color="primary"
:value="previewValue"
:success-title="copySuccessTitle"
/>
</div>
<p
class="flex flex-wrap items-center gap-1.5"
>
<UBadge
v-if="presetLabel"
color="primary"
variant="subtle"
size="xs"
>
{{ presetLabel }}
</UBadge>
<UBadge
v-if="elapsedLabel"
color="success"
variant="subtle"
size="xs"
class="!text-white"
>
{{ t('library.transcodeElapsed') }} {{ elapsedLabel }}
</UBadge>
<UBadge
v-if="totalLabel"
color="success"
variant="subtle"
size="xs"
>
{{ t('library.transcodeTotal') }} {{ totalLabel }}
</UBadge>
</p>
</div>
</div>
</template>
+176
View File
@@ -0,0 +1,176 @@
<script setup lang="ts">
import type { FileItem } from '~/types/file'
const props = defineProps<{
/** 视频床或文件床的上传页 */
kind: 'video' | 'file'
}>()
const { t } = useI18n()
const toast = useToast()
const {
policy: uploadPolicy,
load: loadUploadPolicy
} = useUploadPolicy()
const {
uploading,
progressItems,
sessionItems,
linkUrl,
thumbLink,
loadActiveTranscodes,
uploadFiles
} = useMediaUpload(props.kind)
const { items: tagItems, fetchTags, batchUpdateTags } = useTags()
const { recordRecent } = useRecentTags()
const batchTagOpen = ref(false)
onMounted(() => {
void Promise.all([
loadActiveTranscodes(),
loadUploadPolicy().catch(() => {}),
fetchTags()
])
})
const text = computed(() => {
const allowedVideos = uploadPolicy.value?.allowedVideoExtensions ?? []
const forbiddenFiles = uploadPolicy.value?.forbiddenFileExtensions ?? []
const videoMaxFileSizeMb = uploadPolicy.value?.videoMaxFileSizeMb ?? 500
const fileMaxFileSizeMb = uploadPolicy.value?.fileMaxFileSizeMb ?? 100
return props.kind === 'video'
? {
uploadTitle: t('library.video.uploadTitle'),
uploadHint: t('library.video.uploadHint'),
uploadFormats: allowedVideos.length
? t('library.video.uploadFormatsAllowed', {
formats: allowedVideos.map(item => item.toUpperCase()).join(' / '),
size: videoMaxFileSizeMb
})
: t('library.video.uploadFormats', { size: videoMaxFileSizeMb })
}
: {
uploadTitle: t('library.file.uploadTitle'),
uploadHint: t('library.file.uploadHint'),
uploadFormats: forbiddenFiles.length
? t('library.file.uploadFormatsBlocked', {
formats: forbiddenFiles.map(item => item.toUpperCase()).join(' / '),
size: fileMaxFileSizeMb
})
: t('library.file.uploadFormatsUnrestricted', { size: fileMaxFileSizeMb })
}
})
const accept = computed(() => {
if (props.kind !== 'video') return undefined
const extensions = uploadPolicy.value?.allowedVideoExtensions ?? []
if (!extensions.length) return undefined
return extensions
.map(extension => `.${extension}`)
.join(',')
})
const showProgress = computed(() =>
uploading.value
|| progressItems.value.some(
item =>
item.status === 'pending'
|| item.status === 'uploading'
|| item.status === 'error'
)
)
async function removeItem(item: FileItem) {
try {
await $fetch('/api/files', {
method: 'DELETE',
credentials: 'include',
query: { key: item.key }
})
sessionItems.value = sessionItems.value.filter(entry => entry.key !== item.key)
toast.add({ title: t('files.deleted'), color: 'success' })
} catch {
toast.add({ title: t('files.deleteFailed'), color: 'error' })
}
}
async function confirmBatchTags(tagIds: number[]) {
const keys = sessionItems.value.map(item => item.key)
if (!keys.length || !tagIds.length) return
try {
await batchUpdateTags(keys, tagIds, 'add')
batchTagOpen.value = false
recordRecent(tagIds)
await fetchTags(true)
const addedTags = tagItems.value.filter(tag => tagIds.includes(tag.id))
sessionItems.value = sessionItems.value.map((item) => {
const merged = new Map((item.tags ?? []).map(tag => [tag.id, tag]))
for (const tag of addedTags) merged.set(tag.id, tag)
return { ...item, tags: [...merged.values()] }
})
toast.add({ title: t('tags.batchSuccess'), color: 'success' })
} catch {
toast.add({ title: t('tags.batchFailed'), color: 'error' })
}
}
</script>
<template>
<div class="space-y-6">
<UploadFlipCard
:kind="kind"
:title="text.uploadTitle"
:hint="text.uploadHint"
:formats="text.uploadFormats"
:accept="accept"
:disabled="uploading"
@upload="uploadFiles"
/>
<UploadResult
v-if="showProgress"
:items="progressItems"
/>
<section
v-if="sessionItems.length"
class="space-y-4"
>
<div class="flex w-full items-center justify-between gap-2">
<h2 class="text-lg font-medium">
{{ t('upload.sessionTitle') }}
<span class="ml-1 text-sm font-normal text-muted">({{ sessionItems.length }})</span>
</h2>
<UButton
icon="i-lucide-tags"
variant="outline"
color="primary"
size="sm"
:label="t('tags.batchTagAll')"
@click="() => { batchTagOpen = true }"
/>
</div>
<div class="grid gap-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
<MediaSessionCard
v-for="item in sessionItems"
:key="item.key"
:item="item"
:kind="kind"
:link="linkUrl(item)"
:thumb-link="thumbLink(item)"
@delete="removeItem(item)"
/>
</div>
</section>
<BatchTagModal
v-model:open="batchTagOpen"
:tags="tagItems"
:selected-count="sessionItems.length"
add-only
@confirm="confirmBatchTags"
/>
</div>
</template>
+1 -20
View File
@@ -1,7 +1,7 @@
<script setup lang="ts">
const open = defineModel<boolean>('open', { default: false })
const { logout, isAdmin } = useAuth()
const { logout } = useAuth()
const { t, locale } = useI18n()
const colorMode = useColorMode()
const {
@@ -154,25 +154,6 @@ function toggleOpen() {
/>
</NuxtLink>
<NuxtLink
v-if="isAdmin"
to="/settings"
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-settings"
class="size-4 opacity-70"
/>
{{ t('nav.systemSettings') }}
</span>
<UIcon
name="i-lucide-chevron-right"
class="size-4 text-muted"
/>
</NuxtLink>
<button
type="button"
class="flex w-full items-center justify-between rounded-lg border border-default px-3.5 py-2.5 text-sm text-error transition-colors hover:bg-error/5"
+222 -54
View File
@@ -1,82 +1,250 @@
<script setup lang="ts">
const props = defineProps<{
type PageItem = number | 'ellipsis'
const props = withDefaults(defineProps<{
page: number
totalPages: number
total: number
unit?: string
loading?: boolean
}>()
pageSize?: number
pageSizeOptions?: number[]
}>(), {
loading: false,
pageSizeOptions: () => [10, 20, 30, 50]
})
const emit = defineEmits<{
'update:page': [page: number]
'update:pageSize': [pageSize: number]
}>()
const { t } = useI18n()
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) {
if (target < 1 || target > props.totalPages || target === props.page || props.loading) {
return
}
if (target < 1 || target > props.totalPages || props.loading) return
if (target === props.page) return
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>
<template>
<div
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
v-if="totalPages > 1"
class="text-sm text-muted"
>
{{ t('pagination.pageOf', { page, totalPages, total, unit: unitLabel }) }}
</p>
<p
v-else
class="text-sm text-muted"
>
{{ t('pagination.totalOnly', { total, unit: unitLabel }) }}
</p>
<div class="flex flex-col gap-3">
<div class="flex items-center justify-between gap-2 sm:hidden">
<p class="text-sm text-muted">
{{ t('pagination.totalCount', { total, unit: unitLabel }) }}
</p>
<USelect
v-if="pageSize !== undefined"
:model-value="pageSize"
:items="pageSizeItems"
value-key="value"
size="sm"
class="w-[6.5rem] shrink-0"
:aria-label="t('pagination.pageSize')"
:disabled="loading"
@update:model-value="onPageSizeChange"
/>
</div>
<div
v-if="totalPages > 1"
class="flex items-center gap-1"
>
<UButton
icon="i-lucide-chevrons-left"
variant="outline"
size="sm"
:aria-label="t('pagination.first')"
:disabled="page <= 1 || loading"
@click="goTo(1)"
/>
<UButton
icon="i-lucide-chevron-left"
variant="outline"
size="sm"
:aria-label="t('pagination.prev')"
:disabled="page <= 1 || loading"
@click="goTo(page - 1)"
/>
<UButton
icon="i-lucide-chevron-right"
variant="outline"
size="sm"
:aria-label="t('pagination.next')"
:disabled="page >= totalPages || loading"
@click="goTo(page + 1)"
/>
<UButton
icon="i-lucide-chevrons-right"
variant="outline"
size="sm"
:aria-label="t('pagination.last')"
:disabled="page >= totalPages || loading"
@click="goTo(totalPages)"
/>
<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>
<div class="flex flex-col gap-3 sm:flex-row sm:flex-wrap sm:items-center sm:justify-end sm:gap-3">
<USelect
v-if="pageSize !== undefined"
:model-value="pageSize"
:items="pageSizeItems"
value-key="value"
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')"
:disabled="page <= 1 || loading"
@click="goTo(1)"
>
<UIcon
name="i-lucide-chevrons-left"
class="size-4"
/>
</button>
<button
type="button"
:class="[navButtonClass, 'size-9 sm:size-8']"
:aria-label="t('pagination.prev')"
:disabled="page <= 1 || loading"
@click="goTo(page - 1)"
>
<UIcon
name="i-lucide-chevron-left"
class="size-4"
/>
</button>
<p
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')"
:disabled="page >= totalPages || loading"
@click="goTo(page + 1)"
>
<UIcon
name="i-lucide-chevron-right"
class="size-4"
/>
</button>
<button
type="button"
:class="[navButtonClass, 'hidden size-8 sm:inline-flex']"
:aria-label="t('pagination.last')"
:disabled="page >= totalPages || loading"
@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>
</template>
+261
View File
@@ -0,0 +1,261 @@
<script setup lang="ts">
const emit = defineEmits<{
verified: [payload: { id: string, positionPercent: number }]
}>()
const { t } = useI18n()
const THUMB_SIZE = 40
const TRACK_INSET = 4
const trackRef = ref<HTMLElement | null>(null)
const challengeId = ref('')
const trackSvg = ref('')
const sliderPercent = ref(0)
const verified = ref(false)
const loading = ref(false)
const dragging = ref(false)
const shake = ref(false)
const confirming = ref(false)
const trackWidth = ref(0)
function updateTrackWidth() {
trackWidth.value = trackRef.value?.clientWidth ?? 0
}
function percentToPx(percent: number) {
const max = Math.max(trackWidth.value - THUMB_SIZE - TRACK_INSET * 2, 0)
return TRACK_INSET + (percent / 100) * max
}
const thumbLeft = computed(() => percentToPx(sliderPercent.value))
const isActive = computed(() => dragging.value || sliderPercent.value > 0.5)
const fillWidth = computed(() => `${Math.max(thumbLeft.value + THUMB_SIZE - TRACK_INSET, 0)}px`)
const trackSvgUrl = computed(() =>
trackSvg.value
? `data:image/svg+xml;charset=utf-8,${encodeURIComponent(trackSvg.value)}`
: ''
)
const hintOpacity = computed(() => {
if (verified.value) return 0
if (!isActive.value) return 1
return Math.max(0, 1 - sliderPercent.value / 40)
})
const successLabelLeft = computed(() => `${thumbLeft.value + THUMB_SIZE + 8}px`)
async function loadChallenge() {
loading.value = true
verified.value = false
dragging.value = false
shake.value = false
sliderPercent.value = 0
trackSvg.value = ''
try {
const data = await $fetch<{ id: string, svg: string }>('/api/auth/captcha')
challengeId.value = data.id
trackSvg.value = data.svg
} finally {
loading.value = false
await nextTick()
updateTrackWidth()
}
}
function setSliderFromClientX(clientX: number) {
if (!trackRef.value || verified.value || loading.value || confirming.value) return
const rect = trackRef.value.getBoundingClientRect()
const max = Math.max(rect.width - THUMB_SIZE - TRACK_INSET * 2, 1)
const x = Math.min(
Math.max(clientX - rect.left - THUMB_SIZE / 2 - TRACK_INSET, 0),
max
)
sliderPercent.value = (x / max) * 100
}
async function tryConfirm() {
if (verified.value || !challengeId.value || confirming.value) return
confirming.value = true
try {
const position = Math.round(sliderPercent.value)
await $fetch('/api/auth/captcha/confirm', {
method: 'POST',
body: {
id: challengeId.value,
captchaPosition: position
}
})
verified.value = true
emit('verified', {
id: challengeId.value,
positionPercent: position
})
} catch {
shake.value = true
sliderPercent.value = 0
window.setTimeout(() => {
shake.value = false
}, 450)
} finally {
confirming.value = false
}
}
function onThumbPointerDown(event: PointerEvent) {
if (verified.value || loading.value || confirming.value) return
dragging.value = true
updateTrackWidth()
;(event.currentTarget as HTMLElement).setPointerCapture(event.pointerId)
}
function onThumbPointerMove(event: PointerEvent) {
if (!dragging.value) return
setSliderFromClientX(event.clientX)
}
function onThumbPointerUp() {
if (!dragging.value) return
dragging.value = false
if (!verified.value) {
void tryConfirm()
}
}
onMounted(() => {
void loadChallenge()
})
let resizeObserver: ResizeObserver | undefined
watch(trackRef, (el, _, onCleanup) => {
resizeObserver?.disconnect()
resizeObserver = undefined
if (!el) return
updateTrackWidth()
if (typeof ResizeObserver === 'undefined') return
resizeObserver = new ResizeObserver(updateTrackWidth)
resizeObserver.observe(el)
onCleanup(() => {
resizeObserver?.disconnect()
resizeObserver = undefined
})
})
defineExpose({ reset: loadChallenge })
</script>
<template>
<div
ref="trackRef"
class="captcha-track relative h-12 select-none overflow-hidden rounded-lg border transition-[background-color,border-color] duration-300"
:class="[
verified
? 'border-primary/35 bg-primary/10'
: 'border-default bg-muted/20',
loading ? 'opacity-70' : '',
shake ? 'captcha-shake' : ''
]"
>
<div
v-if="loading"
class="absolute inset-0 flex items-center justify-center"
>
<UIcon
name="i-lucide-loader-2"
class="size-5 animate-spin text-muted"
/>
</div>
<template v-else>
<img
v-if="trackSvgUrl"
:src="trackSvgUrl"
alt=""
aria-hidden="true"
class="pointer-events-none absolute inset-0 size-full object-fill transition-opacity duration-300"
:class="verified ? 'opacity-0' : 'opacity-100'"
draggable="false"
>
<div
v-if="isActive && !verified"
class="pointer-events-none absolute inset-y-1 left-1 rounded-md bg-primary/20 transition-[width] duration-75"
:style="{ width: fillWidth }"
/>
<p
v-if="!verified"
class="pointer-events-none absolute inset-0 flex items-center justify-center pl-12 text-sm text-muted transition-opacity duration-150"
:style="{ opacity: hintOpacity }"
>
{{ t('auth.captchaHint') }}
</p>
<p
v-if="verified"
class="pointer-events-none absolute inset-y-0 flex items-center pr-3 text-sm font-medium text-primary"
:style="{ left: successLabelLeft }"
>
{{ t('auth.captchaSuccess') }}
</p>
<div
class="absolute top-1/2 -translate-y-1/2 touch-none rounded-md border transition-[left,background-color,border-color,color,box-shadow,transform] duration-150"
:class="[
verified
? 'z-20 border-primary bg-primary text-white shadow-sm shadow-primary/25'
: isActive
? 'z-10 border-primary/45 bg-elevated text-primary shadow-md'
: 'z-10 border-default/90 bg-elevated text-muted shadow-sm',
dragging ? 'scale-[1.02] cursor-grabbing' : verified ? 'cursor-default' : 'cursor-grab'
]"
:style="{
left: `${thumbLeft}px`,
width: `${THUMB_SIZE}px`,
height: `${THUMB_SIZE}px`
}"
@pointerdown="onThumbPointerDown"
@pointermove="onThumbPointerMove"
@pointerup="onThumbPointerUp"
@pointercancel="onThumbPointerUp"
>
<div class="flex size-full items-center justify-center">
<UIcon
v-if="confirming"
name="i-lucide-loader-2"
class="size-4 animate-spin"
:class="verified ? 'text-white' : isActive ? 'text-primary' : 'text-muted'"
/>
<UIcon
v-else
:name="verified ? 'i-lucide-check' : 'i-lucide-chevrons-right'"
class="size-4"
/>
</div>
</div>
</template>
</div>
</template>
<style scoped>
.captcha-shake {
animation: captcha-shake 0.4s ease;
}
@keyframes captcha-shake {
0%,
100% {
transform: translateX(0);
}
25%,
75% {
transform: translateX(-4px);
}
50% {
transform: translateX(4px);
}
}
</style>
-111
View File
@@ -1,111 +0,0 @@
<script setup lang="ts">
export interface DonutSegment {
label: string
value: number
color: string
}
const props = defineProps<{
segments: DonutSegment[]
size?: number
}>()
const total = computed(() =>
props.segments.reduce((sum, item) => sum + item.value, 0)
)
const gradient = computed(() => {
if (!total.value) {
return 'conic-gradient(var(--ui-bg-muted) 0% 100%)'
}
let offset = 0
const stops = props.segments
.filter(item => item.value > 0)
.map((item) => {
const start = offset
const percent = (item.value / total.value) * 100
offset += percent
return `${item.color} ${start}% ${offset}%`
})
if (!stops.length) {
return 'conic-gradient(var(--ui-bg-muted) 0% 100%)'
}
return `conic-gradient(${stops.join(', ')})`
})
function percent(value: number) {
if (!total.value) return 0
return Math.round((value / total.value) * 100)
}
function formatCount(value: number) {
if (value >= 100000000) {
return `${(value / 100000000).toFixed(value >= 1000000000 ? 0 : 1)}亿`
}
if (value >= 10000) {
return `${(value / 10000).toFixed(value >= 100000 ? 0 : 1)}万`
}
return String(value)
}
const centerText = computed(() => formatCount(total.value))
const centerTextClass = computed(() => {
const raw = String(total.value)
if (raw.length >= 7) return 'text-[9px] font-semibold leading-none'
if (raw.length >= 5) return 'text-xs font-semibold leading-none'
if (raw.length >= 4) return 'text-sm font-semibold leading-none'
return 'text-lg font-semibold leading-none'
})
</script>
<template>
<div class="flex items-center gap-4">
<div
class="relative shrink-0 rounded-full"
:style="{
width: `${size ?? 88}px`,
height: `${size ?? 88}px`,
background: gradient
}"
>
<div
class="absolute inset-[20%] flex flex-col items-center justify-center rounded-full bg-elevated px-1 text-center"
>
<p
class="max-w-full truncate tabular-nums"
:class="centerTextClass"
:title="String(total)"
>
{{ centerText }}
</p>
<p class="mt-0.5 text-[10px] text-muted">
次
</p>
</div>
</div>
<ul class="min-w-0 flex-1 space-y-1.5">
<li
v-for="item in segments"
:key="item.label"
class="flex items-center gap-2 text-xs"
>
<span
class="size-2 shrink-0 rounded-full"
:style="{ background: item.color }"
/>
<span class="min-w-0 flex-1 truncate">{{ item.label }}</span>
<span
class="shrink-0 tabular-nums text-muted"
:title="`${item.value}`"
>
{{ formatCount(item.value) }} ({{ percent(item.value) }}%)
</span>
</li>
</ul>
</div>
</template>
+296
View File
@@ -0,0 +1,296 @@
<script setup lang="ts">
import type { ImageTag } from '~/types/image'
import { pickRandomUnusedTagColor, tagPillStyle } from '~/utils/tag-colors'
const props = withDefaults(defineProps<{
modelValue: number[]
tags: ImageTag[]
disabled?: boolean
allowCreate?: boolean
}>(), {
disabled: false,
allowCreate: true
})
const emit = defineEmits<{
'update:modelValue': [value: number[]]
'created': [tag: ImageTag]
}>()
const COMMON_LIMIT = 10
const { t } = useI18n()
const toast = useToast()
const { createTag } = useTags()
const { recentIds, loadRecentIds } = useRecentTags()
const query = ref('')
const creating = ref(false)
onMounted(() => {
loadRecentIds()
})
const isSearching = computed(() => query.value.trim().length > 0)
const selectedTags = computed(() =>
props.tags.filter(tag => props.modelValue.includes(tag.id))
)
const commonTags = computed(() =>
[...props.tags]
.sort((a, b) => (b.imageCount ?? 0) - (a.imageCount ?? 0))
.slice(0, COMMON_LIMIT)
)
const recentTags = computed(() =>
recentIds.value
.map(id => props.tags.find(tag => tag.id === id))
.filter((tag): tag is ImageTag => Boolean(tag))
)
const existingTags = computed(() => {
const q = query.value.trim().toLowerCase()
if (!q) return props.tags
return props.tags.filter(tag => tag.name.toLowerCase().includes(q))
})
const showCreateOption = computed(() => {
if (!props.allowCreate) return false
const q = query.value.trim()
if (!q) return false
return !props.tags.some(tag => tag.name === q)
})
const allExistingSelected = computed(() =>
existingTags.value.length > 0
&& existingTags.value.every(tag => props.modelValue.includes(tag.id))
)
function isSelected(tagId: number) {
return props.modelValue.includes(tagId)
}
function toggleTag(tagId: number) {
if (props.disabled) return
const next = new Set(props.modelValue)
if (next.has(tagId)) next.delete(tagId)
else next.add(tagId)
emit('update:modelValue', [...next])
}
function removeTag(tagId: number) {
if (props.disabled) return
emit('update:modelValue', props.modelValue.filter(id => id !== tagId))
}
function clearSelected() {
if (props.disabled) return
emit('update:modelValue', [])
}
function toggleSelectAllExisting() {
if (props.disabled) return
const visibleIds = existingTags.value.map(tag => tag.id)
if (allExistingSelected.value) {
emit('update:modelValue', props.modelValue.filter(id => !visibleIds.includes(id)))
return
}
emit('update:modelValue', [...new Set([...props.modelValue, ...visibleIds])])
}
async function createFromQuery() {
const name = query.value.trim()
if (!name || creating.value) return
creating.value = true
try {
const color = pickRandomUnusedTagColor(props.tags.map(tag => tag.color))
const tag = await createTag(name, color)
emit('created', tag)
emit('update:modelValue', [...new Set([...props.modelValue, tag.id])])
query.value = ''
toast.add({ title: t('tags.created', { name: tag.name }), color: 'success' })
} catch (error: unknown) {
const message = error instanceof Error ? error.message : t('tags.createFailed')
toast.add({ title: message, color: 'error' })
} finally {
creating.value = false
}
}
defineExpose({ reloadRecent: loadRecentIds })
</script>
<template>
<div class="space-y-4">
<UInput
v-model="query"
size="md"
icon="i-lucide-search"
:placeholder="t('tags.searchOrCreate')"
:disabled="disabled"
@keydown.enter.prevent="showCreateOption ? createFromQuery() : undefined"
/>
<button
v-if="showCreateOption"
type="button"
class="flex w-full items-center gap-1.5 rounded-lg px-1 py-1 text-left text-sm text-primary hover:bg-primary/5"
:disabled="disabled || creating"
@click="createFromQuery"
>
<UIcon
name="i-lucide-plus"
class="size-4 shrink-0"
/>
{{ t('tags.createNamed', { name: query.trim() }) }}
</button>
<div
v-if="isSearching && existingTags.length"
class="space-y-2"
>
<div class="flex items-center justify-between gap-2">
<p class="text-sm font-medium text-highlighted">
{{ t('tags.existingLabel', { n: existingTags.length }) }}
</p>
<button
type="button"
class="text-xs text-muted transition-colors hover:text-highlighted"
:disabled="disabled"
@click="toggleSelectAllExisting"
>
{{ allExistingSelected ? t('tags.deselectAll') : t('tags.selectAll') }}
</button>
</div>
<div class="grid grid-cols-3 gap-2">
<button
v-for="tag in existingTags"
:key="`existing-${tag.id}`"
type="button"
class="inline-flex min-w-0 items-center justify-center gap-1 rounded-full border px-2.5 py-1.5 text-xs font-medium transition-opacity hover:opacity-90"
:style="tagPillStyle(tag.color, isSelected(tag.id))"
:disabled="disabled"
@click="toggleTag(tag.id)"
>
<UIcon
v-if="isSelected(tag.id)"
name="i-lucide-check"
class="size-3 shrink-0"
/>
<span class="truncate">{{ tag.name }}</span>
<UIcon
v-if="isSelected(tag.id)"
name="i-lucide-x"
class="size-3 shrink-0 opacity-80"
/>
</button>
</div>
</div>
<template v-else>
<div
v-if="selectedTags.length"
class="space-y-2"
>
<div class="flex items-center justify-between gap-2">
<p class="text-sm font-medium text-highlighted">
{{ t('tags.selectedLabel', { n: selectedTags.length }) }}
</p>
<button
type="button"
class="text-xs text-muted transition-colors hover:text-highlighted"
:disabled="disabled"
@click="clearSelected"
>
{{ t('tags.clearSelected') }}
</button>
</div>
<div class="flex flex-wrap gap-2">
<button
v-for="tag in selectedTags"
:key="tag.id"
type="button"
class="inline-flex max-w-full items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs font-medium transition-opacity hover:opacity-90"
:style="tagPillStyle(tag.color, true)"
:disabled="disabled"
@click="removeTag(tag.id)"
>
<UIcon
name="i-lucide-check"
class="size-3 shrink-0"
/>
<span class="truncate">{{ tag.name }}</span>
<UIcon
name="i-lucide-x"
class="size-3 shrink-0 opacity-80"
/>
</button>
</div>
</div>
<div
v-if="commonTags.length"
class="space-y-2"
>
<p class="text-sm font-medium text-highlighted">
{{ t('tags.commonTags') }}
</p>
<div class="flex flex-wrap gap-2">
<button
v-for="tag in commonTags"
:key="tag.id"
type="button"
class="inline-flex max-w-full items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs font-medium transition-opacity hover:opacity-90"
:style="tagPillStyle(tag.color, isSelected(tag.id))"
:disabled="disabled"
@click="toggleTag(tag.id)"
>
<UIcon
v-if="isSelected(tag.id)"
name="i-lucide-check"
class="size-3 shrink-0"
/>
<span class="truncate">{{ tag.name }}</span>
</button>
</div>
</div>
<div
v-if="recentTags.length"
class="space-y-2"
>
<p class="text-sm font-medium text-highlighted">
{{ t('tags.recentTags') }}
</p>
<div class="flex flex-wrap gap-2">
<button
v-for="tag in recentTags"
:key="`recent-${tag.id}`"
type="button"
class="inline-flex max-w-full items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs font-medium transition-opacity hover:opacity-90"
:style="tagPillStyle(tag.color, isSelected(tag.id))"
:disabled="disabled"
@click="toggleTag(tag.id)"
>
<UIcon
v-if="isSelected(tag.id)"
name="i-lucide-check"
class="size-3 shrink-0"
/>
<span class="truncate">{{ tag.name }}</span>
</button>
</div>
</div>
</template>
<p
v-if="!tags.length && !showCreateOption"
class="py-6 text-center text-sm text-muted"
>
{{ t('tags.emptyHint') }}
</p>
</div>
</template>
+61
View File
@@ -0,0 +1,61 @@
<script setup lang="ts">
import type { ImageTag } from '~/types/image'
import { defaultTagColor, resolveTagHexColor, tagPillStyle } from '~/utils/tag-colors'
const props = withDefaults(defineProps<{
tag: ImageTag
removable?: boolean
clickable?: boolean
size?: 'xs' | 'sm'
}>(), {
removable: false,
clickable: false,
size: 'xs'
})
const emit = defineEmits<{
remove: []
click: []
}>()
const displayColor = computed(() =>
resolveTagHexColor(props.tag.color, defaultTagColor(props.tag.id))
)
const pillStyle = computed(() => tagPillStyle(displayColor.value))
</script>
<template>
<span
class="inline-flex max-w-full items-center gap-1 rounded-md border px-2 py-0.5 font-medium"
:class="[
size === 'sm' ? 'text-sm' : 'text-xs',
clickable ? 'cursor-pointer hover:opacity-90' : ''
]"
:style="pillStyle"
:role="clickable ? 'button' : undefined"
:tabindex="clickable ? 0 : undefined"
@click="clickable ? emit('click') : undefined"
@keydown.enter.prevent="clickable ? emit('click') : undefined"
@keydown.space.prevent="clickable ? emit('click') : undefined"
>
<span
class="size-1.5 shrink-0 rounded-full"
:style="{ backgroundColor: displayColor }"
aria-hidden="true"
/>
<span class="truncate">{{ tag.name }}</span>
<button
v-if="removable"
type="button"
class="inline-flex shrink-0 rounded p-0.5 opacity-70 hover:opacity-100"
:aria-label="`Remove ${tag.name}`"
@click.stop="emit('remove')"
>
<UIcon
name="i-lucide-x"
class="size-3"
/>
</button>
</span>
</template>
+70
View File
@@ -0,0 +1,70 @@
<script setup lang="ts">
import type { ImageTag } from '~/types/image'
const props = withDefaults(defineProps<{
tags?: ImageTag[]
max?: number
compact?: boolean
emptyLabel?: string
}>(), {
tags: () => [],
max: 3,
compact: false,
emptyLabel: ''
})
const emit = defineEmits<{
'tag-click': [tagId: number]
}>()
const { t } = useI18n()
const visibleTags = computed(() => props.tags.slice(0, props.max))
const hiddenTags = computed(() => props.tags.slice(props.max))
</script>
<template>
<div
v-if="tags.length"
class="flex flex-wrap items-center"
:class="compact ? 'gap-1' : 'gap-1.5'"
>
<TagBadge
v-for="tag in visibleTags"
:key="tag.id"
:tag="tag"
@click="emit('tag-click', tag.id)"
/>
<UPopover
v-if="hiddenTags.length"
:content="{ side: 'bottom', align: 'start' }"
>
<button
type="button"
class="inline-flex rounded-md border border-default text-muted transition-colors hover:border-primary/40 hover:text-primary"
:class="compact ? 'px-1.5 py-0.5 text-[11px]' : 'px-2 py-0.5 text-xs'"
:aria-label="t('tags.moreTags', { n: hiddenTags.length })"
>
+{{ hiddenTags.length }}
</button>
<template #content>
<div
class="flex max-w-56 flex-wrap gap-1.5 p-2"
>
<TagBadge
v-for="tag in hiddenTags"
:key="tag.id"
:tag="tag"
@click="emit('tag-click', tag.id)"
/>
</div>
</template>
</UPopover>
</div>
<span
v-else-if="emptyLabel"
class="text-muted"
>
{{ emptyLabel }}
</span>
</template>
+127
View File
@@ -0,0 +1,127 @@
<script setup lang="ts">
import { TAG_COLOR_PRESETS, visibleTagColorPresets } from '~/utils/tag-colors'
const props = withDefaults(defineProps<{
modelValue: string
/** 已被其他标签占用的预设色,选择器中优先展示未占用颜色 */
usedColors?: (string | null | undefined)[]
/** 固定展示的备选色(新增 8 个 / 编辑 3 个) */
candidates?: string[]
/** 紧凑模式:不显示标题 */
compact?: boolean
}>(), {
compact: false
})
const emit = defineEmits<{
'update:modelValue': [value: string]
}>()
const { t } = useI18n()
const colorInput = ref<HTMLInputElement | null>(null)
const normalizedValue = computed(() => normalizeHex(props.modelValue))
const presetColors = computed(() => {
if (props.candidates?.length) {
const seen = new Set<string>()
const items: string[] = []
for (const color of props.candidates) {
const lower = color.toLowerCase()
if (!seen.has(lower)) {
seen.add(lower)
items.push(lower)
}
}
return items
}
if (!props.usedColors?.length) return TAG_COLOR_PRESETS
return visibleTagColorPresets(props.usedColors, props.modelValue)
})
const isPreset = computed(() =>
TAG_COLOR_PRESETS.some(color => color.toLowerCase() === normalizedValue.value.toLowerCase())
)
const isCustomSelected = computed(() => {
if (props.candidates?.length) {
return !presetColors.value.includes(normalizedValue.value)
}
return !isPreset.value
})
function normalizeHex(value: string) {
const trimmed = value.trim()
if (/^#[0-9A-Fa-f]{6}$/.test(trimmed)) return trimmed.toLowerCase()
return '#22c55e'
}
function selectColor(color: string) {
emit('update:modelValue', color.toLowerCase())
}
function onNativeColorInput(event: Event) {
const value = (event.target as HTMLInputElement).value
if (value) emit('update:modelValue', value.toLowerCase())
}
function openNativePicker() {
colorInput.value?.click()
}
</script>
<template>
<div :class="compact ? '' : 'space-y-2'">
<p
v-if="!compact"
class="text-xs font-medium text-muted"
>
{{ t('tags.pickColor') }}
</p>
<div class="flex flex-wrap items-center gap-2">
<button
v-for="color in presetColors"
:key="color"
type="button"
class="size-7 shrink-0 rounded-full border-2 transition-all hover:scale-110"
:class="normalizedValue === color
? 'border-highlighted ring-2 ring-primary/25 scale-110'
: 'border-transparent'"
:style="{ backgroundColor: color }"
:aria-label="color"
:title="color"
@click="selectColor(color)"
/>
<button
type="button"
class="relative size-7 shrink-0 overflow-hidden rounded-full border-2 transition-all hover:scale-110"
:class="isCustomSelected
? 'border-highlighted ring-2 ring-primary/25 scale-110'
: 'border-dashed border-default'"
:style="isCustomSelected ? { backgroundColor: normalizedValue } : undefined"
:aria-label="t('tags.customColor')"
:title="t('tags.customColor')"
@click="openNativePicker"
>
<span
v-if="!isCustomSelected"
class="flex size-full items-center justify-center bg-muted/40"
>
<UIcon
name="i-lucide-pipette"
class="size-3.5 text-muted"
/>
</span>
<input
ref="colorInput"
type="color"
class="pointer-events-none absolute inset-0 size-full opacity-0"
:value="normalizedValue"
@input="onNativeColorInput"
>
</button>
</div>
</div>
</template>
+158
View File
@@ -0,0 +1,158 @@
<script setup lang="ts">
import type { ImageTag } from '~/types/image'
const props = withDefaults(defineProps<{
modelValue: number[]
tagMode: 'or' | 'and'
untaggedOnly: boolean
tags: ImageTag[]
disabled?: boolean
}>(), {
disabled: false
})
const emit = defineEmits<{
'update:modelValue': [value: number[]]
'update:tagMode': [value: 'or' | 'and']
'update:untaggedOnly': [value: boolean]
'manage': []
}>()
const { t } = useI18n()
const selectedTags = computed(() =>
props.tags.filter(tag => props.modelValue.includes(tag.id))
)
const buttonLabel = computed(() => {
if (props.untaggedOnly) return t('tags.untagged')
if (!selectedTags.value.length) return t('tags.filterAll')
if (selectedTags.value.length === 1) return selectedTags.value[0]!.name
return t('tags.filterSelected', { n: selectedTags.value.length })
})
const tagItems = computed(() =>
props.tags.map(tag => ({
label: tag.name,
value: tag.id
}))
)
const selectValue = computed({
get: () => (props.untaggedOnly ? [] : props.modelValue),
set: (value: number[]) => {
emit('update:untaggedOnly', false)
emit('update:modelValue', value)
}
})
function findTag(tagId: number) {
return props.tags.find(tag => tag.id === tagId)
}
function selectUntagged() {
emit('update:modelValue', [])
emit('update:untaggedOnly', true)
}
function clearFilter() {
emit('update:modelValue', [])
emit('update:untaggedOnly', false)
}
</script>
<template>
<USelect
v-model="selectValue"
multiple
:items="tagItems"
class="w-28 shrink-0"
size="sm"
:disabled="disabled"
value-key="value"
:content="{ align: 'start', sideOffset: 4 }"
:ui="{
content: 'w-72 max-w-[calc(100vw-2rem)]',
viewport: 'max-h-44 overflow-y-auto [scrollbar-width:none] [-ms-overflow-style:none] [&::-webkit-scrollbar]:hidden',
item: 'text-xs',
itemLabel: 'text-xs',
itemTrailingIcon: 'size-3.5 text-primary',
placeholder: 'truncate pointer-events-none text-highlighted',
value: 'truncate pointer-events-none text-highlighted'
}"
>
<template #default>
{{ buttonLabel }}
</template>
<template #content-top>
<div class="space-y-1.5 border-b border-default p-2 text-xs">
<div class="flex items-center justify-between gap-2 px-0.5">
<span class="font-medium text-highlighted">{{ t('tags.filterTitle') }}</span>
<button
type="button"
class="text-primary hover:underline"
@click="emit('manage')"
>
{{ t('tags.manage') }}
</button>
</div>
<div
v-if="modelValue.length > 1"
class="flex gap-1 rounded-md border border-default p-0.5"
>
<button
type="button"
class="flex-1 rounded px-2 py-1 text-center transition-colors"
:class="tagMode === 'or' ? 'bg-primary text-white' : 'text-muted hover:bg-elevated'"
@click="emit('update:tagMode', 'or')"
>
{{ t('tags.modeOr') }}
</button>
<button
type="button"
class="flex-1 rounded px-2 py-1 text-center transition-colors"
:class="tagMode === 'and' ? 'bg-primary text-white' : 'text-muted hover:bg-elevated'"
@click="emit('update:tagMode', 'and')"
>
{{ t('tags.modeAnd') }}
</button>
</div>
<div class="space-y-0.5">
<button
type="button"
class="flex w-full items-center rounded-md px-2 py-1 text-left hover:bg-elevated"
:class="!untaggedOnly && !modelValue.length ? 'bg-primary/10 text-primary' : ''"
@click="clearFilter"
>
{{ t('tags.filterAll') }}
</button>
<button
type="button"
class="flex w-full items-center rounded-md px-2 py-1 text-left hover:bg-elevated"
:class="untaggedOnly ? 'bg-primary/10 text-primary' : ''"
@click="selectUntagged"
>
{{ t('tags.untagged') }}
</button>
</div>
</div>
</template>
<template #item-leading="{ item }">
<span
class="size-2 shrink-0 rounded-full"
:style="{ backgroundColor: findTag(item.value)?.color }"
/>
</template>
<template #item-trailing="{ item }">
<span class="text-[11px] tabular-nums text-muted">
{{ findTag(item.value)?.imageCount ?? 0 }}
</span>
</template>
</USelect>
</template>
+49
View File
@@ -0,0 +1,49 @@
<script setup lang="ts">
import type { ImageTag } from '~/types/image'
const props = defineProps<{
open: boolean
tags: ImageTag[]
}>()
const emit = defineEmits<{
'update:open': [value: boolean]
'changed': []
}>()
const { t } = useI18n()
const { fetchTags } = useTags()
watch(() => props.open, (open) => {
if (open) void fetchTags(true)
})
</script>
<template>
<UModal
:open="open"
:title="t('tags.manageTitle')"
:description="t('tags.manageSubtitle')"
:ui="{ content: 'max-w-5xl' }"
@update:open="emit('update:open', $event)"
>
<template #body>
<TagManagePanel
:tags="tags"
@changed="emit('changed')"
/>
</template>
<template #footer>
<div class="flex w-full justify-end">
<UButton
variant="ghost"
color="neutral"
@click="emit('update:open', false)"
>
{{ t('common.cancel') }}
</UButton>
</div>
</template>
</UModal>
</template>
File diff suppressed because it is too large. Load diff
+233
View File
@@ -0,0 +1,233 @@
<script setup lang="ts">
import type { ImageTag } from '~/types/image'
import { pickRandomUnusedTagColor } from '~/utils/tag-colors'
const props = withDefaults(defineProps<{
modelValue: number[]
tags: ImageTag[]
disabled?: boolean
placeholder?: string
allowCreate?: boolean
/** 在 Modal 内使用内联列表,避免 Popover 被遮罩挡住 */
inline?: boolean
/** 内联列表使用复选框样式 */
checkbox?: boolean
/** 隐藏已选标签徽章(弹窗内用复选框列表即可) */
hideSelectedChips?: boolean
}>(), {
disabled: false,
allowCreate: true,
inline: false,
checkbox: false,
hideSelectedChips: false
})
const emit = defineEmits<{
'update:modelValue': [value: number[]]
'created': [tag: ImageTag]
}>()
const { t } = useI18n()
const toast = useToast()
const { createTag } = useTags()
const open = ref(false)
const query = ref('')
const creating = ref(false)
const selectedTags = computed(() =>
props.tags.filter(tag => props.modelValue.includes(tag.id))
)
const filteredTags = computed(() => {
const q = query.value.trim().toLowerCase()
if (!q) return props.tags
return props.tags.filter(tag => tag.name.toLowerCase().includes(q))
})
const showCreateOption = computed(() => {
if (!props.allowCreate) return false
const q = query.value.trim()
if (!q) return false
return !props.tags.some(tag => tag.name === q)
})
function toggleTag(tagId: number) {
const next = new Set(props.modelValue)
if (next.has(tagId)) {
next.delete(tagId)
} else {
next.add(tagId)
}
emit('update:modelValue', [...next])
}
function removeTag(tagId: number) {
emit('update:modelValue', props.modelValue.filter(id => id !== tagId))
}
async function createFromQuery() {
const name = query.value.trim()
if (!name || creating.value) return
creating.value = true
try {
const color = pickRandomUnusedTagColor(props.tags.map(tag => tag.color))
const tag = await createTag(name, color)
emit('created', tag)
emit('update:modelValue', [...new Set([...props.modelValue, tag.id])])
query.value = ''
toast.add({ title: t('tags.created', { name: tag.name }), color: 'success' })
} catch (error: unknown) {
const message = error instanceof Error ? error.message : t('tags.createFailed')
toast.add({ title: message, color: 'error' })
} finally {
creating.value = false
}
}
</script>
<template>
<div class="space-y-2">
<div
v-if="selectedTags.length && !hideSelectedChips"
class="flex flex-wrap gap-1.5"
>
<TagBadge
v-for="tag in selectedTags"
:key="tag.id"
:tag="tag"
removable
@remove="removeTag(tag.id)"
/>
</div>
<div
v-if="inline"
class="space-y-2 rounded-lg border border-default p-2"
>
<UInput
v-model="query"
size="sm"
icon="i-lucide-search"
:placeholder="checkbox ? t('tags.search') : t('tags.searchOrCreate')"
:disabled="disabled"
@keydown.enter.prevent="showCreateOption ? createFromQuery() : undefined"
/>
<div class="max-h-48 space-y-1 overflow-y-auto">
<button
v-for="tag in filteredTags"
:key="tag.id"
type="button"
class="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm hover:bg-elevated"
:class="modelValue.includes(tag.id) ? 'bg-primary/10' : ''"
@click="toggleTag(tag.id)"
>
<UCheckbox
v-if="checkbox"
:model-value="modelValue.includes(tag.id)"
class="pointer-events-none"
@click.stop
/>
<span
class="size-2 shrink-0 rounded-full"
:style="{ backgroundColor: tag.color }"
/>
<span class="min-w-0 flex-1 truncate">{{ tag.name }}</span>
<UIcon
v-if="!checkbox && modelValue.includes(tag.id)"
name="i-lucide-check"
class="size-4 text-primary"
/>
</button>
<p
v-if="!filteredTags.length && !showCreateOption"
class="px-2 py-3 text-center text-xs text-muted"
>
{{ tags.length ? t('tags.noMatch') : t('tags.emptyHint') }}
</p>
<button
v-if="showCreateOption"
type="button"
class="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm text-primary hover:bg-elevated"
:disabled="creating"
@click="createFromQuery"
>
<UIcon
name="i-lucide-plus"
class="size-4"
/>
{{ t('tags.createNamed', { name: query.trim() }) }}
</button>
</div>
</div>
<UPopover
v-else
v-model:open="open"
>
<UButton
type="button"
size="sm"
variant="outline"
color="neutral"
icon="i-lucide-plus"
:disabled="disabled"
>
{{ placeholder ?? t('tags.addTag') }}
</UButton>
<template #content>
<div class="w-64 space-y-2 p-2">
<UInput
v-model="query"
size="sm"
icon="i-lucide-search"
:placeholder="checkbox ? t('tags.search') : t('tags.searchOrCreate')"
:disabled="disabled"
@keydown.enter.prevent="showCreateOption ? createFromQuery() : undefined"
/>
<div class="max-h-48 space-y-1 overflow-y-auto">
<button
v-for="tag in filteredTags"
:key="tag.id"
type="button"
class="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm hover:bg-elevated"
:class="modelValue.includes(tag.id) ? 'bg-primary/10' : ''"
@click="toggleTag(tag.id)"
>
<span
class="size-2 shrink-0 rounded-full"
:style="{ backgroundColor: tag.color }"
/>
<span class="min-w-0 flex-1 truncate">{{ tag.name }}</span>
<UIcon
v-if="modelValue.includes(tag.id)"
name="i-lucide-check"
class="size-4 text-primary"
/>
</button>
<button
v-if="showCreateOption"
type="button"
class="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm text-primary hover:bg-elevated"
:disabled="creating"
@click="createFromQuery"
>
<UIcon
name="i-lucide-plus"
class="size-4"
/>
{{ t('tags.createNamed', { name: query.trim() }) }}
</button>
</div>
</div>
</template>
</UPopover>
</div>
</template>
@@ -0,0 +1,106 @@
<script setup lang="ts">
const { t } = useI18n()
const { isAdmin } = useAuth()
const {
concurrency,
threads,
maxConcurrency,
maxThreads,
preset,
bitratePercent,
minBitratePercent,
maxBitratePercent,
load
} = useTranscodeSettings()
const presetItems = computed(() => [
{ value: 'copy', label: t('preferences.presetCopy') },
{ value: 'veryfast', label: t('preferences.presetSpeed') },
{ value: 'medium', label: t('preferences.presetBalanced') },
{ value: 'slow', label: t('preferences.presetQuality') },
{ value: 'compress', label: t('preferences.presetCompress') }
])
function onBitrateInput(event: Event) {
const value = Number((event.target as HTMLInputElement).value)
bitratePercent.value = Number.isFinite(value) ? value : 80
}
function selectPreset(value: string) {
preset.value = value
}
onMounted(() => {
void load()
})
</script>
<template>
<div class="space-y-3">
<div class="flex items-center gap-2 text-sm">
<span class="shrink-0">{{ t('preferences.transcodeConcurrency') }}</span>
<UInput
v-model.number="concurrency"
type="number"
min="1"
:max="maxConcurrency"
size="xs"
class="w-16 tabular-nums"
:disabled="!isAdmin"
/>
<span class="text-muted">{{ t('preferences.unitCount') }}</span>
<span class="text-xs text-muted">{{ t('preferences.concurrentHint') }}</span>
</div>
<div class="flex items-center gap-2 text-sm">
<span class="shrink-0">{{ t('preferences.transcodeThreads') }}</span>
<UInput
v-model.number="threads"
type="number"
min="1"
:max="maxThreads"
size="xs"
class="w-16 tabular-nums"
:disabled="!isAdmin"
/>
<span class="text-muted">{{ t('preferences.unitCount') }}</span>
<span class="text-xs text-muted">{{ t('preferences.threadsHint') }}</span>
</div>
<div class="flex flex-wrap items-center gap-2 text-sm">
<span class="shrink-0">{{ t('preferences.transcodeQuality') }}</span>
<div class="flex gap-1 rounded-lg border border-default p-0.5">
<UButton
v-for="item in presetItems"
:key="item.value"
size="xs"
class="flex-1 justify-center"
:variant="preset === item.value ? 'solid' : 'ghost'"
:color="preset === item.value ? 'primary' : 'neutral'"
:label="item.label"
:disabled="!isAdmin"
@click="selectPreset(item.value)"
/>
</div>
</div>
<div
class="flex items-center gap-2 text-sm"
:class="preset === 'compress' ? '' : 'opacity-40'"
>
<span class="shrink-0">{{ t('preferences.compressBitrate') }}</span>
<input
type="range"
:min="minBitratePercent"
:max="maxBitratePercent"
:value="bitratePercent"
:disabled="!isAdmin || preset !== 'compress'"
class="h-2 min-w-24 flex-1 cursor-pointer appearance-none rounded-full bg-default accent-primary disabled:opacity-40"
@input="onBitrateInput"
>
<span class="w-12 shrink-0 text-right tabular-nums text-muted">
{{ bitratePercent }}%
</span>
</div>
</div>
</template>
+120
View File
@@ -0,0 +1,120 @@
<script setup lang="ts">
const props = defineProps<{
siteKey: string
}>()
const emit = defineEmits<{
verified: [payload: { turnstileToken: string }]
cleared: []
}>()
interface TurnstileRenderOptions {
'sitekey': string
'action'?: string
'callback'?: (token: string) => void
'expired-callback'?: () => void
'error-callback'?: () => void
}
interface TurnstileApi {
render: (container: HTMLElement, options: TurnstileRenderOptions) => string
reset: (widgetId?: string) => void
remove: (widgetId: string) => void
}
declare global {
interface Window {
turnstile?: TurnstileApi
onTurnstileLoad?: () => void
}
}
const containerRef = ref<HTMLElement | null>(null)
const widgetId = ref<string | null>(null)
const scriptLoaded = ref(false)
function onVerified(token: string) {
if (token) {
emit('verified', { turnstileToken: token })
}
}
function onCleared() {
emit('cleared')
}
function renderWidget() {
if (!containerRef.value || !window.turnstile || !props.siteKey) return
if (widgetId.value) {
window.turnstile.remove(widgetId.value)
widgetId.value = null
}
widgetId.value = window.turnstile.render(containerRef.value, {
'sitekey': props.siteKey,
'action': 'login',
'callback': onVerified,
'expired-callback': onCleared,
'error-callback': onCleared
})
}
function tryRenderWidget() {
if (scriptLoaded.value) {
renderWidget()
}
}
function loadScript() {
if (window.turnstile) {
scriptLoaded.value = true
tryRenderWidget()
return
}
window.onTurnstileLoad = () => {
scriptLoaded.value = true
tryRenderWidget()
}
const existing = document.querySelector('script[data-turnstile]')
if (existing) {
if (window.turnstile) {
scriptLoaded.value = true
tryRenderWidget()
}
return
}
const script = document.createElement('script')
script.src = 'https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit&onload=onTurnstileLoad'
script.async = true
script.defer = true
script.dataset.turnstile = 'true'
document.head.appendChild(script)
}
function reset() {
if (window.turnstile && widgetId.value) {
window.turnstile.reset(widgetId.value)
}
onCleared()
}
onMounted(async () => {
loadScript()
await nextTick()
tryRenderWidget()
})
watch([containerRef, () => props.siteKey, scriptLoaded], () => {
tryRenderWidget()
}, { flush: 'post' })
defineExpose({ reset })
</script>
<template>
<ClientOnly>
<div
ref="containerRef"
class="flex min-h-16 items-center justify-center"
/>
</ClientOnly>
</template>
@@ -2,9 +2,24 @@
import uploadCardBg from '~/assets/image/upload-card-bg.webp'
import uploadCardHero from '~/assets/image/upload-card-hero.webp'
const props = defineProps<{
const props = withDefaults(defineProps<{
disabled?: boolean
}>()
title?: string
hint?: string
formats?: string
accept?: string
heroImage?: string
backgroundImage?: string
showSettings?: boolean
pasteEnabled?: boolean
/** 拖拽/选择时的类型限制;图片页与视频页各自限定,文件页放开 */
mediaKind?: 'image' | 'video' | 'any'
}>(), {
accept: 'image/jpeg,image/png,image/webp,image/gif,image/svg+xml,image/x-icon,.svg,.ico',
showSettings: true,
pasteEnabled: true,
mediaKind: 'image'
})
const emit = defineEmits<{
'upload': [files: File[]]
@@ -18,9 +33,22 @@ const { t } = useI18n()
const { bindPaste } = useClipboardImage(ref(null))
bindPaste((files) => {
if (!props.disabled) emit('upload', files)
if (props.disabled || !props.pasteEnabled) return
emit('upload', files)
})
const heroSource = computed(() => props.heroImage ?? uploadCardHero)
const backgroundSource = computed(() => props.backgroundImage ?? uploadCardBg)
const titleText = computed(() => props.title ?? t('upload.title'))
const hintText = computed(() => props.hint ?? t('upload.hint', { ctrl: 'Ctrl', v: 'V' }))
const formatsText = computed(() => props.formats ?? t('upload.formats'))
function matchesMediaKind(file: File): boolean {
if (props.mediaKind === 'any') return true
if (!file.type) return true
return file.type.startsWith(`${props.mediaKind}/`)
}
function openFilePicker() {
if (props.disabled) return
fileInput.value?.click()
@@ -38,8 +66,7 @@ function onDrop(event: DragEvent) {
dragging.value = false
if (props.disabled) return
const files = Array.from(event.dataTransfer?.files ?? [])
.filter(file => file.type.startsWith('image/'))
const files = Array.from(event.dataTransfer?.files ?? []).filter(matchesMediaKind)
if (files.length) emit('upload', files)
}
@@ -83,7 +110,7 @@ function onKeydown(event: KeyboardEvent) {
<input
ref="fileInput"
type="file"
accept="image/jpeg,image/png,image/webp,image/gif,image/svg+xml,image/x-icon,.svg,.ico"
:accept="accept"
multiple
class="hidden"
:disabled="disabled"
@@ -91,7 +118,7 @@ function onKeydown(event: KeyboardEvent) {
>
<img
:src="uploadCardBg"
:src="backgroundSource"
alt=""
aria-hidden="true"
class="pointer-events-none absolute inset-0 size-full object-cover opacity-100 dark:opacity-[0.12]"
@@ -99,7 +126,10 @@ function onKeydown(event: KeyboardEvent) {
draggable="false"
>
<div class="absolute top-3 right-3 z-20">
<div
v-if="showSettings"
class="absolute top-3 right-3 z-20"
>
<button
type="button"
class="upload-settings-btn inline-flex size-8 items-center justify-center rounded-md bg-default/70 text-muted backdrop-blur-sm transition-colors hover:bg-elevated hover:text-highlighted disabled:pointer-events-none disabled:opacity-40"
@@ -117,7 +147,7 @@ function onKeydown(event: KeyboardEvent) {
<div class="relative z-10 mx-auto flex max-w-lg flex-col items-center">
<div class="flex flex-col items-center">
<img
:src="uploadCardHero"
:src="heroSource"
alt=""
aria-hidden="true"
class="h-24 w-auto -mb-3 select-none transition-transform duration-200 ease-[cubic-bezier(0.4,0,0.2,1)] sm:-mb-4 sm:h-28 md:h-32"
@@ -128,16 +158,16 @@ function onKeydown(event: KeyboardEvent) {
<div class="space-y-2">
<p class="text-xl font-semibold tracking-tight text-highlighted sm:text-2xl">
{{ t('upload.title') }}
{{ titleText }}
</p>
<p class="text-sm text-muted sm:text-base">
{{ t('upload.hint', { ctrl: 'Ctrl', v: 'V' }) }}
{{ hintText }}
</p>
</div>
</div>
<p class="mt-4 max-w-md text-xs leading-relaxed text-dimmed sm:text-sm">
{{ t('upload.formats') }}
{{ formatsText }}
</p>
</div>
</div>
+38 -5
View File
@@ -1,7 +1,20 @@
<script setup lang="ts">
defineProps<{
import type { UploadMediaKind } from '~/composables/useUploadPreferences'
const props = withDefaults(defineProps<{
disabled?: boolean
}>()
/** 上传页类型:决定默认可选文件类型、粘贴支持与偏好面板内容 */
kind?: UploadMediaKind
title?: string
hint?: string
formats?: string
accept?: string
heroImage?: string
backgroundImage?: string
pasteEnabled?: boolean
}>(), {
kind: 'image'
})
const emit = defineEmits<{
upload: [files: File[]]
@@ -9,6 +22,15 @@ const emit = defineEmits<{
const flipped = ref(false)
const mediaKind = computed(() => (props.kind === 'file' ? 'any' : props.kind))
const pasteEnabled = computed(() => props.pasteEnabled ?? props.kind === 'image')
const defaultAccept = computed(() => {
if (props.kind === 'video') return 'video/*,.mp4,.webm,.mov,.mkv,.avi,.m4v'
if (props.kind === 'file') return ''
return undefined
})
const accept = computed(() => props.accept ?? defaultAccept.value)
function onOpenSettings() {
flipped.value = true
}
@@ -21,18 +43,29 @@ function onBack() {
<template>
<div
class="upload-card-flip"
:class="{ 'is-flipped': flipped }"
:class="[`upload-card-flip--${kind}`, { 'is-flipped': flipped }]"
>
<div class="upload-card-flip-inner">
<div class="upload-card-face upload-card-face--front">
<ImageUploader
<UploadCard
:disabled="disabled"
:title="title"
:hint="hint"
:formats="formats"
:accept="accept"
:hero-image="heroImage"
:background-image="backgroundImage"
:media-kind="mediaKind"
:paste-enabled="pasteEnabled"
@upload="emit('upload', $event)"
@open-settings="onOpenSettings"
/>
</div>
<div class="upload-card-face upload-card-face--back">
<UploadPreferencesPanel @back="onBack" />
<UploadPreferencesPanel
:kind="kind"
@back="onBack"
/>
</div>
</div>
</div>
+197 -42
View File
@@ -1,12 +1,15 @@
<script setup lang="ts">
withDefaults(
defineProps<{
embedded?: boolean
}>(),
{
embedded: false
}
)
import {
normalizeUploadTagNames,
type UploadMediaKind
} from '~/composables/useUploadPreferences'
const props = withDefaults(defineProps<{
/** 每个上传页各自一份偏好 */
kind?: UploadMediaKind
}>(), {
kind: 'image'
})
const emit = defineEmits<{
back: []
@@ -17,10 +20,43 @@ const {
autoCopyMarkdown,
copyFormat,
clientWebpQuality,
uploadTagNames,
transcodeToHls,
loadPreferences
} = useUploadPreferences()
} = useUploadPreferences(props.kind)
const uploadTagDraft = ref('')
/** 视频床 / 文件床没有图片压缩相关选项 */
const isImageKind = computed(() => props.kind === 'image')
const isVideoKind = computed(() => props.kind === 'video')
const processingGridClass = computed(() => {
if (isImageKind.value || isVideoKind.value) return 'md:col-start-1 md:row-start-1'
return ''
})
const qualityGridClass = computed(() => {
if (isImageKind.value) return 'md:col-start-1 md:row-start-2'
return ''
})
const convenienceGridClass = computed(() => {
if (isImageKind.value) return 'md:col-start-2 md:row-start-1'
if (isVideoKind.value) return 'md:col-start-1 md:row-start-2'
return ''
})
const tagsGridClass = computed(() => {
if (isImageKind.value) return 'md:col-start-2 md:row-start-2'
if (isVideoKind.value) return 'md:col-start-2 md:row-start-1 md:row-span-2'
return ''
})
const tagsLayoutClass = computed(() => [
tagsGridClass.value
])
const { t } = useI18n()
const {
lowMemory,
memoryBytes,
load: loadTranscodeSettings
} = useTranscodeSettings()
const {
userSettings,
@@ -32,7 +68,8 @@ const {
const copyFormatItems = computed(() => [
{ label: t('copy.url'), value: 'url' as const },
{ label: t('copy.markdown'), value: 'markdown' as const },
{ label: t('copy.html'), value: 'html' as const }
{ label: t('copy.html'), value: 'html' as const },
{ label: t('copy.bbcode'), value: 'bbcode' as const }
])
const clientQualityLevel = computed(() => {
@@ -41,7 +78,13 @@ const clientQualityLevel = computed(() => {
return t('preferences.qualityLow')
})
function setCopyFormat(value: 'url' | 'markdown' | 'html') {
const memoryGb = computed(() => (
memoryBytes.value > 0
? (memoryBytes.value / (1024 * 1024 * 1024)).toFixed(1)
: '—'
))
function setCopyFormat(value: 'url' | 'markdown' | 'html' | 'bbcode') {
copyFormat.value = value
}
@@ -50,24 +93,45 @@ function onClientQualityInput(event: Event) {
clientWebpQuality.value = Number.isFinite(value) ? value : 80
}
function commitUploadTags() {
const names = normalizeUploadTagNames(uploadTagDraft.value)
if (!names.length) return
uploadTagNames.value = normalizeUploadTagNames([
...uploadTagNames.value,
...names
])
uploadTagDraft.value = ''
}
function onUploadTagKeydown(event: KeyboardEvent) {
if (event.key === 'Enter' || event.key === ',' || event.key === ',') {
event.preventDefault()
commitUploadTags()
return
}
if (
event.key === 'Backspace'
&& !uploadTagDraft.value
&& uploadTagNames.value.length
) {
uploadTagNames.value = uploadTagNames.value.slice(0, -1)
}
}
function removeUploadTag(name: string) {
uploadTagNames.value = uploadTagNames.value.filter(item => item !== name)
}
onMounted(() => {
loadPreferences()
void loadUserSettings()
void loadTranscodeSettings()
})
</script>
<template>
<div
class="flex flex-col overflow-hidden max-sm:overflow-visible"
: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="upload-card-surface upload-card-surface--panel flex max-sm:min-h-0 flex-col overflow-hidden max-sm:overflow-visible 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">
<UIcon
name="i-lucide-settings-2"
@@ -83,7 +147,6 @@ onMounted(() => {
</div>
</div>
<UButton
v-if="!embedded"
:label="t('common.back')"
icon="i-lucide-arrow-left"
variant="ghost"
@@ -95,12 +158,15 @@ onMounted(() => {
/>
</div>
<div
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="space-y-4">
<div class="flex flex-1 flex-col justify-start overflow-y-auto p-5 sm:p-6">
<div
class="grid gap-5 md:grid-cols-2 md:gap-6"
>
<div
v-if="isImageKind"
class="space-y-4 rounded-xl border border-default/70 bg-muted/10 p-4"
:class="processingGridClass"
>
<h3 class="text-sm font-semibold">
{{ t('preferences.processing') }}
</h3>
@@ -128,9 +194,7 @@ onMounted(() => {
class="mt-0.5"
/>
<span class="min-w-0 flex-1">
<span
class="flex flex-wrap items-center gap-x-1.5 gap-y-1 text-sm"
>
<span class="flex flex-wrap items-center gap-x-1.5 gap-y-1 text-sm">
<span>{{ t('preferences.autoDeletePrefix') }}</span>
<UInput
v-model.number="userAutoDeleteDaysDraft"
@@ -139,11 +203,7 @@ onMounted(() => {
max="3650"
size="xs"
class="w-14 tabular-nums"
:class="
userAutoDeleteEnabled
? ''
: 'pointer-events-none opacity-40'
"
:class="userAutoDeleteEnabled ? '' : 'pointer-events-none opacity-40'"
/>
<span>{{ t('preferences.autoDeleteSuffix') }}</span>
</span>
@@ -164,16 +224,57 @@ onMounted(() => {
</div>
</div>
<div class="space-y-4">
<div
v-if="isVideoKind"
class="space-y-4 rounded-xl border border-default/70 bg-muted/10 p-4"
:class="processingGridClass"
>
<h3 class="text-sm font-semibold">
{{ t('preferences.videoProcessing') }}
</h3>
<UAlert
v-if="lowMemory"
color="warning"
variant="subtle"
icon="i-lucide-triangle-alert"
:title="t('preferences.lowMemoryTranscodeTitle')"
:description="t('preferences.lowMemoryTranscodeHint', { memory: memoryGb })"
/>
<label class="flex cursor-pointer items-start gap-2.5">
<UCheckbox
v-model="transcodeToHls"
class="mt-0.5"
/>
<span>
<span class="block text-sm">{{ t('preferences.transcodeToHls') }}</span>
<span class="mt-1 block text-xs leading-relaxed text-muted">
{{ t('preferences.transcodeToHlsHint') }}
</span>
</span>
</label>
<div
v-if="transcodeToHls"
class="ml-7 space-y-3 border-t border-default pt-4"
>
<TranscodeSettingsControls />
</div>
</div>
<div
v-if="isImageKind"
class="space-y-4 rounded-xl border border-default/70 bg-muted/10 p-4"
:class="qualityGridClass"
>
<h3 class="text-sm font-semibold">
{{ t('preferences.webpQuality') }}
</h3>
<p class="text-xs leading-relaxed text-muted">
{{ t('preferences.webpQualityHint') }}
</p>
<div
class="flex items-center justify-between gap-2 text-sm text-muted"
>
<div class="flex items-center justify-between gap-2 text-sm text-muted">
<span>{{ t('preferences.current', { n: clientWebpQuality }) }}</span>
<span>{{ clientQualityLevel }}</span>
</div>
@@ -187,7 +288,10 @@ onMounted(() => {
>
</div>
<div class="space-y-4">
<div
class="space-y-4 rounded-xl border border-default/70 bg-muted/10 p-4"
:class="convenienceGridClass"
>
<h3 class="text-sm font-semibold">
{{ t('preferences.convenience') }}
</h3>
@@ -219,6 +323,57 @@ onMounted(() => {
/>
</div>
</div>
<div
class="space-y-3 rounded-xl border border-default/70 bg-muted/10 p-4"
:class="tagsLayoutClass"
>
<div class="space-y-3">
<h3 class="text-sm font-semibold">
{{ t('preferences.uploadTags') }}
</h3>
<p class="text-xs leading-relaxed text-muted">
{{ t('preferences.uploadTagsHint') }}
</p>
</div>
<UInput
v-model="uploadTagDraft"
size="md"
icon="i-lucide-tags"
maxlength="32"
:placeholder="t('preferences.uploadTagsPlaceholder')"
@keydown="onUploadTagKeydown"
@blur="commitUploadTags"
/>
<div
v-if="uploadTagNames.length"
class="space-y-2 pt-1"
>
<p class="text-xs font-medium text-muted">
{{ t('preferences.uploadTagsPending', { n: uploadTagNames.length }) }}
</p>
<div class="flex flex-wrap gap-2.5">
<span
v-for="name in uploadTagNames"
:key="name"
class="relative inline-flex max-w-full items-center rounded-full border border-primary/35 bg-primary/10 px-3 py-1 text-xs font-medium text-primary"
>
<span class="truncate">{{ name }}</span>
<button
type="button"
class="absolute -top-1.5 -right-1.5 inline-flex size-4 items-center justify-center rounded-full border border-primary/40 bg-elevated text-primary shadow-sm transition-colors hover:bg-primary hover:text-inverted"
:aria-label="t('preferences.uploadTagsRemove', { name })"
@click="removeUploadTag(name)"
>
<UIcon
name="i-lucide-x"
class="size-2.5"
/>
</button>
</span>
</div>
</div>
</div>
</div>
</div>
</div>
+160
View File
@@ -0,0 +1,160 @@
<script setup lang="ts">
import type { FileItem } from '~/types/file'
const props = defineProps<{
item: FileItem
/** 已按访问模式拼接好的视频直链 */
link: string
/** 缩略图直链 */
thumbLink: string
/** 原始视频下载地址 */
downloadLink: string
selected?: boolean
selectable?: boolean
}>()
const emit = defineEmits<{
'play': []
'delete': []
'download': []
'update:selected': [value: boolean]
}>()
const { formatFileSize } = useFileSize()
const { t, locale } = useI18n()
const transcodeFailureLabel = useTranscodeFailureLabel()
/** 转码完成后按 HLS 播放列表展示 */
const displayName = computed(() => {
if (!props.item.hlsUrl) return props.item.originalName
const dot = props.item.originalName.lastIndexOf('.')
const base = dot > 0 ? props.item.originalName.slice(0, dot) : props.item.originalName
return `${base}.m3u8`
})
const uploadedLabel = computed(() => {
try {
return new Date(props.item.uploadedAt).toLocaleString(locale.value, { hour12: false })
} catch {
return props.item.uploadedAt
}
})
</script>
<template>
<div class="flex flex-col overflow-hidden rounded-xl border border-default bg-elevated">
<div class="relative aspect-video shrink-0 overflow-hidden bg-muted">
<VideoThumbnail
:src="thumbLink"
:alt="displayName"
/>
<button
type="button"
class="absolute inset-0 flex items-center justify-center transition-colors hover:bg-black/20"
:aria-label="t('library.play')"
@click="emit('play')"
>
<span class="rounded-full bg-black/55 p-3 text-white backdrop-blur-sm">
<UIcon
name="i-lucide-play"
class="size-6"
/>
</span>
</button>
<div
v-if="selectable"
class="absolute top-2 left-2 z-10"
@click.stop
>
<UCheckbox
:model-value="selected"
@update:model-value="emit('update:selected', $event === true)"
/>
</div>
<div
class="absolute top-2 right-2 z-10"
@click.stop
>
<UBadge
v-if="item.hlsUrl"
color="success"
variant="solid"
size="xs"
>
m3u8
</UBadge>
<UBadge
v-else-if="item.transcodeStatus === 'failed'"
color="error"
variant="solid"
size="xs"
class="max-w-48 truncate"
:title="transcodeFailureLabel(item.transcodeError)"
>
{{ transcodeFailureLabel(item.transcodeError) }}
</UBadge>
</div>
</div>
<div class="flex flex-1 flex-col gap-1.5 p-3">
<p
class="truncate text-sm font-semibold"
:title="displayName"
>
{{ displayName }}
</p>
<p class="truncate text-xs text-muted">
{{ uploadedLabel }} · {{ formatFileSize(item.size) }}
</p>
<TagBadgeList :tags="item.tags" />
</div>
<div class="flex items-center justify-around border-t border-default px-2 py-2">
<CopyButton
icon="i-lucide-link"
icon-only
:label="t('library.copyVideoLink')"
:value="link"
:success-title="t('copy.copiedUrl')"
/>
<CopyButton
icon="i-lucide-image"
icon-only
:label="t('library.copyThumbLink')"
:value="thumbLink"
:success-title="t('copy.copiedUrl')"
/>
<UButton
v-if="!item.sourceDeleted"
icon="i-lucide-download"
variant="ghost"
color="neutral"
size="sm"
:to="downloadLink"
target="_blank"
rel="noopener"
:aria-label="t('files.download')"
/>
<UButton
v-else-if="item.hlsUrl"
icon="i-lucide-download"
variant="ghost"
color="neutral"
size="sm"
:aria-label="t('library.video.downloadMp4')"
@click="emit('download')"
/>
<UButton
icon="i-lucide-trash-2"
variant="ghost"
color="neutral"
size="sm"
:aria-label="t('common.delete')"
@click="emit('delete')"
/>
</div>
</div>
</template>
+785
View File
@@ -0,0 +1,785 @@
<script setup lang="ts">
import type { FileItem, FileListResponse } from '~/types/file'
import type { LibraryStatsData } from '~/types/media'
import { formatFileSize } from '~/composables/useFileSize'
import SiteSyncSelectedDialog from '~/components/storage/SiteSyncSelectedDialog.vue'
const { isAuthenticated, handleAuthError } = useAuth()
const toast = useToast()
const { t, locale } = useI18n()
const { items: tagItems, fetchTags, batchUpdateTags } = useTags()
const transcodeFailureLabel = useTranscodeFailureLabel()
const items = ref<FileItem[]>([])
const total = ref(0)
const page = ref(1)
const pageSize = ref(20)
const totalPages = ref(1)
const loading = ref(false)
const searchQuery = ref('')
const activeSearch = ref('')
const accessMode = ref<'public' | 'token'>('public')
const apiToken = ref('')
const stats = ref<LibraryStatsData | null>(null)
const selectedKeys = ref<Set<string>>(new Set())
const deleteModalOpen = ref(false)
const batchTagOpen = ref(false)
const syncOpen = ref(false)
const deleteTargetKeys = ref<string[]>([])
const deleting = ref(false)
const previewOpen = ref(false)
const previewItem = ref<FileItem | null>(null)
const downloadOpen = ref(false)
const downloadItem = ref<FileItem | null>(null)
const currentBackend = ref('all')
const currentSource = ref('all')
const backendOptions = ref<Array<{ id: string, name: string }>>([])
const activeTagIds = ref<number[]>([])
const activeTagMode = ref<'or' | 'and'>('or')
const activeUntaggedOnly = ref(false)
const { viewMode } = useLibraryView('video')
const selectedCount = computed(() => selectedKeys.value.size)
const syncKeys = computed(() => [...selectedKeys.value])
const backendItems = computed(() => [
{ label: t('stats.filterStorageAll'), value: 'all' },
...backendOptions.value.map(backend => ({ label: backend.name, value: backend.id }))
])
const sourceItems = computed(() => [
{ label: t('stats.filterSourceAll'), value: 'all' },
{ label: t('stats.sourceWeb'), value: 'web' },
{ label: t('stats.sourceApi'), value: 'api' }
])
const allSelected = computed(() =>
items.value.length > 0 && items.value.every(item => selectedKeys.value.has(item.key))
)
function withToken(raw: string | undefined): string {
if (!raw) return ''
if (accessMode.value !== 'token' || !apiToken.value) return raw
const separator = raw.includes('?') ? '&' : '?'
return `${raw}${separator}token=${encodeURIComponent(apiToken.value)}`
}
/** 转码完成后主直链指向 m3u8 播放列表 */
function videoLink(item: FileItem): string {
return withToken(item.hlsUrl ?? item.url)
}
function thumbLink(item: FileItem): string {
return withToken(item.thumbUrl) || '/video-placeholder.svg'
}
function downloadUrl(item: FileItem): string {
return withToken(item.url)
}
/** 转码完成后按 HLS 播放列表展示名称 */
function displayName(item: FileItem): string {
if (!item.hlsUrl) return item.originalName
const dot = item.originalName.lastIndexOf('.')
const base = dot > 0 ? item.originalName.slice(0, dot) : item.originalName
return `${base}.m3u8`
}
function uploadedLabel(item: FileItem): string {
try {
return new Date(item.uploadedAt).toLocaleString(locale.value, { hour12: false })
} catch {
return item.uploadedAt
}
}
async function loadApiToken() {
try {
const data = await $fetch<{ apiUploadToken: string }>('/api/user/api-token', {
credentials: 'include'
})
apiToken.value = data.apiUploadToken ?? ''
} catch {
apiToken.value = ''
}
}
async function loadBackendOptions() {
try {
const data = await $fetch<{ backends: Array<{ id: string, name: string }> }>(
'/api/storage-backends',
{ credentials: 'include' }
)
backendOptions.value = data.backends ?? []
} catch (error: unknown) {
handleAuthError(error)
}
}
async function fetchStats() {
if (!isAuthenticated.value) return
try {
stats.value = await $fetch<LibraryStatsData>('/api/library/stats', {
credentials: 'include',
query: { kind: 'video' }
})
} catch (error: unknown) {
handleAuthError(error)
}
}
async function fetchList(targetPage = page.value) {
if (!isAuthenticated.value) return
loading.value = true
try {
const response = await $fetch<FileListResponse>('/api/files', {
credentials: 'include',
query: {
limit: pageSize.value,
page: targetPage,
kind: 'video',
...(activeSearch.value ? { q: activeSearch.value } : {}),
...(currentBackend.value !== 'all' ? { backendId: currentBackend.value } : {}),
...(currentSource.value !== 'all' ? { uploadSource: currentSource.value } : {}),
...(activeTagIds.value.length ? { tagIds: activeTagIds.value.join(',') } : {}),
...(activeUntaggedOnly.value ? { untagged: '1' } : {}),
...(activeTagMode.value === 'and' ? { tagMode: 'and' } : {})
}
})
items.value = response.items
total.value = response.total
page.value = response.page
totalPages.value = response.totalPages
accessMode.value = response.accessMode
const visible = new Set(response.items.map(item => item.key))
selectedKeys.value = new Set(
[...selectedKeys.value].filter(key => visible.has(key))
)
if (response.accessMode === 'token' && !apiToken.value) {
await loadApiToken()
}
} catch (error: unknown) {
handleAuthError(error)
if (isAuthenticated.value) {
toast.add({ title: t('files.loadFailed'), color: 'error' })
}
} finally {
loading.value = false
}
}
async function refresh() {
await Promise.all([fetchList(1), fetchStats(), fetchTags()])
}
async function submitSearch() {
if (!isAuthenticated.value) return
activeSearch.value = searchQuery.value.trim()
await fetchList(1)
}
async function handleBackendChange(value: string) {
currentBackend.value = value
selectedKeys.value = new Set()
await fetchList(1)
}
async function handleSourceChange(value: string) {
currentSource.value = value
selectedKeys.value = new Set()
await fetchList(1)
}
async function handleTagIdsChange(tagIds: number[]) {
activeTagIds.value = tagIds
selectedKeys.value = new Set()
await fetchList(1)
}
async function handleTagModeChange(tagMode: 'or' | 'and') {
activeTagMode.value = tagMode
await fetchList(1)
}
async function handleUntaggedChange(untaggedOnly: boolean) {
activeUntaggedOnly.value = untaggedOnly
selectedKeys.value = new Set()
await fetchList(1)
}
async function resetFilters() {
searchQuery.value = ''
activeSearch.value = ''
currentBackend.value = 'all'
currentSource.value = 'all'
activeTagIds.value = []
activeTagMode.value = 'or'
activeUntaggedOnly.value = false
selectedKeys.value = new Set()
await fetchList(1)
}
function toggleSelected(key: string, value: boolean) {
const next = new Set(selectedKeys.value)
if (value) next.add(key)
else next.delete(key)
selectedKeys.value = next
}
function toggleSelectAll() {
selectedKeys.value = allSelected.value
? new Set()
: new Set(items.value.map(item => item.key))
}
function clearSelection() {
selectedKeys.value = new Set()
}
function setViewMode(mode: 'grid' | 'list') {
viewMode.value = mode
}
function openBatchTags() {
batchTagOpen.value = true
}
function openSync() {
syncOpen.value = true
}
async function confirmBatchTags(tagIds: number[], action: 'add' | 'remove') {
const keys = [...selectedKeys.value]
if (!keys.length || !tagIds.length) return
try {
await batchUpdateTags(keys, tagIds, action)
batchTagOpen.value = false
selectedKeys.value = new Set()
toast.add({ title: t('tags.batchSuccess'), color: 'success' })
await Promise.all([fetchList(1), fetchStats(), fetchTags(true)])
} catch (error: unknown) {
handleAuthError(error)
toast.add({ title: t('tags.batchFailed'), color: 'error' })
}
}
function openPreview(item: FileItem) {
previewItem.value = item
previewOpen.value = true
}
function requestDownload(item: FileItem | null) {
if (!item?.hlsUrl) return
previewOpen.value = false
downloadItem.value = item
downloadOpen.value = true
}
onMounted(() => {
void loadBackendOptions()
})
function requestDelete(item: FileItem) {
if (!isAuthenticated.value) return
deleteTargetKeys.value = [item.key]
deleteModalOpen.value = true
}
function requestBatchDelete() {
if (!isAuthenticated.value) return
if (!selectedCount.value) {
toast.add({ title: t('library.selectFirst'), color: 'warning' })
return
}
deleteTargetKeys.value = [...selectedKeys.value]
deleteModalOpen.value = true
}
async function confirmDelete() {
const keys = [...deleteTargetKeys.value]
if (!keys.length || !isAuthenticated.value) return
deleting.value = true
try {
if (keys.length === 1) {
await $fetch('/api/files', {
method: 'DELETE',
credentials: 'include',
query: { key: keys[0] }
})
} else {
const response = await $fetch<{ deleted: string[], failed: unknown[] }>(
'/api/files/batch-delete',
{ method: 'POST', credentials: 'include', body: { keys } }
)
if (response.failed.length) {
toast.add({
title: t('library.deletePartialFail', { n: response.failed.length }),
color: 'warning'
})
}
}
toast.add({ title: t('library.deleted', { n: keys.length }), color: 'success' })
deleteModalOpen.value = false
deleteTargetKeys.value = []
selectedKeys.value = new Set()
await refresh()
} catch (error: unknown) {
handleAuthError(error)
if (isAuthenticated.value) {
toast.add({ title: t('files.deleteFailed'), color: 'error' })
}
} finally {
deleting.value = false
}
}
async function handlePageChange(targetPage: number) {
selectedKeys.value = new Set()
await fetchList(targetPage)
}
async function handlePageSizeChange(size: number) {
pageSize.value = size
selectedKeys.value = new Set()
await fetchList(1)
}
defineExpose({ refresh })
</script>
<template>
<div class="space-y-6">
<div class="flex items-start justify-between gap-3">
<div class="min-w-0">
<h1 class="text-2xl font-semibold tracking-tight">
{{ t('library.video.title') }}
</h1>
<p class="text-sm text-muted">
{{ t('library.video.subtitle') }}
</p>
</div>
<UButton
icon="i-lucide-refresh-cw"
variant="ghost"
color="neutral"
size="sm"
class="mt-0.5 shrink-0"
:aria-label="t('common.refresh')"
:loading="loading"
@click="refresh"
/>
</div>
<LibraryStatsPanel
kind="video"
:stats="stats"
:loading="loading"
/>
<section class="space-y-3">
<div class="flex flex-wrap items-center justify-between gap-x-4 gap-y-2">
<div class="flex items-center gap-2 text-sm">
<UCheckbox
:model-value="allSelected"
:disabled="!items.length"
@update:model-value="toggleSelectAll"
/>
<span class="text-muted">
{{ t('library.selectedCount', { n: selectedCount }) }}
</span>
<UButton
v-if="selectedCount > 0"
variant="link"
color="primary"
size="xs"
:label="t('library.clearSelection')"
@click="clearSelection"
/>
</div>
<div class="flex flex-wrap items-center gap-2">
<UInput
v-model="searchQuery"
icon="i-lucide-search"
:placeholder="t('library.searchPlaceholder')"
size="sm"
class="w-44"
@keydown.enter.prevent="submitSearch"
/>
<USelect
:model-value="currentBackend"
:items="backendItems"
value-key="value"
size="sm"
class="w-32"
:aria-label="t('stats.filterStorageAll')"
@update:model-value="handleBackendChange"
/>
<USelect
:model-value="currentSource"
:items="sourceItems"
value-key="value"
size="sm"
class="w-28"
:aria-label="t('stats.filterSourceAll')"
@update:model-value="handleSourceChange"
/>
<TagFilterSelect
:model-value="activeTagIds"
:tag-mode="activeTagMode"
:untagged-only="activeUntaggedOnly"
:tags="tagItems"
:disabled="loading"
@update:model-value="handleTagIdsChange"
@update:tag-mode="handleTagModeChange"
@update:untagged-only="handleUntaggedChange"
/>
<UButton
icon="i-lucide-search"
size="sm"
:label="t('common.search')"
:loading="loading"
@click="submitSearch"
/>
<UButton
icon="i-lucide-rotate-ccw"
variant="outline"
color="neutral"
size="sm"
:label="t('library.reset')"
@click="resetFilters"
/>
<UButton
v-if="selectedCount > 0"
icon="i-lucide-tags"
variant="outline"
color="primary"
size="sm"
:disabled="!selectedCount || loading"
:label="t('tags.batchAdd')"
@click="openBatchTags"
/>
<UButton
icon="i-lucide-trash-2"
variant="outline"
color="error"
size="sm"
:disabled="!selectedCount || loading"
:label="t('library.batchDelete')"
@click="requestBatchDelete"
/>
<UButton
v-if="selectedCount > 0"
icon="i-lucide-cloud-upload"
variant="outline"
color="neutral"
size="sm"
:disabled="!selectedCount || loading"
:label="t('storage.backup.syncSelectedAction')"
@click="openSync"
/>
<div class="flex gap-0.5 rounded-lg border border-default p-0.5">
<UButton
icon="i-lucide-layout-grid"
size="xs"
:variant="viewMode === 'grid' ? 'solid' : 'ghost'"
:color="viewMode === 'grid' ? 'primary' : 'neutral'"
:aria-label="t('stats.viewGrid')"
@click="setViewMode('grid')"
/>
<UButton
icon="i-lucide-list"
size="xs"
:variant="viewMode === 'list' ? 'solid' : 'ghost'"
:color="viewMode === 'list' ? 'primary' : 'neutral'"
:aria-label="t('stats.viewList')"
@click="setViewMode('list')"
/>
</div>
</div>
</div>
<div
v-if="loading && !items.length"
class="grid gap-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4"
>
<USkeleton
v-for="n in 8"
:key="n"
class="aspect-[4/3] rounded-xl"
/>
</div>
<p
v-else-if="!items.length"
class="rounded-lg border border-default py-10 text-center text-sm text-muted"
>
{{ activeSearch ? t('library.searchEmpty') : t('library.video.empty') }}
</p>
<div
v-else-if="viewMode === 'grid'"
class="grid gap-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4"
>
<VideoCard
v-for="item in items"
:key="item.key"
:item="item"
:link="videoLink(item)"
:thumb-link="thumbLink(item)"
:download-link="downloadUrl(item)"
selectable
:selected="selectedKeys.has(item.key)"
@update:selected="toggleSelected(item.key, $event)"
@play="openPreview(item)"
@download="requestDownload(item)"
@delete="requestDelete(item)"
/>
</div>
<div
v-else
class="overflow-hidden rounded-xl border border-default bg-default"
>
<div class="overflow-x-auto">
<table class="min-w-full text-xs">
<thead class="border-b border-default bg-elevated/40 text-muted">
<tr>
<th class="w-10 px-3 py-2.5 text-left font-medium">
<UCheckbox
:model-value="allSelected"
:aria-label="t('stats.selectAll')"
@update:model-value="toggleSelectAll"
/>
</th>
<th class="min-w-[14rem] px-3 py-2.5 text-left font-medium">
{{ t('stats.colFilename') }}
</th>
<th class="hidden w-24 px-3 py-2.5 text-left font-medium sm:table-cell">
{{ t('stats.colSource') }}
</th>
<th class="w-20 px-3 py-2.5 text-left font-medium">
{{ t('stats.colSize') }}
</th>
<th class="hidden w-28 px-3 py-2.5 text-left font-medium md:table-cell">
{{ t('stats.colType') }}
</th>
<th class="hidden w-40 px-3 py-2.5 text-left font-medium lg:table-cell">
{{ t('stats.colUploadedAt') }}
</th>
<th class="hidden min-w-[9rem] px-3 py-2.5 text-left font-medium md:table-cell">
{{ t('tags.column') }}
</th>
<th class="w-32 px-3 py-2.5 text-right font-medium">
{{ t('stats.colActions') }}
</th>
</tr>
</thead>
<tbody class="divide-y divide-default">
<tr
v-for="item in items"
:key="item.key"
class="transition-colors hover:bg-elevated/50"
:class="selectedKeys.has(item.key) ? 'bg-primary/5' : ''"
>
<td class="px-3 py-2">
<UCheckbox
:model-value="selectedKeys.has(item.key)"
@update:model-value="toggleSelected(item.key, $event === true)"
/>
</td>
<td class="px-3 py-2.5">
<div class="flex min-w-0 items-center gap-3">
<button
type="button"
class="relative aspect-video w-12 shrink-0 overflow-hidden rounded-md border border-default bg-muted"
:aria-label="t('library.play')"
@click="openPreview(item)"
>
<VideoThumbnail
:src="thumbLink(item)"
:alt="displayName(item)"
/>
</button>
<p
class="min-w-0 flex-1 truncate text-highlighted"
:title="displayName(item)"
>
{{ displayName(item) }}
</p>
<UBadge
v-if="item.hlsUrl"
color="success"
variant="subtle"
size="xs"
class="shrink-0"
>
m3u8
</UBadge>
<UBadge
v-else-if="item.transcodeStatus === 'failed'"
color="error"
variant="subtle"
size="xs"
class="shrink-0"
:title="transcodeFailureLabel(item.transcodeError)"
>
{{ transcodeFailureLabel(item.transcodeError) }}
</UBadge>
</div>
</td>
<td class="hidden px-3 py-2 sm:table-cell">
<span
v-if="item.uploadSource"
class="inline-flex items-center gap-1 rounded-md border border-default px-1.5 py-0.5 text-[11px] leading-4 text-muted"
>
<UIcon
:name="item.uploadSource === 'api' ? 'i-lucide-code-xml' : 'i-lucide-globe'"
class="size-3 shrink-0"
/>
{{ item.uploadSource === 'api' ? t('stats.tagApi') : t('stats.tagWeb') }}
</span>
<span
v-else
class="text-muted"
>—</span>
</td>
<td class="px-3 py-2 tabular-nums text-muted">
{{ formatFileSize(item.size) }}
</td>
<td class="hidden px-3 py-2 md:table-cell">
<span
v-if="item.storage"
class="inline-flex items-center gap-1 rounded-md border border-default px-1.5 py-0.5 text-[11px] leading-4 text-muted"
>
<UIcon
:name="item.storage.type === 'local' ? 'i-lucide-hard-drive' : 'i-lucide-cloud'"
class="size-3 shrink-0"
/>
{{ item.storage.name }}
</span>
<span
v-else
class="text-muted"
>—</span>
</td>
<td class="hidden px-3 py-2 text-muted lg:table-cell">
{{ uploadedLabel(item) }}
</td>
<td class="hidden px-3 py-2 md:table-cell">
<TagBadgeList
:tags="item.tags"
compact
:empty-label="t('tags.unlabeled')"
/>
</td>
<td class="px-3 py-2">
<div class="flex items-center justify-end gap-1">
<CopyButton
icon-only
icon="i-lucide-link"
:label="t('library.copyVideoLink')"
:value="videoLink(item)"
/>
<CopyButton
icon-only
icon="i-lucide-image"
:label="t('library.copyThumbLink')"
:value="thumbLink(item)"
/>
<UButton
v-if="!item.sourceDeleted"
size="xs"
variant="ghost"
color="neutral"
icon="i-lucide-download"
:to="downloadUrl(item)"
target="_blank"
rel="noopener"
:aria-label="t('files.download')"
/>
<UButton
v-else-if="item.hlsUrl"
size="xs"
variant="ghost"
color="neutral"
icon="i-lucide-download"
:aria-label="t('library.video.downloadMp4')"
@click="requestDownload(item)"
/>
<UButton
size="xs"
variant="ghost"
color="error"
icon="i-lucide-trash-2"
:aria-label="t('common.delete')"
@click="requestDelete(item)"
/>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<PaginationBar
v-if="!loading && total > 0"
:page="page"
:total-pages="totalPages"
:total="total"
:page-size="pageSize"
unit=""
:loading="loading"
@update:page="handlePageChange"
@update:page-size="handlePageSizeChange"
/>
</section>
<DeleteConfirmModal
v-model:open="deleteModalOpen"
:count="deleteTargetKeys.length"
:loading="deleting"
kind="video"
@confirm="confirmDelete"
/>
<BatchTagModal
v-model:open="batchTagOpen"
:tags="tagItems"
:selected-count="selectedCount"
@confirm="confirmBatchTags"
/>
<SiteSyncSelectedDialog
v-model:open="syncOpen"
:keys="syncKeys"
scope="video"
/>
<VideoPreviewModal
v-model:open="previewOpen"
:item="previewItem"
:link="previewItem ? videoLink(previewItem) : ''"
:thumb-link="previewItem ? thumbLink(previewItem) : ''"
:download-link="previewItem ? downloadUrl(previewItem) : ''"
:deleting="deleting"
@download="requestDownload(previewItem)"
@delete="previewItem && requestDelete(previewItem)"
/>
<HlsDownloadModal
v-model:open="downloadOpen"
:item="downloadItem"
/>
</div>
</template>
+533
View File
@@ -0,0 +1,533 @@
<script setup lang="ts">
import type { FileItem } from '~/types/file'
import type { CopyFormat } from '~/composables/useUploadPreferences'
const props = withDefaults(defineProps<{
open: boolean
item: FileItem | null
/** 已按访问模式拼接好的视频直链 */
link: string
/** 缩略图直链 */
thumbLink: string
/** 原始视频下载地址 */
downloadLink: string
deleting?: boolean
allowDelete?: boolean
}>(), {
allowDelete: true
})
const emit = defineEmits<{
'update:open': [value: boolean]
'delete': []
'download': []
}>()
const toast = useToast()
const { formatFileSize } = useFileSize()
const { t, locale } = useI18n()
const transcodeFailureLabel = useTranscodeFailureLabel()
const { copyFormat } = useUploadPreferences('video')
const videoEl = ref<HTMLVideoElement | null>(null)
const copyingUrl = ref(false)
const embedFormat = ref<CopyFormat>('url')
let hlsInstance: { destroy: () => void } | null = null
const embedFormatItems = computed(() => [
{ label: t('copy.url'), value: 'url' as const },
{ label: t('copy.markdown'), value: 'markdown' as const },
{ label: t('copy.html'), value: 'html' as const },
{ label: t('copy.bbcode'), value: 'bbcode' as const }
])
/** 转码完成后按 HLS 播放列表展示 */
const displayName = computed(() => {
const item = props.item
if (!item) return '—'
if (!item.hlsUrl) return item.originalName
const dot = item.originalName.lastIndexOf('.')
const base = dot > 0 ? item.originalName.slice(0, dot) : item.originalName
return `${base}.m3u8`
})
const uploadedLabel = computed(() => {
const item = props.item
if (!item) return '—'
try {
return new Date(item.uploadedAt).toLocaleString(locale.value, { hour12: false })
} catch {
return item.uploadedAt
}
})
const sizeLabel = computed(() => {
const item = props.item
if (!item) return '—'
return t('image.previewSizeDetail', {
size: formatFileSize(item.size),
bytes: item.size.toLocaleString(locale.value)
})
})
const transcodeLabel = computed(() => {
const item = props.item
if (!item) return '—'
switch (item.transcodeStatus) {
case 'pending':
return t('library.transcodePending')
case 'running':
return `${t('library.transcoding')} ${item.transcodeProgress ?? 0}%`
case 'done':
return t('library.video.transcodeDone')
case 'failed':
return transcodeFailureLabel(item.transcodeError)
default:
return t('library.video.transcodeNone')
}
})
/** 转码完成后条目实际代表 HLS,路径与类型都按播放列表展示 */
const storagePath = computed(() => props.item?.hlsKey ?? props.item?.key ?? '—')
const contentTypeLabel = computed(() =>
props.item?.hlsKey ? 'application/vnd.apple.mpegurl' : props.item?.contentType ?? '—'
)
const previewValue = computed(() => {
if (!props.item) return ''
switch (embedFormat.value) {
case 'markdown':
return `[${displayName.value}](${props.link})`
case 'html':
return `<a href="${props.link}">${displayName.value}</a>`
case 'bbcode':
return `[url=${props.link}]${displayName.value}[/url]`
default:
return props.link
}
})
const copySuccessTitle = computed(() => {
switch (embedFormat.value) {
case 'markdown':
return t('copy.copiedMarkdown')
case 'html':
return t('copy.copiedHtml')
case 'bbcode':
return t('copy.copiedBbcode')
default:
return t('copy.copiedUrl')
}
})
const storageIcon = computed(() =>
props.item?.storage?.type === 'local' ? 'i-lucide-hard-drive' : 'i-lucide-cloud'
)
const uploadSourceTag = computed(() => {
switch (props.item?.uploadSource) {
case 'web':
return t('stats.tagWeb')
case 'api':
return t('stats.tagApi')
default:
return null
}
})
const uploadSourceIcon = computed(() => {
switch (props.item?.uploadSource) {
case 'web':
return 'i-lucide-globe'
case 'api':
return 'i-lucide-code-xml'
default:
return null
}
})
function destroyPlayer() {
if (hlsInstance) {
hlsInstance.destroy()
hlsInstance = null
}
if (videoEl.value) {
videoEl.value.removeAttribute('src')
videoEl.value.load()
}
}
async function attachPlayerSource() {
const item = props.item
const element = videoEl.value
if (!item || !element) return
element.src = props.link
if (!item.hlsUrl || element.canPlayType('application/vnd.apple.mpegurl')) return
const { default: Hls } = await import('hls.js')
if (!Hls.isSupported()) return
const hls = new Hls()
hls.on(Hls.Events.ERROR, (_event, data) => {
if (!data.fatal) return
// 切片缺失(例如从备份恢复)时回退播放原视频
hls.destroy()
hlsInstance = null
element.src = props.downloadLink
})
hls.loadSource(props.link)
hls.attachMedia(element)
hlsInstance = hls
}
watch(() => props.open, async (open) => {
if (!open) {
destroyPlayer()
return
}
embedFormat.value = copyFormat.value
await nextTick()
await attachPlayerSource()
})
onUnmounted(destroyPlayer)
function selectEmbedFormat(value: CopyFormat) {
embedFormat.value = value
copyFormat.value = value
}
async function copyText(text: string, successTitle: string) {
if (!text) return
try {
if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(text)
toast.add({ title: successTitle, color: 'success' })
return
}
} catch {
// 回退方案见下
}
const textarea = document.createElement('textarea')
textarea.value = text
textarea.setAttribute('readonly', '')
textarea.style.position = 'fixed'
textarea.style.left = '-9999px'
document.body.appendChild(textarea)
textarea.select()
let ok = false
try {
ok = document.execCommand('copy')
} catch {
ok = false
} finally {
document.body.removeChild(textarea)
}
toast.add({
title: ok ? successTitle : t('copy.failedManual'),
color: ok ? 'success' : 'error'
})
}
async function copyLink() {
if (!props.link || copyingUrl.value) return
copyingUrl.value = true
try {
await copyText(props.link, t('copy.copiedUrl'))
} finally {
copyingUrl.value = false
}
}
function openInNewTab() {
if (!props.link) return
window.open(props.link, '_blank', 'noopener,noreferrer')
}
function downloadOriginal() {
if (!props.downloadLink) return
const link = document.createElement('a')
link.href = props.downloadLink
link.download = props.item?.originalName || 'video'
link.rel = 'noopener'
link.click()
}
</script>
<template>
<UModal
:open="open && !!item"
:title="t('library.video.previewTitle')"
:description="t('library.video.previewSubtitle')"
:ui="{ content: 'max-w-5xl' }"
@update:open="emit('update:open', $event)"
>
<template
v-if="item"
#body
>
<div class="grid gap-6 lg:grid-cols-[minmax(0,1fr)_minmax(0,1.15fr)] lg:items-start">
<div class="flex items-center justify-center overflow-hidden rounded-xl border border-default bg-black p-2">
<video
ref="videoEl"
controls
autoplay
playsinline
class="max-h-[min(60vh,24rem)] w-full rounded-lg bg-black"
/>
</div>
<div class="flex min-w-0 flex-col gap-5">
<dl class="divide-y divide-default text-sm">
<div class="flex gap-4 py-2.5 first:pt-0">
<dt class="w-20 shrink-0 text-muted">
{{ t('image.previewOriginalName') }}
</dt>
<dd class="min-w-0 flex-1 break-all font-medium">
{{ displayName }}
</dd>
</div>
<div class="flex gap-4 py-2.5">
<dt class="w-20 shrink-0 text-muted">
{{ t('image.previewUploadedAt') }}
</dt>
<dd class="min-w-0 flex-1">
{{ uploadedLabel }}
</dd>
</div>
<div class="flex gap-4 py-2.5">
<dt class="w-20 shrink-0 text-muted">
{{ t('image.previewSize') }}
</dt>
<dd class="min-w-0 flex-1">
{{ sizeLabel }}
</dd>
</div>
<div class="flex gap-4 py-2.5">
<dt class="w-20 shrink-0 text-muted">
{{ t('image.previewContentType') }}
</dt>
<dd class="min-w-0 flex-1">
{{ contentTypeLabel }}
</dd>
</div>
<div class="flex gap-4 py-2.5">
<dt class="w-20 shrink-0 text-muted">
{{ t('library.video.previewTranscode') }}
</dt>
<dd class="min-w-0 flex-1">
<UBadge
:color="item.hlsUrl ? 'success' : item.transcodeStatus === 'failed' ? 'error' : 'neutral'"
variant="subtle"
size="xs"
>
{{ transcodeLabel }}
</UBadge>
</dd>
</div>
<div
v-if="item.storage"
class="flex gap-4 py-2.5"
>
<dt class="w-20 shrink-0 text-muted">
{{ t('image.previewStorage') }}
</dt>
<dd class="min-w-0 flex-1">
<span class="inline-flex items-center gap-1.5">
<UIcon
:name="storageIcon"
class="size-3.5 shrink-0 text-muted"
/>
{{ item.storage.name }}
</span>
</dd>
</div>
<div
v-if="uploadSourceTag"
class="flex gap-4 py-2.5"
>
<dt class="w-20 shrink-0 text-muted">
{{ t('image.previewUploadSource') }}
</dt>
<dd class="min-w-0 flex-1">
<span
v-if="uploadSourceIcon"
class="inline-flex items-center gap-1 rounded-md border border-default px-2 py-0.5 text-xs text-muted"
>
<UIcon
:name="uploadSourceIcon"
class="size-3 shrink-0"
/>
{{ uploadSourceTag }}
</span>
</dd>
</div>
<div
v-if="item.owner"
class="flex gap-4 py-2.5"
>
<dt class="w-20 shrink-0 text-muted">
{{ t('image.previewOwner') }}
</dt>
<dd class="min-w-0 flex-1">
{{ item.owner.username }}
</dd>
</div>
<div class="flex gap-4 py-2.5">
<dt class="w-20 shrink-0 text-muted">
{{ t('tags.column') }}
</dt>
<dd class="min-w-0 flex-1">
<div
v-if="item.tags?.length"
class="flex flex-wrap gap-1.5"
>
<TagBadge
v-for="tag in item.tags"
:key="tag.id"
:tag="tag"
/>
</div>
<span
v-else
class="inline-flex rounded-md border border-dashed border-default px-2 py-0.5 text-xs text-muted"
>
{{ t('tags.unlabeled') }}
</span>
</dd>
</div>
<div class="flex gap-4 py-2.5 last:pb-0">
<dt class="w-20 shrink-0 text-muted">
{{ t('library.video.previewKey') }}
</dt>
<dd class="flex min-w-0 flex-1 items-start gap-2">
<span class="min-w-0 flex-1 break-all font-mono text-xs text-dimmed">
{{ storagePath }}
</span>
<CopyButton
icon="i-lucide-copy"
icon-only
variant="ghost"
color="neutral"
class="shrink-0"
:label="t('common.copy')"
:value="storagePath"
:success-title="t('copy.copied')"
/>
</dd>
</div>
</dl>
<div class="border-t border-default pt-4">
<p class="mb-2.5 text-sm font-medium">
{{ t('image.previewLinks') }}
</p>
<div class="flex gap-1 rounded-lg border border-default bg-default p-0.5">
<UButton
v-for="option in embedFormatItems"
:key="option.value"
size="xs"
class="flex-1 justify-center"
:variant="embedFormat === option.value ? 'solid' : 'ghost'"
:color="embedFormat === option.value ? 'primary' : 'neutral'"
:label="option.label"
@click="selectEmbedFormat(option.value)"
/>
</div>
<div class="mt-2 flex items-center gap-2">
<UInput
:model-value="previewValue"
readonly
size="sm"
class="min-w-0 flex-1 font-mono text-xs"
/>
<CopyButton
:label="t('common.copy')"
:value="previewValue"
:success-title="copySuccessTitle"
/>
</div>
<div
v-if="thumbLink"
class="mt-3"
>
<p class="mb-1.5 text-xs text-muted">
{{ t('library.video.previewThumbLink') }}
</p>
<div class="flex items-center gap-2">
<UInput
:model-value="thumbLink"
readonly
size="sm"
class="min-w-0 flex-1 font-mono text-xs"
/>
<CopyButton
:label="t('common.copy')"
:value="thumbLink"
:success-title="t('copy.copiedUrl')"
/>
</div>
</div>
</div>
</div>
</div>
</template>
<template
v-if="item"
#footer
>
<div class="flex w-full flex-wrap justify-end gap-1.5">
<UButton
:label="t('image.previewOpen')"
icon="i-lucide-external-link"
color="neutral"
variant="outline"
size="sm"
@click="openInNewTab"
/>
<UButton
v-if="!item.sourceDeleted"
:label="t('image.previewDownload')"
icon="i-lucide-download"
color="neutral"
variant="outline"
size="sm"
@click="downloadOriginal"
/>
<UButton
v-else-if="item.hlsUrl"
:label="t('library.video.downloadMp4')"
icon="i-lucide-download"
color="neutral"
variant="outline"
size="sm"
@click="emit('download')"
/>
<UButton
:label="t('image.previewCopyLink')"
icon="i-lucide-link"
color="primary"
size="sm"
:loading="copyingUrl"
@click="copyLink"
/>
<UButton
v-if="allowDelete"
:label="t('common.delete')"
icon="i-lucide-trash-2"
color="error"
variant="outline"
size="sm"
:loading="deleting"
@click="emit('delete')"
/>
</div>
</template>
</UModal>
</template>
+30
View File
@@ -0,0 +1,30 @@
<script setup lang="ts">
const props = defineProps<{
src?: string
alt: string
}>()
const failed = ref(false)
watch(() => props.src, () => {
failed.value = false
})
</script>
<template>
<img
v-if="src && !failed"
:src="src"
:alt="alt"
loading="lazy"
class="size-full object-cover object-center"
@error="failed = true"
>
<img
v-else
src="/video-placeholder.svg"
:alt="alt"
loading="lazy"
class="size-full object-cover object-center"
>
</template>
+398
View File
@@ -0,0 +1,398 @@
<script setup lang="ts">
import type { ApiDocEndpoint, ApiDocParam } from '~/composables/useApiDocs'
const props = defineProps<{
endpoint: ApiDocEndpoint
token: string
}>()
const { t } = useI18n()
const toast = useToast()
const authToken = ref('')
const paramValues = ref<Record<string, string>>({})
const fileValues = ref<Record<string, File | null>>({})
const fileInputRefs = ref<Record<string, HTMLInputElement | null>>({})
const sending = ref(false)
const responseStatus = ref<number | null>(null)
const responseBody = ref('')
const responseError = ref(false)
function isDebugParam(param: ApiDocParam) {
if (param.name === 'token' && props.endpoint.requiresAuth) {
return false
}
return true
}
const debugQueryParams = computed(() =>
props.endpoint.params.filter(p => p.location === 'query' && isDebugParam(p))
)
const debugBodyParams = computed(() =>
props.endpoint.params.filter(p =>
(p.location === 'form-data' || p.location === 'body') && isDebugParam(p)
)
)
const showAuthHeader = computed(() => props.endpoint.requiresAuth)
watch(
() => props.endpoint.id,
() => {
resetForm()
},
{ immediate: true }
)
watch(
() => props.token,
(value) => {
if (value) {
authToken.value = value
if (!props.endpoint.requiresAuth && 'token' in paramValues.value) {
paramValues.value.token = value
}
}
},
{ immediate: true }
)
function resetForm() {
const values: Record<string, string> = {}
const files: Record<string, File | null> = {}
for (const param of props.endpoint.params) {
if (!isDebugParam(param)) continue
if (param.type === 'file') {
files[param.name] = null
} else if (param.name === 'kind') {
values[param.name] = props.endpoint.category === 'video' ? 'video' : 'file'
} else if (param.name === 'keys') {
values[param.name] = props.endpoint.category === 'video'
? '["videos/2026/10/example.mp4"]'
: props.endpoint.category === 'file'
? '["files/2026/10/example.pdf"]'
: '["images/2026/08/example.webp"]'
} else if (param.name === 'token' && props.token) {
values[param.name] = props.token
} else {
values[param.name] = ''
}
}
paramValues.value = values
fileValues.value = files
responseStatus.value = null
responseBody.value = ''
responseError.value = false
}
function fileLabel(name: string) {
return fileValues.value[name]?.name ?? t('api.debug.noFileSelected')
}
function fileAccept() {
if (props.endpoint.category === 'video') return 'video/*'
if (props.endpoint.category === 'file') return undefined
return 'image/*'
}
function triggerFilePick(name: string) {
fileInputRefs.value[name]?.click()
}
function onFileChange(name: string, event: Event) {
const input = event.target as HTMLInputElement
fileValues.value[name] = input.files?.[0] ?? null
}
function isParamFilled(param: ApiDocParam) {
if (param.type === 'file') {
return fileValues.value[param.name] != null
}
const value = paramValues.value[param.name]?.trim() ?? ''
if (!value) return false
if (param.location === 'body' && param.name === 'keys') {
try {
const parsed = JSON.parse(value) as unknown
return Array.isArray(parsed) && parsed.length > 0
} catch {
return false
}
}
return true
}
const canSendRequest = computed(() => {
if (sending.value) return false
if (showAuthHeader.value && !authToken.value.trim()) return false
const params = [...debugQueryParams.value, ...debugBodyParams.value]
return params.every(param => !param.required || isParamFilled(param))
})
function buildUrl() {
const url = new URL(props.endpoint.path, window.location.origin)
for (const param of debugQueryParams.value) {
const value = paramValues.value[param.name]?.trim()
if (value) {
url.searchParams.set(param.name, value)
}
}
return url.toString()
}
async function sendRequest() {
if (!canSendRequest.value) return
sending.value = true
responseStatus.value = null
responseBody.value = ''
responseError.value = false
try {
const headers: Record<string, string> = {}
if (showAuthHeader.value && authToken.value.trim()) {
headers['Auth-Token'] = authToken.value.trim()
}
let body: BodyInit | undefined
if (debugBodyParams.value.some(p => p.location === 'form-data')) {
const formData = new FormData()
for (const param of debugBodyParams.value) {
if (param.location !== 'form-data') continue
if (param.type === 'file') {
const file = fileValues.value[param.name]
if (file) formData.append(param.name, file)
} else {
const value = paramValues.value[param.name]?.trim()
if (value) formData.append(param.name, value)
}
}
body = formData
} else if (debugBodyParams.value.some(p => p.location === 'body')) {
headers['Content-Type'] = 'application/json'
try {
const payload: Record<string, unknown> = {}
for (const param of debugBodyParams.value) {
if (param.location !== 'body') continue
const value = paramValues.value[param.name]?.trim()
if (!value) continue
payload[param.name] = param.name === 'keys' ? JSON.parse(value) : value
}
body = JSON.stringify(payload)
} catch {
toast.add({ title: t('api.debug.invalidJson'), color: 'error' })
sending.value = false
return
}
}
const response = await fetch(buildUrl(), {
method: props.endpoint.method,
headers,
body,
credentials: 'include'
})
responseStatus.value = response.status
const text = await response.text()
try {
responseBody.value = JSON.stringify(JSON.parse(text), null, 2)
} catch {
responseBody.value = text
}
responseError.value = !response.ok
} catch {
responseError.value = true
responseBody.value = t('api.debug.networkError')
toast.add({ title: t('api.debug.sendFailed'), color: 'error' })
} finally {
sending.value = false
}
}
const responseScrollable = computed(() => responseBody.value.split('\n').length > 14)
</script>
<template>
<aside class="flex w-full shrink-0 flex-col border-t border-default bg-default lg:w-72 lg:border-t-0 lg:border-l xl:w-80">
<div class="flex items-start gap-2 border-b border-default px-5 py-4 sm:px-6">
<UIcon
name="i-lucide-flask-conical"
class="mt-0.5 size-5 shrink-0 text-primary"
/>
<div class="min-w-0">
<h3 class="text-base font-semibold">
{{ t('api.debug.title') }}
</h3>
<div class="mt-1.5 flex flex-wrap items-center gap-2">
<ApiMethodBadge
:method="endpoint.method"
size="sm"
/>
<code class="truncate font-mono text-xs text-muted">
{{ endpoint.path }}
</code>
</div>
</div>
</div>
<div class="flex flex-1 flex-col">
<div class="space-y-3 p-5 sm:p-6">
<section
v-if="showAuthHeader"
class="rounded-xl border border-default p-3 sm:p-4"
>
<h4 class="mb-3 text-xs font-semibold text-highlighted">
{{ t('api.debug.headers') }}
</h4>
<div class="space-y-1.5">
<label class="text-xs text-muted">
Auth-Token
<span class="text-error">*</span>
</label>
<UInput
v-model="authToken"
size="sm"
class="font-mono text-xs"
:placeholder="t('api.debug.tokenPlaceholder')"
/>
</div>
</section>
<section
v-if="debugQueryParams.length"
class="rounded-xl border border-default p-3 sm:p-4"
>
<h4 class="mb-3 text-xs font-semibold text-highlighted">
{{ t('api.debug.query') }}
</h4>
<div class="space-y-3">
<div
v-for="param in debugQueryParams"
:key="param.name"
class="space-y-1.5"
>
<label class="text-xs text-muted">
{{ param.name }}
<span
v-if="param.required"
class="text-error"
>*</span>
</label>
<UInput
v-model="paramValues[param.name]"
size="sm"
class="font-mono text-xs"
/>
</div>
</div>
</section>
<section
v-if="debugBodyParams.length"
class="rounded-xl border border-default p-3 sm:p-4"
>
<h4 class="mb-3 text-xs font-semibold text-highlighted">
{{ t('api.debug.body') }}
</h4>
<div class="space-y-3">
<div
v-for="param in debugBodyParams"
:key="param.name"
class="space-y-1.5"
>
<label class="text-xs text-muted">
{{ param.name }}
<span
v-if="param.required"
class="text-error"
>*</span>
</label>
<div
v-if="param.type === 'file'"
class="flex items-center gap-2"
>
<input
:ref="(el) => { fileInputRefs[param.name] = el as HTMLInputElement }"
type="file"
:accept="fileAccept()"
class="hidden"
@change="onFileChange(param.name, $event)"
>
<UButton
size="xs"
variant="outline"
color="neutral"
:label="t('api.debug.selectFile')"
@click="triggerFilePick(param.name)"
/>
<span class="min-w-0 truncate text-xs text-muted">
{{ fileLabel(param.name) }}
</span>
</div>
<UTextarea
v-else-if="param.location === 'body'"
v-model="paramValues[param.name]"
:rows="3"
size="sm"
class="font-mono text-xs"
/>
<UInput
v-else
v-model="paramValues[param.name]"
size="sm"
class="font-mono text-xs"
/>
</div>
</div>
</section>
<UButton
icon="i-lucide-send"
:label="t('api.debug.send')"
color="primary"
block
:loading="sending"
:disabled="!canSendRequest"
@click="sendRequest"
/>
<section
v-if="responseStatus !== null || responseBody"
class="overflow-hidden rounded-xl border border-default"
>
<div class="flex items-center justify-between border-b border-default px-3 py-2">
<span class="text-xs font-semibold text-highlighted">
{{ t('api.debug.response') }}
</span>
<UBadge
v-if="responseStatus !== null"
:color="responseError ? 'error' : 'success'"
variant="subtle"
size="xs"
>
{{ responseStatus }}
</UBadge>
</div>
<pre
class="api-debug-response bg-neutral-950 p-3 text-xs leading-relaxed text-neutral-100"
:class="responseScrollable ? 'max-h-64 overflow-y-auto' : ''"
><code class="font-mono whitespace-pre">{{ responseBody }}</code></pre>
</section>
</div>
<div class="mt-auto border-t border-default p-5 sm:p-6">
<h4 class="mb-2 text-xs font-semibold">
{{ t('api.debug.notesTitle') }}
</h4>
<ul class="space-y-1 text-xs leading-relaxed text-muted">
<li>{{ t('api.debug.noteSuccess') }}</li>
<li>{{ t('api.debug.noteError') }}</li>
<li>{{ t('api.debug.noteLimit') }}</li>
</ul>
</div>
</div>
</aside>
</template>
+128
View File
@@ -0,0 +1,128 @@
<script setup lang="ts">
import type { ApiDocEndpoint } from '~/composables/useApiDocs'
defineProps<{
endpoint: ApiDocEndpoint
}>()
const { t } = useI18n()
</script>
<template>
<section class="p-5 sm:p-6">
<h2 class="text-base font-semibold">
{{ t(endpoint.titleKey) }}
</h2>
<div class="mt-2 flex flex-wrap items-center gap-2">
<ApiMethodBadge
:method="endpoint.method"
size="sm"
/>
<code class="rounded-md border border-primary/20 bg-primary/10 px-2 py-0.5 font-mono text-sm font-medium text-primary">
{{ endpoint.path }}
</code>
</div>
<p class="mt-3 text-sm leading-relaxed text-muted">
{{ t(endpoint.descKey) }}
</p>
<div
v-if="endpoint.params.length"
class="mt-5 overflow-hidden rounded-xl border border-default"
>
<div class="border-b border-default bg-muted/20 px-4 py-2.5">
<h3 class="text-sm font-medium">
{{ t('api.requestParams') }}
</h3>
</div>
<div class="overflow-x-auto">
<table class="w-full min-w-[36rem] table-fixed text-left text-sm">
<colgroup>
<col class="w-[5.5rem]">
<col class="w-[3.5rem]">
<col class="w-[5.75rem]">
<col class="w-[3.25rem]">
<col>
</colgroup>
<thead>
<tr class="border-b border-default text-xs text-muted">
<th class="px-4 py-2.5 font-medium">
{{ t('api.paramName') }}
</th>
<th class="whitespace-nowrap px-4 py-2.5 font-medium">
{{ t('api.paramType') }}
</th>
<th class="whitespace-nowrap px-4 py-2.5 font-medium">
{{ t('api.paramLocation') }}
</th>
<th class="whitespace-nowrap px-4 py-2.5 font-medium">
{{ t('api.paramRequired') }}
</th>
<th class="px-4 py-2.5 font-medium">
{{ t('api.paramDescription') }}
</th>
</tr>
</thead>
<tbody>
<tr
v-for="param in endpoint.params"
:key="param.name"
class="border-b border-default last:border-b-0"
>
<td class="whitespace-nowrap px-4 py-2.5 font-mono text-xs">
{{ param.name }}
<span
v-if="param.required"
class="text-error"
>*</span>
</td>
<td class="whitespace-nowrap px-4 py-2.5 text-muted">
{{ param.type }}
</td>
<td class="whitespace-nowrap px-4 py-2.5 text-muted">
{{ param.location }}
</td>
<td class="whitespace-nowrap px-4 py-2.5">
<UBadge
:color="param.required ? 'primary' : 'neutral'"
variant="subtle"
size="xs"
>
{{ param.required ? t('api.required') : t('api.optional') }}
</UBadge>
</td>
<td class="px-4 py-2.5 leading-relaxed text-muted break-words">
{{ t(param.descriptionKey) }}
</td>
</tr>
</tbody>
</table>
</div>
</div>
<div class="mt-5 space-y-4">
<div>
<p class="mb-2 text-xs font-medium text-muted">
{{ t('api.requestExample') }}
</p>
<ApiCurlBlock
:code="endpoint.curl"
lang="cURL"
/>
</div>
<div>
<p class="mb-2 text-xs font-medium text-muted">
{{ t('api.responseExample') }}
<span class="font-normal">200 OK</span>
</p>
<ApiCurlBlock
:code="endpoint.responseExample"
lang="JSON"
:scrollable="false"
/>
</div>
</div>
</section>
</template>
+34
View File
@@ -0,0 +1,34 @@
<script setup lang="ts">
import type { ApiDocMethod } from '~/composables/useApiDocs'
const props = defineProps<{
method: ApiDocMethod
size?: 'xs' | 'sm'
}>()
const colorClass = computed(() => {
switch (props.method) {
case 'GET':
return 'bg-sky-500/15 text-sky-700 dark:text-sky-300'
case 'POST':
return 'bg-primary/15 text-primary'
case 'DELETE':
return 'bg-red-500/15 text-red-700 dark:text-red-300'
default:
return 'bg-muted text-muted'
}
})
const sizeClass = computed(() =>
props.size === 'sm' ? 'px-2 py-0.5 text-xs' : 'px-1.5 py-0.5 text-[10px]'
)
</script>
<template>
<span
class="inline-flex shrink-0 rounded font-mono font-semibold leading-none"
:class="[colorClass, sizeClass]"
>
{{ method }}
</span>
</template>
+188
View File
@@ -0,0 +1,188 @@
<script setup lang="ts">
import type { ApiDocCategory, ApiDocEndpoint } from '~/composables/useApiDocs'
const props = defineProps<{
endpoints: ApiDocEndpoint[]
activeId: string
}>()
const emit = defineEmits<{
select: [id: string]
}>()
const { t, locale } = useI18n()
const search = ref('')
const docsUrl = computed(() =>
locale.value === 'en'
? 'https://o96u.github.io/PicHost/en/guide/api.html'
: 'https://o96u.github.io/PicHost/guide/api.html'
)
const categories: { key: ApiDocCategory, labelKey: string }[] = [
{ key: 'image', labelKey: 'api.categories.image' },
{ key: 'video', labelKey: 'api.categories.video' },
{ key: 'file', labelKey: 'api.categories.file' },
{ key: 'extension', labelKey: 'api.categories.extension' },
{ key: 'system', labelKey: 'api.categories.system' }
]
const openCategories = ref<Set<ApiDocCategory>>(
new Set(
categories
.filter(category => category.key !== 'video' && category.key !== 'file')
.map(category => category.key)
)
)
const filteredEndpoints = computed(() => {
const q = search.value.trim().toLowerCase()
if (!q) return props.endpoints
return props.endpoints.filter((endpoint) => {
const title = t(endpoint.titleKey).toLowerCase()
const path = endpoint.path.toLowerCase()
return title.includes(q) || path.includes(q) || endpoint.method.toLowerCase().includes(q)
})
})
function endpointsForCategory(category: ApiDocCategory) {
return filteredEndpoints.value.filter(endpoint => endpoint.category === category)
}
function isCategoryOpen(category: ApiDocCategory) {
return openCategories.value.has(category)
}
function toggleCategory(category: ApiDocCategory) {
const next = new Set(openCategories.value)
if (next.has(category)) next.delete(category)
else next.add(category)
openCategories.value = next
}
function onSelect(id: string) {
emit('select', id)
}
function onSearchKeydown(event: KeyboardEvent) {
if (event.key === 'Escape') {
search.value = ''
event.preventDefault()
}
}
onMounted(() => {
const onGlobalKeydown = (event: KeyboardEvent) => {
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'k') {
event.preventDefault()
document.getElementById('api-endpoint-search')?.focus()
}
}
window.addEventListener('keydown', onGlobalKeydown)
onUnmounted(() => window.removeEventListener('keydown', onGlobalKeydown))
})
watch(search, (value) => {
if (!value.trim()) return
const next = new Set(openCategories.value)
for (const category of categories) {
if (endpointsForCategory(category.key).length) next.add(category.key)
}
openCategories.value = next
})
</script>
<template>
<aside class="flex w-full shrink-0 flex-col border-b border-default bg-default lg:w-52 lg:border-b-0 lg:border-r lg:bg-neutral-50 dark:lg:bg-neutral-900/30 xl:w-56">
<div class="border-b border-default p-3 sm:p-4">
<UInput
id="api-endpoint-search"
v-model="search"
:placeholder="t('api.searchPlaceholder')"
icon="i-lucide-search"
size="sm"
@keydown="onSearchKeydown"
>
<template #trailing>
<UKbd
size="sm"
class="hidden sm:inline-flex"
>
⌘K
</UKbd>
</template>
</UInput>
</div>
<nav class="flex-1 overflow-y-auto p-2 sm:p-3">
<div
v-for="category in categories"
:key="category.key"
class="mb-4"
>
<button
v-if="endpointsForCategory(category.key).length"
type="button"
class="mb-1.5 flex w-full items-center justify-between rounded-md px-2 py-1 text-xs font-medium text-muted transition-colors hover:bg-muted/40 hover:text-highlighted"
:aria-expanded="isCategoryOpen(category.key)"
@click="toggleCategory(category.key)"
>
<span>{{ t(category.labelKey) }}</span>
<UIcon
name="i-lucide-chevron-down"
class="size-3.5 transition-transform"
:class="isCategoryOpen(category.key) ? '' : '-rotate-90'"
/>
</button>
<ul
v-show="isCategoryOpen(category.key)"
class="space-y-0.5"
>
<li
v-for="endpoint in endpointsForCategory(category.key)"
:key="endpoint.id"
>
<button
type="button"
class="flex w-full items-center gap-2 rounded-lg px-2 py-2 text-left text-sm transition-colors"
:class="activeId === endpoint.id
? 'bg-primary/10 font-medium text-primary'
: 'text-muted hover:bg-muted/40 hover:text-highlighted'"
@click="onSelect(endpoint.id)"
>
<ApiMethodBadge :method="endpoint.method" />
<span class="min-w-0 truncate">{{ t(endpoint.titleKey) }}</span>
</button>
</li>
</ul>
</div>
</nav>
<div class="hidden border-t border-default p-3 sm:p-4 lg:block">
<div class="rounded-xl border border-primary/15 bg-primary/5 p-3">
<div class="flex items-start gap-2.5">
<UIcon
name="i-lucide-book-open"
class="mt-0.5 size-4 shrink-0 text-primary"
/>
<div class="min-w-0 space-y-2">
<p class="text-xs font-medium text-highlighted">
{{ t('api.helpTitle') }}
</p>
<p class="text-xs leading-relaxed text-muted">
{{ t('api.helpSubtitle') }}
</p>
<UButton
:label="t('api.viewDocs')"
icon="i-lucide-external-link"
size="xs"
variant="outline"
color="neutral"
:to="docsUrl"
target="_blank"
rel="noopener noreferrer"
/>
</div>
</div>
</div>
</div>
</aside>
</template>
+137
View File
@@ -0,0 +1,137 @@
<script setup lang="ts">
const props = defineProps<{
token: string
loading: boolean
regenerating: boolean
canRegenerate: boolean
tokenSource?: 'env' | 'db' | 'none'
envTokenOverride?: boolean
isAdmin?: boolean
}>()
const emit = defineEmits<{
regenerate: []
}>()
const { t } = useI18n()
const toast = useToast()
const tokenVisible = ref(false)
const copying = ref(false)
function toggleTokenVisible() {
tokenVisible.value = !tokenVisible.value
}
const displayValue = computed(() => {
if (!props.token) return t('common.notConfigured')
if (tokenVisible.value) return props.token
return '•'.repeat(Math.min(props.token.length, 40))
})
function tokenSourceLabel(source: 'env' | 'db' | 'none' | undefined) {
switch (source) {
case 'env':
return t('api.tokenSourceEnv')
case 'db':
return t('api.tokenSourceDb')
default:
return t('api.tokenSourceNone')
}
}
async function copyToken() {
if (!props.token || copying.value) return
copying.value = true
try {
await navigator.clipboard.writeText(props.token)
toast.add({ title: t('copy.copied'), color: 'success' })
} catch {
toast.add({ title: t('copy.failed'), color: 'error' })
} finally {
copying.value = false
}
}
</script>
<template>
<section class="border-b border-default">
<div class="flex items-center gap-2 border-b border-default px-5 py-4 sm:px-6">
<UIcon
name="i-lucide-key-round"
class="size-5 shrink-0 text-primary"
/>
<h2 class="text-base font-semibold">
{{ t('api.tokenTitle') }}
</h2>
</div>
<div class="space-y-4 p-5 sm:p-6">
<UAlert
v-if="envTokenOverride"
color="warning"
variant="subtle"
icon="i-lucide-triangle-alert"
:title="t('api.envOverrideTitle')"
:description="t('api.envOverrideDesc')"
/>
<UAlert
v-else-if="!token && !loading"
color="info"
variant="subtle"
icon="i-lucide-info"
:title="t('api.notConfiguredTitle')"
:description="t('api.notConfiguredDesc')"
/>
<div class="flex flex-col gap-2 sm:flex-row sm:items-stretch">
<UInput
:model-value="displayValue"
readonly
class="min-w-0 flex-1 font-mono text-xs sm:text-sm"
:loading="loading"
/>
<div class="flex gap-2">
<UButton
size="sm"
variant="outline"
color="neutral"
:label="tokenVisible ? t('api.hideToken') : t('api.showToken')"
@click="toggleTokenVisible"
/>
<UButton
size="sm"
variant="outline"
color="neutral"
:label="t('common.copy')"
:loading="copying"
:disabled="!token"
@click="copyToken"
/>
<UButton
icon="i-lucide-refresh-cw"
:label="t('api.regenerate')"
color="primary"
variant="soft"
size="sm"
:loading="regenerating"
:disabled="!canRegenerate"
@click="emit('regenerate')"
/>
</div>
</div>
<p
v-if="tokenSource"
class="text-xs text-muted"
>
{{ tokenSourceLabel(tokenSource) }}
· {{ t('api.tokenHeader') }}
<code class="font-mono">Auth-Token: &lt;token&gt;</code>
<template v-if="token && !envTokenOverride">
· {{ t('api.tokenInvalidate') }}
</template>
</p>
</div>
</section>
</template>
+210
View File
@@ -0,0 +1,210 @@
<script setup lang="ts">
const props = withDefaults(defineProps<{
searchQuery: string
storageBackend: string
storageBackendItems: Array<{ label: string, value: string }>
uploadSource: string
uploadSourceItems: Array<{ label: string, value: string }>
tagIds?: number[]
tagMode?: 'or' | 'and'
untaggedOnly?: boolean
tags?: Array<{ id: number, name: string, color: string, imageCount?: number }>
viewMode: 'grid' | 'list'
selectedCount?: number
loading?: boolean
showBatchDelete?: boolean
showBatchTags?: boolean
showBatchSync?: boolean
}>(), {
selectedCount: 0,
loading: false,
showBatchDelete: true,
showBatchTags: false,
showBatchSync: false,
tagIds: () => [],
tags: () => [],
tagMode: 'or',
untaggedOnly: false
})
const emit = defineEmits<{
'update:searchQuery': [value: string]
'update:storageBackend': [value: string]
'update:uploadSource': [value: string]
'update:tagIds': [value: number[]]
'update:tagMode': [value: 'or' | 'and']
'update:untaggedOnly': [value: boolean]
'update:viewMode': [mode: 'grid' | 'list']
'search': []
'reset': []
'batch-delete': []
'batch-tags': []
'batch-sync': []
'manage-tags': []
}>()
const { t } = useI18n()
function setViewMode(mode: 'grid' | 'list') {
if (mode === props.viewMode || props.loading) return
emit('update:viewMode', mode)
}
function viewButtonClass(active: boolean) {
return active
? 'border-primary bg-primary/10 text-primary'
: 'border-transparent bg-elevated text-muted hover:bg-muted hover:text-default'
}
</script>
<template>
<form
class="flex flex-nowrap items-center gap-2 overflow-x-auto pb-1"
@submit.prevent="emit('search')"
>
<UInput
:model-value="searchQuery"
icon="i-lucide-search"
:placeholder="t('stats.searchPlaceholder')"
class="w-40 shrink-0 sm:w-48"
size="sm"
:disabled="loading"
@update:model-value="emit('update:searchQuery', $event)"
/>
<USelect
:model-value="storageBackend"
:items="storageBackendItems"
class="w-28 shrink-0"
size="sm"
:aria-label="t('stats.storage')"
:disabled="loading"
@update:model-value="emit('update:storageBackend', $event)"
/>
<USelect
:model-value="uploadSource"
:items="uploadSourceItems"
class="w-28 shrink-0"
size="sm"
:aria-label="t('stats.filterSource')"
:disabled="loading"
@update:model-value="emit('update:uploadSource', $event)"
/>
<TagFilterSelect
:model-value="tagIds"
:tag-mode="tagMode"
:untagged-only="untaggedOnly"
:tags="tags"
:disabled="loading"
@update:model-value="emit('update:tagIds', $event)"
@update:tag-mode="emit('update:tagMode', $event)"
@update:untagged-only="emit('update:untaggedOnly', $event)"
@manage="emit('manage-tags')"
/>
<UButton
type="submit"
icon="i-lucide-search"
size="sm"
color="primary"
class="shrink-0"
:aria-label="t('common.search')"
:loading="loading"
>
{{ t('common.search') }}
</UButton>
<UButton
type="button"
icon="i-lucide-rotate-ccw"
size="sm"
variant="outline"
color="neutral"
class="shrink-0"
:aria-label="t('stats.resetFilters')"
:disabled="loading"
@click="emit('reset')"
>
{{ t('stats.resetFilters') }}
</UButton>
<UButton
v-if="showBatchTags"
type="button"
icon="i-lucide-tags"
size="sm"
variant="outline"
color="neutral"
class="shrink-0"
:disabled="!selectedCount || loading"
@click="emit('batch-tags')"
>
{{ t('tags.batchAdd') }}
</UButton>
<UButton
v-if="showBatchDelete"
type="button"
icon="i-lucide-trash-2"
size="sm"
variant="outline"
color="error"
class="shrink-0"
:disabled="!selectedCount || loading"
@click="emit('batch-delete')"
>
{{ t('stats.batchDelete') }}
</UButton>
<UButton
v-if="showBatchSync"
type="button"
icon="i-lucide-cloud-upload"
size="sm"
variant="outline"
color="neutral"
class="shrink-0"
:disabled="!selectedCount || loading"
@click="emit('batch-sync')"
>
{{ t('storage.backup.syncSelectedAction') }}
</UButton>
<div
class="inline-flex shrink-0 overflow-hidden rounded-md border border-default"
role="group"
:aria-label="t('stats.viewMode')"
>
<button
type="button"
class="inline-flex size-8 items-center justify-center border-r border-default transition-colors disabled:pointer-events-none disabled:opacity-40"
:class="viewButtonClass(viewMode === 'grid')"
:aria-label="t('stats.viewGrid')"
:aria-pressed="viewMode === 'grid'"
:disabled="loading"
@click="setViewMode('grid')"
>
<UIcon
name="i-lucide-layout-grid"
class="size-4"
/>
</button>
<button
type="button"
class="inline-flex size-8 items-center justify-center transition-colors disabled:pointer-events-none disabled:opacity-40"
:class="viewButtonClass(viewMode === 'list')"
:aria-label="t('stats.viewList')"
:aria-pressed="viewMode === 'list'"
:disabled="loading"
@click="setViewMode('list')"
>
<UIcon
name="i-lucide-list"
class="size-4"
/>
</button>
</div>
</form>
</template>
+336
View File
@@ -0,0 +1,336 @@
<script setup lang="ts">
import type { ImageItem, ImageTag } from '~/types/image'
const props = withDefaults(defineProps<{
items: ImageItem[]
selectedKeys: Set<string>
selectable?: boolean
showStorage?: boolean
showTags?: boolean
emptyText?: string
showDelete?: boolean
}>(), {
selectable: false,
showStorage: false,
showTags: false,
showDelete: true
})
const emit = defineEmits<{
'update:selectedKeys': [value: Set<string>]
'preview': [image: ImageItem]
'delete': [image: ImageItem]
'tag-click': [tagId: number]
'untagged-click': []
}>()
const { t } = useI18n()
const { formatFileSize } = useFileSize()
const INLINE_TAG_LIMIT = 3
const displayEmptyText = computed(() => props.emptyText ?? t('image.empty'))
const allSelected = computed(() =>
props.items.length > 0 && props.items.every(item => props.selectedKeys.has(item.key))
)
const someSelected = computed(() =>
props.items.some(item => props.selectedKeys.has(item.key))
)
const headerIndeterminate = computed(() => someSelected.value && !allSelected.value)
function updateSelection(key: string, selected: boolean) {
const next = new Set(props.selectedKeys)
if (selected) next.add(key)
else next.delete(key)
emit('update:selectedKeys', next)
}
function toggleAll(value: boolean | 'indeterminate') {
if (value === true) {
emit('update:selectedKeys', new Set(props.items.map(item => item.key)))
return
}
emit('update:selectedKeys', new Set())
}
function formatUploadedAt(value: string) {
try {
const date = new Date(value)
const y = date.getFullYear()
const m = String(date.getMonth() + 1).padStart(2, '0')
const d = String(date.getDate()).padStart(2, '0')
const h = String(date.getHours()).padStart(2, '0')
const min = String(date.getMinutes()).padStart(2, '0')
const s = String(date.getSeconds()).padStart(2, '0')
return `${y}/${m}/${d} ${h}:${min}:${s}`
} catch {
return value
}
}
function sourceLabel(image: ImageItem) {
switch (image.uploadSource) {
case 'web':
return t('stats.tagWeb')
case 'api':
return t('stats.tagApi')
default:
return null
}
}
function sourceIcon(image: ImageItem) {
switch (image.uploadSource) {
case 'web':
return 'i-lucide-globe'
case 'api':
return 'i-lucide-code-xml'
default:
return null
}
}
function storageIcon(image: ImageItem) {
return image.storage?.type === 'local' ? 'i-lucide-hard-drive' : 'i-lucide-cloud'
}
function tagDisplay(tags: ImageTag[] | undefined) {
const list = tags ?? []
if (list.length <= INLINE_TAG_LIMIT) {
return { visible: list, hidden: [] as ImageTag[] }
}
return {
visible: list.slice(0, INLINE_TAG_LIMIT),
hidden: list.slice(INLINE_TAG_LIMIT)
}
}
const metaBadgeClass = 'inline-flex max-w-full items-center gap-1 rounded-md border border-default px-1.5 py-0.5 text-[11px] leading-4 text-muted'
</script>
<template>
<div
v-if="items.length"
class="overflow-hidden rounded-xl border border-default bg-default"
>
<div class="overflow-x-auto">
<table class="min-w-full text-xs">
<thead class="border-b border-default bg-elevated/40 text-muted">
<tr>
<th
v-if="selectable"
class="w-10 px-3 py-2.5 text-left font-medium"
>
<UCheckbox
:model-value="allSelected ? true : headerIndeterminate ? 'indeterminate' : false"
:aria-label="t('stats.selectAll')"
@update:model-value="toggleAll"
/>
</th>
<th class="min-w-[14rem] px-3 py-2.5 text-left font-medium">
{{ t('stats.colFilename') }}
</th>
<th class="hidden w-24 px-3 py-2.5 text-left font-medium sm:table-cell">
{{ t('stats.colSource') }}
</th>
<th class="w-20 px-3 py-2.5 text-left font-medium">
{{ t('stats.colSize') }}
</th>
<th
v-if="showStorage"
class="hidden w-28 px-3 py-2.5 text-left font-medium md:table-cell"
>
{{ t('stats.colType') }}
</th>
<th class="hidden w-40 px-3 py-2.5 text-left font-medium lg:table-cell">
{{ t('stats.colUploadedAt') }}
</th>
<th
v-if="showTags"
class="min-w-[9rem] px-3 py-2.5 text-left font-medium md:table-cell"
>
{{ t('tags.column') }}
</th>
<th class="w-24 px-3 py-2.5 text-right font-medium">
{{ t('stats.colActions') }}
</th>
</tr>
</thead>
<tbody class="divide-y divide-default">
<tr
v-for="image in items"
:key="image.key"
class="transition-colors hover:bg-elevated/50"
:class="selectedKeys.has(image.key) ? 'bg-primary/5' : ''"
>
<td
v-if="selectable"
class="px-3 py-2"
>
<UCheckbox
:model-value="selectedKeys.has(image.key)"
@update:model-value="updateSelection(image.key, $event === true)"
/>
</td>
<td class="px-3 py-2.5">
<div class="flex min-w-0 items-center gap-3">
<button
type="button"
class="relative size-12 shrink-0 overflow-hidden rounded-md border border-default bg-muted"
:aria-label="t('image.openPreview')"
@click="emit('preview', image)"
>
<img
:src="image.url"
:alt="image.originalName"
loading="lazy"
class="size-full object-cover"
>
</button>
<p
class="min-w-0 flex-1 truncate text-highlighted"
:title="image.originalName"
>
{{ image.originalName }}
</p>
</div>
</td>
<td class="hidden px-3 py-2 sm:table-cell">
<span
v-if="sourceLabel(image) && sourceIcon(image)"
:class="metaBadgeClass"
>
<UIcon
:name="sourceIcon(image)!"
class="size-3 shrink-0"
/>
<span class="truncate">{{ sourceLabel(image) }}</span>
</span>
<span
v-else
class="text-muted"
>—</span>
</td>
<td class="px-3 py-2 tabular-nums text-muted">
{{ formatFileSize(image.size) }}
</td>
<td
v-if="showStorage"
class="hidden px-3 py-2 md:table-cell"
>
<span
v-if="image.storage"
:class="metaBadgeClass"
:title="image.storage.name"
>
<UIcon
:name="storageIcon(image)"
class="size-3 shrink-0"
/>
<span class="truncate">{{ image.storage.name }}</span>
</span>
<span
v-else
class="text-muted"
>—</span>
</td>
<td class="hidden px-3 py-2 tabular-nums text-muted lg:table-cell">
{{ formatUploadedAt(image.uploadedAt) }}
</td>
<td
v-if="showTags"
class="px-3 py-2 md:table-cell"
>
<div
v-if="image.tags?.length"
class="flex flex-wrap items-center gap-1"
>
<TagBadge
v-for="tag in tagDisplay(image.tags).visible"
:key="tag.id"
:tag="tag"
clickable
@click="emit('tag-click', tag.id)"
/>
<UPopover
v-if="tagDisplay(image.tags).hidden.length"
:content="{ side: 'bottom', align: 'start' }"
>
<button
type="button"
class="inline-flex rounded-md border border-default px-1.5 py-0.5 text-[11px] text-muted transition-colors hover:border-primary/40 hover:text-primary"
:aria-label="t('tags.moreTags', { n: tagDisplay(image.tags).hidden.length })"
@click.stop
>
+{{ tagDisplay(image.tags).hidden.length }}
</button>
<template #content>
<div
class="flex max-w-56 flex-wrap gap-1 p-2"
@click.stop
>
<TagBadge
v-for="tag in tagDisplay(image.tags).hidden"
:key="tag.id"
:tag="tag"
clickable
@click="emit('tag-click', tag.id)"
/>
</div>
</template>
</UPopover>
</div>
<button
v-else
type="button"
class="rounded-md border border-dashed border-default px-1.5 py-0.5 text-[11px] text-muted transition-colors hover:border-primary/40 hover:text-primary"
@click.stop="emit('untagged-click')"
>
{{ t('tags.unlabeled') }}
</button>
</td>
<td class="px-3 py-2">
<div class="flex items-center justify-end gap-0.5 text-muted">
<CopyButton
icon="i-lucide-link"
icon-only
variant="ghost"
color="neutral"
:label="t('common.copy')"
:value="image.url"
:success-title="t('copy.copiedUrl')"
/>
<UButton
icon="i-lucide-eye"
variant="ghost"
color="neutral"
size="xs"
:aria-label="t('image.openPreview')"
@click="emit('preview', image)"
/>
<UButton
v-if="showDelete"
icon="i-lucide-trash-2"
variant="ghost"
color="neutral"
size="xs"
:aria-label="t('common.delete')"
@click="emit('delete', image)"
/>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<div
v-else
class="rounded-xl border border-dashed border-default py-16 text-center text-xs text-muted"
>
{{ displayEmptyText }}
</div>
</template>
@@ -0,0 +1,55 @@
<script setup lang="ts">
import type { ImageItem } from '~/types/image'
const props = defineProps<{
items: ImageItem[]
selectedKeys: Set<string>
}>()
const emit = defineEmits<{
'update:selectedKeys': [value: Set<string>]
'clear': []
}>()
const { t } = useI18n()
const allSelected = computed(() =>
props.items.length > 0 && props.items.every(item => props.selectedKeys.has(item.key))
)
const someSelected = computed(() =>
props.items.some(item => props.selectedKeys.has(item.key))
)
const indeterminate = computed(() => someSelected.value && !allSelected.value)
function toggleAll(value: boolean | 'indeterminate') {
if (value === true) {
emit('update:selectedKeys', new Set(props.items.map(item => item.key)))
return
}
emit('update:selectedKeys', new Set())
}
</script>
<template>
<div class="flex min-w-0 items-center gap-3">
<UCheckbox
:model-value="allSelected ? true : indeterminate ? 'indeterminate' : false"
:disabled="!items.length"
:aria-label="t('stats.selectAll')"
@update:model-value="toggleAll"
/>
<p class="text-sm text-muted">
{{ t('stats.selectedItems', { n: selectedKeys.size }) }}
</p>
<button
type="button"
class="text-sm text-primary hover:underline disabled:pointer-events-none disabled:opacity-40"
:disabled="selectedKeys.size === 0"
@click="emit('clear')"
>
{{ t('stats.clear') }}
</button>
</div>
</template>
@@ -0,0 +1,182 @@
<script setup lang="ts">
const props = defineProps<{
currentOrigin: string
effectiveSiteUrl: string
}>()
const siteBaseUrl = defineModel<string>('siteBaseUrl', { required: true })
const imageBaseUrl = defineModel<string>('imageBaseUrl', { required: true })
const videoBaseUrl = defineModel<string>('videoBaseUrl', { required: true })
const fileBaseUrl = defineModel<string>('fileBaseUrl', { required: true })
const imageSeparated = defineModel<boolean>('imageSeparated', { required: true })
const videoSeparated = defineModel<boolean>('videoSeparated', { required: true })
const fileSeparated = defineModel<boolean>('fileSeparated', { required: true })
const { t } = useI18n()
function fillDetected(target: 'site' | 'image' | 'video' | 'file') {
if (!props.currentOrigin) return
if (target === 'site') siteBaseUrl.value = props.currentOrigin
else if (target === 'image') imageBaseUrl.value = props.currentOrigin
else if (target === 'video') videoBaseUrl.value = props.currentOrigin
else fileBaseUrl.value = props.currentOrigin
}
function effectiveUrl(separated: boolean, configuredUrl: string): string {
return separated
? configuredUrl.trim() || props.currentOrigin
: siteBaseUrl.value.trim() || props.effectiveSiteUrl || props.currentOrigin
}
</script>
<template>
<div class="grid gap-4 sm:grid-cols-2">
<SettingsGroup>
<div class="space-y-3 py-4 sm:pr-4">
<p class="text-sm font-medium text-highlighted">
{{ t('settings.siteBaseUrl') }}
</p>
<p class="text-xs leading-relaxed text-muted">
{{ t('settings.siteBaseUrlRoleHint') }}
</p>
<div class="flex gap-2">
<UInput
v-model="siteBaseUrl"
size="sm"
:placeholder="t('settings.siteBaseUrlPlaceholder')"
class="min-w-0 flex-1 font-mono"
/>
<UButton
v-if="currentOrigin"
:label="t('settings.fillDetectedOrigin')"
color="neutral"
variant="outline"
size="sm"
class="shrink-0"
@click="fillDetected('site')"
/>
</div>
<p class="truncate rounded-lg bg-muted/30 px-3 py-2 text-xs text-muted">
{{ t('settings.siteBaseUrlActive', { url: effectiveSiteUrl || currentOrigin }) }}
</p>
</div>
</SettingsGroup>
<SettingsGroup>
<div class="space-y-3 py-4 sm:pl-4">
<div class="flex flex-wrap items-center justify-between gap-2">
<p class="text-sm font-medium text-highlighted">
{{ t('settings.imageBaseUrl') }}
</p>
<USwitch v-model="imageSeparated" />
</div>
<p class="text-xs leading-relaxed text-muted">
{{ t('settings.imageBaseUrlRoleHint') }}
</p>
<div
class="flex gap-2"
:class="imageSeparated ? '' : 'pointer-events-none opacity-50'"
>
<UInput
v-model="imageBaseUrl"
size="sm"
:placeholder="t('settings.imageBaseUrlPlaceholder')"
class="min-w-0 flex-1 font-mono"
/>
<UButton
v-if="currentOrigin"
:label="t('settings.fillDetectedOrigin')"
color="neutral"
variant="outline"
size="sm"
class="shrink-0"
@click="fillDetected('image')"
/>
</div>
<p class="truncate rounded-lg bg-muted/30 px-3 py-2 text-xs text-muted">
{{ t('settings.imageBaseUrlActive', {
url: effectiveUrl(imageSeparated, imageBaseUrl)
}) }}
</p>
</div>
</SettingsGroup>
<SettingsGroup>
<div class="space-y-3 py-4 sm:pr-4">
<div class="flex flex-wrap items-center justify-between gap-2">
<p class="text-sm font-medium text-highlighted">
{{ t('settings.videoBaseUrl') }}
</p>
<USwitch v-model="videoSeparated" />
</div>
<p class="text-xs leading-relaxed text-muted">
{{ t('settings.videoBaseUrlRoleHint') }}
</p>
<div
class="flex gap-2"
:class="videoSeparated ? '' : 'pointer-events-none opacity-50'"
>
<UInput
v-model="videoBaseUrl"
size="sm"
:placeholder="t('settings.videoBaseUrlPlaceholder')"
class="min-w-0 flex-1 font-mono"
/>
<UButton
v-if="currentOrigin"
:label="t('settings.fillDetectedOrigin')"
color="neutral"
variant="outline"
size="sm"
class="shrink-0"
@click="fillDetected('video')"
/>
</div>
<p class="truncate rounded-lg bg-muted/30 px-3 py-2 text-xs text-muted">
{{ t('settings.videoBaseUrlActive', {
url: effectiveUrl(videoSeparated, videoBaseUrl)
}) }}
</p>
</div>
</SettingsGroup>
<SettingsGroup>
<div class="space-y-3 py-4 sm:pl-4">
<div class="flex flex-wrap items-center justify-between gap-2">
<p class="text-sm font-medium text-highlighted">
{{ t('settings.fileBaseUrl') }}
</p>
<USwitch v-model="fileSeparated" />
</div>
<p class="text-xs leading-relaxed text-muted">
{{ t('settings.fileBaseUrlRoleHint') }}
</p>
<div
class="flex gap-2"
:class="fileSeparated ? '' : 'pointer-events-none opacity-50'"
>
<UInput
v-model="fileBaseUrl"
size="sm"
:placeholder="t('settings.fileBaseUrlPlaceholder')"
class="min-w-0 flex-1 font-mono"
/>
<UButton
v-if="currentOrigin"
:label="t('settings.fillDetectedOrigin')"
color="neutral"
variant="outline"
size="sm"
class="shrink-0"
@click="fillDetected('file')"
/>
</div>
<p class="truncate rounded-lg bg-muted/30 px-3 py-2 text-xs text-muted">
{{ t('settings.fileBaseUrlActive', {
url: effectiveUrl(fileSeparated, fileBaseUrl)
}) }}
</p>
</div>
</SettingsGroup>
</div>
</template>
@@ -0,0 +1,5 @@
<template>
<div class="divide-y divide-default">
<slot />
</div>
</template>
@@ -0,0 +1,557 @@
<script setup lang="ts">
import type { ActivityLogListResponse, LogAction, LogSource, LogStatus } from '~/types/logs'
const { isAuthenticated, handleAuthError, isAdmin } = useAuth()
const toast = useToast()
const { t, locale } = useI18n()
const { formatFileSize } = useFileSize()
const loading = ref(true)
const page = ref(1)
const pageSize = ref(10)
const actionFilter = ref<'all' | LogAction>('all')
const sourceFilter = ref<'all' | LogSource>('all')
const userFilter = ref<'all' | number>('all')
const dateFrom = ref('')
const dateTo = ref('')
const searchQuery = ref('')
const activeSearch = ref('')
const listData = ref<ActivityLogListResponse | null>(null)
const actionItems = computed(() => [
{ label: t('logs.filterActionAll'), value: 'all' as const },
{ label: t('logs.actionUpload'), value: 'upload' as const },
{ label: t('logs.actionDelete'), value: 'delete' as const },
{ label: t('logs.actionLogin'), value: 'login' as const },
{ label: t('logs.actionEdit'), value: 'edit' as const },
{ label: t('logs.actionSettings'), value: 'settings' as const },
{ label: t('logs.actionTags'), value: 'tags' as const }
])
const sourceItems = computed(() => [
{ label: t('logs.filterSourceAll'), value: 'all' as const },
{ label: t('logs.sourceWeb'), value: 'web' as const },
{ label: t('logs.sourceAdmin'), value: 'admin' as const },
{ label: t('logs.sourceApi'), value: 'api' as const }
])
const userItems = computed(() => {
const items: Array<{ label: string, value: 'all' | number }> = [
{ label: t('logs.filterUserAll'), value: 'all' }
]
for (const user of listData.value?.users ?? []) {
items.push({ label: user.username, value: user.id })
}
return items
})
function formatTime(value: string) {
try {
return new Date(value).toLocaleString(locale.value, { hour12: false })
} catch {
return value
}
}
function formatTimeShort(value: string) {
try {
return new Date(value).toLocaleString(locale.value, {
month: 'numeric',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
hour12: false
})
} catch {
return value
}
}
function actionLabel(action: LogAction) {
const labels: Record<LogAction, string> = {
upload: t('logs.actionUpload'),
delete: t('logs.actionDelete'),
login: t('logs.actionLogin'),
edit: t('logs.actionEdit'),
settings: t('logs.actionSettings'),
tags: t('logs.actionTags')
}
return labels[action]
}
function actionBadgeClass(action: LogAction) {
const base = 'inline-flex items-center gap-1 rounded-md px-2 py-0.5 text-xs font-medium whitespace-nowrap'
switch (action) {
case 'upload':
return `${base} bg-success/10 text-success`
case 'delete':
return `${base} bg-error/10 text-error`
case 'login':
return `${base} bg-sky-500/10 text-sky-600 dark:text-sky-400`
case 'edit':
return `${base} bg-blue-500/10 text-blue-600 dark:text-blue-400`
case 'settings':
return `${base} bg-slate-500/10 text-slate-600 dark:text-slate-400`
case 'tags':
return `${base} bg-violet-500/10 text-violet-600 dark:text-violet-400`
}
}
function actionIcon(action: LogAction) {
switch (action) {
case 'upload':
return 'i-lucide-upload'
case 'delete':
return 'i-lucide-trash-2'
case 'login':
return 'i-lucide-log-in'
case 'edit':
return 'i-lucide-pencil'
case 'settings':
return 'i-lucide-settings'
case 'tags':
return 'i-lucide-tag'
}
}
function sourceIcon(source: LogSource) {
switch (source) {
case 'web':
return 'i-lucide-monitor'
case 'admin':
return 'i-lucide-layout-dashboard'
case 'api':
return 'i-lucide-code-2'
}
}
function sourceLabel(source: LogSource) {
switch (source) {
case 'web':
return t('logs.sourceWeb')
case 'admin':
return t('logs.sourceAdmin')
case 'api':
return t('logs.sourceApi')
}
}
function sourceBadgeClass(source: LogSource) {
const base = 'inline-flex items-center gap-1 rounded-md px-2 py-0.5 text-xs font-medium whitespace-nowrap'
switch (source) {
case 'web':
return `${base} bg-primary/10 text-primary`
case 'admin':
return `${base} bg-orange-500/10 text-orange-600 dark:text-orange-400`
case 'api':
return `${base} bg-violet-500/10 text-violet-600 dark:text-violet-400`
}
}
function statusLabel(status: LogStatus) {
return status === 'failure' ? t('logs.resultFailure') : t('logs.resultSuccess')
}
function statusDotClass(status: LogStatus) {
return status === 'failure' ? 'bg-error' : 'bg-success'
}
function formatIp(value: string | null) {
if (!value || value === '-') return '—'
return value
}
function formatSize(action: LogAction, size: number) {
if (action !== 'upload' && action !== 'delete') return '—'
if (action === 'delete' && size <= 0) return '—'
return formatFileSize(size)
}
async function loadLogs() {
loading.value = true
try {
const query: Record<string, string | number> = {
page: page.value,
limit: pageSize.value
}
if (actionFilter.value !== 'all') {
query.action = actionFilter.value
}
if (sourceFilter.value !== 'all') {
query.source = sourceFilter.value
}
if (isAdmin.value && userFilter.value !== 'all') {
query.userId = userFilter.value
}
if (activeSearch.value) {
query.q = activeSearch.value
}
if (dateFrom.value) {
query.from = dateFrom.value
}
if (dateTo.value) {
query.to = dateTo.value
}
listData.value = await $fetch<ActivityLogListResponse>('/api/logs', {
credentials: 'include',
query
})
page.value = listData.value.page
} catch (error: unknown) {
handleAuthError(error)
if (isAuthenticated.value) {
toast.add({ title: t('logs.loadFailed'), color: 'error' })
}
} finally {
loading.value = false
}
}
function applyFilters() {
page.value = 1
void loadLogs()
}
function submitSearch() {
activeSearch.value = searchQuery.value.trim()
applyFilters()
}
function clearSearch() {
searchQuery.value = ''
activeSearch.value = ''
applyFilters()
}
function handlePageChange(target: number) {
page.value = target
void loadLogs()
}
function handlePageSizeChange(size: number) {
pageSize.value = size
page.value = 1
void loadLogs()
}
onMounted(() => {
void loadLogs()
})
</script>
<template>
<div class="min-w-0">
<div class="mb-6 flex items-start justify-between gap-4">
<div class="min-w-0">
<div class="flex items-center gap-2">
<span
class="h-4 w-1 shrink-0 rounded-full bg-primary"
aria-hidden="true"
/>
<h2 class="text-sm font-semibold text-highlighted">
{{ t('logs.pageTitle') }}
</h2>
</div>
<p class="mt-1.5 pl-3 text-xs leading-relaxed text-muted">
{{ isAdmin ? t('settings.pageSubtitleLogs') : t('logs.pageSubtitle') }}
</p>
</div>
<UButton
icon="i-lucide-refresh-cw"
variant="outline"
color="neutral"
size="sm"
:label="t('common.refresh')"
:loading="loading"
class="shrink-0"
@click="loadLogs"
/>
</div>
<form
class="mb-4 flex flex-col gap-3"
@submit.prevent="submitSearch"
>
<div class="flex flex-col gap-3 xl:flex-row xl:items-center xl:justify-between">
<div class="flex min-w-0 flex-wrap items-center gap-2">
<UInput
v-model="dateFrom"
type="date"
size="sm"
class="min-w-0 w-full sm:w-36"
:aria-label="t('logs.dateFrom')"
:disabled="loading"
@change="applyFilters"
/>
<span class="hidden text-xs text-muted sm:inline">{{ t('logs.dateRangeSeparator') }}</span>
<UInput
v-model="dateTo"
type="date"
size="sm"
class="min-w-0 w-full sm:w-36"
:aria-label="t('logs.dateTo')"
:disabled="loading"
@change="applyFilters"
/>
</div>
<div class="flex min-w-0 flex-wrap items-center gap-2 xl:justify-end">
<USelect
v-if="isAdmin"
v-model="userFilter"
:items="userItems"
value-key="value"
class="min-w-0 w-full sm:w-32"
size="sm"
@update:model-value="applyFilters"
/>
<USelect
v-model="actionFilter"
:items="actionItems"
value-key="value"
class="min-w-0 w-[calc(50%-0.25rem)] sm:w-28"
size="sm"
@update:model-value="applyFilters"
/>
<USelect
v-model="sourceFilter"
:items="sourceItems"
value-key="value"
class="min-w-0 w-[calc(50%-0.25rem)] sm:w-28"
size="sm"
@update:model-value="applyFilters"
/>
<div class="flex min-w-0 w-full items-center gap-2 sm:w-auto">
<UInput
v-model="searchQuery"
icon="i-lucide-search"
:placeholder="t('logs.searchPlaceholder')"
class="min-w-0 flex-1 sm:w-44 sm:flex-none"
size="sm"
:disabled="loading"
/>
<UButton
type="submit"
size="sm"
class="shrink-0"
:loading="loading"
>
{{ t('common.search') }}
</UButton>
<UButton
v-if="activeSearch"
type="button"
icon="i-lucide-x"
size="sm"
variant="ghost"
color="neutral"
class="shrink-0"
:aria-label="t('stats.clear')"
@click="clearSearch"
/>
</div>
</div>
</div>
</form>
<div class="overflow-hidden rounded-xl border border-default bg-default">
<div
v-if="loading"
class="p-4 sm:p-6"
>
<div class="space-y-3 sm:hidden">
<USkeleton
v-for="n in 6"
:key="`mobile-${n}`"
class="h-24 rounded-lg"
/>
</div>
<div class="hidden space-y-2 sm:block">
<USkeleton
v-for="n in 8"
:key="`desktop-${n}`"
class="h-12 rounded-lg"
/>
</div>
</div>
<template v-else-if="listData && listData.items.length">
<div class="min-w-0 sm:hidden">
<div class="divide-y divide-default">
<article
v-for="row in listData.items"
:key="row.id"
class="min-w-0 px-4 py-3.5"
>
<div class="flex items-start justify-between gap-3">
<time class="text-xs text-muted">
{{ formatTimeShort(row.createdAt) }}
</time>
<span class="shrink-0 text-xs tabular-nums text-muted">
{{ formatSize(row.action, row.size) }}
</span>
</div>
<div class="mt-2 flex min-w-0 max-w-full flex-wrap items-center gap-1.5">
<span :class="actionBadgeClass(row.action)">
<UIcon
:name="actionIcon(row.action)"
class="size-3 shrink-0"
/>
{{ actionLabel(row.action) }}
</span>
<span :class="sourceBadgeClass(row.source)">
<UIcon
:name="sourceIcon(row.source)"
class="size-3 shrink-0"
/>
{{ sourceLabel(row.source) }}
</span>
<span
v-if="isAdmin"
class="text-xs text-muted"
>
{{ row.username ?? '—' }}
</span>
</div>
<p
class="mt-2 truncate text-sm"
:title="row.originalName"
>
{{ row.originalName }}
</p>
<div class="mt-2 flex items-center justify-between gap-2 text-xs text-muted">
<span>{{ formatIp(row.ipAddress) }}</span>
<span class="inline-flex items-center gap-1.5">
<span
class="size-1.5 rounded-full"
:class="statusDotClass(row.status)"
/>
{{ statusLabel(row.status) }}
</span>
</div>
</article>
</div>
</div>
<div class="hidden min-w-0 overflow-x-auto sm:block">
<table class="w-full min-w-[56rem] text-left text-sm">
<thead class="border-b border-default bg-muted/30 text-xs text-muted">
<tr>
<th class="whitespace-nowrap px-4 py-3 font-medium sm:px-5">
{{ t('logs.colTime') }}
</th>
<th
v-if="isAdmin"
class="whitespace-nowrap px-4 py-3 font-medium"
>
{{ t('logs.colUser') }}
</th>
<th class="whitespace-nowrap px-4 py-3 font-medium">
{{ t('logs.colAction') }}
</th>
<th class="whitespace-nowrap px-4 py-3 font-medium">
{{ t('logs.colSource') }}
</th>
<th class="px-4 py-3 font-medium">
{{ t('logs.colFile') }}
</th>
<th class="whitespace-nowrap px-4 py-3 font-medium">
{{ t('logs.colSize') }}
</th>
<th class="whitespace-nowrap px-4 py-3 font-medium">
{{ t('logs.colIp') }}
</th>
<th class="whitespace-nowrap px-4 py-3 font-medium sm:px-5">
{{ t('logs.colResult') }}
</th>
</tr>
</thead>
<tbody class="divide-y divide-default">
<tr
v-for="row in listData.items"
:key="row.id"
class="hover:bg-muted/20"
>
<td class="whitespace-nowrap px-4 py-3 text-xs text-muted sm:px-5">
{{ formatTime(row.createdAt) }}
</td>
<td
v-if="isAdmin"
class="whitespace-nowrap px-4 py-3 text-xs"
>
{{ row.username ?? '—' }}
</td>
<td class="whitespace-nowrap px-4 py-3">
<span :class="actionBadgeClass(row.action)">
<UIcon
:name="actionIcon(row.action)"
class="size-3 shrink-0"
/>
{{ actionLabel(row.action) }}
</span>
</td>
<td class="whitespace-nowrap px-4 py-3">
<span :class="sourceBadgeClass(row.source)">
<UIcon
:name="sourceIcon(row.source)"
class="size-3 shrink-0"
/>
{{ sourceLabel(row.source) }}
</span>
</td>
<td
class="max-w-[12rem] truncate px-4 py-3"
:title="row.originalName"
>
{{ row.originalName }}
</td>
<td class="whitespace-nowrap px-4 py-3 tabular-nums text-muted">
{{ formatSize(row.action, row.size) }}
</td>
<td class="whitespace-nowrap px-4 py-3 font-mono text-xs text-muted">
{{ formatIp(row.ipAddress) }}
</td>
<td class="whitespace-nowrap px-4 py-3 sm:px-5">
<span class="inline-flex items-center gap-1.5 text-xs">
<span
class="size-1.5 rounded-full"
:class="statusDotClass(row.status)"
/>
{{ statusLabel(row.status) }}
</span>
</td>
</tr>
</tbody>
</table>
</div>
</template>
<div
v-else
class="px-4 py-16 text-center text-sm text-muted sm:px-6"
>
{{ t('logs.empty') }}
</div>
<div
v-if="listData && listData.total > 0"
class="border-t border-default px-4 py-4 sm:px-5"
>
<PaginationBar
:page="listData.page"
:total-pages="listData.totalPages"
:total="listData.total"
:unit="t('logs.entriesUnit')"
:page-size="pageSize"
:loading="loading"
@update:page="handlePageChange"
@update:page-size="handlePageSizeChange"
/>
</div>
</div>
</div>
</template>
@@ -0,0 +1,29 @@
<script setup lang="ts">
defineProps<{
icon: string
title: string
subtitle?: string
}>()
</script>
<template>
<header class="mb-0 flex items-start gap-3 border-b border-default pb-6">
<div class="flex size-10 shrink-0 items-center justify-center rounded-xl bg-primary/10">
<UIcon
:name="icon"
class="size-5 text-primary"
/>
</div>
<div class="min-w-0 pt-0.5">
<h1 class="text-lg font-semibold tracking-tight text-highlighted">
{{ title }}
</h1>
<p
v-if="subtitle"
class="mt-1 text-xs leading-relaxed text-muted"
>
{{ subtitle }}
</p>
</div>
</header>
</template>
@@ -0,0 +1,5 @@
<template>
<div class="mt-6 space-y-4">
<slot />
</div>
</template>
@@ -0,0 +1,28 @@
<script setup lang="ts">
defineProps<{
title: string
hint?: string
}>()
</script>
<template>
<section class="overflow-hidden rounded-xl border border-default bg-default">
<div class="px-4 py-3">
<div class="flex flex-wrap items-center gap-2">
<h2 class="text-sm font-semibold text-highlighted">
{{ title }}
</h2>
<slot name="badge" />
</div>
<p
v-if="hint"
class="mt-1 text-xs leading-relaxed text-muted"
>
{{ hint }}
</p>
</div>
<div class="border-t border-default px-4 py-4">
<slot />
</div>
</section>
</template>
@@ -0,0 +1,41 @@
<script setup lang="ts">
export type SettingsRadioOption = {
value: string
label: string
description?: string
}
defineProps<{
items: SettingsRadioOption[]
}>()
const model = defineModel<string>({ required: true })
const radioUi = {
fieldset: 'flex flex-col divide-y divide-default',
item: 'flex items-start gap-4 py-4',
wrapper: 'min-w-0 flex-1 pr-2',
base: 'mt-0.5 shrink-0',
label: 'block text-sm font-medium text-highlighted',
description: 'mt-1 block text-xs leading-relaxed text-muted'
}
</script>
<template>
<URadioGroup
v-model="model"
:items="items"
variant="list"
:ui="radioUi"
>
<template #label="{ item }">
<span class="text-sm font-medium text-highlighted">{{ item.label }}</span>
</template>
<template #description="{ item }">
<span
v-if="item.description"
class="mt-1 block text-xs leading-relaxed text-muted"
>{{ item.description }}</span>
</template>
</URadioGroup>
</template>
@@ -0,0 +1,39 @@
<script setup lang="ts">
defineProps<{
title: string
hint?: string
}>()
</script>
<template>
<section class="overflow-hidden rounded-xl border border-default bg-default">
<div class="flex items-start justify-between gap-4 px-4 py-3">
<div class="min-w-0">
<div class="flex items-center gap-2">
<span
class="h-4 w-1 shrink-0 rounded-full bg-primary"
aria-hidden="true"
/>
<h2 class="text-sm font-semibold text-highlighted">
{{ title }}
</h2>
</div>
<p
v-if="hint"
class="mt-1.5 pl-3 text-xs leading-relaxed text-muted"
>
{{ hint }}
</p>
</div>
<div
v-if="$slots.action"
class="shrink-0"
>
<slot name="action" />
</div>
</div>
<div class="border-t border-default px-4">
<slot />
</div>
</section>
</template>
@@ -0,0 +1,40 @@
<script setup lang="ts">
defineProps<{
hasChanges: boolean
saving: boolean
}>()
const emit = defineEmits<{
save: []
restore: []
}>()
const { t } = useI18n()
</script>
<template>
<div class="border-t border-default bg-default px-4 py-4 sm:px-6">
<div class="flex flex-wrap items-center justify-end gap-3">
<p
v-if="hasChanges"
class="mr-auto text-xs text-warning"
>
{{ t('settings.unsavedChanges') }}
</p>
<UButton
:label="t('settings.restoreDefaults')"
color="neutral"
variant="outline"
@click="emit('restore')"
/>
<UButton
:label="t('settings.saveSettings')"
icon="i-lucide-save"
color="primary"
:loading="saving"
:disabled="!hasChanges"
@click="emit('save')"
/>
</div>
</div>
</template>
@@ -0,0 +1,75 @@
<script setup lang="ts">
import type { SettingsTab } from '~/types/settings'
const props = defineProps<{
modelValue: SettingsTab
items: Array<{ id: SettingsTab, label: string, icon: string }>
}>()
const emit = defineEmits<{
'update:modelValue': [SettingsTab]
}>()
const { t } = useI18n()
const searchQuery = ref('')
const filteredItems = computed(() => {
const q = searchQuery.value.trim().toLowerCase()
if (!q) return props.items
return props.items.filter(item => item.label.toLowerCase().includes(q))
})
function selectTab(id: SettingsTab) {
emit('update:modelValue', id)
searchQuery.value = ''
}
</script>
<template>
<aside class="flex w-full shrink-0 flex-col border-b border-default bg-neutral-50 dark:bg-neutral-900/30 lg:w-56 lg:border-b-0 lg:border-r xl:w-60">
<div class="border-b border-default p-3">
<UInput
v-model="searchQuery"
size="sm"
:placeholder="t('settings.searchPlaceholder')"
icon="i-lucide-search"
class="w-full"
>
<template #trailing>
<UKbd
size="sm"
class="hidden sm:inline-flex"
>
⌘K
</UKbd>
</template>
</UInput>
</div>
<nav class="flex-1 space-y-0.5 p-3">
<p
v-if="searchQuery.trim() && !filteredItems.length"
class="px-3 py-6 text-center text-xs text-muted"
>
{{ t('settings.searchEmpty') }}
</p>
<button
v-for="item in filteredItems"
:key="item.id"
type="button"
class="flex w-full items-center gap-2.5 rounded-lg px-3 py-2.5 text-left text-sm transition-colors"
:class="modelValue === item.id
? 'bg-primary/10 font-medium text-primary'
: 'text-muted hover:bg-muted/50 hover:text-highlighted'"
@click="selectTab(item.id)"
>
<UIcon
:name="item.icon"
class="size-4 shrink-0"
:class="modelValue === item.id ? 'text-primary' : 'opacity-70'"
/>
<span>{{ item.label }}</span>
</button>
</nav>
</aside>
</template>
@@ -0,0 +1,71 @@
<script setup lang="ts">
defineProps<{
appVersion: string
updateAvailable: boolean
latestVersion: string | null
releaseUrl: string | null
checking: boolean
}>()
// 「检查更新」已停用(改动较大,与上游版本号已不对应),暂时保留事件定义
defineEmits<{
checkUpdate: []
}>()
const { t } = useI18n()
</script>
<template>
<div class="flex flex-wrap items-center justify-between gap-4 py-4">
<div class="min-w-0">
<p class="text-sm font-medium text-highlighted">
{{ t('settings.systemVersion') }}
</p>
<p class="mt-1 text-xs leading-relaxed text-muted">
{{ t('settings.systemVersionHint') }}
</p>
</div>
<div class="flex flex-wrap items-center gap-2">
<span class="text-sm font-medium tabular-nums text-highlighted">
v{{ appVersion }}
</span>
<!-- 检查更新功能已停用,需要时恢复下面这段
<UBadge
v-if="updateAvailable && latestVersion"
color="warning"
variant="subtle"
size="sm"
>
{{ t('settings.updateAvailable', { version: `v${latestVersion}` }) }}
</UBadge>
<UBadge
v-else
color="success"
variant="subtle"
size="sm"
>
{{ t('settings.upToDate') }}
</UBadge>
<UButton
:label="t('settings.checkUpdate')"
icon="i-lucide-refresh-cw"
variant="outline"
color="neutral"
size="sm"
class="shrink-0"
:loading="checking"
@click="emit('checkUpdate')"
/>
<a
v-if="updateAvailable && latestVersion"
:href="releaseUrl || 'https://github.com/O96u/PicHost/releases'"
target="_blank"
rel="noopener noreferrer"
class="text-xs text-primary hover:underline"
>
{{ t('settings.viewReleases') }}
</a>
-->
</div>
</div>
</template>
@@ -0,0 +1,31 @@
<script setup lang="ts">
const { items: tags, loading, fetchTags } = useTags()
onMounted(() => {
void fetchTags()
})
async function handleChanged() {
await fetchTags(true)
}
</script>
<template>
<SettingsPanel>
<div
v-if="loading && !tags.length"
class="flex justify-center py-16"
>
<UIcon
name="i-lucide-loader-circle"
class="size-6 animate-spin text-muted"
/>
</div>
<TagManagePanel
v-else
:tags="tags"
embedded
@changed="handleChanged"
/>
</SettingsPanel>
</template>
@@ -0,0 +1,34 @@
<script setup lang="ts">
defineProps<{
title?: string
hint?: string
disabled?: boolean
}>()
const model = defineModel<boolean>({ required: true })
</script>
<template>
<div class="flex items-start justify-between gap-4 py-4">
<div class="min-w-0 flex-1 pr-2">
<p class="text-sm font-medium text-highlighted">
<slot name="title">
{{ title }}
</slot>
</p>
<p
v-if="hint || $slots.hint"
class="mt-1 text-xs leading-relaxed text-muted"
>
<slot name="hint">
{{ hint }}
</slot>
</p>
</div>
<USwitch
v-model="model"
:disabled="disabled"
class="mt-0.5 shrink-0"
/>
</div>
</template>
@@ -0,0 +1,546 @@
<script setup lang="ts">
type PolicySource = 'env' | 'db' | 'default'
type MimeType = 'image/jpeg' | 'image/png' | 'image/webp' | 'image/gif' | 'image/svg+xml' | 'image/x-icon'
type VideoExtension = 'mp4' | 'm4v' | 'webm' | 'mov' | 'mkv' | 'avi' | 'flv' | 'wmv' | 'mpg' | 'mpeg'
const props = defineProps<{
part: 'upload-preference' | 'image-processing' | 'login-rate'
uploadImageMaxFileSizeMbSource?: PolicySource
uploadVideoMaxFileSizeMbSource?: PolicySource
uploadFileMaxFileSizeMbSource?: PolicySource
allowedMimeTypesSource?: PolicySource
allowedVideoExtensionsSource?: PolicySource
forbiddenFileExtensionsSource?: PolicySource
uploadRateIpMaxSource?: PolicySource
uploadRateTokenMaxSource?: PolicySource
uploadRateWindowMinutesSource?: PolicySource
loginRateMaxSource?: PolicySource
loginRateWindowMinutesSource?: PolicySource
preserveOriginalUploadSource?: PolicySource
mimeTypeOptions?: ReadonlyArray<{ mime: MimeType, label: string }>
videoExtensionOptions?: ReadonlyArray<{ extension: VideoExtension, label: string }>
forbiddenFileExtensionOptions?: ReadonlyArray<{ extension: string, label: string }>
}>()
const uploadImageMaxFileSizeMbDraft = defineModel<number>('uploadImageMaxFileSizeMb')
const uploadVideoMaxFileSizeMbDraft = defineModel<number>('uploadVideoMaxFileSizeMb')
const uploadFileMaxFileSizeMbDraft = defineModel<number>('uploadFileMaxFileSizeMb')
const allowedMimeTypesDraft = defineModel<MimeType[]>('allowedMimeTypes')
const allowedVideoExtensionsDraft = defineModel<VideoExtension[]>('allowedVideoExtensions')
const forbiddenFileExtensionsDraft = defineModel<string[]>('forbiddenFileExtensions')
const uploadRateIpMaxDraft = defineModel<number>('uploadRateIpMax')
const uploadRateTokenMaxDraft = defineModel<number>('uploadRateTokenMax')
const uploadRateWindowMinutesDraft = defineModel<number>('uploadRateWindowMinutes')
const loginRateMaxDraft = defineModel<number>('loginRateMax')
const loginRateWindowMinutesDraft = defineModel<number>('loginRateWindowMinutes')
const preserveOriginalUploadDraft = defineModel<boolean>('preserveOriginalUpload')
const customForbiddenExtension = ref('')
const { t } = useI18n()
function sourceBadge(source: PolicySource) {
switch (source) {
case 'env':
return { label: t('settings.badgeEnv'), color: 'warning' as const }
case 'db':
return { label: t('settings.badgeSaved'), color: 'success' as const }
default:
return { label: t('settings.badgeDefault'), color: 'neutral' as const }
}
}
function toggleMime(mime: MimeType, checked: boolean) {
if (!allowedMimeTypesDraft.value) return
if (checked) {
if (!allowedMimeTypesDraft.value.includes(mime)) {
allowedMimeTypesDraft.value = [...allowedMimeTypesDraft.value, mime]
}
return
}
if (allowedMimeTypesDraft.value.length <= 1) return
allowedMimeTypesDraft.value = allowedMimeTypesDraft.value.filter(item => item !== mime)
}
function isMimeChecked(mime: MimeType): boolean {
return allowedMimeTypesDraft.value?.includes(mime) ?? false
}
function toggleVideoExtension(extension: VideoExtension, checked: boolean) {
if (!allowedVideoExtensionsDraft.value) return
if (checked) {
if (!allowedVideoExtensionsDraft.value.includes(extension)) {
allowedVideoExtensionsDraft.value = [
...allowedVideoExtensionsDraft.value,
extension
]
}
return
}
if (allowedVideoExtensionsDraft.value.length <= 1) return
allowedVideoExtensionsDraft.value = allowedVideoExtensionsDraft.value
.filter(item => item !== extension)
}
function isVideoExtensionChecked(extension: VideoExtension): boolean {
return allowedVideoExtensionsDraft.value?.includes(extension) ?? false
}
function toggleForbiddenExtension(extension: string, checked: boolean) {
if (!forbiddenFileExtensionsDraft.value) return
if (checked) {
if (!forbiddenFileExtensionsDraft.value.includes(extension)) {
forbiddenFileExtensionsDraft.value = [
...forbiddenFileExtensionsDraft.value,
extension
]
}
return
}
forbiddenFileExtensionsDraft.value = forbiddenFileExtensionsDraft.value
.filter(item => item !== extension)
}
function isForbiddenExtensionChecked(extension: string): boolean {
return forbiddenFileExtensionsDraft.value?.includes(extension) ?? false
}
const allForbiddenExtensionOptions = computed(() => {
const options = new Map(
(props.forbiddenFileExtensionOptions ?? []).map(option => [
option.extension,
option.label
])
)
for (const extension of forbiddenFileExtensionsDraft.value ?? []) {
if (!options.has(extension)) options.set(extension, extension.toUpperCase())
}
return [...options].map(([extension, label]) => ({ extension, label }))
})
function addCustomForbiddenExtension() {
const values = customForbiddenExtension.value
.split(',')
.map(value => value.trim().toLowerCase().replace(/^\.+/, ''))
.filter(value => /^[a-z0-9][a-z0-9_-]{0,15}$/.test(value))
if (!values.length) return
const next = new Set(forbiddenFileExtensionsDraft.value ?? [])
for (const value of values) next.add(value)
forbiddenFileExtensionsDraft.value = [...next]
customForbiddenExtension.value = ''
}
type UploadSizeKind = 'image' | 'video' | 'file'
const uploadSizeFields = computed(() => [
{
kind: 'image' as const,
title: t('settings.uploadImageMaxFileSize'),
hint: t('settings.uploadImageMaxFileSizeHint'),
source: props.uploadImageMaxFileSizeMbSource,
value: uploadImageMaxFileSizeMbDraft.value ?? 10,
max: 100
},
{
kind: 'video' as const,
title: t('settings.uploadVideoMaxFileSize'),
hint: t('settings.uploadVideoMaxFileSizeHint'),
source: props.uploadVideoMaxFileSizeMbSource,
value: uploadVideoMaxFileSizeMbDraft.value ?? 500,
max: 2048
},
{
kind: 'file' as const,
title: t('settings.uploadFileMaxFileSize'),
hint: t('settings.uploadFileMaxFileSizeHint'),
source: props.uploadFileMaxFileSizeMbSource,
value: uploadFileMaxFileSizeMbDraft.value ?? 100,
max: 1024
}
])
function uploadSizeModel(kind: UploadSizeKind) {
if (kind === 'video') return uploadVideoMaxFileSizeMbDraft
if (kind === 'file') return uploadFileMaxFileSizeMbDraft
return uploadImageMaxFileSizeMbDraft
}
function clampUploadMaxFileSizeMb(kind: UploadSizeKind, value: unknown): number {
const num = Number(value)
const model = uploadSizeModel(kind)
const max = kind === 'video' ? 2048 : kind === 'file' ? 1024 : 100
if (!Number.isFinite(num)) return model.value ?? (kind === 'video' ? 500 : kind === 'file' ? 100 : 10)
return Math.min(max, Math.max(0, Math.round(num)))
}
function onUploadMaxFileSizeInput(kind: UploadSizeKind, event: Event) {
uploadSizeModel(kind).value = clampUploadMaxFileSizeMb(kind,
(event.target as HTMLInputElement).value
)
}
function onUploadMaxFileSizeNumberInput(kind: UploadSizeKind, value: string | number) {
uploadSizeModel(kind).value = clampUploadMaxFileSizeMb(kind, value)
}
</script>
<template>
<template v-if="part === 'upload-preference'">
<div class="space-y-5 py-4">
<div
v-for="field in uploadSizeFields"
:key="field.kind"
>
<div class="flex flex-wrap items-center gap-2">
<p class="text-sm font-medium text-highlighted">
{{ field.title }}
</p>
<UBadge
v-if="field.source"
:color="sourceBadge(field.source).color"
variant="subtle"
size="xs"
>
{{ sourceBadge(field.source).label }}
</UBadge>
</div>
<p class="mt-1 text-xs leading-relaxed text-muted">
{{ field.hint }}
</p>
<div
class="mt-3 flex items-center gap-3"
:class="field.source === 'env' ? 'pointer-events-none opacity-60' : ''"
>
<input
type="range"
min="0"
:max="field.max"
:value="field.value"
class="h-1.5 min-w-0 flex-1 cursor-pointer appearance-none rounded-full bg-muted accent-primary"
:disabled="field.source === 'env'"
@input="onUploadMaxFileSizeInput(field.kind, $event)"
>
<div class="flex shrink-0 items-center gap-1.5">
<UInput
:model-value="field.value"
type="number"
:min="0"
:max="field.max"
size="xs"
class="w-16 tabular-nums"
:disabled="field.source === 'env'"
@update:model-value="onUploadMaxFileSizeNumberInput(field.kind, $event)"
/>
<span class="text-xs text-muted">MB</span>
</div>
</div>
</div>
</div>
<div class="border-t border-default/50 py-4">
<div class="flex flex-wrap items-center gap-2">
<p class="text-sm font-medium text-highlighted">
{{ t('settings.allowedMimeTypes') }}
</p>
<UBadge
v-if="allowedMimeTypesSource"
:color="sourceBadge(allowedMimeTypesSource).color"
variant="subtle"
size="xs"
>
{{ sourceBadge(allowedMimeTypesSource).label }}
</UBadge>
</div>
<p class="mt-1 text-xs leading-relaxed text-muted">
{{ t('settings.allowedMimeTypesHint') }}
</p>
<div
class="mt-3 flex flex-wrap gap-2"
:class="allowedMimeTypesSource === 'env' ? 'pointer-events-none opacity-60' : ''"
>
<label
v-for="option in mimeTypeOptions ?? []"
:key="option.mime"
class="inline-flex cursor-pointer items-center gap-2 rounded-lg border px-3 py-2 text-sm transition-colors"
:class="isMimeChecked(option.mime)
? 'border-primary/40 bg-primary/5'
: 'border-default bg-muted/20 hover:bg-muted/40'"
:title="option.mime"
>
<UCheckbox
:model-value="isMimeChecked(option.mime)"
@update:model-value="(v) => toggleMime(option.mime, v === true)"
@click.stop
/>
<span class="font-medium text-highlighted">{{ option.label }}</span>
</label>
</div>
</div>
<div class="border-t border-default/50 py-4">
<div class="flex flex-wrap items-center gap-2">
<p class="text-sm font-medium text-highlighted">
{{ t('settings.allowedVideoExtensions') }}
</p>
<UBadge
v-if="allowedVideoExtensionsSource"
:color="sourceBadge(allowedVideoExtensionsSource).color"
variant="subtle"
size="xs"
>
{{ sourceBadge(allowedVideoExtensionsSource).label }}
</UBadge>
</div>
<p class="mt-1 text-xs leading-relaxed text-muted">
{{ t('settings.allowedVideoExtensionsHint') }}
</p>
<div
class="mt-3 flex flex-wrap gap-2"
:class="allowedVideoExtensionsSource === 'env' ? 'pointer-events-none opacity-60' : ''"
>
<label
v-for="option in videoExtensionOptions ?? []"
:key="option.extension"
class="inline-flex cursor-pointer items-center gap-2 rounded-lg border px-3 py-2 text-sm transition-colors"
:class="isVideoExtensionChecked(option.extension)
? 'border-primary/40 bg-primary/5'
: 'border-default bg-muted/20 hover:bg-muted/40'"
>
<UCheckbox
:model-value="isVideoExtensionChecked(option.extension)"
@update:model-value="(v) => toggleVideoExtension(option.extension, v === true)"
@click.stop
/>
<span class="font-medium text-highlighted">{{ option.label }}</span>
</label>
</div>
</div>
<div class="border-t border-default/50 py-4">
<div class="flex flex-wrap items-center gap-2">
<p class="text-sm font-medium text-highlighted">
{{ t('settings.forbiddenFileExtensions') }}
</p>
<UBadge
v-if="forbiddenFileExtensionsSource"
:color="sourceBadge(forbiddenFileExtensionsSource).color"
variant="subtle"
size="xs"
>
{{ sourceBadge(forbiddenFileExtensionsSource).label }}
</UBadge>
</div>
<p class="mt-1 text-xs leading-relaxed text-muted">
{{ t('settings.forbiddenFileExtensionsHint') }}
</p>
<div
class="mt-3 flex flex-wrap gap-2"
:class="forbiddenFileExtensionsSource === 'env' ? 'pointer-events-none opacity-60' : ''"
>
<label
v-for="option in allForbiddenExtensionOptions"
:key="option.extension"
class="inline-flex cursor-pointer items-center gap-2 rounded-lg border px-3 py-2 text-sm transition-colors"
:class="isForbiddenExtensionChecked(option.extension)
? 'border-error/40 bg-error/5'
: 'border-default bg-muted/20 hover:bg-muted/40'"
>
<UCheckbox
:model-value="isForbiddenExtensionChecked(option.extension)"
@update:model-value="(v) => toggleForbiddenExtension(option.extension, v === true)"
@click.stop
/>
<span class="font-medium text-highlighted">{{ option.label }}</span>
</label>
</div>
<div
class="mt-3 flex max-w-md items-center gap-2"
:class="forbiddenFileExtensionsSource === 'env' ? 'pointer-events-none opacity-60' : ''"
>
<UInput
v-model="customForbiddenExtension"
size="sm"
class="min-w-0 flex-1"
:placeholder="t('settings.forbiddenFileExtensionsCustomPlaceholder')"
:disabled="forbiddenFileExtensionsSource === 'env'"
@keydown.enter.prevent="addCustomForbiddenExtension"
/>
<UButton
:label="t('settings.forbiddenFileExtensionsAdd')"
size="sm"
variant="outline"
color="neutral"
:disabled="forbiddenFileExtensionsSource === 'env'"
@click="addCustomForbiddenExtension"
/>
</div>
</div>
<div class="py-4">
<p class="text-sm font-medium text-highlighted">
{{ t('settings.uploadRateGroup') }}
</p>
<p class="mt-1 text-xs leading-relaxed text-muted">
{{ t('settings.uploadRateFootnote') }}
</p>
<div class="mt-3 grid gap-4 sm:grid-cols-3">
<div>
<div class="flex flex-wrap items-center gap-2">
<p class="text-xs font-medium text-muted">
{{ t('settings.uploadRateIpMax') }}
</p>
<UBadge
v-if="uploadRateIpMaxSource"
:color="sourceBadge(uploadRateIpMaxSource).color"
variant="subtle"
size="xs"
>
{{ sourceBadge(uploadRateIpMaxSource).label }}
</UBadge>
</div>
<UInput
v-model.number="uploadRateIpMaxDraft"
type="number"
:min="1"
:max="1000"
size="sm"
class="mt-2 w-full tabular-nums"
:disabled="uploadRateIpMaxSource === 'env'"
/>
</div>
<div>
<div class="flex flex-wrap items-center gap-2">
<p class="text-xs font-medium text-muted">
{{ t('settings.uploadRateTokenMax') }}
</p>
<UBadge
v-if="uploadRateTokenMaxSource"
:color="sourceBadge(uploadRateTokenMaxSource).color"
variant="subtle"
size="xs"
>
{{ sourceBadge(uploadRateTokenMaxSource).label }}
</UBadge>
</div>
<UInput
v-model.number="uploadRateTokenMaxDraft"
type="number"
:min="1"
:max="5000"
size="sm"
class="mt-2 w-full tabular-nums"
:disabled="uploadRateTokenMaxSource === 'env'"
/>
</div>
<div>
<div class="flex flex-wrap items-center gap-2">
<p class="text-xs font-medium text-muted">
{{ t('settings.uploadRateWindowMinutes') }}
</p>
<UBadge
v-if="uploadRateWindowMinutesSource"
:color="sourceBadge(uploadRateWindowMinutesSource).color"
variant="subtle"
size="xs"
>
{{ sourceBadge(uploadRateWindowMinutesSource).label }}
</UBadge>
</div>
<div class="mt-2 flex items-center gap-2">
<UInput
v-model.number="uploadRateWindowMinutesDraft"
type="number"
:min="1"
:max="1440"
size="sm"
class="w-full tabular-nums"
:disabled="uploadRateWindowMinutesSource === 'env'"
/>
<span class="shrink-0 text-xs text-muted">{{ t('settings.minutesUnit') }}</span>
</div>
</div>
</div>
</div>
</template>
<SettingsToggleRow
v-else-if="part === 'image-processing'"
v-model="preserveOriginalUploadDraft!"
:disabled="preserveOriginalUploadSource === 'env'"
>
<template #title>
<span class="inline-flex flex-wrap items-center gap-2">
{{ t('settings.preserveOriginalUpload') }}
<UBadge
v-if="preserveOriginalUploadSource"
:color="sourceBadge(preserveOriginalUploadSource).color"
variant="subtle"
size="xs"
>
{{ sourceBadge(preserveOriginalUploadSource).label }}
</UBadge>
</span>
</template>
<template #hint>
{{ t('settings.preserveOriginalUploadHint') }}
</template>
</SettingsToggleRow>
<template v-else-if="part === 'login-rate'">
<div class="grid gap-4 py-4 sm:grid-cols-2">
<div>
<div class="flex flex-wrap items-center gap-2">
<p class="text-sm font-medium text-highlighted">
{{ t('settings.loginRateMax') }}
</p>
<UBadge
v-if="loginRateMaxSource"
:color="sourceBadge(loginRateMaxSource).color"
variant="subtle"
size="xs"
>
{{ sourceBadge(loginRateMaxSource).label }}
</UBadge>
</div>
<UInput
v-model.number="loginRateMaxDraft"
type="number"
:min="1"
:max="100"
size="sm"
class="mt-2 w-full tabular-nums"
:disabled="loginRateMaxSource === 'env'"
/>
</div>
<div>
<div class="flex flex-wrap items-center gap-2">
<p class="text-sm font-medium text-highlighted">
{{ t('settings.loginRateWindowMinutes') }}
</p>
<UBadge
v-if="loginRateWindowMinutesSource"
:color="sourceBadge(loginRateWindowMinutesSource).color"
variant="subtle"
size="xs"
>
{{ sourceBadge(loginRateWindowMinutesSource).label }}
</UBadge>
</div>
<div class="mt-2 flex items-center gap-2">
<UInput
v-model.number="loginRateWindowMinutesDraft"
type="number"
:min="1"
:max="1440"
size="sm"
class="w-full tabular-nums"
:disabled="loginRateWindowMinutesSource === 'env'"
/>
<span class="shrink-0 text-xs text-muted">{{ t('settings.minutesUnit') }}</span>
</div>
</div>
</div>
<p class="pb-4 text-xs leading-relaxed text-muted">
{{ t('settings.loginRateFootnote') }}
</p>
</template>
</template>
@@ -0,0 +1,159 @@
<script setup lang="ts">
import type { UploadMediaKind } from '~/composables/useUploadPreferences'
const props = withDefaults(defineProps<{
part: 'upload-preference' | 'image-processing'
kind?: UploadMediaKind
}>(), {
kind: 'image'
})
const {
compressEnabled,
autoCopyMarkdown,
copyFormat,
clientWebpQuality,
loadPreferences
} = useUploadPreferences(props.kind)
const { t } = useI18n()
const {
userSettings,
enabled: userAutoDeleteEnabled,
daysDraft: userAutoDeleteDaysDraft,
load: loadUserSettings
} = useUserAutoDeleteSettings()
const copyFormatItems = computed(() => [
{ label: t('copy.url'), value: 'url' as const },
{ label: t('copy.markdown'), value: 'markdown' as const },
{ label: t('copy.html'), value: 'html' as const },
{ label: t('copy.bbcode'), value: 'bbcode' as const }
])
function setCopyFormat(value: 'url' | 'markdown' | 'html' | 'bbcode') {
copyFormat.value = value
}
function onClientQualityInput(event: Event) {
const value = Number((event.target as HTMLInputElement).value)
clientWebpQuality.value = Number.isFinite(value) ? value : 80
}
function onClientQualityNumberInput(value: string | number) {
const num = Number(value)
clientWebpQuality.value = Number.isFinite(num) ? Math.min(100, Math.max(1, Math.round(num))) : 80
}
onMounted(() => {
loadPreferences()
void loadUserSettings()
})
</script>
<template>
<template v-if="part === 'image-processing'">
<SettingsToggleRow
v-model="compressEnabled"
:title="t('preferences.clientCompress')"
:hint="t('preferences.clientCompressHint')"
/>
<div
v-if="userSettings"
class="flex items-start justify-between gap-4 py-4"
>
<div class="min-w-0 flex-1 pr-2">
<p class="flex flex-wrap items-center gap-x-1.5 gap-y-1 text-sm font-medium text-highlighted">
<span>{{ t('preferences.autoDeletePrefix') }}</span>
<UInput
v-model.number="userAutoDeleteDaysDraft"
type="number"
min="1"
max="3650"
size="xs"
class="w-14 tabular-nums"
:class="userAutoDeleteEnabled ? '' : 'pointer-events-none opacity-40'"
/>
<span>{{ t('preferences.autoDeleteSuffix') }}</span>
</p>
<p class="mt-1 text-xs leading-relaxed text-muted">
{{ t('preferences.autoDeleteHint') }}
</p>
</div>
<USwitch
v-model="userAutoDeleteEnabled"
class="mt-0.5 shrink-0"
/>
</div>
<div
v-else
class="flex justify-center py-4"
>
<UIcon
name="i-lucide-loader-circle"
class="size-5 animate-spin text-muted"
/>
</div>
<div class="py-4">
<div class="min-w-0">
<p class="text-sm font-medium text-highlighted">
{{ t('preferences.webpQuality') }}
</p>
<p class="mt-1 text-xs leading-relaxed text-muted">
{{ t('preferences.webpQualityHint') }}
</p>
</div>
<div class="mt-3 flex items-center gap-3">
<input
type="range"
min="1"
max="100"
:value="clientWebpQuality"
class="h-1.5 min-w-0 flex-1 cursor-pointer appearance-none rounded-full bg-muted accent-primary"
@input="onClientQualityInput"
>
<UInput
:model-value="clientWebpQuality"
type="number"
min="1"
max="100"
size="xs"
class="w-14 shrink-0 tabular-nums"
@update:model-value="onClientQualityNumberInput"
/>
</div>
</div>
</template>
<template v-else>
<SettingsToggleRow
v-model="autoCopyMarkdown"
:title="t('preferences.autoCopy')"
:hint="t('preferences.autoCopyHint')"
/>
<div class="py-4">
<p class="text-sm font-medium text-highlighted">
{{ t('settings.defaultCopyFormat') }}
</p>
<p class="mt-1 text-xs leading-relaxed text-muted">
{{ t('settings.defaultCopyFormatHint') }}
</p>
<div
class="mt-3 flex w-full max-w-md gap-1 rounded-lg border border-default p-0.5"
:class="autoCopyMarkdown ? '' : 'pointer-events-none opacity-40'"
>
<UButton
v-for="item in copyFormatItems"
:key="item.value"
size="xs"
class="flex-1 justify-center"
:variant="copyFormat === item.value ? 'solid' : 'ghost'"
:color="copyFormat === item.value ? 'primary' : 'neutral'"
:label="item.label"
@click="setCopyFormat(item.value)"
/>
</div>
</div>
</template>
</template>
@@ -0,0 +1,43 @@
<script setup lang="ts">
const { transcodeToHls, loadPreferences } = useUploadPreferences('video')
const { t } = useI18n()
const {
lowMemory,
memoryBytes,
load: loadTranscodeSettings
} = useTranscodeSettings()
const memoryGb = computed(() => (
memoryBytes.value > 0
? (memoryBytes.value / (1024 * 1024 * 1024)).toFixed(1)
: '—'
))
onMounted(() => {
loadPreferences()
void loadTranscodeSettings()
})
</script>
<template>
<UAlert
v-if="lowMemory"
color="warning"
variant="subtle"
icon="i-lucide-triangle-alert"
:title="t('preferences.lowMemoryTranscodeTitle')"
:description="t('preferences.lowMemoryTranscodeHint', { memory: memoryGb })"
class="my-4"
/>
<SettingsToggleRow
v-model="transcodeToHls"
:title="t('preferences.transcodeToHls')"
:hint="t('preferences.transcodeToHlsHint')"
/>
<div
v-if="transcodeToHls"
class="border-t border-default/50 py-4"
>
<TranscodeSettingsControls />
</div>
</template>
+444
View File
@@ -0,0 +1,444 @@
<script setup lang="ts">
import type { BackendSyncPreview, SyncDraft } from '~/types/backup'
import type { StorageBackendItem } from '~/types/storage'
const props = defineProps<{
backends: StorageBackendItem[]
syncDraft: SyncDraft | null
highlighted?: boolean
busy: boolean
disabled?: boolean
activeJob?: { progress: number, total: number, message: string } | null
previewSync: (body: { fromBackendId: string, toBackendId: string }) => Promise<BackendSyncPreview>
}>()
const emit = defineEmits<{
sync: [body: {
fromBackendId: string
toBackendId: string
deleteSource: boolean
setDefault: boolean
}]
}>()
const { t, locale } = useI18n()
const { formatFileSize } = useFileSize()
const toast = useToast()
const fromBackendId = ref('')
const toBackendId = ref('')
const deleteSource = ref(false)
const setDefault = ref(false)
const preview = ref<BackendSyncPreview | null>(null)
const previewLoading = ref(false)
const statsLoading = ref(false)
const confirmOpen = ref(false)
const userTouched = ref(false)
let statsTimer: ReturnType<typeof setTimeout> | null = null
const backendOptions = computed(() =>
props.backends.map(backend => ({
label: backend.name,
value: backend.id
}))
)
const backendNameById = computed(() =>
new Map(props.backends.map(backend => [backend.id, backend.name]))
)
const needsMoreBackends = computed(() => props.backends.length < 2)
const syncDisabled = computed(() =>
props.disabled || props.busy || needsMoreBackends.value
)
function formatCount(value: number) {
return value.toLocaleString(locale.value)
}
function initDefaultSelection() {
if (userTouched.value || props.syncDraft) return
const defaultBackend = props.backends.find(backend => backend.isDefault)
const target = props.backends.find(backend => !backend.isDefault && backend.enabled)
if (defaultBackend && target) {
fromBackendId.value = defaultBackend.id
toBackendId.value = target.id
return
}
if (props.backends.length >= 2) {
fromBackendId.value = props.backends[0]!.id
toBackendId.value = props.backends[1]!.id
}
}
function canLoadStats(): boolean {
return !needsMoreBackends.value
&& Boolean(fromBackendId.value)
&& Boolean(toBackendId.value)
&& fromBackendId.value !== toBackendId.value
}
async function refreshStats(immediate = false) {
if (!canLoadStats()) {
preview.value = null
return
}
if (immediate) {
if (statsTimer) clearTimeout(statsTimer)
await loadStats()
return
}
scheduleStatsLoad()
}
async function loadStats() {
if (needsMoreBackends.value || !fromBackendId.value || !toBackendId.value) {
preview.value = null
return
}
if (fromBackendId.value === toBackendId.value) {
preview.value = null
return
}
statsLoading.value = true
try {
preview.value = await props.previewSync({
fromBackendId: fromBackendId.value,
toBackendId: toBackendId.value
})
} catch {
preview.value = null
} finally {
statsLoading.value = false
}
}
function scheduleStatsLoad() {
if (statsTimer) clearTimeout(statsTimer)
statsTimer = setTimeout(() => {
void loadStats()
}, 400)
}
watch(
() => props.backends,
async () => {
initDefaultSelection()
await nextTick()
await refreshStats(true)
},
{ immediate: true }
)
watch(
() => props.syncDraft,
async (draft) => {
if (!draft) return
fromBackendId.value = draft.fromBackendId
toBackendId.value = draft.toBackendId
userTouched.value = true
await refreshStats(true)
},
{ immediate: true }
)
watch([fromBackendId, toBackendId], () => {
void refreshStats()
})
onBeforeUnmount(() => {
if (statsTimer) clearTimeout(statsTimer)
})
function applyDraft(draft: SyncDraft) {
fromBackendId.value = draft.fromBackendId
toBackendId.value = draft.toBackendId
userTouched.value = true
void refreshStats(true)
}
defineExpose({ applyDraft })
function backendName(id: string) {
return backendNameById.value.get(id) ?? id
}
function validateSelection(): boolean {
if (needsMoreBackends.value) {
toast.add({ title: t('storage.backup.syncNeedTwoBackends'), color: 'warning' })
return false
}
if (!fromBackendId.value || !toBackendId.value) {
toast.add({ title: t('storage.backup.syncSelectRequired'), color: 'warning' })
return false
}
if (fromBackendId.value === toBackendId.value) {
toast.add({ title: t('storage.backup.syncSameBackend'), color: 'warning' })
return false
}
return true
}
async function submit() {
if (!validateSelection()) return
previewLoading.value = true
try {
preview.value = await props.previewSync({
fromBackendId: fromBackendId.value,
toBackendId: toBackendId.value
})
confirmOpen.value = true
} catch {
toast.add({ title: t('storage.backup.previewFailed'), color: 'error' })
} finally {
previewLoading.value = false
}
}
function confirmSync() {
emit('sync', {
fromBackendId: fromBackendId.value,
toBackendId: toBackendId.value,
deleteSource: deleteSource.value,
setDefault: setDefault.value
})
confirmOpen.value = false
}
</script>
<template>
<article
class="flex h-full flex-col rounded-xl border bg-elevated p-4 transition-colors sm:p-5"
:class="highlighted ? 'border-primary ring-2 ring-primary/25' : 'border-default'"
>
<div class="flex items-start gap-3">
<div class="flex size-10 shrink-0 items-center justify-center rounded-lg bg-info/10 text-info">
<UIcon
name="i-lucide-arrow-left-right"
class="size-5"
/>
</div>
<div class="min-w-0">
<h3 class="text-base font-semibold">
{{ t('storage.backup.syncTitle') }}
</h3>
<p class="mt-0.5 text-sm text-muted">
{{ t('storage.backup.syncSubtitle') }}
</p>
</div>
</div>
<div class="mt-4 flex flex-1 flex-col">
<UAlert
v-if="needsMoreBackends"
color="warning"
variant="subtle"
icon="i-lucide-triangle-alert"
:description="t('storage.backup.syncNeedTwoBackends')"
/>
<div
v-else
class="flex flex-1 flex-col space-y-3"
>
<div class="grid gap-3 sm:grid-cols-[1fr_auto_1fr] sm:items-end">
<div class="space-y-1.5">
<label class="text-xs text-muted">{{ t('storage.backup.syncFrom') }}</label>
<USelect
v-model="fromBackendId"
:items="backendOptions"
size="sm"
class="w-full"
:disabled="syncDisabled"
@update:model-value="userTouched = true"
/>
</div>
<UIcon
name="i-lucide-arrow-right"
class="hidden size-4 shrink-0 text-muted sm:mb-2.5 sm:block"
/>
<div class="space-y-1.5">
<label class="text-xs text-muted">{{ t('storage.backup.syncTo') }}</label>
<USelect
v-model="toBackendId"
:items="backendOptions"
size="sm"
class="w-full"
:disabled="syncDisabled"
@update:model-value="userTouched = true"
/>
</div>
</div>
<dl class="space-y-1.5 rounded-lg bg-muted/25 px-3 py-2.5 text-sm">
<div class="flex items-baseline justify-between gap-2">
<dt class="text-muted">
{{ t('storage.backup.syncPendingImages') }}
</dt>
<dd class="font-medium tabular-nums">
<template v-if="statsLoading">
…
</template>
<template v-else-if="preview">
{{ formatCount(preview.imageCount) }}
</template>
<template v-else>
—
</template>
</dd>
</div>
<div class="flex items-baseline justify-between gap-2">
<dt class="text-muted">
{{ t('storage.backup.syncPendingSize') }}
</dt>
<dd class="font-medium tabular-nums">
<template v-if="statsLoading">
…
</template>
<template v-else-if="preview">
{{ formatFileSize(preview.imageBytes) }}
</template>
<template v-else>
—
</template>
</dd>
</div>
</dl>
<div class="space-y-2 text-xs">
<label class="flex cursor-pointer items-center gap-2">
<UCheckbox
v-model="deleteSource"
:disabled="syncDisabled"
/>
<span class="text-muted">{{ t('storage.backup.syncDeleteSource') }}</span>
</label>
<label class="flex cursor-pointer items-center gap-2">
<UCheckbox
v-model="setDefault"
:disabled="syncDisabled"
/>
<span class="text-muted">{{ t('storage.backup.syncSetDefault') }}</span>
</label>
</div>
<div class="mt-auto rounded-lg bg-muted/15 px-3 py-2.5">
<p class="text-xs font-medium text-default">
{{ t('storage.backup.syncIncludesTitle') }}
</p>
<ul class="mt-2 space-y-1.5 text-xs leading-relaxed text-muted">
<li class="flex items-start gap-2">
<UIcon
name="i-lucide-check"
class="mt-0.5 size-3.5 shrink-0 text-info"
/>
<span>{{ t('storage.backup.syncIncludesCopy') }}</span>
</li>
<li class="flex items-start gap-2">
<UIcon
name="i-lucide-check"
class="mt-0.5 size-3.5 shrink-0 text-info"
/>
<span>{{ t('storage.backup.syncIncludesIndex') }}</span>
</li>
<li class="flex items-start gap-2">
<UIcon
name="i-lucide-minus"
class="mt-0.5 size-3.5 shrink-0 text-muted"
/>
<span>{{ t('storage.backup.syncIncludesConfig') }}</span>
</li>
</ul>
</div>
<StorageBackupJobProgress
v-if="busy && activeJob"
class="mt-auto"
:message="activeJob.message"
:progress="activeJob.progress"
:total="activeJob.total"
/>
</div>
</div>
<div
v-if="!needsMoreBackends"
class="mt-auto border-t border-default pt-4"
>
<UButton
:label="t('storage.backup.syncPreview')"
icon="i-lucide-play"
size="sm"
:loading="previewLoading || busy"
:disabled="syncDisabled"
@click="submit"
/>
</div>
<UModal
v-model:open="confirmOpen"
:ui="{ content: 'max-w-md' }"
>
<template #content>
<div class="p-5 sm:p-6">
<h2 class="text-base font-semibold">
{{ t('storage.backup.syncConfirmTitle') }}
</h2>
<p class="mt-2 text-sm text-muted">
{{ backendName(fromBackendId) }} → {{ backendName(toBackendId) }}
</p>
<dl
v-if="preview"
class="mt-4 grid grid-cols-2 gap-3 text-sm"
>
<div>
<dt class="text-xs text-muted">
{{ t('storage.backup.previewImages') }}
</dt>
<dd class="mt-0.5 font-semibold tabular-nums">
{{ formatCount(preview.imageCount) }}
</dd>
</div>
<div>
<dt class="text-xs text-muted">
{{ t('storage.backup.previewSize') }}
</dt>
<dd class="mt-0.5 font-semibold tabular-nums">
{{ formatFileSize(preview.imageBytes) }}
</dd>
</div>
</dl>
<UAlert
v-if="deleteSource"
class="mt-4"
color="warning"
variant="subtle"
icon="i-lucide-triangle-alert"
:description="t('storage.backup.syncDeleteWarning')"
/>
<div class="mt-5 flex justify-end gap-2">
<UButton
:label="t('common.cancel')"
size="sm"
variant="outline"
color="neutral"
@click="() => { confirmOpen = false }"
/>
<UButton
:label="t('storage.backup.syncConfirm')"
size="sm"
icon="i-lucide-play"
:loading="busy"
@click="confirmSync"
/>
</div>
</div>
</template>
</UModal>
</article>
</template>
+141
View File
@@ -0,0 +1,141 @@
<script setup lang="ts">
import type { BackupEstimate } from '~/types/backup'
defineProps<{
estimate: BackupEstimate | null
loading: boolean
busy: boolean
disabled?: boolean
activeJob?: { progress: number, total: number, message: string } | null
}>()
const emit = defineEmits<{
export: []
}>()
const { t } = useI18n()
const { formatFileSize } = useFileSize()
</script>
<template>
<article class="flex h-full flex-col rounded-xl border border-default bg-elevated p-4 sm:p-5">
<div class="flex items-start gap-3">
<div class="flex size-10 shrink-0 items-center justify-center rounded-lg bg-primary/10 text-primary">
<UIcon
name="i-lucide-archive"
class="size-5"
/>
</div>
<div class="min-w-0">
<h3 class="text-base font-semibold">
{{ t('storage.backup.exportTitle') }}
</h3>
<p class="mt-0.5 text-sm text-muted">
{{ t('storage.backup.exportSubtitle') }}
</p>
</div>
</div>
<div class="mt-4 flex flex-1 flex-col space-y-2">
<div
v-if="loading"
class="flex items-center gap-2 text-sm text-muted"
>
<UIcon
name="i-lucide-loader-circle"
class="size-4 animate-spin"
/>
{{ t('storage.backup.estimateLoading') }}
</div>
<dl
v-else-if="estimate"
class="space-y-1.5 rounded-lg bg-muted/25 px-3 py-2.5 text-sm"
>
<div class="flex items-baseline justify-between gap-2">
<dt class="text-muted">
{{ t('storage.backup.estimateImageCountLabel') }}
</dt>
<dd class="font-medium tabular-nums">
{{ estimate.imageCount }} · {{ formatFileSize(estimate.imageBytes) }}
</dd>
</div>
<div class="flex items-baseline justify-between gap-2">
<dt class="text-muted">
{{ t('storage.backup.estimateDatabaseSnapshotLabel') }}
</dt>
<dd class="font-medium tabular-nums">
{{ formatFileSize(estimate.databaseBytes) }}
</dd>
</div>
<div class="flex items-baseline justify-between gap-2 border-t border-default/40 pt-1.5">
<dt class="font-medium text-default">
{{ t('storage.backup.estimatePackageSizeLabel') }}
</dt>
<dd class="font-semibold tabular-nums">
{{ formatFileSize(estimate.totalBytes) }}
</dd>
</div>
<div class="flex items-baseline justify-between gap-2 border-t border-default/40 pt-1.5">
<dt class="text-muted">
{{ t('storage.backup.estimateStoragePathLabel') }}
</dt>
<dd class="truncate font-mono text-xs font-medium">
{{ t('storage.backup.estimateStoragePathValue') }}
</dd>
</div>
</dl>
<div
v-if="estimate && !loading"
class="mt-auto rounded-lg bg-muted/15 px-3 py-2.5"
>
<p class="text-xs font-medium text-default">
{{ t('storage.backup.exportIncludesTitle') }}
</p>
<ul class="mt-2 space-y-1.5 text-xs leading-relaxed text-muted">
<li class="flex items-start gap-2">
<UIcon
name="i-lucide-check"
class="mt-0.5 size-3.5 shrink-0 text-primary"
/>
<span>{{ t('storage.backup.exportIncludesDb') }}</span>
</li>
<li class="flex items-start gap-2">
<UIcon
name="i-lucide-check"
class="mt-0.5 size-3.5 shrink-0 text-primary"
/>
<span>{{ t('storage.backup.exportIncludesImages', { count: estimate.imageCount }) }}</span>
</li>
<li class="flex items-start gap-2">
<UIcon
name="i-lucide-minus"
class="mt-0.5 size-3.5 shrink-0 text-muted"
/>
<span>{{ t('storage.backup.exportExcludesSecrets') }}</span>
</li>
</ul>
</div>
<StorageBackupJobProgress
v-if="busy && activeJob"
class="mt-2"
:message="activeJob.message"
:progress="activeJob.progress"
:total="activeJob.total"
/>
</div>
<div class="mt-auto border-t border-default pt-4">
<UButton
:label="t('storage.backup.exportAction')"
icon="i-lucide-download"
size="sm"
:loading="busy"
:disabled="disabled || busy"
@click="emit('export')"
/>
</div>
</article>
</template>
@@ -0,0 +1,32 @@
<script setup lang="ts">
const props = defineProps<{
message: string
progress: number
total: number
}>()
const percent = computed(() => {
if (!props.total) return 0
return Math.min(100, Math.round((props.progress / props.total) * 100))
})
</script>
<template>
<div class="space-y-1.5">
<div class="flex items-center justify-between gap-2 text-xs text-muted">
<span class="truncate">{{ message }}</span>
<span
v-if="total > 0"
class="shrink-0 tabular-nums"
>
{{ progress }} / {{ total }}
</span>
</div>
<div class="h-1 overflow-hidden rounded-full bg-muted">
<div
class="h-full rounded-full bg-primary transition-all duration-300"
:style="{ width: `${percent}%` }"
/>
</div>
</div>
</template>
+310
View File
@@ -0,0 +1,310 @@
<script setup lang="ts">
import type { BackupJobItem } from '~/types/backup'
defineProps<{
jobs: BackupJobItem[]
polling: boolean
page: number
totalPages: number
total: number
pageSize: number
showHidden: boolean
loading?: boolean
}>()
const emit = defineEmits<{
'retry': [jobId: string]
'download': [jobId: string]
'cancel': [jobId: string]
'hide': [jobId: string, hidden: boolean]
'hideAll': [hidden: boolean]
'toggleHidden': []
'view': [job: BackupJobItem]
'update:page': [page: number]
'update:pageSize': [pageSize: number]
}>()
const { t, locale } = useI18n()
function typeLabel(type: BackupJobItem['type']): string {
const map: Record<BackupJobItem['type'], string> = {
full_export: t('storage.backup.jobTypeExport'),
full_restore: t('storage.backup.jobTypeRestore'),
backend_sync: t('storage.backup.jobTypeSync'),
site_sync: t('storage.backup.jobTypeSiteSync')
}
return map[type]
}
function statusColor(status: BackupJobItem['status']): 'success' | 'error' | 'warning' | 'neutral' {
if (status === 'done') return 'success'
if (status === 'failed') return 'error'
if (status === 'running') return 'warning'
return 'neutral'
}
function statusLabel(status: BackupJobItem['status']): string {
const map: Record<BackupJobItem['status'], string> = {
pending: t('storage.backup.statusPending'),
running: t('storage.backup.statusRunning'),
done: t('storage.backup.statusDone'),
failed: t('storage.backup.statusFailed')
}
return map[status]
}
function progressPercent(job: BackupJobItem): number {
if (!job.total) return job.status === 'done' ? 100 : 0
return Math.min(100, Math.round((job.progress / job.total) * 100))
}
function progressUnit(job: BackupJobItem): string {
return job.type === 'full_restore'
? t('storage.backup.progressUnitObjects')
: t('storage.backup.progressUnitMedia')
}
function jobMessage(job: BackupJobItem): string {
if (job.status === 'failed') {
return job.error || job.message || t('storage.backup.jobFailedUnknown')
}
if (job.status === 'running' || job.status === 'pending') {
return job.message
}
if (job.status === 'done' && job.message) {
return job.message
}
return ''
}
function canRetry(job: BackupJobItem): boolean {
return job.type === 'backend_sync' && job.status === 'failed'
}
function canCancel(job: BackupJobItem): boolean {
return job.status === 'running' || job.status === 'pending'
}
function canView(job: BackupJobItem): boolean {
return job.type === 'full_restore' && job.status === 'done' && Boolean(job.message)
}
function canHide(job: BackupJobItem): boolean {
return job.status === 'done' || job.status === 'failed' || job.hidden
}
function formatTime(value: string) {
if (!value) return '—'
return new Date(value).toLocaleString(locale.value, { hour12: false })
}
</script>
<template>
<section class="overflow-hidden rounded-xl border border-default bg-elevated">
<header class="flex items-center justify-between gap-3 border-b border-default/50 px-4 py-4 sm:px-5">
<div>
<h3 class="text-sm font-semibold">
{{ t('storage.backup.jobsTitle') }}
</h3>
<p class="mt-0.5 text-xs text-muted">
{{ t('storage.backup.jobsSubtitle') }}
</p>
</div>
<div class="flex shrink-0 items-center gap-2">
<UIcon
v-if="polling"
name="i-lucide-loader-circle"
class="size-4 animate-spin text-muted"
/>
<UButton
size="xs"
variant="ghost"
color="neutral"
:icon="showHidden ? 'i-lucide-eye-off' : 'i-lucide-eye'"
:label="showHidden
? t('storage.backup.hiddenJobsHide')
: t('storage.backup.hiddenJobsShow')"
@click="emit('toggleHidden')"
/>
<UButton
size="xs"
variant="outline"
color="neutral"
:icon="showHidden ? 'i-lucide-rotate-ccw' : 'i-lucide-x'"
:label="showHidden
? t('storage.backup.jobsRestoreAll')
: t('storage.backup.jobsHideAll')"
@click="emit('hideAll', !showHidden)"
/>
</div>
</header>
<div
v-if="!jobs.length && !loading"
class="flex flex-col items-center gap-2 px-4 py-12 text-center sm:px-5"
>
<UIcon
name="i-lucide-clipboard-list"
class="size-7 text-muted"
/>
<p class="text-sm text-muted">
{{ t('storage.backup.jobsEmpty') }}
</p>
</div>
<div
v-else
class="overflow-x-auto"
>
<table class="w-full min-w-[800px] text-left text-sm">
<thead>
<tr class="border-b border-default/50 text-xs text-muted">
<th class="px-4 py-3 font-medium sm:px-5">
{{ t('storage.backup.colTask') }}
</th>
<th class="px-4 py-3 font-medium sm:px-5">
{{ t('storage.backup.colStatus') }}
</th>
<th class="px-4 py-3 font-medium sm:px-5">
{{ t('storage.backup.colMessage') }}
</th>
<th class="px-4 py-3 font-medium sm:px-5">
{{ t('storage.backup.colProgress') }}
</th>
<th class="px-4 py-3 font-medium sm:px-5">
{{ t('storage.backup.colUpdated') }}
</th>
<th class="px-4 py-3 font-medium sm:px-5">
{{ t('storage.backup.colActions') }}
</th>
</tr>
</thead>
<tbody>
<tr
v-for="job in jobs"
:key="job.id"
class="border-b border-default/40 last:border-0"
>
<td class="px-4 py-3.5 whitespace-nowrap font-medium sm:px-5">
{{ typeLabel(job.type) }}
</td>
<td class="px-4 py-3.5 sm:px-5">
<UBadge
:color="statusColor(job.status)"
variant="subtle"
size="sm"
>
{{ statusLabel(job.status) }}
</UBadge>
</td>
<td class="max-w-xs px-4 py-3.5 sm:px-5">
<p
v-if="jobMessage(job)"
class="line-clamp-2 text-xs leading-relaxed"
:class="job.status === 'failed' ? 'text-error' : 'text-muted'"
:title="jobMessage(job)"
>
{{ jobMessage(job) }}
</p>
<span
v-else
class="text-xs text-dimmed"
>—</span>
</td>
<td class="min-w-40 px-4 py-3.5 sm:px-5">
<div class="flex items-center gap-2">
<div class="h-1 min-w-0 flex-1 overflow-hidden rounded-full bg-muted">
<div
class="h-full rounded-full bg-primary transition-all"
:class="{ 'animate-pulse': job.status === 'running' }"
:style="{ width: `${progressPercent(job)}%` }"
/>
</div>
<span class="flex shrink-0 flex-col text-right">
<span class="text-xs tabular-nums text-muted">
<template v-if="job.total">{{ job.progress }}/{{ job.total }}</template>
<template v-else-if="job.status === 'done'">100%</template>
<template v-else>—</template>
</span>
<span class="text-[10px] text-dimmed">
{{ progressUnit(job) }}
</span>
</span>
</div>
</td>
<td class="px-4 py-3.5 whitespace-nowrap text-xs text-muted sm:px-5">
{{ formatTime(job.updatedAt || job.finishedAt || job.createdAt) }}
</td>
<td class="px-4 py-3.5 sm:px-5">
<div class="flex flex-wrap gap-1.5">
<UButton
v-if="job.downloadable"
:label="t('storage.backup.download')"
icon="i-lucide-download"
size="xs"
variant="outline"
color="neutral"
@click="emit('download', job.id)"
/>
<UButton
v-if="canView(job)"
:label="t('storage.backup.jobView')"
icon="i-lucide-eye"
size="xs"
variant="outline"
color="neutral"
@click="emit('view', job)"
/>
<UButton
v-if="canCancel(job)"
:label="t('storage.backup.cancel')"
icon="i-lucide-x"
size="xs"
variant="outline"
color="warning"
@click="emit('cancel', job.id)"
/>
<UButton
v-if="canRetry(job)"
:label="t('storage.backup.retry')"
icon="i-lucide-rotate-ccw"
size="xs"
variant="outline"
color="neutral"
@click="emit('retry', job.id)"
/>
<UButton
v-if="canHide(job)"
size="xs"
variant="outline"
:color="job.hidden ? 'neutral' : 'error'"
:icon="job.hidden ? 'i-lucide-eye' : 'i-lucide-x'"
:title="job.hidden
? t('storage.backup.jobUnhide')
: t('storage.backup.jobHide')"
@click="emit('hide', job.id, !job.hidden)"
/>
</div>
</td>
</tr>
</tbody>
</table>
</div>
<div
v-if="total > 0"
class="border-t border-default/50 px-4 py-4 sm:px-5"
>
<PaginationBar
:page="page"
:total-pages="totalPages"
:total="total"
:page-size="pageSize"
:loading="loading"
:unit="t('storage.backup.jobsUnit')"
@update:page="emit('update:page', $event)"
@update:page-size="emit('update:pageSize', $event)"
/>
</div>
</section>
</template>
@@ -0,0 +1,322 @@
<script setup lang="ts">
import type { BackupPackageItem, BackupRestorePreview } from '~/types/backup'
const props = defineProps<{
packages: BackupPackageItem[]
busy: boolean
disabled?: boolean
restoreBusy?: boolean
activeJob?: { progress: number, total: number, message: string } | null
previewRestore: (input: { file?: File, archiveName?: string }) => Promise<BackupRestorePreview>
}>()
const emit = defineEmits<{
restore: [input: { file?: File, archiveName?: string, conflictMode: 'skip' | 'overwrite' }]
}>()
const { t, locale } = useI18n()
const { formatFileSize } = useFileSize()
const toast = useToast()
const fileInput = ref<HTMLInputElement | null>(null)
const selectedFile = ref<File | null>(null)
const selectedPackage = ref<string>('')
const conflictMode = ref<'skip' | 'overwrite'>('skip')
const preview = ref<BackupRestorePreview | null>(null)
const previewLoading = ref(false)
const confirmOpen = ref(false)
const conflictItems = computed(() => [
{ label: t('storage.backup.conflictSkip'), value: 'skip' },
{ label: t('storage.backup.conflictOverwrite'), value: 'overwrite' }
])
const selectMenuUi = {
content: 'min-w-[var(--reka-select-trigger-width)] w-max max-w-[min(100vw-2rem,24rem)]',
item: 'whitespace-normal',
itemLabel: 'whitespace-normal'
}
const packageOptions = computed(() =>
props.packages.map(pkg => ({
label: `${shortenName(pkg.name)} (${formatFileSize(pkg.bytes)})`,
value: pkg.name
}))
)
function shortenName(name: string) {
if (name.length <= 36) return name
return `${name.slice(0, 22)}…${name.slice(-10)}`
}
function formatTime(value: string) {
if (!value) return '—'
return new Date(value).toLocaleString(locale.value, { hour12: false })
}
function getErrorMessage(error: unknown) {
const value = error as {
data?: { data?: { message?: string }, message?: string }
statusMessage?: string
message?: string
}
return value?.data?.data?.message
?? value?.data?.message
?? value?.statusMessage
?? value?.message
?? t('storage.backup.previewFailed')
}
function pickFile() {
fileInput.value?.click()
}
function onFileChange(event: Event) {
const input = event.target as HTMLInputElement
const file = input.files?.[0] ?? null
if (!file) return
selectedFile.value = file
selectedPackage.value = ''
preview.value = null
if (input) input.value = ''
}
function selectPackage(name: string | null) {
if (!name) {
selectedPackage.value = ''
return
}
selectedPackage.value = name
selectedFile.value = null
preview.value = null
}
async function submit() {
if (!selectedFile.value && !selectedPackage.value) {
toast.add({ title: t('storage.backup.restoreSelectRequired'), color: 'warning' })
return
}
previewLoading.value = true
try {
preview.value = await props.previewRestore({
file: selectedFile.value ?? undefined,
archiveName: selectedPackage.value || undefined
})
confirmOpen.value = true
} catch (error: unknown) {
toast.add({
title: t('storage.backup.previewFailed'),
description: getErrorMessage(error),
color: 'error'
})
} finally {
previewLoading.value = false
}
}
function confirmRestore() {
emit('restore', {
file: selectedFile.value ?? undefined,
archiveName: selectedPackage.value || undefined,
conflictMode: conflictMode.value
})
confirmOpen.value = false
selectedFile.value = null
selectedPackage.value = ''
preview.value = null
}
const sourceLabel = computed(() => selectedFile.value?.name || selectedPackage.value || '')
</script>
<template>
<article class="flex h-full flex-col rounded-xl border border-default bg-elevated p-4 sm:p-5">
<div class="flex items-start gap-3">
<div class="flex size-10 shrink-0 items-center justify-center rounded-lg bg-warning/10 text-warning">
<UIcon
name="i-lucide-upload"
class="size-5"
/>
</div>
<div class="min-w-0">
<h3 class="text-base font-semibold">
{{ t('storage.backup.restoreTitle') }}
</h3>
<p class="mt-0.5 text-sm text-muted">
{{ t('storage.backup.restoreSubtitle') }}
</p>
</div>
</div>
<div class="mt-4 flex flex-1 flex-col space-y-3">
<div class="space-y-1.5">
<label class="text-xs text-muted">{{ t('storage.backup.restoreUploadLabel') }}</label>
<button
type="button"
class="flex w-full flex-col items-center gap-1.5 rounded-lg border-2 border-dashed px-3 py-2.5 text-center transition-colors disabled:cursor-not-allowed disabled:opacity-60"
:class="selectedFile
? 'border-primary/50 bg-primary/5'
: 'border-warning/40 bg-warning/5 hover:border-warning/60 hover:bg-warning/10'"
:disabled="disabled || busy"
@click="pickFile"
>
<div
class="flex size-8 items-center justify-center rounded-full"
:class="selectedFile ? 'bg-primary/15 text-primary' : 'bg-warning/15 text-warning'"
>
<UIcon
:name="selectedFile ? 'i-lucide-check' : 'i-lucide-upload'"
class="size-4.5"
/>
</div>
<span class="max-w-full truncate text-sm font-medium text-default">
{{ selectedFile?.name ?? t('storage.backup.restoreUpload') }}
</span>
<span
v-if="!selectedFile"
class="text-xs text-muted"
>
{{ t('storage.backup.restoreUploadHint') }}
</span>
</button>
</div>
<div
v-if="packages.length"
class="space-y-1.5"
>
<label class="text-xs text-muted">{{ t('storage.backup.restoreServerPackages') }}</label>
<USelect
:model-value="selectedPackage || undefined"
:items="packageOptions"
size="sm"
class="w-full"
:ui="selectMenuUi"
:placeholder="t('storage.backup.restoreServerSelectPlaceholder')"
:disabled="disabled || busy"
@update:model-value="selectPackage"
/>
</div>
<div class="space-y-2">
<label class="text-xs text-muted">{{ t('storage.backup.conflictMode') }}</label>
<URadioGroup
v-model="conflictMode"
:items="conflictItems"
:disabled="disabled || busy"
/>
</div>
<StorageBackupJobProgress
v-if="restoreBusy && activeJob"
class="mt-auto"
:message="activeJob.message"
:progress="activeJob.progress"
:total="activeJob.total"
/>
</div>
<div class="mt-auto border-t border-default pt-4">
<UButton
:label="t('storage.backup.restorePreviewConfirm')"
icon="i-lucide-search"
size="sm"
:loading="previewLoading || restoreBusy"
:disabled="disabled || busy"
@click="submit"
/>
</div>
<input
ref="fileInput"
type="file"
accept=".phost.tar.gz,.tar.gz"
class="hidden"
@change="onFileChange"
>
<UModal
v-model:open="confirmOpen"
:ui="{ content: 'max-w-md' }"
>
<template #content>
<div class="p-5 sm:p-6">
<h2 class="text-base font-semibold">
{{ t('storage.backup.restoreConfirmTitle') }}
</h2>
<p
v-if="sourceLabel"
class="mt-2 truncate font-mono text-xs text-muted"
:title="sourceLabel"
>
{{ sourceLabel }}
</p>
<dl
v-if="preview"
class="mt-4 grid grid-cols-2 gap-3 text-sm"
>
<div>
<dt class="text-xs text-muted">
{{ t('storage.backup.previewImages') }}
</dt>
<dd class="mt-0.5 font-semibold tabular-nums">
{{ preview.imageCount }}
</dd>
</div>
<div>
<dt class="text-xs text-muted">
{{ t('storage.backup.previewNew') }}
</dt>
<dd class="mt-0.5 font-semibold tabular-nums">
{{ preview.newImages }}
</dd>
</div>
<div>
<dt class="text-xs text-muted">
{{ t('storage.backup.previewConflicts') }}
</dt>
<dd class="mt-0.5 font-semibold tabular-nums">
{{ preview.conflictingImages }}
</dd>
</div>
<div>
<dt class="text-xs text-muted">
{{ t('storage.backup.previewCreated') }}
</dt>
<dd class="mt-0.5 text-xs">
{{ formatTime(preview.createdAt) }}
</dd>
</div>
</dl>
<UAlert
v-if="conflictMode === 'overwrite'"
class="mt-4"
color="warning"
variant="subtle"
icon="i-lucide-triangle-alert"
:description="t('storage.backup.restoreOverwriteWarning')"
/>
<div class="mt-5 flex justify-end gap-2">
<UButton
:label="t('common.cancel')"
size="sm"
variant="outline"
color="neutral"
@click="() => { confirmOpen = false }"
/>
<UButton
:label="t('storage.backup.restoreConfirm')"
size="sm"
color="warning"
:loading="restoreBusy"
@click="confirmRestore"
/>
</div>
</div>
</template>
</UModal>
</article>
</template>
+330
View File
@@ -0,0 +1,330 @@
<script setup lang="ts">
import type {
SiteSyncDirection,
SiteSyncRequest
} from '~/types/backup'
import { loadSiteSyncForm, saveSiteSyncForm } from '~/utils/site-sync-form'
const props = defineProps<{
busy: boolean
disabled?: boolean
activeJob?: {
progress: number
total: number
message: string
payload: Record<string, unknown>
} | null
resumableJob?: {
id: string
payload: Record<string, unknown>
} | null
}>()
const emit = defineEmits<{
sync: [body: SiteSyncRequest]
}>()
const { t } = useI18n()
const targetBaseUrl = ref('')
const token = ref('')
const conflictMode = ref<'skip' | 'overwrite'>('skip')
const direction = ref<SiteSyncDirection>('push')
const scope = ref<'all' | 'image' | 'video' | 'file'>('all')
function loadSavedForm() {
if (!import.meta.client) return
const saved = loadSiteSyncForm()
if (saved?.targetBaseUrl) targetBaseUrl.value = saved.targetBaseUrl
if (saved?.token) token.value = saved.token
}
function persistForm() {
saveSiteSyncForm(targetBaseUrl.value, token.value)
}
watch([targetBaseUrl, token], persistForm)
onMounted(loadSavedForm)
const scopeItems = computed(() => [
{ value: 'all' as const, label: t('storage.backup.siteSyncScopeAll') },
{ value: 'image' as const, label: t('storage.backup.siteSyncScopeImage') },
{ value: 'video' as const, label: t('storage.backup.siteSyncScopeVideo') },
{ value: 'file' as const, label: t('storage.backup.siteSyncScopeFile') }
])
watch(() => props.resumableJob, (job) => {
if (!job) return
direction.value = job.payload.direction === 'pull' ? 'pull' : 'push'
if (typeof job.payload.targetBaseUrl === 'string') {
targetBaseUrl.value = job.payload.targetBaseUrl
}
}, { immediate: true })
const canSubmit = computed(() =>
!props.busy
&& !props.disabled
&& Boolean(targetBaseUrl.value.trim())
&& Boolean(token.value.trim())
)
const canResume = computed(() =>
!props.busy
&& !props.disabled
&& Boolean(props.resumableJob)
&& Boolean(token.value.trim())
)
const fileProgress = computed(() => {
const payload = props.activeJob?.payload
return {
synced: Number(payload?.filesSynced ?? 0),
total: Number(payload?.totalFiles ?? 0)
}
})
const isPull = computed(() => direction.value === 'pull')
const canAbandon = computed(() =>
!props.busy
&& Boolean(props.resumableJob)
)
const abandonConfirm = computed(() => (
isPull.value || token.value.trim()
? t('storage.backup.siteSyncAbandonConfirm')
: t('storage.backup.siteSyncAbandonWithoutTokenConfirm')
))
const directionSubtitle = computed(() => (
isPull.value
? t('storage.backup.siteSyncPullSubtitle')
: t('storage.backup.siteSyncPushSubtitle')
))
const endpointLabel = computed(() => (
isPull.value
? t('storage.backup.siteSyncSource')
: t('storage.backup.siteSyncTarget')
))
const tokenLabel = computed(() => (
isPull.value
? t('storage.backup.siteSyncSourceToken')
: t('storage.backup.siteSyncToken')
))
const tokenPlaceholder = computed(() => (
isPull.value
? t('storage.backup.siteSyncSourceTokenPlaceholder')
: t('storage.backup.siteSyncTokenPlaceholder')
))
const startLabel = computed(() => (
isPull.value
? t('storage.backup.siteSyncStartPull')
: t('storage.backup.siteSyncStartPush')
))
function submit(resume = false) {
if (resume ? !canResume.value : !canSubmit.value) return
emit('sync', {
targetBaseUrl: targetBaseUrl.value.trim(),
token: token.value.trim(),
conflictMode: conflictMode.value,
direction: direction.value,
scope: scope.value,
...(resume && props.resumableJob ? { resumeJobId: props.resumableJob.id } : {})
})
}
function startNew() {
submit(false)
}
function resumeSync() {
submit(true)
}
function abandonSync() {
if (!canAbandon.value || !props.resumableJob) return
if (!window.confirm(abandonConfirm.value)) return
emit('sync', {
targetBaseUrl: targetBaseUrl.value.trim(),
token: token.value.trim(),
conflictMode: conflictMode.value,
direction: direction.value,
abandonJobId: props.resumableJob.id
})
}
function setConflictMode(mode: 'skip' | 'overwrite') {
conflictMode.value = mode
}
function setDirection(value: SiteSyncDirection) {
direction.value = value
}
function setScope(value: 'all' | 'image' | 'video' | 'file') {
scope.value = value
}
</script>
<template>
<article class="rounded-xl border border-default bg-elevated p-4 sm:p-5">
<div class="flex items-start gap-3">
<div class="flex size-10 shrink-0 items-center justify-center rounded-lg bg-primary/10 text-primary">
<UIcon
name="i-lucide-server-cog"
class="size-5"
/>
</div>
<div class="min-w-0">
<h3 class="text-base font-semibold">
{{ t('storage.backup.siteSyncTitle') }}
</h3>
<p class="mt-0.5 text-sm leading-relaxed text-muted">
{{ directionSubtitle }}
</p>
</div>
</div>
<div class="mt-4 grid gap-3 lg:grid-cols-[minmax(0,1.2fr)_minmax(0,1fr)_auto] lg:items-end">
<div class="space-y-1.5">
<label class="text-xs text-muted">{{ endpointLabel }}</label>
<UInput
v-model="targetBaseUrl"
placeholder="https://target.example.com"
size="sm"
class="w-full"
:disabled="busy || disabled"
/>
</div>
<div class="space-y-1.5">
<label class="text-xs text-muted">{{ tokenLabel }}</label>
<UInput
v-model="token"
type="password"
:placeholder="tokenPlaceholder"
size="sm"
class="w-full"
:disabled="busy || disabled"
/>
</div>
<UButton
:label="startLabel"
:icon="isPull ? 'i-lucide-cloud-download' : 'i-lucide-cloud-upload'"
color="primary"
:loading="busy"
:disabled="!canSubmit"
@click="startNew"
/>
<UButton
v-if="resumableJob"
:label="t('storage.backup.siteSyncResume')"
icon="i-lucide-play"
color="warning"
:loading="busy"
:disabled="!canResume"
class="lg:col-start-3"
@click="resumeSync"
/>
<UButton
v-if="resumableJob"
:label="t('storage.backup.siteSyncAbandon')"
icon="i-lucide-trash-2"
color="error"
variant="outline"
:loading="busy"
:disabled="!canAbandon"
class="lg:col-start-3"
@click="abandonSync"
/>
</div>
<div class="mt-3 grid gap-3 sm:grid-cols-2">
<div class="flex flex-wrap items-center gap-2">
<span class="text-xs text-muted">{{ t('storage.backup.siteSyncDirection') }}</span>
<UButton
size="xs"
:variant="direction === 'push' ? 'solid' : 'ghost'"
:color="direction === 'push' ? 'primary' : 'neutral'"
:label="t('storage.backup.siteSyncPush')"
:disabled="busy || disabled"
@click="setDirection('push')"
/>
<UButton
size="xs"
:variant="direction === 'pull' ? 'solid' : 'ghost'"
:color="direction === 'pull' ? 'primary' : 'neutral'"
:label="t('storage.backup.siteSyncPull')"
:disabled="busy || disabled"
@click="setDirection('pull')"
/>
</div>
<div class="flex flex-wrap items-center gap-2 sm:justify-end">
<span class="text-xs text-muted">{{ t('storage.backup.siteSyncConflict') }}</span>
<UButton
size="xs"
:variant="conflictMode === 'skip' ? 'solid' : 'ghost'"
:color="conflictMode === 'skip' ? 'primary' : 'neutral'"
:label="t('storage.backup.siteSyncSkip')"
:disabled="busy || disabled"
@click="setConflictMode('skip')"
/>
<UButton
size="xs"
:variant="conflictMode === 'overwrite' ? 'solid' : 'ghost'"
:color="conflictMode === 'overwrite' ? 'primary' : 'neutral'"
:label="t('storage.backup.siteSyncOverwrite')"
:disabled="busy || disabled"
@click="setConflictMode('overwrite')"
/>
</div>
</div>
<div class="mt-3 flex flex-wrap items-center gap-2">
<span class="text-xs text-muted">{{ t('storage.backup.siteSyncContent') }}</span>
<UButton
v-for="item in scopeItems"
:key="item.value"
size="xs"
:variant="scope === item.value ? 'solid' : 'ghost'"
:color="scope === item.value ? 'primary' : 'neutral'"
:label="item.label"
:disabled="busy || disabled"
@click="setScope(item.value)"
/>
</div>
<UAlert
v-if="activeJob"
class="mt-4"
color="primary"
variant="subtle"
icon="i-lucide-refresh-cw"
:description="activeJob.message"
/>
<div
v-if="activeJob"
class="mt-3 grid gap-3 rounded-lg border border-default bg-default/40 p-3 sm:grid-cols-2"
>
<div>
<p class="text-xs text-muted">
{{ t('storage.backup.siteSyncScanProgress') }}
</p>
<p class="mt-1 font-mono text-sm font-medium">
{{ activeJob.message }}
</p>
</div>
<div>
<p class="text-xs text-muted">
{{ t('storage.backup.siteSyncFileProgress') }}
</p>
<p class="mt-1 font-mono text-sm font-medium">
{{ fileProgress.synced }} / {{ fileProgress.total }}
</p>
</div>
</div>
<BackupJobProgress
v-if="activeJob"
class="mt-2"
:message="activeJob.message"
:progress="activeJob.progress"
:total="activeJob.total"
/>
</article>
</template>
@@ -0,0 +1,134 @@
<script setup lang="ts">
import { loadSiteSyncForm, saveSiteSyncForm } from '~/utils/site-sync-form'
const props = defineProps<{
open: boolean
keys: string[]
scope: 'image' | 'video' | 'file'
}>()
const emit = defineEmits<{
'update:open': [value: boolean]
}>()
const { t } = useI18n()
const { startSiteSync } = useBackup()
const targetBaseUrl = ref('')
const token = ref('')
const conflictMode = ref<'skip' | 'overwrite'>('skip')
const busy = ref(false)
watch(() => props.open, (open) => {
if (!open) return
const saved = loadSiteSyncForm()
if (saved) {
targetBaseUrl.value = saved.targetBaseUrl
token.value = saved.token
}
}, { immediate: true })
watch([targetBaseUrl, token], () => {
saveSiteSyncForm(targetBaseUrl.value, token.value)
})
const canSubmit = computed(() =>
!busy.value
&& props.keys.length > 0
&& Boolean(targetBaseUrl.value.trim())
&& Boolean(token.value.trim())
)
function setConflict(mode: 'skip' | 'overwrite') {
conflictMode.value = mode
}
async function submit() {
if (!canSubmit.value) return
busy.value = true
try {
await startSiteSync({
targetBaseUrl: targetBaseUrl.value.trim(),
token: token.value.trim(),
conflictMode: conflictMode.value,
direction: 'push',
scope: props.scope,
keys: props.keys
})
emit('update:open', false)
} catch {
// useBackup 已提示错误
} finally {
busy.value = false
}
}
</script>
<template>
<UModal
:open="open"
:title="t('storage.backup.syncSelectedTitle')"
:description="t('storage.backup.syncSelectedSubtitle', { n: keys.length })"
@update:open="emit('update:open', $event)"
>
<template #body>
<div class="space-y-3">
<div class="space-y-1.5">
<label class="text-xs text-muted">{{ t('storage.backup.siteSyncTarget') }}</label>
<UInput
v-model="targetBaseUrl"
placeholder="https://target.example.com"
size="sm"
class="w-full"
/>
</div>
<div class="space-y-1.5">
<label class="text-xs text-muted">{{ t('storage.backup.siteSyncToken') }}</label>
<UInput
v-model="token"
type="password"
:placeholder="t('storage.backup.siteSyncTokenPlaceholder')"
size="sm"
class="w-full"
/>
</div>
<div class="flex flex-wrap items-center gap-2">
<span class="text-xs text-muted">{{ t('storage.backup.siteSyncConflict') }}</span>
<UButton
size="xs"
:variant="conflictMode === 'skip' ? 'solid' : 'ghost'"
:color="conflictMode === 'skip' ? 'primary' : 'neutral'"
:label="t('storage.backup.siteSyncSkip')"
@click="setConflict('skip')"
/>
<UButton
size="xs"
:variant="conflictMode === 'overwrite' ? 'solid' : 'ghost'"
:color="conflictMode === 'overwrite' ? 'primary' : 'neutral'"
:label="t('storage.backup.siteSyncOverwrite')"
@click="setConflict('overwrite')"
/>
</div>
</div>
</template>
<template #footer>
<div class="flex justify-end gap-2">
<UButton
:label="t('common.cancel')"
color="neutral"
variant="outline"
@click="emit('update:open', false)"
/>
<UButton
:label="t('storage.backup.syncSelectedStart')"
icon="i-lucide-cloud-upload"
color="primary"
:loading="busy"
:disabled="!canSubmit"
@click="submit"
/>
</div>
</template>
</UModal>
</template>
@@ -0,0 +1,204 @@
<script setup lang="ts">
import type { StorageBackendItem } from '~/types/storage'
const props = defineProps<{
backend: StorageBackendItem
envOverride?: boolean
busy?: boolean
showMigrateIn?: boolean
showMigrateOut?: boolean
}>()
const emit = defineEmits<{
setDefault: []
edit: []
test: []
toggleEnabled: []
delete: []
migrateIn: []
migrateOut: []
}>()
const { t } = useI18n()
const typeIcon = computed(() => {
if (props.backend.type === 'local') return 'i-lucide-hard-drive'
if (props.backend.type === 'telegram') return 'i-lucide-send'
return 'i-lucide-cloud'
})
const typeLabel = computed(() => {
if (props.backend.type === 'local') return t('storage.backendLocal')
if (props.backend.type === 'telegram') return t('storage.backendTelegram')
return t('storage.backendCloud')
})
const actionsDisabled = computed(() => props.envOverride || props.busy)
const isLocal = computed(() => props.backend.type === 'local')
function canSetDefault() {
return !props.backend.isDefault && props.backend.enabled
}
function canEdit() {
return props.backend.type === 's3' || props.backend.type === 'telegram'
}
function canToggle() {
return props.backend.type !== 'local'
}
function canDelete() {
return props.backend.type !== 'local' && !props.backend.isDefault
}
</script>
<template>
<article
class="flex h-full flex-col rounded-xl border border-default bg-elevated p-4 sm:p-5"
:class="{ 'opacity-75': !backend.enabled }"
>
<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
:name="typeIcon"
class="size-5"
/>
</div>
<h3 class="truncate text-base font-semibold">
{{ backend.name }}
</h3>
</div>
<div class="flex shrink-0 flex-wrap justify-end gap-1.5">
<span
v-if="backend.isDefault"
class="inline-flex rounded-md bg-primary/10 px-2 py-0.5 text-xs font-medium text-primary"
>
{{ t('storage.badgeDefaultStorage') }}
</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') }}
</span>
<span
v-else
class="inline-flex rounded-md bg-muted px-2 py-0.5 text-xs font-medium text-muted"
>
{{ t('storage.badgeDisabled') }}
</span>
</div>
</div>
<div class="mt-4">
<StorageUsageBar
:capacity="backend.capacity"
:index-bytes="backend.usage.bytes"
/>
</div>
<div class="mt-4 flex flex-wrap items-center gap-x-4 gap-y-1 text-xs text-muted">
<div class="flex min-w-0 items-center gap-2">
<span class="shrink-0">{{ t('storage.storageId') }}:</span>
<code class="min-w-0 truncate font-mono text-dimmed">{{ backend.id }}</code>
<CopyButton
icon="i-lucide-copy"
icon-only
variant="ghost"
color="primary"
class="shrink-0"
:label="t('common.copy')"
:value="backend.id"
:success-title="t('copy.copied')"
/>
</div>
<span>
{{ t('storage.storageType') }}: {{ typeLabel }}
</span>
</div>
<div class="mt-4 flex flex-wrap gap-2 border-t border-default pt-4">
<UButton
v-if="canSetDefault()"
:label="t('storage.setDefault')"
icon="i-lucide-star"
size="sm"
variant="outline"
color="neutral"
:disabled="actionsDisabled"
@click="emit('setDefault')"
/>
<UButton
v-if="showMigrateIn"
:label="t('storage.backup.migrateIn')"
:title="t('storage.backup.migrateInHint')"
icon="i-lucide-import"
size="sm"
variant="outline"
color="neutral"
:disabled="actionsDisabled"
@click="emit('migrateIn')"
/>
<UButton
v-if="showMigrateOut"
:label="t('storage.backup.migrateOut')"
:title="t('storage.backup.migrateOutHint')"
icon="i-lucide-arrow-up-from-line"
size="sm"
variant="outline"
color="neutral"
:disabled="actionsDisabled"
@click="emit('migrateOut')"
/>
<UButton
v-if="canEdit()"
:label="t('common.edit')"
icon="i-lucide-pencil"
size="sm"
variant="outline"
color="neutral"
:disabled="actionsDisabled"
@click="emit('edit')"
/>
<UButton
:label="t('storage.testConnection')"
:icon="isLocal ? 'i-lucide-wand-sparkles' : 'i-lucide-activity'"
size="sm"
variant="outline"
color="neutral"
:loading="busy"
:disabled="actionsDisabled"
@click="emit('test')"
/>
<UButton
v-if="canToggle()"
:label="backend.enabled ? t('storage.disable') : t('storage.enable')"
:icon="backend.enabled ? 'i-lucide-pause' : 'i-lucide-play'"
size="sm"
variant="outline"
color="neutral"
:disabled="actionsDisabled || (backend.isDefault && backend.enabled)"
@click="emit('toggleEnabled')"
/>
<UButton
v-if="canDelete()"
:label="t('common.delete')"
icon="i-lucide-trash-2"
size="sm"
variant="outline"
color="error"
:disabled="actionsDisabled"
@click="emit('delete')"
/>
</div>
</article>
</template>
@@ -0,0 +1,445 @@
<script setup lang="ts">
import type { QuotaUnit } from '~/composables/useFileSize'
import type { StorageBackendItem } from '~/types/storage'
type StorageProvider = 'r2' | 'cos' | 'oss' | 'aws' | 'custom' | 'telegram'
export interface StorageFormPayload {
backendType: 's3' | 'telegram'
name: string
provider: StorageProvider
config: {
endpoint: string
region: string
bucket: string
prefix?: string
forcePathStyle: boolean
chatId?: string
apiProxyUrl?: string
}
secrets: {
accessKeyId: string
secretAccessKey: string
botToken: string
}
servingMode: 'proxy' | 'public'
publicUrl: string
quotaValue: string
quotaUnit: QuotaUnit
}
const props = defineProps<{
backend?: StorageBackendItem | null
createMode?: boolean
saving?: boolean
}>()
const emit = defineEmits<{
submit: [payload: StorageFormPayload]
cancel: []
}>()
const { t } = useI18n()
const { bytesToQuotaInput, QUOTA_UNITS } = useFileSize()
const name = ref('')
const provider = ref<StorageProvider>('r2')
const endpoint = ref('')
const region = ref('auto')
const bucket = ref('')
const prefix = ref('')
const forcePathStyle = ref(false)
const accessKeyId = ref('')
const secretAccessKey = ref('')
const chatId = ref('')
const botToken = ref('')
const apiProxyUrl = ref('')
const servingMode = ref<'proxy' | 'public'>('proxy')
const publicUrl = ref('')
const quotaValue = ref('')
const quotaUnit = ref<QuotaUnit>('GB')
const isTelegram = computed(() =>
provider.value === 'telegram' || props.backend?.type === 'telegram'
)
const quotaUnitOptions = computed(() =>
QUOTA_UNITS.map(unit => ({ label: unit, value: unit }))
)
const providerOptions = computed(() => [
{ label: t('storage.providerR2'), value: 'r2' as const },
{ label: t('storage.providerCos'), value: 'cos' as const },
{ label: t('storage.providerOss'), value: 'oss' as const },
{ label: t('storage.providerAws'), value: 'aws' as const },
{ label: t('storage.providerCustom'), value: 'custom' as const },
{ label: t('storage.providerTelegram'), value: 'telegram' as const }
])
const servingModeOptions = computed(() => [
{ label: t('storage.servingProxy'), value: 'proxy' },
{ label: t('storage.servingPublic'), value: 'public' }
])
function providerLabel(value: StorageProvider): string {
return providerOptions.value.find(option => option.value === value)?.label
?? t('storage.backendCloud')
}
function applyProviderTemplate(value: StorageProvider) {
if (!props.createMode && props.backend) return
if (props.createMode) {
name.value = providerLabel(value)
}
if (value === 'telegram') {
servingMode.value = 'proxy'
return
}
switch (value) {
case 'r2':
endpoint.value = 'https://{account_id}.r2.cloudflarestorage.com'
region.value = 'auto'
forcePathStyle.value = false
break
case 'cos':
endpoint.value = 'https://cos.{region}.myqcloud.com'
region.value = 'ap-guangzhou'
forcePathStyle.value = true
break
case 'oss':
endpoint.value = 'https://oss-{region}.aliyuncs.com'
region.value = 'cn-hangzhou'
forcePathStyle.value = false
break
case 'aws':
endpoint.value = 'https://s3.{region}.amazonaws.com'
region.value = 'us-east-1'
forcePathStyle.value = false
break
default:
break
}
}
function detectProvider(config: StorageBackendItem['config']): StorageProvider {
const ep = config.endpoint ?? ''
if (ep.includes('r2.cloudflarestorage.com')) return 'r2'
if (ep.includes('myqcloud.com')) return 'cos'
if (ep.includes('aliyuncs.com')) return 'oss'
if (ep.includes('amazonaws.com')) return 'aws'
return 'custom'
}
function resetCreateDefaults() {
name.value = ''
provider.value = 'r2'
applyProviderTemplate('r2')
bucket.value = ''
prefix.value = ''
accessKeyId.value = ''
secretAccessKey.value = ''
chatId.value = ''
botToken.value = ''
apiProxyUrl.value = ''
servingMode.value = 'proxy'
publicUrl.value = ''
quotaValue.value = ''
quotaUnit.value = 'GB'
}
function loadBackend(backend: StorageBackendItem | null | undefined) {
if (!backend) {
if (props.createMode) resetCreateDefaults()
return
}
if (backend.type === 'telegram') {
name.value = backend.name
provider.value = 'telegram'
chatId.value = backend.config.chatId ?? ''
apiProxyUrl.value = backend.config.apiProxyUrl ?? ''
botToken.value = ''
const quota = bytesToQuotaInput(backend.quotaBytes)
quotaValue.value = quota.value
quotaUnit.value = quota.unit
servingMode.value = 'proxy'
return
}
if (backend.type !== 's3') {
if (props.createMode) resetCreateDefaults()
return
}
name.value = backend.name
provider.value = detectProvider(backend.config)
endpoint.value = backend.config.endpoint ?? ''
region.value = backend.config.region ?? 'auto'
bucket.value = backend.config.bucket ?? ''
prefix.value = backend.config.prefix ?? ''
forcePathStyle.value = backend.config.forcePathStyle ?? false
servingMode.value = backend.servingMode
publicUrl.value = backend.publicUrl
const quota = bytesToQuotaInput(backend.quotaBytes)
quotaValue.value = quota.value
quotaUnit.value = quota.unit
accessKeyId.value = ''
secretAccessKey.value = ''
}
watch(() => props.backend, loadBackend, { immediate: true })
watch(provider, (value) => {
if (props.createMode) {
applyProviderTemplate(value)
}
})
function handleSubmit() {
const backendType = isTelegram.value ? 'telegram' : 's3'
emit('submit', {
backendType,
name: name.value.trim() || providerLabel(provider.value),
provider: provider.value,
config: {
endpoint: endpoint.value.trim(),
region: region.value.trim() || 'auto',
bucket: bucket.value.trim(),
prefix: prefix.value.trim() || undefined,
forcePathStyle: forcePathStyle.value,
chatId: chatId.value.trim() || undefined,
apiProxyUrl: apiProxyUrl.value.trim() || undefined
},
secrets: {
accessKeyId: accessKeyId.value.trim(),
secretAccessKey: secretAccessKey.value.trim(),
botToken: botToken.value.trim()
},
servingMode: isTelegram.value ? 'proxy' : servingMode.value,
publicUrl: publicUrl.value.trim(),
quotaValue: String(quotaValue.value ?? '').trim(),
quotaUnit: quotaUnit.value
})
}
</script>
<template>
<form
class="space-y-4"
@submit.prevent="handleSubmit"
>
<div class="space-y-2">
<label class="text-sm">{{ t('storage.provider') }}</label>
<USelect
v-model="provider"
:items="providerOptions"
class="w-full"
:disabled="!createMode && backend?.type === 'telegram'"
/>
</div>
<div class="space-y-2">
<label class="text-sm">{{ t('storage.backendName') }}</label>
<UInput
v-model="name"
class="w-full"
/>
</div>
<template v-if="isTelegram">
<div class="space-y-2">
<label class="text-sm">
{{ t('storage.telegramChatId') }}
<span class="text-error">*</span>
</label>
<UInput
v-model="chatId"
:placeholder="t('storage.telegramChatIdPlaceholder')"
class="w-full font-mono text-sm"
required
/>
<p class="text-xs text-muted">
{{ t('storage.telegramChatIdHint') }}
</p>
</div>
<div class="space-y-2">
<label class="text-sm">
{{ t('storage.telegramBotToken') }}
<span
v-if="createMode"
class="text-error"
>*</span>
</label>
<UInput
v-model="botToken"
type="password"
:placeholder="backend?.secretsMasked.botToken || ''"
class="w-full font-mono text-sm"
:required="createMode"
/>
</div>
<div class="space-y-2">
<label class="text-sm">{{ t('storage.telegramApiProxy') }}</label>
<UInput
v-model="apiProxyUrl"
:placeholder="t('storage.telegramApiProxyPlaceholder')"
class="w-full font-mono text-sm"
/>
<p class="text-xs text-muted">
{{ t('storage.telegramApiProxyHint') }}
</p>
</div>
<p class="text-xs text-muted">
{{ t('storage.telegramServingHint') }}
</p>
</template>
<template v-else>
<div class="space-y-2">
<label class="text-sm">{{ t('storage.endpoint') }}</label>
<UInput
v-model="endpoint"
class="w-full font-mono text-sm"
/>
</div>
<div class="grid gap-3 sm:grid-cols-2">
<div class="space-y-2">
<label class="text-sm">{{ t('storage.region') }}</label>
<UInput
v-model="region"
class="w-full font-mono text-sm"
/>
</div>
<div class="space-y-2">
<label class="text-sm">
{{ t('storage.bucket') }}
<span class="text-error">*</span>
</label>
<UInput
v-model="bucket"
class="w-full font-mono text-sm"
required
/>
</div>
</div>
<div class="space-y-2">
<label class="text-sm">{{ t('storage.prefix') }}</label>
<UInput
v-model="prefix"
:placeholder="t('storage.prefixPlaceholder')"
class="w-full font-mono text-sm"
/>
</div>
<label class="flex cursor-pointer items-center gap-2">
<UCheckbox v-model="forcePathStyle" />
<span class="text-sm">{{ t('storage.forcePathStyle') }}</span>
</label>
<div class="grid gap-3 sm:grid-cols-2">
<div class="space-y-2">
<label class="text-sm">
{{ t('storage.accessKey') }}
<span
v-if="createMode"
class="text-error"
>*</span>
</label>
<UInput
v-model="accessKeyId"
:placeholder="backend?.secretsMasked.accessKeyId || ''"
class="w-full font-mono text-sm"
:required="createMode"
/>
</div>
<div class="space-y-2">
<label class="text-sm">
{{ t('storage.secretKey') }}
<span
v-if="createMode"
class="text-error"
>*</span>
</label>
<UInput
v-model="secretAccessKey"
type="password"
:placeholder="backend?.secretsMasked.secretAccessKey || ''"
class="w-full font-mono text-sm"
:required="createMode"
/>
</div>
</div>
<div class="space-y-2">
<label class="text-sm">{{ t('storage.servingMode') }}</label>
<URadioGroup
v-model="servingMode"
:items="servingModeOptions"
/>
<p class="text-xs text-muted">
{{ t('storage.servingHint') }}
</p>
</div>
<div
v-if="servingMode === 'public'"
class="space-y-2"
>
<label class="text-sm">{{ t('storage.publicUrl') }}</label>
<UInput
v-model="publicUrl"
:placeholder="t('storage.publicUrlPlaceholder')"
class="w-full font-mono text-sm"
/>
</div>
</template>
<div class="space-y-2">
<label class="text-sm">
{{ t('storage.quotaTotal') }}
<span class="text-error">*</span>
</label>
<div class="flex gap-2">
<UInput
:model-value="String(quotaValue)"
type="number"
min="0"
step="any"
required
:placeholder="t('storage.quotaPlaceholder')"
class="min-w-0 flex-1"
@update:model-value="quotaValue = String($event ?? '')"
/>
<USelect
v-model="quotaUnit"
:items="quotaUnitOptions"
class="w-24 shrink-0"
/>
</div>
<p class="text-xs text-muted">
{{ isTelegram ? t('storage.telegramQuotaHint') : t('storage.quotaHint') }}
</p>
</div>
<div class="flex justify-end gap-2 pt-2">
<UButton
type="button"
:label="t('common.cancel')"
variant="outline"
color="neutral"
@click="emit('cancel')"
/>
<UButton
type="submit"
:label="t('common.save')"
icon="i-lucide-save"
:loading="saving"
/>
</div>
</form>
</template>
@@ -0,0 +1,219 @@
<script setup lang="ts">
import type {
BackupJobItem,
SiteSyncRequest,
SyncDraft
} from '~/types/backup'
import type { StorageBackendItem } from '~/types/storage'
const props = defineProps<{
backends: StorageBackendItem[]
}>()
const { t } = useI18n()
const toast = useToast()
const {
jobs,
jobsPage,
jobsPageSize,
jobsTotal,
jobsTotalPages,
jobsLoading,
showHiddenJobs,
estimate,
packages,
loadingEstimate,
polling,
hasActiveJob,
getActiveJob,
loadJobs,
toggleShowHiddenJobs,
hideJob,
hideAllJobs,
setJobsPage,
setJobsPageSize,
loadEstimate,
startPolling,
createExport,
previewRestore,
confirmRestore,
previewSync,
startSync,
startSiteSync,
retryJob,
cancelJob,
downloadUrl
} = useBackup()
const syncDraft = ref<SyncDraft | null>(null)
const syncCardRef = ref<{ applyDraft: (draft: SyncDraft) => void } | null>(null)
const panelRef = ref<HTMLElement | null>(null)
const syncHighlighted = ref(false)
const activeJob = computed(() => getActiveJob())
const exportBusy = computed(() => activeJob.value?.type === 'full_export')
const restoreBusy = computed(() => activeJob.value?.type === 'full_restore')
const syncBusy = computed(() => activeJob.value?.type === 'backend_sync')
const siteSyncBusy = computed(() => activeJob.value?.type === 'site_sync')
const resumableSiteSyncJob = computed(() =>
jobs.value.find(job =>
job.type === 'site_sync'
&& job.status === 'failed'
&& job.payload.resumable === true
) ?? null
)
const anyJobBusy = computed(() => Boolean(activeJob.value))
onMounted(async () => {
await Promise.all([loadJobs(), loadEstimate()])
if (hasActiveJob()) {
startPolling()
}
})
function applySyncDraft(draft: SyncDraft) {
syncDraft.value = draft
syncCardRef.value?.applyDraft(draft)
syncHighlighted.value = true
window.setTimeout(() => {
syncHighlighted.value = false
}, 2500)
nextTick(() => {
panelRef.value?.scrollIntoView({ behavior: 'smooth', block: 'start' })
})
toast.add({ title: t('storage.backup.syncDraftApplied'), color: 'info' })
}
defineExpose({ applySyncDraft })
async function onExport() {
await createExport()
}
async function onRestore(input: {
file?: File
archiveName?: string
conflictMode: 'skip' | 'overwrite'
}) {
await confirmRestore(input)
}
async function onCancel(jobId: string) {
await cancelJob(jobId)
}
async function onHideJob(jobId: string, hidden: boolean) {
await hideJob(jobId, hidden)
}
async function onHideAll(hidden: boolean) {
await hideAllJobs(hidden)
}
async function onSync(body: {
fromBackendId: string
toBackendId: string
deleteSource: boolean
setDefault: boolean
}) {
await startSync(body)
}
async function onSiteSync(body: SiteSyncRequest) {
await startSiteSync(body)
}
function onDownload(jobId: string) {
window.open(downloadUrl(jobId), '_blank')
}
async function onRetry(jobId: string) {
await retryJob(jobId)
}
function onViewJob(job: BackupJobItem) {
toast.add({
title: t('storage.backup.jobTypeRestore'),
description: job.message,
color: 'info'
})
}
</script>
<template>
<section
ref="panelRef"
class="scroll-mt-20 space-y-4 border-t border-default pt-5"
>
<header>
<h2 class="text-base font-semibold">
{{ t('storage.backup.sectionTitle') }}
</h2>
<p class="mt-1 text-sm text-muted">
{{ t('storage.backup.sectionSubtitle') }}
</p>
</header>
<div class="grid grid-cols-1 items-stretch gap-4 xl:grid-cols-3">
<StorageBackupExportCard
class="h-full"
:estimate="estimate"
:loading="loadingEstimate"
:busy="exportBusy"
:active-job="exportBusy ? activeJob : null"
:disabled="anyJobBusy && !exportBusy"
@export="onExport"
/>
<StorageBackupRestoreCard
class="h-full"
:packages="packages"
:busy="anyJobBusy"
:restore-busy="restoreBusy"
:active-job="restoreBusy ? activeJob : null"
:disabled="anyJobBusy && !restoreBusy"
:preview-restore="previewRestore"
@restore="onRestore"
/>
<StorageBackendSyncCard
ref="syncCardRef"
class="h-full"
:backends="props.backends"
:sync-draft="syncDraft"
:highlighted="syncHighlighted"
:busy="syncBusy"
:active-job="syncBusy ? activeJob : null"
:disabled="anyJobBusy && !syncBusy"
:preview-sync="previewSync"
@sync="onSync"
/>
</div>
<StorageSiteSyncCard
:busy="siteSyncBusy"
:disabled="anyJobBusy && !siteSyncBusy"
:active-job="siteSyncBusy ? activeJob : null"
:resumable-job="resumableSiteSyncJob"
@sync="onSiteSync"
/>
<StorageBackupJobTable
:jobs="jobs"
:polling="polling"
:page="jobsPage"
:total-pages="jobsTotalPages"
:total="jobsTotal"
:page-size="jobsPageSize"
:loading="jobsLoading"
:show-hidden="showHiddenJobs"
@update:page="setJobsPage"
@update:page-size="setJobsPageSize"
@download="onDownload"
@retry="onRetry"
@cancel="onCancel"
@hide="onHideJob"
@hide-all="onHideAll"
@toggle-hidden="toggleShowHiddenJobs"
@view="onViewJob"
/>
</section>
</template>
+184
View File
@@ -0,0 +1,184 @@
<script setup lang="ts">
import type { StorageBackendItem } from '~/types/storage'
const props = defineProps<{
backends: StorageBackendItem[]
loading?: boolean
}>()
const { t } = useI18n()
const { formatFileSize } = useFileSize()
const CHART_COLORS = [
'var(--ui-primary)',
'#3b82f6',
'#6366f1',
'#f59e0b',
'#ec4899',
'#8b5cf6'
] as const
function backendUsedBytes(backend: StorageBackendItem) {
return backend.capacity.source === 'disk'
? backend.capacity.usedBytes
: backend.usage.bytes
}
const totalCapacityBytes = computed(() => {
let total = 0
let hasTotal = false
for (const backend of props.backends) {
if (backend.capacity.totalBytes != null && backend.capacity.totalBytes > 0) {
total += backend.capacity.totalBytes
hasTotal = true
}
}
return hasTotal ? total : null
})
const totalUsedBytes = computed(() =>
props.backends.reduce((sum, backend) => sum + backendUsedBytes(backend), 0)
)
const totalFreeBytes = computed(() => {
if (!totalCapacityBytes.value) return null
return Math.max(0, totalCapacityBytes.value - totalUsedBytes.value)
})
const usagePercent = computed(() => {
if (!totalCapacityBytes.value) return null
return Math.min(Math.round((totalUsedBytes.value / totalCapacityBytes.value) * 1000) / 10, 100)
})
const usageBarWidth = computed(() => {
if (usagePercent.value == null) return 0
return Math.max(usagePercent.value, totalUsedBytes.value > 0 ? 4 : 0)
})
const distributionItems = computed(() => {
if (!totalCapacityBytes.value) return []
return props.backends
.filter(backend => backend.capacity.totalBytes != null && backend.capacity.totalBytes > 0)
.map((backend, index) => ({
id: backend.id,
name: backend.name,
color: CHART_COLORS[index % CHART_COLORS.length]!,
percent: Math.round((backend.capacity.totalBytes! / totalCapacityBytes.value!) * 1000) / 10
}))
})
const donutStyle = computed(() => {
const items = distributionItems.value
if (!items.length) return { background: 'var(--ui-bg-muted)' }
let cursor = 0
const segments = items.map((item) => {
const start = cursor
cursor += item.percent
return `${item.color} ${start}% ${cursor}%`
})
return { background: `conic-gradient(${segments.join(', ')})` }
})
function displaySize(bytes: number | null) {
if (props.loading) return '—'
if (bytes == null) return '—'
return formatFileSize(bytes)
}
</script>
<template>
<section class="rounded-xl border border-default bg-elevated p-4 sm:p-5">
<h2 class="text-sm font-semibold">
{{ t('storage.overviewTitle') }}
</h2>
<p class="mt-0.5 text-xs text-muted">
{{ t('storage.overviewSubtitle') }}
</p>
<div class="mt-4 grid gap-5 lg:grid-cols-[minmax(0,1fr)_auto] lg:items-center">
<div>
<div class="grid grid-cols-2 gap-4 sm:grid-cols-4">
<div>
<p class="text-xs text-muted">
{{ t('storage.overviewTotal') }}
</p>
<p class="mt-1 text-lg font-semibold tabular-nums">
{{ displaySize(totalCapacityBytes) }}
</p>
<div
v-if="totalCapacityBytes"
class="mt-2 h-1 overflow-hidden rounded-full bg-muted/50"
>
<div
class="h-full rounded-full bg-primary transition-all"
:style="{ width: `${usageBarWidth}%` }"
/>
</div>
</div>
<div>
<p class="text-xs text-muted">
{{ t('storage.overviewUsed') }}
</p>
<p class="mt-1 text-lg font-semibold tabular-nums">
{{ displaySize(totalUsedBytes) }}
</p>
</div>
<div>
<p class="text-xs text-muted">
{{ t('storage.overviewFree') }}
</p>
<p class="mt-1 text-lg font-semibold tabular-nums">
{{ displaySize(totalFreeBytes) }}
</p>
</div>
<div>
<p class="text-xs text-muted">
{{ t('storage.overviewUsageRate') }}
</p>
<p class="mt-1 text-lg font-semibold tabular-nums">
{{ usagePercent != null ? `${usagePercent}%` : '—' }}
</p>
</div>
</div>
</div>
<div class="flex items-center gap-4">
<div
class="relative size-24 shrink-0 rounded-full p-2"
:style="donutStyle"
>
<div class="flex size-full items-center justify-center rounded-full bg-elevated px-2 text-center">
<p class="text-[10px] leading-tight text-muted">
{{ t('storage.capacityDistribution') }}
</p>
</div>
</div>
<ul
v-if="distributionItems.length"
class="min-w-[8rem] space-y-2"
>
<li
v-for="item in distributionItems"
:key="item.id"
class="flex items-center justify-between gap-3 text-xs"
>
<span class="flex min-w-0 items-center gap-1.5">
<span
class="size-2 shrink-0 rounded-full"
:style="{ background: item.color }"
/>
<span class="truncate">{{ item.name }}</span>
</span>
<span class="shrink-0 tabular-nums text-muted">
{{ item.percent }}%
</span>
</li>
</ul>
</div>
</div>
</section>
</template>
@@ -0,0 +1,69 @@
<script setup lang="ts">
const { t } = useI18n()
const items = computed(() => [
{
icon: 'i-lucide-hard-drive',
iconClass: 'text-primary',
boxClass: 'border-primary/20 bg-primary/5',
title: t('storage.backendLocal'),
desc: t('storage.supportedLocalDesc')
},
{
icon: 'i-lucide-cloud',
iconClass: 'text-orange-500',
boxClass: 'border-orange-500/20 bg-orange-500/5',
title: 'Cloudflare R2',
desc: t('storage.supportedR2Desc')
},
{
icon: 'i-lucide-server',
iconClass: 'text-blue-500',
boxClass: 'border-blue-500/20 bg-blue-500/5',
title: t('storage.supportedS3Title'),
desc: t('storage.supportedS3Desc')
},
{
icon: 'i-lucide-send',
iconClass: 'text-sky-500',
boxClass: 'border-sky-500/20 bg-sky-500/5',
title: t('storage.backendTelegram'),
desc: t('storage.supportedTelegramDesc')
}
])
</script>
<template>
<section class="rounded-xl border border-default bg-elevated p-4 sm:p-5">
<h2 class="text-sm font-semibold">
{{ t('storage.supportedTitle') }}
</h2>
<div class="mt-4 grid grid-cols-1 gap-3 lg:grid-cols-3">
<div
v-for="item in items"
:key="item.title"
class="flex items-start gap-3 rounded-lg border border-default bg-muted/30 px-4 py-3"
>
<div
class="flex size-10 shrink-0 items-center justify-center rounded-lg border"
:class="item.boxClass"
>
<UIcon
:name="item.icon"
class="size-5"
:class="item.iconClass"
/>
</div>
<div class="min-w-0 pt-0.5">
<p class="text-sm font-semibold">
{{ item.title }}
</p>
<p class="mt-0.5 text-xs leading-relaxed text-muted">
{{ item.desc }}
</p>
</div>
</div>
</div>
</section>
</template>
@@ -0,0 +1,88 @@
<script setup lang="ts">
import type { StorageCapacity } from '~/types/storage'
const props = defineProps<{
capacity: StorageCapacity
indexBytes: number
}>()
const { formatFileSize } = useFileSize()
const displayUsedBytes = computed(() =>
props.capacity.source === 'disk' ? props.capacity.usedBytes : props.indexBytes
)
const hasBar = computed(() =>
props.capacity.totalBytes != null && props.capacity.totalBytes > 0
)
const displayPercent = computed(() => props.capacity.percent ?? 0)
const overQuota = computed(() =>
props.capacity.source === 'quota' && displayPercent.value > 100
)
const barWidth = computed(() => {
if (!hasBar.value) return 0
const percent = Math.min(displayPercent.value, 100)
return Math.max(percent, displayUsedBytes.value > 0 ? 4 : 0)
})
function formatOptional(bytes: number | null): string {
if (bytes == null) return '—'
return formatFileSize(bytes)
}
</script>
<template>
<div class="space-y-3">
<div class="grid grid-cols-2 gap-3 sm:grid-cols-4">
<div>
<p class="text-xs text-muted">
{{ $t('storage.statUsed') }}
</p>
<p class="mt-1 text-sm font-semibold tabular-nums">
{{ formatFileSize(displayUsedBytes) }}
</p>
</div>
<div>
<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') }}
</p>
<p
class="mt-1 text-sm font-semibold tabular-nums"
:class="overQuota ? 'text-error' : ''"
>
{{ formatOptional(capacity.freeBytes) }}
</p>
</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
v-if="hasBar"
class="h-1 overflow-hidden rounded-full bg-muted/50"
>
<div
class="h-full rounded-full transition-all"
:class="overQuota ? 'bg-error/80' : 'bg-primary'"
:style="{ width: `${barWidth}%` }"
/>
</div>
</div>
</template>
+984
View File
@@ -0,0 +1,984 @@
export type ApiDocMethod = 'GET' | 'POST' | 'DELETE'
export type ApiDocCategory = 'image' | 'video' | 'file' | 'extension' | 'system'
export type ApiParamLocation = 'form-data' | 'query' | 'header' | 'body'
export interface ApiDocParam {
name: string
type: string
location: ApiParamLocation
required: boolean
descriptionKey: string
}
export interface ApiDocEndpoint {
id: string
category: ApiDocCategory
method: ApiDocMethod
path: string
titleKey: string
descKey: string
params: ApiDocParam[]
requiresAuth: boolean
curl: string
responseExample: string
}
interface ApiDocsContext {
baseUrl: string
authHeader: string
}
function authFlag(authHeader: string) {
return `-H 'Auth-Token: ${authHeader}'`
}
export function buildApiDocs(ctx: ApiDocsContext): ApiDocEndpoint[] {
const { baseUrl, authHeader } = ctx
const auth = authFlag(authHeader)
return [
{
id: 'upload',
category: 'image',
method: 'POST',
path: '/api/images/upload',
titleKey: 'api.docs.upload.title',
descKey: 'api.docs.upload.desc',
requiresAuth: true,
params: [
{
name: 'image',
type: 'file',
location: 'form-data',
required: true,
descriptionKey: 'api.params.upload.image'
},
{
name: 'file',
type: 'file',
location: 'form-data',
required: false,
descriptionKey: 'api.params.upload.file'
},
{
name: 'files',
type: 'file',
location: 'form-data',
required: false,
descriptionKey: 'api.params.upload.files'
},
{
name: 'token',
type: 'string',
location: 'form-data',
required: false,
descriptionKey: 'api.params.upload.token'
},
{
name: 'tagNames',
type: 'string',
location: 'form-data',
required: false,
descriptionKey: 'api.params.upload.tagNames'
}
],
curl: `curl -X POST "${baseUrl}/api/images/upload" \\
${auth} \\
-F "image=@./demo.png" \\
-F 'tagNames=["博客","截图"]'`,
responseExample: `{
"success": true,
"items": [
{
"key": "images/2026/08/abc123.webp",
"url": "https://pic.example.com/abc123.webp",
"originalName": "demo.png",
"contentType": "image/webp",
"size": 12345,
"uploadedAt": "2026-08-01T12:00:00.000Z",
"markdown": "![demo.png](https://pic.example.com/abc123.webp)",
"html": "<img src=\\"https://pic.example.com/abc123.webp\\" alt=\\"demo.png\\">"
}
],
"errors": []
}`
},
{
id: 'list',
category: 'image',
method: 'GET',
path: '/api/images',
titleKey: 'api.docs.list.title',
descKey: 'api.docs.list.desc',
requiresAuth: true,
params: [
{
name: 'limit',
type: 'number',
location: 'query',
required: false,
descriptionKey: 'api.params.list.limit'
},
{
name: 'page',
type: 'number',
location: 'query',
required: false,
descriptionKey: 'api.params.list.page'
},
{
name: 'backendId',
type: 'string',
location: 'query',
required: false,
descriptionKey: 'api.params.backendId'
}
],
curl: `curl "${baseUrl}/api/images?limit=20&page=1" \\
${auth}`,
responseExample: `{
"items": [
{
"key": "images/2026/08/abc123.webp",
"url": "https://pic.example.com/abc123.webp",
"originalName": "demo.png",
"contentType": "image/webp",
"size": 12345,
"uploadedAt": "2026-08-01T12:00:00.000Z",
"markdown": "![demo.png](https://pic.example.com/abc123.webp)",
"html": "<img src=\\"https://pic.example.com/abc123.webp\\" alt=\\"demo.png\\">"
}
],
"total": 1,
"page": 1,
"pageSize": 20,
"totalPages": 1
}`
},
{
id: 'search',
category: 'image',
method: 'GET',
path: '/api/images/search',
titleKey: 'api.docs.search.title',
descKey: 'api.docs.search.desc',
requiresAuth: true,
params: [
{
name: 'q',
type: 'string',
location: 'query',
required: true,
descriptionKey: 'api.params.search.q'
},
{
name: 'limit',
type: 'number',
location: 'query',
required: false,
descriptionKey: 'api.params.list.limit'
},
{
name: 'page',
type: 'number',
location: 'query',
required: false,
descriptionKey: 'api.params.list.page'
},
{
name: 'backendId',
type: 'string',
location: 'query',
required: false,
descriptionKey: 'api.params.backendId'
}
],
curl: `curl "${baseUrl}/api/images/search?q=demo&limit=20&page=1" \\
${auth}`,
responseExample: `{
"items": [],
"total": 0,
"page": 1,
"pageSize": 20,
"totalPages": 0
}`
},
{
id: 'delete',
category: 'image',
method: 'DELETE',
path: '/api/images',
titleKey: 'api.docs.delete.title',
descKey: 'api.docs.delete.desc',
requiresAuth: true,
params: [
{
name: 'key',
type: 'string',
location: 'query',
required: true,
descriptionKey: 'api.params.delete.key'
}
],
curl: `curl -X DELETE "${baseUrl}/api/images?key=images/2026/08/xxxx.webp" \\
${auth}`,
responseExample: `{
"success": true
}`
},
{
id: 'batchDelete',
category: 'image',
method: 'POST',
path: '/api/images/batch-delete',
titleKey: 'api.docs.batchDelete.title',
descKey: 'api.docs.batchDelete.desc',
requiresAuth: true,
params: [
{
name: 'keys',
type: 'string[]',
location: 'body',
required: true,
descriptionKey: 'api.params.batchDelete.keys'
}
],
curl: `curl -X POST "${baseUrl}/api/images/batch-delete" \\
${auth} \\
-H "Content-Type: application/json" \\
-d '{"keys":["images/2026/08/a.webp","images/2026/08/b.webp"]}'`,
responseExample: `{
"success": true,
"deleted": ["images/2026/08/a.webp"],
"failed": []
}`
},
{
id: 'videoUpload',
category: 'video',
method: 'POST',
path: '/api/videos/upload',
titleKey: 'api.docs.videoUpload.title',
descKey: 'api.docs.videoUpload.desc',
requiresAuth: true,
params: [
{
name: 'video',
type: 'file',
location: 'form-data',
required: true,
descriptionKey: 'api.params.videoUpload.video'
},
{
name: 'file',
type: 'file',
location: 'form-data',
required: false,
descriptionKey: 'api.params.videoUpload.file'
},
{
name: 'files',
type: 'file',
location: 'form-data',
required: false,
descriptionKey: 'api.params.videoUpload.files'
},
{
name: 'transcode',
type: 'string',
location: 'form-data',
required: false,
descriptionKey: 'api.params.videoUpload.transcode'
},
{
name: 'transcodePreset',
type: 'string',
location: 'form-data',
required: false,
descriptionKey: 'api.params.videoUpload.transcodePreset'
},
{
name: 'transcodeBitratePercent',
type: 'number',
location: 'form-data',
required: false,
descriptionKey: 'api.params.videoUpload.transcodeBitratePercent'
},
{
name: 'token',
type: 'string',
location: 'form-data',
required: false,
descriptionKey: 'api.params.videoUpload.token'
},
{
name: 'tagNames',
type: 'string',
location: 'form-data',
required: false,
descriptionKey: 'api.params.upload.tagNames'
}
],
curl: `curl -X POST "${baseUrl}/api/videos/upload" \\
${auth} \\
-F "video=@./demo.mp4" \\
-F "transcode=1" \\
-F "transcodePreset=compress" \\
-F "transcodeBitratePercent=80" \\
-F 'tagNames=["博客","视频"]'`,
responseExample: `{
"success": true,
"items": [
{
"key": "videos/2026/10/abc123.mp4",
"url": "https://pic.example.com/videos/2026/10/abc123.mp4",
"originalName": "demo.mp4",
"contentType": "video/mp4",
"size": 10485760,
"uploadedAt": "2026-10-17T03:00:00.000Z",
"markdown": "[demo.mp4](https://pic.example.com/videos/2026/10/abc123.mp4)",
"thumbUrl": "https://pic.example.com/videos/2026/10/abc123.webp",
"tags": [
{ "id": 3, "name": "博客", "color": "#22c55e" },
{ "id": 8, "name": "视频", "color": "#3b82f6" }
]
}
],
"errors": [],
"accessMode": "public"
}`
},
{
id: 'videoList',
category: 'video',
method: 'GET',
path: '/api/files',
titleKey: 'api.docs.videoList.title',
descKey: 'api.docs.videoList.desc',
requiresAuth: true,
params: [
{
name: 'kind',
type: 'string',
location: 'query',
required: true,
descriptionKey: 'api.params.mediaList.kindVideo'
},
{
name: 'page',
type: 'number',
location: 'query',
required: false,
descriptionKey: 'api.params.mediaList.page'
},
{
name: 'limit',
type: 'number',
location: 'query',
required: false,
descriptionKey: 'api.params.mediaList.limit'
},
{
name: 'q',
type: 'string',
location: 'query',
required: false,
descriptionKey: 'api.params.mediaList.q'
},
{
name: 'processing',
type: 'string',
location: 'query',
required: false,
descriptionKey: 'api.params.mediaList.processing'
},
{
name: 'backendId',
type: 'string',
location: 'query',
required: false,
descriptionKey: 'api.params.backendId'
},
{
name: 'uploadSource',
type: 'string',
location: 'query',
required: false,
descriptionKey: 'api.params.mediaList.uploadSource'
},
{
name: 'tagIds',
type: 'string',
location: 'query',
required: false,
descriptionKey: 'api.params.mediaList.tagIds'
},
{
name: 'tagMode',
type: 'string',
location: 'query',
required: false,
descriptionKey: 'api.params.mediaList.tagMode'
},
{
name: 'untagged',
type: 'string',
location: 'query',
required: false,
descriptionKey: 'api.params.mediaList.untagged'
}
],
curl: `curl "${baseUrl}/api/files?kind=video&page=1&limit=20" \\
${auth}`,
responseExample: `{
"items": [
{
"key": "videos/2026/10/abc123.mp4",
"url": "https://pic.example.com/videos/2026/10/abc123.mp4",
"originalName": "demo.mp4",
"contentType": "video/mp4",
"size": 3580000,
"uploadedAt": "2026-10-17T03:00:00.000Z",
"transcodeStatus": "done",
"transcodeProgress": 100,
"transcodePreset": "veryfast",
"sourceDeleted": true,
"thumbUrl": "https://pic.example.com/videos/2026/10/abc123.webp",
"hlsUrl": "https://pic.example.com/videos/2026/10/abc123/index.m3u8",
"hlsKey": "videos/2026/10/abc123/index.m3u8"
}
],
"total": 1,
"page": 1,
"pageSize": 20,
"totalPages": 1,
"accessMode": "public"
}`
},
{
id: 'videoDelete',
category: 'video',
method: 'DELETE',
path: '/api/files',
titleKey: 'api.docs.videoDelete.title',
descKey: 'api.docs.videoDelete.desc',
requiresAuth: true,
params: [
{
name: 'key',
type: 'string',
location: 'query',
required: true,
descriptionKey: 'api.params.videoDelete.key'
}
],
curl: `curl -X DELETE "${baseUrl}/api/files?key=videos/2026/10/abc123.mp4" \\
${auth}`,
responseExample: `{
"success": true
}`
},
{
id: 'videoSearch',
category: 'video',
method: 'GET',
path: '/api/videos/search',
titleKey: 'api.docs.videoSearch.title',
descKey: 'api.docs.videoSearch.desc',
requiresAuth: true,
params: [
{
name: 'q',
type: 'string',
location: 'query',
required: true,
descriptionKey: 'api.params.mediaSearch.q'
},
{
name: 'page',
type: 'number',
location: 'query',
required: false,
descriptionKey: 'api.params.mediaList.page'
},
{
name: 'limit',
type: 'number',
location: 'query',
required: false,
descriptionKey: 'api.params.mediaList.limit'
},
{
name: 'backendId',
type: 'string',
location: 'query',
required: false,
descriptionKey: 'api.params.backendId'
},
{
name: 'uploadSource',
type: 'string',
location: 'query',
required: false,
descriptionKey: 'api.params.mediaList.uploadSource'
},
{
name: 'tagIds',
type: 'string',
location: 'query',
required: false,
descriptionKey: 'api.params.mediaList.tagIds'
},
{
name: 'tagMode',
type: 'string',
location: 'query',
required: false,
descriptionKey: 'api.params.mediaList.tagMode'
},
{
name: 'untagged',
type: 'string',
location: 'query',
required: false,
descriptionKey: 'api.params.mediaList.untagged'
},
{
name: 'processing',
type: 'string',
location: 'query',
required: false,
descriptionKey: 'api.params.mediaList.processing'
}
],
curl: `curl "${baseUrl}/api/videos/search?q=demo&page=1&limit=20" \\
${auth}`,
responseExample: `{
"items": [
{
"key": "videos/2026/10/demo.mp4",
"originalName": "demo.mp4",
"contentType": "video/mp4",
"size": 10485760,
"transcodeStatus": "done",
"thumbUrl": "https://pic.example.com/videos/2026/10/demo.webp",
"hlsUrl": "https://pic.example.com/videos/2026/10/demo/index.m3u8"
}
],
"total": 1,
"page": 1,
"pageSize": 20,
"totalPages": 1,
"accessMode": "public"
}`
},
{
id: 'videoBatchDelete',
category: 'video',
method: 'POST',
path: '/api/files/batch-delete',
titleKey: 'api.docs.videoBatchDelete.title',
descKey: 'api.docs.videoBatchDelete.desc',
requiresAuth: true,
params: [
{
name: 'keys',
type: 'string[]',
location: 'body',
required: true,
descriptionKey: 'api.params.videoBatchDelete.keys'
}
],
curl: `curl -X POST "${baseUrl}/api/files/batch-delete" \\
${auth} \\
-H "Content-Type: application/json" \\
-d '{"keys":["videos/2026/10/a.mp4","videos/2026/10/b.mp4"]}'`,
responseExample: `{
"success": true,
"deleted": [
"videos/2026/10/a.mp4",
"videos/2026/10/b.mp4"
],
"failed": []
}`
},
{
id: 'fileUpload',
category: 'file',
method: 'POST',
path: '/api/files/upload',
titleKey: 'api.docs.fileUpload.title',
descKey: 'api.docs.fileUpload.desc',
requiresAuth: true,
params: [
{
name: 'file',
type: 'file',
location: 'form-data',
required: true,
descriptionKey: 'api.params.fileUpload.file'
},
{
name: 'files',
type: 'file',
location: 'form-data',
required: false,
descriptionKey: 'api.params.fileUpload.files'
},
{
name: 'token',
type: 'string',
location: 'form-data',
required: false,
descriptionKey: 'api.params.fileUpload.token'
},
{
name: 'tagNames',
type: 'string',
location: 'form-data',
required: false,
descriptionKey: 'api.params.upload.tagNames'
}
],
curl: `curl -X POST "${baseUrl}/api/files/upload" \\
${auth} \\
-F "file=@./report.pdf" \\
-F 'tagNames=["文档","归档"]'`,
responseExample: `{
"success": true,
"items": [
{
"key": "files/2026/10/abc123.pdf",
"url": "https://pic.example.com/files/2026/10/abc123.pdf",
"originalName": "report.pdf",
"contentType": "application/pdf",
"size": 5242880,
"uploadedAt": "2026-10-17T03:00:00.000Z",
"markdown": "[report.pdf](https://pic.example.com/files/2026/10/abc123.pdf)",
"tags": [
{ "id": 4, "name": "文档", "color": "#f59e0b" },
{ "id": 9, "name": "归档", "color": "#64748b" }
]
}
],
"errors": [],
"accessMode": "public"
}`
},
{
id: 'fileList',
category: 'file',
method: 'GET',
path: '/api/files',
titleKey: 'api.docs.fileList.title',
descKey: 'api.docs.fileList.desc',
requiresAuth: true,
params: [
{
name: 'kind',
type: 'string',
location: 'query',
required: true,
descriptionKey: 'api.params.mediaList.kindFile'
},
{
name: 'page',
type: 'number',
location: 'query',
required: false,
descriptionKey: 'api.params.mediaList.page'
},
{
name: 'limit',
type: 'number',
location: 'query',
required: false,
descriptionKey: 'api.params.mediaList.limit'
},
{
name: 'q',
type: 'string',
location: 'query',
required: false,
descriptionKey: 'api.params.mediaList.q'
},
{
name: 'backendId',
type: 'string',
location: 'query',
required: false,
descriptionKey: 'api.params.backendId'
},
{
name: 'uploadSource',
type: 'string',
location: 'query',
required: false,
descriptionKey: 'api.params.mediaList.uploadSource'
},
{
name: 'tagIds',
type: 'string',
location: 'query',
required: false,
descriptionKey: 'api.params.mediaList.tagIds'
},
{
name: 'tagMode',
type: 'string',
location: 'query',
required: false,
descriptionKey: 'api.params.mediaList.tagMode'
},
{
name: 'untagged',
type: 'string',
location: 'query',
required: false,
descriptionKey: 'api.params.mediaList.untagged'
}
],
curl: `curl "${baseUrl}/api/files?kind=file&page=1&limit=20" \\
${auth}`,
responseExample: `{
"items": [
{
"key": "files/2026/10/abc123.pdf",
"url": "https://pic.example.com/files/2026/10/abc123.pdf",
"originalName": "report.pdf",
"contentType": "application/pdf",
"size": 5242880,
"uploadedAt": "2026-10-17T03:00:00.000Z",
"markdown": "[report.pdf](https://pic.example.com/files/2026/10/abc123.pdf)"
}
],
"total": 1,
"page": 1,
"pageSize": 20,
"totalPages": 1,
"accessMode": "public"
}`
},
{
id: 'fileDelete',
category: 'file',
method: 'DELETE',
path: '/api/files',
titleKey: 'api.docs.fileDelete.title',
descKey: 'api.docs.fileDelete.desc',
requiresAuth: true,
params: [
{
name: 'key',
type: 'string',
location: 'query',
required: true,
descriptionKey: 'api.params.fileDelete.key'
}
],
curl: `curl -X DELETE "${baseUrl}/api/files?key=files/2026/10/abc123.pdf" \\
${auth}`,
responseExample: `{
"success": true
}`
},
{
id: 'fileBatchDelete',
category: 'file',
method: 'POST',
path: '/api/files/batch-delete',
titleKey: 'api.docs.fileBatchDelete.title',
descKey: 'api.docs.fileBatchDelete.desc',
requiresAuth: true,
params: [
{
name: 'keys',
type: 'string[]',
location: 'body',
required: true,
descriptionKey: 'api.params.fileBatchDelete.keys'
}
],
curl: `curl -X POST "${baseUrl}/api/files/batch-delete" \\
${auth} \\
-H "Content-Type: application/json" \\
-d '{"keys":["files/2026/10/a.pdf","files/2026/10/b.zip"]}'`,
responseExample: `{
"success": true,
"deleted": [
"files/2026/10/a.pdf",
"files/2026/10/b.zip"
],
"failed": []
}`
},
{
id: 'fileSearch',
category: 'file',
method: 'GET',
path: '/api/files/search',
titleKey: 'api.docs.fileSearch.title',
descKey: 'api.docs.fileSearch.desc',
requiresAuth: true,
params: [
{
name: 'kind',
type: 'string',
location: 'query',
required: true,
descriptionKey: 'api.params.mediaList.kindFile'
},
{
name: 'q',
type: 'string',
location: 'query',
required: true,
descriptionKey: 'api.params.mediaSearch.q'
},
{
name: 'page',
type: 'number',
location: 'query',
required: false,
descriptionKey: 'api.params.mediaList.page'
},
{
name: 'limit',
type: 'number',
location: 'query',
required: false,
descriptionKey: 'api.params.mediaList.limit'
},
{
name: 'backendId',
type: 'string',
location: 'query',
required: false,
descriptionKey: 'api.params.backendId'
},
{
name: 'uploadSource',
type: 'string',
location: 'query',
required: false,
descriptionKey: 'api.params.mediaList.uploadSource'
},
{
name: 'tagIds',
type: 'string',
location: 'query',
required: false,
descriptionKey: 'api.params.mediaList.tagIds'
},
{
name: 'tagMode',
type: 'string',
location: 'query',
required: false,
descriptionKey: 'api.params.mediaList.tagMode'
},
{
name: 'untagged',
type: 'string',
location: 'query',
required: false,
descriptionKey: 'api.params.mediaList.untagged'
}
],
curl: `curl "${baseUrl}/api/files/search?kind=file&q=report&page=1&limit=20" \\
${auth}`,
responseExample: `{
"items": [
{
"key": "files/2026/10/report.pdf",
"url": "https://pic.example.com/files/2026/10/report.pdf",
"originalName": "report.pdf",
"contentType": "application/pdf",
"size": 5242880,
"uploadedAt": "2026-10-17T03:00:00.000Z",
"markdown": "[report.pdf](https://pic.example.com/files/2026/10/report.pdf)"
}
],
"total": 1,
"page": 1,
"pageSize": 20,
"totalPages": 1,
"accessMode": "public"
}`
},
{
id: 'twikoo',
category: 'extension',
method: 'POST',
path: '/api/index.php',
titleKey: 'api.docs.twikoo.title',
descKey: 'api.docs.twikoo.desc',
requiresAuth: false,
params: [
{
name: 'image',
type: 'file',
location: 'form-data',
required: true,
descriptionKey: 'api.params.twikoo.image'
},
{
name: 'token',
type: 'string',
location: 'form-data',
required: true,
descriptionKey: 'api.params.twikoo.token'
}
],
curl: `curl -X POST "${baseUrl}/api/index.php" \\
-F "token=${authHeader}" \\
-F "image=@./demo.png"`,
responseExample: `{
"result": "success",
"code": 200,
"url": "https://pic.example.com/abc123.webp"
}`
},
{
id: 'config',
category: 'system',
method: 'GET',
path: '/api/auth/config',
titleKey: 'api.docs.config.title',
descKey: 'api.docs.config.desc',
requiresAuth: false,
params: [],
curl: `curl "${baseUrl}/api/auth/config"`,
responseExample: `{
"initialized": true,
"allowRegistration": false,
"legacyMode": false,
"adminSecretConfigured": false,
"apiUploadTokenConfigured": true
}`
},
{
id: 'count',
category: 'system',
method: 'GET',
path: '/api/images/count',
titleKey: 'api.docs.count.title',
descKey: 'api.docs.count.desc',
requiresAuth: true,
params: [
{
name: 'backendId',
type: 'string',
location: 'query',
required: false,
descriptionKey: 'api.params.backendId'
}
],
curl: `curl "${baseUrl}/api/images/count" \\
${auth}`,
responseExample: `{
"total": 42
}`
}
]
}
+85 -11
View File
@@ -1,3 +1,5 @@
import type { LoginVerificationPayload } from '~/types/auth'
export type AuthStatus = 'checking' | 'authenticated' | 'unauthenticated'
export type UserRole = 'admin' | 'user'
@@ -8,11 +10,18 @@ export interface AuthUser {
role: UserRole
}
export interface LoginVerificationPublicConfig {
method: 'slider' | 'turnstile' | 'cap'
turnstileSiteKey?: string
capApiEndpoint?: string
}
export interface AuthStatusResponse {
initialized: boolean
allowRegistration: boolean
legacyMode: boolean
needsMigration: boolean
loginVerification: LoginVerificationPublicConfig
user: AuthUser | null
}
@@ -66,13 +75,30 @@ export function getFetchErrorMessage(error: unknown, fallback: string): 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() {
const { t } = useI18n()
const status = useState<AuthStatus>('auth-status', () => 'checking')
const user = useState<AuthUser | null>('auth-user', () => null)
const authStatus = useState<AuthStatusResponse | null>('auth-status-response', () => null)
/** 是否已完成至少一次会话校验(用于避免路由切换时反复全屏 loading) */
const hasValidatedOnce = useState('auth-validated-once', () => false)
const isChecking = computed(() => status.value === 'checking')
const isChecking = computed(() => status.value === 'checking' && !hasValidatedOnce.value)
const isAuthenticated = computed(() => status.value === 'authenticated')
const isAdmin = computed(() => user.value?.role === 'admin')
@@ -84,10 +110,14 @@ export function useAuth() {
return data
}
async function checkSession() {
status.value = 'checking'
async function checkSession(options?: { force?: boolean }) {
const silent = !options?.force && hasValidatedOnce.value
if (!silent) {
status.value = 'checking'
}
try {
const data = await fetchStatus()
hasValidatedOnce.value = true
if (data.user) {
user.value = data.user
status.value = 'authenticated'
@@ -97,6 +127,7 @@ export function useAuth() {
status.value = 'unauthenticated'
return false
} catch {
hasValidatedOnce.value = true
user.value = null
status.value = 'unauthenticated'
return false
@@ -110,25 +141,45 @@ export function useAuth() {
async function login(
credentials: { username: string, password: string }
| { secret: string }
): Promise<{ ok: boolean, needsMigration?: boolean, error?: string }> {
| { secret: string },
verification: LoginVerificationPayload
): Promise<{
ok: boolean
needsMigration?: boolean
needsPasswordReset?: boolean
resetUsername?: string
error?: string
}> {
try {
const result = await $fetch<{
success: boolean
needsMigration?: boolean
requiresPasswordReset?: boolean
username?: string
user?: AuthUser
}>('/api/auth/login', {
method: 'POST',
credentials: 'include',
body: credentials
body: {
...credentials,
...buildVerificationBody(verification)
}
})
if (result.needsMigration) {
return { ok: true, needsMigration: true }
}
if (result.requiresPasswordReset) {
return {
ok: false,
needsPasswordReset: true,
resetUsername: result.username
}
}
if (result.user) {
user.value = result.user
}
status.value = 'authenticated'
hasValidatedOnce.value = true
await fetchStatus()
return { ok: true }
} catch (error: unknown) {
@@ -141,7 +192,8 @@ export function useAuth() {
async function register(
username: string,
password: string
password: string,
verification: LoginVerificationPayload
): Promise<AuthActionResult> {
const validationError = validateAuthInput(username, password)
if (validationError) {
@@ -154,11 +206,16 @@ export function useAuth() {
{
method: 'POST',
credentials: 'include',
body: { username: username.trim(), password }
body: {
username: username.trim(),
password,
...buildVerificationBody(verification)
}
}
)
user.value = result.user
status.value = 'authenticated'
hasValidatedOnce.value = true
await fetchStatus()
return { ok: true }
} catch (error: unknown) {
@@ -173,6 +230,11 @@ export function useAuth() {
username: string
password: string
allowRegistration: boolean
domainSeparation?: boolean
siteBaseUrl?: string
imageBaseUrl?: string
videoBaseUrl?: string
fileBaseUrl?: string
}): Promise<AuthActionResult> {
const validationError = validateAuthInput(input.username, input.password)
if (validationError) {
@@ -188,12 +250,18 @@ export function useAuth() {
body: {
username: input.username.trim(),
password: input.password,
allowRegistration: input.allowRegistration
allowRegistration: input.allowRegistration,
domainSeparation: input.domainSeparation,
siteBaseUrl: input.siteBaseUrl,
imageBaseUrl: input.imageBaseUrl,
videoBaseUrl: input.videoBaseUrl,
fileBaseUrl: input.fileBaseUrl
}
}
)
user.value = result.user
status.value = 'authenticated'
hasValidatedOnce.value = true
await fetchStatus()
return { ok: true }
} catch (error: unknown) {
@@ -229,6 +297,7 @@ export function useAuth() {
)
user.value = result.user
status.value = 'authenticated'
hasValidatedOnce.value = true
await fetchStatus()
return { ok: true }
} catch (error: unknown) {
@@ -264,13 +333,18 @@ export function useAuth() {
async function logout() {
try {
await $fetch('/api/auth/logout', { method: 'POST' })
await $fetch('/api/auth/logout', { method: 'POST', credentials: 'include' })
} catch {
// 忽略登出失败
} finally {
status.value = 'unauthenticated'
user.value = null
authStatus.value = null
hasValidatedOnce.value = true
}
try {
await fetchStatus()
} catch {
// 保留既有公开配置(loginVerification 等)
}
}
+421
View File
@@ -0,0 +1,421 @@
import type {
BackupEstimate,
BackupJobItem,
BackupPackageItem,
BackupRestorePreview,
BackendSyncPreview,
SiteSyncRequest
} from '~/types/backup'
const jobs = ref<BackupJobItem[]>([])
const jobsPage = ref(1)
const jobsPageSize = ref(10)
const jobsTotal = ref(0)
const jobsTotalPages = ref(1)
const jobsLoading = ref(false)
const showHiddenJobs = ref(false)
const activeJobState = ref<BackupJobItem | null>(null)
const estimate = ref<BackupEstimate | null>(null)
const packages = ref<BackupPackageItem[]>([])
const loadingEstimate = ref(false)
const polling = ref(false)
let pollTimer: ReturnType<typeof setInterval> | null = null
export function useBackup() {
const toast = useToast()
const { t } = useI18n()
function stopPolling() {
if (pollTimer) {
clearInterval(pollTimer)
pollTimer = null
}
polling.value = false
}
function hasActiveJob(): boolean {
return activeJobState.value !== null
}
function getActiveJob(): BackupJobItem | null {
return activeJobState.value
}
async function cancelJob(jobId: string) {
try {
await $fetch(`/api/admin/backup/jobs/${jobId}/cancel`, {
method: 'POST',
credentials: 'include'
})
await loadJobs()
toast.add({ title: t('storage.backup.cancelDone'), color: 'success' })
} catch (error: unknown) {
handleError(error, t('storage.backup.cancelFailed'))
}
}
async function loadJobs(options?: { page?: number, pageSize?: number }) {
if (options?.page !== undefined) jobsPage.value = options.page
if (options?.pageSize !== undefined) jobsPageSize.value = options.pageSize
jobsLoading.value = true
try {
const data = await $fetch<{
jobs: BackupJobItem[]
total: number
page: number
pageSize: number
totalPages: number
activeJob: BackupJobItem | null
}>('/api/admin/backup/jobs', {
credentials: 'include',
query: {
pageNum: jobsPage.value,
pageSize: jobsPageSize.value,
includeHidden: showHiddenJobs.value
}
})
jobs.value = data.jobs
jobsTotal.value = data.total
jobsPage.value = data.page
jobsPageSize.value = data.pageSize
jobsTotalPages.value = data.totalPages
activeJobState.value = data.activeJob
if (!data.activeJob) {
stopPolling()
void loadEstimate().catch(() => {})
}
return data.jobs
} finally {
jobsLoading.value = false
}
}
async function setJobsPage(page: number) {
jobsPage.value = page
return loadJobs()
}
async function setJobsPageSize(pageSize: number) {
jobsPageSize.value = pageSize
jobsPage.value = 1
return loadJobs()
}
async function refreshJobsFirstPage() {
jobsPage.value = 1
return loadJobs()
}
async function toggleShowHiddenJobs() {
showHiddenJobs.value = !showHiddenJobs.value
jobsPage.value = 1
await loadJobs()
}
async function hideJob(jobId: string, hidden = true) {
try {
await $fetch('/api/admin/backup/jobs/hide', {
method: 'POST',
credentials: 'include',
body: { jobId, hidden }
})
if (hidden) {
showHiddenJobs.value = false
jobsPage.value = 1
}
await loadJobs()
toast.add({
title: hidden
? t('storage.backup.jobHidden')
: t('storage.backup.jobUnhidden'),
color: 'success'
})
} catch (error: unknown) {
handleError(error, t('storage.backup.hideFailed'))
}
}
async function hideAllJobs(hidden = true) {
try {
await $fetch('/api/admin/backup/jobs/hide-all', {
method: 'POST',
credentials: 'include',
body: { hidden }
})
if (hidden) {
showHiddenJobs.value = false
}
jobsPage.value = 1
await loadJobs()
toast.add({
title: hidden
? t('storage.backup.jobsHiddenAll')
: t('storage.backup.jobsUnhiddenAll'),
color: 'success'
})
} catch (error: unknown) {
handleError(error, t('storage.backup.hideFailed'))
}
}
async function loadEstimate() {
loadingEstimate.value = true
try {
const data = await $fetch<{
estimate: BackupEstimate
packages: BackupPackageItem[]
backupsDir: string
}>('/api/admin/backup/estimate', { credentials: 'include' })
estimate.value = data.estimate
packages.value = data.packages
return data
} finally {
loadingEstimate.value = false
}
}
function startPolling() {
if (pollTimer) return
polling.value = true
pollTimer = setInterval(() => {
void loadJobs().catch(() => {})
}, 2000)
}
async function createExport() {
try {
const data = await $fetch<{ job: BackupJobItem, estimate: BackupEstimate }>(
'/api/admin/backup/export',
{ method: 'POST', credentials: 'include' }
)
estimate.value = data.estimate
await refreshJobsFirstPage()
startPolling()
toast.add({ title: t('storage.backup.exportStarted'), color: 'success' })
return data.job
} catch (error: unknown) {
handleError(error, t('storage.backup.exportFailed'))
throw error
}
}
async function previewRestore(input: {
file?: File
archiveName?: string
}): Promise<BackupRestorePreview> {
if (input.file) {
const data = await $fetch<{ preview: BackupRestorePreview }>(
'/api/admin/backup/restore',
{
method: 'POST',
credentials: 'include',
body: input.file,
headers: {
'content-type': 'application/octet-stream',
'x-pichost-dry-run': 'true'
}
}
)
return data.preview
}
const data = await $fetch<{ preview: BackupRestorePreview }>(
'/api/admin/backup/restore',
{
method: 'POST',
credentials: 'include',
body: {
archiveName: input.archiveName,
dryRun: true
}
}
)
return data.preview
}
async function confirmRestore(input: {
file?: File
archiveName?: string
conflictMode: 'skip' | 'overwrite'
}) {
try {
if (input.file) {
const data = await $fetch<{ job: BackupJobItem }>(
'/api/admin/backup/restore',
{
method: 'POST',
credentials: 'include',
body: input.file,
headers: {
'content-type': 'application/octet-stream',
'x-pichost-dry-run': 'false',
'x-pichost-conflict-mode': input.conflictMode
}
}
)
await refreshJobsFirstPage()
startPolling()
toast.add({ title: t('storage.backup.restoreStarted'), color: 'success' })
return data.job
}
const data = await $fetch<{ job: BackupJobItem }>(
'/api/admin/backup/restore',
{
method: 'POST',
credentials: 'include',
body: {
archiveName: input.archiveName,
conflictMode: input.conflictMode,
dryRun: false
}
}
)
await refreshJobsFirstPage()
startPolling()
toast.add({ title: t('storage.backup.restoreStarted'), color: 'success' })
return data.job
} catch (error: unknown) {
handleError(error, t('storage.backup.restoreFailed'))
throw error
}
}
async function previewSync(body: {
fromBackendId: string
toBackendId: string
}): Promise<BackendSyncPreview> {
const data = await $fetch<{ preview: BackendSyncPreview }>(
'/api/admin/storage/sync',
{
method: 'POST',
credentials: 'include',
body: { ...body, dryRun: true }
}
)
return data.preview
}
async function startSync(body: {
fromBackendId: string
toBackendId: string
deleteSource?: boolean
setDefault?: boolean
}) {
try {
const data = await $fetch<{ job: BackupJobItem }>(
'/api/admin/storage/sync',
{
method: 'POST',
credentials: 'include',
body
}
)
await refreshJobsFirstPage()
startPolling()
toast.add({ title: t('storage.backup.syncStarted'), color: 'success' })
return data.job
} catch (error: unknown) {
handleError(error, t('storage.backup.syncFailed'))
throw error
}
}
async function startSiteSync(body: SiteSyncRequest) {
try {
const data = await $fetch<{ job: BackupJobItem }>(
'/api/admin/site-sync',
{
method: 'POST',
credentials: 'include',
body
}
)
await refreshJobsFirstPage()
startPolling()
const cleanupPending = Boolean(
body.abandonJobId
&& data.job.payload.cleanupPending === true
)
toast.add({
title: cleanupPending
? t('storage.backup.siteSyncAbandonedPendingCleanup')
: body.abandonJobId
? t('storage.backup.siteSyncAbandoned')
: t('storage.backup.siteSyncStarted'),
color: cleanupPending ? 'warning' : 'success'
})
return data.job
} catch (error: unknown) {
handleError(error, t('storage.backup.siteSyncFailed'))
throw error
}
}
async function retryJob(jobId: string) {
try {
await $fetch(`/api/admin/backup/jobs/${jobId}/retry`, {
method: 'POST',
credentials: 'include'
})
await refreshJobsFirstPage()
startPolling()
toast.add({ title: t('storage.backup.retryStarted'), color: 'success' })
} catch (error: unknown) {
handleError(error, t('storage.backup.retryFailed'))
}
}
function downloadUrl(jobId: string): string {
return `/api/admin/backup/jobs/${jobId}/download`
}
function handleError(error: unknown, fallback: string) {
const status = typeof error === 'object' && error !== null && 'statusCode' in error
? (error as { statusCode: number }).statusCode
: 0
if (status === 409) {
toast.add({ title: t('storage.backup.jobRunning'), color: 'warning' })
return
}
toast.add({ title: fallback, color: 'error' })
}
onUnmounted(() => {
stopPolling()
})
return {
jobs,
jobsPage,
jobsPageSize,
jobsTotal,
jobsTotalPages,
jobsLoading,
showHiddenJobs,
estimate,
packages,
loadingEstimate,
polling,
hasActiveJob,
getActiveJob,
loadJobs,
toggleShowHiddenJobs,
hideJob,
hideAllJobs,
setJobsPage,
setJobsPageSize,
loadEstimate,
startPolling,
stopPolling,
createExport,
previewRestore,
confirmRestore,
previewSync,
startSync,
startSiteSync,
retryJob,
cancelJob,
downloadUrl
}
}
+52 -7
View File
@@ -1,10 +1,55 @@
export function useFileSize() {
function formatFileSize(bytes: number): string {
if (!Number.isFinite(bytes) || bytes < 0) return '-'
if (bytes < 1024) return `${bytes} B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
return `${(bytes / (1024 * 1024)).toFixed(2)} MB`
export const QUOTA_UNITS = ['KB', 'MB', 'GB', 'TB'] as const
export type QuotaUnit = typeof QUOTA_UNITS[number]
export function formatFileSize(bytes: number): string {
if (!Number.isFinite(bytes) || bytes < 0) return '-'
if (bytes === 0) return '0 B'
if (bytes < 1024) return `${bytes} B`
const units = ['KB', 'MB', 'GB', 'TB'] as const
let value = bytes / 1024
let unitIndex = 0
while (unitIndex < units.length - 1 && value >= 1024) {
value /= 1024
unitIndex++
}
return { formatFileSize }
const decimals = value >= 100 ? 0 : value >= 10 ? 1 : 2
return `${value.toFixed(decimals)} ${units[unitIndex]}`
}
export function bytesToQuotaInput(bytes: number | null): { value: string, unit: QuotaUnit } {
if (!bytes || bytes <= 0) {
return { value: '', unit: 'GB' }
}
let value = bytes / 1024
let unitIndex = 0
while (unitIndex < QUOTA_UNITS.length - 1 && value >= 1024) {
value /= 1024
unitIndex++
}
const decimals = value >= 100 ? 0 : value >= 10 ? 1 : 2
const rounded = Number(value.toFixed(decimals))
const unit: QuotaUnit = QUOTA_UNITS[unitIndex] ?? 'GB'
return { value: String(rounded), unit }
}
export function quotaToBytes(value: string | number, unit: QuotaUnit): number | null {
const trimmed = String(value ?? '').trim()
if (!trimmed) return null
const amount = Number(trimmed)
if (!Number.isFinite(amount) || amount <= 0) return null
const unitIndex = QUOTA_UNITS.indexOf(unit)
if (unitIndex < 0) return null
return Math.round(amount * 1024 ** (unitIndex + 1))
}
export function useFileSize() {
return { formatFileSize, bytesToQuotaInput, quotaToBytes, QUOTA_UNITS }
}
+211 -41
View File
@@ -3,31 +3,79 @@ import type {
ImageListResponse
} from '~/types/image'
const PAGE_SIZE = 12
const PAGE_SIZE_DEFAULT = 10
export function useImageList() {
const { t } = useI18n()
const items = ref<ImageItem[]>([])
const page = ref(1)
const pageSize = ref(PAGE_SIZE_DEFAULT)
const totalPages = ref(1)
const total = ref(0)
const loading = ref(false)
const folderTotal = ref<number | null>(null)
const totalCount = ref<number | null>(null)
const loadingTotal = ref(false)
const searchQuery = ref('')
const activeSearch = ref('')
const activeFolder = ref('all')
const activeStorageBackend = ref('all')
const activeContentType = ref('all')
const activeUploadSource = ref('all')
const activeTagIds = ref<number[]>([])
const activeTagMode = ref<'or' | 'and'>('or')
const activeUntaggedOnly = ref(false)
const storageBackendOptions = ref<Array<{ id: string, name: string }>>([])
function folderQueryParams(): Record<string, string> {
const folder = activeFolder.value.trim()
if (!folder || folder === 'all') return {}
return { folder }
function storageQueryParams(): Record<string, string> {
const backendId = activeStorageBackend.value.trim()
if (!backendId || backendId === 'all') return {}
return { backendId }
}
function matchesActiveFolder(key: string) {
const folder = activeFolder.value.trim()
if (!folder || folder === 'all') return true
return key.startsWith(`${folder}/`)
function filterQueryParams(): Record<string, string> {
const params: Record<string, string> = { ...storageQueryParams() }
const contentType = activeContentType.value.trim()
if (contentType && contentType !== 'all') {
params.contentType = contentType
}
const uploadSource = activeUploadSource.value.trim()
if (uploadSource && uploadSource !== 'all') {
params.uploadSource = uploadSource
}
if (activeUntaggedOnly.value) {
params.untagged = '1'
} else if (activeTagIds.value.length) {
params.tagIds = activeTagIds.value.join(',')
if (activeTagMode.value === 'and' && activeTagIds.value.length > 1) {
params.tagMode = 'and'
}
}
return params
}
function listQueryParams(): Record<string, string> {
return filterQueryParams()
}
function matchesActiveStorage(item: ImageItem) {
const backendId = activeStorageBackend.value.trim()
if (!backendId || backendId === 'all') return true
return item.storage?.id === backendId
}
async function loadStorageBackendOptions() {
try {
const data = await $fetch<{
backends: Array<{ id: string, name: string, type: string }>
}>('/api/storage-backends', {
credentials: 'include'
})
storageBackendOptions.value = data.backends.map(backend => ({
id: backend.id,
name: backend.name
}))
} catch {
storageBackendOptions.value = []
}
}
async function fetchTotal() {
@@ -35,11 +83,11 @@ export function useImageList() {
try {
const data = await $fetch<{ total: number }>('/api/images/count', {
credentials: 'include',
query: folderQueryParams()
query: listQueryParams()
})
folderTotal.value = data.total
totalCount.value = data.total
} catch {
folderTotal.value = null
totalCount.value = null
} finally {
loadingTotal.value = false
}
@@ -51,9 +99,9 @@ export function useImageList() {
const response = await $fetch<ImageListResponse>('/api/images', {
credentials: 'include',
query: {
limit: PAGE_SIZE,
limit: pageSize.value,
page: targetPage,
...folderQueryParams()
...listQueryParams()
}
})
@@ -79,9 +127,9 @@ export function useImageList() {
credentials: 'include',
query: {
q,
limit: PAGE_SIZE,
limit: pageSize.value,
page: targetPage,
...folderQueryParams()
...listQueryParams()
}
})
@@ -101,19 +149,17 @@ export function useImageList() {
await Promise.all([fetchList(1), fetchTotal()])
}
async function initializeList(folder = 'all') {
const next = folder.trim() || 'all'
activeFolder.value = next
async function initializeList() {
page.value = 1
activeSearch.value = ''
searchQuery.value = ''
await Promise.all([fetchList(1), fetchTotal()])
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
async function setActiveStorageBackend(backendId: string) {
const next = backendId.trim() || 'all'
if (next === activeStorageBackend.value) return
activeStorageBackend.value = next
page.value = 1
if (activeSearch.value) {
await Promise.all([fetchSearch(1), fetchTotal()])
@@ -122,6 +168,76 @@ export function useImageList() {
}
}
async function setActiveContentType(contentType: string) {
const next = contentType.trim() || 'all'
if (next === activeContentType.value) return
activeContentType.value = next
page.value = 1
if (activeSearch.value) {
await Promise.all([fetchSearch(1), fetchTotal()])
} else {
await Promise.all([fetchList(1), fetchTotal()])
}
}
async function setActiveUploadSource(uploadSource: string) {
const next = uploadSource.trim() || 'all'
if (next === activeUploadSource.value) return
activeUploadSource.value = next
page.value = 1
if (activeSearch.value) {
await Promise.all([fetchSearch(1), fetchTotal()])
} else {
await Promise.all([fetchList(1), fetchTotal()])
}
}
async function setActiveTagFilter(options: {
tagIds?: number[]
tagMode?: 'or' | 'and'
untaggedOnly?: boolean
}) {
if (options.tagIds !== undefined) {
activeTagIds.value = [...options.tagIds]
}
if (options.tagMode !== undefined) {
activeTagMode.value = options.tagMode
}
if (options.untaggedOnly !== undefined) {
activeUntaggedOnly.value = options.untaggedOnly
}
page.value = 1
if (activeSearch.value) {
await Promise.all([fetchSearch(1), fetchTotal()])
} else {
await Promise.all([fetchList(1), fetchTotal()])
}
}
async function toggleTagFilter(tagId: number) {
const next = new Set(activeTagIds.value)
activeUntaggedOnly.value = false
if (next.has(tagId)) {
next.delete(tagId)
} else {
next.add(tagId)
}
await setActiveTagFilter({ tagIds: [...next] })
}
async function resetFilters() {
searchQuery.value = ''
activeSearch.value = ''
activeStorageBackend.value = 'all'
activeContentType.value = 'all'
activeUploadSource.value = 'all'
activeTagIds.value = []
activeTagMode.value = 'or'
activeUntaggedOnly.value = false
page.value = 1
await Promise.all([fetchList(1), fetchTotal()])
}
async function submitSearch() {
const q = searchQuery.value.trim()
activeSearch.value = q
@@ -142,13 +258,24 @@ export function useImageList() {
}
}
async function setPageSize(size: number) {
if (!Number.isFinite(size) || size <= 0 || size === pageSize.value) return
pageSize.value = size
page.value = 1
if (activeSearch.value) {
await Promise.all([fetchSearch(1), fetchTotal()])
} else {
await Promise.all([fetchList(1), fetchTotal()])
}
}
function prependItems(newItems: ImageItem[]) {
const matched = newItems.filter(item => matchesActiveFolder(item.key))
const matched = newItems.filter(item => matchesActiveStorage(item))
if (!matched.length || page.value !== 1) return
items.value = [...matched, ...items.value]
total.value += matched.length
if (folderTotal.value !== null) {
folderTotal.value += matched.length
if (totalCount.value !== null) {
totalCount.value += matched.length
}
}
@@ -159,31 +286,65 @@ export function useImageList() {
if (removed > 0) {
total.value = Math.max(0, total.value - removed)
}
if (folderTotal.value !== null && removed > 0) {
folderTotal.value = Math.max(0, folderTotal.value - removed)
if (totalCount.value !== null && removed > 0) {
totalCount.value = Math.max(0, totalCount.value - removed)
}
}
const activeStorageLabel = computed(() => {
if (activeStorageBackend.value === 'all') return ''
return storageBackendOptions.value.find(
backend => backend.id === activeStorageBackend.value
)?.name ?? activeStorageBackend.value
})
const listSummary = computed(() => {
const folder = activeFolder.value.trim() || 'all'
const folderLabel = folder === 'all' ? t('stats.allFolders') : folder
if (loadingTotal.value && folderTotal.value === null) {
return `${folderLabel} · ${t('stats.counting')}`
const storageLabel = activeStorageLabel.value
if (loadingTotal.value && totalCount.value === null) {
return storageLabel
? `${storageLabel} · ${t('stats.counting')}`
: t('stats.counting')
}
if (activeSearch.value) {
return t('stats.summarySearch', { folder: folderLabel, q: activeSearch.value, total: total.value })
if (storageLabel) {
return t('stats.summarySearchStorage', {
q: activeSearch.value,
total: total.value,
storage: storageLabel
})
}
return t('stats.summarySearch', { q: activeSearch.value, total: total.value })
}
const count = folderTotal.value === null ? total.value : folderTotal.value
return t('stats.summaryTotal', { folder: folderLabel, total: count })
const count = totalCount.value === null ? total.value : totalCount.value
if (storageLabel) {
return t('stats.summaryTotalStorage', {
total: count,
storage: storageLabel
})
}
return t('stats.summaryTotal', { total: count })
})
function updateItemTags(key: string, tags: ImageItem['tags']) {
items.value = items.value.map(item =>
item.key === key ? { ...item, tags } : item
)
}
return {
items,
page,
pageSize,
totalPages,
total,
loading,
activeFolder,
activeStorageBackend,
activeContentType,
activeUploadSource,
activeTagIds,
activeTagMode,
activeUntaggedOnly,
storageBackendOptions,
searchQuery,
activeSearch,
listSummary,
@@ -192,10 +353,19 @@ export function useImageList() {
fetchTotal,
refreshList,
initializeList,
setActiveFolder,
setActiveStorageBackend,
setActiveContentType,
setActiveUploadSource,
setActiveTagFilter,
toggleTagFilter,
resetFilters,
loadStorageBackendOptions,
submitSearch,
goToPage,
setPageSize,
prependItems,
removeItems
removeItems,
updateItemTags,
listQueryParams
}
}
+40 -13
View File
@@ -4,21 +4,23 @@ import type {
} from '~/types/image'
import { isUnauthorizedError } from './useAuth'
import { prepareFilesForUpload } from './useClipboardImage'
import { normalizeUploadTagNames } from './useUploadPreferences'
import { uploadFormDataWithProgress } from '~/utils/upload-progress'
const MAX_FILES = 10
export function useImageUpload() {
const toast = useToast()
const { t } = useI18n()
const { compressEnabled, clientWebpQuality } = useUploadPreferences()
const { compressEnabled, clientWebpQuality, uploadTagNames } = useUploadPreferences()
const { load: loadUploadPolicy } = useUploadPolicy()
const uploading = ref(false)
const progressItems = ref<UploadProgressItem[]>([])
const lastUploadResult = ref<UploadResponse | null>(null)
async function uploadFiles(
files: File[],
folder = 'images',
options?: { notify?: boolean, includeFolder?: boolean }
options?: { notify?: boolean }
) {
if (uploading.value) return
@@ -43,29 +45,54 @@ export function useImageUpload() {
}))
try {
const prepared = await prepareFilesForUpload(files, {
const preparedFiles = await prepareFilesForUpload(files, {
enabled: compressEnabled.value,
quality: clientWebpQuality.value / 100
})
const currentPolicy = await loadUploadPolicy().catch(() => null)
const maxFileSizeMb = currentPolicy?.imageMaxFileSizeMb ?? 10
const prepared = preparedFiles.filter((file) => {
if (file.size <= maxFileSizeMb * 1024 * 1024) return true
toast.add({
title: t('files.fileTooLarge', {
name: file.name,
limit: maxFileSizeMb
}),
color: 'error'
})
return false
})
if (!prepared.length) {
progressItems.value = []
return
}
progressItems.value = prepared.map((file, index) => ({
progressItems.value = prepared.map(file => ({
name: file.name,
status: 'uploading',
progress: Math.round(((index + 1) / prepared.length) * 50)
progress: 0
}))
const formData = new FormData()
if (options?.includeFolder !== false) {
formData.append('folder', folder.trim() || 'images')
}
for (const file of prepared) {
formData.append('files', file, file.name)
}
const response = await $fetch<UploadResponse>('/api/images/upload', {
method: 'POST',
body: formData
})
const tagNames = normalizeUploadTagNames(uploadTagNames.value).join(',')
if (tagNames) {
formData.append('tagNames', tagNames)
}
const response = await uploadFormDataWithProgress<UploadResponse>(
'/api/images/upload',
formData,
(percent) => {
progressItems.value = progressItems.value.map(item => ({
...item,
progress: Math.min(99, percent)
}))
}
)
lastUploadResult.value = response
+40
View File
@@ -0,0 +1,40 @@
const STORAGE_KEY = 'pichost.library-view'
export type LibraryViewKind = 'image' | 'video' | 'file'
export type LibraryViewMode = 'grid' | 'list'
function storageKeyFor(kind: LibraryViewKind): string {
return `${STORAGE_KEY}.${kind}`
}
/**
* 图库 / 视频库 / 文件库各自的显示方式。
* 三个库分别记住(互不影响),且页面切换后保持,不用重新点。
*/
export function useLibraryView(kind: LibraryViewKind = 'image') {
const viewMode = useState<LibraryViewMode>(`library-view-mode-${kind}`, () => 'grid')
const loaded = useState(`library-view-loaded-${kind}`, () => false)
if (import.meta.client && !loaded.value) {
try {
const stored = localStorage.getItem(storageKeyFor(kind))
if (stored === 'grid' || stored === 'list') {
viewMode.value = stored
}
} catch {
// 读取失败保持默认
}
loaded.value = true
}
watch(viewMode, (value) => {
if (!import.meta.client) return
try {
localStorage.setItem(storageKeyFor(kind), value)
} catch {
// 写入失败不影响使用
}
})
return { viewMode }
}
+347
View File
@@ -0,0 +1,347 @@
import type { UploadProgressItem } from '~/types/image'
import type { FileItem, FileListResponse, FileUploadResponse } from '~/types/file'
import type { CopyFormat, UploadMediaKind } from './useUploadPreferences'
import { isUnauthorizedError } from './useAuth'
import { normalizeUploadTagNames } from './useUploadPreferences'
import { uploadFormDataWithProgress } from '~/utils/upload-progress'
const MAX_FILES = 10
function buildCopyText(item: FileItem, format: CopyFormat): string {
switch (format) {
case 'url':
return item.url
case 'html':
return `<a href="${item.url}">${item.originalName}</a>`
case 'bbcode':
return `[url=${item.url}]${item.originalName}[/url]`
default:
return `[${item.originalName}](${item.url})`
}
}
async function copyToClipboard(text: string): Promise<boolean> {
try {
if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(text)
return true
}
} catch {
// 内网 http:// 等非安全上下文继续走回退方案
}
try {
const textarea = document.createElement('textarea')
textarea.value = text
textarea.style.position = 'fixed'
textarea.style.left = '-9999px'
document.body.appendChild(textarea)
textarea.select()
const ok = document.execCommand('copy')
document.body.removeChild(textarea)
return ok
} catch {
return false
}
}
/** 视频床 / 文件床的上传:走文件床接口,偏好按类型独立 */
export function useMediaUpload(kind: UploadMediaKind) {
const toast = useToast()
const { t } = useI18n()
const {
load: loadUploadPolicy
} = useUploadPolicy()
const {
autoCopyMarkdown,
copyFormat,
transcodeToHls,
uploadTagNames,
loadPreferences
} = useUploadPreferences(kind)
const uploading = ref(false)
const progressItems = ref<UploadProgressItem[]>([])
const lastResult = ref<FileUploadResponse | null>(null)
/** 本次会话内上传的条目,页面刷新后清空 */
const sessionItems = ref<FileItem[]>([])
const accessMode = ref<'public' | 'token'>('public')
const apiToken = ref('')
const endpoint = kind === 'video' ? '/api/videos/upload' : '/api/files/upload'
loadPreferences()
async function loadApiToken() {
try {
const data = await $fetch<{ apiUploadToken: string }>('/api/user/api-token', {
credentials: 'include'
})
apiToken.value = data.apiUploadToken ?? ''
} catch {
apiToken.value = ''
}
}
/** Token 模式下复制出的链接需要带上令牌 */
function linkUrl(item: FileItem): string {
// 转码完成后优先给出 m3u8 播放地址
const base = item.hlsUrl ?? item.url
if (accessMode.value !== 'token' || !apiToken.value) return base
const separator = base.includes('?') ? '&' : '?'
return `${base}${separator}token=${encodeURIComponent(apiToken.value)}`
}
/** 封面缩略图地址(同样按访问模式拼接) */
function thumbLink(item: FileItem): string {
const base = item.thumbUrl
if (!base) return '/video-placeholder.svg'
if (accessMode.value !== 'token' || !apiToken.value) return base
const separator = base.includes('?') ? '&' : '?'
return `${base}${separator}token=${encodeURIComponent(apiToken.value)}`
}
let statusTimer: ReturnType<typeof setInterval> | null = null
function stopStatusPolling() {
if (statusTimer) {
clearInterval(statusTimer)
statusTimer = null
}
}
function hasPendingTranscode(): boolean {
return sessionItems.value.some(
item => item.transcodeStatus === 'pending' || item.transcodeStatus === 'running'
)
}
/** 转码是异步的,轮询列表把状态与 m3u8 地址同步回来 */
async function refreshTranscodeStatus() {
if (!hasPendingTranscode()) {
stopStatusPolling()
return
}
try {
const [active, finished] = await Promise.all([
$fetch<FileListResponse>('/api/files', {
credentials: 'include',
query: { kind: 'video', limit: 100, processing: '1' }
}),
$fetch<FileListResponse>('/api/files', {
credentials: 'include',
query: { kind: 'video', limit: 100 }
})
])
const latest = new Map(
[...active.items, ...finished.items].map(item => [item.key, item])
)
sessionItems.value = sessionItems.value.map(item => latest.get(item.key) ?? item)
} catch {
// 轮询失败保持现状,下个周期重试
}
if (!hasPendingTranscode()) stopStatusPolling()
}
function startStatusPolling() {
if (kind !== 'video' || statusTimer) return
statusTimer = setInterval(() => {
void refreshTranscodeStatus()
}, 1000)
}
onUnmounted(stopStatusPolling)
/** 刷新或换页后仍要显示还在排队/转码中的视频 */
async function loadActiveTranscodes() {
if (kind !== 'video') return
try {
const response = await $fetch<FileListResponse>('/api/files', {
credentials: 'include',
query: { kind: 'video', limit: 100, processing: '1' }
})
if (!response.items.length) return
const known = new Set(sessionItems.value.map(item => item.key))
const missing = response.items.filter(item => !known.has(item.key))
const latest = new Map(response.items.map(item => [item.key, item]))
sessionItems.value = [
...missing,
...sessionItems.value.map(item => latest.get(item.key) ?? item)
]
startStatusPolling()
} catch {
// 拉取失败时保持现状
}
}
async function uploadFiles(files: File[]) {
if (!files.length || uploading.value) return
const currentPolicy = await loadUploadPolicy().catch(() => null)
const acceptedFiles = files.filter((file) => {
const extension = file.name.split('.').pop()?.toLowerCase() ?? ''
if (
kind === 'video'
&& currentPolicy
&& !currentPolicy.allowedVideoExtensions.includes(extension)
) {
toast.add({
title: `${file.name}:${t('files.videoFormatBlocked', {
format: extension ? extension.toUpperCase() : '—'
})}`,
color: 'error'
})
return false
}
if (
kind === 'file'
&& currentPolicy?.forbiddenFileExtensions.includes(extension)
) {
toast.add({
title: `${file.name}:${t('files.fileFormatBlocked', {
format: extension.toUpperCase()
})}`,
color: 'error'
})
return false
}
const maxFileSizeMb = kind === 'video'
? (currentPolicy?.videoMaxFileSizeMb ?? 500)
: (currentPolicy?.fileMaxFileSizeMb ?? 100)
if (file.size > maxFileSizeMb * 1024 * 1024) {
toast.add({
title: t('files.fileTooLarge', {
name: file.name,
limit: maxFileSizeMb
}),
color: 'error'
})
return false
}
return true
})
if (!acceptedFiles.length) return
if (acceptedFiles.length > MAX_FILES) {
toast.add({ title: t('files.maxFiles', { n: MAX_FILES }), color: 'warning' })
return
}
uploading.value = true
progressItems.value = acceptedFiles.map(file => ({
name: file.name,
status: 'pending',
progress: 0
}))
try {
const formData = new FormData()
for (const file of acceptedFiles) {
formData.append('files', file, file.name)
}
if (kind === 'video' && transcodeToHls.value) {
formData.append('transcode', '1')
}
const tagNames = normalizeUploadTagNames(uploadTagNames.value).join(',')
if (tagNames) {
formData.append('tagNames', tagNames)
}
progressItems.value = progressItems.value.map(item => ({
...item,
status: 'uploading',
progress: 0
}))
const response = await uploadFormDataWithProgress<FileUploadResponse>(
endpoint,
formData,
(percent) => {
progressItems.value = progressItems.value.map(item => ({
...item,
progress: Math.min(99, percent)
}))
}
)
lastResult.value = response
accessMode.value = response.accessMode
if (response.accessMode === 'token' && !apiToken.value) {
await loadApiToken()
}
progressItems.value = acceptedFiles.map((file) => {
const failed = response.errors.find(error => error.name === file.name)
return {
name: file.name,
status: failed ? 'error' : 'success',
progress: 100,
error: failed?.error.message
}
})
if (response.items.length) {
const wantsTranscode = kind === 'video' && transcodeToHls.value
const uploaded = wantsTranscode
? response.items.map(item => ({ ...item, transcodeStatus: 'pending' as const }))
: response.items
sessionItems.value = [...uploaded, ...sessionItems.value]
if (wantsTranscode) startStatusPolling()
toast.add({
title: t('files.uploadSuccess', { n: response.items.length }),
color: 'success'
})
const first = response.items[0]
if (autoCopyMarkdown.value && first) {
const text = buildCopyText(first, copyFormat.value)
if (text && await copyToClipboard(text)) {
toast.add({ title: t('copy.copied'), color: 'success' })
}
}
}
for (const failure of response.errors.slice(0, 3)) {
toast.add({
title: `${failure.name}:${failure.error.message}`,
color: 'error'
})
}
return response
} catch (error: unknown) {
if (!isUnauthorizedError(error)) {
progressItems.value = progressItems.value.map(item => ({
...item,
status: 'error',
progress: 100,
error: t('files.uploadFailed')
}))
toast.add({ title: t('files.uploadFailed'), color: 'error' })
}
throw error
} finally {
uploading.value = false
}
}
return {
uploading,
progressItems,
lastResult,
sessionItems,
accessMode,
linkUrl,
thumbLink,
loadActiveTranscodes,
uploadFiles
}
}
+48
View File
@@ -0,0 +1,48 @@
const STORAGE_KEY = 'pichost.recent-tag-ids'
const MAX_RECENT = 12
function readRecentIds(): number[] {
if (import.meta.server) return []
try {
const raw = localStorage.getItem(STORAGE_KEY)
if (!raw) return []
const parsed = JSON.parse(raw)
if (!Array.isArray(parsed)) return []
return parsed.filter((id: unknown): id is number => Number.isInteger(id) && (id as number) > 0)
} catch {
return []
}
}
function writeRecentIds(ids: number[]) {
if (import.meta.server) return
localStorage.setItem(STORAGE_KEY, JSON.stringify(ids))
}
export function useRecentTags() {
const recentIds = useState<number[]>('recent-tag-ids', () => [])
function loadRecentIds() {
recentIds.value = readRecentIds()
}
if (import.meta.client && !recentIds.value.length) {
loadRecentIds()
}
function recordRecent(tagIds: number[]) {
if (!tagIds.length) return
const merged = [
...tagIds,
...recentIds.value.filter(id => !tagIds.includes(id))
].slice(0, MAX_RECENT)
recentIds.value = merged
writeRecentIds(merged)
}
return {
recentIds,
loadRecentIds,
recordRecent
}
}
+122
View File
@@ -0,0 +1,122 @@
import type { ImageTag } from '~/types/image'
export interface TagListResponse {
items: ImageTag[]
}
export interface TagResponse {
tag: ImageTag
}
export function useTags() {
const items = useState<ImageTag[]>('tags-list', () => [])
const loading = ref(false)
const loaded = ref(false)
async function fetchTags(force = false) {
if (loaded.value && !force) return items.value
loading.value = true
try {
const data = await $fetch<TagListResponse>('/api/tags', {
credentials: 'include'
})
items.value = data.items
loaded.value = true
return data.items
} finally {
loading.value = false
}
}
async function createTag(name: string, color?: string | null) {
const data = await $fetch<TagResponse>('/api/tags', {
method: 'POST',
credentials: 'include',
body: { name, color }
})
await fetchTags(true)
return data.tag
}
async function updateTag(id: number, patch: { name?: string, color?: string | null }) {
const data = await $fetch<TagResponse>(`/api/tags/${id}`, {
method: 'PATCH',
credentials: 'include',
body: patch
})
await fetchTags(true)
return data.tag
}
async function deleteTag(id: number) {
await $fetch(`/api/tags/${id}`, {
method: 'DELETE',
credentials: 'include'
})
await fetchTags(true)
}
async function mergeTags(sourceIds: number[], targetId: number) {
const data = await $fetch<TagResponse>('/api/tags/merge', {
method: 'POST',
credentials: 'include',
body: { sourceIds, targetId }
})
await fetchTags(true)
return data.tag
}
async function addTagsToImage(key: string, tagIds: number[]) {
const data = await $fetch<{ tags: ImageTag[] }>('/api/images/tags', {
method: 'POST',
credentials: 'include',
body: { key, tagIds }
})
return data.tags
}
async function setImageTags(key: string, tagIds: number[]) {
const data = await $fetch<{ tags: ImageTag[] }>('/api/images/tags', {
method: 'PATCH',
credentials: 'include',
body: { key, tagIds }
})
return data.tags
}
async function removeTagFromImage(key: string, tagId: number) {
const data = await $fetch<{ tags: ImageTag[] }>('/api/images/tags', {
method: 'DELETE',
credentials: 'include',
body: { key, tagId }
})
return data.tags
}
async function batchUpdateTags(
keys: string[],
tagIds: number[],
action: 'add' | 'remove' = 'add'
) {
return $fetch<{ success: boolean, updated: number }>('/api/images/batch-tags', {
method: 'POST',
credentials: 'include',
body: { keys, tagIds, action }
})
}
return {
items,
loading,
loaded,
fetchTags,
createTag,
updateTag,
deleteTag,
mergeTags,
addTagsToImage,
setImageTags,
removeTagFromImage,
batchUpdateTags
}
}
Loaded 100 of 421 files, more files were not shown because too many files have changed in this diff. Show more