<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom" xml:lang="zh"><generator uri="https://jekyllrb.com/" version="4.4.1">Jekyll</generator><link href="https://ariesoxo.github.io/feed.xml" rel="self" type="application/atom+xml" /><link href="https://ariesoxo.github.io/" rel="alternate" type="text/html" hreflang="zh" /><updated>2026-06-23T10:27:09+08:00</updated><id>https://ariesoxo.github.io/feed.xml</id><title type="html">喵呜</title><subtitle>meow 的个人博客，分享 Java、Rust、Spring Boot 等技术文章，开源项目，摄影作品与生活记录。
</subtitle><author><name>meow</name><email>njwzcb@163.com</email></author><entry><title type="html">MeowCode AI编码助手开发记录（一）</title><link href="https://ariesoxo.github.io/%E5%BC%80%E5%8F%91%E8%AE%B0%E5%BD%95/ai%E5%B7%A5%E5%85%B7/2026/06/23/MeowCode%E9%A1%B9%E7%9B%AE%E5%88%9D%E5%A7%8B%E5%8C%96.html" rel="alternate" type="text/html" title="MeowCode AI编码助手开发记录（一）" /><published>2026-06-23T00:00:00+08:00</published><updated>2026-06-23T00:00:00+08:00</updated><id>https://ariesoxo.github.io/%E5%BC%80%E5%8F%91%E8%AE%B0%E5%BD%95/ai%E5%B7%A5%E5%85%B7/2026/06/23/MeowCode%E9%A1%B9%E7%9B%AE%E5%88%9D%E5%A7%8B%E5%8C%96</id><content type="html" xml:base="https://ariesoxo.github.io/%E5%BC%80%E5%8F%91%E8%AE%B0%E5%BD%95/ai%E5%B7%A5%E5%85%B7/2026/06/23/MeowCode%E9%A1%B9%E7%9B%AE%E5%88%9D%E5%A7%8B%E5%8C%96.html"><![CDATA[<p>用了一段时间 Claude Code，被 AI 编码助手的能力震撼，但也有些想法：能否支持更多模型？能否更轻量？能否自己掌控？于是决定从零开始做一个 <strong>MeowCode</strong> —— 不是要替代 Claude Code，而是深入理解 AI Agent 的设计原理，做一个够用的编码助手。</p>

<p>本文记录项目初始化的完整过程：技术选型、架构设计、核心实现和 API 测试。</p>

<hr />

<h2 id="一技术选型python--deepseek">一、技术选型：Python + DeepSeek</h2>

<h3 id="11-编程语言选择">1.1 编程语言选择</h3>

<p>考虑过 Go、Rust，但最终选择 <strong>Python</strong>：</p>

<p><strong>优势明显</strong>：</p>
<ul>
  <li>AI 生态最成熟（OpenAI SDK、Anthropic SDK 都是 Python 优先）</li>
  <li>开发速度快（1-2 周就能做出 MVP）</li>
  <li>标准库强大（文件操作、正则、subprocess 开箱即用）</li>
  <li>打包方案成熟（PyInstaller / Nuitka）</li>
</ul>

<p><strong>权衡成本</strong>：</p>
<ul>
  <li>打包体积大（20-40MB）</li>
  <li>启动速度慢（1-2秒）</li>
</ul>

<p>对于 MVP 阶段，开发效率优先于运行效率。</p>

<h3 id="12-llm-提供商deepseek-起步">1.2 LLM 提供商：DeepSeek 起步</h3>

<p><strong>价格对比</strong>（修复 100 个 Bug 的实际成本）：</p>

<table>
  <thead>
    <tr>
      <th>模型</th>
      <th>单次成本</th>
      <th>100次成本</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>DeepSeek v4-flash</td>
      <td>¥0.0018</td>
      <td>¥0.18</td>
    </tr>
    <tr>
      <td>GLM-4-flash</td>
      <td>¥0.0015</td>
      <td>¥0.15</td>
    </tr>
    <tr>
      <td>Claude Sonnet 4.5</td>
      <td>¥0.54</td>
      <td>¥54</td>
    </tr>
  </tbody>
</table>

<p>作为个人开发者，DeepSeek 的性价比无敌 —— 比 Claude 便宜 <strong>300 倍</strong>！</p>

<p><strong>架构设计支持未来扩展</strong>：</p>
<ul>
  <li>智谱 GLM（备选方案）</li>
  <li>Anthropic Claude（高端场景）</li>
  <li>OpenAI GPT（兼容性考虑）</li>
</ul>

<h3 id="13-界面cli-优先">1.3 界面：CLI 优先</h3>

<p>选择命令行界面而不是 GUI：</p>
<ul>
  <li>✅ 简单高效，开发成本低</li>
  <li>✅ 适合开发者使用场景</li>
  <li>✅ 易于自动化和集成</li>
</ul>

<hr />

<h2 id="二架构设计多模型抽象层">二、架构设计：多模型抽象层</h2>

<h3 id="21-核心循环">2.1 核心循环</h3>

<p>Agent 的本质是一个反馈循环：</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>用户输入任务
  ↓
┌─────────────────┐
│  查询 LLM       │
└────────┬────────┘
         ↓
┌─────────────────┐
│  解析 Action    │ → bash / read_file / write_file / exit
└────────┬────────┘
         ↓
┌─────────────────┐
│  执行 Action    │
└────────┬────────┘
         ↓
┌─────────────────┐
│  反馈结果       │
└────────┬────────┘
         │
         └─→ 循环，直到完成或超时
</code></pre></div></div>

<h3 id="22-多模型架构抽象工厂模式">2.2 多模型架构：抽象工厂模式</h3>

