mirror of
https://git.fightbot.fun/hxuanyu/BingPaper.git
synced 2026-03-07 19:49:31 +08:00
Compare commits
59 Commits
v0.0.1
...
2660970320
| Author | SHA1 | Date | |
|---|---|---|---|
| 2660970320 | |||
| fb636b9450 | |||
| 8ef66b2cb1 | |||
| 6868a67ed7 | |||
| 52fb8c9328 | |||
| 845dc7d045 | |||
| 93690e10d3 | |||
| b69db53f0a | |||
| 39d4f9c730 | |||
| cee6bc1027 | |||
| 1984e01785 | |||
| 31f32bdb63 | |||
| 9360bd0131 | |||
| 1ca3d15c2f | |||
| e428f5bddb | |||
| c32cb8da3f | |||
| 5e3defc63d | |||
| ea99a31248 | |||
| 86d6517267 | |||
| 2e5eeaf425 | |||
| 7433bc2e7e | |||
| 8bc9b44a14 | |||
| 617c1d0967 | |||
| b31711d86d | |||
| 62ac723c95 | |||
| 5334ee9d41 | |||
| c8a7ea5490 | |||
| 61de3f44dc | |||
| 69abe80264 | |||
| 34848e7b91 | |||
| 9ec9a2ba91 | |||
| 3c1f29e4ef | |||
| ae82557545 | |||
| fecbd014b3 | |||
| 907e158f44 | |||
| f7fc3fa506 | |||
| 6052af8357 | |||
| e12d912a6c | |||
| 35f36c3f97 | |||
| fa0be96004 | |||
| a0b05812d3 | |||
| df6ca6afd4 | |||
| 6db4c6e60c | |||
| 89bdfbd48e | |||
| d757dbd39d | |||
| 9c2a5d5cd8 | |||
| 6dfffe1236 | |||
| be0bcb4d51 | |||
| 911e58c29b | |||
| cfd7c605af | |||
| 89b7f1ae3a | |||
| e6e6d3b222 | |||
| 729d335a69 | |||
| 0fe45e3847 | |||
| 15cceac7e0 | |||
| 4f8f6f3a6c | |||
| 40c268700e | |||
| 395534e6d8 | |||
| 6b5a4295b7 |
22
.dockerignore
Normal file
22
.dockerignore
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
.git
|
||||||
|
.github
|
||||||
|
node_modules
|
||||||
|
**/node_modules
|
||||||
|
data
|
||||||
|
output
|
||||||
|
scripts
|
||||||
|
# docs
|
||||||
|
*.md
|
||||||
|
docker-compose.yaml
|
||||||
|
Dockerfile
|
||||||
|
.dockerignore
|
||||||
|
webapp/.vscode
|
||||||
|
webapp/doc
|
||||||
|
webapp/.env*
|
||||||
|
!webapp/.env.production
|
||||||
|
web
|
||||||
|
webapp/dist
|
||||||
|
webapp/node_modules
|
||||||
|
.vscode
|
||||||
|
.idea
|
||||||
|
*.log
|
||||||
2
.github/workflows/docker-publish.yml
vendored
2
.github/workflows/docker-publish.yml
vendored
@@ -29,7 +29,7 @@ jobs:
|
|||||||
id: meta
|
id: meta
|
||||||
uses: docker/metadata-action@v5
|
uses: docker/metadata-action@v5
|
||||||
with:
|
with:
|
||||||
images: hxuanyu521/bingpaper
|
images: ${{ secrets.DOCKER_HUB_USERNAME }}/bingpaper
|
||||||
tags: |
|
tags: |
|
||||||
type=ref,event=tag
|
type=ref,event=tag
|
||||||
type=raw,value=latest
|
type=raw,value=latest
|
||||||
|
|||||||
7
.github/workflows/release.yml
vendored
7
.github/workflows/release.yml
vendored
@@ -20,6 +20,13 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
go-version: '1.25.5'
|
go-version: '1.25.5'
|
||||||
|
|
||||||
|
- name: Set up Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '20'
|
||||||
|
cache: 'npm'
|
||||||
|
cache-dependency-path: webapp/package-lock.json
|
||||||
|
|
||||||
- name: Build and Package
|
- name: Build and Package
|
||||||
run: |
|
run: |
|
||||||
chmod +x scripts/build.sh
|
chmod +x scripts/build.sh
|
||||||
|
|||||||
35
.github/workflows/verify.yml
vendored
35
.github/workflows/verify.yml
vendored
@@ -17,6 +17,19 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
go-version: '1.25.5'
|
go-version: '1.25.5'
|
||||||
|
|
||||||
|
- name: Set up Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '20'
|
||||||
|
cache: 'npm'
|
||||||
|
cache-dependency-path: webapp/package-lock.json
|
||||||
|
|
||||||
|
- name: Build Frontend
|
||||||
|
run: |
|
||||||
|
cd webapp
|
||||||
|
npm install
|
||||||
|
npm run build
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: go mod download
|
run: go mod download
|
||||||
|
|
||||||
@@ -25,3 +38,25 @@ jobs:
|
|||||||
|
|
||||||
- name: Test
|
- name: Test
|
||||||
run: CGO_ENABLED=0 go test -v ./...
|
run: CGO_ENABLED=0 go test -v ./...
|
||||||
|
|
||||||
|
- name: Notification
|
||||||
|
if: success()
|
||||||
|
env:
|
||||||
|
NOTIFY_CURLS: ${{ secrets.NOTIFY_CURLS }}
|
||||||
|
run: |
|
||||||
|
if [ -n "$NOTIFY_CURLS" ]; then
|
||||||
|
printf "%s\n" "$NOTIFY_CURLS" | while read -r line; do
|
||||||
|
if [ -n "$line" ]; then
|
||||||
|
if [[ "$line" =~ ^[0-9] ]]; then
|
||||||
|
echo "Pausing for $line ms....."
|
||||||
|
sleep "$(awk "BEGIN {print $line/1000}")" || true
|
||||||
|
elif [[ "$line" == curl* ]]; then
|
||||||
|
echo "Response:"
|
||||||
|
eval "$line -w \"\\nHTTP Status: %{http_code}\\n\"" || true
|
||||||
|
else
|
||||||
|
eval "$line" || true
|
||||||
|
fi
|
||||||
|
echo ""
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|||||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -54,3 +54,5 @@ desktop.ini
|
|||||||
/bing_paper.db
|
/bing_paper.db
|
||||||
/req.txt
|
/req.txt
|
||||||
/BingPaper
|
/BingPaper
|
||||||
|
/temp/
|
||||||
|
/web/
|
||||||
|
|||||||
109
CONFIG.md
Normal file
109
CONFIG.md
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
# 配置指南
|
||||||
|
|
||||||
|
BingPaper 支持通过配置文件(YAML)和环境变量进行配置。
|
||||||
|
|
||||||
|
## 配置文件
|
||||||
|
|
||||||
|
程序启动时默认会查找当前目录下的 `config.yaml` 或 `data/config.yaml`。如果不存在,会自动在 `data/config.yaml` 创建一份带有默认值的配置文件。
|
||||||
|
|
||||||
|
你可以通过命令行参数 `-config` 或 `-c` 指定配置文件路径:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./BingPaper -c my_config.yaml
|
||||||
|
```
|
||||||
|
|
||||||
|
### 完整配置说明
|
||||||
|
|
||||||
|
以下是 `config.example.yaml` 的详细说明:
|
||||||
|
|
||||||
|
#### server (服务配置)
|
||||||
|
- `port`: 服务监听端口,默认 `8080`。
|
||||||
|
- `base_url`: 服务的基础 URL,用于生成某些绝对路径,默认为空。
|
||||||
|
|
||||||
|
#### log (日志配置)
|
||||||
|
- `level`: 业务日志级别,可选 `debug`, `info`, `warn`, `error`,默认 `info`。
|
||||||
|
- `filename`: 业务日志输出文件路径,默认 `data/logs/app.log`。
|
||||||
|
- `db_filename`: 数据库日志输出文件路径,默认 `data/logs/db.log`。
|
||||||
|
- `max_size`: 日志文件切割大小 (MB),默认 `100`。
|
||||||
|
- `max_backups`: 保留旧日志文件个数,默认 `3`。
|
||||||
|
- `max_age`: 保留旧日志文件天数,默认 `7`。
|
||||||
|
- `compress`: 是否压缩旧日志文件,默认 `true`。
|
||||||
|
- `log_console`: 是否同时输出到控制台,默认 `true`。
|
||||||
|
- `show_db_log`: 是否在控制台输出数据库日志(SQL),默认 `false`。
|
||||||
|
- `db_log_level`: 数据库日志级别,可选 `debug`, `info`, `warn`, `error`, `silent`。`debug`/`info` 会记录所有 SQL。默认 `info`。
|
||||||
|
|
||||||
|
#### api (API 模式)
|
||||||
|
- `mode`: API 行为模式。
|
||||||
|
- `local`: (默认) 接口直接返回图片的二进制流,适合图片存储对外部不可见的情况。
|
||||||
|
- `redirect`: 接口返回 302 重定向到图片的 `PublicURL`,适合配合 S3 或 WebDAV 的公共访问。
|
||||||
|
- `enable_mkt_fallback`: 当请求的地区不存在或无数据时,是否允许兜底回退到默认地区或任意可用地区,默认 `true`。
|
||||||
|
|
||||||
|
#### cron (定时任务)
|
||||||
|
- `enabled`: 是否启用定时抓取,默认 `true`。
|
||||||
|
- `daily_spec`: Cron 表达式,定义每日抓取时间。默认 `"0 10 * * *"` (每日上午 10:00)。
|
||||||
|
|
||||||
|
#### fetcher (抓取配置)
|
||||||
|
- `regions`: 需要抓取的地区编码列表(如 `zh-CN`, `en-US` 等)。如果不设置,默认为包括主要国家在内的 17 个地区。
|
||||||
|
|
||||||
|
#### retention (数据保留)
|
||||||
|
- `days`: 图片及元数据保留天数。超过此天数的数据可能会被清理任务处理。设置为 `0` 表示永久保留,不进行自动清理。默认 `0`。
|
||||||
|
|
||||||
|
#### db (数据库配置)
|
||||||
|
- `type`: 数据库类型,可选 `sqlite`, `mysql`, `postgres`。默认 `sqlite`。
|
||||||
|
- `dsn`: 数据库连接字符串。
|
||||||
|
- SQLite: `data/bing_paper.db` (默认)
|
||||||
|
- MySQL 示例: `user:pass@tcp(127.0.0.1:3306)/dbname?charset=utf8mb4&parseTime=True&loc=Local`
|
||||||
|
- Postgres 示例: `host=localhost user=user password=pass dbname=db port=5432 sslmode=disable TimeZone=Asia/Shanghai`
|
||||||
|
|
||||||
|
**注意:** BingPaper 支持数据库配置的热更新。如果你在程序运行时修改了 `db.type` 或 `db.dsn`,程序会自动尝试将当前数据库中的所有数据(图片记录、变体信息、Token)迁移到新的数据库中。
|
||||||
|
- 在迁移开始前,程序会**清空**目标数据库中的相关表以防止数据冲突。
|
||||||
|
- 迁移过程在事务中执行,确保数据一致性。
|
||||||
|
- 迁移完成后,程序将无缝切换到新的数据库连接。
|
||||||
|
|
||||||
|
#### storage (存储配置)
|
||||||
|
- `type`: 存储类型,可选 `local`, `s3`, `webdav`。默认 `local`。
|
||||||
|
- **local (本地存储)**:
|
||||||
|
- `root`: 图片存储根目录,默认 `data/picture`。
|
||||||
|
- **s3 (对象存储)**:
|
||||||
|
- `endpoint`: S3 端点(如 `s3.amazonaws.com` 或 MinIO 地址)。
|
||||||
|
- `region`: 区域(如 `us-east-1`)。
|
||||||
|
- `bucket`: 桶名称。
|
||||||
|
- `access_key`: 访问密钥 ID。
|
||||||
|
- `secret_key`: 私有访问密钥。
|
||||||
|
- `public_url_prefix`: 公网访问前缀,若为空则由 SDK 自动尝试生成。
|
||||||
|
- `force_path_style`: 是否强制使用路径样式(MinIO 等通常需要设为 `true`)。
|
||||||
|
- **webdav (WebDAV 存储)**:
|
||||||
|
- `url`: WebDAV 服务器地址。
|
||||||
|
- `username`: 用户名。
|
||||||
|
- `password`: 密码。
|
||||||
|
- `public_url_prefix`: 公网访问前缀。
|
||||||
|
|
||||||
|
#### admin (管理配置)
|
||||||
|
- `password_bcrypt`: 管理员密码的 Bcrypt 哈希值。默认密码为 `admin123`,对应哈希 `$2a$10$fYHPeWHmwObephJvtlyH1O8DIgaLk5TINbi9BOezo2M8cSjmJchka`。
|
||||||
|
- **强烈建议修改此项。**
|
||||||
|
|
||||||
|
#### token (认证配置)
|
||||||
|
- `default_ttl`: 管理后台登录 Token 的默认有效期,默认 `168h` (7天)。
|
||||||
|
|
||||||
|
#### feature (功能开关)
|
||||||
|
- `write_daily_files`: 是否在每日目录下写入原始文件(不仅是数据库记录),默认 `true`。
|
||||||
|
|
||||||
|
#### web (静态资源)
|
||||||
|
- `path`: 自定义管理后台前端文件的存放路径,默认 `web`。若指定路径不存在,将尝试使用内置的嵌入页面。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 环境变量配置
|
||||||
|
|
||||||
|
所有的配置项都可以通过环境变量进行覆盖。环境变量前缀为 `BINGPAPER_`,层级之间使用下划线 `_` 分隔。
|
||||||
|
|
||||||
|
**常用示例:**
|
||||||
|
|
||||||
|
- `BINGPAPER_SERVER_PORT=9090`
|
||||||
|
- `BINGPAPER_DB_TYPE=mysql`
|
||||||
|
- `BINGPAPER_DB_DSN="user:pass@tcp(127.0.0.1:3306)/bingpaper"`
|
||||||
|
- `BINGPAPER_STORAGE_TYPE=s3`
|
||||||
|
- `BINGPAPER_STORAGE_S3_BUCKET=my-images`
|
||||||
|
- `BINGPAPER_ADMIN_PASSWORD_BCRYPT="$2a$10$..."`
|
||||||
|
- `HOST_PORT=8080` (仅限 Docker Compose 部署,控制宿主机映射到外部的端口)
|
||||||
|
- `BINGPAPER_SERVER_PORT=8080` (控制应用监听端口及容器内部端口)
|
||||||
45
Dockerfile
45
Dockerfile
@@ -1,19 +1,48 @@
|
|||||||
FROM golang:1.25.5-alpine AS builder
|
# Stage 1: Build Frontend
|
||||||
|
FROM --platform=$BUILDPLATFORM node:20-alpine AS node-builder
|
||||||
|
ARG NPM_REGISTRY
|
||||||
|
WORKDIR /webapp
|
||||||
|
# 复制 package.json 和 lock 文件以利用 layer 缓存
|
||||||
|
COPY webapp/package*.json ./
|
||||||
|
# 如果设置了 NPM_REGISTRY,则配置 npm 镜像
|
||||||
|
RUN if [ -n "$NPM_REGISTRY" ]; then npm config set registry $NPM_REGISTRY; fi
|
||||||
|
# 使用 npm ci 以获得更快且可重现的构建(如果存在 package-lock.json)
|
||||||
|
RUN if [ -f package-lock.json ]; then npm ci; else npm install; fi
|
||||||
|
# 复制其余源码并构建
|
||||||
|
COPY webapp/ .
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# Stage 2: Build Backend
|
||||||
|
FROM --platform=$BUILDPLATFORM golang:1.25.5-alpine AS builder
|
||||||
|
ARG GOPROXY
|
||||||
|
ENV GOPROXY=$GOPROXY
|
||||||
|
# 安装 Git 以支持某些 Go 模块依赖
|
||||||
|
RUN apk add --no-cache git
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
# 复制 go.mod 和 go.sum 以利用 layer 缓存
|
||||||
COPY go.mod go.sum ./
|
COPY go.mod go.sum ./
|
||||||
RUN go mod download
|
RUN go mod download
|
||||||
|
# 复制源码
|
||||||
COPY . .
|
COPY . .
|
||||||
RUN CGO_ENABLED=0 go build -o BingPaper .
|
# 从 node-builder 复制构建好的 web 目录,用于 go embed
|
||||||
|
COPY --from=node-builder /web ./web
|
||||||
FROM alpine:latest
|
# 编译二进制,针对目标平台
|
||||||
|
ARG TARGETOS
|
||||||
|
ARG TARGETARCH
|
||||||
|
RUN CGO_ENABLED=0 GOOS=$TARGETOS GOARCH=$TARGETARCH go build -ldflags="-s -w" -o BingPaper .
|
||||||
|
|
||||||
|
# Stage 3: Final Image
|
||||||
|
FROM alpine:3.21
|
||||||
|
# 安装运行时必需的证书和时区数据
|
||||||
|
RUN apk add --no-cache ca-certificates tzdata
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY --from=builder /app/BingPaper .
|
# 创建必要目录
|
||||||
RUN mkdir -p data
|
RUN mkdir -p data
|
||||||
COPY --from=builder /app/config.example.yaml ./data/config.yaml
|
# 从构建阶段复制二进制文件
|
||||||
COPY --from=builder /app/web ./web
|
COPY --from=builder /app/BingPaper .
|
||||||
|
# 复制默认配置
|
||||||
|
COPY config.example.yaml ./data/config.yaml
|
||||||
|
|
||||||
EXPOSE 8080
|
EXPOSE 8080
|
||||||
|
VOLUME ["/app/data"]
|
||||||
ENTRYPOINT ["./BingPaper"]
|
ENTRYPOINT ["./BingPaper"]
|
||||||
|
|||||||
34
README.md
34
README.md
@@ -18,7 +18,7 @@
|
|||||||
|
|
||||||
### 1. 配置
|
### 1. 配置
|
||||||
|
|
||||||
复制示例配置文件到 `data` 目录并根据需要修改:
|
复制示例配置文件到 `data` 目录并根据需要修改。详细配置说明请参考 [CONFIG.md](CONFIG.md)。
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
mkdir -p data
|
mkdir -p data
|
||||||
@@ -57,6 +57,7 @@ go run .
|
|||||||
- `GET /api/v1/image/random`:返回随机图片
|
- `GET /api/v1/image/random`:返回随机图片
|
||||||
- `GET /api/v1/image/date/:yyyy-mm-dd`:返回指定日期图片
|
- `GET /api/v1/image/date/:yyyy-mm-dd`:返回指定日期图片
|
||||||
- **查询参数**:
|
- **查询参数**:
|
||||||
|
- `mkt`:地区编码 (zh-CN, en-US, ja-JP 等),默认 `zh-CN`
|
||||||
- `variant`:分辨率 (UHD, 1920x1080, 1366x768),默认 `UHD`
|
- `variant`:分辨率 (UHD, 1920x1080, 1366x768),默认 `UHD`
|
||||||
- `format`:格式 (jpg),默认 `jpg`
|
- `format`:格式 (jpg),默认 `jpg`
|
||||||
|
|
||||||
@@ -90,12 +91,25 @@ go run .
|
|||||||
|
|
||||||
编译后的打包文件将生成在 `output` 目录下。二进制文件已内置默认前端页面,即使不带 `web` 目录也能运行。如果需要自定义页面,可在配置中指定 `web.path`。
|
编译后的打包文件将生成在 `output` 目录下。二进制文件已内置默认前端页面,即使不带 `web` 目录也能运行。如果需要自定义页面,可在配置中指定 `web.path`。
|
||||||
|
|
||||||
### 发布流程
|
### 版本发布 (仅限维护者或 Fork 用户)
|
||||||
本项目集成了 GitHub Actions,可通过以下步骤发布新版本:
|
如果您是项目的维护者(或希望在自己的 Fork 仓库中发布),可以通过以下步骤发布新版本:
|
||||||
|
|
||||||
1. 确保在 `master` 分支且代码已提交。
|
1. **启用 Actions**:在您的 Fork 仓库的 "Actions" 页面手动启用 GitHub Actions。
|
||||||
2. 运行标签脚本:`./scripts/tag.sh v1.0.0` (替换为实际版本号)。
|
2. **配置 Secrets**(可选,仅构建镜像需要):在仓库设置中添加 `DOCKER_HUB_USERNAME` 和 `DOCKER_HUB_TOKEN`。
|
||||||
3. 脚本会自动推送标签,触发 GitHub Actions 进行构建并发布 Release。
|
3. **确保分支一致**:确保在 `master` 分支且代码已提交。
|
||||||
|
4. **运行标签脚本**:`./scripts/tag.sh v1.0.0` (替换为实际版本号)。
|
||||||
|
5. **触发流水线**:脚本会自动推送标签,触发 GitHub Actions 进行构建、发布 Release 并推送 Docker 镜像。
|
||||||
|
|
||||||
|
## 贡献指南
|
||||||
|
|
||||||
|
我们非常欢迎各种形式的贡献!如果您有任何想法或建议,请遵循以下流程:
|
||||||
|
|
||||||
|
1. **Fork** 本仓库到您的 GitHub 账号。
|
||||||
|
2. **Clone** 您 Fork 的仓库到本地。
|
||||||
|
3. 创建一个新的 **Feature 分支** (`git checkout -b feature/your-feature`)。
|
||||||
|
4. **提交** 您的修改 (`git commit -m 'Add some feature'`)。
|
||||||
|
5. **Push** 分支到 GitHub (`git push origin feature/your-feature`)。
|
||||||
|
6. 在本仓库提交一个 **Pull Request**。
|
||||||
|
|
||||||
### Docker 运行
|
### Docker 运行
|
||||||
|
|
||||||
@@ -131,3 +145,11 @@ docker-compose up -d
|
|||||||
## 许可证
|
## 许可证
|
||||||
|
|
||||||
MIT
|
MIT
|
||||||
|
|
||||||
|
## 版权说明
|
||||||
|
|
||||||
|
本项目提供的必应每日一图抓取功能仅供个人学习、研究及壁纸设定等非商业用途。
|
||||||
|
|
||||||
|
- **图片版权**:图片版权归微软(Microsoft)或其原始作者所有。
|
||||||
|
- **合规使用**:在使用本项目或通过本项目获取的图片时,请务必遵守微软的相关服务条款和版权声明。
|
||||||
|
- **免责声明**:本项目不对图片的版权问题承担任何法律责任。用户因违规使用图片而产生的任何纠纷,由用户自行承担。
|
||||||
|
|||||||
@@ -4,16 +4,27 @@ server:
|
|||||||
|
|
||||||
log:
|
log:
|
||||||
level: info
|
level: info
|
||||||
|
filename: data/logs/app.log
|
||||||
|
db_filename: data/logs/db.log
|
||||||
|
max_size: 100
|
||||||
|
max_backups: 3
|
||||||
|
max_age: 7
|
||||||
|
compress: true
|
||||||
|
log_console: true
|
||||||
|
show_db_log: false
|
||||||
|
db_log_level: info
|
||||||
|
|
||||||
api:
|
api:
|
||||||
mode: local # local | redirect
|
mode: local # local | redirect
|
||||||
|
enable_mkt_fallback: true # 当请求的地区不存在时,是否回退到默认地区
|
||||||
|
enable_on_demand_fetch: false # 是否开启按需抓取(当数据库中没有请求的地区图片时,实时从 Bing 抓取)
|
||||||
|
|
||||||
cron:
|
cron:
|
||||||
enabled: true
|
enabled: true
|
||||||
daily_spec: "0 10 * * *"
|
daily_spec: "20 8-23/4 * * *"
|
||||||
|
|
||||||
retention:
|
retention:
|
||||||
days: 30
|
days: 0
|
||||||
|
|
||||||
db:
|
db:
|
||||||
type: sqlite # sqlite | mysql | postgres
|
type: sqlite # sqlite | mysql | postgres
|
||||||
|
|||||||
36
docker-compose.yaml
Normal file
36
docker-compose.yaml
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
services:
|
||||||
|
bingpaper:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
args:
|
||||||
|
- GOPROXY=${GOPROXY:-https://proxy.golang.org,direct}
|
||||||
|
- NPM_REGISTRY=${NPM_REGISTRY:-https://registry.npmjs.org/}
|
||||||
|
container_name: bingpaper
|
||||||
|
restart: always
|
||||||
|
ports:
|
||||||
|
- "${HOST_PORT:-8080}:${BINGPAPER_SERVER_PORT:-8080}"
|
||||||
|
volumes:
|
||||||
|
- ./data:/app/data
|
||||||
|
environment:
|
||||||
|
- TZ=${TZ:-Asia/Shanghai}
|
||||||
|
- BINGPAPER_SERVER_PORT=${BINGPAPER_SERVER_PORT:-8080}
|
||||||
|
- BINGPAPER_LOG_LEVEL=${BINGPAPER_LOG_LEVEL:-info}
|
||||||
|
- BINGPAPER_API_MODE=${BINGPAPER_API_MODE:-local}
|
||||||
|
- BINGPAPER_CRON_ENABLED=${BINGPAPER_CRON_ENABLED:-true}
|
||||||
|
- BINGPAPER_DB_TYPE=${BINGPAPER_DB_TYPE:-sqlite}
|
||||||
|
- BINGPAPER_DB_DSN=${BINGPAPER_DB_DSN:-data/bing_paper.db}
|
||||||
|
- BINGPAPER_STORAGE_TYPE=${BINGPAPER_STORAGE_TYPE:-local}
|
||||||
|
- BINGPAPER_STORAGE_LOCAL_ROOT=${BINGPAPER_STORAGE_LOCAL_ROOT:-data/picture}
|
||||||
|
- BINGPAPER_RETENTION_DAYS=${BINGPAPER_RETENTION_DAYS:-30}
|
||||||
|
# S3 配置 (可选)
|
||||||
|
# - BINGPAPER_STORAGE_S3_ENDPOINT=${BINGPAPER_STORAGE_S3_ENDPOINT:-}
|
||||||
|
# - BINGPAPER_STORAGE_S3_REGION=${BINGPAPER_STORAGE_S3_REGION:-}
|
||||||
|
# - BINGPAPER_STORAGE_S3_BUCKET=${BINGPAPER_STORAGE_S3_BUCKET:-}
|
||||||
|
# - BINGPAPER_STORAGE_S3_ACCESS_KEY=${BINGPAPER_STORAGE_S3_ACCESS_KEY:-}
|
||||||
|
# - BINGPAPER_STORAGE_S3_SECRET_KEY=${BINGPAPER_STORAGE_S3_SECRET_KEY:-}
|
||||||
|
# - BINGPAPER_STORAGE_S3_PUBLIC_URL_PREFIX=${BINGPAPER_STORAGE_S3_PUBLIC_URL_PREFIX:-}
|
||||||
|
# WebDAV 配置 (可选)
|
||||||
|
# - BINGPAPER_STORAGE_WEBDAV_URL=${BINGPAPER_STORAGE_WEBDAV_URL:-}
|
||||||
|
# - BINGPAPER_STORAGE_WEBDAV_USERNAME=${BINGPAPER_STORAGE_WEBDAV_USERNAME:-}
|
||||||
|
# - BINGPAPER_STORAGE_WEBDAV_PASSWORD=${BINGPAPER_STORAGE_WEBDAV_PASSWORD:-}
|
||||||
|
# - BINGPAPER_STORAGE_WEBDAV_PUBLIC_URL_PREFIX=${BINGPAPER_STORAGE_WEBDAV_PUBLIC_URL_PREFIX:-}
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
services:
|
|
||||||
bingpaper:
|
|
||||||
build: .
|
|
||||||
container_name: bingpaper
|
|
||||||
restart: always
|
|
||||||
ports:
|
|
||||||
- "8080:8080"
|
|
||||||
volumes:
|
|
||||||
- ./data:/app/data
|
|
||||||
environment:
|
|
||||||
- BINGPAPER_SERVER_PORT=8080
|
|
||||||
- BINGPAPER_LOG_LEVEL=info
|
|
||||||
- BINGPAPER_API_MODE=local
|
|
||||||
- BINGPAPER_CRON_ENABLED=true
|
|
||||||
- BINGPAPER_DB_TYPE=sqlite
|
|
||||||
- BINGPAPER_DB_DSN=data/bing_paper.db
|
|
||||||
- BINGPAPER_STORAGE_TYPE=local
|
|
||||||
- BINGPAPER_STORAGE_LOCAL_ROOT=data/picture
|
|
||||||
- BINGPAPER_RETENTION_DAYS=30
|
|
||||||
# S3 配置 (可选)
|
|
||||||
# - BINGPAPER_STORAGE_S3_ENDPOINT=
|
|
||||||
# - BINGPAPER_STORAGE_S3_REGION=
|
|
||||||
# - BINGPAPER_STORAGE_S3_BUCKET=
|
|
||||||
# - BINGPAPER_STORAGE_S3_ACCESS_KEY=
|
|
||||||
# - BINGPAPER_STORAGE_S3_SECRET_KEY=
|
|
||||||
# - BINGPAPER_STORAGE_S3_PUBLIC_URL_PREFIX=
|
|
||||||
# WebDAV 配置 (可选)
|
|
||||||
# - BINGPAPER_STORAGE_WEBDAV_URL=
|
|
||||||
# - BINGPAPER_STORAGE_WEBDAV_USERNAME=
|
|
||||||
# - BINGPAPER_STORAGE_WEBDAV_PASSWORD=
|
|
||||||
# - BINGPAPER_STORAGE_WEBDAV_PUBLIC_URL_PREFIX=
|
|
||||||
379
docs/docs.go
379
docs/docs.go
@@ -399,8 +399,7 @@ const docTemplate = `{
|
|||||||
"get": {
|
"get": {
|
||||||
"description": "根据日期返回图片流或重定向 (yyyy-mm-dd)",
|
"description": "根据日期返回图片流或重定向 (yyyy-mm-dd)",
|
||||||
"produces": [
|
"produces": [
|
||||||
"image/jpeg",
|
"image/jpeg"
|
||||||
"image/webp"
|
|
||||||
],
|
],
|
||||||
"tags": [
|
"tags": [
|
||||||
"image"
|
"image"
|
||||||
@@ -414,6 +413,12 @@ const docTemplate = `{
|
|||||||
"in": "path",
|
"in": "path",
|
||||||
"required": true
|
"required": true
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"description": "地区编码 (如 zh-CN, en-US)",
|
||||||
|
"name": "mkt",
|
||||||
|
"in": "query"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"default": "UHD",
|
"default": "UHD",
|
||||||
@@ -435,6 +440,24 @@ const docTemplate = `{
|
|||||||
"schema": {
|
"schema": {
|
||||||
"type": "file"
|
"type": "file"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"202": {
|
||||||
|
"description": "按需抓取任务已启动",
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"404": {
|
||||||
|
"description": "图片未找到,响应体包含具体原因",
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -456,14 +479,37 @@ const docTemplate = `{
|
|||||||
"name": "date",
|
"name": "date",
|
||||||
"in": "path",
|
"in": "path",
|
||||||
"required": true
|
"required": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"description": "地区编码 (如 zh-CN, en-US)",
|
||||||
|
"name": "mkt",
|
||||||
|
"in": "query"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"responses": {
|
"responses": {
|
||||||
"200": {
|
"200": {
|
||||||
"description": "OK",
|
"description": "OK",
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/handlers.ImageMetaResp"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"202": {
|
||||||
|
"description": "按需抓取任务已启动",
|
||||||
"schema": {
|
"schema": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"additionalProperties": true
|
"additionalProperties": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"404": {
|
||||||
|
"description": "图片未找到,响应体包含具体原因",
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -473,14 +519,19 @@ const docTemplate = `{
|
|||||||
"get": {
|
"get": {
|
||||||
"description": "随机返回一张已抓取的图片流或重定向",
|
"description": "随机返回一张已抓取的图片流或重定向",
|
||||||
"produces": [
|
"produces": [
|
||||||
"image/jpeg",
|
"image/jpeg"
|
||||||
"image/webp"
|
|
||||||
],
|
],
|
||||||
"tags": [
|
"tags": [
|
||||||
"image"
|
"image"
|
||||||
],
|
],
|
||||||
"summary": "获取随机图片",
|
"summary": "获取随机图片",
|
||||||
"parameters": [
|
"parameters": [
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"description": "地区编码 (如 zh-CN, en-US)",
|
||||||
|
"name": "mkt",
|
||||||
|
"in": "query"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"default": "UHD",
|
"default": "UHD",
|
||||||
@@ -502,6 +553,24 @@ const docTemplate = `{
|
|||||||
"schema": {
|
"schema": {
|
||||||
"type": "file"
|
"type": "file"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"202": {
|
||||||
|
"description": "按需抓取任务已启动",
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"404": {
|
||||||
|
"description": "图片未找到,响应体包含具体原因",
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -516,12 +585,37 @@ const docTemplate = `{
|
|||||||
"image"
|
"image"
|
||||||
],
|
],
|
||||||
"summary": "获取随机图片元数据",
|
"summary": "获取随机图片元数据",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"description": "地区编码 (如 zh-CN, en-US)",
|
||||||
|
"name": "mkt",
|
||||||
|
"in": "query"
|
||||||
|
}
|
||||||
|
],
|
||||||
"responses": {
|
"responses": {
|
||||||
"200": {
|
"200": {
|
||||||
"description": "OK",
|
"description": "OK",
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/handlers.ImageMetaResp"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"202": {
|
||||||
|
"description": "按需抓取任务已启动",
|
||||||
"schema": {
|
"schema": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"additionalProperties": true
|
"additionalProperties": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"404": {
|
||||||
|
"description": "图片未找到,响应体包含具体原因",
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -531,25 +625,30 @@ const docTemplate = `{
|
|||||||
"get": {
|
"get": {
|
||||||
"description": "根据参数返回今日必应图片流或重定向",
|
"description": "根据参数返回今日必应图片流或重定向",
|
||||||
"produces": [
|
"produces": [
|
||||||
"image/jpeg",
|
"image/jpeg"
|
||||||
"image/webp"
|
|
||||||
],
|
],
|
||||||
"tags": [
|
"tags": [
|
||||||
"image"
|
"image"
|
||||||
],
|
],
|
||||||
"summary": "获取今日图片",
|
"summary": "获取今日图片",
|
||||||
"parameters": [
|
"parameters": [
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"description": "地区编码 (如 zh-CN, en-US)",
|
||||||
|
"name": "mkt",
|
||||||
|
"in": "query"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"default": "UHD",
|
"default": "UHD",
|
||||||
"description": "分辨率 (UHD, 1920x1080, 1366x768)",
|
"description": "分辨率 (UHD, 1920x1080, 1366x768, 1280x720, 1024x768, 800x600, 800x480, 640x480, 640x360, 480x360, 400x240, 320x240)",
|
||||||
"name": "variant",
|
"name": "variant",
|
||||||
"in": "query"
|
"in": "query"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"default": "jpg",
|
"default": "jpg",
|
||||||
"description": "格式 (jpg, webp)",
|
"description": "格式 (jpg)",
|
||||||
"name": "format",
|
"name": "format",
|
||||||
"in": "query"
|
"in": "query"
|
||||||
}
|
}
|
||||||
@@ -560,6 +659,24 @@ const docTemplate = `{
|
|||||||
"schema": {
|
"schema": {
|
||||||
"type": "file"
|
"type": "file"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"202": {
|
||||||
|
"description": "按需抓取任务已启动",
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"404": {
|
||||||
|
"description": "图片未找到,响应体包含具体原因",
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -574,12 +691,37 @@ const docTemplate = `{
|
|||||||
"image"
|
"image"
|
||||||
],
|
],
|
||||||
"summary": "获取今日图片元数据",
|
"summary": "获取今日图片元数据",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"description": "地区编码 (如 zh-CN, en-US)",
|
||||||
|
"name": "mkt",
|
||||||
|
"in": "query"
|
||||||
|
}
|
||||||
|
],
|
||||||
"responses": {
|
"responses": {
|
||||||
"200": {
|
"200": {
|
||||||
"description": "OK",
|
"description": "OK",
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/handlers.ImageMetaResp"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"202": {
|
||||||
|
"description": "按需抓取任务已启动",
|
||||||
"schema": {
|
"schema": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"additionalProperties": true
|
"additionalProperties": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"404": {
|
||||||
|
"description": "图片未找到,响应体包含具体原因",
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -587,7 +729,7 @@ const docTemplate = `{
|
|||||||
},
|
},
|
||||||
"/images": {
|
"/images": {
|
||||||
"get": {
|
"get": {
|
||||||
"description": "分页获取已抓取的图片元数据列表",
|
"description": "分页获取已抓取的图片元数据列表。支持分页(page, page_size)、限制数量(limit)和按月份过滤(month, 格式: YYYY-MM)。",
|
||||||
"produces": [
|
"produces": [
|
||||||
"application/json"
|
"application/json"
|
||||||
],
|
],
|
||||||
@@ -599,9 +741,33 @@ const docTemplate = `{
|
|||||||
{
|
{
|
||||||
"type": "integer",
|
"type": "integer",
|
||||||
"default": 30,
|
"default": 30,
|
||||||
"description": "限制数量",
|
"description": "限制数量 (如果不使用分页)",
|
||||||
"name": "limit",
|
"name": "limit",
|
||||||
"in": "query"
|
"in": "query"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "integer",
|
||||||
|
"description": "页码 (从1开始)",
|
||||||
|
"name": "page",
|
||||||
|
"in": "query"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "integer",
|
||||||
|
"description": "每页数量",
|
||||||
|
"name": "page_size",
|
||||||
|
"in": "query"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"description": "按月份过滤 (格式: YYYY-MM)",
|
||||||
|
"name": "month",
|
||||||
|
"in": "query"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"description": "地区编码 (如 zh-CN, en-US)",
|
||||||
|
"name": "mkt",
|
||||||
|
"in": "query"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"responses": {
|
"responses": {
|
||||||
@@ -610,8 +776,53 @@ const docTemplate = `{
|
|||||||
"schema": {
|
"schema": {
|
||||||
"type": "array",
|
"type": "array",
|
||||||
"items": {
|
"items": {
|
||||||
"type": "object",
|
"$ref": "#/definitions/handlers.ImageMetaResp"
|
||||||
"additionalProperties": true
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/images/global/today": {
|
||||||
|
"get": {
|
||||||
|
"description": "获取配置文件中所有已开启地区的今日必应图片元数据(缩略图)",
|
||||||
|
"produces": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"image"
|
||||||
|
],
|
||||||
|
"summary": "获取所有地区的今日图片列表",
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "OK",
|
||||||
|
"schema": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/definitions/handlers.ImageMetaResp"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/regions": {
|
||||||
|
"get": {
|
||||||
|
"description": "返回系统支持的所有必应地区编码及标签。如果配置中指定了抓取地区,这些地区将排在列表最前面(置顶)。",
|
||||||
|
"produces": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"image"
|
||||||
|
],
|
||||||
|
"summary": "获取支持的地区列表",
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "OK",
|
||||||
|
"schema": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/definitions/util.Region"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -623,6 +834,14 @@ const docTemplate = `{
|
|||||||
"config.APIConfig": {
|
"config.APIConfig": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
|
"enableMktFallback": {
|
||||||
|
"description": "当请求的地区不存在时,是否回退到默认地区",
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"enableOnDemandFetch": {
|
||||||
|
"description": "是否启用按需抓取",
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
"mode": {
|
"mode": {
|
||||||
"description": "local | redirect",
|
"description": "local | redirect",
|
||||||
"type": "string"
|
"type": "string"
|
||||||
@@ -655,6 +874,9 @@ const docTemplate = `{
|
|||||||
"feature": {
|
"feature": {
|
||||||
"$ref": "#/definitions/config.FeatureConfig"
|
"$ref": "#/definitions/config.FeatureConfig"
|
||||||
},
|
},
|
||||||
|
"fetcher": {
|
||||||
|
"$ref": "#/definitions/config.FetcherConfig"
|
||||||
|
},
|
||||||
"log": {
|
"log": {
|
||||||
"$ref": "#/definitions/config.LogConfig"
|
"$ref": "#/definitions/config.LogConfig"
|
||||||
},
|
},
|
||||||
@@ -669,6 +891,9 @@ const docTemplate = `{
|
|||||||
},
|
},
|
||||||
"token": {
|
"token": {
|
||||||
"$ref": "#/definitions/config.TokenConfig"
|
"$ref": "#/definitions/config.TokenConfig"
|
||||||
|
},
|
||||||
|
"web": {
|
||||||
|
"$ref": "#/definitions/config.WebConfig"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -703,6 +928,17 @@ const docTemplate = `{
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"config.FetcherConfig": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"regions": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"config.LocalConfig": {
|
"config.LocalConfig": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
@@ -714,8 +950,44 @@ const docTemplate = `{
|
|||||||
"config.LogConfig": {
|
"config.LogConfig": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
|
"compress": {
|
||||||
|
"description": "是否压缩旧日志文件",
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"dbfilename": {
|
||||||
|
"description": "数据库日志文件名",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"dblogLevel": {
|
||||||
|
"description": "数据库日志级别: debug, info, warn, error",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"filename": {
|
||||||
|
"description": "业务日志文件名",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
"level": {
|
"level": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
|
},
|
||||||
|
"logConsole": {
|
||||||
|
"description": "是否同时输出到控制台",
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"maxAge": {
|
||||||
|
"description": "保留旧日志文件最大天数",
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"maxBackups": {
|
||||||
|
"description": "保留旧日志文件最大个数",
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"maxSize": {
|
||||||
|
"description": "每个日志文件最大大小 (MB)",
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"showDBLog": {
|
||||||
|
"description": "是否在控制台显示数据库日志",
|
||||||
|
"type": "boolean"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -790,6 +1062,14 @@ const docTemplate = `{
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"config.WebConfig": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"path": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"config.WebDAVConfig": {
|
"config.WebDAVConfig": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
@@ -841,6 +1121,64 @@ const docTemplate = `{
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"handlers.ImageMetaResp": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"copyright": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"copyrightlink": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"date": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"fullstartdate": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"hsh": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"mkt": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"quiz": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"startdate": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"title": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"variants": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/definitions/handlers.ImageVariantResp"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"handlers.ImageVariantResp": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"format": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"size": {
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"storage_key": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"url": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"variant": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"handlers.LoginRequest": {
|
"handlers.LoginRequest": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"required": [
|
"required": [
|
||||||
@@ -893,6 +1231,17 @@ const docTemplate = `{
|
|||||||
"type": "string"
|
"type": "string"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"util.Region": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"label": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"value": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"securityDefinitions": {
|
"securityDefinitions": {
|
||||||
|
|||||||
@@ -393,8 +393,7 @@
|
|||||||
"get": {
|
"get": {
|
||||||
"description": "根据日期返回图片流或重定向 (yyyy-mm-dd)",
|
"description": "根据日期返回图片流或重定向 (yyyy-mm-dd)",
|
||||||
"produces": [
|
"produces": [
|
||||||
"image/jpeg",
|
"image/jpeg"
|
||||||
"image/webp"
|
|
||||||
],
|
],
|
||||||
"tags": [
|
"tags": [
|
||||||
"image"
|
"image"
|
||||||
@@ -408,6 +407,12 @@
|
|||||||
"in": "path",
|
"in": "path",
|
||||||
"required": true
|
"required": true
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"description": "地区编码 (如 zh-CN, en-US)",
|
||||||
|
"name": "mkt",
|
||||||
|
"in": "query"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"default": "UHD",
|
"default": "UHD",
|
||||||
@@ -429,6 +434,24 @@
|
|||||||
"schema": {
|
"schema": {
|
||||||
"type": "file"
|
"type": "file"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"202": {
|
||||||
|
"description": "按需抓取任务已启动",
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"404": {
|
||||||
|
"description": "图片未找到,响应体包含具体原因",
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -450,14 +473,37 @@
|
|||||||
"name": "date",
|
"name": "date",
|
||||||
"in": "path",
|
"in": "path",
|
||||||
"required": true
|
"required": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"description": "地区编码 (如 zh-CN, en-US)",
|
||||||
|
"name": "mkt",
|
||||||
|
"in": "query"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"responses": {
|
"responses": {
|
||||||
"200": {
|
"200": {
|
||||||
"description": "OK",
|
"description": "OK",
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/handlers.ImageMetaResp"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"202": {
|
||||||
|
"description": "按需抓取任务已启动",
|
||||||
"schema": {
|
"schema": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"additionalProperties": true
|
"additionalProperties": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"404": {
|
||||||
|
"description": "图片未找到,响应体包含具体原因",
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -467,14 +513,19 @@
|
|||||||
"get": {
|
"get": {
|
||||||
"description": "随机返回一张已抓取的图片流或重定向",
|
"description": "随机返回一张已抓取的图片流或重定向",
|
||||||
"produces": [
|
"produces": [
|
||||||
"image/jpeg",
|
"image/jpeg"
|
||||||
"image/webp"
|
|
||||||
],
|
],
|
||||||
"tags": [
|
"tags": [
|
||||||
"image"
|
"image"
|
||||||
],
|
],
|
||||||
"summary": "获取随机图片",
|
"summary": "获取随机图片",
|
||||||
"parameters": [
|
"parameters": [
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"description": "地区编码 (如 zh-CN, en-US)",
|
||||||
|
"name": "mkt",
|
||||||
|
"in": "query"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"default": "UHD",
|
"default": "UHD",
|
||||||
@@ -496,6 +547,24 @@
|
|||||||
"schema": {
|
"schema": {
|
||||||
"type": "file"
|
"type": "file"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"202": {
|
||||||
|
"description": "按需抓取任务已启动",
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"404": {
|
||||||
|
"description": "图片未找到,响应体包含具体原因",
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -510,12 +579,37 @@
|
|||||||
"image"
|
"image"
|
||||||
],
|
],
|
||||||
"summary": "获取随机图片元数据",
|
"summary": "获取随机图片元数据",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"description": "地区编码 (如 zh-CN, en-US)",
|
||||||
|
"name": "mkt",
|
||||||
|
"in": "query"
|
||||||
|
}
|
||||||
|
],
|
||||||
"responses": {
|
"responses": {
|
||||||
"200": {
|
"200": {
|
||||||
"description": "OK",
|
"description": "OK",
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/handlers.ImageMetaResp"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"202": {
|
||||||
|
"description": "按需抓取任务已启动",
|
||||||
"schema": {
|
"schema": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"additionalProperties": true
|
"additionalProperties": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"404": {
|
||||||
|
"description": "图片未找到,响应体包含具体原因",
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -525,25 +619,30 @@
|
|||||||
"get": {
|
"get": {
|
||||||
"description": "根据参数返回今日必应图片流或重定向",
|
"description": "根据参数返回今日必应图片流或重定向",
|
||||||
"produces": [
|
"produces": [
|
||||||
"image/jpeg",
|
"image/jpeg"
|
||||||
"image/webp"
|
|
||||||
],
|
],
|
||||||
"tags": [
|
"tags": [
|
||||||
"image"
|
"image"
|
||||||
],
|
],
|
||||||
"summary": "获取今日图片",
|
"summary": "获取今日图片",
|
||||||
"parameters": [
|
"parameters": [
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"description": "地区编码 (如 zh-CN, en-US)",
|
||||||
|
"name": "mkt",
|
||||||
|
"in": "query"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"default": "UHD",
|
"default": "UHD",
|
||||||
"description": "分辨率 (UHD, 1920x1080, 1366x768)",
|
"description": "分辨率 (UHD, 1920x1080, 1366x768, 1280x720, 1024x768, 800x600, 800x480, 640x480, 640x360, 480x360, 400x240, 320x240)",
|
||||||
"name": "variant",
|
"name": "variant",
|
||||||
"in": "query"
|
"in": "query"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"default": "jpg",
|
"default": "jpg",
|
||||||
"description": "格式 (jpg, webp)",
|
"description": "格式 (jpg)",
|
||||||
"name": "format",
|
"name": "format",
|
||||||
"in": "query"
|
"in": "query"
|
||||||
}
|
}
|
||||||
@@ -554,6 +653,24 @@
|
|||||||
"schema": {
|
"schema": {
|
||||||
"type": "file"
|
"type": "file"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"202": {
|
||||||
|
"description": "按需抓取任务已启动",
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"404": {
|
||||||
|
"description": "图片未找到,响应体包含具体原因",
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -568,12 +685,37 @@
|
|||||||
"image"
|
"image"
|
||||||
],
|
],
|
||||||
"summary": "获取今日图片元数据",
|
"summary": "获取今日图片元数据",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"description": "地区编码 (如 zh-CN, en-US)",
|
||||||
|
"name": "mkt",
|
||||||
|
"in": "query"
|
||||||
|
}
|
||||||
|
],
|
||||||
"responses": {
|
"responses": {
|
||||||
"200": {
|
"200": {
|
||||||
"description": "OK",
|
"description": "OK",
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/handlers.ImageMetaResp"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"202": {
|
||||||
|
"description": "按需抓取任务已启动",
|
||||||
"schema": {
|
"schema": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"additionalProperties": true
|
"additionalProperties": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"404": {
|
||||||
|
"description": "图片未找到,响应体包含具体原因",
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -581,7 +723,7 @@
|
|||||||
},
|
},
|
||||||
"/images": {
|
"/images": {
|
||||||
"get": {
|
"get": {
|
||||||
"description": "分页获取已抓取的图片元数据列表",
|
"description": "分页获取已抓取的图片元数据列表。支持分页(page, page_size)、限制数量(limit)和按月份过滤(month, 格式: YYYY-MM)。",
|
||||||
"produces": [
|
"produces": [
|
||||||
"application/json"
|
"application/json"
|
||||||
],
|
],
|
||||||
@@ -593,9 +735,33 @@
|
|||||||
{
|
{
|
||||||
"type": "integer",
|
"type": "integer",
|
||||||
"default": 30,
|
"default": 30,
|
||||||
"description": "限制数量",
|
"description": "限制数量 (如果不使用分页)",
|
||||||
"name": "limit",
|
"name": "limit",
|
||||||
"in": "query"
|
"in": "query"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "integer",
|
||||||
|
"description": "页码 (从1开始)",
|
||||||
|
"name": "page",
|
||||||
|
"in": "query"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "integer",
|
||||||
|
"description": "每页数量",
|
||||||
|
"name": "page_size",
|
||||||
|
"in": "query"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"description": "按月份过滤 (格式: YYYY-MM)",
|
||||||
|
"name": "month",
|
||||||
|
"in": "query"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"description": "地区编码 (如 zh-CN, en-US)",
|
||||||
|
"name": "mkt",
|
||||||
|
"in": "query"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"responses": {
|
"responses": {
|
||||||
@@ -604,8 +770,53 @@
|
|||||||
"schema": {
|
"schema": {
|
||||||
"type": "array",
|
"type": "array",
|
||||||
"items": {
|
"items": {
|
||||||
"type": "object",
|
"$ref": "#/definitions/handlers.ImageMetaResp"
|
||||||
"additionalProperties": true
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/images/global/today": {
|
||||||
|
"get": {
|
||||||
|
"description": "获取配置文件中所有已开启地区的今日必应图片元数据(缩略图)",
|
||||||
|
"produces": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"image"
|
||||||
|
],
|
||||||
|
"summary": "获取所有地区的今日图片列表",
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "OK",
|
||||||
|
"schema": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/definitions/handlers.ImageMetaResp"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/regions": {
|
||||||
|
"get": {
|
||||||
|
"description": "返回系统支持的所有必应地区编码及标签。如果配置中指定了抓取地区,这些地区将排在列表最前面(置顶)。",
|
||||||
|
"produces": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"image"
|
||||||
|
],
|
||||||
|
"summary": "获取支持的地区列表",
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "OK",
|
||||||
|
"schema": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/definitions/util.Region"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -617,6 +828,14 @@
|
|||||||
"config.APIConfig": {
|
"config.APIConfig": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
|
"enableMktFallback": {
|
||||||
|
"description": "当请求的地区不存在时,是否回退到默认地区",
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"enableOnDemandFetch": {
|
||||||
|
"description": "是否启用按需抓取",
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
"mode": {
|
"mode": {
|
||||||
"description": "local | redirect",
|
"description": "local | redirect",
|
||||||
"type": "string"
|
"type": "string"
|
||||||
@@ -649,6 +868,9 @@
|
|||||||
"feature": {
|
"feature": {
|
||||||
"$ref": "#/definitions/config.FeatureConfig"
|
"$ref": "#/definitions/config.FeatureConfig"
|
||||||
},
|
},
|
||||||
|
"fetcher": {
|
||||||
|
"$ref": "#/definitions/config.FetcherConfig"
|
||||||
|
},
|
||||||
"log": {
|
"log": {
|
||||||
"$ref": "#/definitions/config.LogConfig"
|
"$ref": "#/definitions/config.LogConfig"
|
||||||
},
|
},
|
||||||
@@ -663,6 +885,9 @@
|
|||||||
},
|
},
|
||||||
"token": {
|
"token": {
|
||||||
"$ref": "#/definitions/config.TokenConfig"
|
"$ref": "#/definitions/config.TokenConfig"
|
||||||
|
},
|
||||||
|
"web": {
|
||||||
|
"$ref": "#/definitions/config.WebConfig"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -697,6 +922,17 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"config.FetcherConfig": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"regions": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"config.LocalConfig": {
|
"config.LocalConfig": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
@@ -708,8 +944,44 @@
|
|||||||
"config.LogConfig": {
|
"config.LogConfig": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
|
"compress": {
|
||||||
|
"description": "是否压缩旧日志文件",
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"dbfilename": {
|
||||||
|
"description": "数据库日志文件名",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"dblogLevel": {
|
||||||
|
"description": "数据库日志级别: debug, info, warn, error",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"filename": {
|
||||||
|
"description": "业务日志文件名",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
"level": {
|
"level": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
|
},
|
||||||
|
"logConsole": {
|
||||||
|
"description": "是否同时输出到控制台",
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"maxAge": {
|
||||||
|
"description": "保留旧日志文件最大天数",
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"maxBackups": {
|
||||||
|
"description": "保留旧日志文件最大个数",
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"maxSize": {
|
||||||
|
"description": "每个日志文件最大大小 (MB)",
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"showDBLog": {
|
||||||
|
"description": "是否在控制台显示数据库日志",
|
||||||
|
"type": "boolean"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -784,6 +1056,14 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"config.WebConfig": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"path": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"config.WebDAVConfig": {
|
"config.WebDAVConfig": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
@@ -835,6 +1115,64 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"handlers.ImageMetaResp": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"copyright": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"copyrightlink": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"date": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"fullstartdate": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"hsh": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"mkt": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"quiz": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"startdate": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"title": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"variants": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/definitions/handlers.ImageVariantResp"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"handlers.ImageVariantResp": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"format": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"size": {
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"storage_key": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"url": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"variant": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"handlers.LoginRequest": {
|
"handlers.LoginRequest": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"required": [
|
"required": [
|
||||||
@@ -887,6 +1225,17 @@
|
|||||||
"type": "string"
|
"type": "string"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"util.Region": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"label": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"value": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"securityDefinitions": {
|
"securityDefinitions": {
|
||||||
|
|||||||
@@ -2,6 +2,12 @@ basePath: /api/v1
|
|||||||
definitions:
|
definitions:
|
||||||
config.APIConfig:
|
config.APIConfig:
|
||||||
properties:
|
properties:
|
||||||
|
enableMktFallback:
|
||||||
|
description: 当请求的地区不存在时,是否回退到默认地区
|
||||||
|
type: boolean
|
||||||
|
enableOnDemandFetch:
|
||||||
|
description: 是否启用按需抓取
|
||||||
|
type: boolean
|
||||||
mode:
|
mode:
|
||||||
description: local | redirect
|
description: local | redirect
|
||||||
type: string
|
type: string
|
||||||
@@ -23,6 +29,8 @@ definitions:
|
|||||||
$ref: '#/definitions/config.DBConfig'
|
$ref: '#/definitions/config.DBConfig'
|
||||||
feature:
|
feature:
|
||||||
$ref: '#/definitions/config.FeatureConfig'
|
$ref: '#/definitions/config.FeatureConfig'
|
||||||
|
fetcher:
|
||||||
|
$ref: '#/definitions/config.FetcherConfig'
|
||||||
log:
|
log:
|
||||||
$ref: '#/definitions/config.LogConfig'
|
$ref: '#/definitions/config.LogConfig'
|
||||||
retention:
|
retention:
|
||||||
@@ -33,6 +41,8 @@ definitions:
|
|||||||
$ref: '#/definitions/config.StorageConfig'
|
$ref: '#/definitions/config.StorageConfig'
|
||||||
token:
|
token:
|
||||||
$ref: '#/definitions/config.TokenConfig'
|
$ref: '#/definitions/config.TokenConfig'
|
||||||
|
web:
|
||||||
|
$ref: '#/definitions/config.WebConfig'
|
||||||
type: object
|
type: object
|
||||||
config.CronConfig:
|
config.CronConfig:
|
||||||
properties:
|
properties:
|
||||||
@@ -54,6 +64,13 @@ definitions:
|
|||||||
writeDailyFiles:
|
writeDailyFiles:
|
||||||
type: boolean
|
type: boolean
|
||||||
type: object
|
type: object
|
||||||
|
config.FetcherConfig:
|
||||||
|
properties:
|
||||||
|
regions:
|
||||||
|
items:
|
||||||
|
type: string
|
||||||
|
type: array
|
||||||
|
type: object
|
||||||
config.LocalConfig:
|
config.LocalConfig:
|
||||||
properties:
|
properties:
|
||||||
root:
|
root:
|
||||||
@@ -61,8 +78,35 @@ definitions:
|
|||||||
type: object
|
type: object
|
||||||
config.LogConfig:
|
config.LogConfig:
|
||||||
properties:
|
properties:
|
||||||
|
compress:
|
||||||
|
description: 是否压缩旧日志文件
|
||||||
|
type: boolean
|
||||||
|
dbfilename:
|
||||||
|
description: 数据库日志文件名
|
||||||
|
type: string
|
||||||
|
dblogLevel:
|
||||||
|
description: '数据库日志级别: debug, info, warn, error'
|
||||||
|
type: string
|
||||||
|
filename:
|
||||||
|
description: 业务日志文件名
|
||||||
|
type: string
|
||||||
level:
|
level:
|
||||||
type: string
|
type: string
|
||||||
|
logConsole:
|
||||||
|
description: 是否同时输出到控制台
|
||||||
|
type: boolean
|
||||||
|
maxAge:
|
||||||
|
description: 保留旧日志文件最大天数
|
||||||
|
type: integer
|
||||||
|
maxBackups:
|
||||||
|
description: 保留旧日志文件最大个数
|
||||||
|
type: integer
|
||||||
|
maxSize:
|
||||||
|
description: 每个日志文件最大大小 (MB)
|
||||||
|
type: integer
|
||||||
|
showDBLog:
|
||||||
|
description: 是否在控制台显示数据库日志
|
||||||
|
type: boolean
|
||||||
type: object
|
type: object
|
||||||
config.RetentionConfig:
|
config.RetentionConfig:
|
||||||
properties:
|
properties:
|
||||||
@@ -110,6 +154,11 @@ definitions:
|
|||||||
defaultTTL:
|
defaultTTL:
|
||||||
type: string
|
type: string
|
||||||
type: object
|
type: object
|
||||||
|
config.WebConfig:
|
||||||
|
properties:
|
||||||
|
path:
|
||||||
|
type: string
|
||||||
|
type: object
|
||||||
config.WebDAVConfig:
|
config.WebDAVConfig:
|
||||||
properties:
|
properties:
|
||||||
password:
|
password:
|
||||||
@@ -144,6 +193,44 @@ definitions:
|
|||||||
required:
|
required:
|
||||||
- name
|
- name
|
||||||
type: object
|
type: object
|
||||||
|
handlers.ImageMetaResp:
|
||||||
|
properties:
|
||||||
|
copyright:
|
||||||
|
type: string
|
||||||
|
copyrightlink:
|
||||||
|
type: string
|
||||||
|
date:
|
||||||
|
type: string
|
||||||
|
fullstartdate:
|
||||||
|
type: string
|
||||||
|
hsh:
|
||||||
|
type: string
|
||||||
|
mkt:
|
||||||
|
type: string
|
||||||
|
quiz:
|
||||||
|
type: string
|
||||||
|
startdate:
|
||||||
|
type: string
|
||||||
|
title:
|
||||||
|
type: string
|
||||||
|
variants:
|
||||||
|
items:
|
||||||
|
$ref: '#/definitions/handlers.ImageVariantResp'
|
||||||
|
type: array
|
||||||
|
type: object
|
||||||
|
handlers.ImageVariantResp:
|
||||||
|
properties:
|
||||||
|
format:
|
||||||
|
type: string
|
||||||
|
size:
|
||||||
|
type: integer
|
||||||
|
storage_key:
|
||||||
|
type: string
|
||||||
|
url:
|
||||||
|
type: string
|
||||||
|
variant:
|
||||||
|
type: string
|
||||||
|
type: object
|
||||||
handlers.LoginRequest:
|
handlers.LoginRequest:
|
||||||
properties:
|
properties:
|
||||||
password:
|
password:
|
||||||
@@ -178,6 +265,13 @@ definitions:
|
|||||||
updated_at:
|
updated_at:
|
||||||
type: string
|
type: string
|
||||||
type: object
|
type: object
|
||||||
|
util.Region:
|
||||||
|
properties:
|
||||||
|
label:
|
||||||
|
type: string
|
||||||
|
value:
|
||||||
|
type: string
|
||||||
|
type: object
|
||||||
host: localhost:8080
|
host: localhost:8080
|
||||||
info:
|
info:
|
||||||
contact: {}
|
contact: {}
|
||||||
@@ -431,6 +525,10 @@ paths:
|
|||||||
name: date
|
name: date
|
||||||
required: true
|
required: true
|
||||||
type: string
|
type: string
|
||||||
|
- description: 地区编码 (如 zh-CN, en-US)
|
||||||
|
in: query
|
||||||
|
name: mkt
|
||||||
|
type: string
|
||||||
- default: UHD
|
- default: UHD
|
||||||
description: 分辨率
|
description: 分辨率
|
||||||
in: query
|
in: query
|
||||||
@@ -443,12 +541,23 @@ paths:
|
|||||||
type: string
|
type: string
|
||||||
produces:
|
produces:
|
||||||
- image/jpeg
|
- image/jpeg
|
||||||
- image/webp
|
|
||||||
responses:
|
responses:
|
||||||
"200":
|
"200":
|
||||||
description: OK
|
description: OK
|
||||||
schema:
|
schema:
|
||||||
type: file
|
type: file
|
||||||
|
"202":
|
||||||
|
description: 按需抓取任务已启动
|
||||||
|
schema:
|
||||||
|
additionalProperties:
|
||||||
|
type: string
|
||||||
|
type: object
|
||||||
|
"404":
|
||||||
|
description: 图片未找到,响应体包含具体原因
|
||||||
|
schema:
|
||||||
|
additionalProperties:
|
||||||
|
type: string
|
||||||
|
type: object
|
||||||
summary: 获取指定日期图片
|
summary: 获取指定日期图片
|
||||||
tags:
|
tags:
|
||||||
- image
|
- image
|
||||||
@@ -461,13 +570,28 @@ paths:
|
|||||||
name: date
|
name: date
|
||||||
required: true
|
required: true
|
||||||
type: string
|
type: string
|
||||||
|
- description: 地区编码 (如 zh-CN, en-US)
|
||||||
|
in: query
|
||||||
|
name: mkt
|
||||||
|
type: string
|
||||||
produces:
|
produces:
|
||||||
- application/json
|
- application/json
|
||||||
responses:
|
responses:
|
||||||
"200":
|
"200":
|
||||||
description: OK
|
description: OK
|
||||||
schema:
|
schema:
|
||||||
additionalProperties: true
|
$ref: '#/definitions/handlers.ImageMetaResp'
|
||||||
|
"202":
|
||||||
|
description: 按需抓取任务已启动
|
||||||
|
schema:
|
||||||
|
additionalProperties:
|
||||||
|
type: string
|
||||||
|
type: object
|
||||||
|
"404":
|
||||||
|
description: 图片未找到,响应体包含具体原因
|
||||||
|
schema:
|
||||||
|
additionalProperties:
|
||||||
|
type: string
|
||||||
type: object
|
type: object
|
||||||
summary: 获取指定日期图片元数据
|
summary: 获取指定日期图片元数据
|
||||||
tags:
|
tags:
|
||||||
@@ -476,6 +600,10 @@ paths:
|
|||||||
get:
|
get:
|
||||||
description: 随机返回一张已抓取的图片流或重定向
|
description: 随机返回一张已抓取的图片流或重定向
|
||||||
parameters:
|
parameters:
|
||||||
|
- description: 地区编码 (如 zh-CN, en-US)
|
||||||
|
in: query
|
||||||
|
name: mkt
|
||||||
|
type: string
|
||||||
- default: UHD
|
- default: UHD
|
||||||
description: 分辨率
|
description: 分辨率
|
||||||
in: query
|
in: query
|
||||||
@@ -488,25 +616,52 @@ paths:
|
|||||||
type: string
|
type: string
|
||||||
produces:
|
produces:
|
||||||
- image/jpeg
|
- image/jpeg
|
||||||
- image/webp
|
|
||||||
responses:
|
responses:
|
||||||
"200":
|
"200":
|
||||||
description: OK
|
description: OK
|
||||||
schema:
|
schema:
|
||||||
type: file
|
type: file
|
||||||
|
"202":
|
||||||
|
description: 按需抓取任务已启动
|
||||||
|
schema:
|
||||||
|
additionalProperties:
|
||||||
|
type: string
|
||||||
|
type: object
|
||||||
|
"404":
|
||||||
|
description: 图片未找到,响应体包含具体原因
|
||||||
|
schema:
|
||||||
|
additionalProperties:
|
||||||
|
type: string
|
||||||
|
type: object
|
||||||
summary: 获取随机图片
|
summary: 获取随机图片
|
||||||
tags:
|
tags:
|
||||||
- image
|
- image
|
||||||
/image/random/meta:
|
/image/random/meta:
|
||||||
get:
|
get:
|
||||||
description: 随机获取一张已抓取图片的元数据
|
description: 随机获取一张已抓取图片的元数据
|
||||||
|
parameters:
|
||||||
|
- description: 地区编码 (如 zh-CN, en-US)
|
||||||
|
in: query
|
||||||
|
name: mkt
|
||||||
|
type: string
|
||||||
produces:
|
produces:
|
||||||
- application/json
|
- application/json
|
||||||
responses:
|
responses:
|
||||||
"200":
|
"200":
|
||||||
description: OK
|
description: OK
|
||||||
schema:
|
schema:
|
||||||
additionalProperties: true
|
$ref: '#/definitions/handlers.ImageMetaResp'
|
||||||
|
"202":
|
||||||
|
description: 按需抓取任务已启动
|
||||||
|
schema:
|
||||||
|
additionalProperties:
|
||||||
|
type: string
|
||||||
|
type: object
|
||||||
|
"404":
|
||||||
|
description: 图片未找到,响应体包含具体原因
|
||||||
|
schema:
|
||||||
|
additionalProperties:
|
||||||
|
type: string
|
||||||
type: object
|
type: object
|
||||||
summary: 获取随机图片元数据
|
summary: 获取随机图片元数据
|
||||||
tags:
|
tags:
|
||||||
@@ -515,50 +670,99 @@ paths:
|
|||||||
get:
|
get:
|
||||||
description: 根据参数返回今日必应图片流或重定向
|
description: 根据参数返回今日必应图片流或重定向
|
||||||
parameters:
|
parameters:
|
||||||
|
- description: 地区编码 (如 zh-CN, en-US)
|
||||||
|
in: query
|
||||||
|
name: mkt
|
||||||
|
type: string
|
||||||
- default: UHD
|
- default: UHD
|
||||||
description: 分辨率 (UHD, 1920x1080, 1366x768)
|
description: 分辨率 (UHD, 1920x1080, 1366x768, 1280x720, 1024x768, 800x600, 800x480,
|
||||||
|
640x480, 640x360, 480x360, 400x240, 320x240)
|
||||||
in: query
|
in: query
|
||||||
name: variant
|
name: variant
|
||||||
type: string
|
type: string
|
||||||
- default: jpg
|
- default: jpg
|
||||||
description: 格式 (jpg, webp)
|
description: 格式 (jpg)
|
||||||
in: query
|
in: query
|
||||||
name: format
|
name: format
|
||||||
type: string
|
type: string
|
||||||
produces:
|
produces:
|
||||||
- image/jpeg
|
- image/jpeg
|
||||||
- image/webp
|
|
||||||
responses:
|
responses:
|
||||||
"200":
|
"200":
|
||||||
description: OK
|
description: OK
|
||||||
schema:
|
schema:
|
||||||
type: file
|
type: file
|
||||||
|
"202":
|
||||||
|
description: 按需抓取任务已启动
|
||||||
|
schema:
|
||||||
|
additionalProperties:
|
||||||
|
type: string
|
||||||
|
type: object
|
||||||
|
"404":
|
||||||
|
description: 图片未找到,响应体包含具体原因
|
||||||
|
schema:
|
||||||
|
additionalProperties:
|
||||||
|
type: string
|
||||||
|
type: object
|
||||||
summary: 获取今日图片
|
summary: 获取今日图片
|
||||||
tags:
|
tags:
|
||||||
- image
|
- image
|
||||||
/image/today/meta:
|
/image/today/meta:
|
||||||
get:
|
get:
|
||||||
description: 获取今日必应图片的标题、版权等元数据
|
description: 获取今日必应图片的标题、版权等元数据
|
||||||
|
parameters:
|
||||||
|
- description: 地区编码 (如 zh-CN, en-US)
|
||||||
|
in: query
|
||||||
|
name: mkt
|
||||||
|
type: string
|
||||||
produces:
|
produces:
|
||||||
- application/json
|
- application/json
|
||||||
responses:
|
responses:
|
||||||
"200":
|
"200":
|
||||||
description: OK
|
description: OK
|
||||||
schema:
|
schema:
|
||||||
additionalProperties: true
|
$ref: '#/definitions/handlers.ImageMetaResp'
|
||||||
|
"202":
|
||||||
|
description: 按需抓取任务已启动
|
||||||
|
schema:
|
||||||
|
additionalProperties:
|
||||||
|
type: string
|
||||||
|
type: object
|
||||||
|
"404":
|
||||||
|
description: 图片未找到,响应体包含具体原因
|
||||||
|
schema:
|
||||||
|
additionalProperties:
|
||||||
|
type: string
|
||||||
type: object
|
type: object
|
||||||
summary: 获取今日图片元数据
|
summary: 获取今日图片元数据
|
||||||
tags:
|
tags:
|
||||||
- image
|
- image
|
||||||
/images:
|
/images:
|
||||||
get:
|
get:
|
||||||
description: 分页获取已抓取的图片元数据列表
|
description: '分页获取已抓取的图片元数据列表。支持分页(page, page_size)、限制数量(limit)和按月份过滤(month,
|
||||||
|
格式: YYYY-MM)。'
|
||||||
parameters:
|
parameters:
|
||||||
- default: 30
|
- default: 30
|
||||||
description: 限制数量
|
description: 限制数量 (如果不使用分页)
|
||||||
in: query
|
in: query
|
||||||
name: limit
|
name: limit
|
||||||
type: integer
|
type: integer
|
||||||
|
- description: 页码 (从1开始)
|
||||||
|
in: query
|
||||||
|
name: page
|
||||||
|
type: integer
|
||||||
|
- description: 每页数量
|
||||||
|
in: query
|
||||||
|
name: page_size
|
||||||
|
type: integer
|
||||||
|
- description: '按月份过滤 (格式: YYYY-MM)'
|
||||||
|
in: query
|
||||||
|
name: month
|
||||||
|
type: string
|
||||||
|
- description: 地区编码 (如 zh-CN, en-US)
|
||||||
|
in: query
|
||||||
|
name: mkt
|
||||||
|
type: string
|
||||||
produces:
|
produces:
|
||||||
- application/json
|
- application/json
|
||||||
responses:
|
responses:
|
||||||
@@ -566,12 +770,41 @@ paths:
|
|||||||
description: OK
|
description: OK
|
||||||
schema:
|
schema:
|
||||||
items:
|
items:
|
||||||
additionalProperties: true
|
$ref: '#/definitions/handlers.ImageMetaResp'
|
||||||
type: object
|
|
||||||
type: array
|
type: array
|
||||||
summary: 获取图片列表
|
summary: 获取图片列表
|
||||||
tags:
|
tags:
|
||||||
- image
|
- image
|
||||||
|
/images/global/today:
|
||||||
|
get:
|
||||||
|
description: 获取配置文件中所有已开启地区的今日必应图片元数据(缩略图)
|
||||||
|
produces:
|
||||||
|
- application/json
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: OK
|
||||||
|
schema:
|
||||||
|
items:
|
||||||
|
$ref: '#/definitions/handlers.ImageMetaResp'
|
||||||
|
type: array
|
||||||
|
summary: 获取所有地区的今日图片列表
|
||||||
|
tags:
|
||||||
|
- image
|
||||||
|
/regions:
|
||||||
|
get:
|
||||||
|
description: 返回系统支持的所有必应地区编码及标签。如果配置中指定了抓取地区,这些地区将排在列表最前面(置顶)。
|
||||||
|
produces:
|
||||||
|
- application/json
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: OK
|
||||||
|
schema:
|
||||||
|
items:
|
||||||
|
$ref: '#/definitions/util.Region'
|
||||||
|
type: array
|
||||||
|
summary: 获取支持的地区列表
|
||||||
|
tags:
|
||||||
|
- image
|
||||||
securityDefinitions:
|
securityDefinitions:
|
||||||
BearerAuth:
|
BearerAuth:
|
||||||
in: header
|
in: header
|
||||||
|
|||||||
28
go.mod
28
go.mod
@@ -3,7 +3,11 @@ module BingPaper
|
|||||||
go 1.25.5
|
go 1.25.5
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/aws/aws-sdk-go v1.55.8
|
github.com/aws/aws-sdk-go-v2 v1.41.1
|
||||||
|
github.com/aws/aws-sdk-go-v2/config v1.32.7
|
||||||
|
github.com/aws/aws-sdk-go-v2/credentials v1.19.7
|
||||||
|
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.21.0
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/s3 v1.95.1
|
||||||
github.com/disintegration/imaging v1.6.2
|
github.com/disintegration/imaging v1.6.2
|
||||||
github.com/fsnotify/fsnotify v1.9.0
|
github.com/fsnotify/fsnotify v1.9.0
|
||||||
github.com/gin-gonic/gin v1.11.0
|
github.com/gin-gonic/gin v1.11.0
|
||||||
@@ -27,12 +31,28 @@ require (
|
|||||||
github.com/KyleBanks/depth v1.2.1 // indirect
|
github.com/KyleBanks/depth v1.2.1 // indirect
|
||||||
github.com/PuerkitoBio/purell v1.1.1 // indirect
|
github.com/PuerkitoBio/purell v1.1.1 // indirect
|
||||||
github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 // indirect
|
github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.4 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.17 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.17 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.17 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.17 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.8 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.17 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.17 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/signin v1.0.5 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/sso v1.30.9 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.13 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/sts v1.41.6 // indirect
|
||||||
|
github.com/aws/smithy-go v1.24.0 // indirect
|
||||||
github.com/bytedance/sonic v1.14.0 // indirect
|
github.com/bytedance/sonic v1.14.0 // indirect
|
||||||
github.com/bytedance/sonic/loader v0.3.0 // indirect
|
github.com/bytedance/sonic/loader v0.3.0 // indirect
|
||||||
github.com/cloudwego/base64x v0.1.6 // indirect
|
github.com/cloudwego/base64x v0.1.6 // indirect
|
||||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||||
github.com/gabriel-vasile/mimetype v1.4.8 // indirect
|
github.com/gabriel-vasile/mimetype v1.4.9 // indirect
|
||||||
|
github.com/gin-contrib/cors v1.7.6 // indirect
|
||||||
github.com/gin-contrib/sse v1.1.0 // indirect
|
github.com/gin-contrib/sse v1.1.0 // indirect
|
||||||
github.com/glebarez/go-sqlite v1.21.2 // indirect
|
github.com/glebarez/go-sqlite v1.21.2 // indirect
|
||||||
github.com/go-openapi/jsonpointer v0.19.5 // indirect
|
github.com/go-openapi/jsonpointer v0.19.5 // indirect
|
||||||
@@ -44,7 +64,7 @@ require (
|
|||||||
github.com/go-playground/validator/v10 v10.27.0 // indirect
|
github.com/go-playground/validator/v10 v10.27.0 // indirect
|
||||||
github.com/go-sql-driver/mysql v1.8.1 // indirect
|
github.com/go-sql-driver/mysql v1.8.1 // indirect
|
||||||
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
|
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
|
||||||
github.com/goccy/go-json v0.10.2 // indirect
|
github.com/goccy/go-json v0.10.5 // indirect
|
||||||
github.com/goccy/go-yaml v1.18.0 // indirect
|
github.com/goccy/go-yaml v1.18.0 // indirect
|
||||||
github.com/google/uuid v1.3.0 // indirect
|
github.com/google/uuid v1.3.0 // indirect
|
||||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||||
@@ -53,7 +73,6 @@ require (
|
|||||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||||
github.com/jinzhu/now v1.1.5 // indirect
|
github.com/jinzhu/now v1.1.5 // indirect
|
||||||
github.com/jmespath/go-jmespath v0.4.0 // indirect
|
|
||||||
github.com/josharian/intern v1.0.0 // indirect
|
github.com/josharian/intern v1.0.0 // indirect
|
||||||
github.com/json-iterator/go v1.1.12 // indirect
|
github.com/json-iterator/go v1.1.12 // indirect
|
||||||
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
||||||
@@ -87,6 +106,7 @@ require (
|
|||||||
golang.org/x/text v0.33.0 // indirect
|
golang.org/x/text v0.33.0 // indirect
|
||||||
golang.org/x/tools v0.40.0 // indirect
|
golang.org/x/tools v0.40.0 // indirect
|
||||||
google.golang.org/protobuf v1.36.9 // indirect
|
google.golang.org/protobuf v1.36.9 // indirect
|
||||||
|
gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect
|
||||||
gopkg.in/yaml.v2 v2.4.0 // indirect
|
gopkg.in/yaml.v2 v2.4.0 // indirect
|
||||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
modernc.org/libc v1.22.5 // indirect
|
modernc.org/libc v1.22.5 // indirect
|
||||||
|
|||||||
55
go.sum
55
go.sum
@@ -6,8 +6,46 @@ github.com/PuerkitoBio/purell v1.1.1 h1:WEQqlqaGbrPkxLJWfBwQmfEAE1Z7ONdDLqrN38tN
|
|||||||
github.com/PuerkitoBio/purell v1.1.1/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbtSwDGJws/X0=
|
github.com/PuerkitoBio/purell v1.1.1/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbtSwDGJws/X0=
|
||||||
github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 h1:d+Bc7a5rLufV/sSk/8dngufqelfh6jnri85riMAaF/M=
|
github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 h1:d+Bc7a5rLufV/sSk/8dngufqelfh6jnri85riMAaF/M=
|
||||||
github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578/go.mod h1:uGdkoq3SwY9Y+13GIhn11/XLaGBb4BfwItxLd5jeuXE=
|
github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578/go.mod h1:uGdkoq3SwY9Y+13GIhn11/XLaGBb4BfwItxLd5jeuXE=
|
||||||
github.com/aws/aws-sdk-go v1.55.8 h1:JRmEUbU52aJQZ2AjX4q4Wu7t4uZjOu71uyNmaWlUkJQ=
|
github.com/aws/aws-sdk-go-v2 v1.41.1 h1:ABlyEARCDLN034NhxlRUSZr4l71mh+T5KAeGh6cerhU=
|
||||||
github.com/aws/aws-sdk-go v1.55.8/go.mod h1:ZkViS9AqA6otK+JBBNH2++sx1sgxrPKcSzPPvQkUtXk=
|
github.com/aws/aws-sdk-go-v2 v1.41.1/go.mod h1:MayyLB8y+buD9hZqkCW3kX1AKq07Y5pXxtgB+rRFhz0=
|
||||||
|
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.4 h1:489krEF9xIGkOaaX3CE/Be2uWjiXrkCH6gUX+bZA/BU=
|
||||||
|
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.4/go.mod h1:IOAPF6oT9KCsceNTvvYMNHy0+kMF8akOjeDvPENWxp4=
|
||||||
|
github.com/aws/aws-sdk-go-v2/config v1.32.7 h1:vxUyWGUwmkQ2g19n7JY/9YL8MfAIl7bTesIUykECXmY=
|
||||||
|
github.com/aws/aws-sdk-go-v2/config v1.32.7/go.mod h1:2/Qm5vKUU/r7Y+zUk/Ptt2MDAEKAfUtKc1+3U1Mo3oY=
|
||||||
|
github.com/aws/aws-sdk-go-v2/credentials v1.19.7 h1:tHK47VqqtJxOymRrNtUXN5SP/zUTvZKeLx4tH6PGQc8=
|
||||||
|
github.com/aws/aws-sdk-go-v2/credentials v1.19.7/go.mod h1:qOZk8sPDrxhf+4Wf4oT2urYJrYt3RejHSzgAquYeppw=
|
||||||
|
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.17 h1:I0GyV8wiYrP8XpA70g1HBcQO1JlQxCMTW9npl5UbDHY=
|
||||||
|
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.17/go.mod h1:tyw7BOl5bBe/oqvoIeECFJjMdzXoa/dfVz3QQ5lgHGA=
|
||||||
|
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.21.0 h1:pQZGI0qQXeCHZHMeWzhwPu+4jkWrdrIb2dgpG4OKmco=
|
||||||
|
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.21.0/go.mod h1:XGq5kImVqQT4HUNbbG+0Y8O74URsPNH7CGPg1s1HW5E=
|
||||||
|
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.17 h1:xOLELNKGp2vsiteLsvLPwxC+mYmO6OZ8PYgiuPJzF8U=
|
||||||
|
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.17/go.mod h1:5M5CI3D12dNOtH3/mk6minaRwI2/37ifCURZISxA/IQ=
|
||||||
|
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.17 h1:WWLqlh79iO48yLkj1v3ISRNiv+3KdQoZ6JWyfcsyQik=
|
||||||
|
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.17/go.mod h1:EhG22vHRrvF8oXSTYStZhJc1aUgKtnJe+aOiFEV90cM=
|
||||||
|
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 h1:WKuaxf++XKWlHWu9ECbMlha8WOEGm0OUEZqm4K/Gcfk=
|
||||||
|
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4/go.mod h1:ZWy7j6v1vWGmPReu0iSGvRiise4YI5SkR3OHKTZ6Wuc=
|
||||||
|
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.17 h1:JqcdRG//czea7Ppjb+g/n4o8i/R50aTBHkA7vu0lK+k=
|
||||||
|
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.17/go.mod h1:CO+WeGmIdj/MlPel2KwID9Gt7CNq4M65HUfBW97liM0=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4 h1:0ryTNEdJbzUCEWkVXEXoqlXV72J5keC1GvILMOuD00E=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4/go.mod h1:HQ4qwNZh32C3CBeO6iJLQlgtMzqeG17ziAA/3KDJFow=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.8 h1:Z5EiPIzXKewUQK0QTMkutjiaPVeVYXX7KIqhXu/0fXs=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.8/go.mod h1:FsTpJtvC4U1fyDXk7c71XoDv3HlRm8V3NiYLeYLh5YE=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.17 h1:RuNSMoozM8oXlgLG/n6WLaFGoea7/CddrCfIiSA+xdY=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.17/go.mod h1:F2xxQ9TZz5gDWsclCtPQscGpP0VUOc8RqgFM3vDENmU=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.17 h1:bGeHBsGZx0Dvu/eJC0Lh9adJa3M1xREcndxLNZlve2U=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.17/go.mod h1:dcW24lbU0CzHusTE8LLHhRLI42ejmINN8Lcr22bwh/g=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/s3 v1.95.1 h1:C2dUPSnEpy4voWFIq3JNd8gN0Y5vYGDo44eUE58a/p8=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/s3 v1.95.1/go.mod h1:5jggDlZ2CLQhwJBiZJb4vfk4f0GxWdEDruWKEJ1xOdo=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/signin v1.0.5 h1:VrhDvQib/i0lxvr3zqlUwLwJP4fpmpyD9wYG1vfSu+Y=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/signin v1.0.5/go.mod h1:k029+U8SY30/3/ras4G/Fnv/b88N4mAfliNn08Dem4M=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/sso v1.30.9 h1:v6EiMvhEYBoHABfbGB4alOYmCIrcgyPPiBE1wZAEbqk=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/sso v1.30.9/go.mod h1:yifAsgBxgJWn3ggx70A3urX2AN49Y5sJTD1UQFlfqBw=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.13 h1:gd84Omyu9JLriJVCbGApcLzVR3XtmC4ZDPcAI6Ftvds=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.13/go.mod h1:sTGThjphYE4Ohw8vJiRStAcu3rbjtXRsdNB0TvZ5wwo=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/sts v1.41.6 h1:5fFjR/ToSOzB2OQ/XqWpZBmNvmP/pJ1jOWYlFDJTjRQ=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/sts v1.41.6/go.mod h1:qgFDZQSD/Kys7nJnVqYlWKnh0SSdMjAi0uSwON4wgYQ=
|
||||||
|
github.com/aws/smithy-go v1.24.0 h1:LpilSUItNPFr1eY85RYgTIg5eIEPtvFbskaFcmmIUnk=
|
||||||
|
github.com/aws/smithy-go v1.24.0/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0=
|
||||||
github.com/bytedance/sonic v1.14.0 h1:/OfKt8HFw0kh2rj8N0F6C/qPGRESq0BbaNZgcNXXzQQ=
|
github.com/bytedance/sonic v1.14.0 h1:/OfKt8HFw0kh2rj8N0F6C/qPGRESq0BbaNZgcNXXzQQ=
|
||||||
github.com/bytedance/sonic v1.14.0/go.mod h1:WoEbx8WTcFJfzCe0hbmyTGrfjt8PzNEBdxlNUO24NhA=
|
github.com/bytedance/sonic v1.14.0/go.mod h1:WoEbx8WTcFJfzCe0hbmyTGrfjt8PzNEBdxlNUO24NhA=
|
||||||
github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZwvZJyqeA=
|
github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZwvZJyqeA=
|
||||||
@@ -28,6 +66,10 @@ github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S
|
|||||||
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
|
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
|
||||||
github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM=
|
github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM=
|
||||||
github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8=
|
github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8=
|
||||||
|
github.com/gabriel-vasile/mimetype v1.4.9 h1:5k+WDwEsD9eTLL8Tz3L0VnmVh9QxGjRmjBvAG7U/oYY=
|
||||||
|
github.com/gabriel-vasile/mimetype v1.4.9/go.mod h1:WnSQhFKJuBlRyLiKohA/2DtIlPFAbguNaG7QCHcyGok=
|
||||||
|
github.com/gin-contrib/cors v1.7.6 h1:3gQ8GMzs1Ylpf70y8bMw4fVpycXIeX1ZemuSQIsnQQY=
|
||||||
|
github.com/gin-contrib/cors v1.7.6/go.mod h1:Ulcl+xN4jel9t1Ry8vqph23a60FwH9xVLd+3ykmTjOk=
|
||||||
github.com/gin-contrib/gzip v0.0.6 h1:NjcunTcGAj5CO1gn4N8jHOSIeRFHIbn51z6K+xaN4d4=
|
github.com/gin-contrib/gzip v0.0.6 h1:NjcunTcGAj5CO1gn4N8jHOSIeRFHIbn51z6K+xaN4d4=
|
||||||
github.com/gin-contrib/gzip v0.0.6/go.mod h1:QOJlmV2xmayAjkNS2Y8NQsMneuRShOU/kjovCXNuzzk=
|
github.com/gin-contrib/gzip v0.0.6/go.mod h1:QOJlmV2xmayAjkNS2Y8NQsMneuRShOU/kjovCXNuzzk=
|
||||||
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
|
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
|
||||||
@@ -62,6 +104,8 @@ github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9L
|
|||||||
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
|
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
|
||||||
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
|
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
|
||||||
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
|
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
|
||||||
|
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
|
||||||
|
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||||
github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw=
|
github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw=
|
||||||
github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
||||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||||
@@ -83,10 +127,6 @@ github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD
|
|||||||
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
||||||
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
||||||
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
||||||
github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg=
|
|
||||||
github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo=
|
|
||||||
github.com/jmespath/go-jmespath/internal/testify v1.5.1 h1:shLQSRRSCCPj3f2gpwzGwWFoC7ycTf1rcQZHOlsJ6N8=
|
|
||||||
github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U=
|
|
||||||
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
|
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
|
||||||
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
|
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
|
||||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||||
@@ -233,8 +273,9 @@ gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8
|
|||||||
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||||
|
gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc=
|
||||||
|
gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc=
|
||||||
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||||
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
|
||||||
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
|
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
|
||||||
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"BingPaper/internal/config"
|
"BingPaper/internal/config"
|
||||||
"BingPaper/internal/cron"
|
"BingPaper/internal/cron"
|
||||||
@@ -34,13 +35,62 @@ func Init(webFS embed.FS, configPath string) *gin.Engine {
|
|||||||
cfg := config.GetConfig()
|
cfg := config.GetConfig()
|
||||||
|
|
||||||
// 2. 初始化日志
|
// 2. 初始化日志
|
||||||
util.InitLogger(cfg.Log.Level)
|
util.InitLogger(cfg.Log)
|
||||||
|
|
||||||
|
// 以 debug 级别输出配置加载详情和环境变量覆盖情况
|
||||||
|
util.Logger.Debug("Configuration loading details",
|
||||||
|
zap.String("config_file", config.GetRawViper().ConfigFileUsed()),
|
||||||
|
)
|
||||||
|
envOverrides := config.GetEnvOverrides()
|
||||||
|
if len(envOverrides) > 0 {
|
||||||
|
for _, override := range envOverrides {
|
||||||
|
util.Logger.Debug("Environment variable override applied", zap.String("detail", override))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
util.Logger.Debug("No environment variable overrides detected")
|
||||||
|
}
|
||||||
|
util.Logger.Debug("Full effective configuration:\n" + config.GetFormattedSettings())
|
||||||
|
|
||||||
|
// 输出配置信息
|
||||||
|
util.Logger.Info("Application configuration loaded")
|
||||||
|
util.Logger.Info("├─ Config file ", zap.String("path", config.GetRawViper().ConfigFileUsed()))
|
||||||
|
util.Logger.Info("├─ Database ", zap.String("type", cfg.DB.Type))
|
||||||
|
util.Logger.Info("├─ Storage ", zap.String("type", cfg.Storage.Type))
|
||||||
|
util.Logger.Info("├─ Server ", zap.Int("port", cfg.Server.Port))
|
||||||
|
util.Logger.Info("└─ Active Mkt ", zap.Strings("regions", cfg.Fetcher.Regions))
|
||||||
|
|
||||||
|
// 根据存储类型输出更多信息
|
||||||
|
switch cfg.Storage.Type {
|
||||||
|
case "s3":
|
||||||
|
util.Logger.Info("S3 storage detail",
|
||||||
|
zap.String("endpoint", cfg.Storage.S3.Endpoint),
|
||||||
|
zap.String("bucket", cfg.Storage.S3.Bucket),
|
||||||
|
)
|
||||||
|
case "webdav":
|
||||||
|
util.Logger.Info("WebDAV storage detail",
|
||||||
|
zap.String("url", cfg.Storage.WebDAV.URL),
|
||||||
|
)
|
||||||
|
default:
|
||||||
|
util.Logger.Info("Local storage detail",
|
||||||
|
zap.String("root", cfg.Storage.Local.Root),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// 3. 初始化数据库
|
// 3. 初始化数据库
|
||||||
if err := repo.InitDB(); err != nil {
|
if err := repo.InitDB(); err != nil {
|
||||||
util.Logger.Fatal("Failed to initialize database")
|
util.Logger.Fatal("Failed to initialize database")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 注册数据库配置变更回调,支持热迁移
|
||||||
|
config.OnDBConfigChange = func(newCfg *config.Config) {
|
||||||
|
util.Logger.Info("Database configuration change detected, initiating migration...")
|
||||||
|
if err := repo.MigrateDataToNewDB(repo.DB, newCfg); err != nil {
|
||||||
|
util.Logger.Error("Automatic data migration failed", zap.Error(err))
|
||||||
|
} else {
|
||||||
|
util.Logger.Info("Automatic data migration finished")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 4. 初始化存储
|
// 4. 初始化存储
|
||||||
var s storage.Storage
|
var s storage.Storage
|
||||||
var err error
|
var err error
|
||||||
@@ -99,5 +149,6 @@ func LogWelcomeInfo() {
|
|||||||
fmt.Printf(" - 管理后台: %s/admin\n", baseURL)
|
fmt.Printf(" - 管理后台: %s/admin\n", baseURL)
|
||||||
fmt.Printf(" - API 文档: %s/swagger/index.html\n", baseURL)
|
fmt.Printf(" - API 文档: %s/swagger/index.html\n", baseURL)
|
||||||
fmt.Printf(" - 今日图片: %s/api/v1/image/today\n", baseURL)
|
fmt.Printf(" - 今日图片: %s/api/v1/image/today\n", baseURL)
|
||||||
|
fmt.Printf(" - 激活地区: %s\n", strings.Join(cfg.Fetcher.Regions, ", "))
|
||||||
fmt.Println("---------------------------------------------------------")
|
fmt.Println("---------------------------------------------------------")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,97 +3,128 @@ package config
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/fsnotify/fsnotify"
|
"github.com/fsnotify/fsnotify"
|
||||||
"github.com/spf13/viper"
|
"github.com/spf13/viper"
|
||||||
|
"gopkg.in/yaml.v3"
|
||||||
|
|
||||||
|
"BingPaper/internal/util"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Config struct {
|
type Config struct {
|
||||||
Server ServerConfig `mapstructure:"server"`
|
Server ServerConfig `mapstructure:"server" yaml:"server"`
|
||||||
Log LogConfig `mapstructure:"log"`
|
Log LogConfig `mapstructure:"log" yaml:"log"`
|
||||||
API APIConfig `mapstructure:"api"`
|
API APIConfig `mapstructure:"api" yaml:"api"`
|
||||||
Cron CronConfig `mapstructure:"cron"`
|
Cron CronConfig `mapstructure:"cron" yaml:"cron"`
|
||||||
Retention RetentionConfig `mapstructure:"retention"`
|
Retention RetentionConfig `mapstructure:"retention" yaml:"retention"`
|
||||||
DB DBConfig `mapstructure:"db"`
|
DB DBConfig `mapstructure:"db" yaml:"db"`
|
||||||
Storage StorageConfig `mapstructure:"storage"`
|
Storage StorageConfig `mapstructure:"storage" yaml:"storage"`
|
||||||
Admin AdminConfig `mapstructure:"admin"`
|
Admin AdminConfig `mapstructure:"admin" yaml:"admin"`
|
||||||
Token TokenConfig `mapstructure:"token"`
|
Token TokenConfig `mapstructure:"token" yaml:"token"`
|
||||||
Feature FeatureConfig `mapstructure:"feature"`
|
Feature FeatureConfig `mapstructure:"feature" yaml:"feature"`
|
||||||
Web WebConfig `mapstructure:"web"`
|
Web WebConfig `mapstructure:"web" yaml:"web"`
|
||||||
|
Fetcher FetcherConfig `mapstructure:"fetcher" yaml:"fetcher"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type ServerConfig struct {
|
type ServerConfig struct {
|
||||||
Port int `mapstructure:"port"`
|
Port int `mapstructure:"port" yaml:"port"`
|
||||||
BaseURL string `mapstructure:"base_url"`
|
BaseURL string `mapstructure:"base_url" yaml:"base_url"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type LogConfig struct {
|
type LogConfig struct {
|
||||||
Level string `mapstructure:"level"`
|
Level string `mapstructure:"level" yaml:"level"`
|
||||||
|
Filename string `mapstructure:"filename" yaml:"filename"` // 业务日志文件名
|
||||||
|
DBFilename string `mapstructure:"db_filename" yaml:"db_filename"` // 数据库日志文件名
|
||||||
|
MaxSize int `mapstructure:"max_size" yaml:"max_size"` // 每个日志文件最大大小 (MB)
|
||||||
|
MaxBackups int `mapstructure:"max_backups" yaml:"max_backups"` // 保留旧日志文件最大个数
|
||||||
|
MaxAge int `mapstructure:"max_age" yaml:"max_age"` // 保留旧日志文件最大天数
|
||||||
|
Compress bool `mapstructure:"compress" yaml:"compress"` // 是否压缩旧日志文件
|
||||||
|
LogConsole bool `mapstructure:"log_console" yaml:"log_console"` // 是否同时输出到控制台
|
||||||
|
ShowDBLog bool `mapstructure:"show_db_log" yaml:"show_db_log"` // 是否在控制台显示数据库日志
|
||||||
|
DBLogLevel string `mapstructure:"db_log_level" yaml:"db_log_level"` // 数据库日志级别: debug, info, warn, error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c LogConfig) GetLevel() string { return c.Level }
|
||||||
|
func (c LogConfig) GetFilename() string { return c.Filename }
|
||||||
|
func (c LogConfig) GetDBFilename() string { return c.DBFilename }
|
||||||
|
func (c LogConfig) GetMaxSize() int { return c.MaxSize }
|
||||||
|
func (c LogConfig) GetMaxBackups() int { return c.MaxBackups }
|
||||||
|
func (c LogConfig) GetMaxAge() int { return c.MaxAge }
|
||||||
|
func (c LogConfig) GetCompress() bool { return c.Compress }
|
||||||
|
func (c LogConfig) GetLogConsole() bool { return c.LogConsole }
|
||||||
|
func (c LogConfig) GetShowDBLog() bool { return c.ShowDBLog }
|
||||||
|
func (c LogConfig) GetDBLogLevel() string { return c.DBLogLevel }
|
||||||
|
|
||||||
type APIConfig struct {
|
type APIConfig struct {
|
||||||
Mode string `mapstructure:"mode"` // local | redirect
|
Mode string `mapstructure:"mode" yaml:"mode"` // local | redirect
|
||||||
|
EnableMktFallback bool `mapstructure:"enable_mkt_fallback" yaml:"enable_mkt_fallback"` // 当请求的地区不存在时,是否回退到默认地区
|
||||||
|
EnableOnDemandFetch bool `mapstructure:"enable_on_demand_fetch" yaml:"enable_on_demand_fetch"` // 是否启用按需抓取
|
||||||
}
|
}
|
||||||
|
|
||||||
type CronConfig struct {
|
type CronConfig struct {
|
||||||
Enabled bool `mapstructure:"enabled"`
|
Enabled bool `mapstructure:"enabled" yaml:"enabled"`
|
||||||
DailySpec string `mapstructure:"daily_spec"`
|
DailySpec string `mapstructure:"daily_spec" yaml:"daily_spec"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type RetentionConfig struct {
|
type RetentionConfig struct {
|
||||||
Days int `mapstructure:"days"`
|
Days int `mapstructure:"days" yaml:"days"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type DBConfig struct {
|
type DBConfig struct {
|
||||||
Type string `mapstructure:"type"` // sqlite/mysql/postgres
|
Type string `mapstructure:"type" yaml:"type"` // sqlite/mysql/postgres
|
||||||
DSN string `mapstructure:"dsn"`
|
DSN string `mapstructure:"dsn" yaml:"dsn"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type StorageConfig struct {
|
type StorageConfig struct {
|
||||||
Type string `mapstructure:"type"` // local/s3/webdav
|
Type string `mapstructure:"type" yaml:"type"` // local/s3/webdav
|
||||||
Local LocalConfig `mapstructure:"local"`
|
Local LocalConfig `mapstructure:"local" yaml:"local"`
|
||||||
S3 S3Config `mapstructure:"s3"`
|
S3 S3Config `mapstructure:"s3" yaml:"s3"`
|
||||||
WebDAV WebDAVConfig `mapstructure:"webdav"`
|
WebDAV WebDAVConfig `mapstructure:"webdav" yaml:"webdav"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type LocalConfig struct {
|
type LocalConfig struct {
|
||||||
Root string `mapstructure:"root"`
|
Root string `mapstructure:"root" yaml:"root"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type S3Config struct {
|
type S3Config struct {
|
||||||
Endpoint string `mapstructure:"endpoint"`
|
Endpoint string `mapstructure:"endpoint" yaml:"endpoint"`
|
||||||
Region string `mapstructure:"region"`
|
Region string `mapstructure:"region" yaml:"region"`
|
||||||
Bucket string `mapstructure:"bucket"`
|
Bucket string `mapstructure:"bucket" yaml:"bucket"`
|
||||||
AccessKey string `mapstructure:"access_key"`
|
AccessKey string `mapstructure:"access_key" yaml:"access_key"`
|
||||||
SecretKey string `mapstructure:"secret_key"`
|
SecretKey string `mapstructure:"secret_key" yaml:"secret_key"`
|
||||||
PublicURLPrefix string `mapstructure:"public_url_prefix"`
|
PublicURLPrefix string `mapstructure:"public_url_prefix" yaml:"public_url_prefix"`
|
||||||
ForcePathStyle bool `mapstructure:"force_path_style"`
|
ForcePathStyle bool `mapstructure:"force_path_style" yaml:"force_path_style"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type WebDAVConfig struct {
|
type WebDAVConfig struct {
|
||||||
URL string `mapstructure:"url"`
|
URL string `mapstructure:"url" yaml:"url"`
|
||||||
Username string `mapstructure:"username"`
|
Username string `mapstructure:"username" yaml:"username"`
|
||||||
Password string `mapstructure:"password"`
|
Password string `mapstructure:"password" yaml:"password"`
|
||||||
PublicURLPrefix string `mapstructure:"public_url_prefix"`
|
PublicURLPrefix string `mapstructure:"public_url_prefix" yaml:"public_url_prefix"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type AdminConfig struct {
|
type AdminConfig struct {
|
||||||
PasswordBcrypt string `mapstructure:"password_bcrypt"`
|
PasswordBcrypt string `mapstructure:"password_bcrypt" yaml:"password_bcrypt"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type TokenConfig struct {
|
type TokenConfig struct {
|
||||||
DefaultTTL string `mapstructure:"default_ttl"`
|
DefaultTTL string `mapstructure:"default_ttl" yaml:"default_ttl"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type FeatureConfig struct {
|
type FeatureConfig struct {
|
||||||
WriteDailyFiles bool `mapstructure:"write_daily_files"`
|
WriteDailyFiles bool `mapstructure:"write_daily_files" yaml:"write_daily_files"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type WebConfig struct {
|
type WebConfig struct {
|
||||||
Path string `mapstructure:"path"`
|
Path string `mapstructure:"path" yaml:"path"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type FetcherConfig struct {
|
||||||
|
Regions []string `mapstructure:"regions" yaml:"regions"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Bing 默认配置 (内置)
|
// Bing 默认配置 (内置)
|
||||||
@@ -107,6 +138,9 @@ var (
|
|||||||
GlobalConfig *Config
|
GlobalConfig *Config
|
||||||
configLock sync.RWMutex
|
configLock sync.RWMutex
|
||||||
v *viper.Viper
|
v *viper.Viper
|
||||||
|
|
||||||
|
// OnDBConfigChange 当数据库配置发生变更时的回调函数
|
||||||
|
OnDBConfigChange func(newCfg *Config)
|
||||||
)
|
)
|
||||||
|
|
||||||
func Init(configPath string) error {
|
func Init(configPath string) error {
|
||||||
@@ -122,10 +156,21 @@ func Init(configPath string) error {
|
|||||||
|
|
||||||
v.SetDefault("server.port", 8080)
|
v.SetDefault("server.port", 8080)
|
||||||
v.SetDefault("log.level", "info")
|
v.SetDefault("log.level", "info")
|
||||||
v.SetDefault("api.mode", "local")
|
v.SetDefault("log.filename", "data/logs/app.log")
|
||||||
|
v.SetDefault("log.db_filename", "data/logs/db.log")
|
||||||
|
v.SetDefault("log.max_size", 100)
|
||||||
|
v.SetDefault("log.max_backups", 3)
|
||||||
|
v.SetDefault("log.max_age", 7)
|
||||||
|
v.SetDefault("log.compress", true)
|
||||||
|
v.SetDefault("log.log_console", true)
|
||||||
|
v.SetDefault("log.show_db_log", false)
|
||||||
|
v.SetDefault("log.db_log_level", "info")
|
||||||
|
v.SetDefault("api.mode", "redirect")
|
||||||
|
v.SetDefault("api.enable_mkt_fallback", false)
|
||||||
|
v.SetDefault("api.enable_on_demand_fetch", false)
|
||||||
v.SetDefault("cron.enabled", true)
|
v.SetDefault("cron.enabled", true)
|
||||||
v.SetDefault("cron.daily_spec", "0 10 * * *")
|
v.SetDefault("cron.daily_spec", "20 8-23/4 * * *")
|
||||||
v.SetDefault("retention.days", 30)
|
v.SetDefault("retention.days", 0)
|
||||||
v.SetDefault("db.type", "sqlite")
|
v.SetDefault("db.type", "sqlite")
|
||||||
v.SetDefault("db.dsn", "data/bing_paper.db")
|
v.SetDefault("db.dsn", "data/bing_paper.db")
|
||||||
v.SetDefault("storage.type", "local")
|
v.SetDefault("storage.type", "local")
|
||||||
@@ -133,6 +178,13 @@ func Init(configPath string) error {
|
|||||||
v.SetDefault("token.default_ttl", "168h")
|
v.SetDefault("token.default_ttl", "168h")
|
||||||
v.SetDefault("feature.write_daily_files", true)
|
v.SetDefault("feature.write_daily_files", true)
|
||||||
v.SetDefault("web.path", "web")
|
v.SetDefault("web.path", "web")
|
||||||
|
|
||||||
|
// 默认抓取所有支持的地区
|
||||||
|
var defaultRegions []string
|
||||||
|
for _, r := range util.AllRegions {
|
||||||
|
defaultRegions = append(defaultRegions, r.Value)
|
||||||
|
}
|
||||||
|
v.SetDefault("fetcher.regions", defaultRegions)
|
||||||
v.SetDefault("admin.password_bcrypt", "$2a$10$fYHPeWHmwObephJvtlyH1O8DIgaLk5TINbi9BOezo2M8cSjmJchka") // 默认密码: admin123
|
v.SetDefault("admin.password_bcrypt", "$2a$10$fYHPeWHmwObephJvtlyH1O8DIgaLk5TINbi9BOezo2M8cSjmJchka") // 默认密码: admin123
|
||||||
|
|
||||||
// 绑定环境变量
|
// 绑定环境变量
|
||||||
@@ -160,8 +212,13 @@ func Init(configPath string) error {
|
|||||||
targetConfigPath = "data/config.yaml"
|
targetConfigPath = "data/config.yaml"
|
||||||
}
|
}
|
||||||
fmt.Printf("Config file not found, creating default config at %s\n", targetConfigPath)
|
fmt.Printf("Config file not found, creating default config at %s\n", targetConfigPath)
|
||||||
if err := v.SafeWriteConfigAs(targetConfigPath); err != nil {
|
|
||||||
fmt.Printf("Warning: Failed to create default config file: %v\n", err)
|
var defaultCfg Config
|
||||||
|
if err := v.Unmarshal(&defaultCfg); err == nil {
|
||||||
|
data, _ := yaml.Marshal(&defaultCfg)
|
||||||
|
if err := os.WriteFile(targetConfigPath, data, 0644); err != nil {
|
||||||
|
fmt.Printf("Warning: Failed to create default config file: %v\n", err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -177,8 +234,19 @@ func Init(configPath string) error {
|
|||||||
var newCfg Config
|
var newCfg Config
|
||||||
if err := v.Unmarshal(&newCfg); err == nil {
|
if err := v.Unmarshal(&newCfg); err == nil {
|
||||||
configLock.Lock()
|
configLock.Lock()
|
||||||
|
oldDBConfig := GlobalConfig.DB
|
||||||
GlobalConfig = &newCfg
|
GlobalConfig = &newCfg
|
||||||
|
newDBConfig := newCfg.DB
|
||||||
configLock.Unlock()
|
configLock.Unlock()
|
||||||
|
|
||||||
|
// 检查数据库配置是否发生变更
|
||||||
|
if oldDBConfig.Type != newDBConfig.Type || oldDBConfig.DSN != newDBConfig.DSN {
|
||||||
|
// 触发数据库迁移逻辑
|
||||||
|
// 这里由于循环依赖问题,我们可能需要通过回调或者一个统一的 Reload 函数来处理
|
||||||
|
if OnDBConfigChange != nil {
|
||||||
|
OnDBConfigChange(&newCfg)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
v.WatchConfig()
|
v.WatchConfig()
|
||||||
@@ -193,24 +261,67 @@ func GetConfig() *Config {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func SaveConfig(cfg *Config) error {
|
func SaveConfig(cfg *Config) error {
|
||||||
v.Set("server", cfg.Server)
|
configLock.Lock()
|
||||||
v.Set("log", cfg.Log)
|
defer configLock.Unlock()
|
||||||
v.Set("api", cfg.API)
|
|
||||||
v.Set("cron", cfg.Cron)
|
// 1. 使用 yaml.v3 序列化,它会尊重结构体字段顺序及 yaml 标签
|
||||||
v.Set("retention", cfg.Retention)
|
data, err := yaml.Marshal(cfg)
|
||||||
v.Set("db", cfg.DB)
|
if err != nil {
|
||||||
v.Set("storage", cfg.Storage)
|
return fmt.Errorf("failed to marshal config: %v", err)
|
||||||
v.Set("admin", cfg.Admin)
|
}
|
||||||
v.Set("token", cfg.Token)
|
|
||||||
v.Set("feature", cfg.Feature)
|
// 2. 获取当前使用的配置文件路径
|
||||||
v.Set("web", cfg.Web)
|
targetPath := v.ConfigFileUsed()
|
||||||
return v.WriteConfig()
|
if targetPath == "" {
|
||||||
|
targetPath = "data/config.yaml" // 默认回退路径
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 直接写入文件,绕过 viper 的字母序排序逻辑
|
||||||
|
if err := os.WriteFile(targetPath, data, 0644); err != nil {
|
||||||
|
return fmt.Errorf("failed to write config file: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. 同步更新内存中的全局配置对象
|
||||||
|
GlobalConfig = cfg
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func GetRawViper() *viper.Viper {
|
func GetRawViper() *viper.Viper {
|
||||||
return v
|
return v
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetAllSettings 返回所有生效配置项
|
||||||
|
func GetAllSettings() map[string]interface{} {
|
||||||
|
return v.AllSettings()
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetFormattedSettings 以 key: value 形式返回所有配置项的字符串
|
||||||
|
func GetFormattedSettings() string {
|
||||||
|
keys := v.AllKeys()
|
||||||
|
sort.Strings(keys)
|
||||||
|
var sb strings.Builder
|
||||||
|
for _, k := range keys {
|
||||||
|
sb.WriteString(fmt.Sprintf("%s: %v\n", k, v.Get(k)))
|
||||||
|
}
|
||||||
|
return sb.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetEnvOverrides 返回环境变量覆盖详情(已排序)
|
||||||
|
func GetEnvOverrides() []string {
|
||||||
|
var overrides []string
|
||||||
|
keys := v.AllKeys()
|
||||||
|
sort.Strings(keys)
|
||||||
|
for _, key := range keys {
|
||||||
|
// 根据 viper 的配置生成对应的环境变量名
|
||||||
|
// Prefix: BINGPAPER, KeyReplacer: . -> _
|
||||||
|
envKey := strings.ToUpper(fmt.Sprintf("BINGPAPER_%s", strings.ReplaceAll(key, ".", "_")))
|
||||||
|
if val, ok := os.LookupEnv(envKey); ok {
|
||||||
|
overrides = append(overrides, fmt.Sprintf("%s: %s=%s", key, envKey, val))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return overrides
|
||||||
|
}
|
||||||
|
|
||||||
func GetTokenTTL() time.Duration {
|
func GetTokenTTL() time.Duration {
|
||||||
ttl, err := time.ParseDuration(GetConfig().Token.DefaultTTL)
|
ttl, err := time.ParseDuration(GetConfig().Token.DefaultTTL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -218,3 +329,11 @@ func GetTokenTTL() time.Duration {
|
|||||||
}
|
}
|
||||||
return ttl
|
return ttl
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetDefaultMkt 返回生效的默认地区编码
|
||||||
|
func (c *Config) GetDefaultMkt() string {
|
||||||
|
if len(c.Fetcher.Regions) > 0 {
|
||||||
|
return c.Fetcher.Regions[0]
|
||||||
|
}
|
||||||
|
return BingMkt
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
package config
|
package config
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -19,3 +22,46 @@ func TestDefaultConfig(t *testing.T) {
|
|||||||
t.Errorf("Expected DB type sqlite, got %s", cfg.DB.Type)
|
t.Errorf("Expected DB type sqlite, got %s", cfg.DB.Type)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestDebugFunctions(t *testing.T) {
|
||||||
|
// 设置一个环境变量
|
||||||
|
os.Setenv("BINGPAPER_SERVER_PORT", "9999")
|
||||||
|
defer os.Unsetenv("BINGPAPER_SERVER_PORT")
|
||||||
|
|
||||||
|
err := Init("")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to init config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
settings := GetAllSettings()
|
||||||
|
serverCfg, ok := settings["server"].(map[string]interface{})
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("Expected server config map, got %v", settings["server"])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Viper numbers in AllSettings are often int
|
||||||
|
portValue := serverCfg["port"]
|
||||||
|
// 允许不同的数字类型,因为 viper 内部实现可能变化
|
||||||
|
portStr := fmt.Sprintf("%v", portValue)
|
||||||
|
if portStr != "9999" {
|
||||||
|
t.Errorf("Expected port 9999 in settings, got %v (%T)", portValue, portValue)
|
||||||
|
}
|
||||||
|
|
||||||
|
overrides := GetEnvOverrides()
|
||||||
|
found := false
|
||||||
|
for _, o := range overrides {
|
||||||
|
if strings.Contains(o, "server.port") && strings.Contains(o, "9999") {
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Errorf("Expected server.port override in %v", overrides)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证格式化输出
|
||||||
|
formatted := GetFormattedSettings()
|
||||||
|
if !strings.Contains(formatted, "server.port: 9999") {
|
||||||
|
t.Errorf("Expected formatted settings to contain server.port: 9999, got %s", formatted)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,46 +5,88 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"BingPaper/internal/config"
|
"BingPaper/internal/config"
|
||||||
"BingPaper/internal/model"
|
"BingPaper/internal/model"
|
||||||
"BingPaper/internal/service/image"
|
"BingPaper/internal/service/image"
|
||||||
"BingPaper/internal/storage"
|
"BingPaper/internal/storage"
|
||||||
|
"BingPaper/internal/util"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
"go.uber.org/zap"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
type ImageVariantResp struct {
|
||||||
|
Variant string `json:"variant"`
|
||||||
|
Format string `json:"format"`
|
||||||
|
Size int64 `json:"size"`
|
||||||
|
URL string `json:"url"`
|
||||||
|
StorageKey string `json:"storage_key"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ImageMetaResp struct {
|
||||||
|
Date string `json:"date"`
|
||||||
|
Mkt string `json:"mkt"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Copyright string `json:"copyright"`
|
||||||
|
CopyrightLink string `json:"copyrightlink"`
|
||||||
|
Quiz string `json:"quiz"`
|
||||||
|
StartDate string `json:"startdate"`
|
||||||
|
FullStartDate string `json:"fullstartdate"`
|
||||||
|
HSH string `json:"hsh"`
|
||||||
|
Variants []ImageVariantResp `json:"variants"`
|
||||||
|
}
|
||||||
|
|
||||||
// GetToday 获取今日图片
|
// GetToday 获取今日图片
|
||||||
// @Summary 获取今日图片
|
// @Summary 获取今日图片
|
||||||
// @Description 根据参数返回今日必应图片流或重定向
|
// @Description 根据参数返回今日必应图片流或重定向
|
||||||
// @Tags image
|
// @Tags image
|
||||||
// @Param variant query string false "分辨率 (UHD, 1920x1080, 1366x768)" default(UHD)
|
// @Param mkt query string false "地区编码 (如 zh-CN, en-US)"
|
||||||
|
// @Param variant query string false "分辨率 (UHD, 1920x1080, 1366x768, 1280x720, 1024x768, 800x600, 800x480, 640x480, 640x360, 480x360, 400x240, 320x240)" default(UHD)
|
||||||
// @Param format query string false "格式 (jpg)" default(jpg)
|
// @Param format query string false "格式 (jpg)" default(jpg)
|
||||||
// @Produce image/jpeg
|
// @Produce image/jpeg
|
||||||
// @Success 200 {file} binary
|
// @Success 200 {file} binary
|
||||||
|
// @Success 202 {object} map[string]string "按需抓取任务已启动"
|
||||||
|
// @Failure 404 {object} map[string]string "图片未找到,响应体包含具体原因"
|
||||||
// @Router /image/today [get]
|
// @Router /image/today [get]
|
||||||
func GetToday(c *gin.Context) {
|
func GetToday(c *gin.Context) {
|
||||||
img, err := image.GetTodayImage()
|
mkt := c.Query("mkt")
|
||||||
if err != nil {
|
img, err := image.GetTodayImage(mkt)
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
if err == image.ErrFetchStarted {
|
||||||
|
c.JSON(http.StatusAccepted, gin.H{"message": fmt.Sprintf("On-demand fetch started for region [%s]. Please try again later.", mkt)})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
handleImageResponse(c, img)
|
if err != nil {
|
||||||
|
sendImageNotFound(c, mkt)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
handleImageResponse(c, img, 7200) // 2小时
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetTodayMeta 获取今日图片元数据
|
// GetTodayMeta 获取今日图片元数据
|
||||||
// @Summary 获取今日图片元数据
|
// @Summary 获取今日图片元数据
|
||||||
// @Description 获取今日必应图片的标题、版权等元数据
|
// @Description 获取今日必应图片的标题、版权等元数据
|
||||||
// @Tags image
|
// @Tags image
|
||||||
|
// @Param mkt query string false "地区编码 (如 zh-CN, en-US)"
|
||||||
// @Produce json
|
// @Produce json
|
||||||
// @Success 200 {object} map[string]interface{}
|
// @Success 200 {object} ImageMetaResp
|
||||||
|
// @Success 202 {object} map[string]string "按需抓取任务已启动"
|
||||||
|
// @Failure 404 {object} map[string]string "图片未找到,响应体包含具体原因"
|
||||||
// @Router /image/today/meta [get]
|
// @Router /image/today/meta [get]
|
||||||
func GetTodayMeta(c *gin.Context) {
|
func GetTodayMeta(c *gin.Context) {
|
||||||
img, err := image.GetTodayImage()
|
mkt := c.Query("mkt")
|
||||||
if err != nil {
|
img, err := image.GetTodayImage(mkt)
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
if err == image.ErrFetchStarted {
|
||||||
|
c.JSON(http.StatusAccepted, gin.H{"message": fmt.Sprintf("On-demand fetch started for region [%s]. Please try again later.", mkt)})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if err != nil {
|
||||||
|
sendImageNotFound(c, mkt)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Header("Cache-Control", "public, max-age=7200") // 2小时
|
||||||
c.JSON(http.StatusOK, formatMeta(img))
|
c.JSON(http.StatusOK, formatMeta(img))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -52,33 +94,50 @@ func GetTodayMeta(c *gin.Context) {
|
|||||||
// @Summary 获取随机图片
|
// @Summary 获取随机图片
|
||||||
// @Description 随机返回一张已抓取的图片流或重定向
|
// @Description 随机返回一张已抓取的图片流或重定向
|
||||||
// @Tags image
|
// @Tags image
|
||||||
|
// @Param mkt query string false "地区编码 (如 zh-CN, en-US)"
|
||||||
// @Param variant query string false "分辨率" default(UHD)
|
// @Param variant query string false "分辨率" default(UHD)
|
||||||
// @Param format query string false "格式" default(jpg)
|
// @Param format query string false "格式" default(jpg)
|
||||||
// @Produce image/jpeg
|
// @Produce image/jpeg
|
||||||
// @Success 200 {file} binary
|
// @Success 200 {file} binary
|
||||||
|
// @Success 202 {object} map[string]string "按需抓取任务已启动"
|
||||||
|
// @Failure 404 {object} map[string]string "图片未找到,响应体包含具体原因"
|
||||||
// @Router /image/random [get]
|
// @Router /image/random [get]
|
||||||
func GetRandom(c *gin.Context) {
|
func GetRandom(c *gin.Context) {
|
||||||
img, err := image.GetRandomImage()
|
mkt := c.Query("mkt")
|
||||||
if err != nil {
|
img, err := image.GetRandomImage(mkt)
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
if err == image.ErrFetchStarted {
|
||||||
|
c.JSON(http.StatusAccepted, gin.H{"message": fmt.Sprintf("On-demand fetch started for region [%s]. Please try again later.", mkt)})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
handleImageResponse(c, img)
|
if err != nil {
|
||||||
|
sendImageNotFound(c, mkt)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
handleImageResponse(c, img, 0) // 禁用缓存
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetRandomMeta 获取随机图片元数据
|
// GetRandomMeta 获取随机图片元数据
|
||||||
// @Summary 获取随机图片元数据
|
// @Summary 获取随机图片元数据
|
||||||
// @Description 随机获取一张已抓取图片的元数据
|
// @Description 随机获取一张已抓取图片的元数据
|
||||||
// @Tags image
|
// @Tags image
|
||||||
|
// @Param mkt query string false "地区编码 (如 zh-CN, en-US)"
|
||||||
// @Produce json
|
// @Produce json
|
||||||
// @Success 200 {object} map[string]interface{}
|
// @Success 200 {object} ImageMetaResp
|
||||||
|
// @Success 202 {object} map[string]string "按需抓取任务已启动"
|
||||||
|
// @Failure 404 {object} map[string]string "图片未找到,响应体包含具体原因"
|
||||||
// @Router /image/random/meta [get]
|
// @Router /image/random/meta [get]
|
||||||
func GetRandomMeta(c *gin.Context) {
|
func GetRandomMeta(c *gin.Context) {
|
||||||
img, err := image.GetRandomImage()
|
mkt := c.Query("mkt")
|
||||||
if err != nil {
|
img, err := image.GetRandomImage(mkt)
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
if err == image.ErrFetchStarted {
|
||||||
|
c.JSON(http.StatusAccepted, gin.H{"message": fmt.Sprintf("On-demand fetch started for region [%s]. Please try again later.", mkt)})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if err != nil {
|
||||||
|
sendImageNotFound(c, mkt)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Header("Cache-Control", "no-cache, no-store, must-revalidate")
|
||||||
c.JSON(http.StatusOK, formatMeta(img))
|
c.JSON(http.StatusOK, formatMeta(img))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -87,19 +146,27 @@ func GetRandomMeta(c *gin.Context) {
|
|||||||
// @Description 根据日期返回图片流或重定向 (yyyy-mm-dd)
|
// @Description 根据日期返回图片流或重定向 (yyyy-mm-dd)
|
||||||
// @Tags image
|
// @Tags image
|
||||||
// @Param date path string true "日期 (yyyy-mm-dd)"
|
// @Param date path string true "日期 (yyyy-mm-dd)"
|
||||||
|
// @Param mkt query string false "地区编码 (如 zh-CN, en-US)"
|
||||||
// @Param variant query string false "分辨率" default(UHD)
|
// @Param variant query string false "分辨率" default(UHD)
|
||||||
// @Param format query string false "格式" default(jpg)
|
// @Param format query string false "格式" default(jpg)
|
||||||
// @Produce image/jpeg
|
// @Produce image/jpeg
|
||||||
// @Success 200 {file} binary
|
// @Success 200 {file} binary
|
||||||
|
// @Success 202 {object} map[string]string "按需抓取任务已启动"
|
||||||
|
// @Failure 404 {object} map[string]string "图片未找到,响应体包含具体原因"
|
||||||
// @Router /image/date/{date} [get]
|
// @Router /image/date/{date} [get]
|
||||||
func GetByDate(c *gin.Context) {
|
func GetByDate(c *gin.Context) {
|
||||||
date := c.Param("date")
|
date := c.Param("date")
|
||||||
img, err := image.GetImageByDate(date)
|
mkt := c.Query("mkt")
|
||||||
if err != nil {
|
img, err := image.GetImageByDate(date, mkt)
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
if err == image.ErrFetchStarted {
|
||||||
|
c.JSON(http.StatusAccepted, gin.H{"message": fmt.Sprintf("On-demand fetch started for region [%s]. Please try again later.", mkt)})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
handleImageResponse(c, img)
|
if err != nil {
|
||||||
|
sendImageNotFound(c, mkt)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
handleImageResponse(c, img, 604800) // 7天
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetByDateMeta 获取指定日期图片元数据
|
// GetByDateMeta 获取指定日期图片元数据
|
||||||
@@ -107,46 +174,141 @@ func GetByDate(c *gin.Context) {
|
|||||||
// @Description 根据日期获取图片元数据 (yyyy-mm-dd)
|
// @Description 根据日期获取图片元数据 (yyyy-mm-dd)
|
||||||
// @Tags image
|
// @Tags image
|
||||||
// @Param date path string true "日期 (yyyy-mm-dd)"
|
// @Param date path string true "日期 (yyyy-mm-dd)"
|
||||||
|
// @Param mkt query string false "地区编码 (如 zh-CN, en-US)"
|
||||||
// @Produce json
|
// @Produce json
|
||||||
// @Success 200 {object} map[string]interface{}
|
// @Success 200 {object} ImageMetaResp
|
||||||
|
// @Success 202 {object} map[string]string "按需抓取任务已启动"
|
||||||
|
// @Failure 404 {object} map[string]string "图片未找到,响应体包含具体原因"
|
||||||
// @Router /image/date/{date}/meta [get]
|
// @Router /image/date/{date}/meta [get]
|
||||||
func GetByDateMeta(c *gin.Context) {
|
func GetByDateMeta(c *gin.Context) {
|
||||||
date := c.Param("date")
|
date := c.Param("date")
|
||||||
img, err := image.GetImageByDate(date)
|
mkt := c.Query("mkt")
|
||||||
if err != nil {
|
img, err := image.GetImageByDate(date, mkt)
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
if err == image.ErrFetchStarted {
|
||||||
|
c.JSON(http.StatusAccepted, gin.H{"message": fmt.Sprintf("On-demand fetch started for region [%s]. Please try again later.", mkt)})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if err != nil {
|
||||||
|
sendImageNotFound(c, mkt)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Header("Cache-Control", "public, max-age=604800") // 7天
|
||||||
c.JSON(http.StatusOK, formatMeta(img))
|
c.JSON(http.StatusOK, formatMeta(img))
|
||||||
}
|
}
|
||||||
|
|
||||||
// ListImages 获取图片列表
|
// ListImages 获取图片列表
|
||||||
// @Summary 获取图片列表
|
// @Summary 获取图片列表
|
||||||
// @Description 分页获取已抓取的图片元数据列表
|
// @Description 分页获取已抓取的图片元数据列表。支持分页(page, page_size)、限制数量(limit)和按月份过滤(month, 格式: YYYY-MM)。
|
||||||
// @Tags image
|
// @Tags image
|
||||||
// @Param limit query int false "限制数量" default(30)
|
// @Param limit query int false "限制数量 (如果不使用分页)" default(30)
|
||||||
|
// @Param page query int false "页码 (从1开始)"
|
||||||
|
// @Param page_size query int false "每页数量"
|
||||||
|
// @Param month query string false "按月份过滤 (格式: YYYY-MM)"
|
||||||
|
// @Param mkt query string false "地区编码 (如 zh-CN, en-US)"
|
||||||
// @Produce json
|
// @Produce json
|
||||||
// @Success 200 {array} map[string]interface{}
|
// @Success 200 {array} ImageMetaResp
|
||||||
// @Router /images [get]
|
// @Router /images [get]
|
||||||
func ListImages(c *gin.Context) {
|
func ListImages(c *gin.Context) {
|
||||||
limitStr := c.DefaultQuery("limit", "30")
|
limitStr := c.Query("limit")
|
||||||
var limit int
|
pageStr := c.Query("page")
|
||||||
fmt.Sscanf(limitStr, "%d", &limit)
|
pageSizeStr := c.Query("page_size")
|
||||||
|
month := c.Query("month")
|
||||||
|
mkt := c.Query("mkt")
|
||||||
|
|
||||||
images, err := image.GetImageList(limit)
|
// 记录请求参数,便于排查过滤失效问题
|
||||||
|
util.Logger.Debug("ListImages parameters",
|
||||||
|
zap.String("month", month),
|
||||||
|
zap.String("mkt", mkt),
|
||||||
|
zap.String("page", pageStr),
|
||||||
|
zap.String("page_size", pageSizeStr),
|
||||||
|
zap.String("limit", limitStr))
|
||||||
|
|
||||||
|
var limit, offset int
|
||||||
|
|
||||||
|
if pageStr != "" && pageSizeStr != "" {
|
||||||
|
page, _ := strconv.Atoi(pageStr)
|
||||||
|
pageSize, _ := strconv.Atoi(pageSizeStr)
|
||||||
|
if page < 1 {
|
||||||
|
page = 1
|
||||||
|
}
|
||||||
|
if pageSize < 1 {
|
||||||
|
pageSize = 30
|
||||||
|
}
|
||||||
|
limit = pageSize
|
||||||
|
offset = (page - 1) * pageSize
|
||||||
|
} else {
|
||||||
|
if limitStr == "" {
|
||||||
|
limit = 30
|
||||||
|
} else {
|
||||||
|
limit, _ = strconv.Atoi(limitStr)
|
||||||
|
}
|
||||||
|
offset = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
images, err := image.GetImageList(limit, offset, month, mkt)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
util.Logger.Error("ListImages service call failed", zap.Error(err))
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
result := []gin.H{}
|
result := []gin.H{}
|
||||||
for _, img := range images {
|
for _, img := range images {
|
||||||
result = append(result, formatMeta(&img))
|
result = append(result, formatMetaSummary(&img))
|
||||||
}
|
}
|
||||||
c.JSON(http.StatusOK, result)
|
c.JSON(http.StatusOK, result)
|
||||||
}
|
}
|
||||||
|
|
||||||
func handleImageResponse(c *gin.Context, img *model.Image) {
|
// ListGlobalTodayImages 获取所有地区的今日图片列表
|
||||||
|
// @Summary 获取所有地区的今日图片列表
|
||||||
|
// @Description 获取配置文件中所有已开启地区的今日必应图片元数据(缩略图)
|
||||||
|
// @Tags image
|
||||||
|
// @Produce json
|
||||||
|
// @Success 200 {array} ImageMetaResp
|
||||||
|
// @Router /images/global/today [get]
|
||||||
|
func ListGlobalTodayImages(c *gin.Context) {
|
||||||
|
images, err := image.GetAllRegionsTodayImages()
|
||||||
|
if err != nil {
|
||||||
|
util.Logger.Error("ListGlobalTodayImages service call failed", zap.Error(err))
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
result := []gin.H{}
|
||||||
|
for _, img := range images {
|
||||||
|
result = append(result, formatMetaSummary(&img))
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
func sendImageNotFound(c *gin.Context, mkt string) {
|
||||||
|
cfg := config.GetConfig().API
|
||||||
|
message := "image not found"
|
||||||
|
|
||||||
|
if mkt != "" {
|
||||||
|
reasons := []string{}
|
||||||
|
if !util.IsValidRegion(mkt) {
|
||||||
|
reasons = append(reasons, fmt.Sprintf("[%s] is not a standard region code", mkt))
|
||||||
|
} else {
|
||||||
|
if !cfg.EnableOnDemandFetch {
|
||||||
|
reasons = append(reasons, "on-demand fetch is disabled")
|
||||||
|
}
|
||||||
|
if !cfg.EnableMktFallback {
|
||||||
|
reasons = append(reasons, "region fallback is disabled")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(reasons) > 0 {
|
||||||
|
message = fmt.Sprintf("Image not found for region [%s]. Reasons: %s.", mkt, strings.Join(reasons, ", "))
|
||||||
|
} else {
|
||||||
|
message = fmt.Sprintf("Image not found for region [%s] even after on-demand fetch and fallback attempts.", mkt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": message})
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleImageResponse(c *gin.Context, img *model.Image, maxAge int) {
|
||||||
variant := c.DefaultQuery("variant", "UHD")
|
variant := c.DefaultQuery("variant", "UHD")
|
||||||
format := c.DefaultQuery("format", "jpg")
|
format := c.DefaultQuery("format", "jpg")
|
||||||
|
|
||||||
@@ -171,22 +333,41 @@ func handleImageResponse(c *gin.Context, img *model.Image) {
|
|||||||
mode := config.GetConfig().API.Mode
|
mode := config.GetConfig().API.Mode
|
||||||
if mode == "redirect" {
|
if mode == "redirect" {
|
||||||
if selected.PublicURL != "" {
|
if selected.PublicURL != "" {
|
||||||
|
if maxAge > 0 {
|
||||||
|
c.Header("Cache-Control", fmt.Sprintf("public, max-age=%d", maxAge))
|
||||||
|
} else {
|
||||||
|
c.Header("Cache-Control", "no-cache, no-store, must-revalidate")
|
||||||
|
}
|
||||||
c.Redirect(http.StatusFound, selected.PublicURL)
|
c.Redirect(http.StatusFound, selected.PublicURL)
|
||||||
} else if img.URLBase != "" {
|
} else if img.URLBase != "" {
|
||||||
// 兜底重定向到原始 Bing
|
// 兜底重定向到原始 Bing
|
||||||
bingURL := fmt.Sprintf("https://www.bing.com%s_%s.jpg", img.URLBase, selected.Variant)
|
bingURL := fmt.Sprintf("https://www.bing.com%s_%s.jpg", img.URLBase, selected.Variant)
|
||||||
|
if maxAge > 0 {
|
||||||
|
c.Header("Cache-Control", fmt.Sprintf("public, max-age=%d", maxAge))
|
||||||
|
} else {
|
||||||
|
c.Header("Cache-Control", "no-cache, no-store, must-revalidate")
|
||||||
|
}
|
||||||
c.Redirect(http.StatusFound, bingURL)
|
c.Redirect(http.StatusFound, bingURL)
|
||||||
} else {
|
} else {
|
||||||
serveLocal(c, selected.StorageKey)
|
serveLocal(c, selected.StorageKey, img.Date, maxAge)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
serveLocal(c, selected.StorageKey)
|
serveLocal(c, selected.StorageKey, img.Date, maxAge)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func serveLocal(c *gin.Context, key string) {
|
func serveLocal(c *gin.Context, key string, etag string, maxAge int) {
|
||||||
|
if etag != "" {
|
||||||
|
c.Header("ETag", fmt.Sprintf("\"%s\"", etag))
|
||||||
|
if c.GetHeader("If-None-Match") == fmt.Sprintf("\"%s\"", etag) {
|
||||||
|
c.AbortWithStatus(http.StatusNotModified)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
reader, contentType, err := storage.GlobalStorage.Get(context.Background(), key)
|
reader, contentType, err := storage.GlobalStorage.Get(context.Background(), key)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
util.Logger.Error("Failed to get image from storage", zap.String("key", key), zap.Error(err))
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get image"})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get image"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -195,9 +376,58 @@ func serveLocal(c *gin.Context, key string) {
|
|||||||
if contentType != "" {
|
if contentType != "" {
|
||||||
c.Header("Content-Type", contentType)
|
c.Header("Content-Type", contentType)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if maxAge > 0 {
|
||||||
|
c.Header("Cache-Control", fmt.Sprintf("public, max-age=%d", maxAge))
|
||||||
|
} else {
|
||||||
|
c.Header("Cache-Control", "no-cache, no-store, must-revalidate")
|
||||||
|
}
|
||||||
io.Copy(c.Writer, reader)
|
io.Copy(c.Writer, reader)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func formatMetaSummary(img *model.Image) gin.H {
|
||||||
|
cfg := config.GetConfig()
|
||||||
|
|
||||||
|
// 找到最小的变体(Size 最小)
|
||||||
|
var smallest *model.ImageVariant
|
||||||
|
for i := range img.Variants {
|
||||||
|
v := &img.Variants[i]
|
||||||
|
if smallest == nil || v.Size < smallest.Size {
|
||||||
|
smallest = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
variants := []gin.H{}
|
||||||
|
if smallest != nil {
|
||||||
|
url := smallest.PublicURL
|
||||||
|
if url == "" && cfg.API.Mode == "redirect" && img.URLBase != "" {
|
||||||
|
url = fmt.Sprintf("https://www.bing.com%s_%s.jpg", img.URLBase, smallest.Variant)
|
||||||
|
} else if cfg.API.Mode == "local" || url == "" {
|
||||||
|
url = fmt.Sprintf("%s/api/v1/image/date/%s?variant=%s&format=%s&mkt=%s", cfg.Server.BaseURL, img.Date, smallest.Variant, smallest.Format, img.Mkt)
|
||||||
|
}
|
||||||
|
variants = append(variants, gin.H{
|
||||||
|
"variant": smallest.Variant,
|
||||||
|
"format": smallest.Format,
|
||||||
|
"size": smallest.Size,
|
||||||
|
"url": url,
|
||||||
|
"storage_key": smallest.StorageKey,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return gin.H{
|
||||||
|
"date": img.Date,
|
||||||
|
"mkt": img.Mkt,
|
||||||
|
"title": img.Title,
|
||||||
|
"copyright": img.Copyright,
|
||||||
|
"copyrightlink": img.CopyrightLink,
|
||||||
|
"quiz": img.Quiz,
|
||||||
|
"startdate": img.StartDate,
|
||||||
|
"fullstartdate": img.FullStartDate,
|
||||||
|
"hsh": img.HSH,
|
||||||
|
"variants": variants,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func formatMeta(img *model.Image) gin.H {
|
func formatMeta(img *model.Image) gin.H {
|
||||||
cfg := config.GetConfig()
|
cfg := config.GetConfig()
|
||||||
variants := []gin.H{}
|
variants := []gin.H{}
|
||||||
@@ -206,7 +436,7 @@ func formatMeta(img *model.Image) gin.H {
|
|||||||
if url == "" && cfg.API.Mode == "redirect" && img.URLBase != "" {
|
if url == "" && cfg.API.Mode == "redirect" && img.URLBase != "" {
|
||||||
url = fmt.Sprintf("https://www.bing.com%s_%s.jpg", img.URLBase, v.Variant)
|
url = fmt.Sprintf("https://www.bing.com%s_%s.jpg", img.URLBase, v.Variant)
|
||||||
} else if cfg.API.Mode == "local" || url == "" {
|
} else if cfg.API.Mode == "local" || url == "" {
|
||||||
url = fmt.Sprintf("%s/api/v1/image/date/%s?variant=%s&format=%s", cfg.Server.BaseURL, img.Date, v.Variant, v.Format)
|
url = fmt.Sprintf("%s/api/v1/image/date/%s?variant=%s&format=%s&mkt=%s", cfg.Server.BaseURL, img.Date, v.Variant, v.Format, img.Mkt)
|
||||||
}
|
}
|
||||||
variants = append(variants, gin.H{
|
variants = append(variants, gin.H{
|
||||||
"variant": v.Variant,
|
"variant": v.Variant,
|
||||||
@@ -218,10 +448,75 @@ func formatMeta(img *model.Image) gin.H {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return gin.H{
|
return gin.H{
|
||||||
"date": img.Date,
|
"date": img.Date,
|
||||||
"title": img.Title,
|
"mkt": img.Mkt,
|
||||||
"copyright": img.Copyright,
|
"title": img.Title,
|
||||||
"quiz": img.Quiz,
|
"copyright": img.Copyright,
|
||||||
"variants": variants,
|
"copyrightlink": img.CopyrightLink,
|
||||||
|
"quiz": img.Quiz,
|
||||||
|
"startdate": img.StartDate,
|
||||||
|
"fullstartdate": img.FullStartDate,
|
||||||
|
"hsh": img.HSH,
|
||||||
|
"variants": variants,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetRegions 获取支持的地区列表
|
||||||
|
// @Summary 获取支持的地区列表
|
||||||
|
// @Description 返回系统支持的所有必应地区编码及标签。如果配置中指定了抓取地区,这些地区将排在列表最前面(置顶)。
|
||||||
|
// @Tags image
|
||||||
|
// @Produce json
|
||||||
|
// @Success 200 {array} util.Region
|
||||||
|
// @Router /regions [get]
|
||||||
|
func GetRegions(c *gin.Context) {
|
||||||
|
cfg := config.GetConfig()
|
||||||
|
pinned := cfg.Fetcher.Regions
|
||||||
|
|
||||||
|
if len(pinned) == 0 {
|
||||||
|
// 如果没有配置抓取地区,返回所有支持的地区
|
||||||
|
c.JSON(http.StatusOK, util.AllRegions)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建一个 Map 用于快速查找配置的地区
|
||||||
|
pinnedMap := make(map[string]bool)
|
||||||
|
for _, v := range pinned {
|
||||||
|
pinnedMap[v] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// 只返回配置中的地区,并保持配置中的顺序
|
||||||
|
var result []util.Region
|
||||||
|
// 为了保持配置顺序,我们遍历 pinned 而不是 AllRegions
|
||||||
|
for _, pVal := range pinned {
|
||||||
|
for _, r := range util.AllRegions {
|
||||||
|
if r.Value == pVal {
|
||||||
|
result = append(result, r)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果配置了一些不在 AllRegions 里的 mkt,上述循环可能漏掉
|
||||||
|
// 但根据之前的逻辑,AllRegions 是已知的 17 个地区。
|
||||||
|
// 如果用户配置了 fr-CA (不在 17 个内),我们也应该返回它吗?
|
||||||
|
// 需求说 "前端页面对地区进行约束",如果配置了,前端就该显示。
|
||||||
|
// 如果不在 AllRegions 里的,我们直接返回原始编码作为 label 或者查找一下。
|
||||||
|
|
||||||
|
if len(result) < len(pinned) {
|
||||||
|
// 补全不在 AllRegions 里的地区
|
||||||
|
for _, pVal := range pinned {
|
||||||
|
found := false
|
||||||
|
for _, r := range result {
|
||||||
|
if r.Value == pVal {
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
result = append(result, util.Region{Value: pVal, Label: pVal})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, result)
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package handlers
|
package handlers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"testing"
|
"testing"
|
||||||
@@ -38,7 +39,7 @@ func TestHandleImageResponseRedirect(t *testing.T) {
|
|||||||
c, _ := gin.CreateTestContext(w)
|
c, _ := gin.CreateTestContext(w)
|
||||||
c.Request, _ = http.NewRequest("GET", "/api/v1/image/today?variant=UHD", nil)
|
c.Request, _ = http.NewRequest("GET", "/api/v1/image/today?variant=UHD", nil)
|
||||||
|
|
||||||
handleImageResponse(c, img)
|
handleImageResponse(c, img, 0)
|
||||||
|
|
||||||
assert.Equal(t, http.StatusFound, w.Code)
|
assert.Equal(t, http.StatusFound, w.Code)
|
||||||
assert.Contains(t, w.Header().Get("Location"), "bing.com")
|
assert.Contains(t, w.Header().Get("Location"), "bing.com")
|
||||||
@@ -65,4 +66,43 @@ func TestHandleImageResponseRedirect(t *testing.T) {
|
|||||||
assert.Contains(t, variants[0]["url"].(string), "myserver.com")
|
assert.Contains(t, variants[0]["url"].(string), "myserver.com")
|
||||||
assert.Contains(t, variants[0]["url"].(string), "/api/v1/image/date/")
|
assert.Contains(t, variants[0]["url"].(string), "/api/v1/image/date/")
|
||||||
})
|
})
|
||||||
|
|
||||||
|
t.Run("FormatMetaSummary should only return the smallest variant", func(t *testing.T) {
|
||||||
|
imgWithMultipleVariants := &model.Image{
|
||||||
|
Date: "2026-01-26",
|
||||||
|
Variants: []model.ImageVariant{
|
||||||
|
{Variant: "UHD", Size: 1000, Format: "jpg"},
|
||||||
|
{Variant: "640x480", Size: 200, Format: "jpg"},
|
||||||
|
{Variant: "1920x1080", Size: 500, Format: "jpg"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
meta := formatMetaSummary(imgWithMultipleVariants)
|
||||||
|
variants := meta["variants"].([]gin.H)
|
||||||
|
assert.Equal(t, 1, len(variants))
|
||||||
|
assert.Equal(t, "640x480", variants[0]["variant"])
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetRegions(t *testing.T) {
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
|
||||||
|
t.Run("GetRegions should respect pinned order", func(t *testing.T) {
|
||||||
|
// Setup config with custom pinned regions
|
||||||
|
config.Init("")
|
||||||
|
config.GetConfig().Fetcher.Regions = []string{"en-US", "ja-JP"}
|
||||||
|
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
c, _ := gin.CreateTestContext(w)
|
||||||
|
GetRegions(c)
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusOK, w.Code)
|
||||||
|
|
||||||
|
var regions []map[string]string
|
||||||
|
err := json.Unmarshal(w.Body.Bytes(), ®ions)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
assert.GreaterOrEqual(t, len(regions), 2)
|
||||||
|
assert.Equal(t, "en-US", regions[0]["value"])
|
||||||
|
assert.Equal(t, "ja-JP", regions[1]["value"])
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import (
|
|||||||
"BingPaper/internal/http/handlers"
|
"BingPaper/internal/http/handlers"
|
||||||
"BingPaper/internal/http/middleware"
|
"BingPaper/internal/http/middleware"
|
||||||
|
|
||||||
|
"github.com/gin-contrib/cors"
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
swaggerFiles "github.com/swaggo/files"
|
swaggerFiles "github.com/swaggo/files"
|
||||||
ginSwagger "github.com/swaggo/gin-swagger"
|
ginSwagger "github.com/swaggo/gin-swagger"
|
||||||
@@ -22,12 +23,17 @@ import (
|
|||||||
func SetupRouter(webFS embed.FS) *gin.Engine {
|
func SetupRouter(webFS embed.FS) *gin.Engine {
|
||||||
r := gin.Default()
|
r := gin.Default()
|
||||||
|
|
||||||
|
// CORS 配置:更宽松的配置以解决 Vue 等前端的预检请求问题
|
||||||
|
corsConfig := cors.DefaultConfig()
|
||||||
|
corsConfig.AllowAllOrigins = true
|
||||||
|
corsConfig.AllowMethods = []string{"GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"}
|
||||||
|
corsConfig.AllowHeaders = []string{"Origin", "Content-Length", "Content-Type", "Authorization", "Accept", "X-Requested-With"}
|
||||||
|
corsConfig.AllowCredentials = true
|
||||||
|
r.Use(cors.New(corsConfig))
|
||||||
|
|
||||||
// Swagger
|
// Swagger
|
||||||
r.GET("/swagger/*any", ginSwagger.WrapHandler(swaggerFiles.Handler))
|
r.GET("/swagger/*any", ginSwagger.WrapHandler(swaggerFiles.Handler))
|
||||||
|
|
||||||
// 静态文件
|
|
||||||
r.Static("/static", "./static")
|
|
||||||
|
|
||||||
api := r.Group("/api/v1")
|
api := r.Group("/api/v1")
|
||||||
{
|
{
|
||||||
// 公共接口
|
// 公共接口
|
||||||
@@ -41,6 +47,8 @@ func SetupRouter(webFS embed.FS) *gin.Engine {
|
|||||||
img.GET("/date/:date/meta", handlers.GetByDateMeta)
|
img.GET("/date/:date/meta", handlers.GetByDateMeta)
|
||||||
}
|
}
|
||||||
api.GET("/images", handlers.ListImages)
|
api.GET("/images", handlers.ListImages)
|
||||||
|
api.GET("/images/global/today", handlers.ListGlobalTodayImages)
|
||||||
|
api.GET("/regions", handlers.GetRegions)
|
||||||
|
|
||||||
// 管理接口
|
// 管理接口
|
||||||
admin := api.Group("/admin")
|
admin := api.Group("/admin")
|
||||||
@@ -74,7 +82,7 @@ func SetupRouter(webFS embed.FS) *gin.Engine {
|
|||||||
path := c.Request.URL.Path
|
path := c.Request.URL.Path
|
||||||
|
|
||||||
// 如果请求的是 API 或 Swagger,则不处理静态资源 (让其返回 404)
|
// 如果请求的是 API 或 Swagger,则不处理静态资源 (让其返回 404)
|
||||||
if strings.HasPrefix(path, "/api") || strings.HasPrefix(path, "/swagger") {
|
if strings.HasPrefix(path, "/api/v1") || strings.HasPrefix(path, "/swagger") {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,21 +7,26 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type Image struct {
|
type Image struct {
|
||||||
ID uint `gorm:"primaryKey" json:"id"`
|
ID uint `gorm:"primaryKey" json:"id"`
|
||||||
Date string `gorm:"uniqueIndex;type:varchar(10)" json:"date"` // YYYY-MM-DD
|
Date string `gorm:"uniqueIndex:idx_date_mkt;type:varchar(10)" json:"date"` // YYYY-MM-DD
|
||||||
Title string `json:"title"`
|
Mkt string `gorm:"uniqueIndex:idx_date_mkt;type:varchar(10)" json:"mkt"` // zh-CN, en-US etc.
|
||||||
Copyright string `json:"copyright"`
|
Title string `json:"title"`
|
||||||
URLBase string `json:"urlbase"`
|
Copyright string `json:"copyright"`
|
||||||
Quiz string `json:"quiz"`
|
CopyrightLink string `json:"copyrightlink"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
URLBase string `json:"urlbase"`
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
Quiz string `json:"quiz"`
|
||||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
StartDate string `json:"startdate"`
|
||||||
Variants []ImageVariant `gorm:"foreignKey:ImageID" json:"variants"`
|
FullStartDate string `json:"fullstartdate"`
|
||||||
|
HSH string `json:"hsh"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
||||||
|
Variants []ImageVariant `gorm:"foreignKey:ImageID;constraint:OnUpdate:CASCADE,OnDelete:SET NULL;" json:"variants"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type ImageVariant struct {
|
type ImageVariant struct {
|
||||||
ID uint `gorm:"primaryKey" json:"id"`
|
ID uint `gorm:"primaryKey" json:"id"`
|
||||||
ImageID uint `gorm:"uniqueIndex:idx_image_variant_format" json:"image_id"`
|
ImageID uint `gorm:"index;uniqueIndex:idx_image_variant_format" json:"image_id"`
|
||||||
Variant string `gorm:"uniqueIndex:idx_image_variant_format;type:varchar(20)" json:"variant"` // UHD, 1920x1080, etc.
|
Variant string `gorm:"uniqueIndex:idx_image_variant_format;type:varchar(20)" json:"variant"` // UHD, 1920x1080, etc.
|
||||||
Format string `gorm:"uniqueIndex:idx_image_variant_format;type:varchar(10)" json:"format"` // jpg, webp
|
Format string `gorm:"uniqueIndex:idx_image_variant_format;type:varchar(10)" json:"format"` // jpg, webp
|
||||||
StorageKey string `json:"storage_key"`
|
StorageKey string `json:"storage_key"`
|
||||||
|
|||||||
@@ -4,7 +4,9 @@ import (
|
|||||||
"BingPaper/internal/config"
|
"BingPaper/internal/config"
|
||||||
"BingPaper/internal/model"
|
"BingPaper/internal/model"
|
||||||
"BingPaper/internal/util"
|
"BingPaper/internal/util"
|
||||||
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/glebarez/sqlite"
|
"github.com/glebarez/sqlite"
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
@@ -16,32 +18,121 @@ import (
|
|||||||
|
|
||||||
var DB *gorm.DB
|
var DB *gorm.DB
|
||||||
|
|
||||||
|
type gormLogger struct {
|
||||||
|
ZapLogger *zap.Logger
|
||||||
|
LogLevel logger.LogLevel
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *gormLogger) LogMode(level logger.LogLevel) logger.Interface {
|
||||||
|
return &gormLogger{
|
||||||
|
ZapLogger: l.ZapLogger,
|
||||||
|
LogLevel: level,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *gormLogger) Info(ctx context.Context, msg string, data ...interface{}) {
|
||||||
|
if l.LogLevel >= logger.Info {
|
||||||
|
l.ZapLogger.Sugar().Infof(msg, data...)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *gormLogger) Warn(ctx context.Context, msg string, data ...interface{}) {
|
||||||
|
if l.LogLevel >= logger.Warn {
|
||||||
|
l.ZapLogger.Sugar().Warnf(msg, data...)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *gormLogger) Error(ctx context.Context, msg string, data ...interface{}) {
|
||||||
|
if l.LogLevel >= logger.Error {
|
||||||
|
l.ZapLogger.Sugar().Errorf(msg, data...)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *gormLogger) Trace(ctx context.Context, begin time.Time, fc func() (string, int64), err error) {
|
||||||
|
if l.LogLevel <= 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
elapsed := time.Since(begin)
|
||||||
|
sql, rows := fc()
|
||||||
|
if err != nil && l.LogLevel >= logger.Error {
|
||||||
|
l.ZapLogger.Error("SQL ERROR",
|
||||||
|
zap.Error(err),
|
||||||
|
zap.Duration("elapsed", elapsed),
|
||||||
|
zap.Int64("rows", rows),
|
||||||
|
zap.String("sql", sql),
|
||||||
|
)
|
||||||
|
} else if elapsed > 200*time.Millisecond && l.LogLevel >= logger.Warn {
|
||||||
|
l.ZapLogger.Warn("SLOW SQL",
|
||||||
|
zap.Duration("elapsed", elapsed),
|
||||||
|
zap.Int64("rows", rows),
|
||||||
|
zap.String("sql", sql),
|
||||||
|
)
|
||||||
|
} else if l.LogLevel >= logger.Info {
|
||||||
|
l.ZapLogger.Info("SQL",
|
||||||
|
zap.Duration("elapsed", elapsed),
|
||||||
|
zap.Int64("rows", rows),
|
||||||
|
zap.String("sql", sql),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetDialector(dbType, dsn string) (gorm.Dialector, error) {
|
||||||
|
switch dbType {
|
||||||
|
case "mysql":
|
||||||
|
return mysql.Open(dsn), nil
|
||||||
|
case "postgres":
|
||||||
|
return postgres.Open(dsn), nil
|
||||||
|
case "sqlite":
|
||||||
|
return sqlite.Open(dsn), nil
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("unsupported db type: %s", dbType)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetGormConfig(cfg *config.Config) *gorm.Config {
|
||||||
|
gormLogLevel := logger.Info
|
||||||
|
switch cfg.Log.DBLogLevel {
|
||||||
|
case "debug":
|
||||||
|
gormLogLevel = logger.Info // GORM 的 Info 级会输出所有 SQL
|
||||||
|
case "info":
|
||||||
|
gormLogLevel = logger.Info
|
||||||
|
case "warn":
|
||||||
|
gormLogLevel = logger.Warn
|
||||||
|
case "error":
|
||||||
|
gormLogLevel = logger.Error
|
||||||
|
case "silent":
|
||||||
|
gormLogLevel = logger.Silent
|
||||||
|
}
|
||||||
|
|
||||||
|
return &gorm.Config{
|
||||||
|
Logger: &gormLogger{
|
||||||
|
ZapLogger: util.DBLogger,
|
||||||
|
LogLevel: gormLogLevel,
|
||||||
|
},
|
||||||
|
DisableForeignKeyConstraintWhenMigrating: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func InitDB() error {
|
func InitDB() error {
|
||||||
cfg := config.GetConfig()
|
cfg := config.GetConfig()
|
||||||
var dialector gorm.Dialector
|
dialector, err := GetDialector(cfg.DB.Type, cfg.DB.DSN)
|
||||||
|
if err != nil {
|
||||||
switch cfg.DB.Type {
|
return err
|
||||||
case "mysql":
|
|
||||||
dialector = mysql.Open(cfg.DB.DSN)
|
|
||||||
case "postgres":
|
|
||||||
dialector = postgres.Open(cfg.DB.DSN)
|
|
||||||
case "sqlite":
|
|
||||||
dialector = sqlite.Open(cfg.DB.DSN)
|
|
||||||
default:
|
|
||||||
return fmt.Errorf("unsupported db type: %s", cfg.DB.Type)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
gormConfig := &gorm.Config{
|
gormConfig := GetGormConfig(cfg)
|
||||||
Logger: logger.Default.LogMode(logger.Info),
|
|
||||||
}
|
|
||||||
|
|
||||||
db, err := gorm.Open(dialector, gormConfig)
|
db, err := gorm.Open(dialector, gormConfig)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 针对 MySQL 的额外处理:如果数据库不存在,GORM 的 mysql 驱动通常无法直接创建库。
|
||||||
|
// 但此处假设 DSN 中指定的数据库已经存在。AutoMigrate 会负责创建表。
|
||||||
|
|
||||||
// 迁移
|
// 迁移
|
||||||
if err := db.AutoMigrate(&model.Image{}, &model.ImageVariant{}, &model.Token{}); err != nil {
|
if err := db.AutoMigrate(&model.Image{}, &model.ImageVariant{}, &model.Token{}); err != nil {
|
||||||
|
util.Logger.Error("Database migration failed", zap.Error(err))
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
94
internal/repo/migration.go
Normal file
94
internal/repo/migration.go
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
package repo
|
||||||
|
|
||||||
|
import (
|
||||||
|
"BingPaper/internal/config"
|
||||||
|
"BingPaper/internal/model"
|
||||||
|
"BingPaper/internal/util"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"go.uber.org/zap"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// MigrateDataToNewDB 将数据从旧数据库迁移到新数据库
|
||||||
|
func MigrateDataToNewDB(oldDB *gorm.DB, newConfig *config.Config) error {
|
||||||
|
util.Logger.Info("Starting data migration to new database",
|
||||||
|
zap.String("new_type", newConfig.DB.Type),
|
||||||
|
zap.String("new_dsn", newConfig.DB.DSN))
|
||||||
|
|
||||||
|
// 1. 初始化新数据库连接
|
||||||
|
dialector, err := GetDialector(newConfig.DB.Type, newConfig.DB.DSN)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to get dialector for new DB: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
gormConfig := GetGormConfig(newConfig)
|
||||||
|
newDB, err := gorm.Open(dialector, gormConfig)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to connect to new DB: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 自动迁移结构
|
||||||
|
if err := newDB.AutoMigrate(&model.Image{}, &model.ImageVariant{}, &model.Token{}); err != nil {
|
||||||
|
return fmt.Errorf("failed to migrate schema in new DB: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 清空新数据库中的现有数据(防止冲突)
|
||||||
|
util.Logger.Info("Cleaning up destination database before migration")
|
||||||
|
// 备份或清空目标数据库。由于用户要求“可能需要清空或备份”,
|
||||||
|
// 这里我们选择在迁移前清空目标表,以确保迁移过来的数据是完整且不冲突的。
|
||||||
|
if err := newDB.Session(&gorm.Session{AllowGlobalUpdate: true}).Delete(&model.ImageVariant{}).Error; err != nil {
|
||||||
|
return fmt.Errorf("failed to clear ImageVariants: %w", err)
|
||||||
|
}
|
||||||
|
if err := newDB.Session(&gorm.Session{AllowGlobalUpdate: true}).Delete(&model.Image{}).Error; err != nil {
|
||||||
|
return fmt.Errorf("failed to clear Images: %w", err)
|
||||||
|
}
|
||||||
|
if err := newDB.Session(&gorm.Session{AllowGlobalUpdate: true}).Delete(&model.Token{}).Error; err != nil {
|
||||||
|
return fmt.Errorf("failed to clear Tokens: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. 开始迁移数据
|
||||||
|
// 使用事务确保迁移的原子性
|
||||||
|
return newDB.Transaction(func(tx *gorm.DB) error {
|
||||||
|
// 迁移 Images
|
||||||
|
var images []model.Image
|
||||||
|
if err := oldDB.Find(&images).Error; err != nil {
|
||||||
|
return fmt.Errorf("failed to fetch images from old DB: %w", err)
|
||||||
|
}
|
||||||
|
if len(images) > 0 {
|
||||||
|
util.Logger.Info("Migrating images", zap.Int("count", len(images)))
|
||||||
|
if err := tx.Create(&images).Error; err != nil {
|
||||||
|
return fmt.Errorf("failed to insert images into new DB: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 迁移 ImageVariants
|
||||||
|
var variants []model.ImageVariant
|
||||||
|
if err := oldDB.Find(&variants).Error; err != nil {
|
||||||
|
return fmt.Errorf("failed to fetch variants from old DB: %w", err)
|
||||||
|
}
|
||||||
|
if len(variants) > 0 {
|
||||||
|
util.Logger.Info("Migrating variants", zap.Int("count", len(variants)))
|
||||||
|
if err := tx.Create(&variants).Error; err != nil {
|
||||||
|
return fmt.Errorf("failed to insert variants into new DB: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 迁移 Tokens
|
||||||
|
var tokens []model.Token
|
||||||
|
if err := oldDB.Find(&tokens).Error; err != nil {
|
||||||
|
return fmt.Errorf("failed to fetch tokens from old DB: %w", err)
|
||||||
|
}
|
||||||
|
if len(tokens) > 0 {
|
||||||
|
util.Logger.Info("Migrating tokens", zap.Int("count", len(tokens)))
|
||||||
|
if err := tx.Create(&tokens).Error; err != nil {
|
||||||
|
return fmt.Errorf("failed to insert tokens into new DB: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新全局 DB 指针
|
||||||
|
DB = newDB
|
||||||
|
util.Logger.Info("Data migration completed successfully")
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -18,8 +18,10 @@ import (
|
|||||||
"BingPaper/internal/repo"
|
"BingPaper/internal/repo"
|
||||||
"BingPaper/internal/storage"
|
"BingPaper/internal/storage"
|
||||||
"BingPaper/internal/util"
|
"BingPaper/internal/util"
|
||||||
|
|
||||||
"github.com/disintegration/imaging"
|
"github.com/disintegration/imaging"
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
|
"gorm.io/gorm/clause"
|
||||||
)
|
)
|
||||||
|
|
||||||
type BingResponse struct {
|
type BingResponse struct {
|
||||||
@@ -36,6 +38,7 @@ type BingImage struct {
|
|||||||
CopyrightLink string `json:"copyrightlink"`
|
CopyrightLink string `json:"copyrightlink"`
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
Quiz string `json:"quiz"`
|
Quiz string `json:"quiz"`
|
||||||
|
HSH string `json:"hsh"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type Fetcher struct {
|
type Fetcher struct {
|
||||||
@@ -52,21 +55,14 @@ func NewFetcher() *Fetcher {
|
|||||||
|
|
||||||
func (f *Fetcher) Fetch(ctx context.Context, n int) error {
|
func (f *Fetcher) Fetch(ctx context.Context, n int) error {
|
||||||
util.Logger.Info("Starting fetch task", zap.Int("n", n))
|
util.Logger.Info("Starting fetch task", zap.Int("n", n))
|
||||||
url := fmt.Sprintf("%s?format=js&idx=0&n=%d&uhd=1&mkt=%s", config.BingAPIBase, n, config.BingMkt)
|
regions := config.GetConfig().Fetcher.Regions
|
||||||
resp, err := f.httpClient.Get(url)
|
if len(regions) == 0 {
|
||||||
if err != nil {
|
regions = []string{config.GetConfig().GetDefaultMkt()}
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
var bingResp BingResponse
|
|
||||||
if err := json.NewDecoder(resp.Body).Decode(&bingResp); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, bingImg := range bingResp.Images {
|
for _, mkt := range regions {
|
||||||
if err := f.processImage(ctx, bingImg); err != nil {
|
if err := f.FetchRegion(ctx, mkt); err != nil {
|
||||||
util.Logger.Error("Failed to process image", zap.String("date", bingImg.Enddate), zap.Error(err))
|
util.Logger.Error("Failed to fetch region images", zap.String("mkt", mkt), zap.Error(err))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -74,82 +70,162 @@ func (f *Fetcher) Fetch(ctx context.Context, n int) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f *Fetcher) processImage(ctx context.Context, bingImg BingImage) error {
|
// FetchRegion 抓取指定地区的图片
|
||||||
|
func (f *Fetcher) FetchRegion(ctx context.Context, mkt string) error {
|
||||||
|
if !util.IsValidRegion(mkt) {
|
||||||
|
util.Logger.Warn("Skipping fetch for invalid region", zap.String("mkt", mkt))
|
||||||
|
return fmt.Errorf("invalid region code: %s", mkt)
|
||||||
|
}
|
||||||
|
util.Logger.Info("Fetching images for region", zap.String("mkt", mkt))
|
||||||
|
// 调用两次 API 获取最多两周的数据
|
||||||
|
// 第一次 idx=0&n=8 (今天起往回数 8 张)
|
||||||
|
if err := f.fetchByMkt(ctx, mkt, 0, 8); err != nil {
|
||||||
|
util.Logger.Error("Failed to fetch images", zap.String("mkt", mkt), zap.Int("idx", 0), zap.Error(err))
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// 第二次 idx=7&n=8 (7天前起往回数 8 张,与第一次有重叠,确保不漏)
|
||||||
|
if err := f.fetchByMkt(ctx, mkt, 7, 8); err != nil {
|
||||||
|
util.Logger.Error("Failed to fetch images", zap.String("mkt", mkt), zap.Int("idx", 7), zap.Error(err))
|
||||||
|
// 第二次失败不一定返回错误,因为可能第一次已经拿到了
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *Fetcher) fetchByMkt(ctx context.Context, mkt string, idx int, n int) error {
|
||||||
|
url := fmt.Sprintf("%s?format=js&idx=%d&n=%d&uhd=1&mkt=%s", config.BingAPIBase, idx, n, mkt)
|
||||||
|
util.Logger.Debug("Requesting Bing API", zap.String("url", url))
|
||||||
|
resp, err := f.httpClient.Get(url)
|
||||||
|
if err != nil {
|
||||||
|
util.Logger.Error("Failed to request Bing API", zap.Error(err))
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
var bingResp BingResponse
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&bingResp); err != nil {
|
||||||
|
util.Logger.Error("Failed to decode Bing API response", zap.Error(err))
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
util.Logger.Info("Fetched images from Bing", zap.String("mkt", mkt), zap.Int("count", len(bingResp.Images)))
|
||||||
|
|
||||||
|
for _, bingImg := range bingResp.Images {
|
||||||
|
if err := f.processImage(ctx, bingImg, mkt); err != nil {
|
||||||
|
util.Logger.Error("Failed to process image", zap.String("date", bingImg.Enddate), zap.String("mkt", mkt), zap.Error(err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *Fetcher) processImage(ctx context.Context, bingImg BingImage, mkt string) error {
|
||||||
dateStr := fmt.Sprintf("%s-%s-%s", bingImg.Enddate[0:4], bingImg.Enddate[4:6], bingImg.Enddate[6:8])
|
dateStr := fmt.Sprintf("%s-%s-%s", bingImg.Enddate[0:4], bingImg.Enddate[4:6], bingImg.Enddate[6:8])
|
||||||
|
|
||||||
// 幂等检查
|
// 幂等检查
|
||||||
var existing model.Image
|
var existing model.Image
|
||||||
if err := repo.DB.Where("date = ?", dateStr).First(&existing).Error; err == nil {
|
if err := repo.DB.Where("date = ? AND mkt = ?", dateStr, mkt).First(&existing).Error; err == nil {
|
||||||
util.Logger.Info("Image already exists, skipping", zap.String("date", dateStr))
|
util.Logger.Info("Image already exists, skipping", zap.String("date", dateStr), zap.String("mkt", mkt))
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
util.Logger.Info("Processing new image", zap.String("date", dateStr), zap.String("title", bingImg.Title))
|
util.Logger.Info("Processing new image", zap.String("date", dateStr), zap.String("mkt", mkt), zap.String("title", bingImg.Title))
|
||||||
|
|
||||||
// UHD 探测
|
// UHD 探测
|
||||||
imgURL, variantName := f.probeUHD(bingImg.URLBase)
|
imgURL, variantName := f.probeUHD(bingImg.URLBase)
|
||||||
|
|
||||||
imgData, err := f.downloadImage(imgURL)
|
imgData, err := f.downloadImage(imgURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
util.Logger.Error("Failed to download image", zap.String("url", imgURL), zap.Error(err))
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// 解码图片用于缩放
|
// 解码图片用于缩放
|
||||||
srcImg, _, err := image.Decode(bytes.NewReader(imgData))
|
srcImg, _, err := image.Decode(bytes.NewReader(imgData))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
util.Logger.Error("Failed to decode image data", zap.Error(err))
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// 创建 DB 记录
|
// 创建 DB 记录
|
||||||
dbImg := model.Image{
|
dbImg := model.Image{
|
||||||
Date: dateStr,
|
Date: dateStr,
|
||||||
Title: bingImg.Title,
|
Mkt: mkt,
|
||||||
Copyright: bingImg.Copyright,
|
Title: bingImg.Title,
|
||||||
URLBase: bingImg.URLBase,
|
Copyright: bingImg.Copyright,
|
||||||
Quiz: bingImg.Quiz,
|
CopyrightLink: bingImg.CopyrightLink,
|
||||||
|
URLBase: bingImg.URLBase,
|
||||||
|
Quiz: bingImg.Quiz,
|
||||||
|
StartDate: bingImg.Startdate,
|
||||||
|
FullStartDate: bingImg.Fullstartdate,
|
||||||
|
HSH: bingImg.HSH,
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := repo.DB.Create(&dbImg).Error; err != nil {
|
if err := repo.DB.Clauses(clause.OnConflict{
|
||||||
|
Columns: []clause.Column{{Name: "date"}, {Name: "mkt"}},
|
||||||
|
DoNothing: true,
|
||||||
|
}).Create(&dbImg).Error; err != nil {
|
||||||
|
util.Logger.Error("Failed to create image record", zap.Error(err))
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 再次检查 dbImg.ID 是否被填充,如果没有被填充(说明由于冲突未插入),则需要查询出已有的 ID
|
||||||
|
if dbImg.ID == 0 {
|
||||||
|
var existing model.Image
|
||||||
|
if err := repo.DB.Where("date = ? AND mkt = ?", dateStr, mkt).First(&existing).Error; err != nil {
|
||||||
|
util.Logger.Error("Failed to query existing image record after conflict", zap.Error(err))
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
dbImg = existing
|
||||||
|
}
|
||||||
|
|
||||||
// 保存各种分辨率
|
// 保存各种分辨率
|
||||||
variants := []struct {
|
targetVariants := []struct {
|
||||||
name string
|
name string
|
||||||
width int
|
width int
|
||||||
height int
|
height int
|
||||||
}{
|
}{
|
||||||
{variantName, 0, 0}, // 原图 (UHD 或 1080p)
|
|
||||||
{"1920x1080", 1920, 1080},
|
{"1920x1080", 1920, 1080},
|
||||||
{"1366x768", 1366, 768},
|
{"1366x768", 1366, 768},
|
||||||
|
{"1280x720", 1280, 720},
|
||||||
|
{"1024x768", 1024, 768},
|
||||||
|
{"800x600", 800, 600},
|
||||||
|
{"800x480", 800, 480},
|
||||||
|
{"640x480", 640, 480},
|
||||||
|
{"640x360", 640, 360},
|
||||||
|
{"480x360", 480, 360},
|
||||||
|
{"400x240", 400, 240},
|
||||||
|
{"320x240", 320, 240},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, v := range variants {
|
// 首先保存原图 (UHD 或 1080p)
|
||||||
// 如果是探测到的最高清版本,且我们已经有了数据,直接使用
|
if err := f.saveVariant(ctx, &dbImg, variantName, "jpg", imgData); err != nil {
|
||||||
var currentImgData []byte
|
util.Logger.Error("Failed to save original variant", zap.String("variant", variantName), zap.Error(err))
|
||||||
if v.width == 0 {
|
}
|
||||||
currentImgData = imgData
|
|
||||||
} else {
|
for _, v := range targetVariants {
|
||||||
resized := imaging.Fill(srcImg, v.width, v.height, imaging.Center, imaging.Lanczos)
|
// 如果目标分辨率就是原图分辨率,则跳过(已经保存过了)
|
||||||
buf := new(bytes.Buffer)
|
if v.name == variantName {
|
||||||
if err := jpeg.Encode(buf, resized, &jpeg.Options{Quality: 90}); err != nil {
|
continue
|
||||||
util.Logger.Warn("Failed to encode jpeg", zap.String("variant", v.name), zap.Error(err))
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
currentImgData = buf.Bytes()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
resized := imaging.Fill(srcImg, v.width, v.height, imaging.Center, imaging.Lanczos)
|
||||||
|
buf := new(bytes.Buffer)
|
||||||
|
if err := jpeg.Encode(buf, resized, &jpeg.Options{Quality: 100}); err != nil {
|
||||||
|
util.Logger.Warn("Failed to encode jpeg", zap.String("variant", v.name), zap.Error(err))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
currentImgData := buf.Bytes()
|
||||||
|
|
||||||
// 保存 JPG
|
// 保存 JPG
|
||||||
if err := f.saveVariant(ctx, &dbImg, v.name, "jpg", currentImgData); err != nil {
|
if err := f.saveVariant(ctx, &dbImg, v.name, "jpg", currentImgData); err != nil {
|
||||||
util.Logger.Error("Failed to save variant", zap.String("variant", v.name), zap.Error(err))
|
util.Logger.Error("Failed to save variant", zap.String("variant", v.name), zap.Error(err))
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 保存今日额外文件
|
// 保存今日额外文件
|
||||||
today := time.Now().Format("2006-01-02")
|
today := time.Now().Format("2006-01-02")
|
||||||
if dateStr == today && config.GetConfig().Feature.WriteDailyFiles {
|
if dateStr == today && config.GetConfig().Feature.WriteDailyFiles {
|
||||||
f.saveDailyFiles(srcImg, imgData)
|
f.saveDailyFiles(srcImg, imgData, mkt)
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
@@ -174,7 +250,7 @@ func (f *Fetcher) downloadImage(url string) ([]byte, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (f *Fetcher) saveVariant(ctx context.Context, img *model.Image, variant, format string, data []byte) error {
|
func (f *Fetcher) saveVariant(ctx context.Context, img *model.Image, variant, format string, data []byte) error {
|
||||||
key := fmt.Sprintf("%s/%s_%s.%s", img.Date, img.Date, variant, format)
|
key := fmt.Sprintf("%s/%s/%s_%s.%s", img.Mkt, img.Date, img.Date, variant, format)
|
||||||
contentType := "image/jpeg"
|
contentType := "image/jpeg"
|
||||||
if format == "webp" {
|
if format == "webp" {
|
||||||
contentType = "image/webp"
|
contentType = "image/webp"
|
||||||
@@ -194,27 +270,51 @@ func (f *Fetcher) saveVariant(ctx context.Context, img *model.Image, variant, fo
|
|||||||
Size: int64(len(data)),
|
Size: int64(len(data)),
|
||||||
}
|
}
|
||||||
|
|
||||||
return repo.DB.Create(&vRecord).Error
|
return repo.DB.Clauses(clause.OnConflict{
|
||||||
|
Columns: []clause.Column{{Name: "image_id"}, {Name: "variant"}, {Name: "format"}},
|
||||||
|
DoNothing: true,
|
||||||
|
}).Create(&vRecord).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f *Fetcher) saveDailyFiles(srcImg image.Image, originalData []byte) {
|
func (f *Fetcher) saveDailyFiles(srcImg image.Image, originalData []byte, mkt string) {
|
||||||
util.Logger.Info("Saving daily files")
|
util.Logger.Info("Saving daily files", zap.String("mkt", mkt))
|
||||||
localRoot := config.GetConfig().Storage.Local.Root
|
localRoot := config.GetConfig().Storage.Local.Root
|
||||||
if config.GetConfig().Storage.Type != "local" {
|
if localRoot == "" {
|
||||||
// 如果不是本地存储,保存在临时目录或指定缓存目录
|
localRoot = "data"
|
||||||
localRoot = "static"
|
|
||||||
}
|
}
|
||||||
os.MkdirAll(filepath.Join(localRoot, "static"), 0755)
|
|
||||||
|
|
||||||
// daily.jpeg (quality 95)
|
mktDir := filepath.Join(localRoot, mkt)
|
||||||
jpegPath := filepath.Join(localRoot, "static", "daily.jpeg")
|
if err := os.MkdirAll(mktDir, 0755); err != nil {
|
||||||
fJpeg, _ := os.Create(jpegPath)
|
util.Logger.Error("Failed to create directory", zap.String("path", mktDir), zap.Error(err))
|
||||||
if fJpeg != nil {
|
return
|
||||||
jpeg.Encode(fJpeg, srcImg, &jpeg.Options{Quality: 95})
|
}
|
||||||
|
|
||||||
|
// daily.jpeg (quality 100)
|
||||||
|
jpegPath := filepath.Join(mktDir, "daily.jpeg")
|
||||||
|
fJpeg, err := os.Create(jpegPath)
|
||||||
|
if err != nil {
|
||||||
|
util.Logger.Error("Failed to create daily.jpeg", zap.Error(err))
|
||||||
|
} else {
|
||||||
|
jpeg.Encode(fJpeg, srcImg, &jpeg.Options{Quality: 100})
|
||||||
fJpeg.Close()
|
fJpeg.Close()
|
||||||
}
|
}
|
||||||
|
|
||||||
// original.jpeg (quality 100)
|
// original.jpeg (quality 100)
|
||||||
originalPath := filepath.Join(localRoot, "static", "original.jpeg")
|
originalPath := filepath.Join(mktDir, "original.jpeg")
|
||||||
os.WriteFile(originalPath, originalData, 0644)
|
if err := os.WriteFile(originalPath, originalData, 0644); err != nil {
|
||||||
|
util.Logger.Error("Failed to write original.jpeg", zap.Error(err))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 同时也保留一份在根目录下(兼容旧逻辑,且作为默认地区图片)
|
||||||
|
// 如果是默认地区或者是第一个抓取的地区,可以覆盖根目录的文件
|
||||||
|
if mkt == config.GetConfig().GetDefaultMkt() {
|
||||||
|
jpegPathRoot := filepath.Join(localRoot, "daily.jpeg")
|
||||||
|
fJpegRoot, err := os.Create(jpegPathRoot)
|
||||||
|
if err == nil {
|
||||||
|
jpeg.Encode(fJpegRoot, srcImg, &jpeg.Options{Quality: 100})
|
||||||
|
fJpegRoot.Close()
|
||||||
|
}
|
||||||
|
originalPathRoot := filepath.Join(localRoot, "original.jpeg")
|
||||||
|
os.WriteFile(originalPathRoot, originalData, 0644)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,18 +2,22 @@ package image
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"BingPaper/internal/config"
|
"BingPaper/internal/config"
|
||||||
"BingPaper/internal/model"
|
"BingPaper/internal/model"
|
||||||
"BingPaper/internal/repo"
|
"BingPaper/internal/repo"
|
||||||
|
"BingPaper/internal/service/fetcher"
|
||||||
"BingPaper/internal/storage"
|
"BingPaper/internal/storage"
|
||||||
"BingPaper/internal/util"
|
"BingPaper/internal/util"
|
||||||
|
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var ErrFetchStarted = errors.New("on-demand fetch started")
|
||||||
|
|
||||||
func CleanupOldImages(ctx context.Context) error {
|
func CleanupOldImages(ctx context.Context) error {
|
||||||
days := config.GetConfig().Retention.Days
|
days := config.GetConfig().Retention.Days
|
||||||
if days <= 0 {
|
if days <= 0 {
|
||||||
@@ -25,6 +29,7 @@ func CleanupOldImages(ctx context.Context) error {
|
|||||||
|
|
||||||
var images []model.Image
|
var images []model.Image
|
||||||
if err := repo.DB.Where("date < ?", threshold).Preload("Variants").Find(&images).Error; err != nil {
|
if err := repo.DB.Where("date < ?", threshold).Preload("Variants").Find(&images).Error; err != nil {
|
||||||
|
util.Logger.Error("Failed to query old images for cleanup", zap.Error(err))
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -35,58 +40,182 @@ func CleanupOldImages(ctx context.Context) error {
|
|||||||
util.Logger.Warn("Failed to delete storage object", zap.String("key", v.StorageKey), zap.Error(err))
|
util.Logger.Warn("Failed to delete storage object", zap.String("key", v.StorageKey), zap.Error(err))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// 删除 DB 记录 (级联删除由代码处理,或者 GORM 会处理已加载的关联吗?)
|
// 删除关联记录(逻辑外键控制)
|
||||||
// 简单起见,手动删除关联
|
if err := repo.DB.Where("image_id = ?", img.ID).Delete(&model.ImageVariant{}).Error; err != nil {
|
||||||
repo.DB.Where("image_id = ?", img.ID).Delete(&model.ImageVariant{})
|
util.Logger.Error("Failed to delete variants", zap.Uint("image_id", img.ID), zap.Error(err))
|
||||||
repo.DB.Delete(&img)
|
}
|
||||||
|
// 删除主表记录
|
||||||
|
if err := repo.DB.Delete(&img).Error; err != nil {
|
||||||
|
util.Logger.Error("Failed to delete image", zap.Uint("id", img.ID), zap.Error(err))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
util.Logger.Info("Cleanup task completed", zap.Int("deleted_count", len(images)))
|
util.Logger.Info("Cleanup task completed", zap.Int("deleted_count", len(images)))
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func GetTodayImage() (*model.Image, error) {
|
func GetTodayImage(mkt string) (*model.Image, error) {
|
||||||
today := time.Now().Format("2006-01-02")
|
today := time.Now().Format("2006-01-02")
|
||||||
var img model.Image
|
var img model.Image
|
||||||
err := repo.DB.Where("date = ?", today).Preload("Variants").First(&img).Error
|
tx := repo.DB.Where("date = ?", today)
|
||||||
if err != nil {
|
if mkt != "" {
|
||||||
// 如果今天没有,尝试获取最近的一张
|
tx = tx.Where("mkt = ?", mkt)
|
||||||
err = repo.DB.Order("date desc").Preload("Variants").First(&img).Error
|
|
||||||
}
|
}
|
||||||
|
err := tx.Preload("Variants").First(&img).Error
|
||||||
|
if err != nil && mkt != "" && config.GetConfig().API.EnableOnDemandFetch && util.IsValidRegion(mkt) {
|
||||||
|
// 如果没找到,尝试异步按需抓取该地区
|
||||||
|
util.Logger.Info("Image not found in DB, starting asynchronous on-demand fetch", zap.String("mkt", mkt))
|
||||||
|
f := fetcher.NewFetcher()
|
||||||
|
go func() {
|
||||||
|
_ = f.FetchRegion(context.Background(), mkt)
|
||||||
|
}()
|
||||||
|
return nil, ErrFetchStarted
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
// 如果今天还是没有,尝试获取最近的一张
|
||||||
|
tx = repo.DB.Order("date desc")
|
||||||
|
if mkt != "" {
|
||||||
|
tx = tx.Where("mkt = ?", mkt)
|
||||||
|
}
|
||||||
|
err = tx.Preload("Variants").First(&img).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// 兜底逻辑:如果指定地区没找到,且开启了兜底开关,则尝试获取默认地区的图片
|
||||||
|
if err != nil && mkt != "" && config.GetConfig().API.EnableMktFallback {
|
||||||
|
defaultMkt := config.GetConfig().GetDefaultMkt()
|
||||||
|
if mkt != defaultMkt {
|
||||||
|
return GetTodayImage(defaultMkt)
|
||||||
|
}
|
||||||
|
return GetTodayImage("")
|
||||||
|
}
|
||||||
|
|
||||||
return &img, err
|
return &img, err
|
||||||
}
|
}
|
||||||
|
|
||||||
func GetRandomImage() (*model.Image, error) {
|
func GetAllRegionsTodayImages() ([]model.Image, error) {
|
||||||
|
regions := config.GetConfig().Fetcher.Regions
|
||||||
|
if len(regions) == 0 {
|
||||||
|
regions = []string{config.GetConfig().GetDefaultMkt()}
|
||||||
|
}
|
||||||
|
|
||||||
|
var images []model.Image
|
||||||
|
for _, mkt := range regions {
|
||||||
|
img, err := GetTodayImage(mkt)
|
||||||
|
if err == nil {
|
||||||
|
images = append(images, *img)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return images, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetRandomImage(mkt string) (*model.Image, error) {
|
||||||
var img model.Image
|
var img model.Image
|
||||||
// SQLite 使用 RANDOM(), MySQL/Postgres 使用 RANDOM() 或 RAND()
|
// SQLite 使用 RANDOM(), MySQL/Postgres 使用 RANDOM() 或 RAND()
|
||||||
// 简单起见,先查总数再 Offset
|
// 简单起见,先查总数再 Offset
|
||||||
var count int64
|
var count int64
|
||||||
repo.DB.Model(&model.Image{}).Count(&count)
|
tx := repo.DB.Model(&model.Image{})
|
||||||
|
if mkt != "" {
|
||||||
|
tx = tx.Where("mkt = ?", mkt)
|
||||||
|
}
|
||||||
|
tx.Count(&count)
|
||||||
|
if count == 0 && mkt != "" && config.GetConfig().API.EnableOnDemandFetch && util.IsValidRegion(mkt) {
|
||||||
|
// 如果没找到,尝试异步按需抓取该地区
|
||||||
|
util.Logger.Info("No images found in DB for region, starting asynchronous on-demand fetch", zap.String("mkt", mkt))
|
||||||
|
f := fetcher.NewFetcher()
|
||||||
|
go func() {
|
||||||
|
_ = f.FetchRegion(context.Background(), mkt)
|
||||||
|
}()
|
||||||
|
return nil, ErrFetchStarted
|
||||||
|
}
|
||||||
|
|
||||||
if count == 0 {
|
if count == 0 {
|
||||||
return nil, fmt.Errorf("no images found")
|
return nil, fmt.Errorf("no images found")
|
||||||
}
|
}
|
||||||
|
|
||||||
// 这种方法不适合海量数据,但对于 30 天的数据没问题
|
// 这种方法不适合海量数据,但对于 30 天的数据没问题
|
||||||
err := repo.DB.Order("RANDOM()").Preload("Variants").First(&img).Error
|
tx = repo.DB.Order("RANDOM()")
|
||||||
|
if mkt != "" {
|
||||||
|
tx = tx.Where("mkt = ?", mkt)
|
||||||
|
}
|
||||||
|
err := tx.Preload("Variants").First(&img).Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// 适配 MySQL
|
// 适配 MySQL
|
||||||
err = repo.DB.Order("RAND()").Preload("Variants").First(&img).Error
|
tx = repo.DB.Order("RAND()")
|
||||||
|
if mkt != "" {
|
||||||
|
tx = tx.Where("mkt = ?", mkt)
|
||||||
|
}
|
||||||
|
err = tx.Preload("Variants").First(&img).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 兜底逻辑
|
||||||
|
if err != nil && mkt != "" && config.GetConfig().API.EnableMktFallback {
|
||||||
|
defaultMkt := config.GetConfig().GetDefaultMkt()
|
||||||
|
if mkt != defaultMkt {
|
||||||
|
return GetRandomImage(defaultMkt)
|
||||||
|
}
|
||||||
|
return GetRandomImage("")
|
||||||
|
}
|
||||||
|
|
||||||
return &img, err
|
return &img, err
|
||||||
}
|
}
|
||||||
|
|
||||||
func GetImageByDate(date string) (*model.Image, error) {
|
func GetImageByDate(date string, mkt string) (*model.Image, error) {
|
||||||
var img model.Image
|
var img model.Image
|
||||||
err := repo.DB.Where("date = ?", date).Preload("Variants").First(&img).Error
|
tx := repo.DB.Where("date = ?", date)
|
||||||
|
if mkt != "" {
|
||||||
|
tx = tx.Where("mkt = ?", mkt)
|
||||||
|
}
|
||||||
|
err := tx.Preload("Variants").First(&img).Error
|
||||||
|
if err != nil && mkt != "" && config.GetConfig().API.EnableOnDemandFetch && util.IsValidRegion(mkt) {
|
||||||
|
// 如果没找到,尝试异步按需抓取该地区
|
||||||
|
util.Logger.Info("Image not found in DB for date, starting asynchronous on-demand fetch", zap.String("mkt", mkt), zap.String("date", date))
|
||||||
|
f := fetcher.NewFetcher()
|
||||||
|
go func() {
|
||||||
|
_ = f.FetchRegion(context.Background(), mkt)
|
||||||
|
}()
|
||||||
|
return nil, ErrFetchStarted
|
||||||
|
}
|
||||||
|
|
||||||
|
// 兜底逻辑
|
||||||
|
if err != nil && mkt != "" && config.GetConfig().API.EnableMktFallback {
|
||||||
|
defaultMkt := config.GetConfig().GetDefaultMkt()
|
||||||
|
if mkt != defaultMkt {
|
||||||
|
return GetImageByDate(date, defaultMkt)
|
||||||
|
}
|
||||||
|
return GetImageByDate(date, "")
|
||||||
|
}
|
||||||
|
|
||||||
return &img, err
|
return &img, err
|
||||||
}
|
}
|
||||||
|
|
||||||
func GetImageList(limit int) ([]model.Image, error) {
|
func GetImageList(limit int, offset int, month string, mkt string) ([]model.Image, error) {
|
||||||
var images []model.Image
|
var images []model.Image
|
||||||
db := repo.DB.Order("date desc").Preload("Variants")
|
tx := repo.DB.Model(&model.Image{})
|
||||||
if limit > 0 {
|
|
||||||
db = db.Limit(limit)
|
if month != "" {
|
||||||
|
// 增强过滤:确保只处理 YYYY-MM 格式,防止注入或非法字符
|
||||||
|
// 这里简单处理:只要不为空就增加 LIKE 过滤
|
||||||
|
util.Logger.Debug("Filtering images by month", zap.String("month", month))
|
||||||
|
tx = tx.Where("date LIKE ?", month+"%")
|
||||||
|
}
|
||||||
|
|
||||||
|
if mkt != "" {
|
||||||
|
tx = tx.Where("mkt = ?", mkt)
|
||||||
|
}
|
||||||
|
|
||||||
|
tx = tx.Order("date desc").Preload("Variants")
|
||||||
|
|
||||||
|
if limit > 0 {
|
||||||
|
tx = tx.Limit(limit)
|
||||||
|
}
|
||||||
|
if offset > 0 {
|
||||||
|
tx = tx.Offset(offset)
|
||||||
|
}
|
||||||
|
|
||||||
|
err := tx.Find(&images).Error
|
||||||
|
if err != nil {
|
||||||
|
util.Logger.Error("Failed to get image list", zap.Error(err), zap.String("month", month))
|
||||||
}
|
}
|
||||||
err := db.Find(&images).Error
|
|
||||||
return images, err
|
return images, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,42 +8,45 @@ import (
|
|||||||
|
|
||||||
"BingPaper/internal/storage"
|
"BingPaper/internal/storage"
|
||||||
|
|
||||||
"github.com/aws/aws-sdk-go/aws"
|
"github.com/aws/aws-sdk-go-v2/aws"
|
||||||
"github.com/aws/aws-sdk-go/aws/credentials"
|
"github.com/aws/aws-sdk-go-v2/config"
|
||||||
"github.com/aws/aws-sdk-go/aws/session"
|
"github.com/aws/aws-sdk-go-v2/credentials"
|
||||||
"github.com/aws/aws-sdk-go/service/s3"
|
"github.com/aws/aws-sdk-go-v2/feature/s3/manager"
|
||||||
"github.com/aws/aws-sdk-go/service/s3/s3manager"
|
"github.com/aws/aws-sdk-go-v2/service/s3"
|
||||||
)
|
)
|
||||||
|
|
||||||
type S3Storage struct {
|
type S3Storage struct {
|
||||||
session *session.Session
|
client *s3.Client
|
||||||
client *s3.S3
|
|
||||||
bucket string
|
bucket string
|
||||||
publicURLPrefix string
|
publicURLPrefix string
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewS3Storage(endpoint, region, bucket, accessKey, secretKey, publicURLPrefix string, forcePathStyle bool) (*S3Storage, error) {
|
func NewS3Storage(endpoint, region, bucket, accessKey, secretKey, publicURLPrefix string, forcePathStyle bool) (*S3Storage, error) {
|
||||||
config := &aws.Config{
|
cfg, err := config.LoadDefaultConfig(context.TODO(),
|
||||||
Region: aws.String(region),
|
config.WithRegion(region),
|
||||||
Credentials: credentials.NewStaticCredentials(accessKey, secretKey, ""),
|
config.WithCredentialsProvider(credentials.NewStaticCredentialsProvider(accessKey, secretKey, "")),
|
||||||
Endpoint: aws.String(endpoint),
|
)
|
||||||
S3ForcePathStyle: aws.Bool(forcePathStyle),
|
|
||||||
}
|
|
||||||
sess, err := session.NewSession(config)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
client := s3.NewFromConfig(cfg, func(o *s3.Options) {
|
||||||
|
if endpoint != "" {
|
||||||
|
o.BaseEndpoint = aws.String(endpoint)
|
||||||
|
}
|
||||||
|
o.UsePathStyle = forcePathStyle
|
||||||
|
})
|
||||||
|
|
||||||
return &S3Storage{
|
return &S3Storage{
|
||||||
session: sess,
|
client: client,
|
||||||
client: s3.New(sess),
|
|
||||||
bucket: bucket,
|
bucket: bucket,
|
||||||
publicURLPrefix: publicURLPrefix,
|
publicURLPrefix: publicURLPrefix,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *S3Storage) Put(ctx context.Context, key string, r io.Reader, contentType string) (storage.StoredObject, error) {
|
func (s *S3Storage) Put(ctx context.Context, key string, r io.Reader, contentType string) (storage.StoredObject, error) {
|
||||||
uploader := s3manager.NewUploader(s.session)
|
uploader := manager.NewUploader(s.client)
|
||||||
output, err := uploader.UploadWithContext(ctx, &s3manager.UploadInput{
|
output, err := uploader.Upload(ctx, &s3.PutObjectInput{
|
||||||
Bucket: aws.String(s.bucket),
|
Bucket: aws.String(s.bucket),
|
||||||
Key: aws.String(key),
|
Key: aws.String(key),
|
||||||
Body: r,
|
Body: r,
|
||||||
@@ -68,18 +71,22 @@ func (s *S3Storage) Put(ctx context.Context, key string, r io.Reader, contentTyp
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *S3Storage) Get(ctx context.Context, key string) (io.ReadCloser, string, error) {
|
func (s *S3Storage) Get(ctx context.Context, key string) (io.ReadCloser, string, error) {
|
||||||
output, err := s.client.GetObjectWithContext(ctx, &s3.GetObjectInput{
|
output, err := s.client.GetObject(ctx, &s3.GetObjectInput{
|
||||||
Bucket: aws.String(s.bucket),
|
Bucket: aws.String(s.bucket),
|
||||||
Key: aws.String(key),
|
Key: aws.String(key),
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, "", err
|
return nil, "", err
|
||||||
}
|
}
|
||||||
return output.Body, aws.StringValue(output.ContentType), nil
|
contentType := ""
|
||||||
|
if output.ContentType != nil {
|
||||||
|
contentType = *output.ContentType
|
||||||
|
}
|
||||||
|
return output.Body, contentType, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *S3Storage) Delete(ctx context.Context, key string) error {
|
func (s *S3Storage) Delete(ctx context.Context, key string) error {
|
||||||
_, err := s.client.DeleteObjectWithContext(ctx, &s3.DeleteObjectInput{
|
_, err := s.client.DeleteObject(ctx, &s3.DeleteObjectInput{
|
||||||
Bucket: aws.String(s.bucket),
|
Bucket: aws.String(s.bucket),
|
||||||
Key: aws.String(key),
|
Key: aws.String(key),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -2,14 +2,61 @@ package util
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"os"
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
"go.uber.org/zap/zapcore"
|
"go.uber.org/zap/zapcore"
|
||||||
|
"gopkg.in/natefinch/lumberjack.v2"
|
||||||
)
|
)
|
||||||
|
|
||||||
var Logger *zap.Logger
|
var Logger *zap.Logger
|
||||||
|
var DBLogger *zap.Logger
|
||||||
|
|
||||||
func InitLogger(level string) {
|
// LogConfig 定义日志配置接口,避免循环依赖
|
||||||
|
type LogConfig interface {
|
||||||
|
GetLevel() string
|
||||||
|
GetFilename() string
|
||||||
|
GetDBFilename() string
|
||||||
|
GetMaxSize() int
|
||||||
|
GetMaxBackups() int
|
||||||
|
GetMaxAge() int
|
||||||
|
GetCompress() bool
|
||||||
|
GetLogConsole() bool
|
||||||
|
GetShowDBLog() bool
|
||||||
|
GetDBLogLevel() string
|
||||||
|
}
|
||||||
|
|
||||||
|
func InitLogger(cfg LogConfig) {
|
||||||
|
// 确保日志目录存在
|
||||||
|
if cfg.GetFilename() != "" {
|
||||||
|
_ = os.MkdirAll(filepath.Dir(cfg.GetFilename()), 0755)
|
||||||
|
}
|
||||||
|
if cfg.GetDBFilename() != "" {
|
||||||
|
_ = os.MkdirAll(filepath.Dir(cfg.GetDBFilename()), 0755)
|
||||||
|
}
|
||||||
|
|
||||||
|
Logger = createZapLogger(
|
||||||
|
cfg.GetLevel(),
|
||||||
|
cfg.GetFilename(),
|
||||||
|
cfg.GetMaxSize(),
|
||||||
|
cfg.GetMaxBackups(),
|
||||||
|
cfg.GetMaxAge(),
|
||||||
|
cfg.GetCompress(),
|
||||||
|
cfg.GetLogConsole(),
|
||||||
|
)
|
||||||
|
|
||||||
|
DBLogger = createZapLogger(
|
||||||
|
cfg.GetDBLogLevel(),
|
||||||
|
cfg.GetDBFilename(),
|
||||||
|
cfg.GetMaxSize(),
|
||||||
|
cfg.GetMaxBackups(),
|
||||||
|
cfg.GetMaxAge(),
|
||||||
|
cfg.GetCompress(),
|
||||||
|
cfg.GetShowDBLog(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func createZapLogger(level, filename string, maxSize, maxBackups, maxAge int, compress, logConsole bool) *zap.Logger {
|
||||||
var zapLevel zapcore.Level
|
var zapLevel zapcore.Level
|
||||||
switch level {
|
switch level {
|
||||||
case "debug":
|
case "debug":
|
||||||
@@ -28,11 +75,33 @@ func InitLogger(level string) {
|
|||||||
encoderConfig.EncodeTime = zapcore.ISO8601TimeEncoder
|
encoderConfig.EncodeTime = zapcore.ISO8601TimeEncoder
|
||||||
encoderConfig.EncodeLevel = zapcore.CapitalLevelEncoder
|
encoderConfig.EncodeLevel = zapcore.CapitalLevelEncoder
|
||||||
|
|
||||||
core := zapcore.NewCore(
|
var cores []zapcore.Core
|
||||||
zapcore.NewConsoleEncoder(encoderConfig),
|
|
||||||
zapcore.AddSync(os.Stdout),
|
|
||||||
zapLevel,
|
|
||||||
)
|
|
||||||
|
|
||||||
Logger = zap.New(core, zap.AddCaller())
|
// 文件输出
|
||||||
|
if filename != "" {
|
||||||
|
w := zapcore.AddSync(&lumberjack.Logger{
|
||||||
|
Filename: filename,
|
||||||
|
MaxSize: maxSize,
|
||||||
|
MaxBackups: maxBackups,
|
||||||
|
MaxAge: maxAge,
|
||||||
|
Compress: compress,
|
||||||
|
})
|
||||||
|
cores = append(cores, zapcore.NewCore(
|
||||||
|
zapcore.NewConsoleEncoder(encoderConfig),
|
||||||
|
w,
|
||||||
|
zapLevel,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 控制台输出
|
||||||
|
if logConsole {
|
||||||
|
cores = append(cores, zapcore.NewCore(
|
||||||
|
zapcore.NewConsoleEncoder(encoderConfig),
|
||||||
|
zapcore.AddSync(os.Stdout),
|
||||||
|
zapLevel,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
core := zapcore.NewTee(cores...)
|
||||||
|
return zap.New(core, zap.AddCaller())
|
||||||
}
|
}
|
||||||
|
|||||||
35
internal/util/regions.go
Normal file
35
internal/util/regions.go
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
package util
|
||||||
|
|
||||||
|
import "golang.org/x/text/language"
|
||||||
|
|
||||||
|
type Region struct {
|
||||||
|
Value string `json:"value"`
|
||||||
|
Label string `json:"label"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsValidRegion 校验是否为标准的地区编码 (BCP 47)
|
||||||
|
func IsValidRegion(mkt string) bool {
|
||||||
|
if mkt == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
_, err := language.Parse(mkt)
|
||||||
|
return err == nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var AllRegions = []Region{
|
||||||
|
{Value: "zh-CN", Label: "中国"},
|
||||||
|
{Value: "en-US", Label: "美国"},
|
||||||
|
{Value: "ja-JP", Label: "日本"},
|
||||||
|
{Value: "en-AU", Label: "澳大利亚"},
|
||||||
|
{Value: "en-GB", Label: "英国"},
|
||||||
|
{Value: "de-DE", Label: "德国"},
|
||||||
|
{Value: "en-NZ", Label: "新西兰"},
|
||||||
|
{Value: "en-CA", Label: "加拿大"},
|
||||||
|
{Value: "fr-FR", Label: "法国"},
|
||||||
|
{Value: "it-IT", Label: "意大利"},
|
||||||
|
{Value: "es-ES", Label: "西班牙"},
|
||||||
|
{Value: "pt-BR", Label: "巴西"},
|
||||||
|
{Value: "ko-KR", Label: "韩国"},
|
||||||
|
{Value: "en-IN", Label: "印度"},
|
||||||
|
{Value: "ru-RU", Label: "俄罗斯"},
|
||||||
|
}
|
||||||
@@ -1,10 +1,23 @@
|
|||||||
@echo off
|
@echo off
|
||||||
setlocal enabledelayedexpansion
|
setlocal enabledelayedexpansion
|
||||||
|
|
||||||
|
:: 切换到项目根目录
|
||||||
|
cd /d %~dp0..
|
||||||
|
|
||||||
set APP_NAME=BingPaper
|
set APP_NAME=BingPaper
|
||||||
set OUTPUT_DIR=output
|
set OUTPUT_DIR=output
|
||||||
|
|
||||||
echo 开始构建 %APP_NAME% 多平台二进制文件...
|
echo 开始构建前端...
|
||||||
|
cd webapp
|
||||||
|
call npm install
|
||||||
|
call npm run build
|
||||||
|
if %errorlevel% neq 0 (
|
||||||
|
echo 前端构建失败
|
||||||
|
exit /b %errorlevel%
|
||||||
|
)
|
||||||
|
cd ..
|
||||||
|
|
||||||
|
echo 开始构建 %APP_NAME% 多平台二进制文件...
|
||||||
|
|
||||||
if exist %OUTPUT_DIR% rd /s /q %OUTPUT_DIR%
|
if exist %OUTPUT_DIR% rd /s /q %OUTPUT_DIR%
|
||||||
mkdir %OUTPUT_DIR%
|
mkdir %OUTPUT_DIR%
|
||||||
@@ -13,41 +26,41 @@ set PLATFORMS=linux/amd64 linux/arm64 windows/amd64 windows/arm64 darwin/amd64 d
|
|||||||
|
|
||||||
for %%p in (%PLATFORMS%) do (
|
for %%p in (%PLATFORMS%) do (
|
||||||
for /f "tokens=1,2 delims=/" %%a in ("%%p") do (
|
for /f "tokens=1,2 delims=/" %%a in ("%%p") do (
|
||||||
set GOOS=%%a
|
|
||||||
set GOARCH=%%b
|
|
||||||
|
|
||||||
set OUTPUT_NAME=%APP_NAME%-%%a-%%b
|
set OUTPUT_NAME=%APP_NAME%-%%a-%%b
|
||||||
set BINARY_NAME=!OUTPUT_NAME!
|
set BINARY_NAME=%APP_NAME%
|
||||||
if "%%a"=="windows" set BINARY_NAME=!OUTPUT_NAME!.exe
|
if "%%a"=="windows" set BINARY_NAME=%APP_NAME%.exe
|
||||||
|
|
||||||
echo 正在编译 %%a/%%b...
|
echo 正在编译 %%a/%%b...
|
||||||
|
|
||||||
set PACKAGE_DIR=%OUTPUT_DIR%\!OUTPUT_NAME!
|
set PACKAGE_DIR=%OUTPUT_DIR%\!OUTPUT_NAME!
|
||||||
if not exist !PACKAGE_DIR! mkdir !PACKAGE_DIR!
|
if not exist !PACKAGE_DIR! mkdir !PACKAGE_DIR!
|
||||||
|
|
||||||
env GOOS=%%a GOARCH=%%b CGO_ENABLED=0 go build -o !PACKAGE_DIR!\!BINARY_NAME! main.go
|
set GOOS=%%a
|
||||||
|
set GOARCH=%%b
|
||||||
|
set CGO_ENABLED=0
|
||||||
|
go build -ldflags="-s -w" -o !PACKAGE_DIR!\!BINARY_NAME! main.go
|
||||||
|
|
||||||
if !errorlevel! equ 0 (
|
if !errorlevel! equ 0 (
|
||||||
echo %%a/%%b 编译成功
|
echo %%a/%%b 编译成功
|
||||||
|
|
||||||
xcopy /e /i /y web !PACKAGE_DIR!\web >nul
|
xcopy /e /i /y web !PACKAGE_DIR!\web >nul
|
||||||
copy /y config.example.yaml !PACKAGE_DIR!\ >nul
|
copy /y config.example.yaml !PACKAGE_DIR!\ >nul
|
||||||
copy /y README.md !PACKAGE_DIR!\ >nul
|
copy /y README.md !PACKAGE_DIR!\ >nul
|
||||||
|
|
||||||
pushd %OUTPUT_DIR%
|
pushd !PACKAGE_DIR!
|
||||||
tar -czf !OUTPUT_NAME!.tar.gz !OUTPUT_NAME!
|
tar -czf ..\!OUTPUT_NAME!.tar.gz .
|
||||||
rd /s /q !OUTPUT_NAME!
|
|
||||||
popd
|
popd
|
||||||
|
rd /s /q !PACKAGE_DIR!
|
||||||
|
|
||||||
echo %%a/%%b 打包完成: !OUTPUT_NAME!.tar.gz
|
echo %%a/%%b 打包完成: !OUTPUT_NAME!.tar.gz
|
||||||
) else (
|
) else (
|
||||||
echo %%a/%%b 编译失败
|
echo %%a/%%b 编译失败
|
||||||
if exist !PACKAGE_DIR! rd /s /q !PACKAGE_DIR!
|
if exist !PACKAGE_DIR! rd /s /q !PACKAGE_DIR!
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
echo ----------------------------------------
|
echo ----------------------------------------
|
||||||
echo 多平台打包完成!输出目录: %OUTPUT_DIR%
|
echo 多平台打包完成!输出目录: %OUTPUT_DIR%
|
||||||
dir /s /b %OUTPUT_DIR%
|
dir /s /b %OUTPUT_DIR%
|
||||||
pause
|
pause
|
||||||
|
|||||||
@@ -1,7 +1,22 @@
|
|||||||
|
# 切换到项目根目录
|
||||||
|
$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Definition
|
||||||
|
if ($ScriptDir) { Set-Location (Join-Path $ScriptDir "..") }
|
||||||
|
|
||||||
$AppName = "BingPaper"
|
$AppName = "BingPaper"
|
||||||
$OutputDir = "output"
|
$OutputDir = "output"
|
||||||
|
|
||||||
Write-Host "开始构建 $AppName 多平台二进制文件..."
|
Write-Host "开始构建前端..."
|
||||||
|
Push-Location webapp
|
||||||
|
npm install
|
||||||
|
npm run build
|
||||||
|
if ($LASTEXITCODE -ne 0) {
|
||||||
|
Write-Host "前端构建失败" -ForegroundColor Red
|
||||||
|
Pop-Location
|
||||||
|
exit $LASTEXITCODE
|
||||||
|
}
|
||||||
|
Pop-Location
|
||||||
|
|
||||||
|
Write-Host "开始构建 $AppName 多平台二进制文件..."
|
||||||
|
|
||||||
if (Test-Path $OutputDir) {
|
if (Test-Path $OutputDir) {
|
||||||
Remove-Item -Recurse -Force $OutputDir
|
Remove-Item -Recurse -Force $OutputDir
|
||||||
@@ -23,12 +38,12 @@ foreach ($Platform in $Platforms) {
|
|||||||
$Arch = $parts[1]
|
$Arch = $parts[1]
|
||||||
|
|
||||||
$OutputName = "$AppName-$OS-$Arch"
|
$OutputName = "$AppName-$OS-$Arch"
|
||||||
$BinaryName = $OutputName
|
$BinaryName = $AppName
|
||||||
if ($OS -eq "windows") {
|
if ($OS -eq "windows") {
|
||||||
$BinaryName = "$OutputName.exe"
|
$BinaryName = "$AppName.exe"
|
||||||
}
|
}
|
||||||
|
|
||||||
Write-Host "正在编译 $OS/$Arch..."
|
Write-Host "正在编译 $OS/$Arch..."
|
||||||
|
|
||||||
$PackageDir = Join-Path $OutputDir $OutputName
|
$PackageDir = Join-Path $OutputDir $OutputName
|
||||||
if (-not (Test-Path $PackageDir)) {
|
if (-not (Test-Path $PackageDir)) {
|
||||||
@@ -38,24 +53,24 @@ foreach ($Platform in $Platforms) {
|
|||||||
$env:GOOS = $OS
|
$env:GOOS = $OS
|
||||||
$env:GOARCH = $Arch
|
$env:GOARCH = $Arch
|
||||||
$env:CGO_ENABLED = "0"
|
$env:CGO_ENABLED = "0"
|
||||||
go build -o (Join-Path $PackageDir $BinaryName) main.go
|
go build -ldflags="-s -w" -o (Join-Path $PackageDir $BinaryName) main.go
|
||||||
|
|
||||||
if ($LASTEXITCODE -eq 0) {
|
if ($LASTEXITCODE -eq 0) {
|
||||||
Write-Host " $OS/$Arch 编译成功"
|
Write-Host " $OS/$Arch 编译成功"
|
||||||
|
|
||||||
Copy-Item -Recurse "web" $PackageDir\
|
Copy-Item -Recurse "web" $PackageDir\
|
||||||
Copy-Item "config.example.yaml" $PackageDir\
|
Copy-Item "config.example.yaml" $PackageDir\
|
||||||
Copy-Item "README.md" $PackageDir\
|
Copy-Item "README.md" $PackageDir\
|
||||||
|
|
||||||
$CurrentDir = Get-Location
|
$CurrentDir = Get-Location
|
||||||
Set-Location $OutputDir
|
Set-Location $PackageDir
|
||||||
tar -czf "${OutputName}.tar.gz" $OutputName
|
tar -czf "../${OutputName}.tar.gz" .
|
||||||
Remove-Item -Recurse -Force $OutputName
|
|
||||||
Set-Location $CurrentDir
|
Set-Location $CurrentDir
|
||||||
|
Remove-Item -Recurse -Force $PackageDir
|
||||||
|
|
||||||
Write-Host " $OS/$Arch 打包完成: ${OutputName}.tar.gz"
|
Write-Host " $OS/$Arch 打包完成: ${OutputName}.tar.gz"
|
||||||
} else {
|
} else {
|
||||||
Write-Host " $OS/$Arch 编译失败"
|
Write-Host " $OS/$Arch 编译失败"
|
||||||
if (Test-Path $PackageDir) {
|
if (Test-Path $PackageDir) {
|
||||||
Remove-Item -Recurse -Force $PackageDir
|
Remove-Item -Recurse -Force $PackageDir
|
||||||
}
|
}
|
||||||
@@ -63,5 +78,5 @@ foreach ($Platform in $Platforms) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Write-Host "----------------------------------------"
|
Write-Host "----------------------------------------"
|
||||||
Write-Host "多平台打包完成!输出目录: $OutputDir"
|
Write-Host "多平台打包完成!输出目录: $OutputDir"
|
||||||
Get-ChildItem -Recurse $OutputDir
|
Get-ChildItem -Recurse $OutputDir
|
||||||
|
|||||||
@@ -20,6 +20,12 @@ PLATFORMS=(
|
|||||||
# 需要包含的额外文件/目录
|
# 需要包含的额外文件/目录
|
||||||
EXTRA_FILES=("web" "config.example.yaml" "README.md")
|
EXTRA_FILES=("web" "config.example.yaml" "README.md")
|
||||||
|
|
||||||
|
echo "开始构建前端..."
|
||||||
|
cd webapp
|
||||||
|
npm install
|
||||||
|
npm run build
|
||||||
|
cd ..
|
||||||
|
|
||||||
echo "开始构建 $APP_NAME 多平台二进制文件..."
|
echo "开始构建 $APP_NAME 多平台二进制文件..."
|
||||||
|
|
||||||
# 清理 output 目录
|
# 清理 output 目录
|
||||||
@@ -39,9 +45,9 @@ for PLATFORM in "${PLATFORMS[@]}"; do
|
|||||||
# 设置输出名称
|
# 设置输出名称
|
||||||
OUTPUT_NAME="${APP_NAME}-${OS}-${ARCH}"
|
OUTPUT_NAME="${APP_NAME}-${OS}-${ARCH}"
|
||||||
if [ "$OS" = "windows" ]; then
|
if [ "$OS" = "windows" ]; then
|
||||||
BINARY_NAME="${OUTPUT_NAME}.exe"
|
BINARY_NAME="${APP_NAME}.exe"
|
||||||
else
|
else
|
||||||
BINARY_NAME="${OUTPUT_NAME}"
|
BINARY_NAME="${APP_NAME}"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "正在编译 ${OS}/${ARCH}..."
|
echo "正在编译 ${OS}/${ARCH}..."
|
||||||
@@ -51,7 +57,8 @@ for PLATFORM in "${PLATFORMS[@]}"; do
|
|||||||
mkdir -p "$PACKAGE_DIR"
|
mkdir -p "$PACKAGE_DIR"
|
||||||
|
|
||||||
# 现在已移除 CGO 依赖,使用 CGO_ENABLED=0 以支持轻松的跨平台编译
|
# 现在已移除 CGO 依赖,使用 CGO_ENABLED=0 以支持轻松的跨平台编译
|
||||||
GOOS=$OS GOARCH=$ARCH CGO_ENABLED=0 go build -o "${PACKAGE_DIR}/${BINARY_NAME}" main.go
|
# 增加 -ldflags="-s -w" 以减少二进制体积
|
||||||
|
GOOS=$OS GOARCH=$ARCH CGO_ENABLED=0 go build -ldflags="-s -w" -o "${PACKAGE_DIR}/${BINARY_NAME}" main.go
|
||||||
|
|
||||||
if [ $? -eq 0 ]; then
|
if [ $? -eq 0 ]; then
|
||||||
echo " ${OS}/${ARCH} 编译成功"
|
echo " ${OS}/${ARCH} 编译成功"
|
||||||
@@ -64,7 +71,7 @@ for PLATFORM in "${PLATFORMS[@]}"; do
|
|||||||
done
|
done
|
||||||
|
|
||||||
# 压缩为 tar.gz
|
# 压缩为 tar.gz
|
||||||
tar -czf "${OUTPUT_DIR}/${OUTPUT_NAME}.tar.gz" -C "${OUTPUT_DIR}" "${OUTPUT_NAME}"
|
tar -czf "${OUTPUT_DIR}/${OUTPUT_NAME}.tar.gz" -C "${PACKAGE_DIR}" .
|
||||||
|
|
||||||
# 删除临时打包目录
|
# 删除临时打包目录
|
||||||
rm -rf "$PACKAGE_DIR"
|
rm -rf "$PACKAGE_DIR"
|
||||||
|
|||||||
101
scripts/deploy.sh
Normal file
101
scripts/deploy.sh
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# 定时任务示例 (每30分钟执行一次):
|
||||||
|
# */30 * * * * /path/to/project/scripts/deploy.sh >> /path/to/project/scripts/deploy_cron.log 2>&1
|
||||||
|
|
||||||
|
# 项目根目录
|
||||||
|
# 假设脚本位于 scripts 目录下
|
||||||
|
PROJECT_DIR=$(cd $(dirname $0)/.. && pwd)
|
||||||
|
cd $PROJECT_DIR
|
||||||
|
|
||||||
|
# 日志文件
|
||||||
|
LOG_FILE="$PROJECT_DIR/scripts/deploy.log"
|
||||||
|
|
||||||
|
log() {
|
||||||
|
echo "$(date '+%Y-%m-%d %H:%M:%S') - $1" | tee -a "$LOG_FILE"
|
||||||
|
}
|
||||||
|
|
||||||
|
# 确保在项目根目录
|
||||||
|
if [ ! -f "docker-compose.yaml" ]; then
|
||||||
|
log "错误: 未能在 $PROJECT_DIR 找到 docker-compose.yml,请确保脚本位置正确。"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
log "开始检查更新..."
|
||||||
|
|
||||||
|
# 获取远程代码
|
||||||
|
git fetch origin
|
||||||
|
|
||||||
|
# 获取当前分支名
|
||||||
|
BRANCH=$(git rev-parse --abbrev-ref HEAD)
|
||||||
|
|
||||||
|
# 检查本地分支是否有上游分支
|
||||||
|
UPSTREAM=$(git rev-parse --abbrev-ref @{u} 2>/dev/null)
|
||||||
|
if [ -z "$UPSTREAM" ]; then
|
||||||
|
log "错误: 当前分支 $BRANCH 没有设置上游分支,无法自动对比更新。"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 检查本地是否落后于远程
|
||||||
|
LOCAL=$(git rev-parse HEAD)
|
||||||
|
REMOTE=$(git rev-parse "$UPSTREAM")
|
||||||
|
|
||||||
|
if [ "$LOCAL" = "$REMOTE" ]; then
|
||||||
|
log "代码已是最新 ($LOCAL),无需更新。"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
log "检测到远程变更 ($LOCAL -> $REMOTE),准备开始升级..."
|
||||||
|
|
||||||
|
# 检查是否有本地修改
|
||||||
|
HAS_CHANGES=$(git status --porcelain)
|
||||||
|
|
||||||
|
if [ -n "$HAS_CHANGES" ]; then
|
||||||
|
log "检测到本地修改,正在暂存以保留个性化配置..."
|
||||||
|
git stash
|
||||||
|
STASHED=true
|
||||||
|
else
|
||||||
|
STASHED=false
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 拉取新代码
|
||||||
|
log "正在拉取远程代码 ($BRANCH)..."
|
||||||
|
if git pull origin "$BRANCH"; then
|
||||||
|
log "代码拉取成功。"
|
||||||
|
else
|
||||||
|
log "错误: 代码拉取失败。"
|
||||||
|
if [ "$STASHED" = true ]; then
|
||||||
|
git stash pop
|
||||||
|
fi
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 恢复本地修改
|
||||||
|
if [ "$STASHED" = true ]; then
|
||||||
|
log "正在恢复本地修改..."
|
||||||
|
if git stash pop; then
|
||||||
|
log "本地修改已成功恢复。"
|
||||||
|
else
|
||||||
|
log "警告: 恢复本地修改时发生冲突,请手动检查 docker-compose.yml 等文件。"
|
||||||
|
# 即使冲突也尝试继续,或者你可以选择在此退出
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 确定 docker-compose 命令
|
||||||
|
DOCKER_COMPOSE_BIN="docker-compose"
|
||||||
|
if ! command -v $DOCKER_COMPOSE_BIN &> /dev/null; then
|
||||||
|
DOCKER_COMPOSE_BIN="docker compose"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 执行 docker-compose 部署
|
||||||
|
log "正在执行 $DOCKER_COMPOSE_BIN 部署..."
|
||||||
|
if $DOCKER_COMPOSE_BIN up -d --build; then
|
||||||
|
log "服务升级成功!"
|
||||||
|
# 清理无用镜像(可选)
|
||||||
|
docker image prune -f
|
||||||
|
else
|
||||||
|
log "错误: $DOCKER_COMPOSE_BIN 部署失败。"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
log "部署任务完成。"
|
||||||
53
scripts/tag.bat
Normal file
53
scripts/tag.bat
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
@echo off
|
||||||
|
setlocal enabledelayedexpansion
|
||||||
|
|
||||||
|
:: 切换到项目根目录
|
||||||
|
cd /d %~dp0..
|
||||||
|
|
||||||
|
:: 获取版本号
|
||||||
|
set TAG_NAME=%1
|
||||||
|
|
||||||
|
if "%TAG_NAME%"=="" (
|
||||||
|
echo Usage: .\scripts\tag.bat ^<version^>
|
||||||
|
exit /b 1
|
||||||
|
)
|
||||||
|
|
||||||
|
:: 确保在 master 分支
|
||||||
|
for /f "tokens=*" %%i in ('git rev-parse --abbrev-ref HEAD') do set CURRENT_BRANCH=%%i
|
||||||
|
if not "%CURRENT_BRANCH%"=="master" (
|
||||||
|
echo Error: Must be on master branch to tag. Current branch: %CURRENT_BRANCH%
|
||||||
|
exit /b 1
|
||||||
|
)
|
||||||
|
|
||||||
|
:: 检查是否有未提交的代码
|
||||||
|
set CHANGES=
|
||||||
|
for /f "tokens=*" %%i in ('git status --porcelain') do set CHANGES=%%i
|
||||||
|
if not "%CHANGES%"=="" (
|
||||||
|
echo Error: You have uncommitted changes. Please commit or stash them first.
|
||||||
|
exit /b 1
|
||||||
|
)
|
||||||
|
|
||||||
|
:: 拉取最新代码
|
||||||
|
echo Updating master branch...
|
||||||
|
git pull origin master
|
||||||
|
if %errorlevel% neq 0 exit /b %errorlevel%
|
||||||
|
|
||||||
|
:: 检查本地和远端是否一致
|
||||||
|
for /f "tokens=*" %%i in ('git rev-parse @') do set LOCAL=%%i
|
||||||
|
for /f "tokens=*" %%i in ('git rev-parse @{u}') do set REMOTE=%%i
|
||||||
|
|
||||||
|
if not "%LOCAL%"=="%REMOTE%" (
|
||||||
|
echo Error: Local branch is not in sync with remote. Please push your changes first.
|
||||||
|
exit /b 1
|
||||||
|
)
|
||||||
|
|
||||||
|
:: 创建并推送 tag
|
||||||
|
echo Creating tag %TAG_NAME%...
|
||||||
|
git tag -f "%TAG_NAME%"
|
||||||
|
if %errorlevel% neq 0 exit /b %errorlevel%
|
||||||
|
|
||||||
|
echo Pushing tag %TAG_NAME% to remote...
|
||||||
|
git push origin "%TAG_NAME%" -f
|
||||||
|
if %errorlevel% neq 0 exit /b %errorlevel%
|
||||||
|
|
||||||
|
echo Done! GitHub Action should be triggered shortly.
|
||||||
50
scripts/tag.ps1
Normal file
50
scripts/tag.ps1
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
# 切换到项目根目录
|
||||||
|
$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Definition
|
||||||
|
if ($ScriptDir) { Set-Location (Join-Path $ScriptDir "..") }
|
||||||
|
|
||||||
|
# 获取版本号
|
||||||
|
$TagName = $args[0]
|
||||||
|
|
||||||
|
if (-not $TagName) {
|
||||||
|
Write-Host "Usage: .\scripts\tag.ps1 <version>"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# 确保在 master 分支
|
||||||
|
$CurrentBranch = git rev-parse --abbrev-ref HEAD
|
||||||
|
if ($CurrentBranch -ne "master") {
|
||||||
|
Write-Host "Error: Must be on master branch to tag. Current branch: $CurrentBranch" -ForegroundColor Red
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# 检查是否有未提交的代码
|
||||||
|
$Changes = git status --porcelain
|
||||||
|
if ($Changes) {
|
||||||
|
Write-Host "Error: You have uncommitted changes. Please commit or stash them first." -ForegroundColor Red
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# 拉取最新代码
|
||||||
|
Write-Host "Updating master branch..."
|
||||||
|
git pull origin master
|
||||||
|
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
|
||||||
|
|
||||||
|
# 检查本地和远端是否一致
|
||||||
|
$Local = git rev-parse "@"
|
||||||
|
$Remote = git rev-parse "@{u}"
|
||||||
|
|
||||||
|
if ($Local -ne $Remote) {
|
||||||
|
Write-Host "Error: Local branch is not in sync with remote. Please push your changes first." -ForegroundColor Red
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# 创建并推送 tag
|
||||||
|
Write-Host "Creating tag $TagName..."
|
||||||
|
git tag -f "$TagName"
|
||||||
|
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
|
||||||
|
|
||||||
|
Write-Host "Pushing tag $TagName to remote..."
|
||||||
|
git push origin "$TagName" -f
|
||||||
|
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
|
||||||
|
|
||||||
|
Write-Host "Done! GitHub Action should be triggered shortly."
|
||||||
404
web/index.html
404
web/index.html
@@ -1,404 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="zh-CN">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>BingPaper - 必应每日一图</title>
|
|
||||||
<style>
|
|
||||||
:root {
|
|
||||||
--primary-color: #007bff;
|
|
||||||
--danger-color: #dc3545;
|
|
||||||
--bg-color: #f8f9fa;
|
|
||||||
--card-bg: #ffffff;
|
|
||||||
--text-color: #333;
|
|
||||||
}
|
|
||||||
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; margin: 0; background: var(--bg-color); color: var(--text-color); line-height: 1.6; }
|
|
||||||
nav { background: #333; color: white; padding: 1rem 2rem; display: flex; justify-content: space-between; align-items: center; position: sticky; top: 0; z-index: 1000; }
|
|
||||||
nav a { color: white; text-decoration: none; margin-left: 1.5rem; font-weight: 500; }
|
|
||||||
nav .logo { font-size: 1.5rem; font-weight: bold; margin-left: 0; }
|
|
||||||
|
|
||||||
.container { max-width: 1200px; margin: 2rem auto; padding: 0 1rem; }
|
|
||||||
.hidden { display: none !important; }
|
|
||||||
|
|
||||||
/* Gallery Styles */
|
|
||||||
.gallery { display: grid; grid-template-columns: repeat(auto-fill, minmax(350px, 1fr)); gap: 20px; }
|
|
||||||
.gallery-item { position: relative; border-radius: 12px; overflow: hidden; box-shadow: 0 4px 15px rgba(0,0,0,0.1); background: #eee; aspect-ratio: 16 / 9; transition: transform 0.3s ease; }
|
|
||||||
.gallery-item:hover { transform: translateY(-5px); }
|
|
||||||
.gallery-item img { width: 100%; height: 100%; object-fit: cover; display: block; }
|
|
||||||
.gallery-item .overlay {
|
|
||||||
position: absolute; bottom: 0; left: 0; right: 0;
|
|
||||||
background: linear-gradient(transparent, rgba(0,0,0,0.8));
|
|
||||||
color: white; padding: 20px 15px 10px;
|
|
||||||
opacity: 0; transition: opacity 0.3s ease;
|
|
||||||
}
|
|
||||||
.gallery-item:hover .overlay { opacity: 1; }
|
|
||||||
.gallery-item .title { font-weight: bold; font-size: 1.1rem; margin-bottom: 5px; }
|
|
||||||
.gallery-item .copyright { font-size: 0.85rem; opacity: 0.9; }
|
|
||||||
.gallery-item .date { position: absolute; top: 10px; left: 10px; background: rgba(0,0,0,0.5); padding: 2px 8px; border-radius: 4px; font-size: 0.8rem; }
|
|
||||||
|
|
||||||
/* Info Section */
|
|
||||||
.info-card { background: var(--card-bg); padding: 2rem; border-radius: 12px; margin-bottom: 2rem; box-shadow: 0 2px 10px rgba(0,0,0,0.05); }
|
|
||||||
.info-card ul { list-style: none; padding: 0; }
|
|
||||||
.info-card li { margin-bottom: 12px; display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
|
|
||||||
.preview-link {
|
|
||||||
text-decoration: none;
|
|
||||||
color: var(--primary-color);
|
|
||||||
font-size: 0.85rem;
|
|
||||||
padding: 2px 8px;
|
|
||||||
border: 1px solid var(--primary-color);
|
|
||||||
border-radius: 4px;
|
|
||||||
transition: all 0.2s;
|
|
||||||
}
|
|
||||||
.preview-link:hover {
|
|
||||||
background: var(--primary-color);
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
code { background: #f1f1f1; padding: 2px 5px; border-radius: 4px; color: #e83e8c; }
|
|
||||||
pre { background: #f1f1f1; padding: 1rem; border-radius: 8px; overflow-x: auto; }
|
|
||||||
|
|
||||||
/* Admin/Login Form Styles */
|
|
||||||
.form-card { max-width: 400px; margin: 5rem auto; background: white; padding: 2.5rem; border-radius: 12px; box-shadow: 0 10px 25px rgba(0,0,0,0.1); }
|
|
||||||
.form-card h2 { margin-top: 0; text-align: center; }
|
|
||||||
input, select { width: 100%; padding: 12px; margin: 10px 0 20px; border: 1px solid #ddd; border-radius: 6px; box-sizing: border-box; }
|
|
||||||
button { width: 100%; padding: 12px; border: none; border-radius: 6px; background: var(--primary-color); color: white; font-size: 1rem; cursor: pointer; transition: background 0.2s; }
|
|
||||||
button:hover { background: #0069d9; }
|
|
||||||
button.danger { background: var(--danger-color); }
|
|
||||||
button.danger:hover { background: #c82333; }
|
|
||||||
button.secondary { background: #6c757d; margin-top: 10px; }
|
|
||||||
|
|
||||||
/* Admin Panel Styles */
|
|
||||||
.admin-grid { display: grid; grid-template-columns: 1fr; gap: 20px; }
|
|
||||||
@media (min-width: 768px) { .admin-grid { grid-template-columns: 2fr 1fr; } }
|
|
||||||
.admin-card { background: white; padding: 1.5rem; border-radius: 12px; box-shadow: 0 2px 10px rgba(0,0,0,0.05); margin-bottom: 20px; }
|
|
||||||
table { width: 100%; border-collapse: collapse; margin-top: 1rem; }
|
|
||||||
th, td { text-align: left; padding: 12px; border-bottom: 1px solid #eee; }
|
|
||||||
.status-badge { padding: 2px 8px; border-radius: 12px; font-size: 0.8rem; }
|
|
||||||
.status-enabled { background: #d4edda; color: #155724; }
|
|
||||||
.status-disabled { background: #f8d7da; color: #721c24; }
|
|
||||||
|
|
||||||
.loading { text-align: center; padding: 3rem; font-size: 1.2rem; color: #666; }
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
|
|
||||||
<nav>
|
|
||||||
<a href="/" class="logo">BingPaper</a>
|
|
||||||
<div>
|
|
||||||
<a href="/">首页</a>
|
|
||||||
<a href="/admin">管理</a>
|
|
||||||
<span id="nav-logout-container" class="hidden">
|
|
||||||
<a href="#" onclick="logout()">退出</a>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
<div id="content-home" class="container hidden">
|
|
||||||
<div class="info-card">
|
|
||||||
<h2>使用说明</h2>
|
|
||||||
<p>这是一个自动抓取必应每日一图并提供多分辨率管理的工具。您可以直接通过以下接口获取图片:</p>
|
|
||||||
<ul>
|
|
||||||
<li><span>今日图片:</span><code>/api/v1/image/today</code> <a href="/api/v1/image/today" target="_blank" class="preview-link">立即预览</a></li>
|
|
||||||
<li><span>今日元数据:</span><code>/api/v1/image/today/meta</code> <a href="/api/v1/image/today/meta" target="_blank" class="preview-link">立即预览</a></li>
|
|
||||||
<li><span>随机图片:</span><code>/api/v1/image/random</code> <a href="/api/v1/image/random" target="_blank" class="preview-link">立即预览</a></li>
|
|
||||||
<li><span>指定日期:</span><code>/api/v1/image/date/2026-01-26</code> <a href="/api/v1/image/date/2026-01-26" target="_blank" class="preview-link">立即预览</a></li>
|
|
||||||
</ul>
|
|
||||||
<p>参数支持:<code>variant=UHD|1920x1080|1366x768</code>,<code>format=jpg</code>。</p>
|
|
||||||
</div>
|
|
||||||
<div id="gallery" class="gallery">
|
|
||||||
<div class="loading">正在加载图片...</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="content-login" class="container hidden">
|
|
||||||
<div class="form-card">
|
|
||||||
<h2>管理员登录</h2>
|
|
||||||
<input type="password" id="login-password" placeholder="请输入管理员密码" onkeypress="if(event.keyCode==13) login()">
|
|
||||||
<button onclick="login()">登录</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="content-admin" class="container hidden">
|
|
||||||
<div class="admin-grid">
|
|
||||||
<div class="left-col">
|
|
||||||
<div class="admin-card">
|
|
||||||
<h3>Token 管理</h3>
|
|
||||||
<table>
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>名称</th>
|
|
||||||
<th>过期时间</th>
|
|
||||||
<th>状态</th>
|
|
||||||
<th>操作</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody id="token-list"></tbody>
|
|
||||||
</table>
|
|
||||||
<h4 style="margin-top: 2rem;">创建新 Token</h4>
|
|
||||||
<div style="display: flex; gap: 10px;">
|
|
||||||
<input type="text" id="token-name" placeholder="Token 名称" style="margin: 0;">
|
|
||||||
<button onclick="createToken()" style="width: auto; padding: 0 20px;">创建</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="admin-card">
|
|
||||||
<h3>配置文件</h3>
|
|
||||||
<pre id="config-view"></pre>
|
|
||||||
<button class="secondary" onclick="loadConfig()">刷新配置</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="right-col">
|
|
||||||
<div class="admin-card">
|
|
||||||
<h3>任务控制</h3>
|
|
||||||
<button onclick="triggerFetch()" style="margin-bottom: 10px;">手动抓取 (最近8天)</button>
|
|
||||||
<button class="secondary" onclick="triggerCleanup()" style="margin-bottom: 10px;">手动清理旧图</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="admin-card">
|
|
||||||
<h3>修改密码</h3>
|
|
||||||
<input type="password" id="old-password" placeholder="旧密码" style="margin-bottom: 10px;">
|
|
||||||
<input type="password" id="new-password" placeholder="新密码" style="margin-bottom: 10px;">
|
|
||||||
<button onclick="changePassword()">提交修改</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="admin-card">
|
|
||||||
<h3>今日图预览</h3>
|
|
||||||
<div id="today-preview" style="text-align: center;">
|
|
||||||
<div class="loading" style="padding: 1rem;">加载中...</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
let token = localStorage.getItem('bing_token');
|
|
||||||
|
|
||||||
// 简易路由实现
|
|
||||||
function router() {
|
|
||||||
const path = window.location.pathname;
|
|
||||||
const homeSection = document.getElementById('content-home');
|
|
||||||
const loginSection = document.getElementById('content-login');
|
|
||||||
const adminSection = document.getElementById('content-admin');
|
|
||||||
const logoutNav = document.getElementById('nav-logout-container');
|
|
||||||
|
|
||||||
// 隐藏所有
|
|
||||||
[homeSection, loginSection, adminSection].forEach(s => s.classList.add('hidden'));
|
|
||||||
logoutNav.classList.add('hidden');
|
|
||||||
|
|
||||||
if (path === '/' || path === '') {
|
|
||||||
homeSection.classList.remove('hidden');
|
|
||||||
loadGallery();
|
|
||||||
} else if (path === '/login') {
|
|
||||||
if (token) {
|
|
||||||
window.history.pushState({}, '', '/admin');
|
|
||||||
router();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
loginSection.classList.remove('hidden');
|
|
||||||
} else if (path === '/admin') {
|
|
||||||
if (!token) {
|
|
||||||
window.history.pushState({}, '', '/login');
|
|
||||||
router();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
adminSection.classList.remove('hidden');
|
|
||||||
logoutNav.classList.remove('hidden');
|
|
||||||
loadAdminData();
|
|
||||||
} else {
|
|
||||||
// 404 默认首页
|
|
||||||
window.history.pushState({}, '', '/');
|
|
||||||
router();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 处理导航点击
|
|
||||||
document.querySelectorAll('nav a').forEach(a => {
|
|
||||||
if (a.onclick) return;
|
|
||||||
a.addEventListener('click', e => {
|
|
||||||
if (a.getAttribute('href').startsWith('http')) return;
|
|
||||||
e.preventDefault();
|
|
||||||
const href = a.getAttribute('href');
|
|
||||||
window.history.pushState({}, '', href);
|
|
||||||
router();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// 监听后退/前进
|
|
||||||
window.addEventListener('popstate', router);
|
|
||||||
|
|
||||||
async function login() {
|
|
||||||
const password = document.getElementById('login-password').value;
|
|
||||||
try {
|
|
||||||
const resp = await fetch('/api/v1/admin/login', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ password })
|
|
||||||
});
|
|
||||||
const data = await resp.json();
|
|
||||||
if (data.token) {
|
|
||||||
token = data.token;
|
|
||||||
localStorage.setItem('bing_token', token);
|
|
||||||
window.history.pushState({}, '', '/admin');
|
|
||||||
router();
|
|
||||||
} else {
|
|
||||||
alert('登录失败: ' + (data.error || '未知错误'));
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
alert('网络请求失败');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function logout() {
|
|
||||||
localStorage.removeItem('bing_token');
|
|
||||||
token = null;
|
|
||||||
window.history.pushState({}, '', '/');
|
|
||||||
router();
|
|
||||||
}
|
|
||||||
|
|
||||||
// 画廊功能
|
|
||||||
async function loadGallery() {
|
|
||||||
const gallery = document.getElementById('gallery');
|
|
||||||
try {
|
|
||||||
const resp = await fetch('/api/v1/images?limit=30');
|
|
||||||
const data = await resp.json();
|
|
||||||
|
|
||||||
if (data.length === 0) {
|
|
||||||
gallery.innerHTML = '<div class="loading">暂无图片,请稍后再试。</div>';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
gallery.innerHTML = data.map(img => `
|
|
||||||
<div class="gallery-item">
|
|
||||||
<div class="date">${img.date}</div>
|
|
||||||
<img src="${img.variants.find(v => v.variant === '1920x1080')?.url || img.variants[0].url}" alt="${img.title}" loading="lazy">
|
|
||||||
<div class="overlay">
|
|
||||||
<div class="title">${img.title}</div>
|
|
||||||
<div class="copyright">${img.copyright}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`).join('');
|
|
||||||
} catch (e) {
|
|
||||||
gallery.innerHTML = '<div class="loading">加载失败,请刷新重试。</div>';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 管理员数据
|
|
||||||
function loadAdminData() {
|
|
||||||
loadTokens();
|
|
||||||
loadConfig();
|
|
||||||
loadTodayPreview();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadTokens() {
|
|
||||||
const resp = await fetch('/api/v1/admin/tokens', {
|
|
||||||
headers: { 'Authorization': 'Bearer ' + token }
|
|
||||||
});
|
|
||||||
if (resp.status === 401) return logout();
|
|
||||||
const data = await resp.json();
|
|
||||||
const tbody = document.getElementById('token-list');
|
|
||||||
tbody.innerHTML = data.map(t => `
|
|
||||||
<tr>
|
|
||||||
<td>${t.name}</td>
|
|
||||||
<td>${new Date(t.expires_at).toLocaleString()}</td>
|
|
||||||
<td><span class="status-badge ${t.disabled ? 'status-disabled' : 'status-enabled'}">${t.disabled ? '禁用' : '启用'}</span></td>
|
|
||||||
<td>
|
|
||||||
<button style="width:auto; padding:5px 10px; margin-right:5px;" onclick="toggleToken(${t.id}, ${!t.disabled})">${t.disabled ? '启用' : '禁用'}</button>
|
|
||||||
<button class="danger" style="width:auto; padding:5px 10px;" onclick="deleteToken(${t.id})">删除</button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
`).join('');
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadConfig() {
|
|
||||||
const resp = await fetch('/api/v1/admin/config', {
|
|
||||||
headers: { 'Authorization': 'Bearer ' + token }
|
|
||||||
});
|
|
||||||
const data = await resp.json();
|
|
||||||
document.getElementById('config-view').innerText = JSON.stringify(data, null, 2);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadTodayPreview() {
|
|
||||||
const resp = await fetch('/api/v1/image/today/meta');
|
|
||||||
const data = await resp.json();
|
|
||||||
const container = document.getElementById('today-preview');
|
|
||||||
container.innerHTML = `
|
|
||||||
<p><strong>${data.date}</strong></p>
|
|
||||||
<p>${data.title}</p>
|
|
||||||
<img src="${data.variants[0].url}" style="max-width:100%; border-radius:8px;">
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function createToken() {
|
|
||||||
const name = document.getElementById('token-name').value;
|
|
||||||
if (!name) return alert('请输入名称');
|
|
||||||
await fetch('/api/v1/admin/tokens', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ name })
|
|
||||||
});
|
|
||||||
document.getElementById('token-name').value = '';
|
|
||||||
loadTokens();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function toggleToken(id, disabled) {
|
|
||||||
await fetch('/api/v1/admin/tokens/' + id, {
|
|
||||||
method: 'PATCH',
|
|
||||||
headers: { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ disabled })
|
|
||||||
});
|
|
||||||
loadTokens();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function deleteToken(id) {
|
|
||||||
if (!confirm('确定删除?')) return;
|
|
||||||
await fetch('/api/v1/admin/tokens/' + id, {
|
|
||||||
method: 'DELETE',
|
|
||||||
headers: { 'Authorization': 'Bearer ' + token }
|
|
||||||
});
|
|
||||||
loadTokens();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function triggerFetch() {
|
|
||||||
await fetch('/api/v1/admin/fetch', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ n: 8 })
|
|
||||||
});
|
|
||||||
alert('抓取任务已启动');
|
|
||||||
}
|
|
||||||
|
|
||||||
async function triggerCleanup() {
|
|
||||||
await fetch('/api/v1/admin/cleanup', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Authorization': 'Bearer ' + token }
|
|
||||||
});
|
|
||||||
alert('清理任务已启动');
|
|
||||||
}
|
|
||||||
|
|
||||||
async function changePassword() {
|
|
||||||
const oldPassword = document.getElementById('old-password').value;
|
|
||||||
const newPassword = document.getElementById('new-password').value;
|
|
||||||
if (!oldPassword || !newPassword) return alert('请输入完整信息');
|
|
||||||
|
|
||||||
const resp = await fetch('/api/v1/admin/password', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ old_password: oldPassword, new_password: newPassword })
|
|
||||||
});
|
|
||||||
|
|
||||||
const data = await resp.json();
|
|
||||||
if (resp.ok) {
|
|
||||||
alert('密码修改成功,请重新登录');
|
|
||||||
logout();
|
|
||||||
} else {
|
|
||||||
alert('修改失败: ' + (data.error || '未知错误'));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 初始化
|
|
||||||
router();
|
|
||||||
</script>
|
|
||||||
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
6
webapp/.env
Normal file
6
webapp/.env
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
# 默认环境配置
|
||||||
|
# API 基础 URL - 默认使用相对路径访问
|
||||||
|
VITE_API_BASE_URL=/api/v1
|
||||||
|
|
||||||
|
# API 模式
|
||||||
|
VITE_API_MODE=relative
|
||||||
6
webapp/.env.development
Normal file
6
webapp/.env.development
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
# 开发环境配置
|
||||||
|
# API 基础 URL - 开发环境直接使用完整的后端服务地址(不使用代理)
|
||||||
|
VITE_API_BASE_URL=http://localhost:8080/api/v1
|
||||||
|
|
||||||
|
# 开发环境使用完整URL,不需要代理
|
||||||
|
VITE_API_MODE=direct
|
||||||
6
webapp/.env.production
Normal file
6
webapp/.env.production
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
# 生产环境配置
|
||||||
|
# API 基础 URL - 生产环境使用相对路径
|
||||||
|
VITE_API_BASE_URL=/api/v1
|
||||||
|
|
||||||
|
# API 模式:生产环境使用相对路径
|
||||||
|
VITE_API_MODE=relative
|
||||||
35
webapp/.gitignore
vendored
Normal file
35
webapp/.gitignore
vendored
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
*.local
|
||||||
|
|
||||||
|
# Build output (to parent directory)
|
||||||
|
../web
|
||||||
|
|
||||||
|
# TypeScript compiled files (should not be generated in Vite projects)
|
||||||
|
src/**/*.js
|
||||||
|
src/**/*.js.map
|
||||||
|
|
||||||
|
# Environment variables (keep .env for defaults, ignore local overrides)
|
||||||
|
.env.local
|
||||||
|
.env.*.local
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
202
webapp/README.md
Normal file
202
webapp/README.md
Normal file
@@ -0,0 +1,202 @@
|
|||||||
|
# BingPaper WebApp
|
||||||
|
|
||||||
|
BingPaper 的前端 Web 应用,使用 Vue 3 + TypeScript + Vite 构建。
|
||||||
|
|
||||||
|
> 💡 **性能优化提示**:已配置浏览器缓存优化,可减少 60-80% 带宽!
|
||||||
|
> 👉 后端配置:查看 [缓存配置快速参考](./CACHE_QUICK_REF.md)
|
||||||
|
|
||||||
|
## 特性
|
||||||
|
|
||||||
|
- ✨ Vue 3 组合式 API
|
||||||
|
- 🎨 Tailwind CSS + shadcn-vue 组件库
|
||||||
|
- 📦 TypeScript 类型支持
|
||||||
|
- 🔧 完整的 API 客户端封装
|
||||||
|
- 🚀 优化的构建配置
|
||||||
|
- ⚡ 浏览器缓存优化(内容哈希 + 代码分割)
|
||||||
|
- 🌐 支持自定义后端路径
|
||||||
|
- 📁 自动输出到上级目录的 web 文件夹
|
||||||
|
- 🔐 完整的管理后台系统(Token 管理、定时任务、系统配置)
|
||||||
|
|
||||||
|
## 快速开始
|
||||||
|
|
||||||
|
### 安装依赖
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
### 开发环境
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
开发服务器会在 `http://localhost:5173` 启动,并自动代理 `/api` 请求到后端服务器。
|
||||||
|
|
||||||
|
### 构建生产版本
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
构建产物会自动输出到项目上级目录的 `web` 文件夹,供 Go 服务器使用。
|
||||||
|
|
||||||
|
### 预览构建结果
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run preview
|
||||||
|
```
|
||||||
|
|
||||||
|
## 构建配置
|
||||||
|
|
||||||
|
### 环境变量
|
||||||
|
|
||||||
|
项目支持通过环境变量配置后端 API 路径:
|
||||||
|
|
||||||
|
- `.env` - 默认配置
|
||||||
|
- `.env.development` - 开发环境配置
|
||||||
|
- `.env.production` - 生产环境配置
|
||||||
|
|
||||||
|
修改 `VITE_API_BASE_URL` 来自定义后端 API 路径。
|
||||||
|
|
||||||
|
### 输出目录
|
||||||
|
|
||||||
|
构建产物输出到 `../web/`,目录结构:
|
||||||
|
|
||||||
|
```
|
||||||
|
web/
|
||||||
|
├── index.html
|
||||||
|
├── assets/
|
||||||
|
│ ├── index-[hash].js
|
||||||
|
│ └── index-[hash].css
|
||||||
|
└── vite.svg
|
||||||
|
```
|
||||||
|
|
||||||
|
## API 使用
|
||||||
|
|
||||||
|
项目提供了完整的 TypeScript API 客户端:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { bingPaperApi } from '@/lib/api-service'
|
||||||
|
|
||||||
|
// 获取今日图片
|
||||||
|
const meta = await bingPaperApi.getTodayImageMeta()
|
||||||
|
|
||||||
|
// 获取图片列表
|
||||||
|
const images = await bingPaperApi.getImages({ limit: 10 })
|
||||||
|
```
|
||||||
|
|
||||||
|
详细的 API 使用示例请参阅 [API_EXAMPLES.md](./API_EXAMPLES.md)
|
||||||
|
|
||||||
|
## 构建说明
|
||||||
|
|
||||||
|
详细的构建配置和部署说明请参阅 [BUILD.md](./BUILD.md)
|
||||||
|
|
||||||
|
## 项目结构
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
├── assets/ # 静态资源
|
||||||
|
├── components/ # Vue 组件
|
||||||
|
│ └── ui/ # shadcn-vue UI 组件库
|
||||||
|
├── composables/ # 可组合函数
|
||||||
|
│ └── useImages.ts # 图片管理相关逻辑
|
||||||
|
├── lib/ # 核心库
|
||||||
|
│ ├── api-config.ts # API 配置
|
||||||
|
│ ├── api-service.ts # API 服务类
|
||||||
|
│ ├── api-types.ts # TypeScript 类型定义
|
||||||
|
│ ├── http-client.ts # HTTP 客户端
|
||||||
|
│ └── utils.ts # 工具函数
|
||||||
|
├── router/ # 路由配置
|
||||||
|
│ └── index.ts
|
||||||
|
├── views/ # 页面组件
|
||||||
|
│ ├── Home.vue # 首页
|
||||||
|
│ ├── ImageView.vue # 图片详情页
|
||||||
|
│ ├── ApiDocs.vue # API 文档页
|
||||||
|
│ ├── AdminLogin.vue # 管理员登录页
|
||||||
|
│ ├── Admin.vue # 管理后台主页面
|
||||||
|
│ ├── AdminTokens.vue # Token 管理
|
||||||
|
│ ├── AdminTasks.vue # 定时任务管理
|
||||||
|
│ └── AdminConfig.vue # 系统配置管理
|
||||||
|
├── App.vue # 根组件
|
||||||
|
└── main.ts # 入口文件
|
||||||
|
```
|
||||||
|
|
||||||
|
## 管理后台
|
||||||
|
|
||||||
|
访问 `/admin/login` 进入管理后台登录页面。
|
||||||
|
|
||||||
|
### 功能模块
|
||||||
|
|
||||||
|
#### 1. Token 管理
|
||||||
|
- 查看所有 API Token
|
||||||
|
- 创建新的 Token(支持设置过期时间)
|
||||||
|
- 启用/禁用 Token
|
||||||
|
- 删除 Token
|
||||||
|
|
||||||
|
#### 2. 定时任务管理
|
||||||
|
- 手动触发图片抓取(可指定抓取天数)
|
||||||
|
- 手动触发旧图片清理
|
||||||
|
- 查看任务执行历史
|
||||||
|
|
||||||
|
#### 3. 系统配置管理
|
||||||
|
- **JSON 编辑模式**:直接编辑配置 JSON
|
||||||
|
- **表单编辑模式**:通过友好的表单界面修改配置
|
||||||
|
- 支持的配置项:
|
||||||
|
- 服务器配置(端口、基础 URL)
|
||||||
|
- API 模式(本地/重定向)
|
||||||
|
- 定时任务配置
|
||||||
|
- 数据库配置
|
||||||
|
- 存储配置(本地/S3/WebDAV)
|
||||||
|
- 图片保留策略
|
||||||
|
- Token 配置
|
||||||
|
- 日志配置
|
||||||
|
- 功能特性开关
|
||||||
|
|
||||||
|
#### 4. 密码管理
|
||||||
|
- 修改管理员密码
|
||||||
|
- 安全退出登录
|
||||||
|
|
||||||
|
### 安全特性
|
||||||
|
- 基于 JWT Token 的身份验证
|
||||||
|
- Token 过期自动跳转登录页
|
||||||
|
- 路由守卫保护管理页面
|
||||||
|
- 密码修改后强制重新登录
|
||||||
|
|
||||||
|
## 项目结构
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
├── lib/ # 核心库
|
||||||
|
│ ├── api-config.ts # API 配置
|
||||||
|
│ ├── api-types.ts # TypeScript 类型定义
|
||||||
|
│ ├── api-service.ts # API 服务封装
|
||||||
|
│ ├── http-client.ts # HTTP 客户端
|
||||||
|
│ └── utils.ts # 工具函数
|
||||||
|
├── components/ # Vue 组件
|
||||||
|
│ └── ui/ # UI 组件库
|
||||||
|
├── views/ # 页面视图
|
||||||
|
├── assets/ # 静态资源
|
||||||
|
├── App.vue # 根组件
|
||||||
|
└── main.ts # 入口文件
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📚 文档
|
||||||
|
|
||||||
|
### 核心文档
|
||||||
|
- [README.md](./README.md) - 项目概览(本文件)
|
||||||
|
- [BUILD.md](./BUILD.md) - 构建说明
|
||||||
|
- [USAGE.md](./USAGE.md) - 使用指南
|
||||||
|
|
||||||
|
### 性能优化 ⚡
|
||||||
|
- [CACHE_QUICK_REF.md](./CACHE_QUICK_REF.md) - **缓存配置快速参考**(推荐从这里开始)
|
||||||
|
- [CACHE_CONFIG.md](./CACHE_CONFIG.md) - 详细的缓存配置指南
|
||||||
|
- [CACHE_OPTIMIZATION_SUMMARY.md](./CACHE_OPTIMIZATION_SUMMARY.md) - 优化总结
|
||||||
|
- [CACHE_TEST.html](./CACHE_TEST.html) - 缓存测试页面
|
||||||
|
|
||||||
|
### API 相关
|
||||||
|
- [CORS_CONFIG.md](./CORS_CONFIG.md) - CORS 配置
|
||||||
|
- [API_EXAMPLES.md](./API_EXAMPLES.md) - API 使用示例
|
||||||
|
|
||||||
|
### 其他
|
||||||
|
- [CHANGELOG.md](./CHANGELOG.md) - 更新日志
|
||||||
21
webapp/components.json
Normal file
21
webapp/components.json
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://shadcn-vue.com/schema.json",
|
||||||
|
"style": "new-york",
|
||||||
|
"typescript": true,
|
||||||
|
"tailwind": {
|
||||||
|
"config": "",
|
||||||
|
"css": "src/style.css",
|
||||||
|
"baseColor": "neutral",
|
||||||
|
"cssVariables": true,
|
||||||
|
"prefix": ""
|
||||||
|
},
|
||||||
|
"iconLibrary": "lucide",
|
||||||
|
"aliases": {
|
||||||
|
"components": "@/components",
|
||||||
|
"utils": "@/lib/utils",
|
||||||
|
"ui": "@/components/ui",
|
||||||
|
"lib": "@/lib",
|
||||||
|
"composables": "@/composables"
|
||||||
|
},
|
||||||
|
"registries": {}
|
||||||
|
}
|
||||||
1012
webapp/doc/swagger.json
Normal file
1012
webapp/doc/swagger.json
Normal file
File diff suppressed because it is too large
Load Diff
13
webapp/index.html
Normal file
13
webapp/index.html
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>BingPaper - 必应每日一图</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/main.ts"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
2532
webapp/package-lock.json
generated
Normal file
2532
webapp/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
39
webapp/package.json
Normal file
39
webapp/package.json
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
{
|
||||||
|
"name": "vue-vite-shadcn-template",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vue-tsc -b && vite build",
|
||||||
|
"build:dev": "vue-tsc -b && vite build --mode development",
|
||||||
|
"build:prod": "vue-tsc -b && vite build --mode production",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"clean": "rimraf ../web"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@tailwindcss/vite": "^4.1.18",
|
||||||
|
"@tanstack/vue-table": "^8.21.3",
|
||||||
|
"@vueuse/core": "^14.1.0",
|
||||||
|
"class-variance-authority": "^0.7.1",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"lucide-vue-next": "^0.562.0",
|
||||||
|
"lunar-javascript": "^1.7.7",
|
||||||
|
"reka-ui": "^2.7.0",
|
||||||
|
"tailwind-merge": "^3.4.0",
|
||||||
|
"tailwindcss": "^4.1.18",
|
||||||
|
"vue": "^3.5.24",
|
||||||
|
"vue-router": "^4.6.4",
|
||||||
|
"vue-sonner": "^2.0.9"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^24.10.8",
|
||||||
|
"@vitejs/plugin-vue": "^6.0.1",
|
||||||
|
"@vue/tsconfig": "^0.8.1",
|
||||||
|
"rimraf": "^6.1.2",
|
||||||
|
"tw-animate-css": "^1.4.0",
|
||||||
|
"typescript": "~5.9.3",
|
||||||
|
"vite": "^7.2.4",
|
||||||
|
"vue-tsc": "^3.1.4"
|
||||||
|
}
|
||||||
|
}
|
||||||
5
webapp/public/favicon.svg
Normal file
5
webapp/public/favicon.svg
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
<svg viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M372.4 148.1L177.7 91.3v696.4l194.8-170.6v-469z" fill="#409EFF"/>
|
||||||
|
<path d="M177.7 787.6L372.4 928l475.3-278.9V427.6l-670 360.1z" fill="#409EFF"/>
|
||||||
|
<path d="M428.8 299.7l93.1 195.5 113.6 46.5 212.2-114.1-414-127.9h-4.9z m0 0" fill="#409EFF"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 327 B |
25
webapp/src/App.vue
Normal file
25
webapp/src/App.vue
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import 'vue-sonner/style.css'
|
||||||
|
import { Toaster } from '@/components/ui/sonner'
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div id="app">
|
||||||
|
<RouterView />
|
||||||
|
<Toaster />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
#app {
|
||||||
|
font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
}
|
||||||
|
|
||||||
|
html, body {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
overflow-x: hidden;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
1
webapp/src/assets/vue.svg
Normal file
1
webapp/src/assets/vue.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg>
|
||||||
|
After Width: | Height: | Size: 496 B |
15
webapp/src/components/ui/alert-dialog/AlertDialog.vue
Normal file
15
webapp/src/components/ui/alert-dialog/AlertDialog.vue
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { AlertDialogEmits, AlertDialogProps } from "reka-ui"
|
||||||
|
import { AlertDialogRoot, useForwardPropsEmits } from "reka-ui"
|
||||||
|
|
||||||
|
const props = defineProps<AlertDialogProps>()
|
||||||
|
const emits = defineEmits<AlertDialogEmits>()
|
||||||
|
|
||||||
|
const forwarded = useForwardPropsEmits(props, emits)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<AlertDialogRoot v-slot="slotProps" data-slot="alert-dialog" v-bind="forwarded">
|
||||||
|
<slot v-bind="slotProps" />
|
||||||
|
</AlertDialogRoot>
|
||||||
|
</template>
|
||||||
18
webapp/src/components/ui/alert-dialog/AlertDialogAction.vue
Normal file
18
webapp/src/components/ui/alert-dialog/AlertDialogAction.vue
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { AlertDialogActionProps } from "reka-ui"
|
||||||
|
import type { HTMLAttributes } from "vue"
|
||||||
|
import { reactiveOmit } from "@vueuse/core"
|
||||||
|
import { AlertDialogAction } from "reka-ui"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { buttonVariants } from '@/components/ui/button'
|
||||||
|
|
||||||
|
const props = defineProps<AlertDialogActionProps & { class?: HTMLAttributes["class"] }>()
|
||||||
|
|
||||||
|
const delegatedProps = reactiveOmit(props, "class")
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<AlertDialogAction v-bind="delegatedProps" :class="cn(buttonVariants(), props.class)">
|
||||||
|
<slot />
|
||||||
|
</AlertDialogAction>
|
||||||
|
</template>
|
||||||
25
webapp/src/components/ui/alert-dialog/AlertDialogCancel.vue
Normal file
25
webapp/src/components/ui/alert-dialog/AlertDialogCancel.vue
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { AlertDialogCancelProps } from "reka-ui"
|
||||||
|
import type { HTMLAttributes } from "vue"
|
||||||
|
import { reactiveOmit } from "@vueuse/core"
|
||||||
|
import { AlertDialogCancel } from "reka-ui"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { buttonVariants } from '@/components/ui/button'
|
||||||
|
|
||||||
|
const props = defineProps<AlertDialogCancelProps & { class?: HTMLAttributes["class"] }>()
|
||||||
|
|
||||||
|
const delegatedProps = reactiveOmit(props, "class")
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<AlertDialogCancel
|
||||||
|
v-bind="delegatedProps"
|
||||||
|
:class="cn(
|
||||||
|
buttonVariants({ variant: 'outline' }),
|
||||||
|
'mt-2 sm:mt-0',
|
||||||
|
props.class,
|
||||||
|
)"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</AlertDialogCancel>
|
||||||
|
</template>
|
||||||
44
webapp/src/components/ui/alert-dialog/AlertDialogContent.vue
Normal file
44
webapp/src/components/ui/alert-dialog/AlertDialogContent.vue
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { AlertDialogContentEmits, AlertDialogContentProps } from "reka-ui"
|
||||||
|
import type { HTMLAttributes } from "vue"
|
||||||
|
import { reactiveOmit } from "@vueuse/core"
|
||||||
|
import {
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogOverlay,
|
||||||
|
AlertDialogPortal,
|
||||||
|
useForwardPropsEmits,
|
||||||
|
} from "reka-ui"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
defineOptions({
|
||||||
|
inheritAttrs: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
const props = defineProps<AlertDialogContentProps & { class?: HTMLAttributes["class"] }>()
|
||||||
|
const emits = defineEmits<AlertDialogContentEmits>()
|
||||||
|
|
||||||
|
const delegatedProps = reactiveOmit(props, "class")
|
||||||
|
|
||||||
|
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<AlertDialogPortal>
|
||||||
|
<AlertDialogOverlay
|
||||||
|
data-slot="alert-dialog-overlay"
|
||||||
|
class="data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/80"
|
||||||
|
/>
|
||||||
|
<AlertDialogContent
|
||||||
|
data-slot="alert-dialog-content"
|
||||||
|
v-bind="{ ...$attrs, ...forwarded }"
|
||||||
|
:class="
|
||||||
|
cn(
|
||||||
|
'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg',
|
||||||
|
props.class,
|
||||||
|
)
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialogPortal>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { AlertDialogDescriptionProps } from "reka-ui"
|
||||||
|
import type { HTMLAttributes } from "vue"
|
||||||
|
import { reactiveOmit } from "@vueuse/core"
|
||||||
|
import {
|
||||||
|
AlertDialogDescription,
|
||||||
|
} from "reka-ui"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const props = defineProps<AlertDialogDescriptionProps & { class?: HTMLAttributes["class"] }>()
|
||||||
|
|
||||||
|
const delegatedProps = reactiveOmit(props, "class")
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<AlertDialogDescription
|
||||||
|
data-slot="alert-dialog-description"
|
||||||
|
v-bind="delegatedProps"
|
||||||
|
:class="cn('text-muted-foreground text-sm', props.class)"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</template>
|
||||||
22
webapp/src/components/ui/alert-dialog/AlertDialogFooter.vue
Normal file
22
webapp/src/components/ui/alert-dialog/AlertDialogFooter.vue
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { HTMLAttributes } from "vue"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
class?: HTMLAttributes["class"]
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
data-slot="alert-dialog-footer"
|
||||||
|
:class="
|
||||||
|
cn(
|
||||||
|
'flex flex-col-reverse gap-2 sm:flex-row sm:justify-end',
|
||||||
|
props.class,
|
||||||
|
)
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
17
webapp/src/components/ui/alert-dialog/AlertDialogHeader.vue
Normal file
17
webapp/src/components/ui/alert-dialog/AlertDialogHeader.vue
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { HTMLAttributes } from "vue"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
class?: HTMLAttributes["class"]
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
data-slot="alert-dialog-header"
|
||||||
|
:class="cn('flex flex-col gap-2 text-center sm:text-left', props.class)"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
21
webapp/src/components/ui/alert-dialog/AlertDialogTitle.vue
Normal file
21
webapp/src/components/ui/alert-dialog/AlertDialogTitle.vue
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { AlertDialogTitleProps } from "reka-ui"
|
||||||
|
import type { HTMLAttributes } from "vue"
|
||||||
|
import { reactiveOmit } from "@vueuse/core"
|
||||||
|
import { AlertDialogTitle } from "reka-ui"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const props = defineProps<AlertDialogTitleProps & { class?: HTMLAttributes["class"] }>()
|
||||||
|
|
||||||
|
const delegatedProps = reactiveOmit(props, "class")
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<AlertDialogTitle
|
||||||
|
data-slot="alert-dialog-title"
|
||||||
|
v-bind="delegatedProps"
|
||||||
|
:class="cn('text-lg font-semibold', props.class)"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</AlertDialogTitle>
|
||||||
|
</template>
|
||||||
12
webapp/src/components/ui/alert-dialog/AlertDialogTrigger.vue
Normal file
12
webapp/src/components/ui/alert-dialog/AlertDialogTrigger.vue
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { AlertDialogTriggerProps } from "reka-ui"
|
||||||
|
import { AlertDialogTrigger } from "reka-ui"
|
||||||
|
|
||||||
|
const props = defineProps<AlertDialogTriggerProps>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<AlertDialogTrigger data-slot="alert-dialog-trigger" v-bind="props">
|
||||||
|
<slot />
|
||||||
|
</AlertDialogTrigger>
|
||||||
|
</template>
|
||||||
9
webapp/src/components/ui/alert-dialog/index.ts
Normal file
9
webapp/src/components/ui/alert-dialog/index.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
export { default as AlertDialog } from "./AlertDialog.vue"
|
||||||
|
export { default as AlertDialogAction } from "./AlertDialogAction.vue"
|
||||||
|
export { default as AlertDialogCancel } from "./AlertDialogCancel.vue"
|
||||||
|
export { default as AlertDialogContent } from "./AlertDialogContent.vue"
|
||||||
|
export { default as AlertDialogDescription } from "./AlertDialogDescription.vue"
|
||||||
|
export { default as AlertDialogFooter } from "./AlertDialogFooter.vue"
|
||||||
|
export { default as AlertDialogHeader } from "./AlertDialogHeader.vue"
|
||||||
|
export { default as AlertDialogTitle } from "./AlertDialogTitle.vue"
|
||||||
|
export { default as AlertDialogTrigger } from "./AlertDialogTrigger.vue"
|
||||||
18
webapp/src/components/ui/avatar/Avatar.vue
Normal file
18
webapp/src/components/ui/avatar/Avatar.vue
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { HTMLAttributes } from "vue"
|
||||||
|
import { AvatarRoot } from "reka-ui"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
class?: HTMLAttributes["class"]
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<AvatarRoot
|
||||||
|
data-slot="avatar"
|
||||||
|
:class="cn('relative flex size-8 shrink-0 overflow-hidden rounded-full', props.class)"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</AvatarRoot>
|
||||||
|
</template>
|
||||||
21
webapp/src/components/ui/avatar/AvatarFallback.vue
Normal file
21
webapp/src/components/ui/avatar/AvatarFallback.vue
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { AvatarFallbackProps } from "reka-ui"
|
||||||
|
import type { HTMLAttributes } from "vue"
|
||||||
|
import { reactiveOmit } from "@vueuse/core"
|
||||||
|
import { AvatarFallback } from "reka-ui"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const props = defineProps<AvatarFallbackProps & { class?: HTMLAttributes["class"] }>()
|
||||||
|
|
||||||
|
const delegatedProps = reactiveOmit(props, "class")
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<AvatarFallback
|
||||||
|
data-slot="avatar-fallback"
|
||||||
|
v-bind="delegatedProps"
|
||||||
|
:class="cn('bg-muted flex size-full items-center justify-center rounded-full', props.class)"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</AvatarFallback>
|
||||||
|
</template>
|
||||||
16
webapp/src/components/ui/avatar/AvatarImage.vue
Normal file
16
webapp/src/components/ui/avatar/AvatarImage.vue
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { AvatarImageProps } from "reka-ui"
|
||||||
|
import { AvatarImage } from "reka-ui"
|
||||||
|
|
||||||
|
const props = defineProps<AvatarImageProps>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<AvatarImage
|
||||||
|
data-slot="avatar-image"
|
||||||
|
v-bind="props"
|
||||||
|
class="aspect-square size-full"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</AvatarImage>
|
||||||
|
</template>
|
||||||
3
webapp/src/components/ui/avatar/index.ts
Normal file
3
webapp/src/components/ui/avatar/index.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export { default as Avatar } from "./Avatar.vue"
|
||||||
|
export { default as AvatarFallback } from "./AvatarFallback.vue"
|
||||||
|
export { default as AvatarImage } from "./AvatarImage.vue"
|
||||||
26
webapp/src/components/ui/badge/Badge.vue
Normal file
26
webapp/src/components/ui/badge/Badge.vue
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { PrimitiveProps } from "reka-ui"
|
||||||
|
import type { HTMLAttributes } from "vue"
|
||||||
|
import type { BadgeVariants } from "."
|
||||||
|
import { reactiveOmit } from "@vueuse/core"
|
||||||
|
import { Primitive } from "reka-ui"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { badgeVariants } from "."
|
||||||
|
|
||||||
|
const props = defineProps<PrimitiveProps & {
|
||||||
|
variant?: BadgeVariants["variant"]
|
||||||
|
class?: HTMLAttributes["class"]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const delegatedProps = reactiveOmit(props, "class")
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Primitive
|
||||||
|
data-slot="badge"
|
||||||
|
:class="cn(badgeVariants({ variant }), props.class)"
|
||||||
|
v-bind="delegatedProps"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</Primitive>
|
||||||
|
</template>
|
||||||
26
webapp/src/components/ui/badge/index.ts
Normal file
26
webapp/src/components/ui/badge/index.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import type { VariantProps } from "class-variance-authority"
|
||||||
|
import { cva } from "class-variance-authority"
|
||||||
|
|
||||||
|
export { default as Badge } from "./Badge.vue"
|
||||||
|
|
||||||
|
export const badgeVariants = cva(
|
||||||
|
"inline-flex items-center justify-center rounded-full border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default:
|
||||||
|
"border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
|
||||||
|
secondary:
|
||||||
|
"border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
|
||||||
|
destructive:
|
||||||
|
"border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
||||||
|
outline:
|
||||||
|
"text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
export type BadgeVariants = VariantProps<typeof badgeVariants>
|
||||||
29
webapp/src/components/ui/button/Button.vue
Normal file
29
webapp/src/components/ui/button/Button.vue
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { PrimitiveProps } from "reka-ui"
|
||||||
|
import type { HTMLAttributes } from "vue"
|
||||||
|
import type { ButtonVariants } from "."
|
||||||
|
import { Primitive } from "reka-ui"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { buttonVariants } from "."
|
||||||
|
|
||||||
|
interface Props extends PrimitiveProps {
|
||||||
|
variant?: ButtonVariants["variant"]
|
||||||
|
size?: ButtonVariants["size"]
|
||||||
|
class?: HTMLAttributes["class"]
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
as: "button",
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Primitive
|
||||||
|
data-slot="button"
|
||||||
|
:as="as"
|
||||||
|
:as-child="asChild"
|
||||||
|
:class="cn(buttonVariants({ variant, size }), props.class)"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</Primitive>
|
||||||
|
</template>
|
||||||
38
webapp/src/components/ui/button/index.ts
Normal file
38
webapp/src/components/ui/button/index.ts
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import type { VariantProps } from "class-variance-authority"
|
||||||
|
import { cva } from "class-variance-authority"
|
||||||
|
|
||||||
|
export { default as Button } from "./Button.vue"
|
||||||
|
|
||||||
|
export const buttonVariants = cva(
|
||||||
|
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default:
|
||||||
|
"bg-primary text-primary-foreground hover:bg-primary/90",
|
||||||
|
destructive:
|
||||||
|
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
||||||
|
outline:
|
||||||
|
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
|
||||||
|
secondary:
|
||||||
|
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||||
|
ghost:
|
||||||
|
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
|
||||||
|
link: "text-primary underline-offset-4 hover:underline",
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
"default": "h-9 px-4 py-2 has-[>svg]:px-3",
|
||||||
|
"sm": "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
|
||||||
|
"lg": "h-10 rounded-md px-6 has-[>svg]:px-4",
|
||||||
|
"icon": "size-9",
|
||||||
|
"icon-sm": "size-8",
|
||||||
|
"icon-lg": "size-10",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
size: "default",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
export type ButtonVariants = VariantProps<typeof buttonVariants>
|
||||||
652
webapp/src/components/ui/calendar/Calendar.vue
Normal file
652
webapp/src/components/ui/calendar/Calendar.vue
Normal file
@@ -0,0 +1,652 @@
|
|||||||
|
<template>
|
||||||
|
<div class="fixed inset-0 z-40">
|
||||||
|
<div
|
||||||
|
ref="calendarPanel"
|
||||||
|
class="fixed bg-gradient-to-br from-black/30 via-black/20 to-black/30 backdrop-blur-xl rounded-3xl p-3 sm:p-4 w-[calc(100%-1rem)] sm:w-full max-w-[95vw] sm:max-w-[420px] shadow-2xl border border-white/10 cursor-move select-none touch-none"
|
||||||
|
:style="{ left: panelPos.x + 'px', top: panelPos.y + 'px' }"
|
||||||
|
@mousedown="startDrag"
|
||||||
|
@touchstart.passive="startDrag"
|
||||||
|
@click.stop
|
||||||
|
>
|
||||||
|
<!-- 拖动手柄指示器 -->
|
||||||
|
<div class="absolute top-2 left-1/2 -translate-x-1/2 w-12 h-1 bg-white/20 rounded-full"></div>
|
||||||
|
|
||||||
|
<!-- 头部 -->
|
||||||
|
<div class="flex items-center justify-between mb-3 sm:mb-4 mt-2">
|
||||||
|
<div class="flex items-center gap-1.5 sm:gap-2 flex-1">
|
||||||
|
<button
|
||||||
|
@click.stop="previousMonth"
|
||||||
|
:disabled="!canGoPrevious"
|
||||||
|
class="p-1 sm:p-1.5 hover:bg-white/20 rounded-lg transition-colors disabled:opacity-30 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
<svg class="w-3.5 h-3.5 sm:w-4 sm:h-4 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="text-center flex-1">
|
||||||
|
<!-- 年月选择器 -->
|
||||||
|
<div class="flex items-center justify-center gap-1 sm:gap-1.5 mb-0.5">
|
||||||
|
<!-- 年份选择 -->
|
||||||
|
<Select v-model="currentYearString">
|
||||||
|
<SelectTrigger
|
||||||
|
class="w-[90px] sm:w-[105px] h-6 sm:h-7 bg-white/10 text-white border-white/20 hover:bg-white/20 backdrop-blur-md font-bold text-xs sm:text-sm px-1.5 sm:px-2"
|
||||||
|
@click.stop
|
||||||
|
@mousedown.stop
|
||||||
|
>
|
||||||
|
<SelectValue>{{ currentYear }}年</SelectValue>
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent class="max-h-[300px] bg-gray-900/95 backdrop-blur-xl border-white/20">
|
||||||
|
<SelectItem
|
||||||
|
v-for="year in yearOptions"
|
||||||
|
:key="year"
|
||||||
|
:value="String(year)"
|
||||||
|
class="text-white hover:bg-white/20 focus:bg-white/20 cursor-pointer"
|
||||||
|
>
|
||||||
|
{{ year }}年
|
||||||
|
</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
<!-- 月份选择 -->
|
||||||
|
<Select v-model="currentMonthString">
|
||||||
|
<SelectTrigger
|
||||||
|
class="w-[65px] sm:w-[75px] h-6 sm:h-7 bg-white/10 text-white border-white/20 hover:bg-white/20 backdrop-blur-md font-bold text-xs sm:text-sm px-1.5 sm:px-2"
|
||||||
|
@click.stop
|
||||||
|
@mousedown.stop
|
||||||
|
>
|
||||||
|
<SelectValue>{{ currentMonth + 1 }}月</SelectValue>
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent class="bg-gray-900/95 backdrop-blur-xl border-white/20">
|
||||||
|
<SelectItem
|
||||||
|
v-for="month in 12"
|
||||||
|
:key="month"
|
||||||
|
:value="String(month - 1)"
|
||||||
|
class="text-white hover:bg-white/20 focus:bg-white/20 cursor-pointer"
|
||||||
|
>
|
||||||
|
{{ month }}月
|
||||||
|
</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="text-[10px] sm:text-xs text-white/60 drop-shadow-md font-['Microsoft_YaHei_UI','Microsoft_YaHei',sans-serif] leading-relaxed">
|
||||||
|
{{ lunarMonthYear }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
@click.stop="nextMonth"
|
||||||
|
:disabled="!canGoNext"
|
||||||
|
class="p-1 sm:p-1.5 hover:bg-white/20 rounded-lg transition-colors disabled:opacity-30 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
<svg class="w-3.5 h-3.5 sm:w-4 sm:h-4 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
@click.stop="$emit('close')"
|
||||||
|
class="p-1 sm:p-1.5 hover:bg-white/20 rounded-lg transition-colors ml-1.5 sm:ml-2"
|
||||||
|
>
|
||||||
|
<svg class="w-3.5 h-3.5 sm:w-4 sm:h-4 text-white drop-shadow-lg" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 星期标题 -->
|
||||||
|
<div class="grid grid-cols-7 gap-1 sm:gap-1.5 mb-1.5 sm:mb-2 pointer-events-none">
|
||||||
|
<div
|
||||||
|
v-for="(day, idx) in weekDays"
|
||||||
|
:key="day"
|
||||||
|
class="text-center text-[11px] sm:text-[13px] font-medium py-1 sm:py-1.5 drop-shadow-md leading-none"
|
||||||
|
:class="idx === 0 || idx === 6 ? 'text-red-300/80' : 'text-white/70'"
|
||||||
|
>
|
||||||
|
{{ day }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 日期格子 -->
|
||||||
|
<div class="grid grid-cols-7 gap-1 sm:gap-1.5">
|
||||||
|
<div
|
||||||
|
v-for="(day, index) in calendarDays"
|
||||||
|
:key="index"
|
||||||
|
class="relative aspect-square flex flex-col items-center justify-center rounded-lg transition-opacity pointer-events-none py-0.5 sm:py-1"
|
||||||
|
:class="[
|
||||||
|
day.isCurrentMonth && !day.isFuture ? 'text-white' : 'text-white/25',
|
||||||
|
day.isToday ? 'bg-blue-400/40 ring-2 ring-blue-300/50' : '',
|
||||||
|
day.isSelected ? 'bg-white/30 ring-1 ring-white/40' : '',
|
||||||
|
day.isFuture ? 'opacity-40' : '',
|
||||||
|
day.isWeekend && day.isCurrentMonth ? 'text-red-200/90' : '',
|
||||||
|
(day.apiHoliday?.isOffDay || (!day.apiHoliday && day.isWeekend)) ? 'text-red-300' : ''
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<!-- 休息/上班标记 (API优先,其次周末) - 使用圆形SVG -->
|
||||||
|
<div
|
||||||
|
v-if="day.isCurrentMonth && (day.apiHoliday || day.isWeekend)"
|
||||||
|
class="absolute top-0 right-0 w-[14px] h-[14px] sm:w-4 sm:h-4"
|
||||||
|
>
|
||||||
|
<svg viewBox="0 0 20 20" class="w-full h-full drop-shadow-md">
|
||||||
|
<circle
|
||||||
|
cx="10"
|
||||||
|
cy="10"
|
||||||
|
r="9"
|
||||||
|
:fill="day.apiHoliday ? (day.apiHoliday.isOffDay ? '#ef4444' : '#3b82f6') : '#ef4444'"
|
||||||
|
opacity="0.65"
|
||||||
|
/>
|
||||||
|
<text
|
||||||
|
x="9.8"
|
||||||
|
y="10.5"
|
||||||
|
text-anchor="middle"
|
||||||
|
dominant-baseline="middle"
|
||||||
|
fill="white"
|
||||||
|
font-size="11"
|
||||||
|
font-weight="bold"
|
||||||
|
font-family="'Microsoft YaHei UI','Microsoft YaHei','PingFang SC','Hiragino Sans GB',sans-serif"
|
||||||
|
>
|
||||||
|
{{ day.apiHoliday ? (day.apiHoliday.isOffDay ? '休' : '班') : '休' }}
|
||||||
|
</text>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 公历日期 -->
|
||||||
|
<div
|
||||||
|
class="text-[13px] sm:text-[15px] font-medium drop-shadow-md font-['Helvetica','Arial',sans-serif] leading-none mb-0.5 sm:mb-1"
|
||||||
|
:class="(day.apiHoliday?.isOffDay || (!day.apiHoliday && day.isWeekend)) ? 'text-red-300 font-bold' : ''"
|
||||||
|
>
|
||||||
|
{{ day.day }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 农历/节日/节气 (不显示API节假日名称) -->
|
||||||
|
<div
|
||||||
|
class="text-[9px] sm:text-[10px] leading-tight drop-shadow-sm font-['Microsoft_YaHei_UI','Microsoft_YaHei',sans-serif] text-center px-0.5"
|
||||||
|
:class="[
|
||||||
|
day.festival || day.solarTerm || day.lunarFestival ? 'text-red-300 font-semibold' : 'text-white/60'
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
{{ day.festival || day.solarTerm || day.lunarFestival || day.lunarDay }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 今日按钮 -->
|
||||||
|
<div class="mt-3 sm:mt-4 flex justify-center">
|
||||||
|
<button
|
||||||
|
@click.stop="goToToday"
|
||||||
|
class="px-4 sm:px-5 py-1 sm:py-1.5 bg-white/15 hover:bg-white/30 text-white rounded-lg text-[11px] sm:text-xs font-medium transition-all hover:scale-105 active:scale-95 drop-shadow-lg"
|
||||||
|
>
|
||||||
|
回到今天
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, watch, onMounted } from 'vue'
|
||||||
|
import { Solar } from 'lunar-javascript'
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue
|
||||||
|
} from '@/components/ui/select'
|
||||||
|
import { getHolidaysByYear, getHolidayByDate, type Holidays, type HolidayDay } from '@/lib/holiday-service'
|
||||||
|
|
||||||
|
interface CalendarDay {
|
||||||
|
day: number
|
||||||
|
isCurrentMonth: boolean
|
||||||
|
isToday: boolean
|
||||||
|
isSelected: boolean
|
||||||
|
isFuture: boolean
|
||||||
|
isWeekend: boolean
|
||||||
|
isHoliday: boolean
|
||||||
|
holidayName: string
|
||||||
|
apiHoliday: HolidayDay | null // API返回的假期信息
|
||||||
|
lunarDay: string
|
||||||
|
festival: string
|
||||||
|
lunarFestival: string
|
||||||
|
solarTerm: string
|
||||||
|
date: Date
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
selectedDate?: string,
|
||||||
|
mkt?: string
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
close: []
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const weekDays = ['日', '一', '二', '三', '四', '五', '六']
|
||||||
|
|
||||||
|
// 日历面板位置
|
||||||
|
const calendarPanel = ref<HTMLElement | null>(null)
|
||||||
|
const panelPos = ref({ x: 0, y: 0 })
|
||||||
|
const isDragging = ref(false)
|
||||||
|
const dragStart = ref({ x: 0, y: 0 })
|
||||||
|
|
||||||
|
// 响应式窗口大小
|
||||||
|
const windowSize = ref({ width: window.innerWidth, height: window.innerHeight })
|
||||||
|
const isMobile = computed(() => windowSize.value.width < 768)
|
||||||
|
|
||||||
|
// 计算图片实际显示区域(与ImageView保持一致)
|
||||||
|
const getImageDisplayBounds = () => {
|
||||||
|
const windowWidth = windowSize.value.width
|
||||||
|
const windowHeight = windowSize.value.height
|
||||||
|
|
||||||
|
// 必应图片通常是16:9或类似宽高比
|
||||||
|
const imageAspectRatio = 16 / 9
|
||||||
|
const windowAspectRatio = windowWidth / windowHeight
|
||||||
|
|
||||||
|
let displayWidth: number
|
||||||
|
let displayHeight: number
|
||||||
|
let offsetX: number
|
||||||
|
let offsetY: number
|
||||||
|
|
||||||
|
if (windowAspectRatio > imageAspectRatio) {
|
||||||
|
// 窗口更宽,图片上下占满,左右留黑边
|
||||||
|
displayHeight = windowHeight
|
||||||
|
displayWidth = displayHeight * imageAspectRatio
|
||||||
|
offsetX = (windowWidth - displayWidth) / 2
|
||||||
|
offsetY = 0
|
||||||
|
} else {
|
||||||
|
// 窗口更高,图片左右占满,上下留黑边
|
||||||
|
displayWidth = windowWidth
|
||||||
|
displayHeight = displayWidth / imageAspectRatio
|
||||||
|
offsetX = 0
|
||||||
|
offsetY = (windowHeight - displayHeight) / 2
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
left: offsetX,
|
||||||
|
top: offsetY,
|
||||||
|
right: offsetX + displayWidth,
|
||||||
|
bottom: offsetY + displayHeight,
|
||||||
|
width: displayWidth,
|
||||||
|
height: displayHeight
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 初始化面板位置(移动端居中,桌面端右上角,限制在图片显示区域内)
|
||||||
|
const initPanelPosition = () => {
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
const bounds = getImageDisplayBounds()
|
||||||
|
|
||||||
|
if (isMobile.value) {
|
||||||
|
// 移动端:居中显示,尽量在图片内,但不强求
|
||||||
|
const panelWidth = Math.min(bounds.width - 16, windowSize.value.width - 16)
|
||||||
|
const panelHeight = 580 // 估计高度
|
||||||
|
panelPos.value = {
|
||||||
|
x: Math.max(bounds.left, bounds.left + (bounds.width - panelWidth) / 2),
|
||||||
|
y: Math.max(bounds.top + 8, bounds.top + (bounds.height - panelHeight) / 2)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 桌面端:在图片区域右上角
|
||||||
|
const panelWidth = Math.min(420, bounds.width * 0.9)
|
||||||
|
const panelHeight = 600
|
||||||
|
panelPos.value = {
|
||||||
|
x: bounds.right - panelWidth - 40,
|
||||||
|
y: Math.max(bounds.top + 80, bounds.top + (bounds.height - panelHeight) / 2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentYear = ref(new Date().getFullYear())
|
||||||
|
const currentMonth = ref(new Date().getMonth())
|
||||||
|
const isChangingMonth = ref(false)
|
||||||
|
|
||||||
|
// 假期数据
|
||||||
|
const holidaysData = ref<Map<number, Holidays | null>>(new Map())
|
||||||
|
const loadingHolidays = ref(false)
|
||||||
|
|
||||||
|
// 字符串版本的年月(用于Select组件)
|
||||||
|
const currentYearString = computed({
|
||||||
|
get: () => String(currentYear.value),
|
||||||
|
set: (val: string) => {
|
||||||
|
currentYear.value = Number(val)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const currentMonthString = computed({
|
||||||
|
get: () => String(currentMonth.value),
|
||||||
|
set: (val: string) => {
|
||||||
|
currentMonth.value = Number(val)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 生成年份选项(从2009年到当前年份+10年)
|
||||||
|
const yearOptions = computed(() => {
|
||||||
|
const currentYearValue = new Date().getFullYear()
|
||||||
|
const years: number[] = []
|
||||||
|
for (let year = currentYearValue - 30; year <= currentYearValue + 10; year++) {
|
||||||
|
years.push(year)
|
||||||
|
}
|
||||||
|
return years
|
||||||
|
})
|
||||||
|
|
||||||
|
// 计算是否可以切换月份(不限制)
|
||||||
|
const canGoPrevious = computed(() => {
|
||||||
|
return !isChangingMonth.value
|
||||||
|
})
|
||||||
|
|
||||||
|
const canGoNext = computed(() => {
|
||||||
|
return !isChangingMonth.value
|
||||||
|
})
|
||||||
|
|
||||||
|
// 初始化为选中的日期
|
||||||
|
watch(() => props.selectedDate, (newDate) => {
|
||||||
|
if (newDate) {
|
||||||
|
const date = new Date(newDate)
|
||||||
|
currentYear.value = date.getFullYear()
|
||||||
|
currentMonth.value = date.getMonth()
|
||||||
|
}
|
||||||
|
}, { immediate: true })
|
||||||
|
|
||||||
|
// 初始化位置
|
||||||
|
initPanelPosition()
|
||||||
|
|
||||||
|
// 加载假期数据
|
||||||
|
const loadHolidaysForYear = async (year: number) => {
|
||||||
|
if (holidaysData.value.has(year)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
loadingHolidays.value = true
|
||||||
|
try {
|
||||||
|
const data = await getHolidaysByYear(year)
|
||||||
|
holidaysData.value.set(year, data)
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`加载${year}年假期数据失败:`, error)
|
||||||
|
holidaysData.value.set(year, null)
|
||||||
|
} finally {
|
||||||
|
loadingHolidays.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 窗口缩放处理
|
||||||
|
const handleResize = () => {
|
||||||
|
windowSize.value = {
|
||||||
|
width: window.innerWidth,
|
||||||
|
height: window.innerHeight
|
||||||
|
}
|
||||||
|
initPanelPosition()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 组件挂载时加载当前年份的假期数据
|
||||||
|
onMounted(() => {
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
window.addEventListener('resize', handleResize)
|
||||||
|
}
|
||||||
|
const currentYearValue = currentYear.value
|
||||||
|
loadHolidaysForYear(currentYearValue)
|
||||||
|
// 预加载前后一年的数据
|
||||||
|
loadHolidaysForYear(currentYearValue - 1)
|
||||||
|
loadHolidaysForYear(currentYearValue + 1)
|
||||||
|
})
|
||||||
|
|
||||||
|
// 监听年份变化,加载对应的假期数据
|
||||||
|
watch(currentYear, (newYear) => {
|
||||||
|
loadHolidaysForYear(newYear)
|
||||||
|
// 预加载前后一年
|
||||||
|
loadHolidaysForYear(newYear - 1)
|
||||||
|
loadHolidaysForYear(newYear + 1)
|
||||||
|
})
|
||||||
|
|
||||||
|
// 开始拖动
|
||||||
|
const startDrag = (e: MouseEvent | TouchEvent) => {
|
||||||
|
const target = e.target as HTMLElement
|
||||||
|
// 如果点击的是按钮或其子元素,不触发拖拽
|
||||||
|
if (target.closest('button') || target.closest('[class*="grid"]')) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (e instanceof MouseEvent) {
|
||||||
|
e.preventDefault()
|
||||||
|
}
|
||||||
|
isDragging.value = true
|
||||||
|
|
||||||
|
const clientX = e instanceof MouseEvent ? e.clientX : e.touches[0].clientX
|
||||||
|
const clientY = e instanceof MouseEvent ? e.clientY : e.touches[0].clientY
|
||||||
|
|
||||||
|
dragStart.value = {
|
||||||
|
x: clientX - panelPos.value.x,
|
||||||
|
y: clientY - panelPos.value.y
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('mousemove', onDrag)
|
||||||
|
document.addEventListener('mouseup', stopDrag)
|
||||||
|
document.addEventListener('touchmove', onDrag, { passive: true })
|
||||||
|
document.addEventListener('touchend', stopDrag)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 拖动中
|
||||||
|
const onDrag = (e: MouseEvent | TouchEvent) => {
|
||||||
|
if (!isDragging.value) return
|
||||||
|
|
||||||
|
if (e instanceof MouseEvent) {
|
||||||
|
e.preventDefault()
|
||||||
|
}
|
||||||
|
|
||||||
|
const clientX = e instanceof MouseEvent ? e.clientX : e.touches[0].clientX
|
||||||
|
const clientY = e instanceof MouseEvent ? e.clientY : e.touches[0].clientY
|
||||||
|
|
||||||
|
const newX = clientX - dragStart.value.x
|
||||||
|
const newY = clientY - dragStart.value.y
|
||||||
|
|
||||||
|
// 限制在有效区域内
|
||||||
|
if (calendarPanel.value) {
|
||||||
|
const rect = calendarPanel.value.getBoundingClientRect()
|
||||||
|
|
||||||
|
let minX, maxX, minY, maxY
|
||||||
|
|
||||||
|
if (isMobile.value) {
|
||||||
|
// 移动端:不限制区域,限制在视口内即可
|
||||||
|
minX = 0
|
||||||
|
maxX = windowSize.value.width - rect.width
|
||||||
|
minY = 0
|
||||||
|
maxY = windowSize.value.height - rect.height
|
||||||
|
} else {
|
||||||
|
// 桌面端:限制在图片实际显示区域内
|
||||||
|
const bounds = getImageDisplayBounds()
|
||||||
|
minX = bounds.left
|
||||||
|
maxX = bounds.right - rect.width
|
||||||
|
minY = bounds.top
|
||||||
|
maxY = bounds.bottom - rect.height
|
||||||
|
}
|
||||||
|
|
||||||
|
panelPos.value = {
|
||||||
|
x: Math.max(minX, Math.min(newX, maxX)),
|
||||||
|
y: Math.max(minY, Math.min(newY, maxY))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 停止拖动
|
||||||
|
const stopDrag = () => {
|
||||||
|
isDragging.value = false
|
||||||
|
document.removeEventListener('mousemove', onDrag)
|
||||||
|
document.removeEventListener('mouseup', stopDrag)
|
||||||
|
document.removeEventListener('touchmove', onDrag)
|
||||||
|
document.removeEventListener('touchend', stopDrag)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 农历月份年份
|
||||||
|
const lunarMonthYear = computed(() => {
|
||||||
|
const solar = Solar.fromDate(new Date(currentYear.value, currentMonth.value, 15))
|
||||||
|
const lunar = solar.getLunar()
|
||||||
|
return `${lunar.getYearInChinese()}年${lunar.getMonthInChinese()}月`
|
||||||
|
})
|
||||||
|
|
||||||
|
// 获取日历天数
|
||||||
|
const calendarDays = computed<CalendarDay[]>(() => {
|
||||||
|
const year = currentYear.value
|
||||||
|
const month = currentMonth.value
|
||||||
|
|
||||||
|
// 当月第一天
|
||||||
|
const firstDay = new Date(year, month, 1)
|
||||||
|
const firstDayWeek = firstDay.getDay()
|
||||||
|
|
||||||
|
// 当月最后一天
|
||||||
|
const lastDay = new Date(year, month + 1, 0)
|
||||||
|
const lastDate = lastDay.getDate()
|
||||||
|
|
||||||
|
// 上月最后几天
|
||||||
|
const prevLastDay = new Date(year, month, 0)
|
||||||
|
const prevLastDate = prevLastDay.getDate()
|
||||||
|
|
||||||
|
const days: CalendarDay[] = []
|
||||||
|
const today = new Date()
|
||||||
|
today.setHours(0, 0, 0, 0)
|
||||||
|
|
||||||
|
// 填充上月日期
|
||||||
|
for (let i = firstDayWeek - 1; i >= 0; i--) {
|
||||||
|
const day = prevLastDate - i
|
||||||
|
const date = new Date(year, month - 1, day)
|
||||||
|
days.push(createDayObject(date, false))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 填充当月日期
|
||||||
|
for (let day = 1; day <= lastDate; day++) {
|
||||||
|
const date = new Date(year, month, day)
|
||||||
|
days.push(createDayObject(date, true))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 填充下月日期
|
||||||
|
const remainingDays = 42 - days.length // 6行7列
|
||||||
|
for (let day = 1; day <= remainingDays; day++) {
|
||||||
|
const date = new Date(year, month + 1, day)
|
||||||
|
days.push(createDayObject(date, false))
|
||||||
|
}
|
||||||
|
|
||||||
|
return days
|
||||||
|
})
|
||||||
|
|
||||||
|
// 创建日期对象
|
||||||
|
const createDayObject = (date: Date, isCurrentMonth: boolean): CalendarDay => {
|
||||||
|
const today = new Date()
|
||||||
|
today.setHours(0, 0, 0, 0)
|
||||||
|
|
||||||
|
const selectedDate = new Date(props.selectedDate || new Date())
|
||||||
|
selectedDate.setHours(0, 0, 0, 0)
|
||||||
|
|
||||||
|
// 转换为农历
|
||||||
|
const solar = Solar.fromDate(date)
|
||||||
|
const lunar = solar.getLunar()
|
||||||
|
|
||||||
|
// 获取节日
|
||||||
|
const festivals = solar.getFestivals()
|
||||||
|
const festival = festivals.length > 0 ? festivals[0] : ''
|
||||||
|
|
||||||
|
// 获取农历节日
|
||||||
|
const lunarFestivals = lunar.getFestivals()
|
||||||
|
const lunarFestival = lunarFestivals.length > 0 ? lunarFestivals[0] : ''
|
||||||
|
|
||||||
|
// 获取节气
|
||||||
|
const solarTerm = lunar.getJieQi()
|
||||||
|
|
||||||
|
// 获取API假期数据 - 使用本地时间避免时区偏移
|
||||||
|
const dateStr = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`
|
||||||
|
const yearHolidays = holidaysData.value.get(date.getFullYear())
|
||||||
|
const apiHoliday = getHolidayByDate(yearHolidays || null, dateStr)
|
||||||
|
|
||||||
|
// 检查是否为假期(使用lunar-javascript的节日信息)
|
||||||
|
let isHoliday = false
|
||||||
|
let holidayName = ''
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (festival || lunarFestival) {
|
||||||
|
// 常见法定节假日
|
||||||
|
const legalHolidays = ['元旦', '春节', '清明', '劳动节', '端午', '中秋', '国庆']
|
||||||
|
const holidayNames = [festival, lunarFestival].filter(Boolean)
|
||||||
|
|
||||||
|
for (const name of holidayNames) {
|
||||||
|
if (legalHolidays.some(legal => name.includes(legal))) {
|
||||||
|
isHoliday = true
|
||||||
|
holidayName = name
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.debug('假期信息获取失败:', e)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 判断是否为周末(周六或周日)
|
||||||
|
const isWeekend = date.getDay() === 0 || date.getDay() === 6
|
||||||
|
|
||||||
|
// 农历日期显示
|
||||||
|
let lunarDay = lunar.getDayInChinese()
|
||||||
|
if (lunar.getDay() === 1) {
|
||||||
|
lunarDay = lunar.getMonthInChinese() + '月'
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
day: date.getDate(),
|
||||||
|
isCurrentMonth,
|
||||||
|
isToday: date.getTime() === today.getTime(),
|
||||||
|
isSelected: date.getTime() === selectedDate.getTime(),
|
||||||
|
isFuture: date > today,
|
||||||
|
isWeekend,
|
||||||
|
isHoliday,
|
||||||
|
holidayName,
|
||||||
|
apiHoliday,
|
||||||
|
lunarDay,
|
||||||
|
festival,
|
||||||
|
lunarFestival,
|
||||||
|
solarTerm,
|
||||||
|
date
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 上一月
|
||||||
|
const previousMonth = () => {
|
||||||
|
if (!canGoPrevious.value) return
|
||||||
|
|
||||||
|
if (currentMonth.value === 0) {
|
||||||
|
currentMonth.value = 11
|
||||||
|
currentYear.value--
|
||||||
|
} else {
|
||||||
|
currentMonth.value--
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 下一月
|
||||||
|
const nextMonth = () => {
|
||||||
|
if (!canGoNext.value) return
|
||||||
|
|
||||||
|
if (currentMonth.value === 11) {
|
||||||
|
currentMonth.value = 0
|
||||||
|
currentYear.value++
|
||||||
|
} else {
|
||||||
|
currentMonth.value++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 回到今天
|
||||||
|
const goToToday = () => {
|
||||||
|
const today = new Date()
|
||||||
|
currentYear.value = today.getFullYear()
|
||||||
|
currentMonth.value = today.getMonth()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 不再支持点击日期选择
|
||||||
|
// 日历仅作为台历展示功能
|
||||||
|
|
||||||
|
// 清理
|
||||||
|
import { onUnmounted } from 'vue'
|
||||||
|
onUnmounted(() => {
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
window.removeEventListener('resize', handleResize)
|
||||||
|
}
|
||||||
|
document.removeEventListener('mousemove', onDrag)
|
||||||
|
document.removeEventListener('mouseup', stopDrag)
|
||||||
|
document.removeEventListener('touchmove', onDrag)
|
||||||
|
document.removeEventListener('touchend', stopDrag)
|
||||||
|
})
|
||||||
|
</script>
|
||||||
22
webapp/src/components/ui/card/Card.vue
Normal file
22
webapp/src/components/ui/card/Card.vue
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { HTMLAttributes } from "vue"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
class?: HTMLAttributes["class"]
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
data-slot="card"
|
||||||
|
:class="
|
||||||
|
cn(
|
||||||
|
'bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm',
|
||||||
|
props.class,
|
||||||
|
)
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
17
webapp/src/components/ui/card/CardAction.vue
Normal file
17
webapp/src/components/ui/card/CardAction.vue
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { HTMLAttributes } from "vue"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
class?: HTMLAttributes["class"]
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
data-slot="card-action"
|
||||||
|
:class="cn('col-start-2 row-span-2 row-start-1 self-start justify-self-end', props.class)"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
17
webapp/src/components/ui/card/CardContent.vue
Normal file
17
webapp/src/components/ui/card/CardContent.vue
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { HTMLAttributes } from "vue"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
class?: HTMLAttributes["class"]
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
data-slot="card-content"
|
||||||
|
:class="cn('px-6', props.class)"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
17
webapp/src/components/ui/card/CardDescription.vue
Normal file
17
webapp/src/components/ui/card/CardDescription.vue
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { HTMLAttributes } from "vue"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
class?: HTMLAttributes["class"]
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<p
|
||||||
|
data-slot="card-description"
|
||||||
|
:class="cn('text-muted-foreground text-sm', props.class)"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</p>
|
||||||
|
</template>
|
||||||
17
webapp/src/components/ui/card/CardFooter.vue
Normal file
17
webapp/src/components/ui/card/CardFooter.vue
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { HTMLAttributes } from "vue"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
class?: HTMLAttributes["class"]
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
data-slot="card-footer"
|
||||||
|
:class="cn('flex items-center px-6 [.border-t]:pt-6', props.class)"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
17
webapp/src/components/ui/card/CardHeader.vue
Normal file
17
webapp/src/components/ui/card/CardHeader.vue
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { HTMLAttributes } from "vue"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
class?: HTMLAttributes["class"]
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
data-slot="card-header"
|
||||||
|
:class="cn('@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6', props.class)"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
17
webapp/src/components/ui/card/CardTitle.vue
Normal file
17
webapp/src/components/ui/card/CardTitle.vue
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { HTMLAttributes } from "vue"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
class?: HTMLAttributes["class"]
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<h3
|
||||||
|
data-slot="card-title"
|
||||||
|
:class="cn('leading-none font-semibold', props.class)"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</h3>
|
||||||
|
</template>
|
||||||
7
webapp/src/components/ui/card/index.ts
Normal file
7
webapp/src/components/ui/card/index.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
export { default as Card } from "./Card.vue"
|
||||||
|
export { default as CardAction } from "./CardAction.vue"
|
||||||
|
export { default as CardContent } from "./CardContent.vue"
|
||||||
|
export { default as CardDescription } from "./CardDescription.vue"
|
||||||
|
export { default as CardFooter } from "./CardFooter.vue"
|
||||||
|
export { default as CardHeader } from "./CardHeader.vue"
|
||||||
|
export { default as CardTitle } from "./CardTitle.vue"
|
||||||
35
webapp/src/components/ui/checkbox/Checkbox.vue
Normal file
35
webapp/src/components/ui/checkbox/Checkbox.vue
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { CheckboxRootEmits, CheckboxRootProps } from "reka-ui"
|
||||||
|
import type { HTMLAttributes } from "vue"
|
||||||
|
import { reactiveOmit } from "@vueuse/core"
|
||||||
|
import { Check } from "lucide-vue-next"
|
||||||
|
import { CheckboxIndicator, CheckboxRoot, useForwardPropsEmits } from "reka-ui"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const props = defineProps<CheckboxRootProps & { class?: HTMLAttributes["class"] }>()
|
||||||
|
const emits = defineEmits<CheckboxRootEmits>()
|
||||||
|
|
||||||
|
const delegatedProps = reactiveOmit(props, "class")
|
||||||
|
|
||||||
|
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<CheckboxRoot
|
||||||
|
v-slot="slotProps"
|
||||||
|
data-slot="checkbox"
|
||||||
|
v-bind="forwarded"
|
||||||
|
:class="
|
||||||
|
cn('peer border-input data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50',
|
||||||
|
props.class)"
|
||||||
|
>
|
||||||
|
<CheckboxIndicator
|
||||||
|
data-slot="checkbox-indicator"
|
||||||
|
class="grid place-content-center text-current transition-none"
|
||||||
|
>
|
||||||
|
<slot v-bind="slotProps">
|
||||||
|
<Check class="size-3.5" />
|
||||||
|
</slot>
|
||||||
|
</CheckboxIndicator>
|
||||||
|
</CheckboxRoot>
|
||||||
|
</template>
|
||||||
1
webapp/src/components/ui/checkbox/index.ts
Normal file
1
webapp/src/components/ui/checkbox/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { default as Checkbox } from "./Checkbox.vue"
|
||||||
87
webapp/src/components/ui/command/Command.vue
Normal file
87
webapp/src/components/ui/command/Command.vue
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { ListboxRootEmits, ListboxRootProps } from "reka-ui"
|
||||||
|
import type { HTMLAttributes } from "vue"
|
||||||
|
import { reactiveOmit } from "@vueuse/core"
|
||||||
|
import { ListboxRoot, useFilter, useForwardPropsEmits } from "reka-ui"
|
||||||
|
import { reactive, ref, watch } from "vue"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { provideCommandContext } from "."
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<ListboxRootProps & { class?: HTMLAttributes["class"] }>(), {
|
||||||
|
modelValue: "",
|
||||||
|
})
|
||||||
|
|
||||||
|
const emits = defineEmits<ListboxRootEmits>()
|
||||||
|
|
||||||
|
const delegatedProps = reactiveOmit(props, "class")
|
||||||
|
|
||||||
|
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||||
|
|
||||||
|
const allItems = ref<Map<string, string>>(new Map())
|
||||||
|
const allGroups = ref<Map<string, Set<string>>>(new Map())
|
||||||
|
|
||||||
|
const { contains } = useFilter({ sensitivity: "base" })
|
||||||
|
const filterState = reactive({
|
||||||
|
search: "",
|
||||||
|
filtered: {
|
||||||
|
/** The count of all visible items. */
|
||||||
|
count: 0,
|
||||||
|
/** Map from visible item id to its search score. */
|
||||||
|
items: new Map() as Map<string, number>,
|
||||||
|
/** Set of groups with at least one visible item. */
|
||||||
|
groups: new Set() as Set<string>,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
function filterItems() {
|
||||||
|
if (!filterState.search) {
|
||||||
|
filterState.filtered.count = allItems.value.size
|
||||||
|
// Do nothing, each item will know to show itself because search is empty
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset the groups
|
||||||
|
filterState.filtered.groups = new Set()
|
||||||
|
let itemCount = 0
|
||||||
|
|
||||||
|
// Check which items should be included
|
||||||
|
for (const [id, value] of allItems.value) {
|
||||||
|
const score = contains(value, filterState.search)
|
||||||
|
filterState.filtered.items.set(id, score ? 1 : 0)
|
||||||
|
if (score)
|
||||||
|
itemCount++
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check which groups have at least 1 item shown
|
||||||
|
for (const [groupId, group] of allGroups.value) {
|
||||||
|
for (const itemId of group) {
|
||||||
|
if (filterState.filtered.items.get(itemId)! > 0) {
|
||||||
|
filterState.filtered.groups.add(groupId)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
filterState.filtered.count = itemCount
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(() => filterState.search, () => {
|
||||||
|
filterItems()
|
||||||
|
})
|
||||||
|
|
||||||
|
provideCommandContext({
|
||||||
|
allItems,
|
||||||
|
allGroups,
|
||||||
|
filterState,
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<ListboxRoot
|
||||||
|
data-slot="command"
|
||||||
|
v-bind="forwarded"
|
||||||
|
:class="cn('bg-popover text-popover-foreground flex h-full w-full flex-col overflow-hidden rounded-md', props.class)"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</ListboxRoot>
|
||||||
|
</template>
|
||||||
31
webapp/src/components/ui/command/CommandDialog.vue
Normal file
31
webapp/src/components/ui/command/CommandDialog.vue
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { DialogRootEmits, DialogRootProps } from "reka-ui"
|
||||||
|
import { useForwardPropsEmits } from "reka-ui"
|
||||||
|
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||||
|
import Command from "./Command.vue"
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<DialogRootProps & {
|
||||||
|
title?: string
|
||||||
|
description?: string
|
||||||
|
}>(), {
|
||||||
|
title: "Command Palette",
|
||||||
|
description: "Search for a command to run...",
|
||||||
|
})
|
||||||
|
const emits = defineEmits<DialogRootEmits>()
|
||||||
|
|
||||||
|
const forwarded = useForwardPropsEmits(props, emits)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Dialog v-slot="slotProps" v-bind="forwarded">
|
||||||
|
<DialogContent class="overflow-hidden p-0 ">
|
||||||
|
<DialogHeader class="sr-only">
|
||||||
|
<DialogTitle>{{ title }}</DialogTitle>
|
||||||
|
<DialogDescription>{{ description }}</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<Command>
|
||||||
|
<slot v-bind="slotProps" />
|
||||||
|
</Command>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</template>
|
||||||
27
webapp/src/components/ui/command/CommandEmpty.vue
Normal file
27
webapp/src/components/ui/command/CommandEmpty.vue
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { PrimitiveProps } from "reka-ui"
|
||||||
|
import type { HTMLAttributes } from "vue"
|
||||||
|
import { reactiveOmit } from "@vueuse/core"
|
||||||
|
import { Primitive } from "reka-ui"
|
||||||
|
import { computed } from "vue"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { useCommand } from "."
|
||||||
|
|
||||||
|
const props = defineProps<PrimitiveProps & { class?: HTMLAttributes["class"] }>()
|
||||||
|
|
||||||
|
const delegatedProps = reactiveOmit(props, "class")
|
||||||
|
|
||||||
|
const { filterState } = useCommand()
|
||||||
|
const isRender = computed(() => !!filterState.search && filterState.filtered.count === 0,
|
||||||
|
)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Primitive
|
||||||
|
v-if="isRender"
|
||||||
|
data-slot="command-empty"
|
||||||
|
v-bind="delegatedProps" :class="cn('py-6 text-center text-sm', props.class)"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</Primitive>
|
||||||
|
</template>
|
||||||
45
webapp/src/components/ui/command/CommandGroup.vue
Normal file
45
webapp/src/components/ui/command/CommandGroup.vue
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { ListboxGroupProps } from "reka-ui"
|
||||||
|
import type { HTMLAttributes } from "vue"
|
||||||
|
import { reactiveOmit } from "@vueuse/core"
|
||||||
|
import { ListboxGroup, ListboxGroupLabel, useId } from "reka-ui"
|
||||||
|
import { computed, onMounted, onUnmounted } from "vue"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { provideCommandGroupContext, useCommand } from "."
|
||||||
|
|
||||||
|
const props = defineProps<ListboxGroupProps & {
|
||||||
|
class?: HTMLAttributes["class"]
|
||||||
|
heading?: string
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const delegatedProps = reactiveOmit(props, "class")
|
||||||
|
|
||||||
|
const { allGroups, filterState } = useCommand()
|
||||||
|
const id = useId()
|
||||||
|
|
||||||
|
const isRender = computed(() => !filterState.search ? true : filterState.filtered.groups.has(id))
|
||||||
|
|
||||||
|
provideCommandGroupContext({ id })
|
||||||
|
onMounted(() => {
|
||||||
|
if (!allGroups.value.has(id))
|
||||||
|
allGroups.value.set(id, new Set())
|
||||||
|
})
|
||||||
|
onUnmounted(() => {
|
||||||
|
allGroups.value.delete(id)
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<ListboxGroup
|
||||||
|
v-bind="delegatedProps"
|
||||||
|
:id="id"
|
||||||
|
data-slot="command-group"
|
||||||
|
:class="cn('text-foreground overflow-hidden p-1', props.class)"
|
||||||
|
:hidden="isRender ? undefined : true"
|
||||||
|
>
|
||||||
|
<ListboxGroupLabel v-if="heading" data-slot="command-group-heading" class="px-2 py-1.5 text-xs font-medium text-muted-foreground">
|
||||||
|
{{ heading }}
|
||||||
|
</ListboxGroupLabel>
|
||||||
|
<slot />
|
||||||
|
</ListboxGroup>
|
||||||
|
</template>
|
||||||
39
webapp/src/components/ui/command/CommandInput.vue
Normal file
39
webapp/src/components/ui/command/CommandInput.vue
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { ListboxFilterProps } from "reka-ui"
|
||||||
|
import type { HTMLAttributes } from "vue"
|
||||||
|
import { reactiveOmit } from "@vueuse/core"
|
||||||
|
import { Search } from "lucide-vue-next"
|
||||||
|
import { ListboxFilter, useForwardProps } from "reka-ui"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { useCommand } from "."
|
||||||
|
|
||||||
|
defineOptions({
|
||||||
|
inheritAttrs: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
const props = defineProps<ListboxFilterProps & {
|
||||||
|
class?: HTMLAttributes["class"]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const delegatedProps = reactiveOmit(props, "class")
|
||||||
|
|
||||||
|
const forwardedProps = useForwardProps(delegatedProps)
|
||||||
|
|
||||||
|
const { filterState } = useCommand()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
data-slot="command-input-wrapper"
|
||||||
|
class="flex h-9 items-center gap-2 border-b px-3"
|
||||||
|
>
|
||||||
|
<Search class="size-4 shrink-0 opacity-50" />
|
||||||
|
<ListboxFilter
|
||||||
|
v-bind="{ ...forwardedProps, ...$attrs }"
|
||||||
|
v-model="filterState.search"
|
||||||
|
data-slot="command-input"
|
||||||
|
auto-focus
|
||||||
|
:class="cn('placeholder:text-muted-foreground flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50', props.class)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
76
webapp/src/components/ui/command/CommandItem.vue
Normal file
76
webapp/src/components/ui/command/CommandItem.vue
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { ListboxItemEmits, ListboxItemProps } from "reka-ui"
|
||||||
|
import type { HTMLAttributes } from "vue"
|
||||||
|
import { reactiveOmit, useCurrentElement } from "@vueuse/core"
|
||||||
|
import { ListboxItem, useForwardPropsEmits, useId } from "reka-ui"
|
||||||
|
import { computed, onMounted, onUnmounted, ref } from "vue"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { useCommand, useCommandGroup } from "."
|
||||||
|
|
||||||
|
const props = defineProps<ListboxItemProps & { class?: HTMLAttributes["class"] }>()
|
||||||
|
const emits = defineEmits<ListboxItemEmits>()
|
||||||
|
|
||||||
|
const delegatedProps = reactiveOmit(props, "class")
|
||||||
|
|
||||||
|
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||||
|
|
||||||
|
const id = useId()
|
||||||
|
const { filterState, allItems, allGroups } = useCommand()
|
||||||
|
const groupContext = useCommandGroup()
|
||||||
|
|
||||||
|
const isRender = computed(() => {
|
||||||
|
if (!filterState.search) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
const filteredCurrentItem = filterState.filtered.items.get(id)
|
||||||
|
// If the filtered items is undefined means not in the all times map yet
|
||||||
|
// Do the first render to add into the map
|
||||||
|
if (filteredCurrentItem === undefined) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check with filter
|
||||||
|
return filteredCurrentItem > 0
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const itemRef = ref()
|
||||||
|
const currentElement = useCurrentElement(itemRef)
|
||||||
|
onMounted(() => {
|
||||||
|
if (!(currentElement.value instanceof HTMLElement))
|
||||||
|
return
|
||||||
|
|
||||||
|
// textValue to perform filter
|
||||||
|
allItems.value.set(id, currentElement.value.textContent ?? (props.value?.toString() ?? ""))
|
||||||
|
|
||||||
|
const groupId = groupContext?.id
|
||||||
|
if (groupId) {
|
||||||
|
if (!allGroups.value.has(groupId)) {
|
||||||
|
allGroups.value.set(groupId, new Set([id]))
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
allGroups.value.get(groupId)?.add(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
onUnmounted(() => {
|
||||||
|
allItems.value.delete(id)
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<ListboxItem
|
||||||
|
v-if="isRender"
|
||||||
|
v-bind="forwarded"
|
||||||
|
:id="id"
|
||||||
|
ref="itemRef"
|
||||||
|
data-slot="command-item"
|
||||||
|
:class="cn('data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground [&_svg:not([class*=\'text-\'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*=\'size-\'])]:size-4', props.class)"
|
||||||
|
@select="() => {
|
||||||
|
filterState.search = ''
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</ListboxItem>
|
||||||
|
</template>
|
||||||
25
webapp/src/components/ui/command/CommandList.vue
Normal file
25
webapp/src/components/ui/command/CommandList.vue
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { ListboxContentProps } from "reka-ui"
|
||||||
|
import type { HTMLAttributes } from "vue"
|
||||||
|
import { reactiveOmit } from "@vueuse/core"
|
||||||
|
import { ListboxContent, useForwardProps } from "reka-ui"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const props = defineProps<ListboxContentProps & { class?: HTMLAttributes["class"] }>()
|
||||||
|
|
||||||
|
const delegatedProps = reactiveOmit(props, "class")
|
||||||
|
|
||||||
|
const forwarded = useForwardProps(delegatedProps)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<ListboxContent
|
||||||
|
data-slot="command-list"
|
||||||
|
v-bind="forwarded"
|
||||||
|
:class="cn('max-h-[300px] scroll-py-1 overflow-x-hidden overflow-y-auto', props.class)"
|
||||||
|
>
|
||||||
|
<div role="presentation">
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</ListboxContent>
|
||||||
|
</template>
|
||||||
21
webapp/src/components/ui/command/CommandSeparator.vue
Normal file
21
webapp/src/components/ui/command/CommandSeparator.vue
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { SeparatorProps } from "reka-ui"
|
||||||
|
import type { HTMLAttributes } from "vue"
|
||||||
|
import { reactiveOmit } from "@vueuse/core"
|
||||||
|
import { Separator } from "reka-ui"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const props = defineProps<SeparatorProps & { class?: HTMLAttributes["class"] }>()
|
||||||
|
|
||||||
|
const delegatedProps = reactiveOmit(props, "class")
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Separator
|
||||||
|
data-slot="command-separator"
|
||||||
|
v-bind="delegatedProps"
|
||||||
|
:class="cn('bg-border -mx-1 h-px', props.class)"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</Separator>
|
||||||
|
</template>
|
||||||
17
webapp/src/components/ui/command/CommandShortcut.vue
Normal file
17
webapp/src/components/ui/command/CommandShortcut.vue
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { HTMLAttributes } from "vue"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
class?: HTMLAttributes["class"]
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<span
|
||||||
|
data-slot="command-shortcut"
|
||||||
|
:class="cn('text-muted-foreground ml-auto text-xs tracking-widest', props.class)"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
25
webapp/src/components/ui/command/index.ts
Normal file
25
webapp/src/components/ui/command/index.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import type { Ref } from "vue"
|
||||||
|
import { createContext } from "reka-ui"
|
||||||
|
|
||||||
|
export { default as Command } from "./Command.vue"
|
||||||
|
export { default as CommandDialog } from "./CommandDialog.vue"
|
||||||
|
export { default as CommandEmpty } from "./CommandEmpty.vue"
|
||||||
|
export { default as CommandGroup } from "./CommandGroup.vue"
|
||||||
|
export { default as CommandInput } from "./CommandInput.vue"
|
||||||
|
export { default as CommandItem } from "./CommandItem.vue"
|
||||||
|
export { default as CommandList } from "./CommandList.vue"
|
||||||
|
export { default as CommandSeparator } from "./CommandSeparator.vue"
|
||||||
|
export { default as CommandShortcut } from "./CommandShortcut.vue"
|
||||||
|
|
||||||
|
export const [useCommand, provideCommandContext] = createContext<{
|
||||||
|
allItems: Ref<Map<string, string>>
|
||||||
|
allGroups: Ref<Map<string, Set<string>>>
|
||||||
|
filterState: {
|
||||||
|
search: string
|
||||||
|
filtered: { count: number, items: Map<string, number>, groups: Set<string> }
|
||||||
|
}
|
||||||
|
}>("Command")
|
||||||
|
|
||||||
|
export const [useCommandGroup, provideCommandGroupContext] = createContext<{
|
||||||
|
id?: string
|
||||||
|
}>("CommandGroup")
|
||||||
19
webapp/src/components/ui/dialog/Dialog.vue
Normal file
19
webapp/src/components/ui/dialog/Dialog.vue
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { DialogRootEmits, DialogRootProps } from "reka-ui"
|
||||||
|
import { DialogRoot, useForwardPropsEmits } from "reka-ui"
|
||||||
|
|
||||||
|
const props = defineProps<DialogRootProps>()
|
||||||
|
const emits = defineEmits<DialogRootEmits>()
|
||||||
|
|
||||||
|
const forwarded = useForwardPropsEmits(props, emits)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<DialogRoot
|
||||||
|
v-slot="slotProps"
|
||||||
|
data-slot="dialog"
|
||||||
|
v-bind="forwarded"
|
||||||
|
>
|
||||||
|
<slot v-bind="slotProps" />
|
||||||
|
</DialogRoot>
|
||||||
|
</template>
|
||||||
15
webapp/src/components/ui/dialog/DialogClose.vue
Normal file
15
webapp/src/components/ui/dialog/DialogClose.vue
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { DialogCloseProps } from "reka-ui"
|
||||||
|
import { DialogClose } from "reka-ui"
|
||||||
|
|
||||||
|
const props = defineProps<DialogCloseProps>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<DialogClose
|
||||||
|
data-slot="dialog-close"
|
||||||
|
v-bind="props"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</DialogClose>
|
||||||
|
</template>
|
||||||
53
webapp/src/components/ui/dialog/DialogContent.vue
Normal file
53
webapp/src/components/ui/dialog/DialogContent.vue
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { DialogContentEmits, DialogContentProps } from "reka-ui"
|
||||||
|
import type { HTMLAttributes } from "vue"
|
||||||
|
import { reactiveOmit } from "@vueuse/core"
|
||||||
|
import { X } from "lucide-vue-next"
|
||||||
|
import {
|
||||||
|
DialogClose,
|
||||||
|
DialogContent,
|
||||||
|
DialogPortal,
|
||||||
|
useForwardPropsEmits,
|
||||||
|
} from "reka-ui"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import DialogOverlay from "./DialogOverlay.vue"
|
||||||
|
|
||||||
|
defineOptions({
|
||||||
|
inheritAttrs: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<DialogContentProps & { class?: HTMLAttributes["class"], showCloseButton?: boolean }>(), {
|
||||||
|
showCloseButton: true,
|
||||||
|
})
|
||||||
|
const emits = defineEmits<DialogContentEmits>()
|
||||||
|
|
||||||
|
const delegatedProps = reactiveOmit(props, "class")
|
||||||
|
|
||||||
|
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<DialogPortal>
|
||||||
|
<DialogOverlay />
|
||||||
|
<DialogContent
|
||||||
|
data-slot="dialog-content"
|
||||||
|
v-bind="{ ...$attrs, ...forwarded }"
|
||||||
|
:class="
|
||||||
|
cn(
|
||||||
|
'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg',
|
||||||
|
props.class,
|
||||||
|
)"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
|
||||||
|
<DialogClose
|
||||||
|
v-if="showCloseButton"
|
||||||
|
data-slot="dialog-close"
|
||||||
|
class="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
|
||||||
|
>
|
||||||
|
<X />
|
||||||
|
<span class="sr-only">Close</span>
|
||||||
|
</DialogClose>
|
||||||
|
</DialogContent>
|
||||||
|
</DialogPortal>
|
||||||
|
</template>
|
||||||
23
webapp/src/components/ui/dialog/DialogDescription.vue
Normal file
23
webapp/src/components/ui/dialog/DialogDescription.vue
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { DialogDescriptionProps } from "reka-ui"
|
||||||
|
import type { HTMLAttributes } from "vue"
|
||||||
|
import { reactiveOmit } from "@vueuse/core"
|
||||||
|
import { DialogDescription, useForwardProps } from "reka-ui"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const props = defineProps<DialogDescriptionProps & { class?: HTMLAttributes["class"] }>()
|
||||||
|
|
||||||
|
const delegatedProps = reactiveOmit(props, "class")
|
||||||
|
|
||||||
|
const forwardedProps = useForwardProps(delegatedProps)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<DialogDescription
|
||||||
|
data-slot="dialog-description"
|
||||||
|
v-bind="forwardedProps"
|
||||||
|
:class="cn('text-muted-foreground text-sm', props.class)"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</DialogDescription>
|
||||||
|
</template>
|
||||||
15
webapp/src/components/ui/dialog/DialogFooter.vue
Normal file
15
webapp/src/components/ui/dialog/DialogFooter.vue
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { HTMLAttributes } from "vue"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const props = defineProps<{ class?: HTMLAttributes["class"] }>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
data-slot="dialog-footer"
|
||||||
|
:class="cn('flex flex-col-reverse gap-2 sm:flex-row sm:justify-end', props.class)"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
17
webapp/src/components/ui/dialog/DialogHeader.vue
Normal file
17
webapp/src/components/ui/dialog/DialogHeader.vue
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { HTMLAttributes } from "vue"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
class?: HTMLAttributes["class"]
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
data-slot="dialog-header"
|
||||||
|
:class="cn('flex flex-col gap-2 text-center sm:text-left', props.class)"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
21
webapp/src/components/ui/dialog/DialogOverlay.vue
Normal file
21
webapp/src/components/ui/dialog/DialogOverlay.vue
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { DialogOverlayProps } from "reka-ui"
|
||||||
|
import type { HTMLAttributes } from "vue"
|
||||||
|
import { reactiveOmit } from "@vueuse/core"
|
||||||
|
import { DialogOverlay } from "reka-ui"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const props = defineProps<DialogOverlayProps & { class?: HTMLAttributes["class"] }>()
|
||||||
|
|
||||||
|
const delegatedProps = reactiveOmit(props, "class")
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<DialogOverlay
|
||||||
|
data-slot="dialog-overlay"
|
||||||
|
v-bind="delegatedProps"
|
||||||
|
:class="cn('data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/80', props.class)"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</DialogOverlay>
|
||||||
|
</template>
|
||||||
59
webapp/src/components/ui/dialog/DialogScrollContent.vue
Normal file
59
webapp/src/components/ui/dialog/DialogScrollContent.vue
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { DialogContentEmits, DialogContentProps } from "reka-ui"
|
||||||
|
import type { HTMLAttributes } from "vue"
|
||||||
|
import { reactiveOmit } from "@vueuse/core"
|
||||||
|
import { X } from "lucide-vue-next"
|
||||||
|
import {
|
||||||
|
DialogClose,
|
||||||
|
DialogContent,
|
||||||
|
DialogOverlay,
|
||||||
|
DialogPortal,
|
||||||
|
useForwardPropsEmits,
|
||||||
|
} from "reka-ui"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
defineOptions({
|
||||||
|
inheritAttrs: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
const props = defineProps<DialogContentProps & { class?: HTMLAttributes["class"] }>()
|
||||||
|
const emits = defineEmits<DialogContentEmits>()
|
||||||
|
|
||||||
|
const delegatedProps = reactiveOmit(props, "class")
|
||||||
|
|
||||||
|
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<DialogPortal>
|
||||||
|
<DialogOverlay
|
||||||
|
class="fixed inset-0 z-50 grid place-items-center overflow-y-auto bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0"
|
||||||
|
>
|
||||||
|
<DialogContent
|
||||||
|
:class="
|
||||||
|
cn(
|
||||||
|
'relative z-50 grid w-full max-w-lg my-8 gap-4 border border-border bg-background p-6 shadow-lg duration-200 sm:rounded-lg md:w-full',
|
||||||
|
props.class,
|
||||||
|
)
|
||||||
|
"
|
||||||
|
v-bind="{ ...$attrs, ...forwarded }"
|
||||||
|
@pointer-down-outside="(event) => {
|
||||||
|
const originalEvent = event.detail.originalEvent;
|
||||||
|
const target = originalEvent.target as HTMLElement;
|
||||||
|
if (originalEvent.offsetX > target.clientWidth || originalEvent.offsetY > target.clientHeight) {
|
||||||
|
event.preventDefault();
|
||||||
|
}
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
|
||||||
|
<DialogClose
|
||||||
|
class="absolute top-4 right-4 p-0.5 transition-colors rounded-md hover:bg-secondary"
|
||||||
|
>
|
||||||
|
<X class="w-4 h-4" />
|
||||||
|
<span class="sr-only">Close</span>
|
||||||
|
</DialogClose>
|
||||||
|
</DialogContent>
|
||||||
|
</DialogOverlay>
|
||||||
|
</DialogPortal>
|
||||||
|
</template>
|
||||||
23
webapp/src/components/ui/dialog/DialogTitle.vue
Normal file
23
webapp/src/components/ui/dialog/DialogTitle.vue
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { DialogTitleProps } from "reka-ui"
|
||||||
|
import type { HTMLAttributes } from "vue"
|
||||||
|
import { reactiveOmit } from "@vueuse/core"
|
||||||
|
import { DialogTitle, useForwardProps } from "reka-ui"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const props = defineProps<DialogTitleProps & { class?: HTMLAttributes["class"] }>()
|
||||||
|
|
||||||
|
const delegatedProps = reactiveOmit(props, "class")
|
||||||
|
|
||||||
|
const forwardedProps = useForwardProps(delegatedProps)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<DialogTitle
|
||||||
|
data-slot="dialog-title"
|
||||||
|
v-bind="forwardedProps"
|
||||||
|
:class="cn('text-lg leading-none font-semibold', props.class)"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</DialogTitle>
|
||||||
|
</template>
|
||||||
15
webapp/src/components/ui/dialog/DialogTrigger.vue
Normal file
15
webapp/src/components/ui/dialog/DialogTrigger.vue
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { DialogTriggerProps } from "reka-ui"
|
||||||
|
import { DialogTrigger } from "reka-ui"
|
||||||
|
|
||||||
|
const props = defineProps<DialogTriggerProps>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<DialogTrigger
|
||||||
|
data-slot="dialog-trigger"
|
||||||
|
v-bind="props"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</DialogTrigger>
|
||||||
|
</template>
|
||||||
10
webapp/src/components/ui/dialog/index.ts
Normal file
10
webapp/src/components/ui/dialog/index.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
export { default as Dialog } from "./Dialog.vue"
|
||||||
|
export { default as DialogClose } from "./DialogClose.vue"
|
||||||
|
export { default as DialogContent } from "./DialogContent.vue"
|
||||||
|
export { default as DialogDescription } from "./DialogDescription.vue"
|
||||||
|
export { default as DialogFooter } from "./DialogFooter.vue"
|
||||||
|
export { default as DialogHeader } from "./DialogHeader.vue"
|
||||||
|
export { default as DialogOverlay } from "./DialogOverlay.vue"
|
||||||
|
export { default as DialogScrollContent } from "./DialogScrollContent.vue"
|
||||||
|
export { default as DialogTitle } from "./DialogTitle.vue"
|
||||||
|
export { default as DialogTrigger } from "./DialogTrigger.vue"
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user