普通视图

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

别再死磕框架了!你的技术路线图该更新了

2025年11月18日 16:03

先说结论:

前端不会凉,但“只会几个框架 API”的前端,确实越来越难混
这两年“前端要凉了”“全栈替代前端”的声音此起彼伏,本质是门槛重新洗牌:

  • 简单 CRUD、纯样式开发被低代码、模板代码和 AI 模型快速蚕食;
  • 复杂业务、工程体系、跨端体验、AI 能力集成,反而需要更强的前端工程师去撑住。

如果你对“前端的尽头是跑路转管理”已经开始迷茫,那这篇就是给你看的:别再死磕框架版本号,该更新的是你的技术路线图。


一、先搞清楚:2025 的前端到底在变什么?

框架红海:从“会用”到“用得值”

React、Vue、Svelte、Solid、Qwik、Next、Nuxt……Meta Framework 一大堆,远远超过岗位需求。
现在企业选型更关注:

  • 生态成熟度(如 Next.js 的 SSR/SSG 能力)
  • 框架在应用生命周期中的角色(渲染策略、数据流转、SEO、部署)

趋势:

  • 框架 Meta 化(Next.js、Nuxt)将路由、数据获取、缓存策略整体纳入规范;
  • 约定优于配置,不再是“一个前端库”,而是“一套完整解决方案”。

以前是“你会 Vue/React 就能干活”,现在是“你要理解框架在整个应用中的角色”。


工具有 AI,开发方式也在变

AI 工具(如 Cursor、GitHub Copilot X)可以显著提速,甚至替代重复劳动。
真正拉开差距的变成了:

  • 你能给 AI 写出清晰、可实现的需求描述(Prompt);
  • 你能判断 AI 生成代码的质量、潜在风险、性能问题;
  • 你能基于生成结果做出合理抽象和重构。

AI 不是来抢饭碗,而是逼你从“码农”进化成“架构和决策的人”。


业务侧:前端不再是“画界面”,而是“做体验 + 做增长”

  • B 端产品:交互工程师 + 低代码拼装师 + 复杂表单处理专家;
  • C 端产品:与产品运营深度捆绑,懂 A/B 测试、埋点、Funnel 分析、广告投放链路;
  • 跨平台:Web + 小程序 + App(RN/Flutter/WebView)混合形态成为常态。

那些还在喊“切图仔优化 padding”的岗位确实在消失,但对“懂业务、有数据意识、能搭全链路体验”的前端需求更高。


二、别再死磕框架 API:2025 的前端核心能力长什么样?

基石能力:Web 原生三件套,得真的吃透

重点不是“会用”,而是理解底层原理:

  • JS:事件循环、原型链、Promise 执行模型、ESM 模块化;
  • 浏览器:渲染流程(DOM/CSSOM/布局/绘制/合成)、HTTP/2/3、安全防护(XSS/CSRF)。

这块扎实了,你在任何框架下都不会慌,也更能看懂“框架为什么这么设计”。


工程能力:从“会用脚手架”到“能看懂和调整工程栈”

Vite、Rspack、Turbopack 等工具让工程构建从“黑魔法”变成“可组合拼装件”。
你需要:

  • 看懂项目的构建配置(Vite/Webpack/Rspack 任意一种);
  • 理解打包拆分、动态加载、CI/CD 流程;
  • 能排查构建问题(路径解析、依赖冲突)。

如果你在团队里能主动做这些事,别人对你的“级别判断”会明显不一样。


跨端和运行时:不只会“写 Web 页”

2025 年前端视角的关键方向:

  • 小程序/多端框架(Taro、Uni-app);
  • 混合方案(RN/Flutter/WebView 通信机制);
  • 桌面端(Electron、Tauri)。

建议:

  • 至少深耕一个“跨端主战场”(如 Web + 小程序 或 Web + Flutter)。

数据和状态:从“会用 Vuex/Redux”到“能设计状态模型”

现代前端复杂度 70% 在“数据和状态管理”。
进阶点在于:

  • 设计合理的数据模型(本地 UI 状态 vs 服务端真相);
  • 学会用 Query 库、State Machine 解耦状态与视图。

当你能把“状态设计清楚”,你在复杂业务团队里会非常吃香。


性能、稳定性、可观测性:高级前端的硬指标

你需要系统性回答问题,而不是“瞎猜”:

  • 性能优化:首屏加载(资源拆分、CDN)、运行时优化(减少重排、虚拟列表);
  • 稳定性:错误采集、日志上报、灰度发布;
  • 工具:Lighthouse、Web Vitals、Session Replay。

这块做得好的人往往是技术骨干,且很难被低代码或 AI 直接替代。


AI 时代的前端:不是“写 AI”,而是“让 AI 真正跑进产品”

你需要驾驭:

  • 基础能力:调用 AI 平台 API(流式返回处理、增量渲染);
  • 产品思维:哪些场景适合 AI(智能搜索、文档问答);如何做权限控制、错误兜底。

三、路线图别再按“框架学习顺序”排了,按角色来选

初中级:从“会用”到“能独立负责一个功能”

目标:

  • 独立完成中等复杂度模块(登录、权限、表单、列表分页)。

建议路线:

  • 夯实 JS + 浏览器基础;
  • 选择 React/Vue + Next/Nuxt 做完整项目;
  • 搭建 eslint + prettier + git hooks 的开发习惯。

进阶:从“功能前端”到“工程前端 + 业务前端”

目标:

  • 优化项目、推进基础设施、给后端/产品提技术方案。

建议路线:

  • 深入构建工具(Webpack/Vite);
  • 主导一次性能优化或埋点方案;
  • 引入 AI 能力(如智能搜索、工单回复建议)。

高级/资深:从“高级前端”到“前端技术负责人”

目标:

  • 设计技术体系、推动长期价值。

建议路线:

  • 明确团队技术栈(框架、状态管理、打包策略);
  • 主导跨部门项目、建立知识分享机制;
  • 评估 AI/低代码/新框架的引入价值。

四、2025 年不要再犯的几个错误

  1. 只跟着热点学框架,不做项目和抽象

    • 选一个主战场 + 一个备胎(React+Next.js,Vue+Nuxt.js),用它们做 2~3 个完整项目。
  2. 完全忽略业务,沉迷写“优雅代码”

    • 把重构和业务迭代绑一起,而不是搞“纯技术重构”。
  3. 对 AI 持敌视和逃避态度

    • 把重复劳动交给 AI,把时间投到架构设计、业务抽象上。
  4. 把“管理”当成唯一出路

    • 做前端架构、性能优化平台、低代码平台的技术专家,薪资和自由度不输管理岗。

五、一个现实点的建议:给自己的 2025 做个“年度规划”

Q1:

  • 选定主技术栈(React+Next 或 Vue+Nuxt);
  • 做一个完整小项目(登录、权限、列表/详情、SSR、部署)。

Q2:

  • 深入工程化方向(优化打包体积、搭建监控埋点系统)。

Q3:

  • 选一个业务场景引入 AI 或配置化能力(如智能搜索、低代码表单)。

Q4:

  • 输出和沉淀(写 3~5 篇技术文章、踩坑复盘)。

最后:别问前端凉没凉,先问问自己“是不是还停在 2018 年的玩法”

  • 如果你还把“熟练掌握 Vue/React”当成简历亮点,那确实会焦虑;
  • 但如果你能说清楚:
    • 在复杂项目里主导过哪些工程优化;
    • 如何把业务抽象成可复用的组件/平台;
    • 如何在产品里融入 AI/多端/数据驱动;
      那么,在 2025 年的前端市场,你不仅不会“凉”,反而会成为别人眼中的“稀缺”。

别再死磕框架了,更新你的技术路线图,从“写页面的人”变成“打造体验和平台的人”。这才是 2025 年前端真正的进化方向。

Ract Router v7:最全基础与高级用法指南(可直接上手)

作者 丁点阳光
2025年11月18日 15:43

React Router v7 已经正式成为现代 React 应用的默认路由方案。相比过去的版本,v7 在数据加载、路由懒加载、错误边界、路由模块化等方面做了更统一、更现代化的设计。

本文带你快速掌握 基础用法 + 高级用法,适合从 v5/v6 升级或新项目使用

一、核心理念与变化概览

React Router v7 主要围绕三个关键词:

1. 路由是 UI 的一部分

-router 是组件

  • 不再是配置式为主,组件式更自然
  • Layout + Outlet 成为主流

2. 数据路由(Data Router)统一化

提供统一 API:

  • loader 数据加载
  • action 数据提交
  • lazy 懒加载路由资源
  • errorElement 错误边界

3. 组件即路由

v7 保留了 v6 的 createBrowserRouterRouterProvider,并继续强化嵌套路由的概念

二、基础用法

1. 安装

npm install react-router-dom

