阅读视图

发现新文章,点击刷新页面。

每日一题-使字符串平衡的最少删除次数🟡

给你一个字符串 s ,它仅包含字符 'a' 和 'b'

你可以删除 s 中任意数目的字符,使得 s 平衡 。当不存在下标对 (i,j) 满足 i < j ,且 s[i] = 'b' 的同时 s[j]= 'a' ,此时认为 s平衡 的。

请你返回使 s 平衡 的 最少 删除次数。

 

示例 1:

输入:s = "aababbab"
输出:2
解释:你可以选择以下任意一种方案:
下标从 0 开始,删除第 2 和第 6 个字符("aababbab" -> "aaabbb"),
下标从 0 开始,删除第 3 和第 6 个字符("aababbab" -> "aabbbb")。

示例 2:

输入:s = "bbaaaaabb"
输出:2
解释:唯一的最优解是删除最前面两个字符。

 

提示:

  • 1 <= s.length <= 105
  • s[i] 要么是 'a' 要么是 'b' 

[Python3/Java/C++/Go] 一题双解:动态规划 & 枚举+前缀和(清晰题解)

方法一:动态规划

我们定义 $f[i]$ 表示前 $i$ 个字符中,删除最少的字符数,使得字符串平衡。初始时 $f[0]=0$。答案为 $f[n]$。

我们遍历字符串 $s$,维护变量 $b$,表示当前遍历到的位置之前的字符中,字符 $b$ 的个数。

  • 如果当前字符为 'b',此时不影响前 $i$ 个字符的平衡性,因此 $f[i]=f[i-1]$,然后我们更新 $b \leftarrow b+1$。
  • 如果当前字符为 'a',此时我们可以选择删除当前字符,那么有 $f[i]=f[i-1]+1$;也可以选择删除之前的字符 $b$,那么有 $f[i]=b$。因此我们取两者的最小值,即 $f[i]=\min(f[i-1]+1,b)$。

综上,我们可以得到状态转移方程:

$$
f[i]=\begin{cases}
f[i-1], & s[i-1]='b'\
\min(f[i-1]+1,b), & s[i-1]='a'
\end{cases}
$$

最终答案为 $f[n]$。

class Solution:
    def minimumDeletions(self, s: str) -> int:
        n = len(s)
        f = [0] * (n + 1)
        b = 0
        for i, c in enumerate(s, 1):
            if c == 'b':
                f[i] = f[i - 1]
                b += 1
            else:
                f[i] = min(f[i - 1] + 1, b)
        return f[n]
class Solution {
    public int minimumDeletions(String s) {
        int n = s.length();
        int[] f = new int[n + 1];
        int b = 0;
        for (int i = 1; i <= n; ++i) {
            if (s.charAt(i - 1) == 'b') {
                f[i] = f[i - 1];
                ++b;
            } else {
                f[i] = Math.min(f[i - 1] + 1, b);
            }
        }
        return f[n];
    }
}
class Solution {
public:
    int minimumDeletions(string s) {
        int n = s.size();
        int f[n + 1];
        memset(f, 0, sizeof(f));
        int b = 0;
        for (int i = 1; i <= n; ++i) {
            if (s[i - 1] == 'b') {
                f[i] = f[i - 1];
                ++b;
            } else {
                f[i] = min(f[i - 1] + 1, b);
            }
        }
        return f[n];
    }
};
func minimumDeletions(s string) int {
n := len(s)
f := make([]int, n+1)
b := 0
for i, c := range s {
i++
if c == 'b' {
f[i] = f[i-1]
b++
} else {
f[i] = min(f[i-1]+1, b)
}
}
return f[n]
}

func min(a, b int) int {
if a < b {
return a
}
return b
}
function minimumDeletions(s: string): number {
    const n = s.length;
    const f = new Array(n + 1).fill(0);
    let b = 0;
    for (let i = 1; i <= n; ++i) {
        if (s.charAt(i - 1) === 'b') {
            f[i] = f[i - 1];
            ++b;
        } else {
            f[i] = Math.min(f[i - 1] + 1, b);
        }
    }
    return f[n];
}

我们注意到,状态转移方程中只与前一个状态以及变量 $b$ 有关,因此我们可以仅用一个答案变量 $ans$ 维护当前的 $f[i]$,并不需要开辟数组 $f$。

class Solution:
    def minimumDeletions(self, s: str) -> int:
        ans = b = 0
        for c in s:
            if c == 'b':
                b += 1
            else:
                ans = min(ans + 1, b)
        return ans
class Solution {
    public int minimumDeletions(String s) {
        int n = s.length();
        int ans = 0, b = 0;
        for (int i = 0; i < n; ++i) {
            if (s.charAt(i) == 'b') {
                ++b;
            } else {
                ans = Math.min(ans + 1, b);
            }
        }
        return ans;
    }
}
class Solution {
public:
    int minimumDeletions(string s) {
        int ans = 0, b = 0;
        for (char& c : s) {
            if (c == 'b') {
                ++b;
            } else {
                ans = min(ans + 1, b);
            }
        }
        return ans;
    }
};
func minimumDeletions(s string) int {
ans, b := 0, 0
for _, c := range s {
if c == 'b' {
b++
} else {
ans = min(ans+1, b)
}
}
return ans
}

func min(a, b int) int {
if a < b {
return a
}
return b
}
function minimumDeletions(s: string): number {
    const n = s.length;
    let ans = 0,
        b = 0;
    for (let i = 0; i < n; ++i) {
        if (s.charAt(i) === 'b') {
            ++b;
        } else {
            ans = Math.min(ans + 1, b);
        }
    }
    return ans;
}

时间复杂度 $O(n)$,空间复杂度 $O(1)$。其中 $n$ 为字符串 $s$ 的长度。


方法二:枚举 + 前缀和

我们可以枚举字符串 $s$ 中的每一个位置 $i$,将字符串 $s$ 分成两部分,分别为 $s[0,..,i-1]$ 和 $s[i+1,..n-1]$,要使得字符串平衡,我们在当前位置 $i$ 需要删除的字符数为 $s[0,..,i-1]$ 中字符 $b$ 的个数加上 $s[i+1,..n-1]$ 中字符 $a$ 的个数。

因此,我们维护两个变量 $lb$ 和 $ra$ 分别表示 $s[0,..,i-1]$ 中字符 $b$ 的个数以及 $s[i+1,..n-1]$ 中字符 $a$ 的个数,那么我们需要删除的字符数为 $lb+ra$。枚举过程中,更新变量 $lb$ 和 $ra$。

class Solution:
    def minimumDeletions(self, s: str) -> int:
        lb, ra = 0, s.count('a')
        ans = len(s)
        for c in s:
            ra -= c == 'a'
            ans = min(ans, lb + ra)
            lb += c == 'b'
        return ans
class Solution {
    public int minimumDeletions(String s) {
        int lb = 0, ra = 0;
        int n = s.length();
        for (int i = 0; i < n; ++i) {
            if (s.charAt(i) == 'a') {
                ++ra;
            }
        }
        int ans = n;
        for (int i = 0; i < n; ++i) {
            ra -= (s.charAt(i) == 'a' ? 1 : 0);
            ans = Math.min(ans, lb + ra);
            lb += (s.charAt(i) == 'b' ? 1 : 0);
        }
        return ans;
    }
}
class Solution {
public:
    int minimumDeletions(string s) {
        int lb = 0, ra = count(s.begin(), s.end(), 'a');
        int ans = ra;
        for (char& c : s) {
            ra -= c == 'a';
            ans = min(ans, lb + ra);
            lb += c == 'b';
        }
        return ans;
    }
};
func minimumDeletions(s string) int {
lb, ra := 0, strings.Count(s, "a")
ans := ra
for _, c := range s {
if c == 'a' {
ra--
}
if t := lb + ra; ans > t {
ans = t
}
if c == 'b' {
lb++
}
}
return ans
}
function minimumDeletions(s: string): number {
    let lb = 0,
        ra = 0;
    const n = s.length;
    for (let i = 0; i < n; ++i) {
        if (s.charAt(i) === 'a') {
            ++ra;
        }
    }
    let ans = n;
    for (let i = 0; i < n; ++i) {
        ra -= s.charAt(i) === 'a' ? 1 : 0;
        ans = Math.min(ans, lb + ra);
        lb += s.charAt(i) === 'b' ? 1 : 0;
    }
    return ans;
}

时间复杂度 $O(n)$,空间复杂度 $O(1)$。其中 $n$ 为字符串 $s$ 的长度。


有任何问题,欢迎评论区交流,欢迎评论区提供其它解题思路(代码),也可以点个赞支持一下作者哈😄~

前后缀分解,一张图秒懂!(附动态规划)Python/Java/C++/Go

方法一:前后缀分解(两次遍历)

1653-2-cut3.png

答疑

:为什么把 if-else 写成 (c - 'a') * 2 - 1 就会快很多?

:CPU 在遇到分支(条件跳转指令)时会预测代码要执行哪个分支,如果预测正确,CPU 就会继续按照预测的路径执行程序。但如果预测失败,CPU 就需要回滚之前的指令并加载正确的指令,以确保程序执行的正确性。

对于本题的数据,字符 $\text{a'}$ 和 $\text{b'}$ 可以认为是随机出现的,在这种情况下分支预测就会有 $50%$ 的概率失败。失败导致的回滚和加载操作需要消耗额外的 CPU 周期,如果能用较小的代价去掉分支,对于本题的情况必然可以带来效率上的提升。

注意:这种优化方法往往会降低可读性,最好不要在业务代码中使用。

class Solution:
    def minimumDeletions(self, s: str) -> int:
        ans = delete = s.count('a')
        for c in s:
            delete -= 1 if c == 'a' else -1
            if delete < ans:  # 手动 min 会快很多
                ans = delete
        return ans
class Solution {
    public int minimumDeletions(String S) {
        var s = S.toCharArray();
        int del = 0;
        for (var c : s)
            del += 'b' - c; // 统计 'a' 的个数
        int ans = del;
        for (var c : s) {
            // 'a' -> -1    'b' -> 1
            del += (c - 'a') * 2 - 1;
            ans = Math.min(ans, del);
        }
        return ans;
    }
}
class Solution {
public:
    int minimumDeletions(string s) {
        int del = 0;
        for (char c : s)
            del += 'b' - c; // 统计 'a' 的个数
        int ans = del;
        for (char c : s) {
            // 'a' -> -1    'b' -> 1
            del += (c - 'a') * 2 - 1;
            ans = min(ans, del);
        }
        return ans;
    }
};
func minimumDeletions(s string) int {
    del := strings.Count(s, "a")
    ans := del
    for _, c := range s {
        // 'a' -> -1    'b' -> 1
        del += int((c-'a')*2 - 1)
        if del < ans {
            ans = del
        }
    }
    return ans
}

复杂度分析

  • 时间复杂度:$O(n)$,其中 $n$ 为 $s$ 的长度。
  • 空间复杂度:$O(1)$,仅用到若干额外变量。

方法二:动态规划(一次遍历)

如果你还不熟悉动态规划(包括空间优化),可以先看看 动态规划入门

