release: PicHost v1.0.0
Publish Docker image / build-and-push (push) Canceled after 0s
ci / ci (22, ubuntu-latest) (push) Canceled after 0s
Multi-user image hosting with RBAC, upload preferences, and admin settings.
No files matched your search
@@ -0,0 +1,9 @@
|
|||||||
|
node_modules
|
||||||
|
.output
|
||||||
|
.nuxt
|
||||||
|
.git
|
||||||
|
.github
|
||||||
|
data
|
||||||
|
dist
|
||||||
|
.env
|
||||||
|
*.md
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
# editorconfig.org
|
||||||
|
root = true
|
||||||
|
|
||||||
|
[*]
|
||||||
|
indent_size = 2
|
||||||
|
indent_style = space
|
||||||
|
end_of_line = lf
|
||||||
|
charset = utf-8
|
||||||
|
trim_trailing_whitespace = true
|
||||||
|
insert_final_newline = true
|
||||||
|
|
||||||
|
[*.md]
|
||||||
|
trim_trailing_whitespace = false
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
# PicHost 环境变量
|
||||||
|
# 本地开发:复制为 .env 后 npm run dev,首次访问按 /setup 引导创建管理员即可
|
||||||
|
# 生产环境:docker-compose.yml 中配置,或挂载 /data 后首次访问 Web 引导
|
||||||
|
|
||||||
|
# 外部脚本上传 Token(Twikoo / 油猴 / 博客用,可选)
|
||||||
|
# 请求头:Auth-Token: <token>(表单上传仍可用 token 字段)
|
||||||
|
# 未配置时可在后台「API」页生成
|
||||||
|
# API_UPLOAD_TOKEN=
|
||||||
|
|
||||||
|
# 防盗链白名单:允许引用图片的站点域名,逗号分隔(自身域名自动放行)
|
||||||
|
# 可选;不填可在设置页配置。若在此填写则覆盖后台设置
|
||||||
|
# ALLOWED_REFERER_HOSTS=example.com,www.example.com
|
||||||
|
|
||||||
|
# 图片直链域名(可选;不填则使用请求域名,也可在设置页配置)
|
||||||
|
# IMAGE_BASE_URL=https://pic.example.com
|
||||||
|
|
||||||
|
# 图片存储目录(默认 ./data,容器内为 /data)
|
||||||
|
# DATA_DIR=./data
|
||||||
|
|
||||||
|
# 服务端 WebP 压缩质量 1-100(默认 80;也可在设置页配置)
|
||||||
|
# WEBP_QUALITY=80
|
||||||
|
|
||||||
|
# 自动删除超过 N 天的图片(0 表示关闭;也可在设置页配置)
|
||||||
|
# AUTO_DELETE_DAYS=0
|
||||||
|
|
||||||
|
# 仅本地开发:设为 true 可绕过登录验证(跳过 /setup,一般无需开启)
|
||||||
|
# DEV_BYPASS_ACCESS=false
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
* text=auto eol=lf
|
||||||
|
*.{ts,vue,js,mjs,cjs,json,jsonc,yml,yaml,md,css} text eol=lf
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"description": "Self-hosted image hosting for your NAS / home server — Nuxt admin UI, local disk storage, server-side WebP compression, referer protection, Twikoo/EasyImage compatible.",
|
||||||
|
"homepage": "https://github.com/O96u/PicHost#readme",
|
||||||
|
"topics": [
|
||||||
|
"docker",
|
||||||
|
"image-hosting",
|
||||||
|
"nas",
|
||||||
|
"nuxt",
|
||||||
|
"nuxt4",
|
||||||
|
"self-hosted",
|
||||||
|
"twikoo",
|
||||||
|
"easyimage",
|
||||||
|
"typescript"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
name: ci
|
||||||
|
|
||||||
|
on: push
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
ci:
|
||||||
|
runs-on: ${{ matrix.os }}
|
||||||
|
|
||||||
|
strategy:
|
||||||
|
matrix:
|
||||||
|
os: [ubuntu-latest]
|
||||||
|
node: [22]
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v7
|
||||||
|
|
||||||
|
- name: Install node
|
||||||
|
uses: actions/setup-node@v6
|
||||||
|
with:
|
||||||
|
node-version: ${{ matrix.node }}
|
||||||
|
cache: npm
|
||||||
|
|
||||||
|
- name: Install npm 11 (lockfile generated with npm 11)
|
||||||
|
run: |
|
||||||
|
npm install -g npm@11.12.1
|
||||||
|
npm --version
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Lint
|
||||||
|
run: npm run lint
|
||||||
|
|
||||||
|
- name: Typecheck
|
||||||
|
run: npm run typecheck
|
||||||
|
|
||||||
|
- name: Test
|
||||||
|
run: npm run test
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
name: Publish Docker image
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
tags:
|
||||||
|
- 'v*'
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
packages: write
|
||||||
|
|
||||||
|
env:
|
||||||
|
IMAGE_NAME: pichost
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-and-push:
|
||||||
|
if: ${{ !contains(github.event.head_commit.message, '[skip docker]') }}
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
env:
|
||||||
|
DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
|
DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v7
|
||||||
|
|
||||||
|
- name: Set up QEMU
|
||||||
|
uses: docker/setup-qemu-action@v3
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
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
|
||||||
|
if: env.DOCKERHUB_USERNAME != '' && env.DOCKERHUB_TOKEN != ''
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
username: ${{ env.DOCKERHUB_USERNAME }}
|
||||||
|
password: ${{ env.DOCKERHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Resolve image names
|
||||||
|
id: images
|
||||||
|
run: |
|
||||||
|
names="ghcr.io/${OWNER}/${IMAGE_NAME}"
|
||||||
|
if [ -n "${DOCKERHUB_USERNAME}" ]; then
|
||||||
|
names="${names}
|
||||||
|
${DOCKERHUB_USERNAME}/${IMAGE_NAME}"
|
||||||
|
fi
|
||||||
|
{
|
||||||
|
echo "list<<EOF"
|
||||||
|
echo "${names}"
|
||||||
|
echo "EOF"
|
||||||
|
} >> "$GITHUB_OUTPUT"
|
||||||
|
env:
|
||||||
|
OWNER: ${{ github.repository_owner }}
|
||||||
|
|
||||||
|
- name: Extract metadata
|
||||||
|
id: meta
|
||||||
|
uses: docker/metadata-action@v5
|
||||||
|
with:
|
||||||
|
images: ${{ steps.images.outputs.list }}
|
||||||
|
tags: |
|
||||||
|
type=raw,value=latest,enable={{is_default_branch}}
|
||||||
|
type=semver,pattern={{version}}
|
||||||
|
type=semver,pattern=v{{version}}
|
||||||
|
|
||||||
|
- name: Build and push
|
||||||
|
uses: docker/build-push-action@v6
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
push: true
|
||||||
|
platforms: linux/amd64,linux/arm64
|
||||||
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
|
cache-from: type=gha
|
||||||
|
cache-to: type=gha,mode=max
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
# Nuxt dev/build outputs
|
||||||
|
.output
|
||||||
|
.data
|
||||||
|
.nuxt
|
||||||
|
.nitro
|
||||||
|
.cache
|
||||||
|
dist
|
||||||
|
|
||||||
|
# Node dependencies
|
||||||
|
node_modules
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
!server/api/logs/
|
||||||
|
!server/api/logs/**
|
||||||
|
|
||||||
|
# Misc
|
||||||
|
.DS_Store
|
||||||
|
.fleet
|
||||||
|
.idea
|
||||||
|
|
||||||
|
# Local env files
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
|
||||||
|
# 本地图片存储目录
|
||||||
|
data
|
||||||
|
|
||||||
|
# Wrangler(main 分支遗留的本地缓存)
|
||||||
|
.wrangler
|
||||||
|
|
||||||
|
# Cursor 本地规则(不上传,避免进仓库)
|
||||||
|
.cursor/
|
||||||
|
|
||||||
|
# 本地 Git hooks 模板(仅安装在 .git/hooks/,不进仓库)
|
||||||
|
git-hooks/
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
# Changelog
|
||||||
|
|
||||||
|
本仓库自 **v1.0.0** 起作为正式版维护;此前历史提交已归档重置。
|
||||||
|
|
||||||
|
## [1.0.0] - 2026-08-22
|
||||||
|
|
||||||
|
### 新增
|
||||||
|
|
||||||
|
- 多用户账号体系:Web 引导创建管理员、用户名密码登录、可选开放注册
|
||||||
|
- 角色权限:管理员 / 普通用户;图库按 `userId` 隔离,服务端强制校验
|
||||||
|
- 个人 API Token:每用户独立 Token,脚本上传归属对应账号
|
||||||
|
- 上传偏好:客户端预压缩、WebP 质量、自动复制链接、按用户自动删除策略
|
||||||
|
- 管理员系统设置:注册开关、防盗链、访问域名、默认上传目录
|
||||||
|
- 统计页:图库浏览 / 搜索 / 批量删除;管理员显示注册用户数量
|
||||||
|
- Twikoo / EasyImage 2.0 兼容:`POST /api/index.php`
|
||||||
|
- 遗留 `ADMIN_SECRET` 一次性迁移至账号密码
|
||||||
|
|
||||||
|
### 技术栈
|
||||||
|
|
||||||
|
- Nuxt 4 + Nuxt UI 4
|
||||||
|
- 本地磁盘存储 + SQLite
|
||||||
|
- sharp 服务端 WebP 压缩
|
||||||
|
- Docker 多架构镜像(amd64 / arm64)
|
||||||
|
|
||||||
|
### 路线图
|
||||||
|
|
||||||
|
- **v1.1.0**(计划中):S3、Cloudflare R2 及更多对象存储后端
|
||||||
|
|
||||||
|
[1.0.0]: https://github.com/O96u/PicHost/releases/tag/v1.0.0
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
FROM node:22-alpine AS build
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# package-lock.json 由 npm 11 生成;镜像自带 npm 10,需对齐后再 npm ci
|
||||||
|
RUN npm install -g npm@11.12.1
|
||||||
|
|
||||||
|
COPY package.json package-lock.json ./
|
||||||
|
RUN npm ci
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
FROM node:22-alpine
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
ENV NODE_ENV=production \
|
||||||
|
NITRO_PORT=6892 \
|
||||||
|
DATA_DIR=/data
|
||||||
|
|
||||||
|
COPY --from=build /app/.output ./.output
|
||||||
|
|
||||||
|
EXPOSE 6892
|
||||||
|
VOLUME /data
|
||||||
|
|
||||||
|
CMD ["node", ".output/server/index.mjs"]
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2026 O96u
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
@@ -0,0 +1,188 @@
|
|||||||
|
<p align="center">
|
||||||
|
<img src="app/assets/image/logo-light.png" alt="PicHost" width="96" />
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h1 align="center">PicHost</h1>
|
||||||
|
|
||||||
|
<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">
|
||||||
|
<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>
|
||||||
|
<img src="https://img.shields.io/badge/Nuxt-4-00DC82?style=flat-square&logo=nuxt.js&logoColor=white" alt="Nuxt 4" />
|
||||||
|
<a href="https://hub.docker.com/r/muxui/pichost"><img src="https://img.shields.io/badge/Docker-muxui%2Fpichost-2496ED?style=flat-square&logo=docker&logoColor=white" alt="Docker" /></a>
|
||||||
|
<img src="https://img.shields.io/badge/license-MIT-blue?style=flat-square" alt="MIT" />
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<a href="#quick-start">Quick Start</a> ·
|
||||||
|
<a href="#screenshots">Screenshots</a> ·
|
||||||
|
<a href="#features">Features</a> ·
|
||||||
|
<a href="#tech-stack">Tech Stack</a> ·
|
||||||
|
<a href="#roadmap">Roadmap</a> ·
|
||||||
|
<a href="README.md">简体中文</a>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Screenshots
|
||||||
|
|
||||||
|
| Login | Upload |
|
||||||
|
|:---:|:---:|
|
||||||
|
|  |  |
|
||||||
|
|
||||||
|
| Upload preferences | Stats & gallery |
|
||||||
|
|:---:|:---:|
|
||||||
|
|  |  |
|
||||||
|
|
||||||
|
| Settings | |
|
||||||
|
|:---:|:---:|
|
||||||
|
|  | |
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- **Drag, click, or Ctrl+V paste** to upload; admins can use custom `folder` paths
|
||||||
|
- **Server-side WebP** (sharp), Referer hotlink protection, configurable public base URL
|
||||||
|
- **Multi-user** — username/password login; optional registration; users see only their images
|
||||||
|
- **Upload preferences** (card flip UI): client-side compression, auto-copy links, per-user auto-delete
|
||||||
|
- **Gallery** — browse, search, batch delete; admin sees uploader badges
|
||||||
|
- **Stats** — upload/delete metrics, folder breakdown; admin sees registered user count
|
||||||
|
- **API** — global token (admin) + per-user tokens; in-app cURL snippets
|
||||||
|
- **Twikoo** — `POST /api/index.php` (`image` + `token`)
|
||||||
|
- **Zero-config Docker** — first-run web wizard, no secrets required upfront
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
### Docker (recommended)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://github.com/O96u/PicHost.git
|
||||||
|
cd PicHost
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
Default port: **6892**. Complete the setup wizard in the browser. Image: `muxui/pichost:latest` (fallback: `ghcr.io/o96u/pichost:latest`).
|
||||||
|
|
||||||
|
### Local development
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
cp .env.example .env # optional
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Open `http://localhost:3000/setup` to create the admin account. Data defaults to `./data`.
|
||||||
|
|
||||||
|
### 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
|
||||||
|
|
||||||
|
| Layer | Technology |
|
||||||
|
|-------|------------|
|
||||||
|
| Frontend | Nuxt 4, Vue 3, Nuxt UI 4, Tailwind CSS 4 |
|
||||||
|
| Backend | Nitro (Node.js), SQLite |
|
||||||
|
| Images | sharp (WebP transcoding & compression) |
|
||||||
|
| Auth | Session cookie, scrypt password hashing, API tokens |
|
||||||
|
| Storage | Local disk (`DATA_DIR`) |
|
||||||
|
| Deploy | Docker (amd64 / arm64), docker compose |
|
||||||
|
| Tests | Vitest |
|
||||||
|
|
||||||
|
## Environment variables
|
||||||
|
|
||||||
|
| Variable | Description |
|
||||||
|
|----------|-------------|
|
||||||
|
| `API_UPLOAD_TOKEN` | Global upload token (`Auth-Token` header); can be generated in the API page |
|
||||||
|
| `ALLOWED_REFERER_HOSTS` | Hotlink allowlist (comma-separated hostnames) |
|
||||||
|
| `IMAGE_BASE_URL` | Public base URL for image links |
|
||||||
|
| `WEBP_QUALITY` | Server WebP quality 1–100, default 80 |
|
||||||
|
| `AUTO_DELETE_DAYS` | Global auto-delete days (0 = off); admin + orphan images only |
|
||||||
|
| `DATA_DIR` | Data directory, default `/data` (container) or `./data` (local) |
|
||||||
|
| `ADMIN_SECRET` | Legacy v1.0 migration only |
|
||||||
|
| `DEV_BYPASS_ACCESS` | Dev only — bypass auth (never in production) |
|
||||||
|
|
||||||
|
Environment variables override database settings. See [`.env.example`](.env.example).
|
||||||
|
|
||||||
|
## Users & permissions
|
||||||
|
|
||||||
|
| Scenario | Auth | Ownership / visibility |
|
||||||
|
|----------|------|------------------------|
|
||||||
|
| Web upload | Session | Current user |
|
||||||
|
| API upload + **user token** | `Auth-Token` | Token owner |
|
||||||
|
| API upload + **global token** | `Auth-Token` | Admin (`userId` null) |
|
||||||
|
| Twikoo `/api/index.php` | form `token` | Same as global token |
|
||||||
|
| Gallery list / search / delete | Session or token | Users: own only; admin: all |
|
||||||
|
| Direct link `GET /images/...` | None (Referer rules) | Public if URL is known |
|
||||||
|
|
||||||
|
Regular users are limited to the `images/` folder; custom `folder` requires admin or global token.
|
||||||
|
|
||||||
|
## Storage layout
|
||||||
|
|
||||||
|
```
|
||||||
|
/data
|
||||||
|
├── pichost.db # SQLite: users, sessions, settings, logs
|
||||||
|
├── images/ # Default upload folder
|
||||||
|
├── blog/ # Example custom folder
|
||||||
|
└── twikoo/ # Twikoo comment images
|
||||||
|
```
|
||||||
|
|
||||||
|
Pattern: `folder/YYYY/MM/randomId.webp` + `.meta.json`. Back up the entire `/data` directory.
|
||||||
|
|
||||||
|
## API overview
|
||||||
|
|
||||||
|
| Method | Path | Description |
|
||||||
|
|--------|------|-------------|
|
||||||
|
| POST | `/api/images/upload` | Upload image |
|
||||||
|
| GET | `/api/images` | Paginated list |
|
||||||
|
| DELETE | `/api/images` | Delete one (`key`) |
|
||||||
|
| POST | `/api/index.php` | Twikoo / EasyImage 2.0 |
|
||||||
|
|
||||||
|
Upload example:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST "https://pic.example.com/api/images/upload" \
|
||||||
|
-H "Auth-Token: YOUR_TOKEN" \
|
||||||
|
-F "folder=blog" \
|
||||||
|
-F "image=@./demo.png"
|
||||||
|
```
|
||||||
|
|
||||||
|
Full docs and copyable cURL snippets are on the **API** page after deployment.
|
||||||
|
|
||||||
|
## Twikoo
|
||||||
|
|
||||||
|
| Setting | Value |
|
||||||
|
|---------|-------|
|
||||||
|
| `IMAGE_CDN` | `easyimage` |
|
||||||
|
| `IMAGE_CDN_URL` | `https://your-domain/api/index.php` |
|
||||||
|
| `IMAGE_CDN_TOKEN` | Same as `API_UPLOAD_TOKEN` |
|
||||||
|
|
||||||
|
## Reverse proxy
|
||||||
|
|
||||||
|
- Proxy to port **6892**; HTTPS recommended
|
||||||
|
- Nginx: `client_max_body_size` ≥ 12m
|
||||||
|
- Set `IMAGE_BASE_URL` for public access
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run dev # Dev server
|
||||||
|
npm run build # Production build
|
||||||
|
npm run start # Run .output
|
||||||
|
npm run lint # ESLint
|
||||||
|
npm run typecheck # Type check
|
||||||
|
npm test # Unit tests
|
||||||
|
```
|
||||||
|
|
||||||
|
## Roadmap
|
||||||
|
|
||||||
|
| Version | Plan |
|
||||||
|
|---------|------|
|
||||||
|
| **v1.0.0** | Local storage, multi-user, API, Twikoo (current) |
|
||||||
|
| **v1.1.0** | S3, Cloudflare R2, and more object storage backends |
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
[MIT](LICENSE)
|
||||||
@@ -0,0 +1,188 @@
|
|||||||
|
<p align="center">
|
||||||
|
<img src="app/assets/image/logo-light.png" alt="PicHost" width="96" />
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h1 align="center">PicHost</h1>
|
||||||
|
|
||||||
|
<p align="center"><strong>个人轻量图床</strong> · 图片放好,管理也要顺手</p>
|
||||||
|
|
||||||
|
<p align="center">自托管图床 / 多用户 / Docker / API / Twikoo · 零配置引导</p>
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<a href="https://github.com/O96u/PicHost/blob/main/package.json"><img src="https://img.shields.io/github/package-json/v/O96u/PicHost?style=flat-square&color=22c55e" alt="version" /></a>
|
||||||
|
<a href="https://github.com/O96u/PicHost/actions/workflows/ci.yml"><img src="https://github.com/O96u/PicHost/actions/workflows/ci.yml/badge.svg" alt="CI" /></a>
|
||||||
|
<img src="https://img.shields.io/badge/Nuxt-4-00DC82?style=flat-square&logo=nuxt.js&logoColor=white" alt="Nuxt 4" />
|
||||||
|
<a href="https://hub.docker.com/r/muxui/pichost"><img src="https://img.shields.io/badge/Docker-muxui%2Fpichost-2496ED?style=flat-square&logo=docker&logoColor=white" alt="Docker" /></a>
|
||||||
|
<img src="https://img.shields.io/badge/license-MIT-blue?style=flat-square" alt="MIT" />
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<a href="#快速开始">快速开始</a> ·
|
||||||
|
<a href="#界面预览">预览</a> ·
|
||||||
|
<a href="#特性">特性</a> ·
|
||||||
|
<a href="#技术栈">技术栈</a> ·
|
||||||
|
<a href="#路线图">路线图</a> ·
|
||||||
|
<a href="README.en.md">English</a>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 界面预览
|
||||||
|
|
||||||
|
| 登录 | 上传 |
|
||||||
|
|:---:|:---:|
|
||||||
|
|  |  |
|
||||||
|
|
||||||
|
| 上传偏好 | 统计与图库 |
|
||||||
|
|:---:|:---:|
|
||||||
|
|  |  |
|
||||||
|
|
||||||
|
| 系统设置 | |
|
||||||
|
|:---:|:---:|
|
||||||
|
|  | |
|
||||||
|
|
||||||
|
## 特性
|
||||||
|
|
||||||
|
- **拖拽 / 点击 / Ctrl+V 粘贴**上传;管理员可自定义 `folder` 目录
|
||||||
|
- **服务端 WebP 压缩**(sharp)、Referer 防盗链、可配置访问域名
|
||||||
|
- **多用户**:账号密码登录;管理员可开放注册;普通用户仅见自己的图片
|
||||||
|
- **上传偏好**(卡片翻转设置):客户端预压缩、自动复制链接、按用户自动删除
|
||||||
|
- **图库**:浏览、搜索、批量删除;管理员可查看上传者标签
|
||||||
|
- **统计**:上传/删除趋势、目录分布;管理员额外显示注册用户数量
|
||||||
|
- **API**:全局 Token(管理员)+ 每用户个人 Token;后台可复制 cURL
|
||||||
|
- **Twikoo**:`POST /api/index.php`(`image` + `token`)
|
||||||
|
- **Docker 零配置**:首次访问 Web 引导创建管理员,无需预先写密钥
|
||||||
|
|
||||||
|
## 快速开始
|
||||||
|
|
||||||
|
### Docker(推荐)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://github.com/O96u/PicHost.git
|
||||||
|
cd PicHost
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
默认端口 **6892**。浏览器打开后按引导创建管理员即可。镜像:`muxui/pichost:latest`(备用 `ghcr.io/o96u/pichost:latest`)。
|
||||||
|
|
||||||
|
### 本地开发
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
cp .env.example .env # 可选
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
访问 `http://localhost:3000/setup` 创建管理员;数据目录默认 `./data`。
|
||||||
|
|
||||||
|
### 遗留部署迁移
|
||||||
|
|
||||||
|
若旧版仅配置了 `ADMIN_SECRET`、尚无用户表,可用密钥登录一次,Web 引导迁移为账号密码。
|
||||||
|
|
||||||
|
## 技术栈
|
||||||
|
|
||||||
|
| 层级 | 技术 |
|
||||||
|
|------|------|
|
||||||
|
| 前端 | Nuxt 4、Vue 3、Nuxt UI 4、Tailwind CSS 4 |
|
||||||
|
| 后端 | Nitro(Node.js)、SQLite |
|
||||||
|
| 图片处理 | sharp(WebP 转码与压缩) |
|
||||||
|
| 鉴权 | Session Cookie、scrypt 密码哈希、API Token |
|
||||||
|
| 存储 | 本地磁盘(`DATA_DIR`) |
|
||||||
|
| 部署 | Docker(amd64 / arm64)、docker compose |
|
||||||
|
| 测试 | Vitest |
|
||||||
|
|
||||||
|
## 环境变量
|
||||||
|
|
||||||
|
| 变量 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| `API_UPLOAD_TOKEN` | 全局上传 Token(`Auth-Token` 头);未配置可在后台 API 页生成 |
|
||||||
|
| `ALLOWED_REFERER_HOSTS` | 防盗链白名单(逗号分隔 hostname) |
|
||||||
|
| `IMAGE_BASE_URL` | 图片直链公网域名(反代时建议填写) |
|
||||||
|
| `WEBP_QUALITY` | 服务端 WebP 质量 1–100,默认 80 |
|
||||||
|
| `AUTO_DELETE_DAYS` | 全局自动删除天数(0 关闭);仅影响管理员及无归属历史图 |
|
||||||
|
| `DATA_DIR` | 数据目录,默认 `/data`(容器)或 `./data`(本地) |
|
||||||
|
| `ADMIN_SECRET` | 仅遗留 v1.0 迁移,新安装不需要 |
|
||||||
|
| `DEV_BYPASS_ACCESS` | 开发用,跳过登录(生产勿开) |
|
||||||
|
|
||||||
|
环境变量优先级高于后台设置。详见 [`.env.example`](.env.example)。
|
||||||
|
|
||||||
|
## 用户与权限
|
||||||
|
|
||||||
|
| 场景 | 鉴权 | 图片归属 / 可见范围 |
|
||||||
|
|------|------|---------------------|
|
||||||
|
| 网页上传 | Session | 当前登录用户 |
|
||||||
|
| API 上传 + **个人 Token** | `Auth-Token` | 该 Token 所属用户 |
|
||||||
|
| API 上传 + **全局 Token** | `Auth-Token` | 管理员(`userId` 为空) |
|
||||||
|
| Twikoo `/api/index.php` | 表单 `token` | 与全局 Token 相同 |
|
||||||
|
| 图库列表 / 搜索 / 删除 | Session 或 Token | 普通用户仅自己;管理员全部 |
|
||||||
|
| 图片直链 `GET /images/...` | 无(Referer 防盗链) | 知道 URL 即可访问 |
|
||||||
|
|
||||||
|
普通用户上传目录固定为 `images/`;自定义 `folder` 仅管理员或全局 Token 可用。
|
||||||
|
|
||||||
|
## 存储结构
|
||||||
|
|
||||||
|
```
|
||||||
|
/data
|
||||||
|
├── pichost.db # SQLite:用户、会话、设置、操作日志
|
||||||
|
├── images/ # 默认上传目录
|
||||||
|
├── blog/ # 管理员自定义 folder 示例
|
||||||
|
└── twikoo/ # Twikoo 评论图片
|
||||||
|
```
|
||||||
|
|
||||||
|
路径规则:`目录/年/月/随机ID.webp` + 同名 `.meta.json`。备份整个 `/data` 即可。
|
||||||
|
|
||||||
|
## API 概览
|
||||||
|
|
||||||
|
| 方法 | 路径 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| POST | `/api/images/upload` | 上传图片 |
|
||||||
|
| GET | `/api/images` | 分页列表 |
|
||||||
|
| DELETE | `/api/images` | 删除单张(`key`) |
|
||||||
|
| POST | `/api/index.php` | Twikoo / EasyImage 2.0 |
|
||||||
|
|
||||||
|
上传示例:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST "https://pic.example.com/api/images/upload" \
|
||||||
|
-H "Auth-Token: YOUR_TOKEN" \
|
||||||
|
-F "folder=blog" \
|
||||||
|
-F "image=@./demo.png"
|
||||||
|
```
|
||||||
|
|
||||||
|
完整接口与可复制 cURL 见部署后后台 **API** 页。
|
||||||
|
|
||||||
|
## Twikoo
|
||||||
|
|
||||||
|
| 配置项 | 值 |
|
||||||
|
|--------|-----|
|
||||||
|
| `IMAGE_CDN` | `easyimage` |
|
||||||
|
| `IMAGE_CDN_URL` | `https://你的域名/api/index.php` |
|
||||||
|
| `IMAGE_CDN_TOKEN` | 与 `API_UPLOAD_TOKEN` 相同 |
|
||||||
|
|
||||||
|
## 反向代理
|
||||||
|
|
||||||
|
- 反代到容器 **6892** 端口,建议 HTTPS
|
||||||
|
- Nginx:`client_max_body_size` ≥ 12m
|
||||||
|
- 公网访问请配置 `IMAGE_BASE_URL`
|
||||||
|
|
||||||
|
## 开发
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run dev # 开发服务器
|
||||||
|
npm run build # 生产构建
|
||||||
|
npm run start # 运行 .output
|
||||||
|
npm run lint # ESLint
|
||||||
|
npm run typecheck # 类型检查
|
||||||
|
npm test # 单元测试
|
||||||
|
```
|
||||||
|
|
||||||
|
## 路线图
|
||||||
|
|
||||||
|
| 版本 | 计划 |
|
||||||
|
|------|------|
|
||||||
|
| **v1.0.0** | 本地存储、多用户、API、Twikoo(当前) |
|
||||||
|
| **v1.1.0** | S3、Cloudflare R2 等对象存储后端 |
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
[MIT](LICENSE)
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
export default defineAppConfig({
|
||||||
|
ui: {
|
||||||
|
colors: {
|
||||||
|
primary: 'green',
|
||||||
|
neutral: 'slate'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
useHead({
|
||||||
|
title: 'PicHost - 个人图床',
|
||||||
|
meta: [
|
||||||
|
{ name: 'description', content: '个人自用的轻量图床系统' }
|
||||||
|
]
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<UApp>
|
||||||
|
<NuxtPage />
|
||||||
|
</UApp>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
|
@import "@nuxt/ui";
|
||||||
|
|
||||||
|
.upload-card-flip {
|
||||||
|
perspective: 1200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-card-flip-inner {
|
||||||
|
position: relative;
|
||||||
|
transition: transform 0.55s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
transform-style: preserve-3d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-card-flip.is-flipped .upload-card-flip-inner {
|
||||||
|
transform: rotateY(180deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-card-face {
|
||||||
|
backface-visibility: hidden;
|
||||||
|
-webkit-backface-visibility: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-card-face--back {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
transform: rotateY(180deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-card-surface {
|
||||||
|
min-height: 20.5rem;
|
||||||
|
border-radius: 1rem;
|
||||||
|
border-width: 2px;
|
||||||
|
border-style: dashed;
|
||||||
|
border-color: var(--ui-border);
|
||||||
|
background-color: color-mix(in oklab, var(--ui-bg-elevated) 50%, transparent);
|
||||||
|
padding: 2.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-card-surface--panel {
|
||||||
|
border-style: solid;
|
||||||
|
background-color: var(--ui-bg-elevated);
|
||||||
|
box-shadow: var(--ui-shadow-sm);
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 640px) {
|
||||||
|
.upload-card-surface {
|
||||||
|
min-height: 22rem;
|
||||||
|
padding: 3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-card-surface--panel {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-settings-btn .upload-settings-icon {
|
||||||
|
transition: transform 0.45s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-settings-btn:hover .upload-settings-icon {
|
||||||
|
transform: rotate(90deg);
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 506 KiB |
|
After Width: | Height: | Size: 546 KiB |
@@ -0,0 +1,194 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import logoLight from '~/assets/image/logo-light.png'
|
||||||
|
import logoDark from '~/assets/image/logo-dark.png'
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
success: []
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const { login, fetchStatus, authStatus } = useAuth()
|
||||||
|
const toast = useToast()
|
||||||
|
|
||||||
|
const username = ref('')
|
||||||
|
const password = ref('')
|
||||||
|
const secret = ref('')
|
||||||
|
const loading = ref(false)
|
||||||
|
|
||||||
|
const legacyMode = computed(() => authStatus.value?.legacyMode ?? false)
|
||||||
|
const allowRegistration = computed(() => authStatus.value?.allowRegistration ?? false)
|
||||||
|
|
||||||
|
const canSubmit = computed(() =>
|
||||||
|
legacyMode.value ? Boolean(secret.value) : Boolean(username.value && password.value)
|
||||||
|
)
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
const status = await fetchStatus()
|
||||||
|
if (!status.initialized && !status.legacyMode) {
|
||||||
|
await router.replace('/setup')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
async function submit() {
|
||||||
|
if (!canSubmit.value || loading.value) return
|
||||||
|
loading.value = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = legacyMode.value
|
||||||
|
? await login({ secret: secret.value })
|
||||||
|
: await login({ username: username.value.trim(), password: password.value })
|
||||||
|
|
||||||
|
if (result.ok) {
|
||||||
|
if (result.needsMigration) {
|
||||||
|
await router.replace('/setup?migrate=1')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
username.value = ''
|
||||||
|
password.value = ''
|
||||||
|
secret.value = ''
|
||||||
|
emit('success')
|
||||||
|
toast.add({ title: '登录成功', color: 'success' })
|
||||||
|
} else {
|
||||||
|
toast.add({
|
||||||
|
title: result.error ?? (legacyMode.value ? '登录失败,请检查密钥' : '用户名或密码错误'),
|
||||||
|
color: 'error'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="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="mb-8 space-y-3 text-center">
|
||||||
|
<div class="mx-auto inline-flex items-center justify-center">
|
||||||
|
<img
|
||||||
|
:src="logoLight"
|
||||||
|
alt=""
|
||||||
|
aria-hidden="true"
|
||||||
|
class="h-14 w-auto shrink-0 object-contain dark:hidden sm:h-16"
|
||||||
|
decoding="async"
|
||||||
|
draggable="false"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
:src="logoDark"
|
||||||
|
alt=""
|
||||||
|
aria-hidden="true"
|
||||||
|
class="hidden h-14 w-auto shrink-0 object-contain dark:block sm:h-16"
|
||||||
|
decoding="async"
|
||||||
|
draggable="false"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-1">
|
||||||
|
<h1 class="text-xl font-bold tracking-tight sm:text-2xl">
|
||||||
|
<span class="text-highlighted">Pic</span><span class="text-primary">Host</span>
|
||||||
|
</h1>
|
||||||
|
<p class="text-sm text-muted">
|
||||||
|
个人轻量图床
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form
|
||||||
|
class="flex w-full flex-col gap-5"
|
||||||
|
@submit.prevent="submit"
|
||||||
|
>
|
||||||
|
<template v-if="legacyMode">
|
||||||
|
<div class="w-full space-y-2">
|
||||||
|
<label
|
||||||
|
for="admin-secret"
|
||||||
|
class="block text-sm font-medium text-default"
|
||||||
|
>
|
||||||
|
管理密钥
|
||||||
|
</label>
|
||||||
|
<UInput
|
||||||
|
id="admin-secret"
|
||||||
|
v-model="secret"
|
||||||
|
type="password"
|
||||||
|
placeholder="输入 ADMIN_SECRET"
|
||||||
|
autocomplete="current-password"
|
||||||
|
size="lg"
|
||||||
|
class="w-full"
|
||||||
|
:ui="{ root: 'w-full' }"
|
||||||
|
@keyup.enter="submit"
|
||||||
|
/>
|
||||||
|
<p class="text-xs leading-relaxed text-muted">
|
||||||
|
遗留部署:使用密钥登录后将引导创建账号
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<div class="w-full space-y-2">
|
||||||
|
<label
|
||||||
|
for="login-username"
|
||||||
|
class="block text-sm font-medium text-default"
|
||||||
|
>
|
||||||
|
用户名
|
||||||
|
</label>
|
||||||
|
<UInput
|
||||||
|
id="login-username"
|
||||||
|
v-model="username"
|
||||||
|
placeholder="用户名"
|
||||||
|
autocomplete="username"
|
||||||
|
size="lg"
|
||||||
|
class="w-full"
|
||||||
|
:ui="{ root: 'w-full' }"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="w-full space-y-2">
|
||||||
|
<label
|
||||||
|
for="login-password"
|
||||||
|
class="block text-sm font-medium text-default"
|
||||||
|
>
|
||||||
|
密码
|
||||||
|
</label>
|
||||||
|
<UInput
|
||||||
|
id="login-password"
|
||||||
|
v-model="password"
|
||||||
|
type="password"
|
||||||
|
placeholder="密码"
|
||||||
|
autocomplete="current-password"
|
||||||
|
size="lg"
|
||||||
|
class="w-full"
|
||||||
|
:ui="{ root: 'w-full' }"
|
||||||
|
@keyup.enter="submit"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<UButton
|
||||||
|
type="submit"
|
||||||
|
label="登录"
|
||||||
|
icon="i-lucide-log-in"
|
||||||
|
size="lg"
|
||||||
|
block
|
||||||
|
class="w-full"
|
||||||
|
:loading="loading"
|
||||||
|
:disabled="!canSubmit"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<p
|
||||||
|
v-if="allowRegistration"
|
||||||
|
class="text-center text-sm text-muted"
|
||||||
|
>
|
||||||
|
没有账号?
|
||||||
|
<NuxtLink
|
||||||
|
to="/register"
|
||||||
|
class="text-primary hover:underline"
|
||||||
|
>
|
||||||
|
注册
|
||||||
|
</NuxtLink>
|
||||||
|
</p>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<p class="mt-6 text-center text-xs leading-relaxed text-dimmed">
|
||||||
|
未登录时无法访问上传和管理功能
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,198 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { DropdownMenuItem } from '@nuxt/ui'
|
||||||
|
import logoLight from '~/assets/image/logo-light.png'
|
||||||
|
import logoDark from '~/assets/image/logo-dark.png'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const colorMode = useColorMode()
|
||||||
|
const { logout, user, isAdmin } = useAuth()
|
||||||
|
|
||||||
|
const navItems = computed(() => {
|
||||||
|
const items = [
|
||||||
|
{ label: 'API', to: '/api', icon: 'i-lucide-code-2' },
|
||||||
|
{ label: '统计', to: '/stats', icon: 'i-lucide-chart-column' }
|
||||||
|
]
|
||||||
|
if (isAdmin.value) {
|
||||||
|
items.push({ label: '设置', to: '/settings', icon: 'i-lucide-settings' })
|
||||||
|
}
|
||||||
|
return items
|
||||||
|
})
|
||||||
|
|
||||||
|
const themeIcon = computed(() => {
|
||||||
|
if (colorMode.preference === 'system') return 'i-lucide-monitor'
|
||||||
|
return colorMode.preference === 'dark' ? 'i-lucide-moon' : 'i-lucide-sun'
|
||||||
|
})
|
||||||
|
|
||||||
|
const themeItems = computed<DropdownMenuItem[][]>(() => [[
|
||||||
|
{
|
||||||
|
label: '浅色',
|
||||||
|
icon: 'i-lucide-sun',
|
||||||
|
type: 'checkbox',
|
||||||
|
checked: colorMode.preference === 'light',
|
||||||
|
onUpdateChecked(checked: boolean) {
|
||||||
|
if (checked) colorMode.preference = 'light'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '深色',
|
||||||
|
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',
|
||||||
|
to: '/account/password'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
if (isAdmin.value) {
|
||||||
|
items.push({
|
||||||
|
label: '系统设置',
|
||||||
|
icon: 'i-lucide-settings',
|
||||||
|
to: '/settings'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
items.push({
|
||||||
|
label: '退出登录',
|
||||||
|
icon: 'i-lucide-log-out',
|
||||||
|
onSelect() {
|
||||||
|
void handleLogout()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return [items]
|
||||||
|
})
|
||||||
|
|
||||||
|
function isNavActive(to: string) {
|
||||||
|
return route.path.startsWith(to)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleLogout() {
|
||||||
|
await logout()
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex min-h-screen flex-col">
|
||||||
|
<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">
|
||||||
|
<NuxtLink
|
||||||
|
to="/"
|
||||||
|
title="返回上传"
|
||||||
|
class="group flex shrink-0 items-center gap-2.5 transition-opacity hover:opacity-90 sm:gap-3"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
:src="logoLight"
|
||||||
|
alt="PicHost"
|
||||||
|
class="h-10 w-auto shrink-0 object-contain sm:h-11 dark:hidden"
|
||||||
|
decoding="async"
|
||||||
|
draggable="false"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
:src="logoDark"
|
||||||
|
alt="PicHost"
|
||||||
|
class="hidden h-10 w-auto shrink-0 object-contain sm:h-11 dark:block"
|
||||||
|
decoding="async"
|
||||||
|
draggable="false"
|
||||||
|
>
|
||||||
|
<div class="min-w-0 leading-tight">
|
||||||
|
<p class="text-[15px] font-bold tracking-tight sm:text-base">
|
||||||
|
<span class="text-highlighted">Pic</span><span class="text-primary">Host</span>
|
||||||
|
</p>
|
||||||
|
<p class="mt-0.5 text-[11px] text-muted sm:text-xs">
|
||||||
|
个人轻量图床
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</NuxtLink>
|
||||||
|
|
||||||
|
<div class="flex flex-wrap items-center justify-end gap-1 sm:gap-2">
|
||||||
|
<nav class="flex flex-wrap items-center gap-1">
|
||||||
|
<UButton
|
||||||
|
v-for="item in navItems"
|
||||||
|
:key="item.to"
|
||||||
|
:to="item.to"
|
||||||
|
size="sm"
|
||||||
|
:variant="isNavActive(item.to) ? 'soft' : 'ghost'"
|
||||||
|
:color="isNavActive(item.to) ? 'primary' : 'neutral'"
|
||||||
|
:icon="item.icon"
|
||||||
|
:label="item.label"
|
||||||
|
/>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div class="mx-1 hidden h-5 w-px bg-muted sm:block" />
|
||||||
|
|
||||||
|
<ClientOnly>
|
||||||
|
<UDropdownMenu
|
||||||
|
v-if="user"
|
||||||
|
:items="userMenuItems"
|
||||||
|
>
|
||||||
|
<UButton
|
||||||
|
variant="ghost"
|
||||||
|
color="neutral"
|
||||||
|
size="sm"
|
||||||
|
class="max-w-[10rem] gap-1.5"
|
||||||
|
>
|
||||||
|
<UIcon
|
||||||
|
name="i-lucide-user"
|
||||||
|
class="size-4 shrink-0"
|
||||||
|
/>
|
||||||
|
<span class="truncate">{{ user.username }}</span>
|
||||||
|
<UIcon
|
||||||
|
name="i-lucide-chevron-down"
|
||||||
|
class="size-3.5 shrink-0 opacity-60"
|
||||||
|
/>
|
||||||
|
</UButton>
|
||||||
|
</UDropdownMenu>
|
||||||
|
<UDropdownMenu :items="themeItems">
|
||||||
|
<UButton
|
||||||
|
:icon="themeIcon"
|
||||||
|
variant="ghost"
|
||||||
|
color="neutral"
|
||||||
|
size="sm"
|
||||||
|
aria-label="切换主题"
|
||||||
|
/>
|
||||||
|
</UDropdownMenu>
|
||||||
|
<template #fallback>
|
||||||
|
<div class="size-8" />
|
||||||
|
</template>
|
||||||
|
</ClientOnly>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="mx-auto w-full max-w-7xl flex-1 space-y-6 p-4 sm:p-6">
|
||||||
|
<slot />
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<footer class="px-4 py-6 text-center text-xs text-muted">
|
||||||
|
Copyright © 2026 O96u
|
||||||
|
·
|
||||||
|
<a
|
||||||
|
href="https://github.com/O96u/PicHost"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
class="hover:text-highlighted"
|
||||||
|
>
|
||||||
|
GitHub
|
||||||
|
</a>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
const props = defineProps<{
|
||||||
|
label?: string
|
||||||
|
value: string
|
||||||
|
iconOnly?: boolean
|
||||||
|
icon?: string
|
||||||
|
block?: boolean
|
||||||
|
successTitle?: string
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
copied: []
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const toast = useToast()
|
||||||
|
const copying = ref(false)
|
||||||
|
|
||||||
|
function copyWithFallback(text: string): boolean {
|
||||||
|
const textarea = document.createElement('textarea')
|
||||||
|
textarea.value = text
|
||||||
|
textarea.setAttribute('readonly', '')
|
||||||
|
textarea.style.position = 'fixed'
|
||||||
|
textarea.style.left = '-9999px'
|
||||||
|
textarea.style.top = '0'
|
||||||
|
document.body.appendChild(textarea)
|
||||||
|
textarea.focus()
|
||||||
|
textarea.select()
|
||||||
|
textarea.setSelectionRange(0, text.length)
|
||||||
|
|
||||||
|
let ok = false
|
||||||
|
try {
|
||||||
|
ok = document.execCommand('copy')
|
||||||
|
} catch {
|
||||||
|
ok = false
|
||||||
|
} finally {
|
||||||
|
document.body.removeChild(textarea)
|
||||||
|
}
|
||||||
|
return ok
|
||||||
|
}
|
||||||
|
|
||||||
|
function notifySuccess() {
|
||||||
|
toast.add({
|
||||||
|
title: props.successTitle ?? (props.label ? `已复制 ${props.label}` : '已复制到剪贴板'),
|
||||||
|
color: 'success'
|
||||||
|
})
|
||||||
|
emit('copied')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function copy() {
|
||||||
|
if (copying.value || !props.value) return
|
||||||
|
copying.value = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (typeof navigator !== 'undefined' && navigator.clipboard?.writeText) {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(props.value)
|
||||||
|
notifySuccess()
|
||||||
|
return
|
||||||
|
} catch {
|
||||||
|
// 内网 http://IP 等非安全上下文会到这里,继续走回退
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (copyWithFallback(props.value)) {
|
||||||
|
notifySuccess()
|
||||||
|
} else {
|
||||||
|
toast.add({ title: '复制失败,请手动选中链接复制', color: 'error' })
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
copying.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<UButton
|
||||||
|
size="xs"
|
||||||
|
variant="soft"
|
||||||
|
:loading="copying"
|
||||||
|
:disabled="!value"
|
||||||
|
:label="iconOnly ? undefined : label"
|
||||||
|
:icon="icon"
|
||||||
|
:class="block ? 'flex-1 justify-center' : undefined"
|
||||||
|
:aria-label="iconOnly ? label : undefined"
|
||||||
|
:title="iconOnly ? label : undefined"
|
||||||
|
@click="copy"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
defineProps<{
|
||||||
|
open: boolean
|
||||||
|
count: number
|
||||||
|
loading?: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'update:open': [value: boolean]
|
||||||
|
'confirm': []
|
||||||
|
}>()
|
||||||
|
|
||||||
|
function close() {
|
||||||
|
emit('update:open', false)
|
||||||
|
}
|
||||||
|
|
||||||
|
function confirm() {
|
||||||
|
emit('confirm')
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<UModal
|
||||||
|
:open="open"
|
||||||
|
title="确认删除"
|
||||||
|
:description="`确定要删除选中的 ${count} 张图片吗?此操作不可恢复。`"
|
||||||
|
@update:open="emit('update:open', $event)"
|
||||||
|
>
|
||||||
|
<template #footer>
|
||||||
|
<div class="flex justify-end gap-2">
|
||||||
|
<UButton
|
||||||
|
label="取消"
|
||||||
|
color="neutral"
|
||||||
|
variant="outline"
|
||||||
|
@click="close"
|
||||||
|
/>
|
||||||
|
<UButton
|
||||||
|
label="确认删除"
|
||||||
|
color="error"
|
||||||
|
:loading="loading"
|
||||||
|
@click="confirm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</UModal>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { ImageItem } from '~/types/image'
|
||||||
|
|
||||||
|
type CopyFormat = 'url' | 'markdown' | 'html'
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<{
|
||||||
|
image: ImageItem
|
||||||
|
selected: boolean
|
||||||
|
deleting?: boolean
|
||||||
|
selectable?: boolean
|
||||||
|
showKey?: boolean
|
||||||
|
}>(), {
|
||||||
|
selectable: true,
|
||||||
|
showKey: true
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'update:selected': [value: boolean]
|
||||||
|
'delete': []
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const { formatFileSize } = useFileSize()
|
||||||
|
const imageError = ref(false)
|
||||||
|
const copyFormat = ref<CopyFormat>('url')
|
||||||
|
|
||||||
|
const copyFormatItems = [
|
||||||
|
{ label: '直链', value: 'url' as const },
|
||||||
|
{ label: 'Markdown', value: 'markdown' as const },
|
||||||
|
{ label: 'HTML', value: 'html' as const }
|
||||||
|
]
|
||||||
|
|
||||||
|
const uploadedLabel = computed(() => {
|
||||||
|
try {
|
||||||
|
return new Date(props.image.uploadedAt).toLocaleString('zh-CN', { hour12: false })
|
||||||
|
} catch {
|
||||||
|
return props.image.uploadedAt
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const previewValue = computed(() => {
|
||||||
|
switch (copyFormat.value) {
|
||||||
|
case 'markdown':
|
||||||
|
return props.image.markdown
|
||||||
|
case 'html':
|
||||||
|
return props.image.html
|
||||||
|
default:
|
||||||
|
return props.image.url
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const copySuccessTitle = computed(() => {
|
||||||
|
switch (copyFormat.value) {
|
||||||
|
case 'markdown':
|
||||||
|
return '已复制 Markdown'
|
||||||
|
case 'html':
|
||||||
|
return '已复制 HTML'
|
||||||
|
default:
|
||||||
|
return '已复制直链'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
function toggleSelected(value: boolean | 'indeterminate') {
|
||||||
|
emit('update:selected', value === true)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex flex-col overflow-hidden rounded-xl border border-default bg-elevated shadow-sm">
|
||||||
|
<div class="relative aspect-square shrink-0 overflow-hidden bg-muted">
|
||||||
|
<img
|
||||||
|
v-if="!imageError"
|
||||||
|
:src="image.url"
|
||||||
|
:alt="image.originalName"
|
||||||
|
loading="lazy"
|
||||||
|
class="absolute inset-0 size-full object-cover object-center"
|
||||||
|
@error="imageError = true"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-else
|
||||||
|
class="flex h-full items-center justify-center text-sm text-muted"
|
||||||
|
>
|
||||||
|
预览失败
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="selectable"
|
||||||
|
class="absolute top-2 left-2"
|
||||||
|
>
|
||||||
|
<UCheckbox
|
||||||
|
:model-value="selected"
|
||||||
|
@update:model-value="toggleSelected"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="absolute top-2 right-2">
|
||||||
|
<UButton
|
||||||
|
size="xs"
|
||||||
|
color="error"
|
||||||
|
variant="solid"
|
||||||
|
icon="i-lucide-trash-2"
|
||||||
|
aria-label="删除"
|
||||||
|
:loading="deleting"
|
||||||
|
@click="emit('delete')"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="image.owner"
|
||||||
|
class="absolute bottom-2 left-2 max-w-[calc(100%-1rem)]"
|
||||||
|
>
|
||||||
|
<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="`上传者:${image.owner.username}`"
|
||||||
|
>
|
||||||
|
<UIcon
|
||||||
|
name="i-lucide-user"
|
||||||
|
class="size-3 shrink-0"
|
||||||
|
/>
|
||||||
|
<span class="truncate">{{ image.owner.username }}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-1 flex-col gap-2 p-3">
|
||||||
|
<p
|
||||||
|
class="truncate text-sm font-medium"
|
||||||
|
:title="image.originalName"
|
||||||
|
>
|
||||||
|
{{ image.originalName }}
|
||||||
|
</p>
|
||||||
|
<p class="text-xs text-muted">
|
||||||
|
{{ uploadedLabel }} · {{ formatFileSize(image.size) }}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="flex gap-1 rounded-lg border border-default p-0.5">
|
||||||
|
<UButton
|
||||||
|
v-for="item in copyFormatItems"
|
||||||
|
:key="item.value"
|
||||||
|
size="xs"
|
||||||
|
class="flex-1 justify-center"
|
||||||
|
:variant="copyFormat === item.value ? 'solid' : 'ghost'"
|
||||||
|
:color="copyFormat === item.value ? 'primary' : 'neutral'"
|
||||||
|
:label="item.label"
|
||||||
|
@click="() => { copyFormat = item.value }"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-1">
|
||||||
|
<UInput
|
||||||
|
:model-value="previewValue"
|
||||||
|
readonly
|
||||||
|
size="xs"
|
||||||
|
class="min-w-0 flex-1 font-mono text-xs"
|
||||||
|
/>
|
||||||
|
<CopyButton
|
||||||
|
icon="i-lucide-copy"
|
||||||
|
label="复制"
|
||||||
|
:value="previewValue"
|
||||||
|
:success-title="copySuccessTitle"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p
|
||||||
|
v-if="showKey"
|
||||||
|
class="truncate text-xs text-dimmed"
|
||||||
|
:title="image.key"
|
||||||
|
>
|
||||||
|
{{ image.key }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { ImageItem } from '~/types/image'
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<{
|
||||||
|
items: ImageItem[]
|
||||||
|
selectedKeys: Set<string>
|
||||||
|
deletingKeys: Set<string>
|
||||||
|
selectable?: boolean
|
||||||
|
showKey?: boolean
|
||||||
|
emptyText?: string
|
||||||
|
}>(), {
|
||||||
|
selectable: true,
|
||||||
|
showKey: true,
|
||||||
|
emptyText: '暂无图片'
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'update:selectedKeys': [value: Set<string>]
|
||||||
|
'delete': [key: string]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
function updateSelection(key: string, selected: boolean) {
|
||||||
|
const next = new Set(props.selectedKeys)
|
||||||
|
if (selected) next.add(key)
|
||||||
|
else next.delete(key)
|
||||||
|
emit('update:selectedKeys', next)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
<ImageCard
|
||||||
|
v-for="image in items"
|
||||||
|
:key="image.key"
|
||||||
|
:image="image"
|
||||||
|
:selected="selectedKeys.has(image.key)"
|
||||||
|
:deleting="deletingKeys.has(image.key)"
|
||||||
|
:selectable="selectable"
|
||||||
|
:show-key="showKey"
|
||||||
|
@update:selected="updateSelection(image.key, $event)"
|
||||||
|
@delete="emit('delete', image.key)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-else
|
||||||
|
class="rounded-xl border border-dashed border-default py-16 text-center text-sm text-muted"
|
||||||
|
>
|
||||||
|
{{ emptyText }}
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
const props = defineProps<{
|
||||||
|
disabled?: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'upload': [files: File[]]
|
||||||
|
'open-settings': []
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const fileInput = ref<HTMLInputElement | null>(null)
|
||||||
|
const dragging = ref(false)
|
||||||
|
const hovering = ref(false)
|
||||||
|
|
||||||
|
const { bindPaste } = useClipboardImage(ref(null))
|
||||||
|
|
||||||
|
const isActive = computed(() => dragging.value || hovering.value)
|
||||||
|
|
||||||
|
bindPaste((files) => {
|
||||||
|
if (!props.disabled) emit('upload', files)
|
||||||
|
})
|
||||||
|
|
||||||
|
function openFilePicker() {
|
||||||
|
if (props.disabled) return
|
||||||
|
fileInput.value?.click()
|
||||||
|
}
|
||||||
|
|
||||||
|
function onFileChange(event: Event) {
|
||||||
|
const input = event.target as HTMLInputElement
|
||||||
|
const files = input.files ? Array.from(input.files) : []
|
||||||
|
if (files.length) emit('upload', files)
|
||||||
|
input.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDrop(event: DragEvent) {
|
||||||
|
event.preventDefault()
|
||||||
|
dragging.value = false
|
||||||
|
if (props.disabled) return
|
||||||
|
|
||||||
|
const files = Array.from(event.dataTransfer?.files ?? [])
|
||||||
|
.filter(file => file.type.startsWith('image/'))
|
||||||
|
if (files.length) emit('upload', files)
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDragOver(event: DragEvent) {
|
||||||
|
event.preventDefault()
|
||||||
|
if (!props.disabled) dragging.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDragLeave() {
|
||||||
|
dragging.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
function onMouseEnter() {
|
||||||
|
if (!props.disabled) hovering.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function onMouseLeave() {
|
||||||
|
hovering.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
function openSettings() {
|
||||||
|
if (props.disabled) return
|
||||||
|
emit('open-settings')
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="upload-card-surface group/upload relative text-center transition-all duration-300 ease-out"
|
||||||
|
:class="isActive
|
||||||
|
? 'scale-[1.01] border-primary bg-primary/5 shadow-md shadow-primary/10'
|
||||||
|
: 'hover:border-primary/50 hover:bg-primary/[0.04] hover:shadow-sm'"
|
||||||
|
@drop="onDrop"
|
||||||
|
@dragover="onDragOver"
|
||||||
|
@dragleave="onDragLeave"
|
||||||
|
@mouseenter="onMouseEnter"
|
||||||
|
@mouseleave="onMouseLeave"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
ref="fileInput"
|
||||||
|
type="file"
|
||||||
|
accept="image/jpeg,image/png,image/webp,image/gif,image/svg+xml,image/x-icon,.svg,.ico"
|
||||||
|
multiple
|
||||||
|
class="hidden"
|
||||||
|
:disabled="disabled"
|
||||||
|
@change="onFileChange"
|
||||||
|
>
|
||||||
|
|
||||||
|
<div class="absolute top-3 right-3 z-10">
|
||||||
|
<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"
|
||||||
|
aria-label="上传设置"
|
||||||
|
:disabled="disabled"
|
||||||
|
@click="openSettings"
|
||||||
|
>
|
||||||
|
<UIcon
|
||||||
|
name="i-lucide-settings"
|
||||||
|
class="upload-settings-icon size-4"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mx-auto max-w-lg space-y-4">
|
||||||
|
<div
|
||||||
|
class="mx-auto flex size-16 items-center justify-center rounded-2xl bg-primary/10 transition-transform duration-300 ease-out"
|
||||||
|
:class="isActive ? 'scale-110' : 'group-hover/upload:scale-105'"
|
||||||
|
>
|
||||||
|
<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">
|
||||||
|
<p class="text-xl font-semibold tracking-tight">
|
||||||
|
拖拽图片到此处上传
|
||||||
|
</p>
|
||||||
|
<p class="text-sm text-muted">
|
||||||
|
或点击选择文件,按
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-dimmed">
|
||||||
|
支持 JPEG / PNG / WebP / GIF / SVG / ICO,单张最大 10 MB,每次最多 10 张
|
||||||
|
</p>
|
||||||
|
<UButton
|
||||||
|
label="选择图片"
|
||||||
|
icon="i-lucide-upload"
|
||||||
|
size="lg"
|
||||||
|
:disabled="disabled"
|
||||||
|
@click="openFilePicker"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
const props = defineProps<{
|
||||||
|
page: number
|
||||||
|
totalPages: number
|
||||||
|
total: number
|
||||||
|
unit?: string
|
||||||
|
loading?: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'update:page': [page: number]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const unitLabel = computed(() => props.unit ?? '条')
|
||||||
|
|
||||||
|
function goTo(target: number) {
|
||||||
|
if (target < 1 || target > props.totalPages || target === props.page || props.loading) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
emit('update:page', target)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
v-if="total > 0"
|
||||||
|
class="flex flex-col items-center gap-2 border-t border-default pt-4 sm:flex-row sm:justify-between"
|
||||||
|
>
|
||||||
|
<p
|
||||||
|
v-if="totalPages > 1"
|
||||||
|
class="text-sm text-muted"
|
||||||
|
>
|
||||||
|
第 {{ page }} / {{ totalPages }} 页 · 共 {{ total }} {{ unitLabel }}
|
||||||
|
</p>
|
||||||
|
<p
|
||||||
|
v-else
|
||||||
|
class="text-sm text-muted"
|
||||||
|
>
|
||||||
|
共 {{ total }} {{ unitLabel }}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="totalPages > 1"
|
||||||
|
class="flex items-center gap-1"
|
||||||
|
>
|
||||||
|
<UButton
|
||||||
|
icon="i-lucide-chevrons-left"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
aria-label="第一页"
|
||||||
|
:disabled="page <= 1 || loading"
|
||||||
|
@click="goTo(1)"
|
||||||
|
/>
|
||||||
|
<UButton
|
||||||
|
icon="i-lucide-chevron-left"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
aria-label="上一页"
|
||||||
|
:disabled="page <= 1 || loading"
|
||||||
|
@click="goTo(page - 1)"
|
||||||
|
/>
|
||||||
|
<UButton
|
||||||
|
icon="i-lucide-chevron-right"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
aria-label="下一页"
|
||||||
|
:disabled="page >= totalPages || loading"
|
||||||
|
@click="goTo(page + 1)"
|
||||||
|
/>
|
||||||
|
<UButton
|
||||||
|
icon="i-lucide-chevrons-right"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
aria-label="最后一页"
|
||||||
|
:disabled="page >= totalPages || loading"
|
||||||
|
@click="goTo(totalPages)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { analyzePassword, PASSWORD_MIN_LENGTH } from '~/utils/password-strength'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
password: string
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const analysis = computed(() => analyzePassword(props.password))
|
||||||
|
|
||||||
|
const show = computed(() => props.password.length > 0)
|
||||||
|
|
||||||
|
const labelColor = computed(() => {
|
||||||
|
switch (analysis.value.label) {
|
||||||
|
case '强':
|
||||||
|
return 'text-success'
|
||||||
|
case '中':
|
||||||
|
return 'text-warning'
|
||||||
|
default:
|
||||||
|
return 'text-error'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const barColor = computed(() => {
|
||||||
|
switch (analysis.value.label) {
|
||||||
|
case '强':
|
||||||
|
return 'bg-success'
|
||||||
|
case '中':
|
||||||
|
return 'bg-warning'
|
||||||
|
default:
|
||||||
|
return 'bg-error/80'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const rules = computed(() => [
|
||||||
|
{ key: 'minLength', label: `至少 ${PASSWORD_MIN_LENGTH} 个字符` },
|
||||||
|
{ key: 'mixedCase', label: '包含大小写字母' },
|
||||||
|
{ key: 'hasNumber', label: '包含数字' },
|
||||||
|
{ key: 'hasSpecial', label: '包含特殊字符' }
|
||||||
|
] as const)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
v-if="show"
|
||||||
|
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">
|
||||||
|
<span class="text-muted">密码强度</span>
|
||||||
|
<span
|
||||||
|
class="font-medium"
|
||||||
|
:class="labelColor"
|
||||||
|
>
|
||||||
|
{{ analysis.label }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex gap-1.5">
|
||||||
|
<span
|
||||||
|
v-for="index in 4"
|
||||||
|
:key="index"
|
||||||
|
class="h-1.5 flex-1 rounded-full transition-colors"
|
||||||
|
:class="index <= analysis.score ? barColor : 'bg-default'"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul class="flex flex-wrap gap-x-4 gap-y-1.5 text-xs">
|
||||||
|
<li
|
||||||
|
v-for="rule in rules"
|
||||||
|
:key="rule.key"
|
||||||
|
class="flex items-center gap-1.5 transition-colors"
|
||||||
|
:class="analysis.criteria[rule.key] ? 'text-success' : 'text-muted'"
|
||||||
|
>
|
||||||
|
<UIcon
|
||||||
|
:name="analysis.criteria[rule.key] ? 'i-lucide-check' : 'i-lucide-circle'"
|
||||||
|
class="size-3.5 shrink-0"
|
||||||
|
/>
|
||||||
|
<span>{{ rule.label }}</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
export interface DonutSegment {
|
||||||
|
label: string
|
||||||
|
value: number
|
||||||
|
color: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
segments: DonutSegment[]
|
||||||
|
size?: number
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const total = computed(() =>
|
||||||
|
props.segments.reduce((sum, item) => sum + item.value, 0)
|
||||||
|
)
|
||||||
|
|
||||||
|
const gradient = computed(() => {
|
||||||
|
if (!total.value) {
|
||||||
|
return 'conic-gradient(var(--ui-bg-muted) 0% 100%)'
|
||||||
|
}
|
||||||
|
|
||||||
|
let offset = 0
|
||||||
|
const stops = props.segments
|
||||||
|
.filter(item => item.value > 0)
|
||||||
|
.map((item) => {
|
||||||
|
const start = offset
|
||||||
|
const percent = (item.value / total.value) * 100
|
||||||
|
offset += percent
|
||||||
|
return `${item.color} ${start}% ${offset}%`
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!stops.length) {
|
||||||
|
return 'conic-gradient(var(--ui-bg-muted) 0% 100%)'
|
||||||
|
}
|
||||||
|
|
||||||
|
return `conic-gradient(${stops.join(', ')})`
|
||||||
|
})
|
||||||
|
|
||||||
|
function percent(value: number) {
|
||||||
|
if (!total.value) return 0
|
||||||
|
return Math.round((value / total.value) * 100)
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatCount(value: number) {
|
||||||
|
if (value >= 100000000) {
|
||||||
|
return `${(value / 100000000).toFixed(value >= 1000000000 ? 0 : 1)}亿`
|
||||||
|
}
|
||||||
|
if (value >= 10000) {
|
||||||
|
return `${(value / 10000).toFixed(value >= 100000 ? 0 : 1)}万`
|
||||||
|
}
|
||||||
|
return String(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
const centerText = computed(() => formatCount(total.value))
|
||||||
|
|
||||||
|
const centerTextClass = computed(() => {
|
||||||
|
const raw = String(total.value)
|
||||||
|
if (raw.length >= 7) return 'text-[9px] font-semibold leading-none'
|
||||||
|
if (raw.length >= 5) return 'text-xs font-semibold leading-none'
|
||||||
|
if (raw.length >= 4) return 'text-sm font-semibold leading-none'
|
||||||
|
return 'text-lg font-semibold leading-none'
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex items-center gap-4">
|
||||||
|
<div
|
||||||
|
class="relative shrink-0 rounded-full"
|
||||||
|
:style="{
|
||||||
|
width: `${size ?? 88}px`,
|
||||||
|
height: `${size ?? 88}px`,
|
||||||
|
background: gradient
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="absolute inset-[20%] flex flex-col items-center justify-center rounded-full bg-elevated px-1 text-center"
|
||||||
|
>
|
||||||
|
<p
|
||||||
|
class="max-w-full truncate tabular-nums"
|
||||||
|
:class="centerTextClass"
|
||||||
|
:title="String(total)"
|
||||||
|
>
|
||||||
|
{{ centerText }}
|
||||||
|
</p>
|
||||||
|
<p class="mt-0.5 text-[10px] text-muted">
|
||||||
|
次
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul class="min-w-0 flex-1 space-y-1.5">
|
||||||
|
<li
|
||||||
|
v-for="item in segments"
|
||||||
|
:key="item.label"
|
||||||
|
class="flex items-center gap-2 text-xs"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="size-2 shrink-0 rounded-full"
|
||||||
|
:style="{ background: item.color }"
|
||||||
|
/>
|
||||||
|
<span class="min-w-0 flex-1 truncate">{{ item.label }}</span>
|
||||||
|
<span
|
||||||
|
class="shrink-0 tabular-nums text-muted"
|
||||||
|
:title="`${item.value}`"
|
||||||
|
>
|
||||||
|
{{ formatCount(item.value) }} ({{ percent(item.value) }}%)
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
defineProps<{
|
||||||
|
disabled?: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
upload: [files: File[]]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const flipped = ref(false)
|
||||||
|
|
||||||
|
function onOpenSettings() {
|
||||||
|
flipped.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function onBack() {
|
||||||
|
flipped.value = false
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="upload-card-flip"
|
||||||
|
:class="{ 'is-flipped': flipped }"
|
||||||
|
>
|
||||||
|
<div class="upload-card-flip-inner">
|
||||||
|
<div class="upload-card-face upload-card-face--front">
|
||||||
|
<ImageUploader
|
||||||
|
:disabled="disabled"
|
||||||
|
@upload="emit('upload', $event)"
|
||||||
|
@open-settings="onOpenSettings"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="upload-card-face upload-card-face--back">
|
||||||
|
<UploadPreferencesPanel @back="onBack" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,221 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
embedded?: boolean
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
embedded: false
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
back: []
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const {
|
||||||
|
compressEnabled,
|
||||||
|
autoCopyMarkdown,
|
||||||
|
copyFormat,
|
||||||
|
clientWebpQuality,
|
||||||
|
loadPreferences
|
||||||
|
} = useUploadPreferences()
|
||||||
|
|
||||||
|
const {
|
||||||
|
userSettings,
|
||||||
|
enabled: userAutoDeleteEnabled,
|
||||||
|
daysDraft: userAutoDeleteDaysDraft,
|
||||||
|
load: loadUserSettings
|
||||||
|
} = useUserAutoDeleteSettings()
|
||||||
|
|
||||||
|
const copyFormatItems = [
|
||||||
|
{ label: '直链', value: 'url' as const },
|
||||||
|
{ label: 'Markdown', value: 'markdown' as const },
|
||||||
|
{ label: 'HTML', value: 'html' as const }
|
||||||
|
]
|
||||||
|
|
||||||
|
const clientQualityLevel = computed(() => {
|
||||||
|
if (clientWebpQuality.value >= 85) return '高质量'
|
||||||
|
if (clientWebpQuality.value >= 60) return '中等质量'
|
||||||
|
return '有损压缩'
|
||||||
|
})
|
||||||
|
|
||||||
|
function setCopyFormat(value: 'url' | 'markdown' | 'html') {
|
||||||
|
copyFormat.value = value
|
||||||
|
}
|
||||||
|
|
||||||
|
function onClientQualityInput(event: Event) {
|
||||||
|
const value = Number((event.target as HTMLInputElement).value)
|
||||||
|
clientWebpQuality.value = Number.isFinite(value) ? value : 80
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
loadPreferences()
|
||||||
|
void loadUserSettings()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="flex flex-col overflow-hidden"
|
||||||
|
:class="
|
||||||
|
embedded ? '' : 'upload-card-surface upload-card-surface--panel h-full'
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="flex items-start justify-between gap-3 border-b border-default px-5 py-4 sm:px-6"
|
||||||
|
>
|
||||||
|
<div class="flex min-w-0 items-start gap-2">
|
||||||
|
<UIcon
|
||||||
|
name="i-lucide-settings-2"
|
||||||
|
class="mt-0.5 size-5 shrink-0 text-primary"
|
||||||
|
/>
|
||||||
|
<div class="min-w-0">
|
||||||
|
<h2 class="text-base font-semibold">
|
||||||
|
上传偏好
|
||||||
|
</h2>
|
||||||
|
<p class="mt-0.5 text-xs text-muted">
|
||||||
|
浏览器本地与账号设置,仅影响您的上传体验
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<UButton
|
||||||
|
v-if="!embedded"
|
||||||
|
label="返回"
|
||||||
|
icon="i-lucide-arrow-left"
|
||||||
|
variant="ghost"
|
||||||
|
color="neutral"
|
||||||
|
size="sm"
|
||||||
|
class="shrink-0"
|
||||||
|
aria-label="返回上传"
|
||||||
|
@click="emit('back')"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="p-5 sm:p-6"
|
||||||
|
:class="embedded ? '' : 'flex flex-1 flex-col justify-center'"
|
||||||
|
>
|
||||||
|
<div class="grid gap-5 sm:grid-cols-3 sm:gap-6">
|
||||||
|
<div class="space-y-4">
|
||||||
|
<h3 class="text-sm font-semibold">
|
||||||
|
图片处理
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<label class="flex cursor-pointer items-start gap-2.5">
|
||||||
|
<UCheckbox
|
||||||
|
v-model="compressEnabled"
|
||||||
|
class="mt-0.5"
|
||||||
|
/>
|
||||||
|
<span>
|
||||||
|
<span class="block text-sm">客户端预压缩</span>
|
||||||
|
<span class="mt-1 block text-xs leading-relaxed text-muted">
|
||||||
|
上传前在浏览器端压缩(服务端仍会转 WebP)
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="userSettings"
|
||||||
|
class="border-t border-default pt-4"
|
||||||
|
>
|
||||||
|
<label class="flex cursor-pointer items-start gap-2.5">
|
||||||
|
<UCheckbox
|
||||||
|
v-model="userAutoDeleteEnabled"
|
||||||
|
class="mt-0.5"
|
||||||
|
/>
|
||||||
|
<span class="min-w-0 flex-1">
|
||||||
|
<span
|
||||||
|
class="flex flex-wrap items-center gap-x-1.5 gap-y-1 text-sm"
|
||||||
|
>
|
||||||
|
<span>自动删除超过</span>
|
||||||
|
<UInput
|
||||||
|
v-model.number="userAutoDeleteDaysDraft"
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
max="3650"
|
||||||
|
size="xs"
|
||||||
|
class="w-14 tabular-nums"
|
||||||
|
:class="
|
||||||
|
userAutoDeleteEnabled
|
||||||
|
? ''
|
||||||
|
: 'pointer-events-none opacity-40'
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
<span>天的图片</span>
|
||||||
|
</span>
|
||||||
|
<span class="mt-1 block text-xs leading-relaxed text-muted">
|
||||||
|
仅清理开启后上传的图片,不影响此前已存的图片
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-else
|
||||||
|
class="flex justify-center border-t border-default py-4"
|
||||||
|
>
|
||||||
|
<UIcon
|
||||||
|
name="i-lucide-loader-circle"
|
||||||
|
class="size-5 animate-spin text-muted"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-4">
|
||||||
|
<h3 class="text-sm font-semibold">
|
||||||
|
WebP 压缩质量
|
||||||
|
</h3>
|
||||||
|
<p class="text-xs leading-relaxed text-muted">
|
||||||
|
客户端预压缩质量(1–100),需开启「客户端预压缩」
|
||||||
|
</p>
|
||||||
|
<div
|
||||||
|
class="flex items-center justify-between gap-2 text-sm text-muted"
|
||||||
|
>
|
||||||
|
<span>当前:{{ clientWebpQuality }}</span>
|
||||||
|
<span>{{ clientQualityLevel }}</span>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min="1"
|
||||||
|
max="100"
|
||||||
|
:value="clientWebpQuality"
|
||||||
|
class="h-2 w-full cursor-pointer appearance-none rounded-full bg-default accent-primary"
|
||||||
|
@input="onClientQualityInput"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-4">
|
||||||
|
<h3 class="text-sm font-semibold">
|
||||||
|
便捷功能
|
||||||
|
</h3>
|
||||||
|
<label class="flex cursor-pointer items-start gap-2.5">
|
||||||
|
<UCheckbox
|
||||||
|
v-model="autoCopyMarkdown"
|
||||||
|
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
|
||||||
|
class="ml-7 flex gap-1 rounded-lg border border-default p-0.5"
|
||||||
|
:class="autoCopyMarkdown ? '' : 'pointer-events-none opacity-40'"
|
||||||
|
>
|
||||||
|
<UButton
|
||||||
|
v-for="item in copyFormatItems"
|
||||||
|
:key="item.value"
|
||||||
|
size="xs"
|
||||||
|
class="flex-1 justify-center"
|
||||||
|
:variant="copyFormat === item.value ? 'solid' : 'ghost'"
|
||||||
|
:color="copyFormat === item.value ? 'primary' : 'neutral'"
|
||||||
|
:label="item.label"
|
||||||
|
@click="setCopyFormat(item.value)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { UploadProgressItem } from '~/types/image'
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
items: UploadProgressItem[]
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
v-if="items.length"
|
||||||
|
class="space-y-2 rounded-lg border border-default p-4"
|
||||||
|
>
|
||||||
|
<p class="text-sm font-medium text-muted">
|
||||||
|
上传进度
|
||||||
|
</p>
|
||||||
|
<div
|
||||||
|
v-for="item in items"
|
||||||
|
:key="item.name"
|
||||||
|
class="space-y-1"
|
||||||
|
>
|
||||||
|
<div class="flex items-center justify-between gap-2 text-sm">
|
||||||
|
<span class="truncate">{{ item.name }}</span>
|
||||||
|
<UBadge
|
||||||
|
:color="item.status === 'success' ? 'success' : item.status === 'error' ? 'error' : 'primary'"
|
||||||
|
variant="subtle"
|
||||||
|
size="xs"
|
||||||
|
>
|
||||||
|
{{ item.status === 'pending' ? '等待' : item.status === 'uploading' ? '上传中' : item.status === 'success' ? '成功' : '失败' }}
|
||||||
|
</UBadge>
|
||||||
|
</div>
|
||||||
|
<UProgress
|
||||||
|
:model-value="item.progress"
|
||||||
|
size="xs"
|
||||||
|
/>
|
||||||
|
<p
|
||||||
|
v-if="item.error"
|
||||||
|
class="text-xs text-error"
|
||||||
|
>
|
||||||
|
{{ item.error }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,299 @@
|
|||||||
|
export type AuthStatus = 'checking' | 'authenticated' | 'unauthenticated'
|
||||||
|
|
||||||
|
export type UserRole = 'admin' | 'user'
|
||||||
|
|
||||||
|
export interface AuthUser {
|
||||||
|
id: number
|
||||||
|
username: string
|
||||||
|
role: UserRole
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuthStatusResponse {
|
||||||
|
initialized: boolean
|
||||||
|
allowRegistration: boolean
|
||||||
|
legacyMode: boolean
|
||||||
|
needsMigration: boolean
|
||||||
|
user: AuthUser | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isUnauthorizedError(error: unknown): boolean {
|
||||||
|
return typeof error === 'object'
|
||||||
|
&& error !== null
|
||||||
|
&& 'statusCode' in error
|
||||||
|
&& (error as { statusCode: number }).statusCode === 401
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AUTH_USERNAME_PATTERN = /^[a-zA-Z0-9_]{3,32}$/
|
||||||
|
export const AUTH_MIN_PASSWORD_LENGTH = 8
|
||||||
|
|
||||||
|
export function validateAuthInput(username: string, password: string): string | null {
|
||||||
|
const trimmed = username.trim()
|
||||||
|
if (!AUTH_USERNAME_PATTERN.test(trimmed)) {
|
||||||
|
return '用户名需为 3–32 位字母、数字或下划线'
|
||||||
|
}
|
||||||
|
if (password.length < AUTH_MIN_PASSWORD_LENGTH) {
|
||||||
|
return '密码至少 8 位'
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getFetchErrorMessage(error: unknown, fallback: string): string {
|
||||||
|
if (!error || typeof error !== 'object') return fallback
|
||||||
|
|
||||||
|
const e = error as {
|
||||||
|
data?: {
|
||||||
|
message?: string
|
||||||
|
error?: { message?: string }
|
||||||
|
data?: { error?: { message?: string } }
|
||||||
|
}
|
||||||
|
statusMessage?: string
|
||||||
|
message?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const nested = e.data?.data?.error?.message
|
||||||
|
?? e.data?.error?.message
|
||||||
|
?? e.data?.message
|
||||||
|
if (nested) return nested
|
||||||
|
|
||||||
|
if (e.statusMessage && e.statusMessage !== 'Bad Request') {
|
||||||
|
return e.statusMessage
|
||||||
|
}
|
||||||
|
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AuthActionResult = { ok: true } | { ok: false, error: string }
|
||||||
|
|
||||||
|
export function useAuth() {
|
||||||
|
const status = useState<AuthStatus>('auth-status', () => 'checking')
|
||||||
|
const user = useState<AuthUser | null>('auth-user', () => null)
|
||||||
|
const authStatus = useState<AuthStatusResponse | null>('auth-status-response', () => null)
|
||||||
|
|
||||||
|
const isChecking = computed(() => status.value === 'checking')
|
||||||
|
const isAuthenticated = computed(() => status.value === 'authenticated')
|
||||||
|
const isAdmin = computed(() => user.value?.role === 'admin')
|
||||||
|
|
||||||
|
async function fetchStatus(): Promise<AuthStatusResponse> {
|
||||||
|
const data = await $fetch<AuthStatusResponse>('/api/auth/status', {
|
||||||
|
credentials: 'include'
|
||||||
|
})
|
||||||
|
authStatus.value = data
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
async function checkSession() {
|
||||||
|
status.value = 'checking'
|
||||||
|
try {
|
||||||
|
const data = await fetchStatus()
|
||||||
|
if (data.user) {
|
||||||
|
user.value = data.user
|
||||||
|
status.value = 'authenticated'
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
user.value = null
|
||||||
|
status.value = 'unauthenticated'
|
||||||
|
return false
|
||||||
|
} catch {
|
||||||
|
user.value = null
|
||||||
|
status.value = 'unauthenticated'
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function markUnauthenticated() {
|
||||||
|
status.value = 'unauthenticated'
|
||||||
|
user.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
async function login(
|
||||||
|
credentials: { username: string, password: string }
|
||||||
|
| { secret: string }
|
||||||
|
): Promise<{ ok: boolean, needsMigration?: boolean, error?: string }> {
|
||||||
|
try {
|
||||||
|
const result = await $fetch<{
|
||||||
|
success: boolean
|
||||||
|
needsMigration?: boolean
|
||||||
|
user?: AuthUser
|
||||||
|
}>('/api/auth/login', {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'include',
|
||||||
|
body: credentials
|
||||||
|
})
|
||||||
|
if (result.needsMigration) {
|
||||||
|
return { ok: true, needsMigration: true }
|
||||||
|
}
|
||||||
|
if (result.user) {
|
||||||
|
user.value = result.user
|
||||||
|
}
|
||||||
|
status.value = 'authenticated'
|
||||||
|
await fetchStatus()
|
||||||
|
return { ok: true }
|
||||||
|
} catch (error: unknown) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: getFetchErrorMessage(error, '登录失败,请检查用户名和密码')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function register(
|
||||||
|
username: string,
|
||||||
|
password: string
|
||||||
|
): Promise<AuthActionResult> {
|
||||||
|
const validationError = validateAuthInput(username, password)
|
||||||
|
if (validationError) {
|
||||||
|
return { ok: false, error: validationError }
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await $fetch<{ success: boolean, user: AuthUser }>(
|
||||||
|
'/api/auth/register',
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'include',
|
||||||
|
body: { username: username.trim(), password }
|
||||||
|
}
|
||||||
|
)
|
||||||
|
user.value = result.user
|
||||||
|
status.value = 'authenticated'
|
||||||
|
await fetchStatus()
|
||||||
|
return { ok: true }
|
||||||
|
} catch (error: unknown) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: getFetchErrorMessage(error, '注册失败,请检查输入')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setup(input: {
|
||||||
|
username: string
|
||||||
|
password: string
|
||||||
|
allowRegistration: boolean
|
||||||
|
}): Promise<AuthActionResult> {
|
||||||
|
const validationError = validateAuthInput(input.username, input.password)
|
||||||
|
if (validationError) {
|
||||||
|
return { ok: false, error: validationError }
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await $fetch<{ success: boolean, user: AuthUser }>(
|
||||||
|
'/api/setup',
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'include',
|
||||||
|
body: {
|
||||||
|
username: input.username.trim(),
|
||||||
|
password: input.password,
|
||||||
|
allowRegistration: input.allowRegistration
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
user.value = result.user
|
||||||
|
status.value = 'authenticated'
|
||||||
|
await fetchStatus()
|
||||||
|
return { ok: true }
|
||||||
|
} catch (error: unknown) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: getFetchErrorMessage(error, '初始化失败,请检查输入')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function migrate(input: {
|
||||||
|
username: string
|
||||||
|
password: string
|
||||||
|
allowRegistration: boolean
|
||||||
|
}): Promise<AuthActionResult> {
|
||||||
|
const validationError = validateAuthInput(input.username, input.password)
|
||||||
|
if (validationError) {
|
||||||
|
return { ok: false, error: validationError }
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await $fetch<{ success: boolean, user: AuthUser }>(
|
||||||
|
'/api/auth/migrate',
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'include',
|
||||||
|
body: {
|
||||||
|
username: input.username.trim(),
|
||||||
|
password: input.password,
|
||||||
|
allowRegistration: input.allowRegistration
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
user.value = result.user
|
||||||
|
status.value = 'authenticated'
|
||||||
|
await fetchStatus()
|
||||||
|
return { ok: true }
|
||||||
|
} catch (error: unknown) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: getFetchErrorMessage(error, '迁移失败,请检查输入')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function changePassword(
|
||||||
|
currentPassword: string,
|
||||||
|
newPassword: string
|
||||||
|
): Promise<AuthActionResult> {
|
||||||
|
if (newPassword.length < AUTH_MIN_PASSWORD_LENGTH) {
|
||||||
|
return { ok: false, error: '新密码至少 8 位' }
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await $fetch('/api/auth/password', {
|
||||||
|
method: 'PATCH',
|
||||||
|
credentials: 'include',
|
||||||
|
body: { currentPassword, newPassword }
|
||||||
|
})
|
||||||
|
return { ok: true }
|
||||||
|
} catch (error: unknown) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: getFetchErrorMessage(error, '修改密码失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function logout() {
|
||||||
|
try {
|
||||||
|
await $fetch('/api/auth/logout', { method: 'POST' })
|
||||||
|
} catch {
|
||||||
|
// 忽略登出失败
|
||||||
|
} finally {
|
||||||
|
status.value = 'unauthenticated'
|
||||||
|
user.value = null
|
||||||
|
authStatus.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleAuthError(error: unknown) {
|
||||||
|
if (isUnauthorizedError(error)) {
|
||||||
|
markUnauthenticated()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
status,
|
||||||
|
user,
|
||||||
|
authStatus,
|
||||||
|
isChecking,
|
||||||
|
isAuthenticated,
|
||||||
|
isAdmin,
|
||||||
|
checkSession,
|
||||||
|
fetchStatus,
|
||||||
|
markUnauthenticated,
|
||||||
|
login,
|
||||||
|
register,
|
||||||
|
setup,
|
||||||
|
migrate,
|
||||||
|
changePassword,
|
||||||
|
logout,
|
||||||
|
handleAuthError,
|
||||||
|
getFetchErrorMessage
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
import type { Ref } from 'vue'
|
||||||
|
|
||||||
|
const MAX_WIDTH = 2560
|
||||||
|
const QUALITY = 0.85
|
||||||
|
|
||||||
|
export interface CompressOptions {
|
||||||
|
enabled: boolean
|
||||||
|
maxWidth?: number
|
||||||
|
quality?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldCompress(file: File): boolean {
|
||||||
|
return file.type === 'image/jpeg' || file.type === 'image/png'
|
||||||
|
}
|
||||||
|
|
||||||
|
async function compressImage(
|
||||||
|
file: File,
|
||||||
|
maxWidth: number,
|
||||||
|
quality: number
|
||||||
|
): Promise<File> {
|
||||||
|
const bitmap = await createImageBitmap(file)
|
||||||
|
const scale = Math.min(1, maxWidth / bitmap.width)
|
||||||
|
const width = Math.round(bitmap.width * scale)
|
||||||
|
const height = Math.round(bitmap.height * scale)
|
||||||
|
|
||||||
|
const canvas = document.createElement('canvas')
|
||||||
|
canvas.width = width
|
||||||
|
canvas.height = height
|
||||||
|
|
||||||
|
const ctx = canvas.getContext('2d')
|
||||||
|
if (!ctx) {
|
||||||
|
bitmap.close()
|
||||||
|
return file
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.drawImage(bitmap, 0, 0, width, height)
|
||||||
|
bitmap.close()
|
||||||
|
|
||||||
|
const blob = await new Promise<Blob | null>((resolve) => {
|
||||||
|
canvas.toBlob(resolve, 'image/webp', quality)
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!blob) return file
|
||||||
|
|
||||||
|
const baseName = file.name.replace(/\.[^.]+$/, '') || 'image'
|
||||||
|
return new File([blob], `${baseName}.webp`, { type: 'image/webp' })
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function prepareFilesForUpload(
|
||||||
|
files: File[],
|
||||||
|
options: CompressOptions
|
||||||
|
): Promise<File[]> {
|
||||||
|
if (!options.enabled) return files
|
||||||
|
|
||||||
|
const maxWidth = options.maxWidth ?? MAX_WIDTH
|
||||||
|
const quality = options.quality ?? QUALITY
|
||||||
|
|
||||||
|
const prepared: File[] = []
|
||||||
|
|
||||||
|
for (const file of files) {
|
||||||
|
if (!shouldCompress(file)) {
|
||||||
|
prepared.push(file)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
prepared.push(await compressImage(file, maxWidth, quality))
|
||||||
|
} catch {
|
||||||
|
prepared.push(file)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return prepared
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useClipboardImage(_target?: Ref<HTMLElement | null>) {
|
||||||
|
function onPaste(event: ClipboardEvent, onFiles: (files: File[]) => void) {
|
||||||
|
const items = event.clipboardData?.items
|
||||||
|
if (!items?.length) return
|
||||||
|
|
||||||
|
const files: File[] = []
|
||||||
|
for (const item of items) {
|
||||||
|
if (item.kind === 'file' && item.type.startsWith('image/')) {
|
||||||
|
const file = item.getAsFile()
|
||||||
|
if (file) files.push(file)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (files.length) {
|
||||||
|
event.preventDefault()
|
||||||
|
onFiles(files)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function bindPaste(onFiles: (files: File[]) => void) {
|
||||||
|
const handler = (event: ClipboardEvent) => onPaste(event, onFiles)
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
window.addEventListener('paste', handler)
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
window.removeEventListener('paste', handler)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return { bindPaste, onPaste }
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
export function useFileSize() {
|
||||||
|
function formatFileSize(bytes: number): string {
|
||||||
|
if (!Number.isFinite(bytes) || bytes < 0) return '-'
|
||||||
|
if (bytes < 1024) return `${bytes} B`
|
||||||
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
|
||||||
|
return `${(bytes / (1024 * 1024)).toFixed(2)} MB`
|
||||||
|
}
|
||||||
|
|
||||||
|
return { formatFileSize }
|
||||||
|
}
|
||||||
@@ -0,0 +1,201 @@
|
|||||||
|
import type {
|
||||||
|
ImageItem,
|
||||||
|
ImageListResponse
|
||||||
|
} from '~/types/image'
|
||||||
|
|
||||||
|
const PAGE_SIZE = 12
|
||||||
|
|
||||||
|
export function useImageList() {
|
||||||
|
const items = ref<ImageItem[]>([])
|
||||||
|
const page = ref(1)
|
||||||
|
const totalPages = ref(1)
|
||||||
|
const total = ref(0)
|
||||||
|
const loading = ref(false)
|
||||||
|
const folderTotal = ref<number | null>(null)
|
||||||
|
const loadingTotal = ref(false)
|
||||||
|
const searchQuery = ref('')
|
||||||
|
const activeSearch = ref('')
|
||||||
|
const activeFolder = ref('all')
|
||||||
|
|
||||||
|
function folderQueryParams(): Record<string, string> {
|
||||||
|
const folder = activeFolder.value.trim()
|
||||||
|
if (!folder || folder === 'all') return {}
|
||||||
|
return { folder }
|
||||||
|
}
|
||||||
|
|
||||||
|
function matchesActiveFolder(key: string) {
|
||||||
|
const folder = activeFolder.value.trim()
|
||||||
|
if (!folder || folder === 'all') return true
|
||||||
|
return key.startsWith(`${folder}/`)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchTotal() {
|
||||||
|
loadingTotal.value = true
|
||||||
|
try {
|
||||||
|
const data = await $fetch<{ total: number }>('/api/images/count', {
|
||||||
|
credentials: 'include',
|
||||||
|
query: folderQueryParams()
|
||||||
|
})
|
||||||
|
folderTotal.value = data.total
|
||||||
|
} catch {
|
||||||
|
folderTotal.value = null
|
||||||
|
} finally {
|
||||||
|
loadingTotal.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchList(targetPage = 1) {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const response = await $fetch<ImageListResponse>('/api/images', {
|
||||||
|
credentials: 'include',
|
||||||
|
query: {
|
||||||
|
limit: PAGE_SIZE,
|
||||||
|
page: targetPage,
|
||||||
|
...folderQueryParams()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
items.value = response.items
|
||||||
|
page.value = response.page
|
||||||
|
totalPages.value = response.totalPages
|
||||||
|
total.value = response.total
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchSearch(targetPage = 1) {
|
||||||
|
const q = activeSearch.value.trim()
|
||||||
|
if (!q) {
|
||||||
|
await fetchList(1)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const response = await $fetch<ImageListResponse>('/api/images/search', {
|
||||||
|
credentials: 'include',
|
||||||
|
query: {
|
||||||
|
q,
|
||||||
|
limit: PAGE_SIZE,
|
||||||
|
page: targetPage,
|
||||||
|
...folderQueryParams()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
items.value = response.items
|
||||||
|
page.value = response.page
|
||||||
|
totalPages.value = response.totalPages
|
||||||
|
total.value = response.total
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshList() {
|
||||||
|
activeSearch.value = ''
|
||||||
|
searchQuery.value = ''
|
||||||
|
page.value = 1
|
||||||
|
await Promise.all([fetchList(1), fetchTotal()])
|
||||||
|
}
|
||||||
|
|
||||||
|
async function initializeList(folder = 'all') {
|
||||||
|
const next = folder.trim() || 'all'
|
||||||
|
activeFolder.value = next
|
||||||
|
page.value = 1
|
||||||
|
activeSearch.value = ''
|
||||||
|
searchQuery.value = ''
|
||||||
|
await Promise.all([fetchList(1), fetchTotal()])
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setActiveFolder(folder: string) {
|
||||||
|
const next = folder.trim() || 'all'
|
||||||
|
if (next === activeFolder.value) return
|
||||||
|
activeFolder.value = next
|
||||||
|
page.value = 1
|
||||||
|
if (activeSearch.value) {
|
||||||
|
await Promise.all([fetchSearch(1), fetchTotal()])
|
||||||
|
} else {
|
||||||
|
await Promise.all([fetchList(1), fetchTotal()])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitSearch() {
|
||||||
|
const q = searchQuery.value.trim()
|
||||||
|
activeSearch.value = q
|
||||||
|
page.value = 1
|
||||||
|
if (!q) {
|
||||||
|
await refreshList()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await fetchSearch(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function goToPage(targetPage: number) {
|
||||||
|
if (targetPage < 1 || targetPage > totalPages.value || loading.value) return
|
||||||
|
if (activeSearch.value) {
|
||||||
|
await fetchSearch(targetPage)
|
||||||
|
} else {
|
||||||
|
await fetchList(targetPage)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function prependItems(newItems: ImageItem[]) {
|
||||||
|
const matched = newItems.filter(item => matchesActiveFolder(item.key))
|
||||||
|
if (!matched.length || page.value !== 1) return
|
||||||
|
items.value = [...matched, ...items.value]
|
||||||
|
total.value += matched.length
|
||||||
|
if (folderTotal.value !== null) {
|
||||||
|
folderTotal.value += matched.length
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeItems(keys: string[]) {
|
||||||
|
const keySet = new Set(keys)
|
||||||
|
const removed = items.value.filter(item => keySet.has(item.key)).length
|
||||||
|
items.value = items.value.filter(item => !keySet.has(item.key))
|
||||||
|
if (removed > 0) {
|
||||||
|
total.value = Math.max(0, total.value - removed)
|
||||||
|
}
|
||||||
|
if (folderTotal.value !== null && removed > 0) {
|
||||||
|
folderTotal.value = Math.max(0, folderTotal.value - removed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const listSummary = computed(() => {
|
||||||
|
const folder = activeFolder.value.trim() || 'all'
|
||||||
|
const folderLabel = folder === 'all' ? '全部' : folder
|
||||||
|
if (loadingTotal.value && folderTotal.value === null) {
|
||||||
|
return `${folderLabel} · 统计中…`
|
||||||
|
}
|
||||||
|
if (activeSearch.value) {
|
||||||
|
return `${folderLabel} · 搜索「${activeSearch.value}」· 共 ${total.value} 张`
|
||||||
|
}
|
||||||
|
if (folderTotal.value === null) {
|
||||||
|
return `${folderLabel} · 共 ${total.value} 张`
|
||||||
|
}
|
||||||
|
return `${folderLabel} · 共 ${folderTotal.value} 张`
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
items,
|
||||||
|
page,
|
||||||
|
totalPages,
|
||||||
|
total,
|
||||||
|
loading,
|
||||||
|
activeFolder,
|
||||||
|
searchQuery,
|
||||||
|
activeSearch,
|
||||||
|
listSummary,
|
||||||
|
fetchList,
|
||||||
|
fetchTotal,
|
||||||
|
refreshList,
|
||||||
|
initializeList,
|
||||||
|
setActiveFolder,
|
||||||
|
submitSearch,
|
||||||
|
goToPage,
|
||||||
|
prependItems,
|
||||||
|
removeItems
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
import type {
|
||||||
|
UploadProgressItem,
|
||||||
|
UploadResponse
|
||||||
|
} from '~/types/image'
|
||||||
|
import { isUnauthorizedError } from './useAuth'
|
||||||
|
import { prepareFilesForUpload } from './useClipboardImage'
|
||||||
|
|
||||||
|
const MAX_FILES = 10
|
||||||
|
|
||||||
|
export function useImageUpload() {
|
||||||
|
const toast = useToast()
|
||||||
|
const { compressEnabled, clientWebpQuality } = useUploadPreferences()
|
||||||
|
const uploading = ref(false)
|
||||||
|
const progressItems = ref<UploadProgressItem[]>([])
|
||||||
|
const lastUploadResult = ref<UploadResponse | null>(null)
|
||||||
|
|
||||||
|
async function uploadFiles(
|
||||||
|
files: File[],
|
||||||
|
folder = 'images',
|
||||||
|
options?: { notify?: boolean, includeFolder?: boolean }
|
||||||
|
) {
|
||||||
|
if (uploading.value) return
|
||||||
|
|
||||||
|
if (!files.length) {
|
||||||
|
toast.add({ title: '请选择图片', color: 'warning' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (files.length > MAX_FILES) {
|
||||||
|
toast.add({
|
||||||
|
title: `每次最多上传 ${MAX_FILES} 张图片`,
|
||||||
|
color: 'warning'
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
uploading.value = true
|
||||||
|
progressItems.value = files.map(file => ({
|
||||||
|
name: file.name,
|
||||||
|
status: 'pending',
|
||||||
|
progress: 0
|
||||||
|
}))
|
||||||
|
|
||||||
|
try {
|
||||||
|
const prepared = await prepareFilesForUpload(files, {
|
||||||
|
enabled: compressEnabled.value,
|
||||||
|
quality: clientWebpQuality.value / 100
|
||||||
|
})
|
||||||
|
|
||||||
|
progressItems.value = prepared.map((file, index) => ({
|
||||||
|
name: file.name,
|
||||||
|
status: 'uploading',
|
||||||
|
progress: Math.round(((index + 1) / prepared.length) * 50)
|
||||||
|
}))
|
||||||
|
|
||||||
|
const formData = new FormData()
|
||||||
|
if (options?.includeFolder !== false) {
|
||||||
|
formData.append('folder', folder.trim() || 'images')
|
||||||
|
}
|
||||||
|
for (const file of prepared) {
|
||||||
|
formData.append('files', file, file.name)
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await $fetch<UploadResponse>('/api/images/upload', {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData
|
||||||
|
})
|
||||||
|
|
||||||
|
lastUploadResult.value = response
|
||||||
|
|
||||||
|
progressItems.value = prepared.map((file) => {
|
||||||
|
const failed = response.errors.find(error => error.name === file.name)
|
||||||
|
return {
|
||||||
|
name: file.name,
|
||||||
|
status: failed ? 'error' : 'success',
|
||||||
|
progress: 100,
|
||||||
|
error: failed?.error.message
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (response.items.length && options?.notify !== false) {
|
||||||
|
toast.add({
|
||||||
|
title: `成功上传 ${response.items.length} 张图片`,
|
||||||
|
color: 'success'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.errors.length) {
|
||||||
|
toast.add({
|
||||||
|
title: `${response.errors.length} 张图片上传失败`,
|
||||||
|
color: 'warning'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return response
|
||||||
|
} catch (error: unknown) {
|
||||||
|
if (isUnauthorizedError(error)) {
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
const message = error instanceof Error ? error.message : '上传失败'
|
||||||
|
progressItems.value = progressItems.value.map(item => ({
|
||||||
|
...item,
|
||||||
|
status: 'error',
|
||||||
|
progress: 100,
|
||||||
|
error: message
|
||||||
|
}))
|
||||||
|
toast.add({ title: message, color: 'error' })
|
||||||
|
throw error
|
||||||
|
} finally {
|
||||||
|
uploading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
uploading,
|
||||||
|
progressItems,
|
||||||
|
lastUploadResult,
|
||||||
|
uploadFiles
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
const STORAGE_KEY = 'pichost.upload-preferences'
|
||||||
|
|
||||||
|
export type CopyFormat = 'url' | 'markdown' | 'html'
|
||||||
|
|
||||||
|
export interface UploadPreferences {
|
||||||
|
defaultFolder: string
|
||||||
|
compressEnabled: boolean
|
||||||
|
autoCopyMarkdown: boolean
|
||||||
|
copyFormat: CopyFormat
|
||||||
|
clientWebpQuality: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULTS: UploadPreferences = {
|
||||||
|
defaultFolder: 'images',
|
||||||
|
compressEnabled: false,
|
||||||
|
autoCopyMarkdown: false,
|
||||||
|
copyFormat: 'markdown',
|
||||||
|
clientWebpQuality: 80
|
||||||
|
}
|
||||||
|
|
||||||
|
function clampQuality(value: unknown): number {
|
||||||
|
const num = Number(value)
|
||||||
|
if (!Number.isFinite(num)) return DEFAULTS.clientWebpQuality
|
||||||
|
return Math.min(100, Math.max(1, Math.round(num)))
|
||||||
|
}
|
||||||
|
|
||||||
|
function isCopyFormat(value: unknown): value is CopyFormat {
|
||||||
|
return value === 'url' || value === 'markdown' || value === 'html'
|
||||||
|
}
|
||||||
|
|
||||||
|
function readStored(): UploadPreferences {
|
||||||
|
if (import.meta.server) return { ...DEFAULTS }
|
||||||
|
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(STORAGE_KEY)
|
||||||
|
if (!raw) return { ...DEFAULTS }
|
||||||
|
const parsed = JSON.parse(raw) as Partial<UploadPreferences>
|
||||||
|
return {
|
||||||
|
defaultFolder: typeof parsed.defaultFolder === 'string' && parsed.defaultFolder.trim()
|
||||||
|
? parsed.defaultFolder.trim()
|
||||||
|
: DEFAULTS.defaultFolder,
|
||||||
|
compressEnabled: parsed.compressEnabled === true,
|
||||||
|
autoCopyMarkdown: parsed.autoCopyMarkdown === true,
|
||||||
|
copyFormat: isCopyFormat(parsed.copyFormat) ? parsed.copyFormat : DEFAULTS.copyFormat,
|
||||||
|
clientWebpQuality: clampQuality(parsed.clientWebpQuality)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return { ...DEFAULTS }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeStored(prefs: UploadPreferences) {
|
||||||
|
if (import.meta.server) return
|
||||||
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(prefs))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUploadPreferences() {
|
||||||
|
const defaultFolder = useState('upload-pref-folder', () => DEFAULTS.defaultFolder)
|
||||||
|
const compressEnabled = useState('upload-pref-compress', () => DEFAULTS.compressEnabled)
|
||||||
|
const autoCopyMarkdown = useState('upload-pref-auto-md', () => DEFAULTS.autoCopyMarkdown)
|
||||||
|
const copyFormat = useState<CopyFormat>('upload-pref-copy-format', () => DEFAULTS.copyFormat)
|
||||||
|
const clientWebpQuality = useState('upload-pref-webp-quality', () => DEFAULTS.clientWebpQuality)
|
||||||
|
const loaded = useState('upload-pref-loaded', () => false)
|
||||||
|
|
||||||
|
function loadPreferences() {
|
||||||
|
const stored = readStored()
|
||||||
|
defaultFolder.value = stored.defaultFolder
|
||||||
|
compressEnabled.value = stored.compressEnabled
|
||||||
|
autoCopyMarkdown.value = stored.autoCopyMarkdown
|
||||||
|
copyFormat.value = stored.copyFormat
|
||||||
|
clientWebpQuality.value = stored.clientWebpQuality
|
||||||
|
loaded.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function savePreferences() {
|
||||||
|
writeStored({
|
||||||
|
defaultFolder: defaultFolder.value.trim() || DEFAULTS.defaultFolder,
|
||||||
|
compressEnabled: compressEnabled.value,
|
||||||
|
autoCopyMarkdown: autoCopyMarkdown.value,
|
||||||
|
copyFormat: copyFormat.value,
|
||||||
|
clientWebpQuality: clampQuality(clientWebpQuality.value)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
watch([defaultFolder, compressEnabled, autoCopyMarkdown, copyFormat, clientWebpQuality], () => {
|
||||||
|
if (loaded.value) {
|
||||||
|
savePreferences()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
defaultFolder,
|
||||||
|
compressEnabled,
|
||||||
|
autoCopyMarkdown,
|
||||||
|
copyFormat,
|
||||||
|
clientWebpQuality,
|
||||||
|
loaded,
|
||||||
|
loadPreferences,
|
||||||
|
savePreferences
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
export function useUserAutoDeleteSettings() {
|
||||||
|
const { isAuthenticated, handleAuthError } = useAuth()
|
||||||
|
const toast = useToast()
|
||||||
|
|
||||||
|
const userSettings = ref<{ autoDeleteDays: number } | null>(null)
|
||||||
|
const enabled = ref(false)
|
||||||
|
const daysDraft = ref(30)
|
||||||
|
const hydrated = ref(false)
|
||||||
|
const saving = ref(false)
|
||||||
|
|
||||||
|
let daysSaveTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
|
|
||||||
|
function apply(data: { autoDeleteDays: number }) {
|
||||||
|
hydrated.value = false
|
||||||
|
userSettings.value = data
|
||||||
|
enabled.value = data.autoDeleteDays > 0
|
||||||
|
daysDraft.value = data.autoDeleteDays > 0 ? data.autoDeleteDays : 30
|
||||||
|
nextTick(() => {
|
||||||
|
hydrated.value = true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
try {
|
||||||
|
const data = await $fetch<{ autoDeleteDays: number }>('/api/user/settings', {
|
||||||
|
credentials: 'include'
|
||||||
|
})
|
||||||
|
apply(data)
|
||||||
|
} catch (error: unknown) {
|
||||||
|
handleAuthError(error)
|
||||||
|
if (isAuthenticated.value) {
|
||||||
|
toast.add({ title: '加载设置失败', color: 'error' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
if (!isAuthenticated.value || !userSettings.value) return
|
||||||
|
|
||||||
|
const days = enabled.value ? daysDraft.value : 0
|
||||||
|
if (enabled.value && (days < 1 || days > 3650)) return
|
||||||
|
|
||||||
|
saving.value = true
|
||||||
|
try {
|
||||||
|
const data = await $fetch<{ autoDeleteDays: number }>('/api/user/settings', {
|
||||||
|
method: 'PATCH',
|
||||||
|
credentials: 'include',
|
||||||
|
body: { autoDeleteDays: days }
|
||||||
|
})
|
||||||
|
apply(data)
|
||||||
|
} catch (error: unknown) {
|
||||||
|
handleAuthError(error)
|
||||||
|
if (isAuthenticated.value) {
|
||||||
|
toast.add({ title: '保存设置失败', color: 'error' })
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
saving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(enabled, () => {
|
||||||
|
if (!hydrated.value) return
|
||||||
|
void save()
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(daysDraft, () => {
|
||||||
|
if (!hydrated.value || !enabled.value) return
|
||||||
|
if (daysSaveTimer) clearTimeout(daysSaveTimer)
|
||||||
|
daysSaveTimer = setTimeout(() => {
|
||||||
|
void save()
|
||||||
|
}, 500)
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
userSettings,
|
||||||
|
enabled,
|
||||||
|
daysDraft,
|
||||||
|
saving,
|
||||||
|
load
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { isPasswordValid } from '~/utils/password-strength'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const {
|
||||||
|
isChecking,
|
||||||
|
isAuthenticated,
|
||||||
|
checkSession,
|
||||||
|
fetchStatus,
|
||||||
|
changePassword
|
||||||
|
} = useAuth()
|
||||||
|
const toast = useToast()
|
||||||
|
|
||||||
|
const currentPassword = ref('')
|
||||||
|
const newPassword = ref('')
|
||||||
|
const confirmPassword = ref('')
|
||||||
|
const loading = ref(false)
|
||||||
|
|
||||||
|
const canSubmit = computed(() =>
|
||||||
|
Boolean(currentPassword.value && newPassword.value && confirmPassword.value)
|
||||||
|
&& newPassword.value === confirmPassword.value
|
||||||
|
&& isPasswordValid(newPassword.value)
|
||||||
|
)
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
const status = await fetchStatus()
|
||||||
|
if (!status.initialized && !status.legacyMode) {
|
||||||
|
await router.replace('/setup')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!isAuthenticated.value) {
|
||||||
|
await checkSession()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
async function submit() {
|
||||||
|
if (!canSubmit.value || loading.value) return
|
||||||
|
|
||||||
|
if (newPassword.value !== confirmPassword.value) {
|
||||||
|
toast.add({ title: '两次输入的新密码不一致', color: 'error' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const result = await changePassword(currentPassword.value, newPassword.value)
|
||||||
|
if (result.ok) {
|
||||||
|
currentPassword.value = ''
|
||||||
|
newPassword.value = ''
|
||||||
|
confirmPassword.value = ''
|
||||||
|
toast.add({ title: '密码已修改', color: 'success' })
|
||||||
|
await router.replace('/')
|
||||||
|
} else {
|
||||||
|
toast.add({ title: result.error, color: 'error' })
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</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>
|
||||||
|
<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">
|
||||||
|
<h1 class="text-lg font-semibold tracking-tight">
|
||||||
|
修改密码
|
||||||
|
</h1>
|
||||||
|
<p class="mt-1 text-sm text-muted">
|
||||||
|
修改您的登录密码
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form
|
||||||
|
class="space-y-5 p-5 sm:p-6"
|
||||||
|
@submit.prevent="submit"
|
||||||
|
>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<label
|
||||||
|
for="current-password"
|
||||||
|
class="block text-sm font-medium"
|
||||||
|
>
|
||||||
|
当前密码
|
||||||
|
</label>
|
||||||
|
<UInput
|
||||||
|
id="current-password"
|
||||||
|
v-model="currentPassword"
|
||||||
|
type="password"
|
||||||
|
autocomplete="current-password"
|
||||||
|
size="lg"
|
||||||
|
class="w-full"
|
||||||
|
:ui="{ root: 'w-full' }"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-2">
|
||||||
|
<label
|
||||||
|
for="new-password"
|
||||||
|
class="block text-sm font-medium"
|
||||||
|
>
|
||||||
|
新密码
|
||||||
|
</label>
|
||||||
|
<UInput
|
||||||
|
id="new-password"
|
||||||
|
v-model="newPassword"
|
||||||
|
type="password"
|
||||||
|
placeholder="设置新密码"
|
||||||
|
autocomplete="new-password"
|
||||||
|
size="lg"
|
||||||
|
class="w-full"
|
||||||
|
:ui="{ root: 'w-full' }"
|
||||||
|
/>
|
||||||
|
<PasswordStrength :password="newPassword" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-2">
|
||||||
|
<label
|
||||||
|
for="confirm-new-password"
|
||||||
|
class="block text-sm font-medium"
|
||||||
|
>
|
||||||
|
确认新密码
|
||||||
|
</label>
|
||||||
|
<UInput
|
||||||
|
id="confirm-new-password"
|
||||||
|
v-model="confirmPassword"
|
||||||
|
type="password"
|
||||||
|
autocomplete="new-password"
|
||||||
|
size="lg"
|
||||||
|
class="w-full"
|
||||||
|
:ui="{ root: 'w-full' }"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex gap-2 pt-1">
|
||||||
|
<UButton
|
||||||
|
type="submit"
|
||||||
|
label="保存"
|
||||||
|
icon="i-lucide-save"
|
||||||
|
:loading="loading"
|
||||||
|
:disabled="!canSubmit"
|
||||||
|
/>
|
||||||
|
<UButton
|
||||||
|
label="取消"
|
||||||
|
variant="ghost"
|
||||||
|
color="neutral"
|
||||||
|
to="/"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
</AppShell>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,518 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
interface SettingsResponse {
|
||||||
|
apiUploadToken: string
|
||||||
|
tokenSource: 'env' | 'db' | 'none'
|
||||||
|
envTokenOverride: boolean
|
||||||
|
env: {
|
||||||
|
webpQuality: number
|
||||||
|
refererConfigured: boolean
|
||||||
|
imageBaseUrl: string
|
||||||
|
appVersion: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ApiDocItem {
|
||||||
|
index: number
|
||||||
|
method: 'GET' | 'POST' | 'DELETE'
|
||||||
|
path: string
|
||||||
|
title: string
|
||||||
|
description: string
|
||||||
|
curl: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const { isChecking, isAuthenticated, checkSession, handleAuthError, fetchStatus, isAdmin } = useAuth()
|
||||||
|
const toast = useToast()
|
||||||
|
|
||||||
|
const settings = ref<SettingsResponse | null>(null)
|
||||||
|
const loading = ref(false)
|
||||||
|
const regenerating = ref(false)
|
||||||
|
const confirmRegenerateOpen = ref(false)
|
||||||
|
const regenerateResultOpen = ref(false)
|
||||||
|
const newToken = ref('')
|
||||||
|
|
||||||
|
const baseUrl = computed(() => {
|
||||||
|
if (import.meta.client) {
|
||||||
|
return window.location.origin
|
||||||
|
}
|
||||||
|
return settings.value?.env.imageBaseUrl ?? ''
|
||||||
|
})
|
||||||
|
|
||||||
|
const tokenDisplay = computed(() => settings.value?.apiUploadToken ?? '')
|
||||||
|
const hasToken = computed(() => tokenDisplay.value.length > 0)
|
||||||
|
const canRegenerate = computed(() => !settings.value?.envTokenOverride && !loading.value)
|
||||||
|
|
||||||
|
const authHeader = computed(() =>
|
||||||
|
hasToken.value ? tokenDisplay.value : 'YOUR-TOKEN'
|
||||||
|
)
|
||||||
|
|
||||||
|
const authHeaderFlag = computed(() => `-H 'Auth-Token: ${authHeader.value}'`)
|
||||||
|
|
||||||
|
const apiDocs = computed<ApiDocItem[]>(() => {
|
||||||
|
const uploadDescription = isAdmin.value
|
||||||
|
? '上传单张或多张图片。字段 image 传图片,folder 指定目录(不存在则自动创建);仍兼容 file、files、type。'
|
||||||
|
: '上传单张或多张图片。字段 image 传图片;仍兼容 file、files。图片固定存入 images/ 目录。'
|
||||||
|
const uploadCurl = isAdmin.value
|
||||||
|
? `curl -X POST "${baseUrl.value}/api/images/upload" \\
|
||||||
|
${authHeaderFlag.value} \\
|
||||||
|
-F "image=@./demo.png"
|
||||||
|
|
||||||
|
# 上传到自定义目录 blog/
|
||||||
|
curl -X POST "${baseUrl.value}/api/images/upload" \\
|
||||||
|
${authHeaderFlag.value} \\
|
||||||
|
-F "folder=blog" \\
|
||||||
|
-F "image=@./demo.png"`
|
||||||
|
: `curl -X POST "${baseUrl.value}/api/images/upload" \\
|
||||||
|
${authHeaderFlag.value} \\
|
||||||
|
-F "image=@./demo.png"`
|
||||||
|
|
||||||
|
const listDescription = isAdmin.value
|
||||||
|
? '分页列出图库,folder 可筛选目录,limit 默认 20、最大 100。'
|
||||||
|
: '分页列出您的图库,limit 默认 20、最大 100。'
|
||||||
|
const listCurl = isAdmin.value
|
||||||
|
? `curl "${baseUrl.value}/api/images?limit=20&page=1" \\
|
||||||
|
${authHeaderFlag.value}
|
||||||
|
|
||||||
|
# 按目录筛选
|
||||||
|
curl "${baseUrl.value}/api/images?folder=blog&limit=20&page=1" \\
|
||||||
|
${authHeaderFlag.value}`
|
||||||
|
: `curl "${baseUrl.value}/api/images?limit=20&page=1" \\
|
||||||
|
${authHeaderFlag.value}`
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
index: 1,
|
||||||
|
method: 'POST',
|
||||||
|
path: '/api/images/upload',
|
||||||
|
title: '上传图片',
|
||||||
|
description: uploadDescription,
|
||||||
|
curl: uploadCurl
|
||||||
|
},
|
||||||
|
{
|
||||||
|
index: 2,
|
||||||
|
method: 'GET',
|
||||||
|
path: '/api/images',
|
||||||
|
title: '获取图片列表',
|
||||||
|
description: listDescription,
|
||||||
|
curl: listCurl
|
||||||
|
},
|
||||||
|
{
|
||||||
|
index: 3,
|
||||||
|
method: 'GET',
|
||||||
|
path: '/api/images/search',
|
||||||
|
title: '搜索图片',
|
||||||
|
description: '按文件名或路径关键词搜索,参数 q 必填。',
|
||||||
|
curl: `curl "${baseUrl.value}/api/images/search?q=demo&limit=20&page=1" \\
|
||||||
|
${authHeaderFlag.value}`
|
||||||
|
},
|
||||||
|
{
|
||||||
|
index: 4,
|
||||||
|
method: 'DELETE',
|
||||||
|
path: '/api/images',
|
||||||
|
title: '删除图片',
|
||||||
|
description: '通过 key 删除单张图片,key 为存储路径(如 images/2026/08/xxx.webp)。',
|
||||||
|
curl: `curl -X DELETE "${baseUrl.value}/api/images?key=images/2026/08/xxxx.webp" \\
|
||||||
|
${authHeaderFlag.value}`
|
||||||
|
},
|
||||||
|
{
|
||||||
|
index: 5,
|
||||||
|
method: 'POST',
|
||||||
|
path: '/api/images/batch-delete',
|
||||||
|
title: '批量删除',
|
||||||
|
description: '一次删除多张图片,请求体为 keys 数组。',
|
||||||
|
curl: `curl -X POST "${baseUrl.value}/api/images/batch-delete" \\
|
||||||
|
${authHeaderFlag.value} \\
|
||||||
|
-H "Content-Type: application/json" \\
|
||||||
|
-d '{"keys":["images/2026/08/a.webp","images/2026/08/b.webp"]}'`
|
||||||
|
}
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
const twikooDoc = computed(() => ({
|
||||||
|
method: 'POST' as const,
|
||||||
|
path: '/api/index.php',
|
||||||
|
title: 'twikoo 兼容上传',
|
||||||
|
description: '兼容 Twikoo / EasyImage 2.0 协议,图片存入 twikoo/ 目录;字段 image + token。',
|
||||||
|
curl: `curl -X POST "${baseUrl.value}/api/index.php" \\
|
||||||
|
-F "token=${authHeader.value}" \\
|
||||||
|
-F "image=@./demo.png"`
|
||||||
|
}))
|
||||||
|
|
||||||
|
function tokenSourceLabel(source: SettingsResponse['tokenSource']) {
|
||||||
|
switch (source) {
|
||||||
|
case 'env':
|
||||||
|
return '来源:环境变量 API_UPLOAD_TOKEN'
|
||||||
|
case 'db':
|
||||||
|
return '来源:后台生成(可重新生成)'
|
||||||
|
default:
|
||||||
|
return '来源:未配置'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function methodBadgeColor(method: ApiDocItem['method']) {
|
||||||
|
switch (method) {
|
||||||
|
case 'GET':
|
||||||
|
return 'info' as const
|
||||||
|
case 'DELETE':
|
||||||
|
return 'error' as const
|
||||||
|
default:
|
||||||
|
return 'primary' as const
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadSettings() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const endpoint = isAdmin.value ? '/api/settings' : '/api/user/api-token'
|
||||||
|
settings.value = await $fetch<SettingsResponse>(endpoint, {
|
||||||
|
credentials: 'include'
|
||||||
|
})
|
||||||
|
} catch (error: unknown) {
|
||||||
|
handleAuthError(error)
|
||||||
|
if (isAuthenticated.value) {
|
||||||
|
toast.add({ title: '加载 API 设置失败', color: 'error' })
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function requestRegenerate() {
|
||||||
|
if (!canRegenerate.value) return
|
||||||
|
confirmRegenerateOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmRegenerate() {
|
||||||
|
confirmRegenerateOpen.value = false
|
||||||
|
regenerating.value = true
|
||||||
|
try {
|
||||||
|
const endpoint = isAdmin.value
|
||||||
|
? '/api/settings/api-token/regenerate'
|
||||||
|
: '/api/user/api-token/regenerate'
|
||||||
|
const response = await $fetch<{ apiUploadToken: string }>(
|
||||||
|
endpoint,
|
||||||
|
{ method: 'POST', credentials: 'include' }
|
||||||
|
)
|
||||||
|
newToken.value = response.apiUploadToken
|
||||||
|
regenerateResultOpen.value = true
|
||||||
|
await loadSettings()
|
||||||
|
} catch (error: unknown) {
|
||||||
|
handleAuthError(error)
|
||||||
|
if (isAuthenticated.value) {
|
||||||
|
const status = typeof error === 'object' && error !== null && 'statusCode' in error
|
||||||
|
? (error as { statusCode: number }).statusCode
|
||||||
|
: 0
|
||||||
|
if (status === 409) {
|
||||||
|
toast.add({
|
||||||
|
title: '环境变量已配置 Token,无法在后台重新生成',
|
||||||
|
color: 'warning'
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
toast.add({ title: '重新生成 Token 失败', color: 'error' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
regenerating.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function copyCurl(text: string) {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(text)
|
||||||
|
toast.add({ title: '已复制 cURL 示例', color: 'success' })
|
||||||
|
} catch {
|
||||||
|
toast.add({ title: '复制失败', color: 'error' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeConfirmRegenerate() {
|
||||||
|
confirmRegenerateOpen.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeRegenerateResult() {
|
||||||
|
regenerateResultOpen.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
const status = await fetchStatus()
|
||||||
|
if (!status.initialized && !status.legacyMode) {
|
||||||
|
await navigateTo('/setup')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await checkSession()
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(isAuthenticated, async (authed) => {
|
||||||
|
if (authed) {
|
||||||
|
await nextTick()
|
||||||
|
await loadSettings()
|
||||||
|
} 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>
|
||||||
|
<section class="overflow-hidden rounded-2xl border border-default bg-elevated shadow-sm">
|
||||||
|
<div class="flex items-center gap-2 border-b border-default px-5 py-4 sm:px-6">
|
||||||
|
<UIcon
|
||||||
|
name="i-lucide-key-round"
|
||||||
|
class="size-5 text-primary"
|
||||||
|
/>
|
||||||
|
<h2 class="text-base font-semibold">
|
||||||
|
API Token
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-4 p-5 sm:p-6">
|
||||||
|
<UAlert
|
||||||
|
v-if="settings?.envTokenOverride"
|
||||||
|
color="warning"
|
||||||
|
variant="subtle"
|
||||||
|
icon="i-lucide-triangle-alert"
|
||||||
|
title="Token 由环境变量覆盖"
|
||||||
|
description="修改 API_UPLOAD_TOKEN 后需重启服务;后台无法重新生成。"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<UAlert
|
||||||
|
v-else-if="!isAdmin"
|
||||||
|
color="info"
|
||||||
|
variant="subtle"
|
||||||
|
icon="i-lucide-info"
|
||||||
|
title="个人 API Token"
|
||||||
|
description="此 Token 仅用于您自己的脚本上传,上传的图片归属您的账号。"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<UAlert
|
||||||
|
v-else-if="settings && !hasToken"
|
||||||
|
color="info"
|
||||||
|
variant="subtle"
|
||||||
|
icon="i-lucide-info"
|
||||||
|
title="尚未配置 Token"
|
||||||
|
description="点击「重新生成」创建 Token,用于下方所有接口的 Auth-Token 鉴权。"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-2 sm:flex-row sm:items-stretch">
|
||||||
|
<UInput
|
||||||
|
:model-value="hasToken ? tokenDisplay : '(未配置)'"
|
||||||
|
readonly
|
||||||
|
class="min-w-0 flex-1 font-mono text-xs sm:text-sm"
|
||||||
|
:loading="loading"
|
||||||
|
/>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<CopyButton
|
||||||
|
label="复制"
|
||||||
|
:value="tokenDisplay"
|
||||||
|
/>
|
||||||
|
<UButton
|
||||||
|
icon="i-lucide-refresh-cw"
|
||||||
|
label="重新生成"
|
||||||
|
color="warning"
|
||||||
|
variant="soft"
|
||||||
|
:loading="regenerating"
|
||||||
|
:disabled="!canRegenerate"
|
||||||
|
@click="requestRegenerate"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p
|
||||||
|
v-if="settings"
|
||||||
|
class="text-xs text-muted"
|
||||||
|
>
|
||||||
|
{{ tokenSourceLabel(settings.tokenSource) }}
|
||||||
|
· 请求头:
|
||||||
|
<code class="font-mono">Auth-Token: <token></code>
|
||||||
|
<template v-if="hasToken && !settings.envTokenOverride">
|
||||||
|
· 重新生成后旧 Token 立即失效
|
||||||
|
</template>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="overflow-hidden rounded-2xl border border-default bg-elevated shadow-sm">
|
||||||
|
<div class="flex items-center gap-2 border-b border-default px-5 py-4 sm:px-6">
|
||||||
|
<UIcon
|
||||||
|
name="i-lucide-book-open"
|
||||||
|
class="size-5 text-primary"
|
||||||
|
/>
|
||||||
|
<h2 class="text-base font-semibold">
|
||||||
|
API 文档
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="divide-y divide-default">
|
||||||
|
<article
|
||||||
|
v-for="doc in apiDocs"
|
||||||
|
:key="doc.index"
|
||||||
|
class="space-y-4 p-5 sm:p-6"
|
||||||
|
>
|
||||||
|
<div class="flex gap-3">
|
||||||
|
<span
|
||||||
|
class="flex size-7 shrink-0 items-center justify-center rounded-full bg-primary/10 text-sm font-semibold text-primary"
|
||||||
|
>
|
||||||
|
{{ doc.index }}
|
||||||
|
</span>
|
||||||
|
<div class="min-w-0 space-y-2">
|
||||||
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
|
<UBadge
|
||||||
|
:color="methodBadgeColor(doc.method)"
|
||||||
|
variant="subtle"
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
{{ doc.method }}
|
||||||
|
</UBadge>
|
||||||
|
<code class="rounded-md border border-primary/20 bg-primary/10 px-2 py-0.5 font-mono text-sm font-medium text-primary">
|
||||||
|
{{ doc.path }}
|
||||||
|
</code>
|
||||||
|
<span class="text-sm font-medium">
|
||||||
|
{{ doc.title }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p class="text-sm leading-relaxed text-muted">
|
||||||
|
{{ doc.description }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<p class="mb-2 text-xs font-medium text-muted">
|
||||||
|
cURL 示例
|
||||||
|
</p>
|
||||||
|
<div class="relative overflow-hidden rounded-xl bg-neutral-950">
|
||||||
|
<UButton
|
||||||
|
icon="i-lucide-copy"
|
||||||
|
label="复制"
|
||||||
|
size="xs"
|
||||||
|
variant="ghost"
|
||||||
|
color="neutral"
|
||||||
|
class="absolute top-2 right-2 z-10 text-neutral-300 hover:text-white"
|
||||||
|
@click="copyCurl(doc.curl)"
|
||||||
|
/>
|
||||||
|
<pre class="max-h-80 overflow-auto p-4 pt-10 text-xs leading-relaxed text-neutral-100"><code class="font-mono">{{ doc.curl }}</code></pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="overflow-hidden rounded-2xl border border-dashed border-default bg-muted/20">
|
||||||
|
<div class="space-y-4 p-5 sm:p-6">
|
||||||
|
<div class="flex gap-3">
|
||||||
|
<UIcon
|
||||||
|
name="i-lucide-message-square"
|
||||||
|
class="mt-0.5 size-5 shrink-0 text-muted"
|
||||||
|
/>
|
||||||
|
<div class="min-w-0 space-y-2">
|
||||||
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
|
<UBadge
|
||||||
|
color="neutral"
|
||||||
|
variant="subtle"
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
{{ twikooDoc.method }}
|
||||||
|
</UBadge>
|
||||||
|
<code class="rounded-md border border-primary/20 bg-primary/10 px-2 py-0.5 font-mono text-sm font-medium text-primary">
|
||||||
|
{{ twikooDoc.path }}
|
||||||
|
</code>
|
||||||
|
<span class="text-sm font-medium">
|
||||||
|
{{ twikooDoc.title }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p class="text-sm leading-relaxed text-muted">
|
||||||
|
{{ twikooDoc.description }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<p class="mb-2 text-xs font-medium text-muted">
|
||||||
|
cURL 示例
|
||||||
|
</p>
|
||||||
|
<div class="relative overflow-hidden rounded-xl bg-neutral-950">
|
||||||
|
<UButton
|
||||||
|
icon="i-lucide-copy"
|
||||||
|
label="复制"
|
||||||
|
size="xs"
|
||||||
|
variant="ghost"
|
||||||
|
color="neutral"
|
||||||
|
class="absolute top-2 right-2 z-10 text-neutral-300 hover:text-white"
|
||||||
|
@click="copyCurl(twikooDoc.curl)"
|
||||||
|
/>
|
||||||
|
<pre class="overflow-auto p-4 pt-10 text-xs leading-relaxed text-neutral-100"><code class="font-mono">{{ twikooDoc.curl }}</code></pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</AppShell>
|
||||||
|
|
||||||
|
<UModal
|
||||||
|
:open="confirmRegenerateOpen"
|
||||||
|
title="确认重新生成 Token?"
|
||||||
|
description="旧 Token 将立即失效,使用旧 Token 的脚本与自动化任务将无法继续调用接口,直到你更新为新 Token。"
|
||||||
|
@update:open="confirmRegenerateOpen = $event"
|
||||||
|
>
|
||||||
|
<template #footer>
|
||||||
|
<div class="flex justify-end gap-2">
|
||||||
|
<UButton
|
||||||
|
label="取消"
|
||||||
|
color="neutral"
|
||||||
|
variant="outline"
|
||||||
|
@click="closeConfirmRegenerate"
|
||||||
|
/>
|
||||||
|
<UButton
|
||||||
|
label="确认重新生成"
|
||||||
|
color="warning"
|
||||||
|
:loading="regenerating"
|
||||||
|
@click="confirmRegenerate"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</UModal>
|
||||||
|
|
||||||
|
<UModal
|
||||||
|
:open="regenerateResultOpen"
|
||||||
|
title="新 Token 已生成"
|
||||||
|
description="请立即复制保存。关闭弹窗后仍可在上方输入框复制,但请尽快更新到你的脚本配置中。"
|
||||||
|
@update:open="regenerateResultOpen = $event"
|
||||||
|
>
|
||||||
|
<template #body>
|
||||||
|
<UInput
|
||||||
|
:model-value="newToken"
|
||||||
|
readonly
|
||||||
|
class="w-full font-mono text-sm"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
<template #footer>
|
||||||
|
<div class="flex w-full flex-wrap justify-end gap-2">
|
||||||
|
<CopyButton
|
||||||
|
label="复制 Token"
|
||||||
|
:value="newToken"
|
||||||
|
/>
|
||||||
|
<UButton
|
||||||
|
label="我已保存"
|
||||||
|
@click="closeRegenerateResult"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</UModal>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,217 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { ImageItem } from '~/types/image'
|
||||||
|
|
||||||
|
const { uploading, progressItems, uploadFiles } = useImageUpload()
|
||||||
|
|
||||||
|
const {
|
||||||
|
defaultFolder,
|
||||||
|
autoCopyMarkdown,
|
||||||
|
copyFormat,
|
||||||
|
loadPreferences
|
||||||
|
} = useUploadPreferences()
|
||||||
|
|
||||||
|
const { isChecking, isAuthenticated, checkSession, handleAuthError, fetchStatus, isAdmin }
|
||||||
|
= useAuth()
|
||||||
|
const toast = useToast()
|
||||||
|
const folderOptions = ref<string[]>(['images'])
|
||||||
|
const sessionItems = ref<ImageItem[]>([])
|
||||||
|
const deletingKeys = ref<Set<string>>(new Set())
|
||||||
|
const emptySelection = ref(new Set<string>())
|
||||||
|
|
||||||
|
const showProgress = computed(
|
||||||
|
() =>
|
||||||
|
uploading.value
|
||||||
|
|| progressItems.value.some(
|
||||||
|
item =>
|
||||||
|
item.status === 'pending'
|
||||||
|
|| item.status === 'uploading'
|
||||||
|
|| item.status === 'error'
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
async function loadFolders() {
|
||||||
|
const data = await $fetch<{ folders: string[] }>('/api/folders', {
|
||||||
|
credentials: 'include'
|
||||||
|
})
|
||||||
|
folderOptions.value = [...data.folders]
|
||||||
|
if (!folderOptions.value.includes(defaultFolder.value)) {
|
||||||
|
defaultFolder.value = folderOptions.value[0] ?? 'images'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadPage() {
|
||||||
|
try {
|
||||||
|
loadPreferences()
|
||||||
|
if (isAdmin.value) {
|
||||||
|
await loadFolders()
|
||||||
|
}
|
||||||
|
} catch (error: unknown) {
|
||||||
|
handleAuthError(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
const status = await fetchStatus()
|
||||||
|
if (!status.initialized && !status.legacyMode) {
|
||||||
|
await navigateTo('/setup')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!isAuthenticated.value) {
|
||||||
|
await checkSession()
|
||||||
|
}
|
||||||
|
if (isAuthenticated.value) {
|
||||||
|
await loadPage()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(isAuthenticated, async (authed, prev) => {
|
||||||
|
if (authed && prev === false) {
|
||||||
|
await nextTick()
|
||||||
|
await loadPage()
|
||||||
|
} else if (!authed) {
|
||||||
|
sessionItems.value = []
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
function copyFormatLabel() {
|
||||||
|
switch (copyFormat.value) {
|
||||||
|
case 'url':
|
||||||
|
return '直链'
|
||||||
|
case 'html':
|
||||||
|
return 'HTML'
|
||||||
|
default:
|
||||||
|
return 'Markdown'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function copyUploadLink(text: string) {
|
||||||
|
try {
|
||||||
|
if (navigator.clipboard?.writeText) {
|
||||||
|
await navigator.clipboard.writeText(text)
|
||||||
|
} else {
|
||||||
|
const textarea = document.createElement('textarea')
|
||||||
|
textarea.value = text
|
||||||
|
textarea.style.position = 'fixed'
|
||||||
|
textarea.style.left = '-9999px'
|
||||||
|
document.body.appendChild(textarea)
|
||||||
|
textarea.select()
|
||||||
|
document.execCommand('copy')
|
||||||
|
document.body.removeChild(textarea)
|
||||||
|
}
|
||||||
|
toast.add({ title: `已复制 ${copyFormatLabel()}`, color: 'success' })
|
||||||
|
} catch {
|
||||||
|
toast.add({ title: '复制失败', color: 'error' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleUpload(files: File[]) {
|
||||||
|
if (!isAuthenticated.value) {
|
||||||
|
toast.add({ title: '请先登录', color: 'warning' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const uploadTarget = isAdmin.value
|
||||||
|
? (defaultFolder.value.trim() || 'images')
|
||||||
|
: 'images'
|
||||||
|
const result = await uploadFiles(files, uploadTarget, {
|
||||||
|
includeFolder: isAdmin.value
|
||||||
|
})
|
||||||
|
if (result?.items.length) {
|
||||||
|
sessionItems.value = [...result.items, ...sessionItems.value]
|
||||||
|
if (isAdmin.value) {
|
||||||
|
const folder = uploadTarget
|
||||||
|
if (!folderOptions.value.includes(folder)) {
|
||||||
|
folderOptions.value = [...folderOptions.value, folder]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (autoCopyMarkdown.value && result.items[0]) {
|
||||||
|
const first = result.items[0]
|
||||||
|
const text
|
||||||
|
= copyFormat.value === 'url'
|
||||||
|
? first.url
|
||||||
|
: copyFormat.value === 'html'
|
||||||
|
? first.html
|
||||||
|
: first.markdown
|
||||||
|
if (text) await copyUploadLink(text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error: unknown) {
|
||||||
|
handleAuthError(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete(key: string) {
|
||||||
|
deletingKeys.value.add(key)
|
||||||
|
try {
|
||||||
|
await $fetch('/api/images', {
|
||||||
|
method: 'DELETE',
|
||||||
|
query: { key }
|
||||||
|
})
|
||||||
|
sessionItems.value = sessionItems.value.filter(item => item.key !== key)
|
||||||
|
toast.add({ title: '已删除', color: 'success' })
|
||||||
|
} catch (error: unknown) {
|
||||||
|
handleAuthError(error)
|
||||||
|
toast.add({ title: '删除失败', color: 'error' })
|
||||||
|
} finally {
|
||||||
|
deletingKeys.value.delete(key)
|
||||||
|
deletingKeys.value = new Set(deletingKeys.value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</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>
|
||||||
|
<UploadFlipCard
|
||||||
|
:disabled="uploading"
|
||||||
|
@upload="handleUpload"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<UploadResult
|
||||||
|
v-if="showProgress"
|
||||||
|
:items="progressItems"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<section
|
||||||
|
v-if="sessionItems.length"
|
||||||
|
class="space-y-4"
|
||||||
|
>
|
||||||
|
<div class="flex items-center justify-between gap-2">
|
||||||
|
<h2 class="text-lg font-medium">
|
||||||
|
本次上传
|
||||||
|
<span class="ml-1 text-sm font-normal text-muted">({{ sessionItems.length }})</span>
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ImageGrid
|
||||||
|
:items="sessionItems"
|
||||||
|
:selected-keys="emptySelection"
|
||||||
|
:deleting-keys="deletingKeys"
|
||||||
|
:selectable="false"
|
||||||
|
:show-key="false"
|
||||||
|
empty-text="暂无本次上传"
|
||||||
|
@update:selected-keys="() => {}"
|
||||||
|
@delete="handleDelete"
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
</AppShell>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,172 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import logoLight from '~/assets/image/logo-light.png'
|
||||||
|
import logoDark from '~/assets/image/logo-dark.png'
|
||||||
|
import { isPasswordValid } from '~/utils/password-strength'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const { register, fetchStatus } = useAuth()
|
||||||
|
const toast = useToast()
|
||||||
|
|
||||||
|
const username = ref('')
|
||||||
|
const password = ref('')
|
||||||
|
const confirmPassword = ref('')
|
||||||
|
const loading = ref(false)
|
||||||
|
|
||||||
|
const canSubmit = computed(() =>
|
||||||
|
Boolean(username.value && password.value && confirmPassword.value)
|
||||||
|
&& password.value === confirmPassword.value
|
||||||
|
&& isPasswordValid(password.value)
|
||||||
|
&& AUTH_USERNAME_PATTERN.test(username.value.trim())
|
||||||
|
)
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
const status = await fetchStatus()
|
||||||
|
if (!status.initialized) {
|
||||||
|
await router.replace('/setup')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!status.allowRegistration) {
|
||||||
|
await router.replace('/')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
async function submit() {
|
||||||
|
if (!canSubmit.value || loading.value) return
|
||||||
|
|
||||||
|
if (password.value !== confirmPassword.value) {
|
||||||
|
toast.add({ title: '两次输入的密码不一致', color: 'error' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const result = await register(username.value.trim(), password.value)
|
||||||
|
if (result.ok) {
|
||||||
|
toast.add({ title: '注册成功', color: 'success' })
|
||||||
|
await router.replace('/')
|
||||||
|
} else {
|
||||||
|
toast.add({ title: result.error, color: 'error' })
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="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="mb-8 space-y-3 text-center">
|
||||||
|
<div class="mx-auto inline-flex items-center justify-center">
|
||||||
|
<img
|
||||||
|
:src="logoLight"
|
||||||
|
alt=""
|
||||||
|
aria-hidden="true"
|
||||||
|
class="h-14 w-auto shrink-0 object-contain dark:hidden sm:h-16"
|
||||||
|
decoding="async"
|
||||||
|
draggable="false"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
:src="logoDark"
|
||||||
|
alt=""
|
||||||
|
aria-hidden="true"
|
||||||
|
class="hidden h-14 w-auto shrink-0 object-contain dark:block sm:h-16"
|
||||||
|
decoding="async"
|
||||||
|
draggable="false"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-1">
|
||||||
|
<h1 class="text-xl font-bold tracking-tight sm:text-2xl">
|
||||||
|
注册账号
|
||||||
|
</h1>
|
||||||
|
<p class="text-sm text-muted">
|
||||||
|
创建 PicHost 用户账号
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form
|
||||||
|
class="flex w-full flex-col gap-5"
|
||||||
|
@submit.prevent="submit"
|
||||||
|
>
|
||||||
|
<div class="w-full space-y-2">
|
||||||
|
<label
|
||||||
|
for="register-username"
|
||||||
|
class="block text-sm font-medium text-default"
|
||||||
|
>
|
||||||
|
用户名
|
||||||
|
</label>
|
||||||
|
<UInput
|
||||||
|
id="register-username"
|
||||||
|
v-model="username"
|
||||||
|
placeholder="3–32 位字母、数字或下划线"
|
||||||
|
autocomplete="username"
|
||||||
|
size="lg"
|
||||||
|
class="w-full"
|
||||||
|
:ui="{ root: 'w-full' }"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="w-full space-y-2">
|
||||||
|
<label
|
||||||
|
for="register-password"
|
||||||
|
class="block text-sm font-medium text-default"
|
||||||
|
>
|
||||||
|
密码
|
||||||
|
</label>
|
||||||
|
<UInput
|
||||||
|
id="register-password"
|
||||||
|
v-model="password"
|
||||||
|
type="password"
|
||||||
|
placeholder="设置登录密码"
|
||||||
|
autocomplete="new-password"
|
||||||
|
size="lg"
|
||||||
|
class="w-full"
|
||||||
|
:ui="{ root: 'w-full' }"
|
||||||
|
/>
|
||||||
|
<PasswordStrength :password="password" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="w-full space-y-2">
|
||||||
|
<label
|
||||||
|
for="register-confirm"
|
||||||
|
class="block text-sm font-medium text-default"
|
||||||
|
>
|
||||||
|
确认密码
|
||||||
|
</label>
|
||||||
|
<UInput
|
||||||
|
id="register-confirm"
|
||||||
|
v-model="confirmPassword"
|
||||||
|
type="password"
|
||||||
|
placeholder="再次输入密码"
|
||||||
|
autocomplete="new-password"
|
||||||
|
size="lg"
|
||||||
|
class="w-full"
|
||||||
|
:ui="{ root: 'w-full' }"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<UButton
|
||||||
|
type="submit"
|
||||||
|
label="注册"
|
||||||
|
icon="i-lucide-user-plus"
|
||||||
|
size="lg"
|
||||||
|
block
|
||||||
|
class="w-full"
|
||||||
|
:loading="loading"
|
||||||
|
:disabled="!canSubmit"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<p class="text-center text-sm text-muted">
|
||||||
|
已有账号?
|
||||||
|
<NuxtLink
|
||||||
|
to="/"
|
||||||
|
class="text-primary hover:underline"
|
||||||
|
>
|
||||||
|
返回登录
|
||||||
|
</NuxtLink>
|
||||||
|
</p>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,359 @@
|
|||||||
|
<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) }}
|
||||||
|
<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>
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import logoLight from '~/assets/image/logo-light.png'
|
||||||
|
import logoDark from '~/assets/image/logo-dark.png'
|
||||||
|
import { isPasswordValid } from '~/utils/password-strength'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
const { setup, migrate, fetchStatus } = useAuth()
|
||||||
|
const toast = useToast()
|
||||||
|
|
||||||
|
const isMigrate = computed(() => route.query.migrate === '1')
|
||||||
|
|
||||||
|
const username = ref('')
|
||||||
|
const password = ref('')
|
||||||
|
const confirmPassword = ref('')
|
||||||
|
const allowRegistration = ref(false)
|
||||||
|
const loading = ref(false)
|
||||||
|
|
||||||
|
const canSubmit = computed(() =>
|
||||||
|
Boolean(username.value && password.value && confirmPassword.value)
|
||||||
|
&& password.value === confirmPassword.value
|
||||||
|
&& isPasswordValid(password.value)
|
||||||
|
&& AUTH_USERNAME_PATTERN.test(username.value.trim())
|
||||||
|
)
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
const status = await fetchStatus()
|
||||||
|
if (status.initialized && !status.needsMigration) {
|
||||||
|
await router.replace('/')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
async function submit() {
|
||||||
|
if (!canSubmit.value || loading.value) return
|
||||||
|
|
||||||
|
if (password.value !== confirmPassword.value) {
|
||||||
|
toast.add({ title: '两次输入的密码不一致', color: 'error' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const payload = {
|
||||||
|
username: username.value.trim(),
|
||||||
|
password: password.value,
|
||||||
|
allowRegistration: allowRegistration.value
|
||||||
|
}
|
||||||
|
const result = isMigrate.value
|
||||||
|
? await migrate(payload)
|
||||||
|
: await setup(payload)
|
||||||
|
|
||||||
|
if (result.ok) {
|
||||||
|
toast.add({
|
||||||
|
title: isMigrate.value ? '账号迁移完成' : '初始化完成',
|
||||||
|
color: 'success'
|
||||||
|
})
|
||||||
|
await router.replace('/')
|
||||||
|
} else {
|
||||||
|
toast.add({ title: result.error, color: 'error' })
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="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="mb-8 space-y-3 text-center">
|
||||||
|
<div class="mx-auto inline-flex items-center justify-center">
|
||||||
|
<img
|
||||||
|
:src="logoLight"
|
||||||
|
alt=""
|
||||||
|
aria-hidden="true"
|
||||||
|
class="h-14 w-auto shrink-0 object-contain dark:hidden sm:h-16"
|
||||||
|
decoding="async"
|
||||||
|
draggable="false"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
:src="logoDark"
|
||||||
|
alt=""
|
||||||
|
aria-hidden="true"
|
||||||
|
class="hidden h-14 w-auto shrink-0 object-contain dark:block sm:h-16"
|
||||||
|
decoding="async"
|
||||||
|
draggable="false"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-1">
|
||||||
|
<h1 class="text-xl font-bold tracking-tight sm:text-2xl">
|
||||||
|
<span class="text-highlighted">Pic</span><span class="text-primary">Host</span>
|
||||||
|
</h1>
|
||||||
|
<p class="text-sm text-muted">
|
||||||
|
{{ isMigrate ? '迁移到账号密码登录' : '首次使用,创建管理员账号' }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form
|
||||||
|
class="flex w-full flex-col gap-5"
|
||||||
|
@submit.prevent="submit"
|
||||||
|
>
|
||||||
|
<div class="w-full space-y-2">
|
||||||
|
<label
|
||||||
|
for="setup-username"
|
||||||
|
class="block text-sm font-medium text-default"
|
||||||
|
>
|
||||||
|
用户名
|
||||||
|
</label>
|
||||||
|
<UInput
|
||||||
|
id="setup-username"
|
||||||
|
v-model="username"
|
||||||
|
placeholder="3–32 位字母、数字或下划线"
|
||||||
|
autocomplete="username"
|
||||||
|
size="lg"
|
||||||
|
class="w-full"
|
||||||
|
:ui="{ root: 'w-full' }"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="w-full space-y-2">
|
||||||
|
<label
|
||||||
|
for="setup-password"
|
||||||
|
class="block text-sm font-medium text-default"
|
||||||
|
>
|
||||||
|
密码
|
||||||
|
</label>
|
||||||
|
<UInput
|
||||||
|
id="setup-password"
|
||||||
|
v-model="password"
|
||||||
|
type="password"
|
||||||
|
placeholder="设置登录密码"
|
||||||
|
autocomplete="new-password"
|
||||||
|
size="lg"
|
||||||
|
class="w-full"
|
||||||
|
:ui="{ root: 'w-full' }"
|
||||||
|
/>
|
||||||
|
<PasswordStrength :password="password" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="w-full space-y-2">
|
||||||
|
<label
|
||||||
|
for="setup-confirm"
|
||||||
|
class="block text-sm font-medium text-default"
|
||||||
|
>
|
||||||
|
确认密码
|
||||||
|
</label>
|
||||||
|
<UInput
|
||||||
|
id="setup-confirm"
|
||||||
|
v-model="confirmPassword"
|
||||||
|
type="password"
|
||||||
|
placeholder="再次输入密码"
|
||||||
|
autocomplete="new-password"
|
||||||
|
size="lg"
|
||||||
|
class="w-full"
|
||||||
|
:ui="{ root: 'w-full' }"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label class="flex cursor-pointer items-center gap-2 text-sm text-default">
|
||||||
|
<UCheckbox v-model="allowRegistration" />
|
||||||
|
允许其他用户注册
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<UButton
|
||||||
|
type="submit"
|
||||||
|
:label="isMigrate ? '完成迁移' : '创建并进入'"
|
||||||
|
icon="i-lucide-user-plus"
|
||||||
|
size="lg"
|
||||||
|
block
|
||||||
|
class="w-full"
|
||||||
|
:loading="loading"
|
||||||
|
:disabled="!canSubmit"
|
||||||
|
/>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,551 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
interface StatsResponse {
|
||||||
|
uploadToday: number
|
||||||
|
uploadMonth: number
|
||||||
|
deleteToday: number
|
||||||
|
deleteMonth: number
|
||||||
|
uploadTotal: number
|
||||||
|
deleteTotal: number
|
||||||
|
uploadBytesTotal: number
|
||||||
|
storedCount: number
|
||||||
|
userCount?: number
|
||||||
|
bySource: {
|
||||||
|
web: number
|
||||||
|
api: number
|
||||||
|
twikoo: number
|
||||||
|
}
|
||||||
|
byFolder: Array<{
|
||||||
|
folder: string
|
||||||
|
count: number
|
||||||
|
bytes: number
|
||||||
|
}>
|
||||||
|
byFolderUploads: Array<{
|
||||||
|
folder: string
|
||||||
|
count: number
|
||||||
|
}>
|
||||||
|
}
|
||||||
|
|
||||||
|
const {
|
||||||
|
items,
|
||||||
|
page: galleryPage,
|
||||||
|
totalPages: galleryTotalPages,
|
||||||
|
total: galleryTotal,
|
||||||
|
loading: galleryLoading,
|
||||||
|
searchQuery,
|
||||||
|
activeSearch,
|
||||||
|
listSummary,
|
||||||
|
refreshList,
|
||||||
|
initializeList,
|
||||||
|
setActiveFolder,
|
||||||
|
submitSearch,
|
||||||
|
goToPage,
|
||||||
|
removeItems
|
||||||
|
} = useImageList()
|
||||||
|
|
||||||
|
const { formatFileSize } = useFileSize()
|
||||||
|
const { isChecking, isAuthenticated, checkSession, handleAuthError, fetchStatus, isAdmin } = useAuth()
|
||||||
|
const toast = useToast()
|
||||||
|
|
||||||
|
const stats = ref<StatsResponse | null>(null)
|
||||||
|
const statsLoading = ref(false)
|
||||||
|
|
||||||
|
const selectedKeys = ref<Set<string>>(new Set())
|
||||||
|
const deletingKeys = ref<Set<string>>(new Set())
|
||||||
|
const deleteModalOpen = ref(false)
|
||||||
|
const deleteTargetKeys = ref<string[]>([])
|
||||||
|
const batchDeleting = ref(false)
|
||||||
|
const showScrollTop = ref(false)
|
||||||
|
const currentFolder = ref('all')
|
||||||
|
const folderOptions = ref<string[]>(['images'])
|
||||||
|
|
||||||
|
const folderSelectItems = computed(() => [
|
||||||
|
{ label: '全部', value: 'all' },
|
||||||
|
...folderOptions.value.map(folder => ({ label: folder, value: folder }))
|
||||||
|
])
|
||||||
|
|
||||||
|
const selectedCount = computed(() => selectedKeys.value.size)
|
||||||
|
|
||||||
|
const FOLDER_CHART_COLORS = [
|
||||||
|
'var(--ui-primary)',
|
||||||
|
'#10b981',
|
||||||
|
'#f59e0b',
|
||||||
|
'#8b5cf6',
|
||||||
|
'#ec4899',
|
||||||
|
'#06b6d4'
|
||||||
|
]
|
||||||
|
|
||||||
|
const folderUploadItems = computed(() =>
|
||||||
|
(stats.value?.byFolderUploads ?? []).map((item, index) => ({
|
||||||
|
key: item.folder,
|
||||||
|
label: item.folder,
|
||||||
|
count: item.count,
|
||||||
|
color: FOLDER_CHART_COLORS[index % FOLDER_CHART_COLORS.length]!
|
||||||
|
}))
|
||||||
|
)
|
||||||
|
|
||||||
|
const donutSegments = computed(() =>
|
||||||
|
folderUploadItems.value.map(item => ({
|
||||||
|
label: item.label,
|
||||||
|
value: item.count,
|
||||||
|
color: item.color
|
||||||
|
}))
|
||||||
|
)
|
||||||
|
|
||||||
|
const folderStats = computed(() => stats.value?.byFolder ?? [])
|
||||||
|
|
||||||
|
const folderMaxBytes = computed(() =>
|
||||||
|
Math.max(...folderStats.value.map(item => item.bytes), 1)
|
||||||
|
)
|
||||||
|
|
||||||
|
const uploadChartTotal = computed(() =>
|
||||||
|
folderUploadItems.value.reduce((sum, item) => sum + item.count, 0)
|
||||||
|
)
|
||||||
|
|
||||||
|
const statCards = computed(() => {
|
||||||
|
const s = stats.value
|
||||||
|
const base = [
|
||||||
|
{ label: '今日上传', value: s?.uploadToday ?? '—' },
|
||||||
|
{ label: '本月上传', value: s?.uploadMonth ?? '—' },
|
||||||
|
{ label: '累计上传', value: s?.uploadTotal ?? '—' },
|
||||||
|
{ label: '当前库存', value: s?.storedCount ?? '—' },
|
||||||
|
{ label: '今日删除', value: s?.deleteToday ?? '—' },
|
||||||
|
{ label: '本月删除', value: s?.deleteMonth ?? '—' },
|
||||||
|
isAdmin.value
|
||||||
|
? { label: '注册用户数量', value: s?.userCount ?? '—' }
|
||||||
|
: { label: '累计删除', value: s?.deleteTotal ?? '—' },
|
||||||
|
{ label: '累计上传体积', value: s ? formatFileSize(s.uploadBytesTotal) : '—' }
|
||||||
|
]
|
||||||
|
return base
|
||||||
|
})
|
||||||
|
|
||||||
|
function onWindowScroll() {
|
||||||
|
showScrollTop.value = window.scrollY > 400
|
||||||
|
}
|
||||||
|
|
||||||
|
function scrollToTop() {
|
||||||
|
window.scrollTo({ top: 0, behavior: 'smooth' })
|
||||||
|
}
|
||||||
|
|
||||||
|
function folderPercent(bytes: number) {
|
||||||
|
return Math.round((bytes / folderMaxBytes.value) * 100)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadFolders() {
|
||||||
|
const data = await $fetch<{ folders: string[] }>('/api/folders', {
|
||||||
|
credentials: 'include'
|
||||||
|
})
|
||||||
|
folderOptions.value = [...data.folders]
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchStats() {
|
||||||
|
stats.value = await $fetch<StatsResponse>('/api/stats', {
|
||||||
|
credentials: 'include'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshStats() {
|
||||||
|
statsLoading.value = true
|
||||||
|
try {
|
||||||
|
await fetchStats()
|
||||||
|
} catch (error: unknown) {
|
||||||
|
handleAuthError(error)
|
||||||
|
if (isAuthenticated.value) {
|
||||||
|
toast.add({ title: '加载统计失败', color: 'error' })
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
statsLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshGallery() {
|
||||||
|
if (!isAuthenticated.value) return
|
||||||
|
selectedKeys.value = new Set()
|
||||||
|
try {
|
||||||
|
await Promise.all([refreshList(), loadFolders()])
|
||||||
|
} catch (error: unknown) {
|
||||||
|
handleAuthError(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshAll() {
|
||||||
|
if (!isAuthenticated.value) return
|
||||||
|
currentFolder.value = 'all'
|
||||||
|
await Promise.all([
|
||||||
|
refreshStats(),
|
||||||
|
initializeList('all').then(() => loadFolders())
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleFolderChange(folder: string) {
|
||||||
|
if (!isAuthenticated.value) return
|
||||||
|
currentFolder.value = folder
|
||||||
|
selectedKeys.value = new Set()
|
||||||
|
try {
|
||||||
|
await setActiveFolder(folder)
|
||||||
|
} catch (error: unknown) {
|
||||||
|
handleAuthError(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSearch() {
|
||||||
|
if (!isAuthenticated.value) return
|
||||||
|
selectedKeys.value = new Set()
|
||||||
|
try {
|
||||||
|
await submitSearch()
|
||||||
|
} catch (error: unknown) {
|
||||||
|
handleAuthError(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function clearSearch() {
|
||||||
|
searchQuery.value = ''
|
||||||
|
if (!activeSearch.value) return
|
||||||
|
await refreshGallery()
|
||||||
|
}
|
||||||
|
|
||||||
|
function requestDelete(key: string) {
|
||||||
|
if (!isAuthenticated.value) return
|
||||||
|
deleteTargetKeys.value = [key]
|
||||||
|
deleteModalOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function requestBatchDelete() {
|
||||||
|
if (!isAuthenticated.value) return
|
||||||
|
if (!selectedCount.value) {
|
||||||
|
toast.add({ title: '请先选择图片', color: 'warning' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
deleteTargetKeys.value = Array.from(selectedKeys.value)
|
||||||
|
deleteModalOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmDelete() {
|
||||||
|
const keys = [...deleteTargetKeys.value]
|
||||||
|
if (!keys.length || !isAuthenticated.value) return
|
||||||
|
|
||||||
|
batchDeleting.value = true
|
||||||
|
for (const key of keys) {
|
||||||
|
deletingKeys.value.add(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleted: string[] = []
|
||||||
|
const failed: string[] = []
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await $fetch<{
|
||||||
|
deleted: string[]
|
||||||
|
failed: Array<{ key: string }>
|
||||||
|
}>('/api/images/batch-delete', {
|
||||||
|
method: 'POST',
|
||||||
|
body: { keys }
|
||||||
|
})
|
||||||
|
deleted.push(...response.deleted)
|
||||||
|
failed.push(...response.failed.map(item => item.key))
|
||||||
|
|
||||||
|
if (deleted.length) {
|
||||||
|
removeItems(deleted)
|
||||||
|
for (const key of deleted) {
|
||||||
|
selectedKeys.value.delete(key)
|
||||||
|
}
|
||||||
|
selectedKeys.value = new Set(selectedKeys.value)
|
||||||
|
toast.add({
|
||||||
|
title: `已删除 ${deleted.length} 张图片`,
|
||||||
|
color: 'success'
|
||||||
|
})
|
||||||
|
await refreshStats()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (failed.length) {
|
||||||
|
toast.add({
|
||||||
|
title: `${failed.length} 张图片删除失败`,
|
||||||
|
color: 'error'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteModalOpen.value = false
|
||||||
|
deleteTargetKeys.value = []
|
||||||
|
} catch (error: unknown) {
|
||||||
|
handleAuthError(error)
|
||||||
|
if (isAuthenticated.value) {
|
||||||
|
toast.add({ title: '删除失败', color: 'error' })
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
batchDeleting.value = false
|
||||||
|
deletingKeys.value = new Set()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleGalleryPageChange(targetPage: number) {
|
||||||
|
selectedKeys.value = new Set()
|
||||||
|
try {
|
||||||
|
await goToPage(targetPage)
|
||||||
|
} catch (error: unknown) {
|
||||||
|
handleAuthError(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
window.addEventListener('scroll', onWindowScroll, { passive: true })
|
||||||
|
const status = await fetchStatus()
|
||||||
|
if (!status.initialized && !status.legacyMode) {
|
||||||
|
await navigateTo('/setup')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await checkSession()
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
window.removeEventListener('scroll', onWindowScroll)
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(isAuthenticated, async (authed, prev) => {
|
||||||
|
if (authed && prev === false) {
|
||||||
|
await nextTick()
|
||||||
|
await refreshAll()
|
||||||
|
} else if (!authed) {
|
||||||
|
stats.value = null
|
||||||
|
items.value = []
|
||||||
|
selectedKeys.value = new Set()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="min-h-screen">
|
||||||
|
<div
|
||||||
|
v-if="isChecking"
|
||||||
|
class="flex min-h-screen items-center justify-center"
|
||||||
|
>
|
||||||
|
<div class="flex flex-col items-center gap-3 text-muted">
|
||||||
|
<UIcon
|
||||||
|
name="i-lucide-loader-circle"
|
||||||
|
class="size-8 animate-spin"
|
||||||
|
/>
|
||||||
|
<p class="text-sm">
|
||||||
|
正在验证登录状态…
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<AdminLoginGate v-else-if="!isAuthenticated" />
|
||||||
|
|
||||||
|
<AppShell v-else>
|
||||||
|
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-2xl font-semibold tracking-tight">
|
||||||
|
统计
|
||||||
|
</h1>
|
||||||
|
<p class="text-sm text-muted">
|
||||||
|
数据概览与图库记录
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<UButton
|
||||||
|
icon="i-lucide-refresh-cw"
|
||||||
|
label="刷新"
|
||||||
|
variant="outline"
|
||||||
|
:loading="statsLoading || galleryLoading"
|
||||||
|
@click="refreshAll"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section class="grid grid-cols-2 gap-3 lg:grid-cols-4">
|
||||||
|
<div
|
||||||
|
v-for="item in statCards"
|
||||||
|
:key="item.label"
|
||||||
|
class="rounded-xl border border-default bg-elevated p-4"
|
||||||
|
>
|
||||||
|
<p class="text-xs text-muted">
|
||||||
|
{{ item.label }}
|
||||||
|
</p>
|
||||||
|
<p class="mt-1 text-2xl font-semibold tabular-nums">
|
||||||
|
{{ item.value }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="grid gap-4 lg:grid-cols-2">
|
||||||
|
<div class="rounded-xl border border-default bg-elevated p-4">
|
||||||
|
<h2 class="mb-3 text-sm font-medium text-muted">
|
||||||
|
上传目录分布
|
||||||
|
</h2>
|
||||||
|
<div
|
||||||
|
v-if="!uploadChartTotal"
|
||||||
|
class="py-6 text-center text-xs text-muted"
|
||||||
|
>
|
||||||
|
暂无数据
|
||||||
|
</div>
|
||||||
|
<StatsDonut
|
||||||
|
v-else
|
||||||
|
:segments="donutSegments"
|
||||||
|
:size="88"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-xl border border-default bg-elevated p-4">
|
||||||
|
<h2 class="mb-3 text-sm font-medium text-muted">
|
||||||
|
目录占用
|
||||||
|
</h2>
|
||||||
|
<div
|
||||||
|
v-if="!folderStats.length"
|
||||||
|
class="py-6 text-center text-xs text-muted"
|
||||||
|
>
|
||||||
|
暂无数据
|
||||||
|
</div>
|
||||||
|
<ul
|
||||||
|
v-else
|
||||||
|
class="space-y-2.5"
|
||||||
|
>
|
||||||
|
<li
|
||||||
|
v-for="item in folderStats"
|
||||||
|
:key="item.folder"
|
||||||
|
>
|
||||||
|
<div class="mb-1 flex items-center justify-between gap-2 text-xs">
|
||||||
|
<span class="font-medium">{{ item.folder }}</span>
|
||||||
|
<span class="tabular-nums text-muted">
|
||||||
|
{{ item.count }} 张 · {{ formatFileSize(item.bytes) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="h-1.5 overflow-hidden rounded-full bg-muted/50">
|
||||||
|
<div
|
||||||
|
class="h-full rounded-full bg-primary/70"
|
||||||
|
:style="{ width: `${Math.max(folderPercent(item.bytes), item.bytes ? 4 : 0)}%` }"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="space-y-4">
|
||||||
|
<form
|
||||||
|
class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"
|
||||||
|
@submit.prevent="handleSearch"
|
||||||
|
>
|
||||||
|
<div class="min-w-0 shrink-0">
|
||||||
|
<h2 class="text-lg font-medium">
|
||||||
|
图库记录
|
||||||
|
</h2>
|
||||||
|
<p
|
||||||
|
v-if="!galleryLoading"
|
||||||
|
class="text-sm text-muted"
|
||||||
|
>
|
||||||
|
{{ listSummary }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-wrap items-center justify-end gap-2">
|
||||||
|
<div class="flex items-center gap-1.5">
|
||||||
|
<span class="text-xs text-muted shrink-0">目录</span>
|
||||||
|
<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
|
||||||
|
v-if="selectedCount"
|
||||||
|
color="primary"
|
||||||
|
variant="subtle"
|
||||||
|
>
|
||||||
|
已选 {{ selectedCount }}
|
||||||
|
</UBadge>
|
||||||
|
<UButton
|
||||||
|
type="button"
|
||||||
|
icon="i-lucide-trash-2"
|
||||||
|
label="批量删除"
|
||||||
|
color="error"
|
||||||
|
variant="soft"
|
||||||
|
size="sm"
|
||||||
|
:disabled="!selectedCount"
|
||||||
|
@click="requestBatchDelete"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="galleryLoading"
|
||||||
|
class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4"
|
||||||
|
>
|
||||||
|
<USkeleton
|
||||||
|
v-for="n in 12"
|
||||||
|
:key="n"
|
||||||
|
class="aspect-square rounded-lg"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ImageGrid
|
||||||
|
v-else
|
||||||
|
:items="items"
|
||||||
|
:selected-keys="selectedKeys"
|
||||||
|
:deleting-keys="deletingKeys"
|
||||||
|
@update:selected-keys="selectedKeys = $event"
|
||||||
|
@delete="requestDelete"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<PaginationBar
|
||||||
|
v-if="!galleryLoading && galleryTotal > 0"
|
||||||
|
:page="galleryPage"
|
||||||
|
:total-pages="galleryTotalPages"
|
||||||
|
:total="galleryTotal"
|
||||||
|
unit="张"
|
||||||
|
:loading="galleryLoading"
|
||||||
|
@update:page="handleGalleryPageChange"
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<DeleteConfirmModal
|
||||||
|
v-model:open="deleteModalOpen"
|
||||||
|
:count="deleteTargetKeys.length"
|
||||||
|
:loading="batchDeleting"
|
||||||
|
@confirm="confirmDelete"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<UButton
|
||||||
|
v-show="showScrollTop"
|
||||||
|
icon="i-lucide-arrow-up"
|
||||||
|
aria-label="回到顶部"
|
||||||
|
color="primary"
|
||||||
|
class="fixed bottom-6 right-6 z-50 shadow-lg"
|
||||||
|
size="lg"
|
||||||
|
@click="scrollToTop"
|
||||||
|
/>
|
||||||
|
</AppShell>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
export type ErrorCode
|
||||||
|
= | 'UNAUTHORIZED'
|
||||||
|
| 'FORBIDDEN'
|
||||||
|
| 'CONFLICT'
|
||||||
|
| 'INVALID_REQUEST'
|
||||||
|
| 'TOO_MANY_FILES'
|
||||||
|
| 'FILE_TOO_LARGE'
|
||||||
|
| 'UNSUPPORTED_FILE_TYPE'
|
||||||
|
| 'INVALID_FILE_SIGNATURE'
|
||||||
|
| 'INVALID_IMAGE_KEY'
|
||||||
|
| 'IMAGE_NOT_FOUND'
|
||||||
|
| 'UPLOAD_FAILED'
|
||||||
|
| 'DELETE_FAILED'
|
||||||
|
|
||||||
|
export interface ApiError {
|
||||||
|
code: ErrorCode
|
||||||
|
message: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ApiErrorResponse {
|
||||||
|
success: false
|
||||||
|
error: ApiError
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ImageItem {
|
||||||
|
key: string
|
||||||
|
url: string
|
||||||
|
originalName: string
|
||||||
|
contentType: string
|
||||||
|
size: number
|
||||||
|
uploadedAt: string
|
||||||
|
markdown: string
|
||||||
|
html: string
|
||||||
|
/** 管理员列表可见:上传者信息 */
|
||||||
|
owner?: {
|
||||||
|
userId: number | null
|
||||||
|
username: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UploadErrorItem {
|
||||||
|
name: string
|
||||||
|
error: ApiError
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UploadResponse {
|
||||||
|
success: boolean
|
||||||
|
items: ImageItem[]
|
||||||
|
errors: UploadErrorItem[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ImageListResponse {
|
||||||
|
items: ImageItem[]
|
||||||
|
total: number
|
||||||
|
page: number
|
||||||
|
pageSize: number
|
||||||
|
totalPages: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DeleteResponse {
|
||||||
|
success: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BatchDeleteResponse {
|
||||||
|
success: boolean
|
||||||
|
deleted: string[]
|
||||||
|
failed: Array<{ key: string, error: ApiError }>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UploadProgressItem {
|
||||||
|
name: string
|
||||||
|
status: 'pending' | 'uploading' | 'success' | 'error'
|
||||||
|
progress: number
|
||||||
|
error?: string
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
export const PASSWORD_MIN_LENGTH = 8
|
||||||
|
|
||||||
|
export interface PasswordCriteria {
|
||||||
|
minLength: boolean
|
||||||
|
mixedCase: boolean
|
||||||
|
hasNumber: boolean
|
||||||
|
hasSpecial: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PasswordStrengthLabel = '弱' | '中' | '强'
|
||||||
|
|
||||||
|
export function analyzePassword(password: string): {
|
||||||
|
criteria: PasswordCriteria
|
||||||
|
score: number
|
||||||
|
label: PasswordStrengthLabel
|
||||||
|
isValid: boolean
|
||||||
|
} {
|
||||||
|
const criteria: PasswordCriteria = {
|
||||||
|
minLength: password.length >= PASSWORD_MIN_LENGTH,
|
||||||
|
mixedCase: /[a-z]/.test(password) && /[A-Z]/.test(password),
|
||||||
|
hasNumber: /\d/.test(password),
|
||||||
|
hasSpecial: /[^a-zA-Z0-9]/.test(password)
|
||||||
|
}
|
||||||
|
|
||||||
|
const score = Object.values(criteria).filter(Boolean).length
|
||||||
|
const label: PasswordStrengthLabel
|
||||||
|
= score <= 1 ? '弱' : score <= 3 ? '中' : '强'
|
||||||
|
|
||||||
|
return {
|
||||||
|
criteria,
|
||||||
|
score,
|
||||||
|
label,
|
||||||
|
isValid: criteria.minLength
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isPasswordValid(password: string): boolean {
|
||||||
|
return analyzePassword(password).isValid
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
services:
|
||||||
|
pichost:
|
||||||
|
# Docker Hub 镜像(推送 main 分支后由 GitHub Actions 自动构建)
|
||||||
|
# 飞牛 Docker 对 Docker Hub 的「更新镜像」支持更好
|
||||||
|
image: muxui/pichost:latest
|
||||||
|
pull_policy: always
|
||||||
|
# 备用:GitHub Container Registry
|
||||||
|
# image: ghcr.io/O96u/pichost:latest
|
||||||
|
container_name: pichost
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "6892:6892"
|
||||||
|
# 首次访问 Web 引导创建管理员,无需预先配置环境变量
|
||||||
|
# 可选:environment 中设置 API_UPLOAD_TOKEN、ALLOWED_REFERER_HOSTS 等
|
||||||
|
volumes:
|
||||||
|
# 冒号左边改成 NAS 上的实际存图目录(飞牛示例:/vol1/1000/pichost-data)
|
||||||
|
- ./data:/data
|
||||||
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 60 KiB |
|
After Width: | Height: | Size: 115 KiB |
|
After Width: | Height: | Size: 24 KiB |
@@ -0,0 +1,6 @@
|
|||||||
|
// @ts-check
|
||||||
|
import withNuxt from './.nuxt/eslint.config.mjs'
|
||||||
|
|
||||||
|
export default withNuxt(
|
||||||
|
// Your custom configs here
|
||||||
|
)
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { readFileSync } from 'node:fs'
|
||||||
|
import { dirname, join } from 'node:path'
|
||||||
|
import { fileURLToPath } from 'node:url'
|
||||||
|
|
||||||
|
const rootDir = dirname(fileURLToPath(import.meta.url))
|
||||||
|
const pkg = JSON.parse(readFileSync(join(rootDir, 'package.json'), 'utf8')) as {
|
||||||
|
version: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// https://nuxt.com/docs/api/configuration/nuxt-config
|
||||||
|
export default defineNuxtConfig({
|
||||||
|
modules: [
|
||||||
|
'@nuxt/eslint',
|
||||||
|
'@nuxt/ui'
|
||||||
|
],
|
||||||
|
|
||||||
|
devtools: {
|
||||||
|
enabled: true
|
||||||
|
},
|
||||||
|
|
||||||
|
css: ['~/assets/css/main.css'],
|
||||||
|
|
||||||
|
colorMode: {
|
||||||
|
preference: 'system',
|
||||||
|
fallback: 'light'
|
||||||
|
},
|
||||||
|
|
||||||
|
runtimeConfig: {
|
||||||
|
appVersion: pkg.version,
|
||||||
|
devBypassAccess: process.env.DEV_BYPASS_ACCESS === 'true',
|
||||||
|
adminSecret: '',
|
||||||
|
imageBaseUrl: '',
|
||||||
|
apiUploadToken: ''
|
||||||
|
},
|
||||||
|
|
||||||
|
compatibilityDate: '2026-06-30',
|
||||||
|
|
||||||
|
nitro: {
|
||||||
|
preset: 'node-server',
|
||||||
|
scheduledTasks: {
|
||||||
|
'0 3 * * *': ['auto-delete']
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
eslint: {
|
||||||
|
config: {
|
||||||
|
stylistic: {
|
||||||
|
commaDangle: 'never',
|
||||||
|
braceStyle: '1tbs'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
{
|
||||||
|
"name": "pichost",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Self-hosted image hosting for your NAS / home server — Nuxt admin UI, local disk storage, server-side WebP compression, referer protection, Twikoo/EasyImage compatible.",
|
||||||
|
"homepage": "https://github.com/O96u/PicHost#readme",
|
||||||
|
"repository": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "git+https://github.com/O96u/PicHost.git"
|
||||||
|
},
|
||||||
|
"bugs": {
|
||||||
|
"url": "https://github.com/O96u/PicHost/issues"
|
||||||
|
},
|
||||||
|
"keywords": [
|
||||||
|
"image-hosting",
|
||||||
|
"self-hosted",
|
||||||
|
"nas",
|
||||||
|
"docker",
|
||||||
|
"nuxt",
|
||||||
|
"twikoo",
|
||||||
|
"easyimage"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"packageManager": "npm@11.12.1",
|
||||||
|
"scripts": {
|
||||||
|
"build": "nuxt build",
|
||||||
|
"dev": "nuxt dev",
|
||||||
|
"preview": "nuxt preview",
|
||||||
|
"postinstall": "nuxt prepare",
|
||||||
|
"lint": "eslint .",
|
||||||
|
"typecheck": "nuxt typecheck",
|
||||||
|
"test": "vitest run",
|
||||||
|
"test:watch": "vitest",
|
||||||
|
"start": "node .output/server/index.mjs"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@iconify-json/lucide": "^1.2.114",
|
||||||
|
"@nuxt/ui": "^4.9.0",
|
||||||
|
"jose": "^6.0.11",
|
||||||
|
"nuxt": "^4.4.8",
|
||||||
|
"sharp": "^0.35.3",
|
||||||
|
"tailwindcss": "^4.3.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@nuxt/eslint": "^1.16.0",
|
||||||
|
"@types/node": "^22.15.0",
|
||||||
|
"eslint": "^10.6.0",
|
||||||
|
"typescript": "^6.0.3",
|
||||||
|
"vitest": "^3.2.4",
|
||||||
|
"vue-tsc": "^3.3.5"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 4.2 KiB |
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"extends": [
|
||||||
|
"github>nuxt/renovate-config-nuxt"
|
||||||
|
],
|
||||||
|
"lockFileMaintenance": {
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
|
"packageRules": [{
|
||||||
|
"matchDepTypes": ["resolutions"],
|
||||||
|
"enabled": false
|
||||||
|
}]
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { isAllowRegistration } from '../../utils/db'
|
||||||
|
import { isAdminSecretConfigured, isApiUploadTokenConfigured } from '../../utils/env'
|
||||||
|
import { isInitialized } from '../../utils/auth'
|
||||||
|
|
||||||
|
export default defineEventHandler((event) => {
|
||||||
|
return {
|
||||||
|
initialized: isInitialized(),
|
||||||
|
allowRegistration: isAllowRegistration(),
|
||||||
|
legacyMode: !isInitialized() && isAdminSecretConfigured(event),
|
||||||
|
adminSecretConfigured: isAdminSecretConfigured(event),
|
||||||
|
apiUploadTokenConfigured: isApiUploadTokenConfigured(event)
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import {
|
||||||
|
checkAdminSecret,
|
||||||
|
setLegacyAuthCookie
|
||||||
|
} from '../../utils/access'
|
||||||
|
import { createApiError } from '../../utils/api-error'
|
||||||
|
import {
|
||||||
|
createSession,
|
||||||
|
isInitialized,
|
||||||
|
verifyPassword
|
||||||
|
} from '../../utils/auth'
|
||||||
|
import { findUserByUsername } from '../../utils/db'
|
||||||
|
import { getAdminSecret } from '../../utils/env'
|
||||||
|
import { clientIp, logInfo, logWarn } from '../../utils/logger'
|
||||||
|
|
||||||
|
interface LoginBody {
|
||||||
|
username?: string
|
||||||
|
password?: string
|
||||||
|
secret?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const loginAttempts = new Map<string, { count: number, resetAt: number }>()
|
||||||
|
const MAX_ATTEMPTS = 10
|
||||||
|
const WINDOW_MS = 15 * 60 * 1000
|
||||||
|
|
||||||
|
function checkRateLimit(ip: string): void {
|
||||||
|
const now = Date.now()
|
||||||
|
const entry = loginAttempts.get(ip)
|
||||||
|
if (!entry || now > entry.resetAt) {
|
||||||
|
loginAttempts.set(ip, { count: 1, resetAt: now + WINDOW_MS })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
entry.count += 1
|
||||||
|
if (entry.count > MAX_ATTEMPTS) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 429,
|
||||||
|
statusMessage: '登录尝试过于频繁,请稍后再试'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
const ip = clientIp(event)
|
||||||
|
checkRateLimit(ip)
|
||||||
|
|
||||||
|
const body = await readBody<LoginBody>(event)
|
||||||
|
const initialized = isInitialized()
|
||||||
|
|
||||||
|
if (!initialized) {
|
||||||
|
const adminSecret = getAdminSecret(event)
|
||||||
|
if (!adminSecret) {
|
||||||
|
createApiError(event, 'FORBIDDEN', '请先完成系统初始化', 403)
|
||||||
|
}
|
||||||
|
|
||||||
|
const input = body?.secret?.trim()
|
||||||
|
if (!input || !(await checkAdminSecret(input, adminSecret))) {
|
||||||
|
logWarn('legacy login failed: invalid secret', { ip })
|
||||||
|
createApiError(event, 'UNAUTHORIZED', '密钥错误', 401)
|
||||||
|
}
|
||||||
|
|
||||||
|
await setLegacyAuthCookie(event, adminSecret)
|
||||||
|
logInfo('legacy login success', { ip })
|
||||||
|
|
||||||
|
return { success: true, needsMigration: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
const username = body?.username?.trim() ?? ''
|
||||||
|
const password = body?.password ?? ''
|
||||||
|
|
||||||
|
if (!username || !password) {
|
||||||
|
createApiError(event, 'INVALID_REQUEST', '请输入用户名和密码', 400)
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = findUserByUsername(username)
|
||||||
|
if (!user || !verifyPassword(password, user.password_hash)) {
|
||||||
|
logWarn('login failed: invalid credentials', { ip, username })
|
||||||
|
createApiError(event, 'UNAUTHORIZED', '用户名或密码错误', 401)
|
||||||
|
}
|
||||||
|
|
||||||
|
await createSession(event, user.id)
|
||||||
|
logInfo('login success', { ip, username: user.username })
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
user: { id: user.id, username: user.username, role: user.role }
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { destroySession } from '../../utils/auth'
|
||||||
|
import { clearLegacyAuthCookie } from '../../utils/access'
|
||||||
|
import { clientIp, logInfo } from '../../utils/logger'
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
await destroySession(event)
|
||||||
|
clearLegacyAuthCookie(event)
|
||||||
|
logInfo('logout', { ip: clientIp(event) })
|
||||||
|
return { success: true }
|
||||||
|
})
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { getCurrentUser } from '../../utils/auth'
|
||||||
|
import { createApiError } from '../../utils/api-error'
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
const user = await getCurrentUser(event)
|
||||||
|
if (!user) {
|
||||||
|
createApiError(event, 'UNAUTHORIZED', '未登录', 401)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
authenticated: true,
|
||||||
|
user: { id: user.id, username: user.username, role: user.role }
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import {
|
||||||
|
clearLegacyAuthCookie,
|
||||||
|
verifyLegacyAuth
|
||||||
|
} from '../../utils/access'
|
||||||
|
import { createApiError } from '../../utils/api-error'
|
||||||
|
import {
|
||||||
|
createSession,
|
||||||
|
createUserAccount,
|
||||||
|
isInitialized
|
||||||
|
} from '../../utils/auth'
|
||||||
|
import { setAllowRegistration } from '../../utils/db'
|
||||||
|
import { clientIp, logInfo } from '../../utils/logger'
|
||||||
|
|
||||||
|
interface MigrateBody {
|
||||||
|
username?: string
|
||||||
|
password?: string
|
||||||
|
allowRegistration?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
if (isInitialized()) {
|
||||||
|
createApiError(event, 'FORBIDDEN', '系统已初始化', 403)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!(await verifyLegacyAuth(event))) {
|
||||||
|
createApiError(event, 'UNAUTHORIZED', '请先使用管理密钥登录', 401)
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await readBody<MigrateBody>(event)
|
||||||
|
const username = body?.username?.trim() ?? ''
|
||||||
|
const password = body?.password ?? ''
|
||||||
|
|
||||||
|
try {
|
||||||
|
const user = await createUserAccount({
|
||||||
|
username,
|
||||||
|
password,
|
||||||
|
role: 'admin'
|
||||||
|
})
|
||||||
|
|
||||||
|
setAllowRegistration(Boolean(body?.allowRegistration))
|
||||||
|
clearLegacyAuthCookie(event)
|
||||||
|
await createSession(event, user.id)
|
||||||
|
|
||||||
|
logInfo('legacy migration complete', {
|
||||||
|
ip: clientIp(event),
|
||||||
|
username: user.username
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
user: { id: user.id, username: user.username, role: user.role }
|
||||||
|
}
|
||||||
|
} catch (error: unknown) {
|
||||||
|
if (error instanceof Error) {
|
||||||
|
if (error.message === 'INVALID_USERNAME') {
|
||||||
|
createApiError(
|
||||||
|
event,
|
||||||
|
'INVALID_REQUEST',
|
||||||
|
'用户名需为 3–32 位字母、数字或下划线',
|
||||||
|
400
|
||||||
|
)
|
||||||
|
} else if (error.message === 'INVALID_PASSWORD') {
|
||||||
|
createApiError(
|
||||||
|
event,
|
||||||
|
'INVALID_REQUEST',
|
||||||
|
'密码至少 8 位',
|
||||||
|
400
|
||||||
|
)
|
||||||
|
} else if (error.message === 'USERNAME_TAKEN') {
|
||||||
|
createApiError(event, 'INVALID_REQUEST', '用户名已被占用', 400)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { createApiError } from '../../utils/api-error'
|
||||||
|
import { requireUserAuth } from '../../utils/access'
|
||||||
|
import {
|
||||||
|
hashPassword,
|
||||||
|
isValidPassword,
|
||||||
|
verifyPassword
|
||||||
|
} from '../../utils/auth'
|
||||||
|
import { findUserById, updateUserPassword } from '../../utils/db'
|
||||||
|
import { clientIp, logInfo } from '../../utils/logger'
|
||||||
|
|
||||||
|
interface PasswordBody {
|
||||||
|
currentPassword?: string
|
||||||
|
newPassword?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
const authUser = await requireUserAuth(event)
|
||||||
|
const body = await readBody<PasswordBody>(event)
|
||||||
|
const currentPassword = body?.currentPassword ?? ''
|
||||||
|
const newPassword = body?.newPassword ?? ''
|
||||||
|
|
||||||
|
if (!currentPassword || !newPassword) {
|
||||||
|
createApiError(event, 'INVALID_REQUEST', '请填写当前密码和新密码', 400)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isValidPassword(newPassword)) {
|
||||||
|
createApiError(event, 'INVALID_REQUEST', '新密码至少 8 位', 400)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentPassword === newPassword) {
|
||||||
|
createApiError(event, 'INVALID_REQUEST', '新密码不能与当前密码相同', 400)
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = findUserById(authUser.id)
|
||||||
|
if (!user || !verifyPassword(currentPassword, user.password_hash)) {
|
||||||
|
createApiError(event, 'UNAUTHORIZED', '当前密码错误', 401)
|
||||||
|
}
|
||||||
|
|
||||||
|
updateUserPassword(authUser.id, hashPassword(newPassword))
|
||||||
|
logInfo('password changed', { ip: clientIp(event), username: authUser.username })
|
||||||
|
|
||||||
|
return { success: true }
|
||||||
|
})
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import { createApiError } from '../../utils/api-error'
|
||||||
|
import {
|
||||||
|
createSession,
|
||||||
|
createUserAccount,
|
||||||
|
isInitialized
|
||||||
|
} from '../../utils/auth'
|
||||||
|
import { isAllowRegistration } from '../../utils/db'
|
||||||
|
import { clientIp, logInfo } from '../../utils/logger'
|
||||||
|
|
||||||
|
interface RegisterBody {
|
||||||
|
username?: string
|
||||||
|
password?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
if (!isInitialized()) {
|
||||||
|
createApiError(event, 'FORBIDDEN', '请先完成系统初始化', 403)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isAllowRegistration()) {
|
||||||
|
createApiError(event, 'FORBIDDEN', '当前未开放用户注册', 403)
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await readBody<RegisterBody>(event)
|
||||||
|
const username = body?.username?.trim() ?? ''
|
||||||
|
const password = body?.password ?? ''
|
||||||
|
|
||||||
|
try {
|
||||||
|
const user = await createUserAccount({
|
||||||
|
username,
|
||||||
|
password,
|
||||||
|
role: 'user'
|
||||||
|
})
|
||||||
|
|
||||||
|
await createSession(event, user.id)
|
||||||
|
|
||||||
|
logInfo('register success', { ip: clientIp(event), username: user.username })
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
user: { id: user.id, username: user.username, role: user.role }
|
||||||
|
}
|
||||||
|
} catch (error: unknown) {
|
||||||
|
if (error instanceof Error) {
|
||||||
|
if (error.message === 'INVALID_USERNAME') {
|
||||||
|
createApiError(
|
||||||
|
event,
|
||||||
|
'INVALID_REQUEST',
|
||||||
|
'用户名需为 3–32 位字母、数字或下划线',
|
||||||
|
400
|
||||||
|
)
|
||||||
|
} else if (error.message === 'INVALID_PASSWORD') {
|
||||||
|
createApiError(
|
||||||
|
event,
|
||||||
|
'INVALID_REQUEST',
|
||||||
|
'密码至少 8 位',
|
||||||
|
400
|
||||||
|
)
|
||||||
|
} else if (error.message === 'USERNAME_TAKEN') {
|
||||||
|
createApiError(event, 'INVALID_REQUEST', '用户名已被占用', 400)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { isAllowRegistration } from '../../utils/db'
|
||||||
|
import { isAdminSecretConfigured } from '../../utils/env'
|
||||||
|
import {
|
||||||
|
getCurrentUser,
|
||||||
|
isInitialized
|
||||||
|
} from '../../utils/auth'
|
||||||
|
import { verifyLegacyAuth } from '../../utils/access'
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
const initialized = isInitialized()
|
||||||
|
const user = await getCurrentUser(event)
|
||||||
|
const legacyMode = !initialized && isAdminSecretConfigured(event)
|
||||||
|
const needsMigration = legacyMode && (await verifyLegacyAuth(event))
|
||||||
|
|
||||||
|
return {
|
||||||
|
initialized,
|
||||||
|
allowRegistration: isAllowRegistration(),
|
||||||
|
legacyMode,
|
||||||
|
needsMigration,
|
||||||
|
user: user
|
||||||
|
? { id: user.id, username: user.username, role: user.role }
|
||||||
|
: null
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { getImageUserFilter, requireUserAuth } from '../utils/access'
|
||||||
|
import { listFolders, listFoldersForUser } from '../utils/storage'
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
await requireUserAuth(event)
|
||||||
|
const userFilter = await getImageUserFilter(event)
|
||||||
|
const folders = userFilter === 'admin'
|
||||||
|
? await listFolders()
|
||||||
|
: await listFoldersForUser(userFilter)
|
||||||
|
return { folders }
|
||||||
|
})
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
export default defineEventHandler((event) => {
|
||||||
|
const config = useRuntimeConfig(event)
|
||||||
|
return {
|
||||||
|
status: 'ok',
|
||||||
|
version: config.appVersion
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
import type { BatchDeleteResponse } from '~/types/image'
|
||||||
|
import { requireApiOrAdminAuth, assertImageOwnership, resolveActivitySource } from '../../utils/access'
|
||||||
|
import { createApiError } from '../../utils/api-error'
|
||||||
|
import { MAX_DELETE_BATCH } from '../../utils/constants'
|
||||||
|
import { insertActivityLog } from '../../utils/db'
|
||||||
|
import { validateImageKey } from '../../utils/image-key'
|
||||||
|
import { deleteImage, headImage } from '../../utils/storage'
|
||||||
|
import { logInfo } from '../../utils/logger'
|
||||||
|
|
||||||
|
interface BatchDeleteBody {
|
||||||
|
keys?: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST 版本:避免部分环境下 DELETE 请求体丢失
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
try {
|
||||||
|
await requireApiOrAdminAuth(event)
|
||||||
|
|
||||||
|
const body = await readBody<BatchDeleteBody>(event)
|
||||||
|
const keys = body?.keys
|
||||||
|
|
||||||
|
if (!Array.isArray(keys) || !keys.length) {
|
||||||
|
createApiError(event, 'INVALID_REQUEST', '请提供要删除的图片列表', 400)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (keys.length > MAX_DELETE_BATCH) {
|
||||||
|
createApiError(
|
||||||
|
event,
|
||||||
|
'INVALID_REQUEST',
|
||||||
|
`每次最多删除 ${MAX_DELETE_BATCH} 张图片`,
|
||||||
|
400
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const key of keys) {
|
||||||
|
if (!validateImageKey(key)) {
|
||||||
|
createApiError(event, 'INVALID_IMAGE_KEY', `无效的图片路径: ${key}`, 400)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleted: string[] = []
|
||||||
|
const failed: BatchDeleteResponse['failed'] = []
|
||||||
|
|
||||||
|
for (const key of keys) {
|
||||||
|
try {
|
||||||
|
const existing = await headImage(key)
|
||||||
|
|
||||||
|
if (!existing) {
|
||||||
|
failed.push({
|
||||||
|
key,
|
||||||
|
error: { code: 'IMAGE_NOT_FOUND', message: '图片不存在' }
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await assertImageOwnership(event, existing.userId)
|
||||||
|
} catch {
|
||||||
|
failed.push({
|
||||||
|
key,
|
||||||
|
error: { code: 'FORBIDDEN', message: '无权操作此图片' }
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
await deleteImage(key)
|
||||||
|
deleted.push(key)
|
||||||
|
|
||||||
|
insertActivityLog({
|
||||||
|
action: 'delete',
|
||||||
|
key,
|
||||||
|
originalName: existing.originalName,
|
||||||
|
size: existing.size,
|
||||||
|
contentType: existing.contentType,
|
||||||
|
source: resolveActivitySource(event)
|
||||||
|
})
|
||||||
|
|
||||||
|
logInfo('delete success', { key, size: existing.size })
|
||||||
|
} catch {
|
||||||
|
failed.push({
|
||||||
|
key,
|
||||||
|
error: { code: 'DELETE_FAILED', message: '删除失败' }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const response: BatchDeleteResponse = {
|
||||||
|
success: failed.length === 0,
|
||||||
|
deleted,
|
||||||
|
failed
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!deleted.length && failed.length) {
|
||||||
|
setResponseStatus(event, 400)
|
||||||
|
}
|
||||||
|
|
||||||
|
return response
|
||||||
|
} catch (error: unknown) {
|
||||||
|
if (error && typeof error === 'object' && 'statusCode' in error) {
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
createApiError(event, 'DELETE_FAILED', '删除失败', 500)
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { getImageUserFilter, requireApiOrAdminAuth } from '../../utils/access'
|
||||||
|
import { isValidFolderName } from '../../utils/image-key'
|
||||||
|
import { countImages } from '../../utils/storage'
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
await requireApiOrAdminAuth(event)
|
||||||
|
const userFilter = await getImageUserFilter(event)
|
||||||
|
|
||||||
|
const query = getQuery(event)
|
||||||
|
const raw = typeof query.folder === 'string' ? query.folder.trim() : ''
|
||||||
|
const folder = raw && raw !== 'all' && isValidFolderName(raw) ? raw : undefined
|
||||||
|
|
||||||
|
const total = await countImages(folder, userFilter)
|
||||||
|
|
||||||
|
return { total }
|
||||||
|
})
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import type { DeleteResponse } from '~/types/image'
|
||||||
|
import { requireApiOrAdminAuth, assertImageOwnership, resolveActivitySource } from '../../utils/access'
|
||||||
|
import { createApiError } from '../../utils/api-error'
|
||||||
|
import { insertActivityLog } from '../../utils/db'
|
||||||
|
import { validateImageKey } from '../../utils/image-key'
|
||||||
|
import { deleteImage, headImage } from '../../utils/storage'
|
||||||
|
import { logInfo } from '../../utils/logger'
|
||||||
|
|
||||||
|
interface DeleteBody {
|
||||||
|
key?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
try {
|
||||||
|
await requireApiOrAdminAuth(event)
|
||||||
|
|
||||||
|
const query = getQuery(event)
|
||||||
|
const body = await readBody<DeleteBody>(event).catch(() => null)
|
||||||
|
const key = (typeof query.key === 'string' ? query.key : undefined)
|
||||||
|
?? body?.key
|
||||||
|
|
||||||
|
if (!key || !validateImageKey(key)) {
|
||||||
|
createApiError(event, 'INVALID_IMAGE_KEY', '无效的图片路径', 400)
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = await headImage(key)
|
||||||
|
if (!existing) {
|
||||||
|
createApiError(event, 'IMAGE_NOT_FOUND', '图片不存在', 404)
|
||||||
|
}
|
||||||
|
|
||||||
|
await assertImageOwnership(event, existing.userId)
|
||||||
|
|
||||||
|
try {
|
||||||
|
await deleteImage(key)
|
||||||
|
} catch {
|
||||||
|
createApiError(event, 'DELETE_FAILED', '删除失败', 500)
|
||||||
|
}
|
||||||
|
|
||||||
|
insertActivityLog({
|
||||||
|
action: 'delete',
|
||||||
|
key,
|
||||||
|
originalName: existing.originalName,
|
||||||
|
size: existing.size,
|
||||||
|
contentType: existing.contentType,
|
||||||
|
source: resolveActivitySource(event)
|
||||||
|
})
|
||||||
|
|
||||||
|
logInfo('delete success', { key, size: existing.size })
|
||||||
|
|
||||||
|
const response: DeleteResponse = { success: true }
|
||||||
|
return response
|
||||||
|
} catch (error: unknown) {
|
||||||
|
if (error && typeof error === 'object' && 'statusCode' in error) {
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
createApiError(event, 'DELETE_FAILED', '删除失败', 500)
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import type { ImageListResponse } from '~/types/image'
|
||||||
|
import { requireApiOrAdminAuth, getImageUserFilter } from '../../utils/access'
|
||||||
|
import { getCurrentUser } from '../../utils/auth'
|
||||||
|
import {
|
||||||
|
DEFAULT_LIST_LIMIT,
|
||||||
|
MAX_LIST_LIMIT
|
||||||
|
} from '../../utils/constants'
|
||||||
|
import { listUserIdUsernameMap } from '../../utils/db'
|
||||||
|
import { mapStoredImageToItem } from '../../utils/image-response'
|
||||||
|
import { isValidFolderName } from '../../utils/image-key'
|
||||||
|
import { listImages } from '../../utils/storage'
|
||||||
|
|
||||||
|
function readFolderQuery(query: Record<string, unknown>): string | undefined {
|
||||||
|
const raw = typeof query.folder === 'string' ? query.folder.trim() : ''
|
||||||
|
if (!raw || raw === 'all') return undefined
|
||||||
|
return isValidFolderName(raw) ? raw : undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
await requireApiOrAdminAuth(event)
|
||||||
|
|
||||||
|
const query = getQuery(event)
|
||||||
|
|
||||||
|
const limitRaw = Number(query.limit ?? DEFAULT_LIST_LIMIT)
|
||||||
|
const limit = Number.isFinite(limitRaw)
|
||||||
|
? Math.min(Math.max(1, Math.floor(limitRaw)), MAX_LIST_LIMIT)
|
||||||
|
: DEFAULT_LIST_LIMIT
|
||||||
|
|
||||||
|
const pageRaw = Number(query.page ?? 1)
|
||||||
|
const page = Number.isFinite(pageRaw) && pageRaw > 0
|
||||||
|
? Math.floor(pageRaw)
|
||||||
|
: 1
|
||||||
|
|
||||||
|
const folder = readFolderQuery(query)
|
||||||
|
const userFilter = await getImageUserFilter(event)
|
||||||
|
|
||||||
|
const listing = await listImages({ limit, page, folder, userFilter })
|
||||||
|
|
||||||
|
const user = await getCurrentUser(event)
|
||||||
|
const ownerMap = user?.role === 'admin' ? listUserIdUsernameMap() : undefined
|
||||||
|
const items = listing.items.map(stored =>
|
||||||
|
mapStoredImageToItem(event, stored, { ownerMap })
|
||||||
|
)
|
||||||
|
|
||||||
|
items.sort((a, b) => b.uploadedAt.localeCompare(a.uploadedAt))
|
||||||
|
|
||||||
|
const response: ImageListResponse = {
|
||||||
|
items,
|
||||||
|
total: listing.total,
|
||||||
|
page: listing.page,
|
||||||
|
pageSize: listing.pageSize,
|
||||||
|
totalPages: listing.totalPages
|
||||||
|
}
|
||||||
|
|
||||||
|
return response
|
||||||
|
})
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import type { ImageListResponse } from '~/types/image'
|
||||||
|
import { requireApiOrAdminAuth, getImageUserFilter } from '../../utils/access'
|
||||||
|
import { getCurrentUser } from '../../utils/auth'
|
||||||
|
import {
|
||||||
|
DEFAULT_LIST_LIMIT,
|
||||||
|
MAX_LIST_LIMIT
|
||||||
|
} from '../../utils/constants'
|
||||||
|
import { createApiError } from '../../utils/api-error'
|
||||||
|
import { listUserIdUsernameMap } from '../../utils/db'
|
||||||
|
import { mapStoredImageToItem } from '../../utils/image-response'
|
||||||
|
import { isValidFolderName } from '../../utils/image-key'
|
||||||
|
import { searchImages } from '../../utils/storage'
|
||||||
|
|
||||||
|
function readFolderQuery(query: Record<string, unknown>): string | undefined {
|
||||||
|
const raw = typeof query.folder === 'string' ? query.folder.trim() : ''
|
||||||
|
if (!raw || raw === 'all') return undefined
|
||||||
|
return isValidFolderName(raw) ? raw : undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
await requireApiOrAdminAuth(event)
|
||||||
|
|
||||||
|
const query = getQuery(event)
|
||||||
|
const q = typeof query.q === 'string' ? query.q.trim() : ''
|
||||||
|
|
||||||
|
if (!q) {
|
||||||
|
createApiError(event, 'INVALID_REQUEST', '请提供搜索关键词 q', 400)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (q.length > 200) {
|
||||||
|
createApiError(event, 'INVALID_REQUEST', '搜索关键词过长', 400)
|
||||||
|
}
|
||||||
|
|
||||||
|
const limitRaw = Number(query.limit ?? DEFAULT_LIST_LIMIT)
|
||||||
|
const limit = Number.isFinite(limitRaw)
|
||||||
|
? Math.min(Math.max(1, Math.floor(limitRaw)), MAX_LIST_LIMIT)
|
||||||
|
: DEFAULT_LIST_LIMIT
|
||||||
|
|
||||||
|
const pageRaw = Number(query.page ?? 1)
|
||||||
|
const page = Number.isFinite(pageRaw) && pageRaw > 0
|
||||||
|
? Math.floor(pageRaw)
|
||||||
|
: 1
|
||||||
|
|
||||||
|
const folder = readFolderQuery(query)
|
||||||
|
const userFilter = await getImageUserFilter(event)
|
||||||
|
|
||||||
|
const result = await searchImages({ query: q, limit, page, folder, userFilter })
|
||||||
|
|
||||||
|
const user = await getCurrentUser(event)
|
||||||
|
const ownerMap = user?.role === 'admin' ? listUserIdUsernameMap() : undefined
|
||||||
|
const items = result.items.map(stored =>
|
||||||
|
mapStoredImageToItem(event, stored, { ownerMap })
|
||||||
|
)
|
||||||
|
|
||||||
|
items.sort((a, b) => b.uploadedAt.localeCompare(a.uploadedAt))
|
||||||
|
|
||||||
|
const response: ImageListResponse = {
|
||||||
|
items,
|
||||||
|
total: result.total,
|
||||||
|
page: result.page,
|
||||||
|
pageSize: result.pageSize,
|
||||||
|
totalPages: result.totalPages
|
||||||
|
}
|
||||||
|
|
||||||
|
return response
|
||||||
|
})
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
import { readMultipartFormData } from 'h3'
|
||||||
|
import type { UploadResponse, UploadErrorItem, ImageItem } from '~/types/image'
|
||||||
|
import { requireUploadAuth, verifyApiUploadToken, getUploadUserId, canUseCustomUploadFolder } from '../../utils/access'
|
||||||
|
import { MAX_FILES_PER_UPLOAD } from '../../utils/constants'
|
||||||
|
import {
|
||||||
|
detectMimeFromSignature,
|
||||||
|
isAllowedMimeType
|
||||||
|
} from '../../utils/file-signature'
|
||||||
|
import {
|
||||||
|
mimeMatchesDeclared,
|
||||||
|
processSingleImageUpload
|
||||||
|
} from '../../utils/process-image-upload'
|
||||||
|
import { createApiError } from '../../utils/api-error'
|
||||||
|
import {
|
||||||
|
DEFAULT_FOLDER,
|
||||||
|
isValidFolderName,
|
||||||
|
normalizeFolderName
|
||||||
|
} from '../../utils/image-key'
|
||||||
|
|
||||||
|
function readFormText(
|
||||||
|
formData: { name?: string, data?: Buffer | Uint8Array }[],
|
||||||
|
name: string
|
||||||
|
): string {
|
||||||
|
const part = formData.find(item => item.name === name && item.data?.length)
|
||||||
|
if (!part?.data) return ''
|
||||||
|
return new TextDecoder().decode(part.data).trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
await requireUploadAuth(event)
|
||||||
|
|
||||||
|
const source = verifyApiUploadToken(event) ? 'api' : 'web'
|
||||||
|
const uploadUserId = await getUploadUserId(event)
|
||||||
|
|
||||||
|
const formData = await readMultipartFormData(event)
|
||||||
|
if (!formData?.length) {
|
||||||
|
createApiError(event, 'INVALID_REQUEST', '未收到上传文件', 400)
|
||||||
|
}
|
||||||
|
|
||||||
|
const query = getQuery(event)
|
||||||
|
const folderFromForm = readFormText(formData, 'folder') || readFormText(formData, 'type')
|
||||||
|
const folderFromQuery = typeof query.folder === 'string'
|
||||||
|
? query.folder
|
||||||
|
: typeof query.type === 'string'
|
||||||
|
? query.type
|
||||||
|
: ''
|
||||||
|
const requestedFolder = normalizeFolderName(folderFromForm || folderFromQuery)
|
||||||
|
const canCustomFolder = await canUseCustomUploadFolder(event)
|
||||||
|
|
||||||
|
if (!canCustomFolder && requestedFolder && requestedFolder !== DEFAULT_FOLDER) {
|
||||||
|
createApiError(
|
||||||
|
event,
|
||||||
|
'FORBIDDEN',
|
||||||
|
'当前账号不支持自定义上传目录',
|
||||||
|
403
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const folder = canCustomFolder ? requestedFolder : ''
|
||||||
|
|
||||||
|
if (folder && !isValidFolderName(folder)) {
|
||||||
|
createApiError(
|
||||||
|
event,
|
||||||
|
'INVALID_REQUEST',
|
||||||
|
'无效的 folder:仅允许字母数字开头,可含 - _,最长 32 位,且不能是 api/stats 等保留名',
|
||||||
|
400
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 兼容 image / file / files 字段名(图床脚本 / 通用客户端 / 网页多图)
|
||||||
|
const fileParts = formData.filter(
|
||||||
|
part => (part.name === 'image' || part.name === 'file' || part.name === 'files')
|
||||||
|
&& part.data?.length
|
||||||
|
)
|
||||||
|
if (!fileParts.length) {
|
||||||
|
createApiError(event, 'INVALID_REQUEST', '未收到上传文件', 400)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fileParts.length > MAX_FILES_PER_UPLOAD) {
|
||||||
|
createApiError(
|
||||||
|
event,
|
||||||
|
'TOO_MANY_FILES',
|
||||||
|
`每次最多上传 ${MAX_FILES_PER_UPLOAD} 张图片`,
|
||||||
|
400
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const items: ImageItem[] = []
|
||||||
|
const errors: UploadErrorItem[] = []
|
||||||
|
|
||||||
|
for (const part of fileParts) {
|
||||||
|
const originalName = part.filename ?? 'image'
|
||||||
|
const bytes = new Uint8Array(part.data)
|
||||||
|
const declaredMime = part.type?.toLowerCase() ?? ''
|
||||||
|
const detectedMime = detectMimeFromSignature(bytes)
|
||||||
|
|
||||||
|
if (detectedMime && !mimeMatchesDeclared(declaredMime, detectedMime)) {
|
||||||
|
errors.push({
|
||||||
|
name: originalName,
|
||||||
|
error: {
|
||||||
|
code: 'INVALID_FILE_SIGNATURE',
|
||||||
|
message: '文件类型与内容不匹配'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (declaredMime && isAllowedMimeType(declaredMime) && !detectedMime) {
|
||||||
|
errors.push({
|
||||||
|
name: originalName,
|
||||||
|
error: {
|
||||||
|
code: 'INVALID_FILE_SIGNATURE',
|
||||||
|
message: '无法识别的图片格式'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await processSingleImageUpload(event, {
|
||||||
|
bytes,
|
||||||
|
filename: part.filename,
|
||||||
|
source,
|
||||||
|
prefix: folder || DEFAULT_FOLDER,
|
||||||
|
userId: uploadUserId
|
||||||
|
})
|
||||||
|
|
||||||
|
if ('error' in result) {
|
||||||
|
errors.push(result.error)
|
||||||
|
} else {
|
||||||
|
items.push(result.item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const response: UploadResponse = {
|
||||||
|
success: items.length > 0,
|
||||||
|
items,
|
||||||
|
errors
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!items.length && errors.length) {
|
||||||
|
setResponseStatus(event, 400)
|
||||||
|
}
|
||||||
|
|
||||||
|
return response
|
||||||
|
})
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import { requireAdminAuth } from '../../utils/access'
|
||||||
|
import { createApiError } from '../../utils/api-error'
|
||||||
|
import {
|
||||||
|
listActivityLogs,
|
||||||
|
type LogAction,
|
||||||
|
type LogSource
|
||||||
|
} from '../../utils/db'
|
||||||
|
import {
|
||||||
|
DEFAULT_LIST_LIMIT,
|
||||||
|
MAX_LIST_LIMIT
|
||||||
|
} from '../../utils/constants'
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
await requireAdminAuth(event)
|
||||||
|
|
||||||
|
const query = getQuery(event)
|
||||||
|
|
||||||
|
const limitRaw = Number(query.limit ?? DEFAULT_LIST_LIMIT)
|
||||||
|
const limit = Number.isFinite(limitRaw)
|
||||||
|
? Math.min(Math.max(1, Math.floor(limitRaw)), MAX_LIST_LIMIT)
|
||||||
|
: DEFAULT_LIST_LIMIT
|
||||||
|
|
||||||
|
const actionRaw = typeof query.action === 'string' ? query.action : ''
|
||||||
|
const action = (actionRaw === 'upload' || actionRaw === 'delete')
|
||||||
|
? actionRaw as LogAction
|
||||||
|
: undefined
|
||||||
|
|
||||||
|
const sourceRaw = typeof query.source === 'string' ? query.source : ''
|
||||||
|
const source = (sourceRaw === 'web' || sourceRaw === 'api' || sourceRaw === 'twikoo')
|
||||||
|
? sourceRaw as LogSource
|
||||||
|
: undefined
|
||||||
|
|
||||||
|
const folder = typeof query.folder === 'string' && query.folder.trim()
|
||||||
|
? query.folder.trim()
|
||||||
|
: undefined
|
||||||
|
|
||||||
|
const pageRaw = Number(query.page ?? 1)
|
||||||
|
const page = Number.isFinite(pageRaw) && pageRaw > 0
|
||||||
|
? Math.floor(pageRaw)
|
||||||
|
: 1
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = listActivityLogs({ limit, page, action, source, folder })
|
||||||
|
return {
|
||||||
|
items: result.items.map(row => ({
|
||||||
|
id: row.id,
|
||||||
|
action: row.action,
|
||||||
|
key: row.key,
|
||||||
|
originalName: row.original_name,
|
||||||
|
size: row.size,
|
||||||
|
contentType: row.content_type,
|
||||||
|
source: row.source,
|
||||||
|
createdAt: row.created_at
|
||||||
|
})),
|
||||||
|
total: result.total,
|
||||||
|
page: result.page,
|
||||||
|
pageSize: result.pageSize,
|
||||||
|
totalPages: result.totalPages
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[logs] list failed', error)
|
||||||
|
createApiError(event, 'INVALID_REQUEST', '读取操作记录失败', 500)
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { requireAdminAuth } from '../../../utils/access'
|
||||||
|
import { createApiError } from '../../../utils/api-error'
|
||||||
|
import {
|
||||||
|
generateApiUploadToken,
|
||||||
|
setSetting,
|
||||||
|
SETTINGS_API_UPLOAD_TOKEN
|
||||||
|
} from '../../../utils/db'
|
||||||
|
import { isApiUploadTokenEnvConfigured } from '../../../utils/env'
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
await requireAdminAuth(event)
|
||||||
|
|
||||||
|
if (isApiUploadTokenEnvConfigured(event)) {
|
||||||
|
createApiError(
|
||||||
|
event,
|
||||||
|
'CONFLICT',
|
||||||
|
'API_UPLOAD_TOKEN 已在环境变量中配置,无法在后台重新生成',
|
||||||
|
409
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = generateApiUploadToken()
|
||||||
|
setSetting(SETTINGS_API_UPLOAD_TOKEN, token)
|
||||||
|
|
||||||
|
return {
|
||||||
|
apiUploadToken: token,
|
||||||
|
tokenSource: 'db' as const
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { requireAdminAuth } from '../../utils/access'
|
||||||
|
import { getSettingsPayload } from '../../utils/env'
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
await requireAdminAuth(event)
|
||||||
|
return getSettingsPayload(event)
|
||||||
|
})
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
import { requireAdminAuth } from '../../utils/access'
|
||||||
|
import { createApiError } from '../../utils/api-error'
|
||||||
|
import {
|
||||||
|
setSetting,
|
||||||
|
SETTINGS_ALLOWED_REFERER_HOSTS,
|
||||||
|
SETTINGS_IMAGE_BASE_URL,
|
||||||
|
SETTINGS_WEBP_QUALITY,
|
||||||
|
setAllowRegistration
|
||||||
|
} from '../../utils/db'
|
||||||
|
import {
|
||||||
|
getSettingsPayload,
|
||||||
|
isValidImageBaseUrl,
|
||||||
|
isValidRefererHost,
|
||||||
|
MAX_AUTO_DELETE_DAYS,
|
||||||
|
normalizeImageBaseUrl,
|
||||||
|
normalizeRefererHosts,
|
||||||
|
parseAutoDeleteDays,
|
||||||
|
parseWebpQuality,
|
||||||
|
setGlobalAutoDeletePolicy
|
||||||
|
} from '../../utils/env'
|
||||||
|
|
||||||
|
interface SettingsPatchBody {
|
||||||
|
webpQuality?: unknown
|
||||||
|
allowedRefererHosts?: unknown
|
||||||
|
imageBaseUrl?: unknown
|
||||||
|
autoDeleteDays?: unknown
|
||||||
|
allowRegistration?: unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
await requireAdminAuth(event)
|
||||||
|
|
||||||
|
const body = await readBody<SettingsPatchBody>(event).catch(
|
||||||
|
(): SettingsPatchBody => ({})
|
||||||
|
)
|
||||||
|
|
||||||
|
if (body.webpQuality !== undefined) {
|
||||||
|
const quality = parseWebpQuality(String(body.webpQuality))
|
||||||
|
if (quality === null) {
|
||||||
|
createApiError(event, 'INVALID_REQUEST', '压缩质量需为 1–100 的整数', 400)
|
||||||
|
}
|
||||||
|
setSetting(SETTINGS_WEBP_QUALITY, String(quality))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (body.allowedRefererHosts !== undefined) {
|
||||||
|
const raw = typeof body.allowedRefererHosts === 'string'
|
||||||
|
? body.allowedRefererHosts
|
||||||
|
: ''
|
||||||
|
const normalized = normalizeRefererHosts(raw)
|
||||||
|
if (normalized) {
|
||||||
|
const invalid = normalized.split(',').find(host => !isValidRefererHost(host))
|
||||||
|
if (invalid) {
|
||||||
|
createApiError(
|
||||||
|
event,
|
||||||
|
'INVALID_REQUEST',
|
||||||
|
`无效的 Referer 域名:${invalid}`,
|
||||||
|
400
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setSetting(SETTINGS_ALLOWED_REFERER_HOSTS, normalized)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (body.imageBaseUrl !== undefined) {
|
||||||
|
const configured = normalizeImageBaseUrl(
|
||||||
|
typeof body.imageBaseUrl === 'string' ? body.imageBaseUrl : ''
|
||||||
|
)
|
||||||
|
if (configured && !isValidImageBaseUrl(configured)) {
|
||||||
|
createApiError(
|
||||||
|
event,
|
||||||
|
'INVALID_REQUEST',
|
||||||
|
'IMAGE_BASE_URL 需为 http(s) 地址,或留空使用当前请求域名',
|
||||||
|
400
|
||||||
|
)
|
||||||
|
}
|
||||||
|
setSetting(SETTINGS_IMAGE_BASE_URL, configured)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (body.autoDeleteDays !== undefined) {
|
||||||
|
const days = parseAutoDeleteDays(String(body.autoDeleteDays))
|
||||||
|
if (days === null) {
|
||||||
|
createApiError(
|
||||||
|
event,
|
||||||
|
'INVALID_REQUEST',
|
||||||
|
`自动删除天数需为 0–${MAX_AUTO_DELETE_DAYS} 的整数(0 表示关闭)`,
|
||||||
|
400
|
||||||
|
)
|
||||||
|
}
|
||||||
|
setGlobalAutoDeletePolicy(days)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (body.allowRegistration !== undefined) {
|
||||||
|
setAllowRegistration(Boolean(body.allowRegistration))
|
||||||
|
}
|
||||||
|
|
||||||
|
return getSettingsPayload(event)
|
||||||
|
})
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import { createApiError } from '../utils/api-error'
|
||||||
|
import {
|
||||||
|
createSession,
|
||||||
|
createUserAccount,
|
||||||
|
isInitialized
|
||||||
|
} from '../utils/auth'
|
||||||
|
import { setAllowRegistration } from '../utils/db'
|
||||||
|
import { clientIp, logInfo } from '../utils/logger'
|
||||||
|
|
||||||
|
interface SetupBody {
|
||||||
|
username?: string
|
||||||
|
password?: string
|
||||||
|
allowRegistration?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
if (isInitialized()) {
|
||||||
|
createApiError(event, 'FORBIDDEN', '系统已初始化', 403)
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await readBody<SetupBody>(event)
|
||||||
|
const username = body?.username?.trim() ?? ''
|
||||||
|
const password = body?.password ?? ''
|
||||||
|
|
||||||
|
try {
|
||||||
|
const user = await createUserAccount({
|
||||||
|
username,
|
||||||
|
password,
|
||||||
|
role: 'admin'
|
||||||
|
})
|
||||||
|
|
||||||
|
setAllowRegistration(Boolean(body?.allowRegistration))
|
||||||
|
await createSession(event, user.id)
|
||||||
|
|
||||||
|
logInfo('setup complete', { ip: clientIp(event), username: user.username })
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
user: { id: user.id, username: user.username, role: user.role }
|
||||||
|
}
|
||||||
|
} catch (error: unknown) {
|
||||||
|
if (error instanceof Error) {
|
||||||
|
if (error.message === 'INVALID_USERNAME') {
|
||||||
|
createApiError(
|
||||||
|
event,
|
||||||
|
'INVALID_REQUEST',
|
||||||
|
'用户名需为 3–32 位字母、数字或下划线',
|
||||||
|
400
|
||||||
|
)
|
||||||
|
} else if (error.message === 'INVALID_PASSWORD') {
|
||||||
|
createApiError(
|
||||||
|
event,
|
||||||
|
'INVALID_REQUEST',
|
||||||
|
'密码至少 8 位',
|
||||||
|
400
|
||||||
|
)
|
||||||
|
} else if (error.message === 'USERNAME_TAKEN') {
|
||||||
|
createApiError(event, 'INVALID_REQUEST', '用户名已被占用', 400)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import { getImageUserFilter, requireUserAuth } from '../utils/access'
|
||||||
|
import { createApiError } from '../utils/api-error'
|
||||||
|
import { countUsers, getActivityStats } from '../utils/db'
|
||||||
|
import {
|
||||||
|
countImages,
|
||||||
|
getFolderStorageStats,
|
||||||
|
getUserScopedStorageStats
|
||||||
|
} from '../utils/storage'
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
const user = await requireUserAuth(event)
|
||||||
|
const userFilter = await getImageUserFilter(event)
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (user.role === 'admin' && userFilter === 'admin') {
|
||||||
|
const [activity, storedCount, byFolder] = await Promise.all([
|
||||||
|
Promise.resolve(getActivityStats()),
|
||||||
|
countImages(),
|
||||||
|
getFolderStorageStats()
|
||||||
|
])
|
||||||
|
|
||||||
|
return {
|
||||||
|
...activity,
|
||||||
|
storedCount,
|
||||||
|
byFolder,
|
||||||
|
userCount: countUsers()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const scoped = await getUserScopedStorageStats(user.id)
|
||||||
|
|
||||||
|
return {
|
||||||
|
uploadToday: scoped.uploadToday,
|
||||||
|
uploadMonth: scoped.uploadMonth,
|
||||||
|
deleteToday: 0,
|
||||||
|
deleteMonth: 0,
|
||||||
|
uploadTotal: scoped.storedCount,
|
||||||
|
deleteTotal: 0,
|
||||||
|
uploadBytesTotal: scoped.uploadBytesTotal,
|
||||||
|
bySource: { web: scoped.storedCount, api: 0, twikoo: 0 },
|
||||||
|
byFolderUploads: scoped.byFolder.map(item => ({
|
||||||
|
folder: item.folder,
|
||||||
|
count: item.count
|
||||||
|
})),
|
||||||
|
storedCount: scoped.storedCount,
|
||||||
|
byFolder: scoped.byFolder
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[stats] failed', error)
|
||||||
|
createApiError(event, 'INVALID_REQUEST', '读取统计失败', 500)
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { requireUserAuth } from '../../utils/access'
|
||||||
|
import { ensureUserApiToken } from '../../utils/db'
|
||||||
|
import { getImageBaseUrl } from '../../utils/env'
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
const user = await requireUserAuth(event)
|
||||||
|
const config = useRuntimeConfig(event)
|
||||||
|
|
||||||
|
return {
|
||||||
|
apiUploadToken: ensureUserApiToken(user.id),
|
||||||
|
tokenSource: 'db' as const,
|
||||||
|
envTokenOverride: false,
|
||||||
|
env: {
|
||||||
|
webpQuality: 80,
|
||||||
|
refererConfigured: false,
|
||||||
|
imageBaseUrl: getImageBaseUrl(event),
|
||||||
|
appVersion: config.appVersion as string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { requireUserAuth } from '../../../utils/access'
|
||||||
|
import { generateApiUploadToken, setUserApiToken } from '../../../utils/db'
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
const user = await requireUserAuth(event)
|
||||||
|
const token = generateApiUploadToken()
|
||||||
|
setUserApiToken(user.id, token)
|
||||||
|
|
||||||
|
return { apiUploadToken: token }
|
||||||
|
})
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { requireUserAuth } from '../../utils/access'
|
||||||
|
import { getUserAutoDeletePolicy } from '../../utils/db'
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
const user = await requireUserAuth(event)
|
||||||
|
const policy = getUserAutoDeletePolicy(user.id)
|
||||||
|
|
||||||
|
return {
|
||||||
|
autoDeleteDays: policy.days
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { requireUserAuth } from '../../utils/access'
|
||||||
|
import { createApiError } from '../../utils/api-error'
|
||||||
|
import { getUserAutoDeletePolicy, setUserAutoDeletePolicy } from '../../utils/db'
|
||||||
|
import { MAX_AUTO_DELETE_DAYS, parseAutoDeleteDays } from '../../utils/env'
|
||||||
|
|
||||||
|
interface UserSettingsPatchBody {
|
||||||
|
autoDeleteDays?: unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
const user = await requireUserAuth(event)
|
||||||
|
|
||||||
|
const body = await readBody<UserSettingsPatchBody>(event).catch(
|
||||||
|
(): UserSettingsPatchBody => ({})
|
||||||
|
)
|
||||||
|
|
||||||
|
if (body.autoDeleteDays !== undefined) {
|
||||||
|
const days = parseAutoDeleteDays(String(body.autoDeleteDays))
|
||||||
|
if (days === null) {
|
||||||
|
createApiError(
|
||||||
|
event,
|
||||||
|
'INVALID_REQUEST',
|
||||||
|
`自动删除天数需为 0–${MAX_AUTO_DELETE_DAYS} 的整数(0 表示关闭)`,
|
||||||
|
400
|
||||||
|
)
|
||||||
|
}
|
||||||
|
setUserAutoDeletePolicy(user.id, days)
|
||||||
|
}
|
||||||
|
|
||||||
|
const policy = getUserAutoDeletePolicy(user.id)
|
||||||
|
return {
|
||||||
|
autoDeleteDays: policy.days
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { requestPathToImageKey, serveImageByKey } from '../utils/serve-image'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 仅当路径像图片 key({type}/YYYY/MM/file.ext)时才接管。
|
||||||
|
* 其它请求(/_nuxt、/api、页面)原样放行。
|
||||||
|
*/
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
const method = getMethod(event)
|
||||||
|
if (method !== 'GET' && method !== 'HEAD') return
|
||||||
|
|
||||||
|
const key = requestPathToImageKey(getRequestURL(event).pathname)
|
||||||
|
if (!key) return
|
||||||
|
|
||||||
|
return serveImageByKey(event, key)
|
||||||
|
})
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { clientIp, logInfo } from '../utils/logger'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 只记录 /api/*,避免 /images、/twikoo 直链刷屏。
|
||||||
|
*/
|
||||||
|
export default defineNitroPlugin((nitroApp) => {
|
||||||
|
nitroApp.hooks.hook('request', (event) => {
|
||||||
|
event.context._reqStartedAt = Date.now()
|
||||||
|
})
|
||||||
|
|
||||||
|
nitroApp.hooks.hook('afterResponse', (event) => {
|
||||||
|
const path = getRequestURL(event).pathname
|
||||||
|
if (!path.startsWith('/api/')) return
|
||||||
|
|
||||||
|
// 高频探测接口降噪
|
||||||
|
if (path === '/api/auth/me' || path === '/api/auth/config') return
|
||||||
|
|
||||||
|
const method = getMethod(event)
|
||||||
|
const status = getResponseStatus(event)
|
||||||
|
const started = event.context._reqStartedAt as number | undefined
|
||||||
|
const ms = started ? Date.now() - started : undefined
|
||||||
|
|
||||||
|
logInfo(`${method} ${path}`, {
|
||||||
|
status,
|
||||||
|
ms,
|
||||||
|
ip: clientIp(event)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { getDataDir } from '../utils/storage'
|
||||||
|
import { runAutoDeleteCleanup } from '../utils/auto-delete'
|
||||||
|
import { logInfo } from '../utils/logger'
|
||||||
|
|
||||||
|
export default defineNitroPlugin(() => {
|
||||||
|
const port = process.env.NITRO_PORT || process.env.PORT || '6892'
|
||||||
|
logInfo('PicHost starting', {
|
||||||
|
port,
|
||||||
|
dataDir: getDataDir(),
|
||||||
|
nodeEnv: process.env.NODE_ENV || 'development',
|
||||||
|
imageBaseUrl: process.env.IMAGE_BASE_URL || '(request origin)',
|
||||||
|
logLevel: process.env.LOG_LEVEL || 'info'
|
||||||
|
})
|
||||||
|
|
||||||
|
void runAutoDeleteCleanup().then((result) => {
|
||||||
|
if (!result.skipped && (result.deleted > 0 || result.failed > 0)) {
|
||||||
|
logInfo('startup auto-delete', { ...result })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
import { readMultipartFormData } from 'h3'
|
||||||
|
import { verifyUploadTokenValue } from '../../utils/access'
|
||||||
|
import {
|
||||||
|
detectMimeFromSignature,
|
||||||
|
isAllowedMimeType
|
||||||
|
} from '../../utils/file-signature'
|
||||||
|
import {
|
||||||
|
mimeMatchesDeclared,
|
||||||
|
processSingleImageUpload
|
||||||
|
} from '../../utils/process-image-upload'
|
||||||
|
import { getApiUploadToken } from '../../utils/env'
|
||||||
|
|
||||||
|
/** EasyImages 2.0 兼容上传(Twikoo IMAGE_CDN=easyimage) */
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
setHeader(event, 'Content-Type', 'application/json; charset=utf-8')
|
||||||
|
|
||||||
|
const config = useRuntimeConfig(event)
|
||||||
|
const devBypass = import.meta.dev && config.devBypassAccess
|
||||||
|
|
||||||
|
const formData = await readMultipartFormData(event)
|
||||||
|
if (!formData?.length) {
|
||||||
|
return easyImageError(400, '未收到上传数据')
|
||||||
|
}
|
||||||
|
|
||||||
|
const tokenField = formData.find(part => part.name === 'token')
|
||||||
|
const token = tokenField?.data?.length
|
||||||
|
? new TextDecoder().decode(tokenField.data).trim()
|
||||||
|
: ''
|
||||||
|
|
||||||
|
if (!devBypass) {
|
||||||
|
if (!getApiUploadToken(event)) {
|
||||||
|
return easyImageError(500, '服务端未配置 API_UPLOAD_TOKEN')
|
||||||
|
}
|
||||||
|
if (!verifyUploadTokenValue(event, token)) {
|
||||||
|
return easyImageError(401, 'token 错误或无效')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const imagePart = formData.find(
|
||||||
|
part => part.name === 'image' && part.data?.length
|
||||||
|
)
|
||||||
|
if (!imagePart?.data?.length) {
|
||||||
|
return easyImageError(400, '未收到图片文件')
|
||||||
|
}
|
||||||
|
|
||||||
|
const bytes = new Uint8Array(imagePart.data)
|
||||||
|
const declaredMime = imagePart.type?.toLowerCase() ?? ''
|
||||||
|
const detectedMime = detectMimeFromSignature(bytes)
|
||||||
|
|
||||||
|
if (detectedMime && !mimeMatchesDeclared(declaredMime, detectedMime)) {
|
||||||
|
return easyImageError(415, '文件类型与内容不匹配')
|
||||||
|
}
|
||||||
|
if (declaredMime && isAllowedMimeType(declaredMime) && !detectedMime) {
|
||||||
|
return easyImageError(415, '无法识别的图片格式')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Twikoo 评论图片单独存放在 twikoo/ 目录下
|
||||||
|
const result = await processSingleImageUpload(event, {
|
||||||
|
bytes,
|
||||||
|
filename: imagePart.filename,
|
||||||
|
prefix: 'twikoo',
|
||||||
|
source: 'twikoo'
|
||||||
|
})
|
||||||
|
|
||||||
|
if ('error' in result) {
|
||||||
|
const code = easyImageCodeFromError(result.error.error.code)
|
||||||
|
return easyImageError(code, result.error.error.message)
|
||||||
|
}
|
||||||
|
|
||||||
|
const { item } = result
|
||||||
|
return {
|
||||||
|
result: 'success',
|
||||||
|
code: 200,
|
||||||
|
url: item.url,
|
||||||
|
srcName: item.originalName,
|
||||||
|
thumb: item.url,
|
||||||
|
del: ''
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
function easyImageError(code: number, message: string) {
|
||||||
|
return {
|
||||||
|
result: 'failed',
|
||||||
|
code,
|
||||||
|
message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function easyImageCodeFromError(code: string): number {
|
||||||
|
switch (code) {
|
||||||
|
case 'FILE_TOO_LARGE':
|
||||||
|
return 413
|
||||||
|
case 'UNSUPPORTED_FILE_TYPE':
|
||||||
|
case 'INVALID_FILE_SIGNATURE':
|
||||||
|
return 415
|
||||||
|
case 'UPLOAD_FAILED':
|
||||||
|
return 500
|
||||||
|
default:
|
||||||
|
return 400
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { runAutoDeleteCleanup } from '../utils/auto-delete'
|
||||||
|
|
||||||
|
export default defineTask({
|
||||||
|
meta: {
|
||||||
|
name: 'auto-delete',
|
||||||
|
description: 'Delete images older than auto_delete_days'
|
||||||
|
},
|
||||||
|
async run() {
|
||||||
|
return { result: await runAutoDeleteCleanup() }
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { validateImageKey } from './image-key'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mirrors assertImageOwnership rules (access.ts) for documentation + regression.
|
||||||
|
*/
|
||||||
|
function canOperateImage(
|
||||||
|
actor: { type: 'user', id: number, role: 'user' | 'admin' }
|
||||||
|
| { type: 'user-token', id: number }
|
||||||
|
| { type: 'global-token' },
|
||||||
|
ownerId: number | null
|
||||||
|
): boolean {
|
||||||
|
if (actor.type === 'global-token') return true
|
||||||
|
if (actor.type === 'user-token') {
|
||||||
|
return ownerId === actor.id
|
||||||
|
}
|
||||||
|
if (actor.role === 'admin') return true
|
||||||
|
return ownerId !== null && ownerId === actor.id
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('image ownership rules', () => {
|
||||||
|
it('regular user can only operate own images', () => {
|
||||||
|
const user1 = { type: 'user' as const, id: 1, role: 'user' as const }
|
||||||
|
expect(canOperateImage(user1, 1)).toBe(true)
|
||||||
|
expect(canOperateImage(user1, 2)).toBe(false)
|
||||||
|
expect(canOperateImage(user1, null)).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('admin can operate any image', () => {
|
||||||
|
const admin = { type: 'user' as const, id: 1, role: 'admin' as const }
|
||||||
|
expect(canOperateImage(admin, 2)).toBe(true)
|
||||||
|
expect(canOperateImage(admin, null)).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('user API token is scoped to owner', () => {
|
||||||
|
const token = { type: 'user-token' as const, id: 2 }
|
||||||
|
expect(canOperateImage(token, 2)).toBe(true)
|
||||||
|
expect(canOperateImage(token, 1)).toBe(false)
|
||||||
|
expect(canOperateImage(token, null)).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('global API token can operate any image', () => {
|
||||||
|
const token = { type: 'global-token' as const }
|
||||||
|
expect(canOperateImage(token, 1)).toBe(true)
|
||||||
|
expect(canOperateImage(token, null)).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('path traversal hardening', () => {
|
||||||
|
it('rejects keys that could escape data dir', () => {
|
||||||
|
const attacks = [
|
||||||
|
'images/2026/08/../secret.webp',
|
||||||
|
'images/../../../etc/passwd',
|
||||||
|
'images//2026/08/a.webp',
|
||||||
|
'/images/2026/08/a.webp',
|
||||||
|
'images/2026/08/%2e%2e/other.webp'
|
||||||
|
]
|
||||||
|
for (const key of attacks) {
|
||||||
|
expect(validateImageKey(key), key).toBe(false)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { createLegacySessionToken } from './access'
|
||||||
|
|
||||||
|
describe('createLegacySessionToken', () => {
|
||||||
|
it('returns a stable hex digest for the same secret', async () => {
|
||||||
|
const first = await createLegacySessionToken('test-secret')
|
||||||
|
const second = await createLegacySessionToken('test-secret')
|
||||||
|
expect(first).toBe(second)
|
||||||
|
expect(first).toMatch(/^[0-9a-f]{64}$/)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('changes when the secret changes', async () => {
|
||||||
|
const a = await createLegacySessionToken('secret-a')
|
||||||
|
const b = await createLegacySessionToken('secret-b')
|
||||||
|
expect(a).not.toBe(b)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,256 @@
|
|||||||
|
import type { H3Event } from 'h3'
|
||||||
|
import { findUserByApiToken } from './db'
|
||||||
|
import { getAdminSecret, getApiUploadToken } from './env'
|
||||||
|
import { createApiError } from './api-error'
|
||||||
|
import {
|
||||||
|
getCurrentUser,
|
||||||
|
type AuthUser
|
||||||
|
} from './auth'
|
||||||
|
|
||||||
|
const LEGACY_AUTH_COOKIE = 'pic_auth'
|
||||||
|
const LEGACY_SESSION_SALT = 'pic-session'
|
||||||
|
const LEGACY_COOKIE_MAX_AGE = 60 * 60 * 24 * 7
|
||||||
|
|
||||||
|
function timingSafeEqual(a: string, b: string): boolean {
|
||||||
|
if (a.length !== b.length) return false
|
||||||
|
let result = 0
|
||||||
|
for (let i = 0; i < a.length; i++) {
|
||||||
|
result |= a.charCodeAt(i) ^ b.charCodeAt(i)
|
||||||
|
}
|
||||||
|
return result === 0
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createLegacySessionToken(secret: string): Promise<string> {
|
||||||
|
const encoder = new TextEncoder()
|
||||||
|
const key = await crypto.subtle.importKey(
|
||||||
|
'raw',
|
||||||
|
encoder.encode(secret),
|
||||||
|
{ name: 'HMAC', hash: 'SHA-256' },
|
||||||
|
false,
|
||||||
|
['sign']
|
||||||
|
)
|
||||||
|
const sig = await crypto.subtle.sign('HMAC', key, encoder.encode(LEGACY_SESSION_SALT))
|
||||||
|
return Array.from(new Uint8Array(sig), byte => byte.toString(16).padStart(2, '0')).join('')
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setLegacyAuthCookie(event: H3Event, secret: string): Promise<void> {
|
||||||
|
const token = await createLegacySessionToken(secret)
|
||||||
|
const isHttps = getRequestURL(event, {
|
||||||
|
xForwardedHost: true,
|
||||||
|
xForwardedProto: true
|
||||||
|
}).protocol === 'https:'
|
||||||
|
setCookie(event, LEGACY_AUTH_COOKIE, token, {
|
||||||
|
httpOnly: true,
|
||||||
|
secure: isHttps,
|
||||||
|
sameSite: 'lax',
|
||||||
|
path: '/',
|
||||||
|
maxAge: LEGACY_COOKIE_MAX_AGE
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearLegacyAuthCookie(event: H3Event): void {
|
||||||
|
deleteCookie(event, LEGACY_AUTH_COOKIE, { path: '/' })
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function verifyLegacyAuth(event: H3Event): Promise<boolean> {
|
||||||
|
const adminSecret = getAdminSecret(event)
|
||||||
|
if (!adminSecret) return false
|
||||||
|
|
||||||
|
const cookie = getCookie(event, LEGACY_AUTH_COOKIE)
|
||||||
|
if (!cookie) return false
|
||||||
|
|
||||||
|
const expected = await createLegacySessionToken(adminSecret)
|
||||||
|
return timingSafeEqual(cookie, expected)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function checkAdminSecret(
|
||||||
|
input: string,
|
||||||
|
secret: string
|
||||||
|
): Promise<boolean> {
|
||||||
|
return timingSafeEqual(input, secret)
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractApiToken(event: H3Event): string {
|
||||||
|
const token = getHeader(event, 'auth-token')
|
||||||
|
return token?.trim() ?? ''
|
||||||
|
}
|
||||||
|
|
||||||
|
export function verifyGlobalApiUploadToken(event: H3Event): boolean {
|
||||||
|
const expected = getApiUploadToken(event)
|
||||||
|
if (!expected) return false
|
||||||
|
|
||||||
|
const token = extractApiToken(event)
|
||||||
|
if (!token) return false
|
||||||
|
|
||||||
|
return timingSafeEqual(token, expected)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveUserIdFromApiToken(event: H3Event): number | null {
|
||||||
|
const token = extractApiToken(event)
|
||||||
|
if (!token) return null
|
||||||
|
return findUserByApiToken(token)?.id ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function verifyApiUploadToken(event: H3Event): boolean {
|
||||||
|
return verifyGlobalApiUploadToken(event)
|
||||||
|
|| resolveUserIdFromApiToken(event) !== null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** EasyImage 等图床在表单字段 token 中传 API_UPLOAD_TOKEN */
|
||||||
|
export function verifyUploadTokenValue(event: H3Event, token: string): boolean {
|
||||||
|
const expected = getApiUploadToken(event)
|
||||||
|
if (expected && token && timingSafeEqual(token, expected)) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return findUserByApiToken(token) !== null
|
||||||
|
}
|
||||||
|
|
||||||
|
function devBypass(event: H3Event): boolean {
|
||||||
|
const config = useRuntimeConfig(event)
|
||||||
|
return import.meta.dev && Boolean(config.devBypassAccess)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function requireUserAuth(event: H3Event): Promise<AuthUser> {
|
||||||
|
if (devBypass(event)) {
|
||||||
|
return { id: 0, username: 'dev', role: 'admin' }
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await getCurrentUser(event)
|
||||||
|
if (!user) {
|
||||||
|
createApiError(event, 'UNAUTHORIZED', '未授权访问,请先登录', 401)
|
||||||
|
}
|
||||||
|
|
||||||
|
return user
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function requireAdminAuth(event: H3Event): Promise<AuthUser> {
|
||||||
|
const user = await requireUserAuth(event)
|
||||||
|
if (user.role !== 'admin') {
|
||||||
|
createApiError(event, 'FORBIDDEN', '需要管理员权限', 403)
|
||||||
|
}
|
||||||
|
return user
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function requireUploadAuth(event: H3Event): Promise<void> {
|
||||||
|
if (devBypass(event)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (verifyApiUploadToken(event)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await getCurrentUser(event)
|
||||||
|
if (!user) {
|
||||||
|
createApiError(event, 'UNAUTHORIZED', '未授权访问,请先登录', 401)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 图库读删等管理接口:API Token 或网页登录 Session */
|
||||||
|
export async function requireApiOrAdminAuth(event: H3Event): Promise<void> {
|
||||||
|
if (devBypass(event)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (verifyApiUploadToken(event)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await getCurrentUser(event)
|
||||||
|
if (!user) {
|
||||||
|
createApiError(event, 'UNAUTHORIZED', '未授权访问,请先登录', 401)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 列表/搜索/删除时按用户过滤;全局 Token 视为管理员,用户 Token 仅见自己的 */
|
||||||
|
export async function getImageUserFilter(
|
||||||
|
event: H3Event
|
||||||
|
): Promise<number | 'admin'> {
|
||||||
|
if (devBypass(event)) {
|
||||||
|
return 'admin'
|
||||||
|
}
|
||||||
|
|
||||||
|
const userIdFromToken = resolveUserIdFromApiToken(event)
|
||||||
|
if (userIdFromToken !== null) {
|
||||||
|
return userIdFromToken
|
||||||
|
}
|
||||||
|
|
||||||
|
if (verifyGlobalApiUploadToken(event)) {
|
||||||
|
return 'admin'
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await requireUserAuth(event)
|
||||||
|
return user.role === 'admin' ? 'admin' : user.id
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function assertImageOwnership(
|
||||||
|
event: H3Event,
|
||||||
|
userId: number | null | undefined
|
||||||
|
): Promise<void> {
|
||||||
|
if (devBypass(event)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const userIdFromToken = resolveUserIdFromApiToken(event)
|
||||||
|
if (userIdFromToken !== null) {
|
||||||
|
const ownerId = userId ?? null
|
||||||
|
if (ownerId !== userIdFromToken) {
|
||||||
|
createApiError(event, 'FORBIDDEN', '无权操作此图片', 403)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (verifyGlobalApiUploadToken(event)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await requireUserAuth(event)
|
||||||
|
if (user.role === 'admin') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const ownerId = userId ?? null
|
||||||
|
if (ownerId === null || ownerId !== user.id) {
|
||||||
|
createApiError(event, 'FORBIDDEN', '无权操作此图片', 403)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveActivitySource(event: H3Event): 'api' | 'web' {
|
||||||
|
return verifyApiUploadToken(event) ? 'api' : 'web'
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getUploadUserId(event: H3Event): Promise<number | null> {
|
||||||
|
if (devBypass(event)) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const userIdFromToken = resolveUserIdFromApiToken(event)
|
||||||
|
if (userIdFromToken !== null) {
|
||||||
|
return userIdFromToken
|
||||||
|
}
|
||||||
|
|
||||||
|
if (verifyGlobalApiUploadToken(event)) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await getCurrentUser(event)
|
||||||
|
return user?.id ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 管理员会话 / 全局 API Token 可使用自定义上传目录 */
|
||||||
|
export async function canUseCustomUploadFolder(event: H3Event): Promise<boolean> {
|
||||||
|
if (devBypass(event)) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
if (verifyGlobalApiUploadToken(event)) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
if (resolveUserIdFromApiToken(event) !== null) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await getCurrentUser(event)
|
||||||
|
return user?.role === 'admin'
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import type { H3Event } from 'h3'
|
||||||
|
import type { ApiError, ErrorCode } from '~/types/image'
|
||||||
|
|
||||||
|
export function createApiError(
|
||||||
|
event: H3Event,
|
||||||
|
code: ErrorCode,
|
||||||
|
message: string,
|
||||||
|
statusCode = 400
|
||||||
|
): never {
|
||||||
|
throw createError({
|
||||||
|
statusCode,
|
||||||
|
statusMessage: message,
|
||||||
|
data: {
|
||||||
|
success: false,
|
||||||
|
error: { code, message } satisfies ApiError
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isApiErrorResponse(data: unknown): data is { error: ApiError } {
|
||||||
|
return typeof data === 'object'
|
||||||
|
&& data !== null
|
||||||
|
&& 'error' in data
|
||||||
|
&& typeof (data as { error: unknown }).error === 'object'
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { hashPassword, isValidPassword, isValidUsername, verifyPassword } from './auth'
|
||||||
|
|
||||||
|
describe('auth password', () => {
|
||||||
|
it('hashes and verifies a password', () => {
|
||||||
|
const hash = hashPassword('test-password-123')
|
||||||
|
expect(hash.startsWith('scrypt$')).toBe(true)
|
||||||
|
expect(verifyPassword('test-password-123', hash)).toBe(true)
|
||||||
|
expect(verifyPassword('wrong-password', hash)).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects invalid passwords', () => {
|
||||||
|
expect(isValidPassword('short')).toBe(false)
|
||||||
|
expect(isValidPassword('long-enough')).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('validates usernames', () => {
|
||||||
|
expect(isValidUsername('ab')).toBe(false)
|
||||||
|
expect(isValidUsername('user_1')).toBe(true)
|
||||||
|
expect(isValidUsername('bad name')).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,197 @@
|
|||||||
|
import { randomBytes, scryptSync, timingSafeEqual, createHash } from 'node:crypto'
|
||||||
|
import type { H3Event } from 'h3'
|
||||||
|
import {
|
||||||
|
countUsers,
|
||||||
|
createSessionRow,
|
||||||
|
deleteSession,
|
||||||
|
findSessionById,
|
||||||
|
findUserById,
|
||||||
|
findUserByUsername,
|
||||||
|
insertUser,
|
||||||
|
type UserRole,
|
||||||
|
type UserRow
|
||||||
|
} from './db'
|
||||||
|
|
||||||
|
const SCRYPT_N = 16384
|
||||||
|
const SCRYPT_R = 8
|
||||||
|
const SCRYPT_P = 1
|
||||||
|
const SCRYPT_KEY_LEN = 64
|
||||||
|
const MIN_PASSWORD_LENGTH = 8
|
||||||
|
const USERNAME_PATTERN = /^[a-zA-Z0-9_]{3,32}$/
|
||||||
|
|
||||||
|
export const SESSION_COOKIE = 'pic_session'
|
||||||
|
const SESSION_MAX_AGE_SEC = 60 * 60 * 24 * 7 // 7 days
|
||||||
|
|
||||||
|
export interface AuthUser {
|
||||||
|
id: number
|
||||||
|
username: string
|
||||||
|
role: UserRole
|
||||||
|
}
|
||||||
|
|
||||||
|
function timingSafeEqualStr(a: string, b: string): boolean {
|
||||||
|
if (a.length !== b.length) return false
|
||||||
|
return timingSafeEqual(Buffer.from(a), Buffer.from(b))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isValidUsername(username: string): boolean {
|
||||||
|
return USERNAME_PATTERN.test(username)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isValidPassword(password: string): boolean {
|
||||||
|
return password.length >= MIN_PASSWORD_LENGTH
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hashPassword(password: string): string {
|
||||||
|
const salt = randomBytes(16).toString('hex')
|
||||||
|
const derived = scryptSync(password, salt, SCRYPT_KEY_LEN, {
|
||||||
|
N: SCRYPT_N,
|
||||||
|
r: SCRYPT_R,
|
||||||
|
p: SCRYPT_P
|
||||||
|
})
|
||||||
|
return `scrypt$${SCRYPT_N}$${SCRYPT_R}$${SCRYPT_P}$${salt}$${derived.toString('hex')}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function verifyPassword(password: string, stored: string): boolean {
|
||||||
|
const parts = stored.split('$')
|
||||||
|
if (parts.length !== 6 || parts[0] !== 'scrypt') return false
|
||||||
|
|
||||||
|
const N = Number(parts[1])
|
||||||
|
const r = Number(parts[2])
|
||||||
|
const p = Number(parts[3])
|
||||||
|
const salt = parts[4]
|
||||||
|
const hashHex = parts[5]
|
||||||
|
if (!salt || !hashHex || !Number.isFinite(N)) return false
|
||||||
|
|
||||||
|
try {
|
||||||
|
const derived = scryptSync(password, salt, SCRYPT_KEY_LEN, { N, r, p })
|
||||||
|
return timingSafeEqualStr(derived.toString('hex'), hashHex)
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function hashSessionToken(token: string): string {
|
||||||
|
return createHash('sha256').update(token).digest('hex')
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateSessionToken(): string {
|
||||||
|
return randomBytes(32).toString('hex')
|
||||||
|
}
|
||||||
|
|
||||||
|
function sessionExpiresAt(): string {
|
||||||
|
return new Date(Date.now() + SESSION_MAX_AGE_SEC * 1000).toISOString()
|
||||||
|
}
|
||||||
|
|
||||||
|
function cookieSecure(event: H3Event): boolean {
|
||||||
|
return getRequestURL(event, {
|
||||||
|
xForwardedHost: true,
|
||||||
|
xForwardedProto: true
|
||||||
|
}).protocol === 'https:'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setSessionCookie(event: H3Event, sessionId: string): void {
|
||||||
|
setCookie(event, SESSION_COOKIE, sessionId, {
|
||||||
|
httpOnly: true,
|
||||||
|
secure: cookieSecure(event),
|
||||||
|
sameSite: 'lax',
|
||||||
|
path: '/',
|
||||||
|
maxAge: SESSION_MAX_AGE_SEC
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearSessionCookie(event: H3Event): void {
|
||||||
|
deleteCookie(event, SESSION_COOKIE, { path: '/' })
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createSession(
|
||||||
|
event: H3Event,
|
||||||
|
userId: number
|
||||||
|
): Promise<string> {
|
||||||
|
const sessionId = randomBytes(24).toString('hex')
|
||||||
|
const token = generateSessionToken()
|
||||||
|
const tokenHash = hashSessionToken(token)
|
||||||
|
const expiresAt = sessionExpiresAt()
|
||||||
|
const createdAt = new Date().toISOString()
|
||||||
|
|
||||||
|
createSessionRow({
|
||||||
|
id: sessionId,
|
||||||
|
userId,
|
||||||
|
tokenHash,
|
||||||
|
expiresAt,
|
||||||
|
createdAt
|
||||||
|
})
|
||||||
|
|
||||||
|
setSessionCookie(event, sessionId)
|
||||||
|
return sessionId
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function destroySession(event: H3Event): Promise<void> {
|
||||||
|
const sessionId = getCookie(event, SESSION_COOKIE)
|
||||||
|
if (sessionId) {
|
||||||
|
deleteSession(sessionId)
|
||||||
|
}
|
||||||
|
clearSessionCookie(event)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function rowToAuthUser(row: UserRow): AuthUser {
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
username: row.username,
|
||||||
|
role: row.role
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getCurrentUser(event: H3Event): Promise<AuthUser | null> {
|
||||||
|
const sessionId = getCookie(event, SESSION_COOKIE)
|
||||||
|
if (!sessionId) return null
|
||||||
|
|
||||||
|
const session = findSessionById(sessionId)
|
||||||
|
if (!session) return null
|
||||||
|
|
||||||
|
if (new Date(session.expires_at) <= new Date()) {
|
||||||
|
deleteSession(sessionId)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = findUserById(session.user_id)
|
||||||
|
if (!user) return null
|
||||||
|
|
||||||
|
return rowToAuthUser(user)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createUserAccount(input: {
|
||||||
|
username: string
|
||||||
|
password: string
|
||||||
|
role: UserRole
|
||||||
|
}): Promise<AuthUser> {
|
||||||
|
if (!isValidUsername(input.username)) {
|
||||||
|
throw new Error('INVALID_USERNAME')
|
||||||
|
}
|
||||||
|
if (!isValidPassword(input.password)) {
|
||||||
|
throw new Error('INVALID_PASSWORD')
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = findUserByUsername(input.username)
|
||||||
|
if (existing) {
|
||||||
|
throw new Error('USERNAME_TAKEN')
|
||||||
|
}
|
||||||
|
|
||||||
|
const passwordHash = hashPassword(input.password)
|
||||||
|
const createdAt = new Date().toISOString()
|
||||||
|
const id = insertUser({
|
||||||
|
username: input.username,
|
||||||
|
passwordHash,
|
||||||
|
role: input.role,
|
||||||
|
createdAt
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
username: input.username,
|
||||||
|
role: input.role
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isInitialized(): boolean {
|
||||||
|
return countUsers() > 0
|
||||||
|
}
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { shouldAutoDeleteImage } from './auto-delete'
|
||||||
|
import type { StoredImage } from './storage'
|
||||||
|
|
||||||
|
function image(userId: number | null, uploadedAt: string): StoredImage {
|
||||||
|
return {
|
||||||
|
key: 'images/2026/01/a.webp',
|
||||||
|
originalName: 'a.webp',
|
||||||
|
uploadedAt,
|
||||||
|
contentType: 'image/webp',
|
||||||
|
size: 100,
|
||||||
|
mtimeMs: 0,
|
||||||
|
userId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const nowMs = Date.parse('2026-09-15T00:00:00.000Z')
|
||||||
|
const enabledAt = '2026-08-01T00:00:00.000Z'
|
||||||
|
const adminIds = new Set([1])
|
||||||
|
|
||||||
|
describe('shouldAutoDeleteImage', () => {
|
||||||
|
it('deletes user images by their own policy after enabled', () => {
|
||||||
|
const userPolicies = new Map([[2, { days: 30, enabledAt }]])
|
||||||
|
const uploadedAt = '2026-07-01T00:00:00.000Z'
|
||||||
|
expect(shouldAutoDeleteImage(
|
||||||
|
image(2, uploadedAt),
|
||||||
|
{ days: 0, enabledAt: null },
|
||||||
|
adminIds,
|
||||||
|
userPolicies,
|
||||||
|
nowMs
|
||||||
|
)).toBe(false)
|
||||||
|
expect(shouldAutoDeleteImage(
|
||||||
|
image(2, '2026-08-05T00:00:00.000Z'),
|
||||||
|
{ days: 0, enabledAt: null },
|
||||||
|
adminIds,
|
||||||
|
userPolicies,
|
||||||
|
nowMs
|
||||||
|
)).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not delete other users images', () => {
|
||||||
|
const userPolicies = new Map([[2, { days: 30, enabledAt }]])
|
||||||
|
expect(shouldAutoDeleteImage(
|
||||||
|
image(3, '2026-07-01T00:00:00.000Z'),
|
||||||
|
{ days: 0, enabledAt: null },
|
||||||
|
adminIds,
|
||||||
|
userPolicies,
|
||||||
|
nowMs
|
||||||
|
)).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('applies admin global policy only to admin or legacy images', () => {
|
||||||
|
const globalPolicy = { days: 30, enabledAt }
|
||||||
|
const userPolicies = new Map<number, { days: number, enabledAt: string | null }>()
|
||||||
|
|
||||||
|
expect(shouldAutoDeleteImage(
|
||||||
|
image(2, '2026-07-01T00:00:00.000Z'),
|
||||||
|
globalPolicy,
|
||||||
|
adminIds,
|
||||||
|
userPolicies,
|
||||||
|
nowMs
|
||||||
|
)).toBe(false)
|
||||||
|
|
||||||
|
expect(shouldAutoDeleteImage(
|
||||||
|
image(1, '2026-07-01T00:00:00.000Z'),
|
||||||
|
globalPolicy,
|
||||||
|
adminIds,
|
||||||
|
userPolicies,
|
||||||
|
nowMs
|
||||||
|
)).toBe(false)
|
||||||
|
|
||||||
|
expect(shouldAutoDeleteImage(
|
||||||
|
image(1, '2026-08-05T00:00:00.000Z'),
|
||||||
|
globalPolicy,
|
||||||
|
adminIds,
|
||||||
|
userPolicies,
|
||||||
|
nowMs
|
||||||
|
)).toBe(true)
|
||||||
|
|
||||||
|
expect(shouldAutoDeleteImage(
|
||||||
|
image(null, '2026-08-05T00:00:00.000Z'),
|
||||||
|
globalPolicy,
|
||||||
|
adminIds,
|
||||||
|
userPolicies,
|
||||||
|
nowMs
|
||||||
|
)).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps recent images', () => {
|
||||||
|
const userPolicies = new Map([[2, { days: 30, enabledAt }]])
|
||||||
|
expect(shouldAutoDeleteImage(
|
||||||
|
image(2, '2026-09-10T00:00:00.000Z'),
|
||||||
|
{ days: 0, enabledAt: null },
|
||||||
|
adminIds,
|
||||||
|
userPolicies,
|
||||||
|
nowMs
|
||||||
|
)).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
import { insertActivityLog, listAdminUserIds, listUserAutoDeletePolicies, type AutoDeletePolicy } from './db'
|
||||||
|
import { getGlobalAutoDeletePolicy } from './env'
|
||||||
|
import { deleteImage, headImage, listImageKeys, type StoredImage } from './storage'
|
||||||
|
import { logInfo, logWarn } from './logger'
|
||||||
|
|
||||||
|
export interface AutoDeleteResult {
|
||||||
|
deleted: number
|
||||||
|
failed: number
|
||||||
|
skipped: boolean
|
||||||
|
days: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const MS_PER_DAY = 24 * 60 * 60 * 1000
|
||||||
|
|
||||||
|
function imageAgeDays(uploadedMs: number, nowMs: number): number {
|
||||||
|
return (nowMs - uploadedMs) / MS_PER_DAY
|
||||||
|
}
|
||||||
|
|
||||||
|
function policyMatches(
|
||||||
|
uploadedMs: number,
|
||||||
|
nowMs: number,
|
||||||
|
policy: AutoDeletePolicy
|
||||||
|
): boolean {
|
||||||
|
if (policy.days <= 0) return false
|
||||||
|
|
||||||
|
if (policy.enabledAt) {
|
||||||
|
const enabledMs = new Date(policy.enabledAt).getTime()
|
||||||
|
if (!Number.isFinite(enabledMs) || uploadedMs < enabledMs) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return imageAgeDays(uploadedMs, nowMs) >= policy.days
|
||||||
|
}
|
||||||
|
|
||||||
|
export function shouldAutoDeleteImage(
|
||||||
|
image: StoredImage,
|
||||||
|
globalPolicy: AutoDeletePolicy,
|
||||||
|
adminUserIds: Set<number>,
|
||||||
|
userPolicies: Map<number, AutoDeletePolicy>,
|
||||||
|
nowMs = Date.now()
|
||||||
|
): boolean {
|
||||||
|
const uploadedMs = new Date(image.uploadedAt).getTime()
|
||||||
|
if (!Number.isFinite(uploadedMs) || uploadedMs > nowMs) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const ownerId = image.userId ?? null
|
||||||
|
|
||||||
|
if (ownerId != null) {
|
||||||
|
const userPolicy = userPolicies.get(ownerId)
|
||||||
|
if (userPolicy && policyMatches(uploadedMs, nowMs, userPolicy)) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
if (adminUserIds.has(ownerId) && policyMatches(uploadedMs, nowMs, globalPolicy)) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return policyMatches(uploadedMs, nowMs, globalPolicy)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runAutoDeleteCleanup(): Promise<AutoDeleteResult> {
|
||||||
|
const globalPolicy = getGlobalAutoDeletePolicy()
|
||||||
|
const userPolicies = listUserAutoDeletePolicies()
|
||||||
|
const adminUserIds = listAdminUserIds()
|
||||||
|
|
||||||
|
if (globalPolicy.days <= 0 && userPolicies.size === 0) {
|
||||||
|
return { deleted: 0, failed: 0, skipped: true, days: 0 }
|
||||||
|
}
|
||||||
|
|
||||||
|
const nowMs = Date.now()
|
||||||
|
const keys = await listImageKeys()
|
||||||
|
let deleted = 0
|
||||||
|
let failed = 0
|
||||||
|
|
||||||
|
for (const key of keys) {
|
||||||
|
const image = await headImage(key)
|
||||||
|
if (!image) continue
|
||||||
|
|
||||||
|
if (!shouldAutoDeleteImage(image, globalPolicy, adminUserIds, userPolicies, nowMs)) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await deleteImage(key)
|
||||||
|
insertActivityLog({
|
||||||
|
action: 'delete',
|
||||||
|
key,
|
||||||
|
originalName: image.originalName,
|
||||||
|
size: image.size,
|
||||||
|
contentType: image.contentType,
|
||||||
|
source: 'web'
|
||||||
|
})
|
||||||
|
deleted++
|
||||||
|
} catch (error) {
|
||||||
|
logWarn('auto-delete failed', { key, error })
|
||||||
|
failed++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (deleted > 0 || failed > 0) {
|
||||||
|
logInfo('auto-delete cleanup finished', { globalDays: globalPolicy.days, deleted, failed })
|
||||||
|
}
|
||||||
|
|
||||||
|
return { deleted, failed, skipped: false, days: globalPolicy.days }
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
export const ALLOWED_MIME_TYPES = [
|
||||||
|
'image/jpeg',
|
||||||
|
'image/png',
|
||||||
|
'image/webp',
|
||||||
|
'image/gif',
|
||||||
|
'image/svg+xml',
|
||||||
|
'image/x-icon'
|
||||||
|
] as const
|
||||||
|
|
||||||
|
export type AllowedMimeType = (typeof ALLOWED_MIME_TYPES)[number]
|
||||||
|
|
||||||
|
export const MAX_FILE_SIZE = 10 * 1024 * 1024
|
||||||
|
export const MAX_FILES_PER_UPLOAD = 10
|
||||||
|
export const MAX_DELETE_BATCH = 100
|
||||||
|
export const DEFAULT_LIST_LIMIT = 30
|
||||||
|
export const MAX_LIST_LIMIT = 100
|
||||||
|
|
||||||
|
export const IMAGE_CACHE_CONTROL
|
||||||
|
= 'public, max-age=86400, s-maxage=604800, stale-while-revalidate=86400'
|
||||||
@@ -0,0 +1,570 @@
|
|||||||
|
import { DatabaseSync } from 'node:sqlite'
|
||||||
|
import { mkdirSync } from 'node:fs'
|
||||||
|
import { join } from 'node:path'
|
||||||
|
import { getDataDir } from './storage'
|
||||||
|
|
||||||
|
export type LogAction = 'upload' | 'delete'
|
||||||
|
export type LogSource = 'web' | 'api' | 'twikoo'
|
||||||
|
|
||||||
|
export interface ActivityLogRow {
|
||||||
|
id: number
|
||||||
|
action: LogAction
|
||||||
|
key: string
|
||||||
|
original_name: string
|
||||||
|
size: number
|
||||||
|
content_type: string
|
||||||
|
source: LogSource
|
||||||
|
created_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ActivityLogInput {
|
||||||
|
action: LogAction
|
||||||
|
key: string
|
||||||
|
originalName: string
|
||||||
|
size: number
|
||||||
|
contentType: string
|
||||||
|
source: LogSource
|
||||||
|
createdAt?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
let db: DatabaseSync | null = null
|
||||||
|
|
||||||
|
function getDb(): DatabaseSync {
|
||||||
|
if (db) return db
|
||||||
|
|
||||||
|
const dataDir = getDataDir()
|
||||||
|
mkdirSync(dataDir, { recursive: true })
|
||||||
|
const path = join(dataDir, 'pichost.db')
|
||||||
|
db = new DatabaseSync(path)
|
||||||
|
db.exec(`
|
||||||
|
PRAGMA journal_mode = WAL;
|
||||||
|
CREATE TABLE IF NOT EXISTS activity_logs (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
action TEXT NOT NULL CHECK(action IN ('upload', 'delete')),
|
||||||
|
key TEXT NOT NULL,
|
||||||
|
original_name TEXT NOT NULL,
|
||||||
|
size INTEGER NOT NULL DEFAULT 0,
|
||||||
|
content_type TEXT NOT NULL DEFAULT '',
|
||||||
|
source TEXT NOT NULL CHECK(source IN ('web', 'api', 'twikoo')),
|
||||||
|
created_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_activity_logs_created_at
|
||||||
|
ON activity_logs(created_at DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_activity_logs_action_created
|
||||||
|
ON activity_logs(action, created_at DESC);
|
||||||
|
CREATE TABLE IF NOT EXISTS settings (
|
||||||
|
key TEXT PRIMARY KEY,
|
||||||
|
value TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
username TEXT NOT NULL UNIQUE,
|
||||||
|
password_hash TEXT NOT NULL,
|
||||||
|
role TEXT NOT NULL CHECK(role IN ('admin', 'user')),
|
||||||
|
created_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS sessions (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
user_id INTEGER NOT NULL,
|
||||||
|
token_hash TEXT NOT NULL,
|
||||||
|
expires_at TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_sessions_user_id ON sessions(user_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_sessions_expires_at ON sessions(expires_at);
|
||||||
|
`)
|
||||||
|
migrateSchema(db)
|
||||||
|
return db
|
||||||
|
}
|
||||||
|
|
||||||
|
function migrateSchema(database: DatabaseSync): void {
|
||||||
|
const columns = database.prepare('PRAGMA table_info(users)').all() as Array<{
|
||||||
|
name: string
|
||||||
|
}>
|
||||||
|
if (!columns.some(column => column.name === 'api_token')) {
|
||||||
|
database.exec('ALTER TABLE users ADD COLUMN api_token TEXT')
|
||||||
|
database.exec(
|
||||||
|
'CREATE UNIQUE INDEX IF NOT EXISTS idx_users_api_token ON users(api_token) WHERE api_token IS NOT NULL'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (!columns.some(column => column.name === 'auto_delete_days')) {
|
||||||
|
database.exec('ALTER TABLE users ADD COLUMN auto_delete_days INTEGER NOT NULL DEFAULT 0')
|
||||||
|
}
|
||||||
|
const userColumns = database.prepare('PRAGMA table_info(users)').all() as Array<{
|
||||||
|
name: string
|
||||||
|
}>
|
||||||
|
if (!userColumns.some(column => column.name === 'auto_delete_enabled_at')) {
|
||||||
|
database.exec('ALTER TABLE users ADD COLUMN auto_delete_enabled_at TEXT')
|
||||||
|
database.exec(`
|
||||||
|
UPDATE users
|
||||||
|
SET auto_delete_enabled_at = datetime('now')
|
||||||
|
WHERE auto_delete_days > 0 AND auto_delete_enabled_at IS NULL
|
||||||
|
`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const daysRow = database.prepare(`
|
||||||
|
SELECT value FROM settings WHERE key = 'auto_delete_days'
|
||||||
|
`).get() as { value: string } | undefined
|
||||||
|
const enabledAtRow = database.prepare(`
|
||||||
|
SELECT value FROM settings WHERE key = 'auto_delete_enabled_at'
|
||||||
|
`).get() as { value: string } | undefined
|
||||||
|
const globalDays = daysRow ? Number(daysRow.value) : 0
|
||||||
|
if (globalDays > 0 && !enabledAtRow) {
|
||||||
|
const now = new Date().toISOString()
|
||||||
|
database.prepare(`
|
||||||
|
INSERT INTO settings (key, value, updated_at)
|
||||||
|
VALUES ('auto_delete_enabled_at', ?, ?)
|
||||||
|
`).run(now, now)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const USER_COLUMNS = 'id, username, password_hash, role, created_at, api_token, auto_delete_days, auto_delete_enabled_at'
|
||||||
|
|
||||||
|
export type UserRole = 'admin' | 'user'
|
||||||
|
|
||||||
|
export interface UserRow {
|
||||||
|
id: number
|
||||||
|
username: string
|
||||||
|
password_hash: string
|
||||||
|
role: UserRole
|
||||||
|
created_at: string
|
||||||
|
api_token: string | null
|
||||||
|
auto_delete_days: number
|
||||||
|
auto_delete_enabled_at: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AutoDeletePolicy {
|
||||||
|
days: number
|
||||||
|
enabledAt: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SessionRow {
|
||||||
|
id: string
|
||||||
|
user_id: number
|
||||||
|
token_hash: string
|
||||||
|
expires_at: string
|
||||||
|
created_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function countUsers(): number {
|
||||||
|
const row = getDb().prepare('SELECT COUNT(*) AS count FROM users').get() as {
|
||||||
|
count: number
|
||||||
|
}
|
||||||
|
return row.count
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listUserIdUsernameMap(): Map<number, string> {
|
||||||
|
const rows = getDb().prepare(`
|
||||||
|
SELECT id, username
|
||||||
|
FROM users
|
||||||
|
`).all() as Array<{ id: number, username: string }>
|
||||||
|
|
||||||
|
return new Map(rows.map(row => [row.id, row.username]))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function findUserById(id: number): UserRow | null {
|
||||||
|
const row = getDb().prepare(`
|
||||||
|
SELECT ${USER_COLUMNS}
|
||||||
|
FROM users
|
||||||
|
WHERE id = ?
|
||||||
|
`).get(id) as UserRow | undefined
|
||||||
|
return row ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function findUserByUsername(username: string): UserRow | null {
|
||||||
|
const row = getDb().prepare(`
|
||||||
|
SELECT ${USER_COLUMNS}
|
||||||
|
FROM users
|
||||||
|
WHERE username = ?
|
||||||
|
`).get(username) as UserRow | undefined
|
||||||
|
return row ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function findUserByApiToken(token: string): UserRow | null {
|
||||||
|
if (!token) return null
|
||||||
|
const row = getDb().prepare(`
|
||||||
|
SELECT ${USER_COLUMNS}
|
||||||
|
FROM users
|
||||||
|
WHERE api_token = ?
|
||||||
|
`).get(token) as UserRow | undefined
|
||||||
|
return row ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getUserApiToken(userId: number): string | null {
|
||||||
|
const row = getDb().prepare(`
|
||||||
|
SELECT api_token
|
||||||
|
FROM users
|
||||||
|
WHERE id = ?
|
||||||
|
`).get(userId) as { api_token: string | null } | undefined
|
||||||
|
return row?.api_token ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setUserApiToken(userId: number, token: string): void {
|
||||||
|
getDb().prepare(`
|
||||||
|
UPDATE users
|
||||||
|
SET api_token = ?
|
||||||
|
WHERE id = ?
|
||||||
|
`).run(token, userId)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getUserAutoDeletePolicy(userId: number): AutoDeletePolicy {
|
||||||
|
const row = getDb().prepare(`
|
||||||
|
SELECT auto_delete_days, auto_delete_enabled_at
|
||||||
|
FROM users
|
||||||
|
WHERE id = ?
|
||||||
|
`).get(userId) as { auto_delete_days: number, auto_delete_enabled_at: string | null } | undefined
|
||||||
|
|
||||||
|
return {
|
||||||
|
days: row?.auto_delete_days ?? 0,
|
||||||
|
enabledAt: row?.auto_delete_enabled_at ?? null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getUserAutoDeleteDays(userId: number): number {
|
||||||
|
return getUserAutoDeletePolicy(userId).days
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setUserAutoDeletePolicy(userId: number, days: number): void {
|
||||||
|
const current = getUserAutoDeletePolicy(userId)
|
||||||
|
const now = new Date().toISOString()
|
||||||
|
let enabledAt = current.enabledAt
|
||||||
|
|
||||||
|
if (days > 0 && current.days === 0) {
|
||||||
|
enabledAt = now
|
||||||
|
} else if (days === 0) {
|
||||||
|
enabledAt = null
|
||||||
|
}
|
||||||
|
|
||||||
|
getDb().prepare(`
|
||||||
|
UPDATE users
|
||||||
|
SET auto_delete_days = ?, auto_delete_enabled_at = ?
|
||||||
|
WHERE id = ?
|
||||||
|
`).run(days, enabledAt, userId)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listUserAutoDeletePolicies(): Map<number, AutoDeletePolicy> {
|
||||||
|
const rows = getDb().prepare(`
|
||||||
|
SELECT id, auto_delete_days, auto_delete_enabled_at
|
||||||
|
FROM users
|
||||||
|
WHERE auto_delete_days > 0
|
||||||
|
`).all() as Array<{
|
||||||
|
id: number
|
||||||
|
auto_delete_days: number
|
||||||
|
auto_delete_enabled_at: string | null
|
||||||
|
}>
|
||||||
|
|
||||||
|
return new Map(rows.map(row => [
|
||||||
|
row.id,
|
||||||
|
{ days: row.auto_delete_days, enabledAt: row.auto_delete_enabled_at }
|
||||||
|
]))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listAdminUserIds(): Set<number> {
|
||||||
|
const rows = getDb().prepare(`
|
||||||
|
SELECT id
|
||||||
|
FROM users
|
||||||
|
WHERE role = 'admin'
|
||||||
|
`).all() as Array<{ id: number }>
|
||||||
|
|
||||||
|
return new Set(rows.map(row => row.id))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setUserAutoDeleteDays(userId: number, days: number): void {
|
||||||
|
setUserAutoDeletePolicy(userId, days)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listUserAutoDeleteDays(): Map<number, number> {
|
||||||
|
const policies = listUserAutoDeletePolicies()
|
||||||
|
return new Map([...policies.entries()].map(([id, policy]) => [id, policy.days]))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateUserPassword(userId: number, passwordHash: string): void {
|
||||||
|
getDb().prepare(`
|
||||||
|
UPDATE users
|
||||||
|
SET password_hash = ?
|
||||||
|
WHERE id = ?
|
||||||
|
`).run(passwordHash, userId)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ensureUserApiToken(userId: number): string {
|
||||||
|
const existing = getUserApiToken(userId)
|
||||||
|
if (existing) return existing
|
||||||
|
|
||||||
|
const token = generateApiUploadToken()
|
||||||
|
setUserApiToken(userId, token)
|
||||||
|
return token
|
||||||
|
}
|
||||||
|
|
||||||
|
export function insertUser(input: {
|
||||||
|
username: string
|
||||||
|
passwordHash: string
|
||||||
|
role: UserRole
|
||||||
|
createdAt: string
|
||||||
|
}): number {
|
||||||
|
const result = getDb().prepare(`
|
||||||
|
INSERT INTO users (username, password_hash, role, created_at)
|
||||||
|
VALUES (?, ?, ?, ?)
|
||||||
|
`).run(input.username, input.passwordHash, input.role, input.createdAt)
|
||||||
|
return Number(result.lastInsertRowid)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createSessionRow(input: {
|
||||||
|
id: string
|
||||||
|
userId: number
|
||||||
|
tokenHash: string
|
||||||
|
expiresAt: string
|
||||||
|
createdAt: string
|
||||||
|
}): void {
|
||||||
|
getDb().prepare(`
|
||||||
|
INSERT INTO sessions (id, user_id, token_hash, expires_at, created_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?)
|
||||||
|
`).run(
|
||||||
|
input.id,
|
||||||
|
input.userId,
|
||||||
|
input.tokenHash,
|
||||||
|
input.expiresAt,
|
||||||
|
input.createdAt
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function findSessionById(id: string): SessionRow | null {
|
||||||
|
const row = getDb().prepare(`
|
||||||
|
SELECT id, user_id, token_hash, expires_at, created_at
|
||||||
|
FROM sessions
|
||||||
|
WHERE id = ?
|
||||||
|
`).get(id) as SessionRow | undefined
|
||||||
|
return row ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteSession(id: string): void {
|
||||||
|
getDb().prepare('DELETE FROM sessions WHERE id = ?').run(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteSessionsForUser(userId: number): void {
|
||||||
|
getDb().prepare('DELETE FROM sessions WHERE user_id = ?').run(userId)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SETTINGS_API_UPLOAD_TOKEN = 'api_upload_token'
|
||||||
|
export const SETTINGS_WEBP_QUALITY = 'webp_quality'
|
||||||
|
export const SETTINGS_ALLOWED_REFERER_HOSTS = 'allowed_referer_hosts'
|
||||||
|
export const SETTINGS_IMAGE_BASE_URL = 'image_base_url'
|
||||||
|
export const SETTINGS_AUTO_DELETE_DAYS = 'auto_delete_days'
|
||||||
|
export const SETTINGS_AUTO_DELETE_ENABLED_AT = 'auto_delete_enabled_at'
|
||||||
|
export const SETTINGS_ALLOW_REGISTRATION = 'allow_registration'
|
||||||
|
|
||||||
|
export function isAllowRegistration(): boolean {
|
||||||
|
return getSetting(SETTINGS_ALLOW_REGISTRATION) === 'true'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setAllowRegistration(allow: boolean): void {
|
||||||
|
setSetting(SETTINGS_ALLOW_REGISTRATION, allow ? 'true' : 'false')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getSetting(key: string): string | null {
|
||||||
|
const row = getDb().prepare(`
|
||||||
|
SELECT value
|
||||||
|
FROM settings
|
||||||
|
WHERE key = ?
|
||||||
|
`).get(key) as { value: string } | undefined
|
||||||
|
return row?.value ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setSetting(key: string, value: string): void {
|
||||||
|
const updatedAt = new Date().toISOString()
|
||||||
|
getDb().prepare(`
|
||||||
|
INSERT INTO settings (key, value, updated_at)
|
||||||
|
VALUES (?, ?, ?)
|
||||||
|
ON CONFLICT(key) DO UPDATE SET
|
||||||
|
value = excluded.value,
|
||||||
|
updated_at = excluded.updated_at
|
||||||
|
`).run(key, value, updatedAt)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function generateApiUploadToken(): string {
|
||||||
|
const bytes = new Uint8Array(32)
|
||||||
|
crypto.getRandomValues(bytes)
|
||||||
|
return Array.from(bytes, byte => byte.toString(16).padStart(2, '0')).join('')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function insertActivityLog(input: ActivityLogInput): void {
|
||||||
|
try {
|
||||||
|
const createdAt = input.createdAt ?? new Date().toISOString()
|
||||||
|
getDb().prepare(`
|
||||||
|
INSERT INTO activity_logs
|
||||||
|
(action, key, original_name, size, content_type, source, created_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||||
|
`).run(
|
||||||
|
input.action,
|
||||||
|
input.key,
|
||||||
|
input.originalName,
|
||||||
|
input.size,
|
||||||
|
input.contentType,
|
||||||
|
input.source,
|
||||||
|
createdAt
|
||||||
|
)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[activity-log] insert failed', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listActivityLogs(options: {
|
||||||
|
limit: number
|
||||||
|
page?: number
|
||||||
|
action?: LogAction
|
||||||
|
source?: LogSource
|
||||||
|
folder?: string
|
||||||
|
}): {
|
||||||
|
items: ActivityLogRow[]
|
||||||
|
total: number
|
||||||
|
page: number
|
||||||
|
pageSize: number
|
||||||
|
totalPages: number
|
||||||
|
} {
|
||||||
|
const database = getDb()
|
||||||
|
const pageSize = options.limit
|
||||||
|
const params: Array<string | number> = []
|
||||||
|
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(`
|
||||||
|
SELECT COUNT(*) AS count
|
||||||
|
FROM activity_logs
|
||||||
|
${whereSql}
|
||||||
|
`).get(...params) as { count: number }
|
||||||
|
|
||||||
|
const total = countRow.count
|
||||||
|
const totalPages = Math.max(1, Math.ceil(total / pageSize) || 1)
|
||||||
|
const requestedPage = Math.max(1, options.page ?? 1)
|
||||||
|
const safePage = Math.min(requestedPage, totalPages)
|
||||||
|
const offset = (safePage - 1) * pageSize
|
||||||
|
|
||||||
|
const rows = database.prepare(`
|
||||||
|
SELECT id, action, key, original_name, size, content_type, source, created_at
|
||||||
|
FROM activity_logs
|
||||||
|
${whereSql}
|
||||||
|
ORDER BY id DESC
|
||||||
|
LIMIT ? OFFSET ?
|
||||||
|
`).all(...params, pageSize, offset) as unknown as ActivityLogRow[]
|
||||||
|
|
||||||
|
return {
|
||||||
|
items: rows,
|
||||||
|
total,
|
||||||
|
page: safePage,
|
||||||
|
pageSize,
|
||||||
|
totalPages
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function countSince(action: LogAction, sinceIso: string): number {
|
||||||
|
const row = getDb().prepare(`
|
||||||
|
SELECT COUNT(*) AS total
|
||||||
|
FROM activity_logs
|
||||||
|
WHERE action = ? AND created_at >= ?
|
||||||
|
`).get(action, sinceIso) as unknown as { total: number } | undefined
|
||||||
|
return row?.total ?? 0
|
||||||
|
}
|
||||||
|
|
||||||
|
function sumUploadBytes(): number {
|
||||||
|
const row = getDb().prepare(`
|
||||||
|
SELECT COALESCE(SUM(size), 0) AS total
|
||||||
|
FROM activity_logs
|
||||||
|
WHERE action = 'upload'
|
||||||
|
`).get() as unknown as { total: number } | undefined
|
||||||
|
return row?.total ?? 0
|
||||||
|
}
|
||||||
|
|
||||||
|
function countByUploadFolder(): Array<{ folder: string, count: number }> {
|
||||||
|
const rows = getDb().prepare(`
|
||||||
|
SELECT
|
||||||
|
CASE
|
||||||
|
WHEN instr(key, '/') > 0 THEN substr(key, 1, instr(key, '/') - 1)
|
||||||
|
ELSE '(root)'
|
||||||
|
END AS folder,
|
||||||
|
COUNT(*) AS total
|
||||||
|
FROM activity_logs
|
||||||
|
WHERE action = 'upload'
|
||||||
|
GROUP BY folder
|
||||||
|
ORDER BY total DESC, folder ASC
|
||||||
|
`).all() as unknown as Array<{ folder: string, total: number }>
|
||||||
|
|
||||||
|
return rows.map(row => ({ folder: row.folder, count: row.total }))
|
||||||
|
}
|
||||||
|
|
||||||
|
function countBySource(): Record<LogSource, number> {
|
||||||
|
const rows = getDb().prepare(`
|
||||||
|
SELECT source, COUNT(*) AS total
|
||||||
|
FROM activity_logs
|
||||||
|
WHERE action = 'upload'
|
||||||
|
GROUP BY source
|
||||||
|
`).all() as unknown as Array<{ source: LogSource, total: number }>
|
||||||
|
|
||||||
|
const result: Record<LogSource, number> = { web: 0, api: 0, twikoo: 0 }
|
||||||
|
for (const row of rows) {
|
||||||
|
result[row.source] = row.total
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
function startOfDayIsoInShanghai(now = new Date()): string {
|
||||||
|
// Asia/Shanghai = UTC+8,没有夏令时
|
||||||
|
const shifted = new Date(now.getTime() + 8 * 60 * 60 * 1000)
|
||||||
|
const y = shifted.getUTCFullYear()
|
||||||
|
const m = shifted.getUTCMonth()
|
||||||
|
const d = shifted.getUTCDate()
|
||||||
|
return new Date(Date.UTC(y, m, d) - 8 * 60 * 60 * 1000).toISOString()
|
||||||
|
}
|
||||||
|
|
||||||
|
function startOfMonthIsoInShanghai(now = new Date()): string {
|
||||||
|
const shifted = new Date(now.getTime() + 8 * 60 * 60 * 1000)
|
||||||
|
const y = shifted.getUTCFullYear()
|
||||||
|
const m = shifted.getUTCMonth()
|
||||||
|
return new Date(Date.UTC(y, m, 1) - 8 * 60 * 60 * 1000).toISOString()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getActivityStats(): {
|
||||||
|
uploadToday: number
|
||||||
|
uploadMonth: number
|
||||||
|
deleteToday: number
|
||||||
|
deleteMonth: number
|
||||||
|
uploadTotal: number
|
||||||
|
deleteTotal: number
|
||||||
|
uploadBytesTotal: number
|
||||||
|
bySource: Record<LogSource, number>
|
||||||
|
byFolderUploads: Array<{ folder: string, count: number }>
|
||||||
|
} {
|
||||||
|
const startOfDay = startOfDayIsoInShanghai()
|
||||||
|
const startOfMonth = startOfMonthIsoInShanghai()
|
||||||
|
|
||||||
|
const uploadTotalRow = getDb().prepare(`
|
||||||
|
SELECT COUNT(*) AS total FROM activity_logs WHERE action = 'upload'
|
||||||
|
`).get() as unknown as { total: number }
|
||||||
|
const deleteTotalRow = getDb().prepare(`
|
||||||
|
SELECT COUNT(*) AS total FROM activity_logs WHERE action = 'delete'
|
||||||
|
`).get() as unknown as { total: number }
|
||||||
|
|
||||||
|
return {
|
||||||
|
uploadToday: countSince('upload', startOfDay),
|
||||||
|
uploadMonth: countSince('upload', startOfMonth),
|
||||||
|
deleteToday: countSince('delete', startOfDay),
|
||||||
|
deleteMonth: countSince('delete', startOfMonth),
|
||||||
|
uploadTotal: uploadTotalRow.total,
|
||||||
|
deleteTotal: deleteTotalRow.total,
|
||||||
|
uploadBytesTotal: sumUploadBytes(),
|
||||||
|
bySource: countBySource(),
|
||||||
|
byFolderUploads: countByUploadFolder()
|
||||||
|
}
|
||||||
|
}
|
||||||