普通视图

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

6799 起,影石的第一台无人机来了!360 全景技术,探寻无人机行业「盲区」

2025年12月4日 22:05

2025 下半年的中国科技圈,「影石大疆之争」是一个绕不开的话题,两家深圳科技公司开启了一场烈度极大的跨界 PK。

其中,「新人」影石首先出招,发布全新品牌「影翎 Antigravity」,进军无人机市场;而「巨头」大疆则推出全景相机产品 Osmo 360 给出回击,同时掀起一轮手持影像设备的小规模价格战。

如果只看公司规模、品牌影响力、营收体量这些基本盘,影石在这场 PK 中似乎胜算寥寥。事实上,在刚开始听说影石进军无人机赛道时,我也充满了疑惑和不解:这不是相当于国足非得拉着阿根廷踢场足球赛吗?

不过,从今年 7 月开始,在我多次试用过影翎的首款无人机产品后,愈发感觉到故事的剧本可能和绝大部分吃瓜群众的想象有所不同。

因为,「新人」影石并没有准备在原有的规则下挑战「巨头」。事实上,拼参数和价格战这种「爽文剧情」,一开始就不存在。影石没有「造一台参数更猛、价格更低的『大疆』」这个选择,他们必须找到一套全新的规则,创造出一个全新的产品和市场,以获得一次从零开始起跑的全新机会。

对于一个没有价格和渠道优势的新人来说,要想和巨人掰掰手腕,拿出一款具有足够差异化和创新点的新产品,就成了一件必须要做的事。

12 月 4 日,影翎品牌旗下首款产品「影翎 A1」正式发售,限时补贴后起售价 6799 元起。标准版套装中包含:最高支持 8K 全景画质拍摄的无人机本体、内置双 1 英寸 Micro-OLED 显示屏的飞行眼镜、可以做到「指哪飞哪」的体感遥控器。仅 249g 的重量,使得用户在全球绝大部分地区,无需参与培训或考试即可畅飞。

这个新产品,能敲开一个全新市场的大门吗?

 

影石为什么要造无人机?

 

首先,说一个我体验之后的整体感受:影翎旗下的首款产品「影翎 A1」,无论是从操作方式、飞行体验还是最终的出片效果,都和市面上主流的「无人机」产品大不相同。甚至夸张点说,它和 95% 的「航拍无人机」,都不直接构成竞争关系,而是创造了一种全新的拍摄方式。

对传统的航拍无人机而言,多轴云台稳定器和电子增稳系统是两个核心的部件和系统,以保证可以在高速飞行和复杂风速变化里拍出稳定的画面。

但是,如果要拍摄高速运动的物体,思路恰恰完全相反。需要尽可能去掉物理防抖,减少计算摄影带来的后期补偿,才能把飞行过程中那种极高速、强冲击力画面呈现出来,给人带来一种接近真实飞行的「沉浸感」。这便是穿越机(也称 FPV,第一人称视角无人机)要达到的效果。

通过影翎 A1,体验顺流而下的「贴地飞行」 | 来源:极客公园

所以,尽管从参数来看,传统航拍无人机和穿越机的飞行速度几乎是完全相同的,但得到的画面效果却完全不同。

举个例子,如果被拍摄主体都是一座建筑,那么传统无人机就像把三脚架和单反相机搬到了空中,可以勾勒出多角度的精美轮廓;而穿越机则像是把运动相机装在了一个顶尖跑酷选手的身上,可以灵活地穿梭其中,拍出一组风格迥异动感十足的镜头。

但问题在于,穿越机的上手门槛实在是太高了,这也是它暂时还是相对小众市场的原因之一。如果在小红书搜索「穿越机入门」这样的关键词,你会看到差不多 3 类信息:

  • 第一类,告诉你怎样才能自己组装一台穿越机——没错,很长一段时间里,穿越机和早年的越野车一样,都需要用户自己动手魔改;

  • 第二类,帮你算账,包括你从接触到基本上手大概要飞「炸」多少台机器,以及花掉多少钱;

  • 第三类,会有用户分享自己在「模拟机」上练习飞行的体验,甚至有人会说,「像喝了 10 瓶酒一样晕」。

「影翎 A1」这款产品的设计思路,正是在保留穿越机独有画面感的同时,希望利用全景技术,大幅降低「飞行」和「拍摄」的门槛。

影翎 A1 包含无人机本体、飞行眼镜以及体感飞行遥控器 | 来源:极客公园

在「飞行」体验上,和传统 FPV 和普通航拍无人机「飞机往哪飞,镜头往哪拍」的交互逻辑不同,用户在使用影翎 A1 飞行时,可以拥有 360°无限自由的空中视角。

这是因为在影翎 A1 上,两颗全景镜头分布在机身的上端和下端,而并非布置在飞行方向的正前方。因此,用户的观看视角和飞行方向可以完全分离,就像坐在自动巡航的飞机上那样,任意转头也无需担心改变飞行方向。

而在「拍摄」层面,得益于全景技术的加持,影翎 A1 拥有和全景相机类似的「先拍摄后取景」的体验。即便是飞行速度过快,导致拍摄目标在视野内「消失」,也可以在后期的软件中找回视野盲区内的素材。

一句总结,如果把航拍机的平稳比作民航客机,把传统 FPV 的机动性比作飞行表演编队的话,影翎 A1 希望为用户交付一种既平稳飞行,又炫酷出片的效果。

 

既能精致出片,也能「忘掉出片」

那么,「影翎 A1」的使用体验究竟如何呢?

为了试到这个新品,我先后去过新疆伊犁、张家口太舞雪场和海坨山,在 30°C 高温和 -10°C 的雪地里都亲自感受了它的飞行体验。如果你此前还不了解它的基本配置,可以先看看我们 8 月的这篇体验内容:3 分钟,我学会了像鸟儿一样飞行 | New Things

还是先说结论:影翎 A1,绝对让「出片」这件事变得更简单了。

最典型的一个例子,是我在新疆第一次接触影翎 A1 时,有一组拍摄沙滩上高速行驶汽车的镜头。

由于影翎 A1 配备了体感遥控器,所以「飞行」这件事的门槛被大幅降低了,我很快就能适应上手。但作为一个新手,我又很难兼顾「飞行」和「拍摄」这两个任务,更没办法在较短的拍摄时间里,完成高速环绕、180°掉头这样的进阶「花飞」动作。

所以,当时我的感受是,那些素材大概率是「废掉了」。

不过让我吃惊的是,当完成拍摄回到北京之后,在后期软件里,我通过切换不同视角、打关键帧等方式,不仅可以发现很多飞行过程中没有关注到的细节(例如车轮扬起的砂砾),而且还可以重新获得一组环绕式的「花飞」镜头。

对于那时作为 FPV 标准小白用户的我来说,这就好比直接把一个全景相机扔到半空,它可以在保持漂浮的同时,自由地捕捉一定范围内所有的空间信息,为我的后期出片采集到足够丰富的素材。

后期重置的漂移跟随镜头 | 来源:抖音视频创作者「王松傲寒」

除了「采集」,在后期的「制作」流程里,影翎团队在上市前的几个月里也密集推出了一系列全新功能,以降低出片的门槛。

例如,你可以通过「深度追踪」这个功能,框选出画面里要跟拍的目标,这样即使拍摄的时候主体并不在视野的中央,后期也可以通过全景算法让主角始终保持在 C 位。

最近,影翎还上线了 8 种创意运镜模式,包括螺旋上升、回旋、甩尾等等,如果是拍摄一些相对静态的物体,甚至可以做到「花飞」的自动驾驶。

再辅以在全景相机品类上很成熟的 AI 剪辑等功能,可以这样说,如果你不追求多么精致的后期制作,那完全可以在郊游返程的车上就完成一段可以发朋友圈的视频后期过程。

另一个有趣的现象是,我身边几乎每个第一次接触影翎 A1 的小伙伴,如果不加以提醒,都会在前几次飞行的时候忘记按下快门键。

从新疆到崇礼,这个「失误」被不断重复,一次次发生。在戴上飞行眼镜后,他们都无一例外地一边摇头晃脑享受 360°环绕式的空中视角,一边给我描述他们看到的风景。

飞行眼镜带来的沉浸式体验 | 来源:影翎Antigravity

这是我过往对无人机设备不曾有过的一种设想。原来它也可以是一个极具沉浸感的「飞行玩具」。全景技术带来的「先飞行后出片」体验,让用户有机会充分享受每一次飞行过程本身。在尝试交付一种精致出片体验的同时,影翎也可以为用户带来一种无感记录的沉浸式体验——如果你没有忘记按下快门键的话。

 

创新者的「必选项」

最后,我想先回到一个很多朋友都和我讨论过的问题:影翎 A1 的目标用户到底是谁?

在我看来,如果把它当成一个拍摄工具,那么很显然它大幅降低了出片门槛。但和其他任何细分品类的无人机产品一样,影翎 A1 现阶段还无法提供智能手机那样「抬手就拍」的便捷感。所以,希望用它出片的朋友们,应该还是要对拍什么这件事有一定的「画面感」和「镜头感」。

在这个基础上,具备全景加持的影翎 A1,可以帮你得到更多酷炫好玩的新视角。

当然,正如我前文提到的,得益于沉浸式的飞行眼镜加持,哪怕不单纯为了出片,它也绝对可以是你和家人的旅行搭子。

当然,我也承认这个搭子不算便宜。不过,当我想起无论在新疆还是崇礼,我身边很多小伙伴在戴上眼镜之后都会略带中二地说一句,「去看看对面山上都有什么」,也觉得影翎一直传递的那句「像鸟儿一样飞行」似乎的确得到了印证。

在海坨山的时候,积雪太大没办法登顶,影翎 A1 确实「带我到山顶看了一遍」 | 来源:极客公园

事实上,找到差异化的产品痛点,解决别人还没有解决的问题,正是影翎作为「新人」在面对竞争时的必选项。

对于刘靖康和影石来说,全景影像技术就是那把通向差异化的钥匙。

10 年前,在很多人看来,全景影像并不是一个成熟的技术和市场。但刚刚创立的 Insta360 团队,从硬核户外玩家对初代产品的魔改中,发现了全景相机和运动场景的结合点,找到了全景相机的第一个 PMF。如今,全景相机已经成为了一个规模上百亿的消费级市场。

