前端公共部分开发完成,支持图片展示功能

This commit is contained in:
2026-01-27 12:56:17 +08:00
parent 911e58c29b
commit be0bcb4d51
25 changed files with 3252 additions and 19 deletions

1
.gitignore vendored
View File

@@ -55,3 +55,4 @@ desktop.ini
/req.txt
/BingPaper
/temp/
/web/

6
webapp/.env Normal file
View File

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

6
webapp/.env.development Normal file
View File

@@ -0,0 +1,6 @@
# 开发环境配置
# API 基础 URL - 开发环境直接使用完整的后端服务地址(不使用代理)
VITE_API_BASE_URL=http://localhost:8080/api/v1
# 开发环境使用完整URL不需要代理
VITE_API_MODE=direct

6
webapp/.env.production Normal file
View File

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

11
webapp/.gitignore vendored
View File

@@ -12,6 +12,17 @@ dist
dist-ssr
*.local
# Build output (to parent directory)
../web
# TypeScript compiled files (should not be generated in Vite projects)
src/**/*.js
src/**/*.js.map
# Environment variables (keep .env for defaults, ignore local overrides)
.env.local
.env.*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json

288
webapp/BUILD.md Normal file
View File

@@ -0,0 +1,288 @@
# BingPaper WebApp 构建说明
## 构建配置优化
本项目已优化构建配置,支持自定义后端路径和自动输出到上级目录的 `web` 文件夹。
## 环境配置
### 环境变量
项目支持通过环境变量配置后端 API 地址:
- **开发环境** (`.env.development`):使用完整的后端服务器地址(直连,不使用代理)
- **生产环境** (`.env.production`):使用相对路径 `/api/v1` 访问后端
- **默认配置** (`.env`):通用配置
### 开发环境 vs 生产环境
#### 开发环境npm run dev
- 直接使用完整的后端 API 地址:`http://localhost:8080/api/v1`
- 不使用代理,前端直接请求后端服务
- 需要确保后端服务器支持 CORS 跨域请求
- 优点:配置简单,调试方便,可以清楚看到实际请求
#### 生产环境npm run build
- 使用相对路径:`/api/v1`
- 前后端部署在同一域名下,无跨域问题
- Go 服务器同时提供静态文件和 API 服务
### 自定义后端路径
可以通过修改环境变量 `VITE_API_BASE_URL` 来自定义后端 API 路径:
```bash
# 开发环境 (.env.development)
VITE_API_BASE_URL=http://localhost:8080/api/v1
# 或使用其他端口/域名
VITE_API_BASE_URL=http://192.168.1.100:8080/api/v1
VITE_API_BASE_URL=https://api.example.com/api/v1
# 生产环境 (.env.production)
VITE_API_BASE_URL=/api/v1
# 或自定义路径
VITE_API_BASE_URL=/custom/api/path
```
## 构建命令
### 开发环境
```bash
# 启动开发服务器(使用完整后端 URL
npm run dev
```
开发环境会直接请求配置的后端服务器地址,无需代理。
**注意**:需要确保后端服务器配置了 CORS允许来自 `http://localhost:5173` 的请求。
Go 后端 CORS 配置示例(使用 Gin
```go
import "github.com/gin-contrib/cors"
r := gin.Default()
r.Use(cors.New(cors.Config{
AllowOrigins: []string{"http://localhost:5173"},
AllowMethods: []string{"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"},
AllowHeaders: []string{"Origin", "Content-Type", "Authorization"},
AllowCredentials: true,
}))
```
### 生产环境构建
```bash
# 标准构建(生产模式)
npm run build
# 显式生产环境构建
npm run build:prod
# 开发模式构建(包含 sourcemap
npm run build:dev
```
### 清理构建
```bash
# 清理输出目录
npm run clean
```
## 输出目录
构建产物会自动输出到项目上级目录的 `web` 文件夹:
```
go-project/
├── webapp/ # Vue 项目源码
│ ├── src/
│ ├── package.json
│ └── vite.config.ts
├── web/ # 构建输出目录(自动生成)
│ ├── index.html
│ ├── assets/
│ └── ...
└── main.go # Go 主程序
```
## Go 服务器配置
Go 服务器需要配置静态文件服务来访问 `web` 目录:
```go
// 示例Gin 框架配置
package main
import (
"github.com/gin-gonic/gin"
"github.com/gin-contrib/cors"
)
func main() {
r := gin.Default()
// 开发环境:配置 CORS
if gin.Mode() == gin.DebugMode {
r.Use(cors.New(cors.Config{
AllowOrigins: []string{"http://localhost:5173"},
AllowMethods: []string{"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"},
AllowHeaders: []string{"Origin", "Content-Type", "Authorization"},
AllowCredentials: true,
}))
}
// API 路由
api := r.Group("/api/v1")
{
// ... 你的 API 路由
}
// 静态文件服务(生产环境)
r.Static("/assets", "./web/assets")
r.StaticFile("/", "./web/index.html")
// SPA fallback
r.NoRoute(func(c *gin.Context) {
c.File("./web/index.html")
})
r.Run(":8080")
}
```
## API 使用示例
项目提供了完整的 TypeScript API 客户端:
```typescript
import { bingPaperApi } from '@/lib/api-service'
// 获取今日图片元数据
const todayMeta = await bingPaperApi.getTodayImageMeta()
// 获取图片列表
const images = await bingPaperApi.getImages({ limit: 10 })
// 管理员登录
const token = await bingPaperApi.login({ password: 'admin123' })
bingPaperApi.setAuthToken(token.token)
```
## 项目结构
```
src/
├── lib/
│ ├── api-config.ts # API 配置管理
│ ├── api-types.ts # TypeScript 类型定义
│ ├── api-service.ts # API 服务封装
│ ├── http-client.ts # HTTP 客户端
│ └── utils.ts # 工具函数
├── components/ # Vue 组件
├── views/ # 页面组件
│ ├── Home.vue # 首页画廊
│ └── ImageView.vue # 图片查看
├── composables/ # 组合式函数
│ └── useImages.ts # 图片数据管理
├── router/ # 路由配置
│ └── index.ts
├── App.vue
└── main.ts
```
## 部署注意事项
1. **构建顺序**:确保在 Go 服务启动前完成前端构建
2. **路径配置**Go 服务器的 API 路径应与前端配置的 `VITE_API_BASE_URL` 一致
3. **静态文件**Go 服务器需要正确配置静态文件服务路径
4. **路由处理**:对于 SPA 应用,需要配置 fallback 到 `index.html`
5. **CORS 配置**:开发环境需要配置 CORS生产环境不需要同域
## 故障排除
### API 请求失败(开发环境)
1. 检查环境变量配置是否正确
2. 确认 Go 服务器已启动在 `http://localhost:8080`
3. 检查 Go 服务器是否配置了 CORS
4. 在浏览器控制台查看具体错误信息
### CORS 错误
如果看到类似以下错误:
```
Access to fetch at 'http://localhost:8080/api/v1/...' from origin 'http://localhost:5173'
has been blocked by CORS policy
```
解决方案:
1. 在 Go 服务器添加 CORS 中间件
2. 或者在 vite.config.ts 中添加代理配置(不推荐,因为会隐藏实际的请求路径)
### 构建失败
1. 清理 `node_modules` 并重新安装依赖
2. 检查 TypeScript 类型错误
3. 确认输出目录权限
### 静态资源加载失败
1. 检查 Go 服务器静态文件配置
2. 确认构建产物的路径结构
3. 检查 Vite 构建配置中的 `base``assetsDir`
## 开发工作流
### 典型的开发流程
1. **启动后端服务**
```bash
cd .. # 回到 Go 项目根目录
go run main.go
```
2. **启动前端开发服务器**
```bash
cd webapp
npm run dev
```
3. **访问应用**
```
http://localhost:5173/
```
4. **开发完成后构建**
```bash
npm run build
```
5. **测试生产构建**
```bash
# 停止开发服务器,启动 Go 服务器
cd ..
go run main.go
# 访问 http://localhost:8080/
```
## 配置对比
| 配置项 | 开发环境 | 生产环境 |
|--------|----------|----------|
| API Base URL | `http://localhost:8080/api/v1` | `/api/v1` |
| 请求方式 | 直接请求 | 相对路径 |
| CORS | 需要 | 不需要 |
| 服务器 | 前后端分离 | 同一服务器 |
| 端口 | 前端 5173 + 后端 8080 | 8080 |
## 最佳实践
1. **开发环境**使用完整 URL便于调试和查看实际请求
2. **生产环境**使用相对路径,简化部署
3. 保持 `.env.development` 和 `.env.production` 文件同步更新
4. 在 Go 服务器中使用环境变量区分开发/生产模式
5. 定期测试生产构建,确保配置正确

255
webapp/CORS_CONFIG.md Normal file
View File

@@ -0,0 +1,255 @@
# Go 后端 CORS 配置示例
## 问题说明
开发环境中,前端运行在 `http://localhost:5173`,后端运行在 `http://localhost:8080`
由于跨域限制,需要在后端配置 CORS 才能正常访问 API。
## 使用 Gin 框架
### 1. 安装 CORS 中间件
```bash
go get github.com/gin-contrib/cors
```
### 2. 配置 CORS
```go
package main
import (
"github.com/gin-gonic/gin"
"github.com/gin-contrib/cors"
"time"
)
func main() {
r := gin.Default()
// 开发环境:配置 CORS
if gin.Mode() == gin.DebugMode {
config := cors.Config{
AllowOrigins: []string{"http://localhost:5173"},
AllowMethods: []string{"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"},
AllowHeaders: []string{"Origin", "Content-Type", "Accept", "Authorization"},
ExposeHeaders: []string{"Content-Length"},
AllowCredentials: true,
MaxAge: 12 * time.Hour,
}
r.Use(cors.New(config))
}
// API 路由
api := r.Group("/api/v1")
{
// 图片相关
api.GET("/images", getImages)
api.GET("/image/today/meta", getTodayImageMeta)
api.GET("/image/date/:date/meta", getImageMetaByDate)
api.GET("/image/random/meta", getRandomImageMeta)
// 管理员相关
api.POST("/admin/login", adminLogin)
// ... 其他路由
}
// 生产环境:静态文件服务
if gin.Mode() == gin.ReleaseMode {
r.Static("/assets", "./web/assets")
r.StaticFile("/", "./web/index.html")
// SPA fallback
r.NoRoute(func(c *gin.Context) {
c.File("./web/index.html")
})
}
r.Run(":8080")
}
```
### 3. 更灵活的 CORS 配置
```go
// 根据环境变量动态配置
func setupCORS(r *gin.Engine) {
allowOrigins := os.Getenv("ALLOW_ORIGINS")
if allowOrigins == "" {
allowOrigins = "http://localhost:5173"
}
origins := strings.Split(allowOrigins, ",")
config := cors.Config{
AllowOrigins: origins,
AllowMethods: []string{"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"},
AllowHeaders: []string{"Origin", "Content-Type", "Accept", "Authorization"},
ExposeHeaders: []string{"Content-Length"},
AllowCredentials: true,
MaxAge: 12 * time.Hour,
}
r.Use(cors.New(config))
}
func main() {
r := gin.Default()
// 只在开发环境启用 CORS
if gin.Mode() == gin.DebugMode {
setupCORS(r)
}
// ... 其他配置
}
```
## 使用标准库
如果不使用 Gin 框架,可以手动实现 CORS
```go
package main
import (
"net/http"
)
func corsMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// 设置 CORS 头
w.Header().Set("Access-Control-Allow-Origin", "http://localhost:5173")
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Origin, Content-Type, Accept, Authorization")
w.Header().Set("Access-Control-Allow-Credentials", "true")
w.Header().Set("Access-Control-Max-Age", "43200") // 12 hours
// 处理预检请求
if r.Method == "OPTIONS" {
w.WriteHeader(http.StatusOK)
return
}
next.ServeHTTP(w, r)
})
}
func main() {
mux := http.NewServeMux()
// 注册路由
mux.HandleFunc("/api/v1/images", getImages)
// ... 其他路由
// 包装 CORS 中间件
handler := corsMiddleware(mux)
http.ListenAndServe(":8080", handler)
}
```
## 配置文件方式
可以通过配置文件管理 CORS 设置:
```yaml
# config.yaml
server:
port: 8080
mode: debug # debug 或 release
cors:
enabled: true
allow_origins:
- http://localhost:5173
- http://127.0.0.1:5173
allow_methods:
- GET
- POST
- PUT
- PATCH
- DELETE
- OPTIONS
allow_headers:
- Origin
- Content-Type
- Accept
- Authorization
allow_credentials: true
max_age: 43200
```
```go
type CORSConfig struct {
Enabled bool `yaml:"enabled"`
AllowOrigins []string `yaml:"allow_origins"`
AllowMethods []string `yaml:"allow_methods"`
AllowHeaders []string `yaml:"allow_headers"`
AllowCredentials bool `yaml:"allow_credentials"`
MaxAge int `yaml:"max_age"`
}
func setupCORSFromConfig(r *gin.Engine, cfg *CORSConfig) {
if !cfg.Enabled {
return
}
config := cors.Config{
AllowOrigins: cfg.AllowOrigins,
AllowMethods: cfg.AllowMethods,
AllowHeaders: cfg.AllowHeaders,
AllowCredentials: cfg.AllowCredentials,
MaxAge: time.Duration(cfg.MaxAge) * time.Second,
}
r.Use(cors.New(config))
}
```
## 安全建议
1. **生产环境不要启用 CORS**:生产环境前后端在同一域名下,不需要 CORS
2. **限制允许的源**:不要使用 `*`,明确指定允许的域名
3. **使用环境变量**:允许的源应该通过环境变量配置,不要硬编码
4. **限制方法和头**:只允许必要的 HTTP 方法和请求头
5. **注意凭证**:如果使用 `AllowCredentials: true`,不能使用通配符源
## 测试 CORS 配置
可以使用 curl 测试 CORS
```bash
# 测试预检请求
curl -X OPTIONS http://localhost:8080/api/v1/images \
-H "Origin: http://localhost:5173" \
-H "Access-Control-Request-Method: GET" \
-v
# 测试实际请求
curl -X GET http://localhost:8080/api/v1/images \
-H "Origin: http://localhost:5173" \
-v
```
应该能看到响应头中包含:
```
Access-Control-Allow-Origin: http://localhost:5173
Access-Control-Allow-Methods: GET, POST, PUT, PATCH, DELETE, OPTIONS
Access-Control-Allow-Headers: Origin, Content-Type, Accept, Authorization
Access-Control-Allow-Credentials: true
```
## 常见问题
### Q: 为什么要区分开发和生产环境?
A: 生产环境前后端部署在同一域名下,不需要 CORS。只在开发环境需要。
### Q: 可以使用代理吗?
A: 可以在 Vite 中配置代理,但不推荐。直接配置 CORS 更接近生产环境,便于发现问题。
### Q: 如何处理认证?
A: 如果使用 Cookie 或需要发送凭证,必须设置 `AllowCredentials: true` 和明确的源。
### Q: 预检请求是什么?
A: 浏览器在某些跨域请求前会发送 OPTIONS 请求,询问服务器是否允许该跨域请求。

View File

@@ -1,5 +1,122 @@
# Vue 3 + TypeScript + Vite
# BingPaper WebApp
This template should help get you started developing with Vue 3 and TypeScript in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
BingPaper 的前端 Web 应用,使用 Vue 3 + TypeScript + Vite 构建。
Learn more about the recommended Project Setup and IDE Support in the [Vue Docs TypeScript Guide](https://vuejs.org/guide/typescript/overview.html#project-setup).
## 特性
- ✨ Vue 3 组合式 API
- 🎨 Tailwind CSS + shadcn-vue 组件库
- 📦 TypeScript 类型支持
- 🔧 完整的 API 客户端封装
- 🚀 优化的构建配置
- 🌐 支持自定义后端路径
- 📁 自动输出到上级目录的 web 文件夹
## 快速开始
### 安装依赖
```bash
npm install
```
### 开发环境
```bash
npm run dev
```
开发服务器会在 `http://localhost:5173` 启动,并自动代理 `/api` 请求到后端服务器。
### 构建生产版本
```bash
npm run build
```
构建产物会自动输出到项目上级目录的 `web` 文件夹,供 Go 服务器使用。
### 预览构建结果
```bash
npm run preview
```
## 构建配置
### 环境变量
项目支持通过环境变量配置后端 API 路径:
- `.env` - 默认配置
- `.env.development` - 开发环境配置
- `.env.production` - 生产环境配置
修改 `VITE_API_BASE_URL` 来自定义后端 API 路径。
### 输出目录
构建产物输出到 `../web/`,目录结构:
```
web/
├── index.html
├── assets/
│ ├── index-[hash].js
│ └── index-[hash].css
└── vite.svg
```
## API 使用
项目提供了完整的 TypeScript API 客户端:
```typescript
import { bingPaperApi } from '@/lib/api-service'
// 获取今日图片
const meta = await bingPaperApi.getTodayImageMeta()
// 获取图片列表
const images = await bingPaperApi.getImages({ limit: 10 })
```
详细的 API 使用示例请参阅 [API_EXAMPLES.md](./API_EXAMPLES.md)
## 构建说明
详细的构建配置和部署说明请参阅 [BUILD.md](./BUILD.md)
## 项目结构
```
src/
├── lib/ # 核心库
│ ├── api-config.ts # API 配置
│ ├── api-types.ts # TypeScript 类型定义
│ ├── api-service.ts # API 服务封装
│ ├── http-client.ts # HTTP 客户端
│ └── utils.ts # 工具函数
├── components/ # Vue 组件
│ └── ui/ # UI 组件库
├── views/ # 页面视图
├── assets/ # 静态资源
├── App.vue # 根组件
└── main.ts # 入口文件
```
## 技术栈
- [Vue 3](https://vuejs.org/) - 渐进式 JavaScript 框架
- [TypeScript](https://www.typescriptlang.org/) - 类型安全的 JavaScript
- [Vite](https://vitejs.dev/) - 下一代前端构建工具
- [Tailwind CSS](https://tailwindcss.com/) - 实用优先的 CSS 框架
- [shadcn-vue](https://www.shadcn-vue.com/) - 高质量的 Vue 组件库
## IDE 支持
推荐使用 [VS Code](https://code.visualstudio.com/) + [Vue - Official](https://marketplace.visualstudio.com/items?itemName=Vue.volar) 扩展。
## License
MIT

201
webapp/USAGE.md Normal file
View File

@@ -0,0 +1,201 @@
# 必应每日一图 - 使用说明
## ✨ 功能特性
### 首页(画廊视图)
1. **顶部大图区域**
- 全屏展示今日必应图片
- 显示图片标题、版权信息和日期
- 渐变遮罩效果,突出文字内容
- 悬停时内容向上浮动动画
2. **操作按钮**
- **查看大图**:跳转到全屏图片查看页面
- **了解更多**:打开必应相关链接(基于 quiz 字段)
3. **历史图片画廊**
- 网格布局展示历史图片响应式手机1列、平板2列、桌面3列
- 鼠标悬停显示元数据信息
- 卡片放大和图片缩放动画效果
- 点击任意图片打开详情页
4. **无限滚动**
- 支持"加载更多"按钮
- 自动检测是否还有更多图片
- 加载状态提示
### 图片查看页面
1. **全屏展示**
- 以最高清晰度UHD展示图片
- 黑色背景,图片居中显示
- 顶部和底部渐变工具栏
2. **信息悬浮层**(类似 Windows 聚焦壁纸)
- 半透明背景,毛玻璃效果
- 显示图片标题、版权信息
- "了解更多信息"链接到必应相关页面
- 可以通过按钮或键盘切换显示/隐藏
3. **日期导航**
- **前一天** / **后一天** 按钮
- 支持键盘方向键导航(← / →)
- 今天的图片禁用"后一天"按钮
- 导航时有节流保护
4. **键盘快捷键**
- `←` : 前一天
- `→` : 后一天
- `Esc` : 返回首页
- `I` : 切换信息显示
## 🎨 设计亮点
### 视觉效果
- 深色主题,突出图片内容
- 渐变背景和遮罩层
- 毛玻璃效果backdrop-blur
- 平滑的过渡动画
- 响应式设计,适配各种屏幕
### 交互体验
- 悬停效果(卡片放大、图片缩放)
- 加载状态提示
- 按钮禁用状态
- 键盘快捷键支持
- 流畅的页面切换
## 🚀 技术实现
### 组件结构
```
src/
├── views/
│ ├── Home.vue # 首页画廊视图
│ └── ImageView.vue # 图片查看页面
├── composables/
│ └── useImages.ts # 数据获取逻辑
├── router/
│ └── index.ts # 路由配置
└── lib/
├── api-service.ts # API 服务
├── api-config.ts # API 配置
├── api-types.ts # 类型定义
└── http-client.ts # HTTP 客户端
```
### 路由配置
- `/` - 首页画廊视图
- `/image/:date` - 图片查看页面(动态路由)
### API 集成
使用封装好的 API 服务:
- `getTodayImageMeta()` - 获取今日图片元数据
- `getImages({ limit })` - 获取图片列表
- `getImageMetaByDate(date)` - 获取指定日期图片
- `getTodayImageUrl()` - 获取今日图片 URL
- `getImageUrlByDate(date, variant)` - 获取指定日期图片 URL
### 数据管理
使用 Vue Composables 模式:
- `useTodayImage()` - 管理今日图片数据
- `useImageList()` - 管理图片列表(支持分页)
- `useImageByDate()` - 管理特定日期图片数据
## 📱 响应式设计
### 断点
- 手机:< 640px1列
- 平板640px - 1024px2列
- 桌面:> 1024px3列
### 适配特性
- 响应式网格布局
- 动态字体大小
- 移动端优化的按钮文字
- 触摸友好的交互
## 🎯 使用流程
1. **访问首页**
```
http://localhost:5173/
```
2. **浏览今日图片**
- 顶部大图展示当天必应图片
- 查看标题和版权信息
3. **点击"了解更多"**
- 自动拼接完整的必应 URL
- 在新标签页打开
4. **浏览历史图片**
- 向下滚动查看历史图片网格
- 鼠标悬停查看详细信息
- 点击"加载更多"获取更多图片
5. **全屏查看图片**
- 点击任意图片进入全屏模式
- 使用按钮或键盘导航
- 按 `Esc` 返回首页
## 🔧 开发
### 启动开发服务器
```bash
npm run dev
```
### 构建生产版本
```bash
npm run build
```
### 预览构建结果
```bash
npm run preview
```
## 📝 注意事项
1. **API 配置**
- 确保后端 API 服务正在运行
- 开发环境会自动代理 `/api` 请求到 `http://localhost:8080`
2. **图片分辨率**
- 首页顶部大图UHD
- 画廊缩略图1920x1080
- 全屏查看UHD
3. **必应链接**
- quiz 字段需要拼接 `https://www.bing.com` 前缀
- 例如:`/search?q=...` → `https://www.bing.com/search?q=...`
4. **日期格式**
- 使用 ISO 格式:`YYYY-MM-DD`
- 例如:`2024-01-27`
## 🎨 自定义样式
可以通过修改 Tailwind CSS 类来调整样式:
- 渐变颜色:`bg-gradient-to-*`
- 透明度:`bg-black/80`
- 模糊效果:`backdrop-blur-*`
- 动画:`transition-*`、`animate-*`
## 🐛 已知问题
- 需要确保 API 返回正确的图片元数据
- 图片加载依赖网络速度
- 大图可能需要一些时间加载
## 🚀 未来优化
- [ ] 添加图片预加载
- [ ] 支持手势滑动切换(移动端)
- [ ] 添加图片下载功能
- [ ] 支持收藏功能
- [ ] 添加搜索和筛选
- [ ] 支持暗色/亮色主题切换

943
webapp/doc/swagger.json Normal file
View File

@@ -0,0 +1,943 @@
{
"swagger": "2.0",
"info": {
"description": "必应每日一图抓取、存储、管理与公共 API 服务。",
"title": "BingPaper API",
"contact": {},
"version": "1.0"
},
"host": "localhost:8080",
"basePath": "/api/v1",
"paths": {
"/admin/cleanup": {
"post": {
"security": [
{
"BearerAuth": []
}
],
"description": "立即启动旧图片清理任务",
"produces": [
"application/json"
],
"tags": [
"admin"
],
"summary": "手动触发清理",
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
}
}
}
},
"/admin/config": {
"get": {
"security": [
{
"BearerAuth": []
}
],
"description": "获取服务的当前运行配置 (脱敏)",
"produces": [
"application/json"
],
"tags": [
"admin"
],
"summary": "获取当前配置",
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/config.Config"
}
}
}
},
"put": {
"security": [
{
"BearerAuth": []
}
],
"description": "在线更新服务配置并保存",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"admin"
],
"summary": "更新配置",
"parameters": [
{
"description": "配置对象",
"name": "request",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/config.Config"
}
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/config.Config"
}
}
}
}
},
"/admin/fetch": {
"post": {
"security": [
{
"BearerAuth": []
}
],
"description": "立即启动抓取 Bing 任务",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"admin"
],
"summary": "手动触发抓取",
"parameters": [
{
"description": "抓取天数",
"name": "request",
"in": "body",
"schema": {
"$ref": "#/definitions/handlers.ManualFetchRequest"
}
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
}
}
}
},
"/admin/login": {
"post": {
"description": "使用密码登录并获取临时 Token",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"admin"
],
"summary": "管理员登录",
"parameters": [
{
"description": "登录请求",
"name": "request",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/handlers.LoginRequest"
}
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/model.Token"
}
},
"401": {
"description": "Unauthorized",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
}
}
}
},
"/admin/password": {
"post": {
"security": [
{
"BearerAuth": []
}
],
"description": "验证旧密码并设置新密码,自动更新配置文件",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"admin"
],
"summary": "修改管理员密码",
"parameters": [
{
"description": "修改密码请求",
"name": "request",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/handlers.ChangePasswordRequest"
}
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"400": {
"description": "Bad Request",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"401": {
"description": "Unauthorized",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
}
}
}
},
"/admin/tokens": {
"get": {
"security": [
{
"BearerAuth": []
}
],
"description": "获取所有已创建的 API Token 列表",
"produces": [
"application/json"
],
"tags": [
"admin"
],
"summary": "获取 Token 列表",
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "array",
"items": {
"$ref": "#/definitions/model.Token"
}
}
}
}
},
"post": {
"security": [
{
"BearerAuth": []
}
],
"description": "创建一个新的 API Token",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"admin"
],
"summary": "创建 Token",
"parameters": [
{
"description": "创建请求",
"name": "request",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/handlers.CreateTokenRequest"
}
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/model.Token"
}
}
}
}
},
"/admin/tokens/{id}": {
"delete": {
"security": [
{
"BearerAuth": []
}
],
"description": "永久删除指定的 API Token",
"tags": [
"admin"
],
"summary": "删除 Token",
"parameters": [
{
"type": "integer",
"description": "Token ID",
"name": "id",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
}
}
},
"patch": {
"security": [
{
"BearerAuth": []
}
],
"description": "启用或禁用指定的 API Token",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"admin"
],
"summary": "更新 Token 状态",
"parameters": [
{
"type": "integer",
"description": "Token ID",
"name": "id",
"in": "path",
"required": true
},
{
"description": "更新请求",
"name": "request",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/handlers.UpdateTokenRequest"
}
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
}
}
}
},
"/image/date/{date}": {
"get": {
"description": "根据日期返回图片流或重定向 (yyyy-mm-dd)",
"produces": [
"image/jpeg"
],
"tags": [
"image"
],
"summary": "获取指定日期图片",
"parameters": [
{
"type": "string",
"description": "日期 (yyyy-mm-dd)",
"name": "date",
"in": "path",
"required": true
},
{
"type": "string",
"default": "UHD",
"description": "分辨率",
"name": "variant",
"in": "query"
},
{
"type": "string",
"default": "jpg",
"description": "格式",
"name": "format",
"in": "query"
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "file"
}
}
}
}
},
"/image/date/{date}/meta": {
"get": {
"description": "根据日期获取图片元数据 (yyyy-mm-dd)",
"produces": [
"application/json"
],
"tags": [
"image"
],
"summary": "获取指定日期图片元数据",
"parameters": [
{
"type": "string",
"description": "日期 (yyyy-mm-dd)",
"name": "date",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "object",
"additionalProperties": true
}
}
}
}
},
"/image/random": {
"get": {
"description": "随机返回一张已抓取的图片流或重定向",
"produces": [
"image/jpeg"
],
"tags": [
"image"
],
"summary": "获取随机图片",
"parameters": [
{
"type": "string",
"default": "UHD",
"description": "分辨率",
"name": "variant",
"in": "query"
},
{
"type": "string",
"default": "jpg",
"description": "格式",
"name": "format",
"in": "query"
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "file"
}
}
}
}
},
"/image/random/meta": {
"get": {
"description": "随机获取一张已抓取图片的元数据",
"produces": [
"application/json"
],
"tags": [
"image"
],
"summary": "获取随机图片元数据",
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "object",
"additionalProperties": true
}
}
}
}
},
"/image/today": {
"get": {
"description": "根据参数返回今日必应图片流或重定向",
"produces": [
"image/jpeg"
],
"tags": [
"image"
],
"summary": "获取今日图片",
"parameters": [
{
"type": "string",
"default": "UHD",
"description": "分辨率 (UHD, 1920x1080, 1366x768)",
"name": "variant",
"in": "query"
},
{
"type": "string",
"default": "jpg",
"description": "格式 (jpg)",
"name": "format",
"in": "query"
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "file"
}
}
}
}
},
"/image/today/meta": {
"get": {
"description": "获取今日必应图片的标题、版权等元数据",
"produces": [
"application/json"
],
"tags": [
"image"
],
"summary": "获取今日图片元数据",
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "object",
"additionalProperties": true
}
}
}
}
},
"/images": {
"get": {
"description": "分页获取已抓取的图片元数据列表",
"produces": [
"application/json"
],
"tags": [
"image"
],
"summary": "获取图片列表",
"parameters": [
{
"type": "integer",
"default": 30,
"description": "限制数量",
"name": "limit",
"in": "query"
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "array",
"items": {
"type": "object",
"additionalProperties": true
}
}
}
}
}
}
},
"definitions": {
"config.APIConfig": {
"type": "object",
"properties": {
"mode": {
"description": "local | redirect",
"type": "string"
}
}
},
"config.AdminConfig": {
"type": "object",
"properties": {
"passwordBcrypt": {
"type": "string"
}
}
},
"config.Config": {
"type": "object",
"properties": {
"admin": {
"$ref": "#/definitions/config.AdminConfig"
},
"api": {
"$ref": "#/definitions/config.APIConfig"
},
"cron": {
"$ref": "#/definitions/config.CronConfig"
},
"db": {
"$ref": "#/definitions/config.DBConfig"
},
"feature": {
"$ref": "#/definitions/config.FeatureConfig"
},
"log": {
"$ref": "#/definitions/config.LogConfig"
},
"retention": {
"$ref": "#/definitions/config.RetentionConfig"
},
"server": {
"$ref": "#/definitions/config.ServerConfig"
},
"storage": {
"$ref": "#/definitions/config.StorageConfig"
},
"token": {
"$ref": "#/definitions/config.TokenConfig"
},
"web": {
"$ref": "#/definitions/config.WebConfig"
}
}
},
"config.CronConfig": {
"type": "object",
"properties": {
"dailySpec": {
"type": "string"
},
"enabled": {
"type": "boolean"
}
}
},
"config.DBConfig": {
"type": "object",
"properties": {
"dsn": {
"type": "string"
},
"type": {
"description": "sqlite/mysql/postgres",
"type": "string"
}
}
},
"config.FeatureConfig": {
"type": "object",
"properties": {
"writeDailyFiles": {
"type": "boolean"
}
}
},
"config.LocalConfig": {
"type": "object",
"properties": {
"root": {
"type": "string"
}
}
},
"config.LogConfig": {
"type": "object",
"properties": {
"compress": {
"description": "是否压缩旧日志文件",
"type": "boolean"
},
"dbfilename": {
"description": "数据库日志文件名",
"type": "string"
},
"dblogLevel": {
"description": "数据库日志级别: debug, info, warn, error",
"type": "string"
},
"filename": {
"description": "业务日志文件名",
"type": "string"
},
"level": {
"type": "string"
},
"logConsole": {
"description": "是否同时输出到控制台",
"type": "boolean"
},
"maxAge": {
"description": "保留旧日志文件最大天数",
"type": "integer"
},
"maxBackups": {
"description": "保留旧日志文件最大个数",
"type": "integer"
},
"maxSize": {
"description": "每个日志文件最大大小 (MB)",
"type": "integer"
},
"showDBLog": {
"description": "是否在控制台显示数据库日志",
"type": "boolean"
}
}
},
"config.RetentionConfig": {
"type": "object",
"properties": {
"days": {
"type": "integer"
}
}
},
"config.S3Config": {
"type": "object",
"properties": {
"accessKey": {
"type": "string"
},
"bucket": {
"type": "string"
},
"endpoint": {
"type": "string"
},
"forcePathStyle": {
"type": "boolean"
},
"publicURLPrefix": {
"type": "string"
},
"region": {
"type": "string"
},
"secretKey": {
"type": "string"
}
}
},
"config.ServerConfig": {
"type": "object",
"properties": {
"baseURL": {
"type": "string"
},
"port": {
"type": "integer"
}
}
},
"config.StorageConfig": {
"type": "object",
"properties": {
"local": {
"$ref": "#/definitions/config.LocalConfig"
},
"s3": {
"$ref": "#/definitions/config.S3Config"
},
"type": {
"description": "local/s3/webdav",
"type": "string"
},
"webDAV": {
"$ref": "#/definitions/config.WebDAVConfig"
}
}
},
"config.TokenConfig": {
"type": "object",
"properties": {
"defaultTTL": {
"type": "string"
}
}
},
"config.WebConfig": {
"type": "object",
"properties": {
"path": {
"type": "string"
}
}
},
"config.WebDAVConfig": {
"type": "object",
"properties": {
"password": {
"type": "string"
},
"publicURLPrefix": {
"type": "string"
},
"url": {
"type": "string"
},
"username": {
"type": "string"
}
}
},
"handlers.ChangePasswordRequest": {
"type": "object",
"required": [
"new_password",
"old_password"
],
"properties": {
"new_password": {
"type": "string"
},
"old_password": {
"type": "string"
}
}
},
"handlers.CreateTokenRequest": {
"type": "object",
"required": [
"name"
],
"properties": {
"expires_at": {
"description": "optional",
"type": "string"
},
"expires_in": {
"description": "optional, e.g. 168h",
"type": "string"
},
"name": {
"type": "string"
}
}
},
"handlers.LoginRequest": {
"type": "object",
"required": [
"password"
],
"properties": {
"password": {
"type": "string"
}
}
},
"handlers.ManualFetchRequest": {
"type": "object",
"properties": {
"n": {
"type": "integer"
}
}
},
"handlers.UpdateTokenRequest": {
"type": "object",
"properties": {
"disabled": {
"type": "boolean"
}
}
},
"model.Token": {
"type": "object",
"properties": {
"created_at": {
"type": "string"
},
"disabled": {
"type": "boolean"
},
"expires_at": {
"type": "string"
},
"id": {
"type": "integer"
},
"name": {
"type": "string"
},
"token": {
"type": "string"
},
"updated_at": {
"type": "string"
}
}
}
},
"securityDefinitions": {
"BearerAuth": {
"type": "apiKey",
"name": "Authorization",
"in": "header"
}
}
}

