普通视图

发现新文章,点击刷新页面。
今天 — 2025年12月19日首页

每日一题-找出知晓秘密的所有专家🔴

2025年12月19日 00:00

给你一个整数 n ,表示有 n 个专家从 0n - 1 编号。另外给你一个下标从 0 开始的二维整数数组 meetings ,其中 meetings[i] = [xi, yi, timei] 表示专家 xi 和专家 yi 在时间 timei 要开一场会。一个专家可以同时参加 多场会议 。最后,给你一个整数 firstPerson

专家 0 有一个 秘密 ,最初,他在时间 0 将这个秘密分享给了专家 firstPerson 。接着,这个秘密会在每次有知晓这个秘密的专家参加会议时进行传播。更正式的表达是,每次会议,如果专家 xi 在时间 timei 时知晓这个秘密,那么他将会与专家 yi 分享这个秘密,反之亦然。

秘密共享是 瞬时发生 的。也就是说,在同一时间,一个专家不光可以接收到秘密,还能在其他会议上与其他专家分享。

在所有会议都结束之后,返回所有知晓这个秘密的专家列表。你可以按 任何顺序 返回答案。

 

示例 1:

输入:n = 6, meetings = [[1,2,5],[2,3,8],[1,5,10]], firstPerson = 1
输出:[0,1,2,3,5]
解释:
时间 0 ,专家 0 将秘密与专家 1 共享。
时间 5 ,专家 1 将秘密与专家 2 共享。
时间 8 ,专家 2 将秘密与专家 3 共享。
时间 10 ,专家 1 将秘密与专家 5 共享。
因此,在所有会议结束后,专家 0、1、2、3 和 5 都将知晓这个秘密。

示例 2:

输入:n = 4, meetings = [[3,1,3],[1,2,2],[0,3,3]], firstPerson = 3
输出:[0,1,3]
解释:
时间 0 ,专家 0 将秘密与专家 3 共享。
时间 2 ,专家 1 与专家 2 都不知晓这个秘密。
时间 3 ,专家 3 将秘密与专家 0 和专家 1 共享。
因此,在所有会议结束后,专家 0、1 和 3 都将知晓这个秘密。

示例 3:

输入:n = 5, meetings = [[3,4,2],[1,2,1],[2,3,1]], firstPerson = 1
输出:[0,1,2,3,4]
解释:
时间 0 ,专家 0 将秘密与专家 1 共享。
时间 1 ,专家 1 将秘密与专家 2 共享,专家 2 将秘密与专家 3 共享。
注意,专家 2 可以在收到秘密的同一时间分享此秘密。
时间 2 ,专家 3 将秘密与专家 4 共享。
因此,在所有会议结束后,专家 0、1、2、3 和 4 都将知晓这个秘密。

 

提示:

  • 2 <= n <= 105
  • 1 <= meetings.length <= 105
  • meetings[i].length == 3
  • 0 <= xi, yi <= n - 1
  • xi != yi
  • 1 <= timei <= 105
  • 1 <= firstPerson <= n - 1

流式渲染 Incremark、ant-design-x markdown、streammarkdown-vue 全流程方案对比

作者 king王一帅
2025年12月18日 22:37

流式 Markdown 渲染方案对比

本文档对流式 Markdown 渲染方案进行技术对比:Incremarkant-design-xmarkstream-vue。每个方案都有其独特的设计理念和优势。

全流程对比

ant-design-x 全流程

用户输入(流式 Markdown)
        ↓
┌─────────────────────────────────────────────────────────┐
│  useTyping Hook                                         │
│    - 逐字符消费纯文本                                     │
│    - 输出带 fade-in 标记的文本块                          │
├─────────────────────────────────────────────────────────┤
│  useStreaming Hook                                       │
│    - 正则检测不完整 token(链接、图片等)                   │
│    - 缓存不完整部分,只输出完整的 Markdown                  │
│    ↓                                                    │
│  Parser (marked.js)                                      │
│    - 全量解析:contentHTML 字符串                      │
│    ↓                                                    │
│  Renderer (html-react-parser)                            │
│    - HTML 字符串 → React 组件                            │
└─────────────────────────────────────────────────────────┘
        ↓
    React DOM

关键特点:

  • 每次内容变化时使用 marked.parse() 全量解析
  • 打字机动画在纯文本字符串层操作
  • 使用 HTML 字符串作为中间格式

markstream-vue 全流程

用户输入(Markdown 字符串)
        ↓
┌─────────────────────────────────────────────────────────┐
│  预处理                                                  │
│    - 正则修复流式边界问题                                  │
│    - "- *" → "- \*",裁剪悬空标记等                       │
│    ↓                                                    │
│  markdown-it.parse()                                     │
│    - 全量解析 → Token 数组                                │
│    ↓                                                    │
│  processTokens()                                         │
│    - Token → ParsedNode[](自定义 AST)                   │
├─────────────────────────────────────────────────────────┤
│  Vue 组件渲染                                            │
│    - <transition> 实现渐入动画                            │
│    - 节点类型 → 组件映射                                  │
└─────────────────────────────────────────────────────────┘
        ↓
    Vue DOM

关键特点:

  • 每次内容变化时使用 markdown-it.parse() 全量解析
  • 预处理层处理流式边界情况
  • 使用 Vue <transition> 实现打字机效果

Incremark 全流程

用户输入(流式 Markdown chunk)
        ↓
┌─────────────────────────────────────────────────────────┐
│  IncremarkParser.append(chunk)                           │
│    - 增量更新缓冲区(只处理新增部分)                        │
│    - 检测稳定边界(空行、标题等)                           │
│    - 稳定部分 → completedBlocks(只解析一次)              │
│    - 不稳定部分 → pendingBlocks(重新解析)                │
│    ↓                                                    │
│  输出:ParsedBlock[](mdast AST)                        │
├─────────────────────────────────────────────────────────┤
│  BlockTransformer(可选中间件)                           │
│    - 打字机效果:sliceAst() 截断 AST                      │
│    - 维护 TextChunk[] 实现渐入动画                        │
│    - 可跳过,直接渲染完整内容                              │
│    ↓                                                    │
│  输出:DisplayBlock[](截断后的 AST)                     │
├─────────────────────────────────────────────────────────┤
│  Vue / React 组件渲染                                    │
│    - AST 节点 → 组件映射                                 │
│    - TextChunks 包装渐入动画                             │
└─────────────────────────────────────────────────────────┘
        ↓
    Vue / React DOM

关键特点:

  • 增量解析:只解析新增的稳定块
  • 打字机动画在 AST 节点层操作
  • 同时提供 Vue 和 React 适配器

核心差异

维度 ant-design-x markstream-vue Incremark
解析方式 全量解析 (marked.js) 全量解析 (markdown-it) 增量解析 (micromark)
单次 chunk 复杂度 O(n) O(n) O(k),k = 新块大小
总复杂度 O(n × chunks) ≈ O(n²) O(n × chunks) ≈ O(n²) O(n)
边界处理 正则 token 检测 预处理层 稳定边界检测
打字机动画 文本字符串层 Vue Transition 保持 Markdown 结构
输出格式 HTML 字符串 自定义 AST mdast(兼容 remark)
框架支持 React Vue Vue + React

解析策略

全量解析 vs 增量解析

全量解析(ant-design-x 和 markstream-vue):

Chunk 1: "# Hello"         解析全部内容
Chunk 2: "# Hello\nWorld"  再次解析全部内容
Chunk 3: "# Hello\nWorld\n\n- item"  再次解析全部内容

每个新 chunk 都触发对所有累积内容的完整解析。

增量解析(Incremark):

Chunk 1: "# Hello"      解析  Block 1 (heading)  完成
Chunk 2: "\n\nWorld"    解析  Block 2 (paragraph)  只处理这部分
Chunk 3: "\n\n- item"   解析  Block 3 (list)  只处理这部分

已完成的块被缓存,不会重新解析。只处理待定部分。

复杂度分析

在多 chunk 流式场景中:

  • 全量解析:O(n) × chunk 数量
  • 增量解析:每个 chunk O(k),k 是新块大小

对于典型的 AI 响应(10-50 块),两种方案都能接受。差异在大文档或高频 chunk 到达时更明显。


流式边界处理

所有方案都需要处理流式过程中不完整的 Markdown 语法,各自采用不同方案:

方案 工作原理 权衡
Incremark 解析前检测稳定边界 结构清晰;可能缓冲部分内容
ant-design-x 正则模式检测不完整 token 立即输出;需要维护正则
markstream-vue 解析前预处理内容 适用于任何解析器;需处理多种边界情况

打字机动画

方案 层级 机制
Incremark AST 节点 sliceAst() 截断 AST,TextChunk[] 追踪渐入
ant-design-x 文本字符串 逐字符文本切片
markstream-vue 组件 组件挂载时 Vue <transition>

各方案的权衡:

  • AST 层:动画过程中保持结构感知
  • 文本层:更简单,与框架无关
  • 组件层:与 Vue 响应式系统自然集成

渲染优化

markstream-vue 提供额外的渲染优化:

特性 描述
虚拟化 只在 DOM 中渲染可见节点
批量渲染 使用 requestIdleCallback 渐进式渲染
视口优先 延迟渲染屏幕外节点

流式场景的考量:

在典型 AI 流式用例中:

  • 内容逐步到达(天然的批量处理)
  • 用户注意力在底部(观看新内容)
  • 典型响应大小是 10-50 块
  • 打字机效果提供渐进式渲染

虚拟化在以下场景提供显著收益:

  • 浏览历史内容(非活跃流式)
  • 渲染超长文档(100+ 块)
  • 用户快速滚动浏览内容

总结

各方案的侧重点

ant-design-x

侧重:完整的 AI 聊天 UI 解决方案

  • 提供 Bubble、Sender、Conversations 组件
  • 与 Ant Design 生态深度集成
  • 内置 <thinking> 等特殊块支持
  • Ant Design 用户可快速上手

适用场景:在 Ant Design 生态中构建 AI 聊天界面的团队

markstream-vue

侧重:功能丰富的 Markdown 渲染

  • 虚拟化处理大文档
  • 自适应性能的批量渲染
  • 全面的边界情况预处理
  • 丰富的自定义选项

适用场景:有大文档或聊天历史浏览需求的 Vue 应用

Incremark

