feat(app): 支持设置 .md 默认打开方式(docs/32)

三条链路:①tauri.conf bundle.fileAssociations 注册 md/markdown,
新版安装包重装后出现在「打开方式」候选;②文件菜单「设为 .md
默认打开方式…」:Rust 命令经 reg.exe 写 HKCU 的 ProgID
NewMarkdown.md/OpenWithProgids/SupportedTypes(免管理员、指向
当前 exe),再打开系统默认应用设置让用户确认一次(Windows 不许
程序直接抢默认);③双击打开:单实例插件转发第二实例参数到待
打开队列+聚焦主窗口,冷启动读 env args,共用 md_files_from_args
过滤;前端 take_pending_open_files 取走逐个打开,只由 main 窗口
消费防子窗口抢文件。命令数 31→33 对账,参数过滤纯函数已抽 crate
实测,vitest 154+2、构建通过;Windows 构建与双击全流程待真机验。
This commit is contained in:
Muse
2026-11-15 13:57:31 +08:00
parent 492051cef0
commit c300b4c8db
10 changed files with 710 additions and 4 deletions

48
docs/32-md文件关联.md Normal file
View File

@@ -0,0 +1,48 @@
# docs/32 .md 文件关联(默认打开方式)
2026-11-15 用户要求:「支持设置 md 格式的打开方式」。目标:在资源
管理器双击 .md 能用本程序打开,且程序内能发起设置。三条链路:
## 1. 安装器注册(tauri.conf.json)
`bundle.fileAssociations` 增加 md/markdown(name=NewMarkdown,
role=Editor)。NSIS/MSI 安装时写入关联注册信息,本程序出现在
「打开方式」候选里,用户在打开方式对话框勾「始终」或在系统设置
里选一次即设为默认。注意:需用新版安装包**重装一次**才生效。
## 2. 程序内发起设置(文件菜单)
「文件 → 设为 .md 默认打开方式…」(仅桌面模式):Rust 命令
`register_file_association` 用 reg.exe 写当前用户注册表(免管理员):
ProgID `NewMarkdown.md`(类型名、打开命令 `"<exe>" "%1"`、图标)、
.md/.markdown 的 OpenWithProgids、Applications 的 SupportedTypes 与
打开命令——便携版/未走安装器的场景也能注册,且指向当前 exe 路径。
写完后打开 `ms-settings:defaultapps` 并弹窗说明:**Windows 不允许
程序直接抢占默认**(UserChoice 带哈希校验),最后一步必须由用户
在设置里把 .md 选为 new-markdown,只需一次。
## 3. 双击真正打开文件
- 单实例插件(tauri-plugin-single-instance,须最先注册):程序已
运行时双击第二个文件不再起新进程,第二实例的参数经回调转入
待打开队列并发 `pending-open-files` 事件,同时聚焦主窗口。
- 冷启动:setup 里读 `std::env::args`,与第二实例共用纯函数
`md_files_from_args` 过滤(跳过开关项、只收存在的 .md/.markdown),
存入 `PendingOpen` 队列。
- 前端:新命令 `take_pending_open_files` 取走队列并逐个 openPath。
主窗口在启动流程末尾拉一次(冷启动),并监听事件随时拉(热转
发)。**只由 label=main 的窗口消费**:子窗口(?file= 打开的)跑
同一套 App,不拦会把文件抢到子窗口里。
命令总数 31 → 33。
## 验证
- 参数过滤纯函数抽入独立 crate 实测:开关项/.txt/不存在的文件被
排除,.md 与 .markdown 存在文件入选。
- rustfmt 语法解析通过;命令数对账 33=33;tsc 0;vitest 全过
(InfoModal 新增 1 例);生产构建通过。
- 沙箱验不了(待真机):Windows 完整构建(含单实例插件与
cfg(windows) 的 reg.exe 分支)、安装包关联注册实效、双击冷启动
打开、已运行双击第二个文件转发到主窗口、菜单项注册后在系统
设置里选默认全流程。

463
src-tauri/Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -14,6 +14,7 @@ tauri-build = { version = "2", features = [] }
[dependencies] [dependencies]
tauri = { version = "2", features = [] } tauri = { version = "2", features = [] }
tauri-plugin-dialog = "2" tauri-plugin-dialog = "2"
tauri-plugin-single-instance = "2"
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"
encoding_rs = "0.8" encoding_rs = "0.8"

