Sync inserted AI regions immediately
- Track report editor AI regions in React state instead of only scanning contentEditable DOM during render. - Observe editor AI region mutations and refresh the AI writing target dropdown without requiring page navigation or refresh. - Select newly inserted AI regions immediately after insertion and keep a live DOM fallback for generation. - Harden AI region insertion so it still appends the region if execCommand has no active editor selection. - Escape AI region names before injecting template HTML and add an accessible label for the insert button. - Add Playwright coverage for inserting an AI region and seeing it immediately in the AI writing dropdown. - Update report editor, feature, progress, testing, and AGENTS documentation for AI region synchronization.
This commit is contained in:
@@ -343,7 +343,7 @@ PostgreSQL 数据模型。当前覆盖 `Tenant`、`Department`、`User`、`UserS
|
|||||||
- 报告管理按角色过滤
|
- 报告管理按角色过滤
|
||||||
- 管理员本部门报告范围和医生本人报告范围
|
- 管理员本部门报告范围和医生本人报告范围
|
||||||
- 模板可用范围,含部门模板和医生个人模板
|
- 模板可用范围,含部门模板和医生个人模板
|
||||||
- Playwright E2E 通过真实后端 API seed 覆盖登录、报告权限、报告修订版本、医生个人模板、模板管理新增保存、路由守卫和审计日志
|
- Playwright E2E 通过真实后端 API seed 覆盖登录、报告权限、报告修订版本、报告 AI 区域同步、医生个人模板、模板管理新增保存、路由守卫和审计日志
|
||||||
- 后端权限策略覆盖报告、模板、用户管理和管理员创建规则
|
- 后端权限策略覆盖报告、模板、用户管理和管理员创建规则
|
||||||
- 后端 Dashboard 统计按角色范围过滤
|
- 后端 Dashboard 统计按角色范围过滤
|
||||||
- 后端报告 metadata 兼容映射和 `ReportMedia` 视频/关键帧组装
|
- 后端报告 metadata 兼容映射和 `ReportMedia` 视频/关键帧组装
|
||||||
|
|||||||
@@ -41,7 +41,7 @@
|
|||||||
| 视频上传 | 真实集成 | 使用浏览器 File API 和对象 URL即时预览,并优先通过 `/api/files` 以 `kind = VIDEO` 写入后端文件资源。 |
|
| 视频上传 | 真实集成 | 使用浏览器 File API 和对象 URL即时预览,并优先通过 `/api/files` 以 `kind = VIDEO` 写入后端文件资源。 |
|
||||||
| 自动抽帧 | 真实集成 | 使用 `<video>` + `<canvas>` 按百分比截取 JPEG,关键帧优先通过 `/api/files` 以 `kind = FRAME` 写入后端文件资源。 |
|
| 自动抽帧 | 真实集成 | 使用 `<video>` + `<canvas>` 按百分比截取 JPEG,关键帧优先通过 `/api/files` 以 `kind = FRAME` 写入后端文件资源。 |
|
||||||
| 关键帧插入 | 真实集成 | 关键帧可点击插入或拖入图片占位符;上传成功后编辑器会把插入图片从 Data URL 替换为受控文件 URL。 |
|
| 关键帧插入 | 真实集成 | 关键帧可点击插入或拖入图片占位符;上传成功后编辑器会把插入图片从 Data URL 替换为受控文件 URL。 |
|
||||||
| AI 辅助撰写 | 真实集成 | 前端调用 `/api/ai/chat`,后端使用全局共用 Provider Key 代理 OpenAI 兼容 `/chat/completions`;需要有效 Provider 配置、模型和网络。 |
|
| AI 辅助撰写 | 真实集成 | 前端调用 `/api/ai/chat`,后端使用全局共用 Provider Key 代理 OpenAI 兼容 `/chat/completions`;AI 可编辑区域插入后会立即同步到目标下拉栏;需要有效 Provider 配置、模型和网络。 |
|
||||||
| AI 差异确认 | 真实可用 | 使用 `diff` 生成左右差异,确认后写入 AI 区域。 |
|
| AI 差异确认 | 真实可用 | 使用 `diff` 生成左右差异,确认后写入 AI 区域。 |
|
||||||
| 讯飞语音听写 | 真实集成 | 前端使用麦克风采集音频并连接 `/api/speech/iat`;后端读取讯飞配置、生成鉴权 URL、补齐首帧 APPID/业务参数并转发 IAT 结果。需要浏览器权限、安全上下文(`localhost` 或 HTTPS)、有效配置和网络;Docker 提供 `https://localhost:4443` 演示入口。 |
|
| 讯飞语音听写 | 真实集成 | 前端使用麦克风采集音频并连接 `/api/speech/iat`;后端读取讯飞配置、生成鉴权 URL、补齐首帧 APPID/业务参数并转发 IAT 结果。需要浏览器权限、安全上下文(`localhost` 或 HTTPS)、有效配置和网络;Docker 提供 `https://localhost:4443` 演示入口。 |
|
||||||
| AI/语音密钥管理 | 真实集成 | AI Key 和讯飞 APIKey/APISecret 均由后端代理读取和使用;普通用户读取设置时不返回真实密钥。 |
|
| AI/语音密钥管理 | 真实集成 | AI Key 和讯飞 APIKey/APISecret 均由后端代理读取和使用;普通用户读取设置时不返回真实密钥。 |
|
||||||
|
|||||||
@@ -71,6 +71,8 @@ AI 面板支持两种模式:
|
|||||||
- 对话模式:根据当前报告内容和图片上下文回答问题。
|
- 对话模式:根据当前报告内容和图片上下文回答问题。
|
||||||
- 修改模式:选中 `.ai-region` 后要求模型返回 JSON,其中包含 `reply` 和 `updatedHtml`。
|
- 修改模式:选中 `.ai-region` 后要求模型返回 JSON,其中包含 `reply` 和 `updatedHtml`。
|
||||||
|
|
||||||
|
编辑器会监听正文中的 `.ai-region` 变化;用户插入新的 AI 可编辑区域后,AI 撰写面板的目标下拉栏会立即更新,不需要离开页面或刷新。
|
||||||
|
|
||||||
模型返回 HTML 后,系统会清理换行和 `<br>`,生成差异预览。用户确认后才写入目标 `.ai-content`。
|
模型返回 HTML 后,系统会清理换行和 `<br>`,生成差异预览。用户确认后才写入目标 `.ai-content`。
|
||||||
|
|
||||||
当前 AI 调用使用后端代理:
|
当前 AI 调用使用后端代理:
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
- 报告编辑、模板选择、字段绑定、富文本编辑已实现。
|
- 报告编辑、模板选择、字段绑定、富文本编辑已实现。
|
||||||
- 视频上传、自动抽帧、手动截帧、关键帧插入已实现;视频和关键帧优先上传到后端 Files API。
|
- 视频上传、自动抽帧、手动截帧、关键帧插入已实现;视频和关键帧优先上传到后端 Files API。
|
||||||
- AI 对话、AI 区域改写、差异确认和调用日志已实现;AI 对话已改为后端 `/api/ai/chat` 代理。
|
- AI 对话、AI 区域改写、差异确认和调用日志已实现;AI 对话已改为后端 `/api/ai/chat` 代理。
|
||||||
|
- 报告编辑器会监听正文 AI 区域变化,新插入的 AI 可编辑区域会立即同步到 AI 撰写目标下拉栏。
|
||||||
- 讯飞语音听写入口已实现,并已改为后端 `/api/speech/iat` WebSocket 代理;前端启动前会检查麦克风采集能力和安全上下文。
|
- 讯飞语音听写入口已实现,并已改为后端 `/api/speech/iat` WebSocket 代理;前端启动前会检查麦克风采集能力和安全上下文。
|
||||||
- 报告管理、查看、历史恢复、打印、JSON/PDF 导出已实现。
|
- 报告管理、查看、历史恢复、打印、JSON/PDF 导出已实现。
|
||||||
- 报告 API 已实现列表、详情、创建、保存、完成修订、历史记录和软删除;`ReportManage`、`ReportView`、`ReportEditor` 已优先调用后端,只有开发/显式回退模式下才保留本地回退。
|
- 报告 API 已实现列表、详情、创建、保存、完成修订、历史记录和软删除;`ReportManage`、`ReportView`、`ReportEditor` 已优先调用后端,只有开发/显式回退模式下才保留本地回退。
|
||||||
@@ -85,3 +86,4 @@
|
|||||||
| 2026-05-02 | 新增 Docker HTTPS 演示入口和麦克风访问说明,解决非安全上下文下语音听写不可启动的问题。 |
|
| 2026-05-02 | 新增 Docker HTTPS 演示入口和麦克风访问说明,解决非安全上下文下语音听写不可启动的问题。 |
|
||||||
| 2026-05-02 | 修复模板管理中新建模板后点击保存内容导致模板从列表消失的问题,并补充单元测试和 E2E。 |
|
| 2026-05-02 | 修复模板管理中新建模板后点击保存内容导致模板从列表消失的问题,并补充单元测试和 E2E。 |
|
||||||
| 2026-05-02 | 模板管理新增 HTML 模板包导出/导入,修正右上角 JSON 导出为标准模板包。 |
|
| 2026-05-02 | 模板管理新增 HTML 模板包导出/导入,修正右上角 JSON 导出为标准模板包。 |
|
||||||
|
| 2026-05-02 | 修复报告编辑器新增 AI 可编辑区域后 AI 撰写下拉栏不立即更新的问题,并补充 E2E。 |
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ npm run build
|
|||||||
| E2E 登录流程 | Playwright 验证默认快捷登录进入工作台。 |
|
| E2E 登录流程 | Playwright 验证默认快捷登录进入工作台。 |
|
||||||
| E2E 权限过滤 | Playwright 验证超级管理员、管理员、医生在报告管理页的可见范围。 |
|
| E2E 权限过滤 | Playwright 验证超级管理员、管理员、医生在报告管理页的可见范围。 |
|
||||||
| E2E 报告修订 | Playwright 验证已完成报告再次完成保存后 `revision` 递增并保留历史。 |
|
| E2E 报告修订 | Playwright 验证已完成报告再次完成保存后 `revision` 递增并保留历史。 |
|
||||||
|
| E2E 报告 AI 区域同步 | Playwright 验证报告编辑器中新插入的 AI 可编辑区域会立即出现在 AI 撰写目标下拉栏。 |
|
||||||
| E2E 个人模板 | Playwright 验证医生可保存个人模板且模板仅归属本人。 |
|
| E2E 个人模板 | Playwright 验证医生可保存个人模板且模板仅归属本人。 |
|
||||||
| E2E 模板管理 | Playwright 验证管理员新增模板后点击保存内容,模板仍保留在列表且后端可查询。 |
|
| E2E 模板管理 | Playwright 验证管理员新增模板后点击保存内容,模板仍保留在列表且后端可查询。 |
|
||||||
| E2E 路由守卫和审计日志 | Playwright 验证医生不能直进管理页,超级管理员可查看审计日志。 |
|
| E2E 路由守卫和审计日志 | Playwright 验证医生不能直进管理页,超级管理员可查看审计日志。 |
|
||||||
@@ -87,6 +88,7 @@ AI 第三方接口、讯飞语音上游 WebSocket、麦克风权限和真实视
|
|||||||
| 默认快捷登录 | 已覆盖 | `e2e/login.spec.ts` |
|
| 默认快捷登录 | 已覆盖 | `e2e/login.spec.ts` |
|
||||||
| 报告权限 E2E | 已覆盖 | `e2e/report-permissions.spec.ts` |
|
| 报告权限 E2E | 已覆盖 | `e2e/report-permissions.spec.ts` |
|
||||||
| 报告修订版本 E2E | 已覆盖 | `e2e/report-revision.spec.ts` |
|
| 报告修订版本 E2E | 已覆盖 | `e2e/report-revision.spec.ts` |
|
||||||
|
| 报告 AI 区域同步 E2E | 已覆盖 | `e2e/report-ai-region.spec.ts` |
|
||||||
| 医生个人模板 E2E | 已覆盖 | `e2e/personal-template.spec.ts` |
|
| 医生个人模板 E2E | 已覆盖 | `e2e/personal-template.spec.ts` |
|
||||||
| 模板管理新增保存 E2E | 已覆盖 | `e2e/template-management.spec.ts` |
|
| 模板管理新增保存 E2E | 已覆盖 | `e2e/template-management.spec.ts` |
|
||||||
| 路由守卫和审计日志 E2E | 已覆盖 | `e2e/audit-and-route-guards.spec.ts` |
|
| 路由守卫和审计日志 E2E | 已覆盖 | `e2e/audit-and-route-guards.spec.ts` |
|
||||||
|
|||||||
25
e2e/report-ai-region.spec.ts
Normal file
25
e2e/report-ai-region.spec.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import { expect, test } from '@playwright/test';
|
||||||
|
import { loginByApi, uniqueId } from './helpers';
|
||||||
|
|
||||||
|
test('newly inserted AI region appears in the AI writing target dropdown immediately', async ({ page }) => {
|
||||||
|
await loginByApi(page, '0001');
|
||||||
|
const regionName = `AI区域${uniqueId('region')}`;
|
||||||
|
|
||||||
|
page.on('dialog', async (dialog) => {
|
||||||
|
if (dialog.type() === 'prompt') {
|
||||||
|
await dialog.accept(regionName);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await dialog.accept();
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.goto('/report-editor');
|
||||||
|
await page.getByTitle('插入AI可编辑区域').click();
|
||||||
|
await page.getByRole('button', { name: 'AI撰写' }).click();
|
||||||
|
|
||||||
|
const aiRegionSelect = page.locator('select').filter({
|
||||||
|
has: page.locator('option', { hasText: regionName }),
|
||||||
|
});
|
||||||
|
await expect(aiRegionSelect).toHaveValue(regionName);
|
||||||
|
await expect(page.locator('select option', { hasText: regionName })).toHaveCount(1);
|
||||||
|
});
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useEffect, useState, useRef } from 'react';
|
import React, { useCallback, useEffect, useState, useRef } from 'react';
|
||||||
import { flushSync } from 'react-dom';
|
import { flushSync } from 'react-dom';
|
||||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||||
import Sidebar from '../components/Sidebar';
|
import Sidebar from '../components/Sidebar';
|
||||||
@@ -28,6 +28,30 @@ type AudioWindow = Window & typeof globalThis & {
|
|||||||
webkitAudioContext?: typeof AudioContext;
|
webkitAudioContext?: typeof AudioContext;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type AiRegionOption = {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getAiRegionOptions = (root: HTMLElement | null): AiRegionOption[] => {
|
||||||
|
if (!root) return [];
|
||||||
|
return Array.from(root.querySelectorAll('.ai-region')).map((el) => {
|
||||||
|
const id = (el as HTMLElement).getAttribute('data-ai-id') || '';
|
||||||
|
const title = (el as HTMLElement).getAttribute('data-ai-title') || id;
|
||||||
|
return { id, title };
|
||||||
|
}).filter((region) => region.id);
|
||||||
|
};
|
||||||
|
|
||||||
|
const areAiRegionOptionsEqual = (a: AiRegionOption[], b: AiRegionOption[]) =>
|
||||||
|
a.length === b.length && a.every((region, index) => region.id === b[index]?.id && region.title === b[index]?.title);
|
||||||
|
|
||||||
|
const escapeHtmlAttribute = (value: string) =>
|
||||||
|
value
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>');
|
||||||
|
|
||||||
const getApiErrorMessage = (error: unknown, fallback: string) => {
|
const getApiErrorMessage = (error: unknown, fallback: string) => {
|
||||||
if (error instanceof ApiError) {
|
if (error instanceof ApiError) {
|
||||||
if (error.status === 401) return '登录状态已失效,请重新登录后再保存。';
|
if (error.status === 401) return '登录状态已失效,请重新登录后再保存。';
|
||||||
@@ -87,6 +111,7 @@ export default function ReportEditor() {
|
|||||||
const [isGenerating, setIsGenerating] = useState(false);
|
const [isGenerating, setIsGenerating] = useState(false);
|
||||||
|
|
||||||
const [aiTargetRegion, setAiTargetRegion] = useState<string>('none');
|
const [aiTargetRegion, setAiTargetRegion] = useState<string>('none');
|
||||||
|
const [aiRegions, setAiRegions] = useState<AiRegionOption[]>([]);
|
||||||
const [aiModifyEnabled, setAiModifyEnabled] = useState(true);
|
const [aiModifyEnabled, setAiModifyEnabled] = useState(true);
|
||||||
const [isListening, setIsListening] = useState(false);
|
const [isListening, setIsListening] = useState(false);
|
||||||
const [aiUploadedImages, setAiUploadedImages] = useState<{id: number, dataUrl: string}[]>([]);
|
const [aiUploadedImages, setAiUploadedImages] = useState<{id: number, dataUrl: string}[]>([]);
|
||||||
@@ -201,6 +226,29 @@ export default function ReportEditor() {
|
|||||||
|
|
||||||
const draftKey = currentUser ? `reportEditorDraft_${currentUser.username}` : '';
|
const draftKey = currentUser ? `reportEditorDraft_${currentUser.username}` : '';
|
||||||
|
|
||||||
|
const syncAiRegions = useCallback(() => {
|
||||||
|
const nextRegions = getAiRegionOptions(editorRef.current);
|
||||||
|
setAiRegions(prev => areAiRegionOptionsEqual(prev, nextRegions) ? prev : nextRegions);
|
||||||
|
setAiTargetRegion(prev => {
|
||||||
|
if (nextRegions.length === 0) return 'none';
|
||||||
|
if (prev !== 'none' && nextRegions.some(region => region.id === prev)) return prev;
|
||||||
|
return nextRegions[0].id;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!editorRef.current) return;
|
||||||
|
syncAiRegions();
|
||||||
|
const observer = new MutationObserver(syncAiRegions);
|
||||||
|
observer.observe(editorRef.current, {
|
||||||
|
childList: true,
|
||||||
|
subtree: true,
|
||||||
|
attributes: true,
|
||||||
|
attributeFilter: ['class', 'data-ai-id', 'data-ai-title'],
|
||||||
|
});
|
||||||
|
return () => observer.disconnect();
|
||||||
|
}, [syncAiRegions]);
|
||||||
|
|
||||||
const updatePageHeight = () => {
|
const updatePageHeight = () => {
|
||||||
if (!editorRef.current) return;
|
if (!editorRef.current) return;
|
||||||
const contentHeight = editorRef.current.scrollHeight;
|
const contentHeight = editorRef.current.scrollHeight;
|
||||||
@@ -862,15 +910,26 @@ export default function ReportEditor() {
|
|||||||
|
|
||||||
const insertAiRegion = () => {
|
const insertAiRegion = () => {
|
||||||
const name = window.prompt('请输入 AI 可编辑区域的名称(如:手术步骤、病灶描述):');
|
const name = window.prompt('请输入 AI 可编辑区域的名称(如:手术步骤、病灶描述):');
|
||||||
if (!name || !name.trim()) return;
|
const regionName = name?.trim();
|
||||||
if (editorRef.current?.querySelector(`[data-ai-id="${name}"]`)) {
|
if (!regionName) return;
|
||||||
|
const hasExistingRegion = Array.from(editorRef.current?.querySelectorAll('.ai-region') || [])
|
||||||
|
.some((region) => (region as HTMLElement).getAttribute('data-ai-id') === regionName);
|
||||||
|
if (hasExistingRegion) {
|
||||||
window.alert('该区域名称已存在,请使用其他名称以保证 AI 定位准确。');
|
window.alert('该区域名称已存在,请使用其他名称以保证 AI 定位准确。');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
editorRef.current?.focus();
|
editorRef.current?.focus();
|
||||||
const html = `<div class="ai-region" data-ai-id="${name}" data-ai-title="${name}" style="border: 1px dashed #3b82f6; padding: 16px 12px 12px; margin: 8px 0; position: relative; min-height: 60px; background: #f8fafc; border-radius: 6px;"><div contenteditable="false" style="position: absolute; top: -10px; right: 10px; background: #3b82f6; color: white; font-size: 10px; padding: 2px 8px; border-radius: 12px; z-index: 10; user-select: none; box-shadow: 0 2px 4px rgba(0,0,0,0.1);">${name}-AI可编辑区域</div><div class="ai-content" style="min-height: 20px;">​</div></div><p><br></p>`;
|
const safeRegionName = escapeHtmlAttribute(regionName);
|
||||||
|
const html = `<div class="ai-region" data-ai-id="${safeRegionName}" data-ai-title="${safeRegionName}" style="border: 1px dashed #3b82f6; padding: 16px 12px 12px; margin: 8px 0; position: relative; min-height: 60px; background: #f8fafc; border-radius: 6px;"><div contenteditable="false" style="position: absolute; top: -10px; right: 10px; background: #3b82f6; color: white; font-size: 10px; padding: 2px 8px; border-radius: 12px; z-index: 10; user-select: none; box-shadow: 0 2px 4px rgba(0,0,0,0.1);">${safeRegionName}-AI可编辑区域</div><div class="ai-content" style="min-height: 20px;">​</div></div><p><br></p>`;
|
||||||
document.execCommand('insertHTML', false, html);
|
document.execCommand('insertHTML', false, html);
|
||||||
|
const didInsert = Array.from(editorRef.current?.querySelectorAll('.ai-region') || [])
|
||||||
|
.some((region) => (region as HTMLElement).getAttribute('data-ai-id') === regionName);
|
||||||
|
if (!didInsert) {
|
||||||
|
editorRef.current?.insertAdjacentHTML('beforeend', html);
|
||||||
|
}
|
||||||
if (editorRef.current) contentRef.current = editorRef.current.innerHTML;
|
if (editorRef.current) contentRef.current = editorRef.current.innerHTML;
|
||||||
|
syncAiRegions();
|
||||||
|
setAiTargetRegion(regionName);
|
||||||
saveDraftToStorage();
|
saveDraftToStorage();
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1041,12 +1100,8 @@ export default function ReportEditor() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const checkAiRegions = () => {
|
const checkAiRegions = () => {
|
||||||
if (!editorRef.current) return [];
|
const liveRegions = getAiRegionOptions(editorRef.current);
|
||||||
return Array.from(editorRef.current.querySelectorAll('.ai-region')).map((el) => {
|
return liveRegions.length > 0 ? liveRegions : aiRegions;
|
||||||
const id = (el as HTMLElement).getAttribute('data-ai-id') || '';
|
|
||||||
const title = (el as HTMLElement).getAttribute('data-ai-title') || id;
|
|
||||||
return { id, title };
|
|
||||||
}).filter(r => r.id);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const stripHtml = (html: string): string => {
|
const stripHtml = (html: string): string => {
|
||||||
@@ -2165,7 +2220,7 @@ export default function ReportEditor() {
|
|||||||
<div className="flex gap-1">
|
<div className="flex gap-1">
|
||||||
<button onClick={insertTable} className="w-9 h-9 flex items-center justify-center rounded-lg hover:bg-white text-text-muted hover:text-text-main transition-colors" title="表格"><Table size={16} /></button>
|
<button onClick={insertTable} className="w-9 h-9 flex items-center justify-center rounded-lg hover:bg-white text-text-muted hover:text-text-main transition-colors" title="表格"><Table size={16} /></button>
|
||||||
<button onClick={insertImage} className="w-9 h-9 flex items-center justify-center rounded-lg hover:bg-white text-text-muted hover:text-text-main transition-colors" title="插入图片占位符"><ImageIcon size={16} /></button>
|
<button onClick={insertImage} className="w-9 h-9 flex items-center justify-center rounded-lg hover:bg-white text-text-muted hover:text-text-main transition-colors" title="插入图片占位符"><ImageIcon size={16} /></button>
|
||||||
<button onMouseDown={(e) => e.preventDefault()} onClick={insertAiRegion} className="w-9 h-9 flex items-center justify-center rounded-lg hover:bg-blue-50 text-blue-500 transition-colors" title="插入AI可编辑区域"><Bot size={16} /></button>
|
<button onMouseDown={(e) => e.preventDefault()} onClick={insertAiRegion} className="w-9 h-9 flex items-center justify-center rounded-lg hover:bg-blue-50 text-blue-500 transition-colors" title="插入AI可编辑区域" aria-label="插入AI可编辑区域"><Bot size={16} /></button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -2742,8 +2797,8 @@ export default function ReportEditor() {
|
|||||||
disabled={!aiModifyEnabled}
|
disabled={!aiModifyEnabled}
|
||||||
className="flex-1 w-0 px-2 py-1 border-none text-xs bg-transparent focus:ring-0 font-bold text-slate-700 disabled:opacity-50"
|
className="flex-1 w-0 px-2 py-1 border-none text-xs bg-transparent focus:ring-0 font-bold text-slate-700 disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{checkAiRegions().length > 0 ? (
|
{aiRegions.length > 0 ? (
|
||||||
checkAiRegions().map((r: any) => <option key={r.id} value={r.id}>🎯 {r.title}</option>)
|
aiRegions.map((r) => <option key={r.id} value={r.id}>🎯 {r.title}</option>)
|
||||||
) : (
|
) : (
|
||||||
<option value="none">无可用 AI 区域</option>
|
<option value="none">无可用 AI 区域</option>
|
||||||
)}
|
)}
|
||||||
|
|||||||
Reference in New Issue
Block a user