侧重:高效的增量解析

  • 增量解析:已完成的块不会重新解析,在长流式会话中显著减少 CPU 消耗
  • 跨框架:同一套核心库支持 Vue 和 React,降低多框架团队的维护成本
  • 兼容 remark 生态:标准 mdast 输出,可使用 remark 插件扩展语法
  • 结构化打字机:动画过程中保持 Markdown 结构,插件系统支持自定义行为(如图片立即显示)

适用场景:长流式内容、多框架团队、或需要 remark 插件兼容的应用


快速参考

你的优先级 可考虑
Ant Design 生态集成 ant-design-x
大文档虚拟化 markstream-vue
纯 Vue 应用 markstream-vue
长流式会话 / 多 chunk Incremark
Vue + React 共用代码 Incremark
需要 remark 插件 Incremark

结论

每个方案以不同的优先级解决流式 Markdown:

  • ant-design-x 提供与 Ant Design 紧密集成的完整 AI 聊天 UI 解决方案
  • markstream-vue 为 Vue 应用提供丰富功能和渲染优化
  • Incremark 专注于解析效率和跨框架灵活性

选择取决于你的具体需求:生态契合度、文档规模、框架需求和性能优先级。

非常优雅清晰的 DFS 代码,(C++ / Python / Java / Kotlin)

作者 shawxing-kwok
2024年7月16日 20:57

本篇题解需读者先掌握 DFS 基础知识。

  1. 视为无向图,专家为顶点,能分享秘密的专家之间存在无向边。
  2. 将 meetings 按时间分组且更早的组在前,每组皆改成邻接表。
  3. 在同一时刻中我们针对已访问的顶点(知晓秘密的专家)和相应邻接表向外探索即可。

探索方式可以为 DFS, BFS, 并查集,本篇采用最简单的 DFS。

Code

###C++

class Solution {
private: 
    void dfs(int u, const unordered_map<int, vector<int>>& adj, vector<bool>& visited) {
        for (int v : adj.at(u)) {
            if (!visited[v]) {
                visited[v] = true; 
                dfs(v, adj, visited);
            }
        }
    };

public:
    vector<int> findAllPeople(int n, vector<vector<int>>& meetings, int firstPerson) {
        // 将 meetings 按时间排序
        sort(meetings.begin(), meetings.end(), [](const vector<int>& a, const vector<int>& b) {
            return a[2] < b[2];
        });

        // 根据时间先后,为每个时刻构建一个邻接表,置于列表中。考虑到本就是稀疏图并再次拆分,非常分散,故每个邻接表都用 hash map。
        vector<unordered_map<int, vector<int>>> multiAdj;
        for(int i = 0; i < meetings.size(); ++i){
            if(i == 0 || meetings[i][2] > meetings[i-1][2]) 
                multiAdj.push_back({});
            auto& adj = multiAdj.back();
            int u = meetings[i][0], v = meetings[i][1];
            adj[u].push_back(v);
            adj[v].push_back(u);
        }
        
        vector<bool> visited(n, false);
        visited[0] = true; 
        visited[firstPerson] = true;

        for(const auto& adj : multiAdj){
            // 选取访问过的点 dfs 向外扩散,此处切忌遍历 0~n-1
            for(const auto& p : adj){
                if(visited[p.first])
                    dfs(p.first, adj, visited);            
            }
        }

        // 筛选访问过的点构成结果,即所有知晓秘密的专家
        vector<int> result;
        for(int i = 0; i < n; ++i) {
            if(visited[i]) result.push_back(i);
        }
        return result;
    }
};

###Python3

class Solution:
    def findAllPeople(self, n: int, meetings: List[List[int]], firstPerson: int) -> List[int]:
        # 将 meetings 按时间排序
        meetings.sort(key=lambda x: x[2])

        # 根据时间先后,为每个时刻构建一个邻接表,置于列表中。考虑到本就是稀疏图并再次拆分,非常分散,故每个邻接表都用 hash map。
        multiAdj = []
        for i in range(len(meetings)):
            if i == 0 or meetings[i][2] > meetings[i-1][2]:
                multiAdj.append({})
            adj = multiAdj[-1]
            u, v, time = meetings[i]
            if u not in adj:
                adj[u] = []
            if v not in adj:
                adj[v] = []
            adj[u].append(v)
            adj[v].append(u)
        
        visited = [False] * n
        visited[0] = True
        visited[firstPerson] = True

        def dfs(u: int, adj: Dict[int, List[int]]):
            for v in adj.get(u, []):
                if not visited[v]:
                    visited[v] = True
                    dfs(v, adj)

        for adj in multiAdj:
            # 选取访问过的点 dfs 向外扩散,此处切忌遍历 0~n-1
            for p in adj.keys():
                if visited[p]:
                    dfs(p, adj)
        
        # 筛选访问过的点构成结果,即所有知晓秘密的专家
        return [i for i in range(n) if visited[i]]

###Java

class Solution {
    private void dfs(int u, Map<Integer, List<Integer>> adj, boolean[] visited) {
        if(adj.get(u).isEmpty()) return;

        for (int v : adj.get(u)) {
            if (!visited[v]) {
                visited[v] = true;
                dfs(v, adj, visited);
            }
        }
    }

    public List<Integer> findAllPeople(int n, int[][] meetings, int firstPerson) {
        // 将 meetings 按时间排序
        Arrays.sort(meetings, (a, b) -> Integer.compare(a[2], b[2]));

        // 根据时间先后,为每个时刻构建一个邻接表,置于列表中。考虑到本就是稀疏图并再次拆分,非常分散,故每个邻接表都用 hash map。
        List<Map<Integer, List<Integer>>> multiAdj = new ArrayList<>();
        for (int i = 0; i < meetings.length; i++) {
            if (i == 0 || meetings[i][2] > meetings[i - 1][2]) 
                multiAdj.add(new HashMap<>());
            Map<Integer, List<Integer>> adj = multiAdj.get(multiAdj.size() - 1);
            int u = meetings[i][0], v = meetings[i][1];
            adj.computeIfAbsent(u, k -> new ArrayList<>()).add(v);
            adj.computeIfAbsent(v, k -> new ArrayList<>()).add(u);
        }

        boolean[] visited = new boolean[n];
        visited[0] = true;
        visited[firstPerson] = true;

        for (Map<Integer, List<Integer>> adj : multiAdj) {
            // 选取访问过的点 dfs 向外扩散,此处切忌遍历 0~n-1
            for (int p : adj.keySet()) {
                if (visited[p]) 
                    dfs(p, adj, visited);
            }
        }

        // 筛选访问过的点构成结果,即所有知晓秘密的专家
        List<Integer> result = new ArrayList<>();
        for (int i = 0; i < n; i++) {
            if (visited[i]) result.add(i);
        }
        return result;
    }
}

###Kotlin

class Solution {
    fun findAllPeople(n: Int, meetings: Array<IntArray>, firstPerson: Int): List<Int> {
        // 将 meetings 按时间排序
        meetings.sortBy{ it[2] }

        // 根据时间先后,为每个时刻构建一个邻接表,置于列表中。考虑到本就是稀疏图并再次拆分,非常分散,故每个邻接表都用 hash map。
        val multiAdj = mutableListOf<MutableMap<Int, MutableList<Int>>>()
        for(i in meetings.indices){
            if(i == 0 || meetings[i][2] > meetings[i-1][2]) 
                multiAdj += mutableMapOf()
            val adj = multiAdj.last()
            val (u, v, time) = meetings[i]
            adj.getOrPut(u){ mutableListOf() } += v
            adj.getOrPut(v){ mutableListOf() } += u
        }
        
        val visited = BooleanArray(n)
        visited[0] = true 
        visited[firstPerson] = true

        fun dfs(u: Int, adj: Map<Int, MutableList<Int>>){
            for(v in adj[u] ?: return) 
                if(!visited[v]){
                    visited[v] = true 
                    dfs(v, adj)
                }   
        }

        for(adj in multiAdj){
            // 选取访问过的点 dfs 向外扩散,此处切忌遍历 0~n-1
            for(p in adj.keys){
                if(visited[p])
                    dfs(p, adj)            
            }
        }

        // 筛选访问过的点构成结果,即所有知晓秘密的专家
        return (0..<n).filter{ visited[it] }
    }
}

复杂度

$n$ 为点数(专家数量),$m$ 为边数(meetings 的长度)
考虑排序。

  • 时间复杂度: $\Theta(n + m\cdot log\ m)$
  • 空间复杂度: $\Theta(n + m)$

推广

以下皆为个人所著,兼顾了职场面试和本硕阶段的学术考试。

点赞关注不迷路。祝君早日上岸,飞黄腾达!

并查集+排序,Java双百详细题解

作者 acoreo
2021年11月28日 14:07

image.png

题意分析

一共有n个专家,最开始只有0号专家知道秘密,在时刻0他会把秘密分享给另一个专家。之后每次开会,知道秘密的专家都会把秘密分享出去。目标是求出最后时刻,一共有多少专家知道秘密。

解题思路

每次分享秘密,可以看作把两个专家合并到一个集合中,因此采用并查集求解。
特别的,本题的目标是求出祖先为0的节点有哪些。

最开始,每个专家的祖先节点记为自己,由于秘密传播是通过会议进行的,时间靠后的会议不可能传播到时间靠前的会议,因此需要先对meetings数组按照会议时间排序。
排序完成后,遍历所有时刻。同一时刻可能存在多场会议,由于秘密共享是瞬时发生的,且同一时刻的会议是乱序的,不存在先后,所以对每一时刻的处理分为两步:

  1. 第一轮遍历:首先判断两位专家中是否有人知道秘密,若有人知道秘密,则将两位专家的祖先节点都置为0。完成该操作后,无论两位专家是否有人知道秘密,都将两个专家合并,因为同一时刻的其他会议中,可能有其他知道秘密的专家将秘密分享给这两位中的任何一个,若存在此情况,则当前时刻过后,这两位专家也知道了秘密。
  2. 第二轮遍历:处理两种情况,
    • 场景一:第一轮遍历中,先遍历到某场会议,此时两位专家都不知道秘密,但在后面的遍历中,其中一位专家知道了秘密,由于上一步做了合并集合处理,此时将两位专家的祖先节点均置为0即可。
    • 场景二:第一轮遍历中,先遍历到某场会议,此时两位专家都不知道秘密,在后面的遍历中,这两位专家均没有被分享秘密,这时需要将两位专家从合并的集合中分离出来,如果不分离出来,在后面某时刻,如果这两位专家其中一个知道了秘密,那么会认为这两位专家都知道了秘密,但事实上,由于该时刻已过去,秘密无法分享给另一位专家。示例2即为此情况

