阅读视图

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

恒生科技指数盘中涨超3%

36氪获悉,恒生科技指数盘中涨超3%,恒生指数涨1.54%,快手涨超7%,华虹半导体涨超6%,腾讯音乐、金山软件、百度均涨超5%。

印尼考虑对16岁以下人群实施“网购禁令”

印度尼西亚政府官员6日说,正在考虑对16岁以下人群实施“网购禁令”,以避免未成年人在网购平台遭遇诈骗。印尼通信和数字事务部长默蒂娅·哈菲兹当天接受法新社采访时作出这一表态。她表示,印尼出台针对16岁以下人群的社交媒体禁令后,接下来将把禁令扩展至购物网站。“电子商务平台将是下一个,因为我们发现有孩子在网购时成为诈骗案受害者。”不过,她没有说明“网购禁令”的详细方案。(财联社)

Vue创建一个简单的Agent聊天

创建项目

用的为vue3.6测试版本

npm create vue@latest

什么是Agent?

就是大语言模型+记忆+工具调用

  • 大语言模型:大脑,会思考、理解、规划、推理

  • 记忆:短期对话记忆 + 长期知识库,知道上下文、历史人设、过往任务

  • 工具调用:会联网搜索、查文件、写代码、调接口、操作插件

安装依赖

  npm install @langchain/core @langchain/langgraph @langchain/openai

@langchain/core

LangChain 核心底座

  • 提供统一接口:LLM、Prompt、Runnable、回调、工具
  • 所有 LangChain 生态都依赖它
  • 相当于底层操作系统

@langchain/openai

对接 OpenAI 大模型

  • 让你能调用 gpt-4o, gpt-3.5-turbo
  • 支持结构化输出、工具调用(Tool Calling)
  • 就是 Agent 的大脑

@langchain/langgraph

做 Agent / 智能体的框架

  • 管理:记忆、状态、工具调用、多步骤流程
  • 支持:循环思考、决策、多 Agent 协作
  • 你说的「LLM + 记忆 + 工具调用」= 全靠它实现

env全局变量

TITLE=聊天机器人

# 模型配置
VITE_API_KEY=你的ApiKey 
VITE_AI_MODEL=模型
VITE_AI_BASE_URL=https://api.deepseek.com/v1 # api地址,此处为deepseek

# api配置
# 高德地图配置 https://console.amap.com/dev/key/app # 高德地图api地址
VITE_AMAP_KEY=你的高德地图APIKEy(用于工具:ip地址查询,天气查询)

ts类型

type ROLE = 'user' | 'assistant' | 'system';

interface IChat {
  id: string,
  name: string,
  messages: string,
  role: ROLE,
  created_at: Date
}

export type Chat = IChat;

创建agent

import type { Chat } from "@/types/chat";
import { AIMessage, HumanMessage } from "@langchain/core/messages";
import { ChatPromptTemplate } from "@langchain/core/prompts";
import { RunnableSequence } from "@langchain/core/runnables";
import { ChatOpenAI } from "@langchain/openai";

function createLLM() {
  // 创建LLM
  const llm = new ChatOpenAI({
    model: import.meta.env.VITE_AI_MODEL,
    apiKey: import.meta.env.VITE_API_KEY,
    configuration: {
      baseURL: import.meta.env.VITE_AI_BASE_URL,
    },
    temperature: 0.7, // ai生成的内容丰富度
  })

  return llm
}

function createPrompt() {
  // 创建一个提示模板
  return ChatPromptTemplate.fromMessages([
    ["system", "你是善解人意的,且热爱着某人的智能女友"],// 创建个对象(提示词,prompt)
    ["placeholder", "{chat_history}"], // 聊天记录(记忆,memory)
    ["human", "{input}"], // 用户输入的问题
  ])
}

function createChain() {
  const llm = createLLM()
  const prompt = createPrompt()
  // 创建一个链,将prompt和llm联系起来
  return RunnableSequence.from([prompt, llm])
}


export default async function chat(
  input: string,
  onChat: (text: string) => void,
  messages: Chat[] = []) {

  // 历史记录
  const allMessages: Array<HumanMessage | AIMessage> = []

  // 遍历历史聊天记录
  for (const msg of messages) {
    if (msg.role === "user") {
      allMessages.push(new HumanMessage(msg.messages))
    } else if (msg.role === "assistant") {
      allMessages.push(new AIMessage(msg.messages))
    }
  }

  // 添加当前用户信息
  allMessages.push(new HumanMessage(input))

  const chain = createChain()
  const res = await chain.invoke({
    chat_history: allMessages,
    input: input,
  })

  onChat(String(res.content) || '')
}

页面组件

<template>
  <div class="p-4 flex justify-center items-center">
    <div class="flex gap-2 p-4 rounded-2xl shadow-md w-full max-w-200">
      <input v-model="value" class=" w-full outline-0" type="text" placeholder="发送消息吧( •̀ ω •́ )✧">
      <ElButton :disabled="isDisabled" type="primary" @click="onChatHandler">{{ btnText }}</ElButton>
    </div>
  </div>
</template>

<script setup lang="ts">
import chat from '@/agent';
import type { Chat } from '@/types/chat';
import { ElButton, ElMessage } from 'element-plus';
import { computed, reactive, ref } from 'vue';
const value = defineModel<string>(''); // vue3.6提供的新语法糖,不用再props和emit一个modelValue了
const loading = ref(false);
const history = reactive<Chat[]>([]);
const isDisabled = computed(() => !value.value && !loading.value); // 禁用按钮
const btnText = computed(() => loading.value ? '正在思考...' : '发送');

// 生成一个随机字符串
function randomId() {
  return Math.random().toString(36).substring(2, 15) + Math.random().toString(36).substring(2, 15);
}

const onChatHandler = async () => {
  if (!value.value) return ElMessage.error('请输入内容');
  loading.value = true;

  console.log("用户:" + value.value);
  console.log("====================================");

  history.push({
    id: randomId(),
    name: 'user',
    messages: value.value,
    role: "user",
    created_at: new Date(),
  });

  try {
    await chat(value.value, (text) => {
      console.log("ai:" + text);
      console.log("====================================");

      history.push({
        id: randomId(),
        name: 'assistant',
        messages: text,
        role: "assistant",
        created_at: new Date(),
      });
    }, history);
  } catch (error) {
    if (error instanceof Error)
      ElMessage.error('请求错误' + error.message);
  } finally {
    value.value = '';
    loading.value = false;
  }

};

</script>

<style lang="css" scoped></style>

经测试,控制台内容如下:

image.png

注意:

  • 诺未生效,可能是没有充值,我deepseek充值了10块钱都用了两个月了
  • apiKey一定要真实的,去模型提供商网站申请
  • 本示例中还使用了element plustailwind css

无问芯穹再获超7亿融资

36氪获悉,5月7日,无问芯穹宣布此前已再获超7亿元融资。联合领投方为杭州高新金投集团和惠远资本,跟投方包括国兴资本、秦淮数据、广发乾和、力合清瞳、中保投资、AEF NextGen、腾瑞资本、卡莱特、中信建投资本和宽德智能学习实验室 (Will),老股东君联资本、上海国投孚腾和元智未来追加投资。

一行命令把 PSD 还原成 HTML / React / Vue:psd2code 实战干货

设计稿来了,运营要求"明天上线"。 你打开 PSD,开始切图、量像素、写 CSS、对位置——半天过去了,还在调那个差 2px 的按钮。

这篇文章介绍我们自研的 psd2code 工具:一行命令把 PSD 转成可运行的前端项目,像素级还原 + 智能布局优化 + 多框架产物(HTML / React / Vue)


一、为什么不用现成的 PSD 转 HTML 工具?

社区里其实已经有不少 PSD 转代码方案,但落到运营活动 / H5 / 长图详情页这类「像素级还原」需求上,普遍有三个痛点:

痛点 现象
① 文字字号不准 PSD 里的 FontSize=17.5,浏览器渲染出来又小又模糊——因为忽略了图层 transform.scale
② 全是 position: absolute 几百个图层全部绝对定位,CSS 体积爆炸,后期完全不可维护
③ 组级效果丢失 圆角矩形 8px 外描边、文字描边+投影叠加,要么裁切要么糊掉

我们做了一组对比统计(实际 PSD:南瓜大作战 H5、总决赛-折叠 H5、兑奖 H5):

传统切图工作流:     设计稿到可运行 HTML 平均  4~6 小时
psd2code 自动转换: 设计稿到可运行 HTML 平均  20

由此得出 psd2code 的四大核心方向,也正是本文后续四大章节:

  • PSD 解析:借助 psd-tools,但对它的缺陷做深度修补;
  • 资源提取与优化:像素去重 + 智能命名 + 合成背景图;
  • 布局优化:聚类算法识别行列/网格、智能重写成 Flex;
  • 多 Target 可插拔:同一份 IR,一键产出 HTML / React / Vue 三种工程。

二、整体架构:编译器式分层

psd2code 借鉴编译器的「前端解析 + IR + 后端代码生成」三段式:

flowchart LR
    PSD[".psd<br/>设计稿"] --> Core["core/<br/>PSD 解析 + 图层渲染"]
    Core --> IR[("IR<br/>pydantic 校验<br/>的中间表示")]
    IR --> HTML["targets/html<br/>HTML + CSS"]
    IR --> React["targets/react<br/>Vite + React 18"]
    IR --> Vue["targets/vue<br/>Vite + Vue 3"]
    IR -.预留.-> MP["targets/mini-program"]

    style PSD fill:#f9e79f,stroke:#b9770e
    style IR fill:#aed6f1,stroke:#1f618d
    style HTML fill:#d5f5e3,stroke:#196f3d
    style React fill:#d5f5e3,stroke:#196f3d
    style Vue fill:#d5f5e3,stroke:#196f3d
    style MP fill:#f5f5f5,stroke:#999,stroke-dasharray: 5 5

核心抽象

  1. IR (Intermediate Representation):pydantic BaseModel 严格定义、自带校验。是 coretargets 之间的契约——任何 target 都从 IR 出发,不直接读 PSD
  2. PipelineContext:贯穿所有 Stage 的全局上下文,承载 PSD、IR、配置、产物路径、target 中间产物等。
  3. Stage:单一职责的处理步骤,输入/输出都是 PipelineContext。
  4. Target:一个产物对应一个 Target 子类,通过 @register("html") 注册到全局 registry。

这个分层带来一个直接好处:HTML target 每次能力升级,自动惠及 React / Vue target——因为后两者只是在 HTML 产物之上做二次加工。


三、Skill 使用方式

psd2code 同时也是一个 CodeBuddy Skill,对话里直接说"帮我把这个 PSD 转成 HTML"就会自动触发;也可以脱离 CodeBuddy 单独跑命令行。

3.1 在 CodeBuddy 中调用(推荐)

只要项目里有 .codebuddy/skills/psd2code/ 目录,触发词就能让 CodeBuddy 自动加载该 skill:

"帮我把 设计稿/南瓜大作战.psd 转成 HTML" "把这个 psd 转成 React 项目" "psd 转 vue" "设计稿转代码"

CodeBuddy 会自动选择合适的 target、定位 PSD 文件、执行 skill、把产物路径回报给你。

3.2 命令行直接运行

# 默认 target = html(同时产出 absolute 原版 + Flex 优化版)
python3 .codebuddy/skills/psd2code/psd_to_code.py /path/to/file.psd

# 显式指定 target
python3 .codebuddy/skills/psd2code/psd_to_code.py /path/to/file.psd --target html
python3 .codebuddy/skills/psd2code/psd_to_code.py /path/to/file.psd --target react
python3 .codebuddy/skills/psd2code/psd_to_code.py /path/to/file.psd --target vue

3.3 常用参数

参数 默认 说明
--target {html,react,vue} html 选择产物形态
--css-style {compact,expanded} compact 优化版 CSS 输出风格:compact 接近手写、expanded 全展开 + PSD 坐标溯源注释
--no-css-pretty 关闭 关闭 CSS 美化,回到字母序机械渲染(CI 基线对比常用)

举例:

# 想要排查某个元素位置不对,开 expanded 模式看坐标溯源注释
python3 .codebuddy/skills/psd2code/psd_to_code.py 南瓜大作战.psd --css-style expanded

# 跑 React 产物 + 启动 dev server
python3 .codebuddy/skills/psd2code/psd_to_code.py 南瓜大作战.psd --target react
cd output/南瓜大作战/react
npm install && npm run dev    # http://localhost:5173

# 跑 Vue 产物
python3 .codebuddy/skills/psd2code/psd_to_code.py 南瓜大作战.psd --target vue
cd output/南瓜大作战/vue
npm install && npm run dev    # http://localhost:5173

3.4 产物目录速查

output/<psd_stem>/
├── html/                        # 任何 target 都会先产出
│   ├── index.html               # 原始 absolute 版(保留 dev metadata,方便诊断)
│   ├── index_optimized.html     # Flex 优化版(已剥离 dev metadata,最终交付物)
│   ├── style.css / style_optimized.css
│   ├── main.js                  # 国际化等运行时逻辑
│   ├── metadata.json            # 图层树元数据
│   ├── layer_map.json           # 反查表:CSS 类名 → PSD 原图层名
│   ├── _naming_report.md        # 语义命名报告(每个 token 的来源)
│   └── images/                  # 切图 / 合成图 / 背景图
├── react/                       # --target react 时产出
└── vue/                         # --target vue 时产出

3.5 排查与定位三件套

跑完后如果发现某处不对,优先看这三个文件

  • _naming_report.md:CSS 类名为什么是这个?哪一层(layer1 词典 / layer2 角色推断 / fallback 拼音)给的?
  • layer_map.json.bg-main-4e8c1d 是 PSD 里的哪个图层?图层类型?
  • 对比 index.htmlindex_optimized.html:absolute 原版作为"地面真相",优化版有偏移基本就是 LayoutOptimizer 哪一步过激了。

3.6 系统依赖

Python 3.10+
psd-tools >= 1.14
Pillow >= 10
numpy
beautifulsoup4
pydantic >= 2.0
pypinyin