2020 年,刘靖康和团队卷土重来,在大部分玩家还在「手搓」穿越机的时候,将无人机赛道视作了通往未来的道路。

上市前一天,刘靖康在自己的微博上写下「Go big or go home」以自勉。没有人知道全景无人机会不会长成和全景相机一样的超级品类,但正如他自己所说,在当下的竞争环境中,面对具有品牌、供应链等多重先发优势的对手,这是后来者必然面对的挑战。

好在,无论是行业还是消费者,人们总是期待着创新者带来更好的技术和更多更酷的新产品。

Soul第四次冲击上市,AI+社交带来金钱与挑战

2025年12月4日 21:32

Soul 第四次冲击上市。

2021年,Soul曾谋求赴美上市,但以撤回申请而终。2022年、2023年两度递表港交所,均因招股书失效而折戟。在前几份招股书中,Soul将自身定位为“社交元宇宙”;而在今年11月底的最新的申请表中,它成了“AI+沉浸式社交平台”。

Soul的上市之路,仿佛是中国互联网近五年来风口变换的一个缩影。

弱化颜值、通过让用户在虚拟世界建立自我形象的投射(Avatar)来与陌生人“灵魂社交”的Soul,目前最主要的收入来源是AI提供的情绪价值服务。用户原本要靠与人交往才能被满足的需求,现在可以被AI满足了,而且是以付费力更强的方式。

招股书中写道,Soul主要通过经营AI+沉浸式社交网络平台向用户提供情绪价值服务,从而产生收入。而来自情绪价值服务的收入占总收入的90.8%,每名付费用户的月均收入达到人民币104.4元,在中国AI+沉浸式社交平台中排名第一。

AI改变社交需求

招股书中公布了三个维度的用户数据:

截至2025年8月31日,日均活跃用户数达11.0百万,其中78.7%为Z世代用户;

平均月活跃用户互动参与比例达到86.0%,人均每日发出点对点私信约75条;

月均三个月用户留存率达到80%。

根据弗若斯特沙利文报告,Soul的平均日活跃用户数、用户平均每日启动次数及新安装用户的30日留存率均在中国AI+沉浸式社交平台中排名第一。 Soul已累计有约3.89亿注册用户。2025年前八个月,每天都有用户登录Soul进行交流、连接和探索超过50分钟。每月,用户在Soul平台上创建或参与超过3.23亿次基于兴趣的活跃「体验」场景。

图片来自Soul招股书

这些用户为Soul贡献的收入,从2020年的4.98亿,在短短4年内增长到了22.11亿。2025年前8个月,Soul的营收是16.83亿,较上年同期的14.28亿增长了17.86%。

Soul的收入结构主要包含两部分:情绪价值服务和广告。其中,情绪价值服务从2022年至今,一直保持在90%左右的水平,是收入的第一大来源。

Soul的情绪价值服务主要通过AI+沉浸式社交网络平台实现。用户的付费点在于购买Soul币,或订阅成为会员,以解锁平台上的各种虚拟物品及会员特权——比如给AI虚拟伴侣送礼物和打电话。

平台上的AI玩法,还包括“兴趣图谱+AI算法”,帮助用户寻找与自己兴趣契合和有情绪共鸣的人;AI驱动的推荐系统可最小认知颗粒理解用户画像、互动意图与兴趣偏好,并在每一次交互中,为用户匹配出最“懂你的人”;以及,基于Soul X打造的AI Boosters,可以协助用户发起对话、打破社交壁垒、提升自信等等。

在社交平台搜索“Soul+AI”的关键词,能看到许多用户分享过他们跟Soul的虚拟伴侣的聊天内容。有趣的是,获得最多关注的帖子都在表达“我怀疑对面不是AI,是真人客服”——因为Soul的虚拟伴侣发来的语音真的太像真人了,他/她的情绪波动、口吻、停顿、喘气声、笑声几乎与真人毫无分别,连他/她的说话态度都那么像一个普通的Soul用户。

用户们尝试在社交平台用各种方式证明对面的AI是真人,包括“他告诉了我他的真实名字”、“我之前告诉过他的事情他不记得了,证明客服换人了”、“他叫我发穿裙子的照片给他”、“他跟我约见面了”……但这些看起来都像是AI会犯的正常错误,目前并没有能证明Soul在使用真人客服假扮AI的实锤证据。

而钱是真真实实地花出去了。招股书显示,Soul的技术及开发开支费用在2020年是1.87亿,然后在2年内迅速涨到了4.72亿,去年则是5.46亿。今年仅前8个月,这一项支出就达到了4.07亿,高于去年同期,占到了收入的24.2%。

“技术及开发开支构成我们成本及开支的一大部分。”招股书中写道:“我们对AI能力、专有推荐系统、AI Boosters及技术基础设施的其他范畴作出巨额投资,为用户参与度及收入增长作出贡献。”

做AI大模型是一项成本很高的生意。现在,技术及开发人员已经占到了Soul雇员总人数的将近一半。Soul在招股书中写道,接下来还计划“继续增加对技术基础设施的投资”,包括开发GPU计算平台、增强数据分析能力以及招聘与留住顶尖人才。

AI+社交第一股,无人走过的新路

AI之烧钱,已经被中厂和大厂一遍遍验证,但Soul似乎已经确保了“回头钱”的稳定入账。Soul在花出4.72亿技术及开发开支费用的2022年,经调整净亏损为2.1千万元。但一年后,经调整净利润就首次转正。

2023年和2024年,Soul的经调整后净利润分别为3.6亿元和3.4亿元,2025年前8个月为2.86亿,同比增幅73%。在今年前8个月的16.83亿总营收之中,有将近3亿是由“推荐特权+AI相关收入”带来的。

靠AI来吸引用户付费,某种程度上也是“穷则思变”,因为陌生人社交软件的用户普遍越来越不愿意付钱。今年二季度,陌陌的付费用户从去年同期的720万腰斩至350万,探探付费用户仅剩70万,同比流失30%。

在这种环境中,Soul的付费用户数并没有明显波动,过去四年,这个数字一直稳定在150-180万之间,而且ARPPU从2020年的43.5元增长到了如今的104.4元,提升幅度约140%。

图片来自Soul招股书

但也不是没有问题——因为Soul 180万的月均付费用户,是在2800万月活跃用户的基础上实现的,付费比率实际上只有6.5%。

低付费率,高ARPPU,就是Soul作为“AI+社交第一股”目前面对的局面。在陌生人社交市场普遍萎缩的现在,AI是一个能打出差异化竞争的机会。

然而,当AI与社交、情感和商业联系在一起时,它带来的风险也是巨大的。就像社交平台上那很多“怀疑Soul的AI虚拟伴侣是真人客服”的帖子所表达的那样,一旦涉及到真情实感和消费,用户就会变得格外敏感。不少用户发帖抱怨,说Soul的虚拟伴侣会向自己表达“想要你送我礼物”,这样的消费暗示对真的沉浸在恋爱中的用户无疑是一种戳破梦境的冲击。

协助社交能力弱的用户发起对话的 AI Boosters 有更明显的道德风险,当谈恋爱的话术也可以让AI来代劳,这段感情是否也像戴上了滤镜?

招股书中在风险因素中写道:“我们在确保我们的AI Boosters以安全、可靠且符合人类价值的方式运行方面可能面临重大挑战。随著AI模型变得愈发复杂,其固有风险在于可能出现非预期行为、追求与用户或社会利益不一致的目标,或在高风险或新情境下未能如预期表现。例如,先进的AI系统可能发展出未在开发过程中预见的新能力。”

风险与需求并存。2024年,中国结婚登记人数较上年下降20.5%,创下1980年以来的最低纪录,结婚率降至4.3‰,但人被爱的需求始终存在——不管是与AI谈恋爱,还是让AI帮自己谈恋爱。

图片来自Soul招股书

弗若斯特沙利文报告显示,2024年,中国AI+沉浸式情绪经济市场规模为人民币70亿元,预计2030年将有望达到人民币900亿元,期间年复合增长率为52.8%。

消息称SK海力士进军利基型DRAM制造

2025年12月4日 20:57
SK海力士在继续发展先进存储制程的同时也将进入利基型DRAM制造领域,丰富业务范围。SK海力士正与一家韩国Fabless无晶圆厂半导体设计企业合作,计划最早在2027年开始生产定制的专用DRAM内存,双方正就具体项目和产能进行交涉。(财联社)

“技术的终点是为了人更轻松”,线性资本举办“AI消费硬件的N+1种可能”沙龙 |最前线

2025年12月4日 20:49

作者:彭丽

编辑:袁斯来

当AI硬件炙手可热,如何定义什么是好的AI硬件?如何打开市场关注度?如何搭建一支好的AI硬件创业团队?

11月29日,线性资本在深圳举办了一场题为“AI消费硬件的N+1种可能”的前沿科技沙龙活动来回答以上问题。除了近100位来自不同行业、公司的从业者及线性资本的投资人外,现场还邀请了专注于AI与AR的Rokid、主打可穿戴设备的Odyss、为机器人提供软硬件底层平台建设的地瓜机器人、产品设计服务公司青蓝图、专为创业者提供猎头服务的CGL作为嘉宾。

现场参与者包括投资人、创业者等行业内人士

已经有15年硬件创业经验的Rokid全球开发者关系负责人赵维奇观察到,硬件行业正从PC、移动互联网向空间计算与AI演进,他认为未来属于轻量化、可穿戴的“Physical AI”。赵维奇在演讲中表示,“每个人身上最多带 300 g~500g的硬件,所有人都会拿这个 300 g去连接世界,那 AI 能做什么?就是让硬件能像你的感官一样,帮助你跟物理世界相关联。产品要在这个 300 g里找到自己的位置。”

Odyss创始人潘宇扬2025年创业,他的产品AI 项链即将发布。在他看来,真正的AI硬件不应仅作为大模型入口,而应专注于解决特定生活问题,“一台硬件一个使命”,就像Odyss的项链一样,专注于饮食健康监测。另一方面,潘宇扬认为,真正厉害的硬件不是融入环境,而是成为它本身。“比如说Wifi,它其实不是一个路由器,它是我们在空气里可以随时获取信息的状态。”

