feat(feedback): 新增用户反馈提交、分页列表与语音描述

支持 Bug/需求提交、截图与聊天同款语音输入,全站反馈分页浏览与详情页,并从聊天与空间页提供入口。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
john
2026-06-29 20:49:15 +08:00
parent 08c5b22d4a
commit 18ea4f82fd
19 changed files with 2846 additions and 150 deletions
+24
View File
@@ -9,6 +9,7 @@ import { MindSpaceView } from './components/MindSpaceView';
import { ChatProvider } from './context/ChatProvider';
import { PREVIEW_USER } from './dev/mindspacePreviewData';
import { MindSpaceRoute } from './routes/MindSpaceRoute';
import { FeedbackRoutes } from './routes/FeedbackRoute';
import type { CapabilityMap, PortalUser } from './types';
function isMindSpacePreview() {
@@ -86,6 +87,7 @@ function AuthenticatedApp({
? () => { window.location.href = import.meta.env.VITE_ADMIN_APP_URL ?? '/admin-app'; }
: undefined
}
onOpenFeedback={() => navigate('/feedback')}
onLogout={handleLogout}
/>
);
@@ -102,6 +104,10 @@ function AuthenticatedApp({
path="/space/*"
element={<MindSpaceAuthGate user={user} onLogout={handleLogout} />}
/>
<Route
path="/feedback/*"
element={<FeedbackRoutes user={user} onLogout={handleLogout} />}
/>
<Route path="/" element={chatElement} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
@@ -117,6 +123,7 @@ export function App() {
const [capabilities, setCapabilities] = useState<CapabilityMap | undefined>();
const [grantedSkills, setGrantedSkills] = useState<string[] | undefined>();
const [legacyMode, setLegacyMode] = useState(false);
const [authUnavailable, setAuthUnavailable] = useState<string | null>(null);
useEffect(() => {
if (mindSpacePreview) return;
@@ -128,6 +135,12 @@ export function App() {
}
});
void checkAuth().then((status) => {
if (status.mode === 'unavailable') {
setAuthUnavailable(status.message ?? '用户认证服务暂时不可用,请稍后刷新');
setAuthed(false);
return;
}
setAuthUnavailable(null);
setLegacyMode(status.mode === 'legacy');
setAuthed(status.authenticated);
setUser(status.user ?? null);
@@ -159,6 +172,17 @@ export function App() {
return <div className="app-loading"></div>;
}
if (authUnavailable) {
return (
<div className="app-loading">
{authUnavailable}
<button type="button" className="login-link" onClick={() => window.location.reload()}>
</button>
</div>
);
}
if (!authed) {
return (
<AuthView
+104 -10
View File
@@ -36,8 +36,16 @@ import type {
PlazaPostBrief,
MindSpaceRedactedCopyResult,
MindSpaceUpload,
FeedbackContextInput,
FeedbackImageInput,
FeedbackSubmission,
FeedbackSubmissionType,
FeedbackBoardPage,
PortalUser,
Session,
SessionConversationPage,
SessionListPage,
SessionSummary,
SessionEvent,
SessionListResponse,
UsageRecord,
@@ -45,8 +53,9 @@ import type {
UserNotification,
PlanDefinition,
ActiveSubscription,
UserMemorySyncResponse,
} from '../types';
import { CHAT_IMAGE_UPLOAD_MAX_OUTPUT_BYTES } from '../utils/imageUpload';
import { CHAT_IMAGE_UPLOAD_MAX_INPUT_BYTES } from '../utils/imageUpload';
import { normalizeConversationMessages, normalizeUserMessageForApi } from '../utils/message';
const API = '/api';
@@ -214,7 +223,10 @@ async function portalFetch<T>(path: string, init?: RequestInit): Promise<T> {
if (res.status === 204) return undefined as T;
const text = await res.text().catch(() => '');
if (text.trimStart().startsWith('<')) {
throw new ApiError(res.status, '服务器返回了意外的页面,请确认后端服务已启动并已重启');
throw new ApiError(
res.status,
`接口 ${path} 返回了页面而非 JSON,请重启后端(pnpm dev 或 node server.mjs`,
);
}
try {
return JSON.parse(text) as T;
@@ -272,7 +284,10 @@ async function apiFetch<T>(path: string, init?: RequestInit): Promise<T> {
if (res.status === 204) return undefined as T;
const rawText = await res.text().catch(() => '');
if (rawText.trimStart().startsWith('<')) {
throw new ApiError(res.status, '服务器返回了意外的页面,请确认后端服务已启动并已重启');
throw new ApiError(
res.status,
`接口 ${API}${path} 返回了页面而非 JSON,请重启后端(pnpm dev 或 node server.mjs`,
);
}
try {
return JSON.parse(rawText) as T;
@@ -310,6 +325,20 @@ export async function rememberProjectContext(
});
}
export async function rememberUserMemory(sessionId: string): Promise<UserMemorySyncResponse> {
return apiFetch<UserMemorySyncResponse>('/user-memory/v1/remember-recent', {
method: 'POST',
body: JSON.stringify({ sessionId }),
});
}
export async function syncUserMemory(sessionId: string): Promise<UserMemorySyncResponse> {
return apiFetch<UserMemorySyncResponse>('/user-memory/v1/sync', {
method: 'POST',
body: JSON.stringify({ sessionId }),
});
}
export type WechatAuthConfig = {
enabled: boolean;
inWechat?: boolean;
@@ -380,8 +409,11 @@ export async function getWechatJsSdkSignature(url: string): Promise<WechatJsSdkS
export async function checkAuth(): Promise<AuthStatus> {
try {
const response = await fetch('/auth/status');
if (!response.ok) return { authenticated: false };
const status = (await response.json()) as AuthStatus;
if (!response.ok) {
if (status.mode === 'unavailable') return status;
return { authenticated: false };
}
if (status.authenticated) resetUnauthorizedGuard();
if (status.authenticated && status.mode === 'user' && !status.capabilities) {
try {
@@ -416,6 +448,53 @@ export async function getMyUsage(): Promise<UsageRecord[]> {
return result.records ?? [];
}
export async function submitFeedback(input: {
type: FeedbackSubmissionType;
title: string;
description: string;
contact?: string;
images?: FeedbackImageInput[];
context?: FeedbackContextInput;
}): Promise<FeedbackSubmission> {
const result = await portalFetch<{ feedback: FeedbackSubmission }>('/auth/feedback', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
type: input.type,
title: input.title,
description: input.description,
contact: input.contact,
images: input.images,
context: input.context,
}),
});
return result.feedback;
}
export async function getMyFeedback(limit = 20): Promise<FeedbackSubmission[]> {
const result = await portalFetch<{ items: FeedbackSubmission[] }>(
`/auth/feedback?limit=${encodeURIComponent(String(limit))}`,
);
return result.items ?? [];
}
export async function listFeedbackBoard(page = 1, limit = 10): Promise<FeedbackBoardPage> {
const params = new URLSearchParams({
page: String(page),
limit: String(limit),
});
return portalFetch<FeedbackBoardPage>(`/auth/feedback/board?${params.toString()}`);
}
export async function getFeedbackDetail(feedbackId: string): Promise<{
item: FeedbackSubmission;
isMine: boolean;
}> {
return portalFetch<{ item: FeedbackSubmission; isMine: boolean }>(
`/auth/feedback/${encodeURIComponent(feedbackId)}`,
);
}
export async function getMyBillingLedger(limit = 30): Promise<LedgerEntry[]> {
const query = limit ? `?limit=${encodeURIComponent(String(limit))}` : '';
const result = await portalFetch<{ entries: LedgerEntry[] }>(`/auth/billing/ledger${query}`);
@@ -521,11 +600,13 @@ export async function listMindSpaceAssets(
export async function uploadMindSpaceAsset(
categoryId: string,
file: File,
options: { maxImageBytes?: number } = {},
): Promise<MindSpaceAsset> {
if (file.type.startsWith('image/') && file.size > CHAT_IMAGE_UPLOAD_MAX_OUTPUT_BYTES) {
const maxImageBytes = options.maxImageBytes ?? CHAT_IMAGE_UPLOAD_MAX_INPUT_BYTES;
if (file.type.startsWith('image/') && file.size > maxImageBytes) {
throw new ApiError(
413,
`图片文件过大,当前 ${(file.size / 1024 / 1024).toFixed(2)}MB请先压缩到 ${CHAT_IMAGE_UPLOAD_MAX_OUTPUT_BYTES / 1024 / 1024}MB 以下再上传。`,
`图片文件过大,当前 ${(file.size / 1024 / 1024).toFixed(2)}MB超过 ${maxImageBytes / 1024 / 1024}MB 上传上限`,
);
}
@@ -2003,9 +2084,18 @@ export async function applyLocalLlmFallback(sessionId: string): Promise<{
});
}
export async function listSessions(): Promise<Session[]> {
const result = await apiFetch<SessionListResponse>('/sessions');
return result.sessions ?? [];
export async function listSessions(options?: {
limit?: number;
offset?: number;
query?: string;
}): Promise<{ items: SessionSummary[]; page: SessionListPage }> {
const params = new URLSearchParams();
if (options?.limit != null) params.set('limit', String(options.limit));
if (options?.offset != null) params.set('offset', String(options.offset));
if (options?.query?.trim()) params.set('query', options.query.trim());
const qs = params.size ? `?${params.toString()}` : '';
const result = await apiFetch<SessionListResponse>(`/sessions${qs}`);
return { items: result.sessions ?? [], page: result.page ?? {} };
}
function sessionPath(sessionId: string, suffix = '') {
@@ -2023,19 +2113,23 @@ export async function deleteChatSession(sessionId: string): Promise<void> {
export async function loadSessionDetail(
sessionId: string,
hints?: { messageCount?: number; updatedAt?: string },
history?: { before?: number; limit?: number },
): Promise<{
session: Session;
messages: Message[];
page: SessionConversationPage;
}> {
const params = new URLSearchParams();
if (hints?.messageCount != null) params.set('hint_mc', String(hints.messageCount));
if (hints?.updatedAt) params.set('hint_ua', hints.updatedAt);
if (history?.before != null) params.set('history_before', String(history.before));
if (history?.limit != null) params.set('history_limit', String(history.limit));
const qs = params.size ? `?${params.toString()}` : '';
const detail = await apiFetch<Session>(`${sessionPath(sessionId)}${qs}`);
const messages = normalizeConversationMessages(
(detail.conversation ?? []).filter((m) => m.metadata?.userVisible),
);
return { session: detail, messages };
return { session: detail, messages, page: detail.conversation_page ?? {} };
}
export async function sendReply(
+28 -35
View File
@@ -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,7 +78,6 @@ export function ChatView({
}
}, [sidebarOpen, onSidebarOpen]);
const busy = chatState === 'streaming' || chatState === 'loading' || chatState === 'connecting';
const showHomeWelcome = messages.length === 0;
const handleSelectSession = (sessionId: string) => {
@@ -96,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() }]
: []),
@@ -123,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' : ''}`}>
@@ -168,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>
@@ -292,6 +281,9 @@ export function ChatView({
variant="full"
user={user}
messages={messages}
historyLoadingMore={messageHistoryLoadingMore}
historyHasMore={messageHistoryHasMore}
historyTotal={messageHistoryTotal}
chatState={chatState}
pendingTool={pendingTool}
session={session}
@@ -301,6 +293,7 @@ export function ChatView({
void submit(text, undefined, imageUrls, previewImageUrls)
}
onUploadImage={uploadChatImage}
onLoadOlderMessages={loadOlderMessages}
onStop={stop}
onApproveTool={approveTool}
onPageSaved={(result) => {
+179
View File
@@ -0,0 +1,179 @@
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>
<button
type="button"
className="feedback-panel-close"
aria-label="关闭反馈"
onClick={() => navigate('/')}
>
</button>
</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>
);
}
+137
View File
@@ -0,0 +1,137 @@
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">
<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">
<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>
<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}
</section>
</main>
</div>
);
}
+30
View File
@@ -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>
);
}
+430
View File
@@ -0,0 +1,430 @@
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">
<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>
</div>
</section>
) : (
<form className="feedback-submit-card" onSubmit={(event) => void handleSubmit(event)}>
<div className="feedback-submit-intro">
<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">
便
</p>
</div>
<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={6}
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 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>
</main>
</div>
);
}
+104 -66
View File
@@ -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);
@@ -1033,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();
@@ -1255,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),
@@ -1267,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]
@@ -1676,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}>
@@ -2033,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}
@@ -2117,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"
@@ -2128,12 +2154,6 @@ export function MindSpaceView({
)}
</div>
{selectedCategory.code === 'private' && (
<div className="mindspace-security-note">
</div>
)}
{selectedCategory.code === 'draft' ? (
pages.length > 0 ? (
<>
@@ -2165,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}
@@ -2186,7 +2206,7 @@ export function MindSpaceView({
) : (
<>
{(() => {
const categoryPages = pages.filter(
const categoryPages = sortedPages.filter(
(p) => p.categoryCode === selectedCategory.code,
);
return categoryPages.length > 0 ? (
@@ -2276,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' : ''}`}
>
@@ -2318,6 +2381,7 @@ export function MindSpaceView({
</article>
))}
</div>
)}
{imagePaginationEnabled && (
<div className="mindspace-pagination mindspace-image-pagination">
<button
@@ -2454,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}`}
@@ -2534,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}
@@ -2543,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">
@@ -2613,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"
+872
View File
@@ -167,6 +167,31 @@ body,
background: var(--color-bg-elevated);
}
.sidebar-search {
display: block;
margin-bottom: var(--space-sm);
}
.sidebar-search-input {
width: 100%;
border: 1px solid var(--color-border-input);
border-radius: var(--radius-md);
background: rgba(255, 255, 255, 0.04);
color: var(--color-text-primary);
padding: 10px 12px;
font-size: 14px;
outline: none;
}
.sidebar-search-input::placeholder {
color: var(--color-text-faint);
}
.sidebar-search-input:focus {
border-color: var(--color-border-strong, var(--color-border-input));
background: rgba(255, 255, 255, 0.06);
}
.sidebar-list {
list-style: none;
margin: 0;
@@ -253,6 +278,10 @@ body,
color: var(--color-text-primary);
}
.sidebar-item-pending {
background: rgba(255, 255, 255, 0.06);
}
.sidebar-item-title {
display: block;
font-size: 14px;
@@ -269,6 +298,14 @@ body,
color: var(--color-text-faint);
}
.sidebar-item-hint {
display: block;
margin-top: 6px;
font-size: 11px;
color: #f2b544;
font-weight: 600;
}
.sidebar-item-confirming .sidebar-item {
pointer-events: none;
opacity: 0.6;
@@ -751,6 +788,23 @@ body,
padding-bottom: var(--space-sm);
}
.chat-history-loader {
position: sticky;
top: 0;
z-index: 2;
width: fit-content;
max-width: 100%;
margin: 0 auto var(--space-sm);
padding: 6px 10px;
border: 1px solid var(--color-border-soft);
border-radius: 999px;
background: rgba(255, 255, 255, 0.92);
color: var(--color-text-muted);
font-size: 12px;
line-height: 1.4;
backdrop-filter: blur(8px);
}
.empty-state {
display: flex;
flex-direction: column;
@@ -9143,3 +9197,821 @@ body,
text-align: center;
line-height: 1.4;
}
.feedback-submit-page {
display: flex;
flex-direction: column;
min-height: 100%;
background:
radial-gradient(circle at 50% 0, rgba(86, 128, 255, 0.1), transparent 24%),
var(--color-bg-surface);
}
.feedback-submit-header {
display: grid;
grid-template-columns: 1fr auto 1fr;
align-items: center;
gap: 12px;
padding: 12px 16px;
border-bottom: 1px solid var(--color-border);
}
.feedback-submit-header-brand {
display: inline-flex;
align-items: center;
gap: 10px;
color: var(--color-text-secondary);
font-size: 14px;
font-weight: 600;
}
.feedback-submit-header .ghost-btn:first-child {
justify-self: start;
}
.feedback-submit-header .ghost-btn:last-child,
.feedback-submit-header > span:last-child {
justify-self: end;
}
.feedback-submit-main {
flex: 1;
overflow: auto;
padding: 24px 16px 40px;
}
.feedback-submit-layout {
display: grid;
gap: 20px;
width: min(720px, 100%);
margin: 0 auto;
}
.feedback-list-card {
padding: clamp(20px, 4vw, 28px);
border: 1px solid rgba(238, 176, 78, 0.22);
border-radius: 22px;
color: #18211d;
background: rgba(248, 243, 232, 0.92);
box-shadow: 0 16px 48px rgba(0, 0, 0, 0.16);
}
.feedback-list-card h2 {
margin: 6px 0 0;
font-family: Georgia, 'Times New Roman', serif;
font-size: clamp(22px, 4vw, 28px);
font-weight: 500;
}
.feedback-list-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
margin-bottom: 16px;
}
.feedback-list-count {
flex-shrink: 0;
padding: 4px 10px;
border-radius: 999px;
color: #2f6f57;
background: rgba(47, 111, 87, 0.1);
font-size: 12px;
font-weight: 700;
}
.feedback-list-muted,
.feedback-list-empty {
margin: 0;
color: #7a8680;
font-size: 14px;
line-height: 1.6;
}
.feedback-list {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: 10px;
}
.feedback-list-item {
border: 1px solid rgba(24, 33, 29, 0.1);
border-radius: 14px;
background: rgba(255, 255, 255, 0.62);
overflow: hidden;
}
.feedback-list-item.is-expanded {
border-color: rgba(47, 111, 87, 0.28);
box-shadow: inset 0 0 0 1px rgba(47, 111, 87, 0.08);
}
.feedback-list-item-trigger {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 12px;
width: 100%;
padding: 14px 16px;
border: 0;
background: transparent;
color: inherit;
text-align: left;
cursor: pointer;
font: inherit;
}
.feedback-list-item-main {
display: grid;
gap: 6px;
min-width: 0;
}
.feedback-list-item-badges {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.feedback-badge {
display: inline-flex;
align-items: center;
padding: 2px 8px;
border-radius: 999px;
font-size: 11px;
font-weight: 700;
line-height: 1.4;
}
.feedback-badge-type-bug {
color: #8b2d20;
background: rgba(172, 55, 55, 0.12);
}
.feedback-badge-type-feature {
color: #2458a6;
background: rgba(61, 139, 253, 0.12);
}
.feedback-badge-type-other {
color: #52605a;
background: rgba(24, 33, 29, 0.08);
}
.feedback-badge-status-pending {
color: #8a6118;
background: rgba(238, 176, 78, 0.18);
}
.feedback-badge-status-reviewing {
color: #2458a6;
background: rgba(61, 139, 253, 0.12);
}
.feedback-badge-status-resolved {
color: #2f6f57;
background: rgba(47, 111, 87, 0.12);
}
.feedback-badge-status-closed {
color: #68716c;
background: rgba(24, 33, 29, 0.08);
}
.feedback-list-item-title {
color: #18211d;
font-size: 15px;
line-height: 1.45;
}
.feedback-list-item-preview {
margin: 0;
color: #68716c;
font-size: 13px;
line-height: 1.5;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.feedback-list-item-meta {
display: grid;
justify-items: end;
gap: 4px;
color: #7a8680;
font-size: 12px;
white-space: nowrap;
}
.feedback-list-item-chevron {
color: #2f6f57;
font-size: 12px;
}
.feedback-list-item-detail {
padding: 0 16px 16px;
display: grid;
gap: 10px;
}
.feedback-list-item-id,
.feedback-list-item-contact,
.feedback-list-item-context {
margin: 0;
color: #68716c;
font-size: 12px;
line-height: 1.5;
}
.feedback-list-item-description {
margin: 0;
color: #18211d;
font-size: 14px;
line-height: 1.65;
white-space: pre-wrap;
}
.feedback-list-item-images {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.feedback-list-image-link {
display: block;
width: 72px;
height: 72px;
}
.feedback-list-image-thumb {
width: 100%;
height: 100%;
border-radius: 10px;
object-fit: cover;
border: 1px solid rgba(24, 33, 29, 0.12);
}
.feedback-submit-card {
width: 100%;
margin: 0;
padding: clamp(24px, 5vw, 40px);
border: 1px solid rgba(238, 176, 78, 0.28);
border-radius: 26px;
color: #18211d;
background:
radial-gradient(circle at 100% 0, rgba(47, 111, 87, 0.16), transparent 20rem),
#f8f3e8;
box-shadow: 0 24px 80px rgba(0, 0, 0, 0.28);
}
.feedback-submit-card h1 {
margin: 8px 0 0;
font-family: Georgia, 'Times New Roman', serif;
font-size: clamp(28px, 5vw, 36px);
font-weight: 500;
line-height: 1.2;
}
.feedback-submit-eyebrow {
margin: 0;
color: #2f6f57;
font-size: 12px;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.feedback-submit-desc {
margin: 12px 0 0;
color: #68716c;
font-size: 14px;
line-height: 1.65;
}
.feedback-submit-intro {
margin-bottom: 24px;
}
.feedback-submit-fieldset {
margin: 0 0 20px;
padding: 0;
border: 0;
}
.feedback-submit-fieldset legend {
margin-bottom: 10px;
color: #52605a;
font-size: 13px;
font-weight: 700;
}
.feedback-type-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 10px;
}
.feedback-type-option {
display: grid;
gap: 4px;
padding: 14px 12px;
border: 1px solid rgba(24, 33, 29, 0.12);
border-radius: 16px;
background: rgba(255, 255, 255, 0.55);
cursor: pointer;
transition: border-color 0.15s ease, background 0.15s ease, box-shadow 0.15s ease;
}
.feedback-type-option input {
position: absolute;
opacity: 0;
pointer-events: none;
}
.feedback-type-option.is-selected {
border-color: rgba(47, 111, 87, 0.55);
background: rgba(47, 111, 87, 0.08);
box-shadow: inset 0 0 0 1px rgba(47, 111, 87, 0.18);
}
.feedback-type-label {
color: #18211d;
font-size: 14px;
font-weight: 700;
}
.feedback-type-hint {
color: #7a8680;
font-size: 12px;
line-height: 1.45;
}
.feedback-submit-label {
display: grid;
gap: 8px;
margin-bottom: 18px;
color: #52605a;
font-size: 13px;
font-weight: 700;
}
.feedback-submit-card .input {
width: 100%;
padding: 12px 14px;
border: 1px solid rgba(24, 33, 29, 0.14);
border-radius: 12px;
color: #18211d;
background: #fffdf7;
font: inherit;
font-size: 14px;
}
.feedback-submit-card .input:focus {
outline: none;
border-color: rgba(47, 111, 87, 0.55);
box-shadow: 0 0 0 3px rgba(47, 111, 87, 0.12);
}
.feedback-description-block {
display: grid;
gap: 8px;
margin-bottom: 18px;
}
.feedback-description-block .feedback-submit-label {
margin-bottom: 0;
}
.feedback-description-shell {
position: relative;
}
.feedback-description-shell .feedback-description-input {
width: 100%;
min-height: 148px;
padding: 12px 46px 12px 14px;
resize: vertical;
line-height: 1.6;
}
.feedback-description-shell .voice-btn {
position: absolute;
right: 10px;
bottom: 10px;
}
.feedback-description-hint {
margin: 0;
color: #7a8680;
font-size: 12px;
line-height: 1.5;
}
.feedback-voice-notice {
margin: 0;
color: #2f6f57;
font-size: 12px;
line-height: 1.5;
}
.feedback-image-grid {
display: flex;
flex-wrap: wrap;
gap: 10px;
}
.feedback-image-item {
position: relative;
width: 88px;
height: 88px;
}
.feedback-image-thumb {
width: 100%;
height: 100%;
border-radius: 12px;
object-fit: cover;
border: 1px solid rgba(24, 33, 29, 0.12);
}
.feedback-image-remove {
position: absolute;
top: -6px;
right: -6px;
width: 22px;
height: 22px;
border: 0;
border-radius: 999px;
color: #fff;
background: rgba(24, 33, 29, 0.82);
cursor: pointer;
font-size: 14px;
line-height: 1;
}
.feedback-image-add {
display: inline-flex;
align-items: center;
justify-content: center;
width: 88px;
height: 88px;
border: 1px dashed rgba(24, 33, 29, 0.28);
border-radius: 12px;
color: #52605a;
background: rgba(255, 255, 255, 0.45);
cursor: pointer;
font: inherit;
font-size: 13px;
}
.feedback-image-add:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.feedback-submit-inline-error {
margin: 0;
color: #9a2f2f;
font-size: 12px;
font-weight: 500;
}
.feedback-submit-meta {
margin: 0 0 16px;
color: #7a8680;
font-size: 12px;
line-height: 1.5;
}
.feedback-submit-meta code,
.feedback-submit-success code {
padding: 1px 6px;
border-radius: 6px;
background: rgba(24, 33, 29, 0.06);
font-size: 11px;
}
.feedback-submit-error {
margin: 0 0 16px;
padding: 10px 12px;
border: 1px solid rgba(172, 55, 55, 0.24);
border-radius: 10px;
color: #9a2f2f;
background: rgba(255, 238, 232, 0.82);
font-size: 13px;
}
.feedback-submit-actions {
display: flex;
justify-content: flex-end;
gap: 10px;
margin-top: 8px;
}
.feedback-submit-actions button {
padding: 11px 16px;
border-radius: 999px;
cursor: pointer;
font: inherit;
}
.feedback-submit-success {
text-align: left;
}
.feedback-submit-title-row {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
}
.feedback-submit-title-row h1 {
margin: 8px 0 0;
}
.feedback-board-link {
padding: 0;
border: 0;
background: transparent;
color: #2f6f57;
font-size: 13px;
font-weight: 600;
cursor: pointer;
text-decoration: underline;
text-underline-offset: 3px;
}
.feedback-board-link:hover {
color: #245a46;
}
.feedback-board-page .feedback-board-main {
display: flex;
align-items: flex-start;
justify-content: center;
padding: 16px;
}
.feedback-board-card {
display: flex;
flex-direction: column;
width: min(640px, 100%);
max-height: min(72dvh, 560px);
padding: clamp(18px, 3vw, 24px);
overflow: hidden;
}
.feedback-board-card h1 {
margin: 4px 0 0;
font-size: clamp(22px, 4vw, 28px);
}
.feedback-board-toolbar {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
flex-shrink: 0;
}
.feedback-board-toolbar-copy {
min-width: 0;
}
.feedback-board-desc {
margin-top: 6px;
font-size: 13px;
}
.feedback-board-page-indicator {
margin: 10px 0 0;
color: #7a8680;
font-size: 12px;
}
.feedback-panel-close {
flex-shrink: 0;
min-width: 72px;
min-height: 40px;
padding: 10px 18px;
border: 1px solid rgba(24, 33, 29, 0.16);
border-radius: 999px;
color: #18211d;
background: rgba(255, 255, 255, 0.72);
cursor: pointer;
font: inherit;
font-size: 14px;
font-weight: 600;
line-height: 1.2;
box-shadow: 0 2px 10px rgba(24, 33, 29, 0.06);
}
.feedback-panel-close:hover {
color: #18211d;
border-color: rgba(47, 111, 87, 0.35);
background: #fff;
}
.feedback-board-scroll {
flex: 1 1 auto;
min-height: 0;
margin-top: 14px;
overflow: auto;
overscroll-behavior: contain;
}
.feedback-board-list {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: 8px;
}
.feedback-board-item,
.feedback-board-item-trigger {
width: 100%;
}
.feedback-board-item {
border: 1px solid rgba(24, 33, 29, 0.1);
border-radius: 12px;
background: rgba(255, 255, 255, 0.62);
overflow: hidden;
}
.feedback-board-item-trigger {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 10px;
padding: 10px 12px;
border: 0;
background: transparent;
color: inherit;
text-align: left;
cursor: pointer;
font: inherit;
}
.feedback-board-item-main {
display: grid;
gap: 4px;
min-width: 0;
}
.feedback-board-item-title {
color: #18211d;
font-size: 14px;
line-height: 1.4;
}
.feedback-board-item-preview {
margin: 0;
color: #68716c;
font-size: 12px;
line-height: 1.45;
display: -webkit-box;
-webkit-line-clamp: 1;
-webkit-box-orient: vertical;
overflow: hidden;
}
.feedback-board-item-meta {
display: grid;
justify-items: end;
gap: 2px;
color: #7a8680;
font-size: 11px;
white-space: nowrap;
}
.feedback-board-card .feedback-pagination {
flex-shrink: 0;
margin-top: 12px;
padding-top: 12px;
border-top: 1px solid rgba(24, 33, 29, 0.08);
}
.feedback-badge-mine {
color: #2458a6;
background: rgba(61, 139, 253, 0.12);
}
.feedback-pagination {
display: flex;
align-items: center;
justify-content: center;
gap: 16px;
margin-top: 18px;
padding-top: 8px;
}
.feedback-pagination-status {
color: #52605a;
font-size: 13px;
}
.feedback-detail-card h1 {
margin: 10px 0 0;
font-family: Georgia, 'Times New Roman', serif;
font-size: clamp(24px, 4vw, 32px);
font-weight: 500;
line-height: 1.3;
}
.feedback-detail-header {
display: grid;
gap: 8px;
margin-bottom: 20px;
}
.feedback-detail-meta {
margin: 0;
color: #68716c;
font-size: 13px;
line-height: 1.6;
}
.feedback-detail-section {
display: grid;
gap: 8px;
margin-top: 18px;
padding-top: 18px;
border-top: 1px solid rgba(24, 33, 29, 0.08);
}
.feedback-detail-section h2 {
margin: 0;
color: #52605a;
font-size: 13px;
font-weight: 700;
}
.feedback-detail-description,
.feedback-detail-text {
margin: 0;
color: #18211d;
font-size: 14px;
line-height: 1.65;
white-space: pre-wrap;
}
.feedback-detail-images {
display: flex;
flex-wrap: wrap;
gap: 10px;
}
.feedback-detail-image-link {
display: block;
width: 120px;
height: 120px;
}
.feedback-detail-image {
width: 100%;
height: 100%;
border-radius: 12px;
object-fit: cover;
border: 1px solid rgba(24, 33, 29, 0.12);
}
@media (max-width: 720px) {
.feedback-type-grid {
grid-template-columns: 1fr;
}
.feedback-submit-actions {
flex-direction: column-reverse;
}
.feedback-submit-actions button {
width: 100%;
}
.feedback-board-item-trigger {
grid-template-columns: 1fr;
}
.feedback-board-item-meta {
justify-items: start;
grid-auto-flow: column;
grid-auto-columns: max-content;
gap: 10px;
}
.feedback-board-card {
max-height: min(78dvh, 620px);
}
.feedback-panel-close {
min-width: 64px;
min-height: 38px;
padding: 9px 16px;
}
}
+28
View File
@@ -0,0 +1,28 @@
import { Navigate, Route, Routes } from 'react-router-dom';
import { useChat } from '../context/ChatProvider';
import type { PortalUser } from '../types';
import { FeedbackBoardView } from '../components/FeedbackBoardView';
import { FeedbackDetailView } from '../components/FeedbackDetailView';
import { FeedbackSubmitView } from '../components/FeedbackSubmitView';
export function FeedbackRoutes({
user,
onLogout,
}: {
user: PortalUser | null;
onLogout: () => void;
}) {
const { session } = useChat();
return (
<Routes>
<Route
index
element={<FeedbackSubmitView user={user} sessionId={session?.id} onLogout={onLogout} />}
/>
<Route path="list" element={<FeedbackBoardView user={user} onLogout={onLogout} />} />
<Route path=":feedbackId" element={<FeedbackDetailView onLogout={onLogout} />} />
<Route path="*" element={<Navigate to="/feedback" replace />} />
</Routes>
);
}
+1
View File
@@ -30,6 +30,7 @@ export function MindSpaceRoute({
initialCategoryCode={categoryCode}
onBack={() => navigate('/')}
onLogout={onLogout}
onOpenFeedback={() => navigate('/feedback')}
routeSync={{
pushHome: () => navigate('/space'),
pushCategory: (code) => navigate(`/space?category=${code}`),
+75 -4
View File
@@ -107,20 +107,41 @@ export type SessionEvent =
| { type: 'Ping' }
| { type: 'ActiveRequests'; request_ids: string[] };
export type Session = {
export type SessionSummary = {
id: string;
name: string;
working_dir: string;
message_count: number;
created_at?: string;
updated_at?: string;
user_set_name?: boolean;
recipe?: { title?: string | null } | null;
};
export type Session = SessionSummary & {
working_dir: string;
conversation?: Message[] | null;
conversation_page?: SessionConversationPage;
};
export type SessionListPage = {
total?: number;
offset?: number;
limit?: number;
has_more?: boolean;
query?: string;
};
export type SessionConversationPage = {
total?: number;
before?: number;
limit?: number;
returned?: number;
has_more_before?: boolean;
};
export type SessionListResponse = {
sessions: Session[];
sessions: SessionSummary[];
page?: SessionListPage;
};
export type HarnessBootstrapResponse = {
@@ -129,6 +150,14 @@ export type HarnessBootstrapResponse = {
refreshed: boolean;
};
export type UserMemorySyncResponse = {
ok: true;
analyzed: number;
memories: number;
totalMemories: number;
syncedToSession: boolean;
};
export type ChatState = 'idle' | 'loading' | 'connecting' | 'streaming' | 'waiting' | 'error';
export type ToolConfirmation = {
@@ -232,6 +261,47 @@ export type PortalUser = {
publishSkillName?: string;
};
export type FeedbackSubmissionType = 'bug' | 'feature' | 'other';
export type FeedbackImageInput = {
filename: string;
mimeType: string;
dataBase64: string;
};
export type FeedbackContextInput = {
pageUrl?: string;
pagePath?: string;
userAgent?: string;
viewport?: string;
sessionId?: string;
appVersion?: string;
};
export type FeedbackSubmission = {
id: string;
userId?: string;
type: FeedbackSubmissionType;
title: string;
description?: string;
contact?: string | null;
status: 'pending' | 'reviewing' | 'resolved' | 'closed';
images?: FeedbackImageInput[];
imageCount?: number;
context?: FeedbackContextInput;
submitterDisplayName?: string | null;
createdAt: number;
updatedAt?: number;
};
export type FeedbackBoardPage = {
items: FeedbackSubmission[];
page: number;
pageSize: number;
total: number;
totalPages: number;
};
export type MindSpaceQuota = {
quotaBytes: number;
usedBytes: number;
@@ -629,7 +699,8 @@ export type PolicyDefinition = {
export type AuthStatus = {
authenticated: boolean;
mode?: 'user' | 'legacy' | 'none';
mode?: 'user' | 'legacy' | 'none' | 'unavailable';
message?: string;
user?: PortalUser | null;
capabilities?: CapabilityMap;
grantedSkills?: string[];
+28
View File
@@ -0,0 +1,28 @@
import type { FeedbackSubmission, FeedbackSubmissionType } from '../types';
export const FEEDBACK_TYPE_LABELS: Record<FeedbackSubmissionType, string> = {
bug: 'Bug',
feature: '需求',
other: '其他',
};
export const FEEDBACK_STATUS_LABELS: Record<FeedbackSubmission['status'], string> = {
pending: '待处理',
reviewing: '处理中',
resolved: '已解决',
closed: '已关闭',
};
export function formatFeedbackTime(timestamp: number) {
return new Date(timestamp).toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
});
}
export function feedbackImageDataUrl(image: { mimeType: string; dataBase64: string }) {
return `data:${image.mimeType || 'image/jpeg'};base64,${image.dataBase64}`;
}