2. 最简路由结构

import { createBrowserRouter, RouterProvider } from "react-router-dom";

const router = createBrowserRouter([
  {
    path: "/",
    element: <HomePage />,
  },
  {
    path: "/about",
    element: <AboutPage />,
  },
]);

export default function App() {
  return <RouterProvider router={router} />;
}

3. Layout + Outlet(嵌套路由)

Layout 是 v7 强调的最佳实践:

import { Outlet, Link } from "react-router-dom";

function Layout() {
  return (
    <div>
      <nav>
        <Link to="/">首页</Link>
        <Link to="/profile">个人中心</Link>
      </nav>

      <main>
        <Outlet /> {/* 子路由在这里渲染 */}
      </main>
    </div>
  );
}

const router = createBrowserRouter([
  {
    path: "/",
    element: <Layout />,
    children: [
      { index: true, element: <HomePage /> },
      { path: "profile", element: <ProfilePage /> },
    ],
  },
]);

三、懒加载(async lazy)——强烈推荐写法

React Router v7 官方推荐使用 async lazy() 来进行路由级按需加载。

🔥 推荐写法:async lazy()

const router = createBrowserRouter([
  {
    path: "/",
    async lazy() {
      const Component = (await import("./pages/Layout")).default;
      return { Component };
    },
    children: [
      {
        index: true,
        async lazy() {
          const Component = (await import("./pages/Home")).default;
          return { Component };
        },
      },
      {
        path: "profile",
        async lazy() {
          const Component = (await import("./pages/Profile")).default;
          return { Component };
        },
      },
    ],
  },
]);

优势:
✔ 语法干净
✔ 和 Data Router 完整兼容
✔ 自动 Suspense
✔ 支持 SSR(未来版本)
✔ 大型项目结构清晰

四、跳转与参数读取

1. 跳转

const navigate = useNavigate();
navigate("/profile");

2. 获取路径参数

const { id } = useParams();

3. 获取查询参数

const [searchParams] = useSearchParams();
const keyword = searchParams.get("keyword");

五、跳转与参数读取(加强版)

这一部分很多人用得不全,这里讲 跳转方式、路径参数、查询参数、状态传参 4 大类。


📌 1. 跳转导航

(1)useNavigate:最常用跳转

import { useNavigate } from "react-router-dom";

const nav = useNavigate();

nav("/profile");                 // 普通跳转
nav(-1);                         // 返回上一页
nav("/login", { replace: true }); // 不留历史记录

(2)链接跳转 Link

import { Link } from "react-router-dom";

<Link to="/about">关于我们</Link>

(3)按钮跳转 Navigate 组件

适合条件跳转:

{ isLogin ? <Dashboard /> : <Navigate to="/login" replace /> }

📌 2. 路径参数(URL Params)

例如访问:
/user/123

定义路由:

{
  path: "user/:id",
  element: <UserDetail />
}

读取参数:

import { useParams } from "react-router-dom";

const { id } = useParams(); // id === "123"

注意:

  • params 一定是字符串类型
  • 可用 Zod/Number() 做转换

📌 3. 查询参数(Search Params)

例如访问:
/list?page=2&keyword=test

读取:

import { useSearchParams } from "react-router-dom";

const [search] = useSearchParams();

const page = search.get("page"); 
const keyword = search.get("keyword");

修改:

const [search, setSearch] = useSearchParams();

setSearch({ page: 3 });

支持 append:

search.append("type", "A");
setSearch(search);

📌 4. 跳转时携带 state(非 URL)

类似 history.push 的 state:

nav("/detail", {
  state: { from: "list", id: 123 }
});

读取:

import { useLocation } from "react-router-dom";

const { state } = useLocation();
// state.from === "list"

用法:

  • 搜索列表 → 详情
  • 跨页面临时数据
  • 不污染 URL
  • 仅会话内有效

六、Data Router:loader 与 action(高级)

📌 为什么需要 Data Router?

传统 SPA 数据加载流程:

渲染组件 → useEffect → fetch 数据 → setState →再渲染

但这有几个问题:

  • 多层嵌套时 useEffect 非常乱
  • SSR、预加载、切换页面时不能保证一致性
  • 异步错误很难集中处理
  • 首屏渲染过慢
  • React 渲染后才请求数据,白屏时间更长

React Router v7 引入 Data Router 后,流程变成:

进入某个路由 → 执行 loader → 数据准备好 → 再渲染组件

好处:

  • 页面渲染 之前 就拿到数据(首屏更快)
  • 数据逻辑从 UI 分离
  • 错误自动走路由 errorElement
  • 多个 loader 并行执行
  • 支持自动 revalidate(自动刷新)
  • SSR & CSR 统一开发体验

📌 1. loader:加载页面数据

loader 是路由级数据加载函数:

{
  path: "/detail/:id",
  loader: async ({ params, request }) => {
    const res = await fetch(`/api/detail/${params.id}`);
    return res.json();
  },
  element: <DetailPage />
}

组件内读取:

import { useLoaderData } from "react-router-dom";

const data = useLoaderData();

2. action:表单提交逻辑

{
  path: "/create",
  action: async ({ request }) => {
    const form = await request.formData();
    return createItem(form);
  },
  element: <CreatePage />
}

配合 <Form>

<Form method="post">
  <input name="title" />
  <button>提交</button>
</Form>

七、错误边界 errorElement

v7 支持路由级错误 UI:

{
  path: "/",
  element: <Layout />,
  errorElement: <ErrorPage />,
}

读取异常:

const err = useRouteError();

八、handle:路由元信息(meta)

可用于:

  • 页面标题
  • 面包屑
  • 权限
  • 路由描述
{
  path: "settings",
  handle: { title: "系统设置", auth: true },
  element: <SettingsPage />
}

读取所有匹配路由的 handle:

import { useMatches } from "react-router-dom";

const matches = useMatches();

九、模块化路由(大型项目最佳实践)

假设有三个模块:Dashboard、User、Project

1️⃣ Dashboard 模块

// routes/dashboard.routes.ts
export const dashboardRoutes = [
  {
    path: "dashboard",
    async lazy() {
      const Component = (await import("@/pages/Dashboard")).default;
      return { Component };
    },
    handle: { title: "Dashboard" },
  },
];

2️⃣ User 模块(带 loader/action)

// routes/user.routes.ts
export const userRoutes = [
  {
    path: "user",
    async lazy() {
      const Layout = (await import("@/pages/User/Layout")).default;
      return { Component: Layout };
    },
    children: [
      {
        index: true,
        async lazy() {
          const Component = (await import("@/pages/User/List")).default;
          return { Component };
        },
        handle: { title: "用户列表" },
      },
    ],
  },
];

3️⃣ Project 模块

// routes/project.routes.ts
export const projectRoutes = [
  {
    path: "project",
    async lazy() {
      const Layout = (await import("@/pages/Project/Layout")).default;
      return { Component: Layout };
    },
    children: [
      {
        index: true,
        async lazy() {
          const Component = (await import("@/pages/Project/List")).default;
          return { Component };
        },
        handle: { title: "项目列表" },
      },
     
    ],
  },
];

4️⃣ 主路由统一整合

// routes/index.ts
import { createBrowserRouter } from "react-router-dom";
import { dashboardRoutes } from "./dashboard.routes";
import { userRoutes } from "./user.routes";
import { projectRoutes } from "./project.routes";

const router = createBrowserRouter([
  {
    path: "/",
    async lazy() {
      const Layout = (await import("@/pages/Layout")).default;
      return { Component: Layout };
    },
    children: [
      ...dashboardRoutes,
      ...userRoutes,
      ...projectRoutes,
    ],
  },
  {
    path: "/login",
    async lazy() {
      const Component = (await import("@/pages/Login")).default;
      return { Component };
    },
  },
  {
    path: "*",
    async lazy() {
      const Component = (await import("@/pages/NotFound")).default;
      return { Component };
    },
  },
]);

export default router;

十、权限控制(结合 loader + handle)

{
  path: "admin",
  handle: { auth: true },
  loader: async () => {
    const login = await checkLogin();
    if (!login) throw redirect("/login");
  },
  element: <Admin />
}

满足所有权限场景。


结语

React Router v7 是目前 React 生态最全面的 SPA/SSR 路由方案,它的优势包括:

  • 统一的页面加载方式
  • 按需加载(lazy)
  • 模块化路由
  • 真正意义的路由数据层
  • 强大的错误边界和 handle

无论是小项目还是大型管理后台,它都足够可控且可扩展。

vue3+Cesium开发教程(14)---Cesium加载与删除geojson、kml、glb、3dtiles、czml数据

作者 GisCoder
2025年11月18日 15:16

