feat: merge conflict resolution, site management, UI fixes

This commit is contained in:
ShukeBta
2026-05-16 17:57:34 +08:00
parent 0be8c7952e
commit cbb4b806be
84 changed files with 13826 additions and 202 deletions
+58
View File
@@ -0,0 +1,58 @@
// API 配置 API 模块
import { api } from './client'
import type { ApiConfig, ApiProvider } from '../types'
// 获取所有 API 配置
export async function listApiConfigs(): Promise<ApiConfig[]> {
const resp = await api.get<ApiConfig[]>('/api-config')
return resp.data as unknown as ApiConfig[]
}
// 获取提供者列表
export async function getProviders(): Promise<ApiProvider[]> {
const resp = await api.get<ApiProvider[]>('/api-config/providers/list')
return resp.data as unknown as ApiProvider[]
}
// 获取指定提供者的配置
export async function getApiConfig(provider: string): Promise<ApiConfig> {
const resp = await api.get<ApiConfig>(`/api-config/${provider}`)
return resp.data as unknown as ApiConfig
}
// 获取生效的配置
export async function getEffectiveConfig(provider: string): Promise<ApiConfig> {
const resp = await api.get<ApiConfig>(`/api-config/${provider}/effective`)
return resp.data as unknown as ApiConfig
}
// 更新 API 配置
export interface UpdateApiConfigRequest {
api_key?: string
base_url?: string
extra?: string
enabled?: boolean
}
export async function updateApiConfig(
provider: string,
data: UpdateApiConfigRequest
): Promise<ApiConfig> {
const resp = await api.post<ApiConfig>(`/api-config/${provider}`, data)
return resp.data as unknown as ApiConfig
}
// 删除 API 配置
export async function deleteApiConfig(provider: string): Promise<void> {
await api.delete(`/api-config/${provider}`)
}
// 测试 API 连接
export interface TestApiConfigResponse {
result: 'success' | 'error' | 'invalid' | 'unknown'
}
export async function testApiConfig(provider: string): Promise<TestApiConfigResponse> {
const resp = await api.post<TestApiConfigResponse>(`/api-config/${provider}/test`)
return resp.data as unknown as TestApiConfigResponse
}
+3 -3
View File
@@ -1,9 +1,9 @@
import { api } from './client'
import type { User } from '../types'
import type { User, TokenPair } from '../types'
export interface LoginResponse {
token: string
user: User
tokens: TokenPair
}
export const authAPI = {
@@ -11,7 +11,7 @@ export const authAPI = {
api.post<LoginResponse>('/auth/login', { username, password }).then((r) => r.data),
register: (username: string, password: string) =>
api.post<User>('/auth/register', { username, password }).then((r) => r.data),
api.post<LoginResponse>('/auth/register', { username, password }).then((r) => r.data),
me: () => api.get<User>('/me').then((r) => r.data),
+68 -3
View File
@@ -1,4 +1,4 @@
import axios, { AxiosError } from 'axios'
import axios, { AxiosError, type InternalAxiosRequestConfig } from 'axios'
import { useAuthStore } from '../stores/auth'
@@ -9,6 +9,22 @@ export const api = axios.create({
timeout: 30000,
})
// Flag to prevent multiple simultaneous refresh attempts
let isRefreshing = false
let refreshSubscribers: Array<(token: string) => void> = []
// Subscribe to token refresh
function subscribeTokenRefresh(callback: (token: string) => void) {
refreshSubscribers.push(callback)
}
// Notify all subscribers about new token
function onTokenRefreshed(newToken: string) {
refreshSubscribers.forEach(callback => callback(newToken))
refreshSubscribers = []
}
// Add auth token to requests
api.interceptors.request.use((config) => {
const token = useAuthStore.getState().token
if (token) {
@@ -18,15 +34,54 @@ api.interceptors.request.use((config) => {
return config
})
// Handle 401 errors with token refresh
api.interceptors.response.use(
(resp) => resp,
(err: AxiosError) => {
if (err.response?.status === 401) {
async (err: AxiosError) => {
const originalRequest = err.config as InternalAxiosRequestConfig & { _retry?: boolean }
// If 401 and not already retried
if (err.response?.status === 401 && originalRequest && !originalRequest._retry) {
if (isRefreshing) {
// Wait for token refresh to complete
return new Promise((resolve) => {
subscribeTokenRefresh((token: string) => {
if (originalRequest.headers) {
originalRequest.headers.Authorization = `Bearer ${token}`
}
resolve(api(originalRequest))
})
})
}
originalRequest._retry = true
isRefreshing = true
try {
const refreshed = await useAuthStore.getState().tokenRefresh()
if (refreshed) {
const newToken = useAuthStore.getState().token
if (newToken && originalRequest.headers) {
originalRequest.headers.Authorization = `Bearer ${newToken}`
}
onTokenRefreshed(newToken || '')
isRefreshing = false
return api(originalRequest)
}
} catch (refreshError) {
isRefreshing = false
refreshSubscribers = []
}
// Refresh failed, logout
useAuthStore.getState().logout()
if (typeof window !== 'undefined' && window.location.pathname !== '/login') {
window.location.href = '/login'
}
return Promise.reject(err)
}
// For other errors, just reject
return Promise.reject(err)
},
)
@@ -55,3 +110,13 @@ export function imageURL(remote?: string): string {
if (remote.startsWith('/api/img')) return remote
return `/api/img?url=${encodeURIComponent(remote)}&${tokenQuery()}`
}
// getToken returns the current auth token
export function getToken(): string | null {
return useAuthStore.getState().token
}
// getRefreshToken returns the current refresh token
export function getRefreshToken(): string | null {
return useAuthStore.getState().refreshToken
}
+51
View File
@@ -0,0 +1,51 @@
import { api } from './client'
import type { DownloadClient } from '../types'
export interface DownloadClientCreateParams {
name: string
type: 'qbittorrent' | 'transmission' | 'aria2'
host: string
username?: string
password?: string
is_default?: boolean
extra?: Record<string, string>
}
export interface DownloadClientUpdateParams {
name?: string
type?: 'qbittorrent' | 'transmission' | 'aria2'
host?: string
username?: string
password?: string
is_default?: boolean
enabled?: boolean
extra?: Record<string, string>
}
export const downloadClientAPI = {
list: () =>
api
.get<{ code: number; data: DownloadClient[] }>('/download-clients')
.then((r) => r.data.data),
get: (id: string) =>
api
.get<{ code: number; data: DownloadClient }>(`/download-clients/${id}`)
.then((r) => r.data.data),
create: (params: DownloadClientCreateParams) =>
api
.post<{ code: number; data: DownloadClient }>('/download-clients', params)
.then((r) => r.data.data),
update: (id: string, params: DownloadClientUpdateParams) =>
api
.put<{ code: number; data: DownloadClient }>(`/download-clients/${id}`, params)
.then((r) => r.data.data),
delete: (id: string) =>
api.delete(`/download-clients/${id}`).then((r) => r.data),
test: (id: string) =>
api.post(`/download-clients/${id}/test`).then((r) => r.data),
}
+50
View File
@@ -0,0 +1,50 @@
import { api } from './client'
import type { NotifyChannel, NotifyProviderInfo } from '../types'
export interface NotifyChannelCreateParams {
name: string
type: 'telegram' | 'wechat' | 'bark' | 'webhook' | 'email'
enabled?: boolean
config: Record<string, string>
events?: string[]
}
export interface NotifyChannelUpdateParams {
name?: string
enabled?: boolean
config?: Record<string, string>
events?: string[]
}
export const notifyAPI = {
list: () =>
api
.get<{ code: number; data: NotifyChannel[] }>('/notify-channels')
.then((r) => r.data.data),
get: (id: string) =>
api
.get<{ code: number; data: NotifyChannel }>(`/notify-channels/${id}`)
.then((r) => r.data.data),
getTypes: () =>
api
.get<{ code: number; data: NotifyProviderInfo[] }>('/notify-channels/types')
.then((r) => r.data.data),
create: (params: NotifyChannelCreateParams) =>
api
.post<{ code: number; data: NotifyChannel }>('/notify-channels', params)
.then((r) => r.data.data),
update: (id: string, params: NotifyChannelUpdateParams) =>
api
.put<{ code: number; data: NotifyChannel }>(`/notify-channels/${id}`, params)
.then((r) => r.data.data),
delete: (id: string) =>
api.delete(`/notify-channels/${id}`).then((r) => r.data),
test: (id: string) =>
api.post(`/notify-channels/${id}/test`).then((r) => r.data),
}
+38
View File
@@ -0,0 +1,38 @@
// 权限 API 模块
import { api } from './client'
import type { UserPermission } from '../types'
// 获取用户权限
export async function getUserPermissions(userId: string): Promise<UserPermission> {
const resp = await api.get<UserPermission>(`/admin/users/${userId}/permissions`)
return resp.data as unknown as UserPermission
}
// 更新用户权限
export async function updateUserPermissions(
userId: string,
permissions: Record<string, boolean>
): Promise<void> {
await api.put(`/admin/users/${userId}/permissions`, { permissions })
}
// 重置用户权限为默认值
export async function resetUserPermissions(userId: string): Promise<void> {
await api.post(`/admin/users/${userId}/permissions/reset`)
}
// 获取当前用户权限
export async function getMyPermissions(): Promise<{
permissions: Record<string, boolean>
role: string
tier: string
is_super: boolean
}> {
const resp = await api.get('/auth/permissions')
return resp.data as unknown as {
permissions: Record<string, boolean>
role: string
tier: string
is_super: boolean
}
}
+27
View File
@@ -0,0 +1,27 @@
// 令牌刷新 API 模块
import { api } from './client'
// 刷新令牌请求/响应
export interface RefreshTokenRequest {
refresh_token: string
}
export interface RefreshTokenResponse {
token: string
refresh_token: string
expires_in: number
token_type: string
}
// 刷新访问令牌
export async function refreshToken(refreshToken: string): Promise<RefreshTokenResponse> {
const resp = await api.post<RefreshTokenResponse>('/auth/refresh', {
refresh_token: refreshToken,
})
return resp.data as unknown as RefreshTokenResponse
}
// 登出
export async function logout(): Promise<void> {
await api.post('/me/logout')
}
+32
View File
@@ -0,0 +1,32 @@
import { api } from './client'
// Site management API - matches Go backend /api/sites endpoints
export const sitesAPI = {
// List all sites
list: () => api.get('/sites').then((r) => r.data),
// Get single site with decrypted fields
get: (id: string | number) => api.get(`/sites/${id}`).then((r) => r.data),
// Create a new site
create: (data: Record<string, unknown>) =>
api.post('/sites', data).then((r) => r.data),
// Update existing site
update: (id: string | number, data: Record<string, unknown>) =>
api.put(`/sites/${id}`, data).then((r) => r.data),
// Delete a site
remove: (id: string | number) =>
api.delete(`/sites/${id}`).then((r) => r.data),
// Test site connectivity
test: (id: string | number) =>
api.post(`/sites/${id}/test`).then((r) => r.data),
// Get supported site types
types: () => api.get('/sites/types').then((r) => r.data),
// Get supported auth types
authTypes: () => api.get('/sites/auth-types').then((r) => r.data),
}