<p>使用抽象基类 + 工厂函数实现多模型支持：</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># models/base.py - 抽象基类
</span><span class="k">class</span> <span class="nc">BaseLLMProvider</span><span class="p">(</span><span class="n">ABC</span><span class="p">):</span>
    <span class="nd">@abstractmethod</span>
    <span class="k">def</span> <span class="nf">query</span><span class="p">(</span><span class="n">self</span><span class="p">,</span> <span class="n">messages</span><span class="p">:</span> <span class="n">List</span><span class="p">[</span><span class="n">Dict</span><span class="p">])</span> <span class="o">-&gt;</span> <span class="nb">str</span><span class="p">:</span>
        <span class="sh">"""</span><span class="s">查询模型</span><span class="sh">"""</span>
        <span class="k">pass</span>
    
    <span class="nd">@abstractmethod</span>
    <span class="k">def</span> <span class="nf">get_model_info</span><span class="p">(</span><span class="n">self</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="n">Dict</span><span class="p">:</span>
        <span class="sh">"""</span><span class="s">获取模型信息（名称、价格、上下文长度）</span><span class="sh">"""</span>
        <span class="k">pass</span>
</code></pre></div></div>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># models/deepseek.py - DeepSeek 实现
</span><span class="k">class</span> <span class="nc">DeepSeekProvider</span><span class="p">(</span><span class="n">BaseLLMProvider</span><span class="p">):</span>
    <span class="k">def</span> <span class="nf">__init__</span><span class="p">(</span><span class="n">self</span><span class="p">,</span> <span class="n">api_key</span><span class="p">:</span> <span class="nb">str</span><span class="p">,</span> <span class="n">model</span><span class="p">:</span> <span class="nb">str</span><span class="p">,</span> <span class="n">base_url</span><span class="p">:</span> <span class="nb">str</span><span class="p">):</span>
        <span class="n">self</span><span class="p">.</span><span class="n">client</span> <span class="o">=</span> <span class="nc">OpenAI</span><span class="p">(</span>
            <span class="n">api_key</span><span class="o">=</span><span class="n">api_key</span><span class="p">,</span>
            <span class="n">base_url</span><span class="o">=</span><span class="n">base_url</span>  <span class="c1"># 硅基流动: https://api.siliconflow.cn/v1
</span>        <span class="p">)</span>
        <span class="n">self</span><span class="p">.</span><span class="n">model</span> <span class="o">=</span> <span class="n">model</span>
    
    <span class="k">def</span> <span class="nf">query</span><span class="p">(</span><span class="n">self</span><span class="p">,</span> <span class="n">messages</span><span class="p">:</span> <span class="n">List</span><span class="p">[</span><span class="n">Dict</span><span class="p">])</span> <span class="o">-&gt;</span> <span class="nb">str</span><span class="p">:</span>
        <span class="n">response</span> <span class="o">=</span> <span class="n">self</span><span class="p">.</span><span class="n">client</span><span class="p">.</span><span class="n">chat</span><span class="p">.</span><span class="n">completions</span><span class="p">.</span><span class="nf">create</span><span class="p">(</span>
            <span class="n">model</span><span class="o">=</span><span class="n">self</span><span class="p">.</span><span class="n">model</span><span class="p">,</span>
            <span class="n">messages</span><span class="o">=</span><span class="n">messages</span>
        <span class="p">)</span>
        <span class="k">return</span> <span class="n">response</span><span class="p">.</span><span class="n">choices</span><span class="p">[</span><span class="mi">0</span><span class="p">].</span><span class="n">message</span><span class="p">.</span><span class="n">content</span>
</code></pre></div></div>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># models/factory.py - 工厂函数
</span><span class="n">PROVIDERS</span> <span class="o">=</span> <span class="p">{</span>
    <span class="sh">"</span><span class="s">deepseek</span><span class="sh">"</span><span class="p">:</span> <span class="n">DeepSeekProvider</span><span class="p">,</span>
    <span class="sh">"</span><span class="s">zhipu</span><span class="sh">"</span><span class="p">:</span> <span class="n">ZhipuProvider</span><span class="p">,</span>      <span class="c1"># 未来支持
</span>    <span class="sh">"</span><span class="s">anthropic</span><span class="sh">"</span><span class="p">:</span> <span class="n">AnthropicProvider</span><span class="p">,</span>  <span class="c1"># 未来支持
</span><span class="p">}</span>

<span class="k">def</span> <span class="nf">create_provider</span><span class="p">(</span><span class="n">provider_name</span><span class="p">:</span> <span class="nb">str</span><span class="p">,</span> <span class="n">api_key</span><span class="p">:</span> <span class="nb">str</span><span class="p">,</span> <span class="o">**</span><span class="n">kwargs</span><span class="p">):</span>
    <span class="k">return</span> <span class="n">PROVIDERS</span><span class="p">[</span><span class="n">provider_name</span><span class="p">](</span><span class="n">api_key</span><span class="p">,</span> <span class="o">**</span><span class="n">kwargs</span><span class="p">)</span>
</code></pre></div></div>

<p><strong>好处</strong>：</p>
<ul>
  <li>新增模型只需实现 3 个方法</li>
  <li>切换模型只需修改配置</li>
  <li>统一接口，代码清晰</li>
</ul>

<h3 id="23-action-解析器">2.3 Action 解析器</h3>

<p>从 LLM 输出中提取结构化命令：</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># utils/parser.py
</span><span class="k">class</span> <span class="nc">ActionParser</span><span class="p">:</span>
    <span class="nd">@staticmethod</span>
    <span class="k">def</span> <span class="nf">parse_bash_action</span><span class="p">(</span><span class="n">text</span><span class="p">:</span> <span class="nb">str</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="n">Optional</span><span class="p">[</span><span class="nb">str</span><span class="p">]:</span>
        <span class="sh">"""</span><span class="s">解析 bash 命令：```bash-action</span><span class="se">\n</span><span class="s">&lt;command&gt;</span><span class="se">\n</span><span class="s">```</span><span class="sh">"""</span>
        <span class="n">matches</span> <span class="o">=</span> <span class="n">re</span><span class="p">.</span><span class="nf">findall</span><span class="p">(</span>
            <span class="sa">r</span><span class="sh">"</span><span class="s">```bash-action\s*\n(.*?)\n```</span><span class="sh">"</span><span class="p">,</span>
            <span class="n">text</span><span class="p">,</span>
            <span class="n">re</span><span class="p">.</span><span class="n">DOTALL</span>
        <span class="p">)</span>
        <span class="k">return</span> <span class="n">matches</span><span class="p">[</span><span class="mi">0</span><span class="p">].</span><span class="nf">strip</span><span class="p">()</span> <span class="k">if</span> <span class="n">matches</span> <span class="k">else</span> <span class="bp">None</span>
    
    <span class="nd">@staticmethod</span>
    <span class="k">def</span> <span class="nf">parse_read_file</span><span class="p">(</span><span class="n">text</span><span class="p">:</span> <span class="nb">str</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="n">Optional</span><span class="p">[</span><span class="nb">str</span><span class="p">]:</span>
        <span class="sh">"""</span><span class="s">解析读取文件：```read-file</span><span class="se">\n</span><span class="s">&lt;filepath&gt;</span><span class="se">\n</span><span class="s">```</span><span class="sh">"""</span>
        <span class="n">matches</span> <span class="o">=</span> <span class="n">re</span><span class="p">.</span><span class="nf">findall</span><span class="p">(</span>
            <span class="sa">r</span><span class="sh">"</span><span class="s">```read-file\s*\n(.*?)\n```</span><span class="sh">"</span><span class="p">,</span>
            <span class="n">text</span><span class="p">,</span>
            <span class="n">re</span><span class="p">.</span><span class="n">DOTALL</span>
        <span class="p">)</span>
        <span class="k">return</span> <span class="n">matches</span><span class="p">[</span><span class="mi">0</span><span class="p">].</span><span class="nf">strip</span><span class="p">()</span> <span class="k">if</span> <span class="n">matches</span> <span class="k">else</span> <span class="bp">None</span>
    
    <span class="nd">@staticmethod</span>
    <span class="k">def</span> <span class="nf">parse_write_file</span><span class="p">(</span><span class="n">text</span><span class="p">:</span> <span class="nb">str</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="n">Optional</span><span class="p">[</span><span class="n">Dict</span><span class="p">]:</span>
        <span class="sh">"""</span><span class="s">解析写入文件：```write-file</span><span class="se">\n</span><span class="s">&lt;filepath&gt;</span><span class="se">\n</span><span class="s">---</span><span class="se">\n</span><span class="s">&lt;content&gt;</span><span class="se">\n</span><span class="s">```</span><span class="sh">"""</span>
        <span class="n">matches</span> <span class="o">=</span> <span class="n">re</span><span class="p">.</span><span class="nf">findall</span><span class="p">(</span>
            <span class="sa">r</span><span class="sh">"</span><span class="s">```write-file\s*\n(.*?)\n---\n(.*?)\n```</span><span class="sh">"</span><span class="p">,</span>
            <span class="n">text</span><span class="p">,</span>
            <span class="n">re</span><span class="p">.</span><span class="n">DOTALL</span>
        <span class="p">)</span>
        <span class="k">if</span> <span class="n">matches</span><span class="p">:</span>
            <span class="n">filepath</span><span class="p">,</span> <span class="n">content</span> <span class="o">=</span> <span class="n">matches</span><span class="p">[</span><span class="mi">0</span><span class="p">]</span>
            <span class="k">return</span> <span class="p">{</span><span class="sh">"</span><span class="s">path</span><span class="sh">"</span><span class="p">:</span> <span class="n">filepath</span><span class="p">.</span><span class="nf">strip</span><span class="p">(),</span> <span class="sh">"</span><span class="s">content</span><span class="sh">"</span><span class="p">:</span> <span class="n">content</span><span class="p">}</span>
        <span class="k">return</span> <span class="bp">None</span>
</code></pre></div></div>

<p>支持多种 Action 类型：</p>
<ul>
  <li><code class="language-plaintext highlighter-rouge">bash-action</code> - 执行 shell 命令</li>
  <li><code class="language-plaintext highlighter-rouge">read-file</code> - 读取文件</li>
  <li><code class="language-plaintext highlighter-rouge">write-file</code> - 写入文件</li>
  <li><code class="language-plaintext highlighter-rouge">exit</code> - 完成任务</li>
</ul>

<h3 id="24-工具系统">2.4 工具系统</h3>

<p><strong>命令执行器</strong>（<code class="language-plaintext highlighter-rouge">tools/execute.py</code>）：</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">class</span> <span class="nc">CommandExecutor</span><span class="p">:</span>
    <span class="k">def</span> <span class="nf">execute</span><span class="p">(</span><span class="n">self</span><span class="p">,</span> <span class="n">command</span><span class="p">:</span> <span class="nb">str</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="n">Dict</span><span class="p">:</span>
        <span class="n">result</span> <span class="o">=</span> <span class="n">subprocess</span><span class="p">.</span><span class="nf">run</span><span class="p">(</span>
            <span class="n">command</span><span class="p">,</span>
            <span class="n">shell</span><span class="o">=</span><span class="bp">True</span><span class="p">,</span>
            <span class="n">text</span><span class="o">=</span><span class="bp">True</span><span class="p">,</span>
            <span class="n">stdout</span><span class="o">=</span><span class="n">subprocess</span><span class="p">.</span><span class="n">PIPE</span><span class="p">,</span>
            <span class="n">stderr</span><span class="o">=</span><span class="n">subprocess</span><span class="p">.</span><span class="n">PIPE</span><span class="p">,</span>
            <span class="n">timeout</span><span class="o">=</span><span class="mi">30</span><span class="p">,</span>
            <span class="n">env</span><span class="o">=</span><span class="n">self</span><span class="p">.</span><span class="n">env</span>  <span class="c1"># 禁用交互式工具
</span>        <span class="p">)</span>
        <span class="k">return</span> <span class="p">{</span>
            <span class="sh">"</span><span class="s">stdout</span><span class="sh">"</span><span class="p">:</span> <span class="n">result</span><span class="p">.</span><span class="n">stdout</span><span class="p">,</span>
            <span class="sh">"</span><span class="s">stderr</span><span class="sh">"</span><span class="p">:</span> <span class="n">result</span><span class="p">.</span><span class="n">stderr</span><span class="p">,</span>
            <span class="sh">"</span><span class="s">returncode</span><span class="sh">"</span><span class="p">:</span> <span class="n">result</span><span class="p">.</span><span class="n">returncode</span><span class="p">,</span>
            <span class="sh">"</span><span class="s">success</span><span class="sh">"</span><span class="p">:</span> <span class="n">result</span><span class="p">.</span><span class="n">returncode</span> <span class="o">==</span> <span class="mi">0</span>
        <span class="p">}</span>
</code></pre></div></div>

<p><strong>文件操作</strong>（<code class="language-plaintext highlighter-rouge">tools/file_ops.py</code>）：</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">class</span> <span class="nc">FileOperations</span><span class="p">:</span>
    <span class="nd">@staticmethod</span>
    <span class="k">def</span> <span class="nf">read_file</span><span class="p">(</span><span class="n">filepath</span><span class="p">:</span> <span class="nb">str</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="nb">str</span><span class="p">:</span>
        <span class="sh">"""</span><span class="s">读取文件，带行号</span><span class="sh">"""</span>
        <span class="k">with</span> <span class="nf">open</span><span class="p">(</span><span class="n">filepath</span><span class="p">,</span> <span class="sh">'</span><span class="s">r</span><span class="sh">'</span><span class="p">,</span> <span class="n">encoding</span><span class="o">=</span><span class="sh">'</span><span class="s">utf-8</span><span class="sh">'</span><span class="p">)</span> <span class="k">as</span> <span class="n">f</span><span class="p">:</span>
            <span class="n">content</span> <span class="o">=</span> <span class="n">f</span><span class="p">.</span><span class="nf">read</span><span class="p">()</span>
        <span class="n">lines</span> <span class="o">=</span> <span class="n">content</span><span class="p">.</span><span class="nf">split</span><span class="p">(</span><span class="sh">'</span><span class="se">\n</span><span class="sh">'</span><span class="p">)</span>
        <span class="n">numbered</span> <span class="o">=</span> <span class="sh">'</span><span class="se">\n</span><span class="sh">'</span><span class="p">.</span><span class="nf">join</span><span class="p">([</span><span class="sa">f</span><span class="sh">"</span><span class="si">{</span><span class="n">i</span><span class="o">+</span><span class="mi">1</span><span class="si">:</span><span class="mi">4</span><span class="n">d</span><span class="si">}</span><span class="s"> | </span><span class="si">{</span><span class="n">line</span><span class="si">}</span><span class="sh">"</span> <span class="k">for</span> <span class="n">i</span><span class="p">,</span> <span class="n">line</span> <span class="ow">in</span> <span class="nf">enumerate</span><span class="p">(</span><span class="n">lines</span><span class="p">)])</span>
        <span class="k">return</span> <span class="sa">f</span><span class="sh">"</span><span class="s">📄 </span><span class="si">{</span><span class="n">filepath</span><span class="si">}</span><span class="se">\n</span><span class="si">{</span><span class="n">numbered</span><span class="si">}</span><span class="sh">"</span>
    
    <span class="nd">@staticmethod</span>
    <span class="k">def</span> <span class="nf">write_file</span><span class="p">(</span><span class="n">filepath</span><span class="p">:</span> <span class="nb">str</span><span class="p">,</span> <span class="n">content</span><span class="p">:</span> <span class="nb">str</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="nb">str</span><span class="p">:</span>
        <span class="sh">"""</span><span class="s">写入文件，自动创建目录</span><span class="sh">"""</span>
        <span class="n">directory</span> <span class="o">=</span> <span class="n">os</span><span class="p">.</span><span class="n">path</span><span class="p">.</span><span class="nf">dirname</span><span class="p">(</span><span class="n">filepath</span><span class="p">)</span>
        <span class="k">if</span> <span class="n">directory</span> <span class="ow">and</span> <span class="ow">not</span> <span class="n">os</span><span class="p">.</span><span class="n">path</span><span class="p">.</span><span class="nf">exists</span><span class="p">(</span><span class="n">directory</span><span class="p">):</span>
            <span class="n">os</span><span class="p">.</span><span class="nf">makedirs</span><span class="p">(</span><span class="n">directory</span><span class="p">)</span>
        <span class="k">with</span> <span class="nf">open</span><span class="p">(</span><span class="n">filepath</span><span class="p">,</span> <span class="sh">'</span><span class="s">w</span><span class="sh">'</span><span class="p">,</span> <span class="n">encoding</span><span class="o">=</span><span class="sh">'</span><span class="s">utf-8</span><span class="sh">'</span><span class="p">)</span> <span class="k">as</span> <span class="n">f</span><span class="p">:</span>
            <span class="n">f</span><span class="p">.</span><span class="nf">write</span><span class="p">(</span><span class="n">content</span><span class="p">)</span>
        <span class="k">return</span> <span class="sa">f</span><span class="sh">"</span><span class="s">✅ 已写入 </span><span class="si">{</span><span class="n">filepath</span><span class="si">}</span><span class="sh">"</span>
</code></pre></div></div>

<hr />

<h2 id="三核心实现300-行代码的-agent">三、核心实现：300 行代码的 Agent</h2>

<h3 id="31-主循环">3.1 主循环</h3>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># agent.py
</span><span class="k">class</span> <span class="nc">MeowCodeAgent</span><span class="p">:</span>
    <span class="k">def</span> <span class="nf">run</span><span class="p">(</span><span class="n">self</span><span class="p">,</span> <span class="n">user_task</span><span class="p">:</span> <span class="nb">str</span><span class="p">):</span>
        <span class="c1"># 初始化对话
</span>        <span class="n">self</span><span class="p">.</span><span class="n">messages</span> <span class="o">=</span> <span class="p">[</span>
            <span class="p">{</span><span class="sh">"</span><span class="s">role</span><span class="sh">"</span><span class="p">:</span> <span class="sh">"</span><span class="s">system</span><span class="sh">"</span><span class="p">,</span> <span class="sh">"</span><span class="s">content</span><span class="sh">"</span><span class="p">:</span> <span class="n">self</span><span class="p">.</span><span class="n">system_prompt</span><span class="p">},</span>
            <span class="p">{</span><span class="sh">"</span><span class="s">role</span><span class="sh">"</span><span class="p">:</span> <span class="sh">"</span><span class="s">user</span><span class="sh">"</span><span class="p">,</span> <span class="sh">"</span><span class="s">content</span><span class="sh">"</span><span class="p">:</span> <span class="n">user_task</span><span class="p">}</span>
        <span class="p">]</span>
        
        <span class="k">for</span> <span class="n">iteration</span> <span class="ow">in</span> <span class="nf">range</span><span class="p">(</span><span class="n">self</span><span class="p">.</span><span class="n">max_iterations</span><span class="p">):</span>
            <span class="c1"># 1. 查询 LLM
</span>            <span class="n">lm_output</span> <span class="o">=</span> <span class="n">self</span><span class="p">.</span><span class="n">provider</span><span class="p">.</span><span class="nf">query</span><span class="p">(</span><span class="n">self</span><span class="p">.</span><span class="n">messages</span><span class="p">)</span>
            <span class="n">self</span><span class="p">.</span><span class="n">messages</span><span class="p">.</span><span class="nf">append</span><span class="p">({</span><span class="sh">"</span><span class="s">role</span><span class="sh">"</span><span class="p">:</span> <span class="sh">"</span><span class="s">assistant</span><span class="sh">"</span><span class="p">,</span> <span class="sh">"</span><span class="s">content</span><span class="sh">"</span><span class="p">:</span> <span class="n">lm_output</span><span class="p">})</span>
            
            <span class="c1"># 2. 解析 action
</span>            <span class="n">action</span> <span class="o">=</span> <span class="n">self</span><span class="p">.</span><span class="n">parser</span><span class="p">.</span><span class="nf">parse_action</span><span class="p">(</span><span class="n">lm_output</span><span class="p">)</span>
            <span class="k">if</span> <span class="ow">not</span> <span class="n">action</span><span class="p">:</span>
                <span class="k">continue</span>
            
            <span class="k">if</span> <span class="n">action</span><span class="p">[</span><span class="sh">"</span><span class="s">type</span><span class="sh">"</span><span class="p">]</span> <span class="o">==</span> <span class="sh">"</span><span class="s">exit</span><span class="sh">"</span><span class="p">:</span>
                <span class="k">break</span>
            
            <span class="c1"># 3. 执行 action
</span>            <span class="n">result</span> <span class="o">=</span> <span class="n">self</span><span class="p">.</span><span class="nf">execute_action</span><span class="p">(</span><span class="n">action</span><span class="p">)</span>
            
            <span class="c1"># 4. 反馈结果
</span>            <span class="n">self</span><span class="p">.</span><span class="n">messages</span><span class="p">.</span><span class="nf">append</span><span class="p">({</span><span class="sh">"</span><span class="s">role</span><span class="sh">"</span><span class="p">:</span> <span class="sh">"</span><span class="s">user</span><span class="sh">"</span><span class="p">,</span> <span class="sh">"</span><span class="s">content</span><span class="sh">"</span><span class="p">:</span> <span class="n">result</span><span class="p">})</span>
</code></pre></div></div>

<h3 id="32-配置系统">3.2 配置系统</h3>

<p>使用 YAML + 环境变量：</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># config/config.yaml</span>
<span class="na">llm</span><span class="pi">:</span>
  <span class="na">provider</span><span class="pi">:</span> <span class="s">deepseek</span>
  <span class="na">deepseek</span><span class="pi">:</span>
    <span class="na">api_key</span><span class="pi">:</span> <span class="s2">"</span><span class="s">"</span>  <span class="c1"># 或从环境变量 DEEPSEEK_API_KEY 读取</span>
    <span class="na">model</span><span class="pi">:</span> <span class="s">deepseek-ai/DeepSeek-V3.2</span>
    <span class="na">base_url</span><span class="pi">:</span> <span class="s">https://api.siliconflow.cn/v1</span>

<span class="na">agent</span><span class="pi">:</span>
  <span class="na">system_prompt</span><span class="pi">:</span> <span class="pi">|</span>
    <span class="s">You are a helpful coding assistant.</span>
    <span class="s">When you want to run a command, wrap it in ```bash-action</span>
    <span class="s">&lt;command&gt;</span>
    <span class="s">```</span>
  <span class="na">max_iterations</span><span class="pi">:</span> <span class="m">20</span>
  <span class="na">command_timeout</span><span class="pi">:</span> <span class="m">30</span>
</code></pre></div></div>

<p><strong>配置加载器</strong>自动合并环境变量：</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">class</span> <span class="nc">Config</span><span class="p">:</span>
    <span class="k">def</span> <span class="nf">get_llm_config</span><span class="p">(</span><span class="n">self</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="n">Dict</span><span class="p">:</span>
        <span class="n">provider</span> <span class="o">=</span> <span class="n">self</span><span class="p">.</span><span class="nf">get</span><span class="p">(</span><span class="sh">'</span><span class="s">llm.provider</span><span class="sh">'</span><span class="p">,</span> <span class="sh">'</span><span class="s">deepseek</span><span class="sh">'</span><span class="p">)</span>
        <span class="n">provider_config</span> <span class="o">=</span> <span class="n">self</span><span class="p">.</span><span class="nf">get</span><span class="p">(</span><span class="sa">f</span><span class="sh">'</span><span class="s">llm.</span><span class="si">{</span><span class="n">provider</span><span class="si">}</span><span class="sh">'</span><span class="p">,</span> <span class="p">{})</span>
        
        <span class="c1"># 优先从环境变量读取 API Key
</span>        <span class="n">env_key</span> <span class="o">=</span> <span class="sa">f</span><span class="sh">"</span><span class="si">{</span><span class="n">provider</span><span class="p">.</span><span class="nf">upper</span><span class="p">()</span><span class="si">}</span><span class="s">_API_KEY</span><span class="sh">"</span>
        <span class="k">if</span> <span class="n">env_key</span> <span class="ow">in</span> <span class="n">os</span><span class="p">.</span><span class="n">environ</span><span class="p">:</span>
            <span class="n">provider_config</span><span class="p">[</span><span class="sh">'</span><span class="s">api_key</span><span class="sh">'</span><span class="p">]</span> <span class="o">=</span> <span class="n">os</span><span class="p">.</span><span class="n">environ</span><span class="p">[</span><span class="n">env_key</span><span class="p">]</span>
        
        <span class="k">return</span> <span class="p">{</span><span class="sh">"</span><span class="s">provider</span><span class="sh">"</span><span class="p">:</span> <span class="n">provider</span><span class="p">,</span> <span class="o">**</span><span class="n">provider_config</span><span class="p">}</span>
</code></pre></div></div>

<hr />

<h2 id="四测试与验证">四、测试与验证</h2>

<h3 id="41-api-连接测试">4.1 API 连接测试</h3>

<p><strong>硅基流动配置</strong>：</p>
<ul>
  <li>Base URL: <code class="language-plaintext highlighter-rouge">https://api.siliconflow.cn/v1</code></li>
  <li>模型: <code class="language-plaintext highlighter-rouge">deepseek-ai/DeepSeek-V3.2</code></li>
  <li>兼容 OpenAI SDK</li>
</ul>

<p><strong>测试代码</strong>：</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kn">from</span> <span class="n">openai</span> <span class="kn">import</span> <span class="n">OpenAI</span>

<span class="n">client</span> <span class="o">=</span> <span class="nc">OpenAI</span><span class="p">(</span>
    <span class="n">api_key</span><span class="o">=</span><span class="sh">"</span><span class="s">sk-xxx</span><span class="sh">"</span><span class="p">,</span>
    <span class="n">base_url</span><span class="o">=</span><span class="sh">"</span><span class="s">https://api.siliconflow.cn/v1</span><span class="sh">"</span>
<span class="p">)</span>

<span class="n">response</span> <span class="o">=</span> <span class="n">client</span><span class="p">.</span><span class="n">chat</span><span class="p">.</span><span class="n">completions</span><span class="p">.</span><span class="nf">create</span><span class="p">(</span>
    <span class="n">model</span><span class="o">=</span><span class="sh">"</span><span class="s">deepseek-ai/DeepSeek-V3.2</span><span class="sh">"</span><span class="p">,</span>
    <span class="n">messages</span><span class="o">=</span><span class="p">[</span>
        <span class="p">{</span><span class="sh">"</span><span class="s">role</span><span class="sh">"</span><span class="p">:</span> <span class="sh">"</span><span class="s">system</span><span class="sh">"</span><span class="p">,</span> <span class="sh">"</span><span class="s">content</span><span class="sh">"</span><span class="p">:</span> <span class="sh">"</span><span class="s">你是一个有帮助的助手。</span><span class="sh">"</span><span class="p">},</span>
        <span class="p">{</span><span class="sh">"</span><span class="s">role</span><span class="sh">"</span><span class="p">:</span> <span class="sh">"</span><span class="s">user</span><span class="sh">"</span><span class="p">,</span> <span class="sh">"</span><span class="s">content</span><span class="sh">"</span><span class="p">:</span> <span class="sh">"</span><span class="s">你好，请用一句话介绍你自己。</span><span class="sh">"</span><span class="p">}</span>
    <span class="p">]</span>
<span class="p">)</span>
</code></pre></div></div>

<p><strong>测试结果</strong>：</p>
<ul>
  <li>✅ API 调用成功</li>
  <li>✅ 响应速度快（&lt;2秒）</li>
  <li>✅ Token 使用：36 tokens（输入 18 + 输出 18）</li>
</ul>

<h3 id="42-action-解析测试">4.2 Action 解析测试</h3>

<p><strong>任务</strong>：”列出当前目录的文件”</p>

<p><strong>LLM 响应</strong>：</p>
<div class="language-markdown highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="se">\`</span><span class="sb">``bash-action
ls
\```</span>
</code></pre></div></div>

<p><strong>解析结果</strong>：</p>
<ul>
  <li>✅ 类型：bash</li>
  <li>✅ 数据：ls</li>
</ul>

<h3 id="43-完整流程测试">4.3 完整流程测试</h3>

<p><strong>测试场景</strong>：列出当前目录文件并生成总结</p>

<p><strong>执行流程</strong>：</p>

<ol>
  <li>
    <p><strong>用户输入</strong>：”列出当前目录的文件”</p>
  </li>
  <li><strong>Agent 第 1 轮</strong>：
    <ul>
      <li>生成命令：<code class="language-plaintext highlighter-rouge">ls -la</code></li>
      <li>执行并返回结果</li>
    </ul>
  </li>
  <li><strong>Agent 第 2 轮</strong>：
    <ul>
      <li>解析文件列表</li>
      <li>生成结构化总结：</li>
    </ul>

    <div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>当前目录包含以下文件和子目录：
   
## 文件：
- .env.example - 环境变量配置示例文件
- QUICKSTART.md - 快速开始指南
- agent.py - 主要的智能体脚本
   
## 子目录：
- config/ - 配置文件目录
- docs/ - 文档目录
- models/ - 模型相关文件目录
- tools/ - 工具函数目录
</code></pre></div>    </div>
  </li>
</ol>

<p><strong>测试结果</strong>：</p>
<ul>
  <li>✅ 完整循环正常运行</li>
  <li>✅ 正确执行 bash 命令</li>
  <li>✅ 理解命令输出</li>
  <li>✅ 生成清晰的结构化总结</li>
</ul>

<h3 id="44-性能评估">4.4 性能评估</h3>

<p><strong>响应速度</strong>：</p>
<ul>
  <li>单次 API 调用：~1-2秒</li>
  <li>完整循环（2轮）：~3-4秒</li>
</ul>

<p><strong>输出质量</strong>：</p>
<ul>
  <li>理解能力：⭐⭐⭐⭐⭐</li>
  <li>格式遵守：⭐⭐⭐⭐⭐</li>
  <li>内容质量：⭐⭐⭐⭐⭐</li>
</ul>

<p><strong>成本</strong>：</p>
<ul>
  <li>简单任务（列出文件）：~100 tokens</li>
  <li>估算成本：&lt;¥0.001/次</li>
</ul>

<hr />

<h2 id="五项目管理git--github">五、项目管理：Git + GitHub</h2>

<h3 id="51-仓库初始化">5.1 仓库初始化</h3>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># 初始化本地仓库</span>
git init
git add <span class="nb">.</span>
git commit <span class="nt">-m</span> <span class="s2">"feat: 初始化 MeowCode Agent 项目"</span>

<span class="c"># 创建 GitHub 仓库并推送</span>
gh repo create meowcode <span class="nt">--public</span> <span class="nt">--source</span><span class="o">=</span><span class="nb">.</span> <span class="nt">--push</span>
</code></pre></div></div>

<p><strong>仓库地址</strong>：https://github.com/AriesOxO/meowcode</p>

<h3 id="52-目录结构">5.2 目录结构</h3>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>meowcode/
├── agent.py              # 主入口
├── models/               # LLM 提供商
│   ├── base.py          # 抽象基类
│   ├── deepseek.py      # DeepSeek 实现
│   └── factory.py       # 工厂函数
├── tools/                # 工具系统
│   ├── execute.py       # 命令执行
│   └── file_ops.py      # 文件操作
├── utils/                # 工具函数
│   ├── parser.py        # Action 解析
│   └── config.py        # 配置加载
├── config/               # 配置文件
│   └── config.example.yaml
├── docs/                 # 文档
│   ├── 设计/            # 架构设计文档
│   ├── 参考文档/        # 学习资料
│   ├── 测试/            # 测试报告
│   └── blog/            # 开发博客
└── requirements.txt      # 依赖（只有 2 个！）
</code></pre></div></div>

<h3 id="53-代码统计">5.3 代码统计</h3>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Python 文件：12 个
代码行数：~300 行（核心）
总行数：2932 行（含文档）
提交数：3 次
开发时间：1 天
</code></pre></div></div>

<hr />

<h2 id="六遇到的问题与解决">六、遇到的问题与解决</h2>

<h3 id="61-windows-编码问题">6.1 Windows 编码问题</h3>

<p><strong>现象</strong>：<code class="language-plaintext highlighter-rouge">UnicodeEncodeError: 'gbk' codec can't encode character</code></p>

<p><strong>原因</strong>：Windows 默认使用 GBK 编码，无法显示 emoji</p>

<p><strong>解决</strong>：</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kn">import</span> <span class="n">sys</span>
<span class="kn">import</span> <span class="n">io</span>

<span class="k">if</span> <span class="n">sys</span><span class="p">.</span><span class="n">platform</span> <span class="o">==</span> <span class="sh">'</span><span class="s">win32</span><span class="sh">'</span><span class="p">:</span>
    <span class="n">sys</span><span class="p">.</span><span class="n">stdout</span> <span class="o">=</span> <span class="n">io</span><span class="p">.</span><span class="nc">TextIOWrapper</span><span class="p">(</span><span class="n">sys</span><span class="p">.</span><span class="n">stdout</span><span class="p">.</span><span class="nb">buffer</span><span class="p">,</span> <span class="n">encoding</span><span class="o">=</span><span class="sh">'</span><span class="s">utf-8</span><span class="sh">'</span><span class="p">)</span>
</code></pre></div></div>

<h3 id="62-依赖管理">6.2 依赖管理</h3>

<p>初始只需要 <code class="language-plaintext highlighter-rouge">openai</code>，后来发现配置系统需要 <code class="language-plaintext highlighter-rouge">pyyaml</code>：</p>

<pre><code class="language-txt">openai&gt;=1.0.0
pyyaml&gt;=6.0
</code></pre>

<p><strong>极简依赖策略</strong>：能用标准库就不加依赖。</p>

<hr />

<h2 id="总结">总结</h2>

<h3 id="关键决策">关键决策</h3>

<ol>
  <li><strong>Python + DeepSeek</strong>：开发速度快，成本极低</li>
  <li><strong>抽象层设计</strong>：为未来多模型支持打好基础</li>
  <li><strong>极简依赖</strong>：只有 2 个外部依赖</li>
  <li><strong>CLI 优先</strong>：简单高效，适合开发者</li>
</ol>

<h3 id="技术亮点">技术亮点</h3>

<ol>
  <li><strong>300 行核心代码</strong>实现完整 Agent 循环</li>
  <li><strong>工厂模式</strong>支持多模型扩展</li>
  <li><strong>模块化设计</strong>，职责清晰</li>
  <li><strong>测试完备</strong>：API/逻辑/流程 三层验证</li>
</ol>

<h3 id="成本优势">成本优势</h3>

<p>DeepSeek 让个人开发者也能负担 AI Agent：</p>
<ul>
  <li>单次调用：&lt;¥0.001</li>
  <li>100 次修复：¥0.18</li>
  <li>vs Claude：便宜 <strong>300 倍</strong></li>
</ul>

<h3 id="下一步计划">下一步计划</h3>

<ul class="task-list">
  <li class="task-list-item"><input type="checkbox" class="task-list-item-checkbox" disabled="disabled" />添加代码搜索功能</li>
  <li class="task-list-item"><input type="checkbox" class="task-list-item-checkbox" disabled="disabled" />实现错误重试机制</li>
  <li class="task-list-item"><input type="checkbox" class="task-list-item-checkbox" disabled="disabled" />集成智谱 GLM</li>
  <li class="task-list-item"><input type="checkbox" class="task-list-item-checkbox" disabled="disabled" />PyInstaller 打包</li>
  <li class="task-list-item"><input type="checkbox" class="task-list-item-checkbox" disabled="disabled" />跨平台测试</li>
</ul>

<hr />

<p><strong>项目地址</strong>：https://github.com/AriesOxO/meowcode</p>

<p>欢迎 Star 和贡献！下一篇将记录代码搜索功能的实现过程。</p>]]></content><author><name>meow</name></author><category term="开发记录" /><category term="AI工具" /><category term="MeowCode" /><category term="AI Agent" /><category term="DeepSeek" /><category term="Python" /><category term="编码助手" /><summary type="html"><![CDATA[用了一段时间 Claude Code，被 AI 编码助手的能力震撼，但也有些想法：能否支持更多模型？能否更轻量？能否自己掌控？于是决定从零开始做一个 MeowCode —— 不是要替代 Claude Code，而是深入理解 AI Agent 的设计原理，做一个够用的编码助手。 本文记录项目初始化的完整过程：技术选型、架构设计、核心实现和 API 测试。 一、技术选型：Python + DeepSeek 1.1 编程语言选择 考虑过 Go、Rust，但最终选择 Python： 优势明显： AI 生态最成熟（OpenAI SDK、Anthropic SDK 都是 Python 优先） 开发速度快（1-2 周就能做出 MVP） 标准库强大（文件操作、正则、subprocess 开箱即用） 打包方案成熟（PyInstaller / Nuitka） 权衡成本： 打包体积大（20-40MB） 启动速度慢（1-2秒） 对于 MVP 阶段，开发效率优先于运行效率。 1.2 LLM 提供商：DeepSeek 起步 价格对比（修复 100 个 Bug 的实际成本）： 模型 单次成本 100次成本 DeepSeek v4-flash ¥0.0018 ¥0.18 GLM-4-flash ¥0.0015 ¥0.15 Claude Sonnet 4.5 ¥0.54 ¥54 作为个人开发者，DeepSeek 的性价比无敌 —— 比 Claude 便宜 300 倍！ 架构设计支持未来扩展： 智谱 GLM（备选方案） Anthropic Claude（高端场景） OpenAI GPT（兼容性考虑） 1.3 界面：CLI 优先 选择命令行界面而不是 GUI： ✅ 简单高效，开发成本低 ✅ 适合开发者使用场景 ✅ 易于自动化和集成 二、架构设计：多模型抽象层 2.1 核心循环 Agent 的本质是一个反馈循环： 用户输入任务 ↓ ┌─────────────────┐ │ 查询 LLM │ └────────┬────────┘ ↓ ┌─────────────────┐ │ 解析 Action │ → bash / read_file / write_file / exit └────────┬────────┘ ↓ ┌─────────────────┐ │ 执行 Action │ └────────┬────────┘ ↓ ┌─────────────────┐ │ 反馈结果 │ └────────┬────────┘ │ └─→ 循环，直到完成或超时 2.2 多模型架构：抽象工厂模式 使用抽象基类 + 工厂函数实现多模型支持： # models/base.py - 抽象基类 class BaseLLMProvider(ABC): @abstractmethod def query(self, messages: List[Dict]) -&gt; str: """查询模型""" pass @abstractmethod def get_model_info(self) -&gt; Dict: """获取模型信息（名称、价格、上下文长度）""" pass # models/deepseek.py - DeepSeek 实现 class DeepSeekProvider(BaseLLMProvider): def __init__(self, api_key: str, model: str, base_url: str): self.client = OpenAI( api_key=api_key, base_url=base_url # 硅基流动: https://api.siliconflow.cn/v1 ) self.model = model def query(self, messages: List[Dict]) -&gt; str: response = self.client.chat.completions.create( model=self.model, messages=messages ) return response.choices[0].message.content # models/factory.py - 工厂函数 PROVIDERS = { "deepseek": DeepSeekProvider, "zhipu": ZhipuProvider, # 未来支持 "anthropic": AnthropicProvider, # 未来支持 } def create_provider(provider_name: str, api_key: str, **kwargs): return PROVIDERS[provider_name](api_key, **kwargs) 好处： 新增模型只需实现 3 个方法 切换模型只需修改配置 统一接口，代码清晰 2.3 Action 解析器 从 LLM 输出中提取结构化命令： # utils/parser.py class ActionParser: @staticmethod def parse_bash_action(text: str) -&gt; Optional[str]: """解析 bash 命令：```bash-action\n&lt;command&gt;\n```""" matches = re.findall( r"```bash-action\s*\n(.*?)\n```", text, re.DOTALL ) return matches[0].strip() if matches else None @staticmethod def parse_read_file(text: str) -&gt; Optional[str]: """解析读取文件：```read-file\n&lt;filepath&gt;\n```""" matches = re.findall( r"```read-file\s*\n(.*?)\n```", text, re.DOTALL ) return matches[0].strip() if matches else None @staticmethod def parse_write_file(text: str) -&gt; Optional[Dict]: """解析写入文件：```write-file\n&lt;filepath&gt;\n---\n&lt;content&gt;\n```""" matches = re.findall( r"```write-file\s*\n(.*?)\n---\n(.*?)\n```", text, re.DOTALL ) if matches: filepath, content = matches[0] return {"path": filepath.strip(), "content": content} return None 支持多种 Action 类型： bash-action - 执行 shell 命令 read-file - 读取文件 write-file - 写入文件 exit - 完成任务 2.4 工具系统 命令执行器（tools/execute.py）： class CommandExecutor: def execute(self, command: str) -&gt; Dict: result = subprocess.run( command, shell=True, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=30, env=self.env # 禁用交互式工具 ) return { "stdout": result.stdout, "stderr": result.stderr, "returncode": result.returncode, "success": result.returncode == 0 } 文件操作（tools/file_ops.py）： class FileOperations: @staticmethod def read_file(filepath: str) -&gt; str: """读取文件，带行号""" with open(filepath, 'r', encoding='utf-8') as f: content = f.read() lines = content.split('\n') numbered = '\n'.join([f"{i+1:4d} | {line}" for i, line in enumerate(lines)]) return f"📄 {filepath}\n{numbered}" @staticmethod def write_file(filepath: str, content: str) -&gt; str: """写入文件，自动创建目录""" directory = os.path.dirname(filepath) if directory and not os.path.exists(directory): os.makedirs(directory) with open(filepath, 'w', encoding='utf-8') as f: f.write(content) return f"✅ 已写入 {filepath}" 三、核心实现：300 行代码的 Agent 3.1 主循环 # agent.py class MeowCodeAgent: def run(self, user_task: str): # 初始化对话 self.messages = [ {"role": "system", "content": self.system_prompt}, {"role": "user", "content": user_task} ] for iteration in range(self.max_iterations): # 1. 查询 LLM lm_output = self.provider.query(self.messages) self.messages.append({"role": "assistant", "content": lm_output}) # 2. 解析 action action = self.parser.parse_action(lm_output) if not action: continue if action["type"] == "exit": break # 3. 执行 action result = self.execute_action(action) # 4. 反馈结果 self.messages.append({"role": "user", "content": result}) 3.2 配置系统 使用 YAML + 环境变量： # config/config.yaml llm: provider: deepseek deepseek: api_key: "" # 或从环境变量 DEEPSEEK_API_KEY 读取 model: deepseek-ai/DeepSeek-V3.2 base_url: https://api.siliconflow.cn/v1 agent: system_prompt: | You are a helpful coding assistant. When you want to run a command, wrap it in ```bash-action &lt;command&gt; ``` max_iterations: 20 command_timeout: 30 配置加载器自动合并环境变量： class Config: def get_llm_config(self) -&gt; Dict: provider = self.get('llm.provider', 'deepseek') provider_config = self.get(f'llm.{provider}', {}) # 优先从环境变量读取 API Key env_key = f"{provider.upper()}_API_KEY" if env_key in os.environ: provider_config['api_key'] = os.environ[env_key] return {"provider": provider, **provider_config} 四、测试与验证 4.1 API 连接测试 硅基流动配置： Base URL: https://api.siliconflow.cn/v1 模型: deepseek-ai/DeepSeek-V3.2 兼容 OpenAI SDK 测试代码： from openai import OpenAI client = OpenAI( api_key="sk-xxx", base_url="https://api.siliconflow.cn/v1" ) response = client.chat.completions.create( model="deepseek-ai/DeepSeek-V3.2", messages=[ {"role": "system", "content": "你是一个有帮助的助手。"}, {"role": "user", "content": "你好，请用一句话介绍你自己。"} ] ) 测试结果： ✅ API 调用成功 ✅ 响应速度快（&lt;2秒） ✅ Token 使用：36 tokens（输入 18 + 输出 18） 4.2 Action 解析测试 任务：”列出当前目录的文件” LLM 响应： \```bash-action ls \``` 解析结果： ✅ 类型：bash ✅ 数据：ls 4.3 完整流程测试 测试场景：列出当前目录文件并生成总结 执行流程： 用户输入：”列出当前目录的文件” Agent 第 1 轮： 生成命令：ls -la 执行并返回结果 Agent 第 2 轮： 解析文件列表 生成结构化总结： 当前目录包含以下文件和子目录： ## 文件： - .env.example - 环境变量配置示例文件 - QUICKSTART.md - 快速开始指南 - agent.py - 主要的智能体脚本 ## 子目录： - config/ - 配置文件目录 - docs/ - 文档目录 - models/ - 模型相关文件目录 - tools/ - 工具函数目录 测试结果： ✅ 完整循环正常运行 ✅ 正确执行 bash 命令 ✅ 理解命令输出 ✅ 生成清晰的结构化总结 4.4 性能评估 响应速度： 单次 API 调用：~1-2秒 完整循环（2轮）：~3-4秒 输出质量： 理解能力：⭐⭐⭐⭐⭐ 格式遵守：⭐⭐⭐⭐⭐ 内容质量：⭐⭐⭐⭐⭐ 成本： 简单任务（列出文件）：~100 tokens 估算成本：&lt;¥0.001/次 五、项目管理：Git + GitHub 5.1 仓库初始化 # 初始化本地仓库 git init git add . git commit -m "feat: 初始化 MeowCode Agent 项目" # 创建 GitHub 仓库并推送 gh repo create meowcode --public --source=. --push 仓库地址：https://github.com/AriesOxO/meowcode 5.2 目录结构 meowcode/ ├── agent.py # 主入口 ├── models/ # LLM 提供商 │ ├── base.py # 抽象基类 │ ├── deepseek.py # DeepSeek 实现 │ └── factory.py # 工厂函数 ├── tools/ # 工具系统 │ ├── execute.py # 命令执行 │ └── file_ops.py # 文件操作 ├── utils/ # 工具函数 │ ├── parser.py # Action 解析 │ └── config.py # 配置加载 ├── config/ # 配置文件 │ └── config.example.yaml ├── docs/ # 文档 │ ├── 设计/ # 架构设计文档 │ ├── 参考文档/ # 学习资料 │ ├── 测试/ # 测试报告 │ └── blog/ # 开发博客 └── requirements.txt # 依赖（只有 2 个！） 5.3 代码统计 Python 文件：12 个 代码行数：~300 行（核心） 总行数：2932 行（含文档） 提交数：3 次 开发时间：1 天 六、遇到的问题与解决 6.1 Windows 编码问题 现象：UnicodeEncodeError: 'gbk' codec can't encode character 原因：Windows 默认使用 GBK 编码，无法显示 emoji 解决： import sys import io if sys.platform == 'win32': sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8') 6.2 依赖管理 初始只需要 openai，后来发现配置系统需要 pyyaml： openai&gt;=1.0.0 pyyaml&gt;=6.0 极简依赖策略：能用标准库就不加依赖。 总结 关键决策 Python + DeepSeek：开发速度快，成本极低 抽象层设计：为未来多模型支持打好基础 极简依赖：只有 2 个外部依赖 CLI 优先：简单高效，适合开发者 技术亮点 300 行核心代码实现完整 Agent 循环 工厂模式支持多模型扩展 模块化设计，职责清晰 测试完备：API/逻辑/流程 三层验证 成本优势 DeepSeek 让个人开发者也能负担 AI Agent： 单次调用：&lt;¥0.001 100 次修复：¥0.18 vs Claude：便宜 300 倍 下一步计划 添加代码搜索功能 实现错误重试机制 集成智谱 GLM PyInstaller 打包 跨平台测试 项目地址：https://github.com/AriesOxO/meowcode 欢迎 Star 和贡献！下一篇将记录代码搜索功能的实现过程。]]></summary></entry><entry><title type="html">用 AI 深度优化 GitHub Profile：从发现问题到自动化部署</title><link href="https://ariesoxo.github.io/%E5%B7%A5%E5%85%B7%E5%88%86%E4%BA%AB/%E6%95%88%E7%8E%87%E6%8F%90%E5%8D%87/2026/05/26/GitHub-Profile%E6%B7%B1%E5%BA%A6%E4%BC%98%E5%8C%96.html" rel="alternate" type="text/html" title="用 AI 深度优化 GitHub Profile：从发现问题到自动化部署" /><published>2026-05-26T00:00:00+08:00</published><updated>2026-05-26T00:00:00+08:00</updated><id>https://ariesoxo.github.io/%E5%B7%A5%E5%85%B7%E5%88%86%E4%BA%AB/%E6%95%88%E7%8E%87%E6%8F%90%E5%8D%87/2026/05/26/GitHub-Profile%E6%B7%B1%E5%BA%A6%E4%BC%98%E5%8C%96</id><content type="html" xml:base="https://ariesoxo.github.io/%E5%B7%A5%E5%85%B7%E5%88%86%E4%BA%AB/%E6%95%88%E7%8E%87%E6%8F%90%E5%8D%87/2026/05/26/GitHub-Profile%E6%B7%B1%E5%BA%A6%E4%BC%98%E5%8C%96.html"><![CDATA[<h1 id="用-ai-深度优化-github-profile从发现问题到自动化部署">用 AI 深度优化 GitHub Profile：从发现问题到自动化部署</h1>

<blockquote>
  <p>GitHub Profile 是开发者的门面，但很多人（包括我）设置完就忘了维护。这次用 Claude Code 做了一次深度体检，发现了不少隐患，顺手全部修复。</p>
</blockquote>

<hr />

<h2 id="一问题诊断你的主页可能也有这些坑">一、问题诊断：你的主页可能也有这些坑</h2>

<h3 id="11-数据硬编码悄悄过时">1.1 数据硬编码，悄悄过时</h3>

<p>之前的 README 用手写的 shields.io badge 展示统计：</p>

<div class="language-markdown highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">![</span><span class="nv">公开仓库</span><span class="p">](</span><span class="sx">https://img.shields.io/badge/公开仓库-35-blue</span><span class="p">)</span>
<span class="p">![</span><span class="nv">总Stars</span><span class="p">](</span><span class="sx">https://img.shields.io/badge/总Stars-31-yellow</span><span class="p">)</span>
</code></pre></div></div>

<p>问题是这些数字是写死的。实际检查发现：</p>

<table>
  <thead>
    <tr>
      <th>指标</th>
      <th>显示值</th>
      <th>实际值</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>公开仓库</td>
      <td>35</td>
      <td>11</td>
    </tr>
    <tr>
      <td>总 Stars</td>
      <td>31</td>
      <td>42</td>
    </tr>
  </tbody>
</table>

<p>数据不一致会给访问者留下「不维护」或「数据造假」的印象。这是最容易被忽视的隐患——静态 badge 永远不会自动更新。</p>

<h3 id="12-技术栈过于宽泛">1.2 技术栈过于宽泛</h3>

<p>列了 Rust/Go/Java/Python/C + 前端全家桶，但公开仓库主要是 Rust 和 Python。没有项目佐证的技术标签反而降低可信度。</p>

<h3 id="13-缺少活跃度证明">1.3 缺少活跃度证明</h3>

<p>没有贡献日历、连续提交记录等动态组件。对独立开发者来说，展示持续活跃比展示技术栈标签更有说服力。</p>

<h3 id="14-访问计数器不可靠">1.4 访问计数器不可靠</h3>

<p>用了 <code class="language-plaintext highlighter-rouge">count.getloli.com</code> 的第三方服务，key 是 <code class="language-plaintext highlighter-rouge">:meow</code> 而不是用户名，稳定性存疑。</p>

<hr />

<h2 id="二动态统计告别手动更新">二、动态统计：告别手动更新</h2>

<h3 id="21-精选项目用-shieldsio-动态-badge">2.1 精选项目用 shields.io 动态 badge</h3>

<p>shields.io 提供基于 GitHub API 的动态 badge，全球 CDN 分发，非常稳定：</p>

<div class="language-markdown highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">![</span><span class="nv">Stars</span><span class="p">](</span><span class="sx">https://img.shields.io/github/stars/AriesOxO/piz?style=flat-square</span><span class="p">)</span>
</code></pre></div></div>

<p>这个 badge 会实时反映仓库的 star 数，不需要手动维护。</p>

<h3 id="22-github-stats-卡片">2.2 GitHub Stats 卡片</h3>

<p><a href="https://github.com/anuraghazra/github-readme-stats">github-readme-stats</a> 提供漂亮的统计卡片，包括总 star、commit 数、PR 数、贡献评级等。但公共实例 <code class="language-plaintext highlighter-rouge">github-readme-stats.vercel.app</code> 有速率限制，高峰期经常加载失败。</p>

<h3 id="23-streak-统计">2.3 Streak 统计</h3>

<p>连续提交记录用 <code class="language-plaintext highlighter-rouge">streak-stats.demolab.com</code>，由原作者 DenverCoder1 官方维护，比旧的 herokuapp 实例稳定得多：</p>

<div class="language-markdown highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">[</span><span class="nv">![GitHub Streak</span><span class="p">](</span><span class="sx">https://streak-stats.demolab.com?user=AriesOxO&amp;theme=dark&amp;hide_border=true</span><span class="p">)</span>](https://git.io/streak-stats)
</code></pre></div></div>

<hr />

<h2 id="三自部署-vercel-实例彻底解决加载问题">三、自部署 Vercel 实例：彻底解决加载问题</h2>

<p>公共 <code class="language-plaintext highlighter-rouge">github-readme-stats.vercel.app</code> 实例的速率限制是个老问题。根本解决方案是自己部署一个实例。</p>

<h3 id="31-部署步骤">3.1 部署步骤</h3>

<ol>
  <li><strong>Fork 仓库</strong>：<code class="language-plaintext highlighter-rouge">gh repo fork anuraghazra/github-readme-stats --clone=false</code></li>
  <li><strong>生成 GitHub Token</strong>：Settings → Fine-grained tokens → Public Repositories (read-only)</li>
  <li><strong>Vercel 部署</strong>：Import fork 的仓库，环境变量加 <code class="language-plaintext highlighter-rouge">PAT_1 = &lt;token&gt;</code></li>
  <li><strong>获取专属域名</strong>：部署完成后 Vercel 分配独立域名</li>
</ol>

<h3 id="32-替换-url">3.2 替换 URL</h3>

<p>部署完成后，把 README 中所有 <code class="language-plaintext highlighter-rouge">github-readme-stats.vercel.app</code> 替换为自己的域名：</p>

<div class="language-markdown highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">![</span><span class="nv">Stats</span><span class="p">](</span><span class="sx">https://your-instance.vercel.app/api?username=AriesOxO&amp;show_icons=true&amp;theme=dark&amp;hide_border=true</span><span class="p">)</span>
<span class="p">![</span><span class="nv">Top Langs</span><span class="p">](</span><span class="sx">https://your-instance.vercel.app/api/top-langs/?username=AriesOxO&amp;layout=compact&amp;theme=dark&amp;hide_border=true</span><span class="p">)</span>
</code></pre></div></div>

<p>自己的实例没有共享速率限制，图片加载问题彻底解决。</p>

<hr />

<h2 id="四访问计数器替换">四、访问计数器替换</h2>

<p>将不稳定的第三方计数器换成 <code class="language-plaintext highlighter-rouge">komarev.com/ghpvc</code>，绑定自己的 GitHub 用户名：</p>

<div class="language-markdown highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">![</span><span class="nv">profile views</span><span class="p">](</span><span class="sx">https://komarev.com/ghpvc/?username=AriesOxO&amp;style=flat-square&amp;color=blue</span><span class="p">)</span>
</code></pre></div></div>

<hr />

<h2 id="总结">总结</h2>

<ol>
  <li><strong>数据要动态</strong>：任何会变化的数字都不应该硬编码，用 API 驱动的 badge 代替</li>
  <li><strong>Bio 要有信息量</strong>：3 秒让陌生人判断你是谁，别浪费这个高曝光位</li>
  <li><strong>公共服务有瓶颈</strong>：依赖第三方免费实例要有 Plan B，自部署是最优解</li>
  <li><strong>定期体检</strong>：Profile 设置完容易忘记维护，数据会悄悄过时</li>
</ol>]]></content><author><name>meow</name></author><category term="工具分享" /><category term="效率提升" /><category term="GitHub Profile" /><category term="github-readme-stats" /><category term="Vercel" /><category term="自动化" /><category term="Claude Code" /><summary type="html"><![CDATA[记录一次完整的 GitHub 主页优化过程：AI 诊断设计隐患、动态统计替换硬编码、自部署 Vercel 实例解决图片加载问题，以及 bio 定位优化。]]></summary></entry><entry><title type="html">换电脑不慌：一套跨平台方案管理你的 Claude Code 环境</title><link href="https://ariesoxo.github.io/%E5%BC%80%E5%8F%91%E8%AE%B0%E5%BD%95/ai%E5%B7%A5%E5%85%B7/2026/05/26/claude-code-env-management.html" rel="alternate" type="text/html" title="换电脑不慌：一套跨平台方案管理你的 Claude Code 环境" /><published>2026-05-26T00:00:00+08:00</published><updated>2026-05-26T00:00:00+08:00</updated><id>https://ariesoxo.github.io/%E5%BC%80%E5%8F%91%E8%AE%B0%E5%BD%95/ai%E5%B7%A5%E5%85%B7/2026/05/26/claude-code-env-management</id><content type="html" xml:base="https://ariesoxo.github.io/%E5%BC%80%E5%8F%91%E8%AE%B0%E5%BD%95/ai%E5%B7%A5%E5%85%B7/2026/05/26/claude-code-env-management.html"><![CDATA[<p>用 Claude Code 一段时间后，你会积累不少配置：自定义指令（CLAUDE.md）、技能（Skills）、插件、Hooks、代理设置……这些东西散落在 <code class="language-plaintext highlighter-rouge">~/.claude/</code> 目录下，换台电脑就得从头来过。更麻烦的是不同机器的环境差异：有的用系统环境变量存 API Token，有的用 .env 文件；有的需要代理，有的不需要。直接复制配置目录行不通。</p>

<p>本文分享我们的解决方案：一套<strong>可选择、可合并、可跨平台</strong>的 Claude Code 环境管理工具。</p>

<hr />

<h2 id="一核心思路">一、核心思路</h2>

<p>我们把 Claude Code 的配置做版本化管理，但不是简单的”备份-恢复”，而是围绕四个原则设计：</p>

<ol>
  <li><strong>配置模块化</strong> — settings.json 拆成独立片段（API、代理、权限、插件……），按需组合</li>
  <li><strong>智能合并</strong> — 不覆盖已有配置，而是深度合并，保留用户自定义内容</li>
  <li><strong>环境感知</strong> — 自动检测系统环境变量，已有的不重复写入</li>
  <li><strong>敏感信息分离</strong> — 密钥通过 .env 管理，不入仓库</li>
</ol>

<hr />

<h2 id="二模块化-settings">二、模块化 Settings</h2>

<p>一个完整的 settings.json 可能有几十行，但换电脑时未必全都需要。我们把它拆成独立片段：</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>settings.d/
├── 01-env-api.json       # API Token、Base URL
├── 02-env-proxy.json     # 代理（国内环境需要，海外不需要）
├── 03-permissions.json   # 权限模式
├── 04-hooks.json         # 自动化 Hooks
├── 05-plugins.json       # 插件列表
└── 06-preferences.json   # 主题等偏好
</code></pre></div></div>

<p>安装时选 <code class="language-plaintext highlighter-rouge">1,3,5,6</code>，跳过代理和 Hooks——因为新机器的网络环境和工作流可能不同。每个片段是一个独立的 JSON 文件，包含 <code class="language-plaintext highlighter-rouge">description</code> 字段用于安装时展示说明。</p>

<hr />

<h2 id="三合并而非覆盖">三、合并而非覆盖</h2>

<p>如果目标机器已经有 settings.json（比如你手动配了一些东西），脚本不会直接覆盖，而是：</p>

<ol>
  <li>以已有文件为基础</li>
  <li>把选中的模块深度合并进去</li>
  <li>新增字段追加，同名字段覆盖</li>
  <li>用户原有的自定义配置不丢失</li>
</ol>

<p>同样，CLAUDE.md 已存在时会询问确认，默认不覆盖。安装前还会自动备份原有配置到带时间戳的目录中，确保可回滚。</p>

<hr />

<h2 id="四环境变量智能检测">四、环境变量智能检测</h2>

<p>很多人的 API Token 是通过系统环境变量配置的，不需要写进 settings.json。脚本会自动检测：</p>

<ul>
  <li>系统已有 <code class="language-plaintext highlighter-rouge">ANTHROPIC_AUTH_TOKEN</code> → 跳过，不写入 settings.json</li>
  <li>系统没有但 .env 中有 → 替换后写入</li>
  <li>都没有 → 保留 <code class="language-plaintext highlighter-rouge">${...}</code> 占位符，提示手动编辑</li>
</ul>

<p>两种配置方式共存，不冲突。这解决了一个常见问题：团队共享配置仓库时，每个人的 Token 来源不同。</p>

<hr />

<h2 id="五实现方案">五、实现方案</h2>

<p>最终用 Bash + PowerShell 双版本脚本，覆盖 macOS/Linux/Windows。核心依赖只有 git 和 Node.js（用于 JSON 深度合并）。</p>

<p>仓库结构：</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>├── bootstrap.sh / .ps1     # 远程一键安装（curl | bash）
├── install.sh / .ps1       # 本地安装脚本
├── .env.example            # 敏感信息模板（可选）
├── profiles/default/       # 配置集
│   ├── CLAUDE.md
│   ├── instructions/
│   └── settings.d/
└── skills/                 # 所有技能（安装时选择）
</code></pre></div></div>

<p>新电脑上一行命令搞定：</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Linux/macOS</span>
curl <span class="nt">-fsSL</span> https://raw.githubusercontent.com/&lt;你的用户名&gt;/&lt;仓库名&gt;/master/bootstrap.sh | bash

<span class="c"># Windows PowerShell</span>
irm https://raw.githubusercontent.com/&lt;你的用户名&gt;/&lt;仓库名&gt;/master/bootstrap.ps1 | iex
</code></pre></div></div>

<p>安装流程：检测配置目录 → 备份原有配置 → 选择 settings 模块 → 选择 skills → 智能合并生成。</p>

<hr />

<h2 id="总结">总结</h2>

<ol>
  <li><strong>模块化</strong>：配置拆片段，按需组合，不同环境选不同模块</li>
  <li><strong>非破坏性</strong>：合并而非覆盖，保留用户已有的自定义配置</li>
  <li><strong>环境感知</strong>：系统变量和 .env 两种方式兼容，不冲突</li>
  <li><strong>跨平台</strong>：Bash + PowerShell 双版本，一套仓库三端通用</li>
  <li><strong>一键恢复</strong>：远程 bootstrap 脚本，新电脑一行命令搞定</li>
</ol>

<p>AI 编码助手的配置管理本质上和 dotfiles 管理是同一个问题，但多了”模块化选择”和”智能合并”的需求。不是所有配置都适合所有环境，也不是每次安装都该从零开始。如果你也有类似的需求，不妨参考这套思路，按自己的实际情况落地。</p>]]></content><author><name>meow</name></author><category term="开发记录" /><category term="AI工具" /><category term="Claude Code" /><category term="环境管理" /><category term="跨平台" /><category term="dotfiles" /><summary type="html"><![CDATA[用 Claude Code 一段时间后，你会积累不少配置：自定义指令（CLAUDE.md）、技能（Skills）、插件、Hooks、代理设置……这些东西散落在 ~/.claude/ 目录下，换台电脑就得从头来过。更麻烦的是不同机器的环境差异：有的用系统环境变量存 API Token，有的用 .env 文件；有的需要代理，有的不需要。直接复制配置目录行不通。 本文分享我们的解决方案：一套可选择、可合并、可跨平台的 Claude Code 环境管理工具。 一、核心思路 我们把 Claude Code 的配置做版本化管理，但不是简单的”备份-恢复”，而是围绕四个原则设计： 配置模块化 — settings.json 拆成独立片段（API、代理、权限、插件……），按需组合 智能合并 — 不覆盖已有配置，而是深度合并，保留用户自定义内容 环境感知 — 自动检测系统环境变量，已有的不重复写入 敏感信息分离 — 密钥通过 .env 管理，不入仓库 二、模块化 Settings 一个完整的 settings.json 可能有几十行，但换电脑时未必全都需要。我们把它拆成独立片段： settings.d/ ├── 01-env-api.json # API Token、Base URL ├── 02-env-proxy.json # 代理（国内环境需要，海外不需要） ├── 03-permissions.json # 权限模式 ├── 04-hooks.json # 自动化 Hooks ├── 05-plugins.json # 插件列表 └── 06-preferences.json # 主题等偏好 安装时选 1,3,5,6，跳过代理和 Hooks——因为新机器的网络环境和工作流可能不同。每个片段是一个独立的 JSON 文件，包含 description 字段用于安装时展示说明。 三、合并而非覆盖 如果目标机器已经有 settings.json（比如你手动配了一些东西），脚本不会直接覆盖，而是： 以已有文件为基础 把选中的模块深度合并进去 新增字段追加，同名字段覆盖 用户原有的自定义配置不丢失 同样，CLAUDE.md 已存在时会询问确认，默认不覆盖。安装前还会自动备份原有配置到带时间戳的目录中，确保可回滚。 四、环境变量智能检测 很多人的 API Token 是通过系统环境变量配置的，不需要写进 settings.json。脚本会自动检测： 系统已有 ANTHROPIC_AUTH_TOKEN → 跳过，不写入 settings.json 系统没有但 .env 中有 → 替换后写入 都没有 → 保留 ${...} 占位符，提示手动编辑 两种配置方式共存，不冲突。这解决了一个常见问题：团队共享配置仓库时，每个人的 Token 来源不同。 五、实现方案 最终用 Bash + PowerShell 双版本脚本，覆盖 macOS/Linux/Windows。核心依赖只有 git 和 Node.js（用于 JSON 深度合并）。 仓库结构： ├── bootstrap.sh / .ps1 # 远程一键安装（curl | bash） ├── install.sh / .ps1 # 本地安装脚本 ├── .env.example # 敏感信息模板（可选） ├── profiles/default/ # 配置集 │ ├── CLAUDE.md │ ├── instructions/ │ └── settings.d/ └── skills/ # 所有技能（安装时选择） 新电脑上一行命令搞定： # Linux/macOS curl -fsSL https://raw.githubusercontent.com/&lt;你的用户名&gt;/&lt;仓库名&gt;/master/bootstrap.sh | bash # Windows PowerShell irm https://raw.githubusercontent.com/&lt;你的用户名&gt;/&lt;仓库名&gt;/master/bootstrap.ps1 | iex 安装流程：检测配置目录 → 备份原有配置 → 选择 settings 模块 → 选择 skills → 智能合并生成。 总结 模块化：配置拆片段，按需组合，不同环境选不同模块 非破坏性：合并而非覆盖，保留用户已有的自定义配置 环境感知：系统变量和 .env 两种方式兼容，不冲突 跨平台：Bash + PowerShell 双版本，一套仓库三端通用 一键恢复：远程 bootstrap 脚本，新电脑一行命令搞定 AI 编码助手的配置管理本质上和 dotfiles 管理是同一个问题，但多了”模块化选择”和”智能合并”的需求。不是所有配置都适合所有环境，也不是每次安装都该从零开始。如果你也有类似的需求，不妨参考这套思路，按自己的实际情况落地。]]></summary></entry><entry><title type="html">辣评 移动端全面优化：可折叠筛选、卡片视图与邮箱自助修改（三十）</title><link href="https://ariesoxo.github.io/%E5%BC%80%E5%8F%91%E8%AE%B0%E5%BD%95/%E7%A7%BB%E5%8A%A8%E7%AB%AF%E4%BC%98%E5%8C%96/2026/03/30/%E7%A7%BB%E5%8A%A8%E7%AB%AF%E5%85%A8%E9%9D%A2%E4%BC%98%E5%8C%96%E4%B8%8E%E9%82%AE%E7%AE%B1%E8%87%AA%E5%8A%A9%E4%BF%AE%E6%94%B9.html" rel="alternate" type="text/html" title="辣评 移动端全面优化：可折叠筛选、卡片视图与邮箱自助修改（三十）" /><published>2026-03-30T00:00:00+08:00</published><updated>2026-03-30T00:00:00+08:00</updated><id>https://ariesoxo.github.io/%E5%BC%80%E5%8F%91%E8%AE%B0%E5%BD%95/%E7%A7%BB%E5%8A%A8%E7%AB%AF%E4%BC%98%E5%8C%96/2026/03/30/%E7%A7%BB%E5%8A%A8%E7%AB%AF%E5%85%A8%E9%9D%A2%E4%BC%98%E5%8C%96%E4%B8%8E%E9%82%AE%E7%AE%B1%E8%87%AA%E5%8A%A9%E4%BF%AE%E6%94%B9</id><content type="html" xml:base="https://ariesoxo.github.io/%E5%BC%80%E5%8F%91%E8%AE%B0%E5%BD%95/%E7%A7%BB%E5%8A%A8%E7%AB%AF%E4%BC%98%E5%8C%96/2026/03/30/%E7%A7%BB%E5%8A%A8%E7%AB%AF%E5%85%A8%E9%9D%A2%E4%BC%98%E5%8C%96%E4%B8%8E%E9%82%AE%E7%AE%B1%E8%87%AA%E5%8A%A9%E4%BF%AE%E6%94%B9.html"><![CDATA[<p>用 Playwright 对移动端逐页截图分析后，发现了一系列严重的可用性问题——表格截断、筛选栏占满首屏、暗黑模式下导航栏刺眼白色。一天时间，13 个文件，+2400 行代码，完成了 4 个页面的移动端重构和邮箱自助修改功能。本文记录整个分析和修复过程。</p>

<hr />

<h2 id="一playwright-驱动的问题发现">一、Playwright 驱动的问题发现</h2>

<p>这次优化的起点不是用户反馈，而是<strong>自动化截图分析</strong>。我们编写了一套 Playwright 脚本，在 iPhone 14 (390px) 和 375px 两种视口下对所有前台页面截图，然后逐帧对比分析问题。</p>

<p>核心发现：</p>

<table>
  <thead>
    <tr>
      <th>页面</th>
      <th>问题</th>
      <th>严重度</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>资格统计</td>
      <td>10 列表格只能看到 3 列，核心数据全部丢失</td>
      <td>致命</td>
    </tr>
    <tr>
      <td>管理员评论</td>
      <td>el-table 严重截断，评论内容/评分不可见</td>
      <td>致命</td>
    </tr>
    <tr>
      <td>前台评论</td>
      <td>筛选栏占首屏 60%，内容不可见</td>
      <td>高</td>
    </tr>
    <tr>
      <td>排行榜</td>
      <td>筛选栏占首屏 70%</td>
      <td>高</td>
    </tr>
    <tr>
      <td>底部导航栏</td>
      <td>暗黑模式下仍为白色背景</td>
      <td>中</td>
    </tr>
    <tr>
      <td>移动端 header</td>
      <td>没有暗黑模式切换入口</td>
      <td>中</td>
    </tr>
  </tbody>
</table>

<p>这个分析方法本身就是一个值得分享的经验：<strong>不要靠感觉判断移动端适配质量，用真实设备视口的截图说话。</strong></p>

<hr />

<h2 id="二可折叠筛选栏一行摘要--展开面板">二、可折叠筛选栏：一行摘要 + 展开面板</h2>

<h3 id="21-问题筛选栏吞掉了整个首屏">2.1 问题：筛选栏吞掉了整个首屏</h3>

<p>移动端纵向排列 4-6 个筛选字段 + 按钮，高度轻松超过 400px。在 844px 高度的 iPhone 14 上，减去 header (44px) 和 tabbar (62px)，可用高度只有 738px——筛选栏直接占了一半以上。</p>

<h3 id="22-方案双模式切换">2.2 方案：双模式切换</h3>

<p>我们实现了「<strong>收起态</strong>」和「<strong>展开态</strong>」两种模式：</p>

<div class="language-vue highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c">&lt;!-- 收起态：一行摘要 --&gt;</span>
<span class="nt">&lt;div</span> <span class="na">v-if=</span><span class="s">"!filterExpanded"</span> <span class="na">class=</span><span class="s">"filter-summary"</span> <span class="err">@</span><span class="na">click=</span><span class="s">"filterExpanded = true"</span><span class="nt">&gt;</span>
  <span class="nt">&lt;div</span> <span class="na">class=</span><span class="s">"filter-tags"</span><span class="nt">&gt;</span>
    <span class="nt">&lt;el-tag</span> <span class="na">v-if=</span><span class="s">"queryParams.competitionId"</span> <span class="na">size=</span><span class="s">"small"</span> <span class="na">closable</span>
      <span class="err">@</span><span class="na">close.stop=</span><span class="s">"clearSingleFilter('competitionId')"</span><span class="nt">&gt;</span>
      
    <span class="nt">&lt;/el-tag&gt;</span>
    <span class="c">&lt;!-- 其他激活的筛选条件 Tag... --&gt;</span>
    <span class="nt">&lt;span</span> <span class="na">v-if=</span><span class="s">"!hasActiveFilters"</span> <span class="na">class=</span><span class="s">"no-filter"</span><span class="nt">&gt;</span>全部评论<span class="nt">&lt;/span&gt;</span>
  <span class="nt">&lt;/div&gt;</span>
  <span class="nt">&lt;el-button</span> <span class="na">text</span> <span class="na">type=</span><span class="s">"primary"</span> <span class="na">size=</span><span class="s">"small"</span><span class="nt">&gt;</span>
    <span class="nt">&lt;el-icon&gt;&lt;Filter</span> <span class="nt">/&gt;&lt;/el-icon&gt;</span>筛选
  <span class="nt">&lt;/el-button&gt;</span>
<span class="nt">&lt;/div&gt;</span>

<span class="c">&lt;!-- 展开态：完整表单 --&gt;</span>
<span class="nt">&lt;FilterBar</span> <span class="na">v-if=</span><span class="s">"filterExpanded"</span> <span class="na">class=</span><span class="s">"filter-container-mobile"</span><span class="nt">&gt;</span>
  <span class="c">&lt;!-- 筛选字段 + 重置/完成按钮 --&gt;</span>
<span class="nt">&lt;/FilterBar&gt;</span>
</code></pre></div></div>

<p><strong>关键设计决策：</strong></p>

<ol>
  <li><strong>Tag 可单独关闭</strong>：点击 Tag 上的 × 可直接清除某个筛选条件，无需展开面板</li>
  <li><strong>桌面端不受影响</strong>：通过 <code class="language-plaintext highlighter-rouge">.desktop-filter</code> / <code class="language-plaintext highlighter-rouge">.mobile-filter</code> 的 <code class="language-plaintext highlighter-rouge">display: none</code> 在 768px 断点切换</li>
  <li><strong>小说类型用 Chip 按钮组</strong>：从 <code class="language-plaintext highlighter-rouge">el-select</code> 改为 <code class="language-plaintext highlighter-rouge">el-check-tag</code>，减少一次点击</li>
</ol>

<p>效果：首屏从只能看到筛选栏 → 直接看到 3 张评论卡片 + FAB 按钮。</p>

<hr />

<h2 id="三移动端卡片视图替代不可用的表格">三、移动端卡片视图：替代不可用的表格</h2>

<h3 id="31-资格统计页从零到完整">3.1 资格统计页：从零到完整</h3>

<p>修复前，10 列表格在移动端只能看到 ID、用户名和操作按钮——笔名、届次、等效评论、实际评论、总字数、资格状态、更新时间全部丢失。</p>

<p>我们参照前台评论页已有的 <code class="language-plaintext highlighter-rouge">desktop-table</code> / <code class="language-plaintext highlighter-rouge">mobile-card-list</code> 双视图模式，为资格统计页设计了卡片布局：</p>

<div class="language-vue highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nt">&lt;div</span> <span class="na">class=</span><span class="s">"stat-card"</span><span class="nt">&gt;</span>
  <span class="nt">&lt;div</span> <span class="na">class=</span><span class="s">"card-header"</span><span class="nt">&gt;</span>
    <span class="nt">&lt;div</span> <span class="na">class=</span><span class="s">"card-user"</span><span class="nt">&gt;</span>
      <span class="nt">&lt;span</span> <span class="na">class=</span><span class="s">"user-name"</span><span class="nt">&gt;&lt;/span&gt;</span>
      <span class="nt">&lt;span</span> <span class="na">class=</span><span class="s">"user-id"</span><span class="nt">&gt;&lt;/span&gt;</span>
    <span class="nt">&lt;/div&gt;</span>
    <span class="nt">&lt;el-tag</span> <span class="na">:type=</span><span class="s">"row.isQualified ? 'success' : 'danger'"</span> <span class="na">size=</span><span class="s">"small"</span><span class="nt">&gt;</span>
      
    <span class="nt">&lt;/el-tag&gt;</span>
  <span class="nt">&lt;/div&gt;</span>
  <span class="nt">&lt;div</span> <span class="na">class=</span><span class="s">"card-body"</span><span class="nt">&gt;</span>
    <span class="c">&lt;!-- 2×2 网格：届次、等效评论、实际评论、总字数 --&gt;</span>
  <span class="nt">&lt;/div&gt;</span>
  <span class="nt">&lt;div</span> <span class="na">class=</span><span class="s">"card-footer"</span><span class="nt">&gt;</span>
    <span class="nt">&lt;span</span> <span class="na">class=</span><span class="s">"update-time"</span><span class="nt">&gt;&lt;/span&gt;</span>
    <span class="nt">&lt;el-button</span> <span class="na">link</span> <span class="na">type=</span><span class="s">"primary"</span> <span class="na">size=</span><span class="s">"small"</span> <span class="err">@</span><span class="na">click=</span><span class="s">"handleRecalculateUser(row)"</span><span class="nt">&gt;</span>重算<span class="nt">&lt;/el-button&gt;</span>
  <span class="nt">&lt;/div&gt;</span>
<span class="nt">&lt;/div&gt;</span>
</code></pre></div></div>

<h3 id="32-管理员评论页同样的方案">3.2 管理员评论页：同样的方案</h3>

<p>管理员评论页的 el-table 也严重截断，用相同思路添加了卡片视图，每张卡片展示标题+类型、笔名/届次/评分、评论预览、编辑/删除操作。</p>

<hr />

<h2 id="四排行榜资格详情从-80-抽屉到全屏重构">四、排行榜资格详情：从 80% 抽屉到全屏重构</h2>

<h3 id="41-问题">4.1 问题</h3>

<p>点击排行榜中的评论者名字，会打开一个 80% 宽度的右侧抽屉显示资格详情。这个抽屉在移动端有多个致命问题：</p>

<ul>
  <li>5 列统计卡片网格崩塌</li>
  <li>7 列明细表格完全截断</li>
  <li>没有关闭按钮</li>
  <li>左侧露出排行榜内容，视觉干扰</li>
</ul>

<h3 id="42-方案条件渲染两套抽屉">4.2 方案：条件渲染两套抽屉</h3>

<div class="language-vue highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c">&lt;!-- 桌面端：保持原有右侧抽屉 --&gt;</span>
<span class="nt">&lt;el-drawer</span> <span class="na">v-if=</span><span class="s">"!isMobile"</span> <span class="na">v-model=</span><span class="s">"showDetail"</span> <span class="na">size=</span><span class="s">"80%"</span><span class="nt">&gt;</span>
  <span class="c">&lt;!-- 桌面版内容不变 --&gt;</span>
<span class="nt">&lt;/el-drawer&gt;</span>

<span class="c">&lt;!-- 移动端：全屏底部抽屉 --&gt;</span>
<span class="nt">&lt;el-drawer</span> <span class="na">v-if=</span><span class="s">"isMobile"</span> <span class="na">v-model=</span><span class="s">"showDetail"</span> <span class="na">direction=</span><span class="s">"btt"</span> <span class="na">size=</span><span class="s">"100%"</span><span class="nt">&gt;</span>
  <span class="nt">&lt;div</span> <span class="na">class=</span><span class="s">"mobile-detail-header"</span><span class="nt">&gt;</span>
    <span class="nt">&lt;el-button</span> <span class="na">text</span> <span class="err">@</span><span class="na">click=</span><span class="s">"showDetail = false"</span><span class="nt">&gt;</span>
      <span class="nt">&lt;el-icon&gt;&lt;ArrowLeft</span> <span class="nt">/&gt;&lt;/el-icon&gt;</span>
    <span class="nt">&lt;/el-button&gt;</span>
    <span class="nt">&lt;span&gt;</span>资格详情<span class="nt">&lt;/span&gt;</span>
  <span class="nt">&lt;/div&gt;</span>
  <span class="nt">&lt;div</span> <span class="na">class=</span><span class="s">"mobile-detail-body"</span><span class="nt">&gt;</span>
    <span class="c">&lt;!-- 状态卡片：用户名 + 届次 + 资格Tag --&gt;</span>
    <span class="c">&lt;!-- 统计 2列网格：等效/字数/总评/深评 --&gt;</span>
    <span class="c">&lt;!-- 综合检查：列表 + 通过/未过 Tag --&gt;</span>
    <span class="c">&lt;!-- 明细：卡片列表替代 7 列表格 --&gt;</span>
  <span class="nt">&lt;/div&gt;</span>
<span class="nt">&lt;/el-drawer&gt;</span>
</code></pre></div></div>

<p><strong>关键改动：</strong></p>
<ul>
  <li>统计从 5 列 → 2 列网格</li>
  <li>明细表格 → 每条一张卡片（标题+类型/字数+权重+深评+累计）</li>
  <li>综合检查从嵌套括号文字 → 结构化列表 + Tag</li>
  <li>标题从「爱丽丝-第5届-未参赛」纯文字 → 用户名大字 + 届次小字 + 状态 Tag</li>
</ul>

<hr />

<h2 id="五暗黑模式补全">五、暗黑模式补全</h2>

<h3 id="51-底部导航栏">5.1 底部导航栏</h3>

<p>暗黑模式下 tabbar 仍为 <code class="language-plaintext highlighter-rouge">rgba(255, 255, 255, 0.96)</code>——在深色页面上非常刺眼。</p>

<p>由于 FrontLayout 使用了 <code class="language-plaintext highlighter-rouge">&lt;style scoped&gt;</code>，<code class="language-plaintext highlighter-rouge">html.dark</code> 选择器不生效。解决方案是新增一个非 scoped 的 <code class="language-plaintext highlighter-rouge">&lt;style&gt;</code> 块：</p>

<div class="language-html highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nt">&lt;style&gt;</span>
<span class="nt">html</span><span class="nc">.dark</span> <span class="nc">.front-layout</span> <span class="nc">.mobile-tabbar</span> <span class="p">{</span>
  <span class="nl">background</span><span class="p">:</span> <span class="nf">rgba</span><span class="p">(</span><span class="m">30</span><span class="p">,</span> <span class="m">30</span><span class="p">,</span> <span class="m">46</span><span class="p">,</span> <span class="m">0.96</span><span class="p">);</span>
  <span class="nl">border-top-color</span><span class="p">:</span> <span class="nf">rgba</span><span class="p">(</span><span class="m">140</span><span class="p">,</span> <span class="m">150</span><span class="p">,</span> <span class="m">200</span><span class="p">,</span> <span class="m">0.15</span><span class="p">);</span>
<span class="p">}</span>
<span class="nt">&lt;/style&gt;</span>
</code></pre></div></div>

<h3 id="52-移动端暗黑切换入口">5.2 移动端暗黑切换入口</h3>

<p>移动端 header 只有标题和用户头像，没有 ThemeSwitch。直接在 <code class="language-plaintext highlighter-rouge">.mobile-actions</code> 中添加已有的 <code class="language-plaintext highlighter-rouge">&lt;ThemeSwitch /&gt;</code> 组件即可。</p>

<hr />

<h2 id="六邮箱自助修改功能">六、邮箱自助修改功能</h2>

<h3 id="61-需求">6.1 需求</h3>

<p>原来修改邮箱需要联系管理员。我们实现了验证码自助修改流程。</p>

<h3 id="62-后端两个新-api">6.2 后端：两个新 API</h3>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c">// POST /api/users/profile/email/send-code</span>
<span class="k">func</span> <span class="n">SendEmailChangeCode</span><span class="p">(</span><span class="n">c</span> <span class="o">*</span><span class="n">gin</span><span class="o">.</span><span class="n">Context</span><span class="p">)</span> <span class="p">{</span>
    <span class="c">// 1. 校验新邮箱 ≠ 当前邮箱</span>
    <span class="c">// 2. 校验新邮箱未被其他用户注册（唯一性）</span>
    <span class="c">// 3. 复用 EmailService.SendVerificationCode(newEmail, "change_email")</span>
<span class="p">}</span>

<span class="c">// PUT /api/users/profile/email</span>
<span class="k">func</span> <span class="n">ConfirmEmailChange</span><span class="p">(</span><span class="n">c</span> <span class="o">*</span><span class="n">gin</span><span class="o">.</span><span class="n">Context</span><span class="p">)</span> <span class="p">{</span>
    <span class="c">// 1. EmailService.VerifyCode 验证验证码</span>
    <span class="c">// 2. 再次校验唯一性（防并发）</span>
    <span class="c">// 3. 更新邮箱</span>
<span class="p">}</span>
</code></pre></div></div>

<p>复用了现有的 <code class="language-plaintext highlighter-rouge">EmailService</code>，无需新建任何服务。唯一性校验做了两次——发送时一次（用户体验），确认时再一次（安全防并发）。</p>

<h3 id="63-前端交互">6.3 前端交互</h3>

<p>邮箱区域从 disabled input 改为纯文本 + 「修改」链接，点击后展开验证码流程：</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>admin@laping.test  修改    ← 默认态
     ↓ 点击修改
[请输入新邮箱      ] [发送验证码]  ← 编辑态
[请输入验证码      ]              ← 发送后出现
        [取消]  [确认修改]
</code></pre></div></div>

<p>60 秒倒计时防止频繁发送，<code class="language-plaintext highlighter-rouge">cancelEmailEdit()</code> 可随时退出。</p>

<hr />

<h2 id="七个人信息页-ui-优化">七、个人信息页 UI 优化</h2>

<h3 id="71-表单-label-上下布局">7.1 表单 label 上下布局</h3>

<p>移动端 640px 以下，表单 label 从左侧 100px 固定宽度改为位于输入框上方，输入框全宽。用 <code class="language-plaintext highlighter-rouge">:deep()</code> 覆盖 Element Plus 默认布局。</p>

<h3 id="72-密码卡片可折叠">7.2 密码卡片可折叠</h3>

<p>修改密码三个输入框默认展示占据大量空间。改为移动端默认收起，点击标题展开：</p>

<div class="language-vue highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nt">&lt;div</span> <span class="na">class=</span><span class="s">"clickable-header"</span> <span class="err">@</span><span class="na">click=</span><span class="s">"passwordExpanded = !passwordExpanded"</span><span class="nt">&gt;</span>
  <span class="nt">&lt;span&gt;</span>修改密码<span class="nt">&lt;/span&gt;</span>
  <span class="nt">&lt;el-icon</span> <span class="na">:class=</span><span class="s">"{ 'is-rotated': passwordExpanded }"</span><span class="nt">&gt;&lt;ArrowDown</span> <span class="nt">/&gt;&lt;/el-icon&gt;</span>
<span class="nt">&lt;/div&gt;</span>
<span class="nt">&lt;div</span> <span class="na">v-show=</span><span class="s">"passwordExpanded || !isMobileView"</span><span class="nt">&gt;</span>
  <span class="c">&lt;!-- 密码表单 --&gt;</span>
<span class="nt">&lt;/div&gt;</span>
</code></pre></div></div>

<p>桌面端通过 <code class="language-plaintext highlighter-rouge">|| !isMobileView</code> 始终展示，箭头图标用 CSS 隐藏。</p>

<hr />

<h2 id="八样式一致性统一">八、样式一致性统一</h2>

<p>跨 4 个页面的可折叠筛选栏最初存在不一致：</p>

<ul>
  <li>label 最小宽度：50px / 60px / 70px / 80px</li>
  <li>label 文字：「届次」vs「比赛届数」，「类型」vs「小说类型」</li>
  <li>完成按钮：有的带 Search 图标，有的不带</li>
</ul>

<p>统一标准后：</p>
<ul>
  <li><code class="language-plaintext highlighter-rouge">min-width: 70px</code></li>
  <li>同名字段用相同文字（比赛届数、小说类型、笔名、小说标题）</li>
  <li>完成按钮统一 <code class="language-plaintext highlighter-rouge">type="primary"</code> 蓝色，不带图标</li>
  <li>小说类型统一使用 <code class="language-plaintext highlighter-rouge">el-check-tag</code> Chip 按钮组</li>
</ul>

<hr />

<h2 id="总结">总结</h2>

<ol>
  <li><strong>Playwright 截图分析法</strong>：不要靠感觉判断移动端适配，用真实视口截图发现问题比手动测试更高效</li>
  <li><strong>双视图模式</strong>：<code class="language-plaintext highlighter-rouge">desktop-table</code> + <code class="language-plaintext highlighter-rouge">mobile-card-list</code> 是表格页面移动端适配的通用方案</li>
  <li><strong>可折叠筛选</strong>：移动端首屏寸土寸金，筛选栏必须可以收起</li>
  <li><strong>scoped 与 html.dark</strong>：暗黑模式样式需要放在非 scoped <code class="language-plaintext highlighter-rouge">&lt;style&gt;</code> 块中</li>
  <li><strong>条件渲染两套布局</strong>：<code class="language-plaintext highlighter-rouge">v-if="isMobile"</code> 比 CSS 媒体查询更灵活，适合结构差异大的场景</li>
  <li><strong>复用现有服务</strong>：邮箱修改功能复用了已有的 EmailService，零新依赖</li>
  <li><strong>统一设计规范</strong>：跨页面组件样式必须从第一天就定标准，否则越做越散</li>
</ol>]]></content><author><name>meow</name></author><category term="开发记录" /><category term="移动端优化" /><category term="辣评" /><category term="移动端适配" /><category term="响应式设计" /><category term="暗黑模式" /><category term="Element Plus" /><summary type="html"><![CDATA[用 Playwright 对移动端逐页截图分析后，发现了一系列严重的可用性问题——表格截断、筛选栏占满首屏、暗黑模式下导航栏刺眼白色。一天时间，13 个文件，+2400 行代码，完成了 4 个页面的移动端重构和邮箱自助修改功能。本文记录整个分析和修复过程。 一、Playwright 驱动的问题发现 这次优化的起点不是用户反馈，而是自动化截图分析。我们编写了一套 Playwright 脚本，在 iPhone 14 (390px) 和 375px 两种视口下对所有前台页面截图，然后逐帧对比分析问题。 核心发现： 页面 问题 严重度 资格统计 10 列表格只能看到 3 列，核心数据全部丢失 致命 管理员评论 el-table 严重截断，评论内容/评分不可见 致命 前台评论 筛选栏占首屏 60%，内容不可见 高 排行榜 筛选栏占首屏 70% 高 底部导航栏 暗黑模式下仍为白色背景 中 移动端 header 没有暗黑模式切换入口 中 这个分析方法本身就是一个值得分享的经验：不要靠感觉判断移动端适配质量，用真实设备视口的截图说话。 二、可折叠筛选栏：一行摘要 + 展开面板 2.1 问题：筛选栏吞掉了整个首屏 移动端纵向排列 4-6 个筛选字段 + 按钮，高度轻松超过 400px。在 844px 高度的 iPhone 14 上，减去 header (44px) 和 tabbar (62px)，可用高度只有 738px——筛选栏直接占了一半以上。 2.2 方案：双模式切换 我们实现了「收起态」和「展开态」两种模式： &lt;!-- 收起态：一行摘要 --&gt; &lt;div v-if="!filterExpanded" class="filter-summary" @click="filterExpanded = true"&gt; &lt;div class="filter-tags"&gt; &lt;el-tag v-if="queryParams.competitionId" size="small" closable @close.stop="clearSingleFilter('competitionId')"&gt; &lt;/el-tag&gt; &lt;!-- 其他激活的筛选条件 Tag... --&gt; &lt;span v-if="!hasActiveFilters" class="no-filter"&gt;全部评论&lt;/span&gt; &lt;/div&gt; &lt;el-button text type="primary" size="small"&gt; &lt;el-icon&gt;&lt;Filter /&gt;&lt;/el-icon&gt;筛选 &lt;/el-button&gt; &lt;/div&gt; &lt;!-- 展开态：完整表单 --&gt; &lt;FilterBar v-if="filterExpanded" class="filter-container-mobile"&gt; &lt;!-- 筛选字段 + 重置/完成按钮 --&gt; &lt;/FilterBar&gt; 关键设计决策： Tag 可单独关闭：点击 Tag 上的 × 可直接清除某个筛选条件，无需展开面板 桌面端不受影响：通过 .desktop-filter / .mobile-filter 的 display: none 在 768px 断点切换 小说类型用 Chip 按钮组：从 el-select 改为 el-check-tag，减少一次点击 效果：首屏从只能看到筛选栏 → 直接看到 3 张评论卡片 + FAB 按钮。 三、移动端卡片视图：替代不可用的表格 3.1 资格统计页：从零到完整 修复前，10 列表格在移动端只能看到 ID、用户名和操作按钮——笔名、届次、等效评论、实际评论、总字数、资格状态、更新时间全部丢失。 我们参照前台评论页已有的 desktop-table / mobile-card-list 双视图模式，为资格统计页设计了卡片布局： &lt;div class="stat-card"&gt; &lt;div class="card-header"&gt; &lt;div class="card-user"&gt; &lt;span class="user-name"&gt;&lt;/span&gt; &lt;span class="user-id"&gt;&lt;/span&gt; &lt;/div&gt; &lt;el-tag :type="row.isQualified ? 'success' : 'danger'" size="small"&gt; &lt;/el-tag&gt; &lt;/div&gt; &lt;div class="card-body"&gt; &lt;!-- 2×2 网格：届次、等效评论、实际评论、总字数 --&gt; &lt;/div&gt; &lt;div class="card-footer"&gt; &lt;span class="update-time"&gt;&lt;/span&gt; &lt;el-button link type="primary" size="small" @click="handleRecalculateUser(row)"&gt;重算&lt;/el-button&gt; &lt;/div&gt; &lt;/div&gt; 3.2 管理员评论页：同样的方案 管理员评论页的 el-table 也严重截断，用相同思路添加了卡片视图，每张卡片展示标题+类型、笔名/届次/评分、评论预览、编辑/删除操作。 四、排行榜资格详情：从 80% 抽屉到全屏重构 4.1 问题 点击排行榜中的评论者名字，会打开一个 80% 宽度的右侧抽屉显示资格详情。这个抽屉在移动端有多个致命问题： 5 列统计卡片网格崩塌 7 列明细表格完全截断 没有关闭按钮 左侧露出排行榜内容，视觉干扰 4.2 方案：条件渲染两套抽屉 &lt;!-- 桌面端：保持原有右侧抽屉 --&gt; &lt;el-drawer v-if="!isMobile" v-model="showDetail" size="80%"&gt; &lt;!-- 桌面版内容不变 --&gt; &lt;/el-drawer&gt; &lt;!-- 移动端：全屏底部抽屉 --&gt; &lt;el-drawer v-if="isMobile" v-model="showDetail" direction="btt" size="100%"&gt; &lt;div class="mobile-detail-header"&gt; &lt;el-button text @click="showDetail = false"&gt; &lt;el-icon&gt;&lt;ArrowLeft /&gt;&lt;/el-icon&gt; &lt;/el-button&gt; &lt;span&gt;资格详情&lt;/span&gt; &lt;/div&gt; &lt;div class="mobile-detail-body"&gt; &lt;!-- 状态卡片：用户名 + 届次 + 资格Tag --&gt; &lt;!-- 统计 2列网格：等效/字数/总评/深评 --&gt; &lt;!-- 综合检查：列表 + 通过/未过 Tag --&gt; &lt;!-- 明细：卡片列表替代 7 列表格 --&gt; &lt;/div&gt; &lt;/el-drawer&gt; 关键改动： 统计从 5 列 → 2 列网格 明细表格 → 每条一张卡片（标题+类型/字数+权重+深评+累计） 综合检查从嵌套括号文字 → 结构化列表 + Tag 标题从「爱丽丝-第5届-未参赛」纯文字 → 用户名大字 + 届次小字 + 状态 Tag 五、暗黑模式补全 5.1 底部导航栏 暗黑模式下 tabbar 仍为 rgba(255, 255, 255, 0.96)——在深色页面上非常刺眼。 由于 FrontLayout 使用了 &lt;style scoped&gt;，html.dark 选择器不生效。解决方案是新增一个非 scoped 的 &lt;style&gt; 块： &lt;style&gt; html.dark .front-layout .mobile-tabbar { background: rgba(30, 30, 46, 0.96); border-top-color: rgba(140, 150, 200, 0.15); } &lt;/style&gt; 5.2 移动端暗黑切换入口 移动端 header 只有标题和用户头像，没有 ThemeSwitch。直接在 .mobile-actions 中添加已有的 &lt;ThemeSwitch /&gt; 组件即可。 六、邮箱自助修改功能 6.1 需求 原来修改邮箱需要联系管理员。我们实现了验证码自助修改流程。 6.2 后端：两个新 API // POST /api/users/profile/email/send-code func SendEmailChangeCode(c *gin.Context) { // 1. 校验新邮箱 ≠ 当前邮箱 // 2. 校验新邮箱未被其他用户注册（唯一性） // 3. 复用 EmailService.SendVerificationCode(newEmail, "change_email") } // PUT /api/users/profile/email func ConfirmEmailChange(c *gin.Context) { // 1. EmailService.VerifyCode 验证验证码 // 2. 再次校验唯一性（防并发） // 3. 更新邮箱 } 复用了现有的 EmailService，无需新建任何服务。唯一性校验做了两次——发送时一次（用户体验），确认时再一次（安全防并发）。 6.3 前端交互 邮箱区域从 disabled input 改为纯文本 + 「修改」链接，点击后展开验证码流程： admin@laping.test 修改 ← 默认态 ↓ 点击修改 [请输入新邮箱 ] [发送验证码] ← 编辑态 [请输入验证码 ] ← 发送后出现 [取消] [确认修改] 60 秒倒计时防止频繁发送，cancelEmailEdit() 可随时退出。 七、个人信息页 UI 优化 7.1 表单 label 上下布局 移动端 640px 以下，表单 label 从左侧 100px 固定宽度改为位于输入框上方，输入框全宽。用 :deep() 覆盖 Element Plus 默认布局。 7.2 密码卡片可折叠 修改密码三个输入框默认展示占据大量空间。改为移动端默认收起，点击标题展开： &lt;div class="clickable-header" @click="passwordExpanded = !passwordExpanded"&gt; &lt;span&gt;修改密码&lt;/span&gt; &lt;el-icon :class="{ 'is-rotated': passwordExpanded }"&gt;&lt;ArrowDown /&gt;&lt;/el-icon&gt; &lt;/div&gt; &lt;div v-show="passwordExpanded || !isMobileView"&gt; &lt;!-- 密码表单 --&gt; &lt;/div&gt; 桌面端通过 || !isMobileView 始终展示，箭头图标用 CSS 隐藏。 八、样式一致性统一 跨 4 个页面的可折叠筛选栏最初存在不一致： label 最小宽度：50px / 60px / 70px / 80px label 文字：「届次」vs「比赛届数」，「类型」vs「小说类型」 完成按钮：有的带 Search 图标，有的不带 统一标准后： min-width: 70px 同名字段用相同文字（比赛届数、小说类型、笔名、小说标题） 完成按钮统一 type="primary" 蓝色，不带图标 小说类型统一使用 el-check-tag Chip 按钮组 总结 Playwright 截图分析法：不要靠感觉判断移动端适配，用真实视口截图发现问题比手动测试更高效 双视图模式：desktop-table + mobile-card-list 是表格页面移动端适配的通用方案 可折叠筛选：移动端首屏寸土寸金，筛选栏必须可以收起 scoped 与 html.dark：暗黑模式样式需要放在非 scoped &lt;style&gt; 块中 条件渲染两套布局：v-if="isMobile" 比 CSS 媒体查询更灵活，适合结构差异大的场景 复用现有服务：邮箱修改功能复用了已有的 EmailService，零新依赖 统一设计规范：跨页面组件样式必须从第一天就定标准，否则越做越散]]></summary></entry><entry><title type="html">fnOS 风扇控制开发日志（一）：为什么要自己写风扇控制</title><link href="https://ariesoxo.github.io/python/nas/%E5%BC%80%E5%8F%91%E8%AE%B0%E5%BD%95/2026/03/29/fnos-fan-control-dev-blog-1-why.html" rel="alternate" type="text/html" title="fnOS 风扇控制开发日志（一）：为什么要自己写风扇控制" /><published>2026-03-29T00:00:00+08:00</published><updated>2026-03-29T00:00:00+08:00</updated><id>https://ariesoxo.github.io/python/nas/%E5%BC%80%E5%8F%91%E8%AE%B0%E5%BD%95/2026/03/29/fnos-fan-control-dev-blog-1-why</id><content type="html" xml:base="https://ariesoxo.github.io/python/nas/%E5%BC%80%E5%8F%91%E8%AE%B0%E5%BD%95/2026/03/29/fnos-fan-control-dev-blog-1-why.html"><![CDATA[<p>飞牛NAS 买回来第一天，我就被风扇噪音劝退了。默认策略要么全速轰鸣，要么低温时完全不转让人焦虑。官方没有提供灵活的风扇控制方案，社区的脚本又各有各的问题——于是我决定自己写一个。</p>

<!--more-->

<h2 id="痛点在哪">痛点在哪</h2>

<p>飞牛NAS（fnOS）基于 Linux，硬件控制走的是标准的 <code class="language-plaintext highlighter-rouge">hwmon</code> 子系统。理论上可以直接写 sysfs 文件来控制风扇，但实际使用中有几个现实问题：</p>

<ol>
  <li><strong>噪音与散热的平衡难</strong>：默认的 BIOS 风扇策略太粗暴，低温全速或者高温才启动，没有中间地带</li>
  <li><strong>机型差异大</strong>：不同飞牛机型用的主板芯片不同（ITE、Nuvoton、Fintek），控制方式有细微差别</li>
  <li><strong>缺少可视化管理</strong>：命令行脚本虽然能用，但调曲线、看温度都不方便</li>
  <li><strong>安全风险</strong>：手动控制风扇如果程序崩了，风扇可能停转导致硬件过热</li>
</ol>

<p>我需要的是一个<strong>安装即用、有 Web 界面、自带安全保护</strong>的风扇控制应用。</p>

<h2 id="目标定义">目标定义</h2>

<p>经过需求分析，我确定了几个核心目标：</p>

<table>
  <thead>
    <tr>
      <th>目标</th>
      <th>说明</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>四种运行模式</td>
      <td>默认（保守曲线）/ 自动（自定义曲线）/ 手动（固定转速）/ 全速（紧急散热）</td>
    </tr>
    <tr>
      <td>自定义温控曲线</td>
      <td>2-10 个节点，支持自动生成和手动微调</td>
    </tr>
    <tr>
      <td>Web 管理界面</td>
      <td>深色主题、实时监控、响应式布局</td>
    </tr>
    <tr>
      <td>多设备兼容</td>
      <td>不绑定特定芯片型号，通用 hwmon 探测</td>
    </tr>
    <tr>
      <td>多层安全保护</td>
      <td>程序崩溃、温度读取失败、PWM 写入异常都有兜底</td>
    </tr>
    <tr>
      <td>FPK 打包</td>
      <td>作为飞牛应用商店的标准应用分发</td>
    </tr>
  </tbody>
</table>

<h2 id="技术选型">技术选型</h2>

<p><strong>为什么用 Python？</strong></p>

<p>这个项目的核心是读写 sysfs 文件和运行一个轻量 HTTP 服务，不需要高性能计算。Python 标准库自带 <code class="language-plaintext highlighter-rouge">http.server</code>、<code class="language-plaintext highlighter-rouge">json</code>、<code class="language-plaintext highlighter-rouge">threading</code>，完全够用。最关键的是——<strong>零第三方依赖</strong>。</p>

<p>NAS 环境特殊，用户不一定有 pip，也不想装一堆包。Python 3.11+ 在飞牛系统上是预装的，拿来就能用。</p>

<p><strong>前端方案</strong>：纯 HTML + CSS + 原生 JavaScript，单文件。不需要 Node.js 构建工具链，不需要 npm install，打开就能用。</p>

<p><strong>整体技术栈</strong>：</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>后端：Python 3.11+（标准库 only）
前端：HTML + CSS + Vanilla JS（单文件）
硬件接口：Linux hwmon sysfs
打包：FPK（飞牛应用包格式）
CI：GitHub Actions
测试：unittest（120 个测试用例）
</code></pre></div></div>

<h2 id="项目结构规划">项目结构规划</h2>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>fnOS-fan-control/
├── src/
│   ├── app/bin/           # Python 应用核心（5 个模块）
│   │   ├── main.py        # 入口（启动、信号处理、OOM 保护）
│   │   ├── hardware.py    # 硬件抽象层（hwmon 探测、PWM 读写）
│   │   ├── fan_controller.py  # 风扇控制核心（温控逻辑）
│   │   ├── config_manager.py  # 配置管理（校验、线程安全）
│   │   ├── web_server.py  # REST API 服务
│   │   └── static/index.html  # Web 管理界面
│   ├── cmd/               # FPK 生命周期脚本（9 个）
│   └── manifest           # FPK 包元数据
├── tests/                 # 120 个单元 + 集成测试
├── scripts/               # 构建和清理脚本
└── .github/workflows/     # CI 自动构建
</code></pre></div></div>

<p>模块划分的原则是<strong>单一职责</strong>：硬件层只管读写 sysfs，控制层只管温控逻辑，配置层只管数据校验，Web 层只管 HTTP 请求。模块之间通过明确的接口交互，任何一个模块出问题都不会拖垮整个系统。</p>

<h2 id="开发计划">开发计划</h2>

<p>整个开发分为 6 个阶段：</p>

<ol>
  <li><strong>基础框架</strong> — 硬件探测、配置管理、单风扇控制</li>
  <li><strong>Web 管理界面</strong> — REST API、前端页面、实时监控</li>
  <li><strong>温控曲线编辑</strong> — SVG 可视化、自动生成、手动微调</li>
  <li><strong>FPK 打包</strong> — 生命周期脚本、安装向导、权限配置</li>
  <li><strong>多设备适配</strong> — 通用 hwmon 探测、多区域控制</li>
  <li><strong>测试与文档</strong> — 单元测试、集成测试、完整文档</li>
</ol>

<h2 id="下一篇预告">下一篇预告</h2>

<p>下一篇会聊架构设计的核心决策：为什么选择「sysfs 就是抽象层」的设计哲学，如何用最少的代码实现多芯片兼容，以及多层安全机制是怎么设计的。</p>

<hr />

<blockquote>
  <p>项目地址：<a href="https://github.com/AriesOxO/fnOS-fan-control">fnOS-fan-control on GitHub</a>（MIT 许可证）</p>
</blockquote>]]></content><author><name>meow</name><email>njwzcb@163.com</email></author><category term="Python" /><category term="NAS" /><category term="开发记录" /><category term="fnOS" /><category term="风扇控制" /><category term="NAS" /><category term="hwmon" /><category term="开发日志" /><summary type="html"><![CDATA[飞牛NAS 风扇噪音让人崩溃，官方没有好用的风扇控制方案，于是我决定自己写一个。这是 fnOS-fan-control 开发日志的第一篇，聊聊项目的背景、需求分析和整体规划。]]></summary></entry><entry><title type="html">fnOS 风扇控制开发日志（二）：架构设计与核心实现</title><link href="https://ariesoxo.github.io/python/nas/%E5%BC%80%E5%8F%91%E8%AE%B0%E5%BD%95/2026/03/29/fnos-fan-control-dev-blog-2-architecture.html" rel="alternate" type="text/html" title="fnOS 风扇控制开发日志（二）：架构设计与核心实现" /><published>2026-03-29T00:00:00+08:00</published><updated>2026-03-29T00:00:00+08:00</updated><id>https://ariesoxo.github.io/python/nas/%E5%BC%80%E5%8F%91%E8%AE%B0%E5%BD%95/2026/03/29/fnos-fan-control-dev-blog-2-architecture</id><content type="html" xml:base="https://ariesoxo.github.io/python/nas/%E5%BC%80%E5%8F%91%E8%AE%B0%E5%BD%95/2026/03/29/fnos-fan-control-dev-blog-2-architecture.html"><![CDATA[<p>上一篇聊了为什么要做这个项目。这一篇进入技术细节——架构怎么设计的，核心模块怎么实现的，踩了哪些坑。</p>

<!--more-->

<h2 id="核心设计哲学sysfs-就是抽象层">核心设计哲学：sysfs 就是抽象层</h2>

<p>做硬件控制最容易掉进去的坑是<strong>过度抽象</strong>。一开始我也想过给不同芯片写 Driver 类，搞一套继承体系：</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># 反面教材：过度设计
</span><span class="k">class</span> <span class="nc">FanDriver</span><span class="p">(</span><span class="n">ABC</span><span class="p">):</span> <span class="bp">...</span>
<span class="k">class</span> <span class="nc">IT8772Driver</span><span class="p">(</span><span class="n">FanDriver</span><span class="p">):</span> <span class="bp">...</span>
<span class="k">class</span> <span class="nc">NCT6776Driver</span><span class="p">(</span><span class="n">FanDriver</span><span class="p">):</span> <span class="bp">...</span>
</code></pre></div></div>

<p>后来想明白了——Linux <code class="language-plaintext highlighter-rouge">hwmon</code> 子系统已经做了这件事。不管底层是 ITE IT8772E 还是 Nuvoton NCT6776，在 <code class="language-plaintext highlighter-rouge">/sys/class/hwmon/</code> 下看到的接口都是统一的：</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>/sys/class/hwmon/hwmonX/
├── name            # 芯片名称
├── pwm1            # PWM 值 (0-255)
├── pwm1_enable     # 控制模式 (1=手动, 2=自动)
├── fan1_input      # 转速 (RPM)
├── temp1_input     # 温度 (毫摄氏度)
└── temp1_label     # 温度标签
</code></pre></div></div>

<p><strong>我要做的不是再写一层抽象，而是直接用好 sysfs 这个现成的抽象层。</strong></p>

<h3 id="硬件探测的实现">硬件探测的实现</h3>

<p><code class="language-plaintext highlighter-rouge">hardware.py</code> 的核心函数 <code class="language-plaintext highlighter-rouge">detect_hwmon_paths()</code> 做的事情很简单：扫描所有 hwmon 设备，找到有 <code class="language-plaintext highlighter-rouge">pwm</code> 文件的就认为可以控制。</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">def</span> <span class="nf">detect_hwmon_paths</span><span class="p">():</span>
    <span class="sh">"""</span><span class="s">扫描 /sys/class/hwmon，返回所有可控风扇通道</span><span class="sh">"""</span>
    <span class="n">channels</span> <span class="o">=</span> <span class="p">{}</span>
    <span class="k">for</span> <span class="n">hwmon_dir</span> <span class="ow">in</span> <span class="nc">Path</span><span class="p">(</span><span class="sh">'</span><span class="s">/sys/class/hwmon</span><span class="sh">'</span><span class="p">).</span><span class="nf">iterdir</span><span class="p">():</span>
        <span class="c1"># 找到所有 pwmN 文件
</span>        <span class="k">for</span> <span class="n">pwm_file</span> <span class="ow">in</span> <span class="nf">sorted</span><span class="p">(</span><span class="n">hwmon_dir</span><span class="p">.</span><span class="nf">glob</span><span class="p">(</span><span class="sh">'</span><span class="s">pwm[0-9]*</span><span class="sh">'</span><span class="p">)):</span>
            <span class="k">if</span> <span class="n">pwm_file</span><span class="p">.</span><span class="n">name</span><span class="p">.</span><span class="nf">endswith</span><span class="p">(</span><span class="sh">'</span><span class="s">_enable</span><span class="sh">'</span><span class="p">):</span>
                <span class="k">continue</span>
            <span class="n">channel_name</span> <span class="o">=</span> <span class="n">pwm_file</span><span class="p">.</span><span class="n">name</span>  <span class="c1"># pwm1, pwm2, ...
</span>            <span class="c1"># 多芯片时加前缀避免冲突
</span>            <span class="k">if</span> <span class="n">channel_name</span> <span class="ow">in</span> <span class="n">channels</span><span class="p">:</span>
                <span class="n">chip_name</span> <span class="o">=</span> <span class="p">(</span><span class="n">hwmon_dir</span> <span class="o">/</span> <span class="sh">'</span><span class="s">name</span><span class="sh">'</span><span class="p">).</span><span class="nf">read_text</span><span class="p">().</span><span class="nf">strip</span><span class="p">()</span>
                <span class="n">channel_name</span> <span class="o">=</span> <span class="sa">f</span><span class="sh">"</span><span class="si">{</span><span class="n">chip_name</span><span class="si">}</span><span class="s">_</span><span class="si">{</span><span class="n">channel_name</span><span class="si">}</span><span class="sh">"</span>
            <span class="n">channels</span><span class="p">[</span><span class="n">channel_name</span><span class="p">]</span> <span class="o">=</span> <span class="p">{</span>
                <span class="sh">'</span><span class="s">pwm_path</span><span class="sh">'</span><span class="p">:</span> <span class="nf">str</span><span class="p">(</span><span class="n">pwm_file</span><span class="p">),</span>
                <span class="sh">'</span><span class="s">enable_path</span><span class="sh">'</span><span class="p">:</span> <span class="nf">str</span><span class="p">(</span><span class="n">pwm_file</span><span class="p">)</span> <span class="o">+</span> <span class="sh">'</span><span class="s">_enable</span><span class="sh">'</span><span class="p">,</span>
                <span class="sh">'</span><span class="s">chip</span><span class="sh">'</span><span class="p">:</span> <span class="n">chip_name</span>
            <span class="p">}</span>
    <span class="k">return</span> <span class="n">channels</span>
</code></pre></div></div>

<p>这个设计的好处是<strong>对未知芯片天然兼容</strong>——只要内核驱动加载了，hwmon 接口就在那里，我不需要提前知道用户的硬件型号。</p>

<h3 id="温度读取的优先级策略">温度读取的优先级策略</h3>

<p>CPU 温度的读取需要一点技巧。不同平台的温度 label 不一样：</p>

<table>
  <thead>
    <tr>
      <th>平台</th>
      <th>首选 label</th>
      <th>备选</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Intel</td>
      <td>Package id 0</td>
      <td>Core 0</td>
    </tr>
    <tr>
      <td>AMD</td>
      <td>Tdie</td>
      <td>Tctl</td>
    </tr>
    <tr>
      <td>ARM</td>
      <td>cpu_thermal</td>
      <td>thermal_zone</td>
    </tr>
  </tbody>
</table>

<p>代码会按优先级扫描 label，找到第一个匹配的就用。读不到就返回 <code class="language-plaintext highlighter-rouge">None</code>，由上层决定如何处理。</p>

<p>硬盘温度走 <code class="language-plaintext highlighter-rouge">drivetemp</code> 内核模块，安装时自动 <code class="language-plaintext highlighter-rouge">modprobe drivetemp</code>，之后硬盘温度就出现在 hwmon 里了。</p>

<h2 id="温控核心线性插值--多层保护">温控核心：线性插值 + 多层保护</h2>

<h3 id="温控曲线的工作原理">温控曲线的工作原理</h3>

<p>用户定义一组温度-转速节点，比如：</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>30°C → 20%
50°C → 40%
65°C → 70%
80°C → 100%
</code></pre></div></div>

<p>实际温度落在两个节点之间时，用<strong>线性插值</strong>计算 PWM 值：</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">def</span> <span class="nf">interpolate</span><span class="p">(</span><span class="n">temp</span><span class="p">,</span> <span class="n">curve</span><span class="p">):</span>
    <span class="sh">"""</span><span class="s">线性插值计算 PWM 值</span><span class="sh">"""</span>
    <span class="k">if</span> <span class="n">temp</span> <span class="o">&lt;=</span> <span class="n">curve</span><span class="p">[</span><span class="mi">0</span><span class="p">][</span><span class="sh">'</span><span class="s">temp</span><span class="sh">'</span><span class="p">]:</span>
        <span class="k">return</span> <span class="n">curve</span><span class="p">[</span><span class="mi">0</span><span class="p">][</span><span class="sh">'</span><span class="s">pwm_percent</span><span class="sh">'</span><span class="p">]</span>
    <span class="k">if</span> <span class="n">temp</span> <span class="o">&gt;=</span> <span class="n">curve</span><span class="p">[</span><span class="o">-</span><span class="mi">1</span><span class="p">][</span><span class="sh">'</span><span class="s">temp</span><span class="sh">'</span><span class="p">]:</span>
        <span class="k">return</span> <span class="n">curve</span><span class="p">[</span><span class="o">-</span><span class="mi">1</span><span class="p">][</span><span class="sh">'</span><span class="s">pwm_percent</span><span class="sh">'</span><span class="p">]</span>

    <span class="k">for</span> <span class="n">i</span> <span class="ow">in</span> <span class="nf">range</span><span class="p">(</span><span class="nf">len</span><span class="p">(</span><span class="n">curve</span><span class="p">)</span> <span class="o">-</span> <span class="mi">1</span><span class="p">):</span>
        <span class="n">t0</span><span class="p">,</span> <span class="n">p0</span> <span class="o">=</span> <span class="n">curve</span><span class="p">[</span><span class="n">i</span><span class="p">][</span><span class="sh">'</span><span class="s">temp</span><span class="sh">'</span><span class="p">],</span> <span class="n">curve</span><span class="p">[</span><span class="n">i</span><span class="p">][</span><span class="sh">'</span><span class="s">pwm_percent</span><span class="sh">'</span><span class="p">]</span>
        <span class="n">t1</span><span class="p">,</span> <span class="n">p1</span> <span class="o">=</span> <span class="n">curve</span><span class="p">[</span><span class="n">i</span><span class="o">+</span><span class="mi">1</span><span class="p">][</span><span class="sh">'</span><span class="s">temp</span><span class="sh">'</span><span class="p">],</span> <span class="n">curve</span><span class="p">[</span><span class="n">i</span><span class="o">+</span><span class="mi">1</span><span class="p">][</span><span class="sh">'</span><span class="s">pwm_percent</span><span class="sh">'</span><span class="p">]</span>
        <span class="k">if</span> <span class="n">t0</span> <span class="o">&lt;=</span> <span class="n">temp</span> <span class="o">&lt;=</span> <span class="n">t1</span><span class="p">:</span>
            <span class="n">ratio</span> <span class="o">=</span> <span class="p">(</span><span class="n">temp</span> <span class="o">-</span> <span class="n">t0</span><span class="p">)</span> <span class="o">/</span> <span class="p">(</span><span class="n">t1</span> <span class="o">-</span> <span class="n">t0</span><span class="p">)</span>
            <span class="k">return</span> <span class="n">p0</span> <span class="o">+</span> <span class="n">ratio</span> <span class="o">*</span> <span class="p">(</span><span class="n">p1</span> <span class="o">-</span> <span class="n">p0</span><span class="p">)</span>
</code></pre></div></div>

<p>这样风扇转速会随温度<strong>平滑过渡</strong>，不会出现突然加速的情况。</p>

<h3 id="控制循环">控制循环</h3>

<p><code class="language-plaintext highlighter-rouge">fan_controller.py</code> 的主循环每 N 秒（默认 2 秒）执行一次：</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>读取所有温度 → 遍历每个区域 → 根据模式计算 PWM → 写入 sysfs
</code></pre></div></div>

<p>关键设计决策：<strong>单线程遍历所有区域</strong>。NAS 通常只有 1-3 个风扇区域，单线程足够，避免了多线程同步的复杂度。温度数据一次读取后所有区域共享，减少 sysfs 读次数。</p>

<h3 id="多层安全机制">多层安全机制</h3>

<p>这是整个项目最重要的部分。硬件控制程序如果出问题，后果可能是硬件损坏。我设计了 5 层保护：</p>

<p><strong>第一层：绝对最低转速</strong></p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">ABSOLUTE_MIN_PWM</span> <span class="o">=</span> <span class="mi">26</span>  <span class="c1"># 约 10%，风扇不会完全停转
</span></code></pre></div></div>

<p>无论用户怎么配置，PWM 值永远不会低于 26。</p>

<p><strong>第二层：温度读取失败保护</strong></p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>连续 3 次失败 → 所有区域全速运转 (PWM=255)
连续 5 次失败 → 降级到默认保守曲线
</code></pre></div></div>

<p>宁可吵一点，也不能让硬件过热。</p>

<p><strong>第三层：PWM 写入异常保护</strong></p>

<p>写 sysfs 文件可能因为各种原因失败（驱动问题、权限问题）。连续 3 次写入失败后，该区域自动降级。</p>

<p><strong>第四层：pwm_enable 自愈</strong></p>

<p>有些 BIOS 会定期把 <code class="language-plaintext highlighter-rouge">pwm_enable</code> 从 1（手动）改回 2（自动）。控制循环每次执行时都会检查并修正这个值。</p>

<p><strong>第五层：看门狗</strong></p>

<p>主进程之外有一个独立的 Bash 看门狗进程。如果主进程意外崩溃，看门狗会在 5 秒内检测到并恢复 <code class="language-plaintext highlighter-rouge">pwm_enable=2</code>，让 BIOS 接管风扇控制。</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>正常退出：SIGTERM → cleanup() → restore_safe_state()
异常崩溃：看门狗 5s 内检测 → restore_safe_state()
卸载：uninstall 脚本 → 扫描恢复所有 pwm_enable=2
</code></pre></div></div>

<h2 id="web-管理界面">Web 管理界面</h2>

<h3 id="后端12-个-rest-api">后端：12 个 REST API</h3>

<p>基于 Python 标准库的 <code class="language-plaintext highlighter-rouge">http.server</code>，用 <code class="language-plaintext highlighter-rouge">ThreadingMixIn</code> 支持并发请求。</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>GET  /api/status         # 实时温度、转速、PWM、模式
GET  /api/config         # 当前配置
POST /api/config         # 更新配置
GET  /api/hardware       # 硬件探测结果
POST /api/mode           # 切换运行模式
POST /api/curve/generate # 自动生成温控曲线
GET  /api/logs           # 事件日志（最近 100 条）
POST /api/auth/login     # 登录认证
</code></pre></div></div>

<p>安全方面：POST body 限制 4KB 防止滥用，可选密码认证（Cookie + Header 双模式），所有异常捕获不泄露堆栈信息。</p>

<h3 id="前端单文件-spa">前端：单文件 SPA</h3>

<p>整个前端是一个 <code class="language-plaintext highlighter-rouge">index.html</code> 文件，约 2000 行，包含 HTML + CSS + JavaScript。</p>

<p>页面结构：</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>┌─ 顶部状态栏（应用名 + 运行状态指示灯）──────┐
├─ 告警条（降级/硬件未探测/连接中断）──────────┤
├─ 状态卡片 4 列（CPU温度/硬盘温度/转速/PWM）─┤
├─ 运行模式切换（4 个按钮）────────────────────┤
├─ 温控曲线（SVG 图 + 折叠编辑器）─────────────┤
├─ ▶ 高级设置（折叠面板）──────────────────────┤
├─ ▶ 运行日志（折叠面板）──────────────────────┤
└─ ▶ 使用说明（折叠面板）──────────────────────┘
</code></pre></div></div>

<p>深色主题配色：背景 <code class="language-plaintext highlighter-rouge">#0f1923</code>，卡片 <code class="language-plaintext highlighter-rouge">#1a2736</code>，强调色 <code class="language-plaintext highlighter-rouge">#00b4d8</code>。温度数字会变色：&lt;50°C 绿色 / 50-65°C 橙色 / &gt;65°C 红色。</p>

<p>交互亮点：</p>
<ul>
  <li>实时轮询刷新（按配置的轮询间隔）</li>
  <li>连接失败时指数退避重试（上限 30 秒）</li>
  <li>温控曲线 SVG 实时预览</li>
  <li>Toast 通知反馈操作结果</li>
</ul>

<h2 id="配置系统的设计">配置系统的设计</h2>

<p>配置采用 JSON 格式，支持两种版本：</p>

<p><strong>v1 扁平格式</strong>（向后兼容，单风扇场景）：</p>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="w">
  </span><span class="nl">"mode"</span><span class="p">:</span><span class="w"> </span><span class="s2">"auto"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"poll_interval"</span><span class="p">:</span><span class="w"> </span><span class="mi">2</span><span class="p">,</span><span class="w">
  </span><span class="nl">"temp_source"</span><span class="p">:</span><span class="w"> </span><span class="s2">"cpu"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"curve"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="w">
    </span><span class="p">{</span><span class="nl">"temp"</span><span class="p">:</span><span class="w"> </span><span class="mi">30</span><span class="p">,</span><span class="w"> </span><span class="nl">"pwm_percent"</span><span class="p">:</span><span class="w"> </span><span class="mi">20</span><span class="p">},</span><span class="w">
    </span><span class="p">{</span><span class="nl">"temp"</span><span class="p">:</span><span class="w"> </span><span class="mi">80</span><span class="p">,</span><span class="w"> </span><span class="nl">"pwm_percent"</span><span class="p">:</span><span class="w"> </span><span class="mi">100</span><span class="p">}</span><span class="w">
  </span><span class="p">]</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<p><strong>v2 zones 格式</strong>（多区域场景）：</p>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="w">
  </span><span class="nl">"zones"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="w">
    </span><span class="p">{</span><span class="w">
      </span><span class="nl">"id"</span><span class="p">:</span><span class="w"> </span><span class="s2">"zone_cpu"</span><span class="p">,</span><span class="w">
      </span><span class="nl">"name"</span><span class="p">:</span><span class="w"> </span><span class="s2">"CPU 风扇"</span><span class="p">,</span><span class="w">
      </span><span class="nl">"channels"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="s2">"pwm1"</span><span class="p">],</span><span class="w">
      </span><span class="nl">"temp_source"</span><span class="p">:</span><span class="w"> </span><span class="s2">"cpu"</span><span class="p">,</span><span class="w">
      </span><span class="nl">"mode"</span><span class="p">:</span><span class="w"> </span><span class="s2">"auto"</span><span class="p">,</span><span class="w">
      </span><span class="nl">"curve"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="err">...</span><span class="p">]</span><span class="w">
    </span><span class="p">},</span><span class="w">
    </span><span class="p">{</span><span class="w">
      </span><span class="nl">"id"</span><span class="p">:</span><span class="w"> </span><span class="s2">"zone_disk"</span><span class="p">,</span><span class="w">
      </span><span class="nl">"name"</span><span class="p">:</span><span class="w"> </span><span class="s2">"硬盘风扇"</span><span class="p">,</span><span class="w">
      </span><span class="nl">"channels"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="s2">"pwm2"</span><span class="p">],</span><span class="w">
      </span><span class="nl">"temp_source"</span><span class="p">:</span><span class="w"> </span><span class="s2">"disk"</span><span class="p">,</span><span class="w">
      </span><span class="nl">"mode"</span><span class="p">:</span><span class="w"> </span><span class="s2">"auto"</span><span class="p">,</span><span class="w">
      </span><span class="nl">"curve"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="err">...</span><span class="p">]</span><span class="w">
    </span><span class="p">}</span><span class="w">
  </span><span class="p">]</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">config_manager.py</code> 在运行时自动将 v1 格式包装成单区域的 v2 格式，<strong>不改写配置文件</strong>。这样老用户升级后配置文件不变，程序内部统一用 zones 格式处理。</p>

<h2 id="下一篇预告">下一篇预告</h2>

<p>最后一篇聊多设备适配的实战经验：不同芯片的坑、FPK 打包的注意事项、测试策略，以及项目的整体复盘。</p>

<hr />

<blockquote>
  <p>项目地址：<a href="https://github.com/AriesOxO/fnOS-fan-control">fnOS-fan-control on GitHub</a>（MIT 许可证）</p>
</blockquote>]]></content><author><name>meow</name><email>njwzcb@163.com</email></author><category term="Python" /><category term="NAS" /><category term="开发记录" /><category term="fnOS" /><category term="风扇控制" /><category term="架构设计" /><category term="hwmon" /><category term="sysfs" /><category term="开发日志" /><summary type="html"><![CDATA[聊聊 fnOS-fan-control 的架构设计：为什么 sysfs 就是最好的抽象层，如何用线性插值实现平滑温控，以及 Web 管理界面的实现细节。]]></summary></entry><entry><title type="html">fnOS 风扇控制开发日志（三）：多设备适配、测试策略与项目复盘</title><link href="https://ariesoxo.github.io/python/nas/%E5%BC%80%E5%8F%91%E8%AE%B0%E5%BD%95/2026/03/29/fnos-fan-control-dev-blog-3-review.html" rel="alternate" type="text/html" title="fnOS 风扇控制开发日志（三）：多设备适配、测试策略与项目复盘" /><published>2026-03-29T00:00:00+08:00</published><updated>2026-03-29T00:00:00+08:00</updated><id>https://ariesoxo.github.io/python/nas/%E5%BC%80%E5%8F%91%E8%AE%B0%E5%BD%95/2026/03/29/fnos-fan-control-dev-blog-3-review</id><content type="html" xml:base="https://ariesoxo.github.io/python/nas/%E5%BC%80%E5%8F%91%E8%AE%B0%E5%BD%95/2026/03/29/fnos-fan-control-dev-blog-3-review.html"><![CDATA[<p>系列最后一篇，聊聊多设备适配的实战、测试策略、FPK 打包，以及整个项目的复盘总结。</p>

<!--more-->

<h2 id="多设备适配芯片差异与统一方案">多设备适配：芯片差异与统一方案</h2>

<h3 id="支持的芯片家族">支持的芯片家族</h3>

<p>经过测试和社区反馈，目前兼容的芯片包括：</p>

<table>
  <thead>
    <tr>
      <th>芯片家族</th>
      <th>常见型号</th>
      <th>典型机型</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>ITE</td>
      <td>IT8772E, IT8786E, IT8688E</td>
      <td>飞牛 F2 Pro 等</td>
    </tr>
    <tr>
      <td>Nuvoton</td>
      <td>NCT6776, NCT6775, NCT6798</td>
      <td>部分第三方 NAS</td>
    </tr>
    <tr>
      <td>Fintek</td>
      <td>F71882FG, F71868A</td>
      <td>老款主板</td>
    </tr>
    <tr>
      <td>ARM</td>
      <td>cpu_thermal</td>
      <td>ARM 架构 NAS</td>
    </tr>
  </tbody>
</table>

<h3 id="踩过的坑">踩过的坑</h3>

<p><strong>坑 1：pwm_enable 的值在不同芯片上含义不完全一致</strong></p>

<p>大多数芯片：<code class="language-plaintext highlighter-rouge">1</code> = 手动控制，<code class="language-plaintext highlighter-rouge">2</code> = 自动（BIOS 控制）。但个别芯片还有 <code class="language-plaintext highlighter-rouge">0</code>（全速）、<code class="language-plaintext highlighter-rouge">3</code>（温控曲线）等值。</p>

<p>解决方案：统一使用 <code class="language-plaintext highlighter-rouge">pwm_enable=1</code> 进入手动模式，退出时统一恢复为 <code class="language-plaintext highlighter-rouge">2</code>。不依赖芯片特有的自动模式。</p>

<p><strong>坑 2：多芯片时通道名冲突</strong></p>

<p>两块芯片都有 <code class="language-plaintext highlighter-rouge">pwm1</code>，直接用名字会冲突。解决方案是检测到冲突时自动加芯片名前缀：</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>chip0: it8772e → it8772e_pwm1
chip1: nct6776 → nct6776_pwm1
</code></pre></div></div>

<p><strong>坑 3：BIOS 会抢回风扇控制权</strong></p>

<p>某些 BIOS 会定期把 <code class="language-plaintext highlighter-rouge">pwm_enable</code> 从 1 改回 2。如果不处理，用户会发现风扇控制「时灵时不灵」。</p>

<p>解决方案：每个控制周期开始前先检查 <code class="language-plaintext highlighter-rouge">pwm_enable</code>，如果被改了就改回来。这就是前面提到的「pwm_enable 自愈」机制。</p>

<h3 id="多区域控制的实现">多区域控制的实现</h3>

<p>多区域的核心思想是：<strong>每个风扇区域独立运行，互不干扰</strong>。</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>区域 A（CPU 风扇）：绑定 pwm1，跟踪 CPU 温度，使用激进曲线
区域 B（硬盘风扇）：绑定 pwm2，跟踪硬盘温度，使用保守曲线
</code></pre></div></div>

<p>实现上，控制循环每次迭代：</p>
<ol>
  <li>一次性读取所有温度（CPU + 硬盘）</li>
  <li>遍历每个区域，根据区域配置的温度来源取对应温度</li>
  <li>按区域自己的模式和曲线计算 PWM</li>
  <li>写入对应的 sysfs 文件</li>
</ol>

<p>区域级降级也是独立的——区域 A 的 PWM 写入失败不影响区域 B。</p>

<h2 id="测试策略120-个测试用例">测试策略：120 个测试用例</h2>

<h3 id="测试结构">测试结构</h3>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>tests/
├── mock_hardware.py        # 模拟硬件环境
├── test_hardware.py        # 硬件抽象层（30 个测试）
├── test_config_manager.py  # 配置管理（25 个测试）
├── test_fan_controller.py  # 控制逻辑（27 个测试）
└── test_web_server.py      # Web API + 认证（28 个测试）
</code></pre></div></div>

<h3 id="mock-硬件">Mock 硬件</h3>

<p>真实测试需要 NAS 硬件，但开发环境是普通 PC。<code class="language-plaintext highlighter-rouge">mock_hardware.py</code> 模拟了完整的 hwmon sysfs 结构：</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">class</span> <span class="nc">MockHardware</span><span class="p">:</span>
    <span class="sh">"""</span><span class="s">在 /tmp 下创建模拟的 hwmon 文件结构</span><span class="sh">"""</span>

    <span class="k">def</span> <span class="nf">create_chip</span><span class="p">(</span><span class="n">self</span><span class="p">,</span> <span class="n">name</span><span class="o">=</span><span class="sh">'</span><span class="s">it8772e</span><span class="sh">'</span><span class="p">,</span> <span class="n">channels</span><span class="o">=</span><span class="mi">1</span><span class="p">):</span>
        <span class="c1"># 创建 /tmp/hwmon0/name, pwm1, pwm1_enable, fan1_input 等文件
</span>        <span class="c1"># 支持读写，行为与真实 sysfs 一致
</span></code></pre></div></div>

<p>支持模拟 IT8772、NCT6776 和自定义芯片，可以测试多芯片冲突等边界场景。</p>

<h3 id="测试重点">测试重点</h3>

<p><strong>硬件层测试</strong>：</p>
<ul>
  <li>芯片探测（单芯片、多芯片、无芯片）</li>
  <li>CPU 温度读取（Intel label、AMD label、ARM thermal）</li>
  <li>PWM 读写（正常值、边界值、文件不存在）</li>
  <li>通道名冲突处理</li>
</ul>

<p><strong>控制逻辑测试</strong>：</p>
<ul>
  <li>线性插值计算（精确到小数点）</li>
  <li>温度边界值（低于最低节点、高于最高节点）</li>
  <li>模式切换（四种模式互相切换）</li>
  <li>降级触发（温度读取失败 N 次后自动降级）</li>
  <li>全速保护（温度异常时的应急响应）</li>
  <li>除零保护（两个节点温度相同时不崩溃）</li>
</ul>

<p><strong>配置管理测试</strong>：</p>
<ul>
  <li>v1/v2 格式兼容</li>
  <li>无效值自动回退默认值</li>
  <li>配置文件损坏恢复</li>
  <li>并发读写安全</li>
</ul>

<p><strong>Web API 测试</strong>：</p>
<ul>
  <li>所有 12 个端点的正常流程</li>
  <li>输入校验（超大 body、非法 JSON、越界参数）</li>
  <li>CORS 头部正确性</li>
  <li>密码认证流程</li>
</ul>

<h2 id="fpk-打包">FPK 打包</h2>

<p>FPK 是飞牛的应用包格式，类似 Synology 的 SPK。一个 FPK 包需要：</p>

<h3 id="生命周期脚本">生命周期脚本</h3>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>cmd/
├── main                    # start / stop / status
├── install_init            # 安装前检查
├── install_callback        # 安装后初始化
├── upgrade_init            # 升级前准备
├── upgrade_callback        # 升级后恢复
├── uninstall_init          # 卸载前清理
├── uninstall_callback      # 卸载后清理
├── config_init             # 配置初始化
└── config_callback         # 配置回调
</code></pre></div></div>

<p><strong>关键脚本逻辑</strong>：</p>

<p><code class="language-plaintext highlighter-rouge">main</code>（启动/停止）：</p>
<ul>
  <li>启动时先启动看门狗进程，再启动主应用</li>
  <li>停止时先停主应用，再停看门狗</li>
  <li><code class="language-plaintext highlighter-rouge">status</code> 检查主进程是否存活</li>
</ul>

<p><code class="language-plaintext highlighter-rouge">install_callback</code>（安装后）：</p>
<ul>
  <li>检查 Python 3.11+ 是否可用</li>
  <li>加载 <code class="language-plaintext highlighter-rouge">drivetemp</code> 内核模块（硬盘温度监控）</li>
  <li>设置正确的文件权限</li>
</ul>

<p><code class="language-plaintext highlighter-rouge">uninstall_init</code>（卸载前）：</p>
<ul>
  <li>停止服务</li>
  <li><strong>扫描所有 hwmon 设备，恢复 pwm_enable=2</strong></li>
  <li>确保卸载后风扇回到 BIOS 控制</li>
</ul>

<h3 id="安装向导">安装向导</h3>

<p>FPK 支持安装时弹出配置向导。我配置了一个端口选择向导，让用户在安装时就能指定 Web 界面的端口号，避免与其他服务冲突。</p>

<h3 id="ci-自动构建">CI 自动构建</h3>

<p>GitHub Actions 自动打包 FPK：</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># .github/workflows/build-fpk.yml</span>
<span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">Build FPK</span>
  <span class="na">run</span><span class="pi">:</span> <span class="s">bash scripts/build-fpk.sh</span>
<span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">Upload artifact</span>
  <span class="na">uses</span><span class="pi">:</span> <span class="s">actions/upload-artifact@v4</span>
  <span class="na">with</span><span class="pi">:</span>
    <span class="na">name</span><span class="pi">:</span> <span class="s">fnos-fan-control.fpk</span>
    <span class="na">path</span><span class="pi">:</span> <span class="s">dist/*.fpk</span>
</code></pre></div></div>

<p>每次 push 或 tag 都会自动构建，Release 页面可以直接下载。</p>

<h2 id="项目复盘">项目复盘</h2>

<h3 id="做对了的事">做对了的事</h3>

<p><strong>1. 零依赖策略</strong></p>

<p>从第一天就决定不引入第三方包，事后证明这是对的。NAS 用户的环境各不相同，零依赖意味着只要有 Python 就能跑，安装从不出问题。</p>

<p><strong>2. 安全优先设计</strong></p>

<p>安全机制不是事后补的，而是从架构层面就考虑进去的。看门狗、多层降级、最低转速保护，这些在第一个版本就有了。硬件控制程序出问题的代价太大，安全必须是一等公民。</p>

<p><strong>3. sysfs 就是抽象层</strong></p>

<p>没有搞复杂的驱动类继承体系，直接利用 Linux hwmon 的统一接口。代码简单，兼容性反而更好。</p>

<p><strong>4. 渐进式多区域</strong></p>

<p>v1 单风扇和 v2 多区域共存，老配置不用改，新功能可选启用。运行时自动适配，没有迁移负担。</p>

<h3 id="可以改进的地方">可以改进的地方</h3>

<p><strong>1. 前端多区域支持</strong></p>

<p>后端的多区域功能已经完整，但前端还没有完全跟上。目前多区域配置需要手动编辑 JSON。这是 v1.2 的重点。</p>

<p><strong>2. 温度历史图表</strong></p>

<p>目前只有实时数据，没有历史趋势图。对于调试温控曲线来说，能看到过去几小时的温度变化会很有帮助。</p>

<p><strong>3. 通知机制</strong></p>

<p>温度告警时能推送通知（飞牛系统通知或邮件）会更实用。</p>

<h3 id="数据总结">数据总结</h3>

<table>
  <thead>
    <tr>
      <th>指标</th>
      <th>数值</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>代码量（Python 后端）</td>
      <td>~1500 行</td>
    </tr>
    <tr>
      <td>代码量（前端）</td>
      <td>~2000 行</td>
    </tr>
    <tr>
      <td>代码量（Shell 脚本）</td>
      <td>~400 行</td>
    </tr>
    <tr>
      <td>测试用例</td>
      <td>120 个</td>
    </tr>
    <tr>
      <td>API 端点</td>
      <td>12 个</td>
    </tr>
    <tr>
      <td>第三方依赖</td>
      <td>0</td>
    </tr>
    <tr>
      <td>支持的芯片家族</td>
      <td>3+（ITE、Nuvoton、Fintek 及其他 hwmon 芯片）</td>
    </tr>
    <tr>
      <td>安全保护层数</td>
      <td>5 层</td>
    </tr>
  </tbody>
</table>

<h2 id="写在最后">写在最后</h2>

<p>这个项目解决了一个很小但很实际的问题——让 NAS 安静下来。技术上没有什么高深的东西，就是把 Linux sysfs 接口用好，把异常情况想全，把安全保护做到位。</p>

<p>如果你也在用飞牛NAS，被风扇噪音困扰，欢迎试试这个工具。也欢迎提 Issue 和 PR，特别是不同机型的兼容性反馈。</p>

<hr />

<blockquote>
  <p>项目地址：<a href="https://github.com/AriesOxO/fnOS-fan-control">fnOS-fan-control on GitHub</a>（MIT 许可证）</p>
</blockquote>]]></content><author><name>meow</name><email>njwzcb@163.com</email></author><category term="Python" /><category term="NAS" /><category term="开发记录" /><category term="fnOS" /><category term="风扇控制" /><category term="测试" /><category term="FPK" /><category term="多设备适配" /><category term="开发日志" /><summary type="html"><![CDATA[fnOS-fan-control 开发日志最终篇：多芯片适配的实战经验、120 个测试用例的策略、FPK 打包踩坑记录，以及从零到 v1.1.0 的完整复盘。]]></summary></entry><entry><title type="html">辣评 v0.9.0 大版本冲刺：暗黑模式、欠债重构与全面打磨（二十九）</title><link href="https://ariesoxo.github.io/%E5%BC%80%E5%8F%91%E8%AE%B0%E5%BD%95/%E7%89%88%E6%9C%AC%E8%BF%AD%E4%BB%A3/2026/03/29/v0.9.0%E5%A4%A7%E7%89%88%E6%9C%AC%E5%86%B2%E5%88%BA.html" rel="alternate" type="text/html" title="辣评 v0.9.0 大版本冲刺：暗黑模式、欠债重构与全面打磨（二十九）" /><published>2026-03-29T00:00:00+08:00</published><updated>2026-03-29T00:00:00+08:00</updated><id>https://ariesoxo.github.io/%E5%BC%80%E5%8F%91%E8%AE%B0%E5%BD%95/%E7%89%88%E6%9C%AC%E8%BF%AD%E4%BB%A3/2026/03/29/v0.9.0%E5%A4%A7%E7%89%88%E6%9C%AC%E5%86%B2%E5%88%BA</id><content type="html" xml:base="https://ariesoxo.github.io/%E5%BC%80%E5%8F%91%E8%AE%B0%E5%BD%95/%E7%89%88%E6%9C%AC%E8%BF%AD%E4%BB%A3/2026/03/29/v0.9.0%E5%A4%A7%E7%89%88%E6%9C%AC%E5%86%B2%E5%88%BA.html"><![CDATA[<p>三天时间，60+ 次提交，版本号从 <code class="language-plaintext highlighter-rouge">v0.1.0-beta</code> 跳到 <code class="language-plaintext highlighter-rouge">v0.9.0</code>。这是辣评平台到目前为止最密集的一次开发冲刺，涵盖了暗黑模式全量适配、欠债系统 v3 重构、管理界面大规模优化、文案提示系统、审计日志增强、用户手册重写等多个维度。本文记录这三天的核心工作和踩过的坑。</p>

<hr />

<h2 id="一暗黑模式从能用到全量可用">一、暗黑模式：从”能用”到”全量可用”</h2>

<p>暗黑模式是这次冲刺中工作量最大的部分。Element Plus 自带暗黑主题支持，但实际落地远没有想象中简单——我们经历了<strong>五轮修复</strong>才彻底解决问题。</p>

<h3 id="11-管理后台css-变量缺失">1.1 管理后台：CSS 变量缺失</h3>

<p>第一轮修复管理后台。问题很直接：侧边栏、仪表盘、设置页等组件中有大量硬编码的白色背景色。</p>

<p>解决方案是新建 <code class="language-plaintext highlighter-rouge">admin-tokens.css</code>，定义暗黑模式下的 CSS 变量：</p>

<div class="language-css highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c">/* admin-vue/src/theme/admin-tokens.css */</span>
<span class="nt">html</span><span class="nc">.dark</span> <span class="p">{</span>
  <span class="py">--app-bg</span><span class="p">:</span> <span class="nx">#141414</span><span class="p">;</span>
  <span class="py">--app-surface</span><span class="p">:</span> <span class="nx">#1d1e1f</span><span class="p">;</span>
  <span class="py">--app-surface-alt</span><span class="p">:</span> <span class="nx">#262727</span><span class="p">;</span>
  <span class="py">--app-text</span><span class="p">:</span> <span class="nx">#e5eaf3</span><span class="p">;</span>
  <span class="py">--app-text-secondary</span><span class="p">:</span> <span class="nx">#a3a6ad</span><span class="p">;</span>
  <span class="py">--app-border</span><span class="p">:</span> <span class="nx">#414243</span><span class="p">;</span>
  <span class="py">--el-card-bg-color</span><span class="p">:</span> <span class="nf">var</span><span class="p">(</span><span class="l">--app-surface</span><span class="p">);</span>
<span class="p">}</span>
</code></pre></div></div>

<p>然后逐页将 <code class="language-plaintext highlighter-rouge">background: #fff</code> 替换为 <code class="language-plaintext highlighter-rouge">background: var(--app-surface)</code>。管理后台涉及 6 个组件，替换了 8 处硬编码。</p>

<h3 id="12-前台页面22-处硬编码白色背景">1.2 前台页面：22 处硬编码白色背景</h3>

<p>管理后台修完后，发现前台页面（排行榜、评论、投稿等）同样有大量白色背景。这次用相同策略，新建 <code class="language-plaintext highlighter-rouge">front-tokens.css</code> 并批量替换，一次处理了 10 个文件中的 22 处硬编码。</p>

<h3 id="13-根本性问题scoped-样式与-htmldark-选择器">1.3 根本性问题：scoped 样式与 html.dark 选择器</h3>

<p>前两轮修完后仍然有页面暗黑模式不生效。排查发现了一个<strong>根本性问题</strong>：</p>

<div class="language-vue highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c">&lt;!-- ❌ 这样写不生效！ --&gt;</span>
<span class="nt">&lt;</span><span class="k">style</span> <span class="na">scoped</span><span class="nt">&gt;</span>
<span class="nt">html</span><span class="nc">.dark</span> <span class="nc">.my-component</span> <span class="p">{</span>
  <span class="nl">background</span><span class="p">:</span> <span class="nx">#1d1e1f</span><span class="p">;</span>
<span class="p">}</span>
<span class="nt">&lt;/</span><span class="k">style</span><span class="nt">&gt;</span>
</code></pre></div></div>

<p>Vue 的 <code class="language-plaintext highlighter-rouge">&lt;style scoped&gt;</code> 会给选择器加上 <code class="language-plaintext highlighter-rouge">data-v-xxx</code> 属性限定，导致 <code class="language-plaintext highlighter-rouge">html.dark</code> 这种向上跨组件的选择器完全失效。</p>

<p><strong>解决方案</strong>：将暗黑模式相关样式从 <code class="language-plaintext highlighter-rouge">&lt;style scoped&gt;</code> 中提取到单独的非 scoped <code class="language-plaintext highlighter-rouge">&lt;style&gt;</code> 标签中：</p>

<div class="language-vue highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nt">&lt;</span><span class="k">style</span> <span class="na">scoped</span><span class="nt">&gt;</span>
<span class="c">/* 组件常规样式 */</span>
<span class="nc">.ranking-card</span> <span class="p">{</span> <span class="nl">background</span><span class="p">:</span> <span class="nf">var</span><span class="p">(</span><span class="l">--app-surface</span><span class="p">);</span> <span class="p">}</span>
<span class="nt">&lt;/</span><span class="k">style</span><span class="nt">&gt;</span>

<span class="c">&lt;!-- 暗黑模式覆盖必须放在非 scoped 的 style 中 --&gt;</span>
<span class="nt">&lt;</span><span class="k">style</span><span class="nt">&gt;</span>
<span class="nt">html</span><span class="nc">.dark</span> <span class="nc">.ranking-card</span> <span class="p">{</span> <span class="nl">background</span><span class="p">:</span> <span class="nf">var</span><span class="p">(</span><span class="l">--app-surface-alt</span><span class="p">);</span> <span class="p">}</span>
<span class="nt">&lt;/</span><span class="k">style</span><span class="nt">&gt;</span>
</code></pre></div></div>

<p>这次修复涉及排行榜、评论列表、参加比赛、添加评论 4 个核心页面。</p>

<h3 id="14-深度修复echarts-图表与边角组件">1.4 深度修复：ECharts 图表与边角组件</h3>

<p>还有 ECharts 统计图表在暗黑模式下文字看不清的问题。通过监听主题切换事件，动态更新图表配色：</p>

<div class="language-typescript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// 监听暗黑模式切换</span>
<span class="kd">const</span> <span class="nx">isDark</span> <span class="o">=</span> <span class="nf">useDark</span><span class="p">()</span>
<span class="nf">watch</span><span class="p">(</span><span class="nx">isDark</span><span class="p">,</span> <span class="p">()</span> <span class="o">=&gt;</span> <span class="p">{</span>
  <span class="nx">chart</span><span class="p">.</span><span class="nf">setOption</span><span class="p">({</span>
    <span class="na">textStyle</span><span class="p">:</span> <span class="p">{</span> <span class="na">color</span><span class="p">:</span> <span class="nx">isDark</span><span class="p">.</span><span class="nx">value</span> <span class="p">?</span> <span class="dl">'</span><span class="s1">#e5eaf3</span><span class="dl">'</span> <span class="p">:</span> <span class="dl">'</span><span class="s1">#303133</span><span class="dl">'</span> <span class="p">},</span>
    <span class="na">legend</span><span class="p">:</span> <span class="p">{</span> <span class="na">textStyle</span><span class="p">:</span> <span class="p">{</span> <span class="na">color</span><span class="p">:</span> <span class="nx">isDark</span><span class="p">.</span><span class="nx">value</span> <span class="p">?</span> <span class="dl">'</span><span class="s1">#e5eaf3</span><span class="dl">'</span> <span class="p">:</span> <span class="dl">'</span><span class="s1">#606266</span><span class="dl">'</span> <span class="p">}</span> <span class="p">}</span>
  <span class="p">})</span>
<span class="p">})</span>
</code></pre></div></div>

<p>最终统计：暗黑模式适配共修复 <strong>60+ 处</strong>样式问题，覆盖全部前台和后台页面。</p>

<hr />

<h2 id="二欠债补评论系统-v3-重构">二、欠债补评论系统 v3 重构</h2>

<h3 id="21-从三维度到单维度">2.1 从三维度到单维度</h3>

<p>v2 的欠债系统使用三个维度（等效评论数、实际评论数、字数）来计算欠债，导致规则复杂且用户难以理解。v3 重构为<strong>单维度等效评论模型</strong>，大幅简化：</p>

<ul>
  <li>用户只需要关注一个数字：欠多少条等效评论</li>
  <li>惩罚规则简化：零评论罚评数、单笔欠债上限、最大禁投届数三个参数可在后台配置</li>
  <li>前端 <code class="language-plaintext highlighter-rouge">DebtManagement.vue</code> 从三列展示改为单列，表头增加帮助 tooltip 解释计算规则</li>
</ul>

<h3 id="22-配置缺失问题">2.2 配置缺失问题</h3>

<p>重构后发现后端 <code class="language-plaintext highlighter-rouge">InitDefaultSettings</code> 中缺少调度器相关的默认配置，导致欠债补评任务无法正常触发。同时 <code class="language-plaintext highlighter-rouge">debt_count</code> 列已被移除但查询代码中仍在引用。两个问题一起修复：</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c">// cmd/server/database/database.go - 补充默认配置</span>
<span class="k">func</span> <span class="n">InitDefaultSettings</span><span class="p">()</span> <span class="p">{</span>
    <span class="n">defaults</span> <span class="o">:=</span> <span class="k">map</span><span class="p">[</span><span class="kt">string</span><span class="p">]</span><span class="kt">string</span><span class="p">{</span>
        <span class="s">"debt_zero_comment_penalty"</span><span class="o">:</span> <span class="s">"2"</span><span class="p">,</span>
        <span class="s">"debt_max_per_entry"</span><span class="o">:</span>        <span class="s">"5"</span><span class="p">,</span>
        <span class="s">"debt_max_ban_periods"</span><span class="o">:</span>      <span class="s">"3"</span><span class="p">,</span>
        <span class="c">// ...调度器配置</span>
    <span class="p">}</span>
    <span class="k">for</span> <span class="n">key</span><span class="p">,</span> <span class="n">val</span> <span class="o">:=</span> <span class="k">range</span> <span class="n">defaults</span> <span class="p">{</span>
        <span class="n">database</span><span class="o">.</span><span class="n">DB</span><span class="o">.</span><span class="n">FirstOrCreate</span><span class="p">(</span><span class="o">&amp;</span><span class="n">models</span><span class="o">.</span><span class="n">Settings</span><span class="p">{},</span> <span class="n">models</span><span class="o">.</span><span class="n">Settings</span><span class="p">{</span><span class="n">Key</span><span class="o">:</span> <span class="n">key</span><span class="p">,</span> <span class="n">Value</span><span class="o">:</span> <span class="n">val</span><span class="p">})</span>
    <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p>所有测试文件也同步适配了 v3 单维度模型。</p>

<hr />

<h2 id="三管理界面全面优化">三、管理界面全面优化</h2>

<p>这次冲刺对管理后台几乎所有页面做了一轮系统性优化。</p>

<h3 id="31-侧边栏重组">3.1 侧边栏重组</h3>

<p>原来的侧边栏菜单项扁平排列，改为<strong>四组分类</strong>：概览、内容管理、数据与规则、系统。每个菜单项使用唯一图标，整体视觉更清晰：</p>

<div class="language-vue highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c">&lt;!-- 分组式侧边栏 --&gt;</span>
<span class="nt">&lt;el-menu-item-group</span> <span class="na">title=</span><span class="s">"概览"</span><span class="nt">&gt;</span>
  <span class="nt">&lt;el-menu-item</span> <span class="na">index=</span><span class="s">"/admin/dashboard"</span><span class="nt">&gt;&lt;el-icon&gt;&lt;Odometer</span> <span class="nt">/&gt;&lt;/el-icon&gt;</span>仪表盘<span class="nt">&lt;/el-menu-item&gt;</span>
<span class="nt">&lt;/el-menu-item-group&gt;</span>
<span class="nt">&lt;el-menu-item-group</span> <span class="na">title=</span><span class="s">"内容管理"</span><span class="nt">&gt;</span>
  <span class="nt">&lt;el-menu-item</span> <span class="na">index=</span><span class="s">"/admin/comments"</span><span class="nt">&gt;</span>...<span class="nt">&lt;/el-menu-item&gt;</span>
  <span class="nt">&lt;el-menu-item</span> <span class="na">index=</span><span class="s">"/admin/submissions"</span><span class="nt">&gt;</span>...<span class="nt">&lt;/el-menu-item&gt;</span>
<span class="nt">&lt;/el-menu-item-group&gt;</span>
</code></pre></div></div>

<h3 id="32-操作列瘦身">3.2 操作列瘦身</h3>

<p>用户管理和比赛管理页面的操作列原来有多个按钮并排，占用 280-350px 宽度。改为「编辑 + 更多下拉菜单」模式，宽度降到 160px：</p>

<div class="language-vue highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nt">&lt;el-table-column</span> <span class="na">label=</span><span class="s">"操作"</span> <span class="na">width=</span><span class="s">"160"</span> <span class="na">fixed=</span><span class="s">"right"</span><span class="nt">&gt;</span>
  <span class="nt">&lt;</span><span class="k">template</span> <span class="na">#default=</span><span class="s">"{ row }"</span><span class="nt">&gt;</span>
    <span class="nt">&lt;el-button</span> <span class="na">size=</span><span class="s">"small"</span> <span class="err">@</span><span class="na">click=</span><span class="s">"editUser(row)"</span><span class="nt">&gt;</span>编辑<span class="nt">&lt;/el-button&gt;</span>
    <span class="nt">&lt;el-dropdown&gt;</span>
      <span class="nt">&lt;el-button</span> <span class="na">size=</span><span class="s">"small"</span><span class="nt">&gt;</span>更多<span class="nt">&lt;el-icon&gt;&lt;ArrowDown</span> <span class="nt">/&gt;&lt;/el-icon&gt;&lt;/el-button&gt;</span>
      <span class="nt">&lt;template</span> <span class="na">#dropdown</span><span class="nt">&gt;</span>
        <span class="nt">&lt;el-dropdown-menu&gt;</span>
          <span class="nt">&lt;el-dropdown-item</span> <span class="err">@</span><span class="na">click=</span><span class="s">"resetPassword(row)"</span><span class="nt">&gt;</span>重置密码<span class="nt">&lt;/el-dropdown-item&gt;</span>
          <span class="nt">&lt;el-dropdown-item</span> <span class="err">@</span><span class="na">click=</span><span class="s">"toggleBan(row)"</span><span class="nt">&gt;&lt;/el-dropdown-item&gt;</span>
        <span class="nt">&lt;/el-dropdown-menu&gt;</span>
      <span class="nt">&lt;/</span><span class="k">template</span><span class="nt">&gt;</span>
    <span class="nt">&lt;/el-dropdown&gt;</span>
  <span class="nt">&lt;/template&gt;</span>
<span class="nt">&lt;/el-table-column&gt;</span>
</code></pre></div></div>

<h3 id="33-系统设置页面重构">3.3 系统设置页面重构</h3>

<p>系统设置页从单栏改为<strong>双栏布局</strong>，Tab 合并调度器设置，统一保存按钮和全局 loading 状态。同时新增欠债惩罚参数的可视化配置，并补充了 20 个 Playwright E2E 测试用例。</p>

<h3 id="34-仪表盘精简">3.4 仪表盘精简</h3>

<p>仪表盘清理了 230 行旧代码（包括不再使用的图表和统计项），参赛管理 Tab 的 tooltip 改为动态读取后端配置。</p>

<hr />

<h2 id="四文案提示系统">四、文案提示系统</h2>

<h3 id="41-问题背景">4.1 问题背景</h3>

<p>辣评有大量业务规则（等效评论公式、合格条件、欠债惩罚规则等），之前这些文案散落在各个组件中，既难以维护也容易出现不一致。</p>

<h3 id="42-集中管理方案">4.2 集中管理方案</h3>

<p>创建 <code class="language-plaintext highlighter-rouge">helpTexts.ts</code> 配置文件和 <code class="language-plaintext highlighter-rouge">useHelpTexts</code> composable：</p>

<div class="language-typescript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// admin-vue/src/config/helpTexts.ts</span>
<span class="k">export</span> <span class="kd">const</span> <span class="nx">helpTexts</span> <span class="o">=</span> <span class="p">{</span>
  <span class="na">qualification</span><span class="p">:</span> <span class="p">{</span>
    <span class="na">formula</span><span class="p">:</span> <span class="dl">'</span><span class="s1">等效评论 = 短篇评论×1 + 中篇评论×1.5 + 长篇评论×2</span><span class="dl">'</span><span class="p">,</span>
    <span class="na">condition</span><span class="p">:</span> <span class="dl">'</span><span class="s1">等效评论 ≥ {minEquivalent} 且 实际评论 ≥ {minActual} 且 总字数 ≥ {minWords}</span><span class="dl">'</span><span class="p">,</span>
    <span class="na">tooltip</span><span class="p">:</span> <span class="dl">'</span><span class="s1">合格条件中的阈值可在系统设置中配置</span><span class="dl">'</span>
  <span class="p">},</span>
  <span class="na">debt</span><span class="p">:</span> <span class="p">{</span>
    <span class="na">penalty</span><span class="p">:</span> <span class="dl">'</span><span class="s1">零评论额外罚 {zeroPenalty} 条，单笔上限 {maxPerEntry} 条</span><span class="dl">'</span><span class="p">,</span>
    <span class="na">ban</span><span class="p">:</span> <span class="dl">'</span><span class="s1">连续欠债 {maxBanPeriods} 届触发禁投</span><span class="dl">'</span>
  <span class="p">}</span>
  <span class="c1">// ...共 20 处文案</span>
<span class="p">}</span>
</code></pre></div></div>

<div class="language-typescript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// admin-vue/src/composables/useHelpTexts.ts</span>
<span class="k">export</span> <span class="kd">function</span> <span class="nf">useHelpTexts</span><span class="p">(</span><span class="nx">section</span><span class="p">:</span> <span class="kr">string</span><span class="p">)</span> <span class="p">{</span>
  <span class="kd">const</span> <span class="nx">settings</span> <span class="o">=</span> <span class="nf">useSettingsStore</span><span class="p">()</span>
  <span class="c1">// 动态替换 {minEquivalent} 等占位符为后端配置值</span>
  <span class="k">return</span> <span class="nf">computed</span><span class="p">(()</span> <span class="o">=&gt;</span> <span class="nf">interpolate</span><span class="p">(</span><span class="nx">helpTexts</span><span class="p">[</span><span class="nx">section</span><span class="p">],</span> <span class="nx">settings</span><span class="p">.</span><span class="nx">config</span><span class="p">))</span>
<span class="p">}</span>
</code></pre></div></div>

<p>最终接入了 6 个页面、13 处文案提示，覆盖了所有 P2/P3 级别的业务规则说明。</p>

<hr />

<h2 id="五审计日志增强">五、审计日志增强</h2>

<h3 id="51-覆盖面扩展">5.1 覆盖面扩展</h3>

<p>原来审计日志只记录 8 种操作类型。这次补充了 15 处写操作的审计记录，覆盖比赛管理、规则管理、评论操作、投稿操作、系统配置、调度器启停等，操作类型扩展到 27 种。</p>

<h3 id="52-csv-导出">5.2 CSV 导出</h3>

<p>新增导出接口，支持按当前筛选条件导出，输出 UTF-8 BOM 编码确保 Excel 直接打开不乱码：</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c">// cmd/server/handlers/audit_handler.go</span>
<span class="k">func</span> <span class="n">ExportAuditLogs</span><span class="p">(</span><span class="n">c</span> <span class="o">*</span><span class="n">gin</span><span class="o">.</span><span class="n">Context</span><span class="p">)</span> <span class="p">{</span>
    <span class="c">// ... 筛选逻辑同列表接口</span>
    <span class="n">c</span><span class="o">.</span><span class="n">Header</span><span class="p">(</span><span class="s">"Content-Type"</span><span class="p">,</span> <span class="s">"text/csv; charset=utf-8"</span><span class="p">)</span>
    <span class="n">c</span><span class="o">.</span><span class="n">Header</span><span class="p">(</span><span class="s">"Content-Disposition"</span><span class="p">,</span> <span class="s">"attachment; filename=audit-logs.csv"</span><span class="p">)</span>
    <span class="c">// 写入 BOM</span>
    <span class="n">c</span><span class="o">.</span><span class="n">Writer</span><span class="o">.</span><span class="n">Write</span><span class="p">([]</span><span class="kt">byte</span><span class="p">{</span><span class="m">0xEF</span><span class="p">,</span> <span class="m">0xBB</span><span class="p">,</span> <span class="m">0xBF</span><span class="p">})</span>
    <span class="n">writer</span> <span class="o">:=</span> <span class="n">csv</span><span class="o">.</span><span class="n">NewWriter</span><span class="p">(</span><span class="n">c</span><span class="o">.</span><span class="n">Writer</span><span class="p">)</span>
    <span class="n">writer</span><span class="o">.</span><span class="n">Write</span><span class="p">([]</span><span class="kt">string</span><span class="p">{</span><span class="s">"时间"</span><span class="p">,</span> <span class="s">"操作人"</span><span class="p">,</span> <span class="s">"操作类型"</span><span class="p">,</span> <span class="s">"操作对象"</span><span class="p">,</span> <span class="s">"详情"</span><span class="p">})</span>
    <span class="c">// ...</span>
<span class="p">}</span>
</code></pre></div></div>

<h3 id="53-前端页面优化">5.3 前端页面优化</h3>

<p>审计日志页面做了 5 项改进：操作类型全中文化、分组筛选下拉、JSON 详情格式化展示、操作对象可读化（从 <code class="language-plaintext highlighter-rouge">user:123</code> 变为显示用户名）、增加导出按钮。</p>

<hr />

<h2 id="六用户手册-v20">六、用户手册 v2.0</h2>

<h3 id="61-全面重写">6.1 全面重写</h3>

<p>原有的 10 章英文手册全部替换为 13 章中文文档，按照实际功能模块重新组织：</p>

<ol>
  <li>系统概述</li>
  <li>注册与登录</li>
  <li>比赛与投稿</li>
  <li>评论与评分</li>
  <li>排行榜与统计</li>
  <li>任务与资格</li>
  <li>用户管理</li>
  <li>规则管理</li>
  <li>投稿管理</li>
  <li>欠债管理</li>
  <li>系统设置</li>
  <li>审计日志</li>
  <li>常见问题</li>
</ol>

<p>新增了排行榜、任务追踪、审计日志三个之前缺失的章节，并补充了暗黑模式的适配样式。</p>

<hr />

<h2 id="七星级评分组件优化">七、星级评分组件优化</h2>

<p>评论打分从原来的自由数字输入改为 <strong>1-5 星评分</strong>，带渐变色和文字描述：</p>

<div class="language-vue highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nt">&lt;el-rate</span>
  <span class="na">v-model=</span><span class="s">"form.rating"</span>
  <span class="na">:texts=</span><span class="s">"['很差', '较差', '一般', '较好', '很好']"</span>
  <span class="na">:colors=</span><span class="s">"['#F56C6C', '#E6A23C', '#909399', '#67C23A', '#409EFF']"</span>
  <span class="na">show-text</span>
  <span class="na">:allow-half=</span><span class="s">"false"</span>
<span class="nt">/&gt;</span>
</code></pre></div></div>

<p>代评弹窗中的评分组件也同步升级，保持前后台交互一致。</p>

<hr />

<h2 id="八工程化改进">八、工程化改进</h2>

<h3 id="81-版本号单一来源">8.1 版本号单一来源</h3>

<p>之前版本号分散在 <code class="language-plaintext highlighter-rouge">package.json</code>、<code class="language-plaintext highlighter-rouge">.env</code>、<code class="language-plaintext highlighter-rouge">config/index.ts</code> 三处，容易出现不一致。重构为 <code class="language-plaintext highlighter-rouge">version.json</code> 单一来源：</p>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="w">
  </span><span class="nl">"version"</span><span class="p">:</span><span class="w"> </span><span class="s2">"0.9.0"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"versionName"</span><span class="p">:</span><span class="w"> </span><span class="s2">"辣评"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"buildDate"</span><span class="p">:</span><span class="w"> </span><span class="s2">"2026-03-29"</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<p>构建时通过 <code class="language-plaintext highlighter-rouge">prebuild</code> 脚本自动同步到 <code class="language-plaintext highlighter-rouge">package.json</code>：</p>

<div class="language-javascript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// admin-vue/scripts/sync-version.js</span>
<span class="kd">const</span> <span class="nx">version</span> <span class="o">=</span> <span class="nf">require</span><span class="p">(</span><span class="dl">'</span><span class="s1">../version.json</span><span class="dl">'</span><span class="p">)</span>
<span class="kd">const</span> <span class="nx">pkg</span> <span class="o">=</span> <span class="nf">require</span><span class="p">(</span><span class="dl">'</span><span class="s1">../package.json</span><span class="dl">'</span><span class="p">)</span>
<span class="nx">pkg</span><span class="p">.</span><span class="nx">version</span> <span class="o">=</span> <span class="nx">version</span><span class="p">.</span><span class="nx">version</span>
<span class="nx">fs</span><span class="p">.</span><span class="nf">writeFileSync</span><span class="p">(</span><span class="dl">'</span><span class="s1">package.json</span><span class="dl">'</span><span class="p">,</span> <span class="nx">JSON</span><span class="p">.</span><span class="nf">stringify</span><span class="p">(</span><span class="nx">pkg</span><span class="p">,</span> <span class="kc">null</span><span class="p">,</span> <span class="mi">2</span><span class="p">))</span>
</code></pre></div></div>

<h3 id="82-部署脚本增强">8.2 部署脚本增强</h3>

<p>部署脚本新增用户手册上传步骤，确保文档与代码同步部署。</p>

<h3 id="83-changelog">8.3 CHANGELOG</h3>

<p>正式建立版本变更日志，记录从 v0.1.0 到 v0.9.0 的所有功能变更，遵循语义化版本规范。</p>

<hr />

<h2 id="九其他修复与优化">九、其他修复与优化</h2>

<ul>
  <li><strong>评论管理不加载数据</strong>：<code class="language-plaintext highlighter-rouge">onMounted</code> 中调用了未定义的 <code class="language-plaintext highlighter-rouge">loadFilters()</code> 导致崩溃，直接移除该调用</li>
  <li><strong>投稿管理无默认届次</strong>：新增 <code class="language-plaintext highlighter-rouge">getActiveCompetition</code> 接口，页面加载时自动选中最新一届</li>
  <li><strong>禁赛规则调整</strong>：禁赛用户仅限制投稿，不再限制登录和评论（用户体验改善）</li>
  <li><strong>个人中心 15 项改进</strong>：统计项增加 tooltip、布局响应式优化、密码修改交互优化等</li>
  <li><strong>测试数据种子脚本</strong>：生成 15 个覆盖各种业务场景的测试账号，方便开发调试</li>
  <li><strong>移除前台全局搜索</strong>：功能使用率极低且增加维护负担，清理掉</li>
</ul>

<hr />

<h2 id="总结">总结</h2>

<ol>
  <li>
    <p><strong>暗黑模式的教训</strong>：Vue scoped 样式中不能使用 <code class="language-plaintext highlighter-rouge">html.dark</code> 这类跨组件选择器，这是一个很容易忽略的陷阱。建议一开始就用 CSS 变量方案，避免在每个组件中重复写暗黑覆盖样式。</p>
  </li>
  <li>
    <p><strong>业务规则文案集中管理</strong>：当系统中有大量业务规则需要向用户解释时，散落在各组件中的文案迟早会出问题。<code class="language-plaintext highlighter-rouge">helpTexts + composable</code> 的方案成本低、效果好。</p>
  </li>
  <li>
    <p><strong>欠债系统简化</strong>：三维度模型看起来更精确，但用户理解成本太高。单维度模型虽然信息量少了，但用户更容易做出正确的行为响应。产品设计上，简单可理解比精确但复杂更重要。</p>
  </li>
  <li>
    <p><strong>操作列下拉菜单</strong>：管理后台表格的操作列如果按钮超过 2 个，就应该用下拉菜单收纳。否则表格宽度会被操作列挤占，主要数据反而展示不全。</p>
  </li>
  <li>
    <p><strong>版本号管理</strong>：任何需要在多处引用的配置值都应该有唯一来源。分散维护版本号是技术债务，迟早会出不一致的问题。</p>
  </li>
</ol>

<p>这三天的冲刺让辣评平台从一个功能基本可用的状态提升到了交互体验和工程质量都相对成熟的 v0.9.0。接下来的重点将是稳定性测试和 v1.0 正式发布。</p>]]></content><author><name>meow</name></author><category term="开发记录" /><category term="版本迭代" /><category term="辣评" /><category term="暗黑模式" /><category term="欠债系统" /><category term="界面优化" /><category term="审计日志" /><summary type="html"><![CDATA[三天时间，60+ 次提交，版本号从 v0.1.0-beta 跳到 v0.9.0。这是辣评平台到目前为止最密集的一次开发冲刺，涵盖了暗黑模式全量适配、欠债系统 v3 重构、管理界面大规模优化、文案提示系统、审计日志增强、用户手册重写等多个维度。本文记录这三天的核心工作和踩过的坑。 一、暗黑模式：从”能用”到”全量可用” 暗黑模式是这次冲刺中工作量最大的部分。Element Plus 自带暗黑主题支持，但实际落地远没有想象中简单——我们经历了五轮修复才彻底解决问题。 1.1 管理后台：CSS 变量缺失 第一轮修复管理后台。问题很直接：侧边栏、仪表盘、设置页等组件中有大量硬编码的白色背景色。 解决方案是新建 admin-tokens.css，定义暗黑模式下的 CSS 变量： /* admin-vue/src/theme/admin-tokens.css */ html.dark { --app-bg: #141414; --app-surface: #1d1e1f; --app-surface-alt: #262727; --app-text: #e5eaf3; --app-text-secondary: #a3a6ad; --app-border: #414243; --el-card-bg-color: var(--app-surface); } 然后逐页将 background: #fff 替换为 background: var(--app-surface)。管理后台涉及 6 个组件，替换了 8 处硬编码。 1.2 前台页面：22 处硬编码白色背景 管理后台修完后，发现前台页面（排行榜、评论、投稿等）同样有大量白色背景。这次用相同策略，新建 front-tokens.css 并批量替换，一次处理了 10 个文件中的 22 处硬编码。 1.3 根本性问题：scoped 样式与 html.dark 选择器 前两轮修完后仍然有页面暗黑模式不生效。排查发现了一个根本性问题： &lt;!-- ❌ 这样写不生效！ --&gt; &lt;style scoped&gt; html.dark .my-component { background: #1d1e1f; } &lt;/style&gt; Vue 的 &lt;style scoped&gt; 会给选择器加上 data-v-xxx 属性限定，导致 html.dark 这种向上跨组件的选择器完全失效。 解决方案：将暗黑模式相关样式从 &lt;style scoped&gt; 中提取到单独的非 scoped &lt;style&gt; 标签中： &lt;style scoped&gt; /* 组件常规样式 */ .ranking-card { background: var(--app-surface); } &lt;/style&gt; &lt;!-- 暗黑模式覆盖必须放在非 scoped 的 style 中 --&gt; &lt;style&gt; html.dark .ranking-card { background: var(--app-surface-alt); } &lt;/style&gt; 这次修复涉及排行榜、评论列表、参加比赛、添加评论 4 个核心页面。 1.4 深度修复：ECharts 图表与边角组件 还有 ECharts 统计图表在暗黑模式下文字看不清的问题。通过监听主题切换事件，动态更新图表配色： // 监听暗黑模式切换 const isDark = useDark() watch(isDark, () =&gt; { chart.setOption({ textStyle: { color: isDark.value ? '#e5eaf3' : '#303133' }, legend: { textStyle: { color: isDark.value ? '#e5eaf3' : '#606266' } } }) }) 最终统计：暗黑模式适配共修复 60+ 处样式问题，覆盖全部前台和后台页面。 二、欠债补评论系统 v3 重构 2.1 从三维度到单维度 v2 的欠债系统使用三个维度（等效评论数、实际评论数、字数）来计算欠债，导致规则复杂且用户难以理解。v3 重构为单维度等效评论模型，大幅简化： 用户只需要关注一个数字：欠多少条等效评论 惩罚规则简化：零评论罚评数、单笔欠债上限、最大禁投届数三个参数可在后台配置 前端 DebtManagement.vue 从三列展示改为单列，表头增加帮助 tooltip 解释计算规则 2.2 配置缺失问题 重构后发现后端 InitDefaultSettings 中缺少调度器相关的默认配置，导致欠债补评任务无法正常触发。同时 debt_count 列已被移除但查询代码中仍在引用。两个问题一起修复： // cmd/server/database/database.go - 补充默认配置 func InitDefaultSettings() { defaults := map[string]string{ "debt_zero_comment_penalty": "2", "debt_max_per_entry": "5", "debt_max_ban_periods": "3", // ...调度器配置 } for key, val := range defaults { database.DB.FirstOrCreate(&amp;models.Settings{}, models.Settings{Key: key, Value: val}) } } 所有测试文件也同步适配了 v3 单维度模型。 三、管理界面全面优化 这次冲刺对管理后台几乎所有页面做了一轮系统性优化。 3.1 侧边栏重组 原来的侧边栏菜单项扁平排列，改为四组分类：概览、内容管理、数据与规则、系统。每个菜单项使用唯一图标，整体视觉更清晰： &lt;!-- 分组式侧边栏 --&gt; &lt;el-menu-item-group title="概览"&gt; &lt;el-menu-item index="/admin/dashboard"&gt;&lt;el-icon&gt;&lt;Odometer /&gt;&lt;/el-icon&gt;仪表盘&lt;/el-menu-item&gt; &lt;/el-menu-item-group&gt; &lt;el-menu-item-group title="内容管理"&gt; &lt;el-menu-item index="/admin/comments"&gt;...&lt;/el-menu-item&gt; &lt;el-menu-item index="/admin/submissions"&gt;...&lt;/el-menu-item&gt; &lt;/el-menu-item-group&gt; 3.2 操作列瘦身 用户管理和比赛管理页面的操作列原来有多个按钮并排，占用 280-350px 宽度。改为「编辑 + 更多下拉菜单」模式，宽度降到 160px： &lt;el-table-column label="操作" width="160" fixed="right"&gt; &lt;template #default="{ row }"&gt; &lt;el-button size="small" @click="editUser(row)"&gt;编辑&lt;/el-button&gt; &lt;el-dropdown&gt; &lt;el-button size="small"&gt;更多&lt;el-icon&gt;&lt;ArrowDown /&gt;&lt;/el-icon&gt;&lt;/el-button&gt; &lt;template #dropdown&gt; &lt;el-dropdown-menu&gt; &lt;el-dropdown-item @click="resetPassword(row)"&gt;重置密码&lt;/el-dropdown-item&gt; &lt;el-dropdown-item @click="toggleBan(row)"&gt;&lt;/el-dropdown-item&gt; &lt;/el-dropdown-menu&gt; &lt;/template&gt; &lt;/el-dropdown&gt; &lt;/template&gt; &lt;/el-table-column&gt; 3.3 系统设置页面重构 系统设置页从单栏改为双栏布局，Tab 合并调度器设置，统一保存按钮和全局 loading 状态。同时新增欠债惩罚参数的可视化配置，并补充了 20 个 Playwright E2E 测试用例。 3.4 仪表盘精简 仪表盘清理了 230 行旧代码（包括不再使用的图表和统计项），参赛管理 Tab 的 tooltip 改为动态读取后端配置。 四、文案提示系统 4.1 问题背景 辣评有大量业务规则（等效评论公式、合格条件、欠债惩罚规则等），之前这些文案散落在各个组件中，既难以维护也容易出现不一致。 4.2 集中管理方案 创建 helpTexts.ts 配置文件和 useHelpTexts composable： // admin-vue/src/config/helpTexts.ts export const helpTexts = { qualification: { formula: '等效评论 = 短篇评论×1 + 中篇评论×1.5 + 长篇评论×2', condition: '等效评论 ≥ {minEquivalent} 且 实际评论 ≥ {minActual} 且 总字数 ≥ {minWords}', tooltip: '合格条件中的阈值可在系统设置中配置' }, debt: { penalty: '零评论额外罚 {zeroPenalty} 条，单笔上限 {maxPerEntry} 条', ban: '连续欠债 {maxBanPeriods} 届触发禁投' } // ...共 20 处文案 } // admin-vue/src/composables/useHelpTexts.ts export function useHelpTexts(section: string) { const settings = useSettingsStore() // 动态替换 {minEquivalent} 等占位符为后端配置值 return computed(() =&gt; interpolate(helpTexts[section], settings.config)) } 最终接入了 6 个页面、13 处文案提示，覆盖了所有 P2/P3 级别的业务规则说明。 五、审计日志增强 5.1 覆盖面扩展 原来审计日志只记录 8 种操作类型。这次补充了 15 处写操作的审计记录，覆盖比赛管理、规则管理、评论操作、投稿操作、系统配置、调度器启停等，操作类型扩展到 27 种。 5.2 CSV 导出 新增导出接口，支持按当前筛选条件导出，输出 UTF-8 BOM 编码确保 Excel 直接打开不乱码： // cmd/server/handlers/audit_handler.go func ExportAuditLogs(c *gin.Context) { // ... 筛选逻辑同列表接口 c.Header("Content-Type", "text/csv; charset=utf-8") c.Header("Content-Disposition", "attachment; filename=audit-logs.csv") // 写入 BOM c.Writer.Write([]byte{0xEF, 0xBB, 0xBF}) writer := csv.NewWriter(c.Writer) writer.Write([]string{"时间", "操作人", "操作类型", "操作对象", "详情"}) // ... } 5.3 前端页面优化 审计日志页面做了 5 项改进：操作类型全中文化、分组筛选下拉、JSON 详情格式化展示、操作对象可读化（从 user:123 变为显示用户名）、增加导出按钮。 六、用户手册 v2.0 6.1 全面重写 原有的 10 章英文手册全部替换为 13 章中文文档，按照实际功能模块重新组织： 系统概述 注册与登录 比赛与投稿 评论与评分 排行榜与统计 任务与资格 用户管理 规则管理 投稿管理 欠债管理 系统设置 审计日志 常见问题 新增了排行榜、任务追踪、审计日志三个之前缺失的章节，并补充了暗黑模式的适配样式。 七、星级评分组件优化 评论打分从原来的自由数字输入改为 1-5 星评分，带渐变色和文字描述： &lt;el-rate v-model="form.rating" :texts="['很差', '较差', '一般', '较好', '很好']" :colors="['#F56C6C', '#E6A23C', '#909399', '#67C23A', '#409EFF']" show-text :allow-half="false" /&gt; 代评弹窗中的评分组件也同步升级，保持前后台交互一致。 八、工程化改进 8.1 版本号单一来源 之前版本号分散在 package.json、.env、config/index.ts 三处，容易出现不一致。重构为 version.json 单一来源： { "version": "0.9.0", "versionName": "辣评", "buildDate": "2026-03-29" } 构建时通过 prebuild 脚本自动同步到 package.json： // admin-vue/scripts/sync-version.js const version = require('../version.json') const pkg = require('../package.json') pkg.version = version.version fs.writeFileSync('package.json', JSON.stringify(pkg, null, 2)) 8.2 部署脚本增强 部署脚本新增用户手册上传步骤，确保文档与代码同步部署。 8.3 CHANGELOG 正式建立版本变更日志，记录从 v0.1.0 到 v0.9.0 的所有功能变更，遵循语义化版本规范。 九、其他修复与优化 评论管理不加载数据：onMounted 中调用了未定义的 loadFilters() 导致崩溃，直接移除该调用 投稿管理无默认届次：新增 getActiveCompetition 接口，页面加载时自动选中最新一届 禁赛规则调整：禁赛用户仅限制投稿，不再限制登录和评论（用户体验改善） 个人中心 15 项改进：统计项增加 tooltip、布局响应式优化、密码修改交互优化等 测试数据种子脚本：生成 15 个覆盖各种业务场景的测试账号，方便开发调试 移除前台全局搜索：功能使用率极低且增加维护负担，清理掉 总结 暗黑模式的教训：Vue scoped 样式中不能使用 html.dark 这类跨组件选择器，这是一个很容易忽略的陷阱。建议一开始就用 CSS 变量方案，避免在每个组件中重复写暗黑覆盖样式。 业务规则文案集中管理：当系统中有大量业务规则需要向用户解释时，散落在各组件中的文案迟早会出问题。helpTexts + composable 的方案成本低、效果好。 欠债系统简化：三维度模型看起来更精确，但用户理解成本太高。单维度模型虽然信息量少了，但用户更容易做出正确的行为响应。产品设计上，简单可理解比精确但复杂更重要。 操作列下拉菜单：管理后台表格的操作列如果按钮超过 2 个，就应该用下拉菜单收纳。否则表格宽度会被操作列挤占，主要数据反而展示不全。 版本号管理：任何需要在多处引用的配置值都应该有唯一来源。分散维护版本号是技术债务，迟早会出不一致的问题。 这三天的冲刺让辣评平台从一个功能基本可用的状态提升到了交互体验和工程质量都相对成熟的 v0.9.0。接下来的重点将是稳定性测试和 v1.0 正式发布。]]></summary></entry><entry><title type="html">用 Claude Code 自动发布开发博客：从 Git 提交到 GitHub Pages 一键搞定</title><link href="https://ariesoxo.github.io/%E5%B7%A5%E5%85%B7%E5%88%86%E4%BA%AB/%E6%95%88%E7%8E%87%E6%8F%90%E5%8D%87/2026/03/29/%E7%94%A8Claude-Code%E8%87%AA%E5%8A%A8%E5%8F%91%E5%B8%83%E5%BC%80%E5%8F%91%E5%8D%9A%E5%AE%A2.html" rel="alternate" type="text/html" title="用 Claude Code 自动发布开发博客：从 Git 提交到 GitHub Pages 一键搞定" /><published>2026-03-29T00:00:00+08:00</published><updated>2026-03-29T00:00:00+08:00</updated><id>https://ariesoxo.github.io/%E5%B7%A5%E5%85%B7%E5%88%86%E4%BA%AB/%E6%95%88%E7%8E%87%E6%8F%90%E5%8D%87/2026/03/29/%E7%94%A8Claude-Code%E8%87%AA%E5%8A%A8%E5%8F%91%E5%B8%83%E5%BC%80%E5%8F%91%E5%8D%9A%E5%AE%A2</id><content type="html" xml:base="https://ariesoxo.github.io/%E5%B7%A5%E5%85%B7%E5%88%86%E4%BA%AB/%E6%95%88%E7%8E%87%E6%8F%90%E5%8D%87/2026/03/29/%E7%94%A8Claude-Code%E8%87%AA%E5%8A%A8%E5%8F%91%E5%B8%83%E5%BC%80%E5%8F%91%E5%8D%9A%E5%AE%A2.html"><![CDATA[<h1 id="用-claude-code-自动发布开发博客从-git-提交到-github-pages-一键搞定">用 Claude Code 自动发布开发博客：从 Git 提交到 GitHub Pages 一键搞定</h1>

<blockquote>
  <p>写代码容易，写博客难——不是不会写，而是懒得整理。本文分享一个完整的自动化方案：一条命令，自动从 Git 提交记录中提炼开发日志，推送到 GitHub Pages 博客，并发送飞书通知。</p>
</blockquote>

<hr />

<h2 id="一痛点为什么需要自动化发博客">一、痛点：为什么需要自动化发博客</h2>

<p>作为独立开发者，项目开发节奏很快，三天就能产出 60+ 次提交。但每次想写开发博客时，面对的流程是这样的：</p>

<ol>
  <li>翻 <code class="language-plaintext highlighter-rouge">git log</code>，回忆做了什么</li>
  <li>打开博客仓库，clone 或 pull 最新代码</li>
  <li>按 Jekyll 格式写 frontmatter、正文</li>
  <li>git add → commit → push</li>
  <li>等 GitHub Pages 构建完成</li>
</ol>

<p><strong>整个流程 30-60 分钟</strong>，其中大部分时间花在”从提交记录中提炼有价值的内容”和”处理发布流程”上。</p>

<p>目标很简单：<strong>一条命令，5 分钟搞定</strong>。</p>

<hr />

<h2 id="二核心思路gh-api-直接推送无需-clone">二、核心思路：gh API 直接推送，无需 clone</h2>

<p>传统方案需要 clone 博客仓库到本地，写入文件后再 push。但 GitHub CLI（<code class="language-plaintext highlighter-rouge">gh</code>）提供了 Contents API，可以<strong>直接在远程仓库中创建或更新文件</strong>：</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># 创建新文件 —— 一条命令，无需 clone</span>
<span class="nv">CONTENT</span><span class="o">=</span><span class="si">$(</span><span class="nb">base64</span> <span class="nt">-w</span> 0 &lt; article.md<span class="si">)</span>
gh api repos/用户名/仓库名/contents/_posts/2026-03-29-文章标题.md <span class="se">\</span>
  <span class="nt">--method</span> PUT <span class="se">\</span>
  <span class="nt">-f</span> <span class="nv">message</span><span class="o">=</span><span class="s2">"发布: 文章标题"</span> <span class="se">\</span>
  <span class="nt">-f</span> <span class="nv">content</span><span class="o">=</span><span class="s2">"</span><span class="nv">$CONTENT</span><span class="s2">"</span>
</code></pre></div></div>

<p>这比 clone → write → commit → push 的流程简洁得多。关键注意点：</p>

<table>
  <thead>
    <tr>
      <th>事项</th>
      <th>说明</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">base64 -w 0</code></td>
      <td><strong>必须加</strong>，否则长内容折行会导致 API 拒绝</td>
    </tr>
    <tr>
      <td>中文文件名</td>
      <td><code class="language-plaintext highlighter-rouge">gh api</code> 自动处理 URL 编码，直接传中文即可</td>
    </tr>
    <tr>
      <td>更新已有文件</td>
      <td>必须先获取 SHA，否则返回 409 冲突</td>
    </tr>
    <tr>
      <td>默认分支</td>
      <td>不传 <code class="language-plaintext highlighter-rouge">branch</code> 参数则写入默认分支</td>
    </tr>
  </tbody>
</table>

<p><strong>更新已有文件的完整命令：</strong></p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># 先获取文件的 SHA</span>
<span class="nv">SHA</span><span class="o">=</span><span class="si">$(</span>gh api <span class="s2">"repos/用户名/仓库名/contents/_posts/已有文件.md"</span> <span class="nt">--jq</span> <span class="s1">'.sha'</span><span class="si">)</span>

<span class="c"># 带 SHA 更新</span>
<span class="nv">CONTENT</span><span class="o">=</span><span class="si">$(</span><span class="nb">base64</span> <span class="nt">-w</span> 0 &lt; updated-article.md<span class="si">)</span>
gh api <span class="s2">"repos/用户名/仓库名/contents/_posts/已有文件.md"</span> <span class="se">\</span>
  <span class="nt">--method</span> PUT <span class="se">\</span>
  <span class="nt">-f</span> <span class="nv">message</span><span class="o">=</span><span class="s2">"更新: 文章标题"</span> <span class="se">\</span>
  <span class="nt">-f</span> <span class="nv">content</span><span class="o">=</span><span class="s2">"</span><span class="nv">$CONTENT</span><span class="s2">"</span> <span class="se">\</span>
  <span class="nt">-f</span> <span class="nv">sha</span><span class="o">=</span><span class="s2">"</span><span class="nv">$SHA</span><span class="s2">"</span>
</code></pre></div></div>

<hr />

<h2 id="三技能架构claude-code-skill-系统">三、技能架构：Claude Code Skill 系统</h2>

<p>Claude Code 的技能（Skill）系统可以将复杂的工作流封装为可复用的指令。一个技能本质上是一个 Markdown 文件，描述了 Claude 应该如何执行某类任务。</p>

<h3 id="31-技能文件结构">3.1 技能文件结构</h3>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>~/.claude/skills/publish-blog/
├── SKILL.md                        # 技能主文件（工作流定义）
└── references/
    └── blog-conventions.md         # 博客格式规范参考
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">SKILL.md</code> 包含 YAML 元数据和完整的工作流指令：</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nn">---</span>
<span class="na">name</span><span class="pi">:</span> <span class="s">publish-blog</span>
<span class="na">description</span><span class="pi">:</span> <span class="s">从当前项目的 Git 提交记录、docs 文档、代码变更生成开发博客文章，</span>
  <span class="s">发布到 GitHub Pages 博客仓库。支持新建和更新已有文章，发布后自动飞书通知。</span>
<span class="nn">---</span>
</code></pre></div></div>

<p>Claude Code 启动时会加载所有技能的 <code class="language-plaintext highlighter-rouge">name</code> 和 <code class="language-plaintext highlighter-rouge">description</code>，当用户输入匹配时自动触发。也可以用 <code class="language-plaintext highlighter-rouge">/publish-blog</code> 手动调用。</p>

<h3 id="32-参考文件博客格式规范">3.2 参考文件：博客格式规范</h3>

<p><code class="language-plaintext highlighter-rouge">references/blog-conventions.md</code> 存储了博客的 frontmatter 模板、中文编号映射表（一、二、三…三十）、文件命名规则、写作风格指南等。这些信息只在技能触发时加载，不会占用日常对话的上下文。</p>

<hr />

<h2 id="四完整工作流设计">四、完整工作流设计</h2>

<p>整个流程分为五个阶段：</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Phase 0: 项目识别
    ↓
Phase 1: 多层内容采集（git log → docs → code diff）
    ↓
Phase 2: 博客仓库分析（系列识别 → 编号递增）
    ↓
Phase 3: 草稿生成 → 用户确认
    ↓
Phase 4: gh api 推送 → 飞书通知
</code></pre></div></div>

<h3 id="41-phase-0-项目识别">4.1 Phase 0: 项目识别</h3>

<p>技能首先读取当前项目的标识文件，自动提取项目元信息：</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># 按优先级读取：CLAUDE.md &gt; README.md &gt; package.json / go.mod</span>
<span class="c"># 提取：项目名称、一句话定位、核心技术栈</span>
</code></pre></div></div>

<p>这些信息决定了博客的标题前缀、分类和标签。</p>

<h3 id="42-phase-1-多层内容采集">4.2 Phase 1: 多层内容采集</h3>

<p>这是最核心的设计。这里不是简单地把 <code class="language-plaintext highlighter-rouge">git log</code> 丢给 AI 总结，而是分三层按需采集：</p>

<p><strong>L1 — Git 提交记录（始终执行）：</strong></p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>git log <span class="nt">--since</span><span class="o">=</span><span class="s2">"3 days ago"</span> <span class="nt">--all</span> <span class="nt">--pretty</span><span class="o">=</span>format:<span class="s2">"%h %s (%ai)"</span> <span class="nt">--stat</span>
</code></pre></div></div>

<p>按 commit message 前缀分类统计：feat / fix / refactor / docs / chore / test 各有多少，快速了解这段时间的工作分布。</p>

<p><strong>L2 — docs/ 目录文档（有变更时执行）：</strong></p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>git diff <span class="nt">--name-only</span> HEAD~20 <span class="nt">--</span> docs/  <span class="c"># 排除 docs/post/ 自身</span>
</code></pre></div></div>

<p>如果 docs/ 下有新增或修改的文档（比如用户手册、CHANGELOG），读取这些文档作为博客素材。文档中往往包含比 commit message 更完整的功能描述。</p>

<p><strong>L3 — 代码 diff（选择性执行）：</strong></p>

<p>不是所有提交都需要看代码。只对<strong>关键提交</strong>读取 diff：</p>

<table>
  <thead>
    <tr>
      <th>提交类型</th>
      <th>触发条件</th>
      <th>采集方式</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">feat:</code></td>
      <td>文件变更 &gt; 50 行</td>
      <td>读取新增的核心文件</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">refactor:</code></td>
      <td>文件变更 &gt; 50 行</td>
      <td>读取重构前后的关键差异</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">fix:</code></td>
      <td>commit message 描述不清晰</td>
      <td>读取修复的代码片段</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">chore:</code> / <code class="language-plaintext highlighter-rouge">style:</code></td>
      <td>—</td>
      <td>跳过，不读代码</td>
    </tr>
  </tbody>
</table>

<p>这种分层策略的好处是：<strong>既不会因为信息不足写出空洞的文章，也不会因为信息过载浪费上下文窗口</strong>。</p>

<h3 id="43-phase-2-博客仓库分析">4.3 Phase 2: 博客仓库分析</h3>

<p>通过 <code class="language-plaintext highlighter-rouge">gh api</code> 直接查询远程博客仓库，不需要 clone：</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># 列出所有文章</span>
gh api repos/AriesOxO/AriesOxO.github.io/contents/_posts <span class="nt">--jq</span> <span class="s1">'.[].name'</span>

<span class="c"># 搜索当前项目的已有系列，确定下一篇编号</span>
</code></pre></div></div>

<p>如果是新项目首篇，会读取博客中最近一篇文章的 frontmatter 和结构作为风格参考，确保新系列与博客整体风格一致。</p>

<h3 id="44-phase-3-草稿管理">4.4 Phase 3: 草稿管理</h3>

<p>生成的文章不会直接推送，而是先写入项目的 <code class="language-plaintext highlighter-rouge">docs/post/</code> 目录：</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>项目根/
├── docs/
│   └── post/
│       ├── 2026-03-29-v0.9.0大版本冲刺.md      # 已发布的
│       └── 2026-03-29-自动发布开发博客.md        # 新生成的草稿
├── src/
└── ...
</code></pre></div></div>

<p><strong>为什么放在项目目录而不是临时目录？</strong></p>

<ol>
  <li><strong>版本追踪</strong>：草稿随项目 git 管理，可以看到修改历史</li>
  <li><strong>支持更新</strong>：修改 <code class="language-plaintext highlighter-rouge">docs/post/</code> 中的文件后再次运行，会自动检测变更并更新远程博客</li>
  <li><strong>多次迭代</strong>：不满意可以直接编辑文件，不需要重新生成</li>
</ol>

<p>草稿生成后展示给用户确认，只有确认后才会推送。</p>

<h3 id="45-phase-4--5-推送与通知">4.5 Phase 4 &amp; 5: 推送与通知</h3>

<p>用户确认后，技能执行两个操作：</p>

<p><strong>推送到 GitHub Pages：</strong></p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nv">CONTENT</span><span class="o">=</span><span class="si">$(</span><span class="nb">base64</span> <span class="nt">-w</span> 0 &lt; docs/post/文章.md<span class="si">)</span>
gh api <span class="s2">"repos/AriesOxO/AriesOxO.github.io/contents/_posts/文章.md"</span> <span class="se">\</span>
  <span class="nt">--method</span> PUT <span class="se">\</span>
  <span class="nt">-f</span> <span class="nv">message</span><span class="o">=</span><span class="s2">"docs: 发布开发博客 - 文章标题"</span> <span class="se">\</span>
  <span class="nt">-f</span> <span class="nv">content</span><span class="o">=</span><span class="s2">"</span><span class="nv">$CONTENT</span><span class="s2">"</span>
</code></pre></div></div>

<p><strong>自动飞书通知（无需确认）：</strong></p>

<p>采用文件中转方式避免 Windows 终端中文乱码：</p>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="w">
  </span><span class="nl">"msg_type"</span><span class="p">:</span><span class="w"> </span><span class="s2">"post"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"content"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
    </span><span class="nl">"post"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
      </span><span class="nl">"zh_cn"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
        </span><span class="nl">"title"</span><span class="p">:</span><span class="w"> </span><span class="s2">"📝 开发博客已发布"</span><span class="p">,</span><span class="w">
        </span><span class="nl">"content"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="w">
          </span><span class="p">[{</span><span class="nl">"tag"</span><span class="p">:</span><span class="w"> </span><span class="s2">"text"</span><span class="p">,</span><span class="w"> </span><span class="nl">"text"</span><span class="p">:</span><span class="w"> </span><span class="s2">"项目：项目名"</span><span class="p">}],</span><span class="w">
          </span><span class="p">[{</span><span class="nl">"tag"</span><span class="p">:</span><span class="w"> </span><span class="s2">"text"</span><span class="p">,</span><span class="w"> </span><span class="nl">"text"</span><span class="p">:</span><span class="w"> </span><span class="s2">"标题：文章标题"</span><span class="p">}],</span><span class="w">
          </span><span class="p">[{</span><span class="nl">"tag"</span><span class="p">:</span><span class="w"> </span><span class="s2">"text"</span><span class="p">,</span><span class="w"> </span><span class="nl">"text"</span><span class="p">:</span><span class="w"> </span><span class="s2">"摘要：文章核心内容概括..."</span><span class="p">}],</span><span class="w">
          </span><span class="p">[{</span><span class="nl">"tag"</span><span class="p">:</span><span class="w"> </span><span class="s2">"a"</span><span class="p">,</span><span class="w"> </span><span class="nl">"text"</span><span class="p">:</span><span class="w"> </span><span class="s2">"查看文章 →"</span><span class="p">,</span><span class="w"> </span><span class="nl">"href"</span><span class="p">:</span><span class="w"> </span><span class="s2">"https://ariesoxo.github.io/"</span><span class="p">}]</span><span class="w">
        </span><span class="p">]</span><span class="w">
      </span><span class="p">}</span><span class="w">
    </span><span class="p">}</span><span class="w">
  </span><span class="p">}</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>curl <span class="nt">-X</span> POST <span class="se">\</span>
  <span class="nt">-H</span> <span class="s2">"Content-Type: application/json; charset=utf-8"</span> <span class="se">\</span>
  <span class="nt">-d</span> @feishu.json <span class="se">\</span>
  https://open.feishu.cn/open-apis/bot/v2/hook/你的webhook地址
</code></pre></div></div>

<hr />

<h2 id="五windows-环境踩坑记录">五、Windows 环境踩坑记录</h2>

<p>在 Windows + Git Bash 环境下开发这套流程，遇到了几个值得记录的坑：</p>

<h3 id="51-base64-折行">5.1 base64 折行</h3>

<p>Git Bash 自带的 <code class="language-plaintext highlighter-rouge">base64</code> 命令默认在 76 字符处插入换行。GitHub API 会拒绝包含换行的 base64 内容。<strong>必须加 <code class="language-plaintext highlighter-rouge">-w 0</code> 参数</strong>：</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># ❌ 会折行，API 报错</span>
<span class="nb">base64</span> &lt; article.md

<span class="c"># ✅ 不折行</span>
<span class="nb">base64</span> <span class="nt">-w</span> 0 &lt; article.md
</code></pre></div></div>

<h3 id="52-printf-与-yaml-frontmatter">5.2 printf 与 YAML frontmatter</h3>

<p>YAML frontmatter 以 <code class="language-plaintext highlighter-rouge">---</code> 开头，而 <code class="language-plaintext highlighter-rouge">printf</code> 在某些 shell 中会把以 <code class="language-plaintext highlighter-rouge">-</code> 开头的参数解释为选项。用文件输入替代 echo/printf：</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># ❌ 可能报错</span>
<span class="nb">printf</span> <span class="s1">'---\ntitle: 标题\n---'</span> | <span class="nb">base64</span> <span class="nt">-w</span> 0

<span class="c"># ✅ 直接从文件读取</span>
<span class="nb">base64</span> <span class="nt">-w</span> 0 &lt; docs/post/article.md
</code></pre></div></div>

<h3 id="53-中文文件名">5.3 中文文件名</h3>

<p>好消息：<code class="language-plaintext highlighter-rouge">gh api</code> 会自动处理中文 URL 编码，不需要手动 encode。直接在路径中写中文文件名即可。</p>

<hr />

<h2 id="六使用效果">六、使用效果</h2>

<h3 id="首次使用">首次使用</h3>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># 在任意项目目录下执行</span>
/publish-blog 3d
</code></pre></div></div>

<p>Claude Code 会自动：</p>
<ol>
  <li>识别当前项目（名称、技术栈）</li>
  <li>读取最近 3 天的 git log</li>
  <li>按需读取 docs/ 和代码 diff</li>
  <li>查询博客仓库已有文章，确定编号</li>
  <li>生成文章草稿到 <code class="language-plaintext highlighter-rouge">docs/post/</code></li>
  <li>等你确认后一键推送 + 飞书通知</li>
</ol>

<h3 id="更新已有文章">更新已有文章</h3>

<p>直接编辑 <code class="language-plaintext highlighter-rouge">docs/post/</code> 中的文件，然后再次运行 <code class="language-plaintext highlighter-rouge">/publish-blog</code>，技能会检测到本地文件与远程版本不同，自动执行更新（带 SHA 参数）。</p>

<h3 id="参数灵活">参数灵活</h3>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>/publish-blog 3d          <span class="c"># 最近 3 天</span>
/publish-blog 2w          <span class="c"># 最近 2 周</span>
/publish-blog v0.9.0      <span class="c"># 从某个 tag 开始</span>
/publish-blog 2026-03-27  <span class="c"># 从指定日期开始</span>
/publish-blog             <span class="c"># 默认最近 7 天</span>
</code></pre></div></div>

<hr />

<h2 id="七如何复刻这套方案">七、如何复刻这套方案</h2>

<p>如果你也想搭建类似的自动化博客发布流程，需要准备：</p>

<h3 id="71-前置条件">7.1 前置条件</h3>

<ol>
  <li><strong>Claude Code</strong>：安装并配置好（<a href="https://claude.ai/code">官方文档</a>）</li>
  <li><strong>GitHub CLI</strong>：安装 <code class="language-plaintext highlighter-rouge">gh</code> 并完成认证（<code class="language-plaintext highlighter-rouge">gh auth login</code>）</li>
  <li><strong>Jekyll 博客</strong>：部署在 GitHub Pages（其他静态博客框架同理，只需调整文件路径）</li>
  <li><strong>飞书机器人</strong>（可选）：创建自定义机器人，获取 Webhook 地址</li>
</ol>

<h3 id="72-创建技能">7.2 创建技能</h3>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># 创建技能目录</span>
<span class="nb">mkdir</span> <span class="nt">-p</span> ~/.claude/skills/publish-blog/references

<span class="c"># 创建 SKILL.md（工作流定义）</span>
<span class="c"># 创建 references/blog-conventions.md（博客格式规范）</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">SKILL.md</code> 中需要配置：</p>
<ul>
  <li>你的博客仓库地址（如 <code class="language-plaintext highlighter-rouge">用户名/用户名.github.io</code>）</li>
  <li>文章目录路径（Jekyll 默认 <code class="language-plaintext highlighter-rouge">_posts/</code>）</li>
  <li>本地草稿目录（建议 <code class="language-plaintext highlighter-rouge">docs/post/</code>）</li>
  <li>飞书 Webhook 地址（如果需要通知）</li>
</ul>

<h3 id="73-适配你的博客格式">7.3 适配你的博客格式</h3>

<p>在 <code class="language-plaintext highlighter-rouge">references/blog-conventions.md</code> 中定义：</p>
<ul>
  <li>frontmatter 模板（每个博客框架格式不同）</li>
  <li>文件命名规则</li>
  <li>写作风格偏好</li>
  <li>项目与博客系列的映射关系</li>
</ul>

<hr />

<h2 id="总结">总结</h2>

<ol>
  <li>
    <p><strong><code class="language-plaintext highlighter-rouge">gh api</code> 是关键</strong>：直接通过 GitHub API 创建/更新文件，完全跳过 clone → commit → push 的流程，一条命令搞定发布。</p>
  </li>
  <li>
    <p><strong>分层采集策略</strong>：git log 始终读、docs/ 变更时读、代码 diff 选择性读——既保证内容质量，又避免信息过载。</p>
  </li>
  <li>
    <p><strong>草稿存项目目录</strong>：<code class="language-plaintext highlighter-rouge">docs/post/</code> 随项目版本管理，支持多次编辑和更新已发布文章，比临时文件更可靠。</p>
  </li>
  <li>
    <p><strong>Claude Code 技能系统</strong>：将复杂的多步骤工作流封装为一条命令，<code class="language-plaintext highlighter-rouge">SKILL.md</code> + <code class="language-plaintext highlighter-rouge">references/</code> 的结构既保持了灵活性，又不会污染日常对话上下文。</p>
  </li>
  <li>
    <p><strong>端到端自动化</strong>：从 git log 到博客发布到飞书通知，全程只需要在”确认草稿”时介入一次。把时间花在审核内容质量上，而不是格式和发布流程上。</p>
  </li>
</ol>]]></content><author><name>meow</name></author><category term="工具分享" /><category term="效率提升" /><category term="Claude Code" /><category term="GitHub CLI" /><category term="自动化" /><category term="博客" /><category term="飞书通知" /><summary type="html"><![CDATA[分享如何用 Claude Code 技能系统 + GitHub CLI，实现从 Git 提交记录自动生成开发博客、一键推送到 GitHub Pages、自动飞书通知的完整工作流。]]></summary></entry><entry><title type="html">piz 开发日志（一）：为什么要做一个用自然语言操控终端的 CLI 工具</title><link href="https://ariesoxo.github.io/rust/cli/ai/2026/03/17/piz-dev-blog-1-why.html" rel="alternate" type="text/html" title="piz 开发日志（一）：为什么要做一个用自然语言操控终端的 CLI 工具" /><published>2026-03-17T00:00:00+08:00</published><updated>2026-03-17T00:00:00+08:00</updated><id>https://ariesoxo.github.io/rust/cli/ai/2026/03/17/piz-dev-blog-1-why</id><content type="html" xml:base="https://ariesoxo.github.io/rust/cli/ai/2026/03/17/piz-dev-blog-1-why.html"><![CDATA[<h1 id="piz-开发日志一为什么要做一个用自然语言操控终端的-cli-工具">piz 开发日志（一）：为什么要做一个用自然语言操控终端的 CLI 工具</h1>

<blockquote>
  <p>这是 piz 开发日志系列的第一篇。piz 是一个用 Rust 编写的终端命令翻译器——用自然语言描述你想做什么，它帮你生成精确的 shell 命令。</p>
</blockquote>

<h2 id="痛点">痛点</h2>

<p>作为开发者，我经常遇到这些场景：</p>

<ul>
  <li>知道要”找出当前目录下所有大于 100MB 的文件”，但 <code class="language-plaintext highlighter-rouge">find</code> 的参数记不住</li>
  <li>知道要”压缩 src 目录”，但 <code class="language-plaintext highlighter-rouge">tar</code> 的 <code class="language-plaintext highlighter-rouge">-czf</code> <code class="language-plaintext highlighter-rouge">-xvf</code> 每次都要查</li>
  <li>知道要”杀掉占用 8080 端口的进程”，但不同系统命令还不一样</li>
  <li>Windows 上用 PowerShell、Linux 上用 bash，语法切来切去头大</li>
</ul>

<p>终端用户面临两个核心痛点：</p>

<ol>
  <li><strong>记不住命令</strong>：不同 OS、不同 Shell 的命令语法差异巨大（Linux <code class="language-plaintext highlighter-rouge">ls</code> vs Windows <code class="language-plaintext highlighter-rouge">dir</code>、bash <code class="language-plaintext highlighter-rouge">$VAR</code> vs PowerShell <code class="language-plaintext highlighter-rouge">$env:VAR</code>）</li>
  <li><strong>命令执行有风险</strong>：一条 <code class="language-plaintext highlighter-rouge">rm -rf /</code> 就能摧毁整个系统，而 LLM 如果被 Prompt 注入，可能会生成这样的命令</li>
</ol>

<p>于是我决定用 Rust 写一个终端命令翻译器——<strong>piz</strong>。</p>

<h2 id="设计哲学">设计哲学</h2>

<p>在动手写代码之前，我先确定了五条设计原则，它们贯穿整个项目的每一个决策：</p>

<h3 id="安全第一">安全第一</h3>

<p>这是最重要的原则。一个能自动执行 shell 命令的工具，如果没有安全防护，后果不堪设想。piz 采用三层安全防护：</p>

<ul>
  <li><strong>第 1 层：Prompt 级别拒绝</strong> —— LLM 自身判断，非命令请求直接拒绝</li>
  <li><strong>第 2 层：本地注入检测</strong> —— 12 种恶意模式的正则匹配，完全离线，不依赖 LLM</li>
  <li><strong>第 3 层：危险分级 + 用户确认</strong> —— 正则 + LLM 双重分级，取最大值</li>
</ul>

<p>宁可误报，也不放行恶意命令。</p>

<h3 id="非侵入性">非侵入性</h3>

<p>很多工具会修改用户的 Shell 环境（比如注入 <code class="language-plaintext highlighter-rouge">chcp 65001</code> 或修改 <code class="language-plaintext highlighter-rouge">OutputEncoding</code>），piz 不做这种事。Windows 上的中文乱码问题，通过 GBK 解码回退来解决，不动用户的任何环境配置。</p>

<h3 id="容错优先">容错优先</h3>

<p>LLM 的输出格式不完全可控。有时它会在 JSON 外面包一层 markdown 代码块，有时 Windows 路径中的反斜杠会破坏 JSON 解析。piz 采用 4 级回退解析策略，最大程度提取有效命令，而不是简单地报错。</p>

<h3 id="零配置启动">零配置启动</h3>

<p>首次运行自动触发配置向导，内置 12 个 API 供应商预设（OpenAI、DeepSeek、硅基流动、Moonshot、智谱、通义千问等），选一个、填个 Key 就能用。</p>

<h3 id="跨平台一致性">跨平台一致性</h3>

<p>同一份代码运行在 Windows/macOS/Linux 上，通过运行时环境检测适配不同平台。不是写三份代码，而是用条件编译和运行时判断。</p>

<h2 id="功能全景">功能全景</h2>

<p>经过从 v0.1.0 到 v0.2.6 的迭代，piz 目前的功能矩阵如下：</p>

<table>
  <thead>
    <tr>
      <th>功能</th>
      <th>描述</th>
      <th>示例</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>自然语言翻译</td>
      <td>将自然语言转为 Shell 命令</td>
      <td><code class="language-plaintext highlighter-rouge">piz 列出所有大于100MB的文件</code></td>
    </tr>
    <tr>
      <td>三层安全防护</td>
      <td>注入检测 + 危险分级 + 用户确认</td>
      <td>自动拦截 <code class="language-plaintext highlighter-rouge">rm -rf /</code></td>
    </tr>
    <tr>
      <td>智能缓存</td>
      <td>SHA256 键 + TTL + LRU 驱逐</td>
      <td>相同问题秒回</td>
    </tr>
    <tr>
      <td>自动修复</td>
      <td>命令失败后自动诊断并修复</td>
      <td><code class="language-plaintext highlighter-rouge">piz fix</code></td>
    </tr>
    <tr>
      <td>交互式聊天</td>
      <td>多轮对话上下文保持</td>
      <td><code class="language-plaintext highlighter-rouge">piz chat</code></td>
    </tr>
    <tr>
      <td>Shell 集成</td>
      <td>cd/export/source 在当前 Shell 生效</td>
      <td><code class="language-plaintext highlighter-rouge">eval "$(piz init bash)"</code></td>
    </tr>
    <tr>
      <td>命令解释</td>
      <td>结构化解释命令含义</td>
      <td><code class="language-plaintext highlighter-rouge">piz -e 'tar -czf a.tar.gz src/'</code></td>
    </tr>
    <tr>
      <td>多候选选择</td>
      <td>生成多个命令方案供选择</td>
      <td><code class="language-plaintext highlighter-rouge">piz -n 3 find large files</code></td>
    </tr>
    <tr>
      <td>管道模式</td>
      <td>无 UI 输出，可嵌入脚本</td>
      <td><code class="language-plaintext highlighter-rouge">piz --pipe list files</code></td>
    </tr>
    <tr>
      <td>自动更新</td>
      <td>后台检查 + 交互式升级</td>
      <td><code class="language-plaintext highlighter-rouge">piz update</code></td>
    </tr>
    <tr>
      <td>多后端支持</td>
      <td>OpenAI/Claude/Gemini/Ollama + 12+ 兼容商</td>
      <td>国内外 API 都能用</td>
    </tr>
  </tbody>
</table>

<h2 id="技术选型为什么是-rust">技术选型：为什么是 Rust</h2>

<p>选择 Rust 主要基于以下考虑：</p>

<ol>
  <li><strong>单二进制分发</strong>：编译出来就是一个可执行文件，没有运行时依赖，用户不需要装 Python、Node.js 之类的</li>
  <li><strong>跨平台编译</strong>：Cargo + cross 可以方便地为三平台编译</li>
  <li><strong>性能</strong>：CLI 工具的启动速度很重要，Rust 的零成本抽象和无 GC 确保了毫秒级启动</li>
  <li><strong>类型安全</strong>：108 个国际化翻译字段、12 种注入检测模式，编译器帮你检查完整性</li>
  <li><strong>生态</strong>：clap（CLI 解析）、reqwest（HTTP）、rusqlite（SQLite）、tokio（异步）都是成熟的 crate</li>
</ol>

<p>当前代码规模约 5500 行 Rust 代码，177 个测试（169 单元 + 8 集成），CI 在 Ubuntu/Windows/macOS 三平台跑。</p>

<h2 id="项目演进时间线">项目演进时间线</h2>

<p>从 git 历史来看，piz 的开发大致经历了以下阶段：</p>

<h3 id="v010--核心功能成型">v0.1.0 — 核心功能成型</h3>
<ul>
  <li>自然语言转 Shell 命令的核心翻译流程</li>
  <li>多后端 LLM 支持（OpenAI/Claude/Gemini/Ollama）</li>
  <li>双重危险检测（正则 + LLM）</li>
  <li>SQLite 缓存</li>
  <li>交互式配置向导</li>
  <li>中英双语 UI</li>
</ul>

<h3 id="v011--windows-适配">v0.1.1 — Windows 适配</h3>
<ul>
  <li>修复 Windows 控制台 GBK 编码乱码</li>
  <li>命令失败自动修复（最多 3 次重试）</li>
</ul>

<h3 id="v020--功能大扩展">v0.2.0 — 功能大扩展</h3>
<ul>
  <li>交互式 Chat 模式</li>
  <li>多候选命令选择</li>
  <li>执行历史记录</li>
  <li>Shell 补全生成</li>
  <li>管道模式</li>
  <li>缓存 LRU 淘汰</li>
  <li>注入检测国际化</li>
  <li>API 重试与指数退避</li>
</ul>

<h3 id="v021--v023--平台打磨">v0.2.1 ~ v0.2.3 — 平台打磨</h3>
<ul>
  <li>Windows Shell 检测修复</li>
  <li>自更新功能</li>
  <li>Shell 集成（eval 模式）</li>
  <li>结构化正则回退解析</li>
  <li>非侵入式编码处理</li>
</ul>

<h3 id="v025--v026--开源治理">v0.2.5 ~ v0.2.6 — 开源治理</h3>
<ul>
  <li>新增注入检测模式</li>
  <li>安全策略、行为准则、PR 模板</li>
  <li>CI 优化</li>
  <li>代理/镜像支持</li>
</ul>

<h2 id="下一篇预告">下一篇预告</h2>

<p>在下一篇中，我会深入讲解 piz 的分层架构设计和 LLM 抽象层——如何用一个 Trait 统一四种完全不同的 LLM API，以及提示词工程中那些有趣的细节。</p>

<hr />

<p><em>本文是 piz 开发日志系列的第 1 篇，共 5 篇。</em></p>

<p>项目地址：<a href="https://github.com/AriesOxO/piz">GitHub</a></p>]]></content><author><name>meow</name><email>njwzcb@163.com</email></author><category term="Rust" /><category term="CLI" /><category term="AI" /><category term="piz" /><category term="rust" /><category term="llm" /><category term="terminal" /><category term="开发日志" /><summary type="html"><![CDATA[piz 是一个 Rust 编写的终端命令翻译器，用自然语言描述你想做什么，它帮你生成精确的 shell 命令。本文介绍项目的动机、设计哲学和功能全景。]]></summary></entry></feed>