Tauri 网站To桌面应用实战总结

Tauri 套壳桌面应用实战总结

项目目标

把一个已经构建好的 Vue 前端 dist/,原封不动包装成一个 Windows 桌面应用:

  • 只保留窗口 + 托盘 + 单实例 + 自定义标题栏
  • 前端资源全部本地
  • 输出 NSIS 安装包

阶段一:环境确认

1.1 检查工具链

powershell 复制代码
node -v          # >= 18
pnpm -v          # 没有就 npm i -g pnpm
rustc -V         # >= 1.77
cargo -V

1.2 前置条件

  • Windows 装 Visual Studio Build Tools(C++ 桌面开发)
  • Win10/11 自带 WebView2 Runtime,没有就要装

1.3 本次环境

复制代码
Node v22.23.3
pnpm 12.8.1
rustc 1.98.0
cargo 1.98.0

阶段二:初始化 Tauri 项目

2.1 空文件夹里初始化

powershell 复制代码
cd E:\TauriWebtoWiin\JjFms
pnpm create tauri-app .

选项:

复制代码
Project name        · jjfms
Identifier          · com.administrator.jjfms-app   (后来改成 top.hayatim.sr)
Frontend language   · TypeScript / JavaScript
Package manager     · pnpm
UI template         · Vanilla
UI flavor           · TypeScript

生成结构:

复制代码
JjFms\
├─ .vscode\
├─ src-tauri\
│  ├─ capabilities\
│  ├─ icons\
│  ├─ src\
│  │  ├─ lib.rs
│  │  └─ main.rs
│  ├─ .gitignore
│  ├─ build.rs
│  ├─ Cargo.toml
│  └─ tauri.conf.json
├─ .gitignore
├─ package.json
└─ README.md

2.2 关键理解

  • crate 名 jjfms-app,lib 名 jjfms_app_lib
  • main.rs 只调 jjfms_app_lib::run(),逻辑都在 lib.rs
  • 这是 Tauri 2 的标准 lib + bin 结构

阶段三:放入自己的 dist

3.1 把现成 dist/ 放到项目根

dist/ 原来错放在 src-tauri/dist/,需要挪:

powershell 复制代码
Move-Item .\src-tauri\dist .\dist

最终布局:

复制代码
JjFms\
├─ dist\                ← Vue 构建产物,原封不动
│  ├─ css\
│  ├─ js\
│  ├─ jse\
│  ├─ index.html
│  ├─ favicon.ico
│  ├─ app.webp / logo.webp / avatar.webp
│  └─ ALKATIP YAZMA TOM.TTF
├─ src-tauri\
├─ package.json

3.2 删掉多余的压缩包

powershell 复制代码
Remove-Item .\dist.zip

阶段四:替换 4 个核心文件

4.1 package.json

关键:去掉 beforeDevCommand / beforeBuildCommand,直接跑 Tauri。

json 复制代码
{
  "name": "jjfms",
  "private": true,
  "version": "0.1.0",
  "type": "module",
  "scripts": {
    "tauri": "tauri",
    "dev": "tauri dev",
    "build": "tauri build"
  },
  "devDependencies": {
    "@tauri-apps/cli": "^2.5.0"
  }
}

4.2 src-tauri/tauri.conf.json

关键点:

  • build 里只有 frontendDist: "../dist",没有 devUrl、没有 beforeXXX
  • windows[0] 加 label、尺寸、url: "index.html"
  • bundle.targets 改成 ["nsis"](不要 "all")
  • 加 nsis.languages: ["SimpChinese"]、installerIcon
  • webviewInstallMode 用 downloadBootstrapper
  • 无边框 + 透明 :decorations: false、transparent: true、shadow: false
json 复制代码
{
  "$schema": "https://schema.tauri.app/config/2",
  "productName": "码艺服务",
  "version": "0.1.0",
  "identifier": "top.hayatim.sr",
  "build": {
    "frontendDist": "../dist"
  },
  "app": {
    "withGlobalTauri": false,
    "windows": [
      {
        "label": "main",
        "url": "index.html",
        "visible": true,
        "title": "码艺服务",
        "width": 1200,
        "height": 800,
        "minWidth": 960,
        "minHeight": 640,
        "resizable": true,
        "dragDropEnabled": true,
        "backgroundThrottling": "disabled",
        "decorations": false,
        "transparent": true,
        "shadow": false,
        "alwaysOnTop": false,
        "fullscreen": false
      }
    ],
    "security": { "csp": null }
  },
  "bundle": {
    "active": true,
    "targets": ["nsis"],
    "icon": [
      "icons/32x32.png",
      "icons/128x128.png",
      "icons/128x128@2x.png",
      "icons/icon.ico"
    ],
    "windows": {
      "nsis": {
        "languages": ["SimpChinese"],
        "installerIcon": "icons/icon.ico"
      },
      "webviewInstallMode": {
        "type": "downloadBootstrapper",
        "silent": true
      }
    }
  }
}

4.3 src-tauri/Cargo.toml

关键点:

  • [lib] 保留 staticlib / cdylib / rlib
  • 加 tray-icon feature
  • 加 chrono、tauri-plugin-single-instance
  • release 优化:lto、codegen-units=1、strip、panic=abort
toml 复制代码
[package]
name = "jjfms-app"
version = "0.1.0"
description = "JjFms Tauri Shell"
authors = ["you"]
edition = "2021"

[lib]
name = "jjfms_app_lib"
crate-type = ["staticlib", "cdylib", "rlib"]

[build-dependencies]
tauri-build = { version = "2", features = [] }

[dependencies]
tauri = { version = "2", features = ["tray-icon"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
chrono = "0.4"
tauri-plugin-single-instance = "2"

[profile.release]
codegen-units = 1
lto = true
opt-level = 3
panic = "abort"
strip = true

4.4 src-tauri/src/lib.rs

最小可用的窗口 + 托盘 + 单实例:

  • ExitSync:退出同步
  • show_main_window:显示 / 反最小化 / 聚焦
  • graceful_exit:先 emit app-exiting,等前端 1.5 秒,然后退出
  • setup:
    • 托盘菜单:显示窗口、退出应用
    • 托盘左键点击显示窗口
  • on_window_event:拦截 CloseRequested,改成 hide 到托盘
  • invoke_handler:greet、confirm_exit
  • RunEvent::ExitRequested:拦截退出,走 graceful_exit

src-tauri/src/main.rs 保持:

rust 复制代码
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]

fn main() {
    jjfms_app_lib::run()
}

阶段五:Tauri 2 的 capabilities 权限系统

5.1 遇到的问题

capabilities/default.json 里模板带 "opener:default",但 Cargo.toml 里删掉了 tauri-plugin-opener,编译报:

复制代码
Permission opener:default not found

5.2 修复

改成:

json 复制代码
{
  "$schema": "../gen/schemas/desktop-schema.json",
  "identifier": "default",
  "description": "Capability for the main window",
  "windows": ["main"],
  "permissions": ["core:default"]
}

5.3 加自定义标题栏后,权限扩成

json 复制代码
"permissions": [
  "core:default",
  "core:window:allow-minimize",
  "core:window:allow-maximize",
  "core:window:allow-unmaximize",
  "core:window:allow-toggle-maximize",
  "core:window:allow-close",
  "core:window:allow-is-maximized",
  "core:window:allow-start-dragging"
]

关键教训 :Tauri 2 里前端调任何窗口 API 都要声明权限 ,漏一个就在 Console 报 not allowed。


阶段六:跑起来 + 排查字体问题

6.1 首次 pnpm dev

第一次编译 Rust 1--5 分钟,窗口出来后显示 dist 首页。

6.2 维吾尔文字体加载失败排查

现象:

复制代码
GET http://127.0.0.1:1430/ALKATIP%20YAZMA%20TOM.TTF
Status: 200
Content-Type: text/html       ← 不是 font/ttf
Content-Length: 4452

关键发现:

  • 1430 是 Tauri dev 内置的 dev server
  • 请求带空格的文件名 %20 时,Tauri dev server 会 fallback 返回 index.html
  • 结果是 HTML 被当作字体解析,字体不生效

验证:

powershell 复制代码
Get-CimInstance Win32_Process -Filter "ProcessId=4536" | Select-Object CommandLine
# → node "...\@tauri-apps\cli\tauri.js" dev

解决:

  • 不要带空格 :重命名 ALKATIP YAZMA TOM.TTF → ALKATIP.ttf,同步改 CSS
  • 或 URL 编码 :url("/ALKATIP%20YAZMA%20TOM.TTF")
  • 或 放到 CSS 同级 + 相对路径 :url("./ALKATIP.ttf")

核心教训:

  • 不要在浏览器里验证 Tauri 应用,浏览器访问 1430 是 Tauri dev server 的兜底行为,会误导
  • 真正验证要看 Tauri 窗口 F12 → Network 的 Content-Type

阶段七:测试自定义无边框窗口

7.1 造一个独立测试页 dist/test_window.html

包含:

  • #app-shell:圆角容器
  • #titlebar + data-tauri-drag-region:可拖动标题栏
  • 三个按钮:最小化、最大化、关闭
  • 底部状态:实时显示窗口状态

7.2 改 tauri.conf.json 临时指向

json 复制代码
"url": "test_window.html",
"decorations": false,
"transparent": true,
"shadow": false,
"withGlobalTauri": true

7.3 结果

  • 无边框窗口
  • 拖动标题栏移动窗口
  • 三个按钮生效
  • 圆角外面仍有白底 ------ WebView2 的默认合成背景,transparent: true 不够

7.4 结论

Windows 上"真圆角 + 透明"这条路上有坑,务实做法是:

  • 方案 1 :接受无边框矩形(不追求圆角)
  • 方案 2 :用 tauri-plugin-decorum 保留系统圆角 + Snap Layout
  • 方案 3:CSS 圆角 + 不透明背景,四角接受黑色

阶段八:把自定义标题栏做进 Vben 源码

8.1 位置

Vben 源码工程 F:\vue\JjFms\apps\web-antd:

  • 装依赖:pnpm add @tauri-apps/api@^2
  • 新建组件:src/components/WindowTitleBar.vue
  • 修改:src/app.vue 引入组件

8.2 WindowTitleBar.vue 的最终设计

(1)动态探测 Tauri 环境 + 加 data-is-tauri 属性

ts 复制代码
let appWindow: any = null;

async function getWindow() {
  if (appWindow) return appWindow;
  try {
    const mod = await import('@tauri-apps/api/window');
    appWindow = mod.getCurrentWindow();
    isTauri.value = true;
    document.documentElement.setAttribute('data-is-tauri', 'yes');
    return appWindow;
  } catch {
    isTauri.value = false;
    return null;
  }
}
  • 浏览器里跑 Vben 时,import('@tauri-apps/api/window') 会失败,isTauri = false,标题栏 display: none
  • Tauri 里跑时才生效,同时给 <html> 打上 data-is-tauri="yes",让全局样式只在 Tauri 里生效

(2)窗口 API 封装

ts 复制代码
async function refreshMaximized() {
  const win = await getWindow();
  if (!win) return;
  try { isMaximized.value = await win.isMaximized(); } catch {}
}

async function onMinimize() {
  const win = await getWindow();
  if (!win) return;
  await win.minimize();
}

async function onToggleMaximize() {
  const win = await getWindow();
  if (!win) return;
  await win.toggleMaximize();
  await refreshMaximized();
}

async function onClose() {
  const win = await getWindow();
  if (!win) return;
  await win.close();
}

(3)onMounted 里同步最大化状态

ts 复制代码
onMounted(async () => {
  await refreshMaximized();

  const win = await getWindow();
  if (!win) return;

  try {
    const unlisten = await win.onResized(() => {
      refreshMaximized();
    });
    onBeforeUnmount(() => unlisten?.());
  } catch {}
});

(4)模板结构

  • 外层 .window-titlebar,:class="{ 'is-tauri': isTauri }"
  • 左侧 .window-titlebar__title,加 data-tauri-drag-region(可拖动)
  • 右侧 .window-titlebar__buttons,三个 <button>:最小化、最大化/还原、关闭
  • 每个按钮用 SVG 图标,不用文字符号,避免字体差异

(5)样式:html[class="dark"] 支持暗色主题

css 复制代码
html[class="dark"]{
  .window-titlebar{
    background: #1c1e23;
    border-bottom: 1px solid #ffffff0d;
  }
  .window-titlebar__title-text{ color: white; }
  .window-titlebar__btn{ color: #ffffff; }
  .window-titlebar__btn:hover {
    background: rgba(250, 250, 250, 0.137);
    color: #ffffff;
    font-weight: bold;
  }
}

(6)按钮交互细节

  • .window-titlebar__btn 46px 宽、满高
  • border-radius: 10px
  • -webkit-app-region: no-drag(防止按钮被当成拖动区)
  • hover 时背景变灰,svg 加 180° 旋转动画:
css 复制代码
@keyframes qogla {
  to { transform: rotate(180deg); }
}
.window-titlebar__btn:hover svg{
  animation: qogla 0.2s linear 1 forwards;
}

(7)标题文字动画

css 复制代码
.window-titlebar__title-text {
  line-height: 40px;
  font-size: 18px;
  color: rgb(37, 37, 37);
  animation: asta 10s linear infinite;
}

@keyframes asta {
  0%, 80%, 100% { transform: rotateY(0deg) scale(1); }
  90%           { transform: rotateY(180deg) scale(0.8); }
}

8.3 全局样式:html[data-is-tauri="yes"]

核心思路 :所有"只在 Tauri 里生效"的全局样式,都挂在 html[data-is-tauri="yes"] 下。

css 复制代码
/* 浏览器默认背景 */
body, .ant-app {
  background: white !important;
}

/* 暗色主题 */
html[class="dark"]{
  body, .ant-app {
    background: #1c1e23 !important;
  }
}

/* ⭐ 只在 Tauri 里:透明 + 圆角 + 给标题栏让位 */
html[data-is-tauri="yes"] {
  background: transparent !important;
  border-radius: 20px !important;
  overflow: hidden !important;
  transform: scale(1);

  body, #app {
    background: transparent !important;
    border-radius: 20px !important;
    overflow: hidden !important;
    transform: scale(1);

    .ant-app {
      max-height: calc(100% - 40px) !important;
      transform: translateY(40px) !important;
    }
  }
}

关键教训:

  • 不要用 <style> 静态全局注入(浏览器里也会生效)
  • 用 data-* 属性 + CSS 属性选择器 做条件样式
  • document.documentElement.setAttribute('data-is-tauri', 'yes') 是开关
  • .ant-app 整体下移 40px,给固定的标题栏让位

8.4 app.vue 引入标题栏

html 复制代码
<script lang="ts" setup>
import { computed } from 'vue';

import { useAntdDesignTokens } from '@vben/hooks';
import { preferences, usePreferences } from '@vben/preferences';

import { App as AntdApp, ConfigProvider, theme } from 'ant-design-vue';

// ⭐ 引入自定义标题栏
import WindowTitleBar from '#/components/WindowTitleBar.vue';
import { antdLocale } from '#/locales';

defineOptions({ name: 'App' });

const { isDark } = usePreferences();
const { tokens } = useAntdDesignTokens();

const tokenTheme = computed(() => {
  const algorithm = isDark.value
    ? [theme.darkAlgorithm]
    : [theme.defaultAlgorithm];

  if (preferences.app.compact) {
    algorithm.push(theme.compactAlgorithm);
  }

  return { algorithm, token: tokens };
});
</script>

<template>
  <!-- ⭐ 自定义窗口标题栏(只在 Tauri 里显示) -->
  <WindowTitleBar :title="$t('page.win.title')" />

  <ConfigProvider :locale="antdLocale" :theme="tokenTheme">
    <AntdApp>
      <RouterView />
    </AntdApp>
  </ConfigProvider>
</template>

注意:

  • WindowTitleBar 放在 ConfigProvider 外面,因为它是窗口级 UI,不受 antd 主题影响
  • 标题从 i18n 取:$t('page.win.title'),多语言切换自动生效

阶段九:图标替换

9.1 生成全套图标

准备一张 ≥ 1024×1024 的 PNG:

powershell 复制代码
pnpm tauri icon .\app-icon.png

生成到 src-tauri/icons/:

复制代码
32x32.png            32×32
128x128.png          128×128
128x128@2x.png       256×256     ← @2x 表示 2 倍图
icon.icns            macOS
icon.ico             Windows(多尺寸)
icon.png             512×512 源图
Square*.png / StoreLogo.png   UWP 尺寸

9.2 关键点

  • 128x128@2x.png 实际尺寸是 256×256 (名字是 128 * 2)

  • Windows 任务栏图标只认 icon.ico

  • 换了图标后必须 清 target/debug 才会重新嵌入:

    powershell 复制代码
    Remove-Item -Recurse -Force .\src-tauri\target\debug
    pnpm dev

9.3 图标缓存问题

如果 exe 图标换了但任务栏还是旧的:

powershell 复制代码
Stop-Process -Name explorer -Force
Remove-Item "$env:LOCALAPPDATA\IconCache.db" -Force
Remove-Item "$env:LOCALAPPDATA\Microsoft\Windows\Explorer\iconcache*" -Force
Start-Process explorer

阶段十:打包安装包

10.1 首次打包失败

复制代码
Downloading https://tauri-apps.github.io/.../nsis-3.11.zip
failed to bundle project: `timeout: global`

原因:网络问题,NSIS 工具下载超时。

10.2 手动方案(备用)

  1. 下载 nsis-3.11.zip 和 nsis_tauri_utils.dll
  2. 解压到 %LOCALAPPDATA%\tauri\NSIS\
  3. dll 放到 %LOCALAPPDATA%\tauri\NSIS\Plugins\x86-unicode\additional\
  4. 重新 pnpm build

10.3 第二次打包成功

复制代码
Built application at: ...\target\release\jjfms-app.exe
Running makensis to produce ...\码艺服务_0.1.0_x64-setup.exe
Finished 1 bundle at:
  ...\码艺服务_0.1.0_x64-setup.exe (3.92 MiB)

10.4 产物位置

复制代码
E:\TauriWebtoWiin\JjFms\src-tauri\target\release\bundle\nsis\码艺服务_0.1.0_x64-setup.exe

10.5 关于体积

3.92 MB 的原因:

  • 主程序 exe 极小(lto + strip + opt-level=3)
  • WebView2 用 downloadBootstrapper(在线下载),没打进包

10.6 离线安装

如果目标机器没网:

json 复制代码
"webviewInstallMode": {
  "type": "offlineInstaller",
  "silent": true
}

安装包变大约 130 MB,但离线可装。


阶段十一:安全性认知

11.1 Tauri 打包后前端能还原吗?

能。

  • Tauri 把 dist/ 前端用 Brotli 压缩后嵌入 exe
  • 社区工具 tauri-dumper / untauri 能扫描并解压出原始文件
  • 前端 HTML/CSS/JS 本质上无法加密,因为 WebView 必须能读

11.2 Rust 侧相对安全

  • Rust 编译成原生机器码
  • 提取工具通常无法还原 Rust 源码

11.3 保护策略

前端:

  • 用 javascript-obfuscator 等工具在构建流程里混淆
  • 删掉所有 .map 文件(sourcemap)
  • 提高阅读难度,但不能阻止有决心的逆向

后端:

  • 把 API 密钥、授权逻辑、核心算法移到 Rust
  • 通过 #[tauri::command] 暴露给前端
  • 这是 Tauri 对比 Electron 的核心优势

结论 :不要把敏感信息放前端。前端混淆 + 后端承载秘密,才是正解。


最终项目结构

复制代码
E:\TauriWebtoWiin\JjFms\
├─ dist\                     ← Vue 构建产物,原封不动
│  ├─ css\
│  ├─ js\
│  ├─ jse\
│  ├─ index.html
│  ├─ favicon.ico
│  ├─ *.webp
│  └─ ALKATIP YAZMA TOM.TTF
├─ src-tauri\
│  ├─ capabilities\
│  │  └─ default.json        ← 权限声明
│  ├─ icons\                 ← 全套图标
│  ├─ src\
│  │  ├─ lib.rs              ← 窗口 + 托盘 + 单实例
│  │  └─ main.rs             ← 薄入口
│  ├─ build.rs
│  ├─ Cargo.toml
│  └─ tauri.conf.json
├─ package.json
└─ .gitignore

Vben 源码侧新增:

复制代码
F:\vue\JjFms\apps\web-antd\src\
├─ components\
│  └─ WindowTitleBar.vue     ← 自定义标题栏
└─ app.vue                   ← 引入标题栏

核心经验清单

  1. frontendDist: "../dist" + 无 beforeXXX 命令 = 纯套壳
  2. decorations: false + data-tauri-drag-region = 自定义标题栏
  3. Tauri 2 权限系统:前端调窗口 API 必须在 capabilities 声明
  4. 不要在浏览器验证 Tauri 应用,1430 端口是 Tauri dev server 的兜底
  5. 字体/资源路径不要带空格,否则 dev server 会 fallback 到 HTML
  6. Windows 上真圆角透明有坑,务实方案是不追求圆角或用 decorum
  7. 图标换完必须清 target/debug,否则 exe 内嵌的还是旧图标
  8. 前端无法加密,敏感逻辑移到 Rust
  9. 打包失败先看是不是网络,NSIS 工具可以手动下
  10. 3.92 MB 装一个 Vue 应用,这就是 Tauri 的价值

后续可选增强

  • tauri-plugin-updater:自动更新
  • tauri-plugin-decorum:保留 Snap Layout 的圆角窗口
  • 代码签名:消 SmartScreen 警告
  • 托盘菜单细化:检查更新、关于、打开日志目录
  • 单实例 argv 处理:支持 app.exe some-file

html 复制代码
<script lang="ts" setup>
import { onBeforeUnmount, onMounted, ref } from 'vue';

defineOptions({ name: 'WindowTitleBar' });

withDefaults(defineProps<Props>(), {
  title: '码艺服务',
});

interface Props {
  title?: string;
}

const isMaximized = ref(false);
const isTauri = ref(false);

let appWindow: any = null;

/** 动态 import,避免在浏览器里跑时崩掉 */
async function getWindow() {
  if (appWindow) return appWindow;
  try {
    const mod = await import('@tauri-apps/api/window');
    appWindow = mod.getCurrentWindow();
    isTauri.value = true;
    document.documentElement.setAttribute('data-is-tauri', 'yes');
    return appWindow;
  } catch {
    isTauri.value = false;
    return null;
  }
}

async function refreshMaximized() {
  const win = await getWindow();
  if (!win) return;
  try {
    isMaximized.value = await win.isMaximized();
  } catch {
    // ignore
  }
}

async function onMinimize() {
  const win = await getWindow();
  if (!win) return;
  await win.minimize();
}

async function onToggleMaximize() {
  const win = await getWindow();
  if (!win) return;
  await win.toggleMaximize();
  await refreshMaximized();
}

async function onClose() {
  const win = await getWindow();
  if (!win) return;
  await win.close();
}

onMounted(async () => {
  await refreshMaximized();

  const win = await getWindow();
  if (!win) return;

  // 监听窗口尺寸变化,同步最大化状态
  try {
    const unlisten = await win.onResized(() => {
      refreshMaximized();
    });
    onBeforeUnmount(() => {
      unlisten?.();
    });
  } catch {
    // ignore
  }
});
</script>

<template>
  <div class="window-titlebar" :class="{ 'is-tauri': isTauri }">
    <!-- 左侧:标题(可拖动区域) -->
    <div
      class="window-titlebar__title"
      data-tauri-drag-region
    >
      <slot name="title">
        <span class="window-titlebar__title-text">{{ title }}</span>
      </slot>
    </div>

    <!-- 右侧:窗口按钮 -->
    <div class="window-titlebar__buttons">
      <button
        class="window-titlebar__btn"
        type="button"
        title="最小化"
        @click="onMinimize"
      >
        <svg width="12" height="12" viewBox="0 0 12 12" aria-hidden="true">
          <rect x="1" y="5.5" width="10" height="1" fill="currentColor" />
        </svg>
      </button>

      <button
        class="window-titlebar__btn"
        type="button"
        :title="isMaximized ? '还原' : '最大化'"
        @click="onToggleMaximize"
      >
        <svg
          v-if="!isMaximized"
          width="12"
          height="12"
          viewBox="0 0 12 12"
          aria-hidden="true"
        >
          <rect
            x="1.5"
            y="1.5"
            width="9"
            height="9"
            fill="none"
            stroke="currentColor"
            stroke-width="1"
          />
        </svg>
        <svg v-else width="12" height="12" viewBox="0 0 12 12" aria-hidden="true">
          <rect
            x="3"
            y="1"
            width="7"
            height="7"
            fill="none"
            stroke="currentColor"
            stroke-width="1"
          />
          <rect
            x="1"
            y="3"
            width="7"
            height="7"
            fill="none"
            stroke="currentColor"
            stroke-width="1"
          />
        </svg>
      </button>

      <button
        class="window-titlebar__btn window-titlebar__btn--close"
        type="button"
        title="关闭"
        @click="onClose"
      >
        <svg width="12" height="12" viewBox="0 0 12 12" aria-hidden="true">
          <path
            d="M2 2 L10 10 M10 2 L2 10"
            stroke="currentColor"
            stroke-width="1.2"
            fill="none"
          />
        </svg>
      </button>
    </div>
  </div>
</template>

<style scoped lang="scss">
html[class="dark"]{
  .window-titlebar{
    background: #1c1e23;
    border-bottom: 1px solid #ffffff0d;
  }
  .window-titlebar__title-text{
    color:white;
  }
  .window-titlebar__btn{
    color: #ffffff;
  }
  .window-titlebar__btn:hover {
    background: rgba(250, 250, 250, 0.137);
    color: #ffffff;
    font-weight: bold;
  }
}
.window-titlebar {
  /* 固定顶部 */
  position: fixed;
  top: 0;
  left: 0;
  right: 0;
  height: 40px;
  background: white;
  display: flex;
  align-items: center;
  justify-content: space-between;

  /* 深色标题栏,可按需改 */
  color: #ddd;
  font-size: 13px;
  user-select: none;

  /* 顶部圆角(配合窗口装饰/自绘圆角) */
  border-top-left-radius: 12px;
  border-top-right-radius: 12px;

  /* 阴影让标题栏和下方内容有层次 */
  box-shadow: 0 1px 0 rgba(255, 255, 255, 0.04);
  border-bottom: 1px solid #0000000d;
}

/* 只有在 Tauri 里才显示标题栏(在浏览器里跑 Vben 时不显示) */
.window-titlebar:not(.is-tauri) {
  display: none;
}

.window-titlebar__title {
  flex: 1 1 auto;
  height: 100%;
  display: flex;
  align-items: center;
  padding-left: 14px;
  overflow: hidden;
  cursor: grab;
}

.window-titlebar__title:active {
  cursor: grabbing;
}

.window-titlebar__title-text {
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
  font-weight: 500;
  letter-spacing: 0.5px;
  line-height: 40px;
  font-size: 18px;
  color:rgb(37, 37, 37);
  animation: asta 10s linear infinite;
}

@keyframes asta {
  0%,80%,100%{
    transform: rotateY(0deg) scale(1);
  }
  90%{
    transform: rotateY(180deg) scale(0.8);
  }
}

.window-titlebar__buttons {
  flex: 0 0 auto;
  display: flex;
  height: 100%;
}

.window-titlebar__btn {
  width: 46px;
  height: 100%;
  border: 0;
  padding: 0;
  background: transparent;
  color: #000000;
  display: flex;
  align-items: center;
  justify-content: center;
  cursor: pointer;
  transition: background 0.12s ease, color 0.12s ease;
  border-radius: 10px;
  /* 按钮不能被当作拖动区域 */
  -webkit-app-region: no-drag;
}

@keyframes qogla {
  to{
    transform: rotate(180deg);
  }
}
.window-titlebar__btn:hover {
  background: rgba(0, 0, 0, 0.137);
  color: #000000;
}
.window-titlebar__btn:hover svg{
  animation: qogla 0.2s linear 1 forwards;
}

.window-titlebar__btn--close:hover {
  background: rgba(0, 0, 0, 0.137);
  color: #000000;
}
</style>

<style lang="scss">
   body,.ant-app {
    background: white !important;
  }

  html[class="dark"]{
    body,.ant-app{
      background: #1c1e23 !important;
  }
  }

  html[data-is-tauri="yes"] {
    background: transparent !important;
    border-radius: 20px !important;
    overflow: hidden !important;
    transform: scale(1);
    body,#app{
        background: transparent !important;
        border-radius: 20px !important;
        overflow: hidden !important;
        transform: scale(1);
        .ant-app{
          max-height: calc(100% - 40px) !important;
          transform: translateY(40px) !important;
        }
    }
  }
</style>

从零到安装包,一共走了这 11 个阶段 。核心难点其实只有三个:权限系统、字体空格、图标嵌入,其他都是配置活。以后再套第二个 Vue/React 项目,一小时能搞定。

相关推荐
颜进强1 小时前
18 · NestJS动态模块与 forRoot:`imports: [ConfigModule.forRoot({...})]` 到底在 import 什么
前端·后端·ai编程
YZ1225521 小时前
【Docker专题】使用Docker部署Vue-Flask项目前后端分离版【前端Docker部署】
前端·vue.js·docker
liangshanbo12151 小时前
浏览器渲染过程:高级前端面试题
前端
太子釢2 小时前
React 函数组件与 Hook 实践指南
前端·react.js
颜进强2 小时前
17 · 自定义 Provider 四形态:useValue / useClass / useFactory / useExisting 到底在选什么
前端·后端·ai编程
传人once2 小时前
网页文字滚动效果如何做
前端·css·html·jquery·html5
liangshanbo12152 小时前
面试题:React 中 Lane 和 Scheduler 是怎么配合的?
前端·react.js·前端框架
liangshanbo12152 小时前
请求拦截器相关面试题
java·前端·javascript
ShawnLiaoking2 小时前
ADS305 Big Data Lecuture4 Web Scraping
大数据·前端