解题技巧

由于需要按照时间对meetings数组进行升序排序,且之后要按照时间顺序进行遍历。所以考虑使用TreeMap对会议进行存储,key为时刻,value为当前时刻的会议列表。

详细代码

class Solution {
    
    // 并查集数组,记录每个元素的祖先节点
    public int[] p;
    
    // 查找每个元素的祖先,(路径压缩,并查集模板)
    public int find(int x) {
        if (p[x] != x) p[x] = find(p[x]);
        return p[x];
    }
        
    public List<Integer> findAllPeople(int n, int[][] meetings, int firstPerson) {
        p = new int[n+1];
        // 祖先数组初始化,将每个元素的祖先标记为自己
        for (int i = 1; i <= n; ++ i) p[i] = i;
        // 合并0号专家与firstPerson
        p[firstPerson] = 0;
        Map<Integer, List<int[]>> map = new TreeMap<>();
        // 构造以时刻为key,会议列表为value的Map,TreeMap将自动按照key升序排序
        for (int[] m : meetings) {
            // m[2]为会议时刻,每个时刻对应多场会议
            List<int[]> list = map.getOrDefault(m[2], new ArrayList<>());
            list.add(m);
            map.put(m[2], list);
        }
        // 对于每个时刻,遍历两次
        for (int x : map.keySet()) {
            // 第一轮遍历,合并集合
            for (int[] l : map.get(x)) {
                int a = l[0], b = l[1];                
                if (p[find(a)] == 0 || p[find(b)] == 0) { p[find(a)] = 0; p[find(b)] = 0; }
                p[find(b)] = p[find(a)];
            }
            // 第二轮遍历,分场景讨论
            for (int[] l : map.get(x)) {
                int a = l[0], b = l[1];
                // 场景一:两位专家在前面的会议均不知道秘密,后面遍历中其中一位专家知道了秘密,瞬时共享,两人都将知道秘密
                if (p[find(a)] == 0 || p[find(b)] == 0) { p[find(a)] = 0; p[find(b)] = 0; }
                // 场景二:两位专家在该时刻始终都不知道秘密,将合并的集合分离开,防止后面时刻有一个专家知道秘密,将秘密分享给另一个专家
                else { p[a] = a; p[b] = b; }
            }
        }       
        List<Integer> ans = new ArrayList<>();
        // 祖先为0的元素即为知道秘密的专家
        for (int i = 0; i < n; ++ i) {
            if (p[find(i)] == 0) ans.add(i);
        }        
        return ans;
    }
}

两种方法:DFS / 并查集(Python/Java/C++/Go)

作者 endlesscheng
2021年11月28日 12:06

分析

假设一开始 $0$ 和 $1$ 知道秘密。对比如下两种情况:

  • 时间 $1$,$1$ 和 $2$ 开会。时间 $2$,$2$ 和 $3$ 开会。秘密会传播给 $2$ 和 $3$,最终 $0,1,2,3$ 都知道秘密。
  • 时间 $1$,$2$ 和 $3$ 开会。时间 $2$,$1$ 和 $2$ 开会。第一场会议,参加会议的人都不知道秘密,所以秘密不会传播。秘密只会在第二场会议传播给 $2$,最终 $0,1,2$ 都知道秘密。

所以要按照开会的先后顺序传播秘密,模拟这个过程。

注意题目的这段话:

  • 秘密共享是瞬时发生的。也就是说,在同一时间,一个专家不光可以接收到秘密,还能在其他会议上与其他专家分享。

解读:在同一时间发生的所有会议,可以视作一个无向图。专家是图中的节点,$\textit{meetings}[i]$ 是图的边,连接 $x_i$ 和 $y_i$。

这个图可能有多个连通块。对于一个连通块,如果其中有知道秘密的专家,那么这个专家会把秘密分享给这个连通块中的其他专家。

方法一:DFS

  1. 把 $\textit{meetings}$ 按照 $\textit{time}$ 从小到大排序。
  2. 创建一个哈希集合(或者布尔数组),记录知道秘密的专家。
  3. 遍历 $\textit{meetings}$,对于在同一时间发生的所有会议,创建一个无向图。
  4. 遍历同一时间参加会议的专家列表,如果 $x$ 知道秘密且没有访问过,那么从 $x$ 出发,DFS $x$ 所在连通块,把连通块中的点都标记为知道秘密,且访问过。
  5. 最后,把知道秘密的专家放入答案列表,返回答案列表。

分组循环

如何遍历 $\textit{time}$ 相同的会议?分组循环。

适用场景:按照题目要求,数组会被分割成若干组,每一组的判断/处理逻辑是相同的。

核心思想

  • 外层循环负责遍历组之前的准备工作(记录开始位置等),和遍历组之后的工作(传播秘密)。
  • 内层循环负责遍历组,找出这一组最远在哪结束,同时建图。

这个写法的好处是,各个逻辑块分工明确,也不需要特判最后一组(易错点)。以我的经验,这个写法是所有写法中最不容易出 bug 的,推荐大家记住。

class Solution:
    def findAllPeople(self, _, meetings: List[List[int]], firstPerson: int) -> List[int]:
        # 按照 time 从小到大排序
        meetings.sort(key=lambda m: m[2])

        # 一开始 0 和 firstPerson 都知道秘密
        have_secret = {0, firstPerson}

        # 分组循环
        m = len(meetings)
        i = 0
        while i < m:
            # 在同一时间发生的会议,建图
            g = defaultdict(list)
            time = meetings[i][2]
            while i < m and meetings[i][2] == time:
                x, y, _ = meetings[i]
                g[x].append(y)
                g[y].append(x)
                i += 1

            # 每个连通块只要有一个人知道秘密,那么整个连通块的人都知道秘密
            vis = set()  # 避免重复访问节点

            def dfs(x: int) -> None:
                vis.add(x)
                have_secret.add(x)
                for y in g[x]:
                    if y not in vis:
                        dfs(y)

            # 遍历在 time 时间点参加会议的专家
            for x in g:
                # 从知道秘密的专家出发,DFS 标记其余专家
                if x in have_secret and x not in vis:
                    dfs(x)

        # 可以按任何顺序返回答案
        return list(have_secret)
class Solution {
    public List<Integer> findAllPeople(int n, int[][] meetings, int firstPerson) {
        // 按照 time 从小到大排序
        Arrays.sort(meetings, (a, b) -> a[2] - b[2]);

        // 一开始 0 和 firstPerson 都知道秘密
        Set<Integer> haveSecret = new HashSet<>();
        haveSecret.add(0);
        haveSecret.add(firstPerson);

        // 分组循环
        int m = meetings.length;
        for (int i = 0; i < m;) {
            // 在同一时间发生的会议,建图
            Map<Integer, List<Integer>> g = new HashMap<>();
            int time = meetings[i][2];
            for (; i < m && meetings[i][2] == time; i++) {
                int x = meetings[i][0];
                int y = meetings[i][1];
                g.computeIfAbsent(x, k -> new ArrayList<>()).add(y);
                g.computeIfAbsent(y, k -> new ArrayList<>()).add(x);
            }

            // 每个连通块只要有一个人知道秘密,那么整个连通块的人都知道秘密
            Set<Integer> vis = new HashSet<>(); // 避免重复访问节点
            for (int x : g.keySet()) {
                // 从知道秘密的专家出发,DFS 标记其余专家
                if (haveSecret.contains(x) && !vis.contains(x)) {
                    dfs(x, g, vis, haveSecret);
                }
            }
        }

        // 可以按任何顺序返回答案
        return new ArrayList<>(haveSecret);
    }

    private void dfs(int x, Map<Integer, List<Integer>> g, Set<Integer> vis, Set<Integer> haveSecret) {
        vis.add(x);
        haveSecret.add(x);
        for (int y : g.get(x)) {
            if (!vis.contains(y)) {
                dfs(y, g, vis, haveSecret);
            }
        }
    }
}
class Solution {
public:
    vector<int> findAllPeople(int, vector<vector<int>>& meetings, int firstPerson) {
        // 按照 time 从小到大排序
        ranges::sort(meetings, {}, [](auto& a) { return a[2]; });

        // 一开始 0 和 firstPerson 都知道秘密
        unordered_set<int> have_secret = {0, firstPerson};

        // 分组循环
        int m = meetings.size();
        for (int i = 0; i < m;) {
            // 在同一时间发生的会议,建图
            unordered_map<int, vector<int>> g;
            int time = meetings[i][2];
            for (; i < m && meetings[i][2] == time; i++) {
                int x = meetings[i][0], y = meetings[i][1];
                g[x].push_back(y);
                g[y].push_back(x);
            }

            // 每个连通块只要有一个人知道秘密,那么整个连通块的人都知道秘密
            unordered_set<int> vis; // 避免重复访问节点
            auto dfs = [&](this auto&& dfs, int x) -> void {
                vis.insert(x);
                have_secret.insert(x);
                for (int y : g[x]) {
                    if (!vis.contains(y)) {
                        dfs(y);
                    }
                }
            };
            for (auto& [x, _] : g) { // 遍历在 time 时间点参加会议的专家
                // 从知道秘密的专家出发,DFS 标记其余专家
                if (have_secret.contains(x) && !vis.contains(x)) {
                    dfs(x);
                }
            }
        }

        // 可以按任何顺序返回答案
        return vector(have_secret.begin(), have_secret.end());
    }
};
func findAllPeople(_ int, meetings [][]int, firstPerson int) []int {
// 按照 time 从小到大排序
slices.SortFunc(meetings, func(a, b []int) int { return a[2] - b[2] })

// 一开始 0 和 firstPerson 都知道秘密
haveSecret := map[int]bool{0: true, firstPerson: true}

// 分组循环
m := len(meetings)
for i := 0; i < m; {
// 在同一时间发生的会议,建图
g := map[int][]int{}
time := meetings[i][2]
for ; i < m && meetings[i][2] == time; i++ {
x, y := meetings[i][0], meetings[i][1]
g[x] = append(g[x], y)
g[y] = append(g[y], x)
}

// 每个连通块只要有一个人知道秘密,那么整个连通块的人都知道秘密
vis := map[int]bool{} // 避免重复访问节点
var dfs func(int)
dfs = func(x int) {
vis[x] = true
haveSecret[x] = true
for _, y := range g[x] {
if !vis[y] {
dfs(y)
}
}
}
for x := range g { // 遍历在 time 时间点参加会议的专家
// 从知道秘密的专家出发,DFS 标记其余专家
if haveSecret[x] && !vis[x] {
dfs(x)
}
}
}

// 可以按任何顺序返回答案
return slices.Collect(maps.Keys(haveSecret))
}

