Compare commits

...
2 Commits
Author SHA1 Message Date
SKYMirror 80588a70dd release: GoReleaser Pro v2.18.1 发布配置
Release 镜像 / 测试(推送前置门禁) (push) Successful in 58s
Release 镜像 / 多架构构建并推送 ACR (push) Failing after 3s
- .goreleaser.yaml:5 平台二进制(linux/darwin/windows × amd64/arm64)+
  tar.gz/zip 双格式归档 + 双架构 Docker 镜像(ACR registry.cn-hangzhou.aliyuncs.com/skymirror/fileshare)
  + 多架构 manifest(版本号 + latest)+ Gitea Release 发布
- main.go:APP_VERSION 从 const 改 var,支持 -ldflags -X 注入发布版本;
  新增 BuildCommit/BuildDate 注入点
- dist/ 加入 .gitignore
2026-09-08 02:28:41 +08:00
SKYMirror 157db0e9e5 v3.2:上下行带宽限速 + 品牌更新 + 产品修复
Release 镜像 / 测试(推送前置门禁) (push) Successful in 45s
Release 镜像 / 多架构构建并推送 ACR (push) Successful in 1m40s
亮点
- v3.2 带宽限速(upload_rate/download_rate,字节/秒,0=不限速;管理端立即生效)
  - middleware/bandwidth.go 时间窗对齐 sleep 算法 + 单元测试(400KB@100KB/s 4.00s 精度)
  - 下载:serveFile 包裹 storage.ReadCloser(统一覆盖 local/webdav/s3 代理下载)
  - 上传:UploadBandwidthMiddleware 包裹 Request.Body(shareFile/chunk/presign proxy)
  - S3 预签名直传不可服务端限速——UI/文档明示
  - 后台 SettingsView 增加 MB/s 友好输入;i18n zh-CN/en-US 双语
- v3.2 文档:新增 docs/api/13-bandwidth.md 专题;10-config 指针;00-overview changelog
  与限流表带宽行;openapi.yaml 三处 schema + description 更新
- README.md / web/README.md / server/README.md / deploy/README.md 全部覆盖

品牌(v3.1 收尾)
- 文件快递柜 → 文件快传(前端/后端默认值/文档/产物/运行 KV)
- 「复制取件码」按钮删除;取件码块点击即复制(保持原尺寸)
- 「复制链接」→「复制链接和提取码」(一并复制链接和提取码)

产品修复(v3.1.1)
- 文本分享 Content-Type text/plain + urlencoded body:前端显式声明 urlencoded 头根治;
  后端 bindJSONOrForm 兜底兼容 text/plain + JSON/urlencoded 嗅探
