66 Commits

Author SHA1 Message Date
fb7545f9a2 优化 Bing API 请求逻辑,增加语言和地区识别支持,统一传递上下文并增强 HTTP 请求头配置 2026-01-31 00:22:24 +08:00
ee814f0380 提升日志级别并增加详细日志信息,优化 Bing API 调用与数据库操作可见性 2026-01-31 00:09:55 +08:00
d3ca6fa919 优化每日图片查询逻辑,使用数据库查询替代循环调用获取多地区图片 2026-01-30 23:40:07 +08:00
49c78506b2 数据库表重新设计,精简数据结构以及存储结构 2026-01-30 23:02:59 +08:00
e40677f105 Merge pull request #2 from hanxuanyu/feature/multi-region-fetch
支持多地区数据切换,优化数据存储结构,基于图片名分类存储,避免重复下载
2026-01-30 16:44:26 +08:00
e48959d5ba 优化存储逻辑:为 WebDAV、Local 和 S3 增加 Exists 方法;调整图片处理逻辑以避免重复存储变体;新增调试日志以便于排查问题 2026-01-30 16:34:20 +08:00
852a72c597 前端构建错误修复 2026-01-30 15:56:22 +08:00
2660970320 为 Swagger 文档添加按需抓取支持:新增 enableOnDemandFetch 配置项和相关接口的 202/404 状态描述 2026-01-30 15:50:20 +08:00
fb636b9450 优化按需抓取逻辑:改为异步处理以提升性能,并为相关接口新增 202 状态支持 2026-01-30 15:49:51 +08:00
8ef66b2cb1 国家地区接口优化 2026-01-30 15:45:55 +08:00
6868a67ed7 调整悬浮信息层样式:优化字体大小、间距及适配性,以提升界面视觉一致性 2026-01-30 14:30:06 +08:00
52fb8c9328 优化图片处理逻辑:优先使用最小变体以节省流量,并新增 normalizeImageUrl 函数处理相对路径问题 2026-01-30 14:28:14 +08:00
845dc7d045 更新默认配置:将 api.mode 的默认值从 local 修改为 redirect 2026-01-30 13:41:21 +08:00
93690e10d3 增加多地区每日图片抓取能力 2026-01-30 13:33:40 +08:00
b69db53f0a GitHub Actions: 添加响应输出日志和延迟指令打印优化 2026-01-29 19:42:13 +08:00
39d4f9c730 GitHub Actions: 移除冗余的命令输出日志以简化脚本 2026-01-29 19:12:08 +08:00
cee6bc1027 GitHub Actions: 移除冗余的命令输出日志以简化脚本 2026-01-29 19:05:27 +08:00
1984e01785 GitHub Actions: 替换通知环境变量来源为 secrets,并支持基于数字的延迟指令解析 2026-01-29 19:03:51 +08:00
31f32bdb63 GitHub Actions: 优化通知脚本,添加 HTTP 状态码输出以提升调试便利性 2026-01-29 17:28:01 +08:00
9360bd0131 GitHub Actions: 替换通知环境变量来源为 vars 提高配置灵活性 2026-01-29 17:24:38 +08:00
1ca3d15c2f GitHub Actions: 添加构建成功后的通知步骤,提高流程透明度 2026-01-29 17:22:05 +08:00
e428f5bddb 修复大图查看页面空值处理问题:增加对 image 对象的可选链操作,防止因空值导致的错误显示或逻辑异常 2026-01-29 16:21:35 +08:00
c32cb8da3f 大图查看页面优化:新增图片切换淡入淡出动画,并通过预加载提升切换速度 2026-01-29 16:15:59 +08:00
5e3defc63d 移除未使用的底部控制栏高度变量 2026-01-29 12:37:59 +08:00
ea99a31248 限制日历面板在图片显示区域内拖动,并优化初始位置计算逻辑 2026-01-29 12:37:15 +08:00
86d6517267 前端页面细节优化:图片查看页面悬浮窗按钮被遮挡的问题修复;首页大图使用独立的变量控制,不受下方筛选结果影响 2026-01-29 12:37:07 +08:00
2e5eeaf425 优化首页图片的加载模式,对于日期变更但图片未更新的情况进行提示 2026-01-29 12:22:59 +08:00
7433bc2e7e 大图查看页面增加日历展示,支持显示节假日信息,提高实用性 2026-01-28 20:23:41 +08:00
8bc9b44a14 大图查看页面增加日历展示,支持显示节假日信息,提高实用性 2026-01-28 20:22:55 +08:00
617c1d0967 新增 YAML 标签支持并优化配置文件保存逻辑 2026-01-28 16:25:34 +08:00
b31711d86d 重命名docker-compose配置文件以使用“.yaml”标准扩展名,更新相关脚本引用 2026-01-28 15:53:50 +08:00
62ac723c95 新增后端管理页面 2026-01-28 15:35:01 +08:00
5334ee9d41 优化Docker配置:支持通过默认值动态设置环境变量 2026-01-28 14:22:46 +08:00
c8a7ea5490 优化Docker配置:支持通过环境变量自定义宿主机与容器端口映射 2026-01-28 13:59:37 +08:00
61de3f44dc 优化Docker配置:支持设置GOPROXY和NPM_REGISTRY构建参数 2026-01-28 13:42:26 +08:00
69abe80264 优化Docker配置:添加时区环境变量TZ,默认设置为Asia/Shanghai 2026-01-28 12:52:21 +08:00
34848e7b91 添加docker compose部署脚本 2026-01-28 12:42:28 +08:00
9ec9a2ba91 新增配置调试功能:支持输出完整配置项与环境变量覆盖详情,调整 cron.daily_spec 默认值 2026-01-28 09:08:26 +08:00
3c1f29e4ef 修复单元测试,调整 handleImageResponse 调用参数数量 2026-01-27 20:54:27 +08:00
ae82557545 调整路由逻辑,更新 API 路径前缀为 /api/v1 2026-01-27 20:45:52 +08:00
fecbd014b3 支持自定义 Cache-Control 头配置,优化图片响应缓存逻辑 2026-01-27 20:32:03 +08:00
907e158f44 更新前端:实现无限滚动加载及前后日期可用性检测 2026-01-27 18:38:49 +08:00
f7fc3fa506 修正脚本:规范二进制文件命名并优化打包目录处理 2026-01-27 17:01:51 +08:00
6052af8357 移除静态文件支持:删除静态资源目录相关代码和 Dockerfile 配置 2026-01-27 16:22:21 +08:00
e12d912a6c 优化Docker配置:更新.dockerignore文件,排除docs目录 2026-01-27 16:12:08 +08:00
35f36c3f97 修正脚本:修复 PowerShell 和批处理脚本中的中文乱码问题 2026-01-27 15:56:16 +08:00
fa0be96004 修正脚本:修复 PowerShell 和批处理脚本中的中文乱码问题 2026-01-27 15:53:48 +08:00
a0b05812d3 新增脚本:添加批处理和 PowerShell 脚本用于项目版本标签的自动化创建与推送 2026-01-27 15:53:29 +08:00
df6ca6afd4 更新前端:替换 favicon,更新页面标题和语言设置 2026-01-27 15:48:48 +08:00
6db4c6e60c 前后端构建逻辑优化:新增Node.js支持,完善Dockerfile,更新构建与验证流程 2026-01-27 15:44:27 +08:00
89bdfbd48e 前后端构建逻辑优化:新增Node.js支持,完善Dockerfile,更新构建与验证流程 2026-01-27 15:44:02 +08:00
d757dbd39d 增加更多分辨率图像的拉取 2026-01-27 15:34:38 +08:00
9c2a5d5cd8 保存更多图片元数据并同步更新前端 2026-01-27 13:52:40 +08:00
6dfffe1236 增加前后端图片传输时的缓存支持,优化前端页面 2026-01-27 13:34:23 +08:00
be0bcb4d51 前端公共部分开发完成,支持图片展示功能 2026-01-27 12:56:17 +08:00
911e58c29b 前端页面初始化更新,使用vue进行开发 2026-01-27 12:21:20 +08:00
cfd7c605af 优化数据库初始化逻辑,新增配置热更新与自动迁移支持,升级相关依赖 2026-01-27 12:20:51 +08:00
89b7f1ae3a 新增WebConfig配置支持,完善日志相关配置选项,移除对WebP格式的支持 2026-01-27 10:43:01 +08:00
e6e6d3b222 增加日志配置选项支持,优化日志初始化与数据库日志记录 2026-01-27 10:32:43 +08:00
729d335a69 数据库迁移优化,增强外键约束控制,改进日志输出 2026-01-27 10:27:25 +08:00
0fe45e3847 windows构建脚本编码格式转换 2026-01-27 09:42:08 +08:00
15cceac7e0 输出配置及存储信息,更新版权说明 2026-01-27 09:40:15 +08:00
4f8f6f3a6c 新增详细配置说明 2026-01-27 09:05:22 +08:00
40c268700e go依赖包升级 2026-01-27 09:04:28 +08:00
395534e6d8 更新 README 和工作流配置,支持 Fork 仓库用户发布版本与推送自定义镜像 2026-01-27 00:40:22 +08:00
6b5a4295b7 更新 README,添加贡献指南和版本发布流程说明 2026-01-27 00:36:18 +08:00
186 changed files with 15617 additions and 873 deletions

