@@ -107,7 +84,7 @@
@@ -222,20 +187,12 @@ const handleLogout = () => {
left: 0;
right: 0;
bottom: 0;
- background-color: #e5e7eb;
+ background-color: rgb(var(--color-surface-muted));
transition: 0.4s;
}
-.dark .slider {
- background-color: #4b5563;
-}
-
input:checked + .slider {
- background-color: #4f46e5;
-}
-
-.dark input:checked + .slider {
- background-color: #4f46e5;
+ background-color: rgb(var(--color-accent));
}
.slider:before {
@@ -245,14 +202,10 @@ input:checked + .slider {
width: 26px;
left: 4px;
bottom: 4px;
- background-color: white;
+ background-color: rgb(var(--color-accent-contrast));
transition: 0.4s;
}
-.dark .slider:before {
- background-color: #e5e7eb;
-}
-
.slider.round {
border-radius: 34px;
}
@@ -291,23 +244,13 @@ input:checked + .slider {
}
&::-webkit-scrollbar-thumb {
- background-color: #cbd5e0;
+ background-color: rgb(var(--color-scrollbar));
border-radius: 3px;
&:hover {
- background-color: #a0aec0;
+ background-color: rgb(var(--color-scrollbar-hover));
}
}
}
-/* 暗黑模式下的滚动条样式 */
-:global(.dark) .custom-scrollbar {
- &::-webkit-scrollbar-thumb {
- background-color: #4a5568;
-
- &:hover {
- background-color: #2d3748;
- }
- }
-}
diff --git a/src/main.ts b/src/main.ts
index 0aa1101..bad6438 100644
--- a/src/main.ts
+++ b/src/main.ts
@@ -7,6 +7,19 @@ import App from './App.vue'
import router from './router'
import i18n from './i18n'
+const OPEN_SOURCE_BANNER = String.raw`
+ ______ _ _ _____ _ ____
+ | ____(_) | / ____| | | | _ \
+ | |__ _| | ___| | ___ __| | ___| |_) | _____ __
+ | __| | | |/ _ \ | / _ \ / _\` |/ _ \ _ < / _ \ \/ /
+ | | | | | __/ |___| (_) | (_| | __/ |_) | (_) > <
+ |_| |_|_|\___|\_____\___/ \__,_|\___|____/ \___/_/\_\
+
+ Open Source: https://github.com/vastsa/FileCodeBox
+`
+
+console.info(OPEN_SOURCE_BANNER)
+
const app = createApp(App)
app.use(createPinia())
diff --git a/src/router/index.ts b/src/router/index.ts
index 2891e0a..e51a3bf 100644
--- a/src/router/index.ts
+++ b/src/router/index.ts
@@ -1,50 +1,105 @@
import { createRouter, createWebHashHistory } from 'vue-router'
+import type { RouteRecordRaw } from 'vue-router'
+import { ROUTE_NAMES, ROUTES } from '@/constants'
+import { hasValidStoredAdminSession } from '@/utils/auth-storage'
-// 预加载 SendFileView 组件
-const SendFileView = () => import('../views/SendFileView.vue')
-const router = createRouter({
- history: createWebHashHistory(import.meta.env.BASE_URL),
- routes: [
- {
- path: '/',
- name: 'Retrieve',
- component: () => import('@/views/RetrievewFileView.vue')
- },
- {
- path: '/send',
- name: 'Send',
- component: SendFileView
- },
- {
- path: '/admin',
- name: 'Manage',
- component: () => import('@/layout/AdminLayout/AdminLayout.vue'),
- redirect: '/admin/dashboard',
- children: [
- {
- path: '/admin/dashboard',
- name: 'Dashboard',
- component: () => import('@/views/manage/DashboardView.vue')
- },
- {
- path: '/admin/files',
- name: 'FileManage',
- component: () => import('@/views/manage/FileManageView.vue')
- },
- {
- path: '/admin/settings',
- name: 'Settings',
- component: () => import('@/views/manage/SystemSettingsView.vue')
+const publicPageMeta = {
+ showGlobalControls: true,
+ showRouteLoading: false,
+ routeTransition: 'transfer-fade'
+}
+
+const adminPageMeta = {
+ requiresAuth: true,
+ showGlobalControls: false,
+ showRouteLoading: false
+}
+
+const routes: RouteRecordRaw[] = [
+ {
+ path: '/',
+ name: ROUTE_NAMES.RETRIEVE,
+ component: () => import('@/views/RetrievewFileView.vue'),
+ meta: {
+ ...publicPageMeta,
+ title: 'retrieve'
+ }
+ },
+ {
+ path: ROUTES.SEND,
+ name: ROUTE_NAMES.SEND,
+ component: () => import('@/views/SendFileView.vue'),
+ meta: {
+ ...publicPageMeta,
+ title: 'send'
+ }
+ },
+ {
+ path: ROUTES.ADMIN,
+ name: ROUTE_NAMES.ADMIN,
+ component: () => import('@/layout/AdminLayout/AdminLayout.vue'),
+ redirect: ROUTES.DASHBOARD,
+ meta: adminPageMeta,
+ children: [
+ {
+ path: 'dashboard',
+ name: ROUTE_NAMES.DASHBOARD,
+ component: () => import('@/views/manage/DashboardView.vue'),
+ meta: {
+ ...adminPageMeta,
+ title: 'dashboard'
+ }
+ },
+ {
+ path: 'files',
+ name: ROUTE_NAMES.FILE_MANAGE,
+ component: () => import('@/views/manage/FileManageView.vue'),
+ meta: {
+ ...adminPageMeta,
+ title: 'files'
}
- ]
- },
- {
- path: '/login',
- name: 'Login',
- component: () => import('@/views/manage/LoginView.vue')
+ },
+ {
+ path: 'settings',
+ name: ROUTE_NAMES.SETTINGS,
+ component: () => import('@/views/manage/SystemSettingsView.vue'),
+ meta: {
+ ...adminPageMeta,
+ title: 'settings'
+ }
+ }
+ ]
+ },
+ {
+ path: ROUTES.LOGIN,
+ name: ROUTE_NAMES.LOGIN,
+ component: () => import('@/views/manage/LoginView.vue'),
+ meta: {
+ showGlobalControls: true,
+ showRouteLoading: true,
+ title: 'login'
}
- ]
+ },
+ {
+ path: '/:pathMatch(.*)*',
+ redirect: ROUTES.HOME
+ }
+]
+
+const router = createRouter({
+ history: createWebHashHistory(import.meta.env.BASE_URL),
+ routes
})
+router.beforeEach((to) => {
+ if (to.meta.requiresAuth && !hasValidStoredAdminSession()) {
+ return {
+ path: ROUTES.LOGIN,
+ query: {
+ redirect: to.fullPath
+ }
+ }
+ }
+})
export default router
diff --git a/src/services/auth.ts b/src/services/auth.ts
new file mode 100644
index 0000000..47fb448
--- /dev/null
+++ b/src/services/auth.ts
@@ -0,0 +1,16 @@
+import api from './client'
+import type { AdminUser, ApiResponse } from '@/types'
+
+export class AuthService {
+ static async login(password: string): Promise
> {
+ return api.post('/admin/login', { password })
+ }
+
+ static async logout(): Promise {
+ return api.post('/admin/logout')
+ }
+
+ static async verifyToken(): Promise> {
+ return api.get('/admin/verify')
+ }
+}
diff --git a/src/services/client.ts b/src/services/client.ts
new file mode 100644
index 0000000..15a26ea
--- /dev/null
+++ b/src/services/client.ts
@@ -0,0 +1,71 @@
+import axios, { type AxiosError, type InternalAxiosRequestConfig } from 'axios'
+import { API_STATUS_CODES, TIME_CONSTANTS } from '@/constants'
+import type { ApiErrorPayload } from '@/types'
+import { clearStoredToken, hasValidStoredAdminSession, readStoredToken } from '@/utils/auth-storage'
+
+export const AUTH_EVENTS = {
+ UNAUTHORIZED: 'filecodebox:auth:unauthorized',
+ SETUP_REQUIRED: 'filecodebox:setup:required'
+} as const
+
+const rawBaseURL =
+ import.meta.env.MODE === 'production'
+ ? import.meta.env.VITE_API_BASE_URL_PROD
+ : import.meta.env.VITE_API_BASE_URL_DEV
+
+export const apiBaseURL = typeof rawBaseURL === 'string' ? rawBaseURL.replace(/\/+$/, '') : ''
+
+const clientOptions = {
+ baseURL: apiBaseURL,
+ timeout: TIME_CONSTANTS.REQUEST_TIMEOUT,
+ headers: {
+ 'Content-Type': 'application/json'
+ }
+}
+
+const apiClient = axios.create(clientOptions)
+export const rawApiClient = axios.create(clientOptions)
+
+const attachAuthToken = (config: InternalAxiosRequestConfig) => {
+ if (hasValidStoredAdminSession()) {
+ const token = readStoredToken()
+ config.headers.Authorization = `Bearer ${token}`
+ } else {
+ clearStoredToken()
+ }
+ return config
+}
+
+const getSetupPath = (payload?: ApiErrorPayload) => {
+ const detail = payload?.detail
+ if (detail && typeof detail === 'object' && typeof detail.setup === 'string') {
+ return detail.setup
+ }
+ return ''
+}
+
+const handleAuthError = (error: AxiosError) => {
+ if (error.response?.status === API_STATUS_CODES.SETUP_REQUIRED) {
+ window.dispatchEvent(
+ new CustomEvent(AUTH_EVENTS.SETUP_REQUIRED, {
+ detail: { setupPath: getSetupPath(error.response.data) }
+ })
+ )
+ }
+
+ if (error.response?.status === API_STATUS_CODES.UNAUTHORIZED) {
+ clearStoredToken()
+ window.dispatchEvent(new CustomEvent(AUTH_EVENTS.UNAUTHORIZED))
+ }
+ return Promise.reject(error)
+}
+
+apiClient.interceptors.request.use(attachAuthToken, (error) => Promise.reject(error))
+
+rawApiClient.interceptors.request.use(attachAuthToken, (error) => Promise.reject(error))
+
+apiClient.interceptors.response.use((response) => response.data, handleAuthError)
+
+rawApiClient.interceptors.response.use((response) => response, handleAuthError)
+
+export default apiClient
diff --git a/src/services/config.ts b/src/services/config.ts
new file mode 100644
index 0000000..4a48b6a
--- /dev/null
+++ b/src/services/config.ts
@@ -0,0 +1,52 @@
+import api from './client'
+import type { ApiResponse, ConfigState, PublicConfigPayload } from '@/types'
+
+const isPublicConfigEnvelope = (
+ detail: ConfigState | PublicConfigPayload | null | undefined
+): detail is PublicConfigPayload => {
+ return !!detail && typeof detail === 'object' && 'config' in detail
+}
+
+const normalizeUserConfigResponse = (
+ response: ApiResponse
+): ApiResponse => {
+ if (isPublicConfigEnvelope(response.detail)) {
+ return {
+ ...response,
+ detail: {
+ config: response.detail.config,
+ meta: response.detail.meta
+ }
+ }
+ }
+
+ return {
+ ...response,
+ detail: {
+ config: response.detail ?? {}
+ }
+ }
+}
+
+export class ConfigService {
+ static async getConfig(): Promise> {
+ return api.get('/admin/config/get')
+ }
+
+ static async getUserConfig(): Promise> {
+ try {
+ const response = (await api.get('/api/v1/config')) as ApiResponse<
+ ConfigState | PublicConfigPayload
+ >
+
+ return normalizeUserConfigResponse(response)
+ } catch {
+ const response = (await api.post('/')) as ApiResponse
+ return normalizeUserConfigResponse(response)
+ }
+ }
+
+ static async updateConfig(config: Partial): Promise {
+ return api.patch('/admin/config/update', config)
+ }
+}
diff --git a/src/services/file.ts b/src/services/file.ts
new file mode 100644
index 0000000..b1eef53
--- /dev/null
+++ b/src/services/file.ts
@@ -0,0 +1,272 @@
+import api, { rawApiClient } from './client'
+import { multipartUploadConfig } from './shared'
+import type {
+ AdminBatchDeleteFilesResponse,
+ AdminBatchPolicyActionRequest,
+ AdminBatchPolicyActionResponse,
+ AdminBatchUpdateFilesRequest,
+ AdminBatchUpdateFilesResponse,
+ AdminFilePatchPayload,
+ AdminFileDetailResponse,
+ AdminFileListParams,
+ AdminFileMetadataRequest,
+ AdminFilePolicyActionRequest,
+ AdminFilePreviewResponse,
+ AdminFileViewPreset,
+ AdminFileViewPresetRequest,
+ AdminFileViewPresetsResponse,
+ ApiResponse,
+ ChunkUploadCompleteRequest,
+ ChunkUploadInitRequest,
+ ChunkUploadInitResponse,
+ ChunkUploadResponse,
+ FileEditForm,
+ FileInfo,
+ FileListResponse,
+ FileUploadResponse,
+ ShareMetadataResponse,
+ ShareSelectResponse,
+ TextSendResponse,
+ UploadProgress
+} from '@/types'
+
+const urlEncodedConfig = {
+ headers: {
+ 'Content-Type': 'application/x-www-form-urlencoded'
+ }
+}
+
+const toUrlEncodedForm = (data: Record) => {
+ const form = new URLSearchParams()
+ Object.entries(data).forEach(([key, value]) => {
+ form.append(key, String(value))
+ })
+ return form
+}
+
+const isMethodFallbackError = (error: unknown) => {
+ const status = (error as { response?: { status?: number } })?.response?.status
+ return status === 404 || status === 405
+}
+
+export class FileService {
+ static async uploadFile(
+ file: File,
+ onProgress?: (progress: UploadProgress) => void
+ ): Promise> {
+ const formData = new FormData()
+ formData.append('file', file)
+
+ return api.post('/share/file/', formData, multipartUploadConfig(onProgress))
+ }
+
+ static async uploadText(
+ text: string,
+ expireValue = 1,
+ expireStyle = 'day'
+ ): Promise> {
+ const formData = new FormData()
+ formData.append('text', text)
+ formData.append('expire_value', String(expireValue))
+ formData.append('expire_style', expireStyle)
+ return api.post('/share/text/', formData, multipartUploadConfig())
+ }
+
+ static async initChunkUpload(
+ request: ChunkUploadInitRequest
+ ): Promise> {
+ return api.post(
+ '/chunk/upload/init/',
+ toUrlEncodedForm({
+ file_name: request.file_name,
+ file_size: request.file_size,
+ chunk_size: request.chunk_size,
+ file_hash: request.file_hash
+ }),
+ urlEncodedConfig
+ )
+ }
+
+ static async uploadChunk(
+ uploadId: string,
+ chunkIndex: number,
+ chunk: Blob,
+ onProgress?: (progress: UploadProgress) => void
+ ): Promise> {
+ const formData = new FormData()
+ formData.append('chunk', chunk)
+ return api.post(
+ `/chunk/upload/chunk/${uploadId}/${chunkIndex}`,
+ formData,
+ multipartUploadConfig(onProgress)
+ )
+ }
+
+ static async completeChunkUpload(
+ uploadId: string,
+ request: ChunkUploadCompleteRequest
+ ): Promise> {
+ return api.post(
+ `/chunk/upload/complete/${uploadId}`,
+ toUrlEncodedForm({
+ expire_value: request.expire_value,
+ expire_style: request.expire_style
+ }),
+ urlEncodedConfig
+ )
+ }
+
+ static async selectFile(code: string): Promise> {
+ return api.post('/share/select/', { code })
+ }
+
+ static async inspectFile(code: string): Promise> {
+ return api.post('/share/metadata/', { code })
+ }
+
+ static async getFile(code: string): Promise> {
+ return api.get(`/file/${code}`)
+ }
+
+ static async downloadFile(code: string): Promise {
+ const response = await rawApiClient.get(`/download/${code}`, {
+ responseType: 'blob'
+ })
+ return response.data
+ }
+
+ static async getAdminFileList(
+ params: AdminFileListParams
+ ): Promise> {
+ return api.get('/admin/file/list', { params })
+ }
+
+ static async getAdminFileDetail(id: number): Promise> {
+ return api.get('/admin/file/detail', {
+ params: { id }
+ })
+ }
+
+ static async updateFile(data: FileEditForm | AdminFilePatchPayload): Promise {
+ return api.patch('/admin/file/update', data)
+ }
+
+ static async applyAdminFilePolicyAction(
+ data: AdminFilePolicyActionRequest
+ ): Promise> {
+ try {
+ return await api.patch('/admin/file/policy-action', data)
+ } catch (error: unknown) {
+ if (!isMethodFallbackError(error)) {
+ throw error
+ }
+
+ return api.post('/admin/file/policy-action', data)
+ }
+ }
+
+ static async applyAdminFilesPolicyAction(
+ data: AdminBatchPolicyActionRequest
+ ): Promise> {
+ try {
+ return await api.patch('/admin/file/batch-policy-action', data)
+ } catch (error: unknown) {
+ if (!isMethodFallbackError(error)) {
+ throw error
+ }
+
+ return api.post('/admin/file/batch-policy-action', data)
+ }
+ }
+
+ static async updateAdminFileMetadata(
+ data: AdminFileMetadataRequest
+ ): Promise> {
+ try {
+ return await api.patch('/admin/file/metadata', data)
+ } catch (error: unknown) {
+ if (!isMethodFallbackError(error)) {
+ throw error
+ }
+
+ return api.post('/admin/file/metadata', data)
+ }
+ }
+
+ static async getAdminFileViewPresets(): Promise> {
+ return api.get('/admin/file/view-presets')
+ }
+
+ static async saveAdminFileViewPreset(
+ data: AdminFileViewPresetRequest
+ ): Promise> {
+ try {
+ return await api.patch('/admin/file/view-presets', data)
+ } catch (error: unknown) {
+ if (!isMethodFallbackError(error)) {
+ throw error
+ }
+
+ return api.post('/admin/file/view-presets', data)
+ }
+ }
+
+ static async updateAdminFiles(
+ data: AdminBatchUpdateFilesRequest
+ ): Promise> {
+ return api.patch('/admin/file/batch-update', data)
+ }
+
+ static async deleteAdminFile(id: number): Promise {
+ return api.delete('/admin/file/delete', {
+ data: { id }
+ })
+ }
+
+ static async deleteAdminFiles(
+ ids: number[]
+ ): Promise> {
+ return api.delete('/admin/file/batch-delete', {
+ data: { ids }
+ })
+ }
+
+ static async deleteAdminFileViewPreset(id: string): Promise {
+ try {
+ return await api.delete('/admin/file/view-presets', {
+ data: { id }
+ })
+ } catch (error: unknown) {
+ if (!isMethodFallbackError(error)) {
+ throw error
+ }
+
+ return api.post('/admin/file/view-presets/delete', { id })
+ }
+ }
+
+ static async downloadAdminFile(
+ id: number
+ ): Promise<{ data: Blob; headers: Record }> {
+ const response = await rawApiClient.get('/admin/file/download', {
+ params: { id },
+ responseType: 'blob'
+ })
+ return {
+ data: response.data,
+ headers: response.headers as Record
+ }
+ }
+
+ static async previewAdminFile(
+ id: number,
+ maxChars = 20000
+ ): Promise> {
+ return api.get('/admin/file/preview', {
+ params: {
+ id,
+ maxChars
+ }
+ })
+ }
+}
diff --git a/src/services/index.ts b/src/services/index.ts
index 8ed9e4d..38e6d24 100644
--- a/src/services/index.ts
+++ b/src/services/index.ts
@@ -1,266 +1,7 @@
-// API 服务层
-import api from '@/utils/api'
-import axios from 'axios'
-import type {
- ApiResponse,
- FileInfo,
- ConfigState,
- AdminUser,
- FileUploadResponse,
- TextSendResponse,
- DashboardData,
- FileListResponse,
- FileEditForm
-} from '@/types'
-import type {
- UploadProgress,
- PresignInitRequest,
- PresignInitResponse,
- PresignConfirmRequest,
- PresignUploadResult,
- PresignStatusResponse
-} from '@/types'
-
-// 系统配置服务
-export class ConfigService {
- static async getConfig(): Promise> {
- return api.get('/admin/config/get')
- }
- static async getUserConfig(): Promise> {
- return api.post('/')
- }
- static async updateConfig(config: Partial): Promise {
- return api.patch('/admin/config/update', config)
- }
-}
-
-// 文件服务
-export class FileService {
- static async uploadFile(
- file: File,
- onProgress?: (progress: UploadProgress) => void
- ): Promise> {
- const formData = new FormData()
- formData.append('file', file)
-
- return api.post('/share/file/', formData, {
- headers: {
- 'Content-Type': 'multipart/form-data'
- },
- timeout: 0, // 禁用超时限制
- onUploadProgress: (progressEvent) => {
- if (onProgress && progressEvent.total) {
- const progress: UploadProgress = {
- loaded: progressEvent.loaded,
- total: progressEvent.total,
- percentage: Math.round((progressEvent.loaded * 100) / progressEvent.total)
- }
- onProgress(progress)
- }
- }
- })
- }
-
- static async uploadText(text: string): Promise> {
- return api.post('/share/text/', { content: text })
- }
-
- static async getFile(code: string): Promise> {
- return api.get(`/file/${code}`)
- }
-
- static async downloadFile(code: string): Promise {
- const response = await api.get(`/download/${code}`, {
- responseType: 'blob'
- })
- return response.data
- }
-
- static async deleteFile(fileId: string): Promise {
- return api.post('/admin/file/delete', { id: fileId })
- }
-
- static async getFileList(
- page = 1,
- limit = 10
- ): Promise<
- ApiResponse<{
- files: FileInfo[]
- total: number
- page: number
- limit: number
- }>
- > {
- return api.get('/admin/file/list', {
- params: { page, size: limit }
- })
- }
-
- // 文件管理相关方法
- static async getAdminFileList(params: {
- page: number
- size: number
- keyword?: string
- }): Promise> {
- return api.get('/admin/file/list', { params })
- }
-
- static async updateFile(data: FileEditForm): Promise {
- return api.patch('/admin/file/update', data)
- }
-
- static async deleteAdminFile(id: number): Promise {
- return api.delete('/admin/file/delete', {
- data: { id }
- })
- }
-
- static async downloadAdminFile(
- id: number
- ): Promise<{ data: Blob; headers: Record }> {
- return api.get('/admin/file/download', {
- params: { id },
- responseType: 'blob'
- })
- }
-}
-
-// 认证服务
-export class AuthService {
- static async login(password: string): Promise> {
- return api.post('/admin/login', { password })
- }
-
- static async logout(): Promise {
- return api.post('/admin/logout')
- }
-
- static async verifyToken(): Promise> {
- return api.get('/admin/verify')
- }
-}
-
-// 统计服务
-export class StatsService {
- static async getDashboardStats(): Promise<
- ApiResponse<{
- totalFiles: number
- totalDownloads: number
- todayUploads: number
- todayDownloads: number
- storageUsed: number
- recentFiles: FileInfo[]
- }>
- > {
- return api.get('/admin/dashboard')
- }
-
- static async getDashboard(): Promise> {
- return api.get('/admin/dashboard')
- }
-}
-
-// 预签名上传服务
-export class PresignUploadService {
- /**
- * 初始化上传会话
- */
- static async initUpload(request: PresignInitRequest): Promise> {
- return api.post('/presign/upload/init', request)
- }
-
- /**
- * 代理模式上传
- */
- static async proxyUpload(
- uploadId: string,
- file: File,
- onProgress?: (progress: UploadProgress) => void
- ): Promise> {
- const formData = new FormData()
- formData.append('file', file)
-
- return api.put(`/presign/upload/proxy/${uploadId}`, formData, {
- headers: {
- 'Content-Type': 'multipart/form-data'
- },
- timeout: 0,
- onUploadProgress: (progressEvent) => {
- if (onProgress && progressEvent.total) {
- const progress: UploadProgress = {
- loaded: progressEvent.loaded,
- total: progressEvent.total,
- percentage: Math.round((progressEvent.loaded * 100) / progressEvent.total)
- }
- onProgress(progress)
- }
- }
- })
- }
-
- /**
- * 确认直传上传
- */
- static async confirmUpload(
- uploadId: string,
- request?: PresignConfirmRequest
- ): Promise> {
- return api.post(`/presign/upload/confirm/${uploadId}`, request || {})
- }
-
- /**
- * 查询上传状态
- */
- static async getUploadStatus(uploadId: string): Promise> {
- return api.get(`/presign/upload/status/${uploadId}`)
- }
-
- /**
- * 取消上传
- */
- static async cancelUpload(uploadId: string): Promise> {
- return api.delete(`/presign/upload/${uploadId}`)
- }
-
- /**
- * S3 直传(不经过后端)
- */
- static async directUploadToS3(
- uploadUrl: string,
- file: File,
- onProgress?: (progress: UploadProgress) => void
- ): Promise {
- try {
- await axios.put(uploadUrl, file, {
- headers: {
- 'Content-Type': 'application/octet-stream'
- },
- timeout: 0,
- onUploadProgress: (progressEvent) => {
- if (onProgress && progressEvent.total) {
- const progress: UploadProgress = {
- loaded: progressEvent.loaded,
- total: progressEvent.total,
- percentage: Math.round((progressEvent.loaded * 100) / progressEvent.total)
- }
- onProgress(progress)
- }
- }
- })
- return true
- } catch {
- return false
- }
- }
-}
-
-// 导出所有服务
-export const services = {
- config: ConfigService,
- file: FileService,
- auth: AuthService,
- stats: StatsService,
- presignUpload: PresignUploadService
-}
-
-export default services
+export { AUTH_EVENTS } from './client'
+export { AuthService } from './auth'
+export { ConfigService } from './config'
+export { FileService } from './file'
+export { PresignUploadService } from './presign-upload'
+export { StatsService } from './stats'
+export { uploadChunkedFile } from './upload-strategy'
diff --git a/src/services/presign-upload.ts b/src/services/presign-upload.ts
new file mode 100644
index 0000000..47c6684
--- /dev/null
+++ b/src/services/presign-upload.ts
@@ -0,0 +1,58 @@
+import api from './client'
+import { multipartUploadConfig } from './shared'
+import { uploadToExternalUrl } from './upload-client'
+import type {
+ ApiResponse,
+ PresignConfirmRequest,
+ PresignInitRequest,
+ PresignInitResponse,
+ PresignStatusResponse,
+ PresignUploadResult,
+ UploadProgress
+} from '@/types'
+
+export class PresignUploadService {
+ static async initUpload(request: PresignInitRequest): Promise> {
+ return api.post('/presign/upload/init', request)
+ }
+
+ static async proxyUpload(
+ uploadId: string,
+ file: File,
+ onProgress?: (progress: UploadProgress) => void,
+ uploadUrl?: string
+ ): Promise> {
+ const formData = new FormData()
+ formData.append('file', file)
+
+ return api.put(
+ uploadUrl || `/presign/upload/proxy/${uploadId}`,
+ formData,
+ multipartUploadConfig(onProgress)
+ )
+ }
+
+ static async confirmUpload(
+ uploadId: string,
+ request?: PresignConfirmRequest
+ ): Promise> {
+ return api.post(`/presign/upload/confirm/${uploadId}`, request || {})
+ }
+
+ static async getUploadStatus(uploadId: string): Promise> {
+ return api.get(`/presign/upload/status/${uploadId}`)
+ }
+
+ static async cancelUpload(uploadId: string): Promise> {
+ return api.delete(`/presign/upload/${uploadId}`)
+ }
+
+ static async directUploadToS3(
+ uploadUrl: string,
+ file: File,
+ onProgress?: (progress: UploadProgress) => void
+ ): Promise {
+ await uploadToExternalUrl(uploadUrl, file, onProgress)
+ return true
+ }
+}
diff --git a/src/services/shared.ts b/src/services/shared.ts
new file mode 100644
index 0000000..58c5535
--- /dev/null
+++ b/src/services/shared.ts
@@ -0,0 +1,20 @@
+import type { AxiosRequestConfig } from 'axios'
+import type { UploadProgress } from '@/types'
+
+export const multipartUploadConfig = (
+ onProgress?: (progress: UploadProgress) => void
+): AxiosRequestConfig => ({
+ headers: {
+ 'Content-Type': 'multipart/form-data'
+ },
+ timeout: 0,
+ onUploadProgress: (progressEvent) => {
+ if (onProgress && progressEvent.total) {
+ onProgress({
+ loaded: progressEvent.loaded,
+ total: progressEvent.total,
+ percentage: Math.round((progressEvent.loaded * 100) / progressEvent.total)
+ })
+ }
+ }
+})
diff --git a/src/services/stats.ts b/src/services/stats.ts
new file mode 100644
index 0000000..c275a12
--- /dev/null
+++ b/src/services/stats.ts
@@ -0,0 +1,8 @@
+import api from './client'
+import type { ApiResponse, DashboardData } from '@/types'
+
+export class StatsService {
+ static async getDashboard(): Promise> {
+ return api.get('/admin/dashboard')
+ }
+}
diff --git a/src/services/upload-client.ts b/src/services/upload-client.ts
new file mode 100644
index 0000000..062fe77
--- /dev/null
+++ b/src/services/upload-client.ts
@@ -0,0 +1,26 @@
+import axios from 'axios'
+import type { UploadProgress } from '@/types'
+
+export async function uploadToExternalUrl(
+ uploadUrl: string,
+ file: File,
+ onProgress?: (progress: UploadProgress) => void
+): Promise {
+ await axios.put(uploadUrl, file, {
+ headers: {
+ 'Content-Type': 'application/octet-stream'
+ },
+ timeout: 0,
+ onUploadProgress: (progressEvent) => {
+ if (!onProgress || !progressEvent.total) {
+ return
+ }
+
+ onProgress({
+ loaded: progressEvent.loaded,
+ total: progressEvent.total,
+ percentage: Math.round((progressEvent.loaded * 100) / progressEvent.total)
+ })
+ }
+ })
+}
diff --git a/src/services/upload-strategy.ts b/src/services/upload-strategy.ts
new file mode 100644
index 0000000..933a483
--- /dev/null
+++ b/src/services/upload-strategy.ts
@@ -0,0 +1,97 @@
+import { FileService } from './file'
+import type { ApiResponse, ChunkUploadInitResponse, FileUploadResponse, UploadProgress } from '@/types'
+import { calculateFileHash } from '@/utils/file-processing'
+
+const CHUNK_SIZE = 5 * 1024 * 1024
+
+type ChunkedUploadOptions = {
+ expireValue: number
+ expireStyle: string
+ onHashCalculated?: (hash: string) => void
+ onProgress?: (progress: UploadProgress) => void
+ messages?: {
+ initFailed?: string
+ chunkFailed?: (index: number) => string
+ completeFailed?: string
+ }
+}
+
+type ChunkedUploadResult = ChunkUploadInitResponse | FileUploadResponse
+
+const calculateCompletedBytes = (uploadedChunks: Set, chunkSize: number, fileSize: number) =>
+ Array.from(uploadedChunks).reduce((total, index) => {
+ const chunkStart = index * chunkSize
+ const chunkEnd = Math.min((index + 1) * chunkSize, fileSize)
+ return total + Math.max(0, chunkEnd - chunkStart)
+ }, 0)
+
+export const uploadChunkedFile = async (
+ file: File,
+ options: ChunkedUploadOptions
+): Promise> => {
+ const fileHash = await calculateFileHash(file)
+ options.onHashCalculated?.(fileHash)
+
+ const chunks = Math.ceil(file.size / CHUNK_SIZE)
+ const initResponse = await FileService.initChunkUpload({
+ file_name: file.name,
+ file_size: file.size,
+ chunk_size: CHUNK_SIZE,
+ file_hash: fileHash
+ })
+
+ if (initResponse.code !== 200) {
+ throw new Error(options.messages?.initFailed || 'Init chunk upload failed')
+ }
+
+ if (initResponse.detail?.existed) {
+ return initResponse
+ }
+
+ const initDetail = initResponse.detail
+ const uploadId = initDetail?.upload_id
+ if (!uploadId) {
+ throw new Error(options.messages?.initFailed || 'Init chunk upload failed')
+ }
+
+ const uploadedChunks = new Set(initDetail.uploaded_chunks || [])
+ for (let index = 0; index < chunks; index++) {
+ if (uploadedChunks.has(index)) {
+ continue
+ }
+
+ const start = index * CHUNK_SIZE
+ const end = Math.min(start + CHUNK_SIZE, file.size)
+ const chunk = file.slice(start, end)
+ const chunkResponse = await FileService.uploadChunk(
+ uploadId,
+ index,
+ new Blob([chunk], { type: file.type }),
+ (progress) => {
+ const completedBytes = calculateCompletedBytes(uploadedChunks, CHUNK_SIZE, file.size)
+ const percentage = Math.round(((completedBytes + progress.loaded) * 100) / file.size)
+ options.onProgress?.({
+ loaded: completedBytes + progress.loaded,
+ total: file.size,
+ percentage: Math.min(percentage, 99)
+ })
+ }
+ )
+
+ if (chunkResponse.code !== 200) {
+ throw new Error(options.messages?.chunkFailed?.(index) || `Chunk upload failed: ${index}`)
+ }
+ uploadedChunks.add(index)
+ }
+
+ const completeResponse = await FileService.completeChunkUpload(uploadId, {
+ expire_value: options.expireValue,
+ expire_style: options.expireStyle
+ })
+
+ if (completeResponse.code !== 200) {
+ throw new Error(options.messages?.completeFailed || 'Complete chunk upload failed')
+ }
+
+ return completeResponse
+}
diff --git a/src/stores/adminStore.ts b/src/stores/adminStore.ts
index 639e9f6..f855f6e 100644
--- a/src/stores/adminStore.ts
+++ b/src/stores/adminStore.ts
@@ -1,70 +1,116 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
-import { STORAGE_KEYS } from '@/constants'
import type { AdminUser } from '@/types'
+import {
+ clearStoredAuth,
+ clearStoredToken,
+ hasValidStoredAdminSession,
+ readStoredAdminPassword,
+ readStoredToken,
+ readStoredTokenExpiresAt,
+ writeStoredAdminPassword,
+ writeStoredToken
+} from '@/utils/auth-storage'
export const useAdminStore = defineStore('admin', () => {
+ const MAX_TIMER_DELAY = 2_147_483_647
+ let expirationTimer: ReturnType | null = null
+
// 状态
- const adminPassword = ref(localStorage.getItem(STORAGE_KEYS.ADMIN_PASSWORD) || '')
- const token = ref(localStorage.getItem(STORAGE_KEYS.TOKEN) || '')
- const isLoggedIn = ref(false)
+ const storedSessionIsValid = hasValidStoredAdminSession()
+ if (!storedSessionIsValid) clearStoredToken()
+ const adminPassword = ref(readStoredAdminPassword())
+ const token = ref(storedSessionIsValid ? readStoredToken() : '')
+ const expiresAt = ref(storedSessionIsValid ? readStoredTokenExpiresAt() : null)
+ const isLoggedIn = ref(!!token.value)
const userInfo = ref(null)
-
+
// 计算属性
const isAuthenticated = computed(() => {
return isLoggedIn.value && !!token.value
})
-
+ const hasToken = computed(() => !!token.value)
+
+ const clearExpirationTimer = () => {
+ if (expirationTimer) clearTimeout(expirationTimer)
+ expirationTimer = null
+ }
+
+ const scheduleExpiration = (sessionExpiresAt: number | null) => {
+ clearExpirationTimer()
+ if (!sessionExpiresAt) return
+
+ const remaining = sessionExpiresAt * 1000 - Date.now()
+ if (remaining <= 0) {
+ logout()
+ return
+ }
+ expirationTimer = setTimeout(
+ () => scheduleExpiration(sessionExpiresAt),
+ Math.min(remaining, MAX_TIMER_DELAY)
+ )
+ }
+
// 方法
const updateAdminPassword = (pwd: string) => {
adminPassword.value = pwd
- localStorage.setItem(STORAGE_KEYS.ADMIN_PASSWORD, pwd)
+ writeStoredAdminPassword(pwd)
}
-
- const setToken = (newToken: string) => {
+
+ const setToken = (newToken: string, newExpiresAt?: number) => {
token.value = newToken
- localStorage.setItem(STORAGE_KEYS.TOKEN, newToken)
+ expiresAt.value = newExpiresAt ?? null
+ writeStoredToken(newToken, newExpiresAt)
+ scheduleExpiration(expiresAt.value)
}
-
+
const setUserInfo = (user: AdminUser) => {
userInfo.value = user
isLoggedIn.value = true
- setToken(user.token)
+ setToken(user.token, user.expires_at)
}
-
+
const login = (user: AdminUser) => {
setUserInfo(user)
}
-
+
const logout = () => {
+ clearExpirationTimer()
adminPassword.value = ''
token.value = ''
+ expiresAt.value = null
isLoggedIn.value = false
userInfo.value = null
-
- // 清除本地存储
- localStorage.removeItem(STORAGE_KEYS.ADMIN_PASSWORD)
- localStorage.removeItem(STORAGE_KEYS.TOKEN)
+
+ clearStoredAuth()
}
-
+
const initAuth = () => {
- const storedToken = localStorage.getItem(STORAGE_KEYS.TOKEN)
- if (storedToken) {
+ if (hasValidStoredAdminSession()) {
+ const storedToken = readStoredToken()
token.value = storedToken
+ expiresAt.value = readStoredTokenExpiresAt()
isLoggedIn.value = true
+ scheduleExpiration(expiresAt.value)
+ } else {
+ logout()
}
}
-
+
+ scheduleExpiration(expiresAt.value)
+
return {
// 状态
adminPassword,
token,
+ expiresAt,
isLoggedIn,
userInfo,
-
+
// 计算属性
isAuthenticated,
-
+ hasToken,
+
// 方法
updateAdminPassword,
setToken,
@@ -74,6 +120,3 @@ export const useAdminStore = defineStore('admin', () => {
initAuth
}
})
-
-// 保持向后兼容
-export const useAdminData = useAdminStore
diff --git a/src/stores/alertStore.ts b/src/stores/alertStore.ts
index 6eb6ff0..21b7e2d 100644
--- a/src/stores/alertStore.ts
+++ b/src/stores/alertStore.ts
@@ -2,6 +2,10 @@ import { defineStore } from 'pinia'
import type { Alert, AlertType } from '@/types'
import { TIME_CONSTANTS } from '@/constants'
+let progressTimer: ReturnType | null = null
+let alertIdSeed = 0
+const alertRemoveTimers = new Map>()
+
export const useAlertStore = defineStore('alert', {
state: () => ({
alerts: [] as Alert[]
@@ -12,16 +16,28 @@ export const useAlertStore = defineStore('alert', {
type: AlertType = 'info',
duration = TIME_CONSTANTS.ALERT_DURATION
) {
- const id = Date.now()
+ const id = Date.now() + alertIdSeed
+ alertIdSeed = (alertIdSeed + 1) % 1000
const startTime = Date.now()
this.alerts.push({ id, message, type, progress: 100, duration, startTime })
- setTimeout(() => this.removeAlert(id), duration)
+ alertRemoveTimers.set(id, setTimeout(() => this.removeAlert(id), duration))
+ this.startProgressTimer()
},
removeAlert(id: number) {
+ const removeTimer = alertRemoveTimers.get(id)
+ if (removeTimer) {
+ clearTimeout(removeTimer)
+ alertRemoveTimers.delete(id)
+ }
+
const index = this.alerts.findIndex((alert) => alert.id === id)
if (index > -1) {
this.alerts.splice(index, 1)
}
+
+ if (this.alerts.length === 0) {
+ this.stopProgressTimer()
+ }
},
updateAlertProgress(id: number) {
const alert = this.alerts.find((a) => a.id === id)
@@ -33,6 +49,25 @@ export const useAlertStore = defineStore('alert', {
this.removeAlert(id)
}
}
+ },
+ startProgressTimer() {
+ if (progressTimer || this.alerts.length === 0) {
+ return
+ }
+
+ progressTimer = setInterval(() => {
+ this.alerts.forEach((alert) => {
+ this.updateAlertProgress(alert.id)
+ })
+ }, TIME_CONSTANTS.PROGRESS_UPDATE_INTERVAL)
+ },
+ stopProgressTimer() {
+ if (!progressTimer) {
+ return
+ }
+
+ clearInterval(progressTimer)
+ progressTimer = null
}
}
})
diff --git a/src/stores/configStore.ts b/src/stores/configStore.ts
new file mode 100644
index 0000000..6400f4b
--- /dev/null
+++ b/src/stores/configStore.ts
@@ -0,0 +1,76 @@
+import { defineStore } from 'pinia'
+import { computed, ref } from 'vue'
+import type { ConfigState, PublicConfigMeta } from '@/types'
+import {
+ DEFAULT_PUBLIC_CONFIG,
+ readNotifyKey,
+ readStoredConfig,
+ toPublicConfig,
+ writeNotifyKey,
+ writeStoredConfig,
+ type PublicConfig
+} from '@/utils/config-storage'
+
+export const useConfigStore = defineStore('config', () => {
+ const config = ref({
+ ...DEFAULT_PUBLIC_CONFIG,
+ ...toPublicConfig(readStoredConfig>())
+ })
+ const publicMeta = ref({})
+
+ const uploadSizeLimit = computed(() => config.value.uploadSize)
+ const appVersion = computed(() => publicMeta.value.version || '')
+
+ const updateConfig = (nextConfig: Partial) => {
+ config.value = {
+ ...DEFAULT_PUBLIC_CONFIG,
+ ...config.value,
+ ...toPublicConfig(nextConfig)
+ }
+ writeStoredConfig(config.value)
+ }
+
+ const applyRemoteConfig = (nextConfig: Partial): string | null => {
+ updateConfig(nextConfig)
+
+ const { notify_title: notifyTitle, notify_content: notifyContent } = nextConfig
+ if (!notifyTitle || !notifyContent) {
+ return null
+ }
+
+ const notifyKey = notifyTitle + notifyContent
+ if (readNotifyKey() === notifyKey) {
+ return null
+ }
+
+ writeNotifyKey(notifyKey)
+ return `${notifyTitle}: ${notifyContent}`
+ }
+
+ const applyPublicMeta = (nextMeta: PublicConfigMeta | undefined) => {
+ if (!nextMeta) return
+
+ publicMeta.value = {
+ ...publicMeta.value,
+ ...nextMeta
+ }
+ }
+
+ const reloadStoredConfig = () => {
+ config.value = {
+ ...DEFAULT_PUBLIC_CONFIG,
+ ...toPublicConfig(readStoredConfig>())
+ }
+ }
+
+ return {
+ appVersion,
+ config,
+ publicMeta,
+ uploadSizeLimit,
+ applyPublicMeta,
+ applyRemoteConfig,
+ updateConfig,
+ reloadStoredConfig
+ }
+})
diff --git a/src/stores/fileData.ts b/src/stores/fileData.ts
index a03519c..a4836fe 100644
--- a/src/stores/fileData.ts
+++ b/src/stores/fileData.ts
@@ -1,197 +1,24 @@
import { defineStore } from 'pinia'
-import { ref, computed } from 'vue'
-import type { FileInfo, UploadProgress, UploadStatus } from '@/types'
-import { UPLOAD_STATUS } from '@/constants'
+import { ref } from 'vue'
+import type { ReceivedFileRecord, SentFileRecord } from '@/types'
export const useFileDataStore = defineStore('fileData', () => {
- // 上传相关状态
- const uploadStatus = ref(UPLOAD_STATUS.IDLE)
- const uploadProgress = ref({
- loaded: 0,
- total: 0,
- percentage: 0
- })
- const uploadedCode = ref('')
- const currentFile = ref(null)
-
- // 下载相关状态
- const downloadCode = ref('')
- const fileInfo = ref(null)
- const isDownloading = ref(false)
-
- // 文件列表状态(管理页面使用)
- const fileList = ref([])
- const totalFiles = ref(0)
- const currentPage = ref(1)
- const pageSize = ref(10)
- const isLoadingList = ref(false)
-
- // 接收数据状态(取件记录)
- const receiveData = ref>([])
-
- // 分享数据状态(发送记录)
- const shareData = ref>([])
-
- // 计算属性
- const isUploading = computed(() => uploadStatus.value === UPLOAD_STATUS.UPLOADING)
- const isUploadSuccess = computed(() => uploadStatus.value === UPLOAD_STATUS.SUCCESS)
- const isUploadError = computed(() => uploadStatus.value === UPLOAD_STATUS.ERROR)
- const hasFileInfo = computed(() => fileInfo.value !== null)
- const canDownload = computed(() => hasFileInfo.value && !isDownloading.value)
-
- const totalPages = computed(() => {
- return Math.ceil(totalFiles.value / pageSize.value)
- })
-
- // 上传相关方法
- const setUploadStatus = (status: UploadStatus) => {
- uploadStatus.value = status
- }
-
- const setUploadProgress = (progress: UploadProgress) => {
- uploadProgress.value = progress
- }
-
- const setUploadedCode = (code: string) => {
- uploadedCode.value = code
- }
-
- const setCurrentFile = (file: File | null) => {
- currentFile.value = file
- }
-
- const resetUpload = () => {
- uploadStatus.value = UPLOAD_STATUS.IDLE
- uploadProgress.value = {
- loaded: 0,
- total: 0,
- percentage: 0
- }
- uploadedCode.value = ''
- currentFile.value = null
- }
-
- // 下载相关方法
- const setDownloadCode = (code: string) => {
- downloadCode.value = code
- }
-
- const setFileInfo = (info: FileInfo | null) => {
- fileInfo.value = info
- }
-
- const setDownloading = (loading: boolean) => {
- isDownloading.value = loading
- }
-
- const resetDownload = () => {
- downloadCode.value = ''
- fileInfo.value = null
- isDownloading.value = false
- }
-
- // 文件列表相关方法
- const setFileList = (files: FileInfo[]) => {
- fileList.value = files
- }
-
- const addFile = (file: FileInfo) => {
- fileList.value.unshift(file)
- totalFiles.value += 1
- }
-
- const removeFile = (fileId: string) => {
- const index = fileList.value.findIndex(file => file.id === fileId)
- if (index > -1) {
- fileList.value.splice(index, 1)
- totalFiles.value -= 1
- }
- }
-
- const updateFile = (fileId: string, updates: Partial) => {
- const index = fileList.value.findIndex(file => file.id === fileId)
- if (index > -1) {
- fileList.value[index] = { ...fileList.value[index], ...updates }
- }
- }
-
- const setTotalFiles = (total: number) => {
- totalFiles.value = total
- }
-
- const setCurrentPage = (page: number) => {
- currentPage.value = page
- }
-
- const setPageSize = (size: number) => {
- pageSize.value = size
- }
-
- const setLoadingList = (loading: boolean) => {
- isLoadingList.value = loading
- }
-
- const resetFileList = () => {
- fileList.value = []
- totalFiles.value = 0
- currentPage.value = 1
- isLoadingList.value = false
- }
-
- // 添加分享数据方法
- const addShareData = (data: { code: string; name?: string }) => {
- setUploadedCode(data.code)
- if (data.name) {
- // 如果有文件名,可以创建一个临时的 FileInfo 对象
- const fileInfo: FileInfo = {
- id: data.code,
- name: data.name,
- size: 0,
- type: '',
- uploadTime: new Date().toISOString(),
- downloadCount: 0
- }
- addFile(fileInfo)
- }
- }
+ const receiveData = ref