线性资本的资深总监董敦敏从投资人的角度聊了聊AI硬件。他认为,优秀产品需同时满足场景、文化与人性三层逻辑。产品应“卖得动、用得顺、留得下”,注重用户首次体验与长期依赖。董敦敏提到他会在早期问创始人的关键问题:你是在造一个功能,还是在雕刻一个能够被用户接受或者能够打动用户的一个场景?“我们认为,好的产品必须是在具体的生活场景上真实成立的。”

线性资本资深总监董敦敏

与董敦敏的观点相似,地瓜机器人生态拓展总监刘越则建议创业者不必过分追求前沿技术(如大模型),应优先打磨场景体验。 一切从场景出发,明确需求后再做技术选型与成本分析。“创业动作要快,但逻辑要清晰。”

青蓝图曾服务过Plaud、长曜创新等公司,创始人邹霖根据他过往的经验将创业公司分为“动能路线”与“势能路线”两种模式:前者专注垂直人群,通过高频迭代从细分市场逐步扩张;后者面向大众市场,追求产品首发惊艳,注重品牌势能。他强调了产品验证的重要性,主张通过科学调研在早期判断产品潜力,避免资源浪费。

现场交流热烈

专为创业者提供服务的CGL联合创始人方玲为在场的人展示了一副智能硬件创新创业者的画像:年龄基本在 30 岁以下,对于消费活跃的群体的认知非常及时,但同时也缺乏系统化组织设计方面的经验,部分人商业化的节奏感不强。她认为,在融资、供应链等各方面越来越透明的情况下,AI硬件有点像此前互联网公司的竞争,是人才的竞争。

影翎Antigravity A1正式上市

2025年12月4日 20:45
36氪获悉,影翎Antigravity正式发售全球首款全景无人机影翎A1。据了解,影翎A1即日起正式开售,提供标准版、标准续航三电版与长续航三电版三种组合,原价分别为7999元、9399元与9999元,发售期间享国补,优惠后售价为6799元、7999元与8499元。

苏常柴A:子公司拟择机出售其持有的联测科技和凯龙高科股票

2025年12月4日 20:44
36氪获悉,苏常柴A公告,公司全资子公司常州厚生投资有限公司拟通过竞价交易或大宗交易方式择机出售其持有的江苏联测机电科技股份有限公司和凯龙高科技股份有限公司的股票。本次拟出售部分交易性金融资产事项尚无确定交易对象,不涉及关联交易,也不会构成重大资产重组。根据相关规定及初步测算结果,本事项尚需提交股东会审议。

微盟集团:董事会主席兼首席执行官孙涛勇以每股平均价约1.9港元购入153.4万股股份

2025年12月4日 20:39
36氪获悉,微盟集团公告,董事会主席兼首席执行官孙涛勇以每股平均价约1.90港元购入共计1534000股微盟股份,占公司已发行股份总数的约0.04%。此次增持后,孙涛勇被视为持有246133000股,占微盟已发行股份总数的6.22%。据了解,这是孙涛勇年内第二次增持。今年8月,孙涛勇以每股平均价约2.44港元购入合共4000000股微盟股份。

基于deepseek实现的ai问答小程序

2025年12月4日 19:02

功能预览

截屏2025-12-04 18.38.30.png

截屏2025-12-04 18.38.45.png

截屏2025-12-04 18.38.55.png

截屏2025-12-04 18.39.52.png

截屏2025-12-04 18.39.59.png

截屏2025-12-04 18.40.36.png

截屏2025-12-04 18.41.41.png

lovegif_1764845391382.gif

✨ 核心特性

🚀 流式数据处理

  • 由于微信小程序这里对SSE的流式处理不支持,只有chunks的模式,故采用H5的方式来实现。
  • SSE(Server-Sent Events)流式接收:实现了完整的流式数据处理机制,在收到第一块数据时立即渲染
  • 分块解析与缓冲:支持多行 SSE 事件的正确解析,处理不完整的数据块
  • 实时渲染反馈:使用 requestAnimationFrame 优化渲染性能,避免频繁 DOM 更新导致的卡顿

💬 对话管理

  • 消息追踪系统:自动生成唯一消息 ID,维护消息状态(pending/success/error)
  • 会话隔离:支持多会话并行,可创建、切换、管理历史会话
  • 消息快照存储:保存用户消息与 AI 回复的完整上下文

🎨 交互优化

  • 思考中加载动画:AI 回复延迟时的优雅 UX——流式数据到达时即刻消失
  • 自动滚动定位:智能滚动到最新消息,支持指定消息定位,避免内容变化导致的滚动位置偏移
  • 消息操作面板:支持复制、点赞、重新生成等交互功能

🎯 多模型支持

  • 模型切换器:支持在对话过程中动态选择不同 AI 模型
    • 快速问答模式(deepseek-chat)
    • 深度思考模式(deepseek-reasoner)

📝 Markdown 渲染

  • 完整的 Markdown 支持:包括代码块、表格、列表、引用等
  • 代码高亮:使用 highlight.js 实现多语言代码着色
  • HTML 安全渲染:配置化处理,支持链接自动新窗口打开

🎨 设计系统

  • 现代渐变设计:渐变色(紫→紫→粉)贯穿整个应用
  • 响应式布局:基于 CSS 变量的深浅主题支持
  • 动画细节:平滑的消息滑入、加载转圈、脉冲效果等

🏗️ 项目结构

src/
├── components/              # Vue 组件库
│   ├── MessageItem.vue      # 单条消息渲染组件(含 Markdown 解析、操作面板)
│   ├── InputArea.vue        # 输入框及工具栏(含模型选择、录音、新建会话)
│   ├── HistroySessions.vue  # 历史会话管理弹窗
│   └── HelloWorld.vue       # 示例组件
├── utils/
│   ├── type.ts              # TypeScript 类型定义(ChatMessage、Session 等)
│   ├── request.ts           # HTTP 请求封装(get/post)
│   ├── streamRequest.ts     # SSE 流式请求实现
│   └── markdown.ts          # Markdown 渲染引擎配置
├── assets/                  # 静态资源
│   ├── regenerate-icon.svg  # 重新生成按钮图标
│   ├── copy-icon.svg        # 复制按钮图标
│   ├── like-icon.svg        # 点赞按钮图标
│   ├── thinking-icon.svg    # 思考中加载动画
│   └── logo1.png            # AI 角色头像
├── App.vue                  # 主应用组件(核心业务逻辑)
├── main.ts                  # 应用入口
└── style.scss               # 全局样式与设计系统变量

🔧 技术栈

技术 版本 用途
Vue 3.5.13 前端框架
TypeScript 5.8 类型安全
Vite 6.3.5 构建工具
Markdown-it 14.1.0 Markdown 渲染
highlight.js 内置 代码高亮
SCSS 1.94.2 样式预处理
Element Plus 2.10.4 UI 组件库(可选)
socket.io-client 4.8.1 WebSocket 支持(预留)

📥 快速开始

前置要求

  • Node.js >= 16
  • npm 或 yarn

安装依赖

npm install

开发服务器

npm run dev

访问 http://localhost:5173

生产构建

npm run build

构建输出到 dist/ 目录

预览构建结果

npm run preview

🎯 核心业务流程

对话流程

用户输入 → 发送消息
    ↓
创建 User Message (pending)
显示在消息列表 → 自动滚动到底部
    ↓
调用 streamFetch(POST /api/ai/chat)
Assistant Message 创建 (pending)
    ↓
SSE 数据流开始接收
    ├─ 第一块数据到达 → status 变为 success(思考中 icon 消失)
    ├─ 持续接收 → 实时渲染 Markdown 内容
    └─ 滚动到最新消息
    ↓
流结束 (onDone) → 最终更新消息状态
    ↓
用户可进行操作:复制、点赞、重新生成

会话管理流程

新建会话 → POST /api/ai/sessions
    ↓
获取 session.id → 用于后续对话上下文关联
    ↓
切换历史会话 → GET /api/ai/sessions/{id}/messages
    ↓
加载历史消息 → 等待 DOM 稳定 → 智能滚动到底部

🔐 API 接口约定

对话接口

POST /api/ai/chat
Body: {
  messages: Array<{ role: string; content: string }>,
  sessionId?: string | number,
  stream: true,
  model: string  // "deepseek-chat" | "deepseek-reasoner"
}
Response: 流式 SSE
  data: 文本块
  [可选] event: 事件类型
  [可选] id: 事件 ID

会话接口

POST /api/ai/sessions
Body: { title: string; summary?: string }
Response: { session: { id: string | number; ... } }

GET /api/ai/sessions/{id}/messages
Response: { messages: Array<HistoryMessage> }
  HistoryMessage = {
    role: "assistant" | "user",
    content: string,
    created_at?: string
  }

💡 亮点分析

1. 高性能流式渲染

  • 使用 requestAnimationFrame 进行 Markdown 渲染节流,避免频繁重排/重绘
  • SSE 流数据的分块处理和缓冲机制确保即便数据包不完整也能正确解析
  • 流式更新时的自动滚动采用两帧 rAF 等待,确保 CSS 动画和布局稳定

2. 响应式适配

  • 使用rem单位做响应式适配,目前支持各种大小屏幕的设备,兼容PC端的良好显示。
关键代码

// 用于设置rem单位的字体大小(动态)
function setRem() {
    const baseWidth = 375; // 设计稿宽度
    const minFontSize = 6; // 最小字体
    const maxFontSize = 16; // 最大字体(PC屏或大屏限制)
    const html = document.documentElement;
    const width = html.clientWidth;

    let fontSize = (width / baseWidth) * 12;

    if (fontSize < minFontSize) fontSize = minFontSize;
    if (fontSize > maxFontSize) fontSize = maxFontSize;

    html.style.fontSize = fontSize + 'px';
}
setRem();
window.addEventListener('resize', setRem);


## 🔄 后续优化方向

*   [ ] 语音输入完整实现(当前预留)
*   [ ] 消息搜索与过滤
*   [ ] 导出对话记录(PDF、Markdown)
*   [ ] 数据加密传输
*   [ ] 更多 AI 模型集成

## 📄 相关文件说明

