feat(chat): port chat widget components from Dart to React
This commit is contained in:
@@ -0,0 +1,119 @@
|
||||
"use client";
|
||||
/**
|
||||
* ImageUploadButton 图片上传按钮
|
||||
*
|
||||
* 原始 Dart: lib/ui/chat/widgets/image_upload_button.dart(159 行)
|
||||
*
|
||||
* 原 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>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user