四、PSD 解析:踩过 psd-tools 的那些坑

psd-tools 是 Python 生态里最成熟的 PSD 解析库,但它只实现了最常用的渲染器,对一些常见场景(shape 填充 + 图层样式、超大发光溢出、引擎字典 transform 等)要么出错、要么丢失。我们的做法是:能修的源码级修,不能修的绕开走手动栅格化

3.1 文字 transform.scale 修正

PSD 文本图层的 engine_dict.StyleRun...FontSize原始字号,但 PS 实际渲染时会用 layer.transform = (a, b, c, d, e, f) 矩阵缩放——其中 a 是 X 缩放、d 是 Y 缩放。

font-size-fix.svg

真实例子:

PSD 图层:"消耗99兑换币"
  raw FontSize    = 17.5
  transform.scale = 1.6
  实际渲染字号     = 17.5 × 1.6 ≈ 28px

如果忽略 transform.scale,浏览器会用 17.5px 渲染,文字直接缩成花生米。另一个易踩的兄弟坑:ParagraphSheet 的路径不在 StyleRun 下,而是 engine.ParagraphRun.RunArray[0].ParagraphSheet.Properties 里的 Justification,老代码写错路径会导致所有文字永远左对齐。这两处我们在 core/psd/text_extractor.py 里重新解析。

3.2 浏览器字宽差异:纵向 + 横向双兜底

PSD 设计师常用思源黑体 / 造字工房,浏览器渲染时却用 PingFang / Arial——相同字号下,浏览器中文会比 PSD 更宽,导致:

  • 单行文本被挤成两行("预测四"+"强")
  • 按钮内文字撑出按钮边界

effect-comparison.svg

我们设计了双兜底:

# 纵向兜底:字号不超过 bbox 高度的 0.85
if font_size >= height * 0.85:
    font_size = height * 0.85

# 横向兜底(仅纯中文短标题):按字数 + 宽度倒推上限
if pure_cjk and char_count <= 12:
    max_font_by_width = width / cjk_count * 0.95
    font_size = min(font_size, max_font_by_width)

效果验证(总决赛-折叠 PSD):

文本 修正前 修正后 效果
主赛区(68px 宽) 25.5px 20.9px ✓ 单行
预测四强(164px 宽) 46.75px 38.5px ✓ 不再折行

3.3 Shape + 图层样式描边:psd-tools 的致命 bug