复杂度分析

  • 时间复杂度:$\mathcal{O}(m\log m)$,其中 $m$ 是 $\textit{meetings}$ 的长度。瓶颈在排序上。分组循环是 $\mathcal{O}(m)$ 的,每个 $\textit{meetings}[i]$ 恰好遍历一次。
  • 空间复杂度:$\mathcal{O}(m)$。

方法二:并查集

考虑用并查集,把参加会议的 $x$ 和 $y$ 合并到同一个集合中。一开始把 $\textit{firstPerson}$ 和 $0$ 合并。

但是,回顾这个例子:

  • 假设一开始 $0$ 和 $1$ 知道秘密。时间 $1$,$2$ 和 $3$ 开会。时间 $2$,$1$ 和 $2$ 开会。第一场会议,参加会议的人都不知道秘密,所以秘密不会传播。秘密只会在第二场会议传播给 $2$,最终 $0,1,2$ 都知道秘密。

如果用并查集合并 $0$ 和 $1$,$2$ 和 $3$,$1$ 和 $2$,最终发现 $0,1,2,3$ 都在同一个集合中,我们会误认为最终 $0,1,2,3$ 都知道秘密。

解决办法:第一场会议,把 $2$ 和 $3$ 合并。合并后,发现 $2$ 不和 $0$ 在同一个集合,说明 $2$ 不知道秘密,那么撤销合并,重置 $2$ 的代表元为 $2$(并查集的初始值)。$3$ 同理。

最后,和 $0$ 在同一个集合的专家(包括 $0$)就是知道秘密的专家。

关于并查集的完整模板,见 数据结构题单

class UnionFind:
    def __init__(self, n: int):
        # 一开始有 n 个集合 {0}, {1}, ..., {n-1}
        # 集合 i 的代表元是自己
        self.fa = list(range(n))  # 代表元

    # 返回 x 所在集合的代表元
    # 同时做路径压缩,也就是把 x 所在集合中的所有元素的 fa 都改成代表元
    def find(self, x: int) -> int:
        fa = self.fa
        # 如果 fa[x] == x,则表示 x 是代表元
        if fa[x] != x:
            fa[x] = self.find(fa[x])  # fa 改成代表元
        return fa[x]

    # 判断 x 和 y 是否在同一个集合
    def is_same(self, x: int, y: int) -> bool:
        # 如果 x 的代表元和 y 的代表元相同,那么 x 和 y 就在同一个集合
        # 这就是代表元的作用:用来快速判断两个元素是否在同一个集合
        return self.find(x) == self.find(y)

    # 把 from 所在集合合并到 to 所在集合中
    def merge(self, from_: int, to: int) -> None:
        x, y = self.find(from_), self.find(to)
        self.fa[x] = y  # 合并集合


class Solution:
    def findAllPeople(self, n: int, meetings: List[List[int]], firstPerson: int) -> List[int]:
        # 按照 time 从小到大排序
        meetings.sort(key=lambda x: x[2])

        uf = UnionFind(n)
        # 一开始 0 和 firstPerson 都知道秘密
        uf.merge(firstPerson, 0)

        # 分组循环
        m = len(meetings)
        i = 0
        while i < m:
            start = i
            time = meetings[i][2]
            # 合并在同一时间发生的会议
            while i < m and meetings[i][2] == time:
                x, y, _ = meetings[i]
                uf.merge(x, y)
                i += 1

            # 如果节点不和 0 在同一个集合,那么撤销合并,恢复成初始值
            for x, y, _ in meetings[start: i]:
                if not uf.is_same(x, 0):
                    uf.fa[x] = x
                if not uf.is_same(y, 0):
                    uf.fa[y] = y

        # 和 0 在同一个集合的专家都知道秘密
        return [i for i in range(n) if uf.is_same(i, 0)]
class UnionFind {
    private final int[] fa; // 代表元

    UnionFind(int n) {
        // 一开始有 n 个集合 {0}, {1}, ..., {n-1}
        // 集合 i 的代表元是自己
        fa = new int[n];
        for (int i = 0; i < n; i++) {
            fa[i] = i;
        }
    }

    // 返回 x 所在集合的代表元
    // 同时做路径压缩,也就是把 x 所在集合中的所有元素的 fa 都改成代表元
    public int find(int x) {
        // 如果 fa[x] == x,则表示 x 是代表元
        if (fa[x] != x) {
            fa[x] = find(fa[x]); // fa 改成代表元
        }
        return fa[x];
    }

    // 判断 x 和 y 是否在同一个集合
    public boolean isSame(int x, int y) {
        // 如果 x 的代表元和 y 的代表元相同,那么 x 和 y 就在同一个集合
        // 这就是代表元的作用:用来快速判断两个元素是否在同一个集合
        return find(x) == find(y);
    }

    // 把 from 所在集合合并到 to 所在集合中
    public void merge(int from, int to) {
        int x = find(from);
        int y = find(to);
        fa[x] = y; // 合并集合
    }

    public void reset(int x) {
        fa[x] = x;
    }
}

class Solution {
    public List<Integer> findAllPeople(int n, int[][] meetings, int firstPerson) {
        // 按照 time 从小到大排序
        Arrays.sort(meetings, (a, b) -> a[2] - b[2]);

        UnionFind uf = new UnionFind(n);
        // 一开始 0 和 firstPerson 都知道秘密
        uf.merge(firstPerson, 0);

        // 分组循环
        int m = meetings.length;
        for (int i = 0; i < m; ) {
            int start = i;
            int time = meetings[i][2];
            // 合并在同一时间发生的会议
            for (; i < m && meetings[i][2] == time; i++) {
                uf.merge(meetings[i][0], meetings[i][1]);
            }

            // 如果节点不和 0 在同一个集合,那么撤销合并,恢复成初始值
            for (int j = start; j < i; j++) {
                int x = meetings[j][0];
                int y = meetings[j][1];
                if (!uf.isSame(x, 0)) {
                    uf.reset(x);
                }
                if (!uf.isSame(y, 0)) {
                    uf.reset(y);
                }
            }
        }

        // 和 0 在同一个集合的专家都知道秘密
        List<Integer> ans = new ArrayList<>();
        for (int k = 0; k < n; k++) {
            if (uf.isSame(k, 0)) {
                ans.add(k);
            }
        }
        return ans;
    }
}
class UnionFind {
public:
    vector<int> fa; // 代表元

    UnionFind(int n) : fa(n) {
        // 一开始有 n 个集合 {0}, {1}, ..., {n-1}
        // 集合 i 的代表元是自己
        ranges::iota(fa, 0);
    }

    // 返回 x 所在集合的代表元
    // 同时做路径压缩,也就是把 x 所在集合中的所有元素的 fa 都改成代表元
    int find(int x) {
        // 如果 fa[x] == x,则表示 x 是代表元
        if (fa[x] != x) {
            fa[x] = find(fa[x]); // fa 改成代表元
        }
        return fa[x];
    }

    // 判断 x 和 y 是否在同一个集合
    bool is_same(int x, int y) {
        // 如果 x 的代表元和 y 的代表元相同,那么 x 和 y 就在同一个集合
        // 这就是代表元的作用:用来快速判断两个元素是否在同一个集合
        return find(x) == find(y);
    }

    // 把 from 所在集合合并到 to 所在集合中
    void merge(int from, int to) {
        int x = find(from), y = find(to);
        fa[x] = y; // 合并集合
    }
};

class Solution {
public:
    vector<int> findAllPeople(int n, vector<vector<int>>& meetings, int firstPerson) {
        // 按照 time 从小到大排序
        ranges::sort(meetings, {}, [](auto& a) { return a[2]; });

        UnionFind uf(n);
        // 一开始 0 和 firstPerson 都知道秘密
        uf.merge(firstPerson, 0);

        // 分组循环
        int m = meetings.size();
        for (int i = 0; i < m;) {
            int start = i;
            int time = meetings[i][2];
            // 合并在同一时间发生的会议
            for (; i < m && meetings[i][2] == time; i++) {
                uf.merge(meetings[i][0], meetings[i][1]);
            }

            // 如果节点不和 0 在同一个集合,那么撤销合并,恢复成初始值
            for (int j = start; j < i; j++) {
                int x = meetings[j][0], y = meetings[j][1];
                if (!uf.is_same(x, 0)) {
                    uf.fa[x] = x;
                }
                if (!uf.is_same(y, 0)) {
                    uf.fa[y] = y;
                }
            }
        }

        // 和 0 在同一个集合的专家都知道秘密
        vector<int> ans;
        for (int i = 0; i < n; i++) {
            if (uf.is_same(i, 0)) {
                ans.push_back(i);
            }
        }
        return ans;
    }
};
type unionFind struct {
fa []int // 代表元
}

func newUnionFind(n int) unionFind {
fa := make([]int, n)
// 一开始有 n 个集合 {0}, {1}, ..., {n-1}
// 集合 i 的代表元是自己
for i := range fa {
fa[i] = i
}
return unionFind{fa}
}

// 返回 x 所在集合的代表元
// 同时做路径压缩,也就是把 x 所在集合中的所有元素的 fa 都改成代表元
func (u unionFind) find(x int) int {
// 如果 fa[x] == x,则表示 x 是代表元
if u.fa[x] != x {
u.fa[x] = u.find(u.fa[x]) // fa 改成代表元
}
return u.fa[x]
}

// 判断 x 和 y 是否在同一个集合
func (u unionFind) same(x, y int) bool {
// 如果 x 的代表元和 y 的代表元相同,那么 x 和 y 就在同一个集合
// 这就是代表元的作用:用来快速判断两个元素是否在同一个集合
return u.find(x) == u.find(y)
}

// 把 from 所在集合合并到 to 所在集合中
func (u *unionFind) merge(from, to int) {
x, y := u.find(from), u.find(to)
u.fa[x] = y
}

func findAllPeople(n int, meetings [][]int, firstPerson int) (ans []int) {
// 按照 time 从小到大排序
slices.SortFunc(meetings, func(a, b []int) int { return a[2] - b[2] })

uf := newUnionFind(n)
// 一开始 0 和 firstPerson 都知道秘密
uf.merge(firstPerson, 0)

// 分组循环
m := len(meetings)
for i := 0; i < m; {
start := i
// 合并在同一时间发生的会议
time := meetings[i][2]
for ; i < m && meetings[i][2] == time; i++ {
uf.merge(meetings[i][0], meetings[i][1])
}

// 如果节点不和 0 在同一个集合,那么撤销合并,恢复成初始值
for j := start; j < i; j++ {
x, y := meetings[j][0], meetings[j][1]
if !uf.same(x, 0) {
uf.fa[x] = x
}
if !uf.same(y, 0) {
uf.fa[y] = y
}
}
}

// 和 0 在同一个集合的专家都知道秘密
for i := range n {
if uf.same(i, 0) {
ans = append(ans, i)
}
}
return
}

复杂度分析

  • 时间复杂度:$\mathcal{O}(n + m\log m)$,其中 $m$ 是 $\textit{meetings}$ 的长度。分组循环是 $\mathcal{O}(m)$ 的,每个 $\textit{meetings}[i]$ 恰好遍历两次。
  • 空间复杂度:$\mathcal{O}(n)$。忽略排序的栈开销。

专题训练

  1. 图论题单的「§1.1 深度优先搜索(DFS)」。
  2. 数据结构题单的「七、并查集」。
  3. 双指针题单的「六、分组循环」。

分类题单

如何科学刷题?

  1. 滑动窗口与双指针(定长/不定长/单序列/双序列/三指针/分组循环)
  2. 二分算法(二分答案/最小化最大值/最大化最小值/第K小)
  3. 单调栈(基础/矩形面积/贡献法/最小字典序)
  4. 网格图(DFS/BFS/综合应用)
  5. 位运算(基础/性质/拆位/试填/恒等式/思维)
  6. 图论算法(DFS/BFS/拓扑排序/基环树/最短路/最小生成树/网络流)
  7. 动态规划(入门/背包/划分/状态机/区间/状压/数位/数据结构优化/树形/博弈/概率期望)
  8. 常用数据结构(前缀和/差分/栈/队列/堆/字典树/并查集/树状数组/线段树)
  9. 数学算法(数论/组合/概率期望/博弈/计算几何/随机算法)
  10. 贪心与思维(基本贪心策略/反悔/区间/字典序/数学/思维/脑筋急转弯/构造)
  11. 链表、树与回溯(前后指针/快慢指针/DFS/BFS/直径/LCA)
  12. 字符串(KMP/Z函数/Manacher/字符串哈希/AC自动机/后缀数组/子序列自动机)

我的题解精选(已分类)

欢迎关注 B站@灵茶山艾府

昨天 — 2025年12月18日首页

后端拒写接口?前端硬核自救:纯前端实现静态资源下载全链路解析

2025年12月18日 22:05

背景

在日常开发中,我们经常遇到这样的场景:业务需求需要提供“导入模板下载”或“操作手册下载”功能。找后端同学要接口,对方却丢下一句:“这不就是个静态文件吗?你们前端自己存一下不就行了,没必要走接口。”

虽然听起来像是在推诿,但从资源利用和架构角度来看,对于纯静态、非敏感、无需鉴权的固定文件,前端自行托管确实是更高效的方案。它减轻了应用服务器的压力,利用了 CDN 或 Nginx 的静态资源分发能力。

本文将从工程实践底层原理,深入剖析如何在 Vue3 + Vite(或 Webpack)项目中优雅地实现这一功能。

一、 核心方案:目录存放策略

实现下载的第一步是决定文件存哪里。在现代前端工程(Vite/Webpack)中,通常有两个存放静态资源的地方:src/assetspublic(或 Vue CLI 时代的 static)。

1.1 src/assets vs public

特性 src/assets public
构建处理 经过 Bundler(Vite/Webpack)编译、压缩、Hash 重命名。 不经过编译,直接原样拷贝到输出目录。
引用方式 import 导入,得到的是打包后的 URL。 使用绝对路径字符串直接引用。
适用场景 组件内部引用的图片、样式、字体。 第三方库、favicon、以及我们要做的“下载文件”。

1.2 最佳实践

对于“下载文件”这种需求,强烈推荐使用 public 目录

理由如下:

  1. 文件名保持不变:用户下载的文件名就是你存放的文件名,不会变成 template.23a8f9.csv 这种带 Hash 的怪名字。
  2. 无需 Import:不需要在 JavaScript 中通过 import 引入文件对象,直接通过 URL 访问,逻辑更解耦。

目录结构示例:

my-project/
├── public/
│   ├── files/
│   │   ├── import_template.csv  <-- 存放在这里
│   │   └── manual.pdf
│   └── favicon.ico
├── src/
│   └── ...

二、 代码实现:动态路径与兼容性

决定了存放位置后,接下来是代码实现。看似简单,但有一个巨大的坑需要注意:部署路径(Public Path)

2.1 基础实现(有坑版)

如果你直接写死路径:

<a href="/files/import_template.csv" download="模板.csv">下载模板</a>

