15 Commits
Author SHA1 Message Date
O96u f8921837af release: v1.1.4 — storage path layout and bidirectional domain isolation
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
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:58:28 +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
O96u 7c45a78572 release: v1.0.4
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
Add PasswordInput with show/hide toggle, align legacy setup/register
routing, merge duplicate i18n keys, and remove unused jose dependency.
2026-08-25 16:11:50 +08:00
O96u 661146ce20 docs: add Linux.do friend link and align README tables 2026-08-24 10:41:52 +08:00
O96u 9d2ee96af0 fix: CI typecheck and native ARM Docker builds for v1.0.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
ci / ci (22, ubuntu-latest) (push) Canceled after 0s
GitHub Release / release (push) Canceled after 0s
Fix TypeScript errors blocking CI, split Docker publish into
native amd64/arm64 jobs with manifest merge, and add npm cache mounts.
2026-08-23 18:26:13 +08:00
O96u 22cb2ca8bc release: v1.0.2
Publish Docker image / build-and-push (push) Canceled after 0s
GitHub Release / release (push) Canceled after 0s
ci / ci (22, ubuntu-latest) (push) Canceled after 0s
Add i18n (zh-CN/en), mobile navigation menus, stats and upload UI
improvements, and Docker reset-password CLI for emergency recovery.
2026-08-23 17:43:42 +08:00
O96u 074e4502c2 ci: auto-create GitHub Release from CHANGELOG on version tags
Add workflow_dispatch to backfill releases for existing tags.
2026-08-22 19:14:25 +08:00
O96u 1096fa8635 docs: use docker run for quick start in README
Users can pull muxui/pichost from Docker Hub without cloning the repo.
2026-08-22 18:55:16 +08:00
O96u 80d2bca4e0 ci: build Docker image only on version tags
Stop publishing on main push and remove GHCR; images go to Docker Hub only.
2026-08-22 18:50:33 +08:00
131 changed files with 12130 additions and 1556 deletions

No files matched your search

+11 -1
View File
@@ -11,7 +11,17 @@
# 可选;不填可在设置页配置。若在此填写则覆盖后台设置 # 可选;不填可在设置页配置。若在此填写则覆盖后台设置
# ALLOWED_REFERER_HOSTS=example.com,www.example.com # 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 # IMAGE_BASE_URL=https://pic.example.com
# 图片存储目录(默认 ./data,容器内为 /data) # 图片存储目录(默认 ./data,容器内为 /data)
+60 -44
View File
@@ -2,22 +2,30 @@ name: Publish Docker image
on: on:
push: push:
branches: [main]
tags: tags:
- 'v*' - 'v*'
workflow_dispatch: workflow_dispatch:
permissions: permissions:
contents: read contents: read
packages: write
env: env:
IMAGE_NAME: pichost IMAGE_NAME: pichost
jobs: jobs:
build-and-push: build:
if: ${{ !contains(github.event.head_commit.message, '[skip docker]') }} if: ${{ !contains(github.event.head_commit.message, '[skip docker]') }}
runs-on: ubuntu-latest strategy:
fail-fast: false
matrix:
include:
- platform: linux/amd64
runner: ubuntu-latest
suffix: amd64
- platform: linux/arm64
runner: ubuntu-24.04-arm
suffix: arm64
runs-on: ${{ matrix.runner }}
env: env:
DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }} DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }} DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }}
@@ -25,59 +33,67 @@ jobs:
- name: Checkout - name: Checkout
uses: actions/checkout@v7 uses: actions/checkout@v7
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3 uses: docker/setup-buildx-action@v3
- name: Log in to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Log in to Docker Hub - name: Log in to Docker Hub
if: env.DOCKERHUB_USERNAME != '' && env.DOCKERHUB_TOKEN != ''
uses: docker/login-action@v3 uses: docker/login-action@v3
with: with:
username: ${{ env.DOCKERHUB_USERNAME }} username: ${{ env.DOCKERHUB_USERNAME }}
password: ${{ env.DOCKERHUB_TOKEN }} password: ${{ env.DOCKERHUB_TOKEN }}
- name: Resolve image names - name: Resolve version
id: images id: version
run: | run: |
names="ghcr.io/${OWNER}/${IMAGE_NAME}" if [ "${{ github.ref_type }}" = "tag" ]; then
if [ -n "${DOCKERHUB_USERNAME}" ]; then echo "value=${GITHUB_REF#refs/tags/v}" >> "$GITHUB_OUTPUT"
names="${names} else
${DOCKERHUB_USERNAME}/${IMAGE_NAME}" echo "value=dev" >> "$GITHUB_OUTPUT"
fi fi
{
echo "list<<EOF"
echo "${names}"
echo "EOF"
} >> "$GITHUB_OUTPUT"
env:
OWNER: ${{ github.repository_owner }}
- name: Extract metadata - name: Build and push platform image
id: meta
uses: docker/metadata-action@v5
with:
# Same tags for ghcr.io and Docker Hub: latest + 1.0.0 (no v prefix)
images: ${{ steps.images.outputs.list }}
tags: |
type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v') }}
type=semver,pattern={{version}}
- name: Build and push
uses: docker/build-push-action@v6 uses: docker/build-push-action@v6
with: with:
context: . context: .
push: true push: true
platforms: linux/amd64,linux/arm64 platforms: ${{ matrix.platform }}
tags: ${{ steps.meta.outputs.tags }} tags: |
labels: ${{ steps.meta.outputs.labels }} ${{ env.DOCKERHUB_USERNAME }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.value }}-${{ matrix.suffix }}
cache-from: type=gha cache-from: type=gha,scope=${{ matrix.suffix }}
cache-to: type=gha,mode=max cache-to: type=gha,mode=max,scope=${{ matrix.suffix }}
merge:
if: ${{ !contains(github.event.head_commit.message, '[skip docker]') }}
needs: build
runs-on: ubuntu-latest
env:
DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }}
steps:
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ env.DOCKERHUB_USERNAME }}
password: ${{ env.DOCKERHUB_TOKEN }}
- name: Resolve version
id: version
run: |
if [ "${{ github.ref_type }}" = "tag" ]; then
echo "value=${GITHUB_REF#refs/tags/v}" >> "$GITHUB_OUTPUT"
else
echo "value=dev" >> "$GITHUB_OUTPUT"
fi
- name: Create multi-arch manifest
run: |
IMAGE="${DOCKERHUB_USERNAME}/${IMAGE_NAME}"
VERSION="${{ steps.version.outputs.value }}"
docker buildx imagetools create \
-t "${IMAGE}:${VERSION}" \
-t "${IMAGE}:latest" \
"${IMAGE}:${VERSION}-amd64" \
"${IMAGE}:${VERSION}-arm64"
+71
View File
@@ -0,0 +1,71 @@
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
+4
View File
@@ -36,3 +36,7 @@ data
# 本地 Git hooks 模板(仅安装在 .git/hooks/,不进仓库) # 本地 Git hooks 模板(仅安装在 .git/hooks/,不进仓库)
git-hooks/ git-hooks/
# 本地维护脚本(assign-image-owner、fpk 打包等,不进仓库)
scripts/
dist-fpk/
+123 -1
View File
@@ -2,6 +2,122 @@
本仓库自 **v1.0.0** 起作为正式版维护;此前历史提交已归档重置。 本仓库自 **v1.0.0** 起作为正式版维护;此前历史提交已归档重置。
## [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 测试):`docs/domain-separation.md`
### 改进
- 双域名下防盗链自动放行网站域与图片域主机名
- 开发服务器默认绑定 `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
### 新增
- 密码输入框支持显示/隐藏切换(`PasswordInput` 组件,含无障碍文案)
### 改进
- 遗留 `ADMIN_SECRET` 部署:`/setup`、`/register` 路由与 `needsMigration` 检测对齐
- 合并重复的密码不一致 i18n 文案;移除未使用的 `jose` 依赖
### 修复
- 修复 `PasswordInput` 导致 CI typecheck 失败
## [1.0.3] - 2026-08-23
### 修复
- 修复 CI typecheck 失败(移动端菜单、i18n、统计页图库刷新)
- Docker 构建:arm64 改用原生 ARM Runner,避免 QEMU 下 `npm ci` 极慢
- Docker 构建:启用 npm 缓存挂载,加快依赖安装
## [1.0.2] - 2026-08-23
### 新增
- 中英文界面(`@nuxtjs/i18n`),语言切换与主题切换独立菜单
- 移动端导航:汉堡菜单 + 分组用户菜单(语言 / 外观 / 账户)
- Docker 忘记密码:`docker exec pichost reset-password`(随机密码,见 `server/cli/`)
### 改进
- 统计页工具栏与图库布局(桌面单行、移动端适配)
- 上传卡片视觉优化(背景图、插图、悬停边框动效)
- Logo 与上传区图片压缩,减小体积
## [1.0.1] - 2026-08-22 ## [1.0.1] - 2026-08-22
### 修复 ### 修复
@@ -31,7 +147,13 @@
### 路线图 ### 路线图
- **v1.1.0**(计划中):S3、Cloudflare R2 及更多对象存储后端 - **v1.1.2**(当前 `main`):操作日志、图库存储筛选、上传限流
[1.1.2]: https://github.com/O96u/PicHost/releases/tag/v1.1.2
[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 [1.0.1]: https://github.com/O96u/PicHost/releases/tag/v1.0.1
[1.0.0]: https://github.com/O96u/PicHost/releases/tag/v1.0.0 [1.0.0]: https://github.com/O96u/PicHost/releases/tag/v1.0.0
+8 -2
View File
@@ -1,3 +1,5 @@
# syntax=docker/dockerfile:1
FROM node:22-alpine AS build FROM node:22-alpine AS build
WORKDIR /app WORKDIR /app
@@ -6,7 +8,8 @@ WORKDIR /app
RUN npm install -g npm@11.12.1 RUN npm install -g npm@11.12.1
COPY package.json package-lock.json ./ COPY package.json package-lock.json ./
RUN npm ci RUN --mount=type=cache,target=/root/.npm \
npm ci
COPY . . COPY . .
RUN npm run build RUN npm run build
@@ -20,8 +23,11 @@ ENV NODE_ENV=production \
DATA_DIR=/data DATA_DIR=/data
COPY --from=build /app/.output ./.output COPY --from=build /app/.output ./.output
COPY server/cli/reset-password.mjs ./server/cli/reset-password.mjs
COPY docker-entrypoint.sh /docker-entrypoint.sh
RUN chmod +x /docker-entrypoint.sh
EXPOSE 6892 EXPOSE 6892
VOLUME /data VOLUME /data
CMD ["node", ".output/server/index.mjs"] ENTRYPOINT ["/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 Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
of this software and associated documentation files (the "Software"), to deal Everyone is permitted to copy and distribute verbatim copies
in the Software without restriction, including without limitation the rights of this license document, but changing it is not allowed.
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:
The above copyright notice and this permission notice shall be included in all Preamble
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR The GNU General Public License is a free, copyleft license for
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, software and other kinds of works.
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER The licenses for most software and other practical works are designed
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, to take away your freedom to share and change the works. By contrast,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE the GNU General Public License is intended to guarantee your freedom to
SOFTWARE. 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>.
+108 -49
View File
@@ -6,17 +6,18 @@
<p align="center"><strong>Lightweight personal image hosting</strong> · Simple uploads, clean management</p> <p align="center"><strong>Lightweight personal image hosting</strong> · Simple uploads, clean management</p>
<p align="center">Self-hosted / Multi-user / Docker / API / Twikoo · Zero-config setup</p> <p align="center">Self-hosted / Multi-user / Docker / API / Twikoo · Local disk or object storage</p>
<p align="center"> <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/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/O96u/PicHost/actions/workflows/ci.yml"><img src="https://github.com/O96u/PicHost/actions/workflows/ci.yml/badge.svg" alt="CI" /></a>
<img src="https://img.shields.io/badge/Nuxt-4-00DC82?style=flat-square&logo=nuxt.js&logoColor=white" alt="Nuxt 4" /> <img src="https://img.shields.io/badge/Nuxt-4-00DC82?style=flat-square&logo=nuxt.js&logoColor=white" alt="Nuxt 4" />
<a href="https://hub.docker.com/r/muxui/pichost"><img src="https://img.shields.io/badge/Docker-muxui%2Fpichost-2496ED?style=flat-square&logo=docker&logoColor=white" alt="Docker" /></a> <a href="https://hub.docker.com/r/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" /> <img src="https://img.shields.io/badge/license-GPL--3.0-blue?style=flat-square" alt="GPL-3.0" />
</p> </p>
<p align="center"> <p align="center">
<a href="#branches">Branches</a> ·
<a href="#quick-start">Quick Start</a> · <a href="#quick-start">Quick Start</a> ·
<a href="#screenshots">Screenshots</a> · <a href="#screenshots">Screenshots</a> ·
<a href="#features">Features</a> · <a href="#features">Features</a> ·
@@ -27,26 +28,43 @@
--- ---
## Branches
| Branch | Notes |
| ------ | ----- |
| [**main**](https://github.com/O96u/PicHost/tree/main) (default) | **v1.1.4** — local disk + multi-backend storage, bidirectional dual-domain isolation, [`/storage`](docs/README.md) UI, hybrid URLs |
| [**cloudflare**](https://github.com/O96u/PicHost/tree/cloudflare) | **Cloudflare R2–only line** — streamlined for deployments that use R2 exclusively |
Use **main** for the full product and multiple cloud backends.
If you **only use Cloudflare R2**, check out the dedicated branch:
```bash
git clone https://github.com/O96u/PicHost.git
cd PicHost
git checkout cloudflare
```
---
## Screenshots ## Screenshots
| Login | Upload | | Upload | Storage |
|:---:|:---:| | :------------------------------------: | :--------------------------------: |
| ![Login](docs/screenshots/login.png) | ![Upload](docs/screenshots/upload.png) | | ![Upload](docs/screenshots/upload.png) | ![Storage](docs/screenshots/storage.png) |
| Upload preferences | Stats & gallery | | Stats & gallery | Settings |
|:---:|:---:| | :----------------------------------: | :--------------------------------: |
| ![Preferences](docs/screenshots/preferences.png) | ![Stats](docs/screenshots/stats.png) | | ![Stats](docs/screenshots/stats.png) | ![Settings](docs/screenshots/settings.png) |
| Settings | |
|:---:|:---:|
| ![Settings](docs/screenshots/settings.png) | |
## Features ## Features
- **Drag, click, or Ctrl+V paste** to upload; admins can use custom `folder` paths - **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 - **Server-side WebP** (sharp), Referer hotlink protection, configurable public base URL
- **Multi-user** — username/password login; optional registration; users see only their images - **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 - **Upload preferences** (home card flip): client-side compression, auto-copy links, per-user auto-delete
- **Multi-backend storage**: local disk + S3-compatible (Cloudflare R2 / Tencent COS / Alibaba OSS / AWS S3); admins manage backends at **Storage** (`/storage`)
- **Hybrid URLs**: `proxy` (same-origin via PicHost) or `public` (302 to CDN / bucket URL)
- **Gallery** — browse, search, batch delete; admin sees uploader badges - **Gallery** — browse, search, batch delete; admin sees uploader badges
- **Stats** — upload/delete metrics, folder breakdown; admin sees registered user count - **Stats** — upload/delete metrics, folder breakdown; admin sees registered user count
- **API** — global token (admin) + per-user tokens; in-app cURL snippets - **API** — global token (admin) + per-user tokens; in-app cURL snippets
@@ -58,12 +76,26 @@
### Docker (recommended) ### Docker (recommended)
```bash ```bash
git clone https://github.com/O96u/PicHost.git docker run -d \
cd PicHost --name pichost \
docker compose up -d -p 6892:6892 \
-v ./data:/data \
--restart unless-stopped \
muxui/pichost:latest
``` ```
Default port: **6892**. Complete the setup wizard in the browser. Image: `muxui/pichost:latest` (fallback: `ghcr.io/o96u/pichost:latest`). Default port: **6892**. Open `http://<host>:6892` in your browser and complete the setup wizard.
**Forgot password** (requires `docker exec` / server access):
```bash
docker exec pichost reset-password # admin only, when there is exactly one
docker exec pichost reset-password <username> # admin or regular user
```
A random password is printed. 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 ### Local development
@@ -75,41 +107,22 @@ npm run dev
Open `http://localhost:3000/setup` to create the admin account. Data defaults to `./data`. Open `http://localhost:3000/setup` to create the admin account. Data defaults to `./data`.
### Legacy migration
If you only had `ADMIN_SECRET` and no user table, log in with the secret once and follow the migration wizard.
## Tech Stack ## Tech Stack
| Layer | Technology | | Layer | Technology |
|-------|------------| | -------- | --------------------------------------------------- |
| Frontend | Nuxt 4, Vue 3, Nuxt UI 4, Tailwind CSS 4 | | Frontend | Nuxt 4, Vue 3, Nuxt UI 4, Tailwind CSS 4 |
| Backend | Nitro (Node.js), SQLite | | Backend | Nitro (Node.js), SQLite |
| Images | sharp (WebP transcoding & compression) | | Images | sharp (WebP transcoding & compression) |
| Auth | Session cookie, scrypt password hashing, API tokens | | Auth | Session cookie, scrypt password hashing, API tokens |
| Storage | Local disk (`DATA_DIR`) | | Storage | Local disk + S3-compatible backends (R2 / COS / OSS / AWS); `images` index in SQLite |
| Deploy | Docker (amd64 / arm64), docker compose | | Deploy | Docker (amd64 / arm64), docker compose |
| Tests | Vitest | | 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 ## Users & permissions
| Scenario | Auth | Ownership / visibility | | Scenario | Auth | Ownership / visibility |
|----------|------|------------------------| | ------------------------------ | -------------------- | --------------------------- |
| Web upload | Session | Current user | | Web upload | Session | Current user |
| API upload + **user token** | `Auth-Token` | Token owner | | API upload + **user token** | `Auth-Token` | Token owner |
| API upload + **global token** | `Auth-Token` | Admin (`userId` null) | | API upload + **global token** | `Auth-Token` | Admin (`userId` null) |
@@ -123,18 +136,18 @@ Regular users are limited to the `images/` folder; custom `folder` requires admi
``` ```
/data /data
├── pichost.db # SQLite: users, sessions, settings, logs ├── pichost.db # SQLite: users, sessions, settings, storage_backends, images index
├── images/ # Default upload folder ├── images/ # Default upload folder (when using local backend)
├── blog/ # Example custom folder ├── blog/ # Example custom folder
└── twikoo/ # Twikoo comment images └── twikoo/ # Twikoo comment images
``` ```
Pattern: `folder/YYYY/MM/randomId.webp` + `.meta.json`. Back up the entire `/data` directory. Pattern: `folder/YYYY/MM/randomId.webp` + `.meta.json`. With cloud backends, blobs live in the bucket; SQLite `images` tracks `backend_id` and `key`. Back up all of `/data` plus bucket data.
## API overview ## API overview
| Method | Path | Description | | Method | Path | Description |
|--------|------|-------------| | ------ | -------------------- | ---------------------- |
| POST | `/api/images/upload` | Upload image | | POST | `/api/images/upload` | Upload image |
| GET | `/api/images` | Paginated list | | GET | `/api/images` | Paginated list |
| DELETE | `/api/images` | Delete one (`key`) | | DELETE | `/api/images` | Delete one (`key`) |
@@ -154,7 +167,7 @@ Full docs and copyable cURL snippets are on the **API** page after deployment.
## Twikoo ## Twikoo
| Setting | Value | | Setting | Value |
|---------|-------| | ----------------- | ----------------------------------- |
| `IMAGE_CDN` | `easyimage` | | `IMAGE_CDN` | `easyimage` |
| `IMAGE_CDN_URL` | `https://your-domain/api/index.php` | | `IMAGE_CDN_URL` | `https://your-domain/api/index.php` |
| `IMAGE_CDN_TOKEN` | Same as `API_UPLOAD_TOKEN` | | `IMAGE_CDN_TOKEN` | Same as `API_UPLOAD_TOKEN` |
@@ -163,7 +176,42 @@ Full docs and copyable cURL snippets are on the **API** page after deployment.
- Proxy to port **6892**; HTTPS recommended - Proxy to port **6892**; HTTPS recommended
- Nginx: `client_max_body_size` ≥ 12m - Nginx: `client_max_body_size` ≥ 12m
- Set `IMAGE_BASE_URL` for public access - Set `IMAGE_BASE_URL` (single domain) or `SITE_BASE_URL` + `IMAGE_BASE_URL` (dual-domain separation)
### Recommended architecture (dual domains)
**One Docker instance + two domains**, both fully proxied to `6892` (isolation via PicHost middleware):
```nginx
# admin.example.com — admin + API + images
server {
server_name admin.example.com;
location / {
proxy_pass http://127.0.0.1:6892;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
client_max_body_size 12m;
}
}
# pic.example.com — same full proxy; PicHost middleware blocks non-image paths
server {
server_name pic.example.com;
location / {
proxy_pass http://127.0.0.1:6892;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
```
FN NAS [Lucky](https://github.com/gdy666/lucky) reverse proxy (public hostname → backend `http://<LAN-IP>:6892`):
| Site domain (admin) | Image domain |
| :-----------------: | :----------: |
| ![Lucky site](docs/screenshots/lucky-site.png) | ![Lucky image](docs/screenshots/lucky-image.png) |
See **[docs/domain-separation.en.md](docs/domain-separation.en.md)** for Caddy/NPM notes, env vars, and setup fields.
## Development ## Development
@@ -179,11 +227,22 @@ npm test # Unit tests
## Roadmap ## Roadmap
| Version | Plan | | Version | Plan |
|---------|------| | ---------- | -------------------------------------------------------------- |
| **v1.0.1** | Settings version display, mobile upload preferences layout fix (current) | | **v1.1.4** | Flat vs date storage paths, basename public URLs, bidirectional domain isolation (current `main`) |
| **v1.1.3** | Dual-domain separation, optional `images/` URL prefix, update check |
| **v1.1.2** | Activity logs, gallery storage filter, upload rate limits |
| **v1.1.1** | Session check fixes, storage placeholder cleanup, Docker logging |
| **v1.1.0** | Multi-backend object storage, hybrid URLs, storage UI |
| **v1.0.4** | Password visibility toggle, legacy ADMIN_SECRET migration |
| **v1.0.3** | CI and Docker multi-arch build fixes |
| **v1.0.2** | i18n, mobile menus, stats/upload UI, Docker password reset |
| **v1.0.1** | Settings version display, mobile upload preferences layout fix |
| **v1.0.0** | Local storage, multi-user, API, Twikoo | | **v1.0.0** | Local storage, multi-user, API, Twikoo |
| **v1.1.0** | S3, Cloudflare R2, and more object storage backends |
## License ## License
[MIT](LICENSE) [GPL-3.0](LICENSE)
## Friend links
[LINUX DO](https://linux.do/)
+114 -50
View File
@@ -6,17 +6,18 @@
<p align="center"><strong>个人轻量图床</strong> · 图片放好,管理也要顺手</p> <p align="center"><strong>个人轻量图床</strong> · 图片放好,管理也要顺手</p>
<p align="center">自托管图床 / 多用户 / Docker / API / Twikoo · 零配置引导</p> <p align="center">自托管图床 / 多用户 / Docker / API / Twikoo · 本地磁盘或对象存储</p>
<p align="center"> <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/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/O96u/PicHost/actions/workflows/ci.yml"><img src="https://github.com/O96u/PicHost/actions/workflows/ci.yml/badge.svg" alt="CI" /></a>
<img src="https://img.shields.io/badge/Nuxt-4-00DC82?style=flat-square&logo=nuxt.js&logoColor=white" alt="Nuxt 4" /> <img src="https://img.shields.io/badge/Nuxt-4-00DC82?style=flat-square&logo=nuxt.js&logoColor=white" alt="Nuxt 4" />
<a href="https://hub.docker.com/r/muxui/pichost"><img src="https://img.shields.io/badge/Docker-muxui%2Fpichost-2496ED?style=flat-square&logo=docker&logoColor=white" alt="Docker" /></a> <a href="https://hub.docker.com/r/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" /> <img src="https://img.shields.io/badge/license-GPL--3.0-blue?style=flat-square" alt="GPL-3.0" />
</p> </p>
<p align="center"> <p align="center">
<a href="#分支">分支</a> ·
<a href="#快速开始">快速开始</a> · <a href="#快速开始">快速开始</a> ·
<a href="#界面预览">预览</a> · <a href="#界面预览">预览</a> ·
<a href="#特性">特性</a> · <a href="#特性">特性</a> ·
@@ -27,26 +28,43 @@
--- ---
## 分支
| 分支 | 说明 |
| ---- | ---- |
| [**main**](https://github.com/O96u/PicHost/tree/main)(默认) | **v1.1.4** 主线:本地磁盘 + 多对象存储、双域名双向隔离、[`/storage`](docs/README.md) 存储管理、混合直链 |
| [**cloudflare**](https://github.com/O96u/PicHost/tree/cloudflare) | **Cloudflare R2 专用线**:面向「只用 R2」的部署,预设与配置更聚焦 R2 |
日常使用、需要管理多种存储后端,请直接用 **main**。
若图床**只接 Cloudflare R2**、希望更简化的 R2 专用版本,可切换分支:
```bash
git clone https://github.com/O96u/PicHost.git
cd PicHost
git checkout cloudflare
```
---
## 界面预览 ## 界面预览
| 登录 | 上传 | | 上传 | 存储管理 |
|:---:|:---:| | :-----------------------------------: | :-------------------------------------: |
| ![登录](docs/screenshots/login.png) | ![上传](docs/screenshots/upload.png) | | ![上传](docs/screenshots/upload.png) | ![存储](docs/screenshots/storage.png) |
| 上传偏好 | 统计与图库 | | 统计与图库 | 系统设置 |
|:---:|:---:| | :---------------------------------: | :-------------------------------------: |
| ![上传偏好](docs/screenshots/preferences.png) | ![统计](docs/screenshots/stats.png) | | ![统计](docs/screenshots/stats.png) | ![设置](docs/screenshots/settings.png) |
| 系统设置 | |
|:---:|:---:|
| ![设置](docs/screenshots/settings.png) | |
## 特性 ## 特性
- **拖拽 / 点击 / Ctrl+V 粘贴**上传;管理员可自定义 `folder` 目录 - **拖拽 / 点击 / Ctrl+V 粘贴**上传;管理员可自定义 `folder` 目录
- **服务端 WebP 压缩**(sharp)、Referer 防盗链、可配置访问域名 - **服务端 WebP 压缩**(sharp)、Referer 防盗链、可配置访问域名
- **多用户**:账号密码登录;管理员可开放注册;普通用户仅见自己的图片 - **多用户**:账号密码登录;管理员可开放注册;普通用户仅见自己的图片
- **上传偏好**(卡片翻转设置):客户端预压缩、自动复制链接、按用户自动删除 - **上传偏好**(首页卡片翻转):客户端预压缩、自动复制链接、按用户自动删除
- **多后端存储**:本地磁盘 + S3 兼容(Cloudflare R2 / 腾讯云 COS / 阿里云 OSS / AWS S3);管理员在 **存储**(`/storage`)页管理,新上传写入默认后端
- **混合直链**:`proxy`(PicHost 同源代理)或 `public`(302 到 CDN / 桶公网地址)
- **图库**:浏览、搜索、批量删除;管理员可查看上传者标签 - **图库**:浏览、搜索、批量删除;管理员可查看上传者标签
- **统计**:上传/删除趋势、目录分布;管理员额外显示注册用户数量 - **统计**:上传/删除趋势、目录分布;管理员额外显示注册用户数量
- **API**:全局 Token(管理员)+ 每用户个人 Token;后台可复制 cURL - **API**:全局 Token(管理员)+ 每用户个人 Token;后台可复制 cURL
@@ -58,12 +76,31 @@
### Docker(推荐) ### Docker(推荐)
```bash ```bash
git clone https://github.com/O96u/PicHost.git docker run -d \
cd PicHost --name pichost \
docker compose up -d -p 6892:6892 \
-v ./data:/data \
--restart unless-stopped \
muxui/pichost:latest
``` ```
默认端口 **6892**。浏览器打开后按引导创建管理员即可。镜像:`muxui/pichost:latest`(备用 `ghcr.io/o96u/pichost:latest`)。 默认端口 **6892**。浏览器打开 `http://<主机IP>:6892`,按引导创建管理员即可。
**忘记密码**(需能执行 `docker exec`,相当于服务器权限):
```bash
# 无参数:重置管理员(仅当系统中只有一个管理员时)
docker exec pichost reset-password
# 指定用户名:可重置管理员或普通用户
docker exec pichost reset-password 用户名
```
终端会打印随机新密码;用户不存在时会报错。登录后请到「修改密码」更换。
本地:`npm run reset-password` 或 `npm run reset-password -- 用户名`
已克隆仓库时也可用 `docker compose up -d`(见仓库内 `docker-compose.yml`)。
### 本地开发 ### 本地开发
@@ -75,41 +112,22 @@ npm run dev
访问 `http://localhost:3000/setup` 创建管理员;数据目录默认 `./data`。 访问 `http://localhost:3000/setup` 创建管理员;数据目录默认 `./data`。
### 遗留部署迁移
若旧版仅配置了 `ADMIN_SECRET`、尚无用户表,可用密钥登录一次,Web 引导迁移为账号密码。
## 技术栈 ## 技术栈
| 层级 | 技术 | | 层级 | 技术 |
|------|------| | -------- | ------------------------------------------ |
| 前端 | Nuxt 4、Vue 3、Nuxt UI 4、Tailwind CSS 4 | | 前端 | Nuxt 4、Vue 3、Nuxt UI 4、Tailwind CSS 4 |
| 后端 | Nitro(Node.js)、SQLite | | 后端 | Nitro(Node.js)、SQLite |
| 图片处理 | sharp(WebP 转码与压缩) | | 图片处理 | sharp(WebP 转码与压缩) |
| 鉴权 | Session Cookie、scrypt 密码哈希、API Token | | 鉴权 | Session Cookie、scrypt 密码哈希、API Token |
| 存储 | 本地磁盘(`DATA_DIR`) | | 存储 | 本地磁盘 + S3 兼容后端(R2 / COS / OSS / AWS);`images` 索引在 SQLite |
| 部署 | Docker(amd64 / arm64)、docker compose | | 部署 | Docker(amd64 / arm64)、docker compose |
| 测试 | Vitest | | 测试 | 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 | 当前登录用户 | | 网页上传 | Session | 当前登录用户 |
| API 上传 + **个人 Token** | `Auth-Token` | 该 Token 所属用户 | | API 上传 + **个人 Token** | `Auth-Token` | 该 Token 所属用户 |
| API 上传 + **全局 Token** | `Auth-Token` | 管理员(`userId` 为空) | | API 上传 + **全局 Token** | `Auth-Token` | 管理员(`userId` 为空) |
@@ -123,18 +141,18 @@ npm run dev
``` ```
/data /data
├── pichost.db # SQLite:用户、会话、设置、操作日志 ├── pichost.db # SQLite:用户、会话、设置、storage_backends、images 索引
├── images/ # 默认上传目录 ├── images/ # 默认上传目录(本地后端时)
├── blog/ # 管理员自定义 folder 示例 ├── blog/ # 管理员自定义 folder 示例
└── twikoo/ # Twikoo 评论图片 └── twikoo/ # Twikoo 评论图片
``` ```
路径规则:`目录/年/月/随机ID.webp` + 同名 `.meta.json`。备份整个 `/data` 即可。 路径规则:`目录/年/月/随机ID.webp` + 同名 `.meta.json`。云存储时文件在对应桶内,SQLite `images` 表记录 `backend_id` 与 `key`。备份请包含整个 `/data` 与云桶数据。
## API 概览 ## API 概览
| 方法 | 路径 | 说明 | | 方法 | 路径 | 说明 |
|------|------|------| | ------ | -------------------- | ---------------------- |
| POST | `/api/images/upload` | 上传图片 | | POST | `/api/images/upload` | 上传图片 |
| GET | `/api/images` | 分页列表 | | GET | `/api/images` | 分页列表 |
| DELETE | `/api/images` | 删除单张(`key`) | | DELETE | `/api/images` | 删除单张(`key`) |
@@ -154,7 +172,7 @@ curl -X POST "https://pic.example.com/api/images/upload" \
## Twikoo ## Twikoo
| 配置项 | 值 | | 配置项 | 值 |
|--------|-----| | ----------------- | -------------------------------- |
| `IMAGE_CDN` | `easyimage` | | `IMAGE_CDN` | `easyimage` |
| `IMAGE_CDN_URL` | `https://你的域名/api/index.php` | | `IMAGE_CDN_URL` | `https://你的域名/api/index.php` |
| `IMAGE_CDN_TOKEN` | 与 `API_UPLOAD_TOKEN` 相同 | | `IMAGE_CDN_TOKEN` | 与 `API_UPLOAD_TOKEN` 相同 |
@@ -163,7 +181,42 @@ curl -X POST "https://pic.example.com/api/images/upload" \
- 反代到容器 **6892** 端口,建议 HTTPS - 反代到容器 **6892** 端口,建议 HTTPS
- Nginx:`client_max_body_size` ≥ 12m - Nginx:`client_max_body_size` ≥ 12m
- 公网访问请配置 `IMAGE_BASE_URL` - 公网访问请配置 `IMAGE_BASE_URL`(单域名)或 `SITE_BASE_URL` + `IMAGE_BASE_URL`(双域名分离)
### 推荐部署架构(双域名分离)
**单 Docker 实例 + 两个域名** 均可全量反代到 `6892`(隔离由 PicHost 中间件负责):
```nginx
# admin.example.com — 后台 + API + 出图
server {
server_name admin.example.com;
location / {
proxy_pass http://127.0.0.1:6892;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
client_max_body_size 12m;
}
}
# pic.example.com — 同样全量反代;PicHost 中间件会拦截非图片路径
server {
server_name pic.example.com;
location / {
proxy_pass http://127.0.0.1:6892;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
```
飞牛 NAS [Lucky](https://github.com/gdy666/lucky) 反向代理示例(前端域名 → 后端 `http://<内网IP>:6892`):
| 网站域名(管理后台) | 图片域名 |
| :------------------: | :------: |
| ![Lucky 网站域名](docs/screenshots/lucky-site.png) | ![Lucky 图片域名](docs/screenshots/lucky-image.png) |
完整说明(Caddy / NPM、环境变量、初始化填写项)见 **[docs/domain-separation.md](docs/domain-separation.md)**。
## 开发 ## 开发
@@ -178,12 +231,23 @@ npm test # 单元测试
## 路线图 ## 路线图
| 版本 | 计划 | | 版本 | 说明 |
|------|------| | ---------- | ------------------------------------------------- |
| **v1.0.1** | 设置页版本展示、移动端上传偏好布局修复(当前) | | **v1.1.4** | 存储路径扁平/分组、basename 外链、双域名双向隔离(当前 main) |
| **v1.1.3** | 双域名分离、隐藏 `images/` 前缀、版本更新提示 |
| **v1.1.2** | 操作日志、图库存储筛选、上传限流 |
| **v1.1.1** | 会话校验与存储占位修复、Docker 日志增强 |
| **v1.1.0** | S3 / R2 多后端对象存储、混合直链、存储管理页 |
| **v1.0.4** | 密码显示切换、遗留 ADMIN_SECRET 迁移对齐 |
| **v1.0.3** | CI 与 Docker 多架构构建修复 |
| **v1.0.2** | 中英文、移动端菜单、统计/上传 UI、Docker 重置密码 |
| **v1.0.1** | 设置页版本展示、移动端上传偏好布局修复 |
| **v1.0.0** | 本地存储、多用户、API、Twikoo | | **v1.0.0** | 本地存储、多用户、API、Twikoo |
| **v1.1.0** | S3、Cloudflare R2 等对象存储后端 |
## License ## License
[MIT](LICENSE) [GPL-3.0](LICENSE)
## 友链
[LINUX DO](https://linux.do/)
+4 -2
View File
@@ -1,8 +1,10 @@
<script setup lang="ts"> <script setup lang="ts">
const { t } = useI18n()
useHead({ useHead({
title: 'PicHost - 个人图床', title: () => t('app.title'),
meta: [ meta: [
{ name: 'description', content: '个人自用的轻量图床系统' } { name: 'description', content: () => t('app.description') }
] ]
}) })
</script> </script>
+19 -2
View File
@@ -59,14 +59,31 @@
border-radius: 1rem; border-radius: 1rem;
border-width: 2px; border-width: 2px;
border-style: dashed; border-style: dashed;
border-color: var(--ui-border); background-color: color-mix(in oklab, var(--ui-bg-elevated) 72%, transparent);
background-color: color-mix(in oklab, var(--ui-bg-elevated) 50%, transparent);
padding: 2.5rem; padding: 2.5rem;
transition:
border-color 0.2s cubic-bezier(0.4, 0, 0.2, 1),
background-color 0.2s cubic-bezier(0.4, 0, 0.2, 1),
box-shadow 0.2s cubic-bezier(0.4, 0, 0.2, 1);
}
.upload-card-surface:not(.upload-card-surface--panel) {
border-color: var(--ui-border);
}
.upload-card-surface:not(.upload-card-surface--panel):hover,
.upload-card-surface:not(.upload-card-surface--panel).upload-card-surface--active {
border-color: var(--ui-primary);
background-color: color-mix(in oklab, var(--ui-primary) 10%, var(--ui-bg-elevated) 72%);
box-shadow: 0 0 0 1px color-mix(in oklab, var(--ui-primary) 25%, transparent),
0 8px 24px color-mix(in oklab, var(--ui-primary) 18%, transparent);
} }
.upload-card-surface--panel { .upload-card-surface--panel {
border-style: solid; border-style: solid;
border-color: var(--ui-border);
background-color: var(--ui-bg-elevated); background-color: var(--ui-bg-elevated);
background-image: none;
box-shadow: var(--ui-shadow-sm); box-shadow: var(--ui-shadow-sm);
padding: 0; padding: 0;
} }
Binary file not shown.

Before

Width:  |  Height:  |  Size: 506 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 546 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

+21 -30
View File
@@ -2,13 +2,10 @@
import logoLight from '~/assets/image/logo-light.png' import logoLight from '~/assets/image/logo-light.png'
import logoDark from '~/assets/image/logo-dark.png' import logoDark from '~/assets/image/logo-dark.png'
const emit = defineEmits<{
success: []
}>()
const router = useRouter() const router = useRouter()
const { login, fetchStatus, authStatus } = useAuth() const { login, fetchStatus, authStatus } = useAuth()
const toast = useToast() const toast = useToast()
const { t } = useI18n()
const username = ref('') const username = ref('')
const password = ref('') const password = ref('')
@@ -46,11 +43,10 @@ async function submit() {
username.value = '' username.value = ''
password.value = '' password.value = ''
secret.value = '' secret.value = ''
emit('success') toast.add({ title: t('auth.loginSuccess'), color: 'success' })
toast.add({ title: '登录成功', color: 'success' })
} else { } else {
toast.add({ toast.add({
title: result.error ?? (legacyMode.value ? '登录失败,请检查密钥' : '用户名或密码错误'), title: result.error ?? (legacyMode.value ? t('auth.loginFailedSecret') : t('auth.loginFailedCredentials')),
color: 'error' color: 'error'
}) })
} }
@@ -61,7 +57,10 @@ async function submit() {
</script> </script>
<template> <template>
<div class="flex min-h-screen items-center justify-center bg-muted/30 p-4 sm:p-6"> <div class="relative flex min-h-screen items-center justify-center bg-muted/30 p-4 sm:p-6">
<div class="absolute top-4 right-4">
<AuthPagePreferences />
</div>
<div class="w-full max-w-sm rounded-2xl border border-default bg-elevated p-6 shadow-lg sm:p-8"> <div class="w-full max-w-sm rounded-2xl border border-default bg-elevated p-6 shadow-lg sm:p-8">
<div class="mb-8 space-y-3 text-center"> <div class="mb-8 space-y-3 text-center">
<div class="mx-auto inline-flex items-center justify-center"> <div class="mx-auto inline-flex items-center justify-center">
@@ -87,7 +86,7 @@ async function submit() {
<span class="text-highlighted">Pic</span><span class="text-primary">Host</span> <span class="text-highlighted">Pic</span><span class="text-primary">Host</span>
</h1> </h1>
<p class="text-sm text-muted"> <p class="text-sm text-muted">
个人轻量图床 {{ t('app.tagline') }}
</p> </p>
</div> </div>
</div> </div>
@@ -102,21 +101,17 @@ async function submit() {
for="admin-secret" for="admin-secret"
class="block text-sm font-medium text-default" class="block text-sm font-medium text-default"
> >
管理密钥 {{ t('auth.adminSecret') }}
</label> </label>
<UInput <PasswordInput
id="admin-secret" id="admin-secret"
v-model="secret" v-model="secret"
type="password" :placeholder="t('auth.adminSecretPlaceholder')"
placeholder="输入 ADMIN_SECRET"
autocomplete="current-password" autocomplete="current-password"
size="lg"
class="w-full"
:ui="{ root: 'w-full' }"
@keyup.enter="submit" @keyup.enter="submit"
/> />
<p class="text-xs leading-relaxed text-muted"> <p class="text-xs leading-relaxed text-muted">
遗留部署:使用密钥登录后将引导创建账号 {{ t('auth.legacyHint') }}
</p> </p>
</div> </div>
</template> </template>
@@ -127,12 +122,12 @@ async function submit() {
for="login-username" for="login-username"
class="block text-sm font-medium text-default" class="block text-sm font-medium text-default"
> >
用户名 {{ t('auth.username') }}
</label> </label>
<UInput <UInput
id="login-username" id="login-username"
v-model="username" v-model="username"
placeholder="用户名" :placeholder="t('auth.username')"
autocomplete="username" autocomplete="username"
size="lg" size="lg"
class="w-full" class="w-full"
@@ -145,17 +140,13 @@ async function submit() {
for="login-password" for="login-password"
class="block text-sm font-medium text-default" class="block text-sm font-medium text-default"
> >
密码 {{ t('auth.password') }}
</label> </label>
<UInput <PasswordInput
id="login-password" id="login-password"
v-model="password" v-model="password"
type="password" :placeholder="t('auth.password')"
placeholder="密码"
autocomplete="current-password" autocomplete="current-password"
size="lg"
class="w-full"
:ui="{ root: 'w-full' }"
@keyup.enter="submit" @keyup.enter="submit"
/> />
</div> </div>
@@ -163,7 +154,7 @@ async function submit() {
<UButton <UButton
type="submit" type="submit"
label="登录" :label="t('auth.login')"
icon="i-lucide-log-in" icon="i-lucide-log-in"
size="lg" size="lg"
block block
@@ -176,18 +167,18 @@ async function submit() {
v-if="allowRegistration" v-if="allowRegistration"
class="text-center text-sm text-muted" class="text-center text-sm text-muted"
> >
没有账号? {{ t('auth.noAccount') }}
<NuxtLink <NuxtLink
to="/register" to="/register"
class="text-primary hover:underline" class="text-primary hover:underline"
> >
注册 {{ t('auth.register') }}
</NuxtLink> </NuxtLink>
</p> </p>
</form> </form>
<p class="mt-6 text-center text-xs leading-relaxed text-dimmed"> <p class="mt-6 text-center text-xs leading-relaxed text-dimmed">
未登录时无法访问上传和管理功能 {{ t('auth.loginFooter') }}
</p> </p>
</div> </div>
</div> </div>
+63 -76
View File
@@ -4,87 +4,63 @@ import logoLight from '~/assets/image/logo-light.png'
import logoDark from '~/assets/image/logo-dark.png' import logoDark from '~/assets/image/logo-dark.png'
const route = useRoute() const route = useRoute()
const colorMode = useColorMode()
const { logout, user, isAdmin } = useAuth() const { logout, user, isAdmin } = useAuth()
const { t } = useI18n()
const mobileNavOpen = ref(false)
const mobileUserOpen = ref(false)
const navItems = computed(() => { const navItems = computed(() => {
const items = [ const items = [
{ label: 'API', to: '/api', icon: 'i-lucide-code-2' }, { label: t('nav.api'), to: '/api', icon: 'i-lucide-code-2' },
{ label: '统计', to: '/stats', icon: 'i-lucide-chart-column' } { label: t('nav.stats'), to: '/stats', icon: 'i-lucide-chart-column' }
] ]
if (isAdmin.value) { if (isAdmin.value) {
items.push({ label: '设置', to: '/settings', icon: 'i-lucide-settings' }) 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 return items
}) })
const themeIcon = computed(() => { const accountMenuItems = computed<DropdownMenuItem[]>(() => [
if (colorMode.preference === 'system') return 'i-lucide-monitor'
return colorMode.preference === 'dark' ? 'i-lucide-moon' : 'i-lucide-sun'
})
const themeItems = computed<DropdownMenuItem[][]>(() => [[
{ {
label: '浅色', label: t('nav.logs'),
icon: 'i-lucide-sun', icon: 'i-lucide-scroll-text',
type: 'checkbox', to: '/logs'
checked: colorMode.preference === 'light',
onUpdateChecked(checked: boolean) {
if (checked) colorMode.preference = 'light'
}
}, },
{ {
label: '深色', label: t('nav.changePassword'),
icon: 'i-lucide-moon',
type: 'checkbox',
checked: colorMode.preference === 'dark',
onUpdateChecked(checked: boolean) {
if (checked) colorMode.preference = 'dark'
}
},
{
label: '跟随系统',
icon: 'i-lucide-monitor',
type: 'checkbox',
checked: colorMode.preference === 'system',
onUpdateChecked(checked: boolean) {
if (checked) colorMode.preference = 'system'
}
}
]])
const userMenuItems = computed<DropdownMenuItem[][]>(() => {
const items: DropdownMenuItem[] = [
{
label: '修改密码',
icon: 'i-lucide-key-round', icon: 'i-lucide-key-round',
to: '/account/password' to: '/account/password'
} },
] {
label: t('nav.logout'),
if (isAdmin.value) {
items.push({
label: '系统设置',
icon: 'i-lucide-settings',
to: '/settings'
})
}
items.push({
label: '退出登录',
icon: 'i-lucide-log-out', icon: 'i-lucide-log-out',
onSelect() { onSelect() {
void handleLogout() void handleLogout()
} }
}) }
])
return [items] const desktopUserMenuItems = computed<DropdownMenuItem[][]>(() => [
}) accountMenuItems.value
])
function isNavActive(to: string) { function isNavActive(to: string) {
if (to === '/settings') {
return route.path === '/settings'
}
return route.path.startsWith(to) return route.path.startsWith(to)
} }
watch(mobileNavOpen, (open) => {
if (open) mobileUserOpen.value = false
})
watch(mobileUserOpen, (open) => {
if (open) mobileNavOpen.value = false
})
async function handleLogout() { async function handleLogout() {
await logout() await logout()
} }
@@ -93,23 +69,24 @@ async function handleLogout() {
<template> <template>
<div class="flex min-h-screen flex-col"> <div class="flex min-h-screen flex-col">
<header class="sticky top-0 z-40 border-b border-default bg-default/80 backdrop-blur"> <header class="sticky top-0 z-40 border-b border-default bg-default/80 backdrop-blur">
<div class="mx-auto flex max-w-7xl flex-col gap-3 px-4 py-2.5 sm:flex-row sm:items-center sm:justify-between sm:px-6"> <div class="mx-auto max-w-7xl px-4 py-2.5 sm:px-6">
<div class="flex items-center justify-between gap-3">
<NuxtLink <NuxtLink
to="/" to="/"
title="返回上传" :title="t('nav.backToUpload')"
class="group flex shrink-0 items-center gap-2.5 transition-opacity hover:opacity-90 sm:gap-3" class="group flex min-w-0 shrink items-center gap-2.5 transition-opacity hover:opacity-90 sm:gap-3"
> >
<img <img
:src="logoLight" :src="logoLight"
alt="PicHost" alt="PicHost"
class="h-10 w-auto shrink-0 object-contain sm:h-11 dark:hidden" class="h-9 w-auto shrink-0 object-contain sm:h-11 dark:hidden"
decoding="async" decoding="async"
draggable="false" draggable="false"
> >
<img <img
:src="logoDark" :src="logoDark"
alt="PicHost" alt="PicHost"
class="hidden h-10 w-auto shrink-0 object-contain sm:h-11 dark:block" class="hidden h-9 w-auto shrink-0 object-contain sm:h-11 dark:block"
decoding="async" decoding="async"
draggable="false" draggable="false"
> >
@@ -117,14 +94,15 @@ async function handleLogout() {
<p class="text-[15px] font-bold tracking-tight sm:text-base"> <p class="text-[15px] font-bold tracking-tight sm:text-base">
<span class="text-highlighted">Pic</span><span class="text-primary">Host</span> <span class="text-highlighted">Pic</span><span class="text-primary">Host</span>
</p> </p>
<p class="mt-0.5 text-[11px] text-muted sm:text-xs"> <p class="mt-0.5 hidden text-[11px] text-muted min-[380px]:block sm:text-xs">
个人轻量图床 {{ t('app.tagline') }}
</p> </p>
</div> </div>
</NuxtLink> </NuxtLink>
<div class="flex flex-wrap items-center justify-end gap-1 sm:gap-2"> <ClientOnly>
<nav class="flex flex-wrap items-center gap-1"> <div class="flex shrink-0 items-center gap-0.5 sm:gap-1">
<nav class="hidden items-center gap-1 sm:flex">
<UButton <UButton
v-for="item in navItems" v-for="item in navItems"
:key="item.to" :key="item.to"
@@ -139,10 +117,26 @@ async function handleLogout() {
<div class="mx-1 hidden h-5 w-px bg-muted sm:block" /> <div class="mx-1 hidden h-5 w-px bg-muted sm:block" />
<ClientOnly> <div class="hidden items-center gap-0.5 sm:flex">
<LocaleMenu />
<ThemeMenu />
</div>
<div class="flex items-center gap-0.5 sm:hidden">
<MobileUserMenu
v-if="user"
v-model:open="mobileUserOpen"
/>
<MobileNavMenu
v-model:open="mobileNavOpen"
:items="navItems"
/>
</div>
<div class="hidden sm:block">
<UDropdownMenu <UDropdownMenu
v-if="user" v-if="user"
:items="userMenuItems" :items="desktopUserMenuItems"
> >
<UButton <UButton
variant="ghost" variant="ghost"
@@ -161,15 +155,8 @@ async function handleLogout() {
/> />
</UButton> </UButton>
</UDropdownMenu> </UDropdownMenu>
<UDropdownMenu :items="themeItems"> </div>
<UButton </div>
:icon="themeIcon"
variant="ghost"
color="neutral"
size="sm"
aria-label="切换主题"
/>
</UDropdownMenu>
<template #fallback> <template #fallback>
<div class="size-8" /> <div class="size-8" />
</template> </template>
+6
View File
@@ -0,0 +1,6 @@
<template>
<div class="flex items-center gap-1">
<LocaleMenu />
<ThemeMenu />
</div>
</template>
+3 -2
View File
@@ -13,6 +13,7 @@ const emit = defineEmits<{
}>() }>()
const toast = useToast() const toast = useToast()
const { t } = useI18n()
const copying = ref(false) const copying = ref(false)
function copyWithFallback(text: string): boolean { function copyWithFallback(text: string): boolean {
@@ -40,7 +41,7 @@ function copyWithFallback(text: string): boolean {
function notifySuccess() { function notifySuccess() {
toast.add({ toast.add({
title: props.successTitle ?? (props.label ? `已复制 ${props.label}` : '已复制到剪贴板'), title: props.successTitle ?? (props.label ? t('copy.copiedLabel', { label: props.label }) : t('copy.copied')),
color: 'success' color: 'success'
}) })
emit('copied') emit('copied')
@@ -64,7 +65,7 @@ async function copy() {
if (copyWithFallback(props.value)) { if (copyWithFallback(props.value)) {
notifySuccess() notifySuccess()
} else { } else {
toast.add({ title: '复制失败,请手动选中链接复制', color: 'error' }) toast.add({ title: t('copy.failedManual'), color: 'error' })
} }
} finally { } finally {
copying.value = false copying.value = false
+9 -5
View File
@@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
defineProps<{ const props = defineProps<{
open: boolean open: boolean
count: number count: number
loading?: boolean loading?: boolean
@@ -10,6 +10,10 @@ const emit = defineEmits<{
'confirm': [] 'confirm': []
}>() }>()
const { t } = useI18n()
const description = computed(() => t('delete.confirmDesc', { count: props.count }))
function close() { function close() {
emit('update:open', false) emit('update:open', false)
} }
@@ -22,20 +26,20 @@ function confirm() {
<template> <template>
<UModal <UModal
:open="open" :open="open"
title="确认删除" :title="t('delete.confirmTitle')"
:description="`确定要删除选中的 ${count} 张图片吗?此操作不可恢复。`" :description="description"
@update:open="emit('update:open', $event)" @update:open="emit('update:open', $event)"
> >
<template #footer> <template #footer>
<div class="flex justify-end gap-2"> <div class="flex justify-end gap-2">
<UButton <UButton
label="取消" :label="t('common.cancel')"
color="neutral" color="neutral"
variant="outline" variant="outline"
@click="close" @click="close"
/> />
<UButton <UButton
label="确认删除" :label="t('delete.confirm')"
color="error" color="error"
:loading="loading" :loading="loading"
@click="confirm" @click="confirm"
+68 -16
View File
@@ -9,9 +9,13 @@ const props = withDefaults(defineProps<{
deleting?: boolean deleting?: boolean
selectable?: boolean selectable?: boolean
showKey?: boolean showKey?: boolean
showStorage?: boolean
compact?: boolean
}>(), { }>(), {
selectable: true, selectable: true,
showKey: true showKey: true,
showStorage: false,
compact: false
}) })
const emit = defineEmits<{ const emit = defineEmits<{
@@ -20,18 +24,19 @@ const emit = defineEmits<{
}>() }>()
const { formatFileSize } = useFileSize() const { formatFileSize } = useFileSize()
const { t, locale } = useI18n()
const imageError = ref(false) const imageError = ref(false)
const copyFormat = ref<CopyFormat>('url') const copyFormat = ref<CopyFormat>('url')
const copyFormatItems = [ const copyFormatItems = computed(() => [
{ label: '直链', value: 'url' as const }, { label: t('copy.url'), value: 'url' as const },
{ label: 'Markdown', value: 'markdown' as const }, { label: t('copy.markdown'), value: 'markdown' as const },
{ label: 'HTML', value: 'html' as const } { label: t('copy.html'), value: 'html' as const }
] ])
const uploadedLabel = computed(() => { const uploadedLabel = computed(() => {
try { try {
return new Date(props.image.uploadedAt).toLocaleString('zh-CN', { hour12: false }) return new Date(props.image.uploadedAt).toLocaleString(locale.value, { hour12: false })
} catch { } catch {
return props.image.uploadedAt return props.image.uploadedAt
} }
@@ -51,14 +56,18 @@ const previewValue = computed(() => {
const copySuccessTitle = computed(() => { const copySuccessTitle = computed(() => {
switch (copyFormat.value) { switch (copyFormat.value) {
case 'markdown': case 'markdown':
return '已复制 Markdown' return t('copy.copiedMarkdown')
case 'html': case 'html':
return '已复制 HTML' return t('copy.copiedHtml')
default: default:
return '已复制直链' return t('copy.copiedUrl')
} }
}) })
const storageIcon = computed(() =>
props.image.storage?.type === 'local' ? 'i-lucide-hard-drive' : 'i-lucide-cloud'
)
function toggleSelected(value: boolean | 'indeterminate') { function toggleSelected(value: boolean | 'indeterminate') {
emit('update:selected', value === true) emit('update:selected', value === true)
} }
@@ -79,7 +88,7 @@ function toggleSelected(value: boolean | 'indeterminate') {
v-else v-else
class="flex h-full items-center justify-center text-sm text-muted" class="flex h-full items-center justify-center text-sm text-muted"
> >
预览失败 {{ t('image.previewFailed') }}
</div> </div>
<div <div
v-if="selectable" v-if="selectable"
@@ -96,18 +105,18 @@ function toggleSelected(value: boolean | 'indeterminate') {
color="error" color="error"
variant="solid" variant="solid"
icon="i-lucide-trash-2" icon="i-lucide-trash-2"
aria-label="删除" :aria-label="t('common.delete')"
:loading="deleting" :loading="deleting"
@click="emit('delete')" @click="emit('delete')"
/> />
</div> </div>
<div <div
v-if="image.owner" v-if="image.owner"
class="absolute bottom-2 left-2 max-w-[calc(100%-1rem)]" class="absolute bottom-2 left-2 max-w-[calc(50%-0.5rem)]"
> >
<span <span
class="inline-flex max-w-full items-center gap-1 rounded-md bg-black/65 px-2 py-0.5 text-xs font-medium text-white shadow-sm backdrop-blur-sm" class="inline-flex max-w-full items-center gap-1 rounded-md bg-black/65 px-2 py-0.5 text-xs font-medium text-white shadow-sm backdrop-blur-sm"
:title="`上传者:${image.owner.username}`" :title="t('image.uploader', { name: image.owner.username })"
> >
<UIcon <UIcon
name="i-lucide-user" name="i-lucide-user"
@@ -116,9 +125,52 @@ function toggleSelected(value: boolean | 'indeterminate') {
<span class="truncate">{{ image.owner.username }}</span> <span class="truncate">{{ image.owner.username }}</span>
</span> </span>
</div> </div>
<div
v-if="showStorage && image.storage"
class="absolute bottom-2 max-w-[calc(50%-0.5rem)]"
:class="image.owner ? 'right-2' : 'left-2'"
>
<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>
<div class="flex flex-1 flex-col gap-2 p-3"> <div
v-if="compact"
class="flex items-center gap-1.5 p-2 sm:hidden"
>
<div class="min-w-0 flex-1">
<p
class="truncate text-xs font-medium"
:title="image.originalName"
>
{{ image.originalName }}
</p>
<p class="text-[11px] text-muted">
{{ formatFileSize(image.size) }}
</p>
</div>
<CopyButton
icon="i-lucide-copy"
icon-only
:label="t('common.copy')"
:value="image.url"
:success-title="t('copy.copiedUrl')"
/>
</div>
<div
class="flex flex-1 flex-col gap-2 p-3"
:class="{ 'hidden sm:flex': compact }"
>
<p <p
class="truncate text-sm font-medium" class="truncate text-sm font-medium"
:title="image.originalName" :title="image.originalName"
@@ -151,7 +203,7 @@ function toggleSelected(value: boolean | 'indeterminate') {
/> />
<CopyButton <CopyButton
icon="i-lucide-copy" icon="i-lucide-copy"
label="复制" :label="t('common.copy')"
:value="previewValue" :value="previewValue"
:success-title="copySuccessTitle" :success-title="copySuccessTitle"
/> />
+15 -3
View File
@@ -7,11 +7,16 @@ const props = withDefaults(defineProps<{
deletingKeys: Set<string> deletingKeys: Set<string>
selectable?: boolean selectable?: boolean
showKey?: boolean showKey?: boolean
showStorage?: boolean
emptyText?: string emptyText?: string
dense?: boolean
compact?: boolean
}>(), { }>(), {
selectable: true, selectable: true,
showKey: true, showKey: true,
emptyText: '暂无图片' showStorage: false,
dense: false,
compact: false
}) })
const emit = defineEmits<{ const emit = defineEmits<{
@@ -19,6 +24,10 @@ const emit = defineEmits<{
'delete': [key: string] 'delete': [key: string]
}>() }>()
const { t } = useI18n()
const displayEmptyText = computed(() => props.emptyText ?? t('image.empty'))
function updateSelection(key: string, selected: boolean) { function updateSelection(key: string, selected: boolean) {
const next = new Set(props.selectedKeys) const next = new Set(props.selectedKeys)
if (selected) next.add(key) if (selected) next.add(key)
@@ -30,7 +39,8 @@ function updateSelection(key: string, selected: boolean) {
<template> <template>
<div <div
v-if="items.length" v-if="items.length"
class="grid grid-cols-1 items-start gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4" 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'"
> >
<ImageCard <ImageCard
v-for="image in items" v-for="image in items"
@@ -40,6 +50,8 @@ function updateSelection(key: string, selected: boolean) {
:deleting="deletingKeys.has(image.key)" :deleting="deletingKeys.has(image.key)"
:selectable="selectable" :selectable="selectable"
:show-key="showKey" :show-key="showKey"
:show-storage="showStorage"
:compact="compact"
@update:selected="updateSelection(image.key, $event)" @update:selected="updateSelection(image.key, $event)"
@delete="emit('delete', image.key)" @delete="emit('delete', image.key)"
/> />
@@ -48,6 +60,6 @@ function updateSelection(key: string, selected: boolean) {
v-else v-else
class="rounded-xl border border-dashed border-default py-16 text-center text-sm text-muted" class="rounded-xl border border-dashed border-default py-16 text-center text-sm text-muted"
> >
{{ emptyText }} {{ displayEmptyText }}
</div> </div>
</template> </template>
+52 -48
View File
@@ -1,4 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import uploadCardBg from '~/assets/image/upload-card-bg.webp'
import uploadCardHero from '~/assets/image/upload-card-hero.webp'
const props = defineProps<{ const props = defineProps<{
disabled?: boolean disabled?: boolean
}>() }>()
@@ -10,12 +13,10 @@ const emit = defineEmits<{
const fileInput = ref<HTMLInputElement | null>(null) const fileInput = ref<HTMLInputElement | null>(null)
const dragging = ref(false) const dragging = ref(false)
const hovering = ref(false)
const { t } = useI18n()
const { bindPaste } = useClipboardImage(ref(null)) const { bindPaste } = useClipboardImage(ref(null))
const isActive = computed(() => dragging.value || hovering.value)
bindPaste((files) => { bindPaste((files) => {
if (!props.disabled) emit('upload', files) if (!props.disabled) emit('upload', files)
}) })
@@ -51,31 +52,33 @@ function onDragLeave() {
dragging.value = false dragging.value = false
} }
function onMouseEnter() { function openSettings(event: Event) {
if (!props.disabled) hovering.value = true event.stopPropagation()
}
function onMouseLeave() {
hovering.value = false
}
function openSettings() {
if (props.disabled) return if (props.disabled) return
emit('open-settings') emit('open-settings')
} }
function onKeydown(event: KeyboardEvent) {
if (props.disabled) return
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault()
openFilePicker()
}
}
</script> </script>
<template> <template>
<div <div
class="upload-card-surface group/upload relative text-center transition-all duration-300 ease-out" role="button"
:class="isActive tabindex="0"
? 'scale-[1.01] border-primary bg-primary/5 shadow-md shadow-primary/10' class="upload-card-surface group/upload relative cursor-pointer overflow-hidden text-center outline-none focus-visible:ring-2 focus-visible:ring-primary/40"
: 'hover:border-primary/50 hover:bg-primary/[0.04] hover:shadow-sm'" :class="{ 'upload-card-surface--active': dragging }"
:aria-disabled="disabled"
@click="openFilePicker"
@keydown="onKeydown"
@drop="onDrop" @drop="onDrop"
@dragover="onDragOver" @dragover="onDragOver"
@dragleave="onDragLeave" @dragleave="onDragLeave"
@mouseenter="onMouseEnter"
@mouseleave="onMouseLeave"
> >
<input <input
ref="fileInput" ref="fileInput"
@@ -87,11 +90,20 @@ function openSettings() {
@change="onFileChange" @change="onFileChange"
> >
<div class="absolute top-3 right-3 z-10"> <img
:src="uploadCardBg"
alt=""
aria-hidden="true"
class="pointer-events-none absolute inset-0 size-full object-cover opacity-100 dark:opacity-[0.12]"
decoding="async"
draggable="false"
>
<div class="absolute top-3 right-3 z-20">
<button <button
type="button" type="button"
class="upload-settings-btn inline-flex size-8 items-center justify-center rounded-md text-muted transition-colors hover:bg-elevated hover:text-highlighted disabled:pointer-events-none disabled:opacity-40" class="upload-settings-btn inline-flex size-8 items-center justify-center rounded-md bg-default/70 text-muted backdrop-blur-sm transition-colors hover:bg-elevated hover:text-highlighted disabled:pointer-events-none disabled:opacity-40"
aria-label="上传设置" :aria-label="t('upload.settings')"
:disabled="disabled" :disabled="disabled"
@click="openSettings" @click="openSettings"
> >
@@ -102,39 +114,31 @@ function openSettings() {
</button> </button>
</div> </div>
<div class="mx-auto max-w-lg space-y-4"> <div class="relative z-10 mx-auto flex max-w-lg flex-col items-center">
<div <div class="flex flex-col items-center">
class="mx-auto flex size-16 items-center justify-center rounded-2xl bg-primary/10 transition-transform duration-300 ease-out" <img
:class="isActive ? 'scale-110' : 'group-hover/upload:scale-105'" :src="uploadCardHero"
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"
:class="dragging ? 'scale-105' : 'group-hover/upload:scale-[1.02]'"
decoding="async"
draggable="false"
> >
<UIcon
name="i-lucide-cloud-upload"
class="size-8 text-primary transition-transform duration-300"
:class="isActive ? '-translate-y-0.5' : ''"
/>
</div>
<div class="space-y-2"> <div class="space-y-2">
<p class="text-xl font-semibold tracking-tight"> <p class="text-xl font-semibold tracking-tight text-highlighted sm:text-2xl">
拖拽图片到此处上传 {{ t('upload.title') }}
</p> </p>
<p class="text-sm text-muted"> <p class="text-sm text-muted sm:text-base">
或点击选择文件,按 {{ t('upload.hint', { ctrl: 'Ctrl', v: 'V' }) }}
<kbd class="mx-1 rounded border border-default bg-muted/50 px-1.5 py-0.5 font-mono text-xs">Ctrl</kbd>
+
<kbd class="mx-1 rounded border border-default bg-muted/50 px-1.5 py-0.5 font-mono text-xs">V</kbd>
粘贴图片
</p> </p>
</div> </div>
<p class="text-xs text-dimmed"> </div>
支持 JPEG / PNG / WebP / GIF / SVG / ICO,单张最大 10 MB,每次最多 10 张
<p class="mt-4 max-w-md text-xs leading-relaxed text-dimmed sm:text-sm">
{{ t('upload.formats') }}
</p> </p>
<UButton
label="选择图片"
icon="i-lucide-upload"
size="lg"
:disabled="disabled"
@click="openFilePicker"
/>
</div> </div>
</div> </div>
</template> </template>
+16
View File
@@ -0,0 +1,16 @@
<script setup lang="ts">
const { localeMenuItems } = useAppearanceMenus()
const { t } = useI18n()
</script>
<template>
<UDropdownMenu :items="localeMenuItems">
<UButton
icon="i-lucide-languages"
variant="ghost"
color="neutral"
size="sm"
:aria-label="t('locale.switch')"
/>
</UDropdownMenu>
</template>
+85
View File
@@ -0,0 +1,85 @@
<script setup lang="ts">
defineProps<{
items: Array<{ label: string, to: string, icon: string }>
}>()
const open = defineModel<boolean>('open', { default: false })
const route = useRoute()
const { t } = useI18n()
const menuRef = ref<HTMLElement | null>(null)
function isActive(to: string) {
return route.path.startsWith(to)
}
function onDocumentClick(event: MouseEvent) {
if (!open.value || !menuRef.value) return
const target = event.target
if (target instanceof Node && !menuRef.value.contains(target)) {
open.value = false
}
}
watch(() => route.path, () => {
open.value = false
})
watch(open, (isOpen) => {
if (isOpen) {
document.addEventListener('click', onDocumentClick, true)
} else {
document.removeEventListener('click', onDocumentClick, true)
}
})
onUnmounted(() => {
document.removeEventListener('click', onDocumentClick, true)
})
function toggleOpen() {
open.value = !open.value
}
</script>
<template>
<div
ref="menuRef"
class="relative sm:hidden"
>
<UButton
variant="outline"
color="neutral"
size="sm"
icon="i-lucide-menu"
:aria-label="t('nav.menu')"
:aria-expanded="open"
@click.stop="toggleOpen"
/>
<div
v-show="open"
class="absolute top-full right-0 z-50 mt-2 w-52 rounded-xl border border-default bg-default p-1.5 shadow-lg"
@click.stop
>
<nav class="space-y-0.5">
<NuxtLink
v-for="item in items"
:key="item.to"
:to="item.to"
class="flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-colors"
:class="isActive(item.to)
? 'bg-primary/10 text-primary'
: 'text-highlighted hover:bg-elevated'"
@click="open = false"
>
<UIcon
:name="item.icon"
class="size-4 shrink-0 opacity-70"
/>
{{ item.label }}
</NuxtLink>
</nav>
</div>
</div>
</template>
+196
View File
@@ -0,0 +1,196 @@
<script setup lang="ts">
const open = defineModel<boolean>('open', { default: false })
const { logout } = useAuth()
const { t, locale } = useI18n()
const colorMode = useColorMode()
const {
localeOptions,
themeOptions,
setLocalePreference,
setThemePreference
} = useAppearanceMenus()
const menuRef = ref<HTMLElement | null>(null)
async function handleLogout() {
open.value = false
await logout()
}
function onDocumentClick(event: MouseEvent) {
if (!open.value || !menuRef.value) return
const target = event.target
if (target instanceof Node && !menuRef.value.contains(target)) {
open.value = false
}
}
watch(open, (isOpen) => {
if (isOpen) {
document.addEventListener('click', onDocumentClick, true)
} else {
document.removeEventListener('click', onDocumentClick, true)
}
})
onUnmounted(() => {
document.removeEventListener('click', onDocumentClick, true)
})
function toggleOpen() {
open.value = !open.value
}
</script>
<template>
<div
ref="menuRef"
class="relative sm:hidden"
>
<UButton
variant="ghost"
color="neutral"
size="sm"
icon="i-lucide-user"
:aria-label="t('nav.account')"
:aria-expanded="open"
@click.stop="toggleOpen"
/>
<div
v-show="open"
class="absolute top-full right-0 z-50 mt-2 w-[min(20rem,calc(100vw-2rem))] rounded-xl border border-default bg-default p-4 shadow-lg"
@click.stop
>
<section class="space-y-2">
<h3 class="flex items-center gap-2 px-1 text-sm font-medium text-muted">
<UIcon
name="i-lucide-globe"
class="size-4"
/>
{{ t('nav.language') }}
</h3>
<div class="space-y-2">
<button
v-for="item in localeOptions"
:key="item.code"
type="button"
class="flex w-full items-center justify-between rounded-lg border px-3.5 py-2.5 text-sm transition-colors"
:class="locale === item.code
? 'border-primary text-highlighted'
: 'border-default text-highlighted hover:bg-elevated'"
@click="setLocalePreference(item.code)"
>
<span>{{ item.label }}</span>
<UIcon
v-if="locale === item.code"
name="i-lucide-check"
class="size-4 text-primary"
/>
</button>
</div>
</section>
<section class="mt-5 space-y-2">
<h3 class="flex items-center gap-2 px-1 text-sm font-medium text-muted">
<UIcon
name="i-lucide-sun"
class="size-4"
/>
{{ t('nav.appearance') }}
</h3>
<div class="space-y-2">
<button
v-for="item in themeOptions"
:key="item.value"
type="button"
class="flex w-full items-center justify-between rounded-lg border px-3.5 py-2.5 text-sm transition-colors"
:class="colorMode.preference === item.value
? 'border-primary text-highlighted'
: 'border-default text-highlighted hover:bg-elevated'"
@click="setThemePreference(item.value)"
>
<span class="flex items-center gap-2.5">
<UIcon
:name="item.icon"
class="size-4 opacity-70"
/>
{{ item.label }}
</span>
<UIcon
v-if="colorMode.preference === item.value"
name="i-lucide-check"
class="size-4 text-primary"
/>
</button>
</div>
</section>
<section class="mt-5 space-y-2">
<h3 class="flex items-center gap-2 px-1 text-sm font-medium text-muted">
<UIcon
name="i-lucide-user"
class="size-4"
/>
{{ t('nav.account') }}
</h3>
<div class="space-y-2">
<NuxtLink
to="/logs"
class="flex items-center justify-between rounded-lg border border-default px-3.5 py-2.5 text-sm text-highlighted transition-colors hover:bg-elevated"
@click="open = false"
>
<span class="flex items-center gap-2.5">
<UIcon
name="i-lucide-scroll-text"
class="size-4 opacity-70"
/>
{{ t('nav.logs') }}
</span>
<UIcon
name="i-lucide-chevron-right"
class="size-4 text-muted"
/>
</NuxtLink>
<NuxtLink
to="/account/password"
class="flex items-center justify-between rounded-lg border border-default px-3.5 py-2.5 text-sm text-highlighted transition-colors hover:bg-elevated"
@click="open = false"
>
<span class="flex items-center gap-2.5">
<UIcon
name="i-lucide-lock"
class="size-4 opacity-70"
/>
{{ t('nav.changePassword') }}
</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"
@click="handleLogout"
>
<span class="flex items-center gap-2.5">
<UIcon
name="i-lucide-log-out"
class="size-4"
/>
{{ t('nav.logout') }}
</span>
<UIcon
name="i-lucide-chevron-right"
class="size-4 opacity-60"
/>
</button>
</div>
</section>
</div>
</div>
</template>
+9 -7
View File
@@ -11,7 +11,9 @@ const emit = defineEmits<{
'update:page': [page: number] 'update:page': [page: number]
}>() }>()
const unitLabel = computed(() => props.unit ?? '条') const { t } = useI18n()
const unitLabel = computed(() => props.unit ?? t('common.itemsUnit'))
function goTo(target: number) { function goTo(target: number) {
if (target < 1 || target > props.totalPages || target === props.page || props.loading) { if (target < 1 || target > props.totalPages || target === props.page || props.loading) {
@@ -30,13 +32,13 @@ function goTo(target: number) {
v-if="totalPages > 1" v-if="totalPages > 1"
class="text-sm text-muted" class="text-sm text-muted"
> >
第 {{ page }} / {{ totalPages }} 页 · 共 {{ total }} {{ unitLabel }} {{ t('pagination.pageOf', { page, totalPages, total, unit: unitLabel }) }}
</p> </p>
<p <p
v-else v-else
class="text-sm text-muted" class="text-sm text-muted"
> >
共 {{ total }} {{ unitLabel }} {{ t('pagination.totalOnly', { total, unit: unitLabel }) }}
</p> </p>
<div <div
@@ -47,7 +49,7 @@ function goTo(target: number) {
icon="i-lucide-chevrons-left" icon="i-lucide-chevrons-left"
variant="outline" variant="outline"
size="sm" size="sm"
aria-label="第一页" :aria-label="t('pagination.first')"
:disabled="page <= 1 || loading" :disabled="page <= 1 || loading"
@click="goTo(1)" @click="goTo(1)"
/> />
@@ -55,7 +57,7 @@ function goTo(target: number) {
icon="i-lucide-chevron-left" icon="i-lucide-chevron-left"
variant="outline" variant="outline"
size="sm" size="sm"
aria-label="上一页" :aria-label="t('pagination.prev')"
:disabled="page <= 1 || loading" :disabled="page <= 1 || loading"
@click="goTo(page - 1)" @click="goTo(page - 1)"
/> />
@@ -63,7 +65,7 @@ function goTo(target: number) {
icon="i-lucide-chevron-right" icon="i-lucide-chevron-right"
variant="outline" variant="outline"
size="sm" size="sm"
aria-label="下一页" :aria-label="t('pagination.next')"
:disabled="page >= totalPages || loading" :disabled="page >= totalPages || loading"
@click="goTo(page + 1)" @click="goTo(page + 1)"
/> />
@@ -71,7 +73,7 @@ function goTo(target: number) {
icon="i-lucide-chevrons-right" icon="i-lucide-chevrons-right"
variant="outline" variant="outline"
size="sm" size="sm"
aria-label="最后一页" :aria-label="t('pagination.last')"
:disabled="page >= totalPages || loading" :disabled="page >= totalPages || loading"
@click="goTo(totalPages)" @click="goTo(totalPages)"
/> />
+50
View File
@@ -0,0 +1,50 @@
<script setup lang="ts">
defineOptions({
inheritAttrs: false
})
const model = defineModel<string>({ default: '' })
defineProps<{
id?: string
placeholder?: string
autocomplete?: string
}>()
const show = ref(false)
const { t } = useI18n()
</script>
<template>
<UInput
:id="id"
v-model="model"
:type="show ? 'text' : 'password'"
:placeholder="placeholder"
:autocomplete="autocomplete"
size="lg"
class="w-full"
:ui="{ root: 'w-full', trailing: 'pe-1' }"
v-bind="$attrs"
>
<template #trailing>
<UButton
color="neutral"
variant="link"
size="sm"
:icon="show ? 'i-lucide-eye-off' : 'i-lucide-eye'"
:aria-label="show ? t('auth.hidePassword') : t('auth.showPassword')"
:aria-pressed="show"
:aria-controls="id"
type="button"
@click="() => { show = !show }"
/>
</template>
</UInput>
</template>
<style scoped>
:deep(::-ms-reveal) {
display: none;
}
</style>
+12 -10
View File
@@ -5,15 +5,17 @@ const props = defineProps<{
password: string password: string
}>() }>()
const { t } = useI18n()
const analysis = computed(() => analyzePassword(props.password)) const analysis = computed(() => analyzePassword(props.password))
const show = computed(() => props.password.length > 0) const show = computed(() => props.password.length > 0)
const labelColor = computed(() => { const labelColor = computed(() => {
switch (analysis.value.label) { switch (analysis.value.label) {
case '强': case 'strong':
return 'text-success' return 'text-success'
case '中': case 'medium':
return 'text-warning' return 'text-warning'
default: default:
return 'text-error' return 'text-error'
@@ -22,9 +24,9 @@ const labelColor = computed(() => {
const barColor = computed(() => { const barColor = computed(() => {
switch (analysis.value.label) { switch (analysis.value.label) {
case '强': case 'strong':
return 'bg-success' return 'bg-success'
case '中': case 'medium':
return 'bg-warning' return 'bg-warning'
default: default:
return 'bg-error/80' return 'bg-error/80'
@@ -32,10 +34,10 @@ const barColor = computed(() => {
}) })
const rules = computed(() => [ const rules = computed(() => [
{ key: 'minLength', label: `至少 ${PASSWORD_MIN_LENGTH} 个字符` }, { key: 'minLength', label: t('password.ruleMinLength', { n: PASSWORD_MIN_LENGTH }) },
{ key: 'mixedCase', label: '包含大小写字母' }, { key: 'mixedCase', label: t('password.ruleMixedCase') },
{ key: 'hasNumber', label: '包含数字' }, { key: 'hasNumber', label: t('password.ruleNumber') },
{ key: 'hasSpecial', label: '包含特殊字符' } { key: 'hasSpecial', label: t('password.ruleSpecial') }
] as const) ] as const)
</script> </script>
@@ -45,12 +47,12 @@ const rules = computed(() => [
class="space-y-3 rounded-lg border border-default bg-muted/20 px-3 py-3" class="space-y-3 rounded-lg border border-default bg-muted/20 px-3 py-3"
> >
<div class="flex items-center justify-between text-sm"> <div class="flex items-center justify-between text-sm">
<span class="text-muted">密码强度</span> <span class="text-muted">{{ t('password.strength') }}</span>
<span <span
class="font-medium" class="font-medium"
:class="labelColor" :class="labelColor"
> >
{{ analysis.label }} {{ t(`password.${analysis.label}`) }}
</span> </span>
</div> </div>
+16
View File
@@ -0,0 +1,16 @@
<script setup lang="ts">
const { themeIcon, themeMenuItems } = useAppearanceMenus()
const { t } = useI18n()
</script>
<template>
<UDropdownMenu :items="themeMenuItems">
<UButton
:icon="themeIcon"
variant="ghost"
color="neutral"
size="sm"
:aria-label="t('theme.switch')"
/>
</UDropdownMenu>
</template>
+26 -24
View File
@@ -20,6 +20,8 @@ const {
loadPreferences loadPreferences
} = useUploadPreferences() } = useUploadPreferences()
const { t } = useI18n()
const { const {
userSettings, userSettings,
enabled: userAutoDeleteEnabled, enabled: userAutoDeleteEnabled,
@@ -27,16 +29,16 @@ const {
load: loadUserSettings load: loadUserSettings
} = useUserAutoDeleteSettings() } = useUserAutoDeleteSettings()
const copyFormatItems = [ const copyFormatItems = computed(() => [
{ label: '直链', value: 'url' as const }, { label: t('copy.url'), value: 'url' as const },
{ label: 'Markdown', value: 'markdown' as const }, { label: t('copy.markdown'), value: 'markdown' as const },
{ label: 'HTML', value: 'html' as const } { label: t('copy.html'), value: 'html' as const }
] ])
const clientQualityLevel = computed(() => { const clientQualityLevel = computed(() => {
if (clientWebpQuality.value >= 85) return '高质量' if (clientWebpQuality.value >= 85) return t('preferences.qualityHigh')
if (clientWebpQuality.value >= 60) return '中等质量' if (clientWebpQuality.value >= 60) return t('preferences.qualityMedium')
return '有损压缩' return t('preferences.qualityLow')
}) })
function setCopyFormat(value: 'url' | 'markdown' | 'html') { function setCopyFormat(value: 'url' | 'markdown' | 'html') {
@@ -73,22 +75,22 @@ onMounted(() => {
/> />
<div class="min-w-0"> <div class="min-w-0">
<h2 class="text-base font-semibold"> <h2 class="text-base font-semibold">
上传偏好 {{ t('preferences.title') }}
</h2> </h2>
<p class="mt-0.5 text-xs text-muted"> <p class="mt-0.5 text-xs text-muted">
浏览器本地与账号设置,仅影响您的上传体验 {{ t('preferences.subtitle') }}
</p> </p>
</div> </div>
</div> </div>
<UButton <UButton
v-if="!embedded" v-if="!embedded"
label="返回" :label="t('common.back')"
icon="i-lucide-arrow-left" icon="i-lucide-arrow-left"
variant="ghost" variant="ghost"
color="neutral" color="neutral"
size="sm" size="sm"
class="shrink-0" class="shrink-0"
aria-label="返回上传" :aria-label="t('preferences.back')"
@click="emit('back')" @click="emit('back')"
/> />
</div> </div>
@@ -100,7 +102,7 @@ onMounted(() => {
<div class="grid gap-5 sm:grid-cols-3 sm:gap-6"> <div class="grid gap-5 sm:grid-cols-3 sm:gap-6">
<div class="space-y-4"> <div class="space-y-4">
<h3 class="text-sm font-semibold"> <h3 class="text-sm font-semibold">
图片处理 {{ t('preferences.processing') }}
</h3> </h3>
<label class="flex cursor-pointer items-start gap-2.5"> <label class="flex cursor-pointer items-start gap-2.5">
@@ -109,9 +111,9 @@ onMounted(() => {
class="mt-0.5" class="mt-0.5"
/> />
<span> <span>
<span class="block text-sm">客户端预压缩</span> <span class="block text-sm">{{ t('preferences.clientCompress') }}</span>
<span class="mt-1 block text-xs leading-relaxed text-muted"> <span class="mt-1 block text-xs leading-relaxed text-muted">
上传前在浏览器端压缩(服务端仍会转 WebP) {{ t('preferences.clientCompressHint') }}
</span> </span>
</span> </span>
</label> </label>
@@ -129,7 +131,7 @@ onMounted(() => {
<span <span
class="flex flex-wrap items-center gap-x-1.5 gap-y-1 text-sm" class="flex flex-wrap items-center gap-x-1.5 gap-y-1 text-sm"
> >
<span>自动删除超过</span> <span>{{ t('preferences.autoDeletePrefix') }}</span>
<UInput <UInput
v-model.number="userAutoDeleteDaysDraft" v-model.number="userAutoDeleteDaysDraft"
type="number" type="number"
@@ -143,10 +145,10 @@ onMounted(() => {
: 'pointer-events-none opacity-40' : 'pointer-events-none opacity-40'
" "
/> />
<span>天的图片</span> <span>{{ t('preferences.autoDeleteSuffix') }}</span>
</span> </span>
<span class="mt-1 block text-xs leading-relaxed text-muted"> <span class="mt-1 block text-xs leading-relaxed text-muted">
仅清理开启后上传的图片,不影响此前已存的图片 {{ t('preferences.autoDeleteHint') }}
</span> </span>
</span> </span>
</label> </label>
@@ -164,15 +166,15 @@ onMounted(() => {
<div class="space-y-4"> <div class="space-y-4">
<h3 class="text-sm font-semibold"> <h3 class="text-sm font-semibold">
WebP 压缩质量 {{ t('preferences.webpQuality') }}
</h3> </h3>
<p class="text-xs leading-relaxed text-muted"> <p class="text-xs leading-relaxed text-muted">
客户端预压缩质量(1–100),需开启「客户端预压缩」 {{ t('preferences.webpQualityHint') }}
</p> </p>
<div <div
class="flex items-center justify-between gap-2 text-sm text-muted" class="flex items-center justify-between gap-2 text-sm text-muted"
> >
<span>当前:{{ clientWebpQuality }}</span> <span>{{ t('preferences.current', { n: clientWebpQuality }) }}</span>
<span>{{ clientQualityLevel }}</span> <span>{{ clientQualityLevel }}</span>
</div> </div>
<input <input
@@ -187,7 +189,7 @@ onMounted(() => {
<div class="space-y-4"> <div class="space-y-4">
<h3 class="text-sm font-semibold"> <h3 class="text-sm font-semibold">
便捷功能 {{ t('preferences.convenience') }}
</h3> </h3>
<label class="flex cursor-pointer items-start gap-2.5"> <label class="flex cursor-pointer items-start gap-2.5">
<UCheckbox <UCheckbox
@@ -195,9 +197,9 @@ onMounted(() => {
class="mt-0.5" class="mt-0.5"
/> />
<span> <span>
<span class="block text-sm">上传成功自动复制链接</span> <span class="block text-sm">{{ t('preferences.autoCopy') }}</span>
<span class="mt-1 block text-xs leading-relaxed text-muted"> <span class="mt-1 block text-xs leading-relaxed text-muted">
按所选格式复制到剪贴板 {{ t('preferences.autoCopyHint') }}
</span> </span>
</span> </span>
</label> </label>
+17 -2
View File
@@ -4,6 +4,21 @@ import type { UploadProgressItem } from '~/types/image'
defineProps<{ defineProps<{
items: UploadProgressItem[] items: UploadProgressItem[]
}>() }>()
const { t } = useI18n()
function statusLabel(status: UploadProgressItem['status']) {
switch (status) {
case 'pending':
return t('upload.statusPending')
case 'uploading':
return t('upload.statusUploading')
case 'success':
return t('upload.statusSuccess')
default:
return t('upload.statusError')
}
}
</script> </script>
<template> <template>
@@ -12,7 +27,7 @@ defineProps<{
class="space-y-2 rounded-lg border border-default p-4" class="space-y-2 rounded-lg border border-default p-4"
> >
<p class="text-sm font-medium text-muted"> <p class="text-sm font-medium text-muted">
上传进度 {{ t('upload.progress') }}
</p> </p>
<div <div
v-for="item in items" v-for="item in items"
@@ -26,7 +41,7 @@ defineProps<{
variant="subtle" variant="subtle"
size="xs" size="xs"
> >
{{ item.status === 'pending' ? '等待' : item.status === 'uploading' ? '上传中' : item.status === 'success' ? '成功' : '失败' }} {{ statusLabel(item.status) }}
</UBadge> </UBadge>
</div> </div>
<UProgress <UProgress
@@ -0,0 +1,215 @@
<script setup lang="ts">
import type { StorageBackendItem } from '~/types/storage'
type CardAction = {
key: string
icon: string
label: string
shortLabel: string
danger?: boolean
disabled?: boolean
onClick: () => void
}
const props = defineProps<{
backend: StorageBackendItem
envOverride?: boolean
busy?: boolean
}>()
const emit = defineEmits<{
setDefault: []
edit: []
test: []
toggleEnabled: []
delete: []
}>()
const { t } = useI18n()
const typeIcon = computed(() =>
props.backend.type === 'local' ? 'i-lucide-hard-drive' : 'i-lucide-cloud'
)
const typeLabel = computed(() =>
props.backend.type === 'local' ? t('storage.backendLocal') : t('storage.backendCloud')
)
const servingLabel = computed(() =>
props.backend.servingMode === 'public'
? t('storage.servingPublic')
: t('storage.servingProxy')
)
const bucketLine = computed(() => {
if (props.backend.type !== 's3') return null
return props.backend.config.bucket || t('storage.notConfigured')
})
const actionsDisabled = computed(() => props.envOverride || props.busy)
const actions = computed<CardAction[]>(() => {
const isLocal = props.backend.type === 'local'
const items: CardAction[] = []
if (!props.backend.isDefault && props.backend.enabled) {
items.push({
key: 'default',
icon: 'i-lucide-star',
label: t('storage.setDefault'),
shortLabel: isLocal ? t('storage.setDefault') : t('storage.actionSetDefault'),
onClick: () => emit('setDefault')
})
}
if (props.backend.type === 's3') {
items.push({
key: 'edit',
icon: 'i-lucide-pencil',
label: t('common.edit'),
shortLabel: t('common.edit'),
onClick: () => emit('edit')
})
}
items.push({
key: 'test',
icon: 'i-lucide-plug-zap',
label: t('storage.testConnection'),
shortLabel: isLocal ? t('storage.testConnection') : t('storage.actionTest'),
onClick: () => emit('test')
})
if (props.backend.type !== 'local') {
items.push({
key: 'toggle',
icon: props.backend.enabled ? 'i-lucide-pause' : 'i-lucide-play',
label: props.backend.enabled ? t('storage.disable') : t('storage.enable'),
shortLabel: props.backend.enabled ? t('storage.disable') : t('storage.enable'),
disabled: props.backend.isDefault && props.backend.enabled,
onClick: () => emit('toggleEnabled')
})
}
if (props.backend.type !== 'local' && !props.backend.isDefault) {
items.push({
key: 'delete',
icon: 'i-lucide-trash-2',
label: t('common.delete'),
shortLabel: t('common.delete'),
danger: true,
onClick: () => emit('delete')
})
}
return items
})
const actionGridClass = computed(() => {
switch (actions.value.length) {
case 1: return 'grid-cols-1'
case 2: return 'grid-cols-2'
case 3: return 'grid-cols-3'
case 4: return 'grid-cols-4'
default: return 'grid-cols-5'
}
})
</script>
<template>
<article
class="flex h-full flex-col rounded-2xl border border-default bg-elevated p-4 shadow-sm"
:class="{
'ring-2 ring-primary/30': backend.isDefault,
'opacity-75': !backend.enabled
}"
>
<div class="mb-3 flex items-start justify-between gap-2">
<div class="flex min-w-0 items-start gap-2.5">
<div
class="flex size-9 shrink-0 items-center justify-center rounded-lg bg-primary/10 text-primary"
>
<UIcon
:name="typeIcon"
class="size-4.5"
/>
</div>
<div class="min-w-0">
<h3 class="truncate text-sm font-semibold">
{{ backend.name }}
</h3>
<p class="mt-0.5 text-xs text-muted">
{{ typeLabel }}
<template v-if="bucketLine">
· {{ bucketLine }}
</template>
</p>
</div>
</div>
<div class="flex shrink-0 flex-wrap justify-end gap-1">
<UBadge
v-if="backend.enabled"
color="success"
variant="subtle"
size="xs"
>
{{ t('storage.badgeEnabled') }}
</UBadge>
<UBadge
v-if="backend.isDefault"
color="primary"
variant="subtle"
size="xs"
>
{{ t('storage.badgeDefault') }}
</UBadge>
<UBadge
v-if="!backend.enabled"
color="warning"
variant="subtle"
size="xs"
>
{{ t('storage.badgeDisabled') }}
</UBadge>
</div>
</div>
<StorageUsageBar
:capacity="backend.capacity"
:index-bytes="backend.usage.bytes"
/>
<p class="mt-3 text-xs text-muted">
{{ t('storage.servingMode') }}: {{ servingLabel }}
</p>
<div class="mt-4 border-t border-default pt-4">
<div
class="grid gap-1.5"
:class="actionGridClass"
>
<button
v-for="action in actions"
:key="action.key"
type="button"
class="flex flex-col items-center justify-center gap-1 rounded-lg border px-1 py-2 transition disabled:cursor-not-allowed disabled:opacity-40"
:class="action.danger
? 'border-error/30 text-error hover:bg-error/5'
: 'border-default text-muted hover:bg-muted/40 hover:text-highlighted'"
:title="action.label"
:aria-label="action.label"
:disabled="actionsDisabled || action.disabled"
@click="action.onClick"
>
<UIcon
:name="action.icon"
class="size-4 shrink-0"
/>
<span class="w-full truncate text-center text-[11px] leading-none">
{{ action.shortLabel }}
</span>
</button>
</div>
</div>
</article>
</template>
@@ -0,0 +1,345 @@
<script setup lang="ts">
import type { QuotaUnit } from '~/composables/useFileSize'
import type { StorageBackendItem } from '~/types/storage'
type StorageProvider = 'r2' | 'cos' | 'oss' | 'aws' | 'custom'
export interface StorageFormPayload {
name: string
config: {
endpoint: string
region: string
bucket: string
prefix?: string
forcePathStyle: boolean
}
secrets: {
accessKeyId: string
secretAccessKey: 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 servingMode = ref<'proxy' | 'public'>('proxy')
const publicUrl = ref('')
const quotaValue = ref('')
const quotaUnit = ref<QuotaUnit>('GB')
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 }
])
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)
}
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 loadBackend(backend: StorageBackendItem | null | undefined) {
if (!backend || backend.type !== 's3') {
if (props.createMode) {
name.value = ''
provider.value = 'r2'
applyProviderTemplate('r2')
bucket.value = ''
prefix.value = ''
accessKeyId.value = ''
secretAccessKey.value = ''
servingMode.value = 'proxy'
publicUrl.value = ''
quotaValue.value = ''
quotaUnit.value = 'GB'
}
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() {
emit('submit', {
name: name.value.trim() || providerLabel(provider.value),
config: {
endpoint: endpoint.value.trim(),
region: region.value.trim() || 'auto',
bucket: bucket.value.trim(),
prefix: prefix.value.trim() || undefined,
forcePathStyle: forcePathStyle.value
},
secrets: {
accessKeyId: accessKeyId.value.trim(),
secretAccessKey: secretAccessKey.value.trim()
},
servingMode: 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"
/>
</div>
<div class="space-y-2">
<label class="text-sm">{{ t('storage.backendName') }}</label>
<UInput
v-model="name"
class="w-full"
/>
</div>
<div class="space-y-2">
<label class="text-sm">{{ t('storage.endpoint') }}</label>
<UInput
v-model="endpoint"
class="w-full font-mono text-sm"
/>
</div>
<div class="grid gap-3 sm:grid-cols-2">
<div class="space-y-2">
<label class="text-sm">{{ t('storage.region') }}</label>
<UInput
v-model="region"
class="w-full font-mono text-sm"
/>
</div>
<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>
<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">
{{ 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,91 @@
<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-3 gap-2 text-center text-xs">
<div class="rounded-lg bg-muted/30 px-2 py-2">
<p class="text-muted">
{{ $t('storage.statTotal') }}
</p>
<p class="mt-1 font-medium tabular-nums text-highlighted">
{{ formatOptional(capacity.totalBytes) }}
</p>
</div>
<div class="rounded-lg bg-muted/30 px-2 py-2">
<p class="text-muted">
{{ $t('storage.statUsed') }}
</p>
<p class="mt-1 font-medium tabular-nums text-highlighted">
{{ formatFileSize(displayUsedBytes) }}
</p>
</div>
<div class="rounded-lg bg-muted/30 px-2 py-2">
<p class="text-muted">
{{ $t('storage.statFree') }}
</p>
<p
class="mt-1 font-medium tabular-nums"
:class="overQuota ? 'text-error' : 'text-highlighted'"
>
{{ formatOptional(capacity.freeBytes) }}
</p>
</div>
</div>
<div
v-if="hasBar"
class="space-y-1"
>
<div class="flex items-center justify-between text-xs">
<span class="text-muted">{{ $t('storage.usageRate') }}</span>
<span
class="tabular-nums font-medium"
:class="overQuota ? 'text-error' : ''"
>
{{ displayPercent }}%
</span>
</div>
<div class="h-1.5 overflow-hidden rounded-full bg-muted/50">
<div
class="h-full rounded-full transition-all"
:class="overQuota ? 'bg-error/80' : 'bg-primary/70'"
:style="{ width: `${barWidth}%` }"
/>
</div>
</div>
</div>
</template>
+100
View File
@@ -0,0 +1,100 @@
import type { DropdownMenuItem } from '@nuxt/ui'
export function useAppearanceMenus() {
const colorMode = useColorMode()
const { locale, locales, setLocale, t } = useI18n()
const themeIcon = computed(() => {
if (colorMode.preference === 'system') return 'i-lucide-monitor'
return colorMode.preference === 'dark' ? 'i-lucide-moon' : 'i-lucide-sun'
})
const localeMenuItems = computed<DropdownMenuItem[][]>(() => [[
...locales.value.map(loc => ({
label: t(`locale.${loc.code}`),
icon: 'i-lucide-languages',
type: 'checkbox' as const,
checked: locale.value === loc.code,
onUpdateChecked(checked: boolean) {
if (checked && typeof loc.code === 'string') {
void setLocale(loc.code)
}
}
}))
]])
const themeMenuItems = computed<DropdownMenuItem[][]>(() => [[
{
label: t('theme.light'),
icon: 'i-lucide-sun',
type: 'checkbox',
checked: colorMode.preference === 'light',
onUpdateChecked(checked: boolean) {
if (checked) colorMode.preference = 'light'
}
},
{
label: t('theme.dark'),
icon: 'i-lucide-moon',
type: 'checkbox',
checked: colorMode.preference === 'dark',
onUpdateChecked(checked: boolean) {
if (checked) colorMode.preference = 'dark'
}
},
{
label: t('theme.system'),
icon: 'i-lucide-monitor',
type: 'checkbox',
checked: colorMode.preference === 'system',
onUpdateChecked(checked: boolean) {
if (checked) colorMode.preference = 'system'
}
}
]])
const themeOptions = computed(() => [
{
value: 'light' as const,
label: t('theme.light'),
icon: 'i-lucide-sun'
},
{
value: 'dark' as const,
label: t('theme.dark'),
icon: 'i-lucide-moon'
},
{
value: 'system' as const,
label: t('theme.system'),
icon: 'i-lucide-monitor'
}
])
const localeOptions = computed(() =>
locales.value.map(loc => ({
code: String(loc.code),
label: t(`locale.${loc.code}`)
}))
)
function setThemePreference(value: 'light' | 'dark' | 'system') {
colorMode.preference = value
}
async function setLocalePreference(code: string) {
if (code === 'zh-CN' || code === 'en') {
await setLocale(code)
}
}
return {
themeIcon,
localeMenuItems,
themeMenuItems,
localeOptions,
themeOptions,
setThemePreference,
setLocalePreference
}
}
+36 -15
View File
@@ -26,13 +26,15 @@ export function isUnauthorizedError(error: unknown): boolean {
export const AUTH_USERNAME_PATTERN = /^[a-zA-Z0-9_]{3,32}$/ export const AUTH_USERNAME_PATTERN = /^[a-zA-Z0-9_]{3,32}$/
export const AUTH_MIN_PASSWORD_LENGTH = 8 export const AUTH_MIN_PASSWORD_LENGTH = 8
export function validateAuthInput(username: string, password: string): string | null { export type AuthValidationErrorKey = 'invalidUsername' | 'invalidPassword'
export function validateAuthInput(username: string, password: string): AuthValidationErrorKey | null {
const trimmed = username.trim() const trimmed = username.trim()
if (!AUTH_USERNAME_PATTERN.test(trimmed)) { if (!AUTH_USERNAME_PATTERN.test(trimmed)) {
return '用户名需为 3–32 位字母、数字或下划线' return 'invalidUsername'
} }
if (password.length < AUTH_MIN_PASSWORD_LENGTH) { if (password.length < AUTH_MIN_PASSWORD_LENGTH) {
return '密码至少 8 位' return 'invalidPassword'
} }
return null return null
} }
@@ -65,11 +67,14 @@ export function getFetchErrorMessage(error: unknown, fallback: string): string {
export type AuthActionResult = { ok: true } | { ok: false, error: string } export type AuthActionResult = { ok: true } | { ok: false, error: string }
export function useAuth() { export function useAuth() {
const { t } = useI18n()
const status = useState<AuthStatus>('auth-status', () => 'checking') const status = useState<AuthStatus>('auth-status', () => 'checking')
const user = useState<AuthUser | null>('auth-user', () => null) const user = useState<AuthUser | null>('auth-user', () => null)
const authStatus = useState<AuthStatusResponse | null>('auth-status-response', () => 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 isAuthenticated = computed(() => status.value === 'authenticated')
const isAdmin = computed(() => user.value?.role === 'admin') const isAdmin = computed(() => user.value?.role === 'admin')
@@ -81,10 +86,14 @@ export function useAuth() {
return data return data
} }
async function checkSession() { async function checkSession(options?: { force?: boolean }) {
const silent = !options?.force && hasValidatedOnce.value
if (!silent) {
status.value = 'checking' status.value = 'checking'
}
try { try {
const data = await fetchStatus() const data = await fetchStatus()
hasValidatedOnce.value = true
if (data.user) { if (data.user) {
user.value = data.user user.value = data.user
status.value = 'authenticated' status.value = 'authenticated'
@@ -94,6 +103,7 @@ export function useAuth() {
status.value = 'unauthenticated' status.value = 'unauthenticated'
return false return false
} catch { } catch {
hasValidatedOnce.value = true
user.value = null user.value = null
status.value = 'unauthenticated' status.value = 'unauthenticated'
return false return false
@@ -126,12 +136,13 @@ export function useAuth() {
user.value = result.user user.value = result.user
} }
status.value = 'authenticated' status.value = 'authenticated'
hasValidatedOnce.value = true
await fetchStatus() await fetchStatus()
return { ok: true } return { ok: true }
} catch (error: unknown) { } catch (error: unknown) {
return { return {
ok: false, ok: false,
error: getFetchErrorMessage(error, '登录失败,请检查用户名和密码') error: getFetchErrorMessage(error, t('auth.loginFailed'))
} }
} }
} }
@@ -142,7 +153,7 @@ export function useAuth() {
): Promise<AuthActionResult> { ): Promise<AuthActionResult> {
const validationError = validateAuthInput(username, password) const validationError = validateAuthInput(username, password)
if (validationError) { if (validationError) {
return { ok: false, error: validationError } return { ok: false, error: t(`auth.errors.${validationError}`) }
} }
try { try {
@@ -156,12 +167,13 @@ export function useAuth() {
) )
user.value = result.user user.value = result.user
status.value = 'authenticated' status.value = 'authenticated'
hasValidatedOnce.value = true
await fetchStatus() await fetchStatus()
return { ok: true } return { ok: true }
} catch (error: unknown) { } catch (error: unknown) {
return { return {
ok: false, ok: false,
error: getFetchErrorMessage(error, '注册失败,请检查输入') error: getFetchErrorMessage(error, t('auth.registerFailed'))
} }
} }
} }
@@ -170,10 +182,13 @@ export function useAuth() {
username: string username: string
password: string password: string
allowRegistration: boolean allowRegistration: boolean
domainSeparation?: boolean
siteBaseUrl?: string
imageBaseUrl?: string
}): Promise<AuthActionResult> { }): Promise<AuthActionResult> {
const validationError = validateAuthInput(input.username, input.password) const validationError = validateAuthInput(input.username, input.password)
if (validationError) { if (validationError) {
return { ok: false, error: validationError } return { ok: false, error: t(`auth.errors.${validationError}`) }
} }
try { try {
@@ -185,18 +200,22 @@ export function useAuth() {
body: { body: {
username: input.username.trim(), username: input.username.trim(),
password: input.password, password: input.password,
allowRegistration: input.allowRegistration allowRegistration: input.allowRegistration,
domainSeparation: input.domainSeparation,
siteBaseUrl: input.siteBaseUrl,
imageBaseUrl: input.imageBaseUrl
} }
} }
) )
user.value = result.user user.value = result.user
status.value = 'authenticated' status.value = 'authenticated'
hasValidatedOnce.value = true
await fetchStatus() await fetchStatus()
return { ok: true } return { ok: true }
} catch (error: unknown) { } catch (error: unknown) {
return { return {
ok: false, ok: false,
error: getFetchErrorMessage(error, '初始化失败,请检查输入') error: getFetchErrorMessage(error, t('auth.setupFailed'))
} }
} }
} }
@@ -208,7 +227,7 @@ export function useAuth() {
}): Promise<AuthActionResult> { }): Promise<AuthActionResult> {
const validationError = validateAuthInput(input.username, input.password) const validationError = validateAuthInput(input.username, input.password)
if (validationError) { if (validationError) {
return { ok: false, error: validationError } return { ok: false, error: t(`auth.errors.${validationError}`) }
} }
try { try {
@@ -226,12 +245,13 @@ export function useAuth() {
) )
user.value = result.user user.value = result.user
status.value = 'authenticated' status.value = 'authenticated'
hasValidatedOnce.value = true
await fetchStatus() await fetchStatus()
return { ok: true } return { ok: true }
} catch (error: unknown) { } catch (error: unknown) {
return { return {
ok: false, ok: false,
error: getFetchErrorMessage(error, '迁移失败,请检查输入') error: getFetchErrorMessage(error, t('auth.migrateFailed'))
} }
} }
} }
@@ -241,7 +261,7 @@ export function useAuth() {
newPassword: string newPassword: string
): Promise<AuthActionResult> { ): Promise<AuthActionResult> {
if (newPassword.length < AUTH_MIN_PASSWORD_LENGTH) { if (newPassword.length < AUTH_MIN_PASSWORD_LENGTH) {
return { ok: false, error: '新密码至少 8 位' } return { ok: false, error: t('password.tooShort') }
} }
try { try {
@@ -254,7 +274,7 @@ export function useAuth() {
} catch (error: unknown) { } catch (error: unknown) {
return { return {
ok: false, ok: false,
error: getFetchErrorMessage(error, '修改密码失败') error: getFetchErrorMessage(error, t('password.changeFailed'))
} }
} }
} }
@@ -268,6 +288,7 @@ export function useAuth() {
status.value = 'unauthenticated' status.value = 'unauthenticated'
user.value = null user.value = null
authStatus.value = null authStatus.value = null
hasValidatedOnce.value = true
} }
} }
+50 -5
View File
@@ -1,10 +1,55 @@
export function useFileSize() { export const QUOTA_UNITS = ['KB', 'MB', 'GB', 'TB'] as const
function formatFileSize(bytes: number): string { export type QuotaUnit = typeof QUOTA_UNITS[number]
export function formatFileSize(bytes: number): string {
if (!Number.isFinite(bytes) || bytes < 0) return '-' if (!Number.isFinite(bytes) || bytes < 0) return '-'
if (bytes === 0) return '0 B'
if (bytes < 1024) return `${bytes} B` if (bytes < 1024) return `${bytes} B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
return `${(bytes / (1024 * 1024)).toFixed(2)} MB` 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 }
} }
+91 -11
View File
@@ -6,6 +6,7 @@ import type {
const PAGE_SIZE = 12 const PAGE_SIZE = 12
export function useImageList() { export function useImageList() {
const { t } = useI18n()
const items = ref<ImageItem[]>([]) const items = ref<ImageItem[]>([])
const page = ref(1) const page = ref(1)
const totalPages = ref(1) const totalPages = ref(1)
@@ -16,6 +17,8 @@ export function useImageList() {
const searchQuery = ref('') const searchQuery = ref('')
const activeSearch = ref('') const activeSearch = ref('')
const activeFolder = ref('all') const activeFolder = ref('all')
const activeStorageBackend = ref('all')
const storageBackendOptions = ref<Array<{ id: string, name: string }>>([])
function folderQueryParams(): Record<string, string> { function folderQueryParams(): Record<string, string> {
const folder = activeFolder.value.trim() const folder = activeFolder.value.trim()
@@ -23,18 +26,53 @@ export function useImageList() {
return { folder } return { folder }
} }
function storageQueryParams(): Record<string, string> {
const backendId = activeStorageBackend.value.trim()
if (!backendId || backendId === 'all') return {}
return { backendId }
}
function listQueryParams(): Record<string, string> {
return {
...folderQueryParams(),
...storageQueryParams()
}
}
function matchesActiveFolder(key: string) { function matchesActiveFolder(key: string) {
const folder = activeFolder.value.trim() const folder = activeFolder.value.trim()
if (!folder || folder === 'all') return true if (!folder || folder === 'all') return true
return key.startsWith(`${folder}/`) return key.startsWith(`${folder}/`)
} }
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() { async function fetchTotal() {
loadingTotal.value = true loadingTotal.value = true
try { try {
const data = await $fetch<{ total: number }>('/api/images/count', { const data = await $fetch<{ total: number }>('/api/images/count', {
credentials: 'include', credentials: 'include',
query: folderQueryParams() query: listQueryParams()
}) })
folderTotal.value = data.total folderTotal.value = data.total
} catch { } catch {
@@ -52,7 +90,7 @@ export function useImageList() {
query: { query: {
limit: PAGE_SIZE, limit: PAGE_SIZE,
page: targetPage, page: targetPage,
...folderQueryParams() ...listQueryParams()
} }
}) })
@@ -80,7 +118,7 @@ export function useImageList() {
q, q,
limit: PAGE_SIZE, limit: PAGE_SIZE,
page: targetPage, page: targetPage,
...folderQueryParams() ...listQueryParams()
} }
}) })
@@ -106,7 +144,7 @@ export function useImageList() {
page.value = 1 page.value = 1
activeSearch.value = '' activeSearch.value = ''
searchQuery.value = '' searchQuery.value = ''
await Promise.all([fetchList(1), fetchTotal()]) await Promise.all([fetchList(1), fetchTotal(), loadStorageBackendOptions()])
} }
async function setActiveFolder(folder: string) { async function setActiveFolder(folder: string) {
@@ -121,6 +159,18 @@ export function useImageList() {
} }
} }
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()])
} else {
await Promise.all([fetchList(1), fetchTotal()])
}
}
async function submitSearch() { async function submitSearch() {
const q = searchQuery.value.trim() const q = searchQuery.value.trim()
activeSearch.value = q activeSearch.value = q
@@ -142,7 +192,9 @@ export function useImageList() {
} }
function prependItems(newItems: ImageItem[]) { function prependItems(newItems: ImageItem[]) {
const matched = newItems.filter(item => matchesActiveFolder(item.key)) const matched = newItems.filter(item =>
matchesActiveFolder(item.key) && matchesActiveStorage(item)
)
if (!matched.length || page.value !== 1) return if (!matched.length || page.value !== 1) return
items.value = [...matched, ...items.value] items.value = [...matched, ...items.value]
total.value += matched.length total.value += matched.length
@@ -163,19 +215,42 @@ export function useImageList() {
} }
} }
const activeStorageLabel = computed(() => {
if (activeStorageBackend.value === 'all') return ''
return storageBackendOptions.value.find(
backend => backend.id === activeStorageBackend.value
)?.name ?? activeStorageBackend.value
})
const listSummary = computed(() => { const listSummary = computed(() => {
const folder = activeFolder.value.trim() || 'all' const folder = activeFolder.value.trim() || 'all'
const folderLabel = folder === 'all' ? '全部' : folder const folderLabel = folder === 'all' ? t('stats.allFolders') : folder
const storageLabel = activeStorageLabel.value
if (loadingTotal.value && folderTotal.value === null) { if (loadingTotal.value && folderTotal.value === null) {
return `${folderLabel} · 统计中…` return storageLabel
? `${folderLabel} · ${storageLabel} · ${t('stats.counting')}`
: `${folderLabel} · ${t('stats.counting')}`
} }
if (activeSearch.value) { if (activeSearch.value) {
return `${folderLabel} · 搜索「${activeSearch.value}」· 共 ${total.value} 张` if (storageLabel) {
return t('stats.summarySearchStorage', {
folder: folderLabel,
storage: storageLabel,
q: activeSearch.value,
total: total.value
})
} }
if (folderTotal.value === null) { return t('stats.summarySearch', { folder: folderLabel, q: activeSearch.value, total: total.value })
return `${folderLabel} · 共 ${total.value} 张`
} }
return `${folderLabel} · 共 ${folderTotal.value} 张` const count = folderTotal.value === null ? total.value : folderTotal.value
if (storageLabel) {
return t('stats.summaryTotalStorage', {
folder: folderLabel,
storage: storageLabel,
total: count
})
}
return t('stats.summaryTotal', { folder: folderLabel, total: count })
}) })
return { return {
@@ -185,14 +260,19 @@ export function useImageList() {
total, total,
loading, loading,
activeFolder, activeFolder,
activeStorageBackend,
storageBackendOptions,
searchQuery, searchQuery,
activeSearch, activeSearch,
listSummary, listSummary,
fetchList, fetchList,
fetchSearch,
fetchTotal, fetchTotal,
refreshList, refreshList,
initializeList, initializeList,
setActiveFolder, setActiveFolder,
setActiveStorageBackend,
loadStorageBackendOptions,
submitSearch, submitSearch,
goToPage, goToPage,
prependItems, prependItems,
+6 -5
View File
@@ -9,6 +9,7 @@ const MAX_FILES = 10
export function useImageUpload() { export function useImageUpload() {
const toast = useToast() const toast = useToast()
const { t } = useI18n()
const { compressEnabled, clientWebpQuality } = useUploadPreferences() const { compressEnabled, clientWebpQuality } = useUploadPreferences()
const uploading = ref(false) const uploading = ref(false)
const progressItems = ref<UploadProgressItem[]>([]) const progressItems = ref<UploadProgressItem[]>([])
@@ -22,13 +23,13 @@ export function useImageUpload() {
if (uploading.value) return if (uploading.value) return
if (!files.length) { if (!files.length) {
toast.add({ title: '请选择图片', color: 'warning' }) toast.add({ title: t('upload.selectImages'), color: 'warning' })
return return
} }
if (files.length > MAX_FILES) { if (files.length > MAX_FILES) {
toast.add({ toast.add({
title: `每次最多上传 ${MAX_FILES} 张图片`, title: t('upload.maxFiles', { n: MAX_FILES }),
color: 'warning' color: 'warning'
}) })
return return
@@ -80,14 +81,14 @@ export function useImageUpload() {
if (response.items.length && options?.notify !== false) { if (response.items.length && options?.notify !== false) {
toast.add({ toast.add({
title: `成功上传 ${response.items.length} 张图片`, title: t('upload.uploadSuccess', { n: response.items.length }),
color: 'success' color: 'success'
}) })
} }
if (response.errors.length) { if (response.errors.length) {
toast.add({ toast.add({
title: `${response.errors.length} 张图片上传失败`, title: t('upload.uploadPartialFail', { n: response.errors.length }),
color: 'warning' color: 'warning'
}) })
} }
@@ -97,7 +98,7 @@ export function useImageUpload() {
if (isUnauthorizedError(error)) { if (isUnauthorizedError(error)) {
throw error throw error
} }
const message = error instanceof Error ? error.message : '上传失败' const message = error instanceof Error ? error.message : t('upload.uploadFailed')
progressItems.value = progressItems.value.map(item => ({ progressItems.value = progressItems.value.map(item => ({
...item, ...item,
status: 'error', status: 'error',
+3 -2
View File
@@ -1,6 +1,7 @@
export function useUserAutoDeleteSettings() { export function useUserAutoDeleteSettings() {
const { isAuthenticated, handleAuthError } = useAuth() const { isAuthenticated, handleAuthError } = useAuth()
const toast = useToast() const toast = useToast()
const { t } = useI18n()
const userSettings = ref<{ autoDeleteDays: number } | null>(null) const userSettings = ref<{ autoDeleteDays: number } | null>(null)
const enabled = ref(false) const enabled = ref(false)
@@ -29,7 +30,7 @@ export function useUserAutoDeleteSettings() {
} catch (error: unknown) { } catch (error: unknown) {
handleAuthError(error) handleAuthError(error)
if (isAuthenticated.value) { if (isAuthenticated.value) {
toast.add({ title: '加载设置失败', color: 'error' }) toast.add({ title: t('userSettings.loadFailed'), color: 'error' })
} }
} }
} }
@@ -51,7 +52,7 @@ export function useUserAutoDeleteSettings() {
} catch (error: unknown) { } catch (error: unknown) {
handleAuthError(error) handleAuthError(error)
if (isAuthenticated.value) { if (isAuthenticated.value) {
toast.add({ title: '保存设置失败', color: 'error' }) toast.add({ title: t('userSettings.saveFailed'), color: 'error' })
} }
} finally { } finally {
saving.value = false saving.value = false
+15 -26
View File
@@ -10,6 +10,7 @@ const {
changePassword changePassword
} = useAuth() } = useAuth()
const toast = useToast() const toast = useToast()
const { t } = useI18n()
const currentPassword = ref('') const currentPassword = ref('')
const newPassword = ref('') const newPassword = ref('')
@@ -37,7 +38,7 @@ async function submit() {
if (!canSubmit.value || loading.value) return if (!canSubmit.value || loading.value) return
if (newPassword.value !== confirmPassword.value) { if (newPassword.value !== confirmPassword.value) {
toast.add({ title: '两次输入的新密码不一致', color: 'error' }) toast.add({ title: t('auth.passwordMismatch'), color: 'error' })
return return
} }
@@ -48,7 +49,7 @@ async function submit() {
currentPassword.value = '' currentPassword.value = ''
newPassword.value = '' newPassword.value = ''
confirmPassword.value = '' confirmPassword.value = ''
toast.add({ title: '密码已修改', color: 'success' }) toast.add({ title: t('password.changed'), color: 'success' })
await router.replace('/') await router.replace('/')
} else { } else {
toast.add({ title: result.error, color: 'error' }) toast.add({ title: result.error, color: 'error' })
@@ -71,7 +72,7 @@ async function submit() {
class="size-8 animate-spin" class="size-8 animate-spin"
/> />
<p class="text-sm"> <p class="text-sm">
正在验证登录状态… {{ t('common.loadingSession') }}
</p> </p>
</div> </div>
</div> </div>
@@ -82,10 +83,10 @@ async function submit() {
<section class="mx-auto max-w-md overflow-hidden rounded-2xl border border-default bg-elevated shadow-sm"> <section class="mx-auto max-w-md overflow-hidden rounded-2xl border border-default bg-elevated shadow-sm">
<div class="border-b border-default px-5 py-4 sm:px-6"> <div class="border-b border-default px-5 py-4 sm:px-6">
<h1 class="text-lg font-semibold tracking-tight"> <h1 class="text-lg font-semibold tracking-tight">
修改密码 {{ t('password.title') }}
</h1> </h1>
<p class="mt-1 text-sm text-muted"> <p class="mt-1 text-sm text-muted">
修改您的登录密码 {{ t('password.subtitle') }}
</p> </p>
</div> </div>
@@ -98,16 +99,12 @@ async function submit() {
for="current-password" for="current-password"
class="block text-sm font-medium" class="block text-sm font-medium"
> >
当前密码 {{ t('password.current') }}
</label> </label>
<UInput <PasswordInput
id="current-password" id="current-password"
v-model="currentPassword" v-model="currentPassword"
type="password"
autocomplete="current-password" autocomplete="current-password"
size="lg"
class="w-full"
:ui="{ root: 'w-full' }"
/> />
</div> </div>
@@ -116,17 +113,13 @@ async function submit() {
for="new-password" for="new-password"
class="block text-sm font-medium" class="block text-sm font-medium"
> >
新密码 {{ t('password.new') }}
</label> </label>
<UInput <PasswordInput
id="new-password" id="new-password"
v-model="newPassword" v-model="newPassword"
type="password" :placeholder="t('password.newPlaceholder')"
placeholder="设置新密码"
autocomplete="new-password" autocomplete="new-password"
size="lg"
class="w-full"
:ui="{ root: 'w-full' }"
/> />
<PasswordStrength :password="newPassword" /> <PasswordStrength :password="newPassword" />
</div> </div>
@@ -136,29 +129,25 @@ async function submit() {
for="confirm-new-password" for="confirm-new-password"
class="block text-sm font-medium" class="block text-sm font-medium"
> >
确认新密码 {{ t('password.confirmNew') }}
</label> </label>
<UInput <PasswordInput
id="confirm-new-password" id="confirm-new-password"
v-model="confirmPassword" v-model="confirmPassword"
type="password"
autocomplete="new-password" autocomplete="new-password"
size="lg"
class="w-full"
:ui="{ root: 'w-full' }"
/> />
</div> </div>
<div class="flex gap-2 pt-1"> <div class="flex gap-2 pt-1">
<UButton <UButton
type="submit" type="submit"
label="保存" :label="t('common.save')"
icon="i-lucide-save" icon="i-lucide-save"
:loading="loading" :loading="loading"
:disabled="!canSubmit" :disabled="!canSubmit"
/> />
<UButton <UButton
label="取消" :label="t('common.cancel')"
variant="ghost" variant="ghost"
color="neutral" color="neutral"
to="/" to="/"
+59 -52
View File
@@ -6,6 +6,7 @@ interface SettingsResponse {
env: { env: {
webpQuality: number webpQuality: number
refererConfigured: boolean refererConfigured: boolean
siteBaseUrl: string
imageBaseUrl: string imageBaseUrl: string
appVersion: string appVersion: string
} }
@@ -22,6 +23,7 @@ interface ApiDocItem {
const { isChecking, isAuthenticated, checkSession, handleAuthError, fetchStatus, isAdmin } = useAuth() const { isChecking, isAuthenticated, checkSession, handleAuthError, fetchStatus, isAdmin } = useAuth()
const toast = useToast() const toast = useToast()
const { t } = useI18n()
const settings = ref<SettingsResponse | null>(null) const settings = ref<SettingsResponse | null>(null)
const loading = ref(false) const loading = ref(false)
@@ -31,6 +33,8 @@ const regenerateResultOpen = ref(false)
const newToken = ref('') const newToken = ref('')
const baseUrl = computed(() => { const baseUrl = computed(() => {
const configured = settings.value?.env.siteBaseUrl
if (configured) return configured
if (import.meta.client) { if (import.meta.client) {
return window.location.origin return window.location.origin
} }
@@ -49,14 +53,14 @@ const authHeaderFlag = computed(() => `-H 'Auth-Token: ${authHeader.value}'`)
const apiDocs = computed<ApiDocItem[]>(() => { const apiDocs = computed<ApiDocItem[]>(() => {
const uploadDescription = isAdmin.value const uploadDescription = isAdmin.value
? '上传单张或多张图片。字段 image 传图片,folder 指定目录(不存在则自动创建);仍兼容 file、files、type。' ? t('api.docs.upload.descAdmin')
: '上传单张或多张图片。字段 image 传图片;仍兼容 file、files。图片固定存入 images/ 目录。' : t('api.docs.upload.descUser')
const uploadCurl = isAdmin.value const uploadCurl = isAdmin.value
? `curl -X POST "${baseUrl.value}/api/images/upload" \\ ? `curl -X POST "${baseUrl.value}/api/images/upload" \\
${authHeaderFlag.value} \\ ${authHeaderFlag.value} \\
-F "image=@./demo.png" -F "image=@./demo.png"
# 上传到自定义目录 blog/ ${t('api.docs.upload.curlCustomFolder')}
curl -X POST "${baseUrl.value}/api/images/upload" \\ curl -X POST "${baseUrl.value}/api/images/upload" \\
${authHeaderFlag.value} \\ ${authHeaderFlag.value} \\
-F "folder=blog" \\ -F "folder=blog" \\
@@ -66,13 +70,13 @@ curl -X POST "${baseUrl.value}/api/images/upload" \\
-F "image=@./demo.png"` -F "image=@./demo.png"`
const listDescription = isAdmin.value const listDescription = isAdmin.value
? '分页列出图库,folder 可筛选目录,limit 默认 20、最大 100。' ? t('api.docs.list.descAdmin')
: '分页列出您的图库,limit 默认 20、最大 100。' : t('api.docs.list.descUser')
const listCurl = isAdmin.value const listCurl = isAdmin.value
? `curl "${baseUrl.value}/api/images?limit=20&page=1" \\ ? `curl "${baseUrl.value}/api/images?limit=20&page=1" \\
${authHeaderFlag.value} ${authHeaderFlag.value}
# 按目录筛选 ${t('api.docs.list.curlFolderFilter')}
curl "${baseUrl.value}/api/images?folder=blog&limit=20&page=1" \\ curl "${baseUrl.value}/api/images?folder=blog&limit=20&page=1" \\
${authHeaderFlag.value}` ${authHeaderFlag.value}`
: `curl "${baseUrl.value}/api/images?limit=20&page=1" \\ : `curl "${baseUrl.value}/api/images?limit=20&page=1" \\
@@ -83,7 +87,7 @@ curl "${baseUrl.value}/api/images?folder=blog&limit=20&page=1" \\
index: 1, index: 1,
method: 'POST', method: 'POST',
path: '/api/images/upload', path: '/api/images/upload',
title: '上传图片', title: t('api.docs.upload.title'),
description: uploadDescription, description: uploadDescription,
curl: uploadCurl curl: uploadCurl
}, },
@@ -91,7 +95,7 @@ curl "${baseUrl.value}/api/images?folder=blog&limit=20&page=1" \\
index: 2, index: 2,
method: 'GET', method: 'GET',
path: '/api/images', path: '/api/images',
title: '获取图片列表', title: t('api.docs.list.title'),
description: listDescription, description: listDescription,
curl: listCurl curl: listCurl
}, },
@@ -99,8 +103,8 @@ curl "${baseUrl.value}/api/images?folder=blog&limit=20&page=1" \\
index: 3, index: 3,
method: 'GET', method: 'GET',
path: '/api/images/search', path: '/api/images/search',
title: '搜索图片', title: t('api.docs.search.title'),
description: '按文件名或路径关键词搜索,参数 q 必填。', description: t('api.docs.search.desc'),
curl: `curl "${baseUrl.value}/api/images/search?q=demo&limit=20&page=1" \\ curl: `curl "${baseUrl.value}/api/images/search?q=demo&limit=20&page=1" \\
${authHeaderFlag.value}` ${authHeaderFlag.value}`
}, },
@@ -108,8 +112,8 @@ curl "${baseUrl.value}/api/images?folder=blog&limit=20&page=1" \\
index: 4, index: 4,
method: 'DELETE', method: 'DELETE',
path: '/api/images', path: '/api/images',
title: '删除图片', title: t('api.docs.delete.title'),
description: '通过 key 删除单张图片,key 为存储路径(如 images/2026/08/xxx.webp)。', description: t('api.docs.delete.desc'),
curl: `curl -X DELETE "${baseUrl.value}/api/images?key=images/2026/08/xxxx.webp" \\ curl: `curl -X DELETE "${baseUrl.value}/api/images?key=images/2026/08/xxxx.webp" \\
${authHeaderFlag.value}` ${authHeaderFlag.value}`
}, },
@@ -117,8 +121,8 @@ curl "${baseUrl.value}/api/images?folder=blog&limit=20&page=1" \\
index: 5, index: 5,
method: 'POST', method: 'POST',
path: '/api/images/batch-delete', path: '/api/images/batch-delete',
title: '批量删除', title: t('api.docs.batchDelete.title'),
description: '一次删除多张图片,请求体为 keys 数组。', description: t('api.docs.batchDelete.desc'),
curl: `curl -X POST "${baseUrl.value}/api/images/batch-delete" \\ curl: `curl -X POST "${baseUrl.value}/api/images/batch-delete" \\
${authHeaderFlag.value} \\ ${authHeaderFlag.value} \\
-H "Content-Type: application/json" \\ -H "Content-Type: application/json" \\
@@ -130,8 +134,8 @@ curl "${baseUrl.value}/api/images?folder=blog&limit=20&page=1" \\
const twikooDoc = computed(() => ({ const twikooDoc = computed(() => ({
method: 'POST' as const, method: 'POST' as const,
path: '/api/index.php', path: '/api/index.php',
title: 'twikoo 兼容上传', title: t('api.docs.twikoo.title'),
description: '兼容 Twikoo / EasyImage 2.0 协议,图片存入 twikoo/ 目录;字段 image + token。', description: t('api.docs.twikoo.desc'),
curl: `curl -X POST "${baseUrl.value}/api/index.php" \\ curl: `curl -X POST "${baseUrl.value}/api/index.php" \\
-F "token=${authHeader.value}" \\ -F "token=${authHeader.value}" \\
-F "image=@./demo.png"` -F "image=@./demo.png"`
@@ -140,11 +144,11 @@ const twikooDoc = computed(() => ({
function tokenSourceLabel(source: SettingsResponse['tokenSource']) { function tokenSourceLabel(source: SettingsResponse['tokenSource']) {
switch (source) { switch (source) {
case 'env': case 'env':
return '来源:环境变量 API_UPLOAD_TOKEN' return t('api.tokenSourceEnv')
case 'db': case 'db':
return '来源:后台生成(可重新生成)' return t('api.tokenSourceDb')
default: default:
return '来源:未配置' return t('api.tokenSourceNone')
} }
} }
@@ -169,7 +173,7 @@ async function loadSettings() {
} catch (error: unknown) { } catch (error: unknown) {
handleAuthError(error) handleAuthError(error)
if (isAuthenticated.value) { if (isAuthenticated.value) {
toast.add({ title: '加载 API 设置失败', color: 'error' }) toast.add({ title: t('api.loadFailed'), color: 'error' })
} }
} finally { } finally {
loading.value = false loading.value = false
@@ -203,11 +207,11 @@ async function confirmRegenerate() {
: 0 : 0
if (status === 409) { if (status === 409) {
toast.add({ toast.add({
title: '环境变量已配置 Token,无法在后台重新生成', title: t('api.regenerateEnvBlocked'),
color: 'warning' color: 'warning'
}) })
} else { } else {
toast.add({ title: '重新生成 Token 失败', color: 'error' }) toast.add({ title: t('api.regenerateFailed'), color: 'error' })
} }
} }
} finally { } finally {
@@ -218,9 +222,9 @@ async function confirmRegenerate() {
async function copyCurl(text: string) { async function copyCurl(text: string) {
try { try {
await navigator.clipboard.writeText(text) await navigator.clipboard.writeText(text)
toast.add({ title: '已复制 cURL 示例', color: 'success' }) toast.add({ title: t('copy.curl'), color: 'success' })
} catch { } catch {
toast.add({ title: '复制失败', color: 'error' }) toast.add({ title: t('copy.failed'), color: 'error' })
} }
} }
@@ -239,10 +243,13 @@ onMounted(async () => {
return return
} }
await checkSession() await checkSession()
if (isAuthenticated.value) {
await loadSettings()
}
}) })
watch(isAuthenticated, async (authed) => { watch(isAuthenticated, async (authed, prev) => {
if (authed) { if (authed && prev === false) {
await nextTick() await nextTick()
await loadSettings() await loadSettings()
} else { } else {
@@ -263,7 +270,7 @@ watch(isAuthenticated, async (authed) => {
class="size-8 animate-spin" class="size-8 animate-spin"
/> />
<p class="text-sm"> <p class="text-sm">
正在验证登录状态… {{ t('common.loadingSession') }}
</p> </p>
</div> </div>
</div> </div>
@@ -278,7 +285,7 @@ watch(isAuthenticated, async (authed) => {
class="size-5 text-primary" class="size-5 text-primary"
/> />
<h2 class="text-base font-semibold"> <h2 class="text-base font-semibold">
API Token {{ t('api.tokenTitle') }}
</h2> </h2>
</div> </div>
@@ -288,8 +295,8 @@ watch(isAuthenticated, async (authed) => {
color="warning" color="warning"
variant="subtle" variant="subtle"
icon="i-lucide-triangle-alert" icon="i-lucide-triangle-alert"
title="Token 由环境变量覆盖" :title="t('api.envOverrideTitle')"
description="修改 API_UPLOAD_TOKEN 后需重启服务;后台无法重新生成。" :description="t('api.envOverrideDesc')"
/> />
<UAlert <UAlert
@@ -297,8 +304,8 @@ watch(isAuthenticated, async (authed) => {
color="info" color="info"
variant="subtle" variant="subtle"
icon="i-lucide-info" icon="i-lucide-info"
title="个人 API Token" :title="t('api.personalTitle')"
description="此 Token 仅用于您自己的脚本上传,上传的图片归属您的账号。" :description="t('api.personalDesc')"
/> />
<UAlert <UAlert
@@ -306,25 +313,25 @@ watch(isAuthenticated, async (authed) => {
color="info" color="info"
variant="subtle" variant="subtle"
icon="i-lucide-info" icon="i-lucide-info"
title="尚未配置 Token" :title="t('api.notConfiguredTitle')"
description="点击「重新生成」创建 Token,用于下方所有接口的 Auth-Token 鉴权。" :description="t('api.notConfiguredDesc')"
/> />
<div class="flex flex-col gap-2 sm:flex-row sm:items-stretch"> <div class="flex flex-col gap-2 sm:flex-row sm:items-stretch">
<UInput <UInput
:model-value="hasToken ? tokenDisplay : '(未配置)'" :model-value="hasToken ? tokenDisplay : t('common.notConfigured')"
readonly readonly
class="min-w-0 flex-1 font-mono text-xs sm:text-sm" class="min-w-0 flex-1 font-mono text-xs sm:text-sm"
:loading="loading" :loading="loading"
/> />
<div class="flex gap-2"> <div class="flex gap-2">
<CopyButton <CopyButton
label="复制" :label="t('common.copy')"
:value="tokenDisplay" :value="tokenDisplay"
/> />
<UButton <UButton
icon="i-lucide-refresh-cw" icon="i-lucide-refresh-cw"
label="重新生成" :label="t('api.regenerate')"
color="warning" color="warning"
variant="soft" variant="soft"
:loading="regenerating" :loading="regenerating"
@@ -339,10 +346,10 @@ watch(isAuthenticated, async (authed) => {
class="text-xs text-muted" class="text-xs text-muted"
> >
{{ tokenSourceLabel(settings.tokenSource) }} {{ tokenSourceLabel(settings.tokenSource) }}
· 请求头: · {{ t('api.tokenHeader') }}
<code class="font-mono">Auth-Token: &lt;token&gt;</code> <code class="font-mono">Auth-Token: &lt;token&gt;</code>
<template v-if="hasToken && !settings.envTokenOverride"> <template v-if="hasToken && !settings.envTokenOverride">
· 重新生成后旧 Token 立即失效 · {{ t('api.tokenInvalidate') }}
</template> </template>
</p> </p>
</div> </div>
@@ -355,7 +362,7 @@ watch(isAuthenticated, async (authed) => {
class="size-5 text-primary" class="size-5 text-primary"
/> />
<h2 class="text-base font-semibold"> <h2 class="text-base font-semibold">
API 文档 {{ t('api.docsTitle') }}
</h2> </h2>
</div> </div>
@@ -395,12 +402,12 @@ watch(isAuthenticated, async (authed) => {
<div> <div>
<p class="mb-2 text-xs font-medium text-muted"> <p class="mb-2 text-xs font-medium text-muted">
cURL 示例 {{ t('api.curlExample') }}
</p> </p>
<div class="relative overflow-hidden rounded-xl bg-neutral-950"> <div class="relative overflow-hidden rounded-xl bg-neutral-950">
<UButton <UButton
icon="i-lucide-copy" icon="i-lucide-copy"
label="复制" :label="t('common.copy')"
size="xs" size="xs"
variant="ghost" variant="ghost"
color="neutral" color="neutral"
@@ -445,12 +452,12 @@ watch(isAuthenticated, async (authed) => {
<div> <div>
<p class="mb-2 text-xs font-medium text-muted"> <p class="mb-2 text-xs font-medium text-muted">
cURL 示例 {{ t('api.curlExample') }}
</p> </p>
<div class="relative overflow-hidden rounded-xl bg-neutral-950"> <div class="relative overflow-hidden rounded-xl bg-neutral-950">
<UButton <UButton
icon="i-lucide-copy" icon="i-lucide-copy"
label="复制" :label="t('common.copy')"
size="xs" size="xs"
variant="ghost" variant="ghost"
color="neutral" color="neutral"
@@ -466,20 +473,20 @@ watch(isAuthenticated, async (authed) => {
<UModal <UModal
:open="confirmRegenerateOpen" :open="confirmRegenerateOpen"
title="确认重新生成 Token?" :title="t('api.confirmRegenerateTitle')"
description="旧 Token 将立即失效,使用旧 Token 的脚本与自动化任务将无法继续调用接口,直到你更新为新 Token。" :description="t('api.confirmRegenerateDesc')"
@update:open="confirmRegenerateOpen = $event" @update:open="confirmRegenerateOpen = $event"
> >
<template #footer> <template #footer>
<div class="flex justify-end gap-2"> <div class="flex justify-end gap-2">
<UButton <UButton
label="取消" :label="t('common.cancel')"
color="neutral" color="neutral"
variant="outline" variant="outline"
@click="closeConfirmRegenerate" @click="closeConfirmRegenerate"
/> />
<UButton <UButton
label="确认重新生成" :label="t('api.confirmRegenerate')"
color="warning" color="warning"
:loading="regenerating" :loading="regenerating"
@click="confirmRegenerate" @click="confirmRegenerate"
@@ -490,8 +497,8 @@ watch(isAuthenticated, async (authed) => {
<UModal <UModal
:open="regenerateResultOpen" :open="regenerateResultOpen"
title="新 Token 已生成" :title="t('api.newTokenTitle')"
description="请立即复制保存。关闭弹窗后仍可在上方输入框复制,但请尽快更新到你的脚本配置中。" :description="t('api.newTokenDesc')"
@update:open="regenerateResultOpen = $event" @update:open="regenerateResultOpen = $event"
> >
<template #body> <template #body>
@@ -504,11 +511,11 @@ watch(isAuthenticated, async (authed) => {
<template #footer> <template #footer>
<div class="flex w-full flex-wrap justify-end gap-2"> <div class="flex w-full flex-wrap justify-end gap-2">
<CopyButton <CopyButton
label="复制 Token" :label="t('api.copyToken')"
:value="newToken" :value="newToken"
/> />
<UButton <UButton
label="我已保存" :label="t('api.saved')"
@click="closeRegenerateResult" @click="closeRegenerateResult"
/> />
</div> </div>
+12 -11
View File
@@ -13,6 +13,7 @@ const {
const { isChecking, isAuthenticated, checkSession, handleAuthError, fetchStatus, isAdmin } const { isChecking, isAuthenticated, checkSession, handleAuthError, fetchStatus, isAdmin }
= useAuth() = useAuth()
const toast = useToast() const toast = useToast()
const { t } = useI18n()
const folderOptions = ref<string[]>(['images']) const folderOptions = ref<string[]>(['images'])
const sessionItems = ref<ImageItem[]>([]) const sessionItems = ref<ImageItem[]>([])
const deletingKeys = ref<Set<string>>(new Set()) const deletingKeys = ref<Set<string>>(new Set())
@@ -76,11 +77,11 @@ watch(isAuthenticated, async (authed, prev) => {
function copyFormatLabel() { function copyFormatLabel() {
switch (copyFormat.value) { switch (copyFormat.value) {
case 'url': case 'url':
return '直链' return t('copy.url')
case 'html': case 'html':
return 'HTML' return t('copy.html')
default: default:
return 'Markdown' return t('copy.markdown')
} }
} }
@@ -98,15 +99,15 @@ async function copyUploadLink(text: string) {
document.execCommand('copy') document.execCommand('copy')
document.body.removeChild(textarea) document.body.removeChild(textarea)
} }
toast.add({ title: `已复制 ${copyFormatLabel()}`, color: 'success' }) toast.add({ title: t('copy.copiedFormat', { format: copyFormatLabel() }), color: 'success' })
} catch { } catch {
toast.add({ title: '复制失败', color: 'error' }) toast.add({ title: t('copy.failed'), color: 'error' })
} }
} }
async function handleUpload(files: File[]) { async function handleUpload(files: File[]) {
if (!isAuthenticated.value) { if (!isAuthenticated.value) {
toast.add({ title: '请先登录', color: 'warning' }) toast.add({ title: t('upload.loginRequired'), color: 'warning' })
return return
} }
@@ -149,10 +150,10 @@ async function handleDelete(key: string) {
query: { key } query: { key }
}) })
sessionItems.value = sessionItems.value.filter(item => item.key !== key) sessionItems.value = sessionItems.value.filter(item => item.key !== key)
toast.add({ title: '已删除', color: 'success' }) toast.add({ title: t('stats.deletedSingle'), color: 'success' })
} catch (error: unknown) { } catch (error: unknown) {
handleAuthError(error) handleAuthError(error)
toast.add({ title: '删除失败', color: 'error' }) toast.add({ title: t('stats.deleteFailed'), color: 'error' })
} finally { } finally {
deletingKeys.value.delete(key) deletingKeys.value.delete(key)
deletingKeys.value = new Set(deletingKeys.value) deletingKeys.value = new Set(deletingKeys.value)
@@ -172,7 +173,7 @@ async function handleDelete(key: string) {
class="size-8 animate-spin" class="size-8 animate-spin"
/> />
<p class="text-sm"> <p class="text-sm">
正在验证登录状态… {{ t('common.loadingSession') }}
</p> </p>
</div> </div>
</div> </div>
@@ -196,7 +197,7 @@ async function handleDelete(key: string) {
> >
<div class="flex items-center justify-between gap-2"> <div class="flex items-center justify-between gap-2">
<h2 class="text-lg font-medium"> <h2 class="text-lg font-medium">
本次上传 {{ t('upload.sessionTitle') }}
<span class="ml-1 text-sm font-normal text-muted">({{ sessionItems.length }})</span> <span class="ml-1 text-sm font-normal text-muted">({{ sessionItems.length }})</span>
</h2> </h2>
</div> </div>
@@ -207,7 +208,7 @@ async function handleDelete(key: string) {
:deleting-keys="deletingKeys" :deleting-keys="deletingKeys"
:selectable="false" :selectable="false"
:show-key="false" :show-key="false"
empty-text="暂无本次上传" :empty-text="t('upload.sessionEmpty')"
@update:selected-keys="() => {}" @update:selected-keys="() => {}"
@delete="handleDelete" @delete="handleDelete"
/> />
+621
View File
@@ -0,0 +1,621 @@
<script setup lang="ts">
import type { ActivityLogListResponse, ActivityLogStorage, LogAction, LogSource } from '~/types/logs'
const { isChecking, isAuthenticated, checkSession, handleAuthError, fetchStatus, isAdmin } = useAuth()
const toast = useToast()
const { t, locale } = useI18n()
const { formatFileSize } = useFileSize()
const loading = ref(true)
const page = ref(1)
const actionFilter = ref<'all' | LogAction>('all')
const sourceFilter = ref<'all' | LogSource>('all')
const userFilter = ref<'all' | number>('all')
const searchQuery = ref('')
const activeSearch = ref('')
const listData = ref<ActivityLogListResponse | null>(null)
const imageLinkBase = ref('')
const hideFolderInUrl = ref(false)
const pageSize = 10
const summaryLabel = computed(() => {
const summary = listData.value?.summary
if (!summary) return ''
return t('logs.summaryAll', {
total: summary.total,
upload: summary.upload,
delete: summary.delete
})
})
const actionItems = computed(() => [
{ label: t('logs.filterActionAll'), value: 'all' as const },
{ label: t('logs.actionUpload'), value: 'upload' as const },
{ label: t('logs.actionDelete'), value: 'delete' as const }
])
const sourceItems = computed(() => [
{ label: t('logs.filterSourceAll'), value: 'all' as const },
{ label: t('logs.sourceWeb'), value: 'web' as const },
{ label: t('logs.sourceApi'), value: 'api' as const },
{ label: t('logs.sourceTwikoo'), value: 'twikoo' as const }
])
const userItems = computed(() => {
const items: Array<{ label: string, value: 'all' | number }> = [
{ label: t('logs.filterUserAll'), value: 'all' }
]
for (const user of listData.value?.users ?? []) {
items.push({ label: user.username, value: user.id })
}
return items
})
function formatTime(value: string) {
try {
return new Date(value).toLocaleString(locale.value, { hour12: false })
} catch {
return value
}
}
function formatTimeShort(value: string) {
try {
return new Date(value).toLocaleString(locale.value, {
month: 'numeric',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
hour12: false
})
} catch {
return value
}
}
function folderFromKey(key: string) {
const slash = key.indexOf('/')
return slash > 0 ? key.slice(0, slash) : '(root)'
}
function actionColor(action: LogAction) {
return action === 'upload' ? 'success' : 'error'
}
function actionIcon(action: LogAction) {
return action === 'upload' ? 'i-lucide-upload' : 'i-lucide-trash-2'
}
function sourceLabel(source: LogSource) {
switch (source) {
case 'web':
return t('logs.sourceWeb')
case 'api':
return t('logs.sourceApi')
case 'twikoo':
return t('logs.sourceTwikoo')
}
}
function storageIcon(storage: ActivityLogStorage | null) {
return storage?.type === 'local' ? 'i-lucide-hard-drive' : 'i-lucide-cloud'
}
function publicPathFromKey(key: string) {
if (!hideFolderInUrl.value) return key
const slash = key.lastIndexOf('/')
return slash >= 0 ? key.slice(slash + 1) : key
}
function imageUrl(key: string) {
const base = imageLinkBase.value.replace(/\/$/, '')
const path = publicPathFromKey(key)
if (!base) return `/${path}`
return `${base}/${path}`
}
async function loadImageLinkBase() {
try {
if (isAdmin.value) {
const data = await $fetch<{ imageBaseUrl: string, hideFolderInUrl: boolean }>('/api/settings', {
credentials: 'include'
})
imageLinkBase.value = data.imageBaseUrl
hideFolderInUrl.value = data.hideFolderInUrl
return
}
const data = await $fetch<{ env: { imageBaseUrl: string, hideFolderInUrl: boolean } }>('/api/user/api-token', {
credentials: 'include'
})
imageLinkBase.value = data.env.imageBaseUrl
hideFolderInUrl.value = data.env.hideFolderInUrl
} catch {
if (import.meta.client) {
imageLinkBase.value = window.location.origin
}
}
}
async function loadLogs() {
loading.value = true
try {
const query: Record<string, string | number> = {
page: page.value,
limit: pageSize
}
if (actionFilter.value !== 'all') {
query.action = actionFilter.value
}
if (sourceFilter.value !== 'all') {
query.source = sourceFilter.value
}
if (isAdmin.value && userFilter.value !== 'all') {
query.userId = userFilter.value
}
if (activeSearch.value) {
query.q = activeSearch.value
}
listData.value = await $fetch<ActivityLogListResponse>('/api/logs', {
credentials: 'include',
query
})
page.value = listData.value.page
} catch (error: unknown) {
handleAuthError(error)
if (isAuthenticated.value) {
toast.add({ title: t('logs.loadFailed'), color: 'error' })
}
} finally {
loading.value = false
}
}
function applyFilters() {
page.value = 1
void loadLogs()
}
function submitSearch() {
activeSearch.value = searchQuery.value.trim()
applyFilters()
}
function clearSearch() {
searchQuery.value = ''
activeSearch.value = ''
applyFilters()
}
function handlePageChange(target: number) {
page.value = target
void loadLogs()
}
onMounted(async () => {
const status = await fetchStatus()
if (!status.initialized && !status.legacyMode) {
await navigateTo('/setup')
return
}
await checkSession()
if (!isAuthenticated.value) {
await navigateTo('/')
return
}
await loadImageLinkBase()
await loadLogs()
})
watch(isAuthenticated, async (authed, prev) => {
if (authed && prev === false) {
await loadImageLinkBase()
await loadLogs()
}
})
</script>
<template>
<div class="min-h-screen">
<div
v-if="isChecking"
class="flex min-h-screen items-center justify-center"
>
<div class="flex flex-col items-center gap-3 text-muted">
<UIcon
name="i-lucide-loader-circle"
class="size-8 animate-spin"
/>
<p class="text-sm">
{{ t('common.loadingSession') }}
</p>
</div>
</div>
<AdminLoginGate v-else-if="!isAuthenticated" />
<AppShell v-else>
<section class="min-w-0 overflow-x-hidden rounded-2xl border border-default bg-elevated shadow-sm">
<div class="flex flex-wrap items-start justify-between gap-3 border-b border-default px-4 py-4 sm:px-6">
<div class="flex items-start gap-2">
<UIcon
name="i-lucide-scroll-text"
class="mt-0.5 size-5 shrink-0 text-primary"
/>
<div>
<h1 class="text-base font-semibold">
{{ t('logs.pageTitle') }}
</h1>
<p class="mt-0.5 text-xs text-muted">
{{ isAdmin ? t('logs.pageSubtitleAdmin') : t('logs.pageSubtitle') }}
</p>
</div>
</div>
<UButton
icon="i-lucide-refresh-cw"
variant="ghost"
color="neutral"
size="sm"
:aria-label="t('common.refresh')"
:loading="loading"
@click="loadLogs"
/>
</div>
<form
class="border-b border-default px-4 py-3 sm:px-6"
@submit.prevent="submitSearch"
>
<div class="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<p
v-if="summaryLabel"
class="text-xs text-muted"
>
{{ summaryLabel }}
</p>
<p
v-else
class="text-xs text-muted"
>
{{ t('stats.counting') }}
</p>
<div class="flex min-w-0 flex-wrap items-center gap-2 sm:justify-end">
<USelect
v-if="isAdmin"
v-model="userFilter"
:items="userItems"
value-key="value"
class="min-w-0 w-full sm:w-36"
size="sm"
@update:model-value="applyFilters"
/>
<USelect
v-model="actionFilter"
:items="actionItems"
value-key="value"
class="min-w-0 w-[calc(50%-0.25rem)] sm:w-32"
size="sm"
@update:model-value="applyFilters"
/>
<USelect
v-model="sourceFilter"
:items="sourceItems"
value-key="value"
class="min-w-0 w-[calc(50%-0.25rem)] sm:w-36"
size="sm"
@update:model-value="applyFilters"
/>
<div class="flex min-w-0 w-full items-center gap-2 sm:w-auto">
<UInput
v-model="searchQuery"
icon="i-lucide-search"
:placeholder="t('logs.searchPlaceholder')"
class="min-w-0 flex-1 sm:w-40 sm:flex-none"
size="sm"
:disabled="loading"
/>
<UButton
type="submit"
icon="i-lucide-search"
size="sm"
class="shrink-0"
:aria-label="t('common.search')"
:loading="loading"
>
<span class="hidden sm:inline">{{ t('common.search') }}</span>
</UButton>
<UButton
v-if="activeSearch"
type="button"
icon="i-lucide-x"
size="sm"
variant="ghost"
color="neutral"
class="shrink-0"
:aria-label="t('stats.clear')"
@click="clearSearch"
>
<span class="hidden sm:inline">{{ t('stats.clear') }}</span>
</UButton>
</div>
</div>
</div>
</form>
<div
v-if="loading"
class="p-4 sm:p-6"
>
<div class="space-y-3 sm:hidden">
<USkeleton
v-for="n in 6"
:key="`mobile-${n}`"
class="h-24 rounded-lg"
/>
</div>
<div class="hidden space-y-2 sm:block">
<USkeleton
v-for="n in 8"
:key="`desktop-${n}`"
class="h-12 rounded-lg"
/>
</div>
</div>
<template v-else-if="listData && listData.items.length">
<div class="min-w-0 sm:hidden">
<div class="divide-y divide-default">
<article
v-for="row in listData.items"
:key="row.id"
class="min-w-0 px-4 py-3.5"
>
<div class="flex items-start justify-between gap-3">
<time class="text-xs text-muted">
{{ formatTimeShort(row.createdAt) }}
</time>
<span class="shrink-0 text-xs tabular-nums text-muted">
{{ formatFileSize(row.size) }}
</span>
</div>
<div class="mt-2 flex min-w-0 max-w-full flex-wrap items-center gap-1.5">
<UBadge
:color="actionColor(row.action)"
variant="subtle"
size="xs"
>
<UIcon
:name="actionIcon(row.action)"
class="mr-1 size-3"
/>
{{ row.action === 'upload' ? t('logs.actionUpload') : t('logs.actionDelete') }}
</UBadge>
<UBadge
color="neutral"
variant="subtle"
size="xs"
>
{{ sourceLabel(row.source) }}
</UBadge>
<span
v-if="isAdmin"
class="text-xs text-muted"
>
{{ row.username ?? '—' }}
</span>
<UBadge
v-if="row.storage"
color="neutral"
variant="subtle"
size="xs"
class="max-w-full truncate"
>
<UIcon
:name="storageIcon(row.storage)"
class="mr-1 size-3"
/>
{{ row.storage.name }}
</UBadge>
</div>
<p
class="mt-2 truncate text-sm"
:title="row.originalName"
>
{{ row.originalName }}
</p>
<div class="mt-2 flex min-w-0 items-center gap-1">
<a
v-if="row.action === 'upload'"
:href="imageUrl(row.key)"
target="_blank"
rel="noopener noreferrer"
class="min-w-0 flex-1 truncate font-mono text-xs text-primary hover:underline"
:title="row.key"
>
{{ row.key }}
</a>
<span
v-else
class="min-w-0 flex-1 truncate font-mono text-xs text-dimmed"
:title="row.key"
>
{{ row.key }}
</span>
<CopyButton
icon="i-lucide-copy"
icon-only
class="shrink-0"
:label="t('common.copy')"
:value="row.key"
:success-title="t('copy.copiedUrl')"
/>
</div>
</article>
</div>
</div>
<div class="hidden overflow-x-auto sm:block">
<table class="min-w-full text-left text-sm">
<thead class="border-b border-default bg-muted/30 text-xs text-muted">
<tr>
<th class="px-4 py-3 font-medium sm:px-6">
{{ t('logs.colTime') }}
</th>
<th
v-if="isAdmin"
class="px-4 py-3 font-medium"
>
{{ t('logs.colUser') }}
</th>
<th class="px-4 py-3 font-medium">
{{ t('logs.colAction') }}
</th>
<th class="px-4 py-3 font-medium">
{{ t('logs.colSource') }}
</th>
<th class="px-4 py-3 font-medium">
{{ t('logs.colStorage') }}
</th>
<th class="hidden px-4 py-3 font-medium md:table-cell">
{{ t('logs.colFile') }}
</th>
<th class="hidden px-4 py-3 font-medium lg:table-cell">
{{ t('logs.colFolder') }}
</th>
<th class="px-4 py-3 font-medium">
{{ t('logs.colSize') }}
</th>
<th class="hidden px-4 py-3 font-medium xl:table-cell">
{{ t('logs.colKey') }}
</th>
</tr>
</thead>
<tbody class="divide-y divide-default">
<tr
v-for="row in listData.items"
:key="row.id"
class="hover:bg-muted/20"
>
<td class="whitespace-nowrap px-4 py-3 text-xs text-muted sm:px-6">
{{ formatTime(row.createdAt) }}
</td>
<td
v-if="isAdmin"
class="px-4 py-3 text-xs"
>
{{ row.username ?? '—' }}
</td>
<td class="px-4 py-3">
<UBadge
:color="actionColor(row.action)"
variant="subtle"
size="xs"
>
<UIcon
:name="actionIcon(row.action)"
class="mr-1 size-3"
/>
{{ row.action === 'upload' ? t('logs.actionUpload') : t('logs.actionDelete') }}
</UBadge>
</td>
<td class="px-4 py-3">
<UBadge
color="neutral"
variant="subtle"
size="xs"
>
{{ sourceLabel(row.source) }}
</UBadge>
</td>
<td class="px-4 py-3 text-xs">
<span
v-if="row.storage"
class="inline-flex max-w-[8rem] items-center gap-1 truncate"
:title="row.storage.name"
>
<UIcon
:name="storageIcon(row.storage)"
class="size-3.5 shrink-0 opacity-70"
/>
<span class="truncate">{{ row.storage.name }}</span>
</span>
<span
v-else
class="text-muted"
>—</span>
</td>
<td
class="hidden max-w-[12rem] truncate px-4 py-3 md:table-cell"
:title="row.originalName"
>
{{ row.originalName }}
</td>
<td class="hidden px-4 py-3 font-mono text-xs text-muted lg:table-cell">
{{ folderFromKey(row.key) }}
</td>
<td class="whitespace-nowrap px-4 py-3 tabular-nums text-muted">
{{ formatFileSize(row.size) }}
</td>
<td class="hidden px-4 py-3 xl:table-cell">
<div class="flex max-w-[16rem] items-center gap-1">
<a
v-if="row.action === 'upload'"
:href="imageUrl(row.key)"
target="_blank"
rel="noopener noreferrer"
class="truncate font-mono text-xs text-primary hover:underline"
:title="row.key"
>
{{ row.key }}
</a>
<span
v-else
class="truncate font-mono text-xs text-dimmed"
:title="row.key"
>
{{ row.key }}
</span>
<CopyButton
icon="i-lucide-copy"
icon-only
:label="t('common.copy')"
:value="row.key"
:success-title="t('copy.copiedUrl')"
/>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</template>
<div
v-else
class="px-4 py-16 text-center text-sm text-muted sm:px-6"
>
{{ t('logs.empty') }}
</div>
<div
v-if="listData && listData.total > 0"
class="border-t border-default px-4 py-4 sm:px-6"
>
<PaginationBar
:page="listData.page"
:total-pages="listData.totalPages"
:total="listData.total"
:unit="t('logs.entriesUnit')"
:loading="loading"
@update:page="handlePageChange"
/>
</div>
</section>
</AppShell>
</div>
</template>
+21 -25
View File
@@ -6,6 +6,7 @@ import { isPasswordValid } from '~/utils/password-strength'
const router = useRouter() const router = useRouter()
const { register, fetchStatus } = useAuth() const { register, fetchStatus } = useAuth()
const toast = useToast() const toast = useToast()
const { t } = useI18n()
const username = ref('') const username = ref('')
const password = ref('') const password = ref('')
@@ -21,7 +22,7 @@ const canSubmit = computed(() =>
onMounted(async () => { onMounted(async () => {
const status = await fetchStatus() const status = await fetchStatus()
if (!status.initialized) { if (!status.initialized && !status.legacyMode) {
await router.replace('/setup') await router.replace('/setup')
return return
} }
@@ -34,7 +35,7 @@ async function submit() {
if (!canSubmit.value || loading.value) return if (!canSubmit.value || loading.value) return
if (password.value !== confirmPassword.value) { if (password.value !== confirmPassword.value) {
toast.add({ title: '两次输入的密码不一致', color: 'error' }) toast.add({ title: t('auth.passwordMismatch'), color: 'error' })
return return
} }
@@ -42,7 +43,7 @@ async function submit() {
try { try {
const result = await register(username.value.trim(), password.value) const result = await register(username.value.trim(), password.value)
if (result.ok) { if (result.ok) {
toast.add({ title: '注册成功', color: 'success' }) toast.add({ title: t('auth.registerSuccess'), color: 'success' })
await router.replace('/') await router.replace('/')
} else { } else {
toast.add({ title: result.error, color: 'error' }) toast.add({ title: result.error, color: 'error' })
@@ -54,7 +55,10 @@ async function submit() {
</script> </script>
<template> <template>
<div class="flex min-h-screen items-center justify-center bg-muted/30 p-4 sm:p-6"> <div class="relative flex min-h-screen items-center justify-center bg-muted/30 p-4 sm:p-6">
<div class="absolute top-4 right-4">
<AuthPagePreferences />
</div>
<div class="w-full max-w-sm rounded-2xl border border-default bg-elevated p-6 shadow-lg sm:p-8"> <div class="w-full max-w-sm rounded-2xl border border-default bg-elevated p-6 shadow-lg sm:p-8">
<div class="mb-8 space-y-3 text-center"> <div class="mb-8 space-y-3 text-center">
<div class="mx-auto inline-flex items-center justify-center"> <div class="mx-auto inline-flex items-center justify-center">
@@ -77,10 +81,10 @@ async function submit() {
</div> </div>
<div class="space-y-1"> <div class="space-y-1">
<h1 class="text-xl font-bold tracking-tight sm:text-2xl"> <h1 class="text-xl font-bold tracking-tight sm:text-2xl">
注册账号 {{ t('register.title') }}
</h1> </h1>
<p class="text-sm text-muted"> <p class="text-sm text-muted">
创建 PicHost 用户账号 {{ t('register.subtitle') }}
</p> </p>
</div> </div>
</div> </div>
@@ -94,12 +98,12 @@ async function submit() {
for="register-username" for="register-username"
class="block text-sm font-medium text-default" class="block text-sm font-medium text-default"
> >
用户名 {{ t('auth.username') }}
</label> </label>
<UInput <UInput
id="register-username" id="register-username"
v-model="username" v-model="username"
placeholder="3–32 位字母、数字或下划线" :placeholder="t('setup.usernamePlaceholder')"
autocomplete="username" autocomplete="username"
size="lg" size="lg"
class="w-full" class="w-full"
@@ -112,17 +116,13 @@ async function submit() {
for="register-password" for="register-password"
class="block text-sm font-medium text-default" class="block text-sm font-medium text-default"
> >
密码 {{ t('auth.password') }}
</label> </label>
<UInput <PasswordInput
id="register-password" id="register-password"
v-model="password" v-model="password"
type="password" :placeholder="t('setup.passwordPlaceholder')"
placeholder="设置登录密码"
autocomplete="new-password" autocomplete="new-password"
size="lg"
class="w-full"
:ui="{ root: 'w-full' }"
/> />
<PasswordStrength :password="password" /> <PasswordStrength :password="password" />
</div> </div>
@@ -132,23 +132,19 @@ async function submit() {
for="register-confirm" for="register-confirm"
class="block text-sm font-medium text-default" class="block text-sm font-medium text-default"
> >
确认密码 {{ t('auth.confirmPassword') }}
</label> </label>
<UInput <PasswordInput
id="register-confirm" id="register-confirm"
v-model="confirmPassword" v-model="confirmPassword"
type="password" :placeholder="t('setup.confirmPlaceholder')"
placeholder="再次输入密码"
autocomplete="new-password" autocomplete="new-password"
size="lg"
class="w-full"
:ui="{ root: 'w-full' }"
/> />
</div> </div>
<UButton <UButton
type="submit" type="submit"
label="注册" :label="t('auth.register')"
icon="i-lucide-user-plus" icon="i-lucide-user-plus"
size="lg" size="lg"
block block
@@ -158,12 +154,12 @@ async function submit() {
/> />
<p class="text-center text-sm text-muted"> <p class="text-center text-sm text-muted">
已有账号? {{ t('auth.hasAccount') }}
<NuxtLink <NuxtLink
to="/" to="/"
class="text-primary hover:underline" class="text-primary hover:underline"
> >
返回登录 {{ t('auth.backToLogin') }}
</NuxtLink> </NuxtLink>
</p> </p>
</form> </form>
-360
View File
@@ -1,360 +0,0 @@
<script setup lang="ts">
type SettingSource = 'env' | 'db' | 'none'
type WebpQualitySource = 'env' | 'db' | 'default'
interface SettingsResponse {
apiUploadToken: string
tokenSource: SettingSource
envTokenOverride: boolean
webpQuality: number
webpQualitySource: WebpQualitySource
allowedRefererHosts: string
refererSource: SettingSource
refererEnvFallback: string
imageBaseUrl: string
imageBaseUrlConfigured: string
imageBaseUrlSource: SettingSource
allowRegistration: boolean
appVersion: string
}
const { isChecking, isAuthenticated, checkSession, handleAuthError, fetchStatus, isAdmin } = useAuth()
const { defaultFolder, loadPreferences } = useUploadPreferences()
const toast = useToast()
const settings = ref<SettingsResponse | null>(null)
const folderOptions = ref<string[]>(['images'])
const savingServer = ref(false)
const refererDraft = ref('')
const imageBaseUrlDraft = ref('')
const allowRegistrationDraft = ref(false)
const folderSelectItems = computed(() =>
folderOptions.value.map(folder => ({ label: folder, value: folder }))
)
const hasServerChanges = computed(() => {
if (!settings.value) return false
return refererDraft.value !== settings.value.allowedRefererHosts
|| imageBaseUrlDraft.value !== settings.value.imageBaseUrlConfigured
|| allowRegistrationDraft.value !== settings.value.allowRegistration
})
function sourceBadge(source: SettingSource | WebpQualitySource) {
switch (source) {
case 'env':
return { label: '.env', color: 'warning' as const }
case 'db':
return { label: '已保存', color: 'success' as const }
case 'default':
return { label: '默认', color: 'neutral' as const }
default:
return { label: '未设置', color: 'neutral' as const }
}
}
function tokenSourceText(source: SettingSource) {
switch (source) {
case 'env':
return '环境变量'
case 'db':
return '后台生成'
default:
return '未配置'
}
}
function errorStatus(error: unknown): number {
if (typeof error === 'object' && error !== null && 'statusCode' in error) {
return (error as { statusCode: number }).statusCode
}
return 0
}
function applySettings(data: SettingsResponse) {
settings.value = data
refererDraft.value = data.allowedRefererHosts
imageBaseUrlDraft.value = data.imageBaseUrlConfigured
allowRegistrationDraft.value = data.allowRegistration
}
async function patchSettings(body: Record<string, unknown>) {
const data = await $fetch<SettingsResponse>('/api/settings', {
method: 'PATCH',
credentials: 'include',
body
})
applySettings(data)
return data
}
function handlePatchError(error: unknown, fallback: string) {
handleAuthError(error)
if (!isAuthenticated.value) return
if (errorStatus(error) === 409) {
toast.add({ title: '该项已由环境变量覆盖,无法在后台修改', color: 'warning' })
void loadSettings()
return
}
toast.add({ title: fallback, color: 'error' })
}
async function saveServerSettings() {
if (!isAuthenticated.value || !hasServerChanges.value) return
savingServer.value = true
try {
await patchSettings({
allowedRefererHosts: refererDraft.value,
imageBaseUrl: imageBaseUrlDraft.value,
allowRegistration: allowRegistrationDraft.value
})
toast.add({ title: '服务器配置已保存', color: 'success' })
} catch (error: unknown) {
handlePatchError(error, '保存服务器配置失败')
} finally {
savingServer.value = false
}
}
async function loadFolders() {
const data = await $fetch<{ folders: string[] }>('/api/folders', {
credentials: 'include'
})
folderOptions.value = [...data.folders]
if (!folderOptions.value.includes(defaultFolder.value)) {
defaultFolder.value = folderOptions.value[0] ?? 'images'
}
}
async function loadSettings() {
try {
const data = await $fetch<SettingsResponse>('/api/settings', {
credentials: 'include'
})
applySettings(data)
} catch (error: unknown) {
handleAuthError(error)
if (isAuthenticated.value) {
toast.add({ title: '加载设置失败', color: 'error' })
}
}
}
async function loadPage() {
if (!isAdmin.value) {
await navigateTo('/')
return
}
loadPreferences()
await Promise.all([loadSettings(), loadFolders()])
}
onMounted(async () => {
const status = await fetchStatus()
if (!status.initialized && !status.legacyMode) {
await navigateTo('/setup')
return
}
await checkSession()
if (isAuthenticated.value && !isAdmin.value) {
await navigateTo('/')
}
})
watch(isAuthenticated, async (authed) => {
if (authed) {
if (!isAdmin.value) {
await navigateTo('/')
return
}
await nextTick()
await loadPage()
} else {
settings.value = null
}
})
</script>
<template>
<div class="min-h-screen">
<div
v-if="isChecking"
class="flex min-h-screen items-center justify-center"
>
<div class="flex flex-col items-center gap-3 text-muted">
<UIcon
name="i-lucide-loader-circle"
class="size-8 animate-spin"
/>
<p class="text-sm">
正在验证登录状态…
</p>
</div>
</div>
<AdminLoginGate v-else-if="!isAuthenticated" />
<AppShell v-else-if="isAdmin">
<section class="overflow-hidden rounded-2xl border border-default bg-elevated shadow-sm">
<UploadPreferencesPanel embedded />
</section>
<section class="overflow-hidden rounded-2xl border border-default bg-elevated shadow-sm">
<div class="flex items-start gap-2 border-b border-default px-5 py-4 sm:px-6">
<UIcon
name="i-lucide-server"
class="mt-0.5 size-5 shrink-0 text-primary"
/>
<div class="min-w-0">
<h1 class="text-base font-semibold">
系统设置
</h1>
<p class="mt-0.5 text-xs text-muted">
保存后写入数据库;环境变量优先级更高
</p>
</div>
</div>
<div
v-if="settings"
class="p-5 sm:p-6"
>
<div class="grid gap-5 sm:grid-cols-3 sm:gap-6">
<div class="space-y-4">
<h3 class="text-sm font-semibold">
上传与账号
</h3>
<div class="space-y-2">
<label class="text-sm">默认上传目录</label>
<p class="text-xs leading-relaxed text-muted">
保存在本机浏览器,仅影响您的上传目标目录
</p>
<USelect
v-model="defaultFolder"
:items="folderSelectItems"
class="w-full"
/>
</div>
<label class="flex cursor-pointer items-start gap-2.5">
<UCheckbox
v-model="allowRegistrationDraft"
class="mt-0.5"
/>
<span>
<span class="block text-sm">开放用户注册</span>
<span class="mt-1 block text-xs leading-relaxed text-muted">
允许新用户自行注册(普通用户仅可见自己的图片)
</span>
</span>
</label>
</div>
<div class="space-y-4">
<div class="flex items-center justify-between gap-2">
<h3 class="text-sm font-semibold">
访问控制
</h3>
<UBadge
:color="sourceBadge(settings.refererSource).color"
variant="subtle"
size="xs"
>
{{ sourceBadge(settings.refererSource).label }}
</UBadge>
</div>
<p class="text-xs leading-relaxed text-muted">
Referer 防盗链,逗号分隔允许引用的域名,留空不限制
</p>
<UTextarea
v-model="refererDraft"
placeholder="example.com, www.example.com"
:rows="4"
class="w-full font-mono text-sm"
/>
</div>
<div class="space-y-4">
<div class="flex items-center justify-between gap-2">
<h3 class="text-sm font-semibold">
链接生成
</h3>
<UBadge
:color="sourceBadge(settings.imageBaseUrlSource).color"
variant="subtle"
size="xs"
>
{{ sourceBadge(settings.imageBaseUrlSource).label }}
</UBadge>
</div>
<p class="text-xs leading-relaxed text-muted">
生成图片链接的 URL 前缀,内网访问建议填公网域名
</p>
<UInput
v-model="imageBaseUrlDraft"
placeholder="https://pic.example.com"
class="w-full font-mono text-sm"
/>
<p
v-if="settings.imageBaseUrl"
class="text-xs text-muted"
>
当前生效:<span class="font-mono break-all">{{ settings.imageBaseUrl }}</span>
</p>
</div>
</div>
</div>
<div
v-else
class="flex justify-center py-12"
>
<UIcon
name="i-lucide-loader-circle"
class="size-6 animate-spin text-muted"
/>
</div>
<div
v-if="settings"
class="flex flex-wrap items-center justify-between gap-3 border-t border-default px-5 py-4 sm:px-6"
>
<div class="flex min-w-0 items-center gap-2 text-xs text-muted">
<UIcon
name="i-lucide-key-round"
class="size-3.5 shrink-0"
/>
<span>
API Token · {{ tokenSourceText(settings.tokenSource) }}
<span class="text-dimmed">· v{{ settings.appVersion }}</span>
<template v-if="hasServerChanges">
· <span class="text-warning">有未保存的更改</span>
</template>
</span>
</div>
<div class="flex flex-wrap items-center gap-2">
<UButton
label="管理 Token"
icon="i-lucide-external-link"
variant="outline"
color="neutral"
size="sm"
to="/api"
/>
<UButton
label="保存"
icon="i-lucide-save"
size="sm"
:loading="savingServer"
:disabled="!hasServerChanges"
@click="saveServerSettings"
/>
</div>
</div>
</section>
</AppShell>
</div>
</template>
+687
View File
@@ -0,0 +1,687 @@
<script setup lang="ts">
type SettingSource = 'env' | 'db' | 'none'
type WebpQualitySource = 'env' | 'db' | 'default'
interface SettingsResponse {
apiUploadToken: string
tokenSource: SettingSource
envTokenOverride: boolean
webpQuality: number
webpQualitySource: WebpQualitySource
allowedRefererHosts: string
refererSource: SettingSource
refererEnvFallback: string
imageBaseUrl: string
imageBaseUrlConfigured: string
imageBaseUrlSource: SettingSource
siteBaseUrl: string
siteBaseUrlConfigured: string
siteBaseUrlSource: SettingSource
domainSeparation: boolean
hideFolderInUrl: boolean
hideFolderInUrlSource: SettingSource
storageUseDatePath: boolean
storageUseDatePathSource: SettingSource
allowRegistration: boolean
appVersion: string
}
const { isChecking, isAuthenticated, checkSession, handleAuthError, fetchStatus, isAdmin } = useAuth()
const { defaultFolder, loadPreferences } = useUploadPreferences()
const toast = useToast()
const { t } = useI18n()
const settings = ref<SettingsResponse | null>(null)
const folderOptions = ref<string[]>(['images'])
const savingServer = ref(false)
const refererDraft = ref('')
const siteBaseUrlDraft = ref('')
const imageBaseUrlDraft = ref('')
const hideFolderInUrlDraft = ref(false)
const storageUseDatePathDraft = ref(true)
const allowRegistrationDraft = ref(false)
const domainSeparationDraft = ref(false)
const hideFolderConfirmOpen = ref(false)
interface ReleaseCheckResponse {
currentVersion: string
latestVersion: string | null
updateAvailable: boolean
releaseUrl: string | null
}
const releaseCheck = ref<ReleaseCheckResponse | null>(null)
const folderSelectItems = computed(() =>
folderOptions.value.map(folder => ({ label: folder, value: folder }))
)
const hasServerChanges = computed(() => {
if (!settings.value) return false
return refererDraft.value !== settings.value.allowedRefererHosts
|| domainSeparationDraft.value !== settings.value.domainSeparation
|| siteBaseUrlDraft.value !== (settings.value.domainSeparation ? settings.value.siteBaseUrlConfigured : '')
|| imageBaseUrlDraft.value !== settings.value.imageBaseUrlConfigured
|| hideFolderInUrlDraft.value !== settings.value.hideFolderInUrl
|| storageUseDatePathDraft.value !== settings.value.storageUseDatePath
|| allowRegistrationDraft.value !== settings.value.allowRegistration
})
function sourceBadge(source: SettingSource | WebpQualitySource) {
switch (source) {
case 'env':
return { label: t('settings.badgeEnv'), color: 'warning' as const }
case 'db':
return { label: t('settings.badgeSaved'), color: 'success' as const }
case 'default':
return { label: t('settings.badgeDefault'), color: 'neutral' as const }
default:
return { label: t('settings.badgeUnset'), color: 'neutral' as const }
}
}
function tokenSourceText(source: SettingSource) {
switch (source) {
case 'env':
return t('settings.tokenEnv')
case 'db':
return t('settings.tokenDb')
default:
return t('settings.tokenNone')
}
}
function errorStatus(error: unknown): number {
if (typeof error === 'object' && error !== null && 'statusCode' in error) {
return (error as { statusCode: number }).statusCode
}
return 0
}
function applySettings(data: SettingsResponse) {
settings.value = data
refererDraft.value = data.allowedRefererHosts
siteBaseUrlDraft.value = data.domainSeparation ? data.siteBaseUrlConfigured : ''
imageBaseUrlDraft.value = data.imageBaseUrlConfigured
hideFolderInUrlDraft.value = data.hideFolderInUrl
storageUseDatePathDraft.value = data.storageUseDatePath
allowRegistrationDraft.value = data.allowRegistration
domainSeparationDraft.value = data.domainSeparation
}
async function patchSettings(body: Record<string, unknown>) {
const data = await $fetch<SettingsResponse>('/api/settings', {
method: 'PATCH',
credentials: 'include',
body
})
applySettings(data)
return data
}
function handlePatchError(error: unknown, fallback: string) {
handleAuthError(error)
if (!isAuthenticated.value) return
if (errorStatus(error) === 409) {
toast.add({ title: t('settings.envOverride'), color: 'warning' })
void loadSettings()
return
}
toast.add({ title: fallback, color: 'error' })
}
function onHideFolderDraftChange(next: boolean | 'indeterminate') {
if (next !== true) {
hideFolderInUrlDraft.value = false
return
}
if (folderOptions.value.length > 1) {
hideFolderConfirmOpen.value = true
return
}
hideFolderInUrlDraft.value = true
}
function confirmHideFolderEnable() {
hideFolderInUrlDraft.value = true
hideFolderConfirmOpen.value = false
}
function cancelHideFolderEnable() {
hideFolderInUrlDraft.value = false
hideFolderConfirmOpen.value = false
}
function onDomainSeparationDraftChange(next: boolean | 'indeterminate') {
const enabled = next === true
domainSeparationDraft.value = enabled
if (!enabled) {
siteBaseUrlDraft.value = ''
return
}
if (!siteBaseUrlDraft.value && import.meta.client) {
siteBaseUrlDraft.value = window.location.origin
}
}
async function saveServerSettings() {
if (!isAuthenticated.value || !hasServerChanges.value) return
savingServer.value = true
try {
await patchSettings({
allowedRefererHosts: refererDraft.value,
siteBaseUrl: domainSeparationDraft.value ? siteBaseUrlDraft.value : '',
imageBaseUrl: imageBaseUrlDraft.value,
hideFolderInUrl: hideFolderInUrlDraft.value,
storageUseDatePath: storageUseDatePathDraft.value,
allowRegistration: allowRegistrationDraft.value
})
toast.add({ title: t('settings.saved'), color: 'success' })
} catch (error: unknown) {
handlePatchError(error, t('settings.saveFailed'))
} finally {
savingServer.value = false
}
}
async function loadFolders() {
const data = await $fetch<{ folders: string[] }>('/api/folders', {
credentials: 'include'
})
folderOptions.value = [...data.folders]
if (!folderOptions.value.includes(defaultFolder.value)) {
defaultFolder.value = folderOptions.value[0] ?? 'images'
}
}
async function loadSettings() {
try {
const data = await $fetch<SettingsResponse>('/api/settings', {
credentials: 'include'
})
applySettings(data)
} catch (error: unknown) {
handleAuthError(error)
if (isAuthenticated.value) {
toast.add({ title: t('settings.loadFailed'), color: 'error' })
}
}
}
async function checkLatestRelease() {
try {
releaseCheck.value = await $fetch<ReleaseCheckResponse>('/api/version/latest', {
credentials: 'include'
})
} catch {
releaseCheck.value = null
}
}
async function loadPage() {
if (!isAdmin.value) {
await navigateTo('/')
return
}
loadPreferences()
await Promise.all([loadSettings(), loadFolders(), checkLatestRelease()])
}
onMounted(async () => {
const status = await fetchStatus()
if (!status.initialized && !status.legacyMode) {
await navigateTo('/setup')
return
}
await checkSession()
if (isAuthenticated.value && !isAdmin.value) {
await navigateTo('/')
return
}
if (isAuthenticated.value && isAdmin.value) {
await loadPage()
}
})
watch(isAuthenticated, async (authed, prev) => {
if (authed && prev === false) {
if (!isAdmin.value) {
await navigateTo('/')
return
}
await nextTick()
await loadPage()
} else {
settings.value = null
releaseCheck.value = null
}
})
</script>
<template>
<div class="min-h-screen">
<div
v-if="isChecking"
class="flex min-h-screen items-center justify-center"
>
<div class="flex flex-col items-center gap-3 text-muted">
<UIcon
name="i-lucide-loader-circle"
class="size-8 animate-spin"
/>
<p class="text-sm">
{{ t('common.loadingSession') }}
</p>
</div>
</div>
<AdminLoginGate v-else-if="!isAuthenticated" />
<AppShell v-else-if="isAdmin">
<section class="overflow-hidden rounded-2xl border border-default bg-elevated shadow-sm">
<UploadPreferencesPanel embedded />
</section>
<section class="overflow-hidden rounded-2xl border border-default bg-elevated shadow-sm">
<div class="flex items-start gap-2 border-b border-default px-5 py-4 sm:px-6">
<UIcon
name="i-lucide-server"
class="mt-0.5 size-5 shrink-0 text-primary"
/>
<div class="min-w-0">
<h1 class="text-base font-semibold">
{{ t('settings.title') }}
</h1>
<p class="mt-0.5 text-xs text-muted">
{{ t('settings.subtitle') }}
</p>
</div>
</div>
<div
v-if="settings"
class="p-5 sm:p-6"
>
<div class="space-y-5">
<div class="grid min-w-0 gap-5 lg:grid-cols-2 lg:items-stretch">
<div class="flex h-full min-w-0 flex-col space-y-3 rounded-xl border border-default/60 bg-muted/10 p-4 sm:p-5">
<h3 class="text-sm font-semibold">
{{ t('settings.featureToggles') }}
</h3>
<label class="flex cursor-pointer items-start gap-2.5 rounded-lg border border-default/50 bg-default/40 p-3">
<UCheckbox
v-model="allowRegistrationDraft"
class="mt-0.5 shrink-0"
/>
<span class="min-w-0">
<span class="block text-sm font-medium">{{ t('settings.allowRegistration') }}</span>
<span class="mt-1 block text-xs leading-relaxed text-muted">
{{ t('settings.allowRegistrationHint') }}
</span>
</span>
</label>
<label class="flex cursor-pointer items-start gap-2.5 rounded-lg border border-default/50 bg-default/40 p-3">
<UCheckbox
:model-value="storageUseDatePathDraft"
class="mt-0.5 shrink-0"
:disabled="settings.storageUseDatePathSource === 'env'"
@update:model-value="(v) => storageUseDatePathDraft = v === true"
/>
<span class="min-w-0">
<span class="flex flex-wrap items-center gap-2 text-sm font-medium">
{{ t('settings.storageUseDatePath') }}
<UBadge
v-if="settings.storageUseDatePathSource === 'env'"
:color="sourceBadge(settings.storageUseDatePathSource).color"
variant="subtle"
size="xs"
>
{{ sourceBadge(settings.storageUseDatePathSource).label }}
</UBadge>
</span>
<span class="mt-1 block text-xs leading-relaxed text-muted">
{{ t('settings.storageUseDatePathHint') }}
</span>
</span>
</label>
<label class="flex cursor-pointer items-start gap-2.5 rounded-lg border border-default/50 bg-default/40 p-3">
<UCheckbox
:model-value="hideFolderInUrlDraft"
class="mt-0.5 shrink-0"
:disabled="settings.hideFolderInUrlSource === 'env'"
@update:model-value="onHideFolderDraftChange"
/>
<span class="min-w-0">
<span class="flex flex-wrap items-center gap-2 text-sm font-medium">
{{ t('settings.hideFolderInUrl') }}
<UBadge
v-if="settings.hideFolderInUrlSource === 'env'"
:color="sourceBadge(settings.hideFolderInUrlSource).color"
variant="subtle"
size="xs"
>
{{ sourceBadge(settings.hideFolderInUrlSource).label }}
</UBadge>
</span>
<span class="mt-1 block text-xs leading-relaxed text-muted">
{{ t('settings.hideFolderInUrlHint') }}
</span>
</span>
</label>
</div>
<div class="settings-side-panel flex h-full min-w-0 flex-col rounded-xl border border-default/60 bg-muted/10 p-4 sm:p-5">
<div class="shrink-0 space-y-1.5">
<label class="text-sm font-medium">{{ t('settings.defaultFolder') }}</label>
<p class="text-xs leading-relaxed text-muted">
{{ t('settings.defaultFolderHint') }}
</p>
<USelect
v-model="defaultFolder"
:items="folderSelectItems"
class="settings-folder-select w-40 max-w-full"
/>
</div>
<div class="settings-referer-section mt-4 space-y-1.5 border-t border-default/60 pt-4">
<div class="flex flex-wrap items-center gap-2">
<label class="text-sm font-medium">{{ t('settings.accessControl') }}</label>
<UBadge
:color="sourceBadge(settings.refererSource).color"
variant="subtle"
size="xs"
>
{{ sourceBadge(settings.refererSource).label }}
</UBadge>
</div>
<p class="text-xs leading-relaxed text-muted">
{{ t('settings.refererHint') }}
</p>
<UTextarea
v-model="refererDraft"
:placeholder="t('settings.refererPlaceholder')"
:rows="4"
:autoresize="false"
class="settings-referer-field flex min-h-0 w-full max-w-xl flex-1 flex-col font-mono text-sm"
/>
</div>
</div>
</div>
<div class="rounded-xl border border-default/60 bg-muted/10 p-4 sm:p-5">
<h3 class="text-sm font-semibold">
{{ t('settings.domainSettings') }}
</h3>
<div class="mt-4 space-y-4">
<label class="flex cursor-pointer items-start gap-2.5 rounded-lg border border-default/50 bg-default/40 p-3">
<UCheckbox
:model-value="domainSeparationDraft"
class="mt-0.5 shrink-0"
@update:model-value="onDomainSeparationDraftChange"
/>
<span class="min-w-0">
<span class="block text-sm font-medium">{{ t('setup.domainSeparation') }}</span>
<span class="mt-1 block text-xs leading-relaxed text-muted">
{{ t('setup.domainSeparationHint') }}
</span>
</span>
</label>
<p
v-if="domainSeparationDraft"
class="rounded-lg border border-warning/25 bg-warning/5 px-3 py-2.5 text-xs leading-relaxed text-warning"
>
{{ t('setup.domainSeparationProxyHint') }}
<a
href="https://github.com/O96u/PicHost/blob/main/docs/domain-separation.md"
target="_blank"
rel="noopener noreferrer"
class="text-primary hover:underline"
>
{{ t('setup.domainSeparationProxyExample') }}
</a>
</p>
<div
v-if="domainSeparationDraft"
class="grid gap-4 sm:grid-cols-2"
>
<div class="space-y-1.5">
<div class="flex flex-wrap items-center gap-2">
<label class="text-sm font-medium">{{ t('settings.siteBaseUrl') }}</label>
<UBadge
:color="sourceBadge(settings.siteBaseUrlSource).color"
variant="subtle"
size="xs"
>
{{ sourceBadge(settings.siteBaseUrlSource).label }}
</UBadge>
</div>
<p class="text-xs leading-relaxed text-muted">
{{ t('settings.siteBaseUrlHint') }}
</p>
<UInput
v-model="siteBaseUrlDraft"
:placeholder="t('settings.siteBaseUrlPlaceholder')"
class="w-full max-w-xl font-mono text-sm"
/>
<p
v-if="settings.siteBaseUrl"
class="truncate text-xs text-muted"
:title="settings.siteBaseUrl"
>
{{ t('settings.siteBaseUrlActive', { url: settings.siteBaseUrl }) }}
</p>
</div>
<div class="space-y-1.5">
<div class="flex flex-wrap items-center gap-2">
<label class="text-sm font-medium">{{ t('settings.imageBaseUrl') }}</label>
<UBadge
:color="sourceBadge(settings.imageBaseUrlSource).color"
variant="subtle"
size="xs"
>
{{ sourceBadge(settings.imageBaseUrlSource).label }}
</UBadge>
</div>
<p class="text-xs leading-relaxed text-muted">
{{ t('settings.imageBaseUrlHint') }}
</p>
<UInput
v-model="imageBaseUrlDraft"
:placeholder="t('settings.imageBaseUrlPlaceholder')"
class="w-full max-w-xl font-mono text-sm"
/>
<p
v-if="settings.imageBaseUrl"
class="truncate text-xs text-muted"
:title="settings.imageBaseUrl"
>
{{ t('settings.imageBaseUrlActive', { url: settings.imageBaseUrl }) }}
</p>
</div>
</div>
<div
v-else
class="space-y-1.5"
>
<div class="flex flex-wrap items-center gap-2">
<label class="text-sm font-medium">{{ t('settings.imageBaseUrl') }}</label>
<UBadge
:color="sourceBadge(settings.imageBaseUrlSource).color"
variant="subtle"
size="xs"
>
{{ sourceBadge(settings.imageBaseUrlSource).label }}
</UBadge>
</div>
<p class="text-xs leading-relaxed text-muted">
{{ t('settings.imageBaseUrlSingleHint') }}
</p>
<UInput
v-model="imageBaseUrlDraft"
:placeholder="t('settings.imageBaseUrlPlaceholder')"
class="w-full max-w-xl font-mono text-sm"
/>
<p
v-if="settings.imageBaseUrl"
class="truncate text-xs text-muted"
:title="settings.imageBaseUrl"
>
{{ t('settings.imageBaseUrlActive', { url: settings.imageBaseUrl }) }}
</p>
</div>
</div>
</div>
</div>
</div>
<div
v-else
class="flex justify-center py-12"
>
<UIcon
name="i-lucide-loader-circle"
class="size-6 animate-spin text-muted"
/>
</div>
<div
v-if="settings"
class="flex flex-col gap-3 border-t border-default px-5 py-4 sm:flex-row sm:items-center sm:justify-between sm:px-6"
>
<div class="flex min-w-0 items-center gap-2 text-xs text-muted">
<UIcon
name="i-lucide-key-round"
class="size-3.5 shrink-0"
/>
<span class="min-w-0">
{{ t('settings.apiTokenLine', { source: tokenSourceText(settings.tokenSource) }) }}
<a
:href="releaseCheck?.updateAvailable && releaseCheck.releaseUrl
? releaseCheck.releaseUrl
: 'https://github.com/O96u/PicHost/releases'"
target="_blank"
rel="noopener noreferrer"
class="inline-flex items-center gap-0.5 underline-offset-2 hover:underline"
:class="releaseCheck?.updateAvailable ? 'text-warning' : 'text-primary'"
:title="t('settings.viewReleases')"
>
v{{ settings.appVersion }}
<UIcon
name="i-lucide-external-link"
class="size-3 opacity-70"
/>
</a>
<template v-if="releaseCheck?.updateAvailable && releaseCheck.latestVersion">
·
<a
:href="releaseCheck.releaseUrl || 'https://github.com/O96u/PicHost/releases'"
target="_blank"
rel="noopener noreferrer"
class="text-warning hover:underline"
>
{{ t('settings.updateAvailable', { version: `v${releaseCheck.latestVersion}` }) }}
</a>
</template>
<template v-if="hasServerChanges">
· <span class="text-warning">{{ t('settings.unsavedChanges') }}</span>
</template>
</span>
</div>
<div class="flex flex-wrap items-center justify-end gap-2">
<UButton
:label="t('settings.manageToken')"
icon="i-lucide-external-link"
variant="outline"
color="neutral"
size="sm"
to="/api"
/>
<UButton
:label="t('common.save')"
icon="i-lucide-save"
size="sm"
:loading="savingServer"
:disabled="!hasServerChanges"
@click="saveServerSettings"
/>
</div>
</div>
</section>
</AppShell>
<UModal
:open="hideFolderConfirmOpen"
:title="t('settings.hideFolderConfirmTitle')"
:description="t('settings.hideFolderConfirmDesc')"
@update:open="(open) => { if (!open) cancelHideFolderEnable() }"
>
<template #footer>
<div class="flex justify-end gap-2">
<UButton
:label="t('common.cancel')"
color="neutral"
variant="outline"
@click="cancelHideFolderEnable"
/>
<UButton
:label="t('settings.hideFolderConfirmEnable')"
color="warning"
@click="confirmHideFolderEnable"
/>
</div>
</template>
</UModal>
</div>
</template>
<style scoped>
.settings-side-panel {
overflow: hidden;
}
.settings-folder-select {
min-width: 0;
}
.settings-folder-select :deep(button),
.settings-folder-select :deep([role='combobox']) {
justify-content: space-between;
}
.settings-referer-section {
display: flex;
flex: 1;
flex-direction: column;
min-height: 0;
min-width: 0;
}
.settings-referer-field {
flex: 1;
min-height: 0;
min-width: 0;
}
.settings-referer-field :deep(textarea),
.settings-referer-field :deep(> div) {
box-sizing: border-box;
width: 100%;
min-width: 0;
}
.settings-referer-field :deep(textarea) {
height: 100%;
min-height: 5.5rem;
resize: none;
}
</style>
+98 -31
View File
@@ -7,26 +7,44 @@ const route = useRoute()
const router = useRouter() const router = useRouter()
const { setup, migrate, fetchStatus } = useAuth() const { setup, migrate, fetchStatus } = useAuth()
const toast = useToast() const toast = useToast()
const { t } = useI18n()
const isMigrate = computed(() => route.query.migrate === '1') const needsMigration = ref(false)
const isMigrate = computed(() => needsMigration.value || route.query.migrate === '1')
const username = ref('') const username = ref('')
const password = ref('') const password = ref('')
const confirmPassword = ref('') const confirmPassword = ref('')
const allowRegistration = ref(false) const allowRegistration = ref(false)
const domainSeparation = ref(false)
const siteBaseUrl = ref('')
const imageBaseUrl = ref('')
const loading = ref(false) const loading = ref(false)
const canSubmit = computed(() => const canSubmit = computed(() => {
Boolean(username.value && password.value && confirmPassword.value) if (!username.value || !password.value || !confirmPassword.value) return false
&& password.value === confirmPassword.value if (password.value !== confirmPassword.value) return false
&& isPasswordValid(password.value) if (!isPasswordValid(password.value)) return false
&& AUTH_USERNAME_PATTERN.test(username.value.trim()) if (!AUTH_USERNAME_PATTERN.test(username.value.trim())) return false
) if (domainSeparation.value) {
return Boolean(siteBaseUrl.value.trim() && imageBaseUrl.value.trim())
}
return true
})
onMounted(async () => { onMounted(async () => {
const status = await fetchStatus() const status = await fetchStatus()
needsMigration.value = status.needsMigration
if (status.legacyMode && !status.needsMigration) {
await router.replace('/')
return
}
if (status.initialized && !status.needsMigration) { if (status.initialized && !status.needsMigration) {
await router.replace('/') await router.replace('/')
return
}
if (import.meta.client) {
siteBaseUrl.value = window.location.origin
} }
}) })
@@ -34,7 +52,7 @@ async function submit() {
if (!canSubmit.value || loading.value) return if (!canSubmit.value || loading.value) return
if (password.value !== confirmPassword.value) { if (password.value !== confirmPassword.value) {
toast.add({ title: '两次输入的密码不一致', color: 'error' }) toast.add({ title: t('auth.passwordMismatch'), color: 'error' })
return return
} }
@@ -43,7 +61,10 @@ async function submit() {
const payload = { const payload = {
username: username.value.trim(), username: username.value.trim(),
password: password.value, password: password.value,
allowRegistration: allowRegistration.value allowRegistration: allowRegistration.value,
domainSeparation: domainSeparation.value,
siteBaseUrl: domainSeparation.value ? siteBaseUrl.value.trim() : undefined,
imageBaseUrl: domainSeparation.value ? imageBaseUrl.value.trim() : undefined
} }
const result = isMigrate.value const result = isMigrate.value
? await migrate(payload) ? await migrate(payload)
@@ -51,7 +72,7 @@ async function submit() {
if (result.ok) { if (result.ok) {
toast.add({ toast.add({
title: isMigrate.value ? '账号迁移完成' : '初始化完成', title: isMigrate.value ? t('setup.migrateSuccess') : t('setup.initSuccess'),
color: 'success' color: 'success'
}) })
await router.replace('/') await router.replace('/')
@@ -65,8 +86,11 @@ async function submit() {
</script> </script>
<template> <template>
<div class="flex min-h-screen items-center justify-center bg-muted/30 p-4 sm:p-6"> <div class="relative flex min-h-screen items-center justify-center bg-muted/30 p-4 sm:p-6">
<div class="w-full max-w-sm rounded-2xl border border-default bg-elevated p-6 shadow-lg sm:p-8"> <div class="absolute top-4 right-4">
<AuthPagePreferences />
</div>
<div class="w-full max-w-sm rounded-2xl border border-default bg-elevated p-6 shadow-lg sm:max-w-md sm:p-8">
<div class="mb-8 space-y-3 text-center"> <div class="mb-8 space-y-3 text-center">
<div class="mx-auto inline-flex items-center justify-center"> <div class="mx-auto inline-flex items-center justify-center">
<img <img
@@ -91,7 +115,7 @@ async function submit() {
<span class="text-highlighted">Pic</span><span class="text-primary">Host</span> <span class="text-highlighted">Pic</span><span class="text-primary">Host</span>
</h1> </h1>
<p class="text-sm text-muted"> <p class="text-sm text-muted">
{{ isMigrate ? '迁移到账号密码登录' : '首次使用,创建管理员账号' }} {{ isMigrate ? t('setup.migrateTitle') : t('setup.initTitle') }}
</p> </p>
</div> </div>
</div> </div>
@@ -105,12 +129,12 @@ async function submit() {
for="setup-username" for="setup-username"
class="block text-sm font-medium text-default" class="block text-sm font-medium text-default"
> >
用户名 {{ t('auth.username') }}
</label> </label>
<UInput <UInput
id="setup-username" id="setup-username"
v-model="username" v-model="username"
placeholder="3–32 位字母、数字或下划线" :placeholder="t('setup.usernamePlaceholder')"
autocomplete="username" autocomplete="username"
size="lg" size="lg"
class="w-full" class="w-full"
@@ -123,17 +147,13 @@ async function submit() {
for="setup-password" for="setup-password"
class="block text-sm font-medium text-default" class="block text-sm font-medium text-default"
> >
密码 {{ t('auth.password') }}
</label> </label>
<UInput <PasswordInput
id="setup-password" id="setup-password"
v-model="password" v-model="password"
type="password" :placeholder="t('setup.passwordPlaceholder')"
placeholder="设置登录密码"
autocomplete="new-password" autocomplete="new-password"
size="lg"
class="w-full"
:ui="{ root: 'w-full' }"
/> />
<PasswordStrength :password="password" /> <PasswordStrength :password="password" />
</div> </div>
@@ -143,28 +163,75 @@ async function submit() {
for="setup-confirm" for="setup-confirm"
class="block text-sm font-medium text-default" class="block text-sm font-medium text-default"
> >
确认密码 {{ t('auth.confirmPassword') }}
</label> </label>
<UInput <PasswordInput
id="setup-confirm" id="setup-confirm"
v-model="confirmPassword" v-model="confirmPassword"
type="password" :placeholder="t('setup.confirmPlaceholder')"
placeholder="再次输入密码"
autocomplete="new-password" autocomplete="new-password"
size="lg"
class="w-full"
:ui="{ root: 'w-full' }"
/> />
</div> </div>
<label class="flex cursor-pointer items-center gap-2 text-sm text-default"> <label class="flex cursor-pointer items-center gap-2 text-sm text-default">
<UCheckbox v-model="allowRegistration" /> <UCheckbox v-model="allowRegistration" />
允许其他用户注册 {{ t('setup.allowRegistration') }}
</label> </label>
<div class="space-y-3 rounded-xl border border-default bg-muted/20 p-4">
<label class="flex cursor-pointer items-start gap-2.5">
<UCheckbox
v-model="domainSeparation"
class="mt-0.5"
/>
<span>
<span class="block text-sm font-medium">{{ t('setup.domainSeparation') }}</span>
<span class="mt-1 block text-xs leading-relaxed text-muted">
{{ t('setup.domainSeparationHint') }}
</span>
</span>
</label>
<template v-if="domainSeparation">
<p class="text-xs leading-relaxed text-warning">
{{ t('setup.domainSeparationProxyHint') }}
<a
href="https://github.com/O96u/PicHost/blob/main/docs/domain-separation.md"
target="_blank"
rel="noopener noreferrer"
class="text-primary hover:underline"
>
{{ t('setup.domainSeparationProxyExample') }}
</a>
</p>
<div class="space-y-2">
<label class="text-sm">{{ t('setup.siteBaseUrl') }}</label>
<p class="text-xs text-muted">
{{ t('setup.siteBaseUrlHint') }}
</p>
<UInput
v-model="siteBaseUrl"
:placeholder="t('setup.siteBaseUrlPlaceholder')"
class="w-full font-mono text-sm"
/>
</div>
<div class="space-y-2">
<label class="text-sm">{{ t('setup.imageBaseUrl') }}</label>
<p class="text-xs text-muted">
{{ t('setup.imageBaseUrlHint') }}
</p>
<UInput
v-model="imageBaseUrl"
:placeholder="t('setup.imageBaseUrlPlaceholder')"
class="w-full font-mono text-sm"
/>
</div>
</template>
</div>
<UButton <UButton
type="submit" type="submit"
:label="isMigrate ? '完成迁移' : '创建并进入'" :label="isMigrate ? t('setup.migrateSubmit') : t('setup.initSubmit')"
icon="i-lucide-user-plus" icon="i-lucide-user-plus"
size="lg" size="lg"
block block
+158 -92
View File
@@ -35,19 +35,27 @@ const {
activeSearch, activeSearch,
listSummary, listSummary,
refreshList, refreshList,
initializeList, fetchList,
fetchSearch,
fetchTotal,
setActiveFolder, setActiveFolder,
submitSearch, submitSearch,
goToPage, goToPage,
removeItems removeItems,
activeStorageBackend,
storageBackendOptions,
setActiveStorageBackend,
loadStorageBackendOptions
} = useImageList() } = useImageList()
const { formatFileSize } = useFileSize() const { formatFileSize } = useFileSize()
const { isChecking, isAuthenticated, checkSession, handleAuthError, fetchStatus, isAdmin } = useAuth() const { isChecking, isAuthenticated, checkSession, handleAuthError, fetchStatus, isAdmin } = useAuth()
const toast = useToast() const toast = useToast()
const { t } = useI18n()
const stats = ref<StatsResponse | null>(null) const stats = ref<StatsResponse | null>(null)
const statsLoading = ref(false) const statsLoading = ref(false)
const pageRefreshing = computed(() => statsLoading.value || galleryLoading.value)
const selectedKeys = ref<Set<string>>(new Set()) const selectedKeys = ref<Set<string>>(new Set())
const deletingKeys = ref<Set<string>>(new Set()) const deletingKeys = ref<Set<string>>(new Set())
@@ -56,13 +64,22 @@ const deleteTargetKeys = ref<string[]>([])
const batchDeleting = ref(false) const batchDeleting = ref(false)
const showScrollTop = ref(false) const showScrollTop = ref(false)
const currentFolder = ref('all') const currentFolder = ref('all')
const currentStorage = ref('all')
const folderOptions = ref<string[]>(['images']) const folderOptions = ref<string[]>(['images'])
const folderSelectItems = computed(() => [ const folderSelectItems = computed(() => [
{ label: '全部', value: 'all' }, { label: t('stats.allFolders'), value: 'all' },
...folderOptions.value.map(folder => ({ label: folder, value: folder })) ...folderOptions.value.map(folder => ({ label: folder, value: folder }))
]) ])
const storageSelectItems = computed(() => [
{ label: t('stats.filterStorageAll'), value: 'all' },
...storageBackendOptions.value.map(backend => ({
label: backend.name,
value: backend.id
}))
])
const selectedCount = computed(() => selectedKeys.value.size) const selectedCount = computed(() => selectedKeys.value.size)
const FOLDER_CHART_COLORS = [ const FOLDER_CHART_COLORS = [
@@ -104,16 +121,16 @@ const uploadChartTotal = computed(() =>
const statCards = computed(() => { const statCards = computed(() => {
const s = stats.value const s = stats.value
const base = [ const base = [
{ label: '今日上传', value: s?.uploadToday ?? '—' }, { label: t('stats.uploadToday'), value: s?.uploadToday ?? '—' },
{ label: '本月上传', value: s?.uploadMonth ?? '—' }, { label: t('stats.uploadMonth'), value: s?.uploadMonth ?? '—' },
{ label: '累计上传', value: s?.uploadTotal ?? '—' }, { label: t('stats.uploadTotal'), value: s?.uploadTotal ?? '—' },
{ label: '当前库存', value: s?.storedCount ?? '—' }, { label: t('stats.storedCount'), value: s?.storedCount ?? '—' },
{ label: '今日删除', value: s?.deleteToday ?? '—' }, { label: t('stats.deleteToday'), value: s?.deleteToday ?? '—' },
{ label: '本月删除', value: s?.deleteMonth ?? '—' }, { label: t('stats.deleteMonth'), value: s?.deleteMonth ?? '—' },
isAdmin.value isAdmin.value
? { label: '注册用户数量', value: s?.userCount ?? '—' } ? { label: t('stats.userCount'), value: s?.userCount ?? '—' }
: { label: '累计删除', value: s?.deleteTotal ?? '—' }, : { label: t('stats.deleteTotal'), value: s?.deleteTotal ?? '—' },
{ label: '累计上传体积', value: s ? formatFileSize(s.uploadBytesTotal) : '—' } { label: t('stats.uploadBytes'), value: s ? formatFileSize(s.uploadBytesTotal) : '—' }
] ]
return base return base
}) })
@@ -150,7 +167,7 @@ async function refreshStats() {
} catch (error: unknown) { } catch (error: unknown) {
handleAuthError(error) handleAuthError(error)
if (isAuthenticated.value) { if (isAuthenticated.value) {
toast.add({ title: '加载统计失败', color: 'error' }) toast.add({ title: t('stats.loadFailed'), color: 'error' })
} }
} finally { } finally {
statsLoading.value = false statsLoading.value = false
@@ -161,19 +178,42 @@ async function refreshGallery() {
if (!isAuthenticated.value) return if (!isAuthenticated.value) return
selectedKeys.value = new Set() selectedKeys.value = new Set()
try { try {
await Promise.all([refreshList(), loadFolders()]) await Promise.all([refreshList(), loadFolders(), loadStorageBackendOptions()])
currentStorage.value = activeStorageBackend.value
} catch (error: unknown) { } catch (error: unknown) {
handleAuthError(error) handleAuthError(error)
} }
} }
async function reloadGallery() {
if (activeSearch.value) {
await fetchSearch(galleryPage.value)
} else {
await fetchList(galleryPage.value)
}
await fetchTotal()
}
async function refreshAll() { async function refreshAll() {
if (!isAuthenticated.value) return if (!isAuthenticated.value) return
currentFolder.value = 'all' selectedKeys.value = new Set()
statsLoading.value = true
try {
await Promise.all([ await Promise.all([
refreshStats(), fetchStats(),
initializeList('all').then(() => loadFolders()) reloadGallery(),
loadFolders(),
loadStorageBackendOptions()
]) ])
currentStorage.value = activeStorageBackend.value
} catch (error: unknown) {
handleAuthError(error)
if (isAuthenticated.value) {
toast.add({ title: t('stats.loadFailed'), color: 'error' })
}
} finally {
statsLoading.value = false
}
} }
async function handleFolderChange(folder: string) { async function handleFolderChange(folder: string) {
@@ -187,6 +227,17 @@ async function handleFolderChange(folder: string) {
} }
} }
async function handleStorageChange(backendId: string) {
if (!isAuthenticated.value) return
currentStorage.value = backendId
selectedKeys.value = new Set()
try {
await setActiveStorageBackend(backendId)
} catch (error: unknown) {
handleAuthError(error)
}
}
async function handleSearch() { async function handleSearch() {
if (!isAuthenticated.value) return if (!isAuthenticated.value) return
selectedKeys.value = new Set() selectedKeys.value = new Set()
@@ -212,7 +263,7 @@ function requestDelete(key: string) {
function requestBatchDelete() { function requestBatchDelete() {
if (!isAuthenticated.value) return if (!isAuthenticated.value) return
if (!selectedCount.value) { if (!selectedCount.value) {
toast.add({ title: '请先选择图片', color: 'warning' }) toast.add({ title: t('stats.selectFirst'), color: 'warning' })
return return
} }
deleteTargetKeys.value = Array.from(selectedKeys.value) deleteTargetKeys.value = Array.from(selectedKeys.value)
@@ -249,7 +300,7 @@ async function confirmDelete() {
} }
selectedKeys.value = new Set(selectedKeys.value) selectedKeys.value = new Set(selectedKeys.value)
toast.add({ toast.add({
title: `已删除 ${deleted.length} 张图片`, title: t('stats.deleted', { n: deleted.length }),
color: 'success' color: 'success'
}) })
await refreshStats() await refreshStats()
@@ -257,7 +308,7 @@ async function confirmDelete() {
if (failed.length) { if (failed.length) {
toast.add({ toast.add({
title: `${failed.length} 张图片删除失败`, title: t('stats.deletePartialFail', { n: failed.length }),
color: 'error' color: 'error'
}) })
} }
@@ -267,7 +318,7 @@ async function confirmDelete() {
} catch (error: unknown) { } catch (error: unknown) {
handleAuthError(error) handleAuthError(error)
if (isAuthenticated.value) { if (isAuthenticated.value) {
toast.add({ title: '删除失败', color: 'error' }) toast.add({ title: t('stats.deleteFailed'), color: 'error' })
} }
} finally { } finally {
batchDeleting.value = false batchDeleting.value = false
@@ -292,6 +343,9 @@ onMounted(async () => {
return return
} }
await checkSession() await checkSession()
if (isAuthenticated.value) {
await refreshAll()
}
}) })
onUnmounted(() => { onUnmounted(() => {
@@ -322,7 +376,7 @@ watch(isAuthenticated, async (authed, prev) => {
class="size-8 animate-spin" class="size-8 animate-spin"
/> />
<p class="text-sm"> <p class="text-sm">
正在验证登录状态… {{ t('common.loadingSession') }}
</p> </p>
</div> </div>
</div> </div>
@@ -330,21 +384,24 @@ watch(isAuthenticated, async (authed, prev) => {
<AdminLoginGate v-else-if="!isAuthenticated" /> <AdminLoginGate v-else-if="!isAuthenticated" />
<AppShell v-else> <AppShell v-else>
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between"> <div class="flex items-start justify-between gap-3">
<div> <div class="min-w-0">
<h1 class="text-2xl font-semibold tracking-tight"> <h1 class="text-2xl font-semibold tracking-tight">
统计 {{ t('stats.title') }}
</h1> </h1>
<p class="text-sm text-muted"> <p class="text-sm text-muted">
数据概览与图库记录 {{ t('stats.subtitle') }}
</p> </p>
</div> </div>
<UButton <UButton
icon="i-lucide-refresh-cw" icon="i-lucide-refresh-cw"
label="刷新" variant="ghost"
variant="outline" color="neutral"
:loading="statsLoading || galleryLoading" size="sm"
class="mt-0.5 shrink-0"
:aria-label="t('common.refresh')"
:loading="pageRefreshing"
@click="refreshAll" @click="refreshAll"
/> />
</div> </div>
@@ -367,13 +424,13 @@ watch(isAuthenticated, async (authed, prev) => {
<section class="grid gap-4 lg:grid-cols-2"> <section class="grid gap-4 lg:grid-cols-2">
<div class="rounded-xl border border-default bg-elevated p-4"> <div class="rounded-xl border border-default bg-elevated p-4">
<h2 class="mb-3 text-sm font-medium text-muted"> <h2 class="mb-3 text-sm font-medium text-muted">
上传目录分布 {{ t('stats.folderDistribution') }}
</h2> </h2>
<div <div
v-if="!uploadChartTotal" v-if="!uploadChartTotal"
class="py-6 text-center text-xs text-muted" class="py-6 text-center text-xs text-muted"
> >
暂无数据 {{ t('stats.noData') }}
</div> </div>
<StatsDonut <StatsDonut
v-else v-else
@@ -384,13 +441,13 @@ watch(isAuthenticated, async (authed, prev) => {
<div class="rounded-xl border border-default bg-elevated p-4"> <div class="rounded-xl border border-default bg-elevated p-4">
<h2 class="mb-3 text-sm font-medium text-muted"> <h2 class="mb-3 text-sm font-medium text-muted">
目录占用 {{ t('stats.folderUsage') }}
</h2> </h2>
<div <div
v-if="!folderStats.length" v-if="!folderStats.length"
class="py-6 text-center text-xs text-muted" class="py-6 text-center text-xs text-muted"
> >
暂无数据 {{ t('stats.noData') }}
</div> </div>
<ul <ul
v-else v-else
@@ -403,7 +460,7 @@ watch(isAuthenticated, async (authed, prev) => {
<div class="mb-1 flex items-center justify-between gap-2 text-xs"> <div class="mb-1 flex items-center justify-between gap-2 text-xs">
<span class="font-medium">{{ item.folder }}</span> <span class="font-medium">{{ item.folder }}</span>
<span class="tabular-nums text-muted"> <span class="tabular-nums text-muted">
{{ item.count }} 张 · {{ formatFileSize(item.bytes) }} {{ t('stats.folderCount', { count: item.count, size: formatFileSize(item.bytes) }) }}
</span> </span>
</div> </div>
<div class="h-1.5 overflow-hidden rounded-full bg-muted/50"> <div class="h-1.5 overflow-hidden rounded-full bg-muted/50">
@@ -418,86 +475,93 @@ watch(isAuthenticated, async (authed, prev) => {
</section> </section>
<section class="space-y-4"> <section class="space-y-4">
<form <div class="flex flex-wrap items-center justify-between gap-x-4 gap-y-3">
class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between" <div class="min-w-0">
@submit.prevent="handleSearch"
>
<div class="min-w-0 shrink-0">
<h2 class="text-lg font-medium"> <h2 class="text-lg font-medium">
图库记录 {{ t('stats.gallery') }}
</h2> </h2>
<p <p
v-if="!galleryLoading" v-if="!galleryLoading"
class="text-sm text-muted" class="mt-0.5 text-sm text-muted"
> >
{{ listSummary }} {{ listSummary }}
</p> </p>
</div> </div>
<div class="flex flex-wrap items-center justify-end gap-2"> <form
<div class="flex items-center gap-1.5"> class="flex flex-wrap items-center justify-end gap-2"
<span class="text-xs text-muted shrink-0">目录</span> @submit.prevent="handleSearch"
<USelect >
v-model="currentFolder"
:items="folderSelectItems"
class="w-28"
@update:model-value="handleFolderChange"
/>
</div>
<UInput
v-model="searchQuery"
icon="i-lucide-search"
placeholder="搜索文件名"
class="w-40 sm:w-48"
size="sm"
:disabled="galleryLoading"
/>
<UButton
type="submit"
label="搜索"
size="sm"
:loading="galleryLoading"
/>
<UButton
v-if="activeSearch"
type="button"
label="清除"
size="sm"
variant="ghost"
color="neutral"
@click="clearSearch"
/>
<UButton
type="button"
icon="i-lucide-refresh-cw"
label="刷新"
variant="outline"
size="sm"
:loading="galleryLoading"
@click="refreshGallery"
/>
<UBadge <UBadge
v-if="selectedCount" v-if="selectedCount"
color="primary" color="primary"
variant="subtle" variant="subtle"
class="shrink-0"
> >
已选 {{ selectedCount }} {{ t('stats.selected', { n: selectedCount }) }}
</UBadge> </UBadge>
<USelect
v-model="currentFolder"
:items="folderSelectItems"
class="w-[5.5rem] shrink-0 sm:w-32"
size="sm"
:aria-label="t('stats.folder')"
@update:model-value="handleFolderChange"
/>
<USelect
v-model="currentStorage"
:items="storageSelectItems"
class="w-[6.5rem] shrink-0 sm:w-36"
size="sm"
:aria-label="t('stats.storage')"
@update:model-value="handleStorageChange"
/>
<UInput
v-model="searchQuery"
icon="i-lucide-search"
:placeholder="t('stats.searchPlaceholder')"
class="w-36 shrink-0 sm:w-44"
size="sm"
:disabled="galleryLoading"
/>
<UButton
type="submit"
icon="i-lucide-search"
size="sm"
class="shrink-0"
:aria-label="t('common.search')"
:loading="galleryLoading"
>
<span class="hidden sm:inline">{{ t('common.search') }}</span>
</UButton>
<UButton
v-if="activeSearch"
type="button"
icon="i-lucide-x"
size="sm"
variant="ghost"
color="neutral"
class="shrink-0"
:aria-label="t('stats.clear')"
@click="clearSearch"
>
<span class="hidden sm:inline">{{ t('stats.clear') }}</span>
</UButton>
<UButton <UButton
type="button" type="button"
icon="i-lucide-trash-2" icon="i-lucide-trash-2"
label="批量删除"
color="error" color="error"
variant="soft" variant="soft"
size="sm" size="sm"
class="shrink-0"
:aria-label="t('stats.batchDelete')"
:disabled="!selectedCount" :disabled="!selectedCount"
@click="requestBatchDelete" @click="requestBatchDelete"
/> >
</div> <span class="hidden sm:inline">{{ t('stats.batchDelete') }}</span>
</UButton>
</form> </form>
</div>
<div <div
v-if="galleryLoading" v-if="galleryLoading"
@@ -512,6 +576,8 @@ watch(isAuthenticated, async (authed, prev) => {
<ImageGrid <ImageGrid
v-else v-else
:show-key="false"
show-storage
:items="items" :items="items"
:selected-keys="selectedKeys" :selected-keys="selectedKeys"
:deleting-keys="deletingKeys" :deleting-keys="deletingKeys"
@@ -524,7 +590,7 @@ watch(isAuthenticated, async (authed, prev) => {
:page="galleryPage" :page="galleryPage"
:total-pages="galleryTotalPages" :total-pages="galleryTotalPages"
:total="galleryTotal" :total="galleryTotal"
unit="张" :unit="t('common.imagesUnit')"
:loading="galleryLoading" :loading="galleryLoading"
@update:page="handleGalleryPageChange" @update:page="handleGalleryPageChange"
/> />
@@ -540,7 +606,7 @@ watch(isAuthenticated, async (authed, prev) => {
<UButton <UButton
v-show="showScrollTop" v-show="showScrollTop"
icon="i-lucide-arrow-up" icon="i-lucide-arrow-up"
aria-label="回到顶部" :aria-label="t('stats.scrollTop')"
color="primary" color="primary"
class="fixed bottom-6 right-6 z-50 shadow-lg" class="fixed bottom-6 right-6 z-50 shadow-lg"
size="lg" size="lg"
+352
View File
@@ -0,0 +1,352 @@
<script setup lang="ts">
import type { StorageFormPayload } from '~/components/storage/StorageBackendForm.vue'
import type { StorageBackendItem, StorageListResponse } from '~/types/storage'
const { isChecking, isAuthenticated, checkSession, handleAuthError, fetchStatus, isAdmin } = useAuth()
const toast = useToast()
const { t } = useI18n()
const loading = ref(true)
const busyId = ref<string | null>(null)
const storageData = ref<StorageListResponse | null>(null)
const formOpen = ref(false)
const formMode = ref<'create' | 'edit'>('create')
const editingBackend = ref<StorageBackendItem | null>(null)
const formInstanceKey = ref('create')
const savingForm = ref(false)
const deleteOpen = ref(false)
const deleteTarget = ref<StorageBackendItem | null>(null)
const deleteConfirmName = ref('')
const deleting = ref(false)
async function loadStorage() {
loading.value = true
try {
storageData.value = await $fetch<StorageListResponse>('/api/admin/storage', {
credentials: 'include'
})
} catch (error: unknown) {
handleAuthError(error)
if (isAuthenticated.value) {
toast.add({ title: t('storage.loadFailed'), color: 'error' })
}
} finally {
loading.value = false
}
}
const { quotaToBytes } = useFileSize()
function buildPatchBody(payload: StorageFormPayload) {
const quotaBytes = quotaToBytes(payload.quotaValue, payload.quotaUnit)
const body: Record<string, unknown> = {
name: payload.name,
servingMode: payload.servingMode,
publicUrl: payload.publicUrl,
quotaBytes,
config: payload.config
}
if (payload.secrets.accessKeyId || payload.secrets.secretAccessKey) {
body.secrets = payload.secrets
}
return body
}
function openCreate() {
formMode.value = 'create'
editingBackend.value = null
formInstanceKey.value = `create-${Date.now()}`
formOpen.value = true
}
function openEdit(backend: StorageBackendItem) {
formMode.value = 'edit'
editingBackend.value = backend
formInstanceKey.value = backend.id
formOpen.value = true
}
async function onFormSubmit(payload: StorageFormPayload) {
if (!quotaToBytes(payload.quotaValue, payload.quotaUnit)) {
toast.add({ title: t('storage.quotaRequired'), color: 'warning' })
return
}
if (formMode.value === 'create') {
if (!payload.secrets.accessKeyId || !payload.secrets.secretAccessKey) {
toast.add({ title: t('storage.secretsRequired'), color: 'warning' })
return
}
}
savingForm.value = true
try {
const body = buildPatchBody(payload)
if (formMode.value === 'create') {
await $fetch('/api/admin/storage', {
method: 'POST',
credentials: 'include',
body
})
toast.add({ title: t('storage.created'), color: 'success' })
} else if (editingBackend.value) {
await $fetch(`/api/admin/storage/${editingBackend.value.id}`, {
method: 'PATCH',
credentials: 'include',
body
})
toast.add({ title: t('storage.saved'), color: 'success' })
}
formOpen.value = false
await loadStorage()
} catch {
toast.add({ title: t('storage.saveFailed'), color: 'error' })
} finally {
savingForm.value = false
}
}
async function setDefault(backend: StorageBackendItem) {
busyId.value = backend.id
try {
await $fetch(`/api/admin/storage/${backend.id}`, {
method: 'PATCH',
credentials: 'include',
body: { isDefault: true, enabled: true }
})
toast.add({ title: t('storage.defaultUpdated'), color: 'success' })
await loadStorage()
} catch {
toast.add({ title: t('storage.saveFailed'), color: 'error' })
} finally {
busyId.value = null
}
}
async function testBackend(backend: StorageBackendItem) {
busyId.value = backend.id
try {
const result = await $fetch<{ ok: boolean, message: string }>(
`/api/admin/storage/${backend.id}/test`,
{ method: 'POST', credentials: 'include' }
)
toast.add({
title: result.ok ? t('storage.testSuccess') : t('storage.testFailed'),
description: result.message,
color: result.ok ? 'success' : 'error'
})
} catch {
toast.add({ title: t('storage.testFailed'), color: 'error' })
} finally {
busyId.value = null
}
}
async function toggleEnabled(backend: StorageBackendItem) {
busyId.value = backend.id
try {
await $fetch(`/api/admin/storage/${backend.id}`, {
method: 'PATCH',
credentials: 'include',
body: { enabled: !backend.enabled }
})
await loadStorage()
} catch {
toast.add({ title: t('storage.saveFailed'), color: 'error' })
} finally {
busyId.value = null
}
}
function confirmDelete(backend: StorageBackendItem) {
if (backend.usage.count > 0) {
toast.add({ title: t('storage.deleteBlockedImages'), color: 'warning' })
return
}
deleteTarget.value = backend
deleteConfirmName.value = backend.name
deleteOpen.value = true
}
async function executeDelete() {
const target = deleteTarget.value
if (!target) return
deleting.value = true
try {
await $fetch(`/api/admin/storage/${target.id}`, {
method: 'DELETE',
credentials: 'include'
})
toast.add({ title: t('storage.deleted'), color: 'success' })
deleteOpen.value = false
deleteTarget.value = null
await loadStorage()
} catch (error: unknown) {
const message = typeof error === 'object' && error !== null && 'data' in error
? String((error as { data?: { message?: string } }).data?.message ?? '')
: ''
toast.add({
title: message || t('storage.deleteFailed'),
color: 'error'
})
} finally {
deleting.value = false
}
}
onMounted(async () => {
const status = await fetchStatus()
if (!status.initialized && !status.legacyMode) {
await navigateTo('/setup')
return
}
await checkSession()
if (!isAuthenticated.value || !isAdmin.value) {
await navigateTo('/')
return
}
await loadStorage()
})
watch(isAuthenticated, async (authed) => {
if (authed && isAdmin.value) {
await loadStorage()
}
})
</script>
<template>
<div class="min-h-screen">
<div
v-if="isChecking"
class="flex min-h-screen items-center justify-center"
>
<UIcon
name="i-lucide-loader-circle"
class="size-8 animate-spin text-muted"
/>
</div>
<AdminLoginGate v-else-if="!isAuthenticated" />
<AppShell v-else-if="isAdmin">
<section class="overflow-hidden rounded-2xl border border-default bg-elevated shadow-sm">
<div class="flex flex-wrap items-start justify-between gap-3 border-b border-default px-5 py-4 sm:px-6">
<div class="flex items-start gap-2">
<UIcon
name="i-lucide-hard-drive"
class="mt-0.5 size-5 shrink-0 text-primary"
/>
<div>
<h1 class="text-base font-semibold">
{{ t('storage.pageTitle') }}
</h1>
<p class="mt-0.5 text-xs text-muted">
{{ t('storage.pageSubtitle') }}
</p>
</div>
</div>
<UButton
:label="t('storage.addBackend')"
icon="i-lucide-plus"
size="sm"
:disabled="storageData?.envOverride"
@click="openCreate"
/>
</div>
<div class="p-5 sm:p-6">
<UAlert
v-if="storageData?.envOverride"
color="warning"
variant="subtle"
class="mb-5"
:title="t('storage.envOverride')"
icon="i-lucide-triangle-alert"
/>
<p class="mb-5 text-xs text-muted">
{{ t('storage.switchHint') }}
</p>
<div
v-if="loading"
class="flex justify-center py-16"
>
<UIcon
name="i-lucide-loader-circle"
class="size-6 animate-spin text-muted"
/>
</div>
<div
v-else-if="storageData"
class="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3"
>
<StorageBackendCard
v-for="backend in storageData.backends"
:key="backend.id"
:backend="backend"
:env-override="storageData.envOverride"
:busy="busyId === backend.id"
@set-default="setDefault(backend)"
@edit="openEdit(backend)"
@test="testBackend(backend)"
@toggle-enabled="toggleEnabled(backend)"
@delete="confirmDelete(backend)"
/>
</div>
</div>
</section>
</AppShell>
<UModal v-model:open="formOpen">
<template #content>
<div class="p-5 sm:p-6">
<h2 class="mb-4 text-base font-semibold">
{{ formMode === 'create' ? t('storage.addBackend') : t('storage.editBackend') }}
</h2>
<div class="max-h-[75vh] overflow-y-auto pr-1">
<StorageBackendForm
:key="formInstanceKey"
:backend="editingBackend"
:create-mode="formMode === 'create'"
:saving="savingForm"
@submit="onFormSubmit"
@cancel="formOpen = false"
/>
</div>
</div>
</template>
</UModal>
<UModal v-model:open="deleteOpen">
<template #content>
<div class="p-5 sm:p-6">
<h2 class="text-base font-semibold">
{{ t('storage.deleteConfirmTitle') }}
</h2>
<p class="mt-2 text-sm text-muted">
{{ t('storage.deleteConfirmBody', { name: deleteConfirmName }) }}
</p>
<div class="mt-5 flex justify-end gap-2">
<UButton
:label="t('common.cancel')"
variant="outline"
color="neutral"
@click="() => { deleteOpen = false }"
/>
<UButton
:label="t('common.delete')"
color="error"
:loading="deleting"
@click="executeDelete"
/>
</div>
</div>
</template>
</UModal>
</div>
</template>
+6
View File
@@ -36,6 +36,12 @@ export interface ImageItem {
userId: number | null userId: number | null
username: string username: string
} }
/** 图片所在存储后端(统计/管理列表) */
storage?: {
id: string
name: string
type: 'local' | 's3'
}
} }
export interface UploadErrorItem { export interface UploadErrorItem {
+43
View File
@@ -0,0 +1,43 @@
export type LogAction = 'upload' | 'delete'
export type LogSource = 'web' | 'api' | 'twikoo'
export interface ActivityLogUser {
id: number
username: string
}
export interface ActivityLogStorage {
id: string
name: string
type: 'local' | 's3'
}
export interface ActivityLogItem {
id: number
action: LogAction
key: string
originalName: string
size: number
contentType: string
source: LogSource
userId: number | null
username: string | null
storage: ActivityLogStorage | null
createdAt: string
}
export interface ActivityLogSummary {
total: number
upload: number
delete: number
}
export interface ActivityLogListResponse {
items: ActivityLogItem[]
total: number
page: number
pageSize: number
totalPages: number
summary: ActivityLogSummary
users?: ActivityLogUser[]
}
+39
View File
@@ -0,0 +1,39 @@
export type ServingMode = 'proxy' | 'public'
export interface StorageCapacity {
totalBytes: number | null
usedBytes: number
freeBytes: number | null
percent: number | null
source: 'disk' | 'quota'
}
export interface StorageBackendItem {
id: string
name: string
type: 'local' | 's3'
config: {
endpoint?: string
region?: string
bucket?: string
prefix?: string
forcePathStyle?: boolean
}
secretsMasked: {
accessKeyId?: string
secretAccessKey?: string
}
servingMode: ServingMode
publicUrl: string
quotaBytes: number | null
enabled: boolean
isDefault: boolean
usage: { count: number, bytes: number }
capacity: StorageCapacity
}
export interface StorageListResponse {
backends: StorageBackendItem[]
activeBackendId: string
envOverride: boolean
}
+2 -2
View File
@@ -7,7 +7,7 @@ export interface PasswordCriteria {
hasSpecial: boolean hasSpecial: boolean
} }
export type PasswordStrengthLabel = '弱' | '中' | '强' export type PasswordStrengthLabel = 'weak' | 'medium' | 'strong'
export function analyzePassword(password: string): { export function analyzePassword(password: string): {
criteria: PasswordCriteria criteria: PasswordCriteria
@@ -24,7 +24,7 @@ export function analyzePassword(password: string): {
const score = Object.values(criteria).filter(Boolean).length const score = Object.values(criteria).filter(Boolean).length
const label: PasswordStrengthLabel const label: PasswordStrengthLabel
= score <= 1 ? '弱' : score <= 3 ? '中' : '强' = score <= 1 ? 'weak' : score <= 3 ? 'medium' : 'strong'
return { return {
criteria, criteria,
-2
View File
@@ -4,8 +4,6 @@ services:
# 飞牛 Docker 对 Docker Hub 的「更新镜像」支持更好 # 飞牛 Docker 对 Docker Hub 的「更新镜像」支持更好
image: muxui/pichost:latest image: muxui/pichost:latest
pull_policy: always pull_policy: always
# 备用:GitHub Container Registry
# image: ghcr.io/O96u/pichost:latest
container_name: pichost container_name: pichost
restart: unless-stopped restart: unless-stopped
ports: ports:
+8
View File
@@ -0,0 +1,8 @@
#!/bin/sh
set -e
if [ "$1" = "reset-password" ]; then
exec node /app/server/cli/reset-password.mjs
fi
exec node /app/.output/server/index.mjs "$@"
+62
View File
@@ -0,0 +1,62 @@
# PicHost 文档资源
本目录主要存放 README 引用的界面截图与分支说明。
## 分支
| 分支 | 说明 |
| ---- | ---- |
| [**main**](https://github.com/O96u/PicHost/tree/main)(默认) | **v1.1.0** 主线:本地磁盘 + 多对象存储(R2 / COS / OSS / AWS)、`/storage` 管理页、混合直链 |
| [**cloudflare**](https://github.com/O96u/PicHost/tree/cloudflare) | **Cloudflare R2 专用线**:面向「只用 R2」的部署场景,配置更聚焦 R2 |
- 需要多种云存储或完整管理界面 → 使用 **main**
- 图床只接 **Cloudflare R2** → 可选用 **`cloudflare`** 分支
## 截图一览
以下为 **main(v1.1.0)** 界面截图。
| 文件 | 说明 |
| ---- | ---- |
| `screenshots/upload.png` | 首页上传区(顶栏含 API / 统计 / 存储 / 设置) |
| `screenshots/storage.png` | 存储管理(`/storage`):多后端卡片、用量、设默认 |
| `screenshots/stats.png` | 统计与图库 |
| `screenshots/settings.png` | 系统设置(上传偏好 + 系统设置) |
| `screenshots/preferences.png` | 首页上传卡片翻转后的上传偏好 |
| `screenshots/login.png` | 登录页 |
| `screenshots/lucky-site.png` | 飞牛 NAS Lucky:网站域名反代示例 |
| `screenshots/lucky-image.png` | 飞牛 NAS Lucky:图片域名反代示例 |
## 部署文档
| 文档 | 说明 |
| ---- | ---- |
| [domain-separation.md](domain-separation.md) | 双域名分离推荐架构(Nginx / Lucky / 环境变量) |
| [domain-separation.en.md](domain-separation.en.md) | Dual-domain deployment (English) |
## 相关页面路由(main / v1.1.0)
| 路由 | 权限 | 说明 |
| ---- | ---- | ---- |
| `/` | 登录用户 | 上传与图库入口 |
| `/storage` | 管理员 | 存储后端管理 |
| `/settings` | 管理员 | 系统设置 |
| `/stats` | 登录用户 | 统计 |
| `/api` | 登录用户 | API 文档与 Token |
管理员顶栏:**API** · **统计** · **存储** · **设置**。用户下拉菜单仅 **修改密码**、**退出登录**。
## Cloudflare R2 配置对照
**main**:后台 **存储 → 添加存储 → Cloudflare R2** 预设。
**cloudflare 分支**(R2 专用):通常以环境变量为主:
| 配置项 | 值 |
| ------ | -- |
| `STORAGE_BACKEND` | `s3` |
| `S3_ENDPOINT` | `https://<account_id>.r2.cloudflarestorage.com` |
| `S3_REGION` | `auto` |
| `S3_BUCKET` | 桶名 |
| `S3_ACCESS_KEY` / `S3_SECRET_KEY` | R2 API Token |
| 直链 | 默认 PicHost 代理;桶绑域名后可改公开 URL |
+83
View File
@@ -0,0 +1,83 @@
# Dual-domain separation · Recommended deployment
When admin and image domains are split, the admin UI, API, and public image links use different hostnames. PicHost enforces isolation in **application middleware**: the image host only serves image paths; other requests return 404.
## Architecture
**One Docker instance + two domains**, both **fully reverse-proxied** to port `6892` (isolation is handled by PicHost middleware; no path splitting in Nginx).
| Domain | Purpose |
| ------ | ------- |
| `admin.example.com` | Admin UI, API, Twikoo upload |
| `pic.example.com` | Public image links (non-image paths blocked) |
Set during `/setup` or in **Settings**:
- **Site URL**: `https://admin.example.com`
- **Image URL**: `https://pic.example.com`
## Nginx example
```nginx
# admin.example.com — admin + API + images
server {
server_name admin.example.com;
location / {
proxy_pass http://127.0.0.1:6892;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
client_max_body_size 12m;
}
}
# pic.example.com — same full proxy; PicHost middleware blocks non-image paths
server {
server_name pic.example.com;
location / {
proxy_pass http://127.0.0.1:6892;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
```
> Caddy, NPM, and similar tools work the same: proxy both hostnames to `http://<host>:6892` and forward `Host` and `X-Forwarded-Proto`.
## FN NAS · Lucky reverse proxy
In [Lucky](https://github.com/gdy666/lucky), create one **reverse proxy** rule per domain. **Frontend** is the public hostname; **backend** is PicHost on your LAN (e.g. `http://192.168.8.3:6892`).
**Site domain (admin)**
| Field | Example |
| ----- | ------- |
| Service type | Reverse proxy |
| Frontend | `admin.pichost.com` |
| Backend | `http://192.168.8.3:6892` |
![Lucky site domain](screenshots/lucky-site.png)
**Image domain**
| Field | Example |
| ----- | ------- |
| Service type | Reverse proxy |
| Frontend | `image.pichost.com` |
| Backend | `http://192.168.8.3:6892` |
![Lucky image domain](screenshots/lucky-image.png)
Both rules can point to the same backend; PicHost applies host-based isolation automatically.
## Environment variables (optional)
```env
SITE_BASE_URL=https://admin.example.com
IMAGE_BASE_URL=https://pic.example.com
```
## Notes
- Hostnames must **differ** (same machine, different subdomains is fine)
- No separate static root on the image domain; unlike EasyImages, PicHost isolates in-app
- Single-domain setups can skip separation and use `IMAGE_BASE_URL` or the request host
+129
View File
@@ -0,0 +1,129 @@
# 双域名分离 · 推荐部署架构
启用「后台与图片域名分离」后,管理后台、API 与图片直链使用不同域名。PicHost 通过 **Host 中间件** 双向隔离:
- **网站域**:后台、API、上传;**禁止**直链出图(图片路径返回 404)
- **图片域**:仅放行图片路径;其余请求返回 404
## 架构说明
**单 Docker 实例 + 两个域名**,均可 **全量反代** 到 `6892`(隔离由 PicHost 中间件负责,无需在 Nginx 层拆分路径)。
| 域名 | 用途 |
| ---- | ---- |
| `admin.example.com` | 管理后台、API、Twikoo 上传 |
| `pic.example.com` | 图片直链(中间件拦截非图片路径) |
初始化时在 `/setup` 或后台 **设置** 中填写:
- **网站域名**:`https://admin.example.com`
- **图片域名**:`https://pic.example.com`
## Nginx 示例
```nginx
# admin.example.com — 后台 + API(不出图,直链请走图片域)
server {
server_name admin.example.com;
location / {
proxy_pass http://127.0.0.1:6892;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
client_max_body_size 12m;
}
}
# pic.example.com — 同样全量反代;PicHost 中间件会拦截非图片路径
server {
server_name pic.example.com;
location / {
proxy_pass http://127.0.0.1:6892;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
```
> Caddy、NPM(Nginx Proxy Manager)等工具同理:两个域名均反代到 `http://<主机>:6892`,并保留 `Host` 与 `X-Forwarded-Proto` 头。
## 飞牛 NAS · Lucky 反向代理
在 [Lucky](https://github.com/gdy666/lucky) 中为两个域名各建一条 **反向代理** 规则,**前端地址** 填公网域名,**后端地址** 填 PicHost 所在内网 IP 与端口(如 `http://192.168.8.3:6892`)。
**网站域名(管理后台)**
| 项 | 示例值 |
| -- | ------ |
| 服务类型 | 反向代理 |
| 前端地址 | `admin.pichost.com` |
| 后端地址 | `http://192.168.8.3:6892` |
![Lucky 网站域名配置](screenshots/lucky-site.png)
**图片域名**
| 项 | 示例值 |
| -- | ------ |
| 服务类型 | 反向代理 |
| 前端地址 | `image.pichost.com` |
| 后端地址 | `http://192.168.8.3:6892` |
![Lucky 图片域名配置](screenshots/lucky-image.png)
两条规则的后端地址相同即可;域名分离与路径隔离由 PicHost 根据请求 `Host` 自动处理。
## 环境变量(可选)
除 Web 设置外,也可在 `.env` 或 Docker 环境中配置:
```env
SITE_BASE_URL=https://admin.example.com
IMAGE_BASE_URL=https://pic.example.com
```
## 本地测试(Windows / macOS)
### 1. 修改 hosts
```text
127.0.0.1 admin.pichost.test
127.0.0.1 pic.pichost.test
```
建议使用 `.test` 后缀,避免 `.local` 在部分系统上与 mDNS 冲突。
### 2. 启动开发服务
```bash
npm run dev
```
项目已默认绑定 `127.0.0.1:3000`(与 hosts 的 IPv4 一致)。若曾出现 `ping` 通但浏览器连不上,多半是 dev 只监听了 IPv6 `::1`。
### 3. 填写双域名
| 项 | 示例 |
| -- | ---- |
| 网站域名 | `http://admin.pichost.test:3000` |
| 图片域名 | `http://pic.pichost.test:3000` |
**后台请始终用网站域名打开**,不要用 `localhost`(Cookie 与 Host 不一致)。
### 4. 预期结果
| 地址 | 预期 |
| ---- | ---- |
| `http://admin.pichost.test:3000/` | 正常进入后台 |
| `http://admin.pichost.test:3000/images/...` 或 `/2026/08/xxx.webp` | **404**(请用图片域直链) |
| `http://pic.pichost.test:3000/` | **404**(正常,图片域不提供后台) |
| `http://pic.pichost.test:3000/images/...` | 能出图 |
| `http://localhost:3000/` | 能打开,但与双域名配置无关,登录态不共享 |
上传一张图后,复制链接应指向 `pic.pichost.test` 域名;在后台图库中缩略图也应能正常显示。
## 相关说明
- 两个域名 **主机名不能相同**(可同机、不同子域)
- 图片域无需单独部署静态目录;与 EasyImages 等不同,PicHost 在应用内完成隔离
- 单域名部署可不启用分离,仅配置 `IMAGE_BASE_URL` 或使用默认当前访问域名
- 配置了 Referer 防盗链时,网站域与图片域会自动加入白名单,无需手写两个域名
Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 37 KiB

+453
View File
@@ -0,0 +1,453 @@
{
"app": {
"title": "PicHost - Image Hosting",
"description": "A lightweight self-hosted image hosting system",
"tagline": "Personal lightweight image host"
},
"common": {
"loadingSession": "Verifying session…",
"save": "Save",
"cancel": "Cancel",
"copy": "Copy",
"delete": "Delete",
"edit": "Edit",
"search": "Search",
"refresh": "Refresh",
"back": "Back",
"all": "All",
"notConfigured": "(not configured)",
"countImages": "{count} images",
"itemsUnit": "items",
"imagesUnit": "images",
"more": "More"
},
"nav": {
"api": "API",
"stats": "Stats",
"logs": "Activity log",
"settings": "Settings",
"storage": "Storage",
"menu": "Menu",
"language": "Language",
"appearance": "Appearance",
"account": "Account",
"backToUpload": "Back to upload",
"changePassword": "Change password",
"systemSettings": "System settings",
"logout": "Log out"
},
"theme": {
"switch": "Switch theme",
"light": "Light",
"dark": "Dark",
"system": "System"
},
"locale": {
"switch": "Switch language",
"zh-CN": "简体中文",
"en": "English"
},
"auth": {
"username": "Username",
"password": "Password",
"confirmPassword": "Confirm password",
"login": "Log in",
"register": "Register",
"adminSecret": "Admin secret",
"adminSecretPlaceholder": "Enter ADMIN_SECRET",
"legacyHint": "Legacy deployment: sign in with the secret to migrate to username/password",
"noAccount": "No account?",
"hasAccount": "Already have an account?",
"backToLogin": "Back to login",
"loginFooter": "Upload and management require sign-in",
"loginSuccess": "Signed in",
"loginFailedSecret": "Sign-in failed. Check your secret",
"loginFailedCredentials": "Invalid username or password",
"loginFailed": "Sign-in failed. Check username and password",
"registerFailed": "Registration failed. Check your input",
"setupFailed": "Setup failed. Check your input",
"migrateFailed": "Migration failed. Check your input",
"passwordMismatch": "Passwords do not match",
"showPassword": "Show password",
"hidePassword": "Hide password",
"registerSuccess": "Registered successfully",
"errors": {
"invalidUsername": "Username must be 3–32 letters, numbers, or underscores",
"invalidPassword": "Password must be at least 8 characters"
}
},
"setup": {
"migrateTitle": "Migrate to username/password",
"initTitle": "First run — create admin account",
"usernamePlaceholder": "3–32 letters, numbers, or underscores",
"passwordPlaceholder": "Set login password",
"confirmPlaceholder": "Enter password again",
"allowRegistration": "Allow user registration",
"domainSeparation": "Separate admin and image domains",
"domainSeparationHint": "Admin and API use the site URL; image links use a dedicated image URL",
"domainSeparationProxyHint": "Point both domains to this host and reverse-proxy to port 6892 (Nginx, Caddy, NPM, etc.)",
"domainSeparationProxyExample": "View example",
"siteBaseUrl": "Site URL",
"siteBaseUrlHint": "No trailing slash; admin UI, API docs, and Twikoo upload endpoint",
"siteBaseUrlPlaceholder": "https://admin.example.com",
"imageBaseUrl": "Image URL",
"imageBaseUrlHint": "No trailing slash; public image link prefix",
"imageBaseUrlPlaceholder": "https://pic.example.com",
"domainSeparationInvalid": "Enter valid site and image URLs with different hostnames",
"migrateSubmit": "Complete migration",
"initSubmit": "Create and continue",
"migrateSuccess": "Migration complete",
"initSuccess": "Setup complete"
},
"register": {
"title": "Register",
"subtitle": "Create a PicHost user account"
},
"password": {
"title": "Change password",
"subtitle": "Update your login password",
"current": "Current password",
"new": "New password",
"confirmNew": "Confirm new password",
"newPlaceholder": "Set new password",
"changed": "Password updated",
"tooShort": "New password must be at least 8 characters",
"changeFailed": "Failed to change password",
"strength": "Password strength",
"weak": "Weak",
"medium": "Medium",
"strong": "Strong",
"ruleMinLength": "At least {n} characters",
"ruleMixedCase": "Upper and lower case letters",
"ruleNumber": "Contains a number",
"ruleSpecial": "Contains a special character"
},
"upload": {
"title": "Drop images here to upload",
"hint": "Or click to select files, press {ctrl} + {v} to paste",
"formats": "JPEG / PNG / WebP / GIF / SVG / ICO, max 10 MB each, up to 10 files per batch",
"selectFiles": "Select images",
"settings": "Upload settings",
"progress": "Upload progress",
"statusPending": "Pending",
"statusUploading": "Uploading",
"statusSuccess": "Done",
"statusError": "Failed",
"selectImages": "Select images to upload",
"maxFiles": "Up to {n} images per upload",
"uploadSuccess": "Uploaded {n} image(s)",
"uploadPartialFail": "{n} image(s) failed to upload",
"uploadFailed": "Upload failed",
"loginRequired": "Please sign in first",
"sessionTitle": "This session",
"sessionEmpty": "No uploads in this session"
},
"copy": {
"url": "URL",
"markdown": "Markdown",
"html": "HTML",
"copied": "Copied to clipboard",
"copiedLabel": "Copied {label}",
"copiedUrl": "URL copied",
"copiedMarkdown": "Markdown copied",
"copiedHtml": "HTML copied",
"copiedFormat": "Copied {format}",
"failed": "Copy failed",
"failedManual": "Copy failed — select the link manually",
"curl": "cURL example copied"
},
"preferences": {
"title": "Upload preferences",
"subtitle": "Browser-local and account settings for your uploads only",
"back": "Back to upload",
"processing": "Image processing",
"clientCompress": "Client-side compression",
"clientCompressHint": "Compress in the browser before upload (server still converts to WebP)",
"autoDeletePrefix": "Auto-delete after",
"autoDeleteSuffix": "days",
"autoDeleteHint": "Only affects images uploaded after enabling; existing images are kept",
"webpQuality": "WebP quality",
"webpQualityHint": "Client compression quality (1–100); requires client-side compression",
"current": "Current: {n}",
"qualityHigh": "High quality",
"qualityMedium": "Medium quality",
"qualityLow": "Lossy",
"convenience": "Convenience",
"autoCopy": "Copy link after upload",
"autoCopyHint": "Copy to clipboard in the selected format"
},
"settings": {
"title": "System settings",
"subtitle": "Saved to database; environment variables take precedence",
"uploadAccount": "Upload & accounts",
"featureToggles": "Feature toggles",
"defaultFolder": "Default upload folder",
"defaultFolderHint": "Stored in this browser only; affects your upload target",
"allowRegistration": "Open registration",
"allowRegistrationHint": "Allow new users to register (regular users see only their images)",
"accessControl": "Access control",
"refererHint": "Referer hotlink protection; comma-separated hostnames, empty = no restriction",
"refererPlaceholder": "example.com, www.example.com",
"linkGeneration": "Domains & links",
"domainSettings": "Domain settings",
"storagePathSettings": "Storage paths & direct links",
"domainSeparationActive": "Domain separation enabled: image host serves images only; site host serves admin and API",
"siteBaseUrl": "Site URL",
"siteBaseUrlHint": "No trailing slash; admin UI, API docs, and Twikoo upload endpoint",
"siteBaseUrlPlaceholder": "https://admin.example.com",
"siteBaseUrlActive": "Active: {url}",
"imageBaseUrl": "Image URL",
"imageBaseUrlHint": "No trailing slash; public image link prefix",
"imageBaseUrlSingleHint": "No trailing slash; leave empty to use the current request host; single-domain setups do not need a site URL",
"imageBaseUrlPlaceholder": "https://pic.example.com",
"imageBaseUrlActive": "Active: {url}",
"hideFolderInUrl": "Hide folder in image URLs",
"hideFolderInUrlHint": "Links become /file.webp without folder or date paths; legacy full paths still work",
"hideFolderConfirmTitle": "Hide folder prefix in URLs?",
"hideFolderConfirmDesc": "If two folders share the same filename (e.g. images/a.webp and blog/a.webp), the short URL /a.webp resolves to the images folder first—you may get the wrong file. Enable only with a single upload folder or globally unique filenames.",
"hideFolderConfirmEnable": "Enable anyway",
"storageUseDatePath": "Group uploads by year/month",
"storageUseDatePathHint": "When enabled, files are stored as images/2026/08/file.webp; when disabled, as images/file.webp. Applies only to new uploads after enabling",
"badgeEnv": ".env",
"badgeSaved": "Saved",
"badgeDefault": "Default",
"badgeUnset": "Not set",
"tokenEnv": "Environment",
"tokenDb": "Generated in app",
"tokenNone": "Not configured",
"apiTokenLine": "API Token · {source} ·",
"viewReleases": "View releases",
"updateAvailable": "Update available: {version}",
"unsavedChanges": "Unsaved changes",
"manageToken": "Manage token",
"envOverride": "Overridden by environment variable; cannot edit in UI",
"saved": "Settings saved",
"saveFailed": "Failed to save settings",
"loadFailed": "Failed to load settings"
},
"storage": {
"title": "Storage",
"subtitle": "New uploads go to the active backend; existing images keep their backend",
"activeBackend": "Active backend",
"backendLocal": "Local disk",
"backendCloud": "Cloud storage",
"backendS3": "Cloud storage",
"switchHint": "Switching only affects new uploads",
"provider": "Preset",
"providerR2": "Cloudflare R2",
"providerCos": "Tencent COS",
"providerOss": "Alibaba OSS",
"providerAws": "AWS S3",
"providerCustom": "Custom",
"endpoint": "Endpoint",
"region": "Region",
"bucket": "Bucket",
"prefix": "Key prefix (optional)",
"prefixPlaceholder": "e.g. pichost",
"forcePathStyle": "Force path-style access",
"accessKey": "Access Key",
"secretKey": "Secret Key",
"servingMode": "Serving mode",
"servingProxy": "Proxy (same origin)",
"servingPublic": "Public URL (302 to CDN)",
"servingHint": "In public mode, configure referer protection on CDN/bucket",
"publicUrl": "Public base URL",
"publicUrlPlaceholder": "https://cdn.example.com",
"testConnection": "Test connection",
"testSuccess": "Connection successful",
"testFailed": "Connection failed",
"saved": "Storage settings saved",
"saveFailed": "Failed to save storage settings",
"loadFailed": "Failed to load storage settings",
"envOverride": "Storage is overridden by environment variables",
"pageTitle": "Storage management",
"pageSubtitle": "Manage backends; new uploads go to the default backend",
"addBackend": "Add storage",
"editBackend": "Edit storage",
"formCreateHint": "Configure cloud storage; set as default after saving to receive new uploads",
"formEditHint": "Update connection settings; leave keys blank to keep existing secrets",
"sectionBasic": "Basic",
"sectionConnection": "Connection",
"sectionCredentials": "Credentials",
"sectionAccess": "Access",
"sectionQuota": "Quota",
"advancedOptions": "Advanced",
"servingProxyShort": "Proxy",
"servingPublicShort": "Public",
"backendName": "Display name",
"badgeDefault": "Default",
"badgeDisabled": "Disabled",
"badgeEnabled": "Enabled",
"statTotal": "Total",
"statUsed": "Used",
"statFree": "Free",
"usageRate": "Usage",
"indexImages": "PicHost images {count} · {size}",
"setDefault": "Set as default",
"actionSetDefault": "Default",
"actionTest": "Test",
"disable": "Disable",
"enable": "Enable",
"usageCount": "{count} images",
"quotaTotal": "Plan size",
"quotaPlaceholder": "e.g. 10",
"quotaHint": "Enter your cloud plan size; used space is calculated from PicHost image index",
"quotaRequired": "Enter a valid plan size",
"notConfigured": "Not configured",
"created": "Storage backend added",
"deleted": "Storage backend deleted",
"deleteFailed": "Delete failed",
"deleteBlockedImages": "This backend still has images. Move or delete them before removing the backend.",
"defaultUpdated": "Default backend updated",
"deleteConfirmTitle": "Delete storage",
"deleteConfirmBody": "Delete “{name}”? This cannot be undone.",
"secretsRequired": "Access Key and Secret Key are required for new storage",
"manageStorage": "Manage object storage",
"manageStorageHint": "Configure R2 / S3 / COS backends and URL serving"
},
"stats": {
"title": "Statistics",
"subtitle": "Overview and gallery",
"uploadToday": "Uploaded today",
"uploadMonth": "Uploaded this month",
"uploadTotal": "Total uploads",
"storedCount": "Stored images",
"deleteToday": "Deleted today",
"deleteMonth": "Deleted this month",
"deleteTotal": "Total deleted",
"userCount": "Registered users",
"uploadBytes": "Total upload size",
"folderDistribution": "Uploads by folder",
"folderUsage": "Storage by folder",
"noData": "No data",
"folderCount": "{count} · {size}",
"gallery": "Gallery",
"folder": "Folder",
"filterStorageAll": "All storage",
"storage": "Storage",
"searchPlaceholder": "Search filename",
"clear": "Clear",
"selected": "{n} selected",
"batchDelete": "Delete selected",
"scrollTop": "Back to top",
"loadFailed": "Failed to load statistics",
"selectFirst": "Select images first",
"deleted": "Deleted {n} image(s)",
"deletePartialFail": "{n} image(s) failed to delete",
"deleteFailed": "Delete failed",
"deletedSingle": "Deleted",
"allFolders": "All",
"counting": "Counting…",
"summarySearch": "{folder} · Search “{q}” · {total} images",
"summaryTotal": "{folder} · {total} images",
"summarySearchStorage": "{folder} · {storage} · Search “{q}” · {total} images",
"summaryTotalStorage": "{folder} · {storage} · {total} images"
},
"logs": {
"pageTitle": "Activity log",
"pageSubtitle": "Your upload and delete history",
"pageSubtitleAdmin": "Upload and delete history for all users",
"loadFailed": "Failed to load activity log",
"empty": "No activity yet",
"entriesUnit": "entries",
"filterActionAll": "All actions",
"filterSourceAll": "All sources",
"filterUserAll": "All users",
"searchPlaceholder": "Search filename",
"summaryAll": "All · {total} entries, {upload} uploads, {delete} deletes",
"actionUpload": "Upload",
"actionDelete": "Delete",
"sourceWeb": "Web",
"sourceApi": "API",
"sourceTwikoo": "Twikoo",
"colTime": "Time",
"colUser": "User",
"colStorage": "Storage",
"colAction": "Action",
"colSource": "Source",
"colFile": "File",
"colFolder": "Folder",
"colSize": "Size",
"colKey": "Path"
},
"image": {
"empty": "No images",
"previewFailed": "Preview failed",
"uploader": "Uploader: {name}",
"storageBackend": "Storage: {name}"
},
"delete": {
"confirmTitle": "Confirm delete",
"confirmDesc": "Delete {count} selected image(s)? This cannot be undone.",
"confirm": "Delete"
},
"pagination": {
"pageOf": "Page {page} / {totalPages} · {total} {unit}",
"totalOnly": "{total} {unit}",
"first": "First page",
"prev": "Previous page",
"next": "Next page",
"last": "Last page"
},
"api": {
"tokenTitle": "API Token",
"envOverrideTitle": "Token overridden by environment",
"envOverrideDesc": "Restart after changing API_UPLOAD_TOKEN; cannot regenerate in UI.",
"personalTitle": "Personal API token",
"personalDesc": "For your scripts only; uploads belong to your account.",
"notConfiguredTitle": "No token configured",
"notConfiguredDesc": "Click Regenerate to create a token for Auth-Token on all endpoints below.",
"regenerate": "Regenerate",
"tokenSourceEnv": "Source: API_UPLOAD_TOKEN environment variable",
"tokenSourceDb": "Source: generated in app (can regenerate)",
"tokenSourceNone": "Source: not configured",
"tokenHeader": "Header:",
"tokenInvalidate": "Old token is invalidated immediately after regenerate",
"docsTitle": "API reference",
"curlExample": "cURL example",
"loadFailed": "Failed to load API settings",
"regenerateEnvBlocked": "Token is set via environment; cannot regenerate in UI",
"regenerateFailed": "Failed to regenerate token",
"confirmRegenerateTitle": "Regenerate token?",
"confirmRegenerateDesc": "The old token stops working immediately. Update scripts and automation before continuing.",
"confirmRegenerate": "Regenerate",
"newTokenTitle": "New token generated",
"newTokenDesc": "Copy and save it now. You can copy again from the field above after closing.",
"copyToken": "Copy token",
"saved": "Saved",
"docs": {
"upload": {
"title": "Upload images",
"descAdmin": "Upload one or more images. Use image field; folder creates directory if missing. Also accepts file, files, type.",
"descUser": "Upload one or more images via image field. Also accepts file, files. Stored under images/.",
"curlCustomFolder": "# Upload to custom folder blog/"
},
"list": {
"title": "List images",
"descAdmin": "Paginated gallery; optional folder filter. limit default 20, max 100.",
"descUser": "Paginated list of your images. limit default 20, max 100.",
"curlFolderFilter": "# Filter by folder"
},
"search": {
"title": "Search images",
"desc": "Search by filename or path; q is required."
},
"delete": {
"title": "Delete image",
"desc": "Delete by key (storage path, e.g. images/2026/08/xxx.webp)."
},
"batchDelete": {
"title": "Batch delete",
"desc": "Delete multiple images; request body is a keys array."
},
"twikoo": {
"title": "Twikoo-compatible upload",
"desc": "Twikoo / EasyImage 2.0 protocol; stored under twikoo/. Fields: image + token."
}
}
},
"userSettings": {
"loadFailed": "Failed to load settings",
"saveFailed": "Failed to save settings"
}
}
+453
View File
@@ -0,0 +1,453 @@
{
"app": {
"title": "PicHost - 个人图床",
"description": "个人自用的轻量图床系统",
"tagline": "个人轻量图床"
},
"common": {
"loadingSession": "正在验证登录状态…",
"save": "保存",
"cancel": "取消",
"copy": "复制",
"delete": "删除",
"edit": "编辑",
"search": "搜索",
"refresh": "刷新",
"back": "返回",
"all": "全部",
"notConfigured": "(未配置)",
"countImages": "{count} 张",
"itemsUnit": "条",
"imagesUnit": "张",
"more": "更多"
},
"nav": {
"api": "API",
"stats": "统计",
"logs": "操作日志",
"settings": "设置",
"storage": "存储",
"menu": "菜单",
"language": "语言",
"appearance": "外观",
"account": "账户",
"backToUpload": "返回上传",
"changePassword": "修改密码",
"systemSettings": "系统设置",
"logout": "退出登录"
},
"theme": {
"switch": "切换主题",
"light": "浅色",
"dark": "深色",
"system": "跟随系统"
},
"locale": {
"switch": "切换语言",
"zh-CN": "简体中文",
"en": "English"
},
"auth": {
"username": "用户名",
"password": "密码",
"confirmPassword": "确认密码",
"login": "登录",
"register": "注册",
"adminSecret": "管理密钥",
"adminSecretPlaceholder": "输入 ADMIN_SECRET",
"legacyHint": "遗留部署:使用密钥登录后将引导创建账号",
"noAccount": "没有账号?",
"hasAccount": "已有账号?",
"backToLogin": "返回登录",
"loginFooter": "未登录时无法访问上传和管理功能",
"loginSuccess": "登录成功",
"loginFailedSecret": "登录失败,请检查密钥",
"loginFailedCredentials": "用户名或密码错误",
"loginFailed": "登录失败,请检查用户名和密码",
"registerFailed": "注册失败,请检查输入",
"setupFailed": "初始化失败,请检查输入",
"migrateFailed": "迁移失败,请检查输入",
"passwordMismatch": "两次输入的密码不一致",
"showPassword": "显示密码",
"hidePassword": "隐藏密码",
"registerSuccess": "注册成功",
"errors": {
"invalidUsername": "用户名需为 3–32 位字母、数字或下划线",
"invalidPassword": "密码至少 8 位"
}
},
"setup": {
"migrateTitle": "迁移到账号密码登录",
"initTitle": "首次使用,创建管理员账号",
"usernamePlaceholder": "3–32 位字母、数字或下划线",
"passwordPlaceholder": "设置登录密码",
"confirmPlaceholder": "再次输入密码",
"allowRegistration": "允许其他用户注册",
"domainSeparation": "启用后台与图片域名分离",
"domainSeparationHint": "管理后台与 API 使用网站域名,图片直链使用独立图片域名",
"domainSeparationProxyHint": "需将两个域名解析到本机,并通过 Nginx、Caddy、NPM 等反代到端口 6892",
"domainSeparationProxyExample": "查看示例",
"siteBaseUrl": "网站域名",
"siteBaseUrlHint": "末尾不加 /;管理后台、API 文档与 Twikoo 上传地址",
"siteBaseUrlPlaceholder": "https://admin.example.com",
"imageBaseUrl": "图片域名",
"imageBaseUrlHint": "末尾不加 /;生成图片直链地址",
"imageBaseUrlPlaceholder": "https://pic.example.com",
"domainSeparationInvalid": "请填写有效的网站域名与图片域名,且两者不能相同",
"migrateSubmit": "完成迁移",
"initSubmit": "创建并进入",
"migrateSuccess": "账号迁移完成",
"initSuccess": "初始化完成"
},
"register": {
"title": "注册账号",
"subtitle": "创建 PicHost 用户账号"
},
"password": {
"title": "修改密码",
"subtitle": "修改您的登录密码",
"current": "当前密码",
"new": "新密码",
"confirmNew": "确认新密码",
"newPlaceholder": "设置新密码",
"changed": "密码已修改",
"tooShort": "新密码至少 8 位",
"changeFailed": "修改密码失败",
"strength": "密码强度",
"weak": "弱",
"medium": "中",
"strong": "强",
"ruleMinLength": "至少 {n} 个字符",
"ruleMixedCase": "包含大小写字母",
"ruleNumber": "包含数字",
"ruleSpecial": "包含特殊字符"
},
"upload": {
"title": "拖拽图片到此处上传",
"hint": "或点击选择文件,按 {ctrl} + {v} 粘贴图片",
"formats": "支持 JPEG / PNG / WebP / GIF / SVG / ICO,单张最大 10 MB,每次最多 10 张",
"selectFiles": "选择图片",
"settings": "上传设置",
"progress": "上传进度",
"statusPending": "等待",
"statusUploading": "上传中",
"statusSuccess": "成功",
"statusError": "失败",
"selectImages": "请选择图片",
"maxFiles": "每次最多上传 {n} 张图片",
"uploadSuccess": "成功上传 {n} 张图片",
"uploadPartialFail": "{n} 张图片上传失败",
"uploadFailed": "上传失败",
"loginRequired": "请先登录",
"sessionTitle": "本次上传",
"sessionEmpty": "暂无本次上传"
},
"copy": {
"url": "直链",
"markdown": "Markdown",
"html": "HTML",
"copied": "已复制到剪贴板",
"copiedLabel": "已复制 {label}",
"copiedUrl": "已复制直链",
"copiedMarkdown": "已复制 Markdown",
"copiedHtml": "已复制 HTML",
"copiedFormat": "已复制 {format}",
"failed": "复制失败",
"failedManual": "复制失败,请手动选中链接复制",
"curl": "已复制 cURL 示例"
},
"preferences": {
"title": "上传偏好",
"subtitle": "浏览器本地与账号设置,仅影响您的上传体验",
"back": "返回上传",
"processing": "图片处理",
"clientCompress": "客户端预压缩",
"clientCompressHint": "上传前在浏览器端压缩(服务端仍会转 WebP)",
"autoDeletePrefix": "自动删除超过",
"autoDeleteSuffix": "天的图片",
"autoDeleteHint": "仅清理开启后上传的图片,不影响此前已存的图片",
"webpQuality": "WebP 压缩质量",
"webpQualityHint": "客户端预压缩质量(1–100),需开启「客户端预压缩」",
"current": "当前:{n}",
"qualityHigh": "高质量",
"qualityMedium": "中等质量",
"qualityLow": "有损压缩",
"convenience": "便捷功能",
"autoCopy": "上传成功自动复制链接",
"autoCopyHint": "按所选格式复制到剪贴板"
},
"settings": {
"title": "系统设置",
"subtitle": "保存后写入数据库;环境变量优先级更高",
"uploadAccount": "上传与账号",
"featureToggles": "功能开关",
"defaultFolder": "默认上传目录",
"defaultFolderHint": "保存在本机浏览器,仅影响您的上传目标目录",
"allowRegistration": "开放用户注册",
"allowRegistrationHint": "允许新用户自行注册(普通用户仅可见自己的图片)",
"accessControl": "访问控制",
"refererHint": "Referer 防盗链,逗号分隔允许引用的域名,留空不限制",
"refererPlaceholder": "example.com, www.example.com",
"linkGeneration": "域名与链接",
"domainSettings": "域名配置",
"storagePathSettings": "存储路径与直链",
"domainSeparationActive": "已启用域名分离:图片域仅出图,管理域提供后台与 API",
"siteBaseUrl": "网站域名",
"siteBaseUrlHint": "末尾不加 /;管理后台、API 文档与 Twikoo 上传地址",
"siteBaseUrlPlaceholder": "https://admin.example.com",
"siteBaseUrlActive": "当前生效:{url}",
"imageBaseUrl": "图片域名",
"imageBaseUrlHint": "末尾不加 /;生成图片直链地址",
"imageBaseUrlSingleHint": "末尾不加 /;留空则使用当前访问域名;单域名部署无需填写网站域名",
"imageBaseUrlPlaceholder": "https://pic.example.com",
"imageBaseUrlActive": "当前生效:{url}",
"hideFolderInUrl": "隐藏直链目录前缀",
"hideFolderInUrlHint": "开启后直链为 /xxx.webp,不含 images/ 或日期路径;旧链接仍可用",
"hideFolderConfirmTitle": "确认隐藏目录前缀?",
"hideFolderConfirmDesc": "开启后不同目录下若存在相同文件名(如 images/a.webp 与 blog/a.webp),短链接 /a.webp 将优先解析到 images 目录,可能访问到非预期的图片。仅单一上传目录或文件名全局唯一时建议开启。",
"hideFolderConfirmEnable": "仍要开启",
"storageUseDatePath": "上传目录按年/月分组",
"storageUseDatePathHint": "开启后保存为 images/2026/08/xxx.webp;关闭则为 images/xxx.webp。仅影响启用后的新上传",
"badgeEnv": ".env",
"badgeSaved": "已保存",
"badgeDefault": "默认",
"badgeUnset": "未设置",
"tokenEnv": "环境变量",
"tokenDb": "后台生成",
"tokenNone": "未配置",
"apiTokenLine": "API Token · {source} ·",
"viewReleases": "查看发行版",
"updateAvailable": "有新版本 {version}",
"unsavedChanges": "有未保存的更改",
"manageToken": "管理 Token",
"envOverride": "该项已由环境变量覆盖,无法在后台修改",
"saved": "服务器配置已保存",
"saveFailed": "保存服务器配置失败",
"loadFailed": "加载设置失败"
},
"storage": {
"title": "存储设置",
"subtitle": "新上传写入活动后端;已有图片仍按原后端读取",
"activeBackend": "活动后端",
"backendLocal": "本地磁盘",
"backendCloud": "云存储",
"backendS3": "云存储",
"switchHint": "切换仅影响新上传,已有图片不受影响",
"provider": "预设",
"providerR2": "Cloudflare R2",
"providerCos": "腾讯云 COS",
"providerOss": "阿里云 OSS",
"providerAws": "AWS S3",
"providerCustom": "自定义",
"endpoint": "Endpoint",
"region": "Region",
"bucket": "Bucket",
"prefix": "对象前缀(可选)",
"prefixPlaceholder": "例如 pichost",
"forcePathStyle": "强制 Path-Style 访问",
"accessKey": "Access Key",
"secretKey": "Secret Key",
"servingMode": "访问方式",
"servingProxy": "代理(PicHost 同源)",
"servingPublic": "公开 URL(302 到 CDN)",
"servingHint": "公开模式下 Referer 防盗链需在 CDN/桶策略侧配置",
"publicUrl": "公开访问 URL",
"publicUrlPlaceholder": "https://cdn.example.com",
"testConnection": "测试连接",
"testSuccess": "连接成功",
"testFailed": "连接失败",
"saved": "存储配置已保存",
"saveFailed": "保存存储配置失败",
"loadFailed": "加载存储配置失败",
"envOverride": "存储配置已由环境变量覆盖,无法在后台修改",
"pageTitle": "存储管理",
"pageSubtitle": "管理多个存储后端,新上传写入默认后端",
"addBackend": "添加存储",
"editBackend": "编辑存储",
"formCreateHint": "配置云存储连接,保存后可设为默认写入目标",
"formEditHint": "修改连接信息;留空密钥则保持原值",
"sectionBasic": "基本信息",
"sectionConnection": "连接配置",
"sectionCredentials": "访问凭证",
"sectionAccess": "访问方式",
"sectionQuota": "容量配额",
"advancedOptions": "高级选项",
"servingProxyShort": "代理",
"servingPublicShort": "公开",
"backendName": "显示名称",
"badgeDefault": "默认",
"badgeDisabled": "已停用",
"badgeEnabled": "已启用",
"statTotal": "总量",
"statUsed": "已用",
"statFree": "剩余",
"usageRate": "使用率",
"indexImages": "PicHost 图片 {count} 张 · {size}",
"setDefault": "设为默认",
"actionSetDefault": "设默认",
"actionTest": "测试",
"disable": "停用",
"enable": "启用",
"usageCount": "{count} 张图片",
"quotaTotal": "套餐总量",
"quotaPlaceholder": "例如 10",
"quotaHint": "填写云存储套餐总量;已用按 PicHost 图片索引自动统计",
"quotaRequired": "请填写有效的套餐总量",
"notConfigured": "未配置",
"created": "存储后端已添加",
"deleted": "存储后端已删除",
"deleteFailed": "删除失败",
"deleteBlockedImages": "该后端仍有图片,请先迁移或删除图片后再删除",
"defaultUpdated": "默认后端已更新",
"deleteConfirmTitle": "删除存储",
"deleteConfirmBody": "确定删除「{name}」?此操作不可撤销。",
"secretsRequired": "新建存储需填写 Access Key 与 Secret Key",
"manageStorage": "管理对象存储",
"manageStorageHint": "配置 R2 / S3 / COS 等多后端与直链策略"
},
"stats": {
"title": "统计",
"subtitle": "数据概览与图库记录",
"uploadToday": "今日上传",
"uploadMonth": "本月上传",
"uploadTotal": "累计上传",
"storedCount": "当前库存",
"deleteToday": "今日删除",
"deleteMonth": "本月删除",
"deleteTotal": "累计删除",
"userCount": "注册用户数量",
"uploadBytes": "累计上传体积",
"folderDistribution": "上传目录分布",
"folderUsage": "目录占用",
"noData": "暂无数据",
"folderCount": "{count} 张 · {size}",
"gallery": "图库记录",
"folder": "目录",
"filterStorageAll": "全部存储",
"storage": "存储",
"searchPlaceholder": "搜索文件名",
"clear": "清除",
"selected": "已选 {n}",
"batchDelete": "批量删除",
"scrollTop": "回到顶部",
"loadFailed": "加载统计失败",
"selectFirst": "请先选择图片",
"deleted": "已删除 {n} 张图片",
"deletePartialFail": "{n} 张图片删除失败",
"deleteFailed": "删除失败",
"deletedSingle": "已删除",
"allFolders": "全部",
"counting": "统计中…",
"summarySearch": "{folder} · 搜索「{q}」· 共 {total} 张",
"summaryTotal": "{folder} · 共 {total} 张",
"summarySearchStorage": "{folder} · {storage} · 搜索「{q}」· 共 {total} 张",
"summaryTotalStorage": "{folder} · {storage} · 共 {total} 张"
},
"logs": {
"pageTitle": "操作日志",
"pageSubtitle": "查看您的上传与删除记录",
"pageSubtitleAdmin": "查看所有用户的上传与删除记录",
"loadFailed": "加载操作日志失败",
"empty": "暂无操作记录",
"entriesUnit": "条记录",
"filterActionAll": "全部操作",
"filterSourceAll": "全部来源",
"filterUserAll": "全部用户",
"searchPlaceholder": "搜索文件名",
"summaryAll": "全部 · 共 {total} 条记录,上传 {upload} 条,删除 {delete} 条",
"actionUpload": "上传",
"actionDelete": "删除",
"sourceWeb": "网页",
"sourceApi": "API",
"sourceTwikoo": "Twikoo",
"colTime": "时间",
"colUser": "用户",
"colStorage": "存储",
"colAction": "操作",
"colSource": "来源",
"colFile": "文件名",
"colFolder": "目录",
"colSize": "大小",
"colKey": "路径"
},
"image": {
"empty": "暂无图片",
"previewFailed": "预览失败",
"uploader": "上传者:{name}",
"storageBackend": "存储:{name}"
},
"delete": {
"confirmTitle": "确认删除",
"confirmDesc": "确定要删除选中的 {count} 张图片吗?此操作不可恢复。",
"confirm": "确认删除"
},
"pagination": {
"pageOf": "第 {page} / {totalPages} 页 · 共 {total} {unit}",
"totalOnly": "共 {total} {unit}",
"first": "第一页",
"prev": "上一页",
"next": "下一页",
"last": "最后一页"
},
"api": {
"tokenTitle": "API Token",
"envOverrideTitle": "Token 由环境变量覆盖",
"envOverrideDesc": "修改 API_UPLOAD_TOKEN 后需重启服务;后台无法重新生成。",
"personalTitle": "个人 API Token",
"personalDesc": "此 Token 仅用于您自己的脚本上传,上传的图片归属您的账号。",
"notConfiguredTitle": "尚未配置 Token",
"notConfiguredDesc": "点击「重新生成」创建 Token,用于下方所有接口的 Auth-Token 鉴权。",
"regenerate": "重新生成",
"tokenSourceEnv": "来源:环境变量 API_UPLOAD_TOKEN",
"tokenSourceDb": "来源:后台生成(可重新生成)",
"tokenSourceNone": "来源:未配置",
"tokenHeader": "请求头:",
"tokenInvalidate": "重新生成后旧 Token 立即失效",
"docsTitle": "API 文档",
"curlExample": "cURL 示例",
"loadFailed": "加载 API 设置失败",
"regenerateEnvBlocked": "环境变量已配置 Token,无法在后台重新生成",
"regenerateFailed": "重新生成 Token 失败",
"confirmRegenerateTitle": "确认重新生成 Token?",
"confirmRegenerateDesc": "旧 Token 将立即失效,使用旧 Token 的脚本与自动化任务将无法继续调用接口,直到你更新为新 Token。",
"confirmRegenerate": "确认重新生成",
"newTokenTitle": "新 Token 已生成",
"newTokenDesc": "请立即复制保存。关闭弹窗后仍可在上方输入框复制,但请尽快更新到你的脚本配置中。",
"copyToken": "复制 Token",
"saved": "我已保存",
"docs": {
"upload": {
"title": "上传图片",
"descAdmin": "上传单张或多张图片。字段 image 传图片,folder 指定目录(不存在则自动创建);仍兼容 file、files、type。",
"descUser": "上传单张或多张图片。字段 image 传图片;仍兼容 file、files。图片固定存入 images/ 目录。",
"curlCustomFolder": "# 上传到自定义目录 blog/"
},
"list": {
"title": "获取图片列表",
"descAdmin": "分页列出图库,folder 可筛选目录,limit 默认 20、最大 100。",
"descUser": "分页列出您的图库,limit 默认 20、最大 100。",
"curlFolderFilter": "# 按目录筛选"
},
"search": {
"title": "搜索图片",
"desc": "按文件名或路径关键词搜索,参数 q 必填。"
},
"delete": {
"title": "删除图片",
"desc": "通过 key 删除单张图片,key 为存储路径(如 images/2026/08/xxx.webp)。"
},
"batchDelete": {
"title": "批量删除",
"desc": "一次删除多张图片,请求体为 keys 数组。"
},
"twikoo": {
"title": "twikoo 兼容上传",
"desc": "兼容 Twikoo / EasyImage 2.0 协议,图片存入 twikoo/ 目录;字段 image + token。"
}
}
},
"userSettings": {
"loadFailed": "加载设置失败",
"saveFailed": "保存设置失败"
}
}
+28 -1
View File
@@ -11,7 +11,8 @@ const pkg = JSON.parse(readFileSync(join(rootDir, 'package.json'), 'utf8')) as {
export default defineNuxtConfig({ export default defineNuxtConfig({
modules: [ modules: [
'@nuxt/eslint', '@nuxt/eslint',
'@nuxt/ui' '@nuxt/ui',
'@nuxtjs/i18n'
], ],
devtools: { devtools: {
@@ -29,10 +30,16 @@ export default defineNuxtConfig({
appVersion: pkg.version, appVersion: pkg.version,
devBypassAccess: process.env.DEV_BYPASS_ACCESS === 'true', devBypassAccess: process.env.DEV_BYPASS_ACCESS === 'true',
adminSecret: '', adminSecret: '',
siteBaseUrl: '',
imageBaseUrl: '', imageBaseUrl: '',
apiUploadToken: '' apiUploadToken: ''
}, },
devServer: {
host: '127.0.0.1',
port: 3000
},
compatibilityDate: '2026-06-30', compatibilityDate: '2026-06-30',
nitro: { nitro: {
@@ -42,6 +49,12 @@ export default defineNuxtConfig({
} }
}, },
vite: {
server: {
allowedHosts: true
}
},
eslint: { eslint: {
config: { config: {
stylistic: { stylistic: {
@@ -49,5 +62,19 @@ export default defineNuxtConfig({
braceStyle: '1tbs' braceStyle: '1tbs'
} }
} }
},
i18n: {
locales: [
{ code: 'zh-CN', name: '简体中文', language: 'zh-CN', file: 'zh-CN.json' },
{ code: 'en', name: 'English', language: 'en', file: 'en.json' }
],
defaultLocale: 'zh-CN',
langDir: 'locales',
strategy: 'no_prefix',
detectBrowserLanguage: {
cookieKey: 'pic_locale',
fallbackLocale: 'zh-CN'
}
} }
}) })
+2345 -89
View File
File diff suppressed because it is too large. Load diff
+7 -5
View File
@@ -1,7 +1,7 @@
{ {
"name": "pichost", "name": "pichost",
"version": "1.0.1", "version": "1.1.4",
"description": "Self-hosted image hosting for your NAS / home server — Nuxt admin UI, local disk storage, server-side WebP compression, referer protection, Twikoo/EasyImage compatible.", "description": "Self-hosted image hosting for your NAS / home server — Nuxt admin UI, local disk or S3-compatible storage, server-side WebP compression, referer protection, Twikoo/EasyImage compatible.",
"homepage": "https://github.com/O96u/PicHost#readme", "homepage": "https://github.com/O96u/PicHost#readme",
"repository": { "repository": {
"type": "git", "type": "git",
@@ -19,7 +19,7 @@
"twikoo", "twikoo",
"easyimage" "easyimage"
], ],
"license": "MIT", "license": "GPL-3.0-only",
"private": true, "private": true,
"type": "module", "type": "module",
"packageManager": "npm@11.12.1", "packageManager": "npm@11.12.1",
@@ -32,12 +32,14 @@
"typecheck": "nuxt typecheck", "typecheck": "nuxt typecheck",
"test": "vitest run", "test": "vitest run",
"test:watch": "vitest", "test:watch": "vitest",
"start": "node .output/server/index.mjs" "start": "node .output/server/index.mjs",
"reset-password": "node server/cli/reset-password.mjs"
}, },
"dependencies": { "dependencies": {
"@aws-sdk/client-s3": "^3.876.0",
"@iconify-json/lucide": "^1.2.114", "@iconify-json/lucide": "^1.2.114",
"@nuxt/ui": "^4.9.0", "@nuxt/ui": "^4.9.0",
"jose": "^6.0.11", "@nuxtjs/i18n": "^10.6.0",
"nuxt": "^4.4.8", "nuxt": "^4.4.8",
"sharp": "^0.35.3", "sharp": "^0.35.3",
"tailwindcss": "^4.3.1" "tailwindcss": "^4.3.1"
+39
View File
@@ -0,0 +1,39 @@
import { requireAdminAuth } from '../../../utils/access'
import { createApiError } from '../../../utils/api-error'
import {
deleteStorageBackend,
isStorageEnvConfigured,
LOCAL_BACKEND_ID
} from '../../../utils/storage-backends'
export default defineEventHandler(async (event) => {
await requireAdminAuth(event)
if (isStorageEnvConfigured()) {
createApiError(event, 'FORBIDDEN', '存储配置已由环境变量覆盖,无法在后台修改', 409)
}
const id = getRouterParam(event, 'id')
if (!id) {
createApiError(event, 'INVALID_REQUEST', '缺少存储后端 ID', 400)
}
if (id === LOCAL_BACKEND_ID) {
createApiError(event, 'INVALID_REQUEST', '本地磁盘后端不可删除', 400)
}
try {
deleteStorageBackend(id)
} catch (error) {
const message = error instanceof Error ? error.message : '删除失败'
if (message.includes('stored images')) {
createApiError(event, 'INVALID_REQUEST', '该后端仍有图片,无法删除', 400)
}
if (message.includes('default backend')) {
createApiError(event, 'INVALID_REQUEST', '请先切换默认后端后再删除', 400)
}
createApiError(event, 'INVALID_REQUEST', message, 400)
}
return { success: true }
})
+129
View File
@@ -0,0 +1,129 @@
import { requireAdminAuth } from '../../../utils/access'
import { createApiError } from '../../../utils/api-error'
import {
isStorageEnvConfigured,
LOCAL_BACKEND_ID,
updateStorageBackend,
getStorageBackendRow,
setDefaultBackend,
type StorageBackendPatch
} from '../../../utils/storage-backends'
import { parseS3Config, validateS3Config } from '../../../utils/storage/s3'
import { parseQuotaBytes } from '../../../utils/storage-capacity'
import type { ServingMode } from '../../../utils/storage/types'
interface StoragePatchBody {
name?: string
config?: {
endpoint?: string
region?: string
bucket?: string
prefix?: string
forcePathStyle?: boolean
}
secrets?: {
accessKeyId?: string
secretAccessKey?: string
}
servingMode?: ServingMode
publicUrl?: string
quotaBytes?: number | null
enabled?: boolean
isDefault?: boolean
}
export default defineEventHandler(async (event) => {
await requireAdminAuth(event)
if (isStorageEnvConfigured()) {
createApiError(event, 'FORBIDDEN', '存储配置已由环境变量覆盖,无法在后台修改', 409)
}
const id = getRouterParam(event, 'id')
if (!id) {
createApiError(event, 'INVALID_REQUEST', '缺少存储后端 ID', 400)
}
const row = getStorageBackendRow(id)
if (!row) {
createApiError(event, 'INVALID_REQUEST', '存储后端不存在', 404)
}
const body = await readBody<StoragePatchBody>(event).catch(
(): StoragePatchBody => ({})
)
if (id === LOCAL_BACKEND_ID && body.isDefault === false) {
createApiError(event, 'INVALID_REQUEST', '必须保留一个默认存储后端', 400)
}
const patch: StorageBackendPatch = {}
if (body.name !== undefined) {
patch.name = body.name.trim() || row.name
}
if (body.config !== undefined && row.type === 's3') {
const current = parseS3Config(row.config_json) ?? {
endpoint: '',
region: 'auto',
bucket: ''
}
const next = {
endpoint: body.config.endpoint?.trim() ?? current.endpoint,
region: body.config.region?.trim() ?? current.region,
bucket: body.config.bucket?.trim() ?? current.bucket,
prefix: body.config.prefix?.trim() || undefined,
forcePathStyle: body.config.forcePathStyle ?? current.forcePathStyle
}
const error = validateS3Config(next)
if (error) {
createApiError(event, 'INVALID_REQUEST', error, 400)
}
patch.config = next
}
if (body.secrets !== undefined && row.type === 's3') {
patch.secrets = body.secrets
}
if (body.servingMode !== undefined) {
if (body.servingMode !== 'proxy' && body.servingMode !== 'public') {
createApiError(event, 'INVALID_REQUEST', '访问方式无效', 400)
}
patch.servingMode = body.servingMode
}
if (body.publicUrl !== undefined) {
patch.publicUrl = body.publicUrl.trim()
}
if (body.quotaBytes !== undefined) {
if (row.type === 's3') {
const quotaBytes = parseQuotaBytes(body.quotaBytes)
if (!quotaBytes) {
createApiError(event, 'INVALID_REQUEST', '请填写有效的存储套餐总量', 400)
}
patch.quotaBytes = quotaBytes
}
}
if (body.enabled !== undefined) {
if (id === LOCAL_BACKEND_ID && !body.enabled) {
createApiError(event, 'INVALID_REQUEST', '本地磁盘后端不可停用', 400)
}
patch.enabled = body.enabled
}
if (body.isDefault !== undefined) {
patch.isDefault = body.isDefault
}
updateStorageBackend(id, patch)
if (body.isDefault) {
setDefaultBackend(id)
}
return { success: true }
})
@@ -0,0 +1,34 @@
import { requireAdminAuth } from '../../../../utils/access'
import { createApiError } from '../../../../utils/api-error'
import {
createBackendInstance,
getStorageBackendRow,
isStorageEnvConfigured
} from '../../../../utils/storage-backends'
export default defineEventHandler(async (event) => {
await requireAdminAuth(event)
if (isStorageEnvConfigured()) {
createApiError(event, 'FORBIDDEN', '存储配置已由环境变量覆盖,无法在后台测试', 409)
}
const id = getRouterParam(event, 'id')
if (!id) {
createApiError(event, 'INVALID_REQUEST', '缺少存储后端 ID', 400)
}
const row = getStorageBackendRow(id)
if (!row) {
createApiError(event, 'INVALID_REQUEST', '存储后端不存在', 404)
}
try {
const backend = createBackendInstance(row)
const result = await backend.testConnection()
return result
} catch (error) {
const message = error instanceof Error ? error.message : '连接失败'
return { ok: false, message }
}
})
+39
View File
@@ -0,0 +1,39 @@
import { requireAdminAuth } from '../../../utils/access'
import {
getBackendUsageStats,
isStorageEnvConfigured,
listStorageBackends
} from '../../../utils/storage-backends'
import { buildBackendCapacity } from '../../../utils/storage-capacity'
import { getActiveBackendRow } from '../../../utils/storage/resolver'
import type { StorageBackendInfo } from '../../../utils/storage/types'
export default defineEventHandler(async (event) => {
await requireAdminAuth(event)
const rows = listStorageBackends()
const backends = await Promise.all(rows.map(async (backend: StorageBackendInfo & {
secretsMasked: Record<string, string>
}) => {
const usage = getBackendUsageStats(backend.id)
const capacity = await buildBackendCapacity(
backend.id,
backend.type,
usage.bytes,
backend.quotaBytes
)
return {
...backend,
usage,
capacity
}
}))
const activeBackend = getActiveBackendRow()
return {
backends,
activeBackendId: activeBackend?.id ?? 'local',
envOverride: isStorageEnvConfigured()
}
})
+83
View File
@@ -0,0 +1,83 @@
import { requireAdminAuth } from '../../../utils/access'
import { createApiError } from '../../../utils/api-error'
import {
insertStorageBackend,
isStorageEnvConfigured
} from '../../../utils/storage-backends'
import { validateS3Config } from '../../../utils/storage/s3'
import { parseQuotaBytes } from '../../../utils/storage-capacity'
import type { ServingMode } from '../../../utils/storage/types'
interface StorageCreateBody {
name?: string
config?: {
endpoint?: string
region?: string
bucket?: string
prefix?: string
forcePathStyle?: boolean
}
secrets?: {
accessKeyId?: string
secretAccessKey?: string
}
servingMode?: ServingMode
publicUrl?: string
quotaBytes?: number | null
enabled?: boolean
isDefault?: boolean
}
export default defineEventHandler(async (event) => {
await requireAdminAuth(event)
if (isStorageEnvConfigured()) {
createApiError(event, 'FORBIDDEN', '存储配置已由环境变量覆盖,无法在后台修改', 409)
}
const body = await readBody<StorageCreateBody>(event).catch(
(): StorageCreateBody => ({})
)
const name = body.name?.trim() || 'S3 兼容存储'
const config = {
endpoint: body.config?.endpoint?.trim() ?? '',
region: body.config?.region?.trim() || 'auto',
bucket: body.config?.bucket?.trim() ?? '',
prefix: body.config?.prefix?.trim() || undefined,
forcePathStyle: body.config?.forcePathStyle ?? false
}
const error = validateS3Config(config)
if (error) {
createApiError(event, 'INVALID_REQUEST', error, 400)
}
const accessKeyId = body.secrets?.accessKeyId?.trim() ?? ''
const secretAccessKey = body.secrets?.secretAccessKey?.trim() ?? ''
if (!accessKeyId || !secretAccessKey) {
createApiError(event, 'INVALID_REQUEST', 'Access Key 与 Secret Key 不能为空', 400)
}
if (body.servingMode && body.servingMode !== 'proxy' && body.servingMode !== 'public') {
createApiError(event, 'INVALID_REQUEST', '访问方式无效', 400)
}
const quotaBytes = parseQuotaBytes(body.quotaBytes)
if (!quotaBytes) {
createApiError(event, 'INVALID_REQUEST', '请填写有效的存储套餐总量', 400)
}
const id = insertStorageBackend({
name,
config,
secrets: { accessKeyId, secretAccessKey },
servingMode: body.servingMode,
publicUrl: body.publicUrl?.trim(),
quotaBytes,
enabled: body.enabled,
isDefault: body.isDefault
})
return { success: true, id }
})
+3 -1
View File
@@ -72,7 +72,9 @@ export default defineEventHandler(async (event) => {
originalName: existing.originalName, originalName: existing.originalName,
size: existing.size, size: existing.size,
contentType: existing.contentType, contentType: existing.contentType,
source: resolveActivitySource(event) source: resolveActivitySource(event),
userId: existing.userId ?? null,
backendId: existing.backendId ?? null
}) })
logInfo('delete success', { key, size: existing.size }) logInfo('delete success', { key, size: existing.size })
+7 -1
View File
@@ -1,5 +1,7 @@
import { getImageUserFilter, requireApiOrAdminAuth } from '../../utils/access' import { getImageUserFilter, requireApiOrAdminAuth } from '../../utils/access'
import { isValidFolderName } from '../../utils/image-key' import { isValidFolderName } from '../../utils/image-key'
import { readBackendIdQuery } from '../../utils/image-query'
import { createApiError } from '../../utils/api-error'
import { countImages } from '../../utils/storage' import { countImages } from '../../utils/storage'
export default defineEventHandler(async (event) => { export default defineEventHandler(async (event) => {
@@ -9,8 +11,12 @@ export default defineEventHandler(async (event) => {
const query = getQuery(event) const query = getQuery(event)
const raw = typeof query.folder === 'string' ? query.folder.trim() : '' const raw = typeof query.folder === 'string' ? query.folder.trim() : ''
const folder = raw && raw !== 'all' && isValidFolderName(raw) ? raw : undefined const folder = raw && raw !== 'all' && isValidFolderName(raw) ? raw : undefined
const backendId = readBackendIdQuery(query)
if (backendId === null) {
createApiError(event, 'INVALID_REQUEST', '无效的存储后端', 400)
}
const total = await countImages(folder, userFilter) const total = await countImages(folder, userFilter, backendId)
return { total } return { total }
}) })
+3 -1
View File
@@ -42,7 +42,9 @@ export default defineEventHandler(async (event) => {
originalName: existing.originalName, originalName: existing.originalName,
size: existing.size, size: existing.size,
contentType: existing.contentType, contentType: existing.contentType,
source: resolveActivitySource(event) source: resolveActivitySource(event),
userId: existing.userId ?? null,
backendId: existing.backendId ?? null
}) })
logInfo('delete success', { key, size: existing.size }) logInfo('delete success', { key, size: existing.size })
+10 -2
View File
@@ -5,9 +5,12 @@ import {
DEFAULT_LIST_LIMIT, DEFAULT_LIST_LIMIT,
MAX_LIST_LIMIT MAX_LIST_LIMIT
} from '../../utils/constants' } from '../../utils/constants'
import { createApiError } from '../../utils/api-error'
import { listUserIdUsernameMap } from '../../utils/db' import { listUserIdUsernameMap } from '../../utils/db'
import { mapStoredImageToItem } from '../../utils/image-response' import { mapStoredImageToItem } from '../../utils/image-response'
import { listStorageBackendNameMap } from '../../utils/storage-backends'
import { isValidFolderName } from '../../utils/image-key' import { isValidFolderName } from '../../utils/image-key'
import { readBackendIdQuery } from '../../utils/image-query'
import { listImages } from '../../utils/storage' import { listImages } from '../../utils/storage'
function readFolderQuery(query: Record<string, unknown>): string | undefined { function readFolderQuery(query: Record<string, unknown>): string | undefined {
@@ -32,14 +35,19 @@ export default defineEventHandler(async (event) => {
: 1 : 1
const folder = readFolderQuery(query) const folder = readFolderQuery(query)
const backendId = readBackendIdQuery(query)
if (backendId === null) {
createApiError(event, 'INVALID_REQUEST', '无效的存储后端', 400)
}
const userFilter = await getImageUserFilter(event) const userFilter = await getImageUserFilter(event)
const listing = await listImages({ limit, page, folder, userFilter }) const listing = await listImages({ limit, page, folder, userFilter, backendId })
const user = await getCurrentUser(event) const user = await getCurrentUser(event)
const ownerMap = user?.role === 'admin' ? listUserIdUsernameMap() : undefined const ownerMap = user?.role === 'admin' ? listUserIdUsernameMap() : undefined
const backendMap = listStorageBackendNameMap()
const items = listing.items.map(stored => const items = listing.items.map(stored =>
mapStoredImageToItem(event, stored, { ownerMap }) mapStoredImageToItem(event, stored, { ownerMap, backendMap })
) )
items.sort((a, b) => b.uploadedAt.localeCompare(a.uploadedAt)) items.sort((a, b) => b.uploadedAt.localeCompare(a.uploadedAt))
+9 -2
View File
@@ -8,7 +8,9 @@ import {
import { createApiError } from '../../utils/api-error' import { createApiError } from '../../utils/api-error'
import { listUserIdUsernameMap } from '../../utils/db' import { listUserIdUsernameMap } from '../../utils/db'
import { mapStoredImageToItem } from '../../utils/image-response' import { mapStoredImageToItem } from '../../utils/image-response'
import { listStorageBackendNameMap } from '../../utils/storage-backends'
import { isValidFolderName } from '../../utils/image-key' import { isValidFolderName } from '../../utils/image-key'
import { readBackendIdQuery } from '../../utils/image-query'
import { searchImages } from '../../utils/storage' import { searchImages } from '../../utils/storage'
function readFolderQuery(query: Record<string, unknown>): string | undefined { function readFolderQuery(query: Record<string, unknown>): string | undefined {
@@ -42,14 +44,19 @@ export default defineEventHandler(async (event) => {
: 1 : 1
const folder = readFolderQuery(query) const folder = readFolderQuery(query)
const backendId = readBackendIdQuery(query)
if (backendId === null) {
createApiError(event, 'INVALID_REQUEST', '无效的存储后端', 400)
}
const userFilter = await getImageUserFilter(event) const userFilter = await getImageUserFilter(event)
const result = await searchImages({ query: q, limit, page, folder, userFilter }) const result = await searchImages({ query: q, limit, page, folder, userFilter, backendId })
const user = await getCurrentUser(event) const user = await getCurrentUser(event)
const ownerMap = user?.role === 'admin' ? listUserIdUsernameMap() : undefined const ownerMap = user?.role === 'admin' ? listUserIdUsernameMap() : undefined
const backendMap = listStorageBackendNameMap()
const items = result.items.map(stored => const items = result.items.map(stored =>
mapStoredImageToItem(event, stored, { ownerMap }) mapStoredImageToItem(event, stored, { ownerMap, backendMap })
) )
items.sort((a, b) => b.uploadedAt.localeCompare(a.uploadedAt)) items.sort((a, b) => b.uploadedAt.localeCompare(a.uploadedAt))
+10 -3
View File
@@ -16,6 +16,7 @@ import {
isValidFolderName, isValidFolderName,
normalizeFolderName normalizeFolderName
} from '../../utils/image-key' } from '../../utils/image-key'
import { checkUploadRateLimit } from '../../utils/rate-limit'
function readFormText( function readFormText(
formData: { name?: string, data?: Buffer | Uint8Array }[], formData: { name?: string, data?: Buffer | Uint8Array }[],
@@ -29,14 +30,20 @@ function readFormText(
export default defineEventHandler(async (event) => { export default defineEventHandler(async (event) => {
await requireUploadAuth(event) await requireUploadAuth(event)
const source = verifyApiUploadToken(event) ? 'api' : 'web'
const uploadUserId = await getUploadUserId(event)
const formData = await readMultipartFormData(event) const formData = await readMultipartFormData(event)
if (!formData?.length) { if (!formData?.length) {
createApiError(event, 'INVALID_REQUEST', '未收到上传文件', 400) createApiError(event, 'INVALID_REQUEST', '未收到上传文件', 400)
} }
const tokenField = formData.find(part => part.name === 'token')
const formToken = tokenField?.data?.length
? new TextDecoder().decode(tokenField.data).trim()
: ''
checkUploadRateLimit(event, formToken)
const source = verifyApiUploadToken(event) ? 'api' : 'web'
const uploadUserId = await getUploadUserId(event)
const query = getQuery(event) const query = getQuery(event)
const folderFromForm = readFormText(formData, 'folder') || readFormText(formData, 'type') const folderFromForm = readFormText(formData, 'folder') || readFormText(formData, 'type')
const folderFromQuery = typeof query.folder === 'string' const folderFromQuery = typeof query.folder === 'string'
+45 -5
View File
@@ -1,17 +1,21 @@
import { requireAdminAuth } from '../../utils/access' import { requireUserAuth } from '../../utils/access'
import { createApiError } from '../../utils/api-error' import { createApiError } from '../../utils/api-error'
import { logException } from '../../utils/logger'
import { import {
listActivityLogs, listActivityLogs,
listUserIdUsernameMap,
summarizeActivityLogs,
type LogAction, type LogAction,
type LogSource type LogSource
} from '../../utils/db' } from '../../utils/db'
import { ensureStorageSchema } from '../../utils/storage-backends'
import { import {
DEFAULT_LIST_LIMIT, DEFAULT_LIST_LIMIT,
MAX_LIST_LIMIT MAX_LIST_LIMIT
} from '../../utils/constants' } from '../../utils/constants'
export default defineEventHandler(async (event) => { export default defineEventHandler(async (event) => {
await requireAdminAuth(event) const user = await requireUserAuth(event)
const query = getQuery(event) const query = getQuery(event)
@@ -34,13 +38,34 @@ export default defineEventHandler(async (event) => {
? query.folder.trim() ? query.folder.trim()
: undefined : undefined
const searchRaw = typeof query.q === 'string' ? query.q.trim() : ''
const search = searchRaw || undefined
const pageRaw = Number(query.page ?? 1) const pageRaw = Number(query.page ?? 1)
const page = Number.isFinite(pageRaw) && pageRaw > 0 const page = Number.isFinite(pageRaw) && pageRaw > 0
? Math.floor(pageRaw) ? Math.floor(pageRaw)
: 1 : 1
let userId: number | undefined
if (user.role === 'admin') {
const userIdRaw = query.userId
if (userIdRaw !== undefined && userIdRaw !== '' && userIdRaw !== 'all') {
const parsed = Number(userIdRaw)
if (!Number.isFinite(parsed) || parsed <= 0) {
createApiError(event, 'INVALID_REQUEST', '无效的用户筛选', 400)
}
userId = Math.floor(parsed)
}
} else {
userId = user.id
}
try { try {
const result = listActivityLogs({ limit, page, action, source, folder }) ensureStorageSchema()
const result = listActivityLogs({ limit, page, action, source, folder, userId, search })
const summary = summarizeActivityLogs({ source, folder, userId, search })
const userMap = user.role === 'admin' ? listUserIdUsernameMap() : undefined
return { return {
items: result.items.map(row => ({ items: result.items.map(row => ({
id: row.id, id: row.id,
@@ -50,15 +75,30 @@ export default defineEventHandler(async (event) => {
size: row.size, size: row.size,
contentType: row.content_type, contentType: row.content_type,
source: row.source, source: row.source,
userId: row.user_id,
username: row.username,
storage: row.backend_id
? {
id: row.backend_id,
name: row.backend_name ?? row.backend_id,
type: (row.backend_type === 'local' ? 'local' : 's3') as 'local' | 's3'
}
: null,
createdAt: row.created_at createdAt: row.created_at
})), })),
total: result.total, total: result.total,
page: result.page, page: result.page,
pageSize: result.pageSize, pageSize: result.pageSize,
totalPages: result.totalPages totalPages: result.totalPages,
summary,
users: userMap
? Array.from(userMap.entries())
.map(([id, username]) => ({ id, username }))
.sort((a, b) => a.username.localeCompare(b.username))
: undefined
} }
} catch (error) { } catch (error) {
console.error('[logs] list failed', error) logException('activity logs list failed', error)
createApiError(event, 'INVALID_REQUEST', '读取操作记录失败', 500) createApiError(event, 'INVALID_REQUEST', '读取操作记录失败', 500)
} }
}) })
+49 -1
View File
@@ -1,9 +1,13 @@
import { requireAdminAuth } from '../../utils/access' import { requireAdminAuth } from '../../utils/access'
import { createApiError } from '../../utils/api-error' import { createApiError } from '../../utils/api-error'
import { import {
getSetting,
setSetting, setSetting,
SETTINGS_ALLOWED_REFERER_HOSTS, SETTINGS_ALLOWED_REFERER_HOSTS,
SETTINGS_HIDE_FOLDER_IN_URL,
SETTINGS_STORAGE_USE_DATE_PATH,
SETTINGS_IMAGE_BASE_URL, SETTINGS_IMAGE_BASE_URL,
SETTINGS_SITE_BASE_URL,
SETTINGS_WEBP_QUALITY, SETTINGS_WEBP_QUALITY,
setAllowRegistration setAllowRegistration
} from '../../utils/db' } from '../../utils/db'
@@ -11,18 +15,24 @@ import {
getSettingsPayload, getSettingsPayload,
isValidImageBaseUrl, isValidImageBaseUrl,
isValidRefererHost, isValidRefererHost,
isValidSiteBaseUrl,
MAX_AUTO_DELETE_DAYS, MAX_AUTO_DELETE_DAYS,
normalizeImageBaseUrl, normalizeImageBaseUrl,
normalizeRefererHosts, normalizeRefererHosts,
normalizeSiteBaseUrl,
parseAutoDeleteDays, parseAutoDeleteDays,
parseWebpQuality, parseWebpQuality,
setGlobalAutoDeletePolicy setGlobalAutoDeletePolicy,
validateDomainSeparationPair
} from '../../utils/env' } from '../../utils/env'
interface SettingsPatchBody { interface SettingsPatchBody {
webpQuality?: unknown webpQuality?: unknown
allowedRefererHosts?: unknown allowedRefererHosts?: unknown
siteBaseUrl?: unknown
imageBaseUrl?: unknown imageBaseUrl?: unknown
hideFolderInUrl?: unknown
storageUseDatePath?: unknown
autoDeleteDays?: unknown autoDeleteDays?: unknown
allowRegistration?: unknown allowRegistration?: unknown
} }
@@ -61,6 +71,21 @@ export default defineEventHandler(async (event) => {
setSetting(SETTINGS_ALLOWED_REFERER_HOSTS, normalized) setSetting(SETTINGS_ALLOWED_REFERER_HOSTS, normalized)
} }
if (body.siteBaseUrl !== undefined) {
const configured = normalizeSiteBaseUrl(
typeof body.siteBaseUrl === 'string' ? body.siteBaseUrl : ''
)
if (configured && !isValidSiteBaseUrl(configured)) {
createApiError(
event,
'INVALID_REQUEST',
'SITE_BASE_URL 需为 http(s) 地址,或留空使用当前请求域名',
400
)
}
setSetting(SETTINGS_SITE_BASE_URL, configured)
}
if (body.imageBaseUrl !== undefined) { if (body.imageBaseUrl !== undefined) {
const configured = normalizeImageBaseUrl( const configured = normalizeImageBaseUrl(
typeof body.imageBaseUrl === 'string' ? body.imageBaseUrl : '' typeof body.imageBaseUrl === 'string' ? body.imageBaseUrl : ''
@@ -76,6 +101,21 @@ export default defineEventHandler(async (event) => {
setSetting(SETTINGS_IMAGE_BASE_URL, configured) setSetting(SETTINGS_IMAGE_BASE_URL, configured)
} }
if (body.siteBaseUrl !== undefined || body.imageBaseUrl !== undefined) {
const siteConfigured = normalizeSiteBaseUrl(
getSetting(SETTINGS_SITE_BASE_URL) ?? ''
)
const imageConfigured = normalizeImageBaseUrl(
getSetting(SETTINGS_IMAGE_BASE_URL) ?? ''
)
if (siteConfigured && imageConfigured) {
const pairError = validateDomainSeparationPair(siteConfigured, imageConfigured)
if (pairError) {
createApiError(event, 'INVALID_REQUEST', pairError, 400)
}
}
}
if (body.autoDeleteDays !== undefined) { if (body.autoDeleteDays !== undefined) {
const days = parseAutoDeleteDays(String(body.autoDeleteDays)) const days = parseAutoDeleteDays(String(body.autoDeleteDays))
if (days === null) { if (days === null) {
@@ -93,5 +133,13 @@ export default defineEventHandler(async (event) => {
setAllowRegistration(Boolean(body.allowRegistration)) setAllowRegistration(Boolean(body.allowRegistration))
} }
if (body.hideFolderInUrl !== undefined) {
setSetting(SETTINGS_HIDE_FOLDER_IN_URL, body.hideFolderInUrl ? 'true' : 'false')
}
if (body.storageUseDatePath !== undefined) {
setSetting(SETTINGS_STORAGE_USE_DATE_PATH, body.storageUseDatePath ? 'true' : 'false')
}
return getSettingsPayload(event) return getSettingsPayload(event)
}) })
+21 -1
View File
@@ -4,13 +4,21 @@ import {
createUserAccount, createUserAccount,
isInitialized isInitialized
} from '../utils/auth' } from '../utils/auth'
import { setAllowRegistration } from '../utils/db' import { setAllowRegistration, setSetting, SETTINGS_IMAGE_BASE_URL, SETTINGS_SITE_BASE_URL } from '../utils/db'
import {
normalizeImageBaseUrl,
normalizeSiteBaseUrl,
validateDomainSeparationPair
} from '../utils/env'
import { clientIp, logInfo } from '../utils/logger' import { clientIp, logInfo } from '../utils/logger'
interface SetupBody { interface SetupBody {
username?: string username?: string
password?: string password?: string
allowRegistration?: boolean allowRegistration?: boolean
domainSeparation?: boolean
siteBaseUrl?: string
imageBaseUrl?: string
} }
export default defineEventHandler(async (event) => { export default defineEventHandler(async (event) => {
@@ -30,6 +38,18 @@ export default defineEventHandler(async (event) => {
}) })
setAllowRegistration(Boolean(body?.allowRegistration)) setAllowRegistration(Boolean(body?.allowRegistration))
if (body?.domainSeparation) {
const siteBaseUrl = normalizeSiteBaseUrl(body.siteBaseUrl ?? '')
const imageBaseUrl = normalizeImageBaseUrl(body.imageBaseUrl ?? '')
const pairError = validateDomainSeparationPair(siteBaseUrl, imageBaseUrl)
if (pairError) {
createApiError(event, 'INVALID_REQUEST', pairError, 400)
}
setSetting(SETTINGS_SITE_BASE_URL, siteBaseUrl)
setSetting(SETTINGS_IMAGE_BASE_URL, imageBaseUrl)
}
await createSession(event, user.id) await createSession(event, user.id)
logInfo('setup complete', { ip: clientIp(event), username: user.username }) logInfo('setup complete', { ip: clientIp(event), username: user.username })
+2 -1
View File
@@ -1,5 +1,6 @@
import { getImageUserFilter, requireUserAuth } from '../utils/access' import { getImageUserFilter, requireUserAuth } from '../utils/access'
import { createApiError } from '../utils/api-error' import { createApiError } from '../utils/api-error'
import { logException } from '../utils/logger'
import { countUsers, getActivityStats } from '../utils/db' import { countUsers, getActivityStats } from '../utils/db'
import { import {
countImages, countImages,
@@ -46,7 +47,7 @@ export default defineEventHandler(async (event) => {
byFolder: scoped.byFolder byFolder: scoped.byFolder
} }
} catch (error) { } catch (error) {
console.error('[stats] failed', error) logException('stats failed', error)
createApiError(event, 'INVALID_REQUEST', '读取统计失败', 500) createApiError(event, 'INVALID_REQUEST', '读取统计失败', 500)
} }
}) })
+30
View File
@@ -0,0 +1,30 @@
import { requireUserAuth } from '../utils/access'
import {
ensureStorageSchema,
listStorageBackendNameMap,
LOCAL_BACKEND_ID
} from '../utils/storage-backends'
function sortBackendsForFilter<T extends { id: string, name: string }>(backends: T[]): T[] {
return [...backends].sort((a, b) => {
if (a.id === LOCAL_BACKEND_ID) return -1
if (b.id === LOCAL_BACKEND_ID) return 1
return a.name.localeCompare(b.name, 'zh-CN')
})
}
export default defineEventHandler(async (event) => {
await requireUserAuth(event)
ensureStorageSchema()
const map = listStorageBackendNameMap()
const backends = sortBackendsForFilter(
Array.from(map.entries()).map(([id, backend]) => ({
id,
name: backend.name,
type: backend.type
}))
)
return { backends }
})
+3 -1
View File
@@ -1,6 +1,6 @@
import { requireUserAuth } from '../../utils/access' import { requireUserAuth } from '../../utils/access'
import { ensureUserApiToken } from '../../utils/db' import { ensureUserApiToken } from '../../utils/db'
import { getImageBaseUrl } from '../../utils/env' import { getImageBaseUrl, getSiteBaseUrl, isHideFolderInUrl } from '../../utils/env'
export default defineEventHandler(async (event) => { export default defineEventHandler(async (event) => {
const user = await requireUserAuth(event) const user = await requireUserAuth(event)
@@ -13,7 +13,9 @@ export default defineEventHandler(async (event) => {
env: { env: {
webpQuality: 80, webpQuality: 80,
refererConfigured: false, refererConfigured: false,
siteBaseUrl: getSiteBaseUrl(event),
imageBaseUrl: getImageBaseUrl(event), imageBaseUrl: getImageBaseUrl(event),
hideFolderInUrl: isHideFolderInUrl(event),
appVersion: config.appVersion as string appVersion: config.appVersion as string
} }
} }
+29
View File
@@ -0,0 +1,29 @@
import { requireAdminAuth } from '../../utils/access'
import {
fetchLatestGitHubRelease,
isVersionNewer
} from '../../utils/app-version'
export default defineEventHandler(async (event) => {
await requireAdminAuth(event)
const config = useRuntimeConfig(event)
const currentVersion = String(config.appVersion ?? '')
const latest = await fetchLatestGitHubRelease()
if (!latest) {
return {
currentVersion,
latestVersion: null,
updateAvailable: false,
releaseUrl: null
}
}
return {
currentVersion,
latestVersion: latest.latestVersion,
updateAvailable: isVersionNewer(latest.latestVersion, currentVersion),
releaseUrl: latest.releaseUrl
}
})
+56
View File
@@ -0,0 +1,56 @@
import { mkdirSync } from 'node:fs'
import { join } from 'node:path'
import { randomBytes, scryptSync } from 'node:crypto'
import { DatabaseSync } from 'node:sqlite'
const usernameArg = process.argv[2]?.trim() || ''
if (process.argv.length > 3) {
console.error('Usage: reset-password [username]')
process.exit(1)
}
const dataDir = process.env.DATA_DIR || join(process.cwd(), 'data')
mkdirSync(dataDir, { recursive: true })
const db = new DatabaseSync(join(dataDir, 'pichost.db'))
function fail(message) {
console.error(`Error: ${message}`)
process.exit(1)
}
let user
if (usernameArg) {
user = db.prepare(`
SELECT id, username, role
FROM users
WHERE username = ?
`).get(usernameArg)
if (!user) fail(`user "${usernameArg}" not found`)
} else {
const admins = db.prepare(`
SELECT id, username, role
FROM users
WHERE role = 'admin'
ORDER BY id ASC
`).all()
if (!admins.length) fail('no administrator account found')
if (admins.length > 1) {
const names = admins.map(row => row.username).join(', ')
fail(`multiple administrators (${names}); run: reset-password <username>`)
}
user = admins[0]
}
const password = randomBytes(9).toString('base64url')
const salt = randomBytes(16).toString('hex')
const derived = scryptSync(password, salt, 64, { N: 16384, r: 8, p: 1 })
const passwordHash = `scrypt$16384$8$1$${salt}$${derived.toString('hex')}`
db.prepare('UPDATE users SET password_hash = ? WHERE id = ?').run(passwordHash, user.id)
db.prepare('DELETE FROM sessions WHERE user_id = ?').run(user.id)
db.close()
console.log(`[PicHost] User "${user.username}" (${user.role}) password reset.`)
console.log(`[PicHost] New password: ${password}`)
console.log('[PicHost] Sign in and change it under Account.')
+7
View File
@@ -0,0 +1,7 @@
import { shouldBlockImageHostRequest, shouldBlockSiteHostImageRequest } from '../utils/host-isolation'
export default defineEventHandler((event) => {
if (shouldBlockImageHostRequest(event) || shouldBlockSiteHostImageRequest(event)) {
throw createError({ statusCode: 404, statusMessage: 'Not Found' })
}
})
+4 -2
View File
@@ -1,14 +1,16 @@
import { requestPathToImageKey, serveImageByKey } from '../utils/serve-image' import { requestPathToImageKey, serveImageByKey } from '../utils/serve-image'
/** /**
* 仅当路径像图片 key({type}/YYYY/MM/file.ext)时才接管。 * 仅当路径像图片 key 或短链时才接管。
* 其它请求(/_nuxt、/api、页面)原样放行。 * 其它请求(/_nuxt、/api、页面)原样放行。
*/ */
export default defineEventHandler(async (event) => { export default defineEventHandler(async (event) => {
const method = getMethod(event) const method = getMethod(event)
if (method !== 'GET' && method !== 'HEAD') return if (method !== 'GET' && method !== 'HEAD') return
const key = requestPathToImageKey(getRequestURL(event).pathname) const key = requestPathToImageKey(getRequestURL(event).pathname, {
hideFolder: isHideFolderInUrl(event)
})
if (!key) return if (!key) return
return serveImageByKey(event, key) return serveImageByKey(event, key)
+43
View File
@@ -0,0 +1,43 @@
import { clientIp, logError, logException, logWarn, serializeError } from '../utils/logger'
/**
* 记录未捕获的 H3/Nitro 错误(含 createApiError 抛出的 4xx/5xx)。
*/
export default defineNitroPlugin((nitroApp) => {
nitroApp.hooks.hook('error', (error, { event }) => {
const serialized = serializeError(error)
const status = typeof serialized.statusCode === 'number'
? serialized.statusCode
: 500
if (!event) {
logError('unhandled error', serialized)
return
}
const path = getRequestURL(event).pathname
const method = getMethod(event)
const base = {
method,
path,
status,
ip: clientIp(event),
message: serialized.message,
...(serialized.statusMessage ? { reason: serialized.statusMessage } : {}),
...(serialized.data ? { data: serialized.data } : {})
}
if (!path.startsWith('/api/')) {
if (status >= 500) {
logException(`${method} ${path}`, error, base)
}
return
}
if (status >= 500) {
logException('API error', error, base)
} else if (status >= 400) {
logWarn('API rejected', base)
}
})
})
+16 -3
View File
@@ -1,4 +1,4 @@
import { clientIp, logInfo } from '../utils/logger' import { clientIp, logError, logInfo, logWarn } from '../utils/logger'
/** /**
* 只记录 /api/*,避免 /images、/twikoo 直链刷屏。 * 只记录 /api/*,避免 /images、/twikoo 直链刷屏。
@@ -19,11 +19,24 @@ export default defineNitroPlugin((nitroApp) => {
const status = getResponseStatus(event) const status = getResponseStatus(event)
const started = event.context._reqStartedAt as number | undefined const started = event.context._reqStartedAt as number | undefined
const ms = started ? Date.now() - started : undefined const ms = started ? Date.now() - started : undefined
const reason = status >= 400 ? getResponseStatusText(event) : undefined
logInfo(`${method} ${path}`, { const detail: Record<string, unknown> = {
status, status,
ms, ms,
ip: clientIp(event) ip: clientIp(event)
}) }
if (reason && reason !== String(status)) {
detail.reason = reason
}
const line = `${method} ${path}`
if (status >= 500) {
logError(line, detail)
} else if (status >= 400) {
logWarn(line, detail)
} else {
logInfo(line, detail)
}
}) })
}) })
+16 -2
View File
@@ -1,6 +1,8 @@
import { getDataDir } from '../utils/storage' import { getDataDir } from '../utils/data-dir'
import { runAutoDeleteCleanup } from '../utils/auto-delete' import { runAutoDeleteCleanup } from '../utils/auto-delete'
import { logInfo } from '../utils/logger' import { migrateLocalImagesToIndex } from '../utils/image-index'
import { ensureDefaultBackends } from '../utils/storage-backends'
import { logException, logInfo } from '../utils/logger'
export default defineNitroPlugin(() => { export default defineNitroPlugin(() => {
const port = process.env.NITRO_PORT || process.env.PORT || '6892' const port = process.env.NITRO_PORT || process.env.PORT || '6892'
@@ -9,12 +11,24 @@ export default defineNitroPlugin(() => {
dataDir: getDataDir(), dataDir: getDataDir(),
nodeEnv: process.env.NODE_ENV || 'development', nodeEnv: process.env.NODE_ENV || 'development',
imageBaseUrl: process.env.IMAGE_BASE_URL || '(request origin)', imageBaseUrl: process.env.IMAGE_BASE_URL || '(request origin)',
storageBackend: process.env.STORAGE_BACKEND || 'db',
logLevel: process.env.LOG_LEVEL || 'info' logLevel: process.env.LOG_LEVEL || 'info'
}) })
ensureDefaultBackends()
void migrateLocalImagesToIndex().then((count) => {
if (count > 0) {
logInfo('migrated local images to index', { count })
}
}).catch((error) => {
logException('migrate local images to index failed', error)
})
void runAutoDeleteCleanup().then((result) => { void runAutoDeleteCleanup().then((result) => {
if (!result.skipped && (result.deleted > 0 || result.failed > 0)) { if (!result.skipped && (result.deleted > 0 || result.failed > 0)) {
logInfo('startup auto-delete', { ...result }) logInfo('startup auto-delete', { ...result })
} }
}).catch((error) => {
logException('startup auto-delete failed', error)
}) })
}) })
+2
View File
@@ -1,5 +1,6 @@
import { readMultipartFormData } from 'h3' import { readMultipartFormData } from 'h3'
import { verifyUploadTokenValue } from '../../utils/access' import { verifyUploadTokenValue } from '../../utils/access'
import { checkUploadRateLimit } from '../../utils/rate-limit'
import { import {
detectMimeFromSignature, detectMimeFromSignature,
isAllowedMimeType isAllowedMimeType
@@ -34,6 +35,7 @@ export default defineEventHandler(async (event) => {
if (!verifyUploadTokenValue(event, token)) { if (!verifyUploadTokenValue(event, token)) {
return easyImageError(401, 'token 错误或无效') return easyImageError(401, 'token 错误或无效')
} }
checkUploadRateLimit(event, token)
} }
const imagePart = formData.find( const imagePart = formData.find(
+20
View File
@@ -0,0 +1,20 @@
import { describe, expect, it } from 'vitest'
import { isVersionNewer, normalizeVersionTag, parseVersionParts } from './app-version'
describe('app-version', () => {
it('normalizes tag prefixes', () => {
expect(normalizeVersionTag('v1.1.2')).toBe('1.1.2')
expect(normalizeVersionTag('V2.0.0')).toBe('2.0.0')
})
it('parses dotted versions', () => {
expect(parseVersionParts('1.10.3')).toEqual([1, 10, 3])
})
it('detects newer versions', () => {
expect(isVersionNewer('1.2.0', '1.1.2')).toBe(true)
expect(isVersionNewer('1.1.2', '1.1.2')).toBe(false)
expect(isVersionNewer('1.1.1', '1.1.2')).toBe(false)
expect(isVersionNewer('2.0.0', '1.9.9')).toBe(true)
})
})
+77
View File
@@ -0,0 +1,77 @@
const GITHUB_REPO = 'O96u/PicHost'
const RELEASES_LATEST_URL = `https://api.github.com/repos/${GITHUB_REPO}/releases/latest`
const CACHE_TTL_MS = 60 * 60 * 1000
export interface LatestReleaseInfo {
latestVersion: string
releaseUrl: string
}
let cachedRelease: { fetchedAt: number, value: LatestReleaseInfo | null } | null = null
export function normalizeVersionTag(tag: string): string {
return tag.trim().replace(/^v/i, '')
}
export function parseVersionParts(version: string): number[] {
return normalizeVersionTag(version)
.split('.')
.map(part => Number.parseInt(part, 10))
.map(part => (Number.isFinite(part) ? part : 0))
}
export function isVersionNewer(candidate: string, current: string): boolean {
const a = parseVersionParts(candidate)
const b = parseVersionParts(current)
const length = Math.max(a.length, b.length)
for (let i = 0; i < length; i++) {
const diff = (a[i] ?? 0) - (b[i] ?? 0)
if (diff !== 0) return diff > 0
}
return false
}
export async function fetchLatestGitHubRelease(): Promise<LatestReleaseInfo | null> {
const now = Date.now()
if (cachedRelease && now - cachedRelease.fetchedAt < CACHE_TTL_MS) {
return cachedRelease.value
}
try {
const response = await fetch(RELEASES_LATEST_URL, {
headers: {
'Accept': 'application/vnd.github+json',
'User-Agent': 'PicHost'
},
signal: AbortSignal.timeout(8000)
})
if (!response.ok) {
cachedRelease = { fetchedAt: now, value: null }
return null
}
const data = await response.json() as {
tag_name?: string
html_url?: string
}
const tag = typeof data.tag_name === 'string' ? data.tag_name : ''
const releaseUrl = typeof data.html_url === 'string' ? data.html_url : ''
if (!tag) {
cachedRelease = { fetchedAt: now, value: null }
return null
}
const value: LatestReleaseInfo = {
latestVersion: normalizeVersionTag(tag),
releaseUrl: releaseUrl || `https://github.com/${GITHUB_REPO}/releases/latest`
}
cachedRelease = { fetchedAt: now, value }
return value
} catch {
cachedRelease = { fetchedAt: now, value: null }
return null
}
}
+11 -11
View File
@@ -1,7 +1,8 @@
import { insertActivityLog, listAdminUserIds, listUserAutoDeletePolicies, type AutoDeletePolicy } from './db' import { insertActivityLog, listAdminUserIds, listUserAutoDeletePolicies, type AutoDeletePolicy } from './db'
import { getGlobalAutoDeletePolicy } from './env' import { getGlobalAutoDeletePolicy } from './env'
import { deleteImage, headImage, listImageKeys, type StoredImage } from './storage' import { deleteImage, type StoredImage } from './storage'
import { logInfo, logWarn } from './logger' import { listImageKeysForAutoDelete } from './image-index'
import { logInfo, logWarn, serializeError } from './logger'
export interface AutoDeleteResult { export interface AutoDeleteResult {
deleted: number deleted: number
@@ -73,31 +74,30 @@ export async function runAutoDeleteCleanup(): Promise<AutoDeleteResult> {
} }
const nowMs = Date.now() const nowMs = Date.now()
const keys = await listImageKeys() const images = await listImageKeysForAutoDelete()
let deleted = 0 let deleted = 0
let failed = 0 let failed = 0
for (const key of keys) { for (const image of images) {
const image = await headImage(key)
if (!image) continue
if (!shouldAutoDeleteImage(image, globalPolicy, adminUserIds, userPolicies, nowMs)) { if (!shouldAutoDeleteImage(image, globalPolicy, adminUserIds, userPolicies, nowMs)) {
continue continue
} }
try { try {
await deleteImage(key) await deleteImage(image.key)
insertActivityLog({ insertActivityLog({
action: 'delete', action: 'delete',
key, key: image.key,
originalName: image.originalName, originalName: image.originalName,
size: image.size, size: image.size,
contentType: image.contentType, contentType: image.contentType,
source: 'web' source: 'web',
userId: image.userId ?? null,
backendId: image.backendId ?? null
}) })
deleted++ deleted++
} catch (error) { } catch (error) {
logWarn('auto-delete failed', { key, error }) logWarn('auto-delete failed', { key: image.key, error: serializeError(error) })
failed++ failed++
} }
} }
+5
View File
@@ -0,0 +1,5 @@
import { join } from 'node:path'
export function getDataDir(): string {
return process.env.DATA_DIR || join(process.cwd(), 'data')
}
+128 -25
View File
@@ -1,7 +1,8 @@
import { DatabaseSync } from 'node:sqlite' import { DatabaseSync } from 'node:sqlite'
import { mkdirSync } from 'node:fs' import { mkdirSync } from 'node:fs'
import { join } from 'node:path' import { join } from 'node:path'
import { getDataDir } from './storage' import { getDataDir } from './data-dir'
import { logException } from './logger'
export type LogAction = 'upload' | 'delete' export type LogAction = 'upload' | 'delete'
export type LogSource = 'web' | 'api' | 'twikoo' export type LogSource = 'web' | 'api' | 'twikoo'
@@ -14,6 +15,11 @@ export interface ActivityLogRow {
size: number size: number
content_type: string content_type: string
source: LogSource source: LogSource
user_id: number | null
username: string | null
backend_id: string | null
backend_name: string | null
backend_type: string | null
created_at: string created_at: string
} }
@@ -24,12 +30,14 @@ export interface ActivityLogInput {
size: number size: number
contentType: string contentType: string
source: LogSource source: LogSource
userId?: number | null
backendId?: string | null
createdAt?: string createdAt?: string
} }
let db: DatabaseSync | null = null let db: DatabaseSync | null = null
function getDb(): DatabaseSync { export function getDb(): DatabaseSync {
if (db) return db if (db) return db
const dataDir = getDataDir() const dataDir = getDataDir()
@@ -117,6 +125,23 @@ function migrateSchema(database: DatabaseSync): void {
VALUES ('auto_delete_enabled_at', ?, ?) VALUES ('auto_delete_enabled_at', ?, ?)
`).run(now, now) `).run(now, now)
} }
const logColumns = database.prepare('PRAGMA table_info(activity_logs)').all() as Array<{
name: string
}>
if (!logColumns.some(column => column.name === 'user_id')) {
database.exec('ALTER TABLE activity_logs ADD COLUMN user_id INTEGER')
database.exec(`
CREATE INDEX IF NOT EXISTS idx_activity_logs_user_created
ON activity_logs(user_id, created_at DESC)
`)
}
const activityLogColumns = database.prepare('PRAGMA table_info(activity_logs)').all() as Array<{
name: string
}>
if (!activityLogColumns.some(column => column.name === 'backend_id')) {
database.exec('ALTER TABLE activity_logs ADD COLUMN backend_id TEXT')
}
} }
const USER_COLUMNS = 'id, username, password_hash, role, created_at, api_token, auto_delete_days, auto_delete_enabled_at' const USER_COLUMNS = 'id, username, password_hash, role, created_at, api_token, auto_delete_days, auto_delete_enabled_at'
@@ -348,6 +373,9 @@ export function deleteSessionsForUser(userId: number): void {
export const SETTINGS_API_UPLOAD_TOKEN = 'api_upload_token' export const SETTINGS_API_UPLOAD_TOKEN = 'api_upload_token'
export const SETTINGS_WEBP_QUALITY = 'webp_quality' export const SETTINGS_WEBP_QUALITY = 'webp_quality'
export const SETTINGS_ALLOWED_REFERER_HOSTS = 'allowed_referer_hosts' export const SETTINGS_ALLOWED_REFERER_HOSTS = 'allowed_referer_hosts'
export const SETTINGS_HIDE_FOLDER_IN_URL = 'hide_folder_in_url'
export const SETTINGS_STORAGE_USE_DATE_PATH = 'storage_use_date_path'
export const SETTINGS_SITE_BASE_URL = 'site_base_url'
export const SETTINGS_IMAGE_BASE_URL = 'image_base_url' export const SETTINGS_IMAGE_BASE_URL = 'image_base_url'
export const SETTINGS_AUTO_DELETE_DAYS = 'auto_delete_days' export const SETTINGS_AUTO_DELETE_DAYS = 'auto_delete_days'
export const SETTINGS_AUTO_DELETE_ENABLED_AT = 'auto_delete_enabled_at' export const SETTINGS_AUTO_DELETE_ENABLED_AT = 'auto_delete_enabled_at'
@@ -392,8 +420,8 @@ export function insertActivityLog(input: ActivityLogInput): void {
const createdAt = input.createdAt ?? new Date().toISOString() const createdAt = input.createdAt ?? new Date().toISOString()
getDb().prepare(` getDb().prepare(`
INSERT INTO activity_logs INSERT INTO activity_logs
(action, key, original_name, size, content_type, source, created_at) (action, key, original_name, size, content_type, source, user_id, backend_id, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run( `).run(
input.action, input.action,
input.key, input.key,
@@ -401,10 +429,15 @@ export function insertActivityLog(input: ActivityLogInput): void {
input.size, input.size,
input.contentType, input.contentType,
input.source, input.source,
input.userId ?? null,
input.backendId ?? null,
createdAt createdAt
) )
} catch (error) { } catch (error) {
console.error('[activity-log] insert failed', error) logException('activity-log insert failed', error, {
action: input.action,
key: input.key
})
} }
} }
@@ -414,6 +447,8 @@ export function listActivityLogs(options: {
action?: LogAction action?: LogAction
source?: LogSource source?: LogSource
folder?: string folder?: string
userId?: number
search?: string
}): { }): {
items: ActivityLogRow[] items: ActivityLogRow[]
total: number total: number
@@ -423,26 +458,11 @@ export function listActivityLogs(options: {
} { } {
const database = getDb() const database = getDb()
const pageSize = options.limit const pageSize = options.limit
const params: Array<string | number> = [] const { whereSql, params } = buildActivityLogWhere(options)
const where: string[] = []
if (options.action) {
where.push('action = ?')
params.push(options.action)
}
if (options.source) {
where.push('source = ?')
params.push(options.source)
}
if (options.folder) {
where.push('key LIKE ?')
params.push(`${options.folder}/%`)
}
const whereSql = where.length ? `WHERE ${where.join(' AND ')}` : ''
const countRow = database.prepare(` const countRow = database.prepare(`
SELECT COUNT(*) AS count SELECT COUNT(*) AS count
FROM activity_logs FROM activity_logs al
${whereSql} ${whereSql}
`).get(...params) as { count: number } `).get(...params) as { count: number }
@@ -453,10 +473,25 @@ export function listActivityLogs(options: {
const offset = (safePage - 1) * pageSize const offset = (safePage - 1) * pageSize
const rows = database.prepare(` const rows = database.prepare(`
SELECT id, action, key, original_name, size, content_type, source, created_at SELECT
FROM activity_logs al.id,
al.action,
al.key,
al.original_name,
al.size,
al.content_type,
al.source,
al.user_id,
u.username,
al.backend_id,
sb.name AS backend_name,
sb.type AS backend_type,
al.created_at
FROM activity_logs al
LEFT JOIN users u ON u.id = al.user_id
LEFT JOIN storage_backends sb ON sb.id = al.backend_id
${whereSql} ${whereSql}
ORDER BY id DESC ORDER BY al.id DESC
LIMIT ? OFFSET ? LIMIT ? OFFSET ?
`).all(...params, pageSize, offset) as unknown as ActivityLogRow[] `).all(...params, pageSize, offset) as unknown as ActivityLogRow[]
@@ -469,6 +504,74 @@ export function listActivityLogs(options: {
} }
} }
export function summarizeActivityLogs(options: {
source?: LogSource
folder?: string
userId?: number
search?: string
}): {
total: number
upload: number
delete: number
} {
const database = getDb()
const { whereSql, params } = buildActivityLogWhere(options, { includeAction: false })
const row = database.prepare(`
SELECT
COUNT(*) AS total,
COALESCE(SUM(CASE WHEN al.action = 'upload' THEN 1 ELSE 0 END), 0) AS upload_count,
COALESCE(SUM(CASE WHEN al.action = 'delete' THEN 1 ELSE 0 END), 0) AS delete_count
FROM activity_logs al
${whereSql}
`).get(...params) as { total: number, upload_count: number, delete_count: number } | undefined
return {
total: row?.total ?? 0,
upload: row?.upload_count ?? 0,
delete: row?.delete_count ?? 0
}
}
function buildActivityLogWhere(
options: {
action?: LogAction
source?: LogSource
folder?: string
userId?: number
search?: string
},
{ includeAction = true }: { includeAction?: boolean } = {}
): { whereSql: string, params: Array<string | number> } {
const params: Array<string | number> = []
const where: string[] = []
if (includeAction && options.action) {
where.push('al.action = ?')
params.push(options.action)
}
if (options.source) {
where.push('al.source = ?')
params.push(options.source)
}
if (options.folder) {
where.push('al.key LIKE ?')
params.push(`${options.folder}/%`)
}
if (options.userId !== undefined) {
where.push('al.user_id = ?')
params.push(options.userId)
}
if (options.search) {
where.push('al.original_name LIKE ?')
params.push(`%${options.search}%`)
}
return {
whereSql: where.length ? `WHERE ${where.join(' AND ')}` : '',
params
}
}
function countSince(action: LogAction, sinceIso: string): number { function countSince(action: LogAction, sinceIso: string): number {
const row = getDb().prepare(` const row = getDb().prepare(`
SELECT COUNT(*) AS total SELECT COUNT(*) AS total
Loaded 100 of 131 files, more files were not shown because too many files have changed in this diff. Show more