一个看似普通的 PSD shape 图层(#feffd7 浅黄填充 + 2px 内描边 #5f8618 绿色),psd-tools 直接 layer.composite() 会得到整片纯绿色——填充色完全丢失。

排查后发现两个叠加的根因:

  1. layer.topil() 对 shape 返回 None,老代码降级到 layer.composite() 取基础图;但 composite() 把整块 shape 区域错误地涂成描边色。
  2. draw_stroke_effectskimage.filters.scharr 检测 alpha 边缘做描边,当 alpha 紧贴画布无 padding 时,scharr 检测不到边缘,归一化后 mask 整片=1,描边色铺满整张图。

绕开方案

  • 新增 _render_shape_base_from_fill(layer):跳过 composite(),直接读 SoCo 填充色 + origination 几何(Rectangle / RoundedRectangle / Ellipse)用 PIL ImageDraw 合成基础图。
  • 调用 draw_stroke_effect 前给 alpha 加 padding(pad = max(stroke_size+2, 4)),渲染完再裁回。
# stroke_renderer.py
padded_alpha = np.pad(alpha, ((pad,pad),(pad,pad),(0,0)), mode='constant')
stroke_color, stroke_mask = draw_stroke_effect(bbox, padded_alpha, ...)
out[:,:,:3]  = stroke_color[pad:pad+h, pad:pad+w, :]
out[:,:,3:4] = stroke_mask[pad:pad+h, pad:pad+w, :] * opacity

效果:兑奖活动卡片的浅黄背景 + 2px 绿描边正确还原,无需人工补图。

3.4 图层样式的两层 enabled 开关

PS 图层样式面板有两个开关:

  • 整体开关layer.effects.enabled(fx 行最左边那个勾)
  • 单项开关effect.enabled(外发光/描边/投影各自的勾)

PS 的规则:整体开关关闭 → 所有效果都不渲染,哪怕单项 enabled=True

psd-tools 不替你 AND 这两个标志,psd2code 早期代码多处只看 effect.enabled,导致"PS 中没效果的文本"被当成"有外发光"处理,错误地栅格化成图片。修复后统一用一个 helper:

def is_effect_active(effect, layer):
    if not layer.effects or not layer.effects.enabled:
        return False
    return bool(effect.enabled)

全工程 8 处调用点全部切换。这解决了带 fx 残留的昵称文本全部被错误降级为图片的问题。

3.5 组级效果溢出:手动渲染 + composite 混合

PS 图层效果(描边、阴影、发光,共 8 种)在组(Group)级别有个隐蔽特性:效果会沿组的整体边界裁切

psd-toolscomposite() 能在组内正确复现这一行为——但只在组的 bbox 内有效。一个圆角矩形有 8px 外描边时,描边会溢出组的 bbox 被 composite() 裁掉。实测过各种"绕过"方法——父级 composite、根级 composite、给超大 viewport——都是徒劳:

psd-tools 在任何层级 composite,都按目标节点(及其所有祖先组)的 bbox 做硬裁切,不存在绕过方案

我们的解法是「手动栅格化 + composite 混合」:

hybrid-render.png

1. 先用扩展画布 + 手动逐层渲染 → 拿到完整溢出像素(外部区域)
2. 再用 group.composite(viewport=bbox) → 拿到组 bbox 内的 PS 原生高质量
3. 把 composite 结果覆盖到手动渲染结果的内部区域

最终:外部保留溢出效果,内部达到像素级匹配(实测 Alpha 差异 max=0、mean=0.00)。

硬约束:子组(嵌套组)必须用 sub_grp.composite(viewport=grp_bbox) 渲染,不能退回"递归调用 _render_group_expanded + 裁切"——历史回归实测会在圆角轮廓位置多出 ~75px/行的错误描边。

3.6 总结:PSD 解析的修复清单

问题 表现 我们的做法
transform.scale 未应用 文本被缩成花生米 layer.transform[0],FontSize 乘以它
ParagraphSheet 路径错误 所有文字都是左对齐 ParagraphRun.RunArray[0] 路径重新解析
shape + 图层样式整片涂描边色 兑奖卡片全变绿 手动栅格化填充 + 给 alpha 加 padding 再描边
两层 enabled 未 AND 无效果文本被错误降级为图片 统一 is_effect_active(effect, layer)
组级效果溢出被裁 外描边断掉 混合渲染:手动扩展 + composite 覆盖

五、资源提取与优化

psd2code 对每个图层做一次决策:切图 / 文字保留 / shape 用 CSS 还原 / 吸收为父容器背景 / 合并成单图,最终写到 output/<psd>/html/images/ 目录。

5.1 智能去重:基于内容哈希而非图层名

活动页里"星星""糖果""装饰点"这类小图会被设计师复用几十次,每次都单独切图是极大浪费。

def _save_image_dedup(self, img, name, depth) -> str:
    data = serialize(img)                        # 按 Config.IMAGE_FORMAT 序列化
    md5_hash = hashlib.md5(data).hexdigest()
    if md5_hash in self._image_hash_map:         # 命中去重
        return self._image_hash_map[md5_hash]
    path = make_image_filename(name, content_hash=md5_hash, ltype=ltype)
    self._image_hash_map[md5_hash] = path
    write(path, data)
    return path

南瓜大作战 H5 实测:239 个 image 图层 → 87 张 PNG(去重率 63.6%)。

5.2 语义化文件名:tag + 内容哈希

旧方案拼音 + 自增序号(yuanjiaojuxing_3_7.png)有两个问题:HTML 里不可读;每次运行序号都在跳,git diff 噪声极大。

新方案:

images/<semantic-tag>-<md5前6位>.png

例:rounded-a3f012.png
    btn-receive-279914.png
    bg-main-4e8c1d.png
    candy-big-7b0a12.png
  • semantic-tagsemantic/ 子包从图层名推断得出(支持 3 层置信度:Layer2 角色推断 → Layer1 清洗词典 → fallback 关键词 + 拼音),PS 默认名走 ltype 兜底(img/shape
  • md5前6位 = 图片内容哈希——PSD 没改,文件名就不变,git diff 和 CDN 缓存两全其美
  • 同名撞车自动追加 -2/-3

5.3 形状层保留矢量:不切图就是最好的优化

圆角矩形、椭圆、纯色矩形这类简单 shape,不切图而是直接翻译成 CSS 几何属性

PSD shape 输出 CSS
Rectangle + SoCo 填充 background: <color>; width/height
RoundedRectangle border-radius: <r>px
Ellipse border-radius: 50%
shape + 图层样式描边 border: <w>px solid <color>

效果:CSS 体积下降的同时,文件也能 retina 无损缩放。

5.4 多张全屏背景的合成

活动页常见模式:组里有 2~3 张全屏背景叠加(渐变底 + 花纹 + 噪点)。如果每张都单独切图,HTML 里会写多 url 背景:

.bg-section {
  background-image: url(bg-gradient.png), url(bg-pattern.png), url(bg-noise.png);
  background-position: 0 0, 0 0, 0 0;
  background-repeat: no-repeat, no-repeat, no-repeat;
}

问题:多张 PNG 多次请求,而且浏览器要多次合成。psd2code 的做法是在布局优化阶段检测这种多 url 模式,用 PIL alpha_composite 合成单张 PNG 写回磁盘

输入:bg-gradient.png(284 KB) + bg-pattern.png(412 KB) + bg-noise.png(67 KB)
输出:flat-af0dce35.png (153 KB)   # 1/5 体积、1 次请求

南瓜大作战 H5 实测:47 组合并、节省 45.6 KB。

一个与 CSS 规范相反的坑:background-image: url(a), url(b) 中第一个 url 在视觉最上层,而 PIL alpha_composite 期望"底层在前"。调用方必须 reverse 列表——早期代码漏掉 reverse 导致所有合成图的颜色上下层叠错,颜色"对调"。

5.5 图层扁平化:子图合并 + 父容器吸收

更激进的优化:当一个容器里只有纯 image 子图层(无文本、无按钮),把容器自身的 background + 所有 image 子按 z 序合成单张 PNG、删掉所有子 div 及其 CSS 规则、只留容器自己的 background-image

这个 ImageLayerFlatten transformer 采用后序遍历 + 多轮扫描:最深层先合并、外层再发现"我的子变成单 div 了"继续合并。护栏非常严格:

  • 子元素必须全部 data-type="image" 且无孙子
  • opacity≈1 / mix-blend-mode: normal
  • 容器本身不能有 border-radius / box-shadow / clip-path / filter / transform 等"不能烧进 PNG 的装饰字段"(一旦合并后再叠这些,会双重作用)
  • 总层数 ≥ 2,几何包围盒 ≤ 画布 50%(否则合并一张巨图反而得不偿失)

南瓜大作战 H5 实测:这一步搞定了 47 个容器的视觉简化,DOM 节点从 ~500 降到 ~280。


六、布局优化(本工具最核心的功能)

直接用 absolute 还原 PSD 没问题,但 200+ 图层全部 position: absolute 是工程灾难。psd2codeLayoutOptimizer 把 absolute 智能重写成 Flex,同时保证视觉零偏移。

flex-before-after.png

6.1 七步流水线全景

flowchart TD
    A["原始 absolute HTML"] --> B["Step 1:DOM 重构<br/>(聚类 / 背景剥离 / 容器吸收)"]
    B --> C["Step 1.2:图层扁平化<br/>(多 image 子 → 单张合成 PNG)"]
    C --> D["Step 1.5:同质兄弟分组<br/>(识别 v-list,支持 v-for)"]
    D --> E["Step 2:Flex 推断<br/>(analyzer V10 + 三道闸门)"]
    E --> F["Step 2.5:单子 wrapper 折叠<br/>(消除中间层)"]
    F --> G["Step 3:CSS 去冗余<br/>(z-index 精简 + 等价规则合并)"]
    G --> G2["Step 3.5:重复元素抽取<br/>(3+ hash 类 → 单 base 类)"]
    G2 --> H["Step 4:CSS 美化<br/>(DOM 序 + 属性分段 + 多行)"]
    H --> I["✅ 优化后 HTML / CSS"]

    style A fill:#fadbd8,stroke:#c0392b
    style I fill:#d5f5e3,stroke:#196f3d
    style B fill:#fcf3cf,stroke:#b7950b
    style E fill:#fcf3cf,stroke:#b7950b
    style G fill:#fcf3cf,stroke:#b7950b

6.2 聚类算法:怎么"看懂"一堆 absolute 框

这是整个 LayoutOptimizer 的灵魂。对任意一个容器,我们有 N 个子图层的 bbox(left/top/width/height),目标是自动把它们组织成**行(row)/ 列(col)/ 叠图组(stack)**的树状结构。

第一步:切行(_split_by_rows

从左到右、从上到下遍历子元素,维护一个"当前行"的 envelope(bbox 包络)。新来一个元素 e,判断它和 envelope 的纵向重叠率

overlap_y = min(e.bottom, env.bottom) - max(e.top, env.top)
ratio     = overlap_y / min(e.height, env.height)

if ratio >= 0.5:  # 同行判据
    env 吸收 e,继续
else:
    新开一行

第二步:行内切列

对每一行内部,把切行逻辑换成纵/横轴就是切列。递归后我们得到一棵"行包列 / 列包行"的嵌套树。

第三步:背景层剥离

一个组里常有"全屏卡片底框 + 多个浮层元素"的设计模式。直接聚类会把底框当成一个"占 100% 空间的大元素",严重干扰行/列判断。我们在聚类前先剥离满足以下三种规则之一的"背景层":

  • 完全包含型:bbox 完全包住其他所有元素
  • 主轴覆盖型:在主轴(宽或高)上覆盖 ≥ 90%
  • 双轴主导覆盖型:宽、高都覆盖 envelope ≥ 80%(识别"略带 padding 的卡片底图")

剥离后的背景层被吸收进父容器的 background-image 列表。

第四步:伪多行装饰堆叠回退

切出多行后,若所有行都只有一个元素、且相邻行横向覆盖率 ≥ 80%,回退为 stack(堆叠)——这是"图标 + 标题上下贴边"这种"本质上堆叠装饰"的场景。

第五步:二维网格识别

当多行 × 多列的元素满足"列对齐 + 跨行对齐"时,单纯用"列 包 行"嵌套表达不够干净,改成显式的 v-grid-row + flex column 结构:

rows = _split_into_rows(...)
if len(rows) >= 2 and all_rows_have_aligned_cols(rows):
    layout_type = 'grid'
    flex_applier 包装为:
      父: display:flex; flex-direction:column
      每行: <div class="grid-row-N v-grid-row">

南瓜大作战 H5 的"用户信息区"9 个子图层(剥掉背景卡 + 头像装饰后剩 7 个文本),被正确识别为 2 行 × [4, 3] 列 grid。

6.3 三道安全闸门:什么时候不该用 Flex

不是所有看起来"整齐"的容器都该用 Flex。我们踩过太多坑后总结出三道闸门(全在 layout_analyzer.py):

互相重叠的装饰簇

n 个图层互相重叠(每个与多个邻居都重叠),且 trend_ratio < 0.6。典型场景:多层装饰贴纸、若干徽章叠在一起。判定为堆叠装饰,保持 absolute。

支配背景层

存在某个子元素 X 满足 X.area / envelope.area >= 0.8,且其余子元素中 ≥ 60% 显著落在 X 内(重叠/自身面积 ≥ 0.6)。判定为"大底图 + 多个浮层"的卡片,整组保持 absolute。

装饰剥离

先把子节点分类为 bg / decor / content 三类,只在 content 子集上做趋势检测。这让"内容整齐成行 + 角落有装饰"的容器不再因为装饰打乱排版被误判。

6.4 Flex 应用:非趋势子元素保留 absolute

识别为 vertical / horizontal 后,我们把趋势元素写成 flex 子项(用 margin 表达间距),非趋势元素(角标、装饰)保留其 position: absolute 坐标:

/* 趋势元素:flex 流 */
.prop-card-1 { margin-top: 20px; margin-left: 0; }
.prop-card-2 { margin-top: 18px; }

/* 非趋势元素:保留 absolute */
.badge { position: absolute; right: -6px; top: -6px; }

这里有个极易反复重犯的 bug:容器重构后,子元素的 top/left 是"相对父容器"的坐标(由 extract 阶段产出),不需要再减父 top。

还有一条来自 v-stack 的保护:flex_applier 默认会 del child_css['position'] 把子元素的定位去掉;但如果子本身是 v-stack wrapper(内含 absolute 子节点),删除 position 会让其孩子跳到外层定位,直接飘到屏幕角落。修复:遇到 'v-stack' in child.classes 就改成 position: relative,而不是删除。

6.5 同质兄弟簇检测:识别"同类卡片"

PSD 设计师经常把 N 个商品卡 / 道具卡 / 礼包卡平铺在 #canvas 直接子,没有用父组包起来。传统聚类只在已有 group 内部做,这种列表会全部走 absolute 路径,开发拿到的 HTML 完全看不出"它是一个数据列表",没法直接写 v-for

SiblingGroupDetector 的 5 条 AND 规则:

  1. ≥ 3 个连续兄弟
  2. class 词根相同(去掉 __\d+ 后缀和 -\d+ 序号)——prop__30 / prop-2__38 / prop-10__101 词根都是 prop这是最强的设计师意图信号
  3. bbox 尺寸近似(误差 ≤ 5%)
  4. 满足网格规则:M 列 × K 行 满格排布,同列 left 一致、同行 top 一致(误差 ≤ 2px)
  5. 父容器本身不是 flex

识别成功后包成虚拟容器:

<div class="prop-list v-list" data-virtual="list">
  <div class="prop__30 layer-group">...</div>
  <div class="prop-2__38 layer-group">...</div>
  <div class="prop-3__45 layer-group">...</div>
</div>

CSS 用 display: flex; flex-wrap: wrap; column-gap / row-gap,下游开发可直接写 v-for

一个设计决定:我们不做子结构同构判定。实际 PSD 里同类卡几乎总是有差异(首张卡设计完复制改文案,结构漂移:少一行文字、按钮换成图片、装饰数量不一致)。强求子结构一致会绝大多数现实场景识别失败——class 词根 + bbox 尺寸两条已经够强。

6.6 CSS 去冗余:z-index 精简 + 等价规则合并

core/extract/layer_exporter.py 给每个图层无脑塞 z-index = 全局 layer_id——这是合理的像素还原默认值,但优化版完全不需要。CssDedup 分两个 Pass:

Pass 1 — z-index 精简

遍历每个父容器,收集子元素的 (selector, z) 序列:

形态 动作
长度 0 跳过
长度 1(独 z,其他全 None) 删该 z-index
长度 ≥ 2 严格递增 全删(DOM 顺序 = z 序)
长度 ≥ 2 出现倒挂 全保留

逻辑:position:absolute 子元素的叠序只在"兄弟 bbox 重叠"时依赖 z-index;绝大多数父容器下"DOM 顺序 = z 序升序"(这是 LayerRenderer 的天然产出),浏览器默认行为就能正确实现叠序。

Pass 2 — 等价规则合并

属性 dict 完全相等的多个选择器合并为 .a, .b, .c { ... } 单条规则。南瓜大作战 H5 实测合并 209 条。

Pass 3.5 — 重复元素抽取

Pass 2 合并了 CSS,但 HTML 里依然写了 N 个不同的 hash 类.prop__68 / .prop__105 / ...)。RepeatClassUnifier 进一步:≥ 3 个 .<base>__<digits> 形式的等价类 → 合并为单一 base 类(.prop),HTML 同步改写。

最终 HTML 里你看到的就是:

<div class="prop-list v-list">
  <div class="prop layer-group">...</div>
  <div class="prop layer-group">...</div>
  <div class="prop layer-group">...</div>
</div>

直接就是这种干净的语义化结构。

6.7 实战效果(南瓜大作战 H5)

指标 V2 优化器 当前版本
元素位置偏移 PSD 原位置 94 个元素偏离 5~13px 0 个元素偏离
CSS 行数 4805 1499
CSS 块数 457 ~270
z-index 字段 432 97
6×4 任务网格识别 每个 cell 独立 absolute 自动识别 v-col + v-row 嵌套

下面这张是真实产物里"任务格子"那段——20 多个图层、4 行多列、每个 cell 带描边小图标,全部由算法自动识别:

pumpkin-grid.png

6.8 算法的天花板与人工边界

再好的算法也有上限——下面这些场景 psd2code 会"尽力而为,但结果不一定最优":

① Flex 布局化不充分:设计师图层组织混乱

典型问题:活动页版块 2 的按钮、图标、装饰全部散乱摆在同一个 PSD 根组,没有任何分组——聚类算法能看到的只是 bbox 位置,看不到"设计意图"

👉 解决方案:整理 PSD 图层结构。按视觉版块分组(版块1-签到 / 版块2-道具 / 版块3-任务),每个版块内部再按"标题 / 卡片列表 / 底部按钮"分组。psd2code 会优先在已有组内部做聚类,组边界 = 聚类边界。分好组之后,95% 的场景都能自动重构为干净的 Flex。

② CSS 不够语义化:图层名用了默认命名

典型问题:PSD 图层名是 矢量智能对象图层 12 拷贝 3形状 47——psd2code 只能给你 .img-a3f012 这种内容哈希名,无从推断语义。

👉 解决方案:整理图层命名。重要的结构性图层给中文或 kebab-case 命名(bg-main / btn-领取 / 用户信息背景 / 任务卡片)。psd2code 的 semantic/ 子包能识别:

  • 按钮语义:btn / 按钮 / 领取 / 确定.btn-receive / .btn-ok
  • 背景语义:bg / 背景 / 底框.bg-main
  • 卡片容器:prop / card / 道具.prop-card
  • 中文关键词:通过 common/cn_dict.json 词典映射到 kebab-token

命名整洁之后,HTML 就会是 .prop-card / .btn-receive / .user-info-bg 这种一眼看懂的语义类,而不是 hash 串。

③ 人工干预:特殊场景需人工调整

psd2code 只实现常用渲染器所以部分图层导出效果不好(全实现产出比太低),需要人工干预。

👉 解决方案:手动栅格化或导出图片


七、实战演练:把"南瓜大作战 H5"PSD 跑一遍

南瓜大作战 H5(750 × 6778 长图活动页)为例,一行命令 20 秒拿到完整可运行 HTML:

$ python3 psd_to_code.py "南瓜大作战 H5.psd" --target html

🎨 合并背景图层: ['背景', '矩形 1', '形状 839 拷贝 2']
🖼️  background [合并3层 750x6778] → images/background-f07984.png
📁 solgan (组)
  ✨ 形状 16 (含效果渲染)
  🌟 检测到效果溢出 6px,使用混合渲染策略
📁 版块1 (组) ...
🎨 开始布局优化...
✅ 优化完成!
   - DOM 重构: 60 个
   - v-list 创建: 3 个 (包裹 24 个节点)
   - 应用 flex: 28 个
   - z-index 精简: 304 处
   - CSS 等价规则合并: 节省 128 条
   - 重复元素抽取: 25 组 → 删除 49 个 hash 类、复用到 61 个元素
   - 图层扁平化: 47 个容器 (共合并 105 层, 节省 45.6 KB)
✅ 产物:output/南瓜大作战 H5/html/

浏览器打开 index.html 第一屏——和 PSD 设计稿完全像素对齐,包括 solgan 上的描边发光、用户信息区的圆角、糖果图标的渐变叠加:

pumpkin-hero.png

absolute 原版 vs Flex 优化版对比

pumpkin-compare.png

文件 HTML 大小 CSS 大小 定位方式 可维护性
index.html 71 KB 113 KB 全部 position: absolute ⭐⭐
index_optimized.html 52 KB 38 KB Flex 嵌套 + 局部 absolute ⭐⭐⭐⭐

不要小看这 75 KB 的 CSS 压缩——它代表着 60 个容器被语义化、25 组 hash 类被复用,后期改样式不再需要逐个调 top/left

整个活动页 6778px 长,包含 9 大版块(用户信息 / 任务区 / 道具 / 助力 / 排行 等):

pumpkin-full.png

转换日志里有几个有意思的点:

  • 组级效果溢出自动触发 3 次:solgan 日期组(6px)、副标题组(10px)、糖果数目组(4px)——全部走"手动栅格化 + composite 混合"。
  • 47 个容器被图层扁平化:原本 105 张 image 合并成 47 张 PNG,节省 45.6 KB。
  • 3 组同质兄弟列表识别:道具卡 × 6、任务卡 × 12、排行榜条目 × 6,被包成 v-list——可直接写 v-for
  • 叠图组识别:邀请助力 / 核销助力码 / 版块3(7 个图层)等被 V8/V9 闸门正确识别为"装饰堆叠",保持 absolute。

八、多 Target 可插拔架构

8.1 Target Registry:装饰器注册

targets/registry.py 非常简单:

_REGISTRY: dict[str, Type[Target]] = {}

def register(name: str):
    def _wrap(cls: Type[Target]) -> Type[Target]:
        key = name.strip().lower()
        if key in _REGISTRY and _REGISTRY[key] is not cls:
            raise ValueError(f"Target '{key}' already registered")
        cls.name = key
        _REGISTRY[key] = cls
        return cls
    return _wrap

每个 target 是 Target 子类,实现 build_pipeline(ctx) -> Pipeline

# targets/html/target.py
@register("html")
class HtmlTarget(Target):
    def build_pipeline(self, ctx):
        return Pipeline([
            LoadPsdStage(),
            ParseStage(),
            ExtractAssetsStage(),
            CodegenStage(),
            LayoutOptimizeStage(),
            EmitStage(),
        ])

# targets/react/target.py
@register("react")
class ReactTarget(Target):
    def build_pipeline(self, ctx):
        return Pipeline([
            HtmlTarget().build_pipeline(ctx),   # 先产出 HTML(含优化版)
            Html2ReactStage(),                  # 再转 JSX + Vite 脚手架
        ])

# targets/vue/target.py 同理

CLI --target vueget("vue") → 实例化 → target.run(ctx)。新增 target(比如小程序)只需:

@register("mini-program")
class MiniProgramTarget(Target):
    def build_pipeline(self, ctx):
        return Pipeline([
            HtmlTarget().build_pipeline(ctx),
            Html2WxmlStage(),        # 转 WXML
            Html2WxssStage(),        # 转 WXSS
        ])

无需改动核心代码。

8.2 为什么 React / Vue 都在 HTML 基础上二次加工

业界也有"直接从 IR 生成 JSX"的设计,但 psd2code 选择"先走一遍 HTML target,再转框架":

  • HTML target 的优化(布局、CSS 去冗余、语义化命名)免费继承给 React/Vue——任何一次 LayoutOptimizer 升级自动惠及三端。
  • 开发者本地 review 时可以直接对比 html/index_optimized.htmlreact/src/App.jsx 的视觉一致性,容易定位转换问题。
  • React/Vue 的转换就是 DOM 遍历 + class/style 重映射 + 模板语法替换,逻辑简单、可测试性强。

8.3 产物结构一览

output/<psd_stem>/
├── html/                       # 任何 target 都会先产出
│   ├── index.html              # absolute 版(与 PSD 像素级对齐)
│   ├── index_optimized.html    # Flex 优化版
│   ├── style.css / style_optimized.css
│   ├── main.js                 # 国际化等运行时逻辑
│   ├── metadata.json           # 图层树元数据
│   ├── class_alias_map.json    # 老 hash 类 → 新语义类的映射
│   └── images/                 # 切图 / 合成图 / 背景图
├── react/                      # --target react 产出
│   ├── package.json / vite.config.js
│   └── src/App.jsx, App.css, main.jsx, assets/images/
└── vue/                        # --target vue 产出
    ├── package.json / vite.config.js
    └── src/App.vue, main.js, assets/images/

React / Vue 产物开箱即用:

cd output/<psd_stem>/react   # 或 vue
npm install && npm run dev   # http://localhost:5173

九、其他你可能在意的细节

  • 图片去重:按内容 MD5,同一张装饰图只导出一次。
  • 语义化类名:图层名 预测四强.yucesi__152(拼音兜底),或通过 cn_dict.json 词典映射为 .predict-top4
  • 国际化预留:所有文本节点自动带 data-i18n-key,可通过 JS 动态替换。
  • 旋转/倾斜文本:自动降级为图片,保证视觉一致。
  • 剪贴蒙版:按 layer.clip 标志识别,合并成父图基底 + 描边/发光效果。

十、踩过的坑(写给后来者)

如果你打算自己实现 PSD → 代码工具,以下几个坑可以省你几天:

  1. transform.scale 不能忘——所有 FontSize 都要乘以 transform.a / transform.d
  2. shape + 图层样式描边 psd-tools 会整片涂描边色——必须手动用 SoCo + origination 合成基础图、给 alpha 加 padding 再描边。
  3. 两层 enabled 开关必须 AND——layer.effects.enabled(整体)和 effect.enabled(单项)都要为 True 才算生效。
  4. composite() 的 viewport 限制——任何层级的 composite 都按"目标节点 + 所有祖先"的 bbox 硬裁切,不存在绕过方案,组级溢出效果必须手动扩展画布。
  5. 子组必须用 composite 渲染——不要退回手动递归,会在圆角处多出 ~75px/行的错误描边。
  6. tree.children 顺序 ≠ z 序——背景剥离后再合并 background-image 时,必须按原 DOM sibling index 重排。
  7. 多 url 背景合成时 reverse 列表——CSS 第一个 url 是视觉最上层,但 PIL alpha_composite 期望底层在前,反了会颜色对调。
  8. CSS parser 别用贪婪正则——@media (...) { #canvas { ... } } 嵌套时,简单正则会把内层 #canvas 误当顶层规则,整个 canvas 塌成 0 高。
  9. flex 容器 envelope 越界envelope.left/top 可能为负(图层超出组 bbox),写 padding 时要 max(0, ...),否则 cross_offset 算多了。
  10. v-stack wrapper 的 position 必须保留——flex_applier 默认 del child_css['position'],遇到 v-stack 要改写为 relative,否则内部 absolute 子元素会跳到外层定位。
  11. background-repeat: no-repeat 不是默认值——background-repeat 的 CSS 默认值是 repeat,CssDedup 删默认值时不能把它加进去,否则大背景图会被平铺。

十一、写在最后

psd2code 不是一个"AI 读图猜布局"的玩具——它是一个严格基于 PSD 结构信息的编译器。每一步决策都可解释、可调参、可单测,算法失败点(比如 V8/V9/V10 闸门)都有明确的 fallback 路径。

再强调一次:算法做的再多效果也是有限的。想要 psd2code 产出高质量代码,有两件事你得做:

  1. 整理 PSD 图层结构(按视觉版块分组)
  2. 整理 PSD 图层命名(关键图层给语义名)

做到这两点,运营活动页从设计稿到可上线代码的时间可以从 4~6 小时降到 20 秒。

未来计划:

  • ✅ HTML / React / Vue 已上线
  • 🚧 小程序 target(架构已预留扩展点)
  • 🚧 Tailwind CSS 输出
  • 🚧 Figma 文件支持(共享 IR,新增 figma loader)

如果你也在做活动页 / 长图详情页 / 运营 H5,欢迎试用并提反馈。


Thanks

以上就是本篇文章的全部内容,如有问题欢迎指出,我们一起进步。
如果觉得本篇文章对您有帮助的话请点个赞让更多人看到吧,您的鼓励是我前进的动力。
谢谢~~

源代码地址

月之暗面申请注册KimiClaw商标

36氪获悉,天眼查知识产权信息显示,近日,北京月之暗面科技有限公司申请注册多枚“KimiClaw”商标,国际分类包括科学仪器、网站服务、通讯服务等,当前商标状态均为等待实质审查。北京月之暗面科技有限公司成立于2023年4月,法定代表人为杨植麟,注册资本100万人民币,经营范围含软件开发、软件销售、人工智能基础软件开发、人工智能应用软件开发等,由杨植麟、周昕宇、吴育昕等共同持股。

抖音火山引擎在苏州成立新科技公司,注册资本1000万

36氪获悉,天眼查App显示,近日,苏州深空引擎科技有限公司成立,法定代表人为陆震洧,注册资本1000万人民币,经营范围含计算机系统服务、数据处理服务、社会经济咨询服务、企业管理咨询、专业设计服务、组织文化艺术交流活动、计算机软硬件及辅助设备零售、机械设备销售、云计算装备技术服务等。股东信息显示,该公司由抖音旗下北京火山引擎科技有限公司全资持股。

纳芯微发布ASIL D隔离驱动芯片

36氪获悉,近日,纳芯微发布首款通过ASIL D认证的隔离栅极驱动芯片NSI6911F。该产品可提供最高19A峰值驱动能力,并实现150kV/μs共模瞬态抗扰度,适配800V及以上高压平台应用,同时集成超过30项安全机制,满足ASIL D等级对故障诊断覆盖率的要求。

WebView 性能优化实战:从首屏1.5秒到300毫秒

WebView 性能优化实战:从首屏1.5秒到300毫秒

做移动端H5开发,性能是永恒的话题。本文分享我在实战中总结的WebView优化方案

前言

"页面加载太慢了,用户都流失了"

这是很多移动端H5开发者面临的痛点。WebView的性能直接影响用户体验,但优化起来往往无从下手。

本文从实战角度出发,分享我从首屏1.5秒优化到300毫秒的经验。


一、性能指标定义

1.1 核心指标

指标 说明 目标值
白屏时间 从发起请求到首屏可见 < 500ms
首屏时间 从发起请求到首屏内容渲染完成 < 1000ms
可交互时间 页面可以响应用户操作 < 1500ms
完全加载时间 所有资源加载完成 < 3000ms

1.2 测量方法

使用 Performance API

// 白屏时间
const whiteScreenTime = performance.timing.domLoading - performance.timing.navigationStart;

// 首屏时间
const firstPaintTime = performance.timing.domContentLoadedEventEnd - performance.timing.navigationStart;

// 完全加载时间
const loadTime = performance.timing.loadEventEnd - performance.timing.navigationStart;

使用 PerformanceObserver(推荐)

// 观察首屏渲染
const observer = new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    console.log('FP:', entry.startTime); // First Paint
  }
});
observer.observe({ type: 'paint', buffered: true });