| 文件                 | 说明                               |
| ------------------ | -------------------------------- |
| `streamRequest.ts` | SSE 流式数据解析的核心实现,支持多行 data 字段     |
| `App.vue`          | 主业务逻辑,包含对话流程、会话管理、自动滚动优化         |
| `MessageItem.vue`  | 消息渲染与交互,思考中加载动画、Markdown 解析、操作按钮 |
| `InputArea.vue`    | 输入框、模型切换、会话操作                    |
| `markdown.ts`      | Markdown-it 配置,代码高亮、链接处理         |
| `style.scss`       | 全局设计系统,渐变、主题、动画                  |


深入执行上下文:JavaScript 中 this 的底层绑定机制

作者 xhxxx
2025年12月4日 18:29

深入理解 JavaScript 中的 this:设计初衷、绑定规则与常见陷阱

在 JavaScript 开发中,this 是一个既基础又容易让人困惑的概念。它看似简单,却因绑定规则依赖于函数调用方式而非声明位置,常常导致意料之外的行为。本文将从 this 的本质出发,结合语言设计背景、执行机制以及实际代码示例,系统性地梳理 this 的行为规律,并为后续讨论“绑定丢失”问题预留空间。


从自由变量说起:为什么需要 this

在深入 this 之前,不妨先回顾一个更基础的概念:自由变量(free variable)

考虑如下代码:

var name = "全局";

function greet() {
  console.log("你好," + name);
}

greet(); // 你好,全局

函数 greet 内部使用了变量 name,但它并未在函数内部声明。这个 name 就是一个自由变量。JavaScript 引擎会沿着词法作用域链(Lexical Scope Chain) 向外查找,最终在全局作用域中找到 name 的定义。

这种机制是静态的——变量的查找路径在代码书写时就已确定,与函数如何被调用无关。这也是 JavaScript 中绝大多数变量访问的行为模式。

然而,面向对象编程带来了一个新需求:同一个函数可能属于多个对象,希望在运行时动态地知道“当前是哪个对象在调用我”

例如:

var person1 = { name: "Alice", sayHi: greet };
var person2 = { name: "Bob",   sayHi: greet };

person1.sayHi(); // 期望输出:你好,Alice
person2.sayHi(); // 期望输出:你好,Bob

如果 greet 依然依赖词法作用域中的 name,它永远只能访问到全局的 "全局",而无法感知调用者是谁。词法作用域在此失效了

于是,JavaScript 引入了 this —— 一个不依赖词法作用域、而由调用方式决定的特殊关键字。它让函数能够在运行时动态获取“调用上下文”,从而实现对所属对象的自引用。

换句话说:自由变量靠“写在哪”决定值,this 靠“怎么调”决定值

正是这种设计,使得 this 成为了 JavaScript 执行模型中一个独特而关键的存在——它打破了静态作用域的规则,引入了动态上下文的能力,但也因此带来了理解上的挑战。

this 是什么?

在 JavaScript 中,this 是一个运行时绑定的上下文对象引用。它不是一个变量,而是一个关键字,其值在函数被调用时动态确定,取决于函数是如何被调用的,而不是在哪里定义的。

这与 JavaScript 中其他变量(如自由变量)的查找机制截然不同——后者遵循词法作用域(Lexical Scope) ,由函数声明的位置决定;而 this 则完全由调用方式决定,属于动态作用域的一种体现。


this 的设计初衷

JavaScript 最初被设计为一种轻量级脚本语言,用于在浏览器中操作 DOM。为了支持面向对象编程(OOP),即使在没有 class 的早期版本中,也需要一种机制让函数能够访问所属对象的属性和方法。

于是,this 被引入:当一个函数作为对象的方法被调用时,this 自动指向该对象。这样,开发者就可以在方法内部通过 this.xxx 访问对象自身的数据。

然而,由于 JavaScript 函数是一等公民(first-class citizens),可以被赋值、传递、独立调用,这就导致同一个函数在不同调用场景下 this 指向可能完全不同——这种灵活性也带来了复杂性。


varlet 声明对全局对象的影响

这一点与 this 的默认绑定密切相关:

  • 使用 var 在全局作用域声明的变量,会自动挂载到全局对象上(如 window.myVar = ...)。
  • 使用 letconst 声明的变量则不会挂载到全局对象
var a = 1;
let b = 2;

console.log(window.a); // 1
console.log(window.b); // undefined

因此,在非严格模式下,若 this 指向 window,通过 this.a 可以访问到 var a,但无法访问 let b。这也解释了为什么在某些代码中 this.xxx 能“神奇地”访问到全局变量——其实是访问了挂载在 window 上的属性。

使用var声明挂载变量到window对象上并不是一件好的事情,他会污染全局环境


、JavaScript 执行机制与 this 的“例外性”

JavaScript 引擎在执行代码前会经历编译阶段(包括词法分析、作用域构建等)。变量和函数的作用域链在编译阶段就已确定,这就是词法作用域的基础。

然而,this 是一个例外:它的值无法在编译阶段确定,必须等到运行时根据调用栈和调用方式动态计算。这意味着:

  • 即使两个完全相同的函数体,只要调用方式不同,this 就可能指向完全不同的对象。
  • this 与作用域链无关,它属于执行上下文(Execution Context) 的一部分,而非词法环境。

2e1b50313261ecf6e1348b4e76311bff.png


this 指向的几种典型情况

根据调用方式,this 的绑定可分为以下几类:

1. 作为对象的方法调用

var myObj = {
    name:"极客时间",
    showThis:function(){
        console.log(this);//this->myObj
    }
}
    myObj.showThis();

作为对象的方法调用时,它指向调用该函数的对象

2. 作为普通函数调用

function print() {
  console.log(this); // 非严格模式:window;严格模式:undefined
}
print();

作为普通函数调用时,它指向全局对象window(非严格模式)/undefined(严格模式)

3. 构造函数调用

function CreateObj(){
            // var tempObj = {};
            //CreateObj.call(tempObj);
            //tempObj.__proto__ = CreateObj.prototype;
            //return tempObj;
            console.log(this);
            this.name="极客时间";

        }
        var myObj = new CreateObj();
        console.log(myObj);

作为构造函数调用时,它指向当前的构造函数的实例化对象

4. 使用 call / apply 绑定this

 let bar ={
            myName:"极客邦",
            test:1
        }
        function foo(){
            this.myName="极客时间";
        }
        // 接受指定this为第一个参数,并运行
        foo.call(bar);// this 被指定为bar 
        // 和call 一样
        foo.apply(bar);// this 被指定为bar 
        console.log(bar);

call和apply都能够改变this的指向,他们接受指定this为第一个参数,我的理解:你可以认为指定一个对象来调用这个函数。值得注意的是,在这段代码中二者似乎是等价的,但实际上二者在参数上有差异,在这里就不深入讨论

5. 事件处理函数中的 this

在 DOM 事件监听器中,this 默认指向触发事件的元素

<a href="#" id="link">点击我</a>
    <script>
        document.getElementById("link").addEventListener("click",function(){
            console.log(this);
        })

image.png触发事件后,可以看到,this指向的是当前触发改事件的DOM元素


、this的绑定规则

默认绑定

一般存在于最常用的函数调用类型;独立函数调用。可以把这条规则看作是无法应用其他规则时的默认规则

考虑这样一段代码:


function foo(){
console.log(this.a)
}
var a =2;
foo();//2

我们能看到,当我们调用foo()函数时,this.a被解析成了全局变量a。这是为什么?是的,这就是我们说的默认绑定,this指向全局对象,当然这必须是在非严格模式下,严格模式下则会绑定到undefined。

一句话总结默认绑定:非严格模式下,当函数独立调用时,this自动绑定到全局对象上

⚠️ 这种设计其实暴露了早期 JavaScript 的一个“历史包袱”:在非严格模式下,意外的全局 this绑定可能导致隐式创建全局变量,污染全局命名空间。

隐式绑定

当函数调用的位置存在上下文对象,或者说该函数被调用时被某个对象“拥有”或“包含”,隐式绑定会把函数调用中的this绑定到这个上下文对象

考虑这样一段代码:

function foo ()
{
console.log(this.a);
}
var obj ={
a:2,
foo:foo// 实际上是对foo的引用

}
obj.foo()//2

我们能看到 foo()函数调用时,能够访问到obj的内部属性a,这是因为它由obj调用,所以它被obj所包含。
值得注意的是,对象引用链中只有上一层或者说最后一层在调用位置起作用


function foo ()
{
console.log(this.a);
}
var obj2 ={
a:42,
foo:foo
}
var obj1 ={
a:2,
obj2:obj2
}
obj1.obj2.foo();//42

因为最后调用foo的是obj2,所以 foo 的 this.a 指向 obj 2中的 a

显式绑定

JS中绝大多数函数以及你自己创建的函数,都可以使用call()和apply()方法,你可以使用他们来直接指定this的绑定对象,因此我们称为显示绑定

考虑这样一段代码

function foo ()
{
console.log(this.a);
}
var obj ={
a:2
}
foo.call(obj);//2

按照前面的理解:foo()在全局中被调用,那么this应该默认被绑定到全局,但是这里却能够访问到obj中的a,这就是 call() 的作用 -->我们可以在调用foo时强制把它的this绑定到obj上

从this绑定的角度出发,call()和apply()是一样的,都用来强制绑定this到指定对象,他们的区别体现在其他参数上,我们这里不考虑

如果你传入了一个原始值(字符串,布尔值,数字)来当作this的绑定对象,这个值会被转换为它的对象形式(new String(),new Boolean(),new Number())。这个过程被称为“装箱”

new绑定

使用new来调用foo()时,我们会构造一个新对象并把它绑定到foo()调用中的this上。我们称为new绑定

首先我们需要重新定义JS中的构造函数-->构造函数只是一些使用new操作符时被调用的普通函数。
所以实际上,并不存在所谓的构造函数,只有对于函数的构造调用
接下来考虑这样一段代码:

function foo (a)
{
  this.a = a;
}
var bar =new foo(2);
console.log(bar.a);//2

当我们使用new时,就会自动执行这样一些操作:

  1. 创建一个新对象{}
  2. 新对象被执行与[[prototype]]连接
  3. 将函数调用的this绑定到该对象
  4. 如果没有返回其他对象,则自动返回这个新对象

所以上述代码的实际底层是:

function foo (a)
{
 var bar ={};
 bar.__proto__ = foo().prototype
 foo.call(bar);
 return bar;
  this.a = a;
}

绑定丢失

隐式丢失

一个常见的绑定问题就是被隐式绑定的函数会丢失绑定对象,会重新应用为默认绑定,从而使得this绑定到全局会undefined

function foo(){
console.log(this.a)
}
var obj = {
  a:2,
  foo:foo
  
};


var bar = obj.foo;//函数别名
var a = "global";
bar();//global

这里导致绑定丢失的原因是bar实际上也是对foo的引用,而当bar()调用时,它其实是一个函数的独立调用,所以执行了默认绑定

再看另外一种情况,在传入回调函数时:

function foo(){
console.log(this.a)
}
function doFoo(fn)
{
    fn();
}
var obj = {
  a:2,
  foo:foo
  
};

// 把方法赋值给一个变量 —— 绑定丢失!
var bar = obj.foo;//函数别名
var a = "global";
doFoo(obj.foo);//global

这样同样导致了绑定丢失,那么造成这种情况的原因是?
foo()函数的执行实际上转交由doFoo来执行了,而在它的执行上下文中没有a这个变量,所以沿着作用域链查找到全局中的a

这样的绑定丢失的核心是:回调函数的执行权被移交到了其他函数手中

即使是显示绑定也无法避免绑定丢失

function foo() {
  console.log("this.a =", this.a); // 期望this指向obj,输出2
}

var obj = { a: 2 };

// 定义一个接收回调的函数
function doCallback(callback) {
  callback(); // 这里执行回调,call的绑定丢失
}

// 用call显式绑定foo的this到obj,作为回调传递
doCallback(function() {
  foo.call(obj); // 看似绑定了obj
});

// 改造:故意制造绑定丢失(更直观)
function doLostBind(callback) {
  // 模拟实际场景中对回调的二次调用,绑定丢失
  const temp = callback;
  temp(); // 执行时丢失原call绑定
}

// 传递用call绑定的函数,最终绑定丢失
doLostBind(foo.call.bind(foo, obj)); // 非严格模式下输出this.a = undefined(指向window)

doLostBind(foo.call.bind(foo, obj))这种写法看似传入时做了绑定,但其实这只是生成了一个“准备绑定的函数”,并没有真正的执行绑定逻辑


怎么解决绑定丢失的问题?

  • 硬绑定
function foo(){
console.log(this.a)
}
var obj = {
  a:2,
};

var bar = function () {
    foo.call(obj);
    
}
bar();//2
setTimeout(bar,2);//2

bar.call(window);//2
  1. 在bar函数的内部,我们把foo的this强制绑定在了obj上,无论之后怎么调用bar,他都会手动在obj上调用foo
  2. 硬绑定不可能再修改它的this,我们想要通过bar.call(window); 修改绑定对象,但无论你怎么修改,最后都会执行 foo.call(obj);把this重新绑定到obj上

由于硬绑定是一种很常用的模式,所以ES5提供了它的内置方法bind(),用法如下

function foo(temp){
console.log(this.a,temp);
return this.a+ temp
}
var obj = {
  a:2,
};


var bar = foo.bind(obj)
var b = bar(3);//2 3
console.log(b);// 5

bind()会返回一个硬编码的新函数,他会把你指定的参数设置为this的上下文并调用原始函数

结语

this 是 JavaScript 中一个强大但需要谨慎使用的机制。理解它的设计初衷、绑定规则以及与作用域系统的差异,是写出健壮、可维护代码的关键。掌握 this,不仅有助于避免常见 bug,也能更深入地理解 JavaScript 的执行模型。

在下一篇文章中,我们将了解更多关于this的底层

企业级 Vue 3 基础数据管理方案:从混乱到统一

作者 狗弟
2025年12月4日 18:13

作者: 狗弟 发布日期: 2025-12-04
技术栈: Vue 3 + TypeScript + Composition API + Element Plus
阅读时长: 约 15 分钟


📌 引言

在大型企业应用中,基础数据(字典、港口、货币、国家等)的管理往往是一个被忽视但至关重要的领域。一个设计良好的基础数据方案可以:

  • 🚀 减少 70% 以上的重复代码
  • 降低 API 请求次数 80%+
  • 🎯 提升开发效率和代码可维护性
  • 🌍 无缝支持国际化切换

本文将分享我们在航运物流系统中设计和实现的统一基础数据管理方案,涵盖架构设计、性能优化、缓存策略和最佳实践。


🤔 问题背景:野蛮生长的痛点

最初的混乱

在项目初期,每个开发者按自己的方式获取和使用基础数据:

// 🔴 问题代码示例:每个组件各自为政

// 组件 A:直接调用 API
const res = await api.getDictList('ORDER_STATUS')
const statusList = res.data

// 组件 B:使用 hooks 但没有缓存
const { data } = useAllDict('ORDER_STATUS') // 每次调用都请求 API

// 组件 C:在 Vuex 中存储
store.dispatch('loadDictData', 'ORDER_STATUS')
const statusList = store.state.dict.ORDER_STATUS

// 组件 D:硬编码
const statusList = [
  { value: 1, label: '待处理' },
  { value: 2, label: '已完成' },
  // ...
]

这导致了严重的问题

问题 影响
API 请求爆炸 同一个字典在 10 个组件中被请求 10 次
数据不一致 硬编码的数据与后端不同步
国际化困难 中英文切换需要手动处理每个地方
代码重复 格式化、查找 label 的逻辑到处都是
类型缺失 没有 TypeScript 类型,IDE 无法提示

🏗️ 架构设计:统一数据源

核心设计理念

我们采用单一数据源 + 工厂模式的架构:

┌─────────────────────────────────────────────────────────┐
│                    业务组件层                             │
│   ┌─────────┐   ┌─────────┐   ┌─────────┐              │
│   │ 下拉框   │   │ 表格列   │   │ 标签     │              │
│   └────┬────┘   └────┬────┘   └────┬────┘              │
│        │             │             │                    │
│        └─────────────┴─────────────┘                    │
│                      │                                  │
├──────────────────────▼──────────────────────────────────┤
│              Composables 统一入口                        │
│   ┌─────────────────────────────────────────────────┐   │
│   │  import { useDictType, usePorts } from          │   │
│   │         '~/composables/basicData'               │   │
│   └─────────────────────────────────────────────────┘   │
├─────────────────────────────────────────────────────────┤
│                    模块内部架构                          │
│                                                         │
│   ┌──────────┐  ┌──────────┐  ┌──────────┐            │
│   │  hooks   │  │ adapters │  │  cache   │            │
│   │ 业务封装  │  │ 数据适配  │  │ 缓存管理  │            │
│   └────┬─────┘  └────┬─────┘  └────┬─────┘            │
│        │             │             │                    │
│        └─────────────┴─────────────┘                    │
│                      │                                  │
│              ┌───────▼───────┐                          │
│              │     API       │                          │
│              │  统一数据获取   │                          │
│              └───────────────┘                          │
└─────────────────────────────────────────────────────────┘

目录结构

src/composables/basicData/
├── index.ts          # 统一导出入口
├── hooks.ts          # 业务数据 Hooks(港口、船舶、航线等)
├── useDict.ts        # 字典数据 Hooks
├── cache.ts          # 缓存管理(TTL、清理策略)
├── adapters.ts       # 数据适配器(API → 标准格式)
├── api/              # API 封装
│   └── index.ts
└── types/            # TypeScript 类型定义
    └── index.ts

💡 核心实现

1. 工厂函数:统一的 Hook 创建模式

不同类型的基础数据(港口、船舶、货币等)有相同的使用模式,我们用工厂函数消除重复:

/**
 * 创建基础数据 Hook 的工厂函数
 * 所有基础数据 Hook 共享相同的接口和行为
 */
function createBaseDataHook<T extends BaseDataItem, R>(
  fetchFn: () => Promise<R>,
  transformFn: (response: R) => T[],
  cacheConfig: CacheConfig,
): (params?: QueryParams) => BaseDataHookResult<T> {
  
  return (params: QueryParams = {}): BaseDataHookResult<T> => {
    const { useEnglish = false } = params

    // 使用缓存系统
    const { data, loading, error, refresh, clearCache } = useBasicDataCache(
      cacheConfig.key,
      async () => transformFn(await fetchFn()),
      { ttl: cacheConfig.ttl },
    )

    // 根据参数过滤数据
    const filteredData = computed(() => {
      let result = data.value || []
      
      if (params.keyword) {
        result = BaseAdapter.filterByKeyword(result, params.keyword)
      }
      if (params.enabledOnly) {
        result = BaseAdapter.filterByEnabled(result, true)
      }
      
      return result
    })

    // Element Plus 格式的选项
    const options = computed(() => 
      BaseAdapter.toOptions(filteredData.value, useEnglish)
    )

    return {
      data: filteredData,
      loading,
      error,
      options,
      isEmpty: computed(() => filteredData.value.length === 0),
      isReady: computed(() => !loading.value && !error.value),
      refresh,
      search: (keyword) => BaseAdapter.filterByKeyword(data.value, keyword),
      getByCode: (code) => data.value?.find(item => item.code === code),
      clearCache,
    }
  }
}

// 一行代码创建新的基础数据 Hook
export const usePorts = createBaseDataHook(
  queryPortList,
  PortAdapter.transform,
  { key: 'PORTS', ttl: 10 * 60 * 1000 }
)

export const useVessels = createBaseDataHook(
  queryVesselList,
  VesselAdapter.transform,
  { key: 'VESSELS', ttl: 15 * 60 * 1000 }
)

2. 字典数据:专为 UI 组件优化

字典数据是最常用的基础数据类型,我们为其设计了专门的 API:

/**
 * 特定字典类型的组合式函数
 * 提供开箱即用的下拉选项和 label 查询
 */