在本地开发(localhost:3000)没问题。但如果你的应用部署在子目录(例如 https://example.com/admin/),这个链接会指向 https://example.com/files/...,导致 404 Not Found

2.2 进阶实现(生产环境健壮版)

我们需要根据构建时的 基础路径(Base Path) 动态拼接 URL。

Vite + Vue3 实现:

<script setup lang="ts">
// 1. 获取环境变量中的 Base Path
// Vite 中通常配置在 vite.config.ts 的 base 属性,对应 import.meta.env.BASE_URL 或 VITE_PUBLIC_PATH
const publicPath = import.meta.env.VITE_PUBLIC_PATH || '/';

// 2. 拼接完整的下载链接
const templateUrl = `${publicPath}files/import_template.csv`;
</script>

<template>
  <!-- download 属性指示浏览器下载,而非导航 -->
  <a :href="templateUrl" download="导入模板.csv">
    下载模板
  </a>
</template>

这种写法无论项目部署在根路径还是 /sub-folder/ 下,都能正确找到文件。


三、 深度解析:Build 打包原理

为什么放在 public 目录下的文件,打包后就能通过 URL 访问?这涉及到构建工具的静态资源处理机制

3.1 Vite/Rollup 的处理流程

当你运行 npm run build 时,Vite(底层基于 Rollup)会执行以下操作:

  1. 编译源码:处理 src 目录下的 .vue, .ts, .js 等文件,生成 Bundles。
  2. 静态拷贝:Vite 默认会检查项目根目录下的 public 文件夹。
    • 它会将 public 文件夹内的所有内容原封不动地复制到构建输出目录(通常是 dist)的根目录下。
    • 这个过程不会对文件进行 Hash 处理,也不会修改文件名。

构建前:

/public/files/demo.csv

构建后(dist 目录):

/dist/index.html
/dist/assets/index.f8s7d9.js
/dist/files/demo.csv  <-- 原样存在

因此,Nginx 或静态服务器在托管 dist 目录时,客户端请求 /files/demo.csv,服务器就能直接找到该文件并返回。


四、 深度解析:浏览器下载原理

前端写了 <a download>,浏览器底层发生了什么?

4.1 触发下载的行为判定

当用户点击链接时,浏览器会根据以下优先级决定是预览还是下载

  1. download 属性(HTML5)

    • 如果在 <a> 标签上存在 download 属性,浏览器会尝试强制下载该资源,并使用属性值作为下载后的文件名。
    • 关键限制download 属性仅对同源 URL(Same-origin)或 blob:data: 协议有效。如果你的静态文件放在完全不同的 CDN 域名下,download 属性可能会失效,浏览器会退化为导航(预览)。
  2. Content-Disposition 响应头(HTTP 协议)

    • 这是服务端的“大杀器”。如果服务器响应头包含 Content-Disposition: attachment; filename="xxx.csv",无论前端怎么写,浏览器必须下载。
    • 对于前端托管的静态文件(Nginx 默认配置),通常没有这个头,所以主要依赖前端的 download 属性。
  3. MIME Type 嗅探

    • 如果没有上述强制下载标志,浏览器会检查文件的 MIME 类型。
    • 浏览器能识别的(如 application/pdf, image/jpeg, text/html):在当前窗口或新标签页预览
    • 浏览器不认识的(如 application/octet-stream, application/zip):默认下载

4.2 本文方案的生效链路

  1. 请求阶段:用户点击链接 -> 浏览器向服务器(Nginx)请求 /files/template.csv
  2. 响应阶段:Nginx 返回文件流,Content-Type 可能是 text/csvapplication/vnd.ms-excel
  3. 处理阶段:浏览器接收到响应,虽然它可能支持预览文本,但检测到了 <a> 标签上的 download 属性。
  4. 最终行为:浏览器忽略预览行为,弹出保存对话框(或直接保存),并将文件名重命名为 download 属性指定的值。

五、 小结

在后端不提供接口的情况下,前端利用 public 目录托管静态文件是一种标准且高效的工程化解法。

  • 实现简单:无需后端参与,纯前端闭环。
  • 性能优异:利用 Nginx/CDN 静态分发,速度快,不占用 API 计算资源。
  • 注意细节
    • 文件放入 public 目录以避免 Hash 重命名。
    • 代码中使用环境变量(import.meta.env.VITE_PUBLIC_PATH)拼接路径以支持子目录部署。
    • 利用 download 属性强制浏览器下载 PDF 等可预览文件。

掌握这一套流程,下次再遇到后端让你 “自己存一下” 时,你不仅能轻松搞定,还能顺便给他科普一下打包原理和浏览器行为。

实测豆包 Seedance 1.5 Pro:哪吒朱迪在线飙戏,复刻名场面,AI台词、音效水平大更新

作者 张子豪
2025年12月18日 21:10

AI 视频最近的玩法特别多,颇有上半年 AI 生图火起来的那种感觉。

▲ 视频来源:https://x.com/pabloprompt/status/2000706593579573301/

之前火过一遍的 AI 探班视频,随着模型能力的提升,现在又开始变成了社交媒体上的热门玩法。

不过彻底摒弃了以往复杂的工作流,有更好用的模型,甚至是简单几句提示词就能复刻,视频里的同款真实感。

视频生成模型的优化,不断地在降低,对我们人类提示词工程的依赖,同时还带来了更稳定的一致性保持。

豆包最近更新了新一代的音视频生成模型,豆包 Seedance 1.5 Pro,在音视频的生成上也有了明显的改善。现在它生成的视频,支持中文、英文、日文、韩语、西班牙语等语种的不同声韵,同时针对中文场景,还能生成四川话、粤语等方言。

不仅能说,而且还能模仿不同语言的口音。有声视频是 Seedance 1.5 Pro 的一大突破,在视频生成本身,结合音频的音画同步,以及电影级的运镜两项优化,让 AI 视频看起来更真实、更细致。

目前该模型已上线豆包 APP,只需要打开豆包,点击「照片动起来」,选择 1.5 Pro 模型,就能体验到 AI 生视频的快乐。此外,在火山引擎体验中心、即梦 AI 也可以体验。

我们也提前测试了一波,Seedance 1.5 Pro 完全可以说,是现在手边能拿起来直接用,能同时融合声音,表现最好的视频生成模型。

听听「臣妾做不到啊」的原音重现

《疯狂动物城 2 》上映之前,网友们对配音演员的选择,有很大的争议。现在 Seedance 1.5 Pro 的语音生成有多牛,我们可以看看之前网上很火的甄嬛传和让子弹飞,两个视频的配音,让它来完成是什么样。

从网上找了一张影视剧的截图,然后丢给豆包,我们甚至什么提示词都没有输入,它就能做到自动识别视频画面,生成一段有感情的台词戏。

▲在豆包 App 内,使用「照片动起来」,上传首帧,生成视频

皇后和张麻子都演得太像了,这和几个月前的视频生成模型,完全不是一个 Level。 以前那些 AI 视频,口型对不上,或者声音有机械感的问题,现在都解决了。

但普通话对它来说都是基本操作,方言的表现才是 Seedance 1.5 Pro 打败那些国外模型的独门秘籍。就像 Sora 2 和 Google Veo 3.1 虽然在画面生成上被认为是行业领先,但如果把上面这两张首帧图片丢给它们。Sora 和 Veo 3 都理解不了甄嬛传的经典台词,和张麻子这流利的四川话口音。

全运会刚结束,如果你也在广州,一定忘不了「活力大湾区,魅力新广州」这句魔性的口号。我们生成了一张站在广州塔前面的照片,然后在豆包「照片动起来」里面输入提示词。

画面里的这个男生正在面向镜头,向大家介绍他身后的广州塔,他用粤语说「活力大湾区,魅力新广州,我身后面嗰个就系广州塔喇!」

这个粤语水平怎么样,比多邻国里面的早茶四件套,虾饺、肠粉、烧卖、豉汁排骨,听着是不是要舒服一点。

而且,Seedance 1.5 Pro 有一个好处是「视听一致性」,意思是它能根据画面的内容,理解视频想要表达的故事,来自动生成对应的配音。

举个例子,当我们上传了一张明显是外国人的图片时,我们不输入任何提示词,它会自动使用英文来配音,并且让画面里的角色,说合适的台词。

即便是在中餐厅面馆里吃面的威尔·史密斯,Seedance 1.5 Pro 还是让他自动用英文来说话,而且这个吃面姿势也完全对了。

同样地,我们用它复刻了 AI 片场探班的视频,直接上传一张图片给豆包,不输入任何提示词,它会自动用中文来生成视频,还配上了台词,「哇,跟阿凡达合影啦!」

当我们重新生成时,Seedance 1.5 Pro 还把照片里的男生识别成韩国人,然后生成了一段讲韩语的视频。不过,说实话,他确实是有点韩国欧巴的感觉。

豆包视频生成还有一点特别好,是我们可以直接把生成的视频,下载为动图保存在手机。配合现在模型更强大的多模态理解能力,以及能生成更真实的画面,手机里那些静态的图片,让它们「真实地」动起来,然后发到微信朋友圈,可能真的会有人看不出来。

AI 巨人照加上无人机运镜,太酷啦

叙事是 Seedance 1.5 Pro 更新的一个关键词,它的意思是这些 AI 视频不只是单纯的生成,而是有了一定的故事感,能够对要表达的内容进行理解,让 AI 生成的视频,更像是一个有血肉的作品。

一个好的视频作品,灯光色彩、音效要出色。技术性的工作也少不了,运镜就是在音画之外,不可忽视的镜头语言。

Seedance 1.5 Pro 在这次更新里,在长镜头跟随、希区柯克变焦这些电影级运镜都有了大幅度的提升。

像是之前我们做的子弹时间,现在上传一张图片到豆包,调整一下提示词,子弹时间特效也自由了。

▲提示词:子弹时间效果。时间完全冻结。舞者悬浮在半空中,对抗重力。[定格画面]:舞者、她的头发和她的蓝色裙子绝对静止,就像时间冻结中的 3D 雕像。摄像机围绕悬浮的舞者水平轨道运行。背景建筑物改变透视(视差),而舞者保持锁定在中心。头发保持僵硬并指向上方,没有飘动。裙子布料是固体的并冻结保持不动。 电影级照明,高质量。

我们把同样的照片交给 Veo 3.1 处理时,它生成的子弹时间也很难做到保持角色一动不动。因为对大多数视频生成模型来说,识别到头发,就一定要飘动;看到裙边也要摆动;所以精准的运镜控制和调度,也是区分不同模型的一项重要能力。

还有这个前段时间很火的 AI 巨人照,现在我们也可以用超酷炫的无人机俯冲和穿越运镜,来凸显视频里的巨人。

▲提示词:电影级 FPV 无人机镜头,极致动态运镜:从高空鸟瞰开始,无人机急速俯冲向一位坐在城市街道中央的巨人,红砖建筑环绕两侧。巨人保持完全静止的姿势,身体、头部、四肢均不移动,如同雕塑般定格。无人机以特技飞行动作环绕巨人静止的身体——盘旋绕腿、从手臂下穿越、沿躯干螺旋上升,然后拉远展现巨人与微小车辆(红色双层巴士、黑色出租车)和行人的尺寸对比。超写实合成。比例 16:9,时长 5s,模型 1.5 Pro。

从参考图转视频,能更好的控制视频的输出效果。但 Seedance 1.5 Pro 的文生视频能力也毫不逊色。

根据字节公开的 Seedance 1.5 pro,在内部基准测试 SeedVideoBench-1.5 的模型表现结果,显示无论是 T2V 文生视频,还是 I2V 首帧转视频,和可灵 2.6、Google Veo 3.1 等模型对比,Seedance 1.5 Pro 的表现都有一定优势。

尤其是在音频生成和音画同步上,Seedance 1.5 Pro 几乎是碾压性的存在。

我们尝试让疯狂动物城朱迪和哪吒一起,一个普通话,一个四川话,演了一出 10s 的小剧场。

▲提示词:[0s-4s] 朱迪指着哪吒说(普通话,语速快,严肃): “那个小孩!站住!双手抱头!根据《动物城交通法》,你刚才风火轮超速了!” • [5s-10s] 哪吒(四川话,翻白眼,语速慢,拖长音): “哎呀,莫挨老子!我是踩的风火轮,又没烧你的油。瓜娃子,管得宽!”

这个视频的风格和内容,和我们平时看的动画片风格真的很类似。当义正辞严的兔朱迪警官,抓到哪吒的时候,那严肃的表情和语气;还有哪吒用四川话说台词,也能对上嘴型。

APPSO 今年前前后后也测试了有十多款 AI 视频生成的模型,我们在使用的过程中,发现很多以前的测试案例,放到现在已经是 Out 了。

一开始是鲁迅来了,都得让他说两句英文;能生成一个 5s 流畅播放的视频,就谢天谢地。现在的模型,不仅支持中、英、日、韩等多语种,广东话、四川话这些特色方言都能同步生成。

恍然间,AI 视频的进化,已经从按年计算变成了按月计算。昨天的突破,今天就是及格线。

▲ Seedance 1.5 Pro 案例截图|来源:字节跳动 Seed 官网

Seedance 1.5 Pro 这次更新,可能又会变成新的及格线。但至少现在我们看到了,有了音画同步后更有感染力的视频;多语种和方言的支持,也让 AI 视频更有「生活味」;专业的电影级运镜和智能理解能力,让一些高难度的复杂场景,也有机会通过 AI 生成。

当技术能够理解画面背后的故事,自动匹配合适的语言和情绪,我们距离想象力和创作自由的时代,又近了一大步。

实现这一切需要什么? 一张图片或者一句提示词。

打开豆包 APP,上传/输入,生成,就这么简单。每张照片都是待激活的故事,每次上传都是创作的开始。

步骤越少,门槛越低,创作者越多,用 AI 视频实现创意就该是这样。

文章内视频可点击该链接前往观看:https://mp.weixin.qq.com/s/em_E90Q7AdydHsNwVkAMTQ

#欢迎关注爱范儿官方微信公众号:爱范儿(微信号:ifanr),更多精彩内容第一时间为您奉上。

爱范儿 | 原文链接 · 查看评论 · 新浪微博


Coco AI 技术演进:Shadcn UI + Tailwind CSS v4.0 深度迁移指南 (踩坑实录)

2025年12月18日 21:07

摘要:本文深度复盘了 Coco AI 项目在引入 shadcn/ui 组件库的同时,激进升级至 Tailwind CSS 4.0 的技术细节。重点剖析了在 Vite + Tsup (Esbuild) 双构建工具链下的兼容性方案,以及如何处理 tailwind.config.js 与 CSS-first 配置模式的冲突,为维护大型遗留项目的开发者提供一份“硬核”避坑指南。

前言:为什么要自找麻烦?

在 Coco AI 的开发过程中,我们面临着大多数成长期项目都会遇到的痛点:

  1. UI 碎片化:早期的手写 CSS 与后期的 Tailwind Utility Class 混杂,维护成本极高。
  2. 重复造轮子:为了一个带键盘导航的 Dropdown,我们可能写了 500 行代码,且 Bug 频出。

引入 shadcn/ui 是为了解决组件复用问题,而升级 Tailwind CSS v4.0 则是为了追求极致的构建性能(Rust 引擎)。当这两者在这个拥有大量遗留代码的项目中相遇时,一场“构建工程化的风暴”不可避免。

本文不谈虚的,直接上干货。

难点一:Vite 与 Tsup 的“双轨制”构建困局

Coco AI 不仅是一个 Web 应用,还包含一个对外提供的 SDK。这就导致我们有两套构建流程:

  • Web App: 使用 Vite (Rollup)。
  • Web SDK: 使用 Tsup (Esbuild)。

Tailwind v4 推荐使用 @tailwindcss/vite 插件,这在 Web App 中运行良好。但在 SDK 构建中,Esbuild 并不支持该插件。

解决方案:混合编译策略

我们被迫采用了一套“混合”方案:Web 端享受 v4 的插件红利,SDK 端则回退到 PostCSS 处理。

1. Web 端 (Vite)

一切从简,使用官方插件。

// vite.config.ts
import tailwindcss from "@tailwindcss/vite";
import react from "@vitejs/plugin-react";

export default defineConfig({
  // 这里的 tailwindcss() 会自动扫描文件,性能极快
  plugins: [tailwindcss() as any, react()],
});

2. SDK 端 (Tsup/PostCSS)

这是最坑的地方。Tsup 基于 Esbuild,而 Esbuild 默认无法解析 v4 的 @import "tailwindcss";。我们需要手动配置 PostCSS 管道。

首先,配置 postcss.config.js,显式使用 v4 的 PostCSS 插件:

// postcss.config.js
export default {
  plugins: {
    // ⚠️ 注意:Tailwind v4 的 PostCSS 插件包名变了
    '@tailwindcss/postcss': {}, 
    autoprefixer: {},
  },
}

然后,在 tsup.config.ts 中施展“魔法”:

// tsup.config.ts
export default defineConfig({
  esbuildOptions(options) {
    // 🔥 关键黑魔法:启用 'style' 条件,让 esbuild 能找到 tailwindcss 的入口
    (options as any).conditions = ["style", "browser", "module", "default"];
  },
  async onSuccess() {
    // 构建后手动运行 PostCSS,处理 CSS 文件中的 @import "tailwindcss"
    // ...代码略,见源码...
  }
});

难点二:JS 配置与 CSS 配置的“博弈”

Tailwind v4 推崇 CSS-first,即把配置都写在 CSS 的 @theme 块中。但 shadcn/ui 强依赖 tailwindcss-animate 插件,且我们有大量复杂的自定义动画(如打字机效果、震动效果)写在 tailwind.config.js 中。

如果完全迁移到 CSS,工作量巨大且易出错。

解决方案:JS 与 CSS 共存

我们保留了 tailwind.config.js,主要用于存放插件复杂动画,而将颜色变量迁移到 CSS 中。

保留的 tailwind.config.js (部分)

import animate from "tailwindcss-animate";

export default {
  // v4 会自动检测并合并这个配置
  theme: {
    extend: {
      // 复杂的 Keyframes 还是写在这里比较清晰
      animation: {
        typing: "typing 1.5s ease-in-out infinite",
        shake: "shake 0.5s ease-in-out",
      },
      keyframes: {
        typing: {
          "0%": { opacity: "0.3" },
          "50%": { opacity: "1" },
          "100%": { opacity: "0.3" },
        },
        // ...
      },
      // 映射 border-radius 到 CSS 变量,适配 shadcn
      borderRadius: {
        lg: "var(--radius)",
        md: "calc(var(--radius) - 2px)",
        sm: "calc(var(--radius) - 4px)",
      },
    },
  },
  plugins: [animate], // shadcn 必需的插件
};

新的 src/main.css (v4 风格)

@import "tailwindcss";

/* ⚠️ 必坑点:显式指定扫描源,否则可能漏掉 HTML 或特定目录 */
@source "../index.html";
@source "./**/*.{ts,tsx}";

@theme {
  /* 在 CSS 中通过变量映射颜色,不仅支持 shadcn,还能兼容旧代码 */
  --color-background: var(--background);
  --color-primary: var(--primary);
  /* ... */
}

难点三:颜色空间与暗色模式的“大一统”

Coco AI 的旧代码使用 RGB 值(如 rgb(149, 5, 153)),而 shadcn 使用 HSL(如 222.2 84% 4.9%),Tailwind v4 默认又倾向 OKLCH。

解决方案:变量映射层

我们在 main.css 中建立了一个“中间层”,让新老变量和谐共存。

:root {
  /* === Shadcn 系统 (HSL) === */
  --primary: 221.2 83.2% 53.3%;
  
  /* === Coco Legacy 系统 (RGB) === */
  /* 即使是旧变量,也可以根据需要调整,或者直接硬编码保留 */
  --coco-primary-color: rgb(149, 5, 153);
}

/* ⚠️ v4 暗色模式新语法:废弃了 darkMode: 'class' */
@custom-variant dark (&:where(.dark, .dark *));
@custom-variant dark (&:where([data-theme="dark"], [data-theme="dark"] *));

.dark.coco-container,
[data-theme="dark"] {
  /* 重新定义 HSL 值实现暗色模式 */
  --background: 222.2 84% 4.9%;
  
  /* 同时覆盖旧系统的变量 */
  --coco-primary-color: rgb(149, 5, 153);
}

难点四:Web SDK 的 CSS 变量兼容性黑科技

在开发 Web SDK 时,我们遇到一个隐蔽的问题:CSS 变量的初始值丢失

Tailwind v4 会生成大量的 CSS Houdini @property 规则来定义变量的类型和初始值:

@property --tw-translate-x {
  syntax: "*";
  inherits: false;
  initial-value: 0;
}

这在现代浏览器中运行完美。但由于我们的 SDK 会被嵌入到各种宿主环境中,部分环境可能不支持 @property,导致变量因为没有显式的赋值而失效(initial-value 被忽略)。

解决方案:构建后脚本补全 (Post-build Script)

为了保证“即插即用”的稳定性,我们编写了一个专门的构建后处理脚本 scripts/buildWebAfter.ts

它的作用是:扫描生成的 CSS,提取所有 @propertyinitial-value,并将它们显式注入到 .coco-container 作用域中。

// scripts/buildWebAfter.ts (精简版)
const extractCssVars = () => {
  const cssContent = readFileSync(filePath, "utf-8");
  const vars: Record<string, string> = {};
  
  // 正则提取所有 @property 的 initial-value
  const propertyBlockRegex = /@property\s+(--[\w-]+)\s*\{([\s\S]*?)\}/g;
  while ((match = propertyBlockRegex.exec(cssContent))) {
    const initialValueMatch = /initial-value\s*:\s*([^;]+);/.exec(match[2]);
    if (initialValueMatch) {
      vars[match[1]] = initialValueMatch[1].trim();
    }
  }

  // 生成标准的 CSS 变量赋值块
  const cssVarsBlock =
    `.coco-container {\n` +
    Object.entries(vars)
      .map(([k, v]) => `  ${k}: ${v};`) // 显式赋值:--var: value;
      .join("\n") +
    `\n}\n`;

  writeFileSync(filePath, `${cssContent}\n${cssVarsBlock}`, "utf-8");
};

效果:即使浏览器不支持 @property,变量也能通过标准的 CSS 级联机制获得正确的初始值,确保 SDK 在任何环境下样式都不崩坏。

避坑清单 (Checklist)

在迁移过程中,我们踩了无数坑,以下是血泪总结:

  1. 样式莫名丢失?

    • 原因:Tailwind v4 的自动扫描可能没覆盖到你的文件结构。
    • 解法:使用 @source 指令显式添加路径,如 @source "./**/*.{ts,tsx}";
  2. VS Code 满屏报错?

    • 原因:VS Code 的 Tailwind 插件版本过低,不认识 @theme@source 等新指令。
    • 解法:升级插件到最新版,并确保设置中关联了正确的文件类型。
  3. 构建时报错 Cannot find module

    • 原因postcss.config.js 中引用了不存在的插件。
    • 解法:确认安装了 @tailwindcss/postcss 并在配置中正确引用(注意包名变化)。
  4. 动画不生效?

    • 原因tailwind.config.js 未被 Vite 插件读取。
    • 解法:在使用 @tailwindcss/vite 时,它通常会自动检测根目录下的配置文件。如果位置特殊,需手动指定。

小结

技术债是还不完的,但每一次还债都是一次成长的机会。

通过这次适配,Coco AI 不仅拥有了更现代化的 UI 架构,也为未来的跨平台(Web/Desktop/Mobile)统一体验打下了基础。特别是 Tailwind CSS v4.0 的引入,虽然初期配置略显折腾,但其带来的构建速度提升和开发体验优化,绝对是“真香”定律的又一次验证。

如果你也想体验一下这个“整容”后的全能生产力工具,欢迎来我们的 GitHub 看看:

1V1 社交精准收割 3.6 亿!40 款马甲包 + 国内社交难度堪比史诗级!

作者 iOS研究院
2025年12月18日 21:06

背景

“她说明年就结婚,转头就把我拉黑了!”2024 年 9 月,山东鱼台县居民王某攥着手机账单冲进警局,声音颤抖。这位常年打工攒下 5 万积蓄的单身汉,从未想过自己在 “念梦”“冬梦” 两款交友 App 上邂逅的 “化妆品店老板娘”,竟是一场精心设计的骗局。

三个月里,这位昵称 “为你而来” 的 “女神” 温柔体贴,频频描绘二人未来的家,却以 “解锁视频聊天”“线下见面需充值刷亲密度” 为由,分三次榨干了他的全部积蓄。当王某停止充值后,昔日热情的恋人瞬间蒸发,只留下 27177 元、9592 元、13794 元三笔冰冷的充值记录。他不知道的是,自己只是这场 3.6 亿诈骗大案中,上千名受害者之一。

40 款马甲包背后:堪比上市公司的诈骗 “工厂”

山东济宁公安破获特大网络交友诈骗案,40余款App全是陷阱。王某的报警,像一把钥匙打开了潘多拉魔盒。警方顺着涉诈 App 的线索深挖,一个隐藏在合法公司外壳下的犯罪集团逐渐浮出水面。团伙头目王某某是正规大学毕业生,曾因运营 “来遇” App 涉诈被查处,却在 2023 年卷土重来,注册多家空壳公司,一口气推出 40 余款交友 App,形成 “换汤不换药” 的马甲矩阵。

这个诈骗团伙的运作模式堪称 “产业化”:运营部负责招募培训 5000 余名女聊手,定制从 “初遇暧昧” 到 “诱导充值” 的全套话术;客服部专门安抚投诉用户,用 “系统维护”“亲密度未达标” 等借口掩盖骗局;甚至设立法务部,钻法律空子规避监管。女聊手们则按照统一剧本,虚构 “单身富婆”“温柔贤妻” 等人设,精准瞄准三、四线城市的大龄单身男性,用暧昧言语和虚假承诺编织情感牢笼。

更令人咋舌的是平台设计的 “吸血机制”:文字消息 10-100 金币 / 条,视频通话 100-2000 金币 / 分钟,充值 1 元仅能兑换 100 金币。女聊手与公司按 4:6 分成,为了多赚钱,她们会用平台发放的免费金币给用户刷礼物,制造 “双向奔赴” 的假象,引诱受害者不断充值。警方后续查获的聊天记录显示,团伙内部流传着 “养鱼玩法拉高点,大哥刷一你刷两” 的黑暗话术。

62 亿条数据剥茧:千人跨省追缉 15 天破局

“这不是零散诈骗,是有组织、有预谋的犯罪网络。” 济宁市公安局迅速成立 “10.14” 专案组,抽调百余名警力攻坚。面对团伙设置的多层数据加密、定期删除证据、核心骨干分散办公等障碍,民警自主编写分析程序,从 8T 容量、超 62 亿条聊天记录和资金明细中抽丝剥茧。

合规化势在必行

立足当前行业大环境,存量社交产品必须将合规化置于开发工作的核心首位。

若不存在关键性的功能迭代需求,建议尽量减少版本更新频次,甚至暂停更新,以此规避审核环节可能出现的风险,避免给产品运营增添不必要的阻碍。

当前国内市场的恶性竞争态势,必然会导致社交类产品在App Store平台面临更严峻的监管压力与发展困境。因此,尽早布局出海业务、开拓海外新市场,已成这类产品突破发展瓶颈的关键方向

合规化的价值懂的无需多言,不懂得多说无益。

遵守规则,方得长治久安,最后祝大家大吉大利,今晚过审!

相关推荐

# 苹果开发者续费大坑及成功续费方案!亲测有效

# AppStore敏感词排查手册,多维度分析Guideline 2.3.1隐藏功能,轻松过审。

# 如何主动提防苹果3.2f的进攻,自查防御手册(代码篇)

# 如何主动提防苹果3.2f的进攻,自查防御手册(ASO篇)

# 苹果加急审核是“绿色通道”还是“死亡陷阱”?

# 苹果开发者邮箱,突然收到11.2通知严重么?

# 不想被苹果卡审最好错开这两个提审时间

# 手撕苹果审核4.3是代码问题还是设计问题?

# 有幸和Appstore审核人员进行了一场视频会议特此记录。

空中客车获西班牙政府100架直升机订单

2025年12月18日 20:53
12月18日,空中客车公司发表声明称,西班牙正在通过国防部军备和物资总局订购100架空客直升机。该订单涵盖四种不同型号的直升机,将交付给西班牙武装力量的三个分支军种。(界面)

中集集团:拟增加3亿元港币额度回购部分H股股份

2025年12月18日 20:50
36氪获悉,中集集团公告,公司拟在2024年度股东大会授权额度内增加H股回购份额,新增港币3亿元用于第二批H股回购。回购的H股股份将作为库存股持有,并在完成回购并披露回购结果公告后三年内完成转让或注销。回购资金来源为公司自有资金、自筹资金或符合法律法规要求的资金。回购数量与第一批H股股份回购方案下可回购的H股股份数合计不超过公司已发行的H股总股本的10%。

旭升集团:控股股东筹划控制权变更事项,股票停牌

2025年12月18日 20:45
36氪获悉,旭升集团公告,公司接到控股股东、实际控制人徐旭东通知,徐旭东及其一致行动人正在筹划涉及所持有公司股份转让事宜,可能导致公司控制权变更。为避免对公司股价造成重大影响,公司股票自2025年12月19日起停牌,预计停牌时间不超过2个交易日。停牌期间,公司将根据事项进展情况履行信息披露义务。

恒大汽车:股票继续停牌

2025年12月18日 20:37
36氪获悉,恒大汽车在港交所公告,于2025年11月22日,公司获悉新闻报道称相关附属公司的登记股东已由集团成员变更为广州聚力现代产业发展有限公司(简称“广州聚力”)。经公司查询后,管理人确认,根据相关地方人民法院批准的破产重整计划,集团于相关附属公司的股权获注销及相关附属公司的全部股权乃于2025年11月18日以广州聚力名义登记。由于该计划及相关附属公司的股东有关变更乃该等程序的结果,集团并无收取任何现金代价。公司股票继续停牌。

盐城超算与太初元碁续约

2025年12月18日 20:35
12月18日,盐城超级计算中心“国家新一代人工智能公共算力开放创新平台”正式揭牌。在揭牌仪式上,盐城超算启动二期项目建设规划。根据规划,盐城超算与太初(无锡)电子科技有限公司续约,构建新一代超智融合计算系统。

Tauri (21)——窗口缩放后的”失焦惊魂”,游戏控制权丢失了

2025年12月18日 20:01

背景

在上一篇文章中,我们分享了如何在 Coco AI 中实现丝滑的 NSPanel 窗口全屏体验。然而,全屏只是第一步,真正的挑战往往隐藏在细节之中。

Coco AI 的核心亮点之一是其日渐强大的插件生态Extensions)。用户可以通过安装插件,在 Coco 的悬浮窗中直接运行各种 Web 应用,甚至玩 Doom 这样的小游戏。