本学习系列将以Cesium + Vue3 + Vite +Typescript+elementplus作为主要技术栈展开,后续会循序渐进,持续探索Cesium的高级功能,相关源码全部免费公开。详情请查看原文Cesium+Vue3学习系列(14)---Cesium加载与删除geojson、kml、glb、3dtiles、czml数据

1、geojson数据配置与加载方式


//geojson测试数据
export const geojsonTestInfo:ILayerItem = {
    id: LayerIdFlag.GEOMSON_TEST,
    name: "geojsonTest",
    type: "geojson",
    url: "/testdata/line.geojson",
    layer: "geojsonTest"
}

case 'geojson':
                handle = await Cesium.GeoJsonDataSource.load(item.url!, {
                    clampToGround: true,
                });
                (handle as Cesium.GeoJsonDataSource).show = show;
                this.viewer.dataSources.add(handle)
                //视角
                this.viewer.zoomTo(handle)
                break

2、kml数据配置与加载方式


//kml测试数据
export const kmlTestInfo:ILayerItem = {
    id: LayerIdFlag.KML_TEST,
    name: "kmlTest",
    type: "kml",
    url: "/testdata/gdpPerCapita2008.kmz",
    layer: "kmlTest"
}

            case 'kml':
                handle = await Cesium.KmlDataSource.load(item.url!, {
                    clampToGround: true,
                });
                (handle as Cesium.KmlDataSource).show = show
                this.viewer.dataSources.add(handle)
                this.viewer.zoomTo(handle)
                break

3、glb数据配置与加载方式


//glb测试数据
export const glbTestInfo:ILayerItem = {
    id: LayerIdFlag.GLB_TEST,
    name: "glbTest",
    type: "glb",
    url: "/testdata/CesiumMilkTruck.glb",
    layer: "glbTest",
    scale: 5,
    lon: 114.314521,
    lat: 22.543062,
    height: 0,
    heading:45,
    pitch: 0,
    roll: 0   
}

            case 'glb':
                let position = Cesium.Cartesian3.fromDegrees(item.lon, item.lat, item.height || 0);
                // 设置模型方向
                let hpRoll = new Cesium.HeadingPitchRoll(Cesium.Math.toRadians(item.heading || 45), Cesium.Math.toRadians(item.pitch || 0), Cesium.Math.toRadians(item.roll || 0));
                // 生成一个函数,该函数从以提供的原点为中心的参考帧到提供的椭圆体的固定参考帧计算4x4变换矩阵。
                let fixedFrame = Cesium.Transforms.localFrameToFixedFrameGenerator('north', 'west');
                handle = await Cesium.Model.fromGltfAsync({
                    url: item.url!,
                    modelMatrix: Cesium.Transforms.headingPitchRollToFixedFrame(position, hpRoll, Cesium.Ellipsoid.WGS84, fixedFrame),
                    scale: item.scale || 1,
                });
                handle.show = show;
                this.viewer.scene.primitives.add(handle)
                this.viewer.camera.flyTo({
                    destination: Cesium.Cartesian3.fromDegrees(item.lon, item.lat, 100),
                    duration: 2
                })
                break;

4、3dtiles配置与加载方式。由于本示例中的3dtiles 数据高度较低,这里需要将其高度提升了500米。后续会出一个动态调整3dtiles位置的章节,敬请期待。


//3dtile测试数据
export const model3dtilesTestInfo:ILayerItem = {
    id: LayerIdFlag.MODEL_3DTILES_TEST,
    name: "model3dtilesTest",
    type: "3dtiles",
    url: "/testdata/dayanta/tileset.json",
    layer: "model3dtilesTest"
}

 case '3dtiles':
                handle = await Cesium.Cesium3DTileset.fromUrl(item.url!);                
                (handle as Cesium.Cesium3DTileset).show = show;
                this.viewer.scene.primitives.add(handle)
                this.viewer.zoomTo(handle, new Cesium.HeadingPitchRange(0.0, -0.3, 0.0))
               
                let cartographic = Cesium.Cartographic.fromCartesian(
                    handle.boundingSphere.center
                );
                let surface = Cesium.Cartesian3.fromRadians(
                    cartographic.longitude,
                    cartographic.latitude,
                    0.0
                );
                let offset = Cesium.Cartesian3.fromRadians(
                    cartographic.longitude,
                    cartographic.latitude,
                    500.0
                );
                let translation = Cesium.Cartesian3.subtract(
                    offset,
                    surface,
                    new Cesium.Cartesian3()
                );
                handle.modelMatrix = Cesium.Matrix4.fromTranslation(translation);
                break

5、czml数据配置与加载方式


export const czmlTestInfo:ILayerItem = {
    id: LayerIdFlag.CZML_TEST,
    name: "czmlTest",
    type: "czml",
    url: "/testdata/simple.czml",
    layer: "czmlTest"
}

          case 'czml':
                handle = await Cesium.CzmlDataSource.load(item.url!);
                (handle as Cesium.CzmlDataSource).show = show
                this.viewer.dataSources.add(handle)
                 this.viewer.zoomTo(handle)
                break

转存失败,建议直接上传图片文件

6、数据删除。

    Remove(id: string): void {
        const item = this.layersIdMap.get(id)
        if (!item) return;
        const { type, handle } = item;
        switch (type) {
            case 'imagery_wms':
            case 'imagery_wmts':
            case 'imagery_xyz':
                this.viewer.imageryLayers.remove(handle)
                break
            case 'terrain':
                // 回到默认椭球
                this.viewer.terrainProvider = new Cesium.EllipsoidTerrainProvider()
                break
            case 'geojson':
            case 'kml':
            case 'czml':
                this.viewer.dataSources.remove(handle)
                break;
            case '3dtiles':
            case 'glb':
                this.viewer.scene.primitives.remove(handle)
                break;
        }
        this.layersIdMap.delete(id);
    }

7、前端实现:

<template>
    <div class="custom-data-container">
        <span class="custom-data-title"> 自定义数据 </span>
        <div style="text-align: left;">
           
            <div class="data-items">
                <span>图层信息</span>
                <div>
                    <el-checkbox v-model="geojsonChecked" label="geojson" size="large" @change="changeGeojson"/>
                    <el-checkbox v-model="kmlChecked" label="KML" size="large" @change="changeKml"/>
                    <el-checkbox v-model="glbChecked" label="glb" size="large" @change="changeGlb"/>
                    <el-checkbox v-model="model3dtilesChecked" label="3dtiles" size="large" @change="changeModel3dtiles"/>
                    <el-checkbox v-model="czmlChecked" label="czml" size="large" @change="changeCzml"/>
                </div>
            </div>
        </div>
    </div>
</template>
<script lang="ts" setup>
import { czmlTestInfo, geojsonTestInfo, glbTestInfo, kmlTestInfo, LayerIdFlag, model3dtilesTestInfo } from '@/system/LayerManager/LayerConfig'
import LayerManager from '@/system/LayerManager/LayerManager'
import CesiumViewer from '@/Viewer/CesiumViewer'
const viewer = CesiumViewer.viewer
let mLayerManager: LayerManager | null = null
const geojsonChecked = ref(false)
const kmlChecked = ref(false)
const glbChecked = ref(false)
const model3dtilesChecked = ref(false)
const czmlChecked = ref(false)
onMounted(() => {
    mLayerManager = LayerManager.getInstance(viewer!)
})
const changeGeojson = (val: string | number | boolean) => {
  if (val) {
    // 加载geojson图层
    mLayerManager?.Add(geojsonTestInfo)
  } else {
    // 移除geojson图层
    mLayerManager?.Remove(LayerIdFlag.GEOMSON_TEST)
  }
}
const changeKml = (val: string | number | boolean) => {
  if (val) {
    // 加载kml图层
    mLayerManager?.Add(kmlTestInfo)
  } else {
    // 移除kml图层
    mLayerManager?.Remove(LayerIdFlag.KML_TEST)
  }
}
const changeGlb = (val: string | number | boolean) => {
  if (val) {
    // 加载glb图层
    mLayerManager?.Add(glbTestInfo)
  } else {
    // 移除glb图层
    mLayerManager?.Remove(LayerIdFlag.GLB_TEST)
  }
}
const changeModel3dtiles = (val: string | number | boolean) => {
  if (val) {
    // 加载3dtiles图层
    mLayerManager?.Add(model3dtilesTestInfo)
  } else {
    // 移除3dtiles图层
    mLayerManager?.Remove(LayerIdFlag.MODEL_3DTILES_TEST)
  }
}
const changeCzml = (val: string | number | boolean) => {
  if (val) {
    // 加载czml图层
    mLayerManager?.Add(czmlTestInfo)
  } else {
    // 移除czml图层
    mLayerManager?.Remove(LayerIdFlag.CZML_TEST)
  }
}
</script>
<style lang="scss" scoped>
.custom-data-container {
    padding: 10px;
    .custom-data-title {
        font-size: 16px;
        font-weight: bold;
        margin-bottom: 10px;
    }
    
    .data-items {
        .el-checkbox {
            margin-left: 20px;
            height: 30px;
        }
        :deep(.el-checkbox__label) {
            color: rgb(206, 194, 194);
        }
    }
}
</style>

