diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 6cc6c74..04c132b 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -29,6 +29,7 @@ jobs: with: apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }} accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + wranglerVersion: '4' preCommands: ./prepare.sh packageManager: pnpm command: deploy --env production diff --git a/.prettierignore b/.prettierignore index 57d8d4b..6c0efc1 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1,4 +1,5 @@ **/.* +.wrangler dist node_modules /data/migrations diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..59d4a65 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,207 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +Cloudflare Drop 是一個基於 Cloudflare Workers 的輕量級文件分享工具,使用 D1 Database(SQLite)和 KV 存儲實現文件上傳、分享和管理功能。 + +**技術棧**: + +- **後端**: Cloudflare Workers + Hono (Web framework) + Chanfana (OpenAPI) +- **數據庫**: Drizzle ORM + D1 Database (SQLite) +- **存儲**: Cloudflare KV (文件二進制存儲) +- **前端**: Preact + Vite + MobX + Material-UI +- **包管理器**: pnpm (必須使用,不要用 npm 或 yarn) + +## Development Commands + +### 本地開發 + +```bash +# 首次啟動前的準備工作(生成 wrangler.toml、應用遷移) +pnpm prestart + +# 同時啟動前後端開發伺服器(推薦) +pnpm start + +# 僅啟動前端(Vite dev server,端口由 SHARE_PORT 環境變量決定) +pnpm dev:web + +# 僅啟動 Worker(Wrangler dev server,監聽 0.0.0.0) +pnpm dev:app +``` + +### 構建和部署 + +```bash +# 構建前端靜態資源 +pnpm build:web + +# 生成數據庫遷移文件(修改 schema 後執行) +pnpm generate + +# 部署到生產環境(包含前置任務:構建前端 + 生成遷移 + 應用遷移) +pnpm deploy +``` + +### 數據庫遷移 + +```bash +# 應用本地 D1 遷移 +wrangler d1 migrations apply airdrop --local + +# 應用生產環境遷移 +wrangler d1 migrations apply airdrop --remote --env production +``` + +### 代碼質量 + +```bash +# 自動格式化和修復 lint 錯誤 +pnpm lint +``` + +## Architecture + +### 後端架構(Cloudflare Worker) + +**入口**: `src/index.ts` +使用 Hono 框架構建 API,通過 Chanfana 提供 OpenAPI 支持。 + +**核心組件**: + +- **Endpoint 基類** (`src/endpoint.ts`): 所有 API 端點的基類 + + - `getDB(c)`: 獲取 Drizzle D1 Database 實例 + - `getKV(c)`: 獲取 KV Namespace 實例 + - `success(data)`: 統一成功響應格式 + - `error(message)`: 統一錯誤響應格式 + +- **中間件系統** (`src/middlewares/`): + + - `db.middleware.ts`: 注入 Drizzle DB 實例到 context + - `auth.middleware.ts`: 管理後台認證(驗證 ADMIN_TOKEN) + - `limit.middleware.ts`: 上傳頻率限制(使用 Cloudflare Rate Limit API) + - `terminal.middleware.ts`: 終端處理中間件 + +- **文件處理** (`src/files/`): + + - `fileCreate.ts`: 創建文件分享記錄 + - `fileChunkCreate.ts`: 上傳文件分塊(支持大文件分塊上傳) + - `mergeFileChunk.ts`: 合併文件分塊 + - `fileFetch.ts`: 下載文件 + - `fileShareCodeFetch.ts`: 根據分享碼獲取文件信息 + +- **管理後台** (`src/admin/`): + + - `listShares.ts`: 列出所有分享 + - `deleteShare.ts`: 刪除分享 + - `getInfo.ts`: 獲取統計信息 + +- **定時任務** (`src/scheduled.ts`): + 每 10 分鐘執行一次,清理過期的 KV 存儲和 D1 記錄(見 `wrangler.example.toml` 的 `triggers.crons`) + +### 前端架構(Preact) + +**入口**: `web/index.tsx` +使用 Preact + MobX 狀態管理 + Material-UI 組件庫。 + +**目錄結構**: + +- `web/views/`: 頁面組件 + - `Home/`: 首頁(文件上傳和分享) + - `Admin/`: 管理後台 +- `web/components/`: 可重用組件 +- `web/api/`: API 客戶端(Axios) +- `web/theme/`: Material-UI 自定義主題 +- `web/helpers/`: 工具函數 + +### 數據庫架構(D1 + Drizzle) + +**Schema 定義**: `data/schemas/files.schema.ts` +**遷移目錄**: `data/migrations/` +**配置文件**: `data/drizzle.config.ts` + +主要表結構: + +- `files`: 文件分享記錄 + - `id`: 主鍵(CUID2) + - `objectId`: KV 中的文件 ID + - `filename`: 文件名 + - `hash`: 文件 hash 值 + - `code`: 分享碼(唯一) + - `size`: 文件大小 + - `is_ephemeral`: 是否閱後即焚 + - `expires_at`: 過期時間 + - `created_at`: 創建時間 + +## Environment Configuration + +### 本地開發 + +創建 `.dev.vars` 文件(參考 `.dev.vars.example`): + +```bash +ADMIN_TOKEN=your-admin-token +SHARE_DURATION=1hour +SHARE_MAX_SIZE_IN_MB=10 +``` + +### Cloudflare 配置 + +需要在 Cloudflare Dashboard 創建: + +1. **D1 Database** (名稱: `airdrop`) +2. **KV Namespace** (binding: `file_drops`) + +然後配置 `wrangler.toml`(可通過 `prepare.sh` 自動生成): + +- `D1_ID` 和 `D1_NAME`: D1 Database 配置 +- `KV_ID`: KV Namespace ID +- `CUSTOM_DOMAIN`: 自定義域名(可選) +- `RATE_LIMIT`: 上傳頻率限制(每 10 秒的請求數) + +## Key Patterns + +### API 響應格式 + +所有 API 端點使用統一的響應格式(來自 `Endpoint` 基類): + +```typescript +// 成功 +{ message: 'ok', result: true, data: } + +// 錯誤 +{ message: , result: false, data: null } +``` + +### 文件上傳流程 + +1. 前端將大文件分塊(通過 `fileChunkCreate` 端點) +2. 所有分塊上傳完成後調用 `mergeFileChunk` 合併 +3. 合併後返回分享碼和下載鏈接 + +### 分享碼生成 + +使用 CUID2 生成唯一的分享碼(見 `@paralleldrive/cuid2`) + +### 認證機制 + +管理後台通過 URL 路徑中的 token 認證: +`/admin/{ADMIN_TOKEN}` - 訪問管理後台 +後端通過 `auth.middleware.ts` 驗證 + +## Important Notes + +- **使用 pnpm**: 項目配置了 `packageManager: "pnpm@9.15.3"`,必須使用 pnpm +- **TypeScript 配置**: 專案有多個 tsconfig 文件: + - `tsconfig.web.json`: 前端配置 + - `tsconfig.worker.json`: Worker 配置 + - `tsconfig.node.json`: Node.js 工具配置 +- **Drizzle Schema 修改**: 修改 `data/schemas/*.schema.ts` 後必須執行 `pnpm generate` 生成遷移 +- **Worker 限制**: + - Cloudflare Workers 有 CPU 時間限制(免費版 10ms,付費版 50ms) + - 文件通過 KV 存儲,單個值最大 25MB +- **定時任務**: Cron 觸發器僅在生產環境運行,本地開發不會執行 +- **Husky Git Hooks**: 提交前會自動執行 `prettier` 和 `eslint`(見 `lint-staged` 配置) diff --git a/eslint.config.mjs b/eslint.config.mjs index d843933..921a886 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -7,7 +7,7 @@ import prettierConfig from 'eslint-config-prettier' export default [ ...tseslint.config(eslint.configs.recommended, tseslint.configs.recommended), { - ignores: ['**/.*', 'dist'], + ignores: ['**/.*', 'dist/**', '.wrangler/**'], rules: { '@typescript-eslint/no-unused-vars': [ 'error', diff --git a/src/files/fileCreate.ts b/src/files/fileCreate.ts index 632e108..6041bfd 100644 --- a/src/files/fileCreate.ts +++ b/src/files/fileCreate.ts @@ -120,14 +120,14 @@ export class FileCreate extends Endpoint { (!data || data.byteLength === 0) && (!objectId || (Array.isArray(objectId) && !objectId.length)) ) { - return this.error('分享内容为空') + return this.error('EMPTY_CONTENT') } const envMax = Number.parseInt(c.env.SHARE_MAX_SIZE_IN_MB, 10) const max = Number.isNaN(envMax) || envMax <= 0 ? 10 : envMax if (size > max * 1000 * 1000) { - return this.error(`文件大于 ${max}M`) + return this.error('FILE_TOO_LARGE') } const kv = this.getKV(c) @@ -140,7 +140,7 @@ export class FileCreate extends Endpoint { } else if (typeof objectId === 'string') { const cacheFile = await kv.get(objectId, 'stream') if (!cacheFile) { - return this.error('分片上传的文件不存在') + return this.error('CHUNK_NOT_FOUND') } // 分片存储 } else if (Array.isArray(objectId) && objectId.length) { @@ -172,7 +172,7 @@ export class FileCreate extends Endpoint { const shareCode = shareCodes.find((d) => !records.includes(d)) if (!shareCode) { - return this.error('分享码生成失败,请重试') + return this.error('CODE_GENERATION_FAILED') } const [due, dueType] = resolveDuration(duration || c.env.SHARE_DURATION) diff --git a/src/files/fileFetch.ts b/src/files/fileFetch.ts index 2a189d7..443703a 100644 --- a/src/files/fileFetch.ts +++ b/src/files/fileFetch.ts @@ -44,13 +44,13 @@ export class FileFetch extends Endpoint { const kv = this.getKV(c) const tokenValue = await kv.get(token, 'text') if (!tokenValue || tokenValue !== token) { - return this.error('无效的 token', true) + return this.error('INVALID_TOKEN', true) } await kv.delete(token) const db = this.getDB(c) const [record] = await db.select().from(files).where(eq(files.id, id)) if (!record) { - return this.error('无效的 object id', true) + return this.error('INVALID_OBJECT_ID', true) } const objectId = record.objectId diff --git a/src/files/fileShareCodeFetch.ts b/src/files/fileShareCodeFetch.ts index 40e824c..a81d377 100644 --- a/src/files/fileShareCodeFetch.ts +++ b/src/files/fileShareCodeFetch.ts @@ -67,12 +67,12 @@ export class FileShareCodeFetch extends Endpoint { const file = await getFile(db, code) if (!file) { - return this.error('分享码无效') + return this.error('INVALID_CODE') } const day = dayjs(file.due_date) if (day.isBefore(dayjs())) { - return this.error('分享已过期') + return this.error('SHARE_EXPIRED') } const { objectId, ...rest } = file diff --git a/tsconfig.worker.json b/tsconfig.worker.json index 013a674..ffc5740 100644 --- a/tsconfig.worker.json +++ b/tsconfig.worker.json @@ -22,7 +22,11 @@ "outDir": "", "target": "esnext", "module": "esnext", - "types": ["@types/node", "@types/service-worker-mock", "@cloudflare/workers-types/2023-07-01"], + "types": [ + "@types/node", + "@types/service-worker-mock", + "@cloudflare/workers-types/2023-07-01" + ], "paths": { "@data/*": ["./data/*"] } diff --git a/web/api/uploader.ts b/web/api/uploader.ts index b1693b1..12788c5 100644 --- a/web/api/uploader.ts +++ b/web/api/uploader.ts @@ -1,5 +1,6 @@ import axios, { AxiosProgressEvent } from 'axios' import { getUserUUID } from '../helpers' +import { i18nStore } from '../i18n' interface ChunkInfo { sha: string @@ -89,7 +90,7 @@ export class Uploader { const { data } = await axios.put('/files', formData) return data as ApiResponseType } - throw new Error('建议使用 R2') + throw new Error(i18nStore.t('errors', 'suggestR2')) } static async uploadWithChunk( @@ -231,7 +232,7 @@ export class Uploader { const data: ApiResponseType = await response.json() if (!data.result) { - throw new Error('获取分片信息失败') + throw new Error(i18nStore.t('errors', 'chunkInfoFailed')) } return data.data! } diff --git a/web/components/Layout.tsx b/web/components/Layout.tsx index baa63ff..75963df 100644 --- a/web/components/Layout.tsx +++ b/web/components/Layout.tsx @@ -9,6 +9,7 @@ import CircularProgress from '@mui/material/CircularProgress' import IconButton from '@mui/material/IconButton' import { Message, useMessage, Github } from './' +import { LanguageSwitch } from '../i18n' export interface LayoutProps { children?: ComponentChildren @@ -58,16 +59,15 @@ export function Layout({ children }: LayoutProps) { - - - + + + + + + {injectedChildren} diff --git a/web/helpers/encryptor.ts b/web/helpers/encryptor.ts index 202a6ec..852550c 100644 --- a/web/helpers/encryptor.ts +++ b/web/helpers/encryptor.ts @@ -1,5 +1,6 @@ // @ts-expect-error sub module import { ArgonType, hash } from 'argon2-browser/dist/argon2-bundled.min.js' +import { i18nStore } from '../i18n' export class Encryptor { private static HEADER_METADATA_SIZE = 4 // 增加版本信息(2 字节) @@ -91,7 +92,8 @@ export class Encryptor { ), ) const version = new DataView(header.buffer).getUint16(0, true) - if (version !== this.VERSION) throw new Error('版本不匹配') + if (version !== this.VERSION) + throw new Error(i18nStore.t('errors', 'versionMismatch')) const salt = header.slice( Encryptor.VERSION_LENGTH, @@ -136,7 +138,7 @@ export class Encryptor { encryptedData, ) if (!this.compareBuffers(dataHash, recalculatedHash)) { - throw new Error('数据完整性校验失败') + throw new Error(i18nStore.t('errors', 'integrityCheckFailed')) } return new Blob([decryptedData]) diff --git a/web/helpers/errorMapper.ts b/web/helpers/errorMapper.ts new file mode 100644 index 0000000..3e00e04 --- /dev/null +++ b/web/helpers/errorMapper.ts @@ -0,0 +1,46 @@ +import { i18nStore } from '../i18n/store' +import { TranslationKeys } from '../i18n/types' + +/** + * 將後端返回的錯誤碼(如 INVALID_CODE)轉換為 camelCase(如 invalidCode) + */ +function toCamelCase(str: string): string { + return str + .split('_') + .map((word, index) => { + if (index === 0) { + return word.toLowerCase() + } + return word.charAt(0).toUpperCase() + word.slice(1).toLowerCase() + }) + .join('') +} + +/** + * 將後端返回的錯誤訊息映射到 i18n 翻譯 + * @param errorMessage 後端返回的錯誤訊息(可能是錯誤碼如 INVALID_CODE 或其他訊息) + * @returns i18n 翻譯後的錯誤訊息 + */ +export function mapError(errorMessage: string): string { + // 如果錯誤訊息是全大寫或包含底線,可能是錯誤碼 + if (/^[A-Z_]+$/.test(errorMessage)) { + const camelCaseKey = toCamelCase(errorMessage) + + // 嘗試從 i18n errors 區塊獲取翻譯 + try { + const translated = i18nStore.t( + 'errors', + camelCaseKey as keyof TranslationKeys['errors'], + ) + if (translated) { + return translated + } + } catch (_e) { + // 如果找不到翻譯,返回 unknownError + return i18nStore.t('errors', 'unknownError') + } + } + + // 如果不是錯誤碼格式,直接返回原始訊息(向後兼容) + return errorMessage || i18nStore.t('errors', 'unknownError') +} diff --git a/web/helpers/index.ts b/web/helpers/index.ts index 1ed6e33..6f73893 100644 --- a/web/helpers/index.ts +++ b/web/helpers/index.ts @@ -1,3 +1,4 @@ export * from './encryptor.ts' export * from './uuid.ts' export * from './file.ts' +export * from './errorMapper.ts' diff --git a/web/i18n/LanguageSwitch.tsx b/web/i18n/LanguageSwitch.tsx new file mode 100644 index 0000000..3073221 --- /dev/null +++ b/web/i18n/LanguageSwitch.tsx @@ -0,0 +1,80 @@ +import { useState } from 'preact/hooks' +import { observer } from 'mobx-react-lite' +import IconButton from '@mui/material/IconButton' +import Menu from '@mui/material/Menu' +import MenuItem from '@mui/material/MenuItem' +import ListItemText from '@mui/material/ListItemText' +import ListItemIcon from '@mui/material/ListItemIcon' +import CheckIcon from '@mui/icons-material/Check' +import LanguageIcon from '@mui/icons-material/Language' +import { useTranslation, Locale } from '.' + +const LANGUAGE_OPTIONS: Array<{ + locale: Locale + label: string + emoji: string +}> = [ + { locale: 'zh-CN', label: '简体中文', emoji: '🇨🇳' }, + { locale: 'zh-TW', label: '繁體中文', emoji: '🇹🇼' }, + { locale: 'en', label: 'English', emoji: '🇺🇸' }, +] + +export const LanguageSwitch = observer(() => { + const { locale, setLocale } = useTranslation() + const [anchorEl, setAnchorEl] = useState(null) + const open = Boolean(anchorEl) + + const handleClick = (event: MouseEvent) => { + setAnchorEl(event.currentTarget as HTMLElement) + } + + const handleClose = () => { + setAnchorEl(null) + } + + const handleSelectLocale = (newLocale: Locale) => { + setLocale(newLocale) + handleClose() + } + + return ( + <> + + + + + {LANGUAGE_OPTIONS.map((option) => ( + handleSelectLocale(option.locale)} + > + {locale === option.locale && ( + + + + )} + + {option.emoji} {option.label} + + + ))} + + + ) +}) diff --git a/web/i18n/index.ts b/web/i18n/index.ts new file mode 100644 index 0000000..ee3d623 --- /dev/null +++ b/web/i18n/index.ts @@ -0,0 +1,4 @@ +export { useTranslation } from './useTranslation' +export { i18nStore } from './store' +export { LanguageSwitch } from './LanguageSwitch' +export type { Locale, TranslationKeys } from './types' diff --git a/web/i18n/locales/en.ts b/web/i18n/locales/en.ts new file mode 100644 index 0000000..c779920 --- /dev/null +++ b/web/i18n/locales/en.ts @@ -0,0 +1,98 @@ +import { TranslationKeys } from '../types' + +export const en: TranslationKeys = { + common: { + confirm: 'Confirm', + cancel: 'Cancel', + copy: 'Copy', + copySuccess: 'Copied successfully', + copyFailed: 'Copy failed', + download: 'Download', + downloading: 'Downloading', + share: 'Share', + close: 'Close', + link: 'Link', + extractCode: 'Extract Code', + verifyTool: 'Verify Tool', + }, + home: { + shareCode: 'Code:', + textShare: 'Text Share', + fileShare: 'File Share', + selectFile: 'Select File', + burnAfterRead: 'Burn After Reading', + history: 'History', + fileTooLarge: 'File is larger than {size}M', + shared: 'Shared by Me', + received: 'Received', + noHistory: 'No history records', + }, + duration: { + default: 'Default', + minute: 'Minute', + hour: 'Hour', + day: 'Day', + week: 'Week', + month: 'Month', + year: 'Year', + permanent: 'Permanent', + expiryConfig: 'Expiry', + expiresIn: 'Expires in {time}', + expiresAt: 'Expires at: ', + permanentValidity: 'Permanent', + }, + password: { + sharePassword: 'Share Password', + enterPassword: 'Enter share password', + clearPassword: 'Clear Password', + encryptedNotice: + 'End-to-end encrypted with AES-GCM. Server does not store passwords. Data cannot be recovered if password is lost.', + decryptFailed: 'Decryption failed', + }, + history: { + noRecords: 'No records', + deleteConfirm: 'Confirm deletion?', + shareCodeClick: 'Share code {code}, click to view', + }, + shareDialog: { + title: 'Share', + hashLabel: 'Original SHA256 Hash', + }, + fileDialog: { + title: 'File Details', + textTitle: 'Text Share', + fileTitle: 'File Share', + encryptedHint: 'Share is encrypted, please decrypt with password', + burnAfterReadWarning: 'Cannot view again after closing. Confirm?', + burnAfterReadTitle: 'Burn After Reading', + hashLabel: 'Original SHA256 Hash', + }, + admin: { + filename: 'Filename', + shareCode: 'Share Code', + size: 'Size', + sizeTooltip: + 'Using binary units: 1 MiB = 1024 × 1024 bytes, slightly different from macOS display', + expiryDate: 'Expiry Date', + isEncrypted: 'Encrypted', + createdAt: 'Created At', + actions: 'Actions', + deleteSelected: 'Delete Selected', + selected: '{count} selected', + }, + errors: { + versionMismatch: 'Version mismatch', + integrityCheckFailed: 'Data integrity check failed', + chunkInfoFailed: 'Failed to get chunk information', + suggestR2: 'Recommend using R2', + invalidCode: 'Invalid share code', + shareExpired: 'Share has expired', + emptyContent: 'Share content is empty', + fileTooLarge: 'File is too large', + chunkNotFound: 'Chunked upload file not found', + codeGenerationFailed: 'Failed to generate share code, please retry', + invalidToken: 'Invalid token', + invalidObjectId: 'Invalid object ID', + unknownError: 'Unknown error', + }, +} diff --git a/web/i18n/locales/index.ts b/web/i18n/locales/index.ts new file mode 100644 index 0000000..3378f08 --- /dev/null +++ b/web/i18n/locales/index.ts @@ -0,0 +1,10 @@ +import { Locale, TranslationKeys } from '../types' +import { zhCN } from './zh-CN' +import { zhTW } from './zh-TW' +import { en } from './en' + +export const locales: Record = { + 'zh-CN': zhCN, + 'zh-TW': zhTW, + en: en, +} diff --git a/web/i18n/locales/zh-CN.ts b/web/i18n/locales/zh-CN.ts new file mode 100644 index 0000000..55b1686 --- /dev/null +++ b/web/i18n/locales/zh-CN.ts @@ -0,0 +1,98 @@ +import { TranslationKeys } from '../types' + +export const zhCN: TranslationKeys = { + common: { + confirm: '确认', + cancel: '取消', + copy: '复制', + copySuccess: '复制成功', + copyFailed: '复制失败', + download: '下载', + downloading: '下载中', + share: '分享', + close: '关闭', + link: '链接', + extractCode: '提取码', + verifyTool: '校验工具', + }, + home: { + shareCode: '分享码:', + textShare: '文本分享', + fileShare: '文件分享', + selectFile: '选择文件', + burnAfterRead: '阅后即焚', + history: '历史记录', + fileTooLarge: '文件大于 {size}M', + shared: '我分享的', + received: '我收到的', + noHistory: '暂无历史记录', + }, + duration: { + default: '默认', + minute: '分钟', + hour: '小时', + day: '天', + week: '周', + month: '月', + year: '年', + permanent: '永久有效', + expiryConfig: '过期配置', + expiresIn: '将在 {time} 后过期', + expiresAt: '预计过期于:', + permanentValidity: '永久有效', + }, + password: { + sharePassword: '分享密码', + enterPassword: '请输入分享密码', + clearPassword: '清空密码', + encryptedNotice: + '采用 AES-GCM 端对端加密,服务器不保存密码,密码丢失数据将无法恢复', + decryptFailed: '解密失败', + }, + history: { + noRecords: '暂无记录', + deleteConfirm: '确认删除?', + shareCodeClick: '分享码 {code},点击查看', + }, + shareDialog: { + title: '分享', + hashLabel: '原始分享 SHA256 Hash 值', + }, + fileDialog: { + title: '文件详情', + textTitle: '文本分享', + fileTitle: '文件分享', + encryptedHint: '分享已加密,请使用密码解密', + burnAfterReadWarning: '关闭后无法再次查看,确认关闭?', + burnAfterReadTitle: '阅后即焚', + hashLabel: '原始分享 SHA256 Hash 值', + }, + admin: { + filename: '文件名', + shareCode: '分享码', + size: '大小', + sizeTooltip: + '使用二进制单位:1 MiB = 1024 × 1024 字节,与 macOS 显示略有不同', + expiryDate: '有效期至', + isEncrypted: '是否加密', + createdAt: '创建时间', + actions: '操作', + deleteSelected: '删除所选', + selected: '已选择 {count} 项', + }, + errors: { + versionMismatch: '版本不匹配', + integrityCheckFailed: '数据完整性校验失败', + chunkInfoFailed: '获取分片信息失败', + suggestR2: '建议使用 R2', + invalidCode: '分享码无效', + shareExpired: '分享已过期', + emptyContent: '分享内容为空', + fileTooLarge: '文件过大', + chunkNotFound: '分片上传的文件不存在', + codeGenerationFailed: '分享码生成失败,请重试', + invalidToken: '无效的 token', + invalidObjectId: '无效的 object id', + unknownError: '未知错误', + }, +} diff --git a/web/i18n/locales/zh-TW.ts b/web/i18n/locales/zh-TW.ts new file mode 100644 index 0000000..2e95045 --- /dev/null +++ b/web/i18n/locales/zh-TW.ts @@ -0,0 +1,98 @@ +import { TranslationKeys } from '../types' + +export const zhTW: TranslationKeys = { + common: { + confirm: '確認', + cancel: '取消', + copy: '複製', + copySuccess: '複製成功', + copyFailed: '複製失敗', + download: '下載', + downloading: '下載中', + share: '分享', + close: '關閉', + link: '連結', + extractCode: '提取碼', + verifyTool: '校驗工具', + }, + home: { + shareCode: '分享碼:', + textShare: '文字分享', + fileShare: '檔案分享', + selectFile: '選擇檔案', + burnAfterRead: '閱後即焚', + history: '歷史記錄', + fileTooLarge: '檔案大於 {size}M', + shared: '我分享的', + received: '我收到的', + noHistory: '暫無歷史記錄', + }, + duration: { + default: '預設', + minute: '分鐘', + hour: '小時', + day: '天', + week: '週', + month: '月', + year: '年', + permanent: '永久有效', + expiryConfig: '過期設定', + expiresIn: '將在 {time} 後過期', + expiresAt: '預計過期於:', + permanentValidity: '永久有效', + }, + password: { + sharePassword: '分享密碼', + enterPassword: '請輸入分享密碼', + clearPassword: '清空密碼', + encryptedNotice: + '採用 AES-GCM 端對端加密,伺服器不保存密碼,密碼遺失資料將無法復原', + decryptFailed: '解密失敗', + }, + history: { + noRecords: '暫無記錄', + deleteConfirm: '確認刪除?', + shareCodeClick: '分享碼 {code},點擊查看', + }, + shareDialog: { + title: '分享', + hashLabel: '原始分享 SHA256 Hash 值', + }, + fileDialog: { + title: '檔案詳情', + textTitle: '文字分享', + fileTitle: '檔案分享', + encryptedHint: '分享已加密,請使用密碼解密', + burnAfterReadWarning: '關閉後無法再次查看,確認關閉?', + burnAfterReadTitle: '閱後即焚', + hashLabel: '原始分享 SHA256 Hash 值', + }, + admin: { + filename: '檔案名稱', + shareCode: '分享碼', + size: '大小', + sizeTooltip: + '使用二進位單位:1 MiB = 1024 × 1024 位元組,與 macOS 顯示略有不同', + expiryDate: '有效期至', + isEncrypted: '是否加密', + createdAt: '建立時間', + actions: '操作', + deleteSelected: '刪除所選', + selected: '已選擇 {count} 項', + }, + errors: { + versionMismatch: '版本不符', + integrityCheckFailed: '資料完整性校驗失敗', + chunkInfoFailed: '獲取分片資訊失敗', + suggestR2: '建議使用 R2', + invalidCode: '分享碼無效', + shareExpired: '分享已過期', + emptyContent: '分享內容為空', + fileTooLarge: '檔案過大', + chunkNotFound: '分片上傳的檔案不存在', + codeGenerationFailed: '分享碼生成失敗,請重試', + invalidToken: '無效的 token', + invalidObjectId: '無效的 object id', + unknownError: '未知錯誤', + }, +} diff --git a/web/i18n/store.ts b/web/i18n/store.ts new file mode 100644 index 0000000..2a40c09 --- /dev/null +++ b/web/i18n/store.ts @@ -0,0 +1,88 @@ +import { observable, action, makeObservable } from 'mobx' +import dayjs from 'dayjs' +import zhCN from 'dayjs/locale/zh-cn' +import zhTW from 'dayjs/locale/zh-tw' +import { Locale, TranslationKeys } from './types' +import { locales } from './locales' + +// dayjs locale 映射 +const dayjsLocaleMap: Record = { + 'zh-CN': 'zh-cn', + 'zh-TW': 'zh-tw', + en: 'en', +} + +// 預先載入所有 locale +dayjs.locale(zhCN) +dayjs.locale(zhTW) + +type TranslationParams = Record + +class I18nStore { + private static STORAGE_KEY = 'app-locale' + + @observable accessor locale: Locale = 'zh-CN' + + constructor() { + makeObservable(this) + this.initLocale() + } + + private initLocale() { + // 1. 優先讀取 localStorage + const saved = localStorage.getItem(I18nStore.STORAGE_KEY) + if (saved && this.isValidLocale(saved)) { + this.setLocale(saved as Locale) + return + } + + // 2. 根據瀏覽器語言自動檢測 + const browserLang = navigator.language + if (browserLang === 'zh-TW' || browserLang === 'zh-HK') { + this.setLocale('zh-TW') + } else if (browserLang.startsWith('zh')) { + this.setLocale('zh-CN') + } else { + this.setLocale('en') + } + } + + private isValidLocale(locale: string): locale is Locale { + return locale === 'zh-CN' || locale === 'zh-TW' || locale === 'en' + } + + @action + setLocale(locale: Locale) { + this.locale = locale + localStorage.setItem(I18nStore.STORAGE_KEY, locale) + + // 同步 dayjs locale + dayjs.locale(dayjsLocaleMap[locale]) + } + + /** + * 獲取翻譯文字 + * @param namespace 命名空間(如 'common', 'home') + * @param key 翻譯鍵 + * @param params 參數(用於替換 {param} 佔位符) + */ + t = ( + namespace: N, + key: keyof TranslationKeys[N], + params?: TranslationParams, + ): string => { + const text = locales[this.locale][namespace][key] as string + + if (!params) { + return text + } + + // 替換 {param} 佔位符 + return Object.entries(params).reduce( + (result, [k, v]) => result.replace(`{${k}}`, String(v)), + text, + ) + } +} + +export const i18nStore = new I18nStore() diff --git a/web/i18n/types.ts b/web/i18n/types.ts new file mode 100644 index 0000000..7fe85d5 --- /dev/null +++ b/web/i18n/types.ts @@ -0,0 +1,96 @@ +export type Locale = 'zh-CN' | 'zh-TW' | 'en' + +export interface TranslationKeys { + common: { + confirm: string + cancel: string + copy: string + copySuccess: string + copyFailed: string + download: string + downloading: string + share: string + close: string + link: string + extractCode: string + verifyTool: string + } + home: { + shareCode: string + textShare: string + fileShare: string + selectFile: string + burnAfterRead: string + history: string + fileTooLarge: string + shared: string + received: string + noHistory: string + } + duration: { + default: string + minute: string + hour: string + day: string + week: string + month: string + year: string + permanent: string + expiryConfig: string + expiresIn: string + expiresAt: string + permanentValidity: string + } + password: { + sharePassword: string + enterPassword: string + clearPassword: string + encryptedNotice: string + decryptFailed: string + } + history: { + noRecords: string + deleteConfirm: string + shareCodeClick: string + } + shareDialog: { + title: string + hashLabel: string + } + fileDialog: { + title: string + textTitle: string + fileTitle: string + encryptedHint: string + burnAfterReadWarning: string + burnAfterReadTitle: string + hashLabel: string + } + admin: { + filename: string + shareCode: string + size: string + sizeTooltip: string + expiryDate: string + isEncrypted: string + createdAt: string + actions: string + deleteSelected: string + selected: string + } + errors: { + versionMismatch: string + integrityCheckFailed: string + chunkInfoFailed: string + suggestR2: string + invalidCode: string + shareExpired: string + emptyContent: string + fileTooLarge: string + chunkNotFound: string + codeGenerationFailed: string + invalidToken: string + invalidObjectId: string + unknownError: string + } +} diff --git a/web/i18n/useTranslation.ts b/web/i18n/useTranslation.ts new file mode 100644 index 0000000..c6bb33c --- /dev/null +++ b/web/i18n/useTranslation.ts @@ -0,0 +1,20 @@ +import { i18nStore } from './store' +import { Locale, TranslationKeys } from './types' + +type TranslationParams = Record + +export function useTranslation() { + const t = ( + namespace: N, + key: keyof TranslationKeys[N], + params?: TranslationParams, + ): string => { + return i18nStore.t(namespace, key, params) + } + + return { + t, + locale: i18nStore.locale, + setLocale: (locale: Locale) => i18nStore.setLocale(locale), + } +} diff --git a/web/theme/AppTheme.tsx b/web/theme/AppTheme.tsx index ad189ab..5865db7 100644 --- a/web/theme/AppTheme.tsx +++ b/web/theme/AppTheme.tsx @@ -26,7 +26,12 @@ export default function AppTheme({ children, mode }: AppThemeProps) { }, }) return ( - + {children} ) diff --git a/web/views/Admin/index.tsx b/web/views/Admin/index.tsx index 2dad756..f047729 100644 --- a/web/views/Admin/index.tsx +++ b/web/views/Admin/index.tsx @@ -1,5 +1,6 @@ import * as React from 'react' import { useEffect, useState } from 'preact/hooks' +import { observer } from 'mobx-react-lite' import { alpha } from '@mui/material/styles' import Box from '@mui/material/Box' import Table from '@mui/material/Table' @@ -24,10 +25,11 @@ import LockClose from '@mui/icons-material/Lock' import { Layout, LayoutProps } from '../../components' import { createAdminApi } from '../../api' -import { humanFileSize } from '../../helpers' +import { humanFileSize, mapError } from '../../helpers' import dayjs from 'dayjs' import { ComponentChildren } from 'preact' import { useDialogs } from '@toolpad/core/useDialogs' +import { useTranslation } from '../../i18n' function Div(props: { children?: ComponentChildren }) { return
{props.children}
@@ -43,46 +45,51 @@ interface HeadCell { tooltip?: string } -const headCells: readonly HeadCell[] = [ - { - disablePadding: true, - label: '文件名', - }, - { - disablePadding: false, - label: '分享码', - width: 150, - }, - { - id: 'size', - disablePadding: false, - label: '大小', - tooltip: '使用二进制单位:1 MiB = 1024 × 1024 字节,与 macOS 显示略有不同', - width: 150, - }, - { - id: 'due_date', - disablePadding: false, - label: '有效期至', - width: 150, - }, - { - disablePadding: true, - label: '是否加密', - width: 100, - }, - { - id: 'created_at', - disablePadding: false, - label: '创建时间', - width: 150, - }, - { - disablePadding: true, - label: '操作', - width: 100, - }, -] +// 動態生成表頭(需要使用 t() 函數) +function createHeadCells( + t: ReturnType['t'], +): readonly HeadCell[] { + return [ + { + disablePadding: true, + label: t('admin', 'filename'), + }, + { + disablePadding: false, + label: t('admin', 'shareCode'), + width: 150, + }, + { + id: 'size', + disablePadding: false, + label: t('admin', 'size'), + tooltip: t('admin', 'sizeTooltip'), + width: 150, + }, + { + id: 'due_date', + disablePadding: false, + label: t('admin', 'expiryDate'), + width: 150, + }, + { + disablePadding: true, + label: t('admin', 'isEncrypted'), + width: 100, + }, + { + id: 'created_at', + disablePadding: false, + label: t('admin', 'createdAt'), + width: 150, + }, + { + disablePadding: true, + label: t('admin', 'actions'), + width: 100, + }, + ] +} interface EnhancedTableProps { numSelected: number @@ -91,9 +98,10 @@ interface EnhancedTableProps { order: Order orderBy: string rowCount: number + headCells: readonly HeadCell[] } -function EnhancedTableHead(props: EnhancedTableProps) { +const EnhancedTableHead = observer((props: EnhancedTableProps) => { const { onSelectAllClick, order, @@ -101,6 +109,7 @@ function EnhancedTableHead(props: EnhancedTableProps) { numSelected, rowCount, onRequestSort, + headCells, } = props const createSortHandler = (property?: keyof FileType) => () => { if (property) { @@ -154,14 +163,15 @@ function EnhancedTableHead(props: EnhancedTableProps) { ) -} +}) interface EnhancedTableToolbarProps { numSelected: number onDelete: (event: Event) => void } -function EnhancedTableToolbar(props: EnhancedTableToolbarProps) { +const EnhancedTableToolbar = observer((props: EnhancedTableToolbarProps) => { + const { t } = useTranslation() const { numSelected } = props return ( @@ -188,7 +198,7 @@ function EnhancedTableToolbar(props: EnhancedTableToolbarProps) { variant="subtitle1" component="div" > - 选中 {numSelected} + {t('admin', 'selected', { count: numSelected })} ) : ( )} {numSelected > 0 && ( - + @@ -209,7 +219,7 @@ function EnhancedTableToolbar(props: EnhancedTableToolbarProps) { )} ) -} +}) interface AdminProps extends LayoutProps { token: string @@ -217,7 +227,9 @@ interface AdminProps extends LayoutProps { const DATE_FORMAT = 'YYYY-MM-DD HH:mm:ss' -function AdminMain(props: AdminProps) { +const AdminMain = observer((props: AdminProps) => { + const { t } = useTranslation() + const headCells = createHeadCells(t) const setBackdropOpen = props.setBackdropOpen! const message = props.message! const token = props.token @@ -245,7 +257,7 @@ function AdminMain(props: AdminProps) { setRows(items) setSelected([]) } else { - message.error(response.message) + message.error(mapError(response.message)) } setBackdropOpen(false) } @@ -305,14 +317,11 @@ function AdminMain(props: AdminProps) { const createRemoveHandler = (id?: string) => async (event: Event) => { event.stopPropagation() - const confirmed = await dialogs.confirm( - '删除后无法恢复,请确认是否删除?', - { - okText: '确认', - cancelText: '取消', - title: !id ? '批量删除' : '删除分享', - }, - ) + const confirmed = await dialogs.confirm(t('history', 'deleteConfirm'), { + okText: t('common', 'confirm'), + cancelText: t('common', 'cancel'), + title: !id ? t('admin', 'deleteSelected') : t('admin', 'shareCode'), + }) if (confirmed) { setBackdropOpen(true) const data = await adminApi.delete(id ?? selected) @@ -320,7 +329,7 @@ function AdminMain(props: AdminProps) { setPage(0) await fetchList(0) } else { - message.error(data.message) + message.error(mapError(data.message)) setBackdropOpen(false) } } @@ -355,6 +364,7 @@ function AdminMain(props: AdminProps) { onSelectAllClick={handleSelectAllClick} onRequestSort={handleRequestSort} rowCount={rows.length} + headCells={headCells} /> {rows.map((row, index) => { @@ -455,9 +465,8 @@ function AdminMain(props: AdminProps) { - `${from} - ${to} 共 ${count} 条` + `${from} - ${to} / ${count}` } - labelRowsPerPage="分页大小" rowsPerPageOptions={[10]} component="div" count={total} @@ -469,7 +478,7 @@ function AdminMain(props: AdminProps) { ) -} +}) export function Admin() { const { params } = useRoute() diff --git a/web/views/Home/components/Duration.tsx b/web/views/Home/components/Duration.tsx index 53b8cb6..4fb8967 100644 --- a/web/views/Home/components/Duration.tsx +++ b/web/views/Home/components/Duration.tsx @@ -1,4 +1,5 @@ import { useEffect } from 'preact/hooks' +import { observer } from 'mobx-react-lite' import FormControlLabel from '@mui/material/FormControlLabel' import Select, { SelectChangeEvent } from '@mui/material/Select' import MenuItem from '@mui/material/MenuItem' @@ -7,6 +8,8 @@ import Box from '@mui/material/Box' import TextField from '@mui/material/TextField' import { ManipulateType } from 'dayjs' +import { useTranslation } from '../../../i18n' + interface DurationProps { value?: string onChange?: (duration: string) => void @@ -16,41 +19,6 @@ const DEFAULT_VALUE = 'default' const MAX_VALUE = '999year' const duration = ['day', 'week', 'month', 'year', 'hour', 'minute'] -// `minute`, `hour`, `day`, `week`, `month`, `year` -const CONFIG = [ - { - label: '默认', - value: DEFAULT_VALUE, - }, - { - label: '分钟', - value: 'minute', - }, - { - label: '小时', - value: 'hour', - }, - { - label: '天', - value: 'day', - }, - { - label: '周', - value: 'week', - }, - { - label: '月', - value: 'month', - }, - { - label: '年', - value: 'year', - }, - { - label: '永久有效', - value: '999year', - }, -] function resolveDuration(str: string): [number, ManipulateType] { const match = new RegExp(`^(\\d+)(${duration.join('|')})$`).exec(str) @@ -60,9 +28,46 @@ function resolveDuration(str: string): [number, ManipulateType] { return [Number.parseInt(match[1], 10), match[2] as ManipulateType] } -export function Duration(props: DurationProps) { +export const Duration = observer((props: DurationProps) => { + const { t } = useTranslation() const { value = '', onChange } = props + // 動態生成 CONFIG + const CONFIG = [ + { + label: t('duration', 'default'), + value: DEFAULT_VALUE, + }, + { + label: t('duration', 'minute'), + value: 'minute', + }, + { + label: t('duration', 'hour'), + value: 'hour', + }, + { + label: t('duration', 'day'), + value: 'day', + }, + { + label: t('duration', 'week'), + value: 'week', + }, + { + label: t('duration', 'month'), + value: 'month', + }, + { + label: t('duration', 'year'), + value: 'year', + }, + { + label: t('duration', 'permanent'), + value: '999year', + }, + ] + const [count, updateCount] = useState(0) const [type, updateType] = useState(DEFAULT_VALUE) @@ -168,8 +173,8 @@ export function Duration(props: DurationProps) { } - label="过期配置" + label={t('duration', 'expiryConfig')} labelPlacement="start" /> ) -} +}) diff --git a/web/views/Home/components/FileDialog.tsx b/web/views/Home/components/FileDialog.tsx index afec0fe..74dc4bb 100644 --- a/web/views/Home/components/FileDialog.tsx +++ b/web/views/Home/components/FileDialog.tsx @@ -1,4 +1,5 @@ -import { useEffect, useState } from 'preact/hooks' +import { useEffect, useState, useRef } from 'preact/hooks' +import { observer } from 'mobx-react-lite' import { AxiosProgressEvent } from 'axios' import { DialogProps } from '@toolpad/core/useDialogs' import Button from '@mui/material/Button' @@ -8,9 +9,9 @@ import Typography from '@mui/material/Typography' import TextField from '@mui/material/TextField' import dayjs from 'dayjs' import relativeTime from 'dayjs/plugin/relativeTime' -import zh from 'dayjs/locale/zh-cn' import { useDialogs } from '@toolpad/core/useDialogs' import Backdrop from '@mui/material/Backdrop' +import QrCode from 'qrcode-svg' import { fetchFile, fetchPlainText } from '../../../api' import { copyToClipboard } from '../../../common.ts' @@ -18,276 +19,300 @@ import { BasicDialog } from './BasicDialog.tsx' import { PasswordSwitch } from './PasswordSwitch.tsx' import LockClose from '@mui/icons-material/Lock' import LockOpen from '@mui/icons-material/LockOpen' +import { useTranslation } from '../../../i18n' dayjs.extend(relativeTime) -dayjs.locale(zh) -export function FileDialog({ - open, - onClose, - payload, -}: DialogProps< - FileType & { - message: { - error(message: string): void - success(message: string): void +export const FileDialog = observer( + ({ + open, + onClose, + payload, + }: DialogProps< + FileType & { + message: { + error(message: string): void + success(message: string): void + } + token?: string } - token?: string - } ->) { - const dialogs = useDialogs() - const isText = payload.type === 'plain/string' - const [text, updateText] = useState( - payload.is_encrypted ? '分享已加密,请使用密码解密' : '', - ) - const [password, updatePassword] = useState('') - const [backdrop] = useState(payload.is_encrypted ?? false) + >) => { + const { t } = useTranslation() + const dialogs = useDialogs() + const isText = payload.type === 'plain/string' + const [text, updateText] = useState( + payload.is_encrypted ? t('fileDialog', 'encryptedHint') : '', + ) + const [password, updatePassword] = useState('') + const [backdrop] = useState(payload.is_encrypted ?? false) - const [downloading, updateDownloading] = useState(false) - const [progress, updateProgress] = useState(0) - const [file, setFile] = useState(null) + const [downloading, updateDownloading] = useState(false) + const [progress, updateProgress] = useState(0) + const [file, setFile] = useState(null) - const showPassword = !password && payload.is_encrypted + const showPassword = !password && payload.is_encrypted - const handleCopy = (str: string) => { - copyToClipboard(str) - .then(() => { - payload.message.success('复制成功') - }) - .catch(() => { - payload.message.success('复制失败') - }) - } + const currentUrl = `${window.location.protocol}//${window.location.host}?code=${payload.code}` + const qr = useRef( + new QrCode({ + content: currentUrl, + width: 200, + height: 200, + }).svg(), + ) - useEffect(() => { - if (isText) { - if (showPassword) return - ;(async () => { - const data = await fetchPlainText(payload.id, password, payload.token) - updateText(data) - })() + const handleCopy = (str: string) => { + copyToClipboard(str) + .then(() => { + payload.message.success(t('common', 'copySuccess')) + }) + .catch(() => { + payload.message.error(t('common', 'copyFailed')) + }) } - }, []) - const handleClose = async () => { - if (!payload.is_ephemeral) { - return onClose() - } - const confirmed = await dialogs.confirm('关闭后无法再次查看,确认关闭?', { - okText: '确认', - cancelText: '取消', - title: '阅后即焚', - }) - if (confirmed) { - return onClose() - } - } + useEffect(() => { + if (isText) { + if (showPassword) return + ;(async () => { + const data = await fetchPlainText(payload.id, password, payload.token) + updateText(data) + })() + } + }, []) - const handlePasswordChange = async (password: string) => { - if (!password) { - updatePassword('') - return - } - try { - const data = await fetchPlainText(payload.id, password, payload.token) - updateText(data) - updatePassword(password) - } catch (_e) { - payload.message.error('解密失败') + const handleClose = async () => { + if (!payload.is_ephemeral) { + return onClose() + } + const confirmed = await dialogs.confirm( + t('fileDialog', 'burnAfterReadWarning'), + { + okText: t('common', 'confirm'), + cancelText: t('common', 'cancel'), + title: t('fileDialog', 'burnAfterReadTitle'), + }, + ) + if (confirmed) { + return onClose() + } } - } - const handlePasswordDownload = async (password: string) => { - if (!password) { - updatePassword('') - return + const handlePasswordChange = async (password: string) => { + if (!password) { + updatePassword('') + return + } + try { + const data = await fetchPlainText(payload.id, password, payload.token) + updateText(data) + updatePassword(password) + } catch (_e) { + payload.message.error(t('password', 'decryptFailed')) + } } - try { - if (!file) { - updateDownloading(true) - updateProgress(0) + const handlePasswordDownload = async (password: string) => { + if (!password) { + updatePassword('') + return } - const [originFile, e] = await fetchFile( - file, - payload.id, - password, - payload.filename, - payload.token, - (e: AxiosProgressEvent) => { - updateProgress(e.loaded) - }, - ) - if (!e) { - updatePassword(password) - } else { - payload.message.error('解密失败') + + try { + if (!file) { + updateDownloading(true) + updateProgress(0) + } + const [originFile, e] = await fetchFile( + file, + payload.id, + password, + payload.filename, + payload.token, + (e: AxiosProgressEvent) => { + updateProgress(e.loaded) + }, + ) + if (!e) { + updatePassword(password) + } else { + payload.message.error(t('password', 'decryptFailed')) + } + setFile(originFile) + } catch (_e) { + payload.message.error(t('password', 'decryptFailed')) } - setFile(originFile) - } catch (_e) { - payload.message.error('解密失败') + updateDownloading(false) + updateProgress(0) } - updateDownloading(false) - updateProgress(0) - } - return ( - - - {isText && ( - - - ({ - '& .MuiInputBase-root': { - color: theme.palette.text.primary, - filter: showPassword ? 'blur(1px)' : 'none', - }, - textarea: { - WebkitTextFillColor: 'currentColor !important', - }, - })} - /> - {showPassword && ( - + + {isText && ( + + + ({ - color: '#fff', - zIndex: theme.zIndex.drawer + 1, + '& .MuiInputBase-root': { + color: theme.palette.text.primary, + filter: showPassword ? 'blur(1px)' : 'none', + }, + textarea: { + WebkitTextFillColor: 'currentColor !important', + }, })} - open={backdrop} - > - - - )} - - - - )} - {!isText && ( - - - {payload.filename} - {payload.size >= 0 - ? ` (${(payload.size / (1000 * 1000)).toFixed(1)}M)` - : ''} - - {!payload.is_encrypted && ( + /> + {showPassword && ( + ({ + color: '#fff', + zIndex: theme.zIndex.drawer + 1, + })} + open={backdrop} + > + + + )} + - )} - {payload.is_encrypted && ( - - {(openPassword) => ( - - )} - - )} - - )} - - {!payload.is_encrypted && ( - <> - - 原始分享 SHA256 Hash 值{' '} - - (校验工具) - - {':'} - - handleCopy(payload.hash)} - sx={{ - wordBreak: 'break-all', - }} - > - {payload.hash} + + )} + {!isText && ( + + + {payload.filename} + {payload.size >= 0 + ? ` (${(payload.size / (1000 * 1000)).toFixed(1)}M)` + : ''} - + {!payload.is_encrypted && ( + + )} + {payload.is_encrypted && ( + + {(openPassword) => ( + + )} + + )} + )} - - {payload.due_date ? '预计过期于:' : '永久有效'} - - {payload.due_date && ( - - {dayjs(payload.due_date).fromNow()} + + + {!payload.is_encrypted && ( + <> + + {t('fileDialog', 'hashLabel')}{' '} + + ({t('common', 'verifyTool')}) + + {':'} + + handleCopy(payload.hash)} + sx={{ + wordBreak: 'break-all', + }} + > + {payload.hash} + + + )} + + {payload.due_date + ? t('duration', 'expiresAt') + : t('duration', 'permanentValidity')} - )} + {payload.due_date && ( + + {dayjs(payload.due_date).fromNow()} + + )} + - - - ) -} + + ) + }, +) diff --git a/web/views/Home/components/History.tsx b/web/views/Home/components/History.tsx index 8716718..d88c653 100644 --- a/web/views/Home/components/History.tsx +++ b/web/views/Home/components/History.tsx @@ -19,6 +19,8 @@ import TabPanel from '@mui/lab/TabPanel' import Tab from '@mui/material/Tab' import { useState } from 'preact/hooks' +import { useTranslation } from '../../../i18n' + export interface ShareType { type: 'received' | 'shared' code: string @@ -131,13 +133,14 @@ interface RecordListProps { onDelete: (e: MouseEvent, id: string) => void } -function RecordList(props: RecordListProps) { +const RecordList = observer((props: RecordListProps) => { + const { t } = useTranslation() const { list, onView, onDelete } = props if (!list.length) return ( - 记录为空 + {t('history', 'noRecords')} ) @@ -168,7 +171,11 @@ function RecordList(props: RecordListProps) { {!item.file && } 分享码 {item.code},点击查看} + primary={ + + {t('history', 'shareCodeClick', { code: item.code })} + + } secondary={ {dayjs(item.date).fromNow()} @@ -182,9 +189,10 @@ function RecordList(props: RecordListProps) { ))} ) -} +}) export const History = observer(({ onItemClick }: HistoryProps) => { + const { t } = useTranslation() const [tab, updateTab] = useState<'shared' | 'received'>('shared') const handleDelete = (e: MouseEvent, id: string) => { @@ -202,7 +210,7 @@ export const History = observer(({ onItemClick }: HistoryProps) => { return ( - 历史记录 + {t('home', 'history')} { sx={{ borderBottom: 1, borderColor: 'divider' }} > updateTab(tab)}> - - + + diff --git a/web/views/Home/components/PasswordSwitch.tsx b/web/views/Home/components/PasswordSwitch.tsx index cf8c677..6c1c00e 100644 --- a/web/views/Home/components/PasswordSwitch.tsx +++ b/web/views/Home/components/PasswordSwitch.tsx @@ -1,6 +1,7 @@ import * as React from 'preact/compat' import { ComponentChildren } from 'preact' import { useState, useEffect, useRef } from 'preact/hooks' +import { observer } from 'mobx-react-lite' import IconButton from '@mui/material/IconButton' import LockClose from '@mui/icons-material/Lock' import LockOpen from '@mui/icons-material/LockOpen' @@ -16,6 +17,8 @@ import VisibilityOff from '@mui/icons-material/VisibilityOff' import OutlinedInput from '@mui/material/OutlinedInput' import FormHelperText from '@mui/material/FormHelperText' +import { useTranslation } from '../../../i18n' + interface PasswordSwitchProps { value?: string onChange?: (password: string) => void @@ -23,103 +26,106 @@ interface PasswordSwitchProps { children?: (open: { (): Promise }) => ComponentChildren } -function PasswordDialog({ - open, - onClose, - payload, -}: DialogProps<{ password: string; showClear: boolean }, string | null>) { - const { password, showClear = true } = payload - const [result, setResult] = useState(password) - const [show, setShow] = useState(false) - const el = useRef(null) - - const handleClickShowPassword = () => setShow((show) => !show) +const PasswordDialog = observer( + ({ + open, + onClose, + payload, + }: DialogProps<{ password: string; showClear: boolean }, string | null>) => { + const { t } = useTranslation() + const { password, showClear = true } = payload + const [result, setResult] = useState(password) + const [show, setShow] = useState(false) + const el = useRef(null) - const handleMouseDownPassword = ( - event: React.MouseEvent, - ) => { - event.preventDefault() - } + const handleClickShowPassword = () => setShow((show) => !show) - const handleMouseUpPassword = ( - event: React.MouseEvent, - ) => { - event.preventDefault() - } + const handleMouseDownPassword = ( + event: React.MouseEvent, + ) => { + event.preventDefault() + } - useEffect(() => { - if (!el.current) return - const input = el.current.querySelector('input') - if (input) { - input.focus() + const handleMouseUpPassword = ( + event: React.MouseEvent, + ) => { + event.preventDefault() } - }, []) - return ( - onClose(null)}> - 分享密码 - - - - {show ? : } - - - } - slotProps={{ - input: { - // @ts-expect-error data-attr - 'data-bwignore': 'off', - autocomplete: 'off', - 'data-1p-ignore': true, - 'data-lpignore': true, - 'data-protonpass-ignore': true, - }, + useEffect(() => { + if (!el.current) return + const input = el.current.querySelector('input') + if (input) { + input.focus() + } + }, []) + + return ( + onClose(null)}> + {t('password', 'sharePassword')} + + + + {show ? : } + + + } + slotProps={{ + input: { + // @ts-expect-error data-attr + 'data-bwignore': 'off', + autocomplete: 'off', + 'data-1p-ignore': true, + 'data-lpignore': true, + 'data-protonpass-ignore': true, + }, + }} + fullWidth + value={result} + onChange={(event) => setResult(event.currentTarget.value)} + /> + + {t('password', 'encryptedNotice')} + + + setResult(event.currentTarget.value)} - /> - - 采用 AES-GCM 端对端加密,服务器不保存密码,密码丢失数据将无法恢复 - - - - {showClear && ( + > + {showClear && ( + + )} - )} - - - - ) -} + + + ) + }, +) export function PasswordSwitch(props: PasswordSwitchProps) { const dialogs = useDialogs() diff --git a/web/views/Home/components/ShareDialog.tsx b/web/views/Home/components/ShareDialog.tsx index 4fb7bd4..0d7fea7 100644 --- a/web/views/Home/components/ShareDialog.tsx +++ b/web/views/Home/components/ShareDialog.tsx @@ -1,10 +1,10 @@ import { useRef } from 'preact/hooks' +import { observer } from 'mobx-react-lite' import { DialogProps } from '@toolpad/core/useDialogs' import Box from '@mui/material/Box' // import DialogActions from '@mui/material/DialogActions' import dayjs from 'dayjs' import relativeTime from 'dayjs/plugin/relativeTime' -import zh from 'dayjs/locale/zh-cn' import QrCode from 'qrcode-svg' import { copyToClipboard } from '../../../common.ts' @@ -14,127 +14,136 @@ import TextField from '@mui/material/TextField' import Button from '@mui/material/Button' import { Code } from './index.tsx' +import { useTranslation } from '../../../i18n' dayjs.extend(relativeTime) -dayjs.locale(zh) -export function ShareDialog({ - open, - onClose, - payload, -}: DialogProps< - FileUploadedType & { - message: { - error(message: string): void - success(message: string): void +export const ShareDialog = observer( + ({ + open, + onClose, + payload, + }: DialogProps< + FileUploadedType & { + message: { + error(message: string): void + success(message: string): void + } } - } ->) { - const url = `${window.location.protocol}//${window.location.host}?code=${payload.code}` - const desc = `链接: ${url} 提取码: ${payload.code} ${payload.is_encrypted ? '' : ` SHA256 Hash 值: ${payload.hash}`} ` - const qr = useRef( - new QrCode({ - content: url, - }).svg(), - ) + >) => { + const { t } = useTranslation() + const url = `${window.location.protocol}//${window.location.host}?code=${payload.code}` + const desc = `${t('common', 'link')}: ${url} ${t('common', 'extractCode')}: ${payload.code} ${payload.is_encrypted ? '' : `SHA256 Hash: ${payload.hash}`} ` + const qr = useRef( + new QrCode({ + content: url, + }).svg(), + ) - const handleCopy = (str: string) => { - copyToClipboard(str) - .then(() => { - payload.message.success('复制成功') - }) - .catch(() => { - payload.message.success('复制失败') - }) - } - - return ( - - - handleCopy(payload.code)} - > - - - - - - ({ - '& .MuiInputBase-root': { - color: theme.palette.text.primary, - }, - textarea: { - WebkitTextFillColor: 'currentColor !important', - }, - })} - /> - - + const handleCopy = (str: string) => { + copyToClipboard(str) + .then(() => { + payload.message.success(t('common', 'copySuccess')) + }) + .catch(() => { + payload.message.error(t('common', 'copyFailed')) + }) + } - - - 原始分享 SHA256 Hash 值{' '} - - (校验工具) - - {':'} - - handleCopy(payload.hash)} + return ( + + + handleCopy(payload.code)} > - {payload.hash} - - {} - - {payload.due_date ? '预计过期于:' : '永久有效'} - - {payload.due_date && ( - - {dayjs(payload.due_date).fromNow()} + + + + + + ({ + '& .MuiInputBase-root': { + color: theme.palette.text.primary, + }, + textarea: { + WebkitTextFillColor: 'currentColor !important', + }, + })} + /> + + + + + + {t('shareDialog', 'hashLabel')}{' '} + + ({t('common', 'verifyTool')}) + + {':'} + + handleCopy(payload.hash)} + sx={{ + wordBreak: 'break-all', + }} + > + {payload.hash} + + {} + + {payload.due_date + ? t('duration', 'expiresAt') + : t('duration', 'permanentValidity')} - )} + {payload.due_date && ( + + {dayjs(payload.due_date).fromNow()} + + )} + - - - ) -} + + ) + }, +) diff --git a/web/views/Home/index.tsx b/web/views/Home/index.tsx index 611b0bb..4199036 100644 --- a/web/views/Home/index.tsx +++ b/web/views/Home/index.tsx @@ -1,4 +1,5 @@ import { useState, useRef } from 'preact/hooks' +import { observer } from 'mobx-react-lite' import { useDialogs } from '@toolpad/core/useDialogs' import Container from '@mui/material/Container' import Paper from '@mui/material/Paper' @@ -33,6 +34,8 @@ import { } from './components' import { resolveFileByCode, uploadFile } from '../../api' import { Layout, LayoutProps } from '../../components' +import { useTranslation } from '../../i18n' +import { mapError } from '../../helpers' const VisuallyHiddenInput = styled('input')({ clip: 'rect(0 0 0 0)', @@ -49,7 +52,8 @@ const VisuallyHiddenInput = styled('input')({ const envMax = Number.parseInt(import.meta.env.SHARE_MAX_SIZE_IN_MB, 10) const MAX_SIZE = Number.isNaN(envMax) || envMax <= 0 ? 10 : envMax -export function AppMain(props: LayoutProps) { +const AppMain = observer((props: LayoutProps) => { + const { t } = useTranslation() const setBackdropOpen = props.setBackdropOpen! const message = props.message! const [tab, setTab] = useState('text') @@ -113,7 +117,7 @@ export function AppMain(props: LayoutProps) { const data = await resolveFileByCode(code) handleBackdropClose() if (!data.result || !data.data) { - message.error(data.message) + message.error(mapError(data.message)) return } // 打开弹窗 @@ -126,7 +130,7 @@ export function AppMain(props: LayoutProps) { .then(reset.current) } catch (e) { const data = (e as { message: string }).message || JSON.stringify(e) - message.error(data) + message.error(mapError(data)) handleBackdropClose() } }) @@ -141,7 +145,7 @@ export function AppMain(props: LayoutProps) { const target: HTMLInputElement = e.target as HTMLInputElement const file = target?.files?.[0] ?? null if (file && file.size > MAX_SIZE * 1000 * 1000) { - message.error(`文件大于 ${MAX_SIZE}M`) + message.error(t('home', 'fileTooLarge', { size: MAX_SIZE })) ;(e.target as HTMLInputElement).value = '' return } @@ -172,7 +176,7 @@ export function AppMain(props: LayoutProps) { ) handleProgressClose() if (!uploaded.result || !uploaded.data) { - message.error(uploaded.message) + message.error(mapError(uploaded.message)) return } historyApi.insertShared(uploaded.data.code, tab === 'file') @@ -181,7 +185,7 @@ export function AppMain(props: LayoutProps) { .then(reset.current) } catch (e) { const data = (e as { message: string }).message || JSON.stringify(e) - message.error(data) + message.error(mapError(data)) handleProgressClose() } } @@ -209,8 +213,12 @@ export function AppMain(props: LayoutProps) { })} > - - 分享码: + + {t('home', 'shareCode')} - - + + @@ -256,7 +264,7 @@ export function AppMain(props: LayoutProps) { tabIndex={-1} startIcon={} > - 选择文件 + {t('home', 'selectFile')} } - label="阅后即焚" + label={t('home', 'burnAfterRead')} /> @@ -303,11 +311,11 @@ export function AppMain(props: LayoutProps) { }} onClick={handleShare} > - 分享 + {t('common', 'share')} @@ -325,7 +333,7 @@ export function AppMain(props: LayoutProps) { ) -} +}) export function Home() { return (