mirror of
https://git.fightbot.fun/hxuanyu/BingPaper.git
synced 2026-02-15 17:09:32 +08:00
前端公共部分开发完成,支持图片展示功能
This commit is contained in:
@@ -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>
|
||||
|
||||
123
webapp/src/composables/useImages.ts
Normal file
123
webapp/src/composables/useImages.ts
Normal 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
|
||||
}
|
||||
}
|
||||
51
webapp/src/lib/api-config.ts
Normal file
51
webapp/src/lib/api-config.ts
Normal 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]
|
||||
202
webapp/src/lib/api-service.ts
Normal file
202
webapp/src/lib/api-service.ts
Normal 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
169
webapp/src/lib/api-types.ts
Normal 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'
|
||||
188
webapp/src/lib/http-client.ts
Normal file
188
webapp/src/lib/http-client.ts
Normal 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()
|
||||
@@ -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')
|
||||
|
||||
33
webapp/src/router/index.ts
Normal file
33
webapp/src/router/index.ts
Normal 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
185
webapp/src/views/Home.vue
Normal 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>
|
||||
247
webapp/src/views/ImageView.vue
Normal file
247
webapp/src/views/ImageView.vue
Normal 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
12
webapp/src/vite-env.d.ts
vendored
Normal 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
|
||||
Reference in New Issue
Block a user