feat: 实现笔临 P0/P1 Electron 桌面应用 v0.1.0
交付写作核心(TipTap、自动保存、分卷分章)、Onboarding、7 主题与双语 i18n、快捷键设置;主进程使用 node:sqlite;补全 Vitest 与 Playwright E2E;统一 design/ 目录并移除 desigin 拼写错误路径。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,3 @@
|
|||||||
|
registry=https://registry.npmmirror.com
|
||||||
|
electron_mirror=https://npmmirror.com/mirrors/electron/
|
||||||
|
electron_builder_binaries_mirror=https://npmmirror.com/mirrors/electron-builder-binaries/
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
# 笔临 (Bilin)
|
||||||
|
|
||||||
|
长篇创作智能协作平台 — Electron 桌面客户端 v0.1.0(P0/P1)
|
||||||
|
|
||||||
|
## 开发
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install --legacy-peer-deps
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
数据库使用 Node.js 内置 `node:sqlite`(无需原生模块编译)。
|
||||||
|
|
||||||
|
## 测试
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run test # 单元测试 6/6
|
||||||
|
npm run test:e2e # E2E 测试(需先 build)
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
## 文档
|
||||||
|
|
||||||
|
- 设计规格:`docs/superpowers/specs/2026-07-05-bilin-p0-p1-design.md`
|
||||||
|
- 实现计划:`docs/superpowers/plans/2026-07-05-bilin-p0-p1.md`
|
||||||
|
- UI 原型:`design/ui_pc.html`
|
||||||
@@ -315,7 +315,10 @@
|
|||||||
|
|
||||||
#### 5.2.1 章节列表
|
#### 5.2.1 章节列表
|
||||||
|
|
||||||
- **树状展示**:卷→章,可折叠;支持拖拽排序(同卷及跨卷移动)。拖拽移动后若章节绑定大纲,询问是否更新大纲关联。
|
- **树状展示**:卷→章,可折叠;点击卷标题▶箭头可单独折叠/展开;支持拖拽排序(同卷及跨卷移动)。拖拽移动后若章节绑定大纲,询问是否更新大纲关联。
|
||||||
|
- **分卷记忆**:点击分卷标题时,自动展开该卷并选中该卷**上次打开的章节**;若该卷尚无打开记录,则选中第一章。当前分卷高亮显示。
|
||||||
|
- **按卷新建章节**:「新章」按钮在**当前选中分卷**内创建章节并立即在编辑器中打开。
|
||||||
|
- **双击改标题**:章节名支持双击原地编辑;修改后同步更新编辑器上方标题栏。
|
||||||
- **章节标记系统**:用户可创建自定义标签(如"高潮""需大修""女主主场"),并给章节打标。列表支持按标签筛选和颜色标识。
|
- **章节标记系统**:用户可创建自定义标签(如"高潮""需大修""女主主场"),并给章节打标。列表支持按标签筛选和颜色标识。
|
||||||
- **章节预览**:鼠标悬停显示首段内容及自定义摘要。
|
- **章节预览**:鼠标悬停显示首段内容及自定义摘要。
|
||||||
- **章节状态**:可手动标注"草稿/润色中/定稿"等状态,不同状态图标区分。
|
- **章节状态**:可手动标注"草稿/润色中/定稿"等状态,不同状态图标区分。
|
||||||
@@ -476,6 +479,11 @@
|
|||||||
|
|
||||||
### 5.5 大纲、设定与灵感管理
|
### 5.5 大纲、设定与灵感管理
|
||||||
|
|
||||||
|
- **统一编辑体验**:章节、大纲、设定、灵感条目均在中央内容编辑器中直接编辑;切换条目时自动保存当前内容。
|
||||||
|
- **侧栏新建入口**:大纲 / 设定 / 灵感面板底部提供「新建」按钮;章节面板底部保留「新章」按钮。
|
||||||
|
- **新建即打开**:新建章节、大纲、设定或灵感后,立即在编辑器中打开该条目,光标定位至文末,可直接输入。
|
||||||
|
- **双击改标题**:侧栏章节名、大纲/设定/灵感标题,以及编辑器上方文档标题,均支持**双击原地变为输入框**修改(无弹窗);Enter 确认、Esc 取消、点击其他区域自动保存。
|
||||||
|
|
||||||
#### 5.5.1 大纲系统
|
#### 5.5.1 大纲系统
|
||||||
|
|
||||||
- **层级大纲编辑器**:支持无限层级的树状大纲,可拖拽排序、缩进/提升层级。
|
- **层级大纲编辑器**:支持无限层级的树状大纲,可拖拽排序、缩进/提升层级。
|
||||||
@@ -505,7 +505,12 @@
|
|||||||
}
|
}
|
||||||
.vol-header:hover {
|
.vol-header:hover {
|
||||||
background: var(--bg-hover);
|
background: var(--bg-hover);
|
||||||
color: #fff;
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
.vol-header.active {
|
||||||
|
background: var(--bg-active);
|
||||||
|
color: var(--text-primary);
|
||||||
|
border-left: 3px solid var(--accent);
|
||||||
}
|
}
|
||||||
.vol-header .vol-arrow {
|
.vol-header .vol-arrow {
|
||||||
font-size: 9px;
|
font-size: 9px;
|
||||||
@@ -554,6 +559,74 @@
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
|
cursor: text;
|
||||||
|
}
|
||||||
|
.chapter-item .ch-title:hover,
|
||||||
|
.setting-entry .entry-name:hover,
|
||||||
|
.outline-item .item-title:hover,
|
||||||
|
.inspiration-item .item-title:hover {
|
||||||
|
color: var(--accent-light);
|
||||||
|
}
|
||||||
|
.inline-title-input {
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 3px;
|
||||||
|
border: 1px solid var(--accent);
|
||||||
|
background: var(--bg-surface);
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: inherit;
|
||||||
|
font-family: inherit;
|
||||||
|
font-weight: inherit;
|
||||||
|
outline: none;
|
||||||
|
box-shadow: 0 0 0 2px var(--accent-glow);
|
||||||
|
}
|
||||||
|
#editor-toolbar .ch-label {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin-left: 8px;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: text;
|
||||||
|
max-width: 280px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
#editor-toolbar .ch-label:hover { color: var(--text-primary); }
|
||||||
|
#editor-toolbar .inline-title-input.editor-label-input {
|
||||||
|
max-width: 240px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.outline-item .item-prefix {
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-right: 4px;
|
||||||
|
}
|
||||||
|
.outline-item .item-title {
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
cursor: text;
|
||||||
|
}
|
||||||
|
.outline-item .item-meta {
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin-left: 4px;
|
||||||
|
}
|
||||||
|
.outline-item[data-status="writing"] .item-title { color: var(--accent-light); }
|
||||||
|
.outline-item[data-status="done"] .item-title { color: var(--green); }
|
||||||
|
.inspiration-item .item-title {
|
||||||
|
display: block;
|
||||||
|
cursor: text;
|
||||||
|
}
|
||||||
|
.setting-entry .entry-name {
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
cursor: text;
|
||||||
}
|
}
|
||||||
.chapter-item .ch-badge {
|
.chapter-item .ch-badge {
|
||||||
font-size: 9px;
|
font-size: 9px;
|
||||||
@@ -657,12 +730,6 @@
|
|||||||
background: rgba(91, 156, 245, 0.2);
|
background: rgba(91, 156, 245, 0.2);
|
||||||
color: var(--blue);
|
color: var(--blue);
|
||||||
}
|
}
|
||||||
.setting-entry .entry-name {
|
|
||||||
flex: 1;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
.setting-entry .entry-count {
|
.setting-entry .entry-count {
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
@@ -711,12 +778,6 @@
|
|||||||
background: var(--border);
|
background: var(--border);
|
||||||
margin: 0 6px;
|
margin: 0 6px;
|
||||||
}
|
}
|
||||||
#editor-toolbar .ch-label {
|
|
||||||
font-size: 11px;
|
|
||||||
color: var(--text-muted);
|
|
||||||
margin-left: 8px;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
#editor-toolbar .word-count-display {
|
#editor-toolbar .word-count-display {
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: var(--accent-light);
|
color: var(--accent-light);
|
||||||
@@ -1069,10 +1130,10 @@
|
|||||||
}
|
}
|
||||||
.settings-sidebar .settings-nav-item:hover {
|
.settings-sidebar .settings-nav-item:hover {
|
||||||
background: var(--bg-hover);
|
background: var(--bg-hover);
|
||||||
color: #fff;
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
.settings-sidebar .settings-nav-item.active {
|
.settings-sidebar .settings-nav-item.active {
|
||||||
color: #fff;
|
color: var(--text-primary);
|
||||||
background: var(--bg-tertiary);
|
background: var(--bg-tertiary);
|
||||||
border-left-color: var(--accent);
|
border-left-color: var(--accent);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
@@ -1091,7 +1152,7 @@
|
|||||||
}
|
}
|
||||||
.settings-content h2 {
|
.settings-content h2 {
|
||||||
font-size: 20px;
|
font-size: 20px;
|
||||||
color: #fff;
|
color: var(--text-primary);
|
||||||
margin-bottom: 20px;
|
margin-bottom: 20px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
@@ -1115,6 +1176,7 @@
|
|||||||
margin-bottom: 6px;
|
margin-bottom: 6px;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
|
color: var(--text-secondary);
|
||||||
}
|
}
|
||||||
.settings-content .setting-item .setting-label {
|
.settings-content .setting-item .setting-label {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
@@ -1126,12 +1188,13 @@
|
|||||||
}
|
}
|
||||||
.settings-content input[type="text"],
|
.settings-content input[type="text"],
|
||||||
.settings-content input[type="number"],
|
.settings-content input[type="number"],
|
||||||
|
.settings-content input[type="password"],
|
||||||
.settings-content select {
|
.settings-content select {
|
||||||
padding: 6px 10px;
|
padding: 6px 10px;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
background: var(--bg-surface);
|
background: var(--bg-surface);
|
||||||
color: #fff;
|
color: var(--text-primary);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-family: var(--font-sans);
|
font-family: var(--font-sans);
|
||||||
}
|
}
|
||||||
@@ -1501,7 +1564,7 @@
|
|||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
background: var(--bg-tertiary);
|
background: var(--bg-tertiary);
|
||||||
color: #fff;
|
color: var(--text-primary);
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
font-family: var(--font-sans);
|
font-family: var(--font-sans);
|
||||||
outline: none;
|
outline: none;
|
||||||
@@ -1535,7 +1598,8 @@
|
|||||||
#batchToolbar button { padding:3px 8px;border-radius:3px;border:1px solid rgba(255,255,255,0.2);background:transparent;color:#fff;cursor:pointer;font-size:10px;transition:var(--transition); }
|
#batchToolbar button { padding:3px 8px;border-radius:3px;border:1px solid rgba(255,255,255,0.2);background:transparent;color:#fff;cursor:pointer;font-size:10px;transition:var(--transition); }
|
||||||
#batchToolbar button:hover { background:rgba(255,255,255,0.15); }
|
#batchToolbar button:hover { background:rgba(255,255,255,0.15); }
|
||||||
.outline-item { display:flex;align-items:center;padding:6px 8px 6px 18px;cursor:pointer;border-radius:4px;font-size:12px;color:var(--text-secondary);border-left:3px solid transparent;margin:1px 4px;gap:6px;transition:var(--transition); }
|
.outline-item { display:flex;align-items:center;padding:6px 8px 6px 18px;cursor:pointer;border-radius:4px;font-size:12px;color:var(--text-secondary);border-left:3px solid transparent;margin:1px 4px;gap:6px;transition:var(--transition); }
|
||||||
.outline-item:hover { background:var(--bg-hover);color:#fff; }
|
.outline-item:hover { background:var(--bg-hover);color:var(--text-primary); }
|
||||||
|
.outline-item.active { background:var(--bg-active);color:var(--text-primary);font-weight:600; }
|
||||||
.outline-item.uncovered { border-left-color:var(--orange); }
|
.outline-item.uncovered { border-left-color:var(--orange); }
|
||||||
.outline-item.uncovered::after { content:'\26A0';font-size:10px;color:var(--orange);margin-left:auto; }
|
.outline-item.uncovered::after { content:'\26A0';font-size:10px;color:var(--orange);margin-left:auto; }
|
||||||
#editor-toolbar .tool-btn.active { color:var(--accent-light);background:var(--bg-active); }
|
#editor-toolbar .tool-btn.active { color:var(--accent-light);background:var(--bg-active); }
|
||||||
@@ -1625,6 +1689,36 @@
|
|||||||
.vol-header:hover .vol-menu-btn { opacity:1; }
|
.vol-header:hover .vol-menu-btn { opacity:1; }
|
||||||
.vol-header .vol-menu-btn:hover { background:var(--bg-active);color:#fff; }
|
.vol-header .vol-menu-btn:hover { background:var(--bg-active);color:#fff; }
|
||||||
.setting-cat .add-btn { color:var(--accent-light);cursor:pointer;font-weight:400; }
|
.setting-cat .add-btn { color:var(--accent-light);cursor:pointer;font-weight:400; }
|
||||||
|
.setting-entry.active {
|
||||||
|
background: var(--bg-active);
|
||||||
|
border-left: 3px solid var(--accent);
|
||||||
|
}
|
||||||
|
.setting-entry.active .entry-name {
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
.inspiration-item {
|
||||||
|
padding: 6px 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
margin: 2px 0;
|
||||||
|
transition: var(--transition);
|
||||||
|
border-left: 3px solid transparent;
|
||||||
|
}
|
||||||
|
.inspiration-item:hover { background: var(--bg-hover); color: var(--text-primary); }
|
||||||
|
.inspiration-item.active { background: var(--bg-active); color: var(--text-primary); border-left-color: var(--accent); font-weight: 600; }
|
||||||
|
.panel-list-body { flex: 1; overflow-y: auto; min-height: 0; }
|
||||||
|
#left-sidebar .sidebar-footer button.primary-new {
|
||||||
|
flex: 1;
|
||||||
|
color: var(--accent-light);
|
||||||
|
border-color: var(--accent-dark);
|
||||||
|
}
|
||||||
|
#left-sidebar .sidebar-footer button.primary-new:hover {
|
||||||
|
background: var(--accent-glow);
|
||||||
|
color: var(--accent-light);
|
||||||
|
}
|
||||||
.retry-btn { display:none;padding:4px 10px;border-radius:10px;border:1px solid var(--orange);background:rgba(232,144,80,0.15);color:var(--orange);cursor:pointer;font-size:10px; }
|
.retry-btn { display:none;padding:4px 10px;border-radius:10px;border:1px solid var(--orange);background:rgba(232,144,80,0.15);color:var(--orange);cursor:pointer;font-size:10px; }
|
||||||
.retry-btn.show { display:inline-block; }
|
.retry-btn.show { display:inline-block; }
|
||||||
.settings-content .shortcut-key { display:inline-block;padding:2px 8px;border-radius:3px;background:var(--bg-active);font-family:var(--font-mono);font-size:11px;border:1px solid var(--border);cursor:pointer;min-width:80px;text-align:center; }
|
.settings-content .shortcut-key { display:inline-block;padding:2px 8px;border-radius:3px;background:var(--bg-active);font-family:var(--font-mono);font-size:11px;border:1px solid var(--border);cursor:pointer;min-width:80px;text-align:center; }
|
||||||
@@ -1740,45 +1834,45 @@
|
|||||||
<button onclick="clearBatchSelection()" style="margin-left:auto;" aria-label="取消选择">✕</button>
|
<button onclick="clearBatchSelection()" style="margin-left:auto;" aria-label="取消选择">✕</button>
|
||||||
</div>
|
</div>
|
||||||
<div id="chapter-tree">
|
<div id="chapter-tree">
|
||||||
<div class="vol-group">
|
<div class="vol-group" data-vol-id="vol-1">
|
||||||
<div class="vol-header open" onclick="toggleVol(this)" oncontextmenu="showVolContextMenu(event,this);return false;">
|
<div class="vol-header open active" onclick="selectVolume(this)" oncontextmenu="showVolContextMenu(event,this);return false;">
|
||||||
<span class="vol-arrow">▶</span> 第一卷·青云之始
|
<span class="vol-arrow" onclick="event.stopPropagation();toggleVolCollapse(this.parentElement)">▶</span> 第一卷·青云之始
|
||||||
<span class="vol-count">12章 · 3.8万字</span>
|
<span class="vol-count">12章 · 3.8万字</span>
|
||||||
<span class="vol-menu-btn" onclick="event.stopPropagation();showVolContextMenu(event,this.parentElement);" aria-label="分卷菜单">⋯</span>
|
<span class="vol-menu-btn" onclick="event.stopPropagation();showVolContextMenu(event,this.parentElement);" aria-label="分卷菜单">⋯</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="vol-chapters">
|
<div class="vol-chapters">
|
||||||
<div class="chapter-item" data-ch="1" onclick="selectChapter(this,1)" tabindex="0" role="treeitem" aria-label="第1章 山村少年 定稿">
|
<div class="chapter-item" data-ch="1" onclick="selectChapter(this,1)" tabindex="0" role="treeitem" aria-label="第1章 山村少年 定稿">
|
||||||
<span class="ch-select" onclick="event.stopPropagation();toggleChapterSelect(this,1)" aria-label="选择章节"></span>
|
<span class="ch-select" onclick="event.stopPropagation();toggleChapterSelect(this,1)" aria-label="选择章节"></span>
|
||||||
<span class="ch-num">第1章</span><span class="ch-title">山村少年</span><span class="ch-badge done">定稿</span>
|
<span class="ch-num">第1章</span><span class="ch-title" ondblclick="startSidebarTitleEdit('chapter',1,this)">山村少年</span><span class="ch-badge done">定稿</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="chapter-item" data-ch="2" onclick="selectChapter(this,2)" tabindex="0" role="treeitem" aria-label="第2章 意外觉醒 定稿">
|
<div class="chapter-item" data-ch="2" onclick="selectChapter(this,2)" tabindex="0" role="treeitem" aria-label="第2章 意外觉醒 定稿">
|
||||||
<span class="ch-select" onclick="event.stopPropagation();toggleChapterSelect(this,2)" aria-label="选择章节"></span>
|
<span class="ch-select" onclick="event.stopPropagation();toggleChapterSelect(this,2)" aria-label="选择章节"></span>
|
||||||
<span class="ch-num">第2章</span><span class="ch-title">意外觉醒</span><span class="ch-badge done">定稿</span>
|
<span class="ch-num">第2章</span><span class="ch-title" ondblclick="startSidebarTitleEdit('chapter',2,this)">意外觉醒</span><span class="ch-badge done">定稿</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="chapter-item active" data-ch="3" onclick="selectChapter(this,3)" tabindex="0" role="treeitem" aria-label="第3章 入门考验 AI初稿">
|
<div class="chapter-item active" data-ch="3" onclick="selectChapter(this,3)" tabindex="0" role="treeitem" aria-label="第3章 入门考验 AI初稿">
|
||||||
<span class="ch-select" onclick="event.stopPropagation();toggleChapterSelect(this,3)" aria-label="选择章节"></span>
|
<span class="ch-select" onclick="event.stopPropagation();toggleChapterSelect(this,3)" aria-label="选择章节"></span>
|
||||||
<span class="ch-num">第3章</span><span class="ch-title">入门考验</span><span class="ch-badge ai">AI初稿</span>
|
<span class="ch-num">第3章</span><span class="ch-title" ondblclick="startSidebarTitleEdit('chapter',3,this)">入门考验</span><span class="ch-badge ai">AI初稿</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="chapter-item" data-ch="4" onclick="selectChapter(this,4)" tabindex="0" role="treeitem" aria-label="第4章 灵根测试 草稿">
|
<div class="chapter-item" data-ch="4" onclick="selectChapter(this,4)" tabindex="0" role="treeitem" aria-label="第4章 灵根测试 草稿">
|
||||||
<span class="ch-select" onclick="event.stopPropagation();toggleChapterSelect(this,4)" aria-label="选择章节"></span>
|
<span class="ch-select" onclick="event.stopPropagation();toggleChapterSelect(this,4)" aria-label="选择章节"></span>
|
||||||
<span class="ch-num">第4章</span><span class="ch-title">灵根测试</span><span class="ch-badge draft">草稿</span>
|
<span class="ch-num">第4章</span><span class="ch-title" ondblclick="startSidebarTitleEdit('chapter',4,this)">灵根测试</span><span class="ch-badge draft">草稿</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="chapter-item" data-ch="5" onclick="selectChapter(this,5)" tabindex="0" role="treeitem" aria-label="第5章 初次修炼 需修">
|
<div class="chapter-item" data-ch="5" onclick="selectChapter(this,5)" tabindex="0" role="treeitem" aria-label="第5章 初次修炼 需修">
|
||||||
<span class="ch-select" onclick="event.stopPropagation();toggleChapterSelect(this,5)" aria-label="选择章节"></span>
|
<span class="ch-select" onclick="event.stopPropagation();toggleChapterSelect(this,5)" aria-label="选择章节"></span>
|
||||||
<span class="ch-num">第5章</span><span class="ch-title">初次修炼</span><span class="ch-badge review">需修</span>
|
<span class="ch-num">第5章</span><span class="ch-title" ondblclick="startSidebarTitleEdit('chapter',5,this)">初次修炼</span><span class="ch-badge review">需修</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="vol-group">
|
<div class="vol-group" data-vol-id="vol-2">
|
||||||
<div class="vol-header" onclick="toggleVol(this)" oncontextmenu="showVolContextMenu(event,this);return false;">
|
<div class="vol-header" onclick="selectVolume(this)" oncontextmenu="showVolContextMenu(event,this);return false;">
|
||||||
<span class="vol-arrow">▶</span> 第二卷·宗门风云
|
<span class="vol-arrow" onclick="event.stopPropagation();toggleVolCollapse(this.parentElement)">▶</span> 第二卷·宗门风云
|
||||||
<span class="vol-count">8章 · 2.4万字</span>
|
<span class="vol-count">8章 · 2.4万字</span>
|
||||||
<span class="vol-menu-btn" onclick="event.stopPropagation();showVolContextMenu(event,this.parentElement);" aria-label="分卷菜单">⋯</span>
|
<span class="vol-menu-btn" onclick="event.stopPropagation();showVolContextMenu(event,this.parentElement);" aria-label="分卷菜单">⋯</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="vol-chapters hidden">
|
<div class="vol-chapters hidden">
|
||||||
<div class="chapter-item" data-ch="13" onclick="selectChapter(this,13)" tabindex="0" role="treeitem" aria-label="第13章 初入宗门 定稿">
|
<div class="chapter-item" data-ch="13" onclick="selectChapter(this,13)" tabindex="0" role="treeitem" aria-label="第13章 初入宗门 定稿">
|
||||||
<span class="ch-select" onclick="event.stopPropagation();toggleChapterSelect(this,13)" aria-label="选择章节"></span>
|
<span class="ch-select" onclick="event.stopPropagation();toggleChapterSelect(this,13)" aria-label="选择章节"></span>
|
||||||
<span class="ch-num">第13章</span><span class="ch-title">初入宗门</span><span class="ch-badge done">定稿</span>
|
<span class="ch-num">第13章</span><span class="ch-title" ondblclick="startSidebarTitleEdit('chapter',13,this)">初入宗门</span><span class="ch-badge done">定稿</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1800,52 +1894,66 @@
|
|||||||
<button class="btn" style="font-size:10px;padding:3px 8px;">已覆盖</button>
|
<button class="btn" style="font-size:10px;padding:3px 8px;">已覆盖</button>
|
||||||
<button class="btn" style="font-size:10px;padding:3px 8px;margin-left:auto;" onclick="showToast('进入大纲-正文对照模式')" title="大纲-正文对照">⇔ 对照</button>
|
<button class="btn" style="font-size:10px;padding:3px 8px;margin-left:auto;" onclick="showToast('进入大纲-正文对照模式')" title="大纲-正文对照">⇔ 对照</button>
|
||||||
</div>
|
</div>
|
||||||
<div style="padding:4px 8px;font-size:10px;color:var(--text-muted);">大纲树(拖拽排序 · 点击关联章节)</div>
|
<div class="panel-list-body" id="outline-list">
|
||||||
<div class="outline-item uncovered" title="预期 3000字 vs 实际 0字 — 偏差100%">⚠ 第一卷核心剧情线</div>
|
<div style="padding:4px 8px;font-size:10px;color:var(--text-muted);">大纲树(拖拽排序 · 点击编辑)</div>
|
||||||
<div class="outline-item uncovered" style="padding-left:28px;" title="预期 2000字 vs 实际 0字 — 偏差100%">⚠ 林远觉醒三纹灵根</div>
|
<div class="outline-item uncovered" data-id="ol-1" data-status="uncovered" onclick="openOutlineItem(this,'ol-1')" title="预期 3000字 vs 实际 0字 — 偏差100%"><span class="item-prefix">⚠</span><span class="item-title" ondblclick="startSidebarTitleEdit('outline','ol-1',this)">第一卷核心剧情线</span></div>
|
||||||
<div class="outline-item" style="padding-left:28px;color:var(--accent-light);">▶ 入门考验(写作中 — 第3章)</div>
|
<div class="outline-item uncovered" data-id="ol-2" data-status="uncovered" style="padding-left:28px;" onclick="openOutlineItem(this,'ol-2')" title="预期 2000字 vs 实际 0字 — 偏差100%"><span class="item-prefix">⚠</span><span class="item-title" ondblclick="startSidebarTitleEdit('outline','ol-2',this)">林远觉醒三纹灵根</span></div>
|
||||||
<div class="outline-item" style="padding-left:28px;color:var(--green);">✓ 拜入青云宗(第13章 · 2,100字)</div>
|
<div class="outline-item" data-id="ol-3" data-status="writing" style="padding-left:28px;" onclick="openOutlineItem(this,'ol-3')"><span class="item-prefix">▶</span><span class="item-title" ondblclick="startSidebarTitleEdit('outline','ol-3',this)">入门考验</span><span class="item-meta">(写作中 — 第3章)</span></div>
|
||||||
<div style="padding:8px;text-align:center;font-size:10px;color:var(--text-dim);">右键大纲条目 → "生成章节骨架"</div>
|
<div class="outline-item" data-id="ol-4" data-status="done" style="padding-left:28px;" onclick="openOutlineItem(this,'ol-4')"><span class="item-prefix">✓</span><span class="item-title" ondblclick="startSidebarTitleEdit('outline','ol-4',this)">拜入青云宗</span><span class="item-meta">(第13章 · 2,100字)</span></div>
|
||||||
|
</div>
|
||||||
|
<div class="sidebar-footer">
|
||||||
|
<button class="primary-new" onclick="addOutlineItem()" aria-label="新建大纲"><i class="fa-solid fa-plus"></i> 新建大纲</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- 设定面板(书籍设定:人物、技能、世界观、场景) -->
|
<!-- 设定面板(书籍设定:人物、技能、世界观、场景) -->
|
||||||
<div class="sidebar-panel" id="setting-panel">
|
<div class="sidebar-panel" id="setting-panel">
|
||||||
<div class="setting-cat">👤 人物角色 <span style="float:right;font-weight:400;color:var(--accent-light);cursor:pointer;">+添加</span></div>
|
<div class="panel-list-body" id="setting-list">
|
||||||
<div class="setting-entry" onclick="showToast('打开角色详情:林远')">
|
<div class="setting-cat" data-category="char">👤 人物角色</div>
|
||||||
<span class="entry-icon char">👤</span><span class="entry-name">林远</span><span class="entry-count">主角</span>
|
<div class="setting-entry" data-id="set-1" data-category="char" onclick="openSettingItem(this,'set-1')">
|
||||||
|
<span class="entry-icon char">👤</span><span class="entry-name" ondblclick="startSidebarTitleEdit('setting','set-1',this)">林远</span><span class="entry-count">主角</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="setting-entry" onclick="showToast('打开角色详情:白发长老')">
|
<div class="setting-entry" data-id="set-2" data-category="char" onclick="openSettingItem(this,'set-2')">
|
||||||
<span class="entry-icon char">🧓</span><span class="entry-name">白发长老</span><span class="entry-count">配角</span>
|
<span class="entry-icon char">🧓</span><span class="entry-name" ondblclick="startSidebarTitleEdit('setting','set-2',this)">白发长老</span><span class="entry-count">配角</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="setting-entry" onclick="showToast('打开角色详情:柳青烟')">
|
<div class="setting-entry" data-id="set-3" data-category="char" onclick="openSettingItem(this,'set-3')">
|
||||||
<span class="entry-icon char">👩</span><span class="entry-name">柳青烟</span><span class="entry-count">女主</span>
|
<span class="entry-icon char">👩</span><span class="entry-name" ondblclick="startSidebarTitleEdit('setting','set-3',this)">柳青烟</span><span class="entry-count">女主</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="setting-cat">⚔ 技能功法 <span style="float:right;font-weight:400;color:var(--accent-light);cursor:pointer;">+添加</span></div>
|
<div class="setting-cat" data-category="skill">⚔ 技能功法</div>
|
||||||
<div class="setting-entry" onclick="showToast('打开技能详情:青云诀')">
|
<div class="setting-entry" data-id="set-4" data-category="skill" onclick="openSettingItem(this,'set-4')">
|
||||||
<span class="entry-icon skill">📜</span><span class="entry-name">青云诀</span><span class="entry-count">核心功法</span>
|
<span class="entry-icon skill">📜</span><span class="entry-name" ondblclick="startSidebarTitleEdit('setting','set-4',this)">青云诀</span><span class="entry-count">核心功法</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="setting-entry" onclick="showToast('打开技能详情:玄火掌')">
|
<div class="setting-entry" data-id="set-5" data-category="skill" onclick="openSettingItem(this,'set-5')">
|
||||||
<span class="entry-icon skill">🔥</span><span class="entry-name">玄火掌</span><span class="entry-count">三阶段</span>
|
<span class="entry-icon skill">🔥</span><span class="entry-name" ondblclick="startSidebarTitleEdit('setting','set-5',this)">玄火掌</span><span class="entry-count">三阶段</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="setting-cat">🌍 世界观 <span style="float:right;font-weight:400;color:var(--accent-light);cursor:pointer;">+添加</span></div>
|
<div class="setting-cat" data-category="world">🌍 世界观</div>
|
||||||
<div class="setting-entry" onclick="showToast('打开世界观条目:九州大陆')">
|
<div class="setting-entry" data-id="set-6" data-category="world" onclick="openSettingItem(this,'set-6')">
|
||||||
<span class="entry-icon world">🗺</span><span class="entry-name">九州大陆</span><span class="entry-count">地理</span>
|
<span class="entry-icon world">🗺</span><span class="entry-name" ondblclick="startSidebarTitleEdit('setting','set-6',this)">九州大陆</span><span class="entry-count">地理</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="setting-entry" onclick="showToast('打开世界观条目:灵根体系')">
|
<div class="setting-entry" data-id="set-7" data-category="world" onclick="openSettingItem(this,'set-7')">
|
||||||
<span class="entry-icon world">✨</span><span class="entry-name">灵根体系</span><span class="entry-count">力量体系</span>
|
<span class="entry-icon world">✨</span><span class="entry-name" ondblclick="startSidebarTitleEdit('setting','set-7',this)">灵根体系</span><span class="entry-count">力量体系</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="setting-cat">🏞 场景地点 <span style="float:right;font-weight:400;color:var(--accent-light);cursor:pointer;">+添加</span></div>
|
<div class="setting-cat" data-category="scene">🏞 场景地点</div>
|
||||||
<div class="setting-entry" onclick="showToast('打开场景详情:青云宗演武场')">
|
<div class="setting-entry" data-id="set-8" data-category="scene" onclick="openSettingItem(this,'set-8')">
|
||||||
<span class="entry-icon scene">🏟</span><span class="entry-name">青云宗演武场</span><span class="entry-count">第3章</span>
|
<span class="entry-icon scene">🏟</span><span class="entry-name" ondblclick="startSidebarTitleEdit('setting','set-8',this)">青云宗演武场</span><span class="entry-count">第3章</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="setting-entry" onclick="showToast('打开场景详情:青石村')">
|
<div class="setting-entry" data-id="set-9" data-category="scene" onclick="openSettingItem(this,'set-9')">
|
||||||
<span class="entry-icon scene">🏡</span><span class="entry-name">青石村</span><span class="entry-count">第1章</span>
|
<span class="entry-icon scene">🏡</span><span class="entry-name" ondblclick="startSidebarTitleEdit('setting','set-9',this)">青石村</span><span class="entry-count">第1章</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="sidebar-footer">
|
||||||
|
<button class="primary-new" onclick="addSettingItem()" aria-label="新建设定"><i class="fa-solid fa-plus"></i> 新建设定</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- 灵感面板 -->
|
<!-- 灵感面板 -->
|
||||||
<div class="sidebar-panel" id="inspiration-panel" style="padding:8px;">
|
<div class="sidebar-panel" id="inspiration-panel" style="padding:8px;">
|
||||||
<div style="padding:8px;color:var(--text-muted);font-size:11px;text-align:center;">💡 灵感碎片(点击可转为大纲或设定)</div>
|
<div class="panel-list-body" id="inspiration-list">
|
||||||
<div style="padding:6px 8px;cursor:pointer;border-radius:4px;font-size:12px;color:var(--text-secondary);background:var(--bg-tertiary);margin:2px 0;">林远的身世可能与太上长老有关...</div>
|
<div style="padding:8px;color:var(--text-muted);font-size:11px;text-align:center;">💡 灵感碎片(点击编辑 · 可转为大纲或设定)</div>
|
||||||
<div style="padding:6px 8px;cursor:pointer;border-radius:4px;font-size:12px;color:var(--text-secondary);background:var(--bg-tertiary);margin:2px 0;">考虑在第三卷加入妖兽潮事件</div>
|
<div class="inspiration-item" data-id="ins-1" onclick="openInspirationItem(this,'ins-1')"><span class="item-title" ondblclick="startSidebarTitleEdit('inspiration','ins-1',this)">林远的身世可能与太上长老有关...</span></div>
|
||||||
|
<div class="inspiration-item" data-id="ins-2" onclick="openInspirationItem(this,'ins-2')"><span class="item-title" ondblclick="startSidebarTitleEdit('inspiration','ins-2',this)">考虑在第三卷加入妖兽潮事件</span></div>
|
||||||
|
</div>
|
||||||
|
<div class="sidebar-footer">
|
||||||
|
<button class="primary-new" onclick="addInspirationItem()" aria-label="新建灵感"><i class="fa-solid fa-plus"></i> 新建灵感</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -1866,7 +1974,7 @@
|
|||||||
<button class="tool-btn" id="btnTTS" title="朗读 Ctrl+Shift+R" aria-label="朗读" onclick="toggleTTS()"><i class="fa-solid fa-headphones"></i></button>
|
<button class="tool-btn" id="btnTTS" title="朗读 Ctrl+Shift+R" aria-label="朗读" onclick="toggleTTS()"><i class="fa-solid fa-headphones"></i></button>
|
||||||
<button class="tool-btn" id="btnReference" title="引用面板 Ctrl+Shift+P" aria-label="引用面板" onclick="toggleReferencePanel()"><i class="fa-solid fa-book-bookmark"></i></button>
|
<button class="tool-btn" id="btnReference" title="引用面板 Ctrl+Shift+P" aria-label="引用面板" onclick="toggleReferencePanel()"><i class="fa-solid fa-book-bookmark"></i></button>
|
||||||
<span class="sep"></span>
|
<span class="sep"></span>
|
||||||
<span class="ch-label">当前:第3章 · 入门考验</span>
|
<span class="ch-label" id="editorDocLabel" ondblclick="startEditorLabelEdit(event)" title="双击修改标题">当前:第3章 · 入门考验</span>
|
||||||
<span class="word-count-display" id="wordCountDisplay" title="点击查看用词分析" onclick="showWordFreqInPanel()">2,847 字</span>
|
<span class="word-count-display" id="wordCountDisplay" title="点击查看用词分析" onclick="showWordFreqInPanel()">2,847 字</span>
|
||||||
<button class="tool-btn" title="保存快照 Ctrl+Alt+S" aria-label="保存快照" onclick="saveSnapshot()"><i class="fa-solid fa-camera"></i></button>
|
<button class="tool-btn" title="保存快照 Ctrl+Alt+S" aria-label="保存快照" onclick="saveSnapshot()"><i class="fa-solid fa-camera"></i></button>
|
||||||
<button class="tool-btn" title="导出当前章节" aria-label="导出" onclick="showExportModal()"><i class="fa-solid fa-download"></i></button>
|
<button class="tool-btn" title="导出当前章节" aria-label="导出" onclick="showExportModal()"><i class="fa-solid fa-download"></i></button>
|
||||||
@@ -2177,7 +2285,7 @@
|
|||||||
<div class="modal-header"><h3>📖 新建书籍</h3><button class="modal-close" onclick="closeModal('newBookModal')">✕</button></div>
|
<div class="modal-header"><h3>📖 新建书籍</h3><button class="modal-close" onclick="closeModal('newBookModal')">✕</button></div>
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
<div class="form-group"><label>书名</label><input type="text" placeholder="输入书名..." id="newBookName"></div>
|
<div class="form-group"><label>书名</label><input type="text" placeholder="输入书名..." id="newBookName"></div>
|
||||||
<div class="form-group"><label>分类(可多选)</label><select multiple style="height:80px;" id="newBookCategory"><option>玄幻</option><option>仙侠</option><option>科幻</option><option>言情</option><option>悬疑</option><option>历史</option></select></div>
|
<div class="form-group"><label>分类</label><select id="newBookCategory"><option value="玄幻">玄幻</option><option value="仙侠">仙侠</option><option value="科幻">科幻</option><option value="言情">言情</option><option value="悬疑">悬疑</option><option value="历史">历史</option></select></div>
|
||||||
<div class="form-group"><label>目标总字数(可选)</label><input type="number" placeholder="如:500000" id="newBookTarget"></div>
|
<div class="form-group"><label>目标总字数(可选)</label><input type="number" placeholder="如:500000" id="newBookTarget"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-footer">
|
<div class="modal-footer">
|
||||||
@@ -2425,6 +2533,37 @@
|
|||||||
let isSpellCheckOn = false;
|
let isSpellCheckOn = false;
|
||||||
let pomodoroSeconds = 25*60, pomodoroInterval = null, pomodoroRunning = false;
|
let pomodoroSeconds = 25*60, pomodoroInterval = null, pomodoroRunning = false;
|
||||||
let onboardingStep = 0, onboardingTotalSlides = 5;
|
let onboardingStep = 0, onboardingTotalSlides = 5;
|
||||||
|
// 编辑器与侧栏状态
|
||||||
|
let activeVolId = 'vol-1';
|
||||||
|
const volLastChapter = { 'vol-1': 3, 'vol-2': 13 };
|
||||||
|
let currentEdit = { type: 'chapter', id: '3' };
|
||||||
|
const editorStore = {};
|
||||||
|
let outlineSeq = 4, settingSeq = 9, inspirationSeq = 2;
|
||||||
|
const settingCategoryMeta = {
|
||||||
|
char: { label: '人物角色', icon: '👤', iconClass: 'char', tag: '未分类' },
|
||||||
|
skill: { label: '技能功法', icon: '📜', iconClass: 'skill', tag: '未分类' },
|
||||||
|
world: { label: '世界观', icon: '🗺', iconClass: 'world', tag: '未分类' },
|
||||||
|
scene: { label: '场景地点', icon: '🏟', iconClass: 'scene', tag: '未分类' }
|
||||||
|
};
|
||||||
|
let lastSettingCategory = 'char';
|
||||||
|
const outlineTitles = {
|
||||||
|
'ol-1': '第一卷核心剧情线', 'ol-2': '林远觉醒三纹灵根',
|
||||||
|
'ol-3': '入门考验', 'ol-4': '拜入青云宗'
|
||||||
|
};
|
||||||
|
const settingTitles = {
|
||||||
|
'set-1': '林远', 'set-2': '白发长老', 'set-3': '柳青烟',
|
||||||
|
'set-4': '青云诀', 'set-5': '玄火掌', 'set-6': '九州大陆',
|
||||||
|
'set-7': '灵根体系', 'set-8': '青云宗演武场', 'set-9': '青石村'
|
||||||
|
};
|
||||||
|
const inspirationTitles = {
|
||||||
|
'ins-1': '林远的身世可能与太上长老有关...',
|
||||||
|
'ins-2': '考虑在第三卷加入妖兽潮事件'
|
||||||
|
};
|
||||||
|
const chapterTitles = {
|
||||||
|
'1': '山村少年', '2': '意外觉醒', '3': '入门考验',
|
||||||
|
'4': '灵根测试', '5': '初次修炼', '13': '初入宗门'
|
||||||
|
};
|
||||||
|
let activeInlineEdit = null;
|
||||||
const books = [
|
const books = [
|
||||||
{ id: 1, name: '仙道长青', category: '玄幻', words: 128450, target: 500000, status: 'ongoing',
|
{ id: 1, name: '仙道长青', category: '玄幻', words: 128450, target: 500000, status: 'ongoing',
|
||||||
icon: '📖', color: '#7c8cf8', lastEdit: '10分钟前', chapters: 20, vols: 2 },
|
icon: '📖', color: '#7c8cf8', lastEdit: '10分钟前', chapters: 20, vols: 2 },
|
||||||
@@ -2573,7 +2712,7 @@
|
|||||||
function createNewBook() {
|
function createNewBook() {
|
||||||
const name = document.getElementById('newBookName').value.trim();
|
const name = document.getElementById('newBookName').value.trim();
|
||||||
if (!name) { showToast('请输入书名'); return; }
|
if (!name) { showToast('请输入书名'); return; }
|
||||||
const category = document.getElementById('newBookCategory').selectedOptions[0]?.value || '未分类';
|
const category = document.getElementById('newBookCategory').value || '未分类';
|
||||||
const target = parseInt(document.getElementById('newBookTarget').value) || 300000;
|
const target = parseInt(document.getElementById('newBookTarget').value) || 300000;
|
||||||
const newBook = { id: Date.now(), name, category, words: 0, target, status: 'draft', icon: '📕',
|
const newBook = { id: Date.now(), name, category, words: 0, target, status: 'draft', icon: '📕',
|
||||||
color: '#7c8cf8', lastEdit: '刚刚', chapters: 0, vols: 1 };
|
color: '#7c8cf8', lastEdit: '刚刚', chapters: 0, vols: 1 };
|
||||||
@@ -2594,7 +2733,20 @@
|
|||||||
if (!t) { showToast('请输入灵感内容'); return; }
|
if (!t) { showToast('请输入灵感内容'); return; }
|
||||||
closeModal('inspirationModal');
|
closeModal('inspirationModal');
|
||||||
document.getElementById('inspirationInput').value = '';
|
document.getElementById('inspirationInput').value = '';
|
||||||
showToast('💡 灵感已保存');
|
const id = 'ins-' + (++inspirationSeq);
|
||||||
|
inspirationTitles[id] = t.slice(0, 60);
|
||||||
|
editorStore[editorKey('inspiration', id)] = t;
|
||||||
|
const list = document.getElementById('inspiration-list');
|
||||||
|
const item = document.createElement('div');
|
||||||
|
item.className = 'inspiration-item';
|
||||||
|
item.dataset.id = id;
|
||||||
|
item.onclick = function() { openInspirationItem(this, id); };
|
||||||
|
item.innerHTML = `<span class="item-title">${inspirationTitles[id]}</span>`;
|
||||||
|
bindTitleDblClick(item, 'inspiration', id, '.item-title');
|
||||||
|
list.appendChild(item);
|
||||||
|
switchSidebarPanel('inspiration-panel', document.querySelector('[data-sidebar-panel="inspiration-panel"]'));
|
||||||
|
openInspirationItem(item, id);
|
||||||
|
showToast('💡 灵感已保存并打开编辑');
|
||||||
}
|
}
|
||||||
function toggleReferencePanel() {
|
function toggleReferencePanel() {
|
||||||
var panel = document.getElementById('reference-panel');
|
var panel = document.getElementById('reference-panel');
|
||||||
@@ -2615,6 +2767,229 @@
|
|||||||
showToast('AI 模式:' + (labels[mode] || mode));
|
showToast('AI 模式:' + (labels[mode] || mode));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getEntryTitle(type, id) {
|
||||||
|
const sid = String(id);
|
||||||
|
if (type === 'chapter') return chapterTitles[sid] || document.querySelector(`.chapter-item[data-ch="${sid}"] .ch-title`)?.textContent?.trim() || '未命名';
|
||||||
|
if (type === 'outline') return outlineTitles[sid] || '未命名';
|
||||||
|
if (type === 'setting') return settingTitles[sid] || '未命名';
|
||||||
|
if (type === 'inspiration') return inspirationTitles[sid] || '新灵感…';
|
||||||
|
return '未命名';
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildEditorLabel(type, id, title) {
|
||||||
|
const t = (title || '未命名').trim() || '未命名';
|
||||||
|
if (type === 'chapter') return `当前:第${id}章 · ${t}`;
|
||||||
|
if (type === 'outline') return `大纲:${t}`;
|
||||||
|
if (type === 'setting') {
|
||||||
|
const el = document.querySelector(`.setting-entry[data-id="${id}"]`);
|
||||||
|
const cat = el?.dataset.category || lastSettingCategory || 'char';
|
||||||
|
const catLabel = settingCategoryMeta[cat]?.label || '设定';
|
||||||
|
return `设定·${catLabel}:${t}`;
|
||||||
|
}
|
||||||
|
if (type === 'inspiration') return `灵感:${t.length > 24 ? t.slice(0, 24) + '…' : t}`;
|
||||||
|
return t;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateEditorDocLabel() {
|
||||||
|
const labelEl = document.getElementById('editorDocLabel');
|
||||||
|
if (!labelEl || activeInlineEdit?.displayEl === labelEl) return;
|
||||||
|
labelEl.textContent = buildEditorLabel(currentEdit.type, currentEdit.id, getEntryTitle(currentEdit.type, currentEdit.id));
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateOutlineItemTitle(id, title) {
|
||||||
|
const el = document.querySelector(`.outline-item[data-id="${id}"] .item-title`);
|
||||||
|
if (el && !el.querySelector('.inline-title-input')) el.textContent = title;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateInspirationItemTitle(id, title) {
|
||||||
|
const el = document.querySelector(`.inspiration-item[data-id="${id}"] .item-title`);
|
||||||
|
if (el && !el.querySelector('.inline-title-input')) el.textContent = title;
|
||||||
|
}
|
||||||
|
|
||||||
|
function bindTitleDblClick(el, type, id, selector) {
|
||||||
|
const titleEl = selector ? el.querySelector(selector) : el;
|
||||||
|
if (!titleEl || titleEl.dataset.titleBound) return;
|
||||||
|
titleEl.dataset.titleBound = '1';
|
||||||
|
titleEl.title = '双击修改标题';
|
||||||
|
titleEl.addEventListener('dblclick', function(e) {
|
||||||
|
e.stopPropagation();
|
||||||
|
e.preventDefault();
|
||||||
|
startSidebarTitleEdit(type, id, this);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function setEntryTitle(type, id, title) {
|
||||||
|
const sid = String(id);
|
||||||
|
const clean = (title || '').trim() || '未命名';
|
||||||
|
if (type === 'chapter') {
|
||||||
|
chapterTitles[sid] = clean;
|
||||||
|
const el = document.querySelector(`.chapter-item[data-ch="${sid}"] .ch-title`);
|
||||||
|
if (el && !el.querySelector('.inline-title-input')) el.textContent = clean;
|
||||||
|
} else if (type === 'outline') {
|
||||||
|
outlineTitles[sid] = clean;
|
||||||
|
updateOutlineItemTitle(sid, clean);
|
||||||
|
} else if (type === 'setting') {
|
||||||
|
settingTitles[sid] = clean;
|
||||||
|
const el = document.querySelector(`.setting-entry[data-id="${sid}"] .entry-name`);
|
||||||
|
if (el && !el.querySelector('.inline-title-input')) el.textContent = clean;
|
||||||
|
} else if (type === 'inspiration') {
|
||||||
|
inspirationTitles[sid] = clean;
|
||||||
|
updateInspirationItemTitle(sid, clean);
|
||||||
|
}
|
||||||
|
if (currentEdit.type === type && currentEdit.id === sid) updateEditorDocLabel();
|
||||||
|
}
|
||||||
|
|
||||||
|
function finishInlineTitleEdit(save) {
|
||||||
|
if (!activeInlineEdit) return;
|
||||||
|
const { displayEl, input, commitFn, restoreFn } = activeInlineEdit;
|
||||||
|
const newVal = input.value.trim();
|
||||||
|
const orig = displayEl.dataset.inlineOrig ?? '';
|
||||||
|
displayEl.removeAttribute('data-inline-orig');
|
||||||
|
if (restoreFn) {
|
||||||
|
if (save && newVal) commitFn(newVal);
|
||||||
|
restoreFn();
|
||||||
|
} else {
|
||||||
|
displayEl.textContent = save && newVal ? newVal : orig;
|
||||||
|
if (save && newVal && newVal !== orig) commitFn(newVal);
|
||||||
|
}
|
||||||
|
activeInlineEdit = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function startInlineTitleEdit(displayEl, commitFn, options) {
|
||||||
|
if (activeInlineEdit) finishInlineTitleEdit(true);
|
||||||
|
if (displayEl.querySelector('.inline-title-input')) return;
|
||||||
|
const originalText = (options?.initialValue ?? displayEl.textContent).trim();
|
||||||
|
const input = document.createElement('input');
|
||||||
|
input.type = 'text';
|
||||||
|
input.className = 'inline-title-input' + (options?.inputClass ? ' ' + options.inputClass : '');
|
||||||
|
input.value = originalText;
|
||||||
|
displayEl.dataset.inlineOrig = originalText;
|
||||||
|
displayEl.textContent = '';
|
||||||
|
displayEl.appendChild(input);
|
||||||
|
activeInlineEdit = {
|
||||||
|
displayEl,
|
||||||
|
input,
|
||||||
|
commitFn,
|
||||||
|
restoreFn: options?.restoreFn || null
|
||||||
|
};
|
||||||
|
input.focus();
|
||||||
|
input.select();
|
||||||
|
input.addEventListener('keydown', function(e) {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (e.key === 'Enter') { e.preventDefault(); finishInlineTitleEdit(true); }
|
||||||
|
if (e.key === 'Escape') { e.preventDefault(); finishInlineTitleEdit(false); }
|
||||||
|
});
|
||||||
|
input.addEventListener('click', function(e) { e.stopPropagation(); });
|
||||||
|
input.addEventListener('dblclick', function(e) { e.stopPropagation(); });
|
||||||
|
}
|
||||||
|
|
||||||
|
function startSidebarTitleEdit(type, id, titleEl, e) {
|
||||||
|
const ev = e || window.event;
|
||||||
|
if (ev) { ev.stopPropagation(); ev.preventDefault(); }
|
||||||
|
startInlineTitleEdit(titleEl, function(newTitle) {
|
||||||
|
setEntryTitle(type, id, newTitle);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function startEditorLabelEdit(e) {
|
||||||
|
if (e) { e.stopPropagation(); e.preventDefault(); }
|
||||||
|
const labelEl = document.getElementById('editorDocLabel');
|
||||||
|
const { type, id } = currentEdit;
|
||||||
|
const title = getEntryTitle(type, id);
|
||||||
|
startInlineTitleEdit(labelEl, function(newTitle) {
|
||||||
|
setEntryTitle(type, id, newTitle);
|
||||||
|
}, {
|
||||||
|
initialValue: title,
|
||||||
|
inputClass: 'editor-label-input',
|
||||||
|
restoreFn: function() { updateEditorDocLabel(); }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ EDITOR ============
|
||||||
|
function editorKey(type, id) { return type + ':' + id; }
|
||||||
|
|
||||||
|
function saveCurrentEditor() {
|
||||||
|
if (!currentEdit.type || currentEdit.id == null) return;
|
||||||
|
editorStore[editorKey(currentEdit.type, currentEdit.id)] =
|
||||||
|
document.getElementById('editor-content').innerText;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearListActiveStates() {
|
||||||
|
document.querySelectorAll('.chapter-item.active, .outline-item.active, .setting-entry.active, .inspiration-item.active')
|
||||||
|
.forEach(el => el.classList.remove('active'));
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEditorEntry(type, id, label, fallbackContent) {
|
||||||
|
saveCurrentEditor();
|
||||||
|
currentEdit = { type, id: String(id) };
|
||||||
|
const key = editorKey(type, id);
|
||||||
|
const content = Object.prototype.hasOwnProperty.call(editorStore, key)
|
||||||
|
? editorStore[key]
|
||||||
|
: (fallbackContent ?? '');
|
||||||
|
editorStore[key] = content;
|
||||||
|
const editor = document.getElementById('editor-content');
|
||||||
|
editor.innerText = content;
|
||||||
|
updateEditorDocLabel();
|
||||||
|
updateWordCount();
|
||||||
|
setTimeout(() => {
|
||||||
|
editor.focus();
|
||||||
|
const range = document.createRange();
|
||||||
|
range.selectNodeContents(editor);
|
||||||
|
range.collapse(false);
|
||||||
|
const sel = window.getSelection();
|
||||||
|
sel.removeAllRanges();
|
||||||
|
sel.addRange(range);
|
||||||
|
}, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getChapterDefaultContent(chNum) {
|
||||||
|
if (chNum === 3) {
|
||||||
|
return `晨光透过稀疏的竹帘,在青石地面上投下细碎的光斑。
|
||||||
|
|
||||||
|
林远深吸一口气,推开木门走了出去。门外是一个宽阔的演武场,已经有数十名少年等在那里。他们的脸上有期待,有紧张,也有毫不掩饰的傲气。
|
||||||
|
|
||||||
|
"下一个,林远。"
|
||||||
|
|
||||||
|
听到自己的名字,他迈步走向那块通体漆黑的测灵石。周围的目光齐刷刷地落在他身上——这个来自偏远山村的少年,在入门考核中表现平平,谁也不曾把他放在眼里。
|
||||||
|
|
||||||
|
他将手掌贴上冰冷的石面。
|
||||||
|
|
||||||
|
刹那间,刺目的青光冲天而起,测灵石上浮现出三道清晰的光纹。全场鸦雀无声。
|
||||||
|
|
||||||
|
"三纹灵根……"负责测试的白发长老眯起眼睛,语气中带着一丝惊讶,"有意思。"`;
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function getOutlineDefaultContent(id) {
|
||||||
|
const defaults = {
|
||||||
|
'ol-1': '第一卷主线:林远从山村少年成长为青云宗内门弟子,经历入门考验与灵根觉醒。',
|
||||||
|
'ol-2': '林远在入门考验中觉醒三纹灵根,引发宗门关注。',
|
||||||
|
'ol-3': '入门考验场景:演武场测灵,长老暗中观察林远天赋。',
|
||||||
|
'ol-4': '林远通过考验拜入青云宗,开启宗门修行线。'
|
||||||
|
};
|
||||||
|
return defaults[id] || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSettingDefaultContent(id) {
|
||||||
|
const defaults = {
|
||||||
|
'set-1': '林远\n身份:主角\n灵根:三纹灵根\n性格:沉稳内敛,遇事冷静',
|
||||||
|
'set-2': '白发长老\n身份:青云宗测试长老\n与林远:暗中关注其天赋',
|
||||||
|
'set-3': '柳青烟\n身份:女主\n与林远:同门师妹',
|
||||||
|
'set-4': '青云诀\n类型:核心功法\n阶段:入门、小成、大成',
|
||||||
|
'set-5': '玄火掌\n类型:攻击武技\n阶段:三阶段',
|
||||||
|
'set-6': '九州大陆\n范围:故事主世界地理设定',
|
||||||
|
'set-7': '灵根体系\n说明:灵根品阶决定修炼天赋上限',
|
||||||
|
'set-8': '青云宗演武场\n出现章节:第3章\n用途:入门考验场地',
|
||||||
|
'set-9': '青石村\n出现章节:第1章\n说明:林远故乡'
|
||||||
|
};
|
||||||
|
return defaults[id] || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function getInspirationDefaultContent(id) {
|
||||||
|
return inspirationTitles[id] || '';
|
||||||
|
}
|
||||||
|
|
||||||
// ============ SIDEBAR ============
|
// ============ SIDEBAR ============
|
||||||
function switchSidebarPanel(panelId, btn) {
|
function switchSidebarPanel(panelId, btn) {
|
||||||
document.querySelectorAll('#left-sidebar .sidebar-panel').forEach(p => p.classList.remove('active'));
|
document.querySelectorAll('#left-sidebar .sidebar-panel').forEach(p => p.classList.remove('active'));
|
||||||
@@ -2623,45 +2998,215 @@
|
|||||||
btn.classList.add('active');
|
btn.classList.add('active');
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleVol(header) {
|
function toggleVolCollapse(header) {
|
||||||
header.classList.toggle('open');
|
header.classList.toggle('open');
|
||||||
header.nextElementSibling.classList.toggle('hidden');
|
header.nextElementSibling.classList.toggle('hidden');
|
||||||
}
|
}
|
||||||
|
|
||||||
function selectChapter(el, chNum) {
|
function selectVolume(header) {
|
||||||
document.querySelectorAll('.chapter-item').forEach(i => i.classList.remove('active'));
|
const volGroup = header.closest('.vol-group');
|
||||||
|
if (!volGroup) return;
|
||||||
|
const volId = volGroup.dataset.volId;
|
||||||
|
if (!header.classList.contains('open')) {
|
||||||
|
header.classList.add('open');
|
||||||
|
header.nextElementSibling.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
document.querySelectorAll('.vol-header').forEach(h => h.classList.remove('active'));
|
||||||
|
header.classList.add('active');
|
||||||
|
activeVolId = volId;
|
||||||
|
const volChapters = volGroup.querySelector('.vol-chapters');
|
||||||
|
const chapters = volChapters.querySelectorAll('.chapter-item');
|
||||||
|
if (chapters.length === 0) {
|
||||||
|
showToast('该卷暂无章节,点击「新章」在此卷创建');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const lastCh = volLastChapter[volId];
|
||||||
|
let target = lastCh ? volChapters.querySelector(`[data-ch="${lastCh}"]`) : null;
|
||||||
|
if (!target) target = chapters[0];
|
||||||
|
selectChapter(target, parseInt(target.dataset.ch, 10), { skipVolUpdate: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectChapter(el, chNum, opts) {
|
||||||
|
if (!el) return;
|
||||||
|
clearListActiveStates();
|
||||||
el.classList.add('active');
|
el.classList.add('active');
|
||||||
document.querySelector('#editor-toolbar .ch-label').textContent = `当前:第${chNum}章 · ${el.querySelector('.ch-title').textContent}`;
|
if (!opts?.skipVolUpdate) {
|
||||||
wordCount = Math.floor(Math.random() * 3000) + 1500;
|
const volGroup = el.closest('.vol-group');
|
||||||
updateWordCount();
|
if (volGroup) {
|
||||||
|
activeVolId = volGroup.dataset.volId;
|
||||||
|
volLastChapter[activeVolId] = chNum;
|
||||||
|
document.querySelectorAll('.vol-header').forEach(h => h.classList.remove('active'));
|
||||||
|
const header = volGroup.querySelector('.vol-header');
|
||||||
|
if (header) header.classList.add('active');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const title = getEntryTitle('chapter', chNum);
|
||||||
|
openEditorEntry('chapter', chNum, buildEditorLabel('chapter', chNum, title), getChapterDefaultContent(chNum));
|
||||||
showToast(`已切换到第${chNum}章`);
|
showToast(`已切换到第${chNum}章`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getNextChapterNum() {
|
||||||
|
let max = 0;
|
||||||
|
document.querySelectorAll('.chapter-item').forEach(item => {
|
||||||
|
max = Math.max(max, parseInt(item.dataset.ch, 10) || 0);
|
||||||
|
});
|
||||||
|
return max + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getActiveVolChapters() {
|
||||||
|
const vol = document.querySelector(`.vol-group[data-vol-id="${activeVolId}"]`);
|
||||||
|
if (vol) return vol.querySelector('.vol-chapters');
|
||||||
|
return document.querySelector('.vol-chapters');
|
||||||
|
}
|
||||||
|
|
||||||
function addChapter() {
|
function addChapter() {
|
||||||
const tree = document.getElementById('chapter-tree');
|
const volChapters = getActiveVolChapters();
|
||||||
const volChapters = tree.querySelector('.vol-chapters:not(.hidden)') || tree.querySelector('.vol-chapters');
|
if (!volChapters) { showToast('请先选择分卷'); return; }
|
||||||
const chNum = volChapters.querySelectorAll('.chapter-item').length + 1;
|
const volGroup = volChapters.closest('.vol-group');
|
||||||
|
const volHeader = volGroup?.querySelector('.vol-header');
|
||||||
|
if (volHeader && !volHeader.classList.contains('open')) {
|
||||||
|
volHeader.classList.add('open');
|
||||||
|
volChapters.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
const chNum = getNextChapterNum();
|
||||||
const newCh = document.createElement('div');
|
const newCh = document.createElement('div');
|
||||||
newCh.className = 'chapter-item';
|
newCh.className = 'chapter-item';
|
||||||
newCh.setAttribute('data-ch', chNum);
|
newCh.setAttribute('data-ch', chNum);
|
||||||
|
newCh.setAttribute('tabindex', '0');
|
||||||
|
newCh.setAttribute('role', 'treeitem');
|
||||||
newCh.onclick = function() { selectChapter(this, chNum); };
|
newCh.onclick = function() { selectChapter(this, chNum); };
|
||||||
newCh.innerHTML =
|
newCh.innerHTML =
|
||||||
|
`<span class="ch-select" onclick="event.stopPropagation();toggleChapterSelect(this,${chNum})" aria-label="选择章节"></span>` +
|
||||||
`<span class="ch-num">第${chNum}章</span><span class="ch-title">新章节</span><span class="ch-badge draft">草稿</span>`;
|
`<span class="ch-num">第${chNum}章</span><span class="ch-title">新章节</span><span class="ch-badge draft">草稿</span>`;
|
||||||
volChapters.appendChild(newCh);
|
volChapters.appendChild(newCh);
|
||||||
showToast(`已创建第${chNum}章`);
|
chapterTitles[String(chNum)] = '新章节';
|
||||||
|
bindTitleDblClick(newCh, 'chapter', chNum, '.ch-title');
|
||||||
|
if (volGroup) {
|
||||||
|
activeVolId = volGroup.dataset.volId;
|
||||||
|
volLastChapter[activeVolId] = chNum;
|
||||||
|
document.querySelectorAll('.vol-header').forEach(h => h.classList.remove('active'));
|
||||||
|
if (volHeader) volHeader.classList.add('active');
|
||||||
|
}
|
||||||
|
editorStore[editorKey('chapter', chNum)] = '';
|
||||||
selectChapter(newCh, chNum);
|
selectChapter(newCh, chNum);
|
||||||
|
showToast(`已在当前卷创建第${chNum}章`);
|
||||||
}
|
}
|
||||||
|
|
||||||
function addVolume() {
|
function addVolume() {
|
||||||
const tree = document.getElementById('chapter-tree');
|
const tree = document.getElementById('chapter-tree');
|
||||||
const volNum = tree.querySelectorAll('.vol-group').length + 1;
|
const volNum = tree.querySelectorAll('.vol-group').length + 1;
|
||||||
|
const volId = 'vol-' + Date.now();
|
||||||
const volGroup = document.createElement('div');
|
const volGroup = document.createElement('div');
|
||||||
volGroup.className = 'vol-group';
|
volGroup.className = 'vol-group';
|
||||||
volGroup.innerHTML = `<div class="vol-header open" onclick="toggleVol(this)"><span class="vol-arrow">▶</span> 第${volNum}卷·未命名<span class="vol-count">0章 · 0字</span></div><div class="vol-chapters"></div>`;
|
volGroup.dataset.volId = volId;
|
||||||
|
volGroup.innerHTML =
|
||||||
|
`<div class="vol-header open" onclick="selectVolume(this)" oncontextmenu="showVolContextMenu(event,this);return false;">` +
|
||||||
|
`<span class="vol-arrow" onclick="event.stopPropagation();toggleVolCollapse(this.parentElement)">▶</span> 第${volNum}卷·未命名` +
|
||||||
|
`<span class="vol-count">0章 · 0字</span>` +
|
||||||
|
`<span class="vol-menu-btn" onclick="event.stopPropagation();showVolContextMenu(event,this.parentElement);" aria-label="分卷菜单">⋯</span></div>` +
|
||||||
|
`<div class="vol-chapters"></div>`;
|
||||||
tree.appendChild(volGroup);
|
tree.appendChild(volGroup);
|
||||||
|
volLastChapter[volId] = null;
|
||||||
|
selectVolume(volGroup.querySelector('.vol-header'));
|
||||||
showToast(`已创建第${volNum}卷`);
|
showToast(`已创建第${volNum}卷`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function openOutlineItem(el, id) {
|
||||||
|
clearListActiveStates();
|
||||||
|
el.classList.add('active');
|
||||||
|
const title = getEntryTitle('outline', id);
|
||||||
|
openEditorEntry('outline', id, buildEditorLabel('outline', id, title), getOutlineDefaultContent(id));
|
||||||
|
showToast('正在编辑大纲:' + title);
|
||||||
|
}
|
||||||
|
|
||||||
|
function addOutlineItem() {
|
||||||
|
const id = 'ol-' + (++outlineSeq);
|
||||||
|
outlineTitles[id] = '新大纲条目';
|
||||||
|
editorStore[editorKey('outline', id)] = '';
|
||||||
|
const list = document.getElementById('outline-list');
|
||||||
|
const item = document.createElement('div');
|
||||||
|
item.className = 'outline-item uncovered';
|
||||||
|
item.dataset.id = id;
|
||||||
|
item.dataset.status = 'uncovered';
|
||||||
|
item.title = '新建条目,尚未关联章节';
|
||||||
|
item.onclick = function() { openOutlineItem(this, id); };
|
||||||
|
item.innerHTML = `<span class="item-prefix">⚠</span><span class="item-title">${outlineTitles[id]}</span>`;
|
||||||
|
list.appendChild(item);
|
||||||
|
bindTitleDblClick(item, 'outline', id, '.item-title');
|
||||||
|
switchSidebarPanel('outline-panel', document.querySelector('[data-sidebar-panel="outline-panel"]'));
|
||||||
|
openOutlineItem(item, id);
|
||||||
|
showToast('已创建并打开新大纲条目');
|
||||||
|
}
|
||||||
|
|
||||||
|
function openSettingItem(el, id) {
|
||||||
|
clearListActiveStates();
|
||||||
|
el.classList.add('active');
|
||||||
|
lastSettingCategory = el.dataset.category || 'char';
|
||||||
|
const title = getEntryTitle('setting', id);
|
||||||
|
const catLabel = settingCategoryMeta[lastSettingCategory]?.label || '设定';
|
||||||
|
openEditorEntry('setting', id, buildEditorLabel('setting', id, title), getSettingDefaultContent(id));
|
||||||
|
showToast('正在编辑设定:' + title);
|
||||||
|
}
|
||||||
|
|
||||||
|
function addSettingItem() {
|
||||||
|
const category = lastSettingCategory || 'char';
|
||||||
|
const meta = settingCategoryMeta[category];
|
||||||
|
const id = 'set-' + (++settingSeq);
|
||||||
|
settingTitles[id] = '新设定';
|
||||||
|
editorStore[editorKey('setting', id)] = meta.label + '\n\n';
|
||||||
|
const list = document.getElementById('setting-list');
|
||||||
|
let anchor = list.querySelector(`.setting-cat[data-category="${category}"]`);
|
||||||
|
if (!anchor) anchor = list.querySelector('.setting-cat');
|
||||||
|
const entry = document.createElement('div');
|
||||||
|
entry.className = 'setting-entry';
|
||||||
|
entry.dataset.id = id;
|
||||||
|
entry.dataset.category = category;
|
||||||
|
entry.onclick = function() { openSettingItem(this, id); };
|
||||||
|
entry.innerHTML =
|
||||||
|
`<span class="entry-icon ${meta.iconClass}">${meta.icon}</span>` +
|
||||||
|
`<span class="entry-name">${settingTitles[id]}</span><span class="entry-count">${meta.tag}</span>`;
|
||||||
|
bindTitleDblClick(entry, 'setting', id, '.entry-name');
|
||||||
|
if (anchor) {
|
||||||
|
let insertAfter = anchor;
|
||||||
|
while (insertAfter.nextElementSibling?.classList.contains('setting-entry')) {
|
||||||
|
insertAfter = insertAfter.nextElementSibling;
|
||||||
|
}
|
||||||
|
insertAfter.insertAdjacentElement('afterend', entry);
|
||||||
|
} else {
|
||||||
|
list.appendChild(entry);
|
||||||
|
}
|
||||||
|
switchSidebarPanel('setting-panel', document.querySelector('[data-sidebar-panel="setting-panel"]'));
|
||||||
|
openSettingItem(entry, id);
|
||||||
|
showToast('已创建并打开新设定条目');
|
||||||
|
}
|
||||||
|
|
||||||
|
function openInspirationItem(el, id) {
|
||||||
|
clearListActiveStates();
|
||||||
|
el.classList.add('active');
|
||||||
|
const title = getEntryTitle('inspiration', id);
|
||||||
|
openEditorEntry('inspiration', id, buildEditorLabel('inspiration', id, title), getInspirationDefaultContent(id));
|
||||||
|
showToast('正在编辑灵感');
|
||||||
|
}
|
||||||
|
|
||||||
|
function addInspirationItem() {
|
||||||
|
const id = 'ins-' + (++inspirationSeq);
|
||||||
|
inspirationTitles[id] = '新灵感…';
|
||||||
|
editorStore[editorKey('inspiration', id)] = '';
|
||||||
|
const list = document.getElementById('inspiration-list');
|
||||||
|
const item = document.createElement('div');
|
||||||
|
item.className = 'inspiration-item';
|
||||||
|
item.dataset.id = id;
|
||||||
|
item.onclick = function() { openInspirationItem(this, id); };
|
||||||
|
item.innerHTML = `<span class="item-title">${inspirationTitles[id]}</span>`;
|
||||||
|
bindTitleDblClick(item, 'inspiration', id, '.item-title');
|
||||||
|
list.appendChild(item);
|
||||||
|
switchSidebarPanel('inspiration-panel', document.querySelector('[data-sidebar-panel="inspiration-panel"]'));
|
||||||
|
openInspirationItem(item, id);
|
||||||
|
showToast('已创建并打开新灵感');
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleVol(header) { toggleVolCollapse(header); }
|
||||||
|
|
||||||
// ============ RIGHT PANEL ============
|
// ============ RIGHT PANEL ============
|
||||||
function switchRightPanel(panelId, btn) {
|
function switchRightPanel(panelId, btn) {
|
||||||
document.querySelectorAll('#right-panel .panel-content').forEach(p => p.classList.remove('active'));
|
document.querySelectorAll('#right-panel .panel-content').forEach(p => p.classList.remove('active'));
|
||||||
@@ -2736,11 +3281,16 @@
|
|||||||
function updateWordCount() {
|
function updateWordCount() {
|
||||||
const content = document.getElementById('editor-content').innerText;
|
const content = document.getElementById('editor-content').innerText;
|
||||||
wordCount = content.replace(/\s/g, '').length;
|
wordCount = content.replace(/\s/g, '').length;
|
||||||
|
saveCurrentEditor();
|
||||||
const display = document.getElementById('wordCountDisplay');
|
const display = document.getElementById('wordCountDisplay');
|
||||||
display.textContent = wordCount.toLocaleString() + ' 字';
|
display.textContent = wordCount.toLocaleString() + ' 字';
|
||||||
display.classList.toggle('warn', wordCount >= 3000);
|
display.classList.toggle('warn', currentEdit.type === 'chapter' && wordCount >= 3000);
|
||||||
document.querySelector('#editor-statusbar span:nth-child(2)').innerHTML =
|
const statusChapter = document.querySelector('#editor-statusbar span:nth-child(2)');
|
||||||
`📖 本章:<b>${wordCount.toLocaleString()}</b> / 提醒 3,000`;
|
if (statusChapter) {
|
||||||
|
statusChapter.innerHTML = currentEdit.type === 'chapter'
|
||||||
|
? `📖 本章:<b>${wordCount.toLocaleString()}</b> / 提醒 3,000`
|
||||||
|
: `📖 编辑:<b>${currentEdit.type === 'outline' ? '大纲' : currentEdit.type === 'setting' ? '设定' : currentEdit.type === 'inspiration' ? '灵感' : '条目'}</b>`;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============ MODALS ============
|
// ============ MODALS ============
|
||||||
@@ -2942,9 +3492,12 @@
|
|||||||
|
|
||||||
// Update selectChapter for batch
|
// Update selectChapter for batch
|
||||||
var origSelectChapter = selectChapter;
|
var origSelectChapter = selectChapter;
|
||||||
selectChapter = function(el, chNum) {
|
selectChapter = function(el, chNum, opts) {
|
||||||
if (event && (event.ctrlKey || event.metaKey || event.shiftKey)) { toggleChapterSelect(el.querySelector('.ch-select'), chNum); return; }
|
if (!opts?.skipVolUpdate && typeof event !== 'undefined' && event && (event.ctrlKey || event.metaKey || event.shiftKey)) {
|
||||||
origSelectChapter(el, chNum);
|
toggleChapterSelect(el.querySelector('.ch-select'), chNum);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
origSelectChapter(el, chNum, opts);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Update updateWordCount for daily goal
|
// Update updateWordCount for daily goal
|
||||||
@@ -2974,6 +3527,17 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
// ============ INIT ============
|
// ============ INIT ============
|
||||||
|
editorStore[editorKey('chapter', '3')] = document.getElementById('editor-content').innerText.trim();
|
||||||
|
document.querySelectorAll('.chapter-item').forEach(function(item) {
|
||||||
|
const ch = item.dataset.ch;
|
||||||
|
const titleEl = item.querySelector('.ch-title');
|
||||||
|
if (ch && titleEl) chapterTitles[ch] = titleEl.textContent.trim();
|
||||||
|
});
|
||||||
|
document.addEventListener('click', function(e) {
|
||||||
|
if (activeInlineEdit && !e.target.classList.contains('inline-title-input')) {
|
||||||
|
finishInlineTitleEdit(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
renderBookGrid();
|
renderBookGrid();
|
||||||
updateWordCount();
|
updateWordCount();
|
||||||
document.getElementById('editor-content').addEventListener('focus', function() {
|
document.getElementById('editor-content').addEventListener('focus', function() {
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,10 @@
|
|||||||
|
import { execSync } from 'child_process'
|
||||||
|
import { resolve } from 'path'
|
||||||
|
|
||||||
|
export default async function globalSetup(): Promise<void> {
|
||||||
|
execSync('npm run build', {
|
||||||
|
cwd: resolve(import.meta.dirname, '..'),
|
||||||
|
stdio: 'inherit',
|
||||||
|
env: process.env
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import path from 'path'
|
||||||
|
import { mkdtempSync, rmSync, readFileSync, existsSync } from 'fs'
|
||||||
|
import { join } from 'path'
|
||||||
|
import { tmpdir } from 'os'
|
||||||
|
import { test, expect, _electron as electron, type ElectronApplication, type Page } from '@playwright/test'
|
||||||
|
|
||||||
|
const ROOT = path.join(import.meta.dirname, '..')
|
||||||
|
|
||||||
|
function createUserDataDir(): string {
|
||||||
|
return mkdtempSync(join(tmpdir(), 'bilin-e2e-'))
|
||||||
|
}
|
||||||
|
|
||||||
|
async function launchApp(userDataDir: string): Promise<ElectronApplication> {
|
||||||
|
return electron.launch({
|
||||||
|
args: [ROOT],
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
BILIN_E2E: '1',
|
||||||
|
BILIN_E2E_USER_DATA: userDataDir
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForApp(page: Page): Promise<void> {
|
||||||
|
await page.waitForLoadState('domcontentloaded')
|
||||||
|
await expect(page.locator('#app')).toBeVisible({ timeout: 20_000 })
|
||||||
|
}
|
||||||
|
|
||||||
|
async function completeOnboarding(page: Page, penName: string, withSample = false): Promise<void> {
|
||||||
|
await waitForApp(page)
|
||||||
|
await expect(page.getByText('欢迎使用笔临')).toBeVisible({ timeout: 15_000 })
|
||||||
|
for (let i = 0; i < 4; i++) {
|
||||||
|
await page.getByTestId('onboarding-next').click()
|
||||||
|
}
|
||||||
|
await page.getByTestId('onboarding-pen-name').fill(penName)
|
||||||
|
if (!withSample) {
|
||||||
|
await page.getByText('创建示例书籍').click()
|
||||||
|
}
|
||||||
|
await page.getByTestId('onboarding-start').click()
|
||||||
|
await expect(page.getByText('欢迎使用笔临')).toBeHidden({ timeout: 10_000 })
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe('Onboarding', () => {
|
||||||
|
let userDataDir: string
|
||||||
|
|
||||||
|
test.beforeEach(() => {
|
||||||
|
userDataDir = createUserDataDir()
|
||||||
|
})
|
||||||
|
|
||||||
|
test.afterEach(async () => {
|
||||||
|
if (userDataDir && existsSync(userDataDir)) {
|
||||||
|
try {
|
||||||
|
rmSync(userDataDir, { recursive: true, force: true, maxRetries: 3, retryDelay: 200 })
|
||||||
|
} catch {
|
||||||
|
/* electron may still release file handles */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('E2E-ONBOARD-02: complete onboarding saves pen name after restart', async () => {
|
||||||
|
const app1 = await launchApp(userDataDir)
|
||||||
|
const page1 = await app1.firstWindow()
|
||||||
|
await completeOnboarding(page1, '山海', false)
|
||||||
|
await app1.close()
|
||||||
|
|
||||||
|
const settingsPath = join(userDataDir, 'global_settings.json')
|
||||||
|
expect(existsSync(settingsPath)).toBe(true)
|
||||||
|
const saved = JSON.parse(readFileSync(settingsPath, 'utf-8')) as { penName: string; onboardingCompleted: boolean }
|
||||||
|
expect(saved.penName).toBe('山海')
|
||||||
|
expect(saved.onboardingCompleted).toBe(true)
|
||||||
|
|
||||||
|
const app2 = await launchApp(userDataDir)
|
||||||
|
const page2 = await app2.firstWindow()
|
||||||
|
await expect(page2.getByText('欢迎使用笔临')).toBeHidden({ timeout: 15_000 })
|
||||||
|
await page2.getByRole('button', { name: /系统设置/ }).click()
|
||||||
|
await expect(page2.getByTestId('settings-pen-name')).toHaveValue('山海')
|
||||||
|
await app2.close()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import path from 'path'
|
||||||
|
import { mkdtempSync, rmSync, existsSync } from 'fs'
|
||||||
|
import { join } from 'path'
|
||||||
|
import { tmpdir } from 'os'
|
||||||
|
import { test, expect, _electron as electron, type Page } from '@playwright/test'
|
||||||
|
|
||||||
|
const ROOT = path.join(import.meta.dirname, '..')
|
||||||
|
|
||||||
|
async function launchApp(userDataDir: string) {
|
||||||
|
return electron.launch({
|
||||||
|
args: [ROOT],
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
BILIN_E2E: '1',
|
||||||
|
BILIN_E2E_USER_DATA: userDataDir
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function skipOnboarding(page: Page): Promise<void> {
|
||||||
|
await page.waitForLoadState('domcontentloaded')
|
||||||
|
await expect(page.locator('#app')).toBeVisible({ timeout: 20_000 })
|
||||||
|
if (await page.getByText('欢迎使用笔临').isVisible()) {
|
||||||
|
await page.getByRole('button', { name: '跳过' }).click()
|
||||||
|
await expect(page.getByText('欢迎使用笔临')).toBeHidden({ timeout: 10_000 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openSettings(page: Page): Promise<void> {
|
||||||
|
await page.getByRole('button', { name: /系统设置/ }).click()
|
||||||
|
await expect(page.locator('#settings-page')).toBeVisible()
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe('Settings', () => {
|
||||||
|
let userDataDir: string
|
||||||
|
|
||||||
|
test.beforeEach(() => {
|
||||||
|
userDataDir = mkdtempSync(join(tmpdir(), 'bilin-e2e-settings-'))
|
||||||
|
})
|
||||||
|
|
||||||
|
test.afterEach(() => {
|
||||||
|
if (userDataDir && existsSync(userDataDir)) {
|
||||||
|
try {
|
||||||
|
rmSync(userDataDir, { recursive: true, force: true, maxRetries: 3, retryDelay: 200 })
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('E2E-THEME: all 7 themes apply data-theme attribute', async () => {
|
||||||
|
const themes = ['default', 'bamboo', 'moonlit', 'ricepaper', 'mist', 'teagarden', 'twilight'] as const
|
||||||
|
const app = await launchApp(userDataDir)
|
||||||
|
const page = await app.firstWindow()
|
||||||
|
await skipOnboarding(page)
|
||||||
|
await openSettings(page)
|
||||||
|
|
||||||
|
for (const theme of themes) {
|
||||||
|
await page.getByTestId('settings-theme').selectOption(theme)
|
||||||
|
const attr = await page.evaluate(() => document.documentElement.getAttribute('data-theme'))
|
||||||
|
if (theme === 'default') {
|
||||||
|
expect(attr).toBeNull()
|
||||||
|
} else {
|
||||||
|
expect(attr).toBe(theme)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await app.close()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('E2E-I18N: language switch updates UI text', async () => {
|
||||||
|
const app = await launchApp(userDataDir)
|
||||||
|
const page = await app.firstWindow()
|
||||||
|
await skipOnboarding(page)
|
||||||
|
await openSettings(page)
|
||||||
|
|
||||||
|
await expect(page.getByRole('heading', { name: '系统设置' })).toBeVisible()
|
||||||
|
await page.getByTestId('settings-language').selectOption('en')
|
||||||
|
await expect(page.getByRole('heading', { name: 'Settings' })).toBeVisible({ timeout: 5_000 })
|
||||||
|
await expect(page.locator('.settings-nav-item.active')).toHaveText('General')
|
||||||
|
|
||||||
|
await page.getByTestId('settings-language').selectOption('zh-CN')
|
||||||
|
await expect(page.getByText('通用')).toBeVisible({ timeout: 5_000 })
|
||||||
|
|
||||||
|
await app.close()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import path from 'path'
|
||||||
|
import { mkdtempSync, rmSync, existsSync } from 'fs'
|
||||||
|
import { join } from 'path'
|
||||||
|
import { tmpdir } from 'os'
|
||||||
|
import { test, expect, _electron as electron, type Page } from '@playwright/test'
|
||||||
|
|
||||||
|
const ROOT = path.join(import.meta.dirname, '..')
|
||||||
|
|
||||||
|
async function launchApp(userDataDir: string) {
|
||||||
|
return electron.launch({
|
||||||
|
args: [ROOT],
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
BILIN_E2E: '1',
|
||||||
|
BILIN_E2E_USER_DATA: userDataDir
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function skipOnboarding(page: Page): Promise<void> {
|
||||||
|
await page.waitForLoadState('domcontentloaded')
|
||||||
|
await expect(page.locator('#app')).toBeVisible({ timeout: 20_000 })
|
||||||
|
if (await page.getByText('欢迎使用笔临').isVisible()) {
|
||||||
|
await page.getByRole('button', { name: '跳过' }).click()
|
||||||
|
await expect(page.getByText('欢迎使用笔临')).toBeHidden({ timeout: 10_000 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createBookAndOpenEditor(page: Page, name: string): Promise<void> {
|
||||||
|
await page.getByRole('button', { name: /新建书籍/ }).first().click()
|
||||||
|
await page.locator('.dialog-content input').first().fill(name)
|
||||||
|
await page.getByRole('button', { name: '创建' }).click()
|
||||||
|
await expect(page.locator('#editor-layout')).toBeVisible({ timeout: 15_000 })
|
||||||
|
await page.getByRole('button', { name: '+ 新章' }).click()
|
||||||
|
await expect(page.locator('.ProseMirror')).toBeVisible({ timeout: 10_000 })
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe('Shortcuts', () => {
|
||||||
|
let userDataDir: string
|
||||||
|
|
||||||
|
test.beforeEach(() => {
|
||||||
|
userDataDir = mkdtempSync(join(tmpdir(), 'bilin-e2e-shortcut-'))
|
||||||
|
})
|
||||||
|
|
||||||
|
test.afterEach(() => {
|
||||||
|
if (userDataDir && existsSync(userDataDir)) {
|
||||||
|
try {
|
||||||
|
rmSync(userDataDir, { recursive: true, force: true, maxRetries: 3, retryDelay: 200 })
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('E2E-SHORTCUT: Ctrl+S saves, chapter/tab actions work, bindings visible', async () => {
|
||||||
|
const app = await launchApp(userDataDir)
|
||||||
|
const page = await app.firstWindow()
|
||||||
|
await skipOnboarding(page)
|
||||||
|
await createBookAndOpenEditor(page, '快捷键测试')
|
||||||
|
|
||||||
|
const editor = page.locator('.ProseMirror')
|
||||||
|
await editor.click()
|
||||||
|
await editor.pressSequentially('快捷键保存测试')
|
||||||
|
await page.keyboard.press('Control+S')
|
||||||
|
await expect(page.getByText('已保存')).toBeVisible({ timeout: 10_000 })
|
||||||
|
|
||||||
|
const chapterCountBefore = await page.locator('.chapter-item').count()
|
||||||
|
await page.getByRole('button', { name: '+ 新章' }).click()
|
||||||
|
await expect(page.locator('.chapter-item')).toHaveCount(chapterCountBefore + 1, { timeout: 10_000 })
|
||||||
|
|
||||||
|
await page.locator('.tab.active').locator('span').last().click()
|
||||||
|
await expect(page.locator('#editor-layout')).toBeHidden({ timeout: 10_000 })
|
||||||
|
await expect(page.locator('#home-page')).toBeVisible()
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: /系统设置/ }).click()
|
||||||
|
await page.locator('.settings-nav-item').filter({ hasText: /快捷键|Shortcuts/ }).click()
|
||||||
|
await expect(page.getByTestId('shortcut-saveChapter')).toContainText('Ctrl+S')
|
||||||
|
await expect(page.getByTestId('shortcut-newChapter')).toContainText('Ctrl+Shift+N')
|
||||||
|
|
||||||
|
await app.close()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import path from 'path'
|
||||||
|
import { mkdtempSync, rmSync, existsSync } from 'fs'
|
||||||
|
import { join } from 'path'
|
||||||
|
import { tmpdir } from 'os'
|
||||||
|
import { test, expect, _electron as electron, type Page } from '@playwright/test'
|
||||||
|
|
||||||
|
const ROOT = path.join(import.meta.dirname, '..')
|
||||||
|
|
||||||
|
async function launchApp(userDataDir: string) {
|
||||||
|
return electron.launch({
|
||||||
|
args: [ROOT],
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
BILIN_E2E: '1',
|
||||||
|
BILIN_E2E_USER_DATA: userDataDir
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function skipOnboarding(page: Page): Promise<void> {
|
||||||
|
await page.waitForLoadState('domcontentloaded')
|
||||||
|
await expect(page.locator('#app')).toBeVisible({ timeout: 20_000 })
|
||||||
|
await expect(page.getByText('欢迎使用笔临')).toBeVisible({ timeout: 15_000 })
|
||||||
|
await page.getByRole('button', { name: '跳过' }).click()
|
||||||
|
await expect(page.getByText('欢迎使用笔临')).toBeHidden({ timeout: 10_000 })
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createAndOpenBook(page: Page, name: string): Promise<void> {
|
||||||
|
await page.getByRole('button', { name: /新建书籍/ }).first().click()
|
||||||
|
await page.locator('.dialog-content input').first().fill(name)
|
||||||
|
await page.getByRole('button', { name: '创建' }).click()
|
||||||
|
await expect(page.locator('#editor-layout')).toBeVisible({ timeout: 15_000 })
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe('Writing', () => {
|
||||||
|
let userDataDir: string
|
||||||
|
|
||||||
|
test.beforeEach(() => {
|
||||||
|
userDataDir = mkdtempSync(join(tmpdir(), 'bilin-e2e-write-'))
|
||||||
|
})
|
||||||
|
|
||||||
|
test.afterEach(async () => {
|
||||||
|
if (userDataDir && existsSync(userDataDir)) {
|
||||||
|
try {
|
||||||
|
rmSync(userDataDir, { recursive: true, force: true, maxRetries: 3, retryDelay: 200 })
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('auto-save persists content after restart', async () => {
|
||||||
|
const app1 = await launchApp(userDataDir)
|
||||||
|
const page1 = await app1.firstWindow()
|
||||||
|
await skipOnboarding(page1)
|
||||||
|
await createAndOpenBook(page1, '测试书')
|
||||||
|
await page1.getByRole('button', { name: '+ 新章' }).click()
|
||||||
|
|
||||||
|
const editor = page1.locator('.ProseMirror')
|
||||||
|
await expect(editor).toBeVisible({ timeout: 10_000 })
|
||||||
|
await editor.click()
|
||||||
|
await editor.pressSequentially('林远深吸一口气')
|
||||||
|
await page1.waitForTimeout(3000)
|
||||||
|
|
||||||
|
await app1.close()
|
||||||
|
|
||||||
|
const app2 = await launchApp(userDataDir)
|
||||||
|
const page2 = await app2.firstWindow()
|
||||||
|
await expect(page2.getByText('欢迎使用笔临')).toBeHidden({ timeout: 15_000 })
|
||||||
|
await page2.getByText('测试书').click()
|
||||||
|
await expect(page2.locator('#editor-layout')).toBeVisible({ timeout: 15_000 })
|
||||||
|
await expect(page2.locator('.ProseMirror')).toContainText('林远深吸一口气', { timeout: 10_000 })
|
||||||
|
await app2.close()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { resolve } from 'path'
|
||||||
|
import { defineConfig, externalizeDepsPlugin } from 'electron-vite'
|
||||||
|
import react from '@vitejs/plugin-react'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
main: {
|
||||||
|
plugins: [externalizeDepsPlugin()],
|
||||||
|
build: {
|
||||||
|
rollupOptions: {
|
||||||
|
input: {
|
||||||
|
index: resolve(__dirname, 'src/main/index.ts')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
preload: {
|
||||||
|
plugins: [externalizeDepsPlugin()],
|
||||||
|
build: {
|
||||||
|
rollupOptions: {
|
||||||
|
input: {
|
||||||
|
index: resolve(__dirname, 'src/preload/index.ts')
|
||||||
|
},
|
||||||
|
output: {
|
||||||
|
format: 'cjs',
|
||||||
|
entryFileNames: '[name].js'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
renderer: {
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@shared': resolve('src/shared'),
|
||||||
|
'@renderer': resolve('src/renderer')
|
||||||
|
}
|
||||||
|
},
|
||||||
|
plugins: [react()],
|
||||||
|
build: {
|
||||||
|
rollupOptions: {
|
||||||
|
input: {
|
||||||
|
index: resolve(__dirname, 'src/renderer/index.html')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
Generated
+8447
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,54 @@
|
|||||||
|
{
|
||||||
|
"name": "bilin",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"description": "笔临 - 长篇创作智能协作平台",
|
||||||
|
"main": "./out/main/index.js",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "npx electron-vite dev",
|
||||||
|
"build": "npx electron-vite build",
|
||||||
|
"preview": "npx electron-vite preview",
|
||||||
|
"test": "node node_modules/vitest/vitest.mjs run",
|
||||||
|
"test:watch": "node node_modules/vitest/vitest.mjs",
|
||||||
|
"test:e2e": "playwright test"
|
||||||
|
},
|
||||||
|
"keywords": [],
|
||||||
|
"author": "",
|
||||||
|
"license": "MIT",
|
||||||
|
"overrides": {
|
||||||
|
"esbuild": "0.21.5"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/react-checkbox": "^1.3.6",
|
||||||
|
"@radix-ui/react-dialog": "^1.1.18",
|
||||||
|
"@radix-ui/react-select": "^2.3.2",
|
||||||
|
"@radix-ui/react-tabs": "^1.1.16",
|
||||||
|
"@radix-ui/react-toast": "^1.2.18",
|
||||||
|
"@tiptap/extension-placeholder": "^3.27.1",
|
||||||
|
"@tiptap/extension-underline": "^3.27.1",
|
||||||
|
"@tiptap/react": "^3.27.1",
|
||||||
|
"@tiptap/starter-kit": "^3.27.1",
|
||||||
|
"electron": "^36.9.0",
|
||||||
|
"i18next": "^24.2.3",
|
||||||
|
"jotai": "^2.12.1",
|
||||||
|
"react": "^18.3.1",
|
||||||
|
"react-dom": "^18.3.1",
|
||||||
|
"react-i18next": "^15.4.1",
|
||||||
|
"uuid": "^11.1.0",
|
||||||
|
"zustand": "^5.0.3"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@playwright/test": "^1.51.0",
|
||||||
|
"@types/node": "^22.13.0",
|
||||||
|
"@types/react": "^18.3.18",
|
||||||
|
"@types/react-dom": "^18.3.5",
|
||||||
|
"@types/uuid": "^10.0.0",
|
||||||
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
|
"electron-builder": "^25.1.8",
|
||||||
|
"electron-vite": "^3.1.0",
|
||||||
|
"playwright": "^1.51.0",
|
||||||
|
"typescript": "^5.8.2",
|
||||||
|
"vite": "^5.4.14",
|
||||||
|
"vitest": "^3.0.9"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { defineConfig } from '@playwright/test'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
testDir: './e2e',
|
||||||
|
timeout: 120_000,
|
||||||
|
workers: 1,
|
||||||
|
globalSetup: './e2e/global-setup.ts',
|
||||||
|
use: {
|
||||||
|
trace: 'on-first-retry'
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
{
|
||||||
|
"app.name": "Bilin",
|
||||||
|
"app.tagline": "Long-form writing platform · Every story deserves care",
|
||||||
|
"home.newBook": "New Book",
|
||||||
|
"home.importBook": "Import Book",
|
||||||
|
"home.exportAll": "Export All Books",
|
||||||
|
"home.myBooks": "My Books",
|
||||||
|
"home.recent": "Recent",
|
||||||
|
"home.bookCount": "{{count}} books",
|
||||||
|
"book.createSample": "My First Book",
|
||||||
|
"dialog.newBook.title": "New Book",
|
||||||
|
"dialog.newBook.name": "Title",
|
||||||
|
"dialog.newBook.category": "Category",
|
||||||
|
"dialog.newBook.target": "Target word count (optional)",
|
||||||
|
"dialog.cancel": "Cancel",
|
||||||
|
"dialog.create": "Create",
|
||||||
|
"settings.title": "Settings",
|
||||||
|
"settings.general": "General",
|
||||||
|
"settings.shortcuts": "Shortcuts",
|
||||||
|
"settings.about": "About",
|
||||||
|
"settings.theme": "Theme",
|
||||||
|
"settings.language": "Language",
|
||||||
|
"settings.penName": "Pen name",
|
||||||
|
"settings.editor": "Editor",
|
||||||
|
"settings.ai": "AI",
|
||||||
|
"settings.backup": "Backup & Sync",
|
||||||
|
"theme.default": "Inkstone (Default)",
|
||||||
|
"theme.bamboo": "Bamboo",
|
||||||
|
"theme.moonlit": "Moonlit",
|
||||||
|
"theme.ricepaper": "Rice Paper",
|
||||||
|
"theme.mist": "Morning Mist",
|
||||||
|
"theme.teagarden": "Tea Garden",
|
||||||
|
"theme.twilight": "Twilight",
|
||||||
|
"onboarding.welcome": "Welcome to Bilin",
|
||||||
|
"onboarding.tagline": "Your intelligent long-form writing partner",
|
||||||
|
"onboarding.slide1": "Chapters & Version History",
|
||||||
|
"onboarding.slide1desc": "Volumes, snapshots, and diffs",
|
||||||
|
"onboarding.slide2": "AI-Assisted Writing",
|
||||||
|
"onboarding.slide2desc": "Context-aware suggestions and style learning",
|
||||||
|
"onboarding.slide3": "Knowledge Base & Foreshadowing",
|
||||||
|
"onboarding.slide3desc": "Auto-extract characters, places, and plot threads",
|
||||||
|
"onboarding.penName": "Set your pen name",
|
||||||
|
"onboarding.penNamePlaceholder": "Pen name (min. 2 characters)",
|
||||||
|
"onboarding.createSample": "Create sample book \"My First Book\"",
|
||||||
|
"onboarding.skip": "Skip",
|
||||||
|
"onboarding.next": "Next",
|
||||||
|
"onboarding.start": "Start Writing",
|
||||||
|
"feature.comingSoon": "Coming soon",
|
||||||
|
"editor.placeholder": "Start writing here…",
|
||||||
|
"editor.newChapter": "New Chapter",
|
||||||
|
"editor.newVolume": "New Volume",
|
||||||
|
"editor.saved": "Saved",
|
||||||
|
"editor.saving": "Saving…",
|
||||||
|
"editor.unsaved": "Unsaved",
|
||||||
|
"editor.words": "{{count}} words",
|
||||||
|
"sidebar.chapters": "Chapters",
|
||||||
|
"sidebar.outline": "Outline",
|
||||||
|
"sidebar.settings": "Settings",
|
||||||
|
"sidebar.inspiration": "Ideas",
|
||||||
|
"panel.ai": "AI",
|
||||||
|
"panel.knowledge": "Knowledge",
|
||||||
|
"panel.wordfreq": "Word Freq",
|
||||||
|
"panel.bookSettings": "Book",
|
||||||
|
"panel.aiPlaceholder": "AI features coming soon. Configure a model to collaborate here.",
|
||||||
|
"status.chapter": "Chapter",
|
||||||
|
"status.volume": "Volume",
|
||||||
|
"status.book": "Book",
|
||||||
|
"toast.bookCreated": "Book created",
|
||||||
|
"toast.saveFailed": "Save failed, please retry",
|
||||||
|
"toast.enterBookName": "Please enter a book title",
|
||||||
|
"shortcuts.saveChapter": "Save chapter",
|
||||||
|
"shortcuts.newChapter": "New chapter",
|
||||||
|
"shortcuts.focusMode": "Focus mode",
|
||||||
|
"shortcuts.globalSearch": "Global search",
|
||||||
|
"shortcuts.closeTab": "Close tab",
|
||||||
|
"shortcuts.openReference": "Open reference",
|
||||||
|
"shortcuts.insertLandmark": "Insert landmark",
|
||||||
|
"shortcuts.openLandmarks": "Open landmarks",
|
||||||
|
"shortcuts.exportBook": "Export book",
|
||||||
|
"shortcuts.toggleTTS": "Toggle TTS",
|
||||||
|
"shortcuts.captureInspiration": "Capture inspiration",
|
||||||
|
"shortcuts.clickToRecord": "Click a shortcut button, then press a new key combination",
|
||||||
|
"shortcuts.recordHint": "Press a new key combination (Esc to cancel)",
|
||||||
|
"shortcuts.recording": "Recording…",
|
||||||
|
"shortcuts.conflict": "This shortcut is already assigned",
|
||||||
|
"shortcuts.registerFailed": "Failed to register shortcut, may conflict with another app"
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
{
|
||||||
|
"app.name": "笔临",
|
||||||
|
"app.tagline": "长篇创作智能协作平台 · 让每一个故事都被认真对待",
|
||||||
|
"home.newBook": "新建书籍",
|
||||||
|
"home.importBook": "导入书籍",
|
||||||
|
"home.exportAll": "导出所有书籍",
|
||||||
|
"home.myBooks": "我的书籍",
|
||||||
|
"home.recent": "最近编辑",
|
||||||
|
"home.bookCount": "{{count}}本",
|
||||||
|
"book.createSample": "我的第一本书",
|
||||||
|
"dialog.newBook.title": "新建书籍",
|
||||||
|
"dialog.newBook.name": "书名",
|
||||||
|
"dialog.newBook.category": "分类",
|
||||||
|
"dialog.newBook.target": "目标总字数(可选)",
|
||||||
|
"dialog.cancel": "取消",
|
||||||
|
"dialog.create": "创建",
|
||||||
|
"settings.title": "系统设置",
|
||||||
|
"settings.general": "通用",
|
||||||
|
"settings.shortcuts": "快捷键",
|
||||||
|
"settings.about": "关于",
|
||||||
|
"settings.theme": "主题",
|
||||||
|
"settings.language": "语言",
|
||||||
|
"settings.penName": "笔名",
|
||||||
|
"settings.editor": "编辑器",
|
||||||
|
"settings.ai": "AI 配置",
|
||||||
|
"settings.backup": "备份与同步",
|
||||||
|
"theme.default": "墨砚(默认)",
|
||||||
|
"theme.bamboo": "竹语",
|
||||||
|
"theme.moonlit": "月夜",
|
||||||
|
"theme.ricepaper": "宣纸",
|
||||||
|
"theme.mist": "晨雾",
|
||||||
|
"theme.teagarden": "茶园",
|
||||||
|
"theme.twilight": "暮光",
|
||||||
|
"onboarding.welcome": "欢迎使用笔临",
|
||||||
|
"onboarding.tagline": "你的智能长篇创作伙伴",
|
||||||
|
"onboarding.slide1": "章节管理与版本历史",
|
||||||
|
"onboarding.slide1desc": "分卷管理、自动快照、版本对比",
|
||||||
|
"onboarding.slide2": "AI 辅助写作",
|
||||||
|
"onboarding.slide2desc": "上下文感知、剧情建议、文风学习",
|
||||||
|
"onboarding.slide3": "知识库与伏笔追踪",
|
||||||
|
"onboarding.slide3desc": "自动抽取角色、地点、伏笔",
|
||||||
|
"onboarding.penName": "设置你的笔名",
|
||||||
|
"onboarding.penNamePlaceholder": "你的笔名(至少2个字符)",
|
||||||
|
"onboarding.createSample": "创建示例书籍「我的第一本书」",
|
||||||
|
"onboarding.skip": "跳过",
|
||||||
|
"onboarding.next": "下一步",
|
||||||
|
"onboarding.start": "开始创作",
|
||||||
|
"feature.comingSoon": "即将推出",
|
||||||
|
"editor.placeholder": "在此开始写作…",
|
||||||
|
"editor.newChapter": "新章",
|
||||||
|
"editor.newVolume": "新卷",
|
||||||
|
"editor.saved": "已保存",
|
||||||
|
"editor.saving": "保存中…",
|
||||||
|
"editor.unsaved": "未保存",
|
||||||
|
"editor.words": "{{count}} 字",
|
||||||
|
"sidebar.chapters": "章节",
|
||||||
|
"sidebar.outline": "大纲",
|
||||||
|
"sidebar.settings": "设定",
|
||||||
|
"sidebar.inspiration": "灵感",
|
||||||
|
"panel.ai": "AI助手",
|
||||||
|
"panel.knowledge": "知识库",
|
||||||
|
"panel.wordfreq": "用词",
|
||||||
|
"panel.bookSettings": "设置",
|
||||||
|
"panel.aiPlaceholder": "AI 功能即将推出。配置模型后,可在此与笔临 AI 协作写作。",
|
||||||
|
"status.chapter": "本章",
|
||||||
|
"status.volume": "本卷",
|
||||||
|
"status.book": "全书",
|
||||||
|
"toast.bookCreated": "书籍已创建",
|
||||||
|
"toast.saveFailed": "保存失败,请重试",
|
||||||
|
"toast.enterBookName": "请输入书名",
|
||||||
|
"shortcuts.saveChapter": "保存章节",
|
||||||
|
"shortcuts.newChapter": "新建章节",
|
||||||
|
"shortcuts.focusMode": "专注模式",
|
||||||
|
"shortcuts.globalSearch": "全局搜索",
|
||||||
|
"shortcuts.closeTab": "关闭标签页",
|
||||||
|
"shortcuts.openReference": "打开引用",
|
||||||
|
"shortcuts.insertLandmark": "插入地标",
|
||||||
|
"shortcuts.openLandmarks": "打开地标列表",
|
||||||
|
"shortcuts.exportBook": "导出书籍",
|
||||||
|
"shortcuts.toggleTTS": "朗读开关",
|
||||||
|
"shortcuts.captureInspiration": "捕获灵感",
|
||||||
|
"shortcuts.clickToRecord": "点击快捷键按钮,然后按下新的组合键",
|
||||||
|
"shortcuts.recordHint": "请按下新的快捷键组合(Esc 取消)",
|
||||||
|
"shortcuts.recording": "录制中…",
|
||||||
|
"shortcuts.conflict": "该快捷键已被其他功能占用",
|
||||||
|
"shortcuts.registerFailed": "快捷键注册失败,可能与其他应用冲突"
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { DatabaseSync } from 'node:sqlite'
|
||||||
|
import { existsSync, mkdirSync } from 'fs'
|
||||||
|
import { dirname } from 'path'
|
||||||
|
import { migrate } from './migrate'
|
||||||
|
import type { SqliteDb } from './types'
|
||||||
|
|
||||||
|
const pools = new Map<string, SqliteDb>()
|
||||||
|
|
||||||
|
export function openBookDb(dbPath: string): SqliteDb {
|
||||||
|
const existing = pools.get(dbPath)
|
||||||
|
if (existing) return existing
|
||||||
|
|
||||||
|
const dir = dirname(dbPath)
|
||||||
|
if (!existsSync(dir)) mkdirSync(dir, { recursive: true })
|
||||||
|
|
||||||
|
const db = new DatabaseSync(dbPath)
|
||||||
|
migrate(db)
|
||||||
|
pools.set(dbPath, db)
|
||||||
|
return db
|
||||||
|
}
|
||||||
|
|
||||||
|
export function closeAllBookDbs(): void {
|
||||||
|
for (const db of pools.values()) db.close()
|
||||||
|
pools.clear()
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import type { SqliteDb } from './types'
|
||||||
|
import schemaV1 from './schema-v1.sql?raw'
|
||||||
|
|
||||||
|
const CURRENT_VERSION = 1
|
||||||
|
|
||||||
|
export function migrate(db: SqliteDb): void {
|
||||||
|
db.exec(`CREATE TABLE IF NOT EXISTS schema_version (
|
||||||
|
version INTEGER PRIMARY KEY,
|
||||||
|
applied_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
description TEXT
|
||||||
|
)`)
|
||||||
|
|
||||||
|
const row = db.prepare('SELECT MAX(version) AS v FROM schema_version').get() as { v: number | null }
|
||||||
|
const current = row?.v ?? 0
|
||||||
|
if (current >= CURRENT_VERSION) return
|
||||||
|
|
||||||
|
db.exec(schemaV1)
|
||||||
|
db.prepare('INSERT INTO schema_version (version, description) VALUES (?, ?)').run(
|
||||||
|
CURRENT_VERSION,
|
||||||
|
'initial schema'
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import { randomUUID } from 'crypto'
|
||||||
|
import { countWords } from '../../services/word-count'
|
||||||
|
import type { Chapter, ChapterStatus } from '../../../shared/types'
|
||||||
|
import type { SqliteDb } from '../types'
|
||||||
|
function mapRow(row: Record<string, unknown>): Chapter {
|
||||||
|
return {
|
||||||
|
id: row.id as string,
|
||||||
|
volumeId: row.volume_id as string | null,
|
||||||
|
title: row.title as string,
|
||||||
|
content: row.content as string,
|
||||||
|
status: row.status as ChapterStatus,
|
||||||
|
wordCount: row.word_count as number,
|
||||||
|
sortOrder: row.sort_order as number,
|
||||||
|
cursorOffset: (row.cursor_offset as number) ?? 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ChapterRepository {
|
||||||
|
constructor(private db: SqliteDb) {}
|
||||||
|
create(volumeId: string, title: string, sortOrder: number, content = ''): Chapter {
|
||||||
|
const id = randomUUID()
|
||||||
|
const wordCount = countWords(content)
|
||||||
|
this.db
|
||||||
|
.prepare(
|
||||||
|
`INSERT INTO chapters (id, volume_id, title, content, word_count, sort_order)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?)`
|
||||||
|
)
|
||||||
|
.run(id, volumeId, title, content, wordCount, sortOrder)
|
||||||
|
return this.get(id)!
|
||||||
|
}
|
||||||
|
|
||||||
|
get(id: string): Chapter | null {
|
||||||
|
const row = this.db.prepare('SELECT * FROM chapters WHERE id = ?').get(id) as
|
||||||
|
| Record<string, unknown>
|
||||||
|
| undefined
|
||||||
|
return row ? mapRow(row) : null
|
||||||
|
}
|
||||||
|
|
||||||
|
list(): Chapter[] {
|
||||||
|
return (
|
||||||
|
this.db.prepare('SELECT * FROM chapters ORDER BY sort_order').all() as Record<string, unknown>[]
|
||||||
|
).map(mapRow)
|
||||||
|
}
|
||||||
|
|
||||||
|
listByVolume(volumeId: string): Chapter[] {
|
||||||
|
return (
|
||||||
|
this.db
|
||||||
|
.prepare('SELECT * FROM chapters WHERE volume_id = ? ORDER BY sort_order')
|
||||||
|
.all(volumeId) as Record<string, unknown>[]
|
||||||
|
).map(mapRow)
|
||||||
|
}
|
||||||
|
|
||||||
|
update(
|
||||||
|
id: string,
|
||||||
|
patch: Partial<{ title: string; content: string; status: ChapterStatus; cursorOffset: number }>
|
||||||
|
): Chapter {
|
||||||
|
const existing = this.get(id)
|
||||||
|
if (!existing) throw new Error('Chapter not found')
|
||||||
|
|
||||||
|
const content = patch.content ?? existing.content
|
||||||
|
const wordCount = countWords(content)
|
||||||
|
|
||||||
|
this.db
|
||||||
|
.prepare(
|
||||||
|
`UPDATE chapters SET
|
||||||
|
title = ?, content = ?, status = ?, cursor_offset = ?,
|
||||||
|
word_count = ?, updated_at = datetime('now')
|
||||||
|
WHERE id = ?`
|
||||||
|
)
|
||||||
|
.run(
|
||||||
|
patch.title ?? existing.title,
|
||||||
|
content,
|
||||||
|
patch.status ?? existing.status,
|
||||||
|
patch.cursorOffset ?? existing.cursorOffset,
|
||||||
|
wordCount,
|
||||||
|
id
|
||||||
|
)
|
||||||
|
return this.get(id)!
|
||||||
|
}
|
||||||
|
|
||||||
|
delete(id: string): void {
|
||||||
|
this.db.prepare('DELETE FROM chapters WHERE id = ?').run(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
nextSortOrder(volumeId: string): number {
|
||||||
|
const row = this.db
|
||||||
|
.prepare('SELECT MAX(sort_order) AS maxOrder FROM chapters WHERE volume_id = ?')
|
||||||
|
.get(volumeId) as { maxOrder: number | null }
|
||||||
|
return (row?.maxOrder ?? -1) + 1
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import { randomUUID } from 'crypto'
|
||||||
|
import type { Volume } from '../../../shared/types'
|
||||||
|
import type { SqliteDb } from '../types'
|
||||||
|
function mapRow(row: Record<string, unknown>): Volume {
|
||||||
|
return {
|
||||||
|
id: row.id as string,
|
||||||
|
name: row.name as string,
|
||||||
|
description: row.description as string,
|
||||||
|
sortOrder: row.sort_order as number
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class VolumeRepository {
|
||||||
|
constructor(private db: SqliteDb) {}
|
||||||
|
create(name: string, sortOrder: number): Volume {
|
||||||
|
const id = randomUUID()
|
||||||
|
this.db
|
||||||
|
.prepare(
|
||||||
|
`INSERT INTO volumes (id, name, sort_order) VALUES (?, ?, ?)`
|
||||||
|
)
|
||||||
|
.run(id, name, sortOrder)
|
||||||
|
return this.get(id)!
|
||||||
|
}
|
||||||
|
|
||||||
|
get(id: string): Volume | null {
|
||||||
|
const row = this.db.prepare('SELECT * FROM volumes WHERE id = ?').get(id) as
|
||||||
|
| Record<string, unknown>
|
||||||
|
| undefined
|
||||||
|
return row ? mapRow(row) : null
|
||||||
|
}
|
||||||
|
|
||||||
|
list(): Volume[] {
|
||||||
|
return (
|
||||||
|
this.db.prepare('SELECT * FROM volumes ORDER BY sort_order').all() as Record<string, unknown>[]
|
||||||
|
).map(mapRow)
|
||||||
|
}
|
||||||
|
|
||||||
|
update(id: string, patch: { name?: string; description?: string }): Volume {
|
||||||
|
const existing = this.get(id)
|
||||||
|
if (!existing) throw new Error('Volume not found')
|
||||||
|
this.db
|
||||||
|
.prepare(
|
||||||
|
`UPDATE volumes SET name = ?, description = ?, updated_at = datetime('now') WHERE id = ?`
|
||||||
|
)
|
||||||
|
.run(patch.name ?? existing.name, patch.description ?? existing.description, id)
|
||||||
|
return this.get(id)!
|
||||||
|
}
|
||||||
|
|
||||||
|
delete(id: string): void {
|
||||||
|
this.db.prepare('DELETE FROM volumes WHERE id = ?').run(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS schema_version (
|
||||||
|
version INTEGER PRIMARY KEY,
|
||||||
|
applied_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
description TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS volumes (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
description TEXT DEFAULT '',
|
||||||
|
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS chapters (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
volume_id TEXT,
|
||||||
|
title TEXT NOT NULL DEFAULT '',
|
||||||
|
content TEXT NOT NULL DEFAULT '',
|
||||||
|
status TEXT NOT NULL DEFAULT 'draft',
|
||||||
|
word_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||||
|
cursor_offset INTEGER DEFAULT 0,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
FOREIGN KEY (volume_id) REFERENCES volumes(id) ON DELETE SET NULL
|
||||||
|
);
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
import type { DatabaseSync } from 'node:sqlite'
|
||||||
|
|
||||||
|
export type SqliteDb = DatabaseSync
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import { app, BrowserWindow, ipcMain } from 'electron'
|
||||||
|
import { existsSync } from 'fs'
|
||||||
|
import { join } from 'path'
|
||||||
|
import { IPC } from '../shared/ipc-channels'
|
||||||
|
import { getShortcutManager, registerIpc } from './ipc/register'
|
||||||
|
import { closeAllBookDbs } from './db/connection'
|
||||||
|
|
||||||
|
if (process.env.BILIN_E2E === '1' && process.env.BILIN_E2E_USER_DATA) {
|
||||||
|
app.setPath('userData', process.env.BILIN_E2E_USER_DATA)
|
||||||
|
}
|
||||||
|
|
||||||
|
let mainWindow: BrowserWindow | null = null
|
||||||
|
|
||||||
|
function preloadPath(): string {
|
||||||
|
const dir = join(__dirname, '../preload')
|
||||||
|
const mjs = join(dir, 'index.mjs')
|
||||||
|
if (existsSync(mjs)) return mjs
|
||||||
|
return join(dir, 'index.js')
|
||||||
|
}
|
||||||
|
|
||||||
|
function createWindow(): void { mainWindow = new BrowserWindow({
|
||||||
|
width: 1280,
|
||||||
|
height: 800,
|
||||||
|
minWidth: 960,
|
||||||
|
minHeight: 600,
|
||||||
|
frame: false,
|
||||||
|
titleBarStyle: 'hidden',
|
||||||
|
webPreferences: {
|
||||||
|
preload: preloadPath(),
|
||||||
|
contextIsolation: true,
|
||||||
|
nodeIntegration: false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (process.env.ELECTRON_RENDERER_URL) {
|
||||||
|
mainWindow.loadURL(process.env.ELECTRON_RENDERER_URL)
|
||||||
|
} else {
|
||||||
|
mainWindow.loadFile(join(__dirname, '../renderer/index.html'))
|
||||||
|
}
|
||||||
|
|
||||||
|
mainWindow.on('closed', () => {
|
||||||
|
mainWindow = null
|
||||||
|
})
|
||||||
|
|
||||||
|
getShortcutManager()?.setWindow(mainWindow)
|
||||||
|
}
|
||||||
|
|
||||||
|
app.whenReady().then(() => {
|
||||||
|
registerIpc()
|
||||||
|
|
||||||
|
ipcMain.handle(IPC.WINDOW_MINIMIZE, (event) => {
|
||||||
|
BrowserWindow.fromWebContents(event.sender)?.minimize()
|
||||||
|
})
|
||||||
|
ipcMain.handle(IPC.WINDOW_MAXIMIZE, (event) => {
|
||||||
|
const win = BrowserWindow.fromWebContents(event.sender)
|
||||||
|
if (!win) return
|
||||||
|
if (win.isMaximized()) win.unmaximize()
|
||||||
|
else win.maximize()
|
||||||
|
})
|
||||||
|
ipcMain.handle(IPC.WINDOW_CLOSE, (event) => {
|
||||||
|
BrowserWindow.fromWebContents(event.sender)?.close()
|
||||||
|
})
|
||||||
|
|
||||||
|
createWindow()
|
||||||
|
})
|
||||||
|
|
||||||
|
app.on('window-all-closed', () => {
|
||||||
|
getShortcutManager()?.unregisterAll()
|
||||||
|
closeAllBookDbs()
|
||||||
|
if (process.platform !== 'darwin') app.quit()
|
||||||
|
})
|
||||||
|
|
||||||
|
app.on('activate', () => {
|
||||||
|
if (BrowserWindow.getAllWindows().length === 0) createWindow()
|
||||||
|
})
|
||||||
|
|
||||||
|
app.on('will-quit', () => {
|
||||||
|
getShortcutManager()?.unregisterAll()
|
||||||
|
})
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { ipcMain } from 'electron'
|
||||||
|
import { IPC } from '../../../shared/ipc-channels'
|
||||||
|
import type { BookMeta, CreateBookParams } from '../../../shared/types'
|
||||||
|
import { BookRegistryService } from '../../services/book-registry'
|
||||||
|
import { wrap } from '../result'
|
||||||
|
|
||||||
|
export function registerBookHandlers(registry: BookRegistryService): void {
|
||||||
|
ipcMain.handle(IPC.BOOK_LIST, () => wrap(() => registry.list()))
|
||||||
|
|
||||||
|
ipcMain.handle(IPC.BOOK_CREATE, (_event, params: CreateBookParams) =>
|
||||||
|
wrap(() => registry.create(params))
|
||||||
|
)
|
||||||
|
|
||||||
|
ipcMain.handle(IPC.BOOK_DELETE, (_event, { bookId }: { bookId: string }) =>
|
||||||
|
wrap(() => {
|
||||||
|
registry.delete(bookId)
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
ipcMain.handle(IPC.BOOK_OPEN, (_event, { bookId }: { bookId: string }) =>
|
||||||
|
wrap(() => registry.open(bookId))
|
||||||
|
)
|
||||||
|
|
||||||
|
ipcMain.handle(
|
||||||
|
IPC.BOOK_UPDATE_META,
|
||||||
|
(
|
||||||
|
_event,
|
||||||
|
{
|
||||||
|
bookId,
|
||||||
|
...patch
|
||||||
|
}: {
|
||||||
|
bookId: string
|
||||||
|
lastChapterId?: string | null
|
||||||
|
lastOpenedAt?: string
|
||||||
|
status?: BookMeta['status']
|
||||||
|
}
|
||||||
|
) => wrap(() => registry.updateMeta(bookId, patch))
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
import { ipcMain } from 'electron'
|
||||||
|
import { IPC } from '../../../shared/ipc-channels'
|
||||||
|
import type { ChapterStatus } from '../../../shared/types'
|
||||||
|
import { BookRegistryService } from '../../services/book-registry'
|
||||||
|
import { wrap } from '../result'
|
||||||
|
|
||||||
|
export function registerChapterHandlers(registry: BookRegistryService): void {
|
||||||
|
ipcMain.handle(
|
||||||
|
IPC.VOLUME_CREATE,
|
||||||
|
(_event, { bookId, name }: { bookId: string; name: string }) =>
|
||||||
|
wrap(() => {
|
||||||
|
const repo = registry.getVolumeRepo(bookId)
|
||||||
|
const volumes = repo.list()
|
||||||
|
return repo.create(name, volumes.length)
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
ipcMain.handle(
|
||||||
|
IPC.VOLUME_UPDATE,
|
||||||
|
(
|
||||||
|
_event,
|
||||||
|
{ bookId, volumeId, name, description }: { bookId: string; volumeId: string; name?: string; description?: string }
|
||||||
|
) => wrap(() => registry.getVolumeRepo(bookId).update(volumeId, { name, description }))
|
||||||
|
)
|
||||||
|
|
||||||
|
ipcMain.handle(
|
||||||
|
IPC.VOLUME_DELETE,
|
||||||
|
(_event, { bookId, volumeId }: { bookId: string; volumeId: string }) =>
|
||||||
|
wrap(() => {
|
||||||
|
registry.getVolumeRepo(bookId).delete(volumeId)
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
ipcMain.handle(
|
||||||
|
IPC.CHAPTER_CREATE,
|
||||||
|
(
|
||||||
|
_event,
|
||||||
|
{ bookId, volumeId, title }: { bookId: string; volumeId: string; title: string }
|
||||||
|
) =>
|
||||||
|
wrap(() => {
|
||||||
|
const repo = registry.getChapterRepo(bookId)
|
||||||
|
const sortOrder = repo.nextSortOrder(volumeId)
|
||||||
|
return repo.create(volumeId, title, sortOrder)
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
ipcMain.handle(
|
||||||
|
IPC.CHAPTER_GET,
|
||||||
|
(_event, { bookId, chapterId }: { bookId: string; chapterId: string }) =>
|
||||||
|
wrap(() => {
|
||||||
|
const ch = registry.getChapterRepo(bookId).get(chapterId)
|
||||||
|
if (!ch) throw new Error('Chapter not found')
|
||||||
|
return ch
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
ipcMain.handle(
|
||||||
|
IPC.CHAPTER_UPDATE,
|
||||||
|
(
|
||||||
|
_event,
|
||||||
|
{
|
||||||
|
bookId,
|
||||||
|
chapterId,
|
||||||
|
title,
|
||||||
|
content,
|
||||||
|
status,
|
||||||
|
cursorOffset
|
||||||
|
}: {
|
||||||
|
bookId: string
|
||||||
|
chapterId: string
|
||||||
|
title?: string
|
||||||
|
content?: string
|
||||||
|
status?: ChapterStatus
|
||||||
|
cursorOffset?: number
|
||||||
|
}
|
||||||
|
) =>
|
||||||
|
wrap(() => {
|
||||||
|
const chapter = registry.getChapterRepo(bookId).update(chapterId, {
|
||||||
|
title,
|
||||||
|
content,
|
||||||
|
status,
|
||||||
|
cursorOffset
|
||||||
|
})
|
||||||
|
registry.updateMeta(bookId, { lastChapterId: chapterId })
|
||||||
|
return chapter
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
ipcMain.handle(
|
||||||
|
IPC.CHAPTER_DELETE,
|
||||||
|
(_event, { bookId, chapterId }: { bookId: string; chapterId: string }) =>
|
||||||
|
wrap(() => {
|
||||||
|
registry.getChapterRepo(bookId).delete(chapterId)
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { ipcMain } from 'electron'
|
||||||
|
import { IPC } from '../../../shared/ipc-channels'
|
||||||
|
import { GlobalSettingsService } from '../services/global-settings'
|
||||||
|
import { wrap } from '../result'
|
||||||
|
|
||||||
|
export function registerSettingsHandlers(settings: GlobalSettingsService): void {
|
||||||
|
ipcMain.handle(IPC.SETTINGS_GET, () => wrap(() => settings.get()))
|
||||||
|
ipcMain.handle(IPC.SETTINGS_UPDATE, (_event, partial) =>
|
||||||
|
wrap(() => settings.update(partial))
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { ipcMain } from 'electron'
|
||||||
|
import { IPC } from '../../../shared/ipc-channels'
|
||||||
|
import type { ActionId } from '../../../shared/types'
|
||||||
|
import { ShortcutManager } from '../shortcuts/manager'
|
||||||
|
import { wrap } from '../result'
|
||||||
|
|
||||||
|
export function registerShortcutHandlers(shortcuts: ShortcutManager): void {
|
||||||
|
ipcMain.handle(IPC.SHORTCUT_GET_ALL, () => wrap(() => shortcuts.getAll()))
|
||||||
|
|
||||||
|
ipcMain.handle(
|
||||||
|
IPC.SHORTCUT_REGISTER,
|
||||||
|
(_event, { action, accelerator }: { action: ActionId; accelerator: string }) =>
|
||||||
|
wrap(() => {
|
||||||
|
const ok = shortcuts.save(action, accelerator)
|
||||||
|
if (!ok) throw new Error('快捷键注册失败,可能与其他应用冲突')
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { join } from 'path'
|
||||||
|
import { app } from 'electron'
|
||||||
|
import { GlobalSettingsService } from '../services/global-settings'
|
||||||
|
import { BookRegistryService } from '../services/book-registry'
|
||||||
|
import { ShortcutManager } from '../shortcuts/manager'
|
||||||
|
import { registerSettingsHandlers } from './handlers/settings.handler'
|
||||||
|
import { registerBookHandlers } from './handlers/book.handler'
|
||||||
|
import { registerChapterHandlers } from './handlers/chapter.handler'
|
||||||
|
import { registerShortcutHandlers } from './handlers/shortcut.handler'
|
||||||
|
|
||||||
|
let shortcutManager: ShortcutManager | null = null
|
||||||
|
|
||||||
|
export function registerIpc(): { settings: GlobalSettingsService; books: BookRegistryService; shortcuts: ShortcutManager } {
|
||||||
|
const userData = app.getPath('userData')
|
||||||
|
|
||||||
|
const settings = new GlobalSettingsService(userData)
|
||||||
|
const books = new BookRegistryService(userData)
|
||||||
|
shortcutManager = new ShortcutManager(settings)
|
||||||
|
|
||||||
|
registerSettingsHandlers(settings)
|
||||||
|
registerBookHandlers(books)
|
||||||
|
registerChapterHandlers(books)
|
||||||
|
registerShortcutHandlers(shortcutManager)
|
||||||
|
|
||||||
|
return { settings, books, shortcuts: shortcutManager }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getShortcutManager(): ShortcutManager | null {
|
||||||
|
return shortcutManager
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { ErrorCode, type IpcError, type IpcResult } from '../../shared/types'
|
||||||
|
|
||||||
|
export function ok<T>(data: T): IpcResult<T> {
|
||||||
|
return { ok: true, data }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fail(code: ErrorCode, message: string, recoverable: boolean): IpcResult<never> {
|
||||||
|
return { ok: false, error: { code, message, recoverable } }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function wrap<T>(fn: () => T, recoverable = true): IpcResult<T> {
|
||||||
|
try {
|
||||||
|
return ok(fn())
|
||||||
|
} catch (e) {
|
||||||
|
const message = e instanceof Error ? e.message : String(e)
|
||||||
|
return fail(ErrorCode.UNKNOWN, message, recoverable)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'
|
||||||
|
import { join } from 'path'
|
||||||
|
import { randomUUID } from 'crypto'
|
||||||
|
import { openBookDb } from '../db/connection'
|
||||||
|
import { VolumeRepository } from '../db/repositories/volume.repo'
|
||||||
|
import { ChapterRepository } from '../db/repositories/chapter.repo'
|
||||||
|
import type { BookMeta, BookOpenResult, CreateBookParams, Chapter, Volume } from '../../shared/types'
|
||||||
|
|
||||||
|
interface RegistryFile {
|
||||||
|
books: BookMeta[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export class BookRegistryService {
|
||||||
|
private booksDir: string
|
||||||
|
|
||||||
|
constructor(private userDataDir: string) {
|
||||||
|
this.booksDir = join(userDataDir, 'books')
|
||||||
|
if (!existsSync(this.booksDir)) mkdirSync(this.booksDir, { recursive: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
private registryPath(): string {
|
||||||
|
return join(this.userDataDir, 'books_registry.json')
|
||||||
|
}
|
||||||
|
|
||||||
|
private readRegistry(): RegistryFile {
|
||||||
|
const path = this.registryPath()
|
||||||
|
if (!existsSync(path)) return { books: [] }
|
||||||
|
return JSON.parse(readFileSync(path, 'utf-8')) as RegistryFile
|
||||||
|
}
|
||||||
|
|
||||||
|
private writeRegistry(data: RegistryFile): void {
|
||||||
|
writeFileSync(this.registryPath(), JSON.stringify(data, null, 2), 'utf-8')
|
||||||
|
}
|
||||||
|
|
||||||
|
list(): BookMeta[] {
|
||||||
|
return this.readRegistry().books.sort(
|
||||||
|
(a, b) => new Date(b.lastOpenedAt).getTime() - new Date(a.lastOpenedAt).getTime()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
getMeta(bookId: string): BookMeta | null {
|
||||||
|
return this.readRegistry().books.find((b) => b.id === bookId) ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
private dbPath(bookId: string): string {
|
||||||
|
return join(this.booksDir, `${bookId}.sqlite`)
|
||||||
|
}
|
||||||
|
|
||||||
|
create(params: CreateBookParams): BookMeta {
|
||||||
|
const id = randomUUID()
|
||||||
|
const now = new Date().toISOString()
|
||||||
|
const meta: BookMeta = {
|
||||||
|
id,
|
||||||
|
name: params.name,
|
||||||
|
category: params.category,
|
||||||
|
targetWordCount: params.targetWordCount ?? null,
|
||||||
|
dbPath: join('books', `${id}.sqlite`),
|
||||||
|
status: 'draft',
|
||||||
|
lastOpenedAt: now,
|
||||||
|
lastChapterId: null,
|
||||||
|
createdAt: now
|
||||||
|
}
|
||||||
|
|
||||||
|
const db = openBookDb(this.dbPath(id))
|
||||||
|
const volumes = new VolumeRepository(db)
|
||||||
|
const chapters = new ChapterRepository(db)
|
||||||
|
const vol = volumes.create('第一卷', 0)
|
||||||
|
|
||||||
|
if (params.createSampleChapter) {
|
||||||
|
const ch = chapters.create(vol.id, '第一章', 0, '在这里开始你的创作……')
|
||||||
|
meta.lastChapterId = ch.id
|
||||||
|
}
|
||||||
|
|
||||||
|
const registry = this.readRegistry()
|
||||||
|
registry.books.unshift(meta)
|
||||||
|
this.writeRegistry(registry)
|
||||||
|
return meta
|
||||||
|
}
|
||||||
|
|
||||||
|
delete(bookId: string): void {
|
||||||
|
const registry = this.readRegistry()
|
||||||
|
registry.books = registry.books.filter((b) => b.id !== bookId)
|
||||||
|
this.writeRegistry(registry)
|
||||||
|
}
|
||||||
|
|
||||||
|
updateMeta(bookId: string, patch: Partial<Pick<BookMeta, 'lastOpenedAt' | 'lastChapterId' | 'status'>>): BookMeta {
|
||||||
|
const registry = this.readRegistry()
|
||||||
|
const idx = registry.books.findIndex((b) => b.id === bookId)
|
||||||
|
if (idx === -1) throw new Error('Book not found')
|
||||||
|
registry.books[idx] = { ...registry.books[idx], ...patch }
|
||||||
|
this.writeRegistry(registry)
|
||||||
|
return registry.books[idx]
|
||||||
|
}
|
||||||
|
|
||||||
|
open(bookId: string): BookOpenResult {
|
||||||
|
const meta = this.getMeta(bookId)
|
||||||
|
if (!meta) throw new Error('Book not found')
|
||||||
|
|
||||||
|
const db = openBookDb(join(this.userDataDir, meta.dbPath))
|
||||||
|
const volumes = new VolumeRepository(db).list()
|
||||||
|
const chapters = new ChapterRepository(db).list()
|
||||||
|
|
||||||
|
const updated = this.updateMeta(bookId, { lastOpenedAt: new Date().toISOString() })
|
||||||
|
return { meta: updated, volumes, chapters }
|
||||||
|
}
|
||||||
|
|
||||||
|
getDb(bookId: string) {
|
||||||
|
const meta = this.getMeta(bookId)
|
||||||
|
if (!meta) throw new Error('Book not found')
|
||||||
|
return openBookDb(join(this.userDataDir, meta.dbPath))
|
||||||
|
}
|
||||||
|
|
||||||
|
getVolumeRepo(bookId: string): VolumeRepository {
|
||||||
|
return new VolumeRepository(this.getDb(bookId))
|
||||||
|
}
|
||||||
|
|
||||||
|
getChapterRepo(bookId: string): ChapterRepository {
|
||||||
|
return new ChapterRepository(this.getDb(bookId))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function aggregateWordCounts(chapters: Chapter[], volumes: Volume[]): {
|
||||||
|
book: number
|
||||||
|
byVolume: Record<string, number>
|
||||||
|
} {
|
||||||
|
const byVolume: Record<string, number> = {}
|
||||||
|
for (const v of volumes) byVolume[v.id] = 0
|
||||||
|
let book = 0
|
||||||
|
for (const ch of chapters) {
|
||||||
|
book += ch.wordCount
|
||||||
|
if (ch.volumeId && byVolume[ch.volumeId] !== undefined) {
|
||||||
|
byVolume[ch.volumeId] += ch.wordCount
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { book, byVolume }
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'
|
||||||
|
import { join } from 'path'
|
||||||
|
import { DEFAULT_SHORTCUTS } from '../../shared/default-shortcuts'
|
||||||
|
import type { GlobalSettings } from '../../shared/types'
|
||||||
|
|
||||||
|
const FILE = 'global_settings.json'
|
||||||
|
|
||||||
|
function defaults(): GlobalSettings {
|
||||||
|
return {
|
||||||
|
penName: '未命名作者',
|
||||||
|
onboardingCompleted: false,
|
||||||
|
theme: 'default',
|
||||||
|
language: 'zh-CN',
|
||||||
|
dailyWordGoal: 0,
|
||||||
|
shortcuts: { ...DEFAULT_SHORTCUTS }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class GlobalSettingsService {
|
||||||
|
constructor(private userDataDir: string) {
|
||||||
|
if (!existsSync(userDataDir)) mkdirSync(userDataDir, { recursive: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
private filePath(): string {
|
||||||
|
return join(this.userDataDir, FILE)
|
||||||
|
}
|
||||||
|
|
||||||
|
get(): GlobalSettings {
|
||||||
|
if (!existsSync(this.filePath())) return defaults()
|
||||||
|
const raw = JSON.parse(readFileSync(this.filePath(), 'utf-8')) as Partial<GlobalSettings>
|
||||||
|
return {
|
||||||
|
...defaults(),
|
||||||
|
...raw,
|
||||||
|
shortcuts: { ...DEFAULT_SHORTCUTS, ...raw.shortcuts }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
update(partial: Partial<GlobalSettings>): GlobalSettings {
|
||||||
|
const current = this.get()
|
||||||
|
const next: GlobalSettings = {
|
||||||
|
...current,
|
||||||
|
...partial,
|
||||||
|
shortcuts: partial.shortcuts
|
||||||
|
? { ...current.shortcuts, ...partial.shortcuts }
|
||||||
|
: current.shortcuts
|
||||||
|
}
|
||||||
|
writeFileSync(this.filePath(), JSON.stringify(next, null, 2), 'utf-8')
|
||||||
|
return next
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
export function countWords(text: string): number {
|
||||||
|
const stripped = text.replace(/<[^>]+>/g, '').replace(/\s+/g, '')
|
||||||
|
const cjk = stripped.match(/[\u4e00-\u9fff\u3400-\u4dbf]/g)
|
||||||
|
const latin = stripped.match(/[a-zA-Z0-9]+/g)
|
||||||
|
return (cjk?.length ?? 0) + (latin?.length ?? 0)
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import { BrowserWindow, globalShortcut } from 'electron'
|
||||||
|
import { DEFAULT_SHORTCUTS } from '../../shared/default-shortcuts'
|
||||||
|
import { IPC } from '../../shared/ipc-channels'
|
||||||
|
import type { ActionId } from '../../shared/types'
|
||||||
|
import { GlobalSettingsService } from '../services/global-settings'
|
||||||
|
|
||||||
|
export class ShortcutManager {
|
||||||
|
private bindings = new Map<ActionId, string>()
|
||||||
|
private targetWindow: BrowserWindow | null = null
|
||||||
|
|
||||||
|
constructor(private settings: GlobalSettingsService) {}
|
||||||
|
|
||||||
|
setWindow(win: BrowserWindow): void {
|
||||||
|
this.targetWindow = win
|
||||||
|
this.registerAll(this.settings.get().shortcuts)
|
||||||
|
}
|
||||||
|
|
||||||
|
unregisterAll(): void {
|
||||||
|
for (const acc of this.bindings.values()) {
|
||||||
|
try {
|
||||||
|
globalShortcut.unregister(acc)
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.bindings.clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
register(action: ActionId, accelerator: string): boolean {
|
||||||
|
const prev = this.bindings.get(action)
|
||||||
|
if (prev) globalShortcut.unregister(prev)
|
||||||
|
|
||||||
|
const win = this.targetWindow
|
||||||
|
if (!win) return false
|
||||||
|
|
||||||
|
const ok = globalShortcut.register(accelerator, () => {
|
||||||
|
win.webContents.send(IPC.SHORTCUT_TRIGGERED, { action })
|
||||||
|
})
|
||||||
|
if (ok) this.bindings.set(action, accelerator)
|
||||||
|
return ok
|
||||||
|
}
|
||||||
|
|
||||||
|
registerAll(map: Record<ActionId, string>): void {
|
||||||
|
this.unregisterAll()
|
||||||
|
const merged = { ...DEFAULT_SHORTCUTS, ...map }
|
||||||
|
for (const [action, acc] of Object.entries(merged) as [ActionId, string][]) {
|
||||||
|
this.register(action, acc)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getAll(): Record<ActionId, string> {
|
||||||
|
return { ...DEFAULT_SHORTCUTS, ...this.settings.get().shortcuts }
|
||||||
|
}
|
||||||
|
|
||||||
|
save(action: ActionId, accelerator: string): boolean {
|
||||||
|
const ok = this.register(action, accelerator)
|
||||||
|
if (ok) {
|
||||||
|
const shortcuts = this.settings.get().shortcuts
|
||||||
|
shortcuts[action] = accelerator
|
||||||
|
this.settings.update({ shortcuts })
|
||||||
|
}
|
||||||
|
return ok
|
||||||
|
}
|
||||||
|
}
|
||||||
Vendored
+4
@@ -0,0 +1,4 @@
|
|||||||
|
declare module '*.sql?raw' {
|
||||||
|
const content: string
|
||||||
|
export default content
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import { contextBridge, ipcRenderer, IpcRendererEvent } from 'electron'
|
||||||
|
import { IPC } from '../shared/ipc-channels'
|
||||||
|
import type {
|
||||||
|
ActionId,
|
||||||
|
BookMeta,
|
||||||
|
BookOpenResult,
|
||||||
|
Chapter,
|
||||||
|
CreateBookParams,
|
||||||
|
GlobalSettings,
|
||||||
|
IpcResult,
|
||||||
|
UpdateChapterParams,
|
||||||
|
Volume
|
||||||
|
} from '../shared/types'
|
||||||
|
|
||||||
|
const electronAPI = {
|
||||||
|
window: {
|
||||||
|
minimize: (): Promise<void> => ipcRenderer.invoke(IPC.WINDOW_MINIMIZE),
|
||||||
|
maximize: (): Promise<void> => ipcRenderer.invoke(IPC.WINDOW_MAXIMIZE),
|
||||||
|
close: (): Promise<void> => ipcRenderer.invoke(IPC.WINDOW_CLOSE)
|
||||||
|
},
|
||||||
|
settings: {
|
||||||
|
get: (): Promise<IpcResult<GlobalSettings>> => ipcRenderer.invoke(IPC.SETTINGS_GET),
|
||||||
|
update: (partial: Partial<GlobalSettings>): Promise<IpcResult<GlobalSettings>> =>
|
||||||
|
ipcRenderer.invoke(IPC.SETTINGS_UPDATE, partial)
|
||||||
|
},
|
||||||
|
book: {
|
||||||
|
list: (): Promise<IpcResult<BookMeta[]>> => ipcRenderer.invoke(IPC.BOOK_LIST),
|
||||||
|
create: (params: CreateBookParams): Promise<IpcResult<BookMeta>> =>
|
||||||
|
ipcRenderer.invoke(IPC.BOOK_CREATE, params),
|
||||||
|
delete: (bookId: string): Promise<IpcResult<void>> =>
|
||||||
|
ipcRenderer.invoke(IPC.BOOK_DELETE, { bookId }),
|
||||||
|
open: (bookId: string): Promise<IpcResult<BookOpenResult>> =>
|
||||||
|
ipcRenderer.invoke(IPC.BOOK_OPEN, { bookId }),
|
||||||
|
updateMeta: (
|
||||||
|
bookId: string,
|
||||||
|
patch: { lastChapterId?: string | null; status?: BookMeta['status'] }
|
||||||
|
): Promise<IpcResult<BookMeta>> => ipcRenderer.invoke(IPC.BOOK_UPDATE_META, { bookId, ...patch })
|
||||||
|
},
|
||||||
|
volume: {
|
||||||
|
create: (bookId: string, name: string): Promise<IpcResult<Volume>> =>
|
||||||
|
ipcRenderer.invoke(IPC.VOLUME_CREATE, { bookId, name }),
|
||||||
|
update: (
|
||||||
|
bookId: string,
|
||||||
|
volumeId: string,
|
||||||
|
patch: { name?: string; description?: string }
|
||||||
|
): Promise<IpcResult<Volume>> =>
|
||||||
|
ipcRenderer.invoke(IPC.VOLUME_UPDATE, { bookId, volumeId, ...patch }),
|
||||||
|
delete: (bookId: string, volumeId: string): Promise<IpcResult<void>> =>
|
||||||
|
ipcRenderer.invoke(IPC.VOLUME_DELETE, { bookId, volumeId })
|
||||||
|
},
|
||||||
|
chapter: {
|
||||||
|
create: (bookId: string, volumeId: string, title: string): Promise<IpcResult<Chapter>> =>
|
||||||
|
ipcRenderer.invoke(IPC.CHAPTER_CREATE, { bookId, volumeId, title }),
|
||||||
|
get: (bookId: string, chapterId: string): Promise<IpcResult<Chapter>> =>
|
||||||
|
ipcRenderer.invoke(IPC.CHAPTER_GET, { bookId, chapterId }),
|
||||||
|
update: (params: UpdateChapterParams): Promise<IpcResult<Chapter>> =>
|
||||||
|
ipcRenderer.invoke(IPC.CHAPTER_UPDATE, params),
|
||||||
|
delete: (bookId: string, chapterId: string): Promise<IpcResult<void>> =>
|
||||||
|
ipcRenderer.invoke(IPC.CHAPTER_DELETE, { bookId, chapterId })
|
||||||
|
},
|
||||||
|
shortcut: {
|
||||||
|
getAll: (): Promise<IpcResult<Record<ActionId, string>>> =>
|
||||||
|
ipcRenderer.invoke(IPC.SHORTCUT_GET_ALL),
|
||||||
|
register: (action: ActionId, accelerator: string): Promise<IpcResult<void>> =>
|
||||||
|
ipcRenderer.invoke(IPC.SHORTCUT_REGISTER, { action, accelerator })
|
||||||
|
},
|
||||||
|
onShortcutTriggered: (callback: (action: ActionId) => void): (() => void) => {
|
||||||
|
const handler = (_event: IpcRendererEvent, payload: { action: ActionId }) => {
|
||||||
|
callback(payload.action)
|
||||||
|
}
|
||||||
|
ipcRenderer.on(IPC.SHORTCUT_TRIGGERED, handler)
|
||||||
|
return () => ipcRenderer.removeListener(IPC.SHORTCUT_TRIGGERED, handler)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
contextBridge.exposeInMainWorld('electronAPI', electronAPI)
|
||||||
|
|
||||||
|
export type ElectronAPI = typeof electronAPI
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { Provider as JotaiProvider } from 'jotai'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import './i18n'
|
||||||
|
import './styles/themes.css'
|
||||||
|
import './styles/globals.css'
|
||||||
|
import './styles/layout.css'
|
||||||
|
import { TopBar } from '@renderer/components/layout/TopBar'
|
||||||
|
import { HomePage } from '@renderer/components/home/HomePage'
|
||||||
|
import { EditorLayout } from '@renderer/components/layout/EditorLayout'
|
||||||
|
import { SettingsPage } from '@renderer/components/settings/SettingsPage'
|
||||||
|
import { OnboardingWizard } from '@renderer/components/onboarding/OnboardingWizard'
|
||||||
|
import { useAppStore } from '@renderer/stores/useAppStore'
|
||||||
|
import { useSettingsStore } from '@renderer/stores/useSettingsStore'
|
||||||
|
import { useTabStore } from '@renderer/stores/useTabStore'
|
||||||
|
import { useBookStore } from '@renderer/stores/useBookStore'
|
||||||
|
import { flushEditorSave } from '@renderer/components/editor/TipTapEditor'
|
||||||
|
import { ipcCall } from '@renderer/lib/ipc-client'
|
||||||
|
|
||||||
|
function AppInner(): React.JSX.Element {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const view = useAppStore((s) => s.view)
|
||||||
|
const toast = useAppStore((s) => s.toast)
|
||||||
|
const showToast = useAppStore((s) => s.showToast)
|
||||||
|
const setView = useAppStore((s) => s.setView)
|
||||||
|
const { loaded, onboardingCompleted, load: loadSettings } = useSettingsStore()
|
||||||
|
const loadBooks = useBookStore((s) => s.loadBooks)
|
||||||
|
const { activeTabId, openTabs } = useTabStore()
|
||||||
|
const openBook = useBookStore((s) => s.openBook)
|
||||||
|
const [showOnboarding, setShowOnboarding] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void (async () => {
|
||||||
|
await loadSettings()
|
||||||
|
await loadBooks()
|
||||||
|
})()
|
||||||
|
}, [loadSettings, loadBooks])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (loaded && !onboardingCompleted) setShowOnboarding(true)
|
||||||
|
}, [loaded, onboardingCompleted])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const unsub = window.electronAPI.onShortcutTriggered(async (action) => {
|
||||||
|
if (action === 'saveChapter') {
|
||||||
|
try {
|
||||||
|
await flushEditorSave()
|
||||||
|
} catch {
|
||||||
|
showToast(t('toast.saveFailed'))
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (action === 'newChapter') {
|
||||||
|
if (view !== 'editor') return
|
||||||
|
const { currentBookId, activeVolumeId, refreshChapters, setSelectedChapter } = useBookStore.getState()
|
||||||
|
if (!currentBookId || !activeVolumeId) return
|
||||||
|
await flushEditorSave()
|
||||||
|
const ch = await ipcCall(() =>
|
||||||
|
window.electronAPI.chapter.create(currentBookId, activeVolumeId, t('editor.newChapter'))
|
||||||
|
)
|
||||||
|
await refreshChapters()
|
||||||
|
setSelectedChapter(ch.id)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (action === 'closeTab') {
|
||||||
|
const tab = openTabs.find((x) => x.id === activeTabId)
|
||||||
|
if (!tab) return
|
||||||
|
const next = useTabStore.getState().closeTab(tab.id)
|
||||||
|
if (next) {
|
||||||
|
const nt = useTabStore.getState().openTabs.find((x) => x.id === next)
|
||||||
|
if (nt?.type === 'book' && nt.bookId) {
|
||||||
|
await openBook(nt.bookId)
|
||||||
|
setView('editor')
|
||||||
|
} else setView('settings')
|
||||||
|
} else setView('home')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
showToast(t('feature.comingSoon'))
|
||||||
|
})
|
||||||
|
return unsub
|
||||||
|
}, [view, activeTabId, openTabs, openBook, setView, showToast, t])
|
||||||
|
|
||||||
|
const handleOnboardingComplete = (): void => {
|
||||||
|
setShowOnboarding(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div id="app">
|
||||||
|
<TopBar />
|
||||||
|
<div id="main-area">
|
||||||
|
{view === 'home' && <HomePage />}
|
||||||
|
{view === 'editor' && <EditorLayout />}
|
||||||
|
{view === 'settings' && <SettingsPage />}
|
||||||
|
</div>
|
||||||
|
{showOnboarding && <OnboardingWizard onComplete={handleOnboardingComplete} />}
|
||||||
|
{toast && <div className="toast">{toast}</div>}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function App(): React.JSX.Element {
|
||||||
|
return (
|
||||||
|
<JotaiProvider>
|
||||||
|
<AppInner />
|
||||||
|
</JotaiProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { atom } from 'jotai'
|
||||||
|
|
||||||
|
export const editorDirtyAtom = atom(false)
|
||||||
|
export const editorSavingAtom = atom(false)
|
||||||
|
export const lastSavedAtAtom = atom<Date | null>(null)
|
||||||
|
export const wordCountAtom = atom({ chapter: 0, volume: 0, book: 0 })
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
import { useEditor, EditorContent } from '@tiptap/react'
|
||||||
|
import StarterKit from '@tiptap/starter-kit'
|
||||||
|
import Underline from '@tiptap/extension-underline'
|
||||||
|
import Placeholder from '@tiptap/extension-placeholder'
|
||||||
|
import { useEffect, useRef, useCallback } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { useSetAtom } from 'jotai'
|
||||||
|
import type { Chapter } from '@shared/types'
|
||||||
|
import { editorDirtyAtom, editorSavingAtom, lastSavedAtAtom, wordCountAtom } from '@renderer/atoms/editorAtoms'
|
||||||
|
import { useBookStore } from '@renderer/stores/useBookStore'
|
||||||
|
import { ipcCall } from '@renderer/lib/ipc-client'
|
||||||
|
|
||||||
|
function countPlain(text: string): number {
|
||||||
|
const stripped = text.replace(/\s+/g, '')
|
||||||
|
const cjk = stripped.match(/[\u4e00-\u9fff\u3400-\u4dbf]/g)
|
||||||
|
const latin = stripped.match(/[a-zA-Z0-9]+/g)
|
||||||
|
return (cjk?.length ?? 0) + (latin?.length ?? 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TipTapEditorProps {
|
||||||
|
chapter: Chapter | null
|
||||||
|
bookId: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TipTapEditor({ chapter, bookId }: TipTapEditorProps): React.JSX.Element {
|
||||||
|
const { t, i18n } = useTranslation()
|
||||||
|
const setDirty = useSetAtom(editorDirtyAtom)
|
||||||
|
const setSaving = useSetAtom(editorSavingAtom)
|
||||||
|
const setLastSaved = useSetAtom(lastSavedAtAtom)
|
||||||
|
const setWordCount = useSetAtom(wordCountAtom)
|
||||||
|
const updateChapterLocal = useBookStore((s) => s.updateChapterLocal)
|
||||||
|
const chapters = useBookStore((s) => s.chapters)
|
||||||
|
const timerRef = useRef<ReturnType<typeof setTimeout>>()
|
||||||
|
const chapterIdRef = useRef<string | null>(null)
|
||||||
|
|
||||||
|
const editor = useEditor({
|
||||||
|
extensions: [
|
||||||
|
StarterKit,
|
||||||
|
Underline,
|
||||||
|
Placeholder.configure({ placeholder: t('editor.placeholder') })
|
||||||
|
],
|
||||||
|
content: chapter?.content ?? '',
|
||||||
|
onUpdate: ({ editor: ed }) => {
|
||||||
|
setDirty(true)
|
||||||
|
clearTimeout(timerRef.current)
|
||||||
|
timerRef.current = setTimeout(() => {
|
||||||
|
void (window as unknown as { __bilinSave?: () => Promise<void> }).__bilinSave?.()
|
||||||
|
}, 2000)
|
||||||
|
const text = ed.getText()
|
||||||
|
const volId = chapter?.volumeId
|
||||||
|
const chapterCount = countPlain(text)
|
||||||
|
const volumeTotal = chapters
|
||||||
|
.filter((c) => c.volumeId === volId)
|
||||||
|
.reduce((sum, c) => sum + (c.id === chapter?.id ? chapterCount : c.wordCount), 0)
|
||||||
|
const bookTotal = chapters.reduce(
|
||||||
|
(sum, c) => sum + (c.id === chapter?.id ? chapterCount : c.wordCount),
|
||||||
|
0
|
||||||
|
)
|
||||||
|
setWordCount({ chapter: chapterCount, volume: volumeTotal, book: bookTotal })
|
||||||
|
},
|
||||||
|
editorProps: {
|
||||||
|
handlePaste: (view, event) => {
|
||||||
|
if (event.shiftKey) return false
|
||||||
|
const text = event.clipboardData?.getData('text/plain')
|
||||||
|
if (text) {
|
||||||
|
view.dispatch(view.state.tr.insertText(text))
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [i18n.language])
|
||||||
|
|
||||||
|
const save = useCallback(async () => {
|
||||||
|
if (!bookId || !chapter || !editor) return
|
||||||
|
setSaving(true)
|
||||||
|
try {
|
||||||
|
const html = editor.getHTML()
|
||||||
|
const text = editor.getText()
|
||||||
|
const updated = await ipcCall(() =>
|
||||||
|
window.electronAPI.chapter.update({
|
||||||
|
bookId,
|
||||||
|
chapterId: chapter.id,
|
||||||
|
content: html,
|
||||||
|
cursorOffset: editor.state.selection.anchor
|
||||||
|
})
|
||||||
|
)
|
||||||
|
updateChapterLocal(updated)
|
||||||
|
setDirty(false)
|
||||||
|
setLastSaved(new Date())
|
||||||
|
const volId = updated.volumeId
|
||||||
|
const volumeTotal = chapters
|
||||||
|
.map((c) => (c.id === updated.id ? updated : c))
|
||||||
|
.filter((c) => c.volumeId === volId)
|
||||||
|
.reduce((sum, c) => sum + (c.id === updated.id ? countPlain(text) : c.wordCount), 0)
|
||||||
|
const bookTotal = chapters
|
||||||
|
.map((c) => (c.id === updated.id ? updated : c))
|
||||||
|
.reduce((sum, c) => sum + (c.id === updated.id ? countPlain(text) : c.wordCount), 0)
|
||||||
|
setWordCount({ chapter: countPlain(text), volume: volumeTotal, book: bookTotal })
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}, [bookId, chapter, chapters, editor, setDirty, setLastSaved, setSaving, setWordCount, updateChapterLocal])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
;(window as unknown as { __bilinSave?: () => Promise<void> }).__bilinSave = save
|
||||||
|
return () => {
|
||||||
|
clearTimeout(timerRef.current)
|
||||||
|
delete (window as unknown as { __bilinSave?: () => Promise<void> }).__bilinSave
|
||||||
|
}
|
||||||
|
}, [save])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!editor || !chapter) return
|
||||||
|
if (chapterIdRef.current !== chapter.id) {
|
||||||
|
void (async () => {
|
||||||
|
await flushEditorSave()
|
||||||
|
chapterIdRef.current = chapter.id
|
||||||
|
editor.commands.setContent(chapter.content || '')
|
||||||
|
const pos = Math.min(chapter.cursorOffset ?? 0, editor.state.doc.content.size)
|
||||||
|
editor.commands.focus(pos)
|
||||||
|
setDirty(false)
|
||||||
|
setWordCount({
|
||||||
|
chapter: chapter.wordCount,
|
||||||
|
volume: chapters.filter((c) => c.volumeId === chapter.volumeId).reduce((s, c) => s + c.wordCount, 0),
|
||||||
|
book: chapters.reduce((s, c) => s + c.wordCount, 0)
|
||||||
|
})
|
||||||
|
})()
|
||||||
|
}
|
||||||
|
}, [chapter, editor, chapters, setDirty, setWordCount])
|
||||||
|
|
||||||
|
if (!chapter) {
|
||||||
|
return <div className="placeholder-box">{t('editor.placeholder')}</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="editor-content-wrap">
|
||||||
|
<EditorContent editor={editor} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function flushEditorSave(): Promise<void> {
|
||||||
|
const fn = (window as unknown as { __bilinSave?: () => Promise<void> }).__bilinSave
|
||||||
|
if (fn) await fn()
|
||||||
|
}
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { useAppStore } from '@renderer/stores/useAppStore'
|
||||||
|
import { useBookStore } from '@renderer/stores/useBookStore'
|
||||||
|
import { useTabStore } from '@renderer/stores/useTabStore'
|
||||||
|
import { ipcCall } from '@renderer/lib/ipc-client'
|
||||||
|
|
||||||
|
export function HomePage(): React.JSX.Element {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const showToast = useAppStore((s) => s.showToast)
|
||||||
|
const setView = useAppStore((s) => s.setView)
|
||||||
|
const { books, loadBooks, openBook } = useBookStore()
|
||||||
|
const { openBookTab, openSettingsTab } = useTabStore()
|
||||||
|
const [showDialog, setShowDialog] = useState(false)
|
||||||
|
const [name, setName] = useState('')
|
||||||
|
const [category, setCategory] = useState('玄幻')
|
||||||
|
const [target, setTarget] = useState('')
|
||||||
|
|
||||||
|
const handleOpenBook = async (bookId: string, bookName: string): Promise<void> => {
|
||||||
|
await openBook(bookId)
|
||||||
|
openBookTab(bookId, bookName)
|
||||||
|
setView('editor')
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCreate = async (): Promise<void> => {
|
||||||
|
if (!name.trim()) {
|
||||||
|
showToast(t('toast.enterBookName'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const meta = await ipcCall(() =>
|
||||||
|
window.electronAPI.book.create({
|
||||||
|
name: name.trim(),
|
||||||
|
category,
|
||||||
|
targetWordCount: target ? Number(target) : null
|
||||||
|
})
|
||||||
|
)
|
||||||
|
setShowDialog(false)
|
||||||
|
setName('')
|
||||||
|
await loadBooks()
|
||||||
|
showToast(t('toast.bookCreated'))
|
||||||
|
await handleOpenBook(meta.id, meta.name)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div id="home-page">
|
||||||
|
<div className="home-hero">
|
||||||
|
<h1>✒ {t('app.name')}</h1>
|
||||||
|
<p className="subtitle">{t('app.tagline')}</p>
|
||||||
|
</div>
|
||||||
|
<div className="home-actions">
|
||||||
|
<button type="button" className="btn-large primary" onClick={() => setShowDialog(true)}>
|
||||||
|
+ {t('home.newBook')}
|
||||||
|
</button>
|
||||||
|
<button type="button" className="btn-large" onClick={() => showToast(t('feature.comingSoon'))}>
|
||||||
|
{t('home.importBook')}
|
||||||
|
</button>
|
||||||
|
<button type="button" className="btn-large" onClick={() => showToast(t('feature.comingSoon'))}>
|
||||||
|
{t('home.exportAll')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn-large"
|
||||||
|
onClick={() => {
|
||||||
|
openSettingsTab()
|
||||||
|
setView('settings')
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
⚙ {t('settings.title')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<h3 style={{ marginBottom: 12, fontSize: 14 }}>
|
||||||
|
📚 {t('home.myBooks')}{' '}
|
||||||
|
<span style={{ color: 'var(--text-muted)', fontSize: 12 }}>
|
||||||
|
{t('home.bookCount', { count: books.length })}
|
||||||
|
</span>
|
||||||
|
</h3>
|
||||||
|
<div className="book-grid">
|
||||||
|
{books.map((book) => (
|
||||||
|
<div
|
||||||
|
key={book.id}
|
||||||
|
className="book-card"
|
||||||
|
onClick={() => void handleOpenBook(book.id, book.name)}
|
||||||
|
onKeyDown={(e) => e.key === 'Enter' && void handleOpenBook(book.id, book.name)}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
>
|
||||||
|
<div className="book-name">{book.name}</div>
|
||||||
|
<div className="book-meta">
|
||||||
|
<div>{book.category}</div>
|
||||||
|
<div>{new Date(book.lastOpenedAt).toLocaleDateString()}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<div
|
||||||
|
className="book-card"
|
||||||
|
style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', minHeight: 100, color: 'var(--text-muted)' }}
|
||||||
|
onClick={() => setShowDialog(true)}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
>
|
||||||
|
+ {t('home.newBook')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{showDialog && (
|
||||||
|
<div className="dialog-overlay" role="dialog" aria-modal="true">
|
||||||
|
<div className="dialog-content">
|
||||||
|
<h3 style={{ marginBottom: 16 }}>{t('dialog.newBook.title')}</h3>
|
||||||
|
<div className="form-group">
|
||||||
|
<label>{t('dialog.newBook.name')}</label>
|
||||||
|
<input value={name} onChange={(e) => setName(e.target.value)} autoFocus />
|
||||||
|
</div>
|
||||||
|
<div className="form-group">
|
||||||
|
<label>{t('dialog.newBook.category')}</label>
|
||||||
|
<select value={category} onChange={(e) => setCategory(e.target.value)}>
|
||||||
|
<option value="玄幻">玄幻</option>
|
||||||
|
<option value="仙侠">仙侠</option>
|
||||||
|
<option value="科幻">科幻</option>
|
||||||
|
<option value="言情">言情</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="form-group">
|
||||||
|
<label>{t('dialog.newBook.target')}</label>
|
||||||
|
<input type="number" value={target} onChange={(e) => setTarget(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8, marginTop: 16 }}>
|
||||||
|
<button type="button" className="btn" onClick={() => setShowDialog(false)}>
|
||||||
|
{t('dialog.cancel')}
|
||||||
|
</button>
|
||||||
|
<button type="button" className="btn primary" onClick={() => void handleCreate()}>
|
||||||
|
{t('dialog.create')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { useAtomValue } from 'jotai'
|
||||||
|
import { useAppStore } from '@renderer/stores/useAppStore'
|
||||||
|
import { useBookStore } from '@renderer/stores/useBookStore'
|
||||||
|
import { ipcCall } from '@renderer/lib/ipc-client'
|
||||||
|
import { TipTapEditor, flushEditorSave } from '@renderer/components/editor/TipTapEditor'
|
||||||
|
import { RightPanel } from '@renderer/components/layout/RightPanel'
|
||||||
|
import {
|
||||||
|
editorDirtyAtom,
|
||||||
|
editorSavingAtom,
|
||||||
|
lastSavedAtAtom,
|
||||||
|
wordCountAtom
|
||||||
|
} from '@renderer/atoms/editorAtoms'
|
||||||
|
|
||||||
|
export function EditorLayout(): React.JSX.Element {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const showToast = useAppStore((s) => s.showToast)
|
||||||
|
const sidebarPanel = useAppStore((s) => s.setSidebarPanel)
|
||||||
|
const activePanel = useAppStore((s) => s.sidebarPanel)
|
||||||
|
const {
|
||||||
|
currentBookId,
|
||||||
|
volumes,
|
||||||
|
chapters,
|
||||||
|
selectedChapterId,
|
||||||
|
activeVolumeId,
|
||||||
|
setSelectedChapter,
|
||||||
|
setActiveVolume,
|
||||||
|
refreshChapters
|
||||||
|
} = useBookStore()
|
||||||
|
|
||||||
|
const dirty = useAtomValue(editorDirtyAtom)
|
||||||
|
const saving = useAtomValue(editorSavingAtom)
|
||||||
|
const lastSaved = useAtomValue(lastSavedAtAtom)
|
||||||
|
const wordCount = useAtomValue(wordCountAtom)
|
||||||
|
|
||||||
|
const currentChapter = chapters.find((c) => c.id === selectedChapterId) ?? null
|
||||||
|
const bookMeta = useBookStore.getState().books.find((b) => b.id === currentBookId)
|
||||||
|
|
||||||
|
const handleSelectChapter = async (chapterId: string): Promise<void> => {
|
||||||
|
await flushEditorSave()
|
||||||
|
setSelectedChapter(chapterId)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleNewChapter = async (): Promise<void> => {
|
||||||
|
if (!currentBookId || !activeVolumeId) return
|
||||||
|
await flushEditorSave()
|
||||||
|
const ch = await ipcCall(() =>
|
||||||
|
window.electronAPI.chapter.create(currentBookId, activeVolumeId, t('editor.newChapter'))
|
||||||
|
)
|
||||||
|
await refreshChapters()
|
||||||
|
setSelectedChapter(ch.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleNewVolume = async (): Promise<void> => {
|
||||||
|
if (!currentBookId) return
|
||||||
|
const vol = await ipcCall(() =>
|
||||||
|
window.electronAPI.volume.create(currentBookId, t('editor.newVolume'))
|
||||||
|
)
|
||||||
|
await refreshChapters()
|
||||||
|
setActiveVolume(vol.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
const saveStatus = saving ? t('editor.saving') : dirty ? t('editor.unsaved') : t('editor.saved')
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div id="editor-layout">
|
||||||
|
<div id="left-sidebar">
|
||||||
|
<div className="sidebar-header">{bookMeta?.name ?? '—'}</div>
|
||||||
|
<div className="sidebar-nav">
|
||||||
|
{(['chapters', 'outline', 'setting', 'inspiration'] as const).map((panel) => (
|
||||||
|
<button
|
||||||
|
key={panel}
|
||||||
|
type="button"
|
||||||
|
className={`nav-btn ${activePanel === panel ? 'active' : ''}`}
|
||||||
|
onClick={() => sidebarPanel(panel)}
|
||||||
|
>
|
||||||
|
{panel === 'chapters' && t('sidebar.chapters')}
|
||||||
|
{panel === 'outline' && t('sidebar.outline')}
|
||||||
|
{panel === 'setting' && t('sidebar.settings')}
|
||||||
|
{panel === 'inspiration' && t('sidebar.inspiration')}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className={`sidebar-panel ${activePanel === 'chapters' ? 'active' : ''}`}>
|
||||||
|
{volumes.map((vol) => (
|
||||||
|
<div key={vol.id}>
|
||||||
|
<div
|
||||||
|
className="vol-header"
|
||||||
|
onClick={() => setActiveVolume(vol.id)}
|
||||||
|
style={{ color: activeVolumeId === vol.id ? 'var(--accent-light)' : undefined }}
|
||||||
|
>
|
||||||
|
{vol.name}
|
||||||
|
</div>
|
||||||
|
{chapters
|
||||||
|
.filter((c) => c.volumeId === vol.id)
|
||||||
|
.map((ch, idx) => (
|
||||||
|
<div
|
||||||
|
key={ch.id}
|
||||||
|
className={`chapter-item ${selectedChapterId === ch.id ? 'active' : ''}`}
|
||||||
|
onClick={() => void handleSelectChapter(ch.id)}
|
||||||
|
onKeyDown={(e) => e.key === 'Enter' && void handleSelectChapter(ch.id)}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
>
|
||||||
|
<span>{idx + 1}.</span>
|
||||||
|
<span style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis' }}>{ch.title}</span>
|
||||||
|
<span className={`ch-badge ${ch.status === 'done' ? 'done' : 'draft'}`}>{ch.status}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{activePanel !== 'chapters' && (
|
||||||
|
<div className="sidebar-panel active">
|
||||||
|
<div className="placeholder-box" style={{ padding: 20 }}>
|
||||||
|
{t('feature.comingSoon')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="sidebar-footer">
|
||||||
|
<button type="button" onClick={() => void handleNewChapter()}>
|
||||||
|
+ {t('editor.newChapter')}
|
||||||
|
</button>
|
||||||
|
<button type="button" style={{ marginTop: 6 }} onClick={() => void handleNewVolume()}>
|
||||||
|
+ {t('editor.newVolume')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="editor-area">
|
||||||
|
<div id="editor-toolbar">
|
||||||
|
<button type="button" className="tool-btn" onClick={() => showToast(t('feature.comingSoon'))} title="引用">
|
||||||
|
📎
|
||||||
|
</button>
|
||||||
|
<span className="editor-label">
|
||||||
|
{currentChapter ? currentChapter.title : '—'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<TipTapEditor chapter={currentChapter} bookId={currentBookId} />
|
||||||
|
<div id="editor-statusbar">
|
||||||
|
<span>{saveStatus}{lastSaved ? ` · ${lastSaved.toLocaleTimeString()}` : ''}</span>
|
||||||
|
<span>{t('status.chapter')}: {t('editor.words', { count: wordCount.chapter })}</span>
|
||||||
|
<span>{t('status.volume')}: {t('editor.words', { count: wordCount.volume })}</span>
|
||||||
|
<span>{t('status.book')}: {t('editor.words', { count: wordCount.book })}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<RightPanel />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { useAppStore } from '@renderer/stores/useAppStore'
|
||||||
|
|
||||||
|
export function RightPanel(): React.JSX.Element {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const { rightPanel, setRightPanel } = useAppStore()
|
||||||
|
|
||||||
|
const tabs = [
|
||||||
|
{ id: 'ai' as const, label: t('panel.ai') },
|
||||||
|
{ id: 'knowledge' as const, label: t('panel.knowledge') },
|
||||||
|
{ id: 'wordfreq' as const, label: t('panel.wordfreq') },
|
||||||
|
{ id: 'book-settings' as const, label: t('panel.bookSettings') }
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div id="right-panel">
|
||||||
|
<div className="panel-tabs">
|
||||||
|
{tabs.map((tab) => (
|
||||||
|
<button
|
||||||
|
key={tab.id}
|
||||||
|
type="button"
|
||||||
|
className={`panel-tab ${rightPanel === tab.id ? 'active' : ''}`}
|
||||||
|
onClick={() => setRightPanel(tab.id)}
|
||||||
|
>
|
||||||
|
{tab.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className={`panel-content ${rightPanel === 'ai' ? 'active' : ''}`}>
|
||||||
|
<p style={{ fontSize: 11, color: 'var(--text-muted)', marginBottom: 8 }}>{t('panel.aiPlaceholder')}</p>
|
||||||
|
<input className="ai-input-disabled" disabled placeholder={t('feature.comingSoon')} />
|
||||||
|
</div>
|
||||||
|
{(['knowledge', 'wordfreq', 'book-settings'] as const).map((id) => (
|
||||||
|
<div key={id} className={`panel-content ${rightPanel === id ? 'active' : ''}`}>
|
||||||
|
<div className="placeholder-box">{t('feature.comingSoon')}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { useAppStore } from '@renderer/stores/useAppStore'
|
||||||
|
import { useTabStore } from '@renderer/stores/useTabStore'
|
||||||
|
import { useBookStore } from '@renderer/stores/useBookStore'
|
||||||
|
|
||||||
|
export function TopBar(): React.JSX.Element {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const setView = useAppStore((s) => s.setView)
|
||||||
|
const showToast = useAppStore((s) => s.showToast)
|
||||||
|
const { openTabs, activeTabId, switchTab, closeTab, openSettingsTab } = useTabStore()
|
||||||
|
const currentBookId = useBookStore((s) => s.currentBookId)
|
||||||
|
|
||||||
|
const handleCloseTab = (tabId: string, e: React.MouseEvent): void => {
|
||||||
|
e.stopPropagation()
|
||||||
|
const next = closeTab(tabId)
|
||||||
|
if (next) {
|
||||||
|
const tab = useTabStore.getState().openTabs.find((t) => t.id === next)
|
||||||
|
if (tab?.type === 'book' && tab.bookId) {
|
||||||
|
void useBookStore.getState().openBook(tab.bookId)
|
||||||
|
setView('editor')
|
||||||
|
} else if (tab?.type === 'settings') {
|
||||||
|
setView('settings')
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setView('home')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div id="topbar">
|
||||||
|
<button type="button" className="logo-area" onClick={() => setView('home')}>
|
||||||
|
✒ {t('app.name')}
|
||||||
|
</button>
|
||||||
|
<div className="tab-bar">
|
||||||
|
{openTabs.map((tab) => (
|
||||||
|
<button
|
||||||
|
key={tab.id}
|
||||||
|
type="button"
|
||||||
|
className={`tab ${tab.id === activeTabId ? 'active' : ''}`}
|
||||||
|
onClick={() => {
|
||||||
|
switchTab(tab.id)
|
||||||
|
if (tab.type === 'book' && tab.bookId) {
|
||||||
|
void useBookStore.getState().openBook(tab.bookId)
|
||||||
|
setView('editor')
|
||||||
|
} else {
|
||||||
|
setView('settings')
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||||
|
{tab.name}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
onClick={(e) => handleCloseTab(tab.id, e)}
|
||||||
|
style={{ marginLeft: 4, opacity: 0.6, fontSize: 10 }}
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="top-spacer" />
|
||||||
|
<button type="button" className="top-btn" title={t('feature.comingSoon')} onClick={() => showToast(t('feature.comingSoon'))}>
|
||||||
|
📊
|
||||||
|
</button>
|
||||||
|
<button type="button" className="top-btn" onClick={() => { openSettingsTab(); setView('settings') }}>
|
||||||
|
⚙
|
||||||
|
</button>
|
||||||
|
<button type="button" className="top-btn" onClick={() => showToast(t('feature.comingSoon'))}>
|
||||||
|
👁
|
||||||
|
</button>
|
||||||
|
<button type="button" className="top-btn" onClick={() => window.electronAPI.window.minimize()}>—</button>
|
||||||
|
<button type="button" className="top-btn" onClick={() => window.electronAPI.window.maximize()}>□</button>
|
||||||
|
<button type="button" className="top-btn window-close" onClick={() => window.electronAPI.window.close()}>✕</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { useSettingsStore } from '@renderer/stores/useSettingsStore'
|
||||||
|
import { useBookStore } from '@renderer/stores/useBookStore'
|
||||||
|
import { ipcCall } from '@renderer/lib/ipc-client'
|
||||||
|
|
||||||
|
interface OnboardingWizardProps {
|
||||||
|
onComplete: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function OnboardingWizard({ onComplete }: OnboardingWizardProps): React.JSX.Element {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const update = useSettingsStore((s) => s.update)
|
||||||
|
const loadBooks = useBookStore((s) => s.loadBooks)
|
||||||
|
const [step, setStep] = useState(0)
|
||||||
|
const [penName, setPenName] = useState('')
|
||||||
|
const [createSample, setCreateSample] = useState(true)
|
||||||
|
|
||||||
|
const finish = async (skipped = false): Promise<void> => {
|
||||||
|
const name = skipped ? '未命名作者' : penName.trim() || '未命名作者'
|
||||||
|
await update({ penName: name, onboardingCompleted: true })
|
||||||
|
if (createSample) {
|
||||||
|
await ipcCall(() =>
|
||||||
|
window.electronAPI.book.create({
|
||||||
|
name: t('book.createSample'),
|
||||||
|
category: '未分类',
|
||||||
|
createSampleChapter: true
|
||||||
|
})
|
||||||
|
)
|
||||||
|
await loadBooks()
|
||||||
|
}
|
||||||
|
onComplete()
|
||||||
|
}
|
||||||
|
|
||||||
|
const slides = [
|
||||||
|
<div key="0" style={{ textAlign: 'center', padding: '20px 0' }}>
|
||||||
|
<div style={{ fontSize: 48, marginBottom: 12 }}>✒</div>
|
||||||
|
<h2>{t('onboarding.welcome')}</h2>
|
||||||
|
<p style={{ color: 'var(--text-secondary)', marginTop: 8 }}>{t('onboarding.tagline')}</p>
|
||||||
|
</div>,
|
||||||
|
<div key="1">
|
||||||
|
<h2>{t('onboarding.slide1')}</h2>
|
||||||
|
<p style={{ color: 'var(--text-secondary)', marginTop: 8 }}>{t('onboarding.slide1desc')}</p>
|
||||||
|
</div>,
|
||||||
|
<div key="2">
|
||||||
|
<h2>{t('onboarding.slide2')}</h2>
|
||||||
|
<p style={{ color: 'var(--text-secondary)', marginTop: 8 }}>{t('onboarding.slide2desc')}</p>
|
||||||
|
</div>,
|
||||||
|
<div key="3">
|
||||||
|
<h2>{t('onboarding.slide3')}</h2>
|
||||||
|
<p style={{ color: 'var(--text-secondary)', marginTop: 8 }}>{t('onboarding.slide3desc')}</p>
|
||||||
|
</div>,
|
||||||
|
<div key="4">
|
||||||
|
<h2>{t('onboarding.penName')}</h2>
|
||||||
|
<input
|
||||||
|
data-testid="onboarding-pen-name"
|
||||||
|
value={penName}
|
||||||
|
onChange={(e) => setPenName(e.target.value)}
|
||||||
|
placeholder={t('onboarding.penNamePlaceholder')}
|
||||||
|
style={{ width: '100%', marginTop: 12, padding: 10, borderRadius: 6, border: '1px solid var(--border)', background: 'var(--bg-tertiary)', color: 'inherit' }}
|
||||||
|
/>
|
||||||
|
<label style={{ display: 'flex', alignItems: 'center', gap: 8, marginTop: 12, fontSize: 12, color: 'var(--text-secondary)' }}>
|
||||||
|
<input type="checkbox" checked={createSample} onChange={(e) => setCreateSample(e.target.checked)} />
|
||||||
|
{t('onboarding.createSample')}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="dialog-overlay">
|
||||||
|
<div className="dialog-content" style={{ maxWidth: 520 }}>
|
||||||
|
{slides[step]}
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', marginTop: 24 }}>
|
||||||
|
<button type="button" className="btn" onClick={() => void finish(true)}>
|
||||||
|
{t('onboarding.skip')}
|
||||||
|
</button>
|
||||||
|
{step < slides.length - 1 ? (
|
||||||
|
<button type="button" className="btn primary" data-testid="onboarding-next" onClick={() => setStep((s) => s + 1)}>
|
||||||
|
{t('onboarding.next')}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button type="button" className="btn primary" data-testid="onboarding-start" onClick={() => void finish(false)}>
|
||||||
|
{t('onboarding.start')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import type { ThemeId, Language } from '@shared/types'
|
||||||
|
import { useSettingsStore } from '@renderer/stores/useSettingsStore'
|
||||||
|
import { ShortcutEditor } from '@renderer/components/settings/ShortcutEditor'
|
||||||
|
|
||||||
|
const THEMES: { id: ThemeId; key: string }[] = [
|
||||||
|
{ id: 'default', key: 'theme.default' },
|
||||||
|
{ id: 'bamboo', key: 'theme.bamboo' },
|
||||||
|
{ id: 'moonlit', key: 'theme.moonlit' },
|
||||||
|
{ id: 'ricepaper', key: 'theme.ricepaper' },
|
||||||
|
{ id: 'mist', key: 'theme.mist' },
|
||||||
|
{ id: 'teagarden', key: 'theme.teagarden' },
|
||||||
|
{ id: 'twilight', key: 'theme.twilight' }
|
||||||
|
]
|
||||||
|
|
||||||
|
export function SettingsPage(): React.JSX.Element {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const settings = useSettingsStore()
|
||||||
|
const [section, setSection] = useState<'general' | 'shortcuts' | 'about'>('general')
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div id="settings-page">
|
||||||
|
<div className="settings-sidebar">
|
||||||
|
<div
|
||||||
|
className={`settings-nav-item ${section === 'general' ? 'active' : ''}`}
|
||||||
|
onClick={() => setSection('general')}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
>
|
||||||
|
{t('settings.general')}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={`settings-nav-item ${section === 'shortcuts' ? 'active' : ''}`}
|
||||||
|
onClick={() => setSection('shortcuts')}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
>
|
||||||
|
{t('settings.shortcuts')}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={`settings-nav-item ${section === 'about' ? 'active' : ''}`}
|
||||||
|
onClick={() => setSection('about')}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
>
|
||||||
|
{t('settings.about')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="settings-content">
|
||||||
|
<h2 style={{ marginBottom: 20 }}>{t('settings.title')}</h2>
|
||||||
|
{section === 'general' && (
|
||||||
|
<>
|
||||||
|
<div className="setting-item">
|
||||||
|
<span>{t('settings.penName')}</span>
|
||||||
|
<input
|
||||||
|
data-testid="settings-pen-name"
|
||||||
|
value={settings.penName}
|
||||||
|
onChange={(e) => void settings.update({ penName: e.target.value })}
|
||||||
|
style={{ width: 160, padding: '6px 8px', borderRadius: 4, border: '1px solid var(--border)', background: 'var(--bg-surface)', color: 'inherit' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="setting-item">
|
||||||
|
<span>{t('settings.theme')}</span>
|
||||||
|
<select
|
||||||
|
data-testid="settings-theme"
|
||||||
|
value={settings.theme}
|
||||||
|
onChange={(e) => void settings.update({ theme: e.target.value as ThemeId })}
|
||||||
|
style={{ padding: '6px 8px', borderRadius: 4, border: '1px solid var(--border)', background: 'var(--bg-surface)', color: 'inherit' }}
|
||||||
|
>
|
||||||
|
{THEMES.map((th) => (
|
||||||
|
<option key={th.id} value={th.id}>
|
||||||
|
{t(th.key)}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="setting-item">
|
||||||
|
<span>{t('settings.language')}</span>
|
||||||
|
<select
|
||||||
|
data-testid="settings-language"
|
||||||
|
value={settings.language}
|
||||||
|
onChange={(e) => void settings.update({ language: e.target.value as Language })}
|
||||||
|
style={{ padding: '6px 8px', borderRadius: 4, border: '1px solid var(--border)', background: 'var(--bg-surface)', color: 'inherit' }}
|
||||||
|
>
|
||||||
|
<option value="zh-CN">简体中文</option>
|
||||||
|
<option value="en">English</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{section === 'shortcuts' && <ShortcutEditor />}
|
||||||
|
{section === 'about' && (
|
||||||
|
<p style={{ color: 'var(--text-secondary)', lineHeight: 1.8 }}>
|
||||||
|
{t('app.name')} v0.1.0
|
||||||
|
<br />
|
||||||
|
{t('app.tagline')}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import type { ActionId } from '@shared/types'
|
||||||
|
import { DEFAULT_SHORTCUTS } from '@shared/default-shortcuts'
|
||||||
|
import { ipcCall } from '@renderer/lib/ipc-client'
|
||||||
|
import { useAppStore } from '@renderer/stores/useAppStore'
|
||||||
|
|
||||||
|
const ACTION_IDS: ActionId[] = [
|
||||||
|
'saveChapter',
|
||||||
|
'newChapter',
|
||||||
|
'focusMode',
|
||||||
|
'globalSearch',
|
||||||
|
'closeTab',
|
||||||
|
'openReference',
|
||||||
|
'insertLandmark',
|
||||||
|
'openLandmarks',
|
||||||
|
'exportBook',
|
||||||
|
'toggleTTS',
|
||||||
|
'captureInspiration'
|
||||||
|
]
|
||||||
|
|
||||||
|
function formatAccelerator(acc: string): string {
|
||||||
|
return acc.replace(/CommandOrControl/g, 'Ctrl')
|
||||||
|
}
|
||||||
|
|
||||||
|
function keyEventToAccelerator(e: KeyboardEvent): string | null {
|
||||||
|
if (['Control', 'Shift', 'Alt', 'Meta'].includes(e.key)) return null
|
||||||
|
|
||||||
|
const parts: string[] = []
|
||||||
|
if (e.ctrlKey || e.metaKey) parts.push('CommandOrControl')
|
||||||
|
if (e.shiftKey) parts.push('Shift')
|
||||||
|
if (e.altKey) parts.push('Alt')
|
||||||
|
|
||||||
|
const special: Record<string, string> = {
|
||||||
|
' ': 'Space',
|
||||||
|
ArrowUp: 'Up',
|
||||||
|
ArrowDown: 'Down',
|
||||||
|
ArrowLeft: 'Left',
|
||||||
|
ArrowRight: 'Right',
|
||||||
|
Escape: 'Esc',
|
||||||
|
Backspace: 'Backspace',
|
||||||
|
Delete: 'Delete',
|
||||||
|
Enter: 'Enter',
|
||||||
|
Tab: 'Tab'
|
||||||
|
}
|
||||||
|
|
||||||
|
let key = special[e.key]
|
||||||
|
if (!key) {
|
||||||
|
if (e.key.length === 1) key = e.key.toUpperCase()
|
||||||
|
else if (e.code.startsWith('Key')) key = e.code.slice(3)
|
||||||
|
else if (e.code.startsWith('Digit')) key = e.code.slice(5)
|
||||||
|
else key = e.key
|
||||||
|
}
|
||||||
|
|
||||||
|
parts.push(key)
|
||||||
|
return parts.join('+')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ShortcutEditor(): React.JSX.Element {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const showToast = useAppStore((s) => s.showToast)
|
||||||
|
const [bindings, setBindings] = useState<Record<ActionId, string>>({ ...DEFAULT_SHORTCUTS })
|
||||||
|
const [recording, setRecording] = useState<ActionId | null>(null)
|
||||||
|
|
||||||
|
const loadBindings = useCallback(async () => {
|
||||||
|
const all = await ipcCall(() => window.electronAPI.shortcut.getAll())
|
||||||
|
setBindings(all)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void loadBindings()
|
||||||
|
}, [loadBindings])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!recording) return
|
||||||
|
|
||||||
|
const onKeyDown = (e: KeyboardEvent): void => {
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
setRecording(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const accelerator = keyEventToAccelerator(e)
|
||||||
|
if (!accelerator) return
|
||||||
|
|
||||||
|
const conflict = ACTION_IDS.some(
|
||||||
|
(id) => id !== recording && bindings[id] === accelerator
|
||||||
|
)
|
||||||
|
if (conflict) {
|
||||||
|
showToast(t('shortcuts.conflict'))
|
||||||
|
setRecording(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
await ipcCall(() => window.electronAPI.shortcut.register(recording, accelerator))
|
||||||
|
setBindings((prev) => ({ ...prev, [recording]: accelerator }))
|
||||||
|
} catch {
|
||||||
|
showToast(t('shortcuts.registerFailed'))
|
||||||
|
} finally {
|
||||||
|
setRecording(null)
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('keydown', onKeyDown, true)
|
||||||
|
return () => window.removeEventListener('keydown', onKeyDown, true)
|
||||||
|
}, [recording, bindings, showToast, t])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="shortcut-editor">
|
||||||
|
<p style={{ color: 'var(--text-secondary)', marginBottom: 16, fontSize: 13 }}>
|
||||||
|
{recording ? t('shortcuts.recordHint') : t('shortcuts.clickToRecord')}
|
||||||
|
</p>
|
||||||
|
{ACTION_IDS.map((action) => (
|
||||||
|
<div key={action} className="setting-item">
|
||||||
|
<span>{t(`shortcuts.${action}`)}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`shortcut-key ${recording === action ? 'recording' : ''}`}
|
||||||
|
data-testid={`shortcut-${action}`}
|
||||||
|
onClick={() => setRecording(action)}
|
||||||
|
>
|
||||||
|
{recording === action ? t('shortcuts.recording') : formatAccelerator(bindings[action] ?? '—')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import i18n from 'i18next'
|
||||||
|
import { initReactI18next } from 'react-i18next'
|
||||||
|
import zh from '../../../public/locales/zh-CN/translation.json'
|
||||||
|
import en from '../../../public/locales/en/translation.json'
|
||||||
|
|
||||||
|
void i18n.use(initReactI18next).init({
|
||||||
|
resources: {
|
||||||
|
'zh-CN': { translation: zh },
|
||||||
|
en: { translation: en }
|
||||||
|
},
|
||||||
|
lng: 'zh-CN',
|
||||||
|
fallbackLng: 'zh-CN',
|
||||||
|
interpolation: { escapeValue: false }
|
||||||
|
})
|
||||||
|
|
||||||
|
export default i18n
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>笔临</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="./main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import type { IpcError, IpcResult } from '@shared/types'
|
||||||
|
|
||||||
|
export class IpcCallError extends Error {
|
||||||
|
ipcError: IpcError
|
||||||
|
|
||||||
|
constructor(ipcError: IpcError) {
|
||||||
|
super(ipcError.message)
|
||||||
|
this.name = 'IpcCallError'
|
||||||
|
this.ipcError = ipcError
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function ipcCall<T>(fn: () => Promise<IpcResult<T>>): Promise<T> {
|
||||||
|
const result = await fn()
|
||||||
|
if (!result.ok) throw new IpcCallError(result.error)
|
||||||
|
return result.data
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import type { ThemeId } from '@shared/types'
|
||||||
|
|
||||||
|
export function applyTheme(theme: ThemeId): void {
|
||||||
|
if (theme === 'default') {
|
||||||
|
document.documentElement.removeAttribute('data-theme')
|
||||||
|
} else {
|
||||||
|
document.documentElement.setAttribute('data-theme', theme)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import ReactDOM from 'react-dom/client'
|
||||||
|
import App from './App'
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<App />
|
||||||
|
</React.StrictMode>
|
||||||
|
)
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { create } from 'zustand'
|
||||||
|
|
||||||
|
export type AppView = 'home' | 'editor' | 'settings'
|
||||||
|
|
||||||
|
interface AppState {
|
||||||
|
view: AppView
|
||||||
|
sidebarPanel: 'chapters' | 'outline' | 'setting' | 'inspiration'
|
||||||
|
rightPanel: 'ai' | 'knowledge' | 'wordfreq' | 'book-settings'
|
||||||
|
toast: string | null
|
||||||
|
setView: (view: AppView) => void
|
||||||
|
setSidebarPanel: (panel: AppState['sidebarPanel']) => void
|
||||||
|
setRightPanel: (panel: AppState['rightPanel']) => void
|
||||||
|
showToast: (message: string) => void
|
||||||
|
clearToast: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useAppStore = create<AppState>((set) => ({
|
||||||
|
view: 'home',
|
||||||
|
sidebarPanel: 'chapters',
|
||||||
|
rightPanel: 'ai',
|
||||||
|
toast: null,
|
||||||
|
setView: (view) => set({ view }),
|
||||||
|
setSidebarPanel: (sidebarPanel) => set({ sidebarPanel }),
|
||||||
|
setRightPanel: (rightPanel) => set({ rightPanel }),
|
||||||
|
showToast: (toast) => {
|
||||||
|
set({ toast })
|
||||||
|
setTimeout(() => set({ toast: null }), 2500)
|
||||||
|
},
|
||||||
|
clearToast: () => set({ toast: null })
|
||||||
|
}))
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import { create } from 'zustand'
|
||||||
|
import type { BookMeta, Volume, Chapter } from '@shared/types'
|
||||||
|
import { ipcCall } from '@renderer/lib/ipc-client'
|
||||||
|
|
||||||
|
interface BookState {
|
||||||
|
books: BookMeta[]
|
||||||
|
currentBookId: string | null
|
||||||
|
volumes: Volume[]
|
||||||
|
chapters: Chapter[]
|
||||||
|
selectedChapterId: string | null
|
||||||
|
activeVolumeId: string | null
|
||||||
|
loadBooks: () => Promise<void>
|
||||||
|
openBook: (bookId: string) => Promise<void>
|
||||||
|
setSelectedChapter: (chapterId: string) => void
|
||||||
|
setActiveVolume: (volumeId: string) => void
|
||||||
|
refreshChapters: () => Promise<void>
|
||||||
|
updateChapterLocal: (chapter: Chapter) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useBookStore = create<BookState>((set, get) => ({
|
||||||
|
books: [],
|
||||||
|
currentBookId: null,
|
||||||
|
volumes: [],
|
||||||
|
chapters: [],
|
||||||
|
selectedChapterId: null,
|
||||||
|
activeVolumeId: null,
|
||||||
|
loadBooks: async () => {
|
||||||
|
const books = await ipcCall(() => window.electronAPI.book.list())
|
||||||
|
set({ books })
|
||||||
|
},
|
||||||
|
openBook: async (bookId) => {
|
||||||
|
const result = await ipcCall(() => window.electronAPI.book.open(bookId))
|
||||||
|
const firstVolume = result.volumes[0]?.id ?? null
|
||||||
|
const lastChapter =
|
||||||
|
result.meta.lastChapterId ??
|
||||||
|
result.chapters.find((c) => c.volumeId === firstVolume)?.id ??
|
||||||
|
result.chapters[0]?.id ??
|
||||||
|
null
|
||||||
|
set({
|
||||||
|
currentBookId: bookId,
|
||||||
|
volumes: result.volumes,
|
||||||
|
chapters: result.chapters,
|
||||||
|
activeVolumeId: result.chapters.find((c) => c.id === lastChapter)?.volumeId ?? firstVolume,
|
||||||
|
selectedChapterId: lastChapter
|
||||||
|
})
|
||||||
|
},
|
||||||
|
setSelectedChapter: (chapterId) => {
|
||||||
|
const ch = get().chapters.find((c) => c.id === chapterId)
|
||||||
|
set({
|
||||||
|
selectedChapterId: chapterId,
|
||||||
|
activeVolumeId: ch?.volumeId ?? get().activeVolumeId
|
||||||
|
})
|
||||||
|
},
|
||||||
|
setActiveVolume: (volumeId) => set({ activeVolumeId: volumeId }),
|
||||||
|
refreshChapters: async () => {
|
||||||
|
const bookId = get().currentBookId
|
||||||
|
if (!bookId) return
|
||||||
|
const result = await ipcCall(() => window.electronAPI.book.open(bookId))
|
||||||
|
set({ volumes: result.volumes, chapters: result.chapters })
|
||||||
|
},
|
||||||
|
updateChapterLocal: (chapter) => {
|
||||||
|
set((s) => ({
|
||||||
|
chapters: s.chapters.map((c) => (c.id === chapter.id ? chapter : c))
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
}))
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { create } from 'zustand'
|
||||||
|
import type { ThemeId, Language, GlobalSettings, ActionId } from '@shared/types'
|
||||||
|
import { ipcCall } from '@renderer/lib/ipc-client'
|
||||||
|
import { applyTheme } from '@renderer/lib/theme'
|
||||||
|
import i18n from '@renderer/i18n'
|
||||||
|
|
||||||
|
interface SettingsState extends GlobalSettings {
|
||||||
|
loaded: boolean
|
||||||
|
load: () => Promise<void>
|
||||||
|
update: (partial: Partial<GlobalSettings>) => Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useSettingsStore = create<SettingsState>((set, get) => ({
|
||||||
|
penName: '未命名作者',
|
||||||
|
onboardingCompleted: false,
|
||||||
|
theme: 'default',
|
||||||
|
language: 'zh-CN',
|
||||||
|
dailyWordGoal: 0,
|
||||||
|
shortcuts: {} as Record<ActionId, string>,
|
||||||
|
loaded: false,
|
||||||
|
load: async () => {
|
||||||
|
const data = await ipcCall(() => window.electronAPI.settings.get())
|
||||||
|
set({ ...data, loaded: true })
|
||||||
|
applyTheme(data.theme)
|
||||||
|
await i18n.changeLanguage(data.language)
|
||||||
|
},
|
||||||
|
update: async (partial) => {
|
||||||
|
const data = await ipcCall(() => window.electronAPI.settings.update(partial))
|
||||||
|
set({ ...get(), ...data })
|
||||||
|
if (partial.theme) applyTheme(data.theme)
|
||||||
|
if (partial.language) await i18n.changeLanguage(data.language)
|
||||||
|
}
|
||||||
|
}))
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { create } from 'zustand'
|
||||||
|
|
||||||
|
export interface AppTab {
|
||||||
|
id: string
|
||||||
|
type: 'book' | 'settings'
|
||||||
|
name: string
|
||||||
|
bookId?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TabState {
|
||||||
|
openTabs: AppTab[]
|
||||||
|
activeTabId: string | null
|
||||||
|
openBookTab: (bookId: string, name: string) => void
|
||||||
|
openSettingsTab: () => void
|
||||||
|
switchTab: (tabId: string) => void
|
||||||
|
closeTab: (tabId: string) => string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useTabStore = create<TabState>((set, get) => ({
|
||||||
|
openTabs: [],
|
||||||
|
activeTabId: null,
|
||||||
|
openBookTab: (bookId, name) => {
|
||||||
|
const existing = get().openTabs.find((t) => t.type === 'book' && t.bookId === bookId)
|
||||||
|
if (existing) {
|
||||||
|
set({ activeTabId: existing.id })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const id = `book-${bookId}`
|
||||||
|
set((s) => ({
|
||||||
|
openTabs: [...s.openTabs, { id, type: 'book', name, bookId }],
|
||||||
|
activeTabId: id
|
||||||
|
}))
|
||||||
|
},
|
||||||
|
openSettingsTab: () => {
|
||||||
|
const existing = get().openTabs.find((t) => t.type === 'settings')
|
||||||
|
if (existing) {
|
||||||
|
set({ activeTabId: existing.id })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
set((s) => ({
|
||||||
|
openTabs: [...s.openTabs, { id: 'settings', type: 'settings', name: '系统设置' }],
|
||||||
|
activeTabId: 'settings'
|
||||||
|
}))
|
||||||
|
},
|
||||||
|
switchTab: (tabId) => set({ activeTabId: tabId }),
|
||||||
|
closeTab: (tabId) => {
|
||||||
|
const { openTabs, activeTabId } = get()
|
||||||
|
const idx = openTabs.findIndex((t) => t.id === tabId)
|
||||||
|
if (idx === -1) return activeTabId
|
||||||
|
const nextTabs = openTabs.filter((t) => t.id !== tabId)
|
||||||
|
let nextActive = activeTabId
|
||||||
|
if (activeTabId === tabId) {
|
||||||
|
nextActive = nextTabs[Math.max(0, idx - 1)]?.id ?? null
|
||||||
|
}
|
||||||
|
set({ openTabs: nextTabs, activeTabId: nextActive })
|
||||||
|
return nextActive
|
||||||
|
}
|
||||||
|
}))
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
html,
|
||||||
|
body,
|
||||||
|
#root {
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
background: var(--bg-primary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
}
|
||||||
|
|
||||||
|
#app {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
font-family: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
input,
|
||||||
|
select,
|
||||||
|
textarea {
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
padding: 8px 16px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 13px;
|
||||||
|
transition: var(--transition);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn.primary {
|
||||||
|
background: var(--accent);
|
||||||
|
border-color: var(--accent);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn.primary:hover {
|
||||||
|
background: var(--accent-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hidden {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
@@ -0,0 +1,515 @@
|
|||||||
|
#topbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
height: 44px;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
padding: 0 10px;
|
||||||
|
gap: 4px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
-webkit-app-region: drag;
|
||||||
|
}
|
||||||
|
|
||||||
|
#topbar button,
|
||||||
|
#topbar .tab,
|
||||||
|
#topbar .logo-area {
|
||||||
|
-webkit-app-region: no-drag;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo-area {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 14px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
cursor: pointer;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo-area:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 2px;
|
||||||
|
flex: 1;
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 6px 12px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
border: 1px solid transparent;
|
||||||
|
background: transparent;
|
||||||
|
cursor: pointer;
|
||||||
|
max-width: 160px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab.active {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
border-color: var(--border-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.top-spacer {
|
||||||
|
flex: 1;
|
||||||
|
-webkit-app-region: drag;
|
||||||
|
}
|
||||||
|
|
||||||
|
.top-btn {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.top-btn:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.window-close:hover {
|
||||||
|
background: var(--red) !important;
|
||||||
|
color: #fff !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
#main-area {
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
#home-page {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 32px 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-hero h1 {
|
||||||
|
font-size: 28px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-hero .subtitle {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-bottom: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-large {
|
||||||
|
padding: 10px 18px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-large.primary {
|
||||||
|
background: var(--accent);
|
||||||
|
border-color: var(--accent);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.book-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.book-card {
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 16px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: var(--transition);
|
||||||
|
}
|
||||||
|
|
||||||
|
.book-card:hover {
|
||||||
|
border-color: var(--accent);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.book-name {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.book-meta {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
#editor-layout {
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
#left-sidebar {
|
||||||
|
width: 260px;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border-right: 1px solid var(--border);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-header {
|
||||||
|
padding: 12px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-nav {
|
||||||
|
display: flex;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-btn {
|
||||||
|
flex: 1;
|
||||||
|
padding: 8px 4px;
|
||||||
|
font-size: 11px;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-muted);
|
||||||
|
border-bottom: 2px solid transparent;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-btn.active {
|
||||||
|
color: var(--accent-light);
|
||||||
|
border-bottom-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-panel {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-panel.active {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vol-header {
|
||||||
|
padding: 8px 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chapter-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 7px 12px 7px 20px;
|
||||||
|
font-size: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chapter-item:hover,
|
||||||
|
.chapter-item.active {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ch-badge {
|
||||||
|
margin-left: auto;
|
||||||
|
font-size: 10px;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ch-badge.done {
|
||||||
|
color: var(--green);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ch-badge.draft {
|
||||||
|
color: var(--orange);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-footer {
|
||||||
|
padding: 10px;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-footer button {
|
||||||
|
width: 100%;
|
||||||
|
padding: 8px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#editor-area {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#editor-toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-btn {
|
||||||
|
width: 30px;
|
||||||
|
height: 30px;
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-btn:hover,
|
||||||
|
.tool-btn.active {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-label {
|
||||||
|
margin-left: auto;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-content-wrap {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
background: var(--editor-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ProseMirror {
|
||||||
|
min-height: 100%;
|
||||||
|
padding: 32px 48px;
|
||||||
|
outline: none;
|
||||||
|
color: var(--editor-text);
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 1.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ProseMirror p.is-editor-empty:first-child::before {
|
||||||
|
color: var(--text-muted);
|
||||||
|
content: attr(data-placeholder);
|
||||||
|
float: left;
|
||||||
|
height: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
#editor-statusbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 6px 14px;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
#right-panel {
|
||||||
|
width: 320px;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border-left: 1px solid var(--border);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-tabs {
|
||||||
|
display: flex;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-tab {
|
||||||
|
flex: 1;
|
||||||
|
padding: 8px 4px;
|
||||||
|
font-size: 10px;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
border-bottom: 2px solid transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-tab.active {
|
||||||
|
color: var(--accent-light);
|
||||||
|
border-bottom-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-content {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 12px;
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-content.active {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.placeholder-box {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.7;
|
||||||
|
padding: 16px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
#settings-page {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-sidebar {
|
||||||
|
width: 180px;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border-right: 1px solid var(--border);
|
||||||
|
padding: 8px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-nav-item {
|
||||||
|
padding: 10px 16px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
border-left: 3px solid transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-nav-item.active {
|
||||||
|
color: var(--text-primary);
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border-left-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-content {
|
||||||
|
flex: 1;
|
||||||
|
padding: 24px 32px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 10px 14px;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
margin-bottom: 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shortcut-key {
|
||||||
|
min-width: 140px;
|
||||||
|
padding: 6px 12px;
|
||||||
|
border-radius: 4px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: var(--bg-surface);
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-family: ui-monospace, monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shortcut-key.recording {
|
||||||
|
border-color: var(--accent);
|
||||||
|
box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent) 30%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.65);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-content {
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 20px;
|
||||||
|
width: min(480px, 90vw);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group label {
|
||||||
|
display: block;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group input,
|
||||||
|
.form-group select {
|
||||||
|
width: 100%;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 24px;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
background: var(--bg-surface);
|
||||||
|
border: 1px solid var(--border-light);
|
||||||
|
padding: 10px 18px;
|
||||||
|
border-radius: 20px;
|
||||||
|
font-size: 12px;
|
||||||
|
z-index: 2000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-input-disabled {
|
||||||
|
width: 100%;
|
||||||
|
padding: 8px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
:root {
|
||||||
|
--bg-primary: #1c1816;
|
||||||
|
--bg-secondary: #201c1a;
|
||||||
|
--bg-tertiary: #262220;
|
||||||
|
--bg-surface: #2a2624;
|
||||||
|
--bg-hover: #302c28;
|
||||||
|
--bg-active: #363230;
|
||||||
|
--bg-card: #221e1c;
|
||||||
|
--text-primary: #e8e0d8;
|
||||||
|
--text-secondary: #b8aca0;
|
||||||
|
--text-muted: #786858;
|
||||||
|
--text-dim: #584838;
|
||||||
|
--accent: #c0504a;
|
||||||
|
--accent-glow: rgba(192, 80, 74, 0.25);
|
||||||
|
--accent-light: #d4706a;
|
||||||
|
--accent-dark: #a0403a;
|
||||||
|
--green: #609060;
|
||||||
|
--red: #c0504a;
|
||||||
|
--orange: #c08050;
|
||||||
|
--border: #383028;
|
||||||
|
--border-light: #484030;
|
||||||
|
--editor-bg: #1c1816;
|
||||||
|
--editor-text: #e8e0d8;
|
||||||
|
--radius-sm: 6px;
|
||||||
|
--radius: 10px;
|
||||||
|
--font-sans: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif;
|
||||||
|
--transition: 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme='bamboo'] {
|
||||||
|
--bg-primary: #1a1e18;
|
||||||
|
--bg-secondary: #1e221c;
|
||||||
|
--bg-tertiary: #242820;
|
||||||
|
--bg-surface: #282c24;
|
||||||
|
--bg-hover: #2e322a;
|
||||||
|
--bg-card: #20241c;
|
||||||
|
--text-primary: #e0e4d8;
|
||||||
|
--text-secondary: #b0b8a8;
|
||||||
|
--text-muted: #687858;
|
||||||
|
--accent: #7a9a60;
|
||||||
|
--accent-light: #98b878;
|
||||||
|
--border: #303828;
|
||||||
|
--editor-bg: #1a1e18;
|
||||||
|
--editor-text: #e0e4d8;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme='moonlit'] {
|
||||||
|
--bg-primary: #1a1e24;
|
||||||
|
--bg-secondary: #1e2228;
|
||||||
|
--bg-tertiary: #242830;
|
||||||
|
--bg-hover: #2e3238;
|
||||||
|
--bg-card: #202428;
|
||||||
|
--text-primary: #d8dce4;
|
||||||
|
--text-secondary: #a8b0b8;
|
||||||
|
--accent: #8098b8;
|
||||||
|
--accent-light: #a0b4d0;
|
||||||
|
--border: #2a3038;
|
||||||
|
--editor-bg: #1a1e24;
|
||||||
|
--editor-text: #d8dce4;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme='ricepaper'] {
|
||||||
|
--bg-primary: #f4efe4;
|
||||||
|
--bg-secondary: #faf6ee;
|
||||||
|
--bg-tertiary: #efe8d8;
|
||||||
|
--bg-hover: #e8e0d0;
|
||||||
|
--bg-card: #fcf6ec;
|
||||||
|
--text-primary: #3a3028;
|
||||||
|
--text-secondary: #6a5a48;
|
||||||
|
--text-muted: #988878;
|
||||||
|
--accent: #8b4513;
|
||||||
|
--accent-light: #a86030;
|
||||||
|
--border: #d8d0c0;
|
||||||
|
--editor-bg: #fdf9f2;
|
||||||
|
--editor-text: #3a3028;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme='mist'] {
|
||||||
|
--bg-primary: #eff0f2;
|
||||||
|
--bg-secondary: #f5f6f8;
|
||||||
|
--bg-tertiary: #eaeaee;
|
||||||
|
--bg-hover: #e2e3e8;
|
||||||
|
--bg-card: #f8f8fa;
|
||||||
|
--text-primary: #2c3038;
|
||||||
|
--text-secondary: #585c68;
|
||||||
|
--accent: #6878a0;
|
||||||
|
--border: #d8dae0;
|
||||||
|
--editor-bg: #f8f9fc;
|
||||||
|
--editor-text: #2c3038;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme='teagarden'] {
|
||||||
|
--bg-primary: #eef0e4;
|
||||||
|
--bg-secondary: #f4f6ec;
|
||||||
|
--bg-tertiary: #e6e8d8;
|
||||||
|
--bg-hover: #dee2d0;
|
||||||
|
--bg-card: #f2f4e8;
|
||||||
|
--text-primary: #2d3a28;
|
||||||
|
--text-secondary: #4a5840;
|
||||||
|
--accent: #688848;
|
||||||
|
--border: #d0d8c0;
|
||||||
|
--editor-bg: #f6faf0;
|
||||||
|
--editor-text: #2d3a28;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme='twilight'] {
|
||||||
|
--bg-primary: #1f1a16;
|
||||||
|
--bg-secondary: #241e1a;
|
||||||
|
--bg-tertiary: #2a2420;
|
||||||
|
--bg-hover: #342e28;
|
||||||
|
--bg-card: #26201c;
|
||||||
|
--text-primary: #e8dcc8;
|
||||||
|
--text-secondary: #b8a890;
|
||||||
|
--accent: #c88850;
|
||||||
|
--accent-light: #d8a870;
|
||||||
|
--border: #383028;
|
||||||
|
--editor-bg: #1f1a16;
|
||||||
|
--editor-text: #e8dcc8;
|
||||||
|
}
|
||||||
Vendored
+1
@@ -0,0 +1 @@
|
|||||||
|
/// <reference types="./../shared/electron-api.d.ts" />
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import type { ActionId } from './types'
|
||||||
|
|
||||||
|
export const DEFAULT_SHORTCUTS: Record<ActionId, string> = {
|
||||||
|
saveChapter: 'CommandOrControl+S',
|
||||||
|
newChapter: 'CommandOrControl+Shift+N',
|
||||||
|
focusMode: 'CommandOrControl+Alt+F',
|
||||||
|
globalSearch: 'CommandOrControl+Shift+F',
|
||||||
|
closeTab: 'CommandOrControl+W',
|
||||||
|
openReference: 'CommandOrControl+Shift+P',
|
||||||
|
insertLandmark: 'CommandOrControl+Shift+M',
|
||||||
|
openLandmarks: 'CommandOrControl+Shift+L',
|
||||||
|
exportBook: 'CommandOrControl+Shift+E',
|
||||||
|
toggleTTS: 'CommandOrControl+Shift+R',
|
||||||
|
captureInspiration: 'CommandOrControl+Shift+I'
|
||||||
|
}
|
||||||
Vendored
+61
@@ -0,0 +1,61 @@
|
|||||||
|
import type {
|
||||||
|
ActionId,
|
||||||
|
BookMeta,
|
||||||
|
BookOpenResult,
|
||||||
|
Chapter,
|
||||||
|
CreateBookParams,
|
||||||
|
GlobalSettings,
|
||||||
|
IpcResult,
|
||||||
|
UpdateChapterParams,
|
||||||
|
Volume
|
||||||
|
} from './types'
|
||||||
|
|
||||||
|
export interface ElectronAPI {
|
||||||
|
window: {
|
||||||
|
minimize: () => Promise<void>
|
||||||
|
maximize: () => Promise<void>
|
||||||
|
close: () => Promise<void>
|
||||||
|
}
|
||||||
|
settings: {
|
||||||
|
get: () => Promise<IpcResult<GlobalSettings>>
|
||||||
|
update: (partial: Partial<GlobalSettings>) => Promise<IpcResult<GlobalSettings>>
|
||||||
|
}
|
||||||
|
book: {
|
||||||
|
list: () => Promise<IpcResult<BookMeta[]>>
|
||||||
|
create: (params: CreateBookParams) => Promise<IpcResult<BookMeta>>
|
||||||
|
delete: (bookId: string) => Promise<IpcResult<void>>
|
||||||
|
open: (bookId: string) => Promise<IpcResult<BookOpenResult>>
|
||||||
|
updateMeta: (
|
||||||
|
bookId: string,
|
||||||
|
patch: { lastChapterId?: string | null; status?: BookMeta['status'] }
|
||||||
|
) => Promise<IpcResult<BookMeta>>
|
||||||
|
}
|
||||||
|
volume: {
|
||||||
|
create: (bookId: string, name: string) => Promise<IpcResult<Volume>>
|
||||||
|
update: (
|
||||||
|
bookId: string,
|
||||||
|
volumeId: string,
|
||||||
|
patch: { name?: string; description?: string }
|
||||||
|
) => Promise<IpcResult<Volume>>
|
||||||
|
delete: (bookId: string, volumeId: string) => Promise<IpcResult<void>>
|
||||||
|
}
|
||||||
|
chapter: {
|
||||||
|
create: (bookId: string, volumeId: string, title: string) => Promise<IpcResult<Chapter>>
|
||||||
|
get: (bookId: string, chapterId: string) => Promise<IpcResult<Chapter>>
|
||||||
|
update: (params: UpdateChapterParams) => Promise<IpcResult<Chapter>>
|
||||||
|
delete: (bookId: string, chapterId: string) => Promise<IpcResult<void>>
|
||||||
|
}
|
||||||
|
shortcut: {
|
||||||
|
getAll: () => Promise<IpcResult<Record<ActionId, string>>>
|
||||||
|
register: (action: ActionId, accelerator: string) => Promise<IpcResult<void>>
|
||||||
|
}
|
||||||
|
onShortcutTriggered: (callback: (action: ActionId) => void) => () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
electronAPI: ElectronAPI
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export {}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
export const IPC = {
|
||||||
|
SETTINGS_GET: 'settings:get',
|
||||||
|
SETTINGS_UPDATE: 'settings:update',
|
||||||
|
BOOK_LIST: 'book:list',
|
||||||
|
BOOK_CREATE: 'book:create',
|
||||||
|
BOOK_DELETE: 'book:delete',
|
||||||
|
BOOK_OPEN: 'book:open',
|
||||||
|
BOOK_UPDATE_META: 'book:updateMeta',
|
||||||
|
VOLUME_CREATE: 'volume:create',
|
||||||
|
VOLUME_UPDATE: 'volume:update',
|
||||||
|
VOLUME_DELETE: 'volume:delete',
|
||||||
|
CHAPTER_CREATE: 'chapter:create',
|
||||||
|
CHAPTER_GET: 'chapter:get',
|
||||||
|
CHAPTER_UPDATE: 'chapter:update',
|
||||||
|
CHAPTER_DELETE: 'chapter:delete',
|
||||||
|
SHORTCUT_GET_ALL: 'shortcut:getAll',
|
||||||
|
SHORTCUT_REGISTER: 'shortcut:register',
|
||||||
|
SHORTCUT_TRIGGERED: 'shortcut:triggered',
|
||||||
|
WINDOW_MINIMIZE: 'window:minimize',
|
||||||
|
WINDOW_MAXIMIZE: 'window:maximize',
|
||||||
|
WINDOW_CLOSE: 'window:close'
|
||||||
|
} as const
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
export type ThemeId =
|
||||||
|
| 'default'
|
||||||
|
| 'bamboo'
|
||||||
|
| 'moonlit'
|
||||||
|
| 'ricepaper'
|
||||||
|
| 'mist'
|
||||||
|
| 'teagarden'
|
||||||
|
| 'twilight'
|
||||||
|
|
||||||
|
export type Language = 'zh-CN' | 'en'
|
||||||
|
export type ChapterStatus = 'draft' | 'review' | 'done'
|
||||||
|
export type BookStatus = 'draft' | 'ongoing' | 'done'
|
||||||
|
|
||||||
|
export type ActionId =
|
||||||
|
| 'saveChapter'
|
||||||
|
| 'newChapter'
|
||||||
|
| 'focusMode'
|
||||||
|
| 'globalSearch'
|
||||||
|
| 'closeTab'
|
||||||
|
| 'openReference'
|
||||||
|
| 'insertLandmark'
|
||||||
|
| 'openLandmarks'
|
||||||
|
| 'exportBook'
|
||||||
|
| 'toggleTTS'
|
||||||
|
| 'captureInspiration'
|
||||||
|
|
||||||
|
export enum ErrorCode {
|
||||||
|
DB_READ_FAILED = 'DB_READ_FAILED',
|
||||||
|
DB_WRITE_FAILED = 'DB_WRITE_FAILED',
|
||||||
|
FILE_NOT_FOUND = 'FILE_NOT_FOUND',
|
||||||
|
IMPORT_INVALID = 'IMPORT_INVALID',
|
||||||
|
UNKNOWN = 'UNKNOWN'
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IpcError {
|
||||||
|
code: ErrorCode
|
||||||
|
message: string
|
||||||
|
recoverable: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export type IpcResult<T> = { ok: true; data: T } | { ok: false; error: IpcError }
|
||||||
|
|
||||||
|
export interface GlobalSettings {
|
||||||
|
penName: string
|
||||||
|
onboardingCompleted: boolean
|
||||||
|
theme: ThemeId
|
||||||
|
language: Language
|
||||||
|
dailyWordGoal: number
|
||||||
|
shortcuts: Record<ActionId, string>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BookMeta {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
category: string
|
||||||
|
targetWordCount: number | null
|
||||||
|
dbPath: string
|
||||||
|
status: BookStatus
|
||||||
|
lastOpenedAt: string
|
||||||
|
lastChapterId: string | null
|
||||||
|
createdAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateBookParams {
|
||||||
|
name: string
|
||||||
|
category: string
|
||||||
|
targetWordCount?: number | null
|
||||||
|
createSampleChapter?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Volume {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
description: string
|
||||||
|
sortOrder: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Chapter {
|
||||||
|
id: string
|
||||||
|
volumeId: string | null
|
||||||
|
title: string
|
||||||
|
content: string
|
||||||
|
status: ChapterStatus
|
||||||
|
wordCount: number
|
||||||
|
sortOrder: number
|
||||||
|
cursorOffset: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BookOpenResult {
|
||||||
|
meta: BookMeta
|
||||||
|
volumes: Volume[]
|
||||||
|
chapters: Chapter[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateChapterParams {
|
||||||
|
bookId: string
|
||||||
|
chapterId: string
|
||||||
|
title?: string
|
||||||
|
content?: string
|
||||||
|
status?: ChapterStatus
|
||||||
|
cursorOffset?: number
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
||||||
|
import { existsSync, mkdtempSync, rmSync } from 'fs'
|
||||||
|
import { join } from 'path'
|
||||||
|
import { tmpdir } from 'os'
|
||||||
|
import { BookRegistryService } from '../../src/main/services/book-registry'
|
||||||
|
import { closeAllBookDbs } from '../../src/main/db/connection'
|
||||||
|
|
||||||
|
describe('BookRegistryService', () => {
|
||||||
|
let dir: string
|
||||||
|
let svc: BookRegistryService
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
dir = mkdtempSync(join(tmpdir(), 'bilin-books-'))
|
||||||
|
svc = new BookRegistryService(dir)
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
closeAllBookDbs()
|
||||||
|
rmSync(dir, { recursive: true, force: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('creates book with registry entry, sqlite file, and default volume', () => {
|
||||||
|
const meta = svc.create({
|
||||||
|
name: '测试书',
|
||||||
|
category: '玄幻',
|
||||||
|
createSampleChapter: true
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(meta.name).toBe('测试书')
|
||||||
|
expect(svc.list()).toHaveLength(1)
|
||||||
|
expect(existsSync(join(dir, 'books', `${meta.id}.sqlite`))).toBe(true)
|
||||||
|
|
||||||
|
const opened = svc.open(meta.id)
|
||||||
|
expect(opened.volumes).toHaveLength(1)
|
||||||
|
expect(opened.volumes[0].name).toBe('第一卷')
|
||||||
|
expect(opened.chapters.length).toBeGreaterThanOrEqual(1)
|
||||||
|
expect(meta.lastChapterId).toBeTruthy()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
||||||
|
import { DatabaseSync } from 'node:sqlite'
|
||||||
|
import { migrate } from '../../src/main/db/migrate'
|
||||||
|
import { ChapterRepository } from '../../src/main/db/repositories/chapter.repo'
|
||||||
|
import { VolumeRepository } from '../../src/main/db/repositories/volume.repo'
|
||||||
|
import type { SqliteDb } from '../../src/main/db/types'
|
||||||
|
|
||||||
|
describe('ChapterRepository', () => {
|
||||||
|
let db: SqliteDb
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
db = new DatabaseSync(':memory:')
|
||||||
|
migrate(db)
|
||||||
|
})
|
||||||
|
afterEach(() => {
|
||||||
|
db.close()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('creates chapter and updates word count', () => {
|
||||||
|
const volumes = new VolumeRepository(db)
|
||||||
|
const chapters = new ChapterRepository(db)
|
||||||
|
const vol = volumes.create('第一卷', 0)
|
||||||
|
const ch = chapters.create(vol.id, '测试章', 0, '林远深吸一口气')
|
||||||
|
expect(ch.wordCount).toBe(7)
|
||||||
|
const updated = chapters.update(ch.id, { content: '<p>更长的一段文字内容</p>' })
|
||||||
|
expect(updated.wordCount).toBeGreaterThan(ch.wordCount)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
||||||
|
import { mkdtempSync, rmSync } from 'fs'
|
||||||
|
import { join } from 'path'
|
||||||
|
import { tmpdir } from 'os'
|
||||||
|
import { GlobalSettingsService } from '../../src/main/services/global-settings'
|
||||||
|
|
||||||
|
let dir: string
|
||||||
|
let svc: GlobalSettingsService
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
dir = mkdtempSync(join(tmpdir(), 'bilin-settings-'))
|
||||||
|
svc = new GlobalSettingsService(dir)
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
rmSync(dir, { recursive: true, force: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('GlobalSettingsService', () => {
|
||||||
|
it('returns defaults when file missing', () => {
|
||||||
|
const s = svc.get()
|
||||||
|
expect(s.onboardingCompleted).toBe(false)
|
||||||
|
expect(s.theme).toBe('default')
|
||||||
|
expect(s.language).toBe('zh-CN')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('persists penName update', () => {
|
||||||
|
svc.update({ penName: '山海' })
|
||||||
|
const s2 = new GlobalSettingsService(dir)
|
||||||
|
expect(s2.get().penName).toBe('山海')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { countWords } from '../../src/main/services/word-count'
|
||||||
|
|
||||||
|
describe('countWords', () => {
|
||||||
|
it('counts CJK characters excluding whitespace', () => {
|
||||||
|
expect(countWords('林远深吸一口气')).toBe(7)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns 0 for empty', () => {
|
||||||
|
expect(countWords('')).toBe(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{ "path": "./tsconfig.node.json" },
|
||||||
|
{ "path": "./tsconfig.web.json" }
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"include": [
|
||||||
|
"electron.vite.config.ts",
|
||||||
|
"src/main/**/*",
|
||||||
|
"src/preload/**/*",
|
||||||
|
"src/shared/**/*",
|
||||||
|
"tests/**/*"
|
||||||
|
],
|
||||||
|
"compilerOptions": {
|
||||||
|
"composite": true,
|
||||||
|
"types": ["node"],
|
||||||
|
"strict": true,
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"module": "ESNext",
|
||||||
|
"target": "ESNext",
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"outDir": "./out"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"include": [
|
||||||
|
"src/renderer/**/*",
|
||||||
|
"src/shared/**/*"
|
||||||
|
],
|
||||||
|
"compilerOptions": {
|
||||||
|
"composite": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"strict": true,
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"module": "ESNext",
|
||||||
|
"target": "ESNext",
|
||||||
|
"lib": ["ESNext", "DOM", "DOM.Iterable"],
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@shared/*": ["src/shared/*"],
|
||||||
|
"@renderer/*": ["src/renderer/*"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { defineConfig } from 'vitest/config'
|
||||||
|
import { resolve } from 'path'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
test: {
|
||||||
|
environment: 'node',
|
||||||
|
include: ['tests/**/*.test.ts']
|
||||||
|
},
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@shared': resolve('src/shared')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user