但我们在开发过程中遇到了一个非常影响心情的 Bug:

当用户正沉浸在游戏中,觉得窗口太小而点击“全屏”后,突然发现键盘失灵了——WASD 怎么按都没反应,手动点画面也不能继续操作...

对于一款追求极致体验的生产力工具来说,这种“断触”是不可接受的。今天我们就来深度复盘这个**焦点丢失(Focus Loss)**问题,以及我们在 Coco App 中的“组合拳”解决方案。

场景复现

  1. 用户在 Coco AI 中启动了一个游戏插件(通过 iframe 加载)。
  2. 初始窗口较小,用户通过 WASD 控制角色移动,一切正常。
  3. 用户点击右上角的“全屏”按钮,希望获得沉浸体验。
  4. 窗口瞬间变大铺满屏幕,但此时按下 W 键,角色纹丝不动。
  5. 用户拿起鼠标不断的点击一下游戏画面,控制权也未能恢复。

为什么会失焦?

这个问题的根源在于 DOM 树的重建和窗口系统的焦点管理机制,特别是在 React + Tauri 的混合架构下:

  1. DOM 重绘/重排:当窗口从悬浮模式切换到全屏模式时,React 组件可能会因为状态变化(如 scale 缩放系数改变、layout 模式切换)而重新渲染。如果 iframe 在这个过程中被卸载并重新挂载,它就是一个全新的 iframe,之前的焦点自然荡然无存。
  2. Native 窗口焦点转移:调用 Tauri 的 setWindowSizesetFullscreen 等底层 API 时,操作系统可能会暂时把焦点从 WebView 内容区域移开,转移到窗口边框或系统层级。
  3. Iframe 的隔离性iframe 内部是一个独立的 window 上下文。主文档(Parent)获得焦点并不意味着 iframe 获得焦点。你需要显式地把焦点“传递”进去。

