diff --git a/apps/web/package.json b/apps/web/package.json index 68aa1db..56e3bd9 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -17,6 +17,7 @@ "@tiptap/extension-youtube": "^3.22.2", "@tiptap/react": "^3.22.2", "@tiptap/starter-kit": "^3.22.2", + "browser-image-compression": "^2.0.2", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "dexie": "^4.4.2", diff --git a/apps/web/src/components/task-rich-editor.tsx b/apps/web/src/components/task-rich-editor.tsx index 53ca2b2..972b5a6 100644 --- a/apps/web/src/components/task-rich-editor.tsx +++ b/apps/web/src/components/task-rich-editor.tsx @@ -1,12 +1,15 @@ -import { useEffect, useMemo } from "react"; -import type { JSONContent } from "@tiptap/react"; -import { EditorContent, useEditor } from "@tiptap/react"; +import { useEffect, useMemo, useRef, useState, type ChangeEvent } from "react"; +import imageCompression from "browser-image-compression"; import Image from "@tiptap/extension-image"; import Link from "@tiptap/extension-link"; import StarterKit from "@tiptap/starter-kit"; import Youtube from "@tiptap/extension-youtube"; +import { EditorContent, type JSONContent, useEditor } from "@tiptap/react"; import { cn } from "@/lib/utils"; +const MAX_IMAGE_UPLOAD_BYTES = 20 * 1024 * 1024; +const MAX_VIDEO_UPLOAD_BYTES = 10 * 1024 * 1024; + type TaskRichEditorProps = { valueJson: string | null; textFallback: string; @@ -54,7 +57,23 @@ function resolveEditorContent( return textFallback; } +function formatBytes(bytes: number): string { + if (bytes < 1024) { + return `${bytes} B`; + } + + if (bytes < 1024 * 1024) { + return `${(bytes / 1024).toFixed(1)} KB`; + } + + return `${(bytes / (1024 * 1024)).toFixed(2)} MB`; +} + export function TaskRichEditor({ valueJson, textFallback, onChange }: TaskRichEditorProps) { + const [mediaHint, setMediaHint] = useState(null); + const imageInputRef = useRef(null); + const videoInputRef = useRef(null); + const content = useMemo( () => resolveEditorContent(valueJson, textFallback), [valueJson, textFallback] @@ -99,8 +118,76 @@ export function TaskRichEditor({ valueJson, textFallback, onChange }: TaskRichEd editor.commands.setContent(content, { emitUpdate: false }); }, [content, editor]); + async function handleImageFileChange(event: ChangeEvent): Promise { + const file = event.target.files?.[0]; + event.target.value = ""; + + if (!file || !editor) { + return; + } + + if (file.size > MAX_IMAGE_UPLOAD_BYTES) { + setMediaHint(`图片过大,请选择小于 ${formatBytes(MAX_IMAGE_UPLOAD_BYTES)} 的文件。`); + return; + } + + try { + const compressedImage = await imageCompression(file, { + maxSizeMB: 1, + maxWidthOrHeight: 1920, + useWebWorker: true, + initialQuality: 0.8 + }); + + const imageSource = await imageCompression.getDataUrlFromFile(compressedImage); + editor.chain().focus().setImage({ src: imageSource, alt: file.name }).run(); + + setMediaHint( + `图片已插入:${formatBytes(file.size)} -> ${formatBytes(compressedImage.size)}。` + ); + } catch { + setMediaHint("图片处理失败,请重试。"); + } + } + + function handleVideoFileChange(event: ChangeEvent): void { + const file = event.target.files?.[0]; + event.target.value = ""; + + if (!file || !editor) { + return; + } + + if (file.size > MAX_VIDEO_UPLOAD_BYTES) { + setMediaHint(`视频过大,请选择小于 ${formatBytes(MAX_VIDEO_UPLOAD_BYTES)} 的文件。`); + return; + } + + editor + .chain() + .focus() + .insertContent(`\n[视频待上传] ${file.name}(${formatBytes(file.size)})\n`) + .run(); + setMediaHint("视频已通过大小校验并插入占位文本,正式上传接口将在后续接入。"); + } + return (
+ + +
{ if (!editor) { @@ -160,7 +247,12 @@ export function TaskRichEditor({ valueJson, textFallback, onChange }: TaskRichEd }} /> imageInputRef.current?.click()} + /> + { if (!editor) { @@ -175,8 +267,14 @@ export function TaskRichEditor({ valueJson, textFallback, onChange }: TaskRichEd editor.chain().focus().setYoutubeVideo({ src: url }).run(); }} /> + videoInputRef.current?.click()} + />
+ {mediaHint ?

{mediaHint}

: null}
); } diff --git a/apps/web/src/pages/todo-shell-page.tsx b/apps/web/src/pages/todo-shell-page.tsx index 64d9034..0ef3285 100644 --- a/apps/web/src/pages/todo-shell-page.tsx +++ b/apps/web/src/pages/todo-shell-page.tsx @@ -11,6 +11,7 @@ import { listLocalTasksByUser, updateLocalTask } from "@/services/local-task-repo"; +import { formatStorageSize, getStorageQuotaSnapshot } from "@/services/storage-quota"; import type { WebSession } from "@/services/session-storage"; type TodoShellPageProps = { @@ -99,6 +100,14 @@ export function TodoShellPage({ session }: TodoShellPageProps) { return listLocalTasksByUser(userId); }, [userId]); + const quotaSnapshot = useLiveQuery(async () => { + if (!userId) { + return null; + } + + return getStorageQuotaSnapshot(userId); + }, [userId]); + const selectedTask = useLiveQuery(async () => { if (!selectedTaskId) { return undefined; @@ -228,6 +237,18 @@ export function TodoShellPage({ session }: TodoShellPageProps) { + {quotaSnapshot ? ( +

= 85 ? "text-destructive" : "text-muted-foreground" + )} + > + 空间占用(估算):{formatStorageSize(quotaSnapshot.usedBytes)} /{" "} + {formatStorageSize(quotaSnapshot.quotaBytes)}({quotaSnapshot.usedPercent.toFixed(1)}%) +

+ ) : null} + {taskList.length === 0 ? (

还没有任务,点击右上角“新建任务”。 diff --git a/apps/web/src/services/storage-quota.ts b/apps/web/src/services/storage-quota.ts new file mode 100644 index 0000000..27445ed --- /dev/null +++ b/apps/web/src/services/storage-quota.ts @@ -0,0 +1,57 @@ +import { localDb } from "@/services/local-db"; + +export const DEFAULT_CLOUD_QUOTA_BYTES = 100 * 1024 * 1024; + +type StorageQuotaSnapshot = { + usedBytes: number; + quotaBytes: number; + remainingBytes: number; + usedPercent: number; +}; + +function measureTextBytes(value: string | null): number { + if (!value) { + return 0; + } + + return new Blob([value]).size; +} + +export async function getStorageQuotaSnapshot(userId: string): Promise { + const tasks = await localDb.tasks.where("userId").equals(userId).toArray(); + + const usedBytes = tasks.reduce((total, task) => { + if (task.deletedAt !== null) { + return total; + } + + return ( + total + + measureTextBytes(task.title) + + measureTextBytes(task.contentText) + + measureTextBytes(task.contentJson) + ); + }, 0); + + const remainingBytes = Math.max(DEFAULT_CLOUD_QUOTA_BYTES - usedBytes, 0); + const usedPercent = Math.min((usedBytes / DEFAULT_CLOUD_QUOTA_BYTES) * 100, 100); + + return { + usedBytes, + quotaBytes: DEFAULT_CLOUD_QUOTA_BYTES, + remainingBytes, + usedPercent + }; +} + +export function formatStorageSize(bytes: number): string { + if (bytes < 1024) { + return `${bytes} B`; + } + + if (bytes < 1024 * 1024) { + return `${(bytes / 1024).toFixed(1)} KB`; + } + + return `${(bytes / (1024 * 1024)).toFixed(2)} MB`; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cd0b5ba..ab3afb0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -164,6 +164,9 @@ importers: "@tiptap/starter-kit": specifier: ^3.22.2 version: 3.22.2 + browser-image-compression: + specifier: ^2.0.2 + version: 2.0.2 class-variance-authority: specifier: ^0.7.1 version: 0.7.1 @@ -3717,6 +3720,12 @@ packages: } engines: { node: ">=8" } + browser-image-compression@2.0.2: + resolution: + { + integrity: sha512-pBLlQyUf6yB8SmmngrcOw3EoS4RpQ1BcylI3T9Yqn7+4nrQTXJD4sJDe5ODnJdrvNMaio5OicFo75rDyJD2Ucw== + } + browserslist@4.28.2: resolution: { @@ -8316,6 +8325,12 @@ packages: } engines: { node: ">= 0.4.0" } + uzip@0.20201231.0: + resolution: + { + integrity: sha512-OZeJfZP+R0z9D6TmBgLq2LHzSSptGMGDGigGiEe0pr8UBe/7fdflgHlHBNDASTXB5jnFuxHpNaJywSg8YFeGng== + } + v8-compile-cache-lib@3.0.1: resolution: { @@ -11306,6 +11321,10 @@ snapshots: dependencies: fill-range: 7.1.1 + browser-image-compression@2.0.2: + dependencies: + uzip: 0.20201231.0 + browserslist@4.28.2: dependencies: baseline-browser-mapping: 2.10.14 @@ -14189,6 +14208,8 @@ snapshots: utils-merge@1.0.1: {} + uzip@0.20201231.0: {} + v8-compile-cache-lib@3.0.1: {} v8-to-istanbul@9.3.0: