@@ -92,23 +84,51 @@
diff --git a/src/main.ts b/src/main.ts
index 170dfde..bad6438 100644
--- a/src/main.ts
+++ b/src/main.ts
@@ -5,10 +5,25 @@ import { createPinia } from 'pinia'
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())
app.use(router)
+app.use(i18n)
app.mount('#app')
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
new file mode 100644
index 0000000..38e6d24
--- /dev/null
+++ b/src/services/index.ts
@@ -0,0 +1,7 @@
+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 9986d22..f855f6e 100644
--- a/src/stores/adminStore.ts
+++ b/src/stores/adminStore.ts
@@ -1,11 +1,122 @@
import { defineStore } from 'pinia'
-import { ref } from 'vue'
+import { ref, computed } from 'vue'
+import type { AdminUser } from '@/types'
+import {
+ clearStoredAuth,
+ clearStoredToken,
+ hasValidStoredAdminSession,
+ readStoredAdminPassword,
+ readStoredToken,
+ readStoredTokenExpiresAt,
+ writeStoredAdminPassword,
+ writeStoredToken
+} from '@/utils/auth-storage'
-export const useAdminData = defineStore('adminData', () => {
- const adminPassword = ref(localStorage.getItem('adminPassword') || '')
- function updateAdminPwd(pwd: string) {
+export const useAdminStore = defineStore('admin', () => {
+ const MAX_TIMER_DELAY = 2_147_483_647
+ let expirationTimer: ReturnType | null = null
+
+ // 状态
+ 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('token', pwd)
+ writeStoredAdminPassword(pwd)
+ }
+
+ const setToken = (newToken: string, newExpiresAt?: number) => {
+ token.value = newToken
+ expiresAt.value = newExpiresAt ?? null
+ writeStoredToken(newToken, newExpiresAt)
+ scheduleExpiration(expiresAt.value)
+ }
+
+ const setUserInfo = (user: AdminUser) => {
+ userInfo.value = user
+ isLoggedIn.value = true
+ 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
+
+ clearStoredAuth()
+ }
+
+ const initAuth = () => {
+ 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,
+ setUserInfo,
+ login,
+ logout,
+ initAuth
}
- return { adminPassword, updateAdminPwd }
})
diff --git a/src/stores/alertStore.ts b/src/stores/alertStore.ts
index a25d102..21b7e2d 100644
--- a/src/stores/alertStore.ts
+++ b/src/stores/alertStore.ts
@@ -1,13 +1,10 @@
import { defineStore } from 'pinia'
+import type { Alert, AlertType } from '@/types'
+import { TIME_CONSTANTS } from '@/constants'
-interface Alert {
- id: number
- message: string
- type: 'success' | 'error' | 'warning' | 'info'
- progress: number
- duration: number
- startTime: number
-}
+let progressTimer: ReturnType | null = null
+let alertIdSeed = 0
+const alertRemoveTimers = new Map>()
export const useAlertStore = defineStore('alert', {
state: () => ({
@@ -16,19 +13,31 @@ export const useAlertStore = defineStore('alert', {
actions: {
showAlert(
message: string,
- type: 'success' | 'error' | 'warning' | 'info' = 'info',
- duration = 5000
+ 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)
@@ -40,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 dbeaa9f..a4836fe 100644
--- a/src/stores/fileData.ts
+++ b/src/stores/fileData.ts
@@ -1,39 +1,55 @@
import { defineStore } from 'pinia'
-import { reactive } from 'vue'
+import { ref } from 'vue'
+import type { ReceivedFileRecord, SentFileRecord } from '@/types'
export const useFileDataStore = defineStore('fileData', () => {
- const receiveData = reactive(JSON.parse(localStorage.getItem('receiveData') || '[]') || []) // 接收的数据
- const shareData = reactive(JSON.parse(localStorage.getItem('shareData') || '[]') || []) // 接收的数据
- function save() {
- localStorage.setItem('receiveData', JSON.stringify(receiveData))
- localStorage.setItem('shareData', JSON.stringify(shareData))
+ const receiveData = ref([])
+ const shareData = ref([])
+
+ const addReceiveData = (record: ReceivedFileRecord) => {
+ receiveData.value.push(record)
+ }
+
+ const removeReceiveData = (id: number) => {
+ const index = receiveData.value.findIndex((record) => record.id === id)
+ if (index !== -1) {
+ receiveData.value.splice(index, 1)
+ }
}
- function addReceiveData(data: any) {
- receiveData.unshift(data)
- save()
+
+ const deleteReceiveData = (index: number) => {
+ if (index >= 0 && index < receiveData.value.length) {
+ receiveData.value.splice(index, 1)
+ }
}
- function addShareData(data: any) {
- shareData.unshift(data)
- save()
+ const clearReceiveData = () => {
+ receiveData.value = []
}
- function deleteReceiveData(index: number) {
- receiveData.splice(index, 1)
- save()
+ const addShareDataRecord = (record: SentFileRecord) => {
+ shareData.value.push(record)
}
- function deleteShareData(index: number) {
- shareData.splice(index, 1)
- save()
+ const deleteShareData = (index: number) => {
+ if (index >= 0 && index < shareData.value.length) {
+ shareData.value.splice(index, 1)
+ }
}
+
+ const clearShareData = () => {
+ shareData.value = []
+ }
+
return {
receiveData,
shareData,
- save,
- addShareData,
addReceiveData,
+ removeReceiveData,
deleteReceiveData,
- deleteShareData
+ clearReceiveData,
+ addShareDataRecord,
+ deleteShareData,
+ clearShareData
}
})
diff --git a/src/types/api.ts b/src/types/api.ts
new file mode 100644
index 0000000..37e873a
--- /dev/null
+++ b/src/types/api.ts
@@ -0,0 +1,12 @@
+export interface ApiResponse {
+ code: number
+ message?: string
+ msg?: string
+ detail?: T
+}
+
+export interface ApiErrorPayload {
+ detail?: string | { setup?: string }
+ message?: string
+ msg?: string
+}
diff --git a/src/types/auth.ts b/src/types/auth.ts
new file mode 100644
index 0000000..0f0bb18
--- /dev/null
+++ b/src/types/auth.ts
@@ -0,0 +1,8 @@
+export interface AdminUser {
+ id: string
+ username: string
+ token: string
+ token_type?: string
+ expires_at: number
+ expires_in?: number
+}
diff --git a/src/types/config.ts b/src/types/config.ts
new file mode 100644
index 0000000..f09ac24
--- /dev/null
+++ b/src/types/config.ts
@@ -0,0 +1,73 @@
+export interface SystemConfig {
+ name: string
+ description?: string
+ maxFileSize: number
+ allowedFileTypes: string[]
+ expireDays: number
+ notify_title?: string
+ notify_content?: string
+}
+
+export interface ThemeChoice {
+ key: string
+ name: string
+ author: string
+ version: string
+}
+
+export interface PublicConfigMeta {
+ version?: string
+ api?: Record
+ features?: Record
+ limits?: Record
+}
+
+export interface PublicConfigPayload {
+ config: Partial
+ meta?: PublicConfigMeta
+}
+
+export interface ConfigState {
+ name: string
+ description: string
+ file_storage: string
+ themesChoices: ThemeChoice[]
+ expireStyle: string[]
+ code_generate_type: 'number' | 'secret'
+ adminSessionExpire: number
+ admin_token: string
+ robotsText: string
+ keywords: string
+ notify_title: string
+ notify_content: string
+ openUpload: number
+ uploadSize: number
+ allowed_file_types: string[]
+ allowedFileTypes?: string[]
+ storage_path: string
+ storageLimit: number
+ uploadMinute: number
+ max_save_seconds: number
+ opacity: number
+ enableChunk: number
+ s3_access_key_id: string
+ background: string
+ showAdminAddr: number
+ page_explain: string
+ s3_secret_access_key: string
+ aws_session_token: string
+ s3_signature_version: string
+ s3_addressing_style: string
+ s3_region_name: string
+ s3_bucket_name: string
+ s3_endpoint_url: string
+ s3_hostname: string
+ uploadCount: number
+ errorMinute: number
+ errorCount: number
+ s3_proxy: number
+ themesSelect: string
+ webdav_url: string
+ webdav_username: string
+ webdav_password: string
+}
diff --git a/src/types/dashboard.ts b/src/types/dashboard.ts
new file mode 100644
index 0000000..6c10d0a
--- /dev/null
+++ b/src/types/dashboard.ts
@@ -0,0 +1,98 @@
+import type { AdminFileHealthFilter } from './file'
+
+export interface DashboardHealthSummary {
+ healthAttentionCount: number
+ healthDangerCount: number
+ healthWarningCount: number
+ expiringSoonCount: number
+ storageIssueCount: number
+ neverRetrievedCount: number
+ healthyCount: number
+ permanentCount: number
+}
+
+export interface DashboardData {
+ totalFiles: number
+ storageUsed: number | string
+ yesterdayCount: number
+ todayCount: number
+ yesterdaySize: number | string
+ todaySize: number | string
+ sysUptime: number | null
+ activeCount?: number
+ expiredCount?: number
+ textCount?: number
+ fileCount?: number
+ chunkedCount?: number
+ usedCount?: number
+ storageBackend?: string
+ uploadSizeLimit?: number
+ openUpload?: number
+ enableChunk?: number
+ maxSaveSeconds?: number
+ healthAttentionCount?: number
+ healthDangerCount?: number
+ healthWarningCount?: number
+ expiringSoonCount?: number
+ storageIssueCount?: number
+ neverRetrievedCount?: number
+ healthyCount?: number
+ permanentCount?: number
+ healthSummary?: Partial
+}
+
+export type DashboardViewData = Omit<
+ DashboardData,
+ | keyof DashboardHealthSummary
+ | 'activeCount'
+ | 'expiredCount'
+ | 'textCount'
+ | 'fileCount'
+ | 'chunkedCount'
+ | 'usedCount'
+ | 'storageBackend'
+ | 'uploadSizeLimit'
+ | 'openUpload'
+ | 'enableChunk'
+ | 'maxSaveSeconds'
+ | 'storageUsed'
+ | 'yesterdaySize'
+ | 'todaySize'
+> &
+ DashboardHealthSummary & {
+ hasExtendedStats: boolean
+ activeCount: number
+ expiredCount: number
+ textCount: number
+ fileCount: number
+ chunkedCount: number
+ usedCount: number
+ storageBackend: string
+ uploadSizeLimit: number
+ openUpload: number
+ enableChunk: number
+ maxSaveSeconds: number
+ storageUsed: number
+ yesterdaySize: number
+ todaySize: number
+ storageUsedText: string
+ yesterdaySizeText: string
+ todaySizeText: string
+ uploadSizeLimitText: string
+ sysUptimeText: string
+ activeRatio: number
+ textRatio: number
+ fileRatio: number
+ healthyRatio: number
+ healthAttentionRatio: number
+ todaySizeRatio: number
+ }
+
+export interface DashboardHealthAction {
+ key: string
+ label: string
+ description: string
+ count: number
+ health: AdminFileHealthFilter
+ tone: 'danger' | 'warning' | 'success' | 'neutral'
+}
diff --git a/src/types/file.ts b/src/types/file.ts
new file mode 100644
index 0000000..50fc5a4
--- /dev/null
+++ b/src/types/file.ts
@@ -0,0 +1,491 @@
+export interface FileInfo {
+ id: string
+ name: string
+ size: number
+ type: string
+ uploadTime: string
+ downloadCount: number
+ expireTime?: string
+}
+
+export interface FileListItem {
+ id: number
+ code: string
+ prefix: string
+ suffix: string
+ size: number
+ text?: string
+ description?: string
+ expired_at: string | null
+ expired_count: number | null
+ created_at: string
+ name?: string
+ type?: 'text' | 'file'
+ status?: 'active' | 'expired'
+ isText?: boolean
+ is_text?: boolean
+ isExpired?: boolean
+ is_expired?: boolean
+ isChunked?: boolean
+ is_chunked?: boolean
+ statusInsights?: AdminFileDetailStatusInsights
+ status_insights?: AdminFileDetailStatusInsights
+ remainingDownloads?: number | null
+ remaining_downloads?: number | null
+ usedCount?: number
+ used_count?: number
+ fileHash?: string | null
+ file_hash?: string | null
+}
+
+export interface AdminFileViewItem extends FileListItem {
+ displayName: string
+ displaySize: string
+ displayExpiredAt: string
+ displayUsage: string
+ displayHealthState: string
+ displayHealthAction: string
+ isTextFile: boolean
+ isExpiredFile: boolean
+ isChunkedFile: boolean
+ remainingDownloadsValue: number | null
+ canPreviewText: boolean
+ statusInsightSeverity: AdminFileInsightSeverity
+ statusInsightState: string
+ statusInsightNextAction: string
+ statusInsightReasons: string[]
+}
+
+export interface AdminFileSummary {
+ totalFiles: number
+ activeCount: number
+ expiredCount: number
+ textCount: number
+ fileCount: number
+ chunkedCount: number
+ healthAttentionCount: number
+ healthDangerCount: number
+ healthWarningCount: number
+ expiringSoonCount: number
+ storageIssueCount: number
+ neverRetrievedCount: number
+ healthyCount: number
+ permanentCount: number
+ storageUsed: number
+ usedCount: number
+}
+
+export type AdminFileStatusFilter = 'all' | 'active' | 'expired'
+export type AdminFileTypeFilter = 'all' | 'file' | 'text' | 'chunked'
+export type AdminFileHealthFilter =
+ | 'all'
+ | 'attention'
+ | 'danger'
+ | 'warning'
+ | 'healthy'
+ | 'expired'
+ | 'expiring_soon'
+ | 'storage_issue'
+ | 'never_retrieved'
+ | 'permanent'
+export type AdminFileSortBy = 'created_at' | 'expired_at' | 'name' | 'size' | 'used_count' | 'code'
+export type AdminFileSortOrder = 'asc' | 'desc'
+
+export interface AdminFileListParams {
+ page: number
+ size: number
+ keyword?: string
+ status?: AdminFileStatusFilter
+ type?: AdminFileTypeFilter
+ health?: AdminFileHealthFilter
+ sortBy?: AdminFileSortBy
+ sortOrder?: AdminFileSortOrder
+}
+
+export interface AdminFileViewPresetParams {
+ keyword: string
+ status: AdminFileStatusFilter
+ type: AdminFileTypeFilter
+ health: AdminFileHealthFilter
+ sortBy: AdminFileSortBy
+ sortOrder: AdminFileSortOrder
+ size: number
+}
+
+export interface AdminFileViewPreset {
+ id: string
+ name: string
+ filters?: AdminFileViewPresetParams
+ params?: AdminFileViewPresetParams
+ isBuiltIn?: boolean
+ isDefault?: boolean
+ is_default?: boolean
+ createdAt?: string | null
+ created_at?: string | null
+ updatedAt?: string | null
+ updated_at?: string | null
+}
+
+export interface AdminFileViewPresetRequest {
+ id?: string
+ name: string
+ filters: AdminFileViewPresetParams
+ params?: AdminFileViewPresetParams
+}
+
+export interface AdminFileViewPresetsResponse {
+ presets?: AdminFileViewPreset[]
+ items?: AdminFileViewPreset[]
+ total?: number
+}
+
+export interface FileEditForm {
+ id: number | null
+ code: string
+ prefix: string
+ suffix: string
+ expired_at: string
+ expired_count: number | null
+}
+
+export interface AdminFilePatchPayload {
+ id: number
+ code?: string
+ prefix?: string
+ suffix?: string
+ expired_at?: string | null
+ expired_count?: number | null
+}
+
+export type AdminFilePolicyAction =
+ | 'extend_24h'
+ | 'extend_7d'
+ | 'make_permanent'
+ | 'reset_download_limit'
+
+export interface AdminFilePolicyActionRequest {
+ id: number
+ action: AdminFilePolicyAction
+ downloadLimit?: number
+ download_limit?: number
+}
+
+export interface AdminFileMetadata {
+ note: string
+ tags: string[]
+ updatedAt?: string | null
+ updated_at?: string | null
+}
+
+export interface AdminFileMetadataRequest {
+ id: number
+ note?: string
+ tags?: string[]
+}
+
+export interface FileListResponse {
+ data: FileListItem[]
+ total: number
+ page: number
+ size: number
+ summary?: AdminFileSummary
+}
+
+export interface AdminFilePreviewResponse {
+ id: number
+ code: string
+ name: string
+ type: 'text'
+ content: string
+ length: number
+ previewLength?: number
+ preview_length?: number
+ truncated: boolean
+ maxChars?: number
+ max_chars?: number
+ createdAt?: string | null
+ created_at?: string | null
+ expiredAt?: string | null
+ expired_at?: string | null
+}
+
+export interface AdminFileDetailPolicy {
+ expiredAt?: string | null
+ expired_at?: string | null
+ expiredCount?: number | null
+ expired_count?: number | null
+ remainingDownloads?: number | null
+ remaining_downloads?: number | null
+ isExpired?: boolean
+ is_expired?: boolean
+ isPermanent?: boolean
+ is_permanent?: boolean
+}
+
+export interface AdminFileDetailStorage {
+ backend?: string
+ filePath?: string | null
+ file_path?: string | null
+ uuidFileName?: string | null
+ uuid_file_name?: string | null
+ fileHash?: string | null
+ file_hash?: string | null
+ isChunked?: boolean
+ is_chunked?: boolean
+ uploadId?: string | null
+ upload_id?: string | null
+}
+
+export type AdminFileInsightSeverity = 'success' | 'warning' | 'danger' | 'info' | 'neutral'
+
+export interface AdminFileDetailInsightMetrics {
+ ageSeconds?: number
+ age_seconds?: number
+ secondsUntilExpiration?: number | null
+ seconds_until_expiration?: number | null
+ remainingDownloads?: number | null
+ remaining_downloads?: number | null
+ usedCount?: number
+ used_count?: number
+}
+
+export interface AdminFileDetailStatusInsights {
+ severity?: AdminFileInsightSeverity
+ state?: string
+ nextAction?: string
+ next_action?: string
+ reasons?: string[]
+ metrics?: AdminFileDetailInsightMetrics
+}
+
+export interface AdminFileDetailTimelineItem {
+ key: string
+ status?: string
+ severity?: AdminFileInsightSeverity
+ timestamp?: string | null
+ value?: number | string | null
+ detail?: string | null
+}
+
+export interface AdminFileDetailTimelineViewItem extends AdminFileDetailTimelineItem {
+ severity: AdminFileInsightSeverity
+ displayTitle: string
+ displayDescription: string
+ displayMeta: string
+}
+
+export interface AdminFileDetailResponse extends FileListItem {
+ filename?: string
+ displayName?: string
+ display_name?: string
+ isPermanent?: boolean
+ is_permanent?: boolean
+ hasDownloadLimit?: boolean
+ has_download_limit?: boolean
+ hasExpirationTime?: boolean
+ has_expiration_time?: boolean
+ textLength?: number
+ text_length?: number
+ canPreviewText?: boolean
+ can_preview_text?: boolean
+ canDownload?: boolean
+ can_download?: boolean
+ storageBackend?: string
+ storage_backend?: string
+ filePath?: string | null
+ file_path?: string | null
+ uuidFileName?: string | null
+ uuid_file_name?: string | null
+ uploadId?: string | null
+ upload_id?: string | null
+ policy?: AdminFileDetailPolicy
+ storage?: AdminFileDetailStorage
+ metadata?: AdminFileMetadata
+ meta?: AdminFileMetadata
+ note?: string
+ tags?: string[]
+ metadataUpdatedAt?: string | null
+ metadata_updated_at?: string | null
+ statusInsights?: AdminFileDetailStatusInsights
+ status_insights?: AdminFileDetailStatusInsights
+ timeline?: AdminFileDetailTimelineItem[]
+}
+
+export interface AdminFileDetailViewItem extends AdminFileViewItem {
+ displayCreatedAt: string
+ displayRetrieveUrl: string
+ textLengthValue: number
+ canDownloadFile: boolean
+ isPermanentFile: boolean
+ hasDownloadLimitFile: boolean
+ hasExpirationTimeFile: boolean
+ isChunkedStorage: boolean
+ storageBackendValue: string
+ fileHashValue?: string | null
+ filePathValue?: string | null
+ uuidFileNameValue?: string | null
+ uploadIdValue?: string | null
+ metadataNote: string
+ metadataTags: string[]
+ metadataUpdatedAt: string | null
+ statusInsightMetrics?: AdminFileDetailInsightMetrics
+ detailTimeline: AdminFileDetailTimelineViewItem[]
+}
+
+export interface AdminBatchDeleteFileFailure {
+ id: number
+ reason: string
+}
+
+export interface AdminBatchDeleteFilesResponse {
+ requestedCount?: number
+ requested_count?: number
+ uniqueCount?: number
+ unique_count?: number
+ deletedCount?: number
+ deleted_count?: number
+ missingCount?: number
+ missing_count?: number
+ failedCount?: number
+ failed_count?: number
+ deleted?: number[]
+ missing?: number[]
+ failed?: AdminBatchDeleteFileFailure[]
+}
+
+export interface AdminBatchUpdateFileFailure {
+ id: number
+ reason: string
+}
+
+export interface AdminBatchUpdateFilesRequest {
+ ids: number[]
+ expired_at?: string | null
+ expired_count?: number | null
+ clearExpiredAt?: boolean
+ clear_expired_at?: boolean
+}
+
+export interface AdminBatchUpdateFilesResponse {
+ requestedCount?: number
+ requested_count?: number
+ uniqueCount?: number
+ unique_count?: number
+ updatedCount?: number
+ updated_count?: number
+ missingCount?: number
+ missing_count?: number
+ failedCount?: number
+ failed_count?: number
+ updated?: number[]
+ missing?: number[]
+ failed?: AdminBatchUpdateFileFailure[]
+}
+
+export interface AdminBatchPolicyActionRequest {
+ ids: number[]
+ action: AdminFilePolicyAction
+ downloadLimit?: number
+ download_limit?: number
+}
+
+export interface AdminBatchPolicyActionResponse extends AdminBatchUpdateFilesResponse {
+ action?: AdminFilePolicyAction | string
+}
+
+export type AdminBatchEditMode = 'expiresAt' | 'downloadLimit' | 'forever'
+
+export interface AdminBatchEditForm {
+ mode: AdminBatchEditMode
+ expired_at: string
+ expired_count: number | null
+}
+
+export interface FileUploadResponse {
+ code: string
+ name: string
+}
+
+export interface TextSendResponse {
+ code: string
+}
+
+export interface ShareSelectResponse {
+ code: string
+ name: string
+ text: string
+ size: number
+ type?: 'file' | 'text'
+ is_text?: boolean
+ content?: string | null
+ download_url?: string | null
+ created_at?: string | null
+ expired_at?: string | null
+ expires_at?: string | null
+ expired_count?: number | null
+ used_count?: number
+ remaining_downloads?: number | null
+}
+
+export interface ShareMetadataResponse {
+ code: string
+ name: string
+ size: number
+ type: 'file' | 'text'
+ is_text: boolean
+ created_at?: string | null
+ expired_at?: string | null
+ expires_at?: string | null
+ expired_count?: number | null
+ used_count?: number
+ remaining_downloads?: number | null
+}
+
+export interface ReceivedFileRecord {
+ id: number
+ code: string
+ filename: string
+ size: string
+ downloadUrl: string | null
+ content: string | null
+ date: string
+ type?: 'file' | 'text'
+ remainingDownloads?: number | null
+}
+
+export interface SentFileRecord {
+ id: number
+ filename: string
+ date: string
+ size: string
+ expiration: string
+ retrieveCode: string
+}
+
+export interface UploadProgress {
+ loaded: number
+ total: number
+ percentage: number
+}
+
+export interface ChunkUploadInitRequest {
+ file_name: string
+ file_size: number
+ chunk_size: number
+ file_hash: string
+}
+
+export interface ChunkUploadInitResponse {
+ code?: string
+ name?: string
+ upload_id?: string
+ existed?: boolean
+ uploaded_chunks?: number[]
+}
+
+export interface ChunkUploadCompleteRequest {
+ expire_value: number
+ expire_style: string
+}
+
+export type ChunkUploadResponse = null
diff --git a/src/types/index.ts b/src/types/index.ts
new file mode 100644
index 0000000..9553f6d
--- /dev/null
+++ b/src/types/index.ts
@@ -0,0 +1,7 @@
+export * from './api'
+export * from './auth'
+export * from './config'
+export * from './dashboard'
+export * from './file'
+export * from './presign-upload'
+export * from './ui'
diff --git a/src/types/presign-upload.ts b/src/types/presign-upload.ts
new file mode 100644
index 0000000..abb09ea
--- /dev/null
+++ b/src/types/presign-upload.ts
@@ -0,0 +1,55 @@
+import type { UploadProgress } from './file'
+
+export type PresignUploadMode = 'direct' | 'proxy'
+
+export type PresignUploadStatus =
+ | 'idle'
+ | 'initializing'
+ | 'uploading'
+ | 'confirming'
+ | 'success'
+ | 'error'
+
+export type ExpireStyle = 'day' | 'hour' | 'minute' | 'forever' | 'count'
+
+export interface PresignInitRequest {
+ file_name: string
+ file_size: number
+ expire_value?: number
+ expire_style?: ExpireStyle
+}
+
+export interface PresignInitResponse {
+ upload_id: string
+ upload_url: string
+ proxy_upload_url?: string
+ legacy_proxy_upload_url?: string
+ mode: PresignUploadMode
+ expires_in: number
+}
+
+export interface PresignConfirmRequest {
+ expire_value?: number
+ expire_style?: ExpireStyle
+}
+
+export interface PresignUploadResult {
+ code: string
+ name: string
+}
+
+export interface PresignStatusResponse {
+ upload_id: string
+ file_name: string
+ file_size: number
+ mode: PresignUploadMode
+ created_at: string
+ expires_at: string
+ is_expired: boolean
+}
+
+export interface PresignUploadOptions {
+ expireValue?: number
+ expireStyle?: ExpireStyle
+ onProgress?: (progress: UploadProgress) => void
+}
diff --git a/src/types/ui.ts b/src/types/ui.ts
new file mode 100644
index 0000000..8a8e21f
--- /dev/null
+++ b/src/types/ui.ts
@@ -0,0 +1,26 @@
+export type ThemeMode = 'light' | 'dark' | 'system'
+
+export type SendType = 'file' | 'text'
+
+export type AlertType = 'success' | 'error' | 'warning' | 'info'
+
+export interface Alert {
+ id: number
+ message: string
+ type: AlertType
+ progress: number
+ duration: number
+ startTime: number
+}
+
+export type UploadStatus = 'idle' | 'uploading' | 'success' | 'error'
+
+export interface RouteConfig {
+ path: string
+ name: string
+ component: () => Promise<{ default: object }>
+ meta?: {
+ requiresAuth?: boolean
+ title?: string
+ }
+}
diff --git a/src/utils/api.ts b/src/utils/api.ts
index 8469250..f4e76cb 100644
--- a/src/utils/api.ts
+++ b/src/utils/api.ts
@@ -1,75 +1,2 @@
-import axios from 'axios'
-
-// 从环境变量中获取 API 基础 URL
-const baseURL =
- import.meta.env.MODE === 'production'
- ? import.meta.env.VITE_API_BASE_URL_PROD
- : import.meta.env.VITE_API_BASE_URL_DEV
-
-// 确保 baseURL 是一个有效的字符串
-const sanitizedBaseURL = typeof baseURL === 'string' ? baseURL : ''
-
-// 创建 axios 实例
-const api = axios.create({
- baseURL: sanitizedBaseURL,
- timeout: 1000000000000000, // 请求超时时间
- headers: {
- 'Content-Type': 'application/json'
- }
-})
-
-// 请求拦截器
-api.interceptors.request.use(
- (config) => {
- // 从 localStorage 获取 token
- const token = localStorage.getItem('token')
- if (token) {
- config.headers['Authorization'] = `Bearer ${token}`
- }
-
- // 确保 URL 是有效的
- if (config.url && !config.url.startsWith('http')) {
- config.url = `${sanitizedBaseURL}/${config.url.replace(/^\//, '')}`
- }
-
- return config
- },
- (error) => {
- return Promise.reject(error)
- }
-)
-// 响应拦截器
-api.interceptors.response.use(
- (response) => {
- return response.data
- },
- (error) => {
- // 处理错误响应
- if (error.response) {
- switch (error.response.status) {
- case 401:
- console.error('未授权,请重新登录')
- localStorage.clear()
- window.location.href = '/#/login'
- break
- case 403:
- // 禁止访问
- console.error('禁止访问')
- break
- case 404:
- // 未找到
- console.error('请求的资源不存在')
- break
- default:
- console.error('发生错误:', error.response.data)
- }
- } else if (error.request) {
- console.error('未收到响应:', error.request)
- } else {
- console.error('请求配置错误:', error.message)
- }
- return Promise.reject(error)
- }
-)
-
-export default api
+export { apiBaseURL, rawApiClient } from '@/services/client'
+export { default } from '@/services/client'
diff --git a/src/utils/auth-storage.ts b/src/utils/auth-storage.ts
new file mode 100644
index 0000000..36a7fd4
--- /dev/null
+++ b/src/utils/auth-storage.ts
@@ -0,0 +1,56 @@
+import { STORAGE_KEYS } from '@/constants'
+
+export function readStoredAdminPassword(): string {
+ return localStorage.getItem(STORAGE_KEYS.ADMIN_PASSWORD) || ''
+}
+
+export function writeStoredAdminPassword(password: string) {
+ localStorage.setItem(STORAGE_KEYS.ADMIN_PASSWORD, password)
+}
+
+export function readStoredToken(): string {
+ return localStorage.getItem(STORAGE_KEYS.TOKEN) || ''
+}
+
+const readTokenPayloadExpiration = (token: string): number | null => {
+ try {
+ const payload = JSON.parse(atob(token.split('.')[1] || '')) as { exp?: unknown }
+ const expiresAt = Number(payload.exp)
+ return Number.isFinite(expiresAt) ? expiresAt : null
+ } catch {
+ return null
+ }
+}
+
+export function readStoredTokenExpiresAt(): number | null {
+ const storedValue = Number(localStorage.getItem(STORAGE_KEYS.TOKEN_EXPIRES_AT))
+ if (Number.isFinite(storedValue) && storedValue > 0) return storedValue
+ return readTokenPayloadExpiration(readStoredToken())
+}
+
+export function hasValidStoredAdminSession(): boolean {
+ const token = readStoredToken()
+ const expiresAt = readStoredTokenExpiresAt()
+ return !!token && expiresAt !== null && expiresAt > Date.now() / 1000
+}
+
+export function writeStoredToken(token: string, expiresAt?: number) {
+ localStorage.setItem(STORAGE_KEYS.TOKEN, token)
+ const normalizedExpiresAt = Number(expiresAt) || readTokenPayloadExpiration(token)
+ if (normalizedExpiresAt) {
+ localStorage.setItem(STORAGE_KEYS.TOKEN_EXPIRES_AT, String(normalizedExpiresAt))
+ } else {
+ localStorage.removeItem(STORAGE_KEYS.TOKEN_EXPIRES_AT)
+ }
+}
+
+export function clearStoredAuth() {
+ localStorage.removeItem(STORAGE_KEYS.ADMIN_PASSWORD)
+ localStorage.removeItem(STORAGE_KEYS.TOKEN)
+ localStorage.removeItem(STORAGE_KEYS.TOKEN_EXPIRES_AT)
+}
+
+export function clearStoredToken() {
+ localStorage.removeItem(STORAGE_KEYS.TOKEN)
+ localStorage.removeItem(STORAGE_KEYS.TOKEN_EXPIRES_AT)
+}
diff --git a/src/utils/build-info.ts b/src/utils/build-info.ts
new file mode 100644
index 0000000..6c7d08b
--- /dev/null
+++ b/src/utils/build-info.ts
@@ -0,0 +1,10 @@
+const abbreviatedCommit = __GIT_COMMIT__.slice(0, 12)
+
+export const buildInfo = Object.freeze({
+ version: __APP_VERSION__,
+ commit: abbreviatedCommit,
+ displayVersion:
+ abbreviatedCommit && abbreviatedCommit !== 'unknown'
+ ? `${__APP_VERSION__} (${abbreviatedCommit})`
+ : __APP_VERSION__
+})
diff --git a/src/utils/clipboard-paste.ts b/src/utils/clipboard-paste.ts
new file mode 100644
index 0000000..4a44e0a
--- /dev/null
+++ b/src/utils/clipboard-paste.ts
@@ -0,0 +1,40 @@
+export const getClipboardFile = (items: DataTransferItemList): File | null => {
+ for (let index = 0; index < items.length; index++) {
+ const item = items[index]
+ if (item.kind !== 'file') {
+ continue
+ }
+
+ const file = item.getAsFile()
+ if (file) {
+ return file
+ }
+ }
+ return null
+}
+
+export type TextInsertionInput = {
+ text: string
+ insertText: string
+ selectionStart: number
+ selectionEnd: number
+}
+
+export type TextInsertionResult = {
+ value: string
+ cursor: number
+}
+
+export const insertTextAtSelection = ({
+ text,
+ insertText,
+ selectionStart,
+ selectionEnd
+}: TextInsertionInput): TextInsertionResult => {
+ const beforeSelection = text.substring(0, selectionStart)
+ const afterSelection = text.substring(selectionEnd)
+ return {
+ value: beforeSelection + insertText + afterSelection,
+ cursor: selectionStart + insertText.length
+ }
+}
diff --git a/src/utils/clipboard.ts b/src/utils/clipboard.ts
index fdc9ca2..1f38d21 100644
--- a/src/utils/clipboard.ts
+++ b/src/utils/clipboard.ts
@@ -2,11 +2,15 @@
* 剪贴板工具函数
*/
-import { useAlertStore } from '@/stores/alertStore'
+import { buildRetrieveUrl, buildWgetCommand } from '@/utils/share-url'
+
+type CopyNotifyType = 'success' | 'error'
+
interface CopyOptions {
successMsg?: string
errorMsg?: string
showMsg?: boolean
+ notify?: (message: string, type: CopyNotifyType) => void
}
/**
@@ -19,13 +23,24 @@ export const copyToClipboard = async (
text: string,
options: CopyOptions = {}
): Promise => {
- const { successMsg = '复制成功', errorMsg = '复制失败,请手动复制', showMsg = true } = options
- const alertStore = useAlertStore()
+ const {
+ successMsg = '复制成功',
+ errorMsg = '复制失败,请手动复制',
+ showMsg = true,
+ notify
+ } = options
+
+ const showCopyMessage = (message: string, type: CopyNotifyType) => {
+ if (showMsg) {
+ notify?.(message, type)
+ }
+ }
+
try {
// 优先使用 Clipboard API
if (document.hasFocus() && navigator.clipboard && navigator.clipboard.writeText) {
await navigator.clipboard.writeText(text)
- if (showMsg) alertStore.showAlert(successMsg, 'success')
+ showCopyMessage(successMsg, 'success')
return true
}
// 后备方案:使用传统的复制方法
@@ -38,14 +53,14 @@ export const copyToClipboard = async (
const success = document.execCommand('copy')
document.body.removeChild(textarea)
if (success) {
- if (showMsg) alertStore.showAlert(successMsg, 'success')
+ showCopyMessage(successMsg, 'success')
return true
} else {
throw new Error('execCommand copy failed')
}
} catch (err) {
console.error('复制失败:', err)
- if (showMsg) alertStore.showAlert(errorMsg, 'error')
+ showCopyMessage(errorMsg, 'error')
return false
}
}
@@ -55,11 +70,15 @@ export const copyToClipboard = async (
* @param code 取件码
* @returns Promise 是否复制成功
*/
-export const copyRetrieveLink = async (code: string): Promise => {
- const link = `${window.location.origin}/#/?code=${code}`
+export const copyRetrieveLink = async (
+ code: string,
+ options: Pick = {}
+): Promise => {
+ const link = buildRetrieveUrl(code)
return copyToClipboard(link, {
successMsg: '取件链接已复制到剪贴板',
- errorMsg: '复制失败,请手动复制取件链接'
+ errorMsg: '复制失败,请手动复制取件链接',
+ ...options
})
}
@@ -68,50 +87,26 @@ export const copyRetrieveLink = async (code: string): Promise => {
* @param code 取件码
* @returns Promise 是否复制成功
*/
-export const copyRetrieveCode = async (code: string): Promise => {
+export const copyRetrieveCode = async (
+ code: string,
+ options: Pick = {}
+): Promise => {
return copyToClipboard(code, {
successMsg: '取件码已复制到剪贴板',
- errorMsg: '复制失败,请手动复制取件码'
+ errorMsg: '复制失败,请手动复制取件码',
+ ...options
})
}
-const baseUrl = window.location.origin + '/';
-
-export const copyWgetCommand = (retrieveCode: string, fileName: string) => {
- const command = `wget ${baseUrl}share/select?code=${retrieveCode} -O "${fileName}"`;
-
- if (navigator.clipboard && navigator.clipboard.writeText) {
- navigator.clipboard.writeText(command)
- .then(() => {
- console.log("命令已复制到剪贴板!");
- })
- .catch((err) => {
- console.error("复制失败,使用回退方法:", err);
- fallbackCopyTextToClipboard(command);
- });
- } else {
- console.warn("Clipboard API 不可用,使用回退方法。");
- fallbackCopyTextToClipboard(command);
- }
-};
-function fallbackCopyTextToClipboard(text:string) {
- const textArea = document.createElement("textarea");
- textArea.value = text;
- textArea.style.position = "fixed"; // 避免滚动
- document.body.appendChild(textArea);
- textArea.focus();
- textArea.select();
- try {
- const successful = document.execCommand("copy");
- console.log("回退复制操作成功:", successful);
- } catch (err) {
- console.error("回退复制操作失败:", err);
- }
- document.body.removeChild(textArea);
+export const copyWgetCommand = (
+ retrieveCode: string,
+ fileName: string,
+ options: Pick = {}
+) => {
+ const command = buildWgetCommand(retrieveCode, fileName)
+ void copyToClipboard(command, {
+ successMsg: '命令已复制到剪贴板',
+ errorMsg: '复制失败,请手动复制命令',
+ ...options
+ })
}
-
-if (document.hasFocus() && navigator.clipboard && navigator.clipboard.writeText) {
- navigator.clipboard.writeText("要复制的文本");
-} else {
- fallbackCopyTextToClipboard("要复制的文本");
-}
\ No newline at end of file
diff --git a/src/utils/common.ts b/src/utils/common.ts
new file mode 100644
index 0000000..85d2001
--- /dev/null
+++ b/src/utils/common.ts
@@ -0,0 +1,267 @@
+/**
+ * 通用工具函数
+ */
+import type { ApiErrorPayload, ApiResponse } from '@/types'
+
+/**
+ * 格式化时间戳为可读格式
+ * @param timestamp 时间戳字符串
+ * @param format 格式类型
+ * @returns 格式化后的时间字符串
+ */
+export function formatTimestamp(timestamp: string, format: 'datetime' | 'date' | 'time' = 'datetime'): string {
+ const date = new Date(timestamp)
+ const year = date.getFullYear()
+ const month = (date.getMonth() + 1).toString().padStart(2, '0')
+ const day = date.getDate().toString().padStart(2, '0')
+ const hours = date.getHours().toString().padStart(2, '0')
+ const minutes = date.getMinutes().toString().padStart(2, '0')
+ const seconds = date.getSeconds().toString().padStart(2, '0')
+
+ switch (format) {
+ case 'date':
+ return `${year}-${month}-${day}`
+ case 'time':
+ return `${hours}:${minutes}:${seconds}`
+ case 'datetime':
+ default:
+ return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`
+ }
+}
+
+/**
+ * 格式化文件大小
+ * @param bytes 字节数
+ * @param decimals 小数位数
+ * @returns 格式化后的文件大小字符串
+ */
+export function formatFileSize(bytes: number, decimals: number = 2): string {
+ if (bytes === 0) return '0 Bytes'
+
+ const k = 1024
+ const dm = decimals < 0 ? 0 : decimals
+ const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']
+
+ const i = Math.floor(Math.log(bytes) / Math.log(k))
+
+ return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i]
+}
+
+/**
+ * 格式化持续时间
+ * @param seconds 秒数
+ * @param t 翻译函数
+ * @returns 格式化后的持续时间字符串
+ */
+export function formatDuration(seconds: number, t?: (key: string) => string): string {
+ if (seconds === 0) return t ? t('utils.time.forever') : 'Forever'
+
+ const units = [
+ { key: 'utils.time.day', value: 86400 },
+ { key: 'utils.time.hour', value: 3600 },
+ { key: 'utils.time.minute', value: 60 },
+ { key: 'utils.time.second', value: 1 }
+ ]
+
+ for (const unit of units) {
+ if (seconds >= unit.value) {
+ const value = Math.floor(seconds / unit.value)
+ const unitName = t ? t(unit.key) : unit.key.split('.').pop()
+ return `${value}${unitName}`
+ }
+ }
+
+ const secondName = t ? t('utils.time.second') : 'second'
+ return `${seconds}${secondName}`
+}
+
+/**
+ * 防抖函数
+ * @param func 要防抖的函数
+ * @param wait 等待时间(毫秒)
+ * @returns 防抖后的函数
+ */
+export function debounce unknown>(
+ func: T,
+ wait: number
+): (...args: Parameters) => void {
+ let timeout: ReturnType | null = null
+
+ return (...args: Parameters) => {
+ if (timeout) {
+ clearTimeout(timeout)
+ }
+ timeout = setTimeout(() => func(...args), wait)
+ }
+}
+
+/**
+ * 节流函数
+ * @param func 要节流的函数
+ * @param limit 限制时间(毫秒)
+ * @returns 节流后的函数
+ */
+export function throttle unknown>(
+ func: T,
+ limit: number
+): (...args: Parameters) => void {
+ let inThrottle: boolean = false
+
+ return (...args: Parameters) => {
+ if (!inThrottle) {
+ func(...args)
+ inThrottle = true
+ setTimeout(() => inThrottle = false, limit)
+ }
+ }
+}
+
+/**
+ * 验证邮箱格式
+ * @param email 邮箱地址
+ * @returns 是否为有效邮箱
+ */
+export function isValidEmail(email: string): boolean {
+ const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
+ return emailRegex.test(email)
+}
+
+/**
+ * 验证URL格式
+ * @param url URL地址
+ * @returns 是否为有效URL
+ */
+export function isValidUrl(url: string): boolean {
+ try {
+ new URL(url)
+ return true
+ } catch {
+ return false
+ }
+}
+
+/**
+ * 生成随机字符串
+ * @param length 字符串长度
+ * @param chars 可选字符集
+ * @returns 随机字符串
+ */
+export function generateRandomString(
+ length: number = 8,
+ chars: string = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'
+): string {
+ let result = ''
+ for (let i = 0; i < length; i++) {
+ result += chars.charAt(Math.floor(Math.random() * chars.length))
+ }
+ return result
+}
+
+/**
+ * 深度克隆对象
+ * @param obj 要克隆的对象
+ * @returns 克隆后的对象
+ */
+export function deepClone(obj: T): T {
+ if (obj === null || typeof obj !== 'object') {
+ return obj
+ }
+
+ if (obj instanceof Date) {
+ return new Date(obj.getTime()) as T
+ }
+
+ if (obj instanceof Array) {
+ return obj.map(item => deepClone(item)) as T
+ }
+
+ if (typeof obj === 'object') {
+ const clonedObj = {} as T
+ for (const key in obj) {
+ if (Object.prototype.hasOwnProperty.call(obj, key)) {
+ clonedObj[key] = deepClone(obj[key])
+ }
+ }
+ return clonedObj
+ }
+
+ return obj
+}
+
+/**
+ * 获取文件扩展名
+ * @param filename 文件名
+ * @returns 文件扩展名(不包含点)
+ */
+export function getFileExtension(filename: string): string {
+ return filename.slice((filename.lastIndexOf('.') - 1 >>> 0) + 2)
+}
+
+/**
+ * 检查是否为移动设备
+ * @returns 是否为移动设备
+ */
+export function isMobile(): boolean {
+ return /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent)
+}
+
+/**
+ * 格式化数字,添加千分位分隔符
+ * @param num 数字
+ * @returns 格式化后的数字字符串
+ */
+export function formatNumber(num: number): string {
+ return num.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ',')
+}
+
+type ErrorWithResponse = {
+ response?: {
+ data?: ApiErrorPayload
+ }
+ message?: string
+}
+
+const getReadableMessage = (value: unknown): string => {
+ if (typeof value === 'string') {
+ return value
+ }
+
+ if (!value || typeof value !== 'object') {
+ return ''
+ }
+
+ const payload = value as Record
+ for (const key of ['message', 'msg', 'detail', 'error']) {
+ const fieldValue = payload[key]
+ if (typeof fieldValue === 'string' && fieldValue.trim()) {
+ return fieldValue
+ }
+ }
+
+ return ''
+}
+
+export function getResponseMessage(response: ApiResponse, fallback: string): string {
+ return (
+ getReadableMessage(response.detail) ||
+ getReadableMessage(response.message) ||
+ getReadableMessage(response.msg) ||
+ fallback
+ )
+}
+
+export function getErrorMessage(error: unknown, fallback: string): string {
+ if (!error || typeof error !== 'object') {
+ return fallback
+ }
+
+ const errorWithResponse = error as ErrorWithResponse
+ const responseData = errorWithResponse.response?.data
+ return (
+ getReadableMessage(responseData?.detail) ||
+ getReadableMessage(responseData?.message) ||
+ getReadableMessage(responseData?.msg) ||
+ errorWithResponse.message ||
+ fallback
+ )
+}
diff --git a/src/utils/config-form.ts b/src/utils/config-form.ts
new file mode 100644
index 0000000..4f14636
--- /dev/null
+++ b/src/utils/config-form.ts
@@ -0,0 +1,107 @@
+import type { ConfigState } from '@/types'
+
+export type FileSizeUnit = 'KB' | 'MB' | 'GB'
+export type SaveTimeUnit = '秒' | '分' | '时' | '天'
+
+export type FileSizeForm = {
+ value: number
+ unit: FileSizeUnit
+}
+
+export type SaveTimeForm = {
+ value: number
+ unit: SaveTimeUnit
+}
+
+const FILE_SIZE_UNITS: Record = {
+ KB: 1024,
+ MB: 1024 * 1024,
+ GB: 1024 * 1024 * 1024
+}
+
+const SAVE_TIME_UNITS: Record = {
+ 秒: 1,
+ 分: 60,
+ 时: 3600,
+ 天: 86400
+}
+
+export function bytesToFileSizeForm(bytes: number): FileSizeForm {
+ if (bytes >= FILE_SIZE_UNITS.GB) {
+ return {
+ value: Math.round(bytes / FILE_SIZE_UNITS.GB),
+ unit: 'GB'
+ }
+ }
+
+ if (bytes >= FILE_SIZE_UNITS.MB) {
+ return {
+ value: Math.round(bytes / FILE_SIZE_UNITS.MB),
+ unit: 'MB'
+ }
+ }
+
+ return {
+ value: Math.round(bytes / FILE_SIZE_UNITS.KB),
+ unit: 'KB'
+ }
+}
+
+export function fileSizeFormToBytes(value: number, unit: FileSizeUnit): number {
+ return value * FILE_SIZE_UNITS[unit]
+}
+
+export function secondsToSaveTimeForm(seconds: number): SaveTimeForm {
+ if (seconds === 0) {
+ return {
+ value: 7,
+ unit: '天'
+ }
+ }
+
+ if (seconds % SAVE_TIME_UNITS.天 === 0 && seconds >= SAVE_TIME_UNITS.天) {
+ return {
+ value: seconds / SAVE_TIME_UNITS.天,
+ unit: '天'
+ }
+ }
+
+ if (seconds % SAVE_TIME_UNITS.时 === 0 && seconds >= SAVE_TIME_UNITS.时) {
+ return {
+ value: seconds / SAVE_TIME_UNITS.时,
+ unit: '时'
+ }
+ }
+
+ if (seconds % SAVE_TIME_UNITS.分 === 0 && seconds >= SAVE_TIME_UNITS.分) {
+ return {
+ value: seconds / SAVE_TIME_UNITS.分,
+ unit: '分'
+ }
+ }
+
+ return {
+ value: seconds,
+ unit: '秒'
+ }
+}
+
+export function saveTimeFormToSeconds(value: number, unit: SaveTimeUnit): number {
+ if (value === 0) {
+ return 7 * SAVE_TIME_UNITS.天
+ }
+
+ return value * SAVE_TIME_UNITS[unit]
+}
+
+export function buildConfigSubmitPayload(
+ config: ConfigState,
+ fileSize: FileSizeForm,
+ saveTime: SaveTimeForm
+): ConfigState {
+ return {
+ ...config,
+ uploadSize: fileSizeFormToBytes(fileSize.value, fileSize.unit),
+ max_save_seconds: saveTimeFormToSeconds(saveTime.value, saveTime.unit)
+ }
+}
diff --git a/src/utils/config-storage.ts b/src/utils/config-storage.ts
new file mode 100644
index 0000000..077168a
--- /dev/null
+++ b/src/utils/config-storage.ts
@@ -0,0 +1,150 @@
+import { DEFAULT_CONFIG, FILE_SIZE_LIMITS, STORAGE_KEYS } from '@/constants'
+import type { ConfigState, SystemConfig } from '@/types'
+
+export type PublicConfig = SystemConfig & {
+ uploadSize: number
+ allowed_file_types?: string[]
+ expireStyle: string[]
+ code_generate_type?: 'number' | 'secret'
+ openUpload: number
+ max_save_seconds: number
+ enableChunk: number
+ notify_title?: string
+ notify_content?: string
+ page_explain?: string
+ showAdminAddr?: number
+ themesSelect?: string
+ background?: string
+ opacity?: number
+}
+
+type PublicConfigInput = Omit, 'showAdminAddr'> & {
+ showAdminAddr?: number | string
+ show_admin_address?: number | string
+}
+
+export const DEFAULT_PUBLIC_CONFIG: PublicConfig = {
+ ...DEFAULT_CONFIG,
+ uploadSize: FILE_SIZE_LIMITS.MAX_FILE_SIZE,
+ allowedFileTypes: ['*'],
+ allowed_file_types: ['*'],
+ expireStyle: ['day'],
+ code_generate_type: 'secret',
+ openUpload: 1,
+ max_save_seconds: 0,
+ enableChunk: 0,
+ showAdminAddr: 0
+}
+
+export const DEFAULT_CONFIG_STATE: ConfigState = {
+ name: DEFAULT_PUBLIC_CONFIG.name,
+ description: DEFAULT_PUBLIC_CONFIG.description || '',
+ file_storage: '',
+ themesChoices: [],
+ expireStyle: DEFAULT_PUBLIC_CONFIG.expireStyle,
+ code_generate_type: DEFAULT_PUBLIC_CONFIG.code_generate_type || 'secret',
+ adminSessionExpire: 30 * 24 * 60 * 60,
+ admin_token: '',
+ robotsText: '',
+ keywords: '',
+ notify_title: '',
+ notify_content: '',
+ openUpload: DEFAULT_PUBLIC_CONFIG.openUpload,
+ uploadSize: DEFAULT_PUBLIC_CONFIG.uploadSize,
+ allowed_file_types: DEFAULT_PUBLIC_CONFIG.allowedFileTypes,
+ allowedFileTypes: DEFAULT_PUBLIC_CONFIG.allowedFileTypes,
+ storage_path: '',
+ storageLimit: 0,
+ uploadMinute: 1,
+ max_save_seconds: DEFAULT_PUBLIC_CONFIG.max_save_seconds,
+ opacity: 0.9,
+ enableChunk: DEFAULT_PUBLIC_CONFIG.enableChunk,
+ s3_access_key_id: '',
+ background: '',
+ showAdminAddr: 0,
+ page_explain: '',
+ s3_secret_access_key: '',
+ aws_session_token: '',
+ s3_signature_version: '',
+ s3_addressing_style: 'auto',
+ s3_region_name: '',
+ s3_bucket_name: '',
+ s3_endpoint_url: '',
+ s3_hostname: '',
+ uploadCount: 1,
+ errorMinute: 1,
+ errorCount: 1,
+ s3_proxy: 0,
+ themesSelect: '',
+ webdav_url: '',
+ webdav_username: '',
+ webdav_password: ''
+}
+
+function normalizeFileTypes(value: unknown): string[] {
+ const rawTypes =
+ typeof value === 'string'
+ ? value.split(',')
+ : Array.isArray(value)
+ ? value
+ : DEFAULT_PUBLIC_CONFIG.allowedFileTypes
+ const normalized = rawTypes.map((item) => String(item).trim()).filter(Boolean)
+ return normalized.length > 0 ? normalized : ['*']
+}
+
+function normalizeAdminAddress(value: number | string | undefined): number | undefined {
+ if (value === undefined) return undefined
+ return Number(value) === 1 ? 1 : 0
+}
+
+export function readStoredConfig>(): T | null {
+ try {
+ const rawConfig = localStorage.getItem(STORAGE_KEYS.CONFIG)
+ return rawConfig ? (JSON.parse(rawConfig) as T) : null
+ } catch {
+ return null
+ }
+}
+
+export function toPublicConfig(
+ config: PublicConfigInput | null | undefined
+): Partial {
+ if (!config) return {}
+
+ const allowedFileTypes = normalizeFileTypes(config.allowedFileTypes ?? config.allowed_file_types)
+
+ return {
+ name: config.name,
+ description: config.description,
+ uploadSize: config.uploadSize,
+ allowedFileTypes,
+ allowed_file_types: allowedFileTypes,
+ expireStyle: config.expireStyle,
+ code_generate_type: config.code_generate_type,
+ openUpload: config.openUpload,
+ max_save_seconds: config.max_save_seconds,
+ enableChunk: config.enableChunk,
+ notify_title: config.notify_title,
+ notify_content: config.notify_content,
+ page_explain: config.page_explain,
+ showAdminAddr: normalizeAdminAddress(config.showAdminAddr ?? config.show_admin_address),
+ themesSelect: config.themesSelect,
+ background: config.background,
+ opacity: config.opacity
+ }
+}
+
+export function writeStoredConfig(config: object) {
+ localStorage.setItem(
+ STORAGE_KEYS.CONFIG,
+ JSON.stringify(toPublicConfig(config as PublicConfigInput))
+ )
+}
+
+export function readNotifyKey(): string | null {
+ return localStorage.getItem(STORAGE_KEYS.NOTIFY)
+}
+
+export function writeNotifyKey(notifyKey: string) {
+ localStorage.setItem(STORAGE_KEYS.NOTIFY, notifyKey)
+}
diff --git a/src/utils/content-preview.ts b/src/utils/content-preview.ts
new file mode 100644
index 0000000..6f0bb9a
--- /dev/null
+++ b/src/utils/content-preview.ts
@@ -0,0 +1,57 @@
+const MARKDOWN_ALLOWED_TAGS = [
+ 'p',
+ 'br',
+ 'strong',
+ 'em',
+ 'u',
+ 'h1',
+ 'h2',
+ 'h3',
+ 'h4',
+ 'h5',
+ 'h6',
+ 'ul',
+ 'ol',
+ 'li',
+ 'blockquote',
+ 'code',
+ 'pre',
+ 'a',
+ 'img'
+]
+
+const MARKDOWN_ALLOWED_ATTR = ['href', 'src', 'alt', 'title', 'class']
+
+type MarkedRenderer = typeof import('marked')['marked']
+type DOMPurifyModule = typeof import('dompurify')['default']
+
+let markdownRendererLoader: Promise<{
+ marked: MarkedRenderer
+ DOMPurify: DOMPurifyModule
+}> | null = null
+
+const loadMarkdownRenderer = async () => {
+ markdownRendererLoader ??= Promise.all([import('marked'), import('dompurify')]).then(
+ ([markedModule, domPurifyModule]) => ({
+ marked: markedModule.marked,
+ DOMPurify: domPurifyModule.default
+ })
+ )
+ return markdownRendererLoader
+}
+
+export async function renderMarkdownPreview(content: string): Promise {
+ try {
+ const { marked, DOMPurify } = await loadMarkdownRenderer()
+ const rawHtml = await marked(content)
+ return DOMPurify.sanitize(rawHtml, {
+ ALLOWED_TAGS: MARKDOWN_ALLOWED_TAGS,
+ ALLOWED_ATTR: MARKDOWN_ALLOWED_ATTR,
+ ALLOWED_URI_REGEXP:
+ /^(?:(?:(?:f|ht)tps?|mailto|tel|callto|cid|xmpp|xxx):|[^a-z]|[a-z+.-]+(?:[^a-z+.-:]|$))/i
+ })
+ } catch (error) {
+ console.error('Markdown 渲染失败:', error)
+ return content
+ }
+}
diff --git a/src/utils/download-action.ts b/src/utils/download-action.ts
new file mode 100644
index 0000000..e5c2722
--- /dev/null
+++ b/src/utils/download-action.ts
@@ -0,0 +1,100 @@
+import type { AdminFileViewItem, ApiResponse, ReceivedFileRecord } from '@/types'
+import { buildDownloadUrl } from '@/utils/share-url'
+
+const unsafeFilenamePattern = new RegExp(
+ `[\\\\/:*?"<>|${String.fromCharCode(0)}-${String.fromCharCode(31)}]`,
+ 'g'
+)
+
+type SaveAs = typeof import('file-saver')
+type FileSaverModule = {
+ default: SaveAs
+ saveAs: SaveAs
+}
+
+let fileSaverLoader: Promise | null = null
+
+const saveBlobAsFile = async (blob: Blob, filename: string) => {
+ fileSaverLoader ??= import('file-saver')
+ const { saveAs } = await fileSaverLoader
+ saveAs(blob, filename)
+}
+
+export async function downloadReceivedRecord(record: ReceivedFileRecord): Promise {
+ if (record.downloadUrl) {
+ window.open(buildDownloadUrl(record.downloadUrl), '_blank')
+ return
+ }
+
+ if (record.content) {
+ const blob = new Blob([record.content], { type: 'text/plain;charset=utf-8' })
+ await saveBlobAsFile(blob, `${record.filename}.txt`)
+ }
+}
+
+export const getSafeFilename = (name: string) =>
+ name
+ .replace(unsafeFilenamePattern, '_')
+ .replace(/\s+/g, ' ')
+ .trim()
+ .slice(0, 180) || 'download'
+
+const getFilenameFromDisposition = (disposition?: string) => {
+ if (!disposition) return ''
+
+ const utf8Match = disposition.match(/filename\*=UTF-8''([^;]+)/i)
+ if (utf8Match?.[1]) {
+ try {
+ return decodeURIComponent(utf8Match[1])
+ } catch {
+ return utf8Match[1]
+ }
+ }
+
+ const asciiMatch = disposition.match(/filename="?([^"]+)"?/i)
+ return asciiMatch?.[1] || ''
+}
+
+const readBlobAsText = (blob: Blob): Promise =>
+ new Promise((resolve, reject) => {
+ const reader = new FileReader()
+ reader.onload = () => resolve(String(reader.result || ''))
+ reader.onerror = () => reject(reader.error)
+ reader.readAsText(blob)
+ })
+
+export async function exportAdminTextFile(
+ file: AdminFileViewItem,
+ content: string
+): Promise {
+ const filename = getSafeFilename(file.displayName || file.code)
+ const blob = new Blob([content], { type: 'text/plain;charset=utf-8' })
+ await saveBlobAsFile(blob, `${filename}.txt`)
+}
+
+export async function downloadAdminManagedFile(
+ file: AdminFileViewItem,
+ response: { data: Blob; headers: Record }
+): Promise {
+ if (file.isTextFile) {
+ const text = await readBlobAsText(response.data)
+ let content = text
+
+ try {
+ const payload = JSON.parse(text) as ApiResponse
+ content = typeof payload.detail === 'string' ? payload.detail : text
+ } catch {
+ content = text
+ }
+
+ await exportAdminTextFile(file, content)
+ return
+ }
+
+ const disposition =
+ response.headers['content-disposition'] || response.headers['Content-Disposition']
+ const filename = getSafeFilename(
+ getFilenameFromDisposition(disposition) || file.displayName || file.code
+ )
+ await saveBlobAsFile(response.data, filename)
+}
diff --git a/src/utils/file-processing.ts b/src/utils/file-processing.ts
new file mode 100644
index 0000000..b22d9fe
--- /dev/null
+++ b/src/utils/file-processing.ts
@@ -0,0 +1,74 @@
+const SMALL_FILE_HASH_LIMIT = 10 * 1024 * 1024
+const LARGE_FILE_HASH_CHUNK_SIZE = 5 * 1024 * 1024
+
+type JSZipConstructor = typeof import('jszip')
+type JSZipModule = JSZipConstructor & {
+ default?: JSZipConstructor
+}
+
+let jsZipLoader: Promise | null = null
+
+const loadJSZip = async () => {
+ jsZipLoader ??= import('jszip').then((module) => {
+ const normalizedModule = module as unknown as JSZipModule
+ return normalizedModule.default ?? normalizedModule
+ })
+ return jsZipLoader
+}
+
+const generateFallbackHash = (file: File): string => {
+ const fileInfo = `${file.name}-${file.size}-${file.lastModified}`
+ let hash = 0
+ for (let i = 0; i < fileInfo.length; i++) {
+ const char = fileInfo.charCodeAt(i)
+ hash = (hash << 5) - hash + char
+ hash = hash & hash
+ }
+ return Math.abs(hash).toString(16).padStart(64, '0')
+}
+
+const createSha256Hash = async (data: BufferSource): Promise => {
+ const hashBuffer = await crypto.subtle.digest('SHA-256', data)
+ const hashArray = Array.from(new Uint8Array(hashBuffer))
+ return hashArray.map((byte) => byte.toString(16).padStart(2, '0')).join('')
+}
+
+export const calculateFileHash = async (file: File): Promise => {
+ try {
+ if (file.size <= SMALL_FILE_HASH_LIMIT) {
+ const buffer = await file.arrayBuffer()
+ return window.isSecureContext ? createSha256Hash(buffer) : generateFallbackHash(file)
+ }
+
+ const firstChunk = file.slice(0, LARGE_FILE_HASH_CHUNK_SIZE)
+ const lastChunk = file.slice(-LARGE_FILE_HASH_CHUNK_SIZE)
+ const [firstBuffer, lastBuffer] = await Promise.all([
+ firstChunk.arrayBuffer(),
+ lastChunk.arrayBuffer()
+ ])
+ const combined = new Uint8Array(firstBuffer.byteLength + lastBuffer.byteLength + 16)
+ combined.set(new Uint8Array(firstBuffer), 0)
+ combined.set(new Uint8Array(lastBuffer), firstBuffer.byteLength)
+ const sizeBytes = new TextEncoder().encode(file.size.toString())
+ combined.set(sizeBytes, firstBuffer.byteLength + lastBuffer.byteLength)
+
+ return window.isSecureContext ? createSha256Hash(combined) : generateFallbackHash(file)
+ } catch (error) {
+ console.error('File hash calculation failed:', error)
+ return generateFallbackHash(file)
+ }
+}
+
+export const packFilesAsZip = async (files: File[]): Promise => {
+ const JSZip = await loadJSZip()
+ const zip = new JSZip()
+ for (const file of files) {
+ zip.file(file.name, file)
+ }
+ const blob = await zip.generateAsync({
+ type: 'blob',
+ compression: 'DEFLATE',
+ compressionOptions: { level: 6 }
+ })
+ return new File([blob], `files_${Date.now()}.zip`, { type: 'application/zip' })
+}
diff --git a/src/utils/preference-storage.ts b/src/utils/preference-storage.ts
new file mode 100644
index 0000000..6582123
--- /dev/null
+++ b/src/utils/preference-storage.ts
@@ -0,0 +1,20 @@
+import { STORAGE_KEYS } from '@/constants'
+import type { ThemeMode } from '@/types'
+
+const LOCALE_STORAGE_KEY = 'locale'
+
+export function readStoredThemeMode(): string | null {
+ return localStorage.getItem(STORAGE_KEYS.COLOR_MODE)
+}
+
+export function writeStoredThemeMode(mode: ThemeMode) {
+ localStorage.setItem(STORAGE_KEYS.COLOR_MODE, mode)
+}
+
+export function readStoredLocale(): string | null {
+ return localStorage.getItem(LOCALE_STORAGE_KEY)
+}
+
+export function writeStoredLocale(locale: string) {
+ localStorage.setItem(LOCALE_STORAGE_KEY, locale)
+}
diff --git a/src/utils/record-storage.ts b/src/utils/record-storage.ts
new file mode 100644
index 0000000..6aba34b
--- /dev/null
+++ b/src/utils/record-storage.ts
@@ -0,0 +1,23 @@
+export function readStoredRecords(key: string): T[] {
+ if (typeof window === 'undefined') return []
+
+ try {
+ const raw = localStorage.getItem(key)
+ if (!raw) return []
+
+ const parsed = JSON.parse(raw)
+ return Array.isArray(parsed) ? (parsed as T[]) : []
+ } catch {
+ return []
+ }
+}
+
+export function writeStoredRecords(key: string, records: T[]) {
+ if (typeof window === 'undefined') return
+
+ try {
+ localStorage.setItem(key, JSON.stringify(records))
+ } catch {
+ // 本地存储不可用时保持内存记录,不影响当前会话。
+ }
+}
diff --git a/src/utils/send-record.ts b/src/utils/send-record.ts
new file mode 100644
index 0000000..00fe046
--- /dev/null
+++ b/src/utils/send-record.ts
@@ -0,0 +1,99 @@
+import type { ApiResponse, SendType, SentFileRecord } from '@/types'
+
+type Translate = (key: string, params?: Record) => string
+
+type BuildSentRecordInput = {
+ response: ApiResponse
+ sendType: SendType
+ textContent: string
+ selectedFile: File | null
+ selectedFiles: File[]
+ expirationMethod: string
+ expirationValue: string
+ translate: Translate
+ getUnit: (method: string) => string
+}
+
+const expirationSecondsByMethod: Record = {
+ minute: 60,
+ hour: 3600,
+ day: 86400
+}
+
+export function isExpirationWithinLimit(
+ method: string,
+ value: string,
+ maxSaveSeconds: number
+): boolean {
+ if (method === 'forever' || method === 'count') return true
+ if (maxSaveSeconds === 0) return true
+
+ const multiplier = expirationSecondsByMethod[method]
+ if (!multiplier) return false
+
+ return parseInt(value) * multiplier <= maxSaveSeconds
+}
+
+export function formatExpirationTime(
+ method: string,
+ value: string,
+ translate: Translate,
+ getUnit: (method: string) => string
+): string {
+ if (method === 'forever') return translate('send.expiration.units.forever')
+ if (method === 'count') return translate('send.messages.expiresAfterCount', { count: value })
+
+ const now = new Date()
+ const expireValue = parseInt(value)
+
+ switch (method) {
+ case 'minute':
+ now.setMinutes(now.getMinutes() + expireValue)
+ break
+ case 'hour':
+ now.setHours(now.getHours() + expireValue)
+ break
+ case 'day':
+ now.setDate(now.getDate() + expireValue)
+ break
+ default:
+ return translate('send.messages.expiresAfter', { value, unit: getUnit(method) })
+ }
+
+ const year = now.getFullYear()
+ const month = (now.getMonth() + 1).toString().padStart(2, '0')
+ const day = now.getDate().toString().padStart(2, '0')
+ const hours = now.getHours().toString().padStart(2, '0')
+ const minutes = now.getMinutes().toString().padStart(2, '0')
+ return translate('send.messages.expiresAt', { date: `${year}-${month}-${day} ${hours}:${minutes}` })
+}
+
+export function buildSentRecord(input: BuildSentRecordInput): SentFileRecord {
+ const retrieveCode = (input.response.detail as { code?: string } | undefined)?.code || ''
+ const fileName = (input.response.detail as { name?: string } | undefined)?.name || ''
+
+ const totalSelectedSize = input.selectedFiles.reduce((total, file) => total + file.size, 0)
+ const displaySize =
+ input.sendType === 'text'
+ ? `${(input.textContent.length / 1024).toFixed(2)} KB`
+ : input.selectedFiles.length > 0
+ ? `${(totalSelectedSize / (1024 * 1024)).toFixed(1)} MB`
+ : `${((input.selectedFile?.size || 0) / (1024 * 1024)).toFixed(1)} MB`
+
+ return {
+ id: Date.now(),
+ filename: fileName,
+ date: new Date().toISOString().split('T')[0],
+ size: displaySize,
+ expiration:
+ input.expirationMethod === 'forever'
+ ? input.translate('send.expiration.forever')
+ : formatExpirationTime(
+ input.expirationMethod,
+ input.expirationValue,
+ input.translate,
+ input.getUnit
+ ),
+ retrieveCode
+ }
+}
diff --git a/src/utils/sent-record-actions.ts b/src/utils/sent-record-actions.ts
new file mode 100644
index 0000000..944cbb0
--- /dev/null
+++ b/src/utils/sent-record-actions.ts
@@ -0,0 +1,17 @@
+import type { SentFileRecord } from '@/types'
+import { copyRetrieveCode, copyRetrieveLink, copyWgetCommand } from '@/utils/clipboard'
+import { buildSentRecordQrValue } from '@/utils/share-url'
+
+type CopyNotify = (message: string, type: 'success' | 'error') => void
+
+export function createSentRecordActions(notify: CopyNotify) {
+ return {
+ copyLink: (record: SentFileRecord) =>
+ copyRetrieveLink(record.retrieveCode, { notify }),
+ copyCode: (record: SentFileRecord) =>
+ copyRetrieveCode(record.retrieveCode, { notify }),
+ copyWgetCommand: (record: SentFileRecord) =>
+ copyWgetCommand(record.retrieveCode, record.filename, { notify }),
+ getQRCodeValue: (record: SentFileRecord) => buildSentRecordQrValue(record)
+ }
+}
diff --git a/src/utils/share-url.ts b/src/utils/share-url.ts
new file mode 100644
index 0000000..b361ab5
--- /dev/null
+++ b/src/utils/share-url.ts
@@ -0,0 +1,38 @@
+import { apiBaseURL } from '@/services/client'
+
+const getApiOrigin = () => {
+ if (!apiBaseURL) return window.location.origin
+ return new URL(apiBaseURL, window.location.origin).origin
+}
+
+export function buildAbsoluteUrl(path: string): string {
+ if (/^https?:\/\//i.test(path)) {
+ return path
+ }
+
+ const normalizedPath = path.startsWith('/') ? path : `/${path}`
+ return `${getApiOrigin()}${normalizedPath}`
+}
+
+export function buildRetrieveUrl(code: string): string {
+ return `${window.location.origin}/#/?code=${code}`
+}
+
+export function buildDownloadUrl(downloadUrl: string | null): string {
+ return downloadUrl ? buildAbsoluteUrl(downloadUrl) : ''
+}
+
+export function buildReceivedRecordQrValue(record: {
+ code: string
+ downloadUrl: string | null
+}): string {
+ return record.downloadUrl ? buildDownloadUrl(record.downloadUrl) : buildRetrieveUrl(record.code)
+}
+
+export function buildSentRecordQrValue(record: { retrieveCode: string }): string {
+ return buildRetrieveUrl(record.retrieveCode)
+}
+
+export function buildWgetCommand(retrieveCode: string, fileName: string): string {
+ return `wget ${buildAbsoluteUrl(`/share/select?code=${retrieveCode}`)} -O "${fileName}"`
+}
diff --git a/src/views/RetrievewFileView.vue b/src/views/RetrievewFileView.vue
index 5c2d8b5..01e10b6 100644
--- a/src/views/RetrievewFileView.vue
+++ b/src/views/RetrievewFileView.vue
@@ -1,578 +1,121 @@
-
-
-
-
-
-
-
-
-
+
-
-
-
diff --git a/src/views/SendFileView.vue b/src/views/SendFileView.vue
index 7e0a635..88a4619 100644
--- a/src/views/SendFileView.vue
+++ b/src/views/SendFileView.vue
@@ -1,1030 +1,200 @@
-
-
diff --git a/src/views/manage/DashboardView.vue b/src/views/manage/DashboardView.vue
index f97c3a1..e87ed8f 100644
--- a/src/views/manage/DashboardView.vue
+++ b/src/views/manage/DashboardView.vue
@@ -1,198 +1,441 @@
-