120 lines
3.4 KiB
TypeScript
120 lines
3.4 KiB
TypeScript
"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>
|
||
</>
|
||
)}
|
||
</>
|
||
);
|
||
}
|