23 Commits

Author SHA1 Message Date
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
168 changed files with 10674 additions and 621 deletions

22
.dockerignore Normal file
View File

@@ -0,0 +1,22 @@
.git
.github
node_modules
**/node_modules
data
output
scripts
# docs
*.md
docker-compose.yml
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

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/

103
CONFIG.md Normal file
View File

@@ -0,0 +1,103 @@
# 配置指南
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 的公共访问。
#### cron (定时任务)
- `enabled`: 是否启用定时抓取,默认 `true`
- `daily_spec`: Cron 表达式,定义每日抓取时间。默认 `"0 10 * * *"` (每日上午 10:00)。
#### 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$..."`

View File

@@ -1,19 +1,43 @@
FROM golang:1.25.5-alpine AS builder # Stage 1: Build Frontend
FROM --platform=$BUILDPLATFORM node:20-alpine AS node-builder
WORKDIR /webapp
# 复制 package.json 和 lock 文件以利用 layer 缓存
COPY webapp/package*.json ./
# 使用 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
# 安装 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
@@ -90,12 +90,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 +144,11 @@ docker-compose up -d
## 许可证 ## 许可证
MIT MIT
## 版权说明
本项目提供的必应每日一图抓取功能仅供个人学习、研究及壁纸设定等非商业用途。
- **图片版权**图片版权归微软Microsoft或其原始作者所有。
- **合规使用**:在使用本项目或通过本项目获取的图片时,请务必遵守微软的相关服务条款和版权声明。
- **免责声明**:本项目不对图片的版权问题承担任何法律责任。用户因违规使用图片而产生的任何纠纷,由用户自行承担。

View File