// 观察 LCP(Largest Contentful Paint)
const lcpObserver = new PerformanceObserver((list) => {
  const entries = list.getEntries();
  const lastEntry = entries[entries.length - 1];
  console.log('LCP:', lastEntry.startTime);
});
lcpObserver.observe({ type: 'largest-contentful-paint', buffered: true });

二、WebView 初始化优化

2.1 WebView 预加载

问题:WebView 初始化需要时间,首次打开页面会有延迟。

方案:在 Application 启动时预热 WebView。

Android 实现

public class MyApp extends Application {
    @Override
    public void onCreate() {
        super.onCreate();
        
        // 在主线程预热 WebView
        WebView webView = new WebView(this);
        webView.destroy();
    }
}

iOS 实现

// 在 AppDelegate 中预热 WKWebView
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
    
    // 预热 WKWebView
    let config = WKWebViewConfiguration()
    let webView = WKWebView(frame: .zero, configuration: config)
    
    return true
}

优化效果:首次打开 WebView 时间减少 200-300ms。

2.2 WebView 复用池

public class WebViewPool {
    private static final int MAX_POOL_SIZE = 2;
    private static final Queue<WebView> pool = new LinkedList<>();
    
    public static WebView obtain(Context context) {
        WebView webView = pool.poll();
        if (webView == null) {
            webView = new WebView(context);
        }
        return webView;
    }
    
    public static void recycle(WebView webView) {
        if (pool.size() < MAX_POOL_SIZE) {
            webView.loadUrl("about:blank");
            webView.clearCache(true);
            pool.offer(webView);
        } else {
            webView.destroy();
        }
    }
}

三、网络请求优化

3.1 DNS 预解析

<head>
  <!-- DNS 预解析 -->
  <link rel="dns-prefetch" href="//cdn.example.com">
  <link rel="dns-prefetch" href="//api.example.com">
</head>

3.2 预连接

<head>
  <!-- 预建立连接 -->
  <link rel="preconnect" href="https://cdn.example.com">
  <link rel="preconnect" href="https://api.example.com" crossorigin>
</head>

3.3 资源预加载

<head>
  <!-- 预加载关键资源 -->
  <link rel="preload" href="/css/main.css" as="style">
  <link rel="preload" href="/js/main.js" as="script">
  <link rel="preload" href="/fonts/main.woff2" as="font" type="font/woff2" crossorigin>
</head>

3.4 HTTP 缓存策略

静态资源:强缓存 + 版本号

# nginx 配置
location ~* \.(js|css|png|jpg|gif|woff2)$ {
    expires 1y;
    add_header Cache-Control "public, immutable";
}

HTML 页面:协商缓存

location ~* \.html$ {
    etag on;
    if_modified_since exact;
    add_header Cache-Control "no-cache";
}

四、WebView 缓存优化

4.1 离线缓存方案

方案一:Application Cache(已废弃)

不推荐,现代浏览器已移除支持。

方案二:Service Worker(推荐)

// 注册 Service Worker
if ('serviceWorker' in navigator) {
  navigator.serviceWorker.register('/sw.js')
    .then(registration => console.log('SW registered'))
    .catch(error => console.log('SW registration failed'));
}

// sw.js
const CACHE_NAME = 'v1';
const CACHE_URLS = [
  '/',
  '/css/main.css',
  '/js/main.js'
];

// 安装时缓存
self.addEventListener('install', event => {
  event.waitUntil(
    caches.open(CACHE_NAME).then(cache => cache.addAll(CACHE_URLS))
  );
});

// 拦截请求
self.addEventListener('fetch', event => {
  event.respondWith(
    caches.match(event.request).then(response => {
      return response || fetch(event.request);
    })
  );
});

4.2 Android WebView 缓存配置

WebSettings settings = webView.getSettings();

// 开启 DOM Storage
settings.setDomStorageEnabled(true);

// 开启数据库缓存
settings.setDatabaseEnabled(true);

// 设置缓存模式
settings.setCacheMode(WebSettings.LOAD_DEFAULT);

// 设置缓存路径
String cachePath = getCacheDir().getAbsolutePath();
settings.setAppCachePath(cachePath);
settings.setAppCacheEnabled(true);

4.3 iOS WKWebView 缓存配置

let config = WKWebViewConfiguration()

// 配置缓存
let websiteDataStore = WKWebsiteDataStore.nonPersistent()
config.websiteDataStore = websiteDataStore

// 或者使用默认缓存
config.websiteDataStore = .default()

五、渲染优化

5.1 阻塞渲染的资源处理

CSS 放头部,JS 放底部

<head>
  <link rel="stylesheet" href="/css/main.css">
</head>
<body>
  <!-- 内容 -->
  <script src="/js/main.js"></script>
</body>

JS 异步加载

<!-- 异步加载,不阻塞解析 -->
<script async src="/js/analytics.js"></script>

<!-- 延迟加载,解析完成后执行 -->
<script defer src="/js/main.js"></script>

5.2 关键渲染路径优化

内联关键 CSS