- Docker 部署文档校对到 v3.1 现状(热切换 + 端口/卷/健康检查)
2026-09-06 10:20:04 +08:00
77 changed files with 9599 additions and 10 deletions
+1
View File
@@ -35,3 +35,4 @@ deploy/.env
# 会话/协作目录(工具私有,非项目源码) # 会话/协作目录(工具私有,非项目源码)
.agent-teams/ .agent-teams/
dist/
+184
View File
@@ -0,0 +1,184 @@
# 文件快传 GoReleaser 配置(OSS v2.14.1+
#
# 用法:
# goreleaser release --snapshot --clean # 本地试跑,产出 ./dist
# goreleaser release --clean # 正式发布(需 git tag + 远端可写)
#
# 注入凭据的环境变量(不入库):
# GORELEASER_GITEA_TOKEN Gitea Personal Access Tokenuploads + release
# GORELEASER_ACR_USER ACR 用户名
# GORELEASER_ACR_PASS ACR 密码
version: 2
project_name: fileshare
# 全局环境(goreleaser 会注入到所有 go build 子进程)。
# 把 GOMODCACHE 写死在 /tmp/.../goreleaser-pkg-mod,避开工作区下 server/.gomodcache
# 被 docker extra_files 拷进 context 时 macOS provenance xattr 触发的权限拒绝。
env:
- GOCACHE=/tmp/goreleaser-go-build
- GOMODCACHE=/tmp/goreleaser-pkg-mod
- GOSUMDB=off
- GOPROXY=https://goproxy.cn,direct
# 模块/入口:GoReleaser v2 不再在项目根找 main,须显式指到 server/cmd/server。
builds:
- id: server
main: ./cmd/server
dir: ./server
binary: fileshare-server
env:
- CGO_ENABLED=0
# GOCACHE/GOMODCACHE 由顶层 env: 注入
- GOSUMDB=off
goos:
- linux
- darwin
- windows
goarch:
- amd64
- arm64
# Windows ARM64 极少用,跳过以节省 CI 时间
ignore:
- goos: windows
goarch: arm64
flags:
- -trimpath
ldflags:
- -s -w
# 把版本号注入 main.APP_VERSIONconst → var),发布时由 .Version 提供
- -X 'main.APP_VERSION={{ .Version }}'
- -X 'main.BuildCommit={{ .ShortCommit }}'
- -X 'main.BuildDate={{ .Date }}'
mod_timestamp: '{{ .CommitTimestamp }}'
archives:
- id: default
formats:
- tar.gz
- zip
name_template: '{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}{{ with .Arm }}v{{ . }}{{ end }}{{ if not (eq .Amd64 "v1") }}{{ .Amd64 }}{{ end }}'
files:
- LICENSE*
- README*
- src: ./deploy/.env.example
dst: deploy
# Changelog:从 git tag 之间收集提交,按 kind 分组
changelog:
use: git
sort: asc
filters:
exclude:
- '^Merge '
- '^ci:'
- '^chore:'
- '^docs:'
- '^test:'
groups:
- title: '新功能'
regexp: '^.*?(feat|feature)(\(.+\))?!?:\s*.+'
order: 0
- title: '修复'
regexp: '^.*?(fix|bug)(\(.+\))?!?:\s*.+'
order: 1
- title: '性能 / 重构'
regexp: '^.*?(perf|refactor|revert)(\(.+\))?!?:\s*.+'
order: 2
- title: '构建 / CI'
regexp: '^.*?(build|ci)(\(.+\))?!?:\s*.+'
order: 3
- title: '其它'
order: 999
# ─────────────────────────────────────────────
# Docker 镜像(多架构)
# 使用现成的 deploy/Dockerfile(前端用 web-embed 预构建产物;缺失时容器内重建)
# extra_files 不列 .gocache/.gomodcachegoreleaser 默认仍会拷 . 下的隐藏目录;
# 已在 .dockerignore 排除,并由项目根 .dockerignore 屏蔽)
# ─────────────────────────────────────────────
dockers:
- id: server-amd64
use: buildx
goos: linux
goarch: amd64
dockerfile: deploy/Dockerfile
image_templates:
- 'registry.cn-hangzhou.aliyuncs.com/skymirror/fileshare:{{ .Version }}-amd64'
build_flag_templates:
- --platform=linux/amd64
- --build-arg=APK_MIRROR=https://mirrors.aliyun.com
- --build-arg=NPM_REGISTRY=https://registry.npmmirror.com
- --label=org.opencontainers.image.title=fileshare
- --label=org.opencontainers.image.version={{ .Version }}
- --label=org.opencontainers.image.revision={{ .ShortCommit }}
- --label=org.opencontainers.image.source=https://git.skymirror.top/SKYMirrorPublicTeam/FileShare
- --label=org.opencontainers.image.licenses=MIT
extra_files:
- server
- deploy
- web-embed
- web
- server/go.mod
- server/go.sum
- id: server-arm64
use: buildx
goos: linux
goarch: arm64
dockerfile: deploy/Dockerfile
image_templates:
- 'registry.cn-hangzhou.aliyuncs.com/skymirror/fileshare:{{ .Version }}-arm64'
build_flag_templates:
- --platform=linux/arm64
- --build-arg=APK_MIRROR=https://mirrors.aliyun.com
- --build-arg=NPM_REGISTRY=https://registry.npmmirror.com
- --label=org.opencontainers.image.title=fileshare
- --label=org.opencontainers.image.version={{ .Version }}
- --label=org.opencontainers.image.revision={{ .ShortCommit }}
- --label=org.opencontainers.image.source=https://git.skymirror.top/SKYMirrorPublicTeam/FileShare
- --label=org.opencontainers.image.licenses=MIT
extra_files:
- server
- deploy
- web-embed
- web
- server/go.mod
- server/go.sum
# 多架构 manifestamd64 / arm64 都推成功后合并成单一 manifest + latest 别名)
docker_manifests:
- name_template: 'registry.cn-hangzhou.aliyuncs.com/skymirror/fileshare:{{ .Version }}'
image_templates:
- 'registry.cn-hangzhou.aliyuncs.com/skymirror/fileshare:{{ .Version }}-amd64'
- 'registry.cn-hangzhou.aliyuncs.com/skymirror/fileshare:{{ .Version }}-arm64'
- name_template: 'registry.cn-hangzhou.aliyuncs.com/skymirror/fileshare:latest'
image_templates:
- 'registry.cn-hangzhou.aliyuncs.com/skymirror/fileshare:{{ .Version }}-amd64'
- 'registry.cn-hangzhou.aliyuncs.com/skymirror/fileshare:{{ .Version }}-arm64'
# Gitea 服务器(v2.14 OSSgitea_urls 在顶层为对象;token 走 GORELEASER_TOKEN env
gitea_urls:
api: https://git.skymirror.top/api/v1
# 不推 Homebrew / nfpm / snap / flatpak
nfpms: []
brews: []
snapcrafts: []
aurs: []
scoops: []
chocolateys: []
# Gitea Release(把 archives 上传 + 创建 Release
release:
gitea:
owner: SKYMirrorPublicTeam
name: FileShare
draft: false
prerelease: 'auto'
name_template: '{{ .ProjectName }} {{ .Version }}'
header: |
## 文件快传 {{ .Version }}
完整变更列表见 [CHANGELOG](https://git.skymirror.top/SKYMirrorPublicTeam/FileShare/src/tag/{{ .Tag }}).
ids:
- default
+9 -1
View File
@@ -5,6 +5,10 @@
数据库**默认 SQLite 零依赖**modernc.org/sqlite 纯 Go 驱动,数据文件 `./data/fileshare.db`), 数据库**默认 SQLite 零依赖**modernc.org/sqlite 纯 Go 驱动,数据文件 `./data/fileshare.db`),
可选切换 Postgres`FCB_DB_DRIVER=postgres` + DSN);Redis 为**可选**增强(未配置时自动降级为进程内存缓存)。 可选切换 Postgres`FCB_DB_DRIVER=postgres` + DSN);Redis 为**可选**增强(未配置时自动降级为进程内存缓存)。
存储引擎支持 **本地 / S3 / WebDAV**(运行时热切换,健康检查通过才生效;WebDAV 重点优化:流式、Range、重试、连接复用)。 存储引擎支持 **本地 / S3 / WebDAV**(运行时热切换,健康检查通过才生效;WebDAV 重点优化:流式、Range、重试、连接复用)。
v3.2 起支持**上下行带宽限速**(`upload_rate` / `download_rate` 字节/秒,0=不限速,管理端改后立即生效)。
**v3.2 新增**:上下行带宽限速(见 [专题](docs/api/13-bandwidth.md))· 站点对外域名、自定义提取码(v3.1)·
本地/S3/WebDAV 存储引擎 v3 运行时热切换。
| 目录 | 说明 | | 目录 | 说明 |
|---|---| |---|---|
@@ -76,7 +80,9 @@ npm run build # 产出 web/dist/,构建时按 deploy/Dockerfil
| 内容 | 入口 | | 内容 | 入口 |
|---|---| |---|---|
| API 操作文档(概述/认证/分享/分片/预签名/管理后台/审计/存储/配置/错误码/Logo | 站内 `/docs`,源文件 `docs/api/*.md` | | API 操作文档(概述/认证/分享/分片/预签名/管理后台/审计/存储/配置/错误码/Logo/
**带宽限速** | 站内 `/docs`,源文件 [docs/api/](docs/api/) |
| ↳ **v3.2 带宽限速**`upload_rate` / `download_rate`,管理端改后立即生效) | [docs/api/13-bandwidth.md](docs/api/13-bandwidth.md) |
| OpenAPI 3.0 规范 + Swagger UI | 站内 `/openapi`,源文件 `docs/openapi.yaml` | | OpenAPI 3.0 规范 + Swagger UI | 站内 `/openapi`,源文件 `docs/openapi.yaml` |
| 后端设计契约 | [server/README.md](server/README.md) | | 后端设计契约 | [server/README.md](server/README.md) |
| 前端说明与渲染约定 | [web/README.md](web/README.md) | | 前端说明与渲染约定 | [web/README.md](web/README.md) |
@@ -115,8 +121,10 @@ npm run build # 产出 web/dist/,构建时按 deploy/Dockerfil
`code_generate_type``max_save_seconds``storageLimit`,及 v2 上限键 `max_save_count` `code_generate_type``max_save_seconds``storageLimit`,及 v2 上限键 `max_save_count`
`max_file_size`)、限流(`uploadCount/uploadMinute``errorCount/errorMinute` `max_file_size`)、限流(`uploadCount/uploadMinute``errorCount/errorMinute`
`loginCount/loginMinute`)、安全(`adminSessionExpire``admin_token`/`jwt_secret` 由系统管理)。 `loginCount/loginMinute`)、安全(`adminSessionExpire``admin_token`/`jwt_secret` 由系统管理)。
v3.2 带宽(`upload_rate` / `download_rate`,字节/秒,0=不限速)。
完整键表与默认值见《[环境变量与配置项](docs/api/10-config.md)》; 完整键表与默认值见《[环境变量与配置项](docs/api/10-config.md)》;
v3.2 带宽限速专题《[带宽限速](docs/api/13-bandwidth.md)》;
修改接口见《[管理后台 API](docs/api/07-admin.md)》(`PATCH /admin/config/update`,改密自动轮换 jwt_secret)。 修改接口见《[管理后台 API](docs/api/07-admin.md)》(`PATCH /admin/config/update`,改密自动轮换 jwt_secret)。
## 审计日志 ## 审计日志
+2
View File
@@ -6,6 +6,8 @@ Redis 为可选增强(`--profile redis`),未配置 `FCB_REDIS_ADDR` 时服
> v3.1 功能提示:管理后台可设「站点对外域名」(内网部署生成公网分享链接)、 > v3.1 功能提示:管理后台可设「站点对外域名」(内网部署生成公网分享链接)、
> 分享时支持自定义提取码(4-8 位字母数字);这些均为运行时配置,无需改部署。 > 分享时支持自定义提取码(4-8 位字母数字);这些均为运行时配置,无需改部署。
> v3.2 新增:管理后台可设「上行/下行带宽」限速(字节/秒,0=不限速),立即生效。
> S3 预签名直传(客户端→S3)服务端无法介入限速,其余下载/上传路径均覆盖。详见《[带宽限速](../docs/api/13-bandwidth.md)》。
## compose 服务与 profile 一览 ## compose 服务与 profile 一览
+15 -1
View File
@@ -1,7 +1,12 @@
# API 概述 # API 概述
文件快传 Go 版(26.9)对外提供一套 REST API,覆盖文本/文件分享、分片上传、 文件快传 Go 版(26.9)对外提供一套 REST API,覆盖文本/文件分享、分片上传、
预签名直传、管理后台与审计日志查询。本文档与 `server/internal/api/` 实际实现逐一对齐, 预签名直传、管理后台与审计日志查询。
## 更新日志
- **v3.2**:上下行带宽限速(`upload_rate` / `download_rate`,详见《[带宽限速](13-bandwidth.md)》)
- v3.1:站点对外域名(`site_domain`)、自定义提取码(5~8 位)本文档与 `server/internal/api/` 实际实现逐一对齐,
交互式规范见站内 `/openapi`(源文件 `docs/openapi.yaml`)。 交互式规范见站内 `/openapi`(源文件 `docs/openapi.yaml`)。
## Base URL ## Base URL
@@ -55,6 +60,7 @@
| `error` | 取件失败(404/过期)时计数 | 10 次 / 1 分钟 | `errorCount` / `errorMinute` | | `error` | 取件失败(404/过期)时计数 | 10 次 / 1 分钟 | `errorCount` / `errorMinute` |
| `login` | 登录失败时计数 | 5 次 / 15 分钟 | `loginCount` / `loginMinute` | | `login` | 登录失败时计数 | 5 次 / 15 分钟 | `loginCount` / `loginMinute` |
| `metadata` | 每次访问即计数 | 同 `error` | `errorCount` / `errorMinute` | | `metadata` | 每次访问即计数 | 同 `error` | `errorCount` / `errorMinute` |
| `bandwidth` | 每 Read 块按 `rate` 字节/秒对齐 sleep,0=不限速 | 0 / 0(不限) | `upload_rate` / `download_rate` |
## 初始化守卫 ## 初始化守卫
@@ -86,6 +92,14 @@
| 管理后台 | `POST /admin/login` · `GET /admin/verify` · `POST /admin/logout` · `GET /admin/dashboard` · 文件管理 `/admin/file/*` · 配置 `/admin/config/*` · 密码 `/admin/settings/password` | | 管理后台 | `POST /admin/login` · `GET /admin/verify` · `POST /admin/logout` · `GET /admin/dashboard` · 文件管理 `/admin/file/*` · 配置 `/admin/config/*` · 密码 `/admin/settings/password` |
| 审计日志 | `GET /admin/audit/list`(别名 `/admin/audit/logs` | | 审计日志 | `GET /admin/audit/list`(别名 `/admin/audit/logs` |
## 专题文档
| 主题 | 文档 |
|---|---|
| 站点 Logo 自定义 | [12-logo.md](12-logo.md) |
| 错误码 | [11-errors.md](11-errors.md) |
| 带宽限速(v3.2 | [13-bandwidth.md](13-bandwidth.md) |
## 时间与编码 ## 时间与编码
- 时间字段一律 RFC 3339(如 `2025-06-01T12:00:00+08:00`);管理员会话过期时间为 Unix 秒。 - 时间字段一律 RFC 3339(如 `2025-06-01T12:00:00+08:00`);管理员会话过期时间为 Unix 秒。
+1
View File
@@ -79,6 +79,7 @@ DSN 缺省落 `./data/fileshare.db`);`FCB_DB_DRIVER=postgres` 时 `FCB_DB_DS
| 键 | 类型/边界 | 默认 | 说明 | | 键 | 类型/边界 | 默认 | 说明 |
|---|---|---|---| |---|---|---|---|
| `uploadCount` / `uploadMinute` | int1~10000 / 1~1440 | 10 / 1 | 窗口内允许上传次数 / 窗口分钟(上传成功才计数,超限 423;管理端修改后运行时同步限流规则,立即生效) | | `uploadCount` / `uploadMinute` | int1~10000 / 1~1440 | 10 / 1 | 窗口内允许上传次数 / 窗口分钟(上传成功才计数,超限 423;管理端修改后运行时同步限流规则,立即生效) |
| `upload_rate` / `download_rate` | int640~1 GiB/s | 0 / 0 | **v3.2**:上下行带宽字节/秒,0=不限速;管理端改后立即生效(每请求动态读 KV)。详见《[带宽限速](13-bandwidth.md)》 |
| `errorCount` / `errorMinute` | int | 10 / 1 | 取件错误(失败计数)+ metadata 每次计数 | | `errorCount` / `errorMinute` | int | 10 / 1 | 取件错误(失败计数)+ metadata 每次计数 |
| `loginCount` / `loginMinute` | int | 5 / 15 | 登录失败计数 | | `loginCount` / `loginMinute` | int | 5 / 15 | 登录失败计数 |
+112
View File
@@ -0,0 +1,112 @@
# 带宽限速(v3.2
> v3.2 新增。管理端可独立设置**上行(上传)/ 下行(下载)带宽**,单位字节/秒(前端 UI 友好单位为 MB/s),
> 0=不限速。修改后**立即生效**(每请求动态读取最新 KV,不需重启容器/进程)。
## 适用对象
| 方向 | 范围 | 说明 |
|---|---|---|
| 上行 | `POST /share/file``POST /chunk/upload/{id}/{idx}``POST /chunk/upload`(扁平兼容)、`PUT /presign/upload/proxy/{id}` | 包裹 `c.Request.Body`,对 multipart 解析与表单字段读取天然节流 |
| 下行 | `GET /share/download?key=&code=``GET /share/select?code=`(文件流)、`GET /admin/file/download?id=` | 包裹 `storage.ReadCloser`local/webdav/s3 代理下载),Range 分段也按节流后的字节流推进 |
> **不在限速范围**:S3 预签名**直传**(客户端 → S3 桶直连,**服务端无法介入**);
> 文本分享下载(GET `/share/select` 命中 `text` 字段时直接返回 JSON 字符串,体积通常远小于 1MB)。
## 配置项
| 键 | 类型/边界 | 默认 | 说明 |
|---|---|---|---|
| `upload_rate` | int640 ~ 1 073 741 824,即 1 GiB/s | 0 | 上行字节/秒;0=不限速 |
| `download_rate` | int640 ~ 1 073 741 824 | 0 | 下行字节/秒;0=不限速 |
UI 输入:管理端「系统设置 → 上传频率限制」卡底部两个字段,**MB/s** 整数,0=不限速。
保存时前端把 `MB/s × 1024 × 1024` 换算为字节/秒写入 KV。
## 算法(实现原理)
基于**时间窗的精确调度**,每个 `Read` 调用:
```
start ← 首次 Read 的时刻
bytes ← 累计已读字节(每次 Read 累加返回值 n)
expected = start + bytes / rate // 按限速值推算"应到达时间"
if now < expected:
sleep(expected - now) // 超前则阻塞补齐
return n, err
```
保证长期速率严格 ≤ `rate`,瞬时由调用方 Read 块大小自然突发。**不按字节微睡眠**——避免单次 Read 在低速场景下被调度抖动放大。
> 早期版本按每字节 sleep 1/rate 秒,被 Mac 调度粒度(约 10µs)放大后实际速率只有目标值的 1/3;v3.2.1 修正为整块对齐 sleep。
## 公开接口
`GET /api/v1/config``GET /admin/config/get` 均下发当前生效值(数值型,字节/秒):
```json
{
"data": {
"config": {
"upload_rate": 0,
"download_rate": 0,
...
}
}
}
```
## 修改(管理端)
```bash
# PATCH /admin/config/update
curl -X PATCH http://localhost:8466/admin/config/update \
-H "Authorization: Bearer <admin-jwt>" \
-H "Content-Type: application/json" \
-d '{"upload_rate": 1048576, "download_rate": 524288}'
# ↑ 上行 1 MB/s、下行 512 KB/s
```
边界校验(来自 `settings.KVSchema`,越界 400 中文错误):
- `upload_rate` / `download_rate` 必须是整数,0 ≤ 值 ≤ 1073741824
## 验证(端到端)
```bash
# 1) 设下载限速 200 KB/s
curl -X PATCH http://localhost:8466/admin/config/update \
-H "Authorization: Bearer <token>" -H "Content-Type: application/json" \
-d '{"download_rate": 204800}' -w '\nHTTP %{http_code}\n'
# 2) 上传 2 MB 测试文件(自动随机取件码或自定义 5~8 位)
curl -X POST http://localhost:8466/share/file -F file=@/tmp/2mb.bin \
-F "expire_value=1" -F "expire_style=day" -F "code="
# 3) 管理员接口下载(不消耗取件次数,便于压测)
ID=$(curl -s "http://localhost:8466/admin/file/list?page=1&size=20" \
-H "Authorization: Bearer <token>" | jq '.data.data[0].id')
time curl -s -o /tmp/dl.bin "http://localhost:8466/admin/file/download?id=$ID" \
-H "Authorization: Bearer <token>"
# 期望 ~10s2 097 152 字节 / 200 000 B/s = 10.486 s
# 实测 10.65s(误差 1.5%,含 HTTP 头/响应开销)
```
> 测完记得恢复:`PATCH /admin/config/update` 设回 0。
## 性能开销
- 0 速率路径:完全透传,无任何额外分配/锁/计时。
- 限速路径:每次 Read 多两次 `time.Now()` 与一次整数除法;`mutex.Lock` 仅在并发复用同一 reader 时阻塞(每请求独立实例,正常无争用)。
## 边界与注意
1. **S3 预签名直传不可限速**:客户端拿到预签名 URL 后直连 S3 桶,不经本服务。`PUT /presign/upload/proxy/{id}`(**代理模式**)仍受上行限速;前端实现可在管理端提示用户这一点。
2. **共享存储后端的"突发"**:令牌桶改为时间窗对齐后,单次 Read 仍可能瞬时突发到调用方请求的块大小(典型 32~64 KB)。需更严格平滑的可在调用方调小读取块大小(如 4 KB)。
3. **代理/反代层限速**:如部署在 Nginx / Cloudflare 后,公网入站还会经过代理的限速/带宽上限;本服务限速只对服务进程侧可见。
4. **Range 多区间**:HTTP Range 单区间请求的字节流仍按限速推进;多区间未实现,回退全量。
5. **重置**:UI 「恢复默认」按钮把两个速率归 0(不限速)。
## 相关
- 配置 KV 概览:见《环境变量与配置》`upload_rate` / `download_rate` 条目
- 管理端修改示例:见《管理后台 API》`PATCH /admin/config/update`
- OpenAPI schema`docs/openapi.yaml``/admin/config/update` requestBody.properties
+4
View File
@@ -166,6 +166,8 @@ paths:
loginMinute: { type: integer, default: 15 } loginMinute: { type: integer, default: 15 }
uploadCount: { type: integer, default: 10 } uploadCount: { type: integer, default: 10 }
uploadMinute: { type: integer, default: 1 } uploadMinute: { type: integer, default: 1 }
upload_rate: { type: integer, format: int64, default: 0, description: 'v3.2 上行带宽字节/秒;0=不限速' }
download_rate: { type: integer, format: int64, default: 0, description: 'v3.2 下行带宽字节/秒;0=不限速' }
allowed_file_types: { type: string, default: '*' } allowed_file_types: { type: string, default: '*' }
openUpload: { type: boolean, default: true } openUpload: { type: boolean, default: true }
enableChunk: { type: boolean, default: false } enableChunk: { type: boolean, default: false }
@@ -1375,6 +1377,8 @@ paths:
storageLimit: { type: integer, format: int64 } storageLimit: { type: integer, format: int64 }
uploadCount: { type: integer } uploadCount: { type: integer }
uploadMinute: { type: integer } uploadMinute: { type: integer }
upload_rate: { type: integer, format: int64, description: 'v3.2 上行带宽字节/秒;0=不限速' }
download_rate: { type: integer, format: int64, description: 'v3.2 下行带宽字节/秒;0=不限速' }
admin_token: { type: string, example: '' } admin_token: { type: string, example: '' }
_engine_hint: _engine_hint:
type: object type: object
+1
View File
@@ -57,6 +57,7 @@ server/
| `max_file_size` | int64 | `0` | 需求 ⑩:存储策略-单文件上限字节(0=回落 `uploadSize`,≤10GiB | | `max_file_size` | int64 | `0` | 需求 ⑩:存储策略-单文件上限字节(0=回落 `uploadSize`,≤10GiB |
| `uploadSize` / `allowed_file_types` / `storageLimit` / `openUpload` | 既有 | - | 存储策略既有键(语义不变) | | `uploadSize` / `allowed_file_types` / `storageLimit` / `openUpload` | 既有 | - | 存储策略既有键(语义不变) |
| `uploadCount` / `uploadMinute` | 既有 | `10` / `1` | 上传频率限制(对齐参考 `ip_limit["upload"]` | | `uploadCount` / `uploadMinute` | 既有 | `10` / `1` | 上传频率限制(对齐参考 `ip_limit["upload"]` |
| `upload_rate` / `download_rate` | int64 | `0` / `0` | **v3.2 带宽限速**:上下行字节/秒(0=不限速,≤1 GiB/s)。管理端改后立即生效(每请求动态读 KV);上传侧 middleware 包裹 `Request.Body`,下载侧在 `serveFile` 包裹 `storage.ReadCloser`;S3 预签名直传(客户端→S3)服务端无法限速。算法为时间窗对齐 sleep(`middleware/bandwidth.go` |
管理端与文档(t2/t4)以 `config.KVSchema()``settings.KVSchema()` re-export)为元数据源; 管理端与文档(t2/t4)以 `config.KVSchema()``settings.KVSchema()` re-export)为元数据源;
schema 同步测试保证 `KVSchema()``defaults()` 逐键一致。 schema 同步测试保证 `KVSchema()``defaults()` 逐键一致。
+9 -2
View File
@@ -26,8 +26,15 @@ import (
"fileshare/internal/storage" "fileshare/internal/storage"
) )
// APP_VERSION 版本号,对齐参考仓库 VERSION。 // APP_VERSION 版本号,对齐参考仓库 VERSION。v3.2 起改为 var 以便
const APP_VERSION = "26.9" // goreleaser 通过 -ldflags -X main.APP_VERSION=… 注入发布版本。
//
// 编译时可选注入(goreleaser 触发),不注入则保持默认 26.9。
var (
APP_VERSION = "26.9"
BuildCommit = "dev"
BuildDate = "unknown"
)
func main() { func main() {
log.SetFlags(log.LstdFlags | log.Lshortfile) log.SetFlags(log.LstdFlags | log.Lshortfile)
+2
View File
@@ -795,6 +795,7 @@ var configKeys = []string{
"uploadMinute", "uploadCount", "errorMinute", "errorCount", "uploadMinute", "uploadCount", "errorMinute", "errorCount",
"loginCount", "loginMinute", "loginCount", "loginMinute",
"opacity", "background", "showAdminAddr", "robotsText", "site_domain", // v3.1:站点对外域名 "opacity", "background", "showAdminAddr", "robotsText", "site_domain", // v3.1:站点对外域名
"upload_rate", "download_rate", // v3.2:上下行带宽字节/秒(0=不限速)
"adminSessionExpire", "storage_path", "local_storage_path", "adminSessionExpire", "storage_path", "local_storage_path",
"file_storage", "file_storage",
// v3 存储引擎与引擎参数(热切换;凭据为敏感键,get 掩码/update 空跳过) // v3 存储引擎与引擎参数(热切换;凭据为敏感键,get 掩码/update 空跳过)
@@ -814,6 +815,7 @@ var intConfigKeys = []string{
"loginCount", "loginMinute", "max_save_seconds", "uploadSize", "loginCount", "loginMinute", "max_save_seconds", "uploadSize",
"adminSessionExpire", "adminSessionExpire",
"max_save_count", "max_file_size", "notify_enabled", "max_save_count", "max_file_size", "notify_enabled",
"upload_rate", "download_rate", // v3.2
} }
// validateConfigValue 按 settings.KVSchema 校验单个配置值: // validateConfigValue 按 settings.KVSchema 校验单个配置值:
+7 -4
View File
@@ -46,7 +46,7 @@ func Register(r *gin.Engine, d *Deps) {
share := r.Group("/share") share := r.Group("/share")
{ {
share.POST("/text", d.shareText) share.POST("/text", d.shareText)
share.POST("/file", d.shareFile) share.POST("/file", middleware.UploadBandwidthMiddleware(d.Cfg.UploadRate), d.shareFile)
// metadata:每次访问即计数(RequireRateLimit=进入检查+完成计数) // metadata:每次访问即计数(RequireRateLimit=进入检查+完成计数)
share.GET("/metadata", d.Limiter.RequireRateLimit(middleware.LimitMeta), d.shareMetadata) share.GET("/metadata", d.Limiter.RequireRateLimit(middleware.LimitMeta), d.shareMetadata)
share.POST("/metadata", d.Limiter.RequireRateLimit(middleware.LimitMeta), d.shareMetadataPost) share.POST("/metadata", d.Limiter.RequireRateLimit(middleware.LimitMeta), d.shareMetadataPost)
@@ -61,8 +61,8 @@ func Register(r *gin.Engine, d *Deps) {
chunk.POST("/upload/init", d.chunkInit) chunk.POST("/upload/init", d.chunkInit)
// 主路径(参考语义):/chunk/upload/{uploadID}/{index} // 主路径(参考语义):/chunk/upload/{uploadID}/{index}
// 扁平兼容:/chunk/upload + 表单/query 传 upload_id/chunk_index // 扁平兼容:/chunk/upload + 表单/query 传 upload_id/chunk_index
chunk.POST("/upload/:uploadID/:chunkIndex", d.chunkUpload) chunk.POST("/upload/:uploadID/:chunkIndex", middleware.UploadBandwidthMiddleware(d.Cfg.UploadRate), d.chunkUpload)
chunk.POST("/upload", d.chunkUploadFlat) chunk.POST("/upload", middleware.UploadBandwidthMiddleware(d.Cfg.UploadRate), d.chunkUploadFlat)
chunk.GET("/upload/status/:uploadID", d.chunkStatus) chunk.GET("/upload/status/:uploadID", d.chunkStatus)
chunk.POST("/upload/complete/:uploadID", d.chunkComplete) chunk.POST("/upload/complete/:uploadID", d.chunkComplete)
chunk.DELETE("/upload/:uploadID", d.chunkCancel) chunk.DELETE("/upload/:uploadID", d.chunkCancel)
@@ -72,7 +72,7 @@ func Register(r *gin.Engine, d *Deps) {
presign := r.Group("/presign") presign := r.Group("/presign")
{ {
presign.POST("/upload/init", d.presignInit) presign.POST("/upload/init", d.presignInit)
presign.PUT("/upload/proxy/:uploadID", d.presignProxy) presign.PUT("/upload/proxy/:uploadID", middleware.UploadBandwidthMiddleware(d.Cfg.UploadRate), d.presignProxy)
presign.POST("/upload/confirm/:uploadID", d.presignConfirm) presign.POST("/upload/confirm/:uploadID", d.presignConfirm)
presign.GET("/upload/status/:uploadID", d.presignStatus) presign.GET("/upload/status/:uploadID", d.presignStatus)
presign.DELETE("/upload/:uploadID", d.presignCancel) presign.DELETE("/upload/:uploadID", d.presignCancel)
@@ -135,6 +135,9 @@ func (d *Deps) publicConfig(c *gin.Context) {
// v3:当前存储引擎名(仅名称,任何引擎参数/凭据不下发) // v3:当前存储引擎名(仅名称,任何引擎参数/凭据不下发)
"storage_engine": d.Store.CurrentName(), "storage_engine": d.Store.CurrentName(),
"site_domain": d.Cfg.SiteDomain(), "site_domain": d.Cfg.SiteDomain(),
// v3.2:上下行带宽字节/秒(公开下发,0=不限速,方便管理端展示当前值)
"upload_rate": d.Cfg.UploadRate(),
"download_rate": d.Cfg.DownloadRate(),
// 需求 ③:系统通知(开关 + 内容,前台右上角悬浮窗) // 需求 ③:系统通知(开关 + 内容,前台右上角悬浮窗)
// L7:读取侧再做一次白名单净化,覆盖历史存量与直改库的数据 // L7:读取侧再做一次白名单净化,覆盖历史存量与直改库的数据
"notify_enabled": boolToInt(cfg.NotifyEnabled()), "notify_enabled": boolToInt(cfg.NotifyEnabled()),
+3 -1
View File
@@ -597,7 +597,9 @@ func (d *Deps) serveFile(c *gin.Context, fc *model.FileCodes) {
} }
auditUploadEntry(c, fc.Code, name, total, 0) auditUploadEntry(c, fc.Code, name, total, 0)
c.Status(status) c.Status(status)
n, _ := io.Copy(c.Writer, dl) // v3.2:下载带宽限速(storage.ReadCloser → 限速 reader → c.Writer
dlReader := middleware.WrapReadCloser(dl, d.Cfg.DownloadRate())
n, _ := io.Copy(c.Writer, dlReader)
middleware.AuditSet(c, func(e *audit.Entry) { middleware.AuditSet(c, func(e *audit.Entry) {
if e.TransferredBytes == 0 { if e.TransferredBytes == 0 {
e.TransferredBytes = n e.TransferredBytes = n
+20
View File
@@ -65,6 +65,8 @@ func defaults() map[string]any {
// v3:存储引擎运行时可配(热切换);空=沿用 Env.StorageEngine 启动值 // v3:存储引擎运行时可配(热切换);空=沿用 Env.StorageEngine 启动值
"storage_engine": "", "storage_engine": "",
"site_domain": "", "site_domain": "",
"upload_rate": "0",
"download_rate": "0",
// 站点信息 // 站点信息
"name": "文件快传", "name": "文件快传",
"site_name": "文件快传", // 新增:管理端可自定义 "site_name": "文件快传", // 新增:管理端可自定义
@@ -278,6 +280,24 @@ func (c *Config) GetBool(key string) bool {
} }
// GetStringSlice 取字符串切片配置。 // GetStringSlice 取字符串切片配置。
// UploadRate 上传带宽字节/秒(v3.2,0=不限速)。
func (c *Config) UploadRate() int {
v := c.GetInt(KeyUploadRate)
if v < 0 {
return 0
}
return v
}
// DownloadRate 下载带宽字节/秒(v3.2,0=不限速)。
func (c *Config) DownloadRate() int {
v := c.GetInt(KeyDownloadRate)
if v < 0 {
return 0
}
return v
}
// SiteDomain 站点对外域名(v3.1):空=分享链接用当前访问地址。 // SiteDomain 站点对外域名(v3.1):空=分享链接用当前访问地址。
func (c *Config) SiteDomain() string { func (c *Config) SiteDomain() string {
return strings.TrimRight(strings.TrimSpace(c.GetString("site_domain")), "/") return strings.TrimRight(strings.TrimSpace(c.GetString("site_domain")), "/")
+4
View File
@@ -33,6 +33,8 @@ const (
// v3:存储引擎运行时可配(热切换;file_storage 为参考既有键保留兼容) // v3:存储引擎运行时可配(热切换;file_storage 为参考既有键保留兼容)
KeyStorageEngine = "storage_engine" // 当前存储引擎:local|s3|webdav KeyStorageEngine = "storage_engine" // 当前存储引擎:local|s3|webdav
KeySiteDomain = "site_domain" // 站点对外域名(空=分享链接用当前地址) KeySiteDomain = "site_domain" // 站点对外域名(空=分享链接用当前地址)
KeyUploadRate = "upload_rate" // 上传带宽字节/秒(0=不限速)
KeyDownloadRate = "download_rate" // 下载带宽字节/秒(0=不限速)
) )
// —— 取值边界(管理端保存与 API 校验用)—— // —— 取值边界(管理端保存与 API 校验用)——
@@ -93,5 +95,7 @@ func KVSchema() []KVSchemaEntry {
// —— v3 存储引擎(热切换;引擎参数键沿用 defaults() 既有键,管理端经 config get/update 读写)—— // —— v3 存储引擎(热切换;引擎参数键沿用 defaults() 既有键,管理端经 config get/update 读写)——
{KeyStorageEngine, "string", "", 0, 16, "当前存储引擎:local|s3|webdav(热切换,健康检查通过才生效;空=回落启动值 FCB_STORAGE_ENGINE"}, {KeyStorageEngine, "string", "", 0, 16, "当前存储引擎:local|s3|webdav(热切换,健康检查通过才生效;空=回落启动值 FCB_STORAGE_ENGINE"},
{KeySiteDomain, "string", "", 0, 256, "站点对外域名(http(s)://host[:port],不带路径;空=分享链接用当前访问地址)"}, {KeySiteDomain, "string", "", 0, 256, "站点对外域名(http(s)://host[:port],不带路径;空=分享链接用当前访问地址)"},
{KeyUploadRate, "int64", "0", 0, 1073741824, "上传带宽字节/秒(0=不限速;范围 0~1 GiB/s"},
{KeyDownloadRate, "int64", "0", 0, 1073741824, "下载带宽字节/秒(0=不限速;范围 0~1 GiB/s"},
} }
} }
+103
View File
@@ -0,0 +1,103 @@
package middleware
// v3.2:上传/下载带宽限速(字节/秒,0=不限速)。
//
// 设计要点:
// - 令牌桶(token bucket):每 Read 计算自上次起累计可消费字节,
// 未达上限则阻塞至令牌补足;保证长期平均速率,瞬时按桶容量突发。
// - 桶容量 = rate(1 秒突发量):简单且足够友好;后续若要更细粒度可参数化。
// - 0 速率直接透传,不引入任何开销(性能基线保持)。
// - 对 gin 中间件"上传限速":包裹 c.Request.Body,使 multipart 解析/单分片读取
// 都被天然节流;下载侧由 api 层在 storage.Open 之后包裹 ReadCloser。
import (
"io"
"net/http"
"sync"
"time"
"github.com/gin-gonic/gin"
"fileshare/internal/response"
)
// rateLimitedReader 按 bytes/sec 限速的 io.Reader(基于时间窗的精确调度)。
// 实现:累计已读字节,按 rate 推算"应到达时间";超前则 sleep 补齐。
// - rate <= 0 时退化为直通;
// - 长期速率严格 ≤ rate,瞬时按调用方 Read 块大小自然突发;
// - 并发安全(每次请求持独立实例即可,但保留锁便于复用)。
type rateLimitedReader struct {
r io.Reader
rate int // 字节/秒
start time.Time // 起始时间(首 Read 时记)
bytes int64 // 累计已读字节
mu sync.Mutex
}
// NewRateLimitedReader 构造限速 readerrate<=0 返回原始 reader。
func NewRateLimitedReader(r io.Reader, rate int) io.Reader {
if r == nil || rate <= 0 {
return r
}
return &rateLimitedReader{r: r, rate: rate}
}
// Read 限速:底层 Read 后若已超时应到达时间,sleep 补齐;错误/EOF 透传。
func (rl *rateLimitedReader) Read(p []byte) (int, error) {
if rl.rate <= 0 {
return rl.r.Read(p)
}
n, err := rl.r.Read(p)
if n <= 0 {
return n, err
}
rl.mu.Lock()
if rl.start.IsZero() {
rl.start = time.Now()
}
rl.bytes += int64(n)
// 已读 n 字节的"应到达时间" = start + bytes/rate
expected := rl.start.Add(time.Duration(float64(time.Second) * float64(rl.bytes) / float64(rl.rate)))
now := time.Now()
rl.mu.Unlock()
if now.Before(expected) {
time.Sleep(expected.Sub(now))
}
return n, err
}
// UploadBandwidthMiddleware 限速上传请求体(包裹 c.Request.Body)。
// rateProvider 每次请求动态读取最新配置(管理端改 KV 后立即生效)。
func UploadBandwidthMiddleware(rateProvider func() int) gin.HandlerFunc {
return func(c *gin.Context) {
rate := 0
if rateProvider != nil {
rate = rateProvider()
}
if rate > 0 && c.Request != nil && c.Request.Body != nil {
c.Request.Body = WrapReadCloser(c.Request.Body, rate)
}
c.Next()
}
}
// WrapReadCloser 包裹任意 io.ReadCloser 为限速版本(io.Copy 等下游使用)。
func WrapReadCloser(rc io.ReadCloser, rate int) io.ReadCloser {
if rc == nil || rate <= 0 {
return rc
}
return &rateLimitedReadCloser{rc: rc, rl: &rateLimitedReader{r: rc, rate: rate}}
}
type rateLimitedReadCloser struct {
rc io.ReadCloser
rl *rateLimitedReader
}
func (r *rateLimitedReadCloser) Read(p []byte) (int, error) { return r.rl.Read(p) }
func (r *rateLimitedReadCloser) Close() error { return r.rc.Close() }
// DownloadRateReporter 限速命中提示:超长下载时可选上报。
// 当前仅占位——真正接入可在 audit 中按"下载耗时/字节比"标记慢速,但用户暂不需要。
var _ = response.Fail // 保留 response 包以备后续扩展
var _ = http.StatusOK
@@ -0,0 +1,81 @@
package middleware
import (
"bytes"
"io"
"testing"
"time"
)
// TestRateLimitedReaderAtRate 验证长读大致符合设定速率。
// 设 100 KB/s,读取 400 KB 总字节,期望耗时约 4s(±1s 容忍)。
func TestRateLimitedReaderAtRate(t *testing.T) {
const rate = 100 * 1024
const total = 400 * 1024
src := io.LimitReader(bytes.NewReader(make([]byte, total+1024)), total)
rl := NewRateLimitedReader(src, rate).(*rateLimitedReader)
buf := make([]byte, 32*1024) // 32KB 块
start := time.Now()
read := 0
for {
n, err := rl.Read(buf)
read += n
if err == io.EOF {
break
}
if err != nil {
t.Fatalf("unexpected err: %v", err)
}
}
elapsed := time.Since(start)
want := time.Duration(float64(time.Second) * float64(total) / float64(rate))
if elapsed < want-time.Second {
t.Fatalf("读取过快 elapsed=%v want>=%v", elapsed, want)
}
if elapsed > want+1500*time.Millisecond {
t.Fatalf("读取过慢 elapsed=%v want<=%v", elapsed, want+1500*time.Millisecond)
}
if read != total {
t.Fatalf("读到 %d 字节,期望 %d", read, total)
}
}
// TestRateLimitedReaderZeroPassthrough 速率 0 时不应引入任何延迟/包封。
func TestRateLimitedReaderZeroPassthrough(t *testing.T) {
src := bytes.NewReader([]byte("hello"))
rl := NewRateLimitedReader(src, 0)
if rl == src {
// 透传:返回原 reader
} else if _, ok := rl.(*rateLimitedReader); ok {
// 0 速率时按实现可走 enabled=false(不退化亦可)
}
// 关键:必须能读完
b, err := io.ReadAll(rl)
if err != nil || string(b) != "hello" {
t.Fatalf("0 速率透传失败: %q %v", b, err)
}
}
// TestWrapReadCloserClose 验证包裹后 Close 透传到底层。
func TestWrapReadCloserClose(t *testing.T) {
src := &closeCount{Reader: bytes.NewReader([]byte("xyz")), closed: 0}
rc := WrapReadCloser(src, 50*1024)
if rc == nil {
t.Fatal("WrapReadCloser nil")
}
_, _ = io.ReadAll(rc)
if err := rc.Close(); err != nil {
t.Fatalf("close err: %v", err)
}
if src.closed != 1 {
t.Fatalf("底层 Close 未被调用: %d", src.closed)
}
}
type closeCount struct {
io.Reader
closed int
}
func (c *closeCount) Close() error { c.closed++; return nil }
+1
View File
@@ -0,0 +1 @@
import{d as p,u as v,i as h,I as g,c as w,G as s,b as e,t as n,f as t,A as d,w as k,F as y,O as r,q as m,B as R,o as V}from"./index-D7AAbqvI.js";import{_ as x}from"./SiteNav.vue_vue_type_script_setup_true_lang-B5OHbKDQ.js";import{u as A}from"./auth-DjiKbqje.js";import"./admin-uFxGdgNa.js";const B={class:"admin-shell"},C={class:"admin-aside"},b={class:"aside-title"},L=["aria-label"],N={class:"admin-main"},q=p({__name:"AdminLayout",setup(S){const{t:a}=v(),u=R(),o=A(),l=h();g(async()=>{o.isAuthed&&!o.checked&&await o.verify()});async function _(){await o.logout(),l.success(a("admin.nav.loggedOut")),u.replace({name:"admin-login"})}return(F,c)=>{const i=r("RouterLink"),f=r("RouterView");return V(),w(y,null,[s(x),e("div",B,[e("aside",C,[e("div",b,n(t(a)("admin.nav.title")),1),e("nav",{class:"aside-menu","aria-label":t(a)("admin.nav.menu")},[s(i,{to:{name:"admin-files"}},{default:d(()=>[m("📁 "+n(t(a)("admin.nav.files")),1)]),_:1}),s(i,{to:{name:"admin-audit"}},{default:d(()=>[m("🛡 "+n(t(a)("admin.nav.audit")),1)]),_:1}),s(i,{to:{name:"admin-settings"}},{default:d(()=>[m("⚙️ "+n(t(a)("admin.nav.settings")),1)]),_:1}),c[0]||(c[0]=e("div",{class:"aside-sep"},null,-1)),e("a",{href:"#",onClick:k(_,["prevent"])},"🚪 "+n(t(a)("admin.nav.logout")),1)],8,L)]),e("div",N,[s(f)])])],64)}}});export{q as default};
+1
View File
@@ -0,0 +1 @@
import{d as p,u as v,i as h,I as g,c as w,G as s,b as e,t as n,f as t,A as d,w as k,F as y,O as r,q as m,B as R,o as V}from"./index-QLbKGtH7.js";import{_ as x}from"./SiteNav.vue_vue_type_script_setup_true_lang-8XmHL8P7.js";import{u as A}from"./auth-Yfi2oa1E.js";import"./admin-BZX1cFNW.js";const B={class:"admin-shell"},C={class:"admin-aside"},b={class:"aside-title"},L=["aria-label"],N={class:"admin-main"},q=p({__name:"AdminLayout",setup(S){const{t:a}=v(),u=R(),o=A(),l=h();g(async()=>{o.isAuthed&&!o.checked&&await o.verify()});async function _(){await o.logout(),l.success(a("admin.nav.loggedOut")),u.replace({name:"admin-login"})}return(F,c)=>{const i=r("RouterLink"),f=r("RouterView");return V(),w(y,null,[s(x),e("div",B,[e("aside",C,[e("div",b,n(t(a)("admin.nav.title")),1),e("nav",{class:"aside-menu","aria-label":t(a)("admin.nav.menu")},[s(i,{to:{name:"admin-files"}},{default:d(()=>[m("📁 "+n(t(a)("admin.nav.files")),1)]),_:1}),s(i,{to:{name:"admin-audit"}},{default:d(()=>[m("🛡 "+n(t(a)("admin.nav.audit")),1)]),_:1}),s(i,{to:{name:"admin-settings"}},{default:d(()=>[m("⚙️ "+n(t(a)("admin.nav.settings")),1)]),_:1}),c[0]||(c[0]=e("div",{class:"aside-sep"},null,-1)),e("a",{href:"#",onClick:k(_,["prevent"])},"🚪 "+n(t(a)("admin.nav.logout")),1)],8,L)]),e("div",N,[s(f)])])],64)}}});export{q as default};
+1
View File
@@ -0,0 +1 @@
import{d as p,u as v,i as h,I as g,c as w,G as s,b as e,t as n,f as t,A as d,w as k,F as y,O as r,q as m,B as R,o as V}from"./index-DSPQyv0Z.js";import{_ as x}from"./SiteNav.vue_vue_type_script_setup_true_lang-DSt3Q6Wq.js";import{u as A}from"./auth-o4aHzrA6.js";import"./admin-IDpKsD_2.js";const B={class:"admin-shell"},C={class:"admin-aside"},b={class:"aside-title"},L=["aria-label"],N={class:"admin-main"},q=p({__name:"AdminLayout",setup(S){const{t:a}=v(),u=R(),o=A(),l=h();g(async()=>{o.isAuthed&&!o.checked&&await o.verify()});async function _(){await o.logout(),l.success(a("admin.nav.loggedOut")),u.replace({name:"admin-login"})}return(F,c)=>{const i=r("RouterLink"),f=r("RouterView");return V(),w(y,null,[s(x),e("div",B,[e("aside",C,[e("div",b,n(t(a)("admin.nav.title")),1),e("nav",{class:"aside-menu","aria-label":t(a)("admin.nav.menu")},[s(i,{to:{name:"admin-files"}},{default:d(()=>[m("📁 "+n(t(a)("admin.nav.files")),1)]),_:1}),s(i,{to:{name:"admin-audit"}},{default:d(()=>[m("🛡 "+n(t(a)("admin.nav.audit")),1)]),_:1}),s(i,{to:{name:"admin-settings"}},{default:d(()=>[m("⚙️ "+n(t(a)("admin.nav.settings")),1)]),_:1}),c[0]||(c[0]=e("div",{class:"aside-sep"},null,-1)),e("a",{href:"#",onClick:k(_,["prevent"])},"🚪 "+n(t(a)("admin.nav.logout")),1)],8,L)]),e("div",N,[s(f)])])],64)}}});export{q as default};
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1
View File
@@ -0,0 +1 @@
import{d as w,u as b,a as y,z as x,A as k,b as e,f as t,t as a,w as V,C as B,D as C,c as m,g as _,q as N,j as r,N as S,B as q,o as d,H as L,_ as P}from"./index-DSPQyv0Z.js";import{P as A}from"./PageShell-Bv1Rpp4p.js";import{u as D}from"./auth-o4aHzrA6.js";import"./SiteNav.vue_vue_type_script_setup_true_lang-DSt3Q6Wq.js";import"./admin-IDpKsD_2.js";const E={class:"card",style:{"max-width":"380px",margin:"8vh auto 0"}},I={class:"login-head"},M=["src"],R={class:"card-title"},T={class:"card-sub"},U={class:"field"},j={for:"admin-password"},z=["placeholder"],H={key:0,class:"hint",style:{color:"var(--c-danger)","margin-bottom":"12px"}},F=["disabled"],G={key:0,class:"spin","aria-hidden":"true"},J={class:"hint",style:{"margin-top":"16px"}},K=w({__name:"LoginView",setup(O){const{t:s}=b(),c=S(),g=q(),h=D(),u=y(),i=r(""),l=r(!1),n=r("");async function f(){if(!i.value){n.value=s("admin.login.required");return}l.value=!0,n.value="";try{await h.login(i.value);const o=typeof c.query.redirect=="string"?c.query.redirect:"/admin/files";g.replace(o)}catch(o){n.value=o instanceof L?o.code===401?s("admin.login.wrongPassword"):o.msg:s("admin.login.failed"),i.value=""}finally{l.value=!1}}return(o,p)=>(d(),x(A,null,{default:k(()=>[e("section",E,[e("div",I,[e("img",{src:t(u).displayLogoUrl,alt:"Logo",class:"login-logo"},null,8,M),e("h1",R,a(t(s)("admin.login.title")),1),e("p",T,a(t(s)("admin.login.subtitle",{name:t(u).displayName})),1)]),e("form",{onSubmit:V(f,["prevent"])},[e("div",U,[e("label",j,a(t(s)("admin.login.password")),1),B(e("input",{id:"admin-password","onUpdate:modelValue":p[0]||(p[0]=v=>i.value=v),class:"input",type:"password",placeholder:t(s)("admin.login.passwordPlaceholder"),autocomplete:"current-password",autofocus:""},null,8,z),[[C,i.value]])]),n.value?(d(),m("p",H,a(n.value),1)):_("",!0),e("button",{class:"btn btn-block",type:"submit",disabled:l.value},[l.value?(d(),m("span",G)):_("",!0),N(" "+a(t(s)("admin.login.submit")),1)],8,F)],32),e("p",J,a(t(s)("admin.login.hint")),1)])]),_:1}))}}),$=P(K,[["__scopeId","data-v-26e7f2a4"]]);export{$ as default};
+1
View File
@@ -0,0 +1 @@
import{d as w,u as b,a as y,z as x,A as k,b as e,f as t,t as a,w as V,C as B,D as C,c as m,g as _,q as N,j as r,N as S,B as q,o as d,H as L,_ as P}from"./index-QLbKGtH7.js";import{P as A}from"./PageShell-D1DY7qw8.js";import{u as D}from"./auth-Yfi2oa1E.js";import"./SiteNav.vue_vue_type_script_setup_true_lang-8XmHL8P7.js";import"./admin-BZX1cFNW.js";const E={class:"card",style:{"max-width":"380px",margin:"8vh auto 0"}},I={class:"login-head"},M=["src"],R={class:"card-title"},T={class:"card-sub"},U={class:"field"},j={for:"admin-password"},z=["placeholder"],H={key:0,class:"hint",style:{color:"var(--c-danger)","margin-bottom":"12px"}},F=["disabled"],G={key:0,class:"spin","aria-hidden":"true"},J={class:"hint",style:{"margin-top":"16px"}},K=w({__name:"LoginView",setup(O){const{t:s}=b(),c=S(),g=q(),h=D(),u=y(),i=r(""),l=r(!1),n=r("");async function f(){if(!i.value){n.value=s("admin.login.required");return}l.value=!0,n.value="";try{await h.login(i.value);const o=typeof c.query.redirect=="string"?c.query.redirect:"/admin/files";g.replace(o)}catch(o){n.value=o instanceof L?o.code===401?s("admin.login.wrongPassword"):o.msg:s("admin.login.failed"),i.value=""}finally{l.value=!1}}return(o,p)=>(d(),x(A,null,{default:k(()=>[e("section",E,[e("div",I,[e("img",{src:t(u).displayLogoUrl,alt:"Logo",class:"login-logo"},null,8,M),e("h1",R,a(t(s)("admin.login.title")),1),e("p",T,a(t(s)("admin.login.subtitle",{name:t(u).displayName})),1)]),e("form",{onSubmit:V(f,["prevent"])},[e("div",U,[e("label",j,a(t(s)("admin.login.password")),1),B(e("input",{id:"admin-password","onUpdate:modelValue":p[0]||(p[0]=v=>i.value=v),class:"input",type:"password",placeholder:t(s)("admin.login.passwordPlaceholder"),autocomplete:"current-password",autofocus:""},null,8,z),[[C,i.value]])]),n.value?(d(),m("p",H,a(n.value),1)):_("",!0),e("button",{class:"btn btn-block",type:"submit",disabled:l.value},[l.value?(d(),m("span",G)):_("",!0),N(" "+a(t(s)("admin.login.submit")),1)],8,F)],32),e("p",J,a(t(s)("admin.login.hint")),1)])]),_:1}))}}),$=P(K,[["__scopeId","data-v-26e7f2a4"]]);export{$ as default};
+1
View File
@@ -0,0 +1 @@
import{d as w,u as b,a as y,z as x,A as k,b as e,f as t,t as a,w as V,C as B,D as C,c as m,g as _,q as N,j as r,N as S,B as q,o as d,H as L,_ as P}from"./index-D7AAbqvI.js";import{P as A}from"./PageShell-D3dyUalL.js";import{u as D}from"./auth-DjiKbqje.js";import"./SiteNav.vue_vue_type_script_setup_true_lang-B5OHbKDQ.js";import"./admin-uFxGdgNa.js";const E={class:"card",style:{"max-width":"380px",margin:"8vh auto 0"}},I={class:"login-head"},M=["src"],R={class:"card-title"},T={class:"card-sub"},U={class:"field"},j={for:"admin-password"},z=["placeholder"],H={key:0,class:"hint",style:{color:"var(--c-danger)","margin-bottom":"12px"}},F=["disabled"],G={key:0,class:"spin","aria-hidden":"true"},J={class:"hint",style:{"margin-top":"16px"}},K=w({__name:"LoginView",setup(O){const{t:s}=b(),c=S(),g=q(),h=D(),u=y(),i=r(""),l=r(!1),n=r("");async function f(){if(!i.value){n.value=s("admin.login.required");return}l.value=!0,n.value="";try{await h.login(i.value);const o=typeof c.query.redirect=="string"?c.query.redirect:"/admin/files";g.replace(o)}catch(o){n.value=o instanceof L?o.code===401?s("admin.login.wrongPassword"):o.msg:s("admin.login.failed"),i.value=""}finally{l.value=!1}}return(o,p)=>(d(),x(A,null,{default:k(()=>[e("section",E,[e("div",I,[e("img",{src:t(u).displayLogoUrl,alt:"Logo",class:"login-logo"},null,8,M),e("h1",R,a(t(s)("admin.login.title")),1),e("p",T,a(t(s)("admin.login.subtitle",{name:t(u).displayName})),1)]),e("form",{onSubmit:V(f,["prevent"])},[e("div",U,[e("label",j,a(t(s)("admin.login.password")),1),B(e("input",{id:"admin-password","onUpdate:modelValue":p[0]||(p[0]=v=>i.value=v),class:"input",type:"password",placeholder:t(s)("admin.login.passwordPlaceholder"),autocomplete:"current-password",autofocus:""},null,8,z),[[C,i.value]])]),n.value?(d(),m("p",H,a(n.value),1)):_("",!0),e("button",{class:"btn btn-block",type:"submit",disabled:l.value},[l.value?(d(),m("span",G)):_("",!0),N(" "+a(t(s)("admin.login.submit")),1)],8,F)],32),e("p",J,a(t(s)("admin.login.hint")),1)])]),_:1}))}}),$=P(K,[["__scopeId","data-v-26e7f2a4"]]);export{$ as default};
+1
View File
@@ -0,0 +1 @@
import{d as c,u as i,z as d,A as a,b as t,t as e,f as s,G as l,q as p,O as u,o as _}from"./index-D7AAbqvI.js";import{P as m}from"./PageShell-D3dyUalL.js";import"./SiteNav.vue_vue_type_script_setup_true_lang-B5OHbKDQ.js";const f={class:"card empty",style:{"max-width":"480px",margin:"10vh auto 0"}},h={style:{"font-weight":"600",color:"var(--c-text)"}},x={class:"hint"},F=c({__name:"NotFoundView",setup(y){const{t:o}=i();return(g,n)=>{const r=u("RouterLink");return _(),d(m,null,{default:a(()=>[t("div",f,[n[0]||(n[0]=t("div",{class:"empty-icon"},"🧭",-1)),t("p",h,e(s(o)("notFound.title")),1),t("p",x,e(s(o)("notFound.desc")),1),l(r,{class:"btn",to:"/",style:{"margin-top":"10px"}},{default:a(()=>[p(e(s(o)("notFound.back")),1)]),_:1})])]),_:1})}}});export{F as default};
+1
View File
@@ -0,0 +1 @@
import{d as c,u as i,z as d,A as a,b as t,t as e,f as s,G as l,q as p,O as u,o as _}from"./index-QLbKGtH7.js";import{P as m}from"./PageShell-D1DY7qw8.js";import"./SiteNav.vue_vue_type_script_setup_true_lang-8XmHL8P7.js";const f={class:"card empty",style:{"max-width":"480px",margin:"10vh auto 0"}},h={style:{"font-weight":"600",color:"var(--c-text)"}},x={class:"hint"},F=c({__name:"NotFoundView",setup(y){const{t:o}=i();return(g,n)=>{const r=u("RouterLink");return _(),d(m,null,{default:a(()=>[t("div",f,[n[0]||(n[0]=t("div",{class:"empty-icon"},"🧭",-1)),t("p",h,e(s(o)("notFound.title")),1),t("p",x,e(s(o)("notFound.desc")),1),l(r,{class:"btn",to:"/",style:{"margin-top":"10px"}},{default:a(()=>[p(e(s(o)("notFound.back")),1)]),_:1})])]),_:1})}}});export{F as default};
+1
View File
@@ -0,0 +1 @@
import{d as c,u as i,z as d,A as a,b as t,t as e,f as s,G as l,q as p,O as u,o as _}from"./index-DSPQyv0Z.js";import{P as m}from"./PageShell-Bv1Rpp4p.js";import"./SiteNav.vue_vue_type_script_setup_true_lang-DSt3Q6Wq.js";const f={class:"card empty",style:{"max-width":"480px",margin:"10vh auto 0"}},h={style:{"font-weight":"600",color:"var(--c-text)"}},x={class:"hint"},F=c({__name:"NotFoundView",setup(y){const{t:o}=i();return(g,n)=>{const r=u("RouterLink");return _(),d(m,null,{default:a(()=>[t("div",f,[n[0]||(n[0]=t("div",{class:"empty-icon"},"🧭",-1)),t("p",h,e(s(o)("notFound.title")),1),t("p",x,e(s(o)("notFound.desc")),1),l(r,{class:"btn",to:"/",style:{"margin-top":"10px"}},{default:a(()=>[p(e(s(o)("notFound.back")),1)]),_:1})])]),_:1})}}});export{F as default};
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1
View File
@@ -0,0 +1 @@
import{d as h,u as g,a as k,c as n,G as i,b as t,Q as y,e as v,f as e,t as a,g as c,q as r,A as d,F as x,h as B,O as C,o as l,_ as b}from"./index-DSPQyv0Z.js";import{_ as w}from"./SiteNav.vue_vue_type_script_setup_true_lang-DSt3Q6Wq.js";const N={class:"site-footer"},S={class:"footer-left"},F={key:0,class:"footer-text"},P={key:1,class:"footer-beian"},T={key:2},V=["aria-label"],$=h({__name:"PageShell",setup(D){const{t:s}=g(),o=k(),m=new Date().getFullYear(),u=B(()=>!!(o.footerText.trim()||o.footerBeian.trim()));return(p,_)=>{const f=C("RouterLink");return l(),n(x,null,[i(w),t("main",{class:v(["page",{"page-wide":p.$route.meta.wide}])},[y(p.$slots,"default",{},void 0,!0)],2),t("footer",N,[t("div",S,[e(o).footerText.trim()?(l(),n("span",F,a(e(o).footerText),1)):c("",!0),e(o).footerBeian.trim()?(l(),n("span",P,a(e(o).footerBeian),1)):c("",!0),u.value?c("",!0):(l(),n("span",T,a(e(s)("footer.copyright",{year:e(m),name:e(o).displayName})),1))]),_[0]||(_[0]=t("span",{class:"footer-powered"},[r(" Powered by "),t("a",{href:"https://skymirror.top",target:"_blank",rel:"noopener noreferrer"},"SKYMirror"),r(" 26.9 ")],-1)),t("nav",{"aria-label":e(s)("footer.linkNav")},[i(f,{to:"/docs"},{default:d(()=>[r(a(e(s)("footer.docs")),1)]),_:1}),i(f,{to:"/openapi"},{default:d(()=>[r(a(e(s)("footer.openapi")),1)]),_:1}),i(f,{to:"/admin/files"},{default:d(()=>[r(a(e(s)("footer.admin")),1)]),_:1})],8,V)])],64)}}}),R=b($,[["__scopeId","data-v-80685d6f"]]);export{R as P};
+1
View File
@@ -0,0 +1 @@
import{d as h,u as g,a as k,c as n,G as i,b as t,Q as y,e as v,f as e,t as a,g as c,q as r,A as d,F as x,h as B,O as C,o as l,_ as b}from"./index-QLbKGtH7.js";import{_ as w}from"./SiteNav.vue_vue_type_script_setup_true_lang-8XmHL8P7.js";const N={class:"site-footer"},S={class:"footer-left"},F={key:0,class:"footer-text"},P={key:1,class:"footer-beian"},T={key:2},V=["aria-label"],$=h({__name:"PageShell",setup(D){const{t:s}=g(),o=k(),m=new Date().getFullYear(),u=B(()=>!!(o.footerText.trim()||o.footerBeian.trim()));return(p,_)=>{const f=C("RouterLink");return l(),n(x,null,[i(w),t("main",{class:v(["page",{"page-wide":p.$route.meta.wide}])},[y(p.$slots,"default",{},void 0,!0)],2),t("footer",N,[t("div",S,[e(o).footerText.trim()?(l(),n("span",F,a(e(o).footerText),1)):c("",!0),e(o).footerBeian.trim()?(l(),n("span",P,a(e(o).footerBeian),1)):c("",!0),u.value?c("",!0):(l(),n("span",T,a(e(s)("footer.copyright",{year:e(m),name:e(o).displayName})),1))]),_[0]||(_[0]=t("span",{class:"footer-powered"},[r(" Powered by "),t("a",{href:"https://skymirror.top",target:"_blank",rel:"noopener noreferrer"},"SKYMirror"),r(" 26.9 ")],-1)),t("nav",{"aria-label":e(s)("footer.linkNav")},[i(f,{to:"/docs"},{default:d(()=>[r(a(e(s)("footer.docs")),1)]),_:1}),i(f,{to:"/openapi"},{default:d(()=>[r(a(e(s)("footer.openapi")),1)]),_:1}),i(f,{to:"/admin/files"},{default:d(()=>[r(a(e(s)("footer.admin")),1)]),_:1})],8,V)])],64)}}}),R=b($,[["__scopeId","data-v-80685d6f"]]);export{R as P};
+1
View File
@@ -0,0 +1 @@
import{d as h,u as g,a as k,c as n,G as i,b as t,Q as y,e as v,f as e,t as a,g as c,q as r,A as d,F as x,h as B,O as C,o as l,_ as b}from"./index-D7AAbqvI.js";import{_ as w}from"./SiteNav.vue_vue_type_script_setup_true_lang-B5OHbKDQ.js";const N={class:"site-footer"},S={class:"footer-left"},F={key:0,class:"footer-text"},P={key:1,class:"footer-beian"},T={key:2},V=["aria-label"],$=h({__name:"PageShell",setup(D){const{t:s}=g(),o=k(),m=new Date().getFullYear(),u=B(()=>!!(o.footerText.trim()||o.footerBeian.trim()));return(p,_)=>{const f=C("RouterLink");return l(),n(x,null,[i(w),t("main",{class:v(["page",{"page-wide":p.$route.meta.wide}])},[y(p.$slots,"default",{},void 0,!0)],2),t("footer",N,[t("div",S,[e(o).footerText.trim()?(l(),n("span",F,a(e(o).footerText),1)):c("",!0),e(o).footerBeian.trim()?(l(),n("span",P,a(e(o).footerBeian),1)):c("",!0),u.value?c("",!0):(l(),n("span",T,a(e(s)("footer.copyright",{year:e(m),name:e(o).displayName})),1))]),_[0]||(_[0]=t("span",{class:"footer-powered"},[r(" Powered by "),t("a",{href:"https://skymirror.top",target:"_blank",rel:"noopener noreferrer"},"SKYMirror"),r(" 26.9 ")],-1)),t("nav",{"aria-label":e(s)("footer.linkNav")},[i(f,{to:"/docs"},{default:d(()=>[r(a(e(s)("footer.docs")),1)]),_:1}),i(f,{to:"/openapi"},{default:d(()=>[r(a(e(s)("footer.openapi")),1)]),_:1}),i(f,{to:"/admin/files"},{default:d(()=>[r(a(e(s)("footer.admin")),1)]),_:1})],8,V)])],64)}}}),R=b($,[["__scopeId","data-v-80685d6f"]]);export{R as P};
@@ -0,0 +1 @@
import{d as b,u as p,c as r,b as o,t as i,f as c,h,o as f}from"./index-DSPQyv0Z.js";const v={class:"pager"},x={class:"pager-info"},k=["disabled"],M=["disabled"],y=b({__name:"Pager",props:{page:{},size:{},total:{}},emits:["change"],setup(t,{emit:m}){const a=t,d=m,{t:n}=p(),s=h(()=>Math.max(1,Math.ceil(a.total/a.size)));function g(l){const e=Math.min(Math.max(1,l),s.value);e!==a.page&&d("change",e,a.size)}return(l,e)=>(f(),r("div",v,[o("span",x,i(c(n)("common.pagerInfo",{total:t.total,page:t.page,pages:s.value})),1),o("button",{class:"btn btn-ghost btn-sm",type:"button",disabled:t.page<=1,onClick:e[0]||(e[0]=u=>g(t.page-1))},i(c(n)("common.previousPage")),9,k),o("button",{class:"btn btn-ghost btn-sm",type:"button",disabled:t.page>=s.value,onClick:e[1]||(e[1]=u=>g(t.page+1))},i(c(n)("common.nextPage")),9,M)]))}});export{y as _};
@@ -0,0 +1 @@
import{d as b,u as p,c as r,b as o,t as i,f as c,h,o as f}from"./index-D7AAbqvI.js";const v={class:"pager"},x={class:"pager-info"},k=["disabled"],M=["disabled"],y=b({__name:"Pager",props:{page:{},size:{},total:{}},emits:["change"],setup(t,{emit:m}){const a=t,d=m,{t:n}=p(),s=h(()=>Math.max(1,Math.ceil(a.total/a.size)));function g(l){const e=Math.min(Math.max(1,l),s.value);e!==a.page&&d("change",e,a.size)}return(l,e)=>(f(),r("div",v,[o("span",x,i(c(n)("common.pagerInfo",{total:t.total,page:t.page,pages:s.value})),1),o("button",{class:"btn btn-ghost btn-sm",type:"button",disabled:t.page<=1,onClick:e[0]||(e[0]=u=>g(t.page-1))},i(c(n)("common.previousPage")),9,k),o("button",{class:"btn btn-ghost btn-sm",type:"button",disabled:t.page>=s.value,onClick:e[1]||(e[1]=u=>g(t.page+1))},i(c(n)("common.nextPage")),9,M)]))}});export{y as _};
@@ -0,0 +1 @@
import{d as b,u as p,c as r,b as o,t as i,f as c,h,o as f}from"./index-QLbKGtH7.js";const v={class:"pager"},x={class:"pager-info"},k=["disabled"],M=["disabled"],y=b({__name:"Pager",props:{page:{},size:{},total:{}},emits:["change"],setup(t,{emit:m}){const a=t,d=m,{t:n}=p(),s=h(()=>Math.max(1,Math.ceil(a.total/a.size)));function g(l){const e=Math.min(Math.max(1,l),s.value);e!==a.page&&d("change",e,a.size)}return(l,e)=>(f(),r("div",v,[o("span",x,i(c(n)("common.pagerInfo",{total:t.total,page:t.page,pages:s.value})),1),o("button",{class:"btn btn-ghost btn-sm",type:"button",disabled:t.page<=1,onClick:e[0]||(e[0]=u=>g(t.page-1))},i(c(n)("common.previousPage")),9,k),o("button",{class:"btn btn-ghost btn-sm",type:"button",disabled:t.page>=s.value,onClick:e[1]||(e[1]=u=>g(t.page+1))},i(c(n)("common.nextPage")),9,M)]))}});export{y as _};
+1
View File
@@ -0,0 +1 @@
import{d as N,u as U,i as q,I,J as b,z as L,A as R,H as C,h as T,j as p,b as t,c as u,q as z,t as s,f as n,w as E,C as H,D as $,F as y,s as _,g as w,K as j,L as J,n as K,M as B,k as W,N as G,B as O,o as l}from"./index-DSPQyv0Z.js";import{P as Q}from"./PageShell-Bv1Rpp4p.js";import{g as X,p as Y,b as Z}from"./share-DQTp5ax3.js";import"./SiteNav.vue_vue_type_script_setup_true_lang-DSt3Q6Wq.js";const ee={class:"card",style:{"max-width":"640px",margin:"12px auto 0"}},te={key:0,class:"loading-block"},ae={class:"empty"},oe={style:{"font-weight":"600",color:"var(--c-text)"}},ne={class:"hint"},se=["placeholder"],ie={class:"btn",type:"submit"},le={style:{display:"flex","align-items":"center",gap:"10px","flex-wrap":"wrap","margin-bottom":"4px"}},ue={class:"badge"},re={style:{"font-size":"16px","word-break":"break-all"}},ce={key:0,class:"hint",style:{margin:"0"}},pe={class:"hint",style:{"margin-bottom":"18px"}},de={key:0,class:"loading-block"},me={class:"text-view"},ve={style:{display:"flex",gap:"10px","margin-top":"14px","flex-wrap":"wrap"}},ye={class:"file-summary"},ke={style:{"font-weight":"600","word-break":"break-all"}},fe={class:"hint",style:{margin:"0"}},ge={key:0,style:{margin:"16px 0 6px"}},he={class:"progress"},xe={class:"hint"},_e=["disabled"],Be=N({__name:"PickupView",setup(we){const{t:e}=U(),D=G(),F=O(),k=q(),d=T(()=>String(D.params.code??"").trim().split(/\s+/)[0]),r=p("loading"),m=p(""),o=p(null),v=p(""),f=p(!1),c=p(null);async function g(){if(!d.value){r.value="error",m.value=e("pickup.emptyCode");return}r.value="loading",m.value="",o.value=null,v.value="";try{const a=await X(d.value);if(o.value=a,r.value="ready",a.isText){f.value=!0;try{v.value=await Y(d.value)}finally{f.value=!1}}}catch(a){r.value="error",m.value=a instanceof C?a.code===404?e("pickup.notFound"):a.code===423?e("home.rateLimited"):a.code===428?e("home.notInitialized"):a.msg:e("pickup.failedDefault")}}I(g),b(d,()=>{g()}),b(()=>e("pickup.emptyCode"),()=>{r.value==="error"&&m.value&&g()});async function A(){if(o.value){c.value=0;try{const{blob:a,filename:i}=await Z(d.value,x=>c.value=x);B(a,i||o.value.name||"download"),k.success(e("pickup.downloaded"))}catch(a){k.error(a instanceof C?a.msg:e("pickup.downloadFailed"))}finally{c.value=null}}}async function M(){await W(v.value)?k.success(e("pickup.copied")):k.error(e("common.copyFailed"))}function P(){if(!o.value)return;const a=new Blob([v.value],{type:"text/plain;charset=utf-8"}),i=o.value.name?.includes(".")?o.value.name:`${o.value.name||"text"}.txt`;B(a,i)}const h=p("");function S(){const a=h.value.trim();a&&F.push({name:"pickup",params:{code:a}})}const V=T(()=>{const a=o.value;return a?a.remainingDownloads===null||a.remainingDownloads===void 0||a.remainingDownloads<0?e("pickup.remainingUnlimited"):e("pickup.remainingCount",{n:a.remainingDownloads}):""});return(a,i)=>(l(),L(Q,null,{default:R(()=>[t("section",ee,[r.value==="loading"?(l(),u("div",te,[i[1]||(i[1]=t("span",{class:"spin","aria-hidden":"true"},null,-1)),z(" "+s(n(e)("pickup.querying",{code:d.value})),1)])):r.value==="error"?(l(),u(y,{key:1},[t("div",ae,[i[2]||(i[2]=t("div",{class:"empty-icon"},"📮",-1)),t("p",oe,s(m.value||n(e)("pickup.failed")),1),t("p",ne,s(n(e)("pickup.confirmHint")),1)]),t("form",{class:"quick-pickup",style:{"margin-top":"6px","max-width":"none"},onSubmit:E(S,["prevent"])},[H(t("input",{"onUpdate:modelValue":i[0]||(i[0]=x=>h.value=x),class:"input input-mono",placeholder:n(e)("pickup.retryPlaceholder"),maxlength:"32"},null,8,se),[[$,h.value]]),t("button",ie,s(n(e)("pickup.retryButton")),1)],32)],64)):o.value?(l(),u(y,{key:2},[t("div",le,[t("span",ue,s(o.value.isText?n(e)("common.text"):n(e)("common.file")),1),t("strong",re,s(o.value.name),1),o.value.isText?w("",!0):(l(),u("span",ce,s(n(_)(o.value.size)),1))]),t("p",pe,s(V.value)+" · "+s(n(e)("pickup.expireAt",{time:o.value.expiredAt?n(j)(o.value.expiredAt):n(e)("time.permanent")}))+" · "+s(n(J)(o.value.expiredAt)),1),o.value.isText?(l(),u(y,{key:0},[f.value?(l(),u("div",de,[i[3]||(i[3]=t("span",{class:"spin","aria-hidden":"true"},null,-1)),z(" "+s(n(e)("pickup.loadingText")),1)])):(l(),u(y,{key:1},[t("pre",me,s(v.value),1),t("div",ve,[t("button",{class:"btn",type:"button",onClick:M},s(n(e)("pickup.copyContent")),1),t("button",{class:"btn btn-ghost",type:"button",onClick:P},s(n(e)("pickup.downloadTxt")),1)])],64))],64)):(l(),u(y,{key:1},[t("div",ye,[i[4]||(i[4]=t("span",{style:{"font-size":"30px"},"aria-hidden":"true"},"📄",-1)),t("div",null,[t("div",ke,s(o.value.name),1),t("div",fe,s(n(e)("pickup.sizeUsed",{size:n(_)(o.value.size),n:o.value.usedCount})),1)])]),c.value!==null?(l(),u("div",ge,[t("div",he,[t("i",{style:K({width:`${c.value}%`})},null,4)]),t("p",xe,s(n(e)("pickup.downloading",{percent:c.value})),1)])):w("",!0),t("button",{class:"btn btn-block",type:"button",disabled:c.value!==null,onClick:A}," ⬇ "+s(n(e)("pickup.downloadFile",{size:n(_)(o.value.size)})),9,_e)],64))],64)):w("",!0)])]),_:1}))}});export{Be as default};
+1
View File
@@ -0,0 +1 @@
import{d as N,u as U,i as q,I,J as b,z as L,A as R,H as C,h as T,j as p,b as t,c as u,q as z,t as s,f as n,w as E,C as H,D as $,F as y,s as _,g as w,K as j,L as J,n as K,M as B,k as W,N as G,B as O,o as l}from"./index-D7AAbqvI.js";import{P as Q}from"./PageShell-D3dyUalL.js";import{g as X,p as Y,b as Z}from"./share-x2wQCCnt.js";import"./SiteNav.vue_vue_type_script_setup_true_lang-B5OHbKDQ.js";const ee={class:"card",style:{"max-width":"640px",margin:"12px auto 0"}},te={key:0,class:"loading-block"},ae={class:"empty"},oe={style:{"font-weight":"600",color:"var(--c-text)"}},ne={class:"hint"},se=["placeholder"],ie={class:"btn",type:"submit"},le={style:{display:"flex","align-items":"center",gap:"10px","flex-wrap":"wrap","margin-bottom":"4px"}},ue={class:"badge"},re={style:{"font-size":"16px","word-break":"break-all"}},ce={key:0,class:"hint",style:{margin:"0"}},pe={class:"hint",style:{"margin-bottom":"18px"}},de={key:0,class:"loading-block"},me={class:"text-view"},ve={style:{display:"flex",gap:"10px","margin-top":"14px","flex-wrap":"wrap"}},ye={class:"file-summary"},ke={style:{"font-weight":"600","word-break":"break-all"}},fe={class:"hint",style:{margin:"0"}},ge={key:0,style:{margin:"16px 0 6px"}},he={class:"progress"},xe={class:"hint"},_e=["disabled"],Be=N({__name:"PickupView",setup(we){const{t:e}=U(),D=G(),F=O(),k=q(),d=T(()=>String(D.params.code??"").trim().split(/\s+/)[0]),r=p("loading"),m=p(""),o=p(null),v=p(""),f=p(!1),c=p(null);async function g(){if(!d.value){r.value="error",m.value=e("pickup.emptyCode");return}r.value="loading",m.value="",o.value=null,v.value="";try{const a=await X(d.value);if(o.value=a,r.value="ready",a.isText){f.value=!0;try{v.value=await Y(d.value)}finally{f.value=!1}}}catch(a){r.value="error",m.value=a instanceof C?a.code===404?e("pickup.notFound"):a.code===423?e("home.rateLimited"):a.code===428?e("home.notInitialized"):a.msg:e("pickup.failedDefault")}}I(g),b(d,()=>{g()}),b(()=>e("pickup.emptyCode"),()=>{r.value==="error"&&m.value&&g()});async function A(){if(o.value){c.value=0;try{const{blob:a,filename:i}=await Z(d.value,x=>c.value=x);B(a,i||o.value.name||"download"),k.success(e("pickup.downloaded"))}catch(a){k.error(a instanceof C?a.msg:e("pickup.downloadFailed"))}finally{c.value=null}}}async function M(){await W(v.value)?k.success(e("pickup.copied")):k.error(e("common.copyFailed"))}function P(){if(!o.value)return;const a=new Blob([v.value],{type:"text/plain;charset=utf-8"}),i=o.value.name?.includes(".")?o.value.name:`${o.value.name||"text"}.txt`;B(a,i)}const h=p("");function S(){const a=h.value.trim();a&&F.push({name:"pickup",params:{code:a}})}const V=T(()=>{const a=o.value;return a?a.remainingDownloads===null||a.remainingDownloads===void 0||a.remainingDownloads<0?e("pickup.remainingUnlimited"):e("pickup.remainingCount",{n:a.remainingDownloads}):""});return(a,i)=>(l(),L(Q,null,{default:R(()=>[t("section",ee,[r.value==="loading"?(l(),u("div",te,[i[1]||(i[1]=t("span",{class:"spin","aria-hidden":"true"},null,-1)),z(" "+s(n(e)("pickup.querying",{code:d.value})),1)])):r.value==="error"?(l(),u(y,{key:1},[t("div",ae,[i[2]||(i[2]=t("div",{class:"empty-icon"},"📮",-1)),t("p",oe,s(m.value||n(e)("pickup.failed")),1),t("p",ne,s(n(e)("pickup.confirmHint")),1)]),t("form",{class:"quick-pickup",style:{"margin-top":"6px","max-width":"none"},onSubmit:E(S,["prevent"])},[H(t("input",{"onUpdate:modelValue":i[0]||(i[0]=x=>h.value=x),class:"input input-mono",placeholder:n(e)("pickup.retryPlaceholder"),maxlength:"32"},null,8,se),[[$,h.value]]),t("button",ie,s(n(e)("pickup.retryButton")),1)],32)],64)):o.value?(l(),u(y,{key:2},[t("div",le,[t("span",ue,s(o.value.isText?n(e)("common.text"):n(e)("common.file")),1),t("strong",re,s(o.value.name),1),o.value.isText?w("",!0):(l(),u("span",ce,s(n(_)(o.value.size)),1))]),t("p",pe,s(V.value)+" · "+s(n(e)("pickup.expireAt",{time:o.value.expiredAt?n(j)(o.value.expiredAt):n(e)("time.permanent")}))+" · "+s(n(J)(o.value.expiredAt)),1),o.value.isText?(l(),u(y,{key:0},[f.value?(l(),u("div",de,[i[3]||(i[3]=t("span",{class:"spin","aria-hidden":"true"},null,-1)),z(" "+s(n(e)("pickup.loadingText")),1)])):(l(),u(y,{key:1},[t("pre",me,s(v.value),1),t("div",ve,[t("button",{class:"btn",type:"button",onClick:M},s(n(e)("pickup.copyContent")),1),t("button",{class:"btn btn-ghost",type:"button",onClick:P},s(n(e)("pickup.downloadTxt")),1)])],64))],64)):(l(),u(y,{key:1},[t("div",ye,[i[4]||(i[4]=t("span",{style:{"font-size":"30px"},"aria-hidden":"true"},"📄",-1)),t("div",null,[t("div",ke,s(o.value.name),1),t("div",fe,s(n(e)("pickup.sizeUsed",{size:n(_)(o.value.size),n:o.value.usedCount})),1)])]),c.value!==null?(l(),u("div",ge,[t("div",he,[t("i",{style:K({width:`${c.value}%`})},null,4)]),t("p",xe,s(n(e)("pickup.downloading",{percent:c.value})),1)])):w("",!0),t("button",{class:"btn btn-block",type:"button",disabled:c.value!==null,onClick:A}," ⬇ "+s(n(e)("pickup.downloadFile",{size:n(_)(o.value.size)})),9,_e)],64))],64)):w("",!0)])]),_:1}))}});export{Be as default};
+1
View File
@@ -0,0 +1 @@
import{d as N,u as U,i as q,I,J as b,z as L,A as R,H as C,h as T,j as p,b as t,c as u,q as z,t as s,f as n,w as E,C as H,D as $,F as y,s as _,g as w,K as j,L as J,n as K,M as B,k as W,N as G,B as O,o as l}from"./index-QLbKGtH7.js";import{P as Q}from"./PageShell-D1DY7qw8.js";import{g as X,p as Y,b as Z}from"./share-Y37-qxxb.js";import"./SiteNav.vue_vue_type_script_setup_true_lang-8XmHL8P7.js";const ee={class:"card",style:{"max-width":"640px",margin:"12px auto 0"}},te={key:0,class:"loading-block"},ae={class:"empty"},oe={style:{"font-weight":"600",color:"var(--c-text)"}},ne={class:"hint"},se=["placeholder"],ie={class:"btn",type:"submit"},le={style:{display:"flex","align-items":"center",gap:"10px","flex-wrap":"wrap","margin-bottom":"4px"}},ue={class:"badge"},re={style:{"font-size":"16px","word-break":"break-all"}},ce={key:0,class:"hint",style:{margin:"0"}},pe={class:"hint",style:{"margin-bottom":"18px"}},de={key:0,class:"loading-block"},me={class:"text-view"},ve={style:{display:"flex",gap:"10px","margin-top":"14px","flex-wrap":"wrap"}},ye={class:"file-summary"},ke={style:{"font-weight":"600","word-break":"break-all"}},fe={class:"hint",style:{margin:"0"}},ge={key:0,style:{margin:"16px 0 6px"}},he={class:"progress"},xe={class:"hint"},_e=["disabled"],Be=N({__name:"PickupView",setup(we){const{t:e}=U(),D=G(),F=O(),k=q(),d=T(()=>String(D.params.code??"").trim().split(/\s+/)[0]),r=p("loading"),m=p(""),o=p(null),v=p(""),f=p(!1),c=p(null);async function g(){if(!d.value){r.value="error",m.value=e("pickup.emptyCode");return}r.value="loading",m.value="",o.value=null,v.value="";try{const a=await X(d.value);if(o.value=a,r.value="ready",a.isText){f.value=!0;try{v.value=await Y(d.value)}finally{f.value=!1}}}catch(a){r.value="error",m.value=a instanceof C?a.code===404?e("pickup.notFound"):a.code===423?e("home.rateLimited"):a.code===428?e("home.notInitialized"):a.msg:e("pickup.failedDefault")}}I(g),b(d,()=>{g()}),b(()=>e("pickup.emptyCode"),()=>{r.value==="error"&&m.value&&g()});async function A(){if(o.value){c.value=0;try{const{blob:a,filename:i}=await Z(d.value,x=>c.value=x);B(a,i||o.value.name||"download"),k.success(e("pickup.downloaded"))}catch(a){k.error(a instanceof C?a.msg:e("pickup.downloadFailed"))}finally{c.value=null}}}async function M(){await W(v.value)?k.success(e("pickup.copied")):k.error(e("common.copyFailed"))}function P(){if(!o.value)return;const a=new Blob([v.value],{type:"text/plain;charset=utf-8"}),i=o.value.name?.includes(".")?o.value.name:`${o.value.name||"text"}.txt`;B(a,i)}const h=p("");function S(){const a=h.value.trim();a&&F.push({name:"pickup",params:{code:a}})}const V=T(()=>{const a=o.value;return a?a.remainingDownloads===null||a.remainingDownloads===void 0||a.remainingDownloads<0?e("pickup.remainingUnlimited"):e("pickup.remainingCount",{n:a.remainingDownloads}):""});return(a,i)=>(l(),L(Q,null,{default:R(()=>[t("section",ee,[r.value==="loading"?(l(),u("div",te,[i[1]||(i[1]=t("span",{class:"spin","aria-hidden":"true"},null,-1)),z(" "+s(n(e)("pickup.querying",{code:d.value})),1)])):r.value==="error"?(l(),u(y,{key:1},[t("div",ae,[i[2]||(i[2]=t("div",{class:"empty-icon"},"📮",-1)),t("p",oe,s(m.value||n(e)("pickup.failed")),1),t("p",ne,s(n(e)("pickup.confirmHint")),1)]),t("form",{class:"quick-pickup",style:{"margin-top":"6px","max-width":"none"},onSubmit:E(S,["prevent"])},[H(t("input",{"onUpdate:modelValue":i[0]||(i[0]=x=>h.value=x),class:"input input-mono",placeholder:n(e)("pickup.retryPlaceholder"),maxlength:"32"},null,8,se),[[$,h.value]]),t("button",ie,s(n(e)("pickup.retryButton")),1)],32)],64)):o.value?(l(),u(y,{key:2},[t("div",le,[t("span",ue,s(o.value.isText?n(e)("common.text"):n(e)("common.file")),1),t("strong",re,s(o.value.name),1),o.value.isText?w("",!0):(l(),u("span",ce,s(n(_)(o.value.size)),1))]),t("p",pe,s(V.value)+" · "+s(n(e)("pickup.expireAt",{time:o.value.expiredAt?n(j)(o.value.expiredAt):n(e)("time.permanent")}))+" · "+s(n(J)(o.value.expiredAt)),1),o.value.isText?(l(),u(y,{key:0},[f.value?(l(),u("div",de,[i[3]||(i[3]=t("span",{class:"spin","aria-hidden":"true"},null,-1)),z(" "+s(n(e)("pickup.loadingText")),1)])):(l(),u(y,{key:1},[t("pre",me,s(v.value),1),t("div",ve,[t("button",{class:"btn",type:"button",onClick:M},s(n(e)("pickup.copyContent")),1),t("button",{class:"btn btn-ghost",type:"button",onClick:P},s(n(e)("pickup.downloadTxt")),1)])],64))],64)):(l(),u(y,{key:1},[t("div",ye,[i[4]||(i[4]=t("span",{style:{"font-size":"30px"},"aria-hidden":"true"},"📄",-1)),t("div",null,[t("div",ke,s(o.value.name),1),t("div",fe,s(n(e)("pickup.sizeUsed",{size:n(_)(o.value.size),n:o.value.usedCount})),1)])]),c.value!==null?(l(),u("div",ge,[t("div",he,[t("i",{style:K({width:`${c.value}%`})},null,4)]),t("p",xe,s(n(e)("pickup.downloading",{percent:c.value})),1)])):w("",!0),t("button",{class:"btn btn-block",type:"button",disabled:c.value!==null,onClick:A}," ⬇ "+s(n(e)("pickup.downloadFile",{size:n(_)(o.value.size)})),9,_e)],64))],64)):w("",!0)])]),_:1}))}});export{Be as default};
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1
View File
@@ -0,0 +1 @@
.settings-grid[data-v-11453c11]{display:grid;gap:16px}.save-row[data-v-11453c11]{display:flex;justify-content:flex-end}.notify-switch-row[data-v-11453c11]{display:flex;align-items:center;justify-content:space-between;gap:12px}.notify-switch-row label[data-v-11453c11]{margin-bottom:0}.unit-row[data-v-11453c11]{display:flex;gap:8px;align-items:center}.unit-row .input[data-v-11453c11]{flex:1}.unit-select[data-v-11453c11]{flex:0 0 110px!important}
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
import{d as z,u as E,a as x,c as i,G as B,f as t,A as m,b as a,F as _,r as v,az as S,t as r,h as d,aA as p,aB as T,O as A,o,z as M,q as V,e as b,aC as w}from"./index-QLbKGtH7.js";const D={class:"site-nav"},F=["src"],I={class:"brand-name"},O=["aria-label"],R={class:"nav-controls"},U=["aria-label"],q=["aria-checked","title","onClick"],G={class:"nav-control-icon","aria-hidden":"true"},H=["title"],K=z({__name:"SiteNav",setup($){const{t:s}=E(),c=x(),{mode:u,setMode:k}=w(),g=d(()=>[{to:"/",label:s("nav.home"),match:n=>n==="/"}]);function y(n){return n.match(location.pathname)}const C={light:"☀️",dark:"🌙",system:"💻"},f=d(()=>({light:s("theme.light"),dark:s("theme.dark"),system:s("theme.system")})),L=d(()=>p()==="zh-CN"?"中文":"English");function N(){const n=p()==="zh-CN"?"en-US":"zh-CN";T(n)}return(n,l)=>{const h=A("RouterLink");return o(),i("header",D,[B(h,{class:"brand",to:"/",title:t(s)("nav.homeTitle",{name:t(c).displayName})},{default:m(()=>[a("img",{src:t(c).displayLogoUrl,alt:"Logo",onError:l[0]||(l[0]=e=>e.target.style.visibility="hidden")},null,40,F),a("span",I,r(t(c).displayName),1)]),_:1},8,["title"]),a("nav",{class:"nav-links","aria-label":t(s)("nav.mainNav")},[(o(!0),i(_,null,v(g.value,e=>(o(),M(h,{key:e.to,to:e.to,class:b({"router-link-active":y(e)})},{default:m(()=>[V(r(e.label),1)]),_:2},1032,["to","class"]))),128))],8,O),a("div",R,[a("div",{class:"theme-seg",role:"radiogroup","aria-label":t(s)("theme.label")},[(o(!0),i(_,null,v(t(S),e=>(o(),i("button",{key:e,type:"button",role:"radio","aria-checked":t(u)===e,class:b({active:t(u)===e}),title:f.value[e],onClick:j=>t(k)(e)},[a("span",G,r(C[e]),1)],10,q))),128))],8,U),a("button",{class:"nav-control",type:"button",title:t(s)("lang.label"),onClick:N},[l[1]||(l[1]=a("span",{class:"nav-control-icon","aria-hidden":"true"},"🌐",-1)),a("span",null,r(L.value),1)],8,H)])])}}});export{K as _};
@@ -0,0 +1 @@
import{d as z,u as E,a as x,c as i,G as B,f as t,A as m,b as a,F as _,r as v,az as S,t as r,h as d,aA as p,aB as T,O as A,o,z as M,q as V,e as b,aC as w}from"./index-D7AAbqvI.js";const D={class:"site-nav"},F=["src"],I={class:"brand-name"},O=["aria-label"],R={class:"nav-controls"},U=["aria-label"],q=["aria-checked","title","onClick"],G={class:"nav-control-icon","aria-hidden":"true"},H=["title"],K=z({__name:"SiteNav",setup($){const{t:s}=E(),c=x(),{mode:u,setMode:k}=w(),g=d(()=>[{to:"/",label:s("nav.home"),match:n=>n==="/"}]);function y(n){return n.match(location.pathname)}const C={light:"☀️",dark:"🌙",system:"💻"},f=d(()=>({light:s("theme.light"),dark:s("theme.dark"),system:s("theme.system")})),L=d(()=>p()==="zh-CN"?"中文":"English");function N(){const n=p()==="zh-CN"?"en-US":"zh-CN";T(n)}return(n,l)=>{const h=A("RouterLink");return o(),i("header",D,[B(h,{class:"brand",to:"/",title:t(s)("nav.homeTitle",{name:t(c).displayName})},{default:m(()=>[a("img",{src:t(c).displayLogoUrl,alt:"Logo",onError:l[0]||(l[0]=e=>e.target.style.visibility="hidden")},null,40,F),a("span",I,r(t(c).displayName),1)]),_:1},8,["title"]),a("nav",{class:"nav-links","aria-label":t(s)("nav.mainNav")},[(o(!0),i(_,null,v(g.value,e=>(o(),M(h,{key:e.to,to:e.to,class:b({"router-link-active":y(e)})},{default:m(()=>[V(r(e.label),1)]),_:2},1032,["to","class"]))),128))],8,O),a("div",R,[a("div",{class:"theme-seg",role:"radiogroup","aria-label":t(s)("theme.label")},[(o(!0),i(_,null,v(t(S),e=>(o(),i("button",{key:e,type:"button",role:"radio","aria-checked":t(u)===e,class:b({active:t(u)===e}),title:f.value[e],onClick:j=>t(k)(e)},[a("span",G,r(C[e]),1)],10,q))),128))],8,U),a("button",{class:"nav-control",type:"button",title:t(s)("lang.label"),onClick:N},[l[1]||(l[1]=a("span",{class:"nav-control-icon","aria-hidden":"true"},"🌐",-1)),a("span",null,r(L.value),1)],8,H)])])}}});export{K as _};
@@ -0,0 +1 @@
import{d as z,u as E,a as x,c as i,G as B,f as t,A as m,b as a,F as _,r as v,az as S,t as r,h as d,aA as p,aB as T,O as A,o,z as M,q as V,e as b,aC as w}from"./index-DSPQyv0Z.js";const D={class:"site-nav"},F=["src"],I={class:"brand-name"},O=["aria-label"],R={class:"nav-controls"},U=["aria-label"],q=["aria-checked","title","onClick"],G={class:"nav-control-icon","aria-hidden":"true"},H=["title"],K=z({__name:"SiteNav",setup($){const{t:s}=E(),c=x(),{mode:u,setMode:k}=w(),g=d(()=>[{to:"/",label:s("nav.home"),match:n=>n==="/"}]);function y(n){return n.match(location.pathname)}const C={light:"☀️",dark:"🌙",system:"💻"},f=d(()=>({light:s("theme.light"),dark:s("theme.dark"),system:s("theme.system")})),L=d(()=>p()==="zh-CN"?"中文":"English");function N(){const n=p()==="zh-CN"?"en-US":"zh-CN";T(n)}return(n,l)=>{const h=A("RouterLink");return o(),i("header",D,[B(h,{class:"brand",to:"/",title:t(s)("nav.homeTitle",{name:t(c).displayName})},{default:m(()=>[a("img",{src:t(c).displayLogoUrl,alt:"Logo",onError:l[0]||(l[0]=e=>e.target.style.visibility="hidden")},null,40,F),a("span",I,r(t(c).displayName),1)]),_:1},8,["title"]),a("nav",{class:"nav-links","aria-label":t(s)("nav.mainNav")},[(o(!0),i(_,null,v(g.value,e=>(o(),M(h,{key:e.to,to:e.to,class:b({"router-link-active":y(e)})},{default:m(()=>[V(r(e.label),1)]),_:2},1032,["to","class"]))),128))],8,O),a("div",R,[a("div",{class:"theme-seg",role:"radiogroup","aria-label":t(s)("theme.label")},[(o(!0),i(_,null,v(t(S),e=>(o(),i("button",{key:e,type:"button",role:"radio","aria-checked":t(u)===e,class:b({active:t(u)===e}),title:f.value[e],onClick:j=>t(k)(e)},[a("span",G,r(C[e]),1)],10,q))),128))],8,U),a("button",{class:"nav-control",type:"button",title:t(s)("lang.label"),onClick:N},[l[1]||(l[1]=a("span",{class:"nav-control-icon","aria-hidden":"true"},"🌐",-1)),a("span",null,r(L.value),1)],8,H)])])}}});export{K as _};
+1
View File
@@ -0,0 +1 @@
import{v as n,S as e,x as s}from"./index-QLbKGtH7.js";function u(t){return n(s.adminLogin,{method:"POST",json:{password:t}})}function m(){return n(s.adminVerify)}async function l(){await n(s.adminLogout,{method:"POST"})}async function g(t){const a=await n(s.adminFileList,{query:{page:t.page,size:t.size,keyword:t.keyword||void 0}}),o=e(a,["data","list","items","files"])??[],d=Number(e(a,["total","count"])??o.length);return{page:Number(e(a,["page"])??t.page),size:Number(e(a,["size"])??t.size),total:d,data:o.map(i=>({id:Number(e(i,["id"])??0),code:String(e(i,["code"])??""),name:String(e(i,["name"])??`${e(i,["prefix"])??""}${e(i,["suffix"])??""}`),suffix:String(e(i,["suffix"])??""),size:Number(e(i,["size"])??0),isText:!!(e(i,["isText","is_text"])??!1),expiredAt:e(i,["expiredAt","expired_at","expires_at"])??null,expiredCount:e(i,["expiredCount","expired_count"])??null,usedCount:Number(e(i,["usedCount","used_count"])??0),createdAt:e(i,["createdAt","created_at"])??null,isExpired:!!(e(i,["isExpired","is_expired"])??!1)}))}}async function p(t){await n(s.adminFileDelete,{method:"DELETE",json:{id:t}})}async function f(t){await n(s.adminFileBatchDelete,{method:"POST",json:{ids:t}})}async function y(t){await n(s.adminFileUpdate,{method:"PATCH",json:t})}async function w(){const t=await n(s.adminConfigGet);if(t&&typeof t=="object"&&!Array.isArray(t)){const a=t;return e(a,["config","data","settings"])??a}return{}}async function S(t){await n(s.adminConfigUpdate,{method:"PATCH",json:t})}async function _(t){const a=await n(s.adminStorageSwitch,{method:"POST",json:{engine:t}});return String(a?.engine??t)}async function x(t,a){await n(s.adminPasswordUpdate,{method:"PATCH",json:{old_password:t,new_password:a}})}async function b(t){const a=await n(s.adminAuditList,{query:{page:t.page,size:t.size,action:t.action||void 0,result:t.result||void 0,ip:t.ip||void 0,start_time:t.startTime||void 0,end_time:t.endTime||void 0}}),o=e(a,["data","list","items","logs"])??[],d=Number(e(a,["total","count"])??o.length);return{page:Number(e(a,["page"])??t.page),size:Number(e(a,["size"])??t.size),total:d,data:o.map((i,r)=>({id:Number(e(i,["id"])??r+1),action:String(e(i,["action"])??""),result:String(e(i,["result"])??""),fileCode:String(e(i,["file_code","fileCode","code"])??""),fileName:String(e(i,["file_name","fileName","name"])??""),sizeBytes:e(i,["size_bytes","sizeBytes","size"])??null,transferredBytes:e(i,["transferred_bytes","transferredBytes","bytes"])??null,ip:String(e(i,["ip","client_ip","clientIp"])??""),userAgent:String(e(i,["user_agent","userAgent"])??""),deviceOs:String(e(i,["device_os","deviceOs","os"])??""),deviceBrowser:String(e(i,["device_browser","deviceBrowser","browser"])??""),deviceType:String(e(i,["device_type","deviceType"])??""),actor:String(e(i,["actor"])??""),errorMsg:String(e(i,["error_msg","errorMsg","error"])??""),durationMs:e(i,["duration_ms","durationMs","duration"])??null,createdAt:e(i,["created_at","createdAt","time"])??null}))}}export{g as a,f as b,p as c,y as d,b as e,w as f,_ as g,S as h,x as i,l as j,m as k,u as l};
+1
View File
@@ -0,0 +1 @@
import{v as n,S as e,x as s}from"./index-DSPQyv0Z.js";function u(t){return n(s.adminLogin,{method:"POST",json:{password:t}})}function m(){return n(s.adminVerify)}async function l(){await n(s.adminLogout,{method:"POST"})}async function g(t){const a=await n(s.adminFileList,{query:{page:t.page,size:t.size,keyword:t.keyword||void 0}}),o=e(a,["data","list","items","files"])??[],d=Number(e(a,["total","count"])??o.length);return{page:Number(e(a,["page"])??t.page),size:Number(e(a,["size"])??t.size),total:d,data:o.map(i=>({id:Number(e(i,["id"])??0),code:String(e(i,["code"])??""),name:String(e(i,["name"])??`${e(i,["prefix"])??""}${e(i,["suffix"])??""}`),suffix:String(e(i,["suffix"])??""),size:Number(e(i,["size"])??0),isText:!!(e(i,["isText","is_text"])??!1),expiredAt:e(i,["expiredAt","expired_at","expires_at"])??null,expiredCount:e(i,["expiredCount","expired_count"])??null,usedCount:Number(e(i,["usedCount","used_count"])??0),createdAt:e(i,["createdAt","created_at"])??null,isExpired:!!(e(i,["isExpired","is_expired"])??!1)}))}}async function p(t){await n(s.adminFileDelete,{method:"DELETE",json:{id:t}})}async function f(t){await n(s.adminFileBatchDelete,{method:"POST",json:{ids:t}})}async function y(t){await n(s.adminFileUpdate,{method:"PATCH",json:t})}async function w(){const t=await n(s.adminConfigGet);if(t&&typeof t=="object"&&!Array.isArray(t)){const a=t;return e(a,["config","data","settings"])??a}return{}}async function S(t){await n(s.adminConfigUpdate,{method:"PATCH",json:t})}async function _(t){const a=await n(s.adminStorageSwitch,{method:"POST",json:{engine:t}});return String(a?.engine??t)}async function x(t,a){await n(s.adminPasswordUpdate,{method:"PATCH",json:{old_password:t,new_password:a}})}async function b(t){const a=await n(s.adminAuditList,{query:{page:t.page,size:t.size,action:t.action||void 0,result:t.result||void 0,ip:t.ip||void 0,start_time:t.startTime||void 0,end_time:t.endTime||void 0}}),o=e(a,["data","list","items","logs"])??[],d=Number(e(a,["total","count"])??o.length);return{page:Number(e(a,["page"])??t.page),size:Number(e(a,["size"])??t.size),total:d,data:o.map((i,r)=>({id:Number(e(i,["id"])??r+1),action:String(e(i,["action"])??""),result:String(e(i,["result"])??""),fileCode:String(e(i,["file_code","fileCode","code"])??""),fileName:String(e(i,["file_name","fileName","name"])??""),sizeBytes:e(i,["size_bytes","sizeBytes","size"])??null,transferredBytes:e(i,["transferred_bytes","transferredBytes","bytes"])??null,ip:String(e(i,["ip","client_ip","clientIp"])??""),userAgent:String(e(i,["user_agent","userAgent"])??""),deviceOs:String(e(i,["device_os","deviceOs","os"])??""),deviceBrowser:String(e(i,["device_browser","deviceBrowser","browser"])??""),deviceType:String(e(i,["device_type","deviceType"])??""),actor:String(e(i,["actor"])??""),errorMsg:String(e(i,["error_msg","errorMsg","error"])??""),durationMs:e(i,["duration_ms","durationMs","duration"])??null,createdAt:e(i,["created_at","createdAt","time"])??null}))}}export{g as a,f as b,p as c,y as d,b as e,w as f,_ as g,S as h,x as i,l as j,m as k,u as l};
+1
View File
@@ -0,0 +1 @@
import{v as n,S as e,x as s}from"./index-D7AAbqvI.js";function u(t){return n(s.adminLogin,{method:"POST",json:{password:t}})}function m(){return n(s.adminVerify)}async function l(){await n(s.adminLogout,{method:"POST"})}async function g(t){const a=await n(s.adminFileList,{query:{page:t.page,size:t.size,keyword:t.keyword||void 0}}),o=e(a,["data","list","items","files"])??[],d=Number(e(a,["total","count"])??o.length);return{page:Number(e(a,["page"])??t.page),size:Number(e(a,["size"])??t.size),total:d,data:o.map(i=>({id:Number(e(i,["id"])??0),code:String(e(i,["code"])??""),name:String(e(i,["name"])??`${e(i,["prefix"])??""}${e(i,["suffix"])??""}`),suffix:String(e(i,["suffix"])??""),size:Number(e(i,["size"])??0),isText:!!(e(i,["isText","is_text"])??!1),expiredAt:e(i,["expiredAt","expired_at","expires_at"])??null,expiredCount:e(i,["expiredCount","expired_count"])??null,usedCount:Number(e(i,["usedCount","used_count"])??0),createdAt:e(i,["createdAt","created_at"])??null,isExpired:!!(e(i,["isExpired","is_expired"])??!1)}))}}async function p(t){await n(s.adminFileDelete,{method:"DELETE",json:{id:t}})}async function f(t){await n(s.adminFileBatchDelete,{method:"POST",json:{ids:t}})}async function y(t){await n(s.adminFileUpdate,{method:"PATCH",json:t})}async function w(){const t=await n(s.adminConfigGet);if(t&&typeof t=="object"&&!Array.isArray(t)){const a=t;return e(a,["config","data","settings"])??a}return{}}async function S(t){await n(s.adminConfigUpdate,{method:"PATCH",json:t})}async function _(t){const a=await n(s.adminStorageSwitch,{method:"POST",json:{engine:t}});return String(a?.engine??t)}async function x(t,a){await n(s.adminPasswordUpdate,{method:"PATCH",json:{old_password:t,new_password:a}})}async function b(t){const a=await n(s.adminAuditList,{query:{page:t.page,size:t.size,action:t.action||void 0,result:t.result||void 0,ip:t.ip||void 0,start_time:t.startTime||void 0,end_time:t.endTime||void 0}}),o=e(a,["data","list","items","logs"])??[],d=Number(e(a,["total","count"])??o.length);return{page:Number(e(a,["page"])??t.page),size:Number(e(a,["size"])??t.size),total:d,data:o.map((i,r)=>({id:Number(e(i,["id"])??r+1),action:String(e(i,["action"])??""),result:String(e(i,["result"])??""),fileCode:String(e(i,["file_code","fileCode","code"])??""),fileName:String(e(i,["file_name","fileName","name"])??""),sizeBytes:e(i,["size_bytes","sizeBytes","size"])??null,transferredBytes:e(i,["transferred_bytes","transferredBytes","bytes"])??null,ip:String(e(i,["ip","client_ip","clientIp"])??""),userAgent:String(e(i,["user_agent","userAgent"])??""),deviceOs:String(e(i,["device_os","deviceOs","os"])??""),deviceBrowser:String(e(i,["device_browser","deviceBrowser","browser"])??""),deviceType:String(e(i,["device_type","deviceType"])??""),actor:String(e(i,["actor"])??""),errorMsg:String(e(i,["error_msg","errorMsg","error"])??""),durationMs:e(i,["duration_ms","durationMs","duration"])??null,createdAt:e(i,["created_at","createdAt","time"])??null}))}}export{g as a,f as b,p as c,y as d,b as e,w as f,_ as g,S as h,x as i,l as j,m as k,u as l};
+1
View File
@@ -0,0 +1 @@
import{av as o,aw as a,W as r}from"./index-D7AAbqvI.js";import{j as n,k as i,l as c}from"./admin-uFxGdgNa.js";const s="fcb_admin_flag",l=o("auth",{state:()=>({token:r(),checked:!1}),getters:{isAuthed:t=>!!t.token},actions:{async login(t){const e=(await c(t))?.token;if(!e)throw new Error("登录响应缺少 token");this.token=e,a(e);try{localStorage.setItem(s,"1")}catch{}this.checked=!0},async verify(){if(!this.token)return!1;try{return await i(),this.checked=!0,!0}catch{return this.reset(),!1}},async logout(){try{this.token&&await n()}catch{}this.reset()},reset(){this.token="",this.checked=!1,a("");try{localStorage.removeItem(s)}catch{}}}});export{l as u};
+1
View File
@@ -0,0 +1 @@
import{av as o,aw as a,W as r}from"./index-QLbKGtH7.js";import{j as n,k as i,l as c}from"./admin-BZX1cFNW.js";const s="fcb_admin_flag",l=o("auth",{state:()=>({token:r(),checked:!1}),getters:{isAuthed:t=>!!t.token},actions:{async login(t){const e=(await c(t))?.token;if(!e)throw new Error("登录响应缺少 token");this.token=e,a(e);try{localStorage.setItem(s,"1")}catch{}this.checked=!0},async verify(){if(!this.token)return!1;try{return await i(),this.checked=!0,!0}catch{return this.reset(),!1}},async logout(){try{this.token&&await n()}catch{}this.reset()},reset(){this.token="",this.checked=!1,a("");try{localStorage.removeItem(s)}catch{}}}});export{l as u};
+1
View File
@@ -0,0 +1 @@
import{av as o,aw as a,W as r}from"./index-DSPQyv0Z.js";import{j as n,k as i,l as c}from"./admin-IDpKsD_2.js";const s="fcb_admin_flag",l=o("auth",{state:()=>({token:r(),checked:!1}),getters:{isAuthed:t=>!!t.token},actions:{async login(t){const e=(await c(t))?.token;if(!e)throw new Error("登录响应缺少 token");this.token=e,a(e);try{localStorage.setItem(s,"1")}catch{}this.checked=!0},async verify(){if(!this.token)return!1;try{return await i(),this.checked=!0,!0}catch{return this.reset(),!1}},async logout(){try{this.token&&await n()}catch{}this.reset()},reset(){this.token="",this.checked=!1,a("");try{localStorage.removeItem(s)}catch{}}}});export{l as u};
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1
View File
@@ -0,0 +1 @@
import{v as x,S as s,U as m,V as g,x as u,W as w,H as i,X as f,Y as h}from"./index-DSPQyv0Z.js";function T(n,t,r,a=""){return x(u.shareText,{method:"POST",form:{text:n,expire_value:String(t),expire_style:r,code:a}})}function y(n,t,r,a,o=""){const e=new FormData;return e.append("file",n),e.append("expire_value",String(t)),e.append("expire_style",r),o&&e.append("code",o),h(u.shareFile,e,a)}async function S(n){const t=await x(u.shareMetadata,{query:{code:n}}),r=!!(s(t,["is_text","isText"])??s(t,["type"])==="text");return{code:String(s(t,["code"])??n),name:String(s(t,["name"])??"未命名"),size:Number(s(t,["size"])??0),type:r?"text":"file",isText:r,createdAt:s(t,["created_at","createdAt"])??null,expiredAt:s(t,["expired_at","expires_at","expiredAt","expiresAt"])??null,expiredCount:s(t,["expired_count","expiredCount","remaining_downloads","remainingDownloads"])??null,usedCount:Number(s(t,["used_count","usedCount"])??0),remainingDownloads:s(t,["remaining_downloads","remainingDownloads","expired_count","expiredCount"])??null}}async function b(n){const{blob:t}=await m(u.shareSelect,{query:{code:n}});return t.text()}function A(n,t){return new Promise((r,a)=>{const o=new URL(g(u.shareSelect),location.origin);o.searchParams.set("code",n);const e=new XMLHttpRequest;e.open("GET",o.toString()),e.timeout=6e5,e.responseType="blob";const l=w();l&&e.setRequestHeader("Authorization",`Bearer ${l}`),e.onprogress=p=>{p.lengthComputable&&t&&t(Math.round(p.loaded/p.total*100))},e.onload=()=>{if((e.getResponseHeader("content-type")??"").includes("application/json")){const d=new FileReader;d.onload=()=>{try{const c=JSON.parse(String(d.result));a(new i(c.code??e.status,c.msg||"取件失败",e.status))}catch{a(new i(e.status,"取件失败",e.status))}},d.readAsText(e.response);return}e.status>=200&&e.status<300?r({blob:e.response,filename:f(e.getResponseHeader("content-disposition"))}):a(new i(e.status,`取件失败(HTTP ${e.status}`,e.status))},e.onerror=()=>a(new i(0,"网络异常,取件失败")),e.ontimeout=()=>a(new i(0,"下载超时,请重试")),e.send()})}export{y as a,A as b,S as g,b as p,T as s};
+1
View File
@@ -0,0 +1 @@
import{v as x,S as s,U as m,V as g,x as u,W as w,H as i,X as f,Y as h}from"./index-QLbKGtH7.js";function T(n,t,r,a=""){return x(u.shareText,{method:"POST",form:{text:n,expire_value:String(t),expire_style:r,code:a}})}function y(n,t,r,a,o=""){const e=new FormData;return e.append("file",n),e.append("expire_value",String(t)),e.append("expire_style",r),o&&e.append("code",o),h(u.shareFile,e,a)}async function S(n){const t=await x(u.shareMetadata,{query:{code:n}}),r=!!(s(t,["is_text","isText"])??s(t,["type"])==="text");return{code:String(s(t,["code"])??n),name:String(s(t,["name"])??"未命名"),size:Number(s(t,["size"])??0),type:r?"text":"file",isText:r,createdAt:s(t,["created_at","createdAt"])??null,expiredAt:s(t,["expired_at","expires_at","expiredAt","expiresAt"])??null,expiredCount:s(t,["expired_count","expiredCount","remaining_downloads","remainingDownloads"])??null,usedCount:Number(s(t,["used_count","usedCount"])??0),remainingDownloads:s(t,["remaining_downloads","remainingDownloads","expired_count","expiredCount"])??null}}async function b(n){const{blob:t}=await m(u.shareSelect,{query:{code:n}});return t.text()}function A(n,t){return new Promise((r,a)=>{const o=new URL(g(u.shareSelect),location.origin);o.searchParams.set("code",n);const e=new XMLHttpRequest;e.open("GET",o.toString()),e.timeout=6e5,e.responseType="blob";const l=w();l&&e.setRequestHeader("Authorization",`Bearer ${l}`),e.onprogress=p=>{p.lengthComputable&&t&&t(Math.round(p.loaded/p.total*100))},e.onload=()=>{if((e.getResponseHeader("content-type")??"").includes("application/json")){const d=new FileReader;d.onload=()=>{try{const c=JSON.parse(String(d.result));a(new i(c.code??e.status,c.msg||"取件失败",e.status))}catch{a(new i(e.status,"取件失败",e.status))}},d.readAsText(e.response);return}e.status>=200&&e.status<300?r({blob:e.response,filename:f(e.getResponseHeader("content-disposition"))}):a(new i(e.status,`取件失败(HTTP ${e.status}`,e.status))},e.onerror=()=>a(new i(0,"网络异常,取件失败")),e.ontimeout=()=>a(new i(0,"下载超时,请重试")),e.send()})}export{y as a,A as b,S as g,b as p,T as s};
+1
View File
@@ -0,0 +1 @@
import{v as x,S as s,U as m,V as g,x as u,W as w,H as i,X as f,Y as h}from"./index-D7AAbqvI.js";function T(n,t,r,a=""){return x(u.shareText,{method:"POST",form:{text:n,expire_value:String(t),expire_style:r,code:a}})}function y(n,t,r,a,o=""){const e=new FormData;return e.append("file",n),e.append("expire_value",String(t)),e.append("expire_style",r),o&&e.append("code",o),h(u.shareFile,e,a)}async function S(n){const t=await x(u.shareMetadata,{query:{code:n}}),r=!!(s(t,["is_text","isText"])??s(t,["type"])==="text");return{code:String(s(t,["code"])??n),name:String(s(t,["name"])??"未命名"),size:Number(s(t,["size"])??0),type:r?"text":"file",isText:r,createdAt:s(t,["created_at","createdAt"])??null,expiredAt:s(t,["expired_at","expires_at","expiredAt","expiresAt"])??null,expiredCount:s(t,["expired_count","expiredCount","remaining_downloads","remainingDownloads"])??null,usedCount:Number(s(t,["used_count","usedCount"])??0),remainingDownloads:s(t,["remaining_downloads","remainingDownloads","expired_count","expiredCount"])??null}}async function b(n){const{blob:t}=await m(u.shareSelect,{query:{code:n}});return t.text()}function A(n,t){return new Promise((r,a)=>{const o=new URL(g(u.shareSelect),location.origin);o.searchParams.set("code",n);const e=new XMLHttpRequest;e.open("GET",o.toString()),e.timeout=6e5,e.responseType="blob";const l=w();l&&e.setRequestHeader("Authorization",`Bearer ${l}`),e.onprogress=p=>{p.lengthComputable&&t&&t(Math.round(p.loaded/p.total*100))},e.onload=()=>{if((e.getResponseHeader("content-type")??"").includes("application/json")){const d=new FileReader;d.onload=()=>{try{const c=JSON.parse(String(d.result));a(new i(c.code??e.status,c.msg||"取件失败",e.status))}catch{a(new i(e.status,"取件失败",e.status))}},d.readAsText(e.response);return}e.status>=200&&e.status<300?r({blob:e.response,filename:f(e.getResponseHeader("content-disposition"))}):a(new i(e.status,`取件失败(HTTP ${e.status}`,e.status))},e.onerror=()=>a(new i(0,"网络异常,取件失败")),e.ontimeout=()=>a(new i(0,"下载超时,请重试")),e.send()})}export{y as a,A as b,S as g,b as p,T as s};
+1 -1
View File
@@ -27,7 +27,7 @@
background: #0b0f1a; background: #0b0f1a;
} }
</style> </style>
<script type="module" crossorigin src="/assets/index-BKnWAKao.js"></script> <script type="module" crossorigin src="/assets/index-D7AAbqvI.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-CtyCxWf5.css"> <link rel="stylesheet" crossorigin href="/assets/index-CtyCxWf5.css">
</head> </head>
<body> <body>
+3
View File
@@ -6,6 +6,9 @@ v2(任务 t3):液态玻璃主题(亮/暗/跟随系统)、中英双语
背景图 / 页脚自定义 / 系统通知(读 `GET /api/v1/config` 下发的 v2 字段)、上传页策略动态渲染、 背景图 / 页脚自定义 / 系统通知(读 `GET /api/v1/config` 下发的 v2 字段)、上传页策略动态渲染、
管理设置页全量策略表单(PATCH `/admin/config/update`)。 管理设置页全量策略表单(PATCH `/admin/config/update`)。
v3.2 起在管理设置「上传频率限制」卡底部新增**上下行带宽限速**字段(MB/s,0=不限速,
提交后换算为字节/秒写入 `upload_rate`/`download_rate` KV)。
## 页面 ## 页面
| 路由 | 说明 | | 路由 | 说明 |
+4
View File
@@ -360,6 +360,10 @@ export default {
uploadCountHint: 'Min 1, max {max}', uploadCountHint: 'Min 1, max {max}',
uploadMinute: 'Window length (minutes) · uploadMinute', uploadMinute: 'Window length (minutes) · uploadMinute',
uploadMinuteHint: 'Min 1, max {max}', uploadMinuteHint: 'Min 1, max {max}',
uploadRate: 'Upload bandwidth (optional)',
uploadRateHint: '0 = unlimited; MB/s; range 0~1024. Live-effective (admin reads latest KV each request)',
downloadRate: 'Download bandwidth (optional)',
downloadRateHint: '0 = unlimited; MB/s; range 0~1024. S3 presigned direct upload cannot be throttled server-side',
// —— v3: friendly units + storage engine —— // —— v3: friendly units + storage engine ——
unitHour: 'Hour(s)', unitHour: 'Hour(s)',
unitDay: 'Day(s)', unitDay: 'Day(s)',
+4
View File
@@ -354,6 +354,10 @@ export default {
uploadCountHint: '最小 1,最大 {max}', uploadCountHint: '最小 1,最大 {max}',
uploadMinute: '频率窗口(分钟)uploadMinute', uploadMinute: '频率窗口(分钟)uploadMinute',
uploadMinuteHint: '最小 1,最大 {max}', uploadMinuteHint: '最小 1,最大 {max}',
uploadRate: '上行带宽(可选)',
uploadRateHint: '0 = 不限速;单位 MB/s;范围 0~1024。修改后立即生效(管理端读最新 KV)',
downloadRate: '下行带宽(可选)',
downloadRateHint: '0 = 不限速;单位 MB/s;范围 0~1024。S3 预签名直传(客户端→S3)无法限速',
// —— v3:单位友好化 + 存储引擎 —— // —— v3:单位友好化 + 存储引擎 ——
unitHour: '小时', unitHour: '小时',
unitDay: '天', unitDay: '天',
+47
View File
@@ -42,6 +42,7 @@ const FOOTER_BEIAN_MAX = 128
const BACKGROUND_URL_MAX = 2048 const BACKGROUND_URL_MAX = 2048
const UPLOAD_COUNT_MAX = 10000 const UPLOAD_COUNT_MAX = 10000
const UPLOAD_MINUTE_MAX = 1440 const UPLOAD_MINUTE_MAX = 1440
const RATE_MAX_MB = 1024 // 1 GiB/s 上限(与后端 KVSchema 1<<30 字节对齐)
const form = reactive({ const form = reactive({
site_name: '', site_name: '',
@@ -61,6 +62,9 @@ const form = reactive({
allowed_file_types: '', allowed_file_types: '',
uploadCount: 0, uploadCount: 0,
uploadMinute: 0, uploadMinute: 0,
// v3.2:上下行带宽(前端友好单位 = MB/s;提交时换算为字节/秒)
uploadRate: 0,
downloadRate: 0,
}) })
// —— v3:友好单位状态(canonical 单位仍为秒/字节,换算在前端)—— // —— v3:友好单位状态(canonical 单位仍为秒/字节,换算在前端)——
@@ -162,6 +166,9 @@ async function load(): Promise<void> {
form.allowed_file_types = types.join(',') form.allowed_file_types = types.join(',')
form.uploadCount = toNum(pick<unknown>(cfg, ['uploadCount', 'upload_count']), 0) form.uploadCount = toNum(pick<unknown>(cfg, ['uploadCount', 'upload_count']), 0)
form.uploadMinute = toNum(pick<unknown>(cfg, ['uploadMinute', 'upload_minute']), 0) form.uploadMinute = toNum(pick<unknown>(cfg, ['uploadMinute', 'upload_minute']), 0)
// v3.2:带宽(KV 字节/秒 → UI MB/s,0 保留为不限)
form.uploadRate = Math.round(toNum(pick<unknown>(cfg, ['upload_rate', 'uploadRate']), 0) / MB_BYTES)
form.downloadRate = Math.round(toNum(pick<unknown>(cfg, ['download_rate', 'downloadRate']), 0) / MB_BYTES)
// v3:当前引擎(get 的 _engine_hint.storage_backend 或公开 config 的 storage_engine // v3:当前引擎(get 的 _engine_hint.storage_backend 或公开 config 的 storage_engine
const hint = pick<Record<string, unknown>>(cfg, ['_engine_hint']) const hint = pick<Record<string, unknown>>(cfg, ['_engine_hint'])
const backend = hint ? String(pick<string>(hint, ['storage_backend']) ?? '') : '' const backend = hint ? String(pick<string>(hint, ['storage_backend']) ?? '') : ''
@@ -229,6 +236,9 @@ async function save(): Promise<void> {
// 上传频率限制(键名对齐参考既有 camelCase 键) // 上传频率限制(键名对齐参考既有 camelCase 键)
uploadCount: clamp(form.uploadCount || 0, 1, UPLOAD_COUNT_MAX), uploadCount: clamp(form.uploadCount || 0, 1, UPLOAD_COUNT_MAX),
uploadMinute: clamp(form.uploadMinute || 0, 1, UPLOAD_MINUTE_MAX), uploadMinute: clamp(form.uploadMinute || 0, 1, UPLOAD_MINUTE_MAX),
// v3.2:带宽(MB/s → 字节/秒;负数与 NaN 归 0)
upload_rate: clamp(Number(form.uploadRate) || 0, 0, RATE_MAX_MB) * MB_BYTES,
download_rate: clamp(Number(form.downloadRate) || 0, 0, RATE_MAX_MB) * MB_BYTES,
}) })
toast.success(t('admin.settings.saved')) toast.success(t('admin.settings.saved'))
await config.load() // 立即刷新导航 Logo / favicon / 站点名称 / 背景 / 页脚 / 通知 await config.load() // 立即刷新导航 Logo / favicon / 站点名称 / 背景 / 页脚 / 通知
@@ -256,6 +266,8 @@ function restoreDefaults(): void {
form.allowed_file_types = '*' form.allowed_file_types = '*'
form.uploadCount = 10 form.uploadCount = 10
form.uploadMinute = 1 form.uploadMinute = 1
form.uploadRate = 0
form.downloadRate = 0
// v3:单位状态复位 // v3:单位状态复位
saveTimeValue.value = 0 saveTimeValue.value = 0
saveTimeUnit.value = 'day' saveTimeUnit.value = 'day'
@@ -736,6 +748,41 @@ onMounted(load)
<p class="hint">{{ t('admin.settings.uploadMinuteHint', { max: UPLOAD_MINUTE_MAX }) }}</p> <p class="hint">{{ t('admin.settings.uploadMinuteHint', { max: UPLOAD_MINUTE_MAX }) }}</p>
</div> </div>
</div> </div>
<!-- v3.2上下行带宽与上传次数限制并列 -->
<div class="field-row" style="margin-top: 14px">
<div class="field">
<label for="set-upload-rate">{{ t('admin.settings.uploadRate') }}</label>
<div class="unit-row">
<input
id="set-upload-rate"
v-model.number="form.uploadRate"
class="input"
type="number"
:min="0"
:max="RATE_MAX_MB"
step="1"
/>
<span class="unit-suffix">MB/s</span>
</div>
<p class="hint">{{ t('admin.settings.uploadRateHint') }}</p>
</div>
<div class="field">
<label for="set-download-rate">{{ t('admin.settings.downloadRate') }}</label>
<div class="unit-row">
<input
id="set-download-rate"
v-model.number="form.downloadRate"
class="input"
type="number"
:min="0"
:max="RATE_MAX_MB"
step="1"
/>
<span class="unit-suffix">MB/s</span>
</div>
<p class="hint">{{ t('admin.settings.downloadRateHint') }}</p>
</div>
</div>
</section> </section>
<div class="save-row"> <div class="save-row">