更多代码请查看原文Cesium+Vue3学习系列(14)---Cesium加载与删除geojson、kml、glb、3dtiles、czml数据

夸克全面接入千问对话助手,将发布全新AI浏览器

2025年11月18日 15:16
36氪获悉,11月18日,夸克App全面接入千问对话助手。用户在夸克App右滑,即可使用千问App的对话能力。内部人士透露,夸克的定位是AI浏览器,将与千问App形成战略协同。夸克PC端也将迎来重大版本升级,推出全新的AI浏览器,与千问深度结合。

H5 WebView 文件下载到手机中(仅安卓与 iOS)

作者 前端一课
2025年11月18日 15:04

H5 WebView 文件下载(仅安卓与 iOS)

原理

  • 使用 H5+ 原生接口 plus.downloader.createDownload 将文件下载到本地;下载完成后通过 plus.runtime.openFile 打开。
  • 不同平台保存路径不同:
    • Android:保存到系统公共 Download 目录(需要存储权限)。
    • iOS:保存到应用沙箱 _doc 持久目录(无需额外权限)。

使用方法(代码)

  • 页面按钮“原生下载PDF”触发函数:src/views/w-success.vue:323
  • 关键实现(节选):
// src/views/w-success.vue:323-388
const downloadByPlus = async () => {
  const p = (window as any).plus;
  if (!p) { alert("当前不在App环境"); return; }
  if (isDownloading.value) return;
  isDownloading.value = true;
  downloadStatus.value = "原生下载中...";

  try {
    const fileOnly = getFileName(downloadUrl);
    const isAndroid = p.os && p.os.name === "Android";
    const isiOS = p.os && p.os.name === "iOS";
    const filename = isAndroid
      ? `file:///storage/emulated/0/Download/${fileOnly}`
      : isiOS
      ? `_doc/${fileOnly}`
      : `_downloads/${fileOnly}`;

    const startDownload = () =>
      p.downloader.createDownload(
        downloadUrl,
        { filename },
        (d: any, status: number) => {
          isDownloading.value = false;
          if (status === 200) {
            let localPath = "";
            try { localPath = p.io.convertLocalFileSystemURL(d.filename); } catch (err) { localPath = ""; }
            downloadStatus.value = localPath ? `下载完成,路径:${localPath}` : "下载完成";
            try { p.runtime.openFile(d.filename); } catch (e: any) { downloadStatus.value = `${downloadStatus.value},打开失败: ${e?.message || e}`; }
          } else {
            downloadStatus.value = `下载失败,状态码:${status}`;
          }
        }
      ).start();

    if (isAndroid) {
      try {
        await new Promise((resolve, reject) => {
          p.android.requestPermissions(
            ["android.permission.WRITE_EXTERNAL_STORAGE", "android.permission.READ_EXTERNAL_STORAGE"],
            () => resolve(null),
            (err: any) => reject(err)
          );
        });
      } catch (err: any) {
        isDownloading.value = false;
        downloadStatus.value = `缺少存储权限: ${err?.message || err}`;
        return;
      }
    }
    startDownload();
  } catch (e: any) {
    isDownloading.value = false;
    downloadStatus.value = `原生下载失败: ${e?.message || e}`;
  }
};