考虑 $s$ 的最后一个字母:

  • 如果它是 $\text{`b'}$,则无需删除,问题规模缩小,变成「使 $s$ 的前 $n-1$ 个字母平衡的最少删除次数」。
  • 如果它是 $\text{`a'}$:
    • 删除它,则答案为「使 $s$ 的前 $n-1$ 个字母平衡的最少删除次数」加上 $1$。
    • 保留它,那么前面的所有 $\text{`b'}$ 都要删除;

设 $\textit{cntB}$ 为前 $i$ 个字母中 $\text{`b'}$ 的个数。定义 $f[i]$ 表示使 $s$ 的前 $i$ 个字母平衡的最少删除次数:

  • 如果第 $i$ 个字母是 $\text{`b'}$,则 $f[i] = f[i-1]$;
  • 如果第 $i$ 个字母是 $\text{`a'}$,则 $f[i] = \min(f[i-1]+1,\textit{cntB})$。

代码实现时,可以只用一个变量表示 $f$。

答疑

:这一次遍历怎么没两次遍历快啊?

:方法一解释了。通过这两种方法的对比,相信你能感受到随机数据对分支预测的影响。

class Solution:
    def minimumDeletions(self, s: str) -> int:
        f = cnt_b = 0
        for c in s:
            if c == 'b': cnt_b += 1  # f 值不变
            else: f = min(f + 1, cnt_b)
        return f
class Solution {
    public int minimumDeletions(String s) {
        int f = 0, cntB = 0;
        for (char c : s.toCharArray())
            if (c == 'b') ++cntB; // f 值不变
            else f = Math.min(f + 1, cntB);
        return f;
    }
}
class Solution {
public:
    int minimumDeletions(string s) {
        int f = 0, cnt_b = 0;
        for (char c : s)
            if (c == 'b') ++cnt_b; // f 值不变
            else f = min(f + 1, cnt_b);
        return f;
    }
};
func minimumDeletions(s string) int {
    f, cntB := 0, 0
    for _, c := range s {
        if c == 'b' { // f 值不变
            cntB++
        } else {
            f = min(f+1, cntB)
        }
    }
    return f
}

func min(a, b int) int { if b < a { return b }; return a }

复杂度分析

  • 时间复杂度:$O(n)$,其中 $n$ 为 $s$ 的长度。
  • 空间复杂度:$O(1)$,仅用到若干额外变量。

相似题目(前后缀分解,部分题目要结合 DP)


附:我的 每日一题·高质量题解精选,已分类整理好。

欢迎关注【biIibiIi@灵茶山艾府】,高质量算法教学,持续更新中~

使字符串平衡的最少删除次数

方法一:枚举

思路

通过删除部分字符串,使得字符串达到下列三种情况之一,即为平衡状态:

  1. 字符串全为 $\text{``a''}$;
  2. 字符串全为 $\text{``b''}$;
  3. 字符串既有 $\text{a''}$ 也有 $\text{b''}$,且所有 $\text{a''}$ 都在所有 $\text{b''}$ 左侧。

其中,为了达到第 $1$ 种情况,最少需要删除所有的 $\text{b''}$。为了达到第 $2$ 种情况,最少需要删除所有的 $\text{a''}$。而第 $3$ 种情况,可以在原字符串相邻的两个字符之间划一条间隔,删除间隔左侧所有的 $\text{b''}$ 和间隔右侧所有的 $\text{a''}$ 即可达到。用 $\textit{leftb}$ 表示间隔左侧的 $\text{b''}$ 的数目,$\textit{righta}$ 表示间隔左侧的 $\text{a''}$ 的数目,$\textit{leftb}+\textit{righta}$ 即为当前划分的间隔下最少需要删除的字符数。这样的间隔一共有 $n-1$ 种,其中 $n$ 是 $s$ 的长度。遍历字符串 $s$,即可以遍历 $n-1$ 种间隔,同时更新 $\textit{leftb}$ 和 $\textit{righta}$ 的数目。而上文讨论的前两种情况,其实就是间隔位于首字符前和末字符后的两种特殊情况,可以加入第 $3$ 种情况一并计算。

代码

###Python

class Solution:
    def minimumDeletions(self, s: str) -> int:
        leftb, righta = 0, s.count('a')
        res = righta
        for c in s:
            if c == 'a':
                righta -= 1
            else:
                leftb += 1
            res = min(res, leftb + righta)
        return res

###Java

class Solution {
    public int minimumDeletions(String s) {
        int leftb = 0, righta = 0;
        for (int i = 0; i < s.length(); i++) {
            if (s.charAt(i) == 'a') {
                righta++;
            }
        }
        int res = righta;
        for (int i = 0; i < s.length(); i++) {
            char c = s.charAt(i);
            if (c == 'a') {
                righta--;
            } else {
                leftb++;
            }
            res = Math.min(res, leftb + righta);
        }
        return res;
    }
}

###C#

public class Solution {
    public int MinimumDeletions(string s) {
        int leftb = 0, righta = 0;
        foreach (char c in s) {
            if (c == 'a') {
                righta++;
            }
        }
        int res = righta;
        foreach (char c in s) {
            if (c == 'a') {
                righta--;
            } else {
                leftb++;
            }
            res = Math.Min(res, leftb + righta);
        }
        return res;
    }
}

###C++

class Solution {
public:
    int minimumDeletions(string s) {
        int leftb = 0, righta = 0;
        for (int i = 0; i < s.size(); i++) {
            if (s[i] == 'a') {
                righta++;
            }
        }
        int res = righta;
        for (int i = 0; i < s.size(); i++) {
            char c = s[i];
            if (c == 'a') {
                righta--;
            } else {
                leftb++;
            }
            res = min(res, leftb + righta);
        }
        return res;
    }
};

###C

#define MIN(a, b) ((a) < (b) ? (a) : (b))

int minimumDeletions(char * s) {
    int len = strlen(s);
    int leftb = 0, righta = 0;
    for (int i = 0; i < len; i++) {
        if (s[i] == 'a') {
            righta++;
        }
    }
    int res = righta;
    for (int i = 0; i < len; i++) {
        char c = s[i];
        if (c == 'a') {
            righta--;
        } else {
            leftb++;
        }
        res = MIN(res, leftb + righta);
    }
    return res;
}

###JavaScript

var minimumDeletions = function(s) {
    let leftb = 0, righta = 0;
    for (let i = 0; i < s.length; i++) {
        if (s[i] === 'a') {
            righta++;
        }
    }
    let res = righta;
    for (let i = 0; i < s.length; i++) {
        const c = s[i];
        if (c === 'a') {
            righta--;
        } else {
            leftb++;
        }
        res = Math.min(res, leftb + righta);
    }
    return res;
};

###go

func minimumDeletions(s string) int {
    leftb := 0
    righta := 0
    for _, c := range s {
        if c == 'a' {
            righta++
        }
    }
    res := righta
    for _, c := range s {
        if c == 'a' {
            righta--
        } else {
            leftb++
        }
        res = min(res, leftb+righta)
    }
    return res
}

func min(a, b int) int {
    if a > b {
        return b
    }
    return a
}

复杂度分析

  • 时间复杂度:$O(n)$,其中 $n$ 是 $s$ 的长度。需要遍历两遍 $s$,第一遍计算出 $s$ 中 $\text{``a''}$ 的数量,第二遍遍历所有的间隔,求出最小需要删除的字符数。

  • 空间复杂度:$O(1)$,只需要常数空间。

提示词工程入门-03

前言

"写个代码" "帮我写个快速排序函数,用 Python 实现,要求时间复杂度 O(n log n),添加详细注释"

同样是让 AI 写代码,为什么第一个指令得到的是模糊的回复,而第二个能得到精确满足需求的代码?

这就是提示词工程(Prompt Engineering)的魔力。

好的 Prompt = 好的输出。今天我们来学习如何写出让 AI "秒懂"的提示词。


1. 什么是提示词工程

提示词(Prompt):你给大模型的输入指令

提示词工程(Prompt Engineering):设计和优化 Prompt 的艺术和科学

Prompt 的黄金结构

┌─────────────────────────────────────────┐
│           Prompt 结构模板                 │
├─────────────────────────────────────────┤
│                                         │
│  1. 角色(Role)                         │
│     "你是一个经验丰富的程序员..."        │
│                                         │
│  2. 任务(Task)                         │
│     "请写一个快速排序函数..."           │
│                                         │
│  3. 上下文(Context)                    │
│     "用于处理整数数组..."               │
│                                         │
│  4. 约束(Constraints)                  │
│     "要求O(n log n)时间复杂度..."       │
│                                         │
│  5. 格式(Format)                       │
│     "返回JSON格式,包含code和说明..."    │
│                                         │
│  6. 示例(Examples)                     │
│     "输入:[3,1,2] 输出:[1,2,3]"         │
│                                         │
└─────────────────────────────────────────┘

2. 案例

案例 1:明确角色的力量

❌ 没有角色

"怎么提高编程能力?"

✅ 有角色

"你是一位有10年经验的编程导师,
曾经指导过数百名初学者成为资深工程师。
请给我提供提高编程能力的建议。"

效果差异:有角色的 Prompt 能得到更有针对性、更有深度的回答。

案例 2:具体明确的任务

❌ 模糊的任务

"帮我写个文章"

✅ 明确的任务

"请写一篇关于人工智能发展历史的文章,
要求:
1. 字数800-1000字
2. 包含三个主要发展阶段
3. 提到GPT、Llama等关键模型
4. 语言风格通俗易懂"

SMART 原则

  • Specific(具体明确):写 Python 代码而非"写代码"
  • Measurable(可衡量):100字以内
  • Achievable(可达成):不要求超出模型能力
  • Relevant(相关性):任务与上下文相关
  • Time-bound(有时限):30秒内能读完的介绍

案例 3:提供充足上下文

❌ 缺少上下文

"这个代码有什么问题?"
[粘贴一段代码]

✅ 充足上下文

"我正在开发一个电商网站的用户认证功能。
这段Python代码用于验证用户密码,
但总是返回False,帮我找出问题:

[粘贴代码]

预期行为:正确密码返回True,错误密码返回False
实际行为:所有密码都返回False"

上下文要素清单

  • 背景:这是什么项目/场景?
  • 目标:想要达到什么效果?
  • 现状:当前是什么情况?
  • 问题:遇到了什么具体问题?

案例 4:使用示例的魔力

少样本学习示例

例子1:
输入:苹果
分类:水果

例子2:
胡萝卜
分类:蔬菜

例子3:
香蕉
分类:?

思维链示例

问题:小明有5个苹果,吃了2个,又买了3个,现在有几个?

思考过程:
1. 初始:小明有5个苹果
2. 吃了2个:5 - 2 = 3个
3. 买了3个:3 + 3 = 6个
答案:6个

现在请解决:
小红有10颗糖,给了妹妹3颗,妈妈又给了她5颗,现在有几颗?

效果:示例能让 AI 快速理解你想要的输出模式。

案例 5:格式化输出

❌ 不好的 Prompt

"分析这段代码"

✅ 好的 Prompt

"请分析以下代码,并按以下格式输出:

## 代码功能
[简要说明代码的功能]

## 时间复杂度
[分析时间复杂度]

## 改进建议
[列出3条具体改进建议]

## 重构代码
\`\`\`python
[重构后的代码]
\`\`\`"

案例 6:常用 Prompt 模板

代码生成模板

你是一个{语言}专家。
请写一个{功能描述}的{语言}函数,
要求:
1. {要求1}
2. {要求2}
3. {要求3}

请包含:
- 详细的注释
- 错误处理
- 使用示例

Bug 调试模板

我在开发一个{项目类型}项目,
这段{语言}代码出现了{问题描述}:

\`\`\`{语言}
{代码}
\`\`\`

预期行为:{预期}
实际行为:{实际}

请帮我:
1. 找出问题所在
2. 解释问题原因
3. 提供修复方案

总结

核心技巧速记

技巧 说明 示例
明确角色 给 AI 分配身份 "你是一位经验丰富的程序员"
具体任务 清楚说明要做什么 "写一个快速排序,O(n log n)"
充足上下文 提供背景信息 "这是电商网站的推荐功能"
使用示例 展示期望格式 "输入A输出B,输入C输出?"
明确格式 说明输出形式 "以 JSON 格式返回"

Prompt 检查清单

发送 Prompt 前,问自己:

  • 角色清晰:是否告诉 AI 它的角色?
  • 任务明确:是否清楚说明了要做什么?
  • 上下文充足:是否提供了足够的背景信息?
  • 约束具体:是否说明了限制和要求?
  • 格式明确:是否说明了输出格式?
  • 示例充分:是否提供了参考示例?

常见陷阱

陷阱 问题 解决方案
过于简短 "帮我优化代码" 说明具体优化目标
矛盾要求 "性能最好但代码简洁" 明确优先级
一次太多 "写完整电商系统" 分解为小任务
缺少验证 "计算复杂数学" 要求给出计算步骤

高级技巧

1. 迭代优化

第一次尝试 → 评估结果 → 修改 Prompt → 再次尝试

2. 温度参数调整

  • 低温度(0.1-0.3):更确定、一致的输出
  • 中等温度(0.4-0.7):平衡创造性和准确性
  • 高温度(0.8-1.0):更创造性、多样化的输出

3. 分解复杂任务

"设计一个博客系统"
↓ 分解为
"1. 设计数据库结构"
"2. 设计用户认证 API"
"3. 设计文章发布 API"
  1. 不要期望一次完美:迭代优化是常态
  2. 从简单开始:先验证基本方向,再添加细节
  3. 保存好用的 Prompt:建立自己的模板库
  4. 对比实验:用不同版本测试效果差异
  5. 学习他人经验:参考优秀的 Prompt 示例

主流模型对比-02

前言

GPT-4、Claude、Llama、Qwen、DeepSeek...

面对层出不穷的大语言模型,你是否也曾感到迷茫?

  • 选贵的 GPT-4,还是用免费的开源模型?
  • 中文场景应该用什么模型?
  • 本地部署和云端 API 各有什么优劣?
  • 性价比最高的选择是什么?

选对模型,不仅能节省成本,还能获得更好的效果。今天我们来聊聊如何做出明智的选择。


1. 什么是模型选型

1.1 闭源模型 vs 开源模型

特点 闭源模型 开源模型
代表 GPT-4、Claude、Gemini Llama、Qwen、DeepSeek
性能 性能最强 快速追赶中
成本 价格昂贵 免费或低价
部署 仅云端 可本地部署
隐私 数据上传云端 完全私密
定制 难以定制 高度可定制

1.2 重要指标

指标 说明
参数量 模型规模,通常越大越强
上下文长度 能处理的文本长度
推理能力 逻辑思考和问题解决能力
中文能力 对中文的理解和生成质量
API 价格 每 1M tokens 的费用

2. 案例

案例 1:主流闭源模型对比

模型 核心优势 价格 最佳场景
GPT-4o 综合能力最强 $5/1M 输入 复杂任务、多模态
Claude 3.5 Sonnet 长文本、推理强 $3/1M 输入 编程、写作
Gemini 1.5 Pro 超长上下文(1M) 按用量计费 视频、长文档处理

选型建议:追求极致性能时选择闭源模型。

案例 2:主流开源模型对比

模型 参数量 核心优势 硬件要求 最佳场景
Qwen2.5 7B 70亿 中文优秀 6GB 显存 中文本地部署
Llama 3.1 8B 80亿 通用平衡 8GB 显存 英文场景
DeepSeek-V3 680亿 性价比之王 24GB 显存 编程任务
GLM-4 9B 90亿 国产化 6GB 显存 企业应用

选型建议:预算有限或关注隐私时选择开源模型。

案例 3:按场景选模型

场景 1:中文日常对话

推荐:Qwen2.5 7B 或 DeepSeek-V3
原因:
- 中文能力强
- API 价格低(¥1/1M 输入)
- 响应速度快

场景 2:代码生成

推荐:DeepSeek-V3 或 Claude 3.5 Sonnet
原因:
- 代码质量高
- 理解上下文能力强
- 调试能力出色

场景 3:长文档处理

推荐:Claude 3 或 Gemini 1.5 Pro
原因:
- 支持 200K-1M tokens 上下文
- 总结能力强
- 细节保留好

场景 4:本地部署

推荐:Qwen2.5 7B (INT4 量化)
原因:
- 显存需求低(~4GB)
- 中文优化好
- 性能接近 GPT-3.5

案例 4:性价比对比

云端 API 价格对比(1M tokens)

模型 输入价格 输出价格 性价比评级
DeepSeek-V3 ¥1 ¥2 ⭐⭐⭐⭐⭐
Qwen-turbo ¥0.8 ¥2 ⭐⭐⭐⭐⭐
GPT-4o $5 $15 ⭐⭐
Claude 3.5 $3 $15 ⭐⭐

结论:DeepSeek 和 Qwen 性价比最高。

案例 5:本地部署成本分析

配置 硬件成本 月电费 年总成本 何时回本
Qwen2.5 7B ¥3000 ¥50 ¥3600 使用 > 1 年
Qwen2.5 14B ¥6000 ¥80 ¥6960 使用 > 1.5 年

结论:长期使用(>1年),本地部署更划算。


总结

  1. 没有最好的模型,只有最适合的模型
  2. 根据实际场景选择,而非盲目追求最新最强
  3. 性价比很重要,尤其是在大规模使用时
  4. 开源模型已足够应对大部分场景
  5. 长期使用考虑本地部署,短期项目用云端 API

Three.js 透视相机完全指南:从入门到精通

什么是透视相机?

生活中的类比

想象你拿着一部手机摄像头对着一个房间:

  • 📱 手机摄像头 = Three.js 中的相机
  • 🎬 摄像头能看到的范围 = 视锥体(Frustum)
  • 📐 摄像头的视角宽度 = 视野角度(FOV)

透视相机就像真实的摄像头一样,离物体越近,看到的范围越小;离物体越远,看到的范围越大。这就是"透视"的含义。

代码示例

import * as THREE from 'three';

// 创建透视相机
const camera = new THREE.PerspectiveCamera(
    40,      // 视野角度(FOV)
    2,       // 宽高比(aspect ratio)(一般3D游戏中的屏幕适配都是在调整这个值)
    0.1,     // 近裁剪面(near)
    1000     // 远裁剪面(far)
);

camera.position.z = 120; // 相机位置

四个关键参数详解

参数 1️⃣:视野角度(FOV - Field of View)

定义:相机能看到的垂直视角范围,单位是度数(°)。

直观理解

  • 🔭 FOV = 10° → 像用望远镜看,视角很窄,看到的东西很小但很清晰
  • 📷 FOV = 40° → 像用普通手机摄像头,视角适中
  • 🐟 FOV = 90° → 像用鱼眼镜头,视角很宽,看到很多东西但会变形
// 小视角 - 看得清楚但范围小
const camera1 = new THREE.PerspectiveCamera(20, 2, 0.1, 1000);

// 中等视角 - 平衡
const camera2 = new THREE.PerspectiveCamera(40, 2, 0.1, 1000);

// 大视角 - 看得多但容易变形
const camera3 = new THREE.PerspectiveCamera(75, 2, 0.1, 1000);

实际应用

  • 🎮 第一人称游戏:FOV 通常 60-90°
  • 🏢 建筑可视化:FOV 通常 40-50°
  • 🎥 电影效果:FOV 通常 24-35°

参数 2️⃣:宽高比(Aspect Ratio)

定义:相机画面的宽度与高度的比例

计算方式

const aspect = window.innerWidth / window.innerHeight;
// 例如:1920 / 1080 = 1.777...

为什么重要

  • ✅ 如果 aspect 与实际画布比例一致,画面不会被拉伸
  • ❌ 如果不一致,圆形会变成椭圆,正方形会变成矩形
// 假设窗口是 1920×1080
const aspect = 1920 / 1080; // ≈ 1.78

const camera = new THREE.PerspectiveCamera(40, aspect, 0.1, 1000);

响应式设计

function onWindowResize() {
    const width = window.innerWidth;
    const height = window.innerHeight;
    
    camera.aspect = width / height;
    camera.updateProjectionMatrix(); // 重要!更新投影矩阵
    
    renderer.setSize(width, height);
}

window.addEventListener('resize', onWindowResize);

参数 3️⃣:近裁剪面(Near)

定义:相机能看到的最近距离。比这个距离更近的物体不会被显示。

为什么需要它

  • 🎯 性能优化:不渲染相机背后的物体
  • 🔍 避免穿模:防止相机进入物体内部时看到内部结构
// near = 0.1 意味着距离相机 0.1 单位以内的物体看不到
const camera = new THREE.PerspectiveCamera(40, 2, 0.1, 1000);

// 如果物体在 z = 0.05,它会被裁剪掉
const cube = new THREE.Mesh(geometry, material);
cube.position.z = 0.05; // ❌ 看不到

参数 4️⃣:远裁剪面(Far)

定义:相机能看到的最远距离。比这个距离更远的物体不会被显示。

为什么需要它

  • 🎯 性能优化:不渲染太远的物体
  • 🔍 深度精度:提高深度缓冲的精度
// far = 1000 意味着距离相机 1000 单位以外的物体看不到
const camera = new THREE.PerspectiveCamera(40, 2, 0.1, 1000);

// 如果物体在 z = 1500,它会被裁剪掉
const star = new THREE.Mesh(geometry, material);
star.position.z = 1500; // ❌ 看不到

视锥体的可视范围计算

什么是视锥体?

视锥体是一个四棱锥形的空间,只有在这个空间内的物体才能被看到。

计算可视范围的公式

在距离相机 distance 处,可视范围的大小为:

垂直高度 = 2 × tan(FOV/2) × distance
水平宽度 = 垂直高度 × aspect

实际计算示例

假设我们有这样的相机配置:

const fov = 40;           // 视野角度
const aspect = 2;         // 宽高比(2:1)
const distance = 120;     // 相机距离(z = 120)

// 计算垂直可视高度
const vFOV = fov * Math.PI / 180; // 转换为弧度
const height = 2 * Math.tan(vFOV / 2) * distance;
// height = 2 × tan(20°) × 120
// height = 2 × 0.364 × 120
// height ≈ 87.2

// 计算水平可视宽度
const width = height * aspect;
// width = 87.2 × 2
// width ≈ 174.4

结论:在 z=120 处,相机能看到的范围是:

  • 📏 宽度:174.4 单位
  • 📏 高度:87.2 单位

坐标范围的确定

场景布局示例

假设我们要在场景中放置一个 5×4 的网格(5列 4行),每个物体之间间距为 15 单位:

const spread = 15; // 间距

// 物体位置计算
for (let x = -2; x <= 2; x++) {
    for (let y = -1; y <= 2; y++) {
        const obj = createObject();
        obj.position.x = x * spread;  // x: -30, -15, 0, 15, 30
        obj.position.y = y * spread;  // y: -15, 0, 15, 30
        scene.add(obj);
    }
}

对象分布范围

  • X 轴:-30 到 30(宽度 60)
  • Y 轴:-15 到 30(高度 45)

检查是否超出可视范围

// 相机可视范围
const visibleWidth = 174.4;
const visibleHeight = 87.2;

// 对象范围
const objectWidth = 60;
const objectHeight = 45;

// 检查
console.log(`宽度是否超出: ${objectWidth > visibleWidth}`); // false ✅
console.log(`高度是否超出: ${objectHeight > visibleHeight}`); // false ✅

如果超出范围怎么办?

问题:当 spread=20 时,对象范围变为 80×60,超出了可视范围。

解决方案

方案 1:增加相机距离

// 原来
camera.position.z = 120;

// 改为
camera.position.z = 160; // 距离越远,看到的范围越大

方案 2:增加视野角度

// 原来
const fov = 40;

// 改为
const fov = 60; // 视角越大,看到的范围越大

方案 3:减小间距

// 原来
const spread = 20;

// 改为
const spread = 15; // 物体靠得更近

方案 4:使用正交相机

如果你不需要透视效果,可以使用正交相机(OrthographicCamera),它的可视范围不会随距离变化:

const camera = new THREE.OrthographicCamera(
    -100,  // left
    100,   // right
    50,    // top
    -50,   // bottom
    0.1,   // near
    1000   // far
);

实战代码

完整的响应式相机设置

import * as THREE from 'three';

class ResponsiveCamera {
    constructor() {
        this.fov = 40;
        this.near = 0.1;
        this.far = 1000;
        this.distance = 120;
        
        this.updateCamera();
    }
    
    updateCamera() {
        const aspect = window.innerWidth / window.innerHeight;
        
        this.camera = new THREE.PerspectiveCamera(
            this.fov,
            aspect,
            this.near,
            this.far
        );
        
        this.camera.position.z = this.distance;
    }
    
    // 计算指定深度处的可视范围
    getVisibleRange(depth = null) {
        const vFOV = (this.fov * Math.PI) / 180;
        // 如果没有指定深度,使用相机的默认距离
        const distance = depth !== null ? depth : this.distance;
        const height = 2 * Math.tan(vFOV / 2) * distance;
        const width = height * (window.innerWidth / window.innerHeight);

        return { width, height };
    }
    
    // 检查物体是否在可视范围内
    isObjectVisible(obj) {
        const pos = obj.position;
        
        // 计算物体相对于相机的距离(沿着相机的视线方向)
        const distanceFromCamera = this.camera.position.z - pos.z;
        
        // 计算物体所在深度的可视范围
        const range = this.getVisibleRange(distanceFromCamera);
        console.log('物体距离相机:', distanceFromCamera, '该深度的可视范围:', range);

        return (
            Math.abs(pos.x) <= range.width / 2 &&
            Math.abs(pos.y) <= range.height / 2 &&
            distanceFromCamera >= this.near &&
            distanceFromCamera <= this.far
        );
    }
    
    // 窗口大小改变时更新
    onWindowResize() {
        this.updateCamera();
        this.camera.updateProjectionMatrix();
    }
}

// 使用示例
// 使用示例
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x222222);

// 4. 创建渲染器:将3D场景渲染到网页上
const renderer = new THREE.WebGLRenderer({ antialias: true }); // antialias开启抗锯齿
renderer.setSize(window.innerWidth, window.innerHeight); // 设置渲染尺寸
// 将渲染器的画布添加到网页中
document.body.appendChild(renderer.domElement);

const camera = new ResponsiveCamera();
window.addEventListener('resize', () => camera.onWindowResize());

// 添加光源
const ambientLight = new THREE.AmbientLight(0xffffff, 0.6);
scene.add(ambientLight);

const directionalLight = new THREE.DirectionalLight(0xffffff, 0.8);
directionalLight.position.set(50, 50, 50);
scene.add(directionalLight);

// 检查物体是否可见
const cube = new THREE.Mesh(new THREE.BoxGeometry(8, 8, 8), createMaterial());
cube.position.set(20, 20, -10);
scene.add(cube);

function animate(time) {
    renderer.render(scene, camera.getCamera());
    requestAnimationFrame(animate);
}

requestAnimationFrame(animate);

console.log(camera.isObjectVisible(cube)); // true or false

小结

  1. FOV 和 distance 决定可视范围

    • FOV 越大,看到的范围越大
    • distance 越大,看到的范围越大
  2. aspect 必须与画布比例一致

    • 否则画面会被拉伸变形
  3. near 和 far 定义了深度范围

    • 在这个范围外的物体看不到

📂 核心代码与完整示例:    my-three-app

总结

如果你喜欢本教程,记得点赞+收藏!关注我获取更多Three.js开发干货

Agent Skills:智能 IDE 正在掀起一场“能力模块化”革命

从“靠嘴指挥”到“直接上手”,AI 终于学会像工程师一样干活了。


AI 从聊天走向执行

一、Agent Skills 是什么?🤔

如果你最近频繁玩 Cursor、Claude Code、Gemini CLI、Codex CLI、Trae 这类“智能 IDE”,应该已经察觉到一个明显的趋势:

AI 不再满足于“你问我答”了,它开始直接上手帮你把事做完

而让这一切成为可能的核心机制之一,就是 Agent Skills

简单来说,Agent Skills 是一组可复用、可组合、能被 AI 主动调用的“技能包”。它不是一个简单的 Prompt,也不只是一个 API,而是:

  • 一套结构化的任务说明书(SOP)
  • 一组可以直接运行的脚本、模板或工具
  • 一种能被 IDE 或 AI 理解并随时调用的能力描述方式

你可以把它想象成:

扔给 AI 的一本「岗位职责 + 操作手册」

来看一个真实的 Skill 长啥样

在 Claude / Cursor 的生态里,一个 Skill 其实就是一个文件夹,核心是里面的 SKILL.md

.claude/skills/
  react-component-generator/
    SKILL.md
    templates/
    scripts/

SKILL.md 的开头是 YAML 配置,后面跟着 Markdown 格式的执行指南:

---
name: react-component-generator
description: 根据需求生成 React 组件
---

当用户描述一个 UI 或功能需求时:
1. 分析组件的职责与结构
2. 匹配合适的组件模板
3. 生成符合当前项目规范的 React 组件代码

AI 不再是“随意发挥”,而是在明确流程约束下执行任务,大幅降低随机性


Skill = SOP + 工具箱

二、技能进化史:Agent Skills 是怎么来的?📜

Agent Skills 不是石头缝里蹦出来的,它的出现其实是一条清晰的技术演进路线的终点。

1️⃣ Prompt Engineering 时代:纯靠“嘴遁”

早期我们只能靠 Prompt 来指挥 AI:

“你是一个资深前端,请按以下规范生成代码……”

但问题很明显:

  • 不稳定(上下文一变就跑偏)
  • 难复用(每次都要重新说一遍)
  • 团队经验无法沉淀

2️⃣ Tool / Function Calling:AI 学会“用工具”了

接着出现了 Toolformer、Function Calling

  • AI 能自己判断“什么时候该调用工具”
  • 还能把参数整理好传给函数

但依然存在一个短板:

工具只告诉 AI“你能做什么”,却没告诉它“你应该怎么做”

3️⃣ Agent + Skills:开始真正“像人一样工作”

Agent Skills 补上了最后一块拼图:

  • 人类工程师的工作流固化下来
  • 让 AI 面对复杂任务时,有清晰的步骤可循
  • 支持按需加载技能,避免“一次性吃成胖子”(上下文爆炸)

这也是为什么 Claude、Cursor、VSCode Agent、Trae 都在 2024~2025 年间集体拥抱了 Skills 机制。


Prompt vs Skill

三、Skills vs MCP:别再傻傻分不清楚!🚨

这是很多人容易搞混、但又必须弄清楚的一个点。

MCP 是什么?

MCP(Multi-agent / Model Context Protocol) 解决的是:

Agent 怎么“连接外部世界”

比如:

  • 读取数据库
  • 调用搜索引擎
  • 操作浏览器或文件系统

你可以把它理解成:

接口协议 / 数据管道 / 外设驱动

Skills 又是什么?

Skills 解决的则是另一件事:

Agent 应该“按照什么流程正确做事”

一个超形象的类比

角色 类比
MCP USB 接口 / API 网关
Skills 说明书 / 标准作业程序(SOP)

👉 MCP 管的是“能不能做”
👉 Skills 管的是“该怎么做”

它们俩是互补关系,而不是谁替代谁。


MCP vs Skills

四、上手实战:在智能 IDE 里怎么玩转 Skills?🎮

我们拿 Claude Code / Cursor 来举个完整的例子。

1️⃣ 安装 Skills 管理工具

npm install -g openskills

2️⃣ 安装官方或社区的 Skills

openskills install anthropics/skills

你可以挑着装,比如只装这几个:

  • pptx-generator(自动做 PPT)
  • git-changelog-analyzer(分析 Git 日志)
  • code-review-helper(辅助代码审查)

3️⃣ 生成 AGENTS.md 索引文件

openskills sync

这个文件非常关键:

它就像是 AI 的“技能清单”

4️⃣ 实际体验:一句话让 AI 干活

在 Cursor / Claude Code 里直接输入:

pptx skill,基于最近 10 次 git commit,生成一份技术周报 PPT

接下来 AI 会自动:

  1. 查看 AGENTS.md
  2. 找到 pptx 这个技能
  3. 拉取 git 日志
  4. 调用模板和脚本生成 PPT 文件

全程无需你再手写任何一句 Prompt


Skill 调用流程

五、真实场景:Skills 能用来做什么?💼

这里才是 Agent Skills 真正让人兴奋(甚至有点吓人)的地方。

🧩 对开发者

  • 统一项目代码风格(ESLint + Prettier 自动化)
  • 自动生成组件、API 层、Mock 数据
  • 一键补全单元测试
  • 按团队规范批量重构代码

📦 对产品与技术管理者

  • 自动生成 PRD / 技术方案框架
  • 版本发布自动生成变更说明
  • 定期生成技术周报、月报

🛠 对团队协作

  • 把团队大佬的经验封装成 Skill
  • 新人入职直接“加载技能包”
  • AI 化身真正能干的“虚拟同事”

一句话总结:

只要你能写成标准流程的活儿,都有可能变成一个 Skill。


Skills 应用场景

六、冷静一下:Skills 的隐患与未来 ⚠️

能力越强,责任越大,风险也越真实。

⚠️ 已经露头的风险

  • 恶意 Skill 偷偷注入危险指令
  • 一次授权,AI 就可能长期“失控”
  • 诱导 AI 执行高权限操作(比如读 SSH Key、跑系统命令)

现实中已有安全团队警告:

部分 Skill 可在用户无感知的情况下,读取敏感文件或执行命令。

🛡️ 必须补上的防护

  • Skill 权限分级管理
  • 脚本在沙箱中执行
  • 安全扫描与审核机制
  • 关键操作需人类确认

🌱 未来的想象空间

  • Skill 应用商店 & 行业标准
  • 一次开发,多 IDE 通用
  • 企业私有 Skill 仓库
  • Skill + MCP + Agent 的完整工程体系

写在最后 ✨

如果你最近觉得:

“AI 终于像个靠谱的工程师了”

那很可能是因为:

它不再只是一个语言模型,而是一个装备了 Skills 的智能体。

下一阶段的竞争,或许不再是比谁的 Prompt 写得妙, 而是看谁更会给 AI 设计“技能架构”

欢迎从今天开始,

少写 Prompt,多写 Skills。 🚀


React扩展

CSS

styled-components: yarn add styled-components

// props透传
// attrs的使用
// 传入state作为props属性
const StyledInput = styled.input.attrs({
  type: 'password',
  placeholder:'请输入',
  bgColor:'#000'
})`
  color: ${props => props.color};
  background-color: ${props => props.bgColor};
`

// 继承
const StyledInput2 = styled(StyledInput)`
  margin-top: 20px;
  padding: 10px;
`

动画

npm install react-transition-group --save

<CSSTransition
    nodeRef={this.nodeRef}
    in={this.state.show}
    timeout={500}
    classNames="fade"
    unmountOnExit
    appear
>
    <h1 ref={this.nodeRef}>CSS Transition</h1>
</CSSTransition>

.fade-enter, .fade-appear{
    opacity: 0;
}

.fade-enter-active, .fade-appear-active{
    opacity: 1;
    transition: opacity 500ms ease-in-out;
}

.fade-enter-done, .fade-appear-done{
    opacity: 1;
}
.fade-exit{
    opacity: 1;
}


.fade-exit-active{
    opacity: 0;
    transition: opacity 500ms ease-in-out;
}

.fade-exit-done{
    opacity: 0;
}

Redux

// index.js
import { createStore } from 'redux'
import reducer from './reducer'
const store = createStore(reducer)
store.subscribe(() => {
  console.log(store.getState())
})
store.dispatch(addAction(1))
store.dispatch(subAction(10))

// action.js
export const addAction= (num) => ({
  type: ADD_NUMBER,
  num
})
export const subAction= (num) => ({
  type: SUB_NUMBER,
  num
})

// reducer.js
const defaultlState = {
  count: 0,
}
function reducer(state = defaultlState, action) {
  switch (action.type) {
    case ADD_NUMBER:
      return {
        ...state,
        count: state.count + action.num,
      } 
    case SUB_NUMBER:
      return {
        ...state,
        count: state.count - action.num,
      } 
    default:
      return state
  }
}


// 加入connect
// index.js
import { createStore, applyMiddleware } from 'redux'
**import { thunk } from 'redux-thunk'**
import reducer from './reducer'

const storeEnhancer = applyMiddleware(thunk)

const store = createStore(reducer, storeEnhancer)    

export default store

// reducer.js
import { createStore, applyMiddleware } from 'redux'
import { thunk } from 'redux-thunk'
import reducer from './reducer'

const storeEnhancer = applyMiddleware(thunk)

const store = createStore(reducer, storeEnhancer)    

export default store

// actionCreator.js
import { ADD_NUMBER, SUB_NUMBER } from './constans'

export const addAction= (num) => ({
  type: ADD_NUMBER,
  num
})

export const subAction= (num) => ({
  type: SUB_NUMBER,
  num
})

**export const getBannerList = (dispatch) => (
  console.log('getBannerList')
)**

// connect.js
import { PureComponent } from "react";
import store from '../store'

export function connect(mapStateToProps, mapDispatchToProps) {
  return function (Component) {
    return class ConnectedComponent extends PureComponent {
      constructor(props) {
        super(props)
        this.state = {
            storeState: mapStateToProps(store.getState()),
        }
      }
      componentDidMount() {
        this.unsubscribe = store.subscribe(() => {
          this.setState({
            storeState: mapStateToProps(store.getState()),
          })
        })
      }
      componentWillUnmount() {
        this.unsubscribe()
      }
      render() {
        return (
          <Component
            {...this.props}
            {...this.state.storeState}
            {...mapDispatchToProps(store.dispatch)}
          />
        );
      }
    };
  };
}
// home.js
import { connect } from '../utils/connect'
import { addAction, subAction, getBannerList } from '../store/actionCreator'

const mapStateToProps = (state) => ({
  count: state.count
})

const mapDispatchToProps = (dispatch) => ({
  addAction: (num) => dispatch(addAction(num)),
  subAction: (num) => dispatch(subAction(num)),
  getBannerList: () => dispatch(getBannerList)
})

function Home2(props) {
    return (
        <>
            <div>
                <h1>HOME</h1>
                <h2>当前计数:{props.count}</h2>
            </div>
            <button onClick={() => props.addAction(1)}>+1</button>
            <button onClick={() => props.subAction(1)}>-1</button>
            <button onClick={() => props.getBannerList()}>获取轮播图</button>
        </>
    ) 
}

export default connect(mapStateToProps, mapDispatchToProps)(Home2)

router

npm install react-router-dom
BrowserRouter: history模式
HashRouter: hash模式

<BrowserRouter>
    <Link to="/a">首页</Link>
    <Link to="/about">关于</Link>
    <Routes>
        <Route path="/a" element={<Home />} />
        <Route path="/about" element={<About />} />
    </Routes>
</BrowserRouter>

function Routes() {
  return useRoutes([    
      {
        path: '/home',
        element: <Home />
      },
      {
        path: '/about',
        element: <About />,
        children: [
          {
            path: 'history',
            element: <AboutHistory />
          },
          {
            path: 'school',
            element: <AboutSchool />
          },
          {
            path: 'company',
            element: <AboutCompany />
          }
        ]
      }
])}

Hooks

class组件比函数式组件的优势(没有hooks的时候)
    1.class组件可以定义自己的state,用来保存组件自己内部的状态
      函数式组件不可以,因为函数每次调用都会产生新的临时变量
    2.class组件有自己的生命周期,比如在componentDidMounted中发送网络请求,
      并且在生命周期函数只会执行一次
      在函数中发送网络请求,意味着每次重新渲染都会重新发送一次网络请求
    3.class组件可以在状态改变时只会重新执行rendr函数以及生命周期componentDidUpdate
      函数式组件在重新渲染时,整个函数都会被执行
      
useState
本身是一个函数,来自react包
参数和返回值
    1.参数:给创建出来的状态一个默认值
    2.返回值:数组,元素1:当前state的状态值,元素2是更新状态的函数
只能在函数最外层调用Hook,不要在循环,条件判断或者子函数中使用
只能在react的函数组件中调用hook,不能在其他js函数中调用

useCallback
// memo 用于包裹函数组件,使其在 props 未发生变化时跳过重新渲染,从而提升性能。
// 父组件重新渲染,子组件也会重新渲染,但是如果子组件被 memo 包裹,且 props 未发生变化,那么子组件就不会重新渲染。
// HYBButton1的increment函数会重新创建,所以memo包裹的props发生变化,会重新渲染
// HYBButton2的increment函数不会重新创建,所以memo包裹的props没有发生变化,不会重新渲染
const HYBButton = memo(({title, increment}) => {
  console.log('重新渲染', title)
  return (
    <button onClick={increment}>
      +1
    </button>
  )
})

export default function Callback() {
  const [count, setCount] = useState(0)
  const [isShow, setIsShow] = useState(true)
  const handleClick1 = () => {
    setCount(count + 1)
    console.log(111)
  }

  const handleClick2 = useCallback(() => {
    setCount(count + 1)
    console.log(222)
  },[count])

  return (
    <div>
      <p>当前计数: {count}</p>
      <p>当前显示状态: {isShow ? '显示' : '隐藏'}</p>
      <HYBButton title="增加1" increment={handleClick1}  />
      <HYBButton title="增加2" increment={handleClick2} />
      <button onClick={() => setIsShow(!isShow)}>显示/隐藏</button>
    </div>
  )
}

useContext
App.js
export const UserContext = createContext()
<React.StrictMode>
    <UserContext.Provider value={{name: '张三', age: 18}}>
      <MemoDemo />
    </UserContext.Provider>
</React.StrictMode>

index.js
import { UserContext } from '../index'
export default function User() {
  const user = useContext(UserContext)
  return (
    <div>
      <p>当前用户: {user.name}</p>
      <p>当前用户年龄: {user.age}</p>
    </div>
  )
}

useMemo
import { useState,useMemo,memo } from 'react';
function calcNum(count){
    let total = 0
    for(let i = 0; i < count; i++) {
        total += i
        console.log('total',total)
    }
    return total
}

const HYUser = memo(function HYUser(props) {
  const {name, age} = props
  console.log('HYUser', name, age)
  return(
    <div>
      <p>当前用户: {name}</p>
      <p>当前年龄: {age}</p>
    </div>
  )
})

export default function MemoDemo() {
  const [count, setCount] = useState(0)
  const [isShow, setIsShow] = useState(true)
//   let total = 0
//   for(let i = 0; i < count; i++) {
//     // 切换show每次都会执行for循环
//     total += i
//   }
  const total = useMemo(() => calcNum(count), [count])
  const info = useMemo(() => ({name: '张三', age: 18}), []) // 依赖项为空数组,只在组件挂载时执行一次
//const info = {name: '张三', age: 18} 每次重新渲染都创建一个新对象,会导致HYUser组件重新渲染

  return(
    <div>
      <p>当前计数: {count}</p>
      <p>当前显示状态: {isShow ? '显示' : '隐藏'}</p>
      <p>当前total: {total}</p>
      <HYUser info={info} />
      <button onClick={() => setCount(count + 1)}>增加</button>
      <button onClick={() => setIsShow(!isShow)}>切换显示状态</button>
    </div>
  )
}
- useMemo :缓存值 ,用于优化复杂计算
- useCallback :缓存函数 ,用于优化子组件渲染
- useCallback 是 useMemo 的特例 : useCallback(fn, deps) 等价于 useMemo(() => fn, deps)

useReducer
import { useReducer } from 'react'
function reducer(state, action) {
  switch(action.type) {
    case 'increment':
      return {counter: state.counter + action.payload}
    case 'decrement':
      return {counter: state.counter - action.payload}
    default:
      return state
  }
}
export default function User() {
  const [state, dispatch] = useReducer(reducer, {counter: 0})
  return(
    <div>
      <p>当前状态: {state.counter}</p>
      <button onClick={() => dispatch({type: 'increment', payload: 1})}>增加</button>
      <button onClick={() => dispatch({type: 'decrement', payload: 1})}>减少</button>
    </div>
  )
}

useLayoutEffect
useEffect会在渲染的内容更新到DOM上后执行,不会阻塞DOM的更新
useLayoutEffect会在渲染的内容更新到DOM上之前执行,会阻塞DOM的更新
如果我们希望在某些操作发生之后再更新DOM,那么应该将这个操作放到useLayoutEffect

拒绝重复造轮子?我们偏偏花365天,用Vue3写了款AI协同的Word编辑器

ps:开源版SDK已发布,github地址:github.com/MrXujiang/j…

又到了我们定期AI创业复盘的时间了。今天和大家分享一下,我们决定花1年时间打造AI Word编辑器的理由,以及做AI文档创业过程中踩过的坑。图片

为什么"偏偏"要造个Word轮子

我们之所以下定决心做这件事,主要是因为前两年我们的一个产品,需要集成word能力对外服务,但是我们调研了一圈,得到的结论是:1. 大厂的文档产品集成成本过高,对外商业化受限头部大厂做的文档产品,要么是按次付费(并发次数):图片比如上面这张,如果高频使用,我们团队算了一笔账,每年api的调用基本都要花10-20w左右,更别说对外给客户服务了,那成本只有大公司能玩了。另一方面,国内B端客户大部分的Saas场景都需要私有化部署,国企事业金融企业更要求内网部署,所以基本上不可能集成大厂的SDK,这条商业模式已经被堵死了。2. 大厂的文档产品技术债过重,扩展度和开放程度受限这基本上是行业的共识了。普通企业只能用大厂的系统,如果要开发,要么动辄百万,要么就等“等更新”。但是我们的AI文档场景,并不需要很多冗余的功能,而是保留最核心的能力,其他的我们希望开发给企业自定义。同时,现有文档编辑器都是"互联网时代的产物",为"人写给人看"设计。AI无法真正理解文档结构,只能当"高级搜索框"。我们结合这两年AI的发展,洞察的结果是:内容创作正在从"人驱动"转向"人机协作",但工具没有跟上。所以综合上面分析,再结合我们团队大厂架构和文档产品的研发经验,我们毅然决定自研。

365天我们做了什么

图片

说实话,规划了1年,其实并不是单纯的时间维度的概念,我们打算将 JitWord 打造成一个未来我们 all in 的一个产品长链路的方向:基于JitWord的文档引擎,扩展出企业共建版,JitOffice办公软件,JitCloud AI云服务生态。所以可能未来1-2年,我们还是会持续深耕在“知识内容传承”这个赛道。

第一步,架构重构:从"富文本"到"结构化数据"

在做 JitWord 之前,我们对 Office 文档做了大量的调研,从docx格式到文档的复杂操作,都意味着传统的富文本格式(html)难以驾驭复杂的文档场景。

我们也调研了很多开源方案,比如 tiptap,quill,editorjs等。最终我们选择了tiptap的一个早期稳定的版本,作为我们的底层文档组件,并对 tiptap 的架构做了上层的优化,已支持复杂的文档操作。

下面是我们第一版的文档设计架构:

图片

其实单纯实现类似 Office 的UI界面,难度不是很大,只需要花时间来开发,我相信每一个前端工程师都能实现,其难点在于:

  • 如何高效的做文档解析(需要对docx进行高精度格式还原)
  • 如何基于文档做高性能协同(支持多人协同编辑)
  • 如何在web文档处理复杂公式编辑和渲染
  • 实现文档的复杂操作(划词评论,版本对比,高级排版,分页视图等)
  • 实现文档的权限控制和高性能渲染(100w+字秒级渲染)

等等,每一个骨头都比较硬,基本上都是我们花大量实现自研,比如:

基于文档做高性能协同(支持多人协同编辑)

目前市面上其实有些协同方案,比如CRDT算法驱动的YJS,当然我们也是基于YJS实现的文档协同算法。但是单纯使用Yjs,只能实现基础协同,在协同过程中我们需要考虑很多复杂场景:

  1. 操作可交换性不同用户的操作可以以任意顺序执行,最终结果一致
  2. 操作可合并性多个操作可以智能合并,减少网络传输
  3. 最终一致性所有客户端最终会收敛到相同的文档状态
  4. 无需中央协调不依赖中央服务器进行冲突解决

下面是我们设计的协同操作流程:

图片

在协同编辑的性能上,我们也做了进一步算法优化,保证我们在普通服务器上(2核4G)也能支持10-50人的高效协同编辑,如果扩大服务器性能和集群,我们将有可能支持数千上万人的协同编辑。

下面是我们的文档协同和存储架构数据流:

图片

实现在web端处理复杂公式编辑和渲染

基本上市面上的开源方案都达不到我们对复杂公式的极致追求,大部分是让用户直接编辑latex,但是到了导出docx后,公式要么无法导出,要么导出后无法二次编辑。

基于上面的痛点,我们对docx文件做了数据结构的分析和算法层的兼容,同时对用户编辑公式的体验也做了进一步升级:

图片

我们提供了数学公式的编辑面板,我们可以实时编辑和预览公式,同时我们内置了100+高频的数学和科研公式,方便大家更快速的编写复杂公式。

下面是渲染到 JitWord 中的公式效果:

图片

同时,我们也能直接导出带复杂公式的文档,并在 word 中二次编辑。

文档的高效渲染(100w字秒级渲染)

图片

上面是我们文档中渲染了100w字的效果,目前测试下来还是可以编辑文档,只有略微的延迟。

这一结果归功于我们对文档性能的极致追求,在文档渲染层我们做了极致的优化,并全方面测试了渲染协同稳定性能:

图片

实现文档的复杂操作(划词评论,版本对比,高级排版,分页视图等)

任何一个富文本编辑器,都很难自带划词评论,版本对比,高级排版,分页视图这些高级操作功能。

我们研究这些功能花费了很多时间,也在每个月以2-3个版本的迭代速度更新着JitWord。

终于在2025年底实现了上面提到的这几个功能,下面我也给大家一一介绍一下。

  1. 划词评论

图片

当然大家可不要以为我们只是做了划词评论功能。我们在划词评论之后,还做了通信机制,在多人协同过程中可以实时通知给其他人,让协作者可以第一时间看到划词的内容,这个流程我们完全打通了。

  1. 版本管理功能

图片

我们的操作会定期存储,可以一键恢复,并支持版本的差异对比。这个功能基本上也是市面上web端文档独一份的,当然我们还在持续优化。

  1. 分页视图功能

图片

这个功能是最难坑的,不容置疑。但是我们花了2个月 all in 这个功能,终于实现了类似 word 的分页功能。它的难点在于分页之后内容的排版和位置自动计算机制,需要消耗大量的 js 性能,所以我们各种性能优化的方式都用上了,结合我们设计的独有的dom组织模式,终于实现了分页能力。

大家可以在 JitWord 共建版体验到分页的功能。

当然,我们还会支持页眉页脚功能,全面对标 Office。

第二步,AI协作引擎:让文档"活"起来

图片

上面是我们设计的 AI Native 驱动的模式架构,保证我们在文档编辑的全生命周期里,都能体验AI创作的乐趣。

下面是演示效果:

图片

我们除了直接让AI创作内容,还可以基于文本和段落,进行二次AI优化,比如文本润色,纠错,续写等:

图片

最近我们还迭代上线了AI公文助手,可以通过AI一键生成合同和公文:

图片图片

当然后续会实现更多和AI结合的场景,提高用户办公的效率。

第三步,Vue3的执念:为什么死磕Vue?

很多人问:文档编辑器为什么要强调Vue3?用React不是生态更好?

我们的回答是:响应式性能决定了AI协作的流畅度

技术细节:

  • Proxy-based响应式:10万字符文档的实时协作,传统Object.defineProperty会卡成PPT,Vue3的Proxy实现了毫秒级更新
  • Composition API:AI建议卡片、协作光标、公式渲染器……这些复杂组件的组合逻辑,用Composables管理比HOC清晰10倍
  • Tree-shaking友好:最终打包体积比同类React方案小40%,SaaS嵌入时客户感知明显

一个真实案例:

一个客户把我们的产品嵌入他们的CRM系统,原先用的React方案首屏加载3.2s,替换为 JitWord 后降到1.1s。客户CTO的原话:"你们这个Vue3版本,让我们的SaaS看起来像原生应用。 "

这就是"造轮子"的意义:不是为造而造,是为跑得更快。

并且国产化环境,很多客户都是更倾向用 Vue 技术栈,所以站在客户和用户体验的角度,我们Vue的策略是完全经得起考验的。

拒绝自嗨,持续打磨应用场景

技术人最容易犯的错:拿着锤子找钉子。

我们花了两个月时间,把产品扔到真实场景里验证,我们上线了我们开源SDK,让大家可以集成到自己的真实项目中来体验:

图片

目前有各行各业的客户给我们反馈了大量的建议,我们也在持续排期优化,下面分享一下我们内部的需求列表:

图片

目前已经有100多个我们评估后觉得非常有价值的功能点,在今年的一年里,我们会陆续上线。

当然大家有好的建议,也欢迎随时交流反馈~

github地址:github.com/MrXujiang/j…


开源与商业化思考

写到这里,必须回答那个最尖锐的问题:

你们最后要卖钱吗?还是说只是技术情怀?

我的答案是:部分开源,商业闭环。


为什么开源?

开源了基础的文档引擎SDK,包括:

  • 结构化文档模型
  • Vue3组件基础
  • 公式渲染模块
  • docx导入导出功能

目的不是做慈善,是建立标准。

如果 JitWord 的文档模型成为行业事实标准,第三方开发者自然会基于我们的格式开发插件。这等于用社区力量帮我们扩展生态——比招100个产品经理都有效


为什么保留商业版?

商业版包含:

  • AI协作引擎(调用大模型API,成本敏感)
  • 企业级协作功能(权限管理、审计日志)
  • SaaS嵌入解决方案

这不是"开源版阉割功能",是"开源版定义基础,商业版解决复杂问题"。

类比:Android开源,但GMS(Google服务)收费;MySQL开源,但企业版有高级监控。这是经过验证的商业模式。

一个创业者的坦白:

我们考虑过完全开源、靠服务变现。但过去一年和几十家企业聊过后,我发现B端客户真正付费的不是"软件",是"确定性" ——出了问题能找到人、能签SLA、能定制开发。这些只能靠商业团队支撑。

所以,造轮子不是目的。

让轮子跑得更快,让更多人用上更快的轮子,同时让造轮子的人能持续造下去——这才是目的。


关于作者:前大厂技术专家,现 JitWord 联合创始人。相信AI时代的生产力工具,应该由懂产品和技术的人重新做一遍。

我们团队均来自一线中大厂,资深工程师和技术专家,配合3个Agent,开启6人协作的创业之旅~

如果大家有好的建议,想法,欢迎留言反馈~

前端 AI Coding 落地指南(二)Rules篇

本篇是《前端 AI Coding 落地指南》的续作,专门讲 Rules 的实践:演进过程、设计原则、如何划分模块、何时写 Rules 何时写 Skills,以及项目级 Rules 的完整清单与每条 rule 的头部、介绍和通用化示例。首篇中已给出 Rules 的简要历程与清单,这里展开为可落地的细节。


Rules 是什么、解决什么问题

Rules 是写在项目里的持久化指导,用 Markdown 等形式约定项目的规范、约束和惯例。Agent 在「需要确认某类规范时」按需读取对应规则文件,从而在生成或审查代码时遵守「做什么、不做什么」——命名、目录、接口契约、样式变量、错误处理等有据可查。

作用:统一边界(减少模型随意发挥)、按需加载(控制上下文长度)、提高接纳率(生成结果更符合项目既有约定)。能解决的 AI Coding 问题:规范不一致(没有规则时 AI 易按通用习惯写,与项目脱节);约束缺失(如「接口必须放某目录」不写进规则就容易漏);上下文漂移(规则按场景拆分,需要时只加载相关文件,避免一次性塞入整本规范导致注意力分散)。


演进过程

一开始:整个项目一个庞大的 Rules
所有规范、步骤、示例都塞在一个或少数几个大文件里。带来的问题是:单次上下文过大(容易顶到上下文上限或挤占对话)、容易丢记忆/注意力分散(长文档里模型容易「看了后面忘前面」)、难以按场景聚焦(比如只想加个路由,却不得不带着 UI 验收、设计稿分析等无关内容)。结果是步骤漏、检查项漏,接纳率反而不稳定。

中期:按模块拆成多个 Rules
把「项目概述、编码、结构、组件、API、路由、状态、通用约束、样式、文档、测试」等拆成 01~11 这样的独立文件,需要哪类规范就读哪几个。按需加载效果好很多,上下文压力下来,也便于维护。但还有一个问题没解决:「如何落地」的步骤和示例仍然写在 Rules 里——例如「怎么加一个路由」的详细步骤、目录示例、检查清单,和「路由必须和菜单一致」这类约束混在一起,单文件仍然偏长,且步骤化、可复用工作流不好跨项目复用。

最近:Skills 盛行,Rules 简化 + 实施指导迁到 Skills
引入 Skills 之后,做了两件事:Rules 只保留「原则与约束」(做什么、不做什么、目录/命名/契约),把「如何一步步做、怎么写示例、产出长什么样」迁到 Skills,并利用 Skills 的渐进式披露(只在执行该任务时加载对应 SKILL 和技能内 rules)。这样 Rules 变薄、按需读;具体落地在 Skills 里按场景加载,既避免上下文爆炸,又避免模型在长文档里「抓不住重点」。Skills 的详细实践、何时封装、技能下 rules 如何拆分见《前端 AI Coding 落地指南(三)Skills 篇》。


设计原则:哪些写 Rules、哪些写 Skills

原则概括Rules 回答「是什么 / 不能是什么」,Skills 回答「怎么做 / 先做什么再做什么」。

模块划分:按「开发时会被问到的决策类型」来拆,而不是按代码目录拆。例如:项目背景与技术栈(项目概述)、命名与风格(编码规范)、文件放哪(项目结构)、组件怎么拆(组件规范)、接口怎么定义和调用(API 规范)、路由和菜单怎么对(路由规范)、状态放哪怎么用(状态管理)、全局约定(通用约束)、样式和主题(样式规范)、注释与测试(文档/测试规范)。这样 Agent 在「要做一个什么类型的决策」时,能对应到某一个或少数几个规则文件。

什么留在 Rules:原则、约束、契约、目录与命名约定。例如「接口必须放在某目录下」「路由配置必须和菜单配置一致」「错误必须用统一方式处理」「主题色必须走 CSS 变量」——这些是「是/否」判断,不涉及长步骤和大量示例,适合放在 Rules,篇幅可控。

什么放到 Skills:多步骤流程、检查清单、产出模板、示例代码。例如「创建提案的 5 步」「设计稿分析的 4 步」「加一个路由要创建哪几个文件、每步检查什么」「UI 分析清单/问题清单长什么样」——这些一旦写进 Rules 会让单文件很长,且和「原则」混在一起,不利于按场景加载;放到 Skills 里,用 SKILL.md + 技能内 rules 做渐进披露更合适。

判断方式:若一句话能说清且不需要示例,放 Rules;若需要「第一步、第二步、检查点、模板、示例」,放 Skills。


项目级 Rules 清单(目录结构)

.agents/rules/
├── 01-项目概述.md
├── 02-编码规范.md
├── 03-项目结构.md
├── 04-组件规范.md
├── 05-API规范.md
├── 06-路由规范.md
├── 07-状态管理.md
├── 08-通用约束.md
├── 09-样式规范.md
├── 10-文档规范.md
├── 11-测试规范.md
└── README.md

由于篇幅问题,以下每条 rule 示例是简化后的,主要作为参考,后续会把完整的代码放到 github 上。


01-项目概述

---
alwaysApply: false
description: 项目定位与技术栈概览。当需要了解项目背景、使用的技术栈时读取此规则。
---

# 项目概述

## 项目定位
一段话说明项目是什么、面向谁、核心能力。

## 技术栈
| 领域 | 技术 | 说明 |
|------|------|------|
| UI 框架 | React x.x | 强制使用 |
| 类型系统 | TypeScript x.x | 强制使用,禁止 JavaScript |
| 状态管理 | Zustand / Redux 等 | 统一使用,禁止其他方案 |
| 组件库 | Ant Design / 其他 | 交互 UI 基于此二次封装 |
| 样式方案 | SCSS Modules | 强制使用,禁止全局样式 |

02-编码规范

---
alwaysApply: false
description: 编码规范,包括 TypeScript 使用、命名约定(变量、常量、接口、组件等)、业务函数命名。编写或审查代码时读取。
---

# 编码规范

## TypeScript 规范
- 所有代码必须使用 TypeScript,禁止使用 JavaScript
- 禁止使用 `any`(除非有明确理由并注释)
- 接口、类型使用 PascalCase;组件 Props 必须定义清晰接口

## 命名规范
| 类型 | 规则 | 示例 |
|------|------|------|
| 文件夹/路由 | kebab-case | user-profile |
| 变量/函数 | camelCase | userList, onSubmit |
| 常量 | UPPER_CASE | API_TIMEOUT |
| 接口/类 | PascalCase | UserInfo |
| React 组件 | PascalCase | UserProfile |
| 自定义 Hooks | use 开头 | useUserStore |

## 业务函数命名
- 事件处理:onXxx(如 onSubmit)
- 内部处理:handleXxx(如 handleDelete)

03-项目结构

---
alwaysApply: false
description: 目录结构规范,各目录用途与约束(禁止新建非标准目录)。确定代码放哪时读取。
---

# 项目结构(NON-NEGOTIABLE)

## 目录结构
src/
├── assets/        # 静态资源
├── components/    # 可复用 UI 组件
├── constants/     # 业务常量、枚举
├── hooks/         # 自定义 Hook
├── http/          # 请求封装
├── interfaces/    # 类型声明(model、api)
├── routes/        # 路由页面
├── stores/        # 全局状态,按业务拆分
├── utils/         # 工具函数
└── 根级入口文件

## 结构约束
- 常量 → constants/;状态 → stores/;接口类型 → interfaces/(每模块含 model.ts、api.ts)
- 组件 → components/ 或 routes/<name>/components/;路由 → routes/,每路由含 Page、Loader、index.module.scss

04-组件规范

---
alwaysApply: false
description: 组件目录结构、样式方案、通用 vs 页面级。创建或拆分组件时读取。
---

# 组件规范

## 组件结构
- 组件在 src/components 或路由下 components,每组件单独目录
- 文件:index.tsx、index.module.scss;必须 SCSS Modules,禁止全局样式
- Props 接口必须明确;使用 classnames 管理动态 class

## 组件层级
- 页面级:仅单页使用 → routes/<page>/components/
- 通用:跨页面复用 → src/components/
- 单文件建议不超过 400 行(拆分参考)

05-API规范

---
alwaysApply: false
description: 接口请求封装、函数命名、错误处理。新增或修改接口时读取。
---

# API 规范

## 接口请求规范
- 请求使用 src/http 下封装;类型 Params/Body/Response 放在 src/interfaces/<module>/api.ts
- 所有接口集中在 src/http/<module>.ts

## 接口函数命名(NON-NEGOTIABLE)
| 操作 | 命名规则 | 示例 |
|------|----------|------|
| 获取列表 | getXxxList | getBannerList |
| 获取详情 | getXxxDetail | getBannerDetail |
| 创建/更新/删除 | createXxx / updateXxx / deleteXxx | 禁止 fetch 前缀 |

## 错误处理(NON-NEGOTIABLE)
- 接口错误由拦截器统一处理,业务代码只处理成功逻辑;禁止重复 message.error

06-路由规范

---
alwaysApply: false
description: 路由目录结构、Page/Loader 模式、路由配置集中管理。新增页面或配置路由时读取。
---

# 路由规范(NON-NEGOTIABLE)

## 路由结构
- 路由目录在 src/routes 下,每页单独目录,kebab-case
- 必须包含:Page.tsx、Loader.tsx、index.module.scss
- Loader 只负责懒加载对应 Page,不嵌套 Routes/Route

## 路由与菜单
- 禁止多处维护路由:全局唯一路由配置集中管理
- 同一页面只在一处被 lazy 引入

07-状态管理

---
alwaysApply: false
description: 全局状态使用 Zustand(或项目约定方案)。新增或重构状态时读取。
---

# 状态管理规范(NON-NEGOTIABLE)

## 基础规范
- 必须使用项目约定的状态库(如 Zustand),所有 store 放在 src/stores
- 禁止在 src 下新建其他目录存放 store

## Store 组织
- 按业务模块划分,每模块单独文件;暴露 useXxxStore Hook
- Store 只存数据与业务行为,不耦合 UI 组件
- 需持久化时使用 persist 等中间件

08-通用约束

---
alwaysApply: false
description: 语言、可观测性、占位元素等通用约束。确认通用约束时读取。
---

# 通用约束

## 语言规范(NON-NEGOTIABLE)
- 文档和代码注释使用中文;变量/函数命名仍用英文

## 可观测性与兜底
- 任务需完整链路:任务 ID、引用、日志;失败时兜底提示与重试

## 占位元素
- 图标/图片未定时:用占位 div + TODO 注释,禁止临时 svg/占位图服务;明确标记替换点

09-样式规范

---
alwaysApply: false
description: SCSS Modules、主题 CSS 变量。编写样式或主题适配时读取。
---

# 样式规范

## 基础原则
- 样式采用 SCSS Modules(index.module.scss);classnames 管理动态 class
- 自定义样式必须使用主题色 CSS 变量,禁止硬编码颜色

## 主题变量(NON-NEGOTIABLE)
- 主色/文本/背景/边框等使用 var(--ant-color-xxx) 或项目自定义 var(--xxx)
- 确保主题切换(浅色/暗色)一致

10-文档规范

---
alwaysApply: false
description: JSDoc 与注释规范。编写或审查注释时读取。
---

# 文档规范

## 注释规范
- 使用 JSDoc(/** */),解释「为什么」而非「是什么」
- 文件头、函数/方法、复杂逻辑、类型定义均需必要说明

11-测试规范

---
alwaysApply: false
description: 测试覆盖与质量门禁。确认测试要求时读取。
---

# 测试规范

## 测试要求
- 新增功能需必要类型定义,关键业务逻辑需测试用例
- 所有代码通过 TypeScript 与 ESLint 检查

以上就是项目级 Rules 的完整清单与通用化示例。

前端 AI Coding 落地指南(一)架构篇

你是不是也遇到过这些情况:AI 生成的代码风格和现有项目不一致;项目结构不符合预期,设计稿要么看不懂、要么分析漏项,页面 UI 还原度低;改来改去返工多,需求越走越偏,最后还得自己重写一版?

我在实际项目里经过多次迭代的实践,落地了一套

以规范、能力扩展和流程为主线的 AI Coding 架构

把「风格不一致、步骤漏、设计稿不会分析/验收、提案任务格式乱」都收进可控范围。落地之后最直观的效果是:

前端页面可以做到 90% 还原设计稿,需求实现更准确,代码风格一致,结构设计合理


架构简介

架构运用了多种现下流行的 Agent 通用能力:

架构组成 说明
SDD 开发流程控制,保证需求不遗漏,开发不脱节,可进行需求分析/开发规划/验收测试/复盘归档
MCP 对接外部能力,如设计稿读取、浏览器访问页面查看实际效果、接口文档
Rules 约定「做什么、不做什么」,如保证 AI 遵循项目结构,规范约束,代码风格
Skills 约定「怎么做」,渐进披露按需指导 AI 怎么做,如怎么写UI,怎么验收UI

Rules 管「怎么才算对」,Skills 管「怎么一步步做」,MCP 让 Agent 能看到真实世界,而 SDD 负责记录「这次为什么要改、应该改成什么样」并把每次变更沉淀成新的真相,四者合在一起,才是真正稳定可演进的 AI Coding 底座。

从需求到归档的完整链路如下:

flowchart LR
    A[需求输入] --> B{有设计稿?}
    B -->|是| C[设计稿 MCP 读稿]
    C --> D[design-analysis 产出 UI 分析清单]
    B -->|否| E[create-proposal + SDD]
    D --> E
    E --> F[proposal / tasks / spec 增量 校验]
    F --> G[按 tasks 实施]
    G --> H[Rules + create-route/component/theme 页面开发]
    H --> I{有实现页?}
    I -->|是| J[Browser MCP 打开页面]
    J --> K[ui-verification 比对设计稿/清单]
    K --> L[产出 UI 问题清单 修复再验]
    L --> M[create-api/store + 接口/Context7 业务联调]
    G --> M
    M --> N[SDD 归档 更新 specs]
  1. 需求输入:明确是否有设计稿、是否有接口、交付形态(页面/组件/其它)。
  2. 设计稿分析(有稿时):用 Pencil(或 Figma)MCP 读稿 → 执行 design-analysis 技能 → 产出 UI 分析清单。
  3. 创建提案:执行 create-proposal,产出 SDD 的 proposal、tasks、spec 增量;若有设计稿,在 tasks 中写明依据 UI 分析清单实现、实现后用 ui-verification 验收;validate 通过后进入实施。
  4. 页面/UI 开发:按 tasks 顺序做;读 Rules(项目结构、组件、路由、样式等)+ Skills(create-route、create-component、theme-variables);依据 UI 分析清单还原布局与样式。
  5. UI 验收:用 Cursor IDE Browser(或 Playwright)打开实现页,执行 ui-verification,与设计稿或分析清单比对,按 P0/P1/P2 产出问题清单;修复后再次用 Browser 验证。
  6. 业务开发与联调:Rules(API、状态、通用约束)+ create-api、create-store;接口文档 MCP、Context7 按需使用。
  7. 归档:tasks 全部勾选后执行 SDD archive,更新 specs,变更挪入 archive。

主流 Agent 通用

本套架构方案,适用所有支持 skills/mcp 的 Agent,且 只维护一份,所有 Agent 共用

首先在你的项目根目录创建 .agents,下面放 rulesskills,然后根据你所使用的 Agent 或多个 Agent 做如下配置:

  • Cursor:在项目根创建 .cursor 目录,其下的 rulesskills软链接 指到 .agents/rules.agents/skills
  • Claude:同理,创建 .claude,将 rulesskills 软链到 .agents 下同名目录。
  • OpenCode:创建 .opencode,同理软链。
  • Gemini:创建 .agent,同理软链。
  • Trae:创建 .trae,同理软链。

这样只需维护 .agents 一份,即可同时支持上述所有 Agent。

目录结构示例(仅展示与本文相关的部分):

项目根/
├── .agents/                    # 唯一维护的规范与技能目录
│   ├── rules/                  # 规则
│   └── skills/                 # 技能
│
├── .cursor/                    # Cursor:内部 rules、skills 软链到 .agents
├── .claude/                    # Claude:同上
├── .opencode/                  # OpenCode:同上
├── .agent/                     # Gemini:同上
└── .trae/                      # Trae:同上

SDD

SDD 是什么
Specification-Driven Development,用「规范/spec」来驱动开发:

  • 先写 spec 再写代码:任何需求或变更,先写清楚需求,「现在是怎样 → 期望变成怎样」,再开干;
  • 变更可追踪:变更以「提案 + 任务 + spec 增量」管理,实施完归档,可回溯是谁、什么时候、为什么改了什么;
  • 验收有据可依:验收按 spec 来,而不是「感觉差不多」。

SDD 的选择

Spec-kit 和 OpenSpec 是两个比较热门的 SDD 工具,我在尝试过两个工具后,最终选择使用 openspec 因为他比 speckit 简单很多,speckit 的流程更严谨,也更繁琐,其复杂度适合从 0 到 1 规划一个新项目。

OpenSpec

通过 openspec init 初始化项目后,你就会得到如下的一个典型的 OpenSpec 目录:

openspec/
├── project.md          # 项目约定与 AI 使用说明
├── specs/              # 当前「真相」:已经生效的能力与约束
└── changes/            # 进行中的变更:proposal、tasks、design、spec 增量
    ├── <change-id>/
    │   ├── proposal.md
    │   ├── tasks.md
    │   ├── design.md      # 可选:草图/交互/边界情况
    │   └── spec-delta.md  # 本次变更对 specs 的增量
    └── ...

首先你需要编写 project.md,将你项目的规范告诉 openspec,以此作为规范进行推动后续的工作,这是我项目的 project.md,起初也是一个庞大的文件,现在已经拆分到 rules 和 skills 了(后面会介绍):

# 项目上下文

## 项目概述

当前项目是一个基于 React + TypeScript 的单页应用(SPA)。

## 技能与规范

本项目定义了两层指导体系,统一存放在 `.agents/` 目录下。

### `.agents/rules/` - 开发规范

规范文件包含项目开发的核心规则,不会自动加载。当需要确认规范时,主动读取对应文件:

| 文件             | 何时读取                   |
| ---------------- | -------------------------- |
| `01-项目概述.md` | 需要了解项目背景、技术栈时 |
| `02-编码规范.md` | 编写或审查代码时           |
| `03-项目结构.md` | 确定代码应放在哪个目录时   |
| `04-组件规范.md` | 创建或拆分组件时           |
| `05-API规范.md`  | 新增或修改接口时           |
| `06-路由规范.md` | 新增页面或配置路由时       |
| `07-状态管理.md` | 新增或重构状态管理时       |
| `08-通用约束.md` | 确认通用约束时             |
| `09-样式规范.md` | 编写组件样式或主题适配时   |
| `10-文档规范.md` | 编写或审查代码注释时       |
| `11-测试规范.md` | 确认测试要求时             |

### `.agents/skills/` - 实践技能

技能文件包含具体落地步骤与示例代码,按需读取:

| 场景              | 技能文件                                   |
| ----------------- | ------------------------------------------ |
| 创建提案时        | `.agents/skills/create-proposal/SKILL.md`  |
| 新增接口          | `.agents/skills/create-api/SKILL.md`       |
| 创建/拆分组件     | `.agents/skills/create-component/SKILL.md` |
| 新增页面路由      | `.agents/skills/create-route/SKILL.md`     |
| 新增全局状态      | `.agents/skills/create-store/SKILL.md`     |
| 编写样式/主题适配 | `.agents/skills/theme-variables/SKILL.md`  |

技能索引文件:`.agents/skills/README.md`

配置了规范后,就可以使用 openspec 的指令进行需求的实现,先是 openspec proposal 创建提案,Agent 会根据你的描述,生成详细的需求文档,规划开发任务,待你确认。

开发任务确认后,再执行 openspec apply 开始实施任务,任务完成 openspec archive 对本次需求归档,这会作为后续新需求的一个参考,让 Agent 对项目的现状更了解。

OpenSpec 在这套架构里的作用
结合前面讲的 Rules / Skills / MCP,这里的 OpenSpec 主要在三个环节发力:

  • 需求 / 变更收敛:不管输入来自 PRD、口头、IM 还是设计稿,先用 OpenSpec 写一条变更(proposal + tasks + spec 增量),把「要改什么、影响到哪里」说清楚,避免 AI 直接在代码上「盲改」;
  • 开发执行对齐:开发时,Agent 读取对应变更的 proposal / tasks / spec 增量,再结合 Rules(项目级约束)、Skills(如 create-route / create-api / design-analysis)和 MCP(设计稿 / 浏览器 / 接口文档),按 tasks 一项项完成,实现过程始终围绕同一份 spec 展开;
  • 验收与归档:UI 验收阶段,ui-verification 会同时参考 UI 分析清单(design-analysis 产物)和 OpenSpec 中的 spec 增量,验证是否满足这次变更的业务与交互预期;验收通过后,将变更移动到 archive,并把 spec 增量合到 specs,下次再改同一块能力时,所有上下文都在这里。

MCP

MCP 是什么:Model Context Protocol,模型通用的上下文协议,让模型能安全、结构化地调用外部能力。设计稿、浏览器、接口文档等通过 MCP 暴露成工具,Agent 按需调用,拿到真实数据或执行真实操作。

这套架构中用到如下的 MCP:

MCP 用途 环节 说明
Pencil .pen 设计稿结构、布局、节点、截图 设计稿分析 设计稿分析首选,效果优于 Figma MCP
Figma Figma 链接的截图与设计上下文 设计稿分析(Figma 稿时) 有 .pen 时优先 Pencil
Cursor IDE Browser 打开页面、快照、截图 UI 验收 Cursor 内优先,效果优于 Playwright
Playwright 页面打开、截图、自动化 UI 验收(非 Cursor 或脚本化) 可作为 Browser 的补充
ApiFox OAS 等接口定义 业务开发/联调 按当前文档生成/校验请求
Context7 主流库最新文档与示例注入 页面/业务开发 避免过时 API、幻觉

设计稿类

  • Pencil MCP:面向 .pen 设计稿。能读层级、节点、布局、截图(如 snapshot_layoutget_screenshotbatch_get 等),把设计稿里的元素尺寸、间距、字体、颜色直接触达 Agent。在「设计稿分析」环节作为主数据源,效果明显好于 Figma MCP——层级与细节更完整,分析清单更准,因此有 .pen 稿时优先用 Pencil。可以直接拷贝 Figma 粘贴到 Pencil 设计稿。
  • Figma MCP:面向 Figma 链接。可解析 file key / node id、取截图与设计上下文,适合设计稿在 Figma 的场景,但 Figma 可复制到 Pencil ,95%+ 样式复制后还原,个别暂不支持的例如 Pencil 没有虚线边框。

浏览器类

  • Cursor IDE Browser:在 Cursor 内打开目标页、拉快照、截图、简单交互。用于 UI 验收时「真实页面 vs 设计稿」比对。在 Cursor 里做 UI 验收时,优先用 Cursor IDE Browser,效果比 Playwright MCP 更好(集成度、稳定性、截图比对流程更顺)。
  • Playwright MCP:同样可打开页面、截图、操作,适合不在 Cursor 或需要脚本化验收时使用。

接口与文档类

  • 接口文档 MCP(如 ApiFox):读/刷新项目 OAS 等,在业务开发/联调时按当前文档生成或校验请求与类型,避免接口变更后模型用旧假设。
  • Context7:按需注入当前版本的库文档与代码示例,在页面/业务开发时减少依赖过时、模型幻觉 API 的问题。

Rules

Rules 是什么:写在项目里的持久化指导,约定规范、约束和惯例(项目概述、编码、结构、组件、API、路由、状态、样式、文档、测试等),让 Agent 遵守「做什么、不做什么」。

实践中的演进历程

  • 最初是一整个大 Rule.md,上下文过大、易丢记忆、难按场景聚焦;
  • 后来按模块拆成多份(如 01~11),按需读;
  • Skills 加入后,Rules 只需要保留原则与约束,把步骤、检查点、模板、示例迁到 Skills,实现渐进式披露。

项目级 Rules 清单

.agents/rules/
├── 01-项目概述.md
├── 02-编码规范.md
├── 03-项目结构.md
├── 04-组件规范.md
├── 05-API规范.md
├── 06-路由规范.md
├── 07-状态管理.md
├── 08-通用约束.md
├── 09-样式规范.md
├── 10-文档规范.md
├── 11-测试规范.md
└── README.md

Rules 目录下的 README 清晰的介绍了各个 rule 的作用和使用时机

# 项目规范索引

本目录包含前端项目的开发规范,按模块分类组织,便于快速查找。

## 规范模块列表

### 📋 [01-项目概述](./01-项目概述.md)

- 项目定位
- 技术栈

### 💻 [02-编码规范](./02-编码规范.md)

- TypeScript 规范
- 命名规范(文件夹、变量、常量、接口、组件等)
- 业务函数命名(事件处理、内部处理)

### 📁 [03-项目结构](./03-项目结构.md)

- 目录结构概览
- 各目录使用约束(禁止新建非标准目录)

### 🧩 [04-组件规范](./04-组件规范.md)

- 组件结构规范
- 组件层级规划(通用 vs 页面级)

### 🌐 [05-API规范](./05-API规范.md)

- 接口请求规范
- 接口函数命名(NON-NEGOTIABLE)
- 接口错误处理(NON-NEGOTIABLE)

### 🛣️ [06-路由规范](./06-路由规范.md)

- 路由结构规范
- 路由与菜单规范(NON-NEGOTIABLE)

### 🗄️ [07-状态管理](./07-状态管理.md)

- Zustand 使用规范
- Store 组织规范
- 持久化策略

### ⚙️ [08-通用约束](./08-通用约束.md)

- 语言规范(中文注释)
- 可观测性与兜底
- 约束汇总

### 🎨 [09-样式规范](./09-样式规范.md)

- SCSS Modules 使用规范
- 主题色 CSS 变量(NON-NEGOTIABLE)

### 📝 [10-文档规范](./10-文档规范.md)

- 注释规范
- JSDoc 使用

### ✅ [11-测试规范](./11-测试规范.md)

- 测试覆盖要求
- 质量门禁

## 使用说明

1. 根据需要的规范类型,点击对应的模块链接查看详细内容
2. 所有规范文件都包含 `alwaysApply: true` 标记,确保规范自动应用
3. 详细示例与落地步骤见 `.agents/skills/` 目录下的技能文件

## 快速查找

| 需求 | 规范文件 |
|------|----------|
| 项目背景是什么?使用哪些技术栈? | 01-项目概述 |
| 如何命名函数/变量? | 02-编码规范 |
| 代码放在哪个目录? | 03-项目结构 |
| 如何创建/拆分组件? | 04-组件规范 |
| 如何调用接口? | 05-API规范 |
| 如何配置路由? | 06-路由规范 |
| 如何使用 Zustand? | 07-状态管理 |
| 有哪些通用约束? | 08-通用约束 |
| 如何使用主题变量? | 09-样式规范 |
| 如何写注释? | 10-文档规范 |
| 有何测试要求? | 11-测试规范 |

更详细的 rules 落地示例见:《前端 AI Coding 落地指南(二)Rules 篇》


Skills

Skills 是什么:用 Markdown 写的技能文件,教会 Agent 完成某一类任务。一个技能一个目录,必有 SKILL.md(YAML 头 + 步骤 + 示例),可带子规则。

Skills 有什么用:按场景加载、渐进披露,例如在 Rules 划好边界的前提下给「怎么做」的步骤与示例拆分成 skills,避免大量 rules 导致上下文庞大,模型无法集中,出现幻觉和记忆丢失问题。

开源 Skills

有许多开源的优质 skills,直接安装到你的项目中大有裨益,在我的 React 项目中就用到了以下开源 skills

技能 简介 使用环节
vercel-react-best-practices React 代码编写的最佳实践 页面/业务开发时按需引用
vercel-composition-patterns 复合组件、状态提升、避免 boolean props 等 设计组件 API、组合与状态时
web-design-guidelines 通用 UI/UX 设计指南 UI 分析、实现时参考
find-skills 查找并选用可用技能 需要发现技能时
skill-creator 创建新技能的结构与规范 扩展能力、新增 Skills 时

自封装 Skills

除了开源 skills,你还可以自己封装 skills,当有固定步骤流程且会反复做的任务时,就可以封装成 Skill。在我的项目中就有以下两类自封装的 skill:

  1. 从 Rules「怎么做」拆出来的(create-route、create-component、create-api、create-store、theme-variables);

  2. 可复用的流程,给予 Agent 指导(create-proposal、design-analysis、ui-verification)。

像 design-analysis、ui-verification 这种内容多的技能,还可以再把细分工具能力等拆到技能下的 rules 子目录里,充分发挥渐进披露的优势,SKILL 只做入口说明。

.agents/skills/
├── create-proposal/     # 创建提案(SDD proposal、tasks、spec 增量)
│   └── SKILL.md
├── design-analysis/     # 设计稿分析 → UI 分析清单
│   ├── SKILL.md
│   └── rules/          # 分析顺序、四类重点、工作流、输出模板、工具等
├── ui-verification/    # UI 验收 → 问题清单
│   ├── SKILL.md
│   └── rules/          # 比对维度、常见错误、工作流、工具等
├── create-route/       # 新增路由、Page、Loader
├── create-api/         # 新增接口、类型、请求封装
├── create-store/       # 新增 Zustand store
├── create-component/   # 新增/拆分组件
├── theme-variables/    # 主题 CSS 变量使用
└── README.md

skills 在 AI Coding 过程中发挥的作用

  • 创建 SDD 提案时同时使用 create-proposal;
  • 有设计稿时用 design-analysis 产出 UI 分析清单;
  • 页面/UI 开发时用 create-route、create-component、theme-variables;
  • 实现完成后用 ui-verification 做还原验收;
  • 业务开发/联调时用 create-api、create-store。
  • 其他开源第三方 skills 在写代码、做架构决策时按需引用。

skills 目录下的 README

---
name: huayiyi-skills-index
description: 前端项目的技能索引,帮助 Agent 在具体开发场景下选择合适的技能文件。
---

# 画衣衣项目技能索引

项目在 `.agents/skills` 下定义了一些与 RULE 配套的技能,用于承载**具体实践步骤与示例代码**,避免在 RULE 中塞入过多细节。

## 当前技能列表

- `create-api`:创建与维护 HTTP 接口(配合 `05-API规范` 使用)
- `create-component`:创建与拆分通用组件/页面级组件(配合 `03/04` 使用)
- `create-route`:创建与维护路由目录与 Loader/Page(配合 `06-路由规范` 使用)
- `create-store`:使用 Zustand 创建与维护全局状态(配合 `07-状态管理` 使用)
- `theme-variables`:正确使用 Antd 与自定义主题 CSS 变量(配合 `09-样式规范` 使用)

后续如有新的实践场景(例如:测试用例编写、文档撰写模板等),也建议以新的技能目录形式补充到本目录中。

更详细的 Skills 落地示例见:《前端 AI Coding 落地指南(三)Skills 篇》


总结

当下的局限性:要高接纳率、少返工,需要较好的模型与算力;在无限额度、高配模型下稳定性才有保障,成本不低。配合 git worktree + 多 Agent 多任务并行,不计成本时接近 100% AI Coding 在技术上可行,成本和流程需按团队权衡。

AI Coding 展望:模型会更聪明,上下文管理会更智能,交互会更简洁。在这套 MCP + Rules + Skills + SDD 的底座上,只靠 AI Coding 完成日常前端开发不会太远;先跑通「需求 → 提案 → 分析 → 开发 → 验收」闭环,再逐步扩展,接纳率和效率都会有可见提升。

当 AI 学会了写博客:Cursor AI Skill 如何让你的开发效率翻倍

前言

你有没有想过,写完代码之后,AI 自动帮你把技术博客也写了?

这不是幻想。我在自己的博客系统 Ink & Codeptclove.com)上实现了这个能力——通过 Cursor 的 AI Skill 机制,只需一句话,AI 就能分析代码、撰写文章、一键发布。

本文会深入介绍 AI Skill 的实际应用,以及支撑这一切的技术架构:Next.js 16 + Prisma + Tailwind CSS 4 + Tiptap

什么是 Cursor AI Skill

AI Skill 是 Cursor IDE 提供的一种扩展机制,本质上是一份 Markdown 格式的指令文件(SKILL.md),告诉 AI 在特定场景下该做什么、怎么做。

与普通的 Prompt 不同,Skill 有几个核心优势:

  1. 场景触发:当用户提到"写博客"、"发布文章"时自动激活
  2. 结构化流程:定义清晰的多步骤工作流,而不是一次性提问
  3. 工具集成:可以调用 Shell 脚本、API 接口,实现端到端的自动化

实战:一个自动写博客的 Skill

我在项目中创建了 .cursor/skills/generate-blog/SKILL.md,定义了博客生成的完整流程:

---
name: generate-blog
description: 生成技术博客并发布到 Ink & Code
---

# 生成博客文章

## 工作流程

### 1. 确定生成模式
- commit 模式:根据 Git 提交改动生成
- topic 模式:根据特定主题生成
- repo 模式:介绍整个项目

### 2. 收集上下文
根据模式收集相关代码上下文...

### 3. 生成博客内容
撰写高质量技术博客,包含背景、方案、实现、总结...

### 4. 发布文章
使用脚本发布到博客系统:

关键在于第 4 步——Skill 不仅能生成内容,还能通过 Shell 脚本直接发布:

# publish.sh - 一键发布到博客
TITLE="$1"
TAGS="$2"

# 读取内容(支持文件、stdin、剪贴板)
if [ -n "$CONTENT_FILE" ] && [ -f "$CONTENT_FILE" ]; then
    CONTENT=$(cat "$CONTENT_FILE")
elif [ ! -t 0 ]; then
    CONTENT=$(cat)
else
    CONTENT=$(pbpaste 2>/dev/null || echo "")
fi

# 构建 JSON 并调用 API
jq -n --arg title "$TITLE" --arg content "$CONTENT" \
  '{title: $title, content: $content, published: false}' \
  > /tmp/blog_payload.json

curl -X POST "${INK_AND_CODE_URL}/api/article/create-from-commit" \
  -H "Authorization: Bearer $INK_AND_CODE_TOKEN" \
  -d @/tmp/blog_payload.json

这意味着从代码变更到文章发布,整个链路都是自动化的。

更进一步:GitHub Actions + AI 自动发文

除了本地 Skill,我还配置了 GitHub Actions 实现 CI/CD 级别的博客自动化:

# .github/workflows/auto-blog.yml
on:
  push:
    branches: [main]

jobs:
  generate-blog:
    # 只有提交信息包含 [blog] 时才触发
    if: contains(github.event.head_commit.message, '[blog]')
    steps:
      - name: Gather project context
        run: |
          git diff HEAD~1 HEAD > /tmp/commit_diff.txt
          # 收集项目结构、改动文件、配置文件...

      - name: Generate and publish
        run: |
          # 将上下文发送给 AI,生成博客
          # 解析标题、内容、标签
          # 调用 API 发布

工作原理:当我提交代码时,在 commit message 中加上 [blog] 标记,GitHub Actions 会自动收集项目上下文(diff、文件结构、配置文件),调用 DeepSeek/Claude/GPT 生成技术博客,最后通过 API 发布到网站。

整个过程无需人工干预,写完代码,博客就自动出现在网站上了。

支撑一切的技术架构

这套自动化能跑通,离不开底层的技术架构。Ink & Code 采用的是 Next.js 16 + Prisma + Tailwind CSS 4 + Tiptap 的组合。

Next.js 16 App Router

项目使用 Next.js 16 的 App Router 架构,目录结构清晰:

app/
├── api/          # API 路由
│   ├── article/  # 文章 CRUD
│   ├── chat/     # AI 聊天
│   └── auth/     # 认证
├── admin/        # 后台管理
├── blog/         # 博客页面
├── components/   # 组件
└── u/[username]/ # 用户主页

API 路由提供了完整的 RESTful 接口,支持两种认证方式:

// Session 认证(用户登录)
const session = await auth();

// Token 认证(外部调用,如 GitHub Actions)
const token = request.headers
  .get('Authorization')?.replace('Bearer ', '');
const hashedToken = hashToken(token);
const apiToken = await prisma.apiToken.findUnique({
  where: { tokenHash: hashedToken }
});

这种双认证机制让博客系统既支持浏览器登录,又支持脚本和 CI/CD 的外部调用。

Prisma 数据建模

数据层使用 Prisma ORM,核心模型设计:

model Post {
  id         String    @id @default(cuid())
  title      String
  slug       String
  content    String    @db.Text  // TipTap JSON
  excerpt    String?
  coverImage String?
  published  Boolean   @default(false)
  tags       String[]
  sortOrder  Int       @default(0)

  user       User      @relation(fields: [userId])
  category   Category? @relation(fields: [categoryId])

  @@unique([userId, slug])
}

model Category {
  id       String     @id @default(cuid())
  name     String
  slug     String
  icon     String?
  parentId String?    // 树形结构
  parent   Category?  @relation("children", fields: [parentId])
  children Category[] @relation("children")

  @@unique([userId, slug])
}

文章内容以 TipTap JSON 格式存储,而非原始 Markdown。这意味着从外部(如 GitHub Actions)提交的 Markdown 内容需要经过转换:

// lib/markdown-to-tiptap.ts
export function markdownToTiptap(markdown: string): string {
  const doc: TiptapDoc = { type: 'doc', content: [] };
  const lines = markdown.split('\n');

  while (i < lines.length) {
    const line = lines[i];
    if (line.startsWith('```')) {
      // 代码块 → codeBlock 节点
    } else if (line.match(/^#{1,6}\s/)) {
      // 标题 → heading 节点
    } else if (line.match(/^\d+.\s/)) {
      // 有序列表 → orderedList 节点
    }
    // ...更多格式处理
  }
  return JSON.stringify(doc);
}

Tiptap 富文本编辑器

后台管理使用 Tiptap 作为编辑器,支持丰富的内容格式:

const editor = useEditor({
  extensions: [
    StarterKit,
    CodeBlockLowlight.configure({
      lowlight  // 代码高亮
    }),
    Image,           // 图片
    Link,            // 链接
    Table,           // 表格
    Placeholder.configure({
      placeholder: '开始写作...'
    }),
  ],
});

编辑器还实现了自动保存、快捷键(Cmd+S)、图片上传(阿里云 OSS)等实用功能。

Tailwind CSS 4

样式层使用最新的 Tailwind CSS 4,通过 CSS 变量实现主题切换:

:root {
  --color-primary: #b8860b;
  --color-background: #faf8f5;
  --color-foreground: #1a1a1a;
}

[data-theme='dark'] {
  --color-primary: #d4a537;
  --color-background: #1a1a1a;
  --color-foreground: #e8e8e8;
}

配合 ThemeProvider 组件,实现了丝滑的明暗主题切换。

Ink & Code 核心功能一览

除了上面提到的技术架构,ptclove.com 还有这些核心功能:

  • AI 助手:内置 AI 聊天组件,基于 DeepSeek 实时流式响应,随时提问
  • 智能目录:自动从文章标题生成目录树,支持大标题折叠子标题、滚动高亮、点击跳转
  • 分类体系:支持树形分类结构,多层级管理文章
  • 文档树管理:后台采用树形文档管理,拖拽排序,所见即所得
  • 多用户支持:每个用户有独立主页(/u/username),独立分类和文章
  • 深色模式:跟随系统或手动切换,全站适配
  • 响应式设计:从手机到桌面端完美适配
  • 一键部署:GitHub Actions 自动构建、SSH 部署到服务器,PM2 进程管理

总结

Cursor AI Skill 的价值不在于它是一个多高深的技术,而在于它提供了一种思路:让 AI 不只是回答问题,而是融入你的工作流

在 Ink & Code 这个项目中,AI Skill 串联了从代码提交到博客发布的完整链路。而 Next.js + Prisma + Tiptap + Tailwind 这套技术栈,则提供了足够灵活的底层能力来支撑这种自动化。

如果你也想体验 AI 驱动的博客写作,欢迎访问 ptclove.com ,也欢迎在评论区交流你的 AI Skill 实践。

你不知道的JS上-(八)

this 和对象原型

对象

语法

对象可以通过两种形式定义:声明(文字)形式和构造形式。

文字语法:

var myObj = {
  key: value,
  //...
};

构造语法:

var myObj = new Object();
myObj.key = value;

构造形式和文字形式的区别就是,文字声明中可以添加多个健/值对,构造语法只能逐个添加。

类型

对象是JavaScript的基础。在JavaScript中一个有六种主要类型(术语是“语言类型”):string、number、boolean、null、undefined、object

注意,简单基本类型本身并不是对象。null有时会被当作一种对象类型,但这是语言本身的一个bug,即对null执行typeof null时会返回字符串“object”。null本身是基本类型。

实际上,JS中有许多特殊的对象子类型,我们称之为复杂基本类型。

函数、数组都是对象的一种类型。

内置对象

JS中还有一些对象子类型,通常被称为内置对象。有些内置对象的名字看起来和基础类型一样,不过它们的关系更加复杂。String、Number、Boolean、Object、Function、Array、Date、RegExp、Error。

这些内置对象从表现形式上和其他语言中的类型(type)或者类(class)很像,比如Java中的String类。

但在JS中,它们实际上只是一些内置函数。这些内置函数可以当作构造函数来使用,从而构造一个对应子类型的新对象。

var strPrimitive = "I am a String";
typeof strPrimitive; // "string"
strPrimitive instanceof String; // false

var strObject = new String("I am a String");
typeof strObject; // "object"
strObject instanceof String; // true

// 检查sub-type对象
Object.prototype.toString.call(strObject); // [object String]

原始值"I am a String"并不是一个对象,它只是一个字面量,并且是一个不可变的值。语言会自动把字符串字面量转换成一个String对象以便对其执行一些操作,比如获取长度、访问其中某个字符等。

var strPrimitive = "I am a String";

console.log(strPrimitive.length); // 13

console.log(strPrimitive.charAt(3)); // "m"

同样的事也会发生在数值字面量和布尔字面量上。

null和undefined没有对应的构造形式,它们只有文字形式。相反,Date只有构造,没有文字形式。

对于Object、Array、Function和RegExp来说,无论使用文字还是构造形式,它们都是对象,不是字面量。

Error对象很少在代码中显示创建,一般是在抛出异常时被自动创建。也可以使用new Error(..)来创建。

内容

对象的内容是由一些存储在特地命名位置的(任意类型的)值组成的,我们称之为属性。

在引擎内部,属性的存储方式是多种多样的,一般并不会存在对象容器内部。存储在对象容器内部的是这些属性的名称,它们就像指针(从技术角度来说就是引用)一样,指向这些值真正的存储位置。

var myObject = {
  a: 2,
};

myObject.a; // 2

myObject["a"]; // 2

“.a”语法通常被称为“属性访问”,“["a"]”语法通常被称为“键访问”。它们访问的都是同一个位置。

两种语法的主要区别为“.”操作符要求属性名满足标识符的命名规范,“[".."]”语法可以接受任意UTF-8/Unicode字符串作为属性名。

在对象中,属性名永远都是字符串。如果使用的是string(字面量)以外的其他值作为属性名,那它首先会被转换为一个字符串。

var myObject = {};

myObject[true] = "foo";
myObject[3] = "bar";
myObject[myObject] = "baz";

myObject["true"]; // "foo"
myObject["3"]; // "bar"
myObject["[object Object]"]; // "baz"
可计算属性名

ES6 增加了可计算属性名:

var prefix = "foo";

var myObject = {
  [prefix + "bar"]: "hello",
  [prefix + "baz"]: "world",
};

myObject["foobar"]; // hello
myObject["foobaz"]; // world
数组

数组也支持[]访问形式。数组期望额是数值下标,也就是值存储的位置(通常被称为索引)是非负整数:

var myArray = ["foo", 42, "bar"];

myArray.length; // 3
myArray[0]; // "foo"
myArray[2]; // "bar"

数组也是对象,可以给数组添加属性(但不建议这么做):

var myArray = ["foo", 42, "bar"];

myArray.baz = "baz";
myArray.length; // 3
myArray.baz; // "baz"
复制对象

在JS中复制一个对象是复杂的,需要判断深复制还是浅复制。对于浅拷贝,会和旧对象使用相同的引用;对于深拷贝,存在循环引用的情况就会导致死循环。

许多JS框架都提出了自己的解决办法,但是JS采用的标准又不统一。

对于JSON安全(也就是可以被序列化为一个JSON字符串并且可以根据这个字符串解析出一个结构和值完全一样的对象)的对象来说:

var newObj = JSON.parse(JSON.stringify(someObj));

ES6定义了Object.assign(..)方法实现浅复制:

var newObj = Object.assign({}, myObject);
属性描述符

ES5之前按,JS语言本身没有提供可以直接检测属性特性的方法,比如判断属性是否是只读。

ES5开始,所有的属性都具备了属性描述符。

var myObject = {
  a: 2,
};

Object.getOwnPropertyDescriptor(myObject, "a");
// {
//   value: 2,
//   writable: true,
//   enumerable: true,
//   configurable: true
// }

如我们所见,该普通的对象属性对应的属性描述符(也被称为“数描述符”,因为它只能保存一个数据),不仅只是2。还有另外三个特性:writable(可写)、enumerable(可枚举)和configurable(可配置)。

我们可以使用Object.defineProperty(..)来添加一个新的属性或修改已有属性(如果它是configurable)对特性进行设置。

  1. writable writable决定是否可以修改属性的值
var myObject = {};

Object.defineProperty(myObject, "a", {
  value: 2,
  writable: fasle, // 不可写
  enumerable: true,
  configurable: true
});

myObject.a = 3;

myObject.a; // 2

  1. configurable 只要属性是可配置的,就可以使用defineProperty(..)方法来修改属性描述符
var myObject = {
  a: 2
};

myObject.a = 3;
myObject.a; // 3

Object.defineProperty(myObject, "a", {
  value: 4,
  writable: true,
  enumerable: true,
  configurable: fasle // 不可配置
});

myObject.a; // 4
myObject.a = 5;
myObject.a; // 5

Object.defineProperty(myObject, "a", {
  value: 6,
  writable: true,
  enumerable: true,wrai
  configurable: true
}); // TypeError

可见将configurable修改为false是单向操作,无法撤销。

要注意一个例外,即便属性是configurable: false,还是能够将 writable 的状态由 true 改为 false,但是无法由 fasle 改为 true。

且configurable: false 还会禁止删除这个属性:

var myObject = {
  a: 2
};

myObject.a; // 2

delete myObject.a;
myObject.a; // undefined

Object.defineProperty(myObject, "a", {
  value: 2,
  writable: true,
  enumerable: true,
  configurable: fasle // 不可配置
});

myObject.a; // 2
delete myObject.a;
myObject.a; // 2
  1. enumerable

该描述符控制属性是否会出现在对象的属性枚举中,比如for..in循环,如果设置enumerable为false,这个属性就不会出现在枚举中。

不变性

有时我们希望属性或者对象是不可改变的,H5中可以通过很多方法实现,但所有的方法都是浅不变性,它们只会影响目标对象和它的直接属性。

  1. 对象常量 结合 writable: false 和 configurable: fasle就可以创建一个真正的常量属性(不可修改、重新定义或者删除):
Object.defineProperty(myObject, "FAVORITE_NUMBER", {
  value: 42,
  writable: false,
  configurable: fasle
});
  1. 禁止拓展 可以使用Object.preventExtensions(..):
var myObject = {
  a: 2,
};

Object.preventExtensions(myObject);

myObject.b = 3;
myObject.b; // undefined
  1. 密封 Object.seal(..) 会创建一个“密封”的对象,这个方法会一个在现有的对象上调用Object.preventExtensions(..),并把所有的属性标记为configurable: false。

  2. 冻结 Object.freeze(..) 会创建一个“冻结”的对象,这个方法会一个在现有的对象上调用Object.seal(..),并把所有“数据访问”属性标记为writable: false,这样旧无法修改值。

该方法是我们可以应用在对象上的最高的不可变性,它会禁止对象本身及其任意直接属性的修改(不过该对象引用的其他对象是不受影响的)。

[[Get]]
var myObjedt = {
  a: 2,
};
myObjedt.a; // 2

myObjedt.a通过[[Get]]操作实现对a属性的访问。对象默认的内置[[Get]]操作会首先查找是否有同名的属性,有就返回该对象的值。没有就会遍历可能存在的[[Prototype]]链,还是没找到的话就会返回undefined。

[[Put]]

[[Put]]被触发时,会取决于许多因素,包括对象中是否存在这个属性。

如果已经存在这个属性,[[Put]]算法大致会检查下面这些内容。

  1. 属性是否是访问描述符?如何是并且存在setter就调用setter。
  2. 属性的数据描述符中writable是否是false?如果是,在非严格模式静默失败,在严格模式抛出TypeError异常。
  3. 如果都不是将值设为属性的值。

如果对象中不存在这个属性,[[Put]]操作会更加复制。后续详细介绍。

Getter和Setter

对象默认的[[Put]]和[[Get]]操作分别可以控制属性值的设置和获取。

在ES5中可以使用getter和setter部分改写默认操作,但是只能应用在单个属性上,无法应用在整个对象上。getter是一个隐藏函数,会在获取属性值时调用。setter也是一个隐藏函数,会在设置属性值时调用。

当你给一个属性定义getter、setter 或者两者都有时,这个属性会被定义为“访问描述符”(和“数据描述符”相对)。对于访问描述符来说,JavaScript 会忽略它们的value和 writable特性,取而代之的是关心set和get(还有configurable和enumerable)特性

var myObject = {
  // 给a定义一个getter
  get a() {
    return 2;
  },
};

Object.defineProperty(
  myObject, // 目标对象
  "b", // 属性名
  {
    get: function () {
      return this.a * 2;
    },
    enumerable: true,
  },
);

myObject.a; // 2

myObject.b; // 4

通常getter和setter是成对出现的(只定义一个的话通常会产生意料之外的行为):

var myObject = {
  // 给a定义一个getter
  get a() {
    return this._a_;
  }

  // 给a定义一个setter
  set a(val) {
    this._a_ = val * 2;
  }
}

myObject.a = 2;

myObject.a; // 4
存在性

当myObject.a的属性访问返回值为undefined时,这个值可能时属性中存储的undefined,也可能是属性不存在而返回undefined。我们可以在不访问属性值的情况下判断对象中是否存在这个属性:

var myObject = {
  a: 2,
};

"a" in myObject; // true
"b" in myObject; // false

myObject.hasOwnProhaperty("a"); // true
myObject.hasOwnProperty("b"); // false

in 操作符会检查属性是否在对象及其[[Prototype]]原型链中。

hasOwnproperty(..)只会检查属性是否在myObject对象中,不会检查[[Prototype]]链。

枚举
var myObject = {};

Object.defineProperty(
  myObject,
  "a",
  // 让a可枚举
  { enumerable: true, value: 2 },
);

Object.defineProperty(
  myObject,
  "b",
  // 让b不可枚举
  { enumerable: false, value: 3 },
);

myObject.b; // 3
"b" in myObject; // true
myObject.hasOwnProperty("b"); // true

// .............

for (var k in myObject) {
  console.log(k, myObject[k]);
} // "a" 2

可以看到myObject.b确实存在并且有访问值,但不会出现在for..in循环中。

也可以通过其他方式来区分属性是否可枚举:

var myObject = {};

Object.defineProperty(
  myObject,
  "a",
  // 让a可枚举
  { enumerable: true, value: 2 },
);

Object.defineProperty(
  myObject,
  "b",
  // 让b不可枚举
  { enumerable: false, value: 3 },
);

myObject.propertyIsEnumerable("a"); // true
myObject.propertyIsEnumerable("b"); // false

Object.keys(myObject); // ["a"]
Object.getOwnPropertyNames(myObject); // ["a", "b"]

遍历

for..in 循环可以用来遍历对象的可枚举属性列表(包括[[Prototype]]链)

ES6中增加了 for..of 循环语法来遍历数组(如果对象本身定义了迭代器的话也可以遍历对象):

var myArray = [1, 2, 3];

for (var v of myArray) {
  console.log(v);
}
// 1
// 2
// 3

for..of 循环首先会向被访问对象请求一个迭代器对象,然后通过调用迭代器对象的next()方法来遍历所有返回值。

数组内有内置的@@iterator,因此for..of可以直接应用在数组上。我们使用内置的@@iterator来手动遍历数组,看看它是怎么工作的:

var myArray = [1, 2, 3];
var it = myArray[Symbol.iterator]();

it.next(); // {value:1,done:false}
it.next(); // {value:2,done:false}
it.next(); // {value:3,done:false}
it.next(); // {done:true}

和数组不同,普通的对象没有内置的@@iterator,所有无法自动完成for..of遍历。之所以要这样做,有许多复杂的原因,不过简单来说这样是为了避免影响未来的对象类型。

我们可以给想遍历的对象定义@@iterator:

var myObject = {
  a: 2,
  b: 3,
};

Object.defineProperty(myObject, Symbol.iterator, {
  enumerable: false,
  writable: false,
  configurable: true,
  value: function () {
    var o = this;
    var idx = 0;
    var ks = Object.keys(o);
    return {
      next: function () {
        return {
          value: o[ks[idx++]],
          done: idx > ks.length,
        };
      },
    };
  },
});

// 手动遍历myObject
var it = myObject[Symbol.iterator]();
it.next(); // {value: 2,done: false}
it.next(); // {value: 3,done: false}
it.next(); // {value: undefined,done: true}

// 用for..of遍历myObject
for (var v of myObject) {
  console.log(v);
}
// 2
// 3

JavaScript的数据类型 —— Boolean类型

Boolean(布尔值)类型有两个字面值:truefalse。 这两个布尔值不同于数值,因此 true 不等于 1,false 不等于 0。

虽然布尔值只有两个,但所有其他类型的值都有相应布尔值的等价形式,可以调用特定的Boolean() 转型函数:

let message = "Hello world!"; 
let messageAsBoolean = Boolean(message);

Boolean()转型函数可以在任意类型的数据上调用,而且始终返回一个布尔值。

下面是不同类型与布尔值之间的转换规则:

数据类型 Truthy(真值) Falsy(假值)
Boolean true false
String 非空字符串 ""(空字符串)
Number 非零数值(包括无穷值) 0、-0、NaN
Object 任意对象,包括空数组 []和空对象 {} null
Undefined N/A(不存在) undefined

直接赋值:最直接的方式。

let isActive = true;
let isLoading = false;

通过表达式:比较或逻辑运算的结果是布尔值。

let isGreater = 5 > 3; // true
let isEqual = (10 === "10"); // false
let logicResult = (5 > 3) && (4 <= 4); // true

显式转换:使用 Boolean()函数或双重非运算符 !!

// 使用 Boolean() 函数
console.log(Boolean("Hello")); // true (非空字符串是 truthy)
console.log(Boolean(0)); // false (0 是 falsy)
console.log(Boolean({})); // true (空对象是 truthy)

// 使用 !! 运算符,效果相同但更简洁
console.log(!!"Hello"); // true
console.log(!!0); // false

不建议使用Boolean构造函数(通过 new关键字):

// 创建原始布尔值
let primitiveTrue = true;
// 创建 Boolean 对象
let objectTrue = new Boolean(true);

console.log(typeof primitiveTrue); // "boolean"
console.log(typeof objectTrue); // "object"

// 即使包装的对象值为 false,其本身作为对象在条件判断中仍为 true
let objectFalse = new Boolean(false);
if (objectFalse) {
  console.log("This will be executed."); // 这行会被执行
}

布尔值的应用:

//条件判断:控制代码分支。
let isLoggedIn = true;
if (isLoggedIn) {
  console.log("Welcome back!");
} else {
  console.log("Please log in.");
}

//循环控制:决定循环是否继续执行。
let count = 0;
while (count < 5) { // 条件为 true 时循环继续
  console.log(count);
  count++;
}

//函数返回:函数常用布尔值返回操作结果或状态检查。
function isAdult(age) {
  return age >= 18;
}
let canVote = isAdult(20); // true

//数据过滤:例如,结合数组的 filter方法快速过滤出有效项。
const mixedArray = [1, 0, "hello", "", null, 42];
const truthyValues = mixedArray.filter(Boolean); // [1, "hello", 42]
// Boolean 函数作为回调,会自动过滤掉所有 falsy 值

基于uview-pro的u-dropdown扩展自己的dropdown组件

基于uview-pro的u-dropdown扩展自己的dropdown组件

uview-pro的u-dropdown只能是菜单,且只能向下展开,当前组件采用它的核心逻辑,去除多余逻辑,兼容上/下展开,以及自定义展示的内容,不再局限于菜单形式

e4043c3d-df06-4a4f-9d61-237d8254cf54.png

import type { ExtractPropTypes, PropType } from 'vue';
import { baseProps } from 'uview-pro/components/common/props';

/**
 * u-dropdown 下拉菜单 Props
 * @description 该组件一般用于向下展开菜单,同时可切换多个选项卡的场景
 */
export const DropdownProps = {
  ...baseProps,
  /** 点击遮罩是否关闭菜单 */
  closeOnClickMask: { type: Boolean, default: true },
  /** 过渡时间 */
  duration: { type: [Number, String] as PropType<number | string>, default: 300 },
  /** 下拉出来的内容部分的圆角值 */
  borderRadius: { type: [Number, String] as PropType<number | string>, default: 20 },
  /** 展开方向 down/up */
  direction: { type: String as PropType<'down' | 'up'>, default: 'up' },
  /** 弹出层最大高度 */
  maxHeight: { type: String as PropType<`${number}rpx` | `${number}vh`>, default: '80vh' },
  /** 弹出层最小高度 */
  minHeight: { type: String as PropType<`${number}rpx` | `${number}vh`>, default: '0rpx' },
  /** 是否隐藏关闭按钮 */
  hiddenClose: { type: Boolean, default: false },
  /** 弹出层标题 */
  title: { type: String, default: '' }
};

export type DropdownProps = ExtractPropTypes<typeof DropdownProps>;

<template>
  <view class="u-dropdown" :style="$u.toStyle(styles, customStyle)" :class="customClass">
    <view class="u-dropdown__menu">
      <slot></slot>
    </view>
    <view
      class="u-dropdown__content"
      :style="[
        contentStyle,
        {
          transition: `opacity ${Number(duration) / 1000}s linear`,
          [currentDirection === 'down' ? 'top' : 'bottom']: menuHeight + 'px',
          height: contentHeight + 'px'
        }
      ]"
      @tap="maskClick"
      @touchmove.stop.prevent>
      <view @tap.stop.prevent class="u-dropdown__content__popup" :style="[popupStyle]">
        <slot name="close" v-if="!hiddenClose">
          <view class="u-dropdown__content__popup__close" @click="close">
            <u-icon name="close" size="48" custom-prefix="custom-icon" />
          </view>
        </slot>

        <slot name="header">
          <view class="u-dropdown__content__popup__header" v-if="title"> {{ title }} </view>
        </slot>

        <view class="u-dropdown__content__popup__body">
          <scroll-view scroll-y class="u-dropdown__content__popup__scroll-view">
            <slot name="content"></slot>
          </scroll-view>
        </view>
        <view class="u-dropdown__content__popup__footer">
          <slot name="footer"></slot>
        </view>
      </view>
      <view class="u-dropdown__content__mask"></view>
    </view>
  </view>