@@ -4,6 +4,15 @@ server:
log: log:
level: info level: info
filename: data/logs/app.log
db_filename: data/logs/db.log
max_size: 100
max_backups: 3
max_age: 7
compress: true
log_console: true
show_db_log: false
db_log_level: info
api: api:
mode: local # local | redirect mode: local # local | redirect
@@ -13,7 +22,7 @@ cron:
daily_spec: "0 10 * * *" daily_spec: "0 10 * * *"
retention: retention:
days: 30 days: 0
db: db:
type: sqlite # sqlite | mysql | postgres type: sqlite # sqlite | mysql | postgres

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"
@@ -462,8 +461,7 @@ const docTemplate = `{
"200": { "200": {
"description": "OK", "description": "OK",
"schema": { "schema": {
"type": "object", "$ref": "#/definitions/handlers.ImageMetaResp"
"additionalProperties": true
} }
} }
} }
@@ -473,8 +471,7 @@ const docTemplate = `{
"get": { "get": {
"description": "随机返回一张已抓取的图片流或重定向", "description": "随机返回一张已抓取的图片流或重定向",
"produces": [ "produces": [
"image/jpeg", "image/jpeg"
"image/webp"
], ],
"tags": [ "tags": [
"image" "image"
@@ -520,8 +517,7 @@ const docTemplate = `{
"200": { "200": {
"description": "OK", "description": "OK",
"schema": { "schema": {
"type": "object", "$ref": "#/definitions/handlers.ImageMetaResp"
"additionalProperties": true
} }
} }
} }
@@ -531,8 +527,7 @@ const docTemplate = `{
"get": { "get": {
"description": "根据参数返回今日必应图片流或重定向", "description": "根据参数返回今日必应图片流或重定向",
"produces": [ "produces": [
"image/jpeg", "image/jpeg"
"image/webp"
], ],
"tags": [ "tags": [
"image" "image"
@@ -542,14 +537,14 @@ const docTemplate = `{
{ {
"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"
} }
@@ -578,8 +573,7 @@ const docTemplate = `{
"200": { "200": {
"description": "OK", "description": "OK",
"schema": { "schema": {
"type": "object", "$ref": "#/definitions/handlers.ImageMetaResp"
"additionalProperties": true
} }
} }
} }
@@ -587,7 +581,7 @@ const docTemplate = `{
}, },
"/images": { "/images": {
"get": { "get": {
"description": "分页获取已抓取的图片元数据列表", "description": "分页获取已抓取的图片元数据列表。支持分页(page, page_size)、限制数量(limit)和按月份过滤(month, 格式: YYYY-MM)。",
"produces": [ "produces": [
"application/json" "application/json"
], ],
@@ -599,9 +593,27 @@ 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"
} }
], ],
"responses": { "responses": {
@@ -610,8 +622,7 @@ const docTemplate = `{
"schema": { "schema": {
"type": "array", "type": "array",
"items": { "items": {
"type": "object", "$ref": "#/definitions/handlers.ImageMetaResp"
"additionalProperties": true
} }
} }
} }
@@ -669,6 +680,9 @@ const docTemplate = `{
}, },
"token": { "token": {
"$ref": "#/definitions/config.TokenConfig" "$ref": "#/definitions/config.TokenConfig"
},
"web": {
"$ref": "#/definitions/config.WebConfig"
} }
} }
}, },
@@ -714,8 +728,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 +840,14 @@ const docTemplate = `{
} }
} }
}, },
"config.WebConfig": {
"type": "object",
"properties": {
"path": {
"type": "string"
}
}
},
"config.WebDAVConfig": { "config.WebDAVConfig": {
"type": "object", "type": "object",
"properties": { "properties": {
@@ -841,6 +899,61 @@ const docTemplate = `{
} }
} }
}, },
"handlers.ImageMetaResp": {
"type": "object",
"properties": {
"copyright": {
"type": "string"
},
"copyrightlink": {
"type": "string"
},
"date": {
"type": "string"
},
"fullstartdate": {
"type": "string"
},
"hsh": {
"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": [

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"
@@ -456,8 +455,7 @@
"200": { "200": {
"description": "OK", "description": "OK",
"schema": { "schema": {
"type": "object", "$ref": "#/definitions/handlers.ImageMetaResp"
"additionalProperties": true
} }
} }
} }
@@ -467,8 +465,7 @@
"get": { "get": {
"description": "随机返回一张已抓取的图片流或重定向", "description": "随机返回一张已抓取的图片流或重定向",
"produces": [ "produces": [
"image/jpeg", "image/jpeg"
"image/webp"
], ],
"tags": [ "tags": [
"image" "image"
@@ -514,8 +511,7 @@
"200": { "200": {
"description": "OK", "description": "OK",
"schema": { "schema": {
"type": "object", "$ref": "#/definitions/handlers.ImageMetaResp"
"additionalProperties": true
} }
} }
} }
@@ -525,8 +521,7 @@
"get": { "get": {
"description": "根据参数返回今日必应图片流或重定向", "description": "根据参数返回今日必应图片流或重定向",
"produces": [ "produces": [
"image/jpeg", "image/jpeg"
"image/webp"
], ],
"tags": [ "tags": [
"image" "image"
@@ -536,14 +531,14 @@
{ {
"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"
} }
@@ -572,8 +567,7 @@
"200": { "200": {
"description": "OK", "description": "OK",
"schema": { "schema": {
"type": "object", "$ref": "#/definitions/handlers.ImageMetaResp"
"additionalProperties": true
} }
} }
} }
@@ -581,7 +575,7 @@
}, },
"/images": { "/images": {
"get": { "get": {
"description": "分页获取已抓取的图片元数据列表", "description": "分页获取已抓取的图片元数据列表。支持分页(page, page_size)、限制数量(limit)和按月份过滤(month, 格式: YYYY-MM)。",
"produces": [ "produces": [
"application/json" "application/json"
], ],
@@ -593,9 +587,27 @@
{ {
"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"
} }
], ],
"responses": { "responses": {
@@ -604,8 +616,7 @@
"schema": { "schema": {
"type": "array", "type": "array",
"items": { "items": {
"type": "object", "$ref": "#/definitions/handlers.ImageMetaResp"
"additionalProperties": true
} }
} }
} }
@@ -663,6 +674,9 @@
}, },
"token": { "token": {
"$ref": "#/definitions/config.TokenConfig" "$ref": "#/definitions/config.TokenConfig"
},
"web": {
"$ref": "#/definitions/config.WebConfig"
} }
} }
}, },
@@ -708,8 +722,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 +834,14 @@
} }
} }
}, },
"config.WebConfig": {
"type": "object",
"properties": {
"path": {
"type": "string"
}
}
},
"config.WebDAVConfig": { "config.WebDAVConfig": {
"type": "object", "type": "object",
"properties": { "properties": {
@@ -835,6 +893,61 @@
} }
} }
}, },
"handlers.ImageMetaResp": {
"type": "object",
"properties": {
"copyright": {
"type": "string"
},
"copyrightlink": {
"type": "string"
},
"date": {
"type": "string"
},
"fullstartdate": {
"type": "string"
},
"hsh": {
"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": [

View File

@@ -33,6 +33,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:
@@ -61,8 +63,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 +139,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 +178,42 @@ 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
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:
@@ -443,7 +513,6 @@ paths:
type: string type: string
produces: produces:
- image/jpeg - image/jpeg
- image/webp
responses: responses:
"200": "200":
description: OK description: OK
@@ -467,8 +536,7 @@ paths:
"200": "200":
description: OK description: OK
schema: schema:
additionalProperties: true $ref: '#/definitions/handlers.ImageMetaResp'
type: object
summary: 获取指定日期图片元数据 summary: 获取指定日期图片元数据
tags: tags:
- image - image
@@ -488,7 +556,6 @@ paths:
type: string type: string
produces: produces:
- image/jpeg - image/jpeg
- image/webp
responses: responses:
"200": "200":
description: OK description: OK
@@ -506,8 +573,7 @@ paths:
"200": "200":
description: OK description: OK
schema: schema:
additionalProperties: true $ref: '#/definitions/handlers.ImageMetaResp'
type: object
summary: 获取随机图片元数据 summary: 获取随机图片元数据
tags: tags:
- image - image
@@ -516,18 +582,18 @@ paths:
description: 根据参数返回今日必应图片流或重定向 description: 根据参数返回今日必应图片流或重定向
parameters: parameters:
- 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
@@ -545,20 +611,32 @@ paths:
"200": "200":
description: OK description: OK
schema: schema:
additionalProperties: true $ref: '#/definitions/handlers.ImageMetaResp'
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
produces: produces:
- application/json - application/json
responses: responses:
@@ -566,8 +644,7 @@ 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:

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

@@ -34,13 +34,47 @@ 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)
// 输出配置信息
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))
// 根据存储类型输出更多信息
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

View File

@@ -32,8 +32,28 @@ type ServerConfig struct {
type LogConfig struct { type LogConfig struct {
Level string `mapstructure:"level"` Level string `mapstructure:"level"`
Filename string `mapstructure:"filename"` // 业务日志文件名
DBFilename string `mapstructure:"db_filename"` // 数据库日志文件名
MaxSize int `mapstructure:"max_size"` // 每个日志文件最大大小 (MB)
MaxBackups int `mapstructure:"max_backups"` // 保留旧日志文件最大个数
MaxAge int `mapstructure:"max_age"` // 保留旧日志文件最大天数
Compress bool `mapstructure:"compress"` // 是否压缩旧日志文件
LogConsole bool `mapstructure:"log_console"` // 是否同时输出到控制台
ShowDBLog bool `mapstructure:"show_db_log"` // 是否在控制台显示数据库日志
DBLogLevel string `mapstructure:"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"` // local | redirect
} }
@@ -107,6 +127,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 +145,19 @@ 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("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", "local") v.SetDefault("api.mode", "local")
v.SetDefault("cron.enabled", true) v.SetDefault("cron.enabled", true)
v.SetDefault("cron.daily_spec", "0 10 * * *") v.SetDefault("cron.daily_spec", "0 10 * * *")
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")
@@ -177,8 +209,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()

View File

@@ -5,20 +5,43 @@ import (
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
"strconv"
"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"`
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 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
@@ -37,7 +60,7 @@ func GetToday(c *gin.Context) {
// @Description 获取今日必应图片的标题、版权等元数据 // @Description 获取今日必应图片的标题、版权等元数据
// @Tags image // @Tags image
// @Produce json // @Produce json
// @Success 200 {object} map[string]interface{} // @Success 200 {object} ImageMetaResp
// @Router /image/today/meta [get] // @Router /image/today/meta [get]
func GetTodayMeta(c *gin.Context) { func GetTodayMeta(c *gin.Context) {
img, err := image.GetTodayImage() img, err := image.GetTodayImage()
@@ -71,7 +94,7 @@ func GetRandom(c *gin.Context) {
// @Description 随机获取一张已抓取图片的元数据 // @Description 随机获取一张已抓取图片的元数据
// @Tags image // @Tags image
// @Produce json // @Produce json
// @Success 200 {object} map[string]interface{} // @Success 200 {object} ImageMetaResp
// @Router /image/random/meta [get] // @Router /image/random/meta [get]
func GetRandomMeta(c *gin.Context) { func GetRandomMeta(c *gin.Context) {
img, err := image.GetRandomImage() img, err := image.GetRandomImage()
@@ -108,7 +131,7 @@ func GetByDate(c *gin.Context) {
// @Tags image // @Tags image
// @Param date path string true "日期 (yyyy-mm-dd)" // @Param date path string true "日期 (yyyy-mm-dd)"
// @Produce json // @Produce json
// @Success 200 {object} map[string]interface{} // @Success 200 {object} ImageMetaResp
// @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")
@@ -122,19 +145,53 @@ func GetByDateMeta(c *gin.Context) {
// 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)"
// @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")
images, err := image.GetImageList(limit) // 记录请求参数,便于排查过滤失效问题
util.Logger.Debug("ListImages parameters",
zap.String("month", month),
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)
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
} }
@@ -171,22 +228,33 @@ 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 != "" {
c.Header("Cache-Control", "public, max-age=604800") // 7天
c.Redirect(http.StatusFound, selected.PublicURL) c.Redirect(http.StatusFound, selected.PublicURL)
} else if img.URLBase != "" { } else if img.URLBase != "" {
// 兜底重定向到原始 Bing // 兜底重定向到原始 Bing
bingURL := fmt.Sprintf("https://www.bing.com%s_%s.jpg", img.URLBase, selected.Variant) bingURL := fmt.Sprintf("https://www.bing.com%s_%s.jpg", img.URLBase, selected.Variant)
c.Header("Cache-Control", "public, max-age=604800") // 7天
c.Redirect(http.StatusFound, bingURL) c.Redirect(http.StatusFound, bingURL)
} else { } else {
serveLocal(c, selected.StorageKey) serveLocal(c, selected.StorageKey, img.Date)
} }
} else { } else {
serveLocal(c, selected.StorageKey) serveLocal(c, selected.StorageKey, img.Date)
}
}
func serveLocal(c *gin.Context, key string, etag string) {
if etag != "" {
c.Header("ETag", fmt.Sprintf("\"%s\"", etag))
if c.GetHeader("If-None-Match") == fmt.Sprintf("\"%s\"", etag) {
c.AbortWithStatus(http.StatusNotModified)
return
} }
} }
func serveLocal(c *gin.Context, key string) {
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,6 +263,7 @@ func serveLocal(c *gin.Context, key string) {
if contentType != "" { if contentType != "" {
c.Header("Content-Type", contentType) c.Header("Content-Type", contentType)
} }
c.Header("Cache-Control", "public, max-age=604800") // 7天
io.Copy(c.Writer, reader) io.Copy(c.Writer, reader)
} }
@@ -221,7 +290,11 @@ func formatMeta(img *model.Image) gin.H {
"date": img.Date, "date": img.Date,
"title": img.Title, "title": img.Title,
"copyright": img.Copyright, "copyright": img.Copyright,
"copyrightlink": img.CopyrightLink,
"quiz": img.Quiz, "quiz": img.Quiz,
"startdate": img.StartDate,
"fullstartdate": img.FullStartDate,
"hsh": img.HSH,
"variants": variants, "variants": variants,
} }
} }

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")
{ {
// 公共接口 // 公共接口

View File

@@ -11,17 +11,21 @@ type Image struct {
Date string `gorm:"uniqueIndex;type:varchar(10)" json:"date"` // YYYY-MM-DD Date string `gorm:"uniqueIndex;type:varchar(10)" json:"date"` // YYYY-MM-DD
Title string `json:"title"` Title string `json:"title"`
Copyright string `json:"copyright"` Copyright string `json:"copyright"`
CopyrightLink string `json:"copyrightlink"`
URLBase string `json:"urlbase"` URLBase string `json:"urlbase"`
Quiz string `json:"quiz"` Quiz string `json:"quiz"`
StartDate string `json:"startdate"`
FullStartDate string `json:"fullstartdate"`
HSH string `json:"hsh"`
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"` UpdatedAt time.Time `json:"updated_at"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
Variants []ImageVariant `gorm:"foreignKey:ImageID" json:"variants"` Variants []ImageVariant `gorm:"foreignKey:ImageID;constraint:OnUpdate:CASCADE,OnDelete:SET NULL;" json:"variants"`
} }
type ImageVariant struct { type ImageVariant struct {
ID uint `gorm:"primaryKey" json:"id"` ID uint `gorm:"primaryKey" json:"id"`
ImageID uint `gorm:"uniqueIndex:idx_image_variant_format" json:"image_id"` ImageID uint `gorm:"index;uniqueIndex:idx_image_variant_format" json:"image_id"`
Variant string `gorm:"uniqueIndex:idx_image_variant_format;type:varchar(20)" json:"variant"` // UHD, 1920x1080, etc. Variant string `gorm:"uniqueIndex:idx_image_variant_format;type:varchar(20)" json:"variant"` // UHD, 1920x1080, etc.
Format string `gorm:"uniqueIndex:idx_image_variant_format;type:varchar(10)" json:"format"` // jpg, webp Format string `gorm:"uniqueIndex:idx_image_variant_format;type:varchar(10)" json:"format"` // jpg, webp
StorageKey string `json:"storage_key"` StorageKey string `json:"storage_key"`

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

View File

@@ -0,0 +1,94 @@
package repo
import (
"BingPaper/internal/config"
"BingPaper/internal/model"
"BingPaper/internal/util"
"fmt"
"go.uber.org/zap"
"gorm.io/gorm"
)
// MigrateDataToNewDB 将数据从旧数据库迁移到新数据库
func MigrateDataToNewDB(oldDB *gorm.DB, newConfig *config.Config) error {
util.Logger.Info("Starting data migration to new database",
zap.String("new_type", newConfig.DB.Type),
zap.String("new_dsn", newConfig.DB.DSN))
// 1. 初始化新数据库连接
dialector, err := GetDialector(newConfig.DB.Type, newConfig.DB.DSN)
if err != nil {
return fmt.Errorf("failed to get dialector for new DB: %w", err)
}
gormConfig := GetGormConfig(newConfig)
newDB, err := gorm.Open(dialector, gormConfig)
if err != nil {
return fmt.Errorf("failed to connect to new DB: %w", err)
}
// 2. 自动迁移结构
if err := newDB.AutoMigrate(&model.Image{}, &model.ImageVariant{}, &model.Token{}); err != nil {
return fmt.Errorf("failed to migrate schema in new DB: %w", err)
}
// 3. 清空新数据库中的现有数据(防止冲突)
util.Logger.Info("Cleaning up destination database before migration")
// 备份或清空目标数据库。由于用户要求“可能需要清空或备份”,
// 这里我们选择在迁移前清空目标表,以确保迁移过来的数据是完整且不冲突的。
if err := newDB.Session(&gorm.Session{AllowGlobalUpdate: true}).Delete(&model.ImageVariant{}).Error; err != nil {
return fmt.Errorf("failed to clear ImageVariants: %w", err)
}
if err := newDB.Session(&gorm.Session{AllowGlobalUpdate: true}).Delete(&model.Image{}).Error; err != nil {
return fmt.Errorf("failed to clear Images: %w", err)
}
if err := newDB.Session(&gorm.Session{AllowGlobalUpdate: true}).Delete(&model.Token{}).Error; err != nil {
return fmt.Errorf("failed to clear Tokens: %w", err)
}
// 4. 开始迁移数据
// 使用事务确保迁移的原子性
return newDB.Transaction(func(tx *gorm.DB) error {
// 迁移 Images
var images []model.Image
if err := oldDB.Find(&images).Error; err != nil {
return fmt.Errorf("failed to fetch images from old DB: %w", err)
}
if len(images) > 0 {
util.Logger.Info("Migrating images", zap.Int("count", len(images)))
if err := tx.Create(&images).Error; err != nil {
return fmt.Errorf("failed to insert images into new DB: %w", err)
}
}
// 迁移 ImageVariants
var variants []model.ImageVariant
if err := oldDB.Find(&variants).Error; err != nil {
return fmt.Errorf("failed to fetch variants from old DB: %w", err)
}
if len(variants) > 0 {
util.Logger.Info("Migrating variants", zap.Int("count", len(variants)))
if err := tx.Create(&variants).Error; err != nil {
return fmt.Errorf("failed to insert variants into new DB: %w", err)
}
}
// 迁移 Tokens
var tokens []model.Token
if err := oldDB.Find(&tokens).Error; err != nil {
return fmt.Errorf("failed to fetch tokens from old DB: %w", err)
}
if len(tokens) > 0 {
util.Logger.Info("Migrating tokens", zap.Int("count", len(tokens)))
if err := tx.Create(&tokens).Error; err != nil {
return fmt.Errorf("failed to insert tokens into new DB: %w", err)
}
}
// 更新全局 DB 指针
DB = newDB
util.Logger.Info("Data migration completed successfully")
return nil
})
}

View File

@@ -18,8 +18,10 @@ import (
"BingPaper/internal/repo" "BingPaper/internal/repo"
"BingPaper/internal/storage" "BingPaper/internal/storage"
"BingPaper/internal/util" "BingPaper/internal/util"
"github.com/disintegration/imaging" "github.com/disintegration/imaging"
"go.uber.org/zap" "go.uber.org/zap"
"gorm.io/gorm/clause"
) )
type BingResponse struct { type BingResponse struct {
@@ -36,6 +38,7 @@ type BingImage struct {
CopyrightLink string `json:"copyrightlink"` CopyrightLink string `json:"copyrightlink"`
Title string `json:"title"` Title string `json:"title"`
Quiz string `json:"quiz"` Quiz string `json:"quiz"`
HSH string `json:"hsh"`
} }
type Fetcher struct { type Fetcher struct {
@@ -53,17 +56,22 @@ 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) url := fmt.Sprintf("%s?format=js&idx=0&n=%d&uhd=1&mkt=%s", config.BingAPIBase, n, config.BingMkt)
util.Logger.Debug("Requesting Bing API", zap.String("url", url))
resp, err := f.httpClient.Get(url) resp, err := f.httpClient.Get(url)
if err != nil { if err != nil {
util.Logger.Error("Failed to request Bing API", zap.Error(err))
return err return err
} }
defer resp.Body.Close() defer resp.Body.Close()
var bingResp BingResponse var bingResp BingResponse
if err := json.NewDecoder(resp.Body).Decode(&bingResp); err != nil { 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
} }
util.Logger.Info("Fetched images from Bing", zap.Int("count", len(bingResp.Images)))
for _, bingImg := range bingResp.Images { for _, bingImg := range bingResp.Images {
if err := f.processImage(ctx, bingImg); err != nil { if err := f.processImage(ctx, bingImg); err != nil {
util.Logger.Error("Failed to process image", zap.String("date", bingImg.Enddate), zap.Error(err)) util.Logger.Error("Failed to process image", zap.String("date", bingImg.Enddate), zap.Error(err))
@@ -91,12 +99,14 @@ func (f *Fetcher) processImage(ctx context.Context, bingImg BingImage) error {
imgData, err := f.downloadImage(imgURL) imgData, err := f.downloadImage(imgURL)
if err != nil { if err != nil {
util.Logger.Error("Failed to download image", zap.String("url", imgURL), zap.Error(err))
return err return err
} }
// 解码图片用于缩放 // 解码图片用于缩放
srcImg, _, err := image.Decode(bytes.NewReader(imgData)) srcImg, _, err := image.Decode(bytes.NewReader(imgData))
if err != nil { if err != nil {
util.Logger.Error("Failed to decode image data", zap.Error(err))
return err return err
} }
@@ -105,45 +115,74 @@ func (f *Fetcher) processImage(ctx context.Context, bingImg BingImage) error {
Date: dateStr, Date: dateStr,
Title: bingImg.Title, Title: bingImg.Title,
Copyright: bingImg.Copyright, Copyright: bingImg.Copyright,
CopyrightLink: bingImg.CopyrightLink,
URLBase: bingImg.URLBase, URLBase: bingImg.URLBase,
Quiz: bingImg.Quiz, Quiz: bingImg.Quiz,
StartDate: bingImg.Startdate,
FullStartDate: bingImg.Fullstartdate,
HSH: bingImg.HSH,
} }
if err := repo.DB.Create(&dbImg).Error; err != nil { if err := repo.DB.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "date"}},
DoNothing: true,
}).Create(&dbImg).Error; err != nil {
util.Logger.Error("Failed to create image record", zap.Error(err))
return err return err
} }
// 再次检查 dbImg.ID 是否被填充,如果没有被填充(说明由于冲突未插入),则需要查询出已有的 ID
if dbImg.ID == 0 {
var existing model.Image
if err := repo.DB.Where("date = ?", dateStr).First(&existing).Error; err != nil {
util.Logger.Error("Failed to query existing image record after conflict", zap.Error(err))
return err
}
dbImg = existing
}
// 保存各种分辨率 // 保存各种分辨率
variants := []struct { targetVariants := []struct {
name string name string
width int width int
height int height int
}{ }{
{variantName, 0, 0}, // 原图 (UHD 或 1080p)
{"1920x1080", 1920, 1080}, {"1920x1080", 1920, 1080},
{"1366x768", 1366, 768}, {"1366x768", 1366, 768},
{"1280x720", 1280, 720},
{"1024x768", 1024, 768},
{"800x600", 800, 600},
{"800x480", 800, 480},
{"640x480", 640, 480},
{"640x360", 640, 360},
{"480x360", 480, 360},
{"400x240", 400, 240},
{"320x240", 320, 240},
}
// 首先保存原图 (UHD 或 1080p)
if err := f.saveVariant(ctx, &dbImg, 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
} }
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) resized := imaging.Fill(srcImg, v.width, v.height, imaging.Center, imaging.Lanczos)
buf := new(bytes.Buffer) buf := new(bytes.Buffer)
if err := jpeg.Encode(buf, resized, &jpeg.Options{Quality: 90}); err != nil { 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)) util.Logger.Warn("Failed to encode jpeg", zap.String("variant", v.name), zap.Error(err))
continue continue
} }
currentImgData = buf.Bytes() currentImgData := buf.Bytes()
}
// 保存 JPG // 保存 JPG
if err := f.saveVariant(ctx, &dbImg, v.name, "jpg", currentImgData); err != nil { if err := f.saveVariant(ctx, &dbImg, v.name, "jpg", currentImgData); err != nil {
util.Logger.Error("Failed to save variant", zap.String("variant", v.name), zap.Error(err)) util.Logger.Error("Failed to save variant", zap.String("variant", v.name), zap.Error(err))
} }
} }
// 保存今日额外文件 // 保存今日额外文件
@@ -194,27 +233,37 @@ func (f *Fetcher) saveVariant(ctx context.Context, img *model.Image, variant, fo
Size: int64(len(data)), Size: int64(len(data)),
} }
return repo.DB.Create(&vRecord).Error return repo.DB.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "image_id"}, {Name: "variant"}, {Name: "format"}},
DoNothing: true,
}).Create(&vRecord).Error
} }
func (f *Fetcher) saveDailyFiles(srcImg image.Image, originalData []byte) { func (f *Fetcher) saveDailyFiles(srcImg image.Image, originalData []byte) {
util.Logger.Info("Saving daily files") util.Logger.Info("Saving daily files")
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) if err := os.MkdirAll(localRoot, 0755); err != nil {
jpegPath := filepath.Join(localRoot, "static", "daily.jpeg") util.Logger.Error("Failed to create directory", zap.String("path", localRoot), zap.Error(err))
fJpeg, _ := os.Create(jpegPath) return
if fJpeg != nil { }
jpeg.Encode(fJpeg, srcImg, &jpeg.Options{Quality: 95})
// daily.jpeg (quality 100)
jpegPath := filepath.Join(localRoot, "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(localRoot, "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))
}
} }