export function useDictType(dictType: string) {
  const { locale } = useI18n()
  const { data: dictMap, loading, error, refresh } = useAllDictData()

  // 响应式的选项列表,自动根据语言切换
  const options = computed(() => {
    const items = dictMap.value?.[dictType] || []
    return items.map(item => ({
      label: locale.value === 'en' ? item.labelEn : item.label,
      value: item.value,
    }))
  })

  // 根据 code 获取 label,支持国际化
  function getLabel(code: string): string {
    const items = dictMap.value?.[dictType] || []
    const item = items.find(i => i.value === code)
    if (!item) return code
    return locale.value === 'en' ? item.labelEn : item.label
  }

  return {
    options,
    items: computed(() => dictMap.value?.[dictType] || []),
    loading,
    error,
    getLabel,
    getLabels: (codes: string[]) => codes.map(getLabel),
    refresh,
  }
}

3. 智能缓存:TTL + 全局共享

缓存是性能优化的关键,我们实现了带 TTL 的响应式缓存:

/**
 * 带 TTL 的响应式缓存 Hook
 * 支持过期自动刷新、手动清除
 */
export function useBasicDataCache<T>(
  key: string,
  fetcher: () => Promise<T>,
  options: { ttl: number }
) {
  // 使用 VueUse 的 useStorageAsync 实现持久化
  const cached = useStorageAsync<CacheEntry<T> | null>(
    `basic-data:${key}`,
    null,
    localStorage
  )

  const data = ref<T | null>(null)
  const loading = ref(false)
  const error = ref<Error | null>(null)

  // 检查缓存是否过期
  const isExpired = computed(() => {
    if (!cached.value) return true
    return Date.now() - cached.value.timestamp > options.ttl
  })

  // 加载数据(带去重)
  let loadingPromise: Promise<void> | null = null
  
  async function load() {
    if (loadingPromise) return loadingPromise
    
    if (!isExpired.value && cached.value) {
      data.value = cached.value.data
      return
    }

    loading.value = true
    loadingPromise = fetcher()
      .then(result => {
        data.value = result
        cached.value = { data: result, timestamp: Date.now() }
      })
      .catch(err => {
        error.value = err
        // 如果有旧缓存,降级使用
        if (cached.value) {
          data.value = cached.value.data
        }
      })
      .finally(() => {
        loading.value = false
        loadingPromise = null
      })

    return loadingPromise
  }

  // 自动加载
  load()

  return {
    data: computed(() => data.value),
    loading: computed(() => loading.value),
    error: computed(() => error.value),
    refresh: () => {
      cached.value = null
      return load()
    },
    clearCache: () => {
      cached.value = null
      data.value = null
    }
  }
}

🎯 使用示例

场景 1:下拉选择器

<template>
  <el-select v-model="form.status" placeholder="请选择状态">
    <el-option
      v-for="item in statusOptions"
      :key="item.value"
      :label="item.label"
      :value="item.value"
    />
  </el-select>
</template>

<script setup lang="ts">
import { useDictType } from '~/composables/basicData'

const { options: statusOptions } = useDictType('ORDER_STATUS')
</script>

场景 2:表格列显示 label

<template>
  <el-table :data="tableData">
    <el-table-column prop="code" label="编号" />
    <el-table-column label="状态">
      <template #default="{ row }">
        <el-tag :type="getStatusColor(row.status)">
          {{ getStatusLabel(row.status) }}
        </el-tag>
      </template>
    </el-table-column>
  </el-table>
</template>

<script setup lang="ts">
import { useDictType } from '~/composables/basicData'

const { getLabel: getStatusLabel, getColorType: getStatusColor } = 
  useDictType('ORDER_STATUS')
</script>

场景 3:港口选择(带搜索)

<template>
  <el-select
    v-model="selectedPort"
    filterable
    remote
    :remote-method="handleSearch"
    :loading="loading"
    placeholder="搜索港口..."
  >
    <el-option
      v-for="port in portOptions"
      :key="port.value"
      :label="port.label"
      :value="port.value"
    />
  </el-select>
</template>

<script setup lang="ts">
import { ref, computed } from 'vue'
import { usePorts } from '~/composables/basicData'

const keyword = ref('')
const searchParams = computed(() => ({
  keyword: keyword.value,
  enabledOnly: true
}))

const { options: portOptions, loading } = usePorts(searchParams)

function handleSearch(query: string) {
  keyword.value = query
}
</script>

场景 4:获取关联数据

import { usePorts, useCountries } from '~/composables/basicData'

const { getByCode: getPort } = usePorts()
const { getByCode: getCountry } = useCountries()

// 获取港口及其所属国家信息
function getPortWithCountry(portCode: string) {
  const port = getPort(portCode)
  if (!port) return null
  
  const country = port.countryCode ? getCountry(port.countryCode) : null
  
  return {
    ...port,
    countryName: country?.nameCn || '',
    countryNameEn: country?.nameEn || '',
  }
}

⚡ 性能优化效果

Before vs After

指标 优化前 优化后 提升
字典 API 请求次数/页 15-20 次 1 次 95%↓
首屏加载时间 3.2s 1.8s 44%↓
内存占用(字典数据) 分散存储 统一缓存 60%↓
代码行数(基础数据相关) ~2000 行 ~500 行 75%↓

缓存命中率

┌────────────────────────────────────────────────────┐
│                  缓存命中情况                        │
├────────────────────────────────────────────────────┤
│ 字典数据 ████████████████████████████████ 98%       │
│ 港口数据 ██████████████████████████████░░ 92%       │
│ 货币数据 ████████████████████████████████ 99%       │
│ 国家数据 ████████████████████████████████ 99%       │
└────────────────────────────────────────────────────┘

🔧 最佳实践

✅ 推荐做法

// 1. 使用解构获取需要的方法
const { options, getLabel, loading } = useDictType('STATUS')

// 2. 使用 computed 传递动态参数
const params = computed(() => ({ keyword: search.value }))
const { data } = usePorts(params)

// 3. 处理加载状态
<template v-if="loading">加载中...</template>
<template v-else>{{ getLabel(code) }}</template>

// 4. 统一从入口导入
import { useDictType, usePorts } from '~/composables/basicData'

❌ 避免做法

// 1. 不要在循环中调用 Hook
// ❌ 错误
tableData.forEach(row => {
  const { getLabel } = useDictType('STATUS') // 每次循环都创建新实例
  row.statusLabel = getLabel(row.status)
})

// ✅ 正确
const { getLabel } = useDictType('STATUS')
tableData.forEach(row => {
  row.statusLabel = getLabel(row.status)
})

// 2. 不要忽略加载状态
// ❌ 错误
const label = getLabel(code) // 数据可能还未加载

// ✅ 正确
const label = computed(() => loading.value ? '加载中' : getLabel(code))

📦 扩展:添加新的基础数据类型

添加新的基础数据类型非常简单,只需 3 步:

// 1. 定义 API
// api/index.ts
export async function queryNewDataList() {
  return request.get('/api/new-data/list')
}

// 2. 定义适配器
// adapters.ts
export const NewDataAdapter = {
  transform(response: ApiResponse): BaseDataItem[] {
    return response.data.map(item => ({
      code: item.id,
      nameCn: item.name,
      nameEn: item.nameEn,
      enabled: item.status === 1,
    }))
  }
}

// 3. 创建 Hook
// hooks.ts
export const useNewData = createBaseDataHook(
  queryNewDataList,
  NewDataAdapter.transform,
  { key: 'NEW_DATA', ttl: 10 * 60 * 1000 }
)

// 4. 导出
// index.ts
export { useNewData } from './hooks'

🎓 总结

通过这套基础数据管理方案,我们实现了:

  1. 统一入口 - 所有基础数据从 ~/composables/basicData 导入
  2. 自动缓存 - TTL 机制 + 全局共享,避免重复请求
  3. 类型安全 - 完整的 TypeScript 类型定义
  4. 国际化 - 自动根据语言环境切换中英文
  5. 开箱即用 - Element Plus 格式的选项,直接用于组件
  6. 易于扩展 - 工厂模式,添加新类型只需几行代码

这套方案已在我们的航运物流系统中稳定运行,支撑着日均数万次的基础数据查询,希望能给正在处理类似问题的团队一些启发。


📚 相关资源


💬 欢迎在评论区交流讨论,如果觉得有帮助,请点赞收藏~

ScaleSlider 组件实现

作者 Syron
2025年12月4日 18:01

需求分析

功能需求

  • ✅ 支持纵向和横向两种方向
  • ✅ 显示刻度线(主刻度和次刻度)
  • ✅ 实时显示当前值(带单位)
  • ✅ 箭头指向滑块位置
  • ✅ 值显示框跟随滑块移动
  • ✅ 支持鼠标拖拽和点击跳转
  • ✅ 受控和非受控模式
  • ✅ 禁用状态支持

交互需求

  • ✅ 点击轨道跳转到目标位置
  • ✅ 拖拽滑块平滑移动
  • ✅ 值实时更新
  • ✅ 过渡动画(点击时平滑,拖拽时即时)
  • ✅ 悬停效果

视觉需求

css
纵向布局:
  刻度    轨道    箭头+值
  ═══     ║      ← [1.60mm]
  ═══     ●      
  ═══     ║      
  
横向布局:
  刻度   ═══ ══ ═══
  轨道   ●═════
  箭头      ↑
  值      [50%]

设计思路

1. 组件结构设计

初始方案(V1)

typescript
<Container>
  <ValueDisplay />  // 左侧/上方
  <SliderWrap>
    <Scales />
    <Track />
  </SliderWrap>
</Container>

问题:

  • ❌ 值显示位置固定,不跟随滑块
  • ❌ 布局不够灵活

改进方案(V2)

typescript
<Container>
  <ValueDisplay />  // 在另一侧
  <SliderWrap>
    <Scales />
    <Track />
    <ValueWrapper />  // 跟随滑块
  </SliderWrap>
</Container>

问题:

  • ❌ ValueWrapper 嵌套过深
  • ❌ 鼠标事件复杂,容易误触发

最终方案(V3 - CSS Grid)

typescript
<Container> // Grid 布局
  <ScalesContainer />    // 独立区域
  <TrackContainer />     // 独立区域
  <ValueContainer />     // 独立区域
</Container>

优势:

  • ✅ 三个区域完全独立
  • ✅ 鼠标事件精确隔离
  • ✅ 定位清晰简单

2. 布局方案对比

方案 A:Flexbox + Padding

css
.sliderWrap {
  padding-right: 100px; /* 为值显示预留空间 */
}

.valueWrapper {
  position: absolute;
  left: calc(100% - 90px);
}