144
webapp/package-lock.json generated
View File

@@ -18,12 +18,14 @@
"tailwind-merge": "^3.4.0",
"tailwindcss": "^4.1.18",
"vue": "^3.5.24",
"vue-router": "^4.6.4",
"vue-sonner": "^2.0.9"
},
"devDependencies": {
"@types/node": "^24.10.8",
"@vitejs/plugin-vue": "^6.0.1",
"@vue/tsconfig": "^0.8.1",
"rimraf": "^6.1.2",
"tw-animate-css": "^1.4.0",
"typescript": "~5.9.3",
"vite": "^7.2.4",
@@ -572,6 +574,29 @@
"@swc/helpers": "^0.5.0"
}
},
"node_modules/@isaacs/balanced-match": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/@isaacs/balanced-match/-/balanced-match-4.0.1.tgz",
"integrity": "sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": "20 || >=22"
}
},
"node_modules/@isaacs/brace-expansion": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/@isaacs/brace-expansion/-/brace-expansion-5.0.0.tgz",
"integrity": "sha512-ZT55BDLV0yv0RBm2czMiZ+SqCGO7AvmOM3G/w2xhVPH+te0aKgFjmBvGlL1dH+ql2tgGO3MVrbb3jCKyvpgnxA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@isaacs/balanced-match": "^4.0.1"
},
"engines": {
"node": "20 || >=22"
}
},
"node_modules/@jridgewell/gen-mapping": {
"version": "0.3.13",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
@@ -1391,6 +1416,12 @@
"@vue/shared": "3.5.26"
}
},
"node_modules/@vue/devtools-api": {
"version": "6.6.4",
"resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-6.6.4.tgz",
"integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==",
"license": "MIT"
},
"node_modules/@vue/language-core": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/@vue/language-core/-/language-core-3.2.2.tgz",
@@ -1690,6 +1721,24 @@
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/glob": {
"version": "13.0.0",
"resolved": "https://registry.npmjs.org/glob/-/glob-13.0.0.tgz",
"integrity": "sha512-tvZgpqk6fz4BaNZ66ZsRaZnbHvP/jG3uKJvAZOwEVUL4RTA5nJeeLYfyN9/VA8NX/V3IBG+hkeuGpKjvELkVhA==",
"dev": true,
"license": "BlueOak-1.0.0",
"dependencies": {
"minimatch": "^10.1.1",
"minipass": "^7.1.2",
"path-scurry": "^2.0.0"
},
"engines": {
"node": "20 || >=22"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/graceful-fs": {
"version": "4.2.11",
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
@@ -1954,6 +2003,16 @@
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lru-cache": {
"version": "11.2.5",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.5.tgz",
"integrity": "sha512-vFrFJkWtJvJnD5hg+hJvVE8Lh/TcMzKnTgCWmtBipwI5yLX/iX+5UB2tfuyODF5E7k9xEzMdYgGqaSb1c0c5Yw==",
"dev": true,
"license": "BlueOak-1.0.0",
"engines": {
"node": "20 || >=22"
}
},
"node_modules/lucide-vue-next": {
"version": "0.562.0",
"resolved": "https://registry.npmjs.org/lucide-vue-next/-/lucide-vue-next-0.562.0.tgz",
@@ -1972,6 +2031,32 @@
"@jridgewell/sourcemap-codec": "^1.5.5"
}
},
"node_modules/minimatch": {
"version": "10.1.1",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.1.1.tgz",
"integrity": "sha512-enIvLvRAFZYXJzkCYG5RKmPfrFArdLv+R+lbQ53BmIMLIry74bjKzX6iHAm8WYamJkhSSEabrWN5D97XnKObjQ==",
"dev": true,
"license": "BlueOak-1.0.0",
"dependencies": {
"@isaacs/brace-expansion": "^5.0.0"
},
"engines": {
"node": "20 || >=22"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/minipass": {
"version": "7.1.2",
"resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz",
"integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==",
"dev": true,
"license": "ISC",
"engines": {
"node": ">=16 || 14 >=14.17"
}
},
"node_modules/muggle-string": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/muggle-string/-/muggle-string-0.4.1.tgz",
@@ -2003,6 +2088,13 @@
"integrity": "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==",
"license": "MIT"
},
"node_modules/package-json-from-dist": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz",
"integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==",
"dev": true,
"license": "BlueOak-1.0.0"
},
"node_modules/path-browserify": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz",
@@ -2010,6 +2102,23 @@
"dev": true,
"license": "MIT"
},
"node_modules/path-scurry": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.1.tgz",
"integrity": "sha512-oWyT4gICAu+kaA7QWk/jvCHWarMKNs6pXOGWKDTr7cw4IGcUbW+PeTfbaQiLGheFRpjo6O9J0PmyMfQPjH71oA==",
"dev": true,
"license": "BlueOak-1.0.0",
"dependencies": {
"lru-cache": "^11.0.0",
"minipass": "^7.1.2"
},
"engines": {
"node": "20 || >=22"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -2101,6 +2210,26 @@
"url": "https://github.com/sponsors/antfu"
}
},
"node_modules/rimraf": {
"version": "6.1.2",
"resolved": "https://registry.npmjs.org/rimraf/-/rimraf-6.1.2.tgz",
"integrity": "sha512-cFCkPslJv7BAXJsYlK1dZsbP8/ZNLkCAQ0bi1hf5EKX2QHegmDFEFA6QhuYJlk7UDdc+02JjO80YSOrWPpw06g==",
"dev": true,
"license": "BlueOak-1.0.0",
"dependencies": {
"glob": "^13.0.0",
"package-json-from-dist": "^1.0.1"
},
"bin": {
"rimraf": "dist/esm/bin.mjs"
},
"engines": {
"node": "20 || >=22"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/rollup": {
"version": "4.55.1",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.55.1.tgz",
@@ -2338,6 +2467,21 @@
}
}
},
"node_modules/vue-router": {
"version": "4.6.4",
"resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.6.4.tgz",
"integrity": "sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg==",
"license": "MIT",
"dependencies": {
"@vue/devtools-api": "^6.6.4"
},
"funding": {
"url": "https://github.com/sponsors/posva"
},
"peerDependencies": {
"vue": "^3.5.0"
}
},
"node_modules/vue-sonner": {
"version": "2.0.9",
"resolved": "https://registry.npmjs.org/vue-sonner/-/vue-sonner-2.0.9.tgz",

View File

@@ -6,7 +6,10 @@
"scripts": {
"dev": "vite",
"build": "vue-tsc -b && vite build",
"preview": "vite preview"
"build:dev": "vue-tsc -b && vite build --mode development",
"build:prod": "vue-tsc -b && vite build --mode production",
"preview": "vite preview",
"clean": "rimraf ../web"
},
"dependencies": {
"@tailwindcss/vite": "^4.1.18",
@@ -19,12 +22,14 @@
"tailwind-merge": "^3.4.0",
"tailwindcss": "^4.1.18",
"vue": "^3.5.24",
"vue-router": "^4.6.4",
"vue-sonner": "^2.0.9"
},
"devDependencies": {
"@types/node": "^24.10.8",
"@vitejs/plugin-vue": "^6.0.1",
"@vue/tsconfig": "^0.8.1",
"rimraf": "^6.1.2",
"tw-animate-css": "^1.4.0",
"typescript": "~5.9.3",
"vite": "^7.2.4",

View File

@@ -1,11 +1,10 @@
<script setup lang="ts">
import 'vue-sonner/style.css'
import ComponentShowcase from './views/ComponentShowcase.vue'
</script>
<template>
<div id="app">
<ComponentShowcase />
<RouterView />
</div>
</template>
@@ -19,5 +18,6 @@
html, body {
margin: 0;
padding: 0;
overflow-x: hidden;
}
</style>

View File

@@ -0,0 +1,123 @@
import { ref, onMounted } from 'vue'
import { bingPaperApi } from '@/lib/api-service'
import type { ImageMeta } from '@/lib/api-types'
/**
* 获取今日图片
*/
export function useTodayImage() {
const image = ref<ImageMeta | null>(null)
const loading = ref(false)
const error = ref<Error | null>(null)
const fetchImage = async () => {
loading.value = true
error.value = null
try {
image.value = await bingPaperApi.getTodayImageMeta()
} catch (e) {
error.value = e as Error
console.error('Failed to fetch today image:', e)
} finally {
loading.value = false
}
}
onMounted(() => {
fetchImage()
})
return {
image,
loading,
error,
refetch: fetchImage
}
}
/**
* 获取图片列表(支持分页)
*/
export function useImageList(initialLimit = 30) {
const images = ref<ImageMeta[]>([])
const loading = ref(false)
const error = ref<Error | null>(null)
const hasMore = ref(true)
const fetchImages = async (limit = initialLimit) => {
if (loading.value) return
loading.value = true
error.value = null
try {
const newImages = await bingPaperApi.getImages({ limit })
if (newImages.length < limit) {
hasMore.value = false
}
images.value = [...images.value, ...newImages]
} catch (e) {
error.value = e as Error
console.error('Failed to fetch images:', e)
} finally {
loading.value = false
}
}
const loadMore = () => {
if (!loading.value && hasMore.value) {
fetchImages()
}
}
onMounted(() => {
fetchImages()
})
return {
images,
loading,
error,
hasMore,
loadMore,
refetch: () => {
images.value = []
hasMore.value = true
fetchImages()
}
}
}
/**
* 获取指定日期的图片
*/
export function useImageByDate(date: string) {
const image = ref<ImageMeta | null>(null)
const loading = ref(false)
const error = ref<Error | null>(null)
const fetchImage = async () => {
loading.value = true
error.value = null
try {
image.value = await bingPaperApi.getImageMetaByDate(date)
} catch (e) {
error.value = e as Error
console.error(`Failed to fetch image for date ${date}:`, e)
} finally {
loading.value = false
}
}
onMounted(() => {
fetchImage()
})
return {
image,
loading,
error,
refetch: fetchImage
}
}

View File

@@ -0,0 +1,51 @@
/**
* API 配置管理
* 用于管理后端 API 的基础配置
*/
// 获取环境变量中的 API 基础 URL
const getApiBaseUrl = (): string => {
// 在构建时Vite 会替换这个变量
const baseUrl = import.meta.env.VITE_API_BASE_URL || '/api/v1'
// 确保以 / 开头但不以 / 结尾
return baseUrl.replace(/\/$/, '')
}
// API 基础 URL
export const API_BASE_URL = getApiBaseUrl()
// API 配置
export const apiConfig = {
baseURL: API_BASE_URL,
timeout: 10000, // 10 秒超时
headers: {
'Content-Type': 'application/json'
}
}
/**
* 构建完整的 API 端点 URL
* @param endpoint - API 端点路径(如:'/images'
* @returns 完整的 API URL
*/
export const buildApiUrl = (endpoint: string): string => {
// 确保端点以 / 开头
const normalizedEndpoint = endpoint.startsWith('/') ? endpoint : `/${endpoint}`
return `${API_BASE_URL}${normalizedEndpoint}`
}
/**
* HTTP 状态码枚举
*/
export const HTTP_STATUS = {
OK: 200,
CREATED: 201,
BAD_REQUEST: 400,
UNAUTHORIZED: 401,
FORBIDDEN: 403,
NOT_FOUND: 404,
INTERNAL_SERVER_ERROR: 500
} as const
export type HttpStatus = typeof HTTP_STATUS[keyof typeof HTTP_STATUS]

View File

@@ -0,0 +1,202 @@
import { apiClient } from './http-client'
import { apiConfig } from './api-config'
import type {
Token,
LoginRequest,
CreateTokenRequest,
UpdateTokenRequest,
ChangePasswordRequest,
Config,
ImageMeta,
ImageListParams,
ManualFetchRequest,
ImageVariant,
ImageFormat
} from './api-types'
/**
* BingPaper API 服务类
*/
export class BingPaperApiService {
// ===== 认证相关 =====
/**
* 管理员登录
*/
async login(request: LoginRequest): Promise<Token> {
return apiClient.post<Token>('/admin/login', request)
}
/**
* 修改管理员密码
*/
async changePassword(request: ChangePasswordRequest): Promise<{ message: string }> {
return apiClient.post('/admin/password', request)
}
// ===== Token 管理 =====
/**
* 获取 Token 列表
*/
async getTokens(): Promise<Token[]> {
return apiClient.get<Token[]>('/admin/tokens')
}
/**
* 创建 Token
*/
async createToken(request: CreateTokenRequest): Promise<Token> {
return apiClient.post<Token>('/admin/tokens', request)
}
/**
* 更新 Token 状态
*/
async updateToken(id: number, request: UpdateTokenRequest): Promise<{ message: string }> {
return apiClient.patch(`/admin/tokens/${id}`, request)
}
/**
* 删除 Token
*/
async deleteToken(id: number): Promise<{ message: string }> {
return apiClient.delete(`/admin/tokens/${id}`)
}
// ===== 配置管理 =====
/**
* 获取当前配置
*/
async getConfig(): Promise<Config> {
return apiClient.get<Config>('/admin/config')
}
/**
* 更新配置
*/
async updateConfig(config: Config): Promise<Config> {
return apiClient.put<Config>('/admin/config', config)
}
// ===== 系统管理 =====
/**
* 手动触发抓取
*/
async manualFetch(request?: ManualFetchRequest): Promise<{ message: string }> {
return apiClient.post('/admin/fetch', request)
}
/**
* 手动触发清理
*/
async manualCleanup(): Promise<{ message: string }> {
return apiClient.post('/admin/cleanup')
}
// ===== 图片相关 =====
/**
* 获取图片列表
*/
async getImages(params?: ImageListParams): Promise<ImageMeta[]> {
const searchParams = new URLSearchParams()
if (params?.limit) searchParams.set('limit', params.limit.toString())
if (params?.offset) searchParams.set('offset', params.offset.toString())
const queryString = searchParams.toString()
const endpoint = queryString ? `/images?${queryString}` : '/images'
return apiClient.get<ImageMeta[]>(endpoint)
}
/**
* 获取今日图片元数据
*/
async getTodayImageMeta(): Promise<ImageMeta> {
return apiClient.get<ImageMeta>('/image/today/meta')
}
/**
* 获取指定日期图片元数据
*/
async getImageMetaByDate(date: string): Promise<ImageMeta> {
return apiClient.get<ImageMeta>(`/image/date/${date}/meta`)
}
/**
* 获取随机图片元数据
*/
async getRandomImageMeta(): Promise<ImageMeta> {
return apiClient.get<ImageMeta>('/image/random/meta')
}
/**
* 构建图片 URL
*/
getTodayImageUrl(variant: ImageVariant = 'UHD', format: ImageFormat = 'jpg'): string {
const params = new URLSearchParams({ variant, format })
return `${apiConfig.baseURL}/image/today?${params.toString()}`
}
/**
* 构建指定日期图片 URL
*/
getImageUrlByDate(date: string, variant: ImageVariant = 'UHD', format: ImageFormat = 'jpg'): string {
const params = new URLSearchParams({ variant, format })
return `${apiConfig.baseURL}/image/date/${date}?${params.toString()}`
}
/**
* 构建随机图片 URL
*/
getRandomImageUrl(variant: ImageVariant = 'UHD', format: ImageFormat = 'jpg'): string {
const params = new URLSearchParams({ variant, format })
return `${apiConfig.baseURL}/image/random?${params.toString()}`
}
// ===== 认证状态管理 =====
/**
* 设置认证 Token
*/
setAuthToken(token: string): void {
apiClient.setAuthToken(token)
}
/**
* 清除认证 Token
*/
clearAuthToken(): void {
apiClient.clearAuthToken()
}
}
// 导出默认实例
export const bingPaperApi = new BingPaperApiService()
// 导出便捷方法
export const {
login,
changePassword,
getTokens,
createToken,
updateToken,
deleteToken,
getConfig,
updateConfig,
manualFetch,
manualCleanup,
getImages,
getTodayImageMeta,
getImageMetaByDate,
getRandomImageMeta,
getTodayImageUrl,
getImageUrlByDate,
getRandomImageUrl,
setAuthToken,
clearAuthToken
} = bingPaperApi

169
webapp/src/lib/api-types.ts Normal file
View File

@@ -0,0 +1,169 @@
/**
* BingPaper API TypeScript 接口定义
* 基于 Swagger 文档自动生成
*/
// ===== 通用类型定义 =====
export interface ApiResponse<T = any> {
data?: T
message?: string
success?: boolean
}
export interface PaginationParams {
limit?: number
offset?: number
}
// ===== Token 相关 =====
export interface Token {
id: number
name: string
token: string
disabled: boolean
created_at: string
updated_at: string
expires_at?: string
}
export interface LoginRequest {
password: string
}
export interface CreateTokenRequest {
name: string
expires_at?: string
expires_in?: string
}
export interface UpdateTokenRequest {
disabled?: boolean
}
export interface ChangePasswordRequest {
old_password: string
new_password: string
}
// ===== 配置相关 =====
export interface Config {
admin: AdminConfig
api: APIConfig
cron: CronConfig
db: DBConfig
feature: FeatureConfig
log: LogConfig
retention: RetentionConfig
server: ServerConfig
storage: StorageConfig
token: TokenConfig
web: WebConfig
}
export interface AdminConfig {
passwordBcrypt: string
}
export interface APIConfig {
mode: string // 'local' | 'redirect'
}
export interface CronConfig {
enabled: boolean
dailySpec: string
}
export interface DBConfig {
type: string // 'sqlite' | 'mysql' | 'postgres'
dsn: string
}
export interface FeatureConfig {
writeDailyFiles: boolean
}
export interface LogConfig {
level: string
filename: string
dbfilename: string
dblogLevel: string
logConsole: boolean
showDBLog: boolean
maxSize: number
maxAge: number
maxBackups: number
compress: boolean
}
export interface RetentionConfig {
days: number
}
export interface ServerConfig {
port: number
baseURL: string
}
export interface StorageConfig {
type: string // 'local' | 's3' | 'webdav'
local: LocalConfig
s3: S3Config
webDAV: WebDAVConfig
}
export interface LocalConfig {
root: string
}
export interface S3Config {
endpoint: string
accessKey: string
secretKey: string
bucket: string
region: string
forcePathStyle: boolean
publicURLPrefix: string
}
export interface WebDAVConfig {
url: string
username: string
password: string
publicURLPrefix: string
}
export interface TokenConfig {
defaultTTL: string
}
export interface WebConfig {
path: string
}
// ===== 图片相关 =====
export interface ImageMeta {
date?: string
title?: string
copyright?: string
url?: string
variant?: string
format?: string
[key: string]: any
}
export interface ImageListParams extends PaginationParams {
// 可以扩展更多筛选参数
}
export interface ManualFetchRequest {
n?: number // 抓取天数
}
// ===== API 端点类型定义 =====
export type ImageVariant = 'UHD' | '1920x1080' | '1366x768'
export type ImageFormat = 'jpg'

View File

@@ -0,0 +1,188 @@
import { apiConfig, buildApiUrl } from './api-config'
/**
* API 错误类
*/
export class ApiError extends Error {
status: number
response?: any
constructor(message: string, status: number, response?: any) {
super(message)
this.name = 'ApiError'
this.status = status
this.response = response
}
}
/**
* HTTP 请求选项
*/
export interface RequestOptions {
method?: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH'
headers?: Record<string, string>
body?: any
timeout?: number
}
/**
* 简单的 HTTP 客户端
*/
export class ApiClient {
private defaultHeaders: Record<string, string>
private defaultTimeout: number
constructor() {
this.defaultHeaders = { ...apiConfig.headers }
this.defaultTimeout = apiConfig.timeout
}
/**
* 设置默认请求头
*/
setHeader(key: string, value: string) {
this.defaultHeaders[key] = value
}
/**
* 移除请求头
*/
removeHeader(key: string) {
delete this.defaultHeaders[key]
}
/**
* 设置认证 Token
*/
setAuthToken(token: string) {
this.setHeader('Authorization', `Bearer ${token}`)
}
/**
* 清除认证 Token
*/
clearAuthToken() {
this.removeHeader('Authorization')
}
/**
* 发送 HTTP 请求
*/
async request<T = any>(
endpoint: string,
options: RequestOptions = {}
): Promise<T> {
const url = buildApiUrl(endpoint)
const {
method = 'GET',
headers = {},
body,
timeout = this.defaultTimeout
} = options
// 合并请求头
const requestHeaders = {
...this.defaultHeaders,
...headers
}
// 构建请求配置
const requestConfig: RequestInit = {
method,
headers: requestHeaders,
signal: AbortSignal.timeout(timeout)
}
// 处理请求体
if (body && method !== 'GET') {
if (typeof body === 'object') {
requestConfig.body = JSON.stringify(body)
} else {
requestConfig.body = body
}
}
try {
const response = await fetch(url, requestConfig)
// 检查响应状态
if (!response.ok) {
const errorData = await this.parseResponse(response)
throw new ApiError(
errorData?.message || `HTTP ${response.status}: ${response.statusText}`,
response.status,
errorData
)
}
return await this.parseResponse(response)
} catch (error) {
if (error instanceof ApiError) {
throw error
}
// 处理网络错误或其他异常
if (error instanceof Error) {
throw new ApiError(error.message, 0)
}
throw new ApiError('Unknown error occurred', 0)
}
}
/**
* 解析响应数据
*/
private async parseResponse(response: Response): Promise<any> {
const contentType = response.headers.get('content-type')
if (contentType?.includes('application/json')) {
return await response.json()
}
if (contentType?.includes('text/')) {
return await response.text()
}
// 对于图片等二进制数据
return response
}
/**
* GET 请求
*/
async get<T = any>(endpoint: string, headers?: Record<string, string>): Promise<T> {
return this.request<T>(endpoint, { method: 'GET', headers })
}
/**
* POST 请求
*/
async post<T = any>(endpoint: string, body?: any, headers?: Record<string, string>): Promise<T> {
return this.request<T>(endpoint, { method: 'POST', body, headers })
}
/**
* PUT 请求
*/
async put<T = any>(endpoint: string, body?: any, headers?: Record<string, string>): Promise<T> {
return this.request<T>(endpoint, { method: 'PUT', body, headers })
}
/**
* DELETE 请求
*/
async delete<T = any>(endpoint: string, headers?: Record<string, string>): Promise<T> {
return this.request<T>(endpoint, { method: 'DELETE', headers })
}
/**
* PATCH 请求
*/
async patch<T = any>(endpoint: string, body?: any, headers?: Record<string, string>): Promise<T> {
return this.request<T>(endpoint, { method: 'PATCH', body, headers })
}
}
// 导出默认实例
export const apiClient = new ApiClient()

View File

@@ -1,5 +1,9 @@
import { createApp } from 'vue'
import './style.css'
import App from './App.vue'
import router from './router'
createApp(App).mount('#app')
const app = createApp(App)
app.use(router)
app.mount('#app')

View File

@@ -0,0 +1,33 @@
import { createRouter, createWebHistory } from 'vue-router'
import Home from '@/views/Home.vue'
import ImageView from '@/views/ImageView.vue'
const router = createRouter({
history: createWebHistory(),
routes: [
{
path: '/',
name: 'Home',
component: Home,
meta: {
title: '必应每日一图'
}
},
{
path: '/image/:date',
name: 'ImageView',
component: ImageView,
meta: {
title: '图片详情'
}
}
]
})
// 路由守卫 - 更新页面标题
router.beforeEach((to, _from, next) => {
document.title = (to.meta.title as string) || '必应每日一图'
next()
})
export default router

185
webapp/src/views/Home.vue Normal file
View File

@@ -0,0 +1,185 @@
<template>
<div class="min-h-screen bg-gradient-to-b from-gray-900 via-gray-800 to-gray-900">
<!-- Hero Section - 今日图片 -->
<section class="relative h-screen w-full overflow-hidden">
<div v-if="todayLoading" class="absolute inset-0 flex items-center justify-center">
<div class="w-12 h-12 border-4 border-white/20 border-t-white rounded-full animate-spin"></div>
</div>
<div v-else-if="todayImage" class="relative h-full w-full group">
<!-- 背景图片 -->
<div class="absolute inset-0">
<img
:src="getTodayImageUrl()"
:alt="todayImage.title || 'Today\'s Bing Image'"
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>
<!-- 内容叠加层 -->
<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="inline-block px-4 py-2 bg-white/10 backdrop-blur-md rounded-full text-white/90 text-sm font-medium">
今日精选 · {{ formatDate(todayImage.date) }}
</div>
<h1 class="text-4xl md:text-6xl font-bold text-white leading-tight drop-shadow-2xl">
{{ todayImage.title || '必应每日一图' }}
</h1>
<p v-if="todayImage.copyright" class="text-lg md:text-xl text-white/80 max-w-2xl">
{{ todayImage.copyright }}
</p>
<div class="flex gap-4 pt-4">
<button
@click="viewImage(todayImage.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"
>
查看大图
</button>
<button
v-if="todayImage.quiz"
@click="openQuiz(todayImage.quiz)"
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"
>
了解更多
</button>
</div>
</div>
</div>
<!-- 滚动提示 -->
<div class="absolute bottom-8 left-1/2 transform -translate-x-1/2 animate-bounce">
<svg class="w-6 h-6 text-white/60" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 14l-7 7m0 0l-7-7m7 7V3"></path>
</svg>
</div>
</div>
</section>
<!-- Gallery Section - 历史图片 -->
<section class="py-16 px-4 md:px-8 lg:px-16">
<div class="max-w-7xl mx-auto">
<h2 class="text-3xl md:text-4xl font-bold text-white mb-8">
历史精选
</h2>
<!-- 图片网格 -->
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
<div
v-for="(image, index) in images"
:key="image.date || index"
class="group relative aspect-video rounded-xl overflow-hidden cursor-pointer transform transition-all duration-300 hover:scale-105 hover:shadow-2xl"
@click="viewImage(image.date!)"
>
<!-- 图片 -->
<img
:src="getImageUrl(image.date!)"
:alt="image.title || 'Bing Image'"
class="w-full h-full object-cover transition-transform duration-500 group-hover:scale-110"
loading="lazy"
/>
<!-- 悬浮信息层 -->
<div class="absolute inset-0 bg-gradient-to-t from-black/90 via-black/50 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-300">
<div class="absolute bottom-0 left-0 right-0 p-6 transform translate-y-4 group-hover:translate-y-0 transition-transform duration-300">
<div class="text-xs text-white/70 mb-2">
{{ formatDate(image.date) }}
</div>
<h3 class="text-lg font-semibold text-white mb-2 line-clamp-2">
{{ image.title || '未命名' }}
</h3>
<p v-if="image.copyright" class="text-sm text-white/80 line-clamp-2">
{{ image.copyright }}
</p>
</div>
</div>
</div>
</div>
<!-- 加载更多 -->
<div class="mt-12 text-center">
<div v-if="loading" class="inline-flex items-center gap-2 text-white/60">
<div class="w-5 h-5 border-2 border-white/20 border-t-white/60 rounded-full animate-spin"></div>
<span>加载中...</span>
</div>
<button
v-else-if="hasMore"
@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"
>
加载更多
</button>
<p v-else class="text-white/40">
已加载全部图片
</p>
</div>
</div>
</section>
<!-- Footer -->
<footer class="py-8 text-center text-white/40 border-t border-white/10">
<p>数据来源于必应每日一图 API</p>
</footer>
</div>
</template>
<script setup lang="ts">
import { useTodayImage, useImageList } from '@/composables/useImages'
import { bingPaperApi } from '@/lib/api-service'
import { useRouter } from 'vue-router'
const router = useRouter()
// 获取今日图片
const { image: todayImage, loading: todayLoading } = useTodayImage()
// 获取图片列表
const { images, loading, hasMore, loadMore } = useImageList(30)
// 格式化日期
const formatDate = (dateStr?: string) => {
if (!dateStr) return ''
const date = new Date(dateStr)
return date.toLocaleDateString('zh-CN', {
year: 'numeric',
month: 'long',
day: 'numeric'
})
}
// 获取今日图片 URL
const getTodayImageUrl = () => {
return bingPaperApi.getTodayImageUrl('UHD', 'jpg')
}
// 获取图片 URL
const getImageUrl = (date: string) => {
return bingPaperApi.getImageUrlByDate(date, '1920x1080', 'jpg')
}
// 查看图片详情
const viewImage = (date: string) => {
router.push(`/image/${date}`)
}
// 打开必应 quiz 链接
const openQuiz = (quiz: string) => {
// 拼接完整的必应地址
const bingUrl = `https://www.bing.com${quiz}`
window.open(bingUrl, '_blank')
}
</script>
<style scoped>
.line-clamp-2 {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
</style>

View File

@@ -0,0 +1,247 @@
<template>
<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 class="w-16 h-16 border-4 border-white/20 border-t-white rounded-full animate-spin"></div>
</div>
<!-- 主要内容 -->
<div v-else-if="image" class="relative h-full w-full">
<!-- 全屏图片 -->
<div class="absolute inset-0 flex items-center justify-center">
<img
:src="getFullImageUrl()"
:alt="image.title || 'Bing Image'"
class="max-w-full max-h-full object-contain"
/>
</div>
<!-- 顶部工具栏 -->
<div class="absolute top-0 left-0 right-0 bg-gradient-to-b from-black/80 to-transparent p-6 z-10">
<div class="flex items-center justify-between max-w-7xl mx-auto">
<button
@click="goBack"
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="M10 19l-7-7m0 0l7-7m-7 7h18"></path>
</svg>
<span>返回</span>
</button>
<div class="text-white/80 text-sm">
{{ formatDate(image.date) }}
</div>
</div>
</div>
<!-- 信息悬浮层类似 Windows 聚焦 -->
<div
v-if="showInfo"
class="absolute bottom-24 left-8 right-8 md:left-16 md:right-auto md:max-w-md bg-black/60 backdrop-blur-xl rounded-2xl p-6 transform transition-all duration-500 z-10"
:class="{ 'translate-y-0 opacity-100': showInfo, 'translate-y-4 opacity-0': !showInfo }"
>
<h2 class="text-2xl font-bold text-white mb-3">
{{ image.title || '未命名' }}
</h2>
<p v-if="image.copyright" class="text-white/80 text-sm mb-4 leading-relaxed">
{{ image.copyright }}
</p>
<!-- Quiz 链接 -->
<a
v-if="image.quiz"
:href="getBingQuizUrl(image.quiz)"
target="_blank"
class="inline-flex items-center gap-2 px-4 py-2 bg-white/20 hover:bg-white/30 text-white rounded-lg text-sm font-medium transition-all group"
>
<span>了解更多信息</span>
<svg class="w-4 h-4 transform group-hover:translate-x-1 transition-transform" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 7l5 5m0 0l-5 5m5-5H6"></path>
</svg>
</a>
<!-- 切换信息显示按钮 -->
<button
@click="showInfo = false"
class="absolute top-4 right-4 p-2 hover:bg-white/10 rounded-lg transition-all"
>
<svg class="w-5 h-5 text-white" 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="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/80 to-transparent p-6 z-10">
<div class="flex items-center justify-between max-w-7xl mx-auto">
<!-- 日期切换按钮 -->
<div class="flex items-center gap-4">
<button
@click="previousDay"
:disabled="navigating"
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">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"></path>
</svg>
<span class="hidden sm:inline">前一天</span>
</button>
<button
@click="nextDay"
:disabled="navigating || isToday"
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>
<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="M9 5l7 7-7 7"></path>
</svg>
</button>
</div>
<!-- 信息按钮 -->
<button
v-if="!showInfo"
@click="showInfo = 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="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg>
<span class="hidden sm:inline">显示信息</span>
</button>
</div>
</div>
</div>
<!-- 错误状态 -->
<div v-else-if="error" class="absolute inset-0 flex items-center justify-center">
<div class="text-center">
<p class="text-white/60 mb-4">加载失败</p>
<button
@click="goBack"
class="px-6 py-3 bg-white/10 backdrop-blur-md text-white rounded-lg hover:bg-white/20 transition-all"
>
返回首页
</button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useImageByDate } from '@/composables/useImages'
import { bingPaperApi } from '@/lib/api-service'
const route = useRoute()
const router = useRouter()
const currentDate = ref(route.params.date as string)
const showInfo = ref(true)
const navigating = ref(false)
// 使用 composable 获取图片数据
const { image, loading, error, refetch } = useImageByDate(currentDate.value)
// 监听日期变化
watch(currentDate, () => {
refetch()
})
// 格式化日期
const formatDate = (dateStr?: string) => {
if (!dateStr) return ''
const date = new Date(dateStr)
return date.toLocaleDateString('zh-CN', {
year: 'numeric',
month: 'long',
day: 'numeric',
weekday: 'long'
})
}
// 判断是否是今天
const isToday = computed(() => {
const today = new Date().toISOString().split('T')[0]
return currentDate.value === today
})
// 获取完整图片 URL
const getFullImageUrl = () => {
return bingPaperApi.getImageUrlByDate(currentDate.value, 'UHD', 'jpg')
}
// 获取必应 quiz URL
const getBingQuizUrl = (quiz: string) => {
return `https://www.bing.com${quiz}`
}
// 返回首页
const goBack = () => {
router.push('/')
}
// 前一天
const previousDay = () => {
if (navigating.value) return
navigating.value = true
const date = new Date(currentDate.value)
date.setDate(date.getDate() - 1)
const newDate = date.toISOString().split('T')[0]
currentDate.value = newDate
router.replace(`/image/${newDate}`)
setTimeout(() => {
navigating.value = false
}, 500)
}
// 后一天
const nextDay = () => {
if (navigating.value || isToday.value) return
navigating.value = true
const date = new Date(currentDate.value)
date.setDate(date.getDate() + 1)
const newDate = date.toISOString().split('T')[0]
currentDate.value = newDate
router.replace(`/image/${newDate}`)
setTimeout(() => {
navigating.value = false
}, 500)
}
// 键盘导航
const handleKeydown = (e: KeyboardEvent) => {
if (e.key === 'ArrowLeft') {
previousDay()
} else if (e.key === 'ArrowRight' && !isToday.value) {
nextDay()
} else if (e.key === 'Escape') {
goBack()
} else if (e.key === 'i' || e.key === 'I') {
showInfo.value = !showInfo.value
}
}
// 添加键盘事件监听
if (typeof window !== 'undefined') {
window.addEventListener('keydown', handleKeydown)
}
// 清理
import { onUnmounted } from 'vue'
onUnmounted(() => {
if (typeof window !== 'undefined') {
window.removeEventListener('keydown', handleKeydown)
}
})
</script>

12
webapp/src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1,12 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_API_BASE_URL: string
readonly VITE_API_MODE: string
}
interface ImportMeta {
readonly env: ImportMetaEnv
}
declare const __API_BASE_URL__: string

View File

@@ -1,19 +1,29 @@
{
"extends": "@vue/tsconfig/tsconfig.dom.json",
"compilerOptions": {
"target": "ES2020",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"jsx": "preserve",
"allowJs": false,
"skipLibCheck": true,
"isolatedModules": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"forceConsistentCasingInFileNames": true,
"useDefineForClassFields": true,
"noEmit": true,
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"types": ["vite/client"],
"baseUrl": ".",
"paths": {
"@/*": [
"./src/*"
]
"@/*": ["./src/*"]
},
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},

View File

@@ -1,14 +1,40 @@
import path from 'node:path'
import { defineConfig } from 'vite'
import { defineConfig, loadEnv } from 'vite'
import tailwindcss from '@tailwindcss/vite'
import vue from '@vitejs/plugin-vue'
// https://vite.dev/config/
export default defineConfig({
plugins: [vue(), tailwindcss()],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
export default defineConfig(({ mode }) => {
// 加载环境变量
const env = loadEnv(mode, process.cwd(), '')
return {
plugins: [vue(), tailwindcss()],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
},
// 构建配置
build: {
// 输出到上级目录的web文件夹
outDir: path.resolve(__dirname, '../web'),
// 清空输出目录
emptyOutDir: true,
// 静态资源处理
assetsDir: 'assets',
// 生成 sourcemap 用于生产环境调试
sourcemap: mode === 'development'
},
// 开发服务器配置
server: {
port: 5173,
strictPort: false,
open: false
},
// 环境变量配置 - 开发环境使用完整URL生产环境使用相对路径
define: {
__API_BASE_URL__: JSON.stringify(env.VITE_API_BASE_URL || '/api/v1')
}
}
})