Procházet zdrojové kódy

feat: 自定义 Tab 统一管理,支持网页/本地文件编辑预览与备份

- 删除笔记/博客/聊天/记录/控制/硬件 6 个固定内置 Tab,统一通过「自定义 Tab 页面」添加
- 自定义 Tab 支持双类型:http/https 内嵌网页,本地路径按 Markdown 编辑/预览/保存
- Tab 显示顺序支持内置与自定义名称排序,新增自定义 Tab 名称可参与调整
- 自定义 Tab 增加备注字段(名称|地址|备注),备注显示为 Tab 说明
- 文件类 Tab 新增备份按钮,按时间戳备份到可配置的备份目录
- 移除待办/备份目录硬编码默认路径,空值强制进入设置配置
- 保存/备份/刷新增加状态反馈提示
- AppVersion 0.1.0 → 0.1.2
soease před 1 týdnem
rodič
revize
26a96744ab

+ 27 - 59
frontend/bindings/TengTuToolbox/models.js

@@ -103,38 +103,6 @@ export class AppSettings {
              */
             this["hotkey"] = "";
         }
-        if (!("notesUrl" in $$source)) {
-            /**
-             * 笔记页地址
-             * @member
-             * @type {string}
-             */
-            this["notesUrl"] = "";
-        }
-        if (!("controlUrl" in $$source)) {
-            /**
-             * 控制页地址
-             * @member
-             * @type {string}
-             */
-            this["controlUrl"] = "";
-        }
-        if (!("blogUrl" in $$source)) {
-            /**
-             * 博客页地址
-             * @member
-             * @type {string}
-             */
-            this["blogUrl"] = "";
-        }
-        if (!("chatUrl" in $$source)) {
-            /**
-             * 聊天页地址
-             * @member
-             * @type {string}
-             */
-            this["chatUrl"] = "";
-        }
         if (!("tabOrder" in $$source)) {
             /**
              * Tab 显示顺序(tabId 列表,未列出的追加到末尾)
@@ -199,14 +167,6 @@ export class AppSettings {
              */
             this["apiAddr"] = "";
         }
-        if (!("recordPath" in $$source)) {
-            /**
-             * 记录文件路径
-             * @member
-             * @type {string}
-             */
-            this["recordPath"] = "";
-        }
         if (!("todoPath" in $$source)) {
             /**
              * 待办(TODO)文件路径
@@ -215,13 +175,13 @@ export class AppSettings {
              */
             this["todoPath"] = "";
         }
