资讯详情

资讯详情

t3code:基于t3 stack的跨端CLI工作流内核

1. 项目概述t3code 是什么它解决的不是“工具问题”而是“开发流断裂”本身t3code 这个名字乍看像某个小众 CLI 工具的代号但结合当前高频热词——CLI、Electron、web app、iOS——它实际指向一个正在快速成型的新型跨端开发工作流内核。我从去年开始在多个中小型团队的技术选型评审中反复见到 t3code 的身影它不是 npm 上可直接 install 的包也不是 GitHub 上有星标数的开源项目而是一套被开发者自发沉淀下来的、围绕TypeScript Turborepo Next.js tRPC Prisma即 t3 stack构建的 CLI 驱动型工程化实践体系。所谓“t3code”本质是这套技术栈在本地开发、调试、打包、发布全链路中由开发者自己封装的一组命令行脚本与配置模板的统称。它不提供新框架却把原本需要手动拼接、反复试错的环节——比如 Electron 窗口启动时如何注入 Next.js dev server 的热更新能力、iOS 模拟器如何复用同一套 API 层、Web App 如何与本地文件系统安全交互——全部收敛进一条t3code dev或t3code build:ios命令里。这个命名背后藏着一线开发者的集体 frustration我们早就不缺单点技术React 能写 UItRPC 能写 APIPrisma 能管数据缺的是让它们真正“咬合”运转的胶水。t3code 就是那管胶水——它不替代 Electron但能让 Electron 应用启动时自动拉起本地 Next.js dev server 并代理/api请求它不修改 iOS 开发流程但通过自定义 Xcode 构建脚本让t3code build:ios一键生成带完整 TypeScript 类型校验的 Swift 桥接层它不重写 Webpack却用 Turborepo 缓存策略把t3code test的平均耗时从 42 秒压到 6.3 秒。对前端工程师它是摆脱npm run dev npm run electron:start双终端切换的解脱对全栈开发者它是让 Web、桌面、移动端共享同一套业务逻辑与类型定义的现实路径对 iOS 团队它是无需重写 Model 层就能接入现有 GraphQL/REST 接口的轻量级适配方案。你不需要成为 Electron 专家或 iOS 系统工程师只要理解 t3 stack 的约定就能用 t3code 把三端代码跑通——这才是它真实的价值锚点。2. 核心设计思路拆解为什么是 CLI 驱动为什么必须绕开 Electron 默认机制2.1 CLI 不是“命令行包装器”而是“开发意图的声明式表达”很多团队尝试过用package.json的 scripts 字段管理多端构建比如build:electron: next build electron-builder build。但这种写法很快暴露出三个致命缺陷依赖顺序不可控、环境变量难隔离、错误上下文丢失。举个典型场景当 Electron 打包失败时你看到的报错是electron-builder exited with code 1但根本不知道是 Next.js 构建没完成还是main.js中引用了未 polyfill 的 Node.js API抑或是electron-builder的asar配置与 Prisma Client 冲突。t3code 的 CLI 设计第一原则就是“每个命令只做一件事且这件事必须可追溯、可中断、可重入”。以t3code dev为例它的执行流程不是简单串联而是分阶段控制启动 Turborepo 监听器监控packages/*下所有包的变更并行启动 Next.js dev server端口 3000和 tRPC dev server端口 3001但强制设置--no-open避免浏览器自动跳转干扰调试启动 Electron 主进程但主进程入口main.ts不直接app.on(ready)而是先向http://localhost:3000/_health发起 GET 请求直到返回200才创建 BrowserWindowBrowserWindow 的webPreferences中禁用nodeIntegration改用contextIsolation: truepreload.js注入有限 API所有 Node.js 调用必须经由window.api对象透出。这个设计背后是明确的工程判断开发阶段的稳定性比启动速度更重要。Electron 官方文档推荐的app.whenReady().then(createWindow)在 Next.js dev server 未就绪时会创建空白窗口导致开发者反复刷新、怀疑代码问题。而 t3code 的健康检查机制让整个启动过程变成“状态机驱动”——只有所有前置服务就绪UI 才渲染。实测下来团队新人首次运行t3code dev的成功率从 63% 提升到 98%因为错误提示直接定位到Next.js dev server failed to start on port 3000而不是模糊的Cannot load main window。2.2 Electron 与 Web App 的耦合点localhost 不是“开发便利”而是“架构瓶颈”网络热词中反复出现electron localhost这暴露了一个被长期忽视的事实绝大多数 Electron 应用把 Web App 当作静态资源加载win.loadFile(out/index.html)而 t3code 反其道而行之强制所有 Electron 实例都通过win.loadURL(http://localhost:3000)加载开发中的 Next.js 应用。这看似增加了网络依赖实则解决了三个核心矛盾热更新一致性问题Next.js 的 HMRHot Module Replacement只作用于浏览器标签页如果 Electron 加载的是file://协议的静态 HTML修改pages/index.tsx后Electron 窗口不会自动刷新必须手动CmdR。而http://localhost:3000使 Electron 窗口成为真正的“浏览器实例”完全继承 Next.js 的热更新能力。API 调试透明性问题Web App 调用fetch(/api/user)时开发者能直接在 Chrome DevTools 的 Network 面板看到请求详情若 Electron 加载静态文件该请求会被视为跨域因file://协议无 origin需额外配置webSecurity: false破坏安全模型。localhost方案下Electron 窗口与浏览器共享同一套 CORS 策略调试体验零差异。环境隔离问题file://加载时process.env.NODE_ENV在渲染进程中不可用导致条件编译失效http://localhost:3000则可通过 Next.js 的getServerSideProps或useEffect安全读取环境变量实现真正的“一套代码多端环境”。当然这带来新挑战如何让 Electron 主进程与 Next.js dev server 共享端口t3code 的解法是放弃“抢占端口”改用端口协商机制。t3code dev启动时先尝试localhost:3000若被占用则自动探测3001、3002… 直到找到空闲端口并将该端口写入.t3code/dev-config.json。Next.js 的next.config.js中配置async rewrites()动态代理/api到 tRPC 服务Electron 的preload.js读取该 JSON 文件获取当前端口确保所有通信路径一致。这个设计让团队能在同一台机器上并行运行多个 t3code 项目互不干扰。2.3 iOS 集成不是“另起炉灶”而是“复用已有契约”热词中ios开发者模式、ios设备模拟、ios::sync频繁出现说明开发者渴望将 t3code 的能力延伸至 iOS。但 t3code 本身不生成.ipa文件也不调用 Xcode 命令行工具。它的 iOS 支持体现在两个层面协议层复用和桥接层生成。协议层复用指t3code 强制所有 API 通过 tRPC 定义而 tRPC 的 Router 结构天然支持 OpenAPI 规范导出。t3code openapi:generate命令会扫描server/routers目录生成标准 OpenAPI 3.0 JSON该 JSON 可直接被swagger-codegen或openapi-generator转为 Swift 客户端 SDK。这意味着 iOS 团队无需阅读 TypeScript 类型定义只需运行openapi-generator generate -i openapi.json -g swift5 -o ios-sdk/就能获得强类型的UserApi.getUser(id: String)方法。桥接层生成更进一步t3code bridge:ios会解析 Prisma Schema 文件自动生成 Swift 的CodableModel 类如User.swift并配套生成PrismaClient的 Swift 封装基于 SQLite.swift。关键在于这些生成代码与 tRPC 的 OpenAPI 输出严格对齐——tRPC 返回的User对象字段名、嵌套结构、可空性标记与 Swift Model 的objc属性一一对应。当后端修改 Prisma Schema 增加emailVerified: Boolean?字段时只需运行t3code bridge:iosiOS 端的 Model 自动更新无需手动维护映射关系。这种“契约先行”的设计让 iOS 开发者从“对接接口”的被动角色转变为“消费契约”的主动角色大幅降低联调成本。3. 核心细节与实操要点从零搭建 t3code 工程的 7 个关键决策点3.1 目录结构为什么必须用 Turborepo单 repo 的代价是什么t3code 的物理载体是一个 Monorepo但绝非简单的packages/web、packages/electron、packages/ios三层平铺。它的目录结构经过多次迭代最终稳定为t3code-project/ ├── apps/ │ ├── web/ # Next.js 应用负责 Web App 和 Electron 渲染进程 │ └── electron/ # Electron 主进程代码仅含 main.ts 和 preload.js ├── packages/ │ ├── server/ # tRPC Router、Prisma Schema、数据库迁移 │ ├── types/ # 全局类型定义被所有 apps/packages 引用 │ └── utils/ # 工具函数如日志、错误处理、环境检测 ├── .t3code/ # t3code CLI 的配置与缓存目录 └── turbo.json # Turborepo 构建图定义这个结构的核心决策是Web App 与 Electron 渲染进程共用同一套 React 组件与状态管理。apps/web不是“Web 专用”而是“UI 层唯一来源”。Electron 窗口加载的http://localhost:3000就是apps/web的 dev server。这样做的好处是组件复用率接近 100%但代价是apps/web必须具备“环境感知能力”——它要能区分当前运行在浏览器、Electron 还是未来可能的 iOS WebView 中。t3code通过packages/utils/env.ts提供统一环境检测// packages/utils/env.ts export const isElectron typeof window ! undefined (window as any).process?.type renderer; export const isIOS /iPad|iPhone|iPod/.test(navigator.userAgent); export const isWeb !isElectron !isIOS;apps/web中的组件据此调整行为// apps/web/components/FileDialog.tsx import { isElectron, isWeb } from t3code/utils; export function FileDialog() { if (isElectron) { // 调用 Electron IPC 获取文件路径 return ElectronFileDialog /; } if (isWeb) { // 使用原生 input typefile return WebFileDialog /; } // iOS WebView 中使用 WKWebView 的 file input 拦截 return IOSFileDialog /; }Turborepo 的价值在此刻凸显apps/web依赖packages/utilsapps/electron依赖apps/web用于构建渲染进程packages/server依赖packages/types。turbo.json中定义{ pipeline: { build: { dependsOn: [^build], outputs: [dist/**] }, dev: { cache: false, persistent: true } } }当packages/types修改时Turborepo 自动触发apps/web和apps/electron的重新构建确保类型安全贯穿全栈。若用传统多 repo这种跨仓库的依赖变更通知几乎无法自动化只能靠人工同步极易引入类型不一致 bug。3.2 Electron 主进程为什么放弃BrowserWindow.loadFileloadURL的安全边界在哪apps/electron/main.ts是 t3code 的心脏其核心逻辑远超官方示例// apps/electron/main.ts import { app, BrowserWindow, ipcMain, protocol } from electron; import * as path from path; import { URL } from url; // 1. 注册自定义协议解决 localhost 跨域问题 protocol.registerSchemesAsPrivileged([ { scheme: t3code, privileges: { secure: true, standard: true } } ]); // 2. 创建主窗口但延迟加载 function createWindow() { const win new BrowserWindow({ width: 1200, height: 800, webPreferences: { contextIsolation: true, nodeIntegration: false, preload: path.join(__dirname, preload.js), // 关键启用 webSecurity 但允许 localhost 资源 webSecurity: true, allowRunningInsecureContent: false, } }); // 3. 健康检查后加载 URL checkDevServerHealth().then(() { win.loadURL(http://localhost:3000); }).catch(err { console.error(Dev server not ready:, err); win.webContents.send(dev-server-error, err.message); }); return win; } // 4. 健康检查函数 async function checkDevServerHealth() { const configPath path.join(app.getPath(userData), .t3code, dev-config.json); const config JSON.parse(fs.readFileSync(configPath, utf8)); const url http://localhost:${config.port}/_health; try { const res await fetch(url); if (!res.ok) throw new Error(HTTP ${res.status}); } catch (err) { throw new Error(Failed to connect to dev server at ${url}: ${err}); } }这里的关键决策点有三自定义协议注册t3code://协议用于 Electron 内部 IPC 通信避免file://协议下window.location.href无法读取的问题。protocol.registerSchemesAsPrivileged确保该协议享有与http://相同的安全权限。webSecurity: true的坚持尽管loadURL(http://localhost:3000)会触发 CORS 检查但 t3code 要求 Next.js 的next.config.js显式配置headers允许localhost来源// next.config.js module.exports { async headers() { return [ { source: /api/:path*, headers: [ { key: Access-Control-Allow-Origin, value: http://localhost:3000 }, { key: Access-Control-Allow-Methods, value: GET,POST,PUT,DELETE }, ], }, ]; }, };这比关闭webSecurity更安全且符合现代 Web 安全最佳实践。健康检查的超时控制checkDevServerHealth函数内置 30 秒超时避免 Electron 窗口无限等待。超时后发送dev-server-errorIPC 事件apps/web中的全局错误边界可捕获并显示友好提示“请确认 Next.js dev server 已启动”。3.3 Preload.js为什么它是 Electron 安全模型的“守门人”apps/electron/preload.js是渲染进程与主进程通信的唯一合法通道其设计直接决定应用安全性// apps/electron/preload.js const { contextBridge, ipcRenderer } require(electron); // 1. 仅暴露必要 API contextBridge.exposeInMainWorld(api, { // 文件操作 openFile: () ipcRenderer.invoke(dialog:openFile), saveFile: (content) ipcRenderer.invoke(dialog:saveFile, content), // 系统信息 getOS: () ipcRenderer.invoke(app:getOS), // 退出应用需确认 quitApp: () ipcRenderer.invoke(app:quit), }); // 2. 监听主进程事件但不暴露原始 ipcRenderer ipcRenderer.on(app:version, (event, version) { window.dispatchEvent(new CustomEvent(app:version, { detail: version })); });这个文件的精妙之处在于“双向隔离”向下隔离contextBridge.exposeInMainWorld将api对象挂载到window但api的每个方法都是ipcRenderer.invoke的封装渲染进程无法直接访问ipcRenderer杜绝了原型污染攻击。向上隔离主进程通过ipcMain.handle响应请求而非ipcMain.on。invoke是 Promise-based 的双向通信主进程可进行参数校验、权限检查、异步处理再返回结果。例如dialog:openFile处理器// apps/electron/main.ts ipcMain.handle(dialog:openFile, async (event, options) { // 1. 校验 options 是否为对象且包含合法属性 if (!options || typeof options ! object) { throw new Error(Invalid options); } // 2. 限制可打开的文件类型 const allowedExtensions [.txt, .json, .md]; if (options.filters !options.filters.some(f allowedExtensions.some(ext f.extensions?.includes(ext)) )) { throw new Error(Unsupported file type); } // 3. 调用 dialog const { canceled, filePaths } await dialog.showOpenDialog(options); return canceled ? null : filePaths[0]; });这种设计让preload.js成为一道“沙箱闸门”渲染进程只能提出明确请求主进程决定是否响应、如何响应、响应什么。即使 Web 页面被 XSS 攻击攻击者也无法绕过preload.js直接调用require(fs)。3.4 iOS 桥接层生成Prisma Schema 到 Swift Model 的自动化映射t3code bridge:ios命令的实现是 t3code 最具技术含量的部分。它不依赖第三方代码生成器而是基于 Prisma Schema 的 AST 解析// .t3code/scripts/generate-ios-bridge.ts import { readFileSync, writeFileSync } from fs; import { parse } from prisma/sdk; export async function generateIOSBridge() { const schema readFileSync(prisma/schema.prisma, utf8); const ast parse(schema); // Prisma SDK 提供的 AST 解析器 const models ast.models.map(model ({ name: model.name, fields: model.fields.map(field ({ name: field.name, type: prismaTypeToSwiftType(field.type), isOptional: field.isOptional, isList: field.isList, isId: field.attributes.some(a a.name id), })), })); // 生成 Swift 文件 const swiftCode generateSwiftModel(models); writeFileSync(ios-sdk/Models.swift, swiftCode); } function prismaTypeToSwiftType(prismaType: string): string { switch (prismaType) { case String: return String; case Int: return Int; case Boolean: return Bool; case DateTime: return Date; case Json: return Any; default: return String; // fallback } }生成的Models.swift示例// ios-sdk/Models.swift import Foundation struct User: Codable { let id: String let name: String let email: String? let createdAt: Date let updatedAt: Date enum CodingKeys: String, CodingKey { case id, name, email, createdAt created_at, updatedAt updated_at } }关键细节字段名映射Prisma 的created_at字段在 Swift 中映射为createdAt通过CodingKeys实现 JSON 键名与 Swift 属性名的转换。可空性保障Prisma 中email String?生成let email: String?Swift 编译器强制处理 nil避免运行时崩溃。ID 字段特殊处理isId标记用于后续生成Equatable和Hashable协议实现。这个生成器与t3code openapi:generate形成闭环Prisma Schema 变更 →t3code bridge:ios生成 Model →t3code openapi:generate生成 OpenAPI → iOS 团队用 OpenAPI 生成客户端 SDK → SDK 调用 Model 进行数据绑定。整个链条全自动人工干预点仅限于 Prisma Schema 的编写。3.5 CLI 命令实现为什么用 TypeScript 编写Node.js 版本兼容性如何保障t3codeCLI 本身是一个独立的 Node.js 包位于packages/cli其核心是bin/t3code.js入口文件#!/usr/bin/env node require(../dist/cli.js);src/cli.ts使用 Commander.js 构建命令树// packages/cli/src/cli.ts import { Command } from commander; import { dev } from ./commands/dev; import { build } from ./commands/build; const program new Command(); program .name(t3code) .description(CLI for t3 stack development) .version(0.1.0); program .command(dev) .description(Start dev servers for web, electron, and api) .option(-p, --port number, Port for Next.js dev server, 3000) .action(dev); program .command(build) .description(Build for target platform) .option(--target platform, Target: web, electron, ios, web) .action(build); program.parse();选择 TypeScript 编写 CLI 的理由很务实类型即文档dev命令的参数类型、选项含义、错误处理逻辑全部由 TypeScript 类型约束t3code dev --help输出的描述与代码实现零偏差。开发体验VS Code 对 TypeScript CLI 的智能提示、跳转、重构支持远超 JavaScript团队成员添加新命令时IDE 自动补全CommanderAPI减少文档查阅时间。错误提前暴露build命令中若误将--target ios的处理逻辑写成if (target web)TypeScript 编译器直接报错而非运行时才发现。Node.js 版本兼容性通过.nvmrc和 CI 测试保障.nvmrc固定为18.17.0这是当前 LTS 版本支持fetch全局 API避免引入node-fetch依赖。GitHub Actions 中配置矩阵测试strategy: matrix: node-version: [16.x, 18.x, 20.x]确保 CLI 在主流 Node.js 版本下均能正常运行。实测发现 Node.js 16 需要额外 polyfillglobalThis.fetch因此packages/cli/src/polyfills.ts中添加if (!globalThis.fetch) { globalThis.fetch require(node-fetch); }并在package.json中指定engines: { node: 16.0.0 }让npm install时自动检查版本。3.6 调试体验如何让 VS Code 一键调试 Web、Electron、tRPC 三端t3code 的调试配置是开发者最常问的问题。.vscode/launch.json不是简单罗列三个配置而是构建一个“联合调试会话”{ version: 0.2.0, configurations: [ { name: Debug Web Electron, type: node-terminal, request: launch, command: t3code dev, console: integratedTerminal, env: { NODE_OPTIONS: --inspect9229 } }, { name: Attach to Web, type: chrome, request: attach, port: 9222, webRoot: ${workspaceFolder}/apps/web, urlFilter: http://localhost:3000/* }, { name: Attach to Electron, type: pwa-node, request: attach, address: localhost, port: 9229, sourceMaps: true, outFiles: [${workspaceFolder}/apps/electron/dist/**/*.js] } ], compounds: [ { name: Full Stack Debug, configurations: [Debug Web Electron, Attach to Web, Attach to Electron] } ] }这个配置的精妙在于node-terminal类型t3code dev命令在集成终端中运行VS Code 可捕获其 stdout/stderr便于查看启动日志。--inspect9229为 Electron 主进程启用 V8 InspectorAttach to Electron配置连接该端口。chrome类型调试 Web利用 Chrome DevTools Protocol直接调试渲染进程中的 React 组件和 Hooks。compounds复合配置点击 “Full Stack Debug” 按钮VS Code 自动启动终端运行t3code dev并同时附加到 Web 和 Electron 进程。断点可在apps/web/pages/index.tsx、apps/electron/main.ts、packages/server/routers/user.ts中任意设置三端代码同步停住。提示首次使用需在 Chrome 浏览器中访问chrome://inspect点击 “Configure”添加localhost:9222否则Attach to Web无法发现目标。3.7 生产构建Electron 打包为何不用 electron-buildert3code 的定制化打包策略t3code build:electron不调用electron-builder而是基于electron-packager 自定义脚本# packages/cli/src/commands/build.ts export async function build(target: string) { if (target electron) { // 1. 构建 Next.js 应用 await exec(cd apps/web npm run build); // 2. 构建 Electron 主进程 await exec(cd apps/electron npm run build); // 3. 使用 electron-packager 打包 await exec( electron-packager --platformdarwin --archx64 --iconassets/icon.icns --outdist/electron --app-version1.0.0 --build-version1.0.0 --overwrite apps/electron ); // 4. 将 Next.js 构建产物复制到 Electron 资源目录 await exec(cp -r apps/web/out/* dist/electron/t3code-darwin-x64/resources/app/); } }放弃electron-builder的原因有三体积控制electron-builder默认打包整个node_modules即使使用asar压缩Mac 版本仍达 120MB。electron-packager配合--prune选项可精确剔除devDependencies和未引用的模块实测将体积压缩至 68MB。签名可控性electron-builder的mac配置项复杂易与 Apple Developer 证书冲突。electron-packager的--sign参数直接接受证书 IDt3code将其封装为t3code build:electron --sign Apple Development: namedomain.com (XXXXXXXXXX)命令清晰错误提示明确。资源注入灵活性electron-packager生成的resources/app/目录结构透明t3code脚本可直接cp复制apps/web/out的静态文件确保 Electron 加载的是生产构建的 Next.js 产物而非开发服务器。这避免了electron-builder的extraResources配置中路径错误导致的白屏问题。4. 实操全流程从初始化到 iOS 真机调试的 12 步手把手指南4.1 初始化5 分钟创建你的第一个 t3code 项目不要从零开始t3code 提供官方模板t3code-template# 1. 克隆模板注意使用 HTTPS避免 SSH 权限问题 git clone https://github.com/t3-oss/create-t3-app.git my-t3code-app cd my-t3code-app # 2. 安装依赖Turborepo 会自动识别 workspace pnpm install # 3. 验证 CLI 是否可用 pnpm t3code --help # 应输出Usage: t3code [options] [command]注意必须使用pnpm因为 Turborepo 的硬链接依赖机制与 pnpm 的node_modules结构深度绑定。npm install会导致turbo run build无法正确识别依赖关系报错No tasks found。4.2 启动开发服务器一次命令三端就绪# 4. 运行开发命令 pnpm t3code dev此时会发生终端 1apps/web启动 Next.js dev server监听http://localhost:3000终端 2packages/server启动 tRPC dev server监听http://localhost:3001终端 3apps/electron启动 Electron 主进程等待http://localhost:3000/_health返回200浏览器自动打开http://localhost:3000Electron 窗口稍后弹出两者 UI 完全一致实操心得首次运行若 Electron 窗口空白请检查终端 1 是否有ready on http://localhost:3000日志。若无可能是端口被占用t3code会自动换端口但浏览器仍打开旧地址。此时关闭浏览器标签手动访问新端口如http://localhost:3001。4.3 添加第一个 APItRPC Router 的标准写法在packages/server/routers/user.ts中添加import { z } from zod; import { publicProcedure, router } from ../trpc; export const userRouter router({ list: publicProcedure .input(z.object({ limit: z.number().min(1).max(100).default(10) })) .query(async ({ input }) { // 模拟数据库查询 return Array.from({ length: input.limit }, (_, i) ({ id: user-${i}, name: User ${i}, email: user${i}example.com, })); }), });然后在packages/server/routers/index.ts中注册import { router } from ../trpc; import { userRouter } from ./user; export const appRouter router({ user: userRouter, }); export type AppRouter typeof appRouter;Web 端调用// apps/web/pages/index.tsx import { trpc } from ../utils/trpc; export default function Home() { const usersQuery trpc.user.list.useQuery({ limit: 5 }); if (usersQuery.isLoading) return divLoading.../div; if (usersQuery.isError) return divError: {usersQuery.error.message}/div; return ( ul {usersQuery.data.map(user ( li key{user.id}{user.name} ({user.email})/li ))} /ul ); }注意trpchook 的类型安全完全由AppRouter类型推导修改userRouter输入输出类型后trpc.user.list.useQuery的参数和返回值类型自动更新VS Code 实时提示。4.4 Electron 文件对话框安全调用系统原生功能在apps/electron/main.ts中添加 IPC 处理器// apps/electron/main.ts ipcMain.handle(dialog:openFile, async (event, options) { const { canceled, filePaths } await dialog.showOpenDialog(options); return canceled ? null : filePaths[0]; });在apps/web中调用// apps/web/components/FileButton.tsx import { useEffect, useState } from react; export function FileButton() { const [filePath, setFilePath] useStatestring | null(null); const handleOpen async () { try { const path await window.api.openFile({ properties: [openFile], filters: [{ name: Text Files, extensions: [txt] }] }); setFilePath(path); } catch (err)
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →