feat: 完成幽灵阅读器MVP版本基础搭建

实现了:
1.  Tauri后端框架搭建与数据库初始化
2.  前端路由与设置页面重构
3.  外观、快捷键、关于三个设置子页面
4.  书架主页UI重构
5.  新增项目文档与依赖配置
This commit is contained in:
Yuhang Wu 2026-08-19 17:39:54 +08:00
parent a37898adde
commit a89ee82be4
18 changed files with 1541 additions and 57 deletions

100
README.md
View File

@ -1,7 +1,99 @@
# Tauri + Vue + TypeScript
# 幽灵阅读器 · Stealth Reader
This template should help get you started developing with Vue 3 and TypeScript in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
一款永远浮在屏幕角落、能一键隐身、看起来像系统通知的本地小说阅读器。
## Recommended IDE Setup
核心不是「读小说」,而是「安全地读小说」——解决职场场景下「想摸鱼但怕被发现」的焦虑。
- [VS Code](https://code.visualstudio.com/) + [Vue - Official](https://marketplace.visualstudio.com/items?itemName=Vue.volar) + [Tauri](https://marketplace.visualstudio.com/items?itemName=tauri-apps.tauri-vscode) + [rust-analyzer](https://marketplace.visualstudio.com/items?itemName=rust-lang.rust-analyzer)
## 定位
桌面端本地小说阅读器,核心卖点是「职场隐身模式」。
- 目标平台Windows / macOS / Linux基于 Tauri 跨平台)
- 核心价值:轻量 + 本地 + 隐蔽
## 特性
- **本地导入**:支持 TXT 导入,自动检测 GBK / UTF-8 编码
- **自动分章**:正则识别「第 X 章」等常见格式,自动拆分章节
- **分页阅读**:按键翻页(↑↓ / PgUp / PgDn无滚动条像读实体书
- **阅读进度持久化**:自动保存进度,下次打开继续读
- **老板键**:系统级全局快捷键,一键瞬间隐身,任务栏无图标
- **幽灵模式**:窗口透明 + 鼠标穿透 + 置顶,真正融入桌面
- **系统托盘**:支持伪装图标,静默驻留后台
- **零打扰**:无账号、无广告、无在线同步,纯本地
## 四种预设模式
| 模式 | 尺寸 | 透明度 | 鼠标穿透 | 交互 | 适用场景 |
|------|------|--------|---------|------|---------|
| 专注模式 | 600×400 | 100% | 关闭 | 完整交互 | 午休 / 独处 |
| 幽灵模式 | 300×150 | 30% | 开启 | 无(纯看) | 边工作边偷看 |
| 隐身模式 | 隐藏 | 0% | - | 无 | 老板来了 |
| 迷你模式 | 200×100 | 50% | 可选 | 仅翻页 | 会议中 |
## 技术栈
- [Tauri 2](https://tauri.app/) — 跨平台桌面壳与窗口级 API
- [Vue 3](https://vuejs.org/) + TypeScript + Vite
- [Naive UI](https://www.naiveui.com/) — 组件库
- [Pinia](https://pinia.vuejs.org/) — 状态管理persistedstate 持久化)
- Rust — 后端能力(编码检测、全局快捷键、系统托盘等)
## 快速开始
### 环境要求
- [Node.js](https://nodejs.org/)18+)与 [pnpm](https://pnpm.io/)
- [Rust](https://www.rust-lang.org/) 工具链
- Tauri 各平台系统依赖(参考 [Tauri 官方文档](https://tauri.app/start/prerequisites/)
### 安装与运行
```bash
pnpm install # 安装依赖
pnpm tauri dev # 开发模式启动
pnpm tauri build # 构建安装包
```
仅调试前端界面:
```bash
pnpm dev # 启动 Vite 开发服务器http://localhost:1420
pnpm build # 类型检查 + 构建前端产物
```
## 路线图
### v0.1.0MVP
- TXT 导入 + 编码自动检测
- 书架列表 + 删除
- 分页阅读 + 字体 / 行距设置
- 老板键(显隐)
- 窗口置顶 + 透明度调节
- 阅读进度保存
### v1.0.0
- 幽灵模式(鼠标穿透 + 悬停切换)
- 系统托盘 + 伪装图标
- 设置面板
- 窗口尺寸记忆
- 安装包Windows .msi / macOS .dmg
### v1.1.0
- EPUB 导入支持
- 章节手动跳转
- 阅读时长统计
- 主题切换IDE 伪装主题)
### v2.0(未来)
- 多设备进度同步WebDAV / 局域网)
- 语音朗读TTS
- 书摘 / 笔记
## 免责声明
本项目仅支持本地文件导入,不内置任何在线书源或下载功能。请尊重版权,仅使用您拥有合法权利的书籍内容。

741
src-tauri/Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -14,12 +14,24 @@ edition = "2021"
name = "stealthreader_lib"
crate-type = ["staticlib", "cdylib", "rlib"]
[workspace]
members = ["."]
[build-dependencies]
tauri-build = { version = "2", features = [] }
tauri-helper = "0.2.1"
[dependencies]
tauri = { version = "2", features = [] }
tauri-plugin-opener = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tauri-plugin-store = "2.4.4"
tauri-helper = "0.2.1"
sqlx = { version = "0.9.0", features = [
"runtime-tokio",
"sqlite",
"tls-native-tls",
"chrono",
] }
chrono = "0.4.45"

View File

@ -1,3 +1,4 @@
fn main() {
tauri_helper::generate_command_file(tauri_helper::TauriHelperOptions::default());
tauri_build::build()
}

View File

@ -0,0 +1,7 @@
use tauri_helper::auto_collect_command;
#[tauri::command]
#[auto_collect_command]
pub async fn import_book() -> Result<(), String> {
Ok(())
}

View File

@ -0,0 +1 @@
pub mod book;

View File

@ -1,14 +1,25 @@
// Learn more about Tauri commands at https://tauri.app/develop/calling-rust/
#[tauri::command]
fn greet(name: &str) -> String {
format!("Hello, {}! You've been greeted from Rust!", name)
}
mod commands;
mod services;
mod models;
use tauri::Manager;
use commands::book::*;
use services::db::init_pool;
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.plugin(tauri_plugin_opener::init())
.invoke_handler(tauri::generate_handler![greet])
.setup(|app| {
let pool = tauri::async_runtime::block_on(async {
init_pool().await.expect("sqlite数据库连接池初始化失败!")
});
app.manage(pool);
Ok(())
})
.plugin(tauri_plugin_store::Builder::default().build())
// .invoke_handler(tauri::generate_handler![greet])
.invoke_handler(tauri_helper::tauri_collect_commands!())
.run(tauri::generate_context!())
.expect("error while running tauri application");
}

47
src-tauri/src/models.rs Normal file
View File

@ -0,0 +1,47 @@
use serde::{Deserialize, Serialize};
#[derive(Debug, Serialize, Deserialize)]
pub struct PageResult<T> {
pub total: i64,
pub list: Vec<T>,
pub page: i32,
pub page_size: i32,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct ApiResponse<T> {
success: bool,
code: i32,
msg: String,
data: Option<T>,
}
impl<T> ApiResponse<T> {
// 成功响应
pub fn success(data: T) -> Self {
Self {
success: true,
code: 200,
msg: "操作成功".to_string(),
data: Some(data),
}
}
// 成功响应,数据为空
pub fn success_empty() -> ApiResponse<()> {
ApiResponse {
success: true,
code: 200,
msg: "操作成功".to_string(),
data: None,
}
}
// 错误响应
pub fn error(code: i32, message: &str) -> Self {
Self {
success: false,
code,
msg: message.to_string(),
data: None,
}
}
}

View File

@ -0,0 +1,10 @@
use sqlx::pool::PoolOptions;
use sqlx::sqlite::{SqliteConnectOptions, SqlitePool, SqlitePoolOptions};
pub async fn init_pool() -> Result<SqlitePool, sqlx::Error> {
SqlitePoolOptions::new()
.max_connections(5)
.min_connections(1)
.connect("sqlite::memory:")
.await
}

View File

@ -0,0 +1 @@
pub mod db;

View File

@ -20,7 +20,7 @@
"decorations": false,
"alwaysOnTop": false,
"skipTaskbar": false,
"shadow": false,
"shadow": true,
"visible": true,
"center": false
}

View File

@ -30,6 +30,7 @@ const routes = [
path: "/setting",
name: "Setting",
component: () => import("@/views/Setting.vue"),
redirect: "/setting/appearance",
meta: {
isCache: true,
title: "设置",
@ -44,6 +45,24 @@ const routes = [
title: "外观设置",
},
},
{
path: "/setting/shortcut",
name: "Shortcut",
component: () => import("@/views/setting/Shortcut.vue"),
meta: {
isCache: true,
title: "快捷键",
},
},
{
path: "/setting/about",
name: "About",
component: () => import("@/views/setting/About.vue"),
meta: {
isCache: true,
title: "关于",
},
},
],
},
],

View File

@ -11,6 +11,8 @@ export {}
/* prettier-ignore */
declare module 'vue' {
export interface GlobalComponents {
NAnchor: typeof import('naive-ui')['NAnchor']
NAnchorLink: typeof import('naive-ui')['NAnchorLink']
NButton: typeof import('naive-ui')['NButton']
NConfigProvider: typeof import('naive-ui')['NConfigProvider']
NDialogProvider: typeof import('naive-ui')['NDialogProvider']
@ -20,10 +22,14 @@ declare module 'vue' {
NLayout: typeof import('naive-ui')['NLayout']
NLayoutContent: typeof import('naive-ui')['NLayoutContent']
NLayoutHeader: typeof import('naive-ui')['NLayoutHeader']
NLayoutSider: typeof import('naive-ui')['NLayoutSider']
NMessageProvider: typeof import('naive-ui')['NMessageProvider']
NNotificationProvider: typeof import('naive-ui')['NNotificationProvider']
NRadioButton: typeof import('naive-ui')['NRadioButton']
NRadioGroup: typeof import('naive-ui')['NRadioGroup']
NScrollbar: typeof import('naive-ui')['NScrollbar']
NSpace: typeof import('naive-ui')['NSpace']
NSwitch: typeof import('naive-ui')['NSwitch']
RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView']
}

View File

@ -1,43 +1,157 @@
<template>
<n-space vertical size="large">
<n-layout>
<n-layout-header class="header">
<n-input round placeholder="搜索">
<template #suffix>
<n-icon :component="BookSearch24Regular" />
</template>
</n-input>
<n-icon :component="SettingOutlined" @click="goSettingView" />
<!-- <n-icon :component="BookReader" @click="goReaderView" /> -->
</n-layout-header>
<n-layout-content class="content">
<div>书籍列表</div>
<n-empty v-if="bookList.length == 0" description="还没有导入书籍哦">
<template> <div class="home">
<header class="home-header">
<n-input
v-model:value="searchValue"
class="search-input"
round
clearable
placeholder="搜索书籍"
>
<template #prefix>
<n-icon :component="BookSearch24Regular" />
</template>
</n-input>
<button class="setting-btn" title="设置" aria-label="设置" @click="goSettingView">
<n-icon :size="20" :component="SettingOutlined" />
</button>
</header>
<section class="home-body">
<div class="section-title">
<span class="section-name">我的书籍</span>
<span class="section-count">{{ bookList.length }} </span>
</div>
<n-scrollbar class="book-scrollbar">
<n-empty v-if="bookList.length === 0" class="empty" description="还没有导入书籍哦">
<template #extra>
<n-button size="small"> 立即导入 </n-button>
<n-button size="small" type="primary" round>立即导入</n-button>
</template>
</n-empty>
</n-layout-content>
</n-layout>
</n-space>
</n-scrollbar>
</section>
</div>
</template>
<script setup lang="ts">
import { BookSearch24Regular } from "@vicons/fluent"
import { SettingOutlined } from "@vicons/antd"
import { BookReader } from "@vicons/fa"
const router = useRouter()
const bookList = ref([])
const searchValue = ref("")
const goSettingView = () => {
router.push({ name: "Setting" })
}
const goReaderView = () => {
router.push({ name: "Reader" })
}
</script>
<style lang="scss" scoped>
.home {
height: 100%;
display: flex;
flex-direction: column;
overflow: hidden;
}
.home-header {
flex-shrink: 0;
display: flex;
align-items: center;
margin: 20px 24px 12px;
}
.search-input {
flex: 1;
margin-right: 16px;
:deep(.n-input) {
background-color: var(--color-surface);
border-color: var(--color-border);
}
:deep(.n-input:hover) {
border-color: var(--color-border-hover);
}
:deep(.n-input.n-input--focus) {
border-color: var(--color-accent);
box-shadow: var(--shadow-focus);
}
}
.setting-btn {
flex-shrink: 0;
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
border: none;
border-radius: var(--radius-sm);
background-color: var(--color-surface);
color: var(--color-text-secondary);
box-shadow: var(--shadow-card);
cursor: pointer;
transition: color 0.2s ease, box-shadow 0.2s ease, background-color 0.2s ease;
&:hover {
color: var(--color-accent);
background-color: var(--color-surface-hover);
box-shadow: var(--shadow-card-hover);
}
}
.home-body {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
margin: 0 24px 20px;
}
.section-title {
flex-shrink: 0;
display: flex;
align-items: baseline;
margin-bottom: 12px;
}
.section-name {
font-size: 15px;
font-weight: 600;
color: var(--color-text-primary);
margin-right: 8px;
}
.section-count {
font-size: 12px;
color: var(--color-text-secondary);
}
.book-scrollbar {
flex: 1;
min-height: 0;
:deep(.n-scrollbar-container) {
height: 100%;
}
:deep(.n-scrollbar-content) {
min-height: 100%;
}
}
.empty {
min-height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
</style>
pt>
<style lang="scss" scoped></style>

View File

@ -1,18 +1,187 @@
<template>
<n-space>
<n-icon :component="MdArrowBack" @click="goBack" />
<p><strong>设置页面</strong></p>
</n-space>
<div class="setting">
<header class="setting-header">
<button class="back-btn" title="返回" aria-label="返回" @click="goBack">
<n-icon :component="ArrowLeftOutlined" />
</button>
<span class="setting-title">设置</span>
</header>
<div class="setting-body">
<aside class="setting-sider">
<n-scrollbar class="setting-sider__scroll">
<n-anchor
class="setting-anchor"
:show-rail="false"
:show-background="false"
>
<n-anchor-link
v-for="item in menuItems"
:key="item.name"
:href="item.href"
@click="onNavClick(item, $event)"
>
<template #title>
<span class="nav-item" :class="{ 'nav-item--active': isActive(item) }">
<n-icon class="nav-item__icon" :component="item.icon" :size="18" />
<span class="nav-item__label">{{ item.label }}</span>
</span>
</template>
</n-anchor-link>
</n-anchor>
</n-scrollbar>
</aside>
<section class="setting-content">
<n-scrollbar class="setting-content__scroll">
<RouterView />
</n-scrollbar>
</section>
</div>
</div>
</template>
<script setup lang="ts">
import { MdArrowBack } from "@vicons/ionicons4"
import {
ArrowLeftOutlined,
BgColorsOutlined,
InfoCircleOutlined,
KeyOutlined,
} from "@vicons/antd"
const router = useRouter()
const route = useRoute()
const menuItems = [
{ label: "外观", name: "Appearance", href: "#appearance", icon: BgColorsOutlined },
{ label: "快捷键", name: "Shortcut", href: "#shortcut", icon: KeyOutlined },
{ label: "关于", name: "About", href: "#about", icon: InfoCircleOutlined },
]
const goBack = () => {
router.back()
router.push({ name: "Home" })
}
const isActive = (item: (typeof menuItems)[number]) => route.name === item.name
const onNavClick = (item: (typeof menuItems)[number], e: Event) => {
e.preventDefault()
router.push({ name: item.name })
}
</script>
<style scoped></style>
<style lang="scss" scoped>
.setting {
height: 100%;
display: flex;
flex-direction: column;
overflow: hidden;
}
.setting-header {
flex-shrink: 0;
display: flex;
align-items: center;
margin: 20px 24px 16px;
}
.back-btn {
width: 36px;
height: 36px;
display: flex;
align-items: center;
justify-content: center;
border: none;
border-radius: var(--radius-sm);
background-color: var(--color-surface);
color: var(--color-text-secondary);
box-shadow: var(--shadow-card);
cursor: pointer;
transition: color 0.2s ease, box-shadow 0.2s ease, background-color 0.2s ease;
&:hover {
color: var(--color-accent);
background-color: var(--color-surface-hover);
box-shadow: var(--shadow-card-hover);
}
}
.setting-title {
font-size: 18px;
font-weight: 600;
color: var(--color-text-primary);
margin-left: 12px;
}
.setting-body {
flex: 1;
min-height: 0;
display: flex;
margin: 0 24px 24px;
}
.setting-sider {
flex-shrink: 0;
width: 200px;
margin-right: 24px;
overflow: hidden;
background-color: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-card);
box-shadow: var(--shadow-card);
}
.setting-sider__scroll {
height: 100%;
}
.setting-anchor {
margin: 12px;
:deep(.n-anchor-link) {
margin-bottom: 4px;
}
:deep(.n-anchor-link__title) {
padding: 0;
color: var(--color-text-secondary);
border-radius: var(--radius-sm);
transition: color 0.2s ease, background-color 0.2s ease;
}
:deep(.n-anchor-link__title:hover) {
color: var(--color-text-primary);
background-color: var(--color-surface-hover);
}
}
.nav-item {
display: flex;
align-items: center;
margin: 8px 12px;
font-size: 14px;
&__icon {
margin-right: 10px;
}
&--active {
color: var(--color-accent);
font-weight: 600;
}
}
.setting-content {
flex: 1;
min-width: 0;
overflow: hidden;
background-color: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-card);
box-shadow: var(--shadow-card);
}
.setting-content__scroll {
height: 100%;
}
</style>

View File

@ -0,0 +1,79 @@
<template>
<div class="setting-page">
<h2 class="page-title">关于</h2>
<p class="page-desc">关于本应用的信息</p>
<div class="about-card">
<div class="app-logo"></div>
<div class="app-name">幽灵阅读器</div>
<div class="app-version">版本 0.1.0</div>
<div class="app-desc">一款安静沉浸的本地阅读器</div>
</div>
</div>
</template>
<script setup lang="ts"></script>
<style lang="scss" scoped>
.setting-page {
margin: 24px 28px;
}
.page-title {
margin: 0 0 4px;
font-size: 20px;
font-weight: 600;
color: var(--color-text-primary);
}
.page-desc {
margin: 0 0 20px;
font-size: 13px;
color: var(--color-text-secondary);
}
.about-card {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 320px;
background-color: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-card);
box-shadow: var(--shadow-card);
margin-top: 8px;
}
.app-logo {
width: 64px;
height: 64px;
display: flex;
align-items: center;
justify-content: center;
border-radius: var(--radius-card);
background: var(--color-accent-gradient);
color: var(--color-text-invert);
font-size: 28px;
font-weight: 700;
margin-bottom: 16px;
}
.app-name {
font-size: 18px;
font-weight: 600;
color: var(--color-text-primary);
}
.app-version {
font-size: 13px;
color: var(--color-text-secondary);
margin-top: 4px;
}
.app-desc {
font-size: 13px;
color: var(--color-text-secondary);
margin-top: 16px;
}
</style>

View File

@ -1,13 +1,108 @@
<template>
<n-space vertical size="large">
<strong>外观设置</strong>
</n-space>
<div class="setting-page">
<h2 class="page-title">外观</h2>
<p class="page-desc">自定义应用的外观与阅读体验</p>
<div class="setting-group">
<div class="group-title">主题</div>
<div class="setting-item">
<div class="item-info">
<span class="item-label">主题模式</span>
<span class="item-desc">选择应用的整体配色方案</span>
</div>
<n-radio-group v-model:value="theme" name="theme">
<n-radio-button value="light">浅色</n-radio-button>
<n-radio-button value="dark">深色</n-radio-button>
<n-radio-button value="system">跟随系统</n-radio-button>
</n-radio-group>
</div>
</div>
<div class="setting-group">
<div class="group-title">阅读</div>
<div class="setting-item">
<div class="item-info">
<span class="item-label">自动换行</span>
<span class="item-desc">长段落自动换行显示</span>
</div>
<n-switch v-model:value="wordWrap" />
</div>
</div>
</div>
</template>
<script setup lang="ts">
const theme = ref("light")
const wordWrap = ref(true)
</script>
<style scoped>
<style lang="scss" scoped>
.setting-page {
margin: 24px 28px;
}
</style>
.page-title {
margin: 0 0 4px;
font-size: 20px;
font-weight: 600;
color: var(--color-text-primary);
}
.page-desc {
margin: 0 0 20px;
font-size: 13px;
color: var(--color-text-secondary);
}
.setting-group {
margin-bottom: 24px;
}
.group-title {
margin-bottom: 8px;
font-size: 13px;
font-weight: 600;
color: var(--color-text-secondary);
}
.setting-item {
display: flex;
align-items: center;
justify-content: space-between;
background-color: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
box-shadow: var(--shadow-card);
margin-bottom: 0;
transition: box-shadow 0.2s ease, border-color 0.2s ease;
&:hover {
border-color: var(--color-border-hover);
box-shadow: var(--shadow-card-hover);
}
}
.item-info {
display: flex;
flex-direction: column;
margin: 12px 16px;
}
.item-label {
font-size: 14px;
font-weight: 500;
color: var(--color-text-primary);
}
.item-desc {
font-size: 12px;
color: var(--color-text-secondary);
margin-top: 2px;
}
:deep(.n-radio-group),
:deep(.n-switch) {
margin: 0 16px;
}
</style>

View File

@ -0,0 +1,95 @@
<template>
<div class="setting-page">
<h2 class="page-title">快捷键</h2>
<p class="page-desc">使用键盘快速完成常用操作</p>
<div class="setting-group">
<div v-for="item in shortcuts" :key="item.action" class="shortcut-item">
<span class="shortcut-action">{{ item.action }}</span>
<span class="shortcut-keys">
<template v-for="(key, i) in item.keys" :key="i">
<kbd class="shortcut-key">{{ key }}</kbd>
<span v-if="i < item.keys.length - 1" class="shortcut-sep">+</span>
</template>
</span>
</div>
</div>
</div>
</template>
<script setup lang="ts">
const shortcuts = [
{ action: "上一页", keys: ["←"] },
{ action: "下一页", keys: ["→"] },
{ action: "打开搜索", keys: ["Ctrl", "F"] },
{ action: "打开设置", keys: ["Ctrl", ","] },
]
</script>
<style lang="scss" scoped>
.setting-page {
margin: 24px 28px;
}
.page-title {
margin: 0 0 4px;
font-size: 20px;
font-weight: 600;
color: var(--color-text-primary);
}
.page-desc {
margin: 0 0 20px;
font-size: 13px;
color: var(--color-text-secondary);
}
.setting-group {
background-color: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
box-shadow: var(--shadow-card);
}
.shortcut-item {
display: flex;
align-items: center;
justify-content: space-between;
margin: 0 16px;
min-height: 44px;
& + & {
border-top: 1px solid var(--color-border);
}
}
.shortcut-action {
font-size: 14px;
color: var(--color-text-primary);
}
.shortcut-keys {
display: flex;
align-items: center;
}
.shortcut-key {
min-width: 24px;
height: 24px;
display: inline-flex;
align-items: center;
justify-content: center;
margin: 0 2px;
border: 1px solid var(--color-border);
border-radius: 6px;
background-color: var(--color-window-bg);
color: var(--color-text-primary);
font-size: 12px;
font-family: inherit;
}
.shortcut-sep {
color: var(--color-text-secondary);
font-size: 12px;
}
</style>