View File

@@ -941,6 +941,76 @@ pub async fn s3_upload_image(
.map_err(|e| format!("上传任务失败:{e}"))? .map_err(|e| format!("上传任务失败:{e}"))?
} }
/// 取走待打开文件队列(docs/32):启动参数/第二实例双击转来的 .md 文件。
#[tauri::command]
pub fn take_pending_open_files(state: tauri::State<'_, crate::PendingOpen>) -> Vec<String> {
match state.0.lock() {
Ok(mut queue) => std::mem::take(&mut *queue),
Err(_) => Vec::new(),
}
}
/// 把本程序注册为 .md/.markdown 的候选打开方式(docs/32),并打开 Windows
/// 「默认应用」设置页让用户确认。Windows 不允许程序直接抢占默认(UserChoice
/// 带校验),应用能做的是注册候选 + 引导用户点一次。
#[tauri::command]
pub async fn register_file_association() -> Result<String, String> {
tauri::async_runtime::spawn_blocking(register_file_association_impl)
.await
.map_err(|e| format!("注册任务失败:{e}"))?
}
#[cfg(not(windows))]
fn register_file_association_impl() -> Result<String, String> {
Err("文件关联设置仅支持 Windows。".into())
}
#[cfg(windows)]
fn register_file_association_impl() -> Result<String, String> {
use std::os::windows::process::CommandExt;
use std::process::Command;
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
let exe = std::env::current_exe().map_err(|e| format!("无法定位程序位置:{e}"))?;
let exe_s = exe.to_string_lossy().to_string();
let exe_name = exe
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_else(|| "new-markdown.exe".into());
let prog_id = "NewMarkdown.md";
let open_cmd = format!("\"{exe_s}\" \"%1\"");
let mut failures: Vec<String> = Vec::new();
let mut reg = |args: &[&str]| {
match Command::new("reg.exe")
.args(args)
.creation_flags(CREATE_NO_WINDOW)
.status()
{
Ok(st) if st.success() => {}
Ok(st) => failures.push(format!("reg {:?} 退出码 {}", args.get(1), st.code().unwrap_or(-1))),
Err(e) => failures.push(format!("reg 执行失败:{e}")),
}
};
// ProgID:类型名、打开命令、图标
reg(&["add", &format!(r"HKCU\Software\Classes\{prog_id}"), "/ve", "/d", "Markdown 文档", "/f"]);
reg(&["add", &format!(r"HKCU\Software\Classes\{prog_id}\shell\open\command"), "/ve", "/d", &open_cmd, "/f"]);
reg(&["add", &format!(r"HKCU\Software\Classes\{prog_id}\DefaultIcon"), "/ve", "/d", &format!("\"{exe_s}\",0"), "/f"]);
for ext in [".md", ".markdown"] {
// 扩展名的候选列表 + 应用的 SupportedTypes(出现在「打开方式」里)
reg(&["add", &format!(r"HKCU\Software\Classes\{ext}\OpenWithProgids"), "/v", prog_id, "/t", "REG_SZ", "/d", "", "/f"]);
reg(&["add", &format!(r"HKCU\Software\Classes\Applications\{exe_name}\SupportedTypes"), "/v", ext, "/t", "REG_SZ", "/d", "", "/f"]);
}
reg(&["add", &format!(r"HKCU\Software\Classes\Applications\{exe_name}\shell\open\command"), "/ve", "/d", &open_cmd, "/f"]);
if !failures.is_empty() {
return Err(format!("注册表写入失败:{}", failures.join(";")));
}
// 打开系统「默认应用」设置,用户在那里把 .md 选成本程序(只需一次)
let _ = Command::new("explorer.exe")
.arg("ms-settings:defaultapps")
.creation_flags(CREATE_NO_WINDOW)
.spawn();
Ok("已把 new-markdown 注册为 .md / .markdown 的候选打开方式,并打开了 Windows「默认应用」设置。请在设置里找到 .md,把默认应用选为 new-markdown(Windows 要求手动确认一次)。".into())
}
#[tauri::command] #[tauri::command]
pub async fn s3_upload_base64( pub async fn s3_upload_base64(
config: crate::s3::S3Config, config: crate::s3::S3Config,

View File

@@ -4,11 +4,28 @@ mod search;
mod storage; mod storage;
use std::sync::Mutex; use std::sync::Mutex;
use tauri::Manager; use tauri::{Emitter, Manager};
/// 应用数据库句柄(阶段 4):连接在启动时打开并建表,命令层经 State 访问。 /// 应用数据库句柄(阶段 4):连接在启动时打开并建表,命令层经 State 访问。
pub struct AppDb(pub Mutex<rusqlite::Connection>); pub struct AppDb(pub Mutex<rusqlite::Connection>);
/// 待打开文件队列(docs/32):启动参数与第二实例转发的 .md 文件先存这里,
/// 前端就绪后经 take_pending_open_files 命令取走打开。
pub struct PendingOpen(pub Mutex<Vec<String>>);
/// 从命令行参数里挑出要打开的 Markdown 文件:跳过开关项,只收扩展名
/// .md/.markdown 且确实存在的文件。
pub fn md_files_from_args<I: IntoIterator<Item = String>>(args: I) -> Vec<String> {
args.into_iter()
.filter(|a| !a.starts_with('-'))
.filter(|a| {
let lower = a.to_lowercase();
(lower.ends_with(".md") || lower.ends_with(".markdown"))
&& std::path::Path::new(a).is_file()
})
.collect()
}
/// 打开数据库并建表:WAL 降低并发写冲突,busy_timeout 让多开实例的写操作排队而非直接报错。 /// 打开数据库并建表:WAL 降低并发写冲突,busy_timeout 让多开实例的写操作排队而非直接报错。
fn open_and_init(path: &std::path::Path) -> Result<rusqlite::Connection, String> { fn open_and_init(path: &std::path::Path) -> Result<rusqlite::Connection, String> {
let conn = rusqlite::Connection::open(path).map_err(|e| e.to_string())?; let conn = rusqlite::Connection::open(path).map_err(|e| e.to_string())?;
@@ -22,6 +39,23 @@ fn open_and_init(path: &std::path::Path) -> Result<rusqlite::Connection, String>
#[cfg_attr(mobile, tauri::mobile_entry_point)] #[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() { pub fn run() {
tauri::Builder::default() tauri::Builder::default()
// 单实例必须最先注册:双击第二个 .md 时不再起新进程,把文件转给
// 已运行的窗口打开(docs/32)。
.plugin(tauri_plugin_single_instance::init(|app, argv, _cwd| {
let files = md_files_from_args(argv);
if !files.is_empty() {
if let Some(state) = app.try_state::<PendingOpen>() {
if let Ok(mut queue) = state.0.lock() {
queue.extend(files);
}
}
let _ = app.emit("pending-open-files", ());
}
if let Some(w) = app.get_webview_window("main") {
let _ = w.unminimize();
let _ = w.set_focus();
}
}))
.plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_dialog::init())
.setup(|app| { .setup(|app| {
let data_dir = app.path().app_data_dir().expect("app data dir"); let data_dir = app.path().app_data_dir().expect("app data dir");
@@ -50,6 +84,9 @@ pub fn run() {
} }
}; };
app.manage(AppDb(Mutex::new(conn))); app.manage(AppDb(Mutex::new(conn)));
// 冷启动参数(双击 .md 打开本程序时,文件路径在这里)
let initial = md_files_from_args(std::env::args().skip(1));
app.manage(PendingOpen(Mutex::new(initial)));
Ok(()) Ok(())
}) })
.invoke_handler(tauri::generate_handler![ .invoke_handler(tauri::generate_handler![
@@ -70,6 +107,8 @@ pub fn run() {
commands::s3_upload_image, commands::s3_upload_image,
commands::s3_upload_base64, commands::s3_upload_base64,
commands::s3_test_connection, commands::s3_test_connection,
commands::take_pending_open_files,
commands::register_file_association,
commands::delete_to_trash, commands::delete_to_trash,
commands::db_recent_list, commands::db_recent_list,
commands::db_recent_add, commands::db_recent_add,

View File

@@ -1 +1 @@
{"$schema":"https://schema.tauri.app/config/2","app":{"security":{"csp":null},"windows":[{"center":true,"decorations":false,"height":820,"label":"main","minHeight":600,"minWidth":920,"resizable":true,"title":"new-markdown","width":1280,"dragDropEnabled":false}]},"build":{"beforeBuildCommand":"npm run build","beforeDevCommand":"npm run dev","devUrl":"http://localhost:1420","frontendDist":"../dist"},"bundle":{"active":true,"category":"Productivity","windows":{"nsis":{"languages":["SimpChinese"],"displayLanguageSelector":false},"wix":{"language":"zh-CN"}},"icon":["icons/32x32.png","icons/128x128.png","icons/128x128@2x.png","icons/icon.ico","icons/icon.png"],"shortDescription":"Windows Markdown 所见即所得编辑器","targets":["nsis","msi"]},"identifier":"app.newmarkdown.desktop","productName":"new-markdown","version":"0.1.0"} {"$schema":"https://schema.tauri.app/config/2","app":{"security":{"csp":null},"windows":[{"center":true,"decorations":false,"height":820,"label":"main","minHeight":600,"minWidth":920,"resizable":true,"title":"new-markdown","width":1280,"dragDropEnabled":false}]},"build":{"beforeBuildCommand":"npm run build","beforeDevCommand":"npm run dev","devUrl":"http://localhost:1420","frontendDist":"../dist"},"bundle":{"active":true,"category":"Productivity","windows":{"nsis":{"languages":["SimpChinese"],"displayLanguageSelector":false},"wix":{"language":"zh-CN"}},"icon":["icons/32x32.png","icons/128x128.png","icons/128x128@2x.png","icons/icon.ico","icons/icon.png"],"shortDescription":"Windows Markdown 所见即所得编辑器","targets":["nsis","msi"],"fileAssociations":[{"ext":["md","markdown"],"name":"NewMarkdown","description":"Markdown 文档","role":"Editor"}]},"identifier":"app.newmarkdown.desktop","productName":"new-markdown","version":"0.1.0"}

View File

@@ -18,7 +18,7 @@ const SourceEditor = lazy(() => import('./components/SourceEditor'))
import { offsetInMarkdown, type CursorAnchor } from './editor/cursorSync' import { offsetInMarkdown, type CursorAnchor } from './editor/cursorSync'
import { insertPlainText, insertLinkText } from './editor/paste' import { insertPlainText, insertLinkText } from './editor/paste'
import { isImeComposing } from './utils/ime' import { isImeComposing } from './utils/ime'
import { ConfirmSaveModal, AboutModal, ShortcutsModal, LinkModal, ErrorModal , EncodingConvertModal, ImageSizeModal, ImageLinkModal } from './components/Modals' import { ConfirmSaveModal, AboutModal, ShortcutsModal, LinkModal, ErrorModal , EncodingConvertModal, ImageSizeModal, ImageLinkModal, InfoModal } from './components/Modals'
import { tabsReducer, createTab, activeTab, isDirty, type DocTab, type TabsState } from './stores/tabs' import { tabsReducer, createTab, activeTab, isDirty, type DocTab, type TabsState } from './stores/tabs'
import { countWords, countChars, extractOutline, baseName, isPathInside, type OutlineItem } from './utils/text' import { countWords, countChars, extractOutline, baseName, isPathInside, type OutlineItem } from './utils/text'
import { callUndo, callRedo, focusEditor, type CursorInfo } from './editor/editor' import { callUndo, callRedo, focusEditor, type CursorInfo } from './editor/editor'
@@ -29,7 +29,7 @@ import {
pickImageFile, pickAnyFile, pickAndReadWebImage, copyToAssets, saveBytesToAssets, fileToBase64, pickImageFile, pickAnyFile, pickAndReadWebImage, copyToAssets, saveBytesToAssets, fileToBase64,
statFile, createTextFile, createDirPath, renamePath, deleteToTrash, revealInExplorer, type DirEntry, type RecoveryEntry, statFile, createTextFile, createDirPath, renamePath, deleteToTrash, revealInExplorer, type DirEntry, type RecoveryEntry,
pickSaveHtmlFile, pickImportFile, readFileBase64, readBinaryFile, pickAndReadWebImport, pickSaveHtmlFile, pickImportFile, readFileBase64, readBinaryFile, pickAndReadWebImport,
gitStatus, gitInitRepo, gitSetRemote, gitSyncNow, s3UploadImage, s3UploadBase64, type GitStatusInfo, gitStatus, gitInitRepo, gitSetRemote, gitSyncNow, s3UploadImage, s3UploadBase64, takePendingOpenFiles, registerFileAssociation, type GitStatusInfo,
} from './services/platform' } from './services/platform'
import * as store from './services/store' import * as store from './services/store'
import { recoveryKey, planRecovery } from './utils/recovery' import { recoveryKey, planRecovery } from './utils/recovery'
@@ -708,11 +708,47 @@ export default function App() {
// 审计修复:这段原先写在恢复记录的提前 return 之后,无恢复记录时永远执行不到。 // 审计修复:这段原先写在恢复记录的提前 return 之后,无恢复记录时永远执行不到。
const startupFile = new URLSearchParams(window.location.search).get('file') const startupFile = new URLSearchParams(window.location.search).get('file')
if (!cancelled && startupFile) void openPath(startupFile) if (!cancelled && startupFile) void openPath(startupFile)
// 双击 .md 冷启动:文件路径经启动参数进 Rust 的待打开队列(docs/32)。
// 只由主窗口消费——子窗口(?file= 打开的)也在跑同一套启动流程,
// 不拦会把队列里的文件抢去自己窗口打开。
if (!cancelled && tauriMode) {
try {
const { getCurrentWindow } = await import('@tauri-apps/api/window')
if (getCurrentWindow().label === 'main') {
const pending = await takePendingOpenFiles()
for (const p of pending) { if (!cancelled) await openPath(p) }
}
} catch { /* 队列不可用时不影响正常启动 */ }
}
})() })()
return () => { cancelled = true } return () => { cancelled = true }
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, []) }, [])
// 第二个文件双击转来时(单实例转发):Rust 发事件,这里取队列打开(docs/32)
useEffect(() => {
if (!tauriMode) return
let unlisten: (() => void) | undefined
let cancelled = false
void (async () => {
try {
const { getCurrentWindow } = await import('@tauri-apps/api/window')
if (getCurrentWindow().label !== 'main') return // 只由主窗口消费(理由同冷启动)
const { listen } = await import('@tauri-apps/api/event')
const un = await listen('pending-open-files', async () => {
try {
const pending = await takePendingOpenFiles()
for (const p of pending) await openPath(p)
} catch { /* 同上 */ }
})
if (cancelled) un()
else unlisten = un
} catch { /* 事件不可用时退化为仅冷启动打开 */ }
})()
return () => { cancelled = true; unlisten?.() }
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [tauriMode])
useEffect(() => { useEffect(() => {
if (workspaceRoot) { if (workspaceRoot) {
store.addWorkspace(workspaceRoot) store.addWorkspace(workspaceRoot)
@@ -1090,6 +1126,8 @@ export default function App() {
/** 右键图片的「超链接」目标(图片模式链接弹窗)与「调整大小」目标(docs/31) */ /** 右键图片的「超链接」目标(图片模式链接弹窗)与「调整大小」目标(docs/31) */
const [imageLinkTarget, setImageLinkTarget] = useState<{ tabId: string; el: HTMLElement; src: string; href: string } | null>(null) const [imageLinkTarget, setImageLinkTarget] = useState<{ tabId: string; el: HTMLElement; src: string; href: string } | null>(null)
const [imageSizeTarget, setImageSizeTarget] = useState<{ tabId: string; el: HTMLElement; width: string | null } | null>(null) const [imageSizeTarget, setImageSizeTarget] = useState<{ tabId: string; el: HTMLElement; width: string | null } | null>(null)
/** 文件关联注册后的引导文案(docs/32) */
const [assocMsg, setAssocMsg] = useState<string | null>(null)
const openEditorMenu = (e: React.MouseEvent) => { const openEditorMenu = (e: React.MouseEvent) => {
const target = e.target as HTMLElement const target = e.target as HTMLElement
const inEditor = sourceMode const inEditor = sourceMode
@@ -1586,6 +1624,8 @@ export default function App() {
{ divider: true }, { divider: true },
{ label: '上传文档图片到 S3…', disabled: !tab || sourceMode, onClick: () => void runBatchImageUpload() }, { label: '上传文档图片到 S3…', disabled: !tab || sourceMode, onClick: () => void runBatchImageUpload() },
{ label: 'S3 上传设置…', onClick: () => setS3SettingsOpen(true) }, { label: 'S3 上传设置…', onClick: () => setS3SettingsOpen(true) },
{ divider: true },
{ label: '设为 .md 默认打开方式…', disabled: !tauriMode, onClick: () => { void (async () => { try { setAssocMsg(await registerFileAssociation()) } catch (e) { setError(e instanceof Error ? e.message : String(e)) } })() } },
], ],
}, },
{ {
@@ -2091,6 +2131,7 @@ export default function App() {
}} }}
/> />
)} )}
{assocMsg && <InfoModal title="文件关联" message={assocMsg} onClose={() => setAssocMsg(null)} />}
{imageSizeTarget && ( {imageSizeTarget && (
<ImageSizeModal <ImageSizeModal
current={imageSizeTarget.width} current={imageSizeTarget.width}

View File

@@ -260,6 +260,16 @@ export function ImageLinkModal({ src, linkHref, onSubmit, onCancel }: {
) )
} }
export function InfoModal({ title, message, onClose }: { title: string; message: string; onClose: () => void }) {
return (
<Shell onCancel={onClose}>
<h3>{title}</h3>
<p>{message}</p>
<div className="modal-actions"><button className="primary" onClick={onClose}>知道了</button></div>
</Shell>
)
}
export function ErrorModal({ message, onClose }: { message: string; onClose: () => void }) { export function ErrorModal({ message, onClose }: { message: string; onClose: () => void }) {
return ( return (
<Shell onCancel={onClose}> <Shell onCancel={onClose}>

View File

@@ -366,6 +366,16 @@ export async function s3UploadImage(config: S3ConfigPayload, filePath: string, f
return invoke<string>('s3_upload_image', { config, filePath, fileName }) return invoke<string>('s3_upload_image', { config, filePath, fileName })
} }
/** 取走待打开文件队列:启动参数/双击第二个文件转来的 .md 路径(docs/32)。 */
export async function takePendingOpenFiles(): Promise<string[]> {
return invoke<string[]>('take_pending_open_files')
}
/** 注册 .md/.markdown 文件关联并打开系统默认应用设置,返回引导文案(docs/32)。 */
export async function registerFileAssociation(): Promise<string> {
return invoke<string>('register_file_association')
}
/** 上传文档内嵌的 base64 图片(已剥 data: 前缀的纯 base64),返回回填地址。 */ /** 上传文档内嵌的 base64 图片(已剥 data: 前缀的纯 base64),返回回填地址。 */
export async function s3UploadBase64(config: S3ConfigPayload, base64: string, fileName: string): Promise<string> { export async function s3UploadBase64(config: S3ConfigPayload, base64: string, fileName: string): Promise<string> {
return invoke<string>('s3_upload_base64', { config, base64, fileName }) return invoke<string>('s3_upload_base64', { config, base64, fileName })

24
tests/info-modal.test.tsx Normal file
View File

@@ -0,0 +1,24 @@
// 通用信息弹窗(docs/32 文件关联引导用):标题/正文渲染,点「知道了」关闭。
import { describe, it, expect, vi } from 'vitest'
import { createRoot } from 'react-dom/client'
import { act } from 'react-dom/test-utils'
import { InfoModal } from '../src/components/Modals'
describe('InfoModal', () => {
it('渲染标题与正文,点知道了回调关闭', async () => {
const container = document.createElement('div')
document.body.appendChild(container)
const root = createRoot(container)
const onClose = vi.fn()
await act(async () => {
root.render(<InfoModal title="文件关联" message="已注册为候选打开方式。" onClose={onClose} />)
})
expect(container.textContent).toContain('文件关联')
expect(container.textContent).toContain('已注册为候选打开方式。')
const btn = [...container.querySelectorAll('button')].find((b) => b.textContent === '知道了')!
await act(async () => { btn.dispatchEvent(new MouseEvent('click', { bubbles: true })) })
expect(onClose).toHaveBeenCalledTimes(1)
await act(async () => { root.unmount() })
container.remove()
})
})