22
.dockerignore Normal file
View 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

View File

@@ -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

View File

@@ -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

View File

@@ -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
View File

@@ -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
View 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` (控制应用监听端口及容器内部端口)

View File

@@ -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"]

View File

@@ -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或其原始作者所有。
- **合规使用**:在使用本项目或通过本项目获取的图片时,请务必遵守微软的相关服务条款和版权声明。
- **免责声明**:本项目不对图片的版权问题承担任何法律责任。用户因违规使用图片而产生的任何纠纷,由用户自行承担。

View File

@@ -1,26 +1,31 @@
server: server:
port: 8080 port: 8080
base_url: "" base_url: ""
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: redirect
enable_mkt_fallback: false
enable_on_demand_fetch: false
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
dsn: data/bing_paper.db dsn: data/bing_paper.db
storage: storage:
type: local # local | s3 | webdav type: local
local: local:
root: data/picture root: data/picture
s3: s3:
@@ -36,15 +41,28 @@ storage:
username: "" username: ""
password: "" password: ""
public_url_prefix: "" public_url_prefix: ""
admin: admin:
password_bcrypt: "$2a$10$fYHPeWHmwObephJvtlyH1O8DIgaLk5TINbi9BOezo2M8cSjmJchka" # 默认密码: admin123 password_bcrypt: $2a$10$fYHPeWHmwObephJvtlyH1O8DIgaLk5TINbi9BOezo2M8cSjmJchka
token: token:
default_ttl: 168h default_ttl: 168h
feature: feature:
write_daily_files: true write_daily_files: true
web: web:
path: web path: web
fetcher:
regions:
- zh-CN
- en-US
- ja-JP
- en-AU
- en-GB
- de-DE
- en-NZ
- en-CA
- fr-FR
- it-IT
- es-ES
- pt-BR
- ko-KR
- en-IN
- ru-RU

17
docker-compose.yaml Normal file
View File

@@ -0,0 +1,17 @@
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}

View File

@@ -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=

View File

@@ -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": {

View File

@@ -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": {

View File

@@ -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
View File

@@ -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
View File

@@ -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=

View File

@@ -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("---------------------------------------------------------")
} }

View File

@@ -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
} }
// GetDefaultRegion 返回生效的默认地区编码
func (c *Config) GetDefaultRegion() string {
if len(c.Fetcher.Regions) > 0 {
return c.Fetcher.Regions[0]
}
return BingMkt
}

View File

@@ -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)
}
}

View File

@@ -5,81 +5,141 @@ 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 { imgRegion, 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, imgRegion, 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 { imgRegion, 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
} }
c.JSON(http.StatusOK, formatMeta(img)) if err != nil {
sendImageNotFound(c, mkt)
return
}
c.Header("Cache-Control", "public, max-age=7200") // 2小时
c.JSON(http.StatusOK, formatMeta(imgRegion))
} }
// GetRandom 获取随机图片 // GetRandom 获取随机图片
// @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]
// GetRandom 获取随机图片
func GetRandom(c *gin.Context) { func GetRandom(c *gin.Context) {
img, err := image.GetRandomImage() mkt := c.Query("mkt")
if err != nil { imgRegion, 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, imgRegion, 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 { imgRegion, 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
} }
c.JSON(http.StatusOK, formatMeta(img)) if err != nil {
sendImageNotFound(c, mkt)
return
}
c.Header("Cache-Control", "no-cache, no-store, must-revalidate")
c.JSON(http.StatusOK, formatMeta(imgRegion))
} }
// GetByDate 获取指定日期图片 // GetByDate 获取指定日期图片
@@ -87,19 +147,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 { imgRegion, 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, imgRegion, 604800) // 7天
} }
// GetByDateMeta 获取指定日期图片元数据 // GetByDateMeta 获取指定日期图片元数据
@@ -107,60 +175,155 @@ 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 { imgRegion, 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
} }
c.JSON(http.StatusOK, formatMeta(img)) if err != nil {
sendImageNotFound(c, mkt)
return
}
c.Header("Cache-Control", "public, max-age=604800") // 7天
c.JSON(http.StatusOK, formatMeta(imgRegion))
} }
// 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, m *model.ImageRegion, maxAge int) {
variant := c.DefaultQuery("variant", "UHD") variant := c.DefaultQuery("variant", "UHD")
format := c.DefaultQuery("format", "jpg") format := c.DefaultQuery("format", "jpg")
var selected *model.ImageVariant var selected *model.ImageVariant
for _, v := range img.Variants { for _, v := range m.Variants {
if v.Variant == variant && v.Format == format { if v.Variant == variant && v.Format == format {
selected = &v selected = &v
break break
} }
} }
if selected == nil && len(img.Variants) > 0 { if selected == nil && len(m.Variants) > 0 {
// 回退逻辑 // 回退逻辑
selected = &img.Variants[0] selected = &m.Variants[0]
} }
if selected == nil { if selected == nil {
@@ -171,22 +334,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 m.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", m.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, m.Date, maxAge)
} }
} else { } else {
serveLocal(c, selected.StorageKey) serveLocal(c, selected.StorageKey, m.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,18 +377,78 @@ 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 formatMeta(img *model.Image) gin.H { func formatMetaSummary(m *model.ImageRegion) gin.H {
cfg := config.GetConfig()
// 找到最小的变体
var smallest *model.ImageVariant
for i := range m.Variants {
v := &m.Variants[i]
if smallest == nil {
smallest = v
continue
}
// 如果当前变体 Size 更小且不为 0或者 smallest 的 Size 为 0
if v.Size > 0 && (smallest.Size == 0 || v.Size < smallest.Size) {
smallest = v
} else if v.Size == smallest.Size {
// 如果 Size 相同(包括都为 0根据分辨率名称判断
if compareResolution(v.Variant, smallest.Variant) < 0 {
smallest = v
}
}
}
variants := []gin.H{}
if smallest != nil {
url := smallest.PublicURL
if url == "" && cfg.API.Mode == "redirect" && m.URLBase != "" {
url = fmt.Sprintf("https://www.bing.com%s_%s.jpg", m.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, m.Date, smallest.Variant, smallest.Format, m.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": m.Date,
"mkt": m.Mkt,
"title": m.Title,
"copyright": m.Copyright,
"copyrightlink": m.CopyrightLink,
"quiz": m.Quiz,
"startdate": m.StartDate,
"fullstartdate": m.FullStartDate,
"hsh": m.HSH,
"variants": variants,
}
}
func formatMeta(m *model.ImageRegion) gin.H {
cfg := config.GetConfig() cfg := config.GetConfig()
variants := []gin.H{} variants := []gin.H{}
for _, v := range img.Variants { for _, v := range m.Variants {
url := v.PublicURL url := v.PublicURL
if url == "" && cfg.API.Mode == "redirect" && img.URLBase != "" { if url == "" && cfg.API.Mode == "redirect" && m.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", m.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, m.Date, v.Variant, v.Format, m.Mkt)
} }
variants = append(variants, gin.H{ variants = append(variants, gin.H{
"variant": v.Variant, "variant": v.Variant,
@@ -218,10 +460,109 @@ func formatMeta(img *model.Image) gin.H {
} }
return gin.H{ return gin.H{
"date": img.Date, "date": m.Date,
"title": img.Title, "mkt": m.Mkt,
"copyright": img.Copyright, "title": m.Title,
"quiz": img.Quiz, "copyright": m.Copyright,
"variants": variants, "copyrightlink": m.CopyrightLink,
"quiz": m.Quiz,
"startdate": m.StartDate,
"fullstartdate": m.FullStartDate,
"hsh": m.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)
}
// compareResolution 比较两个分辨率变体的大小。
// 返回 < 0 表示 v1 < v2返回 > 0 表示 v1 > v2返回 0 表示相等。
func compareResolution(v1, v2 string) int {
resOrder := map[string]int{
"320x240": 1,
"400x240": 2,
"480x360": 3,
"640x360": 4,
"640x480": 5,
"800x480": 6,
"800x600": 7,
"1024x768": 8,
"1280x720": 9,
"1366x768": 10,
"1920x1080": 11,
"UHD": 12,
}
o1, ok1 := resOrder[v1]
o2, ok2 := resOrder[v2]
if !ok1 && !ok2 {
return strings.Compare(v1, v2)
}
if !ok1 {
return 1 // 未知的分辨率认为比已知的大
}
if !ok2 {
return -1
}
return o1 - o2
}

View File

@@ -1,6 +1,7 @@
package handlers package handlers
import ( import (
"encoding/json"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"testing" "testing"
@@ -20,11 +21,15 @@ func TestHandleImageResponseRedirect(t *testing.T) {
config.GetConfig().API.Mode = "redirect" config.GetConfig().API.Mode = "redirect"
// Mock Image and Variant // Mock Image and Variant
img := &model.Image{ imgRegion := &model.ImageRegion{
Date: "2026-01-26", Date: "2026-01-26",
URLBase: "/th?id=OHR.TestImage", Mkt: "zh-CN",
HSH: "testhsh",
ImageName: "TestImage",
URLBase: "/th?id=OHR.TestImage",
Variants: []model.ImageVariant{ Variants: []model.ImageVariant{
{ {
ImageName: "TestImage",
Variant: "UHD", Variant: "UHD",
Format: "jpg", Format: "jpg",
PublicURL: "", // Empty for local storage simulation PublicURL: "", // Empty for local storage simulation
@@ -38,7 +43,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, imgRegion, 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")
@@ -47,7 +52,7 @@ func TestHandleImageResponseRedirect(t *testing.T) {
t.Run("FormatMeta in redirect mode should return Bing URL if PublicURL is empty", func(t *testing.T) { t.Run("FormatMeta in redirect mode should return Bing URL if PublicURL is empty", func(t *testing.T) {
config.GetConfig().API.Mode = "redirect" config.GetConfig().API.Mode = "redirect"
meta := formatMeta(img) meta := formatMeta(imgRegion)
variants := meta["variants"].([]gin.H) variants := meta["variants"].([]gin.H)
assert.Equal(t, 1, len(variants)) assert.Equal(t, 1, len(variants))
@@ -58,11 +63,66 @@ func TestHandleImageResponseRedirect(t *testing.T) {
t.Run("FormatMeta in local mode should return API URL", func(t *testing.T) { t.Run("FormatMeta in local mode should return API URL", func(t *testing.T) {
config.GetConfig().API.Mode = "local" config.GetConfig().API.Mode = "local"
config.GetConfig().Server.BaseURL = "http://myserver.com" config.GetConfig().Server.BaseURL = "http://myserver.com"
meta := formatMeta(img) meta := formatMeta(imgRegion)
variants := meta["variants"].([]gin.H) variants := meta["variants"].([]gin.H)
assert.Equal(t, 1, len(variants)) assert.Equal(t, 1, len(variants))
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.ImageRegion{
Date: "2026-01-26",
ImageName: "TestImage2",
Variants: []model.ImageVariant{
{ImageName: "TestImage2", Variant: "UHD", Size: 1000, Format: "jpg"},
{ImageName: "TestImage2", Variant: "640x480", Size: 200, Format: "jpg"},
{ImageName: "TestImage2", 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"])
})
t.Run("FormatMetaSummary should handle zero size by following order if names suggest it", func(t *testing.T) {
imgWithZeroSize := &model.ImageRegion{
Date: "2026-01-26",
ImageName: "TestImage3",
Variants: []model.ImageVariant{
{ImageName: "TestImage3", Variant: "UHD", Size: 0, Format: "jpg"},
{ImageName: "TestImage3", Variant: "320x240", Size: 0, Format: "jpg"},
},
}
meta := formatMetaSummary(imgWithZeroSize)
variants := meta["variants"].([]gin.H)
assert.Equal(t, 1, len(variants))
assert.Equal(t, "320x240", 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(), &regions)
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"])
})
} }

View File

@@ -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
} }

View File

@@ -6,24 +6,30 @@ import (
"gorm.io/gorm" "gorm.io/gorm"
) )
type Image struct { type ImageRegion 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;index:idx_mkt_date,priority:2;type:varchar(10)" json:"date"` // YYYY-MM-DD
Title string `json:"title"` Mkt string `gorm:"uniqueIndex:idx_date_mkt;index:idx_mkt_date,priority:1;type:varchar(10)" json:"mkt"` // zh-CN, en-US etc.
Copyright string `json:"copyright"` HSH string `gorm:"type:varchar(64)" json:"hsh"`
URLBase string `json:"urlbase"` URLBase string `json:"urlbase"`
Quiz string `json:"quiz"` ImageName string `gorm:"index" json:"image_name"`
CreatedAt time.Time `json:"created_at"` Title string `json:"title"`
UpdatedAt time.Time `json:"updated_at"` Copyright string `json:"copyright"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` CopyrightLink string `json:"copyrightlink"`
Variants []ImageVariant `gorm:"foreignKey:ImageID" json:"variants"` Quiz string `json:"quiz"`
StartDate string `json:"startdate"`
FullStartDate string `json:"fullstartdate"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
Variants []ImageVariant `gorm:"foreignKey:ImageName;references:ImageName" 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"` ImageName string `gorm:"uniqueIndex:idx_name_variant_format;type:varchar(100)" json:"image_name"`
Variant string `gorm:"uniqueIndex:idx_image_variant_format;type:varchar(20)" json:"variant"` // UHD, 1920x1080, etc. Variant string `gorm:"uniqueIndex:idx_name_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_name_variant_format;type:varchar(10)" json:"format"` // jpg, webp
StorageKey string `json:"storage_key"` StorageKey string `json:"storage_key"`
PublicURL string `json:"public_url"` PublicURL string `json:"public_url"`
Size int64 `json:"size"` Size int64 `json:"size"`