View File

@@ -25,6 +25,7 @@ func CleanupOldImages(ctx context.Context) error {
var images []model.Image var images []model.Image
if err := repo.DB.Where("date < ?", threshold).Preload("Variants").Find(&images).Error; err != nil { if err := repo.DB.Where("date < ?", threshold).Preload("Variants").Find(&images).Error; err != nil {
util.Logger.Error("Failed to query old images for cleanup", zap.Error(err))
return err return err
} }
@@ -35,10 +36,14 @@ func CleanupOldImages(ctx context.Context) error {
util.Logger.Warn("Failed to delete storage object", zap.String("key", v.StorageKey), zap.Error(err)) util.Logger.Warn("Failed to delete storage object", zap.String("key", v.StorageKey), zap.Error(err))
} }
} }
// 删除 DB 记录 (级联删除由代码处理,或者 GORM 会处理已加载的关联吗?) // 删除关联记录(逻辑外键控制)
// 简单起见,手动删除关联 if err := repo.DB.Where("image_id = ?", img.ID).Delete(&model.ImageVariant{}).Error; err != nil {
repo.DB.Where("image_id = ?", img.ID).Delete(&model.ImageVariant{}) util.Logger.Error("Failed to delete variants", zap.Uint("image_id", img.ID), zap.Error(err))
repo.DB.Delete(&img) }
// 删除主表记录
if err := repo.DB.Delete(&img).Error; err != nil {
util.Logger.Error("Failed to delete image", zap.Uint("id", img.ID), zap.Error(err))
}
} }
util.Logger.Info("Cleanup task completed", zap.Int("deleted_count", len(images))) util.Logger.Info("Cleanup task completed", zap.Int("deleted_count", len(images)))
@@ -81,12 +86,29 @@ func GetImageByDate(date string) (*model.Image, error) {
return &img, err return &img, err
} }
func GetImageList(limit int) ([]model.Image, error) { func GetImageList(limit int, offset int, month string) ([]model.Image, error) {
var images []model.Image var images []model.Image
db := repo.DB.Order("date desc").Preload("Variants") tx := repo.DB.Model(&model.Image{})
if limit > 0 {
db = db.Limit(limit) if month != "" {
// 增强过滤:确保只处理 YYYY-MM 格式,防止注入或非法字符
// 这里简单处理:只要不为空就增加 LIKE 过滤
util.Logger.Debug("Filtering images by month", zap.String("month", month))
tx = tx.Where("date LIKE ?", month+"%")
}
tx = tx.Order("date desc").Preload("Variants")
if limit > 0 {
tx = tx.Limit(limit)
}
if offset > 0 {
tx = tx.Offset(offset)
}
err := tx.Find(&images).Error
if err != nil {
util.Logger.Error("Failed to get image list", zap.Error(err), zap.String("month", month))
} }
err := db.Find(&images).Error
return images, err return images, err
} }

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

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
// 文件输出
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.NewConsoleEncoder(encoderConfig),
zapcore.AddSync(os.Stdout), zapcore.AddSync(os.Stdout),
zapLevel, zapLevel,
) ))
}
Logger = zap.New(core, zap.AddCaller())
core := zapcore.NewTee(cores...)
return zap.New(core, zap.AddCaller())
} }

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,22 +26,22 @@ 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=!OUTPUT_NAME!
if "%%a"=="windows" set BINARY_NAME=!OUTPUT_NAME!.exe if "%%a"=="windows" set BINARY_NAME=!OUTPUT_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
@@ -39,15 +52,15 @@ for %%p in (%PLATFORMS%) do (
rd /s /q !OUTPUT_NAME! rd /s /q !OUTPUT_NAME!
popd popd
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
@@ -28,7 +43,7 @@ foreach ($Platform in $Platforms) {
$BinaryName = "$OutputName.exe" $BinaryName = "$OutputName.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,10 +53,10 @@ 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\
@@ -53,9 +68,9 @@ foreach ($Platform in $Platforms) {
Remove-Item -Recurse -Force $OutputName Remove-Item -Recurse -Force $OutputName
Set-Location $CurrentDir Set-Location $CurrentDir
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 目录
@@ -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} 编译成功"

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?

130
webapp/README.md Normal file
View File

@@ -0,0 +1,130 @@
# BingPaper WebApp
BingPaper 的前端 Web 应用,使用 Vue 3 + TypeScript + Vite 构建。
> 💡 **性能优化提示**:已配置浏览器缓存优化,可减少 60-80% 带宽!
> 👉 后端配置:查看 [缓存配置快速参考](./CACHE_QUICK_REF.md)
## 特性
- ✨ Vue 3 组合式 API
- 🎨 Tailwind CSS + shadcn-vue 组件库
- 📦 TypeScript 类型支持
- 🔧 完整的 API 客户端封装
- 🚀 优化的构建配置
- ⚡ 浏览器缓存优化(内容哈希 + 代码分割)
- 🌐 支持自定义后端路径
- 📁 自动输出到上级目录的 web 文件夹
## 快速开始
### 安装依赖
```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/
├── 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>

2525
webapp/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

38
webapp/package.json Normal file
View File

@@ -0,0 +1,38 @@
{
"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",
"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

23
webapp/src/App.vue Normal file
View File

@@ -0,0 +1,23 @@
<script setup lang="ts">
import 'vue-sonner/style.css'
</script>
<template>
<div id="app">
<RouterView />
</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,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>

View File

@@ -0,0 +1,23 @@
<script setup lang="ts">
import type { DialogTitleProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { DialogTitle, useForwardProps } from "reka-ui"
import { cn } from "@/lib/utils"
const props = defineProps<DialogTitleProps & { class?: HTMLAttributes["class"] }>()
const delegatedProps = reactiveOmit(props, "class")
const forwardedProps = useForwardProps(delegatedProps)
</script>
<template>
<DialogTitle
data-slot="dialog-title"
v-bind="forwardedProps"
:class="cn('text-lg leading-none font-semibold', props.class)"
>
<slot />
</DialogTitle>
</template>

View File

@@ -0,0 +1,15 @@
<script setup lang="ts">
import type { DialogTriggerProps } from "reka-ui"
import { DialogTrigger } from "reka-ui"
const props = defineProps<DialogTriggerProps>()
</script>
<template>
<DialogTrigger
data-slot="dialog-trigger"
v-bind="props"
>
<slot />
</DialogTrigger>
</template>

View File

@@ -0,0 +1,10 @@
export { default as Dialog } from "./Dialog.vue"
export { default as DialogClose } from "./DialogClose.vue"
export { default as DialogContent } from "./DialogContent.vue"
export { default as DialogDescription } from "./DialogDescription.vue"
export { default as DialogFooter } from "./DialogFooter.vue"
export { default as DialogHeader } from "./DialogHeader.vue"
export { default as DialogOverlay } from "./DialogOverlay.vue"
export { default as DialogScrollContent } from "./DialogScrollContent.vue"
export { default as DialogTitle } from "./DialogTitle.vue"
export { default as DialogTrigger } from "./DialogTrigger.vue"

View File

@@ -0,0 +1,33 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { useVModel } from "@vueuse/core"
import { cn } from "@/lib/utils"
const props = defineProps<{
defaultValue?: string | number
modelValue?: string | number
class?: HTMLAttributes["class"]
}>()
const emits = defineEmits<{
(e: "update:modelValue", payload: string | number): void
}>()
const modelValue = useVModel(props, "modelValue", emits, {
passive: true,
defaultValue: props.defaultValue,
})
</script>
<template>
<input
v-model="modelValue"
data-slot="input"
:class="cn(
'file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',
'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',
props.class,
)"
>
</template>

View File

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

View File

@@ -0,0 +1,26 @@
<script setup lang="ts">
import type { LabelProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { Label } from "reka-ui"
import { cn } from "@/lib/utils"
const props = defineProps<LabelProps & { class?: HTMLAttributes["class"] }>()
const delegatedProps = reactiveOmit(props, "class")
</script>
<template>
<Label
data-slot="label"
v-bind="delegatedProps"
:class="
cn(
'flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50',
props.class,
)
"
>
<slot />
</Label>
</template>

View File

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

View File

@@ -0,0 +1,38 @@
<script setup lang="ts">
import type { ProgressRootProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import {
ProgressIndicator,
ProgressRoot,
} from "reka-ui"
import { cn } from "@/lib/utils"
const props = withDefaults(
defineProps<ProgressRootProps & { class?: HTMLAttributes["class"] }>(),
{
modelValue: 0,
},
)
const delegatedProps = reactiveOmit(props, "class")
</script>
<template>
<ProgressRoot
data-slot="progress"
v-bind="delegatedProps"
:class="
cn(
'bg-primary/20 relative h-2 w-full overflow-hidden rounded-full',
props.class,
)
"
>
<ProgressIndicator
data-slot="progress-indicator"
class="bg-primary h-full w-full flex-1 transition-all"
:style="`transform: translateX(-${100 - (props.modelValue ?? 0)}%);`"
/>
</ProgressRoot>
</template>

View File

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

View File

@@ -0,0 +1,25 @@
<script setup lang="ts">
import type { RadioGroupRootEmits, RadioGroupRootProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { RadioGroupRoot, useForwardPropsEmits } from "reka-ui"
import { cn } from "@/lib/utils"
const props = defineProps<RadioGroupRootProps & { class?: HTMLAttributes["class"] }>()
const emits = defineEmits<RadioGroupRootEmits>()
const delegatedProps = reactiveOmit(props, "class")
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>
<template>
<RadioGroupRoot
v-slot="slotProps"
data-slot="radio-group"
:class="cn('grid gap-3', props.class)"
v-bind="forwarded"
>
<slot v-bind="slotProps" />
</RadioGroupRoot>
</template>

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