diff --git a/.gitignore b/.gitignore index 32066fd..cfa848f 100644 --- a/.gitignore +++ b/.gitignore @@ -55,3 +55,4 @@ desktop.ini /req.txt /BingPaper /temp/ +/web/ diff --git a/webapp/.env b/webapp/.env new file mode 100644 index 0000000..4cbf11c --- /dev/null +++ b/webapp/.env @@ -0,0 +1,6 @@ +# 默认环境配置 +# API 基础 URL - 默认使用相对路径访问 +VITE_API_BASE_URL=/api/v1 + +# API 模式 +VITE_API_MODE=relative \ No newline at end of file diff --git a/webapp/.env.development b/webapp/.env.development new file mode 100644 index 0000000..4c6fa31 --- /dev/null +++ b/webapp/.env.development @@ -0,0 +1,6 @@ +# 开发环境配置 +# API 基础 URL - 开发环境直接使用完整的后端服务地址(不使用代理) +VITE_API_BASE_URL=http://localhost:8080/api/v1 + +# 开发环境使用完整URL,不需要代理 +VITE_API_MODE=direct \ No newline at end of file diff --git a/webapp/.env.production b/webapp/.env.production new file mode 100644 index 0000000..9ca206e --- /dev/null +++ b/webapp/.env.production @@ -0,0 +1,6 @@ +# 生产环境配置 +# API 基础 URL - 生产环境使用相对路径 +VITE_API_BASE_URL=/api/v1 + +# API 模式:生产环境使用相对路径 +VITE_API_MODE=relative \ No newline at end of file diff --git a/webapp/.gitignore b/webapp/.gitignore index a547bf3..f34475a 100644 --- a/webapp/.gitignore +++ b/webapp/.gitignore @@ -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 diff --git a/webapp/BUILD.md b/webapp/BUILD.md new file mode 100644 index 0000000..b6b0d84 --- /dev/null +++ b/webapp/BUILD.md @@ -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. 定期测试生产构建,确保配置正确 \ No newline at end of file diff --git a/webapp/CORS_CONFIG.md b/webapp/CORS_CONFIG.md new file mode 100644 index 0000000..86efd1a --- /dev/null +++ b/webapp/CORS_CONFIG.md @@ -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 请求,询问服务器是否允许该跨域请求。 \ No newline at end of file diff --git a/webapp/README.md b/webapp/README.md index 33895ab..75ca984 100644 --- a/webapp/README.md +++ b/webapp/README.md @@ -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 ` - + @@ -19,5 +18,6 @@ html, body { margin: 0; padding: 0; + overflow-x: hidden; } diff --git a/webapp/src/composables/useImages.ts b/webapp/src/composables/useImages.ts new file mode 100644 index 0000000..7ab0cda --- /dev/null +++ b/webapp/src/composables/useImages.ts @@ -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(null) + const loading = ref(false) + const error = ref(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([]) + const loading = ref(false) + const error = ref(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(null) + const loading = ref(false) + const error = ref(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 + } +} diff --git a/webapp/src/lib/api-config.ts b/webapp/src/lib/api-config.ts new file mode 100644 index 0000000..9a6e907 --- /dev/null +++ b/webapp/src/lib/api-config.ts @@ -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] \ No newline at end of file diff --git a/webapp/src/lib/api-service.ts b/webapp/src/lib/api-service.ts new file mode 100644 index 0000000..57e9cb0 --- /dev/null +++ b/webapp/src/lib/api-service.ts @@ -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 { + return apiClient.post('/admin/login', request) + } + + /** + * 修改管理员密码 + */ + async changePassword(request: ChangePasswordRequest): Promise<{ message: string }> { + return apiClient.post('/admin/password', request) + } + + // ===== Token 管理 ===== + + /** + * 获取 Token 列表 + */ + async getTokens(): Promise { + return apiClient.get('/admin/tokens') + } + + /** + * 创建 Token + */ + async createToken(request: CreateTokenRequest): Promise { + return apiClient.post('/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 { + return apiClient.get('/admin/config') + } + + /** + * 更新配置 + */ + async updateConfig(config: Config): Promise { + return apiClient.put('/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 { + 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(endpoint) + } + + /** + * 获取今日图片元数据 + */ + async getTodayImageMeta(): Promise { + return apiClient.get('/image/today/meta') + } + + /** + * 获取指定日期图片元数据 + */ + async getImageMetaByDate(date: string): Promise { + return apiClient.get(`/image/date/${date}/meta`) + } + + /** + * 获取随机图片元数据 + */ + async getRandomImageMeta(): Promise { + return apiClient.get('/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 \ No newline at end of file diff --git a/webapp/src/lib/api-types.ts b/webapp/src/lib/api-types.ts new file mode 100644 index 0000000..b90acaa --- /dev/null +++ b/webapp/src/lib/api-types.ts @@ -0,0 +1,169 @@ +/** + * BingPaper API TypeScript 接口定义 + * 基于 Swagger 文档自动生成 + */ + +// ===== 通用类型定义 ===== + +export interface ApiResponse { + 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' \ No newline at end of file diff --git a/webapp/src/lib/http-client.ts b/webapp/src/lib/http-client.ts new file mode 100644 index 0000000..9da9feb --- /dev/null +++ b/webapp/src/lib/http-client.ts @@ -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 + body?: any + timeout?: number +} + +/** + * 简单的 HTTP 客户端 + */ +export class ApiClient { + private defaultHeaders: Record + 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( + endpoint: string, + options: RequestOptions = {} + ): Promise { + 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 { + 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(endpoint: string, headers?: Record): Promise { + return this.request(endpoint, { method: 'GET', headers }) + } + + /** + * POST 请求 + */ + async post(endpoint: string, body?: any, headers?: Record): Promise { + return this.request(endpoint, { method: 'POST', body, headers }) + } + + /** + * PUT 请求 + */ + async put(endpoint: string, body?: any, headers?: Record): Promise { + return this.request(endpoint, { method: 'PUT', body, headers }) + } + + /** + * DELETE 请求 + */ + async delete(endpoint: string, headers?: Record): Promise { + return this.request(endpoint, { method: 'DELETE', headers }) + } + + /** + * PATCH 请求 + */ + async patch(endpoint: string, body?: any, headers?: Record): Promise { + return this.request(endpoint, { method: 'PATCH', body, headers }) + } +} + +// 导出默认实例 +export const apiClient = new ApiClient() \ No newline at end of file diff --git a/webapp/src/main.ts b/webapp/src/main.ts index 2425c0f..cc5a0df 100644 --- a/webapp/src/main.ts +++ b/webapp/src/main.ts @@ -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') diff --git a/webapp/src/router/index.ts b/webapp/src/router/index.ts new file mode 100644 index 0000000..64b48ba --- /dev/null +++ b/webapp/src/router/index.ts @@ -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 diff --git a/webapp/src/views/Home.vue b/webapp/src/views/Home.vue new file mode 100644 index 0000000..15eabdc --- /dev/null +++ b/webapp/src/views/Home.vue @@ -0,0 +1,185 @@ + + + + + + + + + + + + + + + + + + + + 今日精选 · {{ formatDate(todayImage.date) }} + + + + {{ todayImage.title || '必应每日一图' }} + + + + {{ todayImage.copyright }} + + + + + 查看大图 + + + 了解更多 + + + + + + + + + + + + + + + + + + + 历史精选 + + + + + + + + + + + + + {{ formatDate(image.date) }} + + + {{ image.title || '未命名' }} + + + {{ image.copyright }} + + + + + + + + + + + 加载中... + + + + 加载更多 + + + + 已加载全部图片 + + + + + + + + + + + + + diff --git a/webapp/src/views/ImageView.vue b/webapp/src/views/ImageView.vue new file mode 100644 index 0000000..a4ef9b6 --- /dev/null +++ b/webapp/src/views/ImageView.vue @@ -0,0 +1,247 @@ + + + + + + + + + + + + + + + + + + + + + + 返回 + + + + {{ formatDate(image.date) }} + + + + + + + + {{ image.title || '未命名' }} + + + + {{ image.copyright }} + + + + + 了解更多信息 + + + + + + + + + + + + + + + + + + + + + + + 前一天 + + + + 后一天 + + + + + + + + + + + + 显示信息 + + + + + + + + + 加载失败 + + 返回首页 + + + + + + + diff --git a/webapp/src/vite-env.d.ts b/webapp/src/vite-env.d.ts new file mode 100644 index 0000000..f41a2ee --- /dev/null +++ b/webapp/src/vite-env.d.ts @@ -0,0 +1,12 @@ +/// + +interface ImportMetaEnv { + readonly VITE_API_BASE_URL: string + readonly VITE_API_MODE: string +} + +interface ImportMeta { + readonly env: ImportMetaEnv +} + +declare const __API_BASE_URL__: string \ No newline at end of file diff --git a/webapp/tsconfig.app.json b/webapp/tsconfig.app.json index 0b0cf62..0142ddd 100644 --- a/webapp/tsconfig.app.json +++ b/webapp/tsconfig.app.json @@ -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 }, diff --git a/webapp/vite.config.ts b/webapp/vite.config.ts index ada632c..acb0e63 100644 --- a/webapp/vite.config.ts +++ b/webapp/vite.config.ts @@ -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') + } + } })
+ {{ todayImage.copyright }} +
+ {{ image.copyright }} +
+ 已加载全部图片 +
加载失败