View File

@@ -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.ImageRegion{}, &model.ImageVariant{}, &model.Token{}); err != nil {
util.Logger.Error("Database migration failed", zap.Error(err))
return err return err
} }

View File

@@ -0,0 +1,92 @@
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.ImageRegion{}, &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.ImageRegion{}).Error; err != nil {
return fmt.Errorf("failed to clear ImageRegions: %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 {
// 迁移 ImageRegions
var regions []model.ImageRegion
if err := oldDB.Find(&regions).Error; err != nil {
return fmt.Errorf("failed to fetch image regions from old DB: %w", err)
}
if len(regions) > 0 {
util.Logger.Info("Migrating image regions", zap.Int("count", len(regions)))
if err := tx.Create(&regions).Error; err != nil {
return fmt.Errorf("failed to insert image regions 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
})
}

View File

@@ -11,6 +11,7 @@ import (
"net/http" "net/http"
"os" "os"
"path/filepath" "path/filepath"
"strings"
"time" "time"
"BingPaper/internal/config" "BingPaper/internal/config"
@@ -18,8 +19,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 +39,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 +56,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().GetDefaultRegion()}
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,98 +71,238 @@ 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 抓取指定地区的图片
dateStr := fmt.Sprintf("%s-%s-%s", bingImg.Enddate[0:4], bingImg.Enddate[4:6], bingImg.Enddate[6:8]) 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))
var existing model.Image return fmt.Errorf("invalid region code: %s", mkt)
if err := repo.DB.Where("date = ?", dateStr).First(&existing).Error; err == nil {
util.Logger.Info("Image already exists, skipping", zap.String("date", dateStr))
return nil
} }
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
}
util.Logger.Info("Processing new image", zap.String("date", dateStr), zap.String("title", bingImg.Title)) func (f *Fetcher) fetchByMkt(ctx context.Context, mkt string, idx int, n int) error {
lang := strings.Split(mkt, "-")[0]
url := fmt.Sprintf("%s?format=js&idx=%d&n=%d&uhd=1&mkt=%s&setlang=%s", config.BingAPIBase, idx, n, mkt, lang)
util.Logger.Info("Requesting Bing API", zap.String("url", url))
// UHD 探测 req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
imgURL, variantName := f.probeUHD(bingImg.URLBase)
imgData, err := f.downloadImage(imgURL)
if err != nil { if err != nil {
util.Logger.Error("Failed to create Bing API request", zap.Error(err))
return err return err
} }
// 解码图片用于缩放 // 添加请求头以增强地区/语言识别
srcImg, _, err := image.Decode(bytes.NewReader(imgData)) req.Header.Set("Accept-Language", fmt.Sprintf("%s,%s;q=0.9", mkt, lang))
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36")
resp, err := f.httpClient.Do(req)
if err != nil { if err != nil {
util.Logger.Error("Failed to request Bing API", zap.Error(err))
return err
}
defer resp.Body.Close()
util.Logger.Info("Received response from Bing API", zap.String("mkt", mkt), zap.Int("status", resp.StatusCode))
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 return err
} }
// 创建 DB 记录 util.Logger.Info("Fetched images from Bing", zap.String("mkt", mkt), zap.Int("count", len(bingResp.Images)))
dbImg := model.Image{
Date: dateStr,
Title: bingImg.Title,
Copyright: bingImg.Copyright,
URLBase: bingImg.URLBase,
Quiz: bingImg.Quiz,
}
if err := repo.DB.Create(&dbImg).Error; err != nil { for _, bingImg := range bingResp.Images {
return err util.Logger.Info("Bing image metadata",
} zap.String("mkt", mkt),
zap.String("date", bingImg.Enddate),
zap.String("title", bingImg.Title),
zap.String("hsh", bingImg.HSH))
// 保存各种分辨率 if err := f.processImage(ctx, bingImg, mkt); err != nil {
variants := []struct { util.Logger.Error("Failed to process image", zap.String("date", bingImg.Enddate), zap.String("mkt", mkt), zap.Error(err))
name string
width int
height int
}{
{variantName, 0, 0}, // 原图 (UHD 或 1080p)
{"1920x1080", 1920, 1080},
{"1366x768", 1366, 768},
}
for _, v := range variants {
// 如果是探测到的最高清版本,且我们已经有了数据,直接使用
var currentImgData []byte
if v.width == 0 {
currentImgData = imgData
} else {
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: 90}); err != nil {
util.Logger.Warn("Failed to encode jpeg", zap.String("variant", v.name), zap.Error(err))
continue
}
currentImgData = buf.Bytes()
} }
// 保存 JPG
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))
}
}
// 保存今日额外文件
today := time.Now().Format("2006-01-02")
if dateStr == today && config.GetConfig().Feature.WriteDailyFiles {
f.saveDailyFiles(srcImg, imgData)
} }
return nil return nil
} }
func (f *Fetcher) probeUHD(urlBase string) (string, string) { 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])
// 1. 地区关联幂等检查
var existingRegion model.ImageRegion
if err := repo.DB.Where("date = ? AND mkt = ?", dateStr, mkt).First(&existingRegion).Error; err == nil {
util.Logger.Info("ImageRegion record already exists, skipping", zap.String("date", dateStr), zap.String("mkt", mkt), zap.String("title", bingImg.Title))
return nil
}
imageName := f.extractImageName(bingImg.URLBase, bingImg.HSH)
// 2. 处理变体
imgURL, variantName := f.probeUHD(ctx, bingImg.URLBase)
targetVariants := []struct {
name string
width int
height int
}{
{"1920x1080", 1920, 1080},
{"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},
}
// 检查变体是否已存在 (通过 ImageName)
var existingVariants []model.ImageVariant
repo.DB.Where("image_name = ?", imageName).Find(&existingVariants)
allVariantsExist := len(existingVariants) > 0
var srcImg image.Image
var imgData []byte
if allVariantsExist {
util.Logger.Debug("Image variants already exist for name, linking only", zap.String("imageName", imageName))
} else {
util.Logger.Debug("Downloading and processing image", zap.String("url", imgURL), zap.String("imageName", imageName))
var err error
imgData, err = f.downloadImage(ctx, imgURL)
if err != nil {
util.Logger.Error("Failed to download image", zap.String("url", imgURL), zap.Error(err))
return err
}
srcImg, _, err = image.Decode(bytes.NewReader(imgData))
if err != nil {
util.Logger.Error("Failed to decode image data", zap.Error(err))
return err
}
// 保存原图变体
if err := f.saveVariant(ctx, imageName, variantName, "jpg", imgData); err != nil {
util.Logger.Error("Failed to save original variant", zap.String("variant", variantName), zap.Error(err))
}
for _, v := range targetVariants {
if v.name == variantName {
continue
}
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()
if err := f.saveVariant(ctx, imageName, v.name, "jpg", currentImgData); err != nil {
util.Logger.Error("Failed to save variant", zap.String("variant", v.name), zap.Error(err))
}
}
}
// 3. 创建 ImageRegion 记录
regionRecord := model.ImageRegion{
HSH: bingImg.HSH,
URLBase: bingImg.URLBase,
ImageName: imageName,
Date: dateStr,
Mkt: mkt,
Title: bingImg.Title,
Copyright: bingImg.Copyright,
CopyrightLink: bingImg.CopyrightLink,
Quiz: bingImg.Quiz,
StartDate: bingImg.Startdate,
FullStartDate: bingImg.Fullstartdate,
}
if err := repo.DB.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "date"}, {Name: "mkt"}},
UpdateAll: true,
}).Create(&regionRecord).Error; err != nil {
util.Logger.Error("Failed to create region record", zap.Error(err))
return err
}
util.Logger.Info("Successfully saved/updated ImageRegion record to database",
zap.String("date", dateStr),
zap.String("mkt", mkt),
zap.String("title", regionRecord.Title))
// 4. 保存今日额外文件
today := time.Now().Format("2006-01-02")
if dateStr == today && config.GetConfig().Feature.WriteDailyFiles {
if imgData != nil && srcImg != nil {
f.saveDailyFiles(srcImg, imgData, mkt)
}
}
return nil
}
func (f *Fetcher) extractImageName(urlBase, hsh string) string {
// 示例: /th?id=OHR.MilwaukeeHall_ROW0871854348
start := 0
if idx := strings.Index(urlBase, "OHR."); idx != -1 {
start = idx + 4
} else if idx := strings.Index(urlBase, "id="); idx != -1 {
start = idx + 3
}
rem := urlBase[start:]
end := strings.Index(rem, "_")
if end == -1 {
end = len(rem)
}
name := rem[:end]
if name == "" {
return hsh
}
return name
}
func (f *Fetcher) probeUHD(ctx context.Context, urlBase string) (string, string) {
uhdURL := fmt.Sprintf("https://www.bing.com%s_UHD.jpg", urlBase) uhdURL := fmt.Sprintf("https://www.bing.com%s_UHD.jpg", urlBase)
resp, err := f.httpClient.Head(uhdURL) req, err := http.NewRequestWithContext(ctx, "HEAD", uhdURL, nil)
if err != nil {
return fmt.Sprintf("https://www.bing.com%s_1920x1080.jpg", urlBase), "1920x1080"
}
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36")
resp, err := f.httpClient.Do(req)
if err == nil && resp.StatusCode == http.StatusOK { if err == nil && resp.StatusCode == http.StatusOK {
return uhdURL, "UHD" return uhdURL, "UHD"
} }
return fmt.Sprintf("https://www.bing.com%s_1920x1080.jpg", urlBase), "1920x1080" return fmt.Sprintf("https://www.bing.com%s_1920x1080.jpg", urlBase), "1920x1080"
} }
func (f *Fetcher) downloadImage(url string) ([]byte, error) { func (f *Fetcher) downloadImage(ctx context.Context, url string) ([]byte, error) {
resp, err := f.httpClient.Get(url) req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
if err != nil {
return nil, err
}
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36")
resp, err := f.httpClient.Do(req)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -173,48 +310,108 @@ func (f *Fetcher) downloadImage(url string) ([]byte, error) {
return io.ReadAll(resp.Body) return io.ReadAll(resp.Body)
} }
func (f *Fetcher) saveVariant(ctx context.Context, img *model.Image, variant, format string, data []byte) error { func (f *Fetcher) generateKey(imageName, variant, format string) string {
key := fmt.Sprintf("%s/%s_%s.%s", img.Date, img.Date, variant, format) return fmt.Sprintf("%s/%s_%s.%s", imageName, imageName, variant, format)
}
func (f *Fetcher) saveVariant(ctx context.Context, imageName, variant, format string, data []byte) error {
key := f.generateKey(imageName, variant, format)
contentType := "image/jpeg" contentType := "image/jpeg"
if format == "webp" { if format == "webp" {
contentType = "image/webp" contentType = "image/webp"
} }
stored, err := storage.GlobalStorage.Put(ctx, key, bytes.NewReader(data), contentType) var size int64
var publicURL string
exists, _ := storage.GlobalStorage.Exists(ctx, key)
if exists {
util.Logger.Debug("Variant already exists in storage, linking", zap.String("key", key))
// 如果存在,尝试获取公共 URL
if pURL, ok := storage.GlobalStorage.PublicURL(key); ok {
publicURL = pURL
}
// 如果传入了数据,则使用数据大小
if data != nil {
size = int64(len(data))
}
} else if data != nil {
util.Logger.Debug("Saving variant to storage", zap.String("key", key))
stored, err := storage.GlobalStorage.Put(ctx, key, bytes.NewReader(data), contentType)
if err != nil {
return err
}
publicURL = stored.PublicURL
size = stored.Size
} else {
return fmt.Errorf("variant %s does not exist and no data provided", key)
}
vRecord := model.ImageVariant{
ImageName: imageName,
Variant: variant,
Format: format,
StorageKey: key,
PublicURL: publicURL,
Size: size,
}
err := repo.DB.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "image_name"}, {Name: "variant"}, {Name: "format"}},
DoNothing: true,
}).Create(&vRecord).Error
if err != nil { if err != nil {
return err return err
} }
vRecord := model.ImageVariant{ util.Logger.Info("Successfully saved ImageVariant record to database",
ImageID: img.ID, zap.String("image_name", imageName),
Variant: variant, zap.String("variant", variant),
Format: format, zap.String("format", format))
StorageKey: stored.Key,
PublicURL: stored.PublicURL,
Size: int64(len(data)),
}
return repo.DB.Create(&vRecord).Error return nil
} }
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().GetDefaultRegion() {
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)
}
} }