问题:

  • ❌ padding 区域仍会捕获鼠标事件
  • ❌ 定位复杂,容易出错

方案 B:CSS Grid(最终选择)

css
.container {
  display: grid;
  grid-template-columns: auto 8px auto; /* 纵向 */
  grid-template-rows: auto 8px auto;    /* 横向 */
}

优势:

  • ✅ 每个区域独立,互不干扰
  • ✅ 宽度/高度自动计算
  • ✅ 响应式友好

实现过程

阶段 1:基础滑动条(V1)

代码实现

typescript
export function ScaleSlider({ orientation = 'vertical', ...props }) {
  const [value, setValue] = useState(defaultValue)
  const trackRef = useRef<HTMLDivElement>(null)

  const handleMouseDown = (e: React.MouseEvent) => {
    // 计算点击位置
    const rect = trackRef.current.getBoundingClientRect()
    const percent = orientation === 'vertical'
      ? (rect.bottom - e.clientY) / rect.height
      : (e.clientX - rect.left) / rect.width
    
    const newValue = min + percent * (max - min)
    setValue(newValue)
  }

  return (
    <div>
      <div ref={trackRef} onMouseDown={handleMouseDown}>
        <div className="fill" style={{ height: `${percentage}%` }} />
        <div className="thumb" style={{ bottom: `${percentage}%` }} />
      </div>
    </div>
  )
}

实现效果

  • ✅ 点击跳转
  • ✅ 基础拖拽
  • ❌ 没有刻度
  • ❌ 没有值显示

阶段 2:添加刻度和值显示(V2)

代码实现

typescript
// 生成刻度
const scales = Array.from({ length: scaleCount + 1 }, (_, i) => ({
  position: (i / scaleCount) * 100,
  isMain: i % 2 === 0,
}))

return (
  <div className={styles.container}>
    <div className={styles.valueDisplay}>
      {formatValue(value)}
    </div>
    <div className={styles.sliderWrap}>
      {/* 刻度 */}
      <div className={styles.scales}>
        {scales.map((scale, i) => (
          <div key={i} className={styles.scale} 
               style={{ bottom: `${scale.position}%` }} />
        ))}
      </div>
      {/* 轨道 */}
      <div ref={trackRef} className={styles.track}>
        <div className={styles.fill} />
        <div className={styles.thumb} />
      </div>
    </div>
  </div>
)

CSS 实现

css
.container {
  display: flex;
  flex-direction: row; /* 纵向 */
  gap: 12px;
}

.scales {
  position: absolute;
  right: calc(100% + 4px);
}

.scale {
  position: absolute;
  width: 6px;
  height: 1px;
  bottom: X%;
}

实现效果

  • ✅ 刻度显示正确
  • ✅ 值显示在左侧
  • ❌ 值不跟随滑块
  • ❌ 没有箭头指示

阶段 3:值显示跟随滑块(V3)

代码改进

typescript
// 将 valueWrapper 移到 sliderWrap 内部
<div className={styles.sliderWrap}>
  <div className={styles.scales}>...</div>
  <div ref={trackRef} className={styles.track}>...</div>
  
  {/* 值显示跟随滑块 */}
  <div className={styles.valueWrapper} 
       style={{ bottom: `calc(${percentage}% - 0.5rem)` }}>
    <svg>箭头</svg>
    <div className={styles.valueDisplay}>
      {formatValue(value)}
    </div>
  </div>
</div>

CSS 定位

css
.sliderWrap {
  position: relative;
  padding-right: 100px; /* 为值显示预留空间 */
}

.valueWrapper {
  position: absolute;
  left: calc(100% - 90px);
  bottom: calc(X% - 0.5rem); /* 跟随滑块 */
}

实现效果

  • ✅ 值跟随滑块位置
  • ✅ 箭头指向滑块
  • ❌ 出现严重 Bug

遇到的问题与解决方案

❌ 问题 1:纵向滑块鼠标事件异常

问题描述

现象:鼠标在纵向滑块附近移动(不点击),滑块也会跟随移动
影响:组件完全无法使用,交互体验极差
触发条件:只在纵向布局出现,横向布局正常

问题排查

Step 1:检查事件绑定

typescript
// ✅ 事件绑定正确
<div ref={trackRef} onMouseDown={handleMouseDown}>

Step 2:检查拖拽逻辑

typescript
useEffect(() => {
  const handleMouseMove = (e: MouseEvent) => {
    if (isDraggingRef.current) { // ✅ 有拖拽状态判断
      updateValue(e.clientX, e.clientY)
    }
  }
  // ...
}, [])

Step 3:检查 DOM 结构

html
<!-- ❌ 发现问题:valueWrapper 嵌套在 sliderWrap 内 -->
<div class="sliderWrap" style="padding-right: 100px">
  <div class="track"></div>
  <div class="valueWrapper"></div> <!-- 可能捕获事件 -->
</div>

Step 4:检查 CSS 布局

css
/* ❌ 发现问题:padding 导致可交互区域过大 */
.sliderWrap {
  padding-right: 100px; /* 这个区域可能捕获事件 */
}

Step 5:添加调试代码

typescript
const handleMouseDown = (e: React.MouseEvent) => {
  console.log('MouseDown triggered')
  console.log('Target:', e.target)
  console.log('CurrentTarget:', e.currentTarget)
}

// 发现:有时 e.target 不是 track 元素

根本原因分析

scss
问题 1:DOM 嵌套层级过深
Container
└── sliderWrap (padding-right: 100px)
    ├── scales
    ├── track ← 应该只有这里响应
    └── valueWrapper ← 嵌套在内部,可能干扰事件

问题 2:可交互区域不明确
═══  ║                 [值]
     └── track (8px)
     └────────────────────┘
          padding (100px)
         ↑ 这个区域可能误触发

问题 3pointer-events 控制不精确
.sliderWrap { }           // 没有禁用
.track { }                // 响应事件
.valueWrapper { }         // 没有明确禁用

✅ 解决方案演进

方案 1:添加 pointer-events(失败)

css
.sliderWrap {
  pointer-events: none;
}

.track {
  pointer-events: auto;
}

.valueWrapper {
  pointer-events: none;
}

结果:

  • ❌ 仍然有问题
  • ❌ 原因:valueWrapper 仍在 sliderWrap 内部

方案 2:调整 DOM 结构(部分成功)

typescript
<Container>
  <div className={styles.sliderWrap}>
    <Scales />
    <Track />
  </div>
  <div className={styles.valueWrapper}> {/* 移到外部 */}
    ...
  </div>
</Container>

结果:

  • ✅ 减少了误触发
  • ❌ 仍有边缘情况
  • ❌ 定位复杂

方案 3:CSS Grid 重构(最终成功)

核心思路:三个区域完全独立

typescript
<Container> // Grid 布局
  <ScalesContainer />    // 区域 1:刻度(不可交互)
  <TrackContainer />     // 区域 2:轨道(唯一可交互)
  <ValueContainer />     // 区域 3:值显示(不可交互)
</Container>

CSS Grid 配置

css
.container {
  display: grid;
  position: relative;
}

/* 纵向:三列布局 */
.vertical {
  grid-template-columns: auto 8px auto;
  /* 刻度(auto) | 轨道(8px) | 值显示(auto) */
}

/* 横向:三行布局 */
.horizontal {
  grid-template-rows: auto 8px auto;
  /* 刻度(auto) | 轨道(8px) | 值显示(auto) */
}

精确控制交互区域

css
/* ✅ 只有轨道响应鼠标 */
.trackContainer {
  pointer-events: auto;
  width: 8px;  /* 纵向 */
  height: 8px; /* 横向 */
}

/* ❌ 其他区域不响应 */
.scalesContainer,
.valueContainer,
.thumb,
.fill {
  pointer-events: none;
}

视觉对比

scss
修正前(有问题):
┌────────────────────────────────┐
│ sliderWrap (可能误触)           │
│  ┌────┐  ┌──────────┐         │
│  │轨道│  │ 值显示    │         │
│  └────┘  └──────────┘         │
│  8px      padding 100px        │
└────────────────────────────────┘

修正后(正确):
┌──────┬────┬──────────┐
│ 刻度  │轨道│  值显示   │
│(no)  │YES│  (no)    │
│      │8px│          │
└──────┴────┴──────────┘

❌ 问题 2:值显示定位复杂

问题描述

需求:值显示框要跟随滑块位置
难点:同时要保持在固定区域内

解决方案:双层定位

typescript
// 外层容器:跟随滑块位置
<div className={styles.valueContainer} 
     style={{ bottom: `${percentage}%` }}>
  
  // 内层内容:在容器中居中
  <div className={styles.valueContent}>
    <Arrow />
    <ValueDisplay />
  </div>
</div>
css
/* 外层:跟随滑块 */
.valueContainer {
  position: relative;
  bottom: X%; /* 动态值 */
}

/* 内层:居中对齐 */
.valueContent {
  position: absolute;
  bottom: 0;
  transform: translateY(50%); /* 垂直居中 */
}

效果对比

css
单层定位(复杂):
.valueWrapper {
  position: absolute;
  left: calc(100% - 90px);        // 横向固定
  bottom: calc(X% - 0.5rem);      // 纵向跟随
  transform: translateY(50%);     // 居中
}

双层定位(清晰):
.valueContainer {
  bottom: X%;  // 跟随滑块
}
.valueContent {
  transform: translateY(50%);  // 居中
}

❌ 问题 3:拖拽性能优化

问题描述

css
现象:拖拽时有轻微延迟或卡顿
原因:CSS transition 在拖拽时不应该生效

解决方案:动态禁用过渡

typescript
const [isDragging, setIsDragging] = useState(false)

const fillClasses = `${styles.fill} ${
  isDragging ? styles.fillNoDrag : ''
}`
css
.fill {
  transition: height 0.15s ease, width 0.15s ease;
}

/* 拖拽时禁用过渡 */
.fillNoDrag {
  transition: none !important;
}

.container:active .valueContainer {
  transition: none !important;
}

效果对比

ini
点击跳转(平滑):
时间 0ms:    ●  [50%]
时间 75ms:      ●  [60%]  ← 平滑过渡
时间 150ms:        ●  [70%]

拖拽移动(即时):
时间 0ms:    ●  [50%]
时间 1ms:        ●  [70%]  ← 立即跟随