</template>

<script lang="ts">
  export default {
    name: 'hj-dropdown',
    options: {
      addGlobalClass: true,
      // #ifndef MP-TOUTIAO
      virtualHost: true,
      // #endif
      styleIsolation: 'shared'
    }
  };
</script>

<script setup lang="ts">
  import { ref, computed, onMounted, getCurrentInstance, watch, type CSSProperties } from 'vue';
  import { $u } from 'uview-pro';
  import { DropdownProps } from './types';

  /**
   * dropdown 下拉菜单
   * @description 该组件一般用于向下展开菜单,同时可切换多个选项卡的场景
   * @tutorial https://uviewpro.cn/zh/components/dropdown.html
   * @property {Boolean} close-on-click-mask 点击遮罩是否关闭菜单(默认true)
   * @property {String | Number} duration 选项卡展开和收起的过渡时间,单位ms(默认300)
   * @property {String | Number} border-radius 菜单展开内容下方的圆角值,单位任意(默认20)
   * @property {String} direction 展开方向 down/up(默认up)
   * @property {String} max-height 弹出层最大高度(默认80vh)
   * @property {String} min-height 弹出层最小高度
   * @property {Boolean} hidden-close 是否隐藏关闭按钮(默认false)
   * @property {String} title 弹出层标题
   * @property {Boolean} show 是否显示下拉菜单(默认false)
   * @event {Function} open 下拉菜单被打开时触发
   * @event {Function} close 下拉菜单被关闭时触发
   * @example <hj-dropdown></hj-dropdown>
   */

  const props = defineProps(DropdownProps);
  const emit = defineEmits(['open', 'close']);

  // 展开状态
  const active = ref(false);
  // 外层内容样式
  const contentStyle = ref<CSSProperties>({
    zIndex: -1,
    opacity: 0
  });
  // 下拉内容高度
  const contentHeight = ref<number>(0);
  // 菜单实际高度
  const menuHeight = ref<number>(0);
  // 当前展开方向
  const currentDirection = ref<'down' | 'up'>(props.direction);
  // 子组件引用
  const instance = getCurrentInstance();

  const vShow = defineModel('show', {
    type: Boolean,
    default: false
  });

  watch(vShow, val => {
    if (val === active.value) return;
    if (val) {
      open();
    } else {
      close();
    }
  });

  // 监听方向变化
  watch(
    () => props.direction,
    val => {
      currentDirection.value = val;
    }
  );

  // 兼容头条样式
  const styles = computed<CSSProperties>(() => {
    const style: CSSProperties = {};
    // #ifdef MP-TOUTIAO
    style.width = '100vw';
    // #endif
    return style;
  });

  // 下拉出来部分的样式
  const popupStyle = computed<CSSProperties>(() => {
    const style: CSSProperties = {};
    const isDown = currentDirection.value === 'down';
    const hiddenTransformLate = isDown ? '-100%' : '100%';

    style.maxHeight = props.maxHeight;
    style.minHeight = props.minHeight;
    style.transform = `translateY(${active.value ? 0 : hiddenTransformLate})`;
    style[isDown ? 'top' : 'bottom'] = 0;
    // 进行Y轴位移,展开状态时,恢复原位。收起状态时,往上位移100%(或下),进行隐藏
    style.transitionDuration = `${Number(props.duration) / 1000}s`;

    if (isDown) {
      style.borderRadius = `0 0 ${$u.addUnit(props.borderRadius)} ${$u.addUnit(props.borderRadius)}`;
    } else {
      style.borderRadius = `${$u.addUnit(props.borderRadius)} ${$u.addUnit(props.borderRadius)} 0 0`;
    }
    return style;
  });

  // 生命周期
  onMounted(() => {
    getContentHeight();
  });

  /**
   * 打开下拉菜单
   * @param direction 展开方向 'down' | 'up'
   */
  function open(direction?: 'down' | 'up') {
    currentDirection.value = direction || props.direction;

    // 重新计算高度,因为方向可能改变
    getContentHeight();

    // 设置展开状态
    active.value = true;

    // 展开时,设置下拉内容的样式
    contentStyle.value = {
      zIndex: 11,
      opacity: 1
    };
    vShow.value = true;
    emit('open');
  }

  /**
   * 关闭下拉菜单
   */
  function close() {
    // 下拉内容的样式进行调整,不透明度设置为0
    active.value = false;
    contentStyle.value = {
      ...contentStyle.value,
      opacity: 0
    };

    // 等待过渡动画结束后隐藏 z-index
    vShow.value = false;
    setTimeout(() => {
      contentStyle.value = {
        zIndex: -1,
        opacity: 0
      };
      emit('close');
    }, Number(props.duration));
  }

  /**
   * 点击遮罩
   */
  function maskClick() {
    if (!props.closeOnClickMask) return;
    close();
  }

  /**
   * 获取下拉菜单内容的高度
   * @description
   * dropdown组件是相对定位的,下拉内容必须给定高度,
   * 才能让遮罩占满菜单以下直到屏幕底部的高度。
   */
  function getContentHeight() {
    const windowHeight = $u.sys().windowHeight;

    $u.getRect('.u-dropdown__menu', instance).then((res: any) => {
      // 获取菜单实际高度
      menuHeight.value = res.height;

      /**
       * 尺寸计算说明:
       * 在H5端,uniapp获取尺寸存在已知问题:
       * 元素尺寸的top值为导航栏底部到元素的上边沿的距离
       * 但元素的bottom值却是导航栏顶部到元素底部的距离
       * 为避免页面滚动,此处取菜单栏的bottom值进行计算
       */
      if (currentDirection.value === 'up') {
        contentHeight.value = res.top;
      } else {
        contentHeight.value = windowHeight - res.bottom;
      }
    });
  }

  // 暴露方法
  defineExpose({
    close,
    open
  });
