refactor: modularize frontend flows
This commit is contained in:
@@ -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
|
||||||
|
```
|
||||||
+11
-1
@@ -14,7 +14,17 @@ export default [
|
|||||||
// 你可以在这里添加自定义规则
|
// 你可以在这里添加自定义规则
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
files: ['scripts/**/*.mjs'],
|
||||||
|
languageOptions: {
|
||||||
|
globals: {
|
||||||
|
console: 'readonly',
|
||||||
|
process: 'readonly',
|
||||||
|
URL: 'readonly',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
ignores: ['dist/**', 'node_modules/**']
|
ignores: ['dist/**', 'node_modules/**']
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
+3
-1
@@ -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",
|
||||||
|
|||||||
Generated
+79
-439
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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,11 +74,11 @@ 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`
|
||||||
}
|
}
|
||||||
|
|
||||||
return ''
|
return ''
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -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 = () => {
|
||||||
@@ -159,4 +180,4 @@ const getPlaceholder = () => {
|
|||||||
return t('send.expiration.placeholders.default')
|
return t('send.expiration.placeholders.default')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
@@ -169,4 +149,4 @@ const getQRCodeValue = (record: FileRecord) => {
|
|||||||
.fade-leave-to {
|
.fade-leave-to {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -109,4 +100,4 @@ const isDarkMode = inject('isDarkMode')
|
|||||||
.list-move {
|
.list-move {
|
||||||
transition: transform 0.3s ease;
|
transition: transform 0.3s ease;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -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 => {
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -99,4 +100,4 @@ onMounted(() => {
|
|||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
document.removeEventListener('click', handleClickOutside)
|
document.removeEventListener('click', handleClickOutside)
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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,34 +37,34 @@ 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]
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -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'
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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)))
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,13 +86,42 @@ 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,8 +160,11 @@ export function useSystemConfig() {
|
|||||||
// 方法
|
// 方法
|
||||||
fetchConfig,
|
fetchConfig,
|
||||||
updateConfig,
|
updateConfig,
|
||||||
|
refreshConfig,
|
||||||
|
submitConfig,
|
||||||
|
toggleConfigFlag,
|
||||||
initConfig,
|
initConfig,
|
||||||
getStoredConfig,
|
getStoredConfig,
|
||||||
saveConfigToStorage
|
saveConfigToStorage
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
||||||
@@ -135,4 +136,4 @@ export function useTheme() {
|
|||||||
initTheme,
|
initTheme,
|
||||||
checkSystemColorScheme
|
checkSystemColorScheme
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+11
-1
@@ -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@]+$/,
|
||||||
@@ -85,4 +95,4 @@ export const DEFAULT_CONFIG = {
|
|||||||
maxFileSize: FILE_SIZE_LIMITS.MAX_FILE_SIZE,
|
maxFileSize: FILE_SIZE_LIMITS.MAX_FILE_SIZE,
|
||||||
allowedFileTypes: ['*'] as string[],
|
allowedFileTypes: ['*'] as string[],
|
||||||
expireDays: 7
|
expireDays: 7
|
||||||
}
|
}
|
||||||
|
|||||||
+4
-3
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -47,4 +48,4 @@ export const getCurrentLocale = () => {
|
|||||||
export const availableLocales = [
|
export const availableLocales = [
|
||||||
{ code: 'zh-CN', name: '中文' },
|
{ code: 'zh-CN', name: '中文' },
|
||||||
{ code: 'en-US', name: 'English' }
|
{ code: 'en-US', name: 'English' }
|
||||||
]
|
]
|
||||||
|
|||||||
+73
-16
@@ -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
@@ -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: {
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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')
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -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')
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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
@@ -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
|
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
})
|
||||||
+17
-252
@@ -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 downloadCode = ref('')
|
|
||||||
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 addReceiveData = (record: ReceivedFileRecord) => {
|
||||||
const addShareData = (data: { code: string; name?: string }) => {
|
receiveData.value.push(record)
|
||||||
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -199,90 +26,28 @@ export const useFileDataStore = defineStore('fileData', () => {
|
|||||||
const clearReceiveData = () => {
|
const clearReceiveData = () => {
|
||||||
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const clearShareData = () => {
|
const clearShareData = () => {
|
||||||
shareData.value = []
|
shareData.value = []
|
||||||
}
|
}
|
||||||
|
|
||||||
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
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
export interface ApiResponse<T = unknown> {
|
||||||
|
code: number
|
||||||
|
message?: string
|
||||||
|
detail?: T
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ApiErrorPayload {
|
||||||
|
detail?: string
|
||||||
|
message?: string
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
export interface AdminUser {
|
||||||
|
id: string
|
||||||
|
username: string
|
||||||
|
token: string
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
@@ -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
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
@@ -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
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
@@ -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)
|
|
||||||
}
|
}
|
||||||
|
|||||||
+23
-31
@@ -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 要防抖的函数
|
||||||
@@ -241,4 +212,25 @@ 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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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`)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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' })
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Vendored
+11
@@ -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 {}
|
||||||
|
|||||||
Reference in New Issue
Block a user