refactor: modularize frontend flows

This commit is contained in:
Lan
2026-06-03 02:01:57 +08:00
parent 9300607f96
commit a11e7900b4
85 changed files with 4654 additions and 4363 deletions
+95
View File
@@ -0,0 +1,95 @@
# 前端架构说明
本文档记录前端代码的分层边界和导入规则,用于保持框架稳定、可扩展。
## 目录职责
- `src/views/`:页面编排层,只负责布局、组件组合和事件绑定。
- `src/components/`:可复用 UI 组件,只接收 props、触发 emits,不直接访问接口。
- `src/composables/`:业务流程层,承载页面状态、业务校验、服务调用和视图模型转换。
- `src/services/`:API 访问层,只封装请求路径、请求参数和响应类型;`client.ts` 是统一 HTTP 客户端入口,`upload-client.ts` 只处理外部直传,`upload-strategy.ts` 承载上传协议编排。
- `src/stores/`:跨页面共享状态。局部页面状态应优先放在对应 composable。
- `src/types/`:领域类型定义,按 `api/auth/config/dashboard/file/presign-upload/ui` 拆分。
- `src/utils/`:纯工具、存储 helper、URL 构造、剪贴板、格式化等无页面状态能力,不直接依赖 store。
## 导入规则
- 页面层统一从 `@/composables` 引入业务流程。
- composable 内部引用同目录 composable 时使用相对路径,不通过 `@/composables/*` 绕回统一出口。
- composable 统一从 `@/services` 引入 API 服务。
- service 内部统一使用 `./client`,不要从 `@/utils/api` 或页面层创建请求客户端。
-`axios` 只能出现在 `services/client.ts``services/upload-client.ts` 这类客户端封装内。
- 类型默认从 `@/types` 引入;需要领域内复用时可在 `src/types/*` 内部相对引入。
- 不在页面或组件中直接调用 `axios``fetch``FileService``ConfigService` 等服务。
- 不在页面中直接读写 `localStorage`,持久化逻辑放在 `utils/*-storage.ts`
- 页面和组件不直接导入剪贴板 helper,复制动作由对应 composable 暴露。
## 状态归属
- 发送流程状态归 `useSendFlow`;路由跳转、返回首页等页面编排放在 view。
- 发送记录列表归 `SentRecordList`,发送记录详情归 `SentRecordDetailModal`,发送页不内联记录抽屉明细模板。
- 发送记录视图模型、过期时间校验和过期时间展示归 `utils/send-record.ts``useSendFlow` 不内联日期格式化和记录构造。
- 发送记录复制和二维码取值动作归 `utils/sent-record-actions.ts``useSendFlow` 不直接调用底层剪贴板和二维码 URL helper。
- 发送提交策略归 `useSendSubmit`,切片上传、预签名上传、文本上传和多文件打包不回流到 `useSendFlow`
- 取件流程状态归 `useRetrieveFlow`;路由参数读取、自动提交、页面跳转等页面编排放在 view。
- 取件详情和内容预览的打开/关闭动作归 `useRetrieveFlow`,取件页不直接改内部状态 ref。
- 管理文件列表状态归 `useAdminFiles`
- 文件管理编辑弹窗字段统一使用 `FileEditField`,页面不手写重复输入字段结构。
- 仪表盘数据状态归 `useDashboardStats`,加载时机由 `DashboardView` 显式触发。
- 登录表单和认证请求归 `useAdminLogin`,登录成功后的页面跳转放在 `LoginView`
- 可复用的底层 composable 不直接读取全局 store 或弹提示,应通过参数注入配置读取和通知回调。
- 系统配置编辑状态归 `useSystemConfig`,公共配置缓存归 `configStore`
- 系统配置页的布尔/数字开关由 `useSystemConfig` 暴露语义动作,模板不直接写切换表达式。
- 系统配置页的二值开关统一使用 `SettingSwitch`,页面不手写 switch 样式结构。
- 系统配置页的简单数字项统一使用 `SettingNumberInput`,带单位下拉的复合输入可保留在页面编排层。
- 系统配置页的单位表单状态和提交 payload 构造归 `useSystemConfig`,单位换算纯函数归 `utils/config-form.ts`
- 发送/接收历史记录归 `fileData` store。
- Alert 计时和进度归 `alertStore``AlertComponent` 只负责渲染。
## 工具边界
- URL 构造统一放在 `utils/share-url.ts`
- 剪贴板能力统一放在 `utils/clipboard.ts`,复制结果提示通过调用方注入回调。
- 粘贴事件的纯解析和文本插入计算统一放在 `utils/clipboard-paste.ts`
- 文件 hash、zip 打包等浏览器文件处理统一放在 `utils/file-processing.ts`
- Markdown 预览渲染与 HTML 清洗统一放在 `utils/content-preview.ts`
- 文件下载、文本另存等浏览器下载动作统一放在 `utils/download-action.ts`
- 配置表单单位转换统一放在 `utils/config-form.ts`
- 配置缓存读写统一放在 `utils/config-storage.ts`
- 认证缓存读写统一放在 `utils/auth-storage.ts`
## 维护约束
- 新页面应先创建对应 composable,再由 view 绑定 composable 输出。
- composable 默认不在初始化阶段自动发起远程请求,页面或上层流程应显式触发加载。
- 新接口应先进入 `services/`,不要在 composable 中拼接散落的底层请求细节。
- Blob、文件下载等需要保留响应头的接口使用 `rawApiClient`,普通 JSON 接口使用默认 `apiClient`
- 上传协议、断点续传、外部直传等流程优先放在 `services/*strategy*` 或客户端封装,composable 只传入状态、回调和文案。
- 文件打包依赖 `JSZip` 只能出现在 `utils/file-processing.ts`,页面和 composable 不直接依赖打包库。
- 预览渲染依赖 `marked` / `DOMPurify` 只能出现在 `utils/content-preview.ts`
- 下载动作依赖 `file-saver``window.open` 时,只能出现在 `utils/download-action.ts`
- 新共享状态进入 store 前,需要确认它确实跨页面使用。
- 新类型应放入对应领域类型文件,再由 `types/index.ts` 汇总导出。
- 删除旧代码优先于保留兼容别名,除非已有明确外部调用方。
## 验证
每次结构性改动后至少运行:
```sh
pnpm build
```
`pnpm build` 会先执行 `pnpm check:architecture`,再执行类型检查和生产构建。
快速验证架构边界时可单独运行:
```sh
pnpm check:architecture
```
必要时补充:
```sh
pnpm lint
```
+10
View File
@@ -14,6 +14,16 @@ export default [
// 你可以在这里添加自定义规则 // 你可以在这里添加自定义规则
} }
}, },
{
files: ['scripts/**/*.mjs'],
languageOptions: {
globals: {
console: 'readonly',
process: 'readonly',
URL: 'readonly',
},
},
},
{ {
ignores: ['dist/**', 'node_modules/**'] ignores: ['dist/**', 'node_modules/**']
} }
+3 -1
View File
@@ -5,10 +5,11 @@
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "run-p type-check \"build-only {@}\" --", "build": "run-s check:architecture type-check build-only",
"preview": "vite preview", "preview": "vite preview",
"build-only": "vite build", "build-only": "vite build",
"type-check": "vue-tsc --build --force", "type-check": "vue-tsc --build --force",
"check:architecture": "node scripts/check-architecture.mjs",
"lint": "eslint . --fix", "lint": "eslint . --fix",
"format": "prettier --write src/" "format": "prettier --write src/"
}, },
@@ -30,6 +31,7 @@
}, },
"devDependencies": { "devDependencies": {
"@eslint/config-array": "^0.21.0", "@eslint/config-array": "^0.21.0",
"@eslint/js": "^9.39.2",
"@eslint/object-schema": "^2.1.6", "@eslint/object-schema": "^2.1.6",
"@rushstack/eslint-patch": "^1.12.0", "@rushstack/eslint-patch": "^1.12.0",
"@tsconfig/node20": "^20.1.6", "@tsconfig/node20": "^20.1.6",
+79 -439
View File
File diff suppressed because it is too large Load Diff
+16
View File
@@ -0,0 +1,16 @@
{
"name": "FileCodeBox",
"short_name": "FileCodeBox",
"description": "FileCodeBox file transfer",
"start_url": "/",
"display": "standalone",
"background_color": "#ffffff",
"theme_color": "#4f46e5",
"icons": [
{
"src": "/assets/logo_small.png",
"sizes": "512x512",
"type": "image/png"
}
]
}
+270
View File
@@ -0,0 +1,270 @@
import { readFileSync } from 'node:fs'
import { join, relative } from 'node:path'
import { globSync } from 'glob'
const rootDir = new URL('..', import.meta.url).pathname
const srcDir = join(rootDir, 'src')
const rules = [
{
name: 'views/components 不直接导入 services',
files: ['src/views/**/*.{ts,vue}', 'src/components/**/*.{ts,vue}'],
patterns: [/from ['"]@\/services(?:\/[^'"]*)?['"]/],
},
{
name: 'views/components 不直接调用服务类',
files: ['src/views/**/*.{ts,vue}', 'src/components/**/*.{ts,vue}'],
patterns: [
/\bFileService\./,
/\bConfigService\./,
/\bAuthService\./,
/\bStatsService\./,
/\bPresignUploadService\./,
],
},
{
name: 'views/components/composables 不直接导入具体 service 文件',
files: ['src/views/**/*.{ts,vue}', 'src/components/**/*.{ts,vue}', 'src/composables/**/*.ts'],
patterns: [/from ['"]@\/services\/[^'"]+['"]/],
},
{
name: '预签名上传 composable 不直接依赖 store',
files: ['src/composables/usePresignedUpload.ts'],
patterns: [/from ['"]@\/stores\/[^'"]+['"]/],
},
{
name: '取件流程 composable 不直接依赖路由',
files: ['src/composables/useRetrieveFlow.ts'],
patterns: [/from ['"]vue-router['"]/],
},
{
name: '发送流程 composable 不直接依赖路由',
files: ['src/composables/useSendFlow.ts'],
patterns: [/from ['"]vue-router['"]/],
},
{
name: '登录流程 composable 不直接依赖路由',
files: ['src/composables/useAdminLogin.ts'],
patterns: [/from ['"]vue-router['"]/],
},
{
name: '应用壳不直接调用服务,启动任务需拆到专用 composable',
files: ['src/composables/useAppShell.ts'],
patterns: [/\b[A-Z][A-Za-z]+Service\./, /from ['"]@\/services\/[^'"]+['"]/],
},
{
name: '公共配置启动任务独立维护',
files: ['src/composables/useAppShell.ts'],
patterns: [/\bgetUserConfig\(/, /\bapplyRemoteConfig\(/],
},
{
name: '仪表盘数据 composable 不在初始化阶段自动加载',
files: ['src/composables/useDashboardStats.ts'],
patterns: [/\bonMounted\(/],
},
{
name: '请求客户端统一放在 services/client',
files: ['src/**/*.{ts,vue}'],
patterns: [/from ['"]@\/utils\/api['"]/],
ignoreFiles: [/^src\/utils\/api\.ts$/],
},
{
name: '请求客户端不直接控制浏览器跳转',
files: ['src/services/client.ts'],
patterns: [/\bwindow\.location\.(href|replace|assign)\b/, /\blocation\.(href|replace|assign)\b/],
},
{
name: '裸 axios 只能出现在请求客户端封装内',
files: ['src/**/*.{ts,vue}'],
patterns: [/import\s+axios\b/],
ignoreFiles: [/^src\/services\/client\.ts$/, /^src\/services\/upload-client\.ts$/],
},
{
name: '文件打包能力统一放在 file-processing 工具',
files: ['src/**/*.{ts,vue}'],
patterns: [/from ['"]jszip['"]/],
ignoreFiles: [/^src\/utils\/file-processing\.ts$/],
},
{
name: '内容预览渲染统一放在 content-preview 工具',
files: ['src/**/*.{ts,vue}'],
patterns: [/from ['"]marked['"]/, /from ['"]dompurify['"]/i],
ignoreFiles: [/^src\/utils\/content-preview\.ts$/],
},
{
name: '下载动作统一放在 download-action 工具',
files: ['src/**/*.{ts,vue}'],
patterns: [/from ['"]file-saver['"]/, /\bwindow\.open\(/],
ignoreFiles: [/^src\/utils\/download-action\.ts$/],
},
{
name: 'services 统一使用 services/client 作为内部请求入口',
files: ['src/services/**/*.ts'],
patterns: [/from ['"]@\/services\/client['"]/],
},
{
name: '非 storage helper 不直接访问 localStorage',
files: ['src/**/*.{ts,vue}'],
patterns: [/\blocalStorage\./],
ignoreFiles: [
/^src\/utils\/auth-storage\.ts$/,
/^src\/utils\/config-storage\.ts$/,
/^src\/utils\/preference-storage\.ts$/,
],
},
{
name: '非 URL helper 不直接读取 window.location.origin',
files: ['src/**/*.{ts,vue}'],
patterns: [/\bwindow\.location\.origin\b/],
ignoreFiles: [/^src\/utils\/share-url\.ts$/],
},
{
name: '非剪贴板 helper 不直接调用剪贴板 API',
files: ['src/**/*.{ts,vue}'],
patterns: [/\bnavigator\.clipboard\b/, /\bdocument\.execCommand\(['"]copy['"]\)/],
ignoreFiles: [/^src\/utils\/clipboard\.ts$/],
},
{
name: '页面和组件不直接导入剪贴板 helper',
files: ['src/views/**/*.{ts,vue}', 'src/components/**/*.{ts,vue}'],
patterns: [/from ['"]@\/utils\/clipboard['"]/],
},
{
name: '工具层不直接依赖 store',
files: ['src/utils/**/*.ts'],
patterns: [/from ['"]@\/stores\/[^'"]+['"]/],
},
{
name: '系统配置页通过 composable 动作切换二值配置',
files: ['src/views/manage/SystemSettingsView.vue'],
patterns: [/config\.(enableChunk|s3_proxy|openUpload)\s*=[^=]/],
},
{
name: '系统配置页开关统一使用 SettingSwitch',
files: ['src/views/manage/SystemSettingsView.vue'],
patterns: [/\brole=['"]switch['"]/],
},
{
name: '系统配置页简单数字项统一使用 SettingNumberInput',
files: ['src/views/manage/SystemSettingsView.vue'],
patterns: [/<input[^>]*v-model=['"]config\.(uploadMinute|uploadCount|errorMinute|errorCount)['"]/],
},
{
name: '系统配置页不直接处理配置表单转换',
files: ['src/views/manage/SystemSettingsView.vue'],
patterns: [/from ['"]@\/utils\/config-form['"]/],
},
{
name: '文件管理编辑弹窗字段统一使用 FileEditField',
files: ['src/views/manage/FileManageView.vue'],
patterns: [/<input[^>]*v-model=['"]editForm\.(code|prefix|suffix|expired_at|expired_count)['"]/],
},
{
name: '发送页记录列表统一使用 SentRecordList',
files: ['src/views/SendFileView.vue'],
patterns: [/v-for=['"]record in sendRecords['"]/],
},
{
name: '发送页记录详情统一使用 SentRecordDetailModal',
files: ['src/views/SendFileView.vue'],
patterns: [/<QRCode\b/, /\bselectedRecord\s*=\s*null\b/],
},
{
name: '发送流程记录构造统一放在 send-record 工具',
files: ['src/composables/useSendFlow.ts'],
patterns: [/\bDate\.now\(\)/, /\bnew Date\(\)\.toISOString\(\)/],
},
{
name: '发送流程过期时间格式化统一放在 send-record 工具',
files: ['src/composables/useSendFlow.ts'],
patterns: [/\bsetMinutes\(/, /\bsetHours\(/, /\bsetDate\(/],
},
{
name: '发送记录复制和二维码动作统一放在 sent-record-actions 工具',
files: ['src/composables/useSendFlow.ts'],
patterns: [
/\bcopyRetrieve(Code|Link)\(/,
/\bcopyWgetCommand\(/,
/\bbuildSentRecordQrValue\(/
],
},
{
name: '发送提交策略统一放在 useSendSubmit',
files: ['src/composables/useSendFlow.ts'],
patterns: [
/from ['"]@\/services['"]/,
/from ['"]\.\/usePresignedUpload['"]/,
/\buploadChunkedFile\(/,
/\bFileService\.uploadText\(/,
/\bpackFilesAsZip\(/
],
},
{
name: '取件页不直接改流程内部状态',
files: ['src/views/RetrievewFileView.vue'],
patterns: [/\bselectedRecord\s*=\s*null\b/, /\bshowPreview\s*=\s*false\b/],
},
{
name: '旧上传下载 composable 不回流',
files: ['src/**/*.{ts,vue}'],
patterns: [/\buseFileUpload\b/, /\buseFileDownload\b/],
},
{
name: '页面层统一从 @/composables 导入',
files: ['src/views/**/*.{ts,vue}', 'src/App.vue'],
patterns: [/from ['"]@\/composables\/[^'"]+['"]/],
},
{
name: 'composables 内部同层依赖使用相对导入',
files: ['src/composables/**/*.ts'],
patterns: [/from ['"]@\/composables\/[^'"]+['"]/],
},
]
const violations = []
for (const rule of rules) {
const files = rule.files.flatMap((pattern) =>
globSync(pattern, {
cwd: rootDir,
absolute: true,
nodir: true,
ignore: ['node_modules/**', 'dist/**'],
})
)
for (const file of files) {
const relativeFile = relative(rootDir, file)
if (rule.ignoreFiles?.some((pattern) => pattern.test(relativeFile))) {
continue
}
const source = readFileSync(file, 'utf8')
const lines = source.split(/\r?\n/)
lines.forEach((line, index) => {
for (const pattern of rule.patterns) {
if (pattern.test(line)) {
violations.push({
rule: rule.name,
file: relativeFile,
line: index + 1,
text: line.trim(),
})
}
}
})
}
}
if (violations.length > 0) {
console.error('架构边界检查失败:')
for (const violation of violations) {
console.error(
`- ${violation.rule}: ${violation.file}:${violation.line} ${violation.text}`
)
}
process.exit(1)
}
console.log(`架构边界检查通过:${srcDir}`)
+3 -57
View File
@@ -1,70 +1,16 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, provide, onMounted, onUnmounted } from 'vue'
import { RouterView } from 'vue-router' import { RouterView } from 'vue-router'
import ThemeToggle from './components/common/ThemeToggle.vue' import ThemeToggle from './components/common/ThemeToggle.vue'
import LanguageSwitcher from './components/common/LanguageSwitcher.vue' import LanguageSwitcher from './components/common/LanguageSwitcher.vue'
import { useRouter, useRoute } from 'vue-router'
import AlertComponent from '@/components/common/AlertComponent.vue' import AlertComponent from '@/components/common/AlertComponent.vue'
import { useAlertStore } from '@/stores/alertStore' import { useAppShell } from '@/composables'
import { ConfigService } from '@/services'
import type { ApiResponse, ConfigState } from '@/types'
import { useTheme } from '@/composables/useTheme'
const isLoading = ref(false) const { isDarkMode, isLoading, route, showGlobalControls } = useAppShell()
const router = useRouter()
const route = useRoute()
const alertStore = useAlertStore()
// 使用主题 composable
const { isDarkMode, toggleTheme, initTheme } = useTheme()
// 清理函数
let cleanupThemeListener: (() => void) | null = null
onMounted(() => {
// 初始化主题并设置监听器
cleanupThemeListener = initTheme()
ConfigService.getUserConfig().then((res: ApiResponse<ConfigState>) => {
if (res.code === 200 && res.detail) {
localStorage.setItem('config', JSON.stringify(res.detail))
if (
res.detail.notify_title &&
res.detail.notify_content &&
localStorage.getItem('notify') !== res.detail.notify_title + res.detail.notify_content
) {
localStorage.setItem('notify', res.detail.notify_title + res.detail.notify_content)
alertStore.showAlert(res.detail.notify_title + ': ' + res.detail.notify_content, 'success')
}
}
})
})
onUnmounted(() => {
// 清理主题监听器
if (cleanupThemeListener) {
cleanupThemeListener()
}
})
router.beforeEach((to, from, next) => {
isLoading.value = true
next()
})
router.afterEach(() => {
setTimeout(() => {
isLoading.value = false
}, 200) // 添加一个小延迟,以确保组件已加载
})
provide('isDarkMode', isDarkMode)
provide('toggleTheme', toggleTheme)
provide('isLoading', isLoading)
</script> </script>
<template> <template>
<div :class="['app-container', isDarkMode ? 'dark' : 'light']"> <div :class="['app-container', isDarkMode ? 'dark' : 'light']">
<div class="fixed top-4 right-4 z-50 flex items-center space-x-3"> <div v-if="showGlobalControls" class="fixed top-4 right-4 z-50 flex items-center space-x-3">
<LanguageSwitcher /> <LanguageSwitcher />
<ThemeToggle v-model="isDarkMode" /> <ThemeToggle v-model="isDarkMode" />
</div> </div>
+3 -9
View File
@@ -53,7 +53,7 @@ const { t } = useI18n()
const alertStore = useAlertStore() const alertStore = useAlertStore()
const { alerts } = storeToRefs(alertStore) const { alerts } = storeToRefs(alertStore)
const { removeAlert, updateAlertProgress } = alertStore const { removeAlert, startProgressTimer, stopProgressTimer } = alertStore
const gradientClasses = { const gradientClasses = {
success: 'from-green-500 to-green-600', success: 'from-green-500 to-green-600',
@@ -69,18 +69,12 @@ const alertIcons = {
info: Info info: Info
} }
let intervalId: ReturnType<typeof setInterval>
onMounted(() => { onMounted(() => {
intervalId = setInterval(() => { startProgressTimer()
alerts.value.forEach((alert) => {
updateAlertProgress(alert.id)
})
}, 100)
}) })
onUnmounted(() => { onUnmounted(() => {
clearInterval(intervalId) stopProgressTimer()
}) })
</script> </script>
+5 -4
View File
@@ -18,7 +18,8 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, inject } from 'vue' import { computed } from 'vue'
import { useInjectedDarkMode } from '@/composables'
interface Props { interface Props {
variant?: 'primary' | 'secondary' | 'danger' | 'success' | 'outline' variant?: 'primary' | 'secondary' | 'danger' | 'success' | 'outline'
@@ -40,7 +41,7 @@ defineEmits<{
click: [event: MouseEvent] click: [event: MouseEvent]
}>() }>()
const isDarkMode = inject('isDarkMode') const isDarkMode = useInjectedDarkMode()
const sizeClasses = computed(() => { const sizeClasses = computed(() => {
const sizes = { const sizes = {
@@ -59,7 +60,7 @@ const variantClasses = computed(() => {
} }
if (props.variant === 'secondary') { if (props.variant === 'secondary') {
return isDarkMode return isDarkMode.value
? `${baseClasses} bg-gray-700 text-gray-300 hover:bg-gray-600 focus:ring-gray-500 border border-gray-600` ? `${baseClasses} bg-gray-700 text-gray-300 hover:bg-gray-600 focus:ring-gray-500 border border-gray-600`
: `${baseClasses} bg-gray-100 text-gray-700 hover:bg-gray-200 focus:ring-gray-500 border border-gray-300` : `${baseClasses} bg-gray-100 text-gray-700 hover:bg-gray-200 focus:ring-gray-500 border border-gray-300`
} }
@@ -73,7 +74,7 @@ const variantClasses = computed(() => {
} }
if (props.variant === 'outline') { if (props.variant === 'outline') {
return isDarkMode return isDarkMode.value
? `${baseClasses} border border-gray-600 text-gray-300 hover:bg-gray-700 focus:ring-gray-500` ? `${baseClasses} border border-gray-600 text-gray-300 hover:bg-gray-700 focus:ring-gray-500`
: `${baseClasses} border border-gray-300 text-gray-700 hover:bg-gray-50 focus:ring-gray-500` : `${baseClasses} border border-gray-300 text-gray-700 hover:bg-gray-50 focus:ring-gray-500`
} }
+37 -16
View File
@@ -77,23 +77,40 @@
:value="expirationMethod" :value="expirationMethod"
@change="updateMethod" @change="updateMethod"
:class="[ :class="[
'absolute right-0 top-0 h-full px-4 rounded-r-2xl border-l transition-all duration-300', 'absolute right-0 top-0 h-full appearance-none cursor-pointer transition-all duration-300',
'focus:outline-none focus:ring-2 focus:ring-offset-0', 'focus:outline-none focus:ring-2 focus:ring-offset-0',
'bg-transparent appearance-none cursor-pointer', expirationMethod === 'forever'
? 'w-full px-5 rounded-2xl'
: 'w-28 pl-4 pr-9 border-l rounded-r-2xl',
isDarkMode isDarkMode
? 'border-gray-700/60 text-gray-300 focus:ring-indigo-500/80' ? 'text-gray-100 border-gray-700/60 focus:ring-indigo-500/80 bg-gray-800/60'
: 'border-gray-200 text-gray-700 focus:ring-indigo-500/60' : 'text-gray-900 border-gray-200 focus:ring-indigo-500/60 bg-white'
]" ]"
:style="{
color: isDarkMode ? '#f3f4f6' : '#111827',
backgroundColor: isDarkMode ? 'rgba(31, 41, 55, 0.5)' : '#ffffff'
}"
> >
<option value="count">{{ t('send.expiration.units.times') }}</option> <option
<option value="minute">{{ t('send.expiration.units.minutes') }}</option> v-for="option in options"
<option value="hour">{{ t('send.expiration.units.hours') }}</option> :key="option.value"
<option value="day">{{ t('send.expiration.units.days') }}</option> :value="option.value"
<option value="forever">{{ t('send.expiration.units.forever') }}</option> :class="[isDarkMode ? 'bg-gray-800 text-gray-100' : 'bg-white text-gray-900']"
:style="{
color: isDarkMode ? '#f3f4f6' : '#111827',
backgroundColor: isDarkMode ? '#1f2937' : '#ffffff'
}"
>
{{ option.label }}
</option>
</select> </select>
<div <div
class="absolute right-4 top-1/2 transform -translate-y-1/2 pointer-events-none" class="absolute pointer-events-none"
:class="[isDarkMode ? 'text-gray-400' : 'text-gray-500']" :class="[
expirationMethod === 'forever' ? 'right-3' : 'right-2',
'top-1/2 -translate-y-1/2',
isDarkMode ? 'text-gray-400' : 'text-gray-500'
]"
> >
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path <path
@@ -117,12 +134,16 @@ const { t } = useI18n()
interface Props { interface Props {
expirationMethod: string expirationMethod: string
expirationValue: number expirationValue: string
options: Array<{
label: string
value: string
}>
} }
interface Emits { interface Emits {
'update:expirationMethod': [value: string] 'update:expirationMethod': [value: string]
'update:expirationValue': [value: number] 'update:expirationValue': [value: string]
} }
const props = defineProps<Props>() const props = defineProps<Props>()
@@ -136,13 +157,13 @@ const updateMethod = (event: Event) => {
const updateValue = (event: Event) => { const updateValue = (event: Event) => {
const target = event.target as HTMLInputElement const target = event.target as HTMLInputElement
emit('update:expirationValue', parseInt(target.value) || 1) emit('update:expirationValue', target.value)
} }
const incrementValue = (delta: number) => { const incrementValue = (delta: number) => {
const currentValue = props.expirationValue || 1 const currentValue = parseInt(props.expirationValue) || 0
const newValue = Math.max(1, currentValue + delta) const newValue = Math.max(1, currentValue + delta)
emit('update:expirationValue', newValue) emit('update:expirationValue', newValue.toString())
} }
const getPlaceholder = () => { const getPlaceholder = () => {
+7 -27
View File
@@ -112,20 +112,12 @@ import { inject } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { FileIcon, CalendarIcon, HardDriveIcon, DownloadIcon } from 'lucide-vue-next' import { FileIcon, CalendarIcon, HardDriveIcon, DownloadIcon } from 'lucide-vue-next'
import QRCode from 'qrcode.vue' import QRCode from 'qrcode.vue'
import type { ReceivedFileRecord } from '@/types'
interface FileRecord { import { buildDownloadUrl, buildReceivedRecordQrValue } from '@/utils/share-url'
id: number
code: string
filename: string
size: string
downloadUrl: string | null
content: string | null
date: string
}
interface Props { interface Props {
visible: boolean visible: boolean
record: FileRecord | null record: ReceivedFileRecord | null
} }
interface Emits { interface Emits {
@@ -137,25 +129,13 @@ defineProps<Props>()
defineEmits<Emits>() defineEmits<Emits>()
const { t } = useI18n() const { t } = useI18n()
const isDarkMode = inject('isDarkMode') const isDarkMode = inject('isDarkMode')
const baseUrl = window.location.origin
const getDownloadUrl = (record: FileRecord) => { const getDownloadUrl = (record: ReceivedFileRecord) => {
if (record.downloadUrl) { return buildDownloadUrl(record.downloadUrl)
if (record.downloadUrl.startsWith('http')) {
return record.downloadUrl
} else {
return `${baseUrl}${record.downloadUrl}`
}
}
return ''
} }
const getQRCodeValue = (record: FileRecord) => { const getQRCodeValue = (record: ReceivedFileRecord) => {
if (record.downloadUrl) { return buildReceivedRecordQrValue(record)
return `${baseUrl}${record.downloadUrl}`
} else {
return `${baseUrl}?code=${record.code}`
}
} }
</script> </script>
+75
View File
@@ -0,0 +1,75 @@
<template>
<div class="space-y-2 group">
<label
class="text-sm font-medium flex items-center space-x-2 transition-colors duration-200"
:class="[
isDarkMode
? 'text-gray-300 group-focus-within:text-indigo-400'
: 'text-gray-700 group-focus-within:text-indigo-600'
]"
>
<span>{{ label }}</span>
<div
class="h-px flex-1 transition-colors duration-200"
:class="[
isDarkMode
? 'bg-gray-700 group-focus-within:bg-indigo-500/50'
: 'bg-gray-200 group-focus-within:bg-indigo-500/30'
]"
></div>
</label>
<div class="relative rounded-lg shadow-sm">
<input
:type="type"
:value="modelValue ?? ''"
class="block w-full rounded-lg border-0 py-2.5 pl-4 pr-10 transition-all duration-200 focus:ring-2 focus:ring-inset sm:text-sm"
:class="[
isDarkMode
? 'bg-gray-700/50 text-white placeholder-gray-400 focus:ring-indigo-500/50'
: 'bg-gray-50 text-gray-900 placeholder-gray-400 focus:ring-indigo-500'
]"
:placeholder="placeholder"
@input="handleInput"
/>
<div
class="absolute inset-y-0 right-0 flex items-center pr-3 pointer-events-none transition-opacity duration-200 opacity-0 group-focus-within:opacity-100"
>
<CheckIcon class="w-5 h-5" :class="[isDarkMode ? 'text-indigo-400' : 'text-indigo-600']" />
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { inject } from 'vue'
import { CheckIcon } from 'lucide-vue-next'
const props = withDefaults(
defineProps<{
label: string
modelValue: string | number | null
placeholder?: string
type?: 'text' | 'number' | 'datetime-local'
}>(),
{
placeholder: '',
type: 'text'
}
)
const emit = defineEmits<{
'update:modelValue': [value: string | number | null]
}>()
const isDarkMode = inject('isDarkMode')
const handleInput = (event: Event) => {
const target = event.target as HTMLInputElement
if (props.type === 'number') {
emit('update:modelValue', target.value === '' ? null : target.valueAsNumber)
return
}
emit('update:modelValue', target.value)
}
</script>
+4 -13
View File
@@ -68,24 +68,15 @@
<script setup lang="ts"> <script setup lang="ts">
import { inject } from 'vue' import { inject } from 'vue'
import { FileIcon, EyeIcon, DownloadIcon, TrashIcon } from 'lucide-vue-next' import { FileIcon, EyeIcon, DownloadIcon, TrashIcon } from 'lucide-vue-next'
import type { ReceivedFileRecord } from '@/types'
interface FileRecord {
id: number
code: string
filename: string
size: string
downloadUrl: string | null
content: string | null
date: string
}
interface Props { interface Props {
records: FileRecord[] records: ReceivedFileRecord[]
} }
interface Emits { interface Emits {
'view-details': [record: FileRecord] 'view-details': [record: ReceivedFileRecord]
'download-record': [record: FileRecord] 'download-record': [record: ReceivedFileRecord]
'delete-record': [id: number] 'delete-record': [id: number]
} }
+12 -11
View File
@@ -84,10 +84,11 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, inject, computed } from 'vue' import { ref, computed } from 'vue'
import { UploadCloudIcon, CheckCircleIcon, XCircleIcon, LoaderIcon } from 'lucide-vue-next' import { UploadCloudIcon, CheckCircleIcon, XCircleIcon, LoaderIcon } from 'lucide-vue-next'
import BorderProgressBar from './BorderProgressBar.vue' import BorderProgressBar from './BorderProgressBar.vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { useInjectedDarkMode } from '@/composables'
const { t } = useI18n() const { t } = useI18n()
@@ -141,7 +142,7 @@ const props = withDefaults(defineProps<Props>(), {
const emit = defineEmits<Emits>() const emit = defineEmits<Emits>()
const isDarkMode = inject('isDarkMode') const isDarkMode = useInjectedDarkMode()
// 使用computed属性处理多语言文本 // 使用computed属性处理多语言文本
const placeholderText = computed(() => props.placeholder || t('send.uploadArea.placeholder')) const placeholderText = computed(() => props.placeholder || t('send.uploadArea.placeholder'))
@@ -178,25 +179,25 @@ const statusIcon = computed(() => {
const statusIconClass = computed(() => { const statusIconClass = computed(() => {
if (isUploading.value) { if (isUploading.value) {
return isDarkMode ? 'text-indigo-400 animate-spin' : 'text-indigo-600 animate-spin' return isDarkMode.value ? 'text-indigo-400 animate-spin' : 'text-indigo-600 animate-spin'
} }
if (isSuccess.value) { if (isSuccess.value) {
return isDarkMode ? 'text-green-400' : 'text-green-600' return isDarkMode.value ? 'text-green-400' : 'text-green-600'
} }
if (hasError.value) { if (hasError.value) {
return isDarkMode ? 'text-red-400' : 'text-red-600' return isDarkMode.value ? 'text-red-400' : 'text-red-600'
} }
return isDarkMode return isDarkMode.value
? 'text-gray-400 group-hover:text-indigo-400' ? 'text-gray-400 group-hover:text-indigo-400'
: 'text-gray-600 group-hover:text-indigo-600' : 'text-gray-600 group-hover:text-indigo-600'
}) })
const statusClass = computed(() => { const statusClass = computed(() => {
if (hasError.value) { if (hasError.value) {
return isDarkMode ? 'border-red-500/50' : 'border-red-300' return isDarkMode.value ? 'border-red-500/50' : 'border-red-300'
} }
if (isSuccess.value) { if (isSuccess.value) {
return isDarkMode ? 'border-green-500/50' : 'border-green-300' return isDarkMode.value ? 'border-green-500/50' : 'border-green-300'
} }
return '' return ''
}) })
@@ -222,12 +223,12 @@ const statusDescription = computed(() => {
const statusDescriptionClass = computed(() => { const statusDescriptionClass = computed(() => {
if (hasError.value) { if (hasError.value) {
return isDarkMode ? 'text-red-400' : 'text-red-500' return isDarkMode.value ? 'text-red-400' : 'text-red-500'
} }
if (isSuccess.value) { if (isSuccess.value) {
return isDarkMode ? 'text-green-400' : 'text-green-500' return isDarkMode.value ? 'text-green-400' : 'text-green-500'
} }
return isDarkMode ? 'text-gray-500' : 'text-gray-400' return isDarkMode.value ? 'text-gray-500' : 'text-gray-400'
}) })
const formatBytes = (bytes: number): string => { const formatBytes = (bytes: number): string => {
+4 -3
View File
@@ -1,5 +1,5 @@
<template> <template>
<div class="relative"> <div ref="switcherRef" class="relative">
<button <button
@click="toggleDropdown" @click="toggleDropdown"
:class="[ :class="[
@@ -69,6 +69,7 @@ import { availableLocales, setLocale } from '@/i18n/index'
const { locale } = useI18n() const { locale } = useI18n()
const isDarkMode = inject('isDarkMode') const isDarkMode = inject('isDarkMode')
const isDropdownOpen = ref(false) const isDropdownOpen = ref(false)
const switcherRef = ref<HTMLElement | null>(null)
const currentLocale = computed(() => locale.value) const currentLocale = computed(() => locale.value)
const currentLanguage = computed(() => { const currentLanguage = computed(() => {
@@ -86,8 +87,8 @@ const switchLanguage = (langCode: string) => {
// 点击外部关闭下拉菜单 // 点击外部关闭下拉菜单
const handleClickOutside = (event: Event) => { const handleClickOutside = (event: Event) => {
const target = event.target as HTMLElement const target = event.target as Node | null
if (!target.closest('.relative')) { if (target && !switcherRef.value?.contains(target)) {
isDropdownOpen.value = false isDropdownOpen.value = false
} }
} }
@@ -0,0 +1,214 @@
<template>
<transition name="fade">
<div
v-if="record"
class="fixed inset-0 bg-black/60 backdrop-blur-sm flex items-center justify-center z-50 p-3 sm:p-4 overflow-y-auto"
>
<div
class="w-full max-w-2xl rounded-2xl shadow-2xl transform transition-all duration-300 ease-out overflow-hidden"
:class="[isDarkMode ? 'bg-gray-900 bg-opacity-70' : 'bg-white bg-opacity-95']"
>
<div
class="px-4 sm:px-6 py-3 sm:py-4 border-b"
:class="[isDarkMode ? 'border-gray-800' : 'border-gray-100']"
>
<div class="flex items-center justify-between">
<h3
class="text-lg sm:text-xl font-semibold"
:class="[isDarkMode ? 'text-white' : 'text-gray-900']"
>
{{ t('send.fileDetails') }}
</h3>
<button
@click="$emit('close')"
class="p-1.5 sm:p-2 rounded-full hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
>
<XIcon
class="w-4 h-4 sm:w-5 sm:h-5"
:class="[isDarkMode ? 'text-gray-400' : 'text-gray-500']"
/>
</button>
</div>
</div>
<div class="p-4 sm:p-6">
<div
class="rounded-xl p-3 sm:p-4 mb-4 sm:mb-6"
:class="[isDarkMode ? 'bg-gray-800 bg-opacity-50' : 'bg-gray-50 bg-opacity-95']"
>
<div class="flex items-center mb-3 sm:mb-4">
<div class="p-2 sm:p-3 rounded-lg" :class="[isDarkMode ? 'bg-gray-800' : 'bg-white']">
<FileIcon
class="w-5 h-5 sm:w-6 sm:h-6"
:class="[isDarkMode ? 'text-indigo-400' : 'text-indigo-600']"
/>
</div>
<div class="ml-3 sm:ml-4 min-w-0 flex-1">
<h4
class="font-medium text-sm sm:text-base truncate"
:class="[isDarkMode ? 'text-white' : 'text-gray-900']"
>
{{ record.filename }}
</h4>
<p
class="text-xs sm:text-sm truncate"
:class="[isDarkMode ? 'text-gray-400' : 'text-gray-500']"
>
{{ record.size }} · {{ record.date }}
</p>
</div>
</div>
<div class="grid grid-cols-2 gap-3 sm:gap-4">
<div class="flex items-center min-w-0">
<ClockIcon
class="w-3.5 h-3.5 sm:w-4 sm:h-4 mr-1.5 sm:mr-2 flex-shrink-0"
:class="[isDarkMode ? 'text-gray-400' : 'text-gray-500']"
/>
<span
class="text-xs sm:text-sm truncate"
:class="[isDarkMode ? 'text-gray-300' : 'text-gray-600']"
>
{{ record.expiration }}
</span>
</div>
<div class="flex items-center min-w-0">
<ShieldCheckIcon
class="w-3.5 h-3.5 sm:w-4 sm:h-4 mr-1.5 sm:mr-2 flex-shrink-0"
:class="[isDarkMode ? 'text-gray-400' : 'text-gray-500']"
/>
<span
class="text-xs sm:text-sm truncate"
:class="[isDarkMode ? 'text-gray-300' : 'text-gray-600']"
>
安全加密
</span>
</div>
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 sm:gap-6">
<div class="space-y-3 sm:space-y-4">
<div class="bg-gradient-to-br from-indigo-500 to-purple-600 rounded-xl p-4 sm:p-5 text-white">
<div class="flex items-center justify-between mb-3 sm:mb-4">
<h4 class="font-medium text-sm sm:text-base">取件码</h4>
<button
@click="$emit('copy-code', record)"
class="p-1.5 sm:p-2 rounded-full hover:bg-white/10 transition-colors"
>
<ClipboardCopyIcon class="w-4 h-4 sm:w-5 sm:h-5" />
</button>
</div>
<p class="text-2xl sm:text-3xl font-bold tracking-wider text-center break-all">
{{ record.retrieveCode }}
</p>
</div>
<div
class="rounded-xl p-3 sm:p-4"
:class="[isDarkMode ? 'bg-gray-800 bg-opacity-50' : 'bg-gray-50 bg-opacity-95']"
>
<div class="flex items-center justify-between mb-2 sm:mb-3">
<h4
class="font-medium text-sm sm:text-base flex items-center min-w-0"
:class="[isDarkMode ? 'text-white' : 'text-gray-900']"
>
<TerminalIcon class="w-4 h-4 sm:w-5 sm:h-5 mr-1.5 sm:mr-2 text-indigo-500 flex-shrink-0" />
<span class="truncate">wget下载</span>
</h4>
<button
@click="$emit('copy-wget', record)"
class="p-1.5 sm:p-2 rounded-full hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors flex-shrink-0"
>
<ClipboardCopyIcon
class="w-4 h-4 sm:w-5 sm:h-5"
:class="[isDarkMode ? 'text-gray-400' : 'text-gray-500']"
/>
</button>
</div>
<p
class="text-xs sm:text-sm font-mono break-all line-clamp-2"
:class="[isDarkMode ? 'text-gray-300' : 'text-gray-600']"
>
点击复制wget命令
</p>
</div>
</div>
<div
class="rounded-xl p-4 sm:p-5 flex flex-col items-center"
:class="[isDarkMode ? 'bg-gray-800 bg-opacity-50' : 'bg-gray-50 bg-opacity-95']"
>
<div class="bg-white p-3 sm:p-4 rounded-lg shadow-sm mb-3 sm:mb-4">
<QRCode :value="qrValue" :size="140" level="M" class="sm:w-[160px] sm:h-[160px]" />
</div>
<p
class="text-xs sm:text-sm truncate max-w-full"
:class="[isDarkMode ? 'text-gray-400' : 'text-gray-500']"
>
扫描二维码快速取件
</p>
</div>
</div>
</div>
<div
class="px-4 sm:px-6 py-3 sm:py-4 border-t"
:class="[isDarkMode ? 'border-gray-800' : 'border-gray-100']"
>
<button
@click="$emit('copy-link', record)"
class="w-full bg-indigo-600 hover:bg-indigo-700 text-white px-4 sm:px-6 py-2 sm:py-3 rounded-lg text-sm sm:text-base font-medium transition-colors"
>
复制取件链接
</button>
</div>
</div>
</div>
</transition>
</template>
<script setup lang="ts">
import { computed, inject } from 'vue'
import {
ClipboardCopyIcon,
ClockIcon,
FileIcon,
ShieldCheckIcon,
TerminalIcon,
XIcon
} from 'lucide-vue-next'
import QRCode from 'qrcode.vue'
import { useI18n } from 'vue-i18n'
import type { SentFileRecord } from '@/types'
const props = defineProps<{
record: SentFileRecord | null
getQRCodeValue: (record: SentFileRecord) => string
}>()
defineEmits<{
close: []
'copy-code': [record: SentFileRecord]
'copy-link': [record: SentFileRecord]
'copy-wget': [record: SentFileRecord]
}>()
const { t } = useI18n()
const isDarkMode = inject('isDarkMode')
const qrValue = computed(() => (props.record ? props.getQRCodeValue(props.record) : ''))
</script>
<style scoped>
.fade-enter-active,
.fade-leave-active {
transition:
opacity 0.3s ease,
transform 0.3s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
transform: translateY(10px);
}
</style>
+92
View File
@@ -0,0 +1,92 @@
<template>
<div class="flex-grow overflow-y-auto p-6">
<transition-group name="list" tag="div" class="space-y-4">
<div
v-for="record in records"
:key="record.id"
class="bg-opacity-50 rounded-lg p-4 flex items-center shadow-md hover:shadow-lg transition duration-300 transform hover:scale-102"
:class="[isDarkMode ? 'bg-gray-800 hover:bg-gray-700' : 'bg-gray-100 hover:bg-white']"
>
<div class="flex-shrink-0 mr-4">
<FileIcon
class="w-10 h-10"
:class="[isDarkMode ? 'text-indigo-400' : 'text-indigo-600']"
/>
</div>
<div class="flex-grow min-w-0 mr-4">
<p
class="font-medium text-lg truncate"
:class="[isDarkMode ? 'text-white' : 'text-gray-800']"
>
{{ record.filename ? record.filename : 'Text' }}
</p>
<p class="text-sm truncate" :class="[isDarkMode ? 'text-gray-400' : 'text-gray-600']">
{{ record.date }} · {{ record.size }}
</p>
</div>
<div class="flex-shrink-0 flex space-x-2">
<button
@click="$emit('copy-link', record)"
class="p-2 rounded-full hover:bg-opacity-20 transition duration-300"
:class="[
isDarkMode ? 'hover:bg-blue-400 text-blue-400' : 'hover:bg-blue-100 text-blue-600'
]"
>
<ClipboardCopyIcon class="w-5 h-5" />
</button>
<button
@click="$emit('view-details', record)"
class="p-2 rounded-full hover:bg-opacity-20 transition duration-300"
:class="[
isDarkMode
? 'hover:bg-green-400 text-green-400'
: 'hover:bg-green-100 text-green-600'
]"
>
<EyeIcon class="w-5 h-5" />
</button>
<button
@click="$emit('delete-record', record.id)"
class="p-2 rounded-full hover:bg-opacity-20 transition duration-300"
:class="[
isDarkMode ? 'hover:bg-red-400 text-red-400' : 'hover:bg-red-100 text-red-600'
]"
>
<TrashIcon class="w-5 h-5" />
</button>
</div>
</div>
</transition-group>
</div>
</template>
<script setup lang="ts">
import { inject } from 'vue'
import { ClipboardCopyIcon, EyeIcon, FileIcon, TrashIcon } from 'lucide-vue-next'
import type { SentFileRecord } from '@/types'
defineProps<{
records: SentFileRecord[]
}>()
defineEmits<{
'copy-link': [record: SentFileRecord]
'view-details': [record: SentFileRecord]
'delete-record': [id: number]
}>()
const isDarkMode = inject('isDarkMode')
</script>
<style scoped>
.list-enter-active,
.list-leave-active {
transition: all 0.5s ease;
}
.list-enter-from,
.list-leave-to {
opacity: 0;
transform: translateX(30px);
}
</style>
@@ -0,0 +1,50 @@
<template>
<div class="space-y-2">
<label class="block text-sm font-medium" :class="[isDarkMode ? 'text-gray-300' : 'text-gray-700']">
{{ label }}
</label>
<div class="flex items-center space-x-2">
<input
type="number"
:value="modelValue"
class="w-24 rounded-md shadow-sm px-4 py-2.5 transition-all duration-200 ease-in-out border focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 outline-none"
:class="[
isDarkMode
? 'bg-gray-700 border-gray-600 text-white placeholder-gray-400 hover:border-gray-500'
: 'border-gray-300 hover:border-gray-400 placeholder-gray-500'
]"
@input="handleInput"
/>
<span :class="[isDarkMode ? 'text-gray-300' : 'text-gray-700']">{{ suffix }}</span>
</div>
</div>
</template>
<script setup lang="ts">
import { inject } from 'vue'
defineProps<{
label: string
modelValue: number
suffix: string
}>()
const isDarkMode = inject('isDarkMode')
const emit = defineEmits<{
'update:modelValue': [value: number]
}>()
const handleInput = (event: Event) => {
const input = event.target as HTMLInputElement
if (!input.value) {
emit('update:modelValue', 0)
return
}
const nextValue = input.valueAsNumber
if (!Number.isNaN(nextValue)) {
emit('update:modelValue', nextValue)
}
}
</script>
+45
View File
@@ -0,0 +1,45 @@
<template>
<div class="space-y-2">
<label class="block text-sm font-medium mb-2" :class="[isDarkMode ? 'text-gray-300' : 'text-gray-700']">
{{ label }}
</label>
<div class="flex items-center">
<button
type="button"
class="relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2"
:class="[modelValue === 1 ? 'bg-indigo-600' : 'bg-gray-200']"
role="switch"
:aria-checked="modelValue === 1"
@click="$emit('toggle')"
>
<span
class="pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out"
:class="[
modelValue === 1 ? 'translate-x-5' : 'translate-x-0',
isDarkMode && modelValue !== 1 ? 'bg-gray-100' : 'bg-white'
]"
/>
</button>
<span class="ml-3 text-sm" :class="[isDarkMode ? 'text-gray-300' : 'text-gray-700']">
{{ modelValue === 1 ? enabledText : disabledText }}
</span>
</div>
</div>
</template>
<script setup lang="ts">
import { inject } from 'vue'
defineProps<{
label: string
modelValue: number
enabledText: string
disabledText: string
}>()
defineEmits<{
toggle: []
}>()
const isDarkMode = inject('isDarkMode')
</script>
+14 -13
View File
@@ -21,8 +21,9 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { inject, computed } from 'vue' import { computed } from 'vue'
import type { Component } from 'vue' import type { Component } from 'vue'
import { useInjectedDarkMode } from '@/composables'
interface Props { interface Props {
title: string title: string
@@ -36,33 +37,33 @@ const props = withDefaults(defineProps<Props>(), {
descriptionType: 'neutral' descriptionType: 'neutral'
}) })
const isDarkMode = inject('isDarkMode') const isDarkMode = useInjectedDarkMode()
const iconBgClass = computed(() => { const iconBgClass = computed(() => {
const colorMap = { const colorMap = {
indigo: isDarkMode ? 'bg-indigo-900' : 'bg-indigo-100', indigo: isDarkMode.value ? 'bg-indigo-900' : 'bg-indigo-100',
purple: isDarkMode ? 'bg-purple-900' : 'bg-purple-100', purple: isDarkMode.value ? 'bg-purple-900' : 'bg-purple-100',
green: isDarkMode ? 'bg-green-900' : 'bg-green-100', green: isDarkMode.value ? 'bg-green-900' : 'bg-green-100',
blue: isDarkMode ? 'bg-blue-900' : 'bg-blue-100' blue: isDarkMode.value ? 'bg-blue-900' : 'bg-blue-100'
} }
return colorMap[props.iconColor] return colorMap[props.iconColor]
}) })
const iconClass = computed(() => { const iconClass = computed(() => {
const colorMap = { const colorMap = {
indigo: isDarkMode ? 'text-indigo-400' : 'text-indigo-600', indigo: isDarkMode.value ? 'text-indigo-400' : 'text-indigo-600',
purple: isDarkMode ? 'text-purple-400' : 'text-purple-600', purple: isDarkMode.value ? 'text-purple-400' : 'text-purple-600',
green: isDarkMode ? 'text-green-400' : 'text-green-600', green: isDarkMode.value ? 'text-green-400' : 'text-green-600',
blue: isDarkMode ? 'text-blue-400' : 'text-blue-600' blue: isDarkMode.value ? 'text-blue-400' : 'text-blue-600'
} }
return colorMap[props.iconColor] return colorMap[props.iconColor]
}) })
const descriptionClass = computed(() => { const descriptionClass = computed(() => {
const typeMap = { const typeMap = {
success: isDarkMode ? 'text-green-400' : 'text-green-600', success: isDarkMode.value ? 'text-green-400' : 'text-green-600',
error: isDarkMode ? 'text-red-400' : 'text-red-600', error: isDarkMode.value ? 'text-red-400' : 'text-red-600',
neutral: isDarkMode ? 'text-gray-400' : 'text-gray-600' neutral: isDarkMode.value ? 'text-gray-400' : 'text-gray-600'
} }
return typeMap[props.descriptionType] return typeMap[props.descriptionType]
}) })
+12
View File
@@ -0,0 +1,12 @@
export { useAdminFiles } from './useAdminFiles'
export { useAdminLogin } from './useAdminLogin'
export { useAppShell } from './useAppShell'
export { useDashboardStats } from './useDashboardStats'
export { useInjectedDarkMode } from './useInjectedDarkMode'
export { usePresignedUpload } from './usePresignedUpload'
export { usePublicConfigBootstrap } from './usePublicConfigBootstrap'
export { useRetrieveFlow } from './useRetrieveFlow'
export { useRouteLoading } from './useRouteLoading'
export { useSendFlow } from './useSendFlow'
export { useSystemConfig } from './useSystemConfig'
export { useTheme } from './useTheme'
+163
View File
@@ -0,0 +1,163 @@
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { FileService } from '@/services'
import { useAlertStore } from '@/stores/alertStore'
import type { AdminFileViewItem, FileEditForm, FileListItem } from '@/types'
import { copyToClipboard } from '@/utils/clipboard'
import { formatFileSize, formatTimestamp, getErrorMessage } from '@/utils/common'
const TEXT_PREVIEW_THRESHOLD = 30
export function useAdminFiles() {
const { t } = useI18n()
const alertStore = useAlertStore()
const tableData = ref<AdminFileViewItem[]>([])
const hasLoadError = ref(false)
const params = ref({
page: 1,
size: 10,
total: 0,
keyword: ''
})
const showEditModal = ref(false)
const editForm = ref<FileEditForm>({
id: null,
code: '',
prefix: '',
suffix: '',
expired_at: '',
expired_count: null
})
const showTextPreview = ref(false)
const previewText = ref('')
const totalPages = computed(() => Math.ceil(params.value.total / params.value.size))
const createFileViewItem = (file: FileListItem): AdminFileViewItem => ({
...file,
displaySize: formatFileSize(file.size),
displayExpiredAt: file.expired_at
? formatTimestamp(file.expired_at)
: t('send.expiration.units.forever'),
canPreviewText: Boolean(file.text && file.text.length > TEXT_PREVIEW_THRESHOLD)
})
const resetEditForm = () => {
editForm.value = {
id: null,
code: '',
prefix: '',
suffix: '',
expired_at: '',
expired_count: null
}
}
const loadFiles = async () => {
try {
hasLoadError.value = false
const res = await FileService.getAdminFileList(params.value)
if (!res.detail) return
tableData.value = res.detail.data.map(createFileViewItem)
params.value.total = res.detail.total
} catch (error) {
hasLoadError.value = true
alertStore.showAlert(getErrorMessage(error, t('manage.fileManage.loadFileListFailed')), 'error')
}
}
const handleSearch = async () => {
params.value.page = 1
await loadFiles()
}
const handlePageChange = async (page: number | string) => {
if (typeof page === 'string') return
if (page < 1 || page > totalPages.value) return
params.value.page = page
await loadFiles()
}
const openEditModal = (file: FileListItem) => {
editForm.value = {
id: file.id,
code: file.code,
prefix: file.prefix,
suffix: file.suffix,
expired_at: file.expired_at ? file.expired_at.slice(0, 16) : '',
expired_count: file.expired_count
}
showEditModal.value = true
}
const closeEditModal = () => {
showEditModal.value = false
resetEditForm()
}
const handleUpdate = async () => {
try {
await FileService.updateFile(editForm.value)
await loadFiles()
closeEditModal()
} catch (error: unknown) {
alertStore.showAlert(getErrorMessage(error, t('manage.fileManage.updateFailed')), 'error')
}
}
const deleteFile = async (id: number) => {
if (!window.confirm(t('manage.fileManage.deleteConfirm'))) {
return
}
try {
await FileService.deleteAdminFile(id)
await loadFiles()
} catch (error: unknown) {
alertStore.showAlert(getErrorMessage(error, t('manage.fileManage.deleteFailed')), 'error')
}
}
const openTextPreview = (text: string) => {
previewText.value = text
showTextPreview.value = true
}
const closeTextPreview = () => {
showTextPreview.value = false
previewText.value = ''
}
const copyText = async () => {
await copyToClipboard(previewText.value, {
successMsg: t('fileManage.copySuccess'),
errorMsg: t('fileManage.copyFailed'),
notify: (message, type) => alertStore.showAlert(message, type)
})
}
return {
tableData,
hasLoadError,
params,
showEditModal,
editForm,
showTextPreview,
previewText,
totalPages,
closeEditModal,
closeTextPreview,
copyText,
deleteFile,
handlePageChange,
handleSearch,
handleUpdate,
loadFiles,
openEditModal,
openTextPreview
}
}
+53
View File
@@ -0,0 +1,53 @@
import { ref } from 'vue'
import { AuthService } from '@/services'
import { useAdminStore } from '@/stores/adminStore'
import { useAlertStore } from '@/stores/alertStore'
import { getErrorMessage } from '@/utils/common'
export function useAdminLogin() {
const alertStore = useAlertStore()
const adminStore = useAdminStore()
const password = ref('')
const isLoading = ref(false)
const validateForm = () => {
if (!password.value) {
alertStore.showAlert('无效的密码', 'error')
return false
}
if (password.value.length < 6) {
alertStore.showAlert('密码长度至少为6位', 'error')
return false
}
return true
}
const handleSubmit = async () => {
if (!validateForm()) return false
isLoading.value = true
try {
const response = await AuthService.login(password.value)
if (!response.detail?.token) {
alertStore.showAlert('登录失败:未获取到有效令牌', 'error')
return false
}
adminStore.setToken(response.detail.token)
return true
} catch (error: unknown) {
alertStore.showAlert(getErrorMessage(error, '登录失败'), 'error')
return false
} finally {
isLoading.value = false
}
}
return {
password,
isLoading,
handleSubmit
}
}
+52
View File
@@ -0,0 +1,52 @@
import { computed, onMounted, onUnmounted, provide } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { AUTH_EVENTS } from '@/services'
import { ROUTES } from '@/constants'
import { useTheme } from './useTheme'
import { usePublicConfigBootstrap } from './usePublicConfigBootstrap'
import { useRouteLoading } from './useRouteLoading'
export function useAppShell() {
const route = useRoute()
const router = useRouter()
const { isDarkMode, toggleTheme, initTheme } = useTheme()
const { isLoading, setupRouteLoading } = useRouteLoading(router)
const { syncPublicConfig } = usePublicConfigBootstrap()
const showGlobalControls = computed(() => route.meta.showGlobalControls !== false)
let cleanupThemeListener: (() => void) | null = null
const handleUnauthorized = () => {
if (router.currentRoute.value.path !== ROUTES.LOGIN) {
void router.push({
path: ROUTES.LOGIN,
query: {
redirect: router.currentRoute.value.fullPath
}
})
}
}
onMounted(() => {
cleanupThemeListener = initTheme()
setupRouteLoading()
window.addEventListener(AUTH_EVENTS.UNAUTHORIZED, handleUnauthorized)
void syncPublicConfig()
})
onUnmounted(() => {
cleanupThemeListener?.()
window.removeEventListener(AUTH_EVENTS.UNAUTHORIZED, handleUnauthorized)
})
provide('isDarkMode', isDarkMode)
provide('toggleTheme', toggleTheme)
provide('isLoading', isLoading)
return {
isDarkMode,
isLoading,
route,
showGlobalControls
}
}
+108
View File
@@ -0,0 +1,108 @@
import { reactive } from 'vue'
import { StatsService } from '@/services'
import type { DashboardViewData } from '@/types'
import { formatFileSize } from '@/utils/common'
const emptyDashboardData = (): DashboardViewData => ({
hasExtendedStats: false,
totalFiles: 0,
storageUsed: 0,
yesterdayCount: 0,
todayCount: 0,
yesterdaySize: 0,
todaySize: 0,
sysUptime: null,
activeCount: 0,
expiredCount: 0,
textCount: 0,
fileCount: 0,
chunkedCount: 0,
usedCount: 0,
storageBackend: '-',
uploadSizeLimit: 0,
openUpload: 0,
enableChunk: 0,
maxSaveSeconds: 0,
topSuffixes: [],
recentFiles: [],
storageUsedText: '0 Bytes',
yesterdaySizeText: '0 Bytes',
todaySizeText: '0 Bytes',
uploadSizeLimitText: '0 Bytes',
sysUptimeText: '-',
activeRatio: 0,
textRatio: 0,
fileRatio: 0,
todaySizeRatio: 0
})
const toNumber = (value: number | string | null | undefined) => Number(value || 0)
const clampRatio = (value: number) => Math.max(0, Math.min(100, Math.round(value)))
const hasOwn = (target: object, key: string) => Object.prototype.hasOwnProperty.call(target, key)
const formatDuration = (startTimestamp: number | null) => {
if (!startTimestamp) return '-'
const uptime = Date.now() - startTimestamp
const days = Math.floor(uptime / (24 * 60 * 60 * 1000))
const hours = Math.floor((uptime % (24 * 60 * 60 * 1000)) / (60 * 60 * 1000))
return `${days}${hours}小时`
}
export function useDashboardStats() {
const dashboardData = reactive<DashboardViewData>(emptyDashboardData())
const fetchDashboardData = async () => {
const response = await StatsService.getDashboard()
if (!response.detail) return
const detail = response.detail
dashboardData.totalFiles = toNumber(detail.totalFiles)
dashboardData.storageUsed = toNumber(detail.storageUsed)
dashboardData.yesterdayCount = toNumber(detail.yesterdayCount)
dashboardData.todayCount = toNumber(detail.todayCount)
dashboardData.yesterdaySize = toNumber(detail.yesterdaySize)
dashboardData.todaySize = toNumber(detail.todaySize)
dashboardData.sysUptime = detail.sysUptime
dashboardData.hasExtendedStats = hasOwn(detail, 'activeCount')
dashboardData.activeCount = dashboardData.hasExtendedStats
? toNumber(detail.activeCount)
: dashboardData.totalFiles
dashboardData.expiredCount = toNumber(detail.expiredCount)
dashboardData.textCount = toNumber(detail.textCount)
dashboardData.fileCount = toNumber(detail.fileCount)
dashboardData.chunkedCount = toNumber(detail.chunkedCount)
dashboardData.usedCount = toNumber(detail.usedCount)
dashboardData.storageBackend = detail.storageBackend || '-'
dashboardData.uploadSizeLimit = toNumber(detail.uploadSizeLimit)
dashboardData.openUpload = toNumber(detail.openUpload)
dashboardData.enableChunk = toNumber(detail.enableChunk)
dashboardData.maxSaveSeconds = toNumber(detail.maxSaveSeconds)
dashboardData.topSuffixes = detail.topSuffixes || []
dashboardData.recentFiles = detail.recentFiles || []
dashboardData.storageUsedText = formatFileSize(dashboardData.storageUsed)
dashboardData.yesterdaySizeText = formatFileSize(dashboardData.yesterdaySize)
dashboardData.todaySizeText = formatFileSize(dashboardData.todaySize)
dashboardData.uploadSizeLimitText = formatFileSize(dashboardData.uploadSizeLimit)
dashboardData.sysUptimeText = formatDuration(dashboardData.sysUptime)
dashboardData.activeRatio = dashboardData.totalFiles
? clampRatio((dashboardData.activeCount / dashboardData.totalFiles) * 100)
: 0
dashboardData.textRatio = dashboardData.totalFiles
? clampRatio((dashboardData.textCount / dashboardData.totalFiles) * 100)
: 0
dashboardData.fileRatio = dashboardData.totalFiles
? clampRatio((dashboardData.fileCount / dashboardData.totalFiles) * 100)
: 0
dashboardData.todaySizeRatio = dashboardData.uploadSizeLimit
? clampRatio((dashboardData.todaySize / dashboardData.uploadSizeLimit) * 100)
: 0
}
return {
dashboardData,
fetchDashboardData
}
}
-144
View File
@@ -1,144 +0,0 @@
import { ref, computed } from 'vue'
import { FileService } from '@/services'
import { useAlertStore } from '@/stores/alertStore'
import type { FileInfo } from '@/types'
import { saveAs } from 'file-saver'
export function useFileDownload() {
const alertStore = useAlertStore()
// 状态管理
const isLoading = ref(false)
const fileInfo = ref<FileInfo | null>(null)
const downloadCode = ref('')
// 计算属性
const hasFileInfo = computed(() => fileInfo.value !== null)
const canDownload = computed(() => hasFileInfo.value && !isLoading.value)
// 获取文件信息
const getFileInfo = async (code: string): Promise<FileInfo | null> => {
if (!code.trim()) {
alertStore.showAlert('请输入取件码', 'warning')
return null
}
try {
isLoading.value = true
downloadCode.value = code
const response = await FileService.getFile(code)
if (response.code === 200 && response.detail) {
fileInfo.value = response.detail
return response.detail
} else {
throw new Error(response.message || '文件不存在或已过期')
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : '获取文件信息失败'
alertStore.showAlert(errorMessage, 'error')
fileInfo.value = null
return null
} finally {
isLoading.value = false
}
}
// 下载文件
const downloadFile = async (code?: string): Promise<boolean> => {
const targetCode = code || downloadCode.value
if (!targetCode.trim()) {
alertStore.showAlert('请输入取件码', 'warning')
return false
}
try {
isLoading.value = true
// 如果没有文件信息,先获取
if (!fileInfo.value || downloadCode.value !== targetCode) {
const info = await getFileInfo(targetCode)
if (!info) {
return false
}
}
const blob = await FileService.downloadFile(targetCode)
// 使用 file-saver 保存文件
if (fileInfo.value?.name) {
saveAs(blob, fileInfo.value.name)
alertStore.showAlert('文件下载成功!', 'success')
return true
} else {
throw new Error('文件名获取失败')
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : '下载失败'
alertStore.showAlert(errorMessage, 'error')
return false
} finally {
isLoading.value = false
}
}
// 重置状态
const resetDownload = () => {
isLoading.value = false
fileInfo.value = null
downloadCode.value = ''
}
// 格式化文件大小
const formatFileSize = (bytes: number): string => {
if (bytes === 0) return '0 B'
const k = 1024
const sizes = ['B', 'KB', 'MB', 'GB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
}
// 格式化时间
const formatTime = (timeString: string): string => {
try {
const date = new Date(timeString)
return date.toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
})
} catch {
return timeString
}
}
// 检查文件是否过期
const isFileExpired = computed(() => {
if (!fileInfo.value?.expireTime) return false
return new Date(fileInfo.value.expireTime) < new Date()
})
return {
// 状态
isLoading,
fileInfo,
downloadCode,
// 计算属性
hasFileInfo,
canDownload,
isFileExpired,
// 方法
getFileInfo,
downloadFile,
resetDownload,
formatFileSize,
formatTime
}
}
-287
View File
@@ -1,287 +0,0 @@
import { ref, computed, readonly } from 'vue'
import { FileService } from '@/services'
import { useAlertStore } from '@/stores/alertStore'
import { usePresignedUpload } from '@/composables/usePresignedUpload'
import type { UploadProgress, UploadStatus, PresignUploadOptions, ExpireStyle, ConfigState } from '@/types'
import { UPLOAD_STATUS, FILE_SIZE_LIMITS, STORAGE_KEYS } from '@/constants'
/**
* 获取最大文件大小限制(字节)
* 优先从后端配置获取,否则使用默认值
*/
function getMaxFileSize(): number {
try {
const configStr = localStorage.getItem(STORAGE_KEYS.CONFIG)
if (configStr) {
const config = JSON.parse(configStr) as Partial<ConfigState>
if (config.uploadSize && config.uploadSize > 0) {
// uploadSize 单位是字节
return config.uploadSize
}
}
} catch {
// 解析失败时使用默认值
}
return FILE_SIZE_LIMITS.MAX_FILE_SIZE
}
export interface FileUploadOptions {
/** 是否使用预签名上传,默认 false 保持向后兼容 */
usePresigned?: boolean
/** 过期时间值 */
expireValue?: number
/** 过期时间类型 */
expireStyle?: ExpireStyle
/** 进度回调 */
onProgress?: (progress: UploadProgress) => void
}
export function useFileUpload(options?: { defaultUsePresigned?: boolean }) {
const alertStore = useAlertStore()
// 预签名上传 composable
const presignedUpload = usePresignedUpload()
// 是否默认使用预签名上传
const defaultUsePresigned = options?.defaultUsePresigned ?? false
// 状态管理
const uploadStatus = ref<UploadStatus>(UPLOAD_STATUS.IDLE)
const uploadProgress = ref<UploadProgress>({
loaded: 0,
total: 0,
percentage: 0
})
const uploadedCode = ref<string>('')
const currentFile = ref<File | null>(null)
// 当前是否使用预签名上传
const isUsingPresigned = ref<boolean>(false)
// 计算属性
const isUploading = computed(() => {
if (isUsingPresigned.value) {
return presignedUpload.isUploading.value || presignedUpload.isInitializing.value || presignedUpload.isConfirming.value
}
return uploadStatus.value === UPLOAD_STATUS.UPLOADING
})
const isSuccess = computed(() => {
if (isUsingPresigned.value) {
return presignedUpload.isSuccess.value
}
return uploadStatus.value === UPLOAD_STATUS.SUCCESS
})
const isError = computed(() => {
if (isUsingPresigned.value) {
return presignedUpload.isError.value
}
return uploadStatus.value === UPLOAD_STATUS.ERROR
})
const isIdle = computed(() => {
if (isUsingPresigned.value) {
return presignedUpload.presignStatus.value === 'idle'
}
return uploadStatus.value === UPLOAD_STATUS.IDLE
})
// 文件验证
const validateFile = (file: File): boolean => {
const maxFileSize = getMaxFileSize()
if (file.size > maxFileSize) {
alertStore.showAlert(
`文件大小不能超过 ${Math.round(maxFileSize / 1024 / 1024)}MB`,
'error'
)
return false
}
return true
}
/**
* 上传文件(支持预签名上传和传统上传)
*/
const uploadFile = async (file: File, uploadOptions?: FileUploadOptions): Promise<string | null> => {
const shouldUsePresigned = uploadOptions?.usePresigned ?? defaultUsePresigned
if (!validateFile(file)) {
return null
}
// 记录当前使用的上传方式
isUsingPresigned.value = shouldUsePresigned
currentFile.value = file
if (shouldUsePresigned) {
// 使用预签名上传
return await uploadFileWithPresigned(file, uploadOptions)
} else {
// 使用传统上传方式
return await uploadFileTraditional(file, uploadOptions?.onProgress)
}
}
/**
* 传统上传方式
*/
const uploadFileTraditional = async (
file: File,
onProgress?: (progress: UploadProgress) => void
): Promise<string | null> => {
try {
uploadStatus.value = UPLOAD_STATUS.UPLOADING
uploadedCode.value = ''
const response = await FileService.uploadFile(file, (progress) => {
uploadProgress.value = progress
onProgress?.(progress)
})
if (response.code === 200 && response.detail?.code) {
uploadStatus.value = UPLOAD_STATUS.SUCCESS
uploadedCode.value = String(response.detail.code)
alertStore.showAlert('文件上传成功!', 'success')
return String(response.detail.code)
} else {
throw new Error(response.message || '上传失败')
}
} catch (error) {
uploadStatus.value = UPLOAD_STATUS.ERROR
const errorMessage = error instanceof Error ? error.message : '上传失败'
alertStore.showAlert(errorMessage, 'error')
return null
}
}
/**
* 预签名上传方式
*/
const uploadFileWithPresigned = async (
file: File,
uploadOptions?: FileUploadOptions
): Promise<string | null> => {
// 构建预签名上传选项
const presignOptions: PresignUploadOptions = {
expireValue: uploadOptions?.expireValue,
expireStyle: uploadOptions?.expireStyle,
onProgress: (progress) => {
// 同步进度到本 composable 的状态
uploadProgress.value = progress
uploadOptions?.onProgress?.(progress)
}
}
const result = await presignedUpload.uploadFile(file, presignOptions)
if (result) {
// 同步预签名上传的结果到本 composable 的状态
uploadedCode.value = result
uploadStatus.value = UPLOAD_STATUS.SUCCESS
} else {
uploadStatus.value = UPLOAD_STATUS.ERROR
}
return result
}
// 上传文本
const uploadText = async (text: string): Promise<string | null> => {
if (!text.trim()) {
alertStore.showAlert('请输入要发送的文本内容', 'warning')
return null
}
try {
uploadStatus.value = UPLOAD_STATUS.UPLOADING
uploadedCode.value = ''
const response = await FileService.uploadText(text)
if (response.code === 200 && response.detail?.code) {
uploadStatus.value = UPLOAD_STATUS.SUCCESS
uploadedCode.value = String(response.detail.code)
alertStore.showAlert('文本发送成功!', 'success')
return String(response.detail.code)
} else {
throw new Error(response.message || '发送失败')
}
} catch (error) {
uploadStatus.value = UPLOAD_STATUS.ERROR
const errorMessage = error instanceof Error ? error.message : '发送失败'
alertStore.showAlert(errorMessage, 'error')
return null
}
}
// 重置状态
const resetUpload = () => {
uploadStatus.value = UPLOAD_STATUS.IDLE
uploadProgress.value = {
loaded: 0,
total: 0,
percentage: 0
}
uploadedCode.value = ''
currentFile.value = null
// 如果使用预签名上传,也重置预签名状态
if (isUsingPresigned.value) {
presignedUpload.reset()
}
isUsingPresigned.value = false
}
/**
* 取消上传
*/
const cancelUpload = async (): Promise<void> => {
if (isUsingPresigned.value) {
await presignedUpload.cancelUpload()
}
resetUpload()
}
// 格式化文件大小
const formatFileSize = (bytes: number): string => {
if (bytes === 0) return '0 B'
const k = 1024
const sizes = ['B', 'KB', 'MB', 'GB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
}
return {
// 状态
uploadStatus: readonly(uploadStatus),
uploadProgress: readonly(uploadProgress),
uploadedCode: readonly(uploadedCode),
currentFile: readonly(currentFile),
isUsingPresigned: readonly(isUsingPresigned),
// 预签名上传相关状态(透传)
presignStatus: presignedUpload.presignStatus,
uploadSession: presignedUpload.uploadSession,
currentMode: presignedUpload.currentMode,
presignErrorMessage: presignedUpload.errorMessage,
// 计算属性
isUploading,
isSuccess,
isError,
isIdle,
// 预签名上传计算属性(透传)
isInitializing: presignedUpload.isInitializing,
isConfirming: presignedUpload.isConfirming,
// 方法
uploadFile,
uploadText,
resetUpload,
cancelUpload,
validateFile,
formatFileSize,
// 预签名上传方法(透传)
getPresignStatus: presignedUpload.getStatus
}
}
+7
View File
@@ -0,0 +1,7 @@
import { computed, inject, unref } from 'vue'
import type { Ref } from 'vue'
export function useInjectedDarkMode() {
const injectedDarkMode = inject<Ref<boolean> | boolean>('isDarkMode', false)
return computed(() => Boolean(unref(injectedDarkMode)))
}
+39 -60
View File
@@ -1,7 +1,5 @@
import { ref, computed, readonly } from 'vue' import { ref, computed, readonly } from 'vue'
import { PresignUploadService } from '@/services' import { PresignUploadService } from '@/services'
import { useAlertStore } from '@/stores/alertStore'
import { FILE_SIZE_LIMITS, STORAGE_KEYS } from '@/constants'
import type { import type {
PresignUploadStatus, PresignUploadStatus,
PresignUploadMode, PresignUploadMode,
@@ -10,29 +8,9 @@ import type {
PresignStatusResponse, PresignStatusResponse,
UploadProgress, UploadProgress,
ExpireStyle, ExpireStyle,
ConfigState AlertType
} from '@/types' } from '@/types'
import axios from 'axios' import { getErrorMessage } from '@/utils/common'
/**
* 获取最大文件大小限制(字节)
* 优先从后端配置获取,否则使用默认值
*/
function getMaxFileSize(): number {
try {
const configStr = localStorage.getItem(STORAGE_KEYS.CONFIG)
if (configStr) {
const config = JSON.parse(configStr) as Partial<ConfigState>
if (config.uploadSize && config.uploadSize > 0) {
// uploadSize 单位是字节
return config.uploadSize
}
}
} catch {
// 解析失败时使用默认值
}
return FILE_SIZE_LIMITS.MAX_FILE_SIZE
}
// 预签名上传状态常量 // 预签名上传状态常量
export const PRESIGN_UPLOAD_STATUS = { export const PRESIGN_UPLOAD_STATUS = {
@@ -48,13 +26,24 @@ export const PRESIGN_UPLOAD_STATUS = {
const DEFAULT_EXPIRE_VALUE = 1 const DEFAULT_EXPIRE_VALUE = 1
const DEFAULT_EXPIRE_STYLE: ExpireStyle = 'day' const DEFAULT_EXPIRE_STYLE: ExpireStyle = 'day'
type ErrorWithResponse = {
response?: {
status?: number
}
}
type PresignedUploadNotifier = (message: string, type: AlertType) => void
type UsePresignedUploadOptions = {
getMaxFileSize?: () => number
notify?: PresignedUploadNotifier
}
/** /**
* 预签名上传 Composable * 预签名上传 Composable
* 支持 S3 直传模式和服务器代理模式 * 支持 S3 直传模式和服务器代理模式
*/ */
export function usePresignedUpload() { export function usePresignedUpload(options: UsePresignedUploadOptions = {}) {
const alertStore = useAlertStore()
// 状态管理 // 状态管理
const presignStatus = ref<PresignUploadStatus>(PRESIGN_UPLOAD_STATUS.IDLE) const presignStatus = ref<PresignUploadStatus>(PRESIGN_UPLOAD_STATUS.IDLE)
const uploadSession = ref<PresignInitResponse | null>(null) const uploadSession = ref<PresignInitResponse | null>(null)
@@ -74,15 +63,23 @@ export function usePresignedUpload() {
const isError = computed(() => presignStatus.value === PRESIGN_UPLOAD_STATUS.ERROR) const isError = computed(() => presignStatus.value === PRESIGN_UPLOAD_STATUS.ERROR)
const currentMode = computed<PresignUploadMode | null>(() => uploadSession.value?.mode ?? null) const currentMode = computed<PresignUploadMode | null>(() => uploadSession.value?.mode ?? null)
const notify: PresignedUploadNotifier = (message, type) => {
options.notify?.(message, type)
}
/** /**
* 文件大小验证 * 文件大小验证
*/ */
const validateFileSize = (file: File): boolean => { const validateFileSize = (file: File): boolean => {
const maxFileSize = getMaxFileSize() const maxFileSize = options.getMaxFileSize?.()
if (!maxFileSize) {
return true
}
if (file.size > maxFileSize) { if (file.size > maxFileSize) {
const maxSizeMB = Math.round(maxFileSize / 1024 / 1024) const maxSizeMB = Math.round(maxFileSize / 1024 / 1024)
errorMessage.value = `文件大小不能超过 ${maxSizeMB}MB` errorMessage.value = `文件大小不能超过 ${maxSizeMB}MB`
alertStore.showAlert(errorMessage.value, 'error') notify(errorMessage.value, 'error')
return false return false
} }
return true return true
@@ -113,34 +110,16 @@ export function usePresignedUpload() {
*/ */
const handleUploadError = (error: unknown): void => { const handleUploadError = (error: unknown): void => {
presignStatus.value = PRESIGN_UPLOAD_STATUS.ERROR presignStatus.value = PRESIGN_UPLOAD_STATUS.ERROR
const status = (error as ErrorWithResponse)?.response?.status
const fallback =
status === 404
? '上传会话不存在或已过期'
: status === 500
? '服务器错误,请稍后重试'
: '上传失败,请重试'
if (axios.isAxiosError(error)) { errorMessage.value = getErrorMessage(error, fallback)
const status = error.response?.status notify(errorMessage.value, 'error')
const detail = error.response?.data?.detail
switch (status) {
case 400:
errorMessage.value = detail || '请求参数错误'
break
case 403:
errorMessage.value = detail || '操作被禁止'
break
case 404:
errorMessage.value = '上传会话不存在或已过期'
break
case 500:
errorMessage.value = '服务器错误,请稍后重试'
break
default:
errorMessage.value = detail || '上传失败,请重试'
}
} else if (error instanceof Error) {
errorMessage.value = error.message
} else {
errorMessage.value = '未知错误'
}
alertStore.showAlert(errorMessage.value, 'error')
} }
/** /**
@@ -211,7 +190,7 @@ export function usePresignedUpload() {
total: file.size, total: file.size,
percentage: 100 percentage: 100
} }
alertStore.showAlert('文件上传成功!', 'success') notify('文件上传成功!', 'success')
return uploadedCode.value return uploadedCode.value
} else { } else {
throw new Error(confirmResponse.message || '确认上传失败') throw new Error(confirmResponse.message || '确认上传失败')
@@ -249,7 +228,7 @@ export function usePresignedUpload() {
total: file.size, total: file.size,
percentage: 100 percentage: 100
} }
alertStore.showAlert('文件上传成功!', 'success') notify('文件上传成功!', 'success')
return uploadedCode.value return uploadedCode.value
} else { } else {
throw new Error(response.message || '代理上传失败') throw new Error(response.message || '代理上传失败')
@@ -290,7 +269,7 @@ export function usePresignedUpload() {
try { try {
await PresignUploadService.cancelUpload(uploadSession.value.upload_id) await PresignUploadService.cancelUpload(uploadSession.value.upload_id)
alertStore.showAlert('上传已取消', 'info') notify('上传已取消', 'info')
} catch (error) { } catch (error) {
// 取消失败时静默处理,因为会话可能已过期 // 取消失败时静默处理,因为会话可能已过期
console.warn('取消上传失败:', error) console.warn('取消上传失败:', error)
@@ -313,7 +292,7 @@ export function usePresignedUpload() {
// 检查会话是否过期 // 检查会话是否过期
if (response.detail.is_expired) { if (response.detail.is_expired) {
errorMessage.value = '上传会话已过期' errorMessage.value = '上传会话已过期'
alertStore.showAlert(errorMessage.value, 'warning') notify(errorMessage.value, 'warning')
} }
return response.detail return response.detail
} }
@@ -0,0 +1,25 @@
import { ConfigService } from '@/services'
import { useAlertStore } from '@/stores/alertStore'
import { useConfigStore } from '@/stores/configStore'
export function usePublicConfigBootstrap() {
const alertStore = useAlertStore()
const configStore = useConfigStore()
const syncPublicConfig = async () => {
const res = await ConfigService.getUserConfig()
if (res.code !== 200 || !res.detail) {
return
}
const notifyMessage = configStore.applyRemoteConfig(res.detail)
if (notifyMessage) {
alertStore.showAlert(notifyMessage, 'success')
}
}
return {
syncPublicConfig
}
}
+173
View File
@@ -0,0 +1,173 @@
import { ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { storeToRefs } from 'pinia'
import { FileService } from '@/services'
import { useAlertStore } from '@/stores/alertStore'
import { useFileDataStore } from '@/stores/fileData'
import type { ReceivedFileRecord } from '@/types'
import { copyToClipboard } from '@/utils/clipboard'
import { getErrorMessage } from '@/utils/common'
import { renderMarkdownPreview } from '@/utils/content-preview'
import { downloadReceivedRecord } from '@/utils/download-action'
type InputStatus = {
readonly: boolean
loading: boolean
}
export function useRetrieveFlow() {
const { t } = useI18n()
const alertStore = useAlertStore()
const fileStore = useFileDataStore()
const { receiveData: records } = storeToRefs(fileStore)
const code = ref('')
const inputStatus = ref<InputStatus>({
readonly: false,
loading: false
})
const error = ref('')
const selectedRecord = ref<ReceivedFileRecord | null>(null)
const showDrawer = ref(false)
const showPreview = ref(false)
const renderedContent = ref('')
const formatFileSize = (bytes: number) => {
if (bytes === 0) return '0 ' + t('fileSize.bytes')
const k = 1024
const sizes = [
t('fileSize.bytes'),
t('fileSize.kb'),
t('fileSize.mb'),
t('fileSize.gb'),
t('fileSize.tb')
]
const i = Math.floor(Math.log(bytes) / Math.log(k))
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
}
const createRecord = (detail: {
code: string
name: string
text: string
size: number
}): ReceivedFileRecord => {
const isFile = detail.text.startsWith('/share/download') || detail.name !== 'Text'
return {
id: Date.now(),
code: detail.code,
filename: detail.name,
size: formatFileSize(detail.size),
downloadUrl: isFile ? detail.text : null,
content: isFile ? null : detail.text,
date: new Date().toLocaleString()
}
}
const handleSubmit = async () => {
if (code.value.length !== 5) {
alertStore.showAlert(t('retrieve.messages.invalidCode'), 'error')
return
}
inputStatus.value.readonly = true
inputStatus.value.loading = true
try {
const res = await FileService.selectFile(code.value)
if (res.code === 200 && res.detail) {
const newFileData = createRecord(res.detail)
if (!fileStore.receiveData.some((file) => file.code === newFileData.code)) {
fileStore.addReceiveData(newFileData)
}
selectedRecord.value = newFileData
if (newFileData.content) {
showPreview.value = true
}
alertStore.showAlert(t('retrieve.messages.retrieveSuccess'), 'success')
} else {
alertStore.showAlert(t('retrieve.messages.retrieveFailure') + res.detail, 'error')
}
} catch (err: unknown) {
const errorMessage = getErrorMessage(err, t('retrieve.messages.unknownError'))
alertStore.showAlert(t('retrieve.messages.networkError') + errorMessage, 'error')
} finally {
inputStatus.value.readonly = false
inputStatus.value.loading = false
code.value = ''
}
}
const copyContent = async () => {
if (selectedRecord.value?.content) {
await copyToClipboard(selectedRecord.value.content, {
successMsg: t('fileRecord.contentCopied'),
errorMsg: t('fileRecord.copyFailed'),
notify: (message, type) => alertStore.showAlert(message, type)
})
}
}
const viewDetails = (record: ReceivedFileRecord) => {
selectedRecord.value = record
}
const closeDetails = () => {
selectedRecord.value = null
}
const deleteRecord = (id: number) => {
const index = records.value.findIndex((record) => record.id === id)
if (index !== -1) {
fileStore.deleteReceiveData(index)
}
}
const toggleDrawer = () => {
showDrawer.value = !showDrawer.value
}
const downloadRecord = (record: ReceivedFileRecord) => {
downloadReceivedRecord(record)
}
const showContentPreview = () => {
showPreview.value = true
}
const closeContentPreview = () => {
showPreview.value = false
}
watch(
() => selectedRecord.value?.content,
async (content) => {
if (content) {
renderedContent.value = await renderMarkdownPreview(content)
} else {
renderedContent.value = ''
}
},
{ immediate: true }
)
return {
code,
inputStatus,
error,
records,
selectedRecord,
showDrawer,
showPreview,
renderedContent,
closeContentPreview,
closeDetails,
copyContent,
deleteRecord,
downloadRecord,
handleSubmit,
showContentPreview,
toggleDrawer,
viewDetails
}
}
+44
View File
@@ -0,0 +1,44 @@
import { onUnmounted, ref } from 'vue'
import type { Router } from 'vue-router'
const ROUTE_LOADING_DELAY = 200
export function useRouteLoading(router: Router) {
const isLoading = ref(false)
let loadingTimer: number | null = null
let cleanupBeforeGuard: (() => void) | null = null
let cleanupAfterHook: (() => void) | null = null
const clearLoadingTimer = () => {
if (loadingTimer !== null) {
window.clearTimeout(loadingTimer)
loadingTimer = null
}
}
const setupRouteLoading = () => {
cleanupBeforeGuard = router.beforeEach((to) => {
clearLoadingTimer()
isLoading.value = to.meta.showRouteLoading !== false
})
cleanupAfterHook = router.afterEach(() => {
clearLoadingTimer()
loadingTimer = window.setTimeout(() => {
isLoading.value = false
loadingTimer = null
}, ROUTE_LOADING_DELAY)
})
}
onUnmounted(() => {
clearLoadingTimer()
cleanupBeforeGuard?.()
cleanupAfterHook?.()
})
return {
isLoading,
setupRouteLoading
}
}
+337
View File
@@ -0,0 +1,337 @@
import { computed, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useAlertStore } from '@/stores/alertStore'
import { useAdminStore } from '@/stores/adminStore'
import { useConfigStore } from '@/stores/configStore'
import { useFileDataStore } from '@/stores/fileData'
import type { SendType, SentFileRecord } from '@/types'
import { getClipboardFile, insertTextAtSelection } from '@/utils/clipboard-paste'
import { getErrorMessage } from '@/utils/common'
import { getStorageUnit } from '@/utils/convert'
import { calculateFileHash } from '@/utils/file-processing'
import { buildSentRecord, isExpirationWithinLimit } from '@/utils/send-record'
import { createSentRecordActions } from '@/utils/sent-record-actions'
import { useSendSubmit } from './useSendSubmit'
export function useSendFlow() {
const { t } = useI18n()
const alertStore = useAlertStore()
const adminStore = useAdminStore()
const configStore = useConfigStore()
const fileDataStore = useFileDataStore()
const config = computed(() => configStore.config)
const sendType = ref<SendType>('file')
const selectedFile = ref<File | null>(null)
const selectedFiles = ref<File[]>([])
const textContent = ref('')
const expirationMethod = ref(config.value.expireStyle[0] || 'day')
const expirationValue = ref('1')
const uploadProgress = ref(0)
const showDrawer = ref(false)
const selectedRecord = ref<SentFileRecord | null>(null)
const isSubmitting = ref(false)
const fileHash = ref('')
const sendRecords = computed(() => fileDataStore.shareData)
const uploadDescription = computed(
() => `支持各种常见格式,最大${getStorageUnit(config.value.uploadSize)}`
)
const expirationOptions = computed(() =>
config.value.expireStyle.map((value) => ({
value,
label: getUnit(value)
}))
)
watch(
() => config.value.expireStyle,
(expireStyle) => {
if (expireStyle.length > 0 && !expireStyle.includes(expirationMethod.value)) {
expirationMethod.value = expireStyle[0]
}
},
{ immediate: true }
)
const notifyCopyResult = (message: string, type: 'success' | 'error') => {
alertStore.showAlert(message, type)
}
const sentRecordActions = createSentRecordActions(notifyCopyResult)
const { resetPresignUpload, submitFile, submitText } = useSendSubmit({
getMaxFileSize: () => configStore.uploadSizeLimit,
notify: (message, type) => alertStore.showAlert(message, type),
translate: t,
onProgress: (progress) => {
uploadProgress.value = progress
},
onHashCalculated: (hash) => {
fileHash.value = hash
}
})
const checkOpenUpload = () => {
if (config.value.openUpload === 0 && !adminStore.hasToken) {
alertStore.showAlert(t('send.messages.guestUploadDisabled'), 'error')
return false
}
return true
}
const checkFileSize = (file: File) => {
if (file.size > config.value.uploadSize) {
alertStore.showAlert(
t('send.messages.fileSizeExceeded', { size: getStorageUnit(config.value.uploadSize) }),
'error'
)
selectedFile.value = null
return false
}
return true
}
const checkExpirationTime = (method: string, value: string): boolean =>
isExpirationWithinLimit(method, value, config.value.max_save_seconds || 0)
const checkUpload = () => {
if (!selectedFile.value) return false
if (!checkOpenUpload()) return false
if (!checkFileSize(selectedFile.value)) return false
if (!checkExpirationTime(expirationMethod.value, expirationValue.value)) return false
return true
}
const handleFileSelected = async (file: File) => {
selectedFile.value = file
selectedFiles.value = []
if (!checkOpenUpload()) return
if (!checkFileSize(file)) return
fileHash.value = await calculateFileHash(file)
}
const handleFilesSelected = async (files: File[]) => {
if (!checkOpenUpload()) return
selectedFiles.value = files
selectedFile.value = null
fileHash.value = ''
}
const handleFileDrop = async (event: DragEvent) => {
if (!event.dataTransfer?.files || event.dataTransfer.files.length === 0) return
const files = Array.from(event.dataTransfer.files)
if (files.length === 1) {
const file = files[0]
selectedFile.value = file
selectedFiles.value = []
if (!checkUpload()) return
fileHash.value = await calculateFileHash(file)
} else {
if (!checkOpenUpload()) return
selectedFiles.value = files
selectedFile.value = null
fileHash.value = ''
}
}
const handlePaste = async (event: ClipboardEvent) => {
const items = event.clipboardData?.items
if (!items) return
const file = getClipboardFile(items)
if (file) {
if (file.size === 0) {
alertStore.showAlert(t('send.messages.emptyFileError'), 'error')
return
}
selectedFile.value = file
if (!checkUpload()) return
try {
fileHash.value = await calculateFileHash(file)
alertStore.showAlert(
t('send.messages.fileAddedFromClipboard', { filename: file.name }),
'success'
)
} catch (err) {
alertStore.showAlert(t('send.messages.fileProcessingFailed'), 'error')
console.error('File hash calculation failed:', err)
}
return
}
const textItem = items[0]
if (!textItem) return
sendType.value = 'text'
textItem.getAsString((str: string) => {
const trimmedStr = str.trim()
if (!trimmedStr) return
const textareaElement = document.getElementById('text-content') as HTMLTextAreaElement
if (!textareaElement) {
textContent.value += trimmedStr
return
}
const insertion = insertTextAtSelection({
text: textContent.value,
insertText: trimmedStr,
selectionStart: textareaElement.selectionStart,
selectionEnd: textareaElement.selectionEnd
})
textContent.value = insertion.value
setTimeout(() => {
textareaElement.setSelectionRange(insertion.cursor, insertion.cursor)
textareaElement.focus()
}, 0)
})
}
const getUnit = (value: string = expirationMethod.value) => {
switch (value) {
case 'day':
return t('send.expiration.units.days')
case 'hour':
return t('send.expiration.units.hours')
case 'minute':
return t('send.expiration.units.minutes')
case 'count':
return t('send.expiration.units.times')
case 'forever':
return t('send.expiration.units.forever')
default:
return ''
}
}
const handleSubmit = async () => {
if (isSubmitting.value) return
isSubmitting.value = true
try {
if (sendType.value === 'file' && !selectedFile.value && selectedFiles.value.length === 0) {
alertStore.showAlert(t('send.messages.selectFile'), 'error')
return
}
if (sendType.value === 'text' && !textContent.value.trim()) {
alertStore.showAlert(t('send.messages.enterText'), 'error')
return
}
if (!checkOpenUpload()) {
return
}
if (expirationMethod.value !== 'forever' && !expirationValue.value) {
alertStore.showAlert(t('send.messages.enterExpirationValue'), 'error')
return
}
if (!checkExpirationTime(expirationMethod.value, expirationValue.value)) {
const maxDays = Math.floor(config.value.max_save_seconds / 86400)
alertStore.showAlert(t('send.messages.expirationTooLong', { days: maxDays }), 'error')
return
}
const expireValue = expirationValue.value ? parseInt(expirationValue.value) : 1
let response
if (sendType.value === 'file') {
response = await submitFile({
selectedFile: selectedFile.value,
selectedFiles: selectedFiles.value,
expireValue,
expireStyle: expirationMethod.value,
enableChunk: Boolean(config.value.enableChunk),
validateFileSize: checkFileSize
})
} else {
response = await submitText({
text: textContent.value,
expireValue,
expireStyle: expirationMethod.value
})
}
if (!response) return
if (response?.code === 200) {
const newRecord = buildSentRecord({
response,
sendType: sendType.value,
textContent: textContent.value,
selectedFile: selectedFile.value,
selectedFiles: selectedFiles.value,
expirationMethod: expirationMethod.value,
expirationValue: expirationValue.value,
translate: t,
getUnit
})
fileDataStore.addShareDataRecord(newRecord)
alertStore.showAlert(
t('send.messages.sendSuccess', { code: newRecord.retrieveCode }),
'success'
)
selectedFile.value = null
selectedFiles.value = []
textContent.value = ''
uploadProgress.value = 0
resetPresignUpload()
selectedRecord.value = newRecord
await sentRecordActions.copyLink(newRecord)
} else {
throw new Error(t('send.messages.serverError'))
}
} catch (error: unknown) {
alertStore.showAlert(getErrorMessage(error, t('send.messages.sendFailed')), 'error')
} finally {
uploadProgress.value = 0
isSubmitting.value = false
}
}
const toggleDrawer = () => {
showDrawer.value = !showDrawer.value
}
const viewDetails = (record: SentFileRecord) => {
selectedRecord.value = record
}
const closeDetails = () => {
selectedRecord.value = null
}
const deleteRecord = (id: number) => {
const index = fileDataStore.shareData.findIndex((record) => record.id === id)
if (index !== -1) {
fileDataStore.deleteShareData(index)
}
}
return {
config,
sendType,
selectedFile,
selectedFiles,
textContent,
expirationMethod,
expirationValue,
uploadProgress,
showDrawer,
selectedRecord,
isSubmitting,
sendRecords,
uploadDescription,
expirationOptions,
closeDetails,
deleteRecord,
copySentRecordCode: sentRecordActions.copyCode,
copySentRecordLink: sentRecordActions.copyLink,
copySentRecordWgetCommand: sentRecordActions.copyWgetCommand,
getQRCodeValue: sentRecordActions.getQRCodeValue,
getUnit,
handleFileDrop,
handleFileSelected,
handleFilesSelected,
handlePaste,
handleSubmit,
toggleDrawer,
viewDetails
}
}
+122
View File
@@ -0,0 +1,122 @@
import { FileService, uploadChunkedFile } from '@/services'
import type { AlertType, ApiResponse, ExpireStyle, UploadProgress } from '@/types'
import { calculateFileHash, packFilesAsZip } from '@/utils/file-processing'
import { usePresignedUpload } from './usePresignedUpload'
type Translate = (
key: string,
params?: Record<string, string | number | undefined>
) => string
type UseSendSubmitOptions = {
getMaxFileSize: () => number
notify: (message: string, type: AlertType) => void
translate: Translate
onProgress: (progress: number) => void
onHashCalculated: (hash: string) => void
}
type SubmitFileOptions = {
selectedFile: File | null
selectedFiles: File[]
expireValue: number
expireStyle: string
enableChunk: boolean
validateFileSize: (file: File) => boolean
}
type SubmitTextOptions = {
text: string
expireValue: number
expireStyle: string
}
export function useSendSubmit(options: UseSendSubmitOptions) {
const { uploadFile: presignUploadFile, reset: resetPresignUpload } = usePresignedUpload({
getMaxFileSize: options.getMaxFileSize,
notify: options.notify
})
const handleChunkUpload = async (
file: File,
expireValue: number,
expireStyle: string
): Promise<ApiResponse> => {
return uploadChunkedFile(file, {
expireValue,
expireStyle,
onHashCalculated: options.onHashCalculated,
onProgress: (progress: UploadProgress) => {
options.onProgress(progress.percentage)
},
messages: {
initFailed: options.translate('send.messages.initChunkUploadFailed'),
chunkFailed: (index) => options.translate('send.messages.chunkUploadFailed', { index }),
completeFailed: options.translate('send.messages.completeUploadFailed')
}
})
}
const handlePresignedUpload = async (
file: File,
expireValue: number,
expireStyle: string
): Promise<ApiResponse<{ code?: string; name?: string }>> => {
const code = await presignUploadFile(file, {
expireValue,
expireStyle: expireStyle as ExpireStyle,
onProgress: (progress) => {
options.onProgress(progress.percentage)
}
})
if (!code) {
throw new Error(options.translate('send.messages.uploadFailed'))
}
return {
code: 200,
detail: {
code,
name: file.name
}
}
}
const submitFile = async ({
selectedFile,
selectedFiles,
expireValue,
expireStyle,
enableChunk,
validateFileSize
}: SubmitFileOptions): Promise<ApiResponse | null> => {
let fileToUpload = selectedFile
if (selectedFiles.length > 0) {
options.notify('正在打包文件...', 'success')
fileToUpload = await packFilesAsZip(selectedFiles)
if (!validateFileSize(fileToUpload)) {
return null
}
options.onHashCalculated(await calculateFileHash(fileToUpload))
}
if (!fileToUpload) {
throw new Error(options.translate('send.messages.selectFile'))
}
return enableChunk
? handleChunkUpload(fileToUpload, expireValue, expireStyle)
: handlePresignedUpload(fileToUpload, expireValue, expireStyle)
}
const submitText = ({ text, expireValue, expireStyle }: SubmitTextOptions) =>
FileService.uploadText(text, expireValue, expireStyle)
return {
resetPresignUpload,
submitFile,
submitText
}
}
+70 -42
View File
@@ -1,61 +1,54 @@
import { ref, computed } from 'vue' import { ref, computed } from 'vue'
import { ConfigService } from '@/services' import { ConfigService } from '@/services'
import { useAlertStore } from '@/stores/alertStore' import { useAlertStore } from '@/stores/alertStore'
import type { SystemConfig } from '@/types' import { useConfigStore } from '@/stores/configStore'
import { STORAGE_KEYS, DEFAULT_CONFIG } from '@/constants' import type { ConfigState } from '@/types'
import { DEFAULT_CONFIG_STATE, readStoredConfig } from '@/utils/config-storage'
import { getErrorMessage } from '@/utils/common'
import {
buildConfigSubmitPayload,
bytesToFileSizeForm,
secondsToSaveTimeForm,
type FileSizeUnit,
type SaveTimeUnit
} from '@/utils/config-form'
type ConfigFlagKey = 'enableChunk' | 's3_proxy' | 'openUpload'
export function useSystemConfig() { export function useSystemConfig() {
const alertStore = useAlertStore() const alertStore = useAlertStore()
const configStore = useConfigStore()
// 状态管理 // 状态管理
const config = ref<SystemConfig>({ ...DEFAULT_CONFIG }) const config = ref<ConfigState>({ ...DEFAULT_CONFIG_STATE })
const isLoading = ref(false) const isLoading = ref(false)
const fileSize = ref(1)
const sizeUnit = ref<FileSizeUnit>('MB')
const saveTime = ref(1)
const saveTimeUnit = ref<SaveTimeUnit>('天')
// 从本地存储获取配置 // 从本地存储获取配置
const getStoredConfig = (): SystemConfig | null => { const getStoredConfig = (): ConfigState | null => {
try { return readStoredConfig<ConfigState>()
const storedConfig = localStorage.getItem(STORAGE_KEYS.CONFIG)
if (storedConfig) {
return JSON.parse(storedConfig)
}
} catch (error) {
console.error('解析本地配置失败:', error)
}
return null
} }
// 保存配置到本地存储 // 保存配置到本地存储
const saveConfigToStorage = (configData: SystemConfig) => { const saveConfigToStorage = (configData: ConfigState) => {
try { configStore.updateConfig(configData)
localStorage.setItem(STORAGE_KEYS.CONFIG, JSON.stringify(configData))
} catch (error) {
console.error('保存配置到本地存储失败:', error)
}
} }
// 获取系统配置 // 获取系统配置
const fetchConfig = async (): Promise<SystemConfig | null> => { const fetchConfig = async (): Promise<ConfigState | null> => {
try { try {
isLoading.value = true isLoading.value = true
const response = await ConfigService.getConfig() const response = await ConfigService.getConfig()
if (response.code === 200 && response.detail) { if (response.code === 200 && response.detail) {
config.value = { ...DEFAULT_CONFIG, ...response.detail } config.value = { ...DEFAULT_CONFIG_STATE, ...response.detail }
saveConfigToStorage(config.value) const notifyMessage = configStore.applyRemoteConfig(config.value)
if (notifyMessage) {
// 处理通知 alertStore.showAlert(notifyMessage, 'success')
if (response.detail.notify_title && response.detail.notify_content) {
const notifyKey = response.detail.notify_title + response.detail.notify_content
const lastNotify = localStorage.getItem(STORAGE_KEYS.NOTIFY)
if (lastNotify !== notifyKey) {
localStorage.setItem(STORAGE_KEYS.NOTIFY, notifyKey)
alertStore.showAlert(
`${response.detail.notify_title}: ${response.detail.notify_content}`,
'success'
)
}
} }
return config.value return config.value
@@ -70,8 +63,7 @@ export function useSystemConfig() {
return config.value return config.value
} }
const errorMessage = error instanceof Error ? error.message : '获取配置失败' alertStore.showAlert(getErrorMessage(error, '获取配置失败'), 'error')
alertStore.showAlert(errorMessage, 'error')
return null return null
} finally { } finally {
isLoading.value = false isLoading.value = false
@@ -79,7 +71,7 @@ export function useSystemConfig() {
} }
// 更新系统配置 // 更新系统配置
const updateConfig = async (newConfig: Partial<SystemConfig>): Promise<boolean> => { const updateConfig = async (newConfig: Partial<ConfigState>): Promise<boolean> => {
try { try {
isLoading.value = true isLoading.value = true
@@ -94,14 +86,43 @@ export function useSystemConfig() {
throw new Error(response.message || '更新配置失败') throw new Error(response.message || '更新配置失败')
} }
} catch (error) { } catch (error) {
const errorMessage = error instanceof Error ? error.message : '更新配置失败' alertStore.showAlert(getErrorMessage(error, '更新配置失败'), 'error')
alertStore.showAlert(errorMessage, 'error')
return false return false
} finally { } finally {
isLoading.value = false isLoading.value = false
} }
} }
const toggleConfigFlag = (key: ConfigFlagKey) => {
config.value[key] = config.value[key] === 1 ? 0 : 1
}
const syncConfigForm = (nextConfig: ConfigState) => {
const sizeForm = bytesToFileSizeForm(nextConfig.uploadSize)
fileSize.value = sizeForm.value
sizeUnit.value = sizeForm.unit
const saveTimeForm = secondsToSaveTimeForm(nextConfig.max_save_seconds)
saveTime.value = saveTimeForm.value
saveTimeUnit.value = saveTimeForm.unit
}
const refreshConfig = async () => {
const latestConfig = await fetchConfig()
if (latestConfig) {
syncConfigForm(latestConfig)
}
}
const submitConfig = () =>
updateConfig(
buildConfigSubmitPayload(
config.value,
{ value: fileSize.value, unit: sizeUnit.value },
{ value: saveTime.value, unit: saveTimeUnit.value }
)
)
// 初始化配置 // 初始化配置
const initConfig = async () => { const initConfig = async () => {
// 先尝试从本地存储加载 // 先尝试从本地存储加载
@@ -116,17 +137,21 @@ export function useSystemConfig() {
// 计算属性 // 计算属性
const maxFileSizeMB = computed(() => { const maxFileSizeMB = computed(() => {
return Math.round(config.value.maxFileSize / 1024 / 1024) return Math.round(config.value.uploadSize / 1024 / 1024)
}) })
const isConfigLoaded = computed(() => { const isConfigLoaded = computed(() => {
return config.value.name !== DEFAULT_CONFIG.name || !isLoading.value return config.value.name !== DEFAULT_CONFIG_STATE.name || !isLoading.value
}) })
return { return {
// 状态 // 状态
config, config,
isLoading, isLoading,
fileSize,
sizeUnit,
saveTime,
saveTimeUnit,
// 计算属性 // 计算属性
maxFileSizeMB, maxFileSizeMB,
@@ -135,6 +160,9 @@ export function useSystemConfig() {
// 方法 // 方法
fetchConfig, fetchConfig,
updateConfig, updateConfig,
refreshConfig,
submitConfig,
toggleConfigFlag,
initConfig, initConfig,
getStoredConfig, getStoredConfig,
saveConfigToStorage saveConfigToStorage
+4 -3
View File
@@ -1,6 +1,7 @@
import { ref, computed } from 'vue' import { ref, computed } from 'vue'
import { STORAGE_KEYS, THEME_MODES } from '@/constants' import { THEME_MODES } from '@/constants'
import type { ThemeMode } from '@/types' import type { ThemeMode } from '@/types'
import { readStoredThemeMode, writeStoredThemeMode } from '@/utils/preference-storage'
export function useTheme() { export function useTheme() {
// 状态管理 // 状态管理
@@ -14,7 +15,7 @@ export function useTheme() {
// 从本地存储获取用户之前的选择 // 从本地存储获取用户之前的选择
const getUserPreference = (): ThemeMode | null => { const getUserPreference = (): ThemeMode | null => {
const storedPreference = localStorage.getItem(STORAGE_KEYS.COLOR_MODE) const storedPreference = readStoredThemeMode()
if (storedPreference && Object.values(THEME_MODES).includes(storedPreference as ThemeMode)) { if (storedPreference && Object.values(THEME_MODES).includes(storedPreference as ThemeMode)) {
return storedPreference as ThemeMode return storedPreference as ThemeMode
} }
@@ -24,7 +25,7 @@ export function useTheme() {
// 设置颜色模式 // 设置颜色模式
const setThemeMode = (mode: ThemeMode) => { const setThemeMode = (mode: ThemeMode) => {
themeMode.value = mode themeMode.value = mode
localStorage.setItem(STORAGE_KEYS.COLOR_MODE, mode) writeStoredThemeMode(mode)
// 根据模式设置实际的暗色模式状态 // 根据模式设置实际的暗色模式状态
if (mode === THEME_MODES.SYSTEM) { if (mode === THEME_MODES.SYSTEM) {
+10
View File
@@ -71,6 +71,16 @@ export const ROUTES = {
SETTINGS: '/admin/settings' SETTINGS: '/admin/settings'
} as const } as const
export const ROUTE_NAMES = {
RETRIEVE: 'Retrieve',
SEND: 'Send',
ADMIN: 'Manage',
LOGIN: 'Login',
DASHBOARD: 'Dashboard',
FILE_MANAGE: 'FileManage',
SETTINGS: 'Settings'
} as const
// 正则表达式 // 正则表达式
export const REGEX_PATTERNS = { export const REGEX_PATTERNS = {
EMAIL: /^[^\s@]+@[^\s@]+\.[^\s@]+$/, EMAIL: /^[^\s@]+@[^\s@]+\.[^\s@]+$/,
+3 -2
View File
@@ -1,10 +1,11 @@
import { createI18n } from 'vue-i18n' import { createI18n } from 'vue-i18n'
import zhCN from './locales/zh-CN' import zhCN from './locales/zh-CN'
import enUS from './locales/en-US' import enUS from './locales/en-US'
import { readStoredLocale, writeStoredLocale } from '@/utils/preference-storage'
// 获取浏览器语言设置 // 获取浏览器语言设置
const getDefaultLocale = (): string => { const getDefaultLocale = (): string => {
const savedLocale = localStorage.getItem('locale') const savedLocale = readStoredLocale()
if (savedLocale) { if (savedLocale) {
return savedLocale return savedLocale
} }
@@ -34,7 +35,7 @@ export default i18n
// 导出切换语言的函数 // 导出切换语言的函数
export const setLocale = (locale: string) => { export const setLocale = (locale: string) => {
i18n.global.locale.value = locale as 'zh-CN' | 'en-US' i18n.global.locale.value = locale as 'zh-CN' | 'en-US'
localStorage.setItem('locale', locale) writeStoredLocale(locale)
document.documentElement.lang = locale document.documentElement.lang = locale
} }
+73 -16
View File
@@ -58,14 +58,42 @@ export default {
title: 'Dashboard', title: 'Dashboard',
totalFiles: 'Total Files', totalFiles: 'Total Files',
storageSpace: 'Storage Space', storageSpace: 'Storage Space',
activeUsers: 'Active Users', todayShares: 'Today Shares',
systemStatus: 'System Status', totalRetrievals: 'Total Retrievals',
yesterday: 'Yesterday:', activeFiles: 'Active files: {count}',
today: 'Today:', todayIncrease: 'Today added: {count}',
weeklyChange: '↓ 5% from last week', yesterdayShares: 'Yesterday: {count}',
normal: 'Normal', serverUptime: 'Uptime',
serverUptime: 'Server Uptime:', refresh: 'Refresh',
version: 'Version v2.2.1 Updated: 2025-09-04' fileHealth: 'File Health',
fileHealthDesc: 'Real file records grouped by availability, expiry, and type.',
activeFileRatio: 'Active Ratio',
fileShareRatio: 'File Ratio',
textShareRatio: 'Text Ratio',
binaryFiles: '{count} file shares',
textShares: '{count} text shares',
expiredFiles: 'Expired Files',
needCleanup: 'Clean up in file management',
chunkedFiles: 'Chunked Files',
storagePolicy: 'Storage & Upload Policy',
storagePolicyDesc: 'Current settings that affect upload behavior.',
storageBackend: 'Storage Backend',
singleFileLimit: 'Single File Limit',
guestUpload: 'Guest Upload',
maxSaveTime: 'Max Retention',
noSaveLimit: 'Unlimited',
todayCapacityReference: 'Today Size / Single File Limit',
fileTypeDistribution: 'Type Distribution',
textType: 'Text',
recentFiles: 'Recent Shares',
recentFilesDesc: 'Recently created share records for quick status checks.',
available: 'Available',
table: {
file: 'File',
size: 'Size',
usage: 'Retrievals',
status: 'Status'
}
}, },
fileManage: { fileManage: {
title: 'File Management' title: 'File Management'
@@ -458,14 +486,42 @@ export default {
title: 'Dashboard', title: 'Dashboard',
totalFiles: 'Total Files', totalFiles: 'Total Files',
storageSpace: 'Storage Space', storageSpace: 'Storage Space',
activeUsers: 'Active Users', todayShares: 'Today Shares',
systemStatus: 'System Status', totalRetrievals: 'Total Retrievals',
yesterday: 'Yesterday:', activeFiles: 'Active files: {count}',
today: 'Today:', todayIncrease: 'Today added: {count}',
weeklyChange: '↓ 5% from last week', yesterdayShares: 'Yesterday: {count}',
normal: 'Normal', serverUptime: 'Uptime',
serverUptime: 'Server Uptime:', refresh: 'Refresh',
version: 'Version v2.2.1 Updated: 2025-09-04' fileHealth: 'File Health',
fileHealthDesc: 'Real file records grouped by availability, expiry, and type.',
activeFileRatio: 'Active Ratio',
fileShareRatio: 'File Ratio',
textShareRatio: 'Text Ratio',
binaryFiles: '{count} file shares',
textShares: '{count} text shares',
expiredFiles: 'Expired Files',
needCleanup: 'Clean up in file management',
chunkedFiles: 'Chunked Files',
storagePolicy: 'Storage & Upload Policy',
storagePolicyDesc: 'Current settings that affect upload behavior.',
storageBackend: 'Storage Backend',
singleFileLimit: 'Single File Limit',
guestUpload: 'Guest Upload',
maxSaveTime: 'Max Retention',
noSaveLimit: 'Unlimited',
todayCapacityReference: 'Today Size / Single File Limit',
fileTypeDistribution: 'Type Distribution',
textType: 'Text',
recentFiles: 'Recent Shares',
recentFilesDesc: 'Recently created share records for quick status checks.',
available: 'Available',
table: {
file: 'File',
size: 'Size',
usage: 'Retrievals',
status: 'Status'
}
}, },
fileManage: { fileManage: {
title: 'File Management', title: 'File Management',
@@ -493,6 +549,7 @@ export default {
}, },
updateFailed: 'Update failed', updateFailed: 'Update failed',
deleteFailed: 'Delete failed', deleteFailed: 'Delete failed',
deleteConfirm: 'Delete this file? This action cannot be undone.',
loadFileListFailed: 'Failed to load file list' loadFileListFailed: 'Failed to load file list'
}, },
login: { login: {
+74 -16
View File
@@ -12,6 +12,7 @@ export default {
next: '下一页', next: '下一页',
previous: '上一页', previous: '上一页',
loading: '加载中...', loading: '加载中...',
noData: '暂无数据',
success: '成功', success: '成功',
error: '错误', error: '错误',
warning: '警告', warning: '警告',
@@ -57,14 +58,42 @@ export default {
title: '仪表盘', title: '仪表盘',
totalFiles: '总文件数', totalFiles: '总文件数',
storageSpace: '存储空间', storageSpace: '存储空间',
activeUsers: '活跃用户', todayShares: '今日分享',
systemStatus: '系统状态', totalRetrievals: '累计取件',
yesterday: '昨天:', activeFiles: '有效文件:{count}',
today: '今天:', todayIncrease: '今日新增容量:{count}',
weeklyChange: '↓ 5% 较上周', yesterdayShares: '昨日分享:{count}',
normal: '正常', serverUptime: '运行时间',
serverUptime: '服务器运行时间:', refresh: '刷新数据',
version: '版本 v2.2.1 更新时间:2025-09-04' fileHealth: '文件健康',
fileHealthDesc: '基于真实文件记录统计有效、过期和类型分布。',
activeFileRatio: '有效占比',
fileShareRatio: '文件占比',
textShareRatio: '文本占比',
binaryFiles: '文件分享 {count} 个',
textShares: '文本分享 {count} 条',
expiredFiles: '已过期文件',
needCleanup: '可在文件管理中清理',
chunkedFiles: '分片上传文件',
storagePolicy: '存储与上传策略',
storagePolicyDesc: '当前后台配置对上传链路的影响。',
storageBackend: '存储后端',
singleFileLimit: '单文件上限',
guestUpload: '游客上传',
maxSaveTime: '最长保存',
noSaveLimit: '不限制',
todayCapacityReference: '今日容量 / 单文件上限',
fileTypeDistribution: '类型分布',
textType: '文本',
recentFiles: '最近分享',
recentFilesDesc: '最近创建的分享记录,便于快速核对状态。',
available: '可取件',
table: {
file: '文件',
size: '大小',
usage: '取件',
status: '状态'
}
}, },
fileManage: { fileManage: {
title: '文件管理' title: '文件管理'
@@ -422,14 +451,42 @@ export default {
title: '仪表盘', title: '仪表盘',
totalFiles: '总文件数', totalFiles: '总文件数',
storageSpace: '存储空间', storageSpace: '存储空间',
activeUsers: '活跃用户', todayShares: '今日分享',
systemStatus: '系统状态', totalRetrievals: '累计取件',
yesterday: '昨天:', activeFiles: '有效文件:{count}',
today: '今天:', todayIncrease: '今日新增容量:{count}',
weeklyChange: '↓ 5% 较上周', yesterdayShares: '昨日分享:{count}',
normal: '正常', serverUptime: '运行时间',
serverUptime: '服务器运行时间:', refresh: '刷新数据',
version: '版本 v2.2.1 更新时间:2025-09-04' fileHealth: '文件健康',
fileHealthDesc: '基于真实文件记录统计有效、过期和类型分布。',
activeFileRatio: '有效占比',
fileShareRatio: '文件占比',
textShareRatio: '文本占比',
binaryFiles: '文件分享 {count} 个',
textShares: '文本分享 {count} 条',
expiredFiles: '已过期文件',
needCleanup: '可在文件管理中清理',
chunkedFiles: '分片上传文件',
storagePolicy: '存储与上传策略',
storagePolicyDesc: '当前后台配置对上传链路的影响。',
storageBackend: '存储后端',
singleFileLimit: '单文件上限',
guestUpload: '游客上传',
maxSaveTime: '最长保存',
noSaveLimit: '不限制',
todayCapacityReference: '今日容量 / 单文件上限',
fileTypeDistribution: '类型分布',
textType: '文本',
recentFiles: '最近分享',
recentFilesDesc: '最近创建的分享记录,便于快速核对状态。',
available: '可取件',
table: {
file: '文件',
size: '大小',
usage: '取件',
status: '状态'
}
}, },
fileManage: { fileManage: {
title: '文件管理', title: '文件管理',
@@ -457,6 +514,7 @@ export default {
}, },
updateFailed: '更新失败', updateFailed: '更新失败',
deleteFailed: '删除失败', deleteFailed: '删除失败',
deleteConfirm: '确认删除这个文件?此操作不可撤销。',
loadFileListFailed: '加载文件列表失败' loadFileListFailed: '加载文件列表失败'
}, },
systemSettings: { systemSettings: {
+18 -34
View File
@@ -44,11 +44,11 @@
<nav class="flex-1 overflow-y-auto custom-scrollbar"> <nav class="flex-1 overflow-y-auto custom-scrollbar">
<ul class="p-4 space-y-2"> <ul class="p-4 space-y-2">
<li v-for="item in menuItems" :key="item.id"> <li v-for="item in menuItems" :key="item.id">
<a <RouterLink
@click="router.push(item.redirect)" :to="item.redirect"
class="flex items-center p-2 rounded-lg transition-colors duration-200" class="flex items-center p-2 rounded-lg transition-colors duration-200"
:class="[ :class="[
router.currentRoute.value.name === item.id route.name === item.id
? isDarkMode ? isDarkMode
? 'bg-indigo-900 text-indigo-400' ? 'bg-indigo-900 text-indigo-400'
: 'bg-indigo-100 text-indigo-600' : 'bg-indigo-100 text-indigo-600'
@@ -59,7 +59,7 @@
> >
<component :is="item.icon" class="w-5 h-5 mr-3" /> <component :is="item.icon" class="w-5 h-5 mr-3" />
{{ item.name }} {{ item.name }}
</a> </RouterLink>
</li> </li>
</ul> </ul>
</nav> </nav>
@@ -117,8 +117,9 @@ import {
LayoutDashboardIcon, LayoutDashboardIcon,
LogOutIcon LogOutIcon
} from 'lucide-vue-next' } from 'lucide-vue-next'
import { useRouter } from 'vue-router' import { RouterLink, useRoute, useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { ROUTE_NAMES, ROUTES } from '@/constants'
import { useAdminStore } from '@/stores/adminStore' import { useAdminStore } from '@/stores/adminStore'
interface MenuItem { interface MenuItem {
@@ -129,23 +130,29 @@ interface MenuItem {
} }
const router = useRouter() const router = useRouter()
const route = useRoute()
const { t } = useI18n() const { t } = useI18n()
const isDarkMode = inject('isDarkMode') const isDarkMode = inject('isDarkMode')
const adminStore = useAdminStore() const adminStore = useAdminStore()
const menuItems: MenuItem[] = [ const menuItems: MenuItem[] = [
{ {
id: 'Dashboard', id: ROUTE_NAMES.DASHBOARD,
name: t('admin.dashboard.title'), name: t('admin.dashboard.title'),
icon: LayoutDashboardIcon, icon: LayoutDashboardIcon,
redirect: '/admin/dashboard' redirect: ROUTES.DASHBOARD
}, },
{ {
id: 'FileManage', id: ROUTE_NAMES.FILE_MANAGE,
name: t('admin.fileManage.title'), name: t('admin.fileManage.title'),
icon: FolderIcon, icon: FolderIcon,
redirect: '/admin/files' redirect: ROUTES.FILE_MANAGE
}, },
{ id: 'Settings', name: t('admin.settings.title'), icon: CogIcon, redirect: '/admin/settings' } {
id: ROUTE_NAMES.SETTINGS,
name: t('admin.settings.title'),
icon: CogIcon,
redirect: ROUTES.SETTINGS
}
] ]
const isSidebarOpen = ref(true) const isSidebarOpen = ref(true)
@@ -171,33 +178,10 @@ onUnmounted(() => {
window.removeEventListener('resize', handleResize) window.removeEventListener('resize', handleResize)
}) })
// 分页参数
const params = ref({
page: 1,
size: 10,
total: 0
})
// 加载文件列表
const loadFiles = async () => {
try {
params.value.total = 85
// 更新文件列表数据...
} catch (error) {
console.error('加载文件列表失败:', error)
// 处理错误...
}
}
// 初始加载
onMounted(() => {
loadFiles()
})
// 登出处理 // 登出处理
const handleLogout = () => { const handleLogout = () => {
adminStore.logout() adminStore.logout()
router.push('/login') router.push(ROUTES.LOGIN)
} }
</script> </script>
+96 -42
View File
@@ -1,50 +1,104 @@
import { createRouter, createWebHashHistory } from 'vue-router' import { createRouter, createWebHashHistory } from 'vue-router'
import type { RouteRecordRaw } from 'vue-router'
import { ROUTE_NAMES, ROUTES } from '@/constants'
import { readStoredToken } from '@/utils/auth-storage'
const publicPageMeta = {
showGlobalControls: true,
showRouteLoading: true
}
const adminPageMeta = {
requiresAuth: true,
showGlobalControls: false,
showRouteLoading: true
}
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: '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
}
]
// 预加载 SendFileView 组件
const SendFileView = () => import('../views/SendFileView.vue')
const router = createRouter({ const router = createRouter({
history: createWebHashHistory(import.meta.env.BASE_URL), history: createWebHashHistory(import.meta.env.BASE_URL),
routes: [ 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')
}
]
},
{
path: '/login',
name: 'Login',
component: () => import('@/views/manage/LoginView.vue')
}
]
}) })
router.beforeEach((to) => {
if (to.meta.requiresAuth && !readStoredToken()) {
return {
path: ROUTES.LOGIN,
query: {
redirect: to.fullPath
}
}
}
})
export default router export default router
+16
View File
@@ -0,0 +1,16 @@
import api from './client'
import type { AdminUser, ApiResponse } from '@/types'
export class AuthService {
static async login(password: string): Promise<ApiResponse<AdminUser>> {
return api.post('/admin/login', { password })
}
static async logout(): Promise<ApiResponse> {
return api.post('/admin/logout')
}
static async verifyToken(): Promise<ApiResponse<AdminUser>> {
return api.get('/admin/verify')
}
}
+61
View File
@@ -0,0 +1,61 @@
import axios, { type AxiosError, type InternalAxiosRequestConfig } from 'axios'
import { API_STATUS_CODES, TIME_CONSTANTS } from '@/constants'
import type { ApiErrorPayload } from '@/types'
import { clearStoredToken, readStoredToken } from '@/utils/auth-storage'
export const AUTH_EVENTS = {
UNAUTHORIZED: 'filecodebox:auth:unauthorized'
} 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) => {
const token = readStoredToken()
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
return config
}
const handleAuthError = (error: AxiosError<ApiErrorPayload>) => {
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
+16
View File
@@ -0,0 +1,16 @@
import api from './client'
import type { ApiResponse, ConfigState } from '@/types'
export class ConfigService {
static async getConfig(): Promise<ApiResponse<ConfigState>> {
return api.get('/admin/config/get')
}
static async getUserConfig(): Promise<ApiResponse<ConfigState>> {
return api.post('/')
}
static async updateConfig(config: Partial<ConfigState>): Promise<ApiResponse> {
return api.patch('/admin/config/update', config)
}
}
+144
View File
@@ -0,0 +1,144 @@
import api, { rawApiClient } from './client'
import { multipartUploadConfig } from './shared'
import type {
ApiResponse,
ChunkUploadCompleteRequest,
ChunkUploadInitRequest,
ChunkUploadInitResponse,
ChunkUploadResponse,
FileEditForm,
FileInfo,
FileListResponse,
FileUploadResponse,
ShareSelectResponse,
TextSendResponse,
UploadProgress
} from '@/types'
const urlEncodedConfig = {
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
}
}
const toUrlEncodedForm = (data: Record<string, string | number>) => {
const form = new URLSearchParams()
Object.entries(data).forEach(([key, value]) => {
form.append(key, String(value))
})
return form
}
export class FileService {
static async uploadFile(
file: File,
onProgress?: (progress: UploadProgress) => void
): Promise<ApiResponse<FileUploadResponse>> {
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<ApiResponse<TextSendResponse>> {
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<ApiResponse<ChunkUploadInitResponse>> {
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<ApiResponse<ChunkUploadResponse>> {
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<ApiResponse<FileUploadResponse>> {
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<ApiResponse<ShareSelectResponse>> {
return api.post('/share/select/', { code })
}
static async getFile(code: string): Promise<ApiResponse<FileInfo>> {
return api.get(`/file/${code}`)
}
static async downloadFile(code: string): Promise<Blob> {
const response = await rawApiClient.get<Blob>(`/download/${code}`, {
responseType: 'blob'
})
return response.data
}
static async getAdminFileList(params: {
page: number
size: number
keyword?: string
}): Promise<ApiResponse<FileListResponse>> {
return api.get('/admin/file/list', { params })
}
static async updateFile(data: FileEditForm): Promise<ApiResponse> {
return api.patch('/admin/file/update', data)
}
static async deleteAdminFile(id: number): Promise<ApiResponse> {
return api.delete('/admin/file/delete', {
data: { id }
})
}
static async downloadAdminFile(
id: number
): Promise<{ data: Blob; headers: Record<string, string> }> {
const response = await rawApiClient.get<Blob>('/admin/file/download', {
params: { id },
responseType: 'blob'
})
return {
data: response.data,
headers: response.headers as Record<string, string>
}
}
}
+7 -266
View File
@@ -1,266 +1,7 @@
// API 服务层 export { AUTH_EVENTS } from './client'
import api from '@/utils/api' export { AuthService } from './auth'
import axios from 'axios' export { ConfigService } from './config'
import type { export { FileService } from './file'
ApiResponse, export { PresignUploadService } from './presign-upload'
FileInfo, export { StatsService } from './stats'
ConfigState, export { uploadChunkedFile } from './upload-strategy'
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<ApiResponse<ConfigState>> {
return api.get('/admin/config/get')
}
static async getUserConfig(): Promise<ApiResponse<ConfigState>> {
return api.post('/')
}
static async updateConfig(config: Partial<ConfigState>): Promise<ApiResponse> {
return api.patch('/admin/config/update', config)
}
}
// 文件服务
export class FileService {
static async uploadFile(
file: File,
onProgress?: (progress: UploadProgress) => void
): Promise<ApiResponse<FileUploadResponse>> {
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<ApiResponse<TextSendResponse>> {
return api.post('/share/text/', { content: text })
}
static async getFile(code: string): Promise<ApiResponse<FileInfo>> {
return api.get(`/file/${code}`)
}
static async downloadFile(code: string): Promise<Blob> {
const response = await api.get(`/download/${code}`, {
responseType: 'blob'
})
return response.data
}
static async deleteFile(fileId: string): Promise<ApiResponse> {
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<ApiResponse<FileListResponse>> {
return api.get('/admin/file/list', { params })
}
static async updateFile(data: FileEditForm): Promise<ApiResponse> {
return api.patch('/admin/file/update', data)
}
static async deleteAdminFile(id: number): Promise<ApiResponse> {
return api.delete('/admin/file/delete', {
data: { id }
})
}
static async downloadAdminFile(
id: number
): Promise<{ data: Blob; headers: Record<string, string> }> {
return api.get('/admin/file/download', {
params: { id },
responseType: 'blob'
})
}
}
// 认证服务
export class AuthService {
static async login(password: string): Promise<ApiResponse<AdminUser>> {
return api.post('/admin/login', { password })
}
static async logout(): Promise<ApiResponse> {
return api.post('/admin/logout')
}
static async verifyToken(): Promise<ApiResponse<AdminUser>> {
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<ApiResponse<DashboardData>> {
return api.get('/admin/dashboard')
}
}
// 预签名上传服务
export class PresignUploadService {
/**
* 初始化上传会话
*/
static async initUpload(request: PresignInitRequest): Promise<ApiResponse<PresignInitResponse>> {
return api.post('/presign/upload/init', request)
}
/**
* 代理模式上传
*/
static async proxyUpload(
uploadId: string,
file: File,
onProgress?: (progress: UploadProgress) => void
): Promise<ApiResponse<PresignUploadResult>> {
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<ApiResponse<PresignUploadResult>> {
return api.post(`/presign/upload/confirm/${uploadId}`, request || {})
}
/**
* 查询上传状态
*/
static async getUploadStatus(uploadId: string): Promise<ApiResponse<PresignStatusResponse>> {
return api.get(`/presign/upload/status/${uploadId}`)
}
/**
* 取消上传
*/
static async cancelUpload(uploadId: string): Promise<ApiResponse<{ message: string }>> {
return api.delete(`/presign/upload/${uploadId}`)
}
/**
* S3 直传(不经过后端)
*/
static async directUploadToS3(
uploadUrl: string,
file: File,
onProgress?: (progress: UploadProgress) => void
): Promise<boolean> {
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
+53
View File
@@ -0,0 +1,53 @@
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<ApiResponse<PresignInitResponse>> {
return api.post('/presign/upload/init', request)
}
static async proxyUpload(
uploadId: string,
file: File,
onProgress?: (progress: UploadProgress) => void
): Promise<ApiResponse<PresignUploadResult>> {
const formData = new FormData()
formData.append('file', file)
return api.put(`/presign/upload/proxy/${uploadId}`, formData, multipartUploadConfig(onProgress))
}
static async confirmUpload(
uploadId: string,
request?: PresignConfirmRequest
): Promise<ApiResponse<PresignUploadResult>> {
return api.post(`/presign/upload/confirm/${uploadId}`, request || {})
}
static async getUploadStatus(uploadId: string): Promise<ApiResponse<PresignStatusResponse>> {
return api.get(`/presign/upload/status/${uploadId}`)
}
static async cancelUpload(uploadId: string): Promise<ApiResponse<{ message: string }>> {
return api.delete(`/presign/upload/${uploadId}`)
}
static async directUploadToS3(
uploadUrl: string,
file: File,
onProgress?: (progress: UploadProgress) => void
): Promise<boolean> {
await uploadToExternalUrl(uploadUrl, file, onProgress)
return true
}
}
+20
View File
@@ -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)
})
}
}
})
+8
View File
@@ -0,0 +1,8 @@
import api from './client'
import type { ApiResponse, DashboardData } from '@/types'
export class StatsService {
static async getDashboard(): Promise<ApiResponse<DashboardData>> {
return api.get('/admin/dashboard')
}
}
+26
View File
@@ -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<void> {
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)
})
}
})
}
+97
View File
@@ -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<number>, 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<ApiResponse<ChunkedUploadResult>> => {
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
}
+15 -12
View File
@@ -1,12 +1,18 @@
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import { ref, computed } from 'vue' import { ref, computed } from 'vue'
import { STORAGE_KEYS } from '@/constants'
import type { AdminUser } from '@/types' import type { AdminUser } from '@/types'
import {
clearStoredAuth,
readStoredAdminPassword,
readStoredToken,
writeStoredAdminPassword,
writeStoredToken
} from '@/utils/auth-storage'
export const useAdminStore = defineStore('admin', () => { export const useAdminStore = defineStore('admin', () => {
// 状态 // 状态
const adminPassword = ref(localStorage.getItem(STORAGE_KEYS.ADMIN_PASSWORD) || '') const adminPassword = ref(readStoredAdminPassword())
const token = ref(localStorage.getItem(STORAGE_KEYS.TOKEN) || '') const token = ref(readStoredToken())
const isLoggedIn = ref(false) const isLoggedIn = ref(false)
const userInfo = ref<AdminUser | null>(null) const userInfo = ref<AdminUser | null>(null)
@@ -14,16 +20,17 @@ export const useAdminStore = defineStore('admin', () => {
const isAuthenticated = computed(() => { const isAuthenticated = computed(() => {
return isLoggedIn.value && !!token.value return isLoggedIn.value && !!token.value
}) })
const hasToken = computed(() => !!token.value)
// 方法 // 方法
const updateAdminPassword = (pwd: string) => { const updateAdminPassword = (pwd: string) => {
adminPassword.value = pwd adminPassword.value = pwd
localStorage.setItem(STORAGE_KEYS.ADMIN_PASSWORD, pwd) writeStoredAdminPassword(pwd)
} }
const setToken = (newToken: string) => { const setToken = (newToken: string) => {
token.value = newToken token.value = newToken
localStorage.setItem(STORAGE_KEYS.TOKEN, newToken) writeStoredToken(newToken)
} }
const setUserInfo = (user: AdminUser) => { const setUserInfo = (user: AdminUser) => {
@@ -42,13 +49,11 @@ export const useAdminStore = defineStore('admin', () => {
isLoggedIn.value = false isLoggedIn.value = false
userInfo.value = null userInfo.value = null
// 清除本地存储 clearStoredAuth()
localStorage.removeItem(STORAGE_KEYS.ADMIN_PASSWORD)
localStorage.removeItem(STORAGE_KEYS.TOKEN)
} }
const initAuth = () => { const initAuth = () => {
const storedToken = localStorage.getItem(STORAGE_KEYS.TOKEN) const storedToken = readStoredToken()
if (storedToken) { if (storedToken) {
token.value = storedToken token.value = storedToken
isLoggedIn.value = true isLoggedIn.value = true
@@ -64,6 +69,7 @@ export const useAdminStore = defineStore('admin', () => {
// 计算属性 // 计算属性
isAuthenticated, isAuthenticated,
hasToken,
// 方法 // 方法
updateAdminPassword, updateAdminPassword,
@@ -74,6 +80,3 @@ export const useAdminStore = defineStore('admin', () => {
initAuth initAuth
} }
}) })
// 保持向后兼容
export const useAdminData = useAdminStore
+21
View File
@@ -2,6 +2,8 @@ import { defineStore } from 'pinia'
import type { Alert, AlertType } from '@/types' import type { Alert, AlertType } from '@/types'
import { TIME_CONSTANTS } from '@/constants' import { TIME_CONSTANTS } from '@/constants'
let progressTimer: ReturnType<typeof setInterval> | null = null
export const useAlertStore = defineStore('alert', { export const useAlertStore = defineStore('alert', {
state: () => ({ state: () => ({
alerts: [] as Alert[] alerts: [] as Alert[]
@@ -33,6 +35,25 @@ export const useAlertStore = defineStore('alert', {
this.removeAlert(id) this.removeAlert(id)
} }
} }
},
startProgressTimer() {
if (progressTimer) {
return
}
progressTimer = setInterval(() => {
this.alerts.forEach((alert) => {
this.updateAlertProgress(alert.id)
})
}, TIME_CONSTANTS.PROGRESS_UPDATE_INTERVAL)
},
stopProgressTimer() {
if (!progressTimer) {
return
}
clearInterval(progressTimer)
progressTimer = null
} }
} }
}) })
+62
View File
@@ -0,0 +1,62 @@
import { defineStore } from 'pinia'
import { computed, ref } from 'vue'
import type { ConfigState } 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<PublicConfig>({
...DEFAULT_PUBLIC_CONFIG,
...toPublicConfig(readStoredConfig<Partial<ConfigState>>())
})
const uploadSizeLimit = computed(() => config.value.uploadSize)
const updateConfig = (nextConfig: Partial<ConfigState>) => {
config.value = {
...DEFAULT_PUBLIC_CONFIG,
...config.value,
...toPublicConfig(nextConfig)
}
writeStoredConfig(config.value)
}
const applyRemoteConfig = (nextConfig: Partial<ConfigState>): 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 reloadStoredConfig = () => {
config.value = {
...DEFAULT_PUBLIC_CONFIG,
...toPublicConfig(readStoredConfig<Partial<ConfigState>>())
}
}
return {
config,
uploadSizeLimit,
applyRemoteConfig,
updateConfig,
reloadStoredConfig
}
})
+13 -248
View File
@@ -1,197 +1,24 @@
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import { ref, computed } from 'vue' import { ref } from 'vue'
import type { FileInfo, UploadProgress, UploadStatus } from '@/types' import type { ReceivedFileRecord, SentFileRecord } from '@/types'
import { UPLOAD_STATUS } from '@/constants'
export const useFileDataStore = defineStore('fileData', () => { export const useFileDataStore = defineStore('fileData', () => {
// 上传相关状态 const receiveData = ref<ReceivedFileRecord[]>([])
const uploadStatus = ref<UploadStatus>(UPLOAD_STATUS.IDLE) const shareData = ref<SentFileRecord[]>([])
const uploadProgress = ref<UploadProgress>({
loaded: 0,
total: 0,
percentage: 0
})
const uploadedCode = ref('')
const currentFile = ref<File | null>(null)
// 下载相关状态 const addReceiveData = (record: ReceivedFileRecord) => {
const downloadCode = ref('') receiveData.value.push(record)
const fileInfo = ref<FileInfo | null>(null)
const isDownloading = ref(false)
// 文件列表状态(管理页面使用)
const fileList = ref<FileInfo[]>([])
const totalFiles = ref(0)
const currentPage = ref(1)
const pageSize = ref(10)
const isLoadingList = ref(false)
// 接收数据状态(取件记录)
const receiveData = ref<Array<{
id: number
code: string
filename: string
size: string
downloadUrl: string | null
content: string | null
date: string
}>>([])
// 分享数据状态(发送记录)
const shareData = ref<Array<{
id: number
filename: string
date: string
size: string
expiration: string
retrieveCode: string
}>>([])
// 计算属性
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<FileInfo>) => {
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 addReceiveData = (data: {
id: number
code: string
filename: string
size: string
downloadUrl: string | null
content: string | null
date: string
}) => {
receiveData.value.push(data)
} }
const removeReceiveData = (id: number) => { const removeReceiveData = (id: number) => {
const index = receiveData.value.findIndex(item => item.id === id) const index = receiveData.value.findIndex((record) => record.id === id)
if (index > -1) { if (index !== -1) {
receiveData.value.splice(index, 1) receiveData.value.splice(index, 1)
} }
} }
const deleteReceiveData = (index: number) => { const deleteReceiveData = (index: number) => {
if (index > -1 && index < receiveData.value.length) { if (index >= 0 && index < receiveData.value.length) {
receiveData.value.splice(index, 1) receiveData.value.splice(index, 1)
} }
} }
@@ -200,20 +27,12 @@ export const useFileDataStore = defineStore('fileData', () => {
receiveData.value = [] receiveData.value = []
} }
// 分享数据相关方法 const addShareDataRecord = (record: SentFileRecord) => {
const addShareDataRecord = (data: { shareData.value.push(record)
id: number
filename: string
date: string
size: string
expiration: string
retrieveCode: string
}) => {
shareData.value.push(data)
} }
const deleteShareData = (index: number) => { const deleteShareData = (index: number) => {
if (index > -1 && index < shareData.value.length) { if (index >= 0 && index < shareData.value.length) {
shareData.value.splice(index, 1) shareData.value.splice(index, 1)
} }
} }
@@ -223,66 +42,12 @@ export const useFileDataStore = defineStore('fileData', () => {
} }
return { return {
// 上传状态
uploadStatus,
uploadProgress,
uploadedCode,
currentFile,
// 下载状态
downloadCode,
fileInfo,
isDownloading,
// 文件列表状态
fileList,
totalFiles,
currentPage,
pageSize,
isLoadingList,
// 计算属性
isUploading,
isUploadSuccess,
isUploadError,
hasFileInfo,
canDownload,
totalPages,
// 上传方法
setUploadStatus,
setUploadProgress,
setUploadedCode,
setCurrentFile,
resetUpload,
// 下载方法
setDownloadCode,
setFileInfo,
setDownloading,
resetDownload,
// 文件列表方法
setFileList,
addFile,
removeFile,
updateFile,
setTotalFiles,
setCurrentPage,
setPageSize,
setLoadingList,
resetFileList,
addShareData,
// 接收数据状态和方法
receiveData, receiveData,
shareData,
addReceiveData, addReceiveData,
removeReceiveData, removeReceiveData,
deleteReceiveData, deleteReceiveData,
clearReceiveData, clearReceiveData,
// 分享数据状态和方法
shareData,
addShareDataRecord, addShareDataRecord,
deleteShareData, deleteShareData,
clearShareData clearShareData
+10
View File
@@ -0,0 +1,10 @@
export interface ApiResponse<T = unknown> {
code: number
message?: string
detail?: T
}
export interface ApiErrorPayload {
detail?: string
message?: string
}
+5
View File
@@ -0,0 +1,5 @@
export interface AdminUser {
id: string
username: string
token: string
}
+55
View File
@@ -0,0 +1,55 @@
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 ConfigState {
name: string
description: string
file_storage: string
themesChoices: ThemeChoice[]
expireStyle: string[]
admin_token: string
robotsText: string
keywords: string
notify_title: string
notify_content: string
openUpload: number
uploadSize: number
storage_path: string
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_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
}
+54
View File
@@ -0,0 +1,54 @@
export interface DashboardData {
totalFiles: number
storageUsed: number
yesterdayCount: number
todayCount: number
yesterdaySize: number
todaySize: number
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
topSuffixes: DashboardSuffixStat[]
recentFiles: DashboardRecentFile[]
}
export interface DashboardSuffixStat {
suffix: string
count: number
}
export interface DashboardRecentFile {
id: number
code: string
name: string
suffix: string
size: number
text: boolean
expiredAt: string | null
expiredCount: number
usedCount: number
createdAt: string | null
isExpired: boolean
}
export interface DashboardViewData extends DashboardData {
hasExtendedStats: boolean
storageUsedText: string
yesterdaySizeText: string
todaySizeText: string
uploadSizeLimitText: string
sysUptimeText: string
activeRatio: number
textRatio: number
fileRatio: number
todaySizeRatio: number
}
+107
View File
@@ -0,0 +1,107 @@
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
expired_count: number | null
created_at: string
}
export interface AdminFileViewItem extends FileListItem {
displaySize: string
displayExpiredAt: string
canPreviewText: boolean
}
export interface FileEditForm {
id: number | null
code: string
prefix: string
suffix: string
expired_at: string
expired_count: number | null
}
export interface FileListResponse {
data: FileListItem[]
total: number
page: number
size: number
}
export interface FileUploadResponse {
code: string
name: string
}
export interface TextSendResponse {
code: string
}
export interface ShareSelectResponse {
code: string
name: string
text: string
size: number
}
export interface ReceivedFileRecord {
id: number
code: string
filename: string
size: string
downloadUrl: string | null
content: string | null
date: string
}
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
+7 -243
View File
@@ -1,243 +1,7 @@
// 通用类型定义 export * from './api'
export interface ApiResponse<T = unknown> { export * from './auth'
code: number export * from './config'
message?: string export * from './dashboard'
detail?: T export * from './file'
} export * from './presign-upload'
export * from './ui'
// 文件相关类型
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
expired_count: number | null
created_at: string
}
export interface FileEditForm {
id: number | null
code: string
prefix: string
suffix: string
expired_at: string
expired_count: number | null
}
export interface FileListResponse {
data: FileListItem[]
total: number
page: number
size: number
}
// 文件上传响应类型
export interface FileUploadResponse {
code: number
name: string
}
// 文本发送响应类型
export interface TextSendResponse {
code: number
}
export interface UploadProgress {
loaded: number
total: number
percentage: number
}
// 系统配置类型
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 ConfigState {
name: string
description: string
file_storage: string
themesChoices: ThemeChoice[]
expireStyle: string[]
admin_token: string
robotsText: string
keywords: string
notify_title: string
notify_content: string
openUpload: number
uploadSize: number
storage_path: string
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_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
}
// 系统配置API响应类型
export interface ConfigResponse {
code: number
message?: string
detail?: ConfigState
}
// 用户相关类型
export interface AdminUser {
id: string
username: string
token: string
}
// Dashboard 数据类型
export interface DashboardData {
totalFiles: number
storageUsed: number | string
yesterdayCount: number
todayCount: number
yesterdaySize: number | string
todaySize: number | string
sysUptime: number | string
}
// 主题相关类型
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
}
}
// ==================== 预签名上传相关类型 ====================
// 预签名上传模式
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
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
}
+53
View File
@@ -0,0 +1,53 @@
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
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
}
+26
View File
@@ -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
}
}
+2 -74
View File
@@ -1,74 +1,2 @@
import axios from 'axios' export { apiBaseURL, rawApiClient } from '@/services/client'
import { TIME_CONSTANTS } from '@/constants' export { default } from '@/services/client'
// 从环境变量中获取 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: TIME_CONSTANTS.REQUEST_TIMEOUT, // 30秒超时
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) {
const { status } = error.response
switch (status) {
case 401:
localStorage.removeItem('token')
// 使用 router 进行导航而不是直接修改 location
if (window.location.hash !== '#/login') {
window.location.href = '/#/login'
}
break
case 403:
case 404:
case 500:
default:
// 错误信息通过Promise.reject传递给调用方处理
break
}
} else if (error.request) {
// 网络错误,通过Promise.reject传递给调用方处理
} else {
// 请求配置错误,通过Promise.reject传递给调用方处理
}
return Promise.reject(error)
}
)
export default api
+26
View File
@@ -0,0 +1,26 @@
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) || ''
}
export function writeStoredToken(token: string) {
localStorage.setItem(STORAGE_KEYS.TOKEN, token)
}
export function clearStoredAuth() {
localStorage.removeItem(STORAGE_KEYS.ADMIN_PASSWORD)
localStorage.removeItem(STORAGE_KEYS.TOKEN)
}
export function clearStoredToken() {
localStorage.removeItem(STORAGE_KEYS.TOKEN)
}
+40
View File
@@ -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
}
}
+45 -50
View File
@@ -2,11 +2,15 @@
* 剪贴板工具函数 * 剪贴板工具函数
*/ */
import { useAlertStore } from '@/stores/alertStore' import { buildRetrieveUrl, buildWgetCommand } from '@/utils/share-url'
type CopyNotifyType = 'success' | 'error'
interface CopyOptions { interface CopyOptions {
successMsg?: string successMsg?: string
errorMsg?: string errorMsg?: string
showMsg?: boolean showMsg?: boolean
notify?: (message: string, type: CopyNotifyType) => void
} }
/** /**
@@ -19,13 +23,24 @@ export const copyToClipboard = async (
text: string, text: string,
options: CopyOptions = {} options: CopyOptions = {}
): Promise<boolean> => { ): Promise<boolean> => {
const { successMsg = '复制成功', errorMsg = '复制失败,请手动复制', showMsg = true } = options const {
const alertStore = useAlertStore() successMsg = '复制成功',
errorMsg = '复制失败,请手动复制',
showMsg = true,
notify
} = options
const showCopyMessage = (message: string, type: CopyNotifyType) => {
if (showMsg) {
notify?.(message, type)
}
}
try { try {
// 优先使用 Clipboard API // 优先使用 Clipboard API
if (document.hasFocus() && navigator.clipboard && navigator.clipboard.writeText) { if (document.hasFocus() && navigator.clipboard && navigator.clipboard.writeText) {
await navigator.clipboard.writeText(text) await navigator.clipboard.writeText(text)
if (showMsg) alertStore.showAlert(successMsg, 'success') showCopyMessage(successMsg, 'success')
return true return true
} }
// 后备方案:使用传统的复制方法 // 后备方案:使用传统的复制方法
@@ -38,14 +53,14 @@ export const copyToClipboard = async (
const success = document.execCommand('copy') const success = document.execCommand('copy')
document.body.removeChild(textarea) document.body.removeChild(textarea)
if (success) { if (success) {
if (showMsg) alertStore.showAlert(successMsg, 'success') showCopyMessage(successMsg, 'success')
return true return true
} else { } else {
throw new Error('execCommand copy failed') throw new Error('execCommand copy failed')
} }
} catch (err) { } catch (err) {
console.error('复制失败:', err) console.error('复制失败:', err)
if (showMsg) alertStore.showAlert(errorMsg, 'error') showCopyMessage(errorMsg, 'error')
return false return false
} }
} }
@@ -55,11 +70,15 @@ export const copyToClipboard = async (
* @param code 取件码 * @param code 取件码
* @returns Promise<boolean> 是否复制成功 * @returns Promise<boolean> 是否复制成功
*/ */
export const copyRetrieveLink = async (code: string): Promise<boolean> => { export const copyRetrieveLink = async (
const link = `${window.location.origin}/#/?code=${code}` code: string,
options: Pick<CopyOptions, 'notify' | 'showMsg'> = {}
): Promise<boolean> => {
const link = buildRetrieveUrl(code)
return copyToClipboard(link, { return copyToClipboard(link, {
successMsg: '取件链接已复制到剪贴板', successMsg: '取件链接已复制到剪贴板',
errorMsg: '复制失败,请手动复制取件链接' errorMsg: '复制失败,请手动复制取件链接',
...options
}) })
} }
@@ -68,50 +87,26 @@ export const copyRetrieveLink = async (code: string): Promise<boolean> => {
* @param code 取件码 * @param code 取件码
* @returns Promise<boolean> 是否复制成功 * @returns Promise<boolean> 是否复制成功
*/ */
export const copyRetrieveCode = async (code: string): Promise<boolean> => { export const copyRetrieveCode = async (
code: string,
options: Pick<CopyOptions, 'notify' | 'showMsg'> = {}
): Promise<boolean> => {
return copyToClipboard(code, { return copyToClipboard(code, {
successMsg: '取件码已复制到剪贴板', successMsg: '取件码已复制到剪贴板',
errorMsg: '复制失败,请手动复制取件码' errorMsg: '复制失败,请手动复制取件码',
...options
}) })
} }
const baseUrl = window.location.origin + '/' export const copyWgetCommand = (
retrieveCode: string,
export const copyWgetCommand = (retrieveCode: string, fileName: string) => { fileName: string,
const command = `wget ${baseUrl}share/select?code=${retrieveCode} -O "${fileName}"` options: Pick<CopyOptions, 'notify' | 'showMsg'> = {}
) => {
if (navigator.clipboard && navigator.clipboard.writeText) { const command = buildWgetCommand(retrieveCode, fileName)
navigator.clipboard void copyToClipboard(command, {
.writeText(command) successMsg: '命令已复制到剪贴板',
.then(() => { errorMsg: '复制失败,请手动复制命令',
console.log('命令已复制到剪贴板!') ...options
}) })
.catch((err) => {
console.error('复制失败,使用回退方法:', err)
fallbackCopyTextToClipboard(command)
})
} else {
console.warn('Clipboard API 不可用,使用回退方法。')
fallbackCopyTextToClipboard(command)
}
}
function fallbackCopyTextToClipboard(command: string) {
const textArea = document.createElement('textarea')
textArea.value = command
textArea.style.position = 'fixed' // 避免滚动
document.body.appendChild(textArea)
textArea.focus()
textArea.select()
try {
const successful = document.execCommand('copy')
console.log('回退复制操作成功:', successful)
if (document.hasFocus() && navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(command)
} else {
console.error('回退复制操作失败')
}
} catch (err) {
console.error('回退复制操作失败:', err)
}
document.body.removeChild(textArea)
} }
+22 -30
View File
@@ -1,6 +1,7 @@
/** /**
* 通用工具函数 * 通用工具函数
*/ */
import type { ApiErrorPayload } from '@/types'
/** /**
* 格式化时间戳为可读格式 * 格式化时间戳为可读格式
@@ -74,36 +75,6 @@ export function formatDuration(seconds: number, t?: (key: string) => string): st
return `${seconds}${secondName}` return `${seconds}${secondName}`
} }
/**
* 复制文本到剪贴板
* @param text 要复制的文本
* @returns Promise<boolean> 是否复制成功
*/
export async function copyToClipboard(text: string): Promise<boolean> {
try {
if (navigator.clipboard && window.isSecureContext) {
await navigator.clipboard.writeText(text)
return true
} else {
// 降级方案
const textArea = document.createElement('textarea')
textArea.value = text
textArea.style.position = 'fixed'
textArea.style.left = '-999999px'
textArea.style.top = '-999999px'
document.body.appendChild(textArea)
textArea.focus()
textArea.select()
const result = document.execCommand('copy')
textArea.remove()
return result
}
} catch (error) {
console.error('Copy failed:', error)
return false
}
}
/** /**
* 防抖函数 * 防抖函数
* @param func 要防抖的函数 * @param func 要防抖的函数
@@ -242,3 +213,24 @@ export function isMobile(): boolean {
export function formatNumber(num: number): string { export function formatNumber(num: number): string {
return num.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ',') return num.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ',')
} }
type ErrorWithResponse = {
response?: {
data?: ApiErrorPayload
}
message?: string
}
export function getErrorMessage(error: unknown, fallback: string): string {
if (!error || typeof error !== 'object') {
return fallback
}
const errorWithResponse = error as ErrorWithResponse
return (
errorWithResponse.response?.data?.detail ||
errorWithResponse.response?.data?.message ||
errorWithResponse.message ||
fallback
)
}
+107
View File
@@ -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<FileSizeUnit, number> = {
KB: 1024,
MB: 1024 * 1024,
GB: 1024 * 1024 * 1024
}
const SAVE_TIME_UNITS: Record<SaveTimeUnit, number> = {
: 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)
}
}
+107
View File
@@ -0,0 +1,107 @@
import { DEFAULT_CONFIG, FILE_SIZE_LIMITS, STORAGE_KEYS } from '@/constants'
import type { ConfigState, SystemConfig } from '@/types'
export type PublicConfig = SystemConfig & {
uploadSize: number
expireStyle: string[]
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
}
export const DEFAULT_PUBLIC_CONFIG: PublicConfig = {
...DEFAULT_CONFIG,
uploadSize: FILE_SIZE_LIMITS.MAX_FILE_SIZE,
expireStyle: ['day'],
openUpload: 1,
max_save_seconds: 0,
enableChunk: 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,
admin_token: '',
robotsText: '',
keywords: '',
notify_title: '',
notify_content: '',
openUpload: DEFAULT_PUBLIC_CONFIG.openUpload,
uploadSize: DEFAULT_PUBLIC_CONFIG.uploadSize,
storage_path: '',
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_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: ''
}
export function readStoredConfig<T extends object = Partial<ConfigState>>(): 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: Partial<ConfigState> | null | undefined): Partial<PublicConfig> {
if (!config) return {}
return {
name: config.name,
description: config.description,
uploadSize: config.uploadSize,
expireStyle: config.expireStyle,
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: config.showAdminAddr,
themesSelect: config.themesSelect,
background: config.background,
opacity: config.opacity
}
}
export function writeStoredConfig(config: object) {
localStorage.setItem(STORAGE_KEYS.CONFIG, JSON.stringify(toPublicConfig(config as Partial<ConfigState>)))
}
export function readNotifyKey(): string | null {
return localStorage.getItem(STORAGE_KEYS.NOTIFY)
}
export function writeNotifyKey(notifyKey: string) {
localStorage.setItem(STORAGE_KEYS.NOTIFY, notifyKey)
}
+41
View File
@@ -0,0 +1,41 @@
import { marked } from 'marked'
import DOMPurify from 'dompurify'
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']
export async function renderMarkdownPreview(content: string): Promise<string> {
try {
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
}
}
+15
View File
@@ -0,0 +1,15 @@
import { saveAs } from 'file-saver'
import type { ReceivedFileRecord } from '@/types'
import { buildDownloadUrl } from '@/utils/share-url'
export function downloadReceivedRecord(record: ReceivedFileRecord): void {
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' })
saveAs(blob, `${record.filename}.txt`)
}
}
+60
View File
@@ -0,0 +1,60 @@
import JSZip from 'jszip'
const SMALL_FILE_HASH_LIMIT = 10 * 1024 * 1024
const LARGE_FILE_HASH_CHUNK_SIZE = 5 * 1024 * 1024
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<string> => {
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<string> => {
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<File> => {
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' })
}
+20
View File
@@ -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)
}
+99
View File
@@ -0,0 +1,99 @@
import type { ApiResponse, SendType, SentFileRecord } from '@/types'
type Translate = (key: string, params?: Record<string, string | number>) => 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<string, number> = {
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
}
}
+17
View File
@@ -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)
}
}
+38
View File
@@ -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}"`
}
+39 -416
View File
@@ -42,24 +42,23 @@
<FileDetailModal <FileDetailModal
:visible="!!selectedRecord" :visible="!!selectedRecord"
:record="selectedRecord" :record="selectedRecord"
@close="selectedRecord = null" @close="closeDetails"
@show-content-preview="showContentPreview" @preview-content="showContentPreview"
:get-download-url="getDownloadUrl"
:get-qr-code-value="getQRCodeValue"
/> />
<ContentPreviewModal <ContentPreviewModal
:visible="showPreview" :visible="showPreview"
:rendered-content="renderedContent" :rendered-content="renderedContent"
@close="showPreview = false" @close="closeContentPreview"
@copy-content="copyContent" @copy-content="copyContent"
/> />
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, inject, onMounted, watch } from 'vue' import { inject, onMounted, watch } from 'vue'
import { useI18n } from 'vue-i18n' import { storeToRefs } from 'pinia'
import { useRoute, useRouter } from 'vue-router'
import PageHeader from '@/components/common/PageHeader.vue' import PageHeader from '@/components/common/PageHeader.vue'
import RetrieveForm from '@/components/common/RetrieveForm.vue' import RetrieveForm from '@/components/common/RetrieveForm.vue'
import PageFooter from '@/components/common/PageFooter.vue' import PageFooter from '@/components/common/PageFooter.vue'
@@ -67,424 +66,48 @@ import SideDrawer from '@/components/common/SideDrawer.vue'
import FileDetailModal from '@/components/common/FileDetailModal.vue' import FileDetailModal from '@/components/common/FileDetailModal.vue'
import FileRecordList from '@/components/common/FileRecordList.vue' import FileRecordList from '@/components/common/FileRecordList.vue'
import ContentPreviewModal from '@/components/common/ContentPreviewModal.vue' import ContentPreviewModal from '@/components/common/ContentPreviewModal.vue'
import { useRetrieveFlow } from '@/composables'
import { useConfigStore } from '@/stores/configStore'
// 定义数据接口
interface FileRecord {
id: number
code: string
filename: string
size: string
downloadUrl: string | null
content: string | null
date: string
}
interface InputStatus {
readonly: boolean
loading: boolean
}
import { useRouter, useRoute } from 'vue-router'
import { useFileDataStore } from '@/stores/fileData'
import { storeToRefs } from 'pinia'
import api from '@/utils/api'
import type { ApiResponse } from '@/types'
interface RetrieveResponse {
code: string
name: string
text: string
size: number
}
import { saveAs } from 'file-saver'
import { marked } from 'marked'
import DOMPurify from 'dompurify'
import { useAlertStore } from '@/stores/alertStore'
import { copyToClipboard } from '@/utils/clipboard'
const { t } = useI18n()
const alertStore = useAlertStore()
const baseUrl = window.location.origin
const router = useRouter()
const isDarkMode = inject('isDarkMode') const isDarkMode = inject('isDarkMode')
const fileStore = useFileDataStore()
const { receiveData } = storeToRefs(fileStore)
const code = ref('')
const inputStatus = ref<InputStatus>({
readonly: false,
loading: false
})
const error = ref('')
const selectedRecord = ref<FileRecord | null>(null)
const showDrawer = ref(false)
const route = useRoute() const route = useRoute()
const router = useRouter()
// 使用 receiveData 替代原来的 records const configStore = useConfigStore()
const records = receiveData const { config } = storeToRefs(configStore)
const config = JSON.parse(localStorage.getItem('config') || '{}') const {
const codeInput = ref<HTMLInputElement | null>(null) code,
inputStatus,
onMounted(() => { error,
if (codeInput.value) { records,
codeInput.value.focus() selectedRecord,
} showDrawer,
const query_code = route.query.code showPreview,
if (query_code && typeof query_code === 'string') { renderedContent,
code.value = query_code closeContentPreview,
} closeDetails,
}) copyContent,
watch(code, (newVal) => { deleteRecord,
if (newVal.length === 5) { downloadRecord,
handleSubmit() handleSubmit,
} showContentPreview,
}) toggleDrawer,
viewDetails
const getDownloadUrl = (record: FileRecord) => { } = useRetrieveFlow()
if (record.downloadUrl) {
if (record.downloadUrl.startsWith('http')) {
return record.downloadUrl
} else {
return `${baseUrl}${record.downloadUrl}`
}
}
return ''
}
// 在其他代码后添加复制功能
const copyContent = async () => {
if (selectedRecord.value && selectedRecord.value.content) {
await copyToClipboard(selectedRecord.value.content, {
successMsg: t('fileRecord.contentCopied'),
errorMsg: t('fileRecord.copyFailed')
})
}
}
const handleSubmit = async () => {
if (code.value.length !== 5) {
alertStore.showAlert(t('retrieve.messages.invalidCode'), 'error')
return
}
inputStatus.value.readonly = true
inputStatus.value.loading = true
try {
const response = await api.post('/share/select/', {
code: code.value
})
const res = (response.data || response) as ApiResponse<RetrieveResponse>
if (res && res.code === 200) {
if (res.detail) {
const isFile = res.detail.text.startsWith('/share/download') || res.detail.name !== 'Text'
const newFileData = {
id: Date.now(),
code: res.detail.code,
filename: res.detail.name,
size: formatFileSize(res.detail.size),
downloadUrl: isFile ? res.detail.text : null,
content: isFile ? null : res.detail.text,
date: new Date().toLocaleString()
}
let flag = true
fileStore.receiveData.forEach((file: FileRecord) => {
if (file.code === newFileData.code) {
flag = false
}
})
if (flag) {
fileStore.addReceiveData(newFileData)
}
if (isFile) {
selectedRecord.value = newFileData
} else {
selectedRecord.value = newFileData
showPreview.value = true
}
alertStore.showAlert(t('retrieve.messages.retrieveSuccess'), 'success')
} else {
alertStore.showAlert(t('retrieve.messages.invalidCodeError'), 'error')
}
} else {
alertStore.showAlert(t('retrieve.messages.retrieveFailure') + res.detail, 'error')
}
} catch (err: unknown) {
console.error('Retrieve failed:', err)
const error = err as { response?: { data?: { detail?: string } }; message?: string }
const errorMessage = error?.response?.data?.detail || error?.message || t('retrieve.messages.unknownError')
alertStore.showAlert(t('retrieve.messages.networkError') + errorMessage, 'error')
} finally {
inputStatus.value.readonly = false
inputStatus.value.loading = false
code.value = ''
}
}
const formatFileSize = (bytes: number) => {
if (bytes === 0) return '0 ' + t('fileSize.bytes')
const k = 1024
const sizes = [t('fileSize.bytes'), t('fileSize.kb'), t('fileSize.mb'), t('fileSize.gb'), t('fileSize.tb')]
const i = Math.floor(Math.log(bytes) / Math.log(k))
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
}
const viewDetails = (record: FileRecord) => {
selectedRecord.value = record
}
const deleteRecord = (id: number) => {
const index = records.value.findIndex((record: FileRecord) => record.id === id)
if (index !== -1) {
fileStore.deleteReceiveData(index)
}
}
const toggleDrawer = () => {
showDrawer.value = !showDrawer.value
}
const toSend = () => { const toSend = () => {
router.push('/send') router.push('/send')
} }
const getQRCodeValue = (record: FileRecord) => { onMounted(() => {
if (record.downloadUrl) { const queryCode = route.query.code
return `${baseUrl}${record.downloadUrl}` if (queryCode && typeof queryCode === 'string') {
} else { code.value = queryCode
return `${baseUrl}?code=${record.code}`
} }
} })
const downloadRecord = (record: FileRecord) => { watch(code, (newCode) => {
if (record.downloadUrl) { if (newCode.length === 5) {
// 如果是文件,直接下载 void handleSubmit()
window.open(
`${record.downloadUrl.startsWith('http') ? '' : baseUrl}${record.downloadUrl}`,
'_blank'
)
} else if (record.content) {
// 如果是文本,转成txt下载
const blob = new Blob([record.content], { type: 'text/plain;charset=utf-8' })
saveAs(blob, `${record.filename}.txt`)
} }
} })
const showPreview = ref(false)
const renderedContent = ref('')
// 监听selectedRecord变化,异步渲染内容
watch(
() => selectedRecord.value?.content,
async (content) => {
if (content) {
try {
// 使用 marked 解析 Markdown,然后用 DOMPurify 清理 HTML 防止 XSS
const rawHtml = await marked(content)
renderedContent.value = DOMPurify.sanitize(rawHtml, {
// 允许的标签和属性
ALLOWED_TAGS: [
'p',
'br',
'strong',
'em',
'u',
'h1',
'h2',
'h3',
'h4',
'h5',
'h6',
'ul',
'ol',
'li',
'blockquote',
'code',
'pre',
'a',
'img'
],
ALLOWED_ATTR: ['href', 'src', 'alt', 'title', 'class'],
// 禁用危险的协议
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)
renderedContent.value = content // fallback 到原始内容
}
} else {
renderedContent.value = ''
}
},
{ immediate: true }
)
const showContentPreview = () => {
showPreview.value = true
}
</script> </script>
<style scoped>
@keyframes blob {
0%,
100% {
transform: translate(0, 0) scale(1);
}
25% {
transform: translate(20px, -50px) scale(1.1);
}
50% {
transform: translate(-20px, 20px) scale(0.9);
}
75% {
transform: translate(50px, 50px) scale(1.05);
}
}
@media (min-width: 640px) {
.sm\:w-120 {
width: 30rem;
/* 480px */
}
}
.animate-spin-slow {
animation: spin 8s linear infinite;
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.w-97-100 {
width: 97%;
}
/* 添加 Markdown 样式 */
:deep(.prose) {
text-align: left;
word-wrap: break-word;
overflow-wrap: break-word;
word-break: break-word;
}
:deep(.prose h1),
:deep(.prose h2),
:deep(.prose h3),
:deep(.prose h4),
:deep(.prose h5),
:deep(.prose h6) {
color: rgb(79, 70, 229);
/* text-indigo-600 */
word-wrap: break-word;
overflow-wrap: break-word;
}
:deep(.prose p),
:deep(.prose div),
:deep(.prose span),
:deep(.prose code),
:deep(.prose pre) {
word-wrap: break-word;
overflow-wrap: break-word;
word-break: break-word;
}
:deep(.prose pre) {
white-space: pre-wrap;
overflow-x: auto;
}
:deep(.prose code) {
white-space: pre-wrap;
}
@media (prefers-color-scheme: dark) {
:deep(.prose h1),
:deep(.prose h2),
:deep(.prose h3),
:deep(.prose h4),
:deep(.prose h5),
:deep(.prose h6) {
color: rgb(129, 140, 248);
/* text-indigo-400 */
}
}
/* 添加新的宽度类 */
@media (min-width: 640px) {
.sm\:w-120 {
width: 30rem;
/* 480px */
}
}
/* 自定义滚动条样式 */
.custom-scrollbar {
scrollbar-width: thin;
scrollbar-color: rgba(156, 163, 175, 0.3) rgba(243, 244, 246, 0.5);
}
.custom-scrollbar::-webkit-scrollbar {
width: 6px;
height: 6px;
}
.custom-scrollbar::-webkit-scrollbar-track {
background: rgba(243, 244, 246, 0.5);
border-radius: 3px;
}
.custom-scrollbar::-webkit-scrollbar-thumb {
background-color: rgba(156, 163, 175, 0.5);
border-radius: 3px;
transition: background-color 0.3s;
}
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
background-color: rgba(156, 163, 175, 0.7);
}
/* 深色模式下的滚动条样式 */
:deep([class*='dark']) .custom-scrollbar {
scrollbar-color: rgba(75, 85, 99, 0.5) rgba(31, 41, 55, 0.5);
}
:deep([class*='dark']) .custom-scrollbar::-webkit-scrollbar-track {
background: rgba(31, 41, 55, 0.5);
}
:deep([class*='dark']) .custom-scrollbar::-webkit-scrollbar-thumb {
background-color: rgba(75, 85, 99, 0.5);
}
:deep([class*='dark']) .custom-scrollbar::-webkit-scrollbar-thumb:hover {
background-color: rgba(75, 85, 99, 0.7);
}
/* 确保滚动容器有背景色 */
.custom-scrollbar {
background: inherit;
}
/* 文本换行相关样式 */
.break-words {
word-wrap: break-word;
overflow-wrap: break-word;
word-break: break-word;
}
.overflow-wrap-anywhere {
overflow-wrap: anywhere;
}
</style>
+60 -1085
View File
File diff suppressed because it is too large Load Diff
+342 -82
View File
@@ -1,134 +1,394 @@
<template> <template>
<div class="p-6 overflow-y-auto custom-scrollbar"> <div class="p-6 overflow-y-auto custom-scrollbar">
<h2 class="text-2xl font-bold mb-6" :class="[isDarkMode ? 'text-white' : 'text-gray-800']"> <div class="mb-6 flex flex-col gap-2 sm:flex-row sm:items-end sm:justify-between">
{{ t('admin.dashboard.title') }} <div>
</h2> <p class="text-sm" :class="[mutedTextClass]">FileCodeBox Admin</p>
<!-- 统计卡片区域 --> <h2 class="text-2xl font-bold" :class="[primaryTextClass]">
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8"> {{ t('admin.dashboard.title') }}
</h2>
</div>
<button
type="button"
@click="fetchDashboardData"
class="inline-flex items-center justify-center rounded-lg px-4 py-2 text-sm font-medium transition-colors"
:class="[
isDarkMode
? 'bg-gray-800 text-gray-200 hover:bg-gray-700'
: 'bg-white text-gray-700 shadow-sm hover:bg-gray-50'
]"
>
<RefreshCwIcon class="mr-2 h-4 w-4" />
{{ t('admin.dashboard.refresh') }}
</button>
</div>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-4">
<StatCard <StatCard
:title="t('admin.dashboard.totalFiles')" :title="t('admin.dashboard.totalFiles')"
:value="dashboardData.totalFiles" :value="dashboardData.totalFiles"
:icon="FileIcon" :icon="FilesIcon"
icon-color="indigo" icon-color="indigo"
description-type="success"> >
<template #description> <template #description>
<span>{{ t('admin.dashboard.yesterday') }}{{ dashboardData.yesterdayCount }}</span> {{ t('admin.dashboard.yesterdayShares', { count: dashboardData.yesterdayCount }) }}
<span class="ml-2">{{ t('admin.dashboard.today') }}{{ dashboardData.todayCount }}</span>
</template> </template>
</StatCard> </StatCard>
<StatCard <StatCard
:title="t('admin.dashboard.storageSpace')" :title="t('admin.dashboard.storageSpace')"
:value="dashboardData.storageUsed" :value="dashboardData.storageUsedText"
:icon="HardDriveIcon" :icon="HardDriveIcon"
icon-color="purple" icon-color="purple"
description-type="success"> >
<template #description> <template #description>
<span>{{ t('admin.dashboard.yesterday') }}{{ dashboardData.yesterdaySize }}</span> {{ t('admin.dashboard.todayIncrease', { count: dashboardData.todaySizeText }) }}
<span class="ml-2">{{ t('admin.dashboard.today') }}{{ dashboardData.todaySize }}</span>
</template> </template>
</StatCard> </StatCard>
<StatCard <StatCard
:title="t('admin.dashboard.activeUsers')" :title="t('admin.dashboard.todayShares')"
value="25" :value="dashboardData.todayCount"
:icon="UsersIcon" :icon="UploadCloudIcon"
icon-color="green" icon-color="green"
description-type="error"> >
<template #description> <template #description>
<span>{{ t('admin.dashboard.weeklyChange') }}</span> {{ t('admin.dashboard.yesterdayShares', { count: dashboardData.yesterdayCount }) }}
</template> </template>
</StatCard> </StatCard>
<StatCard <StatCard
:title="t('admin.dashboard.systemStatus')" :title="t('admin.dashboard.totalRetrievals')"
:value="t('admin.dashboard.normal')" :value="dashboardData.usedCount"
:icon="ActivityIcon" :icon="DownloadCloudIcon"
icon-color="blue" icon-color="blue"
description-type="neutral"> >
<template #description> <template #description>
{{ t('admin.dashboard.serverUptime') }}: {{ dashboardData.sysUptime }} {{ t('admin.dashboard.serverUptime') }} {{ dashboardData.sysUptimeText }}
</template> </template>
</StatCard> </StatCard>
</div> </div>
<!-- 添加版本和版权信息 --> <div v-if="dashboardData.hasExtendedStats" class="mt-6 grid grid-cols-1 gap-6 xl:grid-cols-3">
<div class="mt-auto text-center py-4" :class="[isDarkMode ? 'text-gray-400' : 'text-gray-600']"> <section class="xl:col-span-2 rounded-lg p-5 shadow-sm" :class="[panelClass]">
<p class="text-sm"> <div class="mb-5 flex items-center justify-between">
版本 v2.2.1 更新时间2025-09-04 <div>
</p> <h3 class="text-lg font-semibold" :class="[primaryTextClass]">
<p class="text-sm mt-1"> {{ t('admin.dashboard.fileHealth') }}
© {{ new Date().getFullYear() }} <a href="https://github.com/vastsa/FileCodeBox">FileCodeBox</a> </h3>
</p> <p class="text-sm" :class="[mutedTextClass]">
{{ t('admin.dashboard.fileHealthDesc') }}
</p>
</div>
<ActivityIcon class="h-5 w-5" :class="[isDarkMode ? 'text-indigo-300' : 'text-indigo-500']" />
</div>
<div class="grid grid-cols-1 gap-4 md:grid-cols-3">
<MetricProgress
:label="t('admin.dashboard.activeFileRatio')"
:value="dashboardData.activeRatio"
:detail="`${dashboardData.activeCount} / ${dashboardData.totalFiles}`"
tone="green"
/>
<MetricProgress
:label="t('admin.dashboard.fileShareRatio')"
:value="dashboardData.fileRatio"
:detail="t('admin.dashboard.binaryFiles', { count: dashboardData.fileCount })"
tone="indigo"
/>
<MetricProgress
:label="t('admin.dashboard.textShareRatio')"
:value="dashboardData.textRatio"
:detail="t('admin.dashboard.textShares', { count: dashboardData.textCount })"
tone="purple"
/>
</div>
<div class="mt-6 grid grid-cols-1 gap-4 md:grid-cols-2">
<div class="rounded-lg border p-4" :class="[subtlePanelClass]">
<p class="text-sm" :class="[mutedTextClass]">
{{ t('admin.dashboard.expiredFiles') }}
</p>
<div class="mt-2 flex items-end justify-between">
<strong class="text-3xl" :class="[primaryTextClass]">
{{ dashboardData.expiredCount }}
</strong>
<span class="text-sm" :class="[mutedTextClass]">
{{ t('admin.dashboard.needCleanup') }}
</span>
</div>
</div>
<div class="rounded-lg border p-4" :class="[subtlePanelClass]">
<p class="text-sm" :class="[mutedTextClass]">
{{ t('admin.dashboard.chunkedFiles') }}
</p>
<div class="mt-2 flex items-end justify-between">
<strong class="text-3xl" :class="[primaryTextClass]">
{{ dashboardData.chunkedCount }}
</strong>
<span class="text-sm" :class="[mutedTextClass]">
{{ dashboardData.enableChunk ? t('common.enabled') : t('common.disabled') }}
</span>
</div>
</div>
</div>
</section>
<section class="rounded-lg p-5 shadow-sm" :class="[panelClass]">
<div class="mb-5">
<h3 class="text-lg font-semibold" :class="[primaryTextClass]">
{{ t('admin.dashboard.storagePolicy') }}
</h3>
<p class="text-sm" :class="[mutedTextClass]">
{{ t('admin.dashboard.storagePolicyDesc') }}
</p>
</div>
<div class="space-y-4">
<PolicyRow
:label="t('admin.dashboard.storageBackend')"
:value="dashboardData.storageBackend"
/>
<PolicyRow
:label="t('admin.dashboard.singleFileLimit')"
:value="dashboardData.uploadSizeLimitText"
/>
<PolicyRow
:label="t('admin.dashboard.guestUpload')"
:value="dashboardData.openUpload ? t('common.enabled') : t('common.disabled')"
/>
<PolicyRow
:label="t('admin.dashboard.maxSaveTime')"
:value="maxSaveTimeText"
/>
</div>
<div class="mt-5">
<div class="mb-2 flex items-center justify-between text-sm">
<span :class="[mutedTextClass]">{{ t('admin.dashboard.todayCapacityReference') }}</span>
<span :class="[primaryTextClass]">{{ dashboardData.todaySizeRatio }}%</span>
</div>
<div class="h-2 overflow-hidden rounded-full" :class="[isDarkMode ? 'bg-gray-700' : 'bg-gray-100']">
<div
class="h-full rounded-full bg-indigo-500"
:style="{ width: `${dashboardData.todaySizeRatio}%` }"
></div>
</div>
</div>
</section>
</div>
<div v-if="dashboardData.hasExtendedStats" class="mt-6 grid grid-cols-1 gap-6 xl:grid-cols-3">
<section class="rounded-lg p-5 shadow-sm" :class="[panelClass]">
<h3 class="text-lg font-semibold" :class="[primaryTextClass]">
{{ t('admin.dashboard.fileTypeDistribution') }}
</h3>
<div class="mt-4 space-y-3">
<div v-if="dashboardData.topSuffixes.length === 0" class="text-sm" :class="[mutedTextClass]">
{{ t('common.noData') }}
</div>
<div v-for="item in dashboardData.topSuffixes" :key="item.suffix" class="space-y-1">
<div class="flex items-center justify-between text-sm">
<span :class="[primaryTextClass]">{{ item.suffix || t('admin.dashboard.textType') }}</span>
<span :class="[mutedTextClass]">{{ item.count }}</span>
</div>
<div class="h-2 overflow-hidden rounded-full" :class="[isDarkMode ? 'bg-gray-700' : 'bg-gray-100']">
<div
class="h-full rounded-full bg-purple-500"
:style="{ width: `${getSuffixRatio(item.count)}%` }"
></div>
</div>
</div>
</div>
</section>
<section class="xl:col-span-2 rounded-lg p-5 shadow-sm" :class="[panelClass]">
<div class="mb-4 flex items-center justify-between">
<div>
<h3 class="text-lg font-semibold" :class="[primaryTextClass]">
{{ t('admin.dashboard.recentFiles') }}
</h3>
<p class="text-sm" :class="[mutedTextClass]">
{{ t('admin.dashboard.recentFilesDesc') }}
</p>
</div>
</div>
<div class="overflow-hidden rounded-lg border" :class="[isDarkMode ? 'border-gray-700' : 'border-gray-200']">
<table class="min-w-full divide-y" :class="[isDarkMode ? 'divide-gray-700' : 'divide-gray-200']">
<thead :class="[isDarkMode ? 'bg-gray-900/50' : 'bg-gray-50']">
<tr>
<th class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider" :class="[mutedTextClass]">
{{ t('admin.dashboard.table.file') }}
</th>
<th class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider" :class="[mutedTextClass]">
{{ t('admin.dashboard.table.size') }}
</th>
<th class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider" :class="[mutedTextClass]">
{{ t('admin.dashboard.table.usage') }}
</th>
<th class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider" :class="[mutedTextClass]">
{{ t('admin.dashboard.table.status') }}
</th>
</tr>
</thead>
<tbody class="divide-y" :class="[isDarkMode ? 'divide-gray-700' : 'divide-gray-100']">
<tr v-if="dashboardData.recentFiles.length === 0">
<td colspan="4" class="px-4 py-6 text-center text-sm" :class="[mutedTextClass]">
{{ t('common.noData') }}
</td>
</tr>
<tr v-for="file in dashboardData.recentFiles" :key="file.id">
<td class="px-4 py-3">
<div class="flex items-center gap-3">
<div class="rounded-lg p-2" :class="[isDarkMode ? 'bg-gray-700' : 'bg-gray-100']">
<FileTextIcon v-if="file.text" class="h-4 w-4" :class="[mutedTextClass]" />
<FileIcon v-else class="h-4 w-4" :class="[mutedTextClass]" />
</div>
<div class="min-w-0">
<p class="truncate text-sm font-medium" :class="[primaryTextClass]">
{{ file.name || file.code }}
</p>
<p class="text-xs" :class="[mutedTextClass]">
{{ file.code }} · {{ formatCreatedAt(file.createdAt) }}
</p>
</div>
</div>
</td>
<td class="px-4 py-3 text-sm" :class="[primaryTextClass]">
{{ formatFileSize(file.size) }}
</td>
<td class="px-4 py-3 text-sm" :class="[primaryTextClass]">
{{ file.usedCount }} {{ t('common.times') }}
</td>
<td class="px-4 py-3">
<span
class="inline-flex rounded-full px-2.5 py-1 text-xs font-medium"
:class="[
file.isExpired
? isDarkMode
? 'bg-red-900/40 text-red-300'
: 'bg-red-100 text-red-700'
: isDarkMode
? 'bg-green-900/40 text-green-300'
: 'bg-green-100 text-green-700'
]"
>
{{ file.isExpired ? t('common.expiredFile') : t('admin.dashboard.available') }}
</span>
</td>
</tr>
</tbody>
</table>
</div>
</section>
</div> </div>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { inject, onMounted, reactive } from 'vue' import { computed, defineComponent, h, onMounted } from 'vue'
import type { PropType } from 'vue'
import { import {
FileIcon,
HardDriveIcon,
UsersIcon,
ActivityIcon, ActivityIcon,
DownloadCloudIcon,
FileIcon,
FilesIcon,
FileTextIcon,
HardDriveIcon,
RefreshCwIcon,
UploadCloudIcon
} from 'lucide-vue-next' } from 'lucide-vue-next'
import { StatsService } from '@/services'
import type { DashboardData } from '@/types'
import StatCard from '@/components/common/StatCard.vue' import StatCard from '@/components/common/StatCard.vue'
import { useDashboardStats, useInjectedDarkMode } from '@/composables'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
const isDarkMode = inject('isDarkMode') import { formatFileSize, formatTimestamp } from '@/utils/common'
const { t } = useI18n()
const dashboardData = reactive<DashboardData>({ const isDarkMode = useInjectedDarkMode()
totalFiles: 0, const { t } = useI18n()
storageUsed: 0, const { dashboardData, fetchDashboardData } = useDashboardStats()
yesterdayCount: 0,
todayCount: 0, const primaryTextClass = computed(() => (isDarkMode.value ? 'text-white' : 'text-gray-900'))
yesterdaySize: 0, const mutedTextClass = computed(() => (isDarkMode.value ? 'text-gray-400' : 'text-gray-500'))
todaySize: 0, const panelClass = computed(() =>
sysUptime: 0 isDarkMode.value ? 'bg-gray-800/80 border border-gray-700' : 'bg-white border border-gray-100'
)
const subtlePanelClass = computed(() =>
isDarkMode.value ? 'border-gray-700 bg-gray-900/30' : 'border-gray-100 bg-gray-50'
)
const maxSuffixCount = computed(() =>
Math.max(...dashboardData.topSuffixes.map((item) => item.count), 1)
)
const maxSaveTimeText = computed(() => {
if (!dashboardData.maxSaveSeconds) return t('admin.dashboard.noSaveLimit')
const days = Math.floor(dashboardData.maxSaveSeconds / 86400)
if (days >= 1) return `${days}${t('common.day')}`
const hours = Math.floor(dashboardData.maxSaveSeconds / 3600)
if (hours >= 1) return `${hours}${t('common.hour')}`
return `${Math.floor(dashboardData.maxSaveSeconds / 60)}${t('common.minute')}`
}) })
const getSysUptime = (startTimestamp: number) => { const getSuffixRatio = (count: number) => Math.round((count / maxSuffixCount.value) * 100)
const now = new Date().getTime()
const uptime = now - startTimestamp const formatCreatedAt = (value: string | null) => {
const days = Math.floor(uptime / (24 * 60 * 60 * 1000)) if (!value) return '-'
const hours = Math.floor((uptime % (24 * 60 * 60 * 1000)) / (60 * 60 * 1000)) return formatTimestamp(value, 'datetime')
return `${days}${hours}小时`
} }
const getLocalstorageUsed = (nowUsedBit: string) => { const MetricProgress = defineComponent({
const kb = parseInt(nowUsedBit) / 1024 name: 'MetricProgress',
const mb = kb / 1024 props: {
const gb = mb / 1024 label: { type: String, required: true },
const tb = gb / 1024 value: { type: Number, required: true },
// 根据大小选择合适的单位 detail: { type: String, required: true },
if (tb > 1) { tone: {
return `${tb.toFixed(2)}TB` type: String as PropType<'green' | 'indigo' | 'purple'>,
} else if (gb > 1) { required: true
return `${gb.toFixed(2)}GB` }
} else if (mb > 1) { },
return `${mb.toFixed(2)}MB` setup(props) {
} else if (kb > 1) { const toneClass = computed(() => {
return `${kb.toFixed(2)}KB` const classes = {
} else { green: 'bg-green-500',
return `${nowUsedBit}B` indigo: 'bg-indigo-500',
purple: 'bg-purple-500'
}
return classes[props.tone]
})
return () =>
h('div', { class: 'rounded-lg border p-4 border-gray-200/60 dark:border-gray-700' }, [
h('div', { class: 'mb-2 flex items-center justify-between text-sm' }, [
h('span', { class: 'text-gray-500 dark:text-gray-400' }, props.label),
h('span', { class: 'font-medium text-gray-900 dark:text-white' }, `${props.value}%`)
]),
h('div', { class: 'h-2 overflow-hidden rounded-full bg-gray-100 dark:bg-gray-700' }, [
h('div', {
class: ['h-full rounded-full', toneClass.value],
style: { width: `${props.value}%` }
})
]),
h('p', { class: 'mt-2 text-sm text-gray-500 dark:text-gray-400' }, props.detail)
])
} }
} })
const getDashboardData = async () => {
const response = await StatsService.getDashboard() const PolicyRow = defineComponent({
if (response.detail) { name: 'PolicyRow',
dashboardData.totalFiles = response.detail.totalFiles props: {
dashboardData.storageUsed = getLocalstorageUsed(response.detail.storageUsed.toString()) label: { type: String, required: true },
dashboardData.yesterdaySize = getLocalstorageUsed(response.detail.yesterdaySize.toString()) value: { type: String, required: true }
dashboardData.todaySize = getLocalstorageUsed(response.detail.todaySize.toString()) },
dashboardData.yesterdayCount = response.detail.yesterdayCount setup(props) {
dashboardData.todayCount = response.detail.todayCount return () =>
dashboardData.sysUptime = getSysUptime(Number(response.detail.sysUptime)) h('div', { class: 'flex items-center justify-between gap-4 border-b border-gray-200/60 pb-3 last:border-b-0 dark:border-gray-700' }, [
h('span', { class: 'text-sm text-gray-500 dark:text-gray-400' }, props.label),
h('span', { class: 'text-sm font-medium text-gray-900 dark:text-white' }, props.value)
])
} }
} })
onMounted(() => { onMounted(() => {
getDashboardData() void fetchDashboardData()
}) })
</script> </script>
+54 -266
View File
@@ -61,7 +61,7 @@
<td class="px-6 py-4 whitespace-nowrap"> <td class="px-6 py-4 whitespace-nowrap">
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium" <span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium"
:class="[isDarkMode ? 'bg-gray-700 text-gray-300' : 'bg-gray-100 text-gray-800']"> :class="[isDarkMode ? 'bg-gray-700 text-gray-300' : 'bg-gray-100 text-gray-800']">
{{ Math.round((file.size / 1024 / 1024) * 100) / 100 }}MB {{ file.displaySize }}
</span> </span>
</td> </td>
<td class="px-6 py-4"> <td class="px-6 py-4">
@@ -71,8 +71,8 @@
</span> </span>
<!-- 查看全文按钮 - 仅当文本超过一定长度时显示 --> <!-- 查看全文按钮 - 仅当文本超过一定长度时显示 -->
<button <button
v-if="file.text && file.text.length > 30" v-if="file.canPreviewText"
@click="openTextPreview(file.text)" @click="openTextPreview(file.text || '')"
class="flex-shrink-0 inline-flex items-center px-2 py-1 rounded text-xs transition-colors duration-200" class="flex-shrink-0 inline-flex items-center px-2 py-1 rounded text-xs transition-colors duration-200"
:class="[ :class="[
isDarkMode isDarkMode
@@ -91,7 +91,7 @@
? (isDarkMode ? 'bg-yellow-900/30 text-yellow-400' : 'bg-yellow-100 text-yellow-800') ? (isDarkMode ? 'bg-yellow-900/30 text-yellow-400' : 'bg-yellow-100 text-yellow-800')
: (isDarkMode ? 'bg-green-900/30 text-green-400' : 'bg-green-100 text-green-800') : (isDarkMode ? 'bg-green-900/30 text-green-400' : 'bg-green-100 text-green-800')
]"> ]">
{{ file.expired_at ? formatTimestamp(file.expired_at) : t('send.expiration.units.forever') }} {{ file.displayExpiredAt }}
</span> </span>
</td> </td>
<td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium"> <td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
@@ -167,125 +167,32 @@
<!-- 表单内容 --> <!-- 表单内容 -->
<div class="px-6 py-5"> <div class="px-6 py-5">
<div class="grid gap-6"> <div class="grid gap-6">
<!-- 取件码 --> <FileEditField
<div class="space-y-2 group"> v-model="editForm.code"
<label class="text-sm font-medium flex items-center space-x-2 transition-colors duration-200" :label="t('fileManage.form.code')"
:class="[isDarkMode ? 'text-gray-300 group-focus-within:text-indigo-400' : 'text-gray-700 group-focus-within:text-indigo-600']"> :placeholder="t('fileManage.form.codePlaceholder')"
<span>{{ t('fileManage.form.code') }}</span> />
<div class="h-px flex-1 transition-colors duration-200" <FileEditField
:class="[isDarkMode ? 'bg-gray-700 group-focus-within:bg-indigo-500/50' : 'bg-gray-200 group-focus-within:bg-indigo-500/30']"> v-model="editForm.prefix"
</div> :label="t('fileManage.form.filename')"
</label> :placeholder="t('fileManage.form.filenamePlaceholder')"
<div class="relative rounded-lg shadow-sm"> />
<input type="text" v-model="editForm.code" <FileEditField
class="block w-full rounded-lg border-0 py-2.5 pl-4 pr-10 transition-all duration-200 focus:ring-2 focus:ring-inset sm:text-sm" v-model="editForm.suffix"
:class="[ :label="t('fileManage.form.suffix')"
isDarkMode :placeholder="t('fileManage.form.suffixPlaceholder')"
? 'bg-gray-700/50 text-white placeholder-gray-400 focus:ring-indigo-500/50' />
: 'bg-gray-50 text-gray-900 placeholder-gray-400 focus:ring-indigo-500' <FileEditField
]" :placeholder="t('fileManage.form.codePlaceholder')"> v-model="editForm.expired_at"
<div type="datetime-local"
class="absolute inset-y-0 right-0 flex items-center pr-3 pointer-events-none transition-opacity duration-200 opacity-0 group-focus-within:opacity-100"> :label="t('send.expiration.label')"
<CheckIcon class="w-5 h-5" :class="[isDarkMode ? 'text-indigo-400' : 'text-indigo-600']" /> />
</div> <FileEditField
</div> v-model="editForm.expired_count"
</div> type="number"
:label="t('fileManage.form.downloadLimit')"
<!-- 文件名称 --> :placeholder="t('fileManage.form.downloadLimitPlaceholder')"
<div class="space-y-2 group"> />
<label class="text-sm font-medium flex items-center space-x-2 transition-colors duration-200"
:class="[isDarkMode ? 'text-gray-300 group-focus-within:text-indigo-400' : 'text-gray-700 group-focus-within:text-indigo-600']">
<span>{{ t('fileManage.form.filename') }}</span>
<div class="h-px flex-1 transition-colors duration-200"
:class="[isDarkMode ? 'bg-gray-700 group-focus-within:bg-indigo-500/50' : 'bg-gray-200 group-focus-within:bg-indigo-500/30']">
</div>
</label>
<div class="relative rounded-lg shadow-sm">
<input type="text" v-model="editForm.prefix"
class="block w-full rounded-lg border-0 py-2.5 pl-4 pr-10 transition-all duration-200 focus:ring-2 focus:ring-inset sm:text-sm"
:class="[
isDarkMode
? 'bg-gray-700/50 text-white placeholder-gray-400 focus:ring-indigo-500/50'
: 'bg-gray-50 text-gray-900 placeholder-gray-400 focus:ring-indigo-500'
]" :placeholder="t('fileManage.form.filenamePlaceholder')">
<div
class="absolute inset-y-0 right-0 flex items-center pr-3 pointer-events-none transition-opacity duration-200 opacity-0 group-focus-within:opacity-100">
<CheckIcon class="w-5 h-5" :class="[isDarkMode ? 'text-indigo-400' : 'text-indigo-600']" />
</div>
</div>
</div>
<!-- 文件后缀 -->
<div class="space-y-2 group">
<label class="text-sm font-medium flex items-center space-x-2 transition-colors duration-200"
:class="[isDarkMode ? 'text-gray-300 group-focus-within:text-indigo-400' : 'text-gray-700 group-focus-within:text-indigo-600']">
<span>{{ t('fileManage.form.suffix') }}</span>
<div class="h-px flex-1 transition-colors duration-200"
:class="[isDarkMode ? 'bg-gray-700 group-focus-within:bg-indigo-500/50' : 'bg-gray-200 group-focus-within:bg-indigo-500/30']">
</div>
</label>
<div class="relative rounded-lg shadow-sm">
<input type="text" v-model="editForm.suffix"
class="block w-full rounded-lg border-0 py-2.5 pl-4 pr-10 transition-all duration-200 focus:ring-2 focus:ring-inset sm:text-sm"
:class="[
isDarkMode
? 'bg-gray-700/50 text-white placeholder-gray-400 focus:ring-indigo-500/50'
: 'bg-gray-50 text-gray-900 placeholder-gray-400 focus:ring-indigo-500'
]" :placeholder="t('fileManage.form.suffixPlaceholder')">
<div
class="absolute inset-y-0 right-0 flex items-center pr-3 pointer-events-none transition-opacity duration-200 opacity-0 group-focus-within:opacity-100">
<CheckIcon class="w-5 h-5" :class="[isDarkMode ? 'text-indigo-400' : 'text-indigo-600']" />
</div>
</div>
</div>
<!-- 过期时间 -->
<div class="space-y-2 group">
<label class="text-sm font-medium flex items-center space-x-2 transition-colors duration-200"
:class="[isDarkMode ? 'text-gray-300 group-focus-within:text-indigo-400' : 'text-gray-700 group-focus-within:text-indigo-600']">
<span>{{ t('send.expiration.label') }}</span>
<div class="h-px flex-1 transition-colors duration-200"
:class="[isDarkMode ? 'bg-gray-700 group-focus-within:bg-indigo-500/50' : 'bg-gray-200 group-focus-within:bg-indigo-500/30']">
</div>
</label>
<div class="relative rounded-lg shadow-sm">
<input type="datetime-local" v-model="editForm.expired_at"
class="block w-full rounded-lg border-0 py-2.5 pl-4 pr-10 transition-all duration-200 focus:ring-2 focus:ring-inset sm:text-sm"
:class="[
isDarkMode
? 'bg-gray-700/50 text-white placeholder-gray-400 focus:ring-indigo-500/50'
: 'bg-gray-50 text-gray-900 placeholder-gray-400 focus:ring-indigo-500'
]">
<div
class="absolute inset-y-0 right-0 flex items-center pr-3 pointer-events-none transition-opacity duration-200 opacity-0 group-focus-within:opacity-100">
<CheckIcon class="w-5 h-5" :class="[isDarkMode ? 'text-indigo-400' : 'text-indigo-600']" />
</div>
</div>
</div>
<!-- 下载次数限制 -->
<div class="space-y-2 group">
<label class="text-sm font-medium flex items-center space-x-2 transition-colors duration-200"
:class="[isDarkMode ? 'text-gray-300 group-focus-within:text-indigo-400' : 'text-gray-700 group-focus-within:text-indigo-600']">
<span>{{ t('fileManage.form.downloadLimit') }}</span>
<div class="h-px flex-1 transition-colors duration-200"
:class="[isDarkMode ? 'bg-gray-700 group-focus-within:bg-indigo-500/50' : 'bg-gray-200 group-focus-within:bg-indigo-500/30']">
</div>
</label>
<div class="relative rounded-lg shadow-sm">
<input type="number" v-model="editForm.expired_count"
class="block w-full rounded-lg border-0 py-2.5 pl-4 pr-10 transition-all duration-200 focus:ring-2 focus:ring-inset sm:text-sm"
:class="[
isDarkMode
? 'bg-gray-700/50 text-white placeholder-gray-400 focus:ring-indigo-500/50'
: 'bg-gray-50 text-gray-900 placeholder-gray-400 focus:ring-indigo-500'
]" :placeholder="t('fileManage.form.downloadLimitPlaceholder')">
<div
class="absolute inset-y-0 right-0 flex items-center pr-3 pointer-events-none transition-opacity duration-200 opacity-0 group-focus-within:opacity-100">
<CheckIcon class="w-5 h-5" :class="[isDarkMode ? 'text-indigo-400' : 'text-indigo-600']" />
</div>
</div>
</div>
</div> </div>
</div> </div>
@@ -390,10 +297,8 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { inject, ref, computed } from 'vue' import { inject, computed, onMounted } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { FileService } from '@/services'
import type { FileListItem, FileEditForm } from '@/types'
import { import {
FileIcon, FileIcon,
SearchIcon, SearchIcon,
@@ -406,23 +311,11 @@ import {
} from 'lucide-vue-next' } from 'lucide-vue-next'
import DataTable from '@/components/common/DataTable.vue' import DataTable from '@/components/common/DataTable.vue'
import DataPagination from '@/components/common/DataPagination.vue' import DataPagination from '@/components/common/DataPagination.vue'
import { useAlertStore } from '@/stores/alertStore' import FileEditField from '@/components/common/FileEditField.vue'
import { useAdminFiles } from '@/composables'
function formatTimestamp(timestamp: string): 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')
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`
}
const { t } = useI18n() const { t } = useI18n()
const isDarkMode = inject('isDarkMode') const isDarkMode = inject('isDarkMode')
const tableData = ref<FileListItem[]>([])
const alertStore = useAlertStore()
// 修改文件表头 // 修改文件表头
const fileTableHeaders = computed(() => [ const fileTableHeaders = computed(() => [
t('fileManage.headers.code'), t('fileManage.headers.code'),
@@ -433,133 +326,28 @@ const fileTableHeaders = computed(() => [
t('fileManage.headers.actions') t('fileManage.headers.actions')
]) ])
// 分页参数 const {
const params = ref({ tableData,
page: 1, params,
size: 10, showEditModal,
total: 0, editForm,
keyword: '' showTextPreview,
previewText,
closeEditModal,
closeTextPreview,
copyText,
deleteFile,
handlePageChange,
handleSearch,
handleUpdate,
loadFiles,
openEditModal,
openTextPreview
} = useAdminFiles()
onMounted(() => {
void loadFiles()
}) })
// 添加编辑相关的状态
const showEditModal = ref(false)
const editForm = ref<FileEditForm>({
id: null,
code: '',
prefix: '',
suffix: '',
expired_at: '',
expired_count: null
})
// 文本预览相关状态
const showTextPreview = ref(false)
const previewText = ref('')
// 打开文本预览
const openTextPreview = (text: string) => {
previewText.value = text
showTextPreview.value = true
}
// 关闭文本预览
const closeTextPreview = () => {
showTextPreview.value = false
previewText.value = ''
}
// 复制文本到剪贴板
const copyText = async () => {
try {
await navigator.clipboard.writeText(previewText.value)
alertStore.showAlert(t('fileManage.copySuccess'), 'success')
} catch {
alertStore.showAlert(t('fileManage.copyFailed'), 'error')
}
}
// 打开编辑模态框
const openEditModal = (file: FileListItem) => {
editForm.value = {
id: file.id,
code: file.code,
prefix: file.prefix,
suffix: file.suffix,
expired_at: file.expired_at ? file.expired_at.slice(0, 16) : '', // 格式化日期时间
expired_count: file.expired_count
}
showEditModal.value = true
}
// 关闭编辑模态框
const closeEditModal = () => {
showEditModal.value = false
editForm.value = {
id: null,
code: '',
prefix: '',
suffix: '',
expired_at: '',
expired_count: null
}
}
// 处理更新
const handleUpdate = async () => {
try {
await FileService.updateFile(editForm.value)
await loadFiles()
closeEditModal()
} catch (error: unknown) {
const err = error as { response?: { data?: { detail?: string } } }
alertStore.showAlert(err.response?.data?.detail || t('manage.fileManage.updateFailed'), 'error')
}
}
// 删除文件处理
const deleteFile = async (id: number) => {
try {
await FileService.deleteAdminFile(id)
await loadFiles()
} catch (error) {
console.error(t('manage.fileManage.deleteFailed'), error)
}
}
// 加载文件列表
const loadFiles = async () => {
try {
const res = await FileService.getAdminFileList(params.value)
if (res.detail) {
tableData.value = res.detail.data
params.value.total = res.detail.total
}
} catch (error) {
console.error(t('manage.fileManage.loadFileListFailed'), error)
}
}
// 页码改变处理函数
const handlePageChange = async (page: number | string) => {
if (typeof page === 'string') return // 忽略省略号
if (page < 1 || page > totalPages.value) return
params.value.page = page
await loadFiles()
}
// 初始加载
loadFiles()
// 计算总页数
const totalPages = computed(() => Math.ceil(params.value.total / params.value.size))
// 添加搜索处理函数
const handleSearch = async () => {
params.value.page = 1 // 重置页码到第一页
await loadFiles()
}
</script> </script>
<style> <style>
+30 -47
View File
@@ -39,8 +39,19 @@
登录 登录
</h2> </h2>
</div> </div>
<form class="mt-8 space-y-6" @submit.prevent="handleSubmit"> <form class="mt-8 space-y-6" @submit.prevent="submitLogin">
<input type="hidden" name="remember" value="true" /> <input type="hidden" name="remember" value="true" />
<label for="username" class="sr-only">用户名</label>
<input
id="username"
name="username"
type="text"
autocomplete="username"
value="admin"
readonly
tabindex="-1"
class="sr-only"
/>
<div class="rounded-md shadow-sm -space-y-px"> <div class="rounded-md shadow-sm -space-y-px">
<div> <div>
<label for="password" class="sr-only">密码</label> <label for="password" class="sr-only">密码</label>
@@ -83,57 +94,29 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, inject } from 'vue' import { inject } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { BoxIcon } from 'lucide-vue-next' import { BoxIcon } from 'lucide-vue-next'
import { useAlertStore } from '@/stores/alertStore' import { useAdminLogin } from '@/composables'
import { useAdminData } from '@/stores/adminStore' import { ROUTES } from '@/constants'
import { useRouter } from 'vue-router'
import { AuthService } from '@/services'
const alertStore = useAlertStore()
const password = ref('')
const isLoading = ref(false)
const isDarkMode = inject('isDarkMode') const isDarkMode = inject('isDarkMode')
const adminStore = useAdminData() const router = useRouter()
const validateForm = () => { const route = useRoute()
let isValid = true const { password, isLoading, handleSubmit } = useAdminLogin()
if (!password.value) {
alertStore.showAlert('无效的密码', 'error') const getRedirectPath = () => {
isValid = false const redirect = route.query.redirect
} else if (password.value.length < 6) { if (typeof redirect === 'string' && redirect.startsWith('/')) {
alertStore.showAlert('密码长度至少为6位', 'error') return redirect
isValid = false
} }
return isValid return ROUTES.ADMIN
} }
const router = useRouter() const submitLogin = async () => {
const success = await handleSubmit()
const handleSubmit = async () => { if (success) {
if (!validateForm()) return await router.push(getRedirectPath())
isLoading.value = true
try {
const response = await AuthService.login(password.value)
if (response.detail?.token) {
adminStore.setToken(response.detail.token)
router.push('/admin')
} else {
alertStore.showAlert('登录失败:未获取到有效令牌', 'error')
}
} catch (error: unknown) {
interface ErrorWithResponse {
response?: {
data?: {
detail?: string
}
}
}
const errorMessage = error && typeof error === 'object' && 'response' in error
? (error as ErrorWithResponse).response?.data?.detail || '登录失败'
: '登录失败'
alertStore.showAlert(errorMessage, 'error')
} finally {
isLoading.value = false
} }
} }
</script> </script>
+66 -289
View File
@@ -1,152 +1,26 @@
<script setup lang="ts"> <script setup lang="ts">
import { inject, ref } from 'vue' import { inject, onMounted } from 'vue'
import { ConfigService } from '@/services'
import type { ConfigState } from '@/types'
import { useAlertStore } from '@/stores/alertStore'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import SettingNumberInput from '@/components/common/SettingNumberInput.vue'
import SettingSwitch from '@/components/common/SettingSwitch.vue'
import { useSystemConfig } from '@/composables'
const isDarkMode = inject('isDarkMode') const isDarkMode = inject('isDarkMode')
const { t } = useI18n() const { t } = useI18n()
const {
config,
fileSize,
sizeUnit,
saveTime,
saveTimeUnit,
refreshConfig,
submitConfig,
toggleConfigFlag
} = useSystemConfig()
const config = ref<ConfigState>({ onMounted(() => {
name: '', void refreshConfig()
description: '',
file_storage: '',
webdav_url: '',
webdav_username: '',
webdav_password: '',
themesChoices: [],
expireStyle: [],
admin_token: '',
robotsText: '',
keywords: '',
notify_title: '',
storage_path: '',
notify_content: '',
openUpload: 1,
uploadSize: 1,
enableChunk: 0,
uploadMinute: 1,
max_save_seconds: 0,
opacity: 0.9,
s3_access_key_id: '',
background: '',
showAdminAddr: 0,
page_explain: '',
s3_secret_access_key: '',
aws_session_token: '',
s3_signature_version: '',
s3_region_name: '',
s3_bucket_name: '',
s3_endpoint_url: '',
s3_hostname: '',
uploadCount: 1,
errorMinute: 1,
errorCount: 1,
s3_proxy: 0,
themesSelect: ''
}) })
const fileSize = ref(1)
const sizeUnit = ref('MB')
// 添加保存时间相关的响应式变量
const saveTime = ref(1)
const saveTimeUnit = ref('天')
// 转换时间为秒
const convertToSeconds = (time: number, unit: string): number => {
const units = {
: 1,
: 60,
: 3600,
: 86400
}
return time * units[unit as keyof typeof units]
}
const alertStore = useAlertStore()
const refreshData = async () => {
try {
const res = await ConfigService.getConfig()
if (res.code === 200 && res.detail) {
// 直接使用ConfigState类型的响应数据
config.value = res.detail
// 将字节转换为合适的单位
const size = config.value.uploadSize
if (size >= 1024 * 1024 * 1024) {
fileSize.value = Math.round(size / (1024 * 1024 * 1024))
sizeUnit.value = 'GB'
} else if (size >= 1024 * 1024) {
fileSize.value = Math.round(size / (1024 * 1024))
sizeUnit.value = 'MB'
} else {
fileSize.value = Math.round(size / 1024)
sizeUnit.value = 'KB'
}
// 时间单位转换逻辑
const seconds = config.value.max_save_seconds
if (seconds === 0) {
// 如果是0,显示为7天
saveTime.value = 7
saveTimeUnit.value = '天'
} else if (seconds % 86400 === 0 && seconds >= 86400) {
saveTime.value = seconds / 86400
saveTimeUnit.value = '天'
} else if (seconds % 3600 === 0 && seconds >= 3600) {
saveTime.value = seconds / 3600
saveTimeUnit.value = '时'
} else if (seconds % 60 === 0 && seconds >= 60) {
saveTime.value = seconds / 60
saveTimeUnit.value = '分'
} else {
saveTime.value = seconds
saveTimeUnit.value = '秒'
}
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : t('manage.systemSettings.getConfigFailed')
alertStore.showAlert(errorMessage, 'error')
console.error('Failed to get system config:', error)
}
}
// 转换文件大小为字节
const convertToBytes = (size: number, unit: string): number => {
const units = {
KB: 1024,
MB: 1024 * 1024,
GB: 1024 * 1024 * 1024
}
return size * units[unit as keyof typeof units]
}
const submitSave = () => {
const formData = { ...config.value }
formData.uploadSize = convertToBytes(fileSize.value, sizeUnit.value)
// 如果保存时间为0,则默认设置为7天
if (saveTime.value === 0) {
formData.max_save_seconds = 7 * 86400 // 7天转换为秒
} else {
formData.max_save_seconds = convertToSeconds(saveTime.value, saveTimeUnit.value)
}
ConfigService.updateConfig(formData).then((res) => {
if (res.code == 200) {
alertStore.showAlert(t('manage.systemSettings.saveSuccess'), 'success')
} else {
alertStore.showAlert(res.message || t('manage.systemSettings.saveFailed'), 'error')
}
}).catch((error) => {
const errorMessage = error instanceof Error ? error.message : t('manage.systemSettings.saveFailed')
alertStore.showAlert(errorMessage, 'error')
})
}
refreshData()
</script> </script>
<template> <template>
@@ -322,27 +196,14 @@ refreshData()
<option value="webdav">{{ t('manage.settings.webdavStorage') }}</option> <option value="webdav">{{ t('manage.settings.webdavStorage') }}</option>
</select> </select>
</div> </div>
<div class="space-y-2" v-if="config.file_storage === 'local'"> <SettingSwitch
<label class="block text-sm font-medium mb-2" :class="[isDarkMode ? 'text-gray-300' : 'text-gray-700']"> v-if="config.file_storage === 'local'"
{{ t('manage.settings.chunkUploadNote') }} :label="t('manage.settings.chunkUploadNote')"
</label> :model-value="config.enableChunk"
<div class="flex items-center"> :enabled-text="t('common.enabled')"
<button type="button" @click="config.enableChunk = config.enableChunk === 1 ? 0 : 1" :disabled-text="t('common.disabled')"
class="relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2" @toggle="toggleConfigFlag('enableChunk')"
:class="[config.enableChunk === 1 ? 'bg-indigo-600' : 'bg-gray-200']" role="switch" />
:aria-checked="config.enableChunk === 1">
<span
class="pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out"
:class="[
config.enableChunk === 1 ? 'translate-x-5' : 'translate-x-0',
isDarkMode && config.enableChunk !== 1 ? 'bg-gray-100' : 'bg-white'
]" />
</button>
<span class="ml-3 text-sm" :class="[isDarkMode ? 'text-gray-300' : 'text-gray-700']">
{{ config.enableChunk === 1 ? t('common.enabled') : t('common.disabled') }}
</span>
</div>
</div>
<div v-if="config.file_storage === 'webdav'" class="space-y-4"> <div v-if="config.file_storage === 'webdav'" class="space-y-4">
<!-- 通知设置 --> <!-- 通知设置 -->
<div class="space-y-2"> <div class="space-y-2">
@@ -482,51 +343,21 @@ refreshData()
]" /> ]" />
</div> </div>
<div class="space-y-2"> <SettingSwitch
<label class="block text-sm font-medium mb-2" :label="t('manage.settings.enableProxy')"
:class="[isDarkMode ? 'text-gray-300' : 'text-gray-700']"> :model-value="config.s3_proxy"
{{ t('manage.settings.enableProxy') }} :enabled-text="t('common.enabled')"
</label> :disabled-text="t('common.disabled')"
<div class="flex items-center"> @toggle="toggleConfigFlag('s3_proxy')"
<button type="button" @click="config.s3_proxy = config.s3_proxy === 1 ? 0 : 1" />
class="relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2"
:class="[config.s3_proxy === 1 ? 'bg-indigo-600' : 'bg-gray-200']" role="switch"
:aria-checked="config.s3_proxy === 1">
<span
class="pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out"
:class="[
config.s3_proxy === 1 ? 'translate-x-5' : 'translate-x-0',
isDarkMode && config.s3_proxy !== 1 ? 'bg-gray-100' : 'bg-white'
]" />
</button>
<span class="ml-3 text-sm" :class="[isDarkMode ? 'text-gray-300' : 'text-gray-700']">
{{ config.s3_proxy === 1 ? t('common.enabled') : t('common.disabled') }}
</span>
</div>
</div>
<div class="space-y-2"> <SettingSwitch
<label class="block text-sm font-medium mb-2" :label="t('manage.settings.chunkUploadNote')"
:class="[isDarkMode ? 'text-gray-300' : 'text-gray-700']"> :model-value="config.enableChunk"
{{ t('manage.settings.chunkUploadNote') }} :enabled-text="t('common.enabled')"
</label> :disabled-text="t('common.disabled')"
<div class="flex items-center"> @toggle="toggleConfigFlag('enableChunk')"
<button type="button" @click="config.enableChunk = config.enableChunk === 1 ? 0 : 1" />
class="relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2"
:class="[config.enableChunk === 1 ? 'bg-indigo-600' : 'bg-gray-200']" role="switch"
:aria-checked="config.enableChunk === 1">
<span
class="pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out"
:class="[
config.enableChunk === 1 ? 'translate-x-5' : 'translate-x-0',
isDarkMode && config.enableChunk !== 1 ? 'bg-gray-100' : 'bg-white'
]" />
</button>
<span class="ml-3 text-sm" :class="[isDarkMode ? 'text-gray-300' : 'text-gray-700']">
{{ config.enableChunk === 1 ? t('common.enabled') : t('common.disabled') }}
</span>
</div>
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -539,37 +370,17 @@ refreshData()
</h3> </h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6"> <div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div class="space-y-2"> <SettingNumberInput
<label class="block text-sm font-medium" :class="[isDarkMode ? 'text-gray-300' : 'text-gray-700']"> v-model="config.uploadMinute"
{{ t('manage.settings.uploadPerMinute') }} :label="t('manage.settings.uploadPerMinute')"
</label> :suffix="t('common.minute')"
<div class="flex items-center space-x-2"> />
<input type="number" v-model="config.uploadMinute"
class="w-24 rounded-md shadow-sm px-4 py-2.5 transition-all duration-200 ease-in-out border focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 outline-none"
:class="[
isDarkMode
? 'bg-gray-700 border-gray-600 text-white placeholder-gray-400 hover:border-gray-500'
: 'border-gray-300 hover:border-gray-400 placeholder-gray-500'
]" />
<span :class="[isDarkMode ? 'text-gray-300' : 'text-gray-700']">{{ t('common.minute') }}</span>
</div>
</div>
<div class="space-y-2"> <SettingNumberInput
<label class="block text-sm font-medium" :class="[isDarkMode ? 'text-gray-300' : 'text-gray-700']"> v-model="config.uploadCount"
{{ t('manage.settings.uploadCountLimit') }} :label="t('manage.settings.uploadCountLimit')"
</label> :suffix="t('common.files')"
<div class="flex items-center space-x-2"> />
<input type="number" v-model="config.uploadCount"
class="w-24 rounded-md shadow-sm px-4 py-2.5 transition-all duration-200 ease-in-out border focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 outline-none"
:class="[
isDarkMode
? 'bg-gray-700 border-gray-600 text-white placeholder-gray-400 hover:border-gray-500'
: 'border-gray-300 hover:border-gray-400 placeholder-gray-500'
]" />
<span :class="[isDarkMode ? 'text-gray-300' : 'text-gray-700']">{{ t('common.files') }}</span>
</div>
</div>
<div class="space-y-2"> <div class="space-y-2">
<label class="block text-sm font-medium" :class="[isDarkMode ? 'text-gray-300' : 'text-gray-700']"> <label class="block text-sm font-medium" :class="[isDarkMode ? 'text-gray-300' : 'text-gray-700']">
@@ -647,27 +458,13 @@ refreshData()
</div> </div>
</div> </div>
<div class="space-y-2"> <SettingSwitch
<label class="block text-sm font-medium mb-2" :class="[isDarkMode ? 'text-gray-300' : 'text-gray-700']"> :label="t('manage.settings.guestUpload')"
{{ t('manage.settings.guestUpload') }} :model-value="config.openUpload"
</label> :enabled-text="t('common.enabled')"
<div class="flex items-center"> :disabled-text="t('common.disabled')"
<button type="button" @click="config.openUpload = config.openUpload === 1 ? 0 : 1" @toggle="toggleConfigFlag('openUpload')"
class="relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2" />
:class="[config.openUpload === 1 ? 'bg-indigo-600' : 'bg-gray-200']" role="switch"
:aria-checked="config.openUpload === 1">
<span
class="pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out"
:class="[
config.openUpload === 1 ? 'translate-x-5' : 'translate-x-0',
isDarkMode && config.openUpload !== 1 ? 'bg-gray-100' : 'bg-white'
]" />
</button>
<span class="ml-3 text-sm" :class="[isDarkMode ? 'text-gray-300' : 'text-gray-700']">
{{ config.openUpload === 1 ? t('common.enabled') : t('common.disabled') }}
</span>
</div>
</div>
</div> </div>
</div> </div>
@@ -678,43 +475,23 @@ refreshData()
</h3> </h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6"> <div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div class="space-y-2"> <SettingNumberInput
<label class="block text-sm font-medium" :class="[isDarkMode ? 'text-gray-300' : 'text-gray-700']"> v-model="config.errorMinute"
{{ t('manage.settings.errorPerMinute') }} :label="t('manage.settings.errorPerMinute')"
</label> :suffix="t('common.minute')"
<div class="flex items-center space-x-2"> />
<input type="number" v-model="config.errorMinute"
class="w-24 rounded-md shadow-sm px-4 py-2.5 transition-all duration-200 ease-in-out border focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 outline-none"
:class="[
isDarkMode
? 'bg-gray-700 border-gray-600 text-white placeholder-gray-400 hover:border-gray-500'
: 'border-gray-300 hover:border-gray-400 placeholder-gray-500'
]" />
<span :class="[isDarkMode ? 'text-gray-300' : 'text-gray-700']">{{ t('common.minute') }}</span>
</div>
</div>
<div class="space-y-2"> <SettingNumberInput
<label class="block text-sm font-medium" :class="[isDarkMode ? 'text-gray-300' : 'text-gray-700']"> v-model="config.errorCount"
{{ t('manage.settings.errorCountLimit') }} :label="t('manage.settings.errorCountLimit')"
</label> :suffix="t('common.times')"
<div class="flex items-center space-x-2"> />
<input type="number" v-model="config.errorCount"
class="w-24 rounded-md shadow-sm px-4 py-2.5 transition-all duration-200 ease-in-out border focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 outline-none"
:class="[
isDarkMode
? 'bg-gray-700 border-gray-600 text-white placeholder-gray-400 hover:border-gray-500'
: 'border-gray-300 hover:border-gray-400 placeholder-gray-500'
]" />
<span :class="[isDarkMode ? 'text-gray-300' : 'text-gray-700']">{{ t('common.times') }}</span>
</div>
</div>
</div> </div>
</div> </div>
<!-- 保存按钮 --> <!-- 保存按钮 -->
<div class="flex justify-end mt-8"> <div class="flex justify-end mt-8">
<button @click="submitSave" <button @click="submitConfig"
class="px-4 py-2 bg-indigo-600 text-white rounded-md hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 transition-colors duration-200"> class="px-4 py-2 bg-indigo-600 text-white rounded-md hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 transition-colors duration-200">
{{ t('manage.settings.saveChanges') }} {{ t('manage.settings.saveChanges') }}
</button> </button>
+11
View File
@@ -3,3 +3,14 @@ declare module '*.vue' {
const componentOptions: ComponentOptions const componentOptions: ComponentOptions
export default componentOptions export default componentOptions
} }
declare module 'vue-router' {
interface RouteMeta {
requiresAuth?: boolean
showGlobalControls?: boolean
showRouteLoading?: boolean
title?: string
}
}
export {}