保存路径

  • Android:/storage/emulated/0/Download/<文件名>(绝对路径显示为 file:///storage/emulated/0/Download/<文件名>)。
  • iOS:_doc/<文件名>(绝对路径通过 plus.io.convertLocalFileSystemURL 转换后显示为 file:///.../Documents/<文件名> 等沙箱路径)。

平台要求

  • Android:需要申请 WRITE_EXTERNAL_STORAGEREAD_EXTERNAL_STORAGE 权限;代码中已请求(src/views/w-success.vue:356-371)。
  • iOS:若下载地址为 http://,需在原生打包的 ATS 配置中允许该域名的非 HTTPS 访问;文件仅可写入应用沙箱。

A股三大指数集体收跌,化工板块领跌

2025年11月18日 15:01
36氪获悉,A股三大指数集体收跌,沪指跌0.81%,深成指跌0.92%,创业板指跌1.16%;基本金属、化工、电工电网板块领跌,海南矿业跌停,华盛锂电跌超17%,海博思创跌超10%,远翔新材跌超7%;传媒、软件、半导体板块走强,视觉中国、浪潮软件涨停,北方华创涨超5%。

西班牙今年经济增长预期上调至2.9%

2025年11月18日 14:50
西班牙政府和欧盟委员会17日发布最新预测,均将西班牙2025年经济增长预期上调至2.9%。当天,西班牙经济、贸易和企业大臣卡洛斯·奎尔波在国会发言时表示,政府将更新宏观经济展望,将2025年国内生产总值(GDP)增长预期由此前的2.7%上调至2.9%。欧盟委员会同日发布秋季经济预测,将西班牙2025年GDP增长预期由2.6%上调至2.9%,理由是其国内需求强劲。这一数字显著高于欧盟1.4%和欧元区1.3%的增长预期。(新华社)

在Cesium中实现飘动的红旗

作者 子禾丶
2025年11月18日 14:49

smr4d-ls7t2.gif

前言

实现飘动红旗的效果整体分两部,一是利用三角函数的周期性让红旗摆动起来,二是根据每个片元的摆动幅度来计算对应位置的阴影
这是我在一个园区项目中收到的需求,在此记录及分享实现过程。

基础场景搭建(创建cesium场景和必要的实体)

这里使用gltf模型作为红旗,因为需要获得平滑的摆动效果,因此使用的模型面数较多,同时为了旗子与旗杆可以使用相同的坐标位置,我将模型的定位锚地放到了左上角(见下图,来自建模软件blender)。同样的,飘动的功能也可以手动创建Cesium中polygonrectangle实体来完成,核心部分与使用gltf模型无异。
image.png 创建基础场景

Cesium.Ion.defaultAccessToken = "your token";
const viewer = new Cesium.Viewer("cesiumContainer");
viewer.camera.setView({
    destination: Cesium.Cartesian3.fromDegrees(116.39122232836966, 39.90701265936752, 4.813199462406734),
    orientation: {
        heading: Cesium.Math.toRadians(26.754499635799313),
        pitch: Cesium.Math.toRadians(5.094600937875728),
        roll: 0,
    },
});
const modelPosition = [116.39124568344667, 39.90705858625655, 6]
//绘制旗子
const flag = viewer.entities.add({
    position: Cesium.Cartesian3.fromDegrees(...modelPosition),
    model: {
        uri: '../source/models/旗子/旗子.gltf',
    },
});
//绘制旗杆
viewer.entities.add({
        position: Cesium.Cartesian3.fromDegrees(modelPosition[0], modelPosition[1]),
        ellipse: {
            semiMinorAxis: 0.01,
            semiMajorAxis: 0.01,
            extrudedHeight: modelPosition[2] + 0.05,
            material: Cesium.Color.fromCssColorString('#eee'),
        },
    });

image.png

让旗子飘动起来

请注意,下文中所有着色器的坐标控制都是基于模型自身的局部坐标系。如果使用不同的模型,可能需要根据模型的具体坐标系统调整相关参数。

const customShader = new Cesium.CustomShader({
    uniforms: {
        // 自增变量,让动画持续执行
        u_time: {
            type: Cesium.UniformType.FLOAT,
            value: 0.0
        },
        u_texture: {
            type: Cesium.UniformType.SAMPLER_2D,
            value: new Cesium.TextureUniform({
                url: "../source/models/旗子/hongqi.jpg",
            })
        }
    },
    varyings: {
        // 旗子的片元偏移量需要传递给片元着色器计算阴影,因此定义为varying变量
        v_offset: Cesium.VaryingType.FLOAT
    },
    vertexShaderText: `
        void vertexMain(VertexInput vsInput, inout czm_modelVertexOutput vsOutput) {
            // 根据模型uv坐标的x和y坐标来确定摆动的频率(对应sin曲线的波频)
            // 这里控制波频时,分别用到了x和y轴坐标,并让x坐标权重大于y坐标,使得摆动更加自然
            // 最后乘以0.13是为了控制摆动的幅度(对应sin曲线的波高)
            float offset = sin(vsInput.attributes.texCoord_0.x * 8.0 +  vsInput.attributes.texCoord_0.y * 1.5 - u_time) * 0.13;
            v_offset = offset - offset * smoothstep(0.4, 0.0,  vsInput.attributes.texCoord_0.x);
            // 为片元赋予新的x坐标,新的x坐标为原始x坐标加上摆动偏移量
            vsOutput.positionMC.x += vsOutput.positionMC.x + v_offset;
            // 因为旗子在x方向上有前后摆动,因此在视觉上z轴应当适当缩短一些
            vsOutput.positionMC.z *= 0.95;
        }`,
    fragmentShaderText: `
        void fragmentMain(FragmentInput fsInput, inout czm_modelMaterial material) {
                
        }`
})
flag.model.customShader = customShader;
viewer.clock.onTick.addEventListener(function () {
    // 使动画持续进行
    customShader.uniforms.u_time.value += 0.1;
});

rpwhz-mq5ij.gif

从上图可以看出,虽然旗子已经实现了摆动效果,但与预期不符:靠近旗杆的一侧本应保持不动。接下来进一步优化顶点着色器代码。

void vertexMain(VertexInput vsInput, inout czm_modelVertexOutput vsOutput) {
    float offset = sin(vsInput.attributes.texCoord_0.x * 8.0 +  vsInput.attributes.texCoord_0.y * 1.5 - u_time) * 0.13;
    // 这是关键的一步,使用平滑阶梯函数smoothstep函数来控制摆动的范围
    // smoothstep(0.4, 0.0,  vsInput.attributes.texCoord_0.x)表达式在uv的x轴坐标靠近起点时返回1,到达x轴的0.4时返回0
    // 再用offset减去offset乘以smoothstep函数的结果,就可以得到在x轴坐标靠近0时,offset值为0的效果,往x轴的0.4靠近时再渐渐回到完全的摆动
    // 关于smoothstep函数的更多信息,请参考https://zhuanlan.zhihu.com/p/157758600
    v_offset = offset - offset * smoothstep(0.4, 0.0,  vsInput.attributes.texCoord_0.x);
    vsOutput.positionMC.x += vsOutput.positionMC.x + v_offset;
    vsOutput.positionMC.z *= 0.95;
}

此时smoothstep函数已经把旗杆一侧固定住了。

s6cp1-0sa4x.gif

为旗子添加贴图和阴影

void fragmentMain(FragmentInput fsInput, inout czm_modelMaterial material) {
    // 这一步是为了uv贴图的正确映射
    fsInput.attributes.texCoord_0.y *= -1.0;
    // (1.0 - v_offset * 3.0)表达式用来决定片元的亮度,往x轴的正方向偏移越大,这个表达式输出的值越小,则越暗
    // v_offset * 3.0是为了放大偏移量从而增大明暗对比
    // 贴图颜色乘以亮度系数就是最终色(这里控制亮度系数的范围在0-1之间,不去增加凸起部分的亮度会更逼真一些)
    vec4 color = texture(u_texture,fsInput.attributes.texCoord_0) * min((1.0 - v_offset * 2.0),1.0);
    material.diffuse=vec3(color.rgb);     
}

83e16bd946d643dc8374d800d678666e.png

上图可以看出,旗子凹陷部分已经有了阴影,但是此时阴影和片元的偏移程度为线性关系,阴影处的对比不够强烈,下面分享另一种阴影算法。

优化阴影

接下来通过实现阴影随片元偏移量的指数级增长来增强阴影部分的对比度,使效果更加逼真。

void fragmentMain(FragmentInput fsInput, inout czm_modelMaterial material) {
    // 计算片元偏移量的平方用于后续的阴影计算
    float offsetSquared = v_offset * v_offset;
    // 获取片元偏移量的符号,用于在后续的计算中保留偏移量的正负性
    float offsetSign = v_offset >= 0.0 ? 1.0 : -1.0;
    fsInput.attributes.texCoord_0.y *= -1.0;
    // 1.0 - offsetSquared * offsetSign * 30.0表达式用来决定片元的亮度,原理与上面的着色器一致
    // 不过此时片元的亮度与偏移量为指数级增长关系,会在阴影区域获得更加大的反差,增强逼真度
    vec4 color = texture(u_texture, fsInput.attributes.texCoord_0) * min(1.0 - offsetSquared * offsetSign * 30.0, 1.0);
    material.diffuse=vec3(color.rgb);
}

比刚才逼真多了

8f311d85487d462fa5cac31a6f71550e.png

smr4d-ls7t2.gif

完整的CustomShader代码

const customShader = new Cesium.CustomShader({
    uniforms: {
        u_time: {
            type: Cesium.UniformType.FLOAT,
            value: 0.0
        },
        u_texture: {
            type: Cesium.UniformType.SAMPLER_2D,
            value: new Cesium.TextureUniform({
                url: "../source/models/旗子/hongqi.jpg",
            })
        }
    },
    varyings: {
        v_offset: Cesium.VaryingType.FLOAT
    },
    vertexShaderText: `
            void vertexMain(VertexInput vsInput, inout czm_modelVertexOutput vsOutput) {
                // 根据模型uv坐标的x和y坐标来确定摆动的频率(对应sin曲线的波频)
                // 这里控制波频时,分别用到了x和y轴坐标,并让x坐标权重大于y坐标,使得摆动更加自然
                // 最后乘以0.13是为了控制摆动的幅度(对应sin曲线的波高)
                float offset = sin(vsInput.attributes.texCoord_0.x * 8.0 +  vsInput.attributes.texCoord_0.y * 1.5 - u_time) * 0.13;
                // 这是关键的一步,使用平滑阶梯函数smoothstep函数来控制摆动的范围
                // smoothstep(0.4, 0.0,  vsInput.attributes.texCoord_0.x)表达式在uv的x轴坐标靠近起点时返回1,到达x轴的0.4时返回0
                // 再用offset减去offset乘以smoothstep函数的结果,就可以得到在x轴坐标靠近0时,offset值为0的效果
                // 关于smoothstep函数的更多信息,请参考https://zhuanlan.zhihu.com/p/157758600
                v_offset = offset - offset * smoothstep(0.4, 0.0,  vsInput.attributes.texCoord_0.x);
                // 为片元赋予新的x坐标,新的x坐标为原始x坐标加上摆动偏移量
                vsOutput.positionMC.x += vsOutput.positionMC.x + v_offset;
                // 因为旗子在x方向上有前后摆动,因此在视觉上z轴应当适当缩短一些
                vsOutput.positionMC.z *= 0.95;
            }`,
    fragmentShaderText: `
            void fragmentMain(FragmentInput fsInput, inout czm_modelMaterial material) {
                // 计算片元偏移量的平方用于后续的阴影计算
                float offsetSquared = v_offset * v_offset;
                // 获取片元偏移量的符号,用于在后续的计算中保留偏移量的正负性
                float offsetSign = v_offset >= 0.0 ? 1.0 : -1.0;
                fsInput.attributes.texCoord_0.y *= -1.0;
                // 1.0 - offsetSquared * offsetSign * 30.0表达式用来决定片元的亮度,原理与上面的着色器一致
                // 不过此时片元的亮度与偏移量为指数级增长关系,会在阴影区域获得更加大的反差,增强逼真度
                vec4 color = texture(u_texture, fsInput.attributes.texCoord_0) * min(1.0 - offsetSquared * offsetSign * 30.0, 1.0);
                material.diffuse=vec3(color.rgb);
            }`
})

总结

  1. 摆动核心
    用三角函数把 UV 坐标映射成随时间变化的偏移量,再用 smoothstep 把旗杆侧固定,就能让红旗只在自由端飘动。

  2. 阴影核心
    把顶点着色器算出的偏移量 v_offset 传进片元着色器,用“1 − 偏移量² × 符号 × 放大系数”做指数级压暗,褶皱阴影逼真立体。

  3. 扩展和思考

    • 如何使用噪声实现?
    • 如何为摆动的增加随机性?

祝玩旗愉快!

赛百味上海餐饮管理公司增资至3.8亿,增幅约21%

2025年11月18日 14:47
36氪获悉,爱企查App显示,近日,赛百味餐饮管理(上海)有限公司发生工商变更,注册资本由约3.2亿人民币增至约3.8亿人民币,增幅约21%。赛百味餐饮管理(上海)有限公司成立于2014年4月,法定代表人为朱付强,经营范围包括餐饮管理、工艺美术品及礼仪用品销售、玩具销售等。股东信息显示,该公司由上海富瑞食企业发展有限公司全资持股。

“材科源图”获数千万元天使轮融资

2025年11月18日 14:44
36氪获悉,“材科源图”近日宣布完成数千万元天使轮融资。本轮融资由中科创星领投,元禾控股与亿合资本共同参与。融资将重点用于核心技术研发:数据库扩建、模型开发、高通量平台搭建,及应用场景商业化落地。

wangEditor5在vue中自定义菜单栏--格式刷,上传图片,视频功能

2025年11月18日 14:43

一、安装相关插件

npm install @wangeditor/editor
npm install @wangeditor/editor-for-vue

二、官方关键文档

  1. ButtonMenu:www.wangeditor.com/v5/developm…
  2. 注册菜单到wangEditor:自定义扩展新功能 | wangEditor
  3. insertKeys自定义功能的keys:www.wangeditor.com/v5/toolbar-…
  4. 自定义上传图片视频功能:菜单配置 | wangEditor
  5. 源码地址:GitHub - wangeditor-team/wangEditor: wangEditor —— 开源 Web 富文本编辑器

三、初始化编辑器(wangEdit.vue) 

<template>
  <div style="border: 1px solid #ccc">
    <Toolbar
      style="border-bottom: 1px solid #ccc"
      :editor="editor"
      :defaultConfig="toolbarConfig"
      :mode="mode"
    />
    <Editor
      style="height: 500px; overflow-y: hidden"
      v-model="html"
      :defaultConfig="editorConfig"
      :mode="mode"
      @onCreated="onCreated"
      @onChange="onChange"
    />
  </div>
</template>

<script>
// import Location from "@/utils/location";
import { Editor, Toolbar } from "@wangeditor/editor-for-vue";
import { Boot, IModuleConf, DomEditor } from "@wangeditor/editor";
import { getToken } from "@/utils/auth";
import MyPainter from "./geshi";
const menu1Conf = {
  key: "geshi", // 定义 menu key :要保证唯一、不重复(重要)
  factory() {
    return new MyPainter(); // 把 `YourMenuClass` 替换为你菜单的 class
  },
};
const module = {
  // JS 语法
  menus: [menu1Conf],

  // 其他功能,下文讲解...
};
Boot.registerModule(module);
export default {
  components: { Editor, Toolbar },
  props: {
    relationKey: {
      type: String,
      default: "",
    },
  },
  created() {
    console.log(this.editorConfig.MENU_CONF.uploadImage.meta.activityKey);
  },
  data() {
    return {
      // 富文本实例
      editor: null,
      // 富文本正文内容
      html: "",
      // 编辑器模式
      mode: "default", // or 'simple'
      // 工具栏配置
      toolbarConfig: {
        //新增菜单
        insertKeys: {
          index: 32,
          keys: ["geshi"],
        },
        //去掉网络上传图片和视频
        excludeKeys: ["insertImage", "insertVideo"],
      },
      // 编辑栏配置
      editorConfig: {
        placeholder: "请输入相关内容......",
        // 菜单配置
        MENU_CONF: {
          // ===================
          // 上传图片配置
          // ===================
          uploadImage: {
            // 文件名称
            fieldName: "contentAttachImage",
            server: Location.serverPath + "/editor-upload/upload-image",
            headers: {
              Authorization: "Bearer " + getToken(),
            },
            meta: {
              activityKey: this.relationKey,
            },
            // 单个文件的最大体积限制,默认为 20M
            maxFileSize: 20 * 1024 * 1024,
            // 最多可上传几个文件,默认为 100
            maxNumberOfFiles: 10,
            // 选择文件时的类型限制,默认为 ['image/*'] 。如不想限制,则设置为 []
            allowedFileTypes: ["image/*"],
            // 跨域是否传递 cookie ,默认为 false
            withCredentials: true,
            // 超时时间,默认为 10 秒
            timeout: 5 * 1000,
            // 自定义插入图片操作
            customInsert: (res, insertFn) => {
              if (res.errno == -1) {
                this.$message.error("上传失败!");
                return;
              }
              insertFn(Location.serverPath + res.data.url, "", "");
              this.$message.success("上传成功!");
            },
          },
          // =====================
          // 上传视频配置
          // =====================
          uploadVideo: {
            // 文件名称
            fieldName: "contentAttachVideo",
            server: Location.serverPath + "/editor-upload/upload-video",
            headers: {
              Authorization: "Bearer " + getToken(),
            },
            meta: {
              activityKey: this.relationKey,
            },
            // 单个文件的最大体积限制,默认为 60M
            maxFileSize: 60 * 1024 * 1024,
            // 最多可上传几个文件,默认为 100
            maxNumberOfFiles: 3,
            // 选择文件时的类型限制,默认为 ['video/*'] 。如不想限制,则设置为 []
            allowedFileTypes: ["video/*"],
            // 跨域是否传递 cookie ,默认为 false
            withCredentials: true,
            // 超时时间,默认为 10 秒
            timeout: 15 * 1000,
            // 自定义插入图片操作
            customInsert: (res, insertFn) => {
              if (res.errno == -1) {
                this.$message.error("上传失败!");
                return;
              }
              insertFn(Location.serverPath + res.data.url, "", "");
              this.$message.success("上传成功!");
            },
          },
        },
      },

      // ===== data field end =====
    };
  },
  methods: {
    // =============== Editor 事件相关 ================
    // 1. 创建 Editor 实例对象
    onCreated(editor) {
      this.editor = Object.seal(editor); // 一定要用 Object.seal() ,否则会报错
      this.$nextTick(() => {
        const toolbar = DomEditor.getToolbar(this.editor);
        const curToolbarConfig = toolbar.getConfig();
        console.log("【 curToolbarConfig 】-39", curToolbarConfig);
      });
    },
    // 2. 失去焦点事件
    onChange(editor) {
      this.$emit("change", this.html);
    },

    // =============== Editor操作API相关 ==============
    insertText(insertContent) {
      const editor = this.editor; // 获取 editor 实例
      if (editor == null) {
        return;
      }
      // 执行Editor的API插入
      editor.insertText(insertContent);
    },

    // =============== 组件交互相关 ==================
    // closeEditorBeforeComponent() {
    //   this.$emit("returnEditorContent", this.html);
    // },
    closeContent(){
        this.html=''
    },
    // ========== methods end ===============
  },
  mounted() {
    // ========== mounted end ===============
  },
  beforeDestroy() {
    const editor = this.editor;
    if (editor == null) {
      return;
    }
    editor.destroy();
    console.log("销毁编辑器!");
  },
};
</script>
<style lang="scss" scoped>
// 对默认的p标签进行穿透
::v-deep .editorStyle .w-e-text-container [data-slate-editor] p  {
  margin: 0 !important;
}
</style>
<style src="@wangeditor/editor/dist/css/style.css"></style>
自定义上传图片接口
 uploadImage: {
                        // 文件名称
                        fieldName: "contentAttachImage",
                        // server: '/api/v1/public/uploadFile',
                        headers: {
                            Authorization: "Bearer " + getToken(),
                        },
                        meta: {
                            activityKey: this.relationKey,
                        },
                        // 单个文件的最大体积限制,默认为 20M
                        maxFileSize: 20 * 1024 * 1024,
                        // 最多可上传几个文件,默认为 100
                        maxNumberOfFiles: 10,
                        // 选择文件时的类型限制,默认为 ['image/*'] 。如不想限制,则设置为 []
                        allowedFileTypes: ["image/*"],
                        // 跨域是否传递 cookie ,默认为 false
                        withCredentials: true,
                        // 超时时间,默认为 10 秒
                        timeout: 5 * 1000,
                         这里设置
                        customUpload: async (file, insertFn) => {
                            console.log(file, "file");
                            let formData = new FormData()
                            const sub = "order";
                            formData.append('file', file)
                            formData.append("sub", sub);
                            formData.append("type", "1");
                            let res = await getUploadImg(formData)
                            insertFn(res.data.full_path, '', '');
                        },
                        customInsert: (res, insertFn) => {
                            if (res.errno == -1) {
                                this.$message.error("上传失败!");
                                return;
                            }
                            // insertFn(res.data.url, "", "");
                            this.$message.success("上传成功!");
                        },
                    },

四、格式刷功能类js文件

import {
  SlateEditor,
  SlateText,
  SlateElement,
  SlateTransforms,
  DomEditor,
  //   Boot,
} from "@wangeditor/editor";
// Boot.registerMenu(menu1Conf);
import { Editor } from "slate";
export default class MyPainter {
  constructor() {
    this.title = "格式刷"; // 自定义菜单标题
    // 这里是设置格式刷的样式图片跟svg都可以,但是注意要图片大小要小一点,因为要应用到鼠标手势上
    this.iconSvg = ``;
    this.tag = "button"; //注入的菜单类型
    this.savedMarks = null; //保存的样式
    this.domId = null; //这个可要可不要
    this.editor = null; //编辑器示例
    this.parentStyle = null; //储存父节点样式
    this.mark = "";
    this.marksNeedToRemove = []; // 增加 mark 的同时,需要移除哪些 mark (互斥,不能共存的)
  }
  clickHandler(e) {
    console.log(e, "e"); //无效
  }
  //添加或者移除鼠标事件
  addorRemove = (type) => {
    const dom = document.body;
    if (type === "add") {
      dom.addEventListener("mousedown", this.changeMouseDown);
      dom.addEventListener("mouseup", this.changeMouseup);
    } else {
      //赋值完需要做的清理工作
      this.savedMarks = undefined;
      dom.removeEventListener("mousedown", this.changeMouseDown);
      dom.removeEventListener("mouseup", this.changeMouseup);
      document.querySelector("#w-e-textarea-1").style.cursor = "auto";
    }
  };

  //处理重复键名值不同的情况
  handlerRepeatandNotStyle = (styles) => {
    const addStyles = styles[0];
    const notVal = [];
    for (const style of styles) {
      for (const key in style) {
        const value = style[key];
        if (!addStyles.hasOwnProperty(key)) {
          addStyles[key] = value;
        } else {
          if (addStyles[key] !== value) {
            notVal.push(key);
          }
        }
      }
    }
    for (const key of notVal) {
      delete addStyles[key];
    }
    return addStyles;
  };

  // 获取当前选中范围的父级节点
  getSelectionParentEle = (type, func) => {
    if (this.editor) {
      const parentEntry = SlateEditor.nodes(this.editor, {
        match: (node) => SlateElement.isElement(node),
      });
      let styles = [];
      for (const [node] of parentEntry) {
        styles.push(this.editor.toDOMNode(node).style); //将node对应的DOM对应的style对象加入到数组
      }
      styles = styles.map((item) => {
        //处理不为空的style
        const newItem = {};
        for (const key in item) {
          const val = item[key];
          if (val !== "") {
            newItem[key] = val;
          }
        }
        return newItem;
      });
      type === "get"
        ? func(type, this.handlerRepeatandNotStyle(styles))
        : func(type);
    }
  };

  //获取或者设置父级样式
  getorSetparentStyle = (type, style) => {
    if (type === "get") {
      this.parentStyle = style; //这里是个样式对象 例如{textAlign:'center'}
    } else {
      SlateTransforms.setNodes(
        this.editor,
        { ...this.parentStyle },
        {
          mode: "highest", // 针对最高层级的节点
        }
      );
    }
  };

  //这里是将svg转换为Base64格式
  addmouseStyle = () => {
    const icon = ``; // 这里是给鼠标手势添加图标
    // 将字符串编码为Base64格式
    const base64String = btoa(icon);
    // 生成数据URI
    const dataUri = `data:image/svg+xml;base64,${base64String}`;
    // 将数据URI应用于鼠标图标
    document.querySelector(
      "#w-e-textarea-1"
    ).style.cursor = `url('${dataUri}'), auto`;
  };
  changeMouseDown = () => {}; //鼠标落下

  changeMouseup = () => {
    //鼠标抬起
    if (this.editor) {
      const editor = this.editor;
      const selectTxt = editor.getSelectionText(); //获取文本是否为null
      if (this.savedMarks && selectTxt) {
        //先改变父节点样式
        this.getSelectionParentEle("set", this.getorSetparentStyle);
        // 获取所有 text node
        const nodeEntries = SlateEditor.nodes(editor, {
          //nodeEntries返回的是一个迭代器对象
          match: (n) => SlateText.isText(n), //这里是筛选一个节点是否是 text
          universal: true, //当universal为 true 时,Editor.nodes会遍历整个文档,包括根节点和所有子节点,以匹配满足条件的节点。当universal为 false 时,Editor.nodes只会在当前节点的直接子节点中进行匹配。
        });
        // 先清除选中节点的样式
        for (const node of nodeEntries) {
          const n = node[0]; //{text:xxxx}
          const keys = Object.keys(n);
          keys.forEach((key) => {
            if (key === "text") {
              // 保留 text 属性
              return;
            }
            // 其他属性,全部清除
            SlateEditor.removeMark(editor, key);
          });
        }
        // 再赋值新样式
        for (const key in this.savedMarks) {
          if (Object.hasOwnProperty.call(this.savedMarks, key)) {
            const value = this.savedMarks[key];
            editor.addMark(key, value);
          }
        }
        this.addorRemove("remove");
      }
    }
  };

  getValue(editor) {
    // return "MyPainter"; // 标识格式刷菜单
    const mark = this.mark;
    console.log(mark, "mark");
    const curMarks = Editor.marks(editor);
    // 当 curMarks 存在时,说明用户手动设置,以 curMarks 为准
    if (curMarks) {
      return curMarks[mark];
    } else {
      const [match] = Editor.nodes(editor, {
        // @ts-ignore
        match: (n) => n[mark] === true,
      });
      return !!match;
    }
  }

  isActive(editor, val) {
    const isMark = this.getValue(editor);
    return !!isMark;
    //  return !!DomEditor.getSelectedNodeByType(editor, "geshi");
    // return false;
  }

  isDisabled(editor) {
    //是否禁用
    return false;
  }
  exec(editor, value) {
    //当菜单点击后触发
    // console.log(!this.isActive());
    console.log(value, "value");
    this.editor = editor;
    this.domId = editor.id.split("-")[1]
      ? `w-e-textarea-${editor.id.split("-")[1]}`
      : undefined;
    if (this.isDisabled(editor)) return;
    const { mark, marksNeedToRemove } = this;
    if (value) {
      // 已,则取消
      editor.removeMark(mark);
    } else {
      // 没有,则执行
      editor.addMark(mark, true);
      this.savedMarks = SlateEditor.marks(editor); // 获取当前选中文本的样式
      this.getSelectionParentEle("get", this.getorSetparentStyle); //获取父节点样式并赋值
    //   this.addmouseStyle(); //点击之后给鼠标添加样式
      this.addorRemove("add"); //处理添加和移除事件函数
      // 移除互斥、不能共存的 marks
      if (marksNeedToRemove) {
        marksNeedToRemove.forEach((m) => editor.removeMark(m));
      }
    }
    if (
      editor.isEmpty() ||
      editor.getHtml() == "<p><br></p>" ||
      editor.getSelectionText() == ""
    )
      return; //这里是对没有选中或者没内容做的处理
  }
}

五、页面应用组件

 <el-form-item label="内容">
 <WangEdit v-model="form.content" ref="wangEdit"  @change="change"></WangEdit>
  </el-form-item>


// js
const WangEdit = () => import("@/views/compoments/WangEdit.vue");
export default {
  name: "Notice",
  components: {
    WangEdit,
  },
    data(){
    return{
          form:{
         }
    }
    },

 methods: {
     change(val) {
            console.log(val,'aa');
            this.form.content=val
        },
     // 取消按钮
    cancel() {
      this.open = false;
      this.form={};
      this.$refs.wangEdit.closeContent();
    },
}

转载:wangEditor5在vue中自定义菜单栏--格式刷,上传图片,视频功能_vue.js_liang04273-Vue

Apple同款SVG,怎么写出来?手写+编辑器,两张方法都能搞定!

2025年11月18日 14:40

编辑

有时候刷到那种很克制、很高级的轮播——没有夸张的左右整屏滑动,只是几张卡片在视野里轻轻错位一下,你大脑里就会自动补一句:

嗯,这味儿有点 Apple。

这类效果不一定要上 JS / CSS 大动干戈,很多公众号里的“高级轮播”,其实就是一段 纯 SVG + SMIL 动画。下面我们就拆一段实际在用的 4 图轮播代码,看它是怎么靠几行<animateTransform> 做出这种“款款而来”的感觉的。
*本文代码借鉴学习了一款现成的SVG编辑器 -  E2 编辑器做练习,只是做一下学习分享。

下文的核心结构来自这样一段代码(只保留和动效相关的部分):

<svg viewBox="0 0 0 0" width="100%">
  <g>
    <foreignObject x="0" y="0" width="0" height="0">
      <svg
        style='display:block; background-image:url(""); background-size:100% auto;'
        viewBox="0 0 0 0" width="100%">
      </svg>
    </foreignObject>
  </g>

  <g>
    <foreignObject ...> ... </foreignObject>
    <animateTransform
      attributeName="transform"
      type="translate"
      repeatCount="indefinite"
      values="0 0; 0 0; 0 0; 0 0; 0 0; 0 0; 0 0; -80 0; 0 0;"
      begin="0.3s"
      dur="4s"
      calcMode="spline"
      keySplines="0.42 0 0.58 1.0; ...">
    </animateTransform>
  </g>

  <!-- 另外 3 个 <g>,结构类似,只是 values 不同 -->
</svg>

一、整体结构:一个 <svg> + 多个 <g> = 多个 slide

这段代码的结构非常简单粗暴:

  • 最外层是一个 <svg>width="100%",跟着容器自动铺满;
  • 里面有 多个 <g> 分组,每个分组对应一张“幻灯片”;
  • 每个分组里都有一个 <foreignObject>,里面再包一段 <svg>,这段内嵌 svg 只干一件事:
    用 background-image 把真正的图片铺上去。

换句话说,这个轮播:

  • 没有 <image> 标签;
  • 没有 <img> 标签;
  • 就是把 图片当作 CSS 背景,挂在一个 svg 里。

这种写法在公众号场景里有个好处:

  • 版式层是 SVG 控制的(可以做动效);
  • 图像展示层直接走 CSS 背景(好替换、好裁剪)。

你要换自己的图,只需要改这一段的 URL:

<svg
  style='display:block; 
         background-image:url("https://你的图片地址.jpg");
         background-size:100% auto;
         background-repeat:no-repeat;'>
</svg>

二、轮播核心:用 SMIL 的 <animateTransform> 做平移

真正让这几个 slide “活起来”的,就是每个 <g> 下面那条 animateTransform

典型一条长这样:

<animateTransform
  attributeName="transform"
  type="translate"
  repeatCount="indefinite"
  values="0 0; 0 0; 0 0; 0 0; 0 0; 0 0; 0 0; -80 0; 0 0;"
  begin="0.3s"
  dur="4s"
  calcMode="spline"
  keySplines="
    0.42 0 0.58 1.0;
    0.42 0 0.58 1.0;
    0.42 0 0.58 1.0;
    0.42 0 0.58 1.0;
    0.42 0 0.58 1.0;
    0.42 0 0.58 1.0;
    0.42 0 0.58 1.0;
    0.42 0 0.58 1.0">
</animateTransform>

逐项看:

  • attributeName="transform"
    意味着这条动画会直接改 <g> 的 transform 属性;

  • type="translate"
    指定 transform 的类型是平移,相当于在做 translate(x, y)

  • repeatCount="indefinite"
    无限循环播放,保证轮播不停止;

  • dur="4s"
    一轮动画总时长 4 秒;

  • begin="0.3s"
    整体开始时间延迟了 0.3 秒,避免页面一加载上来就立刻抖动;

  • values="..."
    这是整段的关键帧列表,每一对数字代表一个平移坐标:

    • 0 0:停在初始位置;
    • -80 0:往左移动 80 个单位;
    • 接着再回到 0 0

values 里之所以有一长串 0 0,其实是在拉长“静止时间” ,让卡片绝大部分时间都稳稳地停在中间,只在时间轴尾巴那一段轻轻侧移一下再回来。

这就是“优雅”的第一层来源:
不是一直在明显地滑,而是大部分时间在静止、小部分时间在微动


三、动起来为什么看着“高级”?关键在于缓动 + 细小位移

1. 微小位移:只移动 80,而不是整屏 100%

可以对比一下常见轮播:

  • 普通“整屏轮播”:一张图从 0% 滑到 -100%,下一张从 100% 滑到 0
  • 这段 SVG 轮播:只平移 -80 或 +80 的距离。

也就是说,它并不是在做「换图」,而是在做「轻微错位」:

  • 用户视角里,一直是同一张图为主;
  • 偶尔轻轻滑动一点点,有一种“轻微呼吸”的感觉;
  • 视觉上更接近 Apple 官网那些“轻轻抖一抖”的动效,而不是 APP 里的 banner 跑马灯。

2. 贝塞尔缓动:keySplines="0.42 0 0.58 1.0"

calcMode="spline" + keySplines="0.42 0 0.58 1.0" 这对组合,其实就是 SVG 版本的 CSS cubic-bezier(0.42, 0, 0.58, 1),也就是非常经典的 ease-in-out

效果是:

  • 一开始慢慢起步(不是生硬地动);
  • 中间加速一点;
  • 结束前再慢慢刹车。

配合上前面说的“位移量不大”,就会得到一个很克制的动效——有存在感,但不抢内容风头

3. 多个 slide 共用同一时间轴

注意这 4 个 <g>

  • 都是 dur="4s" begin="0.3s" repeatCount="indefinite"
  • 唯一的区别是:每个 <g> 的 values 序列不一样。

换句话说:

  • 它们跑在同一条时间线上;
  • 但每一张图的“位移节奏”略有差异;
  • 最终呈现出来就是一个整体“呼吸”的卡片群,而不是单张 slide 独立乱飞。

这点非常像 Apple 官网那种「一组卡片跟着一个节奏,轮流被稍稍突出」的感觉。


四、想改成自己的轮播?几个实用的改法

如果你想在这个基础上做个自己的「苹果味轮播」,主要可以改 4 个方面:

1. 换成你自己的图片

找到每个 foreignObject 里的这段:

<svg
  style='display:block;
         background-image:url("https://你的图.jpg");
         background-size:100% auto;
         background-repeat:no-repeat;'>
</svg>

把 background-image 换成你的图地址就行。

想更“苹果一点”,可以准备几张同一系列、同一光感的产品图。

2. 调整位移幅度

所有轮播的空间错位,靠的就是 values 里的那几个 -80 0 / 80 0

  • 想动得更明显一点:可以改成 -160 0 / 160 0
  • 想更微妙一点:可以改成 -40 0 / 40 0

比如:

values="0 0; 0 0; 0 0; -40 0; 0 0; 0 0; 0 0; 0 0; 0 0"

3. 调整节奏和停留时间

几种常见玩法:

  • 整体放慢dur="4s" 改成 6s 或 8s

  • 让“静止”的比例更大:多复制几段 0 0

  • 只在最后 10% 时间里移动
    配合 keyTimes(如果你愿意加上一行):

    • 前 90% 的 keyTime 都是 0 0,最后 10% 才从 0 0 → -80 0 → 0 0

示意写法(扩展版):

<animateTransform
  attributeName="transform"
  type="translate"
  dur="8s"
  repeatCount="indefinite"
  values="0 0; 0 0; -80 0; 0 0"
  keyTimes="0;0.8;0.9;1"
  calcMode="spline"
  keySplines="
    0.42 0 0.58 1;
    0.42 0 0.58 1;
    0.42 0 0.58 1" />

4. 增加 / 减少图片数量

这个轮播没有“总数配置”,每张图就是一个 <g> ,所以:

  • 想多几张图:

    • 复制一份 <g> ... </g>
    • 按照原来的 pattern 写一条新的 values
  • 想只留 3 张图:

    • 删除其中一个 <g> 即可。

只要你保证:

  • 所有 <g> 的 dur 和 begin 一致;
  • values 之间有一点节奏差异(不要全部都一模一样);

整体看上去就还是那种「有秩序的小范围错位」。


五、小结

这个 4 图轮播本质上做了几件事:

  1. 用 SVG + foreignObject 搭了个“动效容器” ,图片当背景;
  2. 用 <animateTransform> + translate 做微位移,一次只挪几十个单位;
  3. 用 cubic-bezier 式的缓动曲线压住“廉价感” ,慢进慢出;
  4. 让所有 slide 套在同一时间轴上,做成整体的“呼吸”,而不是普通 banner 般机械的滑动。

看起来是“苹果味”的轮播,拆开就是一堆很朴素的 SVG 技巧。


最后一句:如果你懒得手写…

如果你不想自己去算这些 valueskeySplines 和 <g> 结构,也可以直接在 E2 编辑器里搜一下,组件名称叫「款款而来」,组件本身是免费使用的,用现成组件然后再对导出的 SVG 做二次改造,会轻松不少。

使用 E2 SVG 编辑器 — 基于 SVG 的“黑科技互动”可视化设计平台,上线1900+原创组件,支持积木式拖拽、组件复用与热区触发,已被 2 万+品牌应用于公众号、微博、活动专题制作,快速打造高互动图文、提升用户停留与完读率。

image.png 上传四张尺寸相同的图片,就可以直接预览效果,还可以点击获取代码后,在其他软件里进行二次编辑。还可以调整速度参数,如果购买了企业会员的话。

西贝回应门店一线全员涨薪

2025年11月18日 14:35
有媒体报道称,受“西贝罗永浩风波”相关负面舆论影响,部分西贝门店一线员工遭遇“网暴”,一些门店甚至每天接到十几通辱骂电话,西贝为员工设立“委屈奖”以表安慰。对此,西贝公关部回应称:“风波发生后,西贝对一线伙伴从9月起平均涨薪500元/人/月,对风波期间遭受网暴和极端辱骂的伙伴给予委屈奖补贴,并邀请心理咨询师对部分门店伙伴进行辅导跟踪。(21财经)

济州航空:今年前10个月中国航线旅客数已超去年全年

2025年11月18日 14:28
11月18日,韩国济州航空表示,公司中国航线今年1至10月共输送旅客49.5万余人次,较去年同期(44.78万人次)增长10%以上,已超过去年全年水平(49.29万余人次)。其中,第三季度济州航空的中国航线客运量为18.26万余人次,同比约增28%。济州航空方面表示,韩中旅游需求有望持续增加,公司将致力于拓展中国航线网络。(界面)
❌
❌