Coco AI 的解决方案:全方位焦点守护

为了确保用户体验的连贯性,我们在 ViewExtension.tsx 组件中实施了一套多层级的焦点管理策略。

第一招:组件挂载后的自动聚焦

在 React 中,利用 refonLoad 事件,确保插件加载完毕的那一刻,焦点就自动锁定在它身上。

<div
  // 绑定 Ref
  ref={iframeRef}
  // 任何点击外层容器的操作,都把焦点送给 iframe
  onClickCapture={() => {
    iframeRef.current?.focus();
  }}
>
  <iframe
    ref={iframeRef}
    src={fileUrl}
    // Iframe 加载完毕瞬间聚焦
    onLoad={(event) => {
      event.currentTarget.focus();
      try {
        // 尝试深入聚焦到 iframe 内部的 window
        iframeRef.current?.contentWindow?.focus();
      } catch (e) {
        console.warn("Failed to focus iframe content:", e);
      }
    }}
    // 允许必要的权限:全屏、鼠标锁定(FPS游戏必备)、手柄
    allow="fullscreen; pointer-lock; gamepad"
    tabIndex={-1}
  />
</div>

第二招:状态变化后的延迟聚焦

当你执行全屏或缩放操作后,Native 层的窗口调整是异步的,React 的渲染也是异步的。如果你立即调用 focus(),可能 DOM 还没稳,或者窗口还在动画中,导致聚焦失败。

我们的秘诀是 setTimeout,等待一轮事件循环:

const applyFullscreen = useCallback(async (next: boolean) => {
  // ... 执行窗口大小调整逻辑 ...
  
  // 等待系统窗口调整完成且 React 渲染完毕
  setTimeout(() => {
    // 1. 聚焦 iframe 元素本身
    iframeRef.current?.focus();
    try {
      // 2. 尝试聚焦 iframe 内部内容(处理跨域限制时可能报错,加 try-catch)
      iframeRef.current?.contentWindow?.focus();
    } catch {}
  }, 0);
}, []);

第三招:显式的“焦点救生圈”

为了应对浏览器安全策略限制脚本自动聚焦等极端情况,我们在 UI 上设计了一个显式的 Focus 按钮。这不仅是一个功能补救,也是一个视觉提示。

{/* Focus helper button */}
<button
  aria-label="Focus Game"
  className="absolute top-2 right-12 z-10 p-2 bg-black/50 hover:bg-black/70 rounded text-white transition-colors"
  onClick={() => {
    iframeRef.current?.focus();
    try {
      iframeRef.current?.contentWindow?.focus();
    } catch {}
  }}
>
  <FocusIcon className="size-4"/>
</button>

当用户发现控制失灵时,潜意识会寻找界面上的交互点,点击这个按钮就能瞬间找回焦点。

第四招:事件捕获(Capture Phase)

有时候用户点击了窗口边缘的空白区域(padding 或 margin),焦点会跑回主文档 body。我们可以通过在容器上监听 onMouseDownCapture 来拦截这些点击,强行把焦点按回 iframe

<div
  className="w-full h-full flex flex-col items-center justify-center"
  // 捕获阶段拦截,比冒泡更早
  onMouseDownCapture={() => {
    iframeRef.current?.focus();
  }}
  onPointerDown={() => {
    iframeRef.current?.focus();
  }}
>
  <iframe ... />
</div>

小结

焦点管理看似简单,但在构建像 Coco AI 这样复杂的桌面+Web 混合应用时,它直接关系到用户的沉浸感。通过这套 “主动出击 + 纵深防御 + 异步等待 + 兜底方案” 的组合拳,我们成功解决了跨平台、跨窗口尺寸下的焦点丢失问题。

现在,无论是在写代码时快速查阅文档,还是在休息时玩一把小游戏,Coco AI 都能提供无缝、流畅的交互体验。

如果你对我们的技术细节感兴趣,或者想体验一下这款全能的生产力工具,欢迎访问我们的开源仓库和官网:

❌
❌