View File

@@ -2,18 +2,24 @@ package image
import ( import (
"context" "context"
"errors"
"fmt" "fmt"
"math/rand"
"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"
"gorm.io/gorm"
) )
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 {
@@ -23,70 +29,203 @@ func CleanupOldImages(ctx context.Context) error {
threshold := time.Now().AddDate(0, 0, -days).Format("2006-01-02") threshold := time.Now().AddDate(0, 0, -days).Format("2006-01-02")
util.Logger.Info("Starting cleanup task", zap.Int("retention_days", days), zap.String("threshold", threshold)) util.Logger.Info("Starting cleanup task", zap.Int("retention_days", days), zap.String("threshold", threshold))
var images []model.Image var regionRecords []model.ImageRegion
if err := repo.DB.Where("date < ?", threshold).Preload("Variants").Find(&images).Error; err != nil { if err := repo.DB.Where("date < ?", threshold).Preload("Variants").Find(&regionRecords).Error; err != nil {
util.Logger.Error("Failed to query old image regions for cleanup", zap.Error(err))
return err return err
} }
for _, img := range images { for _, m := range regionRecords {
util.Logger.Info("Deleting old image", zap.String("date", img.Date)) util.Logger.Info("Deleting old image region record", zap.String("date", m.Date), zap.String("mkt", m.Mkt))
for _, v := range img.Variants {
if err := storage.GlobalStorage.Delete(ctx, v.StorageKey); err != nil { // 检查该图片名是否还有其他地区或日期在使用
util.Logger.Warn("Failed to delete storage object", zap.String("key", v.StorageKey), zap.Error(err)) var count int64
repo.DB.Model(&model.ImageRegion{}).Where("image_name = ? AND id != ?", m.ImageName, m.ID).Count(&count)
if count == 0 {
util.Logger.Info("Image content no longer referenced, deleting files and variants", zap.String("image_name", m.ImageName))
for _, v := range m.Variants {
if err := storage.GlobalStorage.Delete(ctx, v.StorageKey); err != nil {
util.Logger.Warn("Failed to delete storage object", zap.String("key", v.StorageKey), zap.Error(err))
}
}
// 删除变体记录
if err := repo.DB.Where("image_name = ?", m.ImageName).Delete(&model.ImageVariant{}).Error; err != nil {
util.Logger.Error("Failed to delete variants", zap.String("image_name", m.ImageName), zap.Error(err))
} }
} }
// 删除 DB 记录 (级联删除由代码处理,或者 GORM 会处理已加载的关联吗?)
// 简单起见,手动删除关联 // 删除地区记录
repo.DB.Where("image_id = ?", img.ID).Delete(&model.ImageVariant{}) if err := repo.DB.Delete(&m).Error; err != nil {
repo.DB.Delete(&img) util.Logger.Error("Failed to delete image region record", zap.Uint("id", m.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(regionRecords)))
return nil return nil
} }
func GetTodayImage() (*model.Image, error) { func GetTodayImage(mkt string) (*model.ImageRegion, error) {
today := time.Now().Format("2006-01-02") today := time.Now().Format("2006-01-02")
var img model.Image util.Logger.Debug("Getting today image", zap.String("mkt", mkt), zap.String("today", today))
err := repo.DB.Where("date = ?", today).Preload("Variants").First(&img).Error var imgRegion model.ImageRegion
if err != nil { tx := repo.DB.Where("date = ?", today)
// 如果今天没有,尝试获取最近的一张 if mkt != "" {
err = repo.DB.Order("date desc").Preload("Variants").First(&img).Error tx = tx.Where("mkt = ?", mkt)
} }
return &img, err err := tx.Preload("Variants", func(db *gorm.DB) *gorm.DB {
return db.Order("size asc")
}).First(&imgRegion).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 {
util.Logger.Debug("Today image not found, trying latest image", zap.String("mkt", mkt))
// 如果今天还是没有,尝试获取最近的一张
tx = repo.DB.Order("date desc")
if mkt != "" {
tx = tx.Where("mkt = ?", mkt)
}
err = tx.Preload("Variants", func(db *gorm.DB) *gorm.DB {
return db.Order("size asc")
}).First(&imgRegion).Error
}
// 兜底逻辑
if err != nil && mkt != "" && config.GetConfig().API.EnableMktFallback {
defaultMkt := config.GetConfig().GetDefaultRegion()
util.Logger.Debug("Image not found, trying fallback to default region", zap.String("mkt", mkt), zap.String("defaultMkt", defaultMkt))
if mkt != defaultMkt {
return GetTodayImage(defaultMkt)
}
return GetTodayImage("")
}
if err == nil {
util.Logger.Debug("Found image region record", zap.String("date", imgRegion.Date), zap.String("mkt", imgRegion.Mkt))
}
return &imgRegion, err
} }
func GetRandomImage() (*model.Image, error) { func GetAllRegionsTodayImages() ([]model.ImageRegion, error) {
var img model.Image today := time.Now().Format("2006-01-02")
// SQLite 使用 RANDOM(), MySQL/Postgres 使用 RANDOM() 或 RAND() regions := config.GetConfig().Fetcher.Regions
// 简单起见,先查总数再 Offset if len(regions) == 0 {
regions = []string{config.GetConfig().GetDefaultRegion()}
}
var images []model.ImageRegion
err := repo.DB.Where("date = ? AND mkt IN ?", today, regions).
Preload("Variants", func(db *gorm.DB) *gorm.DB {
return db.Order("size asc")
}).Find(&images).Error
return images, err
}
func GetRandomImage(mkt string) (*model.ImageRegion, error) {
util.Logger.Debug("Getting random image", zap.String("mkt", mkt))
var imgRegion model.ImageRegion
var count int64 var count int64
repo.DB.Model(&model.Image{}).Count(&count) tx := repo.DB.Model(&model.ImageRegion{})
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 天的数据没问题 offset := rand.Intn(int(count))
err := repo.DB.Order("RANDOM()").Preload("Variants").First(&img).Error util.Logger.Debug("Random image selection", zap.Int64("total", count), zap.Int("offset", offset))
if err != nil { err := tx.Preload("Variants", func(db *gorm.DB) *gorm.DB {
// 适配 MySQL return db.Order("size asc")
err = repo.DB.Order("RAND()").Preload("Variants").First(&img).Error }).Offset(offset).Limit(1).Find(&imgRegion).Error
if (err != nil || imgRegion.ID == 0) && mkt != "" && config.GetConfig().API.EnableMktFallback {
defaultMkt := config.GetConfig().GetDefaultRegion()
util.Logger.Debug("Random image not found, trying fallback", zap.String("mkt", mkt), zap.String("defaultMkt", defaultMkt))
if mkt != defaultMkt {
return GetRandomImage(defaultMkt)
}
return GetRandomImage("")
} }
return &img, err
if err == nil && imgRegion.ID == 0 {
return nil, fmt.Errorf("no images found")
}
return &imgRegion, err
} }
func GetImageByDate(date string) (*model.Image, error) { func GetImageByDate(date string, mkt string) (*model.ImageRegion, error) {
var img model.Image util.Logger.Debug("Getting image by date", zap.String("date", date), zap.String("mkt", mkt))
err := repo.DB.Where("date = ?", date).Preload("Variants").First(&img).Error var imgRegion model.ImageRegion
return &img, err tx := repo.DB.Where("date = ?", date)
if mkt != "" {
tx = tx.Where("mkt = ?", mkt)
}
err := tx.Preload("Variants", func(db *gorm.DB) *gorm.DB {
return db.Order("size asc")
}).First(&imgRegion).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().GetDefaultRegion()
if mkt != defaultMkt {
return GetImageByDate(date, defaultMkt)
}
return GetImageByDate(date, "")
}
return &imgRegion, err
} }
func GetImageList(limit int) ([]model.Image, error) { func GetImageList(limit int, offset int, month string, mkt string) ([]model.ImageRegion, error) {
var images []model.Image var images []model.ImageRegion
db := repo.DB.Order("date desc").Preload("Variants") tx := repo.DB.Model(&model.ImageRegion{})
if month != "" {
tx = tx.Where("date LIKE ?", month+"%")
}
if mkt != "" {
tx = tx.Where("mkt = ?", mkt)
}
tx = tx.Order("date desc").Preload("Variants", func(db *gorm.DB) *gorm.DB {
return db.Order("size asc")
})
if limit > 0 { if limit > 0 {
db = db.Limit(limit) tx = tx.Limit(limit)
} }
err := db.Find(&images).Error if offset > 0 {
tx = tx.Offset(offset)
}
err := tx.Find(&images).Error
return images, err return images, err
} }

