普通视图
加拿大股市全年累涨28% 创2009年以来最大涨幅
2025年12月31日武汉地铁线网客运量首次突破600万乘次
铁路迎来元旦假期客流最高峰,1月1日全国铁路预计发送旅客1825万人次
2025年黑龙江省粮食总产量达1640.06亿斤
超越「平替」,年轻人的第一支 50mm F/1.4 镜头
印度烟草股因印度对香烟征收新税而下跌
上汽通用五菱2025年累计销量突破163.5万辆,同比增长6.2%
海南自贸港封关以来新增经营主体近1.2万家
国开行2025年发放城市建设领域贷款1.16万亿元
淘宝闪购启动年货节,30分钟“马上到家”
绥芬河口岸出入境客流激增
库克发了张疑似 AI 生图,把反 AI 神剧《同乐者》给背刺了
![]()
苹果全新大戏《同乐者》终于完结,「炸裂」的大结局,大家看了吗?
![]()
为了庆祝大结局播出和圣诞节,苹果 CEO 蒂姆 · 库克在 X 上发布了一条推文,却引发了意料之外的争议。
![]()
油腻的质感、逻辑不通的细节、毫无亮点的画面,引得网友们纷纷留言质问:
这是用 AI 生成的吗?
反 AI 电视剧,被 AI 背刺?
我相信,如果是 Netflix 高管发了一张《怪奇物语》的 AI 图,也不会造成这么大的舆论争议,尽管这部剧集比《同乐者》要火不少。
但不管是《同乐者》,还是放大到 Apple TV 平台本身,都带有强烈的「反 AI」气质。
给没看过《同乐者》的朋友们简单讲述一下剧情:一种神秘的末世病毒席卷了全世界,向世人强行灌输了乐观和满足的情绪,形成统一的「蜂巢意识」。一位悲观的畅销书作家卡罗尔发现自己免疫这种病毒,与此同时病毒群体正在试图转化她和其他免疫者。
![]()
剧集在海内外口碑都不错:MTC 斩获 87 的综合评分,IMDB 用户评分 8.1,豆瓣评分 8.3,算得上今年名列前茅的好剧。
![]()
这部剧集《绝命毒师》《风骚律师》核心主创文斯 · 吉里根打造,独特的美学风格和镜头语言和这两部经典作品一脉相承。苹果还给出了 Apple TV 剧集史上最高的预算——单集 1500 万美元起,让剧集得以充满各种实拍镜头的大场面。凭借《风骚律师》两度提名艾美奖的主演蕾亚 · 塞洪,也已经靠这部剧拿下金球奖提名。
![]()
剧中的一个情节让人印象深刻:卡罗尔和这个蜂巢意识的病毒群体进行接触后发现,这群「同乐者」愿意为她做任何事,满足她任何要求,还集合了人类的智慧和记忆。
不少观众看剧时感到莫名熟悉:这群谄媚、随时帮助、没有其他感情、充满知识的「同乐者」,不就是 ChatGPT 吗?
![]()
不过,对于这种言论,文斯 · 吉里根回应称,他其实从来没用过 AI 聊天机器人,创作《同乐者》时也没考虑到 AI。
我讨厌 AI,AI 是世界上最昂贵、最耗能的抄袭机器。
![]()
▲左: 蕾亚 · 塞洪;右:文斯 · 吉里根
吉里根的立场也直接在剧集制作中体现,片尾字幕特别标注了「本节目由人类制作」,在播出时就引发了不少讨论。
![]()
虽然苹果公司在 AI 技术道路上一往无前,但苹果和各行各业的创作者长期以来保持着密切联系,其实人们更愿意看到苹果继续重视「创作」背后的人文价值。
在《同乐者》播出期间,Apple TV 还发布了新片头和制作幕后:这个看起来像是动画渲染的片头,居然大部分是实拍的成果。
![]()
![]()
这种「全手工」到近乎有点笨拙的创作方式,也被大众解读为苹果对「创作」的尊重,赢得了不少掌声。
在主创旗帜鲜明反对 AI、剧集被当作 AI 寓言、苹果尊重创作的历史种种前提下,库克发布了一张疑似 AI 生成的图来庆祝剧集完结,自然引起了人们的不满和质疑。
![]()
所以这张图真的是 AI 生成的结果吗?苹果没有对此作出回应,有人联系了图片作者 Keith Thomson,一位现代画家,对方给出了这样的回答:
我无法对具体客户项目发表评论。一般来说,我总是手绘绘画,有时也会用到标准的数字工具。
这个似是而非的回答,完全没解决大众的困惑。在这条推文下方,以及更多社交平台上,网友们已经吵成了一锅粥。
一些专业的绘画或科技人士认为,这张图片有着人类手绘的笔触痕迹,并非 AI 出品。
Apple TV 官方账号很快也加入战局,转发推文的同时强调「这是由 Keith Thomson 用 MacBook Pro 创作」,似乎企图用「MacBook Pro」这个老牌创作工具的金字招牌,来为配图正名。
![]()
但认为是 AI 出品的网友证据更加充分:图片的牛奶盒同时标注了「全脂牛奶」和「脱脂牛奶」;盒子上的迷宫也没有解法;画面充满了灰蒙蒙的噪点,这些都是典型的 AI 生图特征。
![]()
也有其他艺术家将这张图和 Keith Thomson 的作品集进行比对,不管是风格、笔触、画面元素的处理方式,都非常不同。
![]()
一些网友也推断,苹果大概率是向这位艺术家买了张配图,结果 Keith Thomson 使用「标准的数字工具」,例如一个用自己作品集训练出来的 AI,生成一张图片再动手改了改交差。
烂图比 AI 图更值得声讨
在没有更多新信息和证据的前提下,这场「是不是 AI」的争论已经成为了一场「罗生门」——观点不同的双方各执一词,事情真相已经扑朔迷离。
著名苹果评论员 John Gruber 直接引用所谓的「奥卡姆剃刀」原则进行推论:在种种复杂的可能性中,最接近事实的往往最简单,Keith Thomson 就是用了 AI。
![]()
▲ AI 检测工具也认为这张图是 AI 出品
艺术家本人模棱两可的态度,其实也坐实了这个结论——对于大部分创作者来说,自己辛苦产出的作品被打为 AI,是绝对不可以接受的,都会第一时间跳出来反驳。
况且,争论进行到这一步,这张图究竟是不是 AI 生成,其实已经不重要了。
就质量本身而言,这张图片细节拙劣,画面粗糙,你很难承认它有什么审美上的价值——这和大部分 AI 产图一样。
![]()
▲ 右边是我使用 Nano Banana 生成主题相似的图片
AI 生成的低质量图片,和人类粗制滥造的作品,本质上真的有区别吗?本质上不都是一些质量很差、毫无美感的图片?
我们为什么会对 AI 产出嗤之以鼻?因为我们的内心都默认,人类用时间、知识、经验浇灌出来的创作,才是真正优秀的作品,AI 更多是不需要心血、量产的「垃圾」。
但怎么用 AI、用 AI 创作出什么,其实都是人决定的,人的审美、品位决定了 AI 作品的高度。
当创作者不愿意去花时间构思,也没有任何好创意,只想躺着赚快钱,AI 就成替罪羊——可惜的是,这样的创作者现在越来越多,因此我们的生活充满了 AI 生产出来的废料,让大众进一步排斥 AI 创作。
![]()
▲ 可口可乐今年的 AI 假日广告,因为效果太糟糕被吐槽
如果想要去把事情做好,那 AI 就会带来前所未有的可能性。
这个月发布的小米 17 Ultra 徕卡定制版手机,有一个独占的「徕卡一瞬」功能,可以让拍出来的图片模拟出徕卡相机 M9 的风格和质感,也就是所谓的「德味」。
实现的方式,并非单纯的滤镜和照片色彩管理,而是小米和徕卡用大量 M9 拍出的照片,训练出一个大模型,用这个 AI 把图片「修」出德味。
![]()
这些「AI 德味」的照片在文字上会存在一定的幻觉,「AI 篡改照片」也引起了一些非议,有人认为是对徕卡纪实摄影传承的背叛。
但在评测的过程中,爱范儿的编辑们都被这些色彩浓郁的照片打动了,认为这台手机确实还原出我们心中的德味,丝毫不介意它是否「AI」。
更重要的是,「德味」这种曾经只属于部分摄影爱好者的审美和创作权,被 AI 复制后,走向了更多的人。
![]()
▲ AI 还原得了德味,未必能还原文字
这几年,所谓的「AI 艺术家」也正在全球崭露头角,他们不避讳自己作品中的 AI 元素,反而利用 AI 生成那种不按常理出牌的效果,创作出风味前所未有的作品,带来了一种全新的审美。
![]()
▲ 汤海清是一位使用 AI 创作数字影像的艺术家,作品常常结合民俗和梦核元素
我们暂且不去考虑关于 AI 抄袭、量产、「没有灵魂」的争议,单就结果而言,能用 AI 产出好的作品,其实一样会受到大家的欢迎。
回过头来看苹果和 Keith Thomson 这件事,其实给全世界的企业和创作者都上了很好的一课。
即使苹果很可能真的是被 Keith Thomson 「诓骗」,买手绘图收到一张 AI 图,对于那个自诩很有「品味」的苹果来说,也不应该启用这张劣质的配图,来宣传《同乐者》这样极具审美水准的影视作品。
而对于创作者来说,如果你不想自己的作品被打为「AI 生成」,那只能把它做好,而且比越来越强的 AI 还要更好。
#欢迎关注爱范儿官方微信公众号:爱范儿(微信号:ifanr),更多精彩内容第一时间为您奉上。
ListenHub完成200万美元融资,ARR 300万美元进军北美
Promise的总结
土耳其对华免签 机票搜索最高增3.2倍
用 Tauri 2.0 + React + Rust 打造跨平台文件工具箱
用 Tauri 2.0 + React + Rust 打造跨平台文件工具箱
前言
最近在整理电脑文件时,发现了大量重复的照片和视频,占用了几十 GB 的空间。市面上的去重工具要么收费,要么功能臃肿,于是萌生了自己造一个的想法。
正好 Tauri 2.0 正式发布,相比 Electron,它有着更小的包体积和更好的性能。于是决定用 Tauri 2.0 + React + Rust 来实现这个工具。
最终成品:File Toolkit —— 一个跨平台的文件工具箱,支持文件统计、文件去重、视频截取。
GitHub: github.com/220529/file…
功能展示
📊 文件统计
递归扫描文件夹,按类型统计文件数量和大小:
- 支持拖拽选择文件夹
- 按文件类型分组
- 显示占比和总大小
🔍 文件去重
这是核心功能,支持:
- 两阶段扫描:先按文件大小筛选,再计算哈希
- xxHash3 快速哈希:比 MD5 快 5-10 倍
- 并行计算:充分利用多核 CPU
- 大文件采样:只读头部 + 中间 + 尾部,避免全量读取
- 缩略图预览:图片直接显示,视频用 FFmpeg 截帧
- 智能选择:自动选中较新的文件,保留最早的
✂️ 视频截取
- 快速模式:无损截取(-c copy),秒级完成
- 精确模式:重新编码,时间精确到毫秒
- 时间轴预览:8 帧缩略图,快速定位
- 实时进度:精确模式显示编码进度
技术选型
为什么选 Tauri 而不是 Electron?
| 对比项 | Electron | Tauri |
|---|---|---|
| 包体积 | 150MB+ | 10MB+ |
| 内存占用 | 高(Chromium) | 低(系统 WebView) |
| 后端语言 | Node.js | Rust |
| 性能 | 一般 | 优秀 |
对于文件处理这种 CPU 密集型任务,Rust 的性能优势非常明显。
技术栈
┌─────────────────────────────────────────────────────┐
│ Frontend │
│ React 19 + TypeScript + Tailwind CSS │
├─────────────────────────────────────────────────────┤
│ Tauri IPC │
├─────────────────────────────────────────────────────┤
│ Backend │
│ Rust + Tauri 2.0 │
│ ┌─────────────┬─────────────┬─────────────────┐ │
│ │ file_stats │ dedup │ video │ │
│ │ walkdir │ xxHash3 │ FFmpeg │ │
│ │ │ rayon │ │ │
│ │ │ memmap2 │ │ │
│ └─────────────┴─────────────┴─────────────────┘ │
└─────────────────────────────────────────────────────┘
核心实现
1. 文件去重算法
去重的核心是计算文件哈希,但如果对每个文件都完整计算哈希,效率会很低。我采用了两阶段策略:
第一阶段:按文件大小筛选
let mut size_map: HashMap<u64, Vec<String>> = HashMap::new();
for entry in WalkDir::new(&path)
.into_iter()
.filter_map(|e| e.ok())
.filter(|e| e.file_type().is_file())
{
if let Ok(meta) = entry.metadata() {
let size = meta.len();
if size > 0 {
size_map.entry(size).or_default().push(entry.path().to_string_lossy().to_string());
}
}
}
// 只对大小相同的文件计算哈希
let files_to_hash: Vec<_> = size_map
.iter()
.filter(|(_, files)| files.len() >= 2)
.flat_map(|(size, files)| files.iter().map(move |f| (*size, f.clone())))
.collect();
这一步可以过滤掉大部分文件,因为大小不同的文件肯定不重复。
第二阶段:并行计算哈希
use rayon::prelude::*;
let results: Vec<(String, FileInfo)> = files_to_hash
.par_iter() // 并行迭代
.filter_map(|(size, file_path)| {
let hash = calculate_fast_hash(Path::new(file_path), *size).ok()?;
// ...
Some((hash, file_info))
})
.collect();
使用 rayon 实现并行计算,充分利用多核 CPU。
2. 快速哈希算法
传统的 MD5 哈希速度较慢,我选择了 xxHash3,它是目前最快的非加密哈希算法之一:
fn calculate_fast_hash(path: &Path, size: u64) -> Result<String, String> {
use memmap2::Mmap;
use xxhash_rust::xxh3::Xxh3;
const SMALL_FILE: u64 = 1024 * 1024; // 1MB
const THRESHOLD: u64 = 10 * 1024 * 1024; // 10MB
const SAMPLE_SIZE: usize = 1024 * 1024; // 采样 1MB
let file = File::open(path).map_err(|e| e.to_string())?;
if size <= SMALL_FILE {
// 小文件:内存映射,零拷贝
let mmap = unsafe { Mmap::map(&file) }.map_err(|e| e.to_string())?;
let hash = xxhash_rust::xxh3::xxh3_64(&mmap);
return Ok(format!("{:016x}", hash));
}
let mut hasher = Xxh3::new();
if size <= THRESHOLD {
// 中等文件:完整读取
let mmap = unsafe { Mmap::map(&file) }.map_err(|e| e.to_string())?;
hasher.update(&mmap);
} else {
// 大文件:只读头部 + 中间 + 尾部
let mmap = unsafe { Mmap::map(&file) }.map_err(|e| e.to_string())?;
let len = mmap.len();
hasher.update(&mmap[..SAMPLE_SIZE]); // 头部
hasher.update(&mmap[len/2 - SAMPLE_SIZE/2..][..SAMPLE_SIZE]); // 中间
hasher.update(&mmap[len - SAMPLE_SIZE..]); // 尾部
hasher.update(&size.to_le_bytes()); // 文件大小
}
Ok(format!("{:016x}", hasher.digest()))
}
优化点:
- xxHash3:比 MD5 快 5-10 倍
- memmap2:内存映射,零拷贝读取
- 大文件采样:只读头中尾各 1MB,避免全量读取
3. 前后端通信
Tauri 使用 IPC 进行前后端通信。后端定义命令:
#[tauri::command]
pub async fn find_duplicates(app: AppHandle, path: String) -> Result<DedupResult, String> {
// ...
}
前端调用:
import { invoke } from "@tauri-apps/api/core";
const result = await invoke<DedupResult>("find_duplicates", { path });
4. 进度反馈
长时间任务需要显示进度,Tauri 支持事件机制:
后端发送进度:
use tauri::{AppHandle, Emitter};
let _ = app.emit("dedup-progress", DedupProgress {
stage: "计算文件指纹".into(),
current,
total: total_to_hash,
percent,
});
前端监听:
import { listen } from "@tauri-apps/api/event";
useEffect(() => {
const unlisten = listen<DedupProgress>("dedup-progress", (event) => {
setProgress(event.payload);
});
return () => { unlisten.then((fn) => fn()); };
}, []);
5. 内嵌 FFmpeg
视频功能依赖 FFmpeg,为了让用户开箱即用,我把 FFmpeg 打包进了应用:
配置 tauri.conf.json:
{
"bundle": {
"externalBin": [
"binaries/ffmpeg",
"binaries/ffprobe"
]
}
}
Rust 中获取路径:
fn get_ffmpeg_path(app: &AppHandle) -> PathBuf {
app.path()
.resource_dir()
.ok()
.map(|p| p.join("binaries").join("ffmpeg"))
.filter(|p| p.exists())
.unwrap_or_else(|| PathBuf::from("ffmpeg")) // 回退到系统 PATH
}
踩坑记录
1. Tauri 拖拽事件是全局的
最初使用 CSS hidden 隐藏非活动 Tab 来保持状态,但发现拖拽文件时所有 Tab 都会响应。
解决方案:给每个组件传递 active 属性,只有激活的组件才监听拖拽事件。
useEffect(() => {
if (!active) return; // 非激活状态不监听
const unlisten = listen("tauri://drag-drop", (event) => {
if (!active) return;
// 处理拖拽
});
// ...
}, [active]);
2. 并行计算进度跳动
使用 rayon 并行计算时,多线程同时更新计数器,导致进度显示不连续。
解决方案:使用原子变量 + compare_exchange 确保进度单调递增:
let progress_counter = Arc::new(AtomicUsize::new(0));
let last_reported = Arc::new(AtomicUsize::new(0));
// 在并行迭代中
let current = progress_counter.fetch_add(1, Ordering::Relaxed) + 1;
let last = last_reported.load(Ordering::Relaxed);
if current > last && (current - last >= 20 || current == total) {
if last_reported.compare_exchange(last, current, Ordering::SeqCst, Ordering::Relaxed).is_ok() {
// 发送进度
}
}
3. Release 比 Debug 快很多
开发时觉得去重速度一般,打包后发现快了好几倍。
| 模式 | 说明 |
|---|---|
| Debug | 无优化,保留调试信息 |
| Release | LTO、内联、循环展开等优化 |
对于 CPU 密集型任务,Release 版本可能快 3-5 倍。
打包发布
本地打包
# 1. 下载 FFmpeg 静态版本(macOS 示例)
curl -L "https://evermeet.cx/ffmpeg/getrelease/ffmpeg/zip" -o /tmp/ffmpeg.zip
unzip /tmp/ffmpeg.zip -d src-tauri/binaries/
mv src-tauri/binaries/ffmpeg src-tauri/binaries/ffmpeg-x86_64-apple-darwin
# 2. 执行打包
pnpm tauri build
产物:src-tauri/target/release/bundle/macos/File Toolkit.app(约 62MB,含 FFmpeg)
GitHub Actions 多平台自动打包
本地打包只能生成当前平台的安装包。要支持 Windows、Linux,需要在对应平台编译。
解决方案:用 GitHub Actions,它提供 macOS、Windows、Linux 虚拟机,可以并行打包。
git push tag v0.2.0
↓
GitHub Actions 触发
↓
┌─────────────────────────────────────────────────────┐
│ 同时启动 4 台虚拟机(并行执行) │
├─────────────┬─────────────┬────────────┬────────────┤
│ macOS VM │ macOS VM │ Windows VM │ Linux VM │
│ (Intel) │ (ARM) │ │ │
├─────────────┼─────────────┼────────────┼────────────┤
│ 装环境 │ 装环境 │ 装环境 │ 装环境 │
│ 下载 FFmpeg │ 下载 FFmpeg │ 下载FFmpeg │ 下载FFmpeg │
│ tauri build │ tauri build │ tauri build│ tauri build│
├─────────────┼─────────────┼────────────┼────────────┤
│ .dmg │ .dmg │ .msi │ .deb │
│ (x86_64) │ (aarch64) │ .exe │ .AppImage │
└─────────────┴─────────────┴────────────┴────────────┘
↓
全部上传到 GitHub Release
核心配置 .github/workflows/release.yml:
name: Release
on:
push:
tags:
- 'v*'
jobs:
build:
strategy:
matrix:
include:
- platform: macos-latest
target: x86_64-apple-darwin
- platform: macos-latest
target: aarch64-apple-darwin
- platform: windows-latest
target: x86_64-pc-windows-msvc
- platform: ubuntu-22.04
target: x86_64-unknown-linux-gnu
runs-on: ${{ matrix.platform }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
- uses: pnpm/action-setup@v4
- uses: dtolnay/rust-toolchain@stable
# 各平台下载对应的 FFmpeg
- name: Download FFmpeg (macOS)
if: matrix.platform == 'macos-latest'
run: |
curl -L "https://evermeet.cx/ffmpeg/getrelease/ffmpeg/zip" -o /tmp/ffmpeg.zip
# ...
- name: Download FFmpeg (Windows)
if: matrix.platform == 'windows-latest'
shell: pwsh
run: |
Invoke-WebRequest -Uri "https://www.gyan.dev/ffmpeg/builds/..." -OutFile ...
# ...
# 打包并上传到 Release
- uses: tauri-apps/tauri-action@v0
with:
tagName: ${{ github.ref_name }}
releaseDraft: true
args: --target ${{ matrix.target }}
使用方式:
git tag v0.2.0
git push origin v0.2.0
# 自动触发,完成后去 Releases 页面发布
为什么能跨平台?
- 前端:React 编译成 HTML/CSS/JS,哪都能跑
- 后端:Rust 在目标平台的虚拟机上编译,生成原生二进制
- FFmpeg:每个平台下载对应的静态版本
本质是在真实目标平台上编译,GitHub 免费提供这些虚拟机。
总结
这个项目让我对 Tauri 2.0 有了更深的理解:
- Rust 性能确实强:文件哈希、并行计算等场景优势明显
- Tauri 开发体验不错:前后端分离,IPC 通信简单
- 包体积小:不含 FFmpeg 只有 10MB 左右
- 跨平台:一套代码,多端运行
如果你也想尝试 Tauri,这个项目可以作为参考。
GitHub: github.com/220529/file…
欢迎 Star ⭐️
相关文章:
一个管理项目中所有弹窗的弹窗管理器(PopupManager)
TypeScript 架构实践:从后端接口到 UI 渲染数据流的完整方案
介绍在 TS 中 DTO 与 VO 的思想碰撞与实践,系统性地梳理从后端接口定义到前端业务消费的完整链路。
一、 为什么要分 DTO 和 VO?
在 TypeScript 开发中,直接将后端返回的 JSON 数据透传到 UI 界面是研发初期的“捷径”,但往往也是后期维护的“噩梦”。为了解决后端字段多变、命名风格不统一(如蛇形命名 vs 驼峰命名)等问题,引入 DTO(数据传输对象) 和 VO(视图对象) 的概念至关重要。
二、 核心思想:职责分离
我们将后端接口数据在系统中的流转拆分为三个关键环节:
| 环节 | 承载载体 | 核心职责 |
|---|---|---|
| 入口层 | DTO (Interface) |
契约。严格对齐接口协议,描述后端发来的原始数据。 |
| 转换层 | Mapper 函数 |
解耦。负责逻辑清洗、字段重命名、类型转换。 |
| 应用层 | VO (Interface) |
纯净。仅包含 UI 层渲染所需的属性,命名符合前端规范。 |
三、 实战演练:Axios 与泛型的完美结合
为了实现自动化的类型推导,我们通过泛型封装通用的响应结构。
1. 定义通用响应壳子
// 统一后端返回的 JSON 格式
export interface ApiResponse<T = any> {
code: number;
message: string;
data: T; // 这里的 T 将被具体的 DTO 替换
}
2. 定义 DTO 与 VO
利用 TS 工具类型(如 Pick)提高定义效率。
// 后端原始 DTO
export interface UserDTO {
id: number;
user_name: string;
avatar_url: string;
created_at: number; // 秒级时间戳
}
// 前端业务 VO
// 挑出需要的字段,并增加/修改业务字段
export type UserVO = Pick<UserDTO, 'id' | 'avatar_url'> & {
displayName: string;
regDate: Date; // 转换为 JS Date 对象
};
3. Axios 请求与 Mapper 转换
将 Axios 的泛型能力与转换函数串联起来:
import axios from 'axios';
axios.interceptors.response.use((response) => {
const res = response.data as ApiResponse;
if (res.code !== 200) {
// 统一弹出后端给的错误提示
showToast(res.message);
return Promise.reject(new Error(res.message));
}
return response;
});
// 1. API 层
const fetchUserApi = (id: string) => {
// 告知 Axios:返回值的 body 是 ApiResponse 结构,其 data 属性是 UserDTO
return axios.get<ApiResponse<UserDTO>>(`/api/user/${id}`);
};
// 2. Mapper 层:负责 DTO -> VO 的脏活累活
const toUserVO = (dto: UserDTO): UserVO => ({
id: dto.id,
avatar_url: dto.avatar_url,
displayName: dto.user_name || '未知用户',
regDate: new Date(dto.created_at * 1000)
});
// 3. Service 层:业务组装
async function getUserDetail(id: string): Promise<UserVO> {
const { data: res } = await fetchUserApi(id);
// res.data 此时被自动推导为 UserDTO
return toUserVO(res.data);
}
// 4. UI 层(Vue/React):直接使用 UserVO
const [user, setUser] = useState<UserVO | null>(null);
useEffect(() => {
getUserDetail('123').then(setUser);
}, []);
总结
这套架构本质上是在前端构建了一道 “类型防火墙”:
- 防火墙外:是不可控的后端原始数据(DTO)。
- 防火墙内:是稳定的、符合业务习惯的干净数据(VO)。
- 防火墙中间:是透明的转换逻辑(Mapper)。
通过这种设计,即便后端接口字段发生变更,你只需修改 DTO 定义和 Mapper 函数,UI 层的代码完全不需要改动。