</script>

<style scoped lang="scss">
  @import 'uview-pro/libs/css/style.components';

  .u-dropdown {
    flex: 1;
    width: 100%;
    position: relative;
    background-color: #fff;

    &__content {
      position: absolute;
      z-index: 8;
      width: 100%;
      left: 0;
      overflow: hidden;

      &__mask {
        position: absolute;
        z-index: 9;
        background: rgba(0, 0, 0, 0.3);
        width: 100%;
        left: 0;
        top: 0;
        bottom: 0;
      }

      &__popup {
        position: absolute;
        width: 100%;
        z-index: 10;
        transition: all 0.3s;
        transform: translate3D(0, -100%, 0);
        overflow: hidden;
        background-color: var(--gray-2);

        &__close {
          width: 40rpx;
          height: 40rpx;
          display: flex;
          align-items: center;
          justify-content: center;
          position: absolute;
          right: 24rpx;
          top: 30rpx;
          z-index: 9;
        }

        &__header {
          display: flex;
          color: var(--title-1);
          font-size: var(--ft-32);
          font-weight: 500;
          line-height: 44rpx;
          padding: 30rpx 24rpx;
        }

        &__body {
          flex: 1;
          overflow: hidden;
          display: flex;
        }

        &__scroll-view {
          flex: 1;
        }

        &__footer {
          display: flex;
        }
      }
    }
  }
