mirror of
https://git.fightbot.fun/hxuanyu/BingPaper.git
synced 2026-03-07 20:59:32 +08:00
Compare commits
43 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fb7545f9a2 | |||
| ee814f0380 | |||
| d3ca6fa919 | |||
| 49c78506b2 | |||
| e40677f105 | |||
| e48959d5ba | |||
| 852a72c597 | |||
| 2660970320 | |||
| fb636b9450 | |||
| 8ef66b2cb1 | |||
| 6868a67ed7 | |||
| 52fb8c9328 | |||
| 845dc7d045 | |||
| 93690e10d3 | |||
| b69db53f0a | |||
| 39d4f9c730 | |||
| cee6bc1027 | |||
| 1984e01785 | |||
| 31f32bdb63 | |||
| 9360bd0131 | |||
| 1ca3d15c2f | |||
| e428f5bddb | |||
| c32cb8da3f | |||
| 5e3defc63d | |||
| ea99a31248 | |||
| 86d6517267 | |||
| 2e5eeaf425 | |||
| 7433bc2e7e | |||
| 8bc9b44a14 | |||
| 617c1d0967 | |||
| b31711d86d | |||
| 62ac723c95 | |||
| 5334ee9d41 | |||
| c8a7ea5490 | |||
| 61de3f44dc | |||
| 69abe80264 | |||
| 34848e7b91 | |||
| 9ec9a2ba91 | |||
| 3c1f29e4ef | |||
| ae82557545 | |||
| fecbd014b3 | |||
| 907e158f44 | |||
| f7fc3fa506 |
@@ -7,7 +7,7 @@ output
|
|||||||
scripts
|
scripts
|
||||||
# docs
|
# docs
|
||||||
*.md
|
*.md
|
||||||
docker-compose.yml
|
docker-compose.yaml
|
||||||
Dockerfile
|
Dockerfile
|
||||||
.dockerignore
|
.dockerignore
|
||||||
webapp/.vscode
|
webapp/.vscode
|
||||||
|
|||||||
22
.github/workflows/verify.yml
vendored
22
.github/workflows/verify.yml
vendored
@@ -38,3 +38,25 @@ jobs:
|
|||||||
|
|
||||||
- name: Test
|
- name: Test
|
||||||
run: CGO_ENABLED=0 go test -v ./...
|
run: CGO_ENABLED=0 go test -v ./...
|
||||||
|
|
||||||
|
- name: Notification
|
||||||
|
if: success()
|
||||||
|
env:
|
||||||
|
NOTIFY_CURLS: ${{ secrets.NOTIFY_CURLS }}
|
||||||
|
run: |
|
||||||
|
if [ -n "$NOTIFY_CURLS" ]; then
|
||||||
|
printf "%s\n" "$NOTIFY_CURLS" | while read -r line; do
|
||||||
|
if [ -n "$line" ]; then
|
||||||
|
if [[ "$line" =~ ^[0-9] ]]; then
|
||||||
|
echo "Pausing for $line ms....."
|
||||||
|
sleep "$(awk "BEGIN {print $line/1000}")" || true
|
||||||
|
elif [[ "$line" == curl* ]]; then
|
||||||
|
echo "Response:"
|
||||||
|
eval "$line -w \"\\nHTTP Status: %{http_code}\\n\"" || true
|
||||||
|
else
|
||||||
|
eval "$line" || true
|
||||||
|
fi
|
||||||
|
echo ""
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|||||||
@@ -36,11 +36,15 @@ BingPaper 支持通过配置文件(YAML)和环境变量进行配置。
|
|||||||
- `mode`: API 行为模式。
|
- `mode`: API 行为模式。
|
||||||
- `local`: (默认) 接口直接返回图片的二进制流,适合图片存储对外部不可见的情况。
|
- `local`: (默认) 接口直接返回图片的二进制流,适合图片存储对外部不可见的情况。
|
||||||
- `redirect`: 接口返回 302 重定向到图片的 `PublicURL`,适合配合 S3 或 WebDAV 的公共访问。
|
- `redirect`: 接口返回 302 重定向到图片的 `PublicURL`,适合配合 S3 或 WebDAV 的公共访问。
|
||||||
|
- `enable_mkt_fallback`: 当请求的地区不存在或无数据时,是否允许兜底回退到默认地区或任意可用地区,默认 `true`。
|
||||||
|
|
||||||
#### cron (定时任务)
|
#### cron (定时任务)
|
||||||
- `enabled`: 是否启用定时抓取,默认 `true`。
|
- `enabled`: 是否启用定时抓取,默认 `true`。
|
||||||
- `daily_spec`: Cron 表达式,定义每日抓取时间。默认 `"0 10 * * *"` (每日上午 10:00)。
|
- `daily_spec`: Cron 表达式,定义每日抓取时间。默认 `"0 10 * * *"` (每日上午 10:00)。
|
||||||
|
|
||||||
|
#### fetcher (抓取配置)
|
||||||
|
- `regions`: 需要抓取的地区编码列表(如 `zh-CN`, `en-US` 等)。如果不设置,默认为包括主要国家在内的 17 个地区。
|
||||||
|
|
||||||
#### retention (数据保留)
|
#### retention (数据保留)
|
||||||
- `days`: 图片及元数据保留天数。超过此天数的数据可能会被清理任务处理。设置为 `0` 表示永久保留,不进行自动清理。默认 `0`。
|
- `days`: 图片及元数据保留天数。超过此天数的数据可能会被清理任务处理。设置为 `0` 表示永久保留,不进行自动清理。默认 `0`。
|
||||||
|
|
||||||
@@ -101,3 +105,5 @@ BingPaper 支持通过配置文件(YAML)和环境变量进行配置。
|
|||||||
- `BINGPAPER_STORAGE_TYPE=s3`
|
- `BINGPAPER_STORAGE_TYPE=s3`
|
||||||
- `BINGPAPER_STORAGE_S3_BUCKET=my-images`
|
- `BINGPAPER_STORAGE_S3_BUCKET=my-images`
|
||||||
- `BINGPAPER_ADMIN_PASSWORD_BCRYPT="$2a$10$..."`
|
- `BINGPAPER_ADMIN_PASSWORD_BCRYPT="$2a$10$..."`
|
||||||
|
- `HOST_PORT=8080` (仅限 Docker Compose 部署,控制宿主机映射到外部的端口)
|
||||||
|
- `BINGPAPER_SERVER_PORT=8080` (控制应用监听端口及容器内部端口)
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
# Stage 1: Build Frontend
|
# Stage 1: Build Frontend
|
||||||
FROM --platform=$BUILDPLATFORM node:20-alpine AS node-builder
|
FROM --platform=$BUILDPLATFORM node:20-alpine AS node-builder
|
||||||
|
ARG NPM_REGISTRY
|
||||||
WORKDIR /webapp
|
WORKDIR /webapp
|
||||||
# 复制 package.json 和 lock 文件以利用 layer 缓存
|
# 复制 package.json 和 lock 文件以利用 layer 缓存
|
||||||
COPY webapp/package*.json ./
|
COPY webapp/package*.json ./
|
||||||
|
# 如果设置了 NPM_REGISTRY,则配置 npm 镜像
|
||||||
|
RUN if [ -n "$NPM_REGISTRY" ]; then npm config set registry $NPM_REGISTRY; fi
|
||||||
# 使用 npm ci 以获得更快且可重现的构建(如果存在 package-lock.json)
|
# 使用 npm ci 以获得更快且可重现的构建(如果存在 package-lock.json)
|
||||||
RUN if [ -f package-lock.json ]; then npm ci; else npm install; fi
|
RUN if [ -f package-lock.json ]; then npm ci; else npm install; fi
|
||||||
# 复制其余源码并构建
|
# 复制其余源码并构建
|
||||||
@@ -11,6 +14,8 @@ RUN npm run build
|
|||||||
|
|
||||||
# Stage 2: Build Backend
|
# Stage 2: Build Backend
|
||||||
FROM --platform=$BUILDPLATFORM golang:1.25.5-alpine AS builder
|
FROM --platform=$BUILDPLATFORM golang:1.25.5-alpine AS builder
|
||||||
|
ARG GOPROXY
|
||||||
|
ENV GOPROXY=$GOPROXY
|
||||||
# 安装 Git 以支持某些 Go 模块依赖
|
# 安装 Git 以支持某些 Go 模块依赖
|
||||||
RUN apk add --no-cache git
|
RUN apk add --no-cache git
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ go run .
|
|||||||
- `GET /api/v1/image/random`:返回随机图片
|
- `GET /api/v1/image/random`:返回随机图片
|
||||||
- `GET /api/v1/image/date/:yyyy-mm-dd`:返回指定日期图片
|
- `GET /api/v1/image/date/:yyyy-mm-dd`:返回指定日期图片
|
||||||
- **查询参数**:
|
- **查询参数**:
|
||||||
|
- `mkt`:地区编码 (zh-CN, en-US, ja-JP 等),默认 `zh-CN`
|
||||||
- `variant`:分辨率 (UHD, 1920x1080, 1366x768),默认 `UHD`
|
- `variant`:分辨率 (UHD, 1920x1080, 1366x768),默认 `UHD`
|
||||||
- `format`:格式 (jpg),默认 `jpg`
|
- `format`:格式 (jpg),默认 `jpg`
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
server:
|
server:
|
||||||
port: 8080
|
port: 8080
|
||||||
base_url: ""
|
base_url: ""
|
||||||
|
|
||||||
log:
|
log:
|
||||||
level: info
|
level: info
|
||||||
filename: data/logs/app.log
|
filename: data/logs/app.log
|
||||||
@@ -13,23 +12,20 @@ log:
|
|||||||
log_console: true
|
log_console: true
|
||||||
show_db_log: false
|
show_db_log: false
|
||||||
db_log_level: info
|
db_log_level: info
|
||||||
|
|
||||||
api:
|
api:
|
||||||
mode: local # local | redirect
|
mode: redirect
|
||||||
|
enable_mkt_fallback: false
|
||||||
|
enable_on_demand_fetch: false
|
||||||
cron:
|
cron:
|
||||||
enabled: true
|
enabled: true
|
||||||
daily_spec: "0 10 * * *"
|
daily_spec: 20 8-23/4 * * *
|
||||||
|
|
||||||
retention:
|
retention:
|
||||||
days: 0
|
days: 0
|
||||||
|
|
||||||
db:
|
db:
|
||||||
type: sqlite # sqlite | mysql | postgres
|
type: sqlite
|
||||||
dsn: data/bing_paper.db
|
dsn: data/bing_paper.db
|
||||||
|
|
||||||
storage:
|
storage:
|
||||||
type: local # local | s3 | webdav
|
type: local
|
||||||
local:
|
local:
|
||||||
root: data/picture
|
root: data/picture
|
||||||
s3:
|
s3:
|
||||||
@@ -45,15 +41,28 @@ storage:
|
|||||||
username: ""
|
username: ""
|
||||||
password: ""
|
password: ""
|
||||||
public_url_prefix: ""
|
public_url_prefix: ""
|
||||||
|
|
||||||
admin:
|
admin:
|
||||||
password_bcrypt: "$2a$10$fYHPeWHmwObephJvtlyH1O8DIgaLk5TINbi9BOezo2M8cSjmJchka" # 默认密码: admin123
|
password_bcrypt: $2a$10$fYHPeWHmwObephJvtlyH1O8DIgaLk5TINbi9BOezo2M8cSjmJchka
|
||||||
|
|
||||||
token:
|
token:
|
||||||
default_ttl: 168h
|
default_ttl: 168h
|
||||||
|
|
||||||
feature:
|
feature:
|
||||||
write_daily_files: true
|
write_daily_files: true
|
||||||
|
|
||||||
web:
|
web:
|
||||||
path: web
|
path: web
|
||||||
|
fetcher:
|
||||||
|
regions:
|
||||||
|
- zh-CN
|
||||||
|
- en-US
|
||||||
|
- ja-JP
|
||||||
|
- en-AU
|
||||||
|
- en-GB
|
||||||
|
- de-DE
|
||||||
|
- en-NZ
|
||||||
|
- en-CA
|
||||||
|
- fr-FR
|
||||||
|
- it-IT
|
||||||
|
- es-ES
|
||||||
|
- pt-BR
|
||||||
|
- ko-KR
|
||||||
|
- en-IN
|
||||||
|
- ru-RU
|
||||||
|
|||||||
17
docker-compose.yaml
Normal file
17
docker-compose.yaml
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
services:
|
||||||
|
bingpaper:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
args:
|
||||||
|
- GOPROXY=${GOPROXY:-https://proxy.golang.org,direct}
|
||||||
|
- NPM_REGISTRY=${NPM_REGISTRY:-https://registry.npmjs.org/}
|
||||||
|
container_name: bingpaper
|
||||||
|
restart: always
|
||||||
|
ports:
|
||||||
|
- "${HOST_PORT:-8080}:${BINGPAPER_SERVER_PORT:-8080}"
|
||||||
|
volumes:
|
||||||
|
- ./data:/app/data
|
||||||
|
environment:
|
||||||
|
- TZ=${TZ:-Asia/Shanghai}
|
||||||
|
- BINGPAPER_SERVER_PORT=${BINGPAPER_SERVER_PORT:-8080}
|
||||||
|
- BINGPAPER_LOG_LEVEL=${BINGPAPER_LOG_LEVEL:-info}
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
services:
|
|
||||||
bingpaper:
|
|
||||||
build: .
|
|
||||||
container_name: bingpaper
|
|
||||||
restart: always
|
|
||||||
ports:
|
|
||||||
- "8080:8080"
|
|
||||||
volumes:
|
|
||||||
- ./data:/app/data
|
|
||||||
environment:
|
|
||||||
- BINGPAPER_SERVER_PORT=8080
|
|
||||||
- BINGPAPER_LOG_LEVEL=info
|
|
||||||
- BINGPAPER_API_MODE=local
|
|
||||||
- BINGPAPER_CRON_ENABLED=true
|
|
||||||
- BINGPAPER_DB_TYPE=sqlite
|
|
||||||
- BINGPAPER_DB_DSN=data/bing_paper.db
|
|
||||||
- BINGPAPER_STORAGE_TYPE=local
|
|
||||||
- BINGPAPER_STORAGE_LOCAL_ROOT=data/picture
|
|
||||||
- BINGPAPER_RETENTION_DAYS=30
|
|
||||||
# S3 配置 (可选)
|
|
||||||
# - BINGPAPER_STORAGE_S3_ENDPOINT=
|
|
||||||
# - BINGPAPER_STORAGE_S3_REGION=
|
|
||||||
# - BINGPAPER_STORAGE_S3_BUCKET=
|
|
||||||
# - BINGPAPER_STORAGE_S3_ACCESS_KEY=
|
|
||||||
# - BINGPAPER_STORAGE_S3_SECRET_KEY=
|
|
||||||
# - BINGPAPER_STORAGE_S3_PUBLIC_URL_PREFIX=
|
|
||||||
# WebDAV 配置 (可选)
|
|
||||||
# - BINGPAPER_STORAGE_WEBDAV_URL=
|
|
||||||
# - BINGPAPER_STORAGE_WEBDAV_USERNAME=
|
|
||||||
# - BINGPAPER_STORAGE_WEBDAV_PASSWORD=
|
|
||||||
# - BINGPAPER_STORAGE_WEBDAV_PUBLIC_URL_PREFIX=
|
|
||||||
236
docs/docs.go
236
docs/docs.go
@@ -413,6 +413,12 @@ const docTemplate = `{
|
|||||||
"in": "path",
|
"in": "path",
|
||||||
"required": true
|
"required": true
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"description": "地区编码 (如 zh-CN, en-US)",
|
||||||
|
"name": "mkt",
|
||||||
|
"in": "query"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"default": "UHD",
|
"default": "UHD",
|
||||||
@@ -434,6 +440,24 @@ const docTemplate = `{
|
|||||||
"schema": {
|
"schema": {
|
||||||
"type": "file"
|
"type": "file"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"202": {
|
||||||
|
"description": "按需抓取任务已启动",
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"404": {
|
||||||
|
"description": "图片未找到,响应体包含具体原因",
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -455,6 +479,12 @@ const docTemplate = `{
|
|||||||
"name": "date",
|
"name": "date",
|
||||||
"in": "path",
|
"in": "path",
|
||||||
"required": true
|
"required": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"description": "地区编码 (如 zh-CN, en-US)",
|
||||||
|
"name": "mkt",
|
||||||
|
"in": "query"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"responses": {
|
"responses": {
|
||||||
@@ -463,6 +493,24 @@ const docTemplate = `{
|
|||||||
"schema": {
|
"schema": {
|
||||||
"$ref": "#/definitions/handlers.ImageMetaResp"
|
"$ref": "#/definitions/handlers.ImageMetaResp"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"202": {
|
||||||
|
"description": "按需抓取任务已启动",
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"404": {
|
||||||
|
"description": "图片未找到,响应体包含具体原因",
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -478,6 +526,12 @@ const docTemplate = `{
|
|||||||
],
|
],
|
||||||
"summary": "获取随机图片",
|
"summary": "获取随机图片",
|
||||||
"parameters": [
|
"parameters": [
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"description": "地区编码 (如 zh-CN, en-US)",
|
||||||
|
"name": "mkt",
|
||||||
|
"in": "query"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"default": "UHD",
|
"default": "UHD",
|
||||||
@@ -499,6 +553,24 @@ const docTemplate = `{
|
|||||||
"schema": {
|
"schema": {
|
||||||
"type": "file"
|
"type": "file"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"202": {
|
||||||
|
"description": "按需抓取任务已启动",
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"404": {
|
||||||
|
"description": "图片未找到,响应体包含具体原因",
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -513,12 +585,38 @@ const docTemplate = `{
|
|||||||
"image"
|
"image"
|
||||||
],
|
],
|
||||||
"summary": "获取随机图片元数据",
|
"summary": "获取随机图片元数据",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"description": "地区编码 (如 zh-CN, en-US)",
|
||||||
|
"name": "mkt",
|
||||||
|
"in": "query"
|
||||||
|
}
|
||||||
|
],
|
||||||
"responses": {
|
"responses": {
|
||||||
"200": {
|
"200": {
|
||||||
"description": "OK",
|
"description": "OK",
|
||||||
"schema": {
|
"schema": {
|
||||||
"$ref": "#/definitions/handlers.ImageMetaResp"
|
"$ref": "#/definitions/handlers.ImageMetaResp"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"202": {
|
||||||
|
"description": "按需抓取任务已启动",
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"404": {
|
||||||
|
"description": "图片未找到,响应体包含具体原因",
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -534,6 +632,12 @@ const docTemplate = `{
|
|||||||
],
|
],
|
||||||
"summary": "获取今日图片",
|
"summary": "获取今日图片",
|
||||||
"parameters": [
|
"parameters": [
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"description": "地区编码 (如 zh-CN, en-US)",
|
||||||
|
"name": "mkt",
|
||||||
|
"in": "query"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"default": "UHD",
|
"default": "UHD",
|
||||||
@@ -555,6 +659,24 @@ const docTemplate = `{
|
|||||||
"schema": {
|
"schema": {
|
||||||
"type": "file"
|
"type": "file"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"202": {
|
||||||
|
"description": "按需抓取任务已启动",
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"404": {
|
||||||
|
"description": "图片未找到,响应体包含具体原因",
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -569,12 +691,38 @@ const docTemplate = `{
|
|||||||
"image"
|
"image"
|
||||||
],
|
],
|
||||||
"summary": "获取今日图片元数据",
|
"summary": "获取今日图片元数据",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"description": "地区编码 (如 zh-CN, en-US)",
|
||||||
|
"name": "mkt",
|
||||||
|
"in": "query"
|
||||||
|
}
|
||||||
|
],
|
||||||
"responses": {
|
"responses": {
|
||||||
"200": {
|
"200": {
|
||||||
"description": "OK",
|
"description": "OK",
|
||||||
"schema": {
|
"schema": {
|
||||||
"$ref": "#/definitions/handlers.ImageMetaResp"
|
"$ref": "#/definitions/handlers.ImageMetaResp"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"202": {
|
||||||
|
"description": "按需抓取任务已启动",
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"404": {
|
||||||
|
"description": "图片未找到,响应体包含具体原因",
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -614,6 +762,12 @@ const docTemplate = `{
|
|||||||
"description": "按月份过滤 (格式: YYYY-MM)",
|
"description": "按月份过滤 (格式: YYYY-MM)",
|
||||||
"name": "month",
|
"name": "month",
|
||||||
"in": "query"
|
"in": "query"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"description": "地区编码 (如 zh-CN, en-US)",
|
||||||
|
"name": "mkt",
|
||||||
|
"in": "query"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"responses": {
|
"responses": {
|
||||||
@@ -628,12 +782,66 @@ const docTemplate = `{
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"/images/global/today": {
|
||||||
|
"get": {
|
||||||
|
"description": "获取配置文件中所有已开启地区的今日必应图片元数据(缩略图)",
|
||||||
|
"produces": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"image"
|
||||||
|
],
|
||||||
|
"summary": "获取所有地区的今日图片列表",
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "OK",
|
||||||
|
"schema": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/definitions/handlers.ImageMetaResp"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/regions": {
|
||||||
|
"get": {
|
||||||
|
"description": "返回系统支持的所有必应地区编码及标签。如果配置中指定了抓取地区,这些地区将排在列表最前面(置顶)。",
|
||||||
|
"produces": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"image"
|
||||||
|
],
|
||||||
|
"summary": "获取支持的地区列表",
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "OK",
|
||||||
|
"schema": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/definitions/util.Region"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"definitions": {
|
"definitions": {
|
||||||
"config.APIConfig": {
|
"config.APIConfig": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
|
"enableMktFallback": {
|
||||||
|
"description": "当请求的地区不存在时,是否回退到默认地区",
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"enableOnDemandFetch": {
|
||||||
|
"description": "是否启用按需抓取",
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
"mode": {
|
"mode": {
|
||||||
"description": "local | redirect",
|
"description": "local | redirect",
|
||||||
"type": "string"
|
"type": "string"
|
||||||
@@ -666,6 +874,9 @@ const docTemplate = `{
|
|||||||
"feature": {
|
"feature": {
|
||||||
"$ref": "#/definitions/config.FeatureConfig"
|
"$ref": "#/definitions/config.FeatureConfig"
|
||||||
},
|
},
|
||||||
|
"fetcher": {
|
||||||
|
"$ref": "#/definitions/config.FetcherConfig"
|
||||||
|
},
|
||||||
"log": {
|
"log": {
|
||||||
"$ref": "#/definitions/config.LogConfig"
|
"$ref": "#/definitions/config.LogConfig"
|
||||||
},
|
},
|
||||||
@@ -717,6 +928,17 @@ const docTemplate = `{
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"config.FetcherConfig": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"regions": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"config.LocalConfig": {
|
"config.LocalConfig": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
@@ -917,6 +1139,9 @@ const docTemplate = `{
|
|||||||
"hsh": {
|
"hsh": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
|
"mkt": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
"quiz": {
|
"quiz": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
@@ -1006,6 +1231,17 @@ const docTemplate = `{
|
|||||||
"type": "string"
|
"type": "string"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"util.Region": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"label": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"value": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"securityDefinitions": {
|
"securityDefinitions": {
|
||||||
|
|||||||
@@ -407,6 +407,12 @@
|
|||||||
"in": "path",
|
"in": "path",
|
||||||
"required": true
|
"required": true
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"description": "地区编码 (如 zh-CN, en-US)",
|
||||||
|
"name": "mkt",
|
||||||
|
"in": "query"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"default": "UHD",
|
"default": "UHD",
|
||||||
@@ -428,6 +434,24 @@
|
|||||||
"schema": {
|
"schema": {
|
||||||
"type": "file"
|
"type": "file"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"202": {
|
||||||
|
"description": "按需抓取任务已启动",
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"404": {
|
||||||
|
"description": "图片未找到,响应体包含具体原因",
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -449,6 +473,12 @@
|
|||||||
"name": "date",
|
"name": "date",
|
||||||
"in": "path",
|
"in": "path",
|
||||||
"required": true
|
"required": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"description": "地区编码 (如 zh-CN, en-US)",
|
||||||
|
"name": "mkt",
|
||||||
|
"in": "query"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"responses": {
|
"responses": {
|
||||||
@@ -457,6 +487,24 @@
|
|||||||
"schema": {
|
"schema": {
|
||||||
"$ref": "#/definitions/handlers.ImageMetaResp"
|
"$ref": "#/definitions/handlers.ImageMetaResp"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"202": {
|
||||||
|
"description": "按需抓取任务已启动",
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"404": {
|
||||||
|
"description": "图片未找到,响应体包含具体原因",
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -472,6 +520,12 @@
|
|||||||
],
|
],
|
||||||
"summary": "获取随机图片",
|
"summary": "获取随机图片",
|
||||||
"parameters": [
|
"parameters": [
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"description": "地区编码 (如 zh-CN, en-US)",
|
||||||
|
"name": "mkt",
|
||||||
|
"in": "query"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"default": "UHD",
|
"default": "UHD",
|
||||||
@@ -493,6 +547,24 @@
|
|||||||
"schema": {
|
"schema": {
|
||||||
"type": "file"
|
"type": "file"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"202": {
|
||||||
|
"description": "按需抓取任务已启动",
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"404": {
|
||||||
|
"description": "图片未找到,响应体包含具体原因",
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -507,12 +579,38 @@
|
|||||||
"image"
|
"image"
|
||||||
],
|
],
|
||||||
"summary": "获取随机图片元数据",
|
"summary": "获取随机图片元数据",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"description": "地区编码 (如 zh-CN, en-US)",
|
||||||
|
"name": "mkt",
|
||||||
|
"in": "query"
|
||||||
|
}
|
||||||
|
],
|
||||||
"responses": {
|
"responses": {
|
||||||
"200": {
|
"200": {
|
||||||
"description": "OK",
|
"description": "OK",
|
||||||
"schema": {
|
"schema": {
|
||||||
"$ref": "#/definitions/handlers.ImageMetaResp"
|
"$ref": "#/definitions/handlers.ImageMetaResp"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"202": {
|
||||||
|
"description": "按需抓取任务已启动",
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"404": {
|
||||||
|
"description": "图片未找到,响应体包含具体原因",
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -528,6 +626,12 @@
|
|||||||
],
|
],
|
||||||
"summary": "获取今日图片",
|
"summary": "获取今日图片",
|
||||||
"parameters": [
|
"parameters": [
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"description": "地区编码 (如 zh-CN, en-US)",
|
||||||
|
"name": "mkt",
|
||||||
|
"in": "query"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"default": "UHD",
|
"default": "UHD",
|
||||||
@@ -549,6 +653,24 @@
|
|||||||
"schema": {
|
"schema": {
|
||||||
"type": "file"
|
"type": "file"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"202": {
|
||||||
|
"description": "按需抓取任务已启动",
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"404": {
|
||||||
|
"description": "图片未找到,响应体包含具体原因",
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -563,12 +685,38 @@
|
|||||||
"image"
|
"image"
|
||||||
],
|
],
|
||||||
"summary": "获取今日图片元数据",
|
"summary": "获取今日图片元数据",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"description": "地区编码 (如 zh-CN, en-US)",
|
||||||
|
"name": "mkt",
|
||||||
|
"in": "query"
|
||||||
|
}
|
||||||
|
],
|
||||||
"responses": {
|
"responses": {
|
||||||
"200": {
|
"200": {
|
||||||
"description": "OK",
|
"description": "OK",
|
||||||
"schema": {
|
"schema": {
|
||||||
"$ref": "#/definitions/handlers.ImageMetaResp"
|
"$ref": "#/definitions/handlers.ImageMetaResp"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"202": {
|
||||||
|
"description": "按需抓取任务已启动",
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"404": {
|
||||||
|
"description": "图片未找到,响应体包含具体原因",
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -608,6 +756,12 @@
|
|||||||
"description": "按月份过滤 (格式: YYYY-MM)",
|
"description": "按月份过滤 (格式: YYYY-MM)",
|
||||||
"name": "month",
|
"name": "month",
|
||||||
"in": "query"
|
"in": "query"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"description": "地区编码 (如 zh-CN, en-US)",
|
||||||
|
"name": "mkt",
|
||||||
|
"in": "query"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"responses": {
|
"responses": {
|
||||||
@@ -622,12 +776,66 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"/images/global/today": {
|
||||||
|
"get": {
|
||||||
|
"description": "获取配置文件中所有已开启地区的今日必应图片元数据(缩略图)",
|
||||||
|
"produces": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"image"
|
||||||
|
],
|
||||||
|
"summary": "获取所有地区的今日图片列表",
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "OK",
|
||||||
|
"schema": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/definitions/handlers.ImageMetaResp"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/regions": {
|
||||||
|
"get": {
|
||||||
|
"description": "返回系统支持的所有必应地区编码及标签。如果配置中指定了抓取地区,这些地区将排在列表最前面(置顶)。",
|
||||||
|
"produces": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"image"
|
||||||
|
],
|
||||||
|
"summary": "获取支持的地区列表",
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "OK",
|
||||||
|
"schema": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/definitions/util.Region"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"definitions": {
|
"definitions": {
|
||||||
"config.APIConfig": {
|
"config.APIConfig": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
|
"enableMktFallback": {
|
||||||
|
"description": "当请求的地区不存在时,是否回退到默认地区",
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"enableOnDemandFetch": {
|
||||||
|
"description": "是否启用按需抓取",
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
"mode": {
|
"mode": {
|
||||||
"description": "local | redirect",
|
"description": "local | redirect",
|
||||||
"type": "string"
|
"type": "string"
|
||||||
@@ -660,6 +868,9 @@
|
|||||||
"feature": {
|
"feature": {
|
||||||
"$ref": "#/definitions/config.FeatureConfig"
|
"$ref": "#/definitions/config.FeatureConfig"
|
||||||
},
|
},
|
||||||
|
"fetcher": {
|
||||||
|
"$ref": "#/definitions/config.FetcherConfig"
|
||||||
|
},
|
||||||
"log": {
|
"log": {
|
||||||
"$ref": "#/definitions/config.LogConfig"
|
"$ref": "#/definitions/config.LogConfig"
|
||||||
},
|
},
|
||||||
@@ -711,6 +922,17 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"config.FetcherConfig": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"regions": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"config.LocalConfig": {
|
"config.LocalConfig": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
@@ -911,6 +1133,9 @@
|
|||||||
"hsh": {
|
"hsh": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
|
"mkt": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
"quiz": {
|
"quiz": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
@@ -1000,6 +1225,17 @@
|
|||||||
"type": "string"
|
"type": "string"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"util.Region": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"label": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"value": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"securityDefinitions": {
|
"securityDefinitions": {
|
||||||
|
|||||||
@@ -2,6 +2,12 @@ basePath: /api/v1
|
|||||||
definitions:
|
definitions:
|
||||||
config.APIConfig:
|
config.APIConfig:
|
||||||
properties:
|
properties:
|
||||||
|
enableMktFallback:
|
||||||
|
description: 当请求的地区不存在时,是否回退到默认地区
|
||||||
|
type: boolean
|
||||||
|
enableOnDemandFetch:
|
||||||
|
description: 是否启用按需抓取
|
||||||
|
type: boolean
|
||||||
mode:
|
mode:
|
||||||
description: local | redirect
|
description: local | redirect
|
||||||
type: string
|
type: string
|
||||||
@@ -23,6 +29,8 @@ definitions:
|
|||||||
$ref: '#/definitions/config.DBConfig'
|
$ref: '#/definitions/config.DBConfig'
|
||||||
feature:
|
feature:
|
||||||
$ref: '#/definitions/config.FeatureConfig'
|
$ref: '#/definitions/config.FeatureConfig'
|
||||||
|
fetcher:
|
||||||
|
$ref: '#/definitions/config.FetcherConfig'
|
||||||
log:
|
log:
|
||||||
$ref: '#/definitions/config.LogConfig'
|
$ref: '#/definitions/config.LogConfig'
|
||||||
retention:
|
retention:
|
||||||
@@ -56,6 +64,13 @@ definitions:
|
|||||||
writeDailyFiles:
|
writeDailyFiles:
|
||||||
type: boolean
|
type: boolean
|
||||||
type: object
|
type: object
|
||||||
|
config.FetcherConfig:
|
||||||
|
properties:
|
||||||
|
regions:
|
||||||
|
items:
|
||||||
|
type: string
|
||||||
|
type: array
|
||||||
|
type: object
|
||||||
config.LocalConfig:
|
config.LocalConfig:
|
||||||
properties:
|
properties:
|
||||||
root:
|
root:
|
||||||
@@ -190,6 +205,8 @@ definitions:
|
|||||||
type: string
|
type: string
|
||||||
hsh:
|
hsh:
|
||||||
type: string
|
type: string
|
||||||
|
mkt:
|
||||||
|
type: string
|
||||||
quiz:
|
quiz:
|
||||||
type: string
|
type: string
|
||||||
startdate:
|
startdate:
|
||||||
@@ -248,6 +265,13 @@ definitions:
|
|||||||
updated_at:
|
updated_at:
|
||||||
type: string
|
type: string
|
||||||
type: object
|
type: object
|
||||||
|
util.Region:
|
||||||
|
properties:
|
||||||
|
label:
|
||||||
|
type: string
|
||||||
|
value:
|
||||||
|
type: string
|
||||||
|
type: object
|
||||||
host: localhost:8080
|
host: localhost:8080
|
||||||
info:
|
info:
|
||||||
contact: {}
|
contact: {}
|
||||||
@@ -501,6 +525,10 @@ paths:
|
|||||||
name: date
|
name: date
|
||||||
required: true
|
required: true
|
||||||
type: string
|
type: string
|
||||||
|
- description: 地区编码 (如 zh-CN, en-US)
|
||||||
|
in: query
|
||||||
|
name: mkt
|
||||||
|
type: string
|
||||||
- default: UHD
|
- default: UHD
|
||||||
description: 分辨率
|
description: 分辨率
|
||||||
in: query
|
in: query
|
||||||
@@ -518,6 +546,18 @@ paths:
|
|||||||
description: OK
|
description: OK
|
||||||
schema:
|
schema:
|
||||||
type: file
|
type: file
|
||||||
|
"202":
|
||||||
|
description: 按需抓取任务已启动
|
||||||
|
schema:
|
||||||
|
additionalProperties:
|
||||||
|
type: string
|
||||||
|
type: object
|
||||||
|
"404":
|
||||||
|
description: 图片未找到,响应体包含具体原因
|
||||||
|
schema:
|
||||||
|
additionalProperties:
|
||||||
|
type: string
|
||||||
|
type: object
|
||||||
summary: 获取指定日期图片
|
summary: 获取指定日期图片
|
||||||
tags:
|
tags:
|
||||||
- image
|
- image
|
||||||
@@ -530,6 +570,10 @@ paths:
|
|||||||
name: date
|
name: date
|
||||||
required: true
|
required: true
|
||||||
type: string
|
type: string
|
||||||
|
- description: 地区编码 (如 zh-CN, en-US)
|
||||||
|
in: query
|
||||||
|
name: mkt
|
||||||
|
type: string
|
||||||
produces:
|
produces:
|
||||||
- application/json
|
- application/json
|
||||||
responses:
|
responses:
|
||||||
@@ -537,6 +581,18 @@ paths:
|
|||||||
description: OK
|
description: OK
|
||||||
schema:
|
schema:
|
||||||
$ref: '#/definitions/handlers.ImageMetaResp'
|
$ref: '#/definitions/handlers.ImageMetaResp'
|
||||||
|
"202":
|
||||||
|
description: 按需抓取任务已启动
|
||||||
|
schema:
|
||||||
|
additionalProperties:
|
||||||
|
type: string
|
||||||
|
type: object
|
||||||
|
"404":
|
||||||
|
description: 图片未找到,响应体包含具体原因
|
||||||
|
schema:
|
||||||
|
additionalProperties:
|
||||||
|
type: string
|
||||||
|
type: object
|
||||||
summary: 获取指定日期图片元数据
|
summary: 获取指定日期图片元数据
|
||||||
tags:
|
tags:
|
||||||
- image
|
- image
|
||||||
@@ -544,6 +600,10 @@ paths:
|
|||||||
get:
|
get:
|
||||||
description: 随机返回一张已抓取的图片流或重定向
|
description: 随机返回一张已抓取的图片流或重定向
|
||||||
parameters:
|
parameters:
|
||||||
|
- description: 地区编码 (如 zh-CN, en-US)
|
||||||
|
in: query
|
||||||
|
name: mkt
|
||||||
|
type: string
|
||||||
- default: UHD
|
- default: UHD
|
||||||
description: 分辨率
|
description: 分辨率
|
||||||
in: query
|
in: query
|
||||||
@@ -561,12 +621,29 @@ paths:
|
|||||||
description: OK
|
description: OK
|
||||||
schema:
|
schema:
|
||||||
type: file
|
type: file
|
||||||
|
"202":
|
||||||
|
description: 按需抓取任务已启动
|
||||||
|
schema:
|
||||||
|
additionalProperties:
|
||||||
|
type: string
|
||||||
|
type: object
|
||||||
|
"404":
|
||||||
|
description: 图片未找到,响应体包含具体原因
|
||||||
|
schema:
|
||||||
|
additionalProperties:
|
||||||
|
type: string
|
||||||
|
type: object
|
||||||
summary: 获取随机图片
|
summary: 获取随机图片
|
||||||
tags:
|
tags:
|
||||||
- image
|
- image
|
||||||
/image/random/meta:
|
/image/random/meta:
|
||||||
get:
|
get:
|
||||||
description: 随机获取一张已抓取图片的元数据
|
description: 随机获取一张已抓取图片的元数据
|
||||||
|
parameters:
|
||||||
|
- description: 地区编码 (如 zh-CN, en-US)
|
||||||
|
in: query
|
||||||
|
name: mkt
|
||||||
|
type: string
|
||||||
produces:
|
produces:
|
||||||
- application/json
|
- application/json
|
||||||
responses:
|
responses:
|
||||||
@@ -574,6 +651,18 @@ paths:
|
|||||||
description: OK
|
description: OK
|
||||||
schema:
|
schema:
|
||||||
$ref: '#/definitions/handlers.ImageMetaResp'
|
$ref: '#/definitions/handlers.ImageMetaResp'
|
||||||
|
"202":
|
||||||
|
description: 按需抓取任务已启动
|
||||||
|
schema:
|
||||||
|
additionalProperties:
|
||||||
|
type: string
|
||||||
|
type: object
|
||||||
|
"404":
|
||||||
|
description: 图片未找到,响应体包含具体原因
|
||||||
|
schema:
|
||||||
|
additionalProperties:
|
||||||
|
type: string
|
||||||
|
type: object
|
||||||
summary: 获取随机图片元数据
|
summary: 获取随机图片元数据
|
||||||
tags:
|
tags:
|
||||||
- image
|
- image
|
||||||
@@ -581,6 +670,10 @@ paths:
|
|||||||
get:
|
get:
|
||||||
description: 根据参数返回今日必应图片流或重定向
|
description: 根据参数返回今日必应图片流或重定向
|
||||||
parameters:
|
parameters:
|
||||||
|
- description: 地区编码 (如 zh-CN, en-US)
|
||||||
|
in: query
|
||||||
|
name: mkt
|
||||||
|
type: string
|
||||||
- default: UHD
|
- default: UHD
|
||||||
description: 分辨率 (UHD, 1920x1080, 1366x768, 1280x720, 1024x768, 800x600, 800x480,
|
description: 分辨率 (UHD, 1920x1080, 1366x768, 1280x720, 1024x768, 800x600, 800x480,
|
||||||
640x480, 640x360, 480x360, 400x240, 320x240)
|
640x480, 640x360, 480x360, 400x240, 320x240)
|
||||||
@@ -599,12 +692,29 @@ paths:
|
|||||||
description: OK
|
description: OK
|
||||||
schema:
|
schema:
|
||||||
type: file
|
type: file
|
||||||
|
"202":
|
||||||
|
description: 按需抓取任务已启动
|
||||||
|
schema:
|
||||||
|
additionalProperties:
|
||||||
|
type: string
|
||||||
|
type: object
|
||||||
|
"404":
|
||||||
|
description: 图片未找到,响应体包含具体原因
|
||||||
|
schema:
|
||||||
|
additionalProperties:
|
||||||
|
type: string
|
||||||
|
type: object
|
||||||
summary: 获取今日图片
|
summary: 获取今日图片
|
||||||
tags:
|
tags:
|
||||||
- image
|
- image
|
||||||
/image/today/meta:
|
/image/today/meta:
|
||||||
get:
|
get:
|
||||||
description: 获取今日必应图片的标题、版权等元数据
|
description: 获取今日必应图片的标题、版权等元数据
|
||||||
|
parameters:
|
||||||
|
- description: 地区编码 (如 zh-CN, en-US)
|
||||||
|
in: query
|
||||||
|
name: mkt
|
||||||
|
type: string
|
||||||
produces:
|
produces:
|
||||||
- application/json
|
- application/json
|
||||||
responses:
|
responses:
|
||||||
@@ -612,6 +722,18 @@ paths:
|
|||||||
description: OK
|
description: OK
|
||||||
schema:
|
schema:
|
||||||
$ref: '#/definitions/handlers.ImageMetaResp'
|
$ref: '#/definitions/handlers.ImageMetaResp'
|
||||||
|
"202":
|
||||||
|
description: 按需抓取任务已启动
|
||||||
|
schema:
|
||||||
|
additionalProperties:
|
||||||
|
type: string
|
||||||
|
type: object
|
||||||
|
"404":
|
||||||
|
description: 图片未找到,响应体包含具体原因
|
||||||
|
schema:
|
||||||
|
additionalProperties:
|
||||||
|
type: string
|
||||||
|
type: object
|
||||||
summary: 获取今日图片元数据
|
summary: 获取今日图片元数据
|
||||||
tags:
|
tags:
|
||||||
- image
|
- image
|
||||||
@@ -637,6 +759,10 @@ paths:
|
|||||||
in: query
|
in: query
|
||||||
name: month
|
name: month
|
||||||
type: string
|
type: string
|
||||||
|
- description: 地区编码 (如 zh-CN, en-US)
|
||||||
|
in: query
|
||||||
|
name: mkt
|
||||||
|
type: string
|
||||||
produces:
|
produces:
|
||||||
- application/json
|
- application/json
|
||||||
responses:
|
responses:
|
||||||
@@ -649,6 +775,36 @@ paths:
|
|||||||
summary: 获取图片列表
|
summary: 获取图片列表
|
||||||
tags:
|
tags:
|
||||||
- image
|
- image
|
||||||
|
/images/global/today:
|
||||||
|
get:
|
||||||
|
description: 获取配置文件中所有已开启地区的今日必应图片元数据(缩略图)
|
||||||
|
produces:
|
||||||
|
- application/json
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: OK
|
||||||
|
schema:
|
||||||
|
items:
|
||||||
|
$ref: '#/definitions/handlers.ImageMetaResp'
|
||||||
|
type: array
|
||||||
|
summary: 获取所有地区的今日图片列表
|
||||||
|
tags:
|
||||||
|
- image
|
||||||
|
/regions:
|
||||||
|
get:
|
||||||
|
description: 返回系统支持的所有必应地区编码及标签。如果配置中指定了抓取地区,这些地区将排在列表最前面(置顶)。
|
||||||
|
produces:
|
||||||
|
- application/json
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: OK
|
||||||
|
schema:
|
||||||
|
items:
|
||||||
|
$ref: '#/definitions/util.Region'
|
||||||
|
type: array
|
||||||
|
summary: 获取支持的地区列表
|
||||||
|
tags:
|
||||||
|
- image
|
||||||
securityDefinitions:
|
securityDefinitions:
|
||||||
BearerAuth:
|
BearerAuth:
|
||||||
in: header
|
in: header
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"BingPaper/internal/config"
|
"BingPaper/internal/config"
|
||||||
"BingPaper/internal/cron"
|
"BingPaper/internal/cron"
|
||||||
@@ -36,12 +37,27 @@ func Init(webFS embed.FS, configPath string) *gin.Engine {
|
|||||||
// 2. 初始化日志
|
// 2. 初始化日志
|
||||||
util.InitLogger(cfg.Log)
|
util.InitLogger(cfg.Log)
|
||||||
|
|
||||||
|
// 以 debug 级别输出配置加载详情和环境变量覆盖情况
|
||||||
|
util.Logger.Debug("Configuration loading details",
|
||||||
|
zap.String("config_file", config.GetRawViper().ConfigFileUsed()),
|
||||||
|
)
|
||||||
|
envOverrides := config.GetEnvOverrides()
|
||||||
|
if len(envOverrides) > 0 {
|
||||||
|
for _, override := range envOverrides {
|
||||||
|
util.Logger.Debug("Environment variable override applied", zap.String("detail", override))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
util.Logger.Debug("No environment variable overrides detected")
|
||||||
|
}
|
||||||
|
util.Logger.Debug("Full effective configuration:\n" + config.GetFormattedSettings())
|
||||||
|
|
||||||
// 输出配置信息
|
// 输出配置信息
|
||||||
util.Logger.Info("Application configuration loaded")
|
util.Logger.Info("Application configuration loaded")
|
||||||
util.Logger.Info("├─ Config file ", zap.String("path", config.GetRawViper().ConfigFileUsed()))
|
util.Logger.Info("├─ Config file ", zap.String("path", config.GetRawViper().ConfigFileUsed()))
|
||||||
util.Logger.Info("├─ Database ", zap.String("type", cfg.DB.Type))
|
util.Logger.Info("├─ Database ", zap.String("type", cfg.DB.Type))
|
||||||
util.Logger.Info("├─ Storage ", zap.String("type", cfg.Storage.Type))
|
util.Logger.Info("├─ Storage ", zap.String("type", cfg.Storage.Type))
|
||||||
util.Logger.Info("└─ Server ", zap.Int("port", cfg.Server.Port))
|
util.Logger.Info("├─ Server ", zap.Int("port", cfg.Server.Port))
|
||||||
|
util.Logger.Info("└─ Active Mkt ", zap.Strings("regions", cfg.Fetcher.Regions))
|
||||||
|
|
||||||
// 根据存储类型输出更多信息
|
// 根据存储类型输出更多信息
|
||||||
switch cfg.Storage.Type {
|
switch cfg.Storage.Type {
|
||||||
@@ -133,5 +149,6 @@ func LogWelcomeInfo() {
|
|||||||
fmt.Printf(" - 管理后台: %s/admin\n", baseURL)
|
fmt.Printf(" - 管理后台: %s/admin\n", baseURL)
|
||||||
fmt.Printf(" - API 文档: %s/swagger/index.html\n", baseURL)
|
fmt.Printf(" - API 文档: %s/swagger/index.html\n", baseURL)
|
||||||
fmt.Printf(" - 今日图片: %s/api/v1/image/today\n", baseURL)
|
fmt.Printf(" - 今日图片: %s/api/v1/image/today\n", baseURL)
|
||||||
|
fmt.Printf(" - 激活地区: %s\n", strings.Join(cfg.Fetcher.Regions, ", "))
|
||||||
fmt.Println("---------------------------------------------------------")
|
fmt.Println("---------------------------------------------------------")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,44 +3,49 @@ package config
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/fsnotify/fsnotify"
|
"github.com/fsnotify/fsnotify"
|
||||||
"github.com/spf13/viper"
|
"github.com/spf13/viper"
|
||||||
|
"gopkg.in/yaml.v3"
|
||||||
|
|
||||||
|
"BingPaper/internal/util"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Config struct {
|
type Config struct {
|
||||||
Server ServerConfig `mapstructure:"server"`
|
Server ServerConfig `mapstructure:"server" yaml:"server"`
|
||||||
Log LogConfig `mapstructure:"log"`
|
Log LogConfig `mapstructure:"log" yaml:"log"`
|
||||||
API APIConfig `mapstructure:"api"`
|
API APIConfig `mapstructure:"api" yaml:"api"`
|
||||||
Cron CronConfig `mapstructure:"cron"`
|
Cron CronConfig `mapstructure:"cron" yaml:"cron"`
|
||||||
Retention RetentionConfig `mapstructure:"retention"`
|
Retention RetentionConfig `mapstructure:"retention" yaml:"retention"`
|
||||||
DB DBConfig `mapstructure:"db"`
|
DB DBConfig `mapstructure:"db" yaml:"db"`
|
||||||
Storage StorageConfig `mapstructure:"storage"`
|
Storage StorageConfig `mapstructure:"storage" yaml:"storage"`
|
||||||
Admin AdminConfig `mapstructure:"admin"`
|
Admin AdminConfig `mapstructure:"admin" yaml:"admin"`
|
||||||
Token TokenConfig `mapstructure:"token"`
|
Token TokenConfig `mapstructure:"token" yaml:"token"`
|
||||||
Feature FeatureConfig `mapstructure:"feature"`
|
Feature FeatureConfig `mapstructure:"feature" yaml:"feature"`
|
||||||
Web WebConfig `mapstructure:"web"`
|
Web WebConfig `mapstructure:"web" yaml:"web"`
|
||||||
|
Fetcher FetcherConfig `mapstructure:"fetcher" yaml:"fetcher"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type ServerConfig struct {
|
type ServerConfig struct {
|
||||||
Port int `mapstructure:"port"`
|
Port int `mapstructure:"port" yaml:"port"`
|
||||||
BaseURL string `mapstructure:"base_url"`
|
BaseURL string `mapstructure:"base_url" yaml:"base_url"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type LogConfig struct {
|
type LogConfig struct {
|
||||||
Level string `mapstructure:"level"`
|
Level string `mapstructure:"level" yaml:"level"`
|
||||||
Filename string `mapstructure:"filename"` // 业务日志文件名
|
Filename string `mapstructure:"filename" yaml:"filename"` // 业务日志文件名
|
||||||
DBFilename string `mapstructure:"db_filename"` // 数据库日志文件名
|
DBFilename string `mapstructure:"db_filename" yaml:"db_filename"` // 数据库日志文件名
|
||||||
MaxSize int `mapstructure:"max_size"` // 每个日志文件最大大小 (MB)
|
MaxSize int `mapstructure:"max_size" yaml:"max_size"` // 每个日志文件最大大小 (MB)
|
||||||
MaxBackups int `mapstructure:"max_backups"` // 保留旧日志文件最大个数
|
MaxBackups int `mapstructure:"max_backups" yaml:"max_backups"` // 保留旧日志文件最大个数
|
||||||
MaxAge int `mapstructure:"max_age"` // 保留旧日志文件最大天数
|
MaxAge int `mapstructure:"max_age" yaml:"max_age"` // 保留旧日志文件最大天数
|
||||||
Compress bool `mapstructure:"compress"` // 是否压缩旧日志文件
|
Compress bool `mapstructure:"compress" yaml:"compress"` // 是否压缩旧日志文件
|
||||||
LogConsole bool `mapstructure:"log_console"` // 是否同时输出到控制台
|
LogConsole bool `mapstructure:"log_console" yaml:"log_console"` // 是否同时输出到控制台
|
||||||
ShowDBLog bool `mapstructure:"show_db_log"` // 是否在控制台显示数据库日志
|
ShowDBLog bool `mapstructure:"show_db_log" yaml:"show_db_log"` // 是否在控制台显示数据库日志
|
||||||
DBLogLevel string `mapstructure:"db_log_level"` // 数据库日志级别: debug, info, warn, error
|
DBLogLevel string `mapstructure:"db_log_level" yaml:"db_log_level"` // 数据库日志级别: debug, info, warn, error
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c LogConfig) GetLevel() string { return c.Level }
|
func (c LogConfig) GetLevel() string { return c.Level }
|
||||||
@@ -55,65 +60,71 @@ func (c LogConfig) GetShowDBLog() bool { return c.ShowDBLog }
|
|||||||
func (c LogConfig) GetDBLogLevel() string { return c.DBLogLevel }
|
func (c LogConfig) GetDBLogLevel() string { return c.DBLogLevel }
|
||||||
|
|
||||||
type APIConfig struct {
|
type APIConfig struct {
|
||||||
Mode string `mapstructure:"mode"` // local | redirect
|
Mode string `mapstructure:"mode" yaml:"mode"` // local | redirect
|
||||||
|
EnableMktFallback bool `mapstructure:"enable_mkt_fallback" yaml:"enable_mkt_fallback"` // 当请求的地区不存在时,是否回退到默认地区
|
||||||
|
EnableOnDemandFetch bool `mapstructure:"enable_on_demand_fetch" yaml:"enable_on_demand_fetch"` // 是否启用按需抓取
|
||||||
}
|
}
|
||||||
|
|
||||||
type CronConfig struct {
|
type CronConfig struct {
|
||||||
Enabled bool `mapstructure:"enabled"`
|
Enabled bool `mapstructure:"enabled" yaml:"enabled"`
|
||||||
DailySpec string `mapstructure:"daily_spec"`
|
DailySpec string `mapstructure:"daily_spec" yaml:"daily_spec"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type RetentionConfig struct {
|
type RetentionConfig struct {
|
||||||
Days int `mapstructure:"days"`
|
Days int `mapstructure:"days" yaml:"days"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type DBConfig struct {
|
type DBConfig struct {
|
||||||
Type string `mapstructure:"type"` // sqlite/mysql/postgres
|
Type string `mapstructure:"type" yaml:"type"` // sqlite/mysql/postgres
|
||||||
DSN string `mapstructure:"dsn"`
|
DSN string `mapstructure:"dsn" yaml:"dsn"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type StorageConfig struct {
|
type StorageConfig struct {
|
||||||
Type string `mapstructure:"type"` // local/s3/webdav
|
Type string `mapstructure:"type" yaml:"type"` // local/s3/webdav
|
||||||
Local LocalConfig `mapstructure:"local"`
|
Local LocalConfig `mapstructure:"local" yaml:"local"`
|
||||||
S3 S3Config `mapstructure:"s3"`
|
S3 S3Config `mapstructure:"s3" yaml:"s3"`
|
||||||
WebDAV WebDAVConfig `mapstructure:"webdav"`
|
WebDAV WebDAVConfig `mapstructure:"webdav" yaml:"webdav"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type LocalConfig struct {
|
type LocalConfig struct {
|
||||||
Root string `mapstructure:"root"`
|
Root string `mapstructure:"root" yaml:"root"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type S3Config struct {
|
type S3Config struct {
|
||||||
Endpoint string `mapstructure:"endpoint"`
|
Endpoint string `mapstructure:"endpoint" yaml:"endpoint"`
|
||||||
Region string `mapstructure:"region"`
|
Region string `mapstructure:"region" yaml:"region"`
|
||||||
Bucket string `mapstructure:"bucket"`
|
Bucket string `mapstructure:"bucket" yaml:"bucket"`
|
||||||
AccessKey string `mapstructure:"access_key"`
|
AccessKey string `mapstructure:"access_key" yaml:"access_key"`
|
||||||
SecretKey string `mapstructure:"secret_key"`
|
SecretKey string `mapstructure:"secret_key" yaml:"secret_key"`
|
||||||
PublicURLPrefix string `mapstructure:"public_url_prefix"`
|
PublicURLPrefix string `mapstructure:"public_url_prefix" yaml:"public_url_prefix"`
|
||||||
ForcePathStyle bool `mapstructure:"force_path_style"`
|
ForcePathStyle bool `mapstructure:"force_path_style" yaml:"force_path_style"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type WebDAVConfig struct {
|
type WebDAVConfig struct {
|
||||||
URL string `mapstructure:"url"`
|
URL string `mapstructure:"url" yaml:"url"`
|
||||||
Username string `mapstructure:"username"`
|
Username string `mapstructure:"username" yaml:"username"`
|
||||||
Password string `mapstructure:"password"`
|
Password string `mapstructure:"password" yaml:"password"`
|
||||||
PublicURLPrefix string `mapstructure:"public_url_prefix"`
|
PublicURLPrefix string `mapstructure:"public_url_prefix" yaml:"public_url_prefix"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type AdminConfig struct {
|
type AdminConfig struct {
|
||||||
PasswordBcrypt string `mapstructure:"password_bcrypt"`
|
PasswordBcrypt string `mapstructure:"password_bcrypt" yaml:"password_bcrypt"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type TokenConfig struct {
|
type TokenConfig struct {
|
||||||
DefaultTTL string `mapstructure:"default_ttl"`
|
DefaultTTL string `mapstructure:"default_ttl" yaml:"default_ttl"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type FeatureConfig struct {
|
type FeatureConfig struct {
|
||||||
WriteDailyFiles bool `mapstructure:"write_daily_files"`
|
WriteDailyFiles bool `mapstructure:"write_daily_files" yaml:"write_daily_files"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type WebConfig struct {
|
type WebConfig struct {
|
||||||
Path string `mapstructure:"path"`
|
Path string `mapstructure:"path" yaml:"path"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type FetcherConfig struct {
|
||||||
|
Regions []string `mapstructure:"regions" yaml:"regions"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Bing 默认配置 (内置)
|
// Bing 默认配置 (内置)
|
||||||
@@ -154,9 +165,11 @@ func Init(configPath string) error {
|
|||||||
v.SetDefault("log.log_console", true)
|
v.SetDefault("log.log_console", true)
|
||||||
v.SetDefault("log.show_db_log", false)
|
v.SetDefault("log.show_db_log", false)
|
||||||
v.SetDefault("log.db_log_level", "info")
|
v.SetDefault("log.db_log_level", "info")
|
||||||
v.SetDefault("api.mode", "local")
|
v.SetDefault("api.mode", "redirect")
|
||||||
|
v.SetDefault("api.enable_mkt_fallback", false)
|
||||||
|
v.SetDefault("api.enable_on_demand_fetch", false)
|
||||||
v.SetDefault("cron.enabled", true)
|
v.SetDefault("cron.enabled", true)
|
||||||
v.SetDefault("cron.daily_spec", "0 10 * * *")
|
v.SetDefault("cron.daily_spec", "20 8-23/4 * * *")
|
||||||
v.SetDefault("retention.days", 0)
|
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")
|
||||||
@@ -165,6 +178,13 @@ func Init(configPath string) error {
|
|||||||
v.SetDefault("token.default_ttl", "168h")
|
v.SetDefault("token.default_ttl", "168h")
|
||||||
v.SetDefault("feature.write_daily_files", true)
|
v.SetDefault("feature.write_daily_files", true)
|
||||||
v.SetDefault("web.path", "web")
|
v.SetDefault("web.path", "web")
|
||||||
|
|
||||||
|
// 默认抓取所有支持的地区
|
||||||
|
var defaultRegions []string
|
||||||
|
for _, r := range util.AllRegions {
|
||||||
|
defaultRegions = append(defaultRegions, r.Value)
|
||||||
|
}
|
||||||
|
v.SetDefault("fetcher.regions", defaultRegions)
|
||||||
v.SetDefault("admin.password_bcrypt", "$2a$10$fYHPeWHmwObephJvtlyH1O8DIgaLk5TINbi9BOezo2M8cSjmJchka") // 默认密码: admin123
|
v.SetDefault("admin.password_bcrypt", "$2a$10$fYHPeWHmwObephJvtlyH1O8DIgaLk5TINbi9BOezo2M8cSjmJchka") // 默认密码: admin123
|
||||||
|
|
||||||
// 绑定环境变量
|
// 绑定环境变量
|
||||||
@@ -192,10 +212,15 @@ func Init(configPath string) error {
|
|||||||
targetConfigPath = "data/config.yaml"
|
targetConfigPath = "data/config.yaml"
|
||||||
}
|
}
|
||||||
fmt.Printf("Config file not found, creating default config at %s\n", targetConfigPath)
|
fmt.Printf("Config file not found, creating default config at %s\n", targetConfigPath)
|
||||||
if err := v.SafeWriteConfigAs(targetConfigPath); err != nil {
|
|
||||||
|
var defaultCfg Config
|
||||||
|
if err := v.Unmarshal(&defaultCfg); err == nil {
|
||||||
|
data, _ := yaml.Marshal(&defaultCfg)
|
||||||
|
if err := os.WriteFile(targetConfigPath, data, 0644); err != nil {
|
||||||
fmt.Printf("Warning: Failed to create default config file: %v\n", err)
|
fmt.Printf("Warning: Failed to create default config file: %v\n", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
var cfg Config
|
var cfg Config
|
||||||
if err := v.Unmarshal(&cfg); err != nil {
|
if err := v.Unmarshal(&cfg); err != nil {
|
||||||
@@ -236,24 +261,67 @@ func GetConfig() *Config {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func SaveConfig(cfg *Config) error {
|
func SaveConfig(cfg *Config) error {
|
||||||
v.Set("server", cfg.Server)
|
configLock.Lock()
|
||||||
v.Set("log", cfg.Log)
|
defer configLock.Unlock()
|
||||||
v.Set("api", cfg.API)
|
|
||||||
v.Set("cron", cfg.Cron)
|
// 1. 使用 yaml.v3 序列化,它会尊重结构体字段顺序及 yaml 标签
|
||||||
v.Set("retention", cfg.Retention)
|
data, err := yaml.Marshal(cfg)
|
||||||
v.Set("db", cfg.DB)
|
if err != nil {
|
||||||
v.Set("storage", cfg.Storage)
|
return fmt.Errorf("failed to marshal config: %v", err)
|
||||||
v.Set("admin", cfg.Admin)
|
}
|
||||||
v.Set("token", cfg.Token)
|
|
||||||
v.Set("feature", cfg.Feature)
|
// 2. 获取当前使用的配置文件路径
|
||||||
v.Set("web", cfg.Web)
|
targetPath := v.ConfigFileUsed()
|
||||||
return v.WriteConfig()
|
if targetPath == "" {
|
||||||
|
targetPath = "data/config.yaml" // 默认回退路径
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 直接写入文件,绕过 viper 的字母序排序逻辑
|
||||||
|
if err := os.WriteFile(targetPath, data, 0644); err != nil {
|
||||||
|
return fmt.Errorf("failed to write config file: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. 同步更新内存中的全局配置对象
|
||||||
|
GlobalConfig = cfg
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func GetRawViper() *viper.Viper {
|
func GetRawViper() *viper.Viper {
|
||||||
return v
|
return v
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetAllSettings 返回所有生效配置项
|
||||||
|
func GetAllSettings() map[string]interface{} {
|
||||||
|
return v.AllSettings()
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetFormattedSettings 以 key: value 形式返回所有配置项的字符串
|
||||||
|
func GetFormattedSettings() string {
|
||||||
|
keys := v.AllKeys()
|
||||||
|
sort.Strings(keys)
|
||||||
|
var sb strings.Builder
|
||||||
|
for _, k := range keys {
|
||||||
|
sb.WriteString(fmt.Sprintf("%s: %v\n", k, v.Get(k)))
|
||||||
|
}
|
||||||
|
return sb.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetEnvOverrides 返回环境变量覆盖详情(已排序)
|
||||||
|
func GetEnvOverrides() []string {
|
||||||
|
var overrides []string
|
||||||
|
keys := v.AllKeys()
|
||||||
|
sort.Strings(keys)
|
||||||
|
for _, key := range keys {
|
||||||
|
// 根据 viper 的配置生成对应的环境变量名
|
||||||
|
// Prefix: BINGPAPER, KeyReplacer: . -> _
|
||||||
|
envKey := strings.ToUpper(fmt.Sprintf("BINGPAPER_%s", strings.ReplaceAll(key, ".", "_")))
|
||||||
|
if val, ok := os.LookupEnv(envKey); ok {
|
||||||
|
overrides = append(overrides, fmt.Sprintf("%s: %s=%s", key, envKey, val))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return overrides
|
||||||
|
}
|
||||||
|
|
||||||
func GetTokenTTL() time.Duration {
|
func GetTokenTTL() time.Duration {
|
||||||
ttl, err := time.ParseDuration(GetConfig().Token.DefaultTTL)
|
ttl, err := time.ParseDuration(GetConfig().Token.DefaultTTL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -261,3 +329,11 @@ func GetTokenTTL() time.Duration {
|
|||||||
}
|
}
|
||||||
return ttl
|
return ttl
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetDefaultRegion 返回生效的默认地区编码
|
||||||
|
func (c *Config) GetDefaultRegion() string {
|
||||||
|
if len(c.Fetcher.Regions) > 0 {
|
||||||
|
return c.Fetcher.Regions[0]
|
||||||
|
}
|
||||||
|
return BingMkt
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
package config
|
package config
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -19,3 +22,46 @@ func TestDefaultConfig(t *testing.T) {
|
|||||||
t.Errorf("Expected DB type sqlite, got %s", cfg.DB.Type)
|
t.Errorf("Expected DB type sqlite, got %s", cfg.DB.Type)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestDebugFunctions(t *testing.T) {
|
||||||
|
// 设置一个环境变量
|
||||||
|
os.Setenv("BINGPAPER_SERVER_PORT", "9999")
|
||||||
|
defer os.Unsetenv("BINGPAPER_SERVER_PORT")
|
||||||
|
|
||||||
|
err := Init("")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to init config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
settings := GetAllSettings()
|
||||||
|
serverCfg, ok := settings["server"].(map[string]interface{})
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("Expected server config map, got %v", settings["server"])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Viper numbers in AllSettings are often int
|
||||||
|
portValue := serverCfg["port"]
|
||||||
|
// 允许不同的数字类型,因为 viper 内部实现可能变化
|
||||||
|
portStr := fmt.Sprintf("%v", portValue)
|
||||||
|
if portStr != "9999" {
|
||||||
|
t.Errorf("Expected port 9999 in settings, got %v (%T)", portValue, portValue)
|
||||||
|
}
|
||||||
|
|
||||||
|
overrides := GetEnvOverrides()
|
||||||
|
found := false
|
||||||
|
for _, o := range overrides {
|
||||||
|
if strings.Contains(o, "server.port") && strings.Contains(o, "9999") {
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Errorf("Expected server.port override in %v", overrides)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证格式化输出
|
||||||
|
formatted := GetFormattedSettings()
|
||||||
|
if !strings.Contains(formatted, "server.port: 9999") {
|
||||||
|
t.Errorf("Expected formatted settings to contain server.port: 9999, got %s", formatted)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"BingPaper/internal/config"
|
"BingPaper/internal/config"
|
||||||
"BingPaper/internal/model"
|
"BingPaper/internal/model"
|
||||||
@@ -27,6 +28,7 @@ type ImageVariantResp struct {
|
|||||||
|
|
||||||
type ImageMetaResp struct {
|
type ImageMetaResp struct {
|
||||||
Date string `json:"date"`
|
Date string `json:"date"`
|
||||||
|
Mkt string `json:"mkt"`
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
Copyright string `json:"copyright"`
|
Copyright string `json:"copyright"`
|
||||||
CopyrightLink string `json:"copyrightlink"`
|
CopyrightLink string `json:"copyrightlink"`
|
||||||
@@ -41,68 +43,103 @@ type ImageMetaResp struct {
|
|||||||
// @Summary 获取今日图片
|
// @Summary 获取今日图片
|
||||||
// @Description 根据参数返回今日必应图片流或重定向
|
// @Description 根据参数返回今日必应图片流或重定向
|
||||||
// @Tags image
|
// @Tags image
|
||||||
|
// @Param mkt query string false "地区编码 (如 zh-CN, en-US)"
|
||||||
// @Param variant query string false "分辨率 (UHD, 1920x1080, 1366x768, 1280x720, 1024x768, 800x600, 800x480, 640x480, 640x360, 480x360, 400x240, 320x240)" default(UHD)
|
// @Param variant query string false "分辨率 (UHD, 1920x1080, 1366x768, 1280x720, 1024x768, 800x600, 800x480, 640x480, 640x360, 480x360, 400x240, 320x240)" default(UHD)
|
||||||
// @Param format query string false "格式 (jpg)" default(jpg)
|
// @Param format query string false "格式 (jpg)" default(jpg)
|
||||||
// @Produce image/jpeg
|
// @Produce image/jpeg
|
||||||
// @Success 200 {file} binary
|
// @Success 200 {file} binary
|
||||||
|
// @Success 202 {object} map[string]string "按需抓取任务已启动"
|
||||||
|
// @Failure 404 {object} map[string]string "图片未找到,响应体包含具体原因"
|
||||||
// @Router /image/today [get]
|
// @Router /image/today [get]
|
||||||
func GetToday(c *gin.Context) {
|
func GetToday(c *gin.Context) {
|
||||||
img, err := image.GetTodayImage()
|
mkt := c.Query("mkt")
|
||||||
if err != nil {
|
imgRegion, err := image.GetTodayImage(mkt)
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
if err == image.ErrFetchStarted {
|
||||||
|
c.JSON(http.StatusAccepted, gin.H{"message": fmt.Sprintf("On-demand fetch started for region [%s]. Please try again later.", mkt)})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
handleImageResponse(c, img)
|
if err != nil {
|
||||||
|
sendImageNotFound(c, mkt)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
handleImageResponse(c, imgRegion, 7200) // 2小时
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetTodayMeta 获取今日图片元数据
|
// GetTodayMeta 获取今日图片元数据
|
||||||
// @Summary 获取今日图片元数据
|
// @Summary 获取今日图片元数据
|
||||||
// @Description 获取今日必应图片的标题、版权等元数据
|
// @Description 获取今日必应图片的标题、版权等元数据
|
||||||
// @Tags image
|
// @Tags image
|
||||||
|
// @Param mkt query string false "地区编码 (如 zh-CN, en-US)"
|
||||||
// @Produce json
|
// @Produce json
|
||||||
// @Success 200 {object} ImageMetaResp
|
// @Success 200 {object} ImageMetaResp
|
||||||
|
// @Success 202 {object} map[string]string "按需抓取任务已启动"
|
||||||
|
// @Failure 404 {object} map[string]string "图片未找到,响应体包含具体原因"
|
||||||
// @Router /image/today/meta [get]
|
// @Router /image/today/meta [get]
|
||||||
func GetTodayMeta(c *gin.Context) {
|
func GetTodayMeta(c *gin.Context) {
|
||||||
img, err := image.GetTodayImage()
|
mkt := c.Query("mkt")
|
||||||
if err != nil {
|
imgRegion, err := image.GetTodayImage(mkt)
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
if err == image.ErrFetchStarted {
|
||||||
|
c.JSON(http.StatusAccepted, gin.H{"message": fmt.Sprintf("On-demand fetch started for region [%s]. Please try again later.", mkt)})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
c.JSON(http.StatusOK, formatMeta(img))
|
if err != nil {
|
||||||
|
sendImageNotFound(c, mkt)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Header("Cache-Control", "public, max-age=7200") // 2小时
|
||||||
|
c.JSON(http.StatusOK, formatMeta(imgRegion))
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetRandom 获取随机图片
|
// GetRandom 获取随机图片
|
||||||
// @Summary 获取随机图片
|
// @Summary 获取随机图片
|
||||||
// @Description 随机返回一张已抓取的图片流或重定向
|
// @Description 随机返回一张已抓取的图片流或重定向
|
||||||
// @Tags image
|
// @Tags image
|
||||||
|
// @Param mkt query string false "地区编码 (如 zh-CN, en-US)"
|
||||||
// @Param variant query string false "分辨率" default(UHD)
|
// @Param variant query string false "分辨率" default(UHD)
|
||||||
// @Param format query string false "格式" default(jpg)
|
// @Param format query string false "格式" default(jpg)
|
||||||
// @Produce image/jpeg
|
// @Produce image/jpeg
|
||||||
// @Success 200 {file} binary
|
// @Success 200 {file} binary
|
||||||
|
// @Success 202 {object} map[string]string "按需抓取任务已启动"
|
||||||
|
// @Failure 404 {object} map[string]string "图片未找到,响应体包含具体原因"
|
||||||
// @Router /image/random [get]
|
// @Router /image/random [get]
|
||||||
|
// GetRandom 获取随机图片
|
||||||
func GetRandom(c *gin.Context) {
|
func GetRandom(c *gin.Context) {
|
||||||
img, err := image.GetRandomImage()
|
mkt := c.Query("mkt")
|
||||||
if err != nil {
|
imgRegion, err := image.GetRandomImage(mkt)
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
if err == image.ErrFetchStarted {
|
||||||
|
c.JSON(http.StatusAccepted, gin.H{"message": fmt.Sprintf("On-demand fetch started for region [%s]. Please try again later.", mkt)})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
handleImageResponse(c, img)
|
if err != nil {
|
||||||
|
sendImageNotFound(c, mkt)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
handleImageResponse(c, imgRegion, 0) // 禁用缓存
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetRandomMeta 获取随机图片元数据
|
// GetRandomMeta 获取随机图片元数据
|
||||||
// @Summary 获取随机图片元数据
|
// @Summary 获取随机图片元数据
|
||||||
// @Description 随机获取一张已抓取图片的元数据
|
// @Description 随机获取一张已抓取图片的元数据
|
||||||
// @Tags image
|
// @Tags image
|
||||||
|
// @Param mkt query string false "地区编码 (如 zh-CN, en-US)"
|
||||||
// @Produce json
|
// @Produce json
|
||||||
// @Success 200 {object} ImageMetaResp
|
// @Success 200 {object} ImageMetaResp
|
||||||
|
// @Success 202 {object} map[string]string "按需抓取任务已启动"
|
||||||
|
// @Failure 404 {object} map[string]string "图片未找到,响应体包含具体原因"
|
||||||
// @Router /image/random/meta [get]
|
// @Router /image/random/meta [get]
|
||||||
func GetRandomMeta(c *gin.Context) {
|
func GetRandomMeta(c *gin.Context) {
|
||||||
img, err := image.GetRandomImage()
|
mkt := c.Query("mkt")
|
||||||
if err != nil {
|
imgRegion, err := image.GetRandomImage(mkt)
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
if err == image.ErrFetchStarted {
|
||||||
|
c.JSON(http.StatusAccepted, gin.H{"message": fmt.Sprintf("On-demand fetch started for region [%s]. Please try again later.", mkt)})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
c.JSON(http.StatusOK, formatMeta(img))
|
if err != nil {
|
||||||
|
sendImageNotFound(c, mkt)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Header("Cache-Control", "no-cache, no-store, must-revalidate")
|
||||||
|
c.JSON(http.StatusOK, formatMeta(imgRegion))
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetByDate 获取指定日期图片
|
// GetByDate 获取指定日期图片
|
||||||
@@ -110,19 +147,27 @@ func GetRandomMeta(c *gin.Context) {
|
|||||||
// @Description 根据日期返回图片流或重定向 (yyyy-mm-dd)
|
// @Description 根据日期返回图片流或重定向 (yyyy-mm-dd)
|
||||||
// @Tags image
|
// @Tags image
|
||||||
// @Param date path string true "日期 (yyyy-mm-dd)"
|
// @Param date path string true "日期 (yyyy-mm-dd)"
|
||||||
|
// @Param mkt query string false "地区编码 (如 zh-CN, en-US)"
|
||||||
// @Param variant query string false "分辨率" default(UHD)
|
// @Param variant query string false "分辨率" default(UHD)
|
||||||
// @Param format query string false "格式" default(jpg)
|
// @Param format query string false "格式" default(jpg)
|
||||||
// @Produce image/jpeg
|
// @Produce image/jpeg
|
||||||
// @Success 200 {file} binary
|
// @Success 200 {file} binary
|
||||||
|
// @Success 202 {object} map[string]string "按需抓取任务已启动"
|
||||||
|
// @Failure 404 {object} map[string]string "图片未找到,响应体包含具体原因"
|
||||||
// @Router /image/date/{date} [get]
|
// @Router /image/date/{date} [get]
|
||||||
func GetByDate(c *gin.Context) {
|
func GetByDate(c *gin.Context) {
|
||||||
date := c.Param("date")
|
date := c.Param("date")
|
||||||
img, err := image.GetImageByDate(date)
|
mkt := c.Query("mkt")
|
||||||
if err != nil {
|
imgRegion, err := image.GetImageByDate(date, mkt)
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
if err == image.ErrFetchStarted {
|
||||||
|
c.JSON(http.StatusAccepted, gin.H{"message": fmt.Sprintf("On-demand fetch started for region [%s]. Please try again later.", mkt)})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
handleImageResponse(c, img)
|
if err != nil {
|
||||||
|
sendImageNotFound(c, mkt)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
handleImageResponse(c, imgRegion, 604800) // 7天
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetByDateMeta 获取指定日期图片元数据
|
// GetByDateMeta 获取指定日期图片元数据
|
||||||
@@ -130,17 +175,26 @@ func GetByDate(c *gin.Context) {
|
|||||||
// @Description 根据日期获取图片元数据 (yyyy-mm-dd)
|
// @Description 根据日期获取图片元数据 (yyyy-mm-dd)
|
||||||
// @Tags image
|
// @Tags image
|
||||||
// @Param date path string true "日期 (yyyy-mm-dd)"
|
// @Param date path string true "日期 (yyyy-mm-dd)"
|
||||||
|
// @Param mkt query string false "地区编码 (如 zh-CN, en-US)"
|
||||||
// @Produce json
|
// @Produce json
|
||||||
// @Success 200 {object} ImageMetaResp
|
// @Success 200 {object} ImageMetaResp
|
||||||
|
// @Success 202 {object} map[string]string "按需抓取任务已启动"
|
||||||
|
// @Failure 404 {object} map[string]string "图片未找到,响应体包含具体原因"
|
||||||
// @Router /image/date/{date}/meta [get]
|
// @Router /image/date/{date}/meta [get]
|
||||||
func GetByDateMeta(c *gin.Context) {
|
func GetByDateMeta(c *gin.Context) {
|
||||||
date := c.Param("date")
|
date := c.Param("date")
|
||||||
img, err := image.GetImageByDate(date)
|
mkt := c.Query("mkt")
|
||||||
if err != nil {
|
imgRegion, err := image.GetImageByDate(date, mkt)
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
if err == image.ErrFetchStarted {
|
||||||
|
c.JSON(http.StatusAccepted, gin.H{"message": fmt.Sprintf("On-demand fetch started for region [%s]. Please try again later.", mkt)})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
c.JSON(http.StatusOK, formatMeta(img))
|
if err != nil {
|
||||||
|
sendImageNotFound(c, mkt)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Header("Cache-Control", "public, max-age=604800") // 7天
|
||||||
|
c.JSON(http.StatusOK, formatMeta(imgRegion))
|
||||||
}
|
}
|
||||||
|
|
||||||
// ListImages 获取图片列表
|
// ListImages 获取图片列表
|
||||||
@@ -151,6 +205,7 @@ func GetByDateMeta(c *gin.Context) {
|
|||||||
// @Param page query int false "页码 (从1开始)"
|
// @Param page query int false "页码 (从1开始)"
|
||||||
// @Param page_size query int false "每页数量"
|
// @Param page_size query int false "每页数量"
|
||||||
// @Param month query string false "按月份过滤 (格式: YYYY-MM)"
|
// @Param month query string false "按月份过滤 (格式: YYYY-MM)"
|
||||||
|
// @Param mkt query string false "地区编码 (如 zh-CN, en-US)"
|
||||||
// @Produce json
|
// @Produce json
|
||||||
// @Success 200 {array} ImageMetaResp
|
// @Success 200 {array} ImageMetaResp
|
||||||
// @Router /images [get]
|
// @Router /images [get]
|
||||||
@@ -159,10 +214,12 @@ func ListImages(c *gin.Context) {
|
|||||||
pageStr := c.Query("page")
|
pageStr := c.Query("page")
|
||||||
pageSizeStr := c.Query("page_size")
|
pageSizeStr := c.Query("page_size")
|
||||||
month := c.Query("month")
|
month := c.Query("month")
|
||||||
|
mkt := c.Query("mkt")
|
||||||
|
|
||||||
// 记录请求参数,便于排查过滤失效问题
|
// 记录请求参数,便于排查过滤失效问题
|
||||||
util.Logger.Debug("ListImages parameters",
|
util.Logger.Debug("ListImages parameters",
|
||||||
zap.String("month", month),
|
zap.String("month", month),
|
||||||
|
zap.String("mkt", mkt),
|
||||||
zap.String("page", pageStr),
|
zap.String("page", pageStr),
|
||||||
zap.String("page_size", pageSizeStr),
|
zap.String("page_size", pageSizeStr),
|
||||||
zap.String("limit", limitStr))
|
zap.String("limit", limitStr))
|
||||||
@@ -189,7 +246,7 @@ func ListImages(c *gin.Context) {
|
|||||||
offset = 0
|
offset = 0
|
||||||
}
|
}
|
||||||
|
|
||||||
images, err := image.GetImageList(limit, offset, month)
|
images, err := image.GetImageList(limit, offset, month, mkt)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
util.Logger.Error("ListImages service call failed", zap.Error(err))
|
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()})
|
||||||
@@ -198,26 +255,75 @@ func ListImages(c *gin.Context) {
|
|||||||
|
|
||||||
result := []gin.H{}
|
result := []gin.H{}
|
||||||
for _, img := range images {
|
for _, img := range images {
|
||||||
result = append(result, formatMeta(&img))
|
result = append(result, formatMetaSummary(&img))
|
||||||
}
|
}
|
||||||
c.JSON(http.StatusOK, result)
|
c.JSON(http.StatusOK, result)
|
||||||
}
|
}
|
||||||
|
|
||||||
func handleImageResponse(c *gin.Context, img *model.Image) {
|
// ListGlobalTodayImages 获取所有地区的今日图片列表
|
||||||
|
// @Summary 获取所有地区的今日图片列表
|
||||||
|
// @Description 获取配置文件中所有已开启地区的今日必应图片元数据(缩略图)
|
||||||
|
// @Tags image
|
||||||
|
// @Produce json
|
||||||
|
// @Success 200 {array} ImageMetaResp
|
||||||
|
// @Router /images/global/today [get]
|
||||||
|
func ListGlobalTodayImages(c *gin.Context) {
|
||||||
|
images, err := image.GetAllRegionsTodayImages()
|
||||||
|
if err != nil {
|
||||||
|
util.Logger.Error("ListGlobalTodayImages service call failed", zap.Error(err))
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
result := []gin.H{}
|
||||||
|
for _, img := range images {
|
||||||
|
result = append(result, formatMetaSummary(&img))
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
func sendImageNotFound(c *gin.Context, mkt string) {
|
||||||
|
cfg := config.GetConfig().API
|
||||||
|
message := "image not found"
|
||||||
|
|
||||||
|
if mkt != "" {
|
||||||
|
reasons := []string{}
|
||||||
|
if !util.IsValidRegion(mkt) {
|
||||||
|
reasons = append(reasons, fmt.Sprintf("[%s] is not a standard region code", mkt))
|
||||||
|
} else {
|
||||||
|
if !cfg.EnableOnDemandFetch {
|
||||||
|
reasons = append(reasons, "on-demand fetch is disabled")
|
||||||
|
}
|
||||||
|
if !cfg.EnableMktFallback {
|
||||||
|
reasons = append(reasons, "region fallback is disabled")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(reasons) > 0 {
|
||||||
|
message = fmt.Sprintf("Image not found for region [%s]. Reasons: %s.", mkt, strings.Join(reasons, ", "))
|
||||||
|
} else {
|
||||||
|
message = fmt.Sprintf("Image not found for region [%s] even after on-demand fetch and fallback attempts.", mkt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": message})
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleImageResponse(c *gin.Context, m *model.ImageRegion, maxAge int) {
|
||||||
variant := c.DefaultQuery("variant", "UHD")
|
variant := c.DefaultQuery("variant", "UHD")
|
||||||
format := c.DefaultQuery("format", "jpg")
|
format := c.DefaultQuery("format", "jpg")
|
||||||
|
|
||||||
var selected *model.ImageVariant
|
var selected *model.ImageVariant
|
||||||
for _, v := range img.Variants {
|
for _, v := range m.Variants {
|
||||||
if v.Variant == variant && v.Format == format {
|
if v.Variant == variant && v.Format == format {
|
||||||
selected = &v
|
selected = &v
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if selected == nil && len(img.Variants) > 0 {
|
if selected == nil && len(m.Variants) > 0 {
|
||||||
// 回退逻辑
|
// 回退逻辑
|
||||||
selected = &img.Variants[0]
|
selected = &m.Variants[0]
|
||||||
}
|
}
|
||||||
|
|
||||||
if selected == nil {
|
if selected == nil {
|
||||||
@@ -228,22 +334,30 @@ 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天
|
if maxAge > 0 {
|
||||||
|
c.Header("Cache-Control", fmt.Sprintf("public, max-age=%d", maxAge))
|
||||||
|
} else {
|
||||||
|
c.Header("Cache-Control", "no-cache, no-store, must-revalidate")
|
||||||
|
}
|
||||||
c.Redirect(http.StatusFound, selected.PublicURL)
|
c.Redirect(http.StatusFound, selected.PublicURL)
|
||||||
} else if img.URLBase != "" {
|
} else if m.URLBase != "" {
|
||||||
// 兜底重定向到原始 Bing
|
// 兜底重定向到原始 Bing
|
||||||
bingURL := fmt.Sprintf("https://www.bing.com%s_%s.jpg", img.URLBase, selected.Variant)
|
bingURL := fmt.Sprintf("https://www.bing.com%s_%s.jpg", m.URLBase, selected.Variant)
|
||||||
c.Header("Cache-Control", "public, max-age=604800") // 7天
|
if maxAge > 0 {
|
||||||
|
c.Header("Cache-Control", fmt.Sprintf("public, max-age=%d", maxAge))
|
||||||
|
} else {
|
||||||
|
c.Header("Cache-Control", "no-cache, no-store, must-revalidate")
|
||||||
|
}
|
||||||
c.Redirect(http.StatusFound, bingURL)
|
c.Redirect(http.StatusFound, bingURL)
|
||||||
} else {
|
} else {
|
||||||
serveLocal(c, selected.StorageKey, img.Date)
|
serveLocal(c, selected.StorageKey, m.Date, maxAge)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
serveLocal(c, selected.StorageKey, img.Date)
|
serveLocal(c, selected.StorageKey, m.Date, maxAge)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func serveLocal(c *gin.Context, key string, etag string) {
|
func serveLocal(c *gin.Context, key string, etag string, maxAge int) {
|
||||||
if etag != "" {
|
if etag != "" {
|
||||||
c.Header("ETag", fmt.Sprintf("\"%s\"", etag))
|
c.Header("ETag", fmt.Sprintf("\"%s\"", etag))
|
||||||
if c.GetHeader("If-None-Match") == fmt.Sprintf("\"%s\"", etag) {
|
if c.GetHeader("If-None-Match") == fmt.Sprintf("\"%s\"", etag) {
|
||||||
@@ -263,19 +377,78 @@ func serveLocal(c *gin.Context, key string, etag string) {
|
|||||||
if contentType != "" {
|
if contentType != "" {
|
||||||
c.Header("Content-Type", contentType)
|
c.Header("Content-Type", contentType)
|
||||||
}
|
}
|
||||||
c.Header("Cache-Control", "public, max-age=604800") // 7天
|
|
||||||
|
if maxAge > 0 {
|
||||||
|
c.Header("Cache-Control", fmt.Sprintf("public, max-age=%d", maxAge))
|
||||||
|
} else {
|
||||||
|
c.Header("Cache-Control", "no-cache, no-store, must-revalidate")
|
||||||
|
}
|
||||||
io.Copy(c.Writer, reader)
|
io.Copy(c.Writer, reader)
|
||||||
}
|
}
|
||||||
|
|
||||||
func formatMeta(img *model.Image) gin.H {
|
func formatMetaSummary(m *model.ImageRegion) gin.H {
|
||||||
|
cfg := config.GetConfig()
|
||||||
|
|
||||||
|
// 找到最小的变体
|
||||||
|
var smallest *model.ImageVariant
|
||||||
|
for i := range m.Variants {
|
||||||
|
v := &m.Variants[i]
|
||||||
|
if smallest == nil {
|
||||||
|
smallest = v
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果当前变体 Size 更小且不为 0,或者 smallest 的 Size 为 0
|
||||||
|
if v.Size > 0 && (smallest.Size == 0 || v.Size < smallest.Size) {
|
||||||
|
smallest = v
|
||||||
|
} else if v.Size == smallest.Size {
|
||||||
|
// 如果 Size 相同(包括都为 0),根据分辨率名称判断
|
||||||
|
if compareResolution(v.Variant, smallest.Variant) < 0 {
|
||||||
|
smallest = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
variants := []gin.H{}
|
||||||
|
if smallest != nil {
|
||||||
|
url := smallest.PublicURL
|
||||||
|
if url == "" && cfg.API.Mode == "redirect" && m.URLBase != "" {
|
||||||
|
url = fmt.Sprintf("https://www.bing.com%s_%s.jpg", m.URLBase, smallest.Variant)
|
||||||
|
} else if cfg.API.Mode == "local" || url == "" {
|
||||||
|
url = fmt.Sprintf("%s/api/v1/image/date/%s?variant=%s&format=%s&mkt=%s", cfg.Server.BaseURL, m.Date, smallest.Variant, smallest.Format, m.Mkt)
|
||||||
|
}
|
||||||
|
variants = append(variants, gin.H{
|
||||||
|
"variant": smallest.Variant,
|
||||||
|
"format": smallest.Format,
|
||||||
|
"size": smallest.Size,
|
||||||
|
"url": url,
|
||||||
|
"storage_key": smallest.StorageKey,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return gin.H{
|
||||||
|
"date": m.Date,
|
||||||
|
"mkt": m.Mkt,
|
||||||
|
"title": m.Title,
|
||||||
|
"copyright": m.Copyright,
|
||||||
|
"copyrightlink": m.CopyrightLink,
|
||||||
|
"quiz": m.Quiz,
|
||||||
|
"startdate": m.StartDate,
|
||||||
|
"fullstartdate": m.FullStartDate,
|
||||||
|
"hsh": m.HSH,
|
||||||
|
"variants": variants,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatMeta(m *model.ImageRegion) gin.H {
|
||||||
cfg := config.GetConfig()
|
cfg := config.GetConfig()
|
||||||
variants := []gin.H{}
|
variants := []gin.H{}
|
||||||
for _, v := range img.Variants {
|
for _, v := range m.Variants {
|
||||||
url := v.PublicURL
|
url := v.PublicURL
|
||||||
if url == "" && cfg.API.Mode == "redirect" && img.URLBase != "" {
|
if url == "" && cfg.API.Mode == "redirect" && m.URLBase != "" {
|
||||||
url = fmt.Sprintf("https://www.bing.com%s_%s.jpg", img.URLBase, v.Variant)
|
url = fmt.Sprintf("https://www.bing.com%s_%s.jpg", m.URLBase, v.Variant)
|
||||||
} else if cfg.API.Mode == "local" || url == "" {
|
} else if cfg.API.Mode == "local" || url == "" {
|
||||||
url = fmt.Sprintf("%s/api/v1/image/date/%s?variant=%s&format=%s", cfg.Server.BaseURL, img.Date, v.Variant, v.Format)
|
url = fmt.Sprintf("%s/api/v1/image/date/%s?variant=%s&format=%s&mkt=%s", cfg.Server.BaseURL, m.Date, v.Variant, v.Format, m.Mkt)
|
||||||
}
|
}
|
||||||
variants = append(variants, gin.H{
|
variants = append(variants, gin.H{
|
||||||
"variant": v.Variant,
|
"variant": v.Variant,
|
||||||
@@ -287,14 +460,109 @@ func formatMeta(img *model.Image) gin.H {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return gin.H{
|
return gin.H{
|
||||||
"date": img.Date,
|
"date": m.Date,
|
||||||
"title": img.Title,
|
"mkt": m.Mkt,
|
||||||
"copyright": img.Copyright,
|
"title": m.Title,
|
||||||
"copyrightlink": img.CopyrightLink,
|
"copyright": m.Copyright,
|
||||||
"quiz": img.Quiz,
|
"copyrightlink": m.CopyrightLink,
|
||||||
"startdate": img.StartDate,
|
"quiz": m.Quiz,
|
||||||
"fullstartdate": img.FullStartDate,
|
"startdate": m.StartDate,
|
||||||
"hsh": img.HSH,
|
"fullstartdate": m.FullStartDate,
|
||||||
|
"hsh": m.HSH,
|
||||||
"variants": variants,
|
"variants": variants,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetRegions 获取支持的地区列表
|
||||||
|
// @Summary 获取支持的地区列表
|
||||||
|
// @Description 返回系统支持的所有必应地区编码及标签。如果配置中指定了抓取地区,这些地区将排在列表最前面(置顶)。
|
||||||
|
// @Tags image
|
||||||
|
// @Produce json
|
||||||
|
// @Success 200 {array} util.Region
|
||||||
|
// @Router /regions [get]
|
||||||
|
func GetRegions(c *gin.Context) {
|
||||||
|
cfg := config.GetConfig()
|
||||||
|
pinned := cfg.Fetcher.Regions
|
||||||
|
|
||||||
|
if len(pinned) == 0 {
|
||||||
|
// 如果没有配置抓取地区,返回所有支持的地区
|
||||||
|
c.JSON(http.StatusOK, util.AllRegions)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建一个 Map 用于快速查找配置的地区
|
||||||
|
pinnedMap := make(map[string]bool)
|
||||||
|
for _, v := range pinned {
|
||||||
|
pinnedMap[v] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// 只返回配置中的地区,并保持配置中的顺序
|
||||||
|
var result []util.Region
|
||||||
|
// 为了保持配置顺序,我们遍历 pinned 而不是 AllRegions
|
||||||
|
for _, pVal := range pinned {
|
||||||
|
for _, r := range util.AllRegions {
|
||||||
|
if r.Value == pVal {
|
||||||
|
result = append(result, r)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果配置了一些不在 AllRegions 里的 mkt,上述循环可能漏掉
|
||||||
|
// 但根据之前的逻辑,AllRegions 是已知的 17 个地区。
|
||||||
|
// 如果用户配置了 fr-CA (不在 17 个内),我们也应该返回它吗?
|
||||||
|
// 需求说 "前端页面对地区进行约束",如果配置了,前端就该显示。
|
||||||
|
// 如果不在 AllRegions 里的,我们直接返回原始编码作为 label 或者查找一下。
|
||||||
|
|
||||||
|
if len(result) < len(pinned) {
|
||||||
|
// 补全不在 AllRegions 里的地区
|
||||||
|
for _, pVal := range pinned {
|
||||||
|
found := false
|
||||||
|
for _, r := range result {
|
||||||
|
if r.Value == pVal {
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
result = append(result, util.Region{Value: pVal, Label: pVal})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
// compareResolution 比较两个分辨率变体的大小。
|
||||||
|
// 返回 < 0 表示 v1 < v2,返回 > 0 表示 v1 > v2,返回 0 表示相等。
|
||||||
|
func compareResolution(v1, v2 string) int {
|
||||||
|
resOrder := map[string]int{
|
||||||
|
"320x240": 1,
|
||||||
|
"400x240": 2,
|
||||||
|
"480x360": 3,
|
||||||
|
"640x360": 4,
|
||||||
|
"640x480": 5,
|
||||||
|
"800x480": 6,
|
||||||
|
"800x600": 7,
|
||||||
|
"1024x768": 8,
|
||||||
|
"1280x720": 9,
|
||||||
|
"1366x768": 10,
|
||||||
|
"1920x1080": 11,
|
||||||
|
"UHD": 12,
|
||||||
|
}
|
||||||
|
|
||||||
|
o1, ok1 := resOrder[v1]
|
||||||
|
o2, ok2 := resOrder[v2]
|
||||||
|
|
||||||
|
if !ok1 && !ok2 {
|
||||||
|
return strings.Compare(v1, v2)
|
||||||
|
}
|
||||||
|
if !ok1 {
|
||||||
|
return 1 // 未知的分辨率认为比已知的大
|
||||||
|
}
|
||||||
|
if !ok2 {
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
|
||||||
|
return o1 - o2
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package handlers
|
package handlers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"testing"
|
"testing"
|
||||||
@@ -20,11 +21,15 @@ func TestHandleImageResponseRedirect(t *testing.T) {
|
|||||||
config.GetConfig().API.Mode = "redirect"
|
config.GetConfig().API.Mode = "redirect"
|
||||||
|
|
||||||
// Mock Image and Variant
|
// Mock Image and Variant
|
||||||
img := &model.Image{
|
imgRegion := &model.ImageRegion{
|
||||||
Date: "2026-01-26",
|
Date: "2026-01-26",
|
||||||
|
Mkt: "zh-CN",
|
||||||
|
HSH: "testhsh",
|
||||||
|
ImageName: "TestImage",
|
||||||
URLBase: "/th?id=OHR.TestImage",
|
URLBase: "/th?id=OHR.TestImage",
|
||||||
Variants: []model.ImageVariant{
|
Variants: []model.ImageVariant{
|
||||||
{
|
{
|
||||||
|
ImageName: "TestImage",
|
||||||
Variant: "UHD",
|
Variant: "UHD",
|
||||||
Format: "jpg",
|
Format: "jpg",
|
||||||
PublicURL: "", // Empty for local storage simulation
|
PublicURL: "", // Empty for local storage simulation
|
||||||
@@ -38,7 +43,7 @@ func TestHandleImageResponseRedirect(t *testing.T) {
|
|||||||
c, _ := gin.CreateTestContext(w)
|
c, _ := gin.CreateTestContext(w)
|
||||||
c.Request, _ = http.NewRequest("GET", "/api/v1/image/today?variant=UHD", nil)
|
c.Request, _ = http.NewRequest("GET", "/api/v1/image/today?variant=UHD", nil)
|
||||||
|
|
||||||
handleImageResponse(c, img)
|
handleImageResponse(c, imgRegion, 0)
|
||||||
|
|
||||||
assert.Equal(t, http.StatusFound, w.Code)
|
assert.Equal(t, http.StatusFound, w.Code)
|
||||||
assert.Contains(t, w.Header().Get("Location"), "bing.com")
|
assert.Contains(t, w.Header().Get("Location"), "bing.com")
|
||||||
@@ -47,7 +52,7 @@ func TestHandleImageResponseRedirect(t *testing.T) {
|
|||||||
|
|
||||||
t.Run("FormatMeta in redirect mode should return Bing URL if PublicURL is empty", func(t *testing.T) {
|
t.Run("FormatMeta in redirect mode should return Bing URL if PublicURL is empty", func(t *testing.T) {
|
||||||
config.GetConfig().API.Mode = "redirect"
|
config.GetConfig().API.Mode = "redirect"
|
||||||
meta := formatMeta(img)
|
meta := formatMeta(imgRegion)
|
||||||
|
|
||||||
variants := meta["variants"].([]gin.H)
|
variants := meta["variants"].([]gin.H)
|
||||||
assert.Equal(t, 1, len(variants))
|
assert.Equal(t, 1, len(variants))
|
||||||
@@ -58,11 +63,66 @@ func TestHandleImageResponseRedirect(t *testing.T) {
|
|||||||
t.Run("FormatMeta in local mode should return API URL", func(t *testing.T) {
|
t.Run("FormatMeta in local mode should return API URL", func(t *testing.T) {
|
||||||
config.GetConfig().API.Mode = "local"
|
config.GetConfig().API.Mode = "local"
|
||||||
config.GetConfig().Server.BaseURL = "http://myserver.com"
|
config.GetConfig().Server.BaseURL = "http://myserver.com"
|
||||||
meta := formatMeta(img)
|
meta := formatMeta(imgRegion)
|
||||||
|
|
||||||
variants := meta["variants"].([]gin.H)
|
variants := meta["variants"].([]gin.H)
|
||||||
assert.Equal(t, 1, len(variants))
|
assert.Equal(t, 1, len(variants))
|
||||||
assert.Contains(t, variants[0]["url"].(string), "myserver.com")
|
assert.Contains(t, variants[0]["url"].(string), "myserver.com")
|
||||||
assert.Contains(t, variants[0]["url"].(string), "/api/v1/image/date/")
|
assert.Contains(t, variants[0]["url"].(string), "/api/v1/image/date/")
|
||||||
})
|
})
|
||||||
|
|
||||||
|
t.Run("FormatMetaSummary should only return the smallest variant", func(t *testing.T) {
|
||||||
|
imgWithMultipleVariants := &model.ImageRegion{
|
||||||
|
Date: "2026-01-26",
|
||||||
|
ImageName: "TestImage2",
|
||||||
|
Variants: []model.ImageVariant{
|
||||||
|
{ImageName: "TestImage2", Variant: "UHD", Size: 1000, Format: "jpg"},
|
||||||
|
{ImageName: "TestImage2", Variant: "640x480", Size: 200, Format: "jpg"},
|
||||||
|
{ImageName: "TestImage2", Variant: "1920x1080", Size: 500, Format: "jpg"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
meta := formatMetaSummary(imgWithMultipleVariants)
|
||||||
|
variants := meta["variants"].([]gin.H)
|
||||||
|
assert.Equal(t, 1, len(variants))
|
||||||
|
assert.Equal(t, "640x480", variants[0]["variant"])
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("FormatMetaSummary should handle zero size by following order if names suggest it", func(t *testing.T) {
|
||||||
|
imgWithZeroSize := &model.ImageRegion{
|
||||||
|
Date: "2026-01-26",
|
||||||
|
ImageName: "TestImage3",
|
||||||
|
Variants: []model.ImageVariant{
|
||||||
|
{ImageName: "TestImage3", Variant: "UHD", Size: 0, Format: "jpg"},
|
||||||
|
{ImageName: "TestImage3", Variant: "320x240", Size: 0, Format: "jpg"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
meta := formatMetaSummary(imgWithZeroSize)
|
||||||
|
variants := meta["variants"].([]gin.H)
|
||||||
|
assert.Equal(t, 1, len(variants))
|
||||||
|
assert.Equal(t, "320x240", variants[0]["variant"])
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetRegions(t *testing.T) {
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
|
||||||
|
t.Run("GetRegions should respect pinned order", func(t *testing.T) {
|
||||||
|
// Setup config with custom pinned regions
|
||||||
|
config.Init("")
|
||||||
|
config.GetConfig().Fetcher.Regions = []string{"en-US", "ja-JP"}
|
||||||
|
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
c, _ := gin.CreateTestContext(w)
|
||||||
|
GetRegions(c)
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusOK, w.Code)
|
||||||
|
|
||||||
|
var regions []map[string]string
|
||||||
|
err := json.Unmarshal(w.Body.Bytes(), ®ions)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
assert.GreaterOrEqual(t, len(regions), 2)
|
||||||
|
assert.Equal(t, "en-US", regions[0]["value"])
|
||||||
|
assert.Equal(t, "ja-JP", regions[1]["value"])
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,6 +47,8 @@ func SetupRouter(webFS embed.FS) *gin.Engine {
|
|||||||
img.GET("/date/:date/meta", handlers.GetByDateMeta)
|
img.GET("/date/:date/meta", handlers.GetByDateMeta)
|
||||||
}
|
}
|
||||||
api.GET("/images", handlers.ListImages)
|
api.GET("/images", handlers.ListImages)
|
||||||
|
api.GET("/images/global/today", handlers.ListGlobalTodayImages)
|
||||||
|
api.GET("/regions", handlers.GetRegions)
|
||||||
|
|
||||||
// 管理接口
|
// 管理接口
|
||||||
admin := api.Group("/admin")
|
admin := api.Group("/admin")
|
||||||
@@ -80,7 +82,7 @@ func SetupRouter(webFS embed.FS) *gin.Engine {
|
|||||||
path := c.Request.URL.Path
|
path := c.Request.URL.Path
|
||||||
|
|
||||||
// 如果请求的是 API 或 Swagger,则不处理静态资源 (让其返回 404)
|
// 如果请求的是 API 或 Swagger,则不处理静态资源 (让其返回 404)
|
||||||
if strings.HasPrefix(path, "/api") || strings.HasPrefix(path, "/swagger") {
|
if strings.HasPrefix(path, "/api/v1") || strings.HasPrefix(path, "/swagger") {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,28 +6,30 @@ import (
|
|||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Image struct {
|
type ImageRegion struct {
|
||||||
ID uint `gorm:"primaryKey" json:"id"`
|
ID uint `gorm:"primaryKey" json:"id"`
|
||||||
Date string `gorm:"uniqueIndex;type:varchar(10)" json:"date"` // YYYY-MM-DD
|
Date string `gorm:"uniqueIndex:idx_date_mkt;index:idx_mkt_date,priority:2;type:varchar(10)" json:"date"` // YYYY-MM-DD
|
||||||
|
Mkt string `gorm:"uniqueIndex:idx_date_mkt;index:idx_mkt_date,priority:1;type:varchar(10)" json:"mkt"` // zh-CN, en-US etc.
|
||||||
|
HSH string `gorm:"type:varchar(64)" json:"hsh"`
|
||||||
|
URLBase string `json:"urlbase"`
|
||||||
|
ImageName string `gorm:"index" json:"image_name"`
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
Copyright string `json:"copyright"`
|
Copyright string `json:"copyright"`
|
||||||
CopyrightLink string `json:"copyrightlink"`
|
CopyrightLink string `json:"copyrightlink"`
|
||||||
URLBase string `json:"urlbase"`
|
|
||||||
Quiz string `json:"quiz"`
|
Quiz string `json:"quiz"`
|
||||||
StartDate string `json:"startdate"`
|
StartDate string `json:"startdate"`
|
||||||
FullStartDate string `json:"fullstartdate"`
|
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;constraint:OnUpdate:CASCADE,OnDelete:SET NULL;" json:"variants"`
|
Variants []ImageVariant `gorm:"foreignKey:ImageName;references:ImageName" json:"variants"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type ImageVariant struct {
|
type ImageVariant struct {
|
||||||
ID uint `gorm:"primaryKey" json:"id"`
|
ID uint `gorm:"primaryKey" json:"id"`
|
||||||
ImageID uint `gorm:"index;uniqueIndex:idx_image_variant_format" json:"image_id"`
|
ImageName string `gorm:"uniqueIndex:idx_name_variant_format;type:varchar(100)" json:"image_name"`
|
||||||
Variant string `gorm:"uniqueIndex:idx_image_variant_format;type:varchar(20)" json:"variant"` // UHD, 1920x1080, etc.
|
Variant string `gorm:"uniqueIndex:idx_name_variant_format;type:varchar(20)" json:"variant"` // UHD, 1920x1080, etc.
|
||||||
Format string `gorm:"uniqueIndex:idx_image_variant_format;type:varchar(10)" json:"format"` // jpg, webp
|
Format string `gorm:"uniqueIndex:idx_name_variant_format;type:varchar(10)" json:"format"` // jpg, webp
|
||||||
StorageKey string `json:"storage_key"`
|
StorageKey string `json:"storage_key"`
|
||||||
PublicURL string `json:"public_url"`
|
PublicURL string `json:"public_url"`
|
||||||
Size int64 `json:"size"`
|
Size int64 `json:"size"`
|
||||||
|
|||||||
@@ -131,7 +131,7 @@ func InitDB() error {
|
|||||||
// 但此处假设 DSN 中指定的数据库已经存在。AutoMigrate 会负责创建表。
|
// 但此处假设 DSN 中指定的数据库已经存在。AutoMigrate 会负责创建表。
|
||||||
|
|
||||||
// 迁移
|
// 迁移
|
||||||
if err := db.AutoMigrate(&model.Image{}, &model.ImageVariant{}, &model.Token{}); err != nil {
|
if err := db.AutoMigrate(&model.ImageRegion{}, &model.ImageVariant{}, &model.Token{}); err != nil {
|
||||||
util.Logger.Error("Database migration failed", zap.Error(err))
|
util.Logger.Error("Database migration failed", zap.Error(err))
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,19 +29,17 @@ func MigrateDataToNewDB(oldDB *gorm.DB, newConfig *config.Config) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 2. 自动迁移结构
|
// 2. 自动迁移结构
|
||||||
if err := newDB.AutoMigrate(&model.Image{}, &model.ImageVariant{}, &model.Token{}); err != nil {
|
if err := newDB.AutoMigrate(&model.ImageRegion{}, &model.ImageVariant{}, &model.Token{}); err != nil {
|
||||||
return fmt.Errorf("failed to migrate schema in new DB: %w", err)
|
return fmt.Errorf("failed to migrate schema in new DB: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. 清空新数据库中的现有数据(防止冲突)
|
// 3. 清空新数据库中的现有数据(防止冲突)
|
||||||
util.Logger.Info("Cleaning up destination database before migration")
|
util.Logger.Info("Cleaning up destination database before migration")
|
||||||
// 备份或清空目标数据库。由于用户要求“可能需要清空或备份”,
|
|
||||||
// 这里我们选择在迁移前清空目标表,以确保迁移过来的数据是完整且不冲突的。
|
|
||||||
if err := newDB.Session(&gorm.Session{AllowGlobalUpdate: true}).Delete(&model.ImageVariant{}).Error; err != nil {
|
if err := newDB.Session(&gorm.Session{AllowGlobalUpdate: true}).Delete(&model.ImageVariant{}).Error; err != nil {
|
||||||
return fmt.Errorf("failed to clear ImageVariants: %w", err)
|
return fmt.Errorf("failed to clear ImageVariants: %w", err)
|
||||||
}
|
}
|
||||||
if err := newDB.Session(&gorm.Session{AllowGlobalUpdate: true}).Delete(&model.Image{}).Error; err != nil {
|
if err := newDB.Session(&gorm.Session{AllowGlobalUpdate: true}).Delete(&model.ImageRegion{}).Error; err != nil {
|
||||||
return fmt.Errorf("failed to clear Images: %w", err)
|
return fmt.Errorf("failed to clear ImageRegions: %w", err)
|
||||||
}
|
}
|
||||||
if err := newDB.Session(&gorm.Session{AllowGlobalUpdate: true}).Delete(&model.Token{}).Error; err != nil {
|
if err := newDB.Session(&gorm.Session{AllowGlobalUpdate: true}).Delete(&model.Token{}).Error; err != nil {
|
||||||
return fmt.Errorf("failed to clear Tokens: %w", err)
|
return fmt.Errorf("failed to clear Tokens: %w", err)
|
||||||
@@ -50,15 +48,15 @@ func MigrateDataToNewDB(oldDB *gorm.DB, newConfig *config.Config) error {
|
|||||||
// 4. 开始迁移数据
|
// 4. 开始迁移数据
|
||||||
// 使用事务确保迁移的原子性
|
// 使用事务确保迁移的原子性
|
||||||
return newDB.Transaction(func(tx *gorm.DB) error {
|
return newDB.Transaction(func(tx *gorm.DB) error {
|
||||||
// 迁移 Images
|
// 迁移 ImageRegions
|
||||||
var images []model.Image
|
var regions []model.ImageRegion
|
||||||
if err := oldDB.Find(&images).Error; err != nil {
|
if err := oldDB.Find(®ions).Error; err != nil {
|
||||||
return fmt.Errorf("failed to fetch images from old DB: %w", err)
|
return fmt.Errorf("failed to fetch image regions from old DB: %w", err)
|
||||||
}
|
}
|
||||||
if len(images) > 0 {
|
if len(regions) > 0 {
|
||||||
util.Logger.Info("Migrating images", zap.Int("count", len(images)))
|
util.Logger.Info("Migrating image regions", zap.Int("count", len(regions)))
|
||||||
if err := tx.Create(&images).Error; err != nil {
|
if err := tx.Create(®ions).Error; err != nil {
|
||||||
return fmt.Errorf("failed to insert images into new DB: %w", err)
|
return fmt.Errorf("failed to insert image regions into new DB: %w", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"BingPaper/internal/config"
|
"BingPaper/internal/config"
|
||||||
@@ -55,26 +56,14 @@ func NewFetcher() *Fetcher {
|
|||||||
|
|
||||||
func (f *Fetcher) Fetch(ctx context.Context, n int) error {
|
func (f *Fetcher) Fetch(ctx context.Context, n int) error {
|
||||||
util.Logger.Info("Starting fetch task", zap.Int("n", n))
|
util.Logger.Info("Starting fetch task", zap.Int("n", n))
|
||||||
url := fmt.Sprintf("%s?format=js&idx=0&n=%d&uhd=1&mkt=%s", config.BingAPIBase, n, config.BingMkt)
|
regions := config.GetConfig().Fetcher.Regions
|
||||||
util.Logger.Debug("Requesting Bing API", zap.String("url", url))
|
if len(regions) == 0 {
|
||||||
resp, err := f.httpClient.Get(url)
|
regions = []string{config.GetConfig().GetDefaultRegion()}
|
||||||
if err != nil {
|
|
||||||
util.Logger.Error("Failed to request Bing API", zap.Error(err))
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
var bingResp BingResponse
|
|
||||||
if err := json.NewDecoder(resp.Body).Decode(&bingResp); err != nil {
|
|
||||||
util.Logger.Error("Failed to decode Bing API response", zap.Error(err))
|
|
||||||
return err
|
|
||||||
}
|
}
|
||||||
|
|
||||||
util.Logger.Info("Fetched images from Bing", zap.Int("count", len(bingResp.Images)))
|
for _, mkt := range regions {
|
||||||
|
if err := f.FetchRegion(ctx, mkt); err != nil {
|
||||||
for _, bingImg := range bingResp.Images {
|
util.Logger.Error("Failed to fetch region images", zap.String("mkt", mkt), zap.Error(err))
|
||||||
if err := f.processImage(ctx, bingImg); err != nil {
|
|
||||||
util.Logger.Error("Failed to process image", zap.String("date", bingImg.Enddate), zap.Error(err))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -82,66 +71,88 @@ func (f *Fetcher) Fetch(ctx context.Context, n int) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f *Fetcher) processImage(ctx context.Context, bingImg BingImage) error {
|
// FetchRegion 抓取指定地区的图片
|
||||||
dateStr := fmt.Sprintf("%s-%s-%s", bingImg.Enddate[0:4], bingImg.Enddate[4:6], bingImg.Enddate[6:8])
|
func (f *Fetcher) FetchRegion(ctx context.Context, mkt string) error {
|
||||||
|
if !util.IsValidRegion(mkt) {
|
||||||
// 幂等检查
|
util.Logger.Warn("Skipping fetch for invalid region", zap.String("mkt", mkt))
|
||||||
var existing model.Image
|
return fmt.Errorf("invalid region code: %s", mkt)
|
||||||
if err := repo.DB.Where("date = ?", dateStr).First(&existing).Error; err == nil {
|
}
|
||||||
util.Logger.Info("Image already exists, skipping", zap.String("date", dateStr))
|
util.Logger.Info("Fetching images for region", zap.String("mkt", mkt))
|
||||||
|
// 调用两次 API 获取最多两周的数据
|
||||||
|
// 第一次 idx=0&n=8 (今天起往回数 8 张)
|
||||||
|
if err := f.fetchByMkt(ctx, mkt, 0, 8); err != nil {
|
||||||
|
util.Logger.Error("Failed to fetch images", zap.String("mkt", mkt), zap.Int("idx", 0), zap.Error(err))
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// 第二次 idx=7&n=8 (7天前起往回数 8 张,与第一次有重叠,确保不漏)
|
||||||
|
if err := f.fetchByMkt(ctx, mkt, 7, 8); err != nil {
|
||||||
|
util.Logger.Error("Failed to fetch images", zap.String("mkt", mkt), zap.Int("idx", 7), zap.Error(err))
|
||||||
|
// 第二次失败不一定返回错误,因为可能第一次已经拿到了
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
util.Logger.Info("Processing new image", zap.String("date", dateStr), zap.String("title", bingImg.Title))
|
func (f *Fetcher) fetchByMkt(ctx context.Context, mkt string, idx int, n int) error {
|
||||||
|
lang := strings.Split(mkt, "-")[0]
|
||||||
|
url := fmt.Sprintf("%s?format=js&idx=%d&n=%d&uhd=1&mkt=%s&setlang=%s", config.BingAPIBase, idx, n, mkt, lang)
|
||||||
|
util.Logger.Info("Requesting Bing API", zap.String("url", url))
|
||||||
|
|
||||||
// UHD 探测
|
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
|
||||||
imgURL, variantName := f.probeUHD(bingImg.URLBase)
|
|
||||||
|
|
||||||
imgData, err := f.downloadImage(imgURL)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
util.Logger.Error("Failed to download image", zap.String("url", imgURL), zap.Error(err))
|
util.Logger.Error("Failed to create Bing API request", zap.Error(err))
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// 解码图片用于缩放
|
// 添加请求头以增强地区/语言识别
|
||||||
srcImg, _, err := image.Decode(bytes.NewReader(imgData))
|
req.Header.Set("Accept-Language", fmt.Sprintf("%s,%s;q=0.9", mkt, lang))
|
||||||
|
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36")
|
||||||
|
|
||||||
|
resp, err := f.httpClient.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
util.Logger.Error("Failed to decode image data", zap.Error(err))
|
util.Logger.Error("Failed to request Bing API", zap.Error(err))
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
util.Logger.Info("Received response from Bing API", zap.String("mkt", mkt), zap.Int("status", resp.StatusCode))
|
||||||
|
|
||||||
|
var bingResp BingResponse
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&bingResp); err != nil {
|
||||||
|
util.Logger.Error("Failed to decode Bing API response", zap.Error(err))
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// 创建 DB 记录
|
util.Logger.Info("Fetched images from Bing", zap.String("mkt", mkt), zap.Int("count", len(bingResp.Images)))
|
||||||
dbImg := model.Image{
|
|
||||||
Date: dateStr,
|
for _, bingImg := range bingResp.Images {
|
||||||
Title: bingImg.Title,
|
util.Logger.Info("Bing image metadata",
|
||||||
Copyright: bingImg.Copyright,
|
zap.String("mkt", mkt),
|
||||||
CopyrightLink: bingImg.CopyrightLink,
|
zap.String("date", bingImg.Enddate),
|
||||||
URLBase: bingImg.URLBase,
|
zap.String("title", bingImg.Title),
|
||||||
Quiz: bingImg.Quiz,
|
zap.String("hsh", bingImg.HSH))
|
||||||
StartDate: bingImg.Startdate,
|
|
||||||
FullStartDate: bingImg.Fullstartdate,
|
if err := f.processImage(ctx, bingImg, mkt); err != nil {
|
||||||
HSH: bingImg.HSH,
|
util.Logger.Error("Failed to process image", zap.String("date", bingImg.Enddate), zap.String("mkt", mkt), zap.Error(err))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := repo.DB.Clauses(clause.OnConflict{
|
return nil
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 再次检查 dbImg.ID 是否被填充,如果没有被填充(说明由于冲突未插入),则需要查询出已有的 ID
|
func (f *Fetcher) processImage(ctx context.Context, bingImg BingImage, mkt string) error {
|
||||||
if dbImg.ID == 0 {
|
dateStr := fmt.Sprintf("%s-%s-%s", bingImg.Enddate[0:4], bingImg.Enddate[4:6], bingImg.Enddate[6:8])
|
||||||
var existing model.Image
|
|
||||||
if err := repo.DB.Where("date = ?", dateStr).First(&existing).Error; err != nil {
|
// 1. 地区关联幂等检查
|
||||||
util.Logger.Error("Failed to query existing image record after conflict", zap.Error(err))
|
var existingRegion model.ImageRegion
|
||||||
return err
|
if err := repo.DB.Where("date = ? AND mkt = ?", dateStr, mkt).First(&existingRegion).Error; err == nil {
|
||||||
}
|
util.Logger.Info("ImageRegion record already exists, skipping", zap.String("date", dateStr), zap.String("mkt", mkt), zap.String("title", bingImg.Title))
|
||||||
dbImg = existing
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// 保存各种分辨率
|
imageName := f.extractImageName(bingImg.URLBase, bingImg.HSH)
|
||||||
|
|
||||||
|
// 2. 处理变体
|
||||||
|
imgURL, variantName := f.probeUHD(ctx, bingImg.URLBase)
|
||||||
targetVariants := []struct {
|
targetVariants := []struct {
|
||||||
name string
|
name string
|
||||||
width int
|
width int
|
||||||
@@ -160,17 +171,41 @@ func (f *Fetcher) processImage(ctx context.Context, bingImg BingImage) error {
|
|||||||
{"320x240", 320, 240},
|
{"320x240", 320, 240},
|
||||||
}
|
}
|
||||||
|
|
||||||
// 首先保存原图 (UHD 或 1080p)
|
// 检查变体是否已存在 (通过 ImageName)
|
||||||
if err := f.saveVariant(ctx, &dbImg, variantName, "jpg", imgData); err != nil {
|
var existingVariants []model.ImageVariant
|
||||||
|
repo.DB.Where("image_name = ?", imageName).Find(&existingVariants)
|
||||||
|
|
||||||
|
allVariantsExist := len(existingVariants) > 0
|
||||||
|
|
||||||
|
var srcImg image.Image
|
||||||
|
var imgData []byte
|
||||||
|
|
||||||
|
if allVariantsExist {
|
||||||
|
util.Logger.Debug("Image variants already exist for name, linking only", zap.String("imageName", imageName))
|
||||||
|
} else {
|
||||||
|
util.Logger.Debug("Downloading and processing image", zap.String("url", imgURL), zap.String("imageName", imageName))
|
||||||
|
var err error
|
||||||
|
imgData, err = f.downloadImage(ctx, imgURL)
|
||||||
|
if err != nil {
|
||||||
|
util.Logger.Error("Failed to download image", zap.String("url", imgURL), zap.Error(err))
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
srcImg, _, err = image.Decode(bytes.NewReader(imgData))
|
||||||
|
if err != nil {
|
||||||
|
util.Logger.Error("Failed to decode image data", zap.Error(err))
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 保存原图变体
|
||||||
|
if err := f.saveVariant(ctx, imageName, variantName, "jpg", imgData); err != nil {
|
||||||
util.Logger.Error("Failed to save original variant", zap.String("variant", variantName), zap.Error(err))
|
util.Logger.Error("Failed to save original variant", zap.String("variant", variantName), zap.Error(err))
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, v := range targetVariants {
|
for _, v := range targetVariants {
|
||||||
// 如果目标分辨率就是原图分辨率,则跳过(已经保存过了)
|
|
||||||
if v.name == variantName {
|
if v.name == variantName {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
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: 100}); err != nil {
|
if err := jpeg.Encode(buf, resized, &jpeg.Options{Quality: 100}); err != nil {
|
||||||
@@ -178,33 +213,96 @@ func (f *Fetcher) processImage(ctx context.Context, bingImg BingImage) error {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
currentImgData := buf.Bytes()
|
currentImgData := buf.Bytes()
|
||||||
|
if err := f.saveVariant(ctx, imageName, v.name, "jpg", currentImgData); err != nil {
|
||||||
// 保存 JPG
|
|
||||||
if err := f.saveVariant(ctx, &dbImg, v.name, "jpg", currentImgData); err != nil {
|
|
||||||
util.Logger.Error("Failed to save variant", zap.String("variant", v.name), zap.Error(err))
|
util.Logger.Error("Failed to save variant", zap.String("variant", v.name), zap.Error(err))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 保存今日额外文件
|
// 3. 创建 ImageRegion 记录
|
||||||
|
regionRecord := model.ImageRegion{
|
||||||
|
HSH: bingImg.HSH,
|
||||||
|
URLBase: bingImg.URLBase,
|
||||||
|
ImageName: imageName,
|
||||||
|
Date: dateStr,
|
||||||
|
Mkt: mkt,
|
||||||
|
Title: bingImg.Title,
|
||||||
|
Copyright: bingImg.Copyright,
|
||||||
|
CopyrightLink: bingImg.CopyrightLink,
|
||||||
|
Quiz: bingImg.Quiz,
|
||||||
|
StartDate: bingImg.Startdate,
|
||||||
|
FullStartDate: bingImg.Fullstartdate,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := repo.DB.Clauses(clause.OnConflict{
|
||||||
|
Columns: []clause.Column{{Name: "date"}, {Name: "mkt"}},
|
||||||
|
UpdateAll: true,
|
||||||
|
}).Create(®ionRecord).Error; err != nil {
|
||||||
|
util.Logger.Error("Failed to create region record", zap.Error(err))
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
util.Logger.Info("Successfully saved/updated ImageRegion record to database",
|
||||||
|
zap.String("date", dateStr),
|
||||||
|
zap.String("mkt", mkt),
|
||||||
|
zap.String("title", regionRecord.Title))
|
||||||
|
|
||||||
|
// 4. 保存今日额外文件
|
||||||
today := time.Now().Format("2006-01-02")
|
today := time.Now().Format("2006-01-02")
|
||||||
if dateStr == today && config.GetConfig().Feature.WriteDailyFiles {
|
if dateStr == today && config.GetConfig().Feature.WriteDailyFiles {
|
||||||
f.saveDailyFiles(srcImg, imgData)
|
if imgData != nil && srcImg != nil {
|
||||||
|
f.saveDailyFiles(srcImg, imgData, mkt)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f *Fetcher) probeUHD(urlBase string) (string, string) {
|
func (f *Fetcher) extractImageName(urlBase, hsh string) string {
|
||||||
|
// 示例: /th?id=OHR.MilwaukeeHall_ROW0871854348
|
||||||
|
start := 0
|
||||||
|
if idx := strings.Index(urlBase, "OHR."); idx != -1 {
|
||||||
|
start = idx + 4
|
||||||
|
} else if idx := strings.Index(urlBase, "id="); idx != -1 {
|
||||||
|
start = idx + 3
|
||||||
|
}
|
||||||
|
|
||||||
|
rem := urlBase[start:]
|
||||||
|
end := strings.Index(rem, "_")
|
||||||
|
if end == -1 {
|
||||||
|
end = len(rem)
|
||||||
|
}
|
||||||
|
|
||||||
|
name := rem[:end]
|
||||||
|
if name == "" {
|
||||||
|
return hsh
|
||||||
|
}
|
||||||
|
return name
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *Fetcher) probeUHD(ctx context.Context, urlBase string) (string, string) {
|
||||||
uhdURL := fmt.Sprintf("https://www.bing.com%s_UHD.jpg", urlBase)
|
uhdURL := fmt.Sprintf("https://www.bing.com%s_UHD.jpg", urlBase)
|
||||||
resp, err := f.httpClient.Head(uhdURL)
|
req, err := http.NewRequestWithContext(ctx, "HEAD", uhdURL, nil)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Sprintf("https://www.bing.com%s_1920x1080.jpg", urlBase), "1920x1080"
|
||||||
|
}
|
||||||
|
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36")
|
||||||
|
|
||||||
|
resp, err := f.httpClient.Do(req)
|
||||||
if err == nil && resp.StatusCode == http.StatusOK {
|
if err == nil && resp.StatusCode == http.StatusOK {
|
||||||
return uhdURL, "UHD"
|
return uhdURL, "UHD"
|
||||||
}
|
}
|
||||||
return fmt.Sprintf("https://www.bing.com%s_1920x1080.jpg", urlBase), "1920x1080"
|
return fmt.Sprintf("https://www.bing.com%s_1920x1080.jpg", urlBase), "1920x1080"
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f *Fetcher) downloadImage(url string) ([]byte, error) {
|
func (f *Fetcher) downloadImage(ctx context.Context, url string) ([]byte, error) {
|
||||||
resp, err := f.httpClient.Get(url)
|
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36")
|
||||||
|
|
||||||
|
resp, err := f.httpClient.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -212,47 +310,84 @@ func (f *Fetcher) downloadImage(url string) ([]byte, error) {
|
|||||||
return io.ReadAll(resp.Body)
|
return io.ReadAll(resp.Body)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f *Fetcher) saveVariant(ctx context.Context, img *model.Image, variant, format string, data []byte) error {
|
func (f *Fetcher) generateKey(imageName, variant, format string) string {
|
||||||
key := fmt.Sprintf("%s/%s_%s.%s", img.Date, img.Date, variant, format)
|
return fmt.Sprintf("%s/%s_%s.%s", imageName, imageName, variant, format)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *Fetcher) saveVariant(ctx context.Context, imageName, variant, format string, data []byte) error {
|
||||||
|
key := f.generateKey(imageName, variant, format)
|
||||||
contentType := "image/jpeg"
|
contentType := "image/jpeg"
|
||||||
if format == "webp" {
|
if format == "webp" {
|
||||||
contentType = "image/webp"
|
contentType = "image/webp"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var size int64
|
||||||
|
var publicURL string
|
||||||
|
|
||||||
|
exists, _ := storage.GlobalStorage.Exists(ctx, key)
|
||||||
|
if exists {
|
||||||
|
util.Logger.Debug("Variant already exists in storage, linking", zap.String("key", key))
|
||||||
|
// 如果存在,尝试获取公共 URL
|
||||||
|
if pURL, ok := storage.GlobalStorage.PublicURL(key); ok {
|
||||||
|
publicURL = pURL
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果传入了数据,则使用数据大小
|
||||||
|
if data != nil {
|
||||||
|
size = int64(len(data))
|
||||||
|
}
|
||||||
|
} else if data != nil {
|
||||||
|
util.Logger.Debug("Saving variant to storage", zap.String("key", key))
|
||||||
stored, err := storage.GlobalStorage.Put(ctx, key, bytes.NewReader(data), contentType)
|
stored, err := storage.GlobalStorage.Put(ctx, key, bytes.NewReader(data), contentType)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
publicURL = stored.PublicURL
|
||||||
|
size = stored.Size
|
||||||
|
} else {
|
||||||
|
return fmt.Errorf("variant %s does not exist and no data provided", key)
|
||||||
|
}
|
||||||
|
|
||||||
vRecord := model.ImageVariant{
|
vRecord := model.ImageVariant{
|
||||||
ImageID: img.ID,
|
ImageName: imageName,
|
||||||
Variant: variant,
|
Variant: variant,
|
||||||
Format: format,
|
Format: format,
|
||||||
StorageKey: stored.Key,
|
StorageKey: key,
|
||||||
PublicURL: stored.PublicURL,
|
PublicURL: publicURL,
|
||||||
Size: int64(len(data)),
|
Size: size,
|
||||||
}
|
}
|
||||||
|
|
||||||
return repo.DB.Clauses(clause.OnConflict{
|
err := repo.DB.Clauses(clause.OnConflict{
|
||||||
Columns: []clause.Column{{Name: "image_id"}, {Name: "variant"}, {Name: "format"}},
|
Columns: []clause.Column{{Name: "image_name"}, {Name: "variant"}, {Name: "format"}},
|
||||||
DoNothing: true,
|
DoNothing: true,
|
||||||
}).Create(&vRecord).Error
|
}).Create(&vRecord).Error
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f *Fetcher) saveDailyFiles(srcImg image.Image, originalData []byte) {
|
util.Logger.Info("Successfully saved ImageVariant record to database",
|
||||||
util.Logger.Info("Saving daily files")
|
zap.String("image_name", imageName),
|
||||||
|
zap.String("variant", variant),
|
||||||
|
zap.String("format", format))
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *Fetcher) saveDailyFiles(srcImg image.Image, originalData []byte, mkt string) {
|
||||||
|
util.Logger.Info("Saving daily files", zap.String("mkt", mkt))
|
||||||
localRoot := config.GetConfig().Storage.Local.Root
|
localRoot := config.GetConfig().Storage.Local.Root
|
||||||
if localRoot == "" {
|
if localRoot == "" {
|
||||||
localRoot = "data"
|
localRoot = "data"
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := os.MkdirAll(localRoot, 0755); err != nil {
|
mktDir := filepath.Join(localRoot, mkt)
|
||||||
util.Logger.Error("Failed to create directory", zap.String("path", localRoot), zap.Error(err))
|
if err := os.MkdirAll(mktDir, 0755); err != nil {
|
||||||
|
util.Logger.Error("Failed to create directory", zap.String("path", mktDir), zap.Error(err))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// daily.jpeg (quality 100)
|
// daily.jpeg (quality 100)
|
||||||
jpegPath := filepath.Join(localRoot, "daily.jpeg")
|
jpegPath := filepath.Join(mktDir, "daily.jpeg")
|
||||||
fJpeg, err := os.Create(jpegPath)
|
fJpeg, err := os.Create(jpegPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
util.Logger.Error("Failed to create daily.jpeg", zap.Error(err))
|
util.Logger.Error("Failed to create daily.jpeg", zap.Error(err))
|
||||||
@@ -262,8 +397,21 @@ func (f *Fetcher) saveDailyFiles(srcImg image.Image, originalData []byte) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// original.jpeg (quality 100)
|
// original.jpeg (quality 100)
|
||||||
originalPath := filepath.Join(localRoot, "original.jpeg")
|
originalPath := filepath.Join(mktDir, "original.jpeg")
|
||||||
if err := os.WriteFile(originalPath, originalData, 0644); err != nil {
|
if err := os.WriteFile(originalPath, originalData, 0644); err != nil {
|
||||||
util.Logger.Error("Failed to write original.jpeg", zap.Error(err))
|
util.Logger.Error("Failed to write original.jpeg", zap.Error(err))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 同时也保留一份在根目录下(兼容旧逻辑,且作为默认地区图片)
|
||||||
|
// 如果是默认地区或者是第一个抓取的地区,可以覆盖根目录的文件
|
||||||
|
if mkt == config.GetConfig().GetDefaultRegion() {
|
||||||
|
jpegPathRoot := filepath.Join(localRoot, "daily.jpeg")
|
||||||
|
fJpegRoot, err := os.Create(jpegPathRoot)
|
||||||
|
if err == nil {
|
||||||
|
jpeg.Encode(fJpegRoot, srcImg, &jpeg.Options{Quality: 100})
|
||||||
|
fJpegRoot.Close()
|
||||||
|
}
|
||||||
|
originalPathRoot := filepath.Join(localRoot, "original.jpeg")
|
||||||
|
os.WriteFile(originalPathRoot, originalData, 0644)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,18 +2,24 @@ package image
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"math/rand"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"BingPaper/internal/config"
|
"BingPaper/internal/config"
|
||||||
"BingPaper/internal/model"
|
"BingPaper/internal/model"
|
||||||
"BingPaper/internal/repo"
|
"BingPaper/internal/repo"
|
||||||
|
"BingPaper/internal/service/fetcher"
|
||||||
"BingPaper/internal/storage"
|
"BingPaper/internal/storage"
|
||||||
"BingPaper/internal/util"
|
"BingPaper/internal/util"
|
||||||
|
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var ErrFetchStarted = errors.New("on-demand fetch started")
|
||||||
|
|
||||||
func CleanupOldImages(ctx context.Context) error {
|
func CleanupOldImages(ctx context.Context) error {
|
||||||
days := config.GetConfig().Retention.Days
|
days := config.GetConfig().Retention.Days
|
||||||
if days <= 0 {
|
if days <= 0 {
|
||||||
@@ -23,81 +29,195 @@ func CleanupOldImages(ctx context.Context) error {
|
|||||||
threshold := time.Now().AddDate(0, 0, -days).Format("2006-01-02")
|
threshold := time.Now().AddDate(0, 0, -days).Format("2006-01-02")
|
||||||
util.Logger.Info("Starting cleanup task", zap.Int("retention_days", days), zap.String("threshold", threshold))
|
util.Logger.Info("Starting cleanup task", zap.Int("retention_days", days), zap.String("threshold", threshold))
|
||||||
|
|
||||||
var images []model.Image
|
var regionRecords []model.ImageRegion
|
||||||
if err := repo.DB.Where("date < ?", threshold).Preload("Variants").Find(&images).Error; err != nil {
|
if err := repo.DB.Where("date < ?", threshold).Preload("Variants").Find(®ionRecords).Error; err != nil {
|
||||||
util.Logger.Error("Failed to query old images for cleanup", zap.Error(err))
|
util.Logger.Error("Failed to query old image regions for cleanup", zap.Error(err))
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, img := range images {
|
for _, m := range regionRecords {
|
||||||
util.Logger.Info("Deleting old image", zap.String("date", img.Date))
|
util.Logger.Info("Deleting old image region record", zap.String("date", m.Date), zap.String("mkt", m.Mkt))
|
||||||
for _, v := range img.Variants {
|
|
||||||
|
// 检查该图片名是否还有其他地区或日期在使用
|
||||||
|
var count int64
|
||||||
|
repo.DB.Model(&model.ImageRegion{}).Where("image_name = ? AND id != ?", m.ImageName, m.ID).Count(&count)
|
||||||
|
|
||||||
|
if count == 0 {
|
||||||
|
util.Logger.Info("Image content no longer referenced, deleting files and variants", zap.String("image_name", m.ImageName))
|
||||||
|
for _, v := range m.Variants {
|
||||||
if err := storage.GlobalStorage.Delete(ctx, v.StorageKey); err != nil {
|
if err := storage.GlobalStorage.Delete(ctx, v.StorageKey); err != nil {
|
||||||
util.Logger.Warn("Failed to delete storage object", zap.String("key", v.StorageKey), zap.Error(err))
|
util.Logger.Warn("Failed to delete storage object", zap.String("key", v.StorageKey), zap.Error(err))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// 删除关联记录(逻辑外键控制)
|
// 删除变体记录
|
||||||
if err := repo.DB.Where("image_id = ?", img.ID).Delete(&model.ImageVariant{}).Error; err != nil {
|
if err := repo.DB.Where("image_name = ?", m.ImageName).Delete(&model.ImageVariant{}).Error; err != nil {
|
||||||
util.Logger.Error("Failed to delete variants", zap.Uint("image_id", img.ID), zap.Error(err))
|
util.Logger.Error("Failed to delete variants", zap.String("image_name", m.ImageName), zap.Error(err))
|
||||||
}
|
|
||||||
// 删除主表记录
|
|
||||||
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)))
|
// 删除地区记录
|
||||||
|
if err := repo.DB.Delete(&m).Error; err != nil {
|
||||||
|
util.Logger.Error("Failed to delete image region record", zap.Uint("id", m.ID), zap.Error(err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
util.Logger.Info("Cleanup task completed", zap.Int("deleted_count", len(regionRecords)))
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func GetTodayImage() (*model.Image, error) {
|
func GetTodayImage(mkt string) (*model.ImageRegion, error) {
|
||||||
today := time.Now().Format("2006-01-02")
|
today := time.Now().Format("2006-01-02")
|
||||||
var img model.Image
|
util.Logger.Debug("Getting today image", zap.String("mkt", mkt), zap.String("today", today))
|
||||||
err := repo.DB.Where("date = ?", today).Preload("Variants").First(&img).Error
|
var imgRegion model.ImageRegion
|
||||||
if err != nil {
|
tx := repo.DB.Where("date = ?", today)
|
||||||
// 如果今天没有,尝试获取最近的一张
|
if mkt != "" {
|
||||||
err = repo.DB.Order("date desc").Preload("Variants").First(&img).Error
|
tx = tx.Where("mkt = ?", mkt)
|
||||||
}
|
}
|
||||||
return &img, err
|
err := tx.Preload("Variants", func(db *gorm.DB) *gorm.DB {
|
||||||
|
return db.Order("size asc")
|
||||||
|
}).First(&imgRegion).Error
|
||||||
|
if err != nil && mkt != "" && config.GetConfig().API.EnableOnDemandFetch && util.IsValidRegion(mkt) {
|
||||||
|
// 如果没找到,尝试异步按需抓取该地区
|
||||||
|
util.Logger.Info("Image not found in DB, starting asynchronous on-demand fetch", zap.String("mkt", mkt))
|
||||||
|
f := fetcher.NewFetcher()
|
||||||
|
go func() {
|
||||||
|
_ = f.FetchRegion(context.Background(), mkt)
|
||||||
|
}()
|
||||||
|
return nil, ErrFetchStarted
|
||||||
}
|
}
|
||||||
|
|
||||||
func GetRandomImage() (*model.Image, error) {
|
if err != nil {
|
||||||
var img model.Image
|
util.Logger.Debug("Today image not found, trying latest image", zap.String("mkt", mkt))
|
||||||
// SQLite 使用 RANDOM(), MySQL/Postgres 使用 RANDOM() 或 RAND()
|
// 如果今天还是没有,尝试获取最近的一张
|
||||||
// 简单起见,先查总数再 Offset
|
tx = repo.DB.Order("date desc")
|
||||||
|
if mkt != "" {
|
||||||
|
tx = tx.Where("mkt = ?", mkt)
|
||||||
|
}
|
||||||
|
err = tx.Preload("Variants", func(db *gorm.DB) *gorm.DB {
|
||||||
|
return db.Order("size asc")
|
||||||
|
}).First(&imgRegion).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// 兜底逻辑
|
||||||
|
if err != nil && mkt != "" && config.GetConfig().API.EnableMktFallback {
|
||||||
|
defaultMkt := config.GetConfig().GetDefaultRegion()
|
||||||
|
util.Logger.Debug("Image not found, trying fallback to default region", zap.String("mkt", mkt), zap.String("defaultMkt", defaultMkt))
|
||||||
|
if mkt != defaultMkt {
|
||||||
|
return GetTodayImage(defaultMkt)
|
||||||
|
}
|
||||||
|
return GetTodayImage("")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err == nil {
|
||||||
|
util.Logger.Debug("Found image region record", zap.String("date", imgRegion.Date), zap.String("mkt", imgRegion.Mkt))
|
||||||
|
}
|
||||||
|
return &imgRegion, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetAllRegionsTodayImages() ([]model.ImageRegion, error) {
|
||||||
|
today := time.Now().Format("2006-01-02")
|
||||||
|
regions := config.GetConfig().Fetcher.Regions
|
||||||
|
if len(regions) == 0 {
|
||||||
|
regions = []string{config.GetConfig().GetDefaultRegion()}
|
||||||
|
}
|
||||||
|
|
||||||
|
var images []model.ImageRegion
|
||||||
|
err := repo.DB.Where("date = ? AND mkt IN ?", today, regions).
|
||||||
|
Preload("Variants", func(db *gorm.DB) *gorm.DB {
|
||||||
|
return db.Order("size asc")
|
||||||
|
}).Find(&images).Error
|
||||||
|
|
||||||
|
return images, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetRandomImage(mkt string) (*model.ImageRegion, error) {
|
||||||
|
util.Logger.Debug("Getting random image", zap.String("mkt", mkt))
|
||||||
|
var imgRegion model.ImageRegion
|
||||||
var count int64
|
var count int64
|
||||||
repo.DB.Model(&model.Image{}).Count(&count)
|
tx := repo.DB.Model(&model.ImageRegion{})
|
||||||
|
if mkt != "" {
|
||||||
|
tx = tx.Where("mkt = ?", mkt)
|
||||||
|
}
|
||||||
|
tx.Count(&count)
|
||||||
|
if count == 0 && mkt != "" && config.GetConfig().API.EnableOnDemandFetch && util.IsValidRegion(mkt) {
|
||||||
|
util.Logger.Info("No images found in DB for region, starting asynchronous on-demand fetch", zap.String("mkt", mkt))
|
||||||
|
f := fetcher.NewFetcher()
|
||||||
|
go func() {
|
||||||
|
_ = f.FetchRegion(context.Background(), mkt)
|
||||||
|
}()
|
||||||
|
return nil, ErrFetchStarted
|
||||||
|
}
|
||||||
|
|
||||||
if count == 0 {
|
if count == 0 {
|
||||||
return nil, fmt.Errorf("no images found")
|
return nil, fmt.Errorf("no images found")
|
||||||
}
|
}
|
||||||
|
|
||||||
// 这种方法不适合海量数据,但对于 30 天的数据没问题
|
offset := rand.Intn(int(count))
|
||||||
err := repo.DB.Order("RANDOM()").Preload("Variants").First(&img).Error
|
util.Logger.Debug("Random image selection", zap.Int64("total", count), zap.Int("offset", offset))
|
||||||
if err != nil {
|
err := tx.Preload("Variants", func(db *gorm.DB) *gorm.DB {
|
||||||
// 适配 MySQL
|
return db.Order("size asc")
|
||||||
err = repo.DB.Order("RAND()").Preload("Variants").First(&img).Error
|
}).Offset(offset).Limit(1).Find(&imgRegion).Error
|
||||||
|
|
||||||
|
if (err != nil || imgRegion.ID == 0) && mkt != "" && config.GetConfig().API.EnableMktFallback {
|
||||||
|
defaultMkt := config.GetConfig().GetDefaultRegion()
|
||||||
|
util.Logger.Debug("Random image not found, trying fallback", zap.String("mkt", mkt), zap.String("defaultMkt", defaultMkt))
|
||||||
|
if mkt != defaultMkt {
|
||||||
|
return GetRandomImage(defaultMkt)
|
||||||
}
|
}
|
||||||
return &img, err
|
return GetRandomImage("")
|
||||||
}
|
}
|
||||||
|
|
||||||
func GetImageByDate(date string) (*model.Image, error) {
|
if err == nil && imgRegion.ID == 0 {
|
||||||
var img model.Image
|
return nil, fmt.Errorf("no images found")
|
||||||
err := repo.DB.Where("date = ?", date).Preload("Variants").First(&img).Error
|
|
||||||
return &img, err
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func GetImageList(limit int, offset int, month string) ([]model.Image, error) {
|
return &imgRegion, err
|
||||||
var images []model.Image
|
}
|
||||||
tx := repo.DB.Model(&model.Image{})
|
|
||||||
|
func GetImageByDate(date string, mkt string) (*model.ImageRegion, error) {
|
||||||
|
util.Logger.Debug("Getting image by date", zap.String("date", date), zap.String("mkt", mkt))
|
||||||
|
var imgRegion model.ImageRegion
|
||||||
|
tx := repo.DB.Where("date = ?", date)
|
||||||
|
if mkt != "" {
|
||||||
|
tx = tx.Where("mkt = ?", mkt)
|
||||||
|
}
|
||||||
|
err := tx.Preload("Variants", func(db *gorm.DB) *gorm.DB {
|
||||||
|
return db.Order("size asc")
|
||||||
|
}).First(&imgRegion).Error
|
||||||
|
if err != nil && mkt != "" && config.GetConfig().API.EnableOnDemandFetch && util.IsValidRegion(mkt) {
|
||||||
|
util.Logger.Info("Image not found in DB for date, starting asynchronous on-demand fetch", zap.String("mkt", mkt), zap.String("date", date))
|
||||||
|
f := fetcher.NewFetcher()
|
||||||
|
go func() {
|
||||||
|
_ = f.FetchRegion(context.Background(), mkt)
|
||||||
|
}()
|
||||||
|
return nil, ErrFetchStarted
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil && mkt != "" && config.GetConfig().API.EnableMktFallback {
|
||||||
|
defaultMkt := config.GetConfig().GetDefaultRegion()
|
||||||
|
if mkt != defaultMkt {
|
||||||
|
return GetImageByDate(date, defaultMkt)
|
||||||
|
}
|
||||||
|
return GetImageByDate(date, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
return &imgRegion, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetImageList(limit int, offset int, month string, mkt string) ([]model.ImageRegion, error) {
|
||||||
|
var images []model.ImageRegion
|
||||||
|
tx := repo.DB.Model(&model.ImageRegion{})
|
||||||
|
|
||||||
if month != "" {
|
if month != "" {
|
||||||
// 增强过滤:确保只处理 YYYY-MM 格式,防止注入或非法字符
|
|
||||||
// 这里简单处理:只要不为空就增加 LIKE 过滤
|
|
||||||
util.Logger.Debug("Filtering images by month", zap.String("month", month))
|
|
||||||
tx = tx.Where("date LIKE ?", month+"%")
|
tx = tx.Where("date LIKE ?", month+"%")
|
||||||
}
|
}
|
||||||
|
if mkt != "" {
|
||||||
|
tx = tx.Where("mkt = ?", mkt)
|
||||||
|
}
|
||||||
|
|
||||||
tx = tx.Order("date desc").Preload("Variants")
|
tx = tx.Order("date desc").Preload("Variants", func(db *gorm.DB) *gorm.DB {
|
||||||
|
return db.Order("size asc")
|
||||||
|
})
|
||||||
|
|
||||||
if limit > 0 {
|
if limit > 0 {
|
||||||
tx = tx.Limit(limit)
|
tx = tx.Limit(limit)
|
||||||
@@ -107,8 +227,5 @@ func GetImageList(limit int, offset int, month string) ([]model.Image, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
err := tx.Find(&images).Error
|
err := tx.Find(&images).Error
|
||||||
if err != nil {
|
|
||||||
util.Logger.Error("Failed to get image list", zap.Error(err), zap.String("month", month))
|
|
||||||
}
|
|
||||||
return images, err
|
return images, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -63,3 +63,15 @@ func (l *LocalStorage) Delete(ctx context.Context, key string) error {
|
|||||||
func (l *LocalStorage) PublicURL(key string) (string, bool) {
|
func (l *LocalStorage) PublicURL(key string) (string, bool) {
|
||||||
return "", false
|
return "", false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (l *LocalStorage) Exists(ctx context.Context, key string) (bool, error) {
|
||||||
|
path := filepath.Join(l.root, key)
|
||||||
|
_, err := os.Stat(path)
|
||||||
|
if err == nil {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
|||||||
@@ -100,3 +100,18 @@ func (s *S3Storage) PublicURL(key string) (string, bool) {
|
|||||||
// 也可以生成签名 URL,但这里简单处理
|
// 也可以生成签名 URL,但这里简单处理
|
||||||
return "", false
|
return "", false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *S3Storage) Exists(ctx context.Context, key string) (bool, error) {
|
||||||
|
_, err := s.client.HeadObject(ctx, &s3.HeadObjectInput{
|
||||||
|
Bucket: aws.String(s.bucket),
|
||||||
|
Key: aws.String(key),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
// 判断是否为 404
|
||||||
|
if strings.Contains(err.Error(), "NotFound") || strings.Contains(err.Error(), "404") {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ type Storage interface {
|
|||||||
Get(ctx context.Context, key string) (io.ReadCloser, string, error)
|
Get(ctx context.Context, key string) (io.ReadCloser, string, error)
|
||||||
Delete(ctx context.Context, key string) error
|
Delete(ctx context.Context, key string) error
|
||||||
PublicURL(key string) (string, bool)
|
PublicURL(key string) (string, bool)
|
||||||
|
Exists(ctx context.Context, key string) (bool, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
var GlobalStorage Storage
|
var GlobalStorage Storage
|
||||||
|
|||||||
@@ -72,3 +72,16 @@ func (w *WebDAVStorage) PublicURL(key string) (string, bool) {
|
|||||||
}
|
}
|
||||||
return "", false
|
return "", false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (w *WebDAVStorage) Exists(ctx context.Context, key string) (bool, error) {
|
||||||
|
_, err := w.client.Stat(key)
|
||||||
|
if err == nil {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
// gowebdav 的错误处理比较原始,通常 404 会返回错误
|
||||||
|
// 这里假设报错就是不存在,或者可以根据错误消息判断
|
||||||
|
if strings.Contains(err.Error(), "404") || strings.Contains(err.Error(), "not found") {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
|||||||
35
internal/util/regions.go
Normal file
35
internal/util/regions.go
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
package util
|
||||||
|
|
||||||
|
import "golang.org/x/text/language"
|
||||||
|
|
||||||
|
type Region struct {
|
||||||
|
Value string `json:"value"`
|
||||||
|
Label string `json:"label"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsValidRegion 校验是否为标准的地区编码 (BCP 47)
|
||||||
|
func IsValidRegion(mkt string) bool {
|
||||||
|
if mkt == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
_, err := language.Parse(mkt)
|
||||||
|
return err == nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var AllRegions = []Region{
|
||||||
|
{Value: "zh-CN", Label: "中国"},
|
||||||
|
{Value: "en-US", Label: "美国"},
|
||||||
|
{Value: "ja-JP", Label: "日本"},
|
||||||
|
{Value: "en-AU", Label: "澳大利亚"},
|
||||||
|
{Value: "en-GB", Label: "英国"},
|
||||||
|
{Value: "de-DE", Label: "德国"},
|
||||||
|
{Value: "en-NZ", Label: "新西兰"},
|
||||||
|
{Value: "en-CA", Label: "加拿大"},
|
||||||
|
{Value: "fr-FR", Label: "法国"},
|
||||||
|
{Value: "it-IT", Label: "意大利"},
|
||||||
|
{Value: "es-ES", Label: "西班牙"},
|
||||||
|
{Value: "pt-BR", Label: "巴西"},
|
||||||
|
{Value: "ko-KR", Label: "韩国"},
|
||||||
|
{Value: "en-IN", Label: "印度"},
|
||||||
|
{Value: "ru-RU", Label: "俄罗斯"},
|
||||||
|
}
|
||||||
@@ -27,8 +27,8 @@ 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 OUTPUT_NAME=%APP_NAME%-%%a-%%b
|
set OUTPUT_NAME=%APP_NAME%-%%a-%%b
|
||||||
set BINARY_NAME=!OUTPUT_NAME!
|
set BINARY_NAME=%APP_NAME%
|
||||||
if "%%a"=="windows" set BINARY_NAME=!OUTPUT_NAME!.exe
|
if "%%a"=="windows" set BINARY_NAME=%APP_NAME%.exe
|
||||||
|
|
||||||
echo 正在编译 %%a/%%b...
|
echo 正在编译 %%a/%%b...
|
||||||
|
|
||||||
@@ -47,10 +47,10 @@ for %%p in (%PLATFORMS%) do (
|
|||||||
copy /y config.example.yaml !PACKAGE_DIR!\ >nul
|
copy /y config.example.yaml !PACKAGE_DIR!\ >nul
|
||||||
copy /y README.md !PACKAGE_DIR!\ >nul
|
copy /y README.md !PACKAGE_DIR!\ >nul
|
||||||
|
|
||||||
pushd %OUTPUT_DIR%
|
pushd !PACKAGE_DIR!
|
||||||
tar -czf !OUTPUT_NAME!.tar.gz !OUTPUT_NAME!
|
tar -czf ..\!OUTPUT_NAME!.tar.gz .
|
||||||
rd /s /q !OUTPUT_NAME!
|
|
||||||
popd
|
popd
|
||||||
|
rd /s /q !PACKAGE_DIR!
|
||||||
|
|
||||||
echo %%a/%%b 打包完成: !OUTPUT_NAME!.tar.gz
|
echo %%a/%%b 打包完成: !OUTPUT_NAME!.tar.gz
|
||||||
) else (
|
) else (
|
||||||
|
|||||||
@@ -38,9 +38,9 @@ foreach ($Platform in $Platforms) {
|
|||||||
$Arch = $parts[1]
|
$Arch = $parts[1]
|
||||||
|
|
||||||
$OutputName = "$AppName-$OS-$Arch"
|
$OutputName = "$AppName-$OS-$Arch"
|
||||||
$BinaryName = $OutputName
|
$BinaryName = $AppName
|
||||||
if ($OS -eq "windows") {
|
if ($OS -eq "windows") {
|
||||||
$BinaryName = "$OutputName.exe"
|
$BinaryName = "$AppName.exe"
|
||||||
}
|
}
|
||||||
|
|
||||||
Write-Host "正在编译 $OS/$Arch..."
|
Write-Host "正在编译 $OS/$Arch..."
|
||||||
@@ -63,10 +63,10 @@ foreach ($Platform in $Platforms) {
|
|||||||
Copy-Item "README.md" $PackageDir\
|
Copy-Item "README.md" $PackageDir\
|
||||||
|
|
||||||
$CurrentDir = Get-Location
|
$CurrentDir = Get-Location
|
||||||
Set-Location $OutputDir
|
Set-Location $PackageDir
|
||||||
tar -czf "${OutputName}.tar.gz" $OutputName
|
tar -czf "../${OutputName}.tar.gz" .
|
||||||
Remove-Item -Recurse -Force $OutputName
|
|
||||||
Set-Location $CurrentDir
|
Set-Location $CurrentDir
|
||||||
|
Remove-Item -Recurse -Force $PackageDir
|
||||||
|
|
||||||
Write-Host " $OS/$Arch 打包完成: ${OutputName}.tar.gz"
|
Write-Host " $OS/$Arch 打包完成: ${OutputName}.tar.gz"
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -45,9 +45,9 @@ for PLATFORM in "${PLATFORMS[@]}"; do
|
|||||||
# 设置输出名称
|
# 设置输出名称
|
||||||
OUTPUT_NAME="${APP_NAME}-${OS}-${ARCH}"
|
OUTPUT_NAME="${APP_NAME}-${OS}-${ARCH}"
|
||||||
if [ "$OS" = "windows" ]; then
|
if [ "$OS" = "windows" ]; then
|
||||||
BINARY_NAME="${OUTPUT_NAME}.exe"
|
BINARY_NAME="${APP_NAME}.exe"
|
||||||
else
|
else
|
||||||
BINARY_NAME="${OUTPUT_NAME}"
|
BINARY_NAME="${APP_NAME}"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "正在编译 ${OS}/${ARCH}..."
|
echo "正在编译 ${OS}/${ARCH}..."
|
||||||
@@ -71,7 +71,7 @@ for PLATFORM in "${PLATFORMS[@]}"; do
|
|||||||
done
|
done
|
||||||
|
|
||||||
# 压缩为 tar.gz
|
# 压缩为 tar.gz
|
||||||
tar -czf "${OUTPUT_DIR}/${OUTPUT_NAME}.tar.gz" -C "${OUTPUT_DIR}" "${OUTPUT_NAME}"
|
tar -czf "${OUTPUT_DIR}/${OUTPUT_NAME}.tar.gz" -C "${PACKAGE_DIR}" .
|
||||||
|
|
||||||
# 删除临时打包目录
|
# 删除临时打包目录
|
||||||
rm -rf "$PACKAGE_DIR"
|
rm -rf "$PACKAGE_DIR"
|
||||||
|
|||||||
101
scripts/deploy.sh
Normal file
101
scripts/deploy.sh
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# 定时任务示例 (每30分钟执行一次):
|
||||||
|
# */30 * * * * /path/to/project/scripts/deploy.sh >> /path/to/project/scripts/deploy_cron.log 2>&1
|
||||||
|
|
||||||
|
# 项目根目录
|
||||||
|
# 假设脚本位于 scripts 目录下
|
||||||
|
PROJECT_DIR=$(cd $(dirname $0)/.. && pwd)
|
||||||
|
cd $PROJECT_DIR
|
||||||
|
|
||||||
|
# 日志文件
|
||||||
|
LOG_FILE="$PROJECT_DIR/scripts/deploy.log"
|
||||||
|
|
||||||
|
log() {
|
||||||
|
echo "$(date '+%Y-%m-%d %H:%M:%S') - $1" | tee -a "$LOG_FILE"
|
||||||
|
}
|
||||||
|
|
||||||
|
# 确保在项目根目录
|
||||||
|
if [ ! -f "docker-compose.yaml" ]; then
|
||||||
|
log "错误: 未能在 $PROJECT_DIR 找到 docker-compose.yml,请确保脚本位置正确。"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
log "开始检查更新..."
|
||||||
|
|
||||||
|
# 获取远程代码
|
||||||
|
git fetch origin
|
||||||
|
|
||||||
|
# 获取当前分支名
|
||||||
|
BRANCH=$(git rev-parse --abbrev-ref HEAD)
|
||||||
|
|
||||||
|
# 检查本地分支是否有上游分支
|
||||||
|
UPSTREAM=$(git rev-parse --abbrev-ref @{u} 2>/dev/null)
|
||||||
|
if [ -z "$UPSTREAM" ]; then
|
||||||
|
log "错误: 当前分支 $BRANCH 没有设置上游分支,无法自动对比更新。"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 检查本地是否落后于远程
|
||||||
|
LOCAL=$(git rev-parse HEAD)
|
||||||
|
REMOTE=$(git rev-parse "$UPSTREAM")
|
||||||
|
|
||||||
|
if [ "$LOCAL" = "$REMOTE" ]; then
|
||||||
|
log "代码已是最新 ($LOCAL),无需更新。"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
log "检测到远程变更 ($LOCAL -> $REMOTE),准备开始升级..."
|
||||||
|
|
||||||
|
# 检查是否有本地修改
|
||||||
|
HAS_CHANGES=$(git status --porcelain)
|
||||||
|
|
||||||
|
if [ -n "$HAS_CHANGES" ]; then
|
||||||
|
log "检测到本地修改,正在暂存以保留个性化配置..."
|
||||||
|
git stash
|
||||||
|
STASHED=true
|
||||||
|
else
|
||||||
|
STASHED=false
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 拉取新代码
|
||||||
|
log "正在拉取远程代码 ($BRANCH)..."
|
||||||
|
if git pull origin "$BRANCH"; then
|
||||||
|
log "代码拉取成功。"
|
||||||
|
else
|
||||||
|
log "错误: 代码拉取失败。"
|
||||||
|
if [ "$STASHED" = true ]; then
|
||||||
|
git stash pop
|
||||||
|
fi
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 恢复本地修改
|
||||||
|
if [ "$STASHED" = true ]; then
|
||||||
|
log "正在恢复本地修改..."
|
||||||
|
if git stash pop; then
|
||||||
|
log "本地修改已成功恢复。"
|
||||||
|
else
|
||||||
|
log "警告: 恢复本地修改时发生冲突,请手动检查 docker-compose.yml 等文件。"
|
||||||
|
# 即使冲突也尝试继续,或者你可以选择在此退出
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 确定 docker-compose 命令
|
||||||
|
DOCKER_COMPOSE_BIN="docker-compose"
|
||||||
|
if ! command -v $DOCKER_COMPOSE_BIN &> /dev/null; then
|
||||||
|
DOCKER_COMPOSE_BIN="docker compose"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 执行 docker-compose 部署
|
||||||
|
log "正在执行 $DOCKER_COMPOSE_BIN 部署..."
|
||||||
|
if $DOCKER_COMPOSE_BIN up -d --build; then
|
||||||
|
log "服务升级成功!"
|
||||||
|
# 清理无用镜像(可选)
|
||||||
|
docker image prune -f
|
||||||
|
else
|
||||||
|
log "错误: $DOCKER_COMPOSE_BIN 部署失败。"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
log "部署任务完成。"
|
||||||
@@ -15,6 +15,7 @@ BingPaper 的前端 Web 应用,使用 Vue 3 + TypeScript + Vite 构建。
|
|||||||
- ⚡ 浏览器缓存优化(内容哈希 + 代码分割)
|
- ⚡ 浏览器缓存优化(内容哈希 + 代码分割)
|
||||||
- 🌐 支持自定义后端路径
|
- 🌐 支持自定义后端路径
|
||||||
- 📁 自动输出到上级目录的 web 文件夹
|
- 📁 自动输出到上级目录的 web 文件夹
|
||||||
|
- 🔐 完整的管理后台系统(Token 管理、定时任务、系统配置)
|
||||||
|
|
||||||
## 快速开始
|
## 快速开始
|
||||||
|
|
||||||
@@ -93,6 +94,77 @@ const images = await bingPaperApi.getImages({ limit: 10 })
|
|||||||
|
|
||||||
## 项目结构
|
## 项目结构
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
├── assets/ # 静态资源
|
||||||
|
├── components/ # Vue 组件
|
||||||
|
│ └── ui/ # shadcn-vue UI 组件库
|
||||||
|
├── composables/ # 可组合函数
|
||||||
|
│ └── useImages.ts # 图片管理相关逻辑
|
||||||
|
├── lib/ # 核心库
|
||||||
|
│ ├── api-config.ts # API 配置
|
||||||
|
│ ├── api-service.ts # API 服务类
|
||||||
|
│ ├── api-types.ts # TypeScript 类型定义
|
||||||
|
│ ├── http-client.ts # HTTP 客户端
|
||||||
|
│ └── utils.ts # 工具函数
|
||||||
|
├── router/ # 路由配置
|
||||||
|
│ └── index.ts
|
||||||
|
├── views/ # 页面组件
|
||||||
|
│ ├── Home.vue # 首页
|
||||||
|
│ ├── ImageView.vue # 图片详情页
|
||||||
|
│ ├── ApiDocs.vue # API 文档页
|
||||||
|
│ ├── AdminLogin.vue # 管理员登录页
|
||||||
|
│ ├── Admin.vue # 管理后台主页面
|
||||||
|
│ ├── AdminTokens.vue # Token 管理
|
||||||
|
│ ├── AdminTasks.vue # 定时任务管理
|
||||||
|
│ └── AdminConfig.vue # 系统配置管理
|
||||||
|
├── App.vue # 根组件
|
||||||
|
└── main.ts # 入口文件
|
||||||
|
```
|
||||||
|
|
||||||
|
## 管理后台
|
||||||
|
|
||||||
|
访问 `/admin/login` 进入管理后台登录页面。
|
||||||
|
|
||||||
|
### 功能模块
|
||||||
|
|
||||||
|
#### 1. Token 管理
|
||||||
|
- 查看所有 API Token
|
||||||
|
- 创建新的 Token(支持设置过期时间)
|
||||||
|
- 启用/禁用 Token
|
||||||
|
- 删除 Token
|
||||||
|
|
||||||
|
#### 2. 定时任务管理
|
||||||
|
- 手动触发图片抓取(可指定抓取天数)
|
||||||
|
- 手动触发旧图片清理
|
||||||
|
- 查看任务执行历史
|
||||||
|
|
||||||
|
#### 3. 系统配置管理
|
||||||
|
- **JSON 编辑模式**:直接编辑配置 JSON
|
||||||
|
- **表单编辑模式**:通过友好的表单界面修改配置
|
||||||
|
- 支持的配置项:
|
||||||
|
- 服务器配置(端口、基础 URL)
|
||||||
|
- API 模式(本地/重定向)
|
||||||
|
- 定时任务配置
|
||||||
|
- 数据库配置
|
||||||
|
- 存储配置(本地/S3/WebDAV)
|
||||||
|
- 图片保留策略
|
||||||
|
- Token 配置
|
||||||
|
- 日志配置
|
||||||
|
- 功能特性开关
|
||||||
|
|
||||||
|
#### 4. 密码管理
|
||||||
|
- 修改管理员密码
|
||||||
|
- 安全退出登录
|
||||||
|
|
||||||
|
### 安全特性
|
||||||
|
- 基于 JWT Token 的身份验证
|
||||||
|
- Token 过期自动跳转登录页
|
||||||
|
- 路由守卫保护管理页面
|
||||||
|
- 密码修改后强制重新登录
|
||||||
|
|
||||||
|
## 项目结构
|
||||||
|
|
||||||
```
|
```
|
||||||
src/
|
src/
|
||||||
├── lib/ # 核心库
|
├── lib/ # 核心库
|
||||||
|
|||||||
7
webapp/package-lock.json
generated
7
webapp/package-lock.json
generated
@@ -14,6 +14,7 @@
|
|||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"lucide-vue-next": "^0.562.0",
|
"lucide-vue-next": "^0.562.0",
|
||||||
|
"lunar-javascript": "^1.7.7",
|
||||||
"reka-ui": "^2.7.0",
|
"reka-ui": "^2.7.0",
|
||||||
"tailwind-merge": "^3.4.0",
|
"tailwind-merge": "^3.4.0",
|
||||||
"tailwindcss": "^4.1.18",
|
"tailwindcss": "^4.1.18",
|
||||||
@@ -2022,6 +2023,12 @@
|
|||||||
"vue": ">=3.0.1"
|
"vue": ">=3.0.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/lunar-javascript": {
|
||||||
|
"version": "1.7.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/lunar-javascript/-/lunar-javascript-1.7.7.tgz",
|
||||||
|
"integrity": "sha512-u/KYiwPIBo/0bT+WWfU7qO1d+aqeB90Tuy4ErXenr2Gam0QcWeezUvtiOIyXR7HbVnW2I1DKfU0NBvzMZhbVQw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/magic-string": {
|
"node_modules/magic-string": {
|
||||||
"version": "0.30.21",
|
"version": "0.30.21",
|
||||||
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
|
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
|
||||||
|
|||||||
@@ -18,6 +18,7 @@
|
|||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"lucide-vue-next": "^0.562.0",
|
"lucide-vue-next": "^0.562.0",
|
||||||
|
"lunar-javascript": "^1.7.7",
|
||||||
"reka-ui": "^2.7.0",
|
"reka-ui": "^2.7.0",
|
||||||
"tailwind-merge": "^3.4.0",
|
"tailwind-merge": "^3.4.0",
|
||||||
"tailwindcss": "^4.1.18",
|
"tailwindcss": "^4.1.18",
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import 'vue-sonner/style.css'
|
import 'vue-sonner/style.css'
|
||||||
|
import { Toaster } from '@/components/ui/sonner'
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div id="app">
|
<div id="app">
|
||||||
<RouterView />
|
<RouterView />
|
||||||
|
<Toaster />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
652
webapp/src/components/ui/calendar/Calendar.vue
Normal file
652
webapp/src/components/ui/calendar/Calendar.vue
Normal file
@@ -0,0 +1,652 @@
|
|||||||
|
<template>
|
||||||
|
<div class="fixed inset-0 z-40">
|
||||||
|
<div
|
||||||
|
ref="calendarPanel"
|
||||||
|
class="fixed bg-gradient-to-br from-black/30 via-black/20 to-black/30 backdrop-blur-xl rounded-3xl p-3 sm:p-4 w-[calc(100%-1rem)] sm:w-full max-w-[95vw] sm:max-w-[420px] shadow-2xl border border-white/10 cursor-move select-none touch-none"
|
||||||
|
:style="{ left: panelPos.x + 'px', top: panelPos.y + 'px' }"
|
||||||
|
@mousedown="startDrag"
|
||||||
|
@touchstart.passive="startDrag"
|
||||||
|
@click.stop
|
||||||
|
>
|
||||||
|
<!-- 拖动手柄指示器 -->
|
||||||
|
<div class="absolute top-2 left-1/2 -translate-x-1/2 w-12 h-1 bg-white/20 rounded-full"></div>
|
||||||
|
|
||||||
|
<!-- 头部 -->
|
||||||
|
<div class="flex items-center justify-between mb-3 sm:mb-4 mt-2">
|
||||||
|
<div class="flex items-center gap-1.5 sm:gap-2 flex-1">
|
||||||
|
<button
|
||||||
|
@click.stop="previousMonth"
|
||||||
|
:disabled="!canGoPrevious"
|
||||||
|
class="p-1 sm:p-1.5 hover:bg-white/20 rounded-lg transition-colors disabled:opacity-30 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
<svg class="w-3.5 h-3.5 sm:w-4 sm:h-4 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="text-center flex-1">
|
||||||
|
<!-- 年月选择器 -->
|
||||||
|
<div class="flex items-center justify-center gap-1 sm:gap-1.5 mb-0.5">
|
||||||
|
<!-- 年份选择 -->
|
||||||
|
<Select v-model="currentYearString">
|
||||||
|
<SelectTrigger
|
||||||
|
class="w-[90px] sm:w-[105px] h-6 sm:h-7 bg-white/10 text-white border-white/20 hover:bg-white/20 backdrop-blur-md font-bold text-xs sm:text-sm px-1.5 sm:px-2"
|
||||||
|
@click.stop
|
||||||
|
@mousedown.stop
|
||||||
|
>
|
||||||
|
<SelectValue>{{ currentYear }}年</SelectValue>
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent class="max-h-[300px] bg-gray-900/95 backdrop-blur-xl border-white/20">
|
||||||
|
<SelectItem
|
||||||
|
v-for="year in yearOptions"
|
||||||
|
:key="year"
|
||||||
|
:value="String(year)"
|
||||||
|
class="text-white hover:bg-white/20 focus:bg-white/20 cursor-pointer"
|
||||||
|
>
|
||||||
|
{{ year }}年
|
||||||
|
</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
<!-- 月份选择 -->
|
||||||
|
<Select v-model="currentMonthString">
|
||||||
|
<SelectTrigger
|
||||||
|
class="w-[65px] sm:w-[75px] h-6 sm:h-7 bg-white/10 text-white border-white/20 hover:bg-white/20 backdrop-blur-md font-bold text-xs sm:text-sm px-1.5 sm:px-2"
|
||||||
|
@click.stop
|
||||||
|
@mousedown.stop
|
||||||
|
>
|
||||||
|
<SelectValue>{{ currentMonth + 1 }}月</SelectValue>
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent class="bg-gray-900/95 backdrop-blur-xl border-white/20">
|
||||||
|
<SelectItem
|
||||||
|
v-for="month in 12"
|
||||||
|
:key="month"
|
||||||
|
:value="String(month - 1)"
|
||||||
|
class="text-white hover:bg-white/20 focus:bg-white/20 cursor-pointer"
|
||||||
|
>
|
||||||
|
{{ month }}月
|
||||||
|
</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="text-[10px] sm:text-xs text-white/60 drop-shadow-md font-['Microsoft_YaHei_UI','Microsoft_YaHei',sans-serif] leading-relaxed">
|
||||||
|
{{ lunarMonthYear }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
@click.stop="nextMonth"
|
||||||
|
:disabled="!canGoNext"
|
||||||
|
class="p-1 sm:p-1.5 hover:bg-white/20 rounded-lg transition-colors disabled:opacity-30 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
<svg class="w-3.5 h-3.5 sm:w-4 sm:h-4 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
@click.stop="$emit('close')"
|
||||||
|
class="p-1 sm:p-1.5 hover:bg-white/20 rounded-lg transition-colors ml-1.5 sm:ml-2"
|
||||||
|
>
|
||||||
|
<svg class="w-3.5 h-3.5 sm:w-4 sm:h-4 text-white drop-shadow-lg" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 星期标题 -->
|
||||||
|
<div class="grid grid-cols-7 gap-1 sm:gap-1.5 mb-1.5 sm:mb-2 pointer-events-none">
|
||||||
|
<div
|
||||||
|
v-for="(day, idx) in weekDays"
|
||||||
|
:key="day"
|
||||||
|
class="text-center text-[11px] sm:text-[13px] font-medium py-1 sm:py-1.5 drop-shadow-md leading-none"
|
||||||
|
:class="idx === 0 || idx === 6 ? 'text-red-300/80' : 'text-white/70'"
|
||||||
|
>
|
||||||
|
{{ day }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 日期格子 -->
|
||||||
|
<div class="grid grid-cols-7 gap-1 sm:gap-1.5">
|
||||||
|
<div
|
||||||
|
v-for="(day, index) in calendarDays"
|
||||||
|
:key="index"
|
||||||
|
class="relative aspect-square flex flex-col items-center justify-center rounded-lg transition-opacity pointer-events-none py-0.5 sm:py-1"
|
||||||
|
:class="[
|
||||||
|
day.isCurrentMonth && !day.isFuture ? 'text-white' : 'text-white/25',
|
||||||
|
day.isToday ? 'bg-blue-400/40 ring-2 ring-blue-300/50' : '',
|
||||||
|
day.isSelected ? 'bg-white/30 ring-1 ring-white/40' : '',
|
||||||
|
day.isFuture ? 'opacity-40' : '',
|
||||||
|
day.isWeekend && day.isCurrentMonth ? 'text-red-200/90' : '',
|
||||||
|
(day.apiHoliday?.isOffDay || (!day.apiHoliday && day.isWeekend)) ? 'text-red-300' : ''
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<!-- 休息/上班标记 (API优先,其次周末) - 使用圆形SVG -->
|
||||||
|
<div
|
||||||
|
v-if="day.isCurrentMonth && (day.apiHoliday || day.isWeekend)"
|
||||||
|
class="absolute top-0 right-0 w-[14px] h-[14px] sm:w-4 sm:h-4"
|
||||||
|
>
|
||||||
|
<svg viewBox="0 0 20 20" class="w-full h-full drop-shadow-md">
|
||||||
|
<circle
|
||||||
|
cx="10"
|
||||||
|
cy="10"
|
||||||
|
r="9"
|
||||||
|
:fill="day.apiHoliday ? (day.apiHoliday.isOffDay ? '#ef4444' : '#3b82f6') : '#ef4444'"
|
||||||
|
opacity="0.65"
|
||||||
|
/>
|
||||||
|
<text
|
||||||
|
x="9.8"
|
||||||
|
y="10.5"
|
||||||
|
text-anchor="middle"
|
||||||
|
dominant-baseline="middle"
|
||||||
|
fill="white"
|
||||||
|
font-size="11"
|
||||||
|
font-weight="bold"
|
||||||
|
font-family="'Microsoft YaHei UI','Microsoft YaHei','PingFang SC','Hiragino Sans GB',sans-serif"
|
||||||
|
>
|
||||||
|
{{ day.apiHoliday ? (day.apiHoliday.isOffDay ? '休' : '班') : '休' }}
|
||||||
|
</text>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 公历日期 -->
|
||||||
|
<div
|
||||||
|
class="text-[13px] sm:text-[15px] font-medium drop-shadow-md font-['Helvetica','Arial',sans-serif] leading-none mb-0.5 sm:mb-1"
|
||||||
|
:class="(day.apiHoliday?.isOffDay || (!day.apiHoliday && day.isWeekend)) ? 'text-red-300 font-bold' : ''"
|
||||||
|
>
|
||||||
|
{{ day.day }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 农历/节日/节气 (不显示API节假日名称) -->
|
||||||
|
<div
|
||||||
|
class="text-[9px] sm:text-[10px] leading-tight drop-shadow-sm font-['Microsoft_YaHei_UI','Microsoft_YaHei',sans-serif] text-center px-0.5"
|
||||||
|
:class="[
|
||||||
|
day.festival || day.solarTerm || day.lunarFestival ? 'text-red-300 font-semibold' : 'text-white/60'
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
{{ day.festival || day.solarTerm || day.lunarFestival || day.lunarDay }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 今日按钮 -->
|
||||||
|
<div class="mt-3 sm:mt-4 flex justify-center">
|
||||||
|
<button
|
||||||
|
@click.stop="goToToday"
|
||||||
|
class="px-4 sm:px-5 py-1 sm:py-1.5 bg-white/15 hover:bg-white/30 text-white rounded-lg text-[11px] sm:text-xs font-medium transition-all hover:scale-105 active:scale-95 drop-shadow-lg"
|
||||||
|
>
|
||||||
|
回到今天
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, watch, onMounted } from 'vue'
|
||||||
|
import { Solar } from 'lunar-javascript'
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue
|
||||||
|
} from '@/components/ui/select'
|
||||||
|
import { getHolidaysByYear, getHolidayByDate, type Holidays, type HolidayDay } from '@/lib/holiday-service'
|
||||||
|
|
||||||
|
interface CalendarDay {
|
||||||
|
day: number
|
||||||
|
isCurrentMonth: boolean
|
||||||
|
isToday: boolean
|
||||||
|
isSelected: boolean
|
||||||
|
isFuture: boolean
|
||||||
|
isWeekend: boolean
|
||||||
|
isHoliday: boolean
|
||||||
|
holidayName: string
|
||||||
|
apiHoliday: HolidayDay | null // API返回的假期信息
|
||||||
|
lunarDay: string
|
||||||
|
festival: string
|
||||||
|
lunarFestival: string
|
||||||
|
solarTerm: string
|
||||||
|
date: Date
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
selectedDate?: string,
|
||||||
|
mkt?: string
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
close: []
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const weekDays = ['日', '一', '二', '三', '四', '五', '六']
|
||||||
|
|
||||||
|
// 日历面板位置
|
||||||
|
const calendarPanel = ref<HTMLElement | null>(null)
|
||||||
|
const panelPos = ref({ x: 0, y: 0 })
|
||||||
|
const isDragging = ref(false)
|
||||||
|
const dragStart = ref({ x: 0, y: 0 })
|
||||||
|
|
||||||
|
// 响应式窗口大小
|
||||||
|
const windowSize = ref({ width: window.innerWidth, height: window.innerHeight })
|
||||||
|
const isMobile = computed(() => windowSize.value.width < 768)
|
||||||
|
|
||||||
|
// 计算图片实际显示区域(与ImageView保持一致)
|
||||||
|
const getImageDisplayBounds = () => {
|
||||||
|
const windowWidth = windowSize.value.width
|
||||||
|
const windowHeight = windowSize.value.height
|
||||||
|
|
||||||
|
// 必应图片通常是16:9或类似宽高比
|
||||||
|
const imageAspectRatio = 16 / 9
|
||||||
|
const windowAspectRatio = windowWidth / windowHeight
|
||||||
|
|
||||||
|
let displayWidth: number
|
||||||
|
let displayHeight: number
|
||||||
|
let offsetX: number
|
||||||
|
let offsetY: number
|
||||||
|
|
||||||
|
if (windowAspectRatio > imageAspectRatio) {
|
||||||
|
// 窗口更宽,图片上下占满,左右留黑边
|
||||||
|
displayHeight = windowHeight
|
||||||
|
displayWidth = displayHeight * imageAspectRatio
|
||||||
|
offsetX = (windowWidth - displayWidth) / 2
|
||||||
|
offsetY = 0
|
||||||
|
} else {
|
||||||
|
// 窗口更高,图片左右占满,上下留黑边
|
||||||
|
displayWidth = windowWidth
|
||||||
|
displayHeight = displayWidth / imageAspectRatio
|
||||||
|
offsetX = 0
|
||||||
|
offsetY = (windowHeight - displayHeight) / 2
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
left: offsetX,
|
||||||
|
top: offsetY,
|
||||||
|
right: offsetX + displayWidth,
|
||||||
|
bottom: offsetY + displayHeight,
|
||||||
|
width: displayWidth,
|
||||||
|
height: displayHeight
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 初始化面板位置(移动端居中,桌面端右上角,限制在图片显示区域内)
|
||||||
|
const initPanelPosition = () => {
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
const bounds = getImageDisplayBounds()
|
||||||
|
|
||||||
|
if (isMobile.value) {
|
||||||
|
// 移动端:居中显示,尽量在图片内,但不强求
|
||||||
|
const panelWidth = Math.min(bounds.width - 16, windowSize.value.width - 16)
|
||||||
|
const panelHeight = 580 // 估计高度
|
||||||
|
panelPos.value = {
|
||||||
|
x: Math.max(bounds.left, bounds.left + (bounds.width - panelWidth) / 2),
|
||||||
|
y: Math.max(bounds.top + 8, bounds.top + (bounds.height - panelHeight) / 2)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 桌面端:在图片区域右上角
|
||||||
|
const panelWidth = Math.min(420, bounds.width * 0.9)
|
||||||
|
const panelHeight = 600
|
||||||
|
panelPos.value = {
|
||||||
|
x: bounds.right - panelWidth - 40,
|
||||||
|
y: Math.max(bounds.top + 80, bounds.top + (bounds.height - panelHeight) / 2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentYear = ref(new Date().getFullYear())
|
||||||
|
const currentMonth = ref(new Date().getMonth())
|
||||||
|
const isChangingMonth = ref(false)
|
||||||
|
|
||||||
|
// 假期数据
|
||||||
|
const holidaysData = ref<Map<number, Holidays | null>>(new Map())
|
||||||
|
const loadingHolidays = ref(false)
|
||||||
|
|
||||||
|
// 字符串版本的年月(用于Select组件)
|
||||||
|
const currentYearString = computed({
|
||||||
|
get: () => String(currentYear.value),
|
||||||
|
set: (val: string) => {
|
||||||
|
currentYear.value = Number(val)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const currentMonthString = computed({
|
||||||
|
get: () => String(currentMonth.value),
|
||||||
|
set: (val: string) => {
|
||||||
|
currentMonth.value = Number(val)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 生成年份选项(从2009年到当前年份+10年)
|
||||||
|
const yearOptions = computed(() => {
|
||||||
|
const currentYearValue = new Date().getFullYear()
|
||||||
|
const years: number[] = []
|
||||||
|
for (let year = currentYearValue - 30; year <= currentYearValue + 10; year++) {
|
||||||
|
years.push(year)
|
||||||
|
}
|
||||||
|
return years
|
||||||
|
})
|
||||||
|
|
||||||
|
// 计算是否可以切换月份(不限制)
|
||||||
|
const canGoPrevious = computed(() => {
|
||||||
|
return !isChangingMonth.value
|
||||||
|
})
|
||||||
|
|
||||||
|
const canGoNext = computed(() => {
|
||||||
|
return !isChangingMonth.value
|
||||||
|
})
|
||||||
|
|
||||||
|
// 初始化为选中的日期
|
||||||
|
watch(() => props.selectedDate, (newDate) => {
|
||||||
|
if (newDate) {
|
||||||
|
const date = new Date(newDate)
|
||||||
|
currentYear.value = date.getFullYear()
|
||||||
|
currentMonth.value = date.getMonth()
|
||||||
|
}
|
||||||
|
}, { immediate: true })
|
||||||
|
|
||||||
|
// 初始化位置
|
||||||
|
initPanelPosition()
|
||||||
|
|
||||||
|
// 加载假期数据
|
||||||
|
const loadHolidaysForYear = async (year: number) => {
|
||||||
|
if (holidaysData.value.has(year)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
loadingHolidays.value = true
|
||||||
|
try {
|
||||||
|
const data = await getHolidaysByYear(year)
|
||||||
|
holidaysData.value.set(year, data)
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`加载${year}年假期数据失败:`, error)
|
||||||
|
holidaysData.value.set(year, null)
|
||||||
|
} finally {
|
||||||
|
loadingHolidays.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 窗口缩放处理
|
||||||
|
const handleResize = () => {
|
||||||
|
windowSize.value = {
|
||||||
|
width: window.innerWidth,
|
||||||
|
height: window.innerHeight
|
||||||
|
}
|
||||||
|
initPanelPosition()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 组件挂载时加载当前年份的假期数据
|
||||||
|
onMounted(() => {
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
window.addEventListener('resize', handleResize)
|
||||||
|
}
|
||||||
|
const currentYearValue = currentYear.value
|
||||||
|
loadHolidaysForYear(currentYearValue)
|
||||||
|
// 预加载前后一年的数据
|
||||||
|
loadHolidaysForYear(currentYearValue - 1)
|
||||||
|
loadHolidaysForYear(currentYearValue + 1)
|
||||||
|
})
|
||||||
|
|
||||||
|
// 监听年份变化,加载对应的假期数据
|
||||||
|
watch(currentYear, (newYear) => {
|
||||||
|
loadHolidaysForYear(newYear)
|
||||||
|
// 预加载前后一年
|
||||||
|
loadHolidaysForYear(newYear - 1)
|
||||||
|
loadHolidaysForYear(newYear + 1)
|
||||||
|
})
|
||||||
|
|
||||||
|
// 开始拖动
|
||||||
|
const startDrag = (e: MouseEvent | TouchEvent) => {
|
||||||
|
const target = e.target as HTMLElement
|
||||||
|
// 如果点击的是按钮或其子元素,不触发拖拽
|
||||||
|
if (target.closest('button') || target.closest('[class*="grid"]')) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (e instanceof MouseEvent) {
|
||||||
|
e.preventDefault()
|
||||||
|
}
|
||||||
|
isDragging.value = true
|
||||||
|
|
||||||
|
const clientX = e instanceof MouseEvent ? e.clientX : e.touches[0].clientX
|
||||||
|
const clientY = e instanceof MouseEvent ? e.clientY : e.touches[0].clientY
|
||||||
|
|
||||||
|
dragStart.value = {
|
||||||
|
x: clientX - panelPos.value.x,
|
||||||
|
y: clientY - panelPos.value.y
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('mousemove', onDrag)
|
||||||
|
document.addEventListener('mouseup', stopDrag)
|
||||||
|
document.addEventListener('touchmove', onDrag, { passive: true })
|
||||||
|
document.addEventListener('touchend', stopDrag)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 拖动中
|
||||||
|
const onDrag = (e: MouseEvent | TouchEvent) => {
|
||||||
|
if (!isDragging.value) return
|
||||||
|
|
||||||
|
if (e instanceof MouseEvent) {
|
||||||
|
e.preventDefault()
|
||||||
|
}
|
||||||
|
|
||||||
|
const clientX = e instanceof MouseEvent ? e.clientX : e.touches[0].clientX
|
||||||
|
const clientY = e instanceof MouseEvent ? e.clientY : e.touches[0].clientY
|
||||||
|
|
||||||
|
const newX = clientX - dragStart.value.x
|
||||||
|
const newY = clientY - dragStart.value.y
|
||||||
|
|
||||||
|
// 限制在有效区域内
|
||||||
|
if (calendarPanel.value) {
|
||||||
|
const rect = calendarPanel.value.getBoundingClientRect()
|
||||||
|
|
||||||
|
let minX, maxX, minY, maxY
|
||||||
|
|
||||||
|
if (isMobile.value) {
|
||||||
|
// 移动端:不限制区域,限制在视口内即可
|
||||||
|
minX = 0
|
||||||
|
maxX = windowSize.value.width - rect.width
|
||||||
|
minY = 0
|
||||||
|
maxY = windowSize.value.height - rect.height
|
||||||
|
} else {
|
||||||
|
// 桌面端:限制在图片实际显示区域内
|
||||||
|
const bounds = getImageDisplayBounds()
|
||||||
|
minX = bounds.left
|
||||||
|
maxX = bounds.right - rect.width
|
||||||
|
minY = bounds.top
|
||||||
|
maxY = bounds.bottom - rect.height
|
||||||
|
}
|
||||||
|
|
||||||
|
panelPos.value = {
|
||||||
|
x: Math.max(minX, Math.min(newX, maxX)),
|
||||||
|
y: Math.max(minY, Math.min(newY, maxY))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 停止拖动
|
||||||
|
const stopDrag = () => {
|
||||||
|
isDragging.value = false
|
||||||
|
document.removeEventListener('mousemove', onDrag)
|
||||||
|
document.removeEventListener('mouseup', stopDrag)
|
||||||
|
document.removeEventListener('touchmove', onDrag)
|
||||||
|
document.removeEventListener('touchend', stopDrag)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 农历月份年份
|
||||||
|
const lunarMonthYear = computed(() => {
|
||||||
|
const solar = Solar.fromDate(new Date(currentYear.value, currentMonth.value, 15))
|
||||||
|
const lunar = solar.getLunar()
|
||||||
|
return `${lunar.getYearInChinese()}年${lunar.getMonthInChinese()}月`
|
||||||
|
})
|
||||||
|
|
||||||
|
// 获取日历天数
|
||||||
|
const calendarDays = computed<CalendarDay[]>(() => {
|
||||||
|
const year = currentYear.value
|
||||||
|
const month = currentMonth.value
|
||||||
|
|
||||||
|
// 当月第一天
|
||||||
|
const firstDay = new Date(year, month, 1)
|
||||||
|
const firstDayWeek = firstDay.getDay()
|
||||||
|
|
||||||
|
// 当月最后一天
|
||||||
|
const lastDay = new Date(year, month + 1, 0)
|
||||||
|
const lastDate = lastDay.getDate()
|
||||||
|
|
||||||
|
// 上月最后几天
|
||||||
|
const prevLastDay = new Date(year, month, 0)
|
||||||
|
const prevLastDate = prevLastDay.getDate()
|
||||||
|
|
||||||
|
const days: CalendarDay[] = []
|
||||||
|
const today = new Date()
|
||||||
|
today.setHours(0, 0, 0, 0)
|
||||||
|
|
||||||
|
// 填充上月日期
|
||||||
|
for (let i = firstDayWeek - 1; i >= 0; i--) {
|
||||||
|
const day = prevLastDate - i
|
||||||
|
const date = new Date(year, month - 1, day)
|
||||||
|
days.push(createDayObject(date, false))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 填充当月日期
|
||||||
|
for (let day = 1; day <= lastDate; day++) {
|
||||||
|
const date = new Date(year, month, day)
|
||||||
|
days.push(createDayObject(date, true))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 填充下月日期
|
||||||
|
const remainingDays = 42 - days.length // 6行7列
|
||||||
|
for (let day = 1; day <= remainingDays; day++) {
|
||||||
|
const date = new Date(year, month + 1, day)
|
||||||
|
days.push(createDayObject(date, false))
|
||||||
|
}
|
||||||
|
|
||||||
|
return days
|
||||||
|
})
|
||||||
|
|
||||||
|
// 创建日期对象
|
||||||
|
const createDayObject = (date: Date, isCurrentMonth: boolean): CalendarDay => {
|
||||||
|
const today = new Date()
|
||||||
|
today.setHours(0, 0, 0, 0)
|
||||||
|
|
||||||
|
const selectedDate = new Date(props.selectedDate || new Date())
|
||||||
|
selectedDate.setHours(0, 0, 0, 0)
|
||||||
|
|
||||||
|
// 转换为农历
|
||||||
|
const solar = Solar.fromDate(date)
|
||||||
|
const lunar = solar.getLunar()
|
||||||
|
|
||||||
|
// 获取节日
|
||||||
|
const festivals = solar.getFestivals()
|
||||||
|
const festival = festivals.length > 0 ? festivals[0] : ''
|
||||||
|
|
||||||
|
// 获取农历节日
|
||||||
|
const lunarFestivals = lunar.getFestivals()
|
||||||
|
const lunarFestival = lunarFestivals.length > 0 ? lunarFestivals[0] : ''
|
||||||
|
|
||||||
|
// 获取节气
|
||||||
|
const solarTerm = lunar.getJieQi()
|
||||||
|
|
||||||
|
// 获取API假期数据 - 使用本地时间避免时区偏移
|
||||||
|
const dateStr = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`
|
||||||
|
const yearHolidays = holidaysData.value.get(date.getFullYear())
|
||||||
|
const apiHoliday = getHolidayByDate(yearHolidays || null, dateStr)
|
||||||
|
|
||||||
|
// 检查是否为假期(使用lunar-javascript的节日信息)
|
||||||
|
let isHoliday = false
|
||||||
|
let holidayName = ''
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (festival || lunarFestival) {
|
||||||
|
// 常见法定节假日
|
||||||
|
const legalHolidays = ['元旦', '春节', '清明', '劳动节', '端午', '中秋', '国庆']
|
||||||
|
const holidayNames = [festival, lunarFestival].filter(Boolean)
|
||||||
|
|
||||||
|
for (const name of holidayNames) {
|
||||||
|
if (legalHolidays.some(legal => name.includes(legal))) {
|
||||||
|
isHoliday = true
|
||||||
|
holidayName = name
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.debug('假期信息获取失败:', e)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 判断是否为周末(周六或周日)
|
||||||
|
const isWeekend = date.getDay() === 0 || date.getDay() === 6
|
||||||
|
|
||||||
|
// 农历日期显示
|
||||||
|
let lunarDay = lunar.getDayInChinese()
|
||||||
|
if (lunar.getDay() === 1) {
|
||||||
|
lunarDay = lunar.getMonthInChinese() + '月'
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
day: date.getDate(),
|
||||||
|
isCurrentMonth,
|
||||||
|
isToday: date.getTime() === today.getTime(),
|
||||||
|
isSelected: date.getTime() === selectedDate.getTime(),
|
||||||
|
isFuture: date > today,
|
||||||
|
isWeekend,
|
||||||
|
isHoliday,
|
||||||
|
holidayName,
|
||||||
|
apiHoliday,
|
||||||
|
lunarDay,
|
||||||
|
festival,
|
||||||
|
lunarFestival,
|
||||||
|
solarTerm,
|
||||||
|
date
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 上一月
|
||||||
|
const previousMonth = () => {
|
||||||
|
if (!canGoPrevious.value) return
|
||||||
|
|
||||||
|
if (currentMonth.value === 0) {
|
||||||
|
currentMonth.value = 11
|
||||||
|
currentYear.value--
|
||||||
|
} else {
|
||||||
|
currentMonth.value--
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 下一月
|
||||||
|
const nextMonth = () => {
|
||||||
|
if (!canGoNext.value) return
|
||||||
|
|
||||||
|
if (currentMonth.value === 11) {
|
||||||
|
currentMonth.value = 0
|
||||||
|
currentYear.value++
|
||||||
|
} else {
|
||||||
|
currentMonth.value++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 回到今天
|
||||||
|
const goToToday = () => {
|
||||||
|
const today = new Date()
|
||||||
|
currentYear.value = today.getFullYear()
|
||||||
|
currentMonth.value = today.getMonth()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 不再支持点击日期选择
|
||||||
|
// 日历仅作为台历展示功能
|
||||||
|
|
||||||
|
// 清理
|
||||||
|
import { onUnmounted } from 'vue'
|
||||||
|
onUnmounted(() => {
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
window.removeEventListener('resize', handleResize)
|
||||||
|
}
|
||||||
|
document.removeEventListener('mousemove', onDrag)
|
||||||
|
document.removeEventListener('mouseup', stopDrag)
|
||||||
|
document.removeEventListener('touchmove', onDrag)
|
||||||
|
document.removeEventListener('touchend', stopDrag)
|
||||||
|
})
|
||||||
|
</script>
|
||||||
@@ -2,11 +2,12 @@ import { ref, onMounted, watch } from 'vue'
|
|||||||
import type { Ref } from 'vue'
|
import type { Ref } from 'vue'
|
||||||
import { bingPaperApi } from '@/lib/api-service'
|
import { bingPaperApi } from '@/lib/api-service'
|
||||||
import type { ImageMeta } from '@/lib/api-types'
|
import type { ImageMeta } from '@/lib/api-types'
|
||||||
|
import { getDefaultMkt } from '@/lib/mkt-utils'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取今日图片
|
* 获取今日图片
|
||||||
*/
|
*/
|
||||||
export function useTodayImage() {
|
export function useTodayImage(mkt?: string) {
|
||||||
const image = ref<ImageMeta | null>(null)
|
const image = ref<ImageMeta | null>(null)
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const error = ref<Error | null>(null)
|
const error = ref<Error | null>(null)
|
||||||
@@ -15,7 +16,7 @@ export function useTodayImage() {
|
|||||||
loading.value = true
|
loading.value = true
|
||||||
error.value = null
|
error.value = null
|
||||||
try {
|
try {
|
||||||
image.value = await bingPaperApi.getTodayImageMeta()
|
image.value = await bingPaperApi.getTodayImageMeta(mkt || getDefaultMkt())
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error.value = e as Error
|
error.value = e as Error
|
||||||
console.error('Failed to fetch today image:', e)
|
console.error('Failed to fetch today image:', e)
|
||||||
@@ -36,6 +37,39 @@ export function useTodayImage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取全球今日图片
|
||||||
|
*/
|
||||||
|
export function useGlobalTodayImages() {
|
||||||
|
const images = ref<ImageMeta[]>([])
|
||||||
|
const loading = ref(false)
|
||||||
|
const error = ref<Error | null>(null)
|
||||||
|
|
||||||
|
const fetchImages = async () => {
|
||||||
|
loading.value = true
|
||||||
|
error.value = null
|
||||||
|
try {
|
||||||
|
images.value = await bingPaperApi.getGlobalTodayImages()
|
||||||
|
} catch (e) {
|
||||||
|
error.value = e as Error
|
||||||
|
console.error('Failed to fetch global today images:', e)
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
fetchImages()
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
images,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
refetch: fetchImages
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取图片列表(支持分页和月份筛选)
|
* 获取图片列表(支持分页和月份筛选)
|
||||||
*/
|
*/
|
||||||
@@ -46,8 +80,9 @@ export function useImageList(pageSize = 30) {
|
|||||||
const hasMore = ref(true)
|
const hasMore = ref(true)
|
||||||
const currentPage = ref(1)
|
const currentPage = ref(1)
|
||||||
const currentMonth = ref<string | undefined>(undefined)
|
const currentMonth = ref<string | undefined>(undefined)
|
||||||
|
const currentMkt = ref<string | undefined>(getDefaultMkt())
|
||||||
|
|
||||||
const fetchImages = async (page = 1, month?: string) => {
|
const fetchImages = async (page = 1, month?: string, mkt?: string) => {
|
||||||
if (loading.value) return
|
if (loading.value) return
|
||||||
|
|
||||||
loading.value = true
|
loading.value = true
|
||||||
@@ -55,7 +90,8 @@ export function useImageList(pageSize = 30) {
|
|||||||
try {
|
try {
|
||||||
const params: any = {
|
const params: any = {
|
||||||
page,
|
page,
|
||||||
page_size: pageSize
|
page_size: pageSize,
|
||||||
|
mkt: mkt || currentMkt.value || getDefaultMkt()
|
||||||
}
|
}
|
||||||
if (month) {
|
if (month) {
|
||||||
params.month = month
|
params.month = month
|
||||||
@@ -84,7 +120,7 @@ export function useImageList(pageSize = 30) {
|
|||||||
|
|
||||||
const loadMore = () => {
|
const loadMore = () => {
|
||||||
if (!loading.value && hasMore.value) {
|
if (!loading.value && hasMore.value) {
|
||||||
fetchImages(currentPage.value + 1, currentMonth.value)
|
fetchImages(currentPage.value + 1, currentMonth.value, currentMkt.value)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -92,7 +128,14 @@ export function useImageList(pageSize = 30) {
|
|||||||
currentMonth.value = month
|
currentMonth.value = month
|
||||||
currentPage.value = 1
|
currentPage.value = 1
|
||||||
hasMore.value = true
|
hasMore.value = true
|
||||||
fetchImages(1, month)
|
fetchImages(1, month, currentMkt.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
const filterByMkt = (mkt?: string) => {
|
||||||
|
currentMkt.value = mkt
|
||||||
|
currentPage.value = 1
|
||||||
|
hasMore.value = true
|
||||||
|
fetchImages(1, currentMonth.value, mkt)
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
@@ -106,10 +149,11 @@ export function useImageList(pageSize = 30) {
|
|||||||
hasMore,
|
hasMore,
|
||||||
loadMore,
|
loadMore,
|
||||||
filterByMonth,
|
filterByMonth,
|
||||||
|
filterByMkt,
|
||||||
refetch: () => {
|
refetch: () => {
|
||||||
currentPage.value = 1
|
currentPage.value = 1
|
||||||
hasMore.value = true
|
hasMore.value = true
|
||||||
fetchImages(1, currentMonth.value)
|
fetchImages(1, currentMonth.value, currentMkt.value)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -117,7 +161,7 @@ export function useImageList(pageSize = 30) {
|
|||||||
/**
|
/**
|
||||||
* 获取指定日期的图片
|
* 获取指定日期的图片
|
||||||
*/
|
*/
|
||||||
export function useImageByDate(dateRef: Ref<string>) {
|
export function useImageByDate(dateRef: Ref<string>, mktRef?: Ref<string | undefined>) {
|
||||||
const image = ref<ImageMeta | null>(null)
|
const image = ref<ImageMeta | null>(null)
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const error = ref<Error | null>(null)
|
const error = ref<Error | null>(null)
|
||||||
@@ -126,7 +170,7 @@ export function useImageByDate(dateRef: Ref<string>) {
|
|||||||
loading.value = true
|
loading.value = true
|
||||||
error.value = null
|
error.value = null
|
||||||
try {
|
try {
|
||||||
image.value = await bingPaperApi.getImageMetaByDate(dateRef.value)
|
image.value = await bingPaperApi.getImageMetaByDate(dateRef.value, mktRef?.value || getDefaultMkt())
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error.value = e as Error
|
error.value = e as Error
|
||||||
console.error(`Failed to fetch image for date ${dateRef.value}:`, e)
|
console.error(`Failed to fetch image for date ${dateRef.value}:`, e)
|
||||||
@@ -135,10 +179,16 @@ export function useImageByDate(dateRef: Ref<string>) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 监听日期变化,自动重新获取
|
// 监听日期和地区变化,自动重新获取
|
||||||
|
if (mktRef) {
|
||||||
|
watch([dateRef, mktRef], () => {
|
||||||
|
fetchImage()
|
||||||
|
}, { immediate: true })
|
||||||
|
} else {
|
||||||
watch(dateRef, () => {
|
watch(dateRef, () => {
|
||||||
fetchImage()
|
fetchImage()
|
||||||
}, { immediate: true })
|
}, { immediate: true })
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
image,
|
image,
|
||||||
|
|||||||
@@ -35,6 +35,26 @@ export const buildApiUrl = (endpoint: string): string => {
|
|||||||
return `${API_BASE_URL}${normalizedEndpoint}`
|
return `${API_BASE_URL}${normalizedEndpoint}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 标准化图片 URL
|
||||||
|
* 当后端返回相对路径且配置了绝对 API 基础地址时,自动拼接完整域名
|
||||||
|
*/
|
||||||
|
export const normalizeImageUrl = (url: string | undefined): string => {
|
||||||
|
if (!url) return ''
|
||||||
|
if (url.startsWith('http')) return url
|
||||||
|
|
||||||
|
// 处理相对路径问题:如果配置了绝对 API 基础地址,则拼接 Origin
|
||||||
|
if (API_BASE_URL.startsWith('http')) {
|
||||||
|
try {
|
||||||
|
const origin = new URL(API_BASE_URL).origin
|
||||||
|
return url.startsWith('/') ? origin + url : origin + '/' + url
|
||||||
|
} catch (e) {
|
||||||
|
// 解析失败则返回原样
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return url
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* HTTP 状态码枚举
|
* HTTP 状态码枚举
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import type {
|
|||||||
UpdateTokenRequest,
|
UpdateTokenRequest,
|
||||||
ChangePasswordRequest,
|
ChangePasswordRequest,
|
||||||
Config,
|
Config,
|
||||||
|
Region,
|
||||||
ImageMeta,
|
ImageMeta,
|
||||||
ImageListParams,
|
ImageListParams,
|
||||||
ManualFetchRequest,
|
ManualFetchRequest,
|
||||||
@@ -109,6 +110,7 @@ export class BingPaperApiService {
|
|||||||
if (params?.page) searchParams.set('page', params.page.toString())
|
if (params?.page) searchParams.set('page', params.page.toString())
|
||||||
if (params?.page_size) searchParams.set('page_size', params.page_size.toString())
|
if (params?.page_size) searchParams.set('page_size', params.page_size.toString())
|
||||||
if (params?.month) searchParams.set('month', params.month)
|
if (params?.month) searchParams.set('month', params.month)
|
||||||
|
if (params?.mkt) searchParams.set('mkt', params.mkt)
|
||||||
|
|
||||||
const queryString = searchParams.toString()
|
const queryString = searchParams.toString()
|
||||||
const endpoint = queryString ? `/images?${queryString}` : '/images'
|
const endpoint = queryString ? `/images?${queryString}` : '/images'
|
||||||
@@ -116,48 +118,68 @@ export class BingPaperApiService {
|
|||||||
return apiClient.get<ImageMeta[]>(endpoint)
|
return apiClient.get<ImageMeta[]>(endpoint)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取所有地区的今日图片列表
|
||||||
|
*/
|
||||||
|
async getGlobalTodayImages(): Promise<ImageMeta[]> {
|
||||||
|
return apiClient.get<ImageMeta[]>('/images/global/today')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取支持的地区列表
|
||||||
|
*/
|
||||||
|
async getRegions(): Promise<Region[]> {
|
||||||
|
return apiClient.get<Region[]>('/regions')
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取今日图片元数据
|
* 获取今日图片元数据
|
||||||
*/
|
*/
|
||||||
async getTodayImageMeta(): Promise<ImageMeta> {
|
async getTodayImageMeta(mkt?: string): Promise<ImageMeta> {
|
||||||
return apiClient.get<ImageMeta>('/image/today/meta')
|
const endpoint = mkt ? `/image/today/meta?mkt=${mkt}` : '/image/today/meta'
|
||||||
|
return apiClient.get<ImageMeta>(endpoint)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取指定日期图片元数据
|
* 获取指定日期图片元数据
|
||||||
*/
|
*/
|
||||||
async getImageMetaByDate(date: string): Promise<ImageMeta> {
|
async getImageMetaByDate(date: string, mkt?: string): Promise<ImageMeta> {
|
||||||
return apiClient.get<ImageMeta>(`/image/date/${date}/meta`)
|
const endpoint = mkt ? `/image/date/${date}/meta?mkt=${mkt}` : `/image/date/${date}/meta`
|
||||||
|
return apiClient.get<ImageMeta>(endpoint)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取随机图片元数据
|
* 获取随机图片元数据
|
||||||
*/
|
*/
|
||||||
async getRandomImageMeta(): Promise<ImageMeta> {
|
async getRandomImageMeta(mkt?: string): Promise<ImageMeta> {
|
||||||
return apiClient.get<ImageMeta>('/image/random/meta')
|
const endpoint = mkt ? `/image/random/meta?mkt=${mkt}` : '/image/random/meta'
|
||||||
|
return apiClient.get<ImageMeta>(endpoint)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 构建图片 URL
|
* 构建图片 URL
|
||||||
*/
|
*/
|
||||||
getTodayImageUrl(variant: ImageVariant = 'UHD', format: ImageFormat = 'jpg'): string {
|
getTodayImageUrl(variant: ImageVariant = 'UHD', format: ImageFormat = 'jpg', mkt?: string): string {
|
||||||
const params = new URLSearchParams({ variant, format })
|
const params = new URLSearchParams({ variant, format })
|
||||||
|
if (mkt) params.set('mkt', mkt)
|
||||||
return `${apiConfig.baseURL}/image/today?${params.toString()}`
|
return `${apiConfig.baseURL}/image/today?${params.toString()}`
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 构建指定日期图片 URL
|
* 构建指定日期图片 URL
|
||||||
*/
|
*/
|
||||||
getImageUrlByDate(date: string, variant: ImageVariant = 'UHD', format: ImageFormat = 'jpg'): string {
|
getImageUrlByDate(date: string, variant: ImageVariant = 'UHD', format: ImageFormat = 'jpg', mkt?: string): string {
|
||||||
const params = new URLSearchParams({ variant, format })
|
const params = new URLSearchParams({ variant, format })
|
||||||
|
if (mkt) params.set('mkt', mkt)
|
||||||
return `${apiConfig.baseURL}/image/date/${date}?${params.toString()}`
|
return `${apiConfig.baseURL}/image/date/${date}?${params.toString()}`
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 构建随机图片 URL
|
* 构建随机图片 URL
|
||||||
*/
|
*/
|
||||||
getRandomImageUrl(variant: ImageVariant = 'UHD', format: ImageFormat = 'jpg'): string {
|
getRandomImageUrl(variant: ImageVariant = 'UHD', format: ImageFormat = 'jpg', mkt?: string): string {
|
||||||
const params = new URLSearchParams({ variant, format })
|
const params = new URLSearchParams({ variant, format })
|
||||||
|
if (mkt) params.set('mkt', mkt)
|
||||||
return `${apiConfig.baseURL}/image/random?${params.toString()}`
|
return `${apiConfig.baseURL}/image/random?${params.toString()}`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -181,6 +203,9 @@ export class BingPaperApiService {
|
|||||||
// 导出默认实例
|
// 导出默认实例
|
||||||
export const bingPaperApi = new BingPaperApiService()
|
export const bingPaperApi = new BingPaperApiService()
|
||||||
|
|
||||||
|
// 为了兼容性,也导出为 apiService
|
||||||
|
export const apiService = bingPaperApi
|
||||||
|
|
||||||
// 导出便捷方法
|
// 导出便捷方法
|
||||||
export const {
|
export const {
|
||||||
login,
|
login,
|
||||||
@@ -194,6 +219,8 @@ export const {
|
|||||||
manualFetch,
|
manualFetch,
|
||||||
manualCleanup,
|
manualCleanup,
|
||||||
getImages,
|
getImages,
|
||||||
|
getGlobalTodayImages,
|
||||||
|
getRegions,
|
||||||
getTodayImageMeta,
|
getTodayImageMeta,
|
||||||
getImageMetaByDate,
|
getImageMetaByDate,
|
||||||
getRandomImageMeta,
|
getRandomImageMeta,
|
||||||
|
|||||||
@@ -50,103 +50,111 @@ export interface ChangePasswordRequest {
|
|||||||
// ===== 配置相关 =====
|
// ===== 配置相关 =====
|
||||||
|
|
||||||
export interface Config {
|
export interface Config {
|
||||||
admin: AdminConfig
|
Server: ServerConfig
|
||||||
api: APIConfig
|
Log: LogConfig
|
||||||
cron: CronConfig
|
API: APIConfig
|
||||||
db: DBConfig
|
Cron: CronConfig
|
||||||
feature: FeatureConfig
|
Retention: RetentionConfig
|
||||||
log: LogConfig
|
DB: DBConfig
|
||||||
retention: RetentionConfig
|
Storage: StorageConfig
|
||||||
server: ServerConfig
|
Admin: AdminConfig
|
||||||
storage: StorageConfig
|
Token: TokenConfig
|
||||||
token: TokenConfig
|
Feature: FeatureConfig
|
||||||
web: WebConfig
|
Web: WebConfig
|
||||||
|
Fetcher: FetcherConfig
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FetcherConfig {
|
||||||
|
Regions: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AdminConfig {
|
export interface AdminConfig {
|
||||||
passwordBcrypt: string
|
PasswordBcrypt: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface APIConfig {
|
export interface APIConfig {
|
||||||
mode: string // 'local' | 'redirect'
|
Mode: string // 'local' | 'redirect'
|
||||||
|
EnableMktFallback: boolean
|
||||||
|
EnableOnDemandFetch: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CronConfig {
|
export interface CronConfig {
|
||||||
enabled: boolean
|
Enabled: boolean
|
||||||
dailySpec: string
|
DailySpec: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DBConfig {
|
export interface DBConfig {
|
||||||
type: string // 'sqlite' | 'mysql' | 'postgres'
|
Type: string // 'sqlite' | 'mysql' | 'postgres'
|
||||||
dsn: string
|
DSN: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FeatureConfig {
|
export interface FeatureConfig {
|
||||||
writeDailyFiles: boolean
|
WriteDailyFiles: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LogConfig {
|
export interface LogConfig {
|
||||||
level: string
|
Level: string
|
||||||
filename: string
|
Filename: string
|
||||||
dbfilename: string
|
DBFilename: string
|
||||||
dblogLevel: string
|
DBLogLevel: string
|
||||||
logConsole: boolean
|
LogConsole: boolean
|
||||||
showDBLog: boolean
|
ShowDBLog: boolean
|
||||||
maxSize: number
|
MaxSize: number
|
||||||
maxAge: number
|
MaxAge: number
|
||||||
maxBackups: number
|
MaxBackups: number
|
||||||
compress: boolean
|
Compress: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RetentionConfig {
|
export interface RetentionConfig {
|
||||||
days: number
|
Days: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ServerConfig {
|
export interface ServerConfig {
|
||||||
port: number
|
Port: number
|
||||||
baseURL: string
|
BaseURL: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface StorageConfig {
|
export interface StorageConfig {
|
||||||
type: string // 'local' | 's3' | 'webdav'
|
Type: string // 'local' | 's3' | 'webdav'
|
||||||
local: LocalConfig
|
Local: LocalConfig
|
||||||
s3: S3Config
|
S3: S3Config
|
||||||
webDAV: WebDAVConfig
|
WebDAV: WebDAVConfig
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LocalConfig {
|
export interface LocalConfig {
|
||||||
root: string
|
Root: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface S3Config {
|
export interface S3Config {
|
||||||
endpoint: string
|
Endpoint: string
|
||||||
accessKey: string
|
AccessKey: string
|
||||||
secretKey: string
|
SecretKey: string
|
||||||
bucket: string
|
Bucket: string
|
||||||
region: string
|
Region: string
|
||||||
forcePathStyle: boolean
|
ForcePathStyle: boolean
|
||||||
publicURLPrefix: string
|
PublicURLPrefix: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface WebDAVConfig {
|
export interface WebDAVConfig {
|
||||||
url: string
|
URL: string
|
||||||
username: string
|
Username: string
|
||||||
password: string
|
Password: string
|
||||||
publicURLPrefix: string
|
PublicURLPrefix: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TokenConfig {
|
export interface TokenConfig {
|
||||||
defaultTTL: string
|
DefaultTTL: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface WebConfig {
|
export interface WebConfig {
|
||||||
path: string
|
Path: string
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== 图片相关 =====
|
// ===== 图片相关 =====
|
||||||
|
|
||||||
export interface ImageMeta {
|
export interface ImageMeta {
|
||||||
date?: string
|
date?: string
|
||||||
|
mkt?: string
|
||||||
title?: string
|
title?: string
|
||||||
copyright?: string
|
copyright?: string
|
||||||
copyrightlink?: string // 图片的详细版权链接(指向 Bing 搜索页面)
|
copyrightlink?: string // 图片的详细版权链接(指向 Bing 搜索页面)
|
||||||
@@ -157,13 +165,28 @@ export interface ImageMeta {
|
|||||||
url?: string
|
url?: string
|
||||||
variant?: string
|
variant?: string
|
||||||
format?: string
|
format?: string
|
||||||
|
variants?: ImageVariantResp[] // 图片变体列表
|
||||||
[key: string]: any
|
[key: string]: any
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ImageVariantResp {
|
||||||
|
variant: string // 分辨率变体 (UHD, 1920x1080, 等)
|
||||||
|
format: string // 格式 (jpg)
|
||||||
|
url: string // 访问 URL
|
||||||
|
storage_key: string // 存储键
|
||||||
|
size: number // 文件大小(字节)
|
||||||
|
}
|
||||||
|
|
||||||
export interface ImageListParams extends PaginationParams {
|
export interface ImageListParams extends PaginationParams {
|
||||||
page?: number // 页码(从1开始)
|
page?: number // 页码(从1开始)
|
||||||
page_size?: number // 每页数量
|
page_size?: number // 每页数量
|
||||||
month?: string // 按月份过滤(格式:YYYY-MM)
|
month?: string // 按月份过滤(格式:YYYY-MM)
|
||||||
|
mkt?: string // 地区编码
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Region {
|
||||||
|
value: string
|
||||||
|
label: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ManualFetchRequest {
|
export interface ManualFetchRequest {
|
||||||
|
|||||||
65
webapp/src/lib/holiday-service.ts
Normal file
65
webapp/src/lib/holiday-service.ts
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
// 假期API类型定义
|
||||||
|
export interface HolidayDay {
|
||||||
|
/** 节日名称 */
|
||||||
|
name: string;
|
||||||
|
/** 日期, ISO 8601 格式 */
|
||||||
|
date: string;
|
||||||
|
/** 是否为休息日 */
|
||||||
|
isOffDay: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Holidays {
|
||||||
|
/** 完整年份, 整数。*/
|
||||||
|
year: number;
|
||||||
|
/** 所用国务院文件网址列表 */
|
||||||
|
papers: string[];
|
||||||
|
days: HolidayDay[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 假期数据缓存
|
||||||
|
const holidayCache = new Map<number, Holidays>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取指定年份的假期数据
|
||||||
|
*/
|
||||||
|
export async function getHolidaysByYear(year: number): Promise<Holidays | null> {
|
||||||
|
// 检查缓存
|
||||||
|
if (holidayCache.has(year)) {
|
||||||
|
return holidayCache.get(year)!;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`https://api.coding.icu/cnholiday/${year}.json`);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
console.warn(`获取${year}年假期数据失败: ${response.status}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data: Holidays = await response.json();
|
||||||
|
|
||||||
|
// 缓存数据
|
||||||
|
holidayCache.set(year, data);
|
||||||
|
|
||||||
|
return data;
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`获取${year}年假期数据出错:`, error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取指定日期的假期信息
|
||||||
|
*/
|
||||||
|
export function getHolidayByDate(holidays: Holidays | null, dateStr: string): HolidayDay | null {
|
||||||
|
if (!holidays) return null;
|
||||||
|
|
||||||
|
return holidays.days.find(day => day.date === dateStr) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 清除假期缓存
|
||||||
|
*/
|
||||||
|
export function clearHolidayCache() {
|
||||||
|
holidayCache.clear();
|
||||||
|
}
|
||||||
@@ -108,11 +108,18 @@ export class ApiClient {
|
|||||||
// 检查响应状态
|
// 检查响应状态
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const errorData = await this.parseResponse(response)
|
const errorData = await this.parseResponse(response)
|
||||||
throw new ApiError(
|
const apiError = new ApiError(
|
||||||
errorData?.message || `HTTP ${response.status}: ${response.statusText}`,
|
errorData?.message || `HTTP ${response.status}: ${response.statusText}`,
|
||||||
response.status,
|
response.status,
|
||||||
errorData
|
errorData
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// 401 未授权错误,自动跳转到登录页
|
||||||
|
if (response.status === 401) {
|
||||||
|
this.handle401Error()
|
||||||
|
}
|
||||||
|
|
||||||
|
throw apiError
|
||||||
}
|
}
|
||||||
|
|
||||||
return await this.parseResponse(response)
|
return await this.parseResponse(response)
|
||||||
@@ -130,6 +137,24 @@ export class ApiClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理 401 错误
|
||||||
|
*/
|
||||||
|
private handle401Error() {
|
||||||
|
// 清除本地存储的 token
|
||||||
|
localStorage.removeItem('admin_token')
|
||||||
|
localStorage.removeItem('admin_token_expires')
|
||||||
|
this.clearAuthToken()
|
||||||
|
|
||||||
|
// 只有在管理页面时才跳转到登录页
|
||||||
|
if (typeof window !== 'undefined' && window.location.pathname.startsWith('/admin')) {
|
||||||
|
// 避免重复跳转
|
||||||
|
if (!window.location.pathname.includes('/admin/login')) {
|
||||||
|
window.location.href = '/admin/login'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 解析响应数据
|
* 解析响应数据
|
||||||
*/
|
*/
|
||||||
|
|||||||
75
webapp/src/lib/mkt-utils.ts
Normal file
75
webapp/src/lib/mkt-utils.ts
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
|
||||||
|
const MKT_STORAGE_KEY = 'bing_paper_selected_mkt'
|
||||||
|
const DEFAULT_MKT = 'zh-CN'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 默认地区列表 (兜底用)
|
||||||
|
*/
|
||||||
|
export const DEFAULT_REGIONS = [
|
||||||
|
{ value: 'zh-CN', label: '中国' },
|
||||||
|
{ value: 'en-US', label: '美国' },
|
||||||
|
{ value: 'ja-JP', label: '日本' },
|
||||||
|
{ value: 'en-AU', label: '澳大利亚' },
|
||||||
|
{ value: 'en-GB', label: '英国' },
|
||||||
|
{ value: 'de-DE', label: '德国' },
|
||||||
|
{ value: 'en-NZ', label: '新西兰' },
|
||||||
|
{ value: 'en-CA', label: '加拿大' },
|
||||||
|
{ value: 'fr-FR', label: '法国' },
|
||||||
|
{ value: 'it-IT', label: '意大利' },
|
||||||
|
{ value: 'es-ES', label: '西班牙' },
|
||||||
|
{ value: 'pt-BR', label: '巴西' },
|
||||||
|
{ value: 'ko-KR', label: '韩国' },
|
||||||
|
{ value: 'en-IN', label: '印度' },
|
||||||
|
{ value: 'ru-RU', label: '俄罗斯' },
|
||||||
|
{ value: 'zh-HK', label: '中国香港' },
|
||||||
|
{ value: 'zh-TW', label: '中国台湾' },
|
||||||
|
]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 支持的地区列表 (优先使用后端提供的)
|
||||||
|
*/
|
||||||
|
export let SUPPORTED_REGIONS = [...DEFAULT_REGIONS]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新支持的地区列表
|
||||||
|
*/
|
||||||
|
export function setSupportedRegions(regions: typeof DEFAULT_REGIONS): void {
|
||||||
|
SUPPORTED_REGIONS = regions
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取浏览器首选地区
|
||||||
|
*/
|
||||||
|
export function getBrowserMkt(): string {
|
||||||
|
const lang = navigator.language || (navigator as any).userLanguage
|
||||||
|
if (!lang) return DEFAULT_MKT
|
||||||
|
|
||||||
|
// 尝试精确匹配
|
||||||
|
const exactMatch = SUPPORTED_REGIONS.find(r => r.value.toLowerCase() === lang.toLowerCase())
|
||||||
|
if (exactMatch) return exactMatch.value
|
||||||
|
|
||||||
|
// 尝试模糊匹配 (前两个字符,如 en-GB 匹配 en-US)
|
||||||
|
const prefix = lang.split('-')[0].toLowerCase()
|
||||||
|
const prefixMatch = SUPPORTED_REGIONS.find(r => r.value.split('-')[0].toLowerCase() === prefix)
|
||||||
|
if (prefixMatch) return prefixMatch.value
|
||||||
|
|
||||||
|
return DEFAULT_MKT
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取当前选择的地区 (优先从 localStorage 获取,其次从浏览器获取)
|
||||||
|
*/
|
||||||
|
export function getDefaultMkt(): string {
|
||||||
|
const saved = localStorage.getItem(MKT_STORAGE_KEY)
|
||||||
|
if (saved && SUPPORTED_REGIONS.some(r => r.value === saved)) {
|
||||||
|
return saved
|
||||||
|
}
|
||||||
|
return getBrowserMkt()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 保存选择的地区
|
||||||
|
*/
|
||||||
|
export function setSavedMkt(mkt: string): void {
|
||||||
|
localStorage.setItem(MKT_STORAGE_KEY, mkt)
|
||||||
|
}
|
||||||
@@ -2,6 +2,8 @@ import { createRouter, createWebHistory } from 'vue-router'
|
|||||||
import Home from '@/views/Home.vue'
|
import Home from '@/views/Home.vue'
|
||||||
import ImageView from '@/views/ImageView.vue'
|
import ImageView from '@/views/ImageView.vue'
|
||||||
import ApiDocs from '@/views/ApiDocs.vue'
|
import ApiDocs from '@/views/ApiDocs.vue'
|
||||||
|
import AdminLogin from '@/views/AdminLogin.vue'
|
||||||
|
import Admin from '@/views/Admin.vue'
|
||||||
|
|
||||||
const router = createRouter({
|
const router = createRouter({
|
||||||
history: createWebHistory(),
|
history: createWebHistory(),
|
||||||
@@ -15,11 +17,20 @@ const router = createRouter({
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/image/:date',
|
path: '/image/:date?',
|
||||||
name: 'ImageView',
|
name: 'ImageView',
|
||||||
component: ImageView,
|
component: ImageView,
|
||||||
meta: {
|
meta: {
|
||||||
title: '图片详情'
|
title: '图片详情'
|
||||||
|
},
|
||||||
|
beforeEnter: (to, _from, next) => {
|
||||||
|
// 如果没有提供日期参数,重定向到今天的日期
|
||||||
|
if (!to.params.date) {
|
||||||
|
const today = new Date().toISOString().split('T')[0]
|
||||||
|
next({ path: `/image/${today}`, replace: true })
|
||||||
|
} else {
|
||||||
|
next()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -29,13 +40,54 @@ const router = createRouter({
|
|||||||
meta: {
|
meta: {
|
||||||
title: 'API 文档'
|
title: 'API 文档'
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/admin/login',
|
||||||
|
name: 'AdminLogin',
|
||||||
|
component: AdminLogin,
|
||||||
|
meta: {
|
||||||
|
title: '管理员登录'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/admin',
|
||||||
|
name: 'Admin',
|
||||||
|
component: Admin,
|
||||||
|
meta: {
|
||||||
|
title: '管理后台',
|
||||||
|
requiresAuth: true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
|
|
||||||
// 路由守卫 - 更新页面标题
|
// 路由守卫 - 更新页面标题和认证检查
|
||||||
router.beforeEach((to, _from, next) => {
|
router.beforeEach((to, _from, next) => {
|
||||||
document.title = (to.meta.title as string) || '必应每日一图'
|
document.title = (to.meta.title as string) || '必应每日一图'
|
||||||
|
|
||||||
|
// 检查是否需要认证
|
||||||
|
if (to.meta.requiresAuth) {
|
||||||
|
const token = localStorage.getItem('admin_token')
|
||||||
|
if (!token) {
|
||||||
|
// 未登录,重定向到登录页
|
||||||
|
next('/admin/login')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查 token 是否过期
|
||||||
|
const expiresAt = localStorage.getItem('admin_token_expires')
|
||||||
|
if (expiresAt) {
|
||||||
|
const expireDate = new Date(expiresAt)
|
||||||
|
if (expireDate < new Date()) {
|
||||||
|
// token 已过期,清除并重定向到登录页
|
||||||
|
localStorage.removeItem('admin_token')
|
||||||
|
localStorage.removeItem('admin_token_expires')
|
||||||
|
next('/admin/login')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
next()
|
next()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
20
webapp/src/types/lunar-javascript.d.ts
vendored
Normal file
20
webapp/src/types/lunar-javascript.d.ts
vendored
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
declare module 'lunar-javascript' {
|
||||||
|
export class Solar {
|
||||||
|
static fromDate(date: Date): Solar
|
||||||
|
getLunar(): Lunar
|
||||||
|
getFestivals(): string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Lunar {
|
||||||
|
getYearInChinese(): string
|
||||||
|
getMonthInChinese(): string
|
||||||
|
getDayInChinese(): string
|
||||||
|
getDay(): number
|
||||||
|
getJieQi(): string
|
||||||
|
getFestivals(): string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export class HolidayUtil {
|
||||||
|
// Add methods if needed
|
||||||
|
}
|
||||||
|
}
|
||||||
199
webapp/src/views/Admin.vue
Normal file
199
webapp/src/views/Admin.vue
Normal file
@@ -0,0 +1,199 @@
|
|||||||
|
<template>
|
||||||
|
<div class="min-h-screen bg-gray-50">
|
||||||
|
<!-- 顶部导航栏 -->
|
||||||
|
<header class="bg-white border-b">
|
||||||
|
<div class="container mx-auto px-4 py-4">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="flex items-center gap-4">
|
||||||
|
<h1 class="text-xl font-bold">BingPaper 管理后台</h1>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-4">
|
||||||
|
<Button variant="outline" size="sm" @click="showPasswordDialog = true">
|
||||||
|
修改密码
|
||||||
|
</Button>
|
||||||
|
<Button variant="destructive" size="sm" @click="handleLogout">
|
||||||
|
退出登录
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- 主内容区 -->
|
||||||
|
<div class="container mx-auto px-4 py-6">
|
||||||
|
<Tabs v-model="activeTab" class="space-y-4">
|
||||||
|
<TabsList class="grid w-full grid-cols-3 lg:w-[400px]">
|
||||||
|
<TabsTrigger value="tokens">Token 管理</TabsTrigger>
|
||||||
|
<TabsTrigger value="tasks">定时任务</TabsTrigger>
|
||||||
|
<TabsTrigger value="config">系统配置</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
<TabsContent value="tokens" class="space-y-4">
|
||||||
|
<AdminTokens />
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="tasks" class="space-y-4">
|
||||||
|
<AdminTasks />
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="config" class="space-y-4">
|
||||||
|
<AdminConfig />
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 修改密码对话框 -->
|
||||||
|
<Dialog v-model:open="showPasswordDialog">
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>修改管理员密码</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
请输入旧密码和新密码
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<form @submit.prevent="handleChangePassword" class="space-y-4">
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label for="old-password">旧密码</Label>
|
||||||
|
<Input
|
||||||
|
id="old-password"
|
||||||
|
v-model="passwordForm.oldPassword"
|
||||||
|
type="password"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label for="new-password">新密码</Label>
|
||||||
|
<Input
|
||||||
|
id="new-password"
|
||||||
|
v-model="passwordForm.newPassword"
|
||||||
|
type="password"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label for="confirm-password">确认新密码</Label>
|
||||||
|
<Input
|
||||||
|
id="confirm-password"
|
||||||
|
v-model="passwordForm.confirmPassword"
|
||||||
|
type="password"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div v-if="passwordError" class="text-sm text-red-600">
|
||||||
|
{{ passwordError }}
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button type="button" variant="outline" @click="showPasswordDialog = false">
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" :disabled="passwordLoading">
|
||||||
|
{{ passwordLoading ? '提交中...' : '确认修改' }}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { toast } from 'vue-sonner'
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { Label } from '@/components/ui/label'
|
||||||
|
import { apiService } from '@/lib/api-service'
|
||||||
|
import { apiClient } from '@/lib/http-client'
|
||||||
|
import AdminTokens from './AdminTokens.vue'
|
||||||
|
import AdminTasks from './AdminTasks.vue'
|
||||||
|
import AdminConfig from './AdminConfig.vue'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const activeTab = ref('tokens')
|
||||||
|
|
||||||
|
const showPasswordDialog = ref(false)
|
||||||
|
const passwordForm = ref({
|
||||||
|
oldPassword: '',
|
||||||
|
newPassword: '',
|
||||||
|
confirmPassword: ''
|
||||||
|
})
|
||||||
|
const passwordLoading = ref(false)
|
||||||
|
const passwordError = ref('')
|
||||||
|
|
||||||
|
// 检查认证状态
|
||||||
|
const checkAuth = () => {
|
||||||
|
const token = localStorage.getItem('admin_token')
|
||||||
|
if (!token) {
|
||||||
|
router.push('/admin/login')
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// 设置认证头
|
||||||
|
apiClient.setAuthToken(token)
|
||||||
|
|
||||||
|
// 检查是否过期
|
||||||
|
const expiresAt = localStorage.getItem('admin_token_expires')
|
||||||
|
if (expiresAt) {
|
||||||
|
const expireDate = new Date(expiresAt)
|
||||||
|
if (expireDate < new Date()) {
|
||||||
|
toast.warning('登录已过期,请重新登录')
|
||||||
|
handleLogout()
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleLogout = () => {
|
||||||
|
localStorage.removeItem('admin_token')
|
||||||
|
localStorage.removeItem('admin_token_expires')
|
||||||
|
apiClient.clearAuthToken()
|
||||||
|
router.push('/admin/login')
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleChangePassword = async () => {
|
||||||
|
passwordError.value = ''
|
||||||
|
|
||||||
|
// 验证新密码
|
||||||
|
if (passwordForm.value.newPassword !== passwordForm.value.confirmPassword) {
|
||||||
|
passwordError.value = '两次输入的新密码不一致'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (passwordForm.value.newPassword.length < 6) {
|
||||||
|
passwordError.value = '新密码长度至少为 6 位'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
passwordLoading.value = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
await apiService.changePassword({
|
||||||
|
old_password: passwordForm.value.oldPassword,
|
||||||
|
new_password: passwordForm.value.newPassword
|
||||||
|
})
|
||||||
|
|
||||||
|
toast.success('密码修改成功,请重新登录')
|
||||||
|
showPasswordDialog.value = false
|
||||||
|
passwordForm.value = {
|
||||||
|
oldPassword: '',
|
||||||
|
newPassword: '',
|
||||||
|
confirmPassword: ''
|
||||||
|
}
|
||||||
|
handleLogout()
|
||||||
|
} catch (err: any) {
|
||||||
|
passwordError.value = err.message || '密码修改失败'
|
||||||
|
console.error('修改密码失败:', err)
|
||||||
|
} finally {
|
||||||
|
passwordLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
checkAuth()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
689
webapp/src/views/AdminConfig.vue
Normal file
689
webapp/src/views/AdminConfig.vue
Normal file
@@ -0,0 +1,689 @@
|
|||||||
|
<template>
|
||||||
|
<div class="space-y-6">
|
||||||
|
<div class="flex justify-between items-center">
|
||||||
|
<h3 class="text-lg font-semibold">系统配置</h3>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
@click="editMode = editMode === 'json' ? 'form' : 'json'"
|
||||||
|
>
|
||||||
|
切换到{{ editMode === 'json' ? '表单' : 'JSON' }}编辑
|
||||||
|
</Button>
|
||||||
|
<Button @click="handleSaveConfig" :disabled="saveLoading">
|
||||||
|
{{ saveLoading ? '保存中...' : '保存配置' }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="loading" class="text-center py-8">
|
||||||
|
<p class="text-gray-500">加载配置中...</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="loadError" class="text-red-600 bg-red-50 p-4 rounded-md">
|
||||||
|
{{ loadError }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else>
|
||||||
|
<!-- JSON 编辑模式 -->
|
||||||
|
<Card v-if="editMode === 'json'">
|
||||||
|
<CardHeader>
|
||||||
|
<div class="flex justify-between items-start">
|
||||||
|
<div>
|
||||||
|
<CardTitle>JSON 配置编辑器</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
直接编辑配置 JSON,请确保格式正确
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
@click="formatJson"
|
||||||
|
:disabled="!configJson.trim()"
|
||||||
|
>
|
||||||
|
格式化 JSON
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Textarea
|
||||||
|
v-model="configJson"
|
||||||
|
class="font-mono text-sm min-h-[500px]"
|
||||||
|
:class="{ 'border-red-500': jsonError }"
|
||||||
|
placeholder="配置 JSON"
|
||||||
|
/>
|
||||||
|
<div v-if="jsonError" class="mt-2 text-sm text-red-600 bg-red-50 p-2 rounded">
|
||||||
|
❌ {{ jsonError }}
|
||||||
|
</div>
|
||||||
|
<div v-else-if="isValidJson" class="mt-2 text-sm text-green-600">
|
||||||
|
✓ JSON 格式正确
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<!-- 表单编辑模式 -->
|
||||||
|
<div v-else class="space-y-4">
|
||||||
|
<!-- 服务器配置 -->
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>服务器配置</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent class="space-y-4">
|
||||||
|
<div class="grid grid-cols-2 gap-4">
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label>端口</Label>
|
||||||
|
<Input v-model.number="config.Server.Port" type="number" />
|
||||||
|
</div>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label>基础 URL</Label>
|
||||||
|
<Input v-model="config.Server.BaseURL" placeholder="http://localhost:8080" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<!-- API 配置 -->
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>API 配置</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent class="space-y-4">
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label>API 模式</Label>
|
||||||
|
<Select v-model="config.API.Mode">
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="选择 API 模式" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="local">本地 (local)</SelectItem>
|
||||||
|
<SelectItem value="redirect">重定向 (redirect)</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<p class="text-xs text-gray-500">
|
||||||
|
local: 直接返回图片流; redirect: 重定向到存储位置
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="space-y-0.5">
|
||||||
|
<Label for="api-fallback">启用地区不存在时兜底</Label>
|
||||||
|
<p class="text-xs text-gray-500">
|
||||||
|
如果请求的地区无数据,自动回退到默认地区
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
id="api-fallback"
|
||||||
|
v-model="config.API.EnableMktFallback"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="space-y-0.5">
|
||||||
|
<Label for="api-on-demand">启用按需实时抓取</Label>
|
||||||
|
<p class="text-xs text-gray-500">
|
||||||
|
如果请求的地区无数据,尝试实时从 Bing 抓取
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
id="api-on-demand"
|
||||||
|
v-model="config.API.EnableOnDemandFetch"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<!-- 定时任务配置 -->
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>定时任务配置</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent class="space-y-4">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<Label for="cron-enabled">启用定时任务</Label>
|
||||||
|
<Switch
|
||||||
|
id="cron-enabled"
|
||||||
|
v-model="config.Cron.Enabled"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label>定时表达式 (Cron)</Label>
|
||||||
|
<Input v-model="config.Cron.DailySpec" placeholder="0 9 * * *" />
|
||||||
|
<p class="text-xs text-gray-500">
|
||||||
|
例如: "0 9 * * *" 表示每天 9:00 执行
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<!-- 数据库配置 -->
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>数据库配置</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent class="space-y-4">
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label>数据库类型</Label>
|
||||||
|
<Select v-model="config.DB.Type">
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="选择数据库类型" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="sqlite">SQLite</SelectItem>
|
||||||
|
<SelectItem value="mysql">MySQL</SelectItem>
|
||||||
|
<SelectItem value="postgres">PostgreSQL</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label>DSN (数据源名称)</Label>
|
||||||
|
<Input
|
||||||
|
v-model="config.DB.DSN"
|
||||||
|
placeholder="数据库连接字符串"
|
||||||
|
:class="{ 'border-red-500': dsnError }"
|
||||||
|
@blur="validateDSN"
|
||||||
|
/>
|
||||||
|
<p v-if="dsnExamples" class="text-xs text-gray-500">
|
||||||
|
💡 示例: {{ dsnExamples }}
|
||||||
|
</p>
|
||||||
|
<p v-if="dsnError" class="text-xs text-red-600">
|
||||||
|
❌ {{ dsnError }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<!-- 存储配置 -->
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>存储配置</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent class="space-y-4">
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label>存储类型</Label>
|
||||||
|
<Select v-model="config.Storage.Type">
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="选择存储类型" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="local">本地存储</SelectItem>
|
||||||
|
<SelectItem value="s3">S3 存储</SelectItem>
|
||||||
|
<SelectItem value="webdav">WebDAV</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 本地存储配置 -->
|
||||||
|
<div v-if="config.Storage.Type === 'local'" class="space-y-2">
|
||||||
|
<Label>本地存储路径</Label>
|
||||||
|
<Input v-model="config.Storage.Local.Root" placeholder="./data/images" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- S3 存储配置 -->
|
||||||
|
<div v-if="config.Storage.Type === 's3'" class="space-y-4">
|
||||||
|
<div class="grid grid-cols-2 gap-4">
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label>Endpoint</Label>
|
||||||
|
<Input v-model="config.Storage.S3.Endpoint" />
|
||||||
|
</div>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label>Region</Label>
|
||||||
|
<Input v-model="config.Storage.S3.Region" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label>Bucket</Label>
|
||||||
|
<Input v-model="config.Storage.S3.Bucket" />
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-2 gap-4">
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label>Access Key</Label>
|
||||||
|
<Input v-model="config.Storage.S3.AccessKey" type="password" />
|
||||||
|
</div>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label>Secret Key</Label>
|
||||||
|
<Input v-model="config.Storage.S3.SecretKey" type="password" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label>公开 URL 前缀</Label>
|
||||||
|
<Input v-model="config.Storage.S3.PublicURLPrefix" />
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<Label for="s3-force-path">强制路径样式</Label>
|
||||||
|
<Switch
|
||||||
|
id="s3-force-path"
|
||||||
|
v-model="config.Storage.S3.ForcePathStyle"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- WebDAV 配置 -->
|
||||||
|
<div v-if="config.Storage.Type === 'webdav'" class="space-y-4">
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label>WebDAV URL</Label>
|
||||||
|
<Input v-model="config.Storage.WebDAV.URL" />
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-2 gap-4">
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label>用户名</Label>
|
||||||
|
<Input v-model="config.Storage.WebDAV.Username" />
|
||||||
|
</div>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label>密码</Label>
|
||||||
|
<Input v-model="config.Storage.WebDAV.Password" type="password" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label>公开 URL 前缀</Label>
|
||||||
|
<Input v-model="config.Storage.WebDAV.PublicURLPrefix" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<!-- 保留策略配置 -->
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>图片保留策略</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent class="space-y-4">
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label>保留天数</Label>
|
||||||
|
<Input v-model.number="config.Retention.Days" type="number" min="1" />
|
||||||
|
<p class="text-xs text-gray-500">
|
||||||
|
超过指定天数的图片将被自动清理
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<!-- Token 配置 -->
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Token 配置</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent class="space-y-4">
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label>默认过期时间 (TTL)</Label>
|
||||||
|
<Input v-model="config.Token.DefaultTTL" placeholder="168h" />
|
||||||
|
<p class="text-xs text-gray-500">
|
||||||
|
例如: 168h (7天), 720h (30天)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<!-- 日志配置 -->
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>日志配置</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent class="space-y-4">
|
||||||
|
<div class="grid grid-cols-2 gap-4">
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label>日志级别</Label>
|
||||||
|
<Select v-model="config.Log.Level">
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="选择日志级别" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="debug">Debug</SelectItem>
|
||||||
|
<SelectItem value="info">Info</SelectItem>
|
||||||
|
<SelectItem value="warn">Warn</SelectItem>
|
||||||
|
<SelectItem value="error">Error</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label>日志文件</Label>
|
||||||
|
<Input v-model="config.Log.Filename" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-2 gap-4">
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label>数据库日志文件</Label>
|
||||||
|
<Input v-model="config.Log.DBFilename" />
|
||||||
|
</div>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label>数据库日志级别</Label>
|
||||||
|
<Select v-model="config.Log.DBLogLevel">
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="选择数据库日志级别" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="debug">Debug</SelectItem>
|
||||||
|
<SelectItem value="info">Info</SelectItem>
|
||||||
|
<SelectItem value="warn">Warn</SelectItem>
|
||||||
|
<SelectItem value="error">Error</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-3 gap-4">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<Label for="log-console">输出到控制台</Label>
|
||||||
|
<Switch
|
||||||
|
id="log-console"
|
||||||
|
v-model="config.Log.LogConsole"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<Label for="log-show-db">显示数据库日志</Label>
|
||||||
|
<Switch
|
||||||
|
id="log-show-db"
|
||||||
|
v-model="config.Log.ShowDBLog"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<Label for="log-compress">压缩旧日志</Label>
|
||||||
|
<Switch
|
||||||
|
id="log-compress"
|
||||||
|
v-model="config.Log.Compress"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-3 gap-4">
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label>单文件大小 (MB)</Label>
|
||||||
|
<Input v-model.number="config.Log.MaxSize" type="number" />
|
||||||
|
</div>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label>最大文件数</Label>
|
||||||
|
<Input v-model.number="config.Log.MaxBackups" type="number" />
|
||||||
|
</div>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label>保留天数</Label>
|
||||||
|
<Input v-model.number="config.Log.MaxAge" type="number" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<!-- 抓取配置 -->
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>抓取配置</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent class="space-y-4">
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label>抓取地区</Label>
|
||||||
|
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mt-2">
|
||||||
|
<div v-for="region in allRegions" :key="region.value" class="flex items-center space-x-2">
|
||||||
|
<Checkbox
|
||||||
|
:id="'region-'+region.value"
|
||||||
|
:checked="config.Fetcher.Regions.includes(region.value)"
|
||||||
|
@update:checked="(checked: any) => toggleRegion(region.value, !!checked)"
|
||||||
|
/>
|
||||||
|
<Label :for="'region-'+region.value" class="text-sm font-normal cursor-pointer">{{ region.label }}</Label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-gray-500 mt-2">
|
||||||
|
勾选需要定期抓取壁纸的地区。如果不勾选任何地区,默认将只抓取 zh-CN。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<!-- 功能特性配置 -->
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>功能特性</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent class="space-y-4">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<Label for="feature-write-daily">写入每日文件</Label>
|
||||||
|
<Switch
|
||||||
|
id="feature-write-daily"
|
||||||
|
v-model="config.Feature.WriteDailyFiles"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<!-- Web 配置 -->
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Web 前端配置</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent class="space-y-4">
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label>前端静态文件路径</Label>
|
||||||
|
<Input v-model="config.Web.Path" placeholder="./webapp/dist" />
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted, watch, computed } from 'vue'
|
||||||
|
import { toast } from 'vue-sonner'
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { Label } from '@/components/ui/label'
|
||||||
|
import { Textarea } from '@/components/ui/textarea'
|
||||||
|
import { Switch } from '@/components/ui/switch'
|
||||||
|
import { Checkbox } from '@/components/ui/checkbox'
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||||
|
import { apiService } from '@/lib/api-service'
|
||||||
|
import type { Config } from '@/lib/api-types'
|
||||||
|
|
||||||
|
const editMode = ref<'json' | 'form'>('form')
|
||||||
|
const loading = ref(false)
|
||||||
|
const loadError = ref('')
|
||||||
|
const saveLoading = ref(false)
|
||||||
|
const dsnError = ref('')
|
||||||
|
|
||||||
|
// 所有可选地区列表
|
||||||
|
const allRegions = ref<any[]>([])
|
||||||
|
|
||||||
|
const config = ref<Config>({
|
||||||
|
Admin: { PasswordBcrypt: '' },
|
||||||
|
API: { Mode: 'local', EnableMktFallback: true, EnableOnDemandFetch: false },
|
||||||
|
Cron: { Enabled: true, DailySpec: '0 9 * * *' },
|
||||||
|
DB: { Type: 'sqlite', DSN: '' },
|
||||||
|
Feature: { WriteDailyFiles: true },
|
||||||
|
Log: {
|
||||||
|
Level: 'info',
|
||||||
|
Filename: '',
|
||||||
|
DBFilename: '',
|
||||||
|
DBLogLevel: 'warn',
|
||||||
|
LogConsole: true,
|
||||||
|
ShowDBLog: false,
|
||||||
|
MaxSize: 10,
|
||||||
|
MaxAge: 30,
|
||||||
|
MaxBackups: 10,
|
||||||
|
Compress: true
|
||||||
|
},
|
||||||
|
Retention: { Days: 30 },
|
||||||
|
Server: { Port: 8080, BaseURL: '' },
|
||||||
|
Storage: {
|
||||||
|
Type: 'local',
|
||||||
|
Local: { Root: './data/images' },
|
||||||
|
S3: {
|
||||||
|
Endpoint: '',
|
||||||
|
AccessKey: '',
|
||||||
|
SecretKey: '',
|
||||||
|
Bucket: '',
|
||||||
|
Region: '',
|
||||||
|
ForcePathStyle: false,
|
||||||
|
PublicURLPrefix: ''
|
||||||
|
},
|
||||||
|
WebDAV: {
|
||||||
|
URL: '',
|
||||||
|
Username: '',
|
||||||
|
Password: '',
|
||||||
|
PublicURLPrefix: ''
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Token: { DefaultTTL: '168h' },
|
||||||
|
Web: { Path: './webapp/dist' },
|
||||||
|
Fetcher: { Regions: [] }
|
||||||
|
})
|
||||||
|
|
||||||
|
const configJson = ref('')
|
||||||
|
const jsonError = ref('')
|
||||||
|
|
||||||
|
// 获取所有地区
|
||||||
|
const fetchRegions = async () => {
|
||||||
|
try {
|
||||||
|
const data = await apiService.getRegions()
|
||||||
|
allRegions.value = data
|
||||||
|
} catch (err) {
|
||||||
|
console.error('获取地区列表失败:', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleRegion = (regionValue: string, checked: boolean) => {
|
||||||
|
if (!config.value.Fetcher.Regions) {
|
||||||
|
config.value.Fetcher.Regions = []
|
||||||
|
}
|
||||||
|
|
||||||
|
if (checked) {
|
||||||
|
if (!config.value.Fetcher.Regions.includes(regionValue)) {
|
||||||
|
config.value.Fetcher.Regions.push(regionValue)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
config.value.Fetcher.Regions = config.value.Fetcher.Regions.filter(r => r !== regionValue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// DSN 示例
|
||||||
|
const dsnExamples = computed(() => {
|
||||||
|
switch (config.value.DB.Type) {
|
||||||
|
case 'sqlite':
|
||||||
|
return 'data/bing_paper.db 或 file:data/bing_paper.db?cache=shared'
|
||||||
|
case 'mysql':
|
||||||
|
return 'user:password@tcp(localhost:3306)/dbname?charset=utf8mb4&parseTime=True'
|
||||||
|
case 'postgres':
|
||||||
|
return 'host=localhost user=postgres password=secret dbname=mydb port=5432 sslmode=disable'
|
||||||
|
default:
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 验证 DSN
|
||||||
|
const validateDSN = () => {
|
||||||
|
dsnError.value = ''
|
||||||
|
const dsn = config.value.DB.DSN.trim()
|
||||||
|
|
||||||
|
if (!dsn) {
|
||||||
|
dsnError.value = 'DSN 不能为空'
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (config.value.DB.Type) {
|
||||||
|
case 'mysql':
|
||||||
|
if (!dsn.includes('@tcp(') && !dsn.includes('://')) {
|
||||||
|
dsnError.value = 'MySQL DSN 格式不正确,应包含 @tcp( 或使用 URI 格式'
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
break
|
||||||
|
case 'postgres':
|
||||||
|
if (!dsn.includes('host=') && !dsn.includes('://')) {
|
||||||
|
dsnError.value = 'PostgreSQL DSN 格式不正确,应包含 host= 或使用 URI 格式'
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// 格式化 JSON
|
||||||
|
const formatJson = () => {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(configJson.value)
|
||||||
|
configJson.value = JSON.stringify(parsed, null, 2)
|
||||||
|
jsonError.value = ''
|
||||||
|
toast.success('JSON 格式化成功')
|
||||||
|
} catch (err: any) {
|
||||||
|
jsonError.value = 'JSON 格式错误: ' + err.message
|
||||||
|
toast.error('JSON 格式错误')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证 JSON 是否有效
|
||||||
|
const isValidJson = computed(() => {
|
||||||
|
if (!configJson.value.trim()) return false
|
||||||
|
try {
|
||||||
|
JSON.parse(configJson.value)
|
||||||
|
return true
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const fetchConfig = async () => {
|
||||||
|
loading.value = true
|
||||||
|
loadError.value = ''
|
||||||
|
try {
|
||||||
|
const data = await apiService.getConfig()
|
||||||
|
config.value = data
|
||||||
|
configJson.value = JSON.stringify(data, null, 2)
|
||||||
|
} catch (err: any) {
|
||||||
|
loadError.value = err.message || '获取配置失败'
|
||||||
|
console.error('获取配置失败:', err)
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 监听表单变化更新 JSON
|
||||||
|
watch(config, (newConfig) => {
|
||||||
|
if (editMode.value === 'form') {
|
||||||
|
configJson.value = JSON.stringify(newConfig, null, 2)
|
||||||
|
}
|
||||||
|
}, { deep: true })
|
||||||
|
|
||||||
|
// 监听 JSON 变化更新表单
|
||||||
|
watch(configJson, (newJson) => {
|
||||||
|
if (editMode.value === 'json') {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(newJson)
|
||||||
|
config.value = parsed
|
||||||
|
jsonError.value = ''
|
||||||
|
} catch (err: any) {
|
||||||
|
jsonError.value = err.message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleSaveConfig = async () => {
|
||||||
|
saveLoading.value = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 如果是 JSON 模式,先验证格式
|
||||||
|
if (editMode.value === 'json') {
|
||||||
|
if (!isValidJson.value) {
|
||||||
|
throw new Error('JSON 格式不正确,请检查语法')
|
||||||
|
}
|
||||||
|
config.value = JSON.parse(configJson.value)
|
||||||
|
} else {
|
||||||
|
// 表单模式下验证 DSN
|
||||||
|
if (!validateDSN()) {
|
||||||
|
throw new Error('DSN 格式不正确: ' + dsnError.value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await apiService.updateConfig(config.value)
|
||||||
|
toast.success('配置保存成功')
|
||||||
|
|
||||||
|
// 重新加载配置
|
||||||
|
await fetchConfig()
|
||||||
|
} catch (err: any) {
|
||||||
|
toast.error(err.message || '保存配置失败')
|
||||||
|
console.error('保存配置失败:', err)
|
||||||
|
} finally {
|
||||||
|
saveLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
fetchRegions()
|
||||||
|
fetchConfig()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
79
webapp/src/views/AdminLogin.vue
Normal file
79
webapp/src/views/AdminLogin.vue
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
<template>
|
||||||
|
<div class="min-h-screen flex items-center justify-center bg-gradient-to-br from-blue-50 to-indigo-100 px-4">
|
||||||
|
<Card class="w-full max-w-md">
|
||||||
|
<CardHeader class="space-y-1">
|
||||||
|
<CardTitle class="text-2xl font-bold text-center">管理员登录</CardTitle>
|
||||||
|
<CardDescription class="text-center">
|
||||||
|
输入管理员密码以访问后台管理系统
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<form @submit.prevent="handleLogin" class="space-y-4">
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label for="password">密码</Label>
|
||||||
|
<Input
|
||||||
|
id="password"
|
||||||
|
v-model="password"
|
||||||
|
type="password"
|
||||||
|
placeholder="请输入管理员密码"
|
||||||
|
required
|
||||||
|
:disabled="loading"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="error" class="text-sm text-red-600 bg-red-50 p-3 rounded-md">
|
||||||
|
{{ error }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button type="submit" class="w-full" :disabled="loading">
|
||||||
|
<span v-if="loading">登录中...</span>
|
||||||
|
<span v-else>登录</span>
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { toast } from 'vue-sonner'
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { Label } from '@/components/ui/label'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { apiService } from '@/lib/api-service'
|
||||||
|
import { apiClient } from '@/lib/http-client'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const password = ref('')
|
||||||
|
const loading = ref(false)
|
||||||
|
const error = ref('')
|
||||||
|
|
||||||
|
const handleLogin = async () => {
|
||||||
|
error.value = ''
|
||||||
|
loading.value = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await apiService.login({ password: password.value })
|
||||||
|
|
||||||
|
// 保存 token 到 localStorage
|
||||||
|
localStorage.setItem('admin_token', response.token)
|
||||||
|
localStorage.setItem('admin_token_expires', response.expires_at || '')
|
||||||
|
|
||||||
|
// 设置 HTTP 客户端的认证头
|
||||||
|
apiClient.setAuthToken(response.token)
|
||||||
|
|
||||||
|
toast.success('登录成功')
|
||||||
|
|
||||||
|
// 跳转到管理后台
|
||||||
|
router.push('/admin')
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error('登录失败:', err)
|
||||||
|
error.value = err.message || '登录失败,请检查密码'
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
189
webapp/src/views/AdminTasks.vue
Normal file
189
webapp/src/views/AdminTasks.vue
Normal file
@@ -0,0 +1,189 @@
|
|||||||
|
<template>
|
||||||
|
<div class="space-y-6">
|
||||||
|
<div>
|
||||||
|
<h3 class="text-lg font-semibold mb-4">定时任务管理</h3>
|
||||||
|
<p class="text-sm text-gray-600 mb-4">
|
||||||
|
手动触发图片抓取和清理任务
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 手动抓取 -->
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>手动抓取图片</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
立即从 Bing 抓取最新的图片
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent class="space-y-4">
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label for="fetch-days">抓取天数</Label>
|
||||||
|
<Input
|
||||||
|
id="fetch-days"
|
||||||
|
v-model.number="fetchDays"
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
max="30"
|
||||||
|
placeholder="输入要抓取的天数,默认 1 天"
|
||||||
|
/>
|
||||||
|
<p class="text-xs text-gray-500">
|
||||||
|
指定要抓取的天数(包括今天),最多 30 天
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
@click="handleManualFetch"
|
||||||
|
:disabled="fetchLoading"
|
||||||
|
class="w-full sm:w-auto"
|
||||||
|
>
|
||||||
|
{{ fetchLoading ? '抓取中...' : '开始抓取' }}
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<!-- 手动清理 -->
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>手动清理旧图片</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
清理超过保留期限的旧图片
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent class="space-y-4">
|
||||||
|
<p class="text-sm text-gray-600">
|
||||||
|
根据系统配置的保留天数,清理过期的图片文件和数据库记录
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
@click="handleManualCleanup"
|
||||||
|
:disabled="cleanupLoading"
|
||||||
|
variant="destructive"
|
||||||
|
class="w-full sm:w-auto"
|
||||||
|
>
|
||||||
|
{{ cleanupLoading ? '清理中...' : '开始清理' }}
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<!-- 任务历史记录 -->
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>任务执行历史</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
最近的任务执行记录
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div v-if="taskHistory.length === 0" class="text-center py-8 text-gray-500">
|
||||||
|
暂无执行记录
|
||||||
|
</div>
|
||||||
|
<div v-else class="space-y-2">
|
||||||
|
<div
|
||||||
|
v-for="(task, index) in taskHistory"
|
||||||
|
:key="index"
|
||||||
|
class="flex items-center justify-between p-3 border rounded-md"
|
||||||
|
>
|
||||||
|
<div class="flex-1">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<Badge :variant="task.success ? 'default' : 'destructive'">
|
||||||
|
{{ task.type }}
|
||||||
|
</Badge>
|
||||||
|
<span class="text-sm">{{ task.message }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="text-xs text-gray-500 mt-1">
|
||||||
|
{{ task.timestamp }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { toast } from 'vue-sonner'
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { Label } from '@/components/ui/label'
|
||||||
|
import { apiService } from '@/lib/api-service'
|
||||||
|
|
||||||
|
interface TaskRecord {
|
||||||
|
type: string
|
||||||
|
success: boolean
|
||||||
|
message: string
|
||||||
|
timestamp: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchDays = ref<number>(1)
|
||||||
|
const fetchLoading = ref(false)
|
||||||
|
|
||||||
|
const cleanupLoading = ref(false)
|
||||||
|
|
||||||
|
const taskHistory = ref<TaskRecord[]>([])
|
||||||
|
|
||||||
|
const handleManualFetch = async () => {
|
||||||
|
fetchLoading.value = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await apiService.manualFetch({ n: fetchDays.value })
|
||||||
|
toast.success(response.message || '抓取任务已启动')
|
||||||
|
|
||||||
|
// 添加到历史记录
|
||||||
|
taskHistory.value.unshift({
|
||||||
|
type: '图片抓取',
|
||||||
|
success: true,
|
||||||
|
message: `抓取 ${fetchDays.value} 天的图片`,
|
||||||
|
timestamp: new Date().toLocaleString('zh-CN')
|
||||||
|
})
|
||||||
|
|
||||||
|
// 只保留最近 10 条记录
|
||||||
|
if (taskHistory.value.length > 10) {
|
||||||
|
taskHistory.value = taskHistory.value.slice(0, 10)
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
toast.error(err.message || '抓取失败')
|
||||||
|
|
||||||
|
taskHistory.value.unshift({
|
||||||
|
type: '图片抓取',
|
||||||
|
success: false,
|
||||||
|
message: err.message || '抓取失败',
|
||||||
|
timestamp: new Date().toLocaleString('zh-CN')
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
fetchLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleManualCleanup = async () => {
|
||||||
|
cleanupLoading.value = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await apiService.manualCleanup()
|
||||||
|
toast.success(response.message || '清理任务已完成')
|
||||||
|
|
||||||
|
taskHistory.value.unshift({
|
||||||
|
type: '清理任务',
|
||||||
|
success: true,
|
||||||
|
message: '清理旧图片',
|
||||||
|
timestamp: new Date().toLocaleString('zh-CN')
|
||||||
|
})
|
||||||
|
|
||||||
|
if (taskHistory.value.length > 10) {
|
||||||
|
taskHistory.value = taskHistory.value.slice(0, 10)
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
toast.error(err.message || '清理失败')
|
||||||
|
|
||||||
|
taskHistory.value.unshift({
|
||||||
|
type: '清理任务',
|
||||||
|
success: false,
|
||||||
|
message: err.message || '清理失败',
|
||||||
|
timestamp: new Date().toLocaleString('zh-CN')
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
cleanupLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
236
webapp/src/views/AdminTokens.vue
Normal file
236
webapp/src/views/AdminTokens.vue
Normal file
@@ -0,0 +1,236 @@
|
|||||||
|
<template>
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div class="flex justify-between items-center">
|
||||||
|
<h3 class="text-lg font-semibold">Token 管理</h3>
|
||||||
|
<Button @click="showCreateDialog = true">
|
||||||
|
<span>创建 Token</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="loading" class="text-center py-8">
|
||||||
|
<p class="text-gray-500">加载中...</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="error" class="text-red-600 bg-red-50 p-4 rounded-md">
|
||||||
|
{{ error }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="tokens.length === 0" class="text-center py-8 text-gray-500">
|
||||||
|
暂无 Token,点击上方按钮创建
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="border rounded-lg overflow-hidden">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>ID</TableHead>
|
||||||
|
<TableHead>名称</TableHead>
|
||||||
|
<TableHead>Token</TableHead>
|
||||||
|
<TableHead>状态</TableHead>
|
||||||
|
<TableHead>过期时间</TableHead>
|
||||||
|
<TableHead>创建时间</TableHead>
|
||||||
|
<TableHead class="text-right">操作</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
<TableRow v-for="token in tokens" :key="token.id">
|
||||||
|
<TableCell>{{ token.id }}</TableCell>
|
||||||
|
<TableCell>{{ token.name }}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<code class="text-xs bg-gray-100 px-2 py-1 rounded">
|
||||||
|
{{ token.token.substring(0, 20) }}...
|
||||||
|
</code>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge :variant="token.disabled ? 'destructive' : 'default'">
|
||||||
|
{{ token.disabled ? '已禁用' : '启用' }}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{{ formatDate(token.expires_at) }}</TableCell>
|
||||||
|
<TableCell>{{ formatDate(token.created_at) }}</TableCell>
|
||||||
|
<TableCell class="text-right space-x-2">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
@click="toggleTokenStatus(token)"
|
||||||
|
>
|
||||||
|
{{ token.disabled ? '启用' : '禁用' }}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="destructive"
|
||||||
|
@click="handleDeleteToken(token)"
|
||||||
|
>
|
||||||
|
删除
|
||||||
|
</Button>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 创建 Token 对话框 -->
|
||||||
|
<Dialog v-model:open="showCreateDialog">
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>创建 Token</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
创建新的 API Token 用于访问接口
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<form @submit.prevent="handleCreateToken" class="space-y-4">
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label for="name">名称</Label>
|
||||||
|
<Input
|
||||||
|
id="name"
|
||||||
|
v-model="createForm.name"
|
||||||
|
placeholder="输入 Token 名称"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label for="expires_in">过期时间</Label>
|
||||||
|
<Input
|
||||||
|
id="expires_in"
|
||||||
|
v-model="createForm.expires_in"
|
||||||
|
placeholder="例如: 168h (7天), 720h (30天)"
|
||||||
|
/>
|
||||||
|
<p class="text-xs text-gray-500">留空表示永不过期</p>
|
||||||
|
</div>
|
||||||
|
<div v-if="createError" class="text-sm text-red-600">
|
||||||
|
{{ createError }}
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button type="button" variant="outline" @click="showCreateDialog = false">
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" :disabled="createLoading">
|
||||||
|
{{ createLoading ? '创建中...' : '创建' }}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
<!-- 删除确认对话框 -->
|
||||||
|
<AlertDialog v-model:open="showDeleteDialog">
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>确认删除</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
确定要删除 Token "{{ deleteTarget?.name }}" 吗?此操作无法撤销。
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>取消</AlertDialogCancel>
|
||||||
|
<AlertDialogAction @click="confirmDelete" class="bg-red-600 hover:bg-red-700">
|
||||||
|
删除
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted } from 'vue'
|
||||||
|
import { toast } from 'vue-sonner'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
|
||||||
|
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||||
|
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from '@/components/ui/alert-dialog'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { Label } from '@/components/ui/label'
|
||||||
|
import { apiService } from '@/lib/api-service'
|
||||||
|
import type { Token } from '@/lib/api-types'
|
||||||
|
|
||||||
|
const tokens = ref<Token[]>([])
|
||||||
|
const loading = ref(false)
|
||||||
|
const error = ref('')
|
||||||
|
|
||||||
|
const showCreateDialog = ref(false)
|
||||||
|
const createForm = ref({
|
||||||
|
name: '',
|
||||||
|
expires_in: ''
|
||||||
|
})
|
||||||
|
const createLoading = ref(false)
|
||||||
|
const createError = ref('')
|
||||||
|
|
||||||
|
const showDeleteDialog = ref(false)
|
||||||
|
const deleteTarget = ref<Token | null>(null)
|
||||||
|
|
||||||
|
const fetchTokens = async () => {
|
||||||
|
loading.value = true
|
||||||
|
error.value = ''
|
||||||
|
try {
|
||||||
|
tokens.value = await apiService.getTokens()
|
||||||
|
} catch (err: any) {
|
||||||
|
error.value = err.message || '获取 Token 列表失败'
|
||||||
|
console.error('获取 Token 失败:', err)
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCreateToken = async () => {
|
||||||
|
createLoading.value = true
|
||||||
|
createError.value = ''
|
||||||
|
try {
|
||||||
|
await apiService.createToken(createForm.value)
|
||||||
|
showCreateDialog.value = false
|
||||||
|
createForm.value = { name: '', expires_in: '' }
|
||||||
|
toast.success('Token 创建成功')
|
||||||
|
await fetchTokens()
|
||||||
|
} catch (err: any) {
|
||||||
|
createError.value = err.message || '创建 Token 失败'
|
||||||
|
console.error('创建 Token 失败:', err)
|
||||||
|
} finally {
|
||||||
|
createLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleTokenStatus = async (token: Token) => {
|
||||||
|
try {
|
||||||
|
await apiService.updateToken(token.id, { disabled: !token.disabled })
|
||||||
|
toast.success(`Token 已${token.disabled ? '启用' : '禁用'}`)
|
||||||
|
await fetchTokens()
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error('更新 Token 状态失败:', err)
|
||||||
|
toast.error(err.message || '更新失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDeleteToken = (token: Token) => {
|
||||||
|
deleteTarget.value = token
|
||||||
|
showDeleteDialog.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const confirmDelete = async () => {
|
||||||
|
if (!deleteTarget.value) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
await apiService.deleteToken(deleteTarget.value.id)
|
||||||
|
showDeleteDialog.value = false
|
||||||
|
deleteTarget.value = null
|
||||||
|
toast.success('Token 删除成功')
|
||||||
|
await fetchTokens()
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error('删除 Token 失败:', err)
|
||||||
|
toast.error(err.message || '删除失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatDate = (dateStr?: string) => {
|
||||||
|
if (!dateStr) return '-'
|
||||||
|
try {
|
||||||
|
return new Date(dateStr).toLocaleString('zh-CN')
|
||||||
|
} catch {
|
||||||
|
return dateStr
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
fetchTokens()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
@@ -103,6 +103,10 @@
|
|||||||
<code class="text-yellow-400 min-w-24">format</code>
|
<code class="text-yellow-400 min-w-24">format</code>
|
||||||
<span class="text-white/50">格式: jpg (默认: jpg)</span>
|
<span class="text-white/50">格式: jpg (默认: jpg)</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="flex gap-4 text-sm">
|
||||||
|
<code class="text-yellow-400 min-w-24">mkt</code>
|
||||||
|
<span class="text-white/50">地区编码 (如 zh-CN, en-US, ja-JP),默认由服务器自动探测</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -182,6 +186,10 @@
|
|||||||
<code class="text-yellow-400 min-w-24">format</code>
|
<code class="text-yellow-400 min-w-24">format</code>
|
||||||
<span class="text-white/50">格式 (默认: jpg)</span>
|
<span class="text-white/50">格式 (默认: jpg)</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="flex gap-4 text-sm">
|
||||||
|
<code class="text-yellow-400 min-w-24">mkt</code>
|
||||||
|
<span class="text-white/50">地区编码 (如 zh-CN, en-US, ja-JP)</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -254,6 +262,10 @@
|
|||||||
<code class="text-yellow-400 min-w-24">format</code>
|
<code class="text-yellow-400 min-w-24">format</code>
|
||||||
<span class="text-white/50">格式 (默认: jpg)</span>
|
<span class="text-white/50">格式 (默认: jpg)</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="flex gap-4 text-sm">
|
||||||
|
<code class="text-yellow-400 min-w-24">mkt</code>
|
||||||
|
<span class="text-white/50">地区编码 (如 zh-CN, en-US, ja-JP)</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -334,6 +346,10 @@
|
|||||||
<code class="text-yellow-400 min-w-32">date</code>
|
<code class="text-yellow-400 min-w-32">date</code>
|
||||||
<span class="text-white/60">图片日期(格式:YYYY-MM-DD)</span>
|
<span class="text-white/60">图片日期(格式:YYYY-MM-DD)</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="flex gap-4">
|
||||||
|
<code class="text-yellow-400 min-w-32">mkt</code>
|
||||||
|
<span class="text-white/60">地区编码(如 zh-CN, en-US)</span>
|
||||||
|
</div>
|
||||||
<div class="flex gap-4">
|
<div class="flex gap-4">
|
||||||
<code class="text-yellow-400 min-w-32">title</code>
|
<code class="text-yellow-400 min-w-32">title</code>
|
||||||
<span class="text-white/60">图片标题</span>
|
<span class="text-white/60">图片标题</span>
|
||||||
@@ -458,24 +474,26 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
import { API_BASE_URL } from '@/lib/api-config'
|
import { API_BASE_URL } from '@/lib/api-config'
|
||||||
|
import { getDefaultMkt } from '@/lib/mkt-utils'
|
||||||
|
|
||||||
const baseURL = ref(API_BASE_URL)
|
const baseURL = ref(API_BASE_URL)
|
||||||
const previewImage = ref<string | null>(null)
|
const previewImage = ref<string | null>(null)
|
||||||
|
const defaultMkt = getDefaultMkt()
|
||||||
|
|
||||||
// 获取今日图片示例
|
// 获取今日图片示例
|
||||||
const getTodayImageExample = () => {
|
const getTodayImageExample = () => {
|
||||||
return `${baseURL.value}/image/today?variant=UHD&format=jpg`
|
return `${baseURL.value}/image/today?variant=UHD&format=jpg&mkt=${defaultMkt}`
|
||||||
}
|
}
|
||||||
|
|
||||||
// 获取指定日期图片示例
|
// 获取指定日期图片示例
|
||||||
const getDateImageExample = () => {
|
const getDateImageExample = () => {
|
||||||
const today = new Date().toISOString().split('T')[0]
|
const today = new Date().toISOString().split('T')[0]
|
||||||
return `${baseURL.value}/image/date/${today}?variant=1920x1080&format=jpg`
|
return `${baseURL.value}/image/date/${today}?variant=1920x1080&format=jpg&mkt=${defaultMkt}`
|
||||||
}
|
}
|
||||||
|
|
||||||
// 获取随机图片示例
|
// 获取随机图片示例
|
||||||
const getRandomImageExample = () => {
|
const getRandomImageExample = () => {
|
||||||
return `${baseURL.value}/image/random?variant=UHD&format=jpg`
|
return `${baseURL.value}/image/random?variant=UHD&format=jpg&mkt=${defaultMkt}`
|
||||||
}
|
}
|
||||||
|
|
||||||
// 复制到剪贴板
|
// 复制到剪贴板
|
||||||
|
|||||||
@@ -6,42 +6,52 @@
|
|||||||
<div class="w-12 h-12 border-4 border-white/20 border-t-white rounded-full animate-spin"></div>
|
<div class="w-12 h-12 border-4 border-white/20 border-t-white rounded-full animate-spin"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else-if="todayImage" class="relative h-full w-full group">
|
<div v-else-if="latestImage" class="relative h-full w-full group">
|
||||||
<!-- 背景图片 -->
|
<!-- 背景图片 -->
|
||||||
<div class="absolute inset-0">
|
<div class="absolute inset-0">
|
||||||
<img
|
<img
|
||||||
:src="getTodayImageUrl()"
|
:src="getLatestImageUrl()"
|
||||||
:alt="todayImage.title || 'Today\'s Bing Image'"
|
:alt="latestImage.title || 'Latest Bing Image'"
|
||||||
class="w-full h-full object-cover"
|
class="w-full h-full object-cover"
|
||||||
/>
|
/>
|
||||||
<div class="absolute inset-0 bg-gradient-to-t from-black/80 via-black/20 to-transparent"></div>
|
<div class="absolute inset-0 bg-gradient-to-t from-black/80 via-black/20 to-transparent"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 更新提示(仅在非今日图片时显示) - 右上角简约徽章 -->
|
||||||
|
<div v-if="!isToday" class="absolute top-4 right-4 md:top-8 md:right-8 z-20">
|
||||||
|
<div class="flex items-center gap-1.5 px-3 py-1.5 bg-black/30 backdrop-blur-md rounded-full border border-white/10 text-white/70 text-xs">
|
||||||
|
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"></path>
|
||||||
|
</svg>
|
||||||
|
<span>下次更新 {{ nextUpdateTime }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- 内容叠加层 -->
|
<!-- 内容叠加层 -->
|
||||||
<div class="relative h-full flex flex-col justify-end p-8 md:p-16 z-10">
|
<div class="relative h-full flex flex-col justify-end p-8 md:p-16 z-10">
|
||||||
<div class="max-w-4xl space-y-4 transform transition-transform duration-500 group-hover:translate-y-[-10px]">
|
<div class="max-w-4xl space-y-4 transform transition-transform duration-500 group-hover:translate-y-[-10px]">
|
||||||
<div class="inline-block px-4 py-2 bg-white/10 backdrop-blur-md rounded-full text-white/90 text-sm font-medium">
|
<div class="inline-block px-4 py-2 bg-white/10 backdrop-blur-md rounded-full text-white/90 text-sm font-medium">
|
||||||
今日精选 · {{ formatDate(todayImage.date) }}
|
{{ isToday ? '今日精选' : '最新图片' }} · {{ formatDate(latestImage.date) }}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h1 class="text-4xl md:text-6xl font-bold text-white leading-tight drop-shadow-2xl">
|
<h1 class="text-4xl md:text-6xl font-bold text-white leading-tight drop-shadow-2xl">
|
||||||
{{ todayImage.title || '必应每日一图' }}
|
{{ latestImage.title || '必应每日一图' }}
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
<p v-if="todayImage.copyright" class="text-lg md:text-xl text-white/80 max-w-2xl">
|
<p v-if="latestImage.copyright" class="text-lg md:text-xl text-white/80 max-w-2xl">
|
||||||
{{ todayImage.copyright }}
|
{{ latestImage.copyright }}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div class="flex gap-4 pt-4">
|
<div class="flex gap-4 pt-4">
|
||||||
<button
|
<button
|
||||||
@click="viewImage(todayImage.date!)"
|
@click="viewImage(latestImage.date!)"
|
||||||
class="px-6 py-3 bg-white text-gray-900 rounded-lg font-semibold hover:bg-white/90 transition-all transform hover:scale-105 shadow-xl"
|
class="px-6 py-3 bg-white text-gray-900 rounded-lg font-semibold hover:bg-white/90 transition-all transform hover:scale-105 shadow-xl"
|
||||||
>
|
>
|
||||||
查看大图
|
查看大图
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
v-if="todayImage.copyrightlink"
|
v-if="latestImage.copyrightlink"
|
||||||
@click="openCopyrightLink(todayImage.copyrightlink)"
|
@click="openCopyrightLink(latestImage.copyrightlink)"
|
||||||
class="px-6 py-3 bg-white/10 backdrop-blur-md text-white rounded-lg font-semibold hover:bg-white/20 transition-all border border-white/30"
|
class="px-6 py-3 bg-white/10 backdrop-blur-md text-white rounded-lg font-semibold hover:bg-white/20 transition-all border border-white/30"
|
||||||
>
|
>
|
||||||
了解更多
|
了解更多
|
||||||
@@ -59,6 +69,89 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<!-- Global Section - 必应全球 -->
|
||||||
|
<section v-if="globalImages.length > 0" class="py-16 px-4 md:px-8 lg:px-16 bg-white/5 border-y border-white/5">
|
||||||
|
<div class="max-w-7xl mx-auto">
|
||||||
|
<div class="flex items-center justify-between mb-8">
|
||||||
|
<div class="space-y-1">
|
||||||
|
<h2 class="text-3xl md:text-4xl font-bold text-white">
|
||||||
|
必应全球
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="relative group">
|
||||||
|
<!-- 左右切换按钮 -->
|
||||||
|
<button
|
||||||
|
v-show="canScrollLeft"
|
||||||
|
@click="scrollGlobal('left')"
|
||||||
|
class="absolute left-[-20px] top-1/2 -translate-y-1/2 z-30 p-2 bg-black/60 backdrop-blur-md rounded-full text-white transition-all hidden md:block border border-white/10 hover:bg-black/80 hover:scale-110 active:scale-95 shadow-2xl"
|
||||||
|
>
|
||||||
|
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-show="canScrollRight"
|
||||||
|
@click="scrollGlobal('right')"
|
||||||
|
class="absolute right-[-20px] top-1/2 -translate-y-1/2 z-30 p-2 bg-black/60 backdrop-blur-md rounded-full text-white transition-all hidden md:block border border-white/10 hover:bg-black/80 hover:scale-110 active:scale-95 shadow-2xl"
|
||||||
|
>
|
||||||
|
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div
|
||||||
|
ref="globalScrollContainer"
|
||||||
|
class="flex overflow-x-auto gap-6 pb-6 -mx-4 px-4 md:mx-0 md:px-0 scrollbar-hide snap-x scroll-smooth"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-for="image in globalImages"
|
||||||
|
:key="image.mkt! + image.date!"
|
||||||
|
class="flex-none w-[280px] md:w-[350px] aspect-video rounded-xl overflow-hidden cursor-pointer transform transition-all duration-500 hover:scale-[1.02] hover:shadow-2xl snap-start relative group/card"
|
||||||
|
@click="viewImage(image.date!, image.mkt!)"
|
||||||
|
>
|
||||||
|
<!-- 图片层 -->
|
||||||
|
<img
|
||||||
|
:src="getImageUrl(image)"
|
||||||
|
:alt="image.title"
|
||||||
|
class="w-full h-full object-cover transition-transform duration-700 group-hover/card:scale-110"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- 渐变层 -->
|
||||||
|
<div class="absolute inset-0 bg-gradient-to-t from-black/90 via-black/20 to-transparent group-hover/card:via-black/40 transition-colors duration-500"></div>
|
||||||
|
|
||||||
|
<!-- 内容层 -->
|
||||||
|
<div class="absolute inset-0 flex flex-col justify-end p-5">
|
||||||
|
<div class="text-[10px] md:text-xs text-white/60 mb-1 transform translate-y-2 group-hover/card:translate-y-0 transition-transform duration-500">
|
||||||
|
{{ formatDate(image.date) }}
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2 mb-2 transform translate-y-2 group-hover/card:translate-y-0 transition-transform duration-500">
|
||||||
|
<span class="text-xs text-white/70 font-medium">
|
||||||
|
{{ getRegionLabel(image.mkt) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3 class="text-white font-bold text-base md:text-lg line-clamp-1 transform translate-y-1 group-hover/card:translate-y-0 transition-transform duration-500">
|
||||||
|
{{ image.title || '必应每日一图' }}
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 左右渐变遮罩 (仅在大屏显示) -->
|
||||||
|
<div
|
||||||
|
class="absolute left-0 top-0 bottom-6 w-24 bg-gradient-to-r from-gray-900 to-transparent pointer-events-none hidden md:block transition-opacity duration-500"
|
||||||
|
:class="canScrollLeft ? 'opacity-100' : 'opacity-0'"
|
||||||
|
></div>
|
||||||
|
<div
|
||||||
|
class="absolute right-0 top-0 bottom-6 w-24 bg-gradient-to-l from-gray-900 to-transparent pointer-events-none hidden md:block transition-opacity duration-500"
|
||||||
|
:class="canScrollRight ? 'opacity-100' : 'opacity-0'"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<!-- Gallery Section - 历史图片 -->
|
<!-- Gallery Section - 历史图片 -->
|
||||||
<section class="py-16 px-4 md:px-8 lg:px-16">
|
<section class="py-16 px-4 md:px-8 lg:px-16">
|
||||||
<div class="max-w-7xl mx-auto">
|
<div class="max-w-7xl mx-auto">
|
||||||
@@ -69,6 +162,23 @@
|
|||||||
|
|
||||||
<!-- 筛选器 -->
|
<!-- 筛选器 -->
|
||||||
<div class="flex flex-wrap items-center gap-3">
|
<div class="flex flex-wrap items-center gap-3">
|
||||||
|
<!-- 地区选择 -->
|
||||||
|
<Select v-model="selectedMkt" @update:model-value="onMktChange">
|
||||||
|
<SelectTrigger class="w-[180px] bg-white/10 backdrop-blur-md text-white border-white/20 hover:bg-white/15 hover:border-white/30 focus:ring-white/50 shadow-lg">
|
||||||
|
<SelectValue placeholder="选择地区" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent class="bg-gray-900/95 backdrop-blur-xl border-white/20 text-white">
|
||||||
|
<SelectItem
|
||||||
|
v-for="region in regions"
|
||||||
|
:key="region.value"
|
||||||
|
:value="region.value"
|
||||||
|
class="focus:bg-white/10 focus:text-white cursor-pointer"
|
||||||
|
>
|
||||||
|
{{ region.label }}
|
||||||
|
</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
|
||||||
<!-- 年份选择 -->
|
<!-- 年份选择 -->
|
||||||
<Select v-model="selectedYear" @update:model-value="onYearChange">
|
<Select v-model="selectedYear" @update:model-value="onYearChange">
|
||||||
<SelectTrigger class="w-[180px] bg-white/10 backdrop-blur-md text-white border-white/20 hover:bg-white/15 hover:border-white/30 focus:ring-white/50 shadow-lg">
|
<SelectTrigger class="w-[180px] bg-white/10 backdrop-blur-md text-white border-white/20 hover:bg-white/15 hover:border-white/30 focus:ring-white/50 shadow-lg">
|
||||||
@@ -129,7 +239,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 图片网格 -->
|
<!-- 图片网格 -->
|
||||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
|
<div class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-6">
|
||||||
<div
|
<div
|
||||||
v-for="(image, index) in images"
|
v-for="(image, index) in images"
|
||||||
:key="image.date || index"
|
:key="image.date || index"
|
||||||
@@ -143,7 +253,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<img
|
<img
|
||||||
v-else
|
v-else
|
||||||
:src="getImageUrl(image.date!)"
|
:src="getImageUrl(image)"
|
||||||
:alt="image.title || 'Bing Image'"
|
:alt="image.title || 'Bing Image'"
|
||||||
class="w-full h-full object-cover transition-transform duration-500 group-hover:scale-110"
|
class="w-full h-full object-cover transition-transform duration-500 group-hover:scale-110"
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
@@ -151,14 +261,14 @@
|
|||||||
|
|
||||||
<!-- 悬浮信息层 -->
|
<!-- 悬浮信息层 -->
|
||||||
<div class="absolute inset-0 bg-gradient-to-t from-black/90 via-black/50 to-transparent md:opacity-0 md:group-hover:opacity-100 transition-opacity duration-300">
|
<div class="absolute inset-0 bg-gradient-to-t from-black/90 via-black/50 to-transparent md:opacity-0 md:group-hover:opacity-100 transition-opacity duration-300">
|
||||||
<div class="absolute bottom-0 left-0 right-0 p-4 md:p-6 transform md:translate-y-4 md:group-hover:translate-y-0 transition-transform duration-300">
|
<div class="absolute bottom-0 left-0 right-0 p-3 md:p-4 transform md:translate-y-4 md:group-hover:translate-y-0 transition-transform duration-300">
|
||||||
<div class="text-xs text-white/70 mb-1">
|
<div class="text-[10px] md:text-xs text-white/70 mb-0.5">
|
||||||
{{ formatDate(image.date) }}
|
{{ formatDate(image.date) }}
|
||||||
</div>
|
</div>
|
||||||
<h3 class="text-base md:text-lg font-semibold text-white mb-1 md:mb-2 line-clamp-2">
|
<h3 class="text-sm md:text-base font-semibold text-white mb-1 line-clamp-2">
|
||||||
{{ image.title || '未命名' }}
|
{{ image.title || '未命名' }}
|
||||||
</h3>
|
</h3>
|
||||||
<p v-if="image.copyright" class="text-xs md:text-sm text-white/80 line-clamp-2">
|
<p v-if="image.copyright" class="text-[10px] md:text-xs text-white/80 line-clamp-2">
|
||||||
{{ image.copyright }}
|
{{ image.copyright }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -173,13 +283,18 @@
|
|||||||
<span>加载中...</span>
|
<span>加载中...</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
<div
|
||||||
v-else-if="hasMore"
|
v-else-if="hasMore"
|
||||||
|
ref="loadMoreTrigger"
|
||||||
|
class="inline-block"
|
||||||
|
>
|
||||||
|
<button
|
||||||
@click="loadMore"
|
@click="loadMore"
|
||||||
class="px-8 py-3 bg-white/10 backdrop-blur-md text-white rounded-lg font-semibold hover:bg-white/20 transition-all border border-white/30"
|
class="px-8 py-3 bg-white/10 backdrop-blur-md text-white rounded-lg font-semibold hover:bg-white/20 transition-all border border-white/30"
|
||||||
>
|
>
|
||||||
加载更多
|
加载更多
|
||||||
</button>
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<p v-else class="text-white/40">
|
<p v-else class="text-white/40">
|
||||||
已加载全部图片
|
已加载全部图片
|
||||||
@@ -230,9 +345,11 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
|
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
|
||||||
import { useTodayImage, useImageList } from '@/composables/useImages'
|
import { useImageList, useGlobalTodayImages } from '@/composables/useImages'
|
||||||
import { bingPaperApi } from '@/lib/api-service'
|
import { bingPaperApi } from '@/lib/api-service'
|
||||||
|
import { normalizeImageUrl } from '@/lib/api-config'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
|
import { getDefaultMkt, setSavedMkt, SUPPORTED_REGIONS, setSupportedRegions } from '@/lib/mkt-utils'
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
@@ -243,26 +360,140 @@ import {
|
|||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|
||||||
// 获取今日图片
|
// 地区列表
|
||||||
const { image: todayImage, loading: todayLoading } = useTodayImage()
|
const regions = ref(SUPPORTED_REGIONS)
|
||||||
|
|
||||||
// 获取图片列表(使用服务端分页和筛选)
|
// 顶部最新图片(独立加载,不受筛选影响)
|
||||||
const { images, loading, hasMore, loadMore, filterByMonth } = useImageList(30)
|
const latestImage = ref<any>(null)
|
||||||
|
const todayLoading = ref(false)
|
||||||
|
|
||||||
|
// 全球今日图片
|
||||||
|
const { images: globalImages } = useGlobalTodayImages()
|
||||||
|
|
||||||
|
// 滚动功能实现
|
||||||
|
const globalScrollContainer = ref<HTMLElement | null>(null)
|
||||||
|
const canScrollLeft = ref(false)
|
||||||
|
const canScrollRight = ref(false)
|
||||||
|
|
||||||
|
const updateScrollState = () => {
|
||||||
|
if (!globalScrollContainer.value) return
|
||||||
|
const { scrollLeft, scrollWidth, clientWidth } = globalScrollContainer.value
|
||||||
|
canScrollLeft.value = scrollLeft > 5
|
||||||
|
canScrollRight.value = scrollLeft + clientWidth < scrollWidth - 5
|
||||||
|
}
|
||||||
|
|
||||||
|
const scrollGlobal = (direction: 'left' | 'right') => {
|
||||||
|
if (!globalScrollContainer.value) return
|
||||||
|
const scrollAmount = globalScrollContainer.value.clientWidth * 0.8 || 1000
|
||||||
|
globalScrollContainer.value.scrollBy({
|
||||||
|
left: direction === 'left' ? -scrollAmount : scrollAmount,
|
||||||
|
behavior: 'smooth'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 历史图片列表(使用服务端分页和筛选,每页15张)
|
||||||
|
const { images, loading, hasMore, loadMore, filterByMonth, filterByMkt } = useImageList(15)
|
||||||
|
|
||||||
|
// 获取地区标签
|
||||||
|
const getRegionLabel = (mkt?: string) => {
|
||||||
|
if (!mkt) return ''
|
||||||
|
const region = regions.value.find(r => r.value === mkt)
|
||||||
|
return region ? region.label : mkt
|
||||||
|
}
|
||||||
|
|
||||||
|
// 加载顶部最新图片
|
||||||
|
const loadLatestImage = async () => {
|
||||||
|
todayLoading.value = true
|
||||||
|
try {
|
||||||
|
const params: any = { page: 1, page_size: 1, mkt: selectedMkt.value }
|
||||||
|
const result = await bingPaperApi.getImages(params)
|
||||||
|
if (result.length > 0) {
|
||||||
|
latestImage.value = result[0]
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load latest image:', error)
|
||||||
|
} finally {
|
||||||
|
todayLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// 监听滚动容器的变化
|
||||||
|
watch(globalScrollContainer, (el, _, onCleanup) => {
|
||||||
|
if (el) {
|
||||||
|
el.addEventListener('scroll', updateScrollState)
|
||||||
|
// 初始检查
|
||||||
|
setTimeout(updateScrollState, 100)
|
||||||
|
onCleanup(() => {
|
||||||
|
el.removeEventListener('scroll', updateScrollState)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 监听全球图片数据变化
|
||||||
|
watch(globalImages, () => {
|
||||||
|
setTimeout(updateScrollState, 500)
|
||||||
|
}, { deep: true })
|
||||||
|
|
||||||
|
// 判断最新图片是否为今天的图片
|
||||||
|
const isToday = computed(() => {
|
||||||
|
if (!latestImage.value?.date) return false
|
||||||
|
const imageDate = new Date(latestImage.value.date).toDateString()
|
||||||
|
const today = new Date().toDateString()
|
||||||
|
return imageDate === today
|
||||||
|
})
|
||||||
|
|
||||||
|
// 计算下次更新时间提示
|
||||||
|
const nextUpdateTime = computed(() => {
|
||||||
|
const now = new Date()
|
||||||
|
const hours = now.getHours()
|
||||||
|
|
||||||
|
// 更新时间点:8:20, 12:20, 16:20, 20:20, 0:20, 4:20
|
||||||
|
const updateHours = [0, 4, 8, 12, 16, 20]
|
||||||
|
const updateMinute = 20
|
||||||
|
|
||||||
|
// 找到下一个更新时间点
|
||||||
|
for (const hour of updateHours) {
|
||||||
|
if (hours < hour || (hours === hour && now.getMinutes() < updateMinute)) {
|
||||||
|
return `${String(hour).padStart(2, '0')}:${String(updateMinute).padStart(2, '0')}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果今天没有下一个更新点,返回明天的第一个更新点
|
||||||
|
return `次日 00:20`
|
||||||
|
})
|
||||||
|
|
||||||
// 筛选相关状态
|
// 筛选相关状态
|
||||||
|
const selectedMkt = ref(getDefaultMkt())
|
||||||
const selectedYear = ref('')
|
const selectedYear = ref('')
|
||||||
const selectedMonth = ref('')
|
const selectedMonth = ref('')
|
||||||
|
|
||||||
|
const onMktChange = () => {
|
||||||
|
setSavedMkt(selectedMkt.value)
|
||||||
|
filterByMkt(selectedMkt.value)
|
||||||
|
loadLatestImage()
|
||||||
|
|
||||||
|
// 重置懒加载状态
|
||||||
|
imageVisibility.value = []
|
||||||
|
setTimeout(() => {
|
||||||
|
setupObserver()
|
||||||
|
}, 100)
|
||||||
|
}
|
||||||
|
|
||||||
// 懒加载相关
|
// 懒加载相关
|
||||||
const imageRefs = ref<(HTMLElement | null)[]>([])
|
const imageRefs = ref<(HTMLElement | null)[]>([])
|
||||||
const imageVisibility = ref<boolean[]>([])
|
const imageVisibility = ref<boolean[]>([])
|
||||||
let observer: IntersectionObserver | null = null
|
let observer: IntersectionObserver | null = null
|
||||||
|
|
||||||
// 计算可用的年份列表(基于当前日期生成,从2020年到当前年份)
|
// 无限滚动加载
|
||||||
|
const loadMoreTrigger = ref<HTMLElement | null>(null)
|
||||||
|
let loadMoreObserver: IntersectionObserver | null = null
|
||||||
|
|
||||||
|
// 计算可用的年份列表(基于当前日期生成,计算前20年)
|
||||||
const availableYears = computed(() => {
|
const availableYears = computed(() => {
|
||||||
const currentYear = new Date().getFullYear()
|
const currentYear = new Date().getFullYear()
|
||||||
const years: number[] = []
|
const years: number[] = []
|
||||||
for (let year = currentYear; year >= 2020; year--) {
|
for (let year = currentYear; year >= currentYear - 20; year--) {
|
||||||
years.push(year)
|
years.push(year)
|
||||||
}
|
}
|
||||||
return years
|
return years
|
||||||
@@ -305,11 +536,14 @@ const onFilterChange = () => {
|
|||||||
|
|
||||||
// 重置筛选
|
// 重置筛选
|
||||||
const resetFilters = () => {
|
const resetFilters = () => {
|
||||||
|
selectedMkt.value = getDefaultMkt()
|
||||||
selectedYear.value = ''
|
selectedYear.value = ''
|
||||||
selectedMonth.value = ''
|
selectedMonth.value = ''
|
||||||
|
|
||||||
// 重置为加载默认数据
|
// 重置为加载默认数据
|
||||||
|
filterByMkt(selectedMkt.value)
|
||||||
filterByMonth(undefined)
|
filterByMonth(undefined)
|
||||||
|
loadLatestImage()
|
||||||
|
|
||||||
// 重置懒加载状态
|
// 重置懒加载状态
|
||||||
imageVisibility.value = []
|
imageVisibility.value = []
|
||||||
@@ -360,16 +594,6 @@ const setupObserver = () => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// 初始化懒加载状态
|
|
||||||
onMounted(() => {
|
|
||||||
// 初始化时设置
|
|
||||||
if (images.value.length > 0) {
|
|
||||||
imageVisibility.value = new Array(images.value.length).fill(false)
|
|
||||||
setTimeout(() => {
|
|
||||||
setupObserver()
|
|
||||||
}, 100)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// 监听 images 变化,动态更新 imageVisibility
|
// 监听 images 变化,动态更新 imageVisibility
|
||||||
watch(() => images.value.length, (newLength, oldLength) => {
|
watch(() => images.value.length, (newLength, oldLength) => {
|
||||||
@@ -399,11 +623,72 @@ watch(() => images.value.length, (newLength, oldLength) => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 设置无限滚动 Observer
|
||||||
|
const setupLoadMoreObserver = () => {
|
||||||
|
if (loadMoreObserver) {
|
||||||
|
loadMoreObserver.disconnect()
|
||||||
|
}
|
||||||
|
|
||||||
|
loadMoreObserver = new IntersectionObserver(
|
||||||
|
(entries) => {
|
||||||
|
entries.forEach(entry => {
|
||||||
|
if (entry.isIntersecting && !loading.value && hasMore.value) {
|
||||||
|
loadMore()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
|
{
|
||||||
|
root: null,
|
||||||
|
rootMargin: '100px',
|
||||||
|
threshold: 0.1
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
if (loadMoreTrigger.value) {
|
||||||
|
loadMoreObserver.observe(loadMoreTrigger.value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 初始化
|
||||||
|
onMounted(async () => {
|
||||||
|
// 1. 获取地区信息
|
||||||
|
try {
|
||||||
|
const backendRegions = await bingPaperApi.getRegions()
|
||||||
|
if (backendRegions && backendRegions.length > 0) {
|
||||||
|
regions.value = backendRegions
|
||||||
|
setSupportedRegions(backendRegions)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch regions:', error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 加载最新图片
|
||||||
|
loadLatestImage()
|
||||||
|
|
||||||
|
// 3. 设置观察者和滚动状态
|
||||||
|
if (images.value.length > 0) {
|
||||||
|
imageVisibility.value = new Array(images.value.length).fill(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
setupObserver()
|
||||||
|
setupLoadMoreObserver()
|
||||||
|
updateScrollState()
|
||||||
|
}, 100)
|
||||||
|
|
||||||
|
// 4. 全局事件
|
||||||
|
window.addEventListener('resize', updateScrollState)
|
||||||
|
})
|
||||||
|
|
||||||
// 清理
|
// 清理
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
if (observer) {
|
if (observer) {
|
||||||
observer.disconnect()
|
observer.disconnect()
|
||||||
}
|
}
|
||||||
|
if (loadMoreObserver) {
|
||||||
|
loadMoreObserver.disconnect()
|
||||||
|
}
|
||||||
|
window.removeEventListener('resize', updateScrollState)
|
||||||
})
|
})
|
||||||
|
|
||||||
// 格式化日期
|
// 格式化日期
|
||||||
@@ -417,19 +702,24 @@ const formatDate = (dateStr?: string) => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// 获取今日图片 URL
|
// 获取最新图片 URL(顶部大图使用UHD高清)
|
||||||
const getTodayImageUrl = () => {
|
const getLatestImageUrl = () => {
|
||||||
return bingPaperApi.getTodayImageUrl('UHD', 'jpg')
|
if (!latestImage.value?.date) return ''
|
||||||
|
return bingPaperApi.getImageUrlByDate(latestImage.value.date, 'UHD', 'jpg', latestImage.value.mkt)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 获取图片 URL(缩略图 - 使用较小分辨率节省流量)
|
// 获取图片 URL(缩略图 - 优先使用后端返回的最小变体以节省流量)
|
||||||
const getImageUrl = (date: string) => {
|
const getImageUrl = (image: any) => {
|
||||||
return bingPaperApi.getImageUrlByDate(date, '640x480', 'jpg')
|
if (image.variants && image.variants.length > 0) {
|
||||||
|
return normalizeImageUrl(image.variants[0].url)
|
||||||
|
}
|
||||||
|
return bingPaperApi.getImageUrlByDate(image.date!, '640x480', 'jpg', image.mkt)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 查看图片详情
|
// 查看图片详情
|
||||||
const viewImage = (date: string) => {
|
const viewImage = (date: string, mkt?: string) => {
|
||||||
router.push(`/image/${date}`)
|
const query = mkt ? `?mkt=${mkt}` : ''
|
||||||
|
router.push(`/image/${date}${query}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 打开版权详情链接
|
// 打开版权详情链接
|
||||||
@@ -448,3 +738,34 @@ const openCopyrightLink = (link: string) => {
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
/* 隐藏滚动条但保持滚动功能 */
|
||||||
|
body {
|
||||||
|
overflow-y: scroll;
|
||||||
|
scrollbar-width: none; /* Firefox */
|
||||||
|
-ms-overflow-style: none; /* IE 10+ */
|
||||||
|
}
|
||||||
|
|
||||||
|
body::-webkit-scrollbar {
|
||||||
|
display: none; /* Chrome, Safari, Opera */
|
||||||
|
}
|
||||||
|
|
||||||
|
html {
|
||||||
|
scrollbar-width: none; /* Firefox */
|
||||||
|
-ms-overflow-style: none; /* IE 10+ */
|
||||||
|
}
|
||||||
|
|
||||||
|
html::-webkit-scrollbar {
|
||||||
|
display: none; /* Chrome, Safari, Opera */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 隐藏横向滚动条 */
|
||||||
|
.scrollbar-hide {
|
||||||
|
-ms-overflow-style: none; /* IE and Edge */
|
||||||
|
scrollbar-width: none; /* Firefox */
|
||||||
|
}
|
||||||
|
.scrollbar-hide::-webkit-scrollbar {
|
||||||
|
display: none; /* Chrome, Safari and Opera */
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -1,23 +1,28 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="fixed inset-0 bg-black z-50 overflow-hidden">
|
<div class="fixed inset-0 bg-black z-50 overflow-hidden">
|
||||||
<!-- 加载状态 -->
|
<!-- 加载状态(动画过渡中不显示) -->
|
||||||
<div v-if="loading" class="absolute inset-0 flex items-center justify-center">
|
<div v-if="loading && !imageTransitioning" class="absolute inset-0 flex items-center justify-center">
|
||||||
<div class="w-16 h-16 border-4 border-white/20 border-t-white rounded-full animate-spin"></div>
|
<div class="w-16 h-16 border-4 border-white/20 border-t-white rounded-full animate-spin"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 主要内容 -->
|
<!-- 主要内容 -->
|
||||||
<div v-else-if="image" class="relative h-full w-full">
|
<div v-else-if="image || imageTransitioning" class="relative h-full w-full">
|
||||||
<!-- 全屏图片 -->
|
<!-- 全屏图片 -->
|
||||||
<div class="absolute inset-0 flex items-center justify-center">
|
<div class="absolute inset-0 flex items-center justify-center">
|
||||||
<img
|
<img
|
||||||
:src="getFullImageUrl()"
|
:src="getFullImageUrl()"
|
||||||
:alt="image.title || 'Bing Image'"
|
:alt="image?.title || 'Bing Image'"
|
||||||
class="max-w-full max-h-full object-contain"
|
class="max-w-full max-h-full object-contain transition-opacity duration-500 ease-in-out"
|
||||||
|
:style="{ opacity: imageOpacity }"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 顶部工具栏 -->
|
<!-- 顶部工具栏 -->
|
||||||
<div class="absolute top-0 left-0 right-0 bg-gradient-to-b from-black/80 to-transparent p-6 z-10">
|
<div
|
||||||
|
v-show="!showCalendar"
|
||||||
|
class="absolute top-0 left-0 right-0 bg-gradient-to-b from-black/80 to-transparent p-6 z-10 transition-opacity duration-300"
|
||||||
|
:class="{ 'opacity-0 pointer-events-none': showCalendar }"
|
||||||
|
>
|
||||||
<div class="flex items-center justify-between max-w-7xl mx-auto">
|
<div class="flex items-center justify-between max-w-7xl mx-auto">
|
||||||
<button
|
<button
|
||||||
@click="goBack"
|
@click="goBack"
|
||||||
@@ -30,37 +35,44 @@
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div class="text-white/80 text-sm">
|
<div class="text-white/80 text-sm">
|
||||||
{{ formatDate(image.date) }}
|
{{ formatDate(image?.date) }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 信息悬浮层(类似 Windows 聚焦) -->
|
<!-- 信息悬浮层(类似 Windows 聚焦) -->
|
||||||
<div
|
<div
|
||||||
v-if="showInfo"
|
v-if="showInfo && !showCalendar && image"
|
||||||
ref="infoPanel"
|
ref="infoPanel"
|
||||||
class="fixed w-[90%] max-w-md bg-black/40 backdrop-blur-lg rounded-xl p-4 transform transition-opacity duration-300 z-10 select-none"
|
class="fixed w-[90%] max-w-md bg-black/40 backdrop-blur-lg rounded-xl p-4 z-20 select-none"
|
||||||
:style="{ left: infoPanelPos.x + 'px', top: infoPanelPos.y + 'px' }"
|
:class="{
|
||||||
:class="{ 'opacity-100': showInfo, 'opacity-0': !showInfo }"
|
'opacity-100': showInfo && !showCalendar,
|
||||||
|
'opacity-0 pointer-events-none': showCalendar,
|
||||||
|
'transition-opacity duration-300': !isDragging
|
||||||
|
}"
|
||||||
|
:style="{
|
||||||
|
transform: `translate(${infoPanelPos.x}px, ${infoPanelPos.y}px)`,
|
||||||
|
willChange: isDragging ? 'transform' : 'auto'
|
||||||
|
}"
|
||||||
>
|
>
|
||||||
<!-- 拖动手柄 -->
|
<!-- 拖动手柄 -->
|
||||||
<div
|
<div
|
||||||
@mousedown="startDrag"
|
@mousedown="startDrag"
|
||||||
@touchstart="startDrag"
|
@touchstart.passive="startDrag"
|
||||||
class="absolute top-2 left-1/2 -translate-x-1/2 w-12 h-1 bg-white/30 rounded-full cursor-move hover:bg-white/50 transition-colors touch-none"
|
class="absolute top-2 left-1/2 -translate-x-1/2 w-12 h-1 bg-white/30 rounded-full cursor-move hover:bg-white/50 transition-colors touch-none"
|
||||||
></div>
|
></div>
|
||||||
|
|
||||||
<h2 class="text-lg font-bold text-white mb-2 mt-2">
|
<h2 class="text-lg font-bold text-white mb-2 mt-2">
|
||||||
{{ image.title || '未命名' }}
|
{{ image?.title || '未命名' }}
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
<p v-if="image.copyright" class="text-white/80 text-xs mb-3 leading-relaxed">
|
<p v-if="image?.copyright" class="text-white/80 text-xs mb-3 leading-relaxed">
|
||||||
{{ image.copyright }}
|
{{ image.copyright }}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<!-- 版权详情链接 -->
|
<!-- 版权详情链接 -->
|
||||||
<a
|
<a
|
||||||
v-if="image.copyrightlink"
|
v-if="image?.copyrightlink"
|
||||||
:href="image.copyrightlink"
|
:href="image.copyrightlink"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
class="inline-flex items-center gap-2 px-3 py-1.5 bg-white/15 hover:bg-white/25 text-white rounded-lg text-xs font-medium transition-all group"
|
class="inline-flex items-center gap-2 px-3 py-1.5 bg-white/15 hover:bg-white/25 text-white rounded-lg text-xs font-medium transition-all group"
|
||||||
@@ -83,13 +95,17 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 底部控制栏 -->
|
<!-- 底部控制栏 -->
|
||||||
<div class="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/80 to-transparent p-6 z-10">
|
<div
|
||||||
|
v-show="!showCalendar"
|
||||||
|
class="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/80 to-transparent p-6 z-10 transition-opacity duration-300"
|
||||||
|
:class="{ 'opacity-0 pointer-events-none': showCalendar }"
|
||||||
|
>
|
||||||
<div class="flex items-center justify-between max-w-7xl mx-auto">
|
<div class="flex items-center justify-between max-w-7xl mx-auto">
|
||||||
<!-- 日期切换按钮 -->
|
<!-- 日期切换按钮 -->
|
||||||
<div class="flex items-center gap-4">
|
<div class="flex items-center gap-4">
|
||||||
<button
|
<button
|
||||||
@click="previousDay"
|
@click="previousDay"
|
||||||
:disabled="navigating"
|
:disabled="navigating || !hasPreviousDay"
|
||||||
class="flex items-center gap-2 px-4 py-2 bg-white/10 backdrop-blur-md text-white rounded-lg hover:bg-white/20 transition-all disabled:opacity-50 disabled:cursor-not-allowed"
|
class="flex items-center gap-2 px-4 py-2 bg-white/10 backdrop-blur-md text-white rounded-lg hover:bg-white/20 transition-all disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
>
|
>
|
||||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
@@ -100,7 +116,7 @@
|
|||||||
|
|
||||||
<button
|
<button
|
||||||
@click="nextDay"
|
@click="nextDay"
|
||||||
:disabled="navigating || isToday"
|
:disabled="navigating || !hasNextDay"
|
||||||
class="flex items-center gap-2 px-4 py-2 bg-white/10 backdrop-blur-md text-white rounded-lg hover:bg-white/20 transition-all disabled:opacity-50 disabled:cursor-not-allowed"
|
class="flex items-center gap-2 px-4 py-2 bg-white/10 backdrop-blur-md text-white rounded-lg hover:bg-white/20 transition-all disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
>
|
>
|
||||||
<span class="hidden sm:inline">后一天</span>
|
<span class="hidden sm:inline">后一天</span>
|
||||||
@@ -110,6 +126,19 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 右侧按钮组 -->
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<!-- 日历按钮 -->
|
||||||
|
<button
|
||||||
|
@click="toggleCalendar(true)"
|
||||||
|
class="flex items-center gap-2 px-4 py-2 bg-white/10 backdrop-blur-md text-white rounded-lg hover:bg-white/20 transition-all"
|
||||||
|
>
|
||||||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"></path>
|
||||||
|
</svg>
|
||||||
|
<span class="hidden sm:inline">日历</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
<!-- 信息按钮 -->
|
<!-- 信息按钮 -->
|
||||||
<button
|
<button
|
||||||
v-if="!showInfo"
|
v-if="!showInfo"
|
||||||
@@ -124,6 +153,14 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 日历弹窗 -->
|
||||||
|
<Calendar
|
||||||
|
v-if="showCalendar"
|
||||||
|
:selected-date="currentDate"
|
||||||
|
@close="toggleCalendar(false)"
|
||||||
|
/>
|
||||||
|
|
||||||
<!-- 错误状态 -->
|
<!-- 错误状态 -->
|
||||||
<div v-else-if="error" class="absolute inset-0 flex items-center justify-center">
|
<div v-else-if="error" class="absolute inset-0 flex items-center justify-center">
|
||||||
@@ -141,40 +178,121 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed } from 'vue'
|
import { ref, watch, onMounted, onUnmounted, computed } from 'vue'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import { useImageByDate } from '@/composables/useImages'
|
import { useImageByDate } from '@/composables/useImages'
|
||||||
import { bingPaperApi } from '@/lib/api-service'
|
import { bingPaperApi } from '@/lib/api-service'
|
||||||
|
import { getDefaultMkt } from '@/lib/mkt-utils'
|
||||||
|
import Calendar from '@/components/ui/calendar/Calendar.vue'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|
||||||
|
// 从 localStorage 读取日历状态,默认关闭
|
||||||
|
const CALENDAR_STATE_KEY = 'imageView_showCalendar'
|
||||||
|
|
||||||
|
// 获取初始日历状态
|
||||||
|
const getInitialCalendarState = (): boolean => {
|
||||||
|
try {
|
||||||
|
const stored = localStorage.getItem(CALENDAR_STATE_KEY)
|
||||||
|
return stored === 'true'
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Failed to read calendar state from localStorage:', error)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const currentDate = ref(route.params.date as string)
|
const currentDate = ref(route.params.date as string)
|
||||||
|
const currentMkt = ref(route.query.mkt as string || getDefaultMkt())
|
||||||
const showInfo = ref(true)
|
const showInfo = ref(true)
|
||||||
|
const showCalendar = ref(getInitialCalendarState())
|
||||||
const navigating = ref(false)
|
const navigating = ref(false)
|
||||||
|
const imageOpacity = ref(1)
|
||||||
|
const imageTransitioning = ref(false)
|
||||||
|
|
||||||
|
// 响应式窗口大小
|
||||||
|
const windowSize = ref({ width: window.innerWidth, height: window.innerHeight })
|
||||||
|
const isMobile = computed(() => windowSize.value.width < 768)
|
||||||
|
|
||||||
|
// 前后日期可用性
|
||||||
|
const hasPreviousDay = ref(true)
|
||||||
|
const hasNextDay = ref(true)
|
||||||
|
const checkingDates = ref(false)
|
||||||
|
|
||||||
// 拖动相关状态
|
// 拖动相关状态
|
||||||
const infoPanel = ref<HTMLElement | null>(null)
|
const infoPanel = ref<HTMLElement | null>(null)
|
||||||
const infoPanelPos = ref({ x: 0, y: 0 })
|
const infoPanelPos = ref({ x: 0, y: 0 })
|
||||||
const isDragging = ref(false)
|
const isDragging = ref(false)
|
||||||
const dragStart = ref({ x: 0, y: 0 })
|
const dragStart = ref({ x: 0, y: 0 })
|
||||||
|
let animationFrameId: number | null = null
|
||||||
|
|
||||||
// 初始化浮窗位置(居中偏下)
|
// 计算图片实际显示区域(考虑图片宽高比和object-contain)
|
||||||
|
const getImageDisplayBounds = () => {
|
||||||
|
const windowWidth = windowSize.value.width
|
||||||
|
const windowHeight = windowSize.value.height
|
||||||
|
|
||||||
|
// 必应图片通常是16:9
|
||||||
|
const imageAspectRatio = 16 / 9
|
||||||
|
const windowAspectRatio = windowWidth / windowHeight
|
||||||
|
|
||||||
|
let displayWidth: number
|
||||||
|
let displayHeight: number
|
||||||
|
let offsetX: number
|
||||||
|
let offsetY: number
|
||||||
|
|
||||||
|
if (windowAspectRatio > imageAspectRatio) {
|
||||||
|
// 窗口更宽,图片上下占满,左右留黑边
|
||||||
|
displayHeight = windowHeight
|
||||||
|
displayWidth = displayHeight * imageAspectRatio
|
||||||
|
offsetX = (windowWidth - displayWidth) / 2
|
||||||
|
offsetY = 0
|
||||||
|
} else {
|
||||||
|
// 窗口更高,图片左右占满,上下留黑边
|
||||||
|
displayWidth = windowWidth
|
||||||
|
displayHeight = displayWidth / imageAspectRatio
|
||||||
|
offsetX = 0
|
||||||
|
offsetY = (windowHeight - displayHeight) / 2
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
left: offsetX,
|
||||||
|
top: offsetY,
|
||||||
|
right: offsetX + displayWidth,
|
||||||
|
bottom: offsetY + displayHeight,
|
||||||
|
width: displayWidth,
|
||||||
|
height: displayHeight
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 初始化浮窗位置(限制在图片显示区域内,移动端默认展示在底部)
|
||||||
const initPanelPosition = () => {
|
const initPanelPosition = () => {
|
||||||
if (typeof window !== 'undefined') {
|
if (typeof window !== 'undefined') {
|
||||||
const windowWidth = window.innerWidth
|
const bounds = getImageDisplayBounds()
|
||||||
const windowHeight = window.innerHeight
|
|
||||||
const panelWidth = Math.min(windowWidth * 0.9, 448) // max-w-md = 448px
|
if (isMobile.value) {
|
||||||
|
// 移动端:默认居中靠下,不严格限制在图片内(因为要求可以不限制)
|
||||||
|
// 但为了好看,我们还是给它一个默认位置
|
||||||
|
const panelWidth = windowSize.value.width * 0.9
|
||||||
infoPanelPos.value = {
|
infoPanelPos.value = {
|
||||||
x: (windowWidth - panelWidth) / 2,
|
x: (windowSize.value.width - panelWidth) / 2,
|
||||||
y: windowHeight - 200 // 距底部200px
|
y: windowSize.value.height - 240 // 靠下
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 桌面端:限制在图片区域内
|
||||||
|
const panelWidth = Math.min(bounds.width * 0.9, 448) // max-w-md = 448px
|
||||||
|
infoPanelPos.value = {
|
||||||
|
x: bounds.left + (bounds.width - panelWidth) / 2,
|
||||||
|
y: Math.max(bounds.top, bounds.bottom - 280) // 距底部280px,避免与控制栏重叠
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 开始拖动
|
// 开始拖动
|
||||||
const startDrag = (e: MouseEvent | TouchEvent) => {
|
const startDrag = (e: MouseEvent | TouchEvent) => {
|
||||||
|
if (e instanceof MouseEvent) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
|
}
|
||||||
isDragging.value = true
|
isDragging.value = true
|
||||||
|
|
||||||
const clientX = e instanceof MouseEvent ? e.clientX : e.touches[0].clientX
|
const clientX = e instanceof MouseEvent ? e.clientX : e.touches[0].clientX
|
||||||
@@ -185,42 +303,74 @@ const startDrag = (e: MouseEvent | TouchEvent) => {
|
|||||||
y: clientY - infoPanelPos.value.y
|
y: clientY - infoPanelPos.value.y
|
||||||
}
|
}
|
||||||
|
|
||||||
document.addEventListener('mousemove', onDrag)
|
document.addEventListener('mousemove', onDrag, { passive: false })
|
||||||
document.addEventListener('mouseup', stopDrag)
|
document.addEventListener('mouseup', stopDrag)
|
||||||
document.addEventListener('touchmove', onDrag, { passive: false })
|
document.addEventListener('touchmove', onDrag, { passive: true })
|
||||||
document.addEventListener('touchend', stopDrag)
|
document.addEventListener('touchend', stopDrag)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 拖动中
|
// 拖动中 - 使用 requestAnimationFrame 优化性能
|
||||||
const onDrag = (e: MouseEvent | TouchEvent) => {
|
const onDrag = (e: MouseEvent | TouchEvent) => {
|
||||||
if (!isDragging.value) return
|
if (!isDragging.value) return
|
||||||
|
|
||||||
if (e instanceof TouchEvent) {
|
if (e instanceof MouseEvent) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 取消之前的动画帧
|
||||||
|
if (animationFrameId !== null) {
|
||||||
|
cancelAnimationFrame(animationFrameId)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 使用 requestAnimationFrame 进行节流优化
|
||||||
|
animationFrameId = requestAnimationFrame(() => {
|
||||||
const clientX = e instanceof MouseEvent ? e.clientX : e.touches[0].clientX
|
const clientX = e instanceof MouseEvent ? e.clientX : e.touches[0].clientX
|
||||||
const clientY = e instanceof MouseEvent ? e.clientY : e.touches[0].clientY
|
const clientY = e instanceof MouseEvent ? e.clientY : e.touches[0].clientY
|
||||||
|
|
||||||
const newX = clientX - dragStart.value.x
|
const newX = clientX - dragStart.value.x
|
||||||
const newY = clientY - dragStart.value.y
|
const newY = clientY - dragStart.value.y
|
||||||
|
|
||||||
// 限制在视口内
|
// 限制在有效区域内
|
||||||
if (infoPanel.value) {
|
if (infoPanel.value) {
|
||||||
const rect = infoPanel.value.getBoundingClientRect()
|
const rect = infoPanel.value.getBoundingClientRect()
|
||||||
const maxX = window.innerWidth - rect.width
|
|
||||||
const maxY = window.innerHeight - rect.height
|
let minX, maxX, minY, maxY
|
||||||
|
|
||||||
|
if (isMobile.value) {
|
||||||
|
// 移动端:不限制区域,限制在视口内即可
|
||||||
|
minX = 0
|
||||||
|
maxX = windowSize.value.width - rect.width
|
||||||
|
minY = 0
|
||||||
|
maxY = windowSize.value.height - rect.height
|
||||||
|
} else {
|
||||||
|
// 桌面端:限制在图片实际显示区域内,考虑底部控制栏高度(约80px)
|
||||||
|
const bounds = getImageDisplayBounds()
|
||||||
|
minX = bounds.left
|
||||||
|
maxX = bounds.right - rect.width
|
||||||
|
minY = bounds.top
|
||||||
|
maxY = bounds.bottom - rect.height - 80 // 预留底部控制栏空间
|
||||||
|
}
|
||||||
|
|
||||||
infoPanelPos.value = {
|
infoPanelPos.value = {
|
||||||
x: Math.max(0, Math.min(newX, maxX)),
|
x: Math.max(minX, Math.min(newX, maxX)),
|
||||||
y: Math.max(0, Math.min(newY, maxY))
|
y: Math.max(minY, Math.min(newY, maxY))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
animationFrameId = null
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// 停止拖动
|
// 停止拖动
|
||||||
const stopDrag = () => {
|
const stopDrag = () => {
|
||||||
isDragging.value = false
|
isDragging.value = false
|
||||||
|
|
||||||
|
// 清理动画帧
|
||||||
|
if (animationFrameId !== null) {
|
||||||
|
cancelAnimationFrame(animationFrameId)
|
||||||
|
animationFrameId = null
|
||||||
|
}
|
||||||
|
|
||||||
document.removeEventListener('mousemove', onDrag)
|
document.removeEventListener('mousemove', onDrag)
|
||||||
document.removeEventListener('mouseup', stopDrag)
|
document.removeEventListener('mouseup', stopDrag)
|
||||||
document.removeEventListener('touchmove', onDrag)
|
document.removeEventListener('touchmove', onDrag)
|
||||||
@@ -228,10 +378,70 @@ const stopDrag = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 使用 composable 获取图片数据(传递 ref,自动响应日期变化)
|
// 使用 composable 获取图片数据(传递 ref,自动响应日期变化)
|
||||||
const { image, loading, error } = useImageByDate(currentDate)
|
const { image, loading, error } = useImageByDate(currentDate, currentMkt)
|
||||||
|
|
||||||
// 初始化位置
|
// 检测指定日期是否有数据
|
||||||
initPanelPosition()
|
const checkDateAvailability = async (dateStr: string): Promise<boolean> => {
|
||||||
|
try {
|
||||||
|
await bingPaperApi.getImageMetaByDate(dateStr, currentMkt.value)
|
||||||
|
return true
|
||||||
|
} catch (e) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检测前后日期可用性
|
||||||
|
const checkAdjacentDates = async () => {
|
||||||
|
if (checkingDates.value) return
|
||||||
|
|
||||||
|
checkingDates.value = true
|
||||||
|
const date = new Date(currentDate.value)
|
||||||
|
|
||||||
|
// 检测前一天
|
||||||
|
const prevDate = new Date(date)
|
||||||
|
prevDate.setDate(prevDate.getDate() - 1)
|
||||||
|
hasPreviousDay.value = await checkDateAvailability(prevDate.toISOString().split('T')[0])
|
||||||
|
|
||||||
|
// 检测后一天(不能超过今天)
|
||||||
|
const nextDate = new Date(date)
|
||||||
|
nextDate.setDate(nextDate.getDate() + 1)
|
||||||
|
const today = new Date().toISOString().split('T')[0]
|
||||||
|
if (nextDate.toISOString().split('T')[0] > today) {
|
||||||
|
hasNextDay.value = false
|
||||||
|
} else {
|
||||||
|
hasNextDay.value = await checkDateAvailability(nextDate.toISOString().split('T')[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
checkingDates.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// 监听showCalendar变化并自动保存到localStorage
|
||||||
|
watch(showCalendar, (newValue) => {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(CALENDAR_STATE_KEY, String(newValue))
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Failed to save calendar state:', error)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 监听日期变化,检测前后日期可用性
|
||||||
|
watch(currentDate, () => {
|
||||||
|
checkAdjacentDates()
|
||||||
|
}, { immediate: true })
|
||||||
|
|
||||||
|
// 监听路由变化,支持前进后退
|
||||||
|
watch(() => route.params.date, (newDate) => {
|
||||||
|
if (newDate && newDate !== currentDate.value) {
|
||||||
|
currentDate.value = newDate as string
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(() => route.query.mkt, (newMkt) => {
|
||||||
|
const mkt = (newMkt as string) || getDefaultMkt()
|
||||||
|
if (mkt !== currentMkt.value) {
|
||||||
|
currentMkt.value = mkt
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
// 格式化日期
|
// 格式化日期
|
||||||
const formatDate = (dateStr?: string) => {
|
const formatDate = (dateStr?: string) => {
|
||||||
@@ -245,15 +455,66 @@ const formatDate = (dateStr?: string) => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// 判断是否是今天
|
|
||||||
const isToday = computed(() => {
|
|
||||||
const today = new Date().toISOString().split('T')[0]
|
|
||||||
return currentDate.value === today
|
|
||||||
})
|
|
||||||
|
|
||||||
// 获取完整图片 URL
|
// 获取完整图片 URL
|
||||||
const getFullImageUrl = () => {
|
const getFullImageUrl = () => {
|
||||||
return bingPaperApi.getImageUrlByDate(currentDate.value, 'UHD', 'jpg')
|
return bingPaperApi.getImageUrlByDate(currentDate.value, 'UHD', 'jpg', currentMkt.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 预加载图片
|
||||||
|
const preloadImage = (url: string): Promise<void> => {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const img = new Image()
|
||||||
|
img.onload = () => resolve()
|
||||||
|
img.onerror = () => reject(new Error('Failed to load image'))
|
||||||
|
img.src = url
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 预加载图片和数据
|
||||||
|
const preloadImageAndData = async (date: string): Promise<void> => {
|
||||||
|
try {
|
||||||
|
// 并行预加载图片和数据
|
||||||
|
const imageUrl = bingPaperApi.getImageUrlByDate(date, 'UHD', 'jpg', currentMkt.value)
|
||||||
|
await Promise.all([
|
||||||
|
preloadImage(imageUrl),
|
||||||
|
bingPaperApi.getImageMetaByDate(date, currentMkt.value)
|
||||||
|
])
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Failed to preload image or data:', error)
|
||||||
|
// 即使预加载失败也继续
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 切换日期并带动画
|
||||||
|
const switchToDate = async (newDate: string) => {
|
||||||
|
if (imageTransitioning.value) return
|
||||||
|
|
||||||
|
imageTransitioning.value = true
|
||||||
|
|
||||||
|
// 1. 淡出当前图片的同时预加载新图片和数据
|
||||||
|
imageOpacity.value = 0
|
||||||
|
const preloadPromise = preloadImageAndData(newDate)
|
||||||
|
|
||||||
|
// 2. 等待淡出动画完成(500ms)
|
||||||
|
await Promise.all([
|
||||||
|
new Promise(resolve => setTimeout(resolve, 500)),
|
||||||
|
preloadPromise
|
||||||
|
])
|
||||||
|
|
||||||
|
// 3. 更新日期(此时图片和数据已经预加载完成)
|
||||||
|
currentDate.value = newDate
|
||||||
|
router.replace(`/image/${newDate}?mkt=${currentMkt.value}`)
|
||||||
|
|
||||||
|
// 4. 等待一个微任务,确保 DOM 更新
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 50))
|
||||||
|
|
||||||
|
// 5. 淡入新图片
|
||||||
|
imageOpacity.value = 1
|
||||||
|
|
||||||
|
// 6. 等待淡入完成
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 500))
|
||||||
|
|
||||||
|
imageTransitioning.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
// copyrightlink 现在是完整的 URL,无需额外处理
|
// copyrightlink 现在是完整的 URL,无需额外处理
|
||||||
@@ -264,68 +525,89 @@ const goBack = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 前一天
|
// 前一天
|
||||||
const previousDay = () => {
|
const previousDay = async () => {
|
||||||
if (navigating.value) return
|
if (navigating.value || !hasPreviousDay.value || imageTransitioning.value) return
|
||||||
|
|
||||||
navigating.value = true
|
navigating.value = true
|
||||||
const date = new Date(currentDate.value)
|
const date = new Date(currentDate.value)
|
||||||
date.setDate(date.getDate() - 1)
|
date.setDate(date.getDate() - 1)
|
||||||
const newDate = date.toISOString().split('T')[0]
|
const newDate = date.toISOString().split('T')[0]
|
||||||
|
|
||||||
currentDate.value = newDate
|
await switchToDate(newDate)
|
||||||
router.replace(`/image/${newDate}`)
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
navigating.value = false
|
navigating.value = false
|
||||||
}, 500)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 后一天
|
// 后一天
|
||||||
const nextDay = () => {
|
const nextDay = async () => {
|
||||||
if (navigating.value || isToday.value) return
|
if (navigating.value || !hasNextDay.value || imageTransitioning.value) return
|
||||||
|
|
||||||
navigating.value = true
|
navigating.value = true
|
||||||
const date = new Date(currentDate.value)
|
const date = new Date(currentDate.value)
|
||||||
date.setDate(date.getDate() + 1)
|
date.setDate(date.getDate() + 1)
|
||||||
const newDate = date.toISOString().split('T')[0]
|
const newDate = date.toISOString().split('T')[0]
|
||||||
|
|
||||||
currentDate.value = newDate
|
await switchToDate(newDate)
|
||||||
router.replace(`/image/${newDate}`)
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
navigating.value = false
|
navigating.value = false
|
||||||
}, 500)
|
}
|
||||||
|
|
||||||
|
// 切换日历状态(watch会自动保存)
|
||||||
|
const toggleCalendar = (state: boolean) => {
|
||||||
|
showCalendar.value = state
|
||||||
}
|
}
|
||||||
|
|
||||||
// 键盘导航
|
// 键盘导航
|
||||||
const handleKeydown = (e: KeyboardEvent) => {
|
const handleKeydown = (e: KeyboardEvent) => {
|
||||||
if (e.key === 'ArrowLeft') {
|
if (e.key === 'ArrowLeft' && hasPreviousDay.value) {
|
||||||
previousDay()
|
previousDay()
|
||||||
} else if (e.key === 'ArrowRight' && !isToday.value) {
|
} else if (e.key === 'ArrowRight' && hasNextDay.value) {
|
||||||
nextDay()
|
nextDay()
|
||||||
} else if (e.key === 'Escape') {
|
} else if (e.key === 'Escape') {
|
||||||
|
if (showCalendar.value) {
|
||||||
|
toggleCalendar(false)
|
||||||
|
} else {
|
||||||
goBack()
|
goBack()
|
||||||
|
}
|
||||||
} else if (e.key === 'i' || e.key === 'I') {
|
} else if (e.key === 'i' || e.key === 'I') {
|
||||||
showInfo.value = !showInfo.value
|
showInfo.value = !showInfo.value
|
||||||
|
} else if (e.key === 'c' || e.key === 'C') {
|
||||||
|
toggleCalendar(!showCalendar.value)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 添加键盘事件监听
|
// 窗口缩放处理
|
||||||
|
const handleResize = () => {
|
||||||
|
windowSize.value = {
|
||||||
|
width: window.innerWidth,
|
||||||
|
height: window.innerHeight
|
||||||
|
}
|
||||||
|
initPanelPosition()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 生命周期钩子
|
||||||
|
onMounted(() => {
|
||||||
if (typeof window !== 'undefined') {
|
if (typeof window !== 'undefined') {
|
||||||
window.addEventListener('keydown', handleKeydown)
|
window.addEventListener('keydown', handleKeydown)
|
||||||
window.addEventListener('resize', initPanelPosition)
|
window.addEventListener('resize', handleResize)
|
||||||
}
|
}
|
||||||
|
// 初始化浮窗位置
|
||||||
|
initPanelPosition()
|
||||||
|
})
|
||||||
|
|
||||||
// 清理
|
|
||||||
import { onUnmounted } from 'vue'
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
if (typeof window !== 'undefined') {
|
if (typeof window !== 'undefined') {
|
||||||
window.removeEventListener('keydown', handleKeydown)
|
window.removeEventListener('keydown', handleKeydown)
|
||||||
window.removeEventListener('resize', initPanelPosition)
|
window.removeEventListener('resize', handleResize)
|
||||||
document.removeEventListener('mousemove', onDrag)
|
document.removeEventListener('mousemove', onDrag)
|
||||||
document.removeEventListener('mouseup', stopDrag)
|
document.removeEventListener('mouseup', stopDrag)
|
||||||
document.removeEventListener('touchmove', onDrag)
|
document.removeEventListener('touchmove', onDrag)
|
||||||
document.removeEventListener('touchend', stopDrag)
|
document.removeEventListener('touchend', stopDrag)
|
||||||
|
|
||||||
|
// 清理动画帧
|
||||||
|
if (animationFrameId !== null) {
|
||||||
|
cancelAnimationFrame(animationFrameId)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -37,15 +37,34 @@ export default defineConfig(({ mode }) => {
|
|||||||
// 入口文件名
|
// 入口文件名
|
||||||
entryFileNames: 'assets/[name]-[hash].js',
|
entryFileNames: 'assets/[name]-[hash].js',
|
||||||
// 手动分割代码
|
// 手动分割代码
|
||||||
manualChunks: {
|
manualChunks: (id) => {
|
||||||
// 将 Vue 相关代码单独打包
|
// 将 node_modules 中的依赖分割成不同的 chunk
|
||||||
'vue-vendor': ['vue', 'vue-router'],
|
if (id.includes('node_modules')) {
|
||||||
// 将 UI 组件库单独打包(如果有的话)
|
// Vue 核心库
|
||||||
// 'ui-vendor': ['其他UI库']
|
if (id.includes('vue') || id.includes('vue-router')) {
|
||||||
|
return 'vue-vendor'
|
||||||
|
}
|
||||||
|
// Radix UI / Reka UI 组件库
|
||||||
|
if (id.includes('reka-ui') || id.includes('@vueuse')) {
|
||||||
|
return 'ui-vendor'
|
||||||
|
}
|
||||||
|
// Lucide 图标库
|
||||||
|
if (id.includes('lucide-vue-next')) {
|
||||||
|
return 'icons'
|
||||||
|
}
|
||||||
|
// lunar-javascript 农历库
|
||||||
|
if (id.includes('lunar-javascript')) {
|
||||||
|
return 'lunar'
|
||||||
|
}
|
||||||
|
// 其他 node_modules 依赖
|
||||||
|
return 'vendor'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
// 增加 chunk 大小警告限制
|
||||||
|
chunkSizeWarningLimit: 1000
|
||||||
|
},
|
||||||
// 开发服务器配置
|
// 开发服务器配置
|
||||||
server: {
|
server: {
|
||||||
port: 5173,
|
port: 5173,
|
||||||
|
|||||||
Reference in New Issue
Block a user