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
+92
View File
@@ -223,6 +223,7 @@ CREATE TABLE IF NOT EXISTS h5_publish_records (
token_hash CHAR(64) NULL,
token_prefix VARCHAR(16) NULL,
expires_at BIGINT NULL,
user_confirmed_at BIGINT NULL,
published_at BIGINT NOT NULL,
offline_at BIGINT NULL,
status ENUM('draft', 'online', 'expired', 'offline', 'blocked') NOT NULL DEFAULT 'online',
@@ -232,6 +233,7 @@ CREATE TABLE IF NOT EXISTS h5_publish_records (
updated_at BIGINT NOT NULL,
KEY idx_h5_publish_route (url_slug, status, published_at),
KEY idx_h5_publish_page (user_id, page_id, published_at),
KEY idx_h5_publish_auto_private (access_mode, status, user_confirmed_at, expires_at),
UNIQUE KEY uq_h5_publish_token_hash (token_hash),
CONSTRAINT fk_h5_publish_user FOREIGN KEY (user_id) REFERENCES h5_users(id) ON DELETE CASCADE,
CONSTRAINT fk_h5_publish_page FOREIGN KEY (page_id) REFERENCES h5_page_records(id) ON DELETE CASCADE,
@@ -239,6 +241,17 @@ CREATE TABLE IF NOT EXISTS h5_publish_records (
CONSTRAINT fk_h5_publish_scan FOREIGN KEY (security_scan_id) REFERENCES h5_security_scans(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS h5_publication_asset_refs (
publication_id CHAR(36) NOT NULL,
asset_id CHAR(36) NOT NULL,
created_at BIGINT NOT NULL,
PRIMARY KEY (publication_id, asset_id),
KEY idx_asset_id (asset_id),
KEY idx_created_at (created_at),
CONSTRAINT fk_h5_pub_asset_ref_pub FOREIGN KEY (publication_id) REFERENCES h5_publish_records(id) ON DELETE CASCADE,
CONSTRAINT fk_h5_pub_asset_ref_asset FOREIGN KEY (asset_id) REFERENCES h5_assets(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS h5_publication_events (
id CHAR(36) PRIMARY KEY,
publish_id CHAR(36) NOT NULL,
@@ -647,6 +660,7 @@ CREATE TABLE IF NOT EXISTS h5_session_snapshots (
agent_session_id VARCHAR(128) NOT NULL PRIMARY KEY,
user_id CHAR(36) NOT NULL,
name VARCHAR(512) NOT NULL DEFAULT '',
display_title VARCHAR(512) NOT NULL DEFAULT '',
working_dir VARCHAR(1024) NOT NULL DEFAULT '',
created_at_str VARCHAR(64) NOT NULL DEFAULT '',
updated_at_str VARCHAR(64) NOT NULL DEFAULT '',
@@ -997,3 +1011,81 @@ CREATE TABLE IF NOT EXISTS h5_blocked_words (
updated_at BIGINT NOT NULL,
UNIQUE KEY uk_blocked_word (word)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- Shared experience store (etat C): all goosed Worker instances read/write the
-- same learned experience so capability is not siloed per instance. Built on
-- MySQL first with a keyword + recency retrieval; `embedding` is reserved for a
-- later move to PostgreSQL + pgvector without changing the calling contract.
CREATE TABLE IF NOT EXISTS h5_experience (
id CHAR(36) PRIMARY KEY,
scope VARCHAR(64) NOT NULL DEFAULT 'global',
kind VARCHAR(32) NOT NULL DEFAULT 'lesson',
title VARCHAR(255) NOT NULL,
body MEDIUMTEXT NOT NULL,
tags_json JSON NULL,
source_session_id VARCHAR(128) NULL,
source_user_id CHAR(36) NULL,
use_count BIGINT NOT NULL DEFAULT 0,
embedding JSON NULL,
created_at BIGINT NOT NULL,
updated_at BIGINT NOT NULL,
KEY idx_h5_experience_scope_updated (scope, updated_at),
FULLTEXT KEY ftx_h5_experience (title, body)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS h5_conversation_messages (
id CHAR(64) PRIMARY KEY,
user_id CHAR(36) NOT NULL,
agent_session_id VARCHAR(128) NOT NULL,
message_key VARCHAR(128) NOT NULL,
sequence_no INT NOT NULL DEFAULT 0,
role VARCHAR(32) NOT NULL,
text MEDIUMTEXT NOT NULL,
raw_json LONGTEXT NULL,
analyzed_at BIGINT NULL,
created_at BIGINT NOT NULL,
updated_at BIGINT NOT NULL,
UNIQUE KEY uq_h5_conversation_message (agent_session_id, message_key),
KEY idx_h5_conversation_user_created (user_id, created_at),
KEY idx_h5_conversation_user_analyzed (user_id, analyzed_at),
CONSTRAINT fk_h5_conversation_user FOREIGN KEY (user_id) REFERENCES h5_users(id) ON DELETE CASCADE,
CONSTRAINT fk_h5_conversation_session FOREIGN KEY (agent_session_id) REFERENCES h5_user_sessions(agent_session_id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS h5_user_memory_items (
id CHAR(64) PRIMARY KEY,
user_id CHAR(36) NOT NULL,
label ENUM('preference', 'habit', 'interest', 'goal', 'fact', 'experience', 'knowledge') NOT NULL DEFAULT 'fact',
memory_hash CHAR(64) NOT NULL,
memory_text TEXT NOT NULL,
evidence_message_id CHAR(64) NULL,
source_session_id VARCHAR(128) NULL,
confidence DECIMAL(4,3) NOT NULL DEFAULT 0.600,
status ENUM('active', 'archived', 'deleted') NOT NULL DEFAULT 'active',
raw_json JSON NULL,
created_at BIGINT NOT NULL,
updated_at BIGINT NOT NULL,
UNIQUE KEY uq_h5_user_memory_hash (user_id, memory_hash),
KEY idx_h5_user_memory_user_label (user_id, label, updated_at),
KEY idx_h5_user_memory_session (source_session_id),
CONSTRAINT fk_h5_user_memory_user FOREIGN KEY (user_id) REFERENCES h5_users(id) ON DELETE CASCADE,
CONSTRAINT fk_h5_user_memory_evidence FOREIGN KEY (evidence_message_id) REFERENCES h5_conversation_messages(id) ON DELETE SET NULL,
CONSTRAINT fk_h5_user_memory_session FOREIGN KEY (source_session_id) REFERENCES h5_user_sessions(agent_session_id) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS h5_user_feedback (
id CHAR(36) PRIMARY KEY,
user_id CHAR(36) NOT NULL,
type ENUM('bug', 'feature', 'other') NOT NULL,
title VARCHAR(120) NOT NULL,
description TEXT NOT NULL,
contact VARCHAR(120) NULL,
status ENUM('pending', 'reviewing', 'resolved', 'closed') NOT NULL DEFAULT 'pending',
images_json JSON NULL,
context_json JSON NULL,
created_at BIGINT NOT NULL,
updated_at BIGINT NOT NULL,
KEY idx_h5_user_feedback_user_created (user_id, created_at),
KEY idx_h5_user_feedback_status_created (status, created_at),
CONSTRAINT fk_h5_user_feedback_user FOREIGN KEY (user_id) REFERENCES h5_users(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+19
View File
@@ -645,6 +645,25 @@ export async function migrateSchema(pool) {
WHERE s.user_id = u.id AND s.status = 'active'
)
`);
await pool.query(`
CREATE TABLE IF NOT EXISTS h5_user_feedback (
id CHAR(36) PRIMARY KEY,
user_id CHAR(36) NOT NULL,
type ENUM('bug', 'feature', 'other') NOT NULL,
title VARCHAR(120) NOT NULL,
description TEXT NOT NULL,
contact VARCHAR(120) NULL,
status ENUM('pending', 'reviewing', 'resolved', 'closed') NOT NULL DEFAULT 'pending',
images_json JSON NULL,
context_json JSON NULL,
created_at BIGINT NOT NULL,
updated_at BIGINT NOT NULL,
KEY idx_h5_user_feedback_user_created (user_id, created_at),
KEY idx_h5_user_feedback_status_created (status, created_at),
CONSTRAINT fk_h5_user_feedback_user FOREIGN KEY (user_id) REFERENCES h5_users(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
`);
}
export async function initSchema(pool) {
+17
View File
@@ -1072,3 +1072,20 @@ CREATE TABLE IF NOT EXISTS h5_user_memory_items (
CONSTRAINT fk_h5_user_memory_evidence FOREIGN KEY (evidence_message_id) REFERENCES h5_conversation_messages(id) ON DELETE SET NULL,
CONSTRAINT fk_h5_user_memory_session FOREIGN KEY (source_session_id) REFERENCES h5_user_sessions(agent_session_id) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS h5_user_feedback (
id CHAR(36) PRIMARY KEY,
user_id CHAR(36) NOT NULL,
type ENUM('bug', 'feature', 'other') NOT NULL,
title VARCHAR(120) NOT NULL,
description TEXT NOT NULL,
contact VARCHAR(120) NULL,
status ENUM('pending', 'reviewing', 'resolved', 'closed') NOT NULL DEFAULT 'pending',
images_json JSON NULL,
context_json JSON NULL,
created_at BIGINT NOT NULL,
updated_at BIGINT NOT NULL,
KEY idx_h5_user_feedback_user_created (user_id, created_at),
KEY idx_h5_user_feedback_status_created (status, created_at),
CONSTRAINT fk_h5_user_feedback_user FOREIGN KEY (user_id) REFERENCES h5_users(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+281 -35
View File
@@ -77,6 +77,8 @@ import {
resolveChatSaveAnalysis,
resolveStaticHtmlContent,
} from './mindspace-chat-save.mjs';
import { syncPublicHtmlAfterFinish } from './mindspace-public-finish-sync.mjs';
import { syncGeneratedPagesFromPublicAssets } from './mindspace-page-sync.mjs';
import { generateHtmlThumbnail } from './mindspace-thumbnails.mjs';
import { injectOgTags } from './mindspace-og-tags.mjs';
import {
@@ -96,6 +98,7 @@ import {
import { createWechatOAuthService, isWechatUserAgent, loadWechatOAuthConfig } from './wechat-oauth.mjs';
import { createWechatMpService, loadWechatMpConfig } from './wechat-mp.mjs';
import { createScheduleService } from './schedule-service.mjs';
import { createFeedbackService } from './user-feedback.mjs';
import { startScheduleReminderWorker } from './schedule-reminder-worker.mjs';
import { createLlmProviderService, RELAY_BOOTSTRAP } from './llm-providers.mjs';
import { createSessionSnapshotService } from './session-snapshot.mjs';
@@ -202,16 +205,13 @@ const rawUploadBody = express.raw({
type: 'application/octet-stream',
limit: Number(process.env.MINDSPACE_MAX_FILE_BYTES ?? DEFAULT_MAX_FILE_BYTES),
});
const rawUploadBodyImage = express.raw({
type: 'application/octet-stream',
limit: 2 * 1024 * 1024,
});
const wikiAuth = createWikiAuth(path.join(__dirname, PUBLISH_ROOT_DIR, 'wiki-db'));
let legacyAuth = null;
if (ACCESS_PASSWORD) {
if (ACCESS_PASSWORD && !isDatabaseConfigured()) {
legacyAuth = createAuthManager({ password: ACCESS_PASSWORD });
} else if (ACCESS_PASSWORD && isDatabaseConfigured()) {
console.log('H5_ACCESS_PASSWORD ignored: multi-user database auth is configured');
}
let userAuth = null;
@@ -241,6 +241,7 @@ let wechatPayClient = null;
let wechatOAuthService = null;
let wechatMpService = null;
let scheduleService = null;
let feedbackService = null;
let scheduleReminderWorker = null;
let llmProviderService = null;
let wordFilterService = null;
@@ -258,6 +259,7 @@ async function bootstrapUserAuth() {
scheduleService = createScheduleService(pool, {
defaultTimezone: process.env.H5_DEFAULT_TIMEZONE || 'Asia/Shanghai',
});
feedbackService = createFeedbackService(pool);
mindSpace = createMindSpaceService(pool, {
maxFileBytes: Number(process.env.MINDSPACE_MAX_FILE_BYTES ?? DEFAULT_MAX_FILE_BYTES),
aiDailyLimit: Number(process.env.MINDSPACE_FREE_AI_DAILY_LIMIT ?? 10),
@@ -605,6 +607,12 @@ async function bootstrapUserAuth() {
return true;
} catch (err) {
console.error('User auth bootstrap failed:', err);
if (isDatabaseConfigured() && process.env.NODE_ENV === 'production') {
console.error(
'Fatal: database is configured but user auth bootstrap failed; exiting so launchd can retry',
);
process.exit(1);
}
return false;
}
}
@@ -661,6 +669,13 @@ app.get('/auth/status', async (req, res) => {
unrestricted: capabilityState.unrestricted,
});
}
if (isDatabaseConfigured()) {
return res.status(503).json({
authenticated: false,
mode: 'unavailable',
message: '用户认证服务不可用,请稍后重试',
});
}
if (legacyAuth) {
return res.json({
authenticated: legacyAuth.verify(legacySessionToken(req)),
@@ -1059,6 +1074,83 @@ app.get('/auth/usage', async (req, res) => {
res.json({ records });
});
app.post('/auth/feedback', jsonBody, async (req, res) => {
await userAuthReady;
if (!userAuth || !feedbackService) {
return res.status(503).json({ message: '反馈服务未启用' });
}
const me = await userAuth.getMe(userToken(req));
if (!me) return res.status(401).json({ message: '未登录' });
try {
const result = await feedbackService.submit(me.id, {
type: req.body?.type,
title: req.body?.title,
description: req.body?.description,
contact: req.body?.contact,
images: req.body?.images,
context: req.body?.context,
});
res.status(201).json({ feedback: result });
} catch (err) {
const code = err && typeof err === 'object' && 'code' in err ? String(err.code) : '';
if (code === 'invalid_input') {
return res.status(400).json({ message: err instanceof Error ? err.message : '提交内容无效' });
}
console.warn('Submit feedback failed:', err instanceof Error ? err.message : err);
return res.status(500).json({ message: '反馈提交失败,请稍后重试' });
}
});
app.get('/auth/feedback', async (req, res) => {
await userAuthReady;
if (!userAuth || !feedbackService) {
return res.status(503).json({ message: '反馈服务未启用' });
}
const me = await userAuth.getMe(userToken(req));
if (!me) return res.status(401).json({ message: '未登录' });
const limit = Math.min(Math.max(Number(req.query?.limit) || 20, 1), 50);
const items = await feedbackService.listForUser(me.id, { limit });
res.json({ items });
});
app.get('/auth/feedback/board', async (req, res) => {
await userAuthReady;
if (!userAuth || !feedbackService) {
return res.status(503).json({ message: '反馈服务未启用' });
}
const me = await userAuth.getMe(userToken(req));
if (!me) return res.status(401).json({ message: '未登录' });
const page = Math.max(Number(req.query?.page) || 1, 1);
const limit = Math.min(Math.max(Number(req.query?.limit) || 10, 1), 10);
try {
const result = await feedbackService.listAll({ page, limit });
res.json(result);
} catch (err) {
console.warn('List feedback board failed:', err instanceof Error ? err.message : err);
return res.status(500).json({ message: '反馈列表加载失败' });
}
});
app.get('/auth/feedback/:feedbackId', async (req, res) => {
await userAuthReady;
if (!userAuth || !feedbackService) {
return res.status(503).json({ message: '反馈服务未启用' });
}
const me = await userAuth.getMe(userToken(req));
if (!me) return res.status(401).json({ message: '未登录' });
try {
const item = await feedbackService.getById(req.params.feedbackId, me.id);
res.json({ item, isMine: item.userId === me.id });
} catch (err) {
const code = err && typeof err === 'object' && 'code' in err ? String(err.code) : '';
if (code === 'feedback_not_found') {
return res.status(404).json({ message: err instanceof Error ? err.message : '反馈不存在' });
}
console.warn('Get feedback detail failed:', err instanceof Error ? err.message : err);
return res.status(500).json({ message: '反馈详情加载失败' });
}
});
app.get('/auth/notifications', async (req, res) => {
await userAuthReady;
if (!userAuth || !scheduleService) {
@@ -1627,6 +1719,115 @@ api.get('/status', async (_req, res, next) => {
return next();
});
async function ensureUserMemoryCapability(req, res) {
if (!userAuth) {
res.status(503).json({ message: '未启用用户系统' });
return null;
}
const userRow = await userAuth.getUserById(req.currentUser.id);
if (!userRow) {
res.status(404).json({ message: '用户不存在' });
return null;
}
const capabilityState = await userAuth.resolveUserCapabilities(userRow);
if (!capabilityState.unrestricted && !capabilityState.capabilities.memory_store) {
res.status(403).json({ message: '当前账户未开通长期记忆,无法访问该 API' });
return null;
}
return capabilityState;
}
async function loadUserVisibleConversation(sessionId) {
const target = await tkmindProxy.resolveTarget(sessionId);
const upstream = await tkmindProxy.apiFetchTo(target, `/sessions/${encodeURIComponent(sessionId)}`, {
method: 'GET',
});
if (!upstream.ok) {
const message = await upstream.text().catch(() => '');
throw new Error(message || '读取会话失败');
}
const session = await upstream.json();
return (session?.conversation ?? []).filter((message) => message?.metadata?.userVisible);
}
async function syncUserMemoriesIntoSession(userId, sessionId) {
if (!tkmindProxy || !sessionId) return false;
await tkmindProxy.reconcileSessionPolicyForUser(userId, sessionId);
return true;
}
api.post('/user-memory/v1/remember-recent', async (req, res) => {
if (!conversationMemoryService?.isEnabled?.()) {
return res.status(503).json({ message: '长期记忆功能未启用' });
}
if (!tkmindProxy) {
return res.status(503).json({ message: '会话代理尚未就绪' });
}
const capabilityState = await ensureUserMemoryCapability(req, res);
if (!capabilityState) return;
const sessionId = String(req.body?.sessionId ?? '').trim();
if (!sessionId) {
return res.status(400).json({ message: '缺少 sessionId' });
}
const owns = await userAuth.ownsSession(req.currentUser.id, sessionId);
if (!owns) {
return res.status(403).json({ message: '无权访问该会话' });
}
try {
const messages = await loadUserVisibleConversation(sessionId);
const result = await conversationMemoryService.saveAndAnalyze(
sessionId,
req.currentUser.id,
messages,
);
const syncedToSession = await syncUserMemoriesIntoSession(req.currentUser.id, sessionId);
const memories = await conversationMemoryService.listMemories(req.currentUser.id, { limit: 200 });
return res.json({
ok: true,
analyzed: result.analyzed ?? 0,
memories: result.memories ?? 0,
totalMemories: memories.length,
syncedToSession,
});
} catch (err) {
return res.status(500).json({ message: err instanceof Error ? err.message : '保存长期记忆失败' });
}
});
api.post('/user-memory/v1/sync', async (req, res) => {
if (!conversationMemoryService?.isEnabled?.()) {
return res.status(503).json({ message: '长期记忆功能未启用' });
}
const capabilityState = await ensureUserMemoryCapability(req, res);
if (!capabilityState) return;
const sessionId = String(req.body?.sessionId ?? '').trim();
if (!sessionId) {
return res.status(400).json({ message: '缺少 sessionId' });
}
const owns = await userAuth.ownsSession(req.currentUser.id, sessionId);
if (!owns) {
return res.status(403).json({ message: '无权访问该会话' });
}
try {
const result = await conversationMemoryService.analyzeUser(req.currentUser.id);
const syncedToSession = await syncUserMemoriesIntoSession(req.currentUser.id, sessionId);
const memories = await conversationMemoryService.listMemories(req.currentUser.id, { limit: 200 });
return res.json({
ok: true,
analyzed: result.analyzed ?? 0,
memories: result.memories ?? 0,
totalMemories: memories.length,
syncedToSession,
});
} catch (err) {
return res.status(500).json({ message: err instanceof Error ? err.message : '刷新长期记忆失败' });
}
});
api.get('/mindspace/v1/space', async (req, res) => {
if (!mindSpace || !ensureMindSpaceEnabled(res, req)) return;
const space = await mindSpace.getSpace(req.currentUser.id);
@@ -2103,7 +2304,7 @@ api.post('/mindspace/v1/uploads', async (req, res) => {
}
});
api.put('/mindspace/v1/uploads/:uploadId/content', rawUploadBodyImage, async (req, res) => {
api.put('/mindspace/v1/uploads/:uploadId/content', rawUploadBody, async (req, res) => {
if (!mindSpaceAssets) return res.status(503).json({ message: 'MindSpace 未启用' });
try {
const result = await mindSpaceAssets.writeUploadContent(
@@ -2426,25 +2627,23 @@ async function resolveOwnedAssistantMessage(userId, sessionId, messageId) {
return { session, message, content };
}
const SAVE_TARGET_CATEGORIES = new Set(['draft', 'oa', 'private', 'public']);
const SAVE_TARGET_CATEGORIES = new Set(['draft', 'oa', 'public']);
function assertPrivateSaveAllowed(categoryCode, privacyScan, acknowledgedFindingIds) {
if (categoryCode !== 'private') return;
if (!privacyScan.allowed) {
throw Object.assign(new Error('内容含阻断级敏感信息,不能保存到私人区'), {
code: 'security_risk_blocked',
details: { findings: privacyScan.findings },
});
}
if (privacyScan.findings.length === 0) return;
const acknowledged = new Set((acknowledgedFindingIds ?? []).map(String));
const missing = privacyScan.findings.filter((finding) => !acknowledged.has(finding.id));
if (missing.length > 0) {
throw Object.assign(new Error('保存到私人区前需确认敏感信息提示'), {
code: 'private_ack_required',
details: { findings: missing },
});
}
async function syncUserGeneratedPages(userId) {
if (!mindSpacePages || !authPool || !userId) return;
await syncGeneratedPagesFromPublicAssets({
pool: authPool,
pageService: mindSpacePages,
assetService: mindSpaceAssets,
userId,
publishDir: resolvePublishDir(__dirname, { id: userId }),
syncWorkspaceAssets:
mindSpaceAssets && WORKSPACE_MAINTENANCE_ENABLED
? (targetUserId, options) => mindSpaceAssets.syncWorkspaceAssets(targetUserId, options)
: null,
}).catch((error) => {
console.warn('[MindSpace] page sync failed:', error?.message ?? error);
});
}
async function resolveChatSaveBundle(user, h5Root, input = {}) {
@@ -2733,7 +2932,6 @@ api.post('/mindspace/v1/pages/save-from-chat', async (req, res) => {
resolvedHtml = await resolveStaticHtmlContent(analysis).catch(() => null);
}
const privacyScan = scanContent(resolvedHtml?.content ?? source.content);
assertPrivateSaveAllowed(categoryCode, privacyScan, req.body?.acknowledged_finding_ids);
if (categoryCode !== 'draft') {
let buffer;
@@ -2847,6 +3045,7 @@ api.post('/mindspace/v1/pages', async (req, res) => {
api.get('/mindspace/v1/pages', async (req, res) => {
if (!mindSpacePages) return res.status(503).json({ message: 'MindSpace 未启用' });
try {
await syncUserGeneratedPages(req.currentUser.id);
const pages = await mindSpacePages.listPages(req.currentUser.id, {
status: typeof req.query.status === 'string' ? req.query.status : undefined,
});
@@ -3777,14 +3976,42 @@ api.get('/sessions/:sessionId/events', async (req, res, next) => {
if (!owns) {
return res.status(403).json({ message: '无权访问该会话' });
}
// After Finish, async-refresh snapshot so next open is a cache hit.
const onAfterFinish = sessionSnapshotService?.isEnabled()
? (sid, uid) =>
sessionSnapshotService.refresh(sid, uid, async (pathname, init) => {
const target = await tkmindProxy.resolveTarget(sid);
return tkmindProxy.apiFetchTo(target, pathname, init);
})
: null;
// After Finish, refresh the snapshot and persist any newly generated public
// workspace HTML into the asset store before a later restart rebuilds the
// workspace from DB-backed assets only.
const onAfterFinish = async (sid, uid) => {
const apiFetchFn = async (pathname, init) => {
const target = await tkmindProxy.resolveTarget(sid);
return tkmindProxy.apiFetchTo(target, pathname, init);
};
let messages = null;
if (sessionSnapshotService?.isEnabled()) {
await sessionSnapshotService.refresh(sid, uid, apiFetchFn);
messages = (await sessionSnapshotService.get(sid))?.messages ?? null;
} else {
try {
const upstream = await apiFetchFn(`/sessions/${encodeURIComponent(sid)}`, { method: 'GET' });
if (upstream.ok) {
const payload = await upstream.json().catch(() => null);
messages = Array.isArray(payload?.conversation)
? payload.conversation.filter((message) => message?.metadata?.userVisible)
: null;
}
} catch {
messages = null;
}
}
await syncPublicHtmlAfterFinish({
messages,
currentUser: req.currentUser,
publishDir: resolvePublishDir(__dirname, { id: uid }),
syncWorkspaceAssets:
WORKSPACE_MAINTENANCE_ENABLED && mindSpaceAssets
? (userId, options) => mindSpaceAssets.syncWorkspaceAssets(userId, options)
: null,
});
await syncUserGeneratedPages(uid);
};
return tkmindProxy.proxySessionEvents(req, res, sessionId, { onAfterFinish });
});
@@ -4648,6 +4875,14 @@ async function recoverMisplacedPublicHtml(targetDir, resolvedRoot, rest) {
return null;
}
function decodePathSegment(segment) {
try {
return decodeURIComponent(segment);
} catch {
return segment;
}
}
async function serveUserPublishFile(req, res, next) {
const parts = req.path.split('/').filter(Boolean);
if (parts.length < 1) {
@@ -4668,7 +4903,7 @@ async function serveUserPublishFile(req, res, next) {
return;
}
const [username, ...rest] = [dirKey, ...parts.slice(1)];
const [username, ...rest] = [dirKey, ...parts.slice(1).map(decodePathSegment)];
const targetDir = path.join(__dirname, PUBLISH_ROOT_DIR, username);
const resolvedRoot = path.resolve(targetDir);
const filePath = path.join(targetDir, ...rest);
@@ -4817,12 +5052,23 @@ app.get(/^\/MP_verify_[A-Za-z0-9]+\.txt$/, (req, res) => {
res.type('text/plain').sendFile(filePath);
});
app.use('/auth', (_req, res) => {
res.status(404).json({ message: '接口不存在,请重启后端服务(node server.mjs 或 pnpm dev' });
});
app.use('/admin-api', (_req, res) => {
res.status(404).json({ message: '接口不存在,请重启后端服务(node server.mjs 或 pnpm dev' });
});
app.use(express.static(path.join(__dirname, 'dist'), { index: 'index.html' }));
app.get('*', (_req, res) => {
res.sendFile(path.join(__dirname, 'dist', 'index.html'));
});
userAuthReady.then((enabled) => {
if (isDatabaseConfigured() && !enabled && process.env.NODE_ENV === 'production') {
console.error('Refusing to start portal without user auth while database is configured');
process.exit(1);
}
app.listen(PORT, HOST, () => {
console.log(`TKMind H5 @ http://${HOST}:${PORT}`);
console.log(`Proxy -> ${API_TARGETS.join(', ')}`);
+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}`;
}
+235
View File
@@ -0,0 +1,235 @@
import crypto from 'node:crypto';
export const FEEDBACK_TYPES = new Set(['bug', 'feature', 'other']);
export const FEEDBACK_STATUSES = new Set(['pending', 'reviewing', 'resolved', 'closed']);
export const FEEDBACK_MAX_IMAGES = 5;
export const FEEDBACK_MAX_TITLE = 120;
export const FEEDBACK_MAX_DESCRIPTION = 5000;
export const FEEDBACK_MAX_CONTACT = 120;
export const FEEDBACK_MAX_IMAGE_BYTES = Math.floor(1.5 * 1024 * 1024);
function feedbackError(message, code = 'invalid_input') {
const err = new Error(message);
err.code = code;
return err;
}
function normalizeType(value) {
const type = String(value ?? '').trim().toLowerCase();
if (!FEEDBACK_TYPES.has(type)) {
throw feedbackError('请选择反馈类型');
}
return type;
}
function normalizeImages(images) {
if (!Array.isArray(images)) return [];
if (images.length > FEEDBACK_MAX_IMAGES) {
throw feedbackError(`最多上传 ${FEEDBACK_MAX_IMAGES} 张图片`);
}
return images.map((item, index) => {
const mimeType = String(item?.mimeType ?? item?.mime_type ?? '').trim().toLowerCase();
const dataBase64 = String(item?.dataBase64 ?? item?.data_base64 ?? '').trim();
const filename = String(item?.filename ?? item?.name ?? `screenshot-${index + 1}.jpg`).trim();
if (!mimeType.startsWith('image/')) {
throw feedbackError('仅支持图片附件');
}
if (!dataBase64) {
throw feedbackError('图片数据无效');
}
let buffer;
try {
buffer = Buffer.from(dataBase64, 'base64');
} catch {
throw feedbackError('图片数据无效');
}
if (!buffer.length) {
throw feedbackError('图片数据无效');
}
if (buffer.length > FEEDBACK_MAX_IMAGE_BYTES) {
throw feedbackError('单张图片不能超过 1.5MB');
}
return {
filename: filename.slice(0, 120),
mimeType,
sizeBytes: buffer.length,
dataBase64,
};
});
}
function normalizeContext(context) {
if (!context || typeof context !== 'object') return {};
const pageUrl = String(context.pageUrl ?? context.page_url ?? '').trim().slice(0, 500);
const pagePath = String(context.pagePath ?? context.page_path ?? '').trim().slice(0, 300);
const userAgent = String(context.userAgent ?? context.user_agent ?? '').trim().slice(0, 500);
const viewport = String(context.viewport ?? '').trim().slice(0, 64);
const sessionId = String(context.sessionId ?? context.session_id ?? '').trim().slice(0, 128);
const appVersion = String(context.appVersion ?? context.app_version ?? '').trim().slice(0, 64);
const normalized = {};
if (pageUrl) normalized.pageUrl = pageUrl;
if (pagePath) normalized.pagePath = pagePath;
if (userAgent) normalized.userAgent = userAgent;
if (viewport) normalized.viewport = viewport;
if (sessionId) normalized.sessionId = sessionId;
if (appVersion) normalized.appVersion = appVersion;
return normalized;
}
function rowToFeedback(row, { includeImages = true, includeContact = true } = {}) {
if (!row) return null;
let images = [];
let context = {};
try {
images = row.images_json ? JSON.parse(row.images_json) : [];
} catch {
images = [];
}
try {
context = row.context_json ? JSON.parse(row.context_json) : {};
} catch {
context = {};
}
const item = {
id: row.id,
userId: row.user_id,
type: row.type,
title: row.title,
description: row.description,
status: row.status,
context,
createdAt: Number(row.created_at),
updatedAt: Number(row.updated_at),
submitterDisplayName: row.submitter_display_name ?? null,
imageCount: images.length,
};
if (includeContact) {
item.contact = row.contact ?? null;
}
if (includeImages) {
item.images = images;
}
return item;
}
export function createFeedbackService(pool) {
return {
async submit(userId, input = {}) {
const type = normalizeType(input.type);
const title = String(input.title ?? '').trim();
const description = String(input.description ?? '').trim();
const contact = String(input.contact ?? '').trim().slice(0, FEEDBACK_MAX_CONTACT);
if (!title) throw feedbackError('请填写标题');
if (title.length > FEEDBACK_MAX_TITLE) throw feedbackError(`标题不能超过 ${FEEDBACK_MAX_TITLE}`);
if (!description) throw feedbackError('请填写详细描述');
if (description.length > FEEDBACK_MAX_DESCRIPTION) {
throw feedbackError(`描述不能超过 ${FEEDBACK_MAX_DESCRIPTION}`);
}
const images = normalizeImages(input.images);
const context = normalizeContext(input.context);
const now = Date.now();
const id = crypto.randomUUID();
await pool.query(
`INSERT INTO h5_user_feedback
(id, user_id, type, title, description, contact, status, images_json, context_json, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, 'pending', ?, ?, ?, ?)`,
[
id,
userId,
type,
title,
description,
contact || null,
JSON.stringify(images),
JSON.stringify(context),
now,
now,
],
);
return {
id,
type,
title,
status: 'pending',
createdAt: now,
};
},
async listForUser(userId, { limit = 20 } = {}) {
const safeLimit = Math.min(Math.max(Number(limit) || 20, 1), 50);
const [rows] = await pool.query(
`SELECT f.id, f.user_id, f.type, f.title, f.description, f.contact, f.status,
f.images_json, f.context_json, f.created_at, f.updated_at,
u.display_name AS submitter_display_name
FROM h5_user_feedback f
JOIN h5_users u ON u.id = f.user_id
WHERE f.user_id = ?
ORDER BY f.created_at DESC
LIMIT ?`,
[userId, safeLimit],
);
return rows.map((row) => rowToFeedback(row));
},
async listAll({ page = 1, limit = 10 } = {}) {
const safeLimit = Math.min(Math.max(Number(limit) || 10, 1), 10);
const safePage = Math.max(Number(page) || 1, 1);
const offset = (safePage - 1) * safeLimit;
const [[countRow]] = await pool.query(`SELECT COUNT(*) AS total FROM h5_user_feedback`);
const total = Number(countRow?.total ?? 0);
const totalPages = total === 0 ? 0 : Math.ceil(total / safeLimit);
const [rows] = await pool.query(
`SELECT f.id, f.user_id, f.type, f.title, f.description, f.status,
f.images_json, f.context_json, f.created_at, f.updated_at,
u.display_name AS submitter_display_name
FROM h5_user_feedback f
JOIN h5_users u ON u.id = f.user_id
ORDER BY f.created_at DESC
LIMIT ? OFFSET ?`,
[safeLimit, offset],
);
return {
items: rows.map((row) =>
rowToFeedback(row, { includeImages: false, includeContact: false }),
),
page: safePage,
pageSize: safeLimit,
total,
totalPages,
};
},
async getById(feedbackId, viewerUserId) {
const id = String(feedbackId ?? '').trim();
if (!id) throw feedbackError('反馈不存在', 'feedback_not_found');
const [rows] = await pool.query(
`SELECT f.id, f.user_id, f.type, f.title, f.description, f.contact, f.status,
f.images_json, f.context_json, f.created_at, f.updated_at,
u.display_name AS submitter_display_name
FROM h5_user_feedback f
JOIN h5_users u ON u.id = f.user_id
WHERE f.id = ?
LIMIT 1`,
[id],
);
const row = rows[0];
if (!row) throw feedbackError('反馈不存在', 'feedback_not_found');
const isOwner = viewerUserId && row.user_id === viewerUserId;
return rowToFeedback(row, { includeImages: true, includeContact: isOwner });
},
};
}
+162
View File
@@ -0,0 +1,162 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createFeedbackService } from './user-feedback.mjs';
function createMockPool() {
const inserts = [];
return {
inserts,
async query(sql, params) {
if (/INSERT INTO h5_user_feedback/.test(sql)) {
inserts.push(params);
return [{ affectedRows: 1 }];
}
throw new Error(`Unexpected query: ${sql}`);
},
};
}
test('submit feedback stores normalized payload', async () => {
const pool = createMockPool();
const service = createFeedbackService(pool);
const tinyPng = Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==',
'base64',
);
const result = await service.submit('user-1', {
type: 'bug',
title: '页面无法保存',
description: '点击保存后没有反应',
contact: 'test@example.com',
images: [
{
filename: 'screen.png',
mimeType: 'image/png',
dataBase64: tinyPng.toString('base64'),
},
],
context: {
pagePath: '/space/page/abc',
userAgent: 'Mozilla/5.0',
},
});
assert.ok(result.id);
assert.equal(result.type, 'bug');
assert.equal(result.status, 'pending');
assert.equal(pool.inserts.length, 1);
assert.equal(pool.inserts[0][1], 'user-1');
assert.equal(pool.inserts[0][2], 'bug');
});
test('submit feedback rejects invalid type', async () => {
const service = createFeedbackService(createMockPool());
await assert.rejects(
() =>
service.submit('user-1', {
type: 'invalid',
title: 'x',
description: 'y',
}),
/反馈类型/,
);
});
test('submit feedback rejects empty description', async () => {
const service = createFeedbackService(createMockPool());
await assert.rejects(
() =>
service.submit('user-1', {
type: 'feature',
title: '想要导出 PDF',
description: ' ',
}),
/详细描述/,
);
});
test('listAll returns paginated board items without contact or images', async () => {
let queryCount = 0;
const pool = {
async query(sql, params) {
if (/SELECT COUNT\(\*\)/.test(sql)) {
return [[{ total: 23 }]];
}
if (/FROM h5_user_feedback f/.test(sql) && /LIMIT \? OFFSET \?/.test(sql)) {
queryCount += 1;
assert.equal(params[0], 10);
assert.equal(params[1], 10);
return [
[
{
id: 'fb-1',
user_id: 'user-2',
type: 'bug',
title: '按钮无响应',
description: '点击保存没有反应',
status: 'pending',
images_json: JSON.stringify([{ mimeType: 'image/png', dataBase64: 'abc' }]),
context_json: JSON.stringify({ pagePath: '/space' }),
created_at: 1000,
updated_at: 1000,
submitter_display_name: 'Alice',
},
],
];
}
throw new Error(`Unexpected query: ${sql}`);
},
};
const service = createFeedbackService(pool);
const result = await service.listAll({ page: 2, limit: 10 });
assert.equal(queryCount, 1);
assert.equal(result.page, 2);
assert.equal(result.pageSize, 10);
assert.equal(result.total, 23);
assert.equal(result.totalPages, 3);
assert.equal(result.items.length, 1);
assert.equal(result.items[0].submitterDisplayName, 'Alice');
assert.equal(result.items[0].imageCount, 1);
assert.equal(result.items[0].images, undefined);
assert.equal(result.items[0].contact, undefined);
});
test('getById hides contact from non-owner', async () => {
const pool = {
async query(sql, params) {
if (/WHERE f.id = \?/.test(sql)) {
assert.equal(params[0], 'fb-1');
return [
[
{
id: 'fb-1',
user_id: 'user-2',
type: 'bug',
title: '按钮无响应',
description: '详细内容',
contact: 'secret@example.com',
status: 'pending',
images_json: '[]',
context_json: '{}',
created_at: 1000,
updated_at: 1000,
submitter_display_name: 'Alice',
},
],
];
}
throw new Error(`Unexpected query: ${sql}`);
},
};
const service = createFeedbackService(pool);
const item = await service.getById('fb-1', 'viewer-1');
assert.equal(item.title, '按钮无响应');
assert.equal(item.contact, undefined);
const ownItem = await service.getById('fb-1', 'user-2');
assert.equal(ownItem.contact, 'secret@example.com');
});