diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md new file mode 100644 index 0000000..8f1a7b8 --- /dev/null +++ b/ARCHITECTURE.md @@ -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 +``` diff --git a/eslint.config.js b/eslint.config.js index da9fcc9..35904b9 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -14,7 +14,17 @@ export default [ // 你可以在这里添加自定义规则 } }, + { + files: ['scripts/**/*.mjs'], + languageOptions: { + globals: { + console: 'readonly', + process: 'readonly', + URL: 'readonly', + }, + }, + }, { ignores: ['dist/**', 'node_modules/**'] } -] \ No newline at end of file +] diff --git a/package.json b/package.json index f284af6..ea88377 100644 --- a/package.json +++ b/package.json @@ -5,10 +5,11 @@ "type": "module", "scripts": { "dev": "vite", - "build": "run-p type-check \"build-only {@}\" --", + "build": "run-s check:architecture type-check build-only", "preview": "vite preview", "build-only": "vite build", "type-check": "vue-tsc --build --force", + "check:architecture": "node scripts/check-architecture.mjs", "lint": "eslint . --fix", "format": "prettier --write src/" }, @@ -30,6 +31,7 @@ }, "devDependencies": { "@eslint/config-array": "^0.21.0", + "@eslint/js": "^9.39.2", "@eslint/object-schema": "^2.1.6", "@rushstack/eslint-patch": "^1.12.0", "@tsconfig/node20": "^20.1.6", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cf95d43..f7a6213 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -20,6 +20,9 @@ importers: file-saver: specifier: ^2.0.5 version: 2.0.5 + jszip: + specifier: 3.10.1 + version: 3.10.1 lru-cache: specifier: ^11.1.0 version: 11.1.0 @@ -51,6 +54,9 @@ importers: '@eslint/config-array': specifier: ^0.21.0 version: 0.21.0 + '@eslint/js': + specifier: ^9.39.2 + version: 9.39.2 '@eslint/object-schema': specifier: ^2.1.6 version: 2.1.6 @@ -117,9 +123,6 @@ importers: vite: specifier: ^7.3.0 version: 7.3.0(@types/node@24.3.0)(jiti@1.21.7)(yaml@2.8.1) - vite-plugin-vue-devtools: - specifier: ^8.0.0 - version: 8.0.1(vite@7.3.0(@types/node@24.3.0)(jiti@1.21.7)(yaml@2.8.1))(vue@3.5.21(typescript@5.8.3)) vue-tsc: specifier: ^3.0.4 version: 3.0.6(typescript@5.8.3) @@ -221,29 +224,6 @@ packages: engines: {node: '>=6.0.0'} hasBin: true - '@babel/plugin-proposal-decorators@7.28.0': - resolution: {integrity: sha512-zOiZqvANjWDUaUS9xMxbMcK/Zccztbe/6ikvUXaG9nsPH3w6qh5UaPGAnirI/WhIbZ8m3OHU0ReyPrknG+ZKeg==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/plugin-syntax-decorators@7.27.1': - resolution: {integrity: sha512-YMq8Z87Lhl8EGkmb0MwYkt36QnxC+fzCgrl66ereamPlYToRpIk5nUjKUY3QKLWq8mwUB1BgbeXcTJhZOCDg5A==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/plugin-syntax-import-attributes@7.27.1': - resolution: {integrity: sha512-oFT0FrKHgF53f4vOsZGi2Hh3I35PfSmVs4IBFLFj4dnafP+hIWDLg3VyKmUHfLoLHlyxY4C7DGtmHuJgn+IGww==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/plugin-syntax-import-meta@7.10.4': - resolution: {integrity: sha512-Yqfm+XDx0+Prh3VSeEQCPU81yC+JWZ2pDPFSS4ZdpfZhp4MkFMaDC1UqseovEKwSUpnIL7+vK+Clp7bfh0iD7g==} - peerDependencies: - '@babel/core': ^7.0.0-0 - '@babel/plugin-syntax-jsx@7.27.1': resolution: {integrity: sha512-y8YTNIeKoyhGd9O0Jiyzyyqk8gdjnumGTQPsz0xOZOQ2RmkVJeZ1vmmfIvFEKqucBG6axJGBZDE/7iI5suUI/w==} engines: {node: '>=6.9.0'} @@ -559,9 +539,6 @@ packages: resolution: {integrity: sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA==} engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} - '@polka/url@1.0.0-next.29': - resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==} - '@rolldown/pluginutils@1.0.0-beta.29': resolution: {integrity: sha512-NIJgOsMjbxAXvoGq/X0gD7VPMQ8j9g0BiDaNjVNVjvl+iKXxL3Jre0v31RmBYeLEmkbj2s02v8vFTbUXi5XS2Q==} @@ -692,13 +669,6 @@ packages: '@rushstack/eslint-patch@1.12.0': resolution: {integrity: sha512-5EwMtOqvJMMa3HbmxLlF74e+3/HhwBTMcvt3nqVJgGCozO6hzIPOBlwm8mGVNR9SN2IJpxSnlxczyDjcn7qIyw==} - '@sec-ant/readable-stream@0.4.1': - resolution: {integrity: sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==} - - '@sindresorhus/merge-streams@4.0.0': - resolution: {integrity: sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==} - engines: {node: '>=18'} - '@tailwindcss/typography@0.5.16': resolution: {integrity: sha512-0wDLwCVF5V3x3b1SGXPCDcdsbDHMBe+lkFzBRaHeLvNi+nrrnZ1lA18u+OTWO8iSWU2GxUOCvlXtDuqftc1oiA==} peerDependencies: @@ -844,23 +814,12 @@ packages: '@vue/devtools-api@7.7.7': resolution: {integrity: sha512-lwOnNBH2e7x1fIIbVT7yF5D+YWhqELm55/4ZKf45R9T8r9dE2AIOy8HKjfqzGsoTHFbWbr337O4E0A0QADnjBg==} - '@vue/devtools-core@8.0.1': - resolution: {integrity: sha512-Lf/+ambV3utWJ18r5TnpePbJ60IcIcqeZSQYLyNcFw2sFel0tGMnMyCdDtR1JNIdVZGAVaksTLhGh0FlrNu+sw==} - peerDependencies: - vue: ^3.0.0 - '@vue/devtools-kit@7.7.7': resolution: {integrity: sha512-wgoZtxcTta65cnZ1Q6MbAfePVFxfM+gq0saaeytoph7nEa7yMXoi6sCPy4ufO111B9msnw0VOWjPEFCXuAKRHA==} - '@vue/devtools-kit@8.0.1': - resolution: {integrity: sha512-7kiPhgTKNtNeXltEHnJJjIDlndlJP4P+UJvCw54uVHNDlI6JzwrSiRmW4cxKTug2wDbc/dkGaMnlZghcwV+aWA==} - '@vue/devtools-shared@7.7.7': resolution: {integrity: sha512-+udSj47aRl5aKb0memBvcUG9koarqnxNM5yjuREvqwK6T3ap4mn3Zqqc17QrBFTqSMjr3HK1cvStEZpMDpfdyw==} - '@vue/devtools-shared@8.0.1': - resolution: {integrity: sha512-PqtWqPPRpMwZ9FjTzyugb5KeV9kmg2C3hjxZHwjl0lijT4QIJDd0z6AWcnbM9w2nayjDymyTt0+sbdTv3pVeNg==} - '@vue/eslint-config-prettier@10.2.0': resolution: {integrity: sha512-GL3YBLwv/+b86yHcNNfPJxOTtVFJ4Mbc9UU3zR+KVoG7SwGTjPT+32fXamscNumElhcpXW3mT0DgzS9w32S7Bw==} peerDependencies: @@ -946,10 +905,6 @@ packages: resolution: {integrity: sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==} engines: {node: '>=12'} - ansis@4.1.0: - resolution: {integrity: sha512-BGcItUBWSMRgOCe+SVZJ+S7yTRG0eGt9cXAHev72yuGcY23hnLA7Bky5L/xLyPINoSN95geovfBkqoTlNZYa7w==} - engines: {node: '>=14'} - any-promise@1.3.0: resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==} @@ -1004,10 +959,6 @@ packages: engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} hasBin: true - bundle-name@4.1.0: - resolution: {integrity: sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==} - engines: {node: '>=18'} - call-bind-apply-helpers@1.0.2: resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} engines: {node: '>= 0.4'} @@ -1056,6 +1007,9 @@ packages: resolution: {integrity: sha512-yCEafptTtb4bk7GLEQoM8KVJpxAfdBJYaXyzQEgQQQgYrZiDp8SJmGKlYza6CYjEDNstAdNdKA3UuoULlEbS6w==} engines: {node: '>=12.13'} + core-util-is@1.0.3: + resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==} + cross-spawn@7.0.6: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} @@ -1092,18 +1046,6 @@ packages: deep-is@0.1.4: resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} - default-browser-id@5.0.0: - resolution: {integrity: sha512-A6p/pu/6fyBcA1TRz/GqWYPViplrftcW2gZC9q79ngNCKAeR/X3gcEdXQHl4KNXV+3wgIJ1CPkJQ3IHM6lcsyA==} - engines: {node: '>=18'} - - default-browser@5.2.1: - resolution: {integrity: sha512-WY/3TUME0x3KPYdRRxEJJvXRHV4PyPoUsxtZa78lwItwRQRHhd2U9xOscaT/YTf8uCXIAjeJOFBVEh/7FtD8Xg==} - engines: {node: '>=18'} - - define-lazy-prop@3.0.0: - resolution: {integrity: sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==} - engines: {node: '>=12'} - delayed-stream@1.0.0: resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} engines: {node: '>=0.4.0'} @@ -1137,9 +1079,6 @@ packages: resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} engines: {node: '>=0.12'} - error-stack-parser-es@1.0.5: - resolution: {integrity: sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA==} - es-define-property@1.0.1: resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} engines: {node: '>= 0.4'} @@ -1245,10 +1184,6 @@ packages: resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} engines: {node: '>=0.10.0'} - execa@9.6.0: - resolution: {integrity: sha512-jpWzZ1ZhwUmeWRhS7Qv3mhpOhLfwI+uAX4e5fOcXqwMR7EcJ0pj2kV1CVzHVMX/LphnKWD3LObjZCoJ71lKpHw==} - engines: {node: ^18.19.0 || >=20.5.0} - fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} @@ -1277,10 +1212,6 @@ packages: picomatch: optional: true - figures@6.1.0: - resolution: {integrity: sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg==} - engines: {node: '>=18'} - file-entry-cache@8.0.0: resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} engines: {node: '>=16.0.0'} @@ -1343,10 +1274,6 @@ packages: resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} engines: {node: '>= 0.4'} - get-stream@9.0.1: - resolution: {integrity: sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA==} - engines: {node: '>=18'} - glob-parent@5.1.2: resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} engines: {node: '>= 6'} @@ -1402,10 +1329,6 @@ packages: hookable@5.5.3: resolution: {integrity: sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==} - human-signals@8.0.1: - resolution: {integrity: sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ==} - engines: {node: '>=18.18.0'} - ignore@5.3.2: resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} engines: {node: '>= 4'} @@ -1414,6 +1337,9 @@ packages: resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==} engines: {node: '>= 4'} + immediate@3.0.6: + resolution: {integrity: sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==} + import-fresh@3.3.1: resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} engines: {node: '>=6'} @@ -1422,6 +1348,9 @@ packages: resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} engines: {node: '>=0.8.19'} + inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + is-binary-path@2.1.0: resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==} engines: {node: '>=8'} @@ -1430,11 +1359,6 @@ packages: resolution: {integrity: sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==} engines: {node: '>= 0.4'} - is-docker@3.0.0: - resolution: {integrity: sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==} - engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} - hasBin: true - is-extglob@2.1.1: resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} engines: {node: '>=0.10.0'} @@ -1447,34 +1371,16 @@ packages: resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} engines: {node: '>=0.10.0'} - is-inside-container@1.0.0: - resolution: {integrity: sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==} - engines: {node: '>=14.16'} - hasBin: true - is-number@7.0.0: resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} engines: {node: '>=0.12.0'} - is-plain-obj@4.1.0: - resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==} - engines: {node: '>=12'} - - is-stream@4.0.1: - resolution: {integrity: sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A==} - engines: {node: '>=18'} - - is-unicode-supported@2.1.0: - resolution: {integrity: sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==} - engines: {node: '>=18'} - is-what@4.1.16: resolution: {integrity: sha512-ZhMwEosbFJkA0YhFnNDgTM4ZxDRsS6HqTo7qsZM08fehyRYIYa0yHu5R6mgo1n/8MgaPBXiPimPD77baVFYg+A==} engines: {node: '>=12.13'} - is-wsl@3.1.0: - resolution: {integrity: sha512-UcVfVfaK4Sc4m7X3dUSoHoozQGBEFeDC+zVo06t98xe8CzHSZZBekNXH+tu0NalHolcJ/QAGqS46Hef7QXBIMw==} - engines: {node: '>=16'} + isarray@1.0.0: + resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==} isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} @@ -1524,16 +1430,19 @@ packages: engines: {node: '>=6'} hasBin: true + jszip@3.10.1: + resolution: {integrity: sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==} + keyv@4.5.4: resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} - kolorist@1.8.0: - resolution: {integrity: sha512-Y+60/zizpJ3HRH8DCss+q95yr6145JXZo46OTpFvDZWLfRCE4qChOyk1b26nMaNpfHHgxagk9dXT5OP0Tfe+dQ==} - levn@0.4.1: resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} engines: {node: '>= 0.8.0'} + lie@3.3.0: + resolution: {integrity: sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==} + lilconfig@3.1.3: resolution: {integrity: sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==} engines: {node: '>=14'} @@ -1619,10 +1528,6 @@ packages: mitt@3.0.1: resolution: {integrity: sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==} - mrmime@2.0.1: - resolution: {integrity: sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==} - engines: {node: '>=10'} - ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} @@ -1637,11 +1542,6 @@ packages: engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true - nanoid@5.1.5: - resolution: {integrity: sha512-Ir/+ZpE9fDsNH0hQ3C68uyThDXzYcim2EqcZ8zn8Chtt1iylPT9xXJB0kPCnqzgcEGikO9RxSrh63MsmVCU7Fw==} - engines: {node: ^18 || >=20} - hasBin: true - natural-compare@1.4.0: resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} @@ -1665,10 +1565,6 @@ packages: engines: {node: ^20.5.0 || >=22.0.0, npm: '>= 10'} hasBin: true - npm-run-path@6.0.0: - resolution: {integrity: sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA==} - engines: {node: '>=18'} - nth-check@2.1.1: resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==} @@ -1680,13 +1576,6 @@ packages: resolution: {integrity: sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==} engines: {node: '>= 6'} - ohash@2.0.11: - resolution: {integrity: sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==} - - open@10.2.0: - resolution: {integrity: sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA==} - engines: {node: '>=18'} - optionator@0.9.4: resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} engines: {node: '>= 0.8.0'} @@ -1702,14 +1591,13 @@ packages: package-json-from-dist@1.0.1: resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} + pako@1.0.11: + resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==} + parent-module@1.0.1: resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} engines: {node: '>=6'} - parse-ms@4.0.0: - resolution: {integrity: sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw==} - engines: {node: '>=18'} - path-browserify@1.0.1: resolution: {integrity: sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==} @@ -1721,10 +1609,6 @@ packages: resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} engines: {node: '>=8'} - path-key@4.0.0: - resolution: {integrity: sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==} - engines: {node: '>=12'} - path-parse@1.0.7: resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} @@ -1740,15 +1624,9 @@ packages: resolution: {integrity: sha512-oWyT4gICAu+kaA7QWk/jvCHWarMKNs6pXOGWKDTr7cw4IGcUbW+PeTfbaQiLGheFRpjo6O9J0PmyMfQPjH71oA==} engines: {node: 20 || >=22} - pathe@2.0.3: - resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} - perfect-debounce@1.0.0: resolution: {integrity: sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==} - perfect-debounce@2.0.0: - resolution: {integrity: sha512-fkEH/OBiKrqqI/yIgjR92lMfs2K8105zt/VT6+7eTjNwisrsh47CeIED9z58zI7DfKdH3uHAn25ziRZn3kgAow==} - picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -1840,9 +1718,8 @@ packages: engines: {node: '>=14'} hasBin: true - pretty-ms@9.2.0: - resolution: {integrity: sha512-4yf0QO/sllf/1zbZWYnvWw3NxCQwLXKzIj0G849LSufP15BXKM0rbD2Z3wVnkMfjdn/CB0Dpp444gYAACdsplg==} - engines: {node: '>=18'} + process-nextick-args@2.0.1: + resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==} proxy-from-env@1.1.0: resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==} @@ -1866,6 +1743,9 @@ packages: resolution: {integrity: sha512-qpt8EwugBWDw2cgE2W+/3oxC+KTez2uSVR8JU9Q36TXPAGCaozfQUs59v4j4GFpWTaw0i6hAZSvOmu1J0uOEUg==} engines: {node: ^18.17.0 || >=20.5.0} + readable-stream@2.3.8: + resolution: {integrity: sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==} + readdirp@3.6.0: resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==} engines: {node: '>=8.10.0'} @@ -1896,13 +1776,12 @@ packages: engines: {node: '>=18.0.0', npm: '>=8.0.0'} hasBin: true - run-applescript@7.0.0: - resolution: {integrity: sha512-9by4Ij99JUr/MCFBUkDKLWK3G9HVXmabKz9U5MlIAIuvuzkiOicRYs8XJLxX+xahD+mLiiCYDqF9dKAgtzKP1A==} - engines: {node: '>=18'} - run-parallel@1.2.0: resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} + safe-buffer@5.1.2: + resolution: {integrity: sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==} + semver@6.3.1: resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} hasBin: true @@ -1912,6 +1791,9 @@ packages: engines: {node: '>=10'} hasBin: true + setimmediate@1.0.5: + resolution: {integrity: sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==} + shebang-command@2.0.0: resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} engines: {node: '>=8'} @@ -1928,10 +1810,6 @@ packages: resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} engines: {node: '>=14'} - sirv@3.0.1: - resolution: {integrity: sha512-FoqMu0NCGBLCcAkS1qA+XJIQTR6/JHfQXl+uGteNCQ76T91DMUjPa9xfmeqMY3z80nLSg9yQmNjK0Px6RWsH/A==} - engines: {node: '>=18'} - source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} @@ -1951,6 +1829,9 @@ packages: resolution: {integrity: sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==} engines: {node: '>=12'} + string_decoder@1.1.1: + resolution: {integrity: sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==} + strip-ansi@6.0.1: resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} engines: {node: '>=8'} @@ -1959,10 +1840,6 @@ packages: resolution: {integrity: sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==} engines: {node: '>=12'} - strip-final-newline@4.0.0: - resolution: {integrity: sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw==} - engines: {node: '>=18'} - strip-json-comments@3.1.1: resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} engines: {node: '>=8'} @@ -2008,10 +1885,6 @@ packages: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} - totalist@3.0.1: - resolution: {integrity: sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==} - engines: {node: '>=6'} - ts-api-utils@2.1.0: resolution: {integrity: sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ==} engines: {node: '>=18.12'} @@ -2040,14 +1913,6 @@ packages: undici-types@7.10.0: resolution: {integrity: sha512-t5Fy/nfn+14LuOc2KNYg75vZqClpAiqscVvMygNnlsHBFpSXdJaYtXMcdNLpl/Qvc3P2cB3s6lOV51nqsFq4ag==} - unicorn-magic@0.3.0: - resolution: {integrity: sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==} - engines: {node: '>=18'} - - unplugin-utils@0.3.0: - resolution: {integrity: sha512-JLoggz+PvLVMJo+jZt97hdIIIZ2yTzGgft9e9q8iMrC4ewufl62ekeW7mixBghonn2gVb/ICjyvlmOCUBnJLQg==} - engines: {node: '>=20.19.0'} - update-browserslist-db@1.1.3: resolution: {integrity: sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw==} hasBin: true @@ -2060,37 +1925,6 @@ packages: util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} - vite-dev-rpc@1.1.0: - resolution: {integrity: sha512-pKXZlgoXGoE8sEKiKJSng4hI1sQ4wi5YT24FCrwrLt6opmkjlqPPVmiPWWJn8M8byMxRGzp1CrFuqQs4M/Z39A==} - peerDependencies: - vite: ^2.9.0 || ^3.0.0-0 || ^4.0.0-0 || ^5.0.0-0 || ^6.0.1 || ^7.0.0-0 - - vite-hot-client@2.1.0: - resolution: {integrity: sha512-7SpgZmU7R+dDnSmvXE1mfDtnHLHQSisdySVR7lO8ceAXvM0otZeuQQ6C8LrS5d/aYyP/QZ0hI0L+dIPrm4YlFQ==} - peerDependencies: - vite: ^2.6.0 || ^3.0.0 || ^4.0.0 || ^5.0.0-0 || ^6.0.0-0 || ^7.0.0-0 - - vite-plugin-inspect@11.3.3: - resolution: {integrity: sha512-u2eV5La99oHoYPHE6UvbwgEqKKOQGz86wMg40CCosP6q8BkB6e5xPneZfYagK4ojPJSj5anHCrnvC20DpwVdRA==} - engines: {node: '>=14'} - peerDependencies: - '@nuxt/kit': '*' - vite: ^6.0.0 || ^7.0.0-0 - peerDependenciesMeta: - '@nuxt/kit': - optional: true - - vite-plugin-vue-devtools@8.0.1: - resolution: {integrity: sha512-ecm/Xvtg5xsFPfY7SJ38Zb6NfmVrHxBhLMk/3nm5ZDAd7n8Dk2BV8JBuq1L5wRMVfvCth01vtzJViZC9TAC6qg==} - engines: {node: '>=v14.21.3'} - peerDependencies: - vite: ^6.0.0 || ^7.0.0-0 - - vite-plugin-vue-inspector@5.3.2: - resolution: {integrity: sha512-YvEKooQcSiBTAs0DoYLfefNja9bLgkFM7NI2b07bE2SruuvX0MEa9cMaxjKVMkeCp5Nz9FRIdcN1rOdFVBeL6Q==} - peerDependencies: - vite: ^3.0.0-0 || ^4.0.0-0 || ^5.0.0-0 || ^6.0.0-0 || ^7.0.0-0 - vite@7.3.0: resolution: {integrity: sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==} engines: {node: ^20.19.0 || >=22.12.0} @@ -2187,10 +2021,6 @@ packages: resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==} engines: {node: '>=12'} - wsl-utils@0.1.0: - resolution: {integrity: sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw==} - engines: {node: '>=18'} - xml-name-validator@4.0.0: resolution: {integrity: sha512-ICP2e+jsHvAj2E2lIHxa5tjXRlKDJo4IdvPvCXbXQGdzSfmSpNVyIKMvoZHjDY9DP0zV17iI85o90vRFXNccRw==} engines: {node: '>=12'} @@ -2207,10 +2037,6 @@ packages: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} - yoctocolors@2.1.2: - resolution: {integrity: sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug==} - engines: {node: '>=18'} - snapshots: '@alloc/quick-lru@5.2.0': {} @@ -2343,30 +2169,6 @@ snapshots: dependencies: '@babel/types': 7.28.2 - '@babel/plugin-proposal-decorators@7.28.0(@babel/core@7.28.3)': - dependencies: - '@babel/core': 7.28.3 - '@babel/helper-create-class-features-plugin': 7.28.3(@babel/core@7.28.3) - '@babel/helper-plugin-utils': 7.27.1 - '@babel/plugin-syntax-decorators': 7.27.1(@babel/core@7.28.3) - transitivePeerDependencies: - - supports-color - - '@babel/plugin-syntax-decorators@7.27.1(@babel/core@7.28.3)': - dependencies: - '@babel/core': 7.28.3 - '@babel/helper-plugin-utils': 7.27.1 - - '@babel/plugin-syntax-import-attributes@7.27.1(@babel/core@7.28.3)': - dependencies: - '@babel/core': 7.28.3 - '@babel/helper-plugin-utils': 7.27.1 - - '@babel/plugin-syntax-import-meta@7.10.4(@babel/core@7.28.3)': - dependencies: - '@babel/core': 7.28.3 - '@babel/helper-plugin-utils': 7.27.1 - '@babel/plugin-syntax-jsx@7.27.1(@babel/core@7.28.3)': dependencies: '@babel/core': 7.28.3 @@ -2621,8 +2423,6 @@ snapshots: '@pkgr/core@0.2.9': {} - '@polka/url@1.0.0-next.29': {} - '@rolldown/pluginutils@1.0.0-beta.29': {} '@rolldown/pluginutils@1.0.0-beta.34': {} @@ -2695,10 +2495,6 @@ snapshots: '@rushstack/eslint-patch@1.12.0': {} - '@sec-ant/readable-stream@0.4.1': {} - - '@sindresorhus/merge-streams@4.0.0': {} - '@tailwindcss/typography@0.5.16(tailwindcss@3.4.17)': dependencies: lodash.castarray: 4.4.0 @@ -2917,18 +2713,6 @@ snapshots: dependencies: '@vue/devtools-kit': 7.7.7 - '@vue/devtools-core@8.0.1(vite@7.3.0(@types/node@24.3.0)(jiti@1.21.7)(yaml@2.8.1))(vue@3.5.21(typescript@5.8.3))': - dependencies: - '@vue/devtools-kit': 8.0.1 - '@vue/devtools-shared': 8.0.1 - mitt: 3.0.1 - nanoid: 5.1.5 - pathe: 2.0.3 - vite-hot-client: 2.1.0(vite@7.3.0(@types/node@24.3.0)(jiti@1.21.7)(yaml@2.8.1)) - vue: 3.5.21(typescript@5.8.3) - transitivePeerDependencies: - - vite - '@vue/devtools-kit@7.7.7': dependencies: '@vue/devtools-shared': 7.7.7 @@ -2939,24 +2723,10 @@ snapshots: speakingurl: 14.0.1 superjson: 2.2.2 - '@vue/devtools-kit@8.0.1': - dependencies: - '@vue/devtools-shared': 8.0.1 - birpc: 2.5.0 - hookable: 5.5.3 - mitt: 3.0.1 - perfect-debounce: 1.0.0 - speakingurl: 14.0.1 - superjson: 2.2.2 - '@vue/devtools-shared@7.7.7': dependencies: rfdc: 1.4.1 - '@vue/devtools-shared@8.0.1': - dependencies: - rfdc: 1.4.1 - '@vue/eslint-config-prettier@10.2.0(eslint@9.39.2(jiti@1.21.7))(prettier@3.6.2)': dependencies: eslint: 9.39.2(jiti@1.21.7) @@ -3046,8 +2816,6 @@ snapshots: ansi-styles@6.2.1: {} - ansis@4.1.0: {} - any-promise@1.3.0: {} anymatch@3.1.3: @@ -3107,10 +2875,6 @@ snapshots: node-releases: 2.0.19 update-browserslist-db: 1.1.3(browserslist@4.25.4) - bundle-name@4.1.0: - dependencies: - run-applescript: 7.0.0 - call-bind-apply-helpers@1.0.2: dependencies: es-errors: 1.3.0 @@ -3159,6 +2923,8 @@ snapshots: dependencies: is-what: 4.1.16 + core-util-is@1.0.3: {} + cross-spawn@7.0.6: dependencies: path-key: 3.1.1 @@ -3181,15 +2947,6 @@ snapshots: deep-is@0.1.4: {} - default-browser-id@5.0.0: {} - - default-browser@5.2.1: - dependencies: - bundle-name: 4.1.0 - default-browser-id: 5.0.0 - - define-lazy-prop@3.0.0: {} - delayed-stream@1.0.0: {} didyoumean@1.2.2: {} @@ -3216,8 +2973,6 @@ snapshots: entities@4.5.0: {} - error-stack-parser-es@1.0.5: {} - es-define-property@1.0.1: {} es-errors@1.3.0: {} @@ -3362,21 +3117,6 @@ snapshots: esutils@2.0.3: {} - execa@9.6.0: - dependencies: - '@sindresorhus/merge-streams': 4.0.0 - cross-spawn: 7.0.6 - figures: 6.1.0 - get-stream: 9.0.1 - human-signals: 8.0.1 - is-plain-obj: 4.1.0 - is-stream: 4.0.1 - npm-run-path: 6.0.0 - pretty-ms: 9.2.0 - signal-exit: 4.1.0 - strip-final-newline: 4.0.0 - yoctocolors: 2.1.2 - fast-deep-equal@3.1.3: {} fast-diff@1.3.0: {} @@ -3401,10 +3141,6 @@ snapshots: optionalDependencies: picomatch: 4.0.3 - figures@6.1.0: - dependencies: - is-unicode-supported: 2.1.0 - file-entry-cache@8.0.0: dependencies: flat-cache: 4.0.1 @@ -3469,11 +3205,6 @@ snapshots: dunder-proto: 1.0.1 es-object-atoms: 1.1.1 - get-stream@9.0.1: - dependencies: - '@sec-ant/readable-stream': 0.4.1 - is-stream: 4.0.1 - glob-parent@5.1.2: dependencies: is-glob: 4.0.3 @@ -3528,12 +3259,12 @@ snapshots: hookable@5.5.3: {} - human-signals@8.0.1: {} - ignore@5.3.2: {} ignore@7.0.5: {} + immediate@3.0.6: {} + import-fresh@3.3.1: dependencies: parent-module: 1.0.1 @@ -3541,6 +3272,8 @@ snapshots: imurmurhash@0.1.4: {} + inherits@2.0.4: {} + is-binary-path@2.1.0: dependencies: binary-extensions: 2.3.0 @@ -3549,8 +3282,6 @@ snapshots: dependencies: hasown: 2.0.2 - is-docker@3.0.0: {} - is-extglob@2.1.1: {} is-fullwidth-code-point@3.0.0: {} @@ -3559,23 +3290,11 @@ snapshots: dependencies: is-extglob: 2.1.1 - is-inside-container@1.0.0: - dependencies: - is-docker: 3.0.0 - is-number@7.0.0: {} - is-plain-obj@4.1.0: {} - - is-stream@4.0.1: {} - - is-unicode-supported@2.1.0: {} - is-what@4.1.16: {} - is-wsl@3.1.0: - dependencies: - is-inside-container: 1.0.0 + isarray@1.0.0: {} isexe@2.0.0: {} @@ -3611,17 +3330,26 @@ snapshots: json5@2.2.3: {} + jszip@3.10.1: + dependencies: + lie: 3.3.0 + pako: 1.0.11 + readable-stream: 2.3.8 + setimmediate: 1.0.5 + keyv@4.5.4: dependencies: json-buffer: 3.0.1 - kolorist@1.8.0: {} - levn@0.4.1: dependencies: prelude-ls: 1.2.1 type-check: 0.4.0 + lie@3.3.0: + dependencies: + immediate: 3.0.6 + lilconfig@3.1.3: {} lines-and-columns@1.2.4: {} @@ -3687,8 +3415,6 @@ snapshots: mitt@3.0.1: {} - mrmime@2.0.1: {} - ms@2.1.3: {} muggle-string@0.4.1: {} @@ -3701,8 +3427,6 @@ snapshots: nanoid@3.3.11: {} - nanoid@5.1.5: {} - natural-compare@1.4.0: {} node-releases@2.0.19: {} @@ -3724,11 +3448,6 @@ snapshots: shell-quote: 1.8.3 which: 5.0.0 - npm-run-path@6.0.0: - dependencies: - path-key: 4.0.0 - unicorn-magic: 0.3.0 - nth-check@2.1.1: dependencies: boolbase: 1.0.0 @@ -3737,15 +3456,6 @@ snapshots: object-hash@3.0.0: {} - ohash@2.0.11: {} - - open@10.2.0: - dependencies: - default-browser: 5.2.1 - define-lazy-prop: 3.0.0 - is-inside-container: 1.0.0 - wsl-utils: 0.1.0 - optionator@0.9.4: dependencies: deep-is: 0.1.4 @@ -3765,20 +3475,18 @@ snapshots: package-json-from-dist@1.0.1: {} + pako@1.0.11: {} + parent-module@1.0.1: dependencies: callsites: 3.1.0 - parse-ms@4.0.0: {} - path-browserify@1.0.1: {} path-exists@4.0.0: {} path-key@3.1.1: {} - path-key@4.0.0: {} - path-parse@1.0.7: {} path-scurry@1.11.1: @@ -3796,12 +3504,8 @@ snapshots: lru-cache: 11.1.0 minipass: 7.1.2 - pathe@2.0.3: {} - perfect-debounce@1.0.0: {} - perfect-debounce@2.0.0: {} - picocolors@1.1.1: {} picomatch@2.3.1: {} @@ -3871,9 +3575,7 @@ snapshots: prettier@3.6.2: {} - pretty-ms@9.2.0: - dependencies: - parse-ms: 4.0.0 + process-nextick-args@2.0.1: {} proxy-from-env@1.1.0: {} @@ -3894,6 +3596,16 @@ snapshots: json-parse-even-better-errors: 4.0.0 npm-normalize-package-bin: 4.0.0 + readable-stream@2.3.8: + dependencies: + core-util-is: 1.0.3 + inherits: 2.0.4 + isarray: 1.0.0 + process-nextick-args: 2.0.1 + safe-buffer: 5.1.2 + string_decoder: 1.1.1 + util-deprecate: 1.0.2 + readdirp@3.6.0: dependencies: picomatch: 2.3.1 @@ -3943,16 +3655,18 @@ snapshots: '@rollup/rollup-win32-x64-msvc': 4.54.0 fsevents: 2.3.3 - run-applescript@7.0.0: {} - run-parallel@1.2.0: dependencies: queue-microtask: 1.2.3 + safe-buffer@5.1.2: {} + semver@6.3.1: {} semver@7.7.2: {} + setimmediate@1.0.5: {} + shebang-command@2.0.0: dependencies: shebang-regex: 3.0.0 @@ -3963,12 +3677,6 @@ snapshots: signal-exit@4.1.0: {} - sirv@3.0.1: - dependencies: - '@polka/url': 1.0.0-next.29 - mrmime: 2.0.1 - totalist: 3.0.1 - source-map-js@1.2.1: {} spark-md5@3.0.2: {} @@ -3987,6 +3695,10 @@ snapshots: emoji-regex: 9.2.2 strip-ansi: 7.1.0 + string_decoder@1.1.1: + dependencies: + safe-buffer: 5.1.2 + strip-ansi@6.0.1: dependencies: ansi-regex: 5.0.1 @@ -3995,8 +3707,6 @@ snapshots: dependencies: ansi-regex: 6.2.0 - strip-final-newline@4.0.0: {} - strip-json-comments@3.1.1: {} sucrase@3.35.0: @@ -4067,8 +3777,6 @@ snapshots: dependencies: is-number: 7.0.0 - totalist@3.0.1: {} - ts-api-utils@2.1.0(typescript@5.8.3): dependencies: typescript: 5.8.3 @@ -4094,13 +3802,6 @@ snapshots: undici-types@7.10.0: {} - unicorn-magic@0.3.0: {} - - unplugin-utils@0.3.0: - dependencies: - pathe: 2.0.3 - picomatch: 4.0.3 - update-browserslist-db@1.1.3(browserslist@4.25.4): dependencies: browserslist: 4.25.4 @@ -4113,61 +3814,6 @@ snapshots: util-deprecate@1.0.2: {} - vite-dev-rpc@1.1.0(vite@7.3.0(@types/node@24.3.0)(jiti@1.21.7)(yaml@2.8.1)): - dependencies: - birpc: 2.5.0 - vite: 7.3.0(@types/node@24.3.0)(jiti@1.21.7)(yaml@2.8.1) - vite-hot-client: 2.1.0(vite@7.3.0(@types/node@24.3.0)(jiti@1.21.7)(yaml@2.8.1)) - - vite-hot-client@2.1.0(vite@7.3.0(@types/node@24.3.0)(jiti@1.21.7)(yaml@2.8.1)): - dependencies: - vite: 7.3.0(@types/node@24.3.0)(jiti@1.21.7)(yaml@2.8.1) - - vite-plugin-inspect@11.3.3(vite@7.3.0(@types/node@24.3.0)(jiti@1.21.7)(yaml@2.8.1)): - dependencies: - ansis: 4.1.0 - debug: 4.4.1 - error-stack-parser-es: 1.0.5 - ohash: 2.0.11 - open: 10.2.0 - perfect-debounce: 2.0.0 - sirv: 3.0.1 - unplugin-utils: 0.3.0 - vite: 7.3.0(@types/node@24.3.0)(jiti@1.21.7)(yaml@2.8.1) - vite-dev-rpc: 1.1.0(vite@7.3.0(@types/node@24.3.0)(jiti@1.21.7)(yaml@2.8.1)) - transitivePeerDependencies: - - supports-color - - vite-plugin-vue-devtools@8.0.1(vite@7.3.0(@types/node@24.3.0)(jiti@1.21.7)(yaml@2.8.1))(vue@3.5.21(typescript@5.8.3)): - dependencies: - '@vue/devtools-core': 8.0.1(vite@7.3.0(@types/node@24.3.0)(jiti@1.21.7)(yaml@2.8.1))(vue@3.5.21(typescript@5.8.3)) - '@vue/devtools-kit': 8.0.1 - '@vue/devtools-shared': 8.0.1 - execa: 9.6.0 - sirv: 3.0.1 - vite: 7.3.0(@types/node@24.3.0)(jiti@1.21.7)(yaml@2.8.1) - vite-plugin-inspect: 11.3.3(vite@7.3.0(@types/node@24.3.0)(jiti@1.21.7)(yaml@2.8.1)) - vite-plugin-vue-inspector: 5.3.2(vite@7.3.0(@types/node@24.3.0)(jiti@1.21.7)(yaml@2.8.1)) - transitivePeerDependencies: - - '@nuxt/kit' - - supports-color - - vue - - vite-plugin-vue-inspector@5.3.2(vite@7.3.0(@types/node@24.3.0)(jiti@1.21.7)(yaml@2.8.1)): - dependencies: - '@babel/core': 7.28.3 - '@babel/plugin-proposal-decorators': 7.28.0(@babel/core@7.28.3) - '@babel/plugin-syntax-import-attributes': 7.27.1(@babel/core@7.28.3) - '@babel/plugin-syntax-import-meta': 7.10.4(@babel/core@7.28.3) - '@babel/plugin-transform-typescript': 7.28.0(@babel/core@7.28.3) - '@vue/babel-plugin-jsx': 1.5.0(@babel/core@7.28.3) - '@vue/compiler-dom': 3.5.21 - kolorist: 1.8.0 - magic-string: 0.30.18 - vite: 7.3.0(@types/node@24.3.0)(jiti@1.21.7)(yaml@2.8.1) - transitivePeerDependencies: - - supports-color - vite@7.3.0(@types/node@24.3.0)(jiti@1.21.7)(yaml@2.8.1): dependencies: esbuild: 0.27.2 @@ -4246,10 +3892,6 @@ snapshots: string-width: 5.1.2 strip-ansi: 7.1.0 - wsl-utils@0.1.0: - dependencies: - is-wsl: 3.1.0 - xml-name-validator@4.0.0: {} yallist@3.1.1: {} @@ -4257,5 +3899,3 @@ snapshots: yaml@2.8.1: {} yocto-queue@0.1.0: {} - - yoctocolors@2.1.2: {} diff --git a/public/assets/manifest.json b/public/assets/manifest.json new file mode 100644 index 0000000..8b56335 --- /dev/null +++ b/public/assets/manifest.json @@ -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" + } + ] +} diff --git a/scripts/check-architecture.mjs b/scripts/check-architecture.mjs new file mode 100644 index 0000000..b5d7c35 --- /dev/null +++ b/scripts/check-architecture.mjs @@ -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: [/]*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: [/]*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: [/ + 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}`) diff --git a/src/App.vue b/src/App.vue index 4844d2a..7d60b64 100644 --- a/src/App.vue +++ b/src/App.vue @@ -1,70 +1,16 @@ \ No newline at end of file + diff --git a/src/components/common/ExpirationSelector.vue b/src/components/common/ExpirationSelector.vue index ec29f68..458d22f 100644 --- a/src/components/common/ExpirationSelector.vue +++ b/src/components/common/ExpirationSelector.vue @@ -77,23 +77,40 @@ :value="expirationMethod" @change="updateMethod" :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', - '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 - ? 'border-gray-700/60 text-gray-300 focus:ring-indigo-500/80' - : 'border-gray-200 text-gray-700 focus:ring-indigo-500/60' + ? 'text-gray-100 border-gray-700/60 focus:ring-indigo-500/80 bg-gray-800/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' + }" > - - - - - +
} interface Emits { 'update:expirationMethod': [value: string] - 'update:expirationValue': [value: number] + 'update:expirationValue': [value: string] } const props = defineProps() @@ -136,13 +157,13 @@ const updateMethod = (event: Event) => { const updateValue = (event: Event) => { const target = event.target as HTMLInputElement - emit('update:expirationValue', parseInt(target.value) || 1) + emit('update:expirationValue', target.value) } const incrementValue = (delta: number) => { - const currentValue = props.expirationValue || 1 + const currentValue = parseInt(props.expirationValue) || 0 const newValue = Math.max(1, currentValue + delta) - emit('update:expirationValue', newValue) + emit('update:expirationValue', newValue.toString()) } const getPlaceholder = () => { @@ -159,4 +180,4 @@ const getPlaceholder = () => { return t('send.expiration.placeholders.default') } } - \ No newline at end of file + diff --git a/src/components/common/FileDetailModal.vue b/src/components/common/FileDetailModal.vue index 5d5c4ce..d4b4d81 100644 --- a/src/components/common/FileDetailModal.vue +++ b/src/components/common/FileDetailModal.vue @@ -112,20 +112,12 @@ import { inject } from 'vue' import { useI18n } from 'vue-i18n' import { FileIcon, CalendarIcon, HardDriveIcon, DownloadIcon } from 'lucide-vue-next' import QRCode from 'qrcode.vue' - -interface FileRecord { - id: number - code: string - filename: string - size: string - downloadUrl: string | null - content: string | null - date: string -} +import type { ReceivedFileRecord } from '@/types' +import { buildDownloadUrl, buildReceivedRecordQrValue } from '@/utils/share-url' interface Props { visible: boolean - record: FileRecord | null + record: ReceivedFileRecord | null } interface Emits { @@ -137,25 +129,13 @@ defineProps() defineEmits() const { t } = useI18n() const isDarkMode = inject('isDarkMode') -const baseUrl = window.location.origin -const getDownloadUrl = (record: FileRecord) => { - if (record.downloadUrl) { - if (record.downloadUrl.startsWith('http')) { - return record.downloadUrl - } else { - return `${baseUrl}${record.downloadUrl}` - } - } - return '' +const getDownloadUrl = (record: ReceivedFileRecord) => { + return buildDownloadUrl(record.downloadUrl) } -const getQRCodeValue = (record: FileRecord) => { - if (record.downloadUrl) { - return `${baseUrl}${record.downloadUrl}` - } else { - return `${baseUrl}?code=${record.code}` - } +const getQRCodeValue = (record: ReceivedFileRecord) => { + return buildReceivedRecordQrValue(record) } @@ -169,4 +149,4 @@ const getQRCodeValue = (record: FileRecord) => { .fade-leave-to { opacity: 0; } - \ No newline at end of file + diff --git a/src/components/common/FileEditField.vue b/src/components/common/FileEditField.vue new file mode 100644 index 0000000..dbccf84 --- /dev/null +++ b/src/components/common/FileEditField.vue @@ -0,0 +1,75 @@ +
+ +
+ +
+ +
+
+
+ + + diff --git a/src/components/common/FileRecordList.vue b/src/components/common/FileRecordList.vue index a0abf92..300d638 100644 --- a/src/components/common/FileRecordList.vue +++ b/src/components/common/FileRecordList.vue @@ -68,24 +68,15 @@ \ No newline at end of file + diff --git a/src/components/common/SentRecordDetailModal.vue b/src/components/common/SentRecordDetailModal.vue new file mode 100644 index 0000000..178f258 --- /dev/null +++ b/src/components/common/SentRecordDetailModal.vue @@ -0,0 +1,214 @@ + + + + + diff --git a/src/components/common/SentRecordList.vue b/src/components/common/SentRecordList.vue new file mode 100644 index 0000000..5773ced --- /dev/null +++ b/src/components/common/SentRecordList.vue @@ -0,0 +1,92 @@ + + + + + diff --git a/src/components/common/SettingNumberInput.vue b/src/components/common/SettingNumberInput.vue new file mode 100644 index 0000000..a44453a --- /dev/null +++ b/src/components/common/SettingNumberInput.vue @@ -0,0 +1,50 @@ + + + diff --git a/src/components/common/SettingSwitch.vue b/src/components/common/SettingSwitch.vue new file mode 100644 index 0000000..fdaaf72 --- /dev/null +++ b/src/components/common/SettingSwitch.vue @@ -0,0 +1,45 @@ + + + diff --git a/src/components/common/StatCard.vue b/src/components/common/StatCard.vue index e86cbab..28d343f 100644 --- a/src/components/common/StatCard.vue +++ b/src/components/common/StatCard.vue @@ -21,8 +21,9 @@ \ No newline at end of file + diff --git a/src/composables/index.ts b/src/composables/index.ts new file mode 100644 index 0000000..5086761 --- /dev/null +++ b/src/composables/index.ts @@ -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' diff --git a/src/composables/useAdminFiles.ts b/src/composables/useAdminFiles.ts new file mode 100644 index 0000000..936441a --- /dev/null +++ b/src/composables/useAdminFiles.ts @@ -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([]) + const hasLoadError = ref(false) + const params = ref({ + page: 1, + size: 10, + total: 0, + keyword: '' + }) + + const showEditModal = ref(false) + const editForm = ref({ + 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 + } +} diff --git a/src/composables/useAdminLogin.ts b/src/composables/useAdminLogin.ts new file mode 100644 index 0000000..7c81627 --- /dev/null +++ b/src/composables/useAdminLogin.ts @@ -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 + } +} diff --git a/src/composables/useAppShell.ts b/src/composables/useAppShell.ts new file mode 100644 index 0000000..dee134a --- /dev/null +++ b/src/composables/useAppShell.ts @@ -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 + } +} diff --git a/src/composables/useDashboardStats.ts b/src/composables/useDashboardStats.ts new file mode 100644 index 0000000..452fc92 --- /dev/null +++ b/src/composables/useDashboardStats.ts @@ -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(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 + } +} diff --git a/src/composables/useFileDownload.ts b/src/composables/useFileDownload.ts deleted file mode 100644 index b578fb2..0000000 --- a/src/composables/useFileDownload.ts +++ /dev/null @@ -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(null) - const downloadCode = ref('') - - // 计算属性 - const hasFileInfo = computed(() => fileInfo.value !== null) - const canDownload = computed(() => hasFileInfo.value && !isLoading.value) - - // 获取文件信息 - const getFileInfo = async (code: string): Promise => { - 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 => { - 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 - } -} \ No newline at end of file diff --git a/src/composables/useFileUpload.ts b/src/composables/useFileUpload.ts deleted file mode 100644 index 3e507ed..0000000 --- a/src/composables/useFileUpload.ts +++ /dev/null @@ -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 - 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(UPLOAD_STATUS.IDLE) - const uploadProgress = ref({ - loaded: 0, - total: 0, - percentage: 0 - }) - const uploadedCode = ref('') - const currentFile = ref(null) - - // 当前是否使用预签名上传 - const isUsingPresigned = ref(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 => { - 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 => { - 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 => { - // 构建预签名上传选项 - 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 => { - 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 => { - 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 - } -} \ No newline at end of file diff --git a/src/composables/useInjectedDarkMode.ts b/src/composables/useInjectedDarkMode.ts new file mode 100644 index 0000000..c6497b8 --- /dev/null +++ b/src/composables/useInjectedDarkMode.ts @@ -0,0 +1,7 @@ +import { computed, inject, unref } from 'vue' +import type { Ref } from 'vue' + +export function useInjectedDarkMode() { + const injectedDarkMode = inject | boolean>('isDarkMode', false) + return computed(() => Boolean(unref(injectedDarkMode))) +} diff --git a/src/composables/usePresignedUpload.ts b/src/composables/usePresignedUpload.ts index bd3e153..2ff2035 100644 --- a/src/composables/usePresignedUpload.ts +++ b/src/composables/usePresignedUpload.ts @@ -1,7 +1,5 @@ import { ref, computed, readonly } from 'vue' import { PresignUploadService } from '@/services' -import { useAlertStore } from '@/stores/alertStore' -import { FILE_SIZE_LIMITS, STORAGE_KEYS } from '@/constants' import type { PresignUploadStatus, PresignUploadMode, @@ -10,29 +8,9 @@ import type { PresignStatusResponse, UploadProgress, ExpireStyle, - ConfigState + AlertType } from '@/types' -import axios from 'axios' - -/** - * 获取最大文件大小限制(字节) - * 优先从后端配置获取,否则使用默认值 - */ -function getMaxFileSize(): number { - try { - const configStr = localStorage.getItem(STORAGE_KEYS.CONFIG) - if (configStr) { - const config = JSON.parse(configStr) as Partial - if (config.uploadSize && config.uploadSize > 0) { - // uploadSize 单位是字节 - return config.uploadSize - } - } - } catch { - // 解析失败时使用默认值 - } - return FILE_SIZE_LIMITS.MAX_FILE_SIZE -} +import { getErrorMessage } from '@/utils/common' // 预签名上传状态常量 export const PRESIGN_UPLOAD_STATUS = { @@ -48,13 +26,24 @@ export const PRESIGN_UPLOAD_STATUS = { const DEFAULT_EXPIRE_VALUE = 1 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 * 支持 S3 直传模式和服务器代理模式 */ -export function usePresignedUpload() { - const alertStore = useAlertStore() - +export function usePresignedUpload(options: UsePresignedUploadOptions = {}) { // 状态管理 const presignStatus = ref(PRESIGN_UPLOAD_STATUS.IDLE) const uploadSession = ref(null) @@ -74,15 +63,23 @@ export function usePresignedUpload() { const isError = computed(() => presignStatus.value === PRESIGN_UPLOAD_STATUS.ERROR) const currentMode = computed(() => uploadSession.value?.mode ?? null) + const notify: PresignedUploadNotifier = (message, type) => { + options.notify?.(message, type) + } + /** * 文件大小验证 */ const validateFileSize = (file: File): boolean => { - const maxFileSize = getMaxFileSize() + const maxFileSize = options.getMaxFileSize?.() + if (!maxFileSize) { + return true + } + if (file.size > maxFileSize) { const maxSizeMB = Math.round(maxFileSize / 1024 / 1024) errorMessage.value = `文件大小不能超过 ${maxSizeMB}MB` - alertStore.showAlert(errorMessage.value, 'error') + notify(errorMessage.value, 'error') return false } return true @@ -113,34 +110,16 @@ export function usePresignedUpload() { */ const handleUploadError = (error: unknown): void => { presignStatus.value = PRESIGN_UPLOAD_STATUS.ERROR + const status = (error as ErrorWithResponse)?.response?.status + const fallback = + status === 404 + ? '上传会话不存在或已过期' + : status === 500 + ? '服务器错误,请稍后重试' + : '上传失败,请重试' - if (axios.isAxiosError(error)) { - const status = error.response?.status - 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') + errorMessage.value = getErrorMessage(error, fallback) + notify(errorMessage.value, 'error') } /** @@ -211,7 +190,7 @@ export function usePresignedUpload() { total: file.size, percentage: 100 } - alertStore.showAlert('文件上传成功!', 'success') + notify('文件上传成功!', 'success') return uploadedCode.value } else { throw new Error(confirmResponse.message || '确认上传失败') @@ -249,7 +228,7 @@ export function usePresignedUpload() { total: file.size, percentage: 100 } - alertStore.showAlert('文件上传成功!', 'success') + notify('文件上传成功!', 'success') return uploadedCode.value } else { throw new Error(response.message || '代理上传失败') @@ -290,7 +269,7 @@ export function usePresignedUpload() { try { await PresignUploadService.cancelUpload(uploadSession.value.upload_id) - alertStore.showAlert('上传已取消', 'info') + notify('上传已取消', 'info') } catch (error) { // 取消失败时静默处理,因为会话可能已过期 console.warn('取消上传失败:', error) @@ -313,7 +292,7 @@ export function usePresignedUpload() { // 检查会话是否过期 if (response.detail.is_expired) { errorMessage.value = '上传会话已过期' - alertStore.showAlert(errorMessage.value, 'warning') + notify(errorMessage.value, 'warning') } return response.detail } diff --git a/src/composables/usePublicConfigBootstrap.ts b/src/composables/usePublicConfigBootstrap.ts new file mode 100644 index 0000000..43b5482 --- /dev/null +++ b/src/composables/usePublicConfigBootstrap.ts @@ -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 + } +} diff --git a/src/composables/useRetrieveFlow.ts b/src/composables/useRetrieveFlow.ts new file mode 100644 index 0000000..60dc4ef --- /dev/null +++ b/src/composables/useRetrieveFlow.ts @@ -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({ + readonly: false, + loading: false + }) + const error = ref('') + const selectedRecord = ref(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 + } +} diff --git a/src/composables/useRouteLoading.ts b/src/composables/useRouteLoading.ts new file mode 100644 index 0000000..6c3592f --- /dev/null +++ b/src/composables/useRouteLoading.ts @@ -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 + } +} diff --git a/src/composables/useSendFlow.ts b/src/composables/useSendFlow.ts new file mode 100644 index 0000000..a798816 --- /dev/null +++ b/src/composables/useSendFlow.ts @@ -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('file') + const selectedFile = ref(null) + const selectedFiles = ref([]) + 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(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 + } +} diff --git a/src/composables/useSendSubmit.ts b/src/composables/useSendSubmit.ts new file mode 100644 index 0000000..958b6ca --- /dev/null +++ b/src/composables/useSendSubmit.ts @@ -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 + +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 => { + 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> => { + 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 => { + 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 + } +} diff --git a/src/composables/useSystemConfig.ts b/src/composables/useSystemConfig.ts index 0b7fe04..5b8ec87 100644 --- a/src/composables/useSystemConfig.ts +++ b/src/composables/useSystemConfig.ts @@ -1,61 +1,54 @@ import { ref, computed } from 'vue' import { ConfigService } from '@/services' import { useAlertStore } from '@/stores/alertStore' -import type { SystemConfig } from '@/types' -import { STORAGE_KEYS, DEFAULT_CONFIG } from '@/constants' +import { useConfigStore } from '@/stores/configStore' +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() { const alertStore = useAlertStore() + const configStore = useConfigStore() // 状态管理 - const config = ref({ ...DEFAULT_CONFIG }) + const config = ref({ ...DEFAULT_CONFIG_STATE }) const isLoading = ref(false) + const fileSize = ref(1) + const sizeUnit = ref('MB') + const saveTime = ref(1) + const saveTimeUnit = ref('天') // 从本地存储获取配置 - const getStoredConfig = (): SystemConfig | null => { - try { - const storedConfig = localStorage.getItem(STORAGE_KEYS.CONFIG) - if (storedConfig) { - return JSON.parse(storedConfig) - } - } catch (error) { - console.error('解析本地配置失败:', error) - } - return null + const getStoredConfig = (): ConfigState | null => { + return readStoredConfig() } // 保存配置到本地存储 - const saveConfigToStorage = (configData: SystemConfig) => { - try { - localStorage.setItem(STORAGE_KEYS.CONFIG, JSON.stringify(configData)) - } catch (error) { - console.error('保存配置到本地存储失败:', error) - } + const saveConfigToStorage = (configData: ConfigState) => { + configStore.updateConfig(configData) } // 获取系统配置 - const fetchConfig = async (): Promise => { + const fetchConfig = async (): Promise => { try { isLoading.value = true const response = await ConfigService.getConfig() if (response.code === 200 && response.detail) { - config.value = { ...DEFAULT_CONFIG, ...response.detail } - saveConfigToStorage(config.value) - - // 处理通知 - 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' - ) - } + config.value = { ...DEFAULT_CONFIG_STATE, ...response.detail } + const notifyMessage = configStore.applyRemoteConfig(config.value) + if (notifyMessage) { + alertStore.showAlert(notifyMessage, 'success') } return config.value @@ -70,8 +63,7 @@ export function useSystemConfig() { return config.value } - const errorMessage = error instanceof Error ? error.message : '获取配置失败' - alertStore.showAlert(errorMessage, 'error') + alertStore.showAlert(getErrorMessage(error, '获取配置失败'), 'error') return null } finally { isLoading.value = false @@ -79,7 +71,7 @@ export function useSystemConfig() { } // 更新系统配置 - const updateConfig = async (newConfig: Partial): Promise => { + const updateConfig = async (newConfig: Partial): Promise => { try { isLoading.value = true @@ -94,13 +86,42 @@ export function useSystemConfig() { throw new Error(response.message || '更新配置失败') } } catch (error) { - const errorMessage = error instanceof Error ? error.message : '更新配置失败' - alertStore.showAlert(errorMessage, 'error') + alertStore.showAlert(getErrorMessage(error, '更新配置失败'), 'error') return false } finally { 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 () => { @@ -116,17 +137,21 @@ export function useSystemConfig() { // 计算属性 const maxFileSizeMB = computed(() => { - return Math.round(config.value.maxFileSize / 1024 / 1024) + return Math.round(config.value.uploadSize / 1024 / 1024) }) const isConfigLoaded = computed(() => { - return config.value.name !== DEFAULT_CONFIG.name || !isLoading.value + return config.value.name !== DEFAULT_CONFIG_STATE.name || !isLoading.value }) return { // 状态 config, isLoading, + fileSize, + sizeUnit, + saveTime, + saveTimeUnit, // 计算属性 maxFileSizeMB, @@ -135,8 +160,11 @@ export function useSystemConfig() { // 方法 fetchConfig, updateConfig, + refreshConfig, + submitConfig, + toggleConfigFlag, initConfig, getStoredConfig, saveConfigToStorage } -} \ No newline at end of file +} diff --git a/src/composables/useTheme.ts b/src/composables/useTheme.ts index aab65aa..63fe823 100644 --- a/src/composables/useTheme.ts +++ b/src/composables/useTheme.ts @@ -1,6 +1,7 @@ import { ref, computed } from 'vue' -import { STORAGE_KEYS, THEME_MODES } from '@/constants' +import { THEME_MODES } from '@/constants' import type { ThemeMode } from '@/types' +import { readStoredThemeMode, writeStoredThemeMode } from '@/utils/preference-storage' export function useTheme() { // 状态管理 @@ -14,7 +15,7 @@ export function useTheme() { // 从本地存储获取用户之前的选择 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)) { return storedPreference as ThemeMode } @@ -24,7 +25,7 @@ export function useTheme() { // 设置颜色模式 const setThemeMode = (mode: ThemeMode) => { themeMode.value = mode - localStorage.setItem(STORAGE_KEYS.COLOR_MODE, mode) + writeStoredThemeMode(mode) // 根据模式设置实际的暗色模式状态 if (mode === THEME_MODES.SYSTEM) { @@ -135,4 +136,4 @@ export function useTheme() { initTheme, checkSystemColorScheme } -} \ No newline at end of file +} diff --git a/src/constants/index.ts b/src/constants/index.ts index 9585dc7..498481f 100644 --- a/src/constants/index.ts +++ b/src/constants/index.ts @@ -71,6 +71,16 @@ export const ROUTES = { SETTINGS: '/admin/settings' } 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 = { EMAIL: /^[^\s@]+@[^\s@]+\.[^\s@]+$/, @@ -85,4 +95,4 @@ export const DEFAULT_CONFIG = { maxFileSize: FILE_SIZE_LIMITS.MAX_FILE_SIZE, allowedFileTypes: ['*'] as string[], expireDays: 7 -} \ No newline at end of file +} diff --git a/src/i18n/index.ts b/src/i18n/index.ts index d8a416d..61c2821 100644 --- a/src/i18n/index.ts +++ b/src/i18n/index.ts @@ -1,10 +1,11 @@ import { createI18n } from 'vue-i18n' import zhCN from './locales/zh-CN' import enUS from './locales/en-US' +import { readStoredLocale, writeStoredLocale } from '@/utils/preference-storage' // 获取浏览器语言设置 const getDefaultLocale = (): string => { - const savedLocale = localStorage.getItem('locale') + const savedLocale = readStoredLocale() if (savedLocale) { return savedLocale } @@ -34,7 +35,7 @@ export default i18n // 导出切换语言的函数 export const setLocale = (locale: string) => { i18n.global.locale.value = locale as 'zh-CN' | 'en-US' - localStorage.setItem('locale', locale) + writeStoredLocale(locale) document.documentElement.lang = locale } @@ -47,4 +48,4 @@ export const getCurrentLocale = () => { export const availableLocales = [ { code: 'zh-CN', name: '中文' }, { code: 'en-US', name: 'English' } -] \ No newline at end of file +] diff --git a/src/i18n/locales/en-US.ts b/src/i18n/locales/en-US.ts index aa4cf60..c0aa755 100644 --- a/src/i18n/locales/en-US.ts +++ b/src/i18n/locales/en-US.ts @@ -58,14 +58,42 @@ export default { title: 'Dashboard', totalFiles: 'Total Files', storageSpace: 'Storage Space', - activeUsers: 'Active Users', - systemStatus: 'System Status', - yesterday: 'Yesterday:', - today: 'Today:', - weeklyChange: '↓ 5% from last week', - normal: 'Normal', - serverUptime: 'Server Uptime:', - version: 'Version v2.2.1 Updated: 2025-09-04' + todayShares: 'Today Shares', + totalRetrievals: 'Total Retrievals', + activeFiles: 'Active files: {count}', + todayIncrease: 'Today added: {count}', + yesterdayShares: 'Yesterday: {count}', + serverUptime: 'Uptime', + refresh: 'Refresh', + 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: { title: 'File Management' @@ -458,14 +486,42 @@ export default { title: 'Dashboard', totalFiles: 'Total Files', storageSpace: 'Storage Space', - activeUsers: 'Active Users', - systemStatus: 'System Status', - yesterday: 'Yesterday:', - today: 'Today:', - weeklyChange: '↓ 5% from last week', - normal: 'Normal', - serverUptime: 'Server Uptime:', - version: 'Version v2.2.1 Updated: 2025-09-04' + todayShares: 'Today Shares', + totalRetrievals: 'Total Retrievals', + activeFiles: 'Active files: {count}', + todayIncrease: 'Today added: {count}', + yesterdayShares: 'Yesterday: {count}', + serverUptime: 'Uptime', + refresh: 'Refresh', + 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: { title: 'File Management', @@ -493,6 +549,7 @@ export default { }, updateFailed: 'Update failed', deleteFailed: 'Delete failed', + deleteConfirm: 'Delete this file? This action cannot be undone.', loadFileListFailed: 'Failed to load file list' }, login: { diff --git a/src/i18n/locales/zh-CN.ts b/src/i18n/locales/zh-CN.ts index b9c7c47..ca5e9b8 100644 --- a/src/i18n/locales/zh-CN.ts +++ b/src/i18n/locales/zh-CN.ts @@ -12,6 +12,7 @@ export default { next: '下一页', previous: '上一页', loading: '加载中...', + noData: '暂无数据', success: '成功', error: '错误', warning: '警告', @@ -57,14 +58,42 @@ export default { title: '仪表盘', totalFiles: '总文件数', storageSpace: '存储空间', - activeUsers: '活跃用户', - systemStatus: '系统状态', - yesterday: '昨天:', - today: '今天:', - weeklyChange: '↓ 5% 较上周', - normal: '正常', - serverUptime: '服务器运行时间:', - version: '版本 v2.2.1 更新时间:2025-09-04' + todayShares: '今日分享', + totalRetrievals: '累计取件', + activeFiles: '有效文件:{count}', + todayIncrease: '今日新增容量:{count}', + yesterdayShares: '昨日分享:{count}', + serverUptime: '运行时间', + refresh: '刷新数据', + 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: { title: '文件管理' @@ -422,14 +451,42 @@ export default { title: '仪表盘', totalFiles: '总文件数', storageSpace: '存储空间', - activeUsers: '活跃用户', - systemStatus: '系统状态', - yesterday: '昨天:', - today: '今天:', - weeklyChange: '↓ 5% 较上周', - normal: '正常', - serverUptime: '服务器运行时间:', - version: '版本 v2.2.1 更新时间:2025-09-04' + todayShares: '今日分享', + totalRetrievals: '累计取件', + activeFiles: '有效文件:{count}', + todayIncrease: '今日新增容量:{count}', + yesterdayShares: '昨日分享:{count}', + serverUptime: '运行时间', + refresh: '刷新数据', + 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: { title: '文件管理', @@ -457,6 +514,7 @@ export default { }, updateFailed: '更新失败', deleteFailed: '删除失败', + deleteConfirm: '确认删除这个文件?此操作不可撤销。', loadFileListFailed: '加载文件列表失败' }, systemSettings: { diff --git a/src/layout/AdminLayout/AdminLayout.vue b/src/layout/AdminLayout/AdminLayout.vue index fc6dfda..6eb588f 100644 --- a/src/layout/AdminLayout/AdminLayout.vue +++ b/src/layout/AdminLayout/AdminLayout.vue @@ -44,11 +44,11 @@ @@ -117,8 +117,9 @@ import { LayoutDashboardIcon, LogOutIcon } from 'lucide-vue-next' -import { useRouter } from 'vue-router' +import { RouterLink, useRoute, useRouter } from 'vue-router' import { useI18n } from 'vue-i18n' +import { ROUTE_NAMES, ROUTES } from '@/constants' import { useAdminStore } from '@/stores/adminStore' interface MenuItem { @@ -129,23 +130,29 @@ interface MenuItem { } const router = useRouter() +const route = useRoute() const { t } = useI18n() const isDarkMode = inject('isDarkMode') const adminStore = useAdminStore() const menuItems: MenuItem[] = [ { - id: 'Dashboard', + id: ROUTE_NAMES.DASHBOARD, name: t('admin.dashboard.title'), icon: LayoutDashboardIcon, - redirect: '/admin/dashboard' + redirect: ROUTES.DASHBOARD }, { - id: 'FileManage', + id: ROUTE_NAMES.FILE_MANAGE, name: t('admin.fileManage.title'), 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) @@ -171,33 +178,10 @@ onUnmounted(() => { 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 = () => { adminStore.logout() - router.push('/login') + router.push(ROUTES.LOGIN) } diff --git a/src/router/index.ts b/src/router/index.ts index 2891e0a..b918d6e 100644 --- a/src/router/index.ts +++ b/src/router/index.ts @@ -1,50 +1,104 @@ 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({ history: createWebHashHistory(import.meta.env.BASE_URL), - routes: [ - { - path: '/', - name: 'Retrieve', - component: () => import('@/views/RetrievewFileView.vue') - }, - { - path: '/send', - name: 'Send', - component: SendFileView - }, - { - path: '/admin', - name: 'Manage', - component: () => import('@/layout/AdminLayout/AdminLayout.vue'), - redirect: '/admin/dashboard', - children: [ - { - path: '/admin/dashboard', - name: 'Dashboard', - component: () => import('@/views/manage/DashboardView.vue') - }, - { - path: '/admin/files', - name: 'FileManage', - component: () => import('@/views/manage/FileManageView.vue') - }, - { - path: '/admin/settings', - name: 'Settings', - component: () => import('@/views/manage/SystemSettingsView.vue') - } - ] - }, - { - path: '/login', - name: 'Login', - component: () => import('@/views/manage/LoginView.vue') - } - ] + routes }) +router.beforeEach((to) => { + if (to.meta.requiresAuth && !readStoredToken()) { + return { + path: ROUTES.LOGIN, + query: { + redirect: to.fullPath + } + } + } +}) export default router diff --git a/src/services/auth.ts b/src/services/auth.ts new file mode 100644 index 0000000..47fb448 --- /dev/null +++ b/src/services/auth.ts @@ -0,0 +1,16 @@ +import api from './client' +import type { AdminUser, ApiResponse } from '@/types' + +export class AuthService { + static async login(password: string): Promise> { + return api.post('/admin/login', { password }) + } + + static async logout(): Promise { + return api.post('/admin/logout') + } + + static async verifyToken(): Promise> { + return api.get('/admin/verify') + } +} diff --git a/src/services/client.ts b/src/services/client.ts new file mode 100644 index 0000000..4a39bfa --- /dev/null +++ b/src/services/client.ts @@ -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) => { + if (error.response?.status === API_STATUS_CODES.UNAUTHORIZED) { + clearStoredToken() + window.dispatchEvent(new CustomEvent(AUTH_EVENTS.UNAUTHORIZED)) + } + return Promise.reject(error) +} + +apiClient.interceptors.request.use( + attachAuthToken, + (error) => Promise.reject(error) +) + +rawApiClient.interceptors.request.use( + attachAuthToken, + (error) => Promise.reject(error) +) + +apiClient.interceptors.response.use( + (response) => response.data, + handleAuthError +) + +rawApiClient.interceptors.response.use((response) => response, handleAuthError) + +export default apiClient diff --git a/src/services/config.ts b/src/services/config.ts new file mode 100644 index 0000000..9d60b75 --- /dev/null +++ b/src/services/config.ts @@ -0,0 +1,16 @@ +import api from './client' +import type { ApiResponse, ConfigState } from '@/types' + +export class ConfigService { + static async getConfig(): Promise> { + return api.get('/admin/config/get') + } + + static async getUserConfig(): Promise> { + return api.post('/') + } + + static async updateConfig(config: Partial): Promise { + return api.patch('/admin/config/update', config) + } +} diff --git a/src/services/file.ts b/src/services/file.ts new file mode 100644 index 0000000..979855b --- /dev/null +++ b/src/services/file.ts @@ -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) => { + 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> { + const formData = new FormData() + formData.append('file', file) + + return api.post('/share/file/', formData, multipartUploadConfig(onProgress)) + } + + static async uploadText( + text: string, + expireValue = 1, + expireStyle = 'day' + ): Promise> { + const formData = new FormData() + formData.append('text', text) + formData.append('expire_value', String(expireValue)) + formData.append('expire_style', expireStyle) + return api.post('/share/text/', formData, multipartUploadConfig()) + } + + static async initChunkUpload( + request: ChunkUploadInitRequest + ): Promise> { + return api.post( + '/chunk/upload/init/', + toUrlEncodedForm({ + file_name: request.file_name, + file_size: request.file_size, + chunk_size: request.chunk_size, + file_hash: request.file_hash + }), + urlEncodedConfig + ) + } + + static async uploadChunk( + uploadId: string, + chunkIndex: number, + chunk: Blob, + onProgress?: (progress: UploadProgress) => void + ): Promise> { + const formData = new FormData() + formData.append('chunk', chunk) + return api.post( + `/chunk/upload/chunk/${uploadId}/${chunkIndex}`, + formData, + multipartUploadConfig(onProgress) + ) + } + + static async completeChunkUpload( + uploadId: string, + request: ChunkUploadCompleteRequest + ): Promise> { + return api.post( + `/chunk/upload/complete/${uploadId}`, + toUrlEncodedForm({ + expire_value: request.expire_value, + expire_style: request.expire_style + }), + urlEncodedConfig + ) + } + + static async selectFile(code: string): Promise> { + return api.post('/share/select/', { code }) + } + + static async getFile(code: string): Promise> { + return api.get(`/file/${code}`) + } + + static async downloadFile(code: string): Promise { + const response = await rawApiClient.get(`/download/${code}`, { + responseType: 'blob' + }) + return response.data + } + + static async getAdminFileList(params: { + page: number + size: number + keyword?: string + }): Promise> { + return api.get('/admin/file/list', { params }) + } + + static async updateFile(data: FileEditForm): Promise { + return api.patch('/admin/file/update', data) + } + + static async deleteAdminFile(id: number): Promise { + return api.delete('/admin/file/delete', { + data: { id } + }) + } + + static async downloadAdminFile( + id: number + ): Promise<{ data: Blob; headers: Record }> { + const response = await rawApiClient.get('/admin/file/download', { + params: { id }, + responseType: 'blob' + }) + return { + data: response.data, + headers: response.headers as Record + } + } +} diff --git a/src/services/index.ts b/src/services/index.ts index 8ed9e4d..38e6d24 100644 --- a/src/services/index.ts +++ b/src/services/index.ts @@ -1,266 +1,7 @@ -// API 服务层 -import api from '@/utils/api' -import axios from 'axios' -import type { - ApiResponse, - FileInfo, - ConfigState, - AdminUser, - FileUploadResponse, - TextSendResponse, - DashboardData, - FileListResponse, - FileEditForm -} from '@/types' -import type { - UploadProgress, - PresignInitRequest, - PresignInitResponse, - PresignConfirmRequest, - PresignUploadResult, - PresignStatusResponse -} from '@/types' - -// 系统配置服务 -export class ConfigService { - static async getConfig(): Promise> { - return api.get('/admin/config/get') - } - static async getUserConfig(): Promise> { - return api.post('/') - } - static async updateConfig(config: Partial): Promise { - return api.patch('/admin/config/update', config) - } -} - -// 文件服务 -export class FileService { - static async uploadFile( - file: File, - onProgress?: (progress: UploadProgress) => void - ): Promise> { - const formData = new FormData() - formData.append('file', file) - - return api.post('/share/file/', formData, { - headers: { - 'Content-Type': 'multipart/form-data' - }, - timeout: 0, // 禁用超时限制 - onUploadProgress: (progressEvent) => { - if (onProgress && progressEvent.total) { - const progress: UploadProgress = { - loaded: progressEvent.loaded, - total: progressEvent.total, - percentage: Math.round((progressEvent.loaded * 100) / progressEvent.total) - } - onProgress(progress) - } - } - }) - } - - static async uploadText(text: string): Promise> { - return api.post('/share/text/', { content: text }) - } - - static async getFile(code: string): Promise> { - return api.get(`/file/${code}`) - } - - static async downloadFile(code: string): Promise { - const response = await api.get(`/download/${code}`, { - responseType: 'blob' - }) - return response.data - } - - static async deleteFile(fileId: string): Promise { - return api.post('/admin/file/delete', { id: fileId }) - } - - static async getFileList( - page = 1, - limit = 10 - ): Promise< - ApiResponse<{ - files: FileInfo[] - total: number - page: number - limit: number - }> - > { - return api.get('/admin/file/list', { - params: { page, size: limit } - }) - } - - // 文件管理相关方法 - static async getAdminFileList(params: { - page: number - size: number - keyword?: string - }): Promise> { - return api.get('/admin/file/list', { params }) - } - - static async updateFile(data: FileEditForm): Promise { - return api.patch('/admin/file/update', data) - } - - static async deleteAdminFile(id: number): Promise { - return api.delete('/admin/file/delete', { - data: { id } - }) - } - - static async downloadAdminFile( - id: number - ): Promise<{ data: Blob; headers: Record }> { - return api.get('/admin/file/download', { - params: { id }, - responseType: 'blob' - }) - } -} - -// 认证服务 -export class AuthService { - static async login(password: string): Promise> { - return api.post('/admin/login', { password }) - } - - static async logout(): Promise { - return api.post('/admin/logout') - } - - static async verifyToken(): Promise> { - return api.get('/admin/verify') - } -} - -// 统计服务 -export class StatsService { - static async getDashboardStats(): Promise< - ApiResponse<{ - totalFiles: number - totalDownloads: number - todayUploads: number - todayDownloads: number - storageUsed: number - recentFiles: FileInfo[] - }> - > { - return api.get('/admin/dashboard') - } - - static async getDashboard(): Promise> { - return api.get('/admin/dashboard') - } -} - -// 预签名上传服务 -export class PresignUploadService { - /** - * 初始化上传会话 - */ - static async initUpload(request: PresignInitRequest): Promise> { - return api.post('/presign/upload/init', request) - } - - /** - * 代理模式上传 - */ - static async proxyUpload( - uploadId: string, - file: File, - onProgress?: (progress: UploadProgress) => void - ): Promise> { - const formData = new FormData() - formData.append('file', file) - - return api.put(`/presign/upload/proxy/${uploadId}`, formData, { - headers: { - 'Content-Type': 'multipart/form-data' - }, - timeout: 0, - onUploadProgress: (progressEvent) => { - if (onProgress && progressEvent.total) { - const progress: UploadProgress = { - loaded: progressEvent.loaded, - total: progressEvent.total, - percentage: Math.round((progressEvent.loaded * 100) / progressEvent.total) - } - onProgress(progress) - } - } - }) - } - - /** - * 确认直传上传 - */ - static async confirmUpload( - uploadId: string, - request?: PresignConfirmRequest - ): Promise> { - return api.post(`/presign/upload/confirm/${uploadId}`, request || {}) - } - - /** - * 查询上传状态 - */ - static async getUploadStatus(uploadId: string): Promise> { - return api.get(`/presign/upload/status/${uploadId}`) - } - - /** - * 取消上传 - */ - static async cancelUpload(uploadId: string): Promise> { - return api.delete(`/presign/upload/${uploadId}`) - } - - /** - * S3 直传(不经过后端) - */ - static async directUploadToS3( - uploadUrl: string, - file: File, - onProgress?: (progress: UploadProgress) => void - ): Promise { - try { - await axios.put(uploadUrl, file, { - headers: { - 'Content-Type': 'application/octet-stream' - }, - timeout: 0, - onUploadProgress: (progressEvent) => { - if (onProgress && progressEvent.total) { - const progress: UploadProgress = { - loaded: progressEvent.loaded, - total: progressEvent.total, - percentage: Math.round((progressEvent.loaded * 100) / progressEvent.total) - } - onProgress(progress) - } - } - }) - return true - } catch { - return false - } - } -} - -// 导出所有服务 -export const services = { - config: ConfigService, - file: FileService, - auth: AuthService, - stats: StatsService, - presignUpload: PresignUploadService -} - -export default services +export { AUTH_EVENTS } from './client' +export { AuthService } from './auth' +export { ConfigService } from './config' +export { FileService } from './file' +export { PresignUploadService } from './presign-upload' +export { StatsService } from './stats' +export { uploadChunkedFile } from './upload-strategy' diff --git a/src/services/presign-upload.ts b/src/services/presign-upload.ts new file mode 100644 index 0000000..3d1def8 --- /dev/null +++ b/src/services/presign-upload.ts @@ -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> { + return api.post('/presign/upload/init', request) + } + + static async proxyUpload( + uploadId: string, + file: File, + onProgress?: (progress: UploadProgress) => void + ): Promise> { + const formData = new FormData() + formData.append('file', file) + + return api.put(`/presign/upload/proxy/${uploadId}`, formData, multipartUploadConfig(onProgress)) + } + + static async confirmUpload( + uploadId: string, + request?: PresignConfirmRequest + ): Promise> { + return api.post(`/presign/upload/confirm/${uploadId}`, request || {}) + } + + static async getUploadStatus(uploadId: string): Promise> { + return api.get(`/presign/upload/status/${uploadId}`) + } + + static async cancelUpload(uploadId: string): Promise> { + return api.delete(`/presign/upload/${uploadId}`) + } + + static async directUploadToS3( + uploadUrl: string, + file: File, + onProgress?: (progress: UploadProgress) => void + ): Promise { + await uploadToExternalUrl(uploadUrl, file, onProgress) + return true + } +} diff --git a/src/services/shared.ts b/src/services/shared.ts new file mode 100644 index 0000000..58c5535 --- /dev/null +++ b/src/services/shared.ts @@ -0,0 +1,20 @@ +import type { AxiosRequestConfig } from 'axios' +import type { UploadProgress } from '@/types' + +export const multipartUploadConfig = ( + onProgress?: (progress: UploadProgress) => void +): AxiosRequestConfig => ({ + headers: { + 'Content-Type': 'multipart/form-data' + }, + timeout: 0, + onUploadProgress: (progressEvent) => { + if (onProgress && progressEvent.total) { + onProgress({ + loaded: progressEvent.loaded, + total: progressEvent.total, + percentage: Math.round((progressEvent.loaded * 100) / progressEvent.total) + }) + } + } +}) diff --git a/src/services/stats.ts b/src/services/stats.ts new file mode 100644 index 0000000..c275a12 --- /dev/null +++ b/src/services/stats.ts @@ -0,0 +1,8 @@ +import api from './client' +import type { ApiResponse, DashboardData } from '@/types' + +export class StatsService { + static async getDashboard(): Promise> { + return api.get('/admin/dashboard') + } +} diff --git a/src/services/upload-client.ts b/src/services/upload-client.ts new file mode 100644 index 0000000..062fe77 --- /dev/null +++ b/src/services/upload-client.ts @@ -0,0 +1,26 @@ +import axios from 'axios' +import type { UploadProgress } from '@/types' + +export async function uploadToExternalUrl( + uploadUrl: string, + file: File, + onProgress?: (progress: UploadProgress) => void +): Promise { + await axios.put(uploadUrl, file, { + headers: { + 'Content-Type': 'application/octet-stream' + }, + timeout: 0, + onUploadProgress: (progressEvent) => { + if (!onProgress || !progressEvent.total) { + return + } + + onProgress({ + loaded: progressEvent.loaded, + total: progressEvent.total, + percentage: Math.round((progressEvent.loaded * 100) / progressEvent.total) + }) + } + }) +} diff --git a/src/services/upload-strategy.ts b/src/services/upload-strategy.ts new file mode 100644 index 0000000..933a483 --- /dev/null +++ b/src/services/upload-strategy.ts @@ -0,0 +1,97 @@ +import { FileService } from './file' +import type { ApiResponse, ChunkUploadInitResponse, FileUploadResponse, UploadProgress } from '@/types' +import { calculateFileHash } from '@/utils/file-processing' + +const CHUNK_SIZE = 5 * 1024 * 1024 + +type ChunkedUploadOptions = { + expireValue: number + expireStyle: string + onHashCalculated?: (hash: string) => void + onProgress?: (progress: UploadProgress) => void + messages?: { + initFailed?: string + chunkFailed?: (index: number) => string + completeFailed?: string + } +} + +type ChunkedUploadResult = ChunkUploadInitResponse | FileUploadResponse + +const calculateCompletedBytes = (uploadedChunks: Set, chunkSize: number, fileSize: number) => + Array.from(uploadedChunks).reduce((total, index) => { + const chunkStart = index * chunkSize + const chunkEnd = Math.min((index + 1) * chunkSize, fileSize) + return total + Math.max(0, chunkEnd - chunkStart) + }, 0) + +export const uploadChunkedFile = async ( + file: File, + options: ChunkedUploadOptions +): Promise> => { + const fileHash = await calculateFileHash(file) + options.onHashCalculated?.(fileHash) + + const chunks = Math.ceil(file.size / CHUNK_SIZE) + const initResponse = await FileService.initChunkUpload({ + file_name: file.name, + file_size: file.size, + chunk_size: CHUNK_SIZE, + file_hash: fileHash + }) + + if (initResponse.code !== 200) { + throw new Error(options.messages?.initFailed || 'Init chunk upload failed') + } + + if (initResponse.detail?.existed) { + return initResponse + } + + const initDetail = initResponse.detail + const uploadId = initDetail?.upload_id + if (!uploadId) { + throw new Error(options.messages?.initFailed || 'Init chunk upload failed') + } + + const uploadedChunks = new Set(initDetail.uploaded_chunks || []) + for (let index = 0; index < chunks; index++) { + if (uploadedChunks.has(index)) { + continue + } + + const start = index * CHUNK_SIZE + const end = Math.min(start + CHUNK_SIZE, file.size) + const chunk = file.slice(start, end) + const chunkResponse = await FileService.uploadChunk( + uploadId, + index, + new Blob([chunk], { type: file.type }), + (progress) => { + const completedBytes = calculateCompletedBytes(uploadedChunks, CHUNK_SIZE, file.size) + const percentage = Math.round(((completedBytes + progress.loaded) * 100) / file.size) + options.onProgress?.({ + loaded: completedBytes + progress.loaded, + total: file.size, + percentage: Math.min(percentage, 99) + }) + } + ) + + if (chunkResponse.code !== 200) { + throw new Error(options.messages?.chunkFailed?.(index) || `Chunk upload failed: ${index}`) + } + uploadedChunks.add(index) + } + + const completeResponse = await FileService.completeChunkUpload(uploadId, { + expire_value: options.expireValue, + expire_style: options.expireStyle + }) + + if (completeResponse.code !== 200) { + throw new Error(options.messages?.completeFailed || 'Complete chunk upload failed') + } + + return completeResponse +} diff --git a/src/stores/adminStore.ts b/src/stores/adminStore.ts index 639e9f6..93d2bad 100644 --- a/src/stores/adminStore.ts +++ b/src/stores/adminStore.ts @@ -1,12 +1,18 @@ import { defineStore } from 'pinia' import { ref, computed } from 'vue' -import { STORAGE_KEYS } from '@/constants' import type { AdminUser } from '@/types' +import { + clearStoredAuth, + readStoredAdminPassword, + readStoredToken, + writeStoredAdminPassword, + writeStoredToken +} from '@/utils/auth-storage' export const useAdminStore = defineStore('admin', () => { // 状态 - const adminPassword = ref(localStorage.getItem(STORAGE_KEYS.ADMIN_PASSWORD) || '') - const token = ref(localStorage.getItem(STORAGE_KEYS.TOKEN) || '') + const adminPassword = ref(readStoredAdminPassword()) + const token = ref(readStoredToken()) const isLoggedIn = ref(false) const userInfo = ref(null) @@ -14,16 +20,17 @@ export const useAdminStore = defineStore('admin', () => { const isAuthenticated = computed(() => { return isLoggedIn.value && !!token.value }) + const hasToken = computed(() => !!token.value) // 方法 const updateAdminPassword = (pwd: string) => { adminPassword.value = pwd - localStorage.setItem(STORAGE_KEYS.ADMIN_PASSWORD, pwd) + writeStoredAdminPassword(pwd) } const setToken = (newToken: string) => { token.value = newToken - localStorage.setItem(STORAGE_KEYS.TOKEN, newToken) + writeStoredToken(newToken) } const setUserInfo = (user: AdminUser) => { @@ -42,13 +49,11 @@ export const useAdminStore = defineStore('admin', () => { isLoggedIn.value = false userInfo.value = null - // 清除本地存储 - localStorage.removeItem(STORAGE_KEYS.ADMIN_PASSWORD) - localStorage.removeItem(STORAGE_KEYS.TOKEN) + clearStoredAuth() } const initAuth = () => { - const storedToken = localStorage.getItem(STORAGE_KEYS.TOKEN) + const storedToken = readStoredToken() if (storedToken) { token.value = storedToken isLoggedIn.value = true @@ -64,6 +69,7 @@ export const useAdminStore = defineStore('admin', () => { // 计算属性 isAuthenticated, + hasToken, // 方法 updateAdminPassword, @@ -74,6 +80,3 @@ export const useAdminStore = defineStore('admin', () => { initAuth } }) - -// 保持向后兼容 -export const useAdminData = useAdminStore diff --git a/src/stores/alertStore.ts b/src/stores/alertStore.ts index 6eb6ff0..d4318c5 100644 --- a/src/stores/alertStore.ts +++ b/src/stores/alertStore.ts @@ -2,6 +2,8 @@ import { defineStore } from 'pinia' import type { Alert, AlertType } from '@/types' import { TIME_CONSTANTS } from '@/constants' +let progressTimer: ReturnType | null = null + export const useAlertStore = defineStore('alert', { state: () => ({ alerts: [] as Alert[] @@ -33,6 +35,25 @@ export const useAlertStore = defineStore('alert', { 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 } } }) diff --git a/src/stores/configStore.ts b/src/stores/configStore.ts new file mode 100644 index 0000000..6cd4d68 --- /dev/null +++ b/src/stores/configStore.ts @@ -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({ + ...DEFAULT_PUBLIC_CONFIG, + ...toPublicConfig(readStoredConfig>()) + }) + + const uploadSizeLimit = computed(() => config.value.uploadSize) + + const updateConfig = (nextConfig: Partial) => { + config.value = { + ...DEFAULT_PUBLIC_CONFIG, + ...config.value, + ...toPublicConfig(nextConfig) + } + writeStoredConfig(config.value) + } + + const applyRemoteConfig = (nextConfig: Partial): string | null => { + updateConfig(nextConfig) + + const { notify_title: notifyTitle, notify_content: notifyContent } = nextConfig + if (!notifyTitle || !notifyContent) { + return null + } + + const notifyKey = notifyTitle + notifyContent + if (readNotifyKey() === notifyKey) { + return null + } + + writeNotifyKey(notifyKey) + return `${notifyTitle}: ${notifyContent}` + } + + const reloadStoredConfig = () => { + config.value = { + ...DEFAULT_PUBLIC_CONFIG, + ...toPublicConfig(readStoredConfig>()) + } + } + + return { + config, + uploadSizeLimit, + applyRemoteConfig, + updateConfig, + reloadStoredConfig + } +}) diff --git a/src/stores/fileData.ts b/src/stores/fileData.ts index a03519c..a4836fe 100644 --- a/src/stores/fileData.ts +++ b/src/stores/fileData.ts @@ -1,197 +1,24 @@ import { defineStore } from 'pinia' -import { ref, computed } from 'vue' -import type { FileInfo, UploadProgress, UploadStatus } from '@/types' -import { UPLOAD_STATUS } from '@/constants' +import { ref } from 'vue' +import type { ReceivedFileRecord, SentFileRecord } from '@/types' export const useFileDataStore = defineStore('fileData', () => { - // 上传相关状态 - const uploadStatus = ref(UPLOAD_STATUS.IDLE) - const uploadProgress = ref({ - loaded: 0, - total: 0, - percentage: 0 - }) - const uploadedCode = ref('') - const currentFile = ref(null) - - // 下载相关状态 - const downloadCode = ref('') - const fileInfo = ref(null) - const isDownloading = ref(false) - - // 文件列表状态(管理页面使用) - const fileList = ref([]) - const totalFiles = ref(0) - const currentPage = ref(1) - const pageSize = ref(10) - const isLoadingList = ref(false) - - // 接收数据状态(取件记录) - const receiveData = ref>([]) - - // 分享数据状态(发送记录) - const shareData = ref>([]) - - // 计算属性 - const isUploading = computed(() => uploadStatus.value === UPLOAD_STATUS.UPLOADING) - const isUploadSuccess = computed(() => uploadStatus.value === UPLOAD_STATUS.SUCCESS) - const isUploadError = computed(() => uploadStatus.value === UPLOAD_STATUS.ERROR) - const hasFileInfo = computed(() => fileInfo.value !== null) - const canDownload = computed(() => hasFileInfo.value && !isDownloading.value) - - const totalPages = computed(() => { - return Math.ceil(totalFiles.value / pageSize.value) - }) - - // 上传相关方法 - const setUploadStatus = (status: UploadStatus) => { - uploadStatus.value = status - } - - const setUploadProgress = (progress: UploadProgress) => { - uploadProgress.value = progress - } - - const setUploadedCode = (code: string) => { - uploadedCode.value = code - } - - const setCurrentFile = (file: File | null) => { - currentFile.value = file - } - - const resetUpload = () => { - uploadStatus.value = UPLOAD_STATUS.IDLE - uploadProgress.value = { - loaded: 0, - total: 0, - percentage: 0 - } - uploadedCode.value = '' - currentFile.value = null - } - - // 下载相关方法 - const setDownloadCode = (code: string) => { - downloadCode.value = code - } - - const setFileInfo = (info: FileInfo | null) => { - fileInfo.value = info - } - - const setDownloading = (loading: boolean) => { - isDownloading.value = loading - } - - const resetDownload = () => { - downloadCode.value = '' - fileInfo.value = null - isDownloading.value = false - } - - // 文件列表相关方法 - const setFileList = (files: FileInfo[]) => { - fileList.value = files - } - - const addFile = (file: FileInfo) => { - fileList.value.unshift(file) - totalFiles.value += 1 - } - - const removeFile = (fileId: string) => { - const index = fileList.value.findIndex(file => file.id === fileId) - if (index > -1) { - fileList.value.splice(index, 1) - totalFiles.value -= 1 - } - } - - const updateFile = (fileId: string, updates: Partial) => { - const index = fileList.value.findIndex(file => file.id === fileId) - if (index > -1) { - fileList.value[index] = { ...fileList.value[index], ...updates } - } - } - - const setTotalFiles = (total: number) => { - totalFiles.value = total - } - - const setCurrentPage = (page: number) => { - currentPage.value = page - } - - const setPageSize = (size: number) => { - pageSize.value = size - } - - const setLoadingList = (loading: boolean) => { - isLoadingList.value = loading - } - - const resetFileList = () => { - fileList.value = [] - totalFiles.value = 0 - currentPage.value = 1 - isLoadingList.value = false - } + const receiveData = ref([]) + const shareData = ref([]) - // 添加分享数据方法 - const addShareData = (data: { code: string; name?: string }) => { - setUploadedCode(data.code) - if (data.name) { - // 如果有文件名,可以创建一个临时的 FileInfo 对象 - const fileInfo: FileInfo = { - id: data.code, - name: data.name, - size: 0, - type: '', - uploadTime: new Date().toISOString(), - downloadCount: 0 - } - addFile(fileInfo) - } - } - - // 接收数据相关方法 - const addReceiveData = (data: { - id: number - code: string - filename: string - size: string - downloadUrl: string | null - content: string | null - date: string - }) => { - receiveData.value.push(data) + const addReceiveData = (record: ReceivedFileRecord) => { + receiveData.value.push(record) } const removeReceiveData = (id: number) => { - const index = receiveData.value.findIndex(item => item.id === id) - if (index > -1) { + const index = receiveData.value.findIndex((record) => record.id === id) + if (index !== -1) { receiveData.value.splice(index, 1) } } - + const deleteReceiveData = (index: number) => { - if (index > -1 && index < receiveData.value.length) { + if (index >= 0 && index < receiveData.value.length) { receiveData.value.splice(index, 1) } } @@ -199,90 +26,28 @@ export const useFileDataStore = defineStore('fileData', () => { const clearReceiveData = () => { receiveData.value = [] } - - // 分享数据相关方法 - const addShareDataRecord = (data: { - id: number - filename: string - date: string - size: string - expiration: string - retrieveCode: string - }) => { - shareData.value.push(data) + + const addShareDataRecord = (record: SentFileRecord) => { + shareData.value.push(record) } - + const deleteShareData = (index: number) => { - if (index > -1 && index < shareData.value.length) { + if (index >= 0 && index < shareData.value.length) { shareData.value.splice(index, 1) } } - + const clearShareData = () => { shareData.value = [] } 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, + shareData, addReceiveData, removeReceiveData, deleteReceiveData, clearReceiveData, - - // 分享数据状态和方法 - shareData, addShareDataRecord, deleteShareData, clearShareData diff --git a/src/types/api.ts b/src/types/api.ts new file mode 100644 index 0000000..3ae1779 --- /dev/null +++ b/src/types/api.ts @@ -0,0 +1,10 @@ +export interface ApiResponse { + code: number + message?: string + detail?: T +} + +export interface ApiErrorPayload { + detail?: string + message?: string +} diff --git a/src/types/auth.ts b/src/types/auth.ts new file mode 100644 index 0000000..8558757 --- /dev/null +++ b/src/types/auth.ts @@ -0,0 +1,5 @@ +export interface AdminUser { + id: string + username: string + token: string +} diff --git a/src/types/config.ts b/src/types/config.ts new file mode 100644 index 0000000..1272c0d --- /dev/null +++ b/src/types/config.ts @@ -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 +} diff --git a/src/types/dashboard.ts b/src/types/dashboard.ts new file mode 100644 index 0000000..5d402d3 --- /dev/null +++ b/src/types/dashboard.ts @@ -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 +} diff --git a/src/types/file.ts b/src/types/file.ts new file mode 100644 index 0000000..4102f0d --- /dev/null +++ b/src/types/file.ts @@ -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 diff --git a/src/types/index.ts b/src/types/index.ts index 00169ab..9553f6d 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -1,243 +1,7 @@ -// 通用类型定义 -export interface ApiResponse { - code: number - message?: string - detail?: T -} - -// 文件相关类型 -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 -} +export * from './api' +export * from './auth' +export * from './config' +export * from './dashboard' +export * from './file' +export * from './presign-upload' +export * from './ui' diff --git a/src/types/presign-upload.ts b/src/types/presign-upload.ts new file mode 100644 index 0000000..8f38144 --- /dev/null +++ b/src/types/presign-upload.ts @@ -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 +} diff --git a/src/types/ui.ts b/src/types/ui.ts new file mode 100644 index 0000000..8a8e21f --- /dev/null +++ b/src/types/ui.ts @@ -0,0 +1,26 @@ +export type ThemeMode = 'light' | 'dark' | 'system' + +export type SendType = 'file' | 'text' + +export type AlertType = 'success' | 'error' | 'warning' | 'info' + +export interface Alert { + id: number + message: string + type: AlertType + progress: number + duration: number + startTime: number +} + +export type UploadStatus = 'idle' | 'uploading' | 'success' | 'error' + +export interface RouteConfig { + path: string + name: string + component: () => Promise<{ default: object }> + meta?: { + requiresAuth?: boolean + title?: string + } +} diff --git a/src/utils/api.ts b/src/utils/api.ts index 5ccf4e1..f4e76cb 100644 --- a/src/utils/api.ts +++ b/src/utils/api.ts @@ -1,74 +1,2 @@ -import axios from 'axios' -import { TIME_CONSTANTS } from '@/constants' - -// 从环境变量中获取 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 +export { apiBaseURL, rawApiClient } from '@/services/client' +export { default } from '@/services/client' diff --git a/src/utils/auth-storage.ts b/src/utils/auth-storage.ts new file mode 100644 index 0000000..ff59afb --- /dev/null +++ b/src/utils/auth-storage.ts @@ -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) +} diff --git a/src/utils/clipboard-paste.ts b/src/utils/clipboard-paste.ts new file mode 100644 index 0000000..4a44e0a --- /dev/null +++ b/src/utils/clipboard-paste.ts @@ -0,0 +1,40 @@ +export const getClipboardFile = (items: DataTransferItemList): File | null => { + for (let index = 0; index < items.length; index++) { + const item = items[index] + if (item.kind !== 'file') { + continue + } + + const file = item.getAsFile() + if (file) { + return file + } + } + return null +} + +export type TextInsertionInput = { + text: string + insertText: string + selectionStart: number + selectionEnd: number +} + +export type TextInsertionResult = { + value: string + cursor: number +} + +export const insertTextAtSelection = ({ + text, + insertText, + selectionStart, + selectionEnd +}: TextInsertionInput): TextInsertionResult => { + const beforeSelection = text.substring(0, selectionStart) + const afterSelection = text.substring(selectionEnd) + return { + value: beforeSelection + insertText + afterSelection, + cursor: selectionStart + insertText.length + } +} diff --git a/src/utils/clipboard.ts b/src/utils/clipboard.ts index ddea5dd..1f38d21 100644 --- a/src/utils/clipboard.ts +++ b/src/utils/clipboard.ts @@ -2,11 +2,15 @@ * 剪贴板工具函数 */ -import { useAlertStore } from '@/stores/alertStore' +import { buildRetrieveUrl, buildWgetCommand } from '@/utils/share-url' + +type CopyNotifyType = 'success' | 'error' + interface CopyOptions { successMsg?: string errorMsg?: string showMsg?: boolean + notify?: (message: string, type: CopyNotifyType) => void } /** @@ -19,13 +23,24 @@ export const copyToClipboard = async ( text: string, options: CopyOptions = {} ): Promise => { - const { successMsg = '复制成功', errorMsg = '复制失败,请手动复制', showMsg = true } = options - const alertStore = useAlertStore() + const { + successMsg = '复制成功', + errorMsg = '复制失败,请手动复制', + showMsg = true, + notify + } = options + + const showCopyMessage = (message: string, type: CopyNotifyType) => { + if (showMsg) { + notify?.(message, type) + } + } + try { // 优先使用 Clipboard API if (document.hasFocus() && navigator.clipboard && navigator.clipboard.writeText) { await navigator.clipboard.writeText(text) - if (showMsg) alertStore.showAlert(successMsg, 'success') + showCopyMessage(successMsg, 'success') return true } // 后备方案:使用传统的复制方法 @@ -38,14 +53,14 @@ export const copyToClipboard = async ( const success = document.execCommand('copy') document.body.removeChild(textarea) if (success) { - if (showMsg) alertStore.showAlert(successMsg, 'success') + showCopyMessage(successMsg, 'success') return true } else { throw new Error('execCommand copy failed') } } catch (err) { console.error('复制失败:', err) - if (showMsg) alertStore.showAlert(errorMsg, 'error') + showCopyMessage(errorMsg, 'error') return false } } @@ -55,11 +70,15 @@ export const copyToClipboard = async ( * @param code 取件码 * @returns Promise 是否复制成功 */ -export const copyRetrieveLink = async (code: string): Promise => { - const link = `${window.location.origin}/#/?code=${code}` +export const copyRetrieveLink = async ( + code: string, + options: Pick = {} +): Promise => { + const link = buildRetrieveUrl(code) return copyToClipboard(link, { successMsg: '取件链接已复制到剪贴板', - errorMsg: '复制失败,请手动复制取件链接' + errorMsg: '复制失败,请手动复制取件链接', + ...options }) } @@ -68,50 +87,26 @@ export const copyRetrieveLink = async (code: string): Promise => { * @param code 取件码 * @returns Promise 是否复制成功 */ -export const copyRetrieveCode = async (code: string): Promise => { +export const copyRetrieveCode = async ( + code: string, + options: Pick = {} +): Promise => { return copyToClipboard(code, { successMsg: '取件码已复制到剪贴板', - errorMsg: '复制失败,请手动复制取件码' + errorMsg: '复制失败,请手动复制取件码', + ...options }) } -const baseUrl = window.location.origin + '/' - -export const copyWgetCommand = (retrieveCode: string, fileName: string) => { - const command = `wget ${baseUrl}share/select?code=${retrieveCode} -O "${fileName}"` - - if (navigator.clipboard && navigator.clipboard.writeText) { - navigator.clipboard - .writeText(command) - .then(() => { - console.log('命令已复制到剪贴板!') - }) - .catch((err) => { - console.error('复制失败,使用回退方法:', err) - fallbackCopyTextToClipboard(command) - }) - } else { - console.warn('Clipboard API 不可用,使用回退方法。') - fallbackCopyTextToClipboard(command) - } -} -function fallbackCopyTextToClipboard(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) +export const copyWgetCommand = ( + retrieveCode: string, + fileName: string, + options: Pick = {} +) => { + const command = buildWgetCommand(retrieveCode, fileName) + void copyToClipboard(command, { + successMsg: '命令已复制到剪贴板', + errorMsg: '复制失败,请手动复制命令', + ...options + }) } diff --git a/src/utils/common.ts b/src/utils/common.ts index cd5c150..f818fe6 100644 --- a/src/utils/common.ts +++ b/src/utils/common.ts @@ -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}` } -/** - * 复制文本到剪贴板 - * @param text 要复制的文本 - * @returns Promise 是否复制成功 - */ -export async function copyToClipboard(text: string): Promise { - 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 要防抖的函数 @@ -241,4 +212,25 @@ export function isMobile(): boolean { */ export function formatNumber(num: number): string { return num.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ',') -} \ No newline at end of file +} + +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 + ) +} diff --git a/src/utils/config-form.ts b/src/utils/config-form.ts new file mode 100644 index 0000000..4f14636 --- /dev/null +++ b/src/utils/config-form.ts @@ -0,0 +1,107 @@ +import type { ConfigState } from '@/types' + +export type FileSizeUnit = 'KB' | 'MB' | 'GB' +export type SaveTimeUnit = '秒' | '分' | '时' | '天' + +export type FileSizeForm = { + value: number + unit: FileSizeUnit +} + +export type SaveTimeForm = { + value: number + unit: SaveTimeUnit +} + +const FILE_SIZE_UNITS: Record = { + KB: 1024, + MB: 1024 * 1024, + GB: 1024 * 1024 * 1024 +} + +const SAVE_TIME_UNITS: Record = { + 秒: 1, + 分: 60, + 时: 3600, + 天: 86400 +} + +export function bytesToFileSizeForm(bytes: number): FileSizeForm { + if (bytes >= FILE_SIZE_UNITS.GB) { + return { + value: Math.round(bytes / FILE_SIZE_UNITS.GB), + unit: 'GB' + } + } + + if (bytes >= FILE_SIZE_UNITS.MB) { + return { + value: Math.round(bytes / FILE_SIZE_UNITS.MB), + unit: 'MB' + } + } + + return { + value: Math.round(bytes / FILE_SIZE_UNITS.KB), + unit: 'KB' + } +} + +export function fileSizeFormToBytes(value: number, unit: FileSizeUnit): number { + return value * FILE_SIZE_UNITS[unit] +} + +export function secondsToSaveTimeForm(seconds: number): SaveTimeForm { + if (seconds === 0) { + return { + value: 7, + unit: '天' + } + } + + if (seconds % SAVE_TIME_UNITS.天 === 0 && seconds >= SAVE_TIME_UNITS.天) { + return { + value: seconds / SAVE_TIME_UNITS.天, + unit: '天' + } + } + + if (seconds % SAVE_TIME_UNITS.时 === 0 && seconds >= SAVE_TIME_UNITS.时) { + return { + value: seconds / SAVE_TIME_UNITS.时, + unit: '时' + } + } + + if (seconds % SAVE_TIME_UNITS.分 === 0 && seconds >= SAVE_TIME_UNITS.分) { + return { + value: seconds / SAVE_TIME_UNITS.分, + unit: '分' + } + } + + return { + value: seconds, + unit: '秒' + } +} + +export function saveTimeFormToSeconds(value: number, unit: SaveTimeUnit): number { + if (value === 0) { + return 7 * SAVE_TIME_UNITS.天 + } + + return value * SAVE_TIME_UNITS[unit] +} + +export function buildConfigSubmitPayload( + config: ConfigState, + fileSize: FileSizeForm, + saveTime: SaveTimeForm +): ConfigState { + return { + ...config, + uploadSize: fileSizeFormToBytes(fileSize.value, fileSize.unit), + max_save_seconds: saveTimeFormToSeconds(saveTime.value, saveTime.unit) + } +} diff --git a/src/utils/config-storage.ts b/src/utils/config-storage.ts new file mode 100644 index 0000000..2c03801 --- /dev/null +++ b/src/utils/config-storage.ts @@ -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 | 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 | null | undefined): Partial { + 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))) +} + +export function readNotifyKey(): string | null { + return localStorage.getItem(STORAGE_KEYS.NOTIFY) +} + +export function writeNotifyKey(notifyKey: string) { + localStorage.setItem(STORAGE_KEYS.NOTIFY, notifyKey) +} diff --git a/src/utils/content-preview.ts b/src/utils/content-preview.ts new file mode 100644 index 0000000..7d172b8 --- /dev/null +++ b/src/utils/content-preview.ts @@ -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 { + 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 + } +} diff --git a/src/utils/download-action.ts b/src/utils/download-action.ts new file mode 100644 index 0000000..be34bef --- /dev/null +++ b/src/utils/download-action.ts @@ -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`) + } +} diff --git a/src/utils/file-processing.ts b/src/utils/file-processing.ts new file mode 100644 index 0000000..ba91f76 --- /dev/null +++ b/src/utils/file-processing.ts @@ -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 => { + const hashBuffer = await crypto.subtle.digest('SHA-256', data) + const hashArray = Array.from(new Uint8Array(hashBuffer)) + return hashArray.map((byte) => byte.toString(16).padStart(2, '0')).join('') +} + +export const calculateFileHash = async (file: File): Promise => { + try { + if (file.size <= SMALL_FILE_HASH_LIMIT) { + const buffer = await file.arrayBuffer() + return window.isSecureContext ? createSha256Hash(buffer) : generateFallbackHash(file) + } + + const firstChunk = file.slice(0, LARGE_FILE_HASH_CHUNK_SIZE) + const lastChunk = file.slice(-LARGE_FILE_HASH_CHUNK_SIZE) + const [firstBuffer, lastBuffer] = await Promise.all([ + firstChunk.arrayBuffer(), + lastChunk.arrayBuffer() + ]) + const combined = new Uint8Array(firstBuffer.byteLength + lastBuffer.byteLength + 16) + combined.set(new Uint8Array(firstBuffer), 0) + combined.set(new Uint8Array(lastBuffer), firstBuffer.byteLength) + const sizeBytes = new TextEncoder().encode(file.size.toString()) + combined.set(sizeBytes, firstBuffer.byteLength + lastBuffer.byteLength) + + return window.isSecureContext ? createSha256Hash(combined) : generateFallbackHash(file) + } catch (error) { + console.error('File hash calculation failed:', error) + return generateFallbackHash(file) + } +} + +export const packFilesAsZip = async (files: File[]): Promise => { + const zip = new JSZip() + for (const file of files) { + zip.file(file.name, file) + } + const blob = await zip.generateAsync({ + type: 'blob', + compression: 'DEFLATE', + compressionOptions: { level: 6 } + }) + return new File([blob], `files_${Date.now()}.zip`, { type: 'application/zip' }) +} diff --git a/src/utils/preference-storage.ts b/src/utils/preference-storage.ts new file mode 100644 index 0000000..6582123 --- /dev/null +++ b/src/utils/preference-storage.ts @@ -0,0 +1,20 @@ +import { STORAGE_KEYS } from '@/constants' +import type { ThemeMode } from '@/types' + +const LOCALE_STORAGE_KEY = 'locale' + +export function readStoredThemeMode(): string | null { + return localStorage.getItem(STORAGE_KEYS.COLOR_MODE) +} + +export function writeStoredThemeMode(mode: ThemeMode) { + localStorage.setItem(STORAGE_KEYS.COLOR_MODE, mode) +} + +export function readStoredLocale(): string | null { + return localStorage.getItem(LOCALE_STORAGE_KEY) +} + +export function writeStoredLocale(locale: string) { + localStorage.setItem(LOCALE_STORAGE_KEY, locale) +} diff --git a/src/utils/send-record.ts b/src/utils/send-record.ts new file mode 100644 index 0000000..00fe046 --- /dev/null +++ b/src/utils/send-record.ts @@ -0,0 +1,99 @@ +import type { ApiResponse, SendType, SentFileRecord } from '@/types' + +type Translate = (key: string, params?: Record) => string + +type BuildSentRecordInput = { + response: ApiResponse + sendType: SendType + textContent: string + selectedFile: File | null + selectedFiles: File[] + expirationMethod: string + expirationValue: string + translate: Translate + getUnit: (method: string) => string +} + +const expirationSecondsByMethod: Record = { + minute: 60, + hour: 3600, + day: 86400 +} + +export function isExpirationWithinLimit( + method: string, + value: string, + maxSaveSeconds: number +): boolean { + if (method === 'forever' || method === 'count') return true + if (maxSaveSeconds === 0) return true + + const multiplier = expirationSecondsByMethod[method] + if (!multiplier) return false + + return parseInt(value) * multiplier <= maxSaveSeconds +} + +export function formatExpirationTime( + method: string, + value: string, + translate: Translate, + getUnit: (method: string) => string +): string { + if (method === 'forever') return translate('send.expiration.units.forever') + if (method === 'count') return translate('send.messages.expiresAfterCount', { count: value }) + + const now = new Date() + const expireValue = parseInt(value) + + switch (method) { + case 'minute': + now.setMinutes(now.getMinutes() + expireValue) + break + case 'hour': + now.setHours(now.getHours() + expireValue) + break + case 'day': + now.setDate(now.getDate() + expireValue) + break + default: + return translate('send.messages.expiresAfter', { value, unit: getUnit(method) }) + } + + const year = now.getFullYear() + const month = (now.getMonth() + 1).toString().padStart(2, '0') + const day = now.getDate().toString().padStart(2, '0') + const hours = now.getHours().toString().padStart(2, '0') + const minutes = now.getMinutes().toString().padStart(2, '0') + return translate('send.messages.expiresAt', { date: `${year}-${month}-${day} ${hours}:${minutes}` }) +} + +export function buildSentRecord(input: BuildSentRecordInput): SentFileRecord { + const retrieveCode = (input.response.detail as { code?: string } | undefined)?.code || '' + const fileName = (input.response.detail as { name?: string } | undefined)?.name || '' + + const totalSelectedSize = input.selectedFiles.reduce((total, file) => total + file.size, 0) + const displaySize = + input.sendType === 'text' + ? `${(input.textContent.length / 1024).toFixed(2)} KB` + : input.selectedFiles.length > 0 + ? `${(totalSelectedSize / (1024 * 1024)).toFixed(1)} MB` + : `${((input.selectedFile?.size || 0) / (1024 * 1024)).toFixed(1)} MB` + + return { + id: Date.now(), + filename: fileName, + date: new Date().toISOString().split('T')[0], + size: displaySize, + expiration: + input.expirationMethod === 'forever' + ? input.translate('send.expiration.forever') + : formatExpirationTime( + input.expirationMethod, + input.expirationValue, + input.translate, + input.getUnit + ), + retrieveCode + } +} diff --git a/src/utils/sent-record-actions.ts b/src/utils/sent-record-actions.ts new file mode 100644 index 0000000..944cbb0 --- /dev/null +++ b/src/utils/sent-record-actions.ts @@ -0,0 +1,17 @@ +import type { SentFileRecord } from '@/types' +import { copyRetrieveCode, copyRetrieveLink, copyWgetCommand } from '@/utils/clipboard' +import { buildSentRecordQrValue } from '@/utils/share-url' + +type CopyNotify = (message: string, type: 'success' | 'error') => void + +export function createSentRecordActions(notify: CopyNotify) { + return { + copyLink: (record: SentFileRecord) => + copyRetrieveLink(record.retrieveCode, { notify }), + copyCode: (record: SentFileRecord) => + copyRetrieveCode(record.retrieveCode, { notify }), + copyWgetCommand: (record: SentFileRecord) => + copyWgetCommand(record.retrieveCode, record.filename, { notify }), + getQRCodeValue: (record: SentFileRecord) => buildSentRecordQrValue(record) + } +} diff --git a/src/utils/share-url.ts b/src/utils/share-url.ts new file mode 100644 index 0000000..b361ab5 --- /dev/null +++ b/src/utils/share-url.ts @@ -0,0 +1,38 @@ +import { apiBaseURL } from '@/services/client' + +const getApiOrigin = () => { + if (!apiBaseURL) return window.location.origin + return new URL(apiBaseURL, window.location.origin).origin +} + +export function buildAbsoluteUrl(path: string): string { + if (/^https?:\/\//i.test(path)) { + return path + } + + const normalizedPath = path.startsWith('/') ? path : `/${path}` + return `${getApiOrigin()}${normalizedPath}` +} + +export function buildRetrieveUrl(code: string): string { + return `${window.location.origin}/#/?code=${code}` +} + +export function buildDownloadUrl(downloadUrl: string | null): string { + return downloadUrl ? buildAbsoluteUrl(downloadUrl) : '' +} + +export function buildReceivedRecordQrValue(record: { + code: string + downloadUrl: string | null +}): string { + return record.downloadUrl ? buildDownloadUrl(record.downloadUrl) : buildRetrieveUrl(record.code) +} + +export function buildSentRecordQrValue(record: { retrieveCode: string }): string { + return buildRetrieveUrl(record.retrieveCode) +} + +export function buildWgetCommand(retrieveCode: string, fileName: string): string { + return `wget ${buildAbsoluteUrl(`/share/select?code=${retrieveCode}`)} -O "${fileName}"` +} diff --git a/src/views/RetrievewFileView.vue b/src/views/RetrievewFileView.vue index 49f7c69..9430646 100644 --- a/src/views/RetrievewFileView.vue +++ b/src/views/RetrievewFileView.vue @@ -42,24 +42,23 @@
- - diff --git a/src/views/SendFileView.vue b/src/views/SendFileView.vue index 7d0e9c0..a65be2c 100644 --- a/src/views/SendFileView.vue +++ b/src/views/SendFileView.vue @@ -15,7 +15,7 @@
@@ -25,7 +25,7 @@ :selected-file="selectedFile" :selected-files="selectedFiles" :progress="uploadProgress" - :description="`支持各种常见格式,最大${getStorageUnit(config.uploadSize)}`" + :description="uploadDescription" @file-selected="handleFileSelected" @files-selected="handleFilesSelected" @file-drop="handleFileDrop" @@ -35,135 +35,11 @@ - -
- -
-
- - -
- - - -
-
-
-
+ - -
- -
-
- -
-
-

- {{ record.filename ? record.filename : 'Text' }} -

-

- {{ record.date }} · {{ record.size }} -

-
-
- - - -
-
-
-
- - + + + - - -
-
- -
-
-

- {{ t('send.fileDetails') }} -

- -
-
- - -
- -
-
-
- -
-
-

- {{ selectedRecord.filename }} -

-

- {{ selectedRecord.size }} · {{ selectedRecord.date }} -

-
-
-
-
- - - {{ selectedRecord.expiration }} - -
-
- - - 安全加密 - -
-
-
- - -
- -
-
-
-

取件码

- -
-

- {{ selectedRecord.retrieveCode }} -

-
- -
-
-

- - wget下载 -

- -
-

- 点击复制wget命令 -

-
-
- - -
-
- -
-

- 扫描二维码快速取件 -

-
-
-
- - -
- -
-
-
-
+