</style>

详解 IEEE 754 标准定义的 JavaScript 64 位浮点数

JavaScript 使用 IEEE 754 标准定义的 64 位浮点格式表示数值。

64位 = 1位符号位(S) + 11位指数位(E) + 52位尾数位(M)

  • 符号位(S):0表示正数,1表示负数,决定数值的正负;
  • 指数位(E):存储指数的偏移值(偏移量1023),决定数值的数量级;
  • 尾数位(M):存储数值的有效数字(二进制),且有一个隐含的1位(规格化数,也叫归一化数,normalized number),所以实际有效位数是 52 + 1 = 53位。

指数位(E)的偏移量 1023 是标准人为规定的,为的是让原本的 11 位无符号位的二进制指数位能表示正负指数,并且正负指数尽可能对称(-1022 ~ 1023)。

尾数位(M)的隐藏位 1 也是标准人为规定的,利用了非零二进制数归一化后整数位必为 1 的特性。

任何一个非零的二进制数,都能唯一表示为 1.xx × 2^e 的形式。

  • e 是二进制的指数(整数,可正可负);
  • 归一化后整数位必然是固定不变的 1,这是二进制的特性。

IEEE 754 标准正是利用了「归一化后整数位必为 1」的特性,做了一个巧妙设计:

既然这个 1 是所有非零浮点数的固定前缀,那就不用在 64 位中实际存储它,而是「默认存在这个 1」,仅将小数点后的 xx 有效数字部分存入 52 位的尾数位(M)中。

