diff --git a/packages/core/src/item.ts b/packages/core/src/item.ts index dbf0b18..d4d74e1 100644 --- a/packages/core/src/item.ts +++ b/packages/core/src/item.ts @@ -134,7 +134,22 @@ async function* chunksOf(source: ContentSource): AsyncGenerator; } +function readBlobPart(blob: Blob): Promise { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => reader.result instanceof ArrayBuffer + ? resolve(new Uint8Array(reader.result)) + : reject(new TypeError('文件读取失败')); + reader.onerror = () => reject(reader.error ?? new TypeError('文件读取失败')); + reader.onabort = () => reject(new DOMException('文件读取已取消', 'AbortError')); + reader.readAsArrayBuffer(blob); + }); +} + interface Chunk { data: Bytes; last: boolean; diff --git a/web/src/core-bridge/types.ts b/web/src/core-bridge/types.ts index a5e5f26..02f5055 100644 --- a/web/src/core-bridge/types.ts +++ b/web/src/core-bridge/types.ts @@ -65,7 +65,7 @@ export interface ShareSettings { /** protocol §7、§14:由帧推导,不单独存储 */ export type ItemState = - | { kind: 'receiving'; progress: number } + | { kind: 'receiving'; progress: number; phase?: 'encrypting' | 'uploading'; speedBytesPerSecond?: number } | { kind: 'complete' } | { kind: 'incomplete' } | { kind: 'tampered' }; diff --git a/web/src/core-bridge/worker/channelSend.ts b/web/src/core-bridge/worker/channelSend.ts index ca31169..ebb0a24 100644 --- a/web/src/core-bridge/worker/channelSend.ts +++ b/web/src/core-bridge/worker/channelSend.ts @@ -1,4 +1,5 @@ import { removeChannelItem } from './channelItems'; +import { errorMessage } from '../errors'; import { saveResource } from '../database'; @@ -10,7 +11,7 @@ import { deviceIdentity } from './presentation'; -import { flushOutbox, persistPlan } from './transfer'; +import { flushOutbox, MAX_FRAME_DATA, MOBILE_UPLOAD_FRAME_DATA_BYTES, persistPlan } from './transfer'; import { publishWorkspace } from './presentation'; export async function sendText(resourceId: string, text: string): Promise<{ itemId: string; uploaded: boolean }> { @@ -35,24 +36,66 @@ export async function sendFiles(resourceId: string, files: File[]): Promise entry.id !== item.id), item]; - await saveResource(resource); - blobs.set(`${resourceId}:${item.id}`, file); - publishWorkspace(resourceId, { t: 'item', item }); - await persistPlan(resource, plan); - const uploaded = await flushOutbox(resource, session); - item = { ...item, state: uploaded ? { kind: 'complete' } : { kind: 'incomplete' } }; - resource.localItems = resource.localItems.map((entry) => entry.id === item.id ? item : entry); - await saveResource(resource); - publishWorkspace(resourceId, { t: 'item', item }); + let item: FileItem | undefined; + try { + const plan = session.encryptFile(file, {}, { maxDownloads: resource.maxDownloads ?? 10, dev: device.name, ts, maxFrameData }); + item = { + id: plan.itemId, kind: 'file', ts, deviceId: device.name, state: { kind: 'receiving', progress: 0, phase: 'encrypting' }, + name: file.name, mime: file.type || 'application/octet-stream', size: file.size, + remaining: resource.maxDownloads || null, preview: previewKindOf(file.type, file.name), gated: true, + }; + resource.localItems = [...(resource.localItems ?? []).filter((entry) => entry.id !== item?.id), item]; + await saveResource(resource); + blobs.set(`${resourceId}:${item.id}`, file); + publishWorkspace(resourceId, { t: 'item', item }); + const updateProgress = (progress: number, phase: 'encrypting' | 'uploading', speedBytesPerSecond?: number) => { + if (!item) return; + item = { + ...item, + state: { + kind: 'receiving', + progress: Math.max(0, Math.min(0.99, progress)), + phase, + ...(speedBytesPerSecond === undefined ? {} : { speedBytesPerSecond }), + }, + }; + publishWorkspace(resourceId, { t: 'item', item }); + }; + const planned = await persistPlan(resource, plan, (_frames) => { + const processed = Math.min(file.size, _frames * MAX_FRAME_DATA); + updateProgress(file.size === 0 ? 0.5 : Math.min(0.5, processed / file.size * 0.5), 'encrypting'); + }); + const uploadedFrames = new Set(); + let uploadedBytes = 0; + const uploadStartedAt = performance.now(); + const uploaded = await flushOutbox(resource, session, (frame, loadedBytes, totalBytes, complete) => { + if (frame.itemId !== item?.id) return; + if (complete && !uploadedFrames.has(frame.index)) { + uploadedFrames.add(frame.index); + uploadedBytes += totalBytes; + } + const currentBytes = uploadedBytes + (complete ? 0 : loadedBytes); + const elapsed = Math.max(1, performance.now() - uploadStartedAt); + updateProgress(0.5 + 0.5 * currentBytes / planned.bytes, 'uploading', currentBytes * 1000 / elapsed); + }); + item = { ...item, state: uploaded ? { kind: 'complete' } : { kind: 'incomplete' } }; + resource.localItems = resource.localItems.map((entry) => entry.id === item?.id ? item! : entry); + await saveResource(resource); + publishWorkspace(resourceId, { t: 'item', item }); + if (!uploaded) publishWorkspace(resourceId, { t: 'error', message: '上传暂停了。重新打开频道并连接网络后,可以继续发送已加密内容。' }); + } catch (error) { + if (item) { + item = { ...item, state: { kind: 'incomplete' } }; + resource.localItems = (resource.localItems ?? []).map((entry) => entry.id === item?.id ? item! : entry); + await saveResource(resource); + publishWorkspace(resourceId, { t: 'item', item }); + } + publishWorkspace(resourceId, { t: 'error', message: `文件上传失败:${errorMessage(error)}` }); + throw error; + } } } diff --git a/web/src/core-bridge/worker/http.ts b/web/src/core-bridge/worker/http.ts index 473ed2d..ee8cdfd 100644 --- a/web/src/core-bridge/worker/http.ts +++ b/web/src/core-bridge/worker/http.ts @@ -1,7 +1,7 @@ import { supportsApiContract } from '../contract'; export type ApiError = Error & { status?: number; code?: string }; -let apiBase = new URL('/api/v1', self.location.origin).toString(); +let apiBase = '/api/v1'; let contractCheck: Promise | undefined; export function setApiBase(value: string): void { @@ -11,9 +11,12 @@ export function setApiBase(value: string): void { } export const fail = (status: number, code: string): ApiError => Object.assign(new Error(code), { status, code }); +const currentOrigin = (): string => typeof self !== 'undefined' && self.location?.origin + ? self.location.origin + : 'http://localhost'; export const apiPath = (path: string): string => new URL( `${apiBase.replace(/\/$/, '')}${path}`, - self.location.origin, + currentOrigin(), ).toString(); function ensureApiContract(): Promise { @@ -55,6 +58,51 @@ export async function apiFetch(path: string, init: RequestInit = {}): Promise, + body: ArrayBuffer, + onProgress: (loadedBytes: number, totalBytes: number) => void, +): Promise { + try { + await ensureApiContract(); + const response = await new Promise((resolve, reject) => { + const xhr = new XMLHttpRequest(); + xhr.open('POST', apiPath(path)); + xhr.withCredentials = false; + xhr.responseType = 'text'; + xhr.setRequestHeader('Cache-Control', 'no-store'); + for (const [name, value] of Object.entries(headers)) xhr.setRequestHeader(name, value); + xhr.upload.onprogress = (event) => { + if (event.lengthComputable) onProgress(event.loaded, event.total); + }; + xhr.onload = () => { + if (xhr.status === 0) { + reject(new Error('network_error')); + return; + } + const responseHeaders = new Headers(); + const contentType = xhr.getResponseHeader('Content-Type'); + if (contentType) responseHeaders.set('Content-Type', contentType); + resolve(new Response(xhr.responseText, { status: xhr.status, statusText: xhr.statusText, headers: responseHeaders })); + }; + xhr.onerror = () => reject(new Error('network_error')); + xhr.onabort = () => reject(new DOMException('上传已取消', 'AbortError')); + xhr.send(body); + }); + if (!response.ok) { + const result = await response.json().catch(() => null) as { error?: unknown } | null; + const code = typeof result?.error === 'string' && /^[a-z_]{1,40}$/.test(result.error) ? result.error : 'request_failed'; + throw fail(response.status, code); + } + } catch (error) { + if (error instanceof Error && 'status' in error) throw error; + if (error instanceof Error && error.message === 'api_contract_mismatch') throw error; + throw new Error('网络连接失败'); + } +} + export async function readJson(path: string, headers: Record): Promise { return apiFetch(path, { headers }).then((response) => response.json() as Promise); } diff --git a/web/src/core-bridge/worker/shareDraft.ts b/web/src/core-bridge/worker/shareDraft.ts index 6e0e61e..a1a9494 100644 --- a/web/src/core-bridge/worker/shareDraft.ts +++ b/web/src/core-bridge/worker/shareDraft.ts @@ -1,14 +1,15 @@ import { dequeueItem, removeDraftFile, saveDraftFile, saveResource, type ManifestEntry } from '../database'; import type { FileItem, TextItem } from '../types'; -import { makeFileItem } from './presentation'; +import { deviceIdentity, makeFileItem } from './presentation'; import { getSession } from './sessions'; import { apiFetch, fail } from './http'; -import { flushOutbox, persistPlan } from './transfer'; +import { flushOutbox, MAX_FRAME_DATA, MOBILE_UPLOAD_FRAME_DATA_BYTES, persistPlan } from './transfer'; import { publishShare } from './presentation'; import { invalidateManifest } from './shareState'; import { blobs } from './sessions'; +import { errorMessage } from '../errors'; function newTextEntry(text: string): { item: TextItem; entry: Extract } { @@ -36,29 +37,67 @@ export async function addShareFiles(id: string, files: File[]): Promise { if (resource.kind !== 'share' || !session.can('owner') || resource.state !== 'draft') throw new Error('此分享已封存'); await invalidateManifest(session, resource); const downloadLimit = resource.maxDownloads ?? 0; + const maxFrameData = deviceIdentity().kind === 'phone' ? MOBILE_UPLOAD_FRAME_DATA_BYTES : MAX_FRAME_DATA; for (const file of files) { const ts = Date.now(); - const plan = session.encryptFile(file, { name: file.name, mime: file.type || 'application/octet-stream', size: file.size }, { - gated: true, maxDownloads: downloadLimit, ts, - }); - const item = makeFileItem(plan.itemId, file.name, file.type || 'application/octet-stream', file.size, ts, { kind: 'receiving', progress: 0 }, downloadLimit || null); - const entry: Extract = { t: 'file', id: plan.itemId, name: file.name, mime: file.type || 'application/octet-stream', size: file.size, ts }; - blobs.set(`${id}:${item.id}`, file); - try { await saveDraftFile(id, item.id, file); } - catch { publishShare(id, { t: 'error', message: '本机存储空间不足,刷新后可能无法预览此文件。' }); } - resource.draftItems = [...(resource.draftItems ?? []), item]; - resource.manifestEntries = [...(resource.manifestEntries ?? []), entry]; - await saveResource(resource); - publishShare(id, { t: 'item', item }); - await persistPlan(resource, plan); - const uploaded = await flushOutbox(resource, session); - const complete: FileItem = { ...item, state: uploaded ? { kind: 'complete' } : { kind: 'incomplete' } }; - resource.draftItems = resource.draftItems.map((draft) => draft.id === item.id ? complete : draft); - await saveResource(resource); - publishShare(id, { t: 'item', item: complete }); - if (!uploaded) { - publishShare(id, { t: 'error', message: '上传暂停了。重新打开草稿并连接网络后,可以继续发送已加密帧。' }); - continue; + let item: FileItem | undefined; + try { + const plan = session.encryptFile(file, { name: file.name, mime: file.type || 'application/octet-stream', size: file.size }, { + gated: true, maxDownloads: downloadLimit, ts, maxFrameData, + }); + item = makeFileItem(plan.itemId, file.name, file.type || 'application/octet-stream', file.size, ts, { kind: 'receiving', progress: 0, phase: 'encrypting' }, downloadLimit || null); + const entry: Extract = { t: 'file', id: plan.itemId, name: file.name, mime: file.type || 'application/octet-stream', size: file.size, ts }; + blobs.set(`${id}:${item.id}`, file); + try { await saveDraftFile(id, item.id, file); } + catch { publishShare(id, { t: 'error', message: '本机存储空间不足,刷新后可能无法预览此文件。' }); } + resource.draftItems = [...(resource.draftItems ?? []), item]; + resource.manifestEntries = [...(resource.manifestEntries ?? []), entry]; + await saveResource(resource); + publishShare(id, { t: 'item', item }); + const updateProgress = (progress: number, phase: 'encrypting' | 'uploading', speedBytesPerSecond?: number) => { + if (!item) return; + item = { + ...item, + state: { + kind: 'receiving', + progress: Math.max(0, Math.min(0.99, progress)), + phase, + ...(speedBytesPerSecond === undefined ? {} : { speedBytesPerSecond }), + }, + }; + publishShare(id, { t: 'item', item }); + }; + const planned = await persistPlan(resource, plan, (_frames) => { + const processed = Math.min(file.size, _frames * MAX_FRAME_DATA); + updateProgress(file.size === 0 ? 0.5 : Math.min(0.5, processed / file.size * 0.5), 'encrypting'); + }); + const uploadedFrames = new Set(); + let uploadedBytes = 0; + const uploadStartedAt = performance.now(); + const uploaded = await flushOutbox(resource, session, (frame, loadedBytes, totalBytes, complete) => { + if (frame.itemId !== item?.id) return; + if (complete && !uploadedFrames.has(frame.index)) { + uploadedFrames.add(frame.index); + uploadedBytes += totalBytes; + } + const currentBytes = uploadedBytes + (complete ? 0 : loadedBytes); + const elapsed = Math.max(1, performance.now() - uploadStartedAt); + updateProgress(0.5 + 0.5 * currentBytes / planned.bytes, 'uploading', currentBytes * 1000 / elapsed); + }); + const complete: FileItem = { ...item, state: uploaded ? { kind: 'complete' } : { kind: 'incomplete' } }; + resource.draftItems = resource.draftItems.map((draft) => draft.id === item?.id ? complete : draft); + await saveResource(resource); + publishShare(id, { t: 'item', item: complete }); + if (!uploaded) publishShare(id, { t: 'error', message: '上传暂停了。重新打开草稿并连接网络后,可以继续发送已加密帧。' }); + } catch (error) { + if (item) { + item = { ...item, state: { kind: 'incomplete' } }; + resource.draftItems = (resource.draftItems ?? []).map((draft) => draft.id === item?.id ? item! : draft); + await saveResource(resource); + publishShare(id, { t: 'item', item }); + } + publishShare(id, { t: 'error', message: `文件上传失败:${errorMessage(error)}` }); + throw error; } } } diff --git a/web/src/core-bridge/worker/transfer.ts b/web/src/core-bridge/worker/transfer.ts index 331b8d5..1a7385e 100644 --- a/web/src/core-bridge/worker/transfer.ts +++ b/web/src/core-bridge/worker/transfer.ts @@ -1,10 +1,21 @@ -import { fromBase64Url, IntegrityError, type ChannelSession, type ItemDecodeResult, type ItemPlan } from '@webdrop/core'; +import { fromBase64Url, IntegrityError, MAX_FRAME_DATA, type ChannelSession, type ItemDecodeResult, type ItemPlan } from '@webdrop/core'; import { dequeueFrame, queueFrame, nextQueuedFrame, type SavedResource } from '../database'; -import { apiFetch, type ApiError } from './http'; +import { apiFetch, apiUpload, type ApiError } from './http'; import { serial } from './serial'; -export async function persistPlan(resource: SavedResource, plan: ItemPlan): Promise { +// Frame plaintext includes a 4-byte length prefix before padding to the 256 KiB tier. +export const MOBILE_UPLOAD_FRAME_DATA_BYTES = 256 * 1024 - 4; +export { MAX_FRAME_DATA }; +const UPLOAD_RETRY_DELAYS_MS = [500, 1500, 4000] as const; + +export async function persistPlan( + resource: SavedResource, + plan: ItemPlan, + onProgress?: (frames: number, bytes: number) => void, +): Promise<{ frames: number; bytes: number }> { const createdAt = Date.now(); + let frames = 0; + let bytes = 0; for await (const frame of plan.frames) { await queueFrame({ key: `${resource.id}:${plan.itemId}:${String(frame.index).padStart(10, '0')}`, @@ -15,7 +26,11 @@ export async function persistPlan(resource: SavedResource, plan: ItemPlan): Prom extraHeaders: frame.index === 0 ? { ...plan.frame0Headers } : {}, createdAt: createdAt + frame.index, }); + frames += 1; + bytes += frame.bytes.byteLength; + onProgress?.(frames, bytes); } + return { frames, bytes }; } function writeRole(session: ChannelSession): 'owner' | 'write' { @@ -25,28 +40,51 @@ function writeRole(session: ChannelSession): 'owner' | 'write' { } /** The outbox contains ciphertext only. Every retry sends these exact bytes. */ -export async function flushOutbox(resource: SavedResource, session: ChannelSession): Promise { - return serial(`outbox:${resource.id}`, () => flush(resource, session)); +export async function flushOutbox( + resource: SavedResource, + session: ChannelSession, + onFrameProgress?: (frame: { itemId: string; index: number }, loadedBytes: number, totalBytes: number, complete: boolean) => void, +): Promise { + return serial(`outbox:${resource.id}`, () => flush(resource, session, onFrameProgress)); } -async function flush(resource: SavedResource, session: ChannelSession): Promise { +async function flush( + resource: SavedResource, + session: ChannelSession, + onFrameProgress?: (frame: { itemId: string; index: number }, loadedBytes: number, totalBytes: number, complete: boolean) => void, +): Promise { for (;;) { const frame = await nextQueuedFrame(resource.id); if (!frame) return true; const role = resource.kind === 'share' ? 'owner' : writeRole(session); const path = resource.kind === 'share' ? `/shares/${resource.id}/frames` : `/channels/${resource.id}/frames`; - try { - await apiFetch(path, { - method: 'POST', - headers: { ...session.authHeaders(role), 'Content-Type': 'application/octet-stream', ...frame.extraHeaders }, - body: frame.bytes.slice().buffer as ArrayBuffer, - }); - await dequeueFrame(frame.key); - } catch (error) { - const apiError = error as ApiError; - if (apiError.status === undefined || apiError.status === 429 || apiError.status >= 500) return false; - throw error; + let maxLoadedBytes = 0; + let accepted = false; + for (let attempt = 0; attempt <= UPLOAD_RETRY_DELAYS_MS.length; attempt += 1) { + try { + await apiUpload( + path, + { ...session.authHeaders(role), 'Content-Type': 'application/octet-stream', ...frame.extraHeaders }, + frame.bytes.slice().buffer as ArrayBuffer, + (loaded, total) => { + maxLoadedBytes = Math.max(maxLoadedBytes, loaded); + onFrameProgress?.(frame, maxLoadedBytes, total, false); + }, + ); + await dequeueFrame(frame.key); + onFrameProgress?.(frame, frame.bytes.byteLength, frame.bytes.byteLength, true); + accepted = true; + break; + } catch (error) { + const apiError = error as ApiError; + const retryable = apiError.status === undefined || apiError.status === 408 || apiError.status === 425 + || apiError.status === 429 || apiError.status === 499 || apiError.status >= 500; + if (!retryable) throw error; + if (attempt === UPLOAD_RETRY_DELAYS_MS.length) return false; + await new Promise((resolve) => setTimeout(resolve, UPLOAD_RETRY_DELAYS_MS[attempt])); + } } + if (!accepted) return false; } } diff --git a/web/src/features/channel/LatestCard.tsx b/web/src/features/channel/LatestCard.tsx index 34955c6..5bf8099 100644 --- a/web/src/features/channel/LatestCard.tsx +++ b/web/src/features/channel/LatestCard.tsx @@ -1,4 +1,4 @@ -import { formatSize, formatTime } from '@/lib/format'; +import { formatSize, formatTime, formatTransferProgress } from '@/lib/format'; import { useDownload } from '@/features/item/download'; import { InlineContent, inlineKindOf } from '@/features/item/InlineContent'; import { shortDevice, titleOf } from '@/features/item/summary'; @@ -33,6 +33,7 @@ export function LatestCard({ id, onOpen }: Props) { {inline === 'text' ? null : {titleOf(item)}} {shortDevice(device)} · {formatTime(item.ts)} · {formatSize(size)} + {receiving !== null && item.state.kind === 'receiving' ? ` · ${formatTransferProgress(item.state)}` : ''} diff --git a/web/src/features/item/CompactRow.tsx b/web/src/features/item/CompactRow.tsx index 52989cd..12e7bff 100644 --- a/web/src/features/item/CompactRow.tsx +++ b/web/src/features/item/CompactRow.tsx @@ -1,5 +1,5 @@ import { memo } from 'react'; -import { formatSize, formatTime } from '@/lib/format'; +import { formatSize, formatTime, formatTransferProgress } from '@/lib/format'; import { Button } from '@/ui/Button'; import { CopyButton } from '@/ui/CopyButton'; import { useDownload } from './download'; @@ -42,6 +42,7 @@ export const CompactRow = memo(function CompactRow({ id, selected, checked, onSe <> {shortDevice(nameOf(item.deviceId))} · {formatTime(item.ts)} · {formatSize(size)} {item.kind === 'file' && item.remaining !== null ? ` · 剩 ${item.remaining} 次` : ''} + {receiving !== null && item.state.kind === 'receiving' ? ` · ${formatTransferProgress(item.state)}` : ''} {version > 1 ? v{version} : null} {item.kind === 'file' && item.unseen ? 有更新 : null} {item.kind === 'text' && item.conflict ? 冲突 : null} diff --git a/web/src/features/item/ItemDetail.tsx b/web/src/features/item/ItemDetail.tsx index 19b7ed9..fa3d52a 100644 --- a/web/src/features/item/ItemDetail.tsx +++ b/web/src/features/item/ItemDetail.tsx @@ -1,7 +1,7 @@ import { useState } from 'react'; import type { Item } from '@/core-bridge/types'; import { isLongText } from '@/lib/fileKind'; -import { formatSize, formatTime } from '@/lib/format'; +import { formatSize, formatTime, formatTransferProgress } from '@/lib/format'; import { Button } from '@/ui/Button'; import { CopyButton } from '@/ui/CopyButton'; import { useDownload } from './download'; @@ -71,7 +71,7 @@ export default function ItemDetail({ id, showTitle, readOnly, onDelete }: Props) ) : null}
- {receiving !== null ?

传输中 {Math.round(receiving * 100)}%,完成后可预览。

: null} + {receiving !== null && item.state.kind === 'receiving' ?

{formatTransferProgress(item.state)} · 完成后可预览。

: null} {receiving === null && editing && item.kind === 'text' ? ( setEditing(false)} /> ) : null} diff --git a/web/src/features/share/DraftRow.tsx b/web/src/features/share/DraftRow.tsx index f8e1943..ac88a6e 100644 --- a/web/src/features/share/DraftRow.tsx +++ b/web/src/features/share/DraftRow.tsx @@ -1,4 +1,4 @@ -import { formatSize } from '@/lib/format'; +import { formatSize, formatTransferProgress } from '@/lib/format'; import { useShareSession } from '@/store/shareSession'; import { Button } from '@/ui/Button'; import { titleOf } from '@/features/item/summary'; @@ -20,7 +20,7 @@ export function DraftRow({ id, selected, onSelect }: { id: string; selected: boo {titleOf(item)} - {formatSize(size)}{receiving !== null ? ` · 传输中 ${Math.round(receiving * 100)}%` : ''} + {formatSize(size)}{receiving !== null && item.state.kind === 'receiving' ? ` · ${formatTransferProgress(item.state)}` : ''} {receiving !== null ? ( diff --git a/web/src/lib/format.ts b/web/src/lib/format.ts index d8e00c3..2f4dada 100644 --- a/web/src/lib/format.ts +++ b/web/src/lib/format.ts @@ -10,6 +10,18 @@ export function formatSize(bytes: number): string { return `${v >= 100 ? v.toFixed(0) : v.toFixed(1)} ${units[i]}`; } +export function formatTransferProgress(state: { + progress: number; + phase?: 'encrypting' | 'uploading'; + speedBytesPerSecond?: number; +}): string { + const phase = state.phase === 'encrypting' ? '加密中' : state.phase === 'uploading' ? '上传中' : '传输中'; + const speed = state.speedBytesPerSecond && state.speedBytesPerSecond > 0 + ? ` · ${formatSize(state.speedBytesPerSecond)}/s` + : ''; + return `${phase} ${Math.round(state.progress * 100)}%${speed}`; +} + const timeFmt = new Intl.DateTimeFormat('zh-CN', { hour: '2-digit', minute: '2-digit', hour12: false }); export function formatTime(ts: number): string { diff --git a/web/src/styles/base.css b/web/src/styles/base.css index 7b97145..00a7df8 100644 --- a/web/src/styles/base.css +++ b/web/src/styles/base.css @@ -45,8 +45,9 @@ summary, /* iOS Safari 对小于 16px 的输入框聚焦时会自动放大页面 */ @media (pointer: coarse) { - input, - textarea { + input:not([type="checkbox"]):not([type="radio"]), + textarea, + select { font-size: 16px; } }