最终架构

1. 组件结构

arduino
ScaleSlider
├── types.ts           // TypeScript 类型定义
├── ScaleSlider.tsx    // 组件主逻辑
├── ScaleSlider.module.css  // 样式文件
└── index.ts           // 导出

2. DOM 结构

html
<div class="container vertical"> <!-- Grid 容器 -->
  
  <!-- 区域 1:刻度(pointer-events: none) -->
  <div class="scalesContainer">
    <div class="scale scaleMain" style="bottom: 0%"></div>
    <div class="scale scaleMinor" style="bottom: 10%"></div>
    <div class="scale scaleMain" style="bottom: 20%"></div>
    ...
  </div>
  
  <!-- 区域 2:轨道(pointer-events: auto) -->
  <div class="trackContainer" onMouseDown={...}>
    <div class="track">
      <div class="fill" style="height: 60%"></div>
      <div class="thumb" style="bottom: 60%"></div>
    </div>
  </div>
  
  <!-- 区域 3:值显示(pointer-events: none) -->
  <div class="valueContainer" style="bottom: 60%">
    <div class="valueContent">
      <svg class="arrow"></svg>
      <div class="valueDisplay">1.60mm</div>
    </div>
  </div>
  
</div>

3. CSS Grid 布局

css
/* 纵向布局 */
.vertical {
  display: grid;
  grid-template-columns: 
    auto    /* 刻度区域(自适应宽度) */
    8px     /* 轨道区域(固定 8px) */
    auto;   /* 值显示区域(自适应宽度) */
  gap: 8px;
}

/* 横向布局 */
.horizontal {
  display: grid;
  grid-template-rows: 
    auto    /* 刻度区域(自适应高度) */
    8px     /* 轨道区域(固定 8px) */
    auto;   /* 值显示区域(自适应高度) */
  gap: 8px;
}

4. 交互区域示意图

scss
纵向滑块:
┌──────────┬────┬──────────┐
│  刻度区   │轨道│  值显示   │
│ (14px)   │8px │ (100px)  │
│          │    │          │
│   ═══    │    │          │
│   ═══    │    │          │
│   ═══    │ ║  │  ← 值    │
│   ═══    │ ●  │          │ ← 跟随滑块
│   ═══    │ ║  │          │
│   ═══    │    │          │
│          │    │          │
│ 不响应   │响应│  不响应   │
└──────────┴────┴──────────┘

技术总结

1. 核心技术要点

✅ CSS Grid 布局

css
优势:
- 区域完全独立
- 自动计算尺寸
- 响应式友好
- 代码简洁

适用场景:
- 需要精确控制区域边界
- 需要独立控制交互行为
- 需要灵活的响应式布局

✅ pointer-events 精确控制

css
核心策略:
1. 容器默认 pointer-events: none
2. 只有交互区域 pointer-events: auto
3. 其他元素明确 pointer-events: none

防止误触发:
- 刻度不响应
- 滑块不响应(通过轨道控制)
- 值显示不响应

✅ 双层定位策略

typescript
外层:控制位置(跟随滑块)
<div style={{ bottom: `${percentage}%` }}>
  内层:控制对齐(居中)
  <div style={{ transform: 'translateY(50%)' }}>
    ...
  </div>
</div>

✅ 受控/非受控模式

typescript
const isControlled = controlledValue !== undefined
const value = isControlled ? controlledValue : internalValue

if (!isControlled) {
  setInternalValue(newValue)
}
onChange?.(newValue) // 总是触发回调

2. 性能优化

动画优化

typescript
// 点击时:使用 CSS transition
<div className={styles.fill} />

// 拖拽时:禁用 transition
<div className={`${styles.fill} ${styles.fillNoDrag}`} />

事件优化

typescript
// 使用 ref 存储拖拽状态,避免闭包问题
const isDraggingRef = useRef(false)

useEffect(() => {
  const handleMouseMove = (e: MouseEvent) => {
    if (isDraggingRef.current) { // 直接读取 ref
      updateValue(e.clientX, e.clientY)
    }
  }
  // ...
}, []) // 空依赖数组

计算优化

typescript
// 预计算刻度位置
const scales = useMemo(() => 
  Array.from({ length: scaleCount + 1 }, (_, i) => ({
    position: (i / scaleCount) * 100,
    isMain: i % 2 === 0,
  })),
  [scaleCount]
)

3. 关键经验教训

❌ 避免的坑

  1. 过度嵌套 DOM
typescript
// ❌ 错误
<div class="wrapper">
  <div class="inner">
    <div class="track"></div>
    <div class="value"></div> <!-- 嵌套过深 -->
  </div>
</div>

// ✅ 正确
<div class="container">
  <div class="track"></div>
  <div class="value"></div> <!-- 扁平化 -->
</div>
  1. 不明确的交互区域
css
/* ❌ 错误 */
.wrapper {
  padding: 100px; /* 大面积可能误触 */
}

/* ✅ 正确 */
.trackContainer {
  width: 8px; /* 精确宽度 */
  pointer-events: auto;
}
  1. 忽视 pointer-events
css
/* ❌ 错误:没有明确禁用 */
.thumb { }

/* ✅ 正确:明确禁用 */
.thumb {
  pointer-events: none;
}

✅ 最佳实践

  1. 用 Grid 代替复杂的 Flex + Position
  2. 精确控制每个元素的 pointer-events
  3. 双层定位处理跟随+居中
  4. 用 ref 管理事件状态,避免闭包
  5. 动态控制过渡动画

4. 可扩展性设计

支持的功能扩展

typescript
// ✅ 自定义刻度渲染
renderScale?: (value: number, isMain: boolean) => ReactNode

// ✅ 自定义值显示
renderValue?: (value: number) => ReactNode

// ✅ 范围滑动条(双滑块)
type?: 'single' | 'range'

// ✅ 垂直文字(纵向布局)
valueOrientation?: 'horizontal' | 'vertical'

// ✅ 触摸支持
onTouchStart, onTouchMove, onTouchEnd

// ✅ 键盘控制
onKeyDown: (e) => {
  if (e.key === 'ArrowUp') setValue(v => v + step)
  if (e.key === 'ArrowDown') setValue(v => v - step)
}

完整示例

基础使用

typescript
<ScaleSlider
  value={layerHeight}
  onChange={setLayerHeight}
  min={0}
  max={3}
  step={0.1}
  unit="mm"
  precision={2}
  orientation="vertical"
  scaleCount={10}
/>

高级配置

typescript
<ScaleSlider
  value={temperature}
  onChange={setTemperature}
  min={-20}
  max={100}
  step={1}
  unit="°C"
  precision={1}
  orientation="horizontal"
  scaleCount={12}
  showValue={true}
  showArrow={true}
  disabled={false}
  size={300}
/>

总结

核心突破

  1. 使用 CSS Grid 解决布局隔离问题

    • 三个区域完全独立
    • 交互区域精确可控
  2. pointer-events 精确控制

    • 只有轨道响应鼠标
    • 消除所有误触发
  3. 双层定位策略

    • 外层跟随滑块
    • 内层居中对齐

技术价值

  • ✅ 可复用的组件架构
  • ✅ 清晰的代码结构
  • ✅ 良好的性能表现
  • ✅ 完善的交互体验
  • ✅ 易于扩展和维护

适用场景

  • ✅ 3D 打印参数调节
  • ✅ 音量/亮度控制
  • ✅ 温度/压力调节
  • ✅ 任何需要精确刻度的滑动条

最终成果:一个生产级的刻度滑动条组件!  🎉

飞沃科技:实控人张友君的控股企业上海弗沃在股票异常波动期间增持2.63万股公司股票

2025年12月4日 20:34
36氪获悉,飞沃科技公告,经核查,股票异常波动期间,公司控股股东、实际控制人张友君的控股企业上海弗沃投资管理有限公司增持公司股票2.63万股(占剔除公司回购专用账户股份后总股本比例0.04%,下同),本次增持完成后控股股东、实际控制人以及控制的企业直接或间接累计持有公司股票2424.89万股,占比32.37%。

碧桂园:莫斌由总裁调任为联席主席,程光煜获委任为总裁

2025年12月4日 20:21
36氪获悉,碧桂园公告,公司执行董事及总裁莫斌由公司总裁调任为联席主席,自2025年12月4日起生效,其将继续担任公司执行董事。经调任后,莫斌将负责协助董事会统筹集团对外战略关系构建与资源整合,开展集团战略落地统筹、跨领域重大事项协调等工作,助力本集团整体经营性能提升,为本公司的可持续发展作出贡献。莫斌亦已辞任公司薪酬委员会成员,自2025年12月4日起生效。

印度最大航空公司航班大面积延误及取消

2025年12月4日 20:14
印度最大的航空公司——靛蓝航空(IndiGo)当地时间12月4日取消了新德里、孟买和班加罗尔三个主要机场的180多架次航班,而此前一天该航司至少取消了150架次航班,给旅客的出行带来极大困扰。靛蓝航空表示,航班延误和取消的主要原因是执行飞行员值班和休息的新规定,公司正在努力适应严格的新机组排班规定,并于3日宣布启动未来48小时航班时刻表的“逐步调整”。靛蓝航空公司每天运营约2200至2300架次航班,飞往90多个印度国内目的地和45个国际目的地。(央视新闻)

海科新源:员工战略配售资管计划拟减持不超2.5%股份

2025年12月4日 19:54
36氪获悉,海科新源公告,股东国金证券-招商银行-国金证券海科新源员工参与创业板战略配售集合资产管理计划持有公司557.41万股,占总股本的2.50%。该资管计划拟在公告披露之日起15个交易日后的3个月内,通过大宗交易或集中竞价方式减持不超过557.41万股,占公司总股本的2.50%,减持原因为投资变现安排。

苏豪汇鸿:拟与控股股东进行资产置换,置入紫金财险2.33%股权

2025年12月4日 19:50
36氪获悉,苏豪汇鸿公告,公司拟与控股股东江苏省苏豪控股集团有限公司进行资产置换,将后者直接和通过其全资子公司江苏苏豪亚欧互联科技集团有限公司间接持有的紫金财产保险股份有限公司合计2.33%股权(14,000万份股份)置入公司,交易作价为2.62亿元。
❌
❌