diff --git a/.gitignore b/.gitignore
index e71476e5..06aeaa5c 100644
--- a/.gitignore
+++ b/.gitignore
@@ -68,6 +68,11 @@ ai/agents/AiTaskPlatform/memory/data/
# ===== CodeBuddy 本地规则(个人纪律/项目约定,仅本地,不入库;用户规则在用户目录本就不跟踪)=====
.codebuddy/rules/
+# ===== CodeBuddy 本地过程产物(方案草稿/设计预览图/临时排查脚本,仅本地,不入库)=====
+.codebuddy/plans/
+.codebuddy/preview/
+.codebuddy/tmp/
+
# ===== WorkBuddy 本地记忆/配置(跨会话记忆与自动化配置,仅本地,不入库)=====
.workbuddy/
@@ -174,6 +179,7 @@ ai/kb/kb_structure_cache.json
# ===== Playwright MCP 浏览器工具产物(每次工具调用自动落盘,不入库)=====
.playwright-mcp/
+.playwright-cli/
# ===== 本地临时排查脚本(不入库)=====
_tmp_*.sql
diff --git a/frontend/src/shared/components/__tests__/DiscussionPanel.input.test.tsx b/frontend/src/shared/components/__tests__/DiscussionPanel.input.test.tsx
new file mode 100644
index 00000000..04ef2e4b
--- /dev/null
+++ b/frontend/src/shared/components/__tests__/DiscussionPanel.input.test.tsx
@@ -0,0 +1,102 @@
+// 讨论区输入框「自验」用例:确认输入值在组件内不被任何转义/改写逻辑篡改。
+//
+// 背景(用户反馈:输入评论时先「闪一下」,随后冒出一长串反斜杠):
+// 怀疑点是 DiscussionPanel 的受控 textarea(value={commentText} + onChange)
+// 在输入过程中对内容做了转义/拼接,或 IME 组合输入期间被重置。
+// 本用例锁定「输入框不会自己造出反斜杠」这一事实,为定位到底是代码 bug
+// 还是用户粘贴/输入法侧带入提供判据。
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { render, screen, fireEvent, act } from '@testing-library/react';
+
+// ---- auth store 替身:无权限(不拉 USP 环境列表),头像/名字为空 ----
+vi.mock('@/stores/auth', () => ({
+ useAuthStore: Object.assign(
+ () => ({ username: 'tester', name: '测试', avatarResourceId: null, hasPermission: () => false }),
+ { getState: () => ({ username: 'tester', name: '测试', avatarResourceId: null, hasPermission: () => false }) },
+ ),
+}));
+
+// ---- 只读名单接口不触网 ----
+vi.mock('@/api/taskRead', () => ({
+ fetchCommentReadList: vi.fn().mockResolvedValue([]),
+ reportCommentRead: vi.fn().mockResolvedValue(undefined),
+}));
+
+import DiscussionPanel from '../DiscussionPanel';
+
+const getInput = () => screen.getByRole('textbox') as HTMLTextAreaElement;
+
+describe('DiscussionPanel 输入框:值不被改写', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it('输入含反斜杠的文本:textarea 值与用户输入逐字一致(不增不减反斜杠)', () => {
+ const onSend = vi.fn().mockResolvedValue(true);
+ render();
+ const input = getInput();
+
+ // 典型场景:正文 + 一串反斜杠
+ const typed = '会议结论:库位空调逻辑需要增加预设逻辑,对于库位状态需要加读写锁,\\\\\\\\\\\\';
+ fireEvent.change(input, { target: { value: typed } });
+
+ expect(input.value).toBe(typed);
+ expect(input.value.match(/\\/g)?.length).toBe(typed.match(/\\/g)?.length);
+ });
+
+ it('连续多次输入(含 IME 组合事件):不会累积出额外字符', () => {
+ const onSend = vi.fn().mockResolvedValue(true);
+ render();
+ const input = getInput();
+
+ fireEvent.compositionStart(input);
+ fireEvent.change(input, { target: { value: '会议结论' } });
+ fireEvent.compositionUpdate(input, { data: '会议结论' });
+ fireEvent.compositionEnd(input, { data: '会议结论' });
+ expect(input.value).toBe('会议结论');
+
+ fireEvent.change(input, { target: { value: '会议结论:库位空调逻辑需要增加预设逻辑' } });
+ expect(input.value).toBe('会议结论:库位空调逻辑需要增加预设逻辑');
+
+ fireEvent.change(input, { target: { value: '会议结论:库位空调逻辑需要增加预设逻辑,\\\\' } });
+ expect(input.value).toBe('会议结论:库位空调逻辑需要增加预设逻辑,\\\\');
+ expect(input.value.match(/\\/g)?.length).toBe(2);
+ });
+
+ it('发送出去的正文与输入框内容逐字一致(后端 JSON 编码不体现在界面文本上)', async () => {
+ const onSend = vi.fn().mockResolvedValue(true);
+ render();
+ const input = getInput();
+
+ const typed = '路径 C:\\logs\\a.txt,反斜杠 \\\\';
+ fireEvent.change(input, { target: { value: typed } });
+
+ await act(async () => {
+ // tdesign Button 不透传 aria-label,这里按发送按钮的类名定位
+ const sendBtn = document.querySelector('.detail-chat-send') as HTMLButtonElement;
+ fireEvent.click(sendBtn);
+ });
+
+ expect(onSend).toHaveBeenCalledTimes(1);
+ const sentText = onSend.mock.calls[0][0] as string;
+ expect(sentText).toBe(typed);
+ expect(sentText.match(/\\/g)?.length).toBe(4);
+ });
+
+ it('「闪一下」的代码机制:超 500 字时输入框内容被整段替换为 #clipboard(N行) 记号', () => {
+ const onSend = vi.fn().mockResolvedValue(true);
+ render();
+ const input = getInput();
+
+ // 600 字、无换行 → 记号按「行数」命名,故为 1 行
+ const long = '库'.repeat(600);
+ fireEvent.change(input, { target: { value: long } });
+
+ // 输入框被换成记号(= 用户肉眼看到的「闪一下、内容突变」)
+ expect(input.value).toBe('#clipboard(1行)');
+ // 原文进了待发送附件,不丢内容(输入框 + 附件芯片各一处同名记号)
+ expect(screen.getAllByText('#clipboard(1行)').length).toBe(2);
+ // 超限提示可见
+ expect(screen.getByText(/超出 500 字限制/)).toBeInTheDocument();
+ });
+});