release: prepare 0629001 portal updates

This commit is contained in:
john
2026-06-29 22:20:04 +08:00
parent 18ea4f82fd
commit a40e340a41
84 changed files with 17516 additions and 2475 deletions
+314 -105
View File
@@ -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' || chatState === 'loading' || chatState === 'connecting';
const offlineBlocked = !online;
@@ -187,16 +330,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) {
@@ -206,17 +404,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;
};
@@ -232,56 +482,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>) => {
@@ -297,56 +514,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) => {
@@ -359,7 +541,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'}
@@ -375,7 +570,6 @@ export function ChatPanel({
<AvatarPicker variant="compact" />
</div>
)}
<div ref={bottomRef} />
</main>
{pendingTool && (
@@ -420,14 +614,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)}
>
×
+18 -8
View File
@@ -113,14 +113,24 @@ export function FeedbackBoardView({
{total} · {PAGE_SIZE}
</p>
</div>
<button
type="button"
className="feedback-panel-close"
aria-label="关闭反馈"
onClick={() => navigate('/')}
>
</button>
<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 ? (
+85 -57
View File
@@ -51,21 +51,18 @@ export function FeedbackDetailView({
}, [feedbackId]);
return (
<div className="feedback-submit-page">
<div className="feedback-submit-page feedback-board-page">
<FeedbackPageHeader
title="反馈详情"
onBack={() => navigate('/feedback/list')}
onLogout={onLogout}
/>
<main className="feedback-submit-main">
<section className="feedback-submit-card feedback-detail-card">
{loading ? <p className="feedback-list-muted"></p> : null}
{error ? <p className="feedback-submit-error">{error}</p> : null}
{!loading && !error && item ? (
<>
<div className="feedback-detail-header">
<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]}
@@ -75,61 +72,92 @@ export function FeedbackDetailView({
</span>
{isMine ? <span className="feedback-badge feedback-badge-mine"></span> : null}
</div>
<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>
) : 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>
{item.context?.pagePath ? (
<div className="feedback-detail-section">
<h2></h2>
<p className="feedback-detail-text">
<code>{item.context.pagePath}</code>
<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>
) : 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>
<h2></h2>
<p className="feedback-detail-description">{item.description || '(无描述)'}</p>
</div>
) : null}
</>
) : null}
{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>
+65 -48
View File
@@ -229,54 +229,71 @@ export function FeedbackSubmitView({
};
return (
<div className="feedback-submit-page">
<div className="feedback-submit-page feedback-board-page">
<FeedbackPageHeader
title={submittedId ? '反馈已收到' : '反馈与建议'}
onBack={() => navigate(-1)}
onLogout={onLogout}
/>
<main className="feedback-submit-main">
<div className="feedback-submit-layout">
{submittedId ? (
<section className="feedback-submit-card feedback-submit-success">
<p className="feedback-submit-eyebrow"></p>
<h1></h1>
<p className="feedback-submit-desc">
{selectedType.label} <code>{submittedId.slice(0, 8)}</code>
</p>
<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>
<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>
</section>
) : (
<form className="feedback-submit-card" onSubmit={(event) => void handleSubmit(event)}>
<div className="feedback-submit-intro">
<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>
<div className="feedback-submit-title-row">
<h1> Bug </h1>
<button
type="button"
className="feedback-board-link"
onClick={() => navigate('/feedback/list')}
>
</button>
</div>
<p className="feedback-submit-desc">
<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="反馈类型">
@@ -331,7 +348,7 @@ export function FeedbackSubmitView({
id="feedback-description"
ref={descriptionRef}
className="input feedback-description-input"
rows={6}
rows={5}
value={description}
disabled={voiceDisabled}
readOnly={voiceRecording}
@@ -412,18 +429,18 @@ export function FeedbackSubmitView({
</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>
)}
</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>
);
+29 -13
View File
@@ -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>
+38 -28
View File
@@ -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}
+6 -70
View 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
? '正在保存…'