feat(chat): port chat widget components from Dart to React

This commit is contained in:
2026-06-09 20:19:52 +08:00
parent 199a14650e
commit 109a3e3855
48 changed files with 2463 additions and 382 deletions
@@ -0,0 +1,119 @@
"use client";
/**
* ImageUploadButton 图片上传按钮
*
* 原始 Dart: lib/ui/chat/widgets/image_upload_button.dart159 行)
*
* 原 Dart 使用 image_picker 库(移动端原生相册/相机)
* 本轮使用 Web File API`<input type="file">`)以避免添加原生依赖
*
* 行为:
* - 点击按钮 → 弹出底部 sheet(相册 / 取消)
* - 选择图片 → 读取为 base64 → dispatch ChatSendImage
* - 上传中按钮显示加载指示器
*/
import { useRef, useState } from "react";
import { useChatDispatch } from "@/stores/chat/chat-context";
import styles from "./image-upload-button.module.css";
export interface ImageUploadButtonProps {
disabled?: boolean;
}
export function ImageUploadButton({ disabled = false }: ImageUploadButtonProps) {
const dispatch = useChatDispatch();
const fileInputRef = useRef<HTMLInputElement>(null);
const [showSheet, setShowSheet] = useState(false);
const [isUploading, setIsUploading] = useState(false);
const handleFile = async (file: File) => {
if (isUploading) return;
setIsUploading(true);
setShowSheet(false);
try {
const buffer = await file.arrayBuffer();
const bytes = new Uint8Array(buffer);
let binary = "";
for (let i = 0; i < bytes.byteLength; i++) {
binary += String.fromCharCode(bytes[i]);
}
const base64 = window.btoa(binary);
const ext = file.name.split(".").pop()?.toLowerCase() || "png";
const mime = file.type || `image/${ext}`;
const dataUri = `data:${mime};base64,${base64}`;
dispatch({ type: "ChatSendImage", imageBase64: dataUri });
} catch (e) {
console.error("[ImageUpload] failed:", e);
} finally {
setIsUploading(false);
}
};
return (
<>
<button
type="button"
className={styles.button}
disabled={disabled || isUploading}
onClick={() => setShowSheet(true)}
aria-label="Upload image"
>
{isUploading ? "⏳" : "🖼"}
</button>
{/* 隐藏的文件 input(触发文件选择) */}
<input
ref={fileInputRef}
type="file"
accept="image/*"
className={styles.hidden}
onChange={(e) => {
const file = e.currentTarget.files?.[0];
if (file) void handleFile(file);
e.currentTarget.value = ""; // 允许重选同一文件
}}
/>
{/* 底部选择 sheet(相册/取消) */}
{showSheet && (
<>
<div
style={{
position: "fixed",
inset: 0,
zIndex: 49,
background: "rgba(0,0,0,0.4)",
}}
onClick={() => setShowSheet(false)}
/>
<div className={styles.bottomSheet} role="dialog">
<button
type="button"
className={styles.sheetItem}
onClick={() => fileInputRef.current?.click()}
>
<span className={styles.sheetIcon} aria-hidden="true">
🖼
</span>
<span>Choose from gallery</span>
</button>
<button
type="button"
className={styles.sheetItem}
onClick={() => setShowSheet(false)}
>
<span className={styles.sheetIcon} aria-hidden="true">
</span>
<span>Cancel</span>
</button>
</div>
</>
)}
</>
);
}