merge: 0629001 into 0629002
合并反馈、语音 ASR、MindSpace 修复等 0629001 发布改动,并与 Agent Runs 网关改动完成冲突解决。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+314
-105
@@ -1,7 +1,12 @@
|
||||
import { ChangeEvent, useEffect, useRef, useState, type ClipboardEvent } from 'react';
|
||||
import { ChangeEvent, useCallback, useEffect, useLayoutEffect, useRef, useState, type ClipboardEvent } from 'react';
|
||||
import { useNetworkStatus } from '../hooks/useNetworkStatus';
|
||||
import { openAvatarPicker } from '../utils/userAvatar';
|
||||
import { CHAT_SKILL_OPTIONS, filterChatSkills } from '../utils/chatSkills';
|
||||
import {
|
||||
CHAT_IMAGE_UPLOAD_MAX_COUNT,
|
||||
CHAT_IMAGE_UPLOAD_MAX_INPUT_BYTES,
|
||||
CHAT_IMAGE_UPLOAD_MAX_TOTAL_BYTES,
|
||||
} from '../utils/imageUpload';
|
||||
import { AvatarPicker } from './AvatarPicker';
|
||||
import { ChatSkillPicker } from './ChatSkillPicker';
|
||||
import { DesignSkillPanel } from './DesignSkillPanel';
|
||||
@@ -11,7 +16,6 @@ import { VoiceInputButton } from './VoiceInputButton';
|
||||
import type { CapabilityMap, ChatState, Message, PortalUser, Session, ToolConfirmation } from '../types';
|
||||
import type { MindSpaceSaveCategory } from '../types';
|
||||
|
||||
const MAX_PENDING_IMAGES = 6;
|
||||
const CHAT_PLACEHOLDER_PROMPTS = [
|
||||
'帮我写一篇温柔一点的小短文',
|
||||
'讲个轻松的笑话,让我换换脑子',
|
||||
@@ -47,11 +51,19 @@ const CHAT_PLACEHOLDER_PROMPTS = [
|
||||
|
||||
type PendingChatImage = {
|
||||
id: string;
|
||||
url: string;
|
||||
file: File;
|
||||
previewUrl: string;
|
||||
uploading: boolean;
|
||||
sizeBytes: number;
|
||||
uploadedUrl: string | null;
|
||||
uploadProgress: number | null;
|
||||
uploadStatus: 'queued' | 'uploading' | 'uploaded' | 'error';
|
||||
};
|
||||
|
||||
function formatUploadMegabytes(bytes: number) {
|
||||
const megabytes = bytes / (1024 * 1024);
|
||||
return `${Number.isInteger(megabytes) ? megabytes : megabytes.toFixed(1)}MB`;
|
||||
}
|
||||
|
||||
function FileIcon() {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true">
|
||||
@@ -68,6 +80,9 @@ export function ChatPanel({
|
||||
variant,
|
||||
user,
|
||||
messages,
|
||||
historyLoadingMore = false,
|
||||
historyHasMore = false,
|
||||
historyTotal = 0,
|
||||
chatState,
|
||||
pendingTool,
|
||||
session,
|
||||
@@ -75,6 +90,7 @@ export function ChatPanel({
|
||||
grantedSkills,
|
||||
onSubmit,
|
||||
onUploadImage,
|
||||
onLoadOlderMessages,
|
||||
onStop,
|
||||
onApproveTool,
|
||||
onPageSaved,
|
||||
@@ -83,17 +99,21 @@ export function ChatPanel({
|
||||
variant: 'full' | 'compact';
|
||||
user?: PortalUser | null;
|
||||
messages: Message[];
|
||||
historyLoadingMore?: boolean;
|
||||
historyHasMore?: boolean;
|
||||
historyTotal?: number;
|
||||
chatState: ChatState;
|
||||
pendingTool: ToolConfirmation | null;
|
||||
session: Session | null;
|
||||
capabilities?: CapabilityMap;
|
||||
grantedSkills?: string[];
|
||||
onSubmit: (text: string, imageUrls?: string[], previewImageUrls?: string[]) => void | Promise<void>;
|
||||
onUploadImage?: (file: File) => Promise<string>;
|
||||
onUploadImage?: (file: File, onProgress?: (progress: number) => void) => Promise<string>;
|
||||
onLoadOlderMessages?: () => void | Promise<void>;
|
||||
onStop: () => void | Promise<void>;
|
||||
onApproveTool: (allow: boolean) => void | Promise<void>;
|
||||
onPageSaved?: (result: {
|
||||
kind: 'page' | 'category';
|
||||
kind: 'page' | 'asset' | 'category';
|
||||
pageId?: string;
|
||||
categoryCode?: MindSpaceSaveCategory;
|
||||
}) => void;
|
||||
@@ -108,16 +128,25 @@ export function ChatPanel({
|
||||
const [pendingImages, setPendingImages] = useState<PendingChatImage[]>([]);
|
||||
const [uploadingImage, setUploadingImage] = useState(false);
|
||||
const [imageError, setImageError] = useState<string | null>(null);
|
||||
const [showHistoryFullyLoadedNotice, setShowHistoryFullyLoadedNotice] = useState(false);
|
||||
const [voiceStopSignal, setVoiceStopSignal] = useState(0);
|
||||
const [randomPrompt] = useState(
|
||||
() => CHAT_PLACEHOLDER_PROMPTS[Math.floor(Math.random() * CHAT_PLACEHOLDER_PROMPTS.length)],
|
||||
);
|
||||
const imageInputRef = useRef<HTMLInputElement>(null);
|
||||
const bottomRef = useRef<HTMLDivElement>(null);
|
||||
const mainRef = useRef<HTMLElement>(null);
|
||||
const inputRef = useRef(input);
|
||||
const voiceBaseRef = useRef('');
|
||||
const pendingImagesRef = useRef(pendingImages);
|
||||
const suppressVoiceUpdateRef = useRef(false);
|
||||
const nearBottomRef = useRef(true);
|
||||
const initializedSessionRef = useRef<string | null>(null);
|
||||
const pendingOlderLoadRef = useRef<null | {
|
||||
anchorId: string | null;
|
||||
anchorTop: number;
|
||||
remainingHeight: number;
|
||||
}>(null);
|
||||
const prevMessageCountRef = useRef(messages.length);
|
||||
|
||||
inputRef.current = input;
|
||||
pendingImagesRef.current = pendingImages;
|
||||
@@ -130,9 +159,123 @@ export function ChatPanel({
|
||||
};
|
||||
}, []);
|
||||
|
||||
const requestOlderMessages = useCallback(() => {
|
||||
const container = mainRef.current;
|
||||
if (!container || !onLoadOlderMessages || historyLoadingMore || !historyHasMore) return;
|
||||
const blocks = Array.from(container.querySelectorAll<HTMLElement>('[data-message-anchor]'));
|
||||
const anchor =
|
||||
blocks.find((block) => {
|
||||
const rect = block.getBoundingClientRect();
|
||||
return rect.bottom >= container.getBoundingClientRect().top + 8;
|
||||
}) ?? null;
|
||||
pendingOlderLoadRef.current = {
|
||||
anchorId: anchor?.dataset.messageAnchor ?? null,
|
||||
anchorTop: anchor?.getBoundingClientRect().top ?? container.getBoundingClientRect().top,
|
||||
remainingHeight: Math.max(container.clientHeight * 2, 1),
|
||||
};
|
||||
void onLoadOlderMessages();
|
||||
}, [historyHasMore, historyLoadingMore, onLoadOlderMessages]);
|
||||
|
||||
const handleMainScroll = useCallback(() => {
|
||||
const container = mainRef.current;
|
||||
if (!container) return;
|
||||
nearBottomRef.current =
|
||||
container.scrollHeight - container.scrollTop - container.clientHeight < 120;
|
||||
if (
|
||||
container.scrollTop < 120 &&
|
||||
historyHasMore &&
|
||||
!historyLoadingMore &&
|
||||
onLoadOlderMessages &&
|
||||
!pendingOlderLoadRef.current
|
||||
) {
|
||||
requestOlderMessages();
|
||||
}
|
||||
}, [historyHasMore, historyLoadingMore, onLoadOlderMessages, requestOlderMessages]);
|
||||
|
||||
useEffect(() => {
|
||||
bottomRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
}, [messages, chatState, pendingTool]);
|
||||
initializedSessionRef.current = null;
|
||||
pendingOlderLoadRef.current = null;
|
||||
nearBottomRef.current = true;
|
||||
setShowHistoryFullyLoadedNotice(false);
|
||||
}, [session?.id]);
|
||||
|
||||
useEffect(() => {
|
||||
const isHomeWelcome = variant !== 'compact' && messages.length === 0;
|
||||
if (isHomeWelcome || historyTotal <= 0 || historyHasMore || historyLoadingMore) {
|
||||
setShowHistoryFullyLoadedNotice(false);
|
||||
return;
|
||||
}
|
||||
setShowHistoryFullyLoadedNotice(true);
|
||||
const timer = window.setTimeout(() => {
|
||||
setShowHistoryFullyLoadedNotice(false);
|
||||
}, 5000);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [historyHasMore, historyLoadingMore, historyTotal, messages.length, variant]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const container = mainRef.current;
|
||||
if (!container) return;
|
||||
|
||||
const pendingOlderLoad = pendingOlderLoadRef.current;
|
||||
if (pendingOlderLoad) {
|
||||
const anchor = pendingOlderLoad.anchorId
|
||||
? container.querySelector<HTMLElement>(`[data-message-anchor="${pendingOlderLoad.anchorId}"]`)
|
||||
: null;
|
||||
const anchorTop = anchor?.getBoundingClientRect().top ?? container.getBoundingClientRect().top;
|
||||
const addedHeight = Math.max(0, anchorTop - pendingOlderLoad.anchorTop);
|
||||
container.scrollTop += addedHeight;
|
||||
|
||||
if (pendingOlderLoad.remainingHeight - addedHeight > 0 && historyHasMore && !historyLoadingMore) {
|
||||
const nextAnchor =
|
||||
anchor ??
|
||||
container.querySelector<HTMLElement>('[data-message-anchor]');
|
||||
pendingOlderLoadRef.current = {
|
||||
anchorId: nextAnchor?.dataset.messageAnchor ?? null,
|
||||
anchorTop: nextAnchor?.getBoundingClientRect().top ?? container.getBoundingClientRect().top,
|
||||
remainingHeight: pendingOlderLoad.remainingHeight - addedHeight,
|
||||
};
|
||||
requestOlderMessages();
|
||||
return;
|
||||
}
|
||||
|
||||
pendingOlderLoadRef.current = null;
|
||||
nearBottomRef.current =
|
||||
container.scrollHeight - container.scrollTop - container.clientHeight < 120;
|
||||
return;
|
||||
}
|
||||
|
||||
if (session?.id && initializedSessionRef.current !== session.id) {
|
||||
container.scrollTop = container.scrollHeight;
|
||||
nearBottomRef.current = true;
|
||||
if (
|
||||
container.scrollHeight < container.clientHeight * 2 &&
|
||||
historyHasMore &&
|
||||
!historyLoadingMore &&
|
||||
onLoadOlderMessages
|
||||
) {
|
||||
requestOlderMessages();
|
||||
return;
|
||||
}
|
||||
initializedSessionRef.current = session.id;
|
||||
prevMessageCountRef.current = messages.length;
|
||||
return;
|
||||
}
|
||||
|
||||
const messageCountIncreased = messages.length > prevMessageCountRef.current;
|
||||
if ((nearBottomRef.current || chatState === 'streaming') && messageCountIncreased) {
|
||||
container.scrollTop = container.scrollHeight;
|
||||
nearBottomRef.current = true;
|
||||
}
|
||||
prevMessageCountRef.current = messages.length;
|
||||
}, [
|
||||
chatState,
|
||||
historyHasMore,
|
||||
historyLoadingMore,
|
||||
messages.length,
|
||||
onLoadOlderMessages,
|
||||
requestOlderMessages,
|
||||
session?.id,
|
||||
]);
|
||||
|
||||
const busy =
|
||||
chatState === 'streaming' ||
|
||||
@@ -193,16 +336,71 @@ export function ChatPanel({
|
||||
if (item.previewUrl.startsWith('blob:')) URL.revokeObjectURL(item.previewUrl);
|
||||
};
|
||||
|
||||
const pendingImageBytes = pendingImages.reduce((sum, item) => sum + item.sizeBytes, 0);
|
||||
|
||||
const planImageUploads = useCallback((files: File[]) => {
|
||||
const remainingCount = CHAT_IMAGE_UPLOAD_MAX_COUNT - pendingImages.length;
|
||||
if (remainingCount <= 0) {
|
||||
return {
|
||||
accepted: [],
|
||||
message: `最多支持一次性附加 ${CHAT_IMAGE_UPLOAD_MAX_COUNT} 张图片`,
|
||||
};
|
||||
}
|
||||
|
||||
const accepted: File[] = [];
|
||||
let nextTotalBytes = pendingImageBytes;
|
||||
let oversizedRejected = false;
|
||||
let totalRejected = false;
|
||||
let countRejected = false;
|
||||
|
||||
for (const file of files) {
|
||||
if (accepted.length >= remainingCount) {
|
||||
countRejected = true;
|
||||
continue;
|
||||
}
|
||||
if (file.size > CHAT_IMAGE_UPLOAD_MAX_INPUT_BYTES) {
|
||||
oversizedRejected = true;
|
||||
continue;
|
||||
}
|
||||
if (nextTotalBytes + file.size > CHAT_IMAGE_UPLOAD_MAX_TOTAL_BYTES) {
|
||||
totalRejected = true;
|
||||
continue;
|
||||
}
|
||||
accepted.push(file);
|
||||
nextTotalBytes += file.size;
|
||||
}
|
||||
|
||||
if (accepted.length === 0) {
|
||||
if (oversizedRejected) {
|
||||
return {
|
||||
accepted,
|
||||
message: `单张图片不能超过 ${formatUploadMegabytes(CHAT_IMAGE_UPLOAD_MAX_INPUT_BYTES)}`,
|
||||
};
|
||||
}
|
||||
return {
|
||||
accepted,
|
||||
message: `图片总大小不能超过 ${formatUploadMegabytes(CHAT_IMAGE_UPLOAD_MAX_TOTAL_BYTES)}`,
|
||||
};
|
||||
}
|
||||
|
||||
let message = null;
|
||||
if (countRejected) {
|
||||
message = `最多支持 ${CHAT_IMAGE_UPLOAD_MAX_COUNT} 张图片,本次仅保留前 ${accepted.length} 张`;
|
||||
} else if (oversizedRejected || totalRejected) {
|
||||
message = `已按单张 ${formatUploadMegabytes(CHAT_IMAGE_UPLOAD_MAX_INPUT_BYTES)}、总计 ${formatUploadMegabytes(CHAT_IMAGE_UPLOAD_MAX_TOTAL_BYTES)} 的限制筛选图片`;
|
||||
}
|
||||
return { accepted, message };
|
||||
}, [pendingImageBytes, pendingImages.length]);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
const trimmed = input.trim();
|
||||
if ((!trimmed && pendingImages.length === 0) || voiceDisabled) return;
|
||||
if (uploadingImage || pendingImages.some((item) => item.uploading)) {
|
||||
setImageError('图片仍在上传中,请稍候再发送');
|
||||
if (pendingImages.length > 0 && !onUploadImage) {
|
||||
setImageError('当前会话暂不支持图片发送');
|
||||
return;
|
||||
}
|
||||
const imagesToSend = pendingImages.map((item) => item.url);
|
||||
const previewImagesToSend = pendingImages.map((item) => item.previewUrl);
|
||||
const sentImages = [...pendingImages];
|
||||
let uploadedImages = sentImages;
|
||||
|
||||
suppressVoiceUpdateRef.current = true;
|
||||
if (voiceRecording) {
|
||||
@@ -212,17 +410,69 @@ export function ChatPanel({
|
||||
voiceBaseRef.current = '';
|
||||
setVoiceNotice(null);
|
||||
setImageError(null);
|
||||
setUploadingImage(true);
|
||||
try {
|
||||
const uploadedUrls = await Promise.all(
|
||||
sentImages.map(async (item) => {
|
||||
if (item.uploadedUrl) return item.uploadedUrl;
|
||||
setPendingImages((current) =>
|
||||
current.map((candidate) =>
|
||||
candidate.id === item.id
|
||||
? { ...candidate, uploadStatus: 'uploading', uploadProgress: 0 }
|
||||
: candidate,
|
||||
),
|
||||
);
|
||||
const uploadedUrl = await onUploadImage!(item.file, (progress) => {
|
||||
setPendingImages((current) =>
|
||||
current.map((candidate) =>
|
||||
candidate.id === item.id
|
||||
? {
|
||||
...candidate,
|
||||
uploadStatus: 'uploading',
|
||||
uploadProgress: Math.round(progress * 100),
|
||||
}
|
||||
: candidate,
|
||||
),
|
||||
);
|
||||
});
|
||||
setPendingImages((current) =>
|
||||
current.map((candidate) =>
|
||||
candidate.id === item.id
|
||||
? {
|
||||
...candidate,
|
||||
uploadedUrl,
|
||||
uploadStatus: 'uploaded',
|
||||
uploadProgress: 100,
|
||||
}
|
||||
: candidate,
|
||||
),
|
||||
);
|
||||
return uploadedUrl;
|
||||
}),
|
||||
);
|
||||
uploadedImages = sentImages.map((item, index) => ({
|
||||
...item,
|
||||
uploadedUrl: uploadedUrls[index] ?? item.uploadedUrl,
|
||||
uploadProgress: uploadedUrls[index] ? 100 : item.uploadProgress,
|
||||
uploadStatus: uploadedUrls[index] ? 'uploaded' : item.uploadStatus,
|
||||
}));
|
||||
const imagesToSend = uploadedUrls.filter(Boolean);
|
||||
const previewImagesToSend = imagesToSend;
|
||||
await onSubmit(trimmed, imagesToSend, previewImagesToSend);
|
||||
sentImages.forEach(revokePendingImage);
|
||||
uploadedImages.forEach(revokePendingImage);
|
||||
setPendingImages([]);
|
||||
} catch (err) {
|
||||
suppressVoiceUpdateRef.current = false;
|
||||
setInput(trimmed);
|
||||
setPendingImages(sentImages);
|
||||
setPendingImages(uploadedImages.map((item) => ({
|
||||
...item,
|
||||
uploadStatus: item.uploadedUrl ? 'uploaded' : 'error',
|
||||
})));
|
||||
setImageError(err instanceof Error ? err.message : '发送失败,请重试');
|
||||
setUploadingImage(false);
|
||||
return;
|
||||
}
|
||||
setUploadingImage(false);
|
||||
suppressVoiceUpdateRef.current = false;
|
||||
};
|
||||
|
||||
@@ -238,56 +488,23 @@ export function ChatPanel({
|
||||
return;
|
||||
}
|
||||
|
||||
const remaining = MAX_PENDING_IMAGES - pendingImages.length;
|
||||
if (remaining <= 0) {
|
||||
setImageError(`最多支持一次性附加 ${MAX_PENDING_IMAGES} 张图片`);
|
||||
const { accepted: toQueue, message } = planImageUploads(imageFiles);
|
||||
if (toQueue.length === 0) {
|
||||
setImageError(message);
|
||||
return;
|
||||
}
|
||||
setImageError(message);
|
||||
|
||||
const toUpload = imageFiles.slice(0, remaining);
|
||||
if (imageFiles.length > remaining) {
|
||||
setImageError(`最多支持 ${MAX_PENDING_IMAGES} 张图片,本次仅上传前 ${remaining} 张`);
|
||||
} else {
|
||||
setImageError(null);
|
||||
}
|
||||
|
||||
const placeholders = toUpload.map((file, index) => ({
|
||||
const placeholders = toQueue.map((file, index) => ({
|
||||
id: `${Date.now()}-${index}-${file.name}`,
|
||||
file,
|
||||
previewUrl: URL.createObjectURL(file),
|
||||
url: '',
|
||||
uploading: true,
|
||||
sizeBytes: file.size,
|
||||
uploadedUrl: null,
|
||||
uploadProgress: null,
|
||||
uploadStatus: 'queued' as const,
|
||||
}));
|
||||
setPendingImages((prev) => [...prev, ...placeholders]);
|
||||
|
||||
setUploadingImage(true);
|
||||
try {
|
||||
const uploaded = await Promise.all(toUpload.map((file) => onUploadImage(file)));
|
||||
const placeholderIds = placeholders.map((item) => item.id);
|
||||
setPendingImages((prev) => {
|
||||
const next = prev.map((item) => {
|
||||
const index = placeholderIds.indexOf(item.id);
|
||||
if (index < 0 || !uploaded[index]) return item;
|
||||
return { ...item, url: uploaded[index], uploading: false };
|
||||
});
|
||||
const seen = new Set<string>();
|
||||
return next.filter((item) => {
|
||||
if (seen.has(item.url)) return false;
|
||||
seen.add(item.url);
|
||||
return true;
|
||||
});
|
||||
});
|
||||
} catch (err) {
|
||||
setPendingImages((prev) => {
|
||||
const removeIds = new Set(placeholders.map((item) => item.id));
|
||||
prev.forEach((item) => {
|
||||
if (removeIds.has(item.id)) revokePendingImage(item);
|
||||
});
|
||||
return prev.filter((item) => !removeIds.has(item.id));
|
||||
});
|
||||
setImageError(err instanceof Error ? err.message : '图片上传失败');
|
||||
} finally {
|
||||
setUploadingImage(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePaste = async (event: ClipboardEvent<HTMLTextAreaElement>) => {
|
||||
@@ -303,56 +520,21 @@ export function ChatPanel({
|
||||
.filter((file): file is File => Boolean(file));
|
||||
if (files.length === 0) return;
|
||||
|
||||
const remaining = MAX_PENDING_IMAGES - pendingImages.length;
|
||||
if (remaining <= 0) {
|
||||
setImageError(`最多支持一次性附加 ${MAX_PENDING_IMAGES} 张图片`);
|
||||
const { accepted: toQueue, message } = planImageUploads(files);
|
||||
if (toQueue.length === 0) {
|
||||
setImageError(message);
|
||||
return;
|
||||
}
|
||||
setImageError(message);
|
||||
|
||||
const toUpload = files.slice(0, remaining);
|
||||
if (files.length > remaining) {
|
||||
setImageError(`最多支持 ${MAX_PENDING_IMAGES} 张图片,本次仅粘贴前 ${remaining} 张`);
|
||||
} else {
|
||||
setImageError(null);
|
||||
}
|
||||
|
||||
const placeholders = toUpload.map((file, index) => ({
|
||||
const placeholders = toQueue.map((file, index) => ({
|
||||
id: `${Date.now()}-${index}-${file.name || 'pasted-image'}`,
|
||||
file,
|
||||
previewUrl: URL.createObjectURL(file),
|
||||
url: '',
|
||||
uploading: true,
|
||||
sizeBytes: file.size,
|
||||
uploadedUrl: null,
|
||||
}));
|
||||
setPendingImages((prev) => [...prev, ...placeholders]);
|
||||
|
||||
setUploadingImage(true);
|
||||
try {
|
||||
const uploaded = await Promise.all(toUpload.map((file) => onUploadImage(file)));
|
||||
const placeholderIds = placeholders.map((item) => item.id);
|
||||
setPendingImages((prev) => {
|
||||
const next = prev.map((item) => {
|
||||
const index = placeholderIds.indexOf(item.id);
|
||||
if (index < 0 || !uploaded[index]) return item;
|
||||
return { ...item, url: uploaded[index], uploading: false };
|
||||
});
|
||||
const seen = new Set<string>();
|
||||
return next.filter((item) => {
|
||||
if (seen.has(item.url)) return false;
|
||||
seen.add(item.url);
|
||||
return true;
|
||||
});
|
||||
});
|
||||
} catch (err) {
|
||||
setPendingImages((prev) => {
|
||||
const removeIds = new Set(placeholders.map((item) => item.id));
|
||||
prev.forEach((item) => {
|
||||
if (removeIds.has(item.id)) revokePendingImage(item);
|
||||
});
|
||||
return prev.filter((item) => !removeIds.has(item.id));
|
||||
});
|
||||
setImageError(err instanceof Error ? err.message : '图片上传失败');
|
||||
} finally {
|
||||
setUploadingImage(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveImage = (idToRemove: string) => {
|
||||
@@ -365,7 +547,20 @@ export function ChatPanel({
|
||||
|
||||
return (
|
||||
<>
|
||||
<main className={compact ? 'space-chat-panel-body' : `main${showHomeWelcome ? ' main-home' : ''}`}>
|
||||
<main
|
||||
ref={mainRef}
|
||||
className={compact ? 'space-chat-panel-body' : `main${showHomeWelcome ? ' main-home' : ''}`}
|
||||
onScroll={handleMainScroll}
|
||||
>
|
||||
{!showHomeWelcome && (historyLoadingMore || historyHasMore || showHistoryFullyLoadedNotice) && (
|
||||
<div className="chat-history-loader" role="status">
|
||||
{historyLoadingMore
|
||||
? `正在加载更早消息… 已加载 ${messages.length}${historyTotal > 0 ? ` / ${historyTotal}` : ''}`
|
||||
: historyHasMore
|
||||
? `向上滚动加载更早消息${historyTotal > 0 ? ` · 已加载 ${messages.length} / ${historyTotal}` : ''}`
|
||||
: `历史消息已全部加载${historyTotal > 0 ? ` · ${messages.length} / ${historyTotal}` : ''}`}
|
||||
</div>
|
||||
)}
|
||||
<MessageList
|
||||
messages={messages}
|
||||
streaming={chatState === 'streaming'}
|
||||
@@ -381,7 +576,6 @@ export function ChatPanel({
|
||||
<AvatarPicker variant="compact" />
|
||||
</div>
|
||||
)}
|
||||
<div ref={bottomRef} />
|
||||
</main>
|
||||
|
||||
{pendingTool && (
|
||||
@@ -426,14 +620,29 @@ export function ChatPanel({
|
||||
{pendingImages.length > 0 && (
|
||||
<div className="chat-image-attachment-grid">
|
||||
{pendingImages.map((item, index) => (
|
||||
<div key={item.id} className="chat-image-attachment-item">
|
||||
<a href={item.url || item.previewUrl} target="_blank" rel="noreferrer">
|
||||
<div
|
||||
key={item.id}
|
||||
className={`chat-image-attachment-item chat-image-attachment-item-${item.uploadStatus}`}
|
||||
>
|
||||
<a href={item.uploadedUrl || item.previewUrl} target="_blank" rel="noreferrer">
|
||||
<img src={item.previewUrl} alt={`上传图片 ${index + 1}`} className="chat-image-attachment-thumb" />
|
||||
</a>
|
||||
{item.uploadStatus === 'uploading' && (
|
||||
<div className="chat-image-upload-progress" aria-label={`上传进度 ${item.uploadProgress ?? 0}%`}>
|
||||
<div className="chat-image-upload-progress-fill" style={{ width: `${item.uploadProgress ?? 0}%` }} />
|
||||
<span>{item.uploadProgress ?? 0}%</span>
|
||||
</div>
|
||||
)}
|
||||
{item.uploadStatus === 'uploaded' && (
|
||||
<div className="chat-image-upload-complete" aria-label="上传完成">
|
||||
已完成
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="chat-image-attachment-remove"
|
||||
aria-label="移除图片"
|
||||
disabled={item.uploadStatus === 'uploading'}
|
||||
onClick={() => handleRemoveImage(item.id)}
|
||||
>
|
||||
×
|
||||
|
||||
+28
-39
@@ -4,7 +4,6 @@ import { INSUFFICIENT_BALANCE_NOTICE } from '../hooks/useTKMindChat';
|
||||
import type { CapabilityMap, PortalUser } from '../types';
|
||||
import { useNetworkStatus } from '../hooks/useNetworkStatus';
|
||||
import { getSessionDisplayName } from '../utils/sessions';
|
||||
import { openAvatarPicker } from '../utils/userAvatar';
|
||||
import { BalanceRing } from './BalanceRing';
|
||||
import { HistorySidebar } from './HistorySidebar';
|
||||
import { TKMindAvatar } from './TKMindAvatar';
|
||||
@@ -23,6 +22,7 @@ export function ChatView({
|
||||
onOpenSpace,
|
||||
onOpenPage,
|
||||
onOpenAdmin,
|
||||
onOpenFeedback,
|
||||
}: {
|
||||
user?: PortalUser | null;
|
||||
capabilities?: CapabilityMap;
|
||||
@@ -32,25 +32,32 @@ export function ChatView({
|
||||
onOpenSpace?: (target?: { categoryCode?: MindSpaceSaveCategory; pageId?: string }) => void;
|
||||
onOpenPage?: (pageId: string) => void;
|
||||
onOpenAdmin?: () => void;
|
||||
onOpenFeedback?: () => void;
|
||||
}) {
|
||||
const {
|
||||
session,
|
||||
sessions,
|
||||
sessionsLoading,
|
||||
sessionsLoadingMore,
|
||||
sessionsHasMore,
|
||||
sessionSearchQuery,
|
||||
messages,
|
||||
messageHistoryLoadingMore,
|
||||
messageHistoryHasMore,
|
||||
messageHistoryTotal,
|
||||
chatState,
|
||||
error,
|
||||
notice,
|
||||
pendingTool,
|
||||
memoryLoading,
|
||||
submit,
|
||||
stop,
|
||||
approveTool,
|
||||
newSession,
|
||||
rememberCurrentContext,
|
||||
refreshProjectMemory,
|
||||
switchSession,
|
||||
deleteSession,
|
||||
loadMoreSessions,
|
||||
loadOlderMessages,
|
||||
setSessionSearchQuery,
|
||||
retryConnect,
|
||||
dismissNotice,
|
||||
onSidebarOpen,
|
||||
@@ -71,11 +78,6 @@ export function ChatView({
|
||||
}
|
||||
}, [sidebarOpen, onSidebarOpen]);
|
||||
|
||||
const busy =
|
||||
chatState === 'streaming' ||
|
||||
chatState === 'loading' ||
|
||||
chatState === 'connecting' ||
|
||||
chatState === 'waiting';
|
||||
const showHomeWelcome = messages.length === 0;
|
||||
|
||||
const handleSelectSession = (sessionId: string) => {
|
||||
@@ -100,18 +102,9 @@ export function ChatView({
|
||||
label: '新会话',
|
||||
onClick: () => void handleNewSession(),
|
||||
},
|
||||
{
|
||||
id: 'remember',
|
||||
label: '记住本轮',
|
||||
onClick: () => void rememberCurrentContext(),
|
||||
disabled: busy || memoryLoading || messages.length === 0,
|
||||
},
|
||||
{
|
||||
id: 'refresh-memory',
|
||||
label: memoryLoading ? '同步中…' : '刷新记忆',
|
||||
onClick: () => void refreshProjectMemory(),
|
||||
disabled: busy || memoryLoading,
|
||||
},
|
||||
...(onOpenFeedback
|
||||
? [{ id: 'feedback', label: '反馈与建议', onClick: () => onOpenFeedback() }]
|
||||
: []),
|
||||
...(onOpenAdmin
|
||||
? [{ id: 'admin', label: '管理', onClick: () => onOpenAdmin() }]
|
||||
: []),
|
||||
@@ -127,10 +120,15 @@ export function ChatView({
|
||||
sessions={sessions}
|
||||
activeSessionId={session?.id}
|
||||
loading={sessionsLoading}
|
||||
loadingMore={sessionsLoadingMore}
|
||||
hasMore={sessionsHasMore}
|
||||
searchQuery={sessionSearchQuery}
|
||||
onClose={() => setSidebarOpen(false)}
|
||||
onSelect={handleSelectSession}
|
||||
onNew={handleNewSession}
|
||||
onDelete={(sessionId) => void deleteSession(sessionId)}
|
||||
onLoadMore={() => void loadMoreSessions()}
|
||||
onSearchChange={setSessionSearchQuery}
|
||||
/>
|
||||
|
||||
<div className={`app${showHomeWelcome ? ' app-home' : ''}`}>
|
||||
@@ -172,24 +170,11 @@ export function ChatView({
|
||||
我的空间
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="ghost-btn"
|
||||
disabled={busy || memoryLoading || messages.length === 0}
|
||||
onClick={() => void rememberCurrentContext()}
|
||||
title="把最近几条对话保存到 harness 项目知识库"
|
||||
>
|
||||
记住本轮
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="ghost-btn"
|
||||
disabled={busy || memoryLoading}
|
||||
onClick={() => void refreshProjectMemory()}
|
||||
title="从 harness 和项目文件重新加载长期记忆"
|
||||
>
|
||||
{memoryLoading ? '同步中…' : '刷新记忆'}
|
||||
</button>
|
||||
{onOpenFeedback && (
|
||||
<button type="button" className="ghost-btn" onClick={() => onOpenFeedback()}>
|
||||
反馈
|
||||
</button>
|
||||
)}
|
||||
<button type="button" className="ghost-btn" onClick={() => void handleNewSession()}>
|
||||
新会话
|
||||
</button>
|
||||
@@ -296,6 +281,9 @@ export function ChatView({
|
||||
variant="full"
|
||||
user={user}
|
||||
messages={messages}
|
||||
historyLoadingMore={messageHistoryLoadingMore}
|
||||
historyHasMore={messageHistoryHasMore}
|
||||
historyTotal={messageHistoryTotal}
|
||||
chatState={chatState}
|
||||
pendingTool={pendingTool}
|
||||
session={session}
|
||||
@@ -305,6 +293,7 @@ export function ChatView({
|
||||
void submit(text, undefined, imageUrls, previewImageUrls)
|
||||
}
|
||||
onUploadImage={uploadChatImage}
|
||||
onLoadOlderMessages={loadOlderMessages}
|
||||
onStop={stop}
|
||||
onApproveTool={approveTool}
|
||||
onPageSaved={(result) => {
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { listFeedbackBoard } from '../api/client';
|
||||
import type { FeedbackSubmission, PortalUser } from '../types';
|
||||
import {
|
||||
FEEDBACK_STATUS_LABELS,
|
||||
FEEDBACK_TYPE_LABELS,
|
||||
formatFeedbackTime,
|
||||
} from '../utils/feedbackLabels';
|
||||
import { FeedbackPageHeader } from './FeedbackPageHeader';
|
||||
|
||||
const PAGE_SIZE = 10;
|
||||
|
||||
function FeedbackBoardItem({
|
||||
item,
|
||||
currentUserId,
|
||||
onOpen,
|
||||
}: {
|
||||
item: FeedbackSubmission;
|
||||
currentUserId?: string;
|
||||
onOpen: (id: string) => void;
|
||||
}) {
|
||||
const isMine = item.userId === currentUserId;
|
||||
return (
|
||||
<li className="feedback-board-item">
|
||||
<button type="button" className="feedback-board-item-trigger" onClick={() => onOpen(item.id)}>
|
||||
<div className="feedback-board-item-main">
|
||||
<div className="feedback-list-item-badges">
|
||||
<span className={`feedback-badge feedback-badge-type feedback-badge-type-${item.type}`}>
|
||||
{FEEDBACK_TYPE_LABELS[item.type]}
|
||||
</span>
|
||||
<span className={`feedback-badge feedback-badge-status feedback-badge-status-${item.status}`}>
|
||||
{FEEDBACK_STATUS_LABELS[item.status]}
|
||||
</span>
|
||||
{isMine ? <span className="feedback-badge feedback-badge-mine">我的</span> : null}
|
||||
</div>
|
||||
<strong className="feedback-board-item-title">{item.title}</strong>
|
||||
{item.description ? <p className="feedback-board-item-preview">{item.description}</p> : null}
|
||||
</div>
|
||||
<div className="feedback-board-item-meta">
|
||||
<span>{item.submitterDisplayName ?? '用户'}</span>
|
||||
<time dateTime={new Date(item.createdAt).toISOString()}>{formatFeedbackTime(item.createdAt)}</time>
|
||||
{(item.imageCount ?? 0) > 0 ? <span>{item.imageCount} 图</span> : null}
|
||||
<span className="feedback-list-item-chevron" aria-hidden="true">
|
||||
▸
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
export function FeedbackBoardView({
|
||||
user,
|
||||
onLogout,
|
||||
}: {
|
||||
user?: PortalUser | null;
|
||||
onLogout?: () => void;
|
||||
}) {
|
||||
const navigate = useNavigate();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const page = Math.max(Number(searchParams.get('page')) || 1, 1);
|
||||
|
||||
const [items, setItems] = useState<FeedbackSubmission[]>([]);
|
||||
const [totalPages, setTotalPages] = useState(0);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const loadPage = useCallback(async (targetPage: number) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const result = await listFeedbackBoard(targetPage, PAGE_SIZE);
|
||||
setItems(result.items);
|
||||
setTotalPages(result.totalPages);
|
||||
setTotal(result.total);
|
||||
if (result.totalPages > 0 && targetPage > result.totalPages) {
|
||||
setSearchParams({ page: String(result.totalPages) }, { replace: true });
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '反馈列表加载失败');
|
||||
setItems([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [setSearchParams]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadPage(page);
|
||||
}, [loadPage, page]);
|
||||
|
||||
const goToPage = (nextPage: number) => {
|
||||
if (nextPage < 1 || (totalPages > 0 && nextPage > totalPages) || nextPage === page) return;
|
||||
setSearchParams({ page: String(nextPage) });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="feedback-submit-page feedback-board-page">
|
||||
<FeedbackPageHeader
|
||||
title="用户反馈"
|
||||
onBack={() => navigate('/feedback')}
|
||||
onLogout={onLogout}
|
||||
/>
|
||||
|
||||
<main className="feedback-submit-main feedback-board-main">
|
||||
<section className="feedback-submit-card feedback-board-card">
|
||||
<div className="feedback-board-toolbar">
|
||||
<div className="feedback-board-toolbar-copy">
|
||||
<p className="feedback-submit-eyebrow">Bug List</p>
|
||||
<h1>全部用户反馈</h1>
|
||||
<p className="feedback-submit-desc feedback-board-desc">
|
||||
共 {total} 条 · 每页 {PAGE_SIZE} 条
|
||||
</p>
|
||||
</div>
|
||||
<div className="feedback-board-toolbar-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="feedback-panel-close"
|
||||
aria-label="提交 Bug 或需求"
|
||||
onClick={() => navigate('/feedback')}
|
||||
>
|
||||
提交 Bug 或需求
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="feedback-panel-close"
|
||||
aria-label="关闭反馈"
|
||||
onClick={() => navigate('/')}
|
||||
>
|
||||
关闭
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!loading && total > 0 ? (
|
||||
<p className="feedback-board-page-indicator">第 {page} / {totalPages || 1} 页</p>
|
||||
) : null}
|
||||
|
||||
<div className="feedback-board-scroll">
|
||||
{loading ? <p className="feedback-list-muted">加载中…</p> : null}
|
||||
{error ? <p className="feedback-submit-error">{error}</p> : null}
|
||||
|
||||
{!loading && !error && items.length === 0 ? (
|
||||
<p className="feedback-list-empty">还没有任何用户反馈。</p>
|
||||
) : null}
|
||||
|
||||
{!loading && items.length > 0 ? (
|
||||
<ul className="feedback-board-list">
|
||||
{items.map((item) => (
|
||||
<FeedbackBoardItem
|
||||
key={item.id}
|
||||
item={item}
|
||||
currentUserId={user?.id}
|
||||
onOpen={(id) => navigate(`/feedback/${id}`)}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{totalPages > 1 ? (
|
||||
<nav className="feedback-pagination" aria-label="反馈分页">
|
||||
<button
|
||||
type="button"
|
||||
className="ghost-btn"
|
||||
disabled={loading || page <= 1}
|
||||
onClick={() => goToPage(page - 1)}
|
||||
>
|
||||
上一页
|
||||
</button>
|
||||
<span className="feedback-pagination-status">
|
||||
第 {page} / {totalPages} 页
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="ghost-btn"
|
||||
disabled={loading || page >= totalPages}
|
||||
onClick={() => goToPage(page + 1)}
|
||||
>
|
||||
下一页
|
||||
</button>
|
||||
</nav>
|
||||
) : null}
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { getFeedbackDetail } from '../api/client';
|
||||
import type { FeedbackSubmission } from '../types';
|
||||
import {
|
||||
FEEDBACK_STATUS_LABELS,
|
||||
FEEDBACK_TYPE_LABELS,
|
||||
feedbackImageDataUrl,
|
||||
formatFeedbackTime,
|
||||
} from '../utils/feedbackLabels';
|
||||
import { FeedbackPageHeader } from './FeedbackPageHeader';
|
||||
|
||||
export function FeedbackDetailView({
|
||||
onLogout,
|
||||
}: {
|
||||
onLogout?: () => void;
|
||||
}) {
|
||||
const navigate = useNavigate();
|
||||
const { feedbackId } = useParams<{ feedbackId: string }>();
|
||||
const [item, setItem] = useState<FeedbackSubmission | null>(null);
|
||||
const [isMine, setIsMine] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!feedbackId) {
|
||||
setError('反馈不存在');
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
void getFeedbackDetail(feedbackId)
|
||||
.then((result) => {
|
||||
if (cancelled) return;
|
||||
setItem(result.item);
|
||||
setIsMine(result.isMine);
|
||||
})
|
||||
.catch((err) => {
|
||||
if (cancelled) return;
|
||||
setError(err instanceof Error ? err.message : '反馈详情加载失败');
|
||||
setItem(null);
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [feedbackId]);
|
||||
|
||||
return (
|
||||
<div className="feedback-submit-page feedback-board-page">
|
||||
<FeedbackPageHeader
|
||||
title="反馈详情"
|
||||
onBack={() => navigate('/feedback/list')}
|
||||
onLogout={onLogout}
|
||||
/>
|
||||
|
||||
<main className="feedback-submit-main feedback-board-main">
|
||||
<section className="feedback-submit-card feedback-board-card feedback-detail-card">
|
||||
<div className="feedback-board-toolbar">
|
||||
<div className="feedback-board-toolbar-copy">
|
||||
{!loading && !error && item ? (
|
||||
<div className="feedback-list-item-badges">
|
||||
<span className={`feedback-badge feedback-badge-type feedback-badge-type-${item.type}`}>
|
||||
{FEEDBACK_TYPE_LABELS[item.type]}
|
||||
</span>
|
||||
<span className={`feedback-badge feedback-badge-status feedback-badge-status-${item.status}`}>
|
||||
{FEEDBACK_STATUS_LABELS[item.status]}
|
||||
</span>
|
||||
{isMine ? <span className="feedback-badge feedback-badge-mine">我的</span> : null}
|
||||
</div>
|
||||
) : loading ? (
|
||||
<p className="feedback-list-muted">加载中…</p>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="feedback-board-toolbar-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="feedback-panel-close"
|
||||
aria-label="返回用户反馈"
|
||||
onClick={() => navigate('/feedback/list')}
|
||||
>
|
||||
返回
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="feedback-panel-close"
|
||||
aria-label="关闭反馈"
|
||||
onClick={() => navigate('/')}
|
||||
>
|
||||
关闭
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="feedback-board-scroll">
|
||||
{error ? <p className="feedback-submit-error">{error}</p> : null}
|
||||
|
||||
{!loading && !error && item ? (
|
||||
<>
|
||||
<div className="feedback-detail-header">
|
||||
<h1>{item.title}</h1>
|
||||
<p className="feedback-detail-meta">
|
||||
提交者:{item.submitterDisplayName ?? '用户'}
|
||||
<span aria-hidden="true"> · </span>
|
||||
{formatFeedbackTime(item.createdAt)}
|
||||
<span aria-hidden="true"> · </span>
|
||||
编号 <code>{item.id.slice(0, 8)}</code>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="feedback-detail-section">
|
||||
<h2>详细描述</h2>
|
||||
<p className="feedback-detail-description">{item.description || '(无描述)'}</p>
|
||||
</div>
|
||||
|
||||
{isMine && item.contact ? (
|
||||
<div className="feedback-detail-section">
|
||||
<h2>联系方式</h2>
|
||||
<p className="feedback-detail-text">{item.contact}</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{item.context?.pagePath ? (
|
||||
<div className="feedback-detail-section">
|
||||
<h2>来源页面</h2>
|
||||
<p className="feedback-detail-text">
|
||||
<code>{item.context.pagePath}</code>
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{(item.images?.length ?? 0) > 0 ? (
|
||||
<div className="feedback-detail-section">
|
||||
<h2>截图</h2>
|
||||
<div className="feedback-detail-images">
|
||||
{item.images?.map((image, index) => (
|
||||
<a
|
||||
key={`${item.id}-${index}`}
|
||||
href={feedbackImageDataUrl(image)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="feedback-detail-image-link"
|
||||
>
|
||||
<img
|
||||
src={feedbackImageDataUrl(image)}
|
||||
alt={`${item.title} 截图 ${index + 1}`}
|
||||
className="feedback-detail-image"
|
||||
/>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { TKMindAvatar } from './TKMindAvatar';
|
||||
|
||||
export function FeedbackPageHeader({
|
||||
title,
|
||||
onBack,
|
||||
onLogout,
|
||||
}: {
|
||||
title: string;
|
||||
onBack: () => void;
|
||||
onLogout?: () => void;
|
||||
}) {
|
||||
return (
|
||||
<header className="feedback-submit-header">
|
||||
<button type="button" className="ghost-btn" onClick={onBack}>
|
||||
返回
|
||||
</button>
|
||||
<div className="feedback-submit-header-brand">
|
||||
<TKMindAvatar size="sm" />
|
||||
<span>{title}</span>
|
||||
</div>
|
||||
{onLogout ? (
|
||||
<button type="button" className="ghost-btn logout-btn" onClick={onLogout}>
|
||||
登出
|
||||
</button>
|
||||
) : (
|
||||
<span aria-hidden="true" />
|
||||
)}
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,447 @@
|
||||
import { useRef, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { submitFeedback } from '../api/client';
|
||||
import type { FeedbackImageInput, FeedbackSubmissionType, PortalUser } from '../types';
|
||||
import {
|
||||
CHAT_IMAGE_UPLOAD_MAX_INPUT_BYTES,
|
||||
CHAT_IMAGE_UPLOAD_MAX_OUTPUT_BYTES,
|
||||
CHAT_IMAGE_MAX_SIDE,
|
||||
compressImageForUpload,
|
||||
} from '../utils/imageUpload';
|
||||
import { FeedbackPageHeader } from './FeedbackPageHeader';
|
||||
import { VoiceInputButton } from './VoiceInputButton';
|
||||
|
||||
const TYPE_OPTIONS: Array<{ value: FeedbackSubmissionType; label: string; hint: string }> = [
|
||||
{ value: 'bug', label: 'Bug 报告', hint: '功能异常、报错、无法完成操作' },
|
||||
{ value: 'feature', label: '功能建议', hint: '新能力、体验优化、流程改进' },
|
||||
{ value: 'other', label: '其他反馈', hint: '账号、计费、文档等问题' },
|
||||
];
|
||||
|
||||
const MAX_IMAGES = 5;
|
||||
|
||||
type PendingImage = {
|
||||
id: string;
|
||||
previewUrl: string;
|
||||
file: File;
|
||||
};
|
||||
|
||||
function fileToBase64(file: File): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
const result = typeof reader.result === 'string' ? reader.result : '';
|
||||
const comma = result.indexOf(',');
|
||||
resolve(comma >= 0 ? result.slice(comma + 1) : result);
|
||||
};
|
||||
reader.onerror = () => reject(new Error('图片读取失败'));
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
}
|
||||
|
||||
function buildFeedbackContext(sessionId?: string | null) {
|
||||
return {
|
||||
pageUrl: window.location.href,
|
||||
pagePath: `${window.location.pathname}${window.location.search}`,
|
||||
userAgent: navigator.userAgent,
|
||||
viewport: `${window.innerWidth}x${window.innerHeight}`,
|
||||
appVersion: import.meta.env.VITE_APP_VERSION ?? undefined,
|
||||
sessionId: sessionId ?? undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export function FeedbackSubmitView({
|
||||
user,
|
||||
sessionId,
|
||||
onLogout,
|
||||
}: {
|
||||
user?: PortalUser | null;
|
||||
sessionId?: string | null;
|
||||
onLogout?: () => void;
|
||||
}) {
|
||||
const navigate = useNavigate();
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const descriptionRef = useRef<HTMLTextAreaElement>(null);
|
||||
const descriptionTextRef = useRef('');
|
||||
const voiceBaseRef = useRef('');
|
||||
const suppressVoiceUpdateRef = useRef(false);
|
||||
|
||||
const [type, setType] = useState<FeedbackSubmissionType>('bug');
|
||||
const [title, setTitle] = useState('');
|
||||
const [description, setDescription] = useState('');
|
||||
const [contact, setContact] = useState(user?.email ?? '');
|
||||
const [images, setImages] = useState<PendingImage[]>([]);
|
||||
const [imageError, setImageError] = useState<string | null>(null);
|
||||
const [voiceNotice, setVoiceNotice] = useState<string | null>(null);
|
||||
const [voiceRecording, setVoiceRecording] = useState(false);
|
||||
const [voiceStopSignal, setVoiceStopSignal] = useState(0);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [submittedId, setSubmittedId] = useState<string | null>(null);
|
||||
const [uploadingImages, setUploadingImages] = useState(false);
|
||||
|
||||
descriptionTextRef.current = description;
|
||||
|
||||
const selectedType = TYPE_OPTIONS.find((item) => item.value === type) ?? TYPE_OPTIONS[0];
|
||||
const voiceDisabled = submitting || uploadingImages;
|
||||
|
||||
const mergeVoiceText = (spoken: string) => {
|
||||
const base = voiceBaseRef.current.trimEnd();
|
||||
const chunk = spoken.trim();
|
||||
if (!chunk) return base;
|
||||
return base ? `${base} ${chunk}` : chunk;
|
||||
};
|
||||
|
||||
const handleVoiceStart = () => {
|
||||
suppressVoiceUpdateRef.current = false;
|
||||
voiceBaseRef.current = descriptionTextRef.current;
|
||||
setVoiceNotice(null);
|
||||
};
|
||||
|
||||
const handleVoiceLiveTranscript = (text: string) => {
|
||||
if (suppressVoiceUpdateRef.current) return;
|
||||
setDescription(mergeVoiceText(text));
|
||||
};
|
||||
|
||||
const handleVoiceTranscript = (text: string) => {
|
||||
if (suppressVoiceUpdateRef.current) return;
|
||||
setDescription(mergeVoiceText(text));
|
||||
setVoiceNotice('已识别,可编辑后提交');
|
||||
};
|
||||
|
||||
const handleVoiceComplete = () => {
|
||||
setVoiceNotice('已识别,可编辑后提交');
|
||||
};
|
||||
|
||||
const stopVoiceInput = () => {
|
||||
suppressVoiceUpdateRef.current = true;
|
||||
if (voiceRecording) {
|
||||
setVoiceStopSignal((value) => value + 1);
|
||||
}
|
||||
setVoiceRecording(false);
|
||||
};
|
||||
|
||||
const handlePickImages = async (files: FileList | null) => {
|
||||
if (!files?.length) return;
|
||||
setImageError(null);
|
||||
|
||||
const remaining = MAX_IMAGES - images.length;
|
||||
if (remaining <= 0) {
|
||||
setImageError(`最多上传 ${MAX_IMAGES} 张图片`);
|
||||
return;
|
||||
}
|
||||
|
||||
const selected = Array.from(files).slice(0, remaining);
|
||||
setUploadingImages(true);
|
||||
try {
|
||||
const nextItems: PendingImage[] = [];
|
||||
for (const file of selected) {
|
||||
if (!file.type.startsWith('image/')) {
|
||||
throw new Error('只支持图片文件');
|
||||
}
|
||||
const compressed = await compressImageForUpload(file, {
|
||||
maxInputBytes: CHAT_IMAGE_UPLOAD_MAX_INPUT_BYTES,
|
||||
maxOutputBytes: CHAT_IMAGE_UPLOAD_MAX_OUTPUT_BYTES,
|
||||
maxDimension: CHAT_IMAGE_MAX_SIDE,
|
||||
});
|
||||
nextItems.push({
|
||||
id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
||||
previewUrl: URL.createObjectURL(compressed),
|
||||
file: compressed,
|
||||
});
|
||||
}
|
||||
setImages((prev) => [...prev, ...nextItems]);
|
||||
} catch (err) {
|
||||
setImageError(err instanceof Error ? err.message : '图片处理失败');
|
||||
} finally {
|
||||
setUploadingImages(false);
|
||||
if (fileInputRef.current) fileInputRef.current.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
const removeImage = (id: string) => {
|
||||
setImages((prev) => {
|
||||
const target = prev.find((item) => item.id === id);
|
||||
if (target) URL.revokeObjectURL(target.previewUrl);
|
||||
return prev.filter((item) => item.id !== id);
|
||||
});
|
||||
};
|
||||
|
||||
const resetForm = () => {
|
||||
stopVoiceInput();
|
||||
setSubmittedId(null);
|
||||
setTitle('');
|
||||
setDescription('');
|
||||
voiceBaseRef.current = '';
|
||||
suppressVoiceUpdateRef.current = false;
|
||||
setVoiceNotice(null);
|
||||
setImages([]);
|
||||
setType('bug');
|
||||
setError(null);
|
||||
};
|
||||
|
||||
const handleSubmit = async (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
setError(null);
|
||||
stopVoiceInput();
|
||||
|
||||
const trimmedTitle = title.trim();
|
||||
const trimmedDescription = description.trim();
|
||||
if (!trimmedTitle) {
|
||||
suppressVoiceUpdateRef.current = false;
|
||||
setError('请填写标题');
|
||||
return;
|
||||
}
|
||||
if (!trimmedDescription) {
|
||||
suppressVoiceUpdateRef.current = false;
|
||||
setError('请填写详细描述,或使用语音口述');
|
||||
descriptionRef.current?.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const imagePayload: FeedbackImageInput[] = [];
|
||||
for (const item of images) {
|
||||
imagePayload.push({
|
||||
filename: item.file.name,
|
||||
mimeType: item.file.type || 'image/jpeg',
|
||||
dataBase64: await fileToBase64(item.file),
|
||||
});
|
||||
}
|
||||
|
||||
const result = await submitFeedback({
|
||||
type,
|
||||
title: trimmedTitle,
|
||||
description: trimmedDescription,
|
||||
contact: contact.trim() || undefined,
|
||||
images: imagePayload,
|
||||
context: buildFeedbackContext(sessionId),
|
||||
});
|
||||
setSubmittedId(result.id);
|
||||
suppressVoiceUpdateRef.current = false;
|
||||
setVoiceNotice(null);
|
||||
} catch (err) {
|
||||
suppressVoiceUpdateRef.current = false;
|
||||
setError(err instanceof Error ? err.message : '提交失败,请稍后重试');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="feedback-submit-page feedback-board-page">
|
||||
<FeedbackPageHeader
|
||||
title={submittedId ? '反馈已收到' : '反馈与建议'}
|
||||
onBack={() => navigate(-1)}
|
||||
onLogout={onLogout}
|
||||
/>
|
||||
|
||||
<main className="feedback-submit-main feedback-board-main">
|
||||
{submittedId ? (
|
||||
<section className="feedback-submit-card feedback-board-card feedback-submit-success-card">
|
||||
<div className="feedback-board-toolbar">
|
||||
<div className="feedback-board-toolbar-copy">
|
||||
<p className="feedback-submit-eyebrow">提交成功</p>
|
||||
<h1>感谢你的反馈</h1>
|
||||
<p className="feedback-submit-desc feedback-board-desc">
|
||||
我们已收到你的{selectedType.label},编号 <code>{submittedId.slice(0, 8)}</code>。
|
||||
你可以在「用户反馈」中查看进度。
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="feedback-panel-close"
|
||||
aria-label="查看用户反馈"
|
||||
onClick={() => navigate('/feedback/list')}
|
||||
>
|
||||
用户反馈
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="feedback-submit-actions page-save-actions">
|
||||
<button type="button" className="page-save-primary" onClick={() => navigate(`/feedback/${submittedId}`)}>
|
||||
查看详情
|
||||
</button>
|
||||
<button type="button" className="ghost-btn" onClick={() => navigate('/feedback/list')}>
|
||||
用户反馈
|
||||
</button>
|
||||
<button type="button" className="ghost-btn" onClick={resetForm}>
|
||||
继续提交
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
) : (
|
||||
<form
|
||||
className="feedback-submit-card feedback-board-card feedback-submit-form-card"
|
||||
onSubmit={(event) => void handleSubmit(event)}
|
||||
>
|
||||
<div className="feedback-board-toolbar">
|
||||
<div className="feedback-board-toolbar-copy">
|
||||
<p className="feedback-submit-eyebrow">帮助我们改进 TKMind</p>
|
||||
<h1>提交 Bug 或需求</h1>
|
||||
<p className="feedback-submit-desc feedback-board-desc">
|
||||
你可以用文字描述、上传截图,或点击麦克风口述问题。我们会自动附带当前页面与设备信息,便于定位问题。
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="feedback-panel-close"
|
||||
aria-label="查看用户反馈"
|
||||
onClick={() => navigate('/feedback/list')}
|
||||
>
|
||||
用户反馈
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="feedback-board-scroll">
|
||||
<fieldset className="feedback-submit-fieldset">
|
||||
<legend>反馈类型</legend>
|
||||
<div className="feedback-type-grid" role="radiogroup" aria-label="反馈类型">
|
||||
{TYPE_OPTIONS.map((option) => (
|
||||
<label
|
||||
key={option.value}
|
||||
className={`feedback-type-option${type === option.value ? ' is-selected' : ''}`}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name="feedback-type"
|
||||
value={option.value}
|
||||
checked={type === option.value}
|
||||
onChange={() => setType(option.value)}
|
||||
/>
|
||||
<span className="feedback-type-label">{option.label}</span>
|
||||
<span className="feedback-type-hint">{option.hint}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<label className="feedback-submit-label">
|
||||
标题
|
||||
<input
|
||||
className="input"
|
||||
value={title}
|
||||
onChange={(event) => setTitle(event.target.value)}
|
||||
placeholder={
|
||||
type === 'bug'
|
||||
? '例如:保存页面时按钮无响应'
|
||||
: type === 'feature'
|
||||
? '例如:希望支持批量导出'
|
||||
: '简要概括你的问题或建议'
|
||||
}
|
||||
maxLength={120}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div className="feedback-description-block">
|
||||
<label className="feedback-submit-label" htmlFor="feedback-description">
|
||||
详细描述
|
||||
</label>
|
||||
{voiceNotice ? (
|
||||
<div className="feedback-voice-notice" role="status">
|
||||
{voiceNotice}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="feedback-description-shell">
|
||||
<textarea
|
||||
id="feedback-description"
|
||||
ref={descriptionRef}
|
||||
className="input feedback-description-input"
|
||||
rows={5}
|
||||
value={description}
|
||||
disabled={voiceDisabled}
|
||||
readOnly={voiceRecording}
|
||||
onChange={(event) => setDescription(event.target.value)}
|
||||
placeholder="请描述复现步骤、期望行为,或你想实现的功能…点击右下角麦克风可语音输入。"
|
||||
maxLength={5000}
|
||||
required
|
||||
/>
|
||||
<VoiceInputButton
|
||||
disabled={voiceDisabled}
|
||||
onVoiceStart={handleVoiceStart}
|
||||
onLiveTranscript={handleVoiceLiveTranscript}
|
||||
onTranscript={handleVoiceTranscript}
|
||||
onVoiceComplete={handleVoiceComplete}
|
||||
onRecordingChange={setVoiceRecording}
|
||||
onError={setVoiceNotice}
|
||||
stopSignal={voiceStopSignal}
|
||||
/>
|
||||
</div>
|
||||
<p className="feedback-description-hint">
|
||||
{voiceRecording ? '正在聆听,文字会实时显示在输入框中…' : '支持实时听写;识别完成后可继续编辑再提交。'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="feedback-submit-label">
|
||||
<span>截图(可选,最多 {MAX_IMAGES} 张)</span>
|
||||
{imageError ? <p className="feedback-submit-inline-error">{imageError}</p> : null}
|
||||
<div className="feedback-image-grid">
|
||||
{images.map((item) => (
|
||||
<div key={item.id} className="feedback-image-item">
|
||||
<img src={item.previewUrl} alt="反馈截图预览" className="feedback-image-thumb" />
|
||||
<button
|
||||
type="button"
|
||||
className="feedback-image-remove"
|
||||
aria-label="移除图片"
|
||||
onClick={() => removeImage(item.id)}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
{images.length < MAX_IMAGES ? (
|
||||
<button
|
||||
type="button"
|
||||
className="feedback-image-add"
|
||||
disabled={uploadingImages || submitting}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
>
|
||||
{uploadingImages ? '处理中…' : '+ 添加图片'}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
multiple
|
||||
hidden
|
||||
onChange={(event) => void handlePickImages(event.target.files)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<label className="feedback-submit-label">
|
||||
联系方式(可选)
|
||||
<input
|
||||
className="input"
|
||||
value={contact}
|
||||
onChange={(event) => setContact(event.target.value)}
|
||||
placeholder="邮箱或微信,便于我们回复你"
|
||||
maxLength={120}
|
||||
autoComplete="email"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<p className="feedback-submit-meta">
|
||||
将自动附带:当前路径 <code>{window.location.pathname}</code>
|
||||
{sessionId ? <>、会话 ID</> : null}
|
||||
</p>
|
||||
|
||||
{error ? <p className="feedback-submit-error">{error}</p> : null}
|
||||
</div>
|
||||
|
||||
<div className="feedback-submit-actions page-save-actions">
|
||||
<button type="button" className="ghost-btn" disabled={submitting} onClick={() => navigate(-1)}>
|
||||
取消
|
||||
</button>
|
||||
<button type="submit" className="page-save-primary" disabled={submitting || uploadingImages}>
|
||||
{submitting ? '提交中…' : '提交反馈'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { AvatarPicker } from './AvatarPicker';
|
||||
import { appConfig } from '../config';
|
||||
import type { Session } from '../types';
|
||||
import type { SessionSummary } from '../types';
|
||||
import { getSessionListLabel, groupSessionsByDate } from '../utils/sessions';
|
||||
|
||||
function formatSessionTime(iso?: string): string {
|
||||
@@ -37,13 +36,18 @@ function CloseIcon() {
|
||||
|
||||
type HistorySidebarProps = {
|
||||
open: boolean;
|
||||
sessions: Session[];
|
||||
sessions: SessionSummary[];
|
||||
activeSessionId?: string;
|
||||
loading: boolean;
|
||||
loadingMore: boolean;
|
||||
hasMore: boolean;
|
||||
searchQuery: string;
|
||||
onClose: () => void;
|
||||
onSelect: (sessionId: string) => void;
|
||||
onNew: () => void;
|
||||
onDelete: (sessionId: string) => void;
|
||||
onLoadMore: () => void;
|
||||
onSearchChange: (query: string) => void;
|
||||
};
|
||||
|
||||
export function HistorySidebar({
|
||||
@@ -51,18 +55,21 @@ export function HistorySidebar({
|
||||
sessions,
|
||||
activeSessionId,
|
||||
loading,
|
||||
loadingMore,
|
||||
hasMore,
|
||||
searchQuery,
|
||||
onClose,
|
||||
onSelect,
|
||||
onNew,
|
||||
onDelete,
|
||||
onLoadMore,
|
||||
onSearchChange,
|
||||
}: HistorySidebarProps) {
|
||||
const bodyRef = useRef<HTMLDivElement>(null);
|
||||
const [visibleCount, setVisibleCount] = useState(appConfig.sessionPageSize);
|
||||
const [pendingDeleteId, setPendingDeleteId] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setVisibleCount(appConfig.sessionPageSize);
|
||||
setPendingDeleteId(null);
|
||||
}
|
||||
}, [open, sessions.length]);
|
||||
@@ -71,10 +78,10 @@ export function HistorySidebar({
|
||||
const el = bodyRef.current;
|
||||
if (!el) return;
|
||||
const nearBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 80;
|
||||
if (nearBottom && visibleCount < sessions.length) {
|
||||
setVisibleCount((count) => Math.min(count + appConfig.sessionPageSize, sessions.length));
|
||||
if (nearBottom && hasMore && !loadingMore && !loading) {
|
||||
onLoadMore();
|
||||
}
|
||||
}, [visibleCount, sessions.length]);
|
||||
}, [hasMore, loading, loadingMore, onLoadMore]);
|
||||
|
||||
const handleDeleteClick = useCallback((sessionId: string) => {
|
||||
setPendingDeleteId(sessionId);
|
||||
@@ -94,9 +101,7 @@ export function HistorySidebar({
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
const visibleSessions = sessions.slice(0, visibleCount);
|
||||
const sessionGroups = groupSessionsByDate(visibleSessions);
|
||||
const hasMore = visibleCount < sessions.length;
|
||||
const sessionGroups = groupSessionsByDate(sessions);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -114,11 +119,21 @@ export function HistorySidebar({
|
||||
<button type="button" className="sidebar-new" onClick={onNew}>
|
||||
+ 新对话
|
||||
</button>
|
||||
<label className="sidebar-search">
|
||||
<input
|
||||
type="search"
|
||||
className="sidebar-search-input"
|
||||
value={searchQuery}
|
||||
placeholder="搜索历史标题"
|
||||
aria-label="搜索历史对话"
|
||||
onChange={(event) => onSearchChange(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
|
||||
{loading && sessions.length === 0 ? (
|
||||
<div className="sidebar-empty">加载中…</div>
|
||||
) : sessions.length === 0 ? (
|
||||
<div className="sidebar-empty">暂无历史对话</div>
|
||||
<div className="sidebar-empty">{searchQuery ? '没有找到相关历史标题' : '暂无历史对话'}</div>
|
||||
) : (
|
||||
<>
|
||||
{sessionGroups.map((group) => (
|
||||
@@ -175,7 +190,8 @@ export function HistorySidebar({
|
||||
</ul>
|
||||
</section>
|
||||
))}
|
||||
{hasMore && <div className="sidebar-more">向下滚动加载更多…</div>}
|
||||
{loadingMore && <div className="sidebar-more">加载更多历史…</div>}
|
||||
{!loadingMore && hasMore && <div className="sidebar-more">向下滚动加载更多…</div>}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from 'react';
|
||||
import { useRef, useState } from 'react';
|
||||
import { useUserAvatar } from '../hooks/useUserAvatar';
|
||||
import type { Message } from '../types';
|
||||
import { getDisplayText, getImageUrls, getRenderableImageUrls, getThinking } from '../utils/message';
|
||||
@@ -8,7 +8,6 @@ import { filterText } from '../utils/wordFilter';
|
||||
import { formatChatTime, shouldShowTimestamp } from '../utils/time';
|
||||
import { TKMindAvatar } from './TKMindAvatar';
|
||||
import { UserAvatar } from './UserAvatar';
|
||||
import { ChatSharePreviewModal } from './ChatSharePreviewModal';
|
||||
import { ChatWelcomePanel } from './ChatWelcomePanel';
|
||||
|
||||
function CopyIcon() {
|
||||
@@ -133,6 +132,33 @@ function CopyButton({ text }: { text: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
function PublicLinkCopyButton({ url }: { url: string }) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const copyTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const copy = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(url);
|
||||
setCopied(true);
|
||||
if (copyTimer.current) window.clearTimeout(copyTimer.current);
|
||||
copyTimer.current = window.setTimeout(() => setCopied(false), 1800);
|
||||
} catch {
|
||||
setCopied(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={`msg-public-share-link${copied ? ' is-copied' : ''}`}
|
||||
onClick={() => void copy()}
|
||||
title={url}
|
||||
>
|
||||
{copied ? '已复制' : '复制链接'}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function TimeDivider({ timestamp }: { timestamp: number }) {
|
||||
return (
|
||||
<div className="msg-time-divider">
|
||||
@@ -184,7 +210,6 @@ function MessageRow({
|
||||
compact?: boolean;
|
||||
}) {
|
||||
const [actionsOpen, setActionsOpen] = useState(false);
|
||||
const [previewOpen, setPreviewOpen] = useState(false);
|
||||
const rawText = getDisplayText(message);
|
||||
const text = filterText(rawText);
|
||||
const saveActions = getMessageSaveActions(text, { userId: publishUserId, username: publishUsername });
|
||||
@@ -275,14 +300,8 @@ function MessageRow({
|
||||
>
|
||||
{saveActions.kind === 'page' ? '保存页面' : '保存为文章'}
|
||||
</button>
|
||||
{saveActions.previewUrl && sessionId && message.id && (
|
||||
<button
|
||||
type="button"
|
||||
className="msg-open-preview"
|
||||
onClick={() => setPreviewOpen(true)}
|
||||
>
|
||||
打开预览
|
||||
</button>
|
||||
{saveActions.previewUrl && (
|
||||
<PublicLinkCopyButton url={saveActions.previewUrl} />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
@@ -301,14 +320,8 @@ function MessageRow({
|
||||
>
|
||||
{saveActions.kind === 'page' ? '保存页面' : '保存为文章'}
|
||||
</button>
|
||||
{saveActions.previewUrl && sessionId && message.id && (
|
||||
<button
|
||||
type="button"
|
||||
className="msg-open-preview"
|
||||
onClick={() => setPreviewOpen(true)}
|
||||
>
|
||||
打开预览
|
||||
</button>
|
||||
{saveActions.previewUrl && (
|
||||
<PublicLinkCopyButton url={saveActions.previewUrl} />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
@@ -324,14 +337,6 @@ function MessageRow({
|
||||
title={onAvatarClick ? '点击更换头像' : undefined}
|
||||
/>
|
||||
)}
|
||||
{previewOpen && sessionId && message.id && (
|
||||
<ChatSharePreviewModal
|
||||
sessionId={sessionId}
|
||||
messageId={message.id}
|
||||
onClose={() => setPreviewOpen(false)}
|
||||
onSave={onSaveAsPage ? () => onSaveAsPage(message) : undefined}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -363,8 +368,13 @@ export function MessageList({
|
||||
{messages.map((message, index) => {
|
||||
const prev = index > 0 ? messages[index - 1] : undefined;
|
||||
const showTime = shouldShowTimestamp(message.created, prev?.created);
|
||||
const anchorId = message.id ?? `msg-${index}-${message.role}-${message.created}`;
|
||||
return (
|
||||
<div key={message.id ?? `msg-${index}`} className="msg-block">
|
||||
<div
|
||||
key={message.id ?? `msg-${index}`}
|
||||
className="msg-block"
|
||||
data-message-anchor={anchorId}
|
||||
>
|
||||
{showTime && <TimeDivider timestamp={message.created} />}
|
||||
<MessageRow
|
||||
message={message}
|
||||
|
||||
@@ -24,7 +24,7 @@ import {
|
||||
uploadMindSpaceAsset,
|
||||
ApiError,
|
||||
} from '../api/client';
|
||||
import { compressImageForUpload } from '../utils/imageUpload';
|
||||
import { MINDSPACE_IMAGE_UPLOAD_MAX_BYTES } from '../utils/imageUpload';
|
||||
import type {
|
||||
MindSpace,
|
||||
MindSpaceAsset,
|
||||
@@ -63,7 +63,7 @@ import { NotificationCenter } from './NotificationCenter';
|
||||
|
||||
const CATEGORY_DESCRIPTIONS: Record<MindSpaceCategory['code'], string> = {
|
||||
oa: '上传文档、表格和资料,让 AI 生成报告与页面',
|
||||
private: '保存敏感资料,发布前必须完成风险检查与脱敏',
|
||||
private: '已停用',
|
||||
public: '管理可公开使用的页面、图片和静态资源',
|
||||
draft: '继续编辑尚未发布或等待安全检查的页面',
|
||||
archive: '保存不再活跃但仍需要保留的内容',
|
||||
@@ -71,13 +71,20 @@ const CATEGORY_DESCRIPTIONS: Record<MindSpaceCategory['code'], string> = {
|
||||
|
||||
const CATEGORY_ACTIONS: Partial<Record<MindSpaceCategory['code'], string>> = {
|
||||
oa: '进入工作区',
|
||||
private: '进入私人区',
|
||||
public: '查看发布',
|
||||
draft: '继续创作',
|
||||
};
|
||||
|
||||
const AGENT_JOBS_PAGE_SIZE = 10;
|
||||
const RECENT_PAGES_PAGE_SIZE = 6;
|
||||
|
||||
function sortPagesByCreatedAt(pages: MindSpacePage[]) {
|
||||
return [...pages].sort((left, right) => {
|
||||
const createdDiff = (right.createdAt ?? 0) - (left.createdAt ?? 0);
|
||||
if (createdDiff !== 0) return createdDiff;
|
||||
return (right.updatedAt ?? 0) - (left.updatedAt ?? 0);
|
||||
});
|
||||
}
|
||||
const IMAGE_PAGE_SIZE = 10;
|
||||
const DEFAULT_MAX_UPLOAD_FILE_BYTES = 5 * 1024 * 1024;
|
||||
const UPLOAD_FILE_EXTENSIONS = [
|
||||
@@ -282,15 +289,29 @@ function fileExtension(filename: string) {
|
||||
return dotIndex >= 0 ? filename.slice(dotIndex).toLowerCase() : '';
|
||||
}
|
||||
|
||||
function isUploadImageFile(file: File) {
|
||||
const extension = fileExtension(file.name);
|
||||
return (
|
||||
file.type.startsWith('image/') ||
|
||||
extension === '.png' ||
|
||||
extension === '.jpg' ||
|
||||
extension === '.jpeg' ||
|
||||
extension === '.webp'
|
||||
);
|
||||
}
|
||||
|
||||
function validateSelectedUploadFile(file: File, maxBytes: number) {
|
||||
if (file.size <= 0) return '文件不能为空,请重新选择。';
|
||||
if (file.size > maxBytes) {
|
||||
return `文件不能超过 ${formatBytes(maxBytes)},当前为 ${formatBytes(file.size)}。`;
|
||||
}
|
||||
const extension = fileExtension(file.name);
|
||||
if (!UPLOAD_FILE_EXTENSIONS.includes(extension as (typeof UPLOAD_FILE_EXTENSIONS)[number])) {
|
||||
return `暂不支持 ${extension || '无扩展名'} 文件,请选择支持的资料格式。`;
|
||||
}
|
||||
const effectiveMaxBytes = isUploadImageFile(file)
|
||||
? Math.min(maxBytes, MINDSPACE_IMAGE_UPLOAD_MAX_BYTES)
|
||||
: maxBytes;
|
||||
if (file.size > effectiveMaxBytes) {
|
||||
return `文件不能超过 ${formatBytes(effectiveMaxBytes)},当前为 ${formatBytes(file.size)}。`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -450,6 +471,7 @@ export function MindSpaceView({
|
||||
initialCategoryCode,
|
||||
onBack,
|
||||
onLogout,
|
||||
onOpenFeedback,
|
||||
routeSync,
|
||||
}: {
|
||||
user: PortalUser;
|
||||
@@ -458,6 +480,7 @@ export function MindSpaceView({
|
||||
initialCategoryCode?: MindSpaceSaveCategory | null;
|
||||
onBack: () => void;
|
||||
onLogout: () => void;
|
||||
onOpenFeedback?: () => void;
|
||||
routeSync?: MindSpaceRouteSync;
|
||||
}) {
|
||||
const [space, setSpace] = useState<MindSpace | null>(null);
|
||||
@@ -503,7 +526,6 @@ export function MindSpaceView({
|
||||
const [agentJobsPage, setAgentJobsPage] = useState(0);
|
||||
const [agentJobsLoading, setAgentJobsLoading] = useState(false);
|
||||
const [allPagesOpen, setAllPagesOpen] = useState(false);
|
||||
const [recentPagesPage, setRecentPagesPage] = useState(0);
|
||||
const [exportingPageId, setExportingPageId] = useState<string | null>(null);
|
||||
const [sharePayload, setSharePayload] = useState<SharePayload | null>(null);
|
||||
const [previewPageTarget, setPreviewPageTarget] = useState<MindSpacePage | null>(null);
|
||||
@@ -755,6 +777,7 @@ export function MindSpaceView({
|
||||
setPendingDeleteId(null);
|
||||
setSelectedAssetIds([]);
|
||||
setImagePage(0);
|
||||
void refreshSpaceQuietly();
|
||||
routeSync?.pushHome();
|
||||
};
|
||||
|
||||
@@ -1032,14 +1055,9 @@ export function MindSpaceView({
|
||||
setError(null);
|
||||
setUploadError(null);
|
||||
try {
|
||||
const fileToUpload = selectedFile.type.startsWith('image/')
|
||||
? await compressImageForUpload(selectedFile, {
|
||||
maxInputBytes: maxUploadFileBytes,
|
||||
maxOutputBytes: maxUploadFileBytes,
|
||||
maxDimension: 1600,
|
||||
})
|
||||
: selectedFile;
|
||||
await uploadMindSpaceAsset(selectedCategory.id, fileToUpload);
|
||||
await uploadMindSpaceAsset(selectedCategory.id, selectedFile, {
|
||||
maxImageBytes: MINDSPACE_IMAGE_UPLOAD_MAX_BYTES,
|
||||
});
|
||||
setUploadOpen(false);
|
||||
setSelectedFile(null);
|
||||
await load();
|
||||
@@ -1254,7 +1272,8 @@ export function MindSpaceView({
|
||||
|
||||
const visibleImageAssets =
|
||||
assetFilter === 'all' || assetFilter === 'images' ? imageAssets : [];
|
||||
const imagePaginationEnabled = visibleImageAssets.length > IMAGE_PAGE_SIZE;
|
||||
const imagePaginationEnabled =
|
||||
selectedCategory?.code !== 'public' && visibleImageAssets.length > IMAGE_PAGE_SIZE;
|
||||
const imagePageCount = Math.max(
|
||||
1,
|
||||
Math.ceil(visibleImageAssets.length / IMAGE_PAGE_SIZE),
|
||||
@@ -1266,17 +1285,20 @@ export function MindSpaceView({
|
||||
safeImagePage * IMAGE_PAGE_SIZE + IMAGE_PAGE_SIZE,
|
||||
)
|
||||
: visibleImageAssets;
|
||||
const recentPagesTotal = Math.max(1, Math.ceil(pages.length / RECENT_PAGES_PAGE_SIZE));
|
||||
const safeRecentPagesPage = Math.min(recentPagesPage, recentPagesTotal - 1);
|
||||
const pagedRecentPages = pages.slice(
|
||||
safeRecentPagesPage * RECENT_PAGES_PAGE_SIZE,
|
||||
safeRecentPagesPage * RECENT_PAGES_PAGE_SIZE + RECENT_PAGES_PAGE_SIZE,
|
||||
);
|
||||
useEffect(() => {
|
||||
const lastRecentPage = Math.max(0, recentPagesTotal - 1);
|
||||
setRecentPagesPage((current) => Math.min(current, lastRecentPage));
|
||||
}, [recentPagesTotal, pages.length]);
|
||||
const sortedPages = useMemo(() => sortPagesByCreatedAt(pages), [pages]);
|
||||
const recentPagesPreview = sortedPages.slice(0, RECENT_PAGES_PAGE_SIZE);
|
||||
|
||||
const imageAssetsByDate = useMemo(() => {
|
||||
const groups = new Map<string, MindSpaceAsset[]>();
|
||||
for (const asset of visibleImageAssets) {
|
||||
const match = asset.filename.match(/images\/(\d{4}-\d{2}-\d{2})\//);
|
||||
const dateKey = match?.[1] ?? '其他';
|
||||
const bucket = groups.get(dateKey) ?? [];
|
||||
bucket.push(asset);
|
||||
groups.set(dateKey, bucket);
|
||||
}
|
||||
return [...groups.entries()].sort((left, right) => right[0].localeCompare(left[0]));
|
||||
}, [visibleImageAssets]);
|
||||
const visibleCardAssets =
|
||||
assetFilter === 'all'
|
||||
? [...fileAssets, ...workAssets]
|
||||
@@ -1675,6 +1697,11 @@ export function MindSpaceView({
|
||||
) : (
|
||||
<MindSpaceNotificationCenter />
|
||||
)}
|
||||
{!previewMode && onOpenFeedback && (
|
||||
<button type="button" className="ghost-btn" onClick={onOpenFeedback}>
|
||||
反馈
|
||||
</button>
|
||||
)}
|
||||
<span>{user.displayName}</span>
|
||||
<button type="button" className="ghost-btn logout-btn" onClick={onLogout}>
|
||||
登出
|
||||
@@ -2032,7 +2059,7 @@ export function MindSpaceView({
|
||||
</div>
|
||||
{pages.length > 0 ? (
|
||||
<div className="mindspace-feed-grid mindspace-recent-work-grid is-all-pages">
|
||||
{pages.map((page) => (
|
||||
{sortedPages.map((page) => (
|
||||
<MindSpaceFeedCard
|
||||
key={page.id}
|
||||
page={page}
|
||||
@@ -2116,7 +2143,7 @@ export function MindSpaceView({
|
||||
</p>
|
||||
<h2>{selectedCategory.name}</h2>
|
||||
</div>
|
||||
{['oa', 'private', 'public'].includes(selectedCategory.code) && (
|
||||
{['oa', 'public'].includes(selectedCategory.code) && (
|
||||
<button
|
||||
type="button"
|
||||
className="mindspace-primary"
|
||||
@@ -2127,12 +2154,6 @@ export function MindSpaceView({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{selectedCategory.code === 'private' && (
|
||||
<div className="mindspace-security-note">
|
||||
私人区默认不可公开。后续生成公开内容时,系统会要求风险扫描和脱敏副本。
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedCategory.code === 'draft' ? (
|
||||
pages.length > 0 ? (
|
||||
<>
|
||||
@@ -2164,7 +2185,7 @@ export function MindSpaceView({
|
||||
</button>
|
||||
</div>
|
||||
<div className="mindspace-feed-grid">
|
||||
{pages.map((page) => (
|
||||
{sortedPages.map((page) => (
|
||||
<MindSpaceFeedCard
|
||||
key={page.id}
|
||||
page={page}
|
||||
@@ -2185,7 +2206,7 @@ export function MindSpaceView({
|
||||
) : (
|
||||
<>
|
||||
{(() => {
|
||||
const categoryPages = pages.filter(
|
||||
const categoryPages = sortedPages.filter(
|
||||
(p) => p.categoryCode === selectedCategory.code,
|
||||
);
|
||||
return categoryPages.length > 0 ? (
|
||||
@@ -2275,6 +2296,49 @@ export function MindSpaceView({
|
||||
<span>{visibleImageAssets.length} 张</span>
|
||||
</div>
|
||||
)}
|
||||
{selectedCategory?.code === 'public' ? (
|
||||
imageAssetsByDate.map(([dateKey, datedAssets]) => (
|
||||
<div className="mindspace-image-date-group" key={dateKey}>
|
||||
<div className="mindspace-section-subheading">
|
||||
<h3>{dateKey === '其他' ? '其他图片' : dateKey}</h3>
|
||||
<span>{datedAssets.length} 张</span>
|
||||
</div>
|
||||
<div className="mindspace-image-grid">
|
||||
{datedAssets.map((asset) => (
|
||||
<article className="mindspace-image-card" key={asset.id}>
|
||||
<label className="mindspace-asset-select">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedAssetSet.has(asset.id)}
|
||||
onChange={() => toggleAssetSelected(asset.id)}
|
||||
/>
|
||||
<span>选择</span>
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
className="mindspace-image-thumb"
|
||||
onClick={() => setPreviewAssetId(asset.id)}
|
||||
title={asset.displayName}
|
||||
>
|
||||
<img
|
||||
src={buildAssetImageUrl(asset)}
|
||||
alt={asset.displayName}
|
||||
loading="lazy"
|
||||
/>
|
||||
</button>
|
||||
<div className="mindspace-image-meta">
|
||||
<span className="mindspace-image-name">{asset.displayName}</span>
|
||||
<span className="mindspace-image-size">
|
||||
{formatBytes(asset.sizeBytes)}
|
||||
</span>
|
||||
</div>
|
||||
{renderAssetActions(asset)}
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div
|
||||
className={`mindspace-image-grid${imagePaginationEnabled ? ' is-compact' : ''}`}
|
||||
>
|
||||
@@ -2317,6 +2381,7 @@ export function MindSpaceView({
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{imagePaginationEnabled && (
|
||||
<div className="mindspace-pagination mindspace-image-pagination">
|
||||
<button
|
||||
@@ -2453,7 +2518,7 @@ export function MindSpaceView({
|
||||
</div>
|
||||
<div className="mindspace-grid">
|
||||
{space.categories
|
||||
.filter((category) => ['oa', 'private', 'public'].includes(category.code))
|
||||
.filter((category) => ['oa', 'public'].includes(category.code))
|
||||
.map((category) => (
|
||||
<article
|
||||
className={`mindspace-card mindspace-card-${category.code}`}
|
||||
@@ -2533,7 +2598,7 @@ export function MindSpaceView({
|
||||
{pages.length > 0 ? (
|
||||
<>
|
||||
<div className="mindspace-feed-grid mindspace-recent-work-grid">
|
||||
{pagedRecentPages.map((page) => (
|
||||
{recentPagesPreview.map((page) => (
|
||||
<MindSpaceFeedCard
|
||||
key={page.id}
|
||||
page={page}
|
||||
@@ -2542,33 +2607,6 @@ export function MindSpaceView({
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{pages.length > RECENT_PAGES_PAGE_SIZE ? (
|
||||
<div className="mindspace-pagination">
|
||||
<button
|
||||
type="button"
|
||||
disabled={safeRecentPagesPage <= 0}
|
||||
onClick={() =>
|
||||
setRecentPagesPage((value) => Math.max(0, value - 1))
|
||||
}
|
||||
>
|
||||
上一页
|
||||
</button>
|
||||
<span>
|
||||
第 {safeRecentPagesPage + 1} / {recentPagesTotal} 页
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
disabled={safeRecentPagesPage >= recentPagesTotal - 1}
|
||||
onClick={() =>
|
||||
setRecentPagesPage((value) =>
|
||||
Math.min(recentPagesTotal - 1, value + 1),
|
||||
)
|
||||
}
|
||||
>
|
||||
下一页
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
) : (
|
||||
<div className="mindspace-recent-work-empty">
|
||||
@@ -2612,7 +2650,8 @@ export function MindSpaceView({
|
||||
</button>
|
||||
</div>
|
||||
<p className="mindspace-upload-dialog-desc">
|
||||
支持 {UPLOAD_FILE_TYPE_LABEL},单文件最大 {formatBytes(maxUploadFileBytes)}。
|
||||
支持 {UPLOAD_FILE_TYPE_LABEL},文档最大 {formatBytes(maxUploadFileBytes)},图片最大{' '}
|
||||
{formatBytes(Math.min(maxUploadFileBytes, MINDSPACE_IMAGE_UPLOAD_MAX_BYTES))}。
|
||||
</p>
|
||||
<input
|
||||
type="file"
|
||||
|
||||
@@ -19,15 +19,10 @@ const CATEGORY_OPTIONS: Array<{
|
||||
label: 'OA 工作区',
|
||||
hint: '与上传文档并列的资料卡片',
|
||||
},
|
||||
{
|
||||
code: 'private',
|
||||
label: '私人区',
|
||||
hint: '默认不可公开,发布前需检查',
|
||||
},
|
||||
{
|
||||
code: 'public',
|
||||
label: '公开区',
|
||||
hint: '可公开候选,仍需走发布流程',
|
||||
hint: '图片与可公开候选资源',
|
||||
},
|
||||
];
|
||||
|
||||
@@ -69,7 +64,6 @@ export function PageSaveDialog({
|
||||
const [saveError, setSaveError] = useState<string | null>(null);
|
||||
const [saveNotice, setSaveNotice] = useState<string | null>(null);
|
||||
const [duplicateResolved, setDuplicateResolved] = useState<'replace' | 'new' | null>(null);
|
||||
const [privateAcknowledged, setPrivateAcknowledged] = useState(false);
|
||||
const [thumbnailVersion, setThumbnailVersion] = useState(0);
|
||||
const [previewScrollLock, setPreviewScrollLock] = useState(false);
|
||||
const titleRef = useRef('');
|
||||
@@ -142,15 +136,7 @@ export function PageSaveDialog({
|
||||
return () => window.removeEventListener('keydown', onKeyDown);
|
||||
}, [onClose, saving]);
|
||||
|
||||
useEffect(() => {
|
||||
setPrivateAcknowledged(false);
|
||||
}, [categoryCode, analysis?.privacyScan?.findings.length]);
|
||||
|
||||
const isStaticHtml = analysis?.contentMode === 'static_html';
|
||||
const privacyFindings = analysis?.privacyScan.findings ?? [];
|
||||
const privateBlocked = categoryCode === 'private' && analysis?.privacyScan.allowed === false;
|
||||
const privateNeedsAck =
|
||||
categoryCode === 'private' && privacyFindings.length > 0 && analysis?.privacyScan.allowed;
|
||||
|
||||
const existingPage = analysis?.existingPage ?? null;
|
||||
const showDuplicatePrompt = existingPage !== null && duplicateResolved === null && !saveNotice;
|
||||
@@ -168,10 +154,6 @@ export function PageSaveDialog({
|
||||
templateId: isStaticHtml ? 'static-html' : templateId,
|
||||
categoryCode,
|
||||
selectedLinkIndex,
|
||||
acknowledgedFindingIds:
|
||||
categoryCode === 'private' && privateNeedsAck && privateAcknowledged
|
||||
? privacyFindings.map((finding) => finding.id)
|
||||
: undefined,
|
||||
replacePageId,
|
||||
});
|
||||
if (result.kind === 'page') {
|
||||
@@ -308,61 +290,22 @@ export function PageSaveDialog({
|
||||
|
||||
<fieldset className="page-save-targets">
|
||||
<legend>保存到</legend>
|
||||
{CATEGORY_OPTIONS.map((option) => {
|
||||
const disabledPrivate =
|
||||
option.code === 'private' && analysis.privacyScan.allowed === false;
|
||||
return (
|
||||
<label
|
||||
className={`page-save-target${disabledPrivate ? ' is-disabled' : ''}`}
|
||||
key={option.code}
|
||||
>
|
||||
{CATEGORY_OPTIONS.map((option) => (
|
||||
<label className="page-save-target" key={option.code}>
|
||||
<input
|
||||
type="radio"
|
||||
name="save-target"
|
||||
value={option.code}
|
||||
checked={categoryCode === option.code}
|
||||
disabled={disabledPrivate}
|
||||
onChange={() => setCategoryCode(option.code)}
|
||||
/>
|
||||
<span>
|
||||
<strong>{option.label}</strong>
|
||||
<small>
|
||||
{disabledPrivate
|
||||
? '内容含阻断级敏感信息,不能保存到私人区'
|
||||
: option.hint}
|
||||
</small>
|
||||
<small>{option.hint}</small>
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
))}
|
||||
</fieldset>
|
||||
|
||||
{categoryCode === 'private' && (
|
||||
<div className="page-save-private-panel">
|
||||
<strong>私人区安全说明</strong>
|
||||
<p>私人区默认不可公开。若后续要发布,系统会要求脱敏副本和二次确认。</p>
|
||||
{privacyFindings.length > 0 && (
|
||||
<ul className="page-save-private-findings">
|
||||
{privacyFindings.map((finding) => (
|
||||
<li key={finding.id}>
|
||||
{finding.blocking ? '阻断' : '提醒'} · {finding.label ?? finding.type}(
|
||||
{finding.occurrenceCount} 处){finding.sampleMasked}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
{privateNeedsAck && (
|
||||
<label className="page-save-private-ack">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={privateAcknowledged}
|
||||
onChange={(event) => setPrivateAcknowledged(event.target.checked)}
|
||||
/>
|
||||
<span>我已知悉上述敏感信息,确认保存到私人区</span>
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!compact && (
|
||||
@@ -408,14 +351,7 @@ export function PageSaveDialog({
|
||||
duplicateResolved === 'replace' && existingPage ? existingPage.id : undefined,
|
||||
)
|
||||
}
|
||||
disabled={
|
||||
saving ||
|
||||
analysisLoading ||
|
||||
!title.trim() ||
|
||||
Boolean(analysisError) ||
|
||||
privateBlocked ||
|
||||
(privateNeedsAck && !privateAcknowledged)
|
||||
}
|
||||
disabled={saving || analysisLoading || !title.trim() || Boolean(analysisError)}
|
||||
>
|
||||
{saving
|
||||
? '正在保存…'
|
||||
|
||||
Reference in New Issue
Block a user