View File

@@ -63,3 +63,15 @@ func (l *LocalStorage) Delete(ctx context.Context, key string) error {
func (l *LocalStorage) PublicURL(key string) (string, bool) { func (l *LocalStorage) PublicURL(key string) (string, bool) {
return "", false return "", false
} }
func (l *LocalStorage) Exists(ctx context.Context, key string) (bool, error) {
path := filepath.Join(l.root, key)
_, err := os.Stat(path)
if err == nil {
return true, nil
}
if os.IsNotExist(err) {
return false, nil
}
return false, err
}

View File

@@ -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),
}) })
@@ -93,3 +100,18 @@ func (s *S3Storage) PublicURL(key string) (string, bool) {
// 也可以生成签名 URL但这里简单处理 // 也可以生成签名 URL但这里简单处理
return "", false return "", false
} }
func (s *S3Storage) Exists(ctx context.Context, key string) (bool, error) {
_, err := s.client.HeadObject(ctx, &s3.HeadObjectInput{
Bucket: aws.String(s.bucket),
Key: aws.String(key),
})
if err != nil {
// 判断是否为 404
if strings.Contains(err.Error(), "NotFound") || strings.Contains(err.Error(), "404") {
return false, nil
}
return false, err
}
return true, nil
}

View File

@@ -17,6 +17,7 @@ type Storage interface {
Get(ctx context.Context, key string) (io.ReadCloser, string, error) Get(ctx context.Context, key string) (io.ReadCloser, string, error)
Delete(ctx context.Context, key string) error Delete(ctx context.Context, key string) error
PublicURL(key string) (string, bool) PublicURL(key string) (string, bool)
Exists(ctx context.Context, key string) (bool, error)
} }
var GlobalStorage Storage var GlobalStorage Storage

