Files
author/app/components/HelpPanel.js
T

926 lines
38 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client';
import { useState, useEffect } from 'react';
import { useI18n } from '../lib/useI18n';
const HELP_SECTIONS = [
{
id: 'quickstart',
title: '🚀 快速开始',
content: `
## 欢迎使用 Author
Author 是一款面向小说创作者的 **AI 辅助写作平台**,集成智能续写、设定集管理、上下文感知等专业功能,为你打造沉浸式创作体验。
### 第一步:配置 AI
1. 点击左侧边栏底部的 **⚙️ 设定** 按钮
2. 在「API 配置」中填写你的 AI 服务信息
3. 支持 **OpenAI 兼容接口** 和 **Google Gemini 原生接口**
4. 点击「测试连接」确认配置正确
### 第二步:开始创作
1. 在左侧边栏点击 **+ 新建** 创建章节
2. 在编辑器中直接开始写作
3. 使用顶部工具栏调整格式
### 第三步:AI 辅助
- **内联 AI**:按 **Ctrl+J** 在光标处唤起 AI,直接在编辑器中续写
- **AI 聊天**:点击右上角 **✦ AI** 打开侧边栏,与 AI 对话讨论剧情
- AI 会自动参考你的设定集、前文内容和写作模式
### 界面概览
| 区域 | 功能 |
|------|------|
| **左侧边栏** | 章节管理、字数统计、导出本章/导出更多、存档/读档、导入作品、设定集、主题 |
| **顶部工具栏** | 文字格式、对齐、列表、公式、排版 |
| **编辑区域** | 所见即所得的富文本编辑,分页预览 |
| **右侧 AI 栏** | AI 聊天对话、上下文参考管理 |
| **底部状态栏** | 写作模式、页数、字数统计 |
`,
},
{
id: 'ai-inline',
title: '✦ 内联 AI 写作',
content: `
## 内联 AI 写作助手
在编辑器内直接使用 AI,类似 Cursor 的体验。
### 唤起方式
按下 **Ctrl+J**(Mac: **⌘+J**)在光标位置弹出 AI 面板。
### AI 模式
| 模式 | 功能 | 需要选中文字 |
|------|------|:----:|
| ✦ 续写 | 从光标处自然续写故事 | ✗ |
| ✎ 润色 | 提升文字质量和流畅度 | ✓ |
| ⊕ 扩写 | 丰富细节与描写 | ✓ |
| ⊖ 精简 | 浓缩核心内容 | ✓ |
### 使用步骤
1. 将光标放在要续写的位置,或选中要修改的文字
2. 按 **Ctrl+J** 打开 AI 面板
3. 选择所需模式
4. (可选)在输入框中补充指示,如"写一段打斗场景"
5. 按 **Enter** 或点击 **✦ 生成** 开始
6. AI 会以 **Ghost Text**(幽灵文字)效果出现在编辑器中
7. 点击 **✓ 接受** 确认采用,或 **✕ 拒绝** 撤销
### 上下文感知
AI 会自动参考:
- **前文内容**:自动采集前文上下文
- **设定集**:角色设定、世界观、情节大纲等
- **写作模式**:网文/纯文学/剧本不同风格
> 提示:在设定集中配置角色和世界观,AI 生成内容会更贴合你的故事。
`,
},
{
id: 'ai-chat',
title: '💬 AI 聊天侧栏',
content: `
## AI 聊天侧栏
与 AI 进行多轮对话,讨论剧情、角色、世界观,甚至让 AI 直接管理你的设定集。
### 打开方式
点击编辑器右上角的 **✦ AI** 按钮,或使用快捷键。
### 核心功能
#### 多轮对话
- 支持完整的多轮对话历史
- AI 回复支持 **Markdown 渲染**(代码块、表格、列表等)
- 可以编辑已发送的消息重新生成
#### 上下文参考(参考 Tab)
切换到「参考」标签页,勾选要注入的上下文:
- 人物设定、世界观、地点、物品、大纲、写作规则
- 勾选的内容会作为 AI 的背景知识
#### 会话管理
- **新建会话**:开启全新对话
- **切换会话**:在多个对话间切换
- **重命名/删除**:右键管理会话
#### AI 管理设定集
对话中 AI 可以生成 **设定操作卡片**,你可以一键应用:
- **添加**:AI 建议新角色/设定,点击「✅ 应用」直接写入设定集
- **更新**:AI 修改已有设定(按名称自动匹配)
- **删除**:AI 建议删除某项设定
- 点击卡片标题栏 **▼ 展开** 可以查看完整内容
#### 消息变体
- 对 AI 回复点击「重新生成」可获取不同版本
- 使用 < 1/3 > 导航切换不同变体
`,
},
{
id: 'settings',
title: '⚙️ 设定集系统',
content: `
## 设定集管理
设定集是 AI 创作的"记忆",帮助 AI 深入理解你的故事世界,生成更贴合的内容。
### 打开方式
点击侧栏底部的 **⚙️** 按钮。
### API 配置与模型设置
这是 AI 助理运作的基石。所有写入的 API 密钥都在 **本地浏览器存储**,绝对安全。
| 设置项 | 说明 |
|------|------|
| **API 提供商** | 支持智谱、DeepSeek、OpenAI、OpenAI Responses、Gemini(兼容/原生)、SiliconFlow、Kimi 等,也可选自定义接入 |
| **API Key** | 你的大模型服务凭证。必须配置才能唤醒光标 AI |
| **Base URL** | 接口地址(切换提供商时会自动填写官方地址) |
| **模型选择** | 点击「从API拉取模型列表」自动更新当前可用的大模型库供你选择 |
| **独立向量API** | 高级功能。允许你用专用的 Embedding 模型解决长篇巨著的“记忆遗忘”问题 |
### 偏好设置 (Preferences)
在设定集的最后一栏,你可以随时:
- 切换语言界面(简体中文、English、Русский)
- 切换工作台视觉主题(经典纸张 / 现代玻璃)
### 多作品管理
支持在一个项目中管理多个作品的设定集:
- 创建多个作品,每个作品拥有独立的设定树
- 在左侧下拉列表中切换当前作品
- 删除作品会同时删除其下所有设定节点
### 书籍信息
设置书名、类型、简介——帮助 AI 了解整体方向。
### 设定树
在左侧树形结构中添加和管理各类设定:
| 分类 | 图标 | 可设置字段 |
|------|------|---------||
| **角色设定** | 🎭 | 类型、性别、年龄、外貌、性格、说话风格、背景、动机、能力、关系 |
| **世界观** | 🌍 | 描述、备注 |
| **地点/空间** | 📍 | 描述、场景标题、感官细节(视觉/听觉/嗅觉)、氛围 |
| **物品/道具** | 📦 | 描述、类型、品阶、持有者、数值属性、象征意义 |
| **剧情大纲** | 📋 | 状态(计划中/写作中/已完成)、描述、备注 |
| **写作规则** | ✍️ | 规则描述(AI 严格遵守) |
### 额外字段
AI 生成的非标准字段会自动出现在 **✨ AI 生成的额外字段** 分组中,可查看和编辑。
### 设定集导入/导出
支持多种格式的设定集导入和导出:
- **导出**:JSON、TXT、Markdown、DOCX、PDF
- **导入**:JSON(完整数据)、TXT / MD / DOCX / PDF(智能解析分类和条目)
#### 导入冲突解决
导入设定集时,如果有同名条目已存在,会弹出 **冲突解决弹窗**:
- **保留已有** — 保持当前版本不变
- **使用导入** — 用导入版本覆盖
- **🤖 AI 智能合并** — AI 将两个版本合并,保留所有有价值信息
- AI 合并支持 **结果轮播**:多次合并时可用 ◀ 1/N ▶ 切换不同版本
### 写作模式
| 模式 | 特点 |
|------|------|
| 📱 网文模式 | 节奏紧凑、对话多、爽点密集 |
| 📖 纯文学模式 | 叙述细腻、描写丰富、注重意境 |
| 🎬 剧本模式 | 标准剧本格式 |
`,
},
{
id: 'chapters',
title: '📚 章节管理',
content: `
## 章节管理
### 创建章节
点击侧栏中的 **+ 新建章节** 按钮,自动创建带有递增编号的新章节(如「第一章」→「第二章」,支持中文数字和阿拉伯数字)。
### 切换章节
点击侧栏中的章节名称切换到该章节,编辑器会加载其内容。
### 右键操作
在章节名称上 **右键单击** 可以:
- **✎ 重命名** — 修改章节标题
- **↓ 导出 Markdown** — 将当前章节导出为 .md 文件
- **✕ 删除章节** — 删除该章节(需确认)
### 拖拽排序
长按章节名称可以拖拽重新排列顺序。
### 字数统计
- 每个章节旁显示本章字数
- 侧栏底部显示全书总字数
### 分页预览
编辑器以类似 Word/Google Docs 的分页视图呈现,白色纸张卡片 + 灰色画布背景,底部状态栏显示当前页数。
`,
},
{
id: 'toolbar',
title: '🎨 工具栏功能',
content: `
## 工具栏一览
### 撤销 / 重做
↩ 撤销最近的操作,↪ 恢复已撤销的操作。
### 字体与字号
- 下拉选择字体:默认(宋体)、黑体、楷体、仿宋、Serif、Monospace
- 下拉选择字号:从 12px 到 32px
### 文字格式
| 按钮 | 功能 | 快捷键 |
|------|------|--------|
| **B** | 加粗 | Ctrl+B |
| *I* | 斜体 | Ctrl+I |
| U | 下划线 | Ctrl+U |
| ~~S~~ | 删除线 | — |
| X² | 上标 | — |
| X₂ | 下标 | — |
### 颜色
- **A▾** — 文字颜色选择器
- **高亮▾** — 背景高亮色选择器
### 标题与对齐
- H1/H2/H3 — 一级/二级/三级标题
- 左对齐/居中/右对齐/两端对齐
### 排版调节
点击 **Aa▾** 调节全局字号(14-24px)和行距(1.4-2.6),可一键恢复默认。
### 列表与块元素
| 按钮 | 功能 |
|------|------|
| • 列 | 无序列表 |
| 1. 列 | 有序列表 |
| ☑ 任 | 任务列表 |
| ❝ 引 | 引用块 |
| </> | 代码块 |
| ∑ | LaTeX 公式 |
| —— | 水平分割线 |
`,
},
{
id: 'data',
title: '💾 数据管理',
content: `
## 数据管理
### 自动保存
编辑内容会 **实时自动保存** 到浏览器的 localStorage,无需手动保存。
### 侧栏底部按钮
#### 导出区(上排)
| 按钮 | 功能 |
|------|------|
| **导出本章** | 将当前选中章节导出(支持 TXT、Markdown、DOCX、EPUB、PDF) |
| **导出更多** | 打开弹窗,自由勾选要导出的章节和格式 |
| ⚙️ | 打开设定集管理 |
| 🌙 / ☀️ | 切换浅色/暗色模式 |
#### 工具区(下排)
| 图标 | 功能 |
|------|------|
| 🕒 | **时光机** — 版本历史,回溯到之前的快照 |
| 💾 | **存档** — 将整个项目(所有章节 + 设定集 + AI 对话)导出为 JSON 文件 |
| 📂 | **读档** — 从 JSON 文件恢复完整项目 |
| 📥 | **导入作品** — 从文件导入章节(支持多种格式) |
### 多格式导入
支持从以下格式导入作品(自动识别章节):
- **TXT** — 纯文本
- **Markdown (.md)** — Markdown 格式
- **EPUB** — 电子书
- **DOCX** — Word 文档
- **DOC** — 旧版 Word 文档
- **PDF** — PDF 文件
#### 智能章节合并
导入到已有作品时,系统会智能比对章节编号:
- 自动识别多种编号格式(如 "第三十三章"、"33"、"三十三" 视为同一章)
- **无冲突** → 按编号自动排序合并
- **有冲突** → 弹出冲突解决弹窗,可勾选保留哪些章节
- 导入到空白作品 → 直接导入
### 多格式导出
通过「导出本章」或「导出更多」按钮,选择导出格式:
- **TXT** — 纯文本
- **Markdown** — .md 格式
- **DOCX** — Word 文档
- **EPUB** — 电子书
- **PDF** — 打印为 PDF
「导出更多」弹窗支持按分组批量勾选章节,方便部分导出。
### 重要提醒
- 所有数据存储在 **浏览器本地**,不会上传到任何服务器
- **清除浏览器数据** 会丢失所有未导出的内容
- **API Key** 存储在本地 localStorage 中
- 建议定期使用 💾 存档功能备份
### ⚠️ AI 功能的隐私须知
使用 AI 功能时(续写、改写、对话等),你的 **API Key** 和 **发送给 AI 的文字内容** 会经过部署者的服务器转发给 AI 供应商。
如果你正在使用他人部署的公开实例:
- 可以先**简单体验**功能
- 体验完毕后,**务必到 API 提供商网站及时销毁你的 Key**
- **正式使用请自行 Fork 并部署私有实例**
`,
},
{
id: 'markdown',
title: '📝 Markdown',
content: `
## Markdown 自动渲染
在编辑器中输入 Markdown 语法会 **自动转换** 为富文本格式。
### 支持的语法
| 输入 | 效果 |
|------|------|
| \`**加粗**\` | **加粗** |
| \`*斜体*\` | *斜体* |
| \`~~删除线~~\` | ~~删除线~~ |
| \`# 标题\` | 一级标题 |
| \`## 标题\` | 二级标题 |
| \`### 标题\` | 三级标题 |
| \`- 列表\` | 无序列表 |
| \`1. 列表\` | 有序列表 |
| \`> 引用\` | 引用块 |
| \`---\` | 分割线 |
### AI 聊天中的 Markdown
AI 聊天侧栏中的回复支持完整的 Markdown 渲染,包括代码块、表格、链接等。
`,
},
{
id: 'shortcuts',
title: '⌨️ 快捷键',
content: `
## 键盘快捷键
### 编辑
| 快捷键 | 功能 |
|--------|------|
| Ctrl+Z | 撤销 |
| Ctrl+Y | 重做 |
| Ctrl+A | 全选 |
### 格式
| 快捷键 | 功能 |
|--------|------|
| Ctrl+B | 加粗 |
| Ctrl+I | 斜体 |
| Ctrl+U | 下划线 |
### AI
| 快捷键 | 功能 |
|--------|------|
| Ctrl+J | 打开/关闭内联 AI 面板 |
| Enter | 开始 AI 生成 |
| Esc | 关闭 AI 面板 / 取消生成 |
### Markdown 快捷输入
| 输入 | 触发 |
|------|------|
| \`# \` + 空格 | 一级标题 |
| \`## \` + 空格 | 二级标题 |
| \`- \` + 空格 | 无序列表 |
| \`1. \` + 空格 | 有序列表 |
| \`> \` + 空格 | 引用块 |
| \`---\` + 回车 | 分割线 |
`,
},
{
id: 'theme',
title: '🎭 主题 & 排版',
content: `
## 主题与偏好排版
### 双旗舰主题引擎
目前 Author 提供两套深度打磨的主题集,可在设定集 -> **偏好设置** 中无缝切换:
- 📜 **经典纸张 (Warm Classic)**: 采用高级护眼的复古暖灰色调,所有卡片呈现日记本般的实体拟物反馈。
- 🧊 **现代通透 (Modern Glass)**: 苹果 macOS 风格,纯白与冷灰基底,带有极其剔透的毛玻璃 (\`backdrop-filter\`) 层级架构,追求极致的干净。
*注:你可以随时点击侧栏右下角的 🌙 / ☀️ 切换对应主题下的深色/浅色模式。*
### 排版引擎调节
在顶部工具栏点击 **Aa▾** 按钮:
- **字号**:滑块无极调节 14px ~ 24px(默认 17px)
- **行距**:滑块无极调节 1.4 ~ 2.6(默认 1.9)
- **恢复默认**:一键重置排版参数
### 字体选择
| 字体 | 适合场景 |
|------|---------|
| 默认(宋体) | 正文写作 |
| 黑体 | 标题 |
| 楷体 | 古风文 |
| 仿宋 | 公文风格 |
| Serif | 英文衬线体 |
| Monospace | 等宽字体 |
### 排版建议
| 场景 | 推荐设置 |
|------|---------|
| 网文写作 | 字号 16-17px,行距 1.8-2.0 |
| 纯文学 | 字号 17-18px,行距 2.0-2.2 |
| 校对审稿 | 字号 14-15px,行距 1.6 |
`,
},
{
id: 'about',
title: 'ℹ️ 关于',
content: `
## 关于 Author
**Author** 是一款 AI 驱动的小说创作工具,旨在为网文作者和文学创作者提供专业、高效的写作体验。
### 核心特色
- 🤖 **AI 智能写作** — 内联续写 + 聊天讨论,双模式辅助创作
- 📖 **上下文感知** — AI 自动参考角色设定、世界观、前文内容
- 🎭 **设定集管理** — 树形结构管理角色、世界观、大纲、写作规则
- ✦ **Ghost Text** — 类似 Cursor 的幽灵文字预览,接受/拒绝一键操作
- 📄 **分页视图** — 类 Word/Google Docs 的白纸分页排版
- 🌙 **深色模式** — 护眼的暗色主题
- 💾 **本地优先** — 所有数据存储在本地,隐私安全
- 📦 **存档/读档** — 一键导出/导入完整项目
### 数据安全
- 所有创作内容存储在你的浏览器本地
- API Key 存储在本地浏览器中
- 支持一键导出全部数据
### ⚠️ 隐私须知
使用 AI 功能时,API Key 和文字内容会经过**部署者的服务器**转发给 AI 供应商。使用他人部署的实例时,体验后请及时销毁 Key,正式使用请自行 Fork 部署。
### 技术栈
Next.js + Tiptap 编辑器 + AI API(OpenAI 兼容 / Gemini)
### 开源项目
Author 是一个开源项目,采用 **AGPL-3.0** 协议。
🔗 **GitHub**: [github.com/YuanShiJiLoong/author](https://github.com/YuanShiJiLoong/author)
欢迎 Star ⭐、提 Issue、贡献代码!
`,
},
];
export default function HelpPanel({ open, onClose }) {
const [activeSection, setActiveSection] = useState('quickstart');
const { t } = useI18n();
const [updateChecking, setUpdateChecking] = useState(false);
const [updateResult, setUpdateResult] = useState(null); // { status: 'latest'|'available'|'error', current, latest, isSourceDeploy }
const [updating, setUpdating] = useState(false);
const [downloadProgress, setDownloadProgress] = useState(null); // { progress, downloaded, total }
const [updateDone, setUpdateDone] = useState(null); // { success, message, logs }
const [sourceProgress, setSourceProgress] = useState(null); // { step, total, label, status }
const isElectron = typeof window !== 'undefined' && window.electronAPI?.isElectron;
// 监听 Electron 下载进度
useEffect(() => {
if (isElectron && window.electronAPI?.onUpdateProgress) {
window.electronAPI.onUpdateProgress((data) => {
setDownloadProgress(data);
});
}
}, [isElectron]);
const checkForUpdates = async () => {
setUpdateChecking(true);
setUpdateResult(null);
setUpdateDone(null);
try {
const res = await fetch('/api/check-update', { cache: 'no-store' });
if (!res.ok) throw new Error('API error');
const data = await res.json();
if (data.hasUpdate && data.latest) {
setUpdateResult({ status: 'available', current: data.current, latest: data.latest, isSourceDeploy: data.isSourceDeploy });
} else {
setUpdateResult({ status: 'latest', current: data.current, latest: data.latest || data.current });
}
} catch {
setUpdateResult({ status: 'error' });
} finally {
setUpdateChecking(false);
}
};
// Electron 客户端:自动下载安装
const handleElectronUpdate = async () => {
setUpdating(true);
setUpdateDone(null);
setDownloadProgress({ progress: 0, downloaded: 0, total: 0 });
try {
const result = await window.electronAPI.downloadAndInstallUpdate();
if (!result.success) {
setUpdateDone({ success: false, message: t('update.updateFailed') + ': ' + (result.error || '') });
setDownloadProgress(null);
}
} catch (err) {
setUpdateDone({ success: false, message: t('update.updateFailed') + ': ' + err.message });
setDownloadProgress(null);
} finally {
setUpdating(false);
}
};
// 源码部署:SSE 流式更新
const handleSourceUpdate = async () => {
setUpdating(true);
setUpdateDone(null);
setSourceProgress(null);
try {
const res = await fetch('/api/update-source-stream', { method: 'POST' });
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n\n');
buffer = lines.pop() || '';
for (const block of lines) {
const dataLine = block.split('\n').find(l => l.startsWith('data: '));
if (!dataLine) continue;
const data = JSON.parse(dataLine.slice(6));
if (data.done) {
if (data.success) {
if (data.alreadyUpToDate) {
setUpdateDone({ success: true, message: t('update.alreadyLatest') });
} else {
setUpdateDone({ success: true, message: t('update.updateSuccess') });
}
} else {
setUpdateDone({ success: false, message: t('update.updateFailed') + ': ' + (data.error || '') });
}
setSourceProgress(null);
} else {
setSourceProgress(data);
}
}
}
} catch (err) {
setUpdateDone({ success: false, message: t('update.updateFailed') + ': ' + err.message });
setSourceProgress(null);
} finally {
setUpdating(false);
}
};
const handleUpdate = () => {
if (isElectron) {
handleElectronUpdate();
} else if (updateResult?.isSourceDeploy) {
handleSourceUpdate();
}
};
const canAutoUpdate = isElectron || updateResult?.isSourceDeploy;
if (!open) return null;
const currentSection = HELP_SECTIONS.find(s => s.id === activeSection);
return (
<div className="help-overlay" onClick={onClose}>
<div className="help-panel" onClick={e => e.stopPropagation()}>
{/* 顶栏 */}
<div className="help-header">
<h2>{t('help.title')}</h2>
<div style={{ display: 'flex', gap: '12px' }}>
<button
className="tour-btn ghost"
style={{ padding: '6px 12px', fontSize: '13px' }}
onClick={() => {
localStorage.removeItem('author-onboarding-done');
window.location.reload();
}}
>
{t('help.btnRetour')}
</button>
<button className="help-close-btn" onClick={onClose}>✕</button>
</div>
</div>
<div className="help-body">
{/* 左侧导航 */}
<nav className="help-nav">
{HELP_SECTIONS.map(section => (
<button
key={section.id}
className={`help-nav-item ${activeSection === section.id ? 'active' : ''}`}
onClick={() => setActiveSection(section.id)}
>
{section.title}
</button>
))}
</nav>
{/* 右侧内容 */}
<div className="help-content">
<div
className="help-markdown"
dangerouslySetInnerHTML={{ __html: renderSimpleMarkdown(currentSection?.content || '') }}
/>
{/* 关于页面 - 检查更新按钮 */}
{activeSection === 'about' && (
<div style={{ marginTop: 24, paddingTop: 20, borderTop: '1px solid var(--border-light)' }}>
<button
onClick={checkForUpdates}
disabled={updateChecking}
style={{
padding: '10px 24px',
fontSize: 14,
fontWeight: 600,
border: '1px solid var(--border-light)',
borderRadius: 8,
background: 'var(--bg-card)',
color: 'var(--text-primary)',
cursor: updateChecking ? 'wait' : 'pointer',
transition: 'all 0.2s ease',
opacity: updateChecking ? 0.7 : 1,
}}
>
{updateChecking ? t('update.checking') : t('update.checkForUpdates')}
</button>
</div>
)}
</div>
</div>
</div>
{/* 检查更新结果弹窗 */}
{updateResult && (
<div
style={{
position: 'fixed', inset: 0, zIndex: 10001,
display: 'flex', alignItems: 'center', justifyContent: 'center',
background: 'rgba(0,0,0,0.45)', backdropFilter: 'blur(4px)',
}}
onClick={(e) => { e.stopPropagation(); if (!updating) { setUpdateResult(null); setUpdateDone(null); } }}
>
<div
style={{
background: 'var(--bg-card)',
borderRadius: 16,
padding: '32px 36px',
minWidth: 340,
maxWidth: 480,
boxShadow: '0 24px 80px rgba(0,0,0,0.3)',
textAlign: 'center',
color: 'var(--text-primary)',
animation: 'fadeInScale 0.2s ease',
}}
onClick={(e) => e.stopPropagation()}
>
{/* 状态图标 */}
<div style={{ fontSize: 48, marginBottom: 16 }}>
{updateDone
? (updateDone.success ? '✅' : '❌')
: updating ? '⏳'
: updateResult.status === 'available' ? '🎉' : updateResult.status === 'latest' ? '✅' : '⚠️'
}
</div>
{/* 状态文字 */}
<div style={{ fontSize: 18, fontWeight: 700, marginBottom: 12 }}>
{updateDone
? updateDone.message
: updating
? t('update.updating')
: updateResult.status === 'available'
? t('update.updateAvailable').replace('{version}', `v${updateResult.latest}`)
: updateResult.status === 'latest'
? t('update.noUpdateAvailable')
: t('update.checkFailed')
}
</div>
{/* 版本信息 */}
{updateResult.current && !updateDone && !updating && (
<div style={{ fontSize: 13, opacity: 0.65, marginBottom: 20 }}>
{t('update.currentVersion')}: v{updateResult.current}
{updateResult.status === 'available' && (
<span> → v{updateResult.latest}</span>
)}
</div>
)}
{/* Electron 下载进度条 */}
{updating && downloadProgress && downloadProgress.total > 0 && (
<div style={{ margin: '16px 0' }}>
<div style={{
width: '100%', height: 8, background: 'var(--bg-secondary)',
borderRadius: 4, overflow: 'hidden',
}}>
<div style={{
width: `${downloadProgress.progress}%`, height: '100%',
background: 'var(--accent)', borderRadius: 4,
transition: 'width 0.3s ease',
}} />
</div>
<div style={{ fontSize: 12, opacity: 0.6, marginTop: 6 }}>
⬇️ {downloadProgress.progress}%
</div>
</div>
)}
{/* 源码更新进度条 */}
{updating && sourceProgress && (
<div style={{ margin: '16px 0' }}>
<div style={{ fontSize: 13, fontWeight: 600, marginBottom: 8, opacity: 0.8 }}>
{sourceProgress.label}
<span style={{ opacity: 0.5, marginLeft: 8 }}>
({sourceProgress.step}/{sourceProgress.total})
</span>
</div>
<div style={{
width: '100%', height: 8, background: 'var(--bg-secondary)',
borderRadius: 4, overflow: 'hidden',
}}>
<div style={{
width: `${(sourceProgress.step / sourceProgress.total) * 100}%`,
height: '100%',
background: sourceProgress.status === 'error' ? '#ef4444' : 'var(--accent)',
borderRadius: 4,
transition: 'width 0.5s ease',
}} />
</div>
</div>
)}
{/* 源码部署更新日志 */}
{updateDone?.logs && updateDone.logs.length > 0 && (
<div style={{
background: 'var(--bg-secondary)', padding: '10px 14px',
borderRadius: 8, fontSize: 11,
fontFamily: 'var(--font-mono, monospace)',
color: 'var(--text-secondary)',
maxHeight: 120, overflowY: 'auto',
lineHeight: 1.6, textAlign: 'left',
marginBottom: 16,
}}>
{updateDone.logs.map((l, i) => (
<div key={i}>{l.msg}</div>
))}
</div>
)}
{/* 操作按钮 */}
<div style={{ display: 'flex', gap: 12, justifyContent: 'center', marginTop: 8, flexWrap: 'wrap' }}>
{/* 有更新且未完成、未正在更新 */}
{updateResult.status === 'available' && !updateDone && !updating && (
<>
{canAutoUpdate && (
<button
onClick={handleUpdate}
style={{
padding: '8px 22px', fontSize: 14, fontWeight: 600,
borderRadius: 8,
background: 'var(--accent)',
color: '#fff', border: 'none',
cursor: 'pointer',
transition: 'opacity 0.15s',
}}
>
{t('update.updateNow')}
</button>
)}
{!canAutoUpdate && (
<>
<a
href="https://github.com/YuanShiJiLoong/author/releases/latest"
target="_blank"
rel="noopener noreferrer"
style={{
padding: '8px 22px', fontSize: 14, fontWeight: 600,
borderRadius: 8, textDecoration: 'none',
background: 'var(--accent)',
color: '#fff', border: 'none', cursor: 'pointer',
transition: 'opacity 0.15s',
}}
>
{t('update.downloadClient')}
</a>
<a
href="https://github.com/YuanShiJiLoong/author"
target="_blank"
rel="noopener noreferrer"
style={{
padding: '8px 22px', fontSize: 14, fontWeight: 600,
borderRadius: 8, textDecoration: 'none',
border: '1px solid var(--border-light)',
background: 'transparent',
color: 'var(--text-primary)',
cursor: 'pointer',
transition: 'opacity 0.15s',
}}
>
{t('update.viewSource')}
</a>
</>
)}
</>
)}
{/* 更新完成后:刷新按钮 */}
{updateDone?.success && !updateDone.message.includes(t('update.alreadyLatest')) && (
<button
onClick={() => window.location.reload()}
style={{
padding: '8px 22px', fontSize: 14, fontWeight: 600,
borderRadius: 8,
background: 'var(--accent)',
color: '#fff', border: 'none', cursor: 'pointer',
transition: 'opacity 0.15s',
}}
>
{t('update.refreshNow')}
</button>
)}
{/* 关闭按钮(更新中时不显示) */}
{!updating && (
<button
onClick={() => { setUpdateResult(null); setUpdateDone(null); }}
style={{
padding: '8px 22px', fontSize: 14, fontWeight: 600,
borderRadius: 8,
border: '1px solid var(--border-light)',
background: 'transparent',
color: 'var(--text-primary)',
cursor: 'pointer',
transition: 'opacity 0.15s',
}}
>
{t('update.close')}
</button>
)}
</div>
</div>
</div>
)}
</div>
);
}
// 简单的 Markdown → HTML 转换(仅用于帮助文档静态内容)
function renderSimpleMarkdown(md) {
let html = md.trim();
// 表格
html = html.replace(/^(\|.+\|)\n(\|[-: |]+\|)\n((?:\|.+\|\n?)*)/gm, (_, header, sep, body) => {
const ths = header.split('|').filter(c => c.trim()).map(c => `<th>${c.trim()}</th>`).join('');
const aligns = sep.split('|').filter(c => c.trim()).map(c => {
if (c.trim().startsWith(':') && c.trim().endsWith(':')) return 'center';
if (c.trim().endsWith(':')) return 'right';
return 'left';
});
const rows = body.trim().split('\n').map(row => {
const tds = row.split('|').filter(c => c.trim()).map((c, i) =>
`<td style="text-align:${aligns[i] || 'left'}">${c.trim()}</td>`
).join('');
return `<tr>${tds}</tr>`;
}).join('');
return `<table><thead><tr>${ths}</tr></thead><tbody>${rows}</tbody></table>`;
});
// Headers
html = html.replace(/^### (.+)$/gm, '<h3>$1</h3>');
html = html.replace(/^## (.+)$/gm, '<h2>$1</h2>');
// Bold, italic, strikethrough, code
html = html.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
html = html.replace(/\*(.+?)\*/g, '<em>$1</em>');
html = html.replace(/~~(.+?)~~/g, '<s>$1</s>');
html = html.replace(/`([^`]+)`/g, '<code>$1</code>');
// Blockquote
html = html.replace(/^> (.+)$/gm, '<blockquote>$1</blockquote>');
// Links [text](url)
html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank" rel="noopener noreferrer">$1</a>');
// List items
html = html.replace(/^- (.+)$/gm, '<li>$1</li>');
// Paragraphs (lines that don't start with < )
html = html.replace(/^(?!<[a-z/]|$)(.+)$/gm, '<p>$1</p>');
// Clean up empty paragraphs
html = html.replace(/<p>\s*<\/p>/g, '');
return html;
}