feat(feedback): 新增用户反馈提交、分页列表与语音描述
支持 Bug/需求提交、截图与聊天同款语音输入,全站反馈分页浏览与详情页,并从聊天与空间页提供入口。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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) => {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { TKMindAvatar } from './TKMindAvatar';
|
||||
|
||||
export function FeedbackPageHeader({
|
||||
title,
|
||||
onBack,
|
||||
onLogout,
|
||||
}: {
|
||||
title: string;
|
||||
onBack: () => void;
|
||||
onLogout?: () => void;
|
||||
}) {
|
||||
return (
|
||||
<header className="feedback-submit-header">
|
||||
<button type="button" className="ghost-btn" onClick={onBack}>
|
||||
返回
|
||||
</button>
|
||||
<div className="feedback-submit-header-brand">
|
||||
<TKMindAvatar size="sm" />
|
||||
<span>{title}</span>
|
||||
</div>
|
||||
{onLogout ? (
|
||||
<button type="button" className="ghost-btn logout-btn" onClick={onLogout}>
|
||||
登出
|
||||
</button>
|
||||
) : (
|
||||
<span aria-hidden="true" />
|
||||
)}
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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[];
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
@@ -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 });
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
Reference in New Issue
Block a user