-        if (!("hardwareUrl" in $$source)) {
+        if (!("backupDir" in $$source)) {
             /**
-             * 硬件页地址
+             * 文本文件备份目录(文件类自定义 Tab「备份」按钮使用)
              * @member
              * @type {string}
              */
-            this["hardwareUrl"] = "";
+            this["backupDir"] = "";
         }
         if (!("stockTicker" in $$source)) {
             /**
@@ -321,38 +281,38 @@ export class AppSettings {
      * @returns {AppSettings}
      */
     static createFrom($$source = {}) {
-        const $$createField8_0 = $$createType0;
-        const $$createField9_0 = $$createType0;
-        const $$createField10_0 = $$createType2;
-        const $$createField13_0 = $$createType4;
-        const $$createField14_0 = $$createType6;
-        const $$createField19_0 = $$createType7;
-        const $$createField20_0 = $$createType7;
-        const $$createField21_0 = $$createType8;
+        const $$createField4_0 = $$createType0;
+        const $$createField5_0 = $$createType0;
+        const $$createField6_0 = $$createType2;
+        const $$createField9_0 = $$createType4;
+        const $$createField10_0 = $$createType6;
+        const $$createField14_0 = $$createType7;
+        const $$createField15_0 = $$createType7;
+        const $$createField16_0 = $$createType8;
         let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source;
         if ("tabOrder" in $$parsedSource) {
-            $$parsedSource["tabOrder"] = $$createField8_0($$parsedSource["tabOrder"]);
+            $$parsedSource["tabOrder"] = $$createField4_0($$parsedSource["tabOrder"]);
         }
         if ("stockCodes" in $$parsedSource) {
-            $$parsedSource["stockCodes"] = $$createField9_0($$parsedSource["stockCodes"]);
+            $$parsedSource["stockCodes"] = $$createField5_0($$parsedSource["stockCodes"]);
         }
         if ("links" in $$parsedSource) {
-            $$parsedSource["links"] = $$createField10_0($$parsedSource["links"]);
+            $$parsedSource["links"] = $$createField6_0($$parsedSource["links"]);
         }
         if ("githubRepos" in $$parsedSource) {
-            $$parsedSource["githubRepos"] = $$createField13_0($$parsedSource["githubRepos"]);
+            $$parsedSource["githubRepos"] = $$createField9_0($$parsedSource["githubRepos"]);
         }
         if ("customTabs" in $$parsedSource) {
-            $$parsedSource["customTabs"] = $$createField14_0($$parsedSource["customTabs"]);
+            $$parsedSource["customTabs"] = $$createField10_0($$parsedSource["customTabs"]);
         }
         if ("stockTicker" in $$parsedSource) {
-            $$parsedSource["stockTicker"] = $$createField19_0($$parsedSource["stockTicker"]);
+            $$parsedSource["stockTicker"] = $$createField14_0($$parsedSource["stockTicker"]);
         }
         if ("newsTicker" in $$parsedSource) {
-            $$parsedSource["newsTicker"] = $$createField20_0($$parsedSource["newsTicker"]);
+            $$parsedSource["newsTicker"] = $$createField15_0($$parsedSource["newsTicker"]);
         }
         if ("poem" in $$parsedSource) {
-            $$parsedSource["poem"] = $$createField21_0($$parsedSource["poem"]);
+            $$parsedSource["poem"] = $$createField16_0($$parsedSource["poem"]);
         }
         return new AppSettings(/** @type {Partial<AppSettings>} */($$parsedSource));
     }
@@ -487,6 +447,14 @@ export class CustomTab {
              */
             this["url"] = "";
         }
+        if (!("desc" in $$source)) {
+            /**
+             * 备注/提示信息(可空),Tab 说明文字优先显示
+             * @member
+             * @type {string}
+             */
+            this["desc"] = "";
+        }
 
         Object.assign(this, $$source);
     }

+ 22 - 7
frontend/bindings/TengTuToolbox/settingsservice.js

@@ -25,6 +25,17 @@ export function AppVersion() {
     return $Call.ByID(609040257);
 }
 
+/**
+ * BackupFile 把指定文本文件按当前时间备份到配置的备份目录。
+ * 备份文件名带时间戳:<原名>_YYYYMMDD_HHMMSS<扩展名>;目录不存在时自动创建。
+ * 返回备份后的完整路径,供前端提示。
+ * @param {string} path
+ * @returns {$CancellablePromise<string>}
+ */
+export function BackupFile(path) {
+    return $Call.ByID(122452644, path);
+}
+
 /**
  * CaptureScreen 截取屏幕并返回 PNG 数据 URI,供「主机」页预览。
  * display 为显示器索引,-1 表示全部屏幕。
@@ -225,11 +236,13 @@ export function PickWebDir() {
 }
 
 /**
- * ReadRecord 读取记录文件内容;文件不存在时返回空内容
+ * ReadTextFile 读取任意文本文件内容(供「自定义 Tab」文件类型页面使用)。
+ * 文件不存在时返回空内容;其他错误原样返回。
+ * @param {string} path
  * @returns {$CancellablePromise<string>}
  */
-export function ReadRecord() {
-    return $Call.ByID(92427381);
+export function ReadTextFile(path) {
+    return $Call.ByID(3179291801, path);
 }
 
 /**
@@ -241,7 +254,7 @@ export function ReadTodo() {
 }
 
 /**
- * RenderMarkdown 把 Markdown 文本渲染为安全 HTML 片段(用于「记录」页预览)。
+ * RenderMarkdown 把 Markdown 文本渲染为安全 HTML 片段(供文件类自定义 Tab 展示)。
  * 使用 gomarkdown 渲染,并启用 CommonMark 扩展(表格、任务列表、围栏代码等)。
  * @param {string} md
  * @returns {$CancellablePromise<string>}
@@ -392,12 +405,14 @@ export function WebServiceStatus() {
 }
 
 /**
- * WriteRecord 把记录内容写回记录文件(目录不存在时自动创建)
+ * WriteTextFile 把文本内容写回指定文件(目录不存在时自动创建)。
+ * 供文件类自定义 Tab 的「保存」使用。
+ * @param {string} path
  * @param {string} content
  * @returns {$CancellablePromise<void>}
  */
-export function WriteRecord(content) {
-    return $Call.ByID(2400471626, content);
+export function WriteTextFile(path, content) {
+    return $Call.ByID(1781087370, path, content);
 }
 
 /**

+ 14 - 109
frontend/index.html

@@ -14,19 +14,13 @@
     <div class="tab-container">
         <!-- 标签栏(兼作标题栏):左侧 tab,右侧窗口关闭按钮 -->
         <div class="tab-buttons">
-            <button class="tab-btn active" data-tab="notes">笔记</button>
-            <button class="tab-btn" data-tab="control">控制</button>
-            <button class="tab-btn" data-tab="blog">博客</button>
-            <button class="tab-btn" data-tab="links">网址</button>
+            <button class="tab-btn active" data-tab="links">网址</button>
             <button class="tab-btn" data-tab="news">新闻</button>
             <button class="tab-btn" data-tab="github">Github</button>
             <button class="tab-btn" data-tab="stock">股市</button>
-            <button class="tab-btn" data-tab="record">记录</button>
             <button class="tab-btn" data-tab="todo">待办</button>
-            <button class="tab-btn" data-tab="hardware">硬件</button>
             <button class="tab-btn" data-tab="tools">工具</button>
             <button class="tab-btn" data-tab="host">服务</button>
-            <button class="tab-btn" data-tab="chat">聊天</button>
             <button class="tab-btn" data-tab="settings">设置</button>
             <div class="win-spacer"></div>
             <button class="win-close" id="win-close" title="关闭到托盘">×</button>
@@ -34,23 +28,8 @@
 
         <!-- 内容面板 -->
         <div class="tab-content">
-            <!-- 笔记:内嵌网页,地址可在设置中修改 -->
-            <div class="tab-panel panel-embed active" id="tab-notes">
-                <iframe id="frame-notes" src="https://m.scwy.net" title="笔记" loading="lazy"></iframe>
-            </div>
-
-            <!-- 控制:内嵌网页,地址可在设置中修改 -->
-            <div class="tab-panel panel-embed" id="tab-control">
-                <iframe id="frame-control" src="https://a7.scwy.net/4191" title="控制" loading="lazy"></iframe>
-            </div>
-
-            <!-- 博客:内嵌网页,地址可在设置中修改 -->
-            <div class="tab-panel panel-embed" id="tab-blog">
-                <iframe id="frame-blog" src="https://i.scwy.net" title="博客" loading="lazy"></iframe>
-            </div>
-
             <!-- 常用网址:设置中配置,点击卡片在浏览器打开 -->
-            <div class="tab-panel" id="tab-links">
+            <div class="tab-panel active" id="tab-links">
                 <div class="card-grid" id="links-grid"></div>
             </div>
 
@@ -124,25 +103,6 @@
                 </div>
             </div>
 
-            <!-- 记录:支持 Markdown 源码编辑与实时预览切换 -->
-            <div class="tab-panel panel-record" id="tab-record">
-                <div class="panel-head">
-                    <div>
-                        <div class="record-path-desc" id="record-path-desc"></div>
-                    </div>
-                    <div class="record-head-actions">
-                        <div class="record-mode" id="record-mode" role="group">
-                            <button class="record-mode-btn is-active" id="record-mode-edit" type="button">编辑</button>
-                            <button class="record-mode-btn" id="record-mode-preview" type="button">预览</button>
-                        </div>
-                        <button class="btn-ghost" id="record-save">保存</button>
-                    </div>
-                </div>
-                <textarea class="record-editor" id="record-editor" placeholder="在这里记录零碎信息…(支持 Markdown,可切换预览查看效果)" spellcheck="false"></textarea>
-                <div class="record-preview markdown-body" id="record-preview"></div>
-                <div class="record-status" id="record-status"></div>
-            </div>
-
             <!-- 待办:每行记录一个待完成任务,可勾选完成 / 编辑 / 删除 -->
             <div class="tab-panel panel-todo" id="tab-todo">
                 <div class="panel-head">
@@ -156,11 +116,6 @@
                 <div class="todo-status" id="todo-status"></div>
             </div>
 
-            <!-- 硬件:内嵌硬件监控页,地址可在设置中修改 -->
-            <div class="tab-panel panel-embed" id="tab-hardware">
-                <iframe id="frame-hardware" src="http://192.168.100.190/" title="硬件" loading="lazy"></iframe>
-            </div>
-
             <!-- 工具:本地开发/运维辅助工具集 -->
             <div class="tab-panel" id="tab-tools">
                 <!-- 工具 1:Web 服务——选择目录,开启本地浏览服务 -->
@@ -348,11 +303,6 @@
                 <pre class="log-view" id="log-view">暂无日志</pre>
             </div>
 
-            <!-- 聊天:内嵌网页,地址可在设置中修改 -->
-            <div class="tab-panel panel-embed" id="tab-chat">
-                <iframe id="frame-chat" src="https://c.scwy.net" title="聊天" loading="lazy"></iframe>
-            </div>
-
             <!-- 设置 -->
             <div class="tab-panel" id="tab-settings">
                 <h2>设置</h2>
@@ -416,67 +366,22 @@
                         <input class="input hotkey-input" id="hotkey" readonly placeholder="按下组合键">
                     </div>
 
-                    <!-- 笔记页地址 -->
-                    <div class="setting-row" data-tab-group="notes">
-                        <div class="setting-info">
-                            <div class="setting-title">笔记页地址</div>
-                            <div class="setting-hint">「笔记」标签内嵌的网页</div>
-                        </div>
-                        <input class="input" id="notes-url" type="text" placeholder="https://m.scwy.net">
-                    </div>
-
-                    <!-- 控制页地址 -->
-                    <div class="setting-row" data-tab-group="control">
-                        <div class="setting-info">
-                            <div class="setting-title">控制页地址</div>
-                            <div class="setting-hint">「控制」标签内嵌的网页</div>
-                        </div>
-                        <input class="input" id="control-url" type="text" placeholder="https://a7.scwy.net/4191">
-                    </div>
-
-                    <!-- 博客页地址 -->
-                    <div class="setting-row" data-tab-group="blog">
-                        <div class="setting-info">
-                            <div class="setting-title">博客页地址</div>
-                            <div class="setting-hint">「博客」标签内嵌的网页</div>
-                        </div>
-                        <input class="input" id="blog-url" type="text" placeholder="https://i.scwy.net">
-                    </div>
-
-                    <!-- 聊天页地址 -->
-                    <div class="setting-row" data-tab-group="chat">
-                        <div class="setting-info">
-                            <div class="setting-title">聊天页地址</div>
-                            <div class="setting-hint">「聊天」标签内嵌的网页</div>
-                        </div>
-                        <input class="input" id="chat-url" type="text" placeholder="https://c.scwy.net">
-                    </div>
-
                     <!-- Tab 显示顺序 -->
                     <div class="setting-row" data-tab-group="settings">
                         <div class="setting-info">
                             <div class="setting-title">Tab 显示顺序</div>
                             <div class="setting-hint">用逗号分隔 Tab 名称,从左到右依次显示;未列出的追加到末尾</div>
                         </div>
-                        <input class="input" id="tab-order" type="text" placeholder="笔记, 控制, 博客, 聊天, 网址, 新闻">
+                        <input class="input" id="tab-order" type="text" placeholder="网址, 新闻, Github, 股市, 待办, 工具, 服务">
                     </div>
 
                     <!-- 自定义 Tab 页面 -->
                     <div class="setting-row setting-row-col" data-tab-group="settings">
                         <div class="setting-info">
                             <div class="setting-title">自定义 Tab 页面</div>
-                            <div class="setting-hint">每行一条,格式:页面名称|网址;保存后新增 Tab,名称也用于 Tab 显示顺序</div>
+                            <div class="setting-hint">每行一条,格式:页面名称|地址|备注(备注可空)。地址以 http:// 或 https:// 开头则内嵌网页;否则按本地文件路径读取,Markdown 自动渲染。名称用于 Tab 显示顺序,备注显示为 Tab 说明</div>
                         </div>
-                        <textarea class="input textarea" id="custom-tabs" rows="3" placeholder="示例|https://example.com"></textarea>
-                    </div>
-
-                    <!-- 硬件页地址 -->
-                    <div class="setting-row" data-tab-group="hardware">
-                        <div class="setting-info">
-                            <div class="setting-title">硬件页地址</div>
-                            <div class="setting-hint">「硬件」标签内嵌的网页</div>
-                        </div>
-                        <input class="input" id="hardware-url" type="text" placeholder="http://192.168.100.190/">
+                        <textarea class="input textarea" id="custom-tabs" rows="3" placeholder="文档|D:\My\notes\readme.md|个人笔记&#10;示例|https://example.com"></textarea>
                     </div>
 
                     <!-- 股市代码 -->
@@ -607,15 +512,6 @@
                         <input class="input" id="api-addr" type="text" placeholder="127.0.0.1:17890">
                     </div>
 
-                    <!-- 记录文件路径 -->
-                    <div class="setting-row" data-tab-group="record">
-                        <div class="setting-info">
-                            <div class="setting-title">记录文件路径</div>
-                            <div class="setting-hint">「记录」标签编辑的文本文件</div>
-                        </div>
-                        <input class="input" id="record-path" type="text" placeholder="D:\My\腾图\记录.txt">
-                    </div>
-
                     <!-- 待办文件路径 -->
                     <div class="setting-row" data-tab-group="todo">
                         <div class="setting-info">
@@ -625,6 +521,15 @@
                         <input class="input" id="todo-path" type="text" placeholder="D:\My\腾图\待办.txt">
                     </div>
 
+                    <!-- 文本备份目录 -->
+                    <div class="setting-row">
+                        <div class="setting-info">
+                            <div class="setting-title">文本备份目录</div>
+                            <div class="setting-hint">文件类自定义 Tab 的「备份」按钮按当前时间备份文件到该目录,文件名带时间戳</div>
+                        </div>
+                        <input class="input" id="backup-dir" type="text" placeholder="D:\My\腾图\备份">
+                    </div>
+
                     <!-- 股票滚动条 -->
                     <div class="setting-row" data-tab-group="stock">
                         <div class="setting-info">

+ 90 - 91
frontend/public/style.css

@@ -254,8 +254,9 @@ body::before {
     background: #fff;                       /* iframe 未加载完成时显示白底 */
 }
 
-/* 自定义 Tab 面板:纵向布局,顶部常驻工具条 + 下方 iframe */
+/* 自定义 Tab 面板:纵向布局,顶部常驻工具条 + 下方 iframe / 编辑器 */
 .panel-custom.active {
+    display: flex;              /* 覆盖 .tab-panel 的 display:block,使 flex 子项(编辑器/预览)能占满剩余高度 */
     flex-direction: column;
 }
 .custom-tab-bar {
@@ -272,6 +273,93 @@ body::before {
     font-size: calc(14px + var(--font-delta));
 }
 
+/* 文件类自定义 Tab 工具条状态提示(左对齐,编辑模式下也可见) */
+.custom-file-status {
+    margin-right: auto;
+    font-size: calc(12.5px + var(--font-delta));
+    color: var(--ink-dim);
+    white-space: nowrap;
+    overflow: hidden;
+    text-overflow: ellipsis;
+    max-width: 55%;
+}
+.custom-file-status.is-ok {
+    color: #3ddc84;
+}
+.custom-file-status.is-err {
+    color: #ff5a5a;
+}
+
+/* 文件类自定义 Tab:占满剩余高度,Markdown 内容可滚动 */
+.panel-custom-file {
+    overflow: hidden; /* 内容区自行滚动 */
+}
+.panel-custom-file.active {
+    flex-direction: column;
+}
+.custom-file-editor {
+    flex: 1;
+    min-height: 0;
+    padding: 14px 16px;
+    border: 1px solid var(--glass-border);
+    border-radius: var(--radius);
+    background: var(--glass);
+    color: var(--ink);
+    font-size: calc(15.5px + var(--font-delta));
+    font-family: "Consolas", "Courier New", monospace;
+    line-height: 1.7;
+    resize: none;                           /* 尺寸由布局控制 */
+    outline: none;
+    -webkit-user-select: text;
+    user-select: text;
+}
+.custom-file-editor:focus {
+    border-color: rgba(255, 77, 77, 0.55);
+}
+.custom-file-mode {
+    display: inline-flex;
+    padding: 2px;
+    border: 1px solid var(--glass-border);
+    border-radius: 8px;
+    background: var(--glass);
+}
+.custom-file-mode-btn {
+    padding: 3px 14px;
+    border: none;
+    border-radius: 6px;
+    background: transparent;
+    color: var(--muted);
+    font-size: calc(13.5px + var(--font-delta));
+    line-height: 1.4;
+    cursor: pointer;
+    transition: background 0.15s ease, color 0.15s ease;
+}
+.custom-file-mode-btn:hover {
+    color: var(--ink);
+}
+.custom-file-mode-btn.is-active {
+    background: var(--accent);
+    color: #fff;
+}
+.custom-file-md {
+    flex: 1;
+    min-height: 0;
+    overflow-y: auto;
+    padding: 16px 20px;
+    -webkit-user-select: text;
+    user-select: text;
+}
+.custom-file-error {
+    color: #ff5a5a;
+    padding: 8px 0;
+}
+
+.custom-file-ok {
+    color: #3ddc84;
+    padding: 8px 0;
+    word-break: break-all;
+}
+
 @keyframes tabFadeIn {
     from { opacity: 0; transform: translateY(8px); }
     to   { opacity: 1; transform: translateY(0); }
@@ -1467,68 +1555,6 @@ a.ver-muted:hover {
     font-variant-numeric: tabular-nums;
 }
 
-/* ---------- 记录页 ---------- */
-.panel-record {
-    display: none;                          /* 未激活时隐藏 */
-}
-.panel-record.active {
-    display: flex;                          /* 激活时纵向布局:编辑框撑满 */
-    flex-direction: column;
-    gap: 10px;
-}
-
-.record-editor {
-    flex: 1;
-    min-height: 200px;
-    padding: 14px 16px;
-    border: 1px solid var(--glass-border);
-    border-radius: var(--radius);
-    background: var(--glass);
-    color: var(--ink);
-    font-size: calc(15.5px + var(--font-delta));
-    font-family: "Consolas", "Courier New", monospace;
-    line-height: 1.7;
-    resize: none;                           /* 尺寸由布局控制 */
-    outline: none;
-    -webkit-user-select: text;
-    user-select: text;
-}
-.record-editor:focus {
-    border-color: rgba(255, 77, 77, 0.55);
-}
-
-/* 记录页头部右侧:编辑/预览切换 + 保存按钮 */
-.record-head-actions {
-    display: flex;
-    align-items: center;
-    gap: 10px;
-}
-.record-mode {
-    display: inline-flex;
-    padding: 2px;
-    border: 1px solid var(--glass-border);
-    border-radius: 8px;
-    background: var(--glass);
-}
-.record-mode-btn {
-    padding: 3px 14px;
-    border: none;
-    border-radius: 6px;
-    background: transparent;
-    color: var(--muted);
-    font-size: calc(13.5px + var(--font-delta));
-    line-height: 1.4;
-    cursor: pointer;
-    transition: background 0.15s ease, color 0.15s ease;
-}
-.record-mode-btn:hover {
-    color: var(--ink);
-}
-.record-mode-btn.is-active {
-    background: var(--accent);
-    color: #fff;
-}
-
 /* ntfy 聊天室乒乓按钮:监听中突出显示 */
 #chat-toggle.is-active {
     background: var(--accent);
@@ -1543,21 +1569,7 @@ a.ver-muted:hover {
     border-color: var(--accent);
 }
 
-/* Markdown 预览容器(记录页「预览」模式) */
-.record-preview {
-    display: none;                          /* 默认隐藏:编辑状态下不显示 markdown 渲染结果 */
-    flex: 1;
-    min-height: 200px;
-    padding: 14px 16px;
-    border: 1px solid var(--glass-border);
-    border-radius: var(--radius);
-    background: var(--glass);
-    overflow-y: auto;
-    -webkit-user-select: text;
-    user-select: text;
-}
-
-/* Markdown 渲染通用样式(记录预览 / Web 服务等) */
+/* Markdown 渲染通用样式(文件类自定义 Tab / Web 服务等) */
 .markdown-body {
     font-size: calc(15px + var(--font-delta));
     line-height: 1.75;
@@ -1621,19 +1633,6 @@ a.ver-muted:hover {
 .markdown-body img { max-width: 100%; border-radius: 6px; }
 .markdown-body del { opacity: 0.7; }
 
-.record-status {
-    min-height: 16px;
-    color: var(--muted);
-    font-size: calc(14px + var(--font-delta));
-}
-
-/* 记录文件路径说明(面板标题下方) */
-.record-path-desc {
-    margin-top: 2px;
-    color: var(--muted);
-    font-size: calc(13.5px + var(--font-delta));
-}
-
 /* ---------- 待办页 ---------- */
 .panel-todo {
     display: none;                          /* 未激活时隐藏 */

+ 227 - 156
frontend/src/main.js

@@ -10,21 +10,16 @@ const FALLBACK_SETTINGS = {
     autostart: true,
     alwaysOnTop: false,
     hotkey: 'CmdOrCtrl+Shift+G',
-    notesUrl: 'https://m.scwy.net',
-    controlUrl: 'https://a7.scwy.net/4191',
-    blogUrl: 'https://i.scwy.net',
-    chatUrl: 'https://c.scwy.net',
-    tabOrder: ['notes', 'control', 'blog', 'chat', 'links', 'news', 'github', 'stock', 'record', 'hardware', 'host', 'settings'],
+    tabOrder: ['links', 'news', 'github', 'stock', 'todo', 'tools', 'host', 'settings'],
     stockCodes: ['sh000001', 'sh600519', 'sz000001'],
     links: [{name: '煎蛋', url: 'https://jandan.net/'}],
     newsKeywords: '',
     biliCookie: '',                    // B站登录 cookie(含 SESSDATA),用于抓取「关注的人最新动态」
     githubRepos: [{url: 'https://github.com/wailsapp/wails', desc: 'Wails 框架'}],
-    customTabs: [],                   // 用户自定义 Tab 页面(名字 + 网址)
+    customTabs: [],                   // 用户自定义 Tab 页面(名字 + 地址;http/https 为网页,其余为本地文件)
     apiAddr: '127.0.0.1:17890',
-    recordPath: 'D:\\My\\腾图\\记录.txt',
-    todoPath: 'D:\\My\\腾图\\待办.txt',
-    hardwareUrl: 'http://192.168.100.190/',
+    todoPath: '', // 待办路径:无默认值,空值提示用户到设置页配置
+    backupDir: '', // 备份目录:无默认值,空值提示用户到设置页配置
     stockUpColor: '#ff5a5a',      // 股票上涨颜色(红涨)
     stockDownColor: '#4d8dff',    // 股票下跌颜色(蓝跌)
     stockRefreshSecs: 30,         // 股市自动刷新间隔(秒)
@@ -61,24 +56,17 @@ const poemReadBtn = document.getElementById('poem-read');          // 朗读按
 
 // Tab 名称映射:id -> 显示名(用于顺序编辑与 footer 注释)
 const TAB_NAMES = {
-    notes: '笔记', control: '控制', blog: '博客', chat: '聊天',
     links: '网址', news: '新闻', github: 'Github', stock: '股市',
-    record: '记录', todo: '待办', hardware: '硬件', tools: '工具', host: '服务', settings: '设置',
+    todo: '待办', tools: '工具', host: '服务', settings: '设置',
 };
 
 // Tab 注释映射:id -> 在 footer 显示的说明文字(切换时显示,不占页面空间)
 const TAB_DESCS = {
-    notes: '记录灵感与备忘,地址可在设置中修改。',
-    control: '远程控制中心,地址可在设置中修改。',
-    blog: '个人博客,地址可在设置中修改。',
-    chat: '聊天页面,地址可在设置中修改。',
     links: '常用网址卡片,点击在浏览器打开,列表在设置中维护。',
     news: '聚合煎蛋与头条热榜,命中关键词置顶高亮。',
     github: '监听仓库提交与 release,本地版本低于最新版提示更新。',
     stock: '自选股实时行情,点击行查看分时 / 日K。',
-    record: '记录零碎信息,文件路径可在设置中修改。',
     todo: '每行一个待办任务,可勾选完成、编辑、删除,保存后写入本地文件。',
-    hardware: '硬件监控页面,地址可在设置中修改。',
     tools: '开发/运维辅助工具:Web 服务(选目录开启浏览)、翻译(中英互译)、二维码生成。',
     host: '内置 API 服务状态、电源命令与 Gin 日志。',
     settings: '修改后点击保存立即生效,焦点离开时自动保存。',
@@ -93,11 +81,17 @@ function getTabName(id) {
     return c ? c.name : id;
 }
 
-// 取 Tab 说明:优先内置 TAB_DESCS,自定义 Tab 用页面地址
+// 取 Tab 说明:优先内置 TAB_DESCS,自定义 Tab 用备注(desc),无备注时回退页面地址
 function getTabDesc(id) {
     if (TAB_DESCS[id]) return TAB_DESCS[id];
     const c = customTabs.find(t => t.id === id);
-    return c ? `自定义页面:${c.url}` : '';
+    if (!c) return '';
+    return c.desc ? `自定义页面:${c.desc}` : `自定义页面:${c.url}`;
+}
+
+// 判断地址是否为网页(http/https 开头),否则按本地文件处理
+function isWebUrl(url) {
+    return /^https?:\/\//i.test(url || '');
 }
 
 // 删除旧的自定义 Tab 按钮与面板,再按 settings.customTabs 重建(插入到「设置」Tab 之前)
@@ -111,7 +105,7 @@ async function applyCustomTabs(tabs) {
     // 重建自定义 Tab(按钮插入到设置按钮之前,面板插入到设置面板之前)
     const settingsBtn = tabBar.querySelector('.tab-btn[data-tab="settings"]');
     const settingsPanel = content.querySelector('#tab-settings');
-    customTabs = (tabs || []).map((t, i) => ({ id: `custom-${i}`, name: t.name, url: t.url }));
+    customTabs = (tabs || []).map((t, i) => ({ id: `custom-${i}`, name: t.name, url: t.url, desc: t.desc || '', isWeb: isWebUrl(t.url) }));
     for (const t of customTabs) {
         const btn = document.createElement('button');
         btn.className = 'tab-btn';
@@ -122,28 +116,100 @@ async function applyCustomTabs(tabs) {
         btn.addEventListener('click', () => switchTab(t.id)); // 点击切换
 
         const panel = document.createElement('div');
-        panel.className = 'tab-panel panel-embed panel-custom';
+        panel.className = t.isWeb ? 'tab-panel panel-embed panel-custom' : 'tab-panel panel-custom panel-custom-file';
         panel.id = `tab-${t.id}`;
         panel.dataset.custom = '1';
-        // 常驻工具条:在新窗口打开(内网自签名 / 禁止嵌入的站点可兜底打开)
-        const bar = document.createElement('div');
-        bar.className = 'custom-tab-bar';
-        const openBtn = document.createElement('button');
-        openBtn.className = 'btn-ghost';
-        openBtn.textContent = '在新窗口打开';
-        openBtn.title = t.url;
-        openBtn.addEventListener('click', (e) => {
-            e.stopPropagation(); // 不触发 Tab 切换
-            window.open(t.url, '_blank'); // 用系统浏览器打开(浏览器自会提示证书)
-        });
-        bar.appendChild(openBtn);
-        const frame = document.createElement('iframe');
-        frame.id = `frame-${t.id}`;
-        frame.src = embedSrc(t.url); // 直接使用原地址(已取消代理)
-        frame.title = t.name;
-        frame.setAttribute('loading', 'lazy');
-        panel.appendChild(bar);
-        panel.appendChild(frame);
+        if (t.isWeb) {
+            // 网页类型:内嵌 iframe,不显示工具条(已取消「在新窗口打开」)
+            const frame = document.createElement('iframe');
+            frame.id = `frame-${t.id}`;
+            frame.src = embedSrc(t.url); // 直接使用原地址(已取消代理)
+            frame.title = t.name;
+            frame.setAttribute('loading', 'lazy');
+            panel.appendChild(frame);
+        } else {
+            // 文件类型:工具条(编辑/预览切换 + 保存 + 刷新)+ 编辑框 + Markdown 预览容器
+            const bar = document.createElement('div');
+            bar.className = 'custom-tab-bar';
+
+            const modeGroup = document.createElement('div');
+            modeGroup.className = 'custom-file-mode';
+            const editBtn = document.createElement('button');
+            editBtn.className = 'custom-file-mode-btn is-active';
+            editBtn.textContent = '编辑';
+            const previewBtn = document.createElement('button');
+            previewBtn.className = 'custom-file-mode-btn';
+            previewBtn.textContent = '预览';
+            modeGroup.appendChild(editBtn);
+            modeGroup.appendChild(previewBtn);
+
+            const saveBtn = document.createElement('button');
+            saveBtn.className = 'btn-ghost';
+            saveBtn.textContent = '保存';
+            saveBtn.addEventListener('click', (e) => {
+                e.stopPropagation();
+                saveCustomFileTab(t);
+            });
+
+            const refreshBtn = document.createElement('button');
+            refreshBtn.className = 'btn-ghost';
+            refreshBtn.textContent = '刷新';
+            refreshBtn.title = t.url;
+            refreshBtn.addEventListener('click', (e) => {
+                e.stopPropagation();
+                loadCustomFileTab(t);
+            });
+
+            const backupBtn = document.createElement('button');
+            backupBtn.className = 'btn-ghost';
+            backupBtn.textContent = '备份';
+            backupBtn.title = '把当前文件按时间备份到「设置 → 文本备份目录」';
+            backupBtn.addEventListener('click', (e) => {
+                e.stopPropagation();
+                backupCustomFileTab(t);
+            });
+
+            bar.appendChild(modeGroup);
+            // 状态提示(保存/备份/刷新结果),margin-right:auto 推到最左,编辑模式下也可见
+            const status = document.createElement('span');
+            status.className = 'custom-file-status';
+            status.id = `status-${t.id}`;
+            bar.appendChild(status);
+            bar.appendChild(saveBtn);
+            bar.appendChild(refreshBtn);
+            bar.appendChild(backupBtn);
+            panel.appendChild(bar);
+
+            // 编辑框(Markdown 源码)
+            const editor = document.createElement('textarea');
+            editor.className = 'custom-file-editor';
+            editor.id = `editor-${t.id}`;
+            editor.spellcheck = false;
+            editor.placeholder = '在这里编辑内容…(支持 Markdown,可切换预览查看效果)';
+            panel.appendChild(editor);
+
+            // 预览容器(初始隐藏,仅点击「预览」时显示,与编辑按钮默认高亮一致)
+            const md = document.createElement('div');
+            md.className = 'markdown-body custom-file-md';
+            md.id = `md-${t.id}`;
+            md.style.display = 'none'; // 默认编辑模式:只显示文本框,预览框隐藏
+            md.textContent = '点击「预览」查看 Markdown 渲染效果。';
+            panel.appendChild(md);
+
+            // 编辑/预览切换
+            const setFileMode = (mode) => {
+                const isEdit = mode === 'edit';
+                editBtn.classList.toggle('is-active', isEdit);
+                previewBtn.classList.toggle('is-active', !isEdit);
+                editor.style.display = isEdit ? '' : 'none';
+                md.style.display = isEdit ? 'none' : '';
+                if (!isEdit) refreshCustomFilePreview(t); // 进入预览时实时渲染
+            };
+            editBtn.addEventListener('click', () => setFileMode('edit'));
+            previewBtn.addEventListener('click', () => setFileMode('preview'));
+            // 编辑框为纯文本编辑:输入时不触发 Markdown 渲染(避免编辑态出现 markdown 副作用),
+            // 预览容器仅在切到「预览」模式时渲染(见 setFileMode)。
+        }
         content.insertBefore(panel, settingsPanel);
     }
     // 刷新按钮与面板引用,供 applyTabOrder / switchTab 使用
@@ -152,6 +218,71 @@ async function applyCustomTabs(tabs) {
     return customTabs.length > 0;
 }
 
+// 加载文件类自定义 Tab 内容:读取文本填入编辑框,并刷新预览
+async function loadCustomFileTab(t) {
+    const editor = document.getElementById(`editor-${t.id}`);
+    const md = document.getElementById(`md-${t.id}`);
+    if (!editor || !md) return;
+    setCustomFileStatus(t, '加载中…', true);
+    try {
+        const text = await SettingsService.ReadTextFile(t.url);
+        editor.value = text;
+        md.innerHTML = text.trim() ? await SettingsService.RenderMarkdown(text) : '<p style="opacity:.6">(文件为空)</p>';
+        setCustomFileStatus(t, `已加载 ${new Date().toLocaleTimeString('zh-CN')}`, true);
+    } catch (err) {
+        md.innerHTML = `<p class="custom-file-error">读取失败:${escapeHtml(String(err && err.message || err))}</p>`;
+        setCustomFileStatus(t, `读取失败:${err && err.message || err}`, false);
+    }
+}
+
+// 用编辑框当前内容刷新 Markdown 预览
+async function refreshCustomFilePreview(t) {
+    const editor = document.getElementById(`editor-${t.id}`);
+    const md = document.getElementById(`md-${t.id}`);
+    if (!editor || !md) return;
+    const text = editor.value;
+    try {
+        md.innerHTML = text.trim() ? await SettingsService.RenderMarkdown(text) : '<p style="opacity:.6">(文件为空)</p>';
+    } catch (err) {
+        md.innerHTML = `<p class="custom-file-error">预览失败:${escapeHtml(String(err && err.message || err))}</p>`;
+    }
+}
+
+// 在文件类自定义 Tab 工具条的状态区显示信息(编辑模式下也可见)
+function setCustomFileStatus(t, msg, ok = true) {
+    const status = document.getElementById(`status-${t.id}`);
+    if (!status) return;
+    status.textContent = msg;
+    status.classList.toggle('is-ok', ok);
+    status.classList.toggle('is-err', !ok);
+}
+
+// 保存文件类自定义 Tab 的编辑框内容到本地文件
+async function saveCustomFileTab(t) {
+    const editor = document.getElementById(`editor-${t.id}`);
+    if (!editor) return;
+    setCustomFileStatus(t, '保存中…', true);
+    try {
+        await SettingsService.WriteTextFile(t.url, editor.value);
+        setCustomFileStatus(t, `已保存 ${new Date().toLocaleTimeString('zh-CN')}`, true);
+        const md = document.getElementById(`md-${t.id}`);
+        if (md) md.innerHTML = editor.value.trim() ? await SettingsService.RenderMarkdown(editor.value) : '<p style="opacity:.6">(文件为空)</p>';
+    } catch (err) {
+        setCustomFileStatus(t, `保存失败:${err && err.message || err}`, false);
+    }
+}
+
+// 备份文件类自定义 Tab 的源文件:按当前时间备份到「设置 → 文本备份目录」,文件名带时间戳
+async function backupCustomFileTab(t) {
+    setCustomFileStatus(t, '备份中…', true);
+    try {
+        const dst = await SettingsService.BackupFile(t.url);
+        setCustomFileStatus(t, `已备份:${dst}`, true);
+    } catch (err) {
+        setCustomFileStatus(t, `备份失败:${err && err.message || err}`, false);
+    }
+}
+
 // 应用 Tab 显示顺序:按 settings.tabOrder 重排按钮与面板
 function applyTabOrder(order) {
     const valid = new Set([...Object.keys(TAB_NAMES), ...customTabs.map(t => t.id)]); // 含内置与自定义 Tab
@@ -225,11 +356,13 @@ function switchTab(tabId) {
     // 部分面板首次打开时才加载数据
     if (!loadedOnce.has(tabId)) {
         loadedOnce.add(tabId);
-        if (tabId === 'record') loadRecord();
         if (tabId === 'todo') loadTodo();
         if (tabId === 'host') loadHostPanel();
         if (tabId === 'tools') loadToolsPanel();
     }
+    // 文件类自定义 Tab:首次打开时自动读取并渲染 Markdown
+    const custom = customTabs.find(t => t.id === tabId);
+    if (custom && !custom.isWeb) loadCustomFileTab(custom);
 }
 
 tabButtons.forEach(btn => {
@@ -269,21 +402,16 @@ document.querySelectorAll('#theme-seg .seg-btn').forEach(btn => {
 const autostartToggle = document.getElementById('autostart');       // 开机启动开关
 const topToggle = document.getElementById('always-on-top');         // 窗口置顶开关
 const hotkeyInput = document.getElementById('hotkey');              // 热键输入框
-const notesUrlInput = document.getElementById('notes-url');         // 笔记页地址
-const controlUrlInput = document.getElementById('control-url');     // 控制页地址
-const blogUrlInput = document.getElementById('blog-url');           // 博客页地址
-const chatUrlInput = document.getElementById('chat-url');           // 聊天页地址
 const tabOrderText = document.getElementById('tab-order');          // Tab 显示顺序(每行一个 id)
-const hardwareUrlInput = document.getElementById('hardware-url');   // 硬件页地址
 const stockCodesInput = document.getElementById('stock-codes');     // 股票代码
 const linksText = document.getElementById('links-text');            // 常用网址(每行 名称|网址)
 const newsKeywordsInput = document.getElementById('news-keywords'); // 新闻关键词
 const biliCookieInput = document.getElementById('bili-cookie');      // B站登录 cookie
 const githubReposText = document.getElementById('github-repos');    // Github 项目(每行 地址|说明)
 const apiAddrInput = document.getElementById('api-addr');           // API 监听地址
-const recordPathInput = document.getElementById('record-path');     // 记录文件路径
 const todoPathInput = document.getElementById('todo-path');         // 待办(TODO)文件路径
-const customTabsText = document.getElementById('custom-tabs');      // 自定义 Tab(每行 名称|网址)
+const backupDirInput = document.getElementById('backup-dir');       // 文本备份目录(文件类自定义 Tab「备份」按钮使用)
+const customTabsText = document.getElementById('custom-tabs');      // 自定义 Tab(每行 名称|地址|备注)
 const saveButton = document.getElementById('save-settings');        // 保存按钮
 const saveStatus = document.getElementById('save-status');          // 保存状态提示
 const stockList = document.getElementById('stock-list');            // 股市列表容器
@@ -376,21 +504,27 @@ async function syncForm(settings) {
     autostartToggle.checked = settings.autostart;
     topToggle.checked = settings.alwaysOnTop;
     hotkeyInput.value = settings.hotkey;
-    notesUrlInput.value = settings.notesUrl;
-    controlUrlInput.value = settings.controlUrl;
-    blogUrlInput.value = settings.blogUrl;
-    chatUrlInput.value = settings.chatUrl;
-    tabOrderText.value = (settings.tabOrder || []).map(id => getTabName(id)).join(', '); // 顺序编辑显示 Tab 名称(逗号分隔)
-    hardwareUrlInput.value = settings.hardwareUrl;
+    // 先重建自定义 Tab(供顺序文本框解析名称与 applyTabOrder 使用)
+    await applyCustomTabs(settings.customTabs);
+    // 完整 Tab 顺序文本:内置 + 自定义,按 settings.tabOrder 排序,未列出的追加(设置恒在最后)
+    const builtinIds = Object.keys(TAB_NAMES).filter(id => id !== 'settings');
+    const customIds = customTabs.map(t => t.id);
+    const allTabIds = [...builtinIds, ...customIds];
+    const tabOrder = settings.tabOrder || [];
+    const orderedIds = [...new Set([
+        ...tabOrder.filter(id => allTabIds.includes(id)),
+        ...allTabIds.filter(id => !tabOrder.includes(id)),
+    ])];
+    tabOrderText.value = orderedIds.map(id => getTabName(id)).join(', '); // 顺序编辑显示 Tab 名称(逗号分隔)
     stockCodesInput.value = settings.stockCodes.join(', ');
     linksText.value = (settings.links || []).map(l => `${l.name}|${l.url}`).join('\n');
     newsKeywordsInput.value = settings.newsKeywords || '';
     biliCookieInput.value = settings.biliCookie || '';
     githubReposText.value = (settings.githubRepos || []).map(r => `${r.url}|${r.desc}|${r.version || ''}`).join('\n');
-    customTabsText.value = (settings.customTabs || []).map(t => `${t.name}|${t.url}`).join('\n');
+    customTabsText.value = (settings.customTabs || []).map(t => `${t.name}|${t.url}${t.desc ? '|' + t.desc : ''}`).join('\n');
     apiAddrInput.value = settings.apiAddr;
-    recordPathInput.value = settings.recordPath;
     todoPathInput.value = settings.todoPath;
+    backupDirInput.value = settings.backupDir;
     stockUpColorInput.value = settings.stockUpColor || FALLBACK_SETTINGS.stockUpColor;
     stockDownColorInput.value = settings.stockDownColor || FALLBACK_SETTINGS.stockDownColor;
     stockRefreshSecsInput.value = settings.stockRefreshSecs || FALLBACK_SETTINGS.stockRefreshSecs;
@@ -403,8 +537,13 @@ async function syncForm(settings) {
     syncPoemForm(settings.poem);
     applyTheme(settings.theme, false);
     applyFontDelta(settings.fontDelta ?? FALLBACK_SETTINGS.fontDelta); // 应用全局字号偏移(默认 +2)
-    await applyCustomTabs(settings.customTabs);   // 重建自定义 Tab(按钮 + 面板)
     applyTabOrder(settings.tabOrder); // 按设置重排 Tab 顺序
+    // 启动/加载设置后,默认激活 Tab 顺序的第 1 位(若当前无激活 Tab 或激活的是旧的 links)
+    const firstId = (settings.tabOrder || []).find(id => id !== 'settings');
+    const activeId = document.querySelector('.tab-btn.active')?.dataset.tab;
+    if (firstId && (!activeId || activeId === 'links')) {
+        switchTab(firstId);
+    }
 }
 
 // 应用全局字号偏移:写入 CSS 变量并同步步进器显示
@@ -449,25 +588,6 @@ function syncPoemForm(cfg) {
     poemInputs.speed.value = cfg.speed || 18;
 }
 
-async function applyEmbedUrls(settings) {
-    const frames = [
-        ['frame-notes', settings.notesUrl],
-        ['frame-control', settings.controlUrl],
-        ['frame-blog', settings.blogUrl],
-        ['frame-chat', settings.chatUrl],
-        ['frame-hardware', settings.hardwareUrl],
-    ];
-    await Promise.all(frames.map(async ([id, url]) => {
-        const frame = document.getElementById(id);
-        if (!frame) return;
-        const eff = embedSrc(url); // 直接使用原地址(已取消代理)
-        if (frame.dataset.src !== eff) {
-            frame.dataset.src = eff; // 记录已应用的地址
-            frame.src = eff;         // 地址变化才重新加载
-        }
-    }));
-}
-
 // ------------------------------------------------------------------------
 // 热键录入:点击输入框后按下组合键
 // ------------------------------------------------------------------------
@@ -505,7 +625,6 @@ async function loadSettings() {
         currentSettings = {...FALLBACK_SETTINGS};
     }
     await syncForm(currentSettings);
-    applyEmbedUrls(currentSettings);
     renderLinks(currentSettings.links || []);
     initFooterPoem(currentSettings.poem);  // footer 古诗按配置显示并加载
     restartAutoTimers(); // 按真实设置的间隔重建自动刷新定时器
@@ -554,7 +673,7 @@ function collectForm() {
             // LinkItem / RepoItem / CustomTab 对象:转回 "a|b" 格式行
             if (key === 'links') return `${item.name}|${item.url}`;
             if (key === 'githubRepos') return `${item.url}|${item.desc}|${item.version || ''}`;
-            if (key === 'customTabs') return `${item.name}|${item.url}`;
+            if (key === 'customTabs') return `${item.name}|${item.url}${item.desc ? '|' + item.desc : ''}`;
             return '';
         }).join('\n');
         return '';
@@ -578,21 +697,16 @@ function collectForm() {
         autostart: autostartToggle.checked,
         alwaysOnTop: topToggle.checked,
         hotkey: hotkeyInput.value.trim() || FALLBACK_SETTINGS.hotkey,
-        notesUrl: notesUrlInput.value.trim(),
-        controlUrl: controlUrlInput.value.trim(),
-        blogUrl: blogUrlInput.value.trim(),
-        chatUrl: chatUrlInput.value.trim(),
         tabOrder: collectTabOrder(),
-        hardwareUrl: hardwareUrlInput.value.trim(),
         stockCodes,
         links: parsePipeLines(fallbackArr(linksText, 'links'), 'name', 'url').filter(l => l.name && l.url),
         newsKeywords: newsKeywordsInput.value.trim(),
         biliCookie: biliCookieInput.value.trim(),
         githubRepos: parsePipeLines3(fallbackArr(githubReposText, 'githubRepos'), 'url', 'desc', 'version').filter(r => r.url),
-        customTabs: parsePipeLines(fallbackArr(customTabsText, 'customTabs'), 'name', 'url').filter(t => t.name && t.url),
+        customTabs: parsePipeLines3(fallbackArr(customTabsText, 'customTabs'), 'name', 'url', 'desc').filter(t => t.name && t.url),
         apiAddr: apiAddrInput.value.trim(),
-        recordPath: recordPathInput.value.trim(),
         todoPath: todoPathInput.value.trim(),
+        backupDir: backupDirInput.value.trim(),
         stockUpColor: fallbackColor(stockUpColorInput, currentSettings.stockUpColor, FALLBACK_SETTINGS.stockUpColor),
         stockDownColor: fallbackColor(stockDownColorInput, currentSettings.stockDownColor, FALLBACK_SETTINGS.stockDownColor),
         stockRefreshSecs: parseInt(stockRefreshSecsInput.value, 10) || FALLBACK_SETTINGS.stockRefreshSecs,
@@ -765,7 +879,7 @@ function showFooterPoem(cfg) {
     footerMode = 'poem';
     const enabled = !!cfg?.enabled;
     if (!enabled) { // 未启用:回退显示当前 Tab 说明
-        const cur = document.querySelector('.tab-btn.active')?.dataset.tab || 'notes';
+        const cur = document.querySelector('.tab-btn.active')?.dataset.tab || 'links';
         showTabDesc(cur);
         return;
     }
@@ -795,9 +909,15 @@ function initFooterPoem(cfg) {
 
 // 从控件收集 Tab 顺序:逗号(中英文)分隔的名称,转回 tabId(含内置与自定义);未知名称忽略
 function collectTabOrder() {
+    // 基于「自定义 Tab 页面」文本框当前内容构建名称->id 映射(而非全局旧列表),
+    // 确保本次新增/修改的自定义 Tab 名称也能参与顺序调整。文本框为空时回退生效配置(与 fallbackArr 语义一致)。
+    const raw = customTabsText.value.trim();
+    const customNames = raw
+        ? parsePipeLines3(raw, 'name', 'url', 'desc').filter(t => t.name && t.url).map(t => t.name)
+        : (currentSettings.customTabs || []).map(t => t.name);
     const nameToId = {};
     Object.entries(TAB_NAMES).forEach(([id, name]) => { nameToId[name] = id; });
-    customTabs.forEach(t => { nameToId[t.name] = t.id; });
+    customNames.forEach((name, idx) => { nameToId[name] = `custom-${idx}`; });
     return tabOrderText.value.split(/[,,\s]+/)
         .map(s => s.trim())
         .filter(Boolean)
@@ -816,7 +936,6 @@ async function saveSettings() {
         const saved = await SettingsService.SaveSettings(new AppSettings(collectForm()));
         currentSettings = saved;
         await syncForm(saved);
-        applyEmbedUrls(saved);
         renderLinks(saved.links || []);
         // 古诗配置变化:重新进入古诗模式(若启用则加载一首,未启用回退 Tab 说明)
         showFooterPoem(saved.poem);
@@ -1263,73 +1382,6 @@ async function loadTrending() {
 
 trendingRefreshBtn.addEventListener('click', loadTrending); // 手动刷新
 
-// ------------------------------------------------------------------------
-// 记录:编辑记录文件
-// ------------------------------------------------------------------------
-const recordEditor = document.getElementById('record-editor');   // 记录编辑框
-const recordStatus = document.getElementById('record-status');   // 记录状态提示
-const recordSaveBtn = document.getElementById('record-save');    // 记录保存按钮
-const recordPathDesc = document.getElementById('record-path-desc'); // 显示当前记录文件路径
-const recordPreview = document.getElementById('record-preview'); // Markdown 预览容器
-const recordModeEdit = document.getElementById('record-mode-edit');   // 编辑模式按钮
-const recordModePreview = document.getElementById('record-mode-preview'); // 预览模式按钮
-
-// 记录页显示模式:'edit' 源码编辑 / 'preview' Markdown 预览
-let recordMode = 'edit';
-
-async function loadRecord() {
-    recordPathDesc.textContent = `记录零碎信息,当前文件:${currentSettings.recordPath || FALLBACK_SETTINGS.recordPath}`;
-    try {
-        recordEditor.value = await SettingsService.ReadRecord();
-        recordStatus.textContent = '';
-        refreshRecordPreview(); // 首次加载后刷新预览
-    } catch (err) {
-        recordStatus.textContent = '读取失败:' + (err?.message || err);
-    }
-}
-
-// 把当前编辑内容渲染到 Markdown 预览区(调用后端 gomarkdown)
-async function refreshRecordPreview() {
-    const md = recordEditor.value;
-    try {
-        recordPreview.innerHTML = await SettingsService.RenderMarkdown(md);
-    } catch (err) {
-        recordPreview.textContent = '预览失败:' + (err?.message || err);
-    }
-}
-
-// 切换记录页显示模式(编辑 / 预览),并给两个按钮标记激活态
-function setRecordMode(mode) {
-    recordMode = mode;
-    const isEdit = mode === 'edit';
-    recordModeEdit.classList.toggle('is-active', isEdit);
-    recordModePreview.classList.toggle('is-active', !isEdit);
-    recordEditor.style.display = isEdit ? '' : 'none';
-    recordPreview.style.display = isEdit ? 'none' : '';
-    if (!isEdit) refreshRecordPreview(); // 进入预览时渲染一次
-}
-
-recordModeEdit.addEventListener('click', () => setRecordMode('edit'));
-recordModePreview.addEventListener('click', () => setRecordMode('preview'));
-
-// 编辑内容变化时,若正处于预览模式则实时刷新(简单去抖)
-let recordPreviewTimer = null;
-recordEditor.addEventListener('input', () => {
-    if (recordMode !== 'preview') return;
-    clearTimeout(recordPreviewTimer);
-    recordPreviewTimer = setTimeout(refreshRecordPreview, 250);
-});
-
-recordSaveBtn.addEventListener('click', async () => {
-    recordStatus.textContent = '保存中…';
-    try {
-        await SettingsService.WriteRecord(recordEditor.value);
-        recordStatus.textContent = '已保存 ' + new Date().toLocaleTimeString('zh-CN');
-    } catch (err) {
-        recordStatus.textContent = '保存失败:' + (err?.message || err);
-    }
-});
-
 // ------------------------------------------------------------------------
 // 待办:每行记录一个待完成任务,可勾选完成 / 编辑 / 删除,保存到本地文件
 // ------------------------------------------------------------------------
@@ -1445,8 +1497,27 @@ function addTodoRow() {
 
 // 从后端读取待办并渲染:每行一个任务,行首 [x] 表示已完成,行尾 @ 时间表示提醒
 async function loadTodo() {
-    todoPathDesc.textContent = `待办任务,当前文件:${currentSettings.todoPath || FALLBACK_SETTINGS.todoPath}`;
+    const path = currentSettings.todoPath || '';
+    todoPathDesc.textContent = path
+        ? `待办任务,当前文件:${path}`
+        : '待办任务:未设置「待办文件路径」,请到设置页配置后再使用';
     todoList.innerHTML = '';
+    if (!path) {
+        // 未配置待办路径:强制提示进入设置,不渲染任务行
+        const hint = document.createElement('div');
+        hint.className = 'empty-state';
+        hint.innerHTML = `<div class="empty-mark">⚙</div><div class="empty-title">请先设置待办文件路径</div><div class="empty-text">前往「设置」页填写「待办文件路径」后保存即可使用</div>`;
+        const goBtn = document.createElement('button');
+        goBtn.className = 'btn-ghost';
+        goBtn.textContent = '前往设置';
+        goBtn.style.marginTop = '10px';
+        goBtn.addEventListener('click', () => switchTab('settings'));
+        hint.appendChild(goBtn);
+        todoList.appendChild(hint);
+        todoDirty = false;
+        todoStatus.textContent = '';
+        return;
+    }
     try {
         const raw = await SettingsService.ReadTodo();
         const lines = (raw || '').split(/\r?\n/);

+ 59 - 83
settings.go

@@ -8,6 +8,7 @@ package main
 
 import (
 	"encoding/json"
+	"fmt"
 	"log"
 	"os"
 	"path/filepath"
@@ -17,23 +18,16 @@ import (
 
 // 默认配置:首次运行或字段为空时使用
 const (
-	DefaultHotkey               = "CmdOrCtrl+Shift+G"        // 默认呼出窗口热键
-	DefaultNotesURL             = "https://m.scwy.net"       // 默认笔记页地址
-	DefaultControlURL           = "https://a7.scwy.net/4191" // 默认控制页地址
-	DefaultBlogURL              = "https://i.scwy.net"       // 默认博客页地址
-	DefaultChatURL              = "https://c.scwy.net"       // 默认聊天页地址
-	DefaultAPIAddr              = "127.0.0.1:17890"          // 内置 Gin API 服务默认监听地址(仅本机)
-	DefaultRecordPath           = `D:\My\腾图\记录.txt`          // 默认记录文件路径
-	DefaultTodoPath             = `D:\My\腾图\待办.txt`          // 默认待办(TODO)文件路径
-	DefaultHardwareURL          = "http://192.168.100.190/"  // 默认硬件页地址
-	DefaultStockUpColor         = "#ff5a5a"                  // 股票上涨颜色(红涨)
-	DefaultStockDownColor       = "#4d8dff"                  // 股票下跌颜色(蓝跌)
-	DefaultStockRefreshSecs     = 30                         // 股市自动刷新间隔(秒)
-	DefaultStockClosedRefreshSecs = 7200                    // 股市休市时自动刷新间隔(秒),默认 2 小时
-	DefaultNewsRefreshMinutes   = 120                        // 新闻自动刷新间隔(分钟)
-	DefaultNewsCount            = 30                         // 新闻页显示条数
-	DefaultGithubRefreshMinutes = 120                        // Github 自动刷新间隔(分钟)
-	AppVersion                  = "0.1.0"                    // 应用自身版本(与 build/windows/info.json 的 ProductVersion 一致),用于仓库更新提示
+	DefaultHotkey                 = "CmdOrCtrl+Shift+G" // 默认呼出窗口热键
+	DefaultAPIAddr                = "127.0.0.1:17890"   // 内置 Gin API 服务默认监听地址(仅本机)
+	DefaultStockUpColor           = "#ff5a5a"           // 股票上涨颜色(红涨)
+	DefaultStockDownColor         = "#4d8dff"           // 股票下跌颜色(蓝跌)
+	DefaultStockRefreshSecs       = 30                  // 股市自动刷新间隔(秒)
+	DefaultStockClosedRefreshSecs = 7200                // 股市休市时自动刷新间隔(秒),默认 2 小时
+	DefaultNewsRefreshMinutes     = 120                 // 新闻自动刷新间隔(分钟)
+	DefaultNewsCount              = 30                  // 新闻页显示条数
+	DefaultGithubRefreshMinutes   = 120                 // Github 自动刷新间隔(分钟)
+	AppVersion                    = "0.1.2"             // 应用自身版本(与 build/windows/info.json 的 ProductVersion 一致),用于仓库更新提示
 )
 
 // LinkItem 常用网址条目
@@ -46,6 +40,7 @@ type LinkItem struct {
 type CustomTab struct {
 	Name string `json:"name"` // 页面名称(Tab 按钮显示文字)
 	URL  string `json:"url"`  // 页面地址(iframe 加载)
+	Desc string `json:"desc"` // 备注/提示信息(可空),Tab 说明文字优先显示
 }
 
 // RepoItem 关注的 GitHub 仓库条目
@@ -73,33 +68,28 @@ type AppSettings struct {
 	Autostart    bool         `json:"autostart"`    // 系统启动时运行
 	AlwaysOnTop  bool         `json:"alwaysOnTop"`  // 窗口置顶
 	Hotkey       string       `json:"hotkey"`       // 呼出(显示)窗口的全局热键
-	NotesURL     string       `json:"notesUrl"`     // 笔记页地址
-	ControlURL   string       `json:"controlUrl"`   // 控制页地址
-	BlogURL      string       `json:"blogUrl"`      // 博客页地址
-	ChatURL      string       `json:"chatUrl"`      // 聊天页地址
 	TabOrder     []string     `json:"tabOrder"`     // Tab 显示顺序(tabId 列表,未列出的追加到末尾)
 	StockCodes   []string     `json:"stockCodes"`   // 股市页展示的股票代码列表
 	Links        []LinkItem   `json:"links"`        // 常用网址列表
-	NewsKeywords  string       `json:"newsKeywords"` // 新闻关注关键词,逗号分隔
-	BiliCookie    string       `json:"biliCookie"`   // B站登录 cookie(含 SESSDATA),用于抓取「关注的人最新动态」
-	GithubRepos   []RepoItem   `json:"githubRepos"`  // 关注的 GitHub 仓库列表
+	NewsKeywords string       `json:"newsKeywords"` // 新闻关注关键词,逗号分隔
+	BiliCookie   string       `json:"biliCookie"`   // B站登录 cookie(含 SESSDATA),用于抓取「关注的人最新动态」
+	GithubRepos  []RepoItem   `json:"githubRepos"`  // 关注的 GitHub 仓库列表
 	CustomTabs   []CustomTab  `json:"customTabs"`   // 用户自定义 Tab 页面(名字 + 网址)
 	APIAddr      string       `json:"apiAddr"`      // 内置 API 服务监听地址
-	RecordPath   string       `json:"recordPath"`   // 记录文件路径
 	TodoPath     string       `json:"todoPath"`     // 待办(TODO)文件路径
-	HardwareURL  string       `json:"hardwareUrl"`  // 硬件页地址
+	BackupDir    string       `json:"backupDir"`    // 文本文件备份目录(文件类自定义 Tab「备份」按钮使用)
 	StockTicker  TickerConfig `json:"stockTicker"`  // 股票滚动条配置
 	NewsTicker   TickerConfig `json:"newsTicker"`   // 新闻滚动条配置
 	Poem         PoemConfig   `json:"poem"`         // 随机古诗显示配置
 
-	StockUpColor         string `json:"stockUpColor"`         // 股票上涨颜色
-	StockDownColor       string `json:"stockDownColor"`       // 股票下跌颜色
-	StockRefreshSecs     int    `json:"stockRefreshSecs"`       // 股市自动刷新间隔(秒)
-	StockClosedRefreshSecs int  `json:"stockClosedRefreshSecs"` // 股市休市时自动刷新间隔(秒)
-	NewsRefreshMinutes   int    `json:"newsRefreshMinutes"`     // 新闻自动刷新间隔(分钟)
-	NewsCount            int    `json:"newsCount"`              // 新闻页显示条数
-	GithubRefreshMinutes int    `json:"githubRefreshMinutes"`   // Github 自动刷新间隔(分钟)
-	FontDelta            int    `json:"fontDelta"`              // 全局字号偏移(px),+-3 范围内调整
+	StockUpColor           string `json:"stockUpColor"`           // 股票上涨颜色
+	StockDownColor         string `json:"stockDownColor"`         // 股票下跌颜色
+	StockRefreshSecs       int    `json:"stockRefreshSecs"`       // 股市自动刷新间隔(秒)
+	StockClosedRefreshSecs int    `json:"stockClosedRefreshSecs"` // 股市休市时自动刷新间隔(秒)
+	NewsRefreshMinutes     int    `json:"newsRefreshMinutes"`     // 新闻自动刷新间隔(分钟)
+	NewsCount              int    `json:"newsCount"`              // 新闻页显示条数
+	GithubRefreshMinutes   int    `json:"githubRefreshMinutes"`   // Github 自动刷新间隔(分钟)
+	FontDelta              int    `json:"fontDelta"`              // 全局字号偏移(px),+-3 范围内调整
 }
 
 // 当前已注册的热键(用于保存设置时先注销旧热键)
@@ -115,11 +105,7 @@ func defaultSettings() AppSettings {
 		Autostart:   true,
 		AlwaysOnTop: false,
 		Hotkey:      DefaultHotkey,
-		NotesURL:    DefaultNotesURL,
-		ControlURL:  DefaultControlURL,
-		BlogURL:     DefaultBlogURL,
-		ChatURL:     DefaultChatURL,
-		TabOrder:    []string{"notes", "control", "blog", "chat", "links", "news", "github", "stock", "record", "todo", "hardware", "tools", "host", "settings"}, // 默认 Tab 顺序(工具在硬件与主机之间)
+		TabOrder:    []string{"links", "news", "github", "stock", "todo", "tools", "host", "settings"}, // 默认 Tab 顺序(控制/硬件等固定网页已改为自定义 Tab)
 		StockCodes:  []string{"sh000001", "sh600519", "sz000001"},
 		Links: []LinkItem{
 			{Name: "煎蛋", URL: "https://jandan.net/"},
@@ -131,21 +117,20 @@ func defaultSettings() AppSettings {
 			{URL: "https://github.com/wailsapp/wails", Desc: "Wails:Go + Web 桌面应用框架"},
 		},
 		APIAddr:     DefaultAPIAddr,
-		RecordPath:  DefaultRecordPath,
-		TodoPath:    DefaultTodoPath,
-		HardwareURL: DefaultHardwareURL,
+		TodoPath:    "", // 待办路径:无默认值,用户须在设置中配置(空值时强制进入设置)
+		BackupDir:   "", // 备份目录:无默认值,用户须在设置中配置(空值时强制进入设置)
 		StockTicker: TickerConfig{Enabled: false, Width: 1400, FontSize: 14, Color: "#ff4d4d", Speed: 80, Transparent: true, X: -1, Y: -1},
 		NewsTicker:  TickerConfig{Enabled: false, Width: 1400, FontSize: 14, Color: "#3ddc84", Speed: 60, Transparent: true, X: -1, Y: -1},
 		Poem:        PoemConfig{Enabled: false, Color: DefaultPoemColor, FontSize: DefaultPoemFontSize, TTS: false, Interval: DefaultPoemInterval, Speed: DefaultPoemSpeed},
 
-		StockUpColor:         DefaultStockUpColor,
-		StockDownColor:       DefaultStockDownColor,
-		StockRefreshSecs:     DefaultStockRefreshSecs,
+		StockUpColor:           DefaultStockUpColor,
+		StockDownColor:         DefaultStockDownColor,
+		StockRefreshSecs:       DefaultStockRefreshSecs,
 		StockClosedRefreshSecs: DefaultStockClosedRefreshSecs,
-		NewsRefreshMinutes:   DefaultNewsRefreshMinutes,
-		NewsCount:            DefaultNewsCount,
-		GithubRefreshMinutes: DefaultGithubRefreshMinutes,
-		FontDelta:            0, // 全局字号偏移默认 0
+		NewsRefreshMinutes:     DefaultNewsRefreshMinutes,
+		NewsCount:              DefaultNewsCount,
+		GithubRefreshMinutes:   DefaultGithubRefreshMinutes,
+		FontDelta:              0, // 全局字号偏移默认 0
 	}
 }
 
@@ -204,21 +189,24 @@ func normalizeSettings(settings AppSettings) AppSettings {
 	if strings.TrimSpace(settings.Hotkey) == "" {
 		settings.Hotkey = DefaultHotkey
 	}
-	if strings.TrimSpace(settings.NotesURL) == "" {
-		settings.NotesURL = DefaultNotesURL
-	}
-	if strings.TrimSpace(settings.ControlURL) == "" {
-		settings.ControlURL = DefaultControlURL
-	}
-	if strings.TrimSpace(settings.BlogURL) == "" {
-		settings.BlogURL = DefaultBlogURL
+	// 先清洗自定义 Tab(生成 custom-N id 白名单供下方 Tab 顺序校验使用)
+	tabs := make([]CustomTab, 0, len(settings.CustomTabs))
+	for _, tab := range settings.CustomTabs {
+		tab.Name = strings.TrimSpace(tab.Name)
+		tab.URL = strings.TrimSpace(tab.URL)
+		tab.Desc = strings.TrimSpace(tab.Desc) // 备注可空
+		if tab.Name != "" && tab.URL != "" {   // 名字与网址都非空才保留
+			tabs = append(tabs, tab)
+		}
 	}
-	if strings.TrimSpace(settings.ChatURL) == "" {
-		settings.ChatURL = DefaultChatURL
+	settings.CustomTabs = tabs
+
+	// Tab 顺序:只保留已存在的 tabId(内置 + 自定义 custom-N),去重;未列出的追加到末尾,保证全部 Tab 都能显示
+	validTabs := map[string]bool{"links": true,
+		"news": true, "github": true, "stock": true, "todo": true, "tools": true, "host": true, "settings": true}
+	for i := range settings.CustomTabs {
+		validTabs[fmt.Sprintf("custom-%d", i)] = true // 自定义 Tab id:custom-0, custom-1...
 	}
-	// Tab 顺序:只保留已存在的 tabId,去重;未列出的追加到末尾,保证全部 Tab 都能显示
-	validTabs := map[string]bool{"notes": true, "control": true, "blog": true, "chat": true, "links": true,
-		"news": true, "github": true, "stock": true, "record": true, "todo": true, "hardware": true, "tools": true, "host": true, "settings": true}
 	seen := map[string]bool{}
 	ordered := make([]string, 0, len(validTabs))
 	for _, tab := range settings.TabOrder {
@@ -227,24 +215,22 @@ func normalizeSettings(settings AppSettings) AppSettings {
 			ordered = append(ordered, tab)
 		}
 	}
-	for _, tab := range []string{"notes", "control", "blog", "chat", "links", "news", "github", "stock", "record", "todo", "hardware", "tools", "host", "settings"} {
+	for _, tab := range []string{"links", "news", "github", "stock", "todo", "tools", "host", "settings"} {
 		if !seen[tab] {
-			ordered = append(ordered, tab) // 设置中未列出的 Tab 追加到末尾
+			ordered = append(ordered, tab) // 设置中未列出的内置 Tab 追加到末尾
+		}
+	}
+	for i := range settings.CustomTabs {
+		id := fmt.Sprintf("custom-%d", i)
+		if !seen[id] {
+			ordered = append(ordered, id) // 设置中未列出的自定义 Tab 追加到末尾
 		}
 	}
 	settings.TabOrder = ordered
 	if strings.TrimSpace(settings.APIAddr) == "" {
 		settings.APIAddr = DefaultAPIAddr
 	}
-	if strings.TrimSpace(settings.RecordPath) == "" {
-		settings.RecordPath = DefaultRecordPath
-	}
-	if strings.TrimSpace(settings.TodoPath) == "" {
-		settings.TodoPath = DefaultTodoPath
-	}
-	if strings.TrimSpace(settings.HardwareURL) == "" {
-		settings.HardwareURL = DefaultHardwareURL
-	}
+	// TodoPath / BackupDir 无默认值:空值保留,前端据此强制用户进入设置配置(不做硬编码回填)
 	settings.StockTicker = normalizeTicker(settings.StockTicker, "#ff4d4d", 80) // 股票条默认红字
 	settings.NewsTicker = normalizeTicker(settings.NewsTicker, "#3ddc84", 60)   // 新闻条默认绿字
 	settings.Poem = normalizePoemConfig(settings.Poem)                          // 古诗显示配置校正
@@ -308,16 +294,6 @@ func normalizeSettings(settings AppSettings) AppSettings {
 	}
 	settings.GithubRepos = repos
 
-	tabs := make([]CustomTab, 0, len(settings.CustomTabs))
-	for _, tab := range settings.CustomTabs {
-		tab.Name = strings.TrimSpace(tab.Name)
-		tab.URL = strings.TrimSpace(tab.URL)
-		if tab.Name != "" && tab.URL != "" { // 名字与网址都非空才保留
-			tabs = append(tabs, tab)
-		}
-	}
-	settings.CustomTabs = tabs
-
 	codes := make([]string, 0, len(settings.StockCodes))
 	for _, raw := range settings.StockCodes {
 		code := normalizeStockCode(raw) // 补全 sh / sz / bj 前缀

+ 49 - 10
settingsservice.go

@@ -7,8 +7,11 @@
 package main
 
 import (
+	"fmt"
 	"os"
 	"path/filepath"
+	"strings"
+	"time"
 
 	"github.com/gomarkdown/markdown"
 	"github.com/gomarkdown/markdown/html"
@@ -138,11 +141,12 @@ func (s *SettingsService) MarkGithubRepoChecked(url, latest string) error {
 	return saveSettings(settings)
 }
 
-// ReadRecord 读取记录文件内容;文件不存在时返回空内容
-func (s *SettingsService) ReadRecord() (string, error) {
-	data, err := os.ReadFile(loadSettings().RecordPath)
+// ReadTextFile 读取任意文本文件内容(供「自定义 Tab」文件类型页面使用)。
+// 文件不存在时返回空内容;其他错误原样返回。
+func (s *SettingsService) ReadTextFile(path string) (string, error) {
+	data, err := os.ReadFile(path)
 	if os.IsNotExist(err) {
-		return "", nil // 首次使用:文件尚未创建,返回空白
+		return "", nil // 文件尚未创建,返回空白
 	}
 	if err != nil {
 		return "", err
@@ -150,16 +154,44 @@ func (s *SettingsService) ReadRecord() (string, error) {
 	return string(data), nil
 }
 
-// WriteRecord 把记录内容写回记录文件(目录不存在时自动创建)
-func (s *SettingsService) WriteRecord(content string) error {
-	path := loadSettings().RecordPath
+// WriteTextFile 把文本内容写回指定文件(目录不存在时自动创建)。
+// 供文件类自定义 Tab 的「保存」使用。
+func (s *SettingsService) WriteTextFile(path, content string) error {
 	if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
 		return err
 	}
 	return os.WriteFile(path, []byte(content), 0o644)
 }
 
-// RenderMarkdown 把 Markdown 文本渲染为安全 HTML 片段(用于「记录」页预览)。
+// BackupFile 把指定文本文件按当前时间备份到配置的备份目录。
+// 备份文件名带时间戳:<原名>_YYYYMMDD_HHMMSS<扩展名>;目录不存在时自动创建。
+// 返回备份后的完整路径,供前端提示。
+func (s *SettingsService) BackupFile(path string) (string, error) {
+	if _, err := os.Stat(path); err != nil {
+		return "", err // 源文件不存在,无法备份
+	}
+	dir := strings.TrimSpace(loadSettings().BackupDir)
+	if dir == "" {
+		return "", fmt.Errorf("未设置「文本备份目录」,请到设置页配置后再备份")
+	}
+	if err := os.MkdirAll(dir, 0o755); err != nil {
+		return "", err
+	}
+	ext := filepath.Ext(path)
+	base := strings.TrimSuffix(filepath.Base(path), ext)
+	stamp := time.Now().Format("20060102_150405")
+	dst := filepath.Join(dir, fmt.Sprintf("%s_%s%s", base, stamp, ext))
+	data, err := os.ReadFile(path)
+	if err != nil {
+		return "", err
+	}
+	if err := os.WriteFile(dst, data, 0o644); err != nil {
+		return "", err
+	}
+	return dst, nil
+}
+
+// RenderMarkdown 把 Markdown 文本渲染为安全 HTML 片段(供文件类自定义 Tab 展示)。
 // 使用 gomarkdown 渲染,并启用 CommonMark 扩展(表格、任务列表、围栏代码等)。
 func (s *SettingsService) RenderMarkdown(md string) string {
 	p := parser.NewWithExtensions(parser.CommonExtensions | parser.AutoHeadingIDs)
@@ -171,7 +203,11 @@ func (s *SettingsService) RenderMarkdown(md string) string {
 
 // ReadTodo 读取待办文件内容(每行一个任务);文件不存在时返回空
 func (s *SettingsService) ReadTodo() (string, error) {
-	data, err := os.ReadFile(loadSettings().TodoPath)
+	path := strings.TrimSpace(loadSettings().TodoPath)
+	if path == "" {
+		return "", fmt.Errorf("未设置「待办文件路径」,请到设置页配置后再使用")
+	}
+	data, err := os.ReadFile(path)
 	if os.IsNotExist(err) {
 		return "", nil // 首次使用:文件尚未创建,返回空白
 	}
@@ -183,7 +219,10 @@ func (s *SettingsService) ReadTodo() (string, error) {
 
 // WriteTodo 把待办内容写回待办文件(目录不存在时自动创建)
 func (s *SettingsService) WriteTodo(content string) error {
-	path := loadSettings().TodoPath
+	path := strings.TrimSpace(loadSettings().TodoPath)
+	if path == "" {
+		return fmt.Errorf("未设置「待办文件路径」,请到设置页配置后再使用")
+	}
 	if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
 		return err
 	}