原本尾数位只有 52 位,加上这 1 位隐藏的固定不变的 1 后,实际可用的有效数字位就变成了 53 位(1 + 52),直接提升了浮点数的精度,且没有占用额外的存储空间。

只有非零的归一化浮点数才有隐藏位。对于接近 0 的极小值(非归一化数),IEEE 754 会放弃归一化,此时没有隐藏位,只有 52 位有效数字。

推导:53 位二进制能表示的最大整数是 2^53 - 1

当 53 位二进制尾数位(M)(包含隐藏位 1),所有位都为 1 时,就是它能表示的最大数,计算如下:

111...111(53个1) = 2^52 + 2^51 + ... + 2^1 + 2^0 = 2^53 - 1

这个数就是 JS 中能精确表示的最大安全整数 Number.MAX_SAFE_INTEGER

一个整数 n 被称为安全整数,当且仅当:它自身和前后相邻的两个整数(n−1、n、n+1),都能被唯一且精确地存储表示,三者的 64 位浮点数编码互不重复,且不存在截断舍入后的失真情况。

在 2^53 ~ 2^1023 这区间内的 2 的整数次幂能精确表示,但都是「孤立的精确数」(相邻数失真,无连续性)。

少数能精确存储的特殊十进制小数

绝大多数小数的二进制,都是无限循环的小数形式,都不能精确存储,只能截断舍入近似存储。