<head>
  <style>
    /* 内联首屏关键 CSS */
    body { margin: 0; font-family: sans-serif; }
    .header { height: 50px; background: #fff; }
    .content { padding: 20px; }
  </style>
  <link rel="preload" href="/css/main.css" as="style" onload="this.rel='stylesheet'">
</head>

5.3 图片优化

懒加载

<img src="placeholder.jpg" data-src="real-image.jpg" loading="lazy">
// Intersection Observer 懒加载
const observer = new IntersectionObserver((entries) => {
  entries.forEach(entry => {
    if (entry.isIntersecting) {
      const img = entry.target;
      img.src = img.dataset.src;
      observer.unobserve(img);
    }
  });
});

document.querySelectorAll('img[data-src]').forEach(img => observer.observe(img));

响应式图片

<picture>
  <source srcset="image-small.webp" media="(max-width: 600px)" type="image/webp">
  <source srcset="image-large.webp" media="(min-width: 601px)" type="image/webp">
  <img src="image.jpg" alt="fallback">
</picture>

六、JavaScript 优化

6.1 代码分割

// 动态导入
const module = await import('./heavy-module.js');

// React 懒加载
const LazyComponent = React.lazy(() => import('./HeavyComponent'));

6.2 长任务优化

// 使用 requestIdleCallback
requestIdleCallback(() => {
  // 执行低优先级任务
});

// 使用 Web Worker
const worker = new Worker('worker.js');
worker.postMessage({ type: 'heavy-computation', data: largeData });
worker.onmessage = (e) => {
  console.log('Result:', e.data);
};

6.3 事件处理优化

// 节流
function throttle(fn, delay) {
  let last = 0;
  return function(...args) {
    const now = Date.now();
    if (now - last >= delay) {
      fn.apply(this, args);
      last = now;
    }
  };
}

// 防抖
function debounce(fn, delay) {
  let timer = null;
  return function(...args) {
    clearTimeout(timer);
    timer = setTimeout(() => fn.apply(this, args), delay);
  };
}

// 应用
window.addEventListener('scroll', throttle(handleScroll, 100));
input.addEventListener('input', debounce(handleInput, 300));

七、WebView 与原生交互优化

7.1 减少通信次数

问题:每次 JSBridge 调用都有开销。

方案:批量传输数据。

// 差:多次调用
const userInfo = await bridge.call('getUserInfo');
const deviceId = await bridge.call('getDeviceId');
const appVersion = await bridge.call('getAppVersion');

// 好:一次调用
const data = await bridge.call('getInitData');
// data = { userInfo, deviceId, appVersion }

7.2 使用消息队列

// 消息队列
const messageQueue = [];

function flushQueue() {
  if (messageQueue.length === 0) return;
  
  const messages = [...messageQueue];
  messageQueue.length = 0;
  
  bridge.call('batchMessages', messages);
}

function enqueueMessage(msg) {
  messageQueue.push(msg);
  requestAnimationFrame(flushQueue);
}

八、内存优化

8.1 Android WebView 内存泄漏

问题:WebView 持有 Activity 引用导致内存泄漏。

方案:使用独立进程 + 动态销毁。

// AndroidManifest.xml
<activity
    android:name=".WebViewActivity"
    android:process=":webview" />

// Activity 销毁时
@Override
protected void onDestroy() {
    if (webView != null) {
        webView.loadDataWithBaseURL(null, "", "text/html", "utf-8", null);
        webView.clearHistory();
        webView.destroy();
        webView = null;
    }
    super.onDestroy();
}

8.2 iOS WKWebView 内存管理

deinit {
    webView.stopLoading()
    webView.navigationDelegate = nil
    webView.uiDelegate = nil
}

8.3 JS 内存管理

// 及时清理定时器
const timer = setInterval(() => {}, 1000);
clearInterval(timer);

// 及时清理事件监听
const handler = () => {};
element.addEventListener('click', handler);
element.removeEventListener('click', handler);

// 避免闭包内存泄漏
function createHandler() {
  const largeData = new Array(10000);
  return () => {
    // 使用 largeData
  };
}
// 用完后置空
let handler = createHandler();
handler();
handler = null;

九、实战案例

9.1 优化前

指标 数值
白屏时间 800ms
首屏时间 1500ms
完全加载 3500ms

9.2 优化措施

措施 效果
WebView 预加载 -200ms
DNS 预解析 + 预连接 -150ms
资源预加载 -200ms
关键 CSS 内联 -100ms
图片懒加载 -300ms
JS 代码分割 -150ms
缓存优化 -100ms

9.3 优化后

指标 数值 提升
白屏时间 300ms -62.5%
首屏时间 700ms -53.3%
完全加载 1800ms -48.6%

十、总结

WebView 性能优化是一个系统工程,需要从多个维度入手:

  1. 初始化优化:WebView 预加载、复用池
  2. 网络优化:DNS 预解析、预连接、预加载、缓存策略
  3. 渲染优化:阻塞资源处理、关键路径优化、图片懒加载
  4. JS 优化:代码分割、长任务处理、事件优化
  5. 交互优化:减少通信次数、消息队列
  6. 内存优化:防止内存泄漏、及时清理资源

记住:性能优化没有银弹,要根据实际场景选择合适的方案。先用工具测量,找到瓶颈,再对症下药。


#WebView性能优化 #移动端H5 #前端优化 #性能调优

vxe-table 实现撤销与重做:单元格编辑后支持 Ctrl+Z 回退

vxe-table 如何实现撤销与重做,修改单元格编辑后支持回退 当公司业务需求需要实现编辑表格支持撤销功能,这时就可以通过设置 keyboard-config.isUndoRedo 启用。当编辑的单元格之后,按 Ctrl + Z | Command + Z 键 ,可以撤销会编辑之前的状态

需求场景

在表格编辑场景中,用户经常需要撤销误操作或重做已撤销的内容。vxe-table 提供了内置的撤销与重做功能,只需启用 keyboardConfig.isUndoRedo 即可获得类似 Excel 的编辑历史记录能力。

核心配置

在 vxe-grid 或 vxe-table 组件中,通过 keyboardConfig 开启撤销与重做:

keyboardConfig: {
  isUndoRedo: true,   // 启用撤销与重做功能(Ctrl+Z / Ctrl+Y)
  // ... 其他键盘配置
}

示例代码

extend_cell_area_table_keypad_undo_redo

<template>
  <div>
    <vxe-grid v-bind="gridOptions"></vxe-grid>
  </div>
</template>

<script setup>
import { reactive } from 'vue'

const sexEditRender = reactive({
  name: 'VxeSelect',
  options: []
})

const gridOptions = reactive({
  border: true,
  height: 500,
  showOverflow: true,
  keepSource: true,
  columnConfig: {
    resizable: true
  },
  mouseConfig: {
    area: true // 是否开启区域选取
  },
  areaConfig: {
    multiple: true // 是否启用多区域选取功能
  },
  editConfig: {
    mode: 'cell', // 单元格编辑模式
    trigger: 'dblclick', // 双击单元格激活编辑状态
    showStatus: true // 显示编辑状态
  },
  keyboardConfig: {
    arrowCursorLock: true, // 方向键光标锁,开启后处于非聚焦式编辑状态,将支持在编辑状态中通过方向键切换单元格。(切换为聚焦编辑状态,可以按 F2 键或者鼠标左键点击输入框,就可以用方向键左右移动输入框的光标)
    isClip: true, // 是否开启复制粘贴
    isArrow: true, // 是否开启方向键功能
    isShift: true, // 是否开启同时按住方向键以活动区域为起始,向指定方向扩展单元格区域
    isTab: true, // 是否开启 Tab 键功能
    isEnter: true, // 是否开启回车键功能
    isEdit: true, // 是否开启任意键进入编辑(功能键除外)
    isBack: true, // 是否开启 Backspace 键功能
    isDel: true, // 是否开启删除键功能
    isEsc: true, // 是否开启Esc键关闭编辑功能
    isFNR: true, // 是否开启查找与替换
    isMerge: true, // 是否开启单元格合并功能
    isChecked: true, // 是否启用复选框/单选框按空格键选中/取消功能
    isUndoRedo: true // 是否启用撤销与重做功能
  },
  columns: [
    { type: 'seq', fixed: 'left', width: 60 },
    { type: 'checkbox', fixed: 'left', width: 60 },
    { field: 'name', title: 'name', width: 200, editRender: { name: 'VxeInput' } },
    { field: 'role', title: 'Role', width: 200, editRender: { name: 'VxeInput' } },
    { field: 'sex', title: 'sex', width: 250, editRender: sexEditRender },
    { field: 'num1', title: 'Num1', width: 200, editRender: { name: 'VxeInput' } },
    { field: 'num2', title: 'Num2', width: 250, editRender: { name: 'VxeInput' } },
    { field: 'num3', title: 'Num3', width: 300, editRender: { name: 'VxeInput' } },
    { field: 'age', title: 'age', width: 100, editRender: { name: 'VxeInput' } },
    { field: 'address', title: 'Address', fixed: 'right', width: 300, editRender: { name: 'VxeInput' } }
  ],
  data: [
    { id: 10001, name: 'Test1', role: 'Develop', sex: '1', num1: 12, num2: 21, num3: 78, age: 28, address: 'Shengzhen' },
    { id: 10002, name: 'Test2', role: 'Test', sex: '0', num1: 23, num2: 45, num3: 23, age: 22, address: 'Guangzhou' },
    { id: 10003, name: 'Test3', role: 'PM', sex: '1', num1: 23, num2: 12, num3: 68, age: 32, address: 'Shanghai' },
    { id: 10004, name: 'Test4', role: 'Designer', sex: '0', num1: 23, num2: 23, num3: 47, age: 24, address: 'Shanghai' },
    { id: 10005, name: 'Test5', role: 'Designer', sex: '0', num1: 32, num2: 77, num3: 65, age: 42, address: 'Guangzhou' },
    { id: 10006, name: 'Test6', role: 'Designer', sex: '1', num1: 39, num2: 66, num3: 87, age: 38, address: 'Shengzhen' },
    { id: 10007, name: 'Test7', role: 'Test', sex: '0', num1: 23, num2: 44, num3: 23, age: 24, address: 'Shengzhen' },
    { id: 10008, name: 'Test8', role: 'PM', sex: '1', num1: 23, num2: 23, num3: 87, age: 34, address: 'Shanghai' },
    { id: 10009, name: 'Test9', role: 'Designer', sex: '1', num1: 91, num2: 23, num3: 24, age: 52, address: 'Shanghai' },
    { id: 10010, name: 'Test10', role: 'Test', sex: '0', num1: 20, num2: 72, num3: 54, age: 44, address: 'Guangzhou' },
    { id: 10011, name: 'Test11', role: 'Designer', sex: '1', num1: 56, num2: 32, num3: 63, age: 52, address: 'Shanghai' },
    { id: 10012, name: 'Test12', role: 'Test', sex: '0', num1: 34, num2: 65, num3: 54, age: 47, address: 'Guangzhou' },
    { id: 10013, name: 'Test13', role: 'Test', sex: '0', num1: 39, num2: 65, num3: 435, age: 47, address: 'Guangzhou' },
    { id: 10014, name: 'Test14', role: 'Test', sex: '0', num1: 44, num2: 65, num3: 324, age: 45, address: 'Guangzhou' },
    { id: 10015, name: 'Test15', role: 'Test', sex: '0', num1: 45, num2: 56, num3: 54, age: 39, address: 'Guangzhou' },
    { id: 10016, name: 'Test16', role: 'Test', sex: '0', num1: 34, num2: 65, num3: 344, age: 44, address: 'Shanghai' },
    { id: 10017, name: 'Test17', role: 'Test', sex: '0', num1: 78, num2: 77, num3: 78, age: 48, address: 'Guangzhou' },
    { id: 10018, name: 'Test18', role: 'Test', sex: '0', num1: 32, num2: 12, num3: 45, age: 89, address: 'Shanghai' },
    { id: 10019, name: 'Test19', role: 'Test', sex: '0', num1: 42, num2: 65, num3: 8, age: 52, address: 'Guangzhou' },
    { id: 10020, name: 'Test20', role: 'Test', sex: '0', num1: 56, num2: 45, num3: 4, age: 41, address: 'Shanghai' },
    { id: 10021, name: 'Test21', role: 'Test', sex: '1', num1: 48, num2: 65, num3: 54, age: 49, address: 'Guangzhou' },
    { id: 10022, name: 'Test22', role: 'Test', sex: '0', num1: 41, num2: 65, num3: 12, age: 50, address: 'Shanghai' }
  ]
})
// 异步加载下拉选项
setTimeout(() => {
  sexEditRender.options = [
    { label: 'Man', value: '1' },
    { label: 'Women', value: '0' }
  ]
}, 300)
</script>

关键配置说明

  1. 必须启用的基础配置
  • keepSource: true:保留原始数据副本,撤销/重做依赖于此。
  • editConfig.mode: 'cell':单元格编辑模式(也支持行编辑模式,但撤销/重做对单元格编辑体验更好)。
  1. 当 isUndoRedo: true 时,vxe-table 内部会维护一个编辑历史栈: 每次单元格编辑完成后(失焦或按回车),当前数据快照会被推入历史栈。 按 Ctrl+Z 时,从历史栈中弹出上一个状态并恢复。 按 Ctrl+Y 时,执行重做操作(需先有撤销操作)。

获取历史记录 API

// 获取表格实例
const $grid = gridRef.value

// 手动撤销
$grid.undo()

// 手动重做
$grid.redo()

vxe-table 的撤销/重做功能开箱即用,无需额外存储逻辑,极大降低了开发成本。如果你的业务需要严谨的编辑历史管理,这是一个非常实用的特性。

vxetable.cn

汇丰上调香港2026年经济增长预期至3.8%

汇丰环球投资研究5月7日发布最新香港经济报告,将2026年及2027年香港本地生产总值(GDP)增长预期分别上调至3.8%及3.0%。报告指出,本地增长正在巩固;鉴于香港的经济结构,预期中东冲突影响相对有限;“安全港”和互联互通吸引资本和人才,财富拉动消费,人工智能推动投资。(界面)

vue甘特图vxe-gantt如何实现拖拽任务条时如有已关联依赖线,同时更新依赖任务的日期的方式

vue甘特图vxe-gantt如何实现拖拽任务条时如有已关联依赖线,同时更新依赖任务的日期的方式

当任务关联前置任务或后置任务依赖线时,拖拽该任务时同步更新对应的起始日期和结束日期,可以通过 task-bar-drag-config.moveSetMethod 来自定义业务逻辑

extend_gantt_chart_gantt_dependency_move_update

基础代码

简单实现同步移动任务

<template>
  <div>
    <vxe-gantt v-bind="ganttOptions"></vxe-gantt>
  </div>
</template>

<script setup>
import { reactive } from 'vue'
import { VxeGanttDependencyType } from 'vxe-gantt'
import XEUtils from 'xe-utils'

const ganttOptions = reactive({
  border: true,
  height: 600,
  rowConfig: {
    keyField: 'id' // 行主键
  },
  taskBarConfig: {
    showProgress: true, // 是否显示进度条
    showContent: true, // 是否在任务条显示内容
    moveable: true, // 是否允许拖拽任务移动日期
    resizable: true, // 是否允许拖拽任务调整日期
    linkCreatable: true, // 是否允许自定义创建依赖线
    barStyle: {
      round: true, // 圆角
      bgColor: '#fca60b', // 任务条的背景颜色
      completedBgColor: '#65c16f' // 已完成部分任务条的背景颜色
    }
  },
  taskLinkConfig: {
    isHover: true, // 当鼠标移到依赖线时,是否要高亮当前依赖线
    isCurrent: true, // 当鼠标点击依赖线时,是否要高亮当前依赖线
    isDblclickToRemove: true // 是否允许双击依赖线删除
  },
  taskViewConfig: {
    tableStyle: {
      width: 480 // 表格宽度
    }
  },
  taskBarMoveConfig: {
    // 自定义拖拽结束时任务日期被赋值的方法
    async moveSetMethod ({ row, startValue, endValue, offsetSize, linkInfo }) {
      const { toRows, fromRows } = linkInfo
      row.start = startValue
      row.end = endValue
      // 实现拖拽任务后,关联任务自动同步更新日期
      fromRows.forEach(row => {
        row.start = XEUtils.toDateString(XEUtils.getWhatDay(row.start, offsetSize), 'yyyy-MM-dd')
        row.end = XEUtils.toDateString(XEUtils.getWhatDay(row.end, offsetSize), 'yyyy-MM-dd')
      })
      toRows.forEach(row => {
        row.start = XEUtils.toDateString(XEUtils.getWhatDay(row.start, offsetSize), 'yyyy-MM-dd')
        row.end = XEUtils.toDateString(XEUtils.getWhatDay(row.end, offsetSize), 'yyyy-MM-dd')
      })
    }
  },
  links: [
    { from: 10001, to: 10002, type: VxeGanttDependencyType.FinishToFinish },
    { from: 10004, to: 10005, type: VxeGanttDependencyType.StartToStart },
    { from: 10005, to: 10006, type: VxeGanttDependencyType.FinishToStart },
    { from: 10013, to: 10012, type: VxeGanttDependencyType.StartToFinish }
  ],
  columns: [
    { type: 'seq', width: 70 },
    { field: 'title', title: '任务名称' },
    { field: 'start', title: '开始时间', width: 100 },
    { field: 'end', title: '结束时间', width: 100 },
    { field: 'progress', title: '进度(%)', width: 80 }
  ],
  data: [
    { id: 10001, title: '任务1', start: '2024-03-01', end: '2024-03-04', progress: 3 },
    { id: 10002, title: '任务2', start: '2024-03-03', end: '2024-03-08', progress: 10 },
    { id: 10003, title: '任务3', start: '2024-03-03', end: '2024-03-11', progress: 90 },
    { id: 10004, title: '任务4', start: '2024-03-05', end: '2024-03-11', progress: 15 },
    { id: 10005, title: '任务5', start: '2024-03-08', end: '2024-03-15', progress: 100 },
    { id: 10006, title: '任务6', start: '2024-03-10', end: '2024-03-21', progress: 5 },
    { id: 10007, title: '任务7', start: '2024-03-15', end: '2024-03-24', progress: 70 },
    { id: 10008, title: '任务8', start: '2024-03-05', end: '2024-03-15', progress: 50 },
    { id: 10009, title: '任务9', start: '2024-03-19', end: '2024-03-20', progress: 5 },
    { id: 10010, title: '任务10', start: '2024-03-12', end: '2024-03-20', progress: 10 },
    { id: 10011, title: '任务11', start: '2024-03-01', end: '2024-03-08', progress: 90 },
    { id: 10012, title: '任务12', start: '2024-03-03', end: '2024-03-06', progress: 60 },
    { id: 10013, title: '任务13', start: '2024-03-02', end: '2024-03-05', progress: 50 },
    { id: 10014, title: '任务14', start: '2024-03-04', end: '2024-03-15', progress: 0 },
    { id: 10015, title: '任务15', start: '2024-03-01', end: '2024-03-05', progress: 30 }
  ]
})
</script>

还可以实现更复杂的逻辑

<template>
  <div>
    <vxe-gantt ref="ganttRef" v-bind="ganttOptions" @task-bar-drag-end="onTaskDragEnd"></vxe-gantt>
  </div>
</template>

<script setup>
import { reactive, ref } from 'vue'
import { VxeGanttDependencyType } from 'vxe-gantt'
import XEUtils from 'xe-utils'

const ganttRef = ref(null)

const ganttOptions = reactive({
  // ...(保持原有配置)
  taskBarMoveConfig: {
    // 自定义拖拽结束时任务日期被赋值的方法
    async moveSetMethod({ row, startValue, endValue, offsetSize, linkInfo }) {
      const { toRows, fromRows, toLinks, fromLinks } = linkInfo
    
      // 1. 获取变更前的日期(用于错误恢复)
      // const oldStart = row.start
      // const oldEnd = row.end

      // 2. 更新当前拖拽任务的日期
      row.start = startValue
      row.end = endValue


      // 4. 统一更新依赖任务的方法
      //   根据依赖类型计算出新的日期,而不是简单粗暴地整体偏移
      const updateDependentTasks = (tasks, currentRow, linkType, offsetDays) => {
        tasks.forEach(task => {
          const currentStart = new Date(currentRow.start)
          const currentEnd = new Date(currentRow.end)
          let newStart, newEnd

          // 根据不同的依赖类型,更新关联任务的日期
          switch (linkType) {
            case VxeGanttDependencyType.FinishToStart:
              // 前置任务结束后,后置任务才能开始
              newStart = XEUtils.toDateString(
                XEUtils.getWhatDay(currentRow.end, offsetDays),
                'yyyy-MM-dd'
              )
              newEnd = XEUtils.toDateString(
                XEUtils.getWhatDay(task.end, offsetDays),
                'yyyy-MM-dd'
              )
              break
            case VxeGanttDependencyType.StartToStart:
              // 两个任务同时开始
              newStart = XEUtils.toDateString(
                XEUtils.getWhatDay(currentRow.start, offsetDays),
                'yyyy-MM-dd'
              )
              newEnd = XEUtils.toDateString(
                XEUtils.getWhatDay(task.end, offsetDays),
                'yyyy-MM-dd'
              )
              break
            case VxeGanttDependencyType.FinishToFinish:
              // 两个任务同时完成
              newStart = XEUtils.toDateString(
                XEUtils.getWhatDay(task.start, offsetDays),
                'yyyy-MM-dd'
              )
              newEnd = XEUtils.toDateString(
                XEUtils.getWhatDay(currentRow.end, offsetDays),
                'yyyy-MM-dd'
              )
              break
            case VxeGanttDependencyType.StartToFinish:
              // 当前任务完成才能开始
              newStart = XEUtils.toDateString(
                XEUtils.getWhatDay(currentRow.start, offsetDays),
                'yyyy-MM-dd'
              )
              newEnd = XEUtils.toDateString(
                XEUtils.getWhatDay(task.end, offsetDays),
                'yyyy-MM-dd'
              )
              break
            default:
              // 默认整体偏移
              newStart = XEUtils.toDateString(
                XEUtils.getWhatDay(task.start, offsetDays),
                'yyyy-MM-dd'
              )
              newEnd = XEUtils.toDateString(
                XEUtils.getWhatDay(task.end, offsetDays),
                'yyyy-MM-dd'
              )
          }

          // 更新任务数据
          task.start = newStart || task.start
          task.end = newEnd || task.end
        })
      }

      // 6. 更新依赖任务(前置和后置)
      // 使用依赖关系信息来精确更新
      if (fromRows.length && fromLinks.length) {
         updateDependentTasks([fromRows[0]], row, fromLinks[0], offsetSize)
      }

      if (toRows.length && toLinks.length) {
        updateDependentTasks([toRows[0]], row, toLinks[0], offsetSize)
      }

    }
  },
  // ...(保持原有数据配置)
})

// 监听拖拽事件,执行其他业务逻辑(如保存到后端)
const onTaskDragEnd = ({ row, startValue, endValue }) => {
  // 可以在这里保存更新后的任务数据,或者执行其他业务逻辑
  console.log('任务拖拽完成', { row, startValue, endValue })
  
  // 可选:触发数据更新到后端
  // updateTaskToBackend(row)
}
</script>

依赖类型说明

依赖类型 原逻辑缺陷 优化后逻辑
FinishToStart 前置任务结束日期推迟,后置任务应该整体推迟?❌ 应该是后置任务的开始日期=前置任务的结束日期 仅更新后置任务的开始日期,保持持续时间不变
StartToStart 同时开始的任务,拖拽一个不应该导致另一个结束日期整体偏移 保持两个任务的结束日期相对不变,仅调整开始日期
FinishToFinish 两个任务同时结束,拖拽一个不应该影响另一个的开始日期 保持两个任务的开始日期相对不变,仅调整结束日期
StartToFinish 前置任务开始日期影响后置任务完成日期 根据依赖关系的具体约束来精确更新

gantt.vxeui.com

腾讯Hy3 preview上线两周Token调用增10倍

36氪获悉,5月7日,腾讯混元公布最新数据,自上线以来,Hy3 preview的Token调用量持续增加,目前总量已经超过上一代版本模型Hy2的10倍,尤其是代码和智能体类场景的Token调用量增加明显,并且腾讯的WorkBuddy/Codebuddy以及Qclaw类应用中的总增长幅度超过16.5倍。

React 中的语音与摄像头输入:语音识别、媒体设备与权限

语音和摄像头是把一个静态 Web 应用变得鲜活的两种感官。一个能对它说话的搜索栏。一个实时把你说的话转成文字的笔记应用。一个让你选择用哪个摄像头的会议工具。一个按住按键就能说话的对讲机。这些早已不再罕见——浏览器有这些 API 已经好多年了——但每一个都被一连串权限弹窗、厂商前缀和生命周期的怪癖挡在前面,让人很难干净地把它们集成进 React 组件。

本文将带你走过四种用于语音和摄像头输入的浏览器能力:带中间结果的实时语音识别、枚举用户的摄像头和麦克风、在权限被撤销时仍能存活的权限查询,以及把 Shift 键当作按住说话修饰符使用。和往常一样,我们会先用手动实现来开局,让你看清底层的管道,然后再换成 ReactUse 里专门的 Hook。最后,我们会把四个 Hook 组合成一个完整的语音搜索组件,包含设备选择器、权限闸门,以及按住说话的录音交互。

1. 实时语音识别

手动实现

Web Speech API 是一个比较老的浏览器 API,但从未真正被标准化——Chrome 把它实现成 webkitSpeechRecognition,而无前缀的 SpeechRecognition 在大多数引擎里仍然缺失。最小可用的 React 包装看起来像这样:

function ManualSpeechRecognition() {
  const [transcript, setTranscript] = useState("");
  const [listening, setListening] = useState(false);
  const recognitionRef = useRef<any>(null);

  useEffect(() => {
    const SR =
      (window as any).SpeechRecognition ||
      (window as any).webkitSpeechRecognition;
    if (!SR) return;
    const recognition = new SR();
    recognition.continuous = true;
    recognition.interimResults = true;
    recognition.lang = "zh-CN";
    recognition.onresult = (event: any) => {
      const result = event.results[event.resultIndex];
      setTranscript(result[0].transcript);
    };
    recognition.onend = () => setListening(false);
    recognitionRef.current = recognition;
    return () => recognition.abort();
  }, []);

  const start = () => {
    recognitionRef.current?.start();
    setListening(true);
  };
  const stop = () => {
    recognitionRef.current?.stop();
    setListening(false);
  };

  return (
    <div>
      <button onClick={listening ? stop : start}>
        {listening ? "停止" : "开始"}识别
      </button>
      <p>{transcript}</p>
    </div>
  );
}

这个能跑,但忽略了那些粗糙的边角。它没有区分 isFinal,所以 UI 无法判断用户什么时候停顿了("中间结果"和"最终结果"的区别正是让语音 UI 显得有响应的关键)。它没有错误处理——如果用户拒绝了麦克风权限或网络断了,转录就会默默地永远不更新。它没有语言协商。而且 SR 的类型很糟糕,因为 TypeScript 没有为 webkitSpeechRecognition 提供类型。

ReactUse 的方式:useSpeechRecognition

useSpeechRecognition 返回一个干净的对象,提供恰当的原语:

import { useSpeechRecognition } from "@reactuses/core";

function VoiceNote() {
  const { isSupported, isListening, isFinal, result, error, start, stop } =
    useSpeechRecognition({
      lang: "zh-CN",
      interimResults: true,
      continuous: true,
    });

  if (!isSupported) {
    return <p>当前浏览器不支持语音识别。</p>;
  }

  return (
    <div>
      <button onClick={isListening ? stop : start}>
        {isListening ? "停止" : "开始"}口述
      </button>
      <p
        style={{
          fontStyle: isFinal ? "normal" : "italic",
          color: isFinal ? "#0f172a" : "#64748b",
        }}
      >
        {result || "说点什么..."}
      </p>
      {error && <p style={{ color: "#ef4444" }}>错误:{error.error}</p>}
    </div>
  );
}

你不用写就能拿到的好处:

  1. isFinal —— Hook 会跟踪当前 result 是语音引擎的临时猜测(在示例里是斜体)还是已经锁定的转录。这是相比朴素版本最大的 UX 提升。
  2. error 对象 —— 当权限被拒、网络断开或引擎失败时,你能拿到一个带类型的错误对象,可以展示给用户而不是默默地卡住。
  3. 热配置start({ lang: "fr-FR" }) 让你能在会话中途切换语言,无需重建识别器。
  4. 卸载时清理。Hook 会自动调用 abort(),所以离开页面永远不会让麦克风一直开着。

最有威力的模式是把识别结果绑到一个搜索输入框上,让用户在说话时实时输入查询。因为 Hook 会在每个中间结果到来时重渲,你可以直接用语音输入来驱动一个实时搜索查询,让用户在说话时就能看到结果。

2. 枚举摄像头和麦克风

手动实现

列出用户的音频和视频设备需要 navigator.mediaDevices.enumerateDevices()。有个陷阱:在用户对某个设备授予权限之前,返回的标签是空的——你只能拿到一组 deviceId,但拿不到像 "FaceTime HD Camera" 这样的 label。要拿到标签,你必须先调用 getUserMedia 触发权限弹窗,然后再枚举一次。

function ManualDeviceList() {
  const [devices, setDevices] = useState<MediaDeviceInfo[]>([]);

  useEffect(() => {
    let mounted = true;
    const refresh = async () => {
      try {
        // 触发权限以填充标签
        const stream = await navigator.mediaDevices.getUserMedia({
          audio: true,
          video: true,
        });
        stream.getTracks().forEach((t) => t.stop());
        const list = await navigator.mediaDevices.enumerateDevices();
        if (mounted) setDevices(list);
      } catch (e) {
        console.error(e);
      }
    };
    refresh();
    navigator.mediaDevices.addEventListener("devicechange", refresh);
    return () => {
      mounted = false;
      navigator.mediaDevices.removeEventListener("devicechange", refresh);
    };
  }, []);

  return (
    <ul>
      {devices.map((d) => (
        <li key={d.deviceId}>
          {d.kind}: {d.label || "(标签隐藏)"}
        </li>
      ))}
    </ul>
  );
}

形状是对的,但你每次都要写权限触发的舞蹈、临时流的清理,以及 device-change 监听器。

ReactUse 的方式:useMediaDevices

useMediaDevices 把整套流程打包了起来:

import { useMediaDevices } from "@reactuses/core";

function CameraPicker({
  selected,
  onSelect,
}: {
  selected: string;
  onSelect: (id: string) => void;
}) {
  const [{ devices }, ensurePermissions] = useMediaDevices({
    requestPermissions: true,
    constraints: { video: true, audio: false },
  });

  const cameras = devices.filter((d) => d.kind === "videoinput");

  return (
    <div>
      <button onClick={() => ensurePermissions()}>刷新设备</button>
      <select
        value={selected}
        onChange={(e) => onSelect(e.target.value)}
        style={{ marginLeft: 8 }}
      >
        {cameras.map((cam) => (
          <option key={cam.deviceId} value={cam.deviceId}>
            {cam.label || `摄像头 ${cam.deviceId.slice(0, 6)}`}
          </option>
        ))}
      </select>
    </div>
  );
}

Hook 处理了三件你本来要自己写的事:

  • 权限协商。传 requestPermissions: true,Hook 会在挂载时根据你指定的 constraints 触发 getUserMedia,然后立即停止临时音视轨道,让摄像头指示灯熄灭。
  • 实时设备列表。Hook 监听 devicechange 并自动重新枚举——如果用户插入新麦克风或拔掉耳机,列表会自动更新,不需要额外代码。
  • 手动刷新。返回的 ensurePermissions 让你随时能再触发一次提示,对于"用户拒绝了一次后想再试一次"的按钮非常有用。

constraints 参数会直接转发给 getUserMedia,所以你只需要视频时(跳过那种"想要麦克风权限吗"的别扭弹窗)就只请求视频。

3. 正确地查询权限

手动实现

要在不触发弹窗的情况下检查用户是否已经授予(或拒绝)麦克风或摄像头权限,需要 Permissions API。它支持得很好但很啰嗦:

function ManualMicPermission() {
  const [state, setState] = useState<PermissionState | "unknown">("unknown");

  useEffect(() => {
    let mounted = true;
    let status: PermissionStatus | null = null;
    (async () => {
      try {
        status = await navigator.permissions.query({
          name: "microphone" as PermissionName,
        });
        if (mounted) setState(status.state);
        status.onchange = () => mounted && status && setState(status.state);
      } catch {
        // 此名称的 Permissions API 不可用
      }
    })();
    return () => {
      mounted = false;
      if (status) status.onchange = null;
    };
  }, []);

  return <p>麦克风权限:{state}</p>;
}

三件值得注意的事。第一,API 通过 onchange 提供回调,对 React 不友好。第二,你必须同时特性检测 Permissions API 本身和具体的 name(某些浏览器不支持 "microphone")。第三,change 监听器必须显式清理,而不能通过 effect 返回值。

ReactUse 的方式:usePermission

usePermission 把整段舞蹈减到一次调用:

import { usePermission } from "@reactuses/core";

function MicStatusBadge() {
  const state = usePermission("microphone");

  const color =
    state === "granted"
      ? "#10b981"
      : state === "denied"
      ? "#ef4444"
      : "#f59e0b";

  return (
    <span style={{ color, fontWeight: 600 }}>
      麦克风:{state || "未知"}
    </span>
  );
}

state 是一个 React 原生字符串,每当底层权限状态变化时就会更新——包括外部变化,比如用户进入浏览器设置撤销了权限,你的组件 state 就会翻转到 "denied",不需要你做任何操作。

你可以传一个像 "microphone""camera" 这样的字符串,也可以传一个完整的 PermissionDescriptor 对象,用于像 "push" 这样需要额外字段的权限。形状和 navigator.permissions.query 完全一致,只是变成了一个 Hook。

4. 用 useKeyModifier 实现按住说话

手动实现

按住说话按钮比看起来要难。你想检测用户是否在按住某个键(比如 Space 或 Shift),按住时开始录音,松开时立即停止。你还得处理这种情况:用户按住按键、把焦点切到另一个窗口、在你的页面隐藏时松开按键、然后再回来——否则录音器会一直卡在录制状态。

function ManualPushToTalk() {
  const [pressed, setPressed] = useState(false);

  useEffect(() => {
    const onDown = (e: KeyboardEvent) => {
      if (e.code === "Space") setPressed(true);
    };
    const onUp = (e: KeyboardEvent) => {
      if (e.code === "Space") setPressed(false);
    };
    const onBlur = () => setPressed(false);
    window.addEventListener("keydown", onDown);
    window.addEventListener("keyup", onUp);
    window.addEventListener("blur", onBlur);
    return () => {
      window.removeEventListener("keydown", onDown);
      window.removeEventListener("keyup", onUp);
      window.removeEventListener("blur", onBlur);
    };
  }, []);

  return <p>{pressed ? "正在录制..." : "按住空格说话"}</p>;
}

这个差不多能跑。bug 是:如果 Space 键在按住时自动重复(大多数操作系统都会这样),你会先收到一个 keydown,然后又一个 keydown,最后才是 keyup。这个你处理了。但如果用户按的是 Shift 并把它当成与其他键的组合修饰符使用,你的手动跟踪就不知道了。

ReactUse 的方式:useKeyModifier

useKeyModifier 把 OS 级别的修饰键状态(和你从 event.getModifierState 拿到的值一样)暴露为 React state:

import { useKeyModifier } from "@reactuses/core";

function ShiftToRecord({ onTalkStart, onTalkEnd }: {
  onTalkStart: () => void;
  onTalkEnd: () => void;
}) {
  const shift = useKeyModifier("Shift");

  useEffect(() => {
    if (shift) onTalkStart();
    else onTalkEnd();
  }, [shift, onTalkStart, onTalkEnd]);

  return (
    <div
      style={{
        padding: 16,
        background: shift ? "#fef3c7" : "#f1f5f9",
        borderRadius: 8,
        textAlign: "center",
      }}
    >
      {shift ? "正在录制(松开 Shift 停止)" : "按住 Shift 说话"}
    </div>
  );
}

相比 keydown/keyup 版本的好处:

  • OS 感知。Hook 读取 getModifierState,从 OS 查询实际的修饰键状态。它能正确应对自动重复、焦点丢失和奇怪的组合键。
  • 支持任何修饰键。传 "Control""Alt""Meta""CapsLock""NumLock"——浏览器追踪的任何修饰键都行。
  • 初始值。如果你想让 React state 初始为 true,就配置 initial: true(不常见,但调试时有用)。

全部组合:带设备选择器的语音搜索

我们把四个 Hook 组合成一个语音驱动的搜索组件。用户可以选择用哪个麦克风、看到一个权限徽章、按住 Shift 开始口述、并在说话时实时看到转录更新。当他们松开 Shift 时,最终转录就成了搜索查询。

import { useEffect, useState } from "react";
import {
  useSpeechRecognition,
  useMediaDevices,
  usePermission,
  useKeyModifier,
} from "@reactuses/core";

function VoiceSearch() {
  const [selectedMic, setSelectedMic] = useState<string>("");
  const [query, setQuery] = useState("");

  const micPermission = usePermission("microphone");
  const [{ devices }, requestDevices] = useMediaDevices({
    requestPermissions: false,
    constraints: { audio: true, video: false },
  });

  const microphones = devices.filter((d) => d.kind === "audioinput");

  const {
    isSupported,
    isListening,
    isFinal,
    result,
    error,
    start,
    stop,
  } = useSpeechRecognition({
    lang: "zh-CN",
    interimResults: true,
    continuous: false,
  });

  const shiftDown = useKeyModifier("Shift");

  // 按住说话:按下 Shift 时开始,松开时停止
  useEffect(() => {
    if (!isSupported || micPermission !== "granted") return;
    if (shiftDown) {
      start();
    } else if (isListening) {
      stop();
    }
  }, [shiftDown, isSupported, micPermission, start, stop, isListening]);

  // 当识别最终化时,把结果提交到查询
  useEffect(() => {
    if (isFinal && result) {
      setQuery(result);
    }
  }, [isFinal, result]);

  const permissionColor =
    micPermission === "granted"
      ? "#10b981"
      : micPermission === "denied"
      ? "#ef4444"
      : "#f59e0b";

  return (
    <div
      style={{
        maxWidth: 640,
        padding: 24,
        background: "#ffffff",
        borderRadius: 16,
        boxShadow: "0 4px 24px rgba(15, 23, 42, 0.06)",
        fontFamily: "system-ui, sans-serif",
      }}
    >
      <header
        style={{
          display: "flex",
          justifyContent: "space-between",
          alignItems: "center",
          marginBottom: 16,
        }}
      >
        <h2 style={{ margin: 0, fontSize: 18 }}>语音搜索</h2>
        <span style={{ color: permissionColor, fontSize: 13, fontWeight: 600 }}>
          ● 麦克风:{micPermission || "未知"}
        </span>
      </header>

      {!isSupported && (
        <p style={{ color: "#64748b" }}>
          当前浏览器不支持语音识别。请试试 Chrome。
        </p>
      )}

      {isSupported && micPermission !== "granted" && (
        <button
          onClick={requestDevices}
          style={{
            width: "100%",
            padding: 12,
            background: "#3b82f6",
            color: "white",
            border: "none",
            borderRadius: 8,
            cursor: "pointer",
          }}
        >
          授权麦克风访问
        </button>
      )}

      {isSupported && micPermission === "granted" && (
        <>
          <div style={{ display: "flex", gap: 12, marginBottom: 12 }}>
            <select
              value={selectedMic}
              onChange={(e) => setSelectedMic(e.target.value)}
              style={{
                flex: 1,
                padding: 8,
                borderRadius: 6,
                border: "1px solid #cbd5e1",
              }}
            >
              <option value="">默认麦克风</option>
              {microphones.map((mic) => (
                <option key={mic.deviceId} value={mic.deviceId}>
                  {mic.label || `麦克风 ${mic.deviceId.slice(0, 6)}`}
                </option>
              ))}
            </select>
          </div>

          <div
            style={{
              padding: 16,
              background: shiftDown ? "#dcfce7" : "#f8fafc",
              borderRadius: 8,
              border: shiftDown
                ? "2px solid #10b981"
                : "2px dashed #cbd5e1",
              textAlign: "center",
              transition: "all 120ms ease",
            }}
          >
            <p style={{ margin: 0, fontWeight: 600, fontSize: 13 }}>
              {shiftDown ? "正在监听..." : "按住 Shift 进行口述"}
            </p>
            {result && (
              <p
                style={{
                  margin: "8px 0 0",
                  fontStyle: isFinal ? "normal" : "italic",
                  color: isFinal ? "#0f172a" : "#64748b",
                }}
              >
                {result}
              </p>
            )}
          </div>

          {error && (
            <p style={{ color: "#ef4444", fontSize: 13, marginTop: 8 }}>
              识别错误:{error.error}
            </p>
          )}

          <input
            value={query}
            onChange={(e) => setQuery(e.target.value)}
            placeholder="搜索查询..."
            style={{
              width: "100%",
              marginTop: 12,
              padding: 10,
              borderRadius: 6,
              border: "1px solid #cbd5e1",
              fontSize: 16,
            }}
          />
        </>
      )}
    </div>
  );
}

四个 Hook,四个相互正交的关注点:

  • usePermission 驱动 header 中的徽章,并把 UI 的其余部分挡在用户实际决策之后。因为它是响应式的,如果用户在浏览器设置里撤销了麦克风权限,徽章会自动更新,输入框会自动消失。
  • useMediaDevices 填充麦克风选择器,除非用户点击"授权",否则不会强制弹出权限对话框。
  • useSpeechRecognition 完成实际的转录,区分中间结果和最终结果,并以带类型的方式暴露引擎错误。
  • useKeyModifier 把 Shift 键变成按住说话的触发器,能正确应对焦点丢失、OS 自动重复和奇怪的组合键。

整个组件大概 130 行,绝大多数都是标签。浏览器 API 那些历来最难做对的部分,每个关注点只占一行 import。

关于测试的一点说明

语音和摄像头功能出了名地难测试,因为它们依赖的浏览器 API 需要真实的人手势和物理硬件。这些 Hook 都暴露了 isSupported 标志,所以你的测试环境(jsdom、Vitest、用 mock navigator 的 Storybook)可以在底层 API 缺失时干净地分支并渲染 fallback 状态。如果你在做严肃的语音 UI,请专门划出一小层在 headless Chrome 里用假媒体流跑的集成测试——那才是抓真正 bug 的唯一方式。

安装

npm i @reactuses/core

相关 Hook

  • useSpeechRecognition —— 实时语音转文字,跟踪中间和最终结果
  • useMediaDevices —— 枚举摄像头和麦克风,处理权限
  • usePermission —— 响应式地查询任意权限的 Permissions API
  • useKeyModifier —— 跟踪 OS 级别的修饰键状态(Shift、Control 等)
  • useSupported —— 响应式地检查浏览器 API 是否可用
  • useEventListener —— 声明式地附加事件监听器,可用于自定义语音流程
  • useObjectUrl —— 为录制的音频 blob 创建临时 URL 以预览

ReactUse 提供了 100+ 个 React Hook。全部探索 →

千问电脑版上线语音输入法:打工人终于可以用嘴干活了

Vibe Coding 火了之后,越来越多的人选择对着屏幕口述,而不是敲键盘,不少网友甚至为此整出 AI 语音键盘。

今年 3 月,Anthropic 也给 Claude Code 加了语音模式,在终端输入 /voice,按住空格说话,松手执行。很难想象,连「写代码」这种最依赖键盘输入的场景,都开始支持语音了。

既然写代码的人都开始用嘴干活了,那我一个每天写文章、写方案、做 PPT、整表格的打工人,还在一个字一个字敲键盘,显然也不够高效,尤其是查数据要切三个页面,做个汇报 PPT 要从找模板开始花三小时,整理会议纪要边听边记还漏一半。

究其原因,不是每个人的口头表达都那么好。哪怕也有一些 AI 输入法能解决,比如我们之前介绍过的 Typeless,每年光订阅费每年就得花 1000 块。

在真正干活的电脑端,至今没有和深度 AI 办公能力打通的语音入口。刚好,千问电脑版/网页版最近也上线了千问语音输入法,据说奔着「用嘴干活」而来。而且千问电脑版还是全免费——不只是语音免费,它内置的所有 AI 办公能力,全部敞开用。

别被输入法这个名字骗了

一开始,看到千问语音输入法这个名字,我下意识以为这就是一个识别准确率更好的 AI 输入法,结果我发现完全不是一回事。

千问语音输入法上手几乎没有门槛。两个快捷键搞定一切,按住是语音输入,双击是让 AI 干活。Win 是右 Alt,Mac 是右 Command,你可以根据使用习惯来设置唤起的快捷键。

你在 Word 里写文档也好,浏览器里看资料也好,钉钉里回消息也好,快捷键一按,语音入口浮出来。不用切到千问客户端,不用打开额外窗口。想问就问,想说就说。

千问语音输入法主要就两种使用姿势:按住开始语音输入,想到什么直接说就行,千问帮你自动去口水话、纠正口误、生成结构化表达。双击唤起语音指令,这时候你是在给 AI 派任务,比如查个东西、帮你回消息、生成文档。

打从一开始,它就不只是打算只做一个「帮你打字更快」的输入法。你的嘴负责下达指令,它是一个中枢接口,负责听懂、翻译、调度,让 AI 把活儿干完交给你。

说话就是比打字好使

2026 年,我对一款语音输入法的要求,已经远远不止是识别准确率。「听得清」的逐字听写都是基操,更重要的是理解我想表达什么,再帮我组织好。

比如口述一段想法,它能保留我的意图,帮同事把废话全部过滤掉,口误也顺手修正,吐出来出来的是干净、精炼、可以直接发出去的文字。

比如碰到方案延期这种事,也可以交给千问整理成一段清晰的书面表达,而我只需要直接按住快捷键,随口反馈给千问:

关于这个项目的延期,我… 啊不对,我想说的是关于这个方案的调整,其实原定计划是本周五交付,但是… 呃… 因为客户那边临时加了三个需求点,我们评估了一下大概需要多两天,所以… 不对,我的最终意思是:方案交付时间从本周五调整至下周三,原因是客户新增三个功能点,需要补充技术评估,我们承诺下周三前一定提交初版方案。

松手后结果就出来了,可以看到它自动删除所有「啊不对」「呃」「但是」等语气词,把我表达的核心清晰整理了出来;对比常规的语音输入,只能逐字记录,还要自己手动编辑,千问语音输入法基本无需手动调整,就能直接发出去。

在一些更专业和复杂的项目沟通中,千问语音输入法就更加实用了。

比如下面这个沟通需求,注意看,我长按说了一大堆话后,最后还补了一句:将关于数据部分提前。

这次产品改版的核心目标是提升新用户的留存率。我们在 onboarding 流程里增加了三个引导步骤,把原来的五步走改成了三步走,还在每个节点加了进度提示。另外,我们发现很多用户在第二步就流失了,所以把第二步的表单从 8 个字段缩减到 3 个必填字段。数据方面,改版后一周的留存率从 35% 提升到了 48%,次日留存提升了 12 个百分点。不过也有一个风险,就是表单精简后收集的用户信息变少了,可能会影响后续精准推荐的效果,这个需要持续观察。最后是团队层面的配合,设计部在两周内出了两版方案,开发部用了三天完成上线,整体节奏还是很快的。嗯把数据那段放到最前面,然后分段给我

这里结果对比就更明显了,只有千问听懂了「把数据那段放前面「」的指令,自动重排段落,我用嘴就完成了原本需要鼠标+键盘的操作。

▲ 常规语音输入结果

▲ 千问语音输入法结果

体验过程我还发现了一个让我惊喜的细节,千问语音输入法对于中英文夹杂的口述内容,识别特别到位。

这个函数的主要作用是处理用户登录时的 token 验证,首先会调用 validateToken 方法检查 token 是否过期,如果 expired 的话就返回 401,然后如果是 valid 的话,再调用 getUserInfo 接口去拉取用户信息,最后把 userId 和 role 写进 session 里面。注意一下,这里有一个 edge case,就是当 token 是 refresh token 的时候,要走另外一条逻辑分支。

千问不只把所有的英文术语都识别对了,而且还自动根据我的话分点输出,一目了然。

▲ 千问语音输入法结果

我还想分享一个对于内容创作者特别有帮助的用法,APPSO 每天早上都要开选题会,大家会有很多碎片想法,一个热点现象、一个行业观察、一个趋势判断……

之前有些想法是散装的,不成体系,现在我可以直接在会上按住唤出千问语音输入法,让它将这些想法整理成大纲。比如这一大段我在会上对编辑选题的反馈意见,如下图所示:

▲ 千问语音输入法结果

松开手后,一段详细的选题大纲就出来了,编辑能稳稳接住我的反馈,稍微扩充就能写出一篇深度分析稿件。最后的成文也附上给大家看看:苹果悄悄砍掉丐版 Mac mini,人人都要交「AI 税」的时代来了 。

单就语音输入这个维度,千问给我最大感受是,真就说多快多乱都没关系,反正输出的质量 AI 会兜底。

万物皆可 Vibe,一句话的事

语音输入只是第一步,千问语音输入法更大的价值是还能帮你干活。

上面提到了整理选题大纲,然后我就需要沉浸式写作,但每次要查个数据和报告,都得切到其他网页和应用。这里千问语音输入法就很自然地出现了——它支持在任意软件、桌面全局唤起,不用切换窗口,动动嘴就能直接查。

比如我在写一篇关于 OpenAI 的文章时,刚好有一段要引用最近的融资金额和投资方。我双击唤起语音指令,说一句:「帮我找一下 OpenAI 最新融资背景。」

思考一两秒,千问小窗就直接弹出把详细结果发我了,我看着引用继续写,心流就不会被打断。

假期刚回来,一大堆工作等着推进,我需要整理一个清晰的周报,但又没时间慢慢敲字,于是双击并随口将把需求说了出来,里面夹杂着带着大量口头表达:

诶那个,我汇报下这周进度哈……A 项目目前跟进到第三阶段了,中间遇到了供应商交付延迟,大概迟了三天,后来通过加班把进度赶回来了……B 项目还在需求评审,产品那边原型图有点模糊,约了下周一早上十点对齐……下周还要申请两台测试服务器……你帮我整理为周报 word 文档,语气专业一点,条理清楚。

此外,千问语音输入法还有一个更有意思的功能——帮你回消息。

我每天往往需要在微信、钉钉、飞书等各种项目群里穿梭,回复各种消息。非常消耗精力,这时候我就能让千问让我的「嘴替」了。

比如假期还没过完就被同事催交文章,我就双击让它帮我来一段高情商回复。

▲ 我无需给它介绍背景,它就能根据屏幕内容补充上下文,给我一个「聪明」的回复

而在一些面对客户或者更正式的场合里,我也可以双击让它给我拟一个得体的回复。

这是因为千问语音输入法支持了「场景感知」。它自动识别你当前在什么应用里,看到你屏幕上的内容,据此调整输出的风格。你不用告诉它更多背景,它自己就能看懂。

下周要出差,我直接双击,在微信让它帮我根据聊天信息,整理成一个出行指南便签。

最后给我的这份出行指南,除了航班信息,还贴心地给我整理了待办事项,并根据当地天气和交通情况给了我一些具体建议,这对于常常出差的媒体人来说十分友好。

开周会的时候我还发现了一个实用的小技巧,会议开始,我双击两下唤起千问语音输入法,结束后一句:「帮我把刚才的内容整理成会议纪要。」它就自动帮我整理好了。

这很适合一些快速拉通的临时会议,不用再单独打开会议记录类的应用,随手双击马上记。

对着电脑说话,活儿 AI 自己就干完了

别误会,对着电脑喊「帮我查资料」「写个邮件」,现在只能算 AI 的基本操作。

千问 电脑端这次真正亮出的底牌,是把语音输入和 PPT 创作、AI 表格、文档处理等功能组合起来,这也是真正能帮打工人实现「每天早下班一小时」的实用功能。

拿最折磨人的 PPT 来说,千问不是去素材库里给你拼凑烂大街的野生模板,而是直接用大模型的代码能力动态生成复杂排版。如果你觉得哪里不够完美?直接多轮对话让它接着改,改到你满意为止。

为了探探底,我先让它帮我做个视频号运营课程 PPT,几乎在语音落地的瞬间,AI 就进入了光速消化模式:填充血肉、匹配逻辑一气呵成。

最令人惊喜的是,千问对「图文穿插」的理解并非生搬硬套,而是根据内容深度匹配了差异化的版式,整份 PPT 拿出来,几乎就是可以直接交付的成稿。

这还没完,你还能一次性给千问喂最多 39 种不同格式的参考文件,让它自动梳理逻辑、提炼重点,帮你省去了来回翻资料的麻烦。至于配图,它也能根据上下文自动匹配,找不着合适的甚至能当场给你生图,全程都不需要你切出界面去求助搜索引擎、或者下载下来用 office 处理。

表格处理方面,千问的 Excel Agent 主打一个高水准。

不管是格式随意的聊天截图、手写笔记,还是大段的纯文本,丢给它就能快速生成标准的 Excel 表格。如果后续还要算算增长率、画个趋势图,也不用再去头疼怎么写函数公式了,直接用自然语言吩咐它就能搞定。

我试了一个稍微有点复杂的需求:让它根据 2026 广州最新版初中英语教材,把各句型的语法结构、时态变化和参考例句整理成 Excel 表格,格式要适合一页纸打印,方便拿来背诵。

换以前,这种事得自己一条条查资料、手动录入、再调格式,至少要折腾半小时。现在说一句话,它直接把表格生成好,列名、行距、例句填充,基本不需要再动手改。

文档处理这边,Word/PDF Agent 支持图文数据混合上传,能自动排版并输出直接可交付的文件。

更有意思的是,传完长文档你不需要自己去翻阅找重点,直接张嘴问,它就能快速定位给出答案;想修改哪里也是一句话的事儿,省去了自己去对照原文件一点点改的麻烦。

我试着传了一份繁杂的合同 PDF,直接问它:「独家授权内容是哪些?」它并没有傻傻地把全文复述一遍,而是精准定位到了授权条款,把独家范围、授权期限和限制事项一条条列得清清楚楚。

目前,这个语音指令甚至还能和 AI 写代码、手搓网页等任务助理功能组合使用,照这个架势下去,未来的办公形态,大概真的就是「动动嘴皮子就把活儿干了」。

和 AI 说话的人,会比键盘打字的人更早下班

用了一段时间千问语音输入法,我想到一件事。

过去几年「AI 提升办公效率」喊得震天响,但大部分人的体验是:我跟 AI 说了半天,它给我的东西根本不能用。然后就觉得 AI 也不过如此。

问题出在哪?出在沟通方式上。你用键盘跟 AI 对话,40% 的精力花在组织文字上,只剩 60% 在想你到底要什么。给出去的指令信息密度低、上下文薄,AI 当然输出垃圾。这不是 AI 不行,是你喂给它的东西不行。

语音把这个死结解开了。说话时你不会给自己设字数限制,细节会自然地冒出来,上下文会自动变厚。它能把嘴里说出的自然语言需求梳理得井井有条,让 AI 精确执行。

纽约销售平台 Clay 的教育负责人 Yash Tekriwal 提到,他用语音输入的速度是每分钟 205 个词,打字只有 110 到 120 个。但速度还不是最关键的,他发现口述的 prompt 质量更高。

AI 圈最近有个词特别火,叫 harness。它的意思大概是:你有一匹马(AI 的能力),但你得有一套缰绳才能驾驭它,让它往你要的方向跑。没有 harness,马再强壮也只是在原地打转。

千问电脑版的语音输入法就是这套 harness。

它连接的一端是你的嘴,另一端是 AI 的全套办公能力:PPT、表格、文档、搜索、分析、格式转换。你说一句话,它把你的意图翻译成 AI 能执行的指令,然后调度对应的 Agent 去跑腿。它不是输入法,是缰绳。是你驾驭 AI 办公能力的那套 harness。

而别的「带 AI 功能的输入法」解决的是什么?是入口问题,帮你找到 AI 在哪里。千问解决的是驾驭问题,帮你把 AI 的能力精确地用起来。一个是给你指路,一个是帮你套好缰绳直接上路。差距就在这。

在 Agent 时代,语音本来是驱动 AI 工作的最自然和高效的方式。千问语音输入法,就是率先在桌面入口端出了这套 harness 的产品 ,这也是为什么我期待,未来在更多终端上,能看到这种真正能驾驭 AI 的语音入口。

去年这个时候,如果你在办公室突然对着电脑说话,一次两次会被当成在打电话,三番五次就不禁让人怀疑,工作压力是不是太大了,精神状态还好吗?

今年开始,那些对着电脑自言自语的,可能就是全公司最早下班 (摸鱼) 的人。

附客户端下载地址:
https://www.qianwen.com/download?ch=tongyi_redirect
网页版体验地址:
https://www.qianwen.com/

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

索尼拟斥资近40亿美元收购比伯和尼尔·杨等音乐版权

据报道,索尼音乐正与黑石集团敲定一项交易,拟收购后者旗下包括贾斯汀·比伯和尼尔·扬作品在内的音乐目录。若交易达成,将成为音乐史上规模最大的版权收购案之一。据悉索尼已进入排他性谈判阶段,计划收购黑石旗下的Recognition Music Group。该公司拥有或管理超过4.5万首歌曲的版权。索尼将与新加坡主权财富基金GIC共同组建合资企业完成此次收购,交易价格预计在35亿至40亿美元之间。(界面)
❌