View File

@@ -72,3 +72,16 @@ func (w *WebDAVStorage) PublicURL(key string) (string, bool) {
} }
return "", false return "", false
} }
func (w *WebDAVStorage) Exists(ctx context.Context, key string) (bool, error) {
_, err := w.client.Stat(key)
if err == nil {
return true, nil
}
// gowebdav 的错误处理比较原始,通常 404 会返回错误
// 这里假设报错就是不存在,或者可以根据错误消息判断
if strings.Contains(err.Error(), "404") || strings.Contains(err.Error(), "not found") {
return false, nil
}
return false, err
}

View File

@@ -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
View 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: "俄罗斯"},
}

View File

@@ -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

View File

@@ -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

View File

@@ -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
View 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
View 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
View 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."

View File

@@ -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
View File

@@ -0,0 +1,6 @@
# 默认环境配置
# API 基础 URL - 默认使用相对路径访问
VITE_API_BASE_URL=/api/v1
# API 模式
VITE_API_MODE=relative

6
webapp/.env.development Normal file
View 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
View File

@@ -0,0 +1,6 @@
# 生产环境配置
# API 基础 URL - 生产环境使用相对路径
VITE_API_BASE_URL=/api/v1
# API 模式:生产环境使用相对路径
VITE_API_MODE=relative

35
webapp/.gitignore vendored Normal file
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

13
webapp/index.html Normal file
View 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

File diff suppressed because it is too large Load Diff

39
webapp/package.json Normal file
View 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"
}
}

View 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
View 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>

View 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

View 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>

View 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>

View 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>

View 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>

View File

@@ -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>

View 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>

View 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>

View 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>

View 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>

View 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"

View 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>

View 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>

View 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>

View 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"

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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"

View 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>

View File

@@ -0,0 +1 @@
export { default as Checkbox } from "./Checkbox.vue"

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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")

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

Some files were not shown because too many files have changed in this diff Show More