如果一个十进制小数转二进制后是「有限位小数」,就能被 64 位浮点数精确存储,这类小数有明确的数学规则:

十进制小数能精确转为有限位二进制的充要条件:小数部分转化成最简分数形式后,它的分母仅包含质因子 2(即分母是 2 的正整数次幂:2¹、2²、2³...)。

简单说:小数部分是 0.5(1/2)、0.25(1/4)、0.125(1/8)、0.0625(1/16)... 的组合,就能精确存储。

浮点数的「可表示值步长」随数值增大而变大(精度衰减规律)

64 位浮点数的可表示值不是连续的,而是离散的、等步长的(同一量级内步长固定),且数值越大,量级越高,步长越大。这是整数和小数的精度都会随数值增大而衰减的根本原因。

步长是(同一量级内)相邻两个可表示的浮点数之间的差值,由归一化后的指数 e 决定

步长 = 2^(e-52) (52 是尾数位 M 的位数)。

  • 步长越小,可表示值越密集,精度越高;
  • 步长越大,可表示值越稀疏,精度越低。

步长对整数和小数的影响:

- 安全整数区(e≤52):步长 = 2^(e-52) ≤1 → 整数的步长为 1,能连续精确表示;小数的步长极小,误差可忽略;

  • 2⁵³~2¹⁰²³ 区:步长≥2 → 整数的步长大于 1,无法连续精确表示(相邻整数重叠);小数的步长极大,几乎无法区分相近小数;
  • 2¹⁰²³ 以上:超出指数范围,直接变成 Infinity,无法表示为常规数。

