refactor(chat): align chat header and input bar with Dart source
This commit is contained in:
@@ -6,14 +6,15 @@
|
|||||||
*
|
*
|
||||||
* 两种模式:
|
* 两种模式:
|
||||||
* - 游客模式(isGuest=true):渲染 `GuestBanner` 提示注册(点击 → /auth)
|
* - 游客模式(isGuest=true):渲染 `GuestBanner` 提示注册(点击 → /auth)
|
||||||
* - 登录模式(isGuest=false):渲染 `MenuButton`(点击 → 打开 ChatMenuBar)
|
* - 登录模式(isGuest=false):渲染 `MenuButton`(点击 → push 到 /sidebar)
|
||||||
|
*
|
||||||
|
* 注意:菜单按钮直接 push 到侧边栏路由,**不**打开下拉菜单
|
||||||
|
* (Dart 端 `MenuButton(onTap: _goSidebar)` 行为)。
|
||||||
*/
|
*/
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { useState } from "react";
|
|
||||||
|
|
||||||
import { ROUTES } from "@/router/routes";
|
import { ROUTES } from "@/router/routes";
|
||||||
|
|
||||||
import { ChatMenuBar } from "./chat-menu-bar";
|
|
||||||
import styles from "./chat-header.module.css";
|
import styles from "./chat-header.module.css";
|
||||||
|
|
||||||
export interface ChatHeaderProps {
|
export interface ChatHeaderProps {
|
||||||
@@ -22,7 +23,6 @@ export interface ChatHeaderProps {
|
|||||||
|
|
||||||
export function ChatHeader({ isGuest }: ChatHeaderProps) {
|
export function ChatHeader({ isGuest }: ChatHeaderProps) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [menuOpen, setMenuOpen] = useState(false);
|
|
||||||
|
|
||||||
if (isGuest) {
|
if (isGuest) {
|
||||||
return (
|
return (
|
||||||
@@ -43,29 +43,20 @@ export function ChatHeader({ isGuest }: ChatHeaderProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<header className={styles.header}>
|
||||||
<header className={styles.header}>
|
<div className={styles.headerRow}>
|
||||||
<div className={styles.headerRow}>
|
{/* 菜单按钮(点击 → push 到 /sidebar,与 Dart MenuButton 一致) */}
|
||||||
{/* 菜单按钮(点击 → 打开 ChatMenuBar 浮层) */}
|
<button
|
||||||
<button
|
type="button"
|
||||||
type="button"
|
className={styles.menuButton}
|
||||||
className={styles.menuButton}
|
onClick={() => router.push(ROUTES.sidebar)}
|
||||||
onClick={() => setMenuOpen(true)}
|
aria-label="Menu"
|
||||||
aria-label="Menu"
|
>
|
||||||
aria-expanded={menuOpen}
|
<span className={styles.menuIcon} aria-hidden="true">
|
||||||
>
|
☰
|
||||||
<span className={styles.menuIcon} aria-hidden="true">
|
</span>
|
||||||
☰
|
</button>
|
||||||
</span>
|
</div>
|
||||||
</button>
|
</header>
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<ChatMenuBar
|
|
||||||
open={menuOpen}
|
|
||||||
onClose={() => setMenuOpen(false)}
|
|
||||||
environmentName="dev"
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,35 +1,32 @@
|
|||||||
/* ChatInputBar 输入栏容器样式 */
|
/* ChatInputBar 输入栏容器样式(与 Dart chat_input_bar.dart 对齐) */
|
||||||
|
|
||||||
|
/* 外层:focused 时加 padding-top + 粉色背景 + 阴影 */
|
||||||
.bar {
|
.bar {
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
padding: var(--spacing-3, 12px) var(--spacing-5, 20px) var(--spacing-5, 20px);
|
padding: 0 var(--spacing-lg, 16px) var(--spacing-lg, 16px);
|
||||||
background: var(--color-input-bar-background, rgba(0, 0, 0, 0.4));
|
background: transparent;
|
||||||
backdrop-filter: blur(8px);
|
transition: padding-top 0.2s ease, background-color 0.2s ease,
|
||||||
border-top: 1px solid var(--color-chat-input-border, rgba(255, 255, 255, 0.12));
|
box-shadow 0.2s ease;
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: var(--spacing-2, 8px);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.barFocused {
|
||||||
|
padding-top: var(--spacing-md, 12px);
|
||||||
|
background: #feeff2;
|
||||||
|
box-shadow: 0 4px 12px var(--color-input-box-shadow, rgba(0, 0, 0, 0.1));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 内层:白底 + 大圆角(Dart AppRadius.radius32)+ focused 时 accent 边框 */
|
||||||
.row {
|
.row {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: var(--spacing-2, 8px);
|
gap: var(--spacing-sm, 8px);
|
||||||
padding: var(--spacing-1, 4px) var(--spacing-3, 12px);
|
padding: var(--spacing-sm, 8px);
|
||||||
background: var(--color-bubble-background, #fff);
|
background: #fff;
|
||||||
border-radius: 9999px;
|
border-radius: 32px;
|
||||||
border: 1px solid var(--color-chat-input-border, rgba(255, 255, 255, 0.2));
|
border: 1px solid transparent;
|
||||||
transition: border-color 0.2s, box-shadow 0.2s;
|
transition: border-color 0.2s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.rowFocused {
|
.rowFocused {
|
||||||
border-color: var(--color-accent, #f84d96);
|
border-color: var(--color-accent, #f84d96);
|
||||||
box-shadow: 0 4px 12px var(--color-input-shadow, rgba(0, 0, 0, 0.1));
|
|
||||||
}
|
|
||||||
|
|
||||||
.textFieldWrap {
|
|
||||||
flex: 1 1 auto;
|
|
||||||
min-width: 0;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,25 +4,28 @@
|
|||||||
*
|
*
|
||||||
* 原始 Dart: lib/ui/chat/widgets/chat_input_bar.dart(121 行)
|
* 原始 Dart: lib/ui/chat/widgets/chat_input_bar.dart(121 行)
|
||||||
*
|
*
|
||||||
* 组成(从左到右):
|
* 状态:
|
||||||
* - ChatMoreButton(⊞):展开更多选项(图片/语音/功能)
|
|
||||||
* - ChatInputTextField:文本输入
|
|
||||||
* - ChatSendButton(↑):发送
|
|
||||||
*
|
|
||||||
* 状态管理(自包含):
|
|
||||||
* - input:受控文本
|
* - input:受控文本
|
||||||
* - focused:是否聚焦(视觉状态)
|
* - isFocused:是否聚焦(控制外层 pink-bg / box-shadow / padding-top)
|
||||||
* - hasContent:是否有内容(决定发送按钮启用)
|
* - hasContent:是否有 trim 内容(决定发送按钮是否启用)
|
||||||
|
*
|
||||||
|
* 容器层次(与 Dart 一致):
|
||||||
|
* - 外层 `.bar`:focused 时 `padding-top: md` + 粉色背景 + 阴影
|
||||||
|
* - 内层 `.row`:白底 + `border-radius: 32` + focused 时 accent 边框
|
||||||
|
* - Row:[ChatInputTextField (Expanded), ChatSendButton]
|
||||||
|
*
|
||||||
|
* 行为:
|
||||||
|
* - send 后清空 input + 保持 textarea 焦点(与 Dart `_focusNode.requestFocus()` 一致)
|
||||||
|
*
|
||||||
|
* 注意:Dart 源里的 `ImageUploadButton` 已被注释(`// const ImageUploadButton()`),
|
||||||
|
* `ChatMoreButton` / `FunctionButtonBar` 不存在于 Dart 源;本组件仅保留与 Dart 一致的部分。
|
||||||
*/
|
*/
|
||||||
import { useState } from "react";
|
import { useRef, useState } from "react";
|
||||||
|
|
||||||
import { useChatDispatch } from "@/stores/chat/chat-context";
|
import { useChatDispatch } from "@/stores/chat/chat-context";
|
||||||
|
|
||||||
import { ChatInputTextField } from "./chat-input-text-field";
|
import { ChatInputTextField } from "./chat-input-text-field";
|
||||||
import { ChatMoreButton } from "./chat-more-button";
|
|
||||||
import { ChatSendButton } from "./chat-send-button";
|
import { ChatSendButton } from "./chat-send-button";
|
||||||
import { FunctionButtonBar } from "./function-button-bar";
|
|
||||||
import { ImageUploadButton } from "./image-upload-button";
|
|
||||||
import styles from "./chat-input-bar.module.css";
|
import styles from "./chat-input-bar.module.css";
|
||||||
|
|
||||||
export interface ChatInputBarProps {
|
export interface ChatInputBarProps {
|
||||||
@@ -32,8 +35,8 @@ export interface ChatInputBarProps {
|
|||||||
export function ChatInputBar({ disabled = false }: ChatInputBarProps) {
|
export function ChatInputBar({ disabled = false }: ChatInputBarProps) {
|
||||||
const dispatch = useChatDispatch();
|
const dispatch = useChatDispatch();
|
||||||
const [input, setInput] = useState("");
|
const [input, setInput] = useState("");
|
||||||
const [focused, setFocused] = useState(false);
|
const [isFocused, setIsFocused] = useState(false);
|
||||||
const [showFunctions, setShowFunctions] = useState(false);
|
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||||
|
|
||||||
const hasContent = input.trim().length > 0;
|
const hasContent = input.trim().length > 0;
|
||||||
|
|
||||||
@@ -41,45 +44,24 @@ export function ChatInputBar({ disabled = false }: ChatInputBarProps) {
|
|||||||
if (!hasContent) return;
|
if (!hasContent) return;
|
||||||
dispatch({ type: "ChatSendMessage", content: input });
|
dispatch({ type: "ChatSendMessage", content: input });
|
||||||
setInput("");
|
setInput("");
|
||||||
|
textareaRef.current?.focus();
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.bar}>
|
<div className={`${styles.bar} ${isFocused ? styles.barFocused : ""}`}>
|
||||||
{showFunctions && (
|
<div className={`${styles.row} ${isFocused ? styles.rowFocused : ""}`}>
|
||||||
<FunctionButtonBar
|
<ChatInputTextField
|
||||||
items={[
|
ref={textareaRef}
|
||||||
{
|
value={input}
|
||||||
key: "image",
|
onChange={setInput}
|
||||||
icon: "🖼",
|
onSubmit={handleSend}
|
||||||
label: "Image",
|
onFocusChange={setIsFocused}
|
||||||
// ImageUploadButton 内部已管理 sheet;FunctionButtonBar 仅触发
|
disabled={disabled}
|
||||||
onClick: () => {
|
/>
|
||||||
/* 触发:ImageUploadButton 已在 ChatInputBar 中渲染 */
|
<ChatSendButton
|
||||||
},
|
disabled={!hasContent || disabled}
|
||||||
},
|
onClick={handleSend}
|
||||||
{ key: "restart", icon: "↻", label: "Restart" },
|
|
||||||
{ key: "history", icon: "⏱", label: "History" },
|
|
||||||
{ key: "call", icon: "📞", label: "Call" },
|
|
||||||
]}
|
|
||||||
/>
|
/>
|
||||||
)}
|
|
||||||
|
|
||||||
<div className={`${styles.row} ${focused ? styles.rowFocused : ""}`}>
|
|
||||||
<ImageUploadButton disabled={disabled} />
|
|
||||||
<ChatMoreButton onClick={() => setShowFunctions((v) => !v)} />
|
|
||||||
<div
|
|
||||||
className={styles.textFieldWrap}
|
|
||||||
onFocus={() => setFocused(true)}
|
|
||||||
onBlur={() => setFocused(false)}
|
|
||||||
>
|
|
||||||
<ChatInputTextField
|
|
||||||
value={input}
|
|
||||||
onChange={setInput}
|
|
||||||
onSubmit={handleSend}
|
|
||||||
disabled={disabled}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<ChatSendButton disabled={!hasContent || disabled} onClick={handleSend} />
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,14 +1,28 @@
|
|||||||
/* ChatInputTextField 输入框样式 */
|
/* ChatInputTextField 输入框样式(外层白底圆角容器 + 内部 textarea) */
|
||||||
|
|
||||||
|
/* 外层圆角容器(与 Dart `Container(borderRadius: AppRadius.xxxl ≈ 32)` 一致) */
|
||||||
|
.wrap {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
background: transparent; /* 容器透明,背景由 .row 提供 */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* textarea 本身(裸元素,背景透明让父容器透出) */
|
||||||
.textField {
|
.textField {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-width: 0;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 40px;
|
min-height: 40px;
|
||||||
padding: 0 var(--spacing-4, 16px);
|
max-height: 120px; /* 约 5 行 × 24px line-height */
|
||||||
|
padding: 0;
|
||||||
border: 0;
|
border: 0;
|
||||||
outline: none;
|
outline: none;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: var(--color-text-foreground, #000);
|
color: var(--color-text-foreground, #000);
|
||||||
font-size: var(--font-size-base, 14px);
|
font-size: var(--font-size-base, 14px);
|
||||||
|
line-height: 24px;
|
||||||
resize: none;
|
resize: none;
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,12 +5,22 @@
|
|||||||
* 原始 Dart: lib/ui/chat/widgets/chat_input_text_field.dart(75 行)
|
* 原始 Dart: lib/ui/chat/widgets/chat_input_text_field.dart(75 行)
|
||||||
*
|
*
|
||||||
* 行为:
|
* 行为:
|
||||||
* - 多行输入(最多 5 行)
|
* - 多行输入(min 1 行 / max 5 行 ≈ max-height 120px)
|
||||||
* - Enter 直接发送(无 Shift/Ctrl)
|
* - Enter 直接发送(无 Shift/Ctrl/Meta)
|
||||||
* - Shift+Enter / Ctrl+Enter 换行
|
* - Shift+Enter / Ctrl+Enter / Meta+Enter 换行
|
||||||
* - 受控 + 非受控:value + onChange
|
* - 外层白底圆角容器(与 Dart `Container(borderRadius: AppRadius.xxxl ≈ 32)` 一致)
|
||||||
|
*
|
||||||
|
* 通过 `forwardRef` 暴露 textarea 节点,父组件可在 send 后调用 `.focus()`
|
||||||
|
* (对齐 Dart `_focusNode.requestFocus()`)。
|
||||||
|
* 通过 `onFocusChange` 回调通知父组件焦点状态,用于外层容器的聚焦样式切换。
|
||||||
*/
|
*/
|
||||||
import { type FormEvent, type KeyboardEvent } from "react";
|
import {
|
||||||
|
forwardRef,
|
||||||
|
type FormEvent,
|
||||||
|
type KeyboardEvent,
|
||||||
|
useImperativeHandle,
|
||||||
|
useRef,
|
||||||
|
} from "react";
|
||||||
|
|
||||||
import styles from "./chat-input-text-field.module.css";
|
import styles from "./chat-input-text-field.module.css";
|
||||||
|
|
||||||
@@ -18,19 +28,32 @@ export interface ChatInputTextFieldProps {
|
|||||||
value: string;
|
value: string;
|
||||||
onChange: (value: string) => void;
|
onChange: (value: string) => void;
|
||||||
onSubmit: () => void;
|
onSubmit: () => void;
|
||||||
|
/** 焦点变化回调(用于父组件 ChatInputBar 切换聚焦态视觉) */
|
||||||
|
onFocusChange?: (focused: boolean) => void;
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
autoFocus?: boolean;
|
autoFocus?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ChatInputTextField({
|
export const ChatInputTextField = forwardRef<
|
||||||
value,
|
HTMLTextAreaElement,
|
||||||
onChange,
|
ChatInputTextFieldProps
|
||||||
onSubmit,
|
>(function ChatInputTextField(
|
||||||
placeholder = "Say something…",
|
{
|
||||||
disabled = false,
|
value,
|
||||||
autoFocus = false,
|
onChange,
|
||||||
}: ChatInputTextFieldProps) {
|
onSubmit,
|
||||||
|
onFocusChange,
|
||||||
|
placeholder = "Say something…",
|
||||||
|
disabled = false,
|
||||||
|
autoFocus = false,
|
||||||
|
},
|
||||||
|
ref,
|
||||||
|
) {
|
||||||
|
const innerRef = useRef<HTMLTextAreaElement>(null);
|
||||||
|
// 对外暴露与 forwardRef 同名的 ref(指向内部 textarea 节点)
|
||||||
|
useImperativeHandle(ref, () => innerRef.current as HTMLTextAreaElement, []);
|
||||||
|
|
||||||
const handleKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>) => {
|
const handleKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>) => {
|
||||||
if (e.key !== "Enter") return;
|
if (e.key !== "Enter") return;
|
||||||
if (e.shiftKey || e.ctrlKey || e.metaKey) return; // 修饰键 → 换行
|
if (e.shiftKey || e.ctrlKey || e.metaKey) return; // 修饰键 → 换行
|
||||||
@@ -39,18 +62,23 @@ export function ChatInputTextField({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<textarea
|
<div className={styles.wrap}>
|
||||||
className={styles.textField}
|
<textarea
|
||||||
value={value}
|
ref={innerRef}
|
||||||
onChange={(e: FormEvent<HTMLTextAreaElement>) =>
|
className={styles.textField}
|
||||||
onChange(e.currentTarget.value)
|
value={value}
|
||||||
}
|
onChange={(e: FormEvent<HTMLTextAreaElement>) =>
|
||||||
onKeyDown={handleKeyDown}
|
onChange(e.currentTarget.value)
|
||||||
placeholder={placeholder}
|
}
|
||||||
disabled={disabled}
|
onKeyDown={handleKeyDown}
|
||||||
autoFocus={autoFocus}
|
onFocus={() => onFocusChange?.(true)}
|
||||||
rows={1}
|
onBlur={() => onFocusChange?.(false)}
|
||||||
aria-label="Message"
|
placeholder={placeholder}
|
||||||
/>
|
disabled={disabled}
|
||||||
|
autoFocus={autoFocus}
|
||||||
|
rows={1}
|
||||||
|
aria-label="Message"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
});
|
||||||
|
|||||||
@@ -1,69 +0,0 @@
|
|||||||
/* ChatMenuBar 菜单栏样式 */
|
|
||||||
|
|
||||||
.overlay {
|
|
||||||
position: fixed;
|
|
||||||
inset: 0;
|
|
||||||
z-index: 60;
|
|
||||||
}
|
|
||||||
|
|
||||||
.backdrop {
|
|
||||||
position: absolute;
|
|
||||||
inset: 0;
|
|
||||||
background: rgba(0, 0, 0, 0.4);
|
|
||||||
}
|
|
||||||
|
|
||||||
.menu {
|
|
||||||
position: absolute;
|
|
||||||
top: 56px;
|
|
||||||
right: 12px;
|
|
||||||
min-width: 200px;
|
|
||||||
background: var(--color-surface, #1f1a2e);
|
|
||||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
|
||||||
border-radius: var(--radius-md, 8px);
|
|
||||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.4);
|
|
||||||
padding: var(--spacing-1, 4px) 0;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.item {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--spacing-3, 12px);
|
|
||||||
padding: var(--spacing-3, 12px) var(--spacing-4, 16px);
|
|
||||||
background: transparent;
|
|
||||||
border: 0;
|
|
||||||
border-radius: 0;
|
|
||||||
color: var(--color-text-primary, #fff);
|
|
||||||
font-size: var(--font-size-base, 14px);
|
|
||||||
text-align: left;
|
|
||||||
cursor: pointer;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.item:hover,
|
|
||||||
.item:focus-visible {
|
|
||||||
background: rgba(255, 255, 255, 0.08);
|
|
||||||
}
|
|
||||||
|
|
||||||
.itemIcon {
|
|
||||||
font-size: 18px;
|
|
||||||
width: 20px;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.divider {
|
|
||||||
height: 1px;
|
|
||||||
background: rgba(255, 255, 255, 0.1);
|
|
||||||
margin: var(--spacing-1, 4px) 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.badge {
|
|
||||||
margin-left: auto;
|
|
||||||
font-size: var(--font-size-xs, 11px);
|
|
||||||
padding: 2px 6px;
|
|
||||||
background: var(--color-accent, #f84d96);
|
|
||||||
color: #fff;
|
|
||||||
border-radius: 9999px;
|
|
||||||
}
|
|
||||||
@@ -1,138 +0,0 @@
|
|||||||
"use client";
|
|
||||||
/**
|
|
||||||
* ChatMenuBar 菜单栏(悬浮下拉菜单)
|
|
||||||
*
|
|
||||||
* 原始 Dart: lib/ui/chat/widgets/chat_menu_bar.dart(224 行,原始文件全部注释)
|
|
||||||
* 原始设计:横向 Row[环境 / 语言 / 登录] 三个胶囊按钮
|
|
||||||
*
|
|
||||||
* 本轮采用 TS 现代版:**悬浮下拉菜单**
|
|
||||||
* - 由 ChatHeader 的 ☰ 按钮触发
|
|
||||||
* - 右上方弹出 menu(环境/语言/设置/退出登录)
|
|
||||||
* - 点击外部 / 选项 → 关闭
|
|
||||||
*
|
|
||||||
* 优势:
|
|
||||||
* - 占用更少屏幕空间(与原 Dart 三个胶囊按钮对比)
|
|
||||||
* - 与 ChatHeader 的 ☰ 按钮天然配合
|
|
||||||
* - 易于扩展更多菜单项
|
|
||||||
*/
|
|
||||||
import { useRouter } from "next/navigation";
|
|
||||||
import { useEffect, useRef } from "react";
|
|
||||||
|
|
||||||
import { ROUTES } from "@/router/routes";
|
|
||||||
import { SpAsyncUtil } from "@/utils/storage";
|
|
||||||
import { useAuthDispatch } from "@/stores/auth/auth-context";
|
|
||||||
import { useUserDispatch } from "@/stores/user/user-context";
|
|
||||||
|
|
||||||
import styles from "./chat-menu-bar.module.css";
|
|
||||||
|
|
||||||
export interface ChatMenuBarProps {
|
|
||||||
open: boolean;
|
|
||||||
onClose: () => void;
|
|
||||||
/** 当前环境名(开发/测试/生产),用于显示在菜单中 */
|
|
||||||
environmentName?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ChatMenuItem {
|
|
||||||
key: string;
|
|
||||||
icon: string;
|
|
||||||
label: string;
|
|
||||||
badge?: string;
|
|
||||||
onClick: () => void;
|
|
||||||
danger?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ChatMenuBar({
|
|
||||||
open,
|
|
||||||
onClose,
|
|
||||||
environmentName = "dev",
|
|
||||||
}: ChatMenuBarProps) {
|
|
||||||
const router = useRouter();
|
|
||||||
const authDispatch = useAuthDispatch();
|
|
||||||
const userDispatch = useUserDispatch();
|
|
||||||
const menuRef = useRef<HTMLDivElement>(null);
|
|
||||||
|
|
||||||
// ESC 关闭
|
|
||||||
useEffect(() => {
|
|
||||||
if (!open) return;
|
|
||||||
const onKey = (e: KeyboardEvent) => {
|
|
||||||
if (e.key === "Escape") onClose();
|
|
||||||
};
|
|
||||||
document.addEventListener("keydown", onKey);
|
|
||||||
return () => document.removeEventListener("keydown", onKey);
|
|
||||||
}, [open, onClose]);
|
|
||||||
|
|
||||||
if (!open) return null;
|
|
||||||
|
|
||||||
const items: ChatMenuItem[] = [
|
|
||||||
{
|
|
||||||
key: "language",
|
|
||||||
icon: "🌐",
|
|
||||||
label: "Language",
|
|
||||||
onClick: () => {
|
|
||||||
// 触发语言切换弹窗(占位:alert)
|
|
||||||
window.alert("Language dialog: see Round 5 LanguageDialog");
|
|
||||||
onClose();
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "environment",
|
|
||||||
icon: "⚙️",
|
|
||||||
label: `Environment: ${environmentName}`,
|
|
||||||
onClick: () => {
|
|
||||||
window.alert("Environment switcher: see Round 5");
|
|
||||||
onClose();
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{ key: "divider1", icon: "", label: "", onClick: () => {} },
|
|
||||||
{
|
|
||||||
key: "logout",
|
|
||||||
icon: "🚪",
|
|
||||||
label: "Sign out",
|
|
||||||
danger: true,
|
|
||||||
onClick: async () => {
|
|
||||||
// 清空本地存储 + 触发 logout 事件
|
|
||||||
await SpAsyncUtil.clear();
|
|
||||||
authDispatch({ type: "AuthReset" });
|
|
||||||
userDispatch({ type: "UserUpdate", user: null as never });
|
|
||||||
onClose();
|
|
||||||
router.push(ROUTES.splash);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={styles.overlay}>
|
|
||||||
<div
|
|
||||||
className={styles.backdrop}
|
|
||||||
onClick={onClose}
|
|
||||||
aria-hidden="true"
|
|
||||||
/>
|
|
||||||
<div
|
|
||||||
ref={menuRef}
|
|
||||||
className={styles.menu}
|
|
||||||
role="menu"
|
|
||||||
aria-orientation="vertical"
|
|
||||||
>
|
|
||||||
{items.map((item) =>
|
|
||||||
item.key.startsWith("divider") ? (
|
|
||||||
<div key={item.key} className={styles.divider} />
|
|
||||||
) : (
|
|
||||||
<button
|
|
||||||
key={item.key}
|
|
||||||
type="button"
|
|
||||||
className={styles.item}
|
|
||||||
onClick={item.onClick}
|
|
||||||
role="menuitem"
|
|
||||||
>
|
|
||||||
<span className={styles.itemIcon} aria-hidden="true">
|
|
||||||
{item.icon}
|
|
||||||
</span>
|
|
||||||
<span>{item.label}</span>
|
|
||||||
{item.badge && <span className={styles.badge}>{item.badge}</span>}
|
|
||||||
</button>
|
|
||||||
),
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
/* ChatMoreButton "更多"按钮 */
|
|
||||||
|
|
||||||
.button {
|
|
||||||
flex: 0 0 auto;
|
|
||||||
width: 40px;
|
|
||||||
height: 40px;
|
|
||||||
border: 0;
|
|
||||||
border-radius: 9999px;
|
|
||||||
background: var(--color-bubble-transparent, rgba(255, 255, 255, 0));
|
|
||||||
color: var(--color-text-primary, #fff);
|
|
||||||
cursor: pointer;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.icon {
|
|
||||||
width: 24px;
|
|
||||||
height: 24px;
|
|
||||||
font-size: 24px;
|
|
||||||
line-height: 1;
|
|
||||||
}
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
"use client";
|
|
||||||
/**
|
|
||||||
* ChatMoreButton "更多"按钮
|
|
||||||
*
|
|
||||||
* 原始 Dart: lib/ui/chat/widgets/chat_more_button.dart(29 行)
|
|
||||||
*
|
|
||||||
* 用途:展开图片上传 / 语音输入 / 功能按钮等更多选项
|
|
||||||
* 第 2 轮:仅占位(点击无操作或打开菜单)
|
|
||||||
*/
|
|
||||||
import styles from "./chat-more-button.module.css";
|
|
||||||
|
|
||||||
export interface ChatMoreButtonProps {
|
|
||||||
onClick: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ChatMoreButton({ onClick }: ChatMoreButtonProps) {
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={styles.button}
|
|
||||||
onClick={onClick}
|
|
||||||
aria-label="More options"
|
|
||||||
>
|
|
||||||
<span className={styles.icon} aria-hidden="true">
|
|
||||||
⊞
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -24,6 +24,8 @@ import Image from "next/image";
|
|||||||
import { useChatDispatch, useChatState } from "@/stores/chat/chat-context";
|
import { useChatDispatch, useChatState } from "@/stores/chat/chat-context";
|
||||||
import { GuestChatQuota } from "@/stores/chat/chat-types";
|
import { GuestChatQuota } from "@/stores/chat/chat-types";
|
||||||
|
|
||||||
|
import { MobileShell } from "@/app/_components/core/mobile-shell";
|
||||||
|
|
||||||
import { BrowserHintOverlay } from "./browser-hint-overlay";
|
import { BrowserHintOverlay } from "./browser-hint-overlay";
|
||||||
import { ChatArea } from "./chat-area";
|
import { ChatArea } from "./chat-area";
|
||||||
import { ChatHeader } from "./chat-header";
|
import { ChatHeader } from "./chat-header";
|
||||||
@@ -76,42 +78,44 @@ export function ChatScreen() {
|
|||||||
}, [state.guestRemainingQuota, state.isGuest]);
|
}, [state.guestRemainingQuota, state.isGuest]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.shell}>
|
<MobileShell>
|
||||||
{/* 背景图(与原 Dart MobileLayout 一致) */}
|
<div className={styles.shell}>
|
||||||
<div className={styles.background}>
|
{/* 背景图(与原 Dart MobileLayout 一致) */}
|
||||||
<Image
|
<div className={styles.background}>
|
||||||
src="/images/chat/bg-chatpage.png"
|
<Image
|
||||||
alt=""
|
src="/images/chat/bg-chatpage.png"
|
||||||
fill
|
alt=""
|
||||||
priority
|
fill
|
||||||
sizes="(max-width: 500px) 100vw, 500px"
|
priority
|
||||||
|
sizes="(max-width: 500px) 100vw, 500px"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.layout}>
|
||||||
|
<ChatHeader isGuest={state.isGuest} />
|
||||||
|
|
||||||
|
<ChatArea
|
||||||
|
messages={state.messages}
|
||||||
|
isReplyingAI={state.isReplyingAI}
|
||||||
|
isGuest={state.isGuest}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ChatInputBar disabled={state.isReplyingAI} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 浏览器提示(应用内浏览器检测) */}
|
||||||
|
<BrowserHintOverlay />
|
||||||
|
|
||||||
|
{/* PWA 安装提示触发器(无可见 UI,3.5s 后弹 dialog) */}
|
||||||
|
<PwaInstallOverlay />
|
||||||
|
|
||||||
|
{/* 配额告警弹窗 */}
|
||||||
|
<QuotaDialog
|
||||||
|
open={quotaDialogOpen}
|
||||||
|
onClose={() => setQuotaDialogOpen(false)}
|
||||||
|
isExhausted={quotaExhausted}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</MobileShell>
|
||||||
<div className={styles.layout}>
|
|
||||||
<ChatHeader isGuest={state.isGuest} />
|
|
||||||
|
|
||||||
<ChatArea
|
|
||||||
messages={state.messages}
|
|
||||||
isReplyingAI={state.isReplyingAI}
|
|
||||||
isGuest={state.isGuest}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<ChatInputBar disabled={state.isReplyingAI} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 浏览器提示(应用内浏览器检测) */}
|
|
||||||
<BrowserHintOverlay />
|
|
||||||
|
|
||||||
{/* PWA 安装提示触发器(无可见 UI,3.5s 后弹 dialog) */}
|
|
||||||
<PwaInstallOverlay />
|
|
||||||
|
|
||||||
{/* 配额告警弹窗 */}
|
|
||||||
<QuotaDialog
|
|
||||||
open={quotaDialogOpen}
|
|
||||||
onClose={() => setQuotaDialogOpen(false)}
|
|
||||||
isExhausted={quotaExhausted}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,30 +0,0 @@
|
|||||||
/* FunctionButtonBar 功能按钮栏样式 */
|
|
||||||
|
|
||||||
.bar {
|
|
||||||
display: flex;
|
|
||||||
gap: var(--spacing-2, 8px);
|
|
||||||
padding: 0 var(--spacing-5, 20px) var(--spacing-2, 8px);
|
|
||||||
overflow-x: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.button {
|
|
||||||
flex: 0 0 auto;
|
|
||||||
height: 36px;
|
|
||||||
padding: 0 var(--spacing-3, 12px);
|
|
||||||
border: 0;
|
|
||||||
border-radius: 9999px;
|
|
||||||
background: var(--color-button-background, rgba(255, 255, 255, 0.1));
|
|
||||||
color: var(--color-text-primary, #fff);
|
|
||||||
font-size: var(--font-size-sm, 12px);
|
|
||||||
cursor: pointer;
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--spacing-1, 4px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.icon {
|
|
||||||
width: 16px;
|
|
||||||
height: 16px;
|
|
||||||
font-size: 16px;
|
|
||||||
line-height: 1;
|
|
||||||
}
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
"use client";
|
|
||||||
/**
|
|
||||||
* FunctionButtonBar 功能按钮栏
|
|
||||||
*
|
|
||||||
* 原始 Dart: lib/ui/chat/widgets/function_button_bar.dart(52 行)
|
|
||||||
*
|
|
||||||
* 横向滚动按钮组:Restart / History / Call / 等
|
|
||||||
* 每个按钮:图标 + 标签
|
|
||||||
*/
|
|
||||||
import styles from "./function-button-bar.module.css";
|
|
||||||
|
|
||||||
export interface FunctionButtonItem {
|
|
||||||
key: string;
|
|
||||||
icon: string; // emoji 占位(生产环境可换为 react-icons / svg)
|
|
||||||
label: string;
|
|
||||||
onClick?: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface FunctionButtonBarProps {
|
|
||||||
items?: readonly FunctionButtonItem[];
|
|
||||||
}
|
|
||||||
|
|
||||||
const DEFAULT_ITEMS: readonly FunctionButtonItem[] = [
|
|
||||||
{ key: "restart", icon: "↻", label: "Restart" },
|
|
||||||
{ key: "history", icon: "⏱", label: "History" },
|
|
||||||
{ key: "call", icon: "📞", label: "Call" },
|
|
||||||
];
|
|
||||||
|
|
||||||
export function FunctionButtonBar({
|
|
||||||
items = DEFAULT_ITEMS,
|
|
||||||
}: FunctionButtonBarProps) {
|
|
||||||
return (
|
|
||||||
<div className={styles.bar} role="toolbar" aria-label="Chat functions">
|
|
||||||
{items.map((it) => (
|
|
||||||
<button
|
|
||||||
key={it.key}
|
|
||||||
type="button"
|
|
||||||
className={styles.button}
|
|
||||||
onClick={it.onClick}
|
|
||||||
>
|
|
||||||
<span className={styles.icon} aria-hidden="true">
|
|
||||||
{it.icon}
|
|
||||||
</span>
|
|
||||||
<span>{it.label}</span>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,62 +0,0 @@
|
|||||||
/* ImageUploadButton 图片上传按钮样式 */
|
|
||||||
|
|
||||||
.button {
|
|
||||||
flex: 0 0 auto;
|
|
||||||
width: 40px;
|
|
||||||
height: 40px;
|
|
||||||
border: 1px solid var(--color-bubble-border, rgba(255, 255, 255, 0.2));
|
|
||||||
border-radius: 9999px;
|
|
||||||
background: var(--color-bubble-transparent, rgba(255, 255, 255, 0));
|
|
||||||
color: var(--color-text-primary, #fff);
|
|
||||||
cursor: pointer;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
transition: opacity 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.button:disabled {
|
|
||||||
opacity: 0.5;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hidden {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bottomSheet {
|
|
||||||
position: fixed;
|
|
||||||
bottom: 0;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
z-index: 50;
|
|
||||||
background: var(--color-dark-surface, #1f1a2e);
|
|
||||||
border-top-left-radius: var(--radius-xl, 20px);
|
|
||||||
border-top-right-radius: var(--radius-xl, 20px);
|
|
||||||
padding: var(--spacing-4, 16px) 0;
|
|
||||||
padding-bottom: max(var(--spacing-4, 16px), env(safe-area-inset-bottom));
|
|
||||||
box-shadow: 0 -4px 24px rgba(0, 0, 0, 0.3);
|
|
||||||
}
|
|
||||||
|
|
||||||
.sheetItem {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--spacing-3, 12px);
|
|
||||||
padding: var(--spacing-3, 12px) var(--spacing-5, 20px);
|
|
||||||
background: transparent;
|
|
||||||
border: 0;
|
|
||||||
color: var(--color-text-primary, #fff);
|
|
||||||
font-size: var(--font-size-base, 14px);
|
|
||||||
width: 100%;
|
|
||||||
text-align: left;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sheetItem:hover,
|
|
||||||
.sheetItem:focus-visible {
|
|
||||||
background: rgba(255, 255, 255, 0.05);
|
|
||||||
}
|
|
||||||
|
|
||||||
.sheetIcon {
|
|
||||||
font-size: 20px;
|
|
||||||
}
|
|
||||||
@@ -1,119 +0,0 @@
|
|||||||
"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>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -5,9 +5,8 @@
|
|||||||
// 屏幕 + 顶层
|
// 屏幕 + 顶层
|
||||||
export { ChatScreen } from "./chat-screen";
|
export { ChatScreen } from "./chat-screen";
|
||||||
|
|
||||||
// 顶部 + 菜单
|
// 顶部
|
||||||
export { ChatHeader } from "./chat-header";
|
export { ChatHeader } from "./chat-header";
|
||||||
export { ChatMenuBar } from "./chat-menu-bar";
|
|
||||||
|
|
||||||
// 消息区
|
// 消息区
|
||||||
export { ChatArea } from "./chat-area";
|
export { ChatArea } from "./chat-area";
|
||||||
@@ -16,9 +15,6 @@ export { ChatArea } from "./chat-area";
|
|||||||
export { ChatInputBar } from "./chat-input-bar";
|
export { ChatInputBar } from "./chat-input-bar";
|
||||||
export { ChatInputTextField } from "./chat-input-text-field";
|
export { ChatInputTextField } from "./chat-input-text-field";
|
||||||
export { ChatSendButton } from "./chat-send-button";
|
export { ChatSendButton } from "./chat-send-button";
|
||||||
export { ChatMoreButton } from "./chat-more-button";
|
|
||||||
export { ImageUploadButton } from "./image-upload-button";
|
|
||||||
export { FunctionButtonBar } from "./function-button-bar";
|
|
||||||
|
|
||||||
// 消息气泡
|
// 消息气泡
|
||||||
export { MessageBubble } from "./message-bubble";
|
export { MessageBubble } from "./message-bubble";
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/* PwaInstallOverlay PWA 安装弹窗触发器样式(无可见 UI,逻辑触发用) */
|
/* PwaInstallOverlay PWA 安装弹窗触发器样式(无可见 UI,逻辑触发用) */
|
||||||
|
|
||||||
/.hidden {
|
.hidden {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
"use client";
|
||||||
/**
|
/**
|
||||||
* SpAsyncUtil — 键值对持久化工具类(基于 unstorage)
|
* SpAsyncUtil — 键值对持久化工具类(基于 unstorage)
|
||||||
*
|
*
|
||||||
|
|||||||
Reference in New Issue
Block a user