MAX_VALUE 和 MIN_VALUE

最大正值:Number.MAX_VALUE = 1.7976931348623157×10³⁰⁸

指数位取归一化数的最大存储值 2046(实际指数 1023),尾数位 52 位全 1(此时浮点数取到归一化数的最大极值,再大就超出指数范围,变成Infinity);

最小正值:Number.MIN_VALUE = 5×10⁻³²⁴(无限接近 0)

指数位取全 0(非归一化数,实际指数 -1023),尾数位仅最后 1 位为 1、其余全 0(此时浮点数取到能表示的最小非 0 正值,再小就会被舍入为 0)。

这是 64 位双精度浮点数基于 IEEE 754 标准的硬件存储极限,是浮点数能表示的所有数值(整数 + 小数)的整体边界,而非专门针对整数的范围。

±Infinity 和 NaN 如何表示

Infinity(正无穷) -Infinity(负无穷) NaN(非数)
1位符号位(S) 0表示正数 1表示负数 0和1都可,无意义
11位指数位(E) 11位全1 11位全1 11位全1
52位尾数位(M) 52位全0 52位全0 52位非全0(即任意1位是1即可)
S0 + E全1 + M全0 S1 + E全1 + M全0 S01 + E全1 + M非0

E全1 表示 ±Infinity 和 NaN 的特殊值,而 E全0 表示非归一化数(接近 0 的极小值,无隐藏位)。

React-create-app使用cesium并且渲染3d倾斜摄影

先上效果 image.png

一、cesium在react-create-app中的引用

首先 yarn add cesium然后yarn add copy-webpack-plugin -D然后yarn add customize-cra react-app-rewired --dev

设置了customize-cra react-app-rewired就可以改写webpack

image.png 新建一个这个文件,在里面改写webpack

const {
  override,
 
  addWebpackPlugin,
} = require("customize-cra");
const path = require("path");
const CopyWebpackPlugin = require("copy-webpack-plugin");
const webpack = require("webpack");


const cesiumSource = 'node_modules/cesium/Source';
const cesiumWorkers = '../Build/Cesium/Workers';



module.exports = override(
  
 
  addWebpackPlugin(
    new CopyWebpackPlugin({
      patterns: [
        
        { from: path.join(cesiumSource, cesiumWorkers), to: 'cesium/Workers' },
        { from: path.join(cesiumSource, 'Assets'), to: 'cesium/Assets' },
        { from: path.join(cesiumSource, 'Widgets'), to: 'cesium/Widgets' }
      ],
    })
  ),
  addWebpackPlugin(
    new webpack.DefinePlugin({
      // Define relative base path in cesium for loading assets
      CESIUM_BASE_URL: JSON.stringify("/cesium"),
    })
  )
  // addWebpackPlugin(new BundleAnalyzerPlugin())
);


package.json里的打包脚本变成

"scripts": {
    "start": "react-app-rewired start",
    "build": "react-app-rewired build",
    
  },

这样就会走我们新设定的webpack,将将node_modules/cesium/Source/Assets, node_modules/cesium/Source/Widgets, node_modules/cesium/Build/Cesium/Workers自动文件打包到build文件里,如图

image.png 这样yarn start 或者yarn build就可以正常使用cesium了

下面这篇我直接按可发布到掘金的技术文章结构帮你整理好了:有背景、有思路、有代码拆解、有优化建议,基本不需要再大改就能发 👍

二、创建 Viewer 且必须只创建一次!

否则:

  • 内存暴涨
  • WebGL context 丢失
  • 页面卡死
useEffect(() => {
  if (!containerRef.current) return;
  if (viewerRef.current) return;

  const viewer = new Cesium.Viewer(containerRef.current, {
    timeline: false,
    animation: false,
    infoBox: false,
    fullscreenButton: false,
  });

  viewerRef.current = viewer;

  return () => {
    viewerRef.current?.destroy();
    viewerRef.current = null;
  };
}, []);
<div ref={containerRef} className="cesium-container" />

这是 Cesium + React 的标准写法


三、加载倾斜摄影模型(3DTiles)

Cesium.Cesium3DTileset.fromUrl("tileset.json")
  .then((tileset) => {
    viewer.scene.primitives.add(tileset);
    viewer.zoomTo(tileset);
  });

一个强烈建议 ⭐⭐⭐⭐⭐

建议监听瓦片失败:

tileset.tileFailed.addEventListener((error) => {
  console.error("瓦片加载失败:", error);
});

否则生产环境排查问题会非常痛苦。


四、动态绘制监测点(核心)

很多人喜欢用:

👉 Primitive
👉 PointPrimitive

但在业务系统中,我更推荐:

⭐ Entity

因为:

  • 开发简单
  • 支持属性绑定
  • 支持 pick
  • 易维护
function createColorIcon(color: string) {
    const canvas = document.createElement("canvas");
    const size = 48;
    canvas.width = size;
    canvas.height = size;
    const ctx = canvas.getContext("2d");
    if (ctx) {
      const cx = size / 2;
      const r = 12;
      const tipY = size - 6;



      // 图钉形状(圆 + 尖)
      ctx.beginPath();
      ctx.moveTo(cx, tipY);
      ctx.quadraticCurveTo(cx + r, r + 12, cx + r, r + 4);
      ctx.arc(cx, r + 4, r, 0, Math.PI, true);
      ctx.quadraticCurveTo(cx - r, r + 12, cx, tipY);
      ctx.closePath();
      ctx.fillStyle = color;
      ctx.fill();



      ctx.fill();
    }

    return canvas;
  }

  const getWarningColor = (p: any) => {
    const level =
      p?.alarmLevel ?? 0;
    switch (Number(level)) {
      case 4:
        return "#D7263D99"; // 红色预警(约 60% 不透明)
      case 3:
        return "#FF6B0099"; // 橙色预警
      case 2:
        return "#C7A20099"; // 黄色预警
      case 1:
        return "#00BEFF99"; // 蓝色预警
      default:
        return "#d3f26199"; // 约 60% 不透明
    }
  };
  useEffect(() => {
    const viewer = viewerRef.current;
    if (!viewer) return;

    if (dataSource && dataSource?.length > 0) {
      pointEntityIdsRef.current.forEach((id) => {
        try {
          viewer.entities.removeById(id);
        } catch (e) {
          // ignore
        }
      });
      pointEntityIdsRef.current = [];
     
      console.log('dataSource', dataSource);
     

      (dataSource || []).forEach((p: any) => {


        const lng = Number(p.longitude);
        const lat = Number(p.latitude);
        if (Number.isNaN(lng) || Number.isNaN(lat)) return;

        const id = `point-${p.id}`;
        viewer.entities.add({
          id,
          position: Cesium.Cartesian3.fromDegrees(lng, lat, Number(p.height) || 0),
          billboard: {
            image: createColorIcon(getWarningColor(p)),
            verticalOrigin: Cesium.VerticalOrigin.BOTTOM,
            // 防止被倾斜摄影挡住
            disableDepthTestDistance: Number.POSITIVE_INFINITY
          },
          label: {
            text: p.pointName || "",
            font: "bold 15px sans-serif",
            fillColor: Cesium.Color.fromCssColorString("rgba(68, 229, 255, 0.92)"),
            outlineColor: Cesium.Color.fromCssColorString("rgba(124, 121, 121, 0.9)").withAlpha(0.85),
            outlineWidth: 2,
            style: Cesium.LabelStyle.FILL_AND_OUTLINE,
            verticalOrigin: Cesium.VerticalOrigin.BOTTOM,
            pixelOffset: new Cesium.Cartesian2(0, -50),
            disableDepthTestDistance: Number.POSITIVE_INFINITY,
            distanceDisplayCondition: new Cesium.DistanceDisplayCondition(
              0,
              5000
            ),
          },
          properties: {
            pickable: true,
            pointId: p.id,
            pointType: p?.pointType,
            pointName: p?.pointName,
          }
        });

        pointEntityIdsRef.current.push(id);
      });
      const handler = new Cesium.ScreenSpaceEventHandler(viewer.scene.canvas);

      handler.setInputAction((movement: any) => {
        const picked = viewer.scene.pick(movement.endPosition);


        if (
          Cesium.defined(picked) &&
          picked.id &&                     // Entity
          picked?.id?.properties?.pickable?.getValue()
        ) {

          viewer.canvas.style.cursor = 'pointer';


        } else {
          viewer.canvas.style.cursor = 'default';


        }
      }, Cesium.ScreenSpaceEventType.MOUSE_MOVE);
      // 点击测点
      handler.setInputAction((click: any) => {
        const picked = viewer.scene.pick(click.position);

        if (
          Cesium.defined(picked) &&
          picked.id &&
          picked.id.billboard
        ) {
          const entity = picked.id;

          console.log('点到了:', entity.id, picked?.id?.properties?.pointId?.getValue());

         

          // 示例:相机飞过去
          viewer.flyTo(entity);
          
        }
      }, Cesium.ScreenSpaceEventType.LEFT_CLICK);


    }


  }, [dataSource]);

五、点击测点飞行定位

pick 实体

const picked = viewer.scene.pick(click.position);

判断:

if (Cesium.defined(picked) && picked.id?.billboard) {

然后:

viewer.flyTo(entity);

体验直接拉满。


鼠标 Hover 手型

细节决定高级感:

viewer.canvas.style.cursor = 'pointer';

六、相机环绕动画

核心思路:

👉 clock.onTick

const remove = viewer.clock.onTick.addEventListener(() => {
  heading += Cesium.Math.toRadians(0.2);

  viewer.camera.lookAt(
    center,
    new Cesium.HeadingPitchRange(
      heading,
      Cesium.Math.toRadians(-30),
      range
    )
  );
// 转满一圈停止
  if (heading >= Cesium.Math.TWO_PI) {
    remove();
    viewer.camera.lookAtTransform(Cesium.Matrix4.IDENTITY);
